This is a read-only view of the notebook source: text and code, no outputs. To run it, open the live marimo server when the workstation tunnel is up, or run uv run marimo edit notebooks/cs224w/L03_graph_neural_networks.py.
import warnings warnings.filterwarnings("ignore", category=FutureWarning) warnings.filterwarnings( "ignore", message=".*Level Zero.*" ) # harmless Intel-GPU probe message import os import time import marimo as mo import numpy as np import pandas as pd import networkx as nx import matplotlib.pyplot as plt import torch from gl.datasets import adjusted_homophily, edge_homophily, node_data, planetoid from gl.nb import json_cache import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv from torch_geometric.utils import negative_sampling, subgraph from sklearn.datasets import load_digits from sklearn.linear_model import LogisticRegression SEED = 0 rng = np.random.default_rng(SEED) torch.manual_seed(SEED) torch.set_num_threads(4) # several open notebooks/kernels share the CPU; more threads = contention DATA_DIR = ( mo.notebook_dir().parents[1] / "data" ) # shared datasets (Cora etc. are cached here) CACHE_DIR = mo.notebook_dir() / "cache" CACHE_DIR.mkdir(exist_ok=True) plt.rcParams.update( { "figure.dpi": 100, "axes.spines.top": False, "axes.spines.right": False, } )
L03 · Graph Neural Networks
CS224W Machine Learning with Graphs · hands-on companion to Lecture 3
Audience: you did L02 (shallow embeddings) and know a PyTorch training loop. Prerequisites: matrix multiplication, softmax / cross-entropy.
By the end you can:
- compute one GCN layer by hand and in matrix form, and draw a node's computation graph;
- test permutation equivariance numerically, and say why GNNs have it and an MLP doesn't;
- implement a GCN from scratch in ~10 lines, check it against PyG's
GCNConv, and train it on Cora (vs. an MLP); - use a GNN inductively on nodes it never saw, train it unsupervised, and see a CNN as a special GNN.
In short. A node's neighbourhood defines its computation graph. Each layer, every node aggregates its neighbours' vectors with an order-independent operation, transforms the result with shared weights, and combines it with its own vector. After \(K\) layers a node "knows" its \(K\)-hop neighbourhood. Shared weights make the model inductive; symmetric aggregation makes it permutation equivariant.
Outline
- One GCN layer by hand — the lecture's 4-node example, then its matrix form \(D^{-1}A\).
- Computation graphs — unroll a Karate node's \(K\)-layer tree.
- Permutation equivariance — a numerical test.
- GCN from scratch — symmetric normalisation, checked against PyG
GCNConv. - Training on Cora — MLP vs. GCN; what the graph adds.
- Inductive use — predict nodes the model never saw. Unsupervised GNN embeddings.
- A CNN is a special GNN — images as grid graphs; and a first look at over-smoothing.
- Exercises, pitfall, research links.
1 · One GCN layer by hand
The basic layer (mean aggregation + self term): $\(\mathbf h_v^{(k+1)}=\sigma\Big(W_k\sum_{u\in N(v)}\frac{\mathbf h_u^{(k)}}{|N(v)|}+B_k\,\mathbf h_v^{(k)}\Big)\)$ Lecture example: edges 1–2, 1–3, 2–3, 3–4; scalar features \(x=(1,2,3,4)\); \(W=B=1\); identity \(\sigma\). Neighbour means: node 1 \((2+3)/2=2.5\), node 2 \((1+3)/2=2\), node 3 \((1+2+4)/3\approx2.33\), node 4 \(3\) → \(h^{(1)}=(3.5,4,5.33,7)\).
toy_edges = [ (0, 1), (0, 2), (1, 2), (2, 3), ] # nodes 1..4 of the slide are 0..3 here toy_A = torch.zeros(4, 4) for _u, _v in toy_edges: toy_A[_u, _v] = toy_A[_v, _u] = 1.0 toy_x = torch.tensor([[1.0], [2.0], [3.0], [4.0]]) def mean_layer(A, H, W=1.0, B=1.0): """h_v' = W * mean_{u in N(v)} h_u + B * h_v (identity non-linearity).""" D_inv = torch.diag(1.0 / A.sum(1)) return W * (D_inv @ A @ H) + B * H toy_h1 = mean_layer(toy_A, toy_x) toy_h2 = mean_layer(toy_A, toy_h1) pd.DataFrame( { "node (slide numbering)": [1, 2, 3, 4], "x": toy_x.squeeze().tolist(), "neighbour mean D⁻¹A x": ( torch.diag(1 / toy_A.sum(1)) @ toy_A @ toy_x ) .squeeze() .tolist(), "h¹": toy_h1.squeeze().tolist(), "h²": toy_h2.squeeze().tolist(), } ).round(3)
The \(D^{-1}A\,x\) column reproduces the slide's neighbour means (2.5, 2, 2.33, 3), and \(h^{(1)}=(3.5, 4, 5.33, 7)\). In matrix form the whole layer is one sparse–dense product for all nodes at once — why GNNs run fast on GPUs: $\(H^{(k+1)}=\sigma\big(D^{-1}A\,H^{(k)}W_k^\top + H^{(k)}B_k^\top\big).\)$ In the second layer node 1 reads \(h^{(1)}_3\), which already contains node 4's feature: after 2 layers node 1 "knows" node 4, two hops away. That is the computation graph.
2 · Computation graphs
Each node gets its own tree: the root is the node, its children are its neighbours, their children are their neighbours (including the root again), and so on for \(K\) levels. Every tree is evaluated with the same weights per level.
karate = nx.karate_club_graph() club = np.array( [karate.nodes[v]["club"] == "Officer" for v in karate], dtype=int ) def computation_tree(g, root, depth): """Unroll the depth-K computation graph of `root` as a tree (nodes may repeat).""" tree = nx.DiGraph() tree.add_node(0, label=root, level=0) frontier, nid = [0], 1 for level in range(1, depth + 1): nxt = [] for t in frontier: for u in sorted(g.neighbors(tree.nodes[t]["label"])): tree.add_node(nid, label=u, level=level) tree.add_edge(nid, t) # messages flow child → parent nxt.append(nid) nid += 1 frontier = nxt return tree tree_root = mo.ui.slider( 0, 33, value=11, label="root node", show_value=True ) tree_depth = mo.ui.slider(1, 3, value=2, label="layers K", show_value=True) mo.hstack([tree_root, tree_depth], justify="start")
_tree = computation_tree(karate, tree_root.value, tree_depth.value) _levels = {} for _n, _d in _tree.nodes(data=True): _levels.setdefault(_d["level"], []).append(_n) _pos = {} for _lvl, _ns in _levels.items(): for _i, _n in enumerate(_ns): _pos[_n] = ((_i + 0.5) / len(_ns), -_lvl) _fig, _ax = plt.subplots(figsize=(8, 2.8)) nx.draw_networkx_edges(_tree, _pos, ax=_ax, alpha=0.25, arrows=False) _cols = [ "#4C78A8" if club[_tree.nodes[n]["label"]] == 0 else "#F58518" for n in _tree ] nx.draw_networkx_nodes( _tree, _pos, ax=_ax, node_size=60 if len(_tree) < 60 else 12, node_color=_cols, ) if len(_tree) < 60: nx.draw_networkx_labels( _tree, _pos, labels={n: _tree.nodes[n]["label"] for n in _tree}, font_size=6, ax=_ax, ) _ax.set_title( f"computation tree of node {tree_root.value}: {len(_tree)} tree nodes, " f"{len({_tree.nodes[n]['label'] for n in _tree})} distinct graph nodes", fontsize=9, ) _ax.axis("off") _ax
Try it: node 11 (a leaf) has a tiny 1-layer tree (one child: node 0), but at \(K=2\) it inherits node 0's 16 neighbours (18 tree nodes) and at \(K=3\) already 87 tree nodes covering 26 of the 34 members. Hubs explode faster: node 0 has 688 tree nodes at \(K=3\), reaching all 34. Karate's diameter is 5, so a few layers already see most of the graph. That fast growth foreshadows over-smoothing (§7, L04) and the need for neighbour sampling on big graphs (L05).
3 · Permutation invariance and equivariance
For a permutation matrix \(P\): a graph-level function is invariant if \(f(PAP^\top, PX)=f(A,X)\); a node-level function is equivariant if \(f(PAP^\top, PX)=P\,f(A,X)\). Below, a 2-layer mean-GNN (random weights) and an MLP that reads each node's row of \(A\) are tested on Karate with a random relabelling.
A_karate = torch.tensor( · 68 lines
A_karate = torch.tensor( nx.to_numpy_array(karate, nodelist=range(34), weight=None), dtype=torch.float, ) X_karate = torch.randn( 34, 8, generator=torch.Generator().manual_seed(SEED) ) class MeanGNN(nn.Module): """K layers of h' = ReLU(W · mean_neighbours(h) + B · h).""" def __init__(self, d_in, d_hid, n_layers=2): super().__init__() dims = [d_in] + [d_hid] * n_layers self.W = nn.ModuleList( nn.Linear(a, b, bias=False) for a, b in zip(dims, dims[1:]) ) self.B = nn.ModuleList( nn.Linear(a, b) for a, b in zip(dims, dims[1:]) ) def forward(self, A, H): P = A / A.sum(1, keepdim=True).clamp(min=1) # D^-1 A for W, B in zip(self.W, self.B): H = F.relu(W(P @ H) + B(H)) return H torch.manual_seed(SEED) _gnn = MeanGNN(8, 16) _row_mlp = nn.Sequential( nn.Linear(34 + 8, 16), nn.ReLU(), nn.Linear(16, 16) ) # input: [row of A, features] _perm = torch.randperm(34, generator=torch.Generator().manual_seed(1)) _P = torch.eye(34)[_perm] with torch.no_grad(): _gnn_gap = ( ( _gnn(_P @ A_karate @ _P.T, _P @ X_karate) - _P @ _gnn(A_karate, X_karate) ) .abs() .max() .item() ) _mlp_in = lambda A, X: torch.cat([A, X], 1) _mlp_gap = ( ( _row_mlp(_mlp_in(_P @ A_karate @ _P.T, _P @ X_karate)) - _P @ _row_mlp(_mlp_in(A_karate, X_karate)) ) .abs() .max() .item() ) _readout_gap = abs( _gnn(_P @ A_karate @ _P.T, _P @ X_karate).sum().item() - _gnn(A_karate, X_karate).sum().item() ) pd.DataFrame( { "test": [ "GNN node outputs: f(PAPᵀ, PX) − P f(A, X)", "GNN + sum readout (graph level): |f(PAPᵀ,PX) − f(A,X)|", "MLP on [row of A, x]: f(PAPᵀ, PX) − P f(A, X)", ], "max |gap|": [_gnn_gap, _readout_gap, _mlp_gap], } )
The GNN gaps are at floating-point noise level (≈ 1e-7, and exactly 0 for the sum readout): it is equivariant per node, and summing node outputs gives an invariant graph-level readout. The row-MLP is not: a node's row of \(A\) is a positional one-hot code of its neighbours, and relabelling scrambles those positions. (Its gap, ≈ 0.27, is real, not rounding noise.)
4 · A GCN layer from scratch (Kipf & Welling)
The famous GCN uses symmetric normalisation with self-loops, \(\tilde A = A + I\), \(\tilde D_{vv}=\sum_u\tilde A_{vu}\): $\(H^{(k+1)}=\sigma\big(\tilde D^{-1/2}\tilde A\tilde D^{-1/2}H^{(k)}W_k\big)\quad\Longleftrightarrow\quad \mathbf h_v'=\sigma\Big(\sum_{u\in N(v)\cup\{v\}}\frac{W\mathbf h_u}{\sqrt{\tilde d_u\tilde d_v}}\Big).\)$ The self-loop folds \(B\) into \(W\); \(1/\sqrt{\tilde d_u\tilde d_v}\) down-weights messages from hubs. (The same idea as L01's \(1/\log\deg\).)
def gcn_norm_dense(edge_index, n): """Â = D^-1/2 (A + I) D^-1/2 as a dense n x n matrix (row = target, column = source).""" A = torch.zeros(n, n) A[edge_index[1], edge_index[0]] = 1.0 A_t = A + torch.eye(n) # self-loops d_inv_sqrt = A_t.sum(1).pow(-0.5) return d_inv_sqrt[:, None] * A_t * d_inv_sqrt[None, :] class GCNLayerScratch(nn.Module): def __init__(self, d_in, d_out): super().__init__() self.lin = nn.Linear(d_in, d_out, bias=False) self.bias = nn.Parameter(torch.zeros(d_out)) def forward(self, x, A_hat): return A_hat @ self.lin(x) + self.bias # transform, then aggregate cora = planetoid("Cora")[0] cora_A_hat = gcn_norm_dense(cora.edge_index, cora.num_nodes) torch.manual_seed(SEED) _mine, _pyg = ( GCNLayerScratch(cora.num_features, 16), GCNConv(cora.num_features, 16), ) with torch.no_grad(): _pyg.lin.weight.copy_(_mine.lin.weight) # same weights in both _gap = ( (_mine(cora.x, cora_A_hat) - _pyg(cora.x, cora.edge_index)) .abs() .max() ) mo.md( f"Scratch layer vs. `GCNConv` on Cora with identical weights: max |difference| = **{_gap:.2e}**. " "PyG computes the same thing with sparse scatter operations instead of a dense $n\\times n$ matrix." )
5 · Training on Cora: does the graph help?
Semi-supervised node classification: only 140 papers are labelled (20 per topic). Both models get the same features, loss and optimiser; the GCN additionally mixes each paper with its citation neighbourhood. $\(\mathcal L=-\sum_{v\in\text{train}}\log \text{softmax}(\mathbf h_v^{(K)})_{y_v}\)$
class GCN(nn.Module): · 74 lines
class GCN(nn.Module): """2-layer GCN. Dropout only on the hidden layer: dropping out the 2708×1433 input every epoch costs more CPU time than the whole model, for little accuracy.""" def __init__(self, d_in, d_hid, n_cls, dropout=0.5): super().__init__() self.c1, self.c2, self.p = ( GCNLayerScratch(d_in, d_hid), GCNLayerScratch(d_hid, n_cls), dropout, ) def forward(self, x, A_hat): x = F.relu(self.c1(x, A_hat)) # layer 1: 1-hop x = F.dropout(x, self.p, self.training) return self.c2(x, A_hat) # layer 2: 2-hop class MLP(nn.Module): def __init__(self, d_in, d_hid, n_cls, dropout=0.5): super().__init__() self.l1, self.l2, self.p = ( nn.Linear(d_in, d_hid), nn.Linear(d_hid, n_cls), dropout, ) def forward(self, x, A_hat=None): # ignores the graph x = F.relu(self.l1(x)) x = F.dropout(x, self.p, self.training) return self.l2(x) def train_node_classifier( model, data, A_hat, epochs=200, lr=0.01, wd=5e-4, train_mask=None ): """Full-batch training; returns (history DataFrame, test accuracy at the best-validation epoch).""" train_mask = data.train_mask if train_mask is None else train_mask opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=wd) hist, best_val, test_at_best = [], 0, 0 for ep in range(epochs): model.train() opt.zero_grad() out = model(data.x, A_hat) loss = F.cross_entropy(out[train_mask], data.y[train_mask]) loss.backward() opt.step() model.eval() with torch.no_grad(): pred = model(data.x, A_hat).argmax(1) accs = [ (pred[m] == data.y[m]).float().mean().item() for m in (train_mask, data.val_mask, data.test_mask) ] hist.append([loss.item(), *accs]) if accs[1] >= best_val: best_val, test_at_best = accs[1], accs[2] return pd.DataFrame( hist, columns=["loss", "train", "val", "test"] ), test_at_best cora_hidden = mo.ui.slider( steps=[4, 8, 16, 32, 64], value=16, label="hidden units", show_value=True, ) cora_seeds = mo.ui.slider(1, 5, value=3, label="seeds", show_value=True) mo.vstack( [ mo.md( "**Try it:** shrink the hidden layer to 4 units, or add seeds to see the spread." ), mo.hstack([cora_hidden, cora_seeds], justify="start"), ] )
_t = time.time() _rows, _curves = [], {} for _name, _cls in [("MLP", MLP), ("GCN", GCN)]: for _s in range(cora_seeds.value): torch.manual_seed(_s) _hist, _test = train_node_classifier( _cls(cora.num_features, cora_hidden.value, 7), cora, cora_A_hat ) _rows.append( { "model": _name, "seed": _s, "test acc (best val epoch)": _test, } ) _curves.setdefault(_name, _hist) cora_results = pd.DataFrame(_rows) _fig, _ax = plt.subplots(figsize=(6, 2.8)) for _name, _h in _curves.items(): _ax.plot(_h["val"], label=f"{_name} val") _ax.plot(_h["train"], "--", alpha=0.5, label=f"{_name} train") _ax.set_xlabel("epoch") _ax.set_ylabel("accuracy") _ax.legend(fontsize=7, ncol=2) _ax.set_title("seed 0 learning curves", fontsize=9) _summary = ( cora_results.groupby("model")["test acc (best val epoch)"] .agg(["mean", "std"]) .round(3) ) mo.hstack( [ _fig, mo.vstack( [ _summary, mo.md( f"{len(_rows)} training runs in {time.time() - _t:.1f} s" ), ] ), ], widths=[3, 2], )
With only 140 labels, the GCN reaches ≈ 0.79 test accuracy versus ≈ 0.54 for the MLP: the graph adds ≈ 25 points. The learning curves show why. The MLP fits the 140 training papers (train ≈ 1.0) but cannot generalise from so few examples; the GCN's predictions for unlabelled papers borrow evidence from their cited/citing neighbours (homophily).
5b · When does the graph help? Cora against real social networks
The ≈ 25-point gain above has a precondition: neighbours tend to share the label (homophily). Measure it as the share of edges whose two ends have the same label (edge homophily), and correct it for class imbalance (adjusted homophily, Platonov et al. 2023: 0 means "no more same-label edges than random wiring by degree would give"). Raw edge homophily misleads on binary tasks: two balanced classes give 0.5 by chance.
Six graphs from the repo's catalogue (gl.datasets): Cora, two homophilous social networks (LastFM Asia friendships,
labelled by country; Facebook verified pages, by page type) and three where the label is a property of the person,
not of the friendship (Twitch England, mature content; Deezer Europe, gender; Penn94 Facebook friendships, gender).
Same MLP and GCN as above (PyG's sparse GCNConv, 64 hidden units), a random 50/25/25 split, 3 seeds.
Predict first: on which of the six does the GCN lose to the MLP? Rank them by how much the graph helps, and check whether homophily alone predicts your ranking.
HOMOPHILY_SETS = ["cora", "lastfm-asia", "facebook-pages", "twitch-en", "deezer-europe", " · 52 lines
HOMOPHILY_SETS = ["cora", "lastfm-asia", "facebook-pages", "twitch-en", "deezer-europe", "penn94"] class SparseGCN(nn.Module): def __init__(self, d_in, d_hid, n_cls): super().__init__() self.c1, self.c2 = GCNConv(d_in, d_hid), GCNConv(d_hid, n_cls) def forward(self, x, ei): return self.c2(F.dropout(F.relu(self.c1(x, ei)), 0.5, self.training), ei) class SparseMLP(nn.Module): def __init__(self, d_in, d_hid, n_cls): super().__init__() self.l1, self.l2 = nn.Linear(d_in, d_hid), nn.Linear(d_hid, n_cls) def forward(self, x, ei=None): return self.l2(F.dropout(F.relu(self.l1(x)), 0.5, self.training)) def homophily_benchmark(name): d = node_data(name) x = (d.x - d.x.mean(0)) / (d.x.std(0) + 1e-6) y = d.y lab = torch.nonzero(y >= 0).squeeze(1) # Penn94 has unknown genders (-1) n_cls = int(y.max()) + 1 out = {"nodes": d.num_nodes, "edges": d.edge_index.size(1) // 2, "classes": n_cls, "edge homophily": edge_homophily(d.edge_index, y), "adjusted homophily": adjusted_homophily(d.edge_index, y), "majority": float(torch.bincount(y[lab]).max() / len(lab)), "MLP": [], "GCN": []} for seed in range(3): g = torch.Generator().manual_seed(seed) perm = lab[torch.randperm(len(lab), generator=g)] tr, va, te = perm[: len(perm) // 2], perm[len(perm) // 2 : 3 * len(perm) // 4], perm[3 * len(perm) // 4 :] for mname, cls in [("MLP", SparseMLP), ("GCN", SparseGCN)]: torch.manual_seed(seed) model = cls(x.size(1), 64, n_cls) opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) best_val, test_at_best = 0.0, 0.0 for _ in range(200): model.train() opt.zero_grad() F.cross_entropy(model(x, d.edge_index)[tr], y[tr]).backward() opt.step() model.eval() with torch.no_grad(): pred = model(x, d.edge_index).argmax(1) val = (pred[va] == y[va]).float().mean().item() if val >= best_val: best_val, test_at_best = val, (pred[te] == y[te]).float().mean().item() out[mname].append(test_at_best) return out homophily_run = mo.ui.run_button(label="Run on six graphs (≈ 10 min the first time, downloads ≈ 30 MB; then cached)") homophily_run
_cache = CACHE_DIR / "L03_homophily.json" mo.stop( not (homophily_run.value or os.environ.get("GL_RUN_EXPERIMENTS") == "1") and not _cache.exists(), mo.md("Press the button. Results are cached in `notebooks/cs224w/cache/L03_homophily.json`."), ) _res = {n: json_cache(_cache, {"dataset": n, "seeds": 3, "epochs": 200, "hidden": 64, "v": 1}, lambda n=n: homophily_benchmark(n)) for n in HOMOPHILY_SETS} homophily_table = pd.DataFrame( {n: {"nodes": r["nodes"], "classes": r["classes"], "edge homophily": r["edge homophily"], "adjusted homophily": r["adjusted homophily"], "majority class": r["majority"], "MLP": np.mean(r["MLP"]), "GCN": np.mean(r["GCN"]), "GCN − MLP": np.mean(r["GCN"]) - np.mean(r["MLP"])} for n, r in _res.items()} ).T.sort_values("adjusted homophily", ascending=False) _fig, _ax = plt.subplots(figsize=(5.2, 3.2)) _ax.axhline(0, c="grey", lw=0.8) for _n, _r in homophily_table.iterrows(): _ax.scatter(_r["adjusted homophily"], _r["GCN − MLP"], s=40, c="#c0392b" if _r["GCN − MLP"] < 0 else "#4c78a8") _ax.annotate(_n, (_r["adjusted homophily"], _r["GCN − MLP"]), fontsize=7, xytext=(4, 3), textcoords="offset points") _ax.set_xlabel("adjusted homophily (0 = random wiring)") _ax.set_ylabel("GCN − MLP test accuracy") _ax.set_title("What the graph adds, against homophily", fontsize=9) plt.tight_layout() mo.hstack([_fig, homophily_table.round(3)], widths=[2, 3])
_t = homophily_table · 23 lines
_t = homophily_table _lose = [n for n in _t.index if _t.loc[n, "GCN − MLP"] < 0] _high = [n for n in _t.index if _t.loc[n, "adjusted homophily"] > 0.5] _low = [n for n in _t.index if n not in _high] _odd = [n for n in _low if _t.loc[n, "GCN − MLP"] > 0.03] mo.md(rf""" **Reading the run.** On the homophilous graphs ({", ".join(_high)}; adjusted homophily {_t.loc[_high, "adjusted homophily"].min():.2f}–{_t.loc[_high, "adjusted homophily"].max():.2f}) the GCN gains {_t.loc[_high, "GCN − MLP"].min():+.3f} to {_t.loc[_high, "GCN − MLP"].max():+.3f} over the MLP: neighbours share the label, and averaging them is extra evidence. On the low-homophily graphs ({", ".join(_low)}) the picture splits: {", ".join(f"{n} {_t.loc[n, 'GCN − MLP']:+.3f}" for n in _low)}{" — the GCN *loses* on " + ", ".join(_lose) if _lose else ""}. Raw edge homophily would have misled here: Twitch and Deezer score {_t.loc["twitch-en", "edge homophily"]:.2f} and {_t.loc["deezer-europe", "edge homophily"]:.2f}, which looks moderate until you remember that a binary label gives about 0.5 by chance. {("**Low homophily is not the same as useless neighbours.** " + ", ".join(_odd) + " has almost no label homophily, yet the GCN still gains. Homophily asks whether neighbours share *my label*; a GNN only needs the neighbourhood to be *informative* about it. A plausible reading, not tested here: in a university friendship graph who your friends are (their dorm, year, major) can say something about you even when their gender does not match yours. Adjusted homophily is a warning light, not a verdict: compare against an MLP before concluding the graph does not help.") if _odd else ""} For a research question on social data this is the first check to run: *is my label homophilous on my graph, and does a graph model beat a feature-only one?* Political camps on a retweet graph are usually homophilous; "is this account a bot" or a demographic attribute often is not, and then a mean-aggregating GCN can average the signal away. L04's GraphSAGE (separate self and neighbour weights) and the heterophily-aware designs of later lectures exist for exactly that case. """)
6 · Inductive use: predicting nodes the model never saw
Shallow embeddings (L02) need a row per node. A GNN is a function of features + neighbourhood, so it can embed new nodes. Experiment: hide 30% of the papers and all their edges during training; afterwards plug them back into the graph and classify them with the trained weights, without retraining.
def inductive_experiment(hide_frac=0.3, seed=SEED): · 47 lines
def inductive_experiment(hide_frac=0.3, seed=SEED): g = torch.Generator().manual_seed(seed) unseen = torch.zeros(cora.num_nodes, dtype=torch.bool) unseen[ torch.randperm(cora.num_nodes, generator=g)[ : int(hide_frac * cora.num_nodes) ] ] = True seen_idx = (~unseen).nonzero().squeeze() sub_ei, _ = subgraph( seen_idx, cora.edge_index, relabel_nodes=True, num_nodes=cora.num_nodes, ) sub = cora.subgraph( seen_idx ) # features, labels, masks of seen nodes only torch.manual_seed(seed) model = GCN(cora.num_features, 16, 7) train_node_classifier( model, sub, gcn_norm_dense(sub_ei, sub.num_nodes).to_sparse() ) model.eval() with torch.no_grad(): pred = model(cora.x, cora_A_hat_sparse).argmax( 1 ) # full graph, new nodes included return { "train labels used (seen nodes only)": int(sub.train_mask.sum()), "accuracy on the UNSEEN 30% of nodes": round( (pred[unseen] == cora.y[unseen]).float().mean().item(), 3 ), "accuracy on seen test nodes": round( ( pred[~unseen & cora.test_mask] == cora.y[~unseen & cora.test_mask] ) .float() .mean() .item(), 3, ), } cora_A_hat_sparse = cora_A_hat.to_sparse() inductive_experiment()
Unsupervised training: the L02 objective with a GNN encoder
Without labels, train the GNN so that linked papers get similar embeddings: positive pairs = edges, negatives = random pairs, decoder \(\sigma(\mathbf z_u^\top\mathbf z_v)\), binary cross-entropy. Same objective as L02, but \(\mathbf z_v = \text{GNN}(v)\) instead of a free vector. Then fit logistic regression on the frozen embeddings, as in L02.
def train_unsupervised_gcn(epochs=100, dim=64, seed=SEED): torch.manual_seed(seed) enc = GCN(cora.num_features, 128, dim, dropout=0.0) opt = torch.optim.Adam(enc.parameters(), lr=0.01) pos = cora.edge_index for _ in range(epochs): enc.train() opt.zero_grad() z = enc(cora.x, cora_A_hat_sparse) neg = negative_sampling( pos, num_nodes=cora.num_nodes, num_neg_samples=pos.size(1) ) logits = torch.cat( [ (z[pos[0]] * z[pos[1]]).sum(1), (z[neg[0]] * z[neg[1]]).sum(1), ] ) labels = torch.cat( [torch.ones(pos.size(1)), torch.zeros(neg.size(1))] ) loss = F.binary_cross_entropy_with_logits(logits, labels) loss.backward() opt.step() enc.eval() with torch.no_grad(): return enc(cora.x, cora_A_hat_sparse).numpy(), loss.item() _t = time.time() unsup_Z, _loss = train_unsupervised_gcn() _tr, _te, _y = ( cora.train_mask.numpy(), cora.test_mask.numpy(), cora.y.numpy(), ) _acc = ( LogisticRegression(max_iter=3000) .fit(unsup_Z[_tr], _y[_tr]) .score(unsup_Z[_te], _y[_te]) ) mo.md( f"Unsupervised GCN embeddings → logistic regression on 140 labels: test accuracy **{_acc:.3f}** " f"(final link loss {_loss:.3f}, {time.time() - _t:.1f} s). For comparison, L02's node2vec embeddings gave ≈ 0.65." )
Inductive: trained without ever seeing 30% of the papers (or their 97-label-sized training set), the GCN classifies those unseen papers at 0.75, the same as seen test papers (0.754). Nothing was retrained; the new nodes simply got computation graphs.
Unsupervised: with zero labels in training, GCN embeddings give 0.67, slightly above node2vec's ≈ 0.65, because the encoder also uses the bag-of-words features. (Supervised end-to-end training, §5, is still far better: 0.79.)
7 · A CNN is a special GNN — and a first look at over-smoothing
An image is a grid graph: pixels are nodes, each linked to its 8 neighbours. A \(3\times3\) convolution is message passing where each relative position gets its own weight. A GNN can't do that (neighbours are a set), so a mean-aggregation GNN on a grid is a fixed blur. Stack many layers and every pixel converges to the same value.
_digits = load_digits() digit_img = torch.tensor( _digits.images[0], dtype=torch.float ) # an 8x8 "0" grid = nx.grid_2d_graph(8, 8) grid.add_edges_from( ((x, y), (x + 1, y + 1)) for x in range(7) for y in range(7) ) # diagonals → 8-neighbourhood grid.add_edges_from( ((x + 1, y), (x, y + 1)) for x in range(7) for y in range(7) ) _nodes = [(r_, c_) for r_ in range(8) for c_ in range(8)] grid_A = torch.tensor( nx.to_numpy_array(grid, nodelist=_nodes), dtype=torch.float ) grid_P = (grid_A + torch.eye(64)) / (grid_A + torch.eye(64)).sum( 1, keepdim=True ) # mean over self + neighbours smooth_layers = mo.ui.slider( 0, 64, value=2, label="mean-aggregation layers", show_value=True ) smooth_layers
_h = digit_img.reshape(64, 1) _spread = [] for _k in range(65): _spread.append(_h.std().item()) if _k == smooth_layers.value: _shown = _h.clone() _h = grid_P @ _h _fig, _axs = plt.subplots( 1, 3, figsize=(8, 2.6), gridspec_kw={"width_ratios": [1, 1, 1.6]} ) _axs[0].imshow(digit_img, cmap="gray_r") _axs[0].set_title("input (8×8 digit)", fontsize=9) _axs[1].imshow(_shown.reshape(8, 8), cmap="gray_r") _axs[1].set_title(f"after {smooth_layers.value} layers", fontsize=9) for _a in _axs[:2]: _a.set_xticks([]) _a.set_yticks([]) _axs[2].plot(_spread) _axs[2].axvline(smooth_layers.value, color="crimson", ls=":") _axs[2].set_yscale("log") _axs[2].set_xlabel("layers") _axs[2].set_title("std of pixel values across nodes", fontsize=9) plt.tight_layout() _fig
Try it: at 2 layers the "0" is a blurred ring; by 10 layers it is a grey smudge. The spread of pixel values falls from 5.2 to 1.7 (2 layers), 0.30 (10 layers) and 0.009 (64 layers): repeated averaging converges to a constant. This is over-smoothing, which you prove in HW1 Q1.5 (an irreducible, aperiodic random walk has one stationary distribution). A CNN avoids it because its 9 position-specific weights can implement edge detectors, not just averages.
8 · Exercises
Exercise 1 (by hand). In the §1 example, set \(W=2\), \(B=0\). What is \(h^{(1)}\) for slide node 3? → ex1_h3.
Exercise 2 (HW1 Q1.3–1.4). Write lazy_layer(A, H) = mean aggregation with a skip connection,
\(\tfrac12 H+\tfrac12 D^{-1}AH\) (no weights). It is one step of a lazy random walk.
Exercise 3 (HW1 Q1.6). Make a GNN run BFS: node features are 0/1 flags (1 = reached). Write bfs_layer(A, h) so
that after \(k\) layers exactly the nodes within \(k\) hops of the source are flagged. Hint: max-aggregation.
ex1_h3 = None · 7 lines
ex1_h3 = None def lazy_layer(A, H): return None # replace def bfs_layer(A, h): return None
def _verdict(ok, answered): · 76 lines
def _verdict(ok, answered): if not answered: return mo.callout(mo.md("Not answered yet."), kind="neutral") return mo.callout( mo.md("Correct!" if ok else "Not yet — try again or open a hint."), kind="success" if ok else "warn", ) def _check_lazy(): try: out = lazy_layer(A_karate, X_karate) if out is None: return _verdict(False, False) ref = ( 0.5 * X_karate + 0.5 * (A_karate / A_karate.sum(1, keepdim=True)) @ X_karate ) return _verdict(torch.allclose(out, ref, atol=1e-5), True) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") def _check_bfs(): try: h = torch.zeros(34, 1) h[0] = 1.0 dist = nx.single_source_shortest_path_length(karate, 0) for k in range(1, 4): h = bfs_layer(A_karate, h) if h is None: return _verdict(False, False) want = torch.tensor( [[1.0 if dist[v] <= k else 0.0] for v in range(34)] ) if not torch.allclose(h.float(), want): return _verdict(False, True) return _verdict(True, True) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_h3 is not None and abs(ex1_h3 - 14 / 3) < 1e-2, ex1_h3 is not None, ), mo.md("**Exercise 2**"), _check_lazy(), mo.md("**Exercise 3**"), _check_bfs(), mo.accordion( { "Solution 1": mo.md( r"$W\cdot\text{mean}(1,2,4)+0=2\cdot 7/3\approx 4.667$." ), "Solution 2": mo.md("""```python def lazy_layer(A, H): P = A / A.sum(1, keepdim=True) # D^-1 A (row-normalised) return 0.5 * H + 0.5 * P @ H ``` Without the skip, repeated application converges to the stationary distribution (over-smoothing); the skip keeps half of each node's own signal per step, which slows convergence."""), "Hint 3": mo.md( "Message = neighbour's flag; AGGREGATE = **max** over neighbours; UPDATE = max(own flag, aggregate)." ), "Solution 3": mo.md("""```python def bfs_layer(A, h): neighbour_max = (A * h.T).max(dim=1, keepdim=True).values # max over neighbours' flags return torch.maximum(h, neighbour_max) ``` Sum or mean would also give non-zeros, but not exact 0/1 flags; **max** is the aggregator that matches the algorithm (an example of *algorithmic alignment*)."""), } ), ] )
9 · Pitfall: aggregating without normalisation
Plain sum aggregation without degree normalisation multiplies the signal by roughly the degree at every layer. Start every Karate node at 1 and apply 5 layers: with \(A\) alone, hub node 0 ends at 25,499 and leaf node 11 at 3,390; with GCN's \(\tilde D^{-1/2}\tilde A\tilde D^{-1/2}\) they stay at 1.69 and 0.58. Unnormalised values blow up activations and gradients and make the output scale depend on degree. Fix: normalise (mean, symmetric GCN norm) or use normalisation layers (L04/L05). (L06 shows that sum is still the most expressive aggregator. Scale it, don't drop it.)
10 · Connecting to your research (SNA on X/Twitter)
-
Semi-supervised bot / stance labelling: you have a few hundred labelled accounts and a big retweet graph, exactly the Cora setting of §5. A 2-layer GCN on (profile features + retweet edges) should beat a feature-only MLP if labels are homophilous (camps retweet camps).
-
New accounts every day → you need the inductive property of §6: the same trained model scores accounts created after training.
-
No labels yet? The unsupervised link objective of §6 gives account embeddings for clustering, which can feed your existing cluster analysis in
usa-active/. -
Caveat: if labels are not homophilous (bots retweet humans, not bots), neighbour averaging can hurt. Check label homophily on your labelled subset first.
Summary
- GNN layer = aggregate neighbours (sum/mean, order-free) → transform with shared weights → combine with self.
- Matrix form \(\sigma(\tilde D^{-1/2}\tilde A\tilde D^{-1/2}HW)\): one sparse product per layer. Ours matches PyG's
GCNConvto 3e-8. - Equivariance holds by construction; graph-level invariance comes from a symmetric readout.
- On Cora: GCN ≈ 0.79 vs. MLP ≈ 0.54 with 140 labels; equally good on unseen nodes (inductive); 0.67 even without labels.
- CNN = GNN with position-specific weights; plain averaging over-smooths.
Next — L04 · A general perspective on GNNs: messages, aggregations (GraphSAGE, GAT), layer connectivity and skip connections.