Inside the Graph Neural Network

Message passing, aggregation, and depth, built one neighbor at a time

Sjoerd Vink

Images are grids and sentences are sequences. A lot of data is neither. Molecules, social networks, road maps and citations are graphs: things, and the links between them.

This essay builds a graph neural network from one idea, that a node should listen to its neighbors. One small graph runs through every chapter, drawn both as a network and as points in space, so you can watch each operation move the data.

  1. 01 Graphs
  2. 02 Message passing
  3. 03 GCN layer
  4. 04 Depth
  5. 05 Attention
  6. 06 Expressivity
edges = [(0, 1), (0, 2), (0, 3), (1, 3), (2, 3),         (3, 4), (4, 5), (4, 6), (5, 6), (5, 7)]X = np.array([[-1.6, 1.1], [0.9, -0.4], [-1.3, 0.0], [-0.4, 1.5],              [1.0, 0.5], [1.8, -0.8], [-0.7, -0.2], [1.4, -1.7]])y = np.array([0, 0, 0, 0, 1, 1, 1, 1])
01234567feature 1feature 201234567

01 Graphs

A graph is things, and the links between them.

Molecules are atoms joined by bonds. A social network is people joined by friendships. Papers cite papers, proteins bind proteins, roads join crossings. All of these are graphs: a set of nodes, and a set of edges that connect pairs of nodes.

This graph has 8 nodes, numbered 0 to 7, and 10 edges. The edges have no direction: if 0 is linked to 1, then 1 is linked to 0.

Every node also carries a vector of features. In a molecule they might describe the element of each atom. In a citation network, the words in each paper.

Here every node has two features, so each node can be drawn as a point in the plane on the right. The number next to a point is its node.

A graph holds two kinds of information: what each node is, and what it is connected to.

Say the nodes belong to two classes, blue and rose, and the task is to predict the class of every node. From the features alone, that is hard.

Node 1 is blue, but its point sits among the rose ones. Node 6 is rose, but sits next to the blue ones. No straight line in the plane puts all blue points on one side and all rose points on the other.

The edges tell a different story. Node 1 is linked to nodes 0 and 3, and both are blue. Nine of the ten edges join two nodes of the same class. Only the bridge between 3 and 4 crosses over.

Many real graphs work this way. Friends share interests, and papers cite papers on the same topic. The pattern is called homophily, and it is what the simplest graph neural networks rely on.

A graph neural network decides about a node using its own features and the features of its neighbors.

X.shapeA = np.zeros((n, n))for u, v in edges:    A[u, v] = A[v, u] = 1deg = A.sum(axis=1)edge_index = to_undirected(torch.tensor(edges).T)
01234567X01234567−1.61.10.9−0.4−1.30.0−0.41.51.00.51.8−0.8−0.7−0.21.4−1.7(8, 2)A01234567(8, 8)deg32243321edge_index (2, 20)src = [0 0 0 1 1 2 2 3 3 3 3 4 4 4 5 5 5 6 6 7]dst = [1 2 3 0 3 0 3 0 1 2 4 3 5 6 4 6 7 4 5 5]

01 Graphs/ As matrices

Two matrices hold the whole graph.

Stack the feature vectors as rows and you get the feature matrix XX. Row ii belongs to node ii. With nn nodes and dd features its shape is (n, d), here (8, 2).

X∈Rn×dX \in \mathbb{R}^{n \times d}

The edges go into the adjacency matrix AA, of shape (n, n). Entry AijA_{ij} is 1 when nodes ii and jj share an edge, and 0 otherwise.

Row 3 has ones in columns 0, 1, 2, 4. Those are the neighbors of node 3, written N(3)\mathcal{N}(3).

N(v)={ u:Avu=1 }\mathcal{N}(v) = \{\, u : A_{vu} = 1 \,\}

Edges have no direction, so AA is symmetric: row 3 and column 3 hold the same entries, and A=A⊤A = A^{\top}.

Adding up a row counts the neighbors of that node. This is its degree dvd_v. Node 3 has degree 4, node 7 only 1.

dv=∑uAvud_v = \sum_u A_{vu}

Real graphs are sparse. The Cora citation graph, a standard benchmark, has 2,708 papers. Its adjacency matrix would have 7,333,264 entries, and only 10,556 of them are ones: 0.14%.

So libraries store only the edges. edge_index has shape (2, E): a row of source nodes and a row of target nodes, with every undirected edge listed in both directions. It holds the same information as AA.

