返回列表

1st Place Solution

687. CSIRO - Image2Biomass Prediction | csiro-biomass

开始: 2025-10-28 结束: 2026-01-28 作物智能识别 数据算法赛
1st Place Solution - 第一名解决方案

第一名解决方案

副标题:双流类 DINO 模型 + 区间分类 + 测试时在线训练

作者:TheoQiu (及团队成员 Baiph, HZM, zxc123cc)

发布时间:2026-01-29

竞赛排名:1

感谢组织者举办如此激烈的比赛。我们很幸运能获胜,我要感谢我的队友们。这是我第一次完整参加 Kaggle 竞赛,这段经历对我来说非常宝贵 💪。我们的解决方案如下。

🔵 交叉验证 (CV) 策略

分为三个折 (folds),由州和采样日期共同决定,然后训练单折模型。

🔵 输入数据

与大多数优秀的开源 Notebook 类似,我们的模型将图像分为左视图和右视图,并将它们通过相同的主干网络以增强一致性。

🔵 训练数据增强

我们使用了各种常规的数据增强方法,如下所示:

def get_train_transforms(args):
    return Compose([
        HorizontalFlip(p=0.5),
        VerticalFlip(p=0.5),
        RandomRotate90(p=0.5),
        A.GaussNoise(p=0.3),
        A.RandomBrightnessContrast(
            brightness_limit=0.2,
            contrast_limit=0.2,
            p=0.75
        ),
        A.HueSaturationValue(
            hue_shift_limit=10,
            sat_shift_limit=20,
            val_shift_limit=20,
            p=0.5
        ),
        A.CLAHE(clip_limit=2.0, tile_grid_size=(8, 8), p=0.3), 
        A.ColorJitter(
            brightness=0.2, 
            contrast=0.2, 
            saturation=0.2, 
            hue=0.1, 
            p=0.75
        ),
        Normalize(
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        ),
        Resize(args.img_size, args.img_size),
        ToTensorV2()
    ])

在分割左右子图像后,我们独立地对子图像应用数据增强。此外,我们随机对图像应用小尺寸缩放,然后用黑色像素填充,以模拟不同相机的焦距和视角差异。

if random.random() < 0.2:
    background_image = np.full_like(image, 0)
    resize_retio = random.uniform(0.85, 1.0)
    image = cv2.resize(image, None, fx=resize_retio, fy=resize_retio, interpolation=cv2.INTER_CUBIC)
    h, w, _ = image.shape
    bg_h, bg_w, _ = background_image.shape
    top = random.randint(0, bg_h - h)
    left = random.randint(0, bg_w - w)
    background_image[top:top + h, left:left + w] = image
    image = background_image

🔵 骨干网络选择

我们使用 DINOv3 作为骨干网络,这大概是本次竞赛大多数获胜团队使用的方法。随后,我们使用单个多头自注意力层与左右子图像的拼接特征进行交互,然后通过 MLP 输出融合特征。

🔵 头部设计

我们使用五个回归头独立预测每个物种的生物量,每个头包含三个线性层。在训练期间不对它们施加物理约束,我们认为这减少了模型对训练集的依赖。

与大多数解决方案不同,我们还额外设计了五个分类头。鉴于此任务类似于没有密度图的 crowd counting (CC) 问题,我们特意阅读了 CC 领域的大量工作。参考 UEPNet[1] 中的区间划分方案,我们使用训练集为每个物种划分了七个区间:

  BORDERS_DICT = {
      'Dry_Clover_g': [1.6e-05, 3.9, 10.5353, 20.6523, 37.5911, 71.7865],
      'Dry_Dead_g': [1.6e-05, 6.1407, 13.1192, 23.277, 38.8581, 83.8407],
      'Dry_Green_g': [1.6e-05, 13.4232, 27.0782, 45.5236, 79.834, 157.9836],
      'Dry_Total_g': [1.6e-05, 23.4907, 41.1, 61.1, 96.8288, 185.7],
      'GDM_g': [1.6e-05, 16.5143, 30.507, 49.5585, 81.0, 157.9836],
  }

基于划分的区间,我们为每个物种添加了一个额外的分类头来预测生物量所在的区间,这使得 LB 和 PB 都提高了约 0.03。这也与我们的 CV 高度一致。理想情况下,如果区间划分得足够小,模型可以通过区间推断值准确预测生物量。(显然,对于这次没有密度图/分割图的竞赛来说,这太难了。)

🔵 损失函数

我们最终提交的模型对回归使用常见的 SmoothL1 Loss,对分类使用 Cross-Entropy loss。每个生物量的损失根据竞赛提出的 R^2 计算权重进行缩放。

