{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10.0"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":127282,"databundleVersionId":15520105,"isSourceIdPinned":false},{"sourceType":"datasetVersion","sourceId":15669959,"datasetId":10033748,"databundleVersionId":16607216}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"a0000001","cell_type":"markdown","source":"# AnimalCLEF 2026 — SeaTurtle Fine-tune + EPS Calibration (0.21)\n\nPublic score: **0.21** (+8.2% over baseline 0.19401)\n\n## Key insight\n\nThe official baseline uses MegaDescriptor-L-384 for SeaTurtleID2022 with `eps=0.40`. After fine-tuning the backbone on the SeaTurtle training split (438 identities, 8729 images) with ArcFace loss, the within-class distances shrink — so the old `eps` becomes too tight and produces near-100% singletons.\n\n**Fix**: run an eps grid search on the train split *using the fine-tuned backbone*, pick the eps that maximises ARI. That turns out to be `eps=0.51`.\n\nEverything else (Lynx, Salamander, Lizard) stays on pretrained weights with the original eps.\n\n## Datasets required\n| Kaggle dataset slug | Contents |\n|---|---|\n| `animal-clef-2026` | Competition images + metadata.csv |\n| `animalclef-turtle-finetuned` | `backbone_final.pth` (fine-tuned SeaTurtle checkpoint) |","metadata":{}},{"id":"a0000002","cell_type":"code","source":"import subprocess, sys\n\n# transformers must be pinned — MiewID breaks on >=5.x\nsubprocess.check_call([\n    sys.executable, '-m', 'pip', 'install', '-q',\n    'transformers==4.49.0',\n    'wildlife-datasets',\n    'wildlife-tools',\n    'timm>=0.9.0',\n])\nprint('✓ done')","metadata":{},"outputs":[],"execution_count":null},{"id":"a0000003","cell_type":"code","source":"import warnings; warnings.filterwarnings('ignore')\nimport os\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport torch\nimport timm\nimport torchvision.transforms as T\nfrom sklearn.cluster import DBSCAN\n\nprint(f'torch {torch.__version__} | cuda={torch.cuda.is_available()}')\nif torch.cuda.is_available():\n    print(f'GPU: {torch.cuda.get_device_name(0)}')\n\n# Show non-image files under /kaggle/input to verify dataset paths\nIMAGE_EXT = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff'}\nfor root, dirs, files in os.walk('/kaggle/input'):\n    for f in files:\n        if Path(f).suffix.lower() not in IMAGE_EXT:\n            print(os.path.join(root, f))","metadata":{},"outputs":[],"execution_count":null},{"id":"a0000004","cell_type":"markdown","source":"## Helper functions","metadata":{}},{"id":"a0000005","cell_type":"code","source":"def similarity_to_distance(sim: np.ndarray) -> np.ndarray:\n    \"\"\"Official AnimalCLEF formula: rescale so max similarity maps to 0.\"\"\"\n    max_sim = np.max(sim)\n    return (max_sim - np.maximum(sim, 0.0)) / max_sim\n\n\ndef relabel_negatives(labels: np.ndarray) -> np.ndarray:\n    \"\"\"Replace DBSCAN noise points (-1) with unique singleton IDs.\"\"\"\n    labels = np.array(labels)\n    noise = labels == -1\n    start = int(labels[~noise].max()) + 1 if np.any(~noise) else 0\n    labels[noise] = np.arange(start, start + noise.sum())\n    return labels\n\n\ndef run_dbscan(sim: np.ndarray, eps: float) -> np.ndarray:\n    dist = similarity_to_distance(sim)\n    labels = DBSCAN(eps=eps, metric='precomputed', min_samples=2).fit(dist).labels_\n    return relabel_negatives(labels)\n\n\ndef make_transform(size: int) -> T.Compose:\n    return T.Compose([\n        T.Resize((size, size)),\n        T.ToTensor(),\n        T.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n    ])\n\n\ndef load_finetuned_megadescriptor(ckpt_path: Path, device: str):\n    \"\"\"Load fine-tuned MegaDescriptor-L-384 backbone at 224px (ArcFace head discarded).\"\"\"\n    model = timm.create_model(\n        'hf-hub:BVRA/MegaDescriptor-L-384', pretrained=False, img_size=224\n    ).eval()\n    ckpt = torch.load(ckpt_path, map_location=device)\n    model.load_state_dict(ckpt.get('model', ckpt), strict=True)\n    return model","metadata":{},"outputs":[],"execution_count":null},{"id":"a0000006","cell_type":"markdown","source":"## Config","metadata":{}},{"id":"a0000007","cell_type":"code","source":"DATA_ROOT  = Path('/kaggle/input/competitions/animal-clef-2026')\nCKPT_PATH  = Path('/kaggle/input/datasets/tsengchienchih/animalclef-turtle-finetuned/backbone_final.pth')\nOUT_PATH   = Path('/kaggle/working/submission.csv')\n\nDEVICE     = 'cuda' if torch.cuda.is_available() else 'cpu'\nBATCH_SIZE = 16\n\nEPS = {\n    'LynxID2025':          0.30,\n    'SalamanderID2025':    0.20,\n    'SeaTurtleID2022':     0.51,   # recalibrated; baseline was 0.40\n    'TexasHornedLizards':  0.24,\n}\n\nassert CKPT_PATH.exists(), f'Checkpoint not found: {CKPT_PATH}'\nprint(f'Device: {DEVICE}  |  batch: {BATCH_SIZE}')\nprint(f'EPS: {EPS}')","metadata":{},"outputs":[],"execution_count":null},{"id":"a0000008","cell_type":"markdown","source":"## Load dataset","metadata":{}},{"id":"a0000009","cell_type":"code","source":"from wildlife_datasets.datasets import AnimalCLEF2026\n\nds_full = AnimalCLEF2026(\n    str(DATA_ROOT),\n    transform=None,\n    load_label=True,\n    factorize_label=True,\n    check_files=False,\n)\n\ntest_ds = ds_full.get_subset(ds_full.metadata['split'] == 'test')\n\nprint(test_ds.metadata['dataset'].value_counts().to_string())","metadata":{},"outputs":[],"execution_count":null},{"id":"a0000010","cell_type":"markdown","source":"## Pipeline\n\nEach dataset runs through:\n1. Load the right model (fine-tuned backbone for SeaTurtle, pretrained for the rest)\n2. Extract embeddings with `wildlife-tools` `DeepFeatures`\n3. Compute pairwise cosine similarity\n4. DBSCAN with dataset-specific `eps`\n5. Relabel noise points as singletons","metadata":{}},{"id":"a0000011","cell_type":"code","source":"from torch.utils.data import DataLoader\nfrom transformers import AutoModel\nfrom sklearn.metrics.pairwise import cosine_similarity as cos_sim\n\ndef extract_features(dataset, model, transform, device, batch_size):\n    from torch.utils.data import Dataset as TorchDataset\n    from PIL import Image\n\n    class SimpleDataset(TorchDataset):\n        def __init__(self, paths, transform):\n            self.paths = paths\n            self.transform = transform\n        def __len__(self):\n            return len(self.paths)\n        def __getitem__(self, i):\n            img = Image.open(self.paths[i]).convert('RGB')\n            return self.transform(img)\n\n    paths = [\n        str(Path(dataset.root) / p)\n        for p in dataset.metadata['path'].tolist()\n    ]\n    loader = DataLoader(SimpleDataset(paths, transform), batch_size=batch_size, num_workers=0)\n    model = model.to(device)\n    feats = []\n    with torch.no_grad():\n        for batch in loader:\n            feats.append(model(batch.to(device)).cpu().numpy())\n    return np.vstack(feats)\n\n\ndef load_model(dataset_name: str):\n    if dataset_name == 'SeaTurtleID2022':\n        model = load_finetuned_megadescriptor(CKPT_PATH, DEVICE)\n        return model, 224\n    if dataset_name == 'SalamanderID2025':\n        model = timm.create_model('hf-hub:BVRA/MegaDescriptor-L-384', pretrained=True).eval()\n        return model, 384\n    model = AutoModel.from_pretrained(\n        'conservationxlabs/miewid-msv3',\n        trust_remote_code=True,\n        low_cpu_mem_usage=False,\n    ).eval()\n    return model, 512\n\n\nresults = []\n\nfor ds_name in ['LynxID2025', 'SalamanderID2025', 'SeaTurtleID2022', 'TexasHornedLizards']:\n    print(f'\\n=== {ds_name} ===')\n    subset = test_ds.get_subset(test_ds.metadata['dataset'] == ds_name)\n\n    model, size = load_model(ds_name)\n    transform = make_transform(size)\n\n    feats    = extract_features(subset, model, transform, DEVICE, BATCH_SIZE)\n    sim      = cos_sim(feats)\n    clusters = run_dbscan(sim, EPS[ds_name])\n\n    result = pd.DataFrame({\n        'image_id': subset.metadata['image_id'].astype(int),\n        'cluster':  [f'cluster_{ds_name}_{c}' for c in clusters],\n    })\n    results.append(result)\n    print(f'  images={len(subset)}  eps={EPS[ds_name]}  clusters={result[\"cluster\"].nunique()}')","metadata":{},"outputs":[],"execution_count":null},{"id":"a0000012","cell_type":"markdown","source":"## Save submission","metadata":{}},{"id":"a0000013","cell_type":"code","source":"submission = pd.concat(results, ignore_index=True).sort_values('image_id')\nsubmission.to_csv(OUT_PATH, index=False)\n\nprint(f'Saved: {OUT_PATH}')\nprint(f'Rows: {len(submission)}  |  Clusters: {submission[\"cluster\"].nunique()}')\nprint(f'NaNs: {submission.isna().sum().to_dict()}')\nprint()\nprint(submission.head(8).to_string(index=False))","metadata":{},"outputs":[],"execution_count":null}]}