perm = [5, 2, 7, 0, 3, 6, 1, 4]P = np.eye(n)[perm].TX_new, A_new = P @ X, P @ A @ P.T(A_new.flatten() != A.flatten()).sum()np.allclose(f(X_new, A_new), P @ f(X, A))
01234567X01234567−1.61.10.9−0.4−1.30.0−0.41.51.00.51.8−0.8−0.7−0.21.4−1.7A01234567AA_new20 of 64 entries of A changed place

01 Graphs/ Order

The numbering means nothing.

Nothing in the graph says which node is number 0. The numbers are names we gave the nodes so we could write them into matrices. A model that gives different answers for different names has learned something that is not there.

Renumber the nodes: node 3 becomes 0, node 0 becomes 5, and so on. The graph is the same, but the rows of XX move, and the rows and columns of AA move with them. The amber row and column belong to the ringed node.

A permutation matrix PP describes the renumbering. It has a single 1 in every row and column. Multiplying by PP on the left reorders rows, and multiplying by P⊤P^{\top} on the right reorders columns. That is why the rows of AA move first, and then its columns.

X′=PX,A′=PAP⊤X' = PX, \qquad A' = PAP^{\top}

This rules out the obvious approach of flattening AA into one long vector and feeding it to an ordinary network. After the renumbering, 20 of its 64 entries are different, so the network would see a different input for the same graph.

There are n!n! ways to number nn nodes, 40,320 for this small graph. An ordinary network would have to learn from examples that all of them mean the same thing, and the count explodes as graphs grow.

So the requirement is built in. A layer that produces one vector per node must be permutation equivariant: renumber the input, and the output rows are renumbered in the same way, with nothing else changed.

f(PX, PAP⊤)=P f(X,A)f(PX,\ PAP^{\top}) = P\,f(X, A)

A model that produces one answer for the whole graph, such as whether a molecule is toxic, must be invariant: the answer does not change at all.

Every layer in this essay is built to satisfy this rule.

How can a node use the features of its neighbors in a way that does not depend on how they are numbered?

nbrs = [0, 3]total = X[nbrs].sum(axis=0)mean = total / len(nbrs)h = X[[1] + nbrs].mean(axis=0)H = np.stack([X[[u] + adj[u]].mean(axis=0) for u in range(n)])
0123456701234567N(1) = {0, 3}X[0] + X[3]= [−1.6, 1.1] + [−0.4, 1.5]= [−2.00, 2.60][−2.00, 2.60] / 2= [−1.00, 1.30](X[1] + X[0] + X[3]) / 3= [−0.37, 0.73]node 1: [0.90, −0.40] → [−0.37, 0.73]node 6: [−0.70, −0.20] → [0.70, −0.17]

02 Message passing

Every node listens to its neighbors.

Take node 1. Its own features say rose, but its neighbors, 0 and 3, are both blue. The idea behind every graph neural network is to let each node collect the vectors of its neighbors and update its own vector with what it finds.

The collecting step is called aggregation. It has one hard rule from the previous chapter: it may not depend on the order in which the neighbors are numbered.

The simplest aggregation is a sum. Geometrically, the neighbor vectors are laid tip to tail, and the sum is the arrow from the start of the first to the end of the last.

∑u∈N(1)xu=[−1.6, 1.1]+[−0.4, 1.5]=[−2.0, 2.6]\sum_{u \in \mathcal{N}(1)} x_u = [-1.6,\ 1.1] + [-0.4,\ 1.5] = [-2.0,\ 2.6]

Addition does not care about order: x0+x3x_0 + x_3 equals x3+x0x_3 + x_0. That is exactly the property we need.

A sum grows with the number of neighbors. A node with fifty neighbors gets a vector about twenty-five times longer than a node with two, even if the neighbors look alike.

Dividing by the count gives the mean. In the plane, the mean of two points is the midpoint between them. The mean of many points is their centroid, the center of mass they would balance on.

1∣N(1)∣∑u∈N(1)xu=[−1.0, 1.3]\frac{1}{|\mathcal{N}(1)|} \sum_{u \in \mathcal{N}(1)} x_u = [-1.0,\ 1.3]

Averaging only the neighbors would throw away the node's own features. So the node counts itself as one of its neighbors. The mean is now the centroid of the shaded triangle.

h1=13(x1+x0+x3)=[−0.37, 0.73]h_1 = \tfrac{1}{3}\left(x_1 + x_0 + x_3\right) = [-0.37,\ 0.73]

Node 1 has moved from [0.90, −0.40] into the blue region. Its neighbors outvoted its own features.

Now every node does the same, all at once, each using the old vectors of its neighbors. Every point moves to the centroid of its own neighborhood.

Node 6 crosses over too, from [−0.70, −0.20] to [0.70, −0.17]. A straight line now separates the two classes, which no line could do before.

