Beyond Fixed Embeddings: Mastering Inductive Learning with GraphSAGE
Beyond Fixed Embeddings: Mastering Inductive Learning with GraphSAGE
In the early days of Graph Neural Networks (GNNs), we were largely limited to transductive learning. If you wanted to generate an embedding for a new node, you typically had to re-train the entire model or at least re-run the embedding process on the entire graph. In a production environment—where users join a social network or new papers are added to a citation index every second—this is computationally impossible.
Enter GraphSAGE (SAmple and aggreGatE).
GraphSAGE shifts the paradigm from learning fixed embeddings for specific nodes to learning a generalizable function. Instead of a lookup table, it treats node embedding as a feature-aggregation problem.
The Core Intuition: From Lookup Tables to Functions
Most traditional graph embedding methods (like Node2Vec) learn a unique vector for every single node. If your graph has 1 million nodes, you have 1 million vectors. If node $1,000,001$ arrives, the model has no idea what to do with it.
GraphSAGE solves this by learning an aggregator.
Instead of asking "What is the embedding for Node A?", GraphSAGE asks, "Given the features of Node A and the features of its neighbors, how do I compute an embedding?" Because the model learns the process of aggregation rather than the result, it can generate embeddings for nodes it has never seen during training, provided they have features and local connectivity.
The High-Level Workflow
The Mathematical Blueprint
GraphSAGE operates through an iterative process of sampling and aggregating. Here is the breakdown of the mathematics powering the architecture.
1. Neighborhood Aggregation
For a node $v$, the model first aggregates the representations of its neighbors $N(v)$ from the previous layer $k-1$:
$$\mathbf{h}_{N(v)}^k = \text{AGGREGATE}_k ({ \mathbf{h}_u^{k-1}, \forall u \in N(v) })$$
2. Concatenation and Update
The model then combines the node's own current representation with the aggregated neighborhood vector. This ensures the node doesn't "forget" its own identity while absorbing context:
$$\mathbf{h}_v^k = \sigma (\mathbf{W}^k [\mathbf{h}v^{k-1} \parallel \mathbf{h}{N(v)}^k])$$
Where:
- $\parallel$ denotes the concatenation operation.
- $\mathbf{W}^k$ is a learnable weight matrix.
- $\sigma$ is a non-linear activation function (like ReLU).
3. Final Embedding
After $K$ iterations (layers), the final representation $\mathbf{z}_v$ is produced: $$\mathbf{z}_v \equiv \mathbf{h}_v^K$$
Production Implementation in PyTorch
Below is a complete, modular implementation of GraphSAGE. We implement the SAGEConv layer to handle the concatenation and aggregation logic, and a GraphSAGE wrapper for multi-layer depth.
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from sklearn.datasets import make_classification
from sklearn.metrics import f1_score, accuracy_score
class SAGEConv(nn.Module):
"""
A single GraphSAGE layer implementing:
h_v = sigma( W * concat(h_v, aggregate({h_u, for u in N(v)})) )
"""
def __init__(self, in_channels, out_channels, aggregator='mean'):
super(SAGEConv, self).__init__()
self.aggregator = aggregator
# Input dim is 2 * in_channels because we concatenate self + neighbors
self.linear = nn.Linear(2 * in_channels, out_channels)
def forward(self, x, adj):
# 1. Aggregate neighborhood features (Mean Aggregator)
deg = torch.sum(adj, dim=1, keepdim=True) + 1e-5
agg_neighbors = torch.mm(adj, x) / deg
# 2. Concatenate node's own features with aggregated neighborhood features
combined = torch.cat([x, agg_neighbors], dim=1)
# 3. Linear transformation and L2 Normalization
out = self.linear(combined)
out = F.normalize(out, p=2, dim=1)
return out
class GraphSAGE(nn.Module):
def __init__(self, in_channels, hidden_channels, out_channels, num_layers=2):
super(GraphSAGE, self).__init__()
self.layers = nn.ModuleList()
# Input layer
self.layers.append(SAGEConv(in_channels, hidden_channels))
# Hidden layers
for _ in range(num_layers - 2):
self.layers.append(SAGEConv(hidden_channels, hidden_channels))
# Output layer
self.layers.append(SAGEConv(hidden_channels, out_channels))
def forward(self, x, adj):
for i, layer in enumerate(self.layers):
x = layer(x, adj)
if i < len(self.layers) - 1:
x = F.relu(x)
return x
# --- Execution Block ---
if __name__ == '__main__':
# Synthetic Data Generation
num_nodes, feat_dim, num_classes = 1000, 16, 3
X, Y = make_classification(n_samples=num_nodes, n_features=feat_dim, n_classes=num_classes, random_state=42)
# Create random symmetric adjacency matrix
adj = np.random.erdos_renyi(p=0.01, n=num_nodes)
adj = (adj + adj.T).astype(float)
np.fill_diagonal(adj, 0)
# Inductive Split: Train on 80%, Test on 20% (unseen nodes)
indices = np.arange(num_nodes)
np.random.shuffle(indices)
train_idx, test_idx = indices[:800], indices[800:]
# Model Setup
model = GraphSAGE(in_channels=feat_dim, hidden_channels=32, out_channels=num_classes)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = nn.CrossEntropyLoss()
x_tensor, adj_tensor, y_tensor = torch.FloatTensor(X), torch.FloatTensor(adj), torch.LongTensor(Y)
# Training Loop
model.train()
for epoch in range(100):
optimizer.zero_grad()
out = model(x_tensor, adj_tensor)
loss = criterion(out[train_idx], y_tensor[train_idx])
loss.backward()
optimizer.step()
# Evaluation
model.eval()
with torch.no_grad():
logits = model(x_tensor, adj_tensor)
predictions = torch.argmax(logits, dim=1)
acc = accuracy_score(y_tensor[test_idx], predictions[test_idx])
print(f"Inductive Test Accuracy: {acc:.4f}")
Key Takeaways for Engineers
1. Transductive vs. Inductive
| Feature | Transductive (e.g., GCN, Node2Vec) | Inductive (GraphSAGE) |
|---|---|---|
| New Nodes | Requires retraining/re-running | Generates embedding on-the-fly |
| Learning Target | Node-specific embeddings | Aggregation functions |
| Scalability | Limited by graph size in memory | Scalable via neighborhood sampling |
2. Complexity and Optimization
In the provided code, we use the full adjacency matrix for simplicity. However, in production, GraphSAGE's true power comes from Neighborhood Sampling. Instead of aggregating all neighbors, you sample a fixed number (e.g., 10 neighbors for layer 1, 25 for layer 2). This keeps the memory footprint constant regardless of the node's degree.
3. Choosing an Aggregator
While we implemented the Mean aggregator, GraphSAGE supports others:
- LSTM Aggregator: Useful when the order of neighbors matters (though neighbors are usually unordered, LSTMs can act as powerful non-linear learners).
- Pooling Aggregator: Passes neighbor vectors through a fully connected layer and takes the
MAXorMEANacross the dimension, capturing the most salient features.