{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.12.12"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":126119,"databundleVersionId":14953781,"sourceType":"competition"}],"dockerImageVersionId":31236,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true},"papermill":{"default_parameters":{},"duration":615.783206,"end_time":"2025-12-31T12:55:19.503665","environment_variables":{},"exception":true,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2025-12-31T12:45:03.720459","version":"2.6.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip install -qq torch torchvision rasterio spectral scikit-learn opencv-python pandas numpy matplotlib seaborn tqdm pillow","metadata":{"papermill":{"duration":null,"end_time":null,"exception":null,"start_time":null,"status":"pending"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom pathlib import Path\nfrom tqdm import tqdm\nimport warnings\nwarnings.filterwarnings('ignore')\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as transforms\nfrom torchvision import models\n\nimport rasterio\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import accuracy_score, precision_recall_fscore_support, confusion_matrix\nfrom sklearn.decomposition import PCA\nimport cv2","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Set random seeds for reproducibility\ndef set_seed(seed=42):\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n\nset_seed(42)\n\n# Device configuration\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"Using device: {device}\")\nif torch.cuda.is_available():\n    print(f\"GPU: {torch.cuda.get_device_name(0)}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class Config:\n    # Data paths\n    DATA_ROOT = Path(\"/kaggle/input/beyond-visible-spectrum-ai-for-agriculture-2026/Kaggle_Prepared\")\n    TRAIN_RGB = DATA_ROOT / \"train\" / \"RGB\"\n    TRAIN_MS = DATA_ROOT / \"train\" / \"MS\"\n    TRAIN_HS = DATA_ROOT / \"train\" / \"HS\"\n    VAL_RGB = DATA_ROOT / \"val\" / \"RGB\"\n    VAL_MS = DATA_ROOT / \"val\" / \"MS\"\n    VAL_HS = DATA_ROOT / \"val\" / \"HS\"\n    \n    # Output paths\n    OUTPUT_DIR = Path(\"outputs\")\n    CHECKPOINT_PATH = OUTPUT_DIR / \"best_model.pth\"\n    SUBMISSION_PATH = OUTPUT_DIR / \"submission.csv\"\n    \n    # Create directories\n    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n    \n    # Model parameters\n    NUM_CLASSES = 3\n    CLASSES = [\"Health\", \"Rust\", \"Other\"]\n    CLASS_TO_IDX = {\"Health\": 0, \"Rust\": 1, \"Other\": 2}\n    \n    # Training hyperparameters\n    BATCH_SIZE = 16\n    NUM_EPOCHS = 50\n    LEARNING_RATE = 1e-4\n    WEIGHT_DECAY = 1e-5\n    NUM_WORKERS = 2\n    \n    # Data parameters\n    RGB_CHANNELS = 3\n    MS_CHANNELS = 5\n    HS_CHANNELS = 125\n    HS_REDUCED = 30  # Reduced HS channels using PCA\n    \n    # Image size (assuming all images are same size)\n    IMG_SIZE = None  # Will be determined from data\n    \n    # Fusion parameters\n    USE_FUSION = True\n    MODALITIES = [\"rgb\", \"ms\", \"hs\"]  # Which modalities to use\n\nconfig = Config()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def explore_data():\n    \"\"\"Explore the dataset structure and visualize samples\"\"\"\n    print(\"=\" * 60)\n    print(\"DATA EXPLORATION\")\n    print(\"=\" * 60)\n    \n    # Check training data\n    train_rgb_files = sorted(list(config.TRAIN_RGB.glob(\"*.png\")))\n    train_ms_files = sorted(list(config.TRAIN_MS.glob(\"*.tif\")))\n    train_hs_files = sorted(list(config.TRAIN_HS.glob(\"*.tif\")))\n    \n    print(f\"\\nTraining samples:\")\n    print(f\"  RGB: {len(train_rgb_files)}\")\n    print(f\"  Multispectral: {len(train_ms_files)}\")\n    print(f\"  Hyperspectral: {len(train_hs_files)}\")\n    \n    # Check validation data\n    val_rgb_files = sorted(list(config.VAL_RGB.glob(\"*.png\")))\n    val_ms_files = sorted(list(config.VAL_MS.glob(\"*.tif\")))\n    val_hs_files = sorted(list(config.VAL_HS.glob(\"*.tif\")))\n    \n    print(f\"\\nValidation samples:\")\n    print(f\"  RGB: {len(val_rgb_files)}\")\n    print(f\"  Multispectral: {len(val_ms_files)}\")\n    print(f\"  Hyperspectral: {len(val_hs_files)}\")\n    \n    # Analyze class distribution in training set\n    class_counts = {\"Health\": 0, \"Rust\": 0, \"Other\": 0}\n    for file in train_rgb_files:\n        filename = file.stem\n        for class_name in config.CLASSES:\n            if class_name.lower() in filename.lower():\n                class_counts[class_name] += 1\n                break\n    \n    print(f\"\\nClass distribution (Training):\")\n    for class_name, count in class_counts.items():\n        print(f\"  {class_name}: {count}\")\n    \n    # Visualize sample images\n    if len(train_rgb_files) > 0:\n        fig, axes = plt.subplots(2, 3, figsize=(15, 10))\n        fig.suptitle(\"Sample Images from Each Modality\", fontsize=16)\n        \n        for idx in range(min(2, len(train_rgb_files))):\n            # Load RGB\n            rgb_img = Image.open(train_rgb_files[idx])\n            axes[idx, 0].imshow(rgb_img)\n            axes[idx, 0].set_title(f\"RGB - {train_rgb_files[idx].stem}\")\n            axes[idx, 0].axis('off')\n            \n            # Load MS (show first 3 bands as RGB)\n            if idx < len(train_ms_files):\n                with rasterio.open(train_ms_files[idx]) as src:\n                    ms_data = src.read()  # Shape: (channels, height, width)\n                    ms_rgb = ms_data[:3].transpose(1, 2, 0)  # Use first 3 bands\n                    ms_rgb = (ms_rgb - ms_rgb.min()) / (ms_rgb.max() - ms_rgb.min())\n                    axes[idx, 1].imshow(ms_rgb)\n                    axes[idx, 1].set_title(f\"MS (5 bands) - Bands 1-3\")\n                    axes[idx, 1].axis('off')\n            \n            # Load HS (show first 3 bands as RGB)\n            if idx < len(train_hs_files):\n                with rasterio.open(train_hs_files[idx]) as src:\n                    hs_data = src.read()  # Shape: (channels, height, width)\n                    hs_rgb = hs_data[:3].transpose(1, 2, 0)\n                    hs_rgb = (hs_rgb - hs_rgb.min()) / (hs_rgb.max() - hs_rgb.min())\n                    axes[idx, 2].imshow(hs_rgb)\n                    axes[idx, 2].set_title(f\"HS (125 bands) - Bands 1-3\")\n                    axes[idx, 2].axis('off')\n        \n        plt.tight_layout()\n        plt.savefig(config.OUTPUT_DIR / \"sample_images.png\", dpi=150)\n        plt.show()\n        print(f\"\\nSample images saved to {config.OUTPUT_DIR / 'sample_images.png'}\")\n    \n    return train_rgb_files, class_counts\n\n# Run data exploration\ntrain_files, class_counts = explore_data()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.model_selection import train_test_split\n\ndef split_train_validation_from_train(\n    train_rgb_dir,\n    val_ratio=0.2,\n    seed=42\n):\n    \"\"\"\n    Split TRAIN folder into train / internal validation\n    Ensure balanced labels using stratified split\n    \"\"\"\n    train_rgb_dir = Path(train_rgb_dir)\n    rgb_files = sorted(list(train_rgb_dir.glob(\"*.png\")))\n\n    filenames = []\n    labels = []\n\n    for f in rgb_files:\n        name = f.stem.lower()\n        if \"health\" in name:\n            labels.append(0)\n        elif \"rust\" in name:\n            labels.append(1)\n        elif \"other\" in name:\n            labels.append(2)\n        else:\n            continue\n\n        filenames.append(f.stem)\n\n    train_ids, val_ids = train_test_split(\n        filenames,\n        test_size=val_ratio,\n        random_state=seed,\n        stratify=labels\n    )\n\n    print(\"\\nInternal validation split (from TRAIN):\")\n    print(f\"  Train samples: {len(train_ids)}\")\n    print(f\"  Val samples:   {len(val_ids)}\")\n\n    return set(train_ids), set(val_ids)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class WheatDiseaseDataset(Dataset):\n    \"\"\"Dataset for multimodal wheat disease classification\"\"\"\n    \n    def __init__(self, rgb_dir, ms_dir, hs_dir, modalities=[\"rgb\", \"ms\", \"hs\"], \n             transform=None, is_train=True, hs_pca=None, hs_bands=125,\n             allowed_ids=None):\n\n        self.rgb_dir = Path(rgb_dir)\n        self.ms_dir = Path(ms_dir)\n        self.hs_dir = Path(hs_dir)\n        self.modalities = modalities\n        self.transform = transform\n        self.is_train = is_train\n        self.hs_pca = hs_pca\n        self.hs_bands = hs_bands  # Actual number of HS bands to read\n        \n        # Get file list\n        all_rgb = sorted(list(self.rgb_dir.glob(\"*.png\")))\n\n        if allowed_ids is not None:\n            self.rgb_files = [f for f in all_rgb if f.stem in allowed_ids]\n        else:\n            self.rgb_files = all_rgb\n        \n        \n        # Extract labels from filenames (training only)\n        self.labels = []\n        if is_train:\n            for file in self.rgb_files:\n                filename = file.stem.lower()\n                if \"health\" in filename:\n                    self.labels.append(0)\n                elif \"rust\" in filename:\n                    self.labels.append(1)\n                elif \"other\" in filename:\n                    self.labels.append(2)\n                else:\n                    self.labels.append(-1)  # Unknown\n        else:\n            self.labels = [-1] * len(self.rgb_files)\n    \n    def __len__(self):\n        return len(self.rgb_files)\n    \n    def __getitem__(self, idx):\n        sample = {}\n        filename = self.rgb_files[idx].stem\n        \n        # Load RGB\n        if \"rgb\" in self.modalities:\n            rgb_img = Image.open(self.rgb_files[idx]).convert('RGB')\n            rgb_array = np.array(rgb_img).astype(np.float32) / 255.0\n            sample['rgb'] = torch.FloatTensor(rgb_array).permute(2, 0, 1)\n        \n        # Load Multispectral\n        if \"ms\" in self.modalities:\n            ms_path = self.ms_dir / f\"{filename}.tif\"\n            with rasterio.open(ms_path) as src:\n                ms_data = src.read().astype(np.float32)  # (5, H, W)\n                # Normalize\n                ms_data = (ms_data - ms_data.min()) / (ms_data.max() - ms_data.min() + 1e-8)\n                sample['ms'] = torch.FloatTensor(ms_data)\n        \n        # Load Hyperspectral\n        if \"hs\" in self.modalities:\n            hs_path = self.hs_dir / f\"{filename}.tif\"\n            with rasterio.open(hs_path) as src:\n                # Read only the first hs_bands to ensure consistency\n                available_bands = src.count\n                bands_to_read = min(self.hs_bands, available_bands)\n                hs_data = src.read(range(1, bands_to_read + 1)).astype(np.float32)\n                \n                # Apply PCA for dimensionality reduction\n                if self.hs_pca is not None:\n                    h, w = hs_data.shape[1], hs_data.shape[2]\n                    hs_reshaped = hs_data.reshape(hs_data.shape[0], -1).T  # (H*W, bands)\n                    hs_reduced = self.hs_pca.transform(hs_reshaped)  # (H*W, 30)\n                    hs_data = hs_reduced.T.reshape(-1, h, w)  # (30, H, W)\n                \n                # Normalize\n                hs_data = (hs_data - hs_data.min()) / (hs_data.max() - hs_data.min() + 1e-8)\n                sample['hs'] = torch.FloatTensor(hs_data)\n        \n        # Apply transforms if any\n        if self.transform:\n            sample = self.transform(sample)\n        \n        sample['label'] = self.labels[idx]\n        sample['filename'] = filename\n        \n        return sample","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def create_hs_pca(train_hs_dir, n_components=30, n_samples=50):\n    \"\"\"Create PCA model for hyperspectral dimensionality reduction\"\"\"\n    print(\"\\nCreating PCA model for hyperspectral data...\")\n    hs_files = sorted(list(Path(train_hs_dir).glob(\"*.tif\")))[:n_samples]\n    \n    # First pass: find the minimum number of bands across all files\n    min_bands = float('inf')\n    all_shapes = []\n    for file in hs_files:\n        with rasterio.open(file) as src:\n            n_bands = src.count\n            min_bands = min(min_bands, n_bands)\n            all_shapes.append((n_bands, src.height, src.width))\n    \n    print(f\"Found band counts ranging from {min([s[0] for s in all_shapes])} to {max([s[0] for s in all_shapes])}\")\n    print(f\"Using {min_bands} bands for PCA (minimum across all files)\")\n    \n    # Update config with actual band count\n    config.HS_CHANNELS = min_bands\n    if n_components > min_bands:\n        n_components = min_bands - 1\n        print(f\"Adjusted n_components to {n_components} (must be < {min_bands})\")\n    \n    # Second pass: load data using only the first min_bands\n    all_data = []\n    for file in tqdm(hs_files, desc=\"Loading HS data for PCA\"):\n        with rasterio.open(file) as src:\n            # Read only the first min_bands\n            hs_data = src.read(range(1, min_bands + 1)).astype(np.float32)\n            hs_reshaped = hs_data.reshape(hs_data.shape[0], -1).T  # (H*W, bands)\n            all_data.append(hs_reshaped)\n    \n    all_data = np.vstack(all_data)\n    print(f\"PCA input shape: {all_data.shape}\")\n    \n    pca = PCA(n_components=n_components)\n    pca.fit(all_data)\n    \n    explained_var = np.sum(pca.explained_variance_ratio_)\n    print(f\"PCA: {n_components} components explain {explained_var*100:.2f}% of variance\")\n    \n    return pca, min_bands\n\n# Create PCA model for hyperspectral data\nif \"hs\" in config.MODALITIES:\n    hs_pca, actual_hs_bands = create_hs_pca(config.TRAIN_HS, n_components=config.HS_REDUCED)\n    print(f\"Actual HS bands used: {actual_hs_bands}\")\nelse:\n    hs_pca = None\n    actual_hs_bands = None\n\n# Create datasets\n# Split TRAIN into train / internal validation\ntrain_ids, val_ids = split_train_validation_from_train(\n    config.TRAIN_RGB,\n    val_ratio=0.2,\n    seed=42\n)\n\ntrain_dataset = WheatDiseaseDataset(\n    config.TRAIN_RGB,\n    config.TRAIN_MS,\n    config.TRAIN_HS,\n    modalities=config.MODALITIES,\n    is_train=True,\n    hs_pca=hs_pca,\n    hs_bands=actual_hs_bands,\n    allowed_ids=train_ids\n)\n\nval_dataset = WheatDiseaseDataset(\n    config.TRAIN_RGB,\n    config.TRAIN_MS,\n    config.TRAIN_HS,\n    modalities=config.MODALITIES,\n    is_train=True,\n    hs_pca=hs_pca,\n    hs_bands=actual_hs_bands,\n    allowed_ids=val_ids\n)\n\ntrain_loader = DataLoader(\n    train_dataset,\n    batch_size=config.BATCH_SIZE,\n    shuffle=True,\n    num_workers=config.NUM_WORKERS,\n    pin_memory=True\n)\n\nval_loader = DataLoader(\n    val_dataset,\n    batch_size=config.BATCH_SIZE,\n    shuffle=False,\n    num_workers=config.NUM_WORKERS,\n    pin_memory=True\n)\n\nprint(f\"\\nInternal datasets created:\")\nprint(f\"  Train samples: {len(train_dataset)}\")\nprint(f\"  Val samples:   {len(val_dataset)}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class MultimodalBranch(nn.Module):\n    \"\"\"Single branch for processing one modality\"\"\"\n    def __init__(self, in_channels, feature_dim=512):\n        super().__init__()\n        \n        # Use ResNet18 as backbone\n        if in_channels == 3:\n            # RGB: use pretrained ResNet\n            backbone = models.resnet18(pretrained=True)\n            self.features = nn.Sequential(*list(backbone.children())[:-1])\n        else:\n            # MS/HS: modify first conv layer\n            backbone = models.resnet18(pretrained=False)\n            backbone.conv1 = nn.Conv2d(in_channels, 64, kernel_size=7, stride=2, padding=3, bias=False)\n            self.features = nn.Sequential(*list(backbone.children())[:-1])\n        \n        self.fc = nn.Linear(512, feature_dim)\n    \n    def forward(self, x):\n        x = self.features(x)\n        x = x.view(x.size(0), -1)\n        x = self.fc(x)\n        return x","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class MultimodalFusionModel(nn.Module):\n    \"\"\"Multimodal fusion model for wheat disease classification\"\"\"\n    def __init__(self, modalities=[\"rgb\", \"ms\", \"hs\"], num_classes=3, feature_dim=512):\n        super().__init__()\n        self.modalities = modalities\n        \n        # Create branches for each modality\n        self.branches = nn.ModuleDict()\n        if \"rgb\" in modalities:\n            self.branches['rgb'] = MultimodalBranch(3, feature_dim)\n        if \"ms\" in modalities:\n            self.branches['ms'] = MultimodalBranch(5, feature_dim)\n        if \"hs\" in modalities:\n            self.branches['hs'] = MultimodalBranch(config.HS_REDUCED, feature_dim)\n        \n        # Fusion layer\n        total_features = feature_dim * len(modalities)\n        self.fusion = nn.Sequential(\n            nn.Linear(total_features, 512),\n            nn.ReLU(),\n            nn.Dropout(0.5),\n            nn.Linear(512, 256),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(256, num_classes)\n        )\n    \n    def forward(self, batch):\n        features = []\n        \n        for modality in self.modalities:\n            if modality in batch:\n                feat = self.branches[modality](batch[modality])\n                features.append(feat)\n        \n        # Concatenate features\n        fused = torch.cat(features, dim=1)\n        \n        # Classification\n        output = self.fusion(fused)\n        return output\n\n# Create model\nmodel = MultimodalFusionModel(\n    modalities=config.MODALITIES,\n    num_classes=config.NUM_CLASSES,\n    feature_dim=512\n).to(device)\n\n# Count parameters\ntotal_params = sum(p.numel() for p in model.parameters())\ntrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\nprint(f\"\\nModel created:\")\nprint(f\"  Total parameters: {total_params:,}\")\nprint(f\"  Trainable parameters: {trainable_params:,}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Loss function with class weights\nclass_weights = torch.FloatTensor([1.0, 1.0, 1.0])  # Adjust based on class distribution\nif sum(class_counts.values()) > 0:\n    total = sum(class_counts.values())\n    class_weights = torch.FloatTensor([\n        total / (class_counts[\"Health\"] * 3),\n        total / (class_counts[\"Rust\"] * 3),\n        total / (class_counts[\"Other\"] * 3)\n    ])\ncriterion = nn.CrossEntropyLoss(weight=class_weights.to(device))\n\n# Optimizer and scheduler\noptimizer = torch.optim.Adam(model.parameters(), lr=config.LEARNING_RATE, weight_decay=config.WEIGHT_DECAY)\nscheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', patience=5, factor=0.5)\n\nprint(\"\\nTraining setup:\")\nprint(f\"  Loss function: CrossEntropyLoss\")\nprint(f\"  Class weights: {class_weights.numpy()}\")\nprint(f\"  Optimizer: Adam (lr={config.LEARNING_RATE})\")\nprint(f\"  Scheduler: ReduceLROnPlateau\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_epoch(model, loader, criterion, optimizer, device):\n    \"\"\"Train for one epoch\"\"\"\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n\n    pbar = tqdm(loader, desc=\"Training\")\n    for batch in pbar:\n        # Move data to device\n        for key, value in batch.items():\n            if isinstance(value, torch.Tensor):\n                batch[key] = value.to(device)\n\n        labels = batch[\"label\"]\n\n        optimizer.zero_grad()\n        outputs = model(batch)\n        loss = criterion(outputs, labels)\n\n        loss.backward()\n        optimizer.step()\n\n        running_loss += loss.item()\n        _, predicted = outputs.max(1)\n        total += labels.size(0)\n        correct += predicted.eq(labels).sum().item()\n\n        pbar.set_postfix(\n            loss=running_loss / (pbar.n + 1),\n            acc=100.0 * correct / total\n        )\n\n    epoch_loss = running_loss / len(loader)\n    epoch_acc = 100.0 * correct / total\n\n    return epoch_loss, epoch_acc\n\n\ndef evaluate(model, loader, criterion, device):\n    \"\"\"Evaluate on INTERNAL validation set (always labeled)\"\"\"\n    model.eval()\n    running_loss = 0.0\n    all_preds = []\n    all_labels = []\n\n    with torch.no_grad():\n        for batch in tqdm(loader, desc=\"Validating\"):\n            # Move data to device\n            for key, value in batch.items():\n                if isinstance(value, torch.Tensor):\n                    batch[key] = value.to(device)\n\n            labels = batch[\"label\"]\n\n            outputs = model(batch)\n            loss = criterion(outputs, labels)\n\n            running_loss += loss.item()\n            _, predicted = outputs.max(1)\n\n            all_preds.extend(predicted.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n\n    epoch_loss = running_loss / len(loader)\n\n    accuracy = accuracy_score(all_labels, all_preds)\n    precision, recall, f1, _ = precision_recall_fscore_support(\n        all_labels,\n        all_preds,\n        average=\"weighted\",\n        zero_division=0\n    )\n\n    return epoch_loss, accuracy * 100, precision, recall, f1\n\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"STARTING TRAINING\")\nprint(\"=\" * 60)\n\nbest_acc = 0.0\ntrain_losses, train_accs = [], []\nval_losses, val_accs = [], []\n\nfor epoch in range(config.NUM_EPOCHS):\n    print(f\"\\nEpoch {epoch + 1}/{config.NUM_EPOCHS}\")\n    print(\"-\" * 60)\n\n    # Train\n    train_loss, train_acc = train_epoch(\n        model, train_loader, criterion, optimizer, device\n    )\n    train_losses.append(train_loss)\n    train_accs.append(train_acc)\n\n    # Validate (always labeled)\n    val_loss, val_acc, val_prec, val_rec, val_f1 = evaluate(\n        model, val_loader, criterion, device\n    )\n    val_losses.append(val_loss)\n    val_accs.append(val_acc)\n\n    print(f\"\\nTrain Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}%\")\n    print(f\"Val   Loss: {val_loss:.4f} | Val   Acc: {val_acc:.2f}%\")\n    print(\n        f\"Val Precision: {val_prec:.4f} | \"\n        f\"Val Recall: {val_rec:.4f} | \"\n        f\"Val F1: {val_f1:.4f}\"\n    )\n\n    # Scheduler based on validation accuracy\n    scheduler.step(val_acc)\n\n    current_lr = optimizer.param_groups[0][\"lr\"]\n    print(f\"Learning Rate: {current_lr:.6f}\")\n\n    # Save best model\n    if val_acc > best_acc:\n        best_acc = val_acc\n        torch.save(\n            {\n                \"epoch\": epoch,\n                \"model_state_dict\": model.state_dict(),\n                \"optimizer_state_dict\": optimizer.state_dict(),\n                \"best_acc\": best_acc,\n                \"class_weights\": class_weights,\n            },\n            config.CHECKPOINT_PATH,\n        )\n        print(f\"✓ Best model saved! (Val Acc: {best_acc:.2f}%)\")\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"TRAINING COMPLETED\")\nprint(f\"Best Validation Accuracy: {best_acc:.2f}%\")\nprint(\"=\" * 60)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Plot training curves\nfig, axes = plt.subplots(1, 2, figsize=(15, 5))\n\n# Loss curve\naxes[0].plot(train_losses, label='Train Loss', marker='o')\naxes[0].plot(val_losses, label='Val Loss', marker='s')\naxes[0].set_xlabel('Epoch')\naxes[0].set_ylabel('Loss')\naxes[0].set_title('Training and Validation Loss')\naxes[0].legend()\naxes[0].grid(True)\n\n# Accuracy curve\naxes[1].plot(train_accs, label='Train Acc', marker='o')\naxes[1].plot(val_accs, label='Val Acc', marker='s')\naxes[1].set_xlabel('Epoch')\naxes[1].set_ylabel('Accuracy (%)')\naxes[1].set_title('Training and Validation Accuracy')\naxes[1].legend()\naxes[1].grid(True)\n\nplt.tight_layout()\nplt.savefig(config.OUTPUT_DIR / \"training_curves.png\", dpi=150)\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Load best model\ncheckpoint = torch.load(config.CHECKPOINT_PATH, map_location=device)\nmodel.load_state_dict(checkpoint['model_state_dict'])\nprint(f\"\\nBest model loaded from epoch {checkpoint['epoch'] + 1}\")\n\n# Evaluate on validation set\nval_loss, val_acc, val_prec, val_rec, val_f1 = evaluate(\n    model, val_loader, criterion, device\n)\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"FINAL EVALUATION RESULTS\")\nprint(\"=\" * 60)\nprint(f\"Validation Loss: {val_loss:.4f}\")\nprint(f\"Validation Accuracy: {val_acc:.2f}%\")\nprint(f\"Validation Precision: {val_prec:.4f}\")\nprint(f\"Validation Recall: {val_rec:.4f}\")\nprint(f\"Validation F1-Score: {val_f1:.4f}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def generate_submission(model, val_loader, device, output_path):\n    \"\"\"Generate submission file for validation set\"\"\"\n    model.eval()\n    \n    predictions = []\n    filenames = []\n    \n    with torch.no_grad():\n        for batch in tqdm(val_loader, desc=\"Generating predictions\"):\n            # Move data to device\n            for key in batch:\n                if isinstance(batch[key], torch.Tensor):\n                    batch[key] = batch[key].to(device)\n            \n            # Forward pass\n            outputs = model(batch)\n            _, predicted = outputs.max(1)\n            \n            predictions.extend(predicted.cpu().numpy())\n            filenames.extend(batch['filename'])\n    \n    # Create submission dataframe\n    submission = pd.DataFrame({\n        'Id': [f\"{fname}.tif\" for fname in filenames],\n        'Category': [config.CLASSES[pred] for pred in predictions]\n    })\n    \n    # Save to CSV\n    submission.to_csv(output_path, index=False)\n    print(f\"\\nSubmission file saved to: {output_path}\")\n    print(f\"Total predictions: {len(submission)}\")\n    print(\"\\nPrediction distribution:\")\n    print(submission['Category'].value_counts())\n    \n    return submission\n\nsubmission_dataset = WheatDiseaseDataset(\n    config.VAL_RGB,\n    config.VAL_MS,\n    config.VAL_HS,\n    modalities=config.MODALITIES,\n    is_train=False,\n    hs_pca=hs_pca,\n    hs_bands=actual_hs_bands\n)\n\nsubmission_loader = DataLoader(\n    submission_dataset,\n    batch_size=config.BATCH_SIZE,\n    shuffle=False,\n    num_workers=config.NUM_WORKERS,\n    pin_memory=True\n)\n\n\n# Generate submission\nsubmission = generate_submission(\n    model,\n    submission_loader,\n    device,\n    config.SUBMISSION_PATH\n)\n\n\n# Display sample predictions\nprint(\"\\n\" + \"=\"*60)\nprint(\"SAMPLE PREDICTIONS\")\nprint(\"=\"*60)\nprint(submission.head(10))\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"ALL DONE! 🎉\")\nprint(\"=\"*60)\nprint(f\"\\nOutputs saved to: {config.OUTPUT_DIR}\")\nprint(f\"  - Best model: {config.CHECKPOINT_PATH}\")\nprint(f\"  - Submission: {config.SUBMISSION_PATH}\")\nprint(f\"  - Training curves: {config.OUTPUT_DIR / 'training_curves.png'}\")\nprint(f\"  - Confusion matrix: {config.OUTPUT_DIR / 'confusion_matrix.png'}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}