{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":112509,"databundleVersionId":14254895,"isSourceIdPinned":false,"sourceType":"competition"}],"dockerImageVersionId":31236,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ============================================================\n# CSIRO Image2Biomass - Advanced CNN (Optimized for Small Data)\n# ============================================================\n# Uses MobileNet-inspired architecture + Multi-scale features\n# Specifically designed for limited data scenarios\n# 100% Offline - No Internet Required\n# ============================================================\n\nimport pandas as pd\nimport numpy as np\nimport os\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\nfrom PIL import Image\nfrom tqdm import tqdm\nfrom sklearn.model_selection import KFold\nimport math\nimport warnings\nwarnings.filterwarnings('ignore')\n\n# ============================================================\n# CONFIGURATION\n# ============================================================\nDATA_ROOT = '/kaggle/input/csiro-biomass'\nOUTPUT_DIR = '/kaggle/working/'\n\n# Hyperparameters optimized for small datasets\nNFOLD = 5\nBATCH_SIZE = 16  # Smaller batch for better generalization\nLEARNING_RATE = 1e-3\nNUM_EPOCHS = 30\nPATIENCE = 15\nSEED = 42\nIMG_SIZE = 256  # Larger images for more detail\n\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\ntorch.manual_seed(SEED)\nnp.random.seed(SEED)\nif torch.cuda.is_available():\n    torch.cuda.manual_seed_all(SEED)\n\nprint(\"=\"*70)\nprint(\"🌱 CSIRO Biomass - Advanced Multi-Scale CNN\")\nprint(\"=\"*70)\nprint(f\"🚀 Device: {device}\")\nprint(f\"🔄 K-Folds: {NFOLD}\")\nprint(f\"⚙️  Epochs: {NUM_EPOCHS}, Batch Size: {BATCH_SIZE}\")\nprint(f\"🎯 Architecture: Multi-Scale Feature Pyramid CNN\")\nprint(f\"📐 Image Size: {IMG_SIZE}x{IMG_SIZE}\")\nprint(f\"💡 Optimized for Small Datasets - 100% Offline!\")\nprint(\"=\"*70)\n\n# ============================================================\n# ADVANCED CNN ARCHITECTURE\n# ============================================================\n\nclass DepthwiseSeparableConv(nn.Module):\n    \"\"\"Efficient convolution used in MobileNet\"\"\"\n    def __init__(self, in_channels, out_channels, stride=1):\n        super().__init__()\n        self.depthwise = nn.Conv2d(in_channels, in_channels, 3, stride=stride, \n                                   padding=1, groups=in_channels, bias=False)\n        self.pointwise = nn.Conv2d(in_channels, out_channels, 1, bias=False)\n        self.bn1 = nn.BatchNorm2d(in_channels)\n        self.bn2 = nn.BatchNorm2d(out_channels)\n        self.relu = nn.ReLU6(inplace=True)\n        \n    def forward(self, x):\n        x = self.depthwise(x)\n        x = self.bn1(x)\n        x = self.relu(x)\n        x = self.pointwise(x)\n        x = self.bn2(x)\n        x = self.relu(x)\n        return x\n\nclass SqueezeExcitation(nn.Module):\n    \"\"\"Squeeze-and-Excitation block for channel attention\"\"\"\n    def __init__(self, channels, reduction=16):\n        super().__init__()\n        self.avg_pool = nn.AdaptiveAvgPool2d(1)\n        self.fc = nn.Sequential(\n            nn.Linear(channels, channels // reduction, bias=False),\n            nn.ReLU(inplace=True),\n            nn.Linear(channels // reduction, channels, bias=False),\n            nn.Sigmoid()\n        )\n        \n    def forward(self, x):\n        b, c, _, _ = x.size()\n        y = self.avg_pool(x).view(b, c)\n        y = self.fc(y).view(b, c, 1, 1)\n        return x * y.expand_as(x)\n\nclass InvertedResidualBlock(nn.Module):\n    \"\"\"MobileNetV2-style inverted residual with SE\"\"\"\n    def __init__(self, in_channels, out_channels, stride=1, expand_ratio=6):\n        super().__init__()\n        hidden_dim = in_channels * expand_ratio\n        self.use_residual = stride == 1 and in_channels == out_channels\n        \n        layers = []\n        if expand_ratio != 1:\n            # Expansion\n            layers.extend([\n                nn.Conv2d(in_channels, hidden_dim, 1, bias=False),\n                nn.BatchNorm2d(hidden_dim),\n                nn.ReLU6(inplace=True)\n            ])\n        \n        # Depthwise\n        layers.extend([\n            nn.Conv2d(hidden_dim, hidden_dim, 3, stride=stride, \n                     padding=1, groups=hidden_dim, bias=False),\n            nn.BatchNorm2d(hidden_dim),\n            nn.ReLU6(inplace=True),\n            SqueezeExcitation(hidden_dim),\n            # Projection\n            nn.Conv2d(hidden_dim, out_channels, 1, bias=False),\n            nn.BatchNorm2d(out_channels)\n        ])\n        \n        self.conv = nn.Sequential(*layers)\n        \n    def forward(self, x):\n        if self.use_residual:\n            return x + self.conv(x)\n        else:\n            return self.conv(x)\n\nclass SpatialAttention(nn.Module):\n    \"\"\"Spatial attention mechanism\"\"\"\n    def __init__(self):\n        super().__init__()\n        self.conv = nn.Sequential(\n            nn.Conv2d(2, 1, kernel_size=7, padding=3, bias=False),\n            nn.BatchNorm2d(1),\n            nn.Sigmoid()\n        )\n        \n    def forward(self, x):\n        avg_out = torch.mean(x, dim=1, keepdim=True)\n        max_out, _ = torch.max(x, dim=1, keepdim=True)\n        attention = torch.cat([avg_out, max_out], dim=1)\n        attention = self.conv(attention)\n        return x * attention\n\nclass MultiScaleCNN(nn.Module):\n    \"\"\"\n    Advanced CNN with:\n    - Inverted residuals (MobileNetV2)\n    - Squeeze-Excitation (channel attention)\n    - Spatial Attention\n    - Multi-scale feature fusion\n    \"\"\"\n    def __init__(self, num_outputs=3):\n        super().__init__()\n        \n        # Stem\n        self.stem = nn.Sequential(\n            nn.Conv2d(3, 32, 3, stride=2, padding=1, bias=False),\n            nn.BatchNorm2d(32),\n            nn.ReLU6(inplace=True)\n        )\n        \n        # Build inverted residual blocks\n        self.layer1 = self._make_layer(32, 16, num_blocks=2, stride=1, expand_ratio=1)\n        self.layer2 = self._make_layer(16, 24, num_blocks=3, stride=2, expand_ratio=6)\n        self.layer3 = self._make_layer(24, 32, num_blocks=3, stride=2, expand_ratio=6)\n        self.layer4 = self._make_layer(32, 64, num_blocks=4, stride=2, expand_ratio=6)\n        self.layer5 = self._make_layer(64, 96, num_blocks=3, stride=1, expand_ratio=6)\n        self.layer6 = self._make_layer(96, 160, num_blocks=3, stride=2, expand_ratio=6)\n        self.layer7 = self._make_layer(160, 320, num_blocks=1, stride=1, expand_ratio=6)\n        \n        # Spatial attention on final features\n        self.spatial_attn = SpatialAttention()\n        \n        # Final conv\n        self.conv_final = nn.Sequential(\n            nn.Conv2d(320, 1280, 1, bias=False),\n            nn.BatchNorm2d(1280),\n            nn.ReLU6(inplace=True)\n        )\n        \n        # Multi-scale pooling\n        self.avgpool = nn.AdaptiveAvgPool2d(1)\n        self.maxpool = nn.AdaptiveMaxPool2d(1)\n        \n        # Regression head with skip connections from multiple scales\n        self.head = nn.Sequential(\n            nn.Dropout(0.4),\n            nn.Linear(1280 * 2, 512),  # *2 for avg+max pool\n            nn.ReLU(inplace=True),\n            nn.Dropout(0.3),\n            nn.Linear(512, 256),\n            nn.ReLU(inplace=True),\n            nn.Dropout(0.2),\n            nn.Linear(256, 128),\n            nn.ReLU(inplace=True),\n            nn.Linear(128, num_outputs)\n        )\n        \n        self._initialize_weights()\n    \n    def _make_layer(self, in_channels, out_channels, num_blocks, stride, expand_ratio):\n        layers = []\n        layers.append(InvertedResidualBlock(in_channels, out_channels, stride, expand_ratio))\n        for _ in range(1, num_blocks):\n            layers.append(InvertedResidualBlock(out_channels, out_channels, 1, expand_ratio))\n        return nn.Sequential(*layers)\n    \n    def _initialize_weights(self):\n        for m in self.modules():\n            if isinstance(m, nn.Conv2d):\n                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')\n                if m.bias is not None:\n                    nn.init.constant_(m.bias, 0)\n            elif isinstance(m, nn.BatchNorm2d):\n                nn.init.constant_(m.weight, 1)\n                nn.init.constant_(m.bias, 0)\n            elif isinstance(m, nn.Linear):\n                nn.init.normal_(m.weight, 0, 0.01)\n                if m.bias is not None:\n                    nn.init.constant_(m.bias, 0)\n    \n    def forward(self, x):\n        # Encoder\n        x = self.stem(x)\n        x = self.layer1(x)\n        x = self.layer2(x)\n        x = self.layer3(x)\n        x = self.layer4(x)\n        x = self.layer5(x)\n        x = self.layer6(x)\n        x = self.layer7(x)\n        \n        # Spatial attention\n        x = self.spatial_attn(x)\n        \n        # Final features\n        x = self.conv_final(x)\n        \n        # Multi-scale pooling (combines avg and max)\n        x_avg = self.avgpool(x).flatten(1)\n        x_max = self.maxpool(x).flatten(1)\n        x = torch.cat([x_avg, x_max], dim=1)\n        \n        # Regression\n        x = self.head(x)\n        \n        return x\n\n# ============================================================\n# DATASET WITH ADVANCED AUGMENTATION\n# ============================================================\nclass BiomassDataset(Dataset):\n    def __init__(self, data, transform=None):\n        self.data = data.reset_index(drop=True)\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.data)\n\n    def __getitem__(self, idx):\n        item = self.data.iloc[idx]\n        \n        try:\n            image = Image.open(item.image_path).convert('RGB')\n        except Exception as e:\n            print(f\"⚠️  Error loading {item.image_path}: {e}\")\n            image = Image.new('RGB', (IMG_SIZE, IMG_SIZE))\n        \n        if self.transform:\n            image = self.transform(image)\n        \n        targets = [item['Dry_Green_g'], item['Dry_Clover_g'], item['Dry_Dead_g']]\n        return image, targets\n\nclass RandAugment:\n    \"\"\"Simple random augmentation\"\"\"\n    def __call__(self, img):\n        if np.random.rand() > 0.5:\n            img = transforms.functional.adjust_brightness(img, np.random.uniform(0.8, 1.2))\n        if np.random.rand() > 0.5:\n            img = transforms.functional.adjust_contrast(img, np.random.uniform(0.8, 1.2))\n        if np.random.rand() > 0.5:\n            img = transforms.functional.adjust_saturation(img, np.random.uniform(0.8, 1.2))\n        if np.random.rand() > 0.5:\n            img = transforms.functional.gaussian_blur(img, kernel_size=3)\n        return img\n\ndef create_dataloader(data, batch_size=32, shuffle=True, augment=True):\n    if augment:\n        transform = transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.RandomHorizontalFlip(p=0.5),\n            transforms.RandomVerticalFlip(p=0.5),\n            transforms.RandomRotation(30),\n            transforms.RandomAffine(degrees=0, translate=(0.15, 0.15), scale=(0.85, 1.15)),\n            transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.15),\n            RandAugment(),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n    else:\n        transform = transforms.Compose([\n            transforms.Resize((IMG_SIZE, IMG_SIZE)),\n            transforms.ToTensor(),\n            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n        ])\n    \n    dataset = BiomassDataset(data, transform=transform)\n    return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle, \n                     num_workers=2, pin_memory=True if torch.cuda.is_available() else False,\n                     drop_last=shuffle)  # Drop last for training to avoid small batches\n\n# ============================================================\n# METRICS\n# ============================================================\ndef weighted_r2_score(y_true, y_pred):\n    weights = np.array([0.1, 0.1, 0.1, 0.2, 0.5])\n    r2_scores = []\n    \n    for i in range(5):\n        y_t = y_true[:, i]\n        y_p = y_pred[:, i]\n        ss_res = np.sum((y_t - y_p) ** 2)\n        ss_tot = np.sum((y_t - np.mean(y_t)) ** 2)\n        r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0\n        r2_scores.append(r2)\n    \n    r2_scores = np.array(r2_scores)\n    weighted_r2 = np.sum(r2_scores * weights)\n    return weighted_r2, r2_scores\n\ndef calc_metric(outputs, targets):\n    y_true = np.column_stack([\n        targets,\n        targets[:, :2].sum(axis=1),\n        targets.sum(axis=1),\n    ])\n    \n    y_pred = np.column_stack([\n        outputs,\n        outputs[:, :2].sum(axis=1),\n        outputs.sum(axis=1),\n    ])\n    \n    return weighted_r2_score(y_true, y_pred)\n\n# ============================================================\n# TRAINING WITH MIXUP\n# ============================================================\ndef mixup_data(x, y, alpha=0.2):\n    \"\"\"Mixup augmentation\"\"\"\n    if alpha > 0:\n        lam = np.random.beta(alpha, alpha)\n    else:\n        lam = 1\n\n    batch_size = x.size()[0]\n    index = torch.randperm(batch_size).to(x.device)\n\n    mixed_x = lam * x + (1 - lam) * x[index, :]\n    y_a, y_b = y, y[index]\n    return mixed_x, y_a, y_b, lam\n\ndef train_epoch(model, dataloader, criterion, optimizer, device, use_mixup=True):\n    model.train()\n    total_loss = 0\n    \n    pbar = tqdm(dataloader, desc='Training', leave=False, ncols=100)\n    for images, targets in pbar:\n        images = images.to(device)\n        targets = torch.stack(targets).T.float().to(device)\n        \n        # Apply mixup\n        if use_mixup and np.random.rand() > 0.5:\n            images, targets_a, targets_b, lam = mixup_data(images, targets, alpha=0.2)\n            \n            optimizer.zero_grad()\n            outputs = model(images)\n            loss = lam * criterion(outputs, targets_a) + (1 - lam) * criterion(outputs, targets_b)\n        else:\n            optimizer.zero_grad()\n            outputs = model(images)\n            loss = criterion(outputs, targets)\n        \n        loss.backward()\n        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n        optimizer.step()\n        \n        total_loss += loss.item()\n        pbar.set_postfix({'loss': f'{loss.item():.4f}'})\n    \n    return total_loss / len(dataloader)\n\ndef validate(model, dataloader, criterion, device):\n    model.eval()\n    total_loss = 0\n    all_outputs = []\n    all_targets = []\n    \n    with torch.no_grad():\n        pbar = tqdm(dataloader, desc='Validating', leave=False, ncols=100)\n        for images, targets in pbar:\n            images = images.to(device)\n            targets = torch.stack(targets).T.float().to(device)\n            \n            outputs = model(images)\n            loss = criterion(outputs, targets)\n            total_loss += loss.item()\n            \n            all_outputs.append(outputs.cpu())\n            all_targets.append(targets.cpu())\n            pbar.set_postfix({'loss': f'{loss.item():.4f}'})\n    \n    outputs = torch.cat(all_outputs).numpy()\n    targets = torch.cat(all_targets).numpy()\n    \n    weighted_r2, r2_scores = calc_metric(outputs, targets)\n    return total_loss / len(dataloader), weighted_r2, r2_scores\n\ndef train_fold(data, fold):\n    print(f\"\\n{'='*70}\")\n    print(f\"Training Fold {fold}\")\n    print(f\"{'='*70}\")\n    \n    model = MultiScaleCNN(num_outputs=3)\n    model.to(device)\n    \n    total_params = sum(p.numel() for p in model.parameters())\n    print(f\"📊 Total parameters: {total_params:,}\")\n    \n    criterion = nn.SmoothL1Loss()\n    optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=5e-4)\n    \n    # Warmup + Cosine scheduler\n    warmup_epochs = 10\n    def lr_lambda(epoch):\n        if epoch < warmup_epochs:\n            return (epoch + 1) / warmup_epochs\n        else:\n            progress = (epoch - warmup_epochs) / (NUM_EPOCHS - warmup_epochs)\n            return 0.5 * (1 + math.cos(math.pi * progress))\n    \n    scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n    \n    train_loader = create_dataloader(\n        data[data.fold != fold], BATCH_SIZE, shuffle=True, augment=True\n    )\n    val_loader = create_dataloader(\n        data[data.fold == fold], BATCH_SIZE, shuffle=False, augment=False\n    )\n    \n    print(f\"Train: {len(train_loader.dataset)} | Val: {len(val_loader.dataset)}\")\n    \n    best_score = -float('inf')\n    epochs_without_improvement = 0\n    \n    for epoch in range(NUM_EPOCHS):\n        train_loss = train_epoch(model, train_loader, criterion, optimizer, device)\n        val_loss, weighted_r2, r2_scores = validate(model, val_loader, criterion, device)\n        scheduler.step()\n        \n        if epoch % 5 == 0 or epoch == NUM_EPOCHS - 1:\n            print(f\"Epoch {epoch:3d} | TrnLoss: {train_loss:.4f} | ValLoss: {val_loss:.4f} | \"\n                  f\"R²: {weighted_r2:.4f} | LR: {optimizer.param_groups[0]['lr']:.6f}\")\n            print(f\"         R² breakdown: Green={r2_scores[0]:.3f}, Clover={r2_scores[1]:.3f}, \"\n                  f\"Dead={r2_scores[2]:.3f}, GDM={r2_scores[3]:.3f}, Total={r2_scores[4]:.3f}\")\n        \n        if weighted_r2 > best_score:\n            best_score = weighted_r2\n            epochs_without_improvement = 0\n            torch.save(model.state_dict(), f'{OUTPUT_DIR}/model_fold{fold}.pth')\n            if epoch % 5 == 0:\n                print(f\"  ✓ New best R²: {best_score:.4f}\")\n        else:\n            epochs_without_improvement += 1\n        \n        if epochs_without_improvement >= PATIENCE:\n            print(f\"\\n⚠ Early stop at epoch {epoch}\")\n            break\n    \n    print(f\"\\nFold {fold} Best R²: {best_score:.4f}\")\n    return best_score\n\n# ============================================================\n# LOAD DATA\n# ============================================================\nprint(\"\\n\" + \"=\"*70)\nprint(\"📊 Loading Data\")\nprint(\"=\"*70)\n\ntrain_df = pd.read_csv(f'{DATA_ROOT}/train.csv')\nprint(f\"✓ Loaded {len(train_df)} training rows\")\n\ntrain_df['image_path'] = train_df['image_path'].apply(lambda x: os.path.join(DATA_ROOT, x))\ntrain_df[['sample_id_prefix', 'sample_id_suffix']] = train_df.sample_id.str.split('__', expand=True)\n\ncols = ['sample_id_prefix', 'image_path', 'Sampling_Date', 'State', 'Species', 'Pre_GSHH_NDVI', 'Height_Ave_cm']\nagg_train_df = train_df.groupby(cols).apply(\n    lambda df: df.set_index('target_name').target\n).reset_index()\nagg_train_df.columns.name = None\n\nprint(f\"✓ Training images: {len(agg_train_df)}\")\n\n# K-Fold split\nkfold = KFold(n_splits=NFOLD, shuffle=True, random_state=SEED)\nagg_train_df['fold'] = -1\nfor i, (trn_idx, val_idx) in enumerate(kfold.split(agg_train_df.index)):\n    agg_train_df.loc[val_idx, 'fold'] = i\n\n# ============================================================\n# TRAIN ALL FOLDS\n# ============================================================\nprint(\"\\n\" + \"=\"*70)\nprint(\"🚀 Starting K-Fold Training\")\nprint(\"=\"*70)\n\nall_best_scores = []\nfor fold in range(NFOLD):\n    score = train_fold(agg_train_df, fold)\n    all_best_scores.append(score)\n\nprint(f\"\\n{'='*70}\")\nprint(\"📊 Cross-Validation Results\")\nprint(f\"{'='*70}\")\nfor fold, score in enumerate(all_best_scores):\n    print(f\"Fold {fold}: {score:.4f}\")\nprint(f\"\\n✨ Average CV: {np.mean(all_best_scores):.4f} ± {np.std(all_best_scores):.4f}\")\n\n# ============================================================\n# INFERENCE WITH TTA\n# ============================================================\nprint(f\"\\n{'='*70}\")\nprint(\"🔮 Generating Predictions with Test-Time Augmentation\")\nprint(f\"{'='*70}\")\n\ntest_df = pd.read_csv(f'{DATA_ROOT}/test.csv')\ntest_df['image_path'] = test_df['image_path'].apply(lambda x: os.path.join(DATA_ROOT, x))\ntest_df['target'] = 0.0\ntest_df[['sample_id_prefix', 'sample_id_suffix']] = test_df.sample_id.str.split('__', expand=True)\n\ncols = ['sample_id_prefix', 'image_path']\nagg_test_df = test_df.groupby(cols).apply(\n    lambda df: df.set_index('target_name').target\n).reset_index()\nagg_test_df.columns.name = None\n\nprint(f\"✓ Test images: {len(agg_test_df)}\")\n\n# TTA transforms\ntta_transforms = [\n    transforms.Compose([\n        transforms.Resize((IMG_SIZE, IMG_SIZE)),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n    ]),\n    transforms.Compose([\n        transforms.Resize((IMG_SIZE, IMG_SIZE)),\n        transforms.RandomHorizontalFlip(p=1.0),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n    ]),\n]\n\ndef predict_fold_with_tta(fold):\n    model = MultiScaleCNN(num_outputs=3)\n    model.load_state_dict(torch.load(f'{OUTPUT_DIR}/model_fold{fold}.pth'))\n    model.to(device)\n    model.eval()\n    \n    tta_preds = []\n    \n    for tta_idx, tta_transform in enumerate(tta_transforms):\n        test_loader = DataLoader(\n            BiomassDataset(agg_test_df, transform=tta_transform),\n            batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True\n        )\n        \n        all_outputs = []\n        with torch.no_grad():\n            for images, _ in test_loader:\n                images = images.to(device)\n                outputs = model(images)\n                all_outputs.append(outputs.cpu())\n        \n        tta_preds.append(torch.cat(all_outputs).numpy())\n    \n    return np.mean(tta_preds, axis=0)\n\nall_preds = []\nfor fold in range(NFOLD):\n    preds = predict_fold_with_tta(fold)\n    all_preds.append(preds)\n    print(f\"✓ Fold {fold} predictions complete (with TTA)\")\n\nfinal_preds = np.mean(all_preds, axis=0)\nprint(f\"✓ Ensemble complete: {final_preds.shape}\")\n\n# ============================================================\n# CREATE SUBMISSION\n# ============================================================\nprint(f\"\\n{'='*70}\")\nprint(\"📝 Creating Submission\")\nprint(f\"{'='*70}\")\n\nagg_test_df[['Dry_Green_g', 'Dry_Clover_g', 'Dry_Dead_g']] = final_preds\nagg_test_df['GDM_g'] = agg_test_df['Dry_Green_g'] + agg_test_df['Dry_Clover_g']\nagg_test_df['Dry_Total_g'] = agg_test_df['GDM_g'] + agg_test_df['Dry_Dead_g']\n\nfor col in ['Dry_Green_g', 'Dry_Clover_g', 'Dry_Dead_g', 'GDM_g', 'Dry_Total_g']:\n    agg_test_df[col] = agg_test_df[col].clip(lower=0)\n\ncols = ['Dry_Green_g', 'Dry_Clover_g', 'Dry_Dead_g', 'GDM_g', 'Dry_Total_g']\nsub_df = agg_test_df.set_index('sample_id_prefix')[cols].stack().reset_index()\nsub_df.columns = ['sample_id_prefix', 'target_name', 'target']\nsub_df['sample_id'] = sub_df['sample_id_prefix'] + '__' + sub_df['target_name']\n\nsubmission = sub_df[['sample_id', 'target']]\n\nexpected_rows = len(agg_test_df) * 5\nassert len(submission) == expected_rows, f\"Expected {expected_rows} rows, got {len(submission)}\"\nassert not submission.isnull().any().any(), \"Submission contains NaN values\"\n\nsubmission.to_csv(f'{OUTPUT_DIR}/submission.csv', index=False)\n\nprint(f\"\\n{'='*70}\")\nprint(\"🎉 SUCCESS!\")\nprint(f\"{'='*70}\")\nprint(f\"📁 File: submission.csv\")\nprint(f\"📊 Rows: {len(submission):,}\")\nprint(f\"✨ CV Score: {np.mean(all_best_scores):.4f} ± {np.std(all_best_scores):.4f}\")\nprint(f\"🏆 Architecture: Multi-Scale CNN with SE + Spatial Attention\")\nprint(f\"🎯 TTA: 2x augmentation for robust predictions\")\nprint(f\"\\n🏁 Ready to submit!\")\nprint(\"=\"*70)","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-12-30T15:42:45.749826Z","iopub.execute_input":"2025-12-30T15:42:45.750205Z","iopub.status.idle":"2025-12-30T16:12:42.190466Z","shell.execute_reply.started":"2025-12-30T15:42:45.750183Z","shell.execute_reply":"2025-12-30T16:12:42.189588Z"}},"outputs":[],"execution_count":null}]}