{"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":[{"sourceType":"competition","sourceId":126777,"databundleVersionId":15314950}],"dockerImageVersionId":31287,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"\"\"\"\nv02: Fine-Tuned 31-Class Classifier\n=====================================\nFine-tune EfficientNet-B0 end-to-end as a 31-class jaguar classifier.\nUse the penultimate layer embeddings (1280-dim) for similarity scoring.\n\nStrategy:\n  Stage 1 (5 epochs)  - frozen backbone, train head only  (lr_head=1e-3)\n  Stage 2 (25 epochs) - full fine-tune with differential LR\n                        (lr_backbone=1e-4, lr_head=1e-3)\n  Then retrain on ALL data with same recipe for final submission.\n\nRun on Kaggle (T4 or P100 GPU):\n  Upload this script as a Kaggle notebook kernel.\n  Competition data must be attached as input dataset.\n  Outputs land in /kaggle/working/ — download the zip.\n\"\"\"\n\nimport sys, os, time\n\n# -- Clone repo and add src/ to path -----------------------------------------\n# Always pull latest — avoids stale src/ from a previous aborted session.\nREPO_URL = 'https://github.com/Smooth-Cactus0/jaguar-re-identification.git'\nREPO_DIR = '/kaggle/working/repo'\n\nif os.path.exists(REPO_DIR):\n    os.system(f'git -C {REPO_DIR} pull --ff-only')\nelse:\n    os.system(f'git clone {REPO_URL} {REPO_DIR}')\nsys.path.insert(0, REPO_DIR)\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import DataLoader\nfrom torch.optim import AdamW\nfrom torch.optim.lr_scheduler import OneCycleLR\nfrom pathlib import Path\nimport warnings\nwarnings.filterwarnings('ignore')\n\nfrom src.data      import JaguarDataset, get_transforms, encode_labels, get_identity_splits\nfrom src.models    import EmbeddingModel\nfrom src.inference import extract_embeddings, make_submission\nfrom src.evaluate  import compute_map, print_results, save_benchmark\n\n# -- Paths (Kaggle layout) ----------------------------------------------------\nKAGGLE_INPUT = Path('/kaggle/input/competitions/jaguar-re-id')\nTRAIN_DIR    = KAGGLE_INPUT / 'train' / 'train'\nTEST_DIR     = KAGGLE_INPUT / 'test'  / 'test'\nOUT_DIR      = Path('/kaggle/working/output')\nOUT_DIR.mkdir(exist_ok=True)\n\n# -- Config -------------------------------------------------------------------\nBACKBONE    = 'efficientnet_b0'\nIMG_SIZE    = 224\nBATCH_SIZE  = 64\nN_CLASSES   = 31\nLR_HEAD     = 1e-3\nLR_BACKBONE = 1e-4\nEPOCHS_S1   = 5      # Stage 1: head only\nEPOCHS_S2   = 25     # Stage 2: full fine-tune\nWEIGHT_DECAY = 1e-4\nLABEL_SMOOTH = 0.1   # label smoothing regularises classification on small dataset\nDEVICE       = 'cuda' if torch.cuda.is_available() else 'cpu'\nVERSION      = 'v02'\nCV_FOLD      = 0     # use fold 0 for validation (80% train / 20% val)\n\nprint('\\n' + '='*60)\nprint(f'  {VERSION}: Fine-Tuned Classifier')\nprint(f'  Backbone   : {BACKBONE}')\nprint(f'  Device     : {DEVICE}')\nprint(f'  Img size   : {IMG_SIZE}')\nprint(f'  Epochs     : S1={EPOCHS_S1} + S2={EPOCHS_S2}')\nprint(f'  Batch size : {BATCH_SIZE}')\nprint('='*60)\n\n# -- Load data ----------------------------------------------------------------\nprint('\\n[1/8] Loading data...')\ntrain_df = pd.read_csv(KAGGLE_INPUT / 'train.csv')\ntest_df  = pd.read_csv(KAGGLE_INPUT / 'test.csv')\nlabels, label_to_idx, idx_to_label = encode_labels(train_df['ground_truth'])\nprint(f'  Train: {len(train_df)} images, {N_CLASSES} identities')\n\n# -- CV split (GroupKFold by identity, 5 folds, use fold 0) ------------------\nprint(f'\\n[2/8] Creating CV split (fold {CV_FOLD}/5)...')\nfor fold, train_idx, val_idx in get_identity_splits(train_df, n_splits=5):\n    if fold == CV_FOLD:\n        break\n\ntf_train = get_transforms(mode='train', img_size=IMG_SIZE)\ntf_val   = get_transforms(mode='val',   img_size=IMG_SIZE)\n\ntrain_ds = JaguarDataset(\n    filenames=train_df['filename'].iloc[train_idx].tolist(),\n    img_dir=TRAIN_DIR, labels=labels[train_idx], transform=tf_train)\nval_ds = JaguarDataset(\n    filenames=train_df['filename'].iloc[val_idx].tolist(),\n    img_dir=TRAIN_DIR, labels=labels[val_idx], transform=tf_val)\n\ntrain_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,\n                          num_workers=2, pin_memory=True)\nval_loader   = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False,\n                          num_workers=2, pin_memory=True)\nprint(f'  Train: {len(train_ds)} imgs | Val: {len(val_ds)} imgs')\n\n\n# -- Model + loss + optimiser -------------------------------------------------\nprint('\\n[3/8] Building model...')\n\nclass ClassifierModel(nn.Module):\n    \"\"\"EfficientNet backbone + linear head for 31-class classification.\"\"\"\n    def __init__(self, backbone_name, n_classes, pretrained=True):\n        super().__init__()\n        self.backbone   = EmbeddingModel(backbone_name, pretrained=pretrained)\n        self.classifier = nn.Linear(self.backbone.out_dim, n_classes)\n\n    def forward(self, x, return_embedding=False):\n        emb = self.backbone(x)\n        if return_embedding:\n            return nn.functional.normalize(emb, p=2, dim=1)\n        return self.classifier(emb)\n\n    def freeze_backbone(self):\n        for p in self.backbone.parameters():\n            p.requires_grad_(False)\n\n    def unfreeze_backbone(self):\n        for p in self.backbone.parameters():\n            p.requires_grad_(True)\n\n\nmodel = ClassifierModel(BACKBONE, N_CLASSES).to(DEVICE)\ncriterion = nn.CrossEntropyLoss(label_smoothing=LABEL_SMOOTH)\n\ntotal_params = sum(p.numel() for p in model.parameters())\nprint(f'  Total params: {total_params:,}')\n\n\n# -- Training utilities -------------------------------------------------------\ndef run_epoch(model, loader, optimizer, scheduler, is_train, device):\n    model.train(is_train)\n    total_loss, correct, n = 0.0, 0, 0\n    ctx = torch.enable_grad() if is_train else torch.no_grad()\n    with ctx:\n        for imgs, targets in loader:\n            imgs, targets = imgs.to(device), targets.to(device)\n            if is_train:\n                optimizer.zero_grad()\n            logits = model(imgs)\n            loss   = criterion(logits, targets)\n            if is_train:\n                loss.backward()\n                optimizer.step()\n                scheduler.step()\n            total_loss += loss.item() * len(imgs)\n            correct    += (logits.argmax(1) == targets).sum().item()\n            n          += len(imgs)\n    return total_loss / n, correct / n\n\n\n# -- Stage 1: head-only training ----------------------------------------------\nprint('\\n[4/8] Stage 1: head-only training...')\nmodel.freeze_backbone()\ntrainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\nprint(f'  Trainable params (head only): {trainable:,}')\n\nopt_s1 = AdamW(filter(lambda p: p.requires_grad, model.parameters()),\n               lr=LR_HEAD, weight_decay=WEIGHT_DECAY)\nsch_s1 = OneCycleLR(opt_s1, max_lr=LR_HEAD,\n                    steps_per_epoch=len(train_loader), epochs=EPOCHS_S1)\n\nhistory = {'train_loss': [], 'val_loss': [], 'train_acc': [], 'val_acc': []}\nfor ep in range(1, EPOCHS_S1 + 1):\n    t0 = time.time()\n    tr_loss, tr_acc = run_epoch(model, train_loader, opt_s1, sch_s1, True,  DEVICE)\n    va_loss, va_acc = run_epoch(model, val_loader,   opt_s1, sch_s1, False, DEVICE)\n    history['train_loss'].append(tr_loss); history['val_loss'].append(va_loss)\n    history['train_acc'].append(tr_acc);  history['val_acc'].append(va_acc)\n    print(f'  S1 ep {ep:02d}/{EPOCHS_S1} | '\n          f'loss {tr_loss:.4f}/{va_loss:.4f} | '\n          f'acc {tr_acc:.3f}/{va_acc:.3f} | '\n          f'{time.time()-t0:.0f}s')\n\n\n# -- Stage 2: full fine-tuning ------------------------------------------------\nprint('\\n[5/8] Stage 2: full fine-tuning...')\nmodel.unfreeze_backbone()\ntrainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\nprint(f'  Trainable params (all): {trainable:,}')\n\n# Differential learning rates: backbone 10x lower than head\nparam_groups = [\n    {'params': model.backbone.parameters(),   'lr': LR_BACKBONE},\n    {'params': model.classifier.parameters(), 'lr': LR_HEAD},\n]\nopt_s2 = AdamW(param_groups, weight_decay=WEIGHT_DECAY)\nsch_s2 = OneCycleLR(opt_s2,\n                    max_lr=[LR_BACKBONE, LR_HEAD],\n                    steps_per_epoch=len(train_loader),\n                    epochs=EPOCHS_S2)\n\nbest_val_loss = float('inf')\nbest_state    = None\n\nfor ep in range(1, EPOCHS_S2 + 1):\n    t0 = time.time()\n    tr_loss, tr_acc = run_epoch(model, train_loader, opt_s2, sch_s2, True,  DEVICE)\n    va_loss, va_acc = run_epoch(model, val_loader,   opt_s2, sch_s2, False, DEVICE)\n    history['train_loss'].append(tr_loss); history['val_loss'].append(va_loss)\n    history['train_acc'].append(tr_acc);  history['val_acc'].append(va_acc)\n\n    if va_loss < best_val_loss:\n        best_val_loss = va_loss\n        best_state    = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n        print(f'  S2 ep {ep:02d}/{EPOCHS_S2} | '\n              f'loss {tr_loss:.4f}/{va_loss:.4f} | '\n              f'acc {tr_acc:.3f}/{va_acc:.3f} | '\n              f'{time.time()-t0:.0f}s  ** best **')\n    else:\n        print(f'  S2 ep {ep:02d}/{EPOCHS_S2} | '\n              f'loss {tr_loss:.4f}/{va_loss:.4f} | '\n              f'acc {tr_acc:.3f}/{va_acc:.3f} | '\n              f'{time.time()-t0:.0f}s')\n\nmodel.load_state_dict(best_state)\nprint(f'  Loaded best checkpoint (val_loss={best_val_loss:.4f})')\n\n\n# -- Evaluate: identity-balanced mAP on validation fold ----------------------\nprint('\\n[6/8] Computing identity-balanced mAP on validation fold...')\nmodel.eval()\n\n# Extract embeddings using the backbone (not classifier logits)\nval_embs = extract_embeddings(\n    lambda x: model(x, return_embedding=True),\n    val_ds, batch_size=BATCH_SIZE, device=DEVICE, desc='Val embeddings')\n\nval_results = compute_map(val_embs, labels[val_idx], identity_balanced=True)\nprint_results(val_results, idx_to_label, title=f'{VERSION} - Fold {CV_FOLD} Validation')\n\n# Also compute on full training set for comparison with v01\nfull_ds   = JaguarDataset(\n    filenames=train_df['filename'].tolist(),\n    img_dir=TRAIN_DIR, labels=labels, transform=tf_val)\nfull_embs = extract_embeddings(\n    lambda x: model(x, return_embedding=True),\n    full_ds, batch_size=BATCH_SIZE, device=DEVICE, desc='Full train embeddings')\nfull_results = compute_map(full_embs, labels, identity_balanced=True)\nprint(f'\\n  Full train leave-one-out mAP: {full_results[\"map\"]:.4f}')\nprint(f'  (v01 was 0.2781 — improvement: '\n      f'{full_results[\"map\"] - 0.2781:+.4f})')\n\nsave_benchmark(\n    OUT_DIR / 'benchmarks_v02.csv',\n    {\n        'version':        VERSION,\n        'backbone':       BACKBONE,\n        'loss':           'CrossEntropy (label_smooth=0.1)',\n        'embedding_dim':  model.backbone.out_dim,\n        'img_size':       IMG_SIZE,\n        'augmentation':   'none (v05 adds augmentation)',\n        'cv_map_val':     round(val_results['map'], 5),\n        'cv_map_full':    round(full_results['map'], 5),\n        'best_identity':  idx_to_label[max(full_results['per_identity_ap'],\n                                           key=full_results['per_identity_ap'].get)],\n        'worst_identity': idx_to_label[min(full_results['per_identity_ap'],\n                                           key=full_results['per_identity_ap'].get)],\n        'epochs_s1':      EPOCHS_S1,\n        'epochs_s2':      EPOCHS_S2,\n        'notes':          'Two-stage fine-tune, differential LR',\n    }\n)\n\n\n# -- Visualisations -----------------------------------------------------------\nprint('\\n[7/8] Generating visualisations...')\n\n# 7a. Training curves\nep_range = range(1, EPOCHS_S1 + EPOCHS_S2 + 1)\nfig, axes = plt.subplots(1, 2, figsize=(13, 4))\n\naxes[0].plot(ep_range, history['train_loss'], label='Train', color='steelblue')\naxes[0].plot(ep_range, history['val_loss'],   label='Val',   color='coral')\naxes[0].axvline(EPOCHS_S1, color='gray', linestyle='--', linewidth=1,\n                label=f'Unfreeze at ep {EPOCHS_S1}')\naxes[0].set_xlabel('Epoch'); axes[0].set_ylabel('Loss')\naxes[0].set_title('Training Loss', fontweight='bold')\naxes[0].legend()\n\naxes[1].plot(ep_range, history['train_acc'], label='Train', color='steelblue')\naxes[1].plot(ep_range, history['val_acc'],   label='Val',   color='coral')\naxes[1].axvline(EPOCHS_S1, color='gray', linestyle='--', linewidth=1)\naxes[1].set_xlabel('Epoch'); axes[1].set_ylabel('Accuracy')\naxes[1].set_title('Classification Accuracy', fontweight='bold')\naxes[1].legend()\n\nplt.suptitle(f'v02: Fine-Tuned {BACKBONE} — Training Curves', fontsize=12, fontweight='bold')\nplt.tight_layout()\nfig.savefig(OUT_DIR / f'{VERSION}_training_curves.png', dpi=150, bbox_inches='tight')\nplt.close()\nprint('  Saved -> v02_training_curves.png')\n\n# 7b. Per-identity AP comparison (v01 vs v02)\n# Load v01 results for comparison if available\nv02_per_id = {idx_to_label[k]: v for k, v in full_results['per_identity_ap'].items()}\nv01_map_ref = {  # from the v01 run results\n    'Katniss':0.5694,'Lua':0.5186,'Bagua':0.5112,'Tomas':0.4515,\n    'Kamaikua':0.3999,'Pyte':0.3825,'Benita':0.3686,'Pollyanna':0.3640,\n    'Guaraci':0.3479,'Madalena':0.3408,'Pixana':0.3251,'Estella':0.3035,\n    'Abril':0.2865,'Apeiara':0.2752,'Ariely':0.2585,'Ti':0.2456,\n    'Alira':0.2420,'Akaloi':0.2377,'Solar':0.2270,'Ousado':0.2187,\n    'Medrosa':0.2149,'Overa':0.2058,'Kwang':0.1977,'Marcela':0.1883,\n    'Saseka':0.1687,'Oxum':0.1655,'Bororo':0.1604,'Bernard':0.1352,\n    'Jaju':0.1332,'Ipepo':0.0968,'Patricia':0.0797,\n}\nids_sorted = sorted(v02_per_id.keys(), key=lambda x: -v02_per_id[x])\nx = np.arange(len(ids_sorted))\nw = 0.38\n\nfig, ax = plt.subplots(figsize=(14, 5))\nax.bar(x - w/2, [v01_map_ref.get(i, 0) for i in ids_sorted], w,\n       color='lightsteelblue', label='v01 frozen', edgecolor='white')\nax.bar(x + w/2, [v02_per_id[i] for i in ids_sorted], w,\n       color='steelblue', label='v02 fine-tuned', edgecolor='white')\nax.axhline(full_results['map'], color='navy', linestyle='--', linewidth=1,\n           label=f'v02 mAP={full_results[\"map\"]:.4f}')\nax.axhline(0.2781, color='gray', linestyle=':', linewidth=1,\n           label='v01 mAP=0.2781')\nax.set_xticks(x); ax.set_xticklabels(ids_sorted, rotation=45, ha='right', fontsize=8)\nax.set_ylabel('Average Precision'); ax.set_ylim(0, 1)\nax.set_title('v01 vs v02: Per-Identity AP Comparison', fontsize=12, fontweight='bold')\nax.legend(fontsize=9)\nplt.tight_layout()\nfig.savefig(OUT_DIR / f'{VERSION}_per_identity_ap.png', dpi=150, bbox_inches='tight')\nplt.close()\nprint('  Saved -> v02_per_identity_ap.png')\n\n# 7c. t-SNE of fine-tuned embeddings\nprint('  Computing t-SNE...')\nfrom sklearn.manifold import TSNE\nimport seaborn as sns\n\ntsne   = TSNE(n_components=2, perplexity=30, n_iter=1000, random_state=42, n_jobs=-1)\nembs2d = tsne.fit_transform(full_embs)\npalette = sns.color_palette('tab20', N_CLASSES) + \\\n          sns.color_palette('tab20b', max(0, N_CLASSES - 20))\n\nfig, ax = plt.subplots(figsize=(12, 10))\nfor lbl in range(N_CLASSES):\n    mask = labels == lbl\n    ax.scatter(embs2d[mask, 0], embs2d[mask, 1], color=palette[lbl],\n               label=idx_to_label[lbl], s=20, alpha=0.7, edgecolors='none')\nax.set_title(f'v02: t-SNE of Fine-Tuned {BACKBONE} Embeddings', fontsize=12, fontweight='bold')\nax.legend(loc='upper right', fontsize=6, ncol=2, markerscale=1.5,\n          framealpha=0.7, title='Identity')\nax.axis('off')\nplt.tight_layout()\nfig.savefig(OUT_DIR / f'{VERSION}_tsne.png', dpi=150, bbox_inches='tight')\nplt.close()\nprint('  Saved -> v02_tsne.png')\n\n\n# -- Full retrain + submission ------------------------------------------------\nprint('\\n[8/8] Retraining on ALL data for submission...')\nfull_train_ds = JaguarDataset(\n    filenames=train_df['filename'].tolist(),\n    img_dir=TRAIN_DIR, labels=labels, transform=tf_train)\nfull_loader = DataLoader(full_train_ds, batch_size=BATCH_SIZE, shuffle=True,\n                         num_workers=2, pin_memory=True)\n\nmodel_final = ClassifierModel(BACKBONE, N_CLASSES).to(DEVICE)\n\n# Stage 1\nmodel_final.freeze_backbone()\nopt_f1 = AdamW(filter(lambda p: p.requires_grad, model_final.parameters()),\n               lr=LR_HEAD, weight_decay=WEIGHT_DECAY)\nsch_f1 = OneCycleLR(opt_f1, max_lr=LR_HEAD,\n                    steps_per_epoch=len(full_loader), epochs=EPOCHS_S1)\nfor ep in range(1, EPOCHS_S1 + 1):\n    loss, acc = run_epoch(model_final, full_loader, opt_f1, sch_f1, True, DEVICE)\n    print(f'  Full S1 ep {ep}/{EPOCHS_S1} | loss {loss:.4f} | acc {acc:.3f}')\n\n# Stage 2\nmodel_final.unfreeze_backbone()\nparam_groups_f = [\n    {'params': model_final.backbone.parameters(),   'lr': LR_BACKBONE},\n    {'params': model_final.classifier.parameters(), 'lr': LR_HEAD},\n]\nopt_f2 = AdamW(param_groups_f, weight_decay=WEIGHT_DECAY)\nsch_f2 = OneCycleLR(opt_f2, max_lr=[LR_BACKBONE, LR_HEAD],\n                    steps_per_epoch=len(full_loader), epochs=EPOCHS_S2)\nfor ep in range(1, EPOCHS_S2 + 1):\n    loss, acc = run_epoch(model_final, full_loader, opt_f2, sch_f2, True, DEVICE)\n    if ep % 5 == 0:\n        print(f'  Full S2 ep {ep}/{EPOCHS_S2} | loss {loss:.4f} | acc {acc:.3f}')\n\n# Test embeddings + submission\ntest_filenames = sorted(pd.unique(\n    test_df[['query_image', 'gallery_image']].values.ravel()))\ntest_ds = JaguarDataset(\n    filenames=test_filenames, img_dir=TEST_DIR, labels=None, transform=tf_val)\nmodel_final.eval()\ntest_embs = extract_embeddings(\n    lambda x: model_final(x, return_embedding=True),\n    test_ds, batch_size=BATCH_SIZE, device=DEVICE, desc='Test embeddings')\nfname_to_idx = {f: i for i, f in enumerate(test_filenames)}\nmake_submission(\n    test_df=test_df, test_embeddings=test_embs,\n    filename_to_idx=fname_to_idx,\n    output_path=OUT_DIR / f'submission_{VERSION}.csv')\n\n# Save final model weights\ntorch.save(model_final.state_dict(), OUT_DIR / f'model_{VERSION}.pth')\nprint(f'  Model saved -> model_{VERSION}.pth')\n\nprint(f'\\n{\"=\"*60}')\nprint(f'  {VERSION} COMPLETE')\nprint(f'  Val fold mAP  : {val_results[\"map\"]:.4f}')\nprint(f'  Full train mAP: {full_results[\"map\"]:.4f}')\nprint(f'  Outputs in    : {OUT_DIR}')\nprint(f'{\"=\"*60}')\nprint('\\nDownload /kaggle/working/output/ zip and push figures + results to GitHub.')","metadata":{"_uuid":"f09073b8-507c-467d-8f38-878c1a1e456b","_cell_guid":"7d96742d-5e5a-4c2e-9a07-91751b1e32c1","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-03-03T08:56:21.001695Z","iopub.execute_input":"2026-03-03T08:56:21.001984Z","iopub.status.idle":"2026-03-03T08:56:28.541004Z","shell.execute_reply.started":"2026-03-03T08:56:21.001960Z","shell.execute_reply":"2026-03-03T08:56:28.539751Z"}},"outputs":[],"execution_count":null}]}