返回列表

3rd Place Solution

687. CSIRO - Image2Biomass Prediction | csiro-biomass

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

第三名解决方案

将各种增强方法和骨干网络统统用在 357 张图片上

作者: Mayukh Bhattacharyya
发布日期: 2026-01-30
竞赛排名: 第 3 名

感谢 CSIRO 和 Kaggle 举办比赛,也感谢其他人全程分享想法!

说实话,我很惊讶能取得这么高的名次。我押注于重型集成(heavy ensemble)以防止榜单变动,但没想到它有助于提升 leaderboard 排名。我的提交是一个包含 19 个模型的集成,跨越不同的模型类型和 CV 策略。

1. 模型架构

我训练了两族模型:

  • 元数据模型 (Metadata Models)
  • 非元数据模型 (Non Metadata Models)

元数据模型

最初我训练了带有 vit_huge_plus_patch16_dinov3 骨干的模型,这些模型有一个辅助头来预测所有元数据,然后输入到最终的头来预测生物量。模型使用辅助元数据损失以及主要损失进行训练。后来我意识到,元数据并没有带来太大区别,所以我放弃了它。

非元数据模型

我采用了大家都在用的基于 MambaBlock 的流行模型,使其适用于 2 个骨干网络,然后最终扩展到 3 个骨干网络。第一个模型使用了 vit_huge_plus_patch16_dinov3convnextv2_large,因为它们互补性很好;第二个模型在此基础上加入了 vit_base_patch16_224.mae。根据我的经验,模型的选择对整体性能改善很小,因为我所有的模型在 4/5 折集成上都获得了 0.64+ 的成绩。然而,DinoV3 的 Huge Plus 版本对我来说总是优于 Large 版本。

除了一个模型我尝试了整张图像作为输入外,所有模型都是基于 2 流(将图像分为左 & 右)的。它们的性能几乎相似。

所有模型都有 3 个头。GDM 和 Total 是从 3 个预测值中推断出来的。

在训练期间,我以正常学习率的 1/10 训练骨干网络,以免完全破坏预训练权重。

