ml-models / Model architectures / Graph neural networks advanced

Graph neural networks

Molecules, road maps, citation networks and social graphs have no grid and no order, only nodes and the edges between them. This page writes a graph as an adjacency matrix and a feature matrix, builds the message-passing layer that every graph neural network shares, derives GCN's normalized propagation, adds GraphSAGE and GAT, shows why deep stacks over-smooth, and batches several graphs into one, all worked on one five-node graph.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. A graph as two matrices
  5. Message passing
  6. GCN: normalized propagation, derived
  7. GraphSAGE: sample, aggregate, concatenate
  8. GAT: attention over the neighbourhood
  9. Over-smoothing
  10. Batching graphs
  11. Worked example
  12. One GCN layer
  13. The same layer as message passing
  14. One GAT head at C
  15. One GraphSAGE layer at C
  16. Over-smoothing, measured
  17. Batching two graphs
  18. Implementation
  19. Tradeoffs
  20. Connections
  21. Read first
  22. Read next
  23. References

Problem

A convolution slides one small filter over a grid because every pixel has the same neighbours in the same positions: up, down, left, right. A transformer works on a sequence, where position is an index. Many datasets have neither. Atoms in a molecule have between one and four bonds. A paper cites any number of others. A user in a social network has three friends or three thousand. There is no "left neighbour", and renumbering the nodes changes nothing about the graph, so a model that depends on the numbering is wrong.

The questions asked of such data are of three kinds: about a node (what topic is this paper, is this account a bot), about an edge (will these two users connect, do these two proteins interact) and about a whole graph (is this molecule toxic). A graph neural network learns a vector for every node from its own features and from its neighbourhood, using the same weights for every node whatever its degree, and in a way that does not depend on how the nodes are numbered.

Intuition

Each node starts with a feature vector: an atom's element, a paper's bag of words. In one layer, every node collects its neighbours' vectors, combines them with its own and passes the result through a small learned function. After one layer a node's vector summarizes its immediate neighbours; after two, its neighbours' neighbours; after k layers, everything within k hops. A paper about graph learning that cites three other graph papers ends up with a vector close to theirs.

The combining step has to treat the neighbours as a set, not a list, so it uses an order-free reduction: a sum, a mean, a maximum, or a weighted sum whose weights are themselves computed from the vectors. Everything else is a linear layer applied to every node with shared weights. The variants differ mostly in how they weight the neighbours, and the cost of the whole idea shows up when the stack gets deep: averaging over and over makes every node look like every other.

Mechanics

A graph as two matrices

Number the n nodes 0 to n − 1. The adjacency matrix A is n × n with Aij = 1 when there is an edge between i and j and 0 otherwise; for an undirected graph it is symmetric. The feature matrix X is n × f, one row per node. The degree di = Σj Aij counts node i's neighbours, and D is the diagonal matrix of degrees. Renumbering the nodes with a permutation matrix P turns A into PAPᵀ and X into PX; a layer is permutation equivariant when its output turns into P times the old output, which is the property every layer on this page has.

Real graphs are sparse: a citation graph with millions of nodes has a few dozen edges per node, so A is stored as an edge index, a 2 × E tensor of (source, target) pairs, with each undirected edge listed in both directions. That is a COO sparse matrix without the values, and PyTorch's torch.sparse_coo_tensor takes exactly that layout.

Message passing

The general layer, from the message passing neural network framework, has three parts. For each edge j → i a message mji = φ(hi, hj, eji) is computed from the two endpoints and any edge features. Each node aggregates its incoming messages with a permutation-invariant reduction ⊕ (sum, mean, max). Then it updates:

hi′ = γ( hi, ⊕j ∈ N(i) φ(hi, hj, eji) )

On an edge index this is three tensor operations: gather the endpoint rows with x[src] (an index_select), compute one message per edge, and scatter the messages back into their target rows with index_add_ or scatter_reduce_. The cost is O(E · f) instead of the O(n² · f) of a dense matrix product, which is what makes graphs with millions of nodes trainable.

GCN: normalized propagation, derived

