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/L04_general_perspective_gnns.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 hashlib import json 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 from torch_geometric.nn import GATConv, GCNConv, MessagePassing, SAGEConv from torch_geometric.utils import ( add_self_loops, degree, k_hop_subgraph, softmax, ) 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, } )
L04 · A general perspective on GNNs
CS224W Machine Learning with Graphs · hands-on companion to Lecture 4
Audience: you did L03 (GCN by hand, training on Cora). Prerequisites: softmax, batch-norm and dropout at a "have used it" level.
By the end you can:
- describe any GNN layer as message + aggregation and implement it with PyG's
MessagePassingbase class; - write GCN, GraphSAGE and GAT layers from scratch, check them against PyG, and compute GAT attention by hand;
- measure the receptive field and show over-smoothing as depth grows, and fix it with skip connections;
- build the "modern" layer (Linear → BatchNorm → Dropout → Activation → Aggregation) with pre/post MLPs.
In short. A GNN is five choices: message, aggregation, layer connectivity, graph augmentation, objective (L05 covers the last two). GCN, GraphSAGE and GAT differ mainly in who weighs the neighbours: GCN by degree, GraphSAGE concatenates self with a pooled summary, GAT learns the weights with attention. Deeper is not better: receptive fields cover the whole graph and embeddings become indistinguishable (over-smoothing). Remedies: shallow but expressive layers, pre/post MLPs, skip connections.
Outline
- One layer = message + aggregation — PyG's
MessagePassingin 10 lines. - GCN, GraphSAGE, GAT from scratch — each checked against PyG with identical weights.
- GAT attention by hand — the lecture's worked example, interactively; why weights that sum to 1 can't count.
- Layers in practice — the modern layer; GCN vs. SAGE vs. GAT on Cora.
- Stacking layers — receptive fields, over-smoothing, and skip connections (an experiment you run on demand).
- Exercises, pitfall, research links.
1 · One layer = message + aggregation
PyG's MessagePassing base class implements exactly this. You write message() (one message per edge \(u\to v\), with
x_j = source features, x_i = target features), choose aggr ("add", "mean", "max"), and call self.propagate().
class MyGCNConv(MessagePassing): · 117 lines
class MyGCNConv(MessagePassing): """GCN: message = W h_u / sqrt(d_u d_v), aggregation = sum, self-loops included.""" def __init__(self, d_in, d_out): super().__init__(aggr="add") self.lin = nn.Linear(d_in, d_out, bias=False) self.bias = nn.Parameter(torch.zeros(d_out)) def forward(self, x, edge_index): edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0)) src, dst = edge_index deg = degree(dst, x.size(0)) norm = deg[src].pow(-0.5) * deg[dst].pow(-0.5) return ( self.propagate(edge_index, x=self.lin(x), norm=norm) + self.bias ) def message(self, x_j, norm): return norm.unsqueeze(-1) * x_j class MySAGEConv(MessagePassing): """GraphSAGE-mean: h_v' = W_l · mean_{u∈N(v)} h_u + W_r · h_v (= W [h_v || h_N(v)]).""" def __init__(self, d_in, d_out): super().__init__(aggr="mean") self.lin_l = nn.Linear(d_in, d_out) # neighbour summary self.lin_r = nn.Linear(d_in, d_out, bias=False) # self def forward(self, x, edge_index): return self.lin_l(self.propagate(edge_index, x=x)) + self.lin_r(x) def message(self, x_j): return x_j class MyGATConv(MessagePassing): """Single-head GAT: e_vu = LeakyReLU(a_src·Wh_u + a_dst·Wh_v), α = softmax over N(v)∪{v}, h_v' = Σ α_vu W h_u.""" def __init__(self, d_in, d_out): super().__init__(aggr="add") self.lin = nn.Linear(d_in, d_out, bias=False) self.att_src = nn.Parameter(torch.randn(d_out) * 0.1) self.att_dst = nn.Parameter(torch.randn(d_out) * 0.1) self.bias = nn.Parameter(torch.zeros(d_out)) def forward(self, x, edge_index, return_attention=False): edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0)) h = self.lin(x) a_src = (h * self.att_src).sum( -1, keepdim=True ) # [N, 1] per-node halves of the score a_dst = (h * self.att_dst).sum(-1, keepdim=True) out = ( self.propagate(edge_index, h=h, a_src=a_src, a_dst=a_dst) + self.bias ) return ( (out, (edge_index, self._alpha)) if return_attention else out ) def message(self, h_j, a_src_j, a_dst_i, index): e = F.leaky_relu( a_src_j + a_dst_i, 0.2 ) # one raw score per edge u→v alpha = softmax( e, index ) # normalise over each target's incoming edges self._alpha = alpha.squeeze(-1) return alpha * h_j cora = planetoid("Cora")[0] def compare_with_pyg(): torch.manual_seed(SEED) rows = [] pairs = [ ( "GCN", MyGCNConv(cora.num_features, 16), GCNConv(cora.num_features, 16), ), ( "GraphSAGE-mean", MySAGEConv(cora.num_features, 16), SAGEConv(cora.num_features, 16), ), ( "GAT (1 head)", MyGATConv(cora.num_features, 16), GATConv(cora.num_features, 16, heads=1), ), ] for name, mine, ref in pairs: with torch.no_grad(): for n_ref, p_ref in ref.named_parameters(): p_ref.copy_( dict(mine.named_parameters())[n_ref].view_as(p_ref) ) # identical weights gap = ( ( mine(cora.x, cora.edge_index) - ref(cora.x, cora.edge_index) ) .abs() .max() .item() ) rows.append( { "layer": name, "parameters": sum(p.numel() for p in mine.parameters()), "max |mine − PyG|": gap, } ) return pd.DataFrame(rows) compare_with_pyg()
All three hand-written layers agree with PyG to the last bit (difference 0.0) once the weights are copied over: the library layers are just these few lines plus sparse scatter kernels. Note GraphSAGE has twice the parameters of GCN: separate matrices for self and neighbours (\(W[\mathbf h_v\,\|\,\mathbf h_{N(v)}]\)), instead of folding self into the average with a self-loop.
| message | aggregation | neighbour weight | |
|---|---|---|---|
| GCN | \(W\mathbf h_u/\sqrt{d_ud_v}\) | sum (with self-loop) | fixed by degree |
| GraphSAGE | \(\mathbf h_u\) (inside AGG) | mean / max / LSTM, then concat with self | fixed: $1/ |
| GAT | \(W\mathbf h_u\) | \(\sum_u\alpha_{vu}\,\cdot\) | learned \(\alpha_{vu}=\text{softmax}_u(e_{vu})\) |
3 · GAT attention by hand
Node A has neighbours B, C, D with raw scores \(e_{AB}, e_{AC}, e_{AD}\). The lecture uses \(1, 0, -1\) → \(\alpha = 0.665, 0.245, 0.090\), versus GCN-style \(1/3\) each.
att_scores = mo.ui.array( [ mo.ui.slider( -3, 3, step=0.5, value=v, label=f"e_A{n}", show_value=True ) for n, v in zip("BCD", [1, 0, -1]) ] ) mo.vstack( [ mo.md( "**Try it:** make all scores equal (GAT becomes mean aggregation), or push one score up (attention concentrates)." ), mo.hstack(list(att_scores), justify="start"), ] )
_e = np.array(att_scores.value, dtype=float) _alpha = np.exp(_e) / np.exp(_e).sum() _fig, _ax = plt.subplots(figsize=(5, 2.4)) _x = np.arange(3) _ax.bar(_x - 0.2, _alpha, 0.4, label="GAT α = softmax(e)", color="#4C78A8") _ax.bar( _x + 0.2, [1 / 3] * 3, 0.4, label="GCN / mean: 1/|N(v)|", color="#BBBBBB", ) for _i, _a in enumerate(_alpha): _ax.text(_i - 0.2, _a + 0.02, f"{_a:.3f}", ha="center", fontsize=8) _ax.set_xticks(_x, ["B", "C", "D"]) _ax.set_ylim(0, 1.05) _ax.legend(fontsize=7) _ax.set_title("weights on A's neighbours", fontsize=9) _ax
Why weights that sum to 1 cannot count
Because \(\sum_u\alpha_{vu}=1\), GAT (like mean) outputs a weighted average. A node with 2 identical neighbours and a node with 5 identical neighbours receive the same message. Below, every node has feature 1; we compare a hub with \(k\) leaves for \(k=2\) and \(k=5\), through untrained layers that use neighbour aggregation only (GraphSAGE's self term switched off):
def star(k): """Hub 0 with k leaves; every node has the same feature vector.""" ei = torch.tensor( [[0] * k + list(range(1, k + 1)), list(range(1, k + 1)) + [0] * k] ) return torch.ones(k + 1, 4), ei class SumAgg(MessagePassing): def __init__(self): super().__init__(aggr="add") def forward(self, x, edge_index): return self.propagate(edge_index, x=x) torch.manual_seed(SEED) _gat, _sage = MyGATConv(4, 4), MySAGEConv(4, 4) with torch.no_grad(): _sage.lin_r.weight.zero_() # neighbour aggregation only _rows = [] for _name, _layer in [ ("GAT (attention, Σα=1)", _gat), ("SAGE-mean neighbour term", _sage), ("plain sum", SumAgg()), ]: with torch.no_grad(): _h2 = _layer(*star(2))[0] _h5 = _layer(*star(5))[0] _rows.append( { "aggregator": _name, "hub output, k=2": round(_h2[0].item(), 4), "hub output, k=5": round(_h5[0].item(), 4), "can tell k=2 from k=5?": not torch.allclose(_h2, _h5), } ) pd.DataFrame(_rows)
With equal features, the hub's output is identical for 2 and 5 leaves under GAT and under SAGE's mean term, while the plain sum gives 2 vs 5. Attention re-weights neighbours but cannot count them, which limits expressive power. L06 makes this precise and introduces GIN (sum aggregation + MLP).
4 · GNN layers in practice
Design-space studies (You et al., 2020) suggest a "modern" layer pipeline:
Linear → BatchNorm → Dropout → Activation → (Attention) → Aggregation, plus pre-processing MLP layers (encode raw
features) and post-processing MLP layers (the prediction head). Below is one configurable model used for the rest of
the notebook; conv chooses the aggregation (GCN / SAGE / GAT), skip adds residual connections.
class FlexGNN(nn.Module): · 74 lines
class FlexGNN(nn.Module): """pre-MLP → L × [conv → BatchNorm → ReLU → Dropout (+ skip)] → post-MLP head.""" def __init__( self, d_in, d_hid, n_cls, n_layers=2, conv="GCN", skip=False, bn=True, dropout=0.5, heads=4, ): super().__init__() make = { "GCN": lambda: GCNConv(d_hid, d_hid), "SAGE": lambda: SAGEConv(d_hid, d_hid), "GAT": lambda: GATConv(d_hid, d_hid // heads, heads=heads), }[conv] self.pre = nn.Linear(d_in, d_hid) self.convs = nn.ModuleList(make() for _ in range(n_layers)) self.norms = nn.ModuleList( nn.BatchNorm1d(d_hid) if bn else nn.Identity() for _ in range(n_layers) ) self.post = nn.Sequential( nn.Linear(d_hid, d_hid), nn.ReLU(), nn.Linear(d_hid, n_cls) ) self.skip, self.p = skip, dropout def forward(self, x, edge_index, return_hidden=False): h = F.relu(self.pre(x)) for conv, norm in zip(self.convs, self.norms): out = F.dropout( F.relu(norm(conv(h, edge_index))), self.p, self.training ) h = h + out if self.skip else out # F(x) + x vs F(x) return (self.post(h), h) if return_hidden else self.post(h) def fit(model, data, epochs=200, lr=0.01, wd=5e-4): """Full-batch training; returns test accuracy at the best-validation epoch and the model.""" opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=wd) best_val, best_test = -1, 0 for _ in range(epochs): model.train() opt.zero_grad() F.cross_entropy( model(data.x, data.edge_index)[data.train_mask], data.y[data.train_mask], ).backward() opt.step() model.eval() with torch.no_grad(): pred = model(data.x, data.edge_index).argmax(1) val = ( (pred[data.val_mask] == data.y[data.val_mask]) .float() .mean() .item() ) if val >= best_val: best_val, best_test = ( val, (pred[data.test_mask] == data.y[data.test_mask]) .float() .mean() .item(), ) return best_test, model layer_run = mo.ui.run_button(label="Train GCN / SAGE / GAT (≈ 20 s)") layer_run
mo.stop( not layer_run.value, mo.md( "Press the button to compare the three layers on Cora (2 seeds × 3 layers, 2 conv layers, 64 hidden)." ), ) _rows = [] for _conv in ["GCN", "SAGE", "GAT"]: for _s in range(2): torch.manual_seed(_s) _t = time.time() _acc, _m = fit( FlexGNN(cora.num_features, 64, 7, n_layers=2, conv=_conv), cora ) _rows.append( { "layer": _conv, "seed": _s, "test acc": _acc, "seconds": time.time() - _t, "parameters": sum(p.numel() for p in _m.parameters()), } ) layer_results = pd.DataFrame(_rows) layer_results.groupby("layer").agg( test_acc=("test acc", "mean"), seconds=("seconds", "mean"), parameters=("parameters", "first"), ).round(3)
_rev = reveal( · 16 lines
_rev = reveal( r""" Measured (2 seeds, 64 hidden, pre/post MLPs + BatchNorm): **GCN ≈ 0.77, GAT ≈ 0.76, SAGE ≈ 0.74**, each ≈ 3 s. On a small, homophilous citation graph the three are within a few points — the *layer* matters less than people expect. (The plain 2-layer GCN of L03 scored ≈ 0.79: with only 140 labels the extra pre/post layers and BatchNorm add parameters without adding much. Design choices interact with data size, which is the point of L19's design-space study.) """, predict="GCN, GAT and GraphSAGE with identical pre/post MLPs on Cora: will they be 1, 5 or 15 points apart?", ) _post = mo.md(r""" ## 5 · Stacking layers: receptive fields and over-smoothing The **receptive field** of a $K$-layer GNN is the $K$-hop neighbourhood. On Cora, measured for 200 random papers: """) mo.vstack([_rev, _post])
def receptive_field_stats(K, n_pairs=100, seed=SEED): """Mean size of K-hop neighbourhoods and mean Jaccard overlap between two random nodes' neighbourhoods.""" r = np.random.default_rng(seed) nodes = r.choice(cora.num_nodes, 2 * n_pairs, replace=False) sizes, overlaps = [], [] for a, b in zip(nodes[::2], nodes[1::2]): sa = set( k_hop_subgraph( int(a), K, cora.edge_index, num_nodes=cora.num_nodes )[0].tolist() ) sb = set( k_hop_subgraph( int(b), K, cora.edge_index, num_nodes=cora.num_nodes )[0].tolist() ) sizes += [len(sa), len(sb)] overlaps.append(len(sa & sb) / len(sa | sb)) return { "K": K, "mean receptive field (nodes)": np.mean(sizes), "share of graph": np.mean(sizes) / cora.num_nodes, "overlap of two random nodes (Jaccard)": np.mean(overlaps), } receptive = pd.DataFrame([receptive_field_stats(K) for K in range(1, 9)]) _fig, _ax = plt.subplots(figsize=(6, 2.6)) _ax.plot( receptive.K, receptive["share of graph"], "o-", label="receptive field / |V|", ) _ax.plot( receptive.K, receptive["overlap of two random nodes (Jaccard)"], "s-", label="overlap of two random nodes", ) _ax.set_xlabel("layers K") _ax.set_ylim(0, 1) _ax.legend(fontsize=8) _ax.set_title("Cora: receptive fields grow exponentially", fontsize=9) mo.hstack([_fig, receptive.round(3)], widths=[3, 2])
A paper sees ≈ 5 nodes at \(K=1\), ≈ 35 at \(K=2\), ≈ 840 (31% of Cora) at \(K=5\) and ≈ 2,000 (75%) at \(K=8\). More importantly, two random papers share ≈ 25% of their receptive fields at \(K=5\) and ≈ 72% at \(K=8\): their embeddings are computed from nearly the same inputs, so they become nearly the same vector. (Cora's largest connected component has 2,485 nodes; the rest sit in small islands, which caps these numbers.)
Lesson 1 — be cautious with depth. Pick \(L\) just above the receptive field the task needs; get expressiveness from deeper MLPs inside layers and from pre/post-processing layers instead. Lesson 2 — skip connections, \(F(\mathbf x)\to F(\mathbf x)+\mathbf x\): \(N\) skips create \(2^N\) paths, an implicit mixture of shallow and deep models. The experiment below trains GCNs of depth 1–32 with and without skips and records test accuracy and over-smoothing = mean cosine similarity between the final hidden vectors of 500 random nodes (1 = all identical).
def depth_experiment(depths=(1, 2, 4, 8, 16, 32), seed=SEED): · 55 lines
def depth_experiment(depths=(1, 2, 4, 8, 16, 32), seed=SEED): key = hashlib.sha1( json.dumps({"depths": list(depths), "seed": seed, "v": 1}).encode() ).hexdigest()[:10] path = CACHE_DIR / f"L04_depth_{key}.json" if path.exists(): return pd.read_json(path), True sample = torch.from_numpy( np.random.default_rng(seed).choice( cora.num_nodes, 500, replace=False ) ) rows = [] for L in depths: for skip in (False, True): torch.manual_seed(seed) t = time.time() acc, model = fit( FlexGNN( cora.num_features, 64, 7, n_layers=L, conv="GCN", skip=skip, ), cora, ) model.eval() with torch.no_grad(): h = F.normalize( model(cora.x, cora.edge_index, return_hidden=True)[1][ sample ], dim=1, ) rows.append( { "layers": L, "skip": skip, "test acc": acc, "mean cosine of hidden vectors": (h @ h.T) .mean() .item(), "seconds": time.time() - t, } ) df = pd.DataFrame(rows) df.to_json(path) return df, False depth_run = mo.ui.run_button( label="Run depth experiment (≈ 3 min first time, then cached)" ) depth_run
mo.stop( not depth_run.value, mo.md( "Press the button. Results are cached in `notebooks/cs224w/cache/`; later runs load instantly." ), ) depth_results, _cached = depth_experiment() _fig, _axs = plt.subplots(1, 2, figsize=(8, 2.8)) for _skip, _style in [(False, "o-"), (True, "s--")]: _d = depth_results[depth_results.skip == _skip] _axs[0].plot( _d.layers, _d["test acc"], _style, label="with skip" if _skip else "plain", ) _axs[1].plot( _d.layers, _d["mean cosine of hidden vectors"], _style, label="with skip" if _skip else "plain", ) for _a, _t in zip( _axs, ["test accuracy", "over-smoothing: mean cosine of hidden vectors"], ): _a.set_xscale("log", base=2) _a.set_xlabel("layers") _a.set_title(_t, fontsize=9) _a.legend(fontsize=8) plt.tight_layout() mo.vstack( [ _fig, mo.md( f"{'Loaded from cache.' if _cached else 'Computed and cached.'}" ), depth_results.pivot( index="layers", columns="skip", values=["test acc", "mean cosine of hidden vectors"], ).round(3), ] )
_rev = reveal( · 28 lines
_rev = reveal( r""" Measured (one seed, so ±2–3 points of noise per cell): * **Plain GCN** peaks at **4 layers (0.80)**, then collapses: 0.70 at 8, 0.50 at 16, **0.32 at 32**. The hidden vectors of random nodes become near-parallel (mean cosine 0.76 at 32 layers vs. ≈ 0.4 for shallow models). That is over-smoothing, together with the general difficulty of training very deep stacks. * **With skip connections** accuracy stays at **0.70–0.75 all the way to 32 layers**. Skips keep each node's own signal alive. * Skips are *not* free: at 1–2 layers they score lower here (0.70 vs. 0.77), because the residual path keeps a lot of the pre-MLP's feature-only signal. Depth and skips are design choices to tune, not defaults. """, predict="At 32 layers, what happens to a plain GCN's accuracy, and what do skip connections change? Guess both numbers before you open this.", ) _post = mo.md(r""" --- ## 6 · Exercises **Exercise 1 (by hand).** Raw GAT scores for A's neighbours B, C, D are $(2, 2, 0)$. What attention weight does D get? → `ex1_alpha_D` (3 decimals). **Exercise 2 (parameters).** How many trainable parameters does `SAGEConv(64, 64)` have (PyG: `lin_l` with bias, `lin_r` without)? → `ex2_params`. **Exercise 3 (code).** Implement `MyMaxSAGE`, GraphSAGE with **max** aggregation, using `MessagePassing`. It must match `SAGEConv(d_in, d_out, aggr="max")` when the weights are copied (parameter names `lin_l`, `lin_r`). """) mo.vstack([_rev, _post])
ex1_alpha_D = None · 14 lines
ex1_alpha_D = None ex2_params = None class MyMaxSAGE(MessagePassing): def __init__(self, d_in, d_out): super().__init__(aggr="mean") # <- change things here self.lin_l = nn.Linear(d_in, d_out) self.lin_r = nn.Linear(d_in, d_out, bias=False) def forward(self, x, edge_index): return None # <- and here def message(self, x_j): return x_j
def _verdict(ok, answered): · 66 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_max_sage(): try: torch.manual_seed(SEED) mine, ref = ( MyMaxSAGE(cora.num_features, 8), SAGEConv(cora.num_features, 8, aggr="max"), ) with torch.no_grad(): for n, p in ref.named_parameters(): p.copy_(dict(mine.named_parameters())[n]) out = mine(cora.x, cora.edge_index) if out is None: return _verdict(False, False) return _verdict( torch.allclose( out, ref(cora.x, cora.edge_index), 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_alpha_D is not None and abs(ex1_alpha_D - 1 / (2 * np.e**2 + 1)) < 2e-3, ex1_alpha_D is not None, ), mo.md("**Exercise 2**"), _verdict(ex2_params == 64 * 64 * 2 + 64, ex2_params is not None), mo.md("**Exercise 3**"), _check_max_sage(), mo.accordion( { "Solution 1": mo.md( r"$\alpha_D=e^0/(e^2+e^2+e^0)=1/(2e^2+1)=1/15.78\approx 0.063$; B and C get 0.468 each." ), "Solution 2": mo.md( r"`lin_l`: $64\times64+64$, `lin_r`: $64\times64$ → **8,256**. Twice a GCN layer's $64\times64+64=4{,}160$." ), "Solution 3": mo.md("""```python class MyMaxSAGE(MessagePassing): def __init__(self, d_in, d_out): super().__init__(aggr="max") self.lin_l = nn.Linear(d_in, d_out) self.lin_r = nn.Linear(d_in, d_out, bias=False) def forward(self, x, edge_index): return self.lin_l(self.propagate(edge_index, x=x)) + self.lin_r(x) def message(self, x_j): return x_j ``` Only `aggr` changes: the framework separates *what is sent* (`message`) from *how it is combined* (`aggr`)."""), } ), ] )
7 · Pitfall: multi-head GAT changes the output width
GATConv(64, 64, heads=4) runs 4 heads of width 64 and concatenates them: the output is 256-wide, and the next
layer that expects 64 inputs crashes (or, worse, a Linear(-1, ...) silently absorbs it and quadruples your parameters).
_x = torch.randn(cora.num_nodes, 64) · 25 lines
_x = torch.randn(cora.num_nodes, 64) pd.DataFrame( [ { "layer": "GATConv(64, 64, heads=4)", "output shape": tuple( GATConv(64, 64, heads=4)(_x, cora.edge_index).shape ), }, { "layer": "GATConv(64, 16, heads=4) ← width per head = 64/4", "output shape": tuple( GATConv(64, 16, heads=4)(_x, cora.edge_index).shape ), }, { "layer": "GATConv(64, 64, heads=4, concat=False) ← average heads", "output shape": tuple( GATConv(64, 64, heads=4, concat=False)( _x, cora.edge_index ).shape ), }, ] )
Fix: set out_channels = hidden // heads (as FlexGNN does) or concat=False for the last layer.
8 · Connecting to your research (SNA on X/Twitter)
-
Attention as a diagnostic. GAT's \(\alpha_{vu}\) tell you which retweet partners drive an account's prediction —
MyGATConv(..., return_attention=True)gives them per edge. Treat them as hints, not explanations (L21 covers proper explainers). -
Choose depth from the question. "Does this account belong to camp A?" needs a 1–3 hop view. A 10-layer GNN on a retweet graph with a small diameter will over-smooth; use skips or keep it shallow.
-
Counting matters for SNA. "Retweeted by 2 vs. 200 accounts" is a key signal; mean/attention aggregators cannot count (§3), so prefer sum-based layers (GIN, L06) or add degree as a feature (L05).
Summary
- Every GNN layer = message + aggregation; PyG's
MessagePassingis that template. GCN, SAGE, GAT are ≈ 10 lines each and match PyG exactly. - GCN weighs neighbours by degree, SAGE concatenates self with a pooled summary, GAT learns \(\alpha_{vu}\) by softmax. Weights summing to 1 cannot count.
- On Cora the three layers land within a few points (≈ 0.74–0.77).
- Receptive fields explode (two random Cora papers share 72% of their 8-hop fields). Plain deep GCNs over-smooth (0.80 at 4 layers → 0.32 at 32); skip connections keep ≈ 0.75.
Next — L05 · GNN augmentation and training: feature augmentation, virtual nodes, neighbour sampling, prediction heads, and correct splits for link prediction.