返回列表

27th Place Solution

687. CSIRO - Image2Biomass Prediction | csiro-biomass

开始: 2025-10-28 结束: 2026-01-28 作物智能识别 数据算法赛
第 27 名解决方案 - CSIRO 生物量竞赛

第 27 名解决方案

作者: Pushpak Bhoge | 排名: 27 | 日期: 2026 年 1 月 30 日

重度增强 + 辅助特征调制 + 失败的特征工程

首先,感谢主办方组织了如此有趣和愉快的比赛。这是我第一次认真参与竞赛,我很高兴我的解决方案实际上足够好,能够排名第 27 位。在这次参与过程中获得的经验和知识极大地开阔了我的视野。所以感谢你们拓宽了我们的知识 horizon。这也是我第一次使用交叉验证和集成策略。

☘️ 探索性数据分析 (EDA)

在分析数据后,我注意到了以下观察结果:

  • 生物量目标遵循清晰的加法关系:Dry_Total_g = Dry_Green_g + Dry_Dead_g + Dry_Clover_gGDM_g = Dry_Green_g + Dry_Clover_g
  • 目标分布特征:
    • Dry_Total_gGDM_g 噪声最小,且只有这两个大致服从高斯分布。
    • Dry_Green_g 服从幂律分布,头部较短。
    • Dry_Dead_gDry_Clover_g 也服从幂律分布,但头部更高且尾部更长。
  • 物种目标噪声较大;某些物种视觉上非常相似(尤其是亚种)。
  • 3 个辅助目标与数值显示出强相关性,例如:
    • 如果不存在三叶草或其亚种,则 Dry_Clover_g 通常为零。
    • 如果高度较高,目标质量也较高,除了 lucerne(苜蓿),其质量值未按相同比例缩放。

☘️ 数据预处理和策略

  • 对于生物量目标,由于 Dry_Dead_gDry_Clover_g 噪声大且有很多零,我决定预测 Dry_Total_gGDM_gDry_Green_g,并从中推导其余两个。我对这些生物量目标使用了对数归一化变换。

    means = torch.tensor([3.6171854219925024, 3.217874424165861, 2.832479241567281])
    stds = torch.tensor([0.5573042636308412, 0.8185179833598041, 1.2134970356508157])
    normliazed = (torch.log(data + 1) - means) / stds
    data = torch.exp((normliazed * stds) + means) - 1
  • 对于辅助回归目标,我进行了以下预处理:

    • 辅助目标 Height_Ave_cm 也以相同方式进行归一化和反归一化,均值为 1.7949544186979798,标准差为 0.7399843934368923。因为高度也服从幂律分布,头部高且尾部长。
    • 辅助目标 Pre_GSHH_NDVI 未进行变换,直接使用。
    • 对于物种,我预处理并分组了外观相似的物种,还解决了大小写不匹配的问题。我将 15 个类别减少到 8 个类别。我使用了以下函数。这种分组带来了约 0.03 的 R² 提升。
    def reduce_species(species):
        new_species = []
        for one_species in species.split("_"):
            if one_species in ("BarleyGrass", "Barleygrass", "Bromegrass", "SilverGrass", "SpearGrass"):
                new_species.append("grass")
            elif one_species in ("SubcloverDalkeith", "SubcloverLosa", "Clover"):
                new_species.append("clover")
            elif one_species in ("Capeweed", "CrumbWeed"):
                new_species.append("weed")
            elif one_species == "Mixed":
                new_species.append("ryegrass")
            else:
                new_species.append(one_species.lower())
        return list(set(new_species))

☘️ 交叉验证折叠创建