The simplest choice is to sum the neighbours: AX. It has two defects. A node's own features are dropped, since Aii = 0, and the scale of the result grows with the degree, so a hub's vector is hundreds of times larger than a leaf's. Adding self-loops fixes the first: Ã = A + I, with degrees d̃i = di + 1. Dividing by the degree fixes the second: D̃−1ÃX is the mean over the closed neighbourhood. That mean is not symmetric, though: it weights the edge i ← j by 1/d̃i and the edge j ← i by 1/d̃j. The symmetric compromise splits the normalization between both ends:

 = D̃−½ à D̃−½,   Âij = Ãij / √(d̃i d̃j),   H′ = σ( H W)

Kipf and Welling reached the same matrix from spectral graph theory. The normalized Laplacian L = I − D−½AD−½ is symmetric with eigenvalues in [0, 2], and its eigenvectors play the role of a Fourier basis on the graph; a spectral filter multiplies each frequency by gθ(λ). Approximating the filter by a first-order Chebyshev polynomial, with λmax ≈ 2, gives g ⋆ x ≈ θ0x + θ1(L − I)x = θ0x − θ1D−½AD−½x, a filter that only looks one hop away. Tying the two parameters, θ = θ0 = −θ1, leaves θ(I + D−½AD−½)x. That operator has eigenvalues in [0, 2], so stacking it can blow activations up; the renormalization trick replaces it with D̃−½ÃD̃−½, which is  above and whose eigenvalues lie in (−1, 1]. A filter with one parameter per pair of input and output features, applied to all f inputs at once, is the matrix W. The paper's two-layer model for node classification is Z = softmax( ReLU(ÂXW(0)) W(1)), trained with cross-entropy on the few labelled nodes.

Read as message passing, GCN's message is the transformed neighbour Wᵀhj scaled by the fixed coefficient 1/√(d̃id̃j), the aggregation is a sum, and the update is the nonlinearity. The coefficients depend only on the graph, so they can be computed once.

GraphSAGE: sample, aggregate, concatenate

GCN as written needs the whole graph in every step, and it mixes a node's own vector into the same average as its neighbours. GraphSAGE changes both. Its layer keeps the self and the neighbourhood apart and concatenates them:

hN(v) = meanu ∈ N(v) hu,   hv′ = σ( W · [hv ‖ hN(v)] ),   hv′ ← hv′ / ‖hv′‖2

so the model can learn how much to trust the node versus its context. The aggregator can be a mean, an elementwise max after a small MLP ("pooling"), or an LSTM over a random ordering. The second change is training on samples: each layer uses a fixed number of randomly sampled neighbours (the paper uses 25 at the first hop and 10 at the second), so the cost of a minibatch of target nodes is bounded however large the graph is, and the learned function applies to nodes and graphs never seen in training. This is the inductive setting, where GCN as originally trained is transductive.

GAT: attention over the neighbourhood

GCN gives every neighbour a weight fixed by degrees. A graph attention network learns the weights from the features, in the spirit of attention but restricted to the edges. With zi = W hi and a learned vector a of length 2f′:

eij = LeakyReLU( aᵀ [zi ‖ zj] ),   αij = softmaxj ∈ N(i) ∪ {i}(eij),   hi′ = σ( Σj αij zj )

The softmax runs only over the node's own neighbourhood including itself, so a degree-2 node and a degree-200 node both get weights that sum to 1. The paper uses a LeakyReLU slope of 0.2 (PyTorch's F.leaky_relu defaults to 0.01), and K independent heads whose outputs are concatenated in hidden layers and averaged in the output layer, exactly as multi-head attention splits a transformer layer. Because a splits into a part that sees zi and a part that sees zj, the score is a monotone function of a2ᵀzj plus a constant per node, so every node ranks shared neighbours in the same order; GATv2 applies the nonlinearity before a to make the attention dynamic.

Over-smoothing

Each layer multiplies by Â, and  is a smoothing operator: its largest eigenvalue is 1, with eigenvector proportional to (√d̃1, …, √d̃n) on a connected graph, and with self-loops every other eigenvalue has magnitude below 1. Applying it k times shrinks every other component by its eigenvalue to the k-th power, so ÂkX tends to a rank-one matrix: row i becomes √d̃i times one shared vector. Nodes then differ only by their degree, and the features that distinguished them are gone. Li, Han and Wu showed this is Laplacian smoothing, the thing that makes shallow GCNs work, taken too far. Weights and nonlinearities between the layers slow it but do not stop it in practice, which is why most GNNs use two to four layers.

