Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L05 · GNN augmentation and training

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/L05_gnn_augmentation_training.py.

import warnings · 54 lines
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 os
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, temporal
from gl.nb import json_cache
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,
    SAGEConv,
    global_add_pool,
    global_max_pool,
    global_mean_pool,
)
from torch_geometric.utils import from_networkx, negative_sampling
from sklearn.metrics import (
    accuracy_score,
    f1_score,
    precision_score,
    recall_score,
    roc_auc_score,
)

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,
    }
)

L05 · GNN augmentation and training

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

Audience: you did L03–L04 (GNN layers, training on Cora). Prerequisites: precision/recall, ROC curves at a "have seen it" level.

By the end you can:

In short. The input graph need not be the computation graph: augment features, add virtual nodes, sample neighbours. Then train: graph → GNN → embeddings → head → predictions, scored against labels by a loss and judged by metrics. The subtle part is splitting: nodes are not independent, and for link prediction the edges must be split twice.

Outline

  1. Feature augmentation — constant vs. one-hot vs. cycle-count features on a "triangle or square?" task.
  2. Virtual nodes — fix long-range message passing on long paths.
  3. Neighbour sampling — a hand-written mini-batch sampler; cost vs. accuracy on Cora.
  4. Prediction heads — node, edge, graph; the trouble with global pooling.
  5. Metrics — ROC AUC by counting pairs; thresholds.
  6. Splits — transductive vs. inductive; link prediction with message vs. supervision edges, and the leakage bug.
  7. Exercises, research links.

1 · Feature augmentation: what if nodes have no features?

feature expressive? inductive? use for
constant (all 1s) medium — only structure yes any graph, new nodes appear
one-hot node ID high — can memorise nodes no small fixed graphs
structural (degree, cycle counts, …) targeted yes when you know what structure matters

Task: many disjoint triangles and squares. Label each node: on a triangle (1) or a square (0)? Every node has degree 2, so every computation tree is the same infinite binary tree. Train on half the nodes, test on the other half.

