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/L09_heterogeneous_graphs.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 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 from torch_geometric.datasets import DBLP from torch_geometric.nn import ( HeteroConv, HGTConv, SAGEConv, GCNConv, Linear, ) 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, } )
L09 · Heterogeneous graphs
CS224W Machine Learning with Graphs · hands-on companion to Lecture 9
Audience: you did L03–L05 (GCN, message passing, training).
Prerequisites: none beyond that; the L01 HeteroData cell helps.
By the end you can:
- describe a heterogeneous graph by its node types and relation types
(source, edge, target), and load one in PyG (HeteroData, DBLP); - implement R-GCN from scratch (one weight matrix per relation, per-relation normalisation, self-loop) and its basis decomposition;
- count parameters (the lecture's 3.28M → 328k example) and use PyG's
HeteroConvand HGT on a real heterogeneous benchmark; - compute ranking metrics (Hits@k, MRR) for link prediction on knowledge graphs.
In short. Real graphs mix kinds of things. The unit is the relation type (Author, writes, Paper). When types have different feature shapes or relations mean different things, model each relation separately: R-GCN gives each relation its own weight matrix, normalises per relation, and sums everything plus a self-loop. That explodes the parameter count, so regularise with block-diagonal or basis weights. HGT makes attention type-aware.
Outline
- What makes a graph heterogeneous — DBLP: authors, papers, terms, conferences.
- R-GCN from scratch — per-relation weights, normalisation, self-loop; basis decomposition.
- Counting parameters — the lecture's worked example, interactive.
- Benchmarks on DBLP — MLP vs. homogeneous GNN vs. R-GCN vs.
HeteroConvvs. HGT. - Ranking metrics — Hits@k and MRR.
- Exercises, pitfall, research links.
1 · What makes a graph heterogeneous
\(G=(V,E,R,T)\): nodes have a type \(T(v)\), edges carry a relation type \(r\in R\), and the key unit is the relation type
(source type, edge type, target type). DBLP (a PyG benchmark): classify authors into 4 research areas.
try: · 46 lines
try: dblp = DBLP(str(DATA_DIR / "DBLP"))[0] except Exception as _e: # no network, or a blocked download host dblp = None _err = _e mo.stop( dblp is None, mo.callout( mo.md( f"Could not download DBLP (`{type(_err).__name__ if dblp is None else ''}`). PyG fetches it from " "Dropbox on first use; run this notebook once with internet access, or copy a `data/DBLP/` folder " "from another machine." ), kind="warn", ), ) dblp["conference"].x = torch.eye( dblp["conference"].num_nodes ) # featureless type: one-hot IDs (only 20 nodes) mo.vstack( [ pd.DataFrame( [ { "node type": t, "nodes": dblp[t].num_nodes, "feature dim": dblp[t].x.size(1), } for t in dblp.node_types ] ), pd.DataFrame( [ { "relation (src, edge, dst)": str(r), "edges": dblp[r].edge_index.size(1), } for r in dblp.edge_types ] ), mo.md( f"Labelled authors: train {int(dblp['author'].train_mask.sum())}, val {int(dblp['author'].val_mask.sum())}, " f"test {int(dblp['author'].test_mask.sum())}; classes: {dblp['author'].y.bincount().tolist()}" ), ] )
Four node types with four different feature sizes (authors 334-d bag-of-words, papers 4,231-d, terms 50-d word vectors, conferences none — we give them 20-d one-hot IDs) and six relation types. A plain GCN cannot even take this input: there is no single feature matrix. The cheapest fix is a per-type linear projection to a shared width; the principled one treats each relation separately.
2 · R-GCN from scratch
$\(\mathbf h_v^{(l+1)}=\sigma\Big(\sum_{r\in R}\sum_{u\in N_v^r}\frac{1}{|N_v^r|}W_r^{(l)}\mathbf h_u^{(l)}+W_0^{(l)}\mathbf h_v^{(l)}\Big)\)$ One weight matrix per relation, mean over each relation's neighbours, sum over relations, plus a self-loop. Basis decomposition: \(W_r=\sum_{b=1}^{B}a_{rb}V_b\) — all relations share \(B\) basis matrices and only the coefficients \(a_{rb}\) are relation-specific.
class RGCNLayer(nn.Module): · 112 lines
class RGCNLayer(nn.Module): """Heterogeneous R-GCN layer on a HeteroData dict. Optional basis decomposition over the relation weights.""" def __init__(self, relations, node_types, d_in, d_out, num_bases=None): super().__init__() self.relations = relations self.key = {r: "__".join(r) for r in relations} if num_bases is None: self.W = nn.ParameterDict( { self.key[r]: nn.Parameter( torch.randn(d_in, d_out) * d_in**-0.5 ) for r in relations } ) else: self.V = nn.Parameter( torch.randn(num_bases, d_in, d_out) * d_in**-0.5 ) # shared bases self.a = nn.ParameterDict( { self.key[r]: nn.Parameter( torch.randn(num_bases) / num_bases ) for r in relations } ) self.W0 = nn.ModuleDict( {t: nn.Linear(d_in, d_out) for t in node_types} ) # self-loop per node type self.num_bases = num_bases def weight(self, r): k = self.key[r] return ( self.W[k] if self.num_bases is None else torch.einsum("b,bio->io", self.a[k], self.V) ) def forward(self, x_dict, edge_index_dict): out = {t: self.W0[t](x) for t, x in x_dict.items()} for r in self.relations: src_t, _, dst_t = r src, dst = edge_index_dict[r] msg = x_dict[src_t][src] @ self.weight( r ) # W_r h_u for every edge agg = torch.zeros( x_dict[dst_t].size(0), msg.size(1) ).index_add_(0, dst, msg) deg = ( torch.bincount(dst, minlength=x_dict[dst_t].size(0)) .clamp(min=1) .unsqueeze(1) ) out[dst_t] = out[dst_t] + agg / deg # mean per relation return out class HeteroNet(nn.Module): """Per-type input projection → 2 message-passing layers (R-GCN, HeteroConv-SAGE or HGT) → author classifier.""" def __init__(self, kind, d=64, num_bases=None): super().__init__() self.kind = kind self.proj = nn.ModuleDict( {t: Linear(-1, d) for t in dblp.node_types} ) if kind == "R-GCN": self.layers = nn.ModuleList( RGCNLayer( dblp.edge_types, dblp.node_types, d, d, num_bases ) for _ in range(2) ) elif kind == "HeteroConv (SAGE per relation)": self.layers = nn.ModuleList( HeteroConv( {r: SAGEConv((-1, -1), d) for r in dblp.edge_types}, aggr="sum", ) for _ in range(2) ) elif kind == "HGT": self.layers = nn.ModuleList( HGTConv(d, d, dblp.metadata(), heads=4) for _ in range(2) ) self.out = nn.Linear(d, 4) def forward(self, data): x = {t: F.relu(self.proj[t](data[t].x)) for t in data.node_types} for layer in self.layers: x = { t: F.dropout(F.relu(h), 0.3, self.training) for t, h in layer(x, data.edge_index_dict).items() } return self.out(x["author"]) def n_params(model): return sum(p.numel() for p in model.parameters()) with torch.no_grad(): _m = HeteroNet("R-GCN") _m(dblp) # lazy Linear(-1, d) layers need one forward pass _mb = HeteroNet("R-GCN", num_bases=2) _mb(dblp) mo.md( f"R-GCN (2 layers, 6 relations, d=64): **{n_params(_m):,}** parameters (incl. input projections); " f"with **2 bases**: **{n_params(_mb):,}**. Message-passing part only: " f"{sum(p.numel() for p in _m.layers.parameters()):,} vs {sum(p.numel() for p in _mb.layers.parameters()):,}." )
On DBLP the R-GCN message-passing layers have 82k parameters (6 relations × 64×64 per layer + self-loops); with 2 shared bases they drop to 50k. The 4,231-d paper projection dominates the total, which is common: input encoders often outweigh the GNN.
3 · Counting parameters: the lecture's worked example
A knowledge graph with \(|R|=100\) relations, \(d=128\), \(L=2\) layers: plain R-GCN \(= |R|\,d^2L\); basis decomposition \(= B\,d^2L + |R|\,B\,L\); block-diagonal with \(k\) blocks \(=|R|\,k\,(d/k)^2L\).
param_controls = mo.ui.dictionary( { "R": mo.ui.slider( steps=[2, 10, 50, 100, 500, 1000], value=100, label="relations |R|", show_value=True, ), "d": mo.ui.slider( steps=[16, 32, 64, 128, 256], value=128, label="hidden d", show_value=True, ), "L": mo.ui.slider( 1, 4, value=2, label="layers L", show_value=True ), "B": mo.ui.slider( steps=[1, 2, 5, 10, 20, 50], value=10, label="bases B", show_value=True, ), "k": mo.ui.slider( steps=[1, 2, 4, 8, 16], value=8, label="diagonal blocks", show_value=True, ), } ) mo.hstack(list(param_controls.elements.values()), justify="start")
_p = param_controls.value _plain = _p["R"] * _p["d"] ** 2 * _p["L"] _basis = _p["B"] * _p["d"] ** 2 * _p["L"] + _p["R"] * _p["B"] * _p["L"] _block = _p["R"] * _p["k"] * (_p["d"] // _p["k"]) ** 2 * _p["L"] mo.md(f""" | variant | relation-weight parameters | vs. plain | |---|---|---| | plain R-GCN | **{_plain:,}** | 1× | | basis decomposition (B={_p["B"]}) | **{_basis:,}** | {_plain / _basis:.1f}× fewer | | block-diagonal ({_p["k"]} blocks of {_p["d"] // _p["k"]}) | **{_block:,}** | {_plain / _block:.1f}× fewer (dims only mix inside a block) | **Try it:** the defaults reproduce the lecture: 3.28M → ≈ 330k with 10 bases, and 8× fewer with 8 blocks. """)
4 · Benchmarks on DBLP
All models share the per-type input projections and train on the same 400 labelled authors (full batch, 100 epochs):
- MLP — author bag-of-words only, no graph.
- Relation-blind GNN — the R-GCN layer with one weight matrix shared by all relations (≈ a homogeneous GNN after projection).
- R-GCN (per-relation weights), R-GCN + 2 bases, PyG
HeteroConvwith a SAGEConv per relation, and HGT.
class SharedRGCNLayer(RGCNLayer): · 82 lines
class SharedRGCNLayer(RGCNLayer): """Relation-blind variant: every relation uses the same weight matrix.""" def __init__(self, relations, node_types, d_in, d_out): super().__init__(relations, node_types, d_in, d_out) self.W_shared = nn.Parameter(torch.randn(d_in, d_out) * d_in**-0.5) del self.W def weight(self, r): return self.W_shared class MLPNet(nn.Module): def __init__(self, d=64): super().__init__() self.net = nn.Sequential( nn.Linear(dblp["author"].x.size(1), d), nn.ReLU(), nn.Dropout(0.3), nn.Linear(d, 4), ) def forward(self, data): return self.net(data["author"].x) def make_model(name): if name == "MLP (author features only)": return MLPNet() if name == "relation-blind GNN": m = HeteroNet("R-GCN") m.layers = nn.ModuleList( SharedRGCNLayer(dblp.edge_types, dblp.node_types, 64, 64) for _ in range(2) ) return m if name == "R-GCN + 2 bases": return HeteroNet("R-GCN", num_bases=2) return HeteroNet(name) def fit_dblp(name, epochs=100, seed=SEED): torch.manual_seed(seed) model = make_model(name) with torch.no_grad(): model(dblp) # initialise lazy layers opt = torch.optim.Adam(model.parameters(), lr=0.005, weight_decay=1e-4) a = dblp["author"] best_val, best_test, t0 = 0, 0, time.time() for _ in range(epochs): model.train() opt.zero_grad() F.cross_entropy( model(dblp)[a.train_mask], a.y[a.train_mask] ).backward() opt.step() model.eval() with torch.no_grad(): pred = model(dblp).argmax(1) val = (pred[a.val_mask] == a.y[a.val_mask]).float().mean().item() if val >= best_val: best_val, best_test = ( val, (pred[a.test_mask] == a.y[a.test_mask]) .float() .mean() .item(), ) return { "model": name, "test acc (best val)": best_test, "parameters": n_params(model), "seconds": time.time() - t0, } dblp_models = [ "MLP (author features only)", "relation-blind GNN", "R-GCN", "R-GCN + 2 bases", "HeteroConv (SAGE per relation)", "HGT", ] dblp_run = mo.ui.run_button(label="Train the six models (≈ 3 min)") dblp_run
mo.stop( not dblp_run.value, mo.md("Press the button to train all six models on DBLP."), ) dblp_results = pd.DataFrame( [ fit_dblp(n) for n in mo.status.progress_bar(dblp_models, title="training") ] ) dblp_results.round(3)
_rev = reveal( · 31 lines
_rev = reveal( r""" Measured (one seed, 100 epochs): | model | test acc | |---|---| | MLP (author features only) | 0.78 | | relation-blind GNN | **0.87** | | R-GCN | 0.86 | | R-GCN + 2 bases | 0.85 | | HeteroConv (SAGE per relation) | 0.86 | | HGT | 0.79 (slowest: 73 s; under-trained at 100 epochs) | * Using the graph is worth **+8–9 points** over the author features alone. * The **relation-blind** model is as good as the relation-aware ones here. In DBLP every pair of node types is linked by exactly one relation, and the per-type projections and self-loops already tell the model *which* type a message came from. Per-relation weights pay off when the **same** pair of types is linked by several relations with different meanings (a knowledge graph's "treats" vs "causes", or follows vs. retweets vs. replies between users). * Differences of 1–2 points are within seed noise; the conclusion "heterogeneous ≫ homogeneous" is not automatic. """, predict="On DBLP, will the relation-aware models (R-GCN, HeteroConv, HGT) beat a relation-blind GNN? How many points does the graph add over author features alone?", ) _post = mo.md(r""" ## 5 · Ranking metrics for link prediction (Hits@k, MRR) For a test edge $(h, r, t)$, score the true tail against candidate tails; the **rank** of the true tail gives Hits@k $=\mathbb 1[\text{rank}\le k]$ and reciprocal rank $1/\text{rank}$, averaged over test edges (MRR). Lecture example: true $(E,r_3,D)$ scores 0.7; candidates $B$: 0.9, $F$: 0.4 → rank 2 → Hits@1 = 0, Hits@2 = 1, RR = 0.5. """) mo.vstack([_rev, _post])
def rank_metrics(true_scores, candidate_scores, ks=(1, 3, 10)): · 25 lines
def rank_metrics(true_scores, candidate_scores, ks=(1, 3, 10)): """true_scores: [n]; candidate_scores: [n, m] for m negatives each. Rank = 1 + #negatives scoring higher (ties count half).""" t = np.asarray(true_scores)[:, None] c = np.asarray(candidate_scores) ranks = 1 + (c > t).sum(1) + 0.5 * (c == t).sum(1) return { "mean rank": ranks.mean(), "MRR": (1 / ranks).mean(), **{f"Hits@{k}": (ranks <= k).mean() for k in ks}, } pd.DataFrame( [ { "case": "lecture example (one test edge)", **rank_metrics([0.7], [[0.9, 0.4]], ks=(1, 2)), }, { "case": "random scorer, 1000 edges × 99 negatives", **rank_metrics( rng.random(1000), rng.random((1000, 99)), ks=(1, 2) ), }, ] ).round(3)
A random scorer with 99 negatives has MRR ≈ 0.05 and Hits@1 ≈ 0.01, the baseline to beat. Use filtered ranking in KGs: remove other known true tails from the candidate list so a model is not penalised for ranking a correct alternative first (L10).
6 · Exercises
Exercise 1 (parameters). R-GCN with \(|R|=20\), \(d=64\), \(L=3\), basis decomposition with \(B=4\). How many relation-weight
parameters (bases + coefficients, no self-loops)? → ex1_params.
Exercise 2 (ranking). Three test edges; true-tail scores and their candidates:
(0.8; 0.9, 0.7, 0.1), (0.5; 0.2, 0.3, 0.4), (0.3; 0.6, 0.9, 0.8). What is the MRR? → ex2_mrr (3 decimals).
Exercise 3 (code). Add a block-diagonal option: write block_diag_weight(blocks) that turns a list of \(k\) matrices
\((d/k)\times(d/k)\) into one \(d\times d\) block-diagonal matrix (no loops over entries; torch.block_diag is allowed).
ex1_params = None · 5 lines
ex1_params = None ex2_mrr = None def block_diag_weight(blocks): return None
def _verdict(ok, answered): · 52 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_block(): try: g = torch.Generator().manual_seed(0) blocks = [torch.randn(4, 4, generator=g) for _ in range(3)] got = block_diag_weight(blocks) if got is None: return _verdict(False, False) want = torch.zeros(12, 12) for i, b in enumerate(blocks): want[4 * i : 4 * i + 4, 4 * i : 4 * i + 4] = b return _verdict(torch.allclose(got, want), True) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_params == 4 * 64 * 64 * 3 + 20 * 4 * 3, ex1_params is not None, ), mo.md("**Exercise 2**"), _verdict( ex2_mrr is not None and abs(ex2_mrr - (1 / 2 + 1 + 1 / 4) / 3) < 2e-3, ex2_mrr is not None, ), mo.md("**Exercise 3**"), _check_block(), mo.accordion( { "Solution 1": mo.md( r"Bases $B\,d^2L=4\cdot4096\cdot3=49{,}152$ plus coefficients $|R|\,B\,L=240$ → **49,392** (plain: $20\cdot4096\cdot3=245{,}760$)." ), "Solution 2": mo.md( r"Ranks 2, 1, 4 → RR 0.5, 1, 0.25 → MRR $=1.75/3\approx 0.583$. Check with `rank_metrics([0.8, 0.5, 0.3], [[0.9, 0.7, 0.1], [0.2, 0.3, 0.4], [0.6, 0.9, 0.8]])`." ), "Solution 3": mo.md( "```python\ndef block_diag_weight(blocks):\n return torch.block_diag(*blocks)\n```\nParameters: $k\\,(d/k)^2 = d^2/k$ per relation, but dimension $i$ only mixes with dimensions in its own block." ), } ), ] )
7 · Pitfall: forgetting reverse relations
HeteroData edges are directed per relation. If you only store (author, writes, paper), papers receive messages from
authors but authors never hear from papers. On a node task about authors the graph is then invisible to them. DBLP ships both
directions (author→paper and paper→author). For your own data, add them with T.ToUndirected() (which creates rev_*
relations) and check data.edge_types before training.
8 · Connecting to your research (SNA on X/Twitter)
-
Your data is naturally heterogeneous: users, tweets, hashtags, URLs, with relations follows / posts / retweets / replies / mentions / uses-hashtag. Build it as
HeteroData(L01 §3 did a small version), with reverse relations. -
Here relation-specific weights should matter more than on DBLP: user→user is linked by several relations with different meanings (follow ≠ retweet ≠ reply), exactly the case R-GCN is made for. Compare with the relation-blind baseline before claiming so.
-
Many relation types (per-hashtag, per-language) → use basis decomposition to keep parameters manageable.
Summary
- Heterogeneous graph = node types + relation types; the relation
(src, edge, dst)is the unit. - R-GCN: one weight per relation, mean per relation, sum + self-loop; bases (3.28M → 330k) or blocks (8×) regularise it.
- DBLP: graph +9 points over features; relation-aware ≈ relation-blind here, because one relation per type pair.
- Link prediction is evaluated by ranks: Hits@k, MRR (random ≈ 0.05 with 99 negatives).
Next — L10 · Knowledge graph embeddings: TransE, DistMult, ComplEx, RotatE and the relation patterns they can express.