Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L14 · Advanced topics — in-context learning on graphs, relational foundation models, conformal prediction

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:

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

  1. In-context learning on a graph (PRODIGY-lite) — label-free pretraining, then few-shot prompts with no weight update.
  2. Relational Transformer tokens and masks — cells as tokens; column / feature / neighbour / full attention.
  3. Conformal prediction for GNNs — prediction sets with guaranteed coverage.
  4. 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):

  1. non-conformity score on calibration nodes: \(s_i = 1-\hat p_{y_i}\);
  2. \(\hat q\) = the \(\lceil (n+1)(1-\alpha)\rceil/n\) empirical quantile of the scores;
  3. \(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)

Summary

Next — L15 · Foundation models for knowledge graphs: InGram / ULTRA, double equivariance.