Averaging over neighbors smooths the features along the edges. When neighbors tend to share a class, smoothing removes noise.

S = A @ XA_hat = A + np.eye(n)deg = A_hat.sum(axis=1, keepdims=True)H = (A_hat @ X) / degsrc, dst = add_self_loops(edge_index)[0]H = scatter(X[src], dst, dim=0, reduce="mean")
A@X−1.61.10.9−0.4−1.30.0−0.41.51.00.51.8−0.8−0.7−0.21.4−1.7=−0.81.1−2.02.6−2.02.6−1.01.20.70.51.7−1.42.8−0.31.8−0.8÷deg43354432=H−0.600.55−0.370.73−1.100.87−0.280.540.420.250.87−0.550.70−0.171.60−1.2501234567row 1: X[0] + X[3] = [−2.00, 2.60]row 1: X[1] + X[0] + X[3] = [−1.10, 2.20]row 1: [−1.10, 2.20] / 3 = [−0.37, 0.73]same result, one message per edge, no n × n matrix

02 Message passing/ As matrices

One matrix product updates every node.

Row vv of AA has a 1 for every neighbor of vv and a 0 everywhere else. So row vv of the product AXAX adds up exactly the feature vectors of the neighbors of vv.

(AX)v=∑uAvu xu=∑u∈N(v)xu(AX)_v = \sum_u A_{vu}\, x_u = \sum_{u \in \mathcal{N}(v)} x_u

To let every node count itself, add the identity matrix. This puts a 1 on the diagonal, a self-loop on every node. Papers write it with a tilde.

A~=A+I\tilde{A} = A + I

For the mean, divide every row by its number of ones. Collect those counts in a diagonal matrix D~\tilde{D}, with D~vv=∑uA~vu\tilde{D}_{vv} = \sum_u \tilde{A}_{vu}. Multiplying by its inverse divides each row by its count.

H=D~−1A~XH = \tilde{D}^{-1} \tilde{A} X

Row 1 of HHis the centroid from before. Every other row is the centroid of that node's neighborhood.

Graph libraries usually skip A~\tilde{A} altogether. Each edge copies the vector of its source node, a message, to its target node, and each node reduces the messages it receives. The cost grows with the number of edges, not with n2n^2. This is why the whole family is called message passing.

Equivariance now comes for free. Renumbering turns A~X\tilde{A}X into (PA~P⊤)(PX)(P\tilde{A}P^{\top})(PX), and because P⊤P=IP^{\top}P = I that is P(A~X)P(\tilde{A}X): the same rows in the new order.

Averaging already separated the classes in our small graph. To learn what to keep and what to ignore, the layer needs weights.

H = XH = A_norm @ HH = H @ WH = np.maximum(H, 0)
01234567Xfeatures Haggregate: mix rowsH Wtransform: move spaceσ(H)ReLU: foldx₆ = [−0.70, −0.20]x₆ = [−0.70, −0.20](ÂX)₆ = [0.57, −0.15]x₆ = [−0.70, −0.20](ÂX)₆ = [0.57, −0.15]· W = [0.60, −0.38]x₆ = [−0.70, −0.20](ÂX)₆ = [0.57, −0.15]· W = [0.60, −0.38]ReLU = [0.60, 0.00]x₆ = [−0.70, −0.20](ÂX)₆ = [0.57, −0.15]· W = [0.60, −0.38]ReLU = [0.60, 0.00]

03 GCN layer

Aggregate, transform, bend.

Averaging has no parameters, so there is nothing to learn. A graph convolutional network, or GCN, adds the two ingredients of an ordinary neural network layer: a weight matrix and a nonlinearity. It was introduced by Kipf and Welling in 2017 and is still the usual starting point.

Start again from the raw features. The grey arrows mark the two unit vectors along the axes, so we can see what happens to the plane itself.

First, aggregate. A GCN uses a slightly different average. Instead of dividing by the degree of the receiving node, it divides each message by the square root of both degrees.

A^=D~−1/2 A~ D~−1/2,A^vu=1d~vd~u\hat{A} = \tilde{D}^{-1/2}\, \tilde{A}\, \tilde{D}^{-1/2}, \qquad \hat{A}_{vu} = \frac{1}{\sqrt{\tilde{d}_v \tilde{d}_u}}

A node with many neighbors now sends weaker messages, because its features are shared with many nodes. The weights in a row no longer add up to exactly 1, but the result is close to the mean, and the matrix stays symmetric.

Look at the grid: it does not move. Aggregation is not a function of the plane. Where a point goes depends on its neighbors, not on where it is.