The remedies keep some of each node's own signal alive: residual connections or a jumping-knowledge concatenation of every layer's output, normalization schemes that re-spread the rows, randomly dropping edges during training, and decoupling the depth of propagation from the depth of the network. A related problem, over-squashing, is the opposite: information from an exponentially growing number of nodes k hops away has to pass through a fixed-size vector, so long-range dependencies are hard to learn even before smoothing sets in.

Batching graphs

For graph-level tasks the data is many small graphs of different sizes, so they cannot be stacked into one tensor. The standard trick is to treat a minibatch as one big disconnected graph. Concatenate the feature matrices, add to each graph's edge index the number of nodes that come before it, and keep a batch vector giving each node's graph. The adjacency of the result is block-diagonal, so no message ever crosses between graphs and every layer above runs unchanged. At the end a readout pools each graph's nodes into one vector with a sum, mean or max over the nodes sharing a batch index; that pooling is one more index_add_ or scatter_reduce_. Sum pooling keeps the count of nodes, which mean pooling loses; the GIN paper shows sum aggregation is what lets a GNN be as discriminating as the Weisfeiler-Lehman graph isomorphism test.

ComponentShape/ParamsMeaningSource
Adjacency A, features Xn × n, n × fWho is connected to whom, and what each node isGCN paper §2
Edge index2 × E longSparse COO indices of A, both directions of each edgesparse_coo_tensor, to_sparse_coo
Tensor.index_select / x[src](E, f)Gather each edge's source row: the messages before weighting_torch_docs.py
Tensor.index_add_(dim, index, source, *, alpha=1)self[index[i]] += alpha * source[i]Sum each message into its target row; nondeterministic on CUDA_tensor_docs.py
Tensor.scatter_reduce_(dim, index, src, reduce, *, include_self=True)reduce in "sum", "prod", "mean", "amax", "amin"Max or mean aggregation, and the per-node max for a stable softmax; marked beta_tensor_docs.py
Tensor.scatter_add_index shaped like srcSum aggregation with an explicit index per element; backward only when src.shape == index.shape_tensor_docs.py
torch.gatherindex with the output's shapeThe inverse direction: read one value per index along a dim_torch_docs.py
nn.Linear(f, f′)GCN layer 2 → 2: 6 parameters with biasThe shared W applied to every node, y = xAᵀ + blinear.py
F.leaky_relu(input, negative_slope=0.01)GAT uses slope 0.2Nonlinearity on the raw attention scorefunctional.py
F.softmaxover one neighbourhoodTurns GAT scores into weights; on an edge list it becomes a segment softmaxfunctional.py
GAT headW f × f′ plus a of length 2f′: 8 hereOne attention head; K heads concatenated or averagedGAT paper §2.1
GraphSAGE layerW f′ × 2f: 8 hereSelf concatenated with the neighbour mean, then L2-normalizedGraphSAGE paper, Algorithm 1

PyTorch v2.15.0 has no graph layers of its own; libraries such as PyTorch Geometric and DGL build them from exactly these index and scatter operations.

Worked example

Five nodes and five undirected edges: A–B, A–C, B–C, C–D, D–E, a triangle with a tail. Each node has two features, chosen so that A and B differ.

The example graph and its normalized adjacency Left: five nodes. A, B and C form a triangle, C connects to D and D to E. Each node is labelled with its features and its degree including the self-loop: A [1, 0] degree 3, B [0, 1] degree 3, C [1, 1] degree 4, D [0, 2] degree 3, E [2, 0] degree 2. Right: a five by five heatmap of the normalized adjacency with self-loops; darker cells are larger. Rows A and B are identical, the diagonal entry for E is the largest at 0.500, and the zeros are the missing edges. A [1, 0] d̃=3 B [0, 1] d̃=3 C [1, 1] d̃=4 D [0, 2] d̃=3 E [2, 0] d̃=2 self-loops are implied on every node  = D̃^−½ (A + I) D̃^−½ A A 0.33 0.33 0.29 0 0 B B 0.33 0.33 0.29 0 0 C C 0.29 0.29 0.25 0.29 0 D D 0 0 0.29 0.33 0.41 E E 0 0 0 0.41 0.50