class WeightedBiomassLoss(nn.Module):
    def __init__(self, loss_weights_dict):
        super(WeightedBiomassLoss, self).__init__()
        
        self.criterion = nn.SmoothL1Loss()
        self.cls_loss = nn.CrossEntropyLoss(reduction='none')
        self.cls_weight = 0.3
        self.weights = loss_weights_dict

    def forward(self, predictions, targets, predictions_cls=None, cls_labels=None):
        total_cls_loss = None
        pred_green, pred_dead, pred_clover, pred_gdm, pred_total = predictions
        if predictions_cls is not None:
            assert cls_labels is not None
            pred_green_cls, pred_dead_cls, pred_clover_cls, pred_gdm_cls, pred_total_cls = predictions_cls
            cls_green_loss = self.cls_loss(pred_green_cls, cls_labels[:, 0])
            cls_dead_loss = self.cls_loss(pred_dead_cls, cls_labels[:, 1])
            cls_clover_loss = self.cls_loss(pred_clover_cls, cls_labels[:, 2])
            cls_gdm_loss = self.cls_loss(pred_gdm_cls, cls_labels[:, 3])
            cls_total_loss = self.cls_loss(pred_total_cls, cls_labels[:, 4])
            total_cls_loss = (
                self.weights['green_loss'] * cls_green_loss.mean() +
                self.weights['dead_loss'] * cls_dead_loss.mean() +
                self.weights['clover_loss'] * cls_clover_loss.mean() +
                self.weights['gdm_loss'] * cls_gdm_loss.mean() +
                self.weights['total_loss'] * cls_total_loss.mean()
            )
        
        true_green = targets[:, 0].unsqueeze(-1) # Shape [batch, 1]
        true_dead   = targets[:, 1].unsqueeze(-1) # Shape [batch, 1]
        true_clover = targets[:, 2].unsqueeze(-1) # Shape [batch, 1]
        true_gdm = targets[:, 3].unsqueeze(-1) # Shape [batch, 1]
        true_total = targets[:, 4].unsqueeze(-1) # Shape [batch, 1]

        loss_green = self.criterion(pred_green, true_green)
        loss_dead = self.criterion(pred_dead, true_dead)
        loss_clover = self.criterion(pred_clover, true_clover)
        loss_gdm   = self.criterion(pred_gdm, true_gdm)
        loss_total = self.criterion(pred_total, true_total)
        
        total_loss = (
            self.weights['total_loss'] * loss_total +
            self.weights['gdm_loss'] * loss_gdm +
            self.weights['green_loss'] * loss_green + 
            self.weights['dead_loss'] * loss_dead +
            self.weights['clover_loss'] * loss_clover # + 
        )
        if predictions_cls is not None:
            total_loss = total_loss + self.cls_weight * total_cls_loss
        return total_loss, total_cls_loss

还有一些额外的实验最终没有包含在提交的模型中,但 PB 证明了我们在此过程中的设计是有意义的。例如,使用 epsilon-insensitive L1 loss 进行回归优化。这一观察基于生物量分布的不均匀性,我们希望“大值有大误差,小值有小误差”。因此,我们设计了以下基于标签的损失,这使我们能够在 PB 上获得最佳单模型结果。

class EpsilonInsensitiveLoss(nn.Module):
    def __init__(self, eps_point=20, max_eps=5, reduction='mean'):
        super().__init__()
        self.eps_point = eps_point
        self.reduction = reduction
        self.scale_ratio = 0.1
        self.max_eps = max_eps
    
    def make_epsilon(self, target):
        epsilon = torch.zeros_like(target)
        epsilon = torch.where(
            target<=self.eps_point, torch.ones_like(target), epsilon
        )
        epsilon = torch.where(
            target>self.eps_point, target*self.scale_ratio, epsilon
        )
        epsilon = torch.clamp(epsilon, max=self.max_eps)
    
        return epsilon

    def forward(self, pred, target):
        epsilon = self.make_epsilon(target.detach())  
        abs_diff = torch.abs(pred - target)
        loss = torch.relu(abs_diff - epsilon)
        
        if self.reduction == 'mean':
            return loss.mean()
        elif self.reduction == 'sum':
            return loss.sum()
        return loss

🔵 训练策略

在竞赛早期,我们尝试了各种不同的策略,最终选择了最有效的两阶段训练策略。

  • 阶段 1:冻结 DINO,然后训练融合层和 MLP 头。
  • 阶段 2:微调整个模型。