Then, transform. Every row is multiplied by the same weight matrix WW, the same for every node.

W=[1.2−0.40.61.0]W = \begin{bmatrix} 1.2 & -0.4 \\ 0.6 & 1.0 \end{bmatrix}

This is a linear map of the whole plane. Grid lines stay straight, parallel and evenly spaced. The two grey arrows land on the rows of WW, and every point moves along with the grid.

Here WW is 2 by 2 so we can draw it. In practice it also changes the width. On Cora the first layer maps 1433 features to 16.

Finally, bend. ReLU replaces every negative coordinate with zero, which folds the plane onto the positive quadrant. Points left of the vertical axis land on it, points below the horizontal axis land on that, and the bottom-left quarter collapses onto the origin.

The fold sorts the nodes. The blue points end up on or right next to the vertical axis, the rose points on or next to the horizontal one. Node 6 started at [−0.70, −0.20] and ends at [0.60, 0.00].

Together, that is one GCN layer.

H(l+1)=σ(A^ H(l) W(l))H^{(l+1)} = \sigma\big(\hat{A}\, H^{(l)}\, W^{(l)}\big)

Papers often write the same layer for a single node. The sum runs over the node and its neighbors.

hv(l+1)=σ(∑u∈N(v)∪{v}1d~vd~u hu(l)W(l))h_v^{(l+1)} = \sigma\Big(\sum_{u \in \mathcal{N}(v) \cup \{v\}} \frac{1}{\sqrt{\tilde{d}_v \tilde{d}_u}}\, h_u^{(l)} W^{(l)}\Big)

The name comes from images. A convolution applies the same small filter at every pixel and its neighbors. A GCN applies the same WW at every node and its neighbors. An image is a graph too: a grid where each pixel links to the pixels around it.

Aggregation moves information between nodes. The weights and the nonlinearity transform each node on its own, with the same parameters everywhere.

class GCNLayer(nn.Module):    def __init__(self, d_in, d_out):        super().__init__()        self.W = nn.Linear(d_in, d_out, bias=False)    def forward(self, H, A):        A_hat = A + torch.eye(A.size(0))        d = A_hat.sum(dim=1)        A_norm = A_hat / torch.sqrt(d[:, None] * d[None, :])        return A_norm @ self.W(H)
A_norm(n, n)@H(n, d)@W(d, d′)=H′(n, d′)X(n, d)H(n, 16)logits(n, c)layer1, ReLUlayer2nnodesdfeatures ind′features outcclasses

03 GCN layer/ In PyTorch

The same layer in PyTorch.

The layer first builds A^\hat{A}. d[:, None] * d[None, :] is an (n, n) matrix whose entry (v,u)(v, u) is d~vd~u\tilde{d}_v \tilde{d}_u, so one division normalizes every entry at once.

nn.Linear without a bias is the multiplication by WW. It works on every row at once and turns shape (n, d) into (n, d′).

Matrix products can be grouped either way: A^(HW)=(A^H)W\hat{A}(HW) = (\hat{A}H)W. Applying WW first is cheaper when it shrinks the vectors, as with the 1433 to 16 layer on Cora.

Multiplying by A_norm does the aggregation. The number of rows stays nn, one vector per node, and the rows are in the same order as the input. The layer is equivariant.

A typical GCN stacks two of these layers with a ReLU between them. The last layer outputs one score per class for every node, and training uses cross-entropy on the nodes whose labels are known.

Libraries such as PyTorch Geometric provide this layer as GCNConv, working from edge_index instead of a dense matrix, with a bias added.

One layer lets a node hear from its neighbors. Stacking layers lets it hear from their neighbors too.

reach = np.linalg.matrix_power(A_hat, k) > 0reach[0]
01234567(A + I)ᵏk = 101234567k = 1: node 0 sees {0, 1, 2, 3}k = 1: node 0 sees {0, 1, 2, 3}k = 1: node 0 sees {0, 1, 2, 3}k = 1: node 0 sees {0, 1, 2, 3}

04 Depth

Each layer reaches one hop further.

One layer mixes each node with its direct neighbors. After one layer, node 0 holds information from nodes 1, 2, 3 and from itself, and from nothing else.

In the second layer, node 0 aggregates again. But node 3 already took in node 4 during the first layer, so information from node 4 now reaches node 0 through node 3.

After kk layers, a node has heard from every node at most kkedges away. This set is the node's receptive field, the same idea as in a convolutional network for images.

The matrix view says the same. Entry (v,u)(v, u) of A~k\tilde{A}^k counts the walks of length kk from vv to uu, where a self-loop lets a walk pause. It is nonzero exactly when uu is at most kk hops away.

