Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L07 · Designing powerful graph encoders — breaking the WL limit

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:

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

  1. The spectral view — what a GNN with constant inputs can and cannot see.
  2. Fix B: structural features — closed walks \(\mathrm{diag}(A^k)\); triangle-vs-square nodes; connected vs. disconnected graphs.
  3. Fix A: eigenvector positional encodings — Laplacian PE, and its sign ambiguity.
  4. Fix C: position-aware features — anchor-set distances on a grid.
  5. 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)

Summary

Next — L08 · Graph transformers: attention over all nodes, where positional encodings become the whole game.