One GCN layer

The degrees with self-loops are 3, 3, 4, 3, 2. Each entry of  is 1/√(d̃id̃j): the A–B entry is 1/√(3·3) = 0.3333, the A–C entry is 1/√(3·4) = 0.2887, and E's self-loop is 1/2 = 0.5000. With W = [[0.5, -1.0], [1.0, 0.5]]:

NodexÂXÂXWReLU(ÂXW)
A[1, 0][0.6220, 0.6220][0.9330, -0.3110][0.9330, 0.0000]
B[0, 1][0.6220, 0.6220][0.9330, -0.3110][0.9330, 0.0000]
C[1, 1][0.5387, 1.1160][1.3854, 0.0193][1.3854, 0.0193]
D[0, 2][1.1052, 0.9553][1.5079, -0.6275][1.5079, 0.0000]
E[2, 0][1.0000, 0.8165][1.3165, -0.5918][1.3165, 0.0000]

A and B started with different features and leave the layer with identical vectors. Their closed neighbourhoods are the same set, {A, B, C}, with the same degrees, so their rows of  are equal and no choice of W can separate them. This is a real limit of GCN, not a quirk of the numbers: a node's output depends only on the multiset of its neighbourhood, never on which of the two endpoints it was.

The same layer as message passing

Transform first: XW gives one row per node, A [0.5, -1.0], B [1.0, 0.5], C [1.5, -0.5], D [2.0, 1.0], E [1.0, -2.0]. The edge list has 15 entries: each of the 5 edges in both directions plus 5 self-loops. Each entry carries its source's row times 1/√(d̃sd̃t), and index_add_ sums them by target. For C:

Source → CCoefficientMessage
A1/√(3·4) = 0.2887[0.1443, -0.2887]
B1/√(3·4) = 0.2887[0.2887, 0.1443]
D1/√(3·4) = 0.2887[0.5774, 0.2887]
C1/√(4·4) = 0.2500[0.3750, -0.1250]
Sum[1.3854, 0.0193]

The sum is C's row of ÂXW above, and every other node matches too. The matrix form does n² = 25 multiply-adds per feature; the edge form does 15, and that gap is what grows to millions on a real graph.

Messages into node C under GCN and GAT Node C in the middle receives arrows from A, B, D and from itself. Each arrow is labelled with two weights: GCN's fixed coefficient and GAT's learned attention. From A: GCN 0.289, GAT 0.420. From B: GCN 0.289, GAT 0.171. From C itself: GCN 0.250, GAT 0.255. From D: GCN 0.289, GAT 0.155. E is two hops away and sends nothing in one layer. The GCN weights do not sum to 1; the GAT weights do. A GCN 0.289 GAT 0.420 B GCN 0.289 GAT 0.171 D GCN 0.289 GAT 0.155 C GCN 0.250 GAT 0.255 E GCN weights into C sum to 1.116; GAT weights sum to 1

One GAT head at C

With W = [[1.0, 0.0], [0.5, 1.0]], the transformed vectors are zA = [1.0, 0.0], zB = [0.5, 1.0], zC = [1.5, 1.0], zD = [1.0, 2.0], zE = [2.0, 0.0]. The attention vector a = [0.5, -1.0, 1.0, -1.0] splits into a1 = [0.5, -1.0], which scores C itself (a1·zC = -0.25), and a2 = [1.0, -1.0], which scores each neighbour. LeakyReLU with slope 0.2, then softmax:

ja₁·zC + a₂·zjeCjαCjα · zj
A0.750.750.4200[0.4200, 0.0000]
B-0.75-0.150.1708[0.0854, 0.1708]
C0.250.250.2547[0.3821, 0.2547]
D-1.25-0.250.1545[0.1545, 0.3090]
Sum1.0000[1.0420, 0.7345]

The two negative raw scores are multiplied by 0.2. A gets 2.46 times the weight of B, though GCN treats them the same. The head has 8 parameters, four in W and four in a; with K heads concatenated, C's output in a hidden layer would be 2K wide.