Node 7 is 4 hops from node 0, so it takes 4 layers before node 0 can depend on it at all. Now every entry in row 0 is filled.

Change the number of layers and watch both views grow together.

Most GCNs use two or three layers. That sounds like very few, but real graphs tend to be small worlds: friends of friends of friends already cover a large part of a social network.

H = Xfor _ in range(k):    H = A_hat @ H / deg
0123456701481632rounds of averagingbetween classes1.85within a class0.45

04 Depth/ Over-smoothing

Too many layers, and every node looks the same.

So why not use many layers? Repeat the mean aggregation of chapter 2 without any weights and watch what happens. The crosses mark the mean of each class.

One round cuts the average distance of a point to its class mean from 1.21 to 0.45, while the gap between the two class means stays at 1.85. This is the useful part: each class pulls together.

After four rounds the classes are tighter still. But the gap between them has started to shrink too, down to 1.36. The bridge nodes 3 and 4 drift toward each other and drag their classes along.

After 32 rounds, every node sits at nearly the same point. This is over-smoothing, and it is not a coincidence of this graph.

Each row of D~−1A~\tilde{D}^{-1}\tilde{A} sums to 1, so it describes a random walk that moves to a random neighbor or stays put. Run the walk long enough and where it ends no longer depends on where it started. Every node ends up with the same mixture of all features, weighted by degree.

(D~−1A~)kX  ⟶  1 π⊤X,πu=d~u∑wd~w\big(\tilde{D}^{-1}\tilde{A}\big)^k X \;\longrightarrow\; \mathbf{1}\, \pi^{\top} X, \qquad \pi_u = \frac{\tilde{d}_u}{\sum_w \tilde{d}_w}

Drag the slider. The differences between nodes never vanish completely, but after the first round they shrink by about 10 percent every round, until they are smaller than the noise and the bias terms that real layers add.

Learned weights and nonlinearities between the layers do not prevent this in general. Deeper models use residual connections, so each layer only adds a change, h(l+1)=h(l)+f(h(l))h^{(l+1)} = h^{(l)} + f(h^{(l)}), or combine the outputs of all layers, or normalize the vectors to keep them apart.

Depth widens what a node can see, and blurs what it sees.

So far, every neighbor counted equally, or by a fixed rule based on degrees. What if a node could decide whom to listen to?

Wh = H @ Wh_mean = Wh[nodes].mean(axis=0)e = np.array([a @ np.concatenate([Wh[v], Wh[u]]) for u in nodes])alpha = softmax(leaky_relu(e))h = alpha @ Wh[nodes]
01234567weightnode 4 (self)0.25node 30.25node 50.25node 60.2501234567meanmean = [0.42, 0.25]e = LeakyReLU(aᵀ[x₄ ‖ xᵤ]) = [0.75, −0.14, 2.01, 0.15]α = softmax(e) = [0.18, 0.08, 0.64, 0.10]h₄ = Σ αᵤ xᵤ = [1.24, −0.33]

05 Attention

Not every neighbor deserves the same vote.

Node 4 sits on the bridge between the two groups. Its neighbors are 3, 5, 6, and node 3 belongs to the other class.

The mean, and the GCN, give every neighbor a weight that depends only on the graph. With itself included, node 4 takes a quarter from each, and its new vector [0.42, 0.25] is pulled toward node 3.

A graph attention network, or GAT, lets the model choose the weights. For every neighbor uu it computes a score from the two vectors involved.

evu=LeakyReLU⁡(a⊤[ Whv ∥ Whu ])e_{vu} = \operatorname{LeakyReLU}\big(a^{\top} [\, W h_v \,\Vert\, W h_u \,]\big)

∥\Vert means concatenation: the two vectors are placed end to end. aa is a learned vector that turns the pair into one number. Here WW is the identity and a=[0.3, 0.3, 0.6, −0.6]a = [0.3,\ 0.3,\ 0.6,\ -0.6], picked by hand. Training would learn both.

Softmax over the neighbors turns the scores into weights that are positive and sum to 1. Node 5 gets 0.64, node 3 only 0.08. The edge widths show the weights.

αvu=exp⁡(evu)∑w∈N(v)∪{v}exp⁡(evw)\alpha_{vu} = \frac{\exp(e_{vu})}{\sum_{w \in \mathcal{N}(v) \cup \{v\}} \exp(e_{vw})}

One detail matters when you read later papers. Split aa into a1a_1, which multiplies WhvW h_v, and a2a_2, which multiplies WhuW h_u. LeakyReLU and softmax never change the order of their inputs, so neighbors are ranked by a2⊤Whua_2^{\top} W h_u alone: a neighbor that ranks high for one node ranks high for every node. GATv2 applies aa after the nonlinearity, so the ranking can depend on who is asking.

