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/L13_advanced_rdl.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 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, } )
L13 · Advanced architectures in relational deep learning
CS224W Machine Learning with Graphs · hands-on companion to Lecture 13
Audience: you did L12 (databases as graphs, training tables, a relational GNN). Prerequisites: heterogeneous message passing (L09), attention (L08).
By the end you can:
- explain why junction tables (transactions linking customers and products) cause imbalance and redundancy in standard message passing;
-
implement RelGNN-style composite message passing along an atomic route customer ← (transaction ⊕ product), and beat a standard heterogeneous GNN with one layer instead of three;
-
tokenise a customer's relational neighbourhood the RelGT way (features, type, hop, time, positional encoding) and run a transformer on it;
- say when each architecture is worth its cost.
In short. PK–FK edges carry no semantics, and many-to-many links go through junction tables. In standard MP the informative node (the product) is 2 hops from the customer while the bland junction row is 1 hop away (imbalance), and the customer's own information bounces back through the junction (redundancy). RelGNN passes composite messages along atomic routes (≈ up to 25% gains on RelBench). RelGT samples a local context and represents every token by a 5-tuple; it is the backbone of the first relational foundation model.
Outline
- A junction-table database — customers, products, transactions; a task that needs price × category pairing.
- Standard heterogeneous message passing with 1, 2, 3 layers.
- Composite message passing (RelGNN) — one step along customer ← (transaction ⊕ product).
- RelGT tokenisation — 5-tuple tokens and a transformer over them.
- Exercises, research links.
1 · A junction-table database (synthetic)
1,500 customers, 200 products (5 categories, a list price), and transactions linking them with an amount = list price ×
quantity (quantity 1–10). Label: does category 0 ("electronics") account for more than 25% of the customer's spend?
That is SUM(amount) GROUP BY category: the model must keep each transaction's amount paired with its product's category.
def make_junction_db(n_customers=1500, n_products=200, seed=SEED): r = np.random.default_rng(seed) category = r.integers(0, 5, n_products) list_price = np.exp(r.normal(3, 0.7, n_products)) rows = [] for c in range(n_customers): for _ in range(r.integers(2, 12)): p = int(r.integers(n_products)) rows.append((c, p, list_price[p] * r.integers(1, 11))) txn = pd.DataFrame( rows, columns=["customer_id", "product_id", "amount"] ) share = ( txn.assign(e=txn.amount * (category[txn.product_id] == 0)) .groupby("customer_id")[["e", "amount"]] .sum() .pipe(lambda d: d.e / d.amount) ) return ( category, list_price, txn, torch.tensor((share > 0.25).values, dtype=torch.float), ) category, list_price, txn_table, y_cust = make_junction_db() n_cust, n_prod = len(y_cust), len(category) x_cust = torch.ones(n_cust, 1) # customers: no features x_prod = torch.tensor( np.c_[np.eye(5)[category], np.log(list_price)], dtype=torch.float ) x_txn = torch.tensor( np.log(txn_table.amount.values)[:, None], dtype=torch.float ) txn_cust = torch.tensor(txn_table.customer_id.values) txn_prod = torch.tensor(txn_table.product_id.values) _perm = np.random.default_rng(SEED).permutation(n_cust) train_mask = torch.zeros(n_cust, dtype=torch.bool) train_mask[_perm[:1000]] = True mo.md( f"{n_cust:,} customers · {n_prod} products · {len(txn_table):,} transactions; positive rate {y_cust.mean():.3f} " f"(so 'always negative' scores {1 - y_cust[~train_mask].mean():.3f} on the test customers)." )
2 · Standard heterogeneous message passing
Edges customer ↔ transaction ↔ product. Each layer, every node type sums transformed messages from its neighbours and adds its own state. After 1 layer the customer has only seen transaction amounts; the categories are 2 hops away (imbalance). In layer 2 the transactions also re-send what they received from the customer itself (redundancy).
def scatter_sum(src, index, n): · 128 lines
def scatter_sum(src, index, n): return torch.zeros(n, src.size(1)).index_add_(0, index, src) class StandardHeteroMP(nn.Module): def __init__(self, n_layers, d=32): super().__init__() self.enc_c, self.enc_t, self.enc_p = ( nn.Linear(1, d), nn.Linear(1, d), nn.Linear(6, d), ) self.layers = nn.ModuleList( nn.ModuleDict( { k: nn.Linear(d, d) for k in [ "self_c", "self_t", "self_p", "t2c", "c2t", "p2t", "t2p", ] } ) for _ in range(n_layers) ) self.out = nn.Linear(d, 1) def forward(self): c, t, p = self.enc_c(x_cust), self.enc_t(x_txn), self.enc_p(x_prod) for W in self.layers: c, t, p = ( F.relu( W["self_c"](c) + scatter_sum(W["t2c"](t), txn_cust, n_cust) ), F.relu( W["self_t"](t) + W["c2t"](c)[txn_cust] + W["p2t"](p)[txn_prod] ), F.relu( W["self_p"](p) + scatter_sum(W["t2p"](t), txn_prod, n_prod) ), ) return self.out(c).squeeze(1) class CompositeMP(nn.Module): """RelGNN-style: ONE step along the atomic route customer ← (transaction ⊕ product).""" def __init__(self, d=32): super().__init__() self.enc_c, self.enc_t, self.enc_p = ( nn.Linear(1, d), nn.Linear(1, d), nn.Linear(6, d), ) self.fuse = nn.Sequential( nn.Linear(2 * d, d), nn.ReLU(), nn.Linear(d, d) ) # FUSE(T, P) keeps the pairing self.self_c, self.out = nn.Linear(d, d), nn.Linear(d, 1) def forward(self): msg = self.fuse( torch.cat([self.enc_t(x_txn), self.enc_p(x_prod)[txn_prod]], 1) ) return self.out( F.relu( self.self_c(self.enc_c(x_cust)) + scatter_sum(msg, txn_cust, n_cust) ) ).squeeze(1) def fit_customer_model(model, epochs=300, seed=SEED): torch.manual_seed(seed) opt = torch.optim.Adam(model.parameters(), lr=0.01) t0 = time.time() for _ in range(epochs): opt.zero_grad() F.binary_cross_entropy_with_logits( model()[train_mask], y_cust[train_mask] ).backward() opt.step() with torch.no_grad(): acc = ( ((model() > 0).float()[~train_mask] == y_cust[~train_mask]) .float() .mean() .item() ) return { "test acc": acc, "parameters": sum(p.numel() for p in model.parameters()), "seconds": time.time() - t0, } mp_results = pd.DataFrame( [ { "model": "standard MP, 1 layer", **fit_customer_model( (torch.manual_seed(SEED), StandardHeteroMP(1))[1] ), }, { "model": "standard MP, 2 layers", **fit_customer_model( (torch.manual_seed(SEED), StandardHeteroMP(2))[1] ), }, { "model": "standard MP, 3 layers", **fit_customer_model( (torch.manual_seed(SEED), StandardHeteroMP(3))[1] ), }, { "model": "composite MP (RelGNN-style), 1 step", **fit_customer_model( (torch.manual_seed(SEED), CompositeMP())[1] ), }, ] ) mp_results.round(3)
_rev = reveal( · 20 lines
_rev = reveal( r""" Measured (one seed): **standard MP with 1 layer = 0.724**, exactly "always negative": it never reaches the categories. **2 layers: 0.964**, 3 layers: 0.944 (more layers, more redundancy and over-fitting). **Composite MP: 0.966 in one step with 4.6k parameters** instead of 15k, and roughly half the time. On RelBench the lecture reports up to 25% gains. Here the benefit shows up as efficiency: one hop of the *right* shape does the work of two generic hops. """, predict="The label needs customer → transaction → product. What does 1-layer standard message passing score? What do 2 layers, and one composite step, achieve?", ) _post = mo.md(r""" ## 4 · RelGT: a relational graph transformer RelGT samples a local context around each seed entity and represents **every token** by a 5-tuple: **(features, node type, hop distance, relative time, positional encoding)**. Here: the seed customer, its ≤ 11 transactions and their products → ≤ 23 tokens. Time is omitted (this toy database is static); as positional encoding we use a simple **local-structure code**: a transaction and *its* product share a one-hot slot id, so attention can tell which product belongs to which purchase. """) mo.vstack([_rev, _post])
MAX_TXN = 11 · 49 lines
MAX_TXN = 11 N_TOK = 1 + 2 * MAX_TXN TYPE_NAMES = ["customer", "transaction", "product"] def relgt_tokens(): """Per customer: token features (7 = amount | category one-hot | log price), type, hop, slot PE, padding mask.""" order = np.argsort(txn_table.customer_id.values, kind="stable") start = np.searchsorted( txn_table.customer_id.values[order], np.arange(n_cust + 1) ) feat = torch.zeros(n_cust, N_TOK, 7) typ = torch.zeros(n_cust, N_TOK, dtype=torch.long) hop = torch.zeros(n_cust, N_TOK, dtype=torch.long) pe = torch.zeros(n_cust, N_TOK, MAX_TXN) mask = torch.zeros(n_cust, N_TOK, dtype=torch.bool) for c in range(n_cust): feat[c, 0, 0] = 1.0 mask[c, 0] = True # seed token for j, ti in enumerate(order[start[c] : start[c + 1]][:MAX_TXN]): a, b = 1 + 2 * j, 2 + 2 * j feat[c, a, 0] = x_txn[ti, 0] typ[c, a] = 1 hop[c, a] = 1 feat[c, b, 1:] = x_prod[txn_prod[ti]] typ[c, b] = 2 hop[c, b] = 2 pe[c, a, j] = pe[c, b, j] = 1.0 # shared slot id mask[c, a] = mask[c, b] = True return feat, typ, hop, pe, mask tok_feat, tok_type, tok_hop, tok_pe, tok_mask = relgt_tokens() _c = 0 pd.DataFrame( [ { "token": i, "type": TYPE_NAMES[tok_type[_c, i]], "hop": int(tok_hop[_c, i]), "features (amount | category | log price)": [ round(v, 2) for v in tok_feat[_c, i].tolist() ], "slot PE": int(tok_pe[_c, i].argmax()) if tok_pe[_c, i].any() else "—", } for i in range(N_TOK) if tok_mask[_c, i] ] )
class RelGTLite(nn.Module): · 63 lines
class RelGTLite(nn.Module): def __init__(self, use_pe, d=32): super().__init__() self.use_pe = use_pe self.feat_enc = nn.Linear(7, d) self.type_emb = nn.Embedding(3, d) self.hop_emb = nn.Embedding(3, d) self.pe_enc = nn.Linear(MAX_TXN, d) self.encoder = nn.TransformerEncoder( nn.TransformerEncoderLayer( d, 4, 64, dropout=0.0, batch_first=True ), 2, enable_nested_tensor=False, ) self.out = nn.Linear(d, 1) def forward(self, idx): h = ( self.feat_enc(tok_feat[idx]) + self.type_emb(tok_type[idx]) + self.hop_emb(tok_hop[idx]) ) if self.use_pe: h = h + self.pe_enc(tok_pe[idx]) h = self.encoder(h, src_key_padding_mask=~tok_mask[idx]) return self.out(h[:, 0]).squeeze(1) # read out the seed token def fit_relgt(use_pe, epochs=60, seed=SEED): torch.manual_seed(seed) model = RelGTLite(use_pe) opt = torch.optim.Adam(model.parameters(), lr=3e-3) tr_idx, te_idx = ( train_mask.nonzero().squeeze(), (~train_mask).nonzero().squeeze(), ) t0 = time.time() for _ in range(epochs): model.train() for b in tr_idx[torch.randperm(len(tr_idx))].split(100): opt.zero_grad() F.binary_cross_entropy_with_logits( model(b), y_cust[b] ).backward() opt.step() model.eval() with torch.no_grad(): acc = ( ((model(te_idx) > 0).float() == y_cust[te_idx]) .float() .mean() .item() ) return { "test acc": acc, "parameters": sum(p.numel() for p in model.parameters()), "seconds": time.time() - t0, } relgt_run = mo.ui.run_button( label="Train RelGT-lite with / without the slot PE (≈ 10 s)" ) relgt_run
mo.stop( not relgt_run.value, mo.md("Press the button to train the two transformer variants."), ) relgt_results = pd.DataFrame( [ {"model": "RelGT-lite, no PE", **fit_relgt(False)}, {"model": "RelGT-lite + slot PE", **fit_relgt(True)}, ] ) pd.concat([mp_results, relgt_results], ignore_index=True).round(3)
_rev = reveal( · 33 lines
_rev = reveal( r""" Measured: both RelGT-lite variants reach **0.884**, better than "always negative" (0.724) and than 1-layer MP, but below 2-layer and composite MP (≈ 0.965), with 4× the parameters of the composite model. The slot PE made no difference at this data size (1,000 training customers, 60 epochs). The transformer could in principle use it, but has too little data to learn to. That matches the lecture's framing: RelGT's strength is **scale and transfer**. One token format for every table, every schema and every time point makes it the backbone of a pretrained relational foundation model (L14). On a single small task, a well-shaped message-passing route is hard to beat. """, predict="On this small task, will RelGT-lite beat composite message passing? Will the slot positional encoding matter?", ) _post = mo.md(r""" | | standard hetero MP | composite MP (RelGNN) | RelGT | |---|---|---|---| | unit of computation | one PK–FK hop | an atomic route (e.g. txn ⊕ product) | a token in a sampled context | | handles junction tables | needs 2 hops, redundancy | **1 step**, pairing preserved | via attention + PE | | best when | simple schemas | many-to-many / junction-heavy schemas | large, multi-schema, pretraining | --- ## 5 · Exercises **Exercise 1 (tokens).** How many RelGT tokens does a customer with 4 transactions get (seed + 1-hop + 2-hop)? → `ex1_tokens`. **Exercise 2 (hops).** In the PK–FK graph, how many hops separate a customer from a product they bought (standard MP), and how many composite steps (RelGNN)? → `ex2_hops = (standard, composite)`. **Exercise 3 (code: the composite message as SQL).** Write `spend_by_category()` returning an `n_cust × 5` tensor with each customer's total `amount` per product category, using only `txn_table`, `category`, `txn_cust`, `txn_prod` and tensor ops (no Python loop over transactions). This is the information a composite message must preserve. """) mo.vstack([_rev, _post])
ex1_tokens = None · 5 lines
ex1_tokens = None ex2_hops = None def spend_by_category(): return None
def _verdict(ok, answered): · 61 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_spend(): try: got = spend_by_category() if got is None: return _verdict(False, False) ref = ( txn_table.assign(cat=category[txn_table.product_id]) .pivot_table( index="customer_id", columns="cat", values="amount", aggfunc="sum", ) .reindex(index=range(n_cust), columns=range(5)) .fillna(0) .values ) return _verdict( np.allclose(np.asarray(got, dtype=float), ref, atol=1e-3), True ) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict(ex1_tokens == 9, ex1_tokens is not None), mo.md("**Exercise 2**"), _verdict( tuple(ex2_hops) == (2, 1) if ex2_hops is not None else False, ex2_hops is not None, ), mo.md("**Exercise 3**"), _check_spend(), mo.accordion( { "Solution 1": mo.md( "1 seed + 4 transactions + 4 products = **9** tokens." ), "Solution 2": mo.md( "Standard: customer → transaction → product = **2** hops. Composite: **1** step along the route customer ← (transaction ⊕ product)." ), "Solution 3": mo.md("""```python def spend_by_category(): amount = torch.tensor(txn_table.amount.values, dtype=torch.float) onehot = torch.eye(5)[torch.tensor(category)[txn_prod]] # category of each transaction's product return torch.zeros(n_cust, 5).index_add_(0, txn_cust, amount[:, None] * onehot) ``` `SUM(amount) GROUP BY customer, category` as one scatter: exactly a composite message (amount ⊗ category) summed per customer."""), } ), ] )
6 · Connecting to your research (SNA on X/Twitter)
-
Retweets and replies are junction tables: a
retweetrow links a user to a tweet, which links to its author and hashtags. "Which authors / hashtags does this user amplify, and how much?" is a composite route user ← (retweet ⊕ tweet ⊕ author): the RelGNN pattern. One composite step replaces 2–3 generic hops and keeps "retweet count × hashtag" paired. -
RelGT-style tokens (user, tweets, hashtags with type, hop and relative time) are a natural input format if you later pretrain one model across several of your datasets (USA students, usa-active, …).
Summary
- Junction tables cause imbalance (the informative node is 2 hops away) and redundancy (self-information bounces back).
- Composite MP along atomic routes: one step, 0.966 accuracy with 4.6k params vs. 2-layer standard MP 0.964 with 15k; 1-layer standard MP fails (0.724).
- RelGT tokens = (features, type, hop, time, PE); on this small task 0.884. Its value is generality, scale and pretraining.
Next — L14 · Advanced topics: in-context learning on graphs (PRODIGY), the Relational Transformer, conformal prediction.