{
  "id": 670668,
  "title": "Rank 5th Solution",
  "url": "/competitions/csiro-biomass/writeups/physics-constrained-regression-integrating-biolog",
  "author_name": "",
  "post_date": "2026-01-29T03:49:41.330Z",
  "votes": 81,
  "comment_count": 15,
  "views": 0,
  "content": "<h1>CSIRO Image2Biomass Prediction - 5th Place Solo Gold Medal Solution</h1>\n<p>First of all, thanks to Kaggle and CSIRO for organizing this competition. This is a very meaningful agricultural AI application scenario.</p>\n<h2>1. Introduction</h2>\n<p>This is my first time participating in a Kaggle CV competition, and I'm very happy and fortunate to have achieved 5th place solo (Private LB: 0.76). Throughout the competition, I conducted extensive experiments - <strong>I did about 100+ training experiments</strong> (from exp-v1 to exp-v188). Through this competition, I learned a lot about computer vision. Below is a detailed sharing of my competition solution.\n<img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb3e7b5b292e7d233fbd722b67d387b73%2F05_submit_records.png?generation=1769658480658921&amp;alt=media\" alt=\"\"></p>\n<h2>2. Competition Understanding</h2>\n<h3>2.1 Problem Description</h3>\n<p>The goal of this competition is to use pasture images to predict five key biomass components:</p>\n<ul>\n<li><strong>Dry_Green_g</strong> - Dry green vegetation (excluding clover)</li>\n<li><strong>Dry_Dead_g</strong> - Dry dead material</li>\n<li><strong>Dry_Clover_g</strong> - Dry clover biomass</li>\n<li><strong>GDM_g</strong> - Green dry matter</li>\n<li><strong>Dry_Total_g</strong> - Total dry biomass</li>\n</ul>\n<h3>2.2 Data Characteristics</h3>\n<ul>\n<li><strong>Small training set</strong>: Approximately 800+ images</li>\n<li><strong>Image size</strong>: Original images are 2000x1000 pixels</li>\n<li><strong>Distribution shift</strong>: Significant differences between training and test set distributions</li>\n<li><strong>Multi-state data</strong>: Data from different Australian states (NSW, Tas, Vic, WA)</li>\n<li><strong>Physical constraint relationships</strong>: Physical relationships exist between targets<ul>\n<li>Dead = Total - GDM</li>\n<li>Clover = GDM - Green</li></ul></li>\n</ul>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2F5f0fef6dc49974807016ae31e3b46086%2F02_metric_lines.png?generation=1769658391835855&amp;alt=media\" alt=\"\"></p>\n<h3>2.3 Evaluation Metric</h3>\n<p>The competition uses weighted R² score as the evaluation metric, with weighted average of R² across five targets.</p>\n<h2>3. Core Improvement Techniques</h2>\n<p>After extensive ablation experiments, the following are key techniques that can stably improve scores:</p>\n<h3>3.1 Image Preprocessing - Timestamp Removal</h3>\n<p>Original images contain orange date timestamps, which are data noise. I used HSV color space detection and image inpainting techniques to remove these timestamps:</p>\n<pre><code>def clean_image(img):\n    \"\"\"\n    Image preprocessing: Remove bottom artifacts and date stamp.\n    \"\"\"\n    # Detect orange date stamp in HSV space\n    hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)\n    # Define orange range\n    lower = np.array([5, 150, 150])\n    upper = np.array([25, 255, 255])\n    mask = cv2.inRange(hsv, lower, upper)\n\n    # Dilate mask to cover text edges\n    mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=2)\n\n    # Inpaint if timestamp detected\n    if np.sum(mask) &gt; 0:\n        img = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)\n\n    return img\n</code></pre>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb6a8641e5dd85c38d8b33ca91d38d9d8%2F01_remove_timstamp.png?generation=1769658419162635&amp;alt=media\" alt=\"\"></p>\n<h3>3.2 Multiple Data Augmentations</h3>\n<p>Due to the small training set size, data augmentation is crucial for preventing overfitting. I used TTA-style training strategy:</p>\n<pre><code>def get_tta_transforms(img_size: int) -&gt; list[A.Compose]:\n    \"\"\"Returns a list of transform pipelines for TTA-style training.\"\"\"\n    normalize = A.Normalize(\n        mean=[0.485, 0.456, 0.406],\n        std=[0.229, 0.224, 0.225]\n    )\n\n    # View 0: Original\n    original_view = A.Compose([\n        A.Resize(img_size, img_size),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 1: Horizontal Flip\n    hflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.HorizontalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 2: Vertical Flip\n    vflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.VerticalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 3: Rotate 90 degrees\n    rotate90_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.Rotate(limit=(90, 90), p=1.0, border_mode=0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    return [hflip_view, vflip_view, rotate90_view, original_view]\n</code></pre>\n<p><strong>Training Strategy</strong>:</p>\n<ul>\n<li>First 15 epochs: Train with augmented views (hflip, vflip, rotate90), equivalent to 4x data</li>\n<li>Subsequent epochs: Use only original view</li>\n<li><strong>Key finding</strong>: Randomly selecting augmentation methods works better than sequential epoch-based selection (exp-v174)</li>\n</ul>\n<h3>3.3 Multi-View Input Architecture</h3>\n<p>In addition to splitting the image into left and right halves (simulating stereo vision), I also added the full image as a third input stream:</p>\n<pre><code>class BiomassDataset(Dataset):\n    \"\"\"Three-stream dataset: splits 2000x1000 image into left, right, and full.\"\"\"\n\n    def __getitem__(self, idx: int):\n        image = self._load_image(self.image_paths[idx])\n        image = clean_image(image)\n\n        height, width = image.shape[:2]\n        mid = width // 2\n        left = image[:, :mid]      # Left half\n        right = image[:, mid:]     # Right half\n        full_image = image.copy()  # Full image\n\n        # Apply same transform to all three views\n        left = self.transform(image=left)[\"image\"]\n        right = self.transform(image=right)[\"image\"]\n        full_image = self.transform(image=full_image)[\"image\"]\n\n        return left, right, full_image, train_tgt, eval_tgt, species_tgt\n</code></pre>\n<p>This design allows the model to learn both local details (left/right views) and global context (full view) simultaneously.</p>\n<h3>3.4 Mixture-of-Experts (MoE) Model Architecture</h3>\n<p><strong>Design Intent</strong>: The model can automatically switch to the most suitable expert for prediction based on the specific characteristics of the input image (e.g., more green grass or more dead grass), effectively resolving feature conflicts in multi-task learning.</p>\n<pre><code>class MetaMoE(nn.Module):\n    \"\"\"Mixture-of-Experts head for main targets, outputs mean and variance.\"\"\"\n\n    def __init__(self, in_dim: int, hidden: int, num_experts: int, \n                 out_dim: int = 1, dropout: float = 0.2):\n        super().__init__()\n        self.num_experts = num_experts\n\n        # Gating network\n        self.gate = nn.Sequential(\n            nn.LayerNorm(in_dim),\n            nn.Linear(in_dim, 256),\n            nn.ReLU(),\n            nn.Linear(256, num_experts),\n        )\n\n        # Expert networks (each outputs mean and variance)\n        self.experts = nn.ModuleList([\n            nn.Sequential(\n                nn.LayerNorm(in_dim),\n                nn.Linear(in_dim, hidden),\n                nn.ReLU(),\n                nn.Dropout(dropout),\n                nn.Linear(hidden, 256),\n                nn.ReLU(),\n                nn.Linear(256, 2),  # [mean, var_logit]\n            )\n            for _ in range(num_experts)\n        ])\n\n    def forward(self, x: torch.Tensor):\n        gate_logits = self.gate(x)\n        gate_weights = F.softmax(gate_logits, dim=1).unsqueeze(-1)\n        expert_outs = torch.stack([expert(x) for expert in self.experts], dim=1)\n        out = (expert_outs * gate_weights).sum(dim=1)\n\n        pred_mean = out[:, 0:1]\n        pred_var_logit = out[:, 1:2]\n        pred_var = F.softplus(pred_var_logit) + 1e-6  # Ensure variance is positive\n\n        return pred_mean, pred_var\n</code></pre>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fd87c4b386b8fe45cdb09d1897004fdfb%2F03_model_arc.png?generation=1769658443172139&amp;alt=media\" alt=\"\"></p>\n<p><strong>Three Model Variants</strong>:</p>\n<ul>\n<li><strong>Model Type A (BiomassModelBasic)</strong>: Basic three-stream model (exp-v147, exp-v164, exp-v174)</li>\n<li><strong>Model Type B (BiomassModelWithStateSpecies)</strong>: With State/Species classification heads (exp-v142)</li>\n<li><strong>Model Type C (BiomassModelWithMetaEmbedding)</strong>: With meta feature embeddings (exp-v148)</li>\n</ul>\n<h3>3.5 Auxiliary Training Heads</h3>\n<p>Adding extra training objectives helps the model learn richer feature representations:</p>\n<pre><code>class BiomassModel(nn.Module):\n    def __init__(self, model_name: str, pretrained: bool = True, \n                 pretrained_path: str = '', num_species: int = 0):\n        super().__init__()\n        # ... backbone initialization ...\n\n        # Main regression heads (using MoE)\n        self.head_total = MetaMoE(...)\n        self.head_gdm = MetaMoE(...)\n        self.head_green = MetaMoE(...)\n\n        # Auxiliary regression heads (NDVI and Height)\n        self.head_ndvi = _AuxHead(self.n_features, self.n_features // 2)\n        self.head_height = _AuxHead(self.n_features, self.n_features // 2)\n\n        # Optional classification heads\n        if num_species &gt; 0:\n            self.head_species = self._make_classification_head(num_species)\n</code></pre>\n<p><strong>Auxiliary Targets</strong>:</p>\n<ul>\n<li>Pre_GSHH_NDVI (Normalized Difference Vegetation Index)</li>\n<li>Height_Ave_cm (Average pasture height)</li>\n<li>Species (Species classification, 15 classes)</li>\n<li>State (State classification, 4 classes)</li>\n</ul>\n<h3>3.6 Physics Consistency Loss</h3>\n<p>Designed loss function based on physical relationships between targets, using GaussianNLLLoss as the main loss function:</p>\n<p><strong>Core Advantages</strong>:</p>\n<ul>\n<li>Addresses the R² metric's <strong>sensitivity to outlier errors</strong></li>\n<li>Automatically downweights high-noise samples via GaussianNLL (avoids being biased by dirty data)</li>\n<li>Uses physical constraints to ensure prediction reasonableness (avoids logical contradictions leading to large errors)</li>\n</ul>\n<pre><code>class PhysicsConsistencyLoss(nn.Module):\n    \"\"\"\n    Physics consistency loss using GaussianNLLLoss\n\n    Key advantages:\n    - Automatic outlier handling: model learns to predict high variance for noisy samples\n    - Handles heteroscedasticity: different uncertainty for different biomass levels\n    - Physical constraints: Dead = Total - GDM, Clover = GDM - Green\n    \"\"\"\n\n    def __init__(self, weights: dict[str, float] = None):\n        super().__init__()\n        self.w = {\n            \"total\": 0.45,\n            \"gdm\": 0.2,\n            \"green\": 0.1,\n            \"dead\": 0.1,\n            \"clover\": 0.1,\n            \"ndvi\": 0.0,\n            \"height\": 0.0,\n            \"species\": 0.05\n        }\n        self.criterion = nn.GaussianNLLLoss()\n\n    def forward(self, preds, targets, species_targets=None):\n        # Direct prediction losses\n        loss_total = self.criterion(pred_total_mean, gt_total, pred_total_var)\n        loss_gdm = self.criterion(pred_gdm_mean, gt_gdm, pred_gdm_var)\n        loss_green = self.criterion(pred_green_mean, gt_green, pred_green_var)\n\n        # Physics constraint losses\n        # Dead = Total - GDM\n        pred_dead_mean = pred_total_mean - pred_gdm_mean\n        pred_dead_var = pred_total_var + pred_gdm_var\n        gt_dead = gt_total - gt_gdm\n        loss_dead = self.criterion(pred_dead_mean, gt_dead, pred_dead_var)\n\n        # Clover = GDM - Green\n        pred_clover_mean = pred_gdm_mean - pred_green_mean\n        pred_clover_var = pred_gdm_var + pred_green_var\n        gt_clover = gt_gdm - gt_green\n        loss_clover = self.criterion(pred_clover_mean, gt_clover, pred_clover_var)\n\n        # Weighted total loss\n        total_loss = (\n            self.w[\"total\"] * loss_total +\n            self.w[\"gdm\"] * loss_gdm +\n            self.w[\"green\"] * loss_green +\n            self.w[\"dead\"] * loss_dead +\n            self.w[\"clover\"] * loss_clover\n        )\n        return total_loss\n</code></pre>\n<h3>3.7 Layer-wise Learning Rate</h3>\n<p>Set different learning rates for pretrained Backbone and newly initialized Heads:</p>\n<pre><code>param_groups = []\nfor name, param in model.named_parameters():\n    if 'backbone' in name:\n        # Backbone uses smaller lr to preserve pretrained capability\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.backbone_lr_mult  # typically 0.1\n        })\n    elif 'head' in name:\n        # Head uses larger lr for fast fitting\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.head_lr_mult  # typically 1.0\n        })\n    else:\n        param_groups.append({'params': param, 'lr': cfg.finetune_lr})\n\noptimizer = optim.AdamW(param_groups, weight_decay=cfg.weight_decay)\n</code></pre>\n<p><strong>Rationale</strong>: Set smaller learning rate for pretrained Backbone to preserve its general feature extraction capability (prevent destroying pretrained weights), while setting larger learning rate for newly initialized Heads to quickly fit the current specific task.</p>\n<h3>3.8 Post-processing Strategies</h3>\n<h4>3.8.1 State-based Post-processing</h4>\n<p>Analysis revealed that WA state data has different characteristics from other states:</p>\n<pre><code># WA state special handling: Dead is 0, Total equals GDM\nif predicted_states_df is not None:\n    final_pred = final_pred.merge(predicted_states_df, on='image_path', how='left')\n    is_wa = final_pred['Predicted_State'] == 'WA'\n    final_pred.loc[is_wa, 'Dry_Dead_g'] = 0\n    final_pred.loc[is_wa, 'Dry_Total_g'] = final_pred.loc[is_wa, 'GDM_g']\n</code></pre>\n<h4>3.8.2 Statistics-based Range Clipping</h4>\n<p>Clip results based on training set target value statistics per state:</p>\n<pre><code>state_stats = {\n    'Tas': {'Dry_Clover_g': {'min': 0.0, 'max': 71.79}, ...},\n    'NSW': {'Dry_Clover_g': {'min': 0.0, 'max': 10.10}, ...},\n    'WA':  {'Dry_Clover_g': {'min': 0.0, 'max': 58.88}, ...},\n    'Vic': {'Dry_Clover_g': {'min': 0.0, 'max': 67.90}, ...}\n}\n\nfor state in state_stats.keys():\n    state_mask = final_pred['Predicted_State'] == state\n    for var in target_vars:\n        min_val = state_stats[state][var]['min']\n        max_val = state_stats[state][var]['max']\n        final_pred.loc[state_mask, var] = final_pred.loc[state_mask, var].clip(\n            lower=min_val, upper=max_val\n        )\n</code></pre>\n<h4>3.8.3 Scaling Post-processing</h4>\n<pre><code>def apply_post_processing(df: pd.DataFrame) -&gt; pd.DataFrame:\n    \"\"\"Apply Clover and Dead post-processing rules.\"\"\"\n    df_out = df.copy()\n    if 'Dry_Clover_g' in df_out.columns:\n        df_out['Dry_Clover_g'] = df_out['Dry_Clover_g'] * 0.8\n    if 'Dry_Dead_g' in df_out.columns:\n        mask_high = df_out['Dry_Dead_g'] &gt; 20\n        df_out.loc[mask_high, 'Dry_Dead_g'] *= 1.1\n        mask_low = df_out['Dry_Dead_g'] &lt; 10\n        df_out.loc[mask_low, 'Dry_Dead_g'] *= 0.9\n    return df_out\n</code></pre>\n<h3>3.9 Full Dataset Training</h3>\n<p>Since the training set is quite small, the intention was to utilize all available data and simply overfit on the training set.</p>\n<pre><code># train_df = df[df[\"fold\"] != fold].reset_index(drop=True)\n# valid_df = df[df[\"fold\"] == fold].reset_index(drop=True)\n\ntrain_df = df\nvalid_df = df\n</code></pre>\n<h2>4. Model Ensemble</h2>\n<h3>4.1 Final Ensemble Scheme</h3>\n<p>The final submission uses weighted ensemble of 6 models:</p>\n<table>\n<thead>\n<tr>\n<th>Model</th>\n<th>Weight</th>\n<th>Features</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>exp-v142</td>\n<td>0.0 (State prediction only)</td>\n<td>BiomassModelWithStateSpecies, extracts State info</td>\n</tr>\n<tr>\n<td>exp-v148</td>\n<td>0.15</td>\n<td>BiomassModelWithMetaEmbedding, uses meta features</td>\n</tr>\n<tr>\n<td>exp-v147</td>\n<td>0.20</td>\n<td>BiomassModelBasic, seed=2026</td>\n</tr>\n<tr>\n<td>exp-v174</td>\n<td>0.25</td>\n<td>BiomassModelBasic, random augmentation</td>\n</tr>\n<tr>\n<td>exp-v164</td>\n<td>0.20</td>\n<td>BiomassModelBasic, TTA-style augmentation</td>\n</tr>\n<tr>\n<td>exp-v177</td>\n<td>0.20</td>\n<td>BiomassModelBasic, adjusted loss weights</td>\n</tr>\n</tbody>\n</table>\n<h3>4.2 TTA Inference</h3>\n<p>Each model uses 3 TTA transforms:</p>\n<ol>\n<li>Original view</li>\n<li>Horizontal flip</li>\n<li>Vertical flip</li>\n</ol>\n<p>Final prediction is averaged.</p>\n<h3>4.3 Dual GPU Parallel Inference</h3>\n<p>Implemented dual GPU parallel inference for acceleration:</p>\n<pre><code>def parallel_dual_gpu_inference(dataset, model_class, model_kwargs, model_states, ...):\n    \"\"\"Run inference in parallel on two GPUs.\"\"\"\n    # Even indices -&gt; GPU 0, Odd indices -&gt; GPU 1\n    indices_0 = list(range(0, n, 2))\n    indices_1 = list(range(1, n, 2))\n\n    with ThreadPoolExecutor(max_workers=2) as executor:\n        future_0 = executor.submit(inference_single_gpu, model_0, ..., device_0)\n        future_1 = executor.submit(inference_single_gpu, model_1, ..., device_1)\n        results_0 = future_0.result()\n        results_1 = future_1.result()\n\n    # Merge results\n    ...\n</code></pre>\n<h2>5. Key Hyperparameters</h2>\n<table>\n<thead>\n<tr>\n<th>Hyperparameter</th>\n<th>Value</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Backbone</td>\n<td>vit_large_patch16_dinov3_qkvb</td>\n</tr>\n<tr>\n<td>Image Size</td>\n<td>1024x1024</td>\n</tr>\n<tr>\n<td>Batch Size</td>\n<td>4</td>\n</tr>\n<tr>\n<td>Learning Rate</td>\n<td>2e-4 (finetune to 5e-5 after warm-up)</td>\n</tr>\n<tr>\n<td>Backbone LR Multiplier</td>\n<td>0.1</td>\n</tr>\n<tr>\n<td>Training Epochs</td>\n<td>25</td>\n</tr>\n<tr>\n<td>MoE Experts</td>\n<td>4</td>\n</tr>\n<tr>\n<td>MoE Hidden Dim</td>\n<td>512</td>\n</tr>\n<tr>\n<td>Optimizer</td>\n<td>AdamW</td>\n</tr>\n<tr>\n<td>Weight Decay</td>\n<td>1e-4</td>\n</tr>\n</tbody>\n</table>\n<h2>6. Failed Attempts</h2>\n<p>Based on experiment records, the following methods did not improve or were unstable:</p>\n<h3>6.1 Methods That Did Not Work</h3>\n<table>\n<thead>\n<tr>\n<th>Experiment</th>\n<th>Description</th>\n<th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>exp-v36</td>\n<td>Ratio prediction</td>\n<td>CV dropped from 0.58 to 0.43</td>\n</tr>\n<tr>\n<td>exp-v97</td>\n<td>Full image without splitting</td>\n<td>CV 0.57, LB 0.52 (severe drop)</td>\n</tr>\n<tr>\n<td>exp-v99</td>\n<td>LR 2e-4 + 20 epochs</td>\n<td>CV only 0.21</td>\n</tr>\n<tr>\n<td>exp-v123</td>\n<td>Segmentation + attention</td>\n<td>CV 0.53, LB 0.53</td>\n</tr>\n<tr>\n<td>exp-v168/169</td>\n<td>Mamba Network</td>\n<td>CV 0.80-0.89 (unstable)</td>\n</tr>\n<tr>\n<td>exp-v180</td>\n<td>UNet original size 1000x2000</td>\n<td>LB 0.64 (significant drop)</td>\n</tr>\n</tbody>\n</table>\n<h3>6.2 Unstable Methods</h3>\n<table>\n<thead>\n<tr>\n<th>Experiment</th>\n<th>Description</th>\n<th>Problem</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Huge model (exp-v170)</td>\n<td>vit_huge</td>\n<td>High CV 0.95 but LB 0.72</td>\n</tr>\n<tr>\n<td>30 epochs (exp-v161)</td>\n<td>Increased epochs</td>\n<td>Overfitting, Private LB dropped</td>\n</tr>\n<tr>\n<td>Various segmentation masks</td>\n<td>ng/xg mask</td>\n<td>Partially effective but unstable</td>\n</tr>\n</tbody>\n</table>\n<h3>6.3 Lessons Learned</h3>\n<ol>\n<li><strong>Larger models are not always better</strong>: Huge models easily overfit on small datasets</li>\n<li><strong>Training epochs need control</strong>: ~25 epochs is the optimal balance point</li>\n<li><strong>Complex attention mechanisms have limited benefits</strong>: Easily overfit on small datasets</li>\n<li><strong>Mamba and other sequential model architectures are not suitable for this problem</strong></li>\n</ol>\n<h2>7. Experiment Progress</h2>\n<h3>7.1 Key Milestones</h3>\n<table>\n<thead>\n<tr>\n<th>Stage</th>\n<th>Experiment</th>\n<th>Public LB</th>\n<th>Private LB</th>\n<th>Key Improvement</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Baseline</td>\n<td>v1</td>\n<td>0.57</td>\n<td>-</td>\n<td>448 size baseline</td>\n</tr>\n<tr>\n<td>Model Upgrade</td>\n<td>v24</td>\n<td>0.65</td>\n<td>-</td>\n<td>vit_large_dinov3</td>\n</tr>\n<tr>\n<td>Resolution</td>\n<td>v32</td>\n<td>0.69</td>\n<td>-</td>\n<td>512 size</td>\n</tr>\n<tr>\n<td>Physics Loss</td>\n<td>v42</td>\n<td>0.69+</td>\n<td>-</td>\n<td>PhysicsConsistencyLoss</td>\n</tr>\n<tr>\n<td>MoE</td>\n<td>v44</td>\n<td>0.69</td>\n<td>-</td>\n<td>Mixture-of-Experts</td>\n</tr>\n<tr>\n<td>1024 Resolution</td>\n<td>v108</td>\n<td>0.73</td>\n<td>-</td>\n<td>1024 size + LR optimization</td>\n</tr>\n<tr>\n<td>Full Training</td>\n<td>v136</td>\n<td>0.74+</td>\n<td>-</td>\n<td>Full dataset + Species</td>\n</tr>\n<tr>\n<td>Seed Optimization</td>\n<td>v147</td>\n<td>0.75+</td>\n<td>-</td>\n<td>seed=2026</td>\n</tr>\n<tr>\n<td>Augmentation</td>\n<td>v164</td>\n<td>0.75</td>\n<td>-</td>\n<td>TTA-style augmentation</td>\n</tr>\n<tr>\n<td>Random Aug</td>\n<td>v174</td>\n<td>0.76</td>\n<td>0.65</td>\n<td>Random augmentation selection</td>\n</tr>\n<tr>\n<td>Ensemble</td>\n<td>Final</td>\n<td>0.76</td>\n<td><strong>0.66</strong></td>\n<td>6-model weighted ensemble</td>\n</tr>\n</tbody>\n</table>\n<h3>7.2 Final Private LB Score</h3>\n<p>Based on submission records, the two final selected submissions:</p>\n<ul>\n<li>Public LB: 0.76, Private LB: 0.66 (5th Place)</li>\n</ul>\n<h2>8. Conclusion and Experience Sharing</h2>\n<h3>8.1 Key Success Factors</h3>\n<ol>\n<li><strong>Extensive experiments</strong>: Conducted 188+ experiments, fully explored the solution space</li>\n<li><strong>Physical constraints</strong>: Designed loss functions using physical relationships between targets</li>\n<li><strong>Diverse ensemble</strong>: Fusion of models with different seeds, architectures, and training strategies</li>\n<li><strong>Data augmentation</strong>: On small datasets, proper data augmentation is crucial</li>\n<li><strong>Post-processing</strong>: Post-processing strategies based on data analysis</li>\n</ol>\n<h3>8.2 About Submission Selection</h3>\n<p>Submission selection was very difficult in this competition, mainly because:</p>\n<ul>\n<li>Small dataset size, models easily overfit</li>\n<li>Distribution shift between training and test sets</li>\n<li>Low correlation between Public LB and Private LB</li>\n</ul>\n<p>I ultimately chose the submission with the highest Public LB. Although the Private LB wasn't optimal (0.66 vs 0.65 for some failed submissions), it was relatively stable.</p>\n<h3>8.3 Luck Factor</h3>\n<p>I must admit there was some luck involved in this result:</p>\n<ul>\n<li>Saw many top players shake-up during the final standings</li>\n<li>Submission selection strategy happened to avoid pitfalls</li>\n<li>Multi-model ensemble improved stability</li>\n</ul>\n<h3>8.4 Acknowledgments</h3>\n<p>Special thanks to:</p>\n<ul>\n<li>The generous sharing from the Kaggle community</li>\n<li>Discussions and notebooks from fellow Kagglers</li>\n<li>CSIRO organizers for providing this meaningful competition</li>\n</ul>\n<p>This solo gold medal is very important for my journey to becoming a Grandmaster! I hope this solution sharing can help everyone.</p>\n<hr>\n<p><strong>Code Repository</strong>: <a href=\"https://github.com/yanqiangmiffy/2025-Kaggle-CSIRO-5th-solution\" target=\"_blank\">2025-Kaggle-CSIRO-5th-solution</a></p>\n<p><strong>Final Submission Notebook</strong>: <a href=\"https://www.kaggle.com/code/quincyqiang/csiro-ensemble-models-v12?scriptVersionId=294382133\" target=\"_blank\">CSIRO-Ensemble-Models-V12</a></p>",
  "messages": [
    {
      "id": "3398385",
      "postDate": "01/29/2026 03:49:26",
      "content": "<h1>CSIRO Image2Biomass Prediction - 5th Place Solo Gold Medal Solution</h1>\n<p>First of all, thanks to Kaggle and CSIRO for organizing this competition. This is a very meaningful agricultural AI application scenario.</p>\n<h2>1. Introduction</h2>\n<p>This is my first time participating in a Kaggle CV competition, and I'm very happy and fortunate to have achieved 5th place solo (Private LB: 0.76). Throughout the competition, I conducted extensive experiments - <strong>I did about 100+ training experiments</strong> (from exp-v1 to exp-v188). Through this competition, I learned a lot about computer vision. Below is a detailed sharing of my competition solution.\n<img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb3e7b5b292e7d233fbd722b67d387b73%2F05_submit_records.png?generation=1769658480658921&amp;alt=media\" alt=\"\"></p>\n<h2>2. Competition Understanding</h2>\n<h3>2.1 Problem Description</h3>\n<p>The goal of this competition is to use pasture images to predict five key biomass components:</p>\n<ul>\n<li><strong>Dry_Green_g</strong> - Dry green vegetation (excluding clover)</li>\n<li><strong>Dry_Dead_g</strong> - Dry dead material</li>\n<li><strong>Dry_Clover_g</strong> - Dry clover biomass</li>\n<li><strong>GDM_g</strong> - Green dry matter</li>\n<li><strong>Dry_Total_g</strong> - Total dry biomass</li>\n</ul>\n<h3>2.2 Data Characteristics</h3>\n<ul>\n<li><strong>Small training set</strong>: Approximately 800+ images</li>\n<li><strong>Image size</strong>: Original images are 2000x1000 pixels</li>\n<li><strong>Distribution shift</strong>: Significant differences between training and test set distributions</li>\n<li><strong>Multi-state data</strong>: Data from different Australian states (NSW, Tas, Vic, WA)</li>\n<li><strong>Physical constraint relationships</strong>: Physical relationships exist between targets<ul>\n<li>Dead = Total - GDM</li>\n<li>Clover = GDM - Green</li></ul></li>\n</ul>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2F5f0fef6dc49974807016ae31e3b46086%2F02_metric_lines.png?generation=1769658391835855&amp;alt=media\" alt=\"\"></p>\n<h3>2.3 Evaluation Metric</h3>\n<p>The competition uses weighted R² score as the evaluation metric, with weighted average of R² across five targets.</p>\n<h2>3. Core Improvement Techniques</h2>\n<p>After extensive ablation experiments, the following are key techniques that can stably improve scores:</p>\n<h3>3.1 Image Preprocessing - Timestamp Removal</h3>\n<p>Original images contain orange date timestamps, which are data noise. I used HSV color space detection and image inpainting techniques to remove these timestamps:</p>\n<pre><code>def clean_image(img):\n    \"\"\"\n    Image preprocessing: Remove bottom artifacts and date stamp.\n    \"\"\"\n    # Detect orange date stamp in HSV space\n    hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)\n    # Define orange range\n    lower = np.array([5, 150, 150])\n    upper = np.array([25, 255, 255])\n    mask = cv2.inRange(hsv, lower, upper)\n\n    # Dilate mask to cover text edges\n    mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=2)\n\n    # Inpaint if timestamp detected\n    if np.sum(mask) &gt; 0:\n        img = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)\n\n    return img\n</code></pre>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb6a8641e5dd85c38d8b33ca91d38d9d8%2F01_remove_timstamp.png?generation=1769658419162635&amp;alt=media\" alt=\"\"></p>\n<h3>3.2 Multiple Data Augmentations</h3>\n<p>Due to the small training set size, data augmentation is crucial for preventing overfitting. I used TTA-style training strategy:</p>\n<pre><code>def get_tta_transforms(img_size: int) -&gt; list[A.Compose]:\n    \"\"\"Returns a list of transform pipelines for TTA-style training.\"\"\"\n    normalize = A.Normalize(\n        mean=[0.485, 0.456, 0.406],\n        std=[0.229, 0.224, 0.225]\n    )\n\n    # View 0: Original\n    original_view = A.Compose([\n        A.Resize(img_size, img_size),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 1: Horizontal Flip\n    hflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.HorizontalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 2: Vertical Flip\n    vflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.VerticalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 3: Rotate 90 degrees\n    rotate90_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.Rotate(limit=(90, 90), p=1.0, border_mode=0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    return [hflip_view, vflip_view, rotate90_view, original_view]\n</code></pre>\n<p><strong>Training Strategy</strong>:</p>\n<ul>\n<li>First 15 epochs: Train with augmented views (hflip, vflip, rotate90), equivalent to 4x data</li>\n<li>Subsequent epochs: Use only original view</li>\n<li><strong>Key finding</strong>: Randomly selecting augmentation methods works better than sequential epoch-based selection (exp-v174)</li>\n</ul>\n<h3>3.3 Multi-View Input Architecture</h3>\n<p>In addition to splitting the image into left and right halves (simulating stereo vision), I also added the full image as a third input stream:</p>\n<pre><code>class BiomassDataset(Dataset):\n    \"\"\"Three-stream dataset: splits 2000x1000 image into left, right, and full.\"\"\"\n\n    def __getitem__(self, idx: int):\n        image = self._load_image(self.image_paths[idx])\n        image = clean_image(image)\n\n        height, width = image.shape[:2]\n        mid = width // 2\n        left = image[:, :mid]      # Left half\n        right = image[:, mid:]     # Right half\n        full_image = image.copy()  # Full image\n\n        # Apply same transform to all three views\n        left = self.transform(image=left)[\"image\"]\n        right = self.transform(image=right)[\"image\"]\n        full_image = self.transform(image=full_image)[\"image\"]\n\n        return left, right, full_image, train_tgt, eval_tgt, species_tgt\n</code></pre>\n<p>This design allows the model to learn both local details (left/right views) and global context (full view) simultaneously.</p>\n<h3>3.4 Mixture-of-Experts (MoE) Model Architecture</h3>\n<p><strong>Design Intent</strong>: The model can automatically switch to the most suitable expert for prediction based on the specific characteristics of the input image (e.g., more green grass or more dead grass), effectively resolving feature conflicts in multi-task learning.</p>\n<pre><code>class MetaMoE(nn.Module):\n    \"\"\"Mixture-of-Experts head for main targets, outputs mean and variance.\"\"\"\n\n    def __init__(self, in_dim: int, hidden: int, num_experts: int, \n                 out_dim: int = 1, dropout: float = 0.2):\n        super().__init__()\n        self.num_experts = num_experts\n\n        # Gating network\n        self.gate = nn.Sequential(\n            nn.LayerNorm(in_dim),\n            nn.Linear(in_dim, 256),\n            nn.ReLU(),\n            nn.Linear(256, num_experts),\n        )\n\n        # Expert networks (each outputs mean and variance)\n        self.experts = nn.ModuleList([\n            nn.Sequential(\n                nn.LayerNorm(in_dim),\n                nn.Linear(in_dim, hidden),\n                nn.ReLU(),\n                nn.Dropout(dropout),\n                nn.Linear(hidden, 256),\n                nn.ReLU(),\n                nn.Linear(256, 2),  # [mean, var_logit]\n            )\n            for _ in range(num_experts)\n        ])\n\n    def forward(self, x: torch.Tensor):\n        gate_logits = self.gate(x)\n        gate_weights = F.softmax(gate_logits, dim=1).unsqueeze(-1)\n        expert_outs = torch.stack([expert(x) for expert in self.experts], dim=1)\n        out = (expert_outs * gate_weights).sum(dim=1)\n\n        pred_mean = out[:, 0:1]\n        pred_var_logit = out[:, 1:2]\n        pred_var = F.softplus(pred_var_logit) + 1e-6  # Ensure variance is positive\n\n        return pred_mean, pred_var\n</code></pre>\n<p><img src=\"https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fd87c4b386b8fe45cdb09d1897004fdfb%2F03_model_arc.png?generation=1769658443172139&amp;alt=media\" alt=\"\"></p>\n<p><strong>Three Model Variants</strong>:</p>\n<ul>\n<li><strong>Model Type A (BiomassModelBasic)</strong>: Basic three-stream model (exp-v147, exp-v164, exp-v174)</li>\n<li><strong>Model Type B (BiomassModelWithStateSpecies)</strong>: With State/Species classification heads (exp-v142)</li>\n<li><strong>Model Type C (BiomassModelWithMetaEmbedding)</strong>: With meta feature embeddings (exp-v148)</li>\n</ul>\n<h3>3.5 Auxiliary Training Heads</h3>\n<p>Adding extra training objectives helps the model learn richer feature representations:</p>\n<pre><code>class BiomassModel(nn.Module):\n    def __init__(self, model_name: str, pretrained: bool = True, \n                 pretrained_path: str = '', num_species: int = 0):\n        super().__init__()\n        # ... backbone initialization ...\n\n        # Main regression heads (using MoE)\n        self.head_total = MetaMoE(...)\n        self.head_gdm = MetaMoE(...)\n        self.head_green = MetaMoE(...)\n\n        # Auxiliary regression heads (NDVI and Height)\n        self.head_ndvi = _AuxHead(self.n_features, self.n_features // 2)\n        self.head_height = _AuxHead(self.n_features, self.n_features // 2)\n\n        # Optional classification heads\n        if num_species &gt; 0:\n            self.head_species = self._make_classification_head(num_species)\n</code></pre>\n<p><strong>Auxiliary Targets</strong>:</p>\n<ul>\n<li>Pre_GSHH_NDVI (Normalized Difference Vegetation Index)</li>\n<li>Height_Ave_cm (Average pasture height)</li>\n<li>Species (Species classification, 15 classes)</li>\n<li>State (State classification, 4 classes)</li>\n</ul>\n<h3>3.6 Physics Consistency Loss</h3>\n<p>Designed loss function based on physical relationships between targets, using GaussianNLLLoss as the main loss function:</p>\n<p><strong>Core Advantages</strong>:</p>\n<ul>\n<li>Addresses the R² metric's <strong>sensitivity to outlier errors</strong></li>\n<li>Automatically downweights high-noise samples via GaussianNLL (avoids being biased by dirty data)</li>\n<li>Uses physical constraints to ensure prediction reasonableness (avoids logical contradictions leading to large errors)</li>\n</ul>\n<pre><code>class PhysicsConsistencyLoss(nn.Module):\n    \"\"\"\n    Physics consistency loss using GaussianNLLLoss\n\n    Key advantages:\n    - Automatic outlier handling: model learns to predict high variance for noisy samples\n    - Handles heteroscedasticity: different uncertainty for different biomass levels\n    - Physical constraints: Dead = Total - GDM, Clover = GDM - Green\n    \"\"\"\n\n    def __init__(self, weights: dict[str, float] = None):\n        super().__init__()\n        self.w = {\n            \"total\": 0.45,\n            \"gdm\": 0.2,\n            \"green\": 0.1,\n            \"dead\": 0.1,\n            \"clover\": 0.1,\n            \"ndvi\": 0.0,\n            \"height\": 0.0,\n            \"species\": 0.05\n        }\n        self.criterion = nn.GaussianNLLLoss()\n\n    def forward(self, preds, targets, species_targets=None):\n        # Direct prediction losses\n        loss_total = self.criterion(pred_total_mean, gt_total, pred_total_var)\n        loss_gdm = self.criterion(pred_gdm_mean, gt_gdm, pred_gdm_var)\n        loss_green = self.criterion(pred_green_mean, gt_green, pred_green_var)\n\n        # Physics constraint losses\n        # Dead = Total - GDM\n        pred_dead_mean = pred_total_mean - pred_gdm_mean\n        pred_dead_var = pred_total_var + pred_gdm_var\n        gt_dead = gt_total - gt_gdm\n        loss_dead = self.criterion(pred_dead_mean, gt_dead, pred_dead_var)\n\n        # Clover = GDM - Green\n        pred_clover_mean = pred_gdm_mean - pred_green_mean\n        pred_clover_var = pred_gdm_var + pred_green_var\n        gt_clover = gt_gdm - gt_green\n        loss_clover = self.criterion(pred_clover_mean, gt_clover, pred_clover_var)\n\n        # Weighted total loss\n        total_loss = (\n            self.w[\"total\"] * loss_total +\n            self.w[\"gdm\"] * loss_gdm +\n            self.w[\"green\"] * loss_green +\n            self.w[\"dead\"] * loss_dead +\n            self.w[\"clover\"] * loss_clover\n        )\n        return total_loss\n</code></pre>\n<h3>3.7 Layer-wise Learning Rate</h3>\n<p>Set different learning rates for pretrained Backbone and newly initialized Heads:</p>\n<pre><code>param_groups = []\nfor name, param in model.named_parameters():\n    if 'backbone' in name:\n        # Backbone uses smaller lr to preserve pretrained capability\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.backbone_lr_mult  # typically 0.1\n        })\n    elif 'head' in name:\n        # Head uses larger lr for fast fitting\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.head_lr_mult  # typically 1.0\n        })\n    else:\n        param_groups.append({'params': param, 'lr': cfg.finetune_lr})\n\noptimizer = optim.AdamW(param_groups, weight_decay=cfg.weight_decay)\n</code></pre>\n<p><strong>Rationale</strong>: Set smaller learning rate for pretrained Backbone to preserve its general feature extraction capability (prevent destroying pretrained weights), while setting larger learning rate for newly initialized Heads to quickly fit the current specific task.</p>\n<h3>3.8 Post-processing Strategies</h3>\n<h4>3.8.1 State-based Post-processing</h4>\n<p>Analysis revealed that WA state data has different characteristics from other states:</p>\n<pre><code># WA state special handling: Dead is 0, Total equals GDM\nif predicted_states_df is not None:\n    final_pred = final_pred.merge(predicted_states_df, on='image_path', how='left')\n    is_wa = final_pred['Predicted_State'] == 'WA'\n    final_pred.loc[is_wa, 'Dry_Dead_g'] = 0\n    final_pred.loc[is_wa, 'Dry_Total_g'] = final_pred.loc[is_wa, 'GDM_g']\n</code></pre>\n<h4>3.8.2 Statistics-based Range Clipping</h4>\n<p>Clip results based on training set target value statistics per state:</p>\n<pre><code>state_stats = {\n    'Tas': {'Dry_Clover_g': {'min': 0.0, 'max': 71.79}, ...},\n    'NSW': {'Dry_Clover_g': {'min': 0.0, 'max': 10.10}, ...},\n    'WA':  {'Dry_Clover_g': {'min': 0.0, 'max': 58.88}, ...},\n    'Vic': {'Dry_Clover_g': {'min': 0.0, 'max': 67.90}, ...}\n}\n\nfor state in state_stats.keys():\n    state_mask = final_pred['Predicted_State'] == state\n    for var in target_vars:\n        min_val = state_stats[state][var]['min']\n        max_val = state_stats[state][var]['max']\n        final_pred.loc[state_mask, var] = final_pred.loc[state_mask, var].clip(\n            lower=min_val, upper=max_val\n        )\n</code></pre>\n<h4>3.8.3 Scaling Post-processing</h4>\n<pre><code>def apply_post_processing(df: pd.DataFrame) -&gt; pd.DataFrame:\n    \"\"\"Apply Clover and Dead post-processing rules.\"\"\"\n    df_out = df.copy()\n    if 'Dry_Clover_g' in df_out.columns:\n        df_out['Dry_Clover_g'] = df_out['Dry_Clover_g'] * 0.8\n    if 'Dry_Dead_g' in df_out.columns:\n        mask_high = df_out['Dry_Dead_g'] &gt; 20\n        df_out.loc[mask_high, 'Dry_Dead_g'] *= 1.1\n        mask_low = df_out['Dry_Dead_g'] &lt; 10\n        df_out.loc[mask_low, 'Dry_Dead_g'] *= 0.9\n    return df_out\n</code></pre>\n<h3>3.9 Full Dataset Training</h3>\n<p>Since the training set is quite small, the intention was to utilize all available data and simply overfit on the training set.</p>\n<pre><code># train_df = df[df[\"fold\"] != fold].reset_index(drop=True)\n# valid_df = df[df[\"fold\"] == fold].reset_index(drop=True)\n\ntrain_df = df\nvalid_df = df\n</code></pre>\n<h2>4. Model Ensemble</h2>\n<h3>4.1 Final Ensemble Scheme</h3>\n<p>The final submission uses weighted ensemble of 6 models:</p>\n<table>\n<thead>\n<tr>\n<th>Model</th>\n<th>Weight</th>\n<th>Features</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>exp-v142</td>\n<td>0.0 (State prediction only)</td>\n<td>BiomassModelWithStateSpecies, extracts State info</td>\n</tr>\n<tr>\n<td>exp-v148</td>\n<td>0.15</td>\n<td>BiomassModelWithMetaEmbedding, uses meta features</td>\n</tr>\n<tr>\n<td>exp-v147</td>\n<td>0.20</td>\n<td>BiomassModelBasic, seed=2026</td>\n</tr>\n<tr>\n<td>exp-v174</td>\n<td>0.25</td>\n<td>BiomassModelBasic, random augmentation</td>\n</tr>\n<tr>\n<td>exp-v164</td>\n<td>0.20</td>\n<td>BiomassModelBasic, TTA-style augmentation</td>\n</tr>\n<tr>\n<td>exp-v177</td>\n<td>0.20</td>\n<td>BiomassModelBasic, adjusted loss weights</td>\n</tr>\n</tbody>\n</table>\n<h3>4.2 TTA Inference</h3>\n<p>Each model uses 3 TTA transforms:</p>\n<ol>\n<li>Original view</li>\n<li>Horizontal flip</li>\n<li>Vertical flip</li>\n</ol>\n<p>Final prediction is averaged.</p>\n<h3>4.3 Dual GPU Parallel Inference</h3>\n<p>Implemented dual GPU parallel inference for acceleration:</p>\n<pre><code>def parallel_dual_gpu_inference(dataset, model_class, model_kwargs, model_states, ...):\n    \"\"\"Run inference in parallel on two GPUs.\"\"\"\n    # Even indices -&gt; GPU 0, Odd indices -&gt; GPU 1\n    indices_0 = list(range(0, n, 2))\n    indices_1 = list(range(1, n, 2))\n\n    with ThreadPoolExecutor(max_workers=2) as executor:\n        future_0 = executor.submit(inference_single_gpu, model_0, ..., device_0)\n        future_1 = executor.submit(inference_single_gpu, model_1, ..., device_1)\n        results_0 = future_0.result()\n        results_1 = future_1.result()\n\n    # Merge results\n    ...\n</code></pre>\n<h2>5. Key Hyperparameters</h2>\n<table>\n<thead>\n<tr>\n<th>Hyperparameter</th>\n<th>Value</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Backbone</td>\n<td>vit_large_patch16_dinov3_qkvb</td>\n</tr>\n<tr>\n<td>Image Size</td>\n<td>1024x1024</td>\n</tr>\n<tr>\n<td>Batch Size</td>\n<td>4</td>\n</tr>\n<tr>\n<td>Learning Rate</td>\n<td>2e-4 (finetune to 5e-5 after warm-up)</td>\n</tr>\n<tr>\n<td>Backbone LR Multiplier</td>\n<td>0.1</td>\n</tr>\n<tr>\n<td>Training Epochs</td>\n<td>25</td>\n</tr>\n<tr>\n<td>MoE Experts</td>\n<td>4</td>\n</tr>\n<tr>\n<td>MoE Hidden Dim</td>\n<td>512</td>\n</tr>\n<tr>\n<td>Optimizer</td>\n<td>AdamW</td>\n</tr>\n<tr>\n<td>Weight Decay</td>\n<td>1e-4</td>\n</tr>\n</tbody>\n</table>\n<h2>6. Failed Attempts</h2>\n<p>Based on experiment records, the following methods did not improve or were unstable:</p>\n<h3>6.1 Methods That Did Not Work</h3>\n<table>\n<thead>\n<tr>\n<th>Experiment</th>\n<th>Description</th>\n<th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>exp-v36</td>\n<td>Ratio prediction</td>\n<td>CV dropped from 0.58 to 0.43</td>\n</tr>\n<tr>\n<td>exp-v97</td>\n<td>Full image without splitting</td>\n<td>CV 0.57, LB 0.52 (severe drop)</td>\n</tr>\n<tr>\n<td>exp-v99</td>\n<td>LR 2e-4 + 20 epochs</td>\n<td>CV only 0.21</td>\n</tr>\n<tr>\n<td>exp-v123</td>\n<td>Segmentation + attention</td>\n<td>CV 0.53, LB 0.53</td>\n</tr>\n<tr>\n<td>exp-v168/169</td>\n<td>Mamba Network</td>\n<td>CV 0.80-0.89 (unstable)</td>\n</tr>\n<tr>\n<td>exp-v180</td>\n<td>UNet original size 1000x2000</td>\n<td>LB 0.64 (significant drop)</td>\n</tr>\n</tbody>\n</table>\n<h3>6.2 Unstable Methods</h3>\n<table>\n<thead>\n<tr>\n<th>Experiment</th>\n<th>Description</th>\n<th>Problem</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Huge model (exp-v170)</td>\n<td>vit_huge</td>\n<td>High CV 0.95 but LB 0.72</td>\n</tr>\n<tr>\n<td>30 epochs (exp-v161)</td>\n<td>Increased epochs</td>\n<td>Overfitting, Private LB dropped</td>\n</tr>\n<tr>\n<td>Various segmentation masks</td>\n<td>ng/xg mask</td>\n<td>Partially effective but unstable</td>\n</tr>\n</tbody>\n</table>\n<h3>6.3 Lessons Learned</h3>\n<ol>\n<li><strong>Larger models are not always better</strong>: Huge models easily overfit on small datasets</li>\n<li><strong>Training epochs need control</strong>: ~25 epochs is the optimal balance point</li>\n<li><strong>Complex attention mechanisms have limited benefits</strong>: Easily overfit on small datasets</li>\n<li><strong>Mamba and other sequential model architectures are not suitable for this problem</strong></li>\n</ol>\n<h2>7. Experiment Progress</h2>\n<h3>7.1 Key Milestones</h3>\n<table>\n<thead>\n<tr>\n<th>Stage</th>\n<th>Experiment</th>\n<th>Public LB</th>\n<th>Private LB</th>\n<th>Key Improvement</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Baseline</td>\n<td>v1</td>\n<td>0.57</td>\n<td>-</td>\n<td>448 size baseline</td>\n</tr>\n<tr>\n<td>Model Upgrade</td>\n<td>v24</td>\n<td>0.65</td>\n<td>-</td>\n<td>vit_large_dinov3</td>\n</tr>\n<tr>\n<td>Resolution</td>\n<td>v32</td>\n<td>0.69</td>\n<td>-</td>\n<td>512 size</td>\n</tr>\n<tr>\n<td>Physics Loss</td>\n<td>v42</td>\n<td>0.69+</td>\n<td>-</td>\n<td>PhysicsConsistencyLoss</td>\n</tr>\n<tr>\n<td>MoE</td>\n<td>v44</td>\n<td>0.69</td>\n<td>-</td>\n<td>Mixture-of-Experts</td>\n</tr>\n<tr>\n<td>1024 Resolution</td>\n<td>v108</td>\n<td>0.73</td>\n<td>-</td>\n<td>1024 size + LR optimization</td>\n</tr>\n<tr>\n<td>Full Training</td>\n<td>v136</td>\n<td>0.74+</td>\n<td>-</td>\n<td>Full dataset + Species</td>\n</tr>\n<tr>\n<td>Seed Optimization</td>\n<td>v147</td>\n<td>0.75+</td>\n<td>-</td>\n<td>seed=2026</td>\n</tr>\n<tr>\n<td>Augmentation</td>\n<td>v164</td>\n<td>0.75</td>\n<td>-</td>\n<td>TTA-style augmentation</td>\n</tr>\n<tr>\n<td>Random Aug</td>\n<td>v174</td>\n<td>0.76</td>\n<td>0.65</td>\n<td>Random augmentation selection</td>\n</tr>\n<tr>\n<td>Ensemble</td>\n<td>Final</td>\n<td>0.76</td>\n<td><strong>0.66</strong></td>\n<td>6-model weighted ensemble</td>\n</tr>\n</tbody>\n</table>\n<h3>7.2 Final Private LB Score</h3>\n<p>Based on submission records, the two final selected submissions:</p>\n<ul>\n<li>Public LB: 0.76, Private LB: 0.66 (5th Place)</li>\n</ul>\n<h2>8. Conclusion and Experience Sharing</h2>\n<h3>8.1 Key Success Factors</h3>\n<ol>\n<li><strong>Extensive experiments</strong>: Conducted 188+ experiments, fully explored the solution space</li>\n<li><strong>Physical constraints</strong>: Designed loss functions using physical relationships between targets</li>\n<li><strong>Diverse ensemble</strong>: Fusion of models with different seeds, architectures, and training strategies</li>\n<li><strong>Data augmentation</strong>: On small datasets, proper data augmentation is crucial</li>\n<li><strong>Post-processing</strong>: Post-processing strategies based on data analysis</li>\n</ol>\n<h3>8.2 About Submission Selection</h3>\n<p>Submission selection was very difficult in this competition, mainly because:</p>\n<ul>\n<li>Small dataset size, models easily overfit</li>\n<li>Distribution shift between training and test sets</li>\n<li>Low correlation between Public LB and Private LB</li>\n</ul>\n<p>I ultimately chose the submission with the highest Public LB. Although the Private LB wasn't optimal (0.66 vs 0.65 for some failed submissions), it was relatively stable.</p>\n<h3>8.3 Luck Factor</h3>\n<p>I must admit there was some luck involved in this result:</p>\n<ul>\n<li>Saw many top players shake-up during the final standings</li>\n<li>Submission selection strategy happened to avoid pitfalls</li>\n<li>Multi-model ensemble improved stability</li>\n</ul>\n<h3>8.4 Acknowledgments</h3>\n<p>Special thanks to:</p>\n<ul>\n<li>The generous sharing from the Kaggle community</li>\n<li>Discussions and notebooks from fellow Kagglers</li>\n<li>CSIRO organizers for providing this meaningful competition</li>\n</ul>\n<p>This solo gold medal is very important for my journey to becoming a Grandmaster! I hope this solution sharing can help everyone.</p>\n<hr>\n<p><strong>Code Repository</strong>: <a href=\"https://github.com/yanqiangmiffy/2025-Kaggle-CSIRO-5th-solution\" target=\"_blank\">2025-Kaggle-CSIRO-5th-solution</a></p>\n<p><strong>Final Submission Notebook</strong>: <a href=\"https://www.kaggle.com/code/quincyqiang/csiro-ensemble-models-v12?scriptVersionId=294382133\" target=\"_blank\">CSIRO-Ensemble-Models-V12</a></p>",
      "rawMarkdown": "# CSIRO Image2Biomass Prediction - 5th Place Solo Gold Medal Solution\n\nFirst of all, thanks to Kaggle and CSIRO for organizing this competition. This is a very meaningful agricultural AI application scenario.\n\n## 1. Introduction\n\nThis is my first time participating in a Kaggle CV competition, and I'm very happy and fortunate to have achieved 5th place solo (Private LB: 0.76). Throughout the competition, I conducted extensive experiments - **I did about 100+ training experiments** (from exp-v1 to exp-v188). Through this competition, I learned a lot about computer vision. Below is a detailed sharing of my competition solution.\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb3e7b5b292e7d233fbd722b67d387b73%2F05_submit_records.png?generation=1769658480658921&alt=media)\n## 2. Competition Understanding\n\n### 2.1 Problem Description\n\nThe goal of this competition is to use pasture images to predict five key biomass components:\n- **Dry_Green_g** - Dry green vegetation (excluding clover)\n- **Dry_Dead_g** - Dry dead material\n- **Dry_Clover_g** - Dry clover biomass\n- **GDM_g** - Green dry matter\n- **Dry_Total_g** - Total dry biomass\n\n### 2.2 Data Characteristics\n\n- **Small training set**: Approximately 800+ images\n- **Image size**: Original images are 2000x1000 pixels\n- **Distribution shift**: Significant differences between training and test set distributions\n- **Multi-state data**: Data from different Australian states (NSW, Tas, Vic, WA)\n- **Physical constraint relationships**: Physical relationships exist between targets\n  - Dead = Total - GDM\n  - Clover = GDM - Green\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2F5f0fef6dc49974807016ae31e3b46086%2F02_metric_lines.png?generation=1769658391835855&alt=media)\n\n### 2.3 Evaluation Metric\n\nThe competition uses weighted R² score as the evaluation metric, with weighted average of R² across five targets.\n\n## 3. Core Improvement Techniques\n\nAfter extensive ablation experiments, the following are key techniques that can stably improve scores:\n\n### 3.1 Image Preprocessing - Timestamp Removal\n\nOriginal images contain orange date timestamps, which are data noise. I used HSV color space detection and image inpainting techniques to remove these timestamps:\n\n```python\ndef clean_image(img):\n    \"\"\"\n    Image preprocessing: Remove bottom artifacts and date stamp.\n    \"\"\"\n    # Detect orange date stamp in HSV space\n    hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)\n    # Define orange range\n    lower = np.array([5, 150, 150])\n    upper = np.array([25, 255, 255])\n    mask = cv2.inRange(hsv, lower, upper)\n\n    # Dilate mask to cover text edges\n    mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=2)\n\n    # Inpaint if timestamp detected\n    if np.sum(mask) > 0:\n        img = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)\n\n    return img\n```\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb6a8641e5dd85c38d8b33ca91d38d9d8%2F01_remove_timstamp.png?generation=1769658419162635&alt=media)\n\n### 3.2 Multiple Data Augmentations\n\nDue to the small training set size, data augmentation is crucial for preventing overfitting. I used TTA-style training strategy:\n\n```python\ndef get_tta_transforms(img_size: int) -> list[A.Compose]:\n    \"\"\"Returns a list of transform pipelines for TTA-style training.\"\"\"\n    normalize = A.Normalize(\n        mean=[0.485, 0.456, 0.406],\n        std=[0.229, 0.224, 0.225]\n    )\n\n    # View 0: Original\n    original_view = A.Compose([\n        A.Resize(img_size, img_size),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 1: Horizontal Flip\n    hflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.HorizontalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 2: Vertical Flip\n    vflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.VerticalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 3: Rotate 90 degrees\n    rotate90_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.Rotate(limit=(90, 90), p=1.0, border_mode=0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    return [hflip_view, vflip_view, rotate90_view, original_view]\n```\n\n**Training Strategy**:\n- First 15 epochs: Train with augmented views (hflip, vflip, rotate90), equivalent to 4x data\n- Subsequent epochs: Use only original view\n- **Key finding**: Randomly selecting augmentation methods works better than sequential epoch-based selection (exp-v174)\n\n### 3.3 Multi-View Input Architecture\n\nIn addition to splitting the image into left and right halves (simulating stereo vision), I also added the full image as a third input stream:\n\n```python\nclass BiomassDataset(Dataset):\n    \"\"\"Three-stream dataset: splits 2000x1000 image into left, right, and full.\"\"\"\n    \n    def __getitem__(self, idx: int):\n        image = self._load_image(self.image_paths[idx])\n        image = clean_image(image)\n\n        height, width = image.shape[:2]\n        mid = width // 2\n        left = image[:, :mid]      # Left half\n        right = image[:, mid:]     # Right half\n        full_image = image.copy()  # Full image\n\n        # Apply same transform to all three views\n        left = self.transform(image=left)[\"image\"]\n        right = self.transform(image=right)[\"image\"]\n        full_image = self.transform(image=full_image)[\"image\"]\n\n        return left, right, full_image, train_tgt, eval_tgt, species_tgt\n```\n\nThis design allows the model to learn both local details (left/right views) and global context (full view) simultaneously.\n\n### 3.4 Mixture-of-Experts (MoE) Model Architecture\n\n**Design Intent**: The model can automatically switch to the most suitable expert for prediction based on the specific characteristics of the input image (e.g., more green grass or more dead grass), effectively resolving feature conflicts in multi-task learning.\n\n```python\nclass MetaMoE(nn.Module):\n    \"\"\"Mixture-of-Experts head for main targets, outputs mean and variance.\"\"\"\n\n    def __init__(self, in_dim: int, hidden: int, num_experts: int, \n                 out_dim: int = 1, dropout: float = 0.2):\n        super().__init__()\n        self.num_experts = num_experts\n        \n        # Gating network\n        self.gate = nn.Sequential(\n            nn.LayerNorm(in_dim),\n            nn.Linear(in_dim, 256),\n            nn.ReLU(),\n            nn.Linear(256, num_experts),\n        )\n        \n        # Expert networks (each outputs mean and variance)\n        self.experts = nn.ModuleList([\n            nn.Sequential(\n                nn.LayerNorm(in_dim),\n                nn.Linear(in_dim, hidden),\n                nn.ReLU(),\n                nn.Dropout(dropout),\n                nn.Linear(hidden, 256),\n                nn.ReLU(),\n                nn.Linear(256, 2),  # [mean, var_logit]\n            )\n            for _ in range(num_experts)\n        ])\n\n    def forward(self, x: torch.Tensor):\n        gate_logits = self.gate(x)\n        gate_weights = F.softmax(gate_logits, dim=1).unsqueeze(-1)\n        expert_outs = torch.stack([expert(x) for expert in self.experts], dim=1)\n        out = (expert_outs * gate_weights).sum(dim=1)\n\n        pred_mean = out[:, 0:1]\n        pred_var_logit = out[:, 1:2]\n        pred_var = F.softplus(pred_var_logit) + 1e-6  # Ensure variance is positive\n\n        return pred_mean, pred_var\n```\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fd87c4b386b8fe45cdb09d1897004fdfb%2F03_model_arc.png?generation=1769658443172139&alt=media)\n\n**Three Model Variants**:\n- **Model Type A (BiomassModelBasic)**: Basic three-stream model (exp-v147, exp-v164, exp-v174)\n- **Model Type B (BiomassModelWithStateSpecies)**: With State/Species classification heads (exp-v142)\n- **Model Type C (BiomassModelWithMetaEmbedding)**: With meta feature embeddings (exp-v148)\n\n### 3.5 Auxiliary Training Heads\n\nAdding extra training objectives helps the model learn richer feature representations:\n\n```python\nclass BiomassModel(nn.Module):\n    def __init__(self, model_name: str, pretrained: bool = True, \n                 pretrained_path: str = '', num_species: int = 0):\n        super().__init__()\n        # ... backbone initialization ...\n        \n        # Main regression heads (using MoE)\n        self.head_total = MetaMoE(...)\n        self.head_gdm = MetaMoE(...)\n        self.head_green = MetaMoE(...)\n        \n        # Auxiliary regression heads (NDVI and Height)\n        self.head_ndvi = _AuxHead(self.n_features, self.n_features // 2)\n        self.head_height = _AuxHead(self.n_features, self.n_features // 2)\n        \n        # Optional classification heads\n        if num_species > 0:\n            self.head_species = self._make_classification_head(num_species)\n```\n\n**Auxiliary Targets**:\n- Pre_GSHH_NDVI (Normalized Difference Vegetation Index)\n- Height_Ave_cm (Average pasture height)\n- Species (Species classification, 15 classes)\n- State (State classification, 4 classes)\n\n### 3.6 Physics Consistency Loss\n\nDesigned loss function based on physical relationships between targets, using GaussianNLLLoss as the main loss function:\n\n**Core Advantages**:\n- Addresses the R² metric's **sensitivity to outlier errors**\n- Automatically downweights high-noise samples via GaussianNLL (avoids being biased by dirty data)\n- Uses physical constraints to ensure prediction reasonableness (avoids logical contradictions leading to large errors)\n\n```python\nclass PhysicsConsistencyLoss(nn.Module):\n    \"\"\"\n    Physics consistency loss using GaussianNLLLoss\n    \n    Key advantages:\n    - Automatic outlier handling: model learns to predict high variance for noisy samples\n    - Handles heteroscedasticity: different uncertainty for different biomass levels\n    - Physical constraints: Dead = Total - GDM, Clover = GDM - Green\n    \"\"\"\n\n    def __init__(self, weights: dict[str, float] = None):\n        super().__init__()\n        self.w = {\n            \"total\": 0.45,\n            \"gdm\": 0.2,\n            \"green\": 0.1,\n            \"dead\": 0.1,\n            \"clover\": 0.1,\n            \"ndvi\": 0.0,\n            \"height\": 0.0,\n            \"species\": 0.05\n        }\n        self.criterion = nn.GaussianNLLLoss()\n\n    def forward(self, preds, targets, species_targets=None):\n        # Direct prediction losses\n        loss_total = self.criterion(pred_total_mean, gt_total, pred_total_var)\n        loss_gdm = self.criterion(pred_gdm_mean, gt_gdm, pred_gdm_var)\n        loss_green = self.criterion(pred_green_mean, gt_green, pred_green_var)\n        \n        # Physics constraint losses\n        # Dead = Total - GDM\n        pred_dead_mean = pred_total_mean - pred_gdm_mean\n        pred_dead_var = pred_total_var + pred_gdm_var\n        gt_dead = gt_total - gt_gdm\n        loss_dead = self.criterion(pred_dead_mean, gt_dead, pred_dead_var)\n        \n        # Clover = GDM - Green\n        pred_clover_mean = pred_gdm_mean - pred_green_mean\n        pred_clover_var = pred_gdm_var + pred_green_var\n        gt_clover = gt_gdm - gt_green\n        loss_clover = self.criterion(pred_clover_mean, gt_clover, pred_clover_var)\n        \n        # Weighted total loss\n        total_loss = (\n            self.w[\"total\"] * loss_total +\n            self.w[\"gdm\"] * loss_gdm +\n            self.w[\"green\"] * loss_green +\n            self.w[\"dead\"] * loss_dead +\n            self.w[\"clover\"] * loss_clover\n        )\n        return total_loss\n```\n\n### 3.7 Layer-wise Learning Rate\n\nSet different learning rates for pretrained Backbone and newly initialized Heads:\n\n```python\nparam_groups = []\nfor name, param in model.named_parameters():\n    if 'backbone' in name:\n        # Backbone uses smaller lr to preserve pretrained capability\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.backbone_lr_mult  # typically 0.1\n        })\n    elif 'head' in name:\n        # Head uses larger lr for fast fitting\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.head_lr_mult  # typically 1.0\n        })\n    else:\n        param_groups.append({'params': param, 'lr': cfg.finetune_lr})\n\noptimizer = optim.AdamW(param_groups, weight_decay=cfg.weight_decay)\n```\n\n**Rationale**: Set smaller learning rate for pretrained Backbone to preserve its general feature extraction capability (prevent destroying pretrained weights), while setting larger learning rate for newly initialized Heads to quickly fit the current specific task.\n\n### 3.8 Post-processing Strategies\n\n#### 3.8.1 State-based Post-processing\n\nAnalysis revealed that WA state data has different characteristics from other states:\n\n```python\n# WA state special handling: Dead is 0, Total equals GDM\nif predicted_states_df is not None:\n    final_pred = final_pred.merge(predicted_states_df, on='image_path', how='left')\n    is_wa = final_pred['Predicted_State'] == 'WA'\n    final_pred.loc[is_wa, 'Dry_Dead_g'] = 0\n    final_pred.loc[is_wa, 'Dry_Total_g'] = final_pred.loc[is_wa, 'GDM_g']\n```\n\n#### 3.8.2 Statistics-based Range Clipping\n\nClip results based on training set target value statistics per state:\n\n```python\nstate_stats = {\n    'Tas': {'Dry_Clover_g': {'min': 0.0, 'max': 71.79}, ...},\n    'NSW': {'Dry_Clover_g': {'min': 0.0, 'max': 10.10}, ...},\n    'WA':  {'Dry_Clover_g': {'min': 0.0, 'max': 58.88}, ...},\n    'Vic': {'Dry_Clover_g': {'min': 0.0, 'max': 67.90}, ...}\n}\n\nfor state in state_stats.keys():\n    state_mask = final_pred['Predicted_State'] == state\n    for var in target_vars:\n        min_val = state_stats[state][var]['min']\n        max_val = state_stats[state][var]['max']\n        final_pred.loc[state_mask, var] = final_pred.loc[state_mask, var].clip(\n            lower=min_val, upper=max_val\n        )\n```\n\n#### 3.8.3 Scaling Post-processing\n\n```python\ndef apply_post_processing(df: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"Apply Clover and Dead post-processing rules.\"\"\"\n    df_out = df.copy()\n    if 'Dry_Clover_g' in df_out.columns:\n        df_out['Dry_Clover_g'] = df_out['Dry_Clover_g'] * 0.8\n    if 'Dry_Dead_g' in df_out.columns:\n        mask_high = df_out['Dry_Dead_g'] > 20\n        df_out.loc[mask_high, 'Dry_Dead_g'] *= 1.1\n        mask_low = df_out['Dry_Dead_g'] < 10\n        df_out.loc[mask_low, 'Dry_Dead_g'] *= 0.9\n    return df_out\n```\n### 3.9 Full Dataset Training\nSince the training set is quite small, the intention was to utilize all available data and simply overfit on the training set.\n```python\n# train_df = df[df[\"fold\"] != fold].reset_index(drop=True)\n# valid_df = df[df[\"fold\"] == fold].reset_index(drop=True)\n\ntrain_df = df\nvalid_df = df\n```\n\n## 4. Model Ensemble\n\n### 4.1 Final Ensemble Scheme\n\nThe final submission uses weighted ensemble of 6 models:\n\n| Model | Weight | Features |\n|-------|--------|----------|\n| exp-v142 | 0.0 (State prediction only) | BiomassModelWithStateSpecies, extracts State info |\n| exp-v148 | 0.15 | BiomassModelWithMetaEmbedding, uses meta features |\n| exp-v147 | 0.20 | BiomassModelBasic, seed=2026 |\n| exp-v174 | 0.25 | BiomassModelBasic, random augmentation |\n| exp-v164 | 0.20 | BiomassModelBasic, TTA-style augmentation |\n| exp-v177 | 0.20 | BiomassModelBasic, adjusted loss weights |\n\n### 4.2 TTA Inference\n\nEach model uses 3 TTA transforms:\n1. Original view\n2. Horizontal flip\n3. Vertical flip\n\nFinal prediction is averaged.\n\n### 4.3 Dual GPU Parallel Inference\n\nImplemented dual GPU parallel inference for acceleration:\n\n```python\ndef parallel_dual_gpu_inference(dataset, model_class, model_kwargs, model_states, ...):\n    \"\"\"Run inference in parallel on two GPUs.\"\"\"\n    # Even indices -> GPU 0, Odd indices -> GPU 1\n    indices_0 = list(range(0, n, 2))\n    indices_1 = list(range(1, n, 2))\n    \n    with ThreadPoolExecutor(max_workers=2) as executor:\n        future_0 = executor.submit(inference_single_gpu, model_0, ..., device_0)\n        future_1 = executor.submit(inference_single_gpu, model_1, ..., device_1)\n        results_0 = future_0.result()\n        results_1 = future_1.result()\n    \n    # Merge results\n    ...\n```\n\n## 5. Key Hyperparameters\n\n| Hyperparameter | Value |\n|----------------|-------|\n| Backbone | vit_large_patch16_dinov3_qkvb |\n| Image Size | 1024x1024 |\n| Batch Size | 4 |\n| Learning Rate | 2e-4 (finetune to 5e-5 after warm-up) |\n| Backbone LR Multiplier | 0.1 |\n| Training Epochs | 25 |\n| MoE Experts | 4 |\n| MoE Hidden Dim | 512 |\n| Optimizer | AdamW |\n| Weight Decay | 1e-4 |\n\n## 6. Failed Attempts\n\nBased on experiment records, the following methods did not improve or were unstable:\n\n### 6.1 Methods That Did Not Work\n\n| Experiment | Description | Result |\n|------------|-------------|--------|\n| exp-v36 | Ratio prediction | CV dropped from 0.58 to 0.43 |\n| exp-v97 | Full image without splitting | CV 0.57, LB 0.52 (severe drop)|\n| exp-v99 | LR 2e-4 + 20 epochs | CV only 0.21 |\n| exp-v123 | Segmentation + attention | CV 0.53, LB 0.53 |\n| exp-v168/169 | Mamba Network | CV 0.80-0.89 (unstable)|\n| exp-v180 | UNet original size 1000x2000 | LB 0.64 (significant drop)|\n\n### 6.2 Unstable Methods\n\n| Experiment | Description | Problem |\n|------------|-------------|---------|\n| Huge model (exp-v170) | vit_huge | High CV 0.95 but LB 0.72 |\n| 30 epochs (exp-v161) | Increased epochs | Overfitting, Private LB dropped |\n| Various segmentation masks | ng/xg mask | Partially effective but unstable |\n\n### 6.3 Lessons Learned\n\n1. **Larger models are not always better**: Huge models easily overfit on small datasets\n2. **Training epochs need control**: ~25 epochs is the optimal balance point\n3. **Complex attention mechanisms have limited benefits**: Easily overfit on small datasets\n4. **Mamba and other sequential model architectures are not suitable for this problem**\n\n## 7. Experiment Progress\n\n### 7.1 Key Milestones\n\n| Stage | Experiment | Public LB | Private LB | Key Improvement |\n|-------|------------|-----------|------------|-----------------|\n| Baseline | v1 | 0.57 | - | 448 size baseline |\n| Model Upgrade | v24 | 0.65 | - | vit_large_dinov3 |\n| Resolution | v32 | 0.69 | - | 512 size |\n| Physics Loss | v42 | 0.69+ | - | PhysicsConsistencyLoss |\n| MoE | v44 | 0.69 | - | Mixture-of-Experts |\n| 1024 Resolution | v108 | 0.73 | - | 1024 size + LR optimization |\n| Full Training | v136 | 0.74+ | - | Full dataset + Species |\n| Seed Optimization | v147 | 0.75+ | - | seed=2026 |\n| Augmentation | v164 | 0.75 | - | TTA-style augmentation |\n| Random Aug | v174 | 0.76 | 0.65 | Random augmentation selection |\n| Ensemble | Final | 0.76 | **0.66** | 6-model weighted ensemble |\n\n### 7.2 Final Private LB Score\n\nBased on submission records, the two final selected submissions:\n- Public LB: 0.76, Private LB: 0.66 (5th Place)\n\n## 8. Conclusion and Experience Sharing\n\n### 8.1 Key Success Factors\n\n1. **Extensive experiments**: Conducted 188+ experiments, fully explored the solution space\n2. **Physical constraints**: Designed loss functions using physical relationships between targets\n3. **Diverse ensemble**: Fusion of models with different seeds, architectures, and training strategies\n4. **Data augmentation**: On small datasets, proper data augmentation is crucial\n5. **Post-processing**: Post-processing strategies based on data analysis\n\n### 8.2 About Submission Selection\n\nSubmission selection was very difficult in this competition, mainly because:\n- Small dataset size, models easily overfit\n- Distribution shift between training and test sets\n- Low correlation between Public LB and Private LB\n\nI ultimately chose the submission with the highest Public LB. Although the Private LB wasn't optimal (0.66 vs 0.65 for some failed submissions), it was relatively stable.\n\n### 8.3 Luck Factor\n\nI must admit there was some luck involved in this result:\n- Saw many top players shake-up during the final standings\n- Submission selection strategy happened to avoid pitfalls\n- Multi-model ensemble improved stability\n\n### 8.4 Acknowledgments\n\nSpecial thanks to:\n- The generous sharing from the Kaggle community\n- Discussions and notebooks from fellow Kagglers\n- CSIRO organizers for providing this meaningful competition\n\nThis solo gold medal is very important for my journey to becoming a Grandmaster! I hope this solution sharing can help everyone.\n\n---\n\n**Code Repository**: [2025-Kaggle-CSIRO-5th-solution](https://github.com/yanqiangmiffy/2025-Kaggle-CSIRO-5th-solution)\n\n**Final Submission Notebook**: [CSIRO-Ensemble-Models-V12](https://www.kaggle.com/code/quincyqiang/csiro-ensemble-models-v12?scriptVersionId=294382133)",
      "votes": null
    },
    {
      "id": "3398390",
      "postDate": "01/29/2026 03:58:02",
      "content": "<p>Wow, you're amazing! You actually made a lot of my 'failed' ideas work. I'm digging into your code right now to learn from it👍</p>",
      "rawMarkdown": "Wow, you're amazing! You actually made a lot of my 'failed' ideas work. I'm digging into your code right now to learn from it👍",
      "votes": null
    },
    {
      "id": "3398402",
      "postDate": "01/29/2026 04:38:52",
      "content": "<p>Thanks for your lot!</p>",
      "rawMarkdown": "Thanks for your lot!",
      "votes": null
    },
    {
      "id": "3398413",
      "postDate": "01/29/2026 05:23:59",
      "content": "<p>I saw a few top solutions. the resolution is all 1024*1024 in commm.I thingk this is the key to improve</p>",
      "rawMarkdown": "I saw a few top solutions. the resolution is all 1024*1024 in commm.I thingk this is the key to improve",
      "votes": null
    },
    {
      "id": "3398414",
      "postDate": "01/29/2026 05:31:42",
      "content": "<p>非常棒的方案，坚持实验的精神值得学习，恭喜你。</p>",
      "rawMarkdown": "非常棒的方案，坚持实验的精神值得学习，恭喜你。",
      "votes": null
    },
    {
      "id": "3398416",
      "postDate": "01/29/2026 05:36:07",
      "content": "<p>Yes，the bigger resolution did work! I tried from 224,448,512,768 to 1024</p>",
      "rawMarkdown": "Yes，the bigger resolution did work! I tried from 224,448,512,768 to 1024",
      "votes": null
    },
    {
      "id": "3398420",
      "postDate": "01/29/2026 05:42:51",
      "content": "<p>确实做了很多实验，也有运气成分，非常感谢认可！🍁🍁</p>",
      "rawMarkdown": "确实做了很多实验，也有运气成分，非常感谢认可！🍁🍁",
      "votes": null
    },
    {
      "id": "3398446",
      "postDate": "01/29/2026 06:50:25",
      "content": "<p>dinov2 pretrained on 518<em>518,dinov2 large with 518</em>518 can get 0.72 in public. dinov3 pretrained on 512<em>512, 768</em>768.Normally dinov3 should be the best in 512and 768.I don't know why it  perform best with 1024*1024.additionally do you use attentionpool butilt in timm?</p>",
      "rawMarkdown": "dinov2 pretrained on 518*518,dinov2 large with 518*518 can get 0.72 in public. dinov3 pretrained on 512*512, 768*768.Normally dinov3 should be the best in 512and 768.I don't know why it  perform best with 1024*1024.additionally do you use attentionpool butilt in timm?",
      "votes": null
    },
    {
      "id": "3398458",
      "postDate": "01/29/2026 07:09:40",
      "content": "<p>This is great , very insightful. Thanks for sharing .  </p>",
      "rawMarkdown": "This is great , very insightful. Thanks for sharing .",
      "votes": null
    },
    {
      "id": "3398470",
      "postDate": "01/29/2026 07:34:05",
      "content": "<p>Amazing approach. \nCoincidently, I also tried the same loss function, but it was too early. I did not have the rest of the things figured out till then, like the architecture itself. And it made me think that the loss function is not working for this problem, but it was just a result of unstable experiment. I should have sticked to it, or try it again when the rest was figured out.</p>\n<p>MoE is also amazing idea for this problem. I tried doing a Gradient Surgery from the paper: \"Gradient Surgery for Multi Task Learning\" to make sure all tasks are learning well, but never thought like this: If they conflict, let them conflict, just split the experts. WoW.\nI thought if we are using one of the N experts, wouldn't that simply perform like Dropout (except this is not random here) training only the activated expert, but now it is clear. </p>\n<p>Just one question: Did you do some sort of analysis to see how much is each expert being activated? \nActually, when I added Task and Prompt tokens, they were converging to near same representations after the training, so I had to add an orthogonality loss. I don't see any such supervision like that here.\nAlso, did you try using more experts? </p>\n<p>Thanks a lot for this writeup.</p>",
      "rawMarkdown": "Amazing approach. \nCoincidently, I also tried the same loss function, but it was too early. I did not have the rest of the things figured out till then, like the architecture itself. And it made me think that the loss function is not working for this problem, but it was just a result of unstable experiment. I should have sticked to it, or try it again when the rest was figured out.\n\nMoE is also amazing idea for this problem. I tried doing a Gradient Surgery from the paper: \"Gradient Surgery for Multi Task Learning\" to make sure all tasks are learning well, but never thought like this: If they conflict, let them conflict, just split the experts. WoW.\nI thought if we are using one of the N experts, wouldn't that simply perform like Dropout (except this is not random here) training only the activated expert, but now it is clear. \n\nJust one question: Did you do some sort of analysis to see how much is each expert being activated? \nActually, when I added Task and Prompt tokens, they were converging to near same representations after the training, so I had to add an orthogonality loss. I don't see any such supervision like that here.\nAlso, did you try using more experts? \n\nThanks a lot for this writeup.",
      "votes": null
    },
    {
      "id": "3398474",
      "postDate": "01/29/2026 07:41:05",
      "content": "<p>Thank you for your feedback.</p>",
      "rawMarkdown": "Thank you for your feedback.",
      "votes": null
    },
    {
      "id": "3398477",
      "postDate": "01/29/2026 07:43:49",
      "content": "<p>I loaded it this way, setting the <code>img_size</code> and using average pooling.</p>\n<pre><code>self.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool=\"avg\")\n</code></pre>",
      "rawMarkdown": "I loaded it this way, setting the `img_size` and using average pooling.\n```\nself.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool=\"avg\")\n```",
      "votes": null
    },
    {
      "id": "3398479",
      "postDate": "01/29/2026 07:45:54",
      "content": "<p>Thank you for your feedback.</p>\n<pre><code>I have another question: Have you conducted any analysis to understand the activation level of each expert?\n</code></pre>\n<p>I haven't done a specific analysis on that; I used a relatively simple configuration.</p>",
      "rawMarkdown": "Thank you for your feedback.\n```\nI have another question: Have you conducted any analysis to understand the activation level of each expert?\n```\nI haven't done a specific analysis on that; I used a relatively simple configuration.",
      "votes": null
    },
    {
      "id": "3398544",
      "postDate": "01/29/2026 09:50:29",
      "content": "<p>appreciate your replyly. from you not only I learned detailed code but also I learned the spirit of perseverance. you did so many experiments.It is really a big job</p>",
      "rawMarkdown": "appreciate your replyly. from you not only I learned detailed code but also I learned the spirit of perseverance. you did so many experiments.It is really a big job",
      "votes": null
    },
    {
      "id": "3398587",
      "postDate": "01/29/2026 10:58:15",
      "content": "<p>absolute cinema</p>",
      "rawMarkdown": "absolute cinema",
      "votes": null
    },
    {
      "id": "3458853",
      "postDate": "05/16/2026 19:19:06",
      "content": "<p>I added a new dataset for Image Inpainting</p>\n<p><a href=\"https://www.kaggle.com/datasets/kannanwisen/urban-street-view-image-inpainting-random-masks\" target=\"_blank\">Urban Street View Image Inpainting-Random Masks</a></p>",
      "rawMarkdown": "I added a new dataset for Image Inpainting\n\n[Urban Street View Image Inpainting-Random Masks](https://www.kaggle.com/datasets/kannanwisen/urban-street-view-image-inpainting-random-masks)",
      "votes": null
    }
  ],
  "comments": [
    {
      "id": 3398390,
      "author_name": "cudacoding",
      "author_url": "",
      "post_date": "01/29/2026 03:58:02",
      "content": "<p>Wow, you're amazing! You actually made a lot of my 'failed' ideas work. I'm digging into your code right now to learn from it👍</p>",
      "votes": null,
      "replies": [
        {
          "id": 3398402,
          "author_name": "quincyqiang",
          "author_url": "",
          "post_date": "01/29/2026 04:38:52",
          "content": "<p>Thanks for your lot!</p>",
          "votes": null,
          "replies": []
        }
      ]
    },
    {
      "id": 3398413,
      "author_name": "",
      "author_url": "",
      "post_date": "01/29/2026 05:23:59",
      "content": "<p>I saw a few top solutions. the resolution is all 1024*1024 in commm.I thingk this is the key to improve</p>",
      "votes": null,
      "replies": [
        {
          "id": 3398416,
          "author_name": "quincyqiang",
          "author_url": "",
          "post_date": "01/29/2026 05:36:07",
          "content": "<p>Yes，the bigger resolution did work! I tried from 224,448,512,768 to 1024</p>",
          "votes": null,
          "replies": [
            {
              "id": 3398446,
              "author_name": "",
              "author_url": "",
              "post_date": "01/29/2026 06:50:25",
              "content": "<p>dinov2 pretrained on 518<em>518,dinov2 large with 518</em>518 can get 0.72 in public. dinov3 pretrained on 512<em>512, 768</em>768.Normally dinov3 should be the best in 512and 768.I don't know why it  perform best with 1024*1024.additionally do you use attentionpool butilt in timm?</p>",
              "votes": null,
              "replies": [
                {
                  "id": 3398477,
                  "author_name": "quincyqiang",
                  "author_url": "",
                  "post_date": "01/29/2026 07:43:49",
                  "content": "<p>I loaded it this way, setting the <code>img_size</code> and using average pooling.</p>\n<pre><code>self.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool=\"avg\")\n</code></pre>",
                  "votes": null,
                  "replies": [
                    {
                      "id": 3398544,
                      "author_name": "",
                      "author_url": "",
                      "post_date": "01/29/2026 09:50:29",
                      "content": "<p>appreciate your replyly. from you not only I learned detailed code but also I learned the spirit of perseverance. you did so many experiments.It is really a big job</p>",
                      "votes": null,
                      "replies": []
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "id": 3398414,
      "author_name": "playwithme",
      "author_url": "",
      "post_date": "01/29/2026 05:31:42",
      "content": "<p>非常棒的方案，坚持实验的精神值得学习，恭喜你。</p>",
      "votes": null,
      "replies": [
        {
          "id": 3398420,
          "author_name": "quincyqiang",
          "author_url": "",
          "post_date": "01/29/2026 05:42:51",
          "content": "<p>确实做了很多实验，也有运气成分，非常感谢认可！🍁🍁</p>",
          "votes": null,
          "replies": []
        }
      ]
    },
    {
      "id": 3398458,
      "author_name": "sharmaharshit",
      "author_url": "",
      "post_date": "01/29/2026 07:09:40",
      "content": "<p>This is great , very insightful. Thanks for sharing .  </p>",
      "votes": null,
      "replies": [
        {
          "id": 3398474,
          "author_name": "quincyqiang",
          "author_url": "",
          "post_date": "01/29/2026 07:41:05",
          "content": "<p>Thank you for your feedback.</p>",
          "votes": null,
          "replies": []
        }
      ]
    },
    {
      "id": 3398470,
      "author_name": "komilparmar",
      "author_url": "",
      "post_date": "01/29/2026 07:34:05",
      "content": "<p>Amazing approach. \nCoincidently, I also tried the same loss function, but it was too early. I did not have the rest of the things figured out till then, like the architecture itself. And it made me think that the loss function is not working for this problem, but it was just a result of unstable experiment. I should have sticked to it, or try it again when the rest was figured out.</p>\n<p>MoE is also amazing idea for this problem. I tried doing a Gradient Surgery from the paper: \"Gradient Surgery for Multi Task Learning\" to make sure all tasks are learning well, but never thought like this: If they conflict, let them conflict, just split the experts. WoW.\nI thought if we are using one of the N experts, wouldn't that simply perform like Dropout (except this is not random here) training only the activated expert, but now it is clear. </p>\n<p>Just one question: Did you do some sort of analysis to see how much is each expert being activated? \nActually, when I added Task and Prompt tokens, they were converging to near same representations after the training, so I had to add an orthogonality loss. I don't see any such supervision like that here.\nAlso, did you try using more experts? </p>\n<p>Thanks a lot for this writeup.</p>",
      "votes": null,
      "replies": [
        {
          "id": 3398479,
          "author_name": "quincyqiang",
          "author_url": "",
          "post_date": "01/29/2026 07:45:54",
          "content": "<p>Thank you for your feedback.</p>\n<pre><code>I have another question: Have you conducted any analysis to understand the activation level of each expert?\n</code></pre>\n<p>I haven't done a specific analysis on that; I used a relatively simple configuration.</p>",
          "votes": null,
          "replies": []
        }
      ]
    },
    {
      "id": 3398587,
      "author_name": "nikitakuznetsof",
      "author_url": "",
      "post_date": "01/29/2026 10:58:15",
      "content": "<p>absolute cinema</p>",
      "votes": null,
      "replies": []
    },
    {
      "id": 3458853,
      "author_name": "kannanwisen",
      "author_url": "",
      "post_date": "05/16/2026 19:19:06",
      "content": "<p>I added a new dataset for Image Inpainting</p>\n<p><a href=\"https://www.kaggle.com/datasets/kannanwisen/urban-street-view-image-inpainting-random-masks\" target=\"_blank\">Urban Street View Image Inpainting-Random Masks</a></p>",
      "votes": null,
      "replies": []
    }
  ],
  "raw_markdown_by_id": {
    "3398385": "# CSIRO Image2Biomass Prediction - 5th Place Solo Gold Medal Solution\n\nFirst of all, thanks to Kaggle and CSIRO for organizing this competition. This is a very meaningful agricultural AI application scenario.\n\n## 1. Introduction\n\nThis is my first time participating in a Kaggle CV competition, and I'm very happy and fortunate to have achieved 5th place solo (Private LB: 0.76). Throughout the competition, I conducted extensive experiments - **I did about 100+ training experiments** (from exp-v1 to exp-v188). Through this competition, I learned a lot about computer vision. Below is a detailed sharing of my competition solution.\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb3e7b5b292e7d233fbd722b67d387b73%2F05_submit_records.png?generation=1769658480658921&alt=media)\n## 2. Competition Understanding\n\n### 2.1 Problem Description\n\nThe goal of this competition is to use pasture images to predict five key biomass components:\n- **Dry_Green_g** - Dry green vegetation (excluding clover)\n- **Dry_Dead_g** - Dry dead material\n- **Dry_Clover_g** - Dry clover biomass\n- **GDM_g** - Green dry matter\n- **Dry_Total_g** - Total dry biomass\n\n### 2.2 Data Characteristics\n\n- **Small training set**: Approximately 800+ images\n- **Image size**: Original images are 2000x1000 pixels\n- **Distribution shift**: Significant differences between training and test set distributions\n- **Multi-state data**: Data from different Australian states (NSW, Tas, Vic, WA)\n- **Physical constraint relationships**: Physical relationships exist between targets\n  - Dead = Total - GDM\n  - Clover = GDM - Green\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2F5f0fef6dc49974807016ae31e3b46086%2F02_metric_lines.png?generation=1769658391835855&alt=media)\n\n### 2.3 Evaluation Metric\n\nThe competition uses weighted R² score as the evaluation metric, with weighted average of R² across five targets.\n\n## 3. Core Improvement Techniques\n\nAfter extensive ablation experiments, the following are key techniques that can stably improve scores:\n\n### 3.1 Image Preprocessing - Timestamp Removal\n\nOriginal images contain orange date timestamps, which are data noise. I used HSV color space detection and image inpainting techniques to remove these timestamps:\n\n```python\ndef clean_image(img):\n    \"\"\"\n    Image preprocessing: Remove bottom artifacts and date stamp.\n    \"\"\"\n    # Detect orange date stamp in HSV space\n    hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)\n    # Define orange range\n    lower = np.array([5, 150, 150])\n    upper = np.array([25, 255, 255])\n    mask = cv2.inRange(hsv, lower, upper)\n\n    # Dilate mask to cover text edges\n    mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=2)\n\n    # Inpaint if timestamp detected\n    if np.sum(mask) > 0:\n        img = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)\n\n    return img\n```\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fb6a8641e5dd85c38d8b33ca91d38d9d8%2F01_remove_timstamp.png?generation=1769658419162635&alt=media)\n\n### 3.2 Multiple Data Augmentations\n\nDue to the small training set size, data augmentation is crucial for preventing overfitting. I used TTA-style training strategy:\n\n```python\ndef get_tta_transforms(img_size: int) -> list[A.Compose]:\n    \"\"\"Returns a list of transform pipelines for TTA-style training.\"\"\"\n    normalize = A.Normalize(\n        mean=[0.485, 0.456, 0.406],\n        std=[0.229, 0.224, 0.225]\n    )\n\n    # View 0: Original\n    original_view = A.Compose([\n        A.Resize(img_size, img_size),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 1: Horizontal Flip\n    hflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.HorizontalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 2: Vertical Flip\n    vflip_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.VerticalFlip(p=1.0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    # View 3: Rotate 90 degrees\n    rotate90_view = A.Compose([\n        A.Resize(img_size, img_size),\n        A.Rotate(limit=(90, 90), p=1.0, border_mode=0),\n        normalize,\n        ToTensorV2()\n    ])\n\n    return [hflip_view, vflip_view, rotate90_view, original_view]\n```\n\n**Training Strategy**:\n- First 15 epochs: Train with augmented views (hflip, vflip, rotate90), equivalent to 4x data\n- Subsequent epochs: Use only original view\n- **Key finding**: Randomly selecting augmentation methods works better than sequential epoch-based selection (exp-v174)\n\n### 3.3 Multi-View Input Architecture\n\nIn addition to splitting the image into left and right halves (simulating stereo vision), I also added the full image as a third input stream:\n\n```python\nclass BiomassDataset(Dataset):\n    \"\"\"Three-stream dataset: splits 2000x1000 image into left, right, and full.\"\"\"\n    \n    def __getitem__(self, idx: int):\n        image = self._load_image(self.image_paths[idx])\n        image = clean_image(image)\n\n        height, width = image.shape[:2]\n        mid = width // 2\n        left = image[:, :mid]      # Left half\n        right = image[:, mid:]     # Right half\n        full_image = image.copy()  # Full image\n\n        # Apply same transform to all three views\n        left = self.transform(image=left)[\"image\"]\n        right = self.transform(image=right)[\"image\"]\n        full_image = self.transform(image=full_image)[\"image\"]\n\n        return left, right, full_image, train_tgt, eval_tgt, species_tgt\n```\n\nThis design allows the model to learn both local details (left/right views) and global context (full view) simultaneously.\n\n### 3.4 Mixture-of-Experts (MoE) Model Architecture\n\n**Design Intent**: The model can automatically switch to the most suitable expert for prediction based on the specific characteristics of the input image (e.g., more green grass or more dead grass), effectively resolving feature conflicts in multi-task learning.\n\n```python\nclass MetaMoE(nn.Module):\n    \"\"\"Mixture-of-Experts head for main targets, outputs mean and variance.\"\"\"\n\n    def __init__(self, in_dim: int, hidden: int, num_experts: int, \n                 out_dim: int = 1, dropout: float = 0.2):\n        super().__init__()\n        self.num_experts = num_experts\n        \n        # Gating network\n        self.gate = nn.Sequential(\n            nn.LayerNorm(in_dim),\n            nn.Linear(in_dim, 256),\n            nn.ReLU(),\n            nn.Linear(256, num_experts),\n        )\n        \n        # Expert networks (each outputs mean and variance)\n        self.experts = nn.ModuleList([\n            nn.Sequential(\n                nn.LayerNorm(in_dim),\n                nn.Linear(in_dim, hidden),\n                nn.ReLU(),\n                nn.Dropout(dropout),\n                nn.Linear(hidden, 256),\n                nn.ReLU(),\n                nn.Linear(256, 2),  # [mean, var_logit]\n            )\n            for _ in range(num_experts)\n        ])\n\n    def forward(self, x: torch.Tensor):\n        gate_logits = self.gate(x)\n        gate_weights = F.softmax(gate_logits, dim=1).unsqueeze(-1)\n        expert_outs = torch.stack([expert(x) for expert in self.experts], dim=1)\n        out = (expert_outs * gate_weights).sum(dim=1)\n\n        pred_mean = out[:, 0:1]\n        pred_var_logit = out[:, 1:2]\n        pred_var = F.softplus(pred_var_logit) + 1e-6  # Ensure variance is positive\n\n        return pred_mean, pred_var\n```\n\n\n![](https://www.googleapis.com/download/storage/v1/b/kaggle-user-content/o/inbox%2F999843%2Fd87c4b386b8fe45cdb09d1897004fdfb%2F03_model_arc.png?generation=1769658443172139&alt=media)\n\n**Three Model Variants**:\n- **Model Type A (BiomassModelBasic)**: Basic three-stream model (exp-v147, exp-v164, exp-v174)\n- **Model Type B (BiomassModelWithStateSpecies)**: With State/Species classification heads (exp-v142)\n- **Model Type C (BiomassModelWithMetaEmbedding)**: With meta feature embeddings (exp-v148)\n\n### 3.5 Auxiliary Training Heads\n\nAdding extra training objectives helps the model learn richer feature representations:\n\n```python\nclass BiomassModel(nn.Module):\n    def __init__(self, model_name: str, pretrained: bool = True, \n                 pretrained_path: str = '', num_species: int = 0):\n        super().__init__()\n        # ... backbone initialization ...\n        \n        # Main regression heads (using MoE)\n        self.head_total = MetaMoE(...)\n        self.head_gdm = MetaMoE(...)\n        self.head_green = MetaMoE(...)\n        \n        # Auxiliary regression heads (NDVI and Height)\n        self.head_ndvi = _AuxHead(self.n_features, self.n_features // 2)\n        self.head_height = _AuxHead(self.n_features, self.n_features // 2)\n        \n        # Optional classification heads\n        if num_species > 0:\n            self.head_species = self._make_classification_head(num_species)\n```\n\n**Auxiliary Targets**:\n- Pre_GSHH_NDVI (Normalized Difference Vegetation Index)\n- Height_Ave_cm (Average pasture height)\n- Species (Species classification, 15 classes)\n- State (State classification, 4 classes)\n\n### 3.6 Physics Consistency Loss\n\nDesigned loss function based on physical relationships between targets, using GaussianNLLLoss as the main loss function:\n\n**Core Advantages**:\n- Addresses the R² metric's **sensitivity to outlier errors**\n- Automatically downweights high-noise samples via GaussianNLL (avoids being biased by dirty data)\n- Uses physical constraints to ensure prediction reasonableness (avoids logical contradictions leading to large errors)\n\n```python\nclass PhysicsConsistencyLoss(nn.Module):\n    \"\"\"\n    Physics consistency loss using GaussianNLLLoss\n    \n    Key advantages:\n    - Automatic outlier handling: model learns to predict high variance for noisy samples\n    - Handles heteroscedasticity: different uncertainty for different biomass levels\n    - Physical constraints: Dead = Total - GDM, Clover = GDM - Green\n    \"\"\"\n\n    def __init__(self, weights: dict[str, float] = None):\n        super().__init__()\n        self.w = {\n            \"total\": 0.45,\n            \"gdm\": 0.2,\n            \"green\": 0.1,\n            \"dead\": 0.1,\n            \"clover\": 0.1,\n            \"ndvi\": 0.0,\n            \"height\": 0.0,\n            \"species\": 0.05\n        }\n        self.criterion = nn.GaussianNLLLoss()\n\n    def forward(self, preds, targets, species_targets=None):\n        # Direct prediction losses\n        loss_total = self.criterion(pred_total_mean, gt_total, pred_total_var)\n        loss_gdm = self.criterion(pred_gdm_mean, gt_gdm, pred_gdm_var)\n        loss_green = self.criterion(pred_green_mean, gt_green, pred_green_var)\n        \n        # Physics constraint losses\n        # Dead = Total - GDM\n        pred_dead_mean = pred_total_mean - pred_gdm_mean\n        pred_dead_var = pred_total_var + pred_gdm_var\n        gt_dead = gt_total - gt_gdm\n        loss_dead = self.criterion(pred_dead_mean, gt_dead, pred_dead_var)\n        \n        # Clover = GDM - Green\n        pred_clover_mean = pred_gdm_mean - pred_green_mean\n        pred_clover_var = pred_gdm_var + pred_green_var\n        gt_clover = gt_gdm - gt_green\n        loss_clover = self.criterion(pred_clover_mean, gt_clover, pred_clover_var)\n        \n        # Weighted total loss\n        total_loss = (\n            self.w[\"total\"] * loss_total +\n            self.w[\"gdm\"] * loss_gdm +\n            self.w[\"green\"] * loss_green +\n            self.w[\"dead\"] * loss_dead +\n            self.w[\"clover\"] * loss_clover\n        )\n        return total_loss\n```\n\n### 3.7 Layer-wise Learning Rate\n\nSet different learning rates for pretrained Backbone and newly initialized Heads:\n\n```python\nparam_groups = []\nfor name, param in model.named_parameters():\n    if 'backbone' in name:\n        # Backbone uses smaller lr to preserve pretrained capability\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.backbone_lr_mult  # typically 0.1\n        })\n    elif 'head' in name:\n        # Head uses larger lr for fast fitting\n        param_groups.append({\n            'params': param, \n            'lr': cfg.finetune_lr * cfg.head_lr_mult  # typically 1.0\n        })\n    else:\n        param_groups.append({'params': param, 'lr': cfg.finetune_lr})\n\noptimizer = optim.AdamW(param_groups, weight_decay=cfg.weight_decay)\n```\n\n**Rationale**: Set smaller learning rate for pretrained Backbone to preserve its general feature extraction capability (prevent destroying pretrained weights), while setting larger learning rate for newly initialized Heads to quickly fit the current specific task.\n\n### 3.8 Post-processing Strategies\n\n#### 3.8.1 State-based Post-processing\n\nAnalysis revealed that WA state data has different characteristics from other states:\n\n```python\n# WA state special handling: Dead is 0, Total equals GDM\nif predicted_states_df is not None:\n    final_pred = final_pred.merge(predicted_states_df, on='image_path', how='left')\n    is_wa = final_pred['Predicted_State'] == 'WA'\n    final_pred.loc[is_wa, 'Dry_Dead_g'] = 0\n    final_pred.loc[is_wa, 'Dry_Total_g'] = final_pred.loc[is_wa, 'GDM_g']\n```\n\n#### 3.8.2 Statistics-based Range Clipping\n\nClip results based on training set target value statistics per state:\n\n```python\nstate_stats = {\n    'Tas': {'Dry_Clover_g': {'min': 0.0, 'max': 71.79}, ...},\n    'NSW': {'Dry_Clover_g': {'min': 0.0, 'max': 10.10}, ...},\n    'WA':  {'Dry_Clover_g': {'min': 0.0, 'max': 58.88}, ...},\n    'Vic': {'Dry_Clover_g': {'min': 0.0, 'max': 67.90}, ...}\n}\n\nfor state in state_stats.keys():\n    state_mask = final_pred['Predicted_State'] == state\n    for var in target_vars:\n        min_val = state_stats[state][var]['min']\n        max_val = state_stats[state][var]['max']\n        final_pred.loc[state_mask, var] = final_pred.loc[state_mask, var].clip(\n            lower=min_val, upper=max_val\n        )\n```\n\n#### 3.8.3 Scaling Post-processing\n\n```python\ndef apply_post_processing(df: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"Apply Clover and Dead post-processing rules.\"\"\"\n    df_out = df.copy()\n    if 'Dry_Clover_g' in df_out.columns:\n        df_out['Dry_Clover_g'] = df_out['Dry_Clover_g'] * 0.8\n    if 'Dry_Dead_g' in df_out.columns:\n        mask_high = df_out['Dry_Dead_g'] > 20\n        df_out.loc[mask_high, 'Dry_Dead_g'] *= 1.1\n        mask_low = df_out['Dry_Dead_g'] < 10\n        df_out.loc[mask_low, 'Dry_Dead_g'] *= 0.9\n    return df_out\n```\n### 3.9 Full Dataset Training\nSince the training set is quite small, the intention was to utilize all available data and simply overfit on the training set.\n```python\n# train_df = df[df[\"fold\"] != fold].reset_index(drop=True)\n# valid_df = df[df[\"fold\"] == fold].reset_index(drop=True)\n\ntrain_df = df\nvalid_df = df\n```\n\n## 4. Model Ensemble\n\n### 4.1 Final Ensemble Scheme\n\nThe final submission uses weighted ensemble of 6 models:\n\n| Model | Weight | Features |\n|-------|--------|----------|\n| exp-v142 | 0.0 (State prediction only) | BiomassModelWithStateSpecies, extracts State info |\n| exp-v148 | 0.15 | BiomassModelWithMetaEmbedding, uses meta features |\n| exp-v147 | 0.20 | BiomassModelBasic, seed=2026 |\n| exp-v174 | 0.25 | BiomassModelBasic, random augmentation |\n| exp-v164 | 0.20 | BiomassModelBasic, TTA-style augmentation |\n| exp-v177 | 0.20 | BiomassModelBasic, adjusted loss weights |\n\n### 4.2 TTA Inference\n\nEach model uses 3 TTA transforms:\n1. Original view\n2. Horizontal flip\n3. Vertical flip\n\nFinal prediction is averaged.\n\n### 4.3 Dual GPU Parallel Inference\n\nImplemented dual GPU parallel inference for acceleration:\n\n```python\ndef parallel_dual_gpu_inference(dataset, model_class, model_kwargs, model_states, ...):\n    \"\"\"Run inference in parallel on two GPUs.\"\"\"\n    # Even indices -> GPU 0, Odd indices -> GPU 1\n    indices_0 = list(range(0, n, 2))\n    indices_1 = list(range(1, n, 2))\n    \n    with ThreadPoolExecutor(max_workers=2) as executor:\n        future_0 = executor.submit(inference_single_gpu, model_0, ..., device_0)\n        future_1 = executor.submit(inference_single_gpu, model_1, ..., device_1)\n        results_0 = future_0.result()\n        results_1 = future_1.result()\n    \n    # Merge results\n    ...\n```\n\n## 5. Key Hyperparameters\n\n| Hyperparameter | Value |\n|----------------|-------|\n| Backbone | vit_large_patch16_dinov3_qkvb |\n| Image Size | 1024x1024 |\n| Batch Size | 4 |\n| Learning Rate | 2e-4 (finetune to 5e-5 after warm-up) |\n| Backbone LR Multiplier | 0.1 |\n| Training Epochs | 25 |\n| MoE Experts | 4 |\n| MoE Hidden Dim | 512 |\n| Optimizer | AdamW |\n| Weight Decay | 1e-4 |\n\n## 6. Failed Attempts\n\nBased on experiment records, the following methods did not improve or were unstable:\n\n### 6.1 Methods That Did Not Work\n\n| Experiment | Description | Result |\n|------------|-------------|--------|\n| exp-v36 | Ratio prediction | CV dropped from 0.58 to 0.43 |\n| exp-v97 | Full image without splitting | CV 0.57, LB 0.52 (severe drop)|\n| exp-v99 | LR 2e-4 + 20 epochs | CV only 0.21 |\n| exp-v123 | Segmentation + attention | CV 0.53, LB 0.53 |\n| exp-v168/169 | Mamba Network | CV 0.80-0.89 (unstable)|\n| exp-v180 | UNet original size 1000x2000 | LB 0.64 (significant drop)|\n\n### 6.2 Unstable Methods\n\n| Experiment | Description | Problem |\n|------------|-------------|---------|\n| Huge model (exp-v170) | vit_huge | High CV 0.95 but LB 0.72 |\n| 30 epochs (exp-v161) | Increased epochs | Overfitting, Private LB dropped |\n| Various segmentation masks | ng/xg mask | Partially effective but unstable |\n\n### 6.3 Lessons Learned\n\n1. **Larger models are not always better**: Huge models easily overfit on small datasets\n2. **Training epochs need control**: ~25 epochs is the optimal balance point\n3. **Complex attention mechanisms have limited benefits**: Easily overfit on small datasets\n4. **Mamba and other sequential model architectures are not suitable for this problem**\n\n## 7. Experiment Progress\n\n### 7.1 Key Milestones\n\n| Stage | Experiment | Public LB | Private LB | Key Improvement |\n|-------|------------|-----------|------------|-----------------|\n| Baseline | v1 | 0.57 | - | 448 size baseline |\n| Model Upgrade | v24 | 0.65 | - | vit_large_dinov3 |\n| Resolution | v32 | 0.69 | - | 512 size |\n| Physics Loss | v42 | 0.69+ | - | PhysicsConsistencyLoss |\n| MoE | v44 | 0.69 | - | Mixture-of-Experts |\n| 1024 Resolution | v108 | 0.73 | - | 1024 size + LR optimization |\n| Full Training | v136 | 0.74+ | - | Full dataset + Species |\n| Seed Optimization | v147 | 0.75+ | - | seed=2026 |\n| Augmentation | v164 | 0.75 | - | TTA-style augmentation |\n| Random Aug | v174 | 0.76 | 0.65 | Random augmentation selection |\n| Ensemble | Final | 0.76 | **0.66** | 6-model weighted ensemble |\n\n### 7.2 Final Private LB Score\n\nBased on submission records, the two final selected submissions:\n- Public LB: 0.76, Private LB: 0.66 (5th Place)\n\n## 8. Conclusion and Experience Sharing\n\n### 8.1 Key Success Factors\n\n1. **Extensive experiments**: Conducted 188+ experiments, fully explored the solution space\n2. **Physical constraints**: Designed loss functions using physical relationships between targets\n3. **Diverse ensemble**: Fusion of models with different seeds, architectures, and training strategies\n4. **Data augmentation**: On small datasets, proper data augmentation is crucial\n5. **Post-processing**: Post-processing strategies based on data analysis\n\n### 8.2 About Submission Selection\n\nSubmission selection was very difficult in this competition, mainly because:\n- Small dataset size, models easily overfit\n- Distribution shift between training and test sets\n- Low correlation between Public LB and Private LB\n\nI ultimately chose the submission with the highest Public LB. Although the Private LB wasn't optimal (0.66 vs 0.65 for some failed submissions), it was relatively stable.\n\n### 8.3 Luck Factor\n\nI must admit there was some luck involved in this result:\n- Saw many top players shake-up during the final standings\n- Submission selection strategy happened to avoid pitfalls\n- Multi-model ensemble improved stability\n\n### 8.4 Acknowledgments\n\nSpecial thanks to:\n- The generous sharing from the Kaggle community\n- Discussions and notebooks from fellow Kagglers\n- CSIRO organizers for providing this meaningful competition\n\nThis solo gold medal is very important for my journey to becoming a Grandmaster! I hope this solution sharing can help everyone.\n\n---\n\n**Code Repository**: [2025-Kaggle-CSIRO-5th-solution](https://github.com/yanqiangmiffy/2025-Kaggle-CSIRO-5th-solution)\n\n**Final Submission Notebook**: [CSIRO-Ensemble-Models-V12](https://www.kaggle.com/code/quincyqiang/csiro-ensemble-models-v12?scriptVersionId=294382133)",
    "3398390": "Wow, you're amazing! You actually made a lot of my 'failed' ideas work. I'm digging into your code right now to learn from it👍",
    "3398402": "Thanks for your lot!",
    "3398413": "I saw a few top solutions. the resolution is all 1024*1024 in commm.I thingk this is the key to improve",
    "3398414": "非常棒的方案，坚持实验的精神值得学习，恭喜你。",
    "3398416": "Yes，the bigger resolution did work! I tried from 224,448,512,768 to 1024",
    "3398420": "确实做了很多实验，也有运气成分，非常感谢认可！🍁🍁",
    "3398446": "dinov2 pretrained on 518*518,dinov2 large with 518*518 can get 0.72 in public. dinov3 pretrained on 512*512, 768*768.Normally dinov3 should be the best in 512and 768.I don't know why it  perform best with 1024*1024.additionally do you use attentionpool butilt in timm?",
    "3398458": "This is great , very insightful. Thanks for sharing .",
    "3398470": "Amazing approach. \nCoincidently, I also tried the same loss function, but it was too early. I did not have the rest of the things figured out till then, like the architecture itself. And it made me think that the loss function is not working for this problem, but it was just a result of unstable experiment. I should have sticked to it, or try it again when the rest was figured out.\n\nMoE is also amazing idea for this problem. I tried doing a Gradient Surgery from the paper: \"Gradient Surgery for Multi Task Learning\" to make sure all tasks are learning well, but never thought like this: If they conflict, let them conflict, just split the experts. WoW.\nI thought if we are using one of the N experts, wouldn't that simply perform like Dropout (except this is not random here) training only the activated expert, but now it is clear. \n\nJust one question: Did you do some sort of analysis to see how much is each expert being activated? \nActually, when I added Task and Prompt tokens, they were converging to near same representations after the training, so I had to add an orthogonality loss. I don't see any such supervision like that here.\nAlso, did you try using more experts? \n\nThanks a lot for this writeup.",
    "3398474": "Thank you for your feedback.",
    "3398477": "I loaded it this way, setting the `img_size` and using average pooling.\n```\nself.backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool=\"avg\")\n```",
    "3398479": "Thank you for your feedback.\n```\nI have another question: Have you conducted any analysis to understand the activation level of each expert?\n```\nI haven't done a specific analysis on that; I used a relatively simple configuration.",
    "3398544": "appreciate your replyly. from you not only I learned detailed code but also I learned the spirit of perseverance. you did so many experiments.It is really a big job",
    "3398587": "absolute cinema",
    "3458853": "I added a new dataset for Image Inpainting\n\n[Urban Street View Image Inpainting-Random Masks](https://www.kaggle.com/datasets/kannanwisen/urban-street-view-image-inpainting-random-masks)"
  },
  "source": "meta"
}