{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "---\n",
    "title: MovieLens 100K Two-Tower Retrieval\n",
    "category: Recommender Systems\n",
    "tags:\n",
    "  - recommender systems\n",
    "  - two tower\n",
    "  - retrieval\n",
    "  - implicit feedback\n",
    "  - movielens\n",
    "  - pytorch\n",
    "date: 2026-07-31\n",
    "description: Train a small two-tower retrieval model on MovieLens 100K using implicit feedback derived from real ratings.\n",
    "notebook_page: false\n",
    "aliases:\n",
    "  - /recc-sys/movielens-100k-two-tower-retrieval\n",
    "sources:\n",
    "  - https://files.grouplens.org/datasets/movielens/ml-100k-README.txt\n",
    "  - https://files.grouplens.org/datasets/movielens/ml-100k/u.data\n",
    "  - https://files.grouplens.org/datasets/movielens/ml-100k/u.item\n",
    "  - https://www.tensorflow.org/recommenders/examples/basic_retrieval\n",
    "---\n",
    "\n",
    "## Goal\n",
    "\n",
    "Build a small retrieval model on the real [MovieLens 100K data set](https://files.grouplens.org/datasets/movielens/ml-100k-README.txt). We will treat ratings of at least \\(4\\) as positive implicit feedback and train a two-tower model with sampled negatives.\n",
    "\n",
    "The scoring function is the dot product\n",
    "\n",
    "$$\n",
    "s(u, i) = q_u^\\\\top k_i,\n",
    "$$\n",
    "\n",
    "where \\(q_u\\\\) is the user embedding and \\(k_i\\\\) is the item embedding. Retrieval quality will be measured with Recall@K and MRR@K on a held-out item per user.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "cell-001",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:00.766945Z",
     "iopub.status.busy": "2026-07-31T21:59:00.766843Z",
     "iopub.status.idle": "2026-07-31T21:59:02.205557Z",
     "shell.execute_reply": "2026-07-31T21:59:02.204753Z"
    }
   },
   "outputs": [],
   "source": [
    "## %pip install -q pandas numpy torch matplotlib\n",
    "\n",
    "from pathlib import Path\n",
    "from urllib.request import urlopen\n",
    "\n",
    "import matplotlib.pyplot as plt\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "\n",
    "SEED = 7\n",
    "np.random.seed(SEED)\n",
    "_ = torch.manual_seed(SEED)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "cell-002",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:02.207693Z",
     "iopub.status.busy": "2026-07-31T21:59:02.207429Z",
     "iopub.status.idle": "2026-07-31T21:59:03.906874Z",
     "shell.execute_reply": "2026-07-31T21:59:03.906321Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>user_id</th>\n",
       "      <th>item_id</th>\n",
       "      <th>rating</th>\n",
       "      <th>timestamp</th>\n",
       "      <th>title</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>196</td>\n",
       "      <td>242</td>\n",
       "      <td>3</td>\n",
       "      <td>881250949</td>\n",
       "      <td>Kolya (1996)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>186</td>\n",
       "      <td>302</td>\n",
       "      <td>3</td>\n",
       "      <td>891717742</td>\n",
       "      <td>L.A. Confidential (1997)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>22</td>\n",
       "      <td>377</td>\n",
       "      <td>1</td>\n",
       "      <td>878887116</td>\n",
       "      <td>Heavyweights (1994)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>244</td>\n",
       "      <td>51</td>\n",
       "      <td>2</td>\n",
       "      <td>880606923</td>\n",
       "      <td>Legends of the Fall (1994)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>166</td>\n",
       "      <td>346</td>\n",
       "      <td>1</td>\n",
       "      <td>886397596</td>\n",
       "      <td>Jackie Brown (1997)</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   user_id  item_id  rating  timestamp                       title\n",
       "0      196      242       3  881250949                Kolya (1996)\n",
       "1      186      302       3  891717742    L.A. Confidential (1997)\n",
       "2       22      377       1  878887116         Heavyweights (1994)\n",
       "3      244       51       2  880606923  Legends of the Fall (1994)\n",
       "4      166      346       1  886397596         Jackie Brown (1997)"
      ]
     },
     "execution_count": 2,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "repo_root = next((path for path in [Path.cwd(), *Path.cwd().parents] if (path / \".git\").exists()), Path.cwd())\n",
    "root = repo_root / \"work/notebook-data/movielens-100k\"\n",
    "root.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "ratings_url = \"https://files.grouplens.org/datasets/movielens/ml-100k/u.data\"\n",
    "items_url = \"https://files.grouplens.org/datasets/movielens/ml-100k/u.item\"\n",
    "\n",
    "ratings_path = root / \"u.data\"\n",
    "items_path = root / \"u.item\"\n",
    "\n",
    "for url, path in [(ratings_url, ratings_path), (items_url, items_path)]:\n",
    "    if not path.exists():\n",
    "        path.write_bytes(urlopen(url).read())\n",
    "\n",
    "ratings = pd.read_csv(\n",
    "    ratings_path,\n",
    "    sep=\"\\t\",\n",
    "    names=[\"user_id\", \"item_id\", \"rating\", \"timestamp\"],\n",
    "    encoding=\"latin-1\",\n",
    ")\n",
    "\n",
    "items = pd.read_csv(\n",
    "    items_path,\n",
    "    sep=\"|\",\n",
    "    header=None,\n",
    "    encoding=\"latin-1\",\n",
    "    usecols=[0, 1],\n",
    "    names=[\"item_id\", \"title\"],\n",
    ")\n",
    "\n",
    "ratings = ratings.merge(items, on=\"item_id\", how=\"left\")\n",
    "ratings.head()\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "cell-003",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:03.908193Z",
     "iopub.status.busy": "2026-07-31T21:59:03.908072Z",
     "iopub.status.idle": "2026-07-31T21:59:04.445535Z",
     "shell.execute_reply": "2026-07-31T21:59:04.444936Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "train positives=54,433 heldout users=942\n"
     ]
    }
   ],
   "source": [
    "positives = ratings[ratings[\"rating\"] >= 4].copy()\n",
    "positives = positives.sort_values([\"user_id\", \"timestamp\"])\n",
    "\n",
    "heldout = positives.groupby(\"user_id\").tail(1)\n",
    "train = positives.drop(index=heldout.index)\n",
    "\n",
    "user_ids = sorted(positives[\"user_id\"].unique())\n",
    "item_ids = sorted(positives[\"item_id\"].unique())\n",
    "user_to_idx = {u: i for i, u in enumerate(user_ids)}\n",
    "item_to_idx = {i: j for j, i in enumerate(item_ids)}\n",
    "\n",
    "train_user = torch.tensor(train[\"user_id\"].map(user_to_idx).to_numpy(), dtype=torch.long)\n",
    "train_item = torch.tensor(train[\"item_id\"].map(item_to_idx).to_numpy(), dtype=torch.long)\n",
    "test_pairs = list(\n",
    "    zip(\n",
    "        heldout[\"user_id\"].map(user_to_idx).to_numpy(),\n",
    "        heldout[\"item_id\"].map(item_to_idx).to_numpy(),\n",
    "    )\n",
    ")\n",
    "\n",
    "positives_by_user = {\n",
    "    user_to_idx[u]: set(group[\"item_id\"].map(item_to_idx).tolist())\n",
    "    for u, group in positives.groupby(\"user_id\")\n",
    "}\n",
    "\n",
    "num_users = len(user_ids)\n",
    "num_items = len(item_ids)\n",
    "print(f\"train positives={len(train):,} heldout users={len(test_pairs):,}\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "cell-004",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:04.446822Z",
     "iopub.status.busy": "2026-07-31T21:59:04.446702Z",
     "iopub.status.idle": "2026-07-31T21:59:04.449351Z",
     "shell.execute_reply": "2026-07-31T21:59:04.448922Z"
    }
   },
   "outputs": [],
   "source": [
    "class TwoTower(nn.Module):\n",
    "    def __init__(self, num_users: int, num_items: int, dim: int = 64):\n",
    "        super().__init__()\n",
    "        self.user = nn.Embedding(num_users, dim)\n",
    "        self.item = nn.Embedding(num_items, dim)\n",
    "        self.user_bias = nn.Embedding(num_users, 1)\n",
    "        self.item_bias = nn.Embedding(num_items, 1)\n",
    "\n",
    "    def score(self, users, items):\n",
    "        dot = (self.user(users) * self.item(items)).sum(dim=-1)\n",
    "        bias = self.user_bias(users).squeeze(-1) + self.item_bias(items).squeeze(-1)\n",
    "        return dot + bias\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "cell-005",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:04.450575Z",
     "iopub.status.busy": "2026-07-31T21:59:04.450477Z",
     "iopub.status.idle": "2026-07-31T21:59:19.815267Z",
     "shell.execute_reply": "2026-07-31T21:59:19.814413Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=0000 loss=3.2362\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=0250 loss=2.2360\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=0500 loss=1.4879\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=0750 loss=0.9925\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=1000 loss=0.7536\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=1250 loss=0.5870\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=1500 loss=0.5085\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=1750 loss=0.4019\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=2000 loss=0.3955\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step=2250 loss=0.3531\n"
     ]
    }
   ],
   "source": [
    "device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
    "model = TwoTower(num_users, num_items, dim=64).to(device)\n",
    "opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4)\n",
    "\n",
    "batch_size = 2048\n",
    "steps = 2500\n",
    "losses = []\n",
    "\n",
    "train_user_device = train_user.to(device)\n",
    "train_item_device = train_item.to(device)\n",
    "\n",
    "for step in range(steps):\n",
    "    idx = torch.randint(0, len(train_user_device), (batch_size,), device=device)\n",
    "    u = train_user_device[idx]\n",
    "    pos_i = train_item_device[idx]\n",
    "    neg_i = torch.randint(0, num_items, (batch_size,), device=device)\n",
    "\n",
    "    pos_score = model.score(u, pos_i)\n",
    "    neg_score = model.score(u, neg_i)\n",
    "    logits = torch.cat([pos_score, neg_score], dim=0)\n",
    "    labels = torch.cat([torch.ones_like(pos_score), torch.zeros_like(neg_score)], dim=0)\n",
    "\n",
    "    loss = F.binary_cross_entropy_with_logits(logits, labels)\n",
    "    loss_value = loss.item()\n",
    "    opt.zero_grad()\n",
    "    loss.backward()\n",
    "    opt.step()\n",
    "    losses.append(loss_value)\n",
    "\n",
    "    if step % 250 == 0:\n",
    "        print(f\"step={step:04d} loss={loss_value:.4f}\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "cell-006",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:19.818766Z",
     "iopub.status.busy": "2026-07-31T21:59:19.818471Z",
     "iopub.status.idle": "2026-07-31T21:59:19.872695Z",
     "shell.execute_reply": "2026-07-31T21:59:19.871882Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "{'recall@10': 0.041401273885350316, 'mrr@10': 0.01159682202675833}\n"
     ]
    }
   ],
   "source": [
    "def evaluate(model: TwoTower, test_pairs, positives_by_user, k: int = 10):\n",
    "    user_emb = model.user.weight.detach()\n",
    "    item_emb = model.item.weight.detach()\n",
    "    user_bias = model.user_bias.weight.detach().squeeze(-1)\n",
    "    item_bias = model.item_bias.weight.detach().squeeze(-1)\n",
    "\n",
    "    recalls = []\n",
    "    rr = []\n",
    "    for u, target in test_pairs:\n",
    "        scores = user_emb[u] @ item_emb.T + user_bias[u] + item_bias\n",
    "        seen = positives_by_user[u] - {target}\n",
    "        if seen:\n",
    "            scores[list(seen)] = -1e9\n",
    "        topk = scores.topk(k).indices.tolist()\n",
    "        if target in topk:\n",
    "            recalls.append(1.0)\n",
    "            rr.append(1.0 / (topk.index(target) + 1))\n",
    "        else:\n",
    "            recalls.append(0.0)\n",
    "            rr.append(0.0)\n",
    "    return float(np.mean(recalls)), float(np.mean(rr))\n",
    "\n",
    "\n",
    "recall_at_10, mrr_at_10 = evaluate(model.cpu(), test_pairs, positives_by_user, k=10)\n",
    "print({\"recall@10\": recall_at_10, \"mrr@10\": mrr_at_10})\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "cell-007",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:19.875284Z",
     "iopub.status.busy": "2026-07-31T21:59:19.875063Z",
     "iopub.status.idle": "2026-07-31T21:59:19.999156Z",
     "shell.execute_reply": "2026-07-31T21:59:19.997965Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "iVBORw0KGgoAAAANSUhEUgAAArMAAAGICAYAAABSsmjoAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAXFlJREFUeJzt3Qd8k1X3wPHTXQodQIGyKXuDIEvZCLIREBS3guvFwYsTXhBRFNz7Lwq+bkFEBRVkKcjeW/belNkWSnf+n3N5E1PoSErSNO3v+/mEJk/uk9w8T0NPbs4918disVgEAAAA8EK+nu4AAAAAkFsEswAAAPBaBLMAAADwWgSzAAAA8FoEswAAAPBaBLMAAADwWgSzAAAA8FoEswAAAPBaBLMAkM+cPXtWJk+eLAcOHPB0V/JNX661H55+HZ5+fqAg8/d0BwA4Ji0tTT7//HOH2rZo0UIaNGiQp4f2hx9+kBIlSkinTp3y9Hnzs1OnTsnMmTOlc+fOUrlyZYf3O3TokDz44IPmmFapUsWtfXRVX3L7Wl3dD3ftf608/fxAQUYwC3gJXXl65cqVGbYtWrRI9u7dK/fee6/4+//zdq5UqVKeB7MjRoyQxo0bE8za0XOjAczPP//sVIBXsmRJGTx4sERHR0tBf615dUy88ZgCcAzBLOAlNFjVrynt3XXXXSaI+PDDD6VYsWIe6xtcq2LFiled68LuWo8JxxQouAhmgQJE0xDq168vzZo1s22bNWuWHD9+XG699VaJiIgw2+Li4mTatGnStm1bqVmzpq1tfHy8LF261HxlXK5cOWndurUEBwfn+Lz//e9/zWNqPqA14NCUg379+tnapKeny6pVq0zwXbRoUWnVqpVERUXZ7v/zzz9NXqH202rr1q1mNFpfT6NGjWzbf/zxRwkLCzNfadvbvn27bNq0yTzX9ddfn+G1qSlTpkjZsmWlffv2smvXLlm7dq1Ur15dmjdvnunryql9cnKyLF++3HyFXLx4cXM8w8PDzX379u2TX375xVxfsGCBnD592lzXfRs2bGjOiZ6brl27mmP9119/mePXp08f0+6nn36Sm2666aqvpLN7zpiYGPOcet5q16591ev5+uuvzXNpKsilS5fk22+/td0XFBRkRi31vPj5+YkzruW1FilSxKF+6O/GlcfkysfVbyqOHTtmfleu/GbiWve3OnLkiHmP6PtCHysgIMAc12tJ7YmNjTWPeebMGalQoYLceOON5jhcmWak5/3w4cMSGRkpTZo0MT+dbQMUSBYAXuvOO++06Ns4Pj7e3L7++ustnTt3tt2flpZmKV68uGnz3Xff2bZPmzbNbFu3bp1t29SpUy0RERGW2rVrWwYMGGCJjo62REVFWf74448c+/HQQw9ZwsLCLFWqVLEMHjzYXF544QXb/bt377Y0aNDAUrJkSUvfvn0trVq1sgQEBFjGjBljazNu3DiLv7+/JTY21rbtnnvuMf0cOHCgbduFCxcsgYGBltGjR9u2nTp1ytK1a1dL0aJFLd26dbP06tXLXL/vvvssKSkptnbly5c3x+z555+3tGjRwtK9e3fLM888k+Xryq79ggULzP36mrV/zZs3N8dAj61avXq1pXfv3qb/N910k+24zJ4929y/cOFCc9/XX39tadu2raVHjx6WevXqWTZs2GAuet8PP/yQoT85PWdiYqI5xrfeeutVr2XNmjXmMSdOnGhu63G29kkv/fr1s5QuXdpStWpVc76ssuqLvWt5rdfSD+vjTpkyxTxvz549LR07djTbRowYkaGP17q/euedd8zvbdOmTc0xbtSokWX+/Pmm/RtvvJHl8cnuOH7++eeW0NBQS/369c37rmLFipYKFSpYli1bZmuzc+dOc871mAwaNMj8Hurt9957z6k2QEFFMAsUoGBW/wAHBwdbLl26ZG6vWrXK3K8BqgZ2Vg8++KAlMjLSkp6ebm5v2rTJBJIaPKampppt+hg333yz+UN7+PDhHPtSrVo1S//+/a/aro9Xp04dc//x48czBAbaty+//DJDX3/++Wdbm3Llypm+a4CmgbmaNWuWabd48WJbOw2QtO2+ffts27Zs2WIpUqSIZezYsbZtGghqsPD666/bth06dCjL15RV++3bt5vH1uOflJRku0+fSwNtvV+tWLHiqtd0ZSClQf62bdtswWhMTEymgY+jz/nkk0+a2xrg23vkkUcsISEhGT4sXCkhIcEEyBrQORPMXstrvZZ+WB+3SZMmJpizGjVqlMXPz8+ya9cul+2/ZMkSi4+Pj2XkyJG2bWfPnjUBcG6DWT1mvr6+5txY34v6Ya1du3aWEiVK2M6hBrl169a1JCcnZzhGv/76q+22I22AgorSXEABol+7JyYmyuLFi83t+fPnm6/FdeKL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",
      "text/plain": [
       "<Figure size 800x400 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "plt.figure(figsize=(8, 4))\n",
    "plt.plot(losses)\n",
    "plt.title(\"Two-tower retrieval training loss\")\n",
    "plt.xlabel(\"step\")\n",
    "plt.ylabel(\"BCE loss\")\n",
    "plt.show()\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "cell-008",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-31T21:59:20.001507Z",
     "iopub.status.busy": "2026-07-31T21:59:20.001339Z",
     "iopub.status.idle": "2026-07-31T21:59:20.013027Z",
     "shell.execute_reply": "2026-07-31T21:59:20.012021Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "['Star Wars (1977)',\n",
       " 'Return of the Jedi (1983)',\n",
       " 'War, The (1994)',\n",
       " 'Alien (1979)',\n",
       " 'Miracle on 34th Street (1994)',\n",
       " 'Rosencrantz and Guildenstern Are Dead (1990)',\n",
       " 'Happy Gilmore (1996)',\n",
       " 'Prophecy II, The (1998)',\n",
       " 'Mother (1996)',\n",
       " 'Delicatessen (1991)']"
      ]
     },
     "execution_count": 8,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "title_to_item = {row.title: item_to_idx[row.item_id] for row in items.itertuples() if row.item_id in item_to_idx}\n",
    "item_lookup = {item_to_idx[row.item_id]: row.title for row in items.itertuples() if row.item_id in item_to_idx}\n",
    "\n",
    "anchor = \"Star Wars (1977)\"\n",
    "anchor_idx = title_to_item[anchor]\n",
    "anchor_vec = model.item.weight.detach()[anchor_idx]\n",
    "sims = model.item.weight.detach() @ anchor_vec\n",
    "top = sims.topk(10).indices.tolist()\n",
    "[item_lookup[i] for i in top]\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-009",
   "metadata": {},
   "source": [
    "## Where To Push Next\n",
    "\n",
    "This notebook is the retrieval stage only. Good follow-ons:\n",
    "\n",
    "1. Add side features from `u.user` and genre indicators from `u.item`.\n",
    "2. Replace pointwise BCE with BPR or sampled softmax.\n",
    "3. Add a reranker and compare Recall@K against NDCG@K.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
