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