返回列表

Rank 5th Solution

687. CSIRO - Image2Biomass Prediction | csiro-biomass

开始: 2025-10-28 结束: 2026-01-28 作物智能识别 数据算法赛
第 5 名解决方案:物理约束回归

CSIRO 图像转生物量预测 - 第 5 名单人金牌解决方案

作者: yanqiangmiffy (quincyqiang) | 排名: 第 5 名 (Private LB: 0.76) | 日期: 2026-01-29

首先,感谢 Kaggle 和 CSIRO 组织这次比赛。这是一个非常有意义的农业 AI 应用场景。

1. 简介

这是我第一次参加 Kaggle 计算机视觉(CV)竞赛,非常高兴和幸运能获得第 5 名(单人,Private LB: 0.76)。在整个比赛过程中,我进行了大量的实验——我大约做了 100+ 次训练实验(从 exp-v1 到 exp-v188)。通过这次比赛,我学到了很多关于计算机视觉的知识。以下是我比赛解决方案的详细分享。

提交记录

2. 赛题理解

2.1 问题描述

本次比赛的目标是利用牧场图像预测五个关键的生物量组成部分:

  • Dry_Green_g - 干绿植被(不包括三叶草)
  • Dry_Dead_g - 干死物质
  • Dry_Clover_g - 干三叶草生物量
  • GDM_g - 绿色干物质
  • Dry_Total_g - 总干生物量

2.2 数据特征

  • 小训练集:大约 800+ 张图像
  • 图像尺寸:原始图像为 2000x1000 像素
  • 分布偏移:训练集和测试集分布存在显著差异
  • 多州数据:来自澳大利亚不同州的数据(NSW, Tas, Vic, WA)
  • 物理约束关系:目标之间存在物理关系
    • Dead = Total - GDM
    • Clover = GDM - Green
指标线图

2.3 评估指标

比赛使用加权 R² 分数作为评估指标,即五个目标的 R² 加权平均值。

3. 核心改进技术

经过大量的消融实验,以下是可以稳定提高分数的关键技术:

3.1 图像预处理 - 时间戳移除

原始图像包含橙色的日期时间戳,这是数据噪声。我使用 HSV 颜色空间检测和图像修复(inpainting)技术来移除这些时间戳:

def clean_image(img):
    """
    图像预处理:移除底部伪影和日期戳。
    """
    # 在 HSV 空间中检测橙色日期戳
    hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
    # 定义橙色范围
    lower = np.array([5, 150, 150])
    upper = np.array([25, 255, 255])
    mask = cv2.inRange(hsv, lower, upper)

    # 膨胀掩膜以覆盖文本边缘
    mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=2)

    # 如果检测到时间戳则进行修复
    if np.sum(mask) > 0:
        img = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)

    return img
移除时间戳示例

3.2 多种数据增强

由于训练集较小,数据增强对于防止过拟合至关重要。我使用了 TTA(测试时增强)风格的训练策略:

def get_tta_transforms(img_size: int) -> list[A.Compose]:
    """返回用于 TTA 风格训练的变换管道列表。"""
    normalize = A.Normalize(
        mean=[0.485, 0.456, 0.406],
        std=[0.229, 0.224, 0.225]
    )

    # 视图 0: 原始
    original_view = A.Compose([
        A.Resize(img_size, img_size),
        normalize,
        ToTensorV2()
    ])

    # 视图 1: 水平翻转
    hflip_view = A.Compose([
        A.Resize(img_size, img_size),
        A.HorizontalFlip(p=1.0),
        normalize,
        ToTensorV2()
    ])

    # 视图 2: 垂直翻转
    vflip_view = A.Compose([
        A.Resize(img_size, img_size),
        A.VerticalFlip(p=1.0),
        normalize,
        ToTensorV2()
    ])

    # 视图 3: 旋转 90 度
    rotate90_view = A.Compose([
        A.Resize(img_size, img_size),
        A.Rotate(limit=(90, 90), p=1.0, border_mode=0),
        normalize,
        ToTensorV2()
    ])

    return [hflip_view, vflip_view, rotate90_view, original_view]