def cycles_graph(n_tri=60, n_sq=45, seed=SEED, relabel=False): · 107 lines
def cycles_graph(n_tri=60, n_sq=45, seed=SEED, relabel=False):
    """Disjoint triangles (label 1) and squares (label 0); half the nodes train, half test.
    relabel=True shuffles node IDs: the same kind of graph, but every ID now names a different node."""
    g = nx.disjoint_union_all(
        [nx.cycle_graph(3)] * n_tri + [nx.cycle_graph(4)] * n_sq
    )
    y = np.array([1] * (3 * n_tri) + [0] * (4 * n_sq))
    if relabel:
        mapping = dict(
            zip(
                g.nodes,
                np.random.default_rng(seed + 1).permutation(
                    g.number_of_nodes()
                ),
            )
        )
        g = nx.relabel_nodes(g, mapping)
        y = y[np.argsort(list(mapping.values()))]
    g_sorted = nx.Graph()
    g_sorted.add_nodes_from(sorted(g.nodes))  # tensor row i = node ID i
    g_sorted.add_edges_from(g.edges)
    g = g_sorted
    data = from_networkx(g)
    data.y = torch.tensor(y)
    perm = torch.randperm(
        data.num_nodes, generator=torch.Generator().manual_seed(seed)
    )
    data.train_mask = torch.zeros(data.num_nodes, dtype=torch.bool)
    data.train_mask[perm[: data.num_nodes // 2]] = True
    data.test_mask = ~data.train_mask
    return g, data

def node_features(g):
    A = torch.tensor(
        nx.to_numpy_array(g, nodelist=sorted(g.nodes)), dtype=torch.float
    )
    return {
        "constant (1)": torch.ones(len(A), 1),
        "one-hot node ID": torch.eye(len(A)),
        "degree": A.sum(1, keepdim=True),
        # closed walks of length 3 and 4 = diag(A^3), diag(A^4): "am I on a triangle / square?"
        "cycle counts diag(A³), diag(A⁴)": torch.stack(
            [torch.diag(A @ A @ A), torch.diag(A @ A @ A @ A)], 1
        ),
    }

class GCN2(nn.Module):
    def __init__(self, d_in, d_hid=32, n_out=2):
        super().__init__()
        self.c1, self.c2, self.head = (
            GCNConv(d_in, d_hid),
            GCNConv(d_hid, d_hid),
            nn.Linear(d_hid, n_out),
        )

    def forward(self, x, edge_index):
        return self.head(
            F.relu(self.c2(F.relu(self.c1(x, edge_index)), edge_index))
        )

def train_gcn2(x, data, epochs=150, seed=SEED):
    torch.manual_seed(seed)
    model = GCN2(x.size(1))
    opt = torch.optim.Adam(model.parameters(), lr=0.01)
    for _ in range(epochs):
        opt.zero_grad()
        F.cross_entropy(
            model(x, data.edge_index)[data.train_mask],
            data.y[data.train_mask],
        ).backward()
        opt.step()
    return model

def accuracy(model, x, data, mask):
    with torch.no_grad():
        return (
            (model(x, data.edge_index).argmax(1)[mask] == data.y[mask])
            .float()
            .mean()
            .item()
        )

cyc_g, cyc_data = cycles_graph()
new_g, new_data = cycles_graph(
    relabel=True
)  # a "new graph" of the same kind
_feats, _new_feats = node_features(cyc_g), node_features(new_g)
_rows = []
for _name, _x in _feats.items():
    _m = train_gcn2(_x, cyc_data)
    _rows.append(
        {
            "node features": _name,
            "dims": _x.size(1),
            "train acc": accuracy(_m, _x, cyc_data, cyc_data.train_mask),
            "test acc (same graph)": accuracy(
                _m, _x, cyc_data, cyc_data.test_mask
            ),
            "acc on a NEW graph": accuracy(
                _m,
                _new_feats[_name],
                new_data,
                torch.ones(new_data.num_nodes, dtype=torch.bool),
            ),
        }
    )
pd.DataFrame(_rows).round(3)

Constant and degree features sit at chance (≈ 0.5): every node's computation tree is identical. Cycle counts solve it perfectly (1.0), on the same graph and on a new one. The surprise is one-hot IDs: 0.94 on test nodes of the same graph. A test node's triangle-mates are mostly training nodes whose IDs the model has memorised, so the label leaks to it through message passing (the transductive setting). On a new graph the IDs mean different nodes and accuracy drops to ≈ 0.57: one-hot features are expressive but not inductive.

2 · Virtual nodes: shortcuts for sparse graphs

Messages travel one hop per layer. On a long path, a 2-layer GNN at node 10 cannot see node 0. Task: 200 paths of 20 nodes; node 0 carries a colour (red/blue) and every node must predict it. Train on 150 paths, test on 50. Adding one virtual node linked to every node makes all nodes ≤ 2 hops apart.

def path_graph_data(colour, length=20, virtual=False): · 65 lines
def path_graph_data(colour, length=20, virtual=False):
    """Path 0-1-...-(length-1); node 0 has a one-hot colour, all nodes a constant channel; optional virtual node."""
    x = torch.zeros(length, 3)
    x[0, colour] = 1.0
    x[:, 2] = 1.0
    src, dst = list(range(length - 1)), list(range(1, length))
    is_real = torch.ones(length, dtype=torch.bool)
    if virtual:  # one extra node, linked to every real node
        x = torch.cat([x, torch.zeros(1, 3)])
        src += [length] * length
        dst += list(range(length))
        is_real = torch.cat([is_real, torch.zeros(1, dtype=torch.bool)])
    edge_index = torch.tensor([src + dst, dst + src])
    return Data(
        x=x,
        edge_index=edge_index,
        y=torch.full((x.size(0),), colour),
        dist=torch.arange(x.size(0)),
        is_real=is_real,
    )

class StackGCN(nn.Module):
    def __init__(self, d_in, n_layers, d_hid=32, n_out=2):
        super().__init__()
        dims = [d_in] + [d_hid] * n_layers
        self.convs = nn.ModuleList(
            GCNConv(a, b) for a, b in zip(dims, dims[1:])
        )
        self.head = nn.Linear(d_hid, n_out)

    def forward(self, x, edge_index):
        for conv in self.convs:
            x = F.relu(conv(x, edge_index))
        return self.head(x)

def path_experiment(n_layers, virtual, epochs=200, seed=SEED):
    colours = np.random.default_rng(seed).integers(0, 2, 200)
    graphs = [path_graph_data(int(c), virtual=virtual) for c in colours]
    train, test = (
        Batch.from_data_list(graphs[:150]),
        Batch.from_data_list(graphs[150:]),
    )  # inductive: separate graphs
    torch.manual_seed(seed)
    model = StackGCN(3, n_layers)
    opt = torch.optim.Adam(model.parameters(), lr=0.01)
    for _ in range(epochs):
        opt.zero_grad()
        out = model(train.x, train.edge_index)
        F.cross_entropy(
            out[train.is_real], train.y[train.is_real]
        ).backward()  # loss on real nodes only
        opt.step()
    with torch.no_grad():
        correct = (model(test.x, test.edge_index).argmax(1) == test.y)[
            test.is_real
        ]
    dist = test.dist[test.is_real]
    return np.array(
        [correct[dist == k].float().mean().item() for k in range(20)]
    )

vn_layers = mo.ui.slider(
    1, 4, value=2, label="GCN layers", show_value=True
)
vn_layers
_plain, _virtual = (
    path_experiment(vn_layers.value, False),
    path_experiment(vn_layers.value, True),
)
_fig, _ax = plt.subplots(figsize=(6, 2.6))
_ax.plot(range(20), _plain, "o-", label="plain path")
_ax.plot(range(20), _virtual, "s--", label="+ virtual node")
_ax.axvline(vn_layers.value + 0.5, color="grey", ls=":", lw=1)
_ax.set_ylim(0.3, 1.05)
_ax.set_xlabel("distance from the coloured node 0")
_ax.set_ylabel("test accuracy")
_ax.set_title(
    f"{vn_layers.value}-layer GCN: who can see node 0? (dotted line = receptive field)",
    fontsize=9,
)
_ax.legend(fontsize=8)
_ax

Try it: with \(K\) layers the plain path is perfect up to distance \(K\) and at chance (≈ 0.56 = share of the majority colour) beyond it. With the virtual node, every distance is solved already at 2 layers. The virtual node also acts as a learned graph-level summary. The cost: it touches every node, so on large graphs it becomes a hub (sample it or use several).

3 · Neighbour sampling: mini-batches on big graphs

Full-batch training needs every node's \(K\)-hop computation graph in memory at once. GraphSAGE / PinSage sampling: for a mini-batch of target nodes, keep at most \(H\) random neighbours per node per hop. Cost per batch \(\approx M\cdot H^K\) instead of the whole graph, and in expectation the aggregation matches full aggregation. (PyG's NeighborLoader does this but needs pyg-lib/torch-sparse, which this environment lacks, so here is a 20-line sampler.)

cora = planetoid("Cora")[0]

def build_csr(edge_index, n):
    """Incoming-neighbour lists in CSR form: for target v, sources are col[rowptr[v]:rowptr[v+1]]."""
    src, dst = edge_index.numpy()
    order = np.argsort(dst, kind="stable")
    return np.searchsorted(dst[order], np.arange(n + 1)), src[order]

cora_csr = build_csr(cora.edge_index, cora.num_nodes)

def sample_subgraph(seeds, fanouts, csr, r):
    """K-hop neighbour sampling (with replacement, deduplicated). Returns (global node ids, local edge_index);
    the first len(seeds) local nodes are the seeds."""
    rowptr, col = csr
    deg = np.diff(rowptr)
    nodes = list(seeds)
    local = {v: i for i, v in enumerate(nodes)}
    frontier, edges = np.asarray(seeds), set()
    for k in fanouts:
        f = frontier[deg[frontier] > 0]
        picks = col[
            rowptr[f][:, None]
            + (r.random((len(f), k)) * deg[f][:, None]).astype(int)
        ]  # k neighbours each
        new = []
        for target, nbrs in zip(f, picks):
            for u in set(nbrs.tolist()):
                if u not in local:
                    local[u] = len(nodes)
                    nodes.append(u)
                    new.append(u)
                edges.add((local[u], local[target]))  # message u -> target
        frontier = np.array(new, dtype=int)
    edge_index = torch.tensor(sorted(edges), dtype=torch.long).t()
    return torch.tensor(nodes), edge_index

_r = np.random.default_rng(SEED)
_n_id, _ei = sample_subgraph([0, 1, 2], [5, 5], cora_csr, _r)
mo.md(
    f"Sampled 2-hop computation graph for seeds 0, 1, 2 with fanout [5, 5]: **{len(_n_id)} nodes, {_ei.size(1)} edges** "
    f"(the full 2-hop neighbourhood of these three nodes has "
    f"{len(set(sum([list(nx.single_source_shortest_path_length(nx.Graph(cora.edge_index.t().tolist()), s, cutoff=2)) for s in [0, 1, 2]], [])))} nodes)."
)
class SAGE2(nn.Module): · 55 lines
class SAGE2(nn.Module):
    def __init__(self, d_in, d_hid, n_out):
        super().__init__()
        self.c1, self.c2 = SAGEConv(d_in, d_hid), SAGEConv(d_hid, n_out)

    def forward(self, x, edge_index):
        return self.c2(
            F.dropout(F.relu(self.c1(x, edge_index)), 0.5, self.training),
            edge_index,
        )

def train_sampled(fanout, epochs=60, batch_size=32, seed=SEED):
    """Mini-batch training on sampled 2-hop subgraphs; full-graph inference at the end."""
    torch.manual_seed(seed)
    r = np.random.default_rng(seed)
    model = SAGE2(cora.num_features, 64, 7)
    opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
    train_idx = cora.train_mask.nonzero().squeeze().numpy()
    sizes, t0 = [], time.time()
    for _ in range(epochs):
        model.train()
        order = r.permutation(train_idx)  # new mini-batches every epoch
        for b in range(0, len(order), batch_size):
            seeds = order[b : b + batch_size]
            n_id, ei = sample_subgraph(
                seeds, [fanout, fanout], cora_csr, r
            )
            sizes.append(len(n_id))
            opt.zero_grad()
            out = model(cora.x[n_id], ei)[
                : len(seeds)
            ]  # predictions for the seeds only
            F.cross_entropy(
                out, cora.y[torch.from_numpy(seeds)]
            ).backward()
            opt.step()
    model.eval()
    with torch.no_grad():
        pred = model(cora.x, cora.edge_index).argmax(
            1
        )  # full-graph inference
    return {
        "fanout per hop": fanout,
        "mean nodes per mini-batch": np.mean(sizes),
        "test acc": (pred[cora.test_mask] == cora.y[cora.test_mask])
        .float()
        .mean()
        .item(),
        "train time (s)": time.time() - t0,
    }

sampling_run = mo.ui.run_button(
    label="Compare fanouts 1, 2, 5, 10, 25 (≈ 5 s)"
)
sampling_run
mo.stop(
    not sampling_run.value,
    mo.md(
        "Press the button to train GraphSAGE with sampled mini-batches at five fanouts."
    ),
)
sampling_results = pd.DataFrame(
    [train_sampled(k) for k in [1, 2, 5, 10, 25]]
)
sampling_results.round(3)
_rev = reveal( · 18 lines
_rev = reveal(
    r"""
Measured (60 epochs, 2-layer GraphSAGE, batches of 32 labelled papers): with **one** neighbour per hop each mini-batch
touches ≈ 73 nodes and still reaches **0.78** test accuracy; fanout 5 touches ≈ 230 nodes and reaches **0.80**; fanout 25
touches ≈ 470 nodes for no further gain. Most of the accuracy comes from a few sampled neighbours, which is why PinSage-style
sampling scales to billions of nodes. (Single seed: differences under ~1–2 points are noise.)

""",
    predict="Sampling 1, 5 or 25 neighbours per hop: how many points of test accuracy does a fanout of 1 cost?",
)
_post = mo.md(r"""
## 4 · Prediction heads

* **Node:** $\hat{\mathbf y}_v = W^{(H)}\mathbf h_v^{(L)}$.
* **Edge:** $\text{Linear}(\mathbf h_u\|\mathbf h_v)$ (2d → k), or a dot product $\mathbf h_u^\top\mathbf h_v$ (1-way only), or $k$ bilinear forms $\mathbf h_u^\top W^{(i)}\mathbf h_v$.
* **Graph:** a global pooling over node embeddings — mean, max or sum. Simple, but lossy. The lecture's puzzle:
""")
mo.vstack([_rev, _post])
_g1 = torch.tensor([[-1.0], [-2.0], [0.0], [1.0], [2.0]])
_g2 = _g1 * 10
_x = torch.cat([_g1, _g2])
_batch = torch.tensor([0] * 5 + [1] * 5)
pd.DataFrame(
    {
        "pooling": ["sum", "mean", "max"],
        "G1 = {-1,-2,0,1,2}": [
            global_add_pool(_x, _batch)[0].item(),
            global_mean_pool(_x, _batch)[0].item(),
            global_max_pool(_x, _batch)[0].item(),
        ],
        "G2 = 10 × G1": [
            global_add_pool(_x, _batch)[1].item(),
            global_mean_pool(_x, _batch)[1].item(),
            global_max_pool(_x, _batch)[1].item(),
        ],
    }
)

Sum and mean give 0 for both graphs; only max separates them here, and it is easy to build counterexamples for max too. Any single statistic throws information away. Remedies: hierarchical pooling (DiffPool) or a virtual super-node (§2) that learns how to summarise.

5 · Metrics: ROC AUC by counting pairs

ROC AUC = probability that a random positive is scored above a random negative, so it is threshold-free. Lecture example: positives score 0.9, 0.6; negatives 0.7, 0.2. Pairs: (0.9>0.7 ✓), (0.9>0.2 ✓), (0.6>0.7 ✗), (0.6>0.2 ✓) → AUC = 3/4.

auc_pos = np.array([0.9, 0.6])
auc_neg = np.array([0.7, 0.2])
threshold = mo.ui.slider(
    0.0,
    1.0,
    step=0.05,
    value=0.5,
    label="decision threshold",
    show_value=True,
)
mo.vstack(
    [
        mo.md(
            "**Try it:** move the threshold: accuracy, precision and recall change, while AUC stays fixed."
        ),
        threshold,
    ]
)
_scores = np.r_[auc_pos, auc_neg]
_y = np.r_[np.ones(2), np.zeros(2)]
_pred = (_scores >= threshold.value).astype(int)
_pairs = [(p, n, p > n) for p in auc_pos for n in auc_neg]
mo.md(f"""
| metric | value |
|---|---|
| ROC AUC (sklearn) | **{roc_auc_score(_y, _scores):.3f}** |
| ROC AUC by counting pairs | {np.mean([ok for _, _, ok in _pairs]):.3f} |
| accuracy @ {threshold.value:.2f} | {accuracy_score(_y, _pred):.2f} |
| precision @ {threshold.value:.2f} | {precision_score(_y, _pred, zero_division=0):.2f} |
| recall @ {threshold.value:.2f} | {recall_score(_y, _pred, zero_division=0):.2f} |
| F1 @ {threshold.value:.2f} | {f1_score(_y, _pred, zero_division=0):.2f} |
""")

At threshold 0.5: accuracy 0.75, precision 0.67, recall 1.0. At 0.65 accuracy becomes 1.0 and at 0.95 recall drops to 0, while AUC stays 0.75. That is why AUC is the default for link prediction. For very imbalanced problems (millions of non-edges per edge), ranking metrics such as Hits@K and MRR are more informative (L09–L11).

6 · Splitting graph data

Nodes are not independent: a test node's features flow into a training node's embedding through message passing.

Labels are created by hiding edges. Each edge becomes a message edge (fed to the GNN) or a supervision edge (only in the loss, never fed to the GNN), and train / val / test. Transductively the visible graph grows: train messages →

link_split = T.RandomLinkSplit(
    num_val=0.1,
    num_test=0.1,
    is_undirected=True,
    add_negative_train_samples=False,
    disjoint_train_ratio=0.3,
)
torch.manual_seed(SEED)
lp_train, lp_val, lp_test = link_split(cora)
pd.DataFrame(
    [
        {
            "split": "train",
            "message edges (edge_index, directed count)": lp_train.edge_index.size(
                1
            ),
            "supervision edges (edge_label_index)": lp_train.edge_label_index.size(
                1
            ),
            "of which negatives": int((lp_train.edge_label == 0).sum()),
        },
        {
            "split": "val",
            "message edges (edge_index, directed count)": lp_val.edge_index.size(
                1
            ),
            "supervision edges (edge_label_index)": lp_val.edge_label_index.size(
                1
            ),
            "of which negatives": int((lp_val.edge_label == 0).sum()),
        },
        {
            "split": "test",
            "message edges (edge_index, directed count)": lp_test.edge_index.size(
                1
            ),
            "supervision edges (edge_label_index)": lp_test.edge_label_index.size(
                1
            ),
            "of which negatives": int((lp_test.edge_label == 0).sum()),
        },
    ]
)
def train_link_predictor(message_edges, epochs=150, seed=SEED): · 48 lines
def train_link_predictor(message_edges, epochs=150, seed=SEED):
    """GCN encoder + dot-product decoder; positives = train supervision edges, fresh negatives every epoch."""
    torch.manual_seed(seed)
    enc = GCN2(cora.num_features, 64, 64)
    opt = torch.optim.Adam(enc.parameters(), lr=0.01)
    pos = lp_train.edge_label_index
    for _ in range(epochs):
        opt.zero_grad()
        z = enc(lp_train.x, message_edges)
        neg = negative_sampling(
            lp_train.edge_index,
            num_nodes=cora.num_nodes,
            num_neg_samples=pos.size(1),
        )
        logits = torch.cat(
            [
                (z[pos[0]] * z[pos[1]]).sum(1),
                (z[neg[0]] * z[neg[1]]).sum(1),
            ]
        )
        F.binary_cross_entropy_with_logits(
            logits,
            torch.cat([torch.ones(pos.size(1)), torch.zeros(neg.size(1))]),
        ).backward()
        opt.step()
    return enc

def test_auc(enc, message_edges):
    with torch.no_grad():
        z = enc(lp_test.x, message_edges)
        e = lp_test.edge_label_index
        return roc_auc_score(
            lp_test.edge_label.numpy(), (z[e[0]] * z[e[1]]).sum(1).numpy()
        )

_clean = train_link_predictor(lp_train.edge_index)
_leaky = train_link_predictor(
    cora.edge_index
)  # BUG: message passing over ALL edges, test edges included
mo.callout(
    mo.md(f"""**Correct split:** test AUC **{test_auc(_clean, lp_test.edge_index):.3f}**
(trained on train message edges, tested with train + val edges as messages).

**Leakage bug:** test AUC **{test_auc(_leaky, cora.edge_index):.3f}** (the full `edge_index`, including the very test edges,
was used for message passing). The model "predicts" edges it can see in its own neighbourhood.
**Fix:** always use `train_data.edge_index` / `val_data.edge_index` / `test_data.edge_index` from `RandomLinkSplit`."""),
    kind="warn",
)

6b · A clean random split is still not the future: real messages over time

RandomLinkSplit removes the leak above, but it still asks the wrong question for a network that grows: it hides a random 10% of edges from all times, so the model trains on ties formed after the ones it is tested on. A deployed recommender only ever sees the past. On CollegeMsg (messages between students of a Californian university over six months, gl.datasets.temporal("collegemsg")), compare the two protocols with three classic scores: common neighbours, Adamic–Adar (L20) and preferential attachment. Temporal split: pairs that first talk before the 80% quantile of first-contact times form the graph; later new pairs between students already in it are the positives.

Predict first: which protocol reports the higher AUC, and for which heuristic is the difference smallest?

def temporal_vs_random(name="collegemsg", seed=0, cut_q=0.8, max_pos=5000):
    t = temporal(name)
    t = t[t.src != t.dst]
    first = pd.DataFrame({"a": np.minimum(t.src, t.dst), "b": np.maximum(t.src, t.dst), "t": t.t})
    first = first.groupby(["a", "b"]).t.min().reset_index()
    cut = first.t.quantile(cut_q)
    full = nx.Graph(first[["a", "b"]].itertuples(index=False, name=None))
    past = nx.Graph(first.loc[first.t <= cut, ["a", "b"]].itertuples(index=False, name=None))
    later = first[(first.t > cut) & first.a.isin(past) & first.b.isin(past)]
    r = np.random.default_rng(seed)
    k = min(max_pos, len(later), len(first) // 10)
    new = [tuple(map(int, e)) for e in later.iloc[r.choice(len(later), k, replace=False)][["a", "b"]].to_numpy()]
    hidden = [tuple(map(int, e)) for e in first.iloc[r.choice(len(first), k, replace=False)][["a", "b"]].to_numpy()]
    rand_train = full.copy()
    rand_train.remove_edges_from(hidden)
    scores = {
        "common neighbours": lambda g, p: [len(list(nx.common_neighbors(g, a, b))) for a, b in p],
        "Adamic–Adar": lambda g, p: [s for *_, s in nx.adamic_adar_index(g, p)],
        "preferential attachment": lambda g, p: [s for *_, s in nx.preferential_attachment(g, p)],
    }
    out = {}
    for prot, g, pos in [("random edge split", rand_train, hidden), ("temporal split", past, new)]:
        nodes = np.fromiter(g.nodes, dtype=np.int64)
        neg = set()
        while len(neg) < len(pos):
            u, v = nodes[r.integers(len(nodes), size=2)]
            if u != v and not full.has_edge(u, v):
                neg.add((int(min(u, v)), int(max(u, v))))
        pairs, lab = list(pos) + sorted(neg), [1] * len(pos) + [0] * len(neg)
        out[prot] = {h: float(roc_auc_score(lab, f(g, pairs))) for h, f in scores.items()}
    return out

msg_run = mo.ui.run_button(label="Run on CollegeMsg (downloads 0.3 MB once; ≈ 20 s; then cached)")
msg_run
_cache = CACHE_DIR / "L05_temporal_split.json"
mo.stop(
    not (msg_run.value or os.environ.get("GL_RUN_EXPERIMENTS") == "1") and not _cache.exists(),
    mo.md("Press the button. Results are cached in `notebooks/cs224w/cache/L05_temporal_split.json`."),
)
_runs = [json_cache(_cache, {"network": "collegemsg", "seed": s, "cut": 0.8, "v": 1},
                    lambda s=s: temporal_vs_random(seed=s)) for s in range(3)]
msg_table = pd.DataFrame({p: pd.DataFrame([r[p] for r in _runs]).mean() for p in _runs[0]})
msg_table["random − temporal"] = msg_table["random edge split"] - msg_table["temporal split"]
_gap = msg_table["random − temporal"]
mo.vstack([
    msg_table.round(3),
    mo.md(f"""
Means over 3 seeds. The random split reports **{_gap.max():+.3f}** more AUC for {_gap.idxmax()} and
{_gap.min():+.3f} for {_gap.idxmin()}. A reading, not tested here: a randomly hidden tie usually sits inside a group
whose other ties, including ones formed *later*, stay visible, which flatters triangle-closing scores; a genuinely new
tie is more often to someone already popular, which degree-based scores capture without seeing the future. The
experiment `experiments/honest-splits` repeats this on five real networks (e-mail, Bitcoin trust, MathOverflow,
Higgs retweets): the direction holds on four, and on retweet cascades the random split mostly tests cold start
instead, because most retweeters act once.
"""),
])

Exercises

Exercise 1 (by hand). Positives score 0.8, 0.4, 0.3; negatives 0.5, 0.2. ROC AUC? → ex1_auc.

Exercise 2 (code). Write my_auc(pos, neg) that computes ROC AUC by counting positive–negative pairs (ties count ½). It is checked against sklearn on random scores.

Exercise 3 (think). Which split does a graph-classification dataset need, and why can't it be transductive? (Open the solution to compare.)

ex1_auc = None · 4 lines
ex1_auc = None

def my_auc(pos, neg):
    return None
def _verdict(ok, answered): · 58 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_auc():
    try:
        r = np.random.default_rng(1)
        pos, neg = (
            np.round(r.random(30), 1),
            np.round(r.random(40), 1),
        )  # rounding creates ties
        got = my_auc(pos, neg)
        if got is None:
            return _verdict(False, False)
        return _verdict(
            abs(
                got
                - roc_auc_score(
                    np.r_[np.ones(30), np.zeros(40)], np.r_[pos, neg]
                )
            )
            < 1e-9,
            True,
        )
    except Exception as e:
        return mo.callout(mo.md(f"Error: `{e}`"), kind="danger")

mo.vstack(
    [
        mo.md("**Exercise 1**"),
        _verdict(
            ex1_auc is not None and abs(ex1_auc - 4 / 6) < 2e-3,
            ex1_auc is not None,
        ),
        mo.md("**Exercise 2**"),
        _check_auc(),
        mo.accordion(
            {
                "Solution 1": mo.md(
                    "Pairs: 0.8 beats both, 0.4 beats 0.2 only, 0.3 beats 0.2 only → 4 of 6 → **0.667**."
                ),
                "Solution 2": mo.md("""```python
def my_auc(pos, neg):
    pos, neg = np.asarray(pos)[:, None], np.asarray(neg)[None, :]
    return ((pos > neg).sum() + 0.5 * (pos == neg).sum()) / (pos.size * neg.size)
```"""),
                "Solution 3": mo.md(
                    "**Inductive**: each graph is one example with one label, so train / val / test must be different graphs. "
                    "A transductive split would need one graph to be partly train and partly test, which has no meaning for a graph-level label."
                ),
            }
        ),
    ]
)

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

Summary

Next — L06 · Theory of GNNs: how expressive are GNNs? WL test, GIN.