{"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":"# =========================================================================================\n# STRATEGY: The Grand Blend (Multimodal 128px + Multimodal 224px + TTA)\n# GOAL: Break 0.70+ by ensembling diverse spatial resolutions\n# =========================================================================================\n\nimport os\nimport re\nimport cv2\nimport gc\nimport timm\nimport torch\nimport random\nimport tifffile as tiff\nimport numpy as np\nimport pandas as pd\nimport torch.nn as nn\nfrom pathlib import Path\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.metrics import f1_score\nfrom torch.utils.data import Dataset, DataLoader\n\n# --- 1. SETUP ---\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 = True\n    torch.backends.cudnn.benchmark = False\n\nseed_everything(42)\ndevice = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n\nBASE = Path(\"/kaggle/input/competitions/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared/train\")\nVAL_PATH = \"/kaggle/input/competitions/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared/val\"\n\nrecords = []\nfor rgb in sorted((BASE/\"RGB\").glob(\"*.png\")):\n    name = rgb.stem\n    label = name.split(\"_\")[0]\n    records.append({\n        \"id\": name, \"label\": label,\n        \"RGB_path\": str(rgb),\n        \"MS_path\": str(BASE/\"MS\"/f\"{name}.tif\"),\n        \"HS_path\": str(BASE/\"HS\"/f\"{name}.tif\")\n    })\ndf = pd.DataFrame(records)\n\nskf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\ndf[\"fold\"] = -1\nfor fold, (_, val_idx) in enumerate(skf.split(df, df[\"label\"])):\n    df.loc[val_idx, \"fold\"] = fold\n\nval_ids = sorted([f for f in os.listdir(f\"{VAL_PATH}/HS\") if re.fullmatch(r\"val_[0-9a-f]{8}\\.tif\", f)])\n\n# --- 2. DATA UTILS ---\nHS_START = 10\nHS_END = 111   \n\ndef load_rgb(path, size):\n    img = cv2.imread(path)\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    img = cv2.resize(img, (size, size))\n    return img.astype(\"float32\") / 255.0\n\ndef load_hs_clean(path):\n    hs = tiff.imread(path).astype(\"float32\")\n    return hs[:, :, HS_START:HS_END]\n\ndef compute_indices(ms):\n    red, rededge, nir = ms[:, :, 2], ms[:, :, 3], ms[:, :, 4]\n    ndvi = (nir - red) / (nir + red + 1e-6)\n    ndre = (nir - rededge) / (nir + rededge + 1e-6)\n    return np.stack([ndvi, ndre], axis=-1)\n\nlabel_map = {\"Health\": 0, \"Other\": 1, \"Rust\": 2}\ninv_map = {v: k for k, v in label_map.items()}\n\nclass MultiModalDataset(Dataset):\n    def __init__(self, df, fold, size, train=True):\n        self.train = train\n        self.size = size\n        self.df = df[df.fold != fold] if train else df[df.fold == fold]\n        \n    def __len__(self): return len(self.df)\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        rgb = torch.tensor(load_rgb(row.RGB_path, self.size)).permute(2, 0, 1).float()\n        \n        ms = tiff.imread(row.MS_path).astype(\"float32\")\n        indices = compute_indices(ms)\n        ms = torch.tensor(np.concatenate([ms, indices], axis=-1)).permute(2, 0, 1).float()\n        \n        hs = torch.tensor(load_hs_clean(row.HS_path)).permute(2, 0, 1).float()\n        return rgb, ms, hs, torch.tensor(label_map[row.label])\n\nclass TestDataset(Dataset):\n    def __init__(self, root, ids, size):\n        self.root = root\n        self.ids = ids\n        self.size = size\n        \n    def __len__(self): return len(self.ids)\n    def __getitem__(self, i):\n        name = self.ids[i]\n        rgb = torch.tensor(load_rgb(f\"{self.root}/RGB/{name.replace('.tif','.png')}\", self.size)).permute(2, 0, 1).float()\n        \n        ms = tiff.imread(f\"{self.root}/MS/{name}\")\n        indices = compute_indices(ms)\n        ms = torch.tensor(np.concatenate([ms, indices], axis=-1)).permute(2, 0, 1).float()\n        \n        hs = torch.tensor(load_hs_clean(f\"{self.root}/HS/{name}\")).permute(2, 0, 1).float()\n        return name, rgb, ms, hs\n\n# --- 3. MODEL ARCHITECTURE ---\nclass RGBEncoder(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.backbone = timm.create_model(\"convnext_tiny\", pretrained=True, num_classes=0)\n    def forward(self, x): return self.backbone(x)\n\nclass MSEncoder(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Conv2d(7, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),\n            nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),\n            nn.AdaptiveAvgPool2d(1)\n        )\n        self.fc = nn.Linear(64, 768)\n    def forward(self, x): return self.fc(self.net(x).flatten(1))\n\nclass HSEncoder(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.spectral = nn.Conv1d(101, 64, kernel_size=1)\n        self.spatial = nn.Sequential(\n            nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),\n            nn.AdaptiveAvgPool2d(1)\n        )\n        self.fc = nn.Linear(64, 768)\n    def forward(self, x):\n        B, C, H, W = x.shape\n        x = self.spectral(x.view(B, C, -1)).view(B, 64, H, W)\n        return self.fc(self.spatial(x).flatten(1))\n\nclass FusionAttention(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.gate = nn.Sequential(nn.Linear(768*3, 768), nn.ReLU(), nn.Linear(768, 3), nn.Softmax(dim=1))\n    def forward(self, r, m, h):\n        weights = self.gate(torch.cat([r, m, h], 1))\n        return weights[:, 0:1]*r + weights[:, 1:2]*m + weights[:, 2:3]*h\n\nclass FusionNet(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.rgb, self.ms, self.hs, self.fusion = RGBEncoder(), MSEncoder(), HSEncoder(), FusionAttention()\n        self.classifier = nn.Sequential(nn.Linear(768, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, 3))\n    def forward(self, rgb, ms, hs):\n        return self.classifier(self.fusion(self.rgb(rgb), self.ms(ms), self.hs(hs)))\n\n\n# --- 4. ENGINE: TRAINING & INFERENCE ---\ndef run_experiment(exp_name, img_size, epochs=25, batch_size=8):\n    print(f\"\\n{'='*50}\\n>>> STARTING EXPERIMENT: {exp_name} (RGB Size: {img_size}px)\\n{'='*50}\")\n    \n    test_loader = DataLoader(TestDataset(VAL_PATH, val_ids, img_size), batch_size=8, shuffle=False)\n    test_preds = np.zeros((len(val_ids), 3))\n    \n    for fold in range(5):\n        print(f\"\\n--- {exp_name} | FOLD {fold} ---\")\n        \n        train_loader = DataLoader(MultiModalDataset(df, fold, img_size, True), batch_size, shuffle=True, num_workers=2)\n        val_loader = DataLoader(MultiModalDataset(df, fold, img_size, False), batch_size, shuffle=False, num_workers=2)\n        \n        model = FusionNet().to(device)\n        optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)\n        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs)\n        criterion = nn.CrossEntropyLoss(label_smoothing=0.1)\n        scaler = torch.amp.GradScaler('cuda')\n        \n        best_f1 = 0\n        patience = 5; wait = 0\n        best_weights = None\n        \n        for epoch in range(epochs):\n            model.train()\n            for rgb, ms, hs, y in train_loader:\n                rgb, ms, hs, y = rgb.to(device), ms.to(device), hs.to(device), y.to(device)\n                optimizer.zero_grad()\n                with torch.amp.autocast('cuda'): loss = criterion(model(rgb, ms, hs), y)\n                scaler.scale(loss).backward()\n                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n                scaler.step(optimizer)\n                scaler.update()\n            scheduler.step()\n            \n            # Validation\n            model.eval()\n            preds, targets = [], []\n            with torch.no_grad():\n                for rgb, ms, hs, y in val_loader:\n                    preds += model(rgb.to(device), ms.to(device), hs.to(device)).argmax(1).cpu().tolist()\n                    targets += y.tolist()\n            f1 = f1_score(targets, preds, average=\"macro\")\n            print(f\"Epoch {epoch+1:02d} | Val F1: {f1:.4f}\")\n            \n            if f1 > best_f1:\n                best_f1 = f1; wait = 0\n                best_weights = model.state_dict().copy()\n            else:\n                wait += 1\n                if wait >= patience:\n                    print(\"Early stopping triggered.\")\n                    break\n        \n        # Inference for this fold (with TTA)\n        model.load_state_dict(best_weights)\n        model.eval()\n        fold_probs = []\n        \n        with torch.no_grad():\n            for names, rgb, ms, hs in test_loader:\n                rgb, ms, hs = rgb.to(device), ms.to(device), hs.to(device)\n                # TTA: Normal + Horizontal Flip\n                p1 = model(rgb, ms, hs).softmax(1)\n                p2 = model(torch.flip(rgb, [3]), torch.flip(ms, [3]), torch.flip(hs, [3])).softmax(1)\n                fold_probs.append(((p1 + p2) / 2.0).cpu().numpy())\n                \n        test_preds += np.concatenate(fold_probs) / 5.0\n        \n        # Cleanup to prevent Out-Of-Memory errors\n        del model, optimizer, scheduler, train_loader, val_loader\n        gc.collect()\n        torch.cuda.empty_cache()\n        \n    return test_preds\n\n# --- 5. THE GRAND BLEND ---\nif __name__ == \"__main__\":\n    # 1. Run 128px Model (The Anchor)\n    probs_128 = run_experiment(\"Model_128px\", img_size=128)\n    \n    # 2. Run 224px Model (The Detail Expert)\n    probs_224 = run_experiment(\"Model_224px\", img_size=224)\n    \n    # 3. Blend Probabilities\n    # We heavily trust the 128px model (0.69 LB), but mix in the 224px (0.65 LB) to catch edge cases\n    print(\"\\n>>> EXECUTING THE BLEND (70% 128px + 30% 224px)...\")\n    final_probs = (0.7 * probs_128) + (0.3 * probs_224)\n    final_preds = final_probs.argmax(axis=1)\n    \n    # 4. Create Submission\n    rows = [(val_ids[i], inv_map[final_preds[i]]) for i in range(len(val_ids))]\n    sub = pd.DataFrame(rows, columns=[\"Id\", \"Category\"])\n    sub.to_csv(\"submission.csv\", index=False)\n    \n    print(\">>> submission.csv created successfully! Ready for final evaluation.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-01T07:43:35.634770Z","iopub.execute_input":"2026-03-01T07:43:35.635048Z"}},"outputs":[],"execution_count":null}]}