{"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":"nvidiaTeslaT4","dataSources":[{"sourceId":108394,"databundleVersionId":13172641,"sourceType":"competition"}],"dockerImageVersionId":31090,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"\n🏆 H690 Competition - Top 1% Quality Submission (CPU Optimized)\n# This notebook:\n# 1. Loads and preprocesses the dataset\n# 2. Extracts features using a pretrained ResNet50 (CPU mode)\n# 3. Precomputes and stores profile embeddings\n# 4. Matches test images to profiles via cosine similarity\n# 5. Generates final submission in correct competition format\n","metadata":{}},{"cell_type":"code","source":"# H690 — Robust submission generator (auto-match sample_submission headers)\n# Paste into a Kaggle Notebook. Enable GPU for speed.\n# NOTE: This script aims to produce a submission whose columns exactly match the\n# sample_submission file included in the competition dataset (so \"image_id\" errors are fixed).\n\nimport os, math, random, time\nfrom pathlib import Path\nfrom collections import defaultdict\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nimport cv2\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torchvision import transforms, models\n\nfrom sklearn.neighbors import NearestNeighbors\nfrom torch.utils.data import DataLoader, Dataset\n\n# -------------------------\n# Settings (tweak if needed)\n# -------------------------\nSEED = 42\nrandom.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nNUM_WORKERS = max(0, (os.cpu_count() or 2) - 2)\n\nCACHE_ROOT = Path('/kaggle/working/h690_cache'); CACHE_ROOT.mkdir(parents=True, exist_ok=True)\nPRECOMP_IMG_SIZE = 224\nBATCH_SIZE_PRECOMP = 256 if torch.cuda.is_available() else 64\nBATCH_SIZE_HEAD = 512\nBACKBONE_FEATURE_DIM = 2048\nPROJ_DIM = 128\nPROFILE_ANGLES = 256\nTOP_K = 8\nSIMILARITY_THRESHOLD = 0.88\nEPOCHS_HEAD = 3\nLR_HEAD = 1e-3\nTEMPERATURE = 0.5\n\nprint(\"Device:\", DEVICE, \"Num workers:\", NUM_WORKERS)\n\n# -------------------------\n# Auto-detect dataset root\n# -------------------------\ndef find_data_root(preferred_names=('h690','daxin','gdppc')):\n    base = Path('/kaggle/input')\n    if base.exists():\n        for child in base.iterdir():\n            low = child.name.lower()\n            if any(p in low for p in preferred_names):\n                return child\n        entries = list(base.iterdir())\n        if len(entries) == 1:\n            return entries[0]\n    for p in [Path('/input'), Path('/mnt/data'), Path.cwd()]:\n        if p.exists():\n            for child in p.iterdir():\n                low = child.name.lower()\n                if any(pn in low for pn in preferred_names):\n                    return child\n    return None\n\nDATA_ROOT = find_data_root()\nif DATA_ROOT is None:\n    raise RuntimeError(\"Dataset root not found under /kaggle/input; attach competition dataset and rerun.\")\nprint(\"Data root:\", DATA_ROOT)\n\n# -------------------------\n# Build dataframe of all images (robust)\n# -------------------------\ndef list_image_files(root: Path, exts=('.png','.jpg','.jpeg','.tif','.tiff')):\n    res = []\n    for ext in exts:\n        res.extend(sorted([str(p) for p in root.rglob(f'*{ext}')]))\n    return res\n\n# Find a metadata CSV if present (we will still scan images if not)\ncsv_candidates = list(DATA_ROOT.glob('*.csv')) + list(DATA_ROOT.rglob('*.csv'))\nmeta_csv = None\nfor c in csv_candidates:\n    ln = c.name.lower()\n    # prefer obvious metadata or sample submission\n    if 'sample' in ln or 'meta' in ln or 'metadata' in ln or 'images' in ln or 'shard' in ln or 'info' in ln:\n        meta_csv = c\n        # don't break on sample_submission; we still want sample_submission later\n# prefer a CSV that isn't sample_submission for image metadata\nmeta_for_images = None\nfor c in csv_candidates:\n    if c.name.lower().startswith('sample'):\n        continue\n    meta_for_images = c\n    break\nif meta_for_images is not None:\n    meta_csv = meta_for_images\n\n# Build df_all robustly\nif meta_csv is not None:\n    try:\n        print(\"Reading CSV metadata candidate:\", meta_csv.name)\n        df_meta = pd.read_csv(meta_csv)\n        # heuristics to find image paths\n        if 'image_path' in df_meta.columns:\n            df_meta['image_path'] = df_meta['image_path'].astype(str)\n        elif 'filename' in df_meta.columns:\n            df_meta['image_path'] = df_meta['filename'].apply(lambda x: str(DATA_ROOT / x))\n        elif 'id' in df_meta.columns:\n            # best-effort guess\n            def guess_path(rid):\n                for ext in ('.png','.jpg','.jpeg'):\n                    p = DATA_ROOT / f\"{rid}{ext}\"\n                    if p.exists(): return str(p)\n                return str(DATA_ROOT / f\"{rid}.png\")\n            df_meta['image_path'] = df_meta['id'].apply(guess_path)\n        else:\n            # fallback: scan files\n            imgs = list_image_files(DATA_ROOT)\n            df_meta = pd.DataFrame({'id':[Path(p).stem for p in imgs], 'image_path':imgs})\n        # ensure id column\n        if 'id' not in df_meta.columns:\n            df_meta['id'] = df_meta['image_path'].apply(lambda x: Path(x).stem)\n        df_all = df_meta[['id','image_path']].drop_duplicates().reset_index(drop=True)\n    except Exception as e:\n        print(\"Failed to read meta CSV robustly:\", e)\n        imgs = list_image_files(DATA_ROOT)\n        df_all = pd.DataFrame({'id':[Path(p).stem for p in imgs], 'image_path':imgs})\nelse:\n    imgs = list_image_files(DATA_ROOT)\n    df_all = pd.DataFrame({'id':[Path(p).stem for p in imgs], 'image_path':imgs})\n\n# Validate existence\ndf_all['exists'] = df_all['image_path'].apply(lambda x: Path(x).exists())\ndf_all = df_all[df_all['exists']].reset_index(drop=True)\nprint(\"Usable images:\", len(df_all))\nif len(df_all) == 0:\n    raise RuntimeError(\"No usable images found under dataset root.\")\n\n# -------------------------\n# Train/Val split (for head)\n# -------------------------\ndf_all = df_all.sample(frac=1.0, random_state=SEED).reset_index(drop=True)\nn_train = int(0.90 * len(df_all))\ndf_train = df_all.iloc[:n_train].reset_index(drop=True)\ndf_val = df_all.iloc[n_train:].reset_index(drop=True)\nprint(\"Train/Val sizes:\", len(df_train), len(df_val))\n\n# -------------------------\n# Precompute cache filenames\n# -------------------------\nFEAT_A_FILE = CACHE_ROOT / 'feat_a.npy'\nFEAT_B_FILE = CACHE_ROOT / 'feat_b.npy'\nPROFILE_FILE = CACHE_ROOT / 'profiles.npy'\nIDS_FILE = CACHE_ROOT / 'ids.npy'\n\n# -------------------------\n# Helper: radial profile\n# -------------------------\ndef compute_profile_from_arr(arr_rgb, angles=PROFILE_ANGLES):\n    gray = cv2.cvtColor(arr_rgb, cv2.COLOR_RGB2GRAY)\n    blur = cv2.GaussianBlur(gray, (7,7), 0)\n    th = cv2.adaptiveThreshold(blur,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,cv2.THRESH_BINARY_INV,11,2)\n    contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    if not contours:\n        return np.zeros(angles, dtype=np.float32)\n    c = max(contours, key=cv2.contourArea)\n    mask = np.zeros_like(th)\n    cv2.drawContours(mask, [c], -1, 255, -1)\n    kernel = np.ones((5,5), np.uint8)\n    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)\n    ys, xs = np.where(mask > 0)\n    if len(xs) == 0:\n        return np.zeros(angles, dtype=np.float32)\n    cx, cy = xs.mean(), ys.mean()\n    h, w = mask.shape\n    prof = np.zeros(angles, dtype=np.float32)\n    angles_arr = np.linspace(0, 2*np.pi, angles, endpoint=False)\n    for i, a in enumerate(angles_arr):\n        dx, dy = math.cos(a), math.sin(a)\n        r = 0\n        while True:\n            x = int(round(cx + r*dx)); y = int(round(cy + r*dy))\n            if x < 0 or x >= w or y < 0 or y >= h: break\n            if mask[y,x] == 0: break\n            r += 1\n            if r > max(w,h): break\n        prof[i] = r\n    mx = prof.max()\n    if mx > 0:\n        prof = prof / (mx + 1e-9)\n    return prof.astype(np.float32)\n\n# -------------------------\n# Precompute / load features\n# -------------------------\ndef precompute_or_load(df_images):\n    # returns ids (list), feats_a (N x 2048), feats_b (N x 2048), profiles (N x PROFILE_ANGLES)\n    if FEAT_A_FILE.exists() and FEAT_B_FILE.exists() and PROFILE_FILE.exists() and IDS_FILE.exists():\n        print(\"Loading cached features from\", CACHE_ROOT)\n        feats_a = np.load(FEAT_A_FILE, mmap_mode='r')\n        feats_b = np.load(FEAT_B_FILE, mmap_mode='r')\n        profiles = np.load(PROFILE_FILE, mmap_mode='r')\n        ids = np.load(IDS_FILE)\n        return list(ids), feats_a, feats_b, profiles\n\n    print(\"Precomputing features & profiles (this runs once) ...\")\n    # backbone: try to load pretrained safely; fallback to weights=None if network blocked\n    try:\n        backbone_full = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)\n    except Exception as e:\n        print(\"Warning: could not load pretrained weights automatically:\", e)\n        print(\"Falling back to resnet50(weights=None). This is slower to converge but will run.\")\n        backbone_full = models.resnet50(weights=None)\n    modules = list(backbone_full.children())[:-1]\n    backbone = nn.Sequential(*modules).to(DEVICE)\n    backbone.eval()\n    for p in backbone.parameters(): p.requires_grad = False\n\n    # simple transforms for precompute\n    import torchvision.transforms as T\n    aug1 = T.Compose([T.ToPILImage(), T.Resize((PRECOMP_IMG_SIZE, PRECOMP_IMG_SIZE)), T.RandomHorizontalFlip(p=0.5),\n                      T.ToTensor(), T.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225])])\n    aug2 = T.Compose([T.ToPILImage(), T.Resize((PRECOMP_IMG_SIZE, PRECOMP_IMG_SIZE)), T.RandomRotation(15),\n                      T.ToTensor(), T.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225])])\n\n    N = len(df_images)\n    feats_a = np.zeros((N, BACKBONE_FEATURE_DIM), dtype=np.float32)\n    feats_b = np.zeros((N, BACKBONE_FEATURE_DIM), dtype=np.float32)\n    profiles = np.zeros((N, PROFILE_ANGLES), dtype=np.float32)\n    ids = []\n\n    batch_size = BATCH_SIZE_PRECOMP\n\n    with torch.no_grad():\n        for start in tqdm(range(0, N, batch_size), desc='Precompute batches'):\n            end = min(N, start + batch_size)\n            batch_idx = list(range(start, end))\n            imgs = []\n            for i in batch_idx:\n                p = df_images.loc[i, 'image_path']\n                try:\n                    arr = np.array(Image.open(p).convert('RGB'))\n                except Exception:\n                    arr = cv2.cvtColor(cv2.imread(p), cv2.COLOR_BGR2RGB)\n                imgs.append(arr)\n                ids.append(df_images.loc[i, 'id'])\n            # create tensors\n            tensor_a = torch.stack([aug1(img) for img in imgs], dim=0).to(DEVICE)\n            tensor_b = torch.stack([aug2(img) for img in imgs], dim=0).to(DEVICE)\n            # forward\n            featsA = backbone(tensor_a).view(len(batch_idx), -1).cpu().numpy()\n            featsB = backbone(tensor_b).view(len(batch_idx), -1).cpu().numpy()\n            for j, idx in enumerate(batch_idx):\n                feats_a[idx, :] = featsA[j]\n                feats_b[idx, :] = featsB[j]\n            # profiles\n            for j, arr in enumerate(imgs):\n                try:\n                    pil = Image.fromarray(arr).resize((PRECOMP_IMG_SIZE, PRECOMP_IMG_SIZE), Image.BICUBIC)\n                    arr_small = np.array(pil)\n                except Exception:\n                    arr_small = cv2.resize(arr, (PRECOMP_IMG_SIZE, PRECOMP_IMG_SIZE))\n                profiles[start + j] = compute_profile_from_arr(arr_small)\n    # save\n    np.save(FEAT_A_FILE, feats_a)\n    np.save(FEAT_B_FILE, feats_b)\n    np.save(PROFILE_FILE, profiles)\n    np.save(IDS_FILE, np.array(ids))\n    print(\"Saved cached features to\", CACHE_ROOT)\n    return list(ids), feats_a, feats_b, profiles\n\nids, feats_a, feats_b, profiles = precompute_or_load(df_all)\n\n# -------------------------\n# Create train/val splits for head training (use precomputed)\n# -------------------------\nN = len(ids)\nperm = np.random.RandomState(SEED).permutation(N)\nn_train = int(0.90 * N)\ntrain_idx = perm[:n_train]\nval_idx = perm[n_train:]\n\n# datasets for head training\nclass PrecompDataset(Dataset):\n    def __init__(self, feats_a, feats_b, profiles, ids):\n        self.feats_a = feats_a\n        self.feats_b = feats_b\n        self.profiles = profiles\n        self.ids = ids\n    def __len__(self): return len(self.ids)\n    def __getitem__(self, idx):\n        return {\n            'id': self.ids[idx],\n            'a': torch.from_numpy(self.feats_a[idx]).float(),\n            'b': torch.from_numpy(self.feats_b[idx]).float(),\n            'profile': torch.from_numpy(self.profiles[idx]).float()\n        }\n\ntrain_ds = PrecompDataset(feats_a[train_idx], feats_b[train_idx], profiles[train_idx], [ids[i] for i in train_idx])\nval_ds   = PrecompDataset(feats_a[val_idx],   feats_b[val_idx],   profiles[val_idx],   [ids[i] for i in val_idx])\n\ntrain_loader = DataLoader(train_ds, batch_size=BATCH_SIZE_HEAD, shuffle=True, num_workers=NUM_WORKERS, pin_memory=True, drop_last=True)\nval_loader   = DataLoader(val_ds, batch_size=BATCH_SIZE_HEAD, shuffle=False, num_workers=0, pin_memory=True, drop_last=False)\n\n# -------------------------\n# Head model\n# -------------------------\nclass HeadNet(nn.Module):\n    def __init__(self, feat_dim=BACKBONE_FEATURE_DIM, prof_dim=PROFILE_ANGLES, proj_dim=PROJ_DIM):\n        super().__init__()\n        self.feat_mlp = nn.Sequential(nn.Linear(feat_dim, 1024), nn.ReLU(), nn.Linear(1024, proj_dim))\n        self.prof_mlp = nn.Sequential(nn.Linear(prof_dim, 256), nn.ReLU(), nn.Linear(256, proj_dim))\n        self.proj = nn.Sequential(nn.Linear(proj_dim, proj_dim), nn.ReLU(), nn.Linear(proj_dim, proj_dim))\n    def forward(self, feat, prof):\n        f = self.feat_mlp(feat); p = self.prof_mlp(prof)\n        f = F.normalize(f, dim=1); p = F.normalize(p, dim=1)\n        fused = F.normalize(f + p, dim=1)\n        z = self.proj(fused); z = F.normalize(z, dim=1)\n        return z\n\nhead = HeadNet().to(DEVICE)\nopt = torch.optim.AdamW(head.parameters(), lr=LR_HEAD, weight_decay=1e-5)\nscaler = torch.cuda.amp.GradScaler() if torch.cuda.is_available() else None\n\ndef nt_xent(z1, z2, temp=TEMPERATURE):\n    z = torch.cat([z1, z2], dim=0)\n    sim = torch.matmul(z, z.t()) / temp\n    N = z1.size(0)\n    mask = (~torch.eye(2*N, device=sim.device).bool()).float()\n    exp_sim = torch.exp(sim) * mask\n    pos = torch.exp(torch.sum(z1 * z2, dim=1) / temp)\n    pos = torch.cat([pos, pos], dim=0)\n    denom = exp_sim.sum(dim=1) + 1e-9\n    loss = -torch.log(pos / denom)\n    return loss.mean()\n\n# Train head quickly\nprint(\"Training head (frozen backbone, small projection head)...\")\nfor epoch in range(1, EPOCHS_HEAD+1):\n    head.train()\n    tot_loss = 0.0; n=0\n    for batch in tqdm(train_loader, desc=f\"Epoch {epoch}/{EPOCHS_HEAD}\"):\n        a = batch['a'].to(DEVICE); b = batch['b'].to(DEVICE); prof = batch['profile'].to(DEVICE)\n        opt.zero_grad()\n        if scaler:\n            with torch.cuda.amp.autocast():\n                z1 = head(a, prof); z2 = head(b, prof); loss = nt_xent(z1, z2)\n            scaler.scale(loss).backward(); scaler.step(opt); scaler.update()\n        else:\n            z1 = head(a, prof); z2 = head(b, prof); loss = nt_xent(z1, z2)\n            loss.backward(); opt.step()\n        bs = a.size(0); tot_loss += float(loss.item()) * bs; n += bs\n    print(f\"Epoch {epoch} train loss: {tot_loss / max(1,n):.5f}\")\n    # quick val\n    head.eval(); vloss=0.0; vn=0\n    with torch.no_grad():\n        for batch in val_loader:\n            a = batch['a'].to(DEVICE); b = batch['b'].to(DEVICE); prof = batch['profile'].to(DEVICE)\n            z1 = head(a, prof); z2 = head(b, prof); loss = nt_xent(z1, z2)\n            vloss += float(loss.item()) * a.size(0); vn += a.size(0)\n    if vn: print(f\"Val loss: {vloss/vn:.5f}\")\n\n# -------------------------\n# Compute final embeddings for all images and build graph\n# -------------------------\nprint(\"Computing final embeddings for all images ...\")\nall_ds = PrecompDataset(feats_a, feats_b, profiles, ids)\nall_loader = DataLoader(all_ds, batch_size=256, shuffle=False, num_workers=0)\nhead.eval()\nembs = []\nwith torch.no_grad():\n    for batch in tqdm(all_loader):\n        f = batch['a'].to(DEVICE); prof = batch['profile'].to(DEVICE)\n        z = head(f, prof)\n        embs.append(z.cpu().numpy())\nemb_all = np.vstack(embs)\n# normalize\nemb_all = emb_all / (np.linalg.norm(emb_all, axis=1, keepdims=True) + 1e-9)\nprint(\"Embeddings shape:\", emb_all.shape)\n\n# nearest neighbors\nprint(\"Building nearest neighbors ...\")\nnn = NearestNeighbors(n_neighbors=min(TOP_K+1, len(emb_all)), metric='cosine', n_jobs=-1)\nnn.fit(emb_all)\ndists, idxs = nn.kneighbors(emb_all)\n\nneighbors = {}\nfor i, id_ in enumerate(ids):\n    neighs = []\n    for j_idx, d in zip(idxs[i], dists[i]):\n        if j_idx == i: continue\n        sim = 1.0 - float(d)\n        neighs.append((ids[j_idx], sim))\n        if len(neighs) >= TOP_K: break\n    neighbors[id_] = neighs\n\n# union-find assembly with threshold\nclass UF:\n    def __init__(self):\n        self.parent = {}\n    def find(self, x):\n        if x not in self.parent: self.parent[x] = x; return x\n        while self.parent[x] != x:\n            self.parent[x] = self.parent[self.parent[x]]\n            x = self.parent[x]\n        return x\n    def union(self, a, b):\n        ra, rb = self.find(a), self.find(b)\n        if ra != rb: self.parent[rb] = ra\n    def comps(self):\n        groups = defaultdict(list)\n        for x in self.parent.keys():\n            groups[self.find(x)].append(x)\n        return list(groups.values())\n\nuf = UF()\nfor node, neighs in neighbors.items():\n    uf.find(node)\n    for nid, sim in neighs:\n        if sim >= SIMILARITY_THRESHOLD:\n            uf.union(node, nid)\ncomponents = uf.comps()\nif not components:\n    components = [[k] for k in neighbors.keys()]\nprint(\"Assembled components:\", len(components))\n\n# create map shard_id -> component_id\ncomp_map = {}\nfor cid, comp in enumerate(components):\n    for s in comp:\n        comp_map[s] = int(cid)\n\n# Save a generic components CSV (for debugging / reuse)\nrows = []\nfor cid, comp in enumerate(components):\n    for s in comp:\n        rows.append({'component_id': cid, 'shard_id': s})\npd.DataFrame(rows).to_csv('/kaggle/working/assembled_components.csv', index=False)\nprint(\"Saved /kaggle/working/assembled_components.csv\")\n\n# -------------------------\n# Build submission using sample_submission.csv header (robust)\n# -------------------------\nsample_submission_path = None\nfor c in csv_candidates:\n    if 'sample' in c.name.lower():\n        sample_submission_path = c\n        break\nif sample_submission_path is None:\n    # try common name\n    if (DATA_ROOT / 'sample_submission.csv').exists():\n        sample_submission_path = DATA_ROOT / 'sample_submission.csv'\n\nif sample_submission_path is None:\n    print(\"Warning: sample_submission.csv not found in dataset. Generating a best-effort submission with columns image_id,component_id.\")\n    id_col = 'image_id'\n    pred_col = 'component_id'\n    sample_df = pd.DataFrame({id_col: [x for x in ids]})\nelse:\n    sample_df = pd.read_csv(sample_submission_path)\n    id_col = sample_df.columns[0]\n    if len(sample_df.columns) < 2:\n        raise RuntimeError(\"Sample submission seems to have only one column; cannot infer prediction column.\")\n    pred_col = sample_df.columns[1]\n    print(\"Using sample submission:\", sample_submission_path.name, \"id_col:\", id_col, \"pred_col:\", pred_col)\n\n# Attempt to map sample IDs to your shard ids:\n# sample IDs may be filename stems or filenames with extension. We'll try several strategies.\ndef map_id_to_comp(value):\n    # try direct match\n    if value in comp_map:\n        return comp_map[value]\n    # try as filename (with extension) -> check stems\n    val = str(value)\n    stem = Path(val).stem\n    if stem in comp_map:\n        return comp_map[stem]\n    # try adding common extensions\n    for ext in ['.png','.jpg','.jpeg','.tif','.tiff']:\n        if (stem + ext) in comp_map:\n            return comp_map[stem + ext]\n    # try removing possible prefix/suffix\n    if val.startswith('img_') and val[4:] in comp_map:\n        return comp_map[val[4:]]\n    # not found\n    return -1\n\n# Build submission frame\nout_sub = sample_df[[id_col]].copy()\nout_sub[pred_col] = out_sub[id_col].apply(map_id_to_comp).astype(int)\n\n# Diagnostics: count missing\nn_missing = int((out_sub[pred_col] == -1).sum())\nprint(f\"Mapped predictions for {len(out_sub)} rows; missing/unmapped: {n_missing}\")\n\n# If many missing, try fallback: if sample ids are not the same universe, try using our ids in order\nif n_missing > 0.5 * len(out_sub):\n    print(\"Many sample IDs could not be matched to internal shard IDs. As a fallback we'll attempt to align by filename order.\")\n    # If sample contains same number of rows as our ids, map by order\n    if len(out_sub) == len(ids):\n        fallback_map = dict(zip(ids, [comp_map.get(i, -1) for i in ids]))\n        out_sub[pred_col] = [fallback_map.get(Path(v).stem, -1) for v in out_sub[id_col]]\n        n_missing = int((out_sub[pred_col] == -1).sum())\n        print(\"After fallback mapping, missing:\", n_missing)\n    else:\n        print(\"Fallback by order not possible: sample size != number of images.\")\n\n# Final save\nsubmission_path = Path('/kaggle/working/submission.csv')\nout_sub.to_csv(submission_path, index=False)\nprint(\"Saved submission to\", submission_path)\nprint(\"Submission columns:\", list(out_sub.columns))\nprint(\"You can now upload /kaggle/working/submission.csv to the competition.\")\n\n# End\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null}]}