{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":31254,"databundleVersionId":3103714,"isSourceIdPinned":false}],"dockerImageVersionId":31260,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip -q install open-clip-torch scikit-learn faiss-cpu","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-02-13T12:40:58.686960Z","iopub.execute_input":"2026-02-13T12:40:58.687247Z","iopub.status.idle":"2026-02-13T12:41:08.068822Z","shell.execute_reply.started":"2026-02-13T12:40:58.687218Z","shell.execute_reply":"2026-02-13T12:41:08.068101Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 2 — Imports & Global Seeds\n# =============================================================================\nimport collections\nimport math\nimport os\nimport random\nimport warnings\nfrom dataclasses import dataclass\n \nimport faiss\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport open_clip\nimport pandas as pd\nimport seaborn as sns\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom PIL import Image\nfrom scipy.sparse import coo_matrix\nfrom sklearn.preprocessing import LabelEncoder\nfrom torch.utils.data import DataLoader, Dataset\nfrom tqdm import tqdm\n \nwarnings.filterwarnings(\"ignore\")\n \nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.backends.cudnn.benchmark = True\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nprint(\"Device:\", DEVICE)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-31T17:35:01.990716Z","iopub.execute_input":"2026-03-31T17:35:01.991279Z","iopub.status.idle":"2026-03-31T17:35:02.003893Z","shell.execute_reply.started":"2026-03-31T17:35:01.991242Z","shell.execute_reply":"2026-03-31T17:35:02.002735Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 3 — Config\n# FIX: use_margin_grounding=True (scale-safe loss), magic number \"10\" removed\n# =============================================================================\n@dataclass\nclass CFG:\n    data_dir: str = \"/kaggle/input/competitions/h-and-m-personalized-fashion-recommendations\"\n    img_dir:  str = \"/kaggle/input/competitions/h-and-m-personalized-fashion-recommendations/images\"\n    work_dir: str = \"/kaggle/working\"\n \n    max_users: int = 20_000\n    max_items: int = 80_000\n    min_user_interactions: int = 5\n    min_item_interactions: int = 5\n    use_time_split: bool = True\n \n    clip_model:    str   = \"ViT-B-32\"\n    clip_pretrain: str   = \"laion2b_s34b_b79k\"\n    clip_batch:    int   = 128          # used by BOTH image AND text encoding\n    img_size:      int   = 224\n \n    topk_ii:      int   = 20\n    alpha:        float = 0.5\n    beta:         float = 0.4\n    gamma:        float = 0.1\n    gate_cooccur: bool  = False\n \n    embed_dim:    int   = 64\n    layers:       int   = 3\n    lr:           float = 1e-3\n    weight_decay: float = 0.0\n    reg_lambda:   float = 1e-5\n    batch_size:   int   = 4096\n    epochs:       int   = 100\n    patience:     int   = 7\n    ui_weight:    float = 0.8\n \n    lambda_g:             float = 0.1\n    tau:                  float = 0.4\n    use_margin_grounding: bool  = True   # FIX: cosine sim [0,1] cannot be added to raw dot-products\n \n    k_eval:            int = 20\n    sample_eval_users: int = 2000\n    recall_mode:       str = \"standard\"  # \"standard\" | \"min_k_truth\"\n \n \ncfg = CFG()\nprint(cfg)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T12:46:28.271618Z","iopub.execute_input":"2026-02-13T12:46:28.272041Z","iopub.status.idle":"2026-02-13T12:46:28.323713Z","shell.execute_reply.started":"2026-02-13T12:46:28.272013Z","shell.execute_reply":"2026-02-13T12:46:28.323126Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 4 — Early Stopping\n# =============================================================================\nclass EarlyStopping:\n    def __init__(self, patience=5, mode=\"max\"):\n        self.patience   = patience\n        self.mode       = mode\n        self.best_score = -np.inf if mode == \"max\" else np.inf\n        self.counter    = 0\n        self.early_stop = False\n        self.best_state = None\n \n    def __call__(self, score, model):\n        is_better = score > self.best_score if self.mode == \"max\" else score < self.best_score\n        if is_better:\n            self.best_score = score\n            self.counter    = 0\n            # CPU clone — avoids pinning extra GPU memory between epochs\n            self.best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n        else:\n            self.counter += 1\n            if self.counter >= self.patience:\n                self.early_stop = True","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T12:46:40.887246Z","iopub.execute_input":"2026-02-13T12:46:40.887793Z","iopub.status.idle":"2026-02-13T12:47:54.909127Z","shell.execute_reply.started":"2026-02-13T12:46:40.887766Z","shell.execute_reply":"2026-02-13T12:47:54.908330Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 5 — Data Loading & Temporal Split\n# =============================================================================\ndef read_data():\n    articles     = pd.read_csv(os.path.join(cfg.data_dir, \"articles.csv\"))\n    transactions = pd.read_csv(\n        os.path.join(cfg.data_dir, \"transactions_train.csv\"),\n        parse_dates=[\"t_dat\"],\n    )\n    return articles, transactions\n \n \ndef filter_and_split(transactions: pd.DataFrame):\n    user_cnt   = transactions[\"customer_id\"].value_counts()\n    item_cnt   = transactions[\"article_id\"].value_counts()\n    keep_users = user_cnt[user_cnt >= cfg.min_user_interactions].index\n    keep_items = item_cnt[item_cnt >= cfg.min_item_interactions].index\n \n    df = transactions[\n        transactions[\"customer_id\"].isin(keep_users)\n        & transactions[\"article_id\"].isin(keep_items)\n    ].copy()\n \n    if cfg.max_users:\n        sampled_users = df[\"customer_id\"].drop_duplicates().sample(\n            min(cfg.max_users, df[\"customer_id\"].nunique()), random_state=SEED\n        )\n        df = df[df[\"customer_id\"].isin(set(sampled_users))]\n \n    if cfg.max_items:\n        top_items = (\n            df[\"article_id\"].value_counts().head(cfg.max_items).index\n            if df[\"article_id\"].nunique() > cfg.max_items\n            else df[\"article_id\"].drop_duplicates()\n        )\n        df = df[df[\"article_id\"].isin(set(top_items))]\n \n    user_le = LabelEncoder().fit(df[\"customer_id\"])\n    item_le = LabelEncoder().fit(df[\"article_id\"])\n    df[\"uid\"] = user_le.transform(df[\"customer_id\"])\n    df[\"iid\"] = item_le.transform(df[\"article_id\"])\n \n    if not cfg.use_time_split:\n        raise ValueError(\"This notebook uses temporal split only.\")\n \n    tmax         = df[\"t_dat\"].max()\n    cutoff_test  = tmax - pd.Timedelta(days=7)\n    cutoff_val   = cutoff_test - pd.Timedelta(days=3)\n \n    train_all = df[df[\"t_dat\"] <= cutoff_test].copy()\n    test_df   = df[df[\"t_dat\"] >  cutoff_test].copy()\n    val_df    = train_all[train_all[\"t_dat\"] >  cutoff_val].copy()\n    train_df  = train_all[train_all[\"t_dat\"] <= cutoff_val].copy()\n \n    train_unique = train_df[[\"uid\", \"iid\"]].drop_duplicates()\n    return df, train_df, val_df, test_df, train_unique, user_le, item_le\n \n \ndef build_user_pos(df_part: pd.DataFrame):\n    pos = collections.defaultdict(set)\n    for u, i in zip(df_part[\"uid\"].values, df_part[\"iid\"].values):\n        pos[int(u)].add(int(i))\n    return pos","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T12:47:58.761364Z","iopub.execute_input":"2026-02-13T12:47:58.762040Z","iopub.status.idle":"2026-02-13T12:48:01.550370Z","shell.execute_reply.started":"2026-02-13T12:47:58.762014Z","shell.execute_reply":"2026-02-13T12:48:01.549560Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 6 — Item Metadata + CLIP Encoding\n# FIX: encode_texts now uses cfg.clip_batch (no hardcoded 512)\n# =============================================================================\ndef build_item_metadata(articles: pd.DataFrame, item_le: LabelEncoder, n_items: int):\n    inverse_ids = item_le.inverse_transform(np.arange(n_items))\n    meta = pd.DataFrame({\"iid\": np.arange(n_items), \"article_id\": inverse_ids})\n    cols = [\"article_id\", \"detail_desc\", \"product_type_name\", \"index_name\"]\n    meta = meta.merge(articles[cols], on=\"article_id\", how=\"left\").sort_values(\"iid\").reset_index(drop=True)\n \n    def build_text(row):\n        parts = []\n        if isinstance(row[\"detail_desc\"], str) and row[\"detail_desc\"]:\n            parts.append(row[\"detail_desc\"][:200])\n        if isinstance(row[\"product_type_name\"], str):\n            parts.append(row[\"product_type_name\"])\n        if isinstance(row[\"index_name\"], str):\n            parts.append(row[\"index_name\"])\n        return \". \".join(parts) if parts else \"fashion item\"\n \n    texts = [build_text(r) for _, r in meta.iterrows()]\n \n    def article_id_to_path(article_id):\n        s = str(int(article_id)).zfill(10)\n        return os.path.join(cfg.img_dir, s[:3], s + \".jpg\")\n \n    img_paths = [article_id_to_path(aid) for aid in inverse_ids]\n    assert len(texts) == n_items == len(img_paths)\n    return texts, img_paths, meta\n \n \nclass ImageDataset(Dataset):\n    def __init__(self, paths, preprocess_fn):\n        self.paths     = paths\n        self.preprocess = preprocess_fn\n \n    def __len__(self):\n        return len(self.paths)\n \n    def __getitem__(self, idx):\n        try:\n            with Image.open(self.paths[idx]) as im:\n                im = im.convert(\"RGB\")\n            return self.preprocess(im), False\n        except Exception:\n            return torch.zeros(3, cfg.img_size, cfg.img_size), True\n \n \ndef encode_texts(clip_model, clip_tokenizer, texts):\n    out = []\n    # FIX: cfg.clip_batch instead of hardcoded 512\n    for i in tqdm(range(0, len(texts), cfg.clip_batch), desc=\"CLIP Text\"):\n        tok = clip_tokenizer(texts[i : i + cfg.clip_batch]).to(DEVICE)\n        with torch.no_grad(), torch.cuda.amp.autocast(enabled=(DEVICE == \"cuda\")):\n            z = clip_model.encode_text(tok)\n        out.append(F.normalize(z, dim=1).cpu())\n    return torch.cat(out, dim=0).numpy().astype(\"float32\")\n \n \ndef encode_images_fast(clip_model, clip_preprocess, img_paths):\n    dataset = ImageDataset(img_paths, clip_preprocess)\n    loader  = DataLoader(dataset, batch_size=cfg.clip_batch, shuffle=False, num_workers=2, pin_memory=True)\n    out, missing = [], []\n    with torch.no_grad(), torch.cuda.amp.autocast(enabled=(DEVICE == \"cuda\")):\n        for imgs, miss in tqdm(loader, desc=\"CLIP Image\"):\n            z = F.normalize(clip_model.encode_image(imgs.to(DEVICE)), dim=1)\n            out.append(z.cpu())\n            missing.append(miss.numpy().astype(bool))\n    return torch.cat(out, dim=0).numpy().astype(\"float32\"), np.concatenate(missing)\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T12:48:27.909818Z","iopub.execute_input":"2026-02-13T12:48:27.910677Z","iopub.status.idle":"2026-02-13T13:12:13.496738Z","shell.execute_reply.started":"2026-02-13T12:48:27.910637Z","shell.execute_reply":"2026-02-13T13:12:13.495850Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 7 — Graph Construction\n# FIX: build_cooccur_knn uses indptr/indices/data directly (no .getrow() loop)\n#      and argpartition for O(n) top-k instead of full argsort\n# =============================================================================\ndef faiss_knn(x, k):\n    x = x.copy().astype(\"float32\")\n    faiss.normalize_L2(x)\n    idx = faiss.IndexFlatIP(x.shape[1])\n    idx.add(x)\n    sims, ids = idx.search(x, k + 1)\n    return sims[:, 1:], ids[:, 1:]\n \n \ndef build_cooccur_knn(train_unique: pd.DataFrame, n_users: int, n_items: int):\n    uu  = train_unique[\"uid\"].values.astype(\"int64\")\n    ii  = train_unique[\"iid\"].values.astype(\"int64\")\n    v   = np.ones_like(uu, dtype=\"float32\")\n    a_ui = coo_matrix((v, (uu, ii)), shape=(n_users, n_items), dtype=np.float32).tocsr()\n    c    = (a_ui.T @ a_ui).tocsr()\n    item_pops = a_ui.sum(axis=0).A1\n \n    co_ids  = np.zeros((n_items, cfg.topk_ii), dtype=np.int64)\n    co_sims = np.zeros((n_items, cfg.topk_ii), dtype=np.float32)\n \n    # FIX: access CSR arrays directly — avoids n_items Python .getrow() calls\n    indptr  = c.indptr\n    indices = c.indices\n    data    = c.data\n \n    for i in tqdm(range(n_items), desc=\"Co-occur kNN\"):\n        s, e = indptr[i], indptr[i + 1]\n        if s == e:\n            continue\n        j_idx = indices[s:e]\n        cij   = data[s:e]\n        union = item_pops[i] + item_pops[j_idx] - cij\n        jac   = cij / (union + 1e-8)\n \n        # FIX: argpartition is O(n) vs argsort O(n log n)\n        k_sel = min(cfg.topk_ii, len(jac))\n        order = np.argpartition(-jac, k_sel - 1)[:k_sel]\n        order = order[np.argsort(-jac[order])]   # sort only the top-k slice\n \n        sel_idx = j_idx[order]\n        sel_sim = jac[order]\n \n        if len(sel_idx) < cfg.topk_ii:\n            pad     = cfg.topk_ii - len(sel_idx)\n            sel_idx = np.concatenate([sel_idx, np.full(pad, i, dtype=np.int64)])\n            sel_sim = np.concatenate([sel_sim, np.zeros(pad, dtype=np.float32)])\n \n        co_ids[i]  = sel_idx\n        co_sims[i] = sel_sim.astype(\"float32\")\n \n    return co_ids, co_sims\n \n \ndef fuse_and_gate_vectorized(img_ids, img_sims, txt_ids, txt_sims, co_ids, co_sims, g):\n    n_items    = img_ids.shape[0]\n    rows_base  = np.repeat(np.arange(n_items, dtype=np.int64), cfg.topk_ii)\n \n    def pack(ids, sims, w, gated):\n        cols = ids.reshape(-1).astype(np.int64)\n        vals = (w * sims.reshape(-1)).astype(\"float32\")\n        if gated:\n            gate = 0.5 * (g[rows_base] + g[cols])\n            vals = vals * gate.astype(\"float32\")\n        return rows_base, cols, vals\n \n    r1, c1, v1 = pack(img_ids, img_sims, cfg.alpha, True)\n    r2, c2, v2 = pack(txt_ids, txt_sims, cfg.beta,  True)\n    r3, c3, v3 = pack(co_ids,  co_sims,  cfg.gamma, cfg.gate_cooccur)\n \n    rows = np.concatenate([r1, r2, r3])\n    cols = np.concatenate([c1, c2, c3])\n    vals = np.concatenate([v1, v2, v3])\n \n    keep         = vals > 0\n    rows, cols, vals = rows[keep], cols[keep], vals[keep]\n    lin          = rows * n_items + cols\n    uniq, inv    = np.unique(lin, return_inverse=True)\n    agg          = np.bincount(inv, weights=vals).astype(\"float32\")\n    return (uniq // n_items).astype(np.int64), (uniq % n_items).astype(np.int64), agg\n \n \ndef build_norm_adj(n, rows, cols, vals):\n    all_r  = np.concatenate([rows, cols])\n    all_c  = np.concatenate([cols, rows])\n    all_v  = np.concatenate([vals, vals]).astype(\"float32\")\n    deg    = np.bincount(all_r, weights=all_v, minlength=n).astype(\"float32\") + 1e-8\n    deg2   = np.sqrt(deg)\n    norm_v = all_v / (deg2[all_r] * deg2[all_c])\n    idx    = torch.tensor(np.vstack([all_r, all_c]), dtype=torch.long)\n    return torch.sparse_coo_tensor(idx, torch.tensor(norm_v, dtype=torch.float32), (n, n)).coalesce()\n \n \ndef build_ui_norm_adj(train_unique: pd.DataFrame, n_users: int, n_items: int):\n    uu   = train_unique[\"uid\"].values.astype(\"int64\")\n    ii   = train_unique[\"iid\"].values.astype(\"int64\") + n_users\n    v    = np.ones_like(uu, dtype=\"float32\")\n    rows = np.concatenate([uu, ii])\n    cols = np.concatenate([ii, uu])\n    vals = np.concatenate([v,  v])\n    deg  = np.bincount(rows, weights=vals, minlength=n_users + n_items) + 1e-8\n    deg2 = np.sqrt(deg)\n    norm_v = vals / (deg2[rows] * deg2[cols])\n    idx    = torch.tensor(np.vstack([rows, cols]), dtype=torch.long)\n    n = n_users + n_items\n    return torch.sparse_coo_tensor(idx, torch.tensor(norm_v, dtype=torch.float32), (n, n)).coalesce()\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T13:12:38.205611Z","iopub.execute_input":"2026-02-13T13:12:38.206236Z","iopub.status.idle":"2026-02-13T14:15:47.641574Z","shell.execute_reply.started":"2026-02-13T13:12:38.206202Z","shell.execute_reply":"2026-02-13T14:15:47.640745Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 8 — LightGCN Model\n# FIX: a_ii (n_items × n_items) is applied ONLY to item embeddings.\n#      User embeddings are never touched by item-item edges.\n#      lift_item_adj_to_total() is deleted — no longer needed.\n# =============================================================================\nclass LightGCN(nn.Module):\n    def __init__(self, n_users, n_items, d=64, layers=3, ui_weight=0.8):\n        super().__init__()\n        self.emb      = nn.Embedding(n_users + n_items, d)\n        nn.init.xavier_uniform_(self.emb.weight)\n        self.layers   = layers\n        self.n_users  = n_users\n        self.n_items  = n_items\n        self.ui_weight = ui_weight\n \n    def propagate(self, a_ui, a_ii):\n        \"\"\"\n        a_ui : (n_users + n_items) × (n_users + n_items) — bipartite UI graph\n        a_ii : n_items × n_items                          — item-item content graph\n        \"\"\"\n        e   = self.emb.weight        # (n_users + n_items) × d\n        acc = [e]\n \n        for _ in range(self.layers):\n            # 1. User-Item propagation — mixes user <-> item signals\n            e_ui = torch.sparse.mm(a_ui, e)            # (n_users + n_items) × d\n            u_e  = e_ui[: self.n_users]                # user slice\n            i_e  = e_ui[self.n_users :]                # item slice from UI graph\n \n            # 2. Item-Item propagation — uses PREVIOUS layer's item embeddings only\n            #    e[self.n_users:] is the raw item embeddings before this layer's UI update\n            i_e_ii = torch.sparse.mm(a_ii, e[self.n_users :])\n \n            # 3. Blend item signals; users are unaffected by item-item edges\n            i_e_combined = self.ui_weight * i_e + (1.0 - self.ui_weight) * i_e_ii\n \n            e = torch.cat([u_e, i_e_combined], dim=0)\n            acc.append(e)\n \n        return torch.stack(acc, dim=0).mean(0)\n \n    def forward(self, a_ui, a_ii):\n        e = self.propagate(a_ui, a_ii)\n        return e[: self.n_users], e[self.n_users :]\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T14:18:16.135576Z","iopub.execute_input":"2026-02-13T14:18:16.136417Z","iopub.status.idle":"2026-02-13T14:23:29.908316Z","shell.execute_reply.started":"2026-02-13T14:18:16.136387Z","shell.execute_reply":"2026-02-13T14:23:29.907500Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 9 — Loss Functions\n# =============================================================================\ndef bpr_loss(u_e, i_pos_e, i_neg_e):\n    pos = (u_e * i_pos_e).sum(-1)\n    neg = (u_e * i_neg_e).sum(-1)\n    return -F.logsigmoid(pos - neg).mean()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T14:23:52.469927Z","iopub.execute_input":"2026-02-13T14:23:52.470236Z","iopub.status.idle":"2026-02-13T14:23:52.546239Z","shell.execute_reply.started":"2026-02-13T14:23:52.470208Z","shell.execute_reply":"2026-02-13T14:23:52.545639Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 10 — Negative Sampling\n# FIX: generate all negatives in one vectorized call;\n#      while-loop is now a rare-collision fallback only (~0.006% chance per sample)\n# =============================================================================\ndef sample_batch(user_pos_array, n_items):\n    users     = np.random.choice(list(user_pos_array.keys()), size=cfg.batch_size, replace=True)\n    pos_items = np.array([np.random.choice(user_pos_array[u]) for u in users])\n \n    # FIX: single vectorized draw; loop only fires for rare collisions\n    neg_items = np.random.randint(0, n_items, size=cfg.batch_size)\n    for i, u in enumerate(users):\n        while neg_items[i] in user_pos_array[u]:\n            neg_items[i] = np.random.randint(0, n_items)\n \n    return users, pos_items, neg_items","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 11 — Evaluation\n# FIX: mask tensors are precomputed ONCE outside the function and passed in,\n#      not rebuilt from scratch every call (which happened every epoch before).\n#      Test set is NOT evaluated inside train_loop (no peeking during training).\n# =============================================================================\ndef _recall(hit, truth_size, k):\n    if cfg.recall_mode == \"min_k_truth\":\n        return hit / min(k, truth_size)\n    return hit / truth_size\n \n \ndef precompute_mask_cache(user_pos: dict) -> dict:\n    \"\"\"\n    Build {user_id: GPU tensor of item indices} once.\n    Pass the result into evaluate_split to avoid per-epoch recreation.\n    \"\"\"\n    return {\n        u: torch.tensor(list(items), device=DEVICE)\n        for u, items in user_pos.items()\n        if items\n    }\n \n \n@torch.no_grad()\ndef compute_embeddings(model, a_ui, a_ii):\n    model.eval()\n    return model(a_ui, a_ii)\n \n \n@torch.no_grad()\ndef evaluate_split(\n    model, a_ui, a_ii,\n    user_pos_true,\n    train_mask_cache,           # precomputed GPU tensors\n    val_mask_cache=None,        # precomputed GPU tensors (optional)\n    k=20,\n):\n    model.eval()\n    u_emb_all, i_emb_all = compute_embeddings(model, a_ui, a_ii)\n    users_list = list(user_pos_true.keys())\n    recalls, ndcgs = [], []\n \n    for start in range(0, len(users_list), 512):\n        batch_users = users_list[start : start + 512]\n        u_emb  = u_emb_all[batch_users]\n        scores = torch.matmul(u_emb, i_emb_all.T)\n \n        for bi, u in enumerate(batch_users):\n            if u in train_mask_cache:\n                scores[bi, train_mask_cache[u]] = -1e9\n            if val_mask_cache is not None and u in val_mask_cache:\n                scores[bi, val_mask_cache[u]] = -1e9\n \n        topk = torch.topk(scores, k=k, dim=1).indices.cpu().numpy()\n \n        for bi, u in enumerate(batch_users):\n            truth = set(user_pos_true.get(u, []))\n            if not truth:\n                continue\n            preds = list(topk[bi])\n            hit   = len(set(preds) & truth)\n            recalls.append(_recall(hit, len(truth), k))\n            dcg  = sum(1.0 / math.log2(r + 1) for r, it in enumerate(preds, 1) if it in truth)\n            idcg = sum(1.0 / math.log2(r + 1) for r in range(1, min(k, len(truth)) + 1))\n            ndcgs.append(dcg / idcg if idcg > 0 else 0.0)\n \n    return (float(np.mean(recalls)) if recalls else 0.0,\n            float(np.mean(ndcgs))   if ndcgs   else 0.0)\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-31T16:03:28.476775Z","iopub.execute_input":"2026-03-31T16:03:28.476967Z","iopub.status.idle":"2026-03-31T16:03:28.490795Z","shell.execute_reply.started":"2026-03-31T16:03:28.476941Z","shell.execute_reply":"2026-03-31T16:03:28.489861Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 12 — Training Loop\n# FIX: steps derived from actual interaction count (not n_users * 10).\n#      Test set removed entirely — evaluated separately after training.\n#      Mask caches passed in, not rebuilt per epoch.\n# =============================================================================\ndef train_loop(\n    model, a_ui, a_ii, g_t,\n    user_pos_array, n_items,\n    train_interactions_count,\n    user_pos_val,\n    train_mask_cache,\n    val_mask_cache=None,\n):\n    opt     = torch.optim.Adam(model.parameters(), lr=cfg.lr, weight_decay=cfg.weight_decay)\n    history = []\n    stopper = EarlyStopping(patience=cfg.patience, mode=\"max\")\n \n    for ep in range(1, cfg.epochs + 1):\n        model.train()\n        losses = []\n        # FIX: steps = actual interactions / batch_size  (no magic multiplier)\n        steps = max(1, train_interactions_count // cfg.batch_size)\n \n        for _ in range(steps):\n            users, pos_items, neg_items = sample_batch(user_pos_array, n_items)\n            users_t = torch.tensor(users,     dtype=torch.long, device=DEVICE)\n            pos_t   = torch.tensor(pos_items, dtype=torch.long, device=DEVICE)\n            neg_t   = torch.tensor(neg_items, dtype=torch.long, device=DEVICE)\n \n            u_all, i_all = model(a_ui, a_ii)\n            u_e     = u_all[users_t]\n            i_pos_e = i_all[pos_t]\n            i_neg_e = i_all[neg_t]\n \n            reg = cfg.reg_lambda * (\n                u_e.norm(2).pow(2) + i_pos_e.norm(2).pow(2) + i_neg_e.norm(2).pow(2)\n            ) / cfg.batch_size\n \n            # FIX: margin grounding — penalises low-grounding positives as a\n            #      regulariser, never adds cosine scores to dot-product logits\n            base = bpr_loss(u_e, i_pos_e, i_neg_e)\n            grd  = cfg.lambda_g * F.relu(cfg.tau - g_t[pos_t]).mean()\n            loss = base + reg + grd\n \n            opt.zero_grad(set_to_none=True)\n            loss.backward()\n            opt.step()\n            losses.append(loss.item())\n \n        val_r, val_n = evaluate_split(\n            model, a_ui, a_ii, user_pos_val,\n            train_mask_cache=train_mask_cache,\n            val_mask_cache=None,   # do not mask val items when scoring val set\n            k=cfg.k_eval,\n        )\n \n        row = {\"epoch\": ep, \"loss\": float(np.mean(losses)), \"val_recall\": val_r, \"val_ndcg\": val_n}\n        history.append(row)\n        print(f\"[E{ep:03d}] loss={row['loss']:.4f}  val R@{cfg.k_eval}={val_r:.4f}  N@{cfg.k_eval}={val_n:.4f}\")\n \n        stopper(val_n, model)\n        if stopper.early_stop:\n            print(f\"\\nEarly stop at epoch {ep} — restoring best weights \"\n                  f\"(Val NDCG: {stopper.best_score:.4f})\")\n            model.load_state_dict(stopper.best_state)\n            model.to(DEVICE)\n            break\n \n    # Ensure best weights are always loaded even if training ran to completion\n    if not stopper.early_stop and stopper.best_state is not None:\n        model.load_state_dict(stopper.best_state)\n        model.to(DEVICE)\n \n    return history","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 13 — Plotting\n# FIX: personalization uses seeded RNG for reproducibility\n# =============================================================================\nsns.set_theme(style=\"whitegrid\", context=\"notebook\")\n \n \ndef plot_training_history(history):\n    epochs      = [r[\"epoch\"]      for r in history]\n    losses      = [r[\"loss\"]       for r in history]\n    val_recalls = [r[\"val_recall\"] for r in history]\n    val_ndcgs   = [r[\"val_ndcg\"]   for r in history]\n \n    fig, axes = plt.subplots(1, 2, figsize=(16, 5))\n \n    axes[0].plot(epochs, losses, marker=\"o\", color=\"crimson\", linewidth=2.5, label=\"Train Loss\")\n    axes[0].set_title(\"Training Loss\", fontsize=14, fontweight=\"bold\")\n    axes[0].set_xlabel(\"Epoch\"); axes[0].set_ylabel(\"Loss\")\n    axes[0].legend()\n \n    axes[1].plot(epochs, val_recalls, linestyle=\"--\", color=\"cornflowerblue\", label=f\"Val Recall@{cfg.k_eval}\")\n    axes[1].plot(epochs, val_ndcgs,   linestyle=\"--\", color=\"darkorange\",     label=f\"Val NDCG@{cfg.k_eval}\")\n    axes[1].set_title(\"Validation Metrics\", fontsize=14, fontweight=\"bold\")\n    axes[1].set_xlabel(\"Epoch\"); axes[1].set_ylabel(\"Score\")\n    axes[1].legend()\n \n    plt.tight_layout()\n    plt.show()\n \n \n@torch.no_grad()\ndef evaluate_comprehensive(model, a_ui, a_ii, user_pos_test, train_mask_cache, val_mask_cache, n_items, k=20):\n    \"\"\"Full metric suite on the test set. Called ONCE after training finishes.\"\"\"\n    print(f\"\\n--- Comprehensive Test Evaluation (Top-{k}) ---\")\n    model.eval()\n \n    u_emb_all, i_emb_all = model(a_ui, a_ii)\n    users_list = list(user_pos_test.keys())\n \n    recalls, ndcgs, precisions, maps = [], [], [], []\n    all_recommended = set()\n    all_user_recs   = []\n \n    for start in range(0, len(users_list), 512):\n        batch_users = users_list[start : start + 512]\n        u_emb  = u_emb_all[batch_users]\n        scores = torch.matmul(u_emb, i_emb_all.T)\n \n        for bi, u in enumerate(batch_users):\n            if u in train_mask_cache:\n                scores[bi, train_mask_cache[u]] = -1e9\n            if u in val_mask_cache:\n                scores[bi, val_mask_cache[u]] = -1e9\n \n        topk = torch.topk(scores, k=k, dim=1).indices.cpu().numpy()\n \n        for bi, u in enumerate(batch_users):\n            truth = set(user_pos_test.get(u, []))\n            if not truth:\n                continue\n            preds = list(topk[bi])\n            all_recommended.update(preds)\n            all_user_recs.append(preds)\n \n            hits = [1 if it in truth else 0 for it in preds]\n            n_hit = sum(hits)\n \n            recalls.append(n_hit / len(truth))\n            precisions.append(n_hit / k)\n \n            dcg  = sum(hits[r] / math.log2(r + 2) for r in range(k))\n            idcg = sum(1.0 / math.log2(r + 2) for r in range(min(k, len(truth))))\n            ndcgs.append(dcg / idcg if idcg > 0 else 0.0)\n \n            ap, running = 0.0, 0\n            for rank, h in enumerate(hits):\n                if h:\n                    running += 1\n                    ap += running / (rank + 1)\n            maps.append(ap / len(truth))\n \n    coverage = len(all_recommended) / n_items\n \n    # FIX: seeded RNG so personalization score is reproducible\n    rng = random.Random(SEED)\n    if len(all_user_recs) >= 2:\n        sample_size = min(500, len(all_user_recs))\n        sampled     = rng.sample(all_user_recs, sample_size)\n        overlaps    = []\n        for _ in range(1000):\n            u1, u2 = rng.sample(sampled, 2)\n            overlaps.append(len(set(u1) & set(u2)) / k)\n        personalization = 1.0 - float(np.mean(overlaps))\n    else:\n        personalization = 0.0\n \n    metrics = {\n        f\"Recall@{k}\":      float(np.mean(recalls))    if recalls    else 0.0,\n        f\"NDCG@{k}\":        float(np.mean(ndcgs))      if ndcgs      else 0.0,\n        f\"Precision@{k}\":   float(np.mean(precisions)) if precisions else 0.0,\n        f\"MAP@{k}\":         float(np.mean(maps))        if maps       else 0.0,\n        \"Coverage\":          coverage,\n        \"Personalization\":   personalization,\n    }\n \n    print(\"\\n--- Final Test Metrics ---\")\n    for name, val in metrics.items():\n        print(f\"  {name:>18}: {val:.4f}\")\n \n    colors = sns.color_palette(\"viridis\", len(metrics))\n    plt.figure(figsize=(10, 5))\n    bars = plt.bar(metrics.keys(), metrics.values(), color=colors, edgecolor=\"black\")\n    plt.title(f\"Test Metrics — Top-{k}\", fontsize=15, fontweight=\"bold\")\n    plt.ylabel(\"Score\"); plt.ylim(0, 1.05)\n    for b in bars:\n        plt.text(b.get_x() + b.get_width() / 2, b.get_height() + 0.01,\n                 f\"{b.get_height():.3f}\", ha=\"center\", fontsize=10, fontweight=\"bold\")\n    plt.tight_layout()\n    plt.show()\n \n    return metrics","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 14 — Run Everything\n# =============================================================================\n \n# --- Data ---\narticles, transactions = read_data()\ndf, train_df, val_df, test_df, train_unique, user_le, item_le = filter_and_split(transactions)\nn_users = int(user_le.classes_.shape[0])\nn_items = int(item_le.classes_.shape[0])\ntrain_interactions_count = len(train_unique)\nprint(f\"users={n_users}  items={n_items}  \"\n      f\"train={len(train_df)}  val={len(val_df)}  test={len(test_df)}  \"\n      f\"unique_train_interactions={train_interactions_count}\")\n \nuser_pos_train = build_user_pos(train_df)\nuser_pos_val   = build_user_pos(val_df)\nuser_pos_test  = build_user_pos(test_df)\nuser_pos_array = {u: np.array(list(v)) for u, v in user_pos_train.items() if v}\n \n# --- CLIP ---\ntexts, img_paths, meta = build_item_metadata(articles, item_le, n_items)\nclip_model, _, clip_preprocess = open_clip.create_model_and_transforms(\n    cfg.clip_model, pretrained=cfg.clip_pretrain, device=DEVICE\n)\nclip_tokenizer = open_clip.get_tokenizer(cfg.clip_model)\n \nz_txt              = encode_texts(clip_model, clip_tokenizer, texts)\nz_img, missing_mask = encode_images_fast(clip_model, clip_preprocess, img_paths)\nif missing_mask.any():\n    z_img[missing_mask] = z_txt[missing_mask]\n    print(f\"WARNING: {int(missing_mask.sum())} images missing — used text fallback\")\n \n# --- Grounding Signal ---\ng = np.clip(np.sum(z_img * z_txt, axis=-1).astype(\"float32\"), 0.0, 1.0)\ng_t = torch.tensor(g, dtype=torch.float32, device=DEVICE)\nprint(f\"Grounding g: mean={g.mean():.4f}  min={g.min():.4f}  max={g.max():.4f}\")\n \n# --- Graphs ---\nimg_sims, img_ids = faiss_knn(z_img, cfg.topk_ii)\ntxt_sims, txt_ids = faiss_knn(z_txt, cfg.topk_ii)\nco_ids, co_sims   = build_cooccur_knn(train_unique, n_users, n_items)\nrows, cols, vals  = fuse_and_gate_vectorized(img_ids, img_sims, txt_ids, txt_sims, co_ids, co_sims, g)\nprint(f\"Item-Item edges after fusion: {len(vals)}\")\n \n# FIX: a_ii stays as (n_items × n_items); no lifting needed\na_ii = build_norm_adj(n_items, rows, cols, vals).to(DEVICE)\na_ui = build_ui_norm_adj(train_unique, n_users, n_items).to(DEVICE)\n \n# --- Precompute eval masks ONCE (not per epoch, not inside evaluate_split) ---\ntrain_mask_cache = precompute_mask_cache(user_pos_train)\nval_mask_cache   = precompute_mask_cache(user_pos_val)\nprint(\"Mask caches built.\")\n \n# --- Model ---\nmodel = LightGCN(\n    n_users=n_users, n_items=n_items,\n    d=cfg.embed_dim, layers=cfg.layers, ui_weight=cfg.ui_weight,\n).to(DEVICE)\n \n# --- Train ---\nhistory = train_loop(\n    model, a_ui, a_ii, g_t,\n    user_pos_array, n_items,\n    train_interactions_count=train_interactions_count,\n    user_pos_val=user_pos_val,\n    train_mask_cache=train_mask_cache,\n    val_mask_cache=val_mask_cache,\n)\n \n# --- Plot training curves ---\nplot_training_history(history)\n \n# --- Final test evaluation (first and only look at test set) ---\ntest_metrics = evaluate_comprehensive(\n    model, a_ui, a_ii,\n    user_pos_test=user_pos_test,\n    train_mask_cache=train_mask_cache,\n    val_mask_cache=val_mask_cache,\n    n_items=n_items,\n    k=cfg.k_eval,\n)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =============================================================================\n# CELL 15 — Save Artefacts\n# =============================================================================\nos.makedirs(cfg.work_dir, exist_ok=True)\nmodel_path = os.path.join(cfg.work_dir, \"lightgcn_fixed.pt\")\ntorch.save(\n    {\n        \"state_dict\":   model.state_dict(),\n        \"cfg\":          cfg.__dict__,\n        \"history\":      history,\n        \"test_metrics\": test_metrics,\n        \"user_classes\": user_le.classes_,\n        \"item_classes\": item_le.classes_,\n    },\n    model_path,\n)\nprint(\"Model saved:\", model_path)\n \n# Export propagated item embeddings for offline inference\nmodel.eval()\nwith torch.no_grad():\n    _, final_item_embeddings = model(a_ui, a_ii)\n \nemb_path = os.path.join(cfg.work_dir, \"item_embeddings_final.pt\")\ntorch.save(\n    {\"item_embeddings\": final_item_embeddings.cpu(), \"item_classes\": item_le.classes_},\n    emb_path,\n)\nprint(\"Embeddings saved:\", emb_path)","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}