{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":31254,"databundleVersionId":3103714,"sourceType":"competition"}],"dockerImageVersionId":31089,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))\n\n# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-09-03T18:04:56.126854Z","iopub.execute_input":"2025-09-03T18:04:56.127264Z","iopub.status.idle":"2025-09-03T18:04:56.134076Z","shell.execute_reply.started":"2025-09-03T18:04:56.127236Z","shell.execute_reply":"2025-09-03T18:04:56.133162Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip -q install open-clip-torch==2.24.0 faiss-cpu==1.8.0.post1 torchmetrics==1.4.0.post0 umap-learn==0.5.6 networkx==3.2.1 rich==13.7.1 --no-input","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-05T18:20:45.619930Z","iopub.execute_input":"2025-09-05T18:20:45.620284Z","iopub.status.idle":"2025-09-05T18:22:23.947493Z","shell.execute_reply.started":"2025-09-05T18:20:45.620255Z","shell.execute_reply":"2025-09-05T18:22:23.946334Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install faiss-cpu","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:39:41.305760Z","iopub.execute_input":"2025-09-06T05:39:41.306086Z","iopub.status.idle":"2025-09-06T05:39:48.052368Z","shell.execute_reply.started":"2025-09-06T05:39:41.306064Z","shell.execute_reply":"2025-09-06T05:39:48.051514Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install open-clip-torch==2.24","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:40:21.889160Z","iopub.execute_input":"2025-09-06T05:40:21.889745Z","iopub.status.idle":"2025-09-06T05:41:59.372352Z","shell.execute_reply.started":"2025-09-06T05:40:21.889716Z","shell.execute_reply":"2025-09-06T05:41:59.370875Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Libraries","metadata":{}},{"cell_type":"code","source":"\n\nimport os, gc, math, time, random, json, pathlib, collections, warnings\nwarnings.filterwarnings(\"ignore\")\n\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\n\n# Viz\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nimport networkx as nx\n\n# kNN\nimport faiss\n\n# CLIP\nimport open_clip\n\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.preprocessing import LabelEncoder","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:48:30.789003Z","iopub.execute_input":"2025-09-06T05:48:30.790116Z","iopub.status.idle":"2025-09-06T05:48:43.223617Z","shell.execute_reply.started":"2025-09-06T05:48:30.790064Z","shell.execute_reply":"2025-09-06T05:48:43.222737Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n\nSEED = 42\nrandom.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)\ntorch.backends.cudnn.benchmark = True\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nprint(\"Device:\", DEVICE)\n\nclass CFG:\n    DATA_DIR = \"/kaggle/input/h-and-m-personalized-fashion-recommendations\"\n    IMG_DIR  = \"/kaggle/input/h-and-m-personalized-fashion-recommendations/images\"\n    WORK_DIR = \"/kaggle/working\"\n\n    # subset for speed (tune up later)\n    MAX_USERS = 20000\n    MAX_ITEMS = 80000\n    MIN_USER_INTERACTIONS = 5\n    MIN_ITEM_INTERACTIONS = 5\n\n    USE_TIME_SPLIT = True  # 7-day holdout\n\n    # CLIP\n    CLIP_MODEL = \"ViT-B-32\"\n    CLIP_PRETRAIN = \"laion2b_s34b_b79k\"\n    CLIP_BATCH = 128\n    IMG_SIZE = 224\n\n    # II graph fusion\n    TOPK_II = 20\n    ALPHA = 0.5  # image\n    BETA  = 0.4  # text\n    GAMMA = 0.1  # co-occur\n\n    # LightGCN\n    EMBED_DIM = 64\n    LAYERS = 3\n    LR = 1e-3\n    WEIGHT_DECAY = 0.0\n    BATCH_SIZE = 4096\n    EPOCHS = 5\n\n    # losses\n    LAMBDA_G = 0.1\n    TAU = 0.4\n\n    # eval\n    K_EVAL = 20\n\ncfg = CFG()\nprint(cfg.__dict__)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:48:51.254841Z","iopub.execute_input":"2025-09-06T05:48:51.255767Z","iopub.status.idle":"2025-09-06T05:48:51.272026Z","shell.execute_reply.started":"2025-09-06T05:48:51.255728Z","shell.execute_reply":"2025-09-06T05:48:51.271187Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"ARTICLES_CSV = os.path.join(cfg.DATA_DIR, \"articles.csv\")\nCUSTOMERS_CSV = os.path.join(cfg.DATA_DIR, \"customers.csv\")\nTRANS_CSV    = os.path.join(cfg.DATA_DIR, \"transactions_train.csv\")\n\narticles = pd.read_csv(ARTICLES_CSV)\ncustomers = pd.read_csv(CUSTOMERS_CSV)\ntransactions = pd.read_csv(TRANS_CSV, parse_dates=[\"t_dat\"])\n\nprint(articles.shape, customers.shape, transactions.shape)\ndisplay(articles.head(2))\ndisplay(customers.head(2))\ndisplay(transactions.head(2))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:48:53.628997Z","iopub.execute_input":"2025-09-06T05:48:53.629296Z","iopub.status.idle":"2025-09-06T05:50:31.709705Z","shell.execute_reply.started":"2025-09-06T05:48:53.629274Z","shell.execute_reply":"2025-09-06T05:50:31.707337Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"daily = transactions.groupby(transactions[\"t_dat\"].dt.date).size()\nplt.figure(figsize=(10,3)); daily.plot(); plt.title(\"Transactions per day\"); plt.tight_layout(); plt.show()\n\nif \"product_type_name\" in articles.columns:\n    top_types = articles[\"product_type_name\"].value_counts().head(15)\n    plt.figure(figsize=(8,4)); sns.barplot(x=top_types.values, y=top_types.index)\n    plt.title(\"Top product types in catalog\"); plt.tight_layout(); plt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:51:07.902834Z","iopub.execute_input":"2025-09-06T05:51:07.903901Z","iopub.status.idle":"2025-09-06T05:51:28.967452Z","shell.execute_reply.started":"2025-09-06T05:51:07.903867Z","shell.execute_reply":"2025-09-06T05:51:28.966344Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 4) Filter cold users/items, map IDs, time split","metadata":{}},{"cell_type":"code","source":"user_cnt = transactions[\"customer_id\"].value_counts()\nitem_cnt = transactions[\"article_id\"].value_counts()\n\nkeep_users = user_cnt[user_cnt >= cfg.MIN_USER_INTERACTIONS].index\nkeep_items = item_cnt[item_cnt >= cfg.MIN_ITEM_INTERACTIONS].index\n\ndf = transactions[transactions[\"customer_id\"].isin(keep_users) & transactions[\"article_id\"].isin(keep_items)].copy()\n\nif cfg.MAX_USERS:\n    sel_users = set(df[\"customer_id\"].drop_duplicates().sample(min(cfg.MAX_USERS, df[\"customer_id\"].nunique()), random_state=SEED))\n    df = df[df[\"customer_id\"].isin(sel_users)]\nif cfg.MAX_ITEMS:\n    sel_items = set(df[\"article_id\"].drop_duplicates().sample(min(cfg.MAX_ITEMS, df[\"article_id\"].nunique()), random_state=SEED))\n    df = df[df[\"article_id\"].isin(sel_items)]\n\nuser_le = LabelEncoder().fit(df[\"customer_id\"])\nitem_le = LabelEncoder().fit(df[\"article_id\"])\ndf[\"uid\"] = user_le.transform(df[\"customer_id\"])\ndf[\"iid\"] = item_le.transform(df[\"article_id\"])\n\nn_users = df[\"uid\"].nunique()\nn_items = df[\"iid\"].nunique()\nprint(\"Users:\", n_users, \"Items:\", n_items, \"Interactions:\", len(df))\n\ntmax = df[\"t_dat\"].max()\nif cfg.USE_TIME_SPLIT:\n    cutoff = tmax - pd.Timedelta(days=7)\n    train_df = df[df[\"t_dat\"] <= cutoff]\n    test_df  = df[df[\"t_dat\"] > cutoff]\n    cutoff_val = cutoff - pd.Timedelta(days=3)\n    val_df = train_df[train_df[\"t_dat\"] > cutoff_val]\n    train_df = train_df[train_df[\"t_dat\"] <= cutoff_val]\nelse:\n    train_df, tail = train_test_split(df, test_size=0.2, random_state=SEED)\n    val_df, test_df = train_test_split(tail, test_size=0.5, random_state=SEED)\n\nprint(\"Train/Val/Test:\", len(train_df), len(val_df), len(test_df))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:52:00.725344Z","iopub.execute_input":"2025-09-06T05:52:00.725648Z","iopub.status.idle":"2025-09-06T05:52:40.347590Z","shell.execute_reply.started":"2025-09-06T05:52:00.725628Z","shell.execute_reply":"2025-09-06T05:52:40.346711Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 5) Build per-user positives & visualize distribution","metadata":{}},{"cell_type":"code","source":"from collections import defaultdict\n\ndef build_user_pos(df_part):\n    pos = 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\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)\n\nlens = [len(v) for v in user_pos_train.values()]\nplt.figure(figsize=(6,3)); sns.histplot(lens, bins=50); plt.title(\"Train positives per user\"); plt.tight_layout(); plt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:52:48.564263Z","iopub.execute_input":"2025-09-06T05:52:48.564568Z","iopub.status.idle":"2025-09-06T05:52:49.204547Z","shell.execute_reply.started":"2025-09-06T05:52:48.564546Z","shell.execute_reply":"2025-09-06T05:52:49.203804Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 6) Prepare CLIP inputs (texts & image paths)","metadata":{}},{"cell_type":"code","source":"# Align articles to filtered items\nart = articles[articles[\"article_id\"].isin(item_le.inverse_transform(np.arange(n_items)))].copy()\nart[\"iid\"] = item_le.transform(art[\"article_id\"])\nart = art.set_index(\"iid\").sort_index()\n\ndef build_text(row):\n    parts = []\n    if isinstance(row.get(\"detail_desc\", None), str) and len(row[\"detail_desc\"])>0:\n        parts.append(row[\"detail_desc\"][:200])\n    if isinstance(row.get(\"product_type_name\", None), str):\n        parts.append(row[\"product_type_name\"])\n    if isinstance(row.get(\"index_name\", None), str):\n        parts.append(row[\"index_name\"])\n    return \". \".join(parts) if parts else \"fashion item\"\n\ntexts = [build_text(art.loc[i].to_dict()) for i in range(n_items)]\n\ndef article_id_to_path(article_id):\n    s = str(article_id).zfill(10)\n    return os.path.join(cfg.IMG_DIR, s[:3], s + \".jpg\")\n\nimg_paths = [article_id_to_path(int(item_le.inverse_transform([i])[0])) for i in range(n_items)]\nprint(texts[0][:120], \"\\n\", img_paths[0])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:53:30.804498Z","iopub.execute_input":"2025-09-06T05:53:30.804889Z","iopub.status.idle":"2025-09-06T05:54:44.534703Z","shell.execute_reply.started":"2025-09-06T05:53:30.804861Z","shell.execute_reply":"2025-09-06T05:54:44.533799Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 7) CLIP: encode text & images, compute grounding score g","metadata":{}},{"cell_type":"code","source":"clip_model, _, clip_preprocess = open_clip.create_model_and_transforms(cfg.CLIP_MODEL, pretrained=cfg.CLIP_PRETRAIN, device=DEVICE)\nclip_tokenizer = open_clip.get_tokenizer(cfg.CLIP_MODEL)\n\ndef encode_texts(texts, batch=512):\n    all_feats = []\n    for i in tqdm(range(0, len(texts), batch), desc=\"CLIP Text\"):\n        tok = clip_tokenizer(texts[i:i+batch]).to(DEVICE)\n        with torch.no_grad(), torch.cuda.amp.autocast(enabled=(DEVICE=='cuda')):\n            feats = clip_model.encode_text(tok)\n        feats = F.normalize(feats, dim=1)\n        all_feats.append(feats.detach().cpu())\n    return torch.cat(all_feats, dim=0)\n\nfrom PIL import Image\n\ndef load_image_safe(p):\n    try:\n        with Image.open(p) as im:\n            im = im.convert(\"RGB\")\n        return clip_preprocess(im)\n    except:\n        return None\n\ndef encode_images(paths, batch=cfg.CLIP_BATCH):\n    all_feats, batch_imgs = [], []\n    for i, p in enumerate(tqdm(paths, desc=\"CLIP Image\")):\n        img = load_image_safe(p)\n        if img is None:\n            batch_imgs.append(torch.zeros(3, cfg.IMG_SIZE, cfg.IMG_SIZE))\n        else:\n            batch_imgs.append(img)\n        if len(batch_imgs)==batch or i==len(paths)-1:\n            imgs = torch.stack(batch_imgs).to(DEVICE)\n            with torch.no_grad(), torch.cuda.amp.autocast(enabled=(DEVICE=='cuda')):\n                feats = clip_model.encode_image(imgs)\n            feats = F.normalize(feats, dim=1)\n            all_feats.append(feats.detach().cpu())\n            batch_imgs = []\n    return torch.cat(all_feats, dim=0)\n\nZ_txt = encode_texts(texts).numpy().astype(\"float32\")\nZ_img = encode_images(img_paths).numpy().astype(\"float32\")\n\ndef rowwise_cos(a, b): return (a*b).sum(-1)\ng = rowwise_cos(Z_img, Z_txt)\ng = np.clip(g, 0.0, 1.0).astype(\"float32\")\nprint(\"Z_img:\", Z_img.shape, \"Z_txt:\", Z_txt.shape, \"g mean:\", g.mean())\n\nplt.figure(figsize=(6,3)); sns.histplot(g, bins=50); plt.title(\"Grounding score g\"); plt.tight_layout(); plt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T05:55:08.308179Z","iopub.execute_input":"2025-09-06T05:55:08.308536Z","iopub.status.idle":"2025-09-06T09:46:52.166938Z","shell.execute_reply.started":"2025-09-06T05:55:08.308510Z","shell.execute_reply":"2025-09-06T09:46:52.164129Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 8) Peak Items with g","metadata":{}},{"cell_type":"code","source":"from PIL import Image\ndef show_examples(idx_list, cols=5):\n    rows = math.ceil(len(idx_list)/cols)\n    plt.figure(figsize=(3*cols, 3*rows))\n    for k, idx in enumerate(idx_list):\n        p = img_paths[idx]\n        try:\n            with Image.open(p) as im: im = im.convert(\"RGB\")\n        except: \n            im = Image.new(\"RGB\", (224,224), (200,200,200))\n        plt.subplot(rows, cols, k+1)\n        plt.imshow(im); plt.axis(\"off\")\n        title = (texts[idx][:35] + \"...\") if len(texts[idx])>35 else texts[idx]\n        plt.title(f\"iid={idx}  g={g[idx]:.2f}\\n{title}\", fontsize=9)\n    plt.tight_layout(); plt.show()\n\nshow_examples(random.sample(range(n_items), k=min(10, n_items)))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T09:48:14.129921Z","iopub.execute_input":"2025-09-06T09:48:14.130585Z","iopub.status.idle":"2025-09-06T09:48:17.709547Z","shell.execute_reply.started":"2025-09-06T09:48:14.130500Z","shell.execute_reply":"2025-09-06T09:48:17.708476Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 9) Build item–item kNN (image, text), co-occurrence, and fuse with grounding","metadata":{}},{"cell_type":"code","source":"def faiss_knn(x, k):\n    index = faiss.IndexFlatIP(x.shape[1])\n    faiss.normalize_L2(x)\n    index.add(x)\n    sims, ids = index.search(x, k+1)  # includes self\n    return sims[:,1:], ids[:,1:]\n\nprint(\"kNN img...\")\nimg_sims, img_ids = faiss_knn(Z_img.copy(), cfg.TOPK_II)\nprint(\"kNN txt...\")\ntxt_sims, txt_ids = faiss_knn(Z_txt.copy(), cfg.TOPK_II)\n\n# co-occur (Jaccard) from TRAIN\nuser_baskets = collections.defaultdict(list)\nfor u,i in zip(train_df[\"uid\"].values, train_df[\"iid\"].values):\n    user_baskets[int(u)].append(int(i))\n\nitem_user = collections.defaultdict(set)\nfor u, items in user_baskets.items():\n    for it in items:\n        item_user[it].add(u)\n\nco_ids = np.zeros_like(img_ids)\nco_sims = np.zeros_like(img_sims)\nfrom collections import Counter\nfor i in tqdm(range(n_items), desc=\"Co-occur kNN\"):\n    users_i = item_user.get(i, set())\n    co_counter = Counter()\n    for u in users_i:\n        for j in user_baskets[u]:\n            if j!=i: co_counter[j]+=1\n    if len(co_counter)==0:\n        co_ids[i]=img_ids[i]; co_sims[i]=0.0; continue\n    top = co_counter.most_common(cfg.TOPK_II*2)\n    jscores, jidx = [], []\n    for j,cij in top:\n        u_j = item_user.get(j, set())\n        inter = cij\n        union = len(users_i) + len(u_j) - inter\n        jacc = inter / union if union>0 else 0.0\n        jscores.append(jacc); jidx.append(j)\n    order = np.argsort(-np.array(jscores))[:cfg.TOPK_II]\n    sel_idx = np.array(jidx)[order]\n    sel_sim = np.array(jscores)[order]\n    if len(sel_idx)<cfg.TOPK_II:\n        need = cfg.TOPK_II - len(sel_idx)\n        sel_idx = np.concatenate([sel_idx, img_ids[i,:need]])\n        sel_sim = np.concatenate([sel_sim, np.zeros(need)])\n    co_ids[i]  = sel_idx\n    co_sims[i] = sel_sim\n\ndef fuse_and_gate(img_ids, img_sims, txt_ids, txt_sims, co_ids, co_sims, g, alpha, beta, gamma):\n    rows, cols, vals = [], [], []\n    for i in range(n_items):\n        nbrs = {}\n        for ids, sims, w in [(img_ids, img_sims, alpha), (txt_ids, txt_sims, beta), (co_ids, co_sims, gamma)]:\n            for j, s in zip(ids[i], sims[i]):\n                nbrs[j] = nbrs.get(j, 0.0) + w * float(s)\n        for j, s in nbrs.items():\n            w = s * float(0.5*(g[i] + g[j]))\n            if w>0:\n                rows.append(i); cols.append(j); vals.append(w)\n    return np.array(rows), np.array(cols), np.array(vals, dtype=\"float32\")\n\nrows, cols, vals = fuse_and_gate(img_ids, img_sims, txt_ids, txt_sims, co_ids, co_sims, g, cfg.ALPHA, cfg.BETA, cfg.GAMMA)\nprint(\"II edges:\", len(vals))\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])\n    deg = np.bincount(all_r, weights=all_v, minlength=n) + 1e-8\n    deg2 = np.sqrt(deg)\n    norm_v = all_v / (deg2[all_r] * deg2[all_c])\n    idx = np.vstack([all_r, all_c])\n    A = torch.sparse_coo_tensor(indices=torch.tensor(idx, dtype=torch.long),\n                                values=torch.tensor(norm_v, dtype=torch.float32),\n                                size=(n, n))\n    return A.coalesce()\n\nA_II = build_norm_adj(n_items, rows, cols, vals)\nA_II\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T09:49:07.092656Z","iopub.execute_input":"2025-09-06T09:49:07.093079Z","iopub.status.idle":"2025-09-06T09:51:01.048218Z","shell.execute_reply.started":"2025-09-06T09:49:07.093053Z","shell.execute_reply":"2025-09-06T09:51:01.047294Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 10) Build User–Item adjacency; lift II to total graph","metadata":{}},{"cell_type":"code","source":"n_total = n_users + n_items\n\ndef build_ui_norm_adj(train_df, n_users, n_items):\n    uu = train_df[\"uid\"].values.astype(\"int64\")\n    ii = train_df[\"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\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 = np.vstack([rows, cols])\n    A = torch.sparse_coo_tensor(indices=torch.tensor(idx, dtype=torch.long),\n                                values=torch.tensor(norm_v, dtype=torch.float32),\n                                size=(n_users+n_items, n_users+n_items))\n    return A.coalesce()\n\nA_UI = build_ui_norm_adj(train_df, n_users, n_items)\n\ndef lift_item_adj_to_total(A_II, n_users, n_items):\n    idx = A_II.indices()\n    val = A_II.values()\n    idx_lift = torch.vstack([idx[0]+n_users, idx[1]+n_users])\n    A = torch.sparse_coo_tensor(idx_lift, val, (n_users+n_items, n_users+n_items)).coalesce()\n    return A\n\nA_II_total = lift_item_adj_to_total(A_II, n_users, n_items)\nA_UI, A_II_total = A_UI.to(DEVICE), A_II_total.to(DEVICE)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T09:51:56.407893Z","iopub.execute_input":"2025-09-06T09:51:56.409185Z","iopub.status.idle":"2025-09-06T09:51:57.306835Z","shell.execute_reply.started":"2025-09-06T09:51:56.409151Z","shell.execute_reply":"2025-09-06T09:51:57.305717Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 11) Visualize a small neighborhood (graph picture)","metadata":{}},{"cell_type":"code","source":"def visualize_user_neighborhood(u, max_items=30):\n    items = list(user_pos_train.get(u, []))[:max_items]\n    G = nx.Graph()\n    G.add_node(f\"U{u}\", bipartite=0)\n    for it in items:\n        G.add_node(f\"I{it}\", bipartite=1)\n        G.add_edge(f\"U{u}\", f\"I{it}\", color=\"tab:blue\")\n        try:\n            nbrs = img_ids[it][:3]  # quick visual neighbors from image-kNN\n        except:\n            nbrs = []\n        for j in nbrs:\n            G.add_node(f\"I{j}\", bipartite=1)\n            G.add_edge(f\"I{it}\", f\"I{j}\", color=\"tab:orange\")\n    colors = [G[u][v]['color'] for u,v in G.edges()]\n    pos = nx.spring_layout(G, seed=SEED, k=0.7)\n    plt.figure(figsize=(8,6))\n    nx.draw(G, pos, with_labels=False, node_size=80, edge_color=colors)\n    plt.title(f\"Neighborhood around user {u}\")\n    plt.show()\n\nvisualize_user_neighborhood(u=random.randint(0, n_users-1))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T09:52:27.342378Z","iopub.execute_input":"2025-09-06T09:52:27.342942Z","iopub.status.idle":"2025-09-06T09:52:27.580494Z","shell.execute_reply.started":"2025-09-06T09:52:27.342910Z","shell.execute_reply":"2025-09-06T09:52:27.579346Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 12) LightGCN with BPR + grounding loss","metadata":{}},{"cell_type":"code","source":"class LightGCN(nn.Module):\n    def __init__(self, n_users, n_items, d=64, layers=3):\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\n    def propagate(self, A_ui, A_ii):\n        E0 = self.emb.weight\n        acc = [E0]\n        E = E0\n        for _ in range(self.layers):\n            E = 0.5*torch.sparse.mm(A_ui, E) + 0.5*torch.sparse.mm(A_ii, E)\n            acc.append(E)\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        U = E[:self.n_users]\n        I = E[self.n_users:]\n        return U, I\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()\n\nuser_pos_array = {u: np.array(list(items)) for u, items in user_pos_train.items()}\nall_items = np.arange(n_items)\n\ndef sample_batch(batch_size=4096):\n    users = np.random.choice(list(user_pos_array.keys()), size=batch_size, replace=True)\n    pos_items = np.array([np.random.choice(user_pos_array[u]) for u in users])\n    neg_items = []\n    for u in users:\n        while True:\n            j = np.random.randint(0, n_items)\n            if j not in user_pos_array[u]:\n                neg_items.append(j); break\n    return users, pos_items, np.array(neg_items)\n\ng_t = torch.tensor(g, dtype=torch.float32, device=DEVICE)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T09:53:00.748546Z","iopub.execute_input":"2025-09-06T09:53:00.748955Z","iopub.status.idle":"2025-09-06T09:53:01.116006Z","shell.execute_reply.started":"2025-09-06T09:53:00.748927Z","shell.execute_reply":"2025-09-06T09:53:01.115124Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 13) Evaluation utilities (Recall/NDCG, Coverage, Grounding)","metadata":{}},{"cell_type":"code","source":"@torch.no_grad()\ndef compute_embeddings(model):\n    U, I = model(A_UI, A_II_total)\n    return U, I\n\ndef evaluate_split(model, user_pos_true, K=20):\n    model.eval()\n    U, I = compute_embeddings(model)\n    recalls, ndcgs = [], []\n    users_list = list(user_pos_true.keys())\n    for start in range(0, len(users_list), 512):\n        batch_users = users_list[start:start+512]\n        u_emb = U[batch_users]\n        scores = torch.matmul(u_emb, I.T)\n        # mask train positives\n        for bi, u in enumerate(batch_users):\n            train_pos = list(user_pos_train.get(u, []))\n            if train_pos:\n                scores[bi, torch.tensor(train_pos, device=DEVICE)] = -1e9\n        topk = torch.topk(scores, k=K, dim=1).indices.cpu().numpy()\n        for bi, u in enumerate(batch_users):\n            truth = set(user_pos_true.get(u, []))\n            if not truth: continue\n            preds = list(topk[bi])\n            hit = len(set(preds) & truth)\n            recalls.append(hit / min(K, len(truth)))\n            dcg = 0.0\n            for rank, it in enumerate(preds, start=1):\n                if it in truth:\n                    dcg += 1.0 / math.log2(rank+1)\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    R = float(np.mean(recalls)) if recalls else 0.0\n    N = float(np.mean(ndcgs)) if ndcgs else 0.0\n    return R, N\n\ndef coverage_at_k(model, K=20, sample_users=2000):\n    model.eval()\n    U, I = compute_embeddings(model)\n    users_list = list(user_pos_test.keys())[:sample_users]\n    seen = set()\n    for start in range(0, len(users_list), 512):\n        batch_users = users_list[start:start+512]\n        u_emb = U[batch_users]\n        scores = torch.matmul(u_emb, I.T)\n        for bi, u in enumerate(batch_users):\n            train_pos = list(user_pos_train.get(u, []))\n            if train_pos:\n                scores[bi, torch.tensor(train_pos, device=DEVICE)] = -1e9\n        topk = torch.topk(scores, k=K, dim=1).indices.cpu().numpy()\n        for row in topk: seen.update(row.tolist())\n    return len(seen) / n_items\n\ndef grounding_at_k(model, K=20, sample_users=2000):\n    model.eval()\n    U, I = compute_embeddings(model)\n    users_list = list(user_pos_test.keys())[:sample_users]\n    vals = []\n    for start in range(0, len(users_list), 512):\n        batch_users = users_list[start:start+512]\n        u_emb = U[batch_users]\n        scores = torch.matmul(u_emb, I.T)\n        for bi, u in enumerate(batch_users):\n            train_pos = list(user_pos_train.get(u, []))\n            if train_pos:\n                scores[bi, torch.tensor(train_pos, device=DEVICE)] = -1e9\n        topk = torch.topk(scores, k=K, dim=1).indices\n        vals.append(g_t[topk].mean().item())\n    return float(np.mean(vals)) if vals else 0.0\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T09:53:34.635313Z","iopub.execute_input":"2025-09-06T09:53:34.635611Z","iopub.status.idle":"2025-09-06T09:53:34.655143Z","shell.execute_reply.started":"2025-09-06T09:53:34.635590Z","shell.execute_reply":"2025-09-06T09:53:34.654218Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 14) Train loop (BPR + grounding)","metadata":{}},{"cell_type":"code","source":"model = LightGCN(n_users, n_items, d=cfg.EMBED_DIM, layers=cfg.LAYERS).to(DEVICE)\nopt = torch.optim.Adam(model.parameters(), lr=cfg.LR, weight_decay=cfg.WEIGHT_DECAY)\n\ndef train(epochs=cfg.EPOCHS, log_every=200):\n    for ep in range(1, epochs+1):\n        model.train()\n        losses = []\n        steps = max(1, len(train_df)//cfg.BATCH_SIZE)\n        for it in range(steps):\n            users, pos_items, neg_items = sample_batch(cfg.BATCH_SIZE)\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            Ue, Ie = model(A_UI, A_II_total)\n            u_e = Ue[users_t]; i_pos_e = Ie[pos_t]; i_neg_e = Ie[neg_t]\n\n            loss_bpr = bpr_loss(u_e, i_pos_e, i_neg_e)\n            loss_gr  = cfg.LAMBDA_G * torch.relu(cfg.TAU - g_t[pos_t]).mean()\n            loss = loss_bpr + loss_gr\n\n            opt.zero_grad(set_to_none=True)\n            loss.backward()\n            opt.step()\n\n            losses.append(loss.item())\n            if (it+1)%log_every==0:\n                print(f\"ep{ep} step{it+1}/{steps} loss={np.mean(losses):.4f}\")\n\n        # epoch eval\n        Rv, Nv = evaluate_split(model, user_pos_val, K=cfg.K_EVAL)\n        Rt, Nt = evaluate_split(model, user_pos_test, K=cfg.K_EVAL)\n        Cov = coverage_at_k(model, K=cfg.K_EVAL)\n        Grd = grounding_at_k(model, K=cfg.K_EVAL)\n        print(f\"[E{ep}] Val R@{cfg.K_EVAL}={Rv:.4f} N@{cfg.K_EVAL}={Nv:.4f} | \"\n              f\"Test R@{cfg.K_EVAL}={Rt:.4f} N@{cfg.K_EVAL}={Nt:.4f} | \"\n              f\"Cov@{cfg.K_EVAL}={Cov:.4f} | Ground@{cfg.K_EVAL}={Grd:.3f}\")\n\ntrain()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T09:54:05.343909Z","iopub.execute_input":"2025-09-06T09:54:05.345206Z","iopub.status.idle":"2025-09-06T11:04:28.300736Z","shell.execute_reply.started":"2025-09-06T09:54:05.345173Z","shell.execute_reply":"2025-09-06T11:04:28.299689Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 15) Inference & visualization for one user","metadata":{}},{"cell_type":"code","source":"from PIL import Image\n\n@torch.no_grad()\ndef recommend_for_user(u, K=10, show=True):\n    model.eval()\n    U, I = compute_embeddings(model)\n    scores = torch.matmul(U[u:u+1], I.T).squeeze(0)\n    train_pos = list(user_pos_train.get(u, []))\n    if train_pos:\n        scores[torch.tensor(train_pos, device=DEVICE)] = -1e9\n    topk = torch.topk(scores, k=K).indices.cpu().numpy().tolist()\n    if show:\n        cols = 5; rows = math.ceil(K/cols)\n        plt.figure(figsize=(3*cols,3*rows))\n        for idx, it in enumerate(topk):\n            p = img_paths[it]\n            try:\n                with Image.open(p) as im: im = im.convert(\"RGB\")\n            except:\n                im = Image.new(\"RGB\", (224,224), (200,200,200))\n            plt.subplot(rows, cols, idx+1)\n            plt.imshow(im); plt.axis(\"off\")\n            title = (texts[it][:30]+\"...\") if len(texts[it])>30 else texts[it]\n            plt.title(f\"iid={it}  g={g[it]:.2f}\\n{title}\", fontsize=9)\n        plt.tight_layout(); plt.show()\n    return topk\n\nu_example = random.randint(0, n_users-1)\nprint(\"User:\", u_example)\n_ = recommend_for_user(u_example, K=10, show=True)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-06T11:05:22.423641Z","iopub.execute_input":"2025-09-06T11:05:22.424032Z","iopub.status.idle":"2025-09-06T11:05:29.966381Z","shell.execute_reply.started":"2025-09-06T11:05:22.424004Z","shell.execute_reply":"2025-09-06T11:05:29.965311Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}