{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":127282,"databundleVersionId":15520105}],"dockerImageVersionId":31328,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"cell-md-header","cell_type":"markdown","source":"\n\n##  Table of Contents\n1. [Setup & Aesthetics](#1-setup)\n2. [Exploratory Data Analysis](#2-eda)\n3. [Feature Extraction — MegaDescriptor-L-384](#3-features)\n4. [The Lookup + Discovery Algorithm](#4-algorithm)\n5. [Submission Formatting](#5-submission)\n6. [Conclusion & Next Steps](#6-conclusion)","metadata":{}},{"id":"38c87cd4-210e-4cde-af4c-c26620b8e1c2","cell_type":"code","source":"import os\nfrom pathlib import Path\n\nprint(\"🔍 Searching for CSV files in /kaggle/input...\\n\")\n\n# rglob recursively searches through all folders for files ending in .csv\ncsv_files = list(Path('/kaggle/input').rglob('*.csv'))\n\nif not csv_files:\n    print(\"⚠️ No CSV files found! Make sure the dataset is attached to the notebook.\")\nelse:\n    for file_path in csv_files:\n        print(file_path)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-24T15:05:59.530674Z","iopub.execute_input":"2026-03-24T15:05:59.531493Z","iopub.status.idle":"2026-03-24T15:06:09.738373Z","shell.execute_reply.started":"2026-03-24T15:05:59.531443Z","shell.execute_reply":"2026-03-24T15:06:09.737790Z"}},"outputs":[],"execution_count":null},{"id":"cell-setup","cell_type":"code","source":"# ============================================================\n# 1. SETUP & AESTHETICS\n# ============================================================\n# Install / upgrade key dependencies (Kaggle environment)\n# !pip install -q wildlife-datasets wildlife-tools timm hdbscan\n\nimport os\nimport warnings\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn.functional as F\nfrom torch.utils.data import DataLoader, Dataset\nfrom torchvision import transforms\nfrom PIL import Image\nfrom pathlib import Path\nfrom tqdm.auto import tqdm\n\nimport timm\nimport plotly.graph_objects as go\nimport plotly.express as px\nfrom plotly.subplots import make_subplots\n\nfrom sklearn.cluster import DBSCAN\nfrom sklearn.preprocessing import normalize\nfrom sklearn.metrics.pairwise import cosine_similarity\n\nwarnings.filterwarnings(\"ignore\")\n\n# ── Custom colour palette ──────────────────────────────────\nPALETTE = {\n    \"lynx\":       \"#a78bfa\",   # violet\n    \"salamander\": \"#34d399\",   # emerald\n    \"turtle\":     \"#38bdf8\",   # sky\n    \"lizard\":     \"#fb923c\",   # orange\n    \"bg\":         \"#0f172a\",\n    \"grid\":       \"#1e293b\",\n    \"text\":       \"#e2e8f0\",\n}\n\nSPECIES_COLORS = list(PALETTE.values())[:4]\n\n# ── Reproducibility ────────────────────────────────────────\nSEED = 42\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\n# ── Device ─────────────────────────────────────────────────\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"🔧  Using device : {DEVICE}\")\nprint(f\"🔧  CUDA available: {torch.cuda.is_available()}\")\n\n# ── Paths (Kaggle competition layout) ──────────────────────\nDATA_ROOT = Path(\"/kaggle/input/competitions/animal-clef-2026\")\nOUTPUT_DIR  = Path(\"/kaggle/working/\")\nSAMPLE_SUB  = Path(\"/kaggle/input/competitions/animal-clef-2026/sample_submission.csv\")\n# ── Algorithm hyper-parameters ─────────────────────────────\nLOOKUP_THRESHOLD = 0.85   # cosine similarity above → known identity\nBATCH_SIZE       = 64\nIMG_SIZE         = 384    # MegaDescriptor-L-384 native resolution\n\n# ── Species registry ───────────────────────────────────────\n# Maps dataset split-folder name → submission prefix\nSPECIES_CONFIG = {\n    \"LynxID2025\":           \"cluster_LynxID2025\",\n    \"SalamanderID2025\":     \"cluster_SalamanderID2025\",\n    \"SeaTurtleID2022\":      \"cluster_SeaTurtleID2022\",\n    \"TexasHornedLizards\":   \"cluster_TexasHornedLizards\",\n}\n\n# Texas Horned Lizards have NO training data → pure discovery\nDISCOVERY_ONLY = {\"TexasHornedLizards\"}\n\nprint(\"✅  Setup complete.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-24T15:06:09.739759Z","iopub.execute_input":"2026-03-24T15:06:09.740007Z","iopub.status.idle":"2026-03-24T15:06:09.751011Z","shell.execute_reply.started":"2026-03-24T15:06:09.739984Z","shell.execute_reply":"2026-03-24T15:06:09.750425Z"}},"outputs":[],"execution_count":null},{"id":"cell-md-eda","cell_type":"markdown","source":"---\n## 2. Exploratory Data Analysis <a id='2-eda'></a>\n\nBefore modelling, we must **understand the data landscape**: how many images exist per species, and how they're split between train and test. This directly informs our pipeline design.","metadata":{}},{"id":"cell-eda","cell_type":"code","source":"# ============================================================\n# 2. EXPLORATORY DATA ANALYSIS (CORRECTED PATH)\n# ============================================================\nimport pandas as pd\nimport plotly.graph_objects as go\nimport plotly.express as px\nfrom pathlib import Path\n\n# ── 1. Update the DATA_ROOT to the exact path we just found! ──\nDATA_ROOT = Path(\"/kaggle/input/competitions/animal-clef-2026\")\n\n# ── 2. Load the single metadata file ──────────────────────────\nprint(f\"⏳ Loading metadata from: {DATA_ROOT / 'metadata.csv'}\")\nmetadata_df = pd.read_csv(DATA_ROOT / \"metadata.csv\")\n\n# We group the master dataframe by 'dataset' (which holds the species folder name) \n# and 'split' (Train/Test) to get the image counts and unique identities.\nstats = metadata_df.groupby(['dataset', 'split']).agg(\n    images=('image_id', 'count'),\n    identities=('identity', 'nunique')\n).reset_index()\n\n# Rename the column so it matches your plotting variables\nstats.rename(columns={'dataset': 'species'}, inplace=True)\n\n# Add TexasHornedLizards if it doesn't appear in Train (to avoid plot errors)\n# It only exists in the test set.\nknown_species = [\"LynxID2025\", \"SalamanderID2025\", \"SeaTurtleID2022\", \"TexasHornedLizards\"]\nmissing_rows = []\nfor sp in known_species:\n    for sp_split in ['Train', 'Test']:\n        if not ((stats['species'] == sp) & (stats['split'] == sp_split)).any():\n            missing_rows.append({'species': sp, 'split': sp_split, 'images': 0, 'identities': 0})\n\nif missing_rows:\n    stats = pd.concat([stats, pd.DataFrame(missing_rows)], ignore_index=True)\n\nprint(stats.to_string(index=False))\n\n# ── Plotly grouped bar chart ──────────────────────────────\nSPECIES_COLORS = [\"#a78bfa\", \"#34d399\", \"#38bdf8\", \"#fb923c\"]\nPALETTE = {\"text\": \"#e2e8f0\", \"bg\": \"#0f172a\", \"grid\": \"#1e293b\"}\n\nspecies_list = stats[\"species\"].unique().tolist()\ncolor_map    = dict(zip(species_list, SPECIES_COLORS))\n\nfig = go.Figure()\nfor split, dash in [(\"Train\", \"solid\"), (\"Test\", \"dot\")]:\n    subset = stats[stats[\"split\"] == split]\n    fig.add_trace(go.Bar(\n        name=split,\n        x=subset[\"species\"],\n        y=subset[\"images\"],\n        marker_color=[color_map.get(s, \"#ffffff\") for s in subset[\"species\"]],\n        marker_line_width=0,\n        opacity=1.0 if split == \"Train\" else 0.55,\n        text=subset[\"images\"],\n        textposition=\"outside\",\n        textfont=dict(color=PALETTE[\"text\"], size=12),\n    ))\n\nfig.update_layout(\n    title=dict(\n        text=\"<b>AnimalCLEF 2026 — Image Distribution by Species & Split</b>\",\n        font=dict(size=18, color=PALETTE[\"text\"])\n    ),\n    barmode=\"group\",\n    paper_bgcolor=PALETTE[\"bg\"],\n    plot_bgcolor=PALETTE[\"bg\"],\n    font=dict(color=PALETTE[\"text\"]),\n    xaxis=dict(gridcolor=PALETTE[\"grid\"], title=\"Species\"),\n    yaxis=dict(gridcolor=PALETTE[\"grid\"], title=\"Number of Images\"),\n    legend=dict(bgcolor=PALETTE[\"grid\"], bordercolor=\"#475569\"),\n    margin=dict(t=70, b=50, l=60, r=30),\n    height=480,\n)\nfig.show()\n\n# ── Second chart: known identities per species ─────────────\ntrain_stats = stats[stats[\"split\"] == \"Train\"].copy()\n# Filter out species with 0 training identities (like TexasHornedLizards)\ntrain_stats = train_stats[train_stats[\"identities\"] > 0]\n\nfig2 = px.bar(\n    train_stats, x=\"species\", y=\"identities\",\n    color=\"species\",\n    color_discrete_sequence=SPECIES_COLORS,\n    text=\"identities\",\n    title=\"<b>Known Identities in Training Set (3 species only)</b>\",\n    template=\"plotly_dark\",\n    height=420,\n)\nfig2.update_traces(textposition=\"outside\")\nfig2.update_layout(showlegend=False,\n                   paper_bgcolor=PALETTE[\"bg\"],\n                   plot_bgcolor=PALETTE[\"bg\"])\nfig2.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-24T15:06:09.751813Z","iopub.execute_input":"2026-03-24T15:06:09.752059Z","iopub.status.idle":"2026-03-24T15:06:09.869496Z","shell.execute_reply.started":"2026-03-24T15:06:09.752032Z","shell.execute_reply":"2026-03-24T15:06:09.868889Z"}},"outputs":[],"execution_count":null},{"id":"cell-md-obs","cell_type":"markdown","source":"> 💡 **Observation:** Notice that **TexasHornedLizards has zero training images** — no known reference individuals exist. This forces us to adopt a **100% unsupervised discovery** strategy for this species (pure DBSCAN clustering), while for the other three species (Lynx, Salamander, SeaTurtle) we can first *look up* known identities via cosine-similarity retrieval before clustering the remaining unmatched images.\n\n> ⚠️ **ARI Warning:** The Adjusted Rand Index is symmetric — it punishes **both** merging different animals into one cluster *and* splitting one animal into many clusters. Our threshold of `0.85` is deliberately strict to minimise false lookups.","metadata":{}},{"id":"cell-dataset","cell_type":"code","source":"# ============================================================\n# 2b. BUILD UNIFIED METADATA DATAFRAMES (FIXED CAPS)\n# ============================================================\nimport pandas as pd\nfrom pathlib import Path\n\nDATA_ROOT = Path(\"/kaggle/input/competitions/animal-clef-2026\")\nprint(f\"⏳ Loading master metadata from {DATA_ROOT / 'metadata.csv'}...\")\nmaster_metadata = pd.read_csv(DATA_ROOT / \"metadata.csv\")\n\nknown_species = [\"LynxID2025\", \"SalamanderID2025\", \"SeaTurtleID2022\", \"TexasHornedLizards\"]\nSPECIES_DFS = {}\n\nfor species in known_species:\n    species_df = master_metadata[master_metadata['dataset'] == species].copy()\n    \n    #  FIX: Use lowercase 'train' and 'test' to match the CSV!\n    df_train = species_df[species_df['split'] == 'train'].copy()\n    df_test  = species_df[species_df['split'] == 'test'].copy()\n    \n    # Format Test\n    if not df_test.empty:\n        df_test[\"species\"] = species\n    else:\n        df_test = pd.DataFrame(columns=master_metadata.columns) # Keep column structure\n        \n    # Format Train\n    if not df_train.empty:\n        df_train[\"species\"] = species\n    else:\n        df_train = None\n        \n    SPECIES_DFS[species] = {\"train\": df_train, \"test\": df_test}\n    \n    n_tr = len(df_train) if df_train is not None else 0\n    print(f\"  {species:26s}  train={n_tr:5d}  test={len(df_test):5d}\")\n\nprint(\"\\n  Metadata loaded.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-24T15:06:09.870333Z","iopub.execute_input":"2026-03-24T15:06:09.870614Z","iopub.status.idle":"2026-03-24T15:06:09.928261Z","shell.execute_reply.started":"2026-03-24T15:06:09.870591Z","shell.execute_reply":"2026-03-24T15:06:09.927420Z"}},"outputs":[],"execution_count":null},{"id":"cell-md-feat","cell_type":"markdown","source":"---\n## 3. Feature Extraction — MegaDescriptor-L-384 <a id='3-features'></a>\n\n**MegaDescriptor-L-384** is a ViT-Large backbone pre-trained specifically for wildlife re-identification across multiple species via metric learning. It produces a **512-d L2-normalised embedding** that is highly discriminative for individual animal identity, making it the perfect backbone for this pipeline.","metadata":{}},{"id":"cell-extract-loop","cell_type":"code","source":"# ============================================================\n# 3. FEATURE EXTRACTION (UNIFIED)\n# ============================================================\n\n\n# ── Ensure Device is set (Safety catch for Commit mode) ────\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"🔧 Using device : {DEVICE}\")\n\n# ── 3a. Model loading ─────────────────────────────────────\nprint(\"⏳ Loading MegaDescriptor-L-384 from HuggingFace …\")\nmodel = timm.create_model(\n    \"hf-hub:BVRA/MegaDescriptor-L-384\",\n    pretrained=True,\n    num_classes=0,         # remove head → raw embeddings\n)\nmodel = model.eval().to(DEVICE)\nprint(f\"✅ Model loaded — embedding dim: {model.num_features}\")\n\n# ── 3b. Image transforms ──────────────────────────────────\nIMG_SIZE = 384\nTRANSFORM = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE),\n                      interpolation=transforms.InterpolationMode.BICUBIC),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406],\n                         std =[0.229, 0.224, 0.225]),\n])\n\n# ── 3c. PyTorch Dataset wrapper (Safe Version) ─────────────\nclass AnimalDataset(Dataset):\n    \"\"\"Lightweight dataset that loads images on-the-fly.\"\"\"\n\n    def __init__(self, df: pd.DataFrame, img_col: str = \"path\",\n                 transform=None, root: Path = DATA_ROOT):\n        # ⚠️ FIX: Safely convert paths to strings before combining\n        self.paths     = [root / str(p) for p in df[img_col].tolist()]\n        self.image_ids = df[\"image_id\"].tolist() if \"image_id\" in df.columns else list(range(len(df)))\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.paths)\n\n    def __getitem__(self, idx):\n        try:\n            img = Image.open(self.paths[idx]).convert(\"RGB\")\n            if self.transform:\n                img = self.transform(img)\n            return img, self.image_ids[idx]\n        except Exception as e:\n            # Fallback for corrupted images so pipeline doesn't crash\n            return torch.zeros((3, IMG_SIZE, IMG_SIZE)), self.image_ids[idx]\n\n# ── 3d. Extraction Function ───────────────────────────────\n@torch.no_grad()\ndef extract_embeddings(df: pd.DataFrame,\n                       model: torch.nn.Module,\n                       transform,\n                       batch_size: int = 64,  # Fallback to 64 if BATCH_SIZE isn't defined\n                       img_col: str = \"path\") -> tuple[np.ndarray, list]:\n    \"\"\"Extract L2-normalised embeddings for all images in *df*.\"\"\"\n    \n    # Use BATCH_SIZE from your setup cell if it exists, otherwise use 64\n    bs = globals().get('BATCH_SIZE', batch_size) \n    \n    dataset = AnimalDataset(df, img_col=img_col, transform=transform)\n    loader  = DataLoader(dataset, batch_size=bs,\n                         num_workers=4, pin_memory=True, shuffle=False)\n\n    all_embeddings = []\n    all_ids        = []\n\n    for imgs, ids in tqdm(loader, desc=\"  Extracting\", leave=False):\n        imgs = imgs.to(DEVICE, non_blocking=True)\n        feats = model(imgs)                     # forward pass\n        feats = F.normalize(feats, p=2, dim=1)  # L2-normalise on GPU\n        all_embeddings.append(feats.cpu().numpy())\n        all_ids.extend(ids if isinstance(ids[0], str) else ids.tolist())\n\n    embeddings = np.vstack(all_embeddings)      # (N, D)\n    return embeddings, all_ids\n\n\n# ── 3e. Extract embeddings for all species (train + test) ──\nprint(\"\\n🚀 Starting Feature Extraction Pipeline...\")\nEMBEDDINGS = {}    # species → {\"train_emb\", \"train_ids\", \"train_labels\", \"test_emb\",  \"test_ids\"}\n\nfor species, dfs in SPECIES_DFS.items():\n    print(f\"\\n🔍 [{species}]\")\n    entry = {}\n\n    # ── Test embeddings (always needed) ──\n    df_te = dfs[\"test\"]\n    if not df_te.empty:\n        test_emb, test_ids = extract_embeddings(df_te, model, TRANSFORM)\n        print(f\"     test  embeddings: {test_emb.shape}\")\n        entry.update({\"test_emb\": test_emb, \"test_ids\": test_ids})\n    else:\n        print(\"     ⚠️ No test images found for this species.\")\n        entry.update({\"test_emb\": np.array([]), \"test_ids\": []})\n\n    # ── Train embeddings (only for species with training data) ──\n    df_tr = dfs[\"train\"]\n    if df_tr is not None and not df_tr.empty:\n        train_emb, train_ids = extract_embeddings(df_tr, model, TRANSFORM)\n        \n        # Keep ground-truth identity labels aligned with embeddings\n        # Use set_index safely\n        temp_tr = df_tr.set_index(\"image_id\")\n        train_labels = [temp_tr.loc[tid, \"identity\"] for tid in train_ids]\n        \n        print(f\"     train embeddings: {train_emb.shape}\")\n        entry.update({\n            \"train_emb\":    train_emb,\n            \"train_ids\":    train_ids,\n            \"train_labels\": train_labels,\n        })\n    else:\n        print(\"     ⚠️ No training images found for this species.\")\n\n    EMBEDDINGS[species] = entry\n\nprint(\"\\n✅ All embeddings extracted.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-24T15:21:19.880132Z","iopub.execute_input":"2026-03-24T15:21:19.880668Z","iopub.status.idle":"2026-03-24T15:21:28.774221Z","shell.execute_reply.started":"2026-03-24T15:21:19.880639Z","shell.execute_reply":"2026-03-24T15:21:28.766923Z"}},"outputs":[],"execution_count":null},{"id":"cell-md-algo","cell_type":"markdown","source":"---\n## 4. The Lookup + Discovery Algorithm <a id='4-algorithm'></a>\n\n```\n  For each species:\n  ─────────────────────────────────────────────────────────────\n  STEP A — LOOKUP (known identities)\n    ┌─────────────────────────────────────────────────────┐\n    │  sim = cosine_similarity(test_emb, train_emb)       │\n    │  If max(sim[i]) >= 0.85  →  assign train identity   │\n    └─────────────────────────────────────────────────────┘\n\n  STEP B — DISCOVERY (unseen individuals)\n    ┌─────────────────────────────────────────────────────┐\n    │  Take remaining unmatched test images               │\n    │  Run DBSCAN on their pairwise cosine distances      │\n    │  Assign each DBSCAN cluster a *unique new ID*       │\n    │  ⚠️  Noise points (label=-1) → each gets its OWN   │\n    │      unique cluster ID too (critical for ARI)       │\n    └─────────────────────────────────────────────────────┘\n```","metadata":{}},{"id":"cell-algo","cell_type":"code","source":"# ============================================================\n# 4. LOOKUP + DISCOVERY ALGORITHM\n# ============================================================\n\nfrom sklearn.cluster import DBSCAN\n\n# DBSCAN hyper-parameters (cosine metric space)\n# eps: max cosine-distance to be considered a neighbour\n# min_samples: smaller → fewer noise points (conservative for ARI)\nDBSCAN_EPS         = 0.30    # equivalently, cosine_sim > 0.70 → neighbours\nDBSCAN_MIN_SAMPLES = 2\n\n\ndef lookup_step(\n    test_emb:    np.ndarray,\n    test_ids:    list,\n    train_emb:   np.ndarray,\n    train_labels: list,\n    threshold:   float = LOOKUP_THRESHOLD,\n) -> tuple[dict, list, list]:\n    \"\"\"STEP A — Cosine-similarity retrieval against known train identities.\n\n    Parameters\n    ----------\n    test_emb     : (N, D) L2-normalised test embeddings\n    test_ids     : list of N image IDs for test images\n    train_emb    : (M, D) L2-normalised train embeddings\n    train_labels : list of M ground-truth train identities\n    threshold    : cosine similarity cutoff for a confident match\n\n    Returns\n    -------\n    matched_clusters  : dict  {image_id: identity_string}\n    unmatched_emb     : np.ndarray  embeddings of unmatched images\n    unmatched_ids     : list         IDs of unmatched images\n    \"\"\"\n    # Cosine similarity matrix: (N_test, M_train)\n    # Because embeddings are already L2-normalised, this is just a dot product.\n    sim_matrix = test_emb @ train_emb.T          # shape (N, M)\n\n    matched_clusters  = {}\n    unmatched_indices = []\n\n    for i, img_id in enumerate(test_ids):\n        best_sim_idx = int(np.argmax(sim_matrix[i]))\n        best_sim_val = float(sim_matrix[i, best_sim_idx])\n\n        if best_sim_val >= threshold:\n            # Confident match → use the train identity directly\n            matched_clusters[img_id] = train_labels[best_sim_idx]\n        else:\n            # Below threshold → send to discovery\n            unmatched_indices.append(i)\n\n    unmatched_emb = test_emb[unmatched_indices]\n    unmatched_ids = [test_ids[i] for i in unmatched_indices]\n\n    return matched_clusters, unmatched_emb, unmatched_ids\n\n\ndef discovery_step(\n    unmatched_emb: np.ndarray,\n    unmatched_ids: list,\n    cluster_id_offset: int,\n    eps: float       = DBSCAN_EPS,\n    min_samples: int = DBSCAN_MIN_SAMPLES,\n) -> tuple[dict, int]:\n    \"\"\"STEP B — DBSCAN clustering on unmatched test images.\n\n    ⚠️  CRITICAL ARI STRATEGY:\n    DBSCAN labels noise points as -1.  If we naively submit all\n    noise points under a single cluster, ARI will tank because\n    we are merging many distinct individuals into one cluster.\n    Instead, EVERY noise point receives its own unique cluster ID.\n\n    Parameters\n    ----------\n    unmatched_emb    : (K, D) embeddings of unmatched images\n    unmatched_ids    : list of K image IDs\n    cluster_id_offset: integer to add to discovered cluster IDs\n                       so they never collide with lookup identities\n    eps              : DBSCAN neighbourhood radius (cosine distance)\n    min_samples      : DBSCAN minimum cluster size\n\n    Returns\n    -------\n    discovered_clusters : dict {image_id: new_unique_cluster_int}\n    next_id_offset      : updated integer for next species\n    \"\"\"\n    if len(unmatched_ids) == 0:\n        return {}, cluster_id_offset\n\n    # Cosine distance = 1 - cosine_similarity\n    # DBSCAN with metric='cosine' handles this natively\n    db = DBSCAN(eps=eps, min_samples=min_samples, metric=\"cosine\", n_jobs=-1)\n    raw_labels = db.fit_predict(unmatched_emb)   # shape (K,)  values ∈ {-1, 0, 1, …}\n\n    # Map DBSCAN labels → globally unique cluster IDs\n    dbscan_to_global = {}   # raw_label (≥0) → global cluster int\n    next_id = cluster_id_offset\n\n    # First pass: assign IDs to proper DBSCAN clusters\n    for raw_lbl in np.unique(raw_labels):\n        if raw_lbl >= 0:\n            dbscan_to_global[int(raw_lbl)] = next_id\n            next_id += 1\n\n    discovered_clusters = {}\n    for img_id, raw_lbl in zip(unmatched_ids, raw_labels):\n        if raw_lbl >= 0:\n            # Belongs to a proper DBSCAN cluster\n            discovered_clusters[img_id] = dbscan_to_global[int(raw_lbl)]\n        else:\n            # ⚠️  Noise point → give it an exclusive unique cluster ID\n            discovered_clusters[img_id] = next_id\n            next_id += 1\n\n    print(f\"     DBSCAN: {len(unmatched_ids)} images → \"\n          f\"{len(dbscan_to_global)} clusters + \"\n          f\"{(raw_labels == -1).sum()} noise (each → unique ID)\")\n\n    return discovered_clusters, next_id\n\n\nprint(\"✅  Pipeline functions defined.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-run-pipeline","cell_type":"code","source":"# ── Run the full pipeline for every species ────────────────\n\n# We maintain a single global integer counter for cluster IDs.\n# Known train identities are stored as strings (their original labels).\n# Discovered clusters get unique integers, prepended with the species prefix at submission time.\n\nall_predictions: dict[str, dict] = {}  # species → {image_id: cluster_int_or_str}\nglobal_cluster_counter = 1_000_000     # start high to avoid collisions with train IDs\n\nfor species, entry in EMBEDDINGS.items():\n    print(f\"\\n{'='*60}\")\n    print(f\"  Species : {species}\")\n    print(f\"{'='*60}\")\n\n    test_emb  = entry[\"test_emb\"]\n    test_ids  = entry[\"test_ids\"]\n\n    preds = {}   # image_id → cluster label (str for known, int for new)\n\n    if species in DISCOVERY_ONLY:\n        # ── 100% Discovery (TexasHornedLizards) ──────────────\n        print(\"  ⚡  No training data — running 100% DBSCAN discovery.\")\n        discovered, global_cluster_counter = discovery_step(\n            test_emb, test_ids,\n            cluster_id_offset=global_cluster_counter,\n        )\n        preds.update(discovered)\n\n    else:\n        # ── Step A: Lookup ────────────────────────────────────\n        print(\"  🔎  Step A — Lookup …\")\n        train_emb    = entry[\"train_emb\"]\n        train_labels = entry[\"train_labels\"]\n\n        matched, unmatched_emb, unmatched_ids = lookup_step(\n            test_emb, test_ids,\n            train_emb, train_labels,\n            threshold=LOOKUP_THRESHOLD,\n        )\n        preds.update(matched)\n        print(f\"     Matched (lookup) : {len(matched):4d} / {len(test_ids)}\")\n        print(f\"     Unmatched (disco): {len(unmatched_ids):4d} / {len(test_ids)}\")\n\n        # ── Step B: Discovery ─────────────────────────────────\n        print(\"  🔬  Step B — Discovery …\")\n        discovered, global_cluster_counter = discovery_step(\n            unmatched_emb, unmatched_ids,\n            cluster_id_offset=global_cluster_counter,\n        )\n        preds.update(discovered)\n\n    all_predictions[species] = preds\n    print(f\"  ✅  Total predicted images: {len(preds)}\")\n\nprint(\"\\n🚀  Pipeline complete!\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-md-sub","cell_type":"markdown","source":"---\n## 5. Submission Formatting <a id='5-submission'></a>\n\nThe competition requires cluster labels in the exact format:\n```\ncluster_LynxID2025_<int>\ncluster_SalamanderID2025_<int>\ncluster_SeaTurtleID2022_<int>\ncluster_TexasHornedLizards_<int>\n```\nKnown train identities are already meaningful strings — we convert them to a numeric index. All discovered cluster integers map directly to `???`.","metadata":{}},{"id":"cell-submission","cell_type":"code","source":"# ============================================================\n# 5. SUBMISSION FORMATTING\n# ============================================================\n\ndef format_cluster_label(species: str, raw_label, identity_encoder: dict) -> str:\n    \"\"\"Convert a raw prediction (str identity or int cluster) to the\n    required submission format: 'cluster_<Species>_<int>'.\n\n    Parameters\n    ----------\n    species          : dataset species key (e.g. 'SeaTurtleID2022')\n    raw_label        : str (known identity) or int (discovered cluster)\n    identity_encoder : dict that maps known identity strings → int\n\n    Returns\n    -------\n    formatted label string\n    \"\"\"\n    prefix = SPECIES_CONFIG[species]   # e.g. 'cluster_SeaTurtleID2022'\n\n    if isinstance(raw_label, str):\n        # Known train identity — encode to a consistent integer\n        cluster_id = identity_encoder[raw_label]\n    else:\n        # Discovered cluster — raw_label is already a unique integer\n        cluster_id = int(raw_label)\n\n    return f\"{prefix}_{cluster_id}\"\n\n\n# ── Build per-species identity encoder ────────────────────\n# Maps each known train identity string → small unique int\n# Starting at 1 to keep things readable.\nidentity_encoders: dict[str, dict] = {}\nfor species, dfs in SPECIES_DFS.items():\n    if dfs[\"train\"] is not None:\n        unique_ids = sorted(dfs[\"train\"][\"identity\"].unique())\n        identity_encoders[species] = {uid: (i + 1) for i, uid in enumerate(unique_ids)}\n    else:\n        identity_encoders[species] = {}\n\n\n# ── Convert all predictions → formatted strings ────────────\nrows = []  # list of {\"image_id\": ..., \"cluster\": ...}\nfor species, preds in all_predictions.items():\n    enc = identity_encoders[species]\n    for img_id, raw_label in preds.items():\n        cluster_str = format_cluster_label(species, raw_label, enc)\n        rows.append({\"image_id\": img_id, \"cluster\": cluster_str})\n\nsubmission_raw = pd.DataFrame(rows)\nprint(f\"Raw submission shape : {submission_raw.shape}\")\nprint(submission_raw.head(10).to_string(index=False))\n\n# ── Align to sample_submission.csv order (REQUIRED) ───────\nsample_sub = pd.read_csv(SAMPLE_SUB)\nprint(f\"\\nSample submission rows: {len(sample_sub)}\")\n\n# Left-join to preserve sample_submission row order exactly\nfinal_submission = sample_sub[[\"image_id\"]].merge(\n    submission_raw, on=\"image_id\", how=\"left\"\n)\n\n# Safety: if any image_id is still missing a prediction, fill with a\n# unique singleton cluster so ARI is not hurt by NaN\nnull_mask = final_submission[\"cluster\"].isna()\nif null_mask.sum() > 0:\n    print(f\"\\n⚠️  {null_mask.sum()} image(s) missing predictions — assigning fallback singletons.\")\n    base_id = global_cluster_counter\n    fallback_ids = range(base_id, base_id + null_mask.sum())\n    final_submission.loc[null_mask, \"cluster\"] = [\n        f\"cluster_fallback_{fid}\" for fid in fallback_ids\n    ]\n\nassert final_submission[\"cluster\"].notna().all(), \"❌ Missing cluster assignments!\"\nassert len(final_submission) == len(sample_sub),   \"❌ Row count mismatch!\"\n\n# ── Save ──────────────────────────────────────────────────\nout_path = OUTPUT_DIR / \"submission.csv\"\nfinal_submission.to_csv(out_path, index=False)\nprint(f\"\\n✅  Submission saved → {out_path}\")\nprint(f\"    Rows            : {len(final_submission):,}\")\nprint(f\"    Unique clusters : {final_submission['cluster'].nunique():,}\")\nprint(\"\\nCluster prefix distribution:\")\nprefix_counts = final_submission[\"cluster\"].str.extract(r\"(cluster_[^_]+_[^_]+)\")[0].str.rsplit(\"_\", n=1).str[0].value_counts()\nprint(prefix_counts.to_string())","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-validate","cell_type":"code","source":"# ── Quick validation: spot-check label format ──────────────\nimport re\n\nVALID_PATTERNS = [\n    re.compile(r\"^cluster_LynxID2025_\\d+$\"),\n    re.compile(r\"^cluster_SalamanderID2025_\\d+$\"),\n    re.compile(r\"^cluster_SeaTurtleID2022_\\d+$\"),\n    re.compile(r\"^cluster_TexasHornedLizards_\\d+$\"),\n    re.compile(r\"^cluster_fallback_\\d+$\"),   # safety fallback pattern\n]\n\ndef is_valid_label(label: str) -> bool:\n    return any(p.match(label) for p in VALID_PATTERNS)\n\ninvalid_rows = final_submission[~final_submission[\"cluster\"].apply(is_valid_label)]\nif len(invalid_rows) == 0:\n    print(\"✅  All cluster labels pass format validation.\")\nelse:\n    print(f\"❌  {len(invalid_rows)} rows have invalid cluster label formats:\")\n    print(invalid_rows.head(20))\n\n# ── Preview ───────────────────────────────────────────────\nprint(\"\\n📊  Final submission preview:\")\nprint(final_submission.head(20).to_string(index=False))","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-viz-distribution","cell_type":"code","source":"# ── Visualise cluster size distribution ───────────────────\nfinal_submission[\"species_prefix\"] = final_submission[\"cluster\"].str.extract(\n    r\"(cluster_\\w+)_\\d+$\"\n)[0]\n\ncluster_sizes = (\n    final_submission.groupby([\"species_prefix\", \"cluster\"])\n    .size()\n    .reset_index(name=\"size\")\n)\n\nfig3 = px.histogram(\n    cluster_sizes,\n    x=\"size\",\n    color=\"species_prefix\",\n    nbins=40,\n    barmode=\"overlay\",\n    opacity=0.75,\n    title=\"<b>Cluster Size Distribution (images per predicted individual)</b>\",\n    labels={\"size\": \"Images per Cluster\", \"species_prefix\": \"Species\"},\n    color_discrete_sequence=SPECIES_COLORS,\n    template=\"plotly_dark\",\n    height=420,\n)\nfig3.update_layout(\n    paper_bgcolor=PALETTE[\"bg\"],\n    plot_bgcolor=PALETTE[\"bg\"],\n    xaxis=dict(gridcolor=PALETTE[\"grid\"]),\n    yaxis=dict(gridcolor=PALETTE[\"grid\"]),\n)\nfig3.show()\n\nprint(\"\\nCluster size statistics:\")\nprint(cluster_sizes[\"size\"].describe().round(2).to_string())","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-md-conclusion","cell_type":"markdown","source":"---\n## 6. Conclusion & Next Steps <a id='6-conclusion'></a>\n\n<div style=\"background: linear-gradient(135deg, #0f172a, #1e1b4b); border-left: 4px solid #a78bfa; border-radius: 10px; padding: 28px 36px; font-family: 'Segoe UI', sans-serif; color: #e2e8f0; margin-top: 10px;\">\n\n###  What We Built\n\n| Stage | Method | Purpose |\n|---|---|---|\n| Feature Extraction | **MegaDescriptor-L-384** (ViT-L, 384px) | State-of-the-art wildlife embeddings |\n| Step A — Lookup | **Cosine sim ≥ 0.85** → known identity | Match to train individuals |\n| Step B — Discovery | **DBSCAN** (cosine dist, eps=0.30) | Cluster unseen individuals |\n| Noise Handling | **Singleton assignment** for DBSCAN noise | Critical ARI fix |\n| TexasHornedLizards | **100% DBSCAN** (no train data) | Pure discovery |\n\n### 🚀 Next Steps \n\n1. **Fine-tune MegaDescriptor on WildlifeReID-10k** using **ArcFace loss** (margin = 0.5, scale = 64). The 140k images across 10k identities will dramatically close the domain gap and yield tighter embedding clusters, improving both lookup precision and discovery quality.\n\n2. **Threshold calibration**: Use a small held-out validation split to sweep `LOOKUP_THRESHOLD` ∈ [0.75, 0.95] and find the value that maximises ARI — the optimal threshold varies between species.\n\n3. **HDBSCAN** (hierarchical DBSCAN) instead of DBSCAN for the discovery step: HDBSCAN is more robust to varying density and typically produces fewer noise points — reducing the number of singletons and thus reducing ARI penalty from over-clustering.\n\n4. **Test-Time Augmentation (TTA)**: Average embeddings across 4–8 augmented views of each image (horizontal flip, colour jitter, random crop) to obtain more robust feature representations.\n\n5. **Re-ranking with DBA (Database-side Augmentation)**: Before the lookup step, augment train embeddings by averaging each identity's embeddings with its k-nearest neighbours to produce a smoother, more generalizable reference prototype per individual.\n\n6. **Ensemble**: Combine **MegaDescriptor-L-384** with **miewid-msv3** embeddings (concatenate + re-normalise) for a stronger retrieval signal.\n\n</div>\n\n","metadata":{}},{"id":"4c83210e-8a46-4399-bdf2-f127b13296cd","cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}