{
  "id": 670785,
  "title": "Rank72 - Simple ensemble with DINO-EVA backbones",
  "url": "/competitions/csiro-biomass/discussion/670785",
  "author_name": "Ravi Ramakrishnan",
  "post_date": "2026-01-29T16:47:27.557000",
  "votes": 18,
  "comment_count": 0,
  "views": 0,
  "content": "<p>Hello all,</p>\n<p>Firstly, we wish to thank Kaggle and the host for this competition. I wish to thank the host specifically for being proactive and answering our questions well within the rules of the competition as well. Also, I wish to thank the forum contributors for being generous and sharing excellent discussion ideas and kernels for one and all. </p>\n<p>Let's delve into the assignment solution and discuss the architecture and model choices below-</p>\n<h1>Assignment details</h1>\n<p>This was a 5-target regression image problem to estimate the pasture biomass from training images covering Australian pastures across different seasons, regions, and species mixes, along with metadata including height, NDVI, state and species details. The test data consists of images only without meta-data provided. The host provided 357 training images and nearly 800 test-set images divided into 53% public leaderboard and 47% private leaderboard. </p>\n<p><strong>Metadata columns</strong></p>\n<ul>\n<li>sample_id — Unique identifier for each training sample (image).</li>\n<li>image_path — Relative path to the training image (e.g., images/ID1098771283.jpg).</li>\n<li>Sampling_Date — Date of sample collection.</li>\n<li>State — Australian state where sample was collected.</li>\n<li>Species — Pasture species present, ordered by biomass (underscore-separated).</li>\n<li>Pre_GSHH_NDVI — Normalized Difference Vegetation Index (GreenSeeker) reading.</li>\n<li>Height_Ave_cm — Average pasture height measured by falling plate (cm).</li>\n<li>target_name — Biomass component name for this row (Dry_Green_g, Dry_Dead_g, Dry_Clover_g, GDM_g, or Dry_Total_g).</li>\n<li>target — Ground-truth biomass value (grams) corresponding to target_name for this image.</li>\n</ul>\n<p><strong>Provided targets -</strong> </p>\n<ul>\n<li>Dry green vegetation (excluding clover)</li>\n<li>Dry dead material</li>\n<li>Dry clover biomass</li>\n<li>Green dry matter (GDM)</li>\n<li>Total dry biomass</li>\n</ul>\n<p>These targets had the below mathematical relationships - \n$$ Total = Dead + GDM $$\n$$ GDM = Green + Clover $$\n$$ Total = Dead + Green + Clover $$</p>\n<h1>CV scheme details</h1>\n<p>This was one of the most tricky and time consuming components in this competition. I joined this competition early and most of my initial experiments in the first 4-5 weeks focused on an untuned EVA02 base model with the below CV schemes -</p>\n<table>\n<thead>\n<tr>\n<th>CV scheme</th>\n<th>Results - CV/ LB relation</th>\n<th>Average CV-LB gap</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>K-fold (5-fold)</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.2-0.25</td>\n</tr>\n<tr>\n<td>Stratified K-fold (5-fold) on state</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.2-0.25</td>\n</tr>\n<tr>\n<td>Stratified K-fold (5-fold) on state and species</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.15-0.20</td>\n</tr>\n<tr>\n<td>Group-K-fold (5-fold) on month of sample date</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.10- 0.175</td>\n</tr>\n<tr>\n<td>Stratified Group-K-fold (5-fold) on state, group by month of sample date</td>\n<td>LB and CV were partially in sync</td>\n<td>0.025-0.06</td>\n</tr>\n</tbody>\n</table>\n<p><br>We ultimately chose to proceed with the Stratified Group-5-fold scheme for our later experiments. <a href=\"https://www.kaggle.com/kishanvavdara\" target=\"_blank\">@kishanvavdara</a> joined the team at this stage and we set out to train models for the final solution. </p>\n<h1>Model details</h1>\n<p>We focused on the below models thereafter - </p>\n<table>\n<thead>\n<tr>\n<th>Model type</th>\n<th>CV range</th>\n<th>LB range</th>\n<th>Comments</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>EVA02 base</td>\n<td>0.65-0.70</td>\n<td>0.55 - 0.60</td>\n<td>Poor LB but quick training pipeline - useful for prototyping</td>\n</tr>\n<tr>\n<td>EVA02 large</td>\n<td>0.68-0.73</td>\n<td>0.62 - 0.67</td>\n<td>Poor LB but provided diversity in the ensemble and useful for prototyping also</td>\n</tr>\n<tr>\n<td>DINOV2 base</td>\n<td>0.65-0.69</td>\n<td>0.62 - 0.66</td>\n<td>Good start point for DINO architectures and quick training</td>\n</tr>\n<tr>\n<td>DINOV2 large</td>\n<td>0.675-0.72</td>\n<td>0.64 - 0.68</td>\n<td>Provided a good option to test the freeze-full train structure - quick training as well</td>\n</tr>\n<tr>\n<td>DINOV3 large</td>\n<td>0.725-0.765</td>\n<td>0.69 - 0.72</td>\n<td>Provided a good solution with moderate CV-LB relation and allowed quick prototyping for larger models</td>\n</tr>\n<tr>\n<td>DINOV3 huge</td>\n<td>0.76 - 0.7925</td>\n<td>0.71-0.74</td>\n<td>Cynosure for the final solution</td>\n</tr>\n</tbody>\n</table>\n<p><br>We were well aware of the high usage of DINOV3 models across the top LB solutions. To engender diversity in our solution compared to others, we focused on the below-</p>\n<h2>Target structure</h2>\n<ul>\n<li>3 target training heads (Dry-dead, Green, Clover) and 2 target predictions after the model </li>\n<li>4-target training heads with auxiliary ratio Dry-dead / Total along with Dry-dead, Green, Clover training heads. Other targets were derived suitably</li>\n<li>5-training heads, one for each target with adjustments for total biomass and green dry matter based on the relations above</li>\n</ul>\n<h2>Image splitting process</h2>\n<table>\n<thead>\n<tr>\n<th>Image splits</th>\n<th>Comment</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>1</td>\n<td>Provided no reasonable CV-LB relation and was excluded</td>\n</tr>\n<tr>\n<td>2</td>\n<td>Provided decent CV-LB relation but it provided room for improvement</td>\n</tr>\n<tr>\n<td>3</td>\n<td>Provided improvement over 2-split, image was split into 3 equal parts</td>\n</tr>\n<tr>\n<td>4</td>\n<td>Provided best CV-LB relation, image was split into 4 equal parts</td>\n</tr>\n</tbody>\n</table>\n<p><br>Sample code for this is as below-</p>\n<pre><code>H, W = image.shape[:2]  # H=1000, W=2000\nquarter = W // 4         # quarter=500\n\npart1 = image[:, :quarter, :]              # [0:500]\npart2 = image[:, quarter:2*quarter, :]      # [500:1000]\npart3 = image[:, 2*quarter:3*quarter, :]    # [1000:1500]\npart4 = image[:, 3*quarter:, :]             # [1500:2000]\n</code></pre>\n<h2>Preprocessing Pipeline</h2>\n<p>Augmentation (training only)</p>\n<ul>\n<li>Horizontal flip (p=0.5)</li>\n<li>Vertical flip (p=0.5)</li>\n<li>Random 90° rotation (p=0.5)</li>\n<li>Color jitter (brightness, contrast, saturation, hue)</li>\n</ul>\n<p>Resize: 1000×500 → 448×448</p>\n<p>Normalization: ImageNet statistics</p>\n<ul>\n<li>Mean: [0.485, 0.456, 0.406]</li>\n<li>Std: [0.229, 0.224, 0.225]</li>\n</ul>\n<p>Finally, this was converted to PyTorch tensors for the model</p>\n<ul>\n<li>We excluded a specific sample (ID230058600.jpg) from training, likely an outlier. This was from our EDA and public discussions</li>\n</ul>\n<h2>Model architecture</h2>\n<ul>\n<li>Self-supervised pre-trained on large-scale image data</li>\n<li>Patch size: 16×16</li>\n<li>Shared weights across all 4 streams</li>\n<li>Global average pooling applied</li>\n</ul>\n<pre><code>def forward(self, part1, part2, part3, part4):\n    # Each part: [B, 3, 448, 448]\n\n    # Extract features using shared backbone\n    feat1 = self.backbone(part1)  # [B, 1536]\n    feat2 = self.backbone(part2)  # [B, 1536]\n    feat3 = self.backbone(part3)  # [B, 1536]\n    feat4 = self.backbone(part4)  # [B, 1536]\n\n    # Concatenate features\n    combined = torch.cat([feat1, feat2, feat3, feat4], dim=1)  \n</code></pre>\n<p><strong>Key Design Choices</strong></p>\n<p>Shared Backbone Weights</p>\n<ul>\n<li>Reduces parameters (only 1 backbone instead of 4)</li>\n<li>Forces consistent feature extraction</li>\n<li>Better generalization with limited data</li>\n</ul>\n<p>Late Fusion</p>\n<ul>\n<li>Features concatenated after extraction</li>\n<li>Allows each stream to process independently</li>\n<li>Combined features capture spatial relationships</li>\n</ul>\n<h2>Target heads</h2>\n<p>We used a SwiGLU module that outperformed a traditional ReLU/GELU. Code for this is as below - </p>\n<pre><code>class SwiGLU(nn.Module):\n    def __init__(self, input_dim, hidden_dim, output_dim, dropout=0.1):\n        super().__init__()\n        self.gate_proj = nn.Linear(input_dim, hidden_dim)\n        self.value_proj = nn.Linear(input_dim, hidden_dim)\n        self.down_proj = nn.Linear(hidden_dim, output_dim)\n        self.dropout = nn.Dropout(dropout)\n        self.silu = nn.SiLU()  # Swish activation\n\n    def forward(self, x):\n        gate = self.silu(self.gate_proj(x))   # Apply Swish\n        value = self.value_proj(x)             # Linear transform\n        hidden = gate * value                  # Element-wise multiplication\n        hidden = self.dropout(hidden)\n        output = self.down_proj(hidden)\n        return output\n</code></pre>\n<p>Usage of swiglu was optional, we tried a conventional approach as well , with the details as below -</p>\n<pre><code>if use_swiglu:\n    hidden_expanded = hidden * 2  # 6144 for hidden_ratio=0.5\n    def make_head():\n        return SwiGLU(\n            input_dim=combined,      # 6144\n            hidden_dim=hidden_expanded,  # 6144\n            output_dim=1,\n            dropout=dropout\n        )\nelse:\n    def make_head():\n        return nn.Sequential(\n            nn.Linear(combined, hidden),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout),\n            nn.Linear(hidden, 1),\n        )\n\nself.head_green = make_head()\nself.head_clover = make_head()  # (if needed)\nself.head_dead = make_head()\n</code></pre>\n<p><br>Swiglu was quite helpful and provided a boost in our CV-LB relation. Our best single models used swiglu.</p>\n<p>We used soft-plus activation across our target heads as below - <br>\n<code>self.softplus = nn.Softplus(beta=1.0)</code></p>\n<pre><code>green = self.softplus(self.head_green(combined))\nclover = self.softplus(self.head_clover(combined))\ndead = self.softplus(self.head_dead(combined))\n</code></pre>\n<p>$$ Softplus(x, β) = log(1 + exp(βx)) / β $$</p>\n<h2>Output Assembly</h2>\n<p>We concatenate the outputs from the heads as below - </p>\n<pre><code>outputs = torch.cat([green, clover, dead], dim=1)  # [B, 3] or [B, 5]\nreturn outputs\n</code></pre>\n<h2>Target derivation</h2>\n<p>One such case using a simple 3-2 targets prediction / derivation is as below- </p>\n<pre><code>def calculate_derived_targets(preds_3, states=None):\n    pred_green = preds_3[:, 0]\n    pred_clover = preds_3[:, 1]\n    pred_dead = preds_3[:, 2]\n\n    # Apply ReLU to ensure non-negative\n    pred_green = np.maximum(pred_green, 0)\n    pred_clover = np.maximum(pred_clover, 0)\n    pred_dead = np.maximum(pred_dead, 0)\n\n    # Derive aggregate targets\n    pred_gdm = pred_green + pred_clover     # GDM = Green Dry Matter\n    pred_total = pred_gdm + pred_dead       # Total = GDM + Dead\n\n    # Assemble final 5 targets\n    preds_5 = np.zeros((len(preds_3), 5), dtype=np.float32)\n    preds_5[:, 0] = pred_green\n    preds_5[:, 1] = pred_dead\n    preds_5[:, 2] = pred_clover\n    preds_5[:, 3] = pred_gdm\n    preds_5[:, 4] = pred_total\n\n    return preds_5\n</code></pre>\n<p>For the 5-target case, we don't derive anything and directly do - <code>all_preds_5 = np.maximum(all_preds_5, 0)</code></p>\n<h2>Loss function</h2>\n<p>We used a simple L1 smooth loss for our submission. We tried weighted R2 loss, L1 loss, MSE loss but smooth L1 loss was the best</p>\n<pre><code>class WeightedSmoothL1Loss(nn.Module):\n    def __init__(self, weights):\n        super().__init__()\n        self.weights = torch.tensor(weights, dtype=torch.float32)\n        self.loss_fn = nn.SmoothL1Loss(reduction='none')\n\n    def forward(self, y_pred, y_true):\n        self.weights = self.weights.to(y_pred.device)\n        loss = self.loss_fn(y_pred, y_true)  # [B, num_targets]\n        weighted_loss = (self.weights * loss).mean(dim=1)  # [B]\n        return weighted_loss.mean()\n</code></pre>\n<h2>Model training</h2>\n<ul>\n<li>We froze the model pipeline for n-epochs, n being 1-6 at the start of training </li>\n<li>We finetuned the model parameters thereafter till our chosen epochs (usually 15-20)</li>\n</ul>\n<pre><code>if epoch &lt; freeze_epochs:\n    for param in model.backbone.parameters():\n        param.requires_grad = False\nelse:\n    # Freeze all backbone\n    for param in model.backbone.parameters():\n        param.requires_grad = False\n\n    # Unfreeze last 6 layers\n    total_layers = len(model.backbone.blocks)\n    start_idx = total_layers - 6\n\n    for i in range(start_idx, total_layers):\n        for param in model.backbone.blocks[i].parameters():\n            param.requires_grad = True\n\n    # Unfreeze norm layer\n    if hasattr(model.backbone, 'norm'):\n        for param in model.backbone.norm.parameters():\n            param.requires_grad = True\n\n    # Reduce learning rate\n    for param_group in optimizer.param_groups:\n        param_group['lr'] = lr_stage2\n</code></pre>\n<h2>Evaluation metric</h2>\n<p>We used the competition metric as below to evaluate the model OOF - </p>\n<pre><code>def weighted_r2_score(y_true, y_pred, weights):\n    weights = np.array(weights)\n    y_true_flat = y_true.flatten()\n    y_pred_flat = y_pred.flatten()\n    weights_flat = np.tile(weights, len(y_true))\n\n    ss_res = np.sum(weights_flat * (y_true_flat - y_pred_flat) ** 2)\n    y_mean = np.sum(weights_flat * y_true_flat) / np.sum(weights_flat)\n    ss_tot = np.sum(weights_flat * (y_true_flat - y_mean) ** 2)\n\n    return 1 - ss_res / ss_tot\n</code></pre>\n<h2>Full refit</h2>\n<ul>\n<li>Considering the lack of CV-LB relations across experiments, we focused on a multi-seed refit approach and fitted 3-5 seed models on the full data</li>\n<li>Refit models were used for the inference</li>\n</ul>\n<h2>Test-time augmentation</h2>\n<p>We used the below augmentations during inference - </p>\n<ul>\n<li>Original</li>\n<li>Horizontal flip</li>\n<li>Vertical flip</li>\n<li>Random 90° rotation</li>\n<li>Color jitter </li>\n</ul>\n<h1>Ensemble</h1>\n<p>Our ensemble strategy included the below individual model ideas - </p>\n<table>\n<thead>\n<tr>\n<th>Model ID</th>\n<th>Model idea</th>\n<th>CV score</th>\n<th>Single LB score</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>DINOV7_17</td>\n<td>- 3 train targets, 2 derived targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1</td>\n<td>0.792425</td>\n<td>0.74</td>\n</tr>\n<tr>\n<td>DINOV7_30</td>\n<td>- 3 train targets, 2 derived targets <br> - 5-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1</td>\n<td>0.78498377</td>\n<td>0.73</td>\n</tr>\n<tr>\n<td>DINOV7_34</td>\n<td>- 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 7-last layers unfrozen <br> - Dropped bottom 10% image due to higher risk of non-grass components in this region <br> - Freeze till epoch 1</td>\n<td>0.78237804</td>\n<td>0.73</td>\n</tr>\n<tr>\n<td>DINOV7_36</td>\n<td>- 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Sequential unfreezing after epoch 1 across 4 stages</td>\n<td>0.78187030</td>\n<td>0.73</td>\n</tr>\n<tr>\n<td>DINOV7_17_5T</td>\n<td>- 5 train targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1</td>\n<td>0.788456</td>\n<td>0.74</td>\n</tr>\n<tr>\n<td>DINOV9_10</td>\n<td>- 5-target prediction and derivation/ estimation of dead biomass based on predicted height <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1 <br> - 4-stream architecture</td>\n<td>0.79041560</td>\n<td>0.72</td>\n</tr>\n<tr>\n<td>EVA02V8_3</td>\n<td>- 3 train targets, 2 derived targets <br> - swiglu <br> - Freeze till epoch 1</td>\n<td>0.73975521</td>\n<td>0.66</td>\n</tr>\n</tbody>\n</table>\n<p><br>We blended the models using -</p>\n<ol>\n<li>Simple ridge - CV = 0.7989367</li>\n<li>Hill climber with positive weights - CV = 0.81518447</li>\n</ol>\n<h1>Post-processing scalers</h1>\n<ul>\n<li>We preferred to avoid post-processing scaler adjustments and are happy our submission without this outscored the one with post-processing</li>\n<li>Our 2nd submission was a leaderboard focused submission that blended the best public notebook with our 0.74 models with post-processing. This one scored 0.62 on the leaderboard</li>\n</ul>\n<h1>Opportunity costs</h1>\n<p>4 of our single models outscored our chosen kernel. All of them were DINOv3 Huge backbones and froze the parameters till epoch-1. <br>\nThey are as below-</p>\n<table>\n<thead>\n<tr>\n<th>Model idea</th>\n<th>Private LB score</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>- 5 target predictions with residual connections</td>\n<td>0.64</td>\n</tr>\n<tr>\n<td>- 3+2 target predictions with linear scheduler instead of cosine</td>\n<td>0.63 (high end)</td>\n</tr>\n<tr>\n<td>- 4 target predictions with dry/total auxiliary ratio with linear scheduler instead of cosine</td>\n<td>0.63 (high end)</td>\n</tr>\n<tr>\n<td>- 5 target predictions with estimation of dead biomass based on predicted height</td>\n<td>0.63 (slightly above chosen kernel)</td>\n</tr>\n</tbody>\n</table>\n<p><br>We learn that good single models can also be of value in small data competitions - a model blend is not always necessary. </p>\n<h1>Outro</h1>\n<p>Thanks for reading the write-up! Hope we were able to articulate the process with some aplomb!! Let us know your thoughts and we will respond appropriately.</p>\n<p>Best wishes for your personal and professional/ Kaggle journey ahead!</p>\n<p>Regards, <br>\nRavi Ramakrishnan <br>\nKishan Vavdara <br>\nTony Li<br></p>",
  "messages": [
    {
      "id": 3398771,
      "postDate": "2026-01-29T16:47:27.557Z",
      "content": "<p>Hello all,</p>\n<p>Firstly, we wish to thank Kaggle and the host for this competition. I wish to thank the host specifically for being proactive and answering our questions well within the rules of the competition as well. Also, I wish to thank the forum contributors for being generous and sharing excellent discussion ideas and kernels for one and all. </p>\n<p>Let's delve into the assignment solution and discuss the architecture and model choices below-</p>\n<h1>Assignment details</h1>\n<p>This was a 5-target regression image problem to estimate the pasture biomass from training images covering Australian pastures across different seasons, regions, and species mixes, along with metadata including height, NDVI, state and species details. The test data consists of images only without meta-data provided. The host provided 357 training images and nearly 800 test-set images divided into 53% public leaderboard and 47% private leaderboard. </p>\n<p><strong>Metadata columns</strong></p>\n<ul>\n<li>sample_id — Unique identifier for each training sample (image).</li>\n<li>image_path — Relative path to the training image (e.g., images/ID1098771283.jpg).</li>\n<li>Sampling_Date — Date of sample collection.</li>\n<li>State — Australian state where sample was collected.</li>\n<li>Species — Pasture species present, ordered by biomass (underscore-separated).</li>\n<li>Pre_GSHH_NDVI — Normalized Difference Vegetation Index (GreenSeeker) reading.</li>\n<li>Height_Ave_cm — Average pasture height measured by falling plate (cm).</li>\n<li>target_name — Biomass component name for this row (Dry_Green_g, Dry_Dead_g, Dry_Clover_g, GDM_g, or Dry_Total_g).</li>\n<li>target — Ground-truth biomass value (grams) corresponding to target_name for this image.</li>\n</ul>\n<p><strong>Provided targets -</strong> </p>\n<ul>\n<li>Dry green vegetation (excluding clover)</li>\n<li>Dry dead material</li>\n<li>Dry clover biomass</li>\n<li>Green dry matter (GDM)</li>\n<li>Total dry biomass</li>\n</ul>\n<p>These targets had the below mathematical relationships - \n$$ Total = Dead + GDM $$\n$$ GDM = Green + Clover $$\n$$ Total = Dead + Green + Clover $$</p>\n<h1>CV scheme details</h1>\n<p>This was one of the most tricky and time consuming components in this competition. I joined this competition early and most of my initial experiments in the first 4-5 weeks focused on an untuned EVA02 base model with the below CV schemes -</p>\n<table>\n<thead>\n<tr>\n<th>CV scheme</th>\n<th>Results - CV/ LB relation</th>\n<th>Average CV-LB gap</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>K-fold (5-fold)</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.2-0.25</td>\n</tr>\n<tr>\n<td>Stratified K-fold (5-fold) on state</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.2-0.25</td>\n</tr>\n<tr>\n<td>Stratified K-fold (5-fold) on state and species</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.15-0.20</td>\n</tr>\n<tr>\n<td>Group-K-fold (5-fold) on month of sample date</td>\n<td>LB and CV were not in sync at all</td>\n<td>0.10- 0.175</td>\n</tr>\n<tr>\n<td>Stratified Group-K-fold (5-fold) on state, group by month of sample date</td>\n<td>LB and CV were partially in sync</td>\n<td>0.025-0.06</td>\n</tr>\n</tbody>\n</table>\n<p><br>We ultimately chose to proceed with the Stratified Group-5-fold scheme for our later experiments. <a href=\"https://www.kaggle.com/kishanvavdara\" target=\"_blank\">@kishanvavdara</a> joined the team at this stage and we set out to train models for the final solution. </p>\n<h1>Model details</h1>\n<p>We focused on the below models thereafter - </p>\n<table>\n<thead>\n<tr>\n<th>Model type</th>\n<th>CV range</th>\n<th>LB range</th>\n<th>Comments</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>EVA02 base</td>\n<td>0.65-0.70</td>\n<td>0.55 - 0.60</td>\n<td>Poor LB but quick training pipeline - useful for prototyping</td>\n</tr>\n<tr>\n<td>EVA02 large</td>\n<td>0.68-0.73</td>\n<td>0.62 - 0.67</td>\n<td>Poor LB but provided diversity in the ensemble and useful for prototyping also</td>\n</tr>\n<tr>\n<td>DINOV2 base</td>\n<td>0.65-0.69</td>\n<td>0.62 - 0.66</td>\n<td>Good start point for DINO architectures and quick training</td>\n</tr>\n<tr>\n<td>DINOV2 large</td>\n<td>0.675-0.72</td>\n<td>0.64 - 0.68</td>\n<td>Provided a good option to test the freeze-full train structure - quick training as well</td>\n</tr>\n<tr>\n<td>DINOV3 large</td>\n<td>0.725-0.765</td>\n<td>0.69 - 0.72</td>\n<td>Provided a good solution with moderate CV-LB relation and allowed quick prototyping for larger models</td>\n</tr>\n<tr>\n<td>DINOV3 huge</td>\n<td>0.76 - 0.7925</td>\n<td>0.71-0.74</td>\n<td>Cynosure for the final solution</td>\n</tr>\n</tbody>\n</table>\n<p><br>We were well aware of the high usage of DINOV3 models across the top LB solutions. To engender diversity in our solution compared to others, we focused on the below-</p>\n<h2>Target structure</h2>\n<ul>\n<li>3 target training heads (Dry-dead, Green, Clover) and 2 target predictions after the model </li>\n<li>4-target training heads with auxiliary ratio Dry-dead / Total along with Dry-dead, Green, Clover training heads. Other targets were derived suitably</li>\n<li>5-training heads, one for each target with adjustments for total biomass and green dry matter based on the relations above</li>\n</ul>\n<h2>Image splitting process</h2>\n<table>\n<thead>\n<tr>\n<th>Image splits</th>\n<th>Comment</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>1</td>\n<td>Provided no reasonable CV-LB relation and was excluded</td>\n</tr>\n<tr>\n<td>2</td>\n<td>Provided decent CV-LB relation but it provided room for improvement</td>\n</tr>\n<tr>\n<td>3</td>\n<td>Provided improvement over 2-split, image was split into 3 equal parts</td>\n</tr>\n<tr>\n<td>4</td>\n<td>Provided best CV-LB relation, image was split into 4 equal parts</td>\n</tr>\n</tbody>\n</table>\n<p><br>Sample code for this is as below-</p>\n<pre><code>H, W = image.shape[:2]  # H=1000, W=2000\nquarter = W // 4         # quarter=500\n\npart1 = image[:, :quarter, :]              # [0:500]\npart2 = image[:, quarter:2*quarter, :]      # [500:1000]\npart3 = image[:, 2*quarter:3*quarter, :]    # [1000:1500]\npart4 = image[:, 3*quarter:, :]             # [1500:2000]\n</code></pre>\n<h2>Preprocessing Pipeline</h2>\n<p>Augmentation (training only)</p>\n<ul>\n<li>Horizontal flip (p=0.5)</li>\n<li>Vertical flip (p=0.5)</li>\n<li>Random 90° rotation (p=0.5)</li>\n<li>Color jitter (brightness, contrast, saturation, hue)</li>\n</ul>\n<p>Resize: 1000×500 → 448×448</p>\n<p>Normalization: ImageNet statistics</p>\n<ul>\n<li>Mean: [0.485, 0.456, 0.406]</li>\n<li>Std: [0.229, 0.224, 0.225]</li>\n</ul>\n<p>Finally, this was converted to PyTorch tensors for the model</p>\n<ul>\n<li>We excluded a specific sample (ID230058600.jpg) from training, likely an outlier. This was from our EDA and public discussions</li>\n</ul>\n<h2>Model architecture</h2>\n<ul>\n<li>Self-supervised pre-trained on large-scale image data</li>\n<li>Patch size: 16×16</li>\n<li>Shared weights across all 4 streams</li>\n<li>Global average pooling applied</li>\n</ul>\n<pre><code>def forward(self, part1, part2, part3, part4):\n    # Each part: [B, 3, 448, 448]\n\n    # Extract features using shared backbone\n    feat1 = self.backbone(part1)  # [B, 1536]\n    feat2 = self.backbone(part2)  # [B, 1536]\n    feat3 = self.backbone(part3)  # [B, 1536]\n    feat4 = self.backbone(part4)  # [B, 1536]\n\n    # Concatenate features\n    combined = torch.cat([feat1, feat2, feat3, feat4], dim=1)  \n</code></pre>\n<p><strong>Key Design Choices</strong></p>\n<p>Shared Backbone Weights</p>\n<ul>\n<li>Reduces parameters (only 1 backbone instead of 4)</li>\n<li>Forces consistent feature extraction</li>\n<li>Better generalization with limited data</li>\n</ul>\n<p>Late Fusion</p>\n<ul>\n<li>Features concatenated after extraction</li>\n<li>Allows each stream to process independently</li>\n<li>Combined features capture spatial relationships</li>\n</ul>\n<h2>Target heads</h2>\n<p>We used a SwiGLU module that outperformed a traditional ReLU/GELU. Code for this is as below - </p>\n<pre><code>class SwiGLU(nn.Module):\n    def __init__(self, input_dim, hidden_dim, output_dim, dropout=0.1):\n        super().__init__()\n        self.gate_proj = nn.Linear(input_dim, hidden_dim)\n        self.value_proj = nn.Linear(input_dim, hidden_dim)\n        self.down_proj = nn.Linear(hidden_dim, output_dim)\n        self.dropout = nn.Dropout(dropout)\n        self.silu = nn.SiLU()  # Swish activation\n\n    def forward(self, x):\n        gate = self.silu(self.gate_proj(x))   # Apply Swish\n        value = self.value_proj(x)             # Linear transform\n        hidden = gate * value                  # Element-wise multiplication\n        hidden = self.dropout(hidden)\n        output = self.down_proj(hidden)\n        return output\n</code></pre>\n<p>Usage of swiglu was optional, we tried a conventional approach as well , with the details as below -</p>\n<pre><code>if use_swiglu:\n    hidden_expanded = hidden * 2  # 6144 for hidden_ratio=0.5\n    def make_head():\n        return SwiGLU(\n            input_dim=combined,      # 6144\n            hidden_dim=hidden_expanded,  # 6144\n            output_dim=1,\n            dropout=dropout\n        )\nelse:\n    def make_head():\n        return nn.Sequential(\n            nn.Linear(combined, hidden),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout),\n            nn.Linear(hidden, 1),\n        )\n\nself.head_green = make_head()\nself.head_clover = make_head()  # (if needed)\nself.head_dead = make_head()\n</code></pre>\n<p><br>Swiglu was quite helpful and provided a boost in our CV-LB relation. Our best single models used swiglu.</p>\n<p>We used soft-plus activation across our target heads as below - <br>\n<code>self.softplus = nn.Softplus(beta=1.0)</code></p>\n<pre><code>green = self.softplus(self.head_green(combined))\nclover = self.softplus(self.head_clover(combined))\ndead = self.softplus(self.head_dead(combined))\n</code></pre>\n<p>$$ Softplus(x, β) = log(1 + exp(βx)) / β $$</p>\n<h2>Output Assembly</h2>\n<p>We concatenate the outputs from the heads as below - </p>\n<pre><code>outputs = torch.cat([green, clover, dead], dim=1)  # [B, 3] or [B, 5]\nreturn outputs\n</code></pre>\n<h2>Target derivation</h2>\n<p>One such case using a simple 3-2 targets prediction / derivation is as below- </p>\n<pre><code>def calculate_derived_targets(preds_3, states=None):\n    pred_green = preds_3[:, 0]\n    pred_clover = preds_3[:, 1]\n    pred_dead = preds_3[:, 2]\n\n    # Apply ReLU to ensure non-negative\n    pred_green = np.maximum(pred_green, 0)\n    pred_clover = np.maximum(pred_clover, 0)\n    pred_dead = np.maximum(pred_dead, 0)\n\n    # Derive aggregate targets\n    pred_gdm = pred_green + pred_clover     # GDM = Green Dry Matter\n    pred_total = pred_gdm + pred_dead       # Total = GDM + Dead\n\n    # Assemble final 5 targets\n    preds_5 = np.zeros((len(preds_3), 5), dtype=np.float32)\n    preds_5[:, 0] = pred_green\n    preds_5[:, 1] = pred_dead\n    preds_5[:, 2] = pred_clover\n    preds_5[:, 3] = pred_gdm\n    preds_5[:, 4] = pred_total\n\n    return preds_5\n</code></pre>\n<p>For the 5-target case, we don't derive anything and directly do - <code>all_preds_5 = np.maximum(all_preds_5, 0)</code></p>\n<h2>Loss function</h2>\n<p>We used a simple L1 smooth loss for our submission. We tried weighted R2 loss, L1 loss, MSE loss but smooth L1 loss was the best</p>\n<pre><code>class WeightedSmoothL1Loss(nn.Module):\n    def __init__(self, weights):\n        super().__init__()\n        self.weights = torch.tensor(weights, dtype=torch.float32)\n        self.loss_fn = nn.SmoothL1Loss(reduction='none')\n\n    def forward(self, y_pred, y_true):\n        self.weights = self.weights.to(y_pred.device)\n        loss = self.loss_fn(y_pred, y_true)  # [B, num_targets]\n        weighted_loss = (self.weights * loss).mean(dim=1)  # [B]\n        return weighted_loss.mean()\n</code></pre>\n<h2>Model training</h2>\n<ul>\n<li>We froze the model pipeline for n-epochs, n being 1-6 at the start of training </li>\n<li>We finetuned the model parameters thereafter till our chosen epochs (usually 15-20)</li>\n</ul>\n<pre><code>if epoch &lt; freeze_epochs:\n    for param in model.backbone.parameters():\n        param.requires_grad = False\nelse:\n    # Freeze all backbone\n    for param in model.backbone.parameters():\n        param.requires_grad = False\n\n    # Unfreeze last 6 layers\n    total_layers = len(model.backbone.blocks)\n    start_idx = total_layers - 6\n\n    for i in range(start_idx, total_layers):\n        for param in model.backbone.blocks[i].parameters():\n            param.requires_grad = True\n\n    # Unfreeze norm layer\n    if hasattr(model.backbone, 'norm'):\n        for param in model.backbone.norm.parameters():\n            param.requires_grad = True\n\n    # Reduce learning rate\n    for param_group in optimizer.param_groups:\n        param_group['lr'] = lr_stage2\n</code></pre>\n<h2>Evaluation metric</h2>\n<p>We used the competition metric as below to evaluate the model OOF - </p>\n<pre><code>def weighted_r2_score(y_true, y_pred, weights):\n    weights = np.array(weights)\n    y_true_flat = y_true.flatten()\n    y_pred_flat = y_pred.flatten()\n    weights_flat = np.tile(weights, len(y_true))\n\n    ss_res = np.sum(weights_flat * (y_true_flat - y_pred_flat) ** 2)\n    y_mean = np.sum(weights_flat * y_true_flat) / np.sum(weights_flat)\n    ss_tot = np.sum(weights_flat * (y_true_flat - y_mean) ** 2)\n\n    return 1 - ss_res / ss_tot\n</code></pre>\n<h2>Full refit</h2>\n<ul>\n<li>Considering the lack of CV-LB relations across experiments, we focused on a multi-seed refit approach and fitted 3-5 seed models on the full data</li>\n<li>Refit models were used for the inference</li>\n</ul>\n<h2>Test-time augmentation</h2>\n<p>We used the below augmentations during inference - </p>\n<ul>\n<li>Original</li>\n<li>Horizontal flip</li>\n<li>Vertical flip</li>\n<li>Random 90° rotation</li>\n<li>Color jitter </li>\n</ul>\n<h1>Ensemble</h1>\n<p>Our ensemble strategy included the below individual model ideas - </p>\n<table>\n<thead>\n<tr>\n<th>Model ID</th>\n<th>Model idea</th>\n<th>CV score</th>\n<th>Single LB score</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>DINOV7_17</td>\n<td>- 3 train targets, 2 derived targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1</td>\n<td>0.792425</td>\n<td>0.74</td>\n</tr>\n<tr>\n<td>DINOV7_30</td>\n<td>- 3 train targets, 2 derived targets <br> - 5-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1</td>\n<td>0.78498377</td>\n<td>0.73</td>\n</tr>\n<tr>\n<td>DINOV7_34</td>\n<td>- 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 7-last layers unfrozen <br> - Dropped bottom 10% image due to higher risk of non-grass components in this region <br> - Freeze till epoch 1</td>\n<td>0.78237804</td>\n<td>0.73</td>\n</tr>\n<tr>\n<td>DINOV7_36</td>\n<td>- 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Sequential unfreezing after epoch 1 across 4 stages</td>\n<td>0.78187030</td>\n<td>0.73</td>\n</tr>\n<tr>\n<td>DINOV7_17_5T</td>\n<td>- 5 train targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1</td>\n<td>0.788456</td>\n<td>0.74</td>\n</tr>\n<tr>\n<td>DINOV9_10</td>\n<td>- 5-target prediction and derivation/ estimation of dead biomass based on predicted height <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1 <br> - 4-stream architecture</td>\n<td>0.79041560</td>\n<td>0.72</td>\n</tr>\n<tr>\n<td>EVA02V8_3</td>\n<td>- 3 train targets, 2 derived targets <br> - swiglu <br> - Freeze till epoch 1</td>\n<td>0.73975521</td>\n<td>0.66</td>\n</tr>\n</tbody>\n</table>\n<p><br>We blended the models using -</p>\n<ol>\n<li>Simple ridge - CV = 0.7989367</li>\n<li>Hill climber with positive weights - CV = 0.81518447</li>\n</ol>\n<h1>Post-processing scalers</h1>\n<ul>\n<li>We preferred to avoid post-processing scaler adjustments and are happy our submission without this outscored the one with post-processing</li>\n<li>Our 2nd submission was a leaderboard focused submission that blended the best public notebook with our 0.74 models with post-processing. This one scored 0.62 on the leaderboard</li>\n</ul>\n<h1>Opportunity costs</h1>\n<p>4 of our single models outscored our chosen kernel. All of them were DINOv3 Huge backbones and froze the parameters till epoch-1. <br>\nThey are as below-</p>\n<table>\n<thead>\n<tr>\n<th>Model idea</th>\n<th>Private LB score</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>- 5 target predictions with residual connections</td>\n<td>0.64</td>\n</tr>\n<tr>\n<td>- 3+2 target predictions with linear scheduler instead of cosine</td>\n<td>0.63 (high end)</td>\n</tr>\n<tr>\n<td>- 4 target predictions with dry/total auxiliary ratio with linear scheduler instead of cosine</td>\n<td>0.63 (high end)</td>\n</tr>\n<tr>\n<td>- 5 target predictions with estimation of dead biomass based on predicted height</td>\n<td>0.63 (slightly above chosen kernel)</td>\n</tr>\n</tbody>\n</table>\n<p><br>We learn that good single models can also be of value in small data competitions - a model blend is not always necessary. </p>\n<h1>Outro</h1>\n<p>Thanks for reading the write-up! Hope we were able to articulate the process with some aplomb!! Let us know your thoughts and we will respond appropriately.</p>\n<p>Best wishes for your personal and professional/ Kaggle journey ahead!</p>\n<p>Regards, <br>\nRavi Ramakrishnan <br>\nKishan Vavdara <br>\nTony Li<br></p>",
      "rawMarkdown": "Hello all,\n\nFirstly, we wish to thank Kaggle and the host for this competition. I wish to thank the host specifically for being proactive and answering our questions well within the rules of the competition as well. Also, I wish to thank the forum contributors for being generous and sharing excellent discussion ideas and kernels for one and all. \n\nLet's delve into the assignment solution and discuss the architecture and model choices below-\n\n# Assignment details \n\nThis was a 5-target regression image problem to estimate the pasture biomass from training images covering Australian pastures across different seasons, regions, and species mixes, along with metadata including height, NDVI, state and species details. The test data consists of images only without meta-data provided. The host provided 357 training images and nearly 800 test-set images divided into 53% public leaderboard and 47% private leaderboard. \n\n**Metadata columns**\n- sample_id — Unique identifier for each training sample (image).\n- image_path — Relative path to the training image (e.g., images/ID1098771283.jpg).\n- Sampling_Date — Date of sample collection.\n- State — Australian state where sample was collected.\n- Species — Pasture species present, ordered by biomass (underscore-separated).\n- Pre_GSHH_NDVI — Normalized Difference Vegetation Index (GreenSeeker) reading.\n- Height_Ave_cm — Average pasture height measured by falling plate (cm).\n- target_name — Biomass component name for this row (Dry_Green_g, Dry_Dead_g, Dry_Clover_g, GDM_g, or Dry_Total_g).\n- target — Ground-truth biomass value (grams) corresponding to target_name for this image.\n\n**Provided targets -** \n- Dry green vegetation (excluding clover)\n- Dry dead material\n- Dry clover biomass\n- Green dry matter (GDM)\n- Total dry biomass\n\nThese targets had the below mathematical relationships - \n$$ Total = Dead + GDM $$\n$$ GDM = Green + Clover $$\n$$ Total = Dead + Green + Clover $$\n\n# CV scheme details \n\nThis was one of the most tricky and time consuming components in this competition. I joined this competition early and most of my initial experiments in the first 4-5 weeks focused on an untuned EVA02 base model with the below CV schemes -\n\n|CV scheme| Results - CV/ LB relation| Average CV-LB gap | \n|--- | --- | --- | \n| K-fold (5-fold) | LB and CV were not in sync at all | 0.2-0.25 | \n| Stratified K-fold (5-fold) on state | LB and CV were not in sync at all | 0.2-0.25 | \n| Stratified K-fold (5-fold) on state and species  | LB and CV were not in sync at all | 0.15-0.20 | \n| Group-K-fold (5-fold) on month of sample date | LB and CV were not in sync at all | 0.10- 0.175| \n| Stratified Group-K-fold (5-fold) on state, group by month of sample date | LB and CV were partially in sync | 0.025-0.06 | \n\n<br>We ultimately chose to proceed with the Stratified Group-5-fold scheme for our later experiments. @kishanvavdara joined the team at this stage and we set out to train models for the final solution. \n\n# Model details \n\nWe focused on the below models thereafter - \n\n|Model type   | CV range| LB range| Comments | \n| ------------  | -------------- | ---------- | ---------- |\n| EVA02 base | 0.65-0.70 | 0.55 - 0.60 | Poor LB but quick training pipeline - useful for prototyping | \n| EVA02 large | 0.68-0.73 | 0.62 - 0.67 | Poor LB but provided diversity in the ensemble and useful for prototyping also | \n| DINOV2 base | 0.65-0.69 | 0.62 - 0.66 | Good start point for DINO architectures and quick training  |  \n| DINOV2 large| 0.675-0.72 | 0.64 - 0.68 | Provided a good option to test the freeze-full train structure - quick training as well |  \n| DINOV3 large| 0.725-0.765 | 0.69 - 0.72 | Provided a good solution with moderate CV-LB relation and allowed quick prototyping for larger models|  \n| DINOV3 huge| 0.76 - 0.7925 | 0.71-0.74 | Cynosure for the final solution|  \n\n<br>We were well aware of the high usage of DINOV3 models across the top LB solutions. To engender diversity in our solution compared to others, we focused on the below-\n\n## Target structure\n \n- 3 target training heads (Dry-dead, Green, Clover) and 2 target predictions after the model \n- 4-target training heads with auxiliary ratio Dry-dead / Total along with Dry-dead, Green, Clover training heads. Other targets were derived suitably\n- 5-training heads, one for each target with adjustments for total biomass and green dry matter based on the relations above\n\n## Image splitting process\n\n|Image splits |Comment |\n|-------- | ----- | \n| 1 | Provided no reasonable CV-LB relation and was excluded | \n| 2 | Provided decent CV-LB relation but it provided room for improvement | \n| 3 | Provided improvement over 2-split, image was split into 3 equal parts   | \n| 4 | Provided best CV-LB relation, image was split into 4 equal parts  | \n\n<br>Sample code for this is as below-\n\n```\nH, W = image.shape[:2]  # H=1000, W=2000\nquarter = W // 4         # quarter=500\n\npart1 = image[:, :quarter, :]              # [0:500]\npart2 = image[:, quarter:2*quarter, :]      # [500:1000]\npart3 = image[:, 2*quarter:3*quarter, :]    # [1000:1500]\npart4 = image[:, 3*quarter:, :]             # [1500:2000]\n```\n\n## Preprocessing Pipeline\n\nAugmentation (training only)\n- Horizontal flip (p=0.5)\n- Vertical flip (p=0.5)\n- Random 90° rotation (p=0.5)\n- Color jitter (brightness, contrast, saturation, hue)\n\nResize: 1000×500 → 448×448\n\nNormalization: ImageNet statistics\n- Mean: [0.485, 0.456, 0.406]\n- Std: [0.229, 0.224, 0.225]\n\nFinally, this was converted to PyTorch tensors for the model\n\n- We excluded a specific sample (ID230058600.jpg) from training, likely an outlier. This was from our EDA and public discussions\n\n## Model architecture \n\n- Self-supervised pre-trained on large-scale image data\n- Patch size: 16×16\n- Shared weights across all 4 streams\n- Global average pooling applied\n\n```\ndef forward(self, part1, part2, part3, part4):\n    # Each part: [B, 3, 448, 448]\n    \n    # Extract features using shared backbone\n    feat1 = self.backbone(part1)  # [B, 1536]\n    feat2 = self.backbone(part2)  # [B, 1536]\n    feat3 = self.backbone(part3)  # [B, 1536]\n    feat4 = self.backbone(part4)  # [B, 1536]\n    \n    # Concatenate features\n    combined = torch.cat([feat1, feat2, feat3, feat4], dim=1)  \n```\n\n**Key Design Choices**\n\nShared Backbone Weights\n\n- Reduces parameters (only 1 backbone instead of 4)\n- Forces consistent feature extraction\n- Better generalization with limited data\n\nLate Fusion\n\n- Features concatenated after extraction\n- Allows each stream to process independently\n- Combined features capture spatial relationships\n\n## Target heads\n\nWe used a SwiGLU module that outperformed a traditional ReLU/GELU. Code for this is as below - \n\n```\nclass SwiGLU(nn.Module):\n    def __init__(self, input_dim, hidden_dim, output_dim, dropout=0.1):\n        super().__init__()\n        self.gate_proj = nn.Linear(input_dim, hidden_dim)\n        self.value_proj = nn.Linear(input_dim, hidden_dim)\n        self.down_proj = nn.Linear(hidden_dim, output_dim)\n        self.dropout = nn.Dropout(dropout)\n        self.silu = nn.SiLU()  # Swish activation\n    \n    def forward(self, x):\n        gate = self.silu(self.gate_proj(x))   # Apply Swish\n        value = self.value_proj(x)             # Linear transform\n        hidden = gate * value                  # Element-wise multiplication\n        hidden = self.dropout(hidden)\n        output = self.down_proj(hidden)\n        return output\n```\n\nUsage of swiglu was optional, we tried a conventional approach as well , with the details as below -\n\n```\nif use_swiglu:\n    hidden_expanded = hidden * 2  # 6144 for hidden_ratio=0.5\n    def make_head():\n        return SwiGLU(\n            input_dim=combined,      # 6144\n            hidden_dim=hidden_expanded,  # 6144\n            output_dim=1,\n            dropout=dropout\n        )\nelse:\n    def make_head():\n        return nn.Sequential(\n            nn.Linear(combined, hidden),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout),\n            nn.Linear(hidden, 1),\n        )\n\nself.head_green = make_head()\nself.head_clover = make_head()  # (if needed)\nself.head_dead = make_head()\n```\n\n<br>Swiglu was quite helpful and provided a boost in our CV-LB relation. Our best single models used swiglu.\n\nWe used soft-plus activation across our target heads as below - <br>\n`self.softplus = nn.Softplus(beta=1.0)`\n\n```\ngreen = self.softplus(self.head_green(combined))\nclover = self.softplus(self.head_clover(combined))\ndead = self.softplus(self.head_dead(combined))\n```\n\n$$ Softplus(x, β) = log(1 + exp(βx)) / β $$\n\n## Output Assembly\n\nWe concatenate the outputs from the heads as below - \n\n```\noutputs = torch.cat([green, clover, dead], dim=1)  # [B, 3] or [B, 5]\nreturn outputs\n```\n\n## Target derivation\n\nOne such case using a simple 3-2 targets prediction / derivation is as below- \n\n```\ndef calculate_derived_targets(preds_3, states=None):\n    pred_green = preds_3[:, 0]\n    pred_clover = preds_3[:, 1]\n    pred_dead = preds_3[:, 2]\n    \n    # Apply ReLU to ensure non-negative\n    pred_green = np.maximum(pred_green, 0)\n    pred_clover = np.maximum(pred_clover, 0)\n    pred_dead = np.maximum(pred_dead, 0)\n    \n    # Derive aggregate targets\n    pred_gdm = pred_green + pred_clover     # GDM = Green Dry Matter\n    pred_total = pred_gdm + pred_dead       # Total = GDM + Dead\n    \n    # Assemble final 5 targets\n    preds_5 = np.zeros((len(preds_3), 5), dtype=np.float32)\n    preds_5[:, 0] = pred_green\n    preds_5[:, 1] = pred_dead\n    preds_5[:, 2] = pred_clover\n    preds_5[:, 3] = pred_gdm\n    preds_5[:, 4] = pred_total\n    \n    return preds_5\n```\n\nFor the 5-target case, we don't derive anything and directly do - `all_preds_5 = np.maximum(all_preds_5, 0)`\n\n## Loss function\n\nWe used a simple L1 smooth loss for our submission. We tried weighted R2 loss, L1 loss, MSE loss but smooth L1 loss was the best\n\n```\nclass WeightedSmoothL1Loss(nn.Module):\n    def __init__(self, weights):\n        super().__init__()\n        self.weights = torch.tensor(weights, dtype=torch.float32)\n        self.loss_fn = nn.SmoothL1Loss(reduction='none')\n    \n    def forward(self, y_pred, y_true):\n        self.weights = self.weights.to(y_pred.device)\n        loss = self.loss_fn(y_pred, y_true)  # [B, num_targets]\n        weighted_loss = (self.weights * loss).mean(dim=1)  # [B]\n        return weighted_loss.mean()\n```\n\n## Model training\n\n- We froze the model pipeline for n-epochs, n being 1-6 at the start of training \n- We finetuned the model parameters thereafter till our chosen epochs (usually 15-20)\n\n```\nif epoch < freeze_epochs:\n    for param in model.backbone.parameters():\n        param.requires_grad = False\nelse:\n    # Freeze all backbone\n    for param in model.backbone.parameters():\n        param.requires_grad = False\n    \n    # Unfreeze last 6 layers\n    total_layers = len(model.backbone.blocks)\n    start_idx = total_layers - 6\n    \n    for i in range(start_idx, total_layers):\n        for param in model.backbone.blocks[i].parameters():\n            param.requires_grad = True\n    \n    # Unfreeze norm layer\n    if hasattr(model.backbone, 'norm'):\n        for param in model.backbone.norm.parameters():\n            param.requires_grad = True\n    \n    # Reduce learning rate\n    for param_group in optimizer.param_groups:\n        param_group['lr'] = lr_stage2\n```\n\n## Evaluation metric\n\nWe used the competition metric as below to evaluate the model OOF - \n\n```\ndef weighted_r2_score(y_true, y_pred, weights):\n    weights = np.array(weights)\n    y_true_flat = y_true.flatten()\n    y_pred_flat = y_pred.flatten()\n    weights_flat = np.tile(weights, len(y_true))\n    \n    ss_res = np.sum(weights_flat * (y_true_flat - y_pred_flat) ** 2)\n    y_mean = np.sum(weights_flat * y_true_flat) / np.sum(weights_flat)\n    ss_tot = np.sum(weights_flat * (y_true_flat - y_mean) ** 2)\n    \n    return 1 - ss_res / ss_tot\n```\n\n## Full refit \n\n- Considering the lack of CV-LB relations across experiments, we focused on a multi-seed refit approach and fitted 3-5 seed models on the full data\n- Refit models were used for the inference\n\n## Test-time augmentation\n\nWe used the below augmentations during inference - \n- Original\n- Horizontal flip\n- Vertical flip\n- Random 90° rotation\n- Color jitter \n\n# Ensemble \n\nOur ensemble strategy included the below individual model ideas - \n\n|Model ID | Model idea| CV score| Single LB score | \n| ---- | ----- | ---- | ---- | \n| DINOV7_17  | - 3 train targets, 2 derived targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1| 0.792425 | 0.74 | \n| DINOV7_30 | - 3 train targets, 2 derived targets <br> - 5-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1| 0.78498377 | 0.73 | \n| DINOV7_34 | - 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 7-last layers unfrozen <br> - Dropped bottom 10% image due to higher risk of non-grass components in this region <br> - Freeze till epoch 1|  0.78237804| 0.73 | \n| DINOV7_36 | - 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Sequential unfreezing after epoch 1 across 4 stages |0.78187030  | 0.73 | \n| DINOV7_17_5T  | - 5 train targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1 | 0.788456 | 0.74 | \n| DINOV9_10      | - 5-target prediction and derivation/ estimation of dead biomass based on predicted height <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1 <br> - 4-stream architecture | 0.79041560  |  0.72 | \n| EVA02V8_3      |  - 3 train targets, 2 derived targets <br> - swiglu <br> - Freeze till epoch 1 | 0.73975521 |  0.66 | \n\n<br>We blended the models using -\n1. Simple ridge - CV = 0.7989367\n2. Hill climber with positive weights - CV = 0.81518447\n\n# Post-processing scalers\n\n- We preferred to avoid post-processing scaler adjustments and are happy our submission without this outscored the one with post-processing\n- Our 2nd submission was a leaderboard focused submission that blended the best public notebook with our 0.74 models with post-processing. This one scored 0.62 on the leaderboard\n\n# Opportunity costs\n\n4 of our single models outscored our chosen kernel. All of them were DINOv3 Huge backbones and froze the parameters till epoch-1. <br>\nThey are as below-\n\n| Model idea | Private LB score | \n|---- | ---- |\n| - 5 target predictions with residual connections | 0.64 | \n| - 3+2 target predictions with linear scheduler instead of cosine | 0.63 (high end) | \n| - 4 target predictions with dry/total auxiliary ratio with linear scheduler instead of cosine | 0.63 (high end) | \n| - 5 target predictions with estimation of dead biomass based on predicted height | 0.63 (slightly above chosen kernel) | \n\n<br>We learn that good single models can also be of value in small data competitions - a model blend is not always necessary. \n\n# Outro\n\nThanks for reading the write-up! Hope we were able to articulate the process with some aplomb!! Let us know your thoughts and we will respond appropriately.\n\nBest wishes for your personal and professional/ Kaggle journey ahead!\n\nRegards, <br>\nRavi Ramakrishnan <br>\nKishan Vavdara <br>\nTony Li<br>",
      "votes": 18
    }
  ],
  "comments": [],
  "raw_markdown_by_id": {
    "3398771": "Hello all,\n\nFirstly, we wish to thank Kaggle and the host for this competition. I wish to thank the host specifically for being proactive and answering our questions well within the rules of the competition as well. Also, I wish to thank the forum contributors for being generous and sharing excellent discussion ideas and kernels for one and all. \n\nLet's delve into the assignment solution and discuss the architecture and model choices below-\n\n# Assignment details \n\nThis was a 5-target regression image problem to estimate the pasture biomass from training images covering Australian pastures across different seasons, regions, and species mixes, along with metadata including height, NDVI, state and species details. The test data consists of images only without meta-data provided. The host provided 357 training images and nearly 800 test-set images divided into 53% public leaderboard and 47% private leaderboard. \n\n**Metadata columns**\n- sample_id — Unique identifier for each training sample (image).\n- image_path — Relative path to the training image (e.g., images/ID1098771283.jpg).\n- Sampling_Date — Date of sample collection.\n- State — Australian state where sample was collected.\n- Species — Pasture species present, ordered by biomass (underscore-separated).\n- Pre_GSHH_NDVI — Normalized Difference Vegetation Index (GreenSeeker) reading.\n- Height_Ave_cm — Average pasture height measured by falling plate (cm).\n- target_name — Biomass component name for this row (Dry_Green_g, Dry_Dead_g, Dry_Clover_g, GDM_g, or Dry_Total_g).\n- target — Ground-truth biomass value (grams) corresponding to target_name for this image.\n\n**Provided targets -** \n- Dry green vegetation (excluding clover)\n- Dry dead material\n- Dry clover biomass\n- Green dry matter (GDM)\n- Total dry biomass\n\nThese targets had the below mathematical relationships - \n$$ Total = Dead + GDM $$\n$$ GDM = Green + Clover $$\n$$ Total = Dead + Green + Clover $$\n\n# CV scheme details \n\nThis was one of the most tricky and time consuming components in this competition. I joined this competition early and most of my initial experiments in the first 4-5 weeks focused on an untuned EVA02 base model with the below CV schemes -\n\n|CV scheme| Results - CV/ LB relation| Average CV-LB gap | \n|--- | --- | --- | \n| K-fold (5-fold) | LB and CV were not in sync at all | 0.2-0.25 | \n| Stratified K-fold (5-fold) on state | LB and CV were not in sync at all | 0.2-0.25 | \n| Stratified K-fold (5-fold) on state and species  | LB and CV were not in sync at all | 0.15-0.20 | \n| Group-K-fold (5-fold) on month of sample date | LB and CV were not in sync at all | 0.10- 0.175| \n| Stratified Group-K-fold (5-fold) on state, group by month of sample date | LB and CV were partially in sync | 0.025-0.06 | \n\n<br>We ultimately chose to proceed with the Stratified Group-5-fold scheme for our later experiments. @kishanvavdara joined the team at this stage and we set out to train models for the final solution. \n\n# Model details \n\nWe focused on the below models thereafter - \n\n|Model type   | CV range| LB range| Comments | \n| ------------  | -------------- | ---------- | ---------- |\n| EVA02 base | 0.65-0.70 | 0.55 - 0.60 | Poor LB but quick training pipeline - useful for prototyping | \n| EVA02 large | 0.68-0.73 | 0.62 - 0.67 | Poor LB but provided diversity in the ensemble and useful for prototyping also | \n| DINOV2 base | 0.65-0.69 | 0.62 - 0.66 | Good start point for DINO architectures and quick training  |  \n| DINOV2 large| 0.675-0.72 | 0.64 - 0.68 | Provided a good option to test the freeze-full train structure - quick training as well |  \n| DINOV3 large| 0.725-0.765 | 0.69 - 0.72 | Provided a good solution with moderate CV-LB relation and allowed quick prototyping for larger models|  \n| DINOV3 huge| 0.76 - 0.7925 | 0.71-0.74 | Cynosure for the final solution|  \n\n<br>We were well aware of the high usage of DINOV3 models across the top LB solutions. To engender diversity in our solution compared to others, we focused on the below-\n\n## Target structure\n \n- 3 target training heads (Dry-dead, Green, Clover) and 2 target predictions after the model \n- 4-target training heads with auxiliary ratio Dry-dead / Total along with Dry-dead, Green, Clover training heads. Other targets were derived suitably\n- 5-training heads, one for each target with adjustments for total biomass and green dry matter based on the relations above\n\n## Image splitting process\n\n|Image splits |Comment |\n|-------- | ----- | \n| 1 | Provided no reasonable CV-LB relation and was excluded | \n| 2 | Provided decent CV-LB relation but it provided room for improvement | \n| 3 | Provided improvement over 2-split, image was split into 3 equal parts   | \n| 4 | Provided best CV-LB relation, image was split into 4 equal parts  | \n\n<br>Sample code for this is as below-\n\n```\nH, W = image.shape[:2]  # H=1000, W=2000\nquarter = W // 4         # quarter=500\n\npart1 = image[:, :quarter, :]              # [0:500]\npart2 = image[:, quarter:2*quarter, :]      # [500:1000]\npart3 = image[:, 2*quarter:3*quarter, :]    # [1000:1500]\npart4 = image[:, 3*quarter:, :]             # [1500:2000]\n```\n\n## Preprocessing Pipeline\n\nAugmentation (training only)\n- Horizontal flip (p=0.5)\n- Vertical flip (p=0.5)\n- Random 90° rotation (p=0.5)\n- Color jitter (brightness, contrast, saturation, hue)\n\nResize: 1000×500 → 448×448\n\nNormalization: ImageNet statistics\n- Mean: [0.485, 0.456, 0.406]\n- Std: [0.229, 0.224, 0.225]\n\nFinally, this was converted to PyTorch tensors for the model\n\n- We excluded a specific sample (ID230058600.jpg) from training, likely an outlier. This was from our EDA and public discussions\n\n## Model architecture \n\n- Self-supervised pre-trained on large-scale image data\n- Patch size: 16×16\n- Shared weights across all 4 streams\n- Global average pooling applied\n\n```\ndef forward(self, part1, part2, part3, part4):\n    # Each part: [B, 3, 448, 448]\n    \n    # Extract features using shared backbone\n    feat1 = self.backbone(part1)  # [B, 1536]\n    feat2 = self.backbone(part2)  # [B, 1536]\n    feat3 = self.backbone(part3)  # [B, 1536]\n    feat4 = self.backbone(part4)  # [B, 1536]\n    \n    # Concatenate features\n    combined = torch.cat([feat1, feat2, feat3, feat4], dim=1)  \n```\n\n**Key Design Choices**\n\nShared Backbone Weights\n\n- Reduces parameters (only 1 backbone instead of 4)\n- Forces consistent feature extraction\n- Better generalization with limited data\n\nLate Fusion\n\n- Features concatenated after extraction\n- Allows each stream to process independently\n- Combined features capture spatial relationships\n\n## Target heads\n\nWe used a SwiGLU module that outperformed a traditional ReLU/GELU. Code for this is as below - \n\n```\nclass SwiGLU(nn.Module):\n    def __init__(self, input_dim, hidden_dim, output_dim, dropout=0.1):\n        super().__init__()\n        self.gate_proj = nn.Linear(input_dim, hidden_dim)\n        self.value_proj = nn.Linear(input_dim, hidden_dim)\n        self.down_proj = nn.Linear(hidden_dim, output_dim)\n        self.dropout = nn.Dropout(dropout)\n        self.silu = nn.SiLU()  # Swish activation\n    \n    def forward(self, x):\n        gate = self.silu(self.gate_proj(x))   # Apply Swish\n        value = self.value_proj(x)             # Linear transform\n        hidden = gate * value                  # Element-wise multiplication\n        hidden = self.dropout(hidden)\n        output = self.down_proj(hidden)\n        return output\n```\n\nUsage of swiglu was optional, we tried a conventional approach as well , with the details as below -\n\n```\nif use_swiglu:\n    hidden_expanded = hidden * 2  # 6144 for hidden_ratio=0.5\n    def make_head():\n        return SwiGLU(\n            input_dim=combined,      # 6144\n            hidden_dim=hidden_expanded,  # 6144\n            output_dim=1,\n            dropout=dropout\n        )\nelse:\n    def make_head():\n        return nn.Sequential(\n            nn.Linear(combined, hidden),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout),\n            nn.Linear(hidden, 1),\n        )\n\nself.head_green = make_head()\nself.head_clover = make_head()  # (if needed)\nself.head_dead = make_head()\n```\n\n<br>Swiglu was quite helpful and provided a boost in our CV-LB relation. Our best single models used swiglu.\n\nWe used soft-plus activation across our target heads as below - <br>\n`self.softplus = nn.Softplus(beta=1.0)`\n\n```\ngreen = self.softplus(self.head_green(combined))\nclover = self.softplus(self.head_clover(combined))\ndead = self.softplus(self.head_dead(combined))\n```\n\n$$ Softplus(x, β) = log(1 + exp(βx)) / β $$\n\n## Output Assembly\n\nWe concatenate the outputs from the heads as below - \n\n```\noutputs = torch.cat([green, clover, dead], dim=1)  # [B, 3] or [B, 5]\nreturn outputs\n```\n\n## Target derivation\n\nOne such case using a simple 3-2 targets prediction / derivation is as below- \n\n```\ndef calculate_derived_targets(preds_3, states=None):\n    pred_green = preds_3[:, 0]\n    pred_clover = preds_3[:, 1]\n    pred_dead = preds_3[:, 2]\n    \n    # Apply ReLU to ensure non-negative\n    pred_green = np.maximum(pred_green, 0)\n    pred_clover = np.maximum(pred_clover, 0)\n    pred_dead = np.maximum(pred_dead, 0)\n    \n    # Derive aggregate targets\n    pred_gdm = pred_green + pred_clover     # GDM = Green Dry Matter\n    pred_total = pred_gdm + pred_dead       # Total = GDM + Dead\n    \n    # Assemble final 5 targets\n    preds_5 = np.zeros((len(preds_3), 5), dtype=np.float32)\n    preds_5[:, 0] = pred_green\n    preds_5[:, 1] = pred_dead\n    preds_5[:, 2] = pred_clover\n    preds_5[:, 3] = pred_gdm\n    preds_5[:, 4] = pred_total\n    \n    return preds_5\n```\n\nFor the 5-target case, we don't derive anything and directly do - `all_preds_5 = np.maximum(all_preds_5, 0)`\n\n## Loss function\n\nWe used a simple L1 smooth loss for our submission. We tried weighted R2 loss, L1 loss, MSE loss but smooth L1 loss was the best\n\n```\nclass WeightedSmoothL1Loss(nn.Module):\n    def __init__(self, weights):\n        super().__init__()\n        self.weights = torch.tensor(weights, dtype=torch.float32)\n        self.loss_fn = nn.SmoothL1Loss(reduction='none')\n    \n    def forward(self, y_pred, y_true):\n        self.weights = self.weights.to(y_pred.device)\n        loss = self.loss_fn(y_pred, y_true)  # [B, num_targets]\n        weighted_loss = (self.weights * loss).mean(dim=1)  # [B]\n        return weighted_loss.mean()\n```\n\n## Model training\n\n- We froze the model pipeline for n-epochs, n being 1-6 at the start of training \n- We finetuned the model parameters thereafter till our chosen epochs (usually 15-20)\n\n```\nif epoch < freeze_epochs:\n    for param in model.backbone.parameters():\n        param.requires_grad = False\nelse:\n    # Freeze all backbone\n    for param in model.backbone.parameters():\n        param.requires_grad = False\n    \n    # Unfreeze last 6 layers\n    total_layers = len(model.backbone.blocks)\n    start_idx = total_layers - 6\n    \n    for i in range(start_idx, total_layers):\n        for param in model.backbone.blocks[i].parameters():\n            param.requires_grad = True\n    \n    # Unfreeze norm layer\n    if hasattr(model.backbone, 'norm'):\n        for param in model.backbone.norm.parameters():\n            param.requires_grad = True\n    \n    # Reduce learning rate\n    for param_group in optimizer.param_groups:\n        param_group['lr'] = lr_stage2\n```\n\n## Evaluation metric\n\nWe used the competition metric as below to evaluate the model OOF - \n\n```\ndef weighted_r2_score(y_true, y_pred, weights):\n    weights = np.array(weights)\n    y_true_flat = y_true.flatten()\n    y_pred_flat = y_pred.flatten()\n    weights_flat = np.tile(weights, len(y_true))\n    \n    ss_res = np.sum(weights_flat * (y_true_flat - y_pred_flat) ** 2)\n    y_mean = np.sum(weights_flat * y_true_flat) / np.sum(weights_flat)\n    ss_tot = np.sum(weights_flat * (y_true_flat - y_mean) ** 2)\n    \n    return 1 - ss_res / ss_tot\n```\n\n## Full refit \n\n- Considering the lack of CV-LB relations across experiments, we focused on a multi-seed refit approach and fitted 3-5 seed models on the full data\n- Refit models were used for the inference\n\n## Test-time augmentation\n\nWe used the below augmentations during inference - \n- Original\n- Horizontal flip\n- Vertical flip\n- Random 90° rotation\n- Color jitter \n\n# Ensemble \n\nOur ensemble strategy included the below individual model ideas - \n\n|Model ID | Model idea| CV score| Single LB score | \n| ---- | ----- | ---- | ---- | \n| DINOV7_17  | - 3 train targets, 2 derived targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1| 0.792425 | 0.74 | \n| DINOV7_30 | - 3 train targets, 2 derived targets <br> - 5-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1| 0.78498377 | 0.73 | \n| DINOV7_34 | - 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 7-last layers unfrozen <br> - Dropped bottom 10% image due to higher risk of non-grass components in this region <br> - Freeze till epoch 1|  0.78237804| 0.73 | \n| DINOV7_36 | - 3 train targets, 2 derived targets <br> - 4-stream architecture <br> - swiglu <br> - 6-last layers unfrozen <br> - Sequential unfreezing after epoch 1 across 4 stages |0.78187030  | 0.73 | \n| DINOV7_17_5T  | - 5 train targets <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1 | 0.788456 | 0.74 | \n| DINOV9_10      | - 5-target prediction and derivation/ estimation of dead biomass based on predicted height <br> - swiglu <br> - 6-last layers unfrozen <br> - Freeze till epoch 1 <br> - 4-stream architecture | 0.79041560  |  0.72 | \n| EVA02V8_3      |  - 3 train targets, 2 derived targets <br> - swiglu <br> - Freeze till epoch 1 | 0.73975521 |  0.66 | \n\n<br>We blended the models using -\n1. Simple ridge - CV = 0.7989367\n2. Hill climber with positive weights - CV = 0.81518447\n\n# Post-processing scalers\n\n- We preferred to avoid post-processing scaler adjustments and are happy our submission without this outscored the one with post-processing\n- Our 2nd submission was a leaderboard focused submission that blended the best public notebook with our 0.74 models with post-processing. This one scored 0.62 on the leaderboard\n\n# Opportunity costs\n\n4 of our single models outscored our chosen kernel. All of them were DINOv3 Huge backbones and froze the parameters till epoch-1. <br>\nThey are as below-\n\n| Model idea | Private LB score | \n|---- | ---- |\n| - 5 target predictions with residual connections | 0.64 | \n| - 3+2 target predictions with linear scheduler instead of cosine | 0.63 (high end) | \n| - 4 target predictions with dry/total auxiliary ratio with linear scheduler instead of cosine | 0.63 (high end) | \n| - 5 target predictions with estimation of dead biomass based on predicted height | 0.63 (slightly above chosen kernel) | \n\n<br>We learn that good single models can also be of value in small data competitions - a model blend is not always necessary. \n\n# Outro\n\nThanks for reading the write-up! Hope we were able to articulate the process with some aplomb!! Let us know your thoughts and we will respond appropriately.\n\nBest wishes for your personal and professional/ Kaggle journey ahead!\n\nRegards, <br>\nRavi Ramakrishnan <br>\nKishan Vavdara <br>\nTony Li<br>"
  }
}