687. CSIRO - Image2Biomass Prediction | csiro-biomass
首先,感谢 Kaggle 和 CSIRO 组织这次比赛。这是一个非常有意义的农业 AI 应用场景。
这是我第一次参加 Kaggle 计算机视觉(CV)竞赛,非常高兴和幸运能获得第 5 名(单人,Private LB: 0.76)。在整个比赛过程中,我进行了大量的实验——我大约做了 100+ 次训练实验(从 exp-v1 到 exp-v188)。通过这次比赛,我学到了很多关于计算机视觉的知识。以下是我比赛解决方案的详细分享。
本次比赛的目标是利用牧场图像预测五个关键的生物量组成部分:
比赛使用加权 R² 分数作为评估指标,即五个目标的 R² 加权平均值。
经过大量的消融实验,以下是可以稳定提高分数的关键技术:
原始图像包含橙色的日期时间戳,这是数据噪声。我使用 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
由于训练集较小,数据增强对于防止过拟合至关重要。我使用了 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]
训练策略:
除了将图像分割为左半部分和右半部分(模拟立体视觉)外,我还添加了完整图像作为第三个输入流:
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
这种设计允许模型同时学习局部细节(左/右视图)和全局上下文(完整视图)。
设计意图:模型可以根据输入图像的具体特征(例如,更多绿草或更多枯草)自动切换到最合适的专家进行预测,有效解决多任务学习中的特征冲突。
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
三种模型变体:
添加额外的训练目标有助于模型学习更丰富的特征表示:
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)
辅助目标:
基于目标之间的物理关系设计损失函数,使用 GaussianNLLLoss 作为主要损失函数:
核心优势:
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
为预训练的 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 设置较大的学习率以快速拟合当前特定任务。
分析显示 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']
根据每个州的训练集目标值统计信息裁剪结果:
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
)
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
由于训练集相当小,目的是利用所有可用数据并简单地在训练集上过拟合。
# 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
最终提交使用了 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, 调整损失权重 |
每个模型使用 3 种 TTA 变换:
最终预测取平均值。
实现了双 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()
# 合并结果
...
| 超参数 | 值 |
|---|---|
| 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 |
根据实验记录,以下方法没有改进或不稳定:
| 实验 | 描述 | 结果 |
|---|---|---|
| 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 (显著下降) |
| 实验 | 描述 | 问题 |
|---|---|---|
| 超大模型 (exp-v170) | vit_huge | 高 CV 0.95 但 LB 0.72 |
| 30 epochs (exp-v161) | 增加 epochs | 过拟合,Private LB 下降 |
| 各种分割掩膜 | ng/xg mask | 部分有效但不稳定 |
| 阶段 | 实验 | 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 模型加权集成 |
根据提交记录,最终选择的两个提交:
本次比赛的提交选择非常困难,主要是因为:
我最终选择了 Public LB 最高的提交。虽然 Private LB 不是最优的(0.66 对比某些失败提交的 0.65),但它相对稳定。
我必须承认这个结果有一些运气成分:
特别感谢:
这枚单人金牌对我成为 Grandmaster 的旅程非常重要!希望这个解决方案分享能帮助到大家。