Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L09 · Heterogeneous 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/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:

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

  1. What makes a graph heterogeneous — DBLP: authors, papers, terms, conferences.
  2. R-GCN from scratch — per-relation weights, normalisation, self-loop; basis decomposition.
  3. Counting parameters — the lecture's worked example, interactive.
  4. Benchmarks on DBLP — MLP vs. homogeneous GNN vs. R-GCN vs. HeteroConv vs. HGT.
  5. Ranking metrics — Hits@k and MRR.
  6. 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):

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)

Summary

Next — L10 · Knowledge graph embeddings: TransE, DistMult, ComplEx, RotatE and the relation patterns they can express.