{"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":126119,"databundleVersionId":14953781}],"dockerImageVersionId":31287,"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\n# import numpy as np # linear algebra\n# import 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\n# import os\n# for 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,"execution":{"iopub.status.busy":"2026-03-01T08:58:43.816855Z","iopub.execute_input":"2026-03-01T08:58:43.817176Z","iopub.status.idle":"2026-03-01T08:58:43.821307Z","shell.execute_reply.started":"2026-03-01T08:58:43.817151Z","shell.execute_reply":"2026-03-01T08:58:43.820603Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# !pip install -q tifffile timm\n\nprint(\"✅ Ready!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:43.822924Z","iopub.execute_input":"2026-03-01T08:58:43.823216Z","iopub.status.idle":"2026-03-01T08:58:43.845233Z","shell.execute_reply.started":"2026-03-01T08:58:43.823194Z","shell.execute_reply":"2026-03-01T08:58:43.844491Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, sys, glob, random, warnings, time, gc, math, copy\nfrom pathlib import Path\nfrom collections import Counter\n\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport tifffile\nimport timm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.cuda.amp import autocast, GradScaler\n\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.metrics import f1_score, accuracy_score, classification_report\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom tqdm.auto import tqdm\nimport pandas as pd\n\nwarnings.filterwarnings('ignore')\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# Reproducibility\ndef seed_everything(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = False\n    torch.backends.cudnn.benchmark = True\n\nseed_everything(42)\n\n# ─── Paths ───\nKAGGLE_INPUT = '/kaggle/input'\n\ndef find_dataset_root(base, max_depth=4, depth=0):\n    if depth > max_depth:\n        return None\n    if (os.path.isdir(os.path.join(base, 'train'))\n        and os.path.isdir(os.path.join(base, 'val'))):\n        return base\n    try:\n        for item in sorted(os.listdir(base)):\n            child = os.path.join(base, item)\n            if os.path.isdir(child):\n                r = find_dataset_root(child, max_depth, depth + 1)\n                if r:\n                    return r\n    except PermissionError:\n        pass\n    return None\n\nBASE_PATH = None\nif os.path.exists(KAGGLE_INPUT):\n    BASE_PATH = find_dataset_root(KAGGLE_INPUT)\n\nif BASE_PATH is None:\n    BASE_PATH = '/kaggle/input/competitions/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared'\n\nTRAIN_DIR = os.path.join(BASE_PATH, 'train')\nVAL_DIR = os.path.join(BASE_PATH, 'val')\nOUTPUT_DIR = '/kaggle/working'\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\nprint(f\"📂 BASE: {BASE_PATH}\")\nprint(f\"   Train exists: {os.path.isdir(TRAIN_DIR)}\")\nprint(f\"   Val exists:   {os.path.isdir(VAL_DIR)}\")\n\n# ─── Hyperparameters ───\nIMG_SIZE = 128\nNUM_CLASSES = 3\nCLASS_NAMES = ['Health', 'Rust', 'Other']\nCLASS_MAP = {n: i for i, n in enumerate(CLASS_NAMES)}\nINV_MAP = {i: n for n, i in CLASS_MAP.items()}\n\n# HS band selection (14 key bands from 125)\nHS_BANDS = [10, 15, 22, 30, 38, 50, 55, 63, 72, 80, 88, 95, 100, 108]\nMS_BANDS = 5  # G, R, RE, NIR\nRGB_CH = 3\n\n# Total input channels: 14 HS + 5 MS + 3 RGB + 2 indices = 24\nTOTAL_CHANNELS = len(HS_BANDS) + MS_BANDS + RGB_CH + 2  # +NDVI +NDWI\n\nN_FOLDS = 5\nEPOCHS = 35\nBATCH_SIZE = 16\nLR = 2e-4\n\nprint(f\"\\n🔧 Config: IMG={IMG_SIZE}, CH={TOTAL_CHANNELS}, FOLDS={N_FOLDS}, EPOCHS={EPOCHS}\")\nprint(\"✅ Configuration loaded!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:43.846607Z","iopub.execute_input":"2026-03-01T08:58:43.846958Z","iopub.status.idle":"2026-03-01T08:58:43.878679Z","shell.execute_reply.started":"2026-03-01T08:58:43.846927Z","shell.execute_reply":"2026-03-01T08:58:43.878124Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def get_class_from_filename(fname):\n    stem = Path(fname).stem\n    for cls in CLASS_NAMES:\n        if stem.startswith(cls):\n            return cls\n    return None\n\n\nclass MultimodalDataset(Dataset):\n    \"\"\"Loads and fuses HS + MS + RGB into a single multi-channel tensor.\n\n    Channel layout: [14 HS bands] + [5 MS bands] + [3 RGB] + [NDVI] + [NDWI] = 24ch\n    \"\"\"\n\n    def __init__(self, hs_paths, labels=None, img_size=IMG_SIZE, augment=False):\n        self.hs_paths = hs_paths\n        self.labels = labels\n        self.img_size = img_size\n        self.augment = augment\n\n    def __len__(self):\n        return len(self.hs_paths)\n\n    def _load_and_resize(self, data, target_size):\n        \"\"\"Resize (C, H, W) array to (C, target_size, target_size).\"\"\"\n        t = torch.from_numpy(data.astype(np.float32)).unsqueeze(0)\n        t = F.interpolate(t, size=(target_size, target_size),\n                          mode='bilinear', align_corners=False)\n        return t.squeeze(0)  # (C, H, W)\n\n    def _normalize_band(self, band):\n        \"\"\"Percentile normalization per band.\"\"\"\n        lo = np.percentile(band, 2)\n        hi = np.percentile(band, 98)\n        if hi > lo:\n            return np.clip((band - lo) / (hi - lo), 0, 1).astype(np.float32)\n        return np.zeros_like(band, dtype=np.float32)\n\n    def __getitem__(self, idx):\n        hs_path = self.hs_paths[idx]\n        parent = os.path.dirname(os.path.dirname(hs_path))\n        stem = Path(hs_path).stem\n\n        channels = []\n\n        # ─── 1. HS bands (14 selected) ───\n        try:\n            hs_raw = tifffile.imread(hs_path)\n            if hs_raw.ndim == 3 and hs_raw.shape[2] > hs_raw.shape[0]:\n                hs_raw = np.transpose(hs_raw, (2, 0, 1))\n            n_bands = hs_raw.shape[0]\n            selected = []\n            for b in HS_BANDS:\n                bi = min(b, n_bands - 1)\n                selected.append(self._normalize_band(hs_raw[bi]))\n            hs_tensor = self._load_and_resize(np.stack(selected), self.img_size)\n            channels.append(hs_tensor)\n\n            # NDVI and NDWI\n            eps = 1e-8\n            nir_idx = min(88, n_bands - 1)\n            red_idx = min(50, n_bands - 1)\n            grn_idx = min(22, n_bands - 1)\n            nir = self._normalize_band(hs_raw[nir_idx])\n            red = self._normalize_band(hs_raw[red_idx])\n            grn = self._normalize_band(hs_raw[grn_idx])\n            ndvi = (nir - red) / (nir + red + eps)\n            ndwi = (grn - nir) / (grn + nir + eps)\n            indices = self._load_and_resize(\n                np.stack([ndvi.astype(np.float32), ndwi.astype(np.float32)]),\n                self.img_size\n            )\n            channels.append(indices)\n        except Exception:\n            channels.append(torch.zeros(len(HS_BANDS), self.img_size, self.img_size))\n            channels.append(torch.zeros(2, self.img_size, self.img_size))\n\n        # ─── 2. MS bands (5) ───\n        ms_dir = os.path.join(parent, 'MS')\n        try:\n            ms_path = None\n            for ext in ['.tif', '.tiff']:\n                p = os.path.join(ms_dir, stem + ext)\n                if os.path.exists(p):\n                    ms_path = p\n                    break\n            if ms_path:\n                ms_raw = tifffile.imread(ms_path)\n                if ms_raw.ndim == 3 and ms_raw.shape[2] > ms_raw.shape[0]:\n                    ms_raw = np.transpose(ms_raw, (2, 0, 1))\n                ms_bands = []\n                for b in range(min(MS_BANDS, ms_raw.shape[0])):\n                    ms_bands.append(self._normalize_band(ms_raw[b]))\n                while len(ms_bands) < MS_BANDS:\n                    ms_bands.append(np.zeros_like(ms_bands[0]))\n                ms_tensor = self._load_and_resize(np.stack(ms_bands), self.img_size)\n                channels.append(ms_tensor)\n            else:\n                raise FileNotFoundError\n        except Exception:\n            channels.append(torch.zeros(MS_BANDS, self.img_size, self.img_size))\n\n        # ─── 3. RGB (3) ───\n        rgb_dir = os.path.join(parent, 'RGB')\n        try:\n            rgb_path = None\n            for ext in ['.png', '.tif', '.tiff', '.jpg']:\n                p = os.path.join(rgb_dir, stem + ext)\n                if os.path.exists(p):\n                    rgb_path = p\n                    break\n            if rgb_path:\n                from PIL import Image\n                if rgb_path.endswith(('.tif', '.tiff')):\n                    rgb_raw = tifffile.imread(rgb_path)\n                else:\n                    rgb_raw = np.array(Image.open(rgb_path).convert('RGB'))\n                if rgb_raw.ndim == 2:\n                    rgb_raw = np.stack([rgb_raw]*3, axis=-1)\n                if rgb_raw.shape[0] <= 4:\n                    rgb_raw = np.transpose(rgb_raw, (1, 2, 0))\n                rgb_raw = rgb_raw[:, :, :3]\n                rgb_bands = []\n                for c in range(3):\n                    rgb_bands.append(self._normalize_band(rgb_raw[:, :, c]))\n                rgb_tensor = self._load_and_resize(np.stack(rgb_bands), self.img_size)\n                channels.append(rgb_tensor)\n            else:\n                raise FileNotFoundError\n        except Exception:\n            channels.append(torch.zeros(RGB_CH, self.img_size, self.img_size))\n\n        # ─── Concatenate all channels ───\n        x = torch.cat(channels, dim=0)  # (24, H, W)\n\n        # ─── Augmentation ───\n        if self.augment:\n            if random.random() > 0.5:\n                x = torch.flip(x, [2])  # Horizontal flip\n            if random.random() > 0.5:\n                x = torch.flip(x, [1])  # Vertical flip\n            k = random.randint(0, 3)\n            x = torch.rot90(x, k, [1, 2])  # Random rotation\n\n            # Random channel dropout (drop 1-2 channels)\n            if random.random() > 0.7:\n                drop_ch = random.randint(0, x.shape[0] - 1)\n                x[drop_ch] = 0.0\n\n            # Gaussian noise\n            if random.random() > 0.5:\n                x = x + torch.randn_like(x) * 0.02\n                x = torch.clamp(x, 0, 1)\n\n        if self.labels is not None:\n            return x, torch.tensor(self.labels[idx], dtype=torch.long)\n        return x\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:43.968012Z","iopub.execute_input":"2026-03-01T08:58:43.968445Z","iopub.status.idle":"2026-03-01T08:58:43.991249Z","shell.execute_reply.started":"2026-03-01T08:58:43.968422Z","shell.execute_reply":"2026-03-01T08:58:43.990405Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class MultimodalModel(nn.Module):\n    \"\"\"Pretrained backbone adapted for multi-channel input with strong head.\"\"\"\n\n    def __init__(self, backbone='efficientnet_b2', in_chans=TOTAL_CHANNELS,\n                 num_classes=NUM_CLASSES, pretrained=True, drop_rate=0.4):\n        super().__init__()\n        self.model = timm.create_model(\n            backbone, pretrained=pretrained,\n            num_classes=0,  # Remove classifier\n            in_chans=in_chans,\n            drop_rate=drop_rate,\n        )\n        feat_dim = self.model.num_features\n\n        self.head = nn.Sequential(\n            nn.LayerNorm(feat_dim),\n            nn.Dropout(drop_rate),\n            nn.Linear(feat_dim, 256),\n            nn.GELU(),\n            nn.Dropout(drop_rate * 0.5),\n            nn.Linear(256, num_classes),\n        )\n        print(f\"✅ {backbone}: {in_chans}ch → {feat_dim}-d → {num_classes} classes\")\n\n    def forward(self, x):\n        features = self.model(x)\n        return self.head(features)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:43.992791Z","iopub.execute_input":"2026-03-01T08:58:43.993014Z","iopub.status.idle":"2026-03-01T08:58:44.008623Z","shell.execute_reply.started":"2026-03-01T08:58:43.992995Z","shell.execute_reply":"2026-03-01T08:58:44.008058Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class FocalLoss(nn.Module):\n    def __init__(self, weight=None, gamma=2.0, smoothing=0.1):\n        super().__init__()\n        self.gamma = gamma\n        self.weight = weight\n        self.smoothing = smoothing\n\n    def forward(self, logits, targets):\n        ce = F.cross_entropy(logits, targets, weight=self.weight,\n                             label_smoothing=self.smoothing, reduction='none')\n        pt = torch.exp(-ce)\n        return (((1 - pt) ** self.gamma) * ce).mean()\n\n\ndef mixup_data(x, y, alpha=0.3):\n    lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0\n    idx = torch.randperm(x.size(0), device=x.device)\n    return lam * x + (1 - lam) * x[idx], y, y[idx], lam\n\n\ndef cutmix_data(x, y, alpha=1.0):\n    lam = np.random.beta(alpha, alpha)\n    idx = torch.randperm(x.size(0), device=x.device)\n    _, _, H, W = x.shape\n    cut_rat = np.sqrt(1.0 - lam)\n    cut_w, cut_h = int(W * cut_rat), int(H * cut_rat)\n    cx, cy = np.random.randint(W), np.random.randint(H)\n    x1 = np.clip(cx - cut_w // 2, 0, W)\n    y1 = np.clip(cy - cut_h // 2, 0, H)\n    x2 = np.clip(cx + cut_w // 2, 0, W)\n    y2 = np.clip(cy + cut_h // 2, 0, H)\n    x_mix = x.clone()\n    x_mix[:, :, y1:y2, x1:x2] = x[idx, :, y1:y2, x1:x2]\n    lam = 1 - (x2 - x1) * (y2 - y1) / (W * H)\n    return x_mix, y, y[idx], lam\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:44.009531Z","iopub.execute_input":"2026-03-01T08:58:44.009827Z","iopub.status.idle":"2026-03-01T08:58:44.023178Z","shell.execute_reply.started":"2026-03-01T08:58:44.009800Z","shell.execute_reply":"2026-03-01T08:58:44.022548Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"\\n\" + \"=\" * 60)\nprint(\"📦 GATHERING DATA\")\nprint(\"=\" * 60)\n\n# Train\nhs_train_dir = os.path.join(TRAIN_DIR, 'HS')\ntrain_paths, train_labels = [], []\nfor f in sorted(os.listdir(hs_train_dir)):\n    fp = os.path.join(hs_train_dir, f)\n    if not os.path.isfile(fp) or f.startswith('.'):\n        continue\n    cls = get_class_from_filename(f)\n    if cls and cls in CLASS_MAP:\n        train_paths.append(fp)\n        train_labels.append(CLASS_MAP[cls])\n\n# Val\nhs_val_dir = os.path.join(VAL_DIR, 'HS')\nval_paths, val_fnames = [], []\nfor f in sorted(os.listdir(hs_val_dir)):\n    fp = os.path.join(hs_val_dir, f)\n    if os.path.isfile(fp) and not f.startswith('.'):\n        val_paths.append(fp)\n        val_fnames.append(f)\n\ntrain_labels = np.array(train_labels)\nprint(f\"Train: {len(train_paths)} | Val: {len(val_paths)}\")\nfor cls in CLASS_NAMES:\n    print(f\"  {cls}: {(train_labels == CLASS_MAP[cls]).sum()}\")\n\n# Sanity check\nds_test = MultimodalDataset(train_paths[:1])\nsample = ds_test[0]\nprint(f\"\\n✅ Sample shape: {sample.shape} ({TOTAL_CHANNELS} channels)\")\ndel ds_test\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:44.023989Z","iopub.execute_input":"2026-03-01T08:58:44.024211Z","iopub.status.idle":"2026-03-01T08:58:46.324233Z","shell.execute_reply.started":"2026-03-01T08:58:44.024192Z","shell.execute_reply":"2026-03-01T08:58:46.323467Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_backbone(backbone_name, train_paths, train_labels, val_paths,\n                   epochs=EPOCHS, lr=LR, batch_size=BATCH_SIZE):\n    \"\"\"Train a single backbone with 5-fold CV. Returns fold models and OOF preds.\"\"\"\n\n    print(f\"\\n{'='*60}\")\n    print(f\"🏗️ TRAINING: {backbone_name}\")\n    print(f\"{'='*60}\")\n\n    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n    fold_scores = []\n    all_val_probs = np.zeros((len(val_paths), NUM_CLASSES))\n\n    for fold, (tr_idx, va_idx) in enumerate(skf.split(train_paths, train_labels)):\n        print(f\"\\n📂 Fold {fold+1}/{N_FOLDS}\")\n        seed_everything(42 + fold)\n\n        tr_paths = [train_paths[i] for i in tr_idx]\n        va_paths = [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_ds = MultimodalDataset(tr_paths, tr_labels_f, augment=True)\n        va_ds = MultimodalDataset(va_paths, va_labels_f, augment=False)\n        tr_loader = DataLoader(tr_ds, batch_size=batch_size, shuffle=True,\n                               num_workers=2, pin_memory=True, drop_last=True)\n        va_loader = DataLoader(va_ds, batch_size=batch_size, shuffle=False,\n                               num_workers=2, pin_memory=True)\n\n        model = MultimodalModel(backbone_name, pretrained=True).to(DEVICE)\n\n        # Class weights\n        cw = compute_class_weight('balanced', classes=np.unique(tr_labels_f), y=tr_labels_f)\n        class_w = torch.tensor(cw, dtype=torch.float32).to(DEVICE)\n        criterion = FocalLoss(weight=class_w, gamma=2.0, smoothing=0.1)\n\n        optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)\n\n        # Cosine with warmup\n        warmup = 3\n        def lr_fn(ep):\n            if ep < warmup:\n                return (ep + 1) / warmup\n            return 0.5 * (1 + math.cos(math.pi * (ep - warmup) / (epochs - warmup)))\n        scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_fn)\n\n        scaler = GradScaler()\n        best_f1 = 0\n        save_path = os.path.join(OUTPUT_DIR, f'{backbone_name}_fold{fold}.pth')\n\n        for epoch in range(epochs):\n            model.train()\n            total_loss = 0\n\n            for imgs, lbls in tr_loader:\n                imgs = imgs.to(DEVICE, non_blocking=True)\n                lbls = lbls.to(DEVICE, non_blocking=True)\n\n                # MixUp or CutMix (50/50 chance)\n                r = random.random()\n                if r < 0.3:\n                    imgs, y_a, y_b, lam = mixup_data(imgs, lbls, alpha=0.3)\n                elif r < 0.6:\n                    imgs, y_a, y_b, lam = cutmix_data(imgs, lbls, alpha=1.0)\n                else:\n                    y_a, y_b, lam = lbls, lbls, 1.0\n\n                optimizer.zero_grad(set_to_none=True)\n                with autocast():\n                    logits = model(imgs)\n                    loss = lam * criterion(logits, y_a) + (1 - lam) * criterion(logits, y_b)\n\n                scaler.scale(loss).backward()\n                scaler.unscale_(optimizer)\n                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n                scaler.step(optimizer)\n                scaler.update()\n                total_loss += loss.item()\n\n            scheduler.step()\n\n            # Validation\n            model.eval()\n            va_preds, va_true = [], []\n            with torch.no_grad():\n                for imgs, lbls in va_loader:\n                    imgs = imgs.to(DEVICE)\n                    with autocast():\n                        logits = model(imgs)\n                    va_preds.extend(logits.argmax(1).cpu().numpy())\n                    va_true.extend(lbls.numpy())\n\n            f1 = f1_score(va_true, va_preds, average='macro')\n            acc = accuracy_score(va_true, va_preds)\n\n            if f1 > best_f1:\n                best_f1 = f1\n                torch.save(model.state_dict(), save_path)\n\n            if (epoch + 1) % 5 == 0 or epoch == 0:\n                print(f\"  Ep {epoch+1:2d}/{epochs} | loss={total_loss/len(tr_loader):.4f} | \"\n                      f\"F1={f1:.4f} | Acc={acc:.4f} | Best={best_f1:.4f}\")\n\n        fold_scores.append(best_f1)\n        print(f\"  ✅ Fold {fold+1} Best F1: {best_f1:.4f}\")\n\n        # Inference on val with TTA\n        model.load_state_dict(torch.load(save_path, map_location=DEVICE))\n        model.eval()\n        fold_probs = predict_with_tta(model, val_paths, n_tta=8)\n        all_val_probs += fold_probs / N_FOLDS\n\n        del model, tr_ds, va_ds, tr_loader, va_loader\n        torch.cuda.empty_cache()\n        gc.collect()\n\n    print(f\"\\n📊 {backbone_name} CV: {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f}\")\n    return all_val_probs, fold_scores\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:46.326278Z","iopub.execute_input":"2026-03-01T08:58:46.326521Z","iopub.status.idle":"2026-03-01T08:58:46.341697Z","shell.execute_reply.started":"2026-03-01T08:58:46.326500Z","shell.execute_reply":"2026-03-01T08:58:46.341095Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"@torch.no_grad()\ndef predict_with_tta(model, file_paths, n_tta=8, batch_size=32):\n    \"\"\"8-fold TTA: original + flips + rotations.\"\"\"\n    model.eval()\n    all_probs = np.zeros((len(file_paths), NUM_CLASSES))\n\n    for tta_round in range(n_tta):\n        augment = (tta_round > 0)\n        ds = MultimodalDataset(file_paths, augment=augment)\n        loader = DataLoader(ds, batch_size=batch_size, shuffle=False,\n                            num_workers=2, pin_memory=True)\n        round_probs = []\n        for batch in loader:\n            if isinstance(batch, (list, tuple)):\n                batch = batch[0]\n            imgs = batch.to(DEVICE)\n            with autocast():\n                logits = model(imgs)\n            probs = F.softmax(logits, dim=1).cpu().numpy()\n            round_probs.append(probs)\n\n        round_probs = np.concatenate(round_probs, axis=0)\n        all_probs += round_probs / n_tta\n\n    return all_probs","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:46.342509Z","iopub.execute_input":"2026-03-01T08:58:46.342803Z","iopub.status.idle":"2026-03-01T08:58:46.363374Z","shell.execute_reply.started":"2026-03-01T08:58:46.342781Z","shell.execute_reply":"2026-03-01T08:58:46.362655Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"\\n\" + \"=\" * 60)\nprint(\"🚀 TRAINING MULTI-BACKBONE ENSEMBLE\")\nprint(\"=\" * 60)\n\n# Backbone 1: EfficientNet-B2\nprobs_eff, scores_eff = train_backbone(\n    'efficientnet_b2', train_paths, train_labels, val_paths,\n    epochs=EPOCHS, lr=2e-4, batch_size=16\n)\n\n# Backbone 2: ConvNeXt-Tiny\nprobs_cnx, scores_cnx = train_backbone(\n    'convnext_tiny', train_paths, train_labels, val_paths,\n    epochs=EPOCHS, lr=1e-4, batch_size=12\n)\n\n# Ensemble: weighted average\nW_EFF = 0.55  # EfficientNet usually slightly better\nW_CNX = 0.45\nensemble_probs = W_EFF * probs_eff + W_CNX * probs_cnx\nensemble_preds = ensemble_probs.argmax(axis=1)\n\n# Save ensemble submission\nsubmission = pd.DataFrame({\n    'Id': val_fnames,\n    'Category': [INV_MAP[p] for p in ensemble_preds],\n})\nsub_path = os.path.join(OUTPUT_DIR, 'submission_ultimate_ensemble.csv')\nsubmission.to_csv(sub_path, index=False)\n\nprint(f\"\\n{'='*60}\")\nprint(f\"📊 ENSEMBLE RESULTS\")\nprint(f\"   EfficientNet-B2 CV: {np.mean(scores_eff):.4f} ± {np.std(scores_eff):.4f}\")\nprint(f\"   ConvNeXt-Tiny CV:   {np.mean(scores_cnx):.4f} ± {np.std(scores_cnx):.4f}\")\nprint(f\"   Predictions: {dict(submission.Category.value_counts())}\")\nprint(f\"✅ Saved: {sub_path}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T08:58:46.364445Z","iopub.execute_input":"2026-03-01T08:58:46.365044Z","iopub.status.idle":"2026-03-01T09:50:08.914142Z","shell.execute_reply.started":"2026-03-01T08:58:46.365018Z","shell.execute_reply":"2026-03-01T09:50:08.913160Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"\\n\" + \"=\" * 60)\nprint(\"🔀 BLENDING WITH SUPERVISED MODEL (0.758)\")\nprint(\"=\" * 60)\n\n# Hardcoded supervised predictions\nsup_preds = {\n    'val_000a83c1.tif': 'Health', 'val_00a704b1.tif': 'Other', 'val_01dde030.tif': 'Other',\n    'val_024df365.tif': 'Health', 'val_02afcb0e.tif': 'Rust', 'val_03864ba6.tif': 'Health',\n    'val_0537e324.tif': 'Rust', 'val_059983e0.tif': 'Health', 'val_05cee914.tif': 'Other',\n    'val_07af871a.tif': 'Rust', 'val_08f755a5.tif': 'Health', 'val_09d2b5ec.tif': 'Rust',\n    'val_0b5a9206.tif': 'Rust', 'val_0d9e5a50.tif': 'Other', 'val_10f28665.tif': 'Rust',\n    'val_1147aa81.tif': 'Other', 'val_1157e423.tif': 'Other', 'val_11fc3df9.tif': 'Other',\n    'val_147643a2.tif': 'Other', 'val_15e86c11.tif': 'Rust', 'val_16a0e917.tif': 'Health',\n    'val_180483f1.tif': 'Other', 'val_185c28ec.tif': 'Other', 'val_19752faf.tif': 'Rust',\n    'val_19a91707.tif': 'Other', 'val_1ad29634.tif': 'Other', 'val_1d31c347.tif': 'Other',\n    'val_1d63cdc8.tif': 'Other', 'val_1dc03c71.tif': 'Other', 'val_1df74c1b.tif': 'Other',\n    'val_1e0be33a.tif': 'Other', 'val_1ebd4873.tif': 'Rust', 'val_1fb27b70.tif': 'Health',\n    'val_1ff2be67.tif': 'Other', 'val_20087514.tif': 'Other', 'val_20e42785.tif': 'Other',\n    'val_222a4874.tif': 'Other', 'val_22fc2410.tif': 'Health', 'val_264d9a97.tif': 'Rust',\n    'val_274306a1.tif': 'Health', 'val_2b215dcf.tif': 'Health', 'val_2cd08e22.tif': 'Health',\n    'val_2d21d0d1.tif': 'Health', 'val_2ddd1bb4.tif': 'Rust', 'val_2e08eeff.tif': 'Rust',\n    'val_2e2cde42.tif': 'Rust', 'val_2e44c155.tif': 'Health', 'val_2eb97425.tif': 'Health',\n    'val_2f371eb8.tif': 'Rust', 'val_30107726.tif': 'Health', 'val_30d17cde.tif': 'Health',\n    'val_31a6a18d.tif': 'Other', 'val_357a9567.tif': 'Other', 'val_35e2faef.tif': 'Rust',\n    'val_3695f734.tif': 'Health', 'val_36c18096.tif': 'Health', 'val_36e957bb.tif': 'Rust',\n    'val_3766bcf8.tif': 'Rust', 'val_37c8cc6c.tif': 'Rust', 'val_3a09250f.tif': 'Other',\n    'val_3a61aa62.tif': 'Other', 'val_3a67986e.tif': 'Health', 'val_3a6a53b3.tif': 'Rust',\n    'val_3f6a4de8.tif': 'Health', 'val_3f8685fe.tif': 'Rust', 'val_40d88f13.tif': 'Other',\n    'val_41059371.tif': 'Rust', 'val_41a41062.tif': 'Rust', 'val_44391beb.tif': 'Health',\n    'val_44f91bba.tif': 'Rust', 'val_45bfdb87.tif': 'Health', 'val_4606d55e.tif': 'Rust',\n    'val_46511c23.tif': 'Other', 'val_47168ee2.tif': 'Health', 'val_47de91ce.tif': 'Other',\n    'val_483d4c50.tif': 'Rust', 'val_49c1e309.tif': 'Health', 'val_4b458633.tif': 'Other',\n    'val_4db30a2c.tif': 'Rust', 'val_4de40fdf.tif': 'Other', 'val_500c6a92.tif': 'Rust',\n    'val_53270b0f.tif': 'Other', 'val_53648d10.tif': 'Health', 'val_5372f1e7.tif': 'Other',\n    'val_57bc995b.tif': 'Rust', 'val_58df2f68.tif': 'Health', 'val_5a361ff8.tif': 'Other',\n    'val_5adcdc2e.tif': 'Other', 'val_5afc79c8.tif': 'Other', 'val_5bd2522f.tif': 'Rust',\n    'val_5ea8e3ba.tif': 'Health', 'val_5fab1376.tif': 'Health', 'val_5fc55ef7.tif': 'Health',\n    'val_604b91f0.tif': 'Rust', 'val_6136f820.tif': 'Health', 'val_62db1e6f.tif': 'Rust',\n    'val_6338d9e6.tif': 'Other', 'val_640be560.tif': 'Health', 'val_65f656ec.tif': 'Rust',\n    'val_66f797e1.tif': 'Other', 'val_672cd515.tif': 'Health', 'val_676f21c4.tif': 'Rust',\n    'val_67af5dc8.tif': 'Health', 'val_67c80815.tif': 'Other', 'val_688d8eae.tif': 'Rust',\n    'val_68aa3069.tif': 'Rust', 'val_68f339c4.tif': 'Health', 'val_6904db6a.tif': 'Other',\n    'val_69f91160.tif': 'Other', 'val_6a351fa5.tif': 'Other', 'val_6aa2e3d0.tif': 'Health',\n    'val_6b1a72fd.tif': 'Rust', 'val_6b6509fd.tif': 'Other', 'val_6c142943.tif': 'Rust',\n    'val_6c1c4dc6.tif': 'Rust', 'val_6cef0158.tif': 'Other', 'val_6dff8663.tif': 'Rust',\n    'val_7127f7c8.tif': 'Other', 'val_71d2759c.tif': 'Rust', 'val_7395d111.tif': 'Rust',\n    'val_743c8012.tif': 'Health', 'val_759a72ba.tif': 'Rust', 'val_75bd9d09.tif': 'Rust',\n    'val_76b734ac.tif': 'Rust', 'val_7b2af9c3.tif': 'Rust', 'val_7ce5ee63.tif': 'Other',\n    'val_7ce60a77.tif': 'Health', 'val_7de8df5a.tif': 'Rust', 'val_7f2b8400.tif': 'Rust',\n    'val_7f561b5d.tif': 'Health', 'val_80e3f278.tif': 'Rust', 'val_81fa3c3e.tif': 'Health',\n    'val_8397daf7.tif': 'Other', 'val_848712e4.tif': 'Other', 'val_84995ce8.tif': 'Rust',\n    'val_84be8027.tif': 'Rust', 'val_84cbd32d.tif': 'Rust', 'val_857f4536.tif': 'Rust',\n    'val_866b354c.tif': 'Rust', 'val_86739c82.tif': 'Rust', 'val_8737d33f.tif': 'Health',\n    'val_8889cbed.tif': 'Rust', 'val_89122c1e.tif': 'Health', 'val_89699d76.tif': 'Rust',\n    'val_8b8362e5.tif': 'Rust', 'val_8d7acaae.tif': 'Other', 'val_8e35e5d5.tif': 'Other',\n    'val_8e9e3f20.tif': 'Other', 'val_8ebe83d8.tif': 'Other', 'val_8efcaee7.tif': 'Rust',\n    'val_8fa76dd5.tif': 'Other', 'val_9265fd4f.tif': 'Rust', 'val_94d72cf9.tif': 'Rust',\n    'val_94f98751.tif': 'Health', 'val_9526c1c0.tif': 'Rust', 'val_95644e7a.tif': 'Health',\n    'val_986dc7b5.tif': 'Rust', 'val_98b71660.tif': 'Health', 'val_98efde52.tif': 'Health',\n    'val_992d823d.tif': 'Rust', 'val_99c4d66f.tif': 'Rust', 'val_9a4bf1b3.tif': 'Health',\n    'val_9c32d2de.tif': 'Other', 'val_9c86f666.tif': 'Health', 'val_9dc465fd.tif': 'Rust',\n    'val_9eb4f156.tif': 'Health', 'val_9f95beac.tif': 'Other', 'val_9fa559c8.tif': 'Rust',\n    'val_9fdb8129.tif': 'Rust', 'val_a236c630.tif': 'Rust', 'val_a35051b0.tif': 'Health',\n    'val_a40dab77.tif': 'Other', 'val_a4353142.tif': 'Rust', 'val_a51a4db3.tif': 'Other',\n    'val_a54fa0cc.tif': 'Health', 'val_a72d6dc0.tif': 'Rust', 'val_a7aa073c.tif': 'Rust',\n    'val_a7edf8d2.tif': 'Other', 'val_a82aa0fa.tif': 'Rust', 'val_a97e31ee.tif': 'Health',\n    'val_a999cac9.tif': 'Other', 'val_a9d4b100.tif': 'Rust', 'val_abefe139.tif': 'Other',\n    'val_ac83f12c.tif': 'Rust', 'val_add7169c.tif': 'Rust', 'val_af4bea21.tif': 'Rust',\n    'val_b0ab7f6a.tif': 'Rust', 'val_b1a321b7.tif': 'Rust', 'val_b2711ce6.tif': 'Rust',\n    'val_b34517b7.tif': 'Other', 'val_b378be0b.tif': 'Health', 'val_b394d497.tif': 'Health',\n    'val_b4efdc58.tif': 'Rust', 'val_b523bc4d.tif': 'Health', 'val_b52c485a.tif': 'Health',\n    'val_b5b4fbcc.tif': 'Rust', 'val_b691522f.tif': 'Health', 'val_b6c9d3d7.tif': 'Rust',\n    'val_b6feb0b5.tif': 'Other', 'val_b77cbc86.tif': 'Other', 'val_b7a1d2f1.tif': 'Other',\n    'val_b81dbdfe.tif': 'Other', 'val_b822b957.tif': 'Other', 'val_b826d518.tif': 'Rust',\n    'val_b8b2cbc2.tif': 'Rust', 'val_b8d1e576.tif': 'Other', 'val_bb78e8a7.tif': 'Rust',\n    'val_bbcdad89.tif': 'Rust', 'val_bbcf9fe5.tif': 'Other', 'val_bbda6767.tif': 'Other',\n    'val_bc1bab51.tif': 'Health', 'val_bc388562.tif': 'Other', 'val_bdbe7456.tif': 'Rust',\n    'val_bff0763d.tif': 'Rust', 'val_c388ecb9.tif': 'Rust', 'val_c4158ad0.tif': 'Rust',\n    'val_c4598f3c.tif': 'Other', 'val_c54d2bfa.tif': 'Other', 'val_c55250e7.tif': 'Rust',\n    'val_c8608ba7.tif': 'Rust', 'val_c8791719.tif': 'Other', 'val_c8c93eaa.tif': 'Other',\n    'val_c8f4b814.tif': 'Other', 'val_c956c61d.tif': 'Health', 'val_c988b239.tif': 'Rust',\n    'val_c9cef268.tif': 'Other', 'val_cc138b03.tif': 'Health', 'val_cc2ca03e.tif': 'Rust',\n    'val_ccb2491e.tif': 'Health', 'val_cd11b52b.tif': 'Health', 'val_ceaa5449.tif': 'Other',\n    'val_d113c25f.tif': 'Rust', 'val_d1d81b47.tif': 'Rust', 'val_d3421d43.tif': 'Rust',\n    'val_d3ccc922.tif': 'Health', 'val_d4984b6c.tif': 'Other', 'val_d4bdd2fb.tif': 'Rust',\n    'val_d55d6e58.tif': 'Other', 'val_d6d67cf3.tif': 'Health', 'val_d6d8faa6.tif': 'Health',\n    'val_d768878f.tif': 'Other', 'val_d7b2a318.tif': 'Rust', 'val_d7fc83cd.tif': 'Rust',\n    'val_d88258eb.tif': 'Other', 'val_da53be12.tif': 'Health', 'val_da9148e1.tif': 'Other',\n    'val_daa666bc.tif': 'Rust', 'val_dad4681b.tif': 'Rust', 'val_dc0b8d70.tif': 'Health',\n    'val_dc5d857c.tif': 'Rust', 'val_de1b5896.tif': 'Rust', 'val_de6bcd4e.tif': 'Health',\n    'val_dea7b29d.tif': 'Health', 'val_deda51bb.tif': 'Other', 'val_df4837c1.tif': 'Health',\n    'val_e0479a61.tif': 'Other', 'val_e1629b46.tif': 'Health', 'val_e2845912.tif': 'Rust',\n    'val_e2969714.tif': 'Health', 'val_e4605d0d.tif': 'Other', 'val_e5abd239.tif': 'Health',\n    'val_e804bb69.tif': 'Rust', 'val_e8238864.tif': 'Other', 'val_e88c83bf.tif': 'Rust',\n    'val_e90aa9a8.tif': 'Health', 'val_e9a3cc79.tif': 'Rust', 'val_e9ce960f.tif': 'Other',\n    'val_e9e8ce23.tif': 'Rust', 'val_ebd5e85f.tif': 'Rust', 'val_ee0dd53f.tif': 'Health',\n    'val_ee803b15.tif': 'Rust', 'val_efdc49ea.tif': 'Other', 'val_efeb1a45.tif': 'Health',\n    'val_f032e5be.tif': 'Health', 'val_f049cacb.tif': 'Rust', 'val_f0a30df9.tif': 'Other',\n    'val_f0d99865.tif': 'Other', 'val_f19e25ce.tif': 'Rust', 'val_f1a4afff.tif': 'Other',\n    'val_f1fed156.tif': 'Other', 'val_f25c0c10.tif': 'Rust', 'val_f2b503c3.tif': 'Health',\n    'val_f424ae34.tif': 'Other', 'val_f44c5f6a.tif': 'Other', 'val_f4ad06bb.tif': 'Other',\n    'val_f5cf987f.tif': 'Rust', 'val_f618e232.tif': 'Other', 'val_f63bd6e1.tif': 'Rust',\n    'val_f6466225.tif': 'Other', 'val_f67249e6.tif': 'Other', 'val_f720cb3d.tif': 'Health',\n    'val_f73b79e5.tif': 'Health', 'val_f7ccfa2c.tif': 'Health', 'val_fa308719.tif': 'Health',\n    'val_fa49e86d.tif': 'Health', 'val_fa892201.tif': 'Rust', 'val_fabc42be.tif': 'Rust',\n    'val_fac15190.tif': 'Rust', 'val_fde50c68.tif': 'Other', 'val_ffbaf88b.tif': 'Rust',\n}\nsup_sub = pd.DataFrame({'Id': list(sup_preds.keys()), 'Category': list(sup_preds.values())})\n# Convert supervised to one-hot probabilities\nsup_probs = np.zeros((len(val_fnames), NUM_CLASSES))\nsup_id_map = {row['Id']: CLASS_MAP.get(row['Category'], 0) for _, row in sup_sub.iterrows()}\nfor i, fname in enumerate(val_fnames):\n    if fname in sup_id_map:\n        sup_probs[i, sup_id_map[fname]] = 1.0\n\n# Blend: 50% ensemble soft probs + 50% supervised hard probs\nfor w_ens in [0.4, 0.5, 0.6]:\n    blended = w_ens * ensemble_probs + (1 - w_ens) * sup_probs\n    blended_preds = blended.argmax(axis=1)\n    sub = pd.DataFrame({\n        'Id': val_fnames,\n        'Category': [INV_MAP[p] for p in blended_preds],\n    })\n    fname = f'submission_blend_{int(w_ens*100)}ens_{int((1-w_ens)*100)}sup.csv'\n    sub.to_csv(os.path.join(OUTPUT_DIR, fname), index=False)\n    dist = dict(sub.Category.value_counts())\n    print(f\"  📄 {fname} → {dist}\")\n\n# Also save pure ensemble\nprint(f\"\\n✅ All submissions saved to {OUTPUT_DIR}\")\nprint(\"   Submit: submission_blend_50ens_50sup.csv (best bet)\")\nprint(\"   Also try: submission_ultimate_ensemble.csv (pure multimodal)\")\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"🏆 ULTIMATE SOLUTION COMPLETE!\")\nprint(\"=\" * 60)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T09:50:08.915701Z","iopub.execute_input":"2026-03-01T09:50:08.915966Z","iopub.status.idle":"2026-03-01T09:50:08.955362Z","shell.execute_reply.started":"2026-03-01T09:50:08.915942Z","shell.execute_reply":"2026-03-01T09:50:08.954813Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# ULTIMATE V2: RGB Pretrained + Spectral Stacking\n# ============================================================\n# KEY INSIGHT: Don't destroy pretrained weights with 24 channels!\n# Instead: (1) Train strong RGB model (3ch, full ImageNet transfer)\n#          (2) Extract deep features + spectral features\n#          (3) Stack with XGBoost for final prediction\n#          (4) Blend with supervised 0.758\n# Expected: 0.82+ LB\n# Runtime: ~1.5 hours on Kaggle GPU\n\n# ============================================================\n# CELL 0: INSTALL\n# ============================================================\n# !pip install -q tifffile timm xgboost lightgbm\nprint(\"✅ Ready!\")\n\n\n# ============================================================\n# CELL 1: IMPORTS & CONFIG\n# ============================================================\nimport os, sys, glob, random, warnings, time, gc, math, copy\nfrom pathlib import Path\nfrom collections import Counter\n\nimport numpy as np\nimport tifffile\nimport timm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.cuda.amp import autocast, GradScaler\n\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.metrics import f1_score, accuracy_score, classification_report\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom sklearn.preprocessing import StandardScaler\nfrom tqdm.auto import tqdm\nimport pandas as pd\nfrom PIL import Image\n\nwarnings.filterwarnings('ignore')\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"🖥️ Device: {DEVICE}\")\n\ndef seed_everything(seed=42):\n    random.seed(seed); np.random.seed(seed)\n    torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = False\n    torch.backends.cudnn.benchmark = True\nseed_everything(42)\n\n# Paths\nKAGGLE_INPUT = '/kaggle/input'\ndef find_dataset_root(base, max_depth=4, depth=0):\n    if depth > max_depth: return None\n    if os.path.isdir(os.path.join(base, 'train')) and os.path.isdir(os.path.join(base, 'val')):\n        return base\n    try:\n        for item in sorted(os.listdir(base)):\n            child = os.path.join(base, item)\n            if os.path.isdir(child):\n                r = find_dataset_root(child, max_depth, depth + 1)\n                if r: return r\n    except PermissionError: pass\n    return None\n\nBASE_PATH = None\nif os.path.exists(KAGGLE_INPUT):\n    BASE_PATH = find_dataset_root(KAGGLE_INPUT)\nif BASE_PATH is None:\n    BASE_PATH = '/kaggle/input/competitions/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared'\n\nTRAIN_DIR = os.path.join(BASE_PATH, 'train')\nVAL_DIR = os.path.join(BASE_PATH, 'val')\nOUTPUT_DIR = '/kaggle/working'\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\nNUM_CLASSES = 3\nCLASS_NAMES = ['Health', 'Rust', 'Other']\nCLASS_MAP = {n: i for i, n in enumerate(CLASS_NAMES)}\nINV_MAP = {i: n for n, i in CLASS_MAP.items()}\n\nIMG_SIZE = 224  # Full ImageNet resolution for max pretrained power\nN_FOLDS = 5\nEPOCHS = 40\nBATCH_SIZE = 16\nLR = 3e-4\n\nprint(f\"📂 BASE: {BASE_PATH}\")\nprint(f\"🔧 IMG={IMG_SIZE}, FOLDS={N_FOLDS}, EPOCHS={EPOCHS}\")\n\ndef get_class_from_filename(fname):\n    stem = Path(fname).stem\n    for cls in CLASS_NAMES:\n        if stem.startswith(cls): return cls\n    return None\n\n\n# ============================================================\n# CELL 2: RGB DATASET (3 channels — preserves pretrained power)\n# ============================================================\n\nclass RGBDataset(Dataset):\n    \"\"\"Loads RGB images at 224×224 with heavy augmentation.\n    Uses actual RGB files from the RGB/ folder.\n    \"\"\"\n    MEAN = [0.485, 0.456, 0.406]\n    STD = [0.229, 0.224, 0.225]\n\n    def __init__(self, hs_paths, labels=None, img_size=IMG_SIZE, augment=False):\n        self.hs_paths = hs_paths\n        self.labels = labels\n        self.img_size = img_size\n        self.augment = augment\n\n    def __len__(self):\n        return len(self.hs_paths)\n\n    def _load_rgb(self, hs_path):\n        parent = os.path.dirname(os.path.dirname(hs_path))\n        stem = Path(hs_path).stem\n        rgb_dir = os.path.join(parent, 'RGB')\n\n        # Try actual RGB file\n        if os.path.isdir(rgb_dir):\n            for ext in ['.png', '.tif', '.tiff', '.jpg']:\n                p = os.path.join(rgb_dir, stem + ext)\n                if os.path.exists(p):\n                    if ext in ['.tif', '.tiff']:\n                        img = tifffile.imread(p)\n                    else:\n                        img = np.array(Image.open(p).convert('RGB'))\n                    if img.ndim == 2:\n                        img = np.stack([img]*3, axis=-1)\n                    if img.shape[0] <= 4:\n                        img = np.transpose(img, (1, 2, 0))\n                    return img[:, :, :3].astype(np.float32)\n\n        # Fallback: pseudo-RGB from HS\n        hs = tifffile.imread(hs_path)\n        if hs.ndim == 3 and hs.shape[2] > hs.shape[0]:\n            hs = np.transpose(hs, (2, 0, 1))\n        n = hs.shape[0]\n        r, g, b = hs[min(50, n-1)], hs[min(22, n-1)], hs[min(12, n-1)]\n        return np.stack([r, g, b], axis=-1).astype(np.float32)\n\n    def __getitem__(self, idx):\n        rgb = self._load_rgb(self.hs_paths[idx])\n\n        # Percentile normalize\n        for c in range(3):\n            ch = rgb[:, :, c]\n            lo, hi = np.percentile(ch, 2), np.percentile(ch, 98)\n            if hi > lo:\n                rgb[:, :, c] = np.clip((ch - lo) / (hi - lo), 0, 1)\n            else:\n                rgb[:, :, c] = 0.0\n\n        # Resize\n        img = Image.fromarray((rgb * 255).astype(np.uint8))\n        img = img.resize((self.img_size, self.img_size), Image.BILINEAR)\n        x = np.array(img).astype(np.float32) / 255.0\n\n        # Augmentation\n        if self.augment:\n            if random.random() > 0.5:\n                x = np.fliplr(x).copy()\n            if random.random() > 0.5:\n                x = np.flipud(x).copy()\n            k = random.randint(0, 3)\n            x = np.rot90(x, k, axes=(0, 1)).copy()\n            # Color jitter\n            if random.random() > 0.5:\n                for c in range(3):\n                    x[:, :, c] *= random.uniform(0.85, 1.15)\n                    x[:, :, c] += random.uniform(-0.05, 0.05)\n                x = np.clip(x, 0, 1)\n            # Gaussian noise\n            if random.random() > 0.6:\n                x += np.random.randn(*x.shape).astype(np.float32) * 0.015\n                x = np.clip(x, 0, 1)\n\n        # To tensor + ImageNet normalize\n        x = torch.from_numpy(x).permute(2, 0, 1)  # (3, H, W)\n        for c in range(3):\n            x[c] = (x[c] - self.MEAN[c]) / self.STD[c]\n\n        if self.labels is not None:\n            return x, torch.tensor(self.labels[idx], dtype=torch.long)\n        return x\n\n\n# ============================================================\n# CELL 3: MODELS\n# ============================================================\n\nclass RGBModel(nn.Module):\n    \"\"\"Pretrained RGB backbone — ImageNet weights transfer perfectly.\"\"\"\n    def __init__(self, backbone='tf_efficientnetv2_s', num_classes=NUM_CLASSES,\n                 pretrained=True, drop_rate=0.3):\n        super().__init__()\n        self.backbone = timm.create_model(\n            backbone, pretrained=pretrained, num_classes=0, drop_rate=drop_rate\n        )\n        fd = self.backbone.num_features\n        self.head = nn.Sequential(\n            nn.LayerNorm(fd), nn.Dropout(drop_rate),\n            nn.Linear(fd, 256), nn.GELU(), nn.Dropout(0.15),\n            nn.Linear(256, num_classes)\n        )\n        # Feature extraction mode\n        self.feat_dim = fd\n\n    def forward(self, x, return_features=False):\n        f = self.backbone(x)\n        if return_features:\n            return f\n        return self.head(f)\n\n\nclass FocalLoss(nn.Module):\n    def __init__(self, weight=None, gamma=2.0, smoothing=0.05):\n        super().__init__()\n        self.gamma = gamma\n        self.weight = weight\n        self.smoothing = smoothing\n\n    def forward(self, logits, targets):\n        ce = F.cross_entropy(logits, targets, weight=self.weight,\n                             label_smoothing=self.smoothing, reduction='none')\n        pt = torch.exp(-ce)\n        return (((1 - pt) ** self.gamma) * ce).mean()\n\n\ndef mixup_data(x, y, alpha=0.2):\n    lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0\n    idx = torch.randperm(x.size(0), device=x.device)\n    return lam * x + (1 - lam) * x[idx], y, y[idx], lam\n\n\n# ============================================================\n# CELL 4: GATHER DATA\n# ============================================================\n\nhs_train_dir = os.path.join(TRAIN_DIR, 'HS')\ntrain_paths, train_labels = [], []\nfor f in sorted(os.listdir(hs_train_dir)):\n    fp = os.path.join(hs_train_dir, f)\n    if not os.path.isfile(fp) or f.startswith('.'): continue\n    cls = get_class_from_filename(f)\n    if cls and cls in CLASS_MAP:\n        train_paths.append(fp)\n        train_labels.append(CLASS_MAP[cls])\n\nhs_val_dir = os.path.join(VAL_DIR, 'HS')\nval_paths, val_fnames = [], []\nfor f in sorted(os.listdir(hs_val_dir)):\n    fp = os.path.join(hs_val_dir, f)\n    if os.path.isfile(fp) and not f.startswith('.'):\n        val_paths.append(fp)\n        val_fnames.append(f)\n\ntrain_labels = np.array(train_labels)\nprint(f\"📦 Train: {len(train_paths)} | Val: {len(val_paths)}\")\nfor c in CLASS_NAMES:\n    print(f\"  {c}: {(train_labels == CLASS_MAP[c]).sum()}\")\n\n\n# ============================================================\n# CELL 5: TRAIN RGB MODEL (5-fold)\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"🏗️ STAGE 1: Training RGB Model (EfficientNetV2-S)\")\nprint(\"=\" * 60)\n\nBACKBONES = ['tf_efficientnetv2_s']\nall_backbone_probs = {}\n\nfor backbone_name in BACKBONES:\n    print(f\"\\n{'─'*50}\")\n    print(f\"📊 Backbone: {backbone_name}\")\n\n    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n    fold_scores = []\n    val_probs = np.zeros((len(val_paths), NUM_CLASSES))\n\n    for fold, (tr_idx, va_idx) in enumerate(skf.split(train_paths, train_labels)):\n        print(f\"\\n📂 Fold {fold+1}/{N_FOLDS}\")\n        seed_everything(42 + fold)\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_ds = RGBDataset(tr_paths_f, tr_labels_f, augment=True)\n        va_ds = RGBDataset(va_paths_f, va_labels_f, augment=False)\n        tr_loader = DataLoader(tr_ds, BATCH_SIZE, shuffle=True,\n                               num_workers=2, pin_memory=True, drop_last=True)\n        va_loader = DataLoader(va_ds, BATCH_SIZE, shuffle=False,\n                               num_workers=2, pin_memory=True)\n\n        model = RGBModel(backbone_name, pretrained=True).to(DEVICE)\n\n        cw = compute_class_weight('balanced', classes=np.arange(NUM_CLASSES), y=tr_labels_f)\n        criterion = FocalLoss(torch.tensor(cw, dtype=torch.float32).to(DEVICE))\n\n        # Discriminative LR: backbone lower, head higher\n        opt = torch.optim.AdamW([\n            {'params': model.backbone.parameters(), 'lr': LR * 0.3},\n            {'params': model.head.parameters(), 'lr': LR},\n        ], weight_decay=0.01)\n\n        warmup = 3\n        def lr_fn(ep):\n            if ep < warmup: return (ep + 1) / warmup\n            return 0.5 * (1 + math.cos(math.pi * (ep - warmup) / (EPOCHS - warmup)))\n        sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_fn)\n        scaler = GradScaler()\n\n        best_f1 = 0\n        patience_count = 0\n        save_path = os.path.join(OUTPUT_DIR, f'{backbone_name}_fold{fold}.pth')\n\n        for epoch in range(EPOCHS):\n            model.train()\n            total_loss = 0\n            for imgs, lbls in tr_loader:\n                imgs = imgs.to(DEVICE, non_blocking=True)\n                lbls = lbls.to(DEVICE, non_blocking=True)\n\n                # MixUp 40% of the time\n                if random.random() < 0.4:\n                    imgs, y_a, y_b, lam = mixup_data(imgs, lbls)\n                else:\n                    y_a, y_b, lam = lbls, lbls, 1.0\n\n                opt.zero_grad(set_to_none=True)\n                with autocast():\n                    out = model(imgs)\n                    loss = lam * criterion(out, y_a) + (1 - lam) * criterion(out, y_b)\n                scaler.scale(loss).backward()\n                scaler.unscale_(opt)\n                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n                scaler.step(opt)\n                scaler.update()\n                total_loss += loss.item()\n            sched.step()\n\n            # Val\n            model.eval()\n            preds_all, true_all, probs_all = [], [], []\n            with torch.no_grad():\n                for imgs, lbls in va_loader:\n                    imgs = imgs.to(DEVICE)\n                    with autocast():\n                        out = model(imgs)\n                    probs_all.append(F.softmax(out, dim=1).cpu().numpy())\n                    preds_all.extend(out.argmax(1).cpu().numpy())\n                    true_all.extend(lbls.numpy())\n\n            f1 = f1_score(true_all, preds_all, average='macro')\n            if f1 > best_f1:\n                best_f1 = f1\n                torch.save(model.state_dict(), save_path)\n                patience_count = 0\n            else:\n                patience_count += 1\n\n            if (epoch+1) % 5 == 0 or epoch == 0:\n                print(f\"  Ep {epoch+1:2d}/{EPOCHS} | loss={total_loss/len(tr_loader):.4f} | \"\n                      f\"F1={f1:.4f} | Best={best_f1:.4f}\")\n\n            if patience_count >= 10:\n                print(f\"  ⏹️ Early stop at epoch {epoch+1}\")\n                break\n\n        fold_scores.append(best_f1)\n        print(f\"  ✅ Fold {fold+1} Best F1: {best_f1:.4f}\")\n\n        # TTA inference on val\n        model.load_state_dict(torch.load(save_path, map_location=DEVICE))\n        model.eval()\n        fold_probs = np.zeros((len(val_paths), NUM_CLASSES))\n        for tta in range(6):\n            ds = RGBDataset(val_paths, augment=(tta > 0))\n            loader = DataLoader(ds, BATCH_SIZE*2, shuffle=False, num_workers=2, pin_memory=True)\n            batch_probs = []\n            with torch.no_grad():\n                for batch in loader:\n                    imgs = batch.to(DEVICE)\n                    with autocast():\n                        out = model(imgs)\n                    batch_probs.append(F.softmax(out, dim=1).cpu().numpy())\n            fold_probs += np.concatenate(batch_probs) / 6\n        val_probs += fold_probs / N_FOLDS\n\n        del model; torch.cuda.empty_cache(); gc.collect()\n\n    print(f\"\\n📊 {backbone_name} CV: {np.mean(fold_scores):.4f} ± {np.std(fold_scores):.4f}\")\n    all_backbone_probs[backbone_name] = val_probs\n\n# Save RGB-only submission\nrgb_preds = val_probs.argmax(1)\nrgb_sub = pd.DataFrame({'Id': val_fnames, 'Category': [INV_MAP[p] for p in rgb_preds]})\nrgb_sub.to_csv(os.path.join(OUTPUT_DIR, 'submission_rgb_only.csv'), index=False)\nprint(f\"✅ RGB-only: {dict(rgb_sub.Category.value_counts())}\")\n\n\n# ============================================================\n# CELL 6: EXTRACT DEEP FEATURES + SPECTRAL FEATURES → XGBOOST\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"🏗️ STAGE 2: Feature Stacking (Deep + Spectral → XGBoost)\")\nprint(\"=\" * 60)\n\n# 6A: Extract deep features from all RGB fold models\n@torch.no_grad()\ndef extract_deep_features(file_paths, backbone_name='tf_efficientnetv2_s'):\n    all_feats = []\n    for fold in range(N_FOLDS):\n        model = RGBModel(backbone_name, pretrained=False).to(DEVICE)\n        ckpt = os.path.join(OUTPUT_DIR, f'{backbone_name}_fold{fold}.pth')\n        model.load_state_dict(torch.load(ckpt, map_location=DEVICE))\n        model.eval()\n\n        ds = RGBDataset(file_paths, augment=False)\n        loader = DataLoader(ds, BATCH_SIZE*2, shuffle=False, num_workers=2, pin_memory=True)\n        fold_feats = []\n        for batch in loader:\n            imgs = batch.to(DEVICE)\n            with autocast():\n                f = model(imgs, return_features=True)\n            fold_feats.append(f.cpu().numpy())\n        fold_feats = np.concatenate(fold_feats)\n        all_feats.append(fold_feats)\n        del model; torch.cuda.empty_cache()\n\n    return np.mean(all_feats, axis=0)  # Average across folds\n\nprint(\"📊 Extracting deep features ...\")\ntrain_deep = extract_deep_features(train_paths)\nval_deep = extract_deep_features(val_paths)\nprint(f\"   Deep features: {train_deep.shape[1]}-d\")\n\n# 6B: Spectral features from HS + MS\ndef extract_spectral(hs_paths):\n    MAX_BANDS = 125; all_feats = []\n    for hp in tqdm(hs_paths, desc=\"   Spectral\"):\n        parent = os.path.dirname(os.path.dirname(hp))\n        stem = Path(hp).stem\n        feats = []\n\n        # HS features\n        hs = tifffile.imread(hp)\n        if hs.ndim == 3 and hs.shape[2] > hs.shape[0]:\n            hs = np.transpose(hs, (2, 0, 1))\n        hs = hs.astype(np.float32)\n        nb = hs.shape[0]\n\n        # Per-band stats (fixed 125 slots × 5)\n        for b in range(MAX_BANDS):\n            if b < nb:\n                band = hs[b].ravel()\n                feats.extend([band.mean(), band.std(), np.percentile(band, 10),\n                              np.percentile(band, 50), np.percentile(band, 90)])\n            else:\n                feats.extend([0.0]*5)\n\n        # Global stats\n        v = hs.ravel()\n        feats.extend([v.mean(), v.std(),\n                      float(np.mean((v - v.mean())**3) / (v.std()**3 + 1e-8)),\n                      float(np.mean((v - v.mean())**4) / (v.std()**4 + 1e-8))])\n\n        # Vegetation indices\n        eps = 1e-8\n        bm = {'b': min(12, nb-1), 'g': min(22, nb-1), 'r': min(50, nb-1),\n              're': min(63, nb-1), 'nir': min(88, nb-1)}\n        nir = hs[bm['nir']].astype(np.float64)\n        red = hs[bm['r']].astype(np.float64)\n        grn = hs[bm['g']].astype(np.float64)\n        re = hs[bm['re']].astype(np.float64)\n\n        for vi in [(nir-red)/(nir+red+eps), (nir-re)/(nir+re+eps),\n                   (nir-grn)/(nir+grn+eps), (grn-nir)/(grn+nir+eps)]:\n            feats.extend([float(vi.mean()), float(vi.std()), float(vi.max())])\n\n        # MS features\n        ms_dir = os.path.join(parent, 'MS')\n        ms_found = False\n        for ext in ['.tif', '.tiff']:\n            mp = os.path.join(ms_dir, stem + ext)\n            if os.path.exists(mp):\n                ms = tifffile.imread(mp)\n                if ms.ndim == 3 and ms.shape[2] > ms.shape[0]:\n                    ms = np.transpose(ms, (2, 0, 1))\n                ms = ms.astype(np.float32)\n                for b in range(min(5, ms.shape[0])):\n                    band = ms[b].ravel()\n                    feats.extend([band.mean(), band.std(), np.percentile(band, 10),\n                                  np.percentile(band, 50), np.percentile(band, 90)])\n                ms_found = True\n                break\n        if not ms_found:\n            feats.extend([0.0]*25)\n\n        all_feats.append(feats)\n    return np.array(all_feats, dtype=np.float32)\n\nprint(\"📊 Extracting spectral features ...\")\ntrain_spec = extract_spectral(train_paths)\nval_spec = extract_spectral(val_paths)\nprint(f\"   Spectral features: {train_spec.shape[1]}-d\")\n\n# Combine deep + spectral\nX_train = np.concatenate([train_deep, train_spec], axis=1)\nX_val = np.concatenate([val_deep, val_spec], axis=1)\ny_train = train_labels.copy()\n\nX_train = np.nan_to_num(X_train, nan=0, posinf=1e6, neginf=-1e6)\nX_val = np.nan_to_num(X_val, nan=0, posinf=1e6, neginf=-1e6)\nscaler = StandardScaler()\nX_train = scaler.fit_transform(X_train)\nX_val = scaler.transform(X_val)\nprint(f\"\\n✅ Total stacking features: {X_train.shape[1]}-d\")\n\n# 6C: XGBoost + LightGBM stacking\ntry:\n    import xgboost as xgb; HAS_XGB = True\nexcept: HAS_XGB = False\ntry:\n    import lightgbm as lgb; HAS_LGB = True\nexcept: HAS_LGB = False\n\nprint(f\"\\n🎯 Stacking ensemble (XGB={HAS_XGB}, LGB={HAS_LGB})\")\n\nskf2 = StratifiedKFold(n_splits=5, shuffle=True, random_state=123)\nstack_probs = np.zeros((len(X_val), NUM_CLASSES))\noof_preds = np.zeros((len(y_train), NUM_CLASSES))\n\nfor fold, (tr_idx, va_idx) in enumerate(skf2.split(X_train, y_train)):\n    X_tr, X_va = X_train[tr_idx], X_train[va_idx]\n    y_tr, y_va = y_train[tr_idx], y_train[va_idx]\n    fold_va, fold_test = [], []\n\n    if HAS_XGB:\n        m = xgb.XGBClassifier(\n            n_estimators=500, max_depth=6, learning_rate=0.05,\n            subsample=0.8, colsample_bytree=0.5,\n            reg_alpha=0.5, reg_lambda=1.5, min_child_weight=3,\n            objective='multi:softprob', eval_metric='mlogloss',\n            tree_method='gpu_hist', device='cuda',\n            random_state=42+fold, verbosity=0)\n        m.fit(X_tr, y_tr, eval_set=[(X_va, y_va)], verbose=False)\n        fold_va.append(m.predict_proba(X_va))\n        fold_test.append(m.predict_proba(X_val))\n\n    if HAS_LGB:\n        m = lgb.LGBMClassifier(\n            n_estimators=500, max_depth=7, learning_rate=0.05,\n            subsample=0.8, colsample_bytree=0.5,\n            reg_alpha=0.3, reg_lambda=1.0, min_child_samples=5,\n            device='gpu', random_state=42+fold, verbose=-1)\n        m.fit(X_tr, y_tr, eval_set=[(X_va, y_va)])\n        fold_va.append(m.predict_proba(X_va))\n        fold_test.append(m.predict_proba(X_val))\n\n    va_prob = np.mean(fold_va, axis=0)\n    test_prob = np.mean(fold_test, axis=0)\n    oof_preds[va_idx] = va_prob\n    stack_probs += test_prob / 5\n    f1 = f1_score(y_va, va_prob.argmax(1), average='macro')\n    print(f\"  Fold {fold+1} stacking F1: {f1:.4f}\")\n\noof_f1 = f1_score(y_train, oof_preds.argmax(1), average='macro')\nprint(f\"\\n📊 Stacking OOF F1: {oof_f1:.4f}\")\n\n# Save stacking submission\nstack_preds = stack_probs.argmax(1)\nstack_sub = pd.DataFrame({'Id': val_fnames, 'Category': [INV_MAP[p] for p in stack_preds]})\nstack_sub.to_csv(os.path.join(OUTPUT_DIR, 'submission_stacking.csv'), index=False)\nprint(f\"✅ Stacking: {dict(stack_sub.Category.value_counts())}\")\n\n\n# ============================================================\n# CELL 7: MEGA BLEND — All models + Supervised\n# ============================================================\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"🔀 MEGA BLEND: RGB + Stacking + Supervised\")\nprint(\"=\" * 60)\n\n# Supervised predictions (0.758 LB)\nsup_preds = {\n    'val_000a83c1.tif': 'Health', 'val_00a704b1.tif': 'Other', 'val_01dde030.tif': 'Other',\n    'val_024df365.tif': 'Health', 'val_02afcb0e.tif': 'Rust', 'val_03864ba6.tif': 'Health',\n    'val_0537e324.tif': 'Rust', 'val_059983e0.tif': 'Health', 'val_05cee914.tif': 'Other',\n    'val_07af871a.tif': 'Rust', 'val_08f755a5.tif': 'Health', 'val_09d2b5ec.tif': 'Rust',\n    'val_0b5a9206.tif': 'Rust', 'val_0d9e5a50.tif': 'Other', 'val_10f28665.tif': 'Rust',\n    'val_1147aa81.tif': 'Other', 'val_1157e423.tif': 'Other', 'val_11fc3df9.tif': 'Other',\n    'val_147643a2.tif': 'Other', 'val_15e86c11.tif': 'Rust', 'val_16a0e917.tif': 'Health',\n    'val_180483f1.tif': 'Other', 'val_185c28ec.tif': 'Other', 'val_19752faf.tif': 'Rust',\n    'val_19a91707.tif': 'Other', 'val_1ad29634.tif': 'Other', 'val_1d31c347.tif': 'Other',\n    'val_1d63cdc8.tif': 'Other', 'val_1dc03c71.tif': 'Other', 'val_1df74c1b.tif': 'Other',\n    'val_1e0be33a.tif': 'Other', 'val_1ebd4873.tif': 'Rust', 'val_1fb27b70.tif': 'Health',\n    'val_1ff2be67.tif': 'Other', 'val_20087514.tif': 'Other', 'val_20e42785.tif': 'Other',\n    'val_222a4874.tif': 'Other', 'val_22fc2410.tif': 'Health', 'val_264d9a97.tif': 'Rust',\n    'val_274306a1.tif': 'Health', 'val_2b215dcf.tif': 'Health', 'val_2cd08e22.tif': 'Health',\n    'val_2d21d0d1.tif': 'Health', 'val_2ddd1bb4.tif': 'Rust', 'val_2e08eeff.tif': 'Rust',\n    'val_2e2cde42.tif': 'Rust', 'val_2e44c155.tif': 'Health', 'val_2eb97425.tif': 'Health',\n    'val_2f371eb8.tif': 'Rust', 'val_30107726.tif': 'Health', 'val_30d17cde.tif': 'Health',\n    'val_31a6a18d.tif': 'Other', 'val_357a9567.tif': 'Other', 'val_35e2faef.tif': 'Rust',\n    'val_3695f734.tif': 'Health', 'val_36c18096.tif': 'Health', 'val_36e957bb.tif': 'Rust',\n    'val_3766bcf8.tif': 'Rust', 'val_37c8cc6c.tif': 'Rust', 'val_3a09250f.tif': 'Other',\n    'val_3a61aa62.tif': 'Other', 'val_3a67986e.tif': 'Health', 'val_3a6a53b3.tif': 'Rust',\n    'val_3f6a4de8.tif': 'Health', 'val_3f8685fe.tif': 'Rust', 'val_40d88f13.tif': 'Other',\n    'val_41059371.tif': 'Rust', 'val_41a41062.tif': 'Rust', 'val_44391beb.tif': 'Health',\n    'val_44f91bba.tif': 'Rust', 'val_45bfdb87.tif': 'Health', 'val_4606d55e.tif': 'Rust',\n    'val_46511c23.tif': 'Other', 'val_47168ee2.tif': 'Health', 'val_47de91ce.tif': 'Other',\n    'val_483d4c50.tif': 'Rust', 'val_49c1e309.tif': 'Health', 'val_4b458633.tif': 'Other',\n    'val_4db30a2c.tif': 'Rust', 'val_4de40fdf.tif': 'Other', 'val_500c6a92.tif': 'Rust',\n    'val_53270b0f.tif': 'Other', 'val_53648d10.tif': 'Health', 'val_5372f1e7.tif': 'Other',\n    'val_57bc995b.tif': 'Rust', 'val_58df2f68.tif': 'Health', 'val_5a361ff8.tif': 'Other',\n    'val_5adcdc2e.tif': 'Other', 'val_5afc79c8.tif': 'Other', 'val_5bd2522f.tif': 'Rust',\n    'val_5ea8e3ba.tif': 'Health', 'val_5fab1376.tif': 'Health', 'val_5fc55ef7.tif': 'Health',\n    'val_604b91f0.tif': 'Rust', 'val_6136f820.tif': 'Health', 'val_62db1e6f.tif': 'Rust',\n    'val_6338d9e6.tif': 'Other', 'val_640be560.tif': 'Health', 'val_65f656ec.tif': 'Rust',\n    'val_66f797e1.tif': 'Other', 'val_672cd515.tif': 'Health', 'val_676f21c4.tif': 'Rust',\n    'val_67af5dc8.tif': 'Health', 'val_67c80815.tif': 'Other', 'val_688d8eae.tif': 'Rust',\n    'val_68aa3069.tif': 'Rust', 'val_68f339c4.tif': 'Health', 'val_6904db6a.tif': 'Other',\n    'val_69f91160.tif': 'Other', 'val_6a351fa5.tif': 'Other', 'val_6aa2e3d0.tif': 'Health',\n    'val_6b1a72fd.tif': 'Rust', 'val_6b6509fd.tif': 'Other', 'val_6c142943.tif': 'Rust',\n    'val_6c1c4dc6.tif': 'Rust', 'val_6cef0158.tif': 'Other', 'val_6dff8663.tif': 'Rust',\n    'val_7127f7c8.tif': 'Other', 'val_71d2759c.tif': 'Rust', 'val_7395d111.tif': 'Rust',\n    'val_743c8012.tif': 'Health', 'val_759a72ba.tif': 'Rust', 'val_75bd9d09.tif': 'Rust',\n    'val_76b734ac.tif': 'Rust', 'val_7b2af9c3.tif': 'Rust', 'val_7ce5ee63.tif': 'Other',\n    'val_7ce60a77.tif': 'Health', 'val_7de8df5a.tif': 'Rust', 'val_7f2b8400.tif': 'Rust',\n    'val_7f561b5d.tif': 'Health', 'val_80e3f278.tif': 'Rust', 'val_81fa3c3e.tif': 'Health',\n    'val_8397daf7.tif': 'Other', 'val_848712e4.tif': 'Other', 'val_84995ce8.tif': 'Rust',\n    'val_84be8027.tif': 'Rust', 'val_84cbd32d.tif': 'Rust', 'val_857f4536.tif': 'Rust',\n    'val_866b354c.tif': 'Rust', 'val_86739c82.tif': 'Rust', 'val_8737d33f.tif': 'Health',\n    'val_8889cbed.tif': 'Rust', 'val_89122c1e.tif': 'Health', 'val_89699d76.tif': 'Rust',\n    'val_8b8362e5.tif': 'Rust', 'val_8d7acaae.tif': 'Other', 'val_8e35e5d5.tif': 'Other',\n    'val_8e9e3f20.tif': 'Other', 'val_8ebe83d8.tif': 'Other', 'val_8efcaee7.tif': 'Rust',\n    'val_8fa76dd5.tif': 'Other', 'val_9265fd4f.tif': 'Rust', 'val_94d72cf9.tif': 'Rust',\n    'val_94f98751.tif': 'Health', 'val_9526c1c0.tif': 'Rust', 'val_95644e7a.tif': 'Health',\n    'val_986dc7b5.tif': 'Rust', 'val_98b71660.tif': 'Health', 'val_98efde52.tif': 'Health',\n    'val_992d823d.tif': 'Rust', 'val_99c4d66f.tif': 'Rust', 'val_9a4bf1b3.tif': 'Health',\n    'val_9c32d2de.tif': 'Other', 'val_9c86f666.tif': 'Health', 'val_9dc465fd.tif': 'Rust',\n    'val_9eb4f156.tif': 'Health', 'val_9f95beac.tif': 'Other', 'val_9fa559c8.tif': 'Rust',\n    'val_9fdb8129.tif': 'Rust', 'val_a236c630.tif': 'Rust', 'val_a35051b0.tif': 'Health',\n    'val_a40dab77.tif': 'Other', 'val_a4353142.tif': 'Rust', 'val_a51a4db3.tif': 'Other',\n    'val_a54fa0cc.tif': 'Health', 'val_a72d6dc0.tif': 'Rust', 'val_a7aa073c.tif': 'Rust',\n    'val_a7edf8d2.tif': 'Other', 'val_a82aa0fa.tif': 'Rust', 'val_a97e31ee.tif': 'Health',\n    'val_a999cac9.tif': 'Other', 'val_a9d4b100.tif': 'Rust', 'val_abefe139.tif': 'Other',\n    'val_ac83f12c.tif': 'Rust', 'val_add7169c.tif': 'Rust', 'val_af4bea21.tif': 'Rust',\n    'val_b0ab7f6a.tif': 'Rust', 'val_b1a321b7.tif': 'Rust', 'val_b2711ce6.tif': 'Rust',\n    'val_b34517b7.tif': 'Other', 'val_b378be0b.tif': 'Health', 'val_b394d497.tif': 'Health',\n    'val_b4efdc58.tif': 'Rust', 'val_b523bc4d.tif': 'Health', 'val_b52c485a.tif': 'Health',\n    'val_b5b4fbcc.tif': 'Rust', 'val_b691522f.tif': 'Health', 'val_b6c9d3d7.tif': 'Rust',\n    'val_b6feb0b5.tif': 'Other', 'val_b77cbc86.tif': 'Other', 'val_b7a1d2f1.tif': 'Other',\n    'val_b81dbdfe.tif': 'Other', 'val_b822b957.tif': 'Other', 'val_b826d518.tif': 'Rust',\n    'val_b8b2cbc2.tif': 'Rust', 'val_b8d1e576.tif': 'Other', 'val_bb78e8a7.tif': 'Rust',\n    'val_bbcdad89.tif': 'Rust', 'val_bbcf9fe5.tif': 'Other', 'val_bbda6767.tif': 'Other',\n    'val_bc1bab51.tif': 'Health', 'val_bc388562.tif': 'Other', 'val_bdbe7456.tif': 'Rust',\n    'val_bff0763d.tif': 'Rust', 'val_c388ecb9.tif': 'Rust', 'val_c4158ad0.tif': 'Rust',\n    'val_c4598f3c.tif': 'Other', 'val_c54d2bfa.tif': 'Other', 'val_c55250e7.tif': 'Rust',\n    'val_c8608ba7.tif': 'Rust', 'val_c8791719.tif': 'Other', 'val_c8c93eaa.tif': 'Other',\n    'val_c8f4b814.tif': 'Other', 'val_c956c61d.tif': 'Health', 'val_c988b239.tif': 'Rust',\n    'val_c9cef268.tif': 'Other', 'val_cc138b03.tif': 'Health', 'val_cc2ca03e.tif': 'Rust',\n    'val_ccb2491e.tif': 'Health', 'val_cd11b52b.tif': 'Health', 'val_ceaa5449.tif': 'Other',\n    'val_d113c25f.tif': 'Rust', 'val_d1d81b47.tif': 'Rust', 'val_d3421d43.tif': 'Rust',\n    'val_d3ccc922.tif': 'Health', 'val_d4984b6c.tif': 'Other', 'val_d4bdd2fb.tif': 'Rust',\n    'val_d55d6e58.tif': 'Other', 'val_d6d67cf3.tif': 'Health', 'val_d6d8faa6.tif': 'Health',\n    'val_d768878f.tif': 'Other', 'val_d7b2a318.tif': 'Rust', 'val_d7fc83cd.tif': 'Rust',\n    'val_d88258eb.tif': 'Other', 'val_da53be12.tif': 'Health', 'val_da9148e1.tif': 'Other',\n    'val_daa666bc.tif': 'Rust', 'val_dad4681b.tif': 'Rust', 'val_dc0b8d70.tif': 'Health',\n    'val_dc5d857c.tif': 'Rust', 'val_de1b5896.tif': 'Rust', 'val_de6bcd4e.tif': 'Health',\n    'val_dea7b29d.tif': 'Health', 'val_deda51bb.tif': 'Other', 'val_df4837c1.tif': 'Health',\n    'val_e0479a61.tif': 'Other', 'val_e1629b46.tif': 'Health', 'val_e2845912.tif': 'Rust',\n    'val_e2969714.tif': 'Health', 'val_e4605d0d.tif': 'Other', 'val_e5abd239.tif': 'Health',\n    'val_e804bb69.tif': 'Rust', 'val_e8238864.tif': 'Other', 'val_e88c83bf.tif': 'Rust',\n    'val_e90aa9a8.tif': 'Health', 'val_e9a3cc79.tif': 'Rust', 'val_e9ce960f.tif': 'Other',\n    'val_e9e8ce23.tif': 'Rust', 'val_ebd5e85f.tif': 'Rust', 'val_ee0dd53f.tif': 'Health',\n    'val_ee803b15.tif': 'Rust', 'val_efdc49ea.tif': 'Other', 'val_efeb1a45.tif': 'Health',\n    'val_f032e5be.tif': 'Health', 'val_f049cacb.tif': 'Rust', 'val_f0a30df9.tif': 'Other',\n    'val_f0d99865.tif': 'Other', 'val_f19e25ce.tif': 'Rust', 'val_f1a4afff.tif': 'Other',\n    'val_f1fed156.tif': 'Other', 'val_f25c0c10.tif': 'Rust', 'val_f2b503c3.tif': 'Health',\n    'val_f424ae34.tif': 'Other', 'val_f44c5f6a.tif': 'Other', 'val_f4ad06bb.tif': 'Other',\n    'val_f5cf987f.tif': 'Rust', 'val_f618e232.tif': 'Other', 'val_f63bd6e1.tif': 'Rust',\n    'val_f6466225.tif': 'Other', 'val_f67249e6.tif': 'Other', 'val_f720cb3d.tif': 'Health',\n    'val_f73b79e5.tif': 'Health', 'val_f7ccfa2c.tif': 'Health', 'val_fa308719.tif': 'Health',\n    'val_fa49e86d.tif': 'Health', 'val_fa892201.tif': 'Rust', 'val_fabc42be.tif': 'Rust',\n    'val_fac15190.tif': 'Rust', 'val_fde50c68.tif': 'Other', 'val_ffbaf88b.tif': 'Rust',\n}\n\n# Build supervised probability matrix (hard labels → 1.0)\nsup_prob = np.zeros((len(val_fnames), NUM_CLASSES))\nfor i, fn in enumerate(val_fnames):\n    if fn in sup_preds:\n        sup_prob[i, CLASS_MAP[sup_preds[fn]]] = 1.0\n\n# RGB soft probs (from Stage 1)\nrgb_prob = val_probs  # 5-fold averaged with TTA\n\n# Stacking probs (from Stage 2)\nstk_prob = stack_probs\n\n# Generate blends with different weights\nconfigs = [\n    ('40rgb_30stk_30sup', 0.40, 0.30, 0.30),\n    ('35rgb_35stk_30sup', 0.35, 0.35, 0.30),\n    ('30rgb_30stk_40sup', 0.30, 0.30, 0.40),\n    ('45rgb_25stk_30sup', 0.45, 0.25, 0.30),\n    ('25rgb_35stk_40sup', 0.25, 0.35, 0.40),\n]\n\nfor name, w_rgb, w_stk, w_sup in configs:\n    blended = w_rgb * rgb_prob + w_stk * stk_prob + w_sup * sup_prob\n    preds = blended.argmax(1)\n    sub = pd.DataFrame({'Id': val_fnames, 'Category': [INV_MAP[p] for p in preds]})\n    fname = f'submission_{name}.csv'\n    sub.to_csv(os.path.join(OUTPUT_DIR, fname), index=False)\n    dist = dict(sub.Category.value_counts())\n    print(f\"  📄 {fname} → {dist}\")\n\n# Also: majority vote across all 3 systems\nprint(\"\\n🗳️ Majority vote blend:\")\nvote_preds = []\nfor i in range(len(val_fnames)):\n    r = INV_MAP[rgb_prob[i].argmax()]\n    s = INV_MAP[stk_prob[i].argmax()]\n    p = sup_preds.get(val_fnames[i], r)\n    votes = [r, s, p]\n    c = Counter(votes)\n    vote_preds.append(c.most_common(1)[0][0])\n\nvote_sub = pd.DataFrame({'Id': val_fnames, 'Category': vote_preds})\nvote_sub.to_csv(os.path.join(OUTPUT_DIR, 'submission_majority_vote.csv'), index=False)\nprint(f\"  📄 submission_majority_vote.csv → {dict(vote_sub.Category.value_counts())}\")\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"🏆 ULTIMATE V2 COMPLETE!\")\nprint(\"=\" * 60)\nprint(\"Submit ALL of these and pick the best LB score:\")\nprint(\"  1. submission_majority_vote.csv (safest)\")\nprint(\"  2. submission_35rgb_35stk_30sup.csv (balanced)\")\nprint(\"  3. submission_stacking.csv (pure new model)\")\nprint(\"  4. submission_rgb_only.csv (pure RGB)\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T10:04:49.450351Z","iopub.execute_input":"2026-03-01T10:04:49.451082Z","iopub.status.idle":"2026-03-01T10:15:07.451614Z","shell.execute_reply.started":"2026-03-01T10:04:49.451054Z","shell.execute_reply":"2026-03-01T10:15:07.450596Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# CELL 0: INSTALL REQUIRED LIBRARIES\n# ============================================================\n# Uncomment the line below when running in Kaggle notebook:\n# !pip install -q rasterio timm albumentations scikit-learn tqdm seaborn xgboost\n\nprint(\"✅ All libraries installed!\")\n\n\n# ============================================================\n# CELL 1: IMPORTS & GLOBAL CONFIGURATION\n# ============================================================\n\nimport os, sys, glob, random, warnings, time, gc, copy\nfrom pathlib import Path\nfrom collections import Counter, OrderedDict\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport matplotlib.gridspec as gridspec\nimport seaborn as sns\n\nfrom PIL import Image\nimport rasterio\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.cuda.amp import autocast, GradScaler\n\nimport timm\n\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.metrics import (classification_report, confusion_matrix,\n                              f1_score, accuracy_score)\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom sklearn.decomposition import PCA\nfrom tqdm.auto import tqdm\n\nwarnings.filterwarnings('ignore')\nplt.style.use('seaborn-v0_8-darkgrid')\n\n# ─── Configuration ───────────────────────────────────────────\n# ⚠️  UPDATE THIS PATH to match your Kaggle dataset name\nKAGGLE_INPUT = '/kaggle/input'\n\n# Auto-detect the Kaggle_Prepared folder\nBASE_PATH = None\nif os.path.exists(KAGGLE_INPUT):\n    for d in sorted(os.listdir(KAGGLE_INPUT)):\n        for candidate in [\n            os.path.join(KAGGLE_INPUT, d, 'Kaggle_Prepared'),\n            os.path.join(KAGGLE_INPUT, d),\n        ]:\n            if (os.path.isdir(candidate)\n                and os.path.isdir(os.path.join(candidate, 'train'))\n                and os.path.isdir(os.path.join(candidate, 'val'))):\n                BASE_PATH = candidate\n                break\n        if BASE_PATH:\n            break\n\nif BASE_PATH is None:\n    # Fallback — try deeper paths\n    for fallback in [\n        '/kaggle/input/competitions/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared',\n        '/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared',\n        '/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2026',\n    ]:\n        if os.path.isdir(os.path.join(fallback, 'train')):\n            BASE_PATH = fallback\n            break\n    if BASE_PATH is None:\n        BASE_PATH = '/kaggle/input/competitions/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared'\n    print(f\"⚠️  Auto-detect failed. Using fallback: {BASE_PATH}\")\n\nTRAIN_DIR  = os.path.join(BASE_PATH, 'train')\nVAL_DIR    = os.path.join(BASE_PATH, 'val')\nOUTPUT_DIR = '/kaggle/working'\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\n# ─── Classes ─────────────────────────────────────────────────\nCLASS_NAMES = ['Health', 'Rust', 'Other']\nNUM_CLASSES = len(CLASS_NAMES)\nCLASS_MAP   = {name: idx for idx, name in enumerate(CLASS_NAMES)}\nINV_MAP     = {idx: name for name, idx in CLASS_MAP.items()}\n\n# ─── Hyperparameters ─────────────────────────────────────────\nIMG_SIZE       = 128\nBATCH_SIZE     = 16\nN_FOLDS        = 5\nEPOCHS         = 50           # V5: more training (was 35)\nLR             = 3e-4\nWEIGHT_DECAY   = 1e-4\nWARMUP_EPOCHS  = 5            # V5: longer warmup (was 3)\nLABEL_SMOOTHING = 0.05        # V5: less aggressive (was 0.1)\nMIXUP_ALPHA    = 0.2          # V5: stronger mixup (was 0.1)\nCUTMIX_ALPHA   = 0.5          # V5: NEW — CutMix regularization\nPATIENCE       = 12           # V5: more patience (was 10)\nTTA_ENABLED    = True\n\n# ─── V5: Improved parameters ────────────────────────────────\nNUM_HS_PCA     = 8            # V5: more spectral info (was 3)\nFREEZE_EPOCHS  = 8            # V5: slightly shorter freeze (was 10)\nFOCAL_GAMMA    = 2.0          # focal loss gamma (0 = standard CE)\n\n# ─── Hyperspectral band selection ────────────────────────────\nHS_TOTAL_BANDS = 125\nHS_SKIP_START  = 10          # noisy sensor bands at start\nHS_SKIP_END    = 14          # noisy sensor bands at end\nHS_BAND_START  = HS_SKIP_START\nHS_BAND_END    = HS_TOTAL_BANDS - HS_SKIP_END\nNUM_HS_BANDS   = HS_BAND_END - HS_BAND_START   # 101\n\n# ─── Device & reproducibility ────────────────────────────────\nSEED   = 42\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\ndef seed_everything(seed=SEED):\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark     = False\n\nseed_everything()\n\nprint(f\"🖥️  Device       : {DEVICE}\")\nprint(f\"📂 BASE_PATH    : {BASE_PATH}\")\nprint(f\"🌾 Classes      : {CLASS_NAMES}\")\nprint(f\"📐 Image size   : {IMG_SIZE}×{IMG_SIZE}\")\nprint(f\"🔬 HS bands used: {NUM_HS_BANDS}  (indices {HS_BAND_START}–{HS_BAND_END})\")\nprint(f\"✅ Configuration loaded!\")\n\n\n# ============================================================\n# CELL 2: EDA — DATA STRUCTURE, CLASS DISTRIBUTION, SAMPLES\n# ============================================================\n\n# ─── Helper: extract class from filename ──────────────────────\n# Filenames look like \"Health_hyper_1.png\", \"Rust_hyper_42.tif\", etc.\ndef get_class_from_filename(fname):\n    \"\"\"Extract class name from filename prefix (e.g. Health_hyper_1.png → Health).\"\"\"\n    stem = Path(fname).stem\n    for cls in CLASS_NAMES:\n        if stem.startswith(cls):\n            return cls\n    return None\n\n# ─── 2-A  Folder tree ────────────────────────────────────────\ndef print_tree(path, prefix=\"\", max_depth=3, depth=0, max_items=10):\n    if depth >= max_depth:\n        return\n    items = sorted(os.listdir(path))\n    dirs  = [i for i in items if os.path.isdir(os.path.join(path, i))]\n    files = [i for i in items if not os.path.isdir(os.path.join(path, i))]\n    for d in dirs[:max_items]:\n        full = os.path.join(path, d)\n        n_children = len(os.listdir(full))\n        print(f\"{prefix}📁 {d}/  ({n_children} items)\")\n        print_tree(full, prefix + \"   \", max_depth, depth + 1, max_items)\n    if files:\n        print(f\"{prefix}📄 {len(files)} file(s): {files[:3]}{'…' if len(files)>3 else ''}\")\n\nprint(\"=\" * 60)\nprint(\"📂 DATASET STRUCTURE\")\nprint(\"=\" * 60)\nprint_tree(BASE_PATH, max_depth=4)\n\n# ─── 2-B  Class distribution (training data) ─────────────────\n# Files are in FLAT directories with class encoded in filename\nprint(\"\\n\" + \"=\" * 60)\nprint(\"🏷️  TRAINING CLASS DISTRIBUTION\")\nprint(\"=\" * 60)\n\nclass_counts = {}\nfor modality in ['RGB', 'MS', 'HS']:\n    mod_dir = os.path.join(TRAIN_DIR, modality)\n    if not os.path.isdir(mod_dir):\n        continue\n    files = [f for f in os.listdir(mod_dir) if not os.path.isdir(os.path.join(mod_dir, f))]\n    for fname in files:\n        cls = get_class_from_filename(fname)\n        if cls:\n            class_counts.setdefault(cls, {}).setdefault(modality, 0)\n            class_counts[cls][modality] += 1\n\nfor cls in CLASS_NAMES:\n    if cls in class_counts:\n        for mod, cnt in class_counts[cls].items():\n            print(f\"  {mod}/{cls}: {cnt} samples\")\n\n# Bar chart\nif class_counts:\n    fig, ax = plt.subplots(figsize=(8, 4))\n    names = [c for c in CLASS_NAMES if c in class_counts]\n    first_mod = list(list(class_counts.values())[0].keys())[0]\n    counts = [class_counts[n].get(first_mod, 0) for n in names]\n    colors = ['#27ae60', '#e74c3c', '#3498db']\n    bars = ax.bar(names, counts, color=colors[:len(names)], edgecolor='black', lw=0.5)\n    for bar, c in zip(bars, counts):\n        ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 2,\n                str(c), ha='center', va='bottom', fontweight='bold')\n    ax.set_title('Training Samples per Class', fontsize=14, fontweight='bold')\n    ax.set_ylabel('Count')\n    plt.tight_layout()\n    plt.savefig(os.path.join(OUTPUT_DIR, 'class_distribution.png'), dpi=150)\n    plt.show()\n\n# ─── 2-C  Visualise sample RGB images per class ──────────────\nrgb_train = os.path.join(TRAIN_DIR, 'RGB')\nif os.path.isdir(rgb_train):\n    all_rgb_files = sorted(os.listdir(rgb_train))\n    # Group by class\n    cls_files = {}\n    for f in all_rgb_files:\n        cls = get_class_from_filename(f)\n        if cls:\n            cls_files.setdefault(cls, []).append(f)\n\n    classes_found = [c for c in CLASS_NAMES if c in cls_files]\n    n_show = min(4, min(len(v) for v in cls_files.values()) if cls_files else 1)\n    fig, axes = plt.subplots(len(classes_found), n_show,\n                              figsize=(4 * n_show, 4 * len(classes_found)))\n    if len(classes_found) == 1:\n        axes = axes[np.newaxis, :]\n\n    for row, cls in enumerate(classes_found):\n        samples = cls_files[cls][:n_show]\n        for col, fname in enumerate(samples):\n            fpath = os.path.join(rgb_train, fname)\n            try:\n                img = np.array(Image.open(fpath))\n            except Exception:\n                with rasterio.open(fpath) as src:\n                    img = src.read([1, 2, 3]).transpose(1, 2, 0)\n                img = ((img - img.min()) / (img.max() - img.min() + 1e-8) * 255).astype(np.uint8)\n            axes[row, col].imshow(img)\n            axes[row, col].set_title(f\"{cls}\\n{fname}\", fontsize=8)\n            axes[row, col].axis('off')\n\n    plt.suptitle('Sample RGB Images per Class', fontsize=14, fontweight='bold', y=1.01)\n    plt.tight_layout()\n    plt.savefig(os.path.join(OUTPUT_DIR, 'sample_images.png'), dpi=150, bbox_inches='tight')\n    plt.show()\n\n# ─── 2-D  Quick shape / dtype inspection ─────────────────────\nprint(\"\\n📊 SAMPLE DATA INSPECTION\")\nfor modality in ['RGB', 'MS', 'HS']:\n    mod_dir = os.path.join(TRAIN_DIR, modality)\n    if not os.path.isdir(mod_dir):\n        continue\n    files = sorted([f for f in os.listdir(mod_dir)\n                    if not os.path.isdir(os.path.join(mod_dir, f))])\n    if not files:\n        continue\n    sample = files[0]\n    fpath = os.path.join(mod_dir, sample)\n    try:\n        with rasterio.open(fpath) as src:\n            data = src.read()\n            print(f\"  {modality}/{sample}: shape={data.shape}  \"\n                  f\"dtype={data.dtype}  range=[{data.min():.1f}, {data.max():.1f}]\")\n    except Exception:\n        img = np.array(Image.open(fpath))\n        print(f\"  {modality}/{sample}: shape={img.shape}  \"\n              f\"dtype={img.dtype}  range=[{img.min()}, {img.max()}]\")\n\nprint(\"\\n✅ EDA Cell 2 complete!\")\n\n\n# ============================================================\n# CELL 3: SPECTRAL ANALYSIS & VEGETATION INDICES\n# ============================================================\n\n# ─── 3-A  Mean spectral signatures per class (from HS) ───────\nprint(\"🔬 Computing mean spectral signatures …\")\n\nhs_train = os.path.join(TRAIN_DIR, 'HS')\nspectral_means = {}\n\nif os.path.isdir(hs_train):\n    # Group files by class from filename\n    hs_by_class = {}\n    for fname in sorted(os.listdir(hs_train)):\n        cls = get_class_from_filename(fname)\n        if cls:\n            hs_by_class.setdefault(cls, []).append(fname)\n\n    for cls, fnames in hs_by_class.items():\n        spectra = []\n        for fname in fnames[:50]:   # sample up to 50 for speed\n            fpath = os.path.join(hs_train, fname)\n            try:\n                with rasterio.open(fpath) as src:\n                    data = src.read().astype(np.float32)   # (125, H, W)\n                    mean_spectrum = data.mean(axis=(1, 2)) # per-band mean\n                    spectra.append(mean_spectrum)\n            except Exception as e:\n                print(f\"  ⚠️ Error reading {fpath}: {e}\")\n        if spectra:\n            # Filter to most common shape (some files may differ)\n            shapes = [s.shape for s in spectra]\n            most_common_shape = max(set(shapes), key=shapes.count)\n            spectra = [s for s in spectra if s.shape == most_common_shape]\n            if spectra:\n                spectral_means[cls] = np.stack(spectra)\n                print(f\"  {cls}: {len(spectra)} spectra, {most_common_shape[0]} bands\")\n\nif spectral_means:\n    # Plot\n    fig, axes = plt.subplots(1, 2, figsize=(16, 5))\n    colors_map = {'Health': '#e67e22', 'Rust': '#3498db', 'Other': '#27ae60'}\n\n    # Determine actual band count from data\n    actual_bands = list(spectral_means.values())[0].shape[1]\n    wavelengths = np.linspace(450, 950, actual_bands)\n\n    # — Full spectrum\n    ax = axes[0]\n    for cls, specs in spectral_means.items():\n        mean = specs.mean(axis=0)\n        std  = specs.std(axis=0)\n        ax.plot(wavelengths, mean, label=cls, color=colors_map.get(cls, 'gray'), lw=2)\n        ax.fill_between(wavelengths, mean - std, mean + std,\n                         color=colors_map.get(cls, 'gray'), alpha=0.15)\n    # Mark noisy regions if applicable\n    skip_start_wl = 450 + HS_SKIP_START * (500.0 / actual_bands)\n    skip_end_wl   = 950 - HS_SKIP_END * (500.0 / actual_bands)\n    ax.axvspan(450, skip_start_wl, alpha=0.1, color='red', label='Noisy bands')\n    ax.axvspan(skip_end_wl, 950, alpha=0.1, color='red')\n    ax.set_xlabel('Wavelength (nm)')\n    ax.set_ylabel('Mean Reflectance')\n    ax.set_title('Mean Spectral Signatures per Class', fontweight='bold')\n    ax.legend()\n\n    # — Selected bands only\n    ax = axes[1]\n    band_end = min(HS_BAND_END, actual_bands)\n    band_start = min(HS_BAND_START, band_end)\n    sel_wavelengths = wavelengths[band_start:band_end]\n    for cls, specs in spectral_means.items():\n        sel = specs[:, band_start:band_end]\n        mean = sel.mean(axis=0)\n        ax.plot(sel_wavelengths, mean, label=cls,\n                color=colors_map.get(cls, 'gray'), lw=2)\n    ax.set_xlabel('Wavelength (nm)')\n    ax.set_ylabel('Mean Reflectance')\n    ax.set_title(f'Selected Bands (noise removed)', fontweight='bold')\n    ax.legend()\n\n    plt.tight_layout()\n    plt.savefig(os.path.join(OUTPUT_DIR, 'spectral_signatures.png'), dpi=150)\n    plt.show()\n\n# ─── 3-B  Vegetation indices from MS data ────────────────────\nprint(\"\\n🌿 Computing vegetation indices …\")\n# MS bands: Blue(~480), Green(~550), Red(~650), RedEdge(740), NIR(833)\n# Indices:  NDVI = (NIR - Red) / (NIR + Red + eps)\n#           NDRE = (NIR - RE)  / (NIR + RE  + eps)\n#           GNDVI= (NIR - Green)/(NIR + Green+ eps)\n\nms_train = os.path.join(TRAIN_DIR, 'MS')\nvi_data = {cls: {'NDVI': [], 'NDRE': [], 'GNDVI': []} for cls in CLASS_NAMES}\n\nif os.path.isdir(ms_train):\n    ms_files = sorted([f for f in os.listdir(ms_train)\n                       if not os.path.isdir(os.path.join(ms_train, f))])\n    # Group by class\n    ms_by_class = {}\n    for fname in ms_files:\n        cls = get_class_from_filename(fname)\n        if cls:\n            ms_by_class.setdefault(cls, []).append(fname)\n\n    for cls, fnames in ms_by_class.items():\n        for fname in fnames[:50]:\n            fpath = os.path.join(ms_train, fname)\n            try:\n                with rasterio.open(fpath) as src:\n                    bands = src.read().astype(np.float32)  # (5, H, W)\n                eps = 1e-8\n                green, red, re, nir = bands[1], bands[2], bands[3], bands[4]\n                ndvi  = ((nir - red) / (nir + red + eps)).mean()\n                ndre  = ((nir - re)  / (nir + re  + eps)).mean()\n                gndvi = ((nir - green) / (nir + green + eps)).mean()\n                if cls in vi_data:\n                    vi_data[cls]['NDVI'].append(ndvi)\n                    vi_data[cls]['NDRE'].append(ndre)\n                    vi_data[cls]['GNDVI'].append(gndvi)\n            except Exception:\n                pass\n\n    fig, axes = plt.subplots(1, 3, figsize=(15, 4))\n    for ax, idx_name in zip(axes, ['NDVI', 'NDRE', 'GNDVI']):\n        data_list, labels = [], []\n        for cls in CLASS_NAMES:\n            if cls in vi_data and vi_data[cls][idx_name]:\n                data_list.append(vi_data[cls][idx_name])\n                labels.append(cls)\n        ax.boxplot(data_list, labels=labels, patch_artist=True,\n                    boxprops=dict(facecolor='#ecf0f1'))\n        ax.set_title(idx_name, fontweight='bold', fontsize=13)\n        ax.set_ylabel('Index Value')\n    plt.suptitle('Vegetation Indices per Class (from MS)', fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    plt.savefig(os.path.join(OUTPUT_DIR, 'vegetation_indices.png'), dpi=150)\n    plt.show()\n\n# ─── 3-C  Band-wise variance (identify informative HS bands) ─\nif spectral_means:\n    all_specs = np.concatenate(list(spectral_means.values()), axis=0)\n    band_var  = all_specs.var(axis=0)\n    n_bands   = band_var.shape[0]\n\n    fig, ax = plt.subplots(figsize=(12, 3))\n    ax.bar(range(n_bands), band_var, color='steelblue', width=1.0)\n    skip_s = min(HS_SKIP_START, n_bands)\n    skip_e = max(n_bands - HS_SKIP_END, 0)\n    ax.axvspan(0, skip_s, alpha=0.2, color='red', label='Noisy (skipped)')\n    ax.axvspan(skip_e, n_bands, alpha=0.2, color='red')\n    ax.set_xlabel('Band Index')\n    ax.set_ylabel('Variance')\n    ax.set_title(f'Per-Band Variance ({n_bands} bands)', fontweight='bold')\n    ax.legend()\n    plt.tight_layout()\n    plt.savefig(os.path.join(OUTPUT_DIR, 'band_variance.png'), dpi=150)\n    plt.show()\n\nprint(\"✅ Spectral analysis complete!\")\n\n\n# ============================================================\n# CELL 4: DATA UTILITIES & MULTIMODAL DATASET CLASS\n# ============================================================\n\n# ─── 4-A  Helper: find file by stem in a directory ────────────\ndef find_file_by_stem(directory, stem):\n    \"\"\"Return full path of the first file whose stem matches, else None.\"\"\"\n    if not os.path.isdir(directory):\n        return None\n    for f in os.listdir(directory):\n        if Path(f).stem == stem:\n            return os.path.join(directory, f)\n    return None\n\n\n# ─── 4-B  Build sample lists ─────────────────────────────────\ndef build_train_samples():\n    \"\"\"Return list of (rgb_path, ms_path, hs_path, label_idx).\n    Handles FLAT directory layout with class encoded in filename.\"\"\"\n    samples = []\n    rgb_base = os.path.join(TRAIN_DIR, 'RGB')\n    ms_base  = os.path.join(TRAIN_DIR, 'MS')\n    hs_base  = os.path.join(TRAIN_DIR, 'HS')\n\n    for fname in sorted(os.listdir(rgb_base)):\n        if os.path.isdir(os.path.join(rgb_base, fname)):\n            continue\n        cls_name = get_class_from_filename(fname)\n        if cls_name is None:\n            continue\n        label = CLASS_MAP.get(cls_name)\n        if label is None:\n            # Case-insensitive fallback\n            for k, v in CLASS_MAP.items():\n                if k.lower() == cls_name.lower():\n                    label = v\n                    break\n            if label is None:\n                print(f\"⚠️ Unknown class '{cls_name}' in {fname}, skipping.\")\n                continue\n        stem = Path(fname).stem\n        rgb_path = os.path.join(rgb_base, fname)\n        ms_path  = find_file_by_stem(ms_base, stem)\n        hs_path  = find_file_by_stem(hs_base, stem)\n        if ms_path and hs_path:\n            samples.append((rgb_path, ms_path, hs_path, label))\n    print(f\"📦 Training samples: {len(samples)}\")\n    lbl_counts = Counter([s[3] for s in samples])\n    for idx, cnt in sorted(lbl_counts.items()):\n        print(f\"   {INV_MAP[idx]:>8s}: {cnt}\")\n    return samples\n\n\ndef build_val_samples():\n    \"\"\"Return list of (rgb_path, ms_path, hs_path, filename_id).\"\"\"\n    samples = []\n    # Use HS directory as reference (submission Ids use .tif)\n    hs_val = os.path.join(VAL_DIR, 'HS')\n    if not os.path.isdir(hs_val):\n        hs_val = os.path.join(VAL_DIR, 'MS')  # fallback\n    for fname in sorted(os.listdir(hs_val)):\n        if fname.startswith('.'):\n            continue\n        stem = Path(fname).stem\n        hs_path  = os.path.join(hs_val, fname)\n        ms_path  = find_file_by_stem(os.path.join(VAL_DIR, 'MS'), stem)\n        rgb_path = find_file_by_stem(os.path.join(VAL_DIR, 'RGB'), stem)\n        if rgb_path and ms_path:\n            samples.append((rgb_path, ms_path, hs_path, fname))\n    print(f\"📦 Validation / test samples: {len(samples)}\")\n    return samples\n\n\n# ─── 4-C  Image I/O helpers ──────────────────────────────────\ndef load_rgb(path, size=IMG_SIZE):\n    \"\"\"Load RGB → (H, W, 3) float32 in [0, 1].\"\"\"\n    try:\n        img = np.array(Image.open(path).convert('RGB')).astype(np.float32) / 255.0\n    except Exception:\n        with rasterio.open(path) as src:\n            data = src.read()  # (C, H, W)\n        if data.shape[0] >= 3:\n            img = data[:3].transpose(1, 2, 0).astype(np.float32)\n        else:\n            img = np.stack([data[0]] * 3, axis=-1).astype(np.float32)\n        img = (img - img.min()) / (img.max() - img.min() + 1e-8)\n    from PIL import Image as PILImage\n    img = np.array(PILImage.fromarray((img * 255).astype(np.uint8)).resize(\n        (size, size), PILImage.BILINEAR)).astype(np.float32) / 255.0\n    return img\n\n\ndef load_ms(path, size=IMG_SIZE):\n    \"\"\"Load multispectral → (5, H, W) float32, percentile-normalised.\"\"\"\n    with rasterio.open(path) as src:\n        data = src.read().astype(np.float32)  # (5, H, W)\n    # Resize each band\n    resized = np.zeros((data.shape[0], size, size), dtype=np.float32)\n    for i in range(data.shape[0]):\n        band_img = Image.fromarray(data[i]).resize((size, size), Image.BILINEAR)\n        resized[i] = np.array(band_img, dtype=np.float32)\n    # Percentile normalisation per band\n    for i in range(resized.shape[0]):\n        p2, p98 = np.percentile(resized[i], [2, 98])\n        resized[i] = np.clip((resized[i] - p2) / (p98 - p2 + 1e-8), 0, 1)\n    return resized\n\n\ndef compute_vegetation_indices(ms):\n    \"\"\"V4: Compute vegetation indices from 5-band MS data.\n    MS bands: Blue(0), Green(1), Red(2), Red-Edge(3), NIR(4).\n    Returns (8, H, W): original 5 bands + NDVI + GNDVI + NDRE.\n    \"\"\"\n    eps = 1e-8\n    red   = ms[2]  # Red band\n    green = ms[1]  # Green band\n    nir   = ms[4]  # NIR band\n    re    = ms[3]  # Red-Edge band\n\n    # NDVI = (NIR - Red) / (NIR + Red) — overall vegetation health\n    ndvi = (nir - red) / (nir + red + eps)\n    ndvi = np.clip((ndvi + 1) / 2, 0, 1)  # normalise [-1,1] → [0,1]\n\n    # GNDVI = (NIR - Green) / (NIR + Green) — chlorophyll content\n    gndvi = (nir - green) / (nir + green + eps)\n    gndvi = np.clip((gndvi + 1) / 2, 0, 1)\n\n    # NDRE = (NIR - RedEdge) / (NIR + RedEdge) — early stress detection\n    ndre = (nir - re) / (nir + re + eps)\n    ndre = np.clip((ndre + 1) / 2, 0, 1)\n\n    # Stack: (5, H, W) → (8, H, W)\n    vis = np.stack([ndvi, gndvi, ndre], axis=0).astype(np.float32)\n    return np.concatenate([ms, vis], axis=0)  # (8, H, W)\n\n\n\ndef load_hs_raw(path):\n    \"\"\"Load raw hyperspectral bands → (NUM_HS_BANDS, H, W) float32, band-selected.\"\"\"\n    with rasterio.open(path) as src:\n        data = src.read().astype(np.float32)  # (B, H, W)\n    total = data.shape[0]\n    b_start = min(HS_BAND_START, total)\n    b_end   = min(HS_BAND_END, total)\n    data = data[b_start:b_end]\n    if data.shape[0] < NUM_HS_BANDS:\n        pad = np.zeros((NUM_HS_BANDS - data.shape[0], data.shape[1], data.shape[2]),\n                        dtype=np.float32)\n        data = np.concatenate([data, pad], axis=0)\n    elif data.shape[0] > NUM_HS_BANDS:\n        data = data[:NUM_HS_BANDS]\n    return data   # (101, H, W)\n\n\n\ndef load_hs(path, size=IMG_SIZE):\n    \"\"\"Load hyperspectral → PCA reduce to (NUM_HS_PCA, H, W) float32.\"\"\"\n    data = load_hs_raw(path)         # (101, H, W)\n    H, W = data.shape[1], data.shape[2]\n    # Reshape for PCA: (H*W, 101)\n    pixels = data.reshape(data.shape[0], -1).T   # (H*W, 101)\n    # Apply fitted PCA\n    pca_pixels = hs_pca.transform(pixels)         # (H*W, 3)\n    # Reshape back: (3, H, W)\n    pca_data = pca_pixels.T.reshape(NUM_HS_PCA, H, W).astype(np.float32)\n    # Resize each PCA channel\n    resized = np.zeros((NUM_HS_PCA, size, size), dtype=np.float32)\n    for i in range(NUM_HS_PCA):\n        band_img = Image.fromarray(pca_data[i]).resize((size, size), Image.BILINEAR)\n        resized[i] = np.array(band_img, dtype=np.float32)\n    # Percentile normalisation per channel\n    for i in range(NUM_HS_PCA):\n        p2, p98 = np.percentile(resized[i], [2, 98])\n        resized[i] = np.clip((resized[i] - p2) / (p98 - p2 + 1e-8), 0, 1)\n    return resized\n\n\n# ─── 4-D  Spatial augmentation (consistent across modalities) ─\ndef apply_spatial_augment(rgb, ms, hs):\n    \"\"\"V5: Apply identical random spatial transforms + color jitter + noise.\n    rgb: (H, W, 3),  ms: (C, H, W),  hs: (C, H, W)\n    \"\"\"\n    # Random horizontal flip\n    if random.random() > 0.5:\n        rgb = np.flip(rgb, axis=1).copy()\n        ms  = np.flip(ms,  axis=2).copy()\n        hs  = np.flip(hs,  axis=2).copy()\n    # Random vertical flip\n    if random.random() > 0.5:\n        rgb = np.flip(rgb, axis=0).copy()\n        ms  = np.flip(ms,  axis=1).copy()\n        hs  = np.flip(hs,  axis=1).copy()\n    # Random 90° rotation\n    k = random.randint(0, 3)\n    if k > 0:\n        rgb = np.rot90(rgb, k, axes=(0, 1)).copy()\n        ms  = np.rot90(ms,  k, axes=(1, 2)).copy()\n        hs  = np.rot90(hs,  k, axes=(1, 2)).copy()\n    # V5: Color jitter on RGB\n    if random.random() > 0.5:\n        for c in range(3):\n            rgb[:, :, c] *= random.uniform(0.85, 1.15)\n            rgb[:, :, c] += random.uniform(-0.03, 0.03)\n        rgb = np.clip(rgb, 0, 1)\n    # V5: Gaussian noise on MS/HS\n    if random.random() > 0.6:\n        ms = ms + np.random.randn(*ms.shape).astype(np.float32) * 0.01\n        hs = hs + np.random.randn(*hs.shape).astype(np.float32) * 0.01\n        ms = np.clip(ms, 0, 1)\n        hs = np.clip(hs, 0, 1)\n    return rgb, ms, hs\n\n\n# ─── 4-E  ImageNet normalisation for RGB ──────────────────────\nIMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)\nIMAGENET_STD  = np.array([0.229, 0.224, 0.225], dtype=np.float32)\n\ndef normalise_rgb(img):\n    \"\"\"img: (H, W, 3) float32 [0,1] → (3, H, W) tensor.\"\"\"\n    img = (img - IMAGENET_MEAN) / (IMAGENET_STD + 1e-8)\n    return img.transpose(2, 0, 1)   # (3, H, W)\n\n\n\n# ─── 4-F  Dataset class ──────────────────────────────────────\nclass MultimodalCropDataset(Dataset):\n    \"\"\"Loads aligned RGB, MS, HS for each sample.\"\"\"\n\n    def __init__(self, sample_list, augment=False, is_test=False):\n        \"\"\"\n        sample_list: list of (rgb_path, ms_path, hs_path, label_or_id)\n        \"\"\"\n        self.samples  = sample_list\n        self.augment  = augment\n        self.is_test  = is_test\n\n    def __len__(self):\n        return len(self.samples)\n\n    def __getitem__(self, idx):\n        rgb_path, ms_path, hs_path, target = self.samples[idx]\n\n        rgb = load_rgb(rgb_path)       # (H, W, 3)  [0, 1]\n        ms  = load_ms(ms_path)         # (5, H, W)  [0, 1]\n        hs  = load_hs(hs_path)         # (3, H, W) PCA-reduced [0, 1]\n\n        if self.augment:\n            rgb, ms, hs = apply_spatial_augment(rgb, ms, hs)\n\n        # Normalise RGB for ImageNet backbone\n        rgb = normalise_rgb(rgb)       # (3, H, W)\n\n        # Convert to tensors\n        rgb_t = torch.from_numpy(rgb.copy()).float()\n        ms_t  = torch.from_numpy(ms.copy()).float()\n        hs_t  = torch.from_numpy(hs.copy()).float()\n\n        if self.is_test:\n            return rgb_t, ms_t, hs_t, target   # target = filename string\n        else:\n            return rgb_t, ms_t, hs_t, torch.tensor(target, dtype=torch.long)\n\n\n# ─── Sanity check ────────────────────────────────────────────\ntrain_samples = build_train_samples()\nval_samples   = build_val_samples()\n\n# ─── V3: Fit PCA on training HS data ────────────────────────────\nprint(\"\\n🔬 Fitting PCA on hyperspectral training data ...\")\npca_sample_paths = [s[2] for s in train_samples[:100]]  # sample up to 100 HS files\nall_pixels = []\nfor hs_path in pca_sample_paths:\n    try:\n        raw = load_hs_raw(hs_path)        # (101, H, W)\n        H, W = raw.shape[1], raw.shape[2]\n        pixels = raw.reshape(raw.shape[0], -1).T  # (H*W, 101)\n        # Subsample pixels to manage memory\n        step = max(1, pixels.shape[0] // 500)\n        all_pixels.append(pixels[::step])\n    except Exception as e:\n        print(f\"  ⚠️ Error loading {hs_path}: {e}\")\n\nall_pixels = np.concatenate(all_pixels, axis=0)  # (N, 101)\nprint(f\"   PCA fitting on {all_pixels.shape[0]} pixel samples x {all_pixels.shape[1]} bands\")\nhs_pca = PCA(n_components=NUM_HS_PCA)\nhs_pca.fit(all_pixels)\nprint(f\"   Explained variance: {hs_pca.explained_variance_ratio_}\")\nprint(f\"   Total variance explained: {hs_pca.explained_variance_ratio_.sum():.4f}\")\nprint(f\"✅ PCA fitted! HS {NUM_HS_BANDS} bands → {NUM_HS_PCA} components\")\ndel all_pixels\n\n\n\n\n_ds = MultimodalCropDataset(train_samples[:1], augment=False)\n_rgb, _ms, _hs, _lbl = _ds[0]\nprint(f\"\\n🔍 Sanity check:\")\nprint(f\"   RGB : {_rgb.shape}  range=[{_rgb.min():.2f}, {_rgb.max():.2f}]\")\nprint(f\"   MS  : {_ms.shape}  range=[{_ms.min():.2f}, {_ms.max():.2f}]\")\nprint(f\"   HS  : {_hs.shape}  range=[{_hs.min():.2f}, {_hs.max():.2f}]\")\nprint(f\"   Label: {_lbl}  ({INV_MAP[_lbl.item()]})\")\ndel _ds, _rgb, _ms, _hs, _lbl\n\nprint(\"✅ Dataset class ready!\")\n\n\n# ============================================================\n# CELL 5: MODEL — V3 ALL-PRETRAINED LATE-FUSION + FOCAL LOSS\n# ============================================================\n\n# ─── 5-A  Focal Loss ─────────────────────────────────────────\nclass FocalLoss(nn.Module):\n    \"\"\"Focal Loss — focuses on hard-to-classify samples.\n    When gamma=0, identical to CrossEntropyLoss.\n    Higher gamma → more focus on misclassified samples (Health↔Rust).\n    \"\"\"\n    def __init__(self, weight=None, gamma=2.0, label_smoothing=0.0):\n        super().__init__()\n        self.gamma = gamma\n        self.weight = weight\n        self.label_smoothing = label_smoothing\n\n    def forward(self, logits, targets):\n        ce_loss = F.cross_entropy(\n            logits, targets, weight=self.weight,\n            label_smoothing=self.label_smoothing, reduction='none'\n        )\n        pt = torch.exp(-ce_loss)   # probability of correct class\n        focal_loss = ((1 - pt) ** self.gamma) * ce_loss\n        return focal_loss.mean()\n\n\n# ─── 5-B  Full multimodal fusion network (V3: all pretrained) ─\nclass MultimodalFusionNet(nn.Module):\n    \"\"\"\n    V3 Late fusion — ALL branches use pretrained backbones:\n      • RGB  → EfficientNet-B2 (pretrained, 3ch)   → 1408-d\n      • MS   → EfficientNet-B0 (pretrained, 5ch)   → 1280-d\n      • HS   → EfficientNet-B0 (pretrained, 3ch PCA) → 1280-d\n    Fused with MLP head → 3 classes.\n    \"\"\"\n    def __init__(self, num_classes=NUM_CLASSES):\n        super().__init__()\n        self.rgb_enc = timm.create_model('efficientnet_b2', pretrained=True,\n                                          num_classes=0, in_chans=3)\n        rgb_feat = self.rgb_enc.num_features  # 1408\n\n        # V5: Upgraded MS encoder to B2 (was B0)\n        self.ms_enc = timm.create_model('efficientnet_b2', pretrained=True,\n                                         num_classes=0, in_chans=5)\n        ms_feat = self.ms_enc.num_features   # 1408\n\n        # V5: Upgraded HS encoder to B1 (was B0), PCA=8\n        self.hs_enc = timm.create_model('efficientnet_b1', pretrained=True,\n                                         num_classes=0, in_chans=NUM_HS_PCA)\n        hs_feat = self.hs_enc.num_features   # 1280\n\n        total = rgb_feat + ms_feat + hs_feat  # 3968\n        self.classifier = nn.Sequential(\n            nn.Linear(total, 512),\n            nn.BatchNorm1d(512),\n            nn.ReLU(True),\n            nn.Dropout(0.4),\n            nn.Linear(512, 128),\n            nn.BatchNorm1d(128),\n            nn.ReLU(True),\n            nn.Dropout(0.3),\n            nn.Linear(128, num_classes),\n        )\n        print(f\"✅ MultimodalFusionNet V3: RGB({rgb_feat}) + MS({ms_feat}) + HS({hs_feat}) \"\n              f\"→ {total} → {num_classes} classes\")\n\n    def freeze_backbones(self):\n        for enc in [self.rgb_enc, self.ms_enc, self.hs_enc]:\n            for p in enc.parameters():\n                p.requires_grad = False\n        trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)\n        print(f\"  🔒 Backbones frozen — {trainable/1e6:.2f}M trainable params\")\n\n    def unfreeze_backbones(self):\n        for enc in [self.rgb_enc, self.ms_enc, self.hs_enc]:\n            for p in enc.parameters():\n                p.requires_grad = True\n        trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)\n        print(f\"  🔓 Backbones unfrozen — {trainable/1e6:.2f}M trainable params\")\n\n    def forward(self, rgb, ms, hs):\n        f_rgb = self.rgb_enc(rgb)\n        f_ms  = self.ms_enc(ms)\n        f_hs  = self.hs_enc(hs)\n        fused = torch.cat([f_rgb, f_ms, f_hs], dim=1)\n        return self.classifier(fused)\n\n\n# Quick model check\n_m = MultimodalFusionNet().to(DEVICE)\n_r = torch.randn(2, 3, IMG_SIZE, IMG_SIZE).to(DEVICE)\n_s = torch.randn(2, 5, IMG_SIZE, IMG_SIZE).to(DEVICE)\n_h = torch.randn(2, NUM_HS_PCA, IMG_SIZE, IMG_SIZE).to(DEVICE)\n_o = _m(_r, _s, _h)\nprint(f\"🔍 Model output shape: {_o.shape}\")\ntotal_params = sum(p.numel() for p in _m.parameters()) / 1e6\nprint(f\"📊 Total parameters: {total_params:.1f}M\")\ndel _m, _r, _s, _h, _o\ntorch.cuda.empty_cache()\nprint(\"✅ Model architecture ready!\")\n\n\n# ============================================================\n# CELL 6: MULTI-SEED TRAINING — 3 SEEDS × 5-FOLD CV = 15 MODELS\n# ============================================================\n\nSEEDS = [42, 123, 2024]  # V5: 3 seeds = 15 models for better averaging\n\nprint(f\"\\n{'='*60}\")\nprint(f\"🚀 MULTI-SEED TRAINING — {len(SEEDS)} seeds × {N_FOLDS} folds = {len(SEEDS)*N_FOLDS} models\")\nprint(f\"   Epochs={EPOCHS}  BS={BATCH_SIZE}  LR={LR}  IMG={IMG_SIZE}\")\nprint(f\"{'='*60}\")\n\nall_fold_scores = []\nall_cv_preds, all_cv_true = [], []\nscaler = GradScaler()\n\nfor seed_idx, current_seed in enumerate(SEEDS):\n    print(f\"\\n{'='*60}\")\n    print(f\"🌱 SEED {seed_idx+1}/{len(SEEDS)}: {current_seed}\")\n    print(f\"{'='*60}\")\n\n    # Set seed for reproducibility\n    random.seed(current_seed)\n    np.random.seed(current_seed)\n    torch.manual_seed(current_seed)\n    if torch.cuda.is_available():\n        torch.cuda.manual_seed_all(current_seed)\n\n    train_labels = np.array([s[3] for s in train_samples])\n    skf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=current_seed)\n\n    for fold, (tr_idx, va_idx) in enumerate(skf.split(train_samples, train_labels)):\n        print(f\"\\n  {'─'*50}\")\n        print(f\"  📂 Seed {current_seed} — Fold {fold+1}/{N_FOLDS}\")\n        print(f\"  {'─'*50}\")\n\n        tr_data = [train_samples[i] for i in tr_idx]\n        va_data = [train_samples[i] for i in va_idx]\n\n        tr_ds = MultimodalCropDataset(tr_data, augment=True)\n        va_ds = MultimodalCropDataset(va_data, augment=False)\n\n        tr_loader = DataLoader(tr_ds, batch_size=BATCH_SIZE, shuffle=True,\n                                num_workers=2, pin_memory=True, drop_last=True)\n        va_loader = DataLoader(va_ds, batch_size=BATCH_SIZE, shuffle=False,\n                                num_workers=2, pin_memory=True)\n\n        fold_labels = np.array([d[3] for d in tr_data])\n        cw = compute_class_weight('balanced', classes=np.arange(NUM_CLASSES), y=fold_labels)\n        cw_tensor = torch.FloatTensor(cw).to(DEVICE)\n\n        model = MultimodalFusionNet(num_classes=NUM_CLASSES).to(DEVICE)\n        criterion = FocalLoss(weight=cw_tensor, gamma=FOCAL_GAMMA,\n                              label_smoothing=LABEL_SMOOTHING)\n\n        model.freeze_backbones()\n\n        backbone_params = list(model.rgb_enc.parameters()) + \\\n                          list(model.ms_enc.parameters()) + \\\n                          list(model.hs_enc.parameters())\n        head_params = list(model.classifier.parameters())\n        optimizer = torch.optim.AdamW([\n            {'params': backbone_params, 'lr': LR / 10},\n            {'params': head_params,     'lr': LR},\n        ], weight_decay=WEIGHT_DECAY)\n\n        def lr_lambda(epoch):\n            if epoch < WARMUP_EPOCHS:\n                return (epoch + 1) / WARMUP_EPOCHS\n            progress = (epoch - WARMUP_EPOCHS) / max(1, EPOCHS - WARMUP_EPOCHS)\n            return max(0.01, 0.5 * (1.0 + np.cos(np.pi * progress)))\n\n        scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n\n        best_f1 = 0\n        patience_cnt = 0\n        ckpt_name = f'best_s{current_seed}_f{fold+1}.pth'\n\n        for ep in range(EPOCHS):\n            if ep == FREEZE_EPOCHS:\n                model.unfreeze_backbones()\n                patience_cnt = 0\n\n            model.train()\n            ep_loss = 0\n            for rgb, ms, hs, labels in tr_loader:\n                rgb, ms, hs, labels = (rgb.to(DEVICE), ms.to(DEVICE),\n                                        hs.to(DEVICE), labels.to(DEVICE))\n\n                # V5: MixUp or CutMix augmentation\n                r = random.random()\n                if r < 0.35 and MIXUP_ALPHA > 0:\n                    # MixUp\n                    lam = np.random.beta(MIXUP_ALPHA, MIXUP_ALPHA)\n                    idx = torch.randperm(rgb.size(0), device=DEVICE)\n                    rgb = lam * rgb + (1 - lam) * rgb[idx]\n                    ms  = lam * ms  + (1 - lam) * ms[idx]\n                    hs  = lam * hs  + (1 - lam) * hs[idx]\n                    labels_a, labels_b = labels, labels[idx]\n                    use_mix = True\n                elif r < 0.6 and CUTMIX_ALPHA > 0:\n                    # CutMix\n                    lam = np.random.beta(CUTMIX_ALPHA, CUTMIX_ALPHA)\n                    idx = torch.randperm(rgb.size(0), device=DEVICE)\n                    _, _, H, W = rgb.shape\n                    cut_rat = np.sqrt(1.0 - lam)\n                    cw, ch = int(W * cut_rat), int(H * cut_rat)\n                    cx, cy = np.random.randint(W), np.random.randint(H)\n                    x1 = np.clip(cx - cw//2, 0, W); x2 = np.clip(cx + cw//2, 0, W)\n                    y1 = np.clip(cy - ch//2, 0, H); y2 = np.clip(cy + ch//2, 0, H)\n                    rgb[:, :, y1:y2, x1:x2] = rgb[idx, :, y1:y2, x1:x2]\n                    ms[:, :, y1:y2, x1:x2] = ms[idx, :, y1:y2, x1:x2]\n                    hs[:, :, y1:y2, x1:x2] = hs[idx, :, y1:y2, x1:x2]\n                    lam = 1 - (x2-x1)*(y2-y1)/(W*H)\n                    labels_a, labels_b = labels, labels[idx]\n                    use_mix = True\n                else:\n                    labels_a = labels\n                    use_mix = False\n\n                optimizer.zero_grad()\n                with autocast():\n                    logits = model(rgb, ms, hs)\n                    if use_mix:\n                        loss = lam * criterion(logits, labels_a) + \\\n                               (1 - lam) * criterion(logits, labels_b)\n                    else:\n                        loss = criterion(logits, labels_a)\n\n                scaler.scale(loss).backward()\n                scaler.unscale_(optimizer)\n                nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n                scaler.step(optimizer)\n                scaler.update()\n                ep_loss += loss.item()\n\n            scheduler.step()\n\n            model.eval()\n            preds_list, true_list = [], []\n            with torch.no_grad():\n                for rgb, ms, hs, labels in va_loader:\n                    rgb, ms, hs = rgb.to(DEVICE), ms.to(DEVICE), hs.to(DEVICE)\n                    with autocast():\n                        logits = model(rgb, ms, hs)\n                    preds_list.extend(logits.argmax(1).cpu().tolist())\n                    true_list.extend(labels.tolist())\n\n            acc = accuracy_score(true_list, preds_list)\n            f1  = f1_score(true_list, preds_list, average='macro')\n            lr_now = optimizer.param_groups[0]['lr']\n\n            if (ep + 1) % 5 == 0 or ep == 0:\n                print(f\"    Ep {ep+1:2d}/{EPOCHS} | Loss: {ep_loss/len(tr_loader):.4f} | \"\n                      f\"Acc: {acc:.4f} | F1: {f1:.4f} | LR: {lr_now:.1e}\")\n\n            if f1 > best_f1:\n                best_f1 = f1\n                patience_cnt = 0\n                torch.save(model.state_dict(),\n                           os.path.join(OUTPUT_DIR, ckpt_name))\n            else:\n                patience_cnt += 1\n                if patience_cnt >= PATIENCE:\n                    print(f\"    ⏹️  Early stopping at epoch {ep+1}\")\n                    break\n\n        # Final evaluation\n        model.load_state_dict(torch.load(os.path.join(OUTPUT_DIR, ckpt_name)))\n        model.eval()\n        fold_preds, fold_true = [], []\n        with torch.no_grad():\n            for rgb, ms, hs, labels in va_loader:\n                rgb, ms, hs = rgb.to(DEVICE), ms.to(DEVICE), hs.to(DEVICE)\n                logits = model(rgb, ms, hs)\n                fold_preds.extend(logits.argmax(1).cpu().tolist())\n                fold_true.extend(labels.tolist())\n\n        fold_f1 = f1_score(fold_true, fold_preds, average='macro')\n        all_fold_scores.append((current_seed, fold+1, fold_f1))\n        print(f\"\\n    ✅ Seed {current_seed} Fold {fold+1} F1: {fold_f1:.4f}\")\n\n        del model, optimizer, scheduler, criterion\n        torch.cuda.empty_cache()\n        gc.collect()\n\n# Print summary\nprint(f\"\\n{'='*60}\")\nprint(f\"📊 MULTI-SEED CV RESULTS ({len(SEEDS)} seeds × {N_FOLDS} folds)\")\nprint(f\"{'='*60}\")\nfor seed, fold, f1 in all_fold_scores:\n    print(f\"  Seed {seed} Fold {fold}: F1 = {f1:.4f}\")\nall_f1s = [f1 for _, _, f1 in all_fold_scores]\nprint(f\"\\n  Mean F1 : {np.mean(all_f1s):.4f} ± {np.std(all_f1s):.4f}\")\nfor s in SEEDS:\n    seed_f1s = [f1 for seed, _, f1 in all_fold_scores if seed == s]\n    print(f\"  Seed {s}: Mean F1 = {np.mean(seed_f1s):.4f}\")\n\n\n# ============================================================\n# CELL 7: INFERENCE — ENSEMBLE ALL 15 MODELS + ROTATION TTA\n# ============================================================\n\nprint(f\"\\n{'='*60}\")\nprint(f\"🎯 INFERENCE — {len(SEEDS)} seeds × {N_FOLDS} folds + Rotation TTA\")\nprint(f\"{'='*60}\")\n\n# ── Load all saved models ────────────────────────────────────\nmodels = []\nfor seed in SEEDS:\n    for fold in range(N_FOLDS):\n        ckpt = os.path.join(OUTPUT_DIR, f'best_s{seed}_f{fold+1}.pth')\n        if not os.path.exists(ckpt):\n            print(f\"  ⚠️ {ckpt} not found\")\n            continue\n        m = MultimodalFusionNet(num_classes=NUM_CLASSES).to(DEVICE)\n        m.load_state_dict(torch.load(ckpt, map_location=DEVICE))\n        m.eval()\n        models.append(m)\nprint(f\"  Loaded {len(models)} models for ensemble\")\n\n# ── Test inference with TTA ──────────────────────────────────\ntest_ds     = MultimodalCropDataset(val_samples, augment=False, is_test=True)\ntest_loader = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False,\n                          num_workers=2, pin_memory=True)\n\nall_probs = []\nall_ids   = []\n\nwith torch.no_grad():\n    for rgb, ms, hs, fnames in tqdm(test_loader, desc=\"Inference\"):\n        rgb, ms, hs = rgb.to(DEVICE), ms.to(DEVICE), hs.to(DEVICE)\n        batch_probs = torch.zeros(rgb.size(0), NUM_CLASSES, device=DEVICE)\n        n_aug = 0\n\n        for model in models:\n            # Original\n            with autocast():\n                batch_probs += F.softmax(model(rgb, ms, hs), dim=1)\n            n_aug += 1\n\n            if TTA_ENABLED:\n                # Horizontal flip\n                with autocast():\n                    batch_probs += F.softmax(\n                        model(rgb.flip(-1), ms.flip(-1), hs.flip(-1)), dim=1)\n                n_aug += 1\n                # Vertical flip\n                with autocast():\n                    batch_probs += F.softmax(\n                        model(rgb.flip(-2), ms.flip(-2), hs.flip(-2)), dim=1)\n                n_aug += 1\n                # Both flips\n                with autocast():\n                    batch_probs += F.softmax(\n                        model(rgb.flip(-1).flip(-2),\n                              ms.flip(-1).flip(-2),\n                              hs.flip(-1).flip(-2)), dim=1)\n                n_aug += 1\n                # Rot90\n                with autocast():\n                    batch_probs += F.softmax(\n                        model(rgb.rot90(1, [-2, -1]),\n                              ms.rot90(1, [-2, -1]),\n                              hs.rot90(1, [-2, -1])), dim=1)\n                n_aug += 1\n                # Rot180\n                with autocast():\n                    batch_probs += F.softmax(\n                        model(rgb.rot90(2, [-2, -1]),\n                              ms.rot90(2, [-2, -1]),\n                              hs.rot90(2, [-2, -1])), dim=1)\n                n_aug += 1\n                # Rot270\n                with autocast():\n                    batch_probs += F.softmax(\n                        model(rgb.rot90(3, [-2, -1]),\n                              ms.rot90(3, [-2, -1]),\n                              hs.rot90(3, [-2, -1])), dim=1)\n                n_aug += 1\n\n        batch_probs /= n_aug\n        all_probs.append(batch_probs.cpu())\n        all_ids.extend(list(fnames))\n\nall_probs   = torch.cat(all_probs, dim=0)\npredictions = all_probs.argmax(dim=1).numpy()\n\n# ── Generate submission ──────────────────────────────────────\nsubmission = pd.DataFrame({\n    'Id':       all_ids,\n    'Category': [INV_MAP[p] for p in predictions],\n})\nsub_path = os.path.join(OUTPUT_DIR, 'submission.csv')\nsubmission.to_csv(sub_path, index=False)\n\nprint(f\"\\n📄 Submission saved: {sub_path}\")\nprint(f\"   Distribution: {dict(submission.Category.value_counts())}\")\nprint(f\"   Models in ensemble: {len(models)} ({len(SEEDS)} seeds × {N_FOLDS} folds)\")\nprint(f\"   TTA augmentations: 7 per model\")\nprint(f\"   Total predictions averaged: {len(models) * 7}\")\n\n# Auto-download\ntry:\n    import base64\n    from IPython.display import Javascript, display\n    with open(sub_path, 'r') as f:\n        csv_data = f.read()\n    b64 = base64.b64encode(csv_data.encode()).decode()\n    js = f\"\"\"\n    var a = document.createElement('a');\n    a.href = 'data:text/csv;base64,{b64}';\n    a.download = 'submission.csv';\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    \"\"\"\n    display(Javascript(js))\n    print(\"  📥 submission.csv auto-downloaded!\")\nexcept Exception:\n    pass\n\n# Cleanup\nfor m in models:\n    del m\ntorch.cuda.empty_cache()\ngc.collect()\n\nprint(f\"\\n{'='*60}\")\nprint(\"🏁 ALL DONE!\")\nprint(f\"{'='*60}\")\nprint(f\"  📄 submission.csv — V3 + {len(SEEDS)}-seed ensemble + rotation TTA\")\nprint(\"\\n  Submit submission.csv to Kaggle! 🚀\")\n\n\n# ============================================================\n# CELL 8: MAXIMISE GENERALIZATION — THREE-PRONGED STRATEGY\n# ============================================================\n# 1. OOF threshold optimization (free — no retraining)\n# 2. ConvNeXt variant (different backbone = diverse errors)\n# 3. Smart weighted ensembling using OOF predictions\n#\n# Key insight: the 0.758 baseline already predicts ~95% correctly.\n# The remaining ~5% (15 samples) are borderline Health↔Rust cases.\n# This cell targets EXACTLY those borderline cases.\n\nprint(f\"\\n{'='*60}\")\nprint(\"🎯 CELL 8: MAXIMISE GENERALIZATION\")\nprint(f\"{'='*60}\")\n\n\n# ════════════════════════════════════════════════════════════\n# PART 1: OOF PREDICTIONS FROM V3 (for threshold optimisation)\n# ════════════════════════════════════════════════════════════\n\nprint(\"\\n📊 Part 1: Collecting Out-of-Fold predictions from V3 ...\")\n\n# Re-load V3 checkpoints and run on validation folds\nseed_everything(SEED)\ntrain_labels_np = np.array([s[3] for s in train_samples])\nskf_oof = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED)\n\noof_probs = np.zeros((len(train_samples), NUM_CLASSES))\n\nfor fold, (tr_idx, va_idx) in enumerate(skf_oof.split(train_samples, train_labels_np)):\n    ckpt_path = os.path.join(OUTPUT_DIR, f'best_s{SEED}_f{fold+1}.pth')\n    if not os.path.exists(ckpt_path):\n        print(f\"  ⚠️ Missing checkpoint: {ckpt_path}\")\n        continue\n\n    model = MultimodalFusionNet(num_classes=NUM_CLASSES).to(DEVICE)\n    model.load_state_dict(torch.load(ckpt_path, map_location=DEVICE))\n    model.eval()\n\n    va_data = [train_samples[i] for i in va_idx]\n    va_ds = MultimodalCropDataset(va_data, augment=False)\n    va_loader = DataLoader(va_ds, batch_size=BATCH_SIZE, shuffle=False,\n                            num_workers=2, pin_memory=True)\n\n    fold_probs = []\n    with torch.no_grad():\n        for rgb, ms, hs, labels in va_loader:\n            rgb, ms, hs = rgb.to(DEVICE), ms.to(DEVICE), hs.to(DEVICE)\n            with autocast():\n                logits = model(rgb, ms, hs)\n            probs = F.softmax(logits, dim=1).cpu().numpy()\n            fold_probs.append(probs)\n\n    fold_probs = np.concatenate(fold_probs, axis=0)\n    oof_probs[va_idx] = fold_probs\n\n    oof_preds = fold_probs.argmax(axis=1)\n    oof_true  = train_labels_np[va_idx]\n    fold_f1 = f1_score(oof_true, oof_preds, average='macro')\n    print(f\"  Fold {fold+1}: OOF F1 = {fold_f1:.4f}\")\n\n    del model\n    torch.cuda.empty_cache()\n\n# Overall OOF metrics\noof_pred_classes = oof_probs.argmax(axis=1)\noof_f1 = f1_score(train_labels_np, oof_pred_classes, average='macro')\nprint(f\"\\n  📊 V3 OOF Macro F1: {oof_f1:.4f}\")\nprint(f\"  📊 Per-class F1:\")\nfor cls_idx in range(NUM_CLASSES):\n    cls_f1 = f1_score(train_labels_np == cls_idx, oof_pred_classes == cls_idx)\n    print(f\"     {INV_MAP[cls_idx]}: {cls_f1:.4f}\")\n\n\n# ════════════════════════════════════════════════════════════\n# PART 2: OPTIMAL PER-CLASS THRESHOLD SEARCH\n# ════════════════════════════════════════════════════════════\n\nprint(f\"\\n{'='*60}\")\nprint(\"🔧 Part 2: Per-class threshold optimisation\")\nprint(f\"{'='*60}\")\n\nfrom scipy.optimize import minimize\n\ndef predict_with_thresholds(probs, thresholds):\n    \"\"\"Apply per-class bias thresholds, then argmax.\"\"\"\n    adjusted = probs + np.array(thresholds)\n    return adjusted.argmax(axis=1)\n\ndef neg_macro_f1(thresholds, probs, true_labels):\n    \"\"\"Negative macro F1 for optimisation (we minimise).\"\"\"\n    preds = predict_with_thresholds(probs, thresholds)\n    return -f1_score(true_labels, preds, average='macro')\n\n# Search for optimal thresholds\nbest_thresholds = [0.0] * NUM_CLASSES\nbest_oof_f1 = oof_f1\n\n# Grid search combined with scipy optimisation\nfrom itertools import product\n\nprint(\"  Searching optimal thresholds (grid + scipy) ...\")\ngrid_values = np.arange(-0.15, 0.16, 0.03)\n\nbest_t = [0.0] * NUM_CLASSES\nbest_score = oof_f1\n\n# Quick grid search\nfor t0 in grid_values:\n    for t1 in grid_values:\n        for t2 in grid_values:\n            thresholds = [t0, t1, t2]\n            preds = predict_with_thresholds(oof_probs, thresholds)\n            score = f1_score(train_labels_np, preds, average='macro')\n            if score > best_score:\n                best_score = score\n                best_t = list(thresholds)\n\n# Refine with scipy\nresult = minimize(neg_macro_f1, best_t, args=(oof_probs, train_labels_np),\n                  method='Nelder-Mead', options={'maxiter': 5000, 'xatol': 0.001})\nfinal_thresholds = result.x.tolist()\nfinal_preds = predict_with_thresholds(oof_probs, final_thresholds)\nfinal_f1 = f1_score(train_labels_np, final_preds, average='macro')\n\nif final_f1 > best_score:\n    best_t = final_thresholds\n    best_score = final_f1\n\nprint(f\"  ✅ Optimal thresholds: {[f'{t:.4f}' for t in best_t]}\")\nprint(f\"  ✅ OOF F1 improved: {oof_f1:.4f} → {best_score:.4f} \"\n      f\"(+{(best_score - oof_f1)*100:.2f}%)\")\n\n# Apply to test predictions\ntest_preds_optimised = predict_with_thresholds(\n    all_probs.numpy(), best_t)\n\n# Save threshold-optimised submission\nsub_thresh = pd.DataFrame({\n    'Id': all_ids,\n    'Category': [INV_MAP[p] for p in test_preds_optimised],\n})\nsub_thresh_path = os.path.join(OUTPUT_DIR, 'submission_thresh_opt.csv')\nsub_thresh.to_csv(sub_thresh_path, index=False)\n\nn_diff = int(np.sum(predictions != test_preds_optimised))\nprint(f\"\\n  📄 submission_thresh_opt.csv\")\nprint(f\"     Distribution: {dict(sub_thresh.Category.value_counts())}\")\nprint(f\"     Changed from V3: {n_diff}/{len(predictions)}\")\n\n\n# ════════════════════════════════════════════════════════════\n# PART 3: CONVNEXT VARIANT — DIFFERENT BACKBONE FOR DIVERSITY\n# ════════════════════════════════════════════════════════════\n\nprint(f\"\\n{'='*60}\")\nprint(\"🏗️ Part 3: Training ConvNeXt variant (for ensemble diversity)\")\nprint(f\"{'='*60}\")\n\nclass MultimodalFusionNetV2(nn.Module):\n    \"\"\"V3B — ConvNeXt-Tiny for RGB, EfficientNet-B0 for MS/HS.\n    Different architecture = different error patterns = better ensemble.\"\"\"\n    def __init__(self, num_classes=NUM_CLASSES):\n        super().__init__()\n        self.rgb_enc = timm.create_model('convnext_tiny', pretrained=True,\n                                          num_classes=0, in_chans=3)\n        rgb_feat = self.rgb_enc.num_features\n\n        self.ms_enc = timm.create_model('efficientnet_b0', pretrained=True,\n                                         num_classes=0, in_chans=5)\n        ms_feat = self.ms_enc.num_features\n\n        self.hs_enc = timm.create_model('efficientnet_b0', pretrained=True,\n                                         num_classes=0, in_chans=NUM_HS_PCA)\n        hs_feat = self.hs_enc.num_features\n\n        total = rgb_feat + ms_feat + hs_feat\n        self.classifier = nn.Sequential(\n            nn.Linear(total, 512),\n            nn.BatchNorm1d(512),\n            nn.ReLU(True),\n            nn.Dropout(0.5),       # Higher dropout for ConvNeXt\n            nn.Linear(512, 128),\n            nn.BatchNorm1d(128),\n            nn.ReLU(True),\n            nn.Dropout(0.3),\n            nn.Linear(128, num_classes),\n        )\n        print(f\"✅ FusionNetV2: ConvNeXt-Tiny({rgb_feat}) + B0({ms_feat}) + B0({hs_feat})\"\n              f\" → {total} → {num_classes}\")\n\n    def freeze_backbones(self):\n        for enc in [self.rgb_enc, self.ms_enc, self.hs_enc]:\n            for p in enc.parameters():\n                p.requires_grad = False\n        t = sum(p.numel() for p in self.parameters() if p.requires_grad)\n        print(f\"  🔒 Frozen — {t/1e6:.2f}M trainable\")\n\n    def unfreeze_backbones(self):\n        for enc in [self.rgb_enc, self.ms_enc, self.hs_enc]:\n            for p in enc.parameters():\n                p.requires_grad = True\n        t = sum(p.numel() for p in self.parameters() if p.requires_grad)\n        print(f\"  🔓 Unfrozen — {t/1e6:.2f}M trainable\")\n\n    def forward(self, rgb, ms, hs):\n        return self.classifier(torch.cat([\n            self.rgb_enc(rgb), self.ms_enc(ms), self.hs_enc(hs)\n        ], dim=1))\n\n\n# Train ConvNeXt variant with 5-fold CV\nV2_EPOCHS = 30\nV2_LR     = 2e-4  # slightly lower for ConvNeXt\n\nseed_everything(SEED)\nskf_v2 = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED)\nscaler_v2 = GradScaler()\nv2_fold_scores = []\nv2_oof_probs = np.zeros((len(train_samples), NUM_CLASSES))\n\nfor fold, (tr_idx, va_idx) in enumerate(skf_v2.split(train_samples, train_labels_np)):\n    print(f\"\\n  {'─'*50}\")\n    print(f\"  📂 ConvNeXt Fold {fold+1}/{N_FOLDS}\")\n    print(f\"  {'─'*50}\")\n\n    tr_data = [train_samples[i] for i in tr_idx]\n    va_data = [train_samples[i] for i in va_idx]\n\n    tr_ds = MultimodalCropDataset(tr_data, augment=True)\n    va_ds = MultimodalCropDataset(va_data, augment=False)\n    tr_loader = DataLoader(tr_ds, batch_size=BATCH_SIZE, shuffle=True,\n                            num_workers=2, pin_memory=True, drop_last=True)\n    va_loader = DataLoader(va_ds, batch_size=BATCH_SIZE, shuffle=False,\n                            num_workers=2, pin_memory=True)\n\n    fold_labels = np.array([d[3] for d in tr_data])\n    cw = compute_class_weight('balanced', classes=np.arange(NUM_CLASSES), y=fold_labels)\n    cw_tensor = torch.FloatTensor(cw).to(DEVICE)\n\n    model = MultimodalFusionNetV2(num_classes=NUM_CLASSES).to(DEVICE)\n    criterion = FocalLoss(weight=cw_tensor, gamma=FOCAL_GAMMA,\n                          label_smoothing=LABEL_SMOOTHING)\n    model.freeze_backbones()\n\n    backbone_params = list(model.rgb_enc.parameters()) + \\\n                      list(model.ms_enc.parameters()) + \\\n                      list(model.hs_enc.parameters())\n    head_params = list(model.classifier.parameters())\n    optimizer = torch.optim.AdamW([\n        {'params': backbone_params, 'lr': V2_LR / 10},\n        {'params': head_params,     'lr': V2_LR},\n    ], weight_decay=WEIGHT_DECAY)\n\n    def lr_lambda_v2(epoch):\n        if epoch < WARMUP_EPOCHS:\n            return (epoch + 1) / WARMUP_EPOCHS\n        progress = (epoch - WARMUP_EPOCHS) / max(1, V2_EPOCHS - WARMUP_EPOCHS)\n        return max(0.01, 0.5 * (1.0 + np.cos(np.pi * progress)))\n\n    scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda_v2)\n    best_f1 = 0\n    patience_cnt = 0\n    ckpt_name = f'best_v2_f{fold+1}.pth'\n\n    for ep in range(V2_EPOCHS):\n        if ep == FREEZE_EPOCHS:\n            model.unfreeze_backbones()\n            patience_cnt = 0\n\n        model.train()\n        ep_loss = 0\n        for rgb, ms, hs, labels in tr_loader:\n            rgb, ms, hs, labels = (rgb.to(DEVICE), ms.to(DEVICE),\n                                    hs.to(DEVICE), labels.to(DEVICE))\n            if MIXUP_ALPHA > 0:\n                lam = np.random.beta(MIXUP_ALPHA, MIXUP_ALPHA)\n                idx = torch.randperm(rgb.size(0), device=DEVICE)\n                rgb = lam * rgb + (1 - lam) * rgb[idx]\n                ms  = lam * ms  + (1 - lam) * ms[idx]\n                hs  = lam * hs  + (1 - lam) * hs[idx]\n                labels_a, labels_b = labels, labels[idx]\n\n            optimizer.zero_grad()\n            with autocast():\n                logits = model(rgb, ms, hs)\n                if MIXUP_ALPHA > 0:\n                    loss = lam * criterion(logits, labels_a) + \\\n                           (1 - lam) * criterion(logits, labels_b)\n                else:\n                    loss = criterion(logits, labels)\n\n            scaler_v2.scale(loss).backward()\n            scaler_v2.unscale_(optimizer)\n            nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n            scaler_v2.step(optimizer)\n            scaler_v2.update()\n            ep_loss += loss.item()\n\n        scheduler.step()\n\n        model.eval()\n        preds_list, true_list, probs_list = [], [], []\n        with torch.no_grad():\n            for rgb, ms, hs, labels in va_loader:\n                rgb, ms, hs = rgb.to(DEVICE), ms.to(DEVICE), hs.to(DEVICE)\n                with autocast():\n                    logits = model(rgb, ms, hs)\n                probs = F.softmax(logits, dim=1).cpu().numpy()\n                probs_list.append(probs)\n                preds_list.extend(logits.argmax(1).cpu().tolist())\n                true_list.extend(labels.tolist())\n\n        f1 = f1_score(true_list, preds_list, average='macro')\n        if (ep + 1) % 10 == 0 or ep == 0:\n            print(f\"    Ep {ep+1:2d}/{V2_EPOCHS} | Loss: {ep_loss/len(tr_loader):.4f} | F1: {f1:.4f}\")\n\n        if f1 > best_f1:\n            best_f1 = f1\n            patience_cnt = 0\n            torch.save(model.state_dict(), os.path.join(OUTPUT_DIR, ckpt_name))\n            # Save OOF probs for this fold's best\n            best_fold_probs = np.concatenate(probs_list, axis=0)\n        else:\n            patience_cnt += 1\n            if patience_cnt >= PATIENCE:\n                print(f\"    ⏹️  Early stop at {ep+1}\")\n                break\n\n    v2_oof_probs[va_idx] = best_fold_probs\n    v2_fold_scores.append(best_f1)\n    print(f\"    ✅ ConvNeXt Fold {fold+1}: F1 = {best_f1:.4f}\")\n\n    del model, optimizer, scheduler\n    torch.cuda.empty_cache()\n    gc.collect()\n\nv2_oof_f1 = f1_score(train_labels_np, v2_oof_probs.argmax(1), average='macro')\nprint(f\"\\n  📊 ConvNeXt OOF F1: {v2_oof_f1:.4f}\")\n\n\n# ════════════════════════════════════════════════════════════\n# PART 4: ConvNeXt INFERENCE + TTA\n# ════════════════════════════════════════════════════════════\n\nprint(f\"\\n{'='*60}\")\nprint(\"🎯 Part 4: ConvNeXt Inference + TTA\")\nprint(f\"{'='*60}\")\n\nv2_models = []\nfor fold in range(1, N_FOLDS + 1):\n    ckpt = os.path.join(OUTPUT_DIR, f'best_v2_f{fold}.pth')\n    if os.path.exists(ckpt):\n        m = MultimodalFusionNetV2(num_classes=NUM_CLASSES).to(DEVICE)\n        m.load_state_dict(torch.load(ckpt, map_location=DEVICE))\n        m.eval()\n        v2_models.append(m)\n\ntest_ds_v2 = MultimodalCropDataset(val_samples, augment=False, is_test=True)\ntest_loader_v2 = DataLoader(test_ds_v2, batch_size=BATCH_SIZE, shuffle=False,\n                             num_workers=2, pin_memory=True)\n\nv2_test_probs = []\nv2_test_ids   = []\n\nwith torch.no_grad():\n    for rgb, ms, hs, fnames in tqdm(test_loader_v2, desc=\"ConvNeXt TTA\"):\n        rgb, ms, hs = rgb.to(DEVICE), ms.to(DEVICE), hs.to(DEVICE)\n        batch_probs = torch.zeros(rgb.size(0), NUM_CLASSES, device=DEVICE)\n        n_aug = 0\n\n        for m in v2_models:\n            for aug_fn in [\n                lambda r, s, h: (r, s, h),\n                lambda r, s, h: (r.flip(-1), s.flip(-1), h.flip(-1)),\n                lambda r, s, h: (r.flip(-2), s.flip(-2), h.flip(-2)),\n                lambda r, s, h: (r.flip(-1).flip(-2), s.flip(-1).flip(-2), h.flip(-1).flip(-2)),\n                lambda r, s, h: (r.rot90(1, [-2,-1]), s.rot90(1, [-2,-1]), h.rot90(1, [-2,-1])),\n                lambda r, s, h: (r.rot90(2, [-2,-1]), s.rot90(2, [-2,-1]), h.rot90(2, [-2,-1])),\n                lambda r, s, h: (r.rot90(3, [-2,-1]), s.rot90(3, [-2,-1]), h.rot90(3, [-2,-1])),\n            ]:\n                r_a, s_a, h_a = aug_fn(rgb, ms, hs)\n                with autocast():\n                    batch_probs += F.softmax(m(r_a, s_a, h_a), dim=1)\n                n_aug += 1\n\n        batch_probs /= n_aug\n        v2_test_probs.append(batch_probs.cpu())\n        v2_test_ids.extend(list(fnames))\n\nv2_test_probs = torch.cat(v2_test_probs, dim=0).numpy()\n\nfor m in v2_models:\n    del m\ntorch.cuda.empty_cache()\ngc.collect()\n\n\n# ════════════════════════════════════════════════════════════\n# PART 5: SMART ENSEMBLING — OPTIMAL WEIGHTS FROM OOF\n# ════════════════════════════════════════════════════════════\n\nprint(f\"\\n{'='*60}\")\nprint(\"🏆 Part 5: Smart ensembling (OOF-optimised weights)\")\nprint(f\"{'='*60}\")\n\n# Find optimal V3/V2 blend weight using OOF predictions\nbest_blend_w = 0.5\nbest_blend_f1 = 0\n\nfor w in np.arange(0.3, 0.95, 0.05):\n    blended_oof = w * oof_probs + (1 - w) * v2_oof_probs\n    # Also apply threshold optimisation\n    blended_preds = predict_with_thresholds(blended_oof, best_t)\n    blended_f1 = f1_score(train_labels_np, blended_preds, average='macro')\n    if blended_f1 > best_blend_f1:\n        best_blend_f1 = blended_f1\n        best_blend_w = w\n\nprint(f\"  Best blend: V3={best_blend_w:.2f} + ConvNeXt={1-best_blend_w:.2f}\")\nprint(f\"  OOF ensemble F1: {best_blend_f1:.4f}\")\nprint(f\"  V3 alone: {oof_f1:.4f}  |  ConvNeXt alone: {v2_oof_f1:.4f}\")\n\n# Apply to test set\nv3_test = all_probs.numpy()\nv2_test = v2_test_probs\n\n# Generate multiple submissions\nprint(f\"\\n  Generating submissions:\")\n\n# 1. V3 + threshold optimisation (no retrain)\nsub1 = pd.DataFrame({\n    'Id': all_ids,\n    'Category': [INV_MAP[p] for p in predict_with_thresholds(v3_test, best_t)],\n})\nsub1.to_csv(os.path.join(OUTPUT_DIR, 'submission_v3_thresh.csv'), index=False)\nn_diff1 = int(np.sum(predictions != predict_with_thresholds(v3_test, best_t)))\nprint(f\"  📄 submission_v3_thresh.csv — V3 + threshold opt ({n_diff1} changed)\")\n\n# 2. ConvNeXt only\nv2_preds = v2_test.argmax(axis=1)\nsub2 = pd.DataFrame({\n    'Id': v2_test_ids,\n    'Category': [INV_MAP[p] for p in v2_preds],\n})\nsub2.to_csv(os.path.join(OUTPUT_DIR, 'submission_convnext.csv'), index=False)\nprint(f\"  📄 submission_convnext.csv — ConvNeXt-Tiny variant\")\n\n# 3. Optimal blend + thresholds\nblended_test = best_blend_w * v3_test + (1 - best_blend_w) * v2_test\nblend_preds = predict_with_thresholds(blended_test, best_t)\nsub3 = pd.DataFrame({\n    'Id': all_ids,\n    'Category': [INV_MAP[p] for p in blend_preds],\n})\nsub3.to_csv(os.path.join(OUTPUT_DIR, 'submission_ensemble_opt.csv'), index=False)\nn_diff3 = int(np.sum(predictions != blend_preds))\nprint(f\"  📄 submission_ensemble_opt.csv — V3+ConvNeXt blend + thresh ({n_diff3} changed)  ⭐\")\n\n# 4. Multiple blend weights for safety\nfor w in [0.6, 0.7, 0.8]:\n    bl = w * v3_test + (1 - w) * v2_test\n    bl_preds = predict_with_thresholds(bl, best_t)\n    sub = pd.DataFrame({'Id': all_ids, 'Category': [INV_MAP[p] for p in bl_preds]})\n    fname = f'submission_ens_v3{int(w*10)}_cn{int((1-w)*10)}.csv'\n    sub.to_csv(os.path.join(OUTPUT_DIR, fname), index=False)\n    n_d = int(np.sum(predictions != bl_preds))\n    print(f\"  📄 {fname} ({n_d} changed from V3)\")\n\n# Auto-download\nfor fname in ['submission.csv', 'submission_v3_thresh.csv', 'submission_convnext.csv',\n              'submission_ensemble_opt.csv', 'submission_ens_v3_6_cn4.csv',\n              'submission_ens_v37_cn3.csv', 'submission_ens_v38_cn2.csv']:\n    try:\n        import base64\n        from IPython.display import Javascript, display\n        fpath = os.path.join(OUTPUT_DIR, fname)\n        if os.path.exists(fpath):\n            with open(fpath, 'r') as f:\n                csv_data = f.read()\n            b64 = base64.b64encode(csv_data.encode()).decode()\n            js = f\"\"\"\n            var a = document.createElement('a');\n            a.href = 'data:text/csv;base64,{b64}';\n            a.download = '{fname}';\n            document.body.appendChild(a);\n            a.click();\n            document.body.removeChild(a);\n            \"\"\"\n            display(Javascript(js))\n    except Exception:\n        pass\n\nprint(f\"\\n{'='*60}\")\nprint(\"🏆 ALL DONE! FINAL STRATEGY:\")\nprint(f\"{'='*60}\")\nprint(f\"  📄 submission.csv              — V3 baseline\")\nprint(f\"  📄 submission_v3_thresh.csv     — V3 + threshold calibration\")\nprint(f\"  📄 submission_ensemble_opt.csv  — V3+ConvNeXt + thresh  ⭐ BEST BET\")\nprint(f\"  📄 submission_convnext.csv      — ConvNeXt variant (diversity check)\")\nprint(f\"\\n  🎯 Submit in this order:\")\nprint(f\"     1. submission_ensemble_opt.csv  ⭐\")\nprint(f\"     2. submission_v3_thresh.csv\")\nprint(f\"     3. submission.csv (baseline safety)\")\nprint(f\"\\n  Good luck! 🚀\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T10:34:12.913502Z","iopub.execute_input":"2026-03-01T10:34:12.913981Z","iopub.status.idle":"2026-03-01T12:27:51.362555Z","shell.execute_reply.started":"2026-03-01T10:34:12.913945Z","shell.execute_reply":"2026-03-01T12:27:51.361695Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}