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/L08_graph_transformers.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 from gl.datasets import planetoid import torch.nn as nn import torch.nn.functional as F import torch_geometric.transforms as T from torch_geometric.data import Batch, Data from torch_geometric.nn import GCNConv, GINConv from torch_geometric.utils import to_dense_adj 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, } )
L08 · Graph transformers
CS224W Machine Learning with Graphs · hands-on companion to Lecture 8
Audience: you did L04 (GAT) and L07 (positional encodings). Prerequisites: softmax, matrix products; having heard of "attention is all you need".
By the end you can:
- compute scaled dot-product self-attention by hand and in code (the lecture's 0.88 / 0.12 example);
- show that self-attention is message passing on the complete graph, and that masking it to neighbours gives a GAT-like layer;
- put the graph back into a transformer in three ways — positional encodings, attention biases (Graphormer's shortest-path bias), and local message passing (GPS-style);
- build PEARL-style positional encodings from random node IDs, and compare everything on a long-range task and on Cora.
In short. Each token computes a query, key and value; each output is a softmax-weighted sum of all values — message + aggregation over the fully connected graph. Self-attention ignores order, so a graph transformer must get the adjacency back through tokens, positional encodings (Laplacian eigenvectors, random walks, learned) or modified attention (edge / shortest-path biases). Eigenvector PEs are sign-ambiguous and \(O(n^3)\); PEARL runs a GNN on random IDs and averages.
Outline
- Self-attention by hand — the lecture's two-token example, plus a Graphormer bias.
- Attention is message passing — full attention = complete graph; masked attention = neighbours only.
- Putting the graph back — PE vs. attention bias vs. local MP; a long-range task where it matters.
- PEARL — positional encodings from random node IDs.
- Cora — GCN vs. graph transformers on a real (homophilous) graph.
- Exercises, pitfall, research links.
1 · Self-attention by hand
Tokens \(\mathbf x_i\) → \(\mathbf q_i=W_Q\mathbf x_i\), \(\mathbf k_i=W_K\mathbf x_i\), \(\mathbf v_i=W_V\mathbf x_i\); $\(\mathbf z_i=\sum_j \text{softmax}_j\!\Big(\frac{\mathbf q_i^\top\mathbf k_j}{\sqrt d}+c_{ij}\Big)\mathbf v_j,\)$ where \(c_{ij}\) is an optional attention bias (Graphormer adds one per shortest-path distance). Lecture example: \(d=64\), \(\mathbf q_1^\top\mathbf k_1=112\), \(\mathbf q_1^\top\mathbf k_2=96\) → scaled 14 and 12 → weights 0.88 / 0.12. With bias \(c_{12}=-2\) (nodes far apart) → 0.98 / 0.02.
att_controls = mo.ui.dictionary( { "q1·k1": mo.ui.slider( 0, 160, step=4, value=112, label="q₁·k₁", show_value=True ), "q1·k2": mo.ui.slider( 0, 160, step=4, value=96, label="q₁·k₂", show_value=True ), "d": mo.ui.slider( steps=[1, 4, 16, 64, 256], value=64, label="d", show_value=True ), "bias": mo.ui.slider( -6, 2, step=0.5, value=0, label="bias c₁₂", show_value=True ), } ) mo.vstack( [ mo.md( "**Try it:** set the bias to −2 (the lecture's Graphormer case), or shrink $d$ to see why we scale by $\\sqrt d$." ), mo.hstack(list(att_controls.elements.values()), justify="start"), ] )
_v = att_controls.value _scores = np.array([_v["q1·k1"], _v["q1·k2"]]) / np.sqrt( _v["d"] ) + np.array([0.0, _v["bias"]]) _w = np.exp(_scores - _scores.max()) _w /= _w.sum() mo.md(f"""scaled scores = ({_scores[0]:.2f}, {_scores[1]:.2f}) → attention weights **α₁₁ = {_w[0]:.3f}, α₁₂ = {_w[1]:.3f}** → $\\mathbf z_1 = {_w[0]:.2f}\\,\\mathbf v_1 + {_w[1]:.2f}\\,\\mathbf v_2$""")
2 · Self-attention is message passing on the complete graph
Read the formula per token: token \(i\) receives a message \(\mathbf v_j\) from every token \(j\), weighted by a softmax over all \(j\) — a GAT layer on the fully connected graph. Restrict the softmax to \(j\in N(i)\cup\{i\}\) (a mask) and you get neighbourhood attention. Below, one attention head written once and used two ways, checked against an explicit per-node message-passing loop.
def attention(X, Wq, Wk, Wv, mask=None, bias=None): · 48 lines
def attention(X, Wq, Wk, Wv, mask=None, bias=None): """Single-head scaled dot-product attention. mask[i, j] = False blocks j → i; bias[i, j] is added to the score.""" Q, K, V = X @ Wq, X @ Wk, X @ Wv S = Q @ K.T / K.size(1) ** 0.5 if bias is not None: S = S + bias if mask is not None: S = S.masked_fill(~mask, float("-inf")) return torch.softmax(S, dim=1) @ V karate = nx.karate_club_graph() A_k = torch.tensor( nx.to_numpy_array(karate, weight=None), dtype=torch.bool ) torch.manual_seed(SEED) _X = torch.randn(34, 8) _Wq, _Wk, _Wv = (torch.randn(8, 8) for _ in range(3)) _masked = attention( _X, _Wq, _Wk, _Wv, mask=A_k | torch.eye(34, dtype=torch.bool) ) # the same thing as explicit message passing: for every node, softmax over its neighbours (+ itself) _loop = torch.zeros(34, 8) for _v in range(34): _nbrs = [_v] + list(karate.neighbors(_v)) _s = torch.stack( [(_X[_v] @ _Wq) @ (_X[_u] @ _Wk) / 8**0.5 for _u in _nbrs] ) _loop[_v] = ( torch.softmax(_s, 0)[:, None] * torch.stack([_X[_u] @ _Wv for _u in _nbrs]) ).sum(0) _full = attention( _X, _Wq, _Wk, _Wv ) # no mask: the graph is not an input at all pd.DataFrame( [ { "check": "masked attention vs. per-node message-passing loop", "max |difference|": (_masked - _loop).abs().max().item(), }, { "check": "masked attention vs. full attention", "max |difference|": (_masked - _full).abs().max().item(), }, ] )
Masked attention equals the explicit per-node message-passing loop (difference ≈ 3e-6, float rounding): a GAT-style layer is attention restricted to neighbours. Full attention gives very different outputs (≈ 11) and never receives the graph at all; without help, a transformer sees a set of nodes, not a graph.
3 · Putting the graph back: a long-range task
Task: 400 random trees with 20 nodes; one node is marked. Each node predicts: am I within 3 hops of the marked node? (≈ 43% yes). The answer needs both reach (3 hops) and structure (distances). Train on 300 trees, test on 100. Models:
- GCN with 2 or 4 layers (local message passing; 2 layers can't reach 3 hops).
- Graph transformer (2 layers, 4 heads) with: no structure · random-walk PE · Laplacian PE (with random sign flips) · Graphormer shortest-path bias (a learned scalar per head per distance, added to the attention scores).
N_TREE = 20 def make_trees(n_graphs, seed, k_pe=8): """Dense batch of random trees: features [marked, 1], adjacency, shortest-path distances, RWPE, LapPE, labels.""" r = np.random.default_rng(seed) X, A, D, RW, LAP, Y = [], [], [], [], [], [] for _ in range(n_graphs): g = nx.random_labeled_tree(N_TREE, seed=int(r.integers(1e9))) marked = int(r.integers(N_TREE)) sp = dict(nx.all_pairs_shortest_path_length(g)) d = np.array( [[sp[i][j] for j in range(N_TREE)] for i in range(N_TREE)] ) a = nx.to_numpy_array(g, nodelist=range(N_TREE)) x = np.zeros((N_TREE, 2)) x[:, 1] = 1 x[marked, 0] = 1 P, M, rw = a / a.sum(1, keepdims=True), np.eye(N_TREE), [] for _ in range(k_pe): M = M @ P rw.append(np.diag(M).copy()) lam, vec = np.linalg.eigh( np.diag(a.sum(1)) - a ) # Laplacian L = D - A X.append(x) A.append(a) D.append(d) RW.append(np.stack(rw, 1)) LAP.append(vec[:, 1 : k_pe + 1]) Y.append((d[marked] <= 3).astype(int)) f = lambda z, dt=torch.float: torch.tensor(np.array(z), dtype=dt) return { "X": f(X), "A": f(A), "D": f(D, torch.long), "rw": f(RW), "lap": f(LAP), "y": f(Y, torch.long), } trees_train, trees_test = make_trees(300, seed=0), make_trees(100, seed=1) mo.md( f"Train: {trees_train['X'].shape[0]} trees, test: {trees_test['X'].shape[0]} trees, " f"share of positive nodes: {trees_train['y'].float().mean():.2f}" )
class DenseGCN(nn.Module): · 91 lines
class DenseGCN(nn.Module): def __init__(self, n_layers, d=32, d_in=2): super().__init__() self.inp = nn.Linear(d_in, d) self.layers = nn.ModuleList( nn.Linear(d, d) for _ in range(n_layers) ) self.out = nn.Linear(d, 2) def forward(self, b, pe=None): A_t = b["A"] + torch.eye(b["A"].size(-1)) d = A_t.sum(-1).pow(-0.5) A_hat = d[..., :, None] * A_t * d[..., None, :] h = F.relu(self.inp(b["X"])) for lin in self.layers: h = F.relu(A_hat @ lin(h)) return self.out(h) class GraphTransformer(nn.Module): """Pre-built PyTorch multi-head attention + FFN blocks; optional PE (concatenated) and shortest-path bias.""" def __init__( self, n_layers=2, d=32, heads=4, d_pe=0, spd_bias=False, max_dist=N_TREE, ): super().__init__() self.heads = heads self.inp = nn.Linear(2 + d_pe, d) self.att = nn.ModuleList( nn.MultiheadAttention(d, heads, batch_first=True) for _ in range(n_layers) ) self.ff = nn.ModuleList( nn.Sequential( nn.Linear(d, 2 * d), nn.ReLU(), nn.Linear(2 * d, d) ) for _ in range(n_layers) ) self.n1 = nn.ModuleList(nn.LayerNorm(d) for _ in range(n_layers)) self.n2 = nn.ModuleList(nn.LayerNorm(d) for _ in range(n_layers)) self.spd = ( nn.Embedding(max_dist, heads) if spd_bias else None ) # b(d(i, j)) per head self.out = nn.Linear(d, 2) def forward(self, b, pe=None): h = self.inp(b["X"] if pe is None else torch.cat([b["X"], pe], -1)) B, n, _ = h.shape bias = None if self.spd is not None: bias = ( self.spd(b["D"]) .permute(0, 3, 1, 2) .reshape(B * self.heads, n, n) ) # float mask = additive bias for att, ff, n1, n2 in zip(self.att, self.ff, self.n1, self.n2): h = n1(h + att(h, h, h, attn_mask=bias, need_weights=False)[0]) h = n2(h + ff(h)) return self.out(h) def sign_flip(pe): return pe * (torch.randint(0, 2, (pe.size(0), 1, pe.size(2))) * 2 - 1) def fit_trees(model, pe_key=None, epochs=150, augment=None, seed=SEED): torch.manual_seed(seed) opt = torch.optim.Adam(model.parameters(), lr=3e-3) tr = trees_train for _ in range(epochs): for i in range(0, tr["X"].size(0), 50): batch = {k: v[i : i + 50] for k, v in tr.items()} pe = None if pe_key is None else batch[pe_key] if pe is not None and augment: pe = augment(pe) opt.zero_grad() F.cross_entropy( model(batch, pe).reshape(-1, 2), batch["y"].reshape(-1) ).backward() opt.step() with torch.no_grad(): out = model( trees_test, None if pe_key is None else trees_test[pe_key] ) return (out.argmax(-1) == trees_test["y"]).float().mean().item() tree_run = mo.ui.run_button(label="Train the 6 models (≈ 10 s)") tree_run
mo.stop( not tree_run.value, mo.md("Press the button to train and compare the six models."), ) _specs = [ ("GCN, 2 layers", lambda: DenseGCN(2), None, None), ("GCN, 4 layers", lambda: DenseGCN(4), None, None), ("Transformer, no structure", lambda: GraphTransformer(), None, None), ( "Transformer + random-walk PE", lambda: GraphTransformer(d_pe=8), "rw", None, ), ( "Transformer + Laplacian PE (sign flips)", lambda: GraphTransformer(d_pe=8), "lap", sign_flip, ), ( "Transformer + shortest-path bias (Graphormer)", lambda: GraphTransformer(spd_bias=True), None, None, ), ] _rows = [] for _name, _make, _pe, _aug in _specs: torch.manual_seed(SEED) _t = time.time() _rows.append( { "model": _name, "test acc": fit_trees(_make(), _pe, augment=_aug), "seconds": time.time() - _t, } ) tree_results = pd.DataFrame(_rows) tree_results.round(3)
_rev = reveal( · 20 lines
_rev = reveal( r""" Measured (one seed): **GCN-2 0.87**, since it cannot reach 3 hops, so nodes at exactly distance 3 are lost; **GCN-4 0.99**. **Transformer without structure 0.65**, near "always say no" (0.57): attention can find the marked node anywhere but has no idea how far away it is. **Random-walk PE barely helps (0.67)**: return probabilities describe a node's own neighbourhood, not its distance to *another* node. **Laplacian PE 0.97**: eigenvectors are global coordinates, so distances are recoverable. **Shortest-path bias 1.00**: the needed quantity is injected straight into the attention scores. Lesson: *what* structural information you give a transformer must match the task. """, predict="Rank GCN-2, GCN-4, a structure-blind transformer, + random-walk PE, + Laplacian PE and + shortest-path bias. Which structural signal actually encodes the distance between *two* nodes?", ) _post = mo.md(r""" ## 4 · PEARL: positional encodings from random node IDs Eigenvectors cost $O(n^3)$ and are sign/basis-ambiguous. **PEARL** (Kanatsoulis et al., ICLR 2025): give every node $M$ random numbers (random "IDs"), run the **same GNN** on each random sample, and **average** over samples. Each sample breaks symmetry like a one-hot ID; the average restores permutation equivariance, and the GNN costs $O(|E|)$ per sample. Trained end-to-end: """) mo.vstack([_rev, _post])
class PEARL(nn.Module): """Random IDs (M samples per node) → shared dense GIN-style GNN per sample → mean over samples → k-dim PE.""" def __init__(self, k=8, M=32, d=32, n_layers=3): super().__init__() self.M = M self.layers = nn.ModuleList( nn.Sequential( nn.Linear(1 if i == 0 else d, d), nn.ReLU(), nn.Linear(d, d), ) for i in range(n_layers) ) self.out = nn.Linear(d, k) def forward(self, A): B, n, _ = A.shape h = torch.randn(B, self.M, n, 1) # M random scalar IDs per node A_s = (A + torch.eye(n)).unsqueeze( 1 ) # same graph for every sample for mlp in self.layers: h = F.relu( mlp(A_s @ h) ) # sum aggregation (with self), shared weights return self.out(h).mean( 1 ) # average over the M samples → [B, n, k] class PEARLTransformer(nn.Module): def __init__(self): super().__init__() self.pe, self.gt = PEARL(), GraphTransformer(d_pe=8) def forward(self, b, pe=None): return self.gt(b, self.pe(b["A"])) pearl_run = mo.ui.run_button(label="Train transformer + PEARL (≈ 20 s)") pearl_run
mo.stop( not pearl_run.value, mo.md( "Press the button to train the PEARL model on the same tree task." ), ) _t = time.time() torch.manual_seed(SEED) _pearl_model = PEARLTransformer() pearl_acc = fit_trees(_pearl_model) with torch.no_grad(): _again = ( (_pearl_model(trees_test).argmax(-1) == trees_test["y"]) .float() .mean() .item() ) # new random IDs mo.md( f"Transformer + **PEARL** PE: test accuracy **{pearl_acc:.3f}** (re-evaluated with fresh random IDs: {_again:.3f}), " f"trained in {time.time() - _t:.0f} s. No eigendecomposition, no sign ambiguity, and it works on any new graph." )
_rev = reveal( · 17 lines
_rev = reveal( r""" Measured: PEARL lifts the structure-blind transformer from 0.65 to **≈ 0.81** (0.82 with fresh random IDs at test time, so the result does not depend on one random draw). It sits below the Laplacian PE (0.97) and shortest-path bias (1.00) on this small, distance-defined task: those inject *exactly* the needed quantity, while PEARL must learn it. Its selling points are cost ($O(M|E|)$ instead of $O(n^3)$) and no sign or basis ambiguity. """, predict="Where will PEARL land between the structure-blind transformer (0.65) and the Laplacian PE (0.97)?", ) _post = mo.md(r""" ## 5 · On Cora: do graph transformers beat a GCN? Full attention over all 2,708 papers (7.3M attention scores per head per layer), compared with a 2-layer GCN and a **GPS-style** layer (Rampášek et al. 2022): $\mathbf h \leftarrow \mathbf h + \text{GCN}(\mathbf h) + \text{Attention}(\mathbf h)$, local and global together. """) mo.vstack([_rev, _post])
cora = planetoid("Cora")[0] · 77 lines
cora = planetoid("Cora")[0] cora_lap = T.AddLaplacianEigenvectorPE( k=16, attr_name="pe", is_undirected=True )(cora.clone()).pe class CoraModel(nn.Module): def __init__(self, kind, d=64, heads=4, d_pe=0): super().__init__() self.kind = kind self.inp = nn.Linear(cora.num_features + d_pe, d) self.gcn = ( nn.ModuleList(GCNConv(d, d) for _ in range(2)) if kind in ("GCN", "GPS") else None ) self.att = ( nn.ModuleList( nn.MultiheadAttention(d, heads, batch_first=True) for _ in range(2) ) if kind != "GCN" else None ) self.norm = nn.ModuleList(nn.LayerNorm(d) for _ in range(2)) self.out = nn.Linear(d, 7) def forward(self, x, edge_index): h = F.dropout(F.relu(self.inp(x)), 0.5, self.training) for i in range(2): upd = 0 if self.gcn is not None: upd = upd + self.gcn[i](h, edge_index) if self.att is not None: upd = ( upd + self.att[i]( h[None], h[None], h[None], need_weights=False )[0][0] ) h = self.norm[i]( h + F.dropout(F.relu(upd), 0.5, self.training) ) return self.out(h) def fit_cora(model, x, epochs=150, seed=SEED): torch.manual_seed(seed) opt = torch.optim.Adam(model.parameters(), lr=0.005, weight_decay=5e-4) best_val, best_test = 0, 0 for _ in range(epochs): model.train() opt.zero_grad() F.cross_entropy( model(x, cora.edge_index)[cora.train_mask], cora.y[cora.train_mask], ).backward() opt.step() model.eval() with torch.no_grad(): pred = model(x, cora.edge_index).argmax(1) val = ( (pred[cora.val_mask] == cora.y[cora.val_mask]) .float() .mean() .item() ) if val >= best_val: best_val, best_test = ( val, (pred[cora.test_mask] == cora.y[cora.test_mask]) .float() .mean() .item(), ) return best_test cora_run = mo.ui.run_button(label="Train on Cora (≈ 40 s)") cora_run
mo.stop( not cora_run.value, mo.md( "Press the button to compare GCN, transformers and GPS on Cora." ), ) _rows = [] for _name, _make, _x in [ ("GCN (local only)", lambda: CoraModel("GCN"), cora.x), ("Transformer, no PE (global only)", lambda: CoraModel("TF"), cora.x), ( "Transformer + Laplacian PE (k=16)", lambda: CoraModel("TF", d_pe=16), torch.cat([cora.x, cora_lap], 1), ), ("GPS-style: GCN + attention", lambda: CoraModel("GPS"), cora.x), ]: _t = time.time() _rows.append( { "model": _name, "test acc (best val)": fit_cora(_make(), _x), "seconds": time.time() - _t, } ) cora_results = pd.DataFrame(_rows) cora_results.round(3)
_rev = reveal( · 25 lines
_rev = reveal( r""" Measured (one seed, 150 epochs): **GCN 0.74 · GPS 0.75 · transformer 0.56 · transformer + LapPE 0.57**. On a small, homophilous citation graph with 140 labels, the useful signal is *local* (cited papers share topics). Global attention alone behaves like an MLP over the bag-of-words (≈ L03's 0.54) and costs 5× the time; Laplacian PEs don't rescue it here. GPS keeps the local GCN path and matches it. Graph transformers pay off on **long-range** tasks (§3) and on molecules, where graphs are small and global interactions matter; they are not a free upgrade. """, predict="On Cora, will a full graph transformer beat a 2-layer GCN? Where does GPS (local + global) land?", ) _post = mo.md(r""" --- ## 6 · Exercises **Exercise 1 (by hand).** Graphormer bias: two keys with scaled scores 14 and 12, and the second gets bias $c_{12}=-2$. What attention weight goes to token 2? → `ex1_alpha` (3 decimals). **Exercise 2 (code).** Write `my_sdpa(Q, K, V, mask=None)` (shapes `[n, d]`; boolean `mask[i, j]=True` means *allowed*) that matches `F.scaled_dot_product_attention`. **Exercise 3 (cost).** How many attention scores does one head of full attention compute per layer on a 1M-node social graph? → `ex3_scores`. """) mo.vstack([_rev, _post])
ex1_alpha = None · 5 lines
ex1_alpha = None ex3_scores = None def my_sdpa(Q, K, V, mask=None): return None
def _verdict(ok, answered): · 60 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_sdpa(): try: g = torch.Generator().manual_seed(1) Q, K, V = (torch.randn(10, 8, generator=g) for _ in range(3)) mask = torch.rand(10, 10, generator=g) > 0.3 mask |= torch.eye(10, dtype=torch.bool) got = my_sdpa(Q, K, V, mask) if got is None: return _verdict(False, False) ref = F.scaled_dot_product_attention( Q[None], K[None], V[None], attn_mask=mask[None] )[0] ok = torch.allclose(got, ref, atol=1e-5) and torch.allclose( my_sdpa(Q, K, V), F.scaled_dot_product_attention(Q[None], K[None], V[None])[0], atol=1e-5, ) return _verdict(ok, True) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_alpha is not None and abs(ex1_alpha - 1 / (1 + np.e**4)) < 2e-3, ex1_alpha is not None, ), mo.md("**Exercise 2**"), _check_sdpa(), mo.md("**Exercise 3**"), _verdict(ex3_scores == 10**12, ex3_scores is not None), mo.accordion( { "Solution 1": mo.md( r"Scores 14 and $12-2=10$: $\alpha_{12}=e^{10}/(e^{14}+e^{10})=1/(1+e^4)\approx 0.018$." ), "Solution 2": mo.md("""```python def my_sdpa(Q, K, V, mask=None): S = Q @ K.T / Q.size(-1) ** 0.5 if mask is not None: S = S.masked_fill(~mask, float("-inf")) return torch.softmax(S, dim=-1) @ V ```"""), "Solution 3": mo.md( r"$n^2=10^{12}$ scores, about 4 TB in float32 per head per layer. Hence sparse / linear attention (Exphormer, NodeFormer) or GPS with sampled global attention at social-network scale." ), } ), ] )
7 · Pitfall: a "graph" transformer that never sees the graph
nn.TransformerEncoder on node features alone is a set model: §2 showed its output does not depend on the edges, and on Cora
it scored like an MLP. Fix: give it structure explicitly: an attention bias or mask from the adjacency or shortest
paths, positional encodings that match the task (§3: RWPE did not help a distance task, LapPE and SPD did), or a local
message-passing branch (GPS).
8 · Connecting to your research (SNA on X/Twitter)
-
Full attention over millions of accounts is infeasible (\(10^{12}\) scores). Use GPS-style models on sampled subgraphs, or attention only inside a cascade / ego-network (tens to thousands of nodes) where it is cheap.
-
Retweet cascades are trees, like §3's task, and properties such as "depth from the source" or "distance to a seed account" are exactly what shortest-path biases encode. A Graphormer-style cascade classifier is a natural experiment.
-
PEARL-style random-ID encodings are inductive, so they can embed new accounts and new cascades without eigendecomposition.
Summary
- Attention = message passing on the complete graph (masked attention = neighbour attention, checked to 3e-6).
-
A transformer needs structure back: PEs, attention biases, or local MP. On the 3-hop tree task: no structure 0.65, RWPE 0.67, PEARL 0.81, LapPE 0.97, shortest-path bias 1.00, GCN-4 0.99.
-
On Cora (local, homophilous) global attention alone ≈ MLP (0.56); GPS ≈ GCN (0.75).
Next — L09 · Heterogeneous graphs: node and edge types, R-GCN, HGT.