Classic GNN Architectures

GCN, GraphSAGE, GAT, GIN, and MPNN - the models you must understand cold. Math, intuition, code, and when to use each.

1. GCN - Graph Convolutional Network

Update rule

$$\mathbf{h}_i^{(l+1)} = \sigma\left(\sum_{j \in \mathcal{N}(i) \cup \{i\}} \frac{1}{\sqrt{d_i d_j}} \mathbf{h}_j^{(l)} \mathbf{W}^{(l)}\right)$$

Derivation intuition

GCN is a first-order approximation of spectral graph convolution (ChebNet with $K=1$). The factor $\frac{1}{\sqrt{d_i d_j}}$ prevents high-degree nodes from dominating the sum.

Intuition: Each node replaces itself with a weighted average of its neighbors' embeddings (plus itself), then applies a linear layer. Like a CNN's local filter, but on arbitrary graphs.

Pros and cons

from torch_geometric.nn import GCNConv

class GCN(torch.nn.Module):
    def __init__(self, in_dim, hidden, out_dim):
        super().__init__()
        self.conv1 = GCNConv(in_dim, hidden)
        self.conv2 = GCNConv(hidden, out_dim)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        x = F.dropout(x, 0.5, training=self.training)
        return self.conv2(x, edge_index)

2. GraphSAGE

Update rule

$$\mathbf{h}_i^{(l+1)} = \sigma\left(\mathbf{W}^{(l)} \cdot \text{CONCAT}\left(\mathbf{h}_i^{(l)}, \text{AGG}\left(\{\mathbf{h}_j^{(l)} : j \in \mathcal{N}(i)\}\right)\right)\right)$$

Aggregators

Key idea: GraphSAGE is inductive - it learns a function to generate embeddings for unseen nodes using only their features and local neighborhood. Sample a fixed number of neighbors for scalability.

When to use

Large graphs, inductive settings, production systems where new nodes arrive daily (Pinterest, Uber Eats recommendations).

from torch_geometric.nn import SAGEConv

class GraphSAGE(torch.nn.Module):
    def __init__(self, in_dim, hidden, out_dim):
        super().__init__()
        self.conv1 = SAGEConv(in_dim, hidden)
        self.conv2 = SAGEConv(hidden, out_dim)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        return self.conv2(x, edge_index)

3. GAT - Graph Attention Network

Attention coefficient

$$e_{ij} = \text{LeakyReLU}\left(\vec{a}^T [\mathbf{W}\mathbf{h}_i \| \mathbf{W}\mathbf{h}_j]\right)$$ $$\alpha_{ij} = \frac{\exp(e_{ij})}{\sum_{k \in \mathcal{N}(i)} \exp(e_{ik})}$$

Update

$$\mathbf{h}_i^{(l+1)} = \sigma\left(\sum_{j \in \mathcal{N}(i)} \alpha_{ij} \mathbf{W}\mathbf{h}_j^{(l)}\right)$$
Intuition: Not all neighbors matter equally. GAT learns attention weights - like Transformer attention but only over graph neighbors (sparse attention).

Multi-head attention

Run $K$ independent attention heads, concatenate (or average) outputs - same idea as Transformer multi-head.

When to use

When neighbors are heterogeneous in importance. Slightly more expensive than GCN but often more accurate.

from torch_geometric.nn import GATConv

class GAT(torch.nn.Module):
    def __init__(self, in_dim, hidden, out_dim, heads=4):
        super().__init__()
        self.conv1 = GATConv(in_dim, hidden, heads=heads, dropout=0.6)
        self.conv2 = GATConv(hidden * heads, out_dim, heads=1, concat=False)

    def forward(self, x, edge_index):
        x = F.elu(self.conv1(x, edge_index))
        return self.conv2(x, edge_index)

4. GIN - Graph Isomorphism Network

Update rule

$$\mathbf{h}_i^{(l+1)} = \text{MLP}^{(l)}\left((1 + \epsilon^{(l)}) \cdot \mathbf{h}_i^{(l)} + \sum_{j \in \mathcal{N}(i)} \mathbf{h}_j^{(l)}\right)$$

Connection to Weisfeiler-Lehman (WL) test

The 1-WL test distinguishes graphs by repeatedly replacing each node's label with a hash of its own label + sorted neighbor labels. GIN with sum aggregation + injective MLP matches the power of 1-WL - the strongest expressiveness among standard MPNNs.

Why sum, not mean? Mean loses information about neighborhood size. Sum + MLP can distinguish different multiset structures. Xu et al. prove sum is the right aggregator for maximum expressiveness.

When to use

Graph classification (molecules, proteins), when structural discrimination matters. State-of-the-art on many graph-level benchmarks.

from torch_geometric.nn import GINConv

class GIN(torch.nn.Module):
    def __init__(self, in_dim, hidden, out_dim):
        super().__init__()
        nn1 = torch.nn.Sequential(torch.nn.Linear(in_dim, hidden), torch.nn.ReLU(),
                                  torch.nn.Linear(hidden, hidden))
        nn2 = torch.nn.Sequential(torch.nn.Linear(hidden, hidden), torch.nn.ReLU(),
                                  torch.nn.Linear(hidden, out_dim))
        self.conv1 = GINConv(nn1)
        self.conv2 = GINConv(nn2)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        return self.conv2(x, edge_index)

5. MPNN

MPNN specializes message passing for molecules:

$$\mathbf{m}_{ij} = \mathbf{M}(\mathbf{h}_i, \mathbf{h}_j, \mathbf{e}_{ij}) \quad \mathbf{h}_i' = \mathbf{U}(\mathbf{h}_i, \sum_j \mathbf{m}_{ij})$$

Edge features (bond type, distance) are first-class citizens. This design powers molecular property prediction - atoms are nodes, bonds are edges with rich features.

6. Comparison Table

ModelAggregationInductiveEdge featuresBest for
GCNNormalized sumLimitedNo (default)Homophilous node classification baseline
GraphSAGEMean/max/LSTMYesLimitedLarge-scale inductive
GATAttention-weightedYesWith extensionVariable neighbor importance
GINSum + MLPYesWith extensionGraph classification, expressiveness
MPNNSum + edge msgsYesYesMolecules, edge-rich graphs

7. Full Training Example

# Pick GAT for Cora  -  swap GATConv for GCNConv/SAGEConv/GINConv
model = GAT(dataset.num_features, 8, dataset.num_classes, heads=8)
optimizer = torch.optim.Adam(model.parameters(), lr=0.005, weight_decay=5e-4)

def train():
    model.train()
    optimizer.zero_grad()
    out = model(data.x, data.edge_index)
    loss = F.cross_entropy(out[data.train_mask], data.y[data.train_mask])
    loss.backward()
    optimizer.step()
    return loss.item()

@torch.no_grad()
def test():
    model.eval()
    out = model(data.x, data.edge_index)
    pred = out.argmax(dim=1)
    acc = (pred[data.test_mask] == data.y[data.test_mask]).float().mean()
    return acc.item()