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/L07_powerful_graph_encoders.py.
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 time import marimo as mo from gl.nb import 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 import torch_geometric.transforms as T from torch_geometric.loader import DataLoader from torch_geometric.nn import GINConv, global_add_pool from torch_geometric.utils import from_networkx 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, } )
L07 · Designing powerful graph encoders — breaking the WL limit
CS224W Machine Learning with Graphs · hands-on companion to Lecture 7
Audience: you did L06 (WL test, GIN, the WL ceiling). Prerequisites: eigenvalues/eigenvectors of a symmetric matrix; shortest-path distance.
By the end you can:
- explain the WL limit spectrally: with constant inputs a GNN only "sees" eigenvectors not orthogonal to \(\mathbf 1\);
- compute closed-walk features \(\mathrm{diag}(A^k)\) (the lecture's \(K_3\) vs \(C_4\) example) and Laplacian / random-walk PEs with PyG;
- show a GIN that fails with constant features and succeeds with structural features, on node and graph tasks;
- build anchor-set distance features (P-GNN, Bourgain) for position-aware tasks, and see the eigenvector sign ambiguity.
In short. A perfect GNN maps same structure → same embedding and different structure → different embedding. MPNNs break the second rule (triangle vs. square), and position-aware tasks need to break the first on purpose (opposite corners of a grid). Both are fixed by feature augmentation: eigenvector PEs, closed-walk / cycle counts for structure, distances to random anchor sets for position.
Outline
- The spectral view — what a GNN with constant inputs can and cannot see.
- Fix B: structural features — closed walks \(\mathrm{diag}(A^k)\); triangle-vs-square nodes; connected vs. disconnected graphs.
- Fix A: eigenvector positional encodings — Laplacian PE, and its sign ambiguity.
- Fix C: position-aware features — anchor-set distances on a grid.
- Exercises, pitfall, research links.
1 · A spectral perspective on message passing
A one-layer GIN step is \(C^{(l+1)}=\sigma\big(\sum_{k=0}^{1}A^kC^{(l)}W_k\big)\). With \(A=V\Lambda V^\top\): $\(C^{(l+1)}=\sigma\Big(\sum_k V\Lambda^k\,\underbrace{V^\top C^{(l)}}_{\text{projections}}\,W_k\Big).\)$ With constant inputs \(C^{(0)}=\mathbf 1\) the network receives only \(V^\top\mathbf 1\): every eigenvector orthogonal to \(\mathbf 1\) is invisible. Below: the eigenvalues of \(A\) and how much of \(\mathbf 1\) each eigenspace carries.
spectral_graphs = { "hexagon C6": nx.cycle_graph(6), "two triangles C3 ∪ C3": nx.disjoint_union( nx.cycle_graph(3), nx.cycle_graph(3) ), "square C4": nx.cycle_graph(4), "path P6": nx.path_graph(6), "star S5": nx.star_graph(5), } def visible_spectrum(g): """Eigenvalues of A, grouped into eigenspaces, with the norm of the projection of 1 onto each eigenspace.""" A = nx.to_numpy_array(g) lam, V = np.linalg.eigh(A) proj = V.T @ np.ones(len(A)) rows = {} for l, p in zip(np.round(lam, 6), proj): rows[l] = rows.get(l, 0.0) + p**2 return pd.DataFrame( { "eigenvalue λ": list(rows), "‖projection of 1‖²": [round(v, 6) for v in rows.values()], } ) spec_picker = mo.ui.multiselect( list(spectral_graphs), value=["hexagon C6", "two triangles C3 ∪ C3"], label="graphs", ) spec_picker
mo.hstack( [ mo.vstack( [mo.md(f"**{name}**"), visible_spectrum(spectral_graphs[name])] ) for name in spec_picker.value ], justify="start", gap=2, )
_k3 = [ np.trace( np.linalg.matrix_power(nx.to_numpy_array(nx.complete_graph(3)), k) ) / 3 for k in range(6) ] _c4 = [ np.trace( np.linalg.matrix_power(nx.to_numpy_array(nx.cycle_graph(4)), k) ) / 4 for k in range(6) ] def closed_walks(g, K=6, nodelist=None): """Node features diag(A^0), ..., diag(A^(K-1)): number of closed walks of each length starting at the node.""" A = nx.to_numpy_array(g, nodelist=nodelist) P, out = np.eye(len(A)), [] for _ in range(K): out.append(np.diag(P).copy()) P = P @ A return np.stack(out, 1) pd.DataFrame( { "k": range(6), "node of triangle K3: (A^k)_vv": [int(v) for v in _k3], "node of square C4: (A^k)_vv": [int(v) for v in _c4], } )
Try it: for the hexagon and two triangles, the only eigenvalue that \(\mathbf 1\) "touches" is \(\lambda=2\), with the same weight (6) in both graphs. Their other eigenvalues differ (\(\pm1, -2\) vs. \(-1\)), but those eigenvectors are orthogonal to \(\mathbf 1\), so a GNN fed constant features never sees them. For any \(d\)-regular graph \(\mathbf 1\) is itself an eigenvector, so everything else is invisible. Path and star are irregular: several eigenspaces are visible and WL separates them. The WL limitation is a limitation of the initial node colours.
2 · Fix B: structural features — closed walks
Feed \([\mathrm{diag}(A^0),\dots,\mathrm{diag}(A^{K-1})]\) as node features (closed walks of each length; Kanatsoulis & Ribeiro 2024). The table above reproduces the lecture's worked example: a triangle node \([1,0,2,2,6,10]\) vs. a square node \([1,0,2,0,8,0]\). They agree up to \(k=2\) (same degree) and differ at \(k=3\) — a GIN sees the difference with zero message-passing steps.
Node task (inductive). A ring of 40 triangles and squares, joined by single edges. Label = "am I on a triangle?". Train a 3-layer GIN on one random ring, test on a different random ring, with four input encodings:
def ring_of_cycles(n_comp=40, seed=SEED): · 88 lines
def ring_of_cycles(n_comp=40, seed=SEED): """Triangles and squares (random mix) in a ring; component i's first node links to component i+1's first node.""" r = np.random.default_rng(seed) g, y, firsts = nx.Graph(), [], [] for _ in range(n_comp): size, base = int(r.choice([3, 4])), g.number_of_nodes() nx.add_cycle(g, range(base, base + size)) y += [int(size == 3)] * size firsts.append(base) g.add_edges_from(zip(firsts, firsts[1:] + firsts[:1])) return g, torch.tensor(y) def encode(g, kind, k=8): n = g.number_of_nodes() if kind == "constant": return torch.ones(n, 1) if kind == "closed walks log(1+diag(A^k))": return torch.tensor( np.log1p(closed_walks(g, 6, nodelist=range(n))), dtype=torch.float, ) data = from_networkx(g) if kind == "random-walk PE diag((D⁻¹A)^k)": return T.AddRandomWalkPE(walk_length=6, attr_name="pe")(data).pe if kind == "Laplacian eigenvector PE (k=8)": return T.AddLaplacianEigenvectorPE(k=k, attr_name="pe")(data).pe raise ValueError(kind) class NodeGIN(nn.Module): def __init__(self, d_in, d=32, n_layers=3, n_out=2): super().__init__() self.convs = nn.ModuleList( GINConv( nn.Sequential( nn.Linear(d_in if i == 0 else d, d), nn.ReLU(), nn.Linear(d, d), ) ) for i in range(n_layers) ) self.head = nn.Linear(d, 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 fit_nodes(x, edge_index, y, epochs=300, seed=SEED, augment=None): torch.manual_seed(seed) model = NodeGIN(x.size(1)) opt = torch.optim.Adam(model.parameters(), lr=0.01) for _ in range(epochs): opt.zero_grad() F.cross_entropy( model(augment(x) if augment else x, edge_index), y ).backward() opt.step() return model def node_acc(model, x, edge_index, y): with torch.no_grad(): return (model(x, edge_index).argmax(1) == y).float().mean().item() ring_a, y_a = ring_of_cycles(seed=0) ring_b, y_b = ring_of_cycles(seed=1) ei_a, ei_b = ( from_networkx(ring_a).edge_index, from_networkx(ring_b).edge_index, ) encodings = [ "constant", "closed walks log(1+diag(A^k))", "random-walk PE diag((D⁻¹A)^k)", "Laplacian eigenvector PE (k=8)", ] _rows = [] for _kind in encodings: _xa, _xb = encode(ring_a, _kind), encode(ring_b, _kind) _m = fit_nodes(_xa, ei_a, y_a) _rows.append( { "encoding": _kind, "train acc (ring A)": node_acc(_m, _xa, ei_a, y_a), "test acc (new ring B)": node_acc(_m, _xb, ei_b, y_b), } ) pd.DataFrame(_rows).round(3)
_rev = reveal( · 18 lines
_rev = reveal( r""" Measured: **constant** inputs reach 0.84 / 0.87. They are not hopeless here, because the ring's linking edges create degree-3 nodes that break some symmetry, but they are far from perfect. **Closed walks: 1.00 on the new ring**; random-walk PE 0.99. These features are computed the same way on any graph, so they transfer. The **Laplacian PE** fits ring A perfectly (1.00) but gets only ≈ 0.58 on ring B: eigenvectors are a *global* coordinate system specific to one graph, and their signs are arbitrary. """, predict="Trained on ring A and tested on a new ring B, rank constant inputs, closed-walk counts, random-walk PE and Laplacian PE. Which transfers worst, and why?", ) _post = mo.md(r""" ## 3 · Fix A: eigenvector positional encodings — and their sign ambiguity If $v$ is an eigenvector, so is $-v$; with repeated eigenvalues any rotation inside the eigenspace works too. A network fed raw eigenvectors can give **different answers for the same graph** depending on which signs the solver returned. Test it on ring A itself: flip the sign of each PE column at random (same graph, equally valid eigenvectors). """) mo.vstack([_rev, _post])
def random_sign_flip(x, generator=None): signs = ( torch.randint(0, 2, (1, x.size(1)), generator=generator) * 2 - 1 ) return x * signs _x_lap = encode(ring_a, "Laplacian eigenvector PE (k=8)") _plain = fit_nodes(_x_lap, ei_a, y_a) _aug = fit_nodes( _x_lap, ei_a, y_a, epochs=600, augment=random_sign_flip ) # SignNet-lite: train under random flips _g = torch.Generator().manual_seed(SEED) _flips = [random_sign_flip(_x_lap, _g) for _ in range(20)] pd.DataFrame( [ { "model": "trained on one sign choice", "acc, original signs": node_acc(_plain, _x_lap, ei_a, y_a), "acc, 20 random sign flips (mean)": np.mean( [node_acc(_plain, f, ei_a, y_a) for f in _flips] ), }, { "model": "trained with random sign flips", "acc, original signs": node_acc(_aug, _x_lap, ei_a, y_a), "acc, 20 random sign flips (mean)": np.mean( [node_acc(_aug, f, ei_a, y_a) for f in _flips] ), }, ] ).round(3)
Same graph, equally valid eigenvectors: the model trained on one sign choice drops from 1.00 to ≈ 0.56 under random flips. Training under random flips (a cheap stand-in for SignNet) makes it sign-invariant (≈ 0.91 either way), at some cost in accuracy. PEARL and SignNet/BasisNet (L08) solve this properly.
4 · Fix C: position-aware features — anchor sets
Structural features give mirror-image nodes the same embedding, which is correct for structure tasks and wrong for position tasks. Task: an \(8\times8\) grid; label = which quadrant a node is in. Corners, edges and centre cells come in symmetric groups of 4, one per quadrant, so structure alone cannot decide. Anchor sets (P-GNN, after Bourgain's theorem): sample sets \(S_{i,j}\) that contain each node with probability \(1/2^i\), and describe \(v\) by its distances \(d_{\min}(v,S_{i,j})\).
grid = nx.grid_2d_graph(8, 8) grid_nodes = sorted(grid.nodes) grid_int = nx.convert_node_labels_to_integers(grid, ordering="sorted") grid_ei = from_networkx(grid_int).edge_index grid_y = torch.tensor( [2 * (r >= 4) + (c >= 4) for r, c in grid_nodes] ) # quadrant 0..3 _perm = torch.randperm(64, generator=torch.Generator().manual_seed(SEED)) grid_train = torch.zeros(64, dtype=torch.bool) grid_train[_perm[:32]] = True # transductive: half the labels def anchor_features(g, c=2, seed=SEED): """Bourgain-style anchor sets: for i = 1..log2(n), c sets that include each node with prob 1/2^i; feature = 1/(1+d_min).""" r = np.random.default_rng(seed) n = g.number_of_nodes() dist = dict(nx.all_pairs_shortest_path_length(g)) feats = [] for i in range(1, int(np.log2(n)) + 1): for _ in range(c): S = [v for v in g if r.random() < 1 / 2**i] or [ int(r.integers(n)) ] feats.append( [1 / (1 + min(dist[v][s] for s in S)) for v in range(n)] ) return torch.tensor(feats, dtype=torch.float).t() anchor_c = mo.ui.slider( 1, 6, value=2, label="anchor sets per scale (c)", show_value=True ) anchor_c
_encs = { "constant": torch.ones(64, 1), "closed walks (structure)": torch.tensor( np.log1p(closed_walks(grid_int, 6, nodelist=range(64))), dtype=torch.float, ), f"anchor-set distances ({anchor_c.value * 6} dims)": anchor_features( grid_int, c=anchor_c.value ), } _rows = [] for _name, _x in _encs.items(): torch.manual_seed(SEED) _m = NodeGIN(_x.size(1), n_out=4) _opt = torch.optim.Adam(_m.parameters(), lr=0.01) for _ in range(300): _opt.zero_grad() F.cross_entropy( _m(_x, grid_ei)[grid_train], grid_y[grid_train] ).backward() _opt.step() with torch.no_grad(): _pred = _m(_x, grid_ei).argmax(1) _rows.append( { "features": _name, "train acc": (_pred[grid_train] == grid_y[grid_train]) .float() .mean() .item(), "test acc (other half of the grid)": ( _pred[~grid_train] == grid_y[~grid_train] ) .float() .mean() .item(), } ) pd.DataFrame(_rows).round(3)
_rev = reveal( · 27 lines
_rev = reveal( r""" Measured: **constant** and **closed-walk** features are at or below chance (0.16–0.19 test, chance = 0.25): the four quadrants are mirror images, so structure cannot tell them apart. **Anchor-set distances reach ≈ 0.91**. Across settings of $c$ the test accuracy ranged 0.81–0.94 (random anchor sets; P-GNN re-samples them every epoch to stay inductive). """, predict="On the mirrored grid, which features can tell the four quadrants apart: constant, closed walks, or anchor distances? Can anything score *below* chance (0.25)?", ) _post = mo.md(r""" **Try it:** change the number of anchor sets per scale. | failure | example | fix | |---|---|---| | different structure, same embedding | triangle vs. square node | structural features: closed walks, cycle counts, RWPE (§2) | | same structure, should differ | mirror positions on a grid | positional features: anchor distances (§4), Laplacian PE (§3, mind the signs) | --- ## 5 · Exercises **Exercise 1 (by hand).** Closed walks $(A^k)_{vv}$ for a node of the 5-cycle $C_5$, $k=0..5$. Use $\mathrm{tr}(A^k)/5$ and $\lambda_j=2\cos(2\pi j/5)$, or count. → `ex1_c5` (list of 6 integers). **Exercise 2 (code).** Write `my_rwpe(g, K)` returning an $n\times K$ tensor whose column $k$ is $\mathrm{diag}\big((D^{-1}A)^{k}\big)$, $k=1..K$. It must match `T.AddRandomWalkPE(walk_length=K)`. """) mo.vstack([_rev, _post])
ex1_c5 = None · 4 lines
ex1_c5 = None def my_rwpe(g, K): return None
def _verdict(ok, answered): · 54 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_rwpe(): try: g = nx.karate_club_graph() got = my_rwpe(g, 5) if got is None: return _verdict(False, False) ref = T.AddRandomWalkPE(walk_length=5, attr_name="pe")( from_networkx(g) ).pe return _verdict( torch.allclose( torch.as_tensor(got, dtype=torch.float), ref, atol=1e-5 ), True, ) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_c5 is not None and list(ex1_c5) == [1, 0, 2, 0, 6, 2], ex1_c5 is not None, ), mo.md("**Exercise 2**"), _check_rwpe(), mo.accordion( { "Solution 1": mo.md(r"""$[1, 0, 2, 0, 6, 2]$. Length 2: step out and back (2 ways). Length 3: 0 (no triangle). Length 4: 6. Length 5: **2**, going once round the cycle either way. A 5-cycle node is the first to show a non-zero odd entry at $k=5$, just as a triangle node does at $k=3$. Check: `closed_walks(nx.cycle_graph(5), 6)[0]`."""), "Solution 2": mo.md("""```python def my_rwpe(g, K): A = torch.tensor(nx.to_numpy_array(g, weight=None), dtype=torch.float) P = A / A.sum(1, keepdim=True) # D^-1 A M, cols = torch.eye(len(A)), [] for _ in range(K): M = M @ P cols.append(torch.diagonal(M)) # return probability after k steps return torch.stack(cols, 1) ```"""), } ), ] )
6 · Pitfall: transductive positional encodings in an inductive setting
Laplacian eigenvectors (and one-hot IDs, L05) are coordinates of one graph. §2 showed a perfect training fit falling to ≈ 0.58 on a new graph, and §3 showed that even the same graph breaks when the solver returns other signs. Fix: prefer encodings defined the same way on every graph (closed walks, RWPE, degrees, cycle counts); if you use eigenvectors, train with random sign flips or use sign-invariant architectures (SignNet, PEARL — L08).
7 · Connecting to your research (SNA on X/Twitter)
-
Structure task: "is this account part of a tightly knit amplification clique?" → closed-walk / triangle counts / RWPE features on the retweet graph. Cheap, inductive, and they break the WL symmetry of regular coordinated structures.
-
Position task: "which side of the polarised network is this account on?" → distances to anchor accounts (known partisan hubs, or random anchor sets). This is P-GNN with domain-chosen anchors, and it formalises L01's "closer to node 0 or 33?" feature.
-
Avoid raw Laplacian eigenvectors if your graph changes daily: their signs and order shift between snapshots.
Summary
- Constant inputs only expose eigenvectors not orthogonal to \(\mathbf 1\); regular graphs hide everything but the degree.
- Closed walks \(\mathrm{diag}(A^k)\): triangle \([1,0,2,2,6,10]\) vs square \([1,0,2,0,8,0]\); on new graphs 1.00 vs 0.87 for constant inputs.
- Laplacian PEs are powerful but graph-specific and sign-ambiguous (1.00 → 0.56 under sign flips).
- Anchor-set distances solve position tasks that structure cannot (quadrants: ≈ 0.9 vs ≤ 0.2).
Next — L08 · Graph transformers: attention over all nodes, where positional encodings become the whole game.