Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L15 · Towards foundation models for knowledge graphs

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:

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

  1. The inductiveness ladder.
  2. The relation graph — the lecture's movie-KG worked example.
  3. ULTRA-lite — relation GNN + query-conditioned entity GNN, from scratch.
  4. Zero-shot transfer to a new KG with unseen entities and shuffled relation IDs.
  5. 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, ?)\):

  1. 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.

  2. 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)

Summary

Next — L16 · LLMs + GNNs: GraphRAG, G-Retriever, GLEM.