由于数据集非常小,我试图创建数据拆分,使每个训练集都能看到完整的数值谱。但这在单个交叉折叠中无法实现,因为我总是在训练 - 验证拆分中泄露日期和状态。所以我决定创建两个交叉验证拆分。

  • 5 折交叉验证 -> 我称之为 clean_folds,我在 StateSampling_Date 上进行了分层,使用以下代码:

    df = pd.read_csv('./datasets/csiro-biomass/train_unwrapped.csv')
    df['session_id'] = df['State'].astype(str) + "_" + df['Sampling_Date'].astype(str)
    df['biomass_bin'] = pd.qcut(df['Dry_Total_g'], q=5, labels=False)
    sgkf = StratifiedGroupKFold(n_splits=5, shuffle=False)
  • 3 折交叉验证 -> 这里我简单地按 Dry_Total_g 降序排序并创建了 3 个组,以便每个训练集都能看到完整的目标值谱。只使用了 3 折以避免训练/验证拆分过于相似。

☘️ 数据增强

首先,什么奏效了

  • 测试了图像分辨率 384, 512, 768, 1024 与 patch 大小 2x1, 4x2 的组合。发现图像大小 768x768 配合两个 patches(left and right)效果最好,所以其余实验都以此方式进行。

  • 对于增强,我使用了一些全局变换,应用于整张图像。以及局部变换,应用于 patch 级别。我为此编写了一个自定义 compose 类。我还尝试了在训练中途切换到较弱的 pipeline。最坏情况下它什么都没做,最好情况下给指标带来了一点提升,所以我默认在其余运行中使用它,在最后 5-10 个 epoch 切换 pipeline。

    NORM_SETTING = {"type": "Normalize", "mean": [0.485, 0.456, 0.406], "std": [0.229, 0.224, 0.225]}
    STRONG_AUG_TRANSFORMS = {
        "type": "albu",
        "compose_cls": "tiled_compose",
        "global_transforms": [
            {
                "type": "OneOf", "p": 0.5,
                "transforms": [
                    {"type": "AutoContrast", "method": "cdf", "p": 0.5},
                    {"type": "RandomBrightnessContrast", "brightness_limit": 0.3, "contrast_limit": 0.2, "p": 0.5},
                    {"type": "RandomToneCurve", "scale": 0.3, "p": 0.3},
                    {"type": "HueSaturationValue", "hue_shift_limit": 20, "sat_shift_limit": 30, "val_shift_limit": 20, "p": 0.5},
                    {"type": "PlanckianJitter", "mode": "blackbody", "temperature_limit": [4000, 8000], "sampling_method": "gaussian", "p": 0.5},
                    {"type": "CLAHE", "p": 0.5},
                ],
            },
            {"type": "ShiftScaleRotate", "shift_limit": [-0.1, 0.1], "scale_limit": [-0.1, 0.1], "rotate_limit": [10, 10], "border_mode": 4, "p": 0.5},
            {"type": "GaussianBlur", "blur_limit": 3, "sigma_limit": 1, "p": 0.5},
            {
                "type": "OneOf", "p": 0.5,
                "transforms": [
                    {"type": "GridDistortion", "distort_limit": 0.5, "p": 1.0},
                    {"type": "OpticalDistortion", "p": 1.0},
                ],
            },
        ],
        "local_transforms": [
            {"type": "D4", "p": 1.0},
            {
                "type": "OneOf", "p": 0.5,
                "transforms": [
                    {"type": "Sharpen", "alpha": [0.1, 0.5], "lightness": [1.0, 1.0], "method": "kernel", "p": 0.8},
                    {"type": "Emboss", "alpha": [0.4, 0.5], "strength": [0.3, 0.7], "p": 0.8},
                ],
            },
        ],
        "cvt_transforms": [NORM_SETTING, {"type": "ToTensorV2"}],
        "n_patch_h": 1, "n_patch_w": 2,
        "target_size": [IMAGE_SIZE, IMAGE_SIZE * 2]
    }

