Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L19 · Conclusion — the GNN design space

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/L19_conclusion_design_space.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 mean_std, reveal, stable_seed
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 scipy.stats import kendalltau
from torch_geometric.data import Data
from torch_geometric.nn import GATConv, GCNConv, SAGEConv
from torch_geometric.utils import from_networkx

SEED = 0
# every design is trained with these seeds and the test accuracies averaged
SEEDS = (0, 1, 2)
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,
    }
)

L19 · Conclusion — the GNN design space

CS224W Machine Learning with Graphs · hands-on companion to Lecture 19

Audience: you did L03–L05 (GNN layers, the modern layer, training). Prerequisites: rank correlation (Kendall's τ) at a "have heard of it" level.

By the end you can:

In short. There are too many architectures, and the best one for one task can be bad for another. Design Space of GNNs (You, Ying, Leskovec 2020) studies a Cartesian product of design choices (315K designs) over 32 tasks. Controlled random search evaluates one dimension by sampling random design–task configurations, flipping only that dimension, and comparing rankings. Task similarity = how similarly 12 fixed anchor models rank on two tasks; for a new task, run the anchors, find the nearest known task and reuse its best design. The platform is GraphGym (in PyG).

Outline

  1. Kendall's τ by hand — the lecture's worked example.
  2. A small design space and task space — 72 designs × 4 node-classification tasks.
  3. Controlled random search — is BatchNorm useful? Are skip connections?
  4. Task similarity and transfer — anchors, τ matrix, and picking a design for a "new" task.
  5. Exercises, research links, and the course on one page.

1 · Kendall's τ by hand

Task A ranks five anchor models M1 > M2 > M3 > M4 > M5; Task B ranks M1 > M3 > M2 > M4 > M5. Of the \(\binom52=10\) pairs only (M2, M3) is ordered differently → \(\tau=(9-1)/10=0.8\). Task C (M5, M1, M4, M3, M2) reverses most pairs.

_rank_A = {"M1": 1, "M2": 2, "M3": 3, "M4": 4, "M5": 5}
_rank_B = {"M1": 1, "M3": 2, "M2": 3, "M4": 4, "M5": 5}
_rank_C = {"M5": 1, "M1": 2, "M4": 3, "M3": 4, "M2": 5}
_models = sorted(_rank_A)
pd.DataFrame(
    [
        {
            "pair of tasks": f"A vs {n}",
            "Kendall τ": kendalltau(
                [_rank_A[m] for m in _models], [r[m] for m in _models]
            ).statistic,
        }
        for n, r in [("B", _rank_B), ("C", _rank_C)]
    ]
)

τ(A, B) = 0.8, τ(A, C) = −0.4, as on the slide. A new task resembling A should reuse A's best design and ignore C's.

2 · A small design space and task space

Design space (3 × 3 × 2 × 2 × 2 = 72 designs): layer type {GCN, GraphSAGE, GAT} · message-passing layers {1, 2, 3} · BatchNorm {no, yes} · skip connections {no, yes} · hidden size {32, 64}. Every model has a pre-MLP and a linear head (L04's "modern layer").

Task space (4 node-classification tasks):

def sbm_task(homophilous, n=600, k=3, seed=SEED): · 63 lines
def sbm_task(homophilous, n=600, k=3, seed=SEED):
    r = np.random.default_rng(seed)
    sizes = [n // k] * k
    p_in, p_out = (0.05, 0.005) if homophilous else (0.005, 0.03)
    g = nx.stochastic_block_model(
        sizes,
        [[p_in if i == j else p_out for j in range(k)] for i in range(k)],
        seed=seed,
    )
    y = torch.tensor(np.repeat(np.arange(k), sizes))
    x = torch.tensor(
        np.eye(k)[y.numpy()] * 0.6 + r.normal(0, 1, (n, k)),
        dtype=torch.float,
    )  # weak features
    x = torch.cat(
        [x, torch.tensor(r.normal(0, 1, (n, 13)), dtype=torch.float)], 1
    )
    data = from_networkx(g)
    data.x, data.y = x, y
    perm = torch.randperm(n, generator=torch.Generator().manual_seed(seed))
    data.train_mask = torch.zeros(n, dtype=torch.bool)
    data.train_mask[perm[:60]] = True
    data.val_mask = torch.zeros(n, dtype=torch.bool)
    data.val_mask[perm[60:200]] = True
    data.test_mask = torch.zeros(n, dtype=torch.bool)
    data.test_mask[perm[200:]] = True
    return data

TASKS = {
    "Cora": planetoid("Cora")[0],
    "CiteSeer": planetoid("CiteSeer")[0],
    "SBM-homophilous": sbm_task(True),
    "SBM-heterophilous": sbm_task(False),
}
DESIGN_SPACE = {
    "layer": ["GCN", "SAGE", "GAT"],
    "n_layers": [1, 2, 3],
    "batchnorm": [False, True],
    "skip": [False, True],
    "hidden": [32, 64],
}

def edge_homophily(d):
    return (
        (d.y[d.edge_index[0]] == d.y[d.edge_index[1]])
        .float()
        .mean()
        .item()
    )

pd.DataFrame(
    [
        {
            "task": n,
            "nodes": d.num_nodes,
            "edges": d.edge_index.size(1) // 2,
            "classes": int(d.y.max()) + 1,
            "train labels": int(d.train_mask.sum()),
            "edge homophily": edge_homophily(d),
        }
        for n, d in TASKS.items()
    ]
).round(3)
class DesignGNN(nn.Module): · 102 lines
class DesignGNN(nn.Module):
    def __init__(self, design, d_in, n_cls):
        super().__init__()
        h, self.design = design["hidden"], design
        make = {
            "GCN": lambda: GCNConv(h, h),
            "SAGE": lambda: SAGEConv(h, h),
            "GAT": lambda: GATConv(h, h // 4, heads=4),
        }[design["layer"]]
        self.pre = nn.Linear(d_in, h)
        self.convs = nn.ModuleList(
            make() for _ in range(design["n_layers"])
        )
        self.norms = nn.ModuleList(
            nn.BatchNorm1d(h) if design["batchnorm"] else nn.Identity()
            for _ in range(design["n_layers"])
        )
        self.head = nn.Linear(h, n_cls)

    def forward(self, x, ei):
        h = F.relu(self.pre(x))
        for conv, norm in zip(self.convs, self.norms):
            out = F.dropout(F.relu(norm(conv(h, ei))), 0.5, self.training)
            h = h + out if self.design["skip"] else out
        return self.head(h)

RESULTS_PATH = CACHE_DIR / "L19_design_results.json"

def evaluate_design(design, task, seed=SEED, epochs=100):
    """Test accuracy at the best-validation epoch; cached on disk by (design, task, seed)."""
    key = json.dumps(
        {"design": design, "task": task, "seed": seed, "epochs": epochs},
        sort_keys=True,
    )
    cache = (
        json.loads(RESULTS_PATH.read_text())
        if RESULTS_PATH.exists()
        else {}
    )
    if key in cache:
        return cache[key]
    d = TASKS[task]
    torch.manual_seed(seed)
    model = DesignGNN(design, d.num_features, int(d.y.max()) + 1)
    opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
    best_val, best_test = -1.0, 0.0
    for _ in range(epochs):
        model.train()
        opt.zero_grad()
        F.cross_entropy(
            model(d.x, d.edge_index)[d.train_mask], d.y[d.train_mask]
        ).backward()
        opt.step()
        model.eval()
        with torch.no_grad():
            pred = model(d.x, d.edge_index).argmax(1)
        val = (pred[d.val_mask] == d.y[d.val_mask]).float().mean().item()
        if val > best_val:
            best_val, best_test = (
                val,
                (pred[d.test_mask] == d.y[d.test_mask])
                .float()
                .mean()
                .item(),
            )
    cache = (
        json.loads(RESULTS_PATH.read_text())
        if RESULTS_PATH.exists()
        else {}
    )
    cache[key] = best_test
    RESULTS_PATH.write_text(json.dumps(cache))
    return best_test

def evaluate_mean(design, task, seeds=SEEDS):
    """Mean test accuracy over `seeds` (each seed cached separately)."""
    return float(np.mean([evaluate_design(design, task, seed=s) for s in seeds]))

def random_design(r):
    return {
        k: (
            v[int(r.integers(len(v)))]
            if not isinstance(v[0], bool)
            else bool(r.integers(2))
        )
        for k, v in DESIGN_SPACE.items()
    }

_t = time.time()
_demo = evaluate_design(
    {
        "layer": "GCN",
        "n_layers": 2,
        "batchnorm": False,
        "skip": False,
        "hidden": 64,
    },
    "Cora",
)
mo.md(
    f"One design on Cora (2-layer GCN, 64 hidden): test acc **{_demo:.3f}** in {time.time() - _t:.1f} s (cached afterwards)."
)

Edge homophily confirms the task space spans two regimes: Cora, CiteSeer and SBM-homophilous ≈ 0.74–0.84 (neighbours share labels), SBM-heterophilous 0.075 (neighbours almost never do). Every run below is cached in notebooks/cs224w/cache/L19_design_results.json.

To ask "is BatchNorm useful?" without cherry-picking one model: sample \(K\) random (design, task) configurations; for each, train two models that differ only in BatchNorm; rank the pair (1 = better, 2 = worse, 1.5 for a tie). The distribution of ranks over many configurations is the answer, and it is robust to the other choices. Same for skip connections.

def controlled_random_search(dimension, k=20, seed=SEED):
    # NOT seed + hash(dimension): str hashes are randomised per Python process,
    # which made the sampled configurations (and the cache hits) differ on every run
    r = np.random.default_rng(stable_seed("crs", dimension, base=seed))
    rows = []
    for _ in range(k):
        design, task = (
            random_design(r),
            list(TASKS)[int(r.integers(len(TASKS)))],
        )
        accs = {}
        for value in (False, True):
            accs[value] = evaluate_mean({**design, dimension: value}, task)
        rank_true = (
            1.0
            if accs[True] > accs[False]
            else 2.0
            if accs[True] < accs[False]
            else 1.5
        )
        rows.append(
            {
                "task": task,
                **{f"{d}": v for d, v in design.items() if d != dimension},
                f"acc {dimension}=False": accs[False],
                f"acc {dimension}=True": accs[True],
                f"rank of {dimension}=True": rank_true,
            }
        )
    return pd.DataFrame(rows)

crs_run = mo.ui.run_button(
    label="Run controlled random search for BatchNorm and skip (2 × 20 pairs × 3 seeds = 240 trainings, ≈ 8 min first time)"
)
crs_run
mo.stop(
    not crs_run.value,
    mo.md("Press the button (results are cached per design and task)."),
)
crs_results = {
    dim: controlled_random_search(dim)
    for dim in mo.status.progress_bar(
        ["batchnorm", "skip"], title="dimensions"
    )
}
_fig, _axs = plt.subplots(1, 2, figsize=(8, 2.6))
_summary = []
for _ax, (_dim, _df) in zip(_axs, crs_results.items()):
    _ranks = _df[f"rank of {_dim}=True"]
    _ax.bar(
        ["with " + _dim + " better", "tie", "without better"],
        [(_ranks == 1).sum(), (_ranks == 1.5).sum(), (_ranks == 2).sum()],
        color=["#54A24B", "#BBBBBB", "#E45756"],
    )
    _ax.set_title(
        f"{_dim}: mean rank {_ranks.mean():.2f} (1 = always helps)",
        fontsize=9,
    )
    _ax.tick_params(axis="x", labelsize=7)
    _gain = _df[f"acc {_dim}=True"] - _df[f"acc {_dim}=False"]
    _summary += [
        {
            "dimension": _dim,
            "task": t,
            "mean accuracy change when switched on": g.mean(),
            "configs": len(g),
        }
        for t, g in _gain.groupby(_df.task)
    ]
plt.tight_layout()
crs_summary = pd.DataFrame(_summary)
mo.vstack([_fig, crs_summary.round(3)])
_lines = [] · 31 lines
    _lines = []
    for _dim, _df in crs_results.items():
        _r = _df[f"rank of {_dim}=True"]
        _wins, _losses = int((_r == 1).sum()), int((_r == 2).sum())
        _per = crs_summary[crs_summary.dimension == _dim].sort_values("mean accuracy change when switched on")
        _span = ", ".join(
            f"{t}: {g * 100:+.1f} pts ({n} configs)"
            for t, g, n in zip(_per.task, _per["mean accuracy change when switched on"], _per.configs)
        )
        _lines.append(f"* **{_dim}** wins {_wins} and loses {_losses} of {len(_r)} paired comparisons (mean rank {_r.mean():.2f}). By task: {_span}.")
    mo.md(
        "**This run** (20 random configurations per dimension, each accuracy the mean of "
        + f"{len(SEEDS)} seeds):\n\n"
        + "\n".join(_lines)
        + """

How to read it: a mean rank near 1.5 with large per-task effects of opposite sign means the choice is **task-dependent**,
not good or bad. The design-space paper found BatchNorm *generally* helpful across its 32 tasks; small, label-scarce citation
tasks trained full-batch can disagree. That is exactly why the method reports *distributions over tasks and seeds*, not one
number. (With a single seed, a 1–2 point difference between two runs of the same design is common; averaging 3 seeds cuts that
noise by about 40%, a factor of $1/\\sqrt3$.)

## 4 · Task similarity and transfer

Characterise each task by how a fixed set of **anchor designs** rank on it; tasks with similar rankings (high Kendall τ) should share
good designs. For a **new task**: run only the anchors, find the most similar known task, and reuse *its* best design, without searching
the whole space. We evaluate 16 random designs on all 4 tasks (3 seeds each); the first 8 are the anchors.

> **Predict first.** Which pair of tasks will have the highest τ? Which task has no good neighbour?
"""
    )
def design_pool(n=16, seed=SEED + 7):
    r = np.random.default_rng(seed)
    pool = []
    while len(pool) < n:
        d = random_design(r)
        if d not in pool:
            pool.append(d)
    return pool

POOL = design_pool()
N_ANCHORS = 8
transfer_run = mo.ui.run_button(
    label="Evaluate 16 designs × 4 tasks × 3 seeds (192 trainings, ≈ 6 min first time)"
)
transfer_run
mo.stop( · 60 lines
mo.stop(
    not transfer_run.value,
    mo.md("Press the button (cached per design and task)."),
)
pool_acc = pd.DataFrame(
    {
        task: [evaluate_mean(d, task) for d in POOL]
        for task in mo.status.progress_bar(list(TASKS), title="tasks")
    }
)
_anchor = pool_acc.iloc[:N_ANCHORS]
task_tau = pd.DataFrame(
    {
        a: {b: kendalltau(_anchor[a], _anchor[b]).statistic for b in TASKS}
        for a in TASKS
    }
)
_rows = []
for new in TASKS:
    nearest = task_tau[new].drop(new).idxmax()
    chosen = int(
        pool_acc[nearest].idxmax()
    )  # best design on the most similar known task
    _rows.append(
        {
            "new task": new,
            "most similar known task": nearest,
            "τ": task_tau.loc[nearest, new],
            "transferred design acc": pool_acc.loc[chosen, new],
            "best design in pool": pool_acc[new].max(),
            "average design": pool_acc[new].mean(),
        }
    )
_fig, _ax = plt.subplots(figsize=(4.2, 3.4))
_im = _ax.imshow(task_tau.values, cmap="RdBu", vmin=-1, vmax=1)
_ax.set_xticks(range(4), list(TASKS), rotation=30, fontsize=7)
_ax.set_yticks(range(4), list(TASKS), fontsize=7)
for (_i, _j), _v in np.ndenumerate(task_tau.values):
    _ax.text(_j, _i, f"{_v:.2f}", ha="center", va="center", fontsize=8)
_ax.set_title("Kendall τ of anchor rankings", fontsize=9)
transfer_results = pd.DataFrame(_rows)
mo.vstack(
    [
        mo.hstack(
            [
                _fig,
                pool_acc.round(3)
                .assign(
                    design=[
                        f"{d['layer']}-{d['n_layers']}L{'-BN' if d['batchnorm'] else ''}{'-skip' if d['skip'] else ''}-{d['hidden']}"
                        for d in POOL
                    ]
                )
                .set_index("design"),
            ],
            widths=[2, 3],
        ),
        transfer_results.round(3),
    ]
)
_pairs = [(a_, b_, task_tau.loc[a_, b_]) for i, a_ in enumerate(task_tau.index) for b_ · 20 lines
    _pairs = [(a_, b_, task_tau.loc[a_, b_]) for i, a_ in enumerate(task_tau.index) for b_ in task_tau.index[i + 1 :]]
    _hi = max(_pairs, key=lambda t: t[2])
    _lonely = task_tau.where(~np.eye(len(task_tau), dtype=bool)).max().idxmin()
    _rows = "\n".join(
        f"| {r['new task']} | {r['most similar known task']} | {r['τ']:.2f} | {r['transferred design acc']:.3f} | "
        f"{r['best design in pool']:.3f} | {r['average design']:.3f} |"
        for _, r in transfer_results.iterrows()
    )
    mo.md(
        f"""**This run.** Most similar pair: **{_hi[0]} ↔ {_hi[1]} (τ = {_hi[2]:.2f})**. Task with the weakest best neighbour: **{_lonely}**.

| new task | nearest known task | τ | transferred design | best in pool | average design |
|---|---|---|---|---|---|
{_rows}

How to read it: when τ is high the transferred design should sit near the best in the pool and well above the average.
When the nearest neighbour has τ near or below 0, transfer is a guess: that is the signal to *search* instead. An easy task can
get a good design from a dissimilar one by luck, because many designs solve it. GraphGym (`torch_geometric.graphgym`) runs
this at scale from YAML configs."""
    )

5 · Exercises

Exercise 1 (τ by hand). Task A ranks M1 > M2 > M3 > M4; task B ranks M2 > M1 > M4 > M3. Kendall τ? → ex1_tau (3 decimals).

Exercise 2 (design-space size). Add activation {ReLU, PReLU, Swish} and aggregation {mean, max, sum} to our 72-design space. How many designs? → ex2_designs.

Exercise 3 (code). Write nearest_task(anchor_accs): given a list of 8 test accuracies of the anchor designs (POOL[:8]) on a new task, return the name of the known task in TASKS whose anchor ranking has the highest Kendall τ with it.

ex1_tau = None · 5 lines
ex1_tau = None
ex2_designs = None

def nearest_task(anchor_accs):
    return None
def _verdict(ok, answered): · 52 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_nearest():
    try:
        if "pool_acc" not in globals():
            return mo.callout(
                mo.md("Run §4 first (press its button)."), kind="neutral"
            )
        noisy = pool_acc["Cora"].iloc[
            :N_ANCHORS
        ].values + np.random.default_rng(0).normal(0, 0.003, N_ANCHORS)
        got = nearest_task(list(noisy))
        if got is None:
            return _verdict(False, False)
        return _verdict(got in ("Cora", "CiteSeer"), True)
    except Exception as e:
        return mo.callout(mo.md(f"Error: `{e}`"), kind="danger")

mo.vstack(
    [
        mo.md("**Exercise 1**"),
        _verdict(
            ex1_tau is not None and abs(ex1_tau - 1 / 3) < 2e-3,
            ex1_tau is not None,
        ),
        mo.md("**Exercise 2**"),
        _verdict(ex2_designs == 72 * 9, ex2_designs is not None),
        mo.md("**Exercise 3**"),
        _check_nearest(),
        mo.accordion(
            {
                "Solution 1": mo.md(
                    r"6 pairs; (M1,M2) and (M3,M4) are flipped → 4 concordant, 2 discordant → $\tau=(4-2)/6\approx 0.333$."
                ),
                "Solution 2": mo.md(
                    "72 × 3 × 3 = **648**. The paper's space has 315,000 designs, which is why it samples instead of enumerating."
                ),
                "Solution 3": mo.md("""```python
def nearest_task(anchor_accs):
    taus = {t: kendalltau(anchor_accs, pool_acc[t].iloc[:N_ANCHORS]).statistic for t in TASKS}
    return max(taus, key=taus.get)
```"""),
            }
        ),
    ]
)

6 · Connecting to your research (SNA on X/Twitter)

The course on one page

block notebooks the one idea, as measured in these notebooks
Foundations L01 · L02 graphs are data; random-walk embeddings beat raw features without labels (Cora 0.65 vs 0.58)
GNN core L03 · L04 · L05 message + aggregation; GCN 0.79 vs MLP 0.54; depth over-smooths, skips help; split edges twice
Theory L06 · L07 GNN ≤ 1-WL; sum aggregation counts (GIN); structural / positional features break the WL limit
Transformers L08 attention needs structure back: shortest-path bias 1.00 vs 0.65 without
Heterogeneous & KGs L09 · L10 · L15 relation types; KGE geometry decides which patterns are learnable; relation graphs transfer to new KGs
Applications L11 · L12 · L13 LightGCN beats MF; relational GNNs beat SQL features; composite routes for junction tables
Frontiers L14 · L16 · L17 · L18 in-context learning, GraphRAG with a real LLM, agents, graph generation
Design L19 no universal best design; measure task similarity, transfer or search

Bonus notebooks: L20 (classic graph ML and scaling) and L21 (geometry, subgraphs and explainability).