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/L14_advanced_topics.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 GCNConv from torch_geometric.utils import negative_sampling 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, } )
L14 · Advanced topics — in-context learning on graphs, relational foundation models, conformal prediction
CS224W Machine Learning with Graphs · hands-on companion to Lecture 14
Audience: you did L03–L05 (GNNs, training) and L12–L13 (relational deep learning). Prerequisites: the idea of few-shot prompting in LLMs; quantiles.
By the end you can:
-
build a PRODIGY-style prompt graph (data nodes + label nodes) and do few-shot node classification on Cora without any gradient update, on top of a GNN pretrained with no labels;
-
tokenise database cells as (value, column, table) and build the Relational Transformer's attention masks;
- wrap a GNN in split conformal prediction to get prediction sets with a coverage guarantee.
In short. LLMs do in-context learning: show a few examples, get the answer, no training. PRODIGY brings this to graphs: any few-shot graph task becomes a prompt graph (examples' subgraphs as data nodes, linked to label nodes), and classification becomes link prediction between the query and the labels. The Relational Transformer tokenises every cell of any database as (value, column, table), so one pretrained model reads any schema; tasks become masked-token prediction.
Outline
- In-context learning on a graph (PRODIGY-lite) — label-free pretraining, then few-shot prompts with no weight update.
- Relational Transformer tokens and masks — cells as tokens; column / feature / neighbour / full attention.
- Conformal prediction for GNNs — prediction sets with guaranteed coverage.
- Exercises, research links.
1 · In-context learning on a graph
Step 1 — pretrain without labels. A 2-layer GCN encoder on Cora, trained only to predict links (the L03 objective).
The topic labels are never used. Cached to notebooks/cs224w/cache/ (≈ 20 s the first time).
cora = planetoid("Cora")[0] class Encoder(nn.Module): def __init__(self, d_in, d_hid=128, d_out=64): super().__init__() self.c1, self.c2 = GCNConv(d_in, d_hid), GCNConv(d_hid, d_out) def forward(self, x, edge_index): return self.c2(F.relu(self.c1(x, edge_index)), edge_index) def pretrain_encoder(epochs=150, seed=SEED): path = CACHE_DIR / f"L14_encoder_e{epochs}_s{seed}.pt" enc = Encoder(cora.num_features) if path.exists(): enc.load_state_dict(torch.load(path)) return enc, True torch.manual_seed(seed) opt = torch.optim.Adam(enc.parameters(), lr=0.01) for _ in range(epochs): opt.zero_grad() z = enc(cora.x, cora.edge_index) neg = negative_sampling(cora.edge_index, num_nodes=cora.num_nodes) pos_s = (z[cora.edge_index[0]] * z[cora.edge_index[1]]).sum(1) neg_s = (z[neg[0]] * z[neg[1]]).sum(1) F.binary_cross_entropy_with_logits( torch.cat([pos_s, neg_s]), torch.cat([torch.ones_like(pos_s), torch.zeros_like(neg_s)]), ).backward() opt.step() torch.save(enc.state_dict(), path) return enc, False encoder, _cached = pretrain_encoder() encoder.eval() with torch.no_grad(): node_emb = F.normalize( encoder(cora.x, cora.edge_index), dim=1 ) # each node's embedding = its encoded subgraph raw_feat = F.normalize(cora.x, dim=1) mo.md( f"Encoder {'loaded from cache' if _cached else 'pretrained and cached'}: {node_emb.shape[0]} node embeddings of size {node_emb.shape[1]}. No label was used." )
Step 2 — the prompt graph. A few-shot task ("3-way, \(k\)-shot": 3 topics, \(k\) labelled papers each, plus queries) becomes a small graph: every example and query is a data node (its embedding = the encoder applied to its neighbourhood), every class is a label node, and each example links to its own label node. One round of message passing data → label gives each label node an embedding; the prediction for a query is the label node it would link to (highest cosine). No weights change: a new task is just a new prompt graph.
def prompt_graph_predict(emb, support_idx, support_lbl, query_idx, n_way): · 52 lines
def prompt_graph_predict(emb, support_idx, support_lbl, query_idx, n_way): """Explicit PRODIGY-style task graph with a parameter-free task GNN (mean aggregation, cosine readout).""" # task-graph edges: data node i -> label node support_lbl[i] src = torch.arange(len(support_idx)) dst = torch.as_tensor(support_lbl) label_nodes = torch.zeros(n_way, emb.size(1)).index_add_( 0, dst, emb[support_idx][src] ) label_nodes = F.normalize( label_nodes / torch.bincount(dst, minlength=n_way).unsqueeze(1), dim=1, ) return (emb[query_idx] @ label_nodes.T).argmax( 1 ) # query–label "link" scores def run_episodes( emb, n_way=3, k_shot=3, n_query=20, n_episodes=300, seed=SEED ): r = np.random.default_rng(seed) y = cora.y.numpy() accs = [] for _ in range(n_episodes): classes = r.choice(7, n_way, replace=False) sup, sup_l, qry, qry_l = [], [], [], [] for j, c in enumerate(classes): idx = r.choice( np.flatnonzero(y == c), k_shot + n_query, replace=False ) sup += list(idx[:k_shot]) sup_l += [j] * k_shot qry += list(idx[k_shot:]) qry_l += [j] * n_query pred = prompt_graph_predict( emb, torch.tensor(sup), sup_l, torch.tensor(qry), n_way ) accs.append((pred.numpy() == np.array(qry_l)).mean()) return float(np.mean(accs)) icl_rows = [] for _k in [1, 3, 5, 10]: icl_rows.append( { "k-shot (3-way)": _k, "raw bag-of-words features": run_episodes(raw_feat, k_shot=_k), "pretrained GNN embeddings (ICL)": run_episodes( node_emb, k_shot=_k ), } ) icl_results = pd.DataFrame(icl_rows) icl_results.round(3)
_rev = reveal( · 20 lines
_rev = reveal( r""" Measured (300 random 3-way episodes, 20 queries per class): with **one** example per class the label-free GNN embeddings already reach **0.70** (chance 0.33), and **0.85** at 10 shots, against 0.48 → 0.70 for the raw features. No weights were updated for any task, and no labels were used in pretraining. PRODIGY goes further: it *learns* the task-graph GNN during pretraining on self-supervised prompt tasks (neighbour matching, multi-task), and transfers to graphs and tasks it never saw (up to +48% over contrastive pretraining). Ours is the parameter-free special case: the label node is the mean of its examples, i.e. a prototype. """, predict="With one labelled example per class and no training at all, how far above chance (0.33) can prototypes on label-free GNN embeddings get?", ) _post = mo.md(r""" ## 2 · Relational Transformer: cells as tokens RT tokenises **every cell** of every table as (value, column name, table name), so any schema lands in one input space. A task is a table whose label cell is **masked**; earlier rows of the task table act as in-context examples. Attention is restricted by **relational masks**: *column* (same column), *feature* (same row + its FK-parent rows), *neighbour* (rows linked by a foreign key) and *full*. A tiny database: """) mo.vstack([_rev, _post])
tiny_db = { · 54 lines
tiny_db = { "users": pd.DataFrame( {"user_id": [0, 1], "age": [34, 51], "country": ["IR", "US"]} ), "products": pd.DataFrame( { "product_id": [0, 1], "category": ["books", "toys"], "price": [12.0, 30.0], } ), "orders": pd.DataFrame( { "order_id": [0, 1, 2], "user_id": [0, 0, 1], "product_id": [1, 0, 1], "amount": [30.0, 24.0, 60.0], } ), "task_churn": pd.DataFrame( {"user_id": [0, 1], "churn": [0, None]} ), # label of user 1 is the [MASK] to predict } FOREIGN_KEYS = { ("orders", "user_id"): "users", ("orders", "product_id"): "products", ("task_churn", "user_id"): "users", } def cell_tokens(db): toks = [] for table, df in db.items(): for i, row in df.iterrows(): for col, val in row.items(): if ( col.endswith("_id") and not (table, col) in FOREIGN_KEYS and col == f"{table[:-1]}_id" ): continue # primary keys are not content tokens if (table, col) in FOREIGN_KEYS: continue # FKs become links, not tokens toks.append( { "table": table, "row": i, "column": col, "value": "[MASK]" if pd.isna(val) else val, } ) return pd.DataFrame(toks) rt_tokens = cell_tokens(tiny_db) rt_tokens
def parent_rows(table, row): """FK parents of a row: (parent_table, parent_row).""" out = [] for (t, col), parent in FOREIGN_KEYS.items(): if t == table: out.append((parent, int(tiny_db[table].loc[row, col]))) return out def relational_masks(toks): n = len(toks) rows = list(zip(toks.table, toks.row)) linked = {r: set(parent_rows(*r)) for r in rows} masks = { k: np.zeros((n, n), dtype=bool) for k in ["column", "feature", "neighbour", "full"] } for i, (ti, ri) in enumerate(rows): for j, (tj, rj) in enumerate(rows): masks["full"][i, j] = True masks["column"][i, j] = ( ti == tj and toks.column[i] == toks.column[j] ) masks["feature"][i, j] = (ti, ri) == (tj, rj) or ( tj, rj, ) in linked[(ti, ri)] masks["neighbour"][i, j] = (tj, rj) in linked[(ti, ri)] or ( ti, ri, ) in linked[(tj, rj)] return masks rt_masks = relational_masks(rt_tokens) _labels = [ f"{t[:4]}{r}.{c[:5]}" for t, r, c in zip(rt_tokens.table, rt_tokens.row, rt_tokens.column) ] _fig, _axs = plt.subplots(1, 4, figsize=(12, 3.4)) for _ax, (_name, _m) in zip(_axs, rt_masks.items()): _ax.imshow(_m, cmap="Blues", vmin=0, vmax=1.3) _ax.set_title(f"{_name} attention ({_m.sum()} pairs)", fontsize=9) _ax.set_xticks(range(len(_labels)), _labels, rotation=90, fontsize=5) _ax.set_yticks(range(len(_labels)), _labels, fontsize=5) plt.tight_layout() _fig
Thirteen content tokens (keys become links, not tokens), including the masked churn label of user 1. Each mask is a different relational view: column attention compares a value with the same column in other rows (is 51 an old age?); feature attention lets a cell read its own row and its FK parents (an order reads its user's and product's cells); neighbour attention links rows connected by a foreign key in either direction; full attention is the unrestricted fallback. RT interleaves these in its layers. With 22M parameters, pretrained leave-one-database-out, it beats a 27B-parameter LLM by 13% zero-shot AUROC on unseen schemas.
3 · Conformal prediction for GNNs
A classifier gives a label; conformal prediction gives a set \(C(x)\) with a guarantee \(P(y\in C(x))\ge 1-\alpha\), assuming calibration and test points are exchangeable (CF-GNN shows this holds for transductive node classification with permutation-invariant training). Recipe (split conformal):
- non-conformity score on calibration nodes: \(s_i = 1-\hat p_{y_i}\);
- \(\hat q\) = the \(\lceil (n+1)(1-\alpha)\rceil/n\) empirical quantile of the scores;
- \(C(x)=\{y : 1-\hat p_y\le\hat q\}\).
We train a GCN and an MLP on Cora's 140 labels, then split the 1,000 test nodes 50/50 into calibration and evaluation, 200 times.
class Classifier(nn.Module): · 72 lines
class Classifier(nn.Module): def __init__(self, use_graph, d=64): super().__init__() self.use_graph = use_graph self.l1 = ( GCNConv(cora.num_features, d) if use_graph else nn.Linear(cora.num_features, d) ) self.l2 = GCNConv(d, 7) if use_graph else nn.Linear(d, 7) def forward(self, x, ei): h = F.relu(self.l1(x, ei) if self.use_graph else self.l1(x)) h = F.dropout(h, 0.5, self.training) return self.l2(h, ei) if self.use_graph else self.l2(h) def test_probabilities(use_graph, seed=SEED): torch.manual_seed(seed) m = Classifier(use_graph) opt = torch.optim.Adam(m.parameters(), lr=0.01, weight_decay=5e-4) for _ in range(200): m.train() opt.zero_grad() F.cross_entropy( m(cora.x, cora.edge_index)[cora.train_mask], cora.y[cora.train_mask], ).backward() opt.step() m.eval() with torch.no_grad(): return F.softmax(m(cora.x, cora.edge_index), 1)[ cora.test_mask ].numpy(), cora.y[cora.test_mask].numpy() conformal_probs = { "GCN": test_probabilities(True), "MLP": test_probabilities(False), } def split_conformal(probs, y, alpha, n_splits=200, seed=SEED): r = np.random.default_rng(seed) n = len(y) cover, size = [], [] for _ in range(n_splits): perm = r.permutation(n) cal, ev = perm[: n // 2], perm[n // 2 :] scores = 1 - probs[cal, y[cal]] level = min(1.0, np.ceil((len(cal) + 1) * (1 - alpha)) / len(cal)) q = np.quantile(scores, level, method="higher") sets = (1 - probs[ev]) <= q cover.append(sets[np.arange(len(ev)), y[ev]].mean()) size.append(sets.sum(1).mean()) return float(np.mean(cover)), float(np.mean(size)) conf_alpha = mo.ui.slider( 0.01, 0.3, step=0.01, value=0.1, label="α (target miscoverage)", show_value=True, ) mo.vstack( [ mo.md( f"Test accuracy: GCN {(conformal_probs['GCN'][0].argmax(1) == conformal_probs['GCN'][1]).mean():.3f}, " f"MLP {(conformal_probs['MLP'][0].argmax(1) == conformal_probs['MLP'][1]).mean():.3f}. " "**Try it:** lower α demands more coverage, so sets grow." ), conf_alpha, ] )
_rows = [] for _name, (_p, _y) in conformal_probs.items(): _cov, _size = split_conformal(_p, _y, conf_alpha.value) _rows.append( { "model": _name, "target coverage": 1 - conf_alpha.value, "empirical coverage": _cov, "mean set size (of 7 classes)": _size, } ) _alphas = np.linspace(0.02, 0.3, 15) _fig, _ax = plt.subplots(figsize=(5.5, 2.6)) for _name, (_p, _y) in conformal_probs.items(): _ax.plot( 1 - _alphas, [split_conformal(_p, _y, a, n_splits=50)[1] for a in _alphas], "o-", ms=3, label=_name, ) _ax.axvline(1 - conf_alpha.value, color="grey", ls=":") _ax.set_xlabel("target coverage 1 − α") _ax.set_ylabel("mean set size") _ax.legend(fontsize=8) _ax.set_title( "better model → smaller sets at the same guarantee", fontsize=9 ) mo.hstack([pd.DataFrame(_rows).round(3), _fig], widths=[2, 3])
_rev = reveal( · 26 lines
_rev = reveal( r""" Measured (200 random calibration/evaluation splits): both models hit the target **exactly** (0.90 → 0.902 empirical), as the theory promises, *whatever their accuracy*. The difference is **efficiency**: at 90% coverage the GCN's sets contain ≈ **1.5** labels on average, the MLP's ≈ **3.6** of 7. At 95% coverage: 2.3 vs 4.5; at 80%: 1.0 vs 2.4. The guarantee is free; a good model makes it *useful*. CF-GNN adds a second, topology-aware GNN that shrinks the sets further using neighbours' uncertainty. """, predict="Both models are calibrated for 90% coverage. Will their empirical coverage differ? Will their prediction-set sizes?", ) _post = mo.md(r""" --- ## 4 · Exercises **Exercise 1 (conformal quantile).** 9 calibration scores: 0.05, 0.1, 0.12, 0.2, 0.3, 0.35, 0.5, 0.7, 0.9; $\alpha=0.2$. $\lceil(n+1)(1-\alpha)\rceil = 8$, so $\hat q$ is the 8th smallest score. Which classes enter the set for a node with $\hat p=(0.6, 0.25, 0.1, 0.05)$? → `ex1_set` (a Python set of class indices). **Exercise 2 (ICL by hand).** Label-node embeddings (unit vectors) $\ell_A=(1,0)$, $\ell_B=(0,1)$, $\ell_C=(-0.6,0.8)$; query $(0.8,0.6)$. Which label does the prompt graph predict? → `ex2_label`. **Exercise 3 (code).** Write `aps_scores(probs, y)`: the **adaptive prediction set** score, i.e. the total probability mass of all classes ranked *at or above* the true class (sort probabilities descending, cumulative sum up to and including $y$). """) mo.vstack([_rev, _post])
ex1_set = None · 5 lines
ex1_set = None ex2_label = None def aps_scores(probs, y): return None
def _verdict(ok, answered): · 47 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_aps(): try: p = np.array([[0.6, 0.25, 0.1, 0.05], [0.1, 0.2, 0.3, 0.4]]) y = np.array([1, 3]) got = aps_scores(p, y) if got is None: return _verdict(False, False) return _verdict(np.allclose(got, [0.85, 0.4]), True) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict(ex1_set == {0}, ex1_set is not None), mo.md("**Exercise 2**"), _verdict(ex2_label == "A", ex2_label is not None), mo.md("**Exercise 3**"), _check_aps(), mo.accordion( { "Solution 1": mo.md( r"$\hat q$ = 8th smallest score = **0.7**. Scores $1-\hat p$: 0.4, 0.75, 0.9, 0.95 → only class 0 has score ≤ 0.7, so the set is **{0}**. Class 1 just misses (0.75)." ), "Solution 2": mo.md( "Cosines: A 0.8, B 0.6, C $-0.48+0.48=0$ → **A**." ), "Solution 3": mo.md("""```python def aps_scores(probs, y): order = np.argsort(-probs, axis=1) cum = np.cumsum(np.take_along_axis(probs, order, 1), axis=1) rank = np.argmax(order == y[:, None], axis=1) return cum[np.arange(len(y)), rank] ``` APS sets adapt their size to how spread out the model's probabilities are, unlike the simple $1-\\hat p_y$ score."""), } ), ] )
5 · Connecting to your research (SNA on X/Twitter)
-
Few-shot labelling. You often have a handful of hand-labelled accounts per category (bot types, stances). Pretrain a GNN encoder on the retweet graph without labels, then classify new categories from 1–10 examples each with the prompt-graph readout of §1: no retraining per new category.
-
Conformal sets for annotation triage. Wrap your account or tweet classifier in split conformal prediction: singletons can be auto-labelled; sets with 2+ labels go to human review, with a coverage guarantee on what you keep. Calibrate per time window, because exchangeability breaks when the network drifts.
-
Relational Transformer tokens map naturally onto your PostgreSQL tables (cells of users, tweets, relations), the direction to watch for zero-shot prediction across datasets.
Summary
-
ICL on graphs: a prompt graph links example data nodes to label nodes; prediction = query–label link. A label-free pretrained GCN gives 0.70 (1-shot) → 0.85 (10-shot) on 3-way Cora tasks with no weight updates, vs 0.48 → 0.70 from raw features.
-
Relational Transformer: (value, column, table) cell tokens, masked-token tasks, column/feature/neighbour/full attention masks.
- Conformal prediction: guaranteed coverage (0.90 → 0.902); a better model gives smaller sets (GCN 1.5 vs MLP 3.6 labels).
Next — L15 · Foundation models for knowledge graphs: InGram / ULTRA, double equivariance.