此外,在我们的设置下,无论是 LB 还是 CV,我们都发现 DINO-Large 和 DINO-Huge 的性能相似。由于计算资源有限,增加输入尺寸的好处优于增加模型尺寸。因此,在竞赛后期,我们最好的单模型通常使用 1024 像素 + Large 配置进行训练。

🔵 DINOv3-Large 单模型结果

实验名称 (exp_name) 输入尺寸 (单视图) LB PB
Baseline-large 512 0.70 0.60
+cls 512 0.73 0.63
+cls 1024 0.74 0.64
+aug_scale + cls 1024 0.74 0.65
+cls + $\epsilon$-insensitive loss 512 0.71 0.66

🔵 测试阶段

测试期间的在线训练为我们带来了 >0.02 的提升。

  • 步骤 1:我们选择了四个在 LB 上表现良好且具有差异性的单模型,为测试集生成伪标签。
  • 步骤 2:基于获得的伪标签,我们迭代训练了两个 DINO-Large 模型。与上述训练不同,我们使用整个训练集和测试集进行训练,不再进行 CV,而是采用最终多个 epoch 的 SWA 集成模型。使用伪标签训练后,我们在训练集上又训练了几个 epoch,以防止模型过拟合伪标签。
  • 步骤 3:基于上一步获得的两个 DINO-Large 模型,我们第二次生成伪标签,然后根据相同的策略训练两个 DINO-Base 模型。
  • 步骤 4:融合两个 DINO-Large 模型和两个 DINO-Base 模型的推理结果,权重分别为 0.4 和 0.6。
    提交 Notebook 已发布在 Final_Inference_Notebook

🔵 后处理

我们使用了一些简单的后处理来略微提高 LB 和 PB 的分数,如下所示:

out_green = self.head_green(combined)
out_dead = self.head_dead(combined)
out_clover = self.head_clover(combined)
out_gdm = self.head_gdm(combined)
out_total = self.head_total(combined)

out_clover = out_clover * 0.8
if out_dead > 20:
    out_dead *= 1.1
elif out_dead < 10:
    out_dead *= 0.9

out_gdm = 0.5*out_gdm+ 0.5*(out_green+out_clover)

pred_total1 = out_green + out_clover + out_dead
pred_total2 = out_gdm + out_dead
out_total = 0.5*out_total + 0.5*pred_total1 + 0.0*pred_total2

🔵 最终竞赛得分

实验名称 (exp_name) LB PB
PB 最佳 0.77 0.68
提交 0.77 0.67

🔵 未成功的想法与讨论

  1. 各种半监督方案 😭。实施和调整这些方法几乎占用了我竞赛的最后几天,但没有一个比直接的硬半监督方法表现更好。
  2. 为一个物种训练一个模型,然后合并五个模型。
  3. 在训练中引入物理约束,或训练几个头然后计算剩余的生物量。
  4. 移除 dropout。鉴于这次竞赛本质上是一个回归任务,添加 dropout 通常会导致训练和推理之间的方差不一致。然而,dropout 在我们的实验中表现出色,明显优于移除它。
  5. 生成额外的未标记数据和外部数据集。
  6. 向训练图像添加阴影。
  7. 添加排名损失 (ranking loss)。由于训练数据量少,我们原本想添加排名损失以强制模型学习比较不同区域的生物量。首先,我们随机裁剪训练图像 $x$ 得到 $x_{crop}$,可以确定 $y>y_{crop}$,这给出了内部排名关系。然后,我们基于标签计算图像之间的排名关系。该方案在 CV 上表现良好,一度超过了我们之前的最佳模型,但在 LB 上表现不佳,这可能又是由于过拟合~
  8. 用各种其他损失替换 L1 损失。
  9. 多分辨率的补丁输入 ([1×2,2×4,4×8,...])。虽然该方案最终取得了不错的 CV 分数,但震荡严重。事实也证明,它们在 LB 和 PB 上的最终表现并不好(尤其是 PB,得分 <0.6)。
  10. ...........

🎉🎉🎉
最后但同样重要的是,祝贺我的队友 @pingfan,Kaggle 社区即将迎来一位新的 GM!!!
🎉🎉🎉

[1] Wang, Changan, Qingyu Song, Boshen Zhang, Yabiao Wang, Ying Tai, Xuyi Hu, Chengjie Wang, Jilin Li, Jiayi Ma, and Yang Wu. "Uniformity in heterogeneity: Diving deep into count interval partition for crowd counting." In Proceedings of the IEEE/CVF international conference on computer vision, pp. 3234-3242. 2021.

同比赛其他方案