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/L11_recommender_systems.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.*") warnings.filterwarnings("ignore", message=".*Sparse invariant checks.*") 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 from torch_geometric.nn import LightGCN 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, } )
L11 · GNNs for recommender systems
CS224W Machine Learning with Graphs · hands-on companion to Lecture 11
Audience: you did L02 (shallow embeddings) and L05 (link prediction, splits). Prerequisites: logistic loss; the idea of top-\(K\) retrieval.
By the end you can:
- frame recommendation as link prediction on a user–item bipartite graph and evaluate it with Recall@K on a per-user split;
- compute the binary vs. BPR loss by hand (the lecture's worked example) and explain why BPR fits the metric better;
-
train matrix factorisation and LightGCN (from scratch, then checked against PyG's
LightGCN) on MovieLens and compare with a popularity baseline; -
sample hard negatives PinSage-style and see what they change.
In short. Recommend \(K\) items per user = rank all user–item pairs by a score and take the top \(K\) the user has not seen. Recall@K isn't differentiable, so train a surrogate: BPR asks only that, per user, positives outrank negatives. Embeddings work because of collaborative filtering. LightGCN drops a GNN's weights and non-linearities: diffuse the embeddings with the normalised bipartite adjacency and average the layers. It is simpler and better.
Outline
- Data — MovieLens (100k ratings) as an implicit-feedback bipartite graph; a per-user temporal split.
- Recall@K and a popularity baseline.
- Surrogate losses — binary vs. BPR, the lecture's worked example.
- Matrix factorisation vs. LightGCN — from scratch, trained with BPR; LightGCN checked against PyG.
- Hard negatives (PinSage idea).
- Exercises, pitfall, research links.
1 · Data: MovieLens as a bipartite graph
ml-latest-small (GroupLens; downloaded to data/ml-latest-small/): 100,836 ratings by 610 users of 9,742 movies.
Implicit feedback: an edge user→movie if the rating is ≥ 4. For each user, the latest 20% of their positive
interactions are the test set (we predict the future, not random holes).
def _ensure_movielens(): · 77 lines
def _ensure_movielens(): """Download and unzip ml-latest-small into data/ if it is not there yet (≈ 1 MB).""" folder = DATA_DIR / "ml-latest-small" if (folder / "ratings.csv").exists(): return True import io import urllib.request import zipfile url = "https://files.grouplens.org/datasets/movielens/ml-latest-small.zip" try: with urllib.request.urlopen(url, timeout=60) as resp: zipfile.ZipFile(io.BytesIO(resp.read())).extractall(DATA_DIR) return (folder / "ratings.csv").exists() except Exception: return False mo.stop( not _ensure_movielens(), mo.callout( mo.md( "MovieLens `ml-latest-small` is missing and could not be downloaded. Get it from " "<https://grouplens.org/datasets/movielens/latest/> and unzip it into `data/` " "(you should have `data/ml-latest-small/ratings.csv`). See `data/README.md`." ), kind="warn", ), ) _ratings = pd.read_csv(DATA_DIR / "ml-latest-small" / "ratings.csv") movies = pd.read_csv( DATA_DIR / "ml-latest-small" / "movies.csv" ).set_index("movieId") _pos = _ratings[_ratings.rating >= 4].sort_values(["userId", "timestamp"]) _pos = _pos[ _pos.groupby("userId").movieId.transform("size") >= 10 ] # users with ≥ 10 positives _rank = _pos.groupby("userId").cumcount() _n = _pos.groupby("userId").movieId.transform("size") _pos = _pos.assign(is_test=_rank >= (0.8 * _n).astype(int)) _train_items = _pos[~_pos.is_test].movieId.unique() _pos = _pos[ _pos.movieId.isin(_train_items) ] # can only recommend items seen in training user_ids = {u: i for i, u in enumerate(sorted(_pos.userId.unique()))} item_ids = {m: i for i, m in enumerate(sorted(_train_items))} item_movie = np.array(sorted(_train_items)) n_users, n_items = len(user_ids), len(item_ids) train_pairs = np.stack( [ _pos[~_pos.is_test].userId.map(user_ids), _pos[~_pos.is_test].movieId.map(item_ids), ], 1, ) test_pairs = np.stack( [ _pos[_pos.is_test].userId.map(user_ids), _pos[_pos.is_test].movieId.map(item_ids), ], 1, ) train_by_user = ( pd.Series(train_pairs[:, 1]) .groupby(train_pairs[:, 0]) .apply(set) .to_dict() ) test_by_user = ( pd.Series(test_pairs[:, 1]) .groupby(test_pairs[:, 0]) .apply(set) .to_dict() ) mo.md( f"**{n_users} users × {n_items:,} items**; train edges {len(train_pairs):,}, test edges {len(test_pairs):,}; " f"density {len(train_pairs) / (n_users * n_items):.4f}." )
def recall_at_k(scores, k=20): """scores: [n_users, n_items] tensor. Train items are excluded; Recall@K = |top-K ∩ test| / |test|, averaged over users.""" scores = scores.clone() for u, items in train_by_user.items(): scores[u, list(items)] = -float("inf") top = scores.topk(k, dim=1).indices.numpy() return float( np.mean( [ len(set(top[u]) & test_by_user[u]) / len(test_by_user[u]) for u in test_by_user ] ) ) _popularity = torch.tensor( np.bincount(train_pairs[:, 1], minlength=n_items), dtype=torch.float ) popularity_scores = _popularity.repeat(n_users, 1) baselines = { "random": recall_at_k(torch.rand(n_users, n_items)), "popularity (most-watched first)": recall_at_k(popularity_scores), } mo.md( "## 2 · Recall@K and baselines\n\n$\\text{Recall@}K(u)=|P_u\\cap R_u|/|P_u|$ with $P_u$ the user's future positives and $R_u$ the top-$K$ " "recommended unseen items.\n\n" + "\n".join( f"- **{k}**: Recall@20 = {v:.4f}" for k, v in baselines.items() ) )
Random ranking gets Recall@20 ≈ 0.004; "recommend the most popular movies" already gets ≈ 0.082, 20× better. Popularity is the baseline every recommender must beat; with temporal splits it is surprisingly strong.
3 · Surrogate losses: binary vs. BPR
Lecture example: scores \(s(u_0,v_0)=1\) (positive), \(s(u_0,v_1)=-1\), \(s(u_1,v_0)=2\), \(s(u_1,v_1)=4\) (positive). Binary loss treats every pair independently: \(-\log\sigma(s^+)-\log(1-\sigma(s^-))\). BPR compares, per user, a positive with a negative: \(-\log\sigma(s(u,v^+)-s(u,v^-))\).
_s = { ("u0", "v0"): 1.0, ("u0", "v1"): -1.0, ("u1", "v0"): 2.0, ("u1", "v1"): 4.0, } _positives = {("u0", "v0"), ("u1", "v1")} _sig = lambda x: 1 / (1 + np.exp(-x)) _binary = { k: (-np.log(_sig(v)) if k in _positives else -np.log(1 - _sig(v))) for k, v in _s.items() } _bpr = { "u0": -np.log(_sig(_s[("u0", "v0")] - _s[("u0", "v1")])), "u1": -np.log(_sig(_s[("u1", "v1")] - _s[("u1", "v0")])), } pd.DataFrame( [{"term": f"binary {k}", "loss": v} for k, v in _binary.items()] + [{"term": f"BPR {u}", "loss": v} for u, v in _bpr.items()] ).round(3)
Both users already rank their positive first, which is all Recall@K cares about. BPR agrees: each user's loss is only 0.127. The binary loss charges 2.127 for \((u_1,v_0)\), because score 2 is "too high in absolute terms", even though \(u_1\) ranks it below the true positive and nobody else's ranking is affected. BPR optimises the personalised ordering the metric measures.
4 · Matrix factorisation vs. LightGCN
- MF: \(s(u,v)=\mathbf e_u^\top\mathbf e_v\) with free embeddings (L02-style shallow encoder).
- LightGCN: same embedding table, but before scoring diffuse it over the bipartite graph with \(\tilde A=D^{-1/2}AD^{-1/2}\) (\(A\) = user–item adjacency, no self-loops, no weights, no non-linearity) and average the layers: $\(E^{(k+1)}=\tilde A E^{(k)},\qquad E_{\text{final}}=\tfrac{1}{K+1}\sum_{k=0}^{K}E^{(k)}.\)$ Two users who liked the same items get similar final embeddings even if their own rows were trained little: high-order collaborative filtering.
def bipartite_norm_adj(): · 95 lines
def bipartite_norm_adj(): """Sparse D^-1/2 A D^-1/2 over n_users + n_items nodes (items offset by n_users).""" u, i = ( torch.from_numpy(train_pairs[:, 0]), torch.from_numpy(train_pairs[:, 1]) + n_users, ) row, col = torch.cat([u, i]), torch.cat([i, u]) deg = torch.bincount(row, minlength=n_users + n_items).float() val = deg[row].pow(-0.5) * deg[col].pow(-0.5) return torch.sparse_coo_tensor( torch.stack([row, col]), val, (n_users + n_items,) * 2 ).coalesce() A_norm = bipartite_norm_adj() class Recommender(nn.Module): def __init__(self, n_layers=0, dim=64): super().__init__() self.emb = nn.Embedding(n_users + n_items, dim) nn.init.normal_(self.emb.weight, std=0.1) self.n_layers = n_layers # 0 layers = plain matrix factorisation def final_embeddings(self): e = self.emb.weight layers = [e] for _ in range(self.n_layers): e = torch.sparse.mm(A_norm, e) layers.append(e) return torch.stack(layers).mean(0) def all_scores(self): e = self.final_embeddings() return e[:n_users] @ e[n_users:].T def sample_bpr_batch(r, size): idx = r.integers(len(train_pairs), size=size) users, pos = train_pairs[idx, 0], train_pairs[idx, 1] neg = r.integers( n_items, size=size ) # uniform negatives (rarely a positive by accident) return ( torch.from_numpy(users), torch.from_numpy(pos), torch.from_numpy(neg), ) def train_recommender( n_layers, epochs=40, batch=2048, lr=0.005, reg=1e-4, seed=SEED, neg_sampler=None, ): torch.manual_seed(seed) r = np.random.default_rng(seed) model = Recommender(n_layers) opt = torch.optim.Adam(model.parameters(), lr=lr) history = [] for ep in range(epochs): for _ in range(len(train_pairs) // batch): u, p, n = (neg_sampler or sample_bpr_batch)(r, batch) e = model.final_embeddings() eu, ep_, en = e[u], e[n_users + p], e[n_users + n] loss = -F.logsigmoid( (eu * ep_).sum(1) - (eu * en).sum(1) ).mean() # BPR loss = ( loss + reg * ( model.emb.weight[u].pow(2).sum() + model.emb.weight[n_users + p].pow(2).sum() + model.emb.weight[n_users + n].pow(2).sum() ) / batch ) # L2 on used rows opt.zero_grad() loss.backward() opt.step() if (ep + 1) % 10 == 0: with torch.no_grad(): history.append( { "epoch": ep + 1, "Recall@20": recall_at_k(model.all_scores()), } ) return model, pd.DataFrame(history) rec_run = mo.ui.run_button( label="Train MF and LightGCN (K = 1, 2, 3) (≈ 1–2 min)" ) rec_run
mo.stop( not rec_run.value, mo.md( "Press the button to train four recommenders with BPR (40 epochs each)." ), ) rec_models, _curves = {}, {} for _name, _k in [ ("MF (0 layers)", 0), ("LightGCN, K=1", 1), ("LightGCN, K=2", 2), ("LightGCN, K=3", 3), ]: rec_models[_name], _curves[_name] = train_recommender(_k) _fig, _ax = plt.subplots(figsize=(6, 2.8)) for _name, _h in _curves.items(): _ax.plot(_h.epoch, _h["Recall@20"], "o-", label=_name) _ax.axhline( baselines["popularity (most-watched first)"], color="grey", ls=":", label="popularity", ) _ax.set_xlabel("epoch") _ax.set_ylabel("Recall@20") _ax.legend(fontsize=7) rec_summary = pd.DataFrame( { "model": list(_curves), "Recall@20 (final)": [ h["Recall@20"].iloc[-1] for h in _curves.values() ], } ) mo.hstack([_fig, rec_summary.round(4)], widths=[3, 2])
_rev = reveal( · 13 lines
_rev = reveal( r""" Measured (Recall@20 after 40 epochs, one seed): **MF 0.110 · LightGCN K=1 0.124 · K=2 0.128 · K=3 0.122** vs. popularity 0.082. Propagating over the graph adds ≈ 15% relative recall over plain MF, with **zero extra parameters**: the only parameters are the embedding table in both. Two layers are the sweet spot here; a third starts to over-smooth. """, predict="Will LightGCN beat plain matrix factorisation on Recall@20 with zero extra parameters? Is K = 3 better than K = 2?", ) _post = mo.md(r""" **Check against PyG.** PyG's `LightGCN` implements the same propagation. Copy our trained table into it and compare final embeddings: """) mo.vstack([_rev, _post])
_ours = rec_models["LightGCN, K=2"] _pyg = LightGCN( num_nodes=n_users + n_items, embedding_dim=64, num_layers=2 ) with torch.no_grad(): _pyg.embedding.weight.copy_(_ours.emb.weight) _ei = A_norm.indices() # both directions of every user–item edge _gap = ( (_pyg.get_embedding(_ei) - _ours.final_embeddings()) .abs() .max() .item() ) mo.md( f"max |ours − PyG `LightGCN.get_embedding`| = **{_gap:.2e}**. In practice you would use `LightGCN`, " "its `recommendation_loss` (BPR + L2) and `recommend(..., k=20)` directly." )
What does it recommend? Pick a user: their most recent training likes, the top-5 recommendations, and whether each was in their (future) test set.
rec_user = mo.ui.slider( 0, n_users - 1, value=0, label="user index", show_value=True ) rec_user
_u = rec_user.value with torch.no_grad(): _scores = rec_models["LightGCN, K=2"].all_scores()[_u] _scores[list(train_by_user[_u])] = -float("inf") _top = _scores.topk(5).indices.tolist() _liked = [i for _, i in train_pairs[train_pairs[:, 0] == _u]][-5:] mo.hstack( [ mo.md( "**recent likes (train)**\n\n" + "\n".join( f"- {movies.loc[item_movie[i], 'title']}" for i in _liked ) ), mo.md( "**top-5 recommendations**\n\n" + "\n".join( f"- {movies.loc[item_movie[i], 'title']} {'✅ (watched & liked later)' if i in test_by_user.get(_u, set()) else ''}" for i in _top ) ), ], widths=[1, 1], )
5 · Hard negatives (PinSage)
Uniform random negatives are mostly easy (obscure movies nobody would confuse with a favourite), so the loss quickly stops teaching anything. PinSage adds hard negatives: items that rank fairly high for the user but are not positives (they used ranks 2,000–5,000 out of billions), and phases them in gradually (curriculum). Here: negatives drawn half uniformly and half from the items a trained teacher model ranks 50–300 for that user.
def make_hard_sampler(teacher, lo=50, hi=300, frac_hard=0.5): with torch.no_grad(): s = teacher.all_scores() for u, items in train_by_user.items(): s[u, list(items)] = -float("inf") band = ( s.topk(hi, dim=1).indices[:, lo:].numpy() ) # per user: items ranked lo..hi by the teacher def sampler(r, size): u, p, n = sample_bpr_batch(r, size) hard = r.random(size) < frac_hard n = n.clone() n[torch.from_numpy(hard)] = torch.from_numpy( band[ u.numpy()[hard], r.integers(hi - lo, size=int(hard.sum())) ] ) return u, p, n return sampler hard_run = mo.ui.run_button( label="Train LightGCN (K=2) with hard negatives (≈ 30 s)" ) hard_run
mo.stop( not hard_run.value, mo.md( "Press the button (uses the LightGCN K=2 from §4 as the teacher)." ), ) _model, _hist = train_recommender( 2, neg_sampler=make_hard_sampler(rec_models["LightGCN, K=2"]) ) hard_recall = _hist["Recall@20"].iloc[-1] mo.md( f"LightGCN K=2 with 50% hard negatives: Recall@20 = **{hard_recall:.4f}** " f"(uniform negatives: {rec_summary.set_index('model').loc['LightGCN, K=2', 'Recall@20 (final)']:.4f})." )
Hard negatives: 0.130 vs. 0.128, a gain well inside seed noise on a dataset this small. With only 5,287 items, uniform negatives are not that easy. PinSage's billions of pins are where hard negatives (and their curriculum) matter. Note also that a "hard negative" may be a movie the user would actually like (false negative), which is why PinSage avoids the very top ranks.
6 · Exercises
Exercise 1 (Recall@K). A user's future positives are \(\{a,b,c,d\}\); the model recommends \([a, x, c]\). Recall@3? → ex1_recall.
Exercise 2 (BPR). For a user, the positive scores 0.5 and a sampled negative 1.5. BPR loss for this pair? → ex2_bpr (3 decimals).
Exercise 3 (code). Write lightgcn_propagate(E, A, K): given the embedding table E and sparse normalised adjacency A,
return \(\frac{1}{K+1}\sum_{k=0}^{K}A^kE\). It is checked against rec_models["LightGCN, K=2"].
ex1_recall = None · 5 lines
ex1_recall = None ex2_bpr = None def lightgcn_propagate(E, A, K): return None
def _verdict(ok, answered): · 54 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_prop(): try: m = rec_models["LightGCN, K=2"] with torch.no_grad(): got = lightgcn_propagate(m.emb.weight, A_norm, 2) if got is None: return _verdict(False, False) return _verdict( torch.allclose(got, m.final_embeddings(), atol=1e-5), True ) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_recall is not None and abs(ex1_recall - 0.5) < 1e-6, ex1_recall is not None, ), mo.md("**Exercise 2**"), _verdict( ex2_bpr is not None and abs(ex2_bpr - np.log1p(np.e)) < 2e-3, ex2_bpr is not None, ), mo.md("**Exercise 3**"), _check_prop(), mo.accordion( { "Solution 1": mo.md( "Hits $\\{a, c\\}$ out of 4 future positives → **0.5**. (Precision@3 would be 2/3.)" ), "Solution 2": mo.md( r"$-\log\sigma(0.5-1.5)=-\log\sigma(-1)=\log(1+e)\approx 1.313$. The ranking is wrong, so the loss is large." ), "Solution 3": mo.md("""```python def lightgcn_propagate(E, A, K): layers = [E] for _ in range(K): layers.append(torch.sparse.mm(A, layers[-1])) return torch.stack(layers).mean(0) ```"""), } ), ] )
7 · Pitfall: recommending what the user already has
If you rank all items without removing the user's training interactions, the top of the list is filled with movies they already watched (high scores by construction), and Recall@K on future items collapses:
with torch.no_grad(): · 27 lines
with torch.no_grad(): _scores = rec_models["LightGCN, K=2"].all_scores() _top = _scores.topk( 20, dim=1 ).indices.numpy() # NO masking of training items _unmasked = float( np.mean( [ len(set(_top[u]) & test_by_user[u]) / len(test_by_user[u]) for u in test_by_user ] ) ) _already_seen = float( np.mean( [len(set(_top[u]) & train_by_user[u]) / 20 for u in test_by_user] ) ) mo.callout( mo.md( f"Without masking: Recall@20 = **{_unmasked:.4f}** (vs **{recall_at_k(_scores):.4f}** masked); " f"on average **{_already_seen:.0%}** of each top-20 list are movies the user already liked. " "**Fix:** set the scores of training interactions to −∞ before `topk`, as `recall_at_k` does. " "PyG's `LightGCN.recommend()` does *not* do this for you." ), kind="warn", )
8 · Connecting to your research (SNA on X/Twitter)
-
"Who to retweet / which hashtag to adopt next" is a user–item recommendation problem on the user–tweet or user–hashtag bipartite graph: BPR + LightGCN is a strong, cheap first model, with popularity as the baseline.
-
Split by time (as here): recommending tomorrow's retweets from today's graph is the honest version of the task.
- Fairness / filter-bubble analysis: collaborative filtering amplifies what similar users already do. Compare the camp composition of recommended items with the user's history to measure an "echo-chamber" effect of the model itself.
Summary
- Recommendation = link prediction on a bipartite graph, judged by Recall@K per user (popularity: 0.082 on MovieLens with a temporal split).
- BPR matches the personalised ranking metric; the binary loss wastes effort on irrelevant absolute scores (2.127 vs 0.127).
- LightGCN = embedding table + parameter-free diffusion + layer averaging: 0.128 vs MF 0.110, matches PyG to 4e-7.
- Hard negatives help at scale; on 5k items the gain was within noise.
Next — L12 · Relational deep learning: databases as graphs.