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/L12_relational_deep_learning.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 duckdb 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.data import HeteroData from sklearn.ensemble import HistGradientBoostingClassifier from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score from sklearn.model_selection import cross_val_predict 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, } )
L12 · Relational deep learning
CS224W Machine Learning with Graphs · hands-on companion to Lecture 12
Audience: you did L09 (heterogeneous graphs) and know basic SQL.
Prerequisites: SELECT … JOIN … GROUP BY; gradient-boosted trees at a "have used them" level.
By the end you can:
- turn a relational database into a relational entity graph (rows = nodes, primary–foreign keys = edges);
- define a predictive task with a training table of (entity, timestamp, label) and respect time to avoid leakage;
- show that a GNN layer is a learnable SQL JOIN + GROUP BY (the lecture's SALES example, run in DuckDB);
- compare hand-engineered SQL features + gradient boosting with an end-to-end temporal relational GNN on a churn task.
In short. Business data lives in many linked tables. Today's practice flattens them with hand-written aggregate features and runs XGBoost, which means months of feature work, lost information and a risk of time leakage. RDL skips the flattening: rows become nodes, PK–FK links become edges, a training table attaches (entity, time, label), and a heterogeneous, time-aware GNN learns the joins and aggregations end to end.
Outline
- A small e-commerce database — users, products, transactions (synthetic, with a planted signal).
- A database is a graph — the relational entity graph as
HeteroData. - The training table — churn labels at several cutoff times; temporal split.
- GNN layer = SQL JOIN + GROUP BY — the lecture's SALES example in DuckDB.
- Baseline: hand-written SQL features + gradient boosting. RDL: a temporal relational GNN.
- Leakage — what happens when features peek past the cutoff.
- Exercises, research links.
1 · A small e-commerce database (synthetic)
Generated here, so we know the truth: 1,500 users, 300 products, a year of purchases. Each user buys at their own rate; some stop for good (churn). The planted signal: 20% of products are secretly bad, and buying one raises the chance of churning. "Bad" is not a column anywhere; it can only be inferred from what happened to other buyers of the same product.
def make_database(n_users=1500, n_products=300, days=365, seed=SEED): · 68 lines
def make_database(n_users=1500, n_products=300, days=365, seed=SEED): r = np.random.default_rng(seed) categories = np.array( ["electronics", "books", "grocery", "fashion", "toys"] ) products = pd.DataFrame( { "product_id": np.arange(n_products), "category": r.choice(categories, n_products), "price": np.round(np.exp(r.normal(3, 0.8, n_products)), 2), } ) bad = r.random(n_products) < 0.2 # hidden quality — NOT stored users = pd.DataFrame( { "user_id": np.arange(n_users), "signup_day": r.integers(0, 200, n_users), "age": r.integers(18, 70, n_users), } ) rate = r.gamma(2.0, 0.06, n_users) # purchases per day pref = r.dirichlet(np.ones(5) * 0.5, n_users) # category preferences by_cat = { c: products.index[products.category == c].to_numpy() for c in categories } rows = [] for u in range(n_users): hazard = 0.002 # daily churn hazard for day in range(users.signup_day[u], days): if r.random() < hazard: break # churned: no more purchases for _ in range(r.poisson(rate[u])): p = int( r.choice(by_cat[categories[r.choice(5, p=pref[u])]]) ) rows.append( ( len(rows), u, p, day, round(products.price[p] * r.uniform(0.8, 1.2), 2), ) ) hazard += ( 0.03 if bad[p] else -0.0005 ) # bad products drive churn hazard = max(hazard, 0.0005) transactions = pd.DataFrame( rows, columns=["txn_id", "user_id", "product_id", "day", "amount"] ) return users, products, transactions, bad users, products, transactions, hidden_bad = ( make_database() ) # hidden_bad: ground truth, never shown to models mo.vstack( [ mo.md( f"**users** {len(users):,} rows · **products** {len(products):,} rows · **transactions** {len(transactions):,} rows" ), mo.hstack( [users.head(3), products.head(3), transactions.head(3)], justify="start", ), ] )
2 · A database is a graph
Every row becomes a node of its table's type; every primary–foreign key match becomes an edge:
transactions.user_id → users.user_id and transactions.product_id → products.product_id. Transactions are nodes too
(with amount and timestamp), not edge attributes. That is what lets a GNN reason about individual purchases.
def database_to_graph(): data = HeteroData() data["user"].x = torch.tensor( users[["age"]].values / 70.0, dtype=torch.float ) data["product"].x = torch.tensor( np.c_[ pd.get_dummies(products.category).values, np.log(products.price.values), ], dtype=torch.float, ) data["transaction"].x = torch.tensor( np.log1p(transactions[["amount"]].values), dtype=torch.float ) data["transaction"].time = torch.tensor(transactions.day.values) t = torch.tensor(transactions.txn_id.values) data["transaction", "made_by", "user"].edge_index = torch.stack( [t, torch.tensor(transactions.user_id.values)] ) data["transaction", "of", "product"].edge_index = torch.stack( [t, torch.tensor(transactions.product_id.values)] ) return data db_graph = database_to_graph() print(db_graph)
3 · The training table
A predictive task = a table of (entity, cutoff time, label). Churn: for user \(u\) active at cutoff \(t\) (bought something in the 90 days before \(t\)), label = 1 if they buy nothing in \([t, t+30)\). Several cutoffs give many examples; the split is temporal: train on early cutoffs, validate and test on later ones. Everything a model sees for example \((u,t)\) must happen before \(t\).
CUTOFFS = {"train": [120, 150, 180, 210, 240], "val": [270], "test": [300]} def training_table(): rows = [] for split, ts in CUTOFFS.items(): for t in ts: before = transactions[ (transactions.day < t) & (transactions.day >= t - 90) ] after = transactions[ (transactions.day >= t) & (transactions.day < t + 30) ] active = np.sort(before.user_id.unique()) churned = ~np.isin(active, after.user_id.unique()) rows.append( pd.DataFrame( { "user_id": active, "cutoff": t, "label": churned.astype(int), "split": split, } ) ) return pd.concat(rows, ignore_index=True) train_table = training_table() train_table.groupby(["split", "cutoff"]).label.agg( examples="size", churn_rate="mean" ).round(3)
4 · A GNN layer is a learnable SQL JOIN + GROUP BY
Lecture example: SALES rows (user, value, date). "Spend after 01-01 per user" in SQL, run here in DuckDB:
sales = pd.DataFrame( { "user_id": [1, 1, 2, 3, 3], "value": [20, 80, 15, 20, 80], "date": ["01-01", "01-02", "01-01", "01-02", "01-03"], } ) _sql = duckdb.sql(""" SELECT u.user_id, COALESCE(SUM(s.value), 0) AS spend_after_0101 FROM (SELECT DISTINCT user_id FROM sales) u LEFT JOIN sales s ON s.user_id = u.user_id AND s.date > '01-01' GROUP BY u.user_id ORDER BY u.user_id """).df() # the same thing as one round of message passing: sale → user, message = value · gate(time), aggregation = SUM _gate = torch.tensor( [0.0 if d == "01-01" else 1.0 for d in sales.date] ) # a "learned" temporal encoding _msg = torch.tensor(sales.value.values, dtype=torch.float) * _gate _agg = torch.zeros(4).index_add_( 0, torch.tensor(sales.user_id.values), _msg )[1:] _sql.assign(gnn_sum_aggregation=_agg.numpy())
SQL and a hand-gated sum aggregation agree (80, 0, 100). A GNN layer learns the gate (a temporal encoding) and the message (a function of the row), and can stack such joins over several hops.
5 · Baseline: hand-written SQL features + gradient boosting
What a data scientist would write first: for each (user, cutoff), aggregates over the user's transactions before the cutoff: counts and spend in the last 30 / 90 days, all-time count, days since last purchase, number of categories, tenure, age.
def sql_features(table, leak=False): · 47 lines
def sql_features(table, leak=False): """One row of hand-made features per (user, cutoff). leak=True deliberately ignores the cutoff (see §6).""" time_filter = "TRUE" if leak else "t.day < tt.cutoff" return duckdb.sql(f""" SELECT tt.user_id, tt.cutoff, COUNT(t.txn_id) FILTER (WHERE t.day >= tt.cutoff - 30) AS n_30d, COUNT(t.txn_id) FILTER (WHERE t.day >= tt.cutoff - 90) AS n_90d, COUNT(t.txn_id) AS n_all, COALESCE(SUM(t.amount) FILTER (WHERE t.day >= tt.cutoff - 30), 0) AS spend_30d, COALESCE(SUM(t.amount) FILTER (WHERE t.day >= tt.cutoff - 90), 0) AS spend_90d, tt.cutoff - MAX(t.day) AS days_since_last, COUNT(DISTINCT p.category) AS n_categories, tt.cutoff - ANY_VALUE(u.signup_day) AS tenure, ANY_VALUE(u.age) AS age FROM train_table tt JOIN users u ON u.user_id = tt.user_id LEFT JOIN transactions t ON t.user_id = tt.user_id AND {time_filter} LEFT JOIN products p ON p.product_id = t.product_id GROUP BY tt.user_id, tt.cutoff """).df() def fit_gbm(feature_frame): df = train_table.merge(feature_frame, on=["user_id", "cutoff"]) cols = [ c for c in feature_frame.columns if c not in ("user_id", "cutoff") ] tr, te = df[df.split == "train"], df[df.split == "test"] clf = HistGradientBoostingClassifier( max_iter=200, learning_rate=0.05, random_state=SEED ).fit(tr[cols], tr.label) return ( roc_auc_score(te.label, clf.predict_proba(te[cols])[:, 1]), clf, cols, ) _t = time.time() hand_features = sql_features(train_table) gbm_auc, _, _ = fit_gbm(hand_features) mo.vstack( [ hand_features.head(4), mo.md( f"Gradient boosting on 9 SQL features: **test AUC = {gbm_auc:.3f}** (features + training: {time.time() - _t:.1f} s)" ), ] )
A solid baseline: AUC 0.84 from nine obvious features. Recency and frequency explain most churn.
RDL: a temporal relational GNN
For each training example \((u,t)\) the GNN sees the 2-hop relational neighbourhood as of time \(t\): user ← their transactions before \(t\) ← the products bought. Each transaction sends a learned message built from its row (amount), a temporal encoding of how long ago it happened, and its product (category, price and a learned product embedding). The user aggregates the messages with sum and mean and combines them with its own row. This is the lecture's "PyTorch-Frame encoders → heterogeneous GraphSAGE → MLP head", in miniature.
def temporal_neighbourhoods(table): · 134 lines
def temporal_neighbourhoods(table): """For every example (user, cutoff): the user's transactions strictly before the cutoff (temporal sampling).""" by_user = {u: g for u, g in transactions.groupby("user_id")} ex_idx, txn_idx = [], [] for i, (u, t) in enumerate(zip(table.user_id, table.cutoff)): g = by_user.get(u) if g is not None: keep = g.txn_id.values[ g.day.values < t ] # no peeking past the cutoff ex_idx.append(np.full(len(keep), i)) txn_idx.append(keep) return torch.from_numpy(np.concatenate(ex_idx)), torch.from_numpy( np.concatenate(txn_idx) ) nbr_example, nbr_txn = temporal_neighbourhoods(train_table) _txn_day = torch.tensor(transactions.day.values) _delta = ( torch.tensor(train_table.cutoff.values)[nbr_example] - _txn_day[nbr_txn] ).float() # days before cutoff txn_features = torch.stack( [ db_graph["transaction"].x[nbr_txn, 0], _delta / 90, torch.exp(-_delta / 30), ], 1, ) txn_product = torch.tensor(transactions.product_id.values)[nbr_txn] user_features = torch.tensor( np.c_[ users.age.values[train_table.user_id] / 70, ( train_table.cutoff.values - users.signup_day.values[train_table.user_id] ) / 365, ], dtype=torch.float, ) labels = torch.tensor(train_table.label.values, dtype=torch.float) split_mask = { s: torch.tensor((train_table.split == s).values) for s in ("train", "val", "test") } class RelationalGNN(nn.Module): def __init__(self, d=32, product_embedding=True): super().__init__() self.prod_emb = ( nn.Embedding(len(products), 16) if product_embedding else None ) d_msg_in = ( 3 + db_graph["product"].x.size(1) + (16 if product_embedding else 0) ) self.msg = nn.Sequential( nn.Linear(d_msg_in, d), nn.ReLU(), nn.Linear(d, d) ) # learnable "JOIN" message self.head = nn.Sequential( nn.Linear(2 * d + 2 + 1, d), nn.ReLU(), nn.Linear(d, 1) ) def forward(self): parts = [txn_features, db_graph["product"].x[txn_product]] if self.prod_emb is not None: parts.append(self.prod_emb(txn_product)) m = self.msg(torch.cat(parts, 1)) n_ex = len(labels) s = torch.zeros(n_ex, m.size(1)).index_add_( 0, nbr_example, m ) # "GROUP BY user": SUM cnt = ( torch.bincount(nbr_example, minlength=n_ex) .float() .unsqueeze(1) ) h = torch.cat( [s, s / cnt.clamp(min=1), user_features, torch.log1p(cnt)], 1 ) # + MEAN, own row, count return self.head(h).squeeze(1) def fit_rdl(product_embedding=True, epochs=300, seed=SEED): torch.manual_seed(seed) model = RelationalGNN(product_embedding=product_embedding) opt = torch.optim.Adam(model.parameters(), lr=0.005, weight_decay=1e-4) best_val, best_test = 0, 0 for _ in range(epochs): model.train() opt.zero_grad() out = model() F.binary_cross_entropy_with_logits( out[split_mask["train"]], labels[split_mask["train"]] ).backward() opt.step() model.eval() with torch.no_grad(): p = model() val = roc_auc_score( labels[split_mask["val"]], p[split_mask["val"]] ) if val > best_val: best_val, best_test = ( val, roc_auc_score( labels[split_mask["test"]], p[split_mask["test"]] ), ) return best_test, model _t = time.time() rdl_auc, rdl_model = fit_rdl(True) rdl_auc_noemb, _ = fit_rdl(False) pd.DataFrame( [ { "model": "gradient boosting on 9 SQL features", "test AUC": gbm_auc, }, { "model": "relational GNN, product features only", "test AUC": rdl_auc_noemb, }, { "model": "relational GNN + learned product embeddings", "test AUC": rdl_auc, }, ] ).round(3).assign( note=["", "", f"both GNNs trained in {time.time() - _t:.0f} s"] )
_rev = reveal( · 12 lines
_rev = reveal( r""" Measured (one seed): **gradient boosting 0.840 · GNN with product features 0.851 · GNN + learned product embeddings 0.873.** The extra 2 points come from the product embeddings, which can only be learned from *other users' outcomes*: a multi-hop signal (user → transaction → product ← other transactions ← other users' churn) that none of the nine SQL features express. """, predict="Can a GNN beat gradient boosting on nine hand-written SQL features? What signal could it use that the SQL features cannot express?", ) _post = mo.md(r""" Did the embeddings really discover the hidden "bad" products? We know the truth, so we can check: """) mo.vstack([_rev, _post])
_emb = rdl_model.prod_emb.weight.detach().numpy() _bought = np.bincount(transactions.product_id, minlength=len(products)) > 0 _pred = cross_val_predict( LogisticRegression(max_iter=2000), _emb[_bought], hidden_bad[_bought], cv=5, method="predict_proba", )[:, 1] mo.md( f"Logistic regression from the 16-d learned product embedding to the **hidden** bad/good flag (5-fold CV, " f"{_bought.sum()} products): AUC = **{roc_auc_score(hidden_bad[_bought], _pred):.3f}** (0.5 = no information). " "The model was never told which products are bad; it inferred it from what happened to their buyers." )
6 · Leakage: features that peek past the cutoff
The single most common bug in this setting: computing features from the whole transactions table instead of only rows
before each example's cutoff. Same SQL, same model, one WHERE clause removed:
_leaky_auc, _, _ = fit_gbm(sql_features(train_table, leak=True)) mo.callout( mo.md( f"Leaky features: test AUC **{_leaky_auc:.3f}** vs. **{gbm_auc:.3f}** with the time filter. " "`days_since_last` and `n_30d` now see purchases made *after* the cutoff, i.e. the label itself. " "**Fix:** every feature and every sampled neighbour must satisfy `time < cutoff`. RelBench's loaders enforce it, and " "`temporal_neighbourhoods` above does it explicitly." ), kind="danger", )
The probe reaches AUC ≈ 0.79: the learned embeddings separate bad from good products well above chance, although nobody labelled them. With leakage, the same gradient-boosting model jumps to ≈ 0.99, a number that should always trigger suspicion in a churn task.
7 · Exercises
Exercise 1 (temporal sampling). How many of user 5's transactions may the model see for the example (user 5, cutoff 200)?
→ ex1_visible.
Exercise 2 (hops). In the relational entity graph, how many hops from a user node does a message travel for the
feature "total spend on products that churned users also bought"? → ex2_hops.
Exercise 3 (SQL with time). Write distinct_products_60d() returning a DataFrame with columns user_id, cutoff,
n_products_60d: the number of distinct products each training-table user bought in the 60 days before the cutoff
(0 if none). Use DuckDB on train_table and transactions.
ex1_visible = None · 5 lines
ex1_visible = None ex2_hops = None def distinct_products_60d(): return None
def _verdict(ok, answered): · 72 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_sql(): try: got = distinct_products_60d() if got is None: return _verdict(False, False) ref = [] for u, t in zip(train_table.user_id, train_table.cutoff): g = transactions[ (transactions.user_id == u) & (transactions.day < t) & (transactions.day >= t - 60) ] ref.append((u, t, g.product_id.nunique())) ref = pd.DataFrame( ref, columns=["user_id", "cutoff", "n_products_60d"] ).drop_duplicates() m = ref.merge( got, on=["user_id", "cutoff"], how="left", suffixes=("", "_got"), ).fillna(-1) return _verdict( bool((m.n_products_60d == m.n_products_60d_got).all()), True ) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") _SQL_SOLUTION = """```python def distinct_products_60d(): return duckdb.sql(''' SELECT tt.user_id, tt.cutoff, COUNT(DISTINCT t.product_id) AS n_products_60d FROM train_table tt LEFT JOIN transactions t ON t.user_id = tt.user_id AND t.day < tt.cutoff AND t.day >= tt.cutoff - 60 GROUP BY tt.user_id, tt.cutoff ''').df() ``` The time condition belongs in the `JOIN … ON`, not in a `WHERE`: a `WHERE` would drop users with no recent purchase instead of giving them 0.""" _visible = int( ((transactions.user_id == 5) & (transactions.day < 200)).sum() ) mo.vstack( [ mo.md("**Exercise 1**"), _verdict(ex1_visible == _visible, ex1_visible is not None), mo.md("**Exercise 2**"), _verdict(ex2_hops == 4, ex2_hops is not None), mo.md("**Exercise 3**"), _check_sql(), mo.accordion( { "Solution 1": mo.md( "`int(((transactions.user_id == 5) & (transactions.day < 200)).sum())`: strictly before the cutoff." ), "Solution 2": mo.md( "user → transaction → product → (other) transaction → (other) user: **4 hops**. A 2-hop GNN cannot see other users' labels directly; the learned product embedding in §5 is a shortcut, since it absorbs that signal during training." ), "Solution 3": mo.md(_SQL_SOLUTION), } ), ] )
8 · Connecting to your research (SNA on X/Twitter)
-
Your PostgreSQL tweet database is a relational database: users, tweets, retweets, replies, hashtags linked by keys. RDL lets you predict e.g. "will this account go inactive in 30 days?" or "will this user retweet camp-B content next month?" directly from the tables, with cutoff-based training tables instead of hand-built per-user aggregates.
-
The leakage rule is critical with tweet data: engagement counts (likes, retweets) are measured after posting. Only use values as they were at the cutoff.
-
The
stable_hash+ cache pattern you use inusa-active/fits RDL well: cache the temporal neighbourhoods per cutoff.
Summary
- Rows → nodes, PK–FK → edges; a training table (entity, time, label) defines the task; split by time.
- A GNN layer = learnable JOIN + GROUP BY (DuckDB and sum aggregation agree: 80, 0, 100).
- Churn: SQL features + GBM 0.840; relational GNN 0.851; + learned product embeddings 0.873, which recovered hidden product quality (probe AUC 0.79).
- Time leakage turns 0.84 into a fake 0.99.
Next — L13 · Advanced architectures in RDL: RelGNN (composite message passing) and RelGT.