Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L04 · A general perspective on GNNs

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:

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

  1. One layer = message + aggregation — PyG's MessagePassing in 10 lines.
  2. GCN, GraphSAGE, GAT from scratch — each checked against PyG with identical weights.
  3. GAT attention by hand — the lecture's worked example, interactively; why weights that sum to 1 can't count.
  4. Layers in practice — the modern layer; GCN vs. SAGE vs. GAT on Cora.
  5. Stacking layers — receptive fields, over-smoothing, and skip connections (an experiment you run on demand).
  6. Exercises, pitfall, research links.

1 · One layer = message + aggregation

\[\mathbf m_u^{(l)}=\text{MSG}^{(l)}\big(\mathbf h_u^{(l-1)}\big),\qquad \mathbf h_v^{(l)}=\text{AGG}^{(l)}\Big(\{\mathbf m_u^{(l)}:u\in N(v)\},\ \mathbf m_v^{(l)}\Big)\]

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)

Summary

Next — L05 · GNN augmentation and training: feature augmentation, virtual nodes, neighbour sampling, prediction heads, and correct splits for link prediction.