{"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":126118,"databundleVersionId":14933142,"sourceType":"competition"}],"dockerImageVersionId":31260,"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 -q rasterio timm albumentations\n!pip install -q scikit-learn matplotlib pandas numpy\n\nprint(\"✅ All libraries installed!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-15T21:04:38.342185Z","iopub.execute_input":"2026-02-15T21:04:38.342456Z","iopub.status.idle":"2026-02-15T21:04:48.371253Z","shell.execute_reply.started":"2026-02-15T21:04:38.342426Z","shell.execute_reply":"2026-02-15T21:04:48.370284Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport matplotlib.gridspec as gridspec\nfrom pathlib import Path\nimport warnings\nimport time\nwarnings.filterwarnings('ignore')\n\ntry:\n    import rasterio\nexcept ImportError:\n    os.system('pip install rasterio')\n    import rasterio\n\n# ===== PATHS =====\nBASE_PATH   = '/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2026p2'\nTRAIN_DIR   = os.path.join(BASE_PATH, 'ICPR02', 'kaggle')\nEVAL_DIR    = os.path.join(BASE_PATH, 'ICPR02', 'kaggle', 'evaluation')\nARCHIVE_DIR = os.path.join(BASE_PATH, 'archive', 'share')\nCLASS_NAMES = ['Aphid', 'Blast', 'RPH', 'Rust']\n\nprint(\"📂 Checking all paths:\")\nfor name, path in [('TRAIN_DIR', TRAIN_DIR), ('EVAL_DIR', EVAL_DIR), ('ARCHIVE_DIR', ARCHIVE_DIR)]:\n    exists = os.path.exists(path)\n    count = len(os.listdir(path)) if exists else 0\n    print(f\"  {'✅' if exists else '❌'} {name}: {path} ({count} items)\")\n\nprint(f\"\\n📁 Training class folders:\")\nif os.path.exists(TRAIN_DIR):\n    for item in sorted(os.listdir(TRAIN_DIR)):\n        full = os.path.join(TRAIN_DIR, item)\n        if os.path.isdir(full):\n            print(f\"  📁 {item}/ → {len(os.listdir(full))} samples\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-15T22:19:47.374267Z","iopub.execute_input":"2026-02-15T22:19:47.374585Z","iopub.status.idle":"2026-02-15T22:19:47.391111Z","shell.execute_reply.started":"2026-02-15T22:19:47.374557Z","shell.execute_reply":"2026-02-15T22:19:47.390478Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def print_tree(path, prefix=\"\", max_depth=3, depth=0, max_items=8):\n    if depth >= max_depth: return\n    try: items = sorted(os.listdir(path))\n    except: return\n    for i, item in enumerate(items[:max_items]):\n        full = os.path.join(path, item)\n        last = (i == min(len(items), max_items) - 1)\n        conn = \"└── \" if last else \"├── \"\n        if os.path.isdir(full):\n            print(f\"{prefix}{conn}📁 {item}/ ({len(os.listdir(full))} items)\")\n            print_tree(full, prefix + (\"    \" if last else \"│   \"), max_depth, depth+1, max_items)\n        else:\n            print(f\"{prefix}{conn}📄 {item} ({os.path.getsize(full):,} bytes)\")\n    if len(items) > max_items:\n        print(f\"{prefix}└── ... +{len(items)-max_items} more\")\n\nprint(\"=\" * 60)\nprint(\"📂 FULL DATA STRUCTURE\")\nprint(\"=\" * 60)\nprint_tree(BASE_PATH, max_depth=4)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-15T21:07:04.906662Z","iopub.execute_input":"2026-02-15T21:07:04.907296Z","iopub.status.idle":"2026-02-15T21:07:05.263619Z","shell.execute_reply.started":"2026-02-15T21:07:04.907268Z","shell.execute_reply":"2026-02-15T21:07:05.263015Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"BAND_NAMES = ['B1','B2','B3','B4','B5','B6','B7','B8','B8A','B9','B11','B12']\nBAND_INFO = {\n    'B1':('Coastal/Aerosol','60m','#9400D3'), 'B2':('Blue','10m','#0000FF'),\n    'B3':('Green','10m','#00FF00'), 'B4':('Red','10m','#FF0000'),\n    'B5':('Red Edge 1','20m','#FF4500'), 'B6':('Red Edge 2','20m','#FF6347'),\n    'B7':('Red Edge 3','20m','#FF7F50'), 'B8':('NIR','10m','#8B0000'),\n    'B8A':('NIR Narrow','20m','#A52A2A'), 'B9':('Water Vapour','60m','#00CED1'),\n    'B11':('SWIR 1','20m','#FFD700'), 'B12':('SWIR 2','20m','#FFA500'),\n}\n\ndef load_bands(folder_path):\n    bands = {}\n    for bn in BAND_NAMES:\n        tif = os.path.join(folder_path, f\"{bn}.tif\")\n        if os.path.exists(tif):\n            with rasterio.open(tif) as src:\n                bands[bn] = src.read(1).astype(np.float32)\n    return bands\n\ndef normalize_band(band, pct=(2,98)):\n    valid = band[band > 0]\n    if len(valid) == 0: return np.zeros_like(band, dtype=np.float32)\n    lo, hi = np.percentile(valid, pct)\n    if hi == lo: return np.zeros_like(band, dtype=np.float32)\n    return np.clip((band - lo) / (hi - lo), 0, 1)\n\ndef load_any_image(path):\n    \"\"\"Load image from path (folder with .tif bands, single .tif, or image file).\"\"\"\n    if os.path.isdir(path):\n        bands = load_bands(path)\n        if all(b in bands for b in ['B4','B3','B2']):\n            return np.stack([normalize_band(bands['B4']),\n                           normalize_band(bands['B3']),\n                           normalize_band(bands['B2'])], axis=-1), 'rgb', bands\n        elif bands:\n            first = list(bands.values())[0]\n            return normalize_band(first), 'gray', bands\n    else:\n        fpath = path if os.path.isfile(path) else None\n        if fpath and fpath.endswith(('.tif','.tiff')):\n            with rasterio.open(fpath) as src:\n                data = src.read().astype(np.float32)\n                if data.shape[0] >= 3:\n                    return np.stack([normalize_band(data[2]),\n                                   normalize_band(data[1]),\n                                   normalize_band(data[0])], axis=-1), 'rgb', None\n                else:\n                    return normalize_band(data[0]), 'gray', None\n        elif fpath and fpath.endswith(('.png','.jpg','.jpeg')):\n            from PIL import Image\n            return np.array(Image.open(fpath)), 'rgb', None\n    return None, None, None\n\n# --- Class Distribution ---\nprint(\"🏷️ LABELED DATASET\")\nprint(\"=\" * 60)\nclass_counts = {}\nfor cls in CLASS_NAMES:\n    d = os.path.join(TRAIN_DIR, cls)\n    class_counts[cls] = len(os.listdir(d)) if os.path.exists(d) else 0\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\ncolors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']\nnames, counts = list(class_counts.keys()), list(class_counts.values())\n\nbars = axes[0].bar(names, counts, color=colors, edgecolor='black', lw=0.5)\naxes[0].set_title('Class Distribution', fontsize=13, fontweight='bold')\naxes[0].set_ylabel('Samples')\nfor b, c in zip(bars, counts):\n    axes[0].text(b.get_x()+b.get_width()/2, b.get_height()+1, str(c), ha='center', fontweight='bold')\n\naxes[1].pie(counts, labels=names, autopct='%1.1f%%', colors=colors, startangle=90, explode=[0.05]*4)\naxes[1].set_title('Class Distribution (%)', fontsize=13, fontweight='bold')\nplt.suptitle('� Crop Disease Dataset', fontsize=15, fontweight='bold')\nplt.tight_layout(); plt.savefig('/kaggle/working/class_dist.png', dpi=150, bbox_inches='tight'); plt.show()\n\n# --- Sample Images Per Class ---\nn_show = 4\nfig, axes = plt.subplots(len(CLASS_NAMES), n_show, figsize=(4*n_show, 4*len(CLASS_NAMES)))\n\nfor row, cls in enumerate(CLASS_NAMES):\n    cls_dir = os.path.join(TRAIN_DIR, cls)\n    samples = sorted(os.listdir(cls_dir))[:n_show] if os.path.exists(cls_dir) else []\n    axes[row, 0].set_ylabel(cls, fontsize=14, fontweight='bold', rotation=0, labelpad=60)\n\n    for col in range(n_show):\n        ax = axes[row, col]\n        if col < len(samples):\n            img, mode, _ = load_any_image(os.path.join(cls_dir, samples[col]))\n            if img is not None:\n                ax.imshow(img, cmap='gray' if mode == 'gray' else None)\n            ax.set_title(samples[col], fontsize=7)\n        ax.axis('off')\n\nplt.suptitle('🏷️ Sample Images by Disease Class', fontsize=14, fontweight='bold', y=1.02)\nplt.tight_layout(); plt.savefig('/kaggle/working/labeled_samples.png', dpi=150, bbox_inches='tight'); plt.show()\n\n# --- Band Details for One Sample ---\nprint(\"\\n📊 Inspecting one sample in detail:\")\nfor cls in CLASS_NAMES:\n    cls_dir = os.path.join(TRAIN_DIR, cls)\n    if os.path.exists(cls_dir):\n        sample = sorted(os.listdir(cls_dir))[0]\n        sp = os.path.join(cls_dir, sample)\n        img, mode, bands = load_any_image(sp)\n        print(f\"\\n  Class: {cls} | Sample: {sample}\")\n        if os.path.isdir(sp):\n            print(f\"  Type: Folder with files: {os.listdir(sp)[:5]}\")\n        else:\n            print(f\"  Type: File ({os.path.getsize(sp):,} bytes)\")\n        if bands:\n            for bn, bd in bands.items():\n                info = BAND_INFO.get(bn, ('?','?','#000'))\n                print(f\"    {bn:5s} {info[0]:18s} {info[1]:5s} shape={bd.shape} \"\n                      f\"min={bd.min():.1f} max={bd.max():.1f} mean={bd.mean():.1f}\")\n        break\n\n# --- Evaluation Data Peek ---\nprint(\"\\n📋 EVALUATION (Test) DATA:\")\nif os.path.exists(EVAL_DIR):\n    items = sorted(os.listdir(EVAL_DIR))\n    print(f\"  {len(items)} items\")\n    print(f\"  First 5: {items[:5]}\")\n    first = os.path.join(EVAL_DIR, items[0])\n    if os.path.isdir(first):\n        print(f\"  Inside '{items[0]}': {os.listdir(first)[:5]}\")\nelse:\n    print(\"  ❌ Not found\")\n\nprint(\"\\n✅ Data exploration complete!\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-15T21:08:32.532336Z","iopub.execute_input":"2026-02-15T21:08:32.532980Z","iopub.status.idle":"2026-02-15T21:08:40.216693Z","shell.execute_reply.started":"2026-02-15T21:08:32.532952Z","shell.execute_reply":"2026-02-15T21:08:40.216094Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.metrics import classification_report, accuracy_score, f1_score\nimport timm\nimport rasterio\nimport numpy as np\nimport pandas as pd\nimport os, time\n\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"🖥️ Device: {DEVICE}\")\nif torch.cuda.is_available():\n    print(f\"   GPU: {torch.cuda.get_device_name(0)}\")\n    print(f\"   VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB\")\n\n# ===== PATHS =====\nBASE_PATH   = '/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2026p2'\nTRAIN_DIR   = os.path.join(BASE_PATH, 'ICPR02', 'kaggle')\nEVAL_DIR    = os.path.join(BASE_PATH, 'ICPR02', 'kaggle', 'evaluation')\n\nBAND_NAMES  = ['B1','B2','B3','B4','B5','B6','B7','B8','B8A','B9','B11','B12']\nNUM_BANDS   = 12\nIMG_SIZE    = 64     # Start small for stable training (increase later)\nCLASS_NAMES = ['Aphid', 'Blast', 'RPH', 'Rust']\nCLASS_MAP   = {n: i for i, n in enumerate(CLASS_NAMES)}\nINV_MAP     = {i: n for n, i in CLASS_MAP.items()}\n\n\ndef build_labeled_data():\n    \"\"\"Build (file_paths, labels) from class folders.\"\"\"\n    paths, labels = [], []\n    for cls in CLASS_NAMES:\n        cls_dir = os.path.join(TRAIN_DIR, cls)\n        if not os.path.exists(cls_dir):\n            print(f\"  ⚠️ Missing: {cls_dir}\")\n            continue\n        for item in sorted(os.listdir(cls_dir)):\n            paths.append(os.path.join(cls_dir, item))\n            labels.append(CLASS_MAP[cls])\n    labels = np.array(labels)\n    print(f\"📊 Labeled data: {len(paths)} samples\")\n    for cls in CLASS_NAMES:\n        print(f\"   {cls}: {(labels == CLASS_MAP[cls]).sum()}\")\n    return paths, labels\n\n\ndef build_test_data():\n    \"\"\"Build file paths for evaluation folder.\"\"\"\n    paths, ids = [], []\n    if not os.path.exists(EVAL_DIR):\n        print(f\"⚠️ Eval dir missing: {EVAL_DIR}\")\n        return paths, ids\n    for item in sorted(os.listdir(EVAL_DIR)):\n        paths.append(os.path.join(EVAL_DIR, item))\n        ids.append(item)\n    print(f\"📊 Test data: {len(paths)} samples\")\n    return paths, ids\n\n\nclass SentinelDataset(Dataset):\n    \"\"\"Loads Sentinel-2 images (folder-of-bands or single-file).\"\"\"\n    def __init__(self, file_paths, labels=None, img_size=IMG_SIZE, augment=False):\n        self.paths = file_paths\n        self.labels = labels\n        self.img_size = img_size\n        self.augment = augment\n\n    def _load(self, path):\n        bands = []\n        if os.path.isdir(path):\n            for bn in BAND_NAMES:\n                tif = os.path.join(path, f\"{bn}.tif\")\n                if os.path.exists(tif):\n                    with rasterio.open(tif) as src:\n                        bands.append(src.read(1).astype(np.float32))\n                else:\n                    bands.append(None)\n        else:\n            fpath = path if os.path.isfile(path) else path + '.tif'\n            if os.path.exists(fpath):\n                with rasterio.open(fpath) as src:\n                    data = src.read().astype(np.float32)\n                    for i in range(data.shape[0]):\n                        bands.append(data[i])\n            while len(bands) < NUM_BANDS:\n                bands.append(None)\n\n        result = []\n        for b in bands[:NUM_BANDS]:\n            if b is not None:\n                t = torch.from_numpy(b).unsqueeze(0).unsqueeze(0)\n                t = F.interpolate(t, size=(self.img_size, self.img_size),\n                                 mode='bilinear', align_corners=False)\n                result.append(t.squeeze())\n            else:\n                result.append(torch.zeros(self.img_size, self.img_size))\n\n        img = torch.stack(result, dim=0)  # (NUM_BANDS, H, W)\n\n        # Normalize each band using percentile clipping (more robust than min-max)\n        for i in range(img.shape[0]):\n            b = img[i]\n            valid = b[b > 0]\n            if len(valid) > 0:\n                lo = torch.quantile(valid, 0.02)\n                hi = torch.quantile(valid, 0.98)\n                if hi > lo:\n                    img[i] = torch.clamp((b - lo) / (hi - lo), 0.0, 1.0)\n                else:\n                    img[i] = torch.zeros_like(b)\n            else:\n                img[i] = torch.zeros_like(b)\n        return img\n\n    def __len__(self):\n        return len(self.paths)\n\n    def __getitem__(self, idx):\n        img = self._load(self.paths[idx])\n        if self.augment:\n            if torch.rand(1) > 0.5: img = torch.flip(img, [2])\n            if torch.rand(1) > 0.5: img = torch.flip(img, [1])\n            k = torch.randint(0, 4, (1,)).item()\n            img = torch.rot90(img, k, [1, 2])\n        if self.labels is not None:\n            return img, self.labels[idx]\n        return img\n\n\n# --- SANITY CHECK: Load one sample and print stats ---\nprint(\"✅ Utilities ready!\")\ntrain_paths, train_labels = build_labeled_data()\ntest_paths, test_ids = build_test_data()\n\nprint(\"\\n🔍 Sanity check — loading first sample:\")\n_ds = SentinelDataset(train_paths[:1])\n_img = _ds[0]\nprint(f\"   Shape: {_img.shape}\")\nprint(f\"   Dtype: {_img.dtype}\")\nfor i, bn in enumerate(BAND_NAMES):\n    b = _img[i]\n    print(f\"   {bn:5s}: min={b.min():.3f} max={b.max():.3f} mean={b.mean():.3f} nonzero={(b>0).sum().item()}\")\ndel _ds, _img\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-15T22:17:23.953436Z","iopub.execute_input":"2026-02-15T22:17:23.954173Z","iopub.status.idle":"2026-02-15T22:17:24.059018Z","shell.execute_reply.started":"2026-02-15T22:17:23.954144Z","shell.execute_reply":"2026-02-15T22:17:24.058274Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class CropDiseaseModel(nn.Module):\n    \"\"\"\n    Pre-trained ImageNet backbone adapted for 12-channel satellite input.\n    timm's in_chans parameter handles all architectures automatically.\n    \"\"\"\n    def __init__(self, backbone='resnet50', num_classes=4, pretrained=True):\n        super().__init__()\n        self.model = timm.create_model(\n            backbone,\n            pretrained=pretrained,\n            num_classes=num_classes,\n            in_chans=NUM_BANDS\n        )\n        print(f\"✅ {backbone}: {NUM_BANDS}-ch input, {num_classes} classes (pretrained={pretrained})\")\n\n    def forward(self, x):\n        return self.model(x)\n\n\n# Quick sanity test\n_m = CropDiseaseModel('resnet50', pretrained=False)\n_d = torch.randn(2, NUM_BANDS, IMG_SIZE, IMG_SIZE)\n_o = _m(_d)\nprint(f\"✅ Model test: {_d.shape} → {_o.shape}\")\ndel _m, _d, _o","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-15T22:18:42.232604Z","iopub.execute_input":"2026-02-15T22:18:42.232958Z","iopub.status.idle":"2026-02-15T22:18:42.579548Z","shell.execute_reply.started":"2026-02-15T22:18:42.232926Z","shell.execute_reply":"2026-02-15T22:18:42.578746Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"BACKBONES = {\n    'resnet50':        {'img_size': 64,  'bs': 32, 'lr': 3e-4},\n    'efficientnet_b2': {'img_size': 64,  'bs': 32, 'lr': 3e-4},\n    'convnext_tiny':   {'img_size': 64,  'bs': 16, 'lr': 1e-4},\n    'swin_tiny_patch4_window7_224': {'img_size': 224, 'bs': 8, 'lr': 1e-4},\n}\n\nCOMPARE_EPOCHS = 20\nCOMPARE_FOLDS  = 3\n\nbackbone_results = {}\n\nfor bb_name, bb_cfg in BACKBONES.items():\n    print(f\"\\n{'🔷'*25}\")\n    print(f\"🏗️ BACKBONE: {bb_name}\")\n    print(f\"   img_size={bb_cfg['img_size']} | bs={bb_cfg['bs']} | lr={bb_cfg['lr']}\")\n    print(f\"{'🔷'*25}\")\n\n    skf = StratifiedKFold(n_splits=COMPARE_FOLDS, shuffle=True, random_state=42)\n    fold_accs, fold_f1s = [], []\n    start_time = time.time()\n\n    for fold, (tr_idx, va_idx) in enumerate(skf.split(train_paths, train_labels)):\n        print(f\"\\n  📂 Fold {fold+1}/{COMPARE_FOLDS}\")\n\n        tr_paths_f = [train_paths[i] for i in tr_idx]\n        va_paths_f = [train_paths[i] for i in va_idx]\n        tr_labels_f = train_labels[tr_idx]\n        va_labels_f = train_labels[va_idx]\n\n        tr_loader = DataLoader(\n            SentinelDataset(tr_paths_f, tr_labels_f, img_size=bb_cfg['img_size'], augment=True),\n            batch_size=bb_cfg['bs'], shuffle=True, num_workers=2, pin_memory=True)\n        va_loader = DataLoader(\n            SentinelDataset(va_paths_f, va_labels_f, img_size=bb_cfg['img_size']),\n            batch_size=bb_cfg['bs'], num_workers=2, pin_memory=True)\n\n        try:\n            model = CropDiseaseModel(bb_name, num_classes=4, pretrained=True).to(DEVICE)\n        except Exception as e:\n            print(f\"  ❌ Failed: {e}\")\n            break\n\n        # Class weights\n        counts = np.bincount(tr_labels_f, minlength=4).astype(np.float32)\n        weights = torch.tensor(counts.sum() / (4 * counts + 1e-6)).to(DEVICE)\n        criterion = nn.CrossEntropyLoss(weight=weights, label_smoothing=0.1)\n\n        # Single optimizer — same LR for all params (simpler, more stable)\n        optimizer = torch.optim.AdamW(model.parameters(), lr=bb_cfg['lr'], weight_decay=1e-4)\n\n        # Warmup + Cosine schedule\n        warmup_epochs = 3\n        def lr_lambda(epoch):\n            if epoch < warmup_epochs:\n                return (epoch + 1) / warmup_epochs\n            return 0.5 * (1 + np.cos(np.pi * (epoch - warmup_epochs) / (COMPARE_EPOCHS - warmup_epochs)))\n        scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n\n        best_acc, best_f1 = 0, 0\n        for ep in range(COMPARE_EPOCHS):\n            model.train()\n            ep_loss = 0\n            for imgs, lbls in tr_loader:\n                imgs, lbls = imgs.to(DEVICE), lbls.to(DEVICE)\n                loss = criterion(model(imgs), lbls)\n                optimizer.zero_grad()\n                loss.backward()\n                torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n                optimizer.step()\n                ep_loss += loss.item()\n            scheduler.step()\n\n            model.eval()\n            vp, vt = [], []\n            with torch.no_grad():\n                for imgs, lbls in va_loader:\n                    preds = model(imgs.to(DEVICE)).argmax(1).cpu().numpy()\n                    vp.extend(preds); vt.extend(lbls.numpy())\n            acc = accuracy_score(vt, vp)\n            f1 = f1_score(vt, vp, average='macro')\n\n            if acc > best_acc:\n                best_acc = acc\n                best_f1 = f1\n\n            if (ep+1) % 5 == 0:\n                lr_now = optimizer.param_groups[0]['lr']\n                print(f\"    Ep {ep+1:2d}/{COMPARE_EPOCHS} | Loss: {ep_loss/len(tr_loader):.4f} | \"\n                      f\"Acc: {acc:.4f} | F1: {f1:.4f} | LR: {lr_now:.1e}\")\n\n        fold_accs.append(best_acc)\n        fold_f1s.append(best_f1)\n        print(f\"  ✅ Fold {fold+1} → Acc: {best_acc:.4f} | F1: {best_f1:.4f}\")\n        del model; torch.cuda.empty_cache()\n\n    elapsed = time.time() - start_time\n    if fold_accs:\n        backbone_results[bb_name] = {\n            'mean_acc': np.mean(fold_accs),\n            'std_acc': np.std(fold_accs),\n            'mean_f1': np.mean(fold_f1s),\n            'time_min': elapsed / 60,\n            'cfg': bb_cfg,\n        }\n\n# --- Comparison Table ---\nprint(f\"\\n\\n{'='*70}\")\nprint(f\"📊 BACKBONE COMPARISON ({COMPARE_FOLDS}-Fold, {COMPARE_EPOCHS} epochs)\")\nprint(f\"{'='*70}\")\nprint(f\"  {'Backbone':<35} {'Acc':>10} {'F1':>10} {'Time':>10}\")\nprint(\"-\" * 70)\nbest_bb, best_score = None, 0\nfor name, res in sorted(backbone_results.items(), key=lambda x: -x[1]['mean_f1']):\n    print(f\"  {name:<33} {res['mean_acc']:.4f}±{res['std_acc']:.3f}\"\n          f\"  {res['mean_f1']:.4f}    {res['time_min']:.1f} min\")\n    if res['mean_f1'] > best_score:\n        best_score = res['mean_f1']\n        best_bb = name\n\nprint(f\"\\n🏆 BEST: {best_bb} (F1: {best_score:.4f})\")\n\n# Bar chart\nimport matplotlib.pyplot as plt\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\nnames = list(backbone_results.keys())\naccs = [backbone_results[n]['mean_acc'] for n in names]\nf1s = [backbone_results[n]['mean_f1'] for n in names]\ncolors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']\n\nfor ax, vals, title in [(axes[0], accs, 'Accuracy'), (axes[1], f1s, 'F1 Score')]:\n    bars = ax.bar(range(len(names)), vals, color=colors[:len(names)], edgecolor='black', lw=0.5)\n    ax.set_xticks(range(len(names)))\n    ax.set_xticklabels(names, rotation=25, ha='right', fontsize=8)\n    ax.set_ylabel(title); ax.set_title(f'{title} by Backbone', fontweight='bold')\n    for i, v in enumerate(vals): ax.text(i, v+0.005, f'{v:.3f}', ha='center', fontsize=9)\nplt.suptitle('🏗️ Backbone Comparison', fontsize=14, fontweight='bold')\nplt.tight_layout()\nplt.savefig('/kaggle/working/backbone_comparison.png', dpi=150, bbox_inches='tight')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-15T22:19:59.766671Z","iopub.execute_input":"2026-02-15T22:19:59.767341Z","iopub.status.idle":"2026-02-16T00:21:40.192217Z","shell.execute_reply.started":"2026-02-15T22:19:59.767313Z","shell.execute_reply":"2026-02-16T00:21:40.191496Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"BEST_BB   = best_bb\nBEST_CFG  = backbone_results[BEST_BB]['cfg']\nFT_EPOCHS = 40\nN_FOLDS   = 5\n\nprint(f\"🚀 Full training: {BEST_BB}\")\nprint(f\"   img_size={BEST_CFG['img_size']} | bs={BEST_CFG['bs']} | lr={BEST_CFG['lr']}\")\n\nskf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\nfold_scores = []\nall_val_preds, all_val_true = [], []\n\nfor fold, (tr_idx, va_idx) in enumerate(skf.split(train_paths, train_labels)):\n    print(f\"\\n{'='*50}\\n📂 FOLD {fold+1}/{N_FOLDS}\\n{'='*50}\")\n\n    tr_paths_f = [train_paths[i] for i in tr_idx]\n    va_paths_f = [train_paths[i] for i in va_idx]\n    tr_labels_f = train_labels[tr_idx]\n    va_labels_f = train_labels[va_idx]\n\n    tr_loader = DataLoader(\n        SentinelDataset(tr_paths_f, tr_labels_f, img_size=BEST_CFG['img_size'], augment=True),\n        batch_size=BEST_CFG['bs'], shuffle=True, num_workers=2, pin_memory=True)\n    va_loader = DataLoader(\n        SentinelDataset(va_paths_f, va_labels_f, img_size=BEST_CFG['img_size']),\n        batch_size=BEST_CFG['bs'], num_workers=2, pin_memory=True)\n\n    model = CropDiseaseModel(BEST_BB, num_classes=4, pretrained=True).to(DEVICE)\n\n    counts = np.bincount(tr_labels_f, minlength=4).astype(np.float32)\n    weights = torch.tensor(counts.sum() / (4 * counts + 1e-6)).to(DEVICE)\n    criterion = nn.CrossEntropyLoss(weight=weights, label_smoothing=0.1)\n\n    optimizer = torch.optim.AdamW(model.parameters(), lr=BEST_CFG['lr'], weight_decay=1e-4)\n    warmup_epochs = 5\n    def lr_lambda(epoch):\n        if epoch < warmup_epochs:\n            return (epoch + 1) / warmup_epochs\n        return 0.5 * (1 + np.cos(np.pi * (epoch - warmup_epochs) / (FT_EPOCHS - warmup_epochs)))\n    scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n\n    best_acc = 0\n    for ep in range(FT_EPOCHS):\n        model.train()\n        train_loss = 0\n        for imgs, lbls in tr_loader:\n            imgs, lbls = imgs.to(DEVICE), lbls.to(DEVICE)\n            loss = criterion(model(imgs), lbls)\n            optimizer.zero_grad()\n            loss.backward()\n            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n            optimizer.step()\n            train_loss += loss.item()\n        scheduler.step()\n\n        model.eval()\n        vp, vt = [], []\n        with torch.no_grad():\n            for imgs, lbls in va_loader:\n                preds = model(imgs.to(DEVICE)).argmax(1).cpu().numpy()\n                vp.extend(preds); vt.extend(lbls.numpy())\n\n        acc = accuracy_score(vt, vp)\n        f1 = f1_score(vt, vp, average='macro')\n\n        if acc > best_acc:\n            best_acc = acc\n            torch.save(model.state_dict(), f'/kaggle/working/best_fold{fold}.pth')\n\n        if (ep+1) % 5 == 0 or ep == 0:\n            print(f\"  Ep {ep+1:2d}/{FT_EPOCHS} | Loss: {train_loss/len(tr_loader):.4f} | \"\n                  f\"Acc: {acc:.4f} | F1: {f1:.4f} | Best: {best_acc:.4f}\")\n\n    fold_scores.append(best_acc)\n    all_val_preds.extend(vp)\n    all_val_true.extend(vt)\n    print(f\"\\n  ✅ Fold {fold+1} Best: {best_acc:.4f}\")\n    print(classification_report(vt, vp, target_names=CLASS_NAMES))\n    del model; torch.cuda.empty_cache()\n\nprint(f\"\\n{'='*50}\\n📊 FINAL CV RESULTS ({BEST_BB})\\n{'='*50}\")\nfor i, s in enumerate(fold_scores):\n    print(f\"  Fold {i+1}: {s:.4f}\")\nprint(f\"  Mean:  {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f}\")\nprint(f\"  Overall F1: {f1_score(all_val_true, all_val_preds, average='macro'):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-16T00:34:40.783801Z","iopub.execute_input":"2026-02-16T00:34:40.784503Z","iopub.status.idle":"2026-02-16T04:13:51.546953Z","shell.execute_reply.started":"2026-02-16T00:34:40.784474Z","shell.execute_reply":"2026-02-16T04:13:51.546049Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay\n\ncm = confusion_matrix(all_val_true, all_val_preds)\nfig, ax = plt.subplots(figsize=(8, 6))\ndisp = ConfusionMatrixDisplay(cm, display_labels=CLASS_NAMES)\ndisp.plot(ax=ax, cmap='Blues', values_format='d')\nax.set_title(f'Confusion Matrix — {BEST_BB}', fontsize=14, fontweight='bold')\nplt.tight_layout()\nplt.savefig('/kaggle/working/confusion_matrix.png', dpi=150, bbox_inches='tight')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-16T04:39:53.738385Z","iopub.execute_input":"2026-02-16T04:39:53.739178Z","iopub.status.idle":"2026-02-16T04:39:54.155067Z","shell.execute_reply.started":"2026-02-16T04:39:53.739140Z","shell.execute_reply":"2026-02-16T04:39:54.154352Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"🎯 GENERATING SUBMISSION\")\n\nif len(test_paths) > 0:\n    test_ds = SentinelDataset(test_paths, img_size=BEST_CFG['img_size'])\n    all_preds = []\n\n    for fold in range(N_FOLDS):\n        mp = f'/kaggle/working/best_fold{fold}.pth'\n        if not os.path.exists(mp):\n            continue\n\n        model = CropDiseaseModel(BEST_BB, num_classes=4, pretrained=False).to(DEVICE)\n        model.load_state_dict(torch.load(mp, map_location=DEVICE))\n        model.eval()\n\n        fold_preds = []\n        with torch.no_grad():\n            loader = DataLoader(test_ds, batch_size=BEST_CFG['bs'], num_workers=2)\n            for imgs in loader:\n                if isinstance(imgs, (list, tuple)):\n                    imgs = imgs[0]\n                pred = F.softmax(model(imgs.to(DEVICE)), dim=1).cpu().numpy()\n                fold_preds.append(pred)\n\n        all_preds.append(np.concatenate(fold_preds, axis=0))\n        del model; torch.cuda.empty_cache()\n        print(f\"  ✅ Fold {fold+1} done\")\n\n    if all_preds:\n        ensemble = np.mean(all_preds, axis=0)\n        final_classes = ensemble.argmax(axis=1)\n\n        submission = pd.DataFrame({\n            'Id': test_ids,\n            'Category': [INV_MAP[c] for c in final_classes]\n        })\n        submission.to_csv('/kaggle/working/submission.csv', index=False)\n\n        print(f\"\\n✅ submission.csv saved! ({len(submission)} predictions)\")\n        print(f\"\\nDistribution:\\n{submission['Category'].value_counts().to_string()}\")\n        print(f\"\\nFirst 10:\\n{submission.head(10).to_string(index=False)}\")\n\n        max_conf = ensemble.max(axis=1)\n        print(f\"\\n📊 Confidence: mean={max_conf.mean():.3f} | \"\n              f\"min={max_conf.min():.3f} | max={max_conf.max():.3f}\")\nelse:\n    print(\"⚠️ No test data found.\")\n\nprint(\"\\n🎉 Done! Submit 'submission.csv' on the competition page.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-16T04:40:39.329974Z","iopub.execute_input":"2026-02-16T04:40:39.330712Z","iopub.status.idle":"2026-02-16T04:41:01.280939Z","shell.execute_reply.started":"2026-02-16T04:40:39.330686Z","shell.execute_reply":"2026-02-16T04:41:01.280162Z"}},"outputs":[],"execution_count":null}]}