训练策略

  • 前 15 个 epoch:使用增强视图(水平翻转、垂直翻转、旋转 90 度)训练,相当于 4 倍数据
  • 后续 epoch:仅使用原始视图
  • 关键发现:随机选择增强方法比按 epoch 顺序选择效果更好(exp-v174)

3.3 多视图输入架构

除了将图像分割为左半部分和右半部分(模拟立体视觉)外,我还添加了完整图像作为第三个输入流:

class BiomassDataset(Dataset):
    """三流数据集:将 2000x1000 图像分割为左、右和完整图。"""
    
    def __getitem__(self, idx: int):
        image = self._load_image(self.image_paths[idx])
        image = clean_image(image)

        height, width = image.shape[:2]
        mid = width // 2
        left = image[:, :mid]      # 左半部分
        right = image[:, mid:]     # 右半部分
        full_image = image.copy()  # 完整图像

        # 对所有三个视图应用相同的变换
        left = self.transform(image=left)["image"]
        right = self.transform(image=right)["image"]
        full_image = self.transform(image=full_image)["image"]

        return left, right, full_image, train_tgt, eval_tgt, species_tgt

这种设计允许模型同时学习局部细节(左/右视图)和全局上下文(完整视图)。

3.4 混合专家模型 (MoE) 架构

设计意图:模型可以根据输入图像的具体特征(例如,更多绿草或更多枯草)自动切换到最合适的专家进行预测,有效解决多任务学习中的特征冲突。

class MetaMoE(nn.Module):
    """用于主要目标的混合专家头,输出均值和方差。"""

    def __init__(self, in_dim: int, hidden: int, num_experts: int, 
                 out_dim: int = 1, dropout: float = 0.2):
        super().__init__()
        self.num_experts = num_experts
        
        # 门控网络
        self.gate = nn.Sequential(
            nn.LayerNorm(in_dim),
            nn.Linear(in_dim, 256),
            nn.ReLU(),
            nn.Linear(256, num_experts),
        )
        
        # 专家网络(每个输出均值和方差)
        self.experts = nn.ModuleList([
            nn.Sequential(
                nn.LayerNorm(in_dim),
                nn.Linear(in_dim, hidden),
                nn.ReLU(),
                nn.Dropout(dropout),
                nn.Linear(hidden, 256),
                nn.ReLU(),
                nn.Linear(256, 2),  # [mean, var_logit]
            )
            for _ in range(num_experts)
        ])

    def forward(self, x: torch.Tensor):
        gate_logits = self.gate(x)
        gate_weights = F.softmax(gate_logits, dim=1).unsqueeze(-1)
        expert_outs = torch.stack([expert(x) for expert in self.experts], dim=1)
        out = (expert_outs * gate_weights).sum(dim=1)

        pred_mean = out[:, 0:1]
        pred_var_logit = out[:, 1:2]
        pred_var = F.softplus(pred_var_logit) + 1e-6  # 确保方差为正

        return pred_mean, pred_var
模型架构图

三种模型变体

  • 模型类型 A (BiomassModelBasic):基础三流模型 (exp-v147, exp-v164, exp-v174)
  • 模型类型 B (BiomassModelWithStateSpecies):带有州/物种分类头 (exp-v142)
  • 模型类型 C (BiomassModelWithMetaEmbedding):带有元特征嵌入 (exp-v148)

3.5 辅助训练头

添加额外的训练目标有助于模型学习更丰富的特征表示:

class BiomassModel(nn.Module):
    def __init__(self, model_name: str, pretrained: bool = True, 
                 pretrained_path: str = '', num_species: int = 0):
        super().__init__()
        # ... 骨干网络初始化 ...
        
        # 主要回归头(使用 MoE)
        self.head_total = MetaMoE(...)
        self.head_gdm = MetaMoE(...)
        self.head_green = MetaMoE(...)
        
        # 辅助回归头(NDVI 和高度)
        self.head_ndvi = _AuxHead(self.n_features, self.n_features // 2)
        self.head_height = _AuxHead(self.n_features, self.n_features // 2)
        
        # 可选分类头
        if num_species > 0:
            self.head_species = self._make_classification_head(num_species)

辅助目标

  • Pre_GSHH_NDVI (归一化植被指数)
  • Height_Ave_cm (平均牧场高度)
  • Species (物种分类,15 类)
  • State (州分类,4 类)

3.6 物理一致性损失

基于目标之间的物理关系设计损失函数,使用 GaussianNLLLoss 作为主要损失函数:

核心优势

  • 解决 R² 指标对异常值误差的敏感性
  • 通过 GaussianNLL 自动降低高噪声样本的权重(避免被脏数据偏差)
  • 使用物理约束确保预测的合理性(避免逻辑矛盾导致的大误差)
class PhysicsConsistencyLoss(nn.Module):
    """
    使用 GaussianNLLLoss 的物理一致性损失
    
    关键优势:
    - 自动异常值处理:模型学会为噪声样本预测高方差
    - 处理异方差性:不同生物量水平具有不同的不确定性
    - 物理约束:Dead = Total - GDM, Clover = GDM - Green
    """

    def __init__(self, weights: dict[str, float] = None):
        super().__init__()
        self.w = {
            "total": 0.45,
            "gdm": 0.2,
            "green": 0.1,
            "dead": 0.1,
            "clover": 0.1,
            "ndvi": 0.0,
            "height": 0.0,
            "species": 0.05
        }
        self.criterion = nn.GaussianNLLLoss()

    def forward(self, preds, targets, species_targets=None):
        # 直接预测损失
        loss_total = self.criterion(pred_total_mean, gt_total, pred_total_var)
        loss_gdm = self.criterion(pred_gdm_mean, gt_gdm, pred_gdm_var)
        loss_green = self.criterion(pred_green_mean, gt_green, pred_green_var)
        
        # 物理约束损失
        # Dead = Total - GDM
        pred_dead_mean = pred_total_mean - pred_gdm_mean
        pred_dead_var = pred_total_var + pred_gdm_var
        gt_dead = gt_total - gt_gdm
        loss_dead = self.criterion(pred_dead_mean, gt_dead, pred_dead_var)
        
        # Clover = GDM - Green
        pred_clover_mean = pred_gdm_mean - pred_green_mean
        pred_clover_var = pred_gdm_var + pred_green_var
        gt_clover = gt_gdm - gt_green
        loss_clover = self.criterion(pred_clover_mean, gt_clover, pred_clover_var)
        
        # 加权总损失
        total_loss = (
            self.w["total"] * loss_total +
            self.w["gdm"] * loss_gdm +
            self.w["green"] * loss_green +
            self.w["dead"] * loss_dead +
            self.w["clover"] * loss_clover
        )
        return total_loss

3.7 分层学习率

为预训练的 Backbone 和新初始化的 Heads 设置不同的学习率:

param_groups = []
for name, param in model.named_parameters():
    if 'backbone' in name:
        # Backbone 使用较小的 lr 以保留预训练能力
        param_groups.append({
            'params': param, 
            'lr': cfg.finetune_lr * cfg.backbone_lr_mult  # 通常为 0.1
        })
    elif 'head' in name:
        # Head 使用较大的 lr 以快速拟合
        param_groups.append({
            'params': param, 
            'lr': cfg.finetune_lr * cfg.head_lr_mult  # 通常为 1.0
        })
    else:
        param_groups.append({'params': param, 'lr': cfg.finetune_lr})

optimizer = optim.AdamW(param_groups, weight_decay=cfg.weight_decay)

理由:为预训练的 Backbone 设置较小的学习率以保留其通用特征提取能力(防止破坏预训练权重),而为新初始化的 Heads 设置较大的学习率以快速拟合当前特定任务。

3.8 后处理策略

3.8.1 基于州的后处理

分析显示 WA 州的数据与其他州有不同的特征:

# WA 州特殊处理:Dead 为 0, Total 等于 GDM
if predicted_states_df is not None:
    final_pred = final_pred.merge(predicted_states_df, on='image_path', how='left')
    is_wa = final_pred['Predicted_State'] == 'WA'
    final_pred.loc[is_wa, 'Dry_Dead_g'] = 0
    final_pred.loc[is_wa, 'Dry_Total_g'] = final_pred.loc[is_wa, 'GDM_g']

3.8.2 基于统计的范围裁剪

根据每个州的训练集目标值统计信息裁剪结果:

state_stats = {
    'Tas': {'Dry_Clover_g': {'min': 0.0, 'max': 71.79}, ...},
    'NSW': {'Dry_Clover_g': {'min': 0.0, 'max': 10.10}, ...},
    'WA':  {'Dry_Clover_g': {'min': 0.0, 'max': 58.88}, ...},
    'Vic': {'Dry_Clover_g': {'min': 0.0, 'max': 67.90}, ...}
}

for state in state_stats.keys():
    state_mask = final_pred['Predicted_State'] == state
    for var in target_vars:
        min_val = state_stats[state][var]['min']
        max_val = state_stats[state][var]['max']
        final_pred.loc[state_mask, var] = final_pred.loc[state_mask, var].clip(
            lower=min_val, upper=max_val
        )

3.8.3 缩放后处理

def apply_post_processing(df: pd.DataFrame) -> pd.DataFrame:
    """应用 Clover 和 Dead 后处理规则。"""
    df_out = df.copy()
    if 'Dry_Clover_g' in df_out.columns:
        df_out['Dry_Clover_g'] = df_out['Dry_Clover_g'] * 0.8
    if 'Dry_Dead_g' in df_out.columns:
        mask_high = df_out['Dry_Dead_g'] > 20
        df_out.loc[mask_high, 'Dry_Dead_g'] *= 1.1
        mask_low = df_out['Dry_Dead_g'] < 10
        df_out.loc[mask_low, 'Dry_Dead_g'] *= 0.9
    return df_out

3.9 全数据集训练

由于训练集相当小,目的是利用所有可用数据并简单地在训练集上过拟合。

# train_df = df[df["fold"] != fold].reset_index(drop=True)
# valid_df = df[df["fold"] == fold].reset_index(drop=True)

train_df = df
valid_df = df

4. 模型集成

4.1 最终集成方案

最终提交使用了 6 个模型的加权集成:

模型 权重 特征
exp-v142 0.0 (仅用于州预测) BiomassModelWithStateSpecies, 提取州信息
exp-v148 0.15 BiomassModelWithMetaEmbedding, 使用元特征
exp-v147 0.20 BiomassModelBasic, seed=2026
exp-v174 0.25 BiomassModelBasic, 随机增强
exp-v164 0.20 BiomassModelBasic, TTA 风格增强
exp-v177 0.20 BiomassModelBasic, 调整损失权重

4.2 TTA 推理

每个模型使用 3 种 TTA 变换:

  1. 原始视图
  2. 水平翻转
  3. 垂直翻转

最终预测取平均值。

4.3 双 GPU 并行推理

实现了双 GPU 并行推理以加速:

def parallel_dual_gpu_inference(dataset, model_class, model_kwargs, model_states, ...):
    """在两个 GPU 上并行运行推理。"""
    # 偶数索引 -> GPU 0, 奇数索引 -> GPU 1
    indices_0 = list(range(0, n, 2))
    indices_1 = list(range(1, n, 2))
    
    with ThreadPoolExecutor(max_workers=2) as executor:
        future_0 = executor.submit(inference_single_gpu, model_0, ..., device_0)
        future_1 = executor.submit(inference_single_gpu, model_1, ..., device_1)
        results_0 = future_0.result()
        results_1 = future_1.result()
    
    # 合并结果
    ...

5. 关键超参数

超参数
Backbone vit_large_patch16_dinov3_qkvb
图像尺寸 1024x1024
Batch Size 4
学习率 2e-4 (warm-up 后微调至 5e-5)
Backbone LR 乘数 0.1
训练 Epochs 25
MoE 专家数 4
MoE 隐藏层维度 512
优化器 AdamW
权重衰减 1e-4

6. 失败的尝试

根据实验记录,以下方法没有改进或不稳定:

6.1 无效的方法

实验 描述 结果
exp-v36 比率预测 CV 从 0.58 降至 0.43
exp-v97 不分割的完整图像 CV 0.57, LB 0.52 (严重下降)
exp-v99 LR 2e-4 + 20 epochs CV 仅 0.21
exp-v123 分割 + 注意力 CV 0.53, LB 0.53
exp-v168/169 Mamba 网络 CV 0.80-0.89 (不稳定)
exp-v180 UNet 原始尺寸 1000x2000 LB 0.64 (显著下降)

6.2 不稳定的方法

实验 描述 问题
超大模型 (exp-v170) vit_huge 高 CV 0.95 但 LB 0.72
30 epochs (exp-v161) 增加 epochs 过拟合,Private LB 下降
各种分割掩膜 ng/xg mask 部分有效但不稳定

6.3 经验教训

  1. 大模型并不总是更好:超大模型在小数据集上容易过拟合
  2. 训练 epochs 需要控制:~25 个 epochs 是最佳平衡点
  3. 复杂的注意力机制收益有限:在小数据集上容易过拟合
  4. Mamba 和其他序列模型架构不适合此问题

7. 实验进展

7.1 关键里程碑

阶段 实验 Public LB Private LB 关键改进
基线 v1 0.57 - 448 尺寸基线
模型升级 v24 0.65 - vit_large_dinov3
分辨率 v32 0.69 - 512 尺寸
物理损失 v42 0.69+ - PhysicsConsistencyLoss
MoE v44 0.69 - 混合专家模型
1024 分辨率 v108 0.73 - 1024 尺寸 + LR 优化
全量训练 v136 0.74+ - 全数据集 + 物种
种子优化 v147 0.75+ - seed=2026
增强 v164 0.75 - TTA 风格增强
随机增强 v174 0.76 0.65 随机增强选择
集成 Final 0.76 0.66 6 模型加权集成

7.2 最终 Private LB 分数

根据提交记录,最终选择的两个提交:

  • Public LB: 0.76, Private LB: 0.66 (第 5 名)

8. 结论与经验分享

8.1 关键成功因素

  1. 广泛的实验:进行了 188+ 次实验,充分探索了解决方案空间
  2. 物理约束:利用目标间的物理关系设计损失函数
  3. 多样化集成:融合不同种子、架构和训练策略的模型
  4. 数据增强:在小数据集上,适当的数据增强至关重要
  5. 后处理:基于数据分析的后处理策略

8.2 关于提交选择

本次比赛的提交选择非常困难,主要是因为:

  • 数据集小,模型容易过拟合
  • 训练集和测试集之间存在分布偏移
  • Public LB 和 Private LB 相关性低

我最终选择了 Public LB 最高的提交。虽然 Private LB 不是最优的(0.66 对比某些失败提交的 0.65),但它相对稳定。

8.3 运气因素

我必须承认这个结果有一些运气成分:

  • 看到许多顶级玩家在最终排名中发生变动
  • 提交选择策略碰巧避开了陷阱
  • 多模型集成提高了稳定性

8.4 致谢

特别感谢:

  • Kaggle 社区的慷慨分享
  • 其他 Kagglers 的讨论和 Notebook
  • CSIRO 组织者提供这个有意义的比赛

这枚单人金牌对我成为 Grandmaster 的旅程非常重要!希望这个解决方案分享能帮助到大家。

同比赛其他方案