The new vector is the weighted sum of the neighbors. It moves from the mean to [1.24, −0.33], deep among its own class.

hv′=∑u∈N(v)∪{v}αvu Whuh_v' = \sum_{u \in \mathcal{N}(v) \cup \{v\}} \alpha_{vu}\, W h_u

Because the weights are positive and sum to 1, this is a convex combination, and the result always lies inside the shaded region, the convex hull of the neighbors. The mean always lands on the plain average of the points. Attention can choose any point in the hull, but never one outside it.

Attention picks a point inside the hull of the neighbors. The mean always picks their average.

If this looks familiar, it should. A Transformer does the same with queries and keys instead of concatenation, and lets every token attend to every other token. That is attention on a complete graph, the dashed edges.

Set the scores of non-neighbors to −∞-\infty before the softmax, and Transformer attention only flows along the edges of the graph, as in GAT. The borrowing goes both ways: GAT usually runs several attention heads side by side and concatenates their outputs, just like a Transformer.

05 Attention/ One template

Message, aggregate, update.

Most graph neural networks in the literature fit one template. Each neighbor sends a message ψ\psi, the messages are combined by an order-independent aggregation ⨁\bigoplus, and an update ϕ\phi produces the new vector. Edge features evue_{vu}, such as bond types in a molecule, enter through the message.

mv=⨁u∈N(v)ψ(hv,hu,evu),hv′=ϕ(hv,mv)m_v = \bigoplus_{u \in \mathcal{N}(v)} \psi\big(h_v, h_u, e_{vu}\big), \qquad h_v' = \phi\big(h_v, m_v\big)

When you meet a new architecture, look for these three choices. The models from this essay, and two others you will see often:

GCNmessagehuW/d~vd~uh_u W / \sqrt{\tilde{d}_v \tilde{d}_u}aggregatesum, with self-loop\text{sum, with self-loop}updateσ(mv)\sigma(m_v)
GraphSAGEmessagehuh_uaggregatemean\text{mean}updateσ([hv∥mv] W)\sigma\big([h_v \Vert m_v]\, W\big)
GATmessageαvu huW\alpha_{vu}\, h_u Waggregatesum, with self-loop\text{sum, with self-loop}updateσ(mv)\sigma(m_v)
GINmessagehuh_uaggregatesum\text{sum}updateMLP⁡((1+ϵ)hv+mv)\operatorname{MLP}((1 + \epsilon) h_v + m_v)

Every layer so far combines the neighbors with a sum, a mean, or a weighted mean. Does the choice matter?

N1 = np.array([u, w])N2 = np.array([u, u, w, w])N3 = np.array([u, w, c])N1.sum(0), N1.mean(0), N1.max(0)
u×1w×1c×0{u, w}u×2w×2c×0{u, u, w, w}sumtells apartmeansamemaxsame

06 Expressivity

What a sum can see that a mean cannot.

An aggregation receives a multiset: a collection in which order does not matter but the same vector may appear more than once. Ideally, different multisets give different results.

Compare a node with neighbors uu and ww to a node with two copies of each. The mean lands on the same centroid. The element-wise maximum, which takes the largest value in every coordinate, lands on the same corner. Only the sum notices: it doubles.

Now add a small vector cc. It is below the maximum in every coordinate, so the maximum does not move. The mean does move, and so does the sum.

The pattern is general. The maximum ignores how often a vector occurs, and can miss a vector entirely, like cc. The mean sees the proportions of the neighbors but not how many there are. The sum keeps the count.

The sum remembers how many neighbors there are. The mean and the maximum forget it.

The graph isomorphism network, or GIN, builds on this. It sums the neighbors and passes the result through a small MLP instead of a single linear map.

hv′=MLP⁡((1+ϵ) hv+∑u∈N(v)hu)h_v' = \operatorname{MLP}\Big((1 + \epsilon)\, h_v + \sum_{u \in \mathcal{N}(v)} h_u\Big)

The factor 1+ϵ1 + \epsilonkeeps a node's own vector from blending in with its neighbors. Xu and colleagues proved in 2019 that when node features come from a countable set, such as categories, a suitable transformation followed by a sum can tell apart any two different multisets. No mean or maximum can do that. With enough capacity, GIN is as powerful as message passing gets.

That is about what a model can tell apart, not what it learns best. When the number of neighbors is mostly noise, a mean often works better in practice.