One GraphSAGE layer at C

C's neighbours are A, B, D, whose mean is [0.3333, 1.0000]. Concatenated after C's own features [1, 1] that gives [1.0000, 1.0000, 0.3333, 1.0000]. With the 2 × 4 matrix W = [[1.0, 0.0, 0.5, 0.5], [0.0, 1.0, -0.5, 1.0]], W · [h ‖ mean] = [1.6667, 1.8333]; ReLU leaves it unchanged, and dividing by its length 2.4777 gives [0.6727, 0.7399]. The first two columns of W only see the node itself and the last two only see the neighbourhood, so the model learns separately how much of each to keep. Note the mean is over neighbours without C: GraphSAGE keeps the self apart instead of averaging it in.

Over-smoothing, measured

Apply  repeatedly to X with no weights, the pure propagation that every GCN layer contains. Two measures: the smallest cosine similarity between any two rows, and the largest distance between rows after dividing each by √d̃i, which goes to 0 exactly when the rows line up with the leading eigenvector (√d̃) = [1.732, 1.732, 2.000, 1.732, 1.414].

Layers kRow C of ÂkXRow EMin cosineSpread
0[1.000, 1.000][2.000, 0.000]0.000001.82574
1[0.539, 1.116][1.000, 0.816]0.906280.43820
2[0.813, 0.914][0.951, 0.798]0.977180.37048
4[0.825, 0.934][0.809, 0.772]0.993180.22505
8[0.855, 0.949][0.694, 0.716]0.998860.08897
16[0.872, 0.958][0.630, 0.684]0.999970.01397
32[0.875, 0.959][0.619, 0.679]1.000000.00034

One layer already pushes every pair of rows above cosine 0.906. By 8 layers the spread is 4.9% of where it started, and by 32 every row is its node's √d̃ times one shared vector: C, with degree 4, has the largest row and E, with 2, the smallest, and the original features are no longer recoverable.

Spread between node vectors as propagation is repeated Bars for 7 depths, 0, 1, 2, 4, 8, 16, 32 layers of propagation, each showing how far apart the degree-scaled node vectors are. The bar falls from 1.826 at depth 0 to 0.438 after one layer, 0.089 after eight and 0.00034 after thirty-two, so all nodes collapse onto one direction. spread 0.0 0.5 1.0 1.5 0 1.826 1 0.438 2 0.370 4 0.225 8 0.089 16 0.014 32 3.4e-04 layers of propagation k

Batching two graphs

Add a triangle with features [[1.0, 1.0], [0.0, 3.0], [2.0, 2.0]] to the five-node graph. Its nodes become 5, 6 and 7, so its edges (0, 1), (1, 2), (0, 2) are shifted by the offset 5:

TensorValue
offsets[0, 5]
edge sources[0, 0, 1, 2, 3, 5, 6, 5]
edge targets[1, 2, 2, 3, 4, 6, 7, 7]
batch vector[0, 0, 0, 0, 0, 1, 1, 1]
sum pool[4.0, 4.0], [3.0, 6.0]
mean pool[0.80, 0.80], [1.00, 2.00]

No edge links a node below 5 to one at or above it, so the block-diagonal structure keeps the graphs apart through any number of layers. Each pool is index_add_ over the batch vector, divided by the node counts 5 and 3 for the mean.

Why does  use 1/√(d̃id̃j) instead of 1/d̃i?

1/d̃i is a plain mean, but its matrix is not symmetric, so it is not the normalized Laplacian the spectral derivation starts from, and it weights a message by the receiver's degree only. The symmetric form also shrinks messages from hubs: D weights C's message by 0.289 because C has degree 4, and E's by 0.408 because E has degree 2.

Implementation

A GCN layer, a neighbourhood softmax for GAT and mean pooling for a batch, in plain PyTorch on an edge index. The example graph's tensors reproduce the worked numbers above; nn.Linear computes x·Wᵀ, so the page's W goes in transposed.

import torch
import torch.nn as nn

