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/L15_foundation_models_kgs.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 import torch.nn as nn import torch.nn.functional as F 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, } )
L15 · Towards foundation models for knowledge graphs
CS224W Machine Learning with Graphs · hands-on companion to Lecture 15
Audience: you did L09 (R-GCN) and L10 (KG embeddings). Prerequisites: message passing; the idea of zero-shot transfer.
By the end you can:
- place a KG model on the inductiveness ladder: shallow embeddings → R-GCN (new entities) → InGram/ULTRA (new entities and new relations);
- build the relation graph of a KG (relations linked by shared heads/tails), reproducing the lecture's movie-KG weights;
- implement a small ULTRA-style, doubly equivariant model (relation GNN → query-conditioned entity GNN) and show it answers queries on a completely new KG whose entities and relation IDs it has never seen, where an ID-based model collapses.
In short. Shallow KG embeddings give every entity and relation a free vector: retrain for every new drug. R-GCN computes entities from structure (new entities fine) but relations are still weight matrices. InGram/ULTRA build a relation graph from how relations share head/tail entities, run a GNN on it to get relation representations, then run an entity-level GNN conditioned on them. Both entities and relations come from structure (double equivariance), so one model transfers across KGs.
Outline
- The inductiveness ladder.
- The relation graph — the lecture's movie-KG worked example.
- ULTRA-lite — relation GNN + query-conditioned entity GNN, from scratch.
- Zero-shot transfer to a new KG with unseen entities and shuffled relation IDs.
- Exercises, research links.
1 · The inductiveness ladder
| model | entity representation | relation representation | new entities? | new relations? |
|---|---|---|---|---|
| TransE, DistMult, … (L10) | free vector per entity | free vector per relation | ✗ retrain | ✗ retrain |
| R-GCN / NBFNet (L09) | computed from structure | learned weight per relation ID | ✓ | ✗ |
| InGram / ULTRA | computed from structure | computed from the relation graph | ✓ | ✓ |
Double equivariance: relabel the entities or the relations of a KG and the predictions are relabelled the same way. A model with a weight per relation ID is not equivariant to relation relabelling. That is exactly why it cannot transfer.
2 · The relation graph
Two relations are linked if they share entities: head–head (the same entity is head of both), tail–tail, head–tail and tail–head. InGram weights the head part by \(A_h=E_h^\top D_h^{-2}E_h\), with \(E_h[e,r]=1\) if \(e\) is a head of \(r\) and \(D_h\) the head degree, which damps hubs. Lecture example: Emma heads 3 triples (actedIn ×2, costarred), Olivia 2 (actedIn, bornIn), Yorgos 2 (directed ×2).
movie_kg = [ ("Emma", "actedIn", "La La Land"), ("Emma", "actedIn", "Poor Things"), ("Emma", "costarred", "Ryan"), ("Olivia", "actedIn", "The Favourite"), ("Olivia", "bornIn", "London"), ("Yorgos", "directed", "Poor Things"), ("Yorgos", "directed", "The Favourite"), ] _ents = sorted({x for h, _, t in movie_kg for x in (h, t)}) _rels = ["actedIn", "costarred", "bornIn", "directed"] E_h = np.zeros((len(_ents), len(_rels))) for _h, _r, _t in movie_kg: E_h[_ents.index(_h), _rels.index(_r)] = 1.0 _head_degree = np.array( [sum(h == e for h, _, _ in movie_kg) for e in _ents], dtype=float ) # number of triples headed _D_inv2 = np.diag( np.where(_head_degree > 0, 1 / np.maximum(_head_degree, 1) ** 2, 0) ) A_h = E_h.T @ _D_inv2 @ E_h np.fill_diagonal(A_h, 0) pd.DataFrame(A_h, index=_rels, columns=_rels).round(3)
Reproduces the lecture: \(A_h[\text{actedIn},\text{costarred}]=1/3^2=0.111\) (via Emma), \(A_h[\text{actedIn},\text{bornIn}]=1/2^2=0.25\) (via Olivia), and "directed" links to nothing on the head side (Yorgos only directs). The tail part would link actedIn and directed through shared films. "bornIn is close to actedIn" because the same people do both: a structural signature that exists in any KG, whatever the relations are called.
3 · ULTRA-lite, from scratch
Data: synthetic family KGs (as in L10: spouse, parent_of, child_of, grandparent_of, sibling_of). For a query \((h, q, ?)\):
-
Relation GNN on the relation graph with 4 edge types (h2h, t2t, h2t, t2h): the query relation starts as an all-ones vector, every other relation as zeros; 3 layers of message passing give query-conditioned relation representations \(R_r\). No relation ID is ever looked up.
-
Entity GNN (NBFNet-style) on the KG: the head entity starts as \(R_q\), all others as 0; messages \(x_u\odot R_r\) along every triple \((u,r,v)\); after 3 layers each candidate tail is scored by an MLP. Cross-entropy over all entities.
Baseline: the same model, but with \(R_r\) = a learned embedding per relation ID (entity-inductive only, like R-GCN/NBFNet).
FAMILY_RELS = [ · 76 lines
FAMILY_RELS = [ "spouse", "parent_of", "child_of", "grandparent_of", "sibling_of", ] def family_kg(n_families, seed): r = np.random.default_rng(seed) triples, pid = set(), 0 def add(h, rel, t): triples.add((h, FAMILY_RELS.index(rel), t)) for _ in range(n_families): ga, gb = pid, pid + 1 pid += 2 add(ga, "spouse", gb) add(gb, "spouse", ga) kids = [] for _ in range(int(r.integers(2, 4))): c, partner = pid, pid + 1 pid += 2 kids.append(c) add(c, "spouse", partner) add(partner, "spouse", c) for p in (ga, gb): add(p, "parent_of", c) add(c, "child_of", p) grandkids = [] for _ in range(int(r.integers(1, 4))): k = pid pid += 1 grandkids.append(k) for p in (c, partner): add(p, "parent_of", k) add(k, "child_of", p) for gp in (ga, gb): add(gp, "grandparent_of", k) for a in grandkids: for b in grandkids: if a != b: add(a, "sibling_of", b) for a in kids: for b in kids: if a != b: add(a, "sibling_of", b) return np.array(sorted(triples)), pid def split_triples(T, seed, frac=0.2): p = np.random.default_rng(seed).permutation(len(T)) k = int(frac * len(T)) return T[p[k:]], T[p[:k]] # (message triples, query triples) def relation_graph(msg, n_rel, n_ent): """4 x n_rel x n_rel adjacency: h2h, t2t, h2t, t2h (relations sharing an entity in those roles).""" Eh = np.zeros((n_ent, n_rel)) Et = np.zeros((n_ent, n_rel)) Eh[msg[:, 0], msg[:, 1]] = 1 Et[msg[:, 2], msg[:, 1]] = 1 A = np.stack([Eh.T @ Eh, Et.T @ Et, Eh.T @ Et, Et.T @ Eh]) > 0 return torch.tensor(A, dtype=torch.float) # KG "A" for training; KG "B": different people AND relation IDs shuffled (a new KG to the model) kg_A, n_ent_A = family_kg(40, seed=0) kg_B_raw, n_ent_B = family_kg(40, seed=1) REL_SHUFFLE = np.array([3, 0, 4, 1, 2]) kg_B = kg_B_raw.copy() kg_B[:, 1] = REL_SHUFFLE[kg_B_raw[:, 1]] msg_A, query_A = split_triples(kg_A, seed=0) msg_B, query_B = split_triples(kg_B, seed=1) mo.md( f"KG A: {len(kg_A):,} triples, {n_ent_A} entities. KG B: {len(kg_B):,} triples, {n_ent_B} **different** entities, " f"relation IDs shuffled by {REL_SHUFFLE.tolist()} (old id → new id). Each KG: 80% message triples, 20% queries." )
class ULTRALite(nn.Module): · 108 lines
class ULTRALite(nn.Module): def __init__( self, d=32, n_layers=3, relation_inductive=True, n_relation_ids=5 ): super().__init__() self.d, self.relation_inductive = d, relation_inductive self.rel_edge = nn.Parameter( torch.randn(4, d) * 0.1 ) # one vector per relation-graph edge type self.rel_layers = nn.ModuleList( nn.Linear(2 * d, d) for _ in range(n_layers) ) self.rel_id_emb = nn.Embedding( n_relation_ids, d ) # baseline only: a vector per relation ID self.ent_layers = nn.ModuleList( nn.Linear(2 * d, d) for _ in range(n_layers) ) self.score = nn.Sequential( nn.Linear(d, d), nn.ReLU(), nn.Linear(d, 1) ) def relation_reps(self, A_rel, q): B, n_rel = len(q), A_rel.size(1) if not self.relation_inductive: R = ( self.rel_id_emb.weight[:n_rel] .unsqueeze(0) .expand(B, -1, -1) ) return R, R[torch.arange(B), q] h = torch.zeros(B, n_rel, self.d) h[torch.arange(B), q] = 1.0 # query relation = all-ones for lin in self.rel_layers: msg = sum( torch.einsum("ij,bjd->bid", A_rel[k], h) * self.rel_edge[k] for k in range(4) ) h = F.relu(lin(torch.cat([h, msg], -1))) + h return h, h[torch.arange(B), q] def forward(self, msg, n_ent, A_rel, heads, q): R, r_q = self.relation_reps(A_rel, q) x = torch.zeros(len(heads), n_ent, self.d) x[torch.arange(len(heads)), heads] = r_q # labelling trick src, rel, dst = (torch.from_numpy(msg[:, i]) for i in range(3)) for lin in self.ent_layers: m = x[:, src] * R[:, rel] # x_u ⊙ R_r along every triple x = ( F.relu( lin( torch.cat( [x, torch.zeros_like(x).index_add_(1, dst, m)], -1, ) ) ) + x ) return self.score(x).squeeze(-1) # [batch, n_ent] tail scores def train_ultra(model, epochs=30, bs=64, seed=SEED): torch.manual_seed(seed) r = np.random.default_rng(seed) A_rel = relation_graph(msg_A, 5, n_ent_A) opt = torch.optim.Adam(model.parameters(), lr=5e-3) for _ in range(epochs): order = r.permutation(len(query_A)) for i in range(0, len(order), bs): b = query_A[order[i : i + bs]] logits = model( msg_A, n_ent_A, A_rel, torch.from_numpy(b[:, 0]), torch.from_numpy(b[:, 1]), ) opt.zero_grad() F.cross_entropy(logits, torch.from_numpy(b[:, 2])).backward() opt.step() return model def filtered_mrr(model, msg, queries, n_ent, all_triples): A_rel = relation_graph(msg, 5, n_ent) known = {} for h, r, t in all_triples: known.setdefault((h, r), set()).add(t) rr = [] with torch.no_grad(): for i in range(0, len(queries), 128): b = queries[i : i + 128] logits = model( msg, n_ent, A_rel, torch.from_numpy(b[:, 0]), torch.from_numpy(b[:, 1]), ) for j, (h, r, t) in enumerate(b): s = logits[j].clone() s[[x for x in known[(h, r)] if x != t]] = -float("inf") rr.append(1.0 / (1 + (s > s[t]).sum().item())) return float(np.mean(rr)) ultra_run = mo.ui.run_button( label="Train both models on KG A and test on KG B (≈ 40 s)" ) ultra_run
mo.stop( not ultra_run.value, mo.md( "Press the button to train the relation-inductive model and the relation-ID baseline." ), ) msg_B_raw, query_B_raw = split_triples( kg_B_raw, seed=1 ) # KG B with its ORIGINAL relation IDs _rows = [] for _name, _ind in [ ("ULTRA-lite (relations from the relation graph)", True), ("same model, relation-ID embeddings", False), ]: torch.manual_seed(SEED) _t = time.time() _m = train_ultra(ULTRALite(relation_inductive=_ind)) _rows.append( { "model": _name, "KG A (training queries)": filtered_mrr( _m, msg_A, query_A, n_ent_A, kg_A ), "KG B, new entities, same relation IDs": filtered_mrr( _m, msg_B_raw, query_B_raw, n_ent_B, kg_B_raw ), "KG B, new entities + shuffled relation IDs": filtered_mrr( _m, msg_B, query_B, n_ent_B, kg_B ), "seconds": time.time() - _t, } ) ultra_results = pd.DataFrame(_rows) ultra_results.round(3)
_rev = reveal( · 31 lines
_rev = reveal( r""" Measured (one seed, ≈ 17 s per model): | | KG A (training) | KG B: new entities | KG B: new entities + shuffled relation IDs | |---|---|---|---| | **ULTRA-lite** | 0.963 | 0.962 | **0.962** | | **relation-ID baseline** | 0.973 | 0.967 | **0.105** | * Both models are **entity-inductive**: on 482 people they have never seen, MRR stays ≈ 0.96–0.97. Entities are computed from structure (the labelling trick + message passing), as in R-GCN / NBFNet. * Shuffle the relation IDs, which is what a new KG with its own relation vocabulary looks like, and the ID-based model collapses to **0.105**: its "parent_of" vector now means "grandparent_of". ULTRA-lite is **unchanged (0.962)**, because it never looks at relation IDs, only at how relations co-occur. That is double equivariance, and it is what lets ULTRA be pretrained on a few KGs and used zero-shot on dozens of others. """, predict="Shuffle the relation IDs of the new KG. What happens to the relation-ID model's MRR, and to ULTRA-lite's?", ) _post = mo.md(r""" --- ## 5 · Exercises **Exercise 1 (relation graph).** Triples $(a,r_1,b)$, $(a,r_2,c)$, $(d,r_3,a)$. How many *pairs* of distinct relations are linked in the relation graph by any of the four edge types? → `ex1_pairs`. **Exercise 2 (tail part).** For the movie KG of §2, compute the tail-side weight $A_t[\text{actedIn},\text{directed}]$ with $A_t=E_t^\top D_t^{-2}E_t$ ($E_t[e,r]=1$ if $e$ is a tail of $r$; $D_t$ = number of triples in which $e$ is the tail). → `ex2_At`. """) mo.vstack([_rev, _post])
ex1_pairs = None · 2 lines
ex1_pairs = None ex2_At = None
def _verdict(ok, answered): · 29 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", ) mo.vstack( [ mo.md("**Exercise 1**"), _verdict(ex1_pairs == 3, ex1_pairs is not None), mo.md("**Exercise 2**"), _verdict( ex2_At is not None and abs(ex2_At - 0.5) < 1e-6, ex2_At is not None, ), mo.accordion( { "Solution 1": mo.md( r"$r_1$–$r_2$ (head–head via $a$); $r_1$–$r_3$ and $r_2$–$r_3$ (head–tail / tail–head via $a$, head of $r_1,r_2$ and tail of $r_3$) → **3** pairs." ), "Solution 2": mo.md( r"Shared tails: *Poor Things* (tail of actedIn and directed, tail degree 2 → $1/4$) and *The Favourite* (same → $1/4$); *La La Land* is only an actedIn tail → **0.5**." ), } ), ] )
6 · Connecting to your research (SNA on X/Twitter)
-
Different platforms and datasets name their relations differently (follows / subscribes; retweet / repost / share). A relation-inductive model trained on one social KG can answer link queries on another, because it reads relations through their structural role, not their name or ID.
-
The relation graph is itself an analysis tool: which interaction types share actors (e.g. "accounts that reply also quote") is a compact fingerprint of a platform's behaviour that you can compare across your datasets.
-
In practice: start from the pretrained ULTRA checkpoints released by the authors (their code is built on PyG) for zero-shot link prediction on your user–hashtag–URL KG, then fine-tune if labels exist.
Summary
- Ladder: shallow KGE (transductive) → R-GCN/NBFNet (new entities) → InGram/ULTRA (new entities and relations).
- Relation graph: relations linked by shared heads/tails, with \(D^{-2}\) damping (movie KG: 0.111, 0.25).
- ULTRA-lite: transfers zero-shot to a new KG with unseen people and shuffled relation IDs (MRR 0.96); the relation-ID baseline drops from 0.97 to 0.11.
Next — L16 · LLMs + GNNs: GraphRAG, G-Retriever, GLEM.