☘️ 架构概述

  • 使用 DinoV3 ViT large 作为骨干网络,几乎本竞赛中的每个人都这样做。
  • 图像 patches 被传递到共享骨干网络,来自两个 patches 的形状为 [N, dim] 的特征被拼接。
  • 首先,使用一个简单的 2 层 MLP 从骨干特征预测高度、NDVI 和物种。相同的骨干特征被传递到 MLP 投影网络,产生一个嵌入。
  • 然后,将 NDVI、高度和 sigmoid 激活的物种 logits 拼接,然后传递到 FiLM 层,该层随后调制上述 MLP 投影网络产生的嵌入。之前,我只是简单地拼接它们,但由于嵌入的大小,它占据了主导地位。我在寻找更好的选项,所以我想预测 scale shift 参数并以这种方式转换嵌入。我最终设计了 FiLM 层。(有趣的事实:我最初以为我发现了一个新颖的想法,因为这使我在公共排行榜上从 0.68 提升到了 0.72。后来发现了 FiLM 论文 😅)
  • 最后,我从调制后的嵌入中一次性预测了 3 个生物量目标。

☘️ 损失与优化

  • 对于损失,我对生物量目标使用了加权 SmoothL1 损失,对高度和 NDVI 目标使用了 SmoothL1 损失。
  • 对于物种头,我使用了简单的 BCE 损失,每个类别的权重如下:
    SPECIES = ["clover", "ryegrass", "phalaris", "grass", "fescue", "lucerne", "weed", "whiteclover"]
    SPECIES_WEIGHTS = [1.68, 1.86, 2.76, 3.59, 4.09, 5.01, 5.89, 7.08]  # 根号 sqrt(N / Nc)
  • 上述 3 个损失 combined 权重为 {"biomass": 3.0, "aux": 1.0, "species": 0.2}
  • 对于训练,我使用了 AdamW,学习率为 1e-5,权重衰减为 0.3(我知道这很高,但它能正则化模型),warmup 5 个 epoch,然后使用余弦退火降低其余训练的学习率。
  • 训练不稳定,所以我使用了 EMA 和梯度裁剪来稳定训练。
  • 所有三个头一起在 3CV 和 5CV 上训练。这两个是我的最终提交。

☘️ 未奏效的方法

  • 由于竞赛提供的数据集非常小,我找到了一个大型数据集(约 270GB,约 4 万张图像),我已经处理并上传到 Kaggle。不过没什么太大帮助。点击此处
  • DinoV2 预训练:在 largely unlabelled 数据上微调 DinoV2 (ViT-small) 100 个 epoch。尽管注意力图很有希望,但未能击败原始权重。由于计算限制而停止,但这仍然是一个潜在的探索途径。
  • 分割:在合成数据上训练了一个 16 类模型,为下游 CNN 创建引导注意力图。代码
  • CNN 架构:最初使用 ConvNeXt-atto (ImageNet1k 预训练),理论上较小的模型适合数据集大小。集成了分割图用于注意力,并测试了各种 pooling 方法。
  • CNN 结果:尽管进行了广泛的调整,CNN 无法超越 ViT。后来意识到 superior 特征至关重要,我尝试了带有 DinoV3 权重的 ConvNeXt-Base,但只达到了 0.65 公共排行榜,表明 ConvNeXt 特征在这里不如 ViT-base 有效。这些 ConvNeXt 权重是从 ViT 蒸馏而来的,所以也许与此有关。
  • 元平均 (Meta-Averaging):尝试在生物量推断之前对辅助预测进行元平均,效果中性。
  • 解冻骨干网络 (Unfreezing-backbone):尝试 across 不同骨干阶段缩放 lr,从头训练小模型也不起作用。
  • 生物量求和:尝试通过求和 patch 生物量来近似密度图。求和原始通道导致梯度爆炸,而 3x3 平均 pooling 稳定了训练但没有带来收益。
  • 空间 Pooling:实现了一个卷积空间 pooling 层(将 24x24 特征减少到 1x1),后跟 MLPs。求和这些输出使公共排行榜提高了 0.01,峰值为 0.59。代码
  • FPN & 多阶段头:测试了在不同分辨率下融合骨干特征 (FPN) 并将头直接附加到 Stage 2 和 3 输出。这两种方法都没有提高分数。

我为此编写了相当广泛的代码。你可以在我的 GitHub 仓库 找到代码。
这是我的 提交 notebook。我计划发布一篇详尽的文章,这需要一两周的时间。

最后,为什么在这次竞赛中 CNN consistently inferior 于 ViT?我很想听听其他人的见解。

同比赛其他方案