class BiomassModelV2(nn.Module):
    """DINOv3 + Mamba Fusion + Multi-Head Regression"""

    def __init__(self, model1_name: str,
                 model2_name: str,
                 pretrained: bool = True
                ):
        super().__init__()
        self.model1_name = model1_name
        self.model2_name = model2_name

        self.backbone1 = timm.create_model(
            model1_name, pretrained=pretrained, num_classes=0, global_pool=''
        )
        self.backbone2 = timm.create_model(
            model2_name, pretrained=pretrained, num_classes=0, global_pool=''
        )
        nf1 = self.backbone1.num_features
        nf2 = 256
        nf = nf1 + nf2

        self.fusion1 = nn.Sequential(
            LocalMambaBlock(nf1, kernel_size=5, dropout=0.1),
            LocalMambaBlock(nf1, kernel_size=5, dropout=0.1)
        )
        self.fusion2 = nn.Sequential(
            LocalMambaBlock(nf2, kernel_size=5, dropout=0.1),
            LocalMambaBlock(nf2, kernel_size=5, dropout=0.1)
        )
        self.pool = nn.AdaptiveAvgPool1d(1)

        self.head_green = nn.Sequential(
            nn.Linear(2*nf, nf // 2), nn.GELU(), nn.Dropout(0.2),
            nn.Linear(nf // 2, 1), nn.Softplus()
        )
        self.head_dead = nn.Sequential(
            nn.Linear(2*nf, nf // 2), nn.GELU(), nn.Dropout(0.2),
            nn.Linear(nf // 2, 1), nn.Softplus()
        )
        self.head_clover = nn.Sequential(
            nn.Linear(2*nf, nf // 2), nn.GELU(), nn.Dropout(0.2),
            nn.Linear(nf // 2, 1), nn.Softplus()
        )

    def forward(self, x):
        # x is a tuple (left, right)
        left, right = x
        
        x_l1 = self.fusion1(self.backbone1(left))
        x_l2 = self.fusion2(self.backbone2(left).flatten(2))
        x_r1 = self.fusion1(self.backbone1(right))
        x_r2 = self.fusion2(self.backbone2(right).flatten(2))

        x_l1 = self.pool(x_l1.transpose(1, 2)).flatten(1)
        x_l2 = self.pool(x_l2.transpose(1, 2)).flatten(1)
        x_r1 = self.pool(x_r1.transpose(1, 2)).flatten(1)
        x_r2 = self.pool(x_r2.transpose(1, 2)).flatten(1)

        x_cat = torch.cat([x_l1, x_l2, x_r1, x_r2], dim=1)

        green = self.head_green(x_cat)
        dead = self.head_dead(x_cat)
        clover = self.head_clover(x_cat)
        gdm = green + clover
        total = gdm + dead

        # Return as a single tensor (batch, 5)
        return torch.cat([green, dead, clover, gdm, total], dim=1)

数据

数据显然是本次竞争最大的因素,从一开始我的重点就是从提供的训练数据中提取最大信息。我使用了所有可用的主要增强方法。特别值得一提的是随机灰度化,以确保模型学习特征而不仅仅是颜色。

我即兴创作了两种额外的增强方法,我相信它们有所帮助:

  • 一种是简单的 MixUp,lambda 值较低,为 0.2-0.3。
  • 另一种是在图像上进行 3 次随机垂直切割,将其分为 4 段,然后置换段的顺序。这是一个很好的增强方法,因为打乱的图像仍然描绘了相同数量的生物量。类似地,我以 50% 的概率交换了左图和右图。

我同时应用了上述两种增强方法,而不是单独使用。

交叉验证 (CV)

我尝试了 2 种 CV 技术,或者更确切地说是折创建技术——因为在第一个月后,我放弃了 CV 分数,完全信任 LB 分数。为了避免过拟合,我选择在训练特定架构时始终使用所有折的所有模型。没有任何 CV 策略感觉是稳健的——总是有 1-2 个折表现很好,而 1-2 个折在 LB 上表现不佳。

  • 第一种 CV 技术是基于日期作为组的 GroupKFold。
  • 第二种 CV 技术稍微复杂一些。它基于开源的嵌入距离 CV。我将其扩展为将每个验证集分为 2 个相等的子集——简单和困难(基于最近邻)。此时,我期望在困难子集上给出更高分数的模型权重与 LB 更一致,但事实恰恰相反。简单子集和 LB consistently 有更好的对齐。我的猜测是,测试集中的困难样本真的很难,模型可以通过在简单样本上表现出色而在困难样本上表现尚可,从而获得比在两类样本上都表现良好的更高分数。
def create_robust_cv(
    df,
    embeddings_col,
    target_names,
    n_splits=5,
    n_clusters=50,
    seed=42,
    k=5,
    distance_metric="euclidean",
):
    df = df.copy().reset_index(drop=True)

    # 1. Embeddings
    X = np.vstack(df[embeddings_col].values).astype(np.float32)
    X /= np.linalg.norm(X, axis=1, keepdims=True) + 1e-8

    # 2. Visual clustering
    kmeans = KMeans(
        n_clusters=n_clusters,
        random_state=seed,
        n_init=10,
    )
    df["visual_cluster"] = kmeans.fit_predict(X)

    # 3. Stratification target
    weights = {
        "Dry_Green_g": 0.1,
        "Dry_Dead_g": 0.1,
        "Dry_Clover_g": 0.1,
        "GDM_g": 0.2,
        "Dry_Total_g": 0.5,
    }

    composite = sum(df[t] * weights.get(t, 0) for t in target_names)
    try:
        df["target_bins"] = pd.qcut(composite, q=10, labels=False, duplicates="drop")
    except ValueError:
        df["target_bins"] = pd.qcut(composite, q=5, labels=False, duplicates="drop")

    # 4. CV split
    sgkf = StratifiedGroupKFold(
        n_splits=n_splits,
        shuffle=True,
        random_state=seed,
    )
    df["fold"] = -1
    df["val_difficulty"] = None
    df["mean_knn_dist_to_train"] = np.nan

    for f, (train_idx, val_idx) in enumerate(
        sgkf.split(df, df["target_bins"], groups=df["visual_cluster"])
    ):
        X_train, X_val = X[train_idx], X[val_idx]

        nn = NearestNeighbors(n_neighbors=k, metric=distance_metric)
        nn.fit(X_train)
        knn_dist, _ = nn.kneighbors(X_val)
        mean_dist = knn_dist.mean(axis=1)
        df.loc[val_idx, "mean_knn_dist_to_train"] = mean_dist

        # 5. EASY / HARD split
        order = np.argsort(mean_dist)
        mid = len(order) // 2

        easy = val_idx[order[:mid]]
        hard = val_idx[order[mid:]]

        df.loc[easy, ["fold", "val_difficulty"]] = [f, "easy"]
        df.loc[hard, ["fold", "val_difficulty"]] = [f + n_splits, "hard"]
    return df

推理

  • TTA 实际上使 LB 性能变差,所以我关掉了它。有了 heavy augmentation 和 ensemble,我最终不需要它。

  • 我显然使用了我在 分享的 notebook 中展示的缩放。我最终又扩展了一点。对于 Clover,测试集的数据分布似乎非常不同。对于 Dry Dead,我觉得所有模型在预测时都保守地趋向于均值,而实际值更偏向于边缘。缩放在公共 LB 上给了我不错的提升(< 0.01)。

既然 CV 和 LB 都不可靠,我从一开始的策略就是训练多种架构并进行集成。我没有对模型或训练做任何 drastic changes,主要是基于 CV 分数的稳定性和 LB 分数的改进进行缓慢的渐进式改进。

我的大多数单独架构在 5 折上的性能非常相似(~0.64+)。我训练的最后一组模型——3 骨干非元数据模型是我得分最高的提交,得分为 0.67。

最后值得一提的是,我尝试使其生效但失败的两件事是合成数据集和测试时训练,尤其是后者——因为测试数据差异太大了。读到一些顶级团队是如何让它生效的,还是挺令人兴奋的。

同比赛其他方案