{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","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"},{"sourceId":13733028,"sourceType":"datasetVersion","datasetId":8737641}],"dockerImageVersionId":31153,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"##### 🌾 CSIRO Biomass Prediction - Two-Stream ConvNeXt Inference\n\n### Reference: https://www.kaggle.com/code/none00000/lb-0-57-infer-model-code\n### training: https://www.kaggle.com/code/takahitomizunobyts/convnext-training-analysis-notebook\n\n\n## 📊 Performance\n- **Public LB Score:** 0.61\n- **Model:** ConvNeXt-Tiny with Two-Stream Architecture\n- **Ensemble:** 5-Fold + 3-View TTA\n\n---\n\n## 🎯 What's in This Notebook?\n\nThis inference notebook implements a **two-stream architecture** for predicting pasture biomass from top-view images. Key features:\n\n- ✅ **Two-Stream CNN**: Processes left/right image halves independently\n- ✅ **Three-Head Regression**: Dedicated heads for Total, GDM, and Green biomass\n- ✅ **5-Fold Ensemble**: Robust predictions through cross-validation\n- ✅ **Test-Time Augmentation**: Original + HFlip + VFlip (3 views)\n- ✅ **Clean & Documented Code**: Easy to understand and modify\n\n---\n\n## 💬 Feedback & Discussion\n\nIf you find this notebook helpful:\n- 👍 **Please upvote** to support my work\n- 💬 **Leave a comment** with your questions or suggestions\n- 🔔 **Follow me** for more competitions and insights\n\n**Questions? Issues?** Feel free to comment below, and I'll respond ASAP!\n\n","metadata":{}},{"cell_type":"code","source":"\"\"\"\nCSIRO Biomass Competition - Inference Pipeline (TTA + Ensemble)\n================================================================================\nThis script performs predictions on test data using trained models.\n\nPipeline Overview:\n1. Test Data Preparation: Load CSV → Extract unique images\n2. Model Loading: Load 5-Fold trained models\n3. TTA Inference: 3 Views × 5-Fold Ensemble\n4. Post-processing: 3 predictions → Reconstruct 5 targets\n5. Submission Creation: Wide format → Long format conversion\n\"\"\"\n\nfrom __future__ import annotations\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Optional\nfrom collections import OrderedDict\nimport os\nimport gc\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport timm\nimport cv2\nfrom tqdm import tqdm\n\n\n# ============================================================================\n# Configuration Management\n# ============================================================================\n\n@dataclass\nclass InferenceConfig:\n    \"\"\"\n    Data class for managing inference pipeline configuration.\n    \n    Items that must match training configuration:\n    - model_name\n    - img_size\n    - target column names\n    \"\"\"\n    \n    # Path settings\n    base_path: Path = Path('/kaggle/input/csiro-biomass')\n    test_csv: Path = field(init=False)\n    test_image_dir: Path = field(init=False)\n    model_dir: Path = Path('/kaggle/input/coatnet-exp2/coatnet_exp2')\n    submission_file: str = 'submission.csv'\n    \n    # Model settings (must match training)\n    model_name: str = \"coatnet_rmlp_2_rw_384.sw_in12k_ft_in1k\"\n    img_size: int = 384\n    \n    # Device settings\n    device: torch.device = field(default_factory=lambda: torch.device(\n        'cuda' if torch.cuda.is_available() else 'cpu'\n    ))\n    \n    # Inference settings\n    batch_size: int = 1\n    num_workers: int = 1\n    n_folds: int = 5\n    \n    # Target settings (must match training)\n    train_target_cols: list[str] = field(default_factory=lambda: [\n        'Dry_Total_g', 'GDM_g', 'Dry_Green_g'\n    ])\n    \n    all_target_cols: list[str] = field(default_factory=lambda: [\n        'Dry_Green_g', 'Dry_Dead_g', 'Dry_Clover_g', 'GDM_g', 'Dry_Total_g'\n    ])\n    \n    def __post_init__(self) -> None:\n        \"\"\"Construct paths after initialization\"\"\"\n        self.test_csv = self.base_path / 'test.csv'\n        self.test_image_dir = self.base_path / 'test'\n    \n    def display_info(self) -> None:\n        \"\"\"Display configuration information\"\"\"\n        print(f\"{'='*70}\")\n        print(f\"Inference Configuration\")\n        print(f\"{'='*70}\")\n        print(f\"Device: {self.device}\")\n        print(f\"Backbone: {self.model_name}\")\n        print(f\"Image Size: {self.img_size}x{self.img_size}\")\n        print(f\"Batch Size: {self.batch_size}\")\n        print(f\"Ensemble: {self.n_folds}-Fold\")\n        print(f\"TTA: 3 Views (Original, HFlip, VFlip)\")\n        print(f\"{'='*70}\\n\")\n\n\n# ============================================================================\n# TTA Augmentation\n# ============================================================================\n\nclass TTATransformFactory:\n    \"\"\"\n    Factory class for generating Test Time Augmentation transforms.\n    \n    Provides 3 different views:\n    1. Original (no augmentation)\n    2. Horizontal flip\n    3. Vertical flip\n    \"\"\"\n    \n    def __init__(self, img_size: int):\n        \"\"\"\n        Args:\n            img_size: Image size after resizing\n        \"\"\"\n        self.img_size = img_size\n        \n        # Base transforms common to all views\n        self.base_transforms = [\n            A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n            ToTensorV2()\n        ]\n    \n    def get_tta_transforms(self) -> list[A.Compose]:\n        \"\"\"\n        Generate 3 transform pipelines for TTA.\n        \n        Returns:\n            List of 3 Albumentations Compose objects\n            \n        Why not: Not adding more TTA variations\n            → Considering trade-off with inference time\n        \"\"\"\n        # View 1: Original\n        original = A.Compose([\n            A.Resize(self.img_size, self.img_size),\n            *self.base_transforms\n        ])\n        \n        # View 2: Horizontal flip\n        hflip = A.Compose([\n            A.HorizontalFlip(p=1.0),\n            A.Resize(self.img_size, self.img_size),\n            *self.base_transforms\n        ])\n        \n        # View 3: Vertical flip\n        vflip = A.Compose([\n            A.VerticalFlip(p=1.0),\n            A.Resize(self.img_size, self.img_size),\n            *self.base_transforms\n        ])\n        \n        return [original, hflip, vflip]\n\n\n# ============================================================================\n# Dataset\n# ============================================================================\n\nclass TestBiomassDataset(Dataset):\n    \"\"\"\n    Two-stream dataset for testing.\n    \n    Accepts a specific transform pipeline for TTA and applies\n    the same augmentation to both left and right images.\n    \n    Returns:\n        tuple: (img_left, img_right)\n    \"\"\"\n    \n    def __init__(\n        self,\n        df: pd.DataFrame,\n        transform_pipeline: A.Compose,\n        image_dir: Path\n    ):\n        \"\"\"\n        Args:\n            df: DataFrame containing image paths\n            transform_pipeline: Augmentation pipeline to apply\n            image_dir: Image directory path\n        \"\"\"\n        self.df = df\n        self.transform = transform_pipeline\n        self.image_dir = image_dir\n        self.image_paths = df['image_path'].values\n    \n    def __len__(self) -> int:\n        return len(self.df)\n    \n    def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:\n        \"\"\"\n        Get one sample.\n        \n        Args:\n            idx: Sample index\n            \n        Returns:\n            (left_image, right_image)\n            \n        Why not: Not applying different augmentations to left/right as in training\n            → During TTA, apply same transform to both images to preserve symmetry\n        \"\"\"\n        img_path = self.image_paths[idx]\n        full_path = self.image_dir / Path(img_path).name\n        \n        # Load image (return black image on error)\n        image = cv2.imread(str(full_path))\n        \n        if image is None:\n            print(f\"Warning: Failed to load image: {full_path} → Returning black image\")\n            image = np.zeros((1000, 2000, 3), dtype=np.uint8)\n        \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        # Split into left and right\n        height, width = image.shape[:2]\n        mid_point = width // 2\n        img_left = image[:, :mid_point]\n        img_right = image[:, mid_point:]\n        \n        # Apply same transform to both\n        img_left_tensor = self.transform(image=img_left)['image']\n        img_right_tensor = self.transform(image=img_right)['image']\n        \n        return img_left_tensor, img_right_tensor\n\n\n# ============================================================================\n# Model\n# ============================================================================\n\nclass BiomassModel(nn.Module):\n    \"\"\"\n    Two-stream, three-head regression model.\n    \n    Exactly the same architecture as during training.\n    \"\"\"\n    \n    def __init__(self, model_name: str, pretrained: bool = False):\n        \"\"\"\n        Args:\n            model_name: timm model name\n            pretrained: Always False (weights loaded separately)\n        \"\"\"\n        super().__init__()\n        \n        # Shared backbone\n        self.backbone = timm.create_model(\n            model_name,\n            pretrained=pretrained,\n            num_classes=0,\n            global_pool='avg'\n        )\n        \n        self.n_features = self.backbone.num_features\n        self.n_combined = self.n_features * 2\n        \n        # Three dedicated heads\n        self.head_total = self._create_head()\n        self.head_gdm = self._create_head()\n        self.head_green = self._create_head()\n    \n    def _create_head(self) -> nn.Sequential:\n        \"\"\"Generate MLP structure for a single head\"\"\"\n        return nn.Sequential(\n            nn.Linear(self.n_combined, self.n_combined // 2),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(self.n_combined // 2, 1)\n        )\n    \n    def forward(\n        self,\n        img_left: torch.Tensor,\n        img_right: torch.Tensor\n    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:\n        \"\"\"\n        Forward pass.\n        \n        Args:\n            img_left: Left image [B, C, H, W]\n            img_right: Right image [B, C, H, W]\n            \n        Returns:\n            (total_pred, gdm_pred, green_pred) each [B, 1]\n        \"\"\"\n        feat_left = self.backbone(img_left)\n        feat_right = self.backbone(img_right)\n        combined = torch.cat([feat_left, feat_right], dim=1)\n        \n        out_total = self.head_total(combined)\n        out_gdm = self.head_gdm(combined)\n        out_green = self.head_green(combined)\n        \n        return out_total, out_gdm, out_green\n\n\n# ============================================================================\n# Model Loader\n# ============================================================================\n\nclass ModelLoader:\n    \"\"\"\n    Class for loading trained models.\n    \n    Handles weights saved with DataParallel.\n    \"\"\"\n    \n    def __init__(self, config: InferenceConfig):\n        \"\"\"\n        Args:\n            config: Configuration object\n        \"\"\"\n        self.config = config\n    \n    def load_fold_models(self) -> list[nn.Module]:\n        \"\"\"\n        Load all 5-Fold trained models.\n        \n        Returns:\n            List of models (each in eval mode on GPU)\n            \n        Raises:\n            FileNotFoundError: If model file not found\n        \"\"\"\n        print(f\"\\nLoading {self.config.n_folds} trained models...\")\n        \n        models = []\n        \n        for fold in range(self.config.n_folds):\n            model_path = self.config.model_dir / f'model_fold{fold}.pth'\n            \n            if not model_path.exists():\n                raise FileNotFoundError(f\"Model file not found: {model_path}\")\n            \n            # Initialize model\n            model = BiomassModel(self.config.model_name, pretrained=False)\n            \n            # Load weights\n            state_dict = torch.load(model_path, map_location=self.config.device)\n            \n            # Handle DataParallel (remove 'module.' prefix)\n            state_dict = self._remove_dataparallel_prefix(state_dict)\n            \n            model.load_state_dict(state_dict)\n            model.eval()  # Evaluation mode\n            model.to(self.config.device)\n            \n            models.append(model)\n            print(f\"  ✓ Fold {fold} model loaded\")\n        \n        print(f\"✓ Successfully loaded {len(models)} models\\n\")\n        return models\n    \n    @staticmethod\n    def _remove_dataparallel_prefix(state_dict: dict) -> dict:\n        \"\"\"\n        Remove 'module.' prefix from DataParallel-saved weights.\n        \n        Args:\n            state_dict: Model weight dictionary\n            \n        Returns:\n            Weight dictionary with prefix removed\n            \n        Why not: Not using try-except with direct load_state_dict\n            → Explicitly handling prefix presence improves readability\n        \"\"\"\n        if not any(k.startswith('module.') for k in state_dict.keys()):\n            return state_dict  # No prefix\n        \n        new_state_dict = OrderedDict()\n        for key, value in state_dict.items():\n            new_key = key.replace('module.', '')\n            new_state_dict[new_key] = value\n        \n        return new_state_dict\n\n\n# ============================================================================\n# Inference Engine\n# ============================================================================\n\nclass InferenceEngine:\n    \"\"\"\n    Engine for executing TTA + Ensemble inference.\n    \"\"\"\n    \n    def __init__(\n        self,\n        models: list[nn.Module],\n        config: InferenceConfig\n    ):\n        \"\"\"\n        Args:\n            models: List of trained models (5-Fold)\n            config: Configuration object\n        \"\"\"\n        self.models = models\n        self.config = config\n    \n    def predict_single_view(\n        self,\n        loader: DataLoader\n    ) -> dict[str, np.ndarray]:\n        \"\"\"\n        Predict with 5-Fold Ensemble for one TTA view.\n        \n        Args:\n            loader: DataLoader (with specific TTA transform applied)\n            \n        Returns:\n            {'total': [N], 'gdm': [N], 'green': [N]}\n        \"\"\"\n        view_preds = {'total': [], 'gdm': [], 'green': []}\n        \n        with torch.no_grad():\n            for img_left, img_right in tqdm(loader, desc=\"  Predicting\", leave=False):\n                img_left = img_left.to(self.config.device)\n                img_right = img_right.to(self.config.device)\n                \n                # Collect predictions from 5 folds\n                fold_preds = {'total': [], 'gdm': [], 'green': []}\n                \n                for model in self.models:\n                    pred_total, pred_gdm, pred_green = model(img_left, img_right)\n                    fold_preds['total'].append(pred_total.cpu())\n                    fold_preds['gdm'].append(pred_gdm.cpu())\n                    fold_preds['green'].append(pred_green.cpu())\n                \n                # Average across 5 folds\n                avg_total = torch.mean(torch.stack(fold_preds['total']), dim=0)\n                avg_gdm = torch.mean(torch.stack(fold_preds['gdm']), dim=0)\n                avg_green = torch.mean(torch.stack(fold_preds['green']), dim=0)\n                \n                view_preds['total'].append(avg_total.numpy())\n                view_preds['gdm'].append(avg_gdm.numpy())\n                view_preds['green'].append(avg_green.numpy())\n        \n        # Concatenate batches\n        return {\n            k: np.concatenate(v).flatten()\n            for k, v in view_preds.items()\n        }\n    \n    def predict_with_tta(\n        self,\n        test_df: pd.DataFrame,\n        tta_transforms: list[A.Compose]\n    ) -> dict[str, np.ndarray]:\n        \"\"\"\n        Execute final prediction with TTA + Ensemble.\n        \n        Args:\n            test_df: Test data DataFrame\n            tta_transforms: List of transforms for TTA\n            \n        Returns:\n            {'total': [N], 'gdm': [N], 'green': [N]} (after TTA averaging)\n        \"\"\"\n        print(f\"\\nStarting TTA inference: {len(tta_transforms)} Views × {self.config.n_folds} Folds\")\n        \n        all_view_preds: list[dict[str, np.ndarray]] = []\n        \n        for i, transform in enumerate(tta_transforms):\n            print(f\"--- TTA View {i+1}/{len(tta_transforms)} ---\")\n            \n            # Create Dataset/Loader for this view\n            dataset = TestBiomassDataset(\n                test_df,\n                transform,\n                self.config.test_image_dir\n            )\n            \n            loader = DataLoader(\n                dataset,\n                batch_size=self.config.batch_size,\n                shuffle=False,\n                num_workers=self.config.num_workers,\n                pin_memory=True\n            )\n            \n            # 5-Fold Ensemble prediction\n            view_preds = self.predict_single_view(loader)\n            all_view_preds.append(view_preds)\n            \n            print(f\"  ✓ View {i+1} completed\")\n        \n        # TTA Ensemble (average across all views)\n        print(\"\\nCalculating TTA Ensemble (averaging all views)...\")\n        final_preds = {\n            'total': np.mean([p['total'] for p in all_view_preds], axis=0),\n            'gdm': np.mean([p['gdm'] for p in all_view_preds], axis=0),\n            'green': np.mean([p['green'] for p in all_view_preds], axis=0)\n        }\n        \n        print(\"✓ Inference completed\\n\")\n        return final_preds\n\n\n# ============================================================================\n# Submission Creation\n# ============================================================================\n\nclass SubmissionCreator:\n    \"\"\"\n    Class for creating Kaggle submission CSV from predictions.\n    \"\"\"\n    \n    def __init__(self, config: InferenceConfig):\n        \"\"\"\n        Args:\n            config: Configuration object\n        \"\"\"\n        self.config = config\n    \n    def create(\n        self,\n        predictions: dict[str, np.ndarray],\n        test_df_long: pd.DataFrame,\n        test_df_unique: pd.DataFrame\n    ) -> None:\n        \"\"\"\n        Create and save submission CSV from predictions.\n        \n        Args:\n            predictions: {'total': [N], 'gdm': [N], 'green': [N]}\n            test_df_long: Original test.csv (long format)\n            test_df_unique: DataFrame of unique images\n            \n        Processing flow:\n        1. Calculate 5 targets from 3 predictions\n        2. Create wide format DataFrame\n        3. Convert to long format (melt)\n        4. Merge with sample_id\n        5. Save CSV\n        \"\"\"\n        print(\"Creating submission CSV...\")\n        \n        # 1. Get 3 predictions\n        pred_total = predictions['total']\n        pred_gdm = predictions['gdm']\n        pred_green = predictions['green']\n        \n        # 2. Calculate remaining 2 (clip negative values)\n        pred_clover = np.maximum(0, pred_gdm - pred_green)\n        pred_dead = np.maximum(0, pred_total - pred_gdm)\n        \n        # 3. Create wide format DataFrame\n        preds_wide = pd.DataFrame({\n            'image_path': test_df_unique['image_path'],\n            'Dry_Green_g': pred_green,\n            'Dry_Dead_g': pred_dead,\n            'Dry_Clover_g': pred_clover,\n            'GDM_g': pred_gdm,\n            'Dry_Total_g': pred_total\n        })\n        \n        # 4. Convert to long format (unpivot)\n        preds_long = preds_wide.melt(\n            id_vars=['image_path'],\n            value_vars=self.config.all_target_cols,\n            var_name='target_name',\n            value_name='target'\n        )\n        \n        # 5. Merge with original test.csv to get sample_id\n        submission = pd.merge(\n            test_df_long[['sample_id', 'image_path', 'target_name']],\n            preds_long,\n            on=['image_path', 'target_name'],\n            how='left'\n        )\n        \n        # 6. Format and save\n        submission = submission[['sample_id', 'target']]\n        submission.to_csv(self.config.submission_file, index=False)\n        \n        print(f\"\\n🎉 Submission saved: {self.config.submission_file}\")\n        print(\"\\n--- First 5 rows ---\")\n        print(submission.head())\n        print(\"\\n--- Last 5 rows ---\")\n        print(submission.tail())\n\n\n# ============================================================================\n# Inference Pipeline\n# ============================================================================\n\nclass InferencePipeline:\n    \"\"\"\n    Class that orchestrates the entire inference pipeline.\n    \"\"\"\n    \n    def __init__(self, config: InferenceConfig):\n        \"\"\"\n        Args:\n            config: Configuration object\n        \"\"\"\n        self.config = config\n        self.model_loader = ModelLoader(config)\n        self.tta_factory = TTATransformFactory(config.img_size)\n        self.submission_creator = SubmissionCreator(config)\n    \n    def run(self) -> None:\n        \"\"\"\n        Execute the entire inference pipeline.\n        \n        Processing flow:\n        1. Load test data\n        2. Load models (5-Fold)\n        3. TTA inference (3 Views × 5 Folds)\n        4. Create submission\n        \"\"\"\n        print(f\"\\n{'='*70}\")\n        print(f\"🚀 Starting Inference Pipeline\")\n        print(f\"{'='*70}\")\n        \n        try:\n            # 1. Load test data\n            test_df_long, test_df_unique = self._load_test_data()\n            \n            # 2. Load models\n            models = self.model_loader.load_fold_models()\n            \n            # 3. TTA inference\n            engine = InferenceEngine(models, self.config)\n            tta_transforms = self.tta_factory.get_tta_transforms()\n            predictions = engine.predict_with_tta(test_df_unique, tta_transforms)\n            \n            # 4. Create submission\n            self.submission_creator.create(\n                predictions,\n                test_df_long,\n                test_df_unique\n            )\n            \n            print(\"\\n✨ Inference Pipeline Completed ✨\")\n            \n        except Exception as e:\n            print(f\"\\n❌ Error occurred: {e}\")\n            raise\n        \n        finally:\n            # Free memory\n            gc.collect()\n            torch.cuda.empty_cache()\n    \n    def _load_test_data(self) -> tuple[pd.DataFrame, pd.DataFrame]:\n        \"\"\"\n        Load test data.\n        \n        Returns:\n            (test_df_long, test_df_unique)\n            - test_df_long: Original long format (with sample_id)\n            - test_df_unique: Unique images only\n            \n        Raises:\n            FileNotFoundError: If test.csv not found\n        \"\"\"\n        print(f\"\\nLoading test data: {self.config.test_csv}\")\n        \n        if not self.config.test_csv.exists():\n            raise FileNotFoundError(f\"test.csv not found: {self.config.test_csv}\")\n        \n        test_df_long = pd.read_csv(self.config.test_csv)\n        test_df_unique = test_df_long.drop_duplicates(\n            subset=['image_path']\n        ).reset_index(drop=True)\n        \n        print(f\"  Long format: {len(test_df_long)} rows\")\n        print(f\"  Unique images: {len(test_df_unique)} images\\n\")\n        \n        return test_df_long, test_df_unique\n\n\n# ============================================================================\n# Main Execution\n# ============================================================================\n\nif __name__ == '__main__':\n    # Initialize configuration\n    config = InferenceConfig()\n    config.display_info()\n    \n    # Run pipeline\n    pipeline = InferencePipeline(config)\n    pipeline.run()\n    \n    print(\"\\n\" + \"=\"*70)\n    print(\"🎊 Inference Pipeline Completed!\")\n    print(\"=\"*70)","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-11-14T14:20:38.569905Z","iopub.execute_input":"2025-11-14T14:20:38.570220Z","iopub.status.idle":"2025-11-14T14:21:00.810999Z","shell.execute_reply.started":"2025-11-14T14:20:38.570185Z","shell.execute_reply":"2025-11-14T14:21:00.810345Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}