def gcn_layer(x, edge_index, lin):
    n = x.size(0)
    loops = torch.arange(n)
    src = torch.cat([edge_index[0], edge_index[1], loops])   # both directions plus self-loops
    dst = torch.cat([edge_index[1], edge_index[0], loops])
    deg = torch.zeros(n).index_add_(0, dst, torch.ones(dst.numel()))
    coef = deg[src].rsqrt() * deg[dst].rsqrt()                # 1 / sqrt(d_s * d_t)
    h = lin(x)
    return torch.zeros_like(h).index_add_(0, dst, coef.unsqueeze(1) * h[src])

def neighbourhood_softmax(scores, dst, n):
    top = torch.full((n,), float("-inf")).scatter_reduce(0, dst, scores, reduce="amax")
    e = (scores - top[dst]).exp()                             # subtract each node's max for stability
    return e / torch.zeros(n).index_add_(0, dst, e)[dst]

def mean_pool(h, batch, num_graphs):
    total = torch.zeros(num_graphs, h.size(1)).index_add_(0, batch, h)
    count = torch.zeros(num_graphs).index_add_(0, batch, torch.ones(batch.numel()))
    return total / count.unsqueeze(1)

x = torch.tensor([[1., 0.], [0., 1.], [1., 1.], [0., 2.], [2., 0.]])
edge_index = torch.tensor([[0, 0, 1, 2, 3], [1, 2, 2, 3, 4]])
lin = nn.Linear(2, 2, bias=False)
with torch.no_grad():
    lin.weight.copy_(torch.tensor([[0.5, -1.0], [1.0, 0.5]]).T)
print(gcn_layer(x, edge_index, lin))       # row 2 is C: [1.3854, 0.0193]

The docstrings of index_add_ and scatter_reduce_ warn that they may behave nondeterministically on a CUDA device, so sums can differ in the last bits from run to run; torch.use_deterministic_algorithms(True) makes PyTorch pick a deterministic implementation or raise an error. For a fixed graph the coefficients can be computed once and the whole propagation stored as one sparse matrix, after which each layer is a single torch.sparse.mm. Libraries such as PyTorch Geometric and DGL package these layers, neighbour samplers and batching collators; the operations underneath are the ones above.

Tradeoffs

  • GCN vs GraphSAGE vs GAT. GCN is the cheapest, with fixed coefficients and one matrix per layer, and is a strong baseline on citation-style graphs where neighbours tend to share labels. GraphSAGE separates the self from the neighbourhood and scales through sampling. GAT learns neighbour weights at the cost of one score per edge per head, which helps when some neighbours matter more than others.
  • Depth. More layers see further but over-smooth and over-squash; two to four layers are typical, with residual connections or jumping knowledge to go deeper.
  • Full graph vs sampling. Full-batch training is exact but needs the whole graph and all activations in memory. Neighbour sampling bounds memory per minibatch but adds variance, and the number of sampled nodes still grows exponentially with depth.
  • Expressiveness. A message-passing GNN cannot tell apart nodes that the 1-WL test cannot, like A and B in the worked example. Sum aggregation (GIN) reaches that bound; mean and max fall below it. Going further needs positional encodings, subgraph features or higher-order methods.
  • Homophily. Averaging neighbours assumes they resemble the node. On heterophilous graphs, where connected nodes tend to differ (fraudsters and their victims), plain GCN can do worse than an MLP that ignores the edges; keeping the self apart helps.
  • Graph transformers. Full attention over all nodes avoids over-squashing but costs O(n²) and needs positional encodings to know the structure at all; most such models still mix in local message passing.
A two-layer GCN predicts a label for node E. Which nodes' features can affect it?

Nodes within two hops: E, D and C. A and B are three hops away, so they are invisible until a third layer.

Why can't the GAT softmax use F.softmax directly on the edge scores?

Each node normalizes over its own neighbourhood, and the neighbourhoods have different sizes. On an edge list that is a segment softmax: subtract each target's max (a scatter_reduce with "amax"), exponentiate, and divide by each target's sum (an index_add_).

Your 16-layer GCN has the same accuracy as one that predicts by degree alone. What happened?

Over-smoothing: after many applications of  every node's vector is close to √d̃ times one shared vector, so degree is all that is left. Cut to two or three layers, or add residual connections.

Connections

Read first

Read next

No page builds on this one yet.

References