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:
- choose node features when there are none (constant vs. one-hot vs. structural) and show when each fails;
- add a virtual node to fix slow message passing, and write a neighbour sampler for mini-batch training;
- attach node-, edge- and graph-level prediction heads and pick the right metric (AUC by counting pairs);
- split graph data correctly — transductive vs. inductive, and message vs. supervision edges with
RandomLinkSplit— and recognise the leakage bug that makes link-prediction AUC look spectacular.
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
- Feature augmentation — constant vs. one-hot vs. cycle-count features on a "triangle or square?" task.
- Virtual nodes — fix long-range message passing on long paths.
- Neighbour sampling — a hand-written mini-batch sampler; cost vs. accuracy on Cora.
- Prediction heads — node, edge, graph; the trouble with global pooling.
- Metrics — ROC AUC by counting pairs; thresholds.
- Splits — transductive vs. inductive; link prediction with message vs. supervision edges, and the leakage bug.
- 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.
- Transductive — one graph, visible in all splits; only the labels are split (Cora's masks, §1 and §3). Node/edge tasks only.
- Inductive — separate graphs per split; the model must generalise to unseen graphs (§2's paths). Required for graph-level tasks.
Link prediction: split the edges twice
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 →
- train supervision (for validation) → + validation edges (for test). PyG's
RandomLinkSplitdoes all of this:
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)
-
Retweet prediction is link prediction: apply
RandomLinkSplitto your user–user retweet edges (or aHeteroDatausers–tweets graph) and never let supervision edges into message passing. For a temporal question ("who retweets next month?") split by time instead of randomly. -
Accounts without profile features: constant or degree features keep the model inductive for new accounts; one-hot IDs do not.
- Big graphs: the 20-line sampler of §3 trains on millions of users with bounded memory per batch.
Summary
- Input graph ≠ computation graph: augment features (cycle counts solved a task constant features could not), add virtual nodes (long-range in 2 hops), sample neighbours (fanout 5 ≈ full accuracy on Cora).
- Heads: node linear, edge concat/dot, graph pooling (sum/mean/max are lossy).
- AUC = share of correctly ordered positive–negative pairs; threshold-free.
- Split carefully: transductive vs inductive; for links, message vs supervision edges. Leakage inflates AUC.
Next — L06 · Theory of GNNs: how expressive are GNNs? WL test, GIN.