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/L06_theory_of_gnns.py.
import warnings · 49 lines
import warnings warnings.filterwarnings("ignore", category=FutureWarning) # harmless Intel-GPU probe message printed by torch on this machine warnings.filterwarnings("ignore", message=".*Level Zero.*") import hashlib import json import time from collections import Counter import marimo as mo from gl.nb import json_cache, mean_std, reveal import numpy as np import pandas as pd import networkx as nx import matplotlib.pyplot as plt import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.data import Data from torch_geometric.datasets import TUDataset from torch_geometric.loader import DataLoader from torch_geometric.nn import ( GCNConv, GINConv, global_add_pool, global_mean_pool, ) from torch_geometric.utils import from_networkx from sklearn.svm import SVC from sklearn.model_selection import StratifiedKFold, cross_val_score SEED = 0 rng = np.random.default_rng(SEED) torch.manual_seed(SEED) # several open notebooks share the CPU; more threads = contention torch.set_num_threads(4) # shared datasets (Cora etc. are cached here) and this folder's disk cache DATA_DIR = mo.notebook_dir().parents[1] / "data" 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, } )
L06 · Theory of GNNs — how expressive are they?
CS224W Machine Learning with Graphs · hands-on companion to Lecture 6
Audience: you did L03–L05 (GCN/SAGE/GAT, training). Prerequisites: the idea of a multiset; graph isomorphism at an intuitive level.
By the end you can:
- explain why a message-passing GNN can only see a node's rooted subtree, and test whether an aggregator is injective on multisets;
- implement the 1-WL colour-refinement test and run it on the lecture's hexagon-vs-two-triangles example;
- build GIN and show, on a counting task and on real graph-classification benchmarks, where it beats mean-aggregation GCN;
- show the WL ceiling: graphs no MPNN can tell apart, even connected vs. disconnected.
In short. "Power" = giving different embeddings to different structures. A GNN embeds a node by recursively aggregating its rooted subtree. If every aggregation is injective on the multiset of neighbour vectors, different subtrees get different embeddings — the best any MPNN can do. Mean can't tell \(\{a,b\}\) from \(\{a,a,b,b\}\); max can't tell \(\{a,b\}\) from \(\{a,a,b\}\); sum (of a suitable encoding) can. Wrap it in MLPs → GIN, exactly as powerful as 1-WL.
Outline
- Aggregators on multisets — mean, max, sum: which can tell which neighbourhoods apart?
- The WL test — colour refinement from scratch; hexagon vs. two triangles; more pairs.
- GIN — sum + MLP; a counting task where GCN fails and GIN wins.
- Real benchmarks — GIN vs. GCN on MUTAG / PROTEINS / IMDB-BINARY, and the classic WL-kernel + SVM.
- The WL ceiling — what no MPNN can see.
- Exercises (HW1 Q3), pitfall, research links.
1 · Aggregators on multisets
A node's neighbours form a multiset of vectors (a set that allows repeats). An aggregator is injective if different multisets always give different outputs. Colours are one-hot: \(a=(1,0)\), \(b=(0,1)\).
Predict first. For each pair in the dropdown below, decide on paper which of mean, max and sum can tell the two multisets apart. Only then step through the dropdown.
multiset_pairs = { "{a, b} vs {a, a, b, b}": ("ab", "aabb"), "{a, b} vs {a, a, b}": ("ab", "aab"), "{a} vs {a, a}": ("a", "aa"), "{a, a, b} vs {a, b, b}": ("aab", "abb"), } pair_picker = mo.ui.dropdown( list(multiset_pairs), value="{a, b} vs {a, a, b, b}", label="neighbour multisets", ) pair_picker
_onehot = {"a": torch.tensor([1.0, 0.0]), "b": torch.tensor([0.0, 1.0])} _rows = [] for _name, _f in [ ("mean (GCN)", lambda X: X.mean(0)), ("max (GraphSAGE-pool)", lambda X: X.max(0).values), ("sum (GIN)", lambda X: X.sum(0)), ]: _out = [ _f(torch.stack([_onehot[c] for c in s])) for s in multiset_pairs[pair_picker.value] ] _rows.append( { "aggregator": _name, "left": _out[0].tolist(), "right": _out[1].tolist(), "distinguishes?": "yes" if not torch.allclose(*_out) else "NO", } ) pd.DataFrame(_rows)
_rev = reveal( · 27 lines
_rev = reveal( r""" | pair | mean | max | sum | |---|---|---|---| | {a,b} vs {a,a,b,b} | ✗ same proportions | ✗ same set | ✓ | | {a,b} vs {a,a,b} | ✓ | ✗ same set | ✓ | | {a} vs {a,a} | ✗ | ✗ | ✓ | | {a,a,b} vs {a,b,b} | ✓ | ✗ | ✓ | **Mean** sees only proportions, **max** sees only *which* colours occur (the underlying set), **sum** of one-hot vectors is the full **count vector**, so it separates every pair. With continuous features you need an MLP before the sum, which GIN provides: $\text{MLP}\big(\sum_u f(\mathbf h_u)\big)$ can approximate any injective multiset function (Xu et al., 2019). """, predict="Did your paper answers match the dropdown? The table below is the full answer, with the reason for each cell.", env="exact: no randomness involved", ) _next = mo.md(r""" ## 2 · The Weisfeiler–Lehman (1-WL) test Colour refinement: every node starts with the same colour; each round, a node's new colour is a hash of (its colour, the multiset of its neighbours' colours). If the two graphs' colour histograms ever differ, they are **not** isomorphic; if the histograms stay equal until the colouring stabilises, WL says "possibly isomorphic". A GNN with injective aggregation computes exactly these colours as vectors, so **no MPNN can distinguish what WL can't**. > **Predict first.** For each pair in the next dropdown, guess: does WL separate them, and at which round? """) mo.vstack([_rev, _next])
def wl_refine(graphs, max_iter=10): · 90 lines
def wl_refine(graphs, max_iter=10): """Run 1-WL jointly on several graphs (shared colour dictionary). Returns a list of per-iteration colour dicts for each graph, and per-iteration histograms.""" colours = [{v: 0 for v in g} for g in graphs] history = [[dict(c) for c in colours]] for _ in range(max_iter): palette = {} signatures = [ {v: (c[v], tuple(sorted(c[u] for u in g[v]))) for v in g} for g, c in zip(graphs, colours) ] for sig in signatures: for s in sorted(set(sig.values())): palette.setdefault(s, len(palette)) new = [ {v: palette[s[v]] for v in g} for g, s in zip(graphs, signatures) ] stable = all( len(set(n.values())) == len(set(c.values())) for n, c in zip(new, colours) ) colours = new history.append([dict(c) for c in colours]) if stable: break return history def wl_histograms(history): return [ [tuple(sorted(Counter(c.values()).items())) for c in step] for step in history ] def decalin(): return nx.Graph( [ (0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0), (0, 6), (6, 7), (7, 8), (8, 9), (9, 5), ] ) def bicyclopentyl(): return nx.Graph( [ (0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 5), (5, 6), (6, 7), (7, 8), (8, 9), (9, 5), ] ) wl_pairs = { "hexagon C6 vs two triangles": ( nx.cycle_graph(6), nx.disjoint_union(nx.cycle_graph(3), nx.cycle_graph(3)), ), "triangular prism vs K3,3 (both 3-regular)": ( nx.circular_ladder_graph(3), nx.complete_bipartite_graph(3, 3), ), "decalin vs bicyclopentyl (molecules)": (decalin(), bicyclopentyl()), "path P6 vs star S5": (nx.path_graph(6), nx.star_graph(5)), "two random trees, 8 nodes": ( nx.random_labeled_tree(8, seed=1), nx.random_labeled_tree(8, seed=2), ), } wl_picker = mo.ui.dropdown( list(wl_pairs), value="hexagon C6 vs two triangles", label="graph pair", ) wl_picker
_g1, _g2 = wl_pairs[wl_picker.value] _hist = wl_refine([_g1, _g2]) _h = wl_histograms(_hist) _diff_at = next((i for i, (a, b) in enumerate(_h) if a != b), None) _iso = nx.is_isomorphic(_g1, _g2) _cmap = plt.get_cmap("tab10") _fig, _axs = plt.subplots(1, 2, figsize=(7, 2.8)) for _ax, _g, _c in zip(_axs, (_g1, _g2), _hist[-1]): nx.draw( _g, nx.kamada_kawai_layout(_g), ax=_ax, node_size=160, node_color=[_cmap(_c[v] % 10) for v in _g], ) _axs[0].set_title("G1 (final WL colours)", fontsize=9) _axs[1].set_title("G2", fontsize=9) _rows = [ { "iteration": i, "G1 colour histogram": str(a), "G2 colour histogram": str(b), "equal?": a == b, } for i, (a, b) in enumerate(_h) ] mo.vstack( [ _fig, mo.md( f"**WL verdict:** {'NOT isomorphic (histograms differ at iteration ' + str(_diff_at) + ')' if _diff_at is not None else 'possibly isomorphic (never separated)'} · " f"**truth (exhaustive check):** {'isomorphic' if _iso else 'not isomorphic'}" ), pd.DataFrame(_rows), ] )
_rev = reveal( · 23 lines
_rev = reveal( r""" The lecture's worked example — hexagon $C_6$ vs. two triangles — keeps **identical** histograms; colouring is stable after one round. WL (and so GCN, GAT, GraphSAGE, GIN, …) says "possibly isomorphic", yet one graph is connected and the other isn't. The triangular prism vs. $K_{3,3}$ (both 3-regular) and **decalin vs. bicyclopentyl** (two real molecules) also fool WL. Path vs. star and the two random trees are separated at the first round (different degrees). WL succeeds on almost all real graphs; it fails on highly regular structures. (Exercise 4 below asks you to prove the regular case.) """, predict="Which of the five pairs fooled WL? What do the fooling pairs have in common?", env="exact: WL is deterministic", ) _next = mo.md(r""" ## 3 · GIN: the most expressive message-passing GNN $$\mathbf h_v^{(k)}=\text{MLP}^{(k)}\Big((1+\epsilon^{(k)})\,\mathbf h_v^{(k-1)}+\sum_{u\in N(v)}\mathbf h_u^{(k-1)}\Big)$$ Sum (injective on multisets) + MLP (an injective map of the result) + a learnable weight on the node itself. **Counting task.** Random graphs with **no features** (every node gets 1). Predict each node's **degree** (0–7, capped). > **Predict first.** Three 2-layer models: mean aggregation, GCN (degree-normalised sum), GIN (sum + MLP). Which can learn > to output a node's degree from constant inputs, and which are stuck near the majority class (≈ 0.18)? """) mo.vstack([_rev, _next])
def mlp(d_in, d_out): · 107 lines
def mlp(d_in, d_out): return nn.Sequential( nn.Linear(d_in, d_out), nn.BatchNorm1d(d_out), nn.ReLU(), nn.Linear(d_out, d_out), ) class MeanConv(nn.Module): """h_v' = MLP(h_v + mean_{u∈N(v)} h_u): the GIN layer with mean instead of sum.""" def __init__(self, d_in, d_out): super().__init__() self.mlp = mlp(d_in, d_out) def forward(self, x, edge_index): src, dst = edge_index agg = torch.zeros_like(x).index_add_(0, dst, x[src]) deg = ( torch.bincount(dst, minlength=x.size(0)) .clamp(min=1) .unsqueeze(1) ) return self.mlp(x + agg / deg) class NodeNet(nn.Module): def __init__(self, kind, d_hid=32, n_out=8, n_layers=2): super().__init__() make = { "mean aggregation": lambda a: MeanConv(a, d_hid), "GCN (degree-normalised sum)": lambda a: GCNConv(a, d_hid), "GIN (sum + MLP)": lambda a: GINConv( mlp(a, d_hid), train_eps=True ), }[kind] self.convs = nn.ModuleList( make(1 if i == 0 else d_hid) for i in range(n_layers) ) self.head = nn.Linear(d_hid, n_out) def forward(self, x, edge_index): for conv in self.convs: x = F.relu(conv(x, edge_index)) return self.head(x) def degree_task(n_graphs=60, n=30, seed=SEED): r = np.random.default_rng(seed) graphs = [ nx.gnp_random_graph( n, r.uniform(0.05, 0.2), seed=int(r.integers(1e9)) ) for _ in range(n_graphs) ] big = nx.disjoint_union_all(graphs) data = from_networkx(big) data.x = torch.ones(data.num_nodes, 1) data.y = torch.tensor([min(d, 7) for _, d in big.degree()]) data.train_mask = ( torch.arange(data.num_nodes) < data.num_nodes * 2 // 3 ) # first 40 graphs train, rest test return data deg_data = degree_task() _rows = [] for _kind in [ "mean aggregation", "GCN (degree-normalised sum)", "GIN (sum + MLP)", ]: torch.manual_seed(SEED) _m = NodeNet(_kind) _opt = torch.optim.Adam(_m.parameters(), lr=0.01) for _ in range(200): _m.train() _opt.zero_grad() F.cross_entropy( _m(deg_data.x, deg_data.edge_index)[deg_data.train_mask], deg_data.y[deg_data.train_mask], ).backward() _opt.step() _m.eval() with torch.no_grad(): _pred = _m(deg_data.x, deg_data.edge_index).argmax(1) _rows.append( { "model": _kind, "test acc (predict degree)": ( _pred[~deg_data.train_mask] == deg_data.y[~deg_data.train_mask] ) .float() .mean() .item(), } ) _rows.append( { "model": "majority class", "test acc (predict degree)": deg_data.y[~deg_data.train_mask] .bincount() .max() .item() / int((~deg_data.train_mask).sum()), } ) deg_table = pd.DataFrame(_rows).round(3) deg_table
_acc = dict(zip(deg_table["model"], deg_table["test acc (predict degree)"])) · 32 lines
_acc = dict(zip(deg_table["model"], deg_table["test acc (predict degree)"])) mo.md(rf""" **This run:** GIN **{_acc["GIN (sum + MLP)"]:.2f}**, GCN {_acc["GCN (degree-normalised sum)"]:.2f}, mean aggregation {_acc["mean aggregation"]:.2f} (majority class: {_acc["majority class"]:.2f}). Why: with constant inputs every neighbour "looks like" the node itself, so the **mean** of the neighbours is the same vector whatever their number, and the mean layer can do no better than guess the most common degree. **GCN** gets a little through its $1/\sqrt{{d_ud_v}}$ normalisation, which leaks degree information into the sum. **Sum** aggregation counts neighbours exactly, so GIN solves the task (the reference run gave 1.00 / 0.29 / 0.20). ## 4 · Real benchmarks: GIN vs. GCN, and the WL kernel Graph classification (TU datasets, downloaded to `data/TUDataset/`): * **MUTAG** — 188 molecules, mutagenic or not; atom-type features. * **PROTEINS** — 1,113 proteins, enzyme or not; 3 node features. * **IMDB-BINARY** — 1,000 actor ego-networks, Action vs Romance; **no node features** (we use constant 1s), so structure is everything. **A controlled comparison.** "GIN vs. GCN" usually changes three things at once: the aggregator (sum vs. normalised sum), the **readout** (sum vs. mean over nodes) and the update (MLP vs. one linear layer). To see *which* one matters we cross the two layers with the two readouts, a $2\times2$ ablation, and run every cell with **3 seeds × 5 folds** (15 test scores). Classical baseline: the **WL subtree kernel** (Shervashidze et al., 2011): count WL colours over 3 rounds (the same depth as the 3-layer GNNs) and train a linear SVM. On IMDB it starts from the **same constant colour** the GNNs get; on the molecules from the atom type. This is the protocol of Errica et al., *A Fair Comparison of Graph Neural Networks for Graph Classification* (ICLR 2020), scaled down: they found that on these very datasets a structure-agnostic baseline often matches published GNNs once model selection and splits are fixed. > **Predict first.** On featureless IMDB-BINARY, which of the four GNN cells collapses to chance (0.50), and is it the > *aggregator* or the *readout* that causes it? Does any GNN beat the WL kernel? """)
def load_tu(name): · 128 lines
def load_tu(name): ds = TUDataset(str(DATA_DIR / "TUDataset"), name) if ds.num_features == 0: # IMDB-BINARY: constant features graphs = [] for g in ds: g = g.clone() g.x = torch.ones(g.num_nodes, 1) graphs.append(g) return graphs return list(ds) class GraphNet(nn.Module): """`conv` in {GIN, GCN}; `readout` in {sum, mean}; reads out every layer (jumping knowledge).""" def __init__(self, conv, d_in, readout=None, d_hid=64, n_layers=3, n_cls=2): super().__init__() readout = readout or ("sum" if conv == "GIN" else "mean") self.pool = global_add_pool if readout == "sum" else global_mean_pool self.convs = nn.ModuleList( GINConv(mlp(d_in if i == 0 else d_hid, d_hid), train_eps=True) if conv == "GIN" else GCNConv(d_in if i == 0 else d_hid, d_hid) for i in range(n_layers) ) self.head = nn.Sequential( nn.Linear(d_hid * n_layers, d_hid), nn.ReLU(), nn.Dropout(0.5), nn.Linear(d_hid, n_cls), ) def forward(self, x, edge_index, batch): pooled = [] for conv in self.convs: x = F.relu(conv(x, edge_index)) pooled.append(self.pool(x, batch)) return self.head(torch.cat(pooled, 1)) def cv_splits(y, seeds, folds=5): for seed in seeds: for tr, te in StratifiedKFold(folds, shuffle=True, random_state=seed).split(y, y): yield seed, tr, te def cv_graph_model(graphs, conv, readout, seeds=(0, 1, 2), epochs=100): """Test accuracy on every (seed, fold): 15 numbers for 3 seeds × 5 folds. Fixed epochs, no model selection.""" y = np.array([int(g.y) for g in graphs]) accs = [] for seed, tr, te in cv_splits(y, seeds): torch.manual_seed(seed) model = GraphNet(conv, graphs[0].num_features, readout) opt = torch.optim.Adam(model.parameters(), lr=0.01) loader = DataLoader([graphs[i] for i in tr], batch_size=64, shuffle=True) for _ in range(epochs): model.train() for b in loader: opt.zero_grad() F.cross_entropy(model(b.x, b.edge_index, b.batch), b.y).backward() opt.step() model.eval() b = next(iter(DataLoader([graphs[i] for i in te], batch_size=len(te)))) with torch.no_grad(): accs.append((model(b.x, b.edge_index, b.batch).argmax(1) == b.y).float().mean().item()) return accs def wl_kernel_features(graphs, h=3): """WL subtree features: counts of every colour seen in rounds 0..h, palette shared by all graphs. Initial colour = node label (argmax of one-hot features) or, for featureless graphs, one constant colour, exactly the information the GNNs receive.""" adj, colours = [], [] for g in graphs: nbrs = [[] for _ in range(g.num_nodes)] for s_, d_ in g.edge_index.t().tolist(): nbrs[d_].append(s_) adj.append(nbrs) init = g.x.argmax(1).tolist() if g.num_features > 1 else [0] * g.num_nodes colours.append([("init", c) for c in init]) palette, feats = {}, [Counter() for _ in graphs] for _ in range(h + 1): for i, cols in enumerate(colours): for c in cols: feats[i][palette.setdefault(c, len(palette))] += 1 colours = [ [ (palette[cols[v]], tuple(sorted(palette[cols[u]] for u in adj[i][v]))) for v in range(len(cols)) ] for i, cols in enumerate(colours) ] X = np.zeros((len(graphs), len(palette))) for i, f in enumerate(feats): for k, v in f.items(): X[i, k] = v return X def wl_svm(graphs, seeds=(0, 1, 2)): y = np.array([int(g.y) for g in graphs]) X = wl_kernel_features(graphs) X = X / np.linalg.norm(X, axis=1, keepdims=True) accs = [] for _, tr, te in cv_splits(y, seeds): clf = SVC(kernel="linear", C=1.0).fit(X[tr], y[tr]) accs.append(float((clf.predict(X[te]) == y[te]).mean())) return accs ABLATION = [("GCN", "mean"), ("GCN", "sum"), ("GIN", "mean"), ("GIN", "sum")] def benchmark(name, graphs=None): """All models on one dataset; per-model lists of 15 test accuracies. Cached in cache/L06_benchmarks.json.""" def compute(): gs = graphs if graphs is not None else load_tu(name) out = {"graphs": len(gs), "WL kernel + SVM": wl_svm(gs)} for conv, readout in ABLATION: out[f"{conv} + {readout} readout"] = cv_graph_model(gs, conv, readout) return out if graphs is not None: # ad-hoc data (tests): never cache return compute() return json_cache( CACHE_DIR / "L06_benchmarks.json", {"dataset": name, "seeds": [0, 1, 2], "folds": 5, "epochs": 100, "v": 2}, compute, ) bench_run = mo.ui.run_button( label="Run benchmarks: 3 datasets × 5 models × 15 runs (≈ 15 min first time, then cached)" ) bench_run
mo.stop( not bench_run.value and not (CACHE_DIR / "L06_benchmarks.json").exists(), mo.md("Press the button. Results are cached in `notebooks/cs224w/cache/L06_benchmarks.json`."), ) _names = ["MUTAG", "PROTEINS", "IMDB-BINARY"] bench_raw = {n: benchmark(n) for n in mo.status.progress_bar(_names, title="datasets")} _rows = [] for _n, _r in bench_raw.items(): for _model, _accs in _r.items(): if _model == "graphs": continue _m, _s = mean_std(_accs) _rows.append({"dataset": _n, "model": _model, "mean": _m, "std": _s, "runs": len(_accs)}) bench_table = pd.DataFrame(_rows) _wide = bench_table.pivot(index="model", columns="dataset", values="mean")[_names].round(3) _fig, _ax = plt.subplots(figsize=(8, 2.8)) _models = ["WL kernel + SVM"] + [k for k in bench_raw[_names[0]] if k not in ("graphs", "WL kernel + SVM")] for _j, _model in enumerate(_models): _sub = bench_table[bench_table.model == _model].set_index("dataset").loc[_names] _ax.errorbar(np.arange(3) + (_j - 2) * 0.12, _sub["mean"], yerr=_sub["std"], fmt="o", capsize=3, label=_model) _ax.set_xticks(range(3), _names) _ax.axhline(0.5, ls=":", c="grey", lw=1) _ax.set_ylabel("test accuracy\n(mean ± std, 15 runs)") _ax.legend(fontsize=7, ncol=3, loc="lower left") plt.tight_layout() mo.vstack([_fig, _wide])
def _get(d, m): · 32 lines
def _get(d, m): r = bench_table[(bench_table.dataset == d) & (bench_table.model == m)].iloc[0] return f"{r['mean']:.3f} ± {r['std']:.3f}" def _best(d): sub = bench_table[bench_table.dataset == d] return sub.loc[sub["mean"].idxmax(), "model"] _imdb = {m: _get("IMDB-BINARY", m) for m in bench_table[bench_table.dataset == "IMDB-BINARY"].model} mo.md(rf""" **Reading the ablation (this run).** * IMDB-BINARY, the featureless case: GCN + mean readout **{_imdb["GCN + mean readout"]}**, GCN + sum readout **{_imdb["GCN + sum readout"]}**, GIN + mean readout **{_imdb["GIN + mean readout"]}**, GIN + sum readout **{_imdb["GIN + sum readout"]}**. Compare along a row (same layer, different readout) and down a column (same readout, different layer) to see which choice carries the effect: here it is the **layer**, not the readout. A standard deviation of 0.000 at 0.500 means the model predicted one class in every run. Why the GCN layer: on a $d$-regular graph the normalised sum of a constant signal is $\sum_{{j}}\frac{{c}}{{\sqrt{{(d+1)(d+1)}}}}=\frac{{(d+1)c}}{{d+1}}=c$, the same at every node *and for every degree*. IMDB ego-networks are dense and close to regular, so the layer sees almost nothing: a blind spot of the normalisation, which GIN's plain sum does not have (the sum gives $(d+1)c$, which counts). * Best model per dataset: MUTAG → {_best("MUTAG")}; PROTEINS → {_best("PROTEINS")}; IMDB-BINARY → {_best("IMDB-BINARY")}. * The WL kernel + linear SVM (2011) is a strong baseline. "As powerful as WL" bounds what a GNN *can* represent; it does not promise that training finds it, and small datasets favour simple models. Differences smaller than one standard deviation are not evidence of anything. ## 5 · The WL ceiling: what no MPNN can see A GIN can only be as good as WL. Task: random **2-regular** graphs on $2n$ nodes — one big cycle $C_{{2n}}$ (connected) vs. two cycles $C_n\cup C_n$ (disconnected). Every node in both graphs has the same infinite computation tree. > **Predict first.** Trained on all 20 graphs (no test set at all), what *training* accuracy can GIN reach? """)
def cycle_pairs(sizes=range(3, 13)): graphs = [] for n in sizes: for label, g in [ (1, nx.cycle_graph(2 * n)), (0, nx.disjoint_union(nx.cycle_graph(n), nx.cycle_graph(n))), ]: d = from_networkx(g) d.x = torch.ones(d.num_nodes, 1) d.y = torch.tensor([label]) graphs.append(d) return graphs ceiling_graphs = cycle_pairs() torch.manual_seed(SEED) _gin = GraphNet("GIN", 1) _opt = torch.optim.Adam(_gin.parameters(), lr=0.01) _b = next(iter(DataLoader(ceiling_graphs, batch_size=len(ceiling_graphs)))) for _ in range(300): _gin.train() _opt.zero_grad() F.cross_entropy(_gin(_b.x, _b.edge_index, _b.batch), _b.y).backward() _opt.step() _gin.eval() with torch.no_grad(): _logits = _gin(_b.x, _b.edge_index, _b.batch) _fmt = lambda t: "[" + ", ".join(f"{v:.4f}" for v in t.tolist()) + "]" mo.md(f"""GIN trained **300 epochs on all {len(ceiling_graphs)} graphs**; *training* accuracy: **{(_logits.argmax(1) == _b.y).float().mean().item():.2f}**. Logits for $C_6$: {_fmt(_logits[0])} · for $C_3\\cup C_3$: {_fmt(_logits[1])} — identical. It cannot even *memorise* the training set: connected and disconnected graphs of the same size get the same embedding. **Escapes** (L07): give nodes identities or positions — e.g. colour one node and count how many steps until the colour returns (6 on the hexagon, 3 on a triangle), or add cycle-count / random-walk features.""")
6 · Exercises (HW1 Q3)
Exercise 1 (Q3.2). Give two neighbour multisets of scalar features where mean and max agree but sum differs.
Put them in ex1_left, ex1_right (Python lists of numbers).
Exercise 2. Write wl_rounds(g1, g2) returning the first WL round at which the two graphs' colour histograms differ
(0 = initial colours), or None if WL never separates them. You may call wl_refine / wl_histograms from §2.
Exercise 3 (Q3.3, proof sketch). Why is any AGGREGATE/COMBINE/READOUT GNN at most as powerful as WL? Write one sentence for yourself, then open the solution.
ex1_left = None · 5 lines
ex1_left = None ex1_right = None def wl_rounds(g1, g2): return "todo"
def _verdict(ok, answered): · 72 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_ex1(): if ex1_left is None or ex1_right is None: return _verdict(False, False) l, r = np.array(ex1_left, float), np.array(ex1_right, float) return _verdict( np.isclose(l.mean(), r.mean()) and np.isclose(l.max(), r.max()) and not np.isclose(l.sum(), r.sum()), True, ) def _check_ex2(): tests = [ ( wl_pairs[k], None if k.startswith(("hexagon", "triangular", "decalin")) else 1, ) for k in wl_pairs ] tests.append(((nx.path_graph(5), nx.path_graph(5)), None)) tests.append( ( ( nx.path_graph(7), nx.Graph([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (2, 6)]), ), 1, ) ) try: if wl_rounds(nx.path_graph(3), nx.path_graph(3)) == "todo": return _verdict(False, False) return _verdict( all(wl_rounds(*g) == want for g, want in tests), True ) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _check_ex1(), mo.md("**Exercise 2**"), _check_ex2(), mo.accordion( { "Solution 1": mo.md( "`ex1_left = [1]`, `ex1_right = [1, 1]`: mean 1 = 1, max 1 = 1, sum 1 ≠ 2. (A node with one neighbour vs. two identical neighbours.)" ), "Solution 2": mo.md("""```python def wl_rounds(g1, g2): hist = wl_histograms(wl_refine([g1, g2])) return next((i for i, (a, b) in enumerate(hist) if a != b), None) ```"""), "Solution 3": mo.md(r"""Induction on the layer $k$: if two nodes have the same WL colour after $k$ rounds, they have the same colour and the same multiset of neighbour colours at round $k-1$; by the induction hypothesis the neighbours' GNN embeddings form the same multiset, so any AGGREGATE/COMBINE (functions of the multiset) gives the same output. So equal WL colours ⇒ equal embeddings; equal histograms ⇒ equal READOUT. Contrapositive: if the GNN separates two graphs, WL does too."""), } ), ] )
7 · Pitfall: "my GNN is as powerful as WL, so it should win"
The theorem is about capacity: some weights exist that make GIN injective. Whether training finds them, and whether injectivity helps generalisation, are separate questions. Look at §4: the 2011 WL kernel with a linear SVM is competitive with GIN, and on featureless IMDB the GCN layer collapses whatever the readout. Fix: always report a simple baseline (WL kernel, degree histogram + logistic regression, MLP on pooled features) before claiming an architecture is better.
8 · Connecting to your research (SNA on X/Twitter)
-
Cascade classification (organic vs. coordinated retweet trees) is a graph-level task on mostly featureless trees. That is exactly the IMDB-BINARY setting, where the GCN layer collapsed. Use GIN-style layers and the WL kernel baseline, and run the readout ablation of §4 on your own data before choosing a readout.
-
Coordinated accounts often produce highly regular structures (everyone retweets everyone in a clique). WL-style models cannot separate some of these regular patterns (§5). Add cycle counts / clustering features (L05, L07).
-
Counting matters: "retweeted by 3 vs. 300" is invisible to mean/attention aggregation; sum sees it.
Summary
- MPNNs see rooted subtrees; their power = injectivity of aggregation on multisets.
- Mean sees proportions, max sees the set, sum sees counts → GIN = sum + MLP = as powerful as 1-WL.
-
Counting task: sum aggregation learns degrees exactly; mean cannot. Benchmarks: separate the aggregator from the readout before crediting either, average over seeds, and keep the WL kernel as a baseline.
-
WL (and every MPNN) cannot tell a hexagon from two triangles — not even connectivity.
Next — L07 · Designing powerful graph encoders: break the WL limit with positional and structural encodings.