colors = [0] * nfor _ in range(rounds):    colors = [hash((colors[v], tuple(sorted(colors[u] for u in nbrs[v]))))              for v in range(n)]
01234567colorsround 0: 1 colorone hexagontwo triangleshexagontrianglesround 0: identical

06 Expressivity/ The WL test

Some graphs look the same to every message-passing network.

How much can message passing tell apart at best? A graph algorithm from 1968 gives the answer. It is called color refinement, or the Weisfeiler-Leman test, WL for short.

Every node starts with the same color, as if all features were equal. Only the structure of the graph is left.

In each round, a node gets a new color determined by its own color and the multiset of its neighbors' colors. Nodes with the same old color and the same neighbor colors get the same new color.

After one round the colors simply count neighbors: 4 colors, one for each degree.

In the second round, nodes with the same degree split up if their neighbors have different degrees. There are now 7 colors.

Nodes 1 and 2 still share a color. They are mirror images: swap them and the graph does not change, so nothing could tell them apart.

A third round changes nothing, so the colors are stable.

Compare the code with a GNN layer. The new color depends on the node's own color and the multiset of its neighbors' colors, exactly like aggregate and update. The hash is a perfect aggregation that never confuses two multisets.

It follows that if WL, started from the node features, gives two nodes the same color, every message-passing GNN gives them the same vector, whatever its weights. Xu and colleagues and Morris and colleagues proved this independently in 2019.

Here are two graphs that are clearly different. One is a single ring of six nodes. The other is two separate triangles. Every node in both has exactly two neighbors.

Run the refinement and nothing ever splits. Every node sees two neighbors with its own color, in every round. The color histograms of the two graphs are identical, so any sum or mean over the nodes is too.

No message-passing GNN can tell these graphs apart, however deep or wide, as long as the node features are equal. In particular, it cannot count triangles.

Stronger models break the symmetry. Some add features that describe a node's position in the graph, others pass messages between pairs of nodes or count small subgraphs.

Message passing sees a graph only through what each node can learn from its neighbors.

logits = classifier(Z)pred = logits.argmax(dim=1)
01234567Node classificationone prediction per row of ZLink predictionone score per pair of rowsGraph classificationone vector for all rowsz₂ = [0.00, 1.10] → class 0z₆ = [0.60, 0.00] → class 1z₀ · z₂ = 0.81 σ = 0.69z₂ · z₅ = 0.00 σ = 0.50mean of 8 rows = [0.36, 0.40]
Putting it together

One set of node vectors, three kinds of tasks.

After the GCN layer of chapter 3, every node has a vector zvz_v, a row of the matrix ZZ. These are the node embeddings. Each kind of task reads them in its own way.

Node classification puts a classifier on every row. Here the dashed line is enough. Training uses cross-entropy on the nodes whose labels are known, but message passing carries what they teach to their unlabeled neighbors.

Link prediction asks whether two nodes should be connected, for recommending friends or predicting which drugs interact. The usual score is the dot product of the two embeddings.

p(u∼v)=σ(zu⊤zv)p(u \sim v) = \sigma\big(z_u^{\top} z_v\big)

The geometry of chapter 3 pays off. Blue vectors lie along one axis and rose vectors along the other, so they are orthogonal. Nodes 0 and 2 score 0.81, nodes 2 and 5 score 0.00. The sigmoid turns these into 0.69 and 0.50, and the second is a coin flip. In practice the last layer has no ReLU, so scores can go negative, and training pushes random pairs that are not edges toward 0.

Graph classification gives one answer for a whole graph, such as whether a molecule is toxic. A readout pools all node vectors into one, here the mean, shown as a star.

zG=1n∑vzvz_G = \frac{1}{n} \sum_{v} z_v

The readout must not depend on the numbering either, so it uses the same order-independent operations as aggregation: sum, mean or max. A classifier on zGz_G makes the prediction.

data = Planetoid(root="data", name="Cora")[0]class GCN(torch.nn.Module):    def __init__(self, d_in, d_hidden, n_classes):        super().__init__()        self.conv1 = GCNConv(d_in, d_hidden)        self.conv2 = GCNConv(d_hidden, n_classes)    def forward(self, x, edge_index):        h = F.relu(self.conv1(x, edge_index))        return self.conv2(h, edge_index)
x(2708, 1433)conv1h(2708, 16)conv2out(2708, 7)edge_index(2, 10556)
Putting it together

A complete GCN, trained on Cora.

Cora is the citation graph from chapter 1: 2,708 machine learning papers. Each is described by 1,433 binary features that mark which words it contains. Citations are the edges, and the task is to predict each paper's topic, one of 7.

PyTorch Geometric loads it as a single graph with data.x, data.edge_index, data.y, and masks that say which nodes are used for training and testing.

