Graph Learning

Notebooks CS224W companions Study guideLive marimo ↗

L11 · GNNs for recommender systems

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:

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

  1. Data — MovieLens (100k ratings) as an implicit-feedback bipartite graph; a per-user temporal split.
  2. Recall@K and a popularity baseline.
  3. Surrogate losses — binary vs. BPR, the lecture's worked example.
  4. Matrix factorisation vs. LightGCN — from scratch, trained with BPR; LightGCN checked against PyG.
  5. Hard negatives (PinSage idea).
  6. 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

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)

Summary

Next — L12 · Relational deep learning: databases as graphs.