{"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":[{"sourceId":31254,"databundleVersionId":3103714,"sourceType":"competition"}],"dockerImageVersionId":31236,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"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},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install polars fastembed-gpu ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T11:22:07.073204Z","iopub.execute_input":"2026-01-07T11:22:07.073882Z","iopub.status.idle":"2026-01-07T11:22:21.592109Z","shell.execute_reply.started":"2026-01-07T11:22:07.073851Z","shell.execute_reply":"2026-01-07T11:22:21.591153Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 1: Environment Setup & Data Loading\nimport os\nimport gc\nimport polars as pl\nimport numpy as np\nimport torch\nfrom datetime import datetime, timedelta\n\n# ⚙️ CONFIG\n# Standard Kaggle path for H&M dataset\nDATA_DIR = \"/kaggle/input/h-and-m-personalized-fashion-recommendations\"\n# We only use the last 4 weeks for training to capture \"current trends\"\n# This significantly speeds up training and improves relevance\nTRAIN_START_DATE = \"2020-08-22\" \n\n# Check Device\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"🚀 Device: {DEVICE}\")\nif torch.cuda.device_count() > 1:\n    print(f\"🔥 Multi-GPU Detected: {torch.cuda.device_count()} x {torch.cuda.get_device_name(0)}\")\n\ndef load_data():\n    print(\"📂 Loading Metadata...\")\n    # Load Articles (Items)\n    shema_overrides = {\"article_id\":pl.datatypes.String}\n    # We treat article_id as string to preserve leading zeros\n    articles = pl.read_csv(f\"{DATA_DIR}/articles.csv\", schema_overrides=shema_overrides)\n    \n    # Load Customers (Users)\n    customers = pl.read_csv(f\"{DATA_DIR}/customers.csv\")\n    \n    print(\"📉 Loading & Filtering Transactions...\")\n    \n    # Transactions are huge. We filter immediately upon load if possible, \n    # but with Polars, doing a filter after load is still very fast.\n    transactions = pl.read_csv(f\"{DATA_DIR}/transactions_train.csv\",schema_overrides=shema_overrides )\n    \n    # Filter for recent history (Last ~1 month)\n    # This reduces noise from 2018/2019 trends that are no longer relevant\n    transactions = transactions.filter(\n        pl.col(\"t_dat\") >= TRAIN_START_DATE\n    )\n    \n    print(f\"✅ Data Loaded:\")\n    print(f\"   - Articles: {len(articles):,}\")\n    print(f\"   - Customers: {len(customers):,}\")\n    print(f\"   - Recent Transactions: {len(transactions):,}\")\n    \n    return articles, customers, transactions\n\n# Execute\narticles, customers, transactions = load_data()\ngc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T11:22:21.593834Z","iopub.execute_input":"2026-01-07T11:22:21.594137Z","iopub.status.idle":"2026-01-07T11:23:02.026254Z","shell.execute_reply.started":"2026-01-07T11:22:21.594080Z","shell.execute_reply":"2026-01-07T11:23:02.025549Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 2: Feature Engineering - Metadata & ID Mapping\nimport polars as pl\nimport os\n\ndef process_metadata(articles, customers, transactions):\n    print(\"🛠️ Processing Metadata...\")\n    \n    # --- 1. Article Prep (Items) ---\n    # Construct \"Rich Text\" for the Text Encoder\n    # We combine key attributes to give the embedding model semantic context\n    articles = articles.with_columns([\n        (\n            pl.col(\"prod_name\") + \" \" + \n            pl.col(\"product_type_name\") + \" \" + \n            pl.col(\"graphical_appearance_name\") + \" \" + \n            pl.col(\"colour_group_name\") + \" \" + \n            pl.col(\"detail_desc\").fill_null(\"\")\n        ).alias(\"semantic_text\"),\n        \n        # Construct Image Path: H&M images are stored as 'images/012/0123456789.jpg'\n        # We need the first 3 digits for the subfolder\n        pl.format(\n            \"{}/images/{}/{}.jpg\", \n            pl.lit(DATA_DIR), \n            pl.col(\"article_id\").str.slice(0, 3), \n            pl.col(\"article_id\")\n        ).alias(\"image_path\")\n    ])\n    \n    # Create Integer ID Mappings\n    # We use a global index (1-based) so 0 can be reserved for padding/masking\n    unique_article_ids = articles[\"article_id\"].unique().sort()\n    article_map = {aid: i+1 for i, aid in enumerate(unique_article_ids)}\n    \n    unique_customer_ids = customers[\"customer_id\"].unique().sort()\n    customer_map = {cid: i+1 for i, cid in enumerate(unique_customer_ids)}\n    \n    # Apply Mappings\n    print(\"🔄 Mapping IDs to Integers...\")\n    \n    # Map Articles\n    articles = articles.with_columns(\n        pl.col(\"article_id\").replace(article_map, default=0).cast(pl.Int32).alias(\"item_idx\")\n    )\n    \n    # Map Transactions (This is the heavy part)\n    transactions = transactions.with_columns([\n        pl.col(\"article_id\").replace(article_map, default=0).cast(pl.Int32).alias(\"item_idx\"),\n        pl.col(\"customer_id\").replace(customer_map, default=0).cast(pl.Int32).alias(\"user_idx\")\n    ])\n    \n    # Drop rows where mapping failed (if any)\n    transactions = transactions.filter((pl.col(\"item_idx\") > 0) & (pl.col(\"user_idx\") > 0))\n\n    print(f\"✅ Processing Complete.\")\n    print(f\"   - Max Item Idx: {articles['item_idx'].max():,}\")\n    print(f\"   - Max User Idx: {transactions['user_idx'].max():,}\")\n    \n    return articles, transactions, article_map\n\n# Execute\narticles_proc, transactions_proc, article_map = process_metadata(articles, customers, transactions)\n\n# Sanity Check\nprint(\"\\n👀 Sample Article Data:\")\nprint(articles_proc.select([\"article_id\", \"item_idx\", \"semantic_text\", \"image_path\"]).head(3))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T11:23:05.319196Z","iopub.execute_input":"2026-01-07T11:23:05.320052Z","iopub.status.idle":"2026-01-07T11:23:07.740598Z","shell.execute_reply.started":"2026-01-07T11:23:05.320020Z","shell.execute_reply":"2026-01-07T11:23:07.739790Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 3: Optimized Embedding Generation (Text + Image)\nimport torch\nimport torch.nn as nn\nimport torchvision.models as models\nimport torchvision.transforms as transforms\nfrom torch.utils.data import Dataset, DataLoader\nfrom PIL import Image\nfrom fastembed import TextEmbedding\nfrom tqdm import tqdm\nimport numpy as np\n\n# CONFIG\nOUTPUT_DIR = \"artifacts_hm\"\nos.makedirs(OUTPUT_DIR, exist_ok=True)\nBATCH_SIZE = 256  # Increased batch size for T4\nNUM_WORKERS = 4   # Parallel CPU loaders\n\n# --- A. OPTIMIZED IMAGE DATASET ---\nclass HMImageDataset(Dataset):\n    def __init__(self, image_paths, transform):\n        self.image_paths = image_paths\n        self.transform = transform\n        \n    def __len__(self):\n        return len(self.image_paths)\n    \n    def __getitem__(self, idx):\n        path = self.image_paths[idx]\n        try:\n            # We return index so we know where to put the vector later\n            img = Image.open(path).convert('RGB')\n            return idx, self.transform(img), True # True = Found\n        except (FileNotFoundError, OSError):\n            # Return dummy tensor if missing (will be filtered out)\n            return idx, torch.zeros((3, 224, 224)), False # False = Missing\n\n# Setup Model\nprint(\"🖼️ Initializing ResNet50...\")\nresnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)\nresnet = nn.Sequential(*list(resnet.children())[:-1])\nresnet.to(DEVICE)\nresnet.eval()\n\n# Transforms\nimg_transform = transforms.Compose([\n    transforms.Resize((224, 224)),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\ndef generate_image_vectors_fast(articles_df):\n    paths = articles_df[\"image_path\"].to_list()\n    dataset = HMImageDataset(paths, img_transform)\n    \n    # DataLoader handles the parallel I/O!\n    loader = DataLoader(\n        dataset, \n        batch_size=BATCH_SIZE, \n        shuffle=False, \n        num_workers=NUM_WORKERS, \n        pin_memory=True\n    )\n    \n    # Pre-allocate result array (Default to zeros)\n    # This automatically handles the \"missing images\" case by leaving them as 0\n    full_matrix = np.zeros((len(paths), 2048), dtype='float32')\n    \n    print(f\"📸 Processing {len(paths)} images with {NUM_WORKERS} workers...\")\n    \n    with torch.no_grad(), torch.cuda.amp.autocast(): # Mixed precision for speed\n        for batch_idx, batch_imgs, found_mask in tqdm(loader, desc=\"Encoding Images\"):\n            # Filter only valid images to send to GPU\n            valid_mask = found_mask.bool()\n            if valid_mask.sum() == 0: continue\n            \n            valid_imgs = batch_imgs[valid_mask].to(DEVICE)\n            valid_indices = batch_idx[valid_mask].numpy()\n            \n            # Forward Pass\n            emb = resnet(valid_imgs).squeeze()\n            \n            # Save to CPU Matrix\n            full_matrix[valid_indices] = emb.cpu().numpy().astype('float32')\n            \n    return full_matrix\n\n# Execute Fast Image Encoding\nimg_vectors = generate_image_vectors_fast(articles_proc)\nnp.save(f\"{OUTPUT_DIR}/image_vectors.npy\", img_vectors)\nprint(f\"✅ Saved Image Vectors: {img_vectors.shape}\")\n\n# Cleanup Image Resources\ndel resnet, img_vectors\ntorch.cuda.empty_cache()\ngc.collect()\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T11:23:15.740970Z","iopub.execute_input":"2026-01-07T11:23:15.741831Z","iopub.status.idle":"2026-01-07T11:44:44.871356Z","shell.execute_reply.started":"2026-01-07T11:23:15.741795Z","shell.execute_reply":"2026-01-07T11:44:44.870497Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from tqdm import tqdm\n\nprint(\"📝 Initializing Text Embedding (BGE-Small) on GPU...\")\n\nembed_model = TextEmbedding(\n    model_name=\"BAAI/bge-small-en-v1.5\",\n    providers=[\"CUDAExecutionProvider\"]  # 🔥 GPU ENABLED\n)\n\ntexts = articles_proc[\"semantic_text\"].to_list()\ntext_vectors = []\n\nprint(f\"📝 Encoding {len(texts)} descriptions...\")\n\n# fastembed already batches internally, but tqdm needs wrapping\nfor emb in tqdm(\n    embed_model.embed(texts, batch_size=BATCH_SIZE),\n    total=len(texts),\n    desc=\"Encoding Text\"\n):\n    text_vectors.append(emb)\n\ntext_vectors = np.asarray(text_vectors, dtype=\"float32\")\n\nnp.save(f\"{OUTPUT_DIR}/text_vectors.npy\", text_vectors)\nprint(f\"✅ Saved Text Vectors: {text_vectors.shape}\")\n\n# Cleanup\ndel embed_model, text_vectors\ngc.collect()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:24:57.001004Z","iopub.execute_input":"2026-01-07T12:24:57.001863Z","iopub.status.idle":"2026-01-07T12:25:45.291293Z","shell.execute_reply.started":"2026-01-07T12:24:57.001830Z","shell.execute_reply":"2026-01-07T12:25:45.290608Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 4: FAST Sequence Creation (Top 100k Users)\nimport polars as pl\nimport os\n\n# CONFIG\nOUTPUT_DIR = \"artifacts_hm\"\nos.makedirs(OUTPUT_DIR, exist_ok=True)\nMIN_LEN = 3   # User must have bought at least 3 items\nMAX_LEN = 20  # Keep last 20 items\nSAMPLE_SIZE = 100_00 # ⚡ Only process top 10k active users\n\ndef create_sequences_fast(transactions):\n    print(\"✂️  Filtering for active users...\")\n    \n    # 1. Find users with enough activity\n    # We count transactions per user\n    user_counts = (\n        transactions.group_by(\"user_idx\")\n        .count()\n        .filter(pl.col(\"count\") >= MIN_LEN)\n    )\n    \n    print(f\"   Found {len(user_counts):,} valid users (>= {MIN_LEN} items).\")\n    \n    # 2. Sample Top-K Users (Most Active)\n    # Taking the most active users gives the model better signals than random sampling\n    top_users = user_counts.sort(\"count\", descending=True).head(SAMPLE_SIZE)\n    \n    # 3. Filter Transactions\n    # Keep only rows belonging to our Top-K users\n    active_tx = transactions.join(top_users, on=\"user_idx\", how=\"inner\")\n    \n    print(f\"🔗 Grouping {len(active_tx):,} transactions...\")\n    \n    # 4. Sort & Group\n    # Sort by date so the sequence is [Oldest -> Newest]\n    active_tx = active_tx.sort(\"t_dat\")\n    \n    sequences = (\n        active_tx.group_by(\"user_idx\")\n        .agg(pl.col(\"item_idx\"))\n    )\n    \n    # 5. Truncate to Max Length (Keep most recent 20)\n    sequences = sequences.with_columns(\n        pl.col(\"item_idx\").list.slice(-MAX_LEN, MAX_LEN).alias(\"sequence\")\n    )\n    \n    print(f\"✅ Created {len(sequences):,} sequences.\")\n    \n    # Save\n    save_path = f\"{OUTPUT_DIR}/train_sequences.parquet\"\n    sequences.write_parquet(save_path)\n    print(f\"💾 Saved to {save_path}\")\n    \n    return sequences\n\n# Execute\ntrain_sequences = create_sequences_fast(transactions_proc)\n\n# Sanity Check\nprint(\"\\n👀 Sample User History:\")\nprint(train_sequences.head(1))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:25:56.467591Z","iopub.execute_input":"2026-01-07T12:25:56.467901Z","iopub.status.idle":"2026-01-07T12:25:56.631765Z","shell.execute_reply.started":"2026-01-07T12:25:56.467875Z","shell.execute_reply":"2026-01-07T12:25:56.631034Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 5: Production-Grade Model & Dataset\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport numpy as np\nimport polars as pl\n\n# ===========================\n# 1. ROBUST DATASET (Fixed Masking)\n# ===========================\nclass HMDataset(Dataset):\n    def __init__(self, parquet_path, max_len=20):\n        self.data = pl.read_parquet(parquet_path)\n        self.max_len = max_len\n        print(f\"📦 Loaded {len(self.data):,} User Histories\")\n\n    def __len__(self):\n        return len(self.data)\n\n    def __getitem__(self, idx):\n        row = self.data.row(idx, named=True)\n        seq = row['sequence']\n        \n        # 1. Create Inputs/Targets\n        # Input:  [A, B, C]\n        # Target: [B, C, D]\n        if len(seq) < 2:\n            seq = seq + [0] * (2 - len(seq))\n            \n        input_ids = seq[:-1]\n        target_ids = seq[1:]\n        \n        # 2. Smart Truncation (Keep ONLY the last max_len items)\n        # This fixes the bug where mask length could mismatch\n        if len(input_ids) > self.max_len:\n            input_ids = input_ids[-self.max_len:]\n            target_ids = target_ids[-self.max_len:]\n        \n        # 3. Padding Calculation\n        real_len = len(input_ids)\n        pad_len = self.max_len - real_len\n        \n        # 4. Apply Padding\n        if pad_len > 0:\n            input_ids = [0] * pad_len + input_ids\n            target_ids = [0] * pad_len + target_ids\n            # Mask: 0 = Pad, 1 = Valid\n            # We explicitly use the calculated lengths to ensure alignment\n            mask = [0] * pad_len + [1] * real_len\n        else:\n            mask = [1] * self.max_len\n            \n        return {\n            \"input_ids\": torch.tensor(input_ids, dtype=torch.long),\n            \"target_ids\": torch.tensor(target_ids, dtype=torch.long),\n            \"mask\": torch.tensor(mask, dtype=torch.float)\n        }\n\n# ===========================\n# 2. MULTIMODAL MODEL (With Scaling)\n# ===========================\nclass MultimodalTwoTower(nn.Module):\n    def __init__(self, text_vec_path, img_vec_path, hidden_dim=512, n_heads=4, n_layers=2, dropout=0.2, max_len=20):\n        super().__init__()\n        \n        print(\"🧠 Loading Vectors...\")\n        text_vecs = np.load(text_vec_path)\n        img_vecs = np.load(img_vec_path)\n        \n        # --- ENCODERS (Frozen) ---\n        self.text_emb = nn.Embedding.from_pretrained(\n            torch.tensor(text_vecs, dtype=torch.float32), freeze=True, padding_idx=0\n        )\n        self.img_emb = nn.Embedding.from_pretrained(\n            torch.tensor(img_vecs, dtype=torch.float32), freeze=True, padding_idx=0\n        )\n        \n        # --- FUSION ---\n        input_dim = 384 + 2048\n        self.fusion = nn.Sequential(\n            nn.Linear(input_dim, hidden_dim),\n            nn.LayerNorm(hidden_dim),\n            nn.GELU(),\n            nn.Dropout(dropout)\n        )\n        \n        # --- USER TOWER ---\n        # Fixed: Positional embedding now matches max_len\n        self.pos_embedding = nn.Embedding(max_len, hidden_dim)\n        \n        encoder_layer = nn.TransformerEncoderLayer(\n            d_model=hidden_dim, \n            nhead=n_heads, \n            dim_feedforward=hidden_dim*4, \n            dropout=dropout, \n            batch_first=True, \n            norm_first=True\n        )\n        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)\n        \n        # --- HEADS ---\n        # Temperature Scaling for Contrastive Loss (New)\n        self.temperature = nn.Parameter(torch.ones(1) * 0.07)\n        \n        # Repurchase Head\n        self.repurchase_head = nn.Sequential(\n            nn.Linear(hidden_dim, 64),\n            nn.ReLU(),\n            nn.Linear(64, 1)\n        )\n\n    def encode_items(self, item_ids):\n        t = self.text_emb(item_ids)\n        i = self.img_emb(item_ids)\n        cat = torch.cat([t, i], dim=-1)\n        return self.fusion(cat)\n\n    def forward(self, input_ids, mask):\n        x = self.encode_items(input_ids)\n        \n        # Positional Info\n        seq_len = input_ids.size(1)\n        positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)\n        x = x + self.pos_embedding(positions)\n        \n        # Transformer\n        src_key_padding_mask = (mask == 0)\n        causal_mask = nn.Transformer.generate_square_subsequent_mask(seq_len, device=input_ids.device)\n        \n        user_emb = self.transformer(\n            x, \n            mask=causal_mask, \n            src_key_padding_mask=src_key_padding_mask, \n            is_causal=True\n        )\n        \n        rep_logits = self.repurchase_head(user_emb).squeeze(-1)\n        \n        return user_emb, rep_logits\n\nprint(\"✅ Fixed Model & Dataset Classes Defined.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:26:59.317617Z","iopub.execute_input":"2026-01-07T12:26:59.318195Z","iopub.status.idle":"2026-01-07T12:26:59.333940Z","shell.execute_reply.started":"2026-01-07T12:26:59.318166Z","shell.execute_reply":"2026-01-07T12:26:59.332995Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 6: Stable Training Loop (Tuned for Small Data)\nimport torch\nimport torch.optim as optim\nimport torch.nn.functional as F\nfrom tqdm import tqdm\n\n# CONFIG - UPDATED FOR STABILITY\nBATCH_SIZE = 256\nACCUM_STEPS = 1     # Removed accumulation (10k data is small enough)\nLR = 1e-4           # ⚡ Reduced by 10x (Prevents diverging)\nEPOCHS = 3          # ⚡ Reduced (Prevents overfitting)\nTOP_K = 10\nWEIGHT_DECAY = 0.01 # ⚡ Added Regularization\n\n# 1. Setup Data & Model\ndataset = HMDataset(\"artifacts_hm/train_sequences.parquet\")\ndataloader = DataLoader(\n    dataset, \n    batch_size=BATCH_SIZE, \n    shuffle=True, \n    num_workers=2, \n    pin_memory=True,\n    drop_last=True\n)\n\nmodel = MultimodalTwoTower(\n    \"artifacts_hm/text_vectors.npy\", \n    \"artifacts_hm/image_vectors.npy\"\n).to(DEVICE)\n\n# ⚡ Updated Optimizer with Weight Decay\noptimizer = optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\nscaler = torch.amp.GradScaler('cuda')\n\n# Metric Helper\ndef calculate_metrics_flat(scores, k=10):\n    true_scores = scores.diag() \n    ranks = (scores > true_scores.unsqueeze(-1)).sum(dim=1) + 1\n    hits = (ranks <= k).float().mean()\n    mrr = (1.0 / ranks).mean()\n    ndcg = (1.0 / torch.log2(ranks + 1)).mean()\n    return hits.item(), mrr.item(), ndcg.item()\n\nprint(f\"🚀 Starting Stable Training on {DEVICE}...\")\n\nfor epoch in range(EPOCHS):\n    model.train()\n    total_loss = 0\n    metrics_avg = {\"hit\": 0, \"mrr\": 0, \"ndcg\": 0}\n    \n    pbar = tqdm(dataloader, desc=f\"Epoch {epoch+1}/{EPOCHS}\")\n    \n    for i, batch in enumerate(pbar):\n        input_ids = batch['input_ids'].to(DEVICE)\n        target_ids = batch['target_ids'].to(DEVICE)\n        mask = batch['mask'].to(DEVICE)\n        \n        optimizer.zero_grad()\n        \n        with torch.amp.autocast('cuda'):\n            # Forward\n            user_seq, rep_logits = model(input_ids, mask)\n            target_seq = model.encode_items(target_ids)\n            \n            # Flatten & Normalize\n            active_mask = mask.view(-1) == 1\n            user_flat = user_seq.view(-1, 512)[active_mask]\n            target_flat = target_seq.view(-1, 512)[active_mask]\n            \n            user_flat = F.normalize(user_flat, p=2, dim=1)\n            target_flat = F.normalize(target_flat, p=2, dim=1)\n            \n            # Scores\n            scores = torch.matmul(user_flat, target_flat.t())\n            scores = scores / model.temperature.clamp(min=0.01)\n            \n            labels = torch.arange(scores.size(0), device=DEVICE)\n            \n            # Loss\n            rec_loss = F.cross_entropy(scores, labels)\n            \n            # Aux Loss\n            rep_flat = rep_logits.view(-1)[active_mask]\n            aux_target = torch.ones_like(rep_flat)\n            aux_loss = F.binary_cross_entropy_with_logits(rep_flat, aux_target)\n            \n            loss = rec_loss + 0.1 * aux_loss\n\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        \n        # Metrics\n        with torch.no_grad():\n            h, m, n = calculate_metrics_flat(scores.detach(), k=TOP_K)\n            metrics_avg[\"hit\"] += h\n            metrics_avg[\"mrr\"] += m\n            metrics_avg[\"ndcg\"] += n\n\n        total_loss += loss.item()\n        \n        pbar.set_postfix({\n            \"Loss\": f\"{loss.item():.3f}\", \n            \"Hit@10\": f\"{metrics_avg['hit'] / (i+1):.1%}\",\n            \"NDCG\": f\"{metrics_avg['ndcg'] / (i+1):.3f}\"\n        })\n        \n    print(f\"📉 Epoch {epoch+1} Loss: {total_loss / len(dataloader):.4f}\")\n\nprint(\"💾 Saving Model...\")\ntorch.save(model.state_dict(), \"artifacts_hm/hm_model_best.pth\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:27:03.562309Z","iopub.execute_input":"2026-01-07T12:27:03.563162Z","iopub.status.idle":"2026-01-07T12:27:14.163336Z","shell.execute_reply.started":"2026-01-07T12:27:03.563098Z","shell.execute_reply":"2026-01-07T12:27:14.162324Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 7: Fast Candidate Generation (10k Users, New Model)\nimport polars as pl\nimport torch\nimport torch.nn.functional as F\nfrom torch.utils.data import DataLoader, Subset\nfrom tqdm import tqdm\nimport numpy as np\nimport gc\n\n# CONFIG\nTOP_K = 100\nBATCH_SIZE = 4096\nNUM_USERS = 30000\n\ndef generate_candidates_fast(model, dataset, item_vectors_path, text_vectors_path):\n    print(\"🔄 Loading New Model Weights...\")\n    # Load the specific checkpoint we just saved\n    state_dict = torch.load(\"artifacts_hm/hm_model_best.pth\", map_location=DEVICE)\n    model.load_state_dict(state_dict)\n    model.eval()\n    \n    # --- 1. Index Items ---\n    print(\"⚡ Pre-computing Item Index...\")\n    text_vecs = np.load(text_vectors_path)\n    img_vecs = np.load(item_vectors_path)\n    all_item_ids = torch.arange(len(text_vecs), dtype=torch.long)\n    item_loader = DataLoader(all_item_ids, batch_size=4096, shuffle=False)\n    \n    item_embeddings_list = []\n    \n    with torch.no_grad(), torch.amp.autocast('cuda'):\n        for batch_ids in tqdm(item_loader, desc=\"Indexing Items\"):\n            batch_ids = batch_ids.to(DEVICE)\n            t = model.text_emb(batch_ids)\n            i = model.img_emb(batch_ids)\n            # Normalize immediately for Dot Product\n            emb = F.normalize(model.fusion(torch.cat([t, i], dim=-1)), p=2, dim=1)\n            item_embeddings_list.append(emb)\n            \n    item_index = torch.cat(item_embeddings_list, dim=0)\n    \n    # --- 2. User Inference ---\n    print(f\"🔮 Generating Candidates for {NUM_USERS} Users...\")\n    indices = range(min(NUM_USERS, len(dataset)))\n    subset = Subset(dataset, indices)\n    dataloader = DataLoader(subset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)\n    \n    candidates = []\n    \n    with torch.no_grad(), torch.amp.autocast('cuda'):\n        for batch in tqdm(dataloader, desc=\"User Inference\"):\n            input_ids = batch['input_ids'].to(DEVICE)\n            mask = batch['mask'].to(DEVICE)\n            \n            user_seq, _ = model(input_ids, mask)\n            \n            # Extract Last Valid State & Normalize\n            last_indices = mask.sum(dim=1).long() - 1\n            user_emb = user_seq[torch.arange(user_seq.size(0)), last_indices]\n            user_emb = F.normalize(user_emb, p=2, dim=1)\n            \n            # Retrieval\n            scores = torch.matmul(user_emb, item_index.t())\n            top_scores, top_indices = torch.topk(scores, k=TOP_K, dim=1)\n            \n            candidates.append((top_indices.cpu().numpy(), top_scores.cpu().numpy()))\n\n    return candidates\n\n# Execute\n# We reuse the existing model object and dataset\ntrain_dataset = HMDataset(\"artifacts_hm/train_sequences.parquet\")\n\ncandidates_raw = generate_candidates_fast(\n    model, \n    train_dataset, \n    \"artifacts_hm/image_vectors.npy\", \n    \"artifacts_hm/text_vectors.npy\"\n)\n\n# Cleanup\ntorch.cuda.empty_cache()\ngc.collect()\n\n# --- Build DataFrame ---\nprint(\"💾 Saving Candidate Parquet...\")\nall_users = pl.read_parquet(\"artifacts_hm/train_sequences.parquet\").select(\"user_idx\")[\"user_idx\"]\nsubset_users = all_users.head(NUM_USERS).to_numpy()\n\nall_item_indices = np.concatenate([c[0] for c in candidates_raw], axis=0)\nall_scores = np.concatenate([c[1] for c in candidates_raw], axis=0)\n\ncandidate_df = pl.DataFrame({\n    \"user_idx\": np.repeat(subset_users, TOP_K),\n    \"item_idx\": all_item_indices.flatten(),\n    \"tower_score\": all_scores.flatten()\n})\n\ncandidate_df.write_parquet(\"artifacts_hm/candidates.parquet\")\nprint(f\"✅ Generated {len(candidate_df):,} candidates using Improved Model.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:38:59.509510Z","iopub.execute_input":"2026-01-07T12:38:59.510426Z","iopub.status.idle":"2026-01-07T12:39:02.753644Z","shell.execute_reply.started":"2026-01-07T12:38:59.510392Z","shell.execute_reply":"2026-01-07T12:39:02.752705Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 8: FAST Feature Engineering (Fixed)\nimport polars as pl\nimport numpy as np\n\n# CONFIG\nTRAIN_CUTOFF = \"2020-09-15\"\nVALID_START = \"2020-09-16\"\n\ndef build_ranker_features_fast(candidates_df, articles_df, transactions_df):\n    print(\"🚀 Building Fast Ranker Dataset...\")\n    \n    # 1. LABELS (Did user buy this item after Sept 15?)\n    print(\"🏷️  Creating Labels...\")\n    truth = (\n        transactions_df\n        .filter(pl.col(\"t_dat\") >= TRAIN_CUTOFF)\n        .select([\"user_idx\", \"item_idx\"])\n        .unique()\n        .with_columns(pl.lit(1).alias(\"label\"))\n    )\n    \n    # Join Candidates + Truth\n    df = candidates_df.join(truth, on=[\"user_idx\", \"item_idx\"], how=\"left\").fill_null(0)\n    \n    # 2. FEATURE: Item Popularity (Trend)\n    # We only look at the week BEFORE the cutoff (No leakage!)\n    print(\"📈 Adding Trend Features...\")\n    pop_stats = (\n        transactions_df\n        .filter((pl.col(\"t_dat\") >= \"2020-09-08\") & (pl.col(\"t_dat\") < TRAIN_CUTOFF))\n        .group_by(\"item_idx\")\n        .agg(pl.len().alias(\"pop_7d\")) # Fixed Aggregation Syntax\n    )\n    df = df.join(pop_stats, on=\"item_idx\", how=\"left\").fill_null(0)\n    \n    # 3. FEATURE: Item Metadata (Category)\n    print(\"👗 Adding Category Features...\")\n    # Select only one high-level category for speed\n    item_feats = articles_df.select([\"item_idx\", \"index_group_no\"])\n    df = df.join(item_feats, on=\"item_idx\", how=\"left\")\n    \n    print(f\"✅ Ready: {len(df):,} rows.\")\n    return df\n\n# Execute\n# Load Candidates if not in memory\nif 'candidate_df' not in locals():\n    candidate_df = pl.read_parquet(\"artifacts_hm/candidates.parquet\")\n\nranker_data = build_ranker_features_fast(candidate_df, articles_proc, transactions_proc)\nprint(ranker_data.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:29:52.147798Z","iopub.execute_input":"2026-01-07T12:29:52.148787Z","iopub.status.idle":"2026-01-07T12:29:52.281762Z","shell.execute_reply.started":"2026-01-07T12:29:52.148751Z","shell.execute_reply":"2026-01-07T12:29:52.280969Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 9: Train Robust LightGBM Ranker\nimport lightgbm as lgb\nimport matplotlib.pyplot as plt\nimport pandas as pd\n\ndef train_ranker_robust(df):\n    print(\"📉 Training Robust Ranker...\")\n    \n    # 1. Sort by User (Critical for LambdaRank)\n    df = df.sort(\"user_idx\")\n    \n    # 2. Prepare Data\n    features = [\"tower_score\", \"pop_7d\", \"index_group_no\"]\n    X = df.select(features).to_pandas()\n    y = df[\"label\"].to_pandas()\n    \n    # 3. Create Groups (How many items per user?)\n    groups = df.group_by(\"user_idx\", maintain_order=True).len()[\"len\"].to_numpy()\n    \n    # 4. Train with Regularization\n    model = lgb.LGBMRanker(\n        objective=\"lambdarank\",\n        metric=\"ndcg\",\n        eval_at=[12],\n        learning_rate=0.05,  # Slower learning for stability\n        n_estimators=500,    # Enough trees to converge\n        \n        # --- Robustness for Small Data ---\n        num_leaves=10,       # Simple trees\n        max_depth=3,         # Shallow depth\n        reg_alpha=10,        # L1 Regularization to filter noise\n        min_child_samples=50,# Prevent memorizing single items\n        \n        verbose=-1\n    )\n    \n    model.fit(X, y, group=groups)\n    \n    # 5. Plot Importance\n    lgb.plot_importance(model, importance_type=\"gain\", title=\"Feature Importance (Gain)\", figsize=(8,4))\n    plt.show()\n    \n    return model\n\nranker_model = train_ranker_robust(ranker_data)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:39:04.814290Z","iopub.execute_input":"2026-01-07T12:39:04.814570Z","iopub.status.idle":"2026-01-07T12:39:37.068731Z","shell.execute_reply.started":"2026-01-07T12:39:04.814542Z","shell.execute_reply":"2026-01-07T12:39:37.067905Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 13: Pipeline Evaluation (Retriever vs. Ranker)\nimport numpy as np\nimport polars as pl\nimport math\n\ndef calculate_metrics(df, score_col, k=12):\n    \"\"\"\n    Computes Hit, NDCG, and MAP@k for a given score column.\n    \"\"\"\n    print(f\"📉 Sorting by {score_col}...\")\n    \n    # 1. Sort by the specific score (Desc)\n    # We take the top K items per user\n    top_k = (\n        df.sort([\"user_idx\", score_col], descending=[False, True])\n        .group_by(\"user_idx\")\n        .head(k)\n    )\n    \n    # 2. Extract Ground Truth matches\n    # label=1 means the user actually bought it\n    # We need to know: \"Did the user buy ANY item?\" (for denominator)\n    # But here, our dataframe assumes the 'candidates' are the universe.\n    # To be precise: We check how many 'label=1' items ended up in the Top K.\n    \n    # Convert to Pandas for easier metric loop (Polars is fast for sorting, Pandas for iteration)\n    # Group by user -> list of labels\n    user_labels = (\n        top_k.select([\"user_idx\", \"label\"])\n        .to_pandas()\n        .groupby(\"user_idx\")[\"label\"]\n        .apply(list)\n    )\n    \n    hits = 0\n    ndcg = 0\n    ap_sum = 0\n    n_users = len(user_labels)\n    \n    for labels in user_labels:\n        # labels is a list of 0s and 1s, sorted by score. e.g., [0, 1, 0, 0, 1...]\n        \n        # HIT RATE: Did we find at least one relevant item?\n        if 1 in labels:\n            hits += 1\n            \n        # NDCG calculation\n        dcg = 0\n        idcg = 0\n        # Optimal ranking would have all 1s at the top\n        num_positives = sum(labels)\n        \n        for i, rel in enumerate(labels):\n            # DCG\n            if rel == 1:\n                dcg += 1.0 / math.log2(i + 2)\n            \n            # IDCG (Ideal)\n            if i < num_positives:\n                idcg += 1.0 / math.log2(i + 2)\n        \n        if idcg > 0:\n            ndcg += (dcg / idcg)\n            \n        # MAP (Mean Average Precision)\n        # Average precision at each correct recall point\n        running_hits = 0\n        precision_sum = 0\n        \n        for i, rel in enumerate(labels):\n            if rel == 1:\n                running_hits += 1\n                precision_sum += running_hits / (i + 1)\n        \n        if num_positives > 0:\n            ap_sum += precision_sum / min(k, num_positives) # Standard AP def\n\n    return {\n        \"Hit@12\": hits / n_users,\n        \"NDCG@12\": ndcg / n_users,\n        \"MAP@12\": ap_sum / n_users\n    }\n\ndef evaluate_pipeline(ranker_model, test_df):\n    print(\"🚀 Starting Evaluation...\")\n    \n    # 1. Predict with Ranker\n    # We need to generate the scores for LightGBM\n    features = [\"tower_score\", \"pop_7d\", \"index_group_no\"]\n    X = test_df.select(features).to_pandas()\n    \n    print(\"🧠 Inference with LightGBM...\")\n    test_df = test_df.with_columns(\n        pl.Series(ranker_model.predict(X)).alias(\"ranker_score\")\n    )\n    \n    # 2. Evaluate BASELINE (Two-Tower Only)\n    print(\"\\n📊 Evaluating Baseline (Deep Learning Only)...\")\n    base_metrics = calculate_metrics(test_df, \"tower_score\", k=12)\n    \n    # 3. Evaluate RERANKER (Hybrid)\n    print(\"\\n📊 Evaluating Hybrid (Deep Learning + GBM)...\")\n    rank_metrics = calculate_metrics(test_df, \"ranker_score\", k=12)\n    \n    # 4. Show Results\n    print(\"\\n\" + \"=\"*40)\n    print(f\"{'METRIC':<10} | {'BASELINE':<10} | {'RERANKER':<10} | {'LIFT':<10}\")\n    print(\"-\" * 46)\n    \n    for m in [\"Hit@12\", \"NDCG@12\", \"MAP@12\"]:\n        b = base_metrics[m]\n        r = rank_metrics[m]\n        lift = (r - b) / b * 100\n        print(f\"{m:<10} | {b:.4f}     | {r:.4f}     | {lift:+.1f}%\")\n    print(\"=\"*40)\n\n# Execute\n# We use the 'ranker_data' we created in Cell 8\nevaluate_pipeline(ranker_model, ranker_data)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T12:42:47.999177Z","iopub.execute_input":"2026-01-07T12:42:47.999989Z","iopub.status.idle":"2026-01-07T12:42:54.841235Z","shell.execute_reply.started":"2026-01-07T12:42:47.999949Z","shell.execute_reply":"2026-01-07T12:42:54.840484Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 15: Visual H&M Inference Engine (Fixed & Memory Safe)\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport numpy as np\nimport polars as pl\nimport os\nimport base64\nimport gc\nfrom IPython.display import display, HTML\n\n# --- 🧹 MEMORY CLEANUP ---\n# Delete heavy variables from previous cells if they exist\ncleanup_vars = ['model', 'optimizer', 'dataloader', 'ranker_model', 'candidates_raw', 'dataset']\nfor var in cleanup_vars:\n    if var in globals():\n        del globals()[var]\n\ngc.collect()\ntorch.cuda.empty_cache()\nprint(f\"🧹 GPU Memory Cleared. Free: {torch.cuda.memory_reserved() / 1e9:.2f} GB\")\n\n# --- 1. MODEL DEFINITION ---\nclass MultimodalTwoTower(nn.Module):\n    def __init__(self, text_vectors_path, image_vectors_path, embed_dim=512):\n        super().__init__()\n        text_vecs = torch.tensor(np.load(text_vectors_path), dtype=torch.float32)\n        img_vecs = torch.tensor(np.load(image_vectors_path), dtype=torch.float32)\n        \n        self.text_emb = nn.Embedding.from_pretrained(text_vecs, freeze=True)\n        self.img_emb = nn.Embedding.from_pretrained(img_vecs, freeze=True)\n        \n        self.fusion = nn.Sequential(\n            nn.Linear(text_vecs.shape[1] + img_vecs.shape[1], embed_dim),\n            nn.LayerNorm(embed_dim),\n            nn.GELU()\n        )\n        \n        self.transformer = nn.TransformerEncoder(\n            nn.TransformerEncoderLayer(d_model=embed_dim, nhead=4, batch_first=True, dropout=0.1),\n            num_layers=2\n        )\n        \n        self.pos_embedding = nn.Embedding(20, embed_dim)\n        \n        self.repurchase_head = nn.Sequential(\n            nn.Linear(embed_dim, 64),\n            nn.ReLU(),\n            nn.Linear(64, 1)\n        )\n        \n        self.temperature = nn.Parameter(torch.tensor(0.07))\n\n    def encode_items(self, item_ids):\n        t = self.text_emb(item_ids)\n        i = self.img_emb(item_ids)\n        cat = torch.cat([t, i], dim=-1)\n        return self.fusion(cat)\n\n    def forward(self, item_seq, mask):\n        seq_emb = self.encode_items(item_seq)\n        batch_size, seq_len = item_seq.shape\n        positions = torch.arange(seq_len, device=item_seq.device).unsqueeze(0).expand(batch_size, -1)\n        seq_emb = seq_emb + self.pos_embedding(positions)\n        \n        tf_mask = (mask == 0)\n        user_out = self.transformer(seq_emb, src_key_padding_mask=tf_mask)\n        return user_out, None\n\n# --- 2. VISUAL ENGINE ---\nclass VisualHMEngine:\n    def __init__(self, artifact_dir=\"artifacts_hm\", data_dir=\"/kaggle/input\"):\n        print(\"🚀 Initializing Visual Engine...\")\n        self.device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n        \n        # A. Load Model\n        print(f\"   Loading Model from {artifact_dir}...\")\n        self.model = MultimodalTwoTower(\n            f\"{artifact_dir}/text_vectors.npy\",\n            f\"{artifact_dir}/image_vectors.npy\"\n        ).to(self.device)\n        \n        state = torch.load(f\"{artifact_dir}/hm_model_best.pth\", map_location=self.device)\n        self.model.load_state_dict(state)\n        self.model.eval()\n        \n        # B. Load Metadata\n        print(\"   Loading Metadata...\")\n        possible_paths = [\n            f\"{data_dir}/h-and-m-personalized-fashion-recommendations/articles.csv\",\n            f\"{data_dir}/articles.csv\", \n            \"articles.csv\"\n        ]\n        \n        self.articles_df = None\n        for path in possible_paths:\n            if os.path.exists(path):\n                try:\n                    self.articles_df = pl.read_csv(\n                        path, \n                        schema_overrides={\"article_id\": pl.String}\n                    )\n                except Exception as e:\n                    print(f\"   (Read Warning: {e})\")\n                    self.articles_df = pl.read_csv(path, dtypes={\"article_id\": str})\n                    \n                print(f\"   Found Metadata: {path}\")\n                break\n                \n        if self.articles_df is None:\n            print(\"   ⚠️ WARNING: articles.csv not found. Metadata will be missing.\")\n            self.articles_df = pl.DataFrame({\"article_id\": [], \"prod_name\": [], \"detail_desc\": []})\n\n        self.meta_store = {}\n        if not self.articles_df.is_empty():\n            rows = self.articles_df.select([\"article_id\", \"prod_name\", \"detail_desc\"]).to_dicts()\n            for row in rows:\n                self.meta_store[row[\"article_id\"]] = row\n\n        n_items = len(self.model.text_emb.weight)\n        if not self.articles_df.is_empty():\n            unique_ids = self.articles_df[\"article_id\"].unique().sort().to_list()\n        else:\n            unique_ids = [str(i).zfill(10) for i in range(n_items)]\n            \n        self.id_to_idx = {aid: i for i, aid in enumerate(unique_ids)}\n        self.idx_to_id = {i: aid for i, aid in enumerate(unique_ids)}\n        \n        # C. Build Index\n        print(\"⚡ Building Vector Index...\")\n        self.item_index = self._build_index()\n        print(\"✅ Engine Ready!\")\n\n    def _build_index(self):\n        all_ids = torch.arange(len(self.model.text_emb.weight), device=self.device)\n        batch_size = 2048 # Reduced batch size to save memory\n        embeddings = []\n        with torch.no_grad():\n            for i in range(0, len(all_ids), batch_size):\n                batch = all_ids[i : i+batch_size]\n                emb = self.model.encode_items(batch)\n                emb = F.normalize(emb, p=2, dim=1)\n                embeddings.append(emb)\n        return torch.cat(embeddings, dim=0)\n\n    def get_image_tag(self, article_id):\n        subfolder = article_id[:3]\n        paths = [\n            f\"/kaggle/input/h-and-m-personalized-fashion-recommendations/images/{subfolder}/{article_id}.jpg\",\n            f\"data/images/{subfolder}/{article_id}.jpg\"\n        ]\n        img_path = None\n        for p in paths:\n            if os.path.exists(p):\n                img_path = p\n                break\n        \n        if img_path:\n            try:\n                with open(img_path, \"rb\") as img_file:\n                    b64_string = base64.b64encode(img_file.read()).decode('utf-8')\n                return f'<img src=\"data:image/jpeg;base64,{b64_string}\" style=\"width:100%; height:100%; object-fit:cover;\">'\n            except:\n                pass\n        return '<div style=\"width:100%; height:100%; background:#eee; display:flex; align-items:center; justify-content:center; color:#999;\">No Img</div>'\n\n    def recommend(self, history_ids, top_k=5):\n        valid_seq = [self.id_to_idx[aid] for aid in history_ids if aid in self.id_to_idx]\n        if not valid_seq: return []\n        valid_seq = valid_seq[-20:]\n        input_tensor = torch.tensor([valid_seq], dtype=torch.long).to(self.device)\n        mask = torch.ones_like(input_tensor).float().to(self.device)\n        \n        with torch.no_grad():\n            user_seq, _ = self.model(input_tensor, mask)\n            user_vec = F.normalize(user_seq[0, -1, :], p=2, dim=0)\n            scores = torch.matmul(user_vec, self.item_index.t())\n            for idx in valid_seq: scores[idx] = -float('inf')\n            _, top_indices = torch.topk(scores, k=top_k)\n            \n        return [self.idx_to_id[idx.item()] for idx in top_indices]\n\n    def visualize_interaction(self, history_ids):\n        recs = self.recommend(history_ids, top_k=6)\n        \n        def card(aid):\n            meta = self.meta_store.get(aid, {\"prod_name\": \"Unknown\"})\n            img_html = self.get_image_tag(aid)\n            return f\"\"\"\n            <div style=\"display: inline-block; width: 140px; margin: 10px; \n                border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; \n                font-family: Arial, sans-serif; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05);\">\n                <div style=\"height: 180px; overflow: hidden; background: #fafafa;\">\n                    {img_html}\n                </div>\n                <div style=\"padding: 8px;\">\n                    <div style=\"font-weight: bold; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\">{meta['prod_name']}</div>\n                    <div style=\"color: #888; font-size: 10px;\">{aid}</div>\n                </div>\n            </div>\n            \"\"\"\n        html = f\"\"\"\n        <div style=\"padding: 20px; font-family: sans-serif;\">\n            <h3 style=\"margin-bottom: 10px;\">🛍️ Your Shopping History</h3>\n            <div style=\"display: flex; flex-wrap: wrap; margin-bottom: 30px;\">\n                {''.join([card(aid) for aid in history_ids])}\n            </div>\n            <h3 style=\"margin-bottom: 10px;\">✨ AI Recommendations</h3>\n            <div style=\"display: flex; flex-wrap: wrap; background: #f4faff; padding: 15px; border-radius: 12px; border: 1px solid #d0e8ff;\">\n                {''.join([card(aid) for aid in recs])}\n            </div>\n        </div>\n        \"\"\"\n        display(HTML(html))\n\n# --- RUN ---\nviz_engine = VisualHMEngine()\nmy_history = [\"0108775015\", \"0108775044\", \"0108775051\"] \nviz_engine.visualize_interaction(my_history)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T13:05:46.895318Z","iopub.execute_input":"2026-01-07T13:05:46.896162Z","iopub.status.idle":"2026-01-07T13:05:50.427917Z","shell.execute_reply.started":"2026-01-07T13:05:46.896098Z","shell.execute_reply":"2026-01-07T13:05:50.426955Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install streamlit streamlit_jupyter","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T13:10:16.739958Z","iopub.execute_input":"2026-01-07T13:10:16.740704Z","iopub.status.idle":"2026-01-07T13:10:23.014501Z","shell.execute_reply.started":"2026-01-07T13:10:16.740673Z","shell.execute_reply":"2026-01-07T13:10:23.013400Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%writefile app.py\nimport streamlit as st\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport numpy as np\nimport polars as pl\nimport os\nimport random\nimport gc\n\n# ==========================================\n# 1. CORE MODEL ARCHITECTURE (Must Match Checkpoint)\n# ==========================================\nclass MultimodalTwoTower(nn.Module):\n    def __init__(self, text_vectors_path, image_vectors_path, embed_dim=512):\n        super().__init__()\n        # Load Buffers (using cpu map_location to avoid immediate gpu load if cached)\n        text_vecs = torch.tensor(np.load(text_vectors_path), dtype=torch.float32)\n        img_vecs = torch.tensor(np.load(image_vectors_path), dtype=torch.float32)\n        \n        self.text_emb = nn.Embedding.from_pretrained(text_vecs, freeze=True)\n        self.img_emb = nn.Embedding.from_pretrained(img_vecs, freeze=True)\n        \n        self.fusion = nn.Sequential(\n            nn.Linear(text_vecs.shape[1] + img_vecs.shape[1], embed_dim),\n            nn.LayerNorm(embed_dim),\n            nn.GELU()\n        )\n        \n        self.transformer = nn.TransformerEncoder(\n            nn.TransformerEncoderLayer(d_model=embed_dim, nhead=4, batch_first=True, dropout=0.1),\n            num_layers=2\n        )\n        \n        self.pos_embedding = nn.Embedding(20, embed_dim)\n        self.repurchase_head = nn.Sequential(\n            nn.Linear(embed_dim, 64), nn.ReLU(), nn.Linear(64, 1)\n        )\n        self.temperature = nn.Parameter(torch.tensor(0.07))\n\n    def encode_items(self, item_ids):\n        t = self.text_emb(item_ids)\n        i = self.img_emb(item_ids)\n        cat = torch.cat([t, i], dim=-1)\n        return self.fusion(cat)\n\n    def forward(self, item_seq, mask):\n        seq_emb = self.encode_items(item_seq)\n        batch_size, seq_len = item_seq.shape\n        positions = torch.arange(seq_len, device=item_seq.device).unsqueeze(0).expand(batch_size, -1)\n        seq_emb = seq_emb + self.pos_embedding(positions)\n        tf_mask = (mask == 0)\n        user_out = self.transformer(seq_emb, src_key_padding_mask=tf_mask)\n        return user_out, None\n\n# ==========================================\n# 2. CACHED ENGINE LOADER (Singleton)\n# ==========================================\n@st.cache_resource\ndef load_engine():\n    print(\"🚀 Loading Engine Resources...\")\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    artifact_dir = \"artifacts_hm\"\n    data_dir = \"/kaggle/input\" \n    \n    # Check paths\n    if not os.path.exists(\"artifacts_hm/hm_model_best.pth\"):\n        st.error(\"Model artifacts not found. Please run training cells first.\")\n        return None\n\n    # Load Model\n    model = MultimodalTwoTower(\n        f\"{artifact_dir}/text_vectors.npy\",\n        f\"{artifact_dir}/image_vectors.npy\"\n    ).to(device)\n    \n    state = torch.load(f\"{artifact_dir}/hm_model_best.pth\", map_location=device)\n    model.load_state_dict(state)\n    model.eval()\n\n    # Load Metadata\n    articles = None\n    possible_paths = [\n        f\"{data_dir}/h-and-m-personalized-fashion-recommendations/articles.csv\",\n        \"articles.csv\", \"data/articles.csv\"\n    ]\n    \n    for path in possible_paths:\n        if os.path.exists(path):\n            try:\n                articles = pl.read_csv(path, schema_overrides={\"article_id\": pl.String})\n            except:\n                articles = pl.read_csv(path, dtypes={\"article_id\": str})\n            break\n            \n    if articles is None:\n        st.warning(\"Metadata CSV not found. Using Mock Data.\")\n        articles = pl.DataFrame({\"article_id\": [], \"prod_name\": [], \"index_group_name\": []})\n\n    # Prepare Mappings\n    n_items = len(model.text_emb.weight)\n    if not articles.is_empty():\n        unique_ids = articles[\"article_id\"].unique().sort().to_list()\n    else:\n        unique_ids = [str(i).zfill(10) for i in range(n_items)]\n\n    id_to_idx = {aid: i for i, aid in enumerate(unique_ids)}\n    idx_to_id = {i: aid for i, aid in enumerate(unique_ids)}\n\n    # Build Index\n    all_ids = torch.arange(n_items, device=device)\n    embeddings = []\n    with torch.no_grad():\n        for i in range(0, n_items, 4096):\n            batch = all_ids[i : i+4096]\n            batch = batch.to(device)\n            emb = model.encode_items(batch)\n            emb = F.normalize(emb, p=2, dim=1)\n            embeddings.append(emb)\n    item_index = torch.cat(embeddings, dim=0)\n\n    # Pre-calculate Global Popularity\n    # For demo purposes, just taking the first 50 items if popular list isn't pre-computed\n    popular_ids = unique_ids[:50] \n\n    return {\n        \"model\": model, \"index\": item_index, \"articles\": articles,\n        \"id_map\": id_to_idx, \"rev_map\": idx_to_id, \"popular\": popular_ids,\n        \"device\": device\n    }\n\n# ==========================================\n# 3. HELPER FUNCTIONS\n# ==========================================\ndef get_recommendations(engine, history_ids, top_k=10):\n    if not history_ids: return []\n    \n    # Filter valid\n    valid = [engine[\"id_map\"][i] for i in history_ids if i in engine[\"id_map\"]]\n    if not valid: return []\n    \n    valid = valid[-20:] # Max seq len\n    \n    tens = torch.tensor([valid], dtype=torch.long).to(engine[\"device\"])\n    mask = torch.ones_like(tens).float()\n    \n    with torch.no_grad():\n        out, _ = engine[\"model\"](tens, mask)\n        u_vec = F.normalize(out[0, -1, :], p=2, dim=0)\n        scores = torch.matmul(u_vec, engine[\"index\"].t())\n        \n        # Suppress already seen\n        for idx in valid: scores[idx] = -float('inf')\n        \n        _, top_indices = torch.topk(scores, k=top_k)\n        \n    return [engine[\"rev_map\"][i.item()] for i in top_indices]\n\ndef get_image_path(article_id):\n    sub = article_id[:3]\n    paths = [\n        f\"/kaggle/input/h-and-m-personalized-fashion-recommendations/images/{sub}/{article_id}.jpg\",\n        f\"data/images/{sub}/{article_id}.jpg\"\n    ]\n    for p in paths:\n        if os.path.exists(p): return p\n    return None\n\ndef render_grid(items, engine, cols=5, select_key=\"select\"):\n    # Split into rows\n    rows = [items[i:i+cols] for i in range(0, len(items), cols)]\n    \n    for row in rows:\n        cols_ui = st.columns(cols)\n        for idx, aid in enumerate(row):\n            with cols_ui[idx]:\n                # Metadata\n                meta = engine[\"articles\"].filter(pl.col(\"article_id\") == aid)\n                name = meta[\"prod_name\"][0] if not meta.is_empty() else \"Unknown\"\n                \n                # Image\n                img_path = get_image_path(aid)\n                if img_path:\n                    st.image(img_path, use_container_width=True)\n                else:\n                    st.markdown(\"🖼️ *No Image*\")\n                \n                st.caption(f\"**{name}**\")\n                \n                # Interaction Button\n                if st.button(f\"❤️ Like\", key=f\"{select_key}_{aid}\"):\n                    st.session_state.history.append(aid)\n                    st.session_state.interactions += 1\n                    st.rerun()\n\n# ==========================================\n# 4. MAIN APP LOGIC\n# ==========================================\nst.set_page_config(layout=\"wide\", page_title=\"H&M Neural RecSys\")\nengine = load_engine()\n\nif engine:\n    # --- SESSION STATE INITIALIZATION ---\n    if \"history\" not in st.session_state: st.session_state.history = []\n    if \"interactions\" not in st.session_state: st.session_state.interactions = 0\n    if \"user_info\" not in st.session_state: st.session_state.user_info = {}\n    if \"refresh_trigger\" not in st.session_state: st.session_state.refresh_trigger = 0\n\n    # --- SIDEBAR: USER PROFILE ---\n    with st.sidebar:\n        st.header(\"👤 User Profile\")\n        \n        # Cold Start Info\n        gender = st.selectbox(\"Gender Preference\", [\"Ladieswear\", \"Menswear\", \"Baby/Children\", \"Sport\"])\n        age = st.slider(\"Age Group\", 16, 60, 25)\n        \n        if st.button(\"Reset User\"):\n            st.session_state.history = []\n            st.session_state.interactions = 0\n            st.session_state.user_info = {\"gender\": gender, \"age\": age}\n            st.rerun()\n\n        st.divider()\n        st.metric(\"Total Interactions\", st.session_state.interactions)\n        st.write(\"### Shopping Bag (History)\")\n        st.write(st.session_state.history)\n\n    # --- MAIN PAGE ---\n    st.title(\"🛍️ H&M Interactive Neural Recommender\")\n\n    # 1. LOGIC: Decide Interaction Phase\n    is_cold_start = len(st.session_state.history) < 1\n    \n    # Check for Refresh Trigger (Every 5 interactions)\n    if st.session_state.interactions > 0 and st.session_state.interactions % 5 == 0:\n        if st.session_state.interactions != st.session_state.refresh_trigger:\n            st.toast(f\"🎉 {st.session_state.interactions} Interactions! Model Updated.\")\n            st.session_state.refresh_trigger = st.session_state.interactions\n\n    # 2. GENERATE CANDIDATES (60% Personalized / 40% Popular)\n    total_slots = 45\n    \n    if is_cold_start:\n        st.info(f\"👋 New User Detected! Showing top items for **{gender}**.\")\n        # Filter popular items by category\n        filtered_pop = engine[\"articles\"].filter(\n            pl.col(\"index_group_name\").str.contains(gender.split(\"/\")[0])\n        ).head(total_slots)[\"article_id\"].to_list()\n        \n        display_items = filtered_pop\n        if not display_items: display_items = engine[\"popular\"][:total_slots] # Fallback\n        \n    else:\n        # HYBRID MIX\n        n_personal = int(total_slots * 0.6) # 27 items\n        n_popular = total_slots - n_personal # 18 items\n        \n        # A. Personalized (Neural Model)\n        personal_recs = get_recommendations(engine, st.session_state.history, top_k=n_personal)\n        \n        # B. Popular (Global Trend) - Shuffled for variety\n        popular_recs = random.sample(engine[\"popular\"], min(len(engine[\"popular\"]), n_popular))\n        \n        display_items = personal_recs + popular_recs\n        random.shuffle(display_items)\n\n    # 3. RENDER FEED (Discovery Area)\n    st.subheader(\"Discover Fashion\")\n    render_grid(display_items, engine, cols=6, select_key=\"main_feed\")\n\n    # 4. RECOMMENDATION SECTION (Top 10 High Confidence)\n    # Only show if we have some history\n    if not is_cold_start:\n        st.divider()\n        st.subheader(\"🔥 Top 10 Picks For You (Real-Time)\")\n        \n        # High confidence fetch\n        top_picks = get_recommendations(engine, st.session_state.history, top_k=10)\n        render_grid(top_picks, engine, cols=5, select_key=\"top_picks\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T13:17:06.073312Z","iopub.execute_input":"2026-01-07T13:17:06.073717Z","iopub.status.idle":"2026-01-07T13:17:06.085588Z","shell.execute_reply.started":"2026-01-07T13:17:06.073678Z","shell.execute_reply":"2026-01-07T13:17:06.084891Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 1: Environment Setup\nimport os\nimport subprocess\n\nprint(\"⚙️ Installing Streamlit via pip...\")\n# Install python dependencies\nsubprocess.run([\"pip\", \"install\", \"-q\", \"streamlit\"], check=True)\n\nprint(\"⚙️ Installing Localtunnel via npm...\")\n# Install localtunnel using npm (Kaggle has nodejs pre-installed)\nsubprocess.run([\"npm\", \"install\", \"-g\", \"localtunnel\"], check=True)\n\nprint(\"✅ Environment Ready.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T13:19:29.518556Z","iopub.execute_input":"2026-01-07T13:19:29.519445Z","iopub.status.idle":"2026-01-07T13:19:34.929969Z","shell.execute_reply.started":"2026-01-07T13:19:29.519405Z","shell.execute_reply":"2026-01-07T13:19:34.929174Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 3: Launch Service\nimport subprocess\nimport time\nimport urllib.request\n\ndef run_app():\n    print(\"🚀 Starting Streamlit Server...\")\n    # 1. Run Streamlit in the background\n    # We set headless=true to avoid interactive prompts\n    process_streamlit = subprocess.Popen(\n        [\"streamlit\", \"run\", \"app.py\", \"--server.port\", \"8501\", \"--server.headless\", \"true\"],\n        stdout=subprocess.DEVNULL,\n        stderr=subprocess.DEVNULL\n    )\n    \n    # Give it a moment to start\n    time.sleep(5)\n    \n    print(\"🔗 Starting Tunnel...\")\n    # 2. Run Localtunnel\n    # This forwards port 8501 to the public internet\n    process_tunnel = subprocess.Popen(\n        [\"npx\", \"localtunnel\", \"--port\", \"8501\"],\n        stdout=subprocess.PIPE\n    )\n    \n    # 3. Retrieve the Public IP (Password)\n    # You MUST enter this IP on the localtunnel page to access the app\n    public_ip = urllib.request.urlopen('https://ipv4.icanhazip.com').read().decode('utf8').strip()\n    print(f\"⚠️ **IMPORTANT**: Your Tunnel Password is: {public_ip}\")\n    print(\"-\" * 50)\n    \n    # 4. Read the Tunnel URL\n    time.sleep(3)\n    for line in iter(process_tunnel.stdout.readline, b''):\n        url = line.decode().strip()\n        if \"your url is\" in url:\n            print(f\"🌍 CLICK HERE: {url}\")\n            print(\"-\" * 50)\n            break\n\n# Execute\nrun_app()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-07T13:21:56.439626Z","iopub.execute_input":"2026-01-07T13:21:56.440351Z","iopub.status.idle":"2026-01-07T13:22:04.531371Z","shell.execute_reply.started":"2026-01-07T13:21:56.440317Z","shell.execute_reply":"2026-01-07T13:22:04.530566Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}