The model is the two-layer GCN from chapter 3. GCNConv does the normalization, the multiplication by WW and the aggregation, working directly on the edge list, and adds a bias.

Only 140 nodes, 20 per class, have labels for training. The loss looks at those nodes alone. The forward pass still runs over the whole graph, so every unlabeled node takes part through the messages it sends and receives.

This setting, a few labels and a large graph, is called semi-supervised node classification, and it is where GCNs first stood out.

On the 1,000 test nodes this model reaches roughly 80 percent accuracy, close to the 81.5 percent reported by Kipf and Welling. An ordinary network that sees only the word features, and not the citations, stays below 60.

The citations are worth about twenty points of accuracy, and two layers of averaging are enough to use them.

Field guide

Reading GNN papers

The symbols that appear most often in graph learning papers, with what they mean and where they live in PyTorch Geometric.

G=(V,E)G = (V, E)
a graph: nodes V and edges E
Data(x=X, edge_index=edge_index)
AA
adjacency matrix, 1 where an edge exists
to_dense_adj(edge_index)[0]
N(v)\mathcal{N}(v)
the neighbors of node v
edge_index[1][edge_index[0] == v]
dvd_v
degree, the number of neighbors
degree(edge_index[0])
X∈Rn×dX \in \mathbb{R}^{n \times d}
node features, one row per node
data.x
hv(l)h_v^{(l)}
the vector of node v after l layers
H[v]
A~=A+I\tilde{A} = A + I
adjacency with self-loops
add_self_loops(edge_index)
D~−1/2A~D~−1/2\tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2}
symmetric normalization used by GCN
gcn_norm(edge_index)
⨁\bigoplus
any order-independent aggregation
scatter(m, dst, reduce="sum")
ψ, ϕ\psi,\ \phi
message and update functions
MessagePassing.message, .update
αvu\alpha_{vu}
attention weight node v gives neighbor u
return_attention_weights=True
[ a∥b ][\, a \Vert b \,]
concatenation
torch.cat([a, b], dim=-1)
zG=READOUT⁡({hv})z_G = \operatorname{READOUT}(\{ h_v \})
one vector for a whole graph
global_mean_pool(H, batch)
σ(zu⊤zv)\sigma(z_u^{\top} z_v)
probability of a link between u and v
torch.sigmoid((z[u] * z[v]).sum(-1))
f(PX,PAP⊤)=Pf(X,A)f(PX, PAP^{\top}) = P f(X, A)
permutation equivariance
renumber in, renumber out
Further reading

Where to go next

Every paper below builds on the ideas and notation from this essay, from the original layers to the wider view of why they work.

  1. Kipf and Welling, 2017

    Semi-Supervised Classification with Graph Convolutional Networks

    The GCN layer of chapter 3, and the Cora experiment.

  2. Gilmer et al., 2017

    Neural Message Passing for Quantum Chemistry

    The message, aggregate, update template.

  3. Hamilton, Ying and Leskovec, 2017

    Inductive Representation Learning on Large Graphs

    GraphSAGE: sampling neighbors to scale to huge graphs.

  4. Veličković et al., 2018

    Graph Attention Networks

    The attention layer of chapter 5.

  5. Li, Han and Wu, 2018

    Deeper Insights into Graph Convolutional Networks for Semi-Supervised Learning

    GCN as smoothing, and why depth leads to over-smoothing.

  6. Xu et al., 2019

    How Powerful are Graph Neural Networks?

    Sum versus mean versus max, GIN, and the link to the WL test.

  7. Morris et al., 2019

    Weisfeiler and Leman Go Neural: Higher-order Graph Neural Networks

    The same bound, and networks that go beyond it.

  8. Brody, Alon and Yahav, 2022

    How Attentive are Graph Attention Networks?

    Why GAT ranks neighbors the same for every node, and GATv2.

  9. Bronstein, Bruna, Cohen and Veličković, 2021

    Geometric Deep Learning: Grids, Groups, Graphs, Geodesics, and Gauges

    Symmetry as the organizing idea behind CNNs, GNNs and Transformers.

  10. Hamilton, 2020

    Graph Representation Learning

    A short, free book covering all of the above in more depth.

A graph neural network is a short list of choices. What message each neighbor sends, how the messages are combined without caring about order, and how a node updates itself with the result. Stack a few of these layers and every node sees a little further.

The rest is trade-offs: more layers against over-smoothing, fixed weights against attention, means against sums. The attention here is a close relative of the one in the Transformer, and the dot products, matrices and gradients underneath are covered in The Math Behind AI.

← Back to blog