ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ConvNeXt植物幼苗分类实战:数据增强、分层学习率与TTA调优

ConvNeXt植物幼苗分类实战:数据增强、分层学习率与TTA调优

植物幼苗分类这个题目,我在农业视觉的项目里反复碰到过:田里刚冒头的小苗,叶片还没完全展开,作物和杂草挤在一垄里,颜色、形状、纹理都高度相似,人眼在强光或者湿泥背景下都容易看错,更别说让模型去分。ConvNeXt 是我在这类细粒度、小样本任务上试下来比较顺手的一个骨干网络,纯卷积结构、部署简单、预训练权重好找、对小数据集不像纯 Transformer 那样挑食。这篇就把我用 ConvNeXt 做植物幼苗分类的整套流程摊开讲:数据怎么清、划分怎么防泄漏、增强开到什么程度、学习率和层衰减怎么配、训练完怎么用 TTA 把最后几个点抠出来,以及我在验证集上翻车之后是怎么一项项排查回去的。不管你是刚接触迁移学习、想找个能跑通的完整流程,还是已经在做农业图像分类、想看看别人的参数是怎么定的,下面这些内容都可以直接拿去改改就用。

1. 项目整体设计与技术选型思路

1.1 先把任务本质说清楚:幼苗分类到底难在哪

很多人一上来就调模型,结果卡在验证集不动,其实问题根本不在网络。幼苗分类的难点有四层,得先认清楚。第一层是细粒度:十字花科的几个近缘种在子叶期长得几乎一样,差别可能只在叶片边缘的锯齿深浅、叶面是否有细毛、茎基部是否泛紫,这些信息在 224 分辨率下可能就剩十几个像素。第二层是样本少:这类数据集通常是每类几十张到一百多张,整体规模在千张量级,用百万级参数量的模型去拟合,过拟合是必然的,不是意外。第三层是域偏移:同一个物种,晴天拍、阴天拍、湿土拍、干土拍,颜色分布能差出一大截,模型很容易学到"背景是黑土还是砂土"而不是"叶子长什么样"。第四层是长尾:不同类别的样本数能差两三倍,直接用普通交叉熵训练,模型会倾向于把少数类往多数类上靠。

我一开始没意识到第四层的杀伤力,只看总体准确率,结果 78% 的准确率看着还行,一拉混淆矩阵才发现有两个类别召回率只有 40% 出头,全被吞进了一个样本量最大的类。所以这个项目从一开始就要把评估指标定成macro F1而不是 accuracy,这不是洁癖,是防止自己被平均数骗了。

1.2 为什么选 ConvNeXt,而不是 ResNet 或纯 ViT

我拿三个方向都试过,说说取舍逻辑。ResNet50 是最稳的基线,训起来不挑参数,但它的问题是感受野和特征表达上限摆在那,细粒度任务上想再往上抬很难,我这边卡在一个平台上怎么调增强都没突破。纯 ViT 方向,理论上全局注意力对叶形这种整体结构敏感,但它对数据量的胃口太大,千张级别的数据即使挂 ImageNet 预训练,微调时也很容易出现注意力图乱飘、需要极小的学习率和很长的 warmup,调参成本高。

ConvNeXt 的定位正好在中间:它把 Transformer 训练配方里那些真正有用的东西(大感受野、LayerNorm、GELU、Layer Scale、更"patch 化"的下采样)搬进了一个纯卷积骨架里。对我这个任务来说有三个实际好处。一是感受野,7×7 的深度可分离卷积堆起来,第三个 stage 的有效感受野能覆盖整片叶子,判断锯齿、叶脉走向这类中尺度结构时信息是完整的。二是归一化方式,LayerNorm 不依赖 batch 统计量,我本地显卡只有一张,batch 只能开到 32 甚至 16,用 BatchNorm 的模型在小 batch 下统计量噪声大,ConvNeXt 就没这个问题。三是部署,纯卷积在推理端的算子支持最成熟,后面要往边缘设备上挪的时候省事。

代价也要说清楚:ConvNeXt-T 大约 2800 万参数,比 ResNet50 重,千张数据直接全量微调必然过拟合,所以必须配套强增强、随机深度和分层学习率。如果显存或者数据量更紧张,直接退到convnext_femto或convnext_pico这类小号版本,效果未必差多少。

1.3 整体方案的骨架和几个关键取舍

整套流程我拆成五段:数据清洗与分组划分 → 强增强的预处理流水线 → ConvNeXt 骨干 + 分层学习率微调 → EMA 权重 + 余弦退火 → 多尺度 TTA 与多折集成。中间有两个取舍值得单独讲。

第一个是要不要冻结骨干。我的做法是前 2 个 epoch 冻结 stem 和 stage1,只训后面的 stage 和分类头,之后全部解冻。理由是 stem 是 4×4 stride 4 的大步长卷积,它学的是最底层的颜色和边缘统计,这部分在 ImageNet 上学到的东西对植物图像同样成立,没必要在训练初期用随机梯度把它冲掉。实测下来这一步能让前期 loss 曲线平滑不少。

第二个是输入分辨率。数据集原图普遍在 300 到 500 像素之间,短边缩放到 256 再中心裁 224 是标准做法。但幼苗分类的判别信息经常在细节上,我后来把训练分辨率提到 288、推理用 320,macro F1 有可感知的提升,代价是显存涨了约 1.6 倍、单 epoch 时间长了 40%。如果你的卡够用,我建议直接从 288 起步。

2. ConvNeXt 核心结构拆解:改动到底发生在哪

2.1 从 ResNet 到 ConvNeXt 的四组现代化改造

ConvNeXt 的论文思路很直白:拿一个 ResNet50 当起点,然后把 Transformer 那边被验证有效的东西一项项搬过来,每搬一项测一次精度,最后得到一个"卷积外壳 + Transformer 内脏"的结构。拆开看是四组改动。

宏观结构上,它把 ResNet 的 stem(7×7 stride 2 卷积 + maxpool)换成了 4×4 stride 4 的卷积,这一步等价于 ViT 的 patchify,把输入直接下采样 4 倍,后面的计算量大幅下降。四个 stage 的 block 数量比例也从 ResNet 的 3:4:6:3 改成了 3:3:9:3,通道数 96:192:384:768,把算力往第三个 stage 集中。

深度可分离卷积替换了普通 3×3 卷积,这是省参数的关键。倒残差结构(inverted bottleneck)来自 MobileNetV2:先 1×1 升维 4 倍,做深度卷积,再 1×1 降维回原通道,形成"窄-宽-窄"的沙漏。大卷积核是最显眼的一步,深度卷积从 3×3 一路试到 7×7,7×7 精度最好且计算量增加有限,最终定在 7×7。

微观层面还有四个小改动:激活函数从 ReLU 换成 GELU;每个 block 里只保留一个激活(放在升维和深度卷积之间),而 ResNet 的 bottleneck 有两个;归一化从 BatchNorm 换成 LayerNorm,并且挪到每个卷积之前;残差分支输出加一个 Layer Scale,初始值 1e-6,让训练初期整个 block 近似恒等映射,深层网络更好训。

一个 ConvNeXt block 的前向顺序是:7×7 深度卷积 → LayerNorm → 1×1 升维 4 倍 → GELU → 1×1 降维 → Layer Scale 缩放 → 加残差。你写代码的时候不需要自己搭,但理解这个顺序对排查问题很有用,比如后面我遇到 loss 突然炸掉,就是因为 Layer Scale 被我在自定义模型里漏掉了。

2.2 7×7 大卷积核为什么"又大又便宜"

这是很多人第一次看 ConvNeXt 时的疑惑:卷积核从 3 变成 7,计算量不是涨了 5 倍多吗?答案在于它是深度卷积,每个通道独立卷积,不跨通道做乘加。

拿 stage1 举例,特征图通道 96,空间尺寸 H×W。一个 7×7 深度卷积的乘加次数约等于 49 × 96 × H × W。而同一个 block 里那个 1×1 升维卷积是 96 × 384 × H × W,也就是 36864 × H × W,比大核卷积高出将近 8 倍。所以在这个结构里,把核从 3 加到 7,只让深度卷积那一项从 9 涨到 49,占整个 block 的比例依然很小;但感受野从中等直接扩大到能覆盖叶片的主要结构。

我做过一个粗略的对比实验:把convnext_tiny的 7×7 换成 3×3,参数量几乎不变,但在幼苗数据上 macro F1 掉了将近 2 个点,尤其是那两个十字花科近缘类,混淆更严重。这从侧面说明大核带来的上下文对细粒度判别是真有用的。代价主要是显存和访存:大核深度卷积在 GPU 上的实现效率不如小核,实际吞吐会比 FLOPs 算出来的低一些,这一点在做部署预算时要注意。

2.3 训练配方比结构更关键,别只盯着网络

ConvNeXt 论文里反复强调的一点是:结构改造的收益,有一大半来自配套的训练配方。我完全同意,这个项目里的调参经验也印证了这点。配方里最值得抄的几项是:

训练技巧推荐值在幼苗任务上的作用
AdamW + 权重衰减wd = 0.05比 SGD 收敛快,配合 LN 更稳
余弦退火含 3~5 epoch warmup小数据上避免前期震荡
标签平滑0.1缓解近缘类的过度自信
随机深度 drop_path0.1~0.2千张数据必开,防过拟合主力
Mixup / CutMix0.2 / 0.5(微调阶段减半)提升校准和鲁棒性
EMA 权重decay 0.9998验证集指标稳定提升
层衰减 layer decay0.75~0.85保护预训练底层特征

其中层衰减是我强烈建议加上的一项,它的意思是越靠近输入的层,学习率越小,按lr × decay^k逐层递减。ConvNeXt 的底层特征通用性强,不该被小数据集的高梯度冲掉;上层语义特征才需要大幅调整。没加层衰减和加了层衰减,我在同一个配置下看到的差别是验证曲线在后半程更平稳,不容易在第 30 个 epoch 之后开始回弹。

3. 数据准备与预处理实操

3.1 数据清洗:先保证标签是对的

拿到数据别急着训模型,先花半天做三件事。第一是查重,田间拍摄经常同一株苗连拍好几张,这些图如果分散到训练和验证集,验证指标会虚高十几个点。我用感知哈希(pHash)做近重复检测,汉明距离小于 5 的归为一组,只保留组内一张进验证集之前先剔除冗余。第二是核对类别体系,幼苗数据集最容易出问题的地方是两个近缘种被人工标错,或者同一物种在不同批次被抓成了两个类。我一般会把每个类的样本抽 20 张拼成一张联系图扫一遍,肉眼能发现大部分异常。第三是剔除废图,重度失焦、叶子被遮挡超过一半、只有土壤没有苗的,直接移走,这类样本对训练只有负面作用。

这一步做完通常能砍掉 5% 到 10% 的样本,看着心疼,但验证集的可信度会完全不同。我之前有次没做去重,训练准确率冲到 99%,验证一直卡在 65%,查了两天才发现是同株连拍泄漏,白折腾。

3.2 划分策略:别让同一株苗同时出现在两侧

标准做法是分层采样(stratified split)保证每类在两个集合里的比例一致,但对这个任务不够,因为同一拍摄批次的图像之间有强相关:同一盘苗、同一天、同一光照条件。如果按图片随机划分,验证集里的背景分布和训练集几乎一样,测出来的数字偏乐观,一换到新批次的图上就崩。

我的做法是给每张图打一个"批次标签"(产地托盘编号或者文件名前缀里能提取出来),按批次做分组划分,同一批次整体进训练集或验证集。数据量够的时候做 5 折分组交叉验证,每折的验证集来自不同批次,这样得到的分数更接近真实泛化。如果批次信息拿不到,退一步,用感知哈希聚类的结果当分组依据,效果也还可以。

划分比例建议 8:2 起步,数据到两千张以上再考虑 7:1.5:1.5 的三段划分。随机种子固定下来,写进配置里,别每次跑都不一样,否则你没法判断改进来自代码还是来自运气。

3.3 预处理的几个参数,为什么这么定

预处理流水线不长,但每个参数都有理由。尺寸:短边 resize 到 256,中心裁 224(训练时用 RandomResizedCrop 替代中心裁剪)。缩放用双线性插值,不要用最近邻,幼苗的细锯齿在最近邻下会产生锯齿伪影。归一化:直接用 ImageNet 的均值方差[0.485, 0.456, 0.406] / [0.229, 0.224, 0.225],因为骨干是在这个分布上预训练的,改了反而要重新适应。

有一个我特意没做的操作:直方图均衡化和白平衡校正。很多农业图像的教程会建议做色彩归一化,理由是对抗光照变化,但幼苗分类里颜色本身是判别信息,比如某些物种的茎基部偏紫、子叶下表面偏灰绿,这些是重要线索。归一化把颜色分布拉平的同时也把线索抹掉了。我的替代方案是训练时用温和的颜色抖动做数据增强,让模型学会忽略光照差异但保留物种固有的色调。

还有一个细节:填充方式。如果做旋转增强,边缘会引入黑边,用reflect或constant填充配合背景色,别留纯黑边,模型会学到"有黑边的图就是某个类"这种无意义线索。

3.4 数据增强组合:开到什么程度算合适

千张数据训 2800 万参数模型,增强就是生命线。我的组合是这样的(基于 torchvision.transforms.v2,用 albumentations 也一样的思路):

  • RandomResizedCrop(224, scale=(0.6, 1.0), ratio=(0.85, 1.15)):缩放范围不要压到 0.3,幼苗主体本来就小,再裁就只剩背景了。
  • RandomHorizontalFlip(p=0.5)和RandomVerticalFlip(p=0.2):植物图像上下翻转一般不会改变语义,可以开,但概率别太高。
  • RandomRotation(degrees=20):拍摄角度本身有随机性,小幅旋转合理,超过 30 度会引入大量黑边。
  • ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.05):hue 一定要小,0.05 到 0.1 之间,再大就把物种固有色调没了。
  • RandAugment(num_ops=2, magnitude=7):有用,但要留意它自带的 shear 和 posterize 对这类图像收益不大,可以从策略里剔掉。
  • RandomErasing(p=0.25, scale=(0.02, 0.15)):模拟叶片被遮挡,配合 CutMix 一起用。
  • Mixup(alpha=0.2)或CutMix(alpha=0.5):二选一,微调阶段把 alpha 减半,太强会让收敛变慢。

注意事项:增强强度和训练轮数是绑定的。增强开得猛,就需要更多 epoch 才能收敛,我第一次把 RandAugment magnitude 提到 9 配 30 个 epoch,结果模型根本没学完,验证集一直在爬坡。这种情况要么降强度,要么加 epoch,别急着换模型。

4. 训练流程与关键代码实现

4.1 环境准备与显存预算

环境不复杂:PyTorch 2.x、timm 0.9 以上、CUDA 对应版本。timm 里直接有现成的 ConvNeXt 实现和预训练权重,别自己抄结构,容易漏 Layer Scale 这种细节。

显存预算我实测的量级是这样:convnext_tiny、输入 224、batch 32、混合精度打开,大约占 6 到 7 GB;输入提到 288、batch 32,涨到 10 GB 左右;如果只开 batch 16,224 输入下 4 GB 足够。一张 12 GB 的卡跑 288 输入加 batch 32 是比较舒服的配置。batch 再小也可以训,LayerNorm 不依赖 batch 统计量,这是选 ConvNeXt 的一个隐性优势。

pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install timm==0.9.16 pip install albumentations opencv-python-headless scikit-learn

4.2 模型构建:一行创建,重点在参数

import timm import torch.nn as nn NUM_CLASSES = 12 # 换成你实际的类别数 model = timm.create_model( "convnext_tiny", pretrained=True, num_classes=NUM_CLASSES, drop_path_rate=0.15, # 随机深度,千张数据必开 drop_rate=0.1, # 分类头前的 dropout ) # 分类头单独拆出来,方便给它更大的学习率 head = model.get_classifier() in_features = head.in_features model.reset_classifier(0) # 先去掉原分类头 model.head = nn.Sequential( nn.LayerNorm(in_features), nn.Dropout(0.3), nn.Linear(in_features, NUM_CLASSES), )

三个参数的选择理由:drop_path_rate设 0.15,太低了没正则效果,太高了收敛慢,0.1 到 0.2 之间都可以试;分类头加一层 LayerNorm 是因为reset_classifier之后原始归一化被去掉,加回来训练更稳;分类头的 dropout 给到 0.3,比主干里的 0.1 大,因为这里是最后一道防线。

如果显存吃紧或者数据更少,把模型换成convnext_femto(约 5M 参数)或convnext_pico,配合冻结前两个 stage,小数据上经常比大模型表现更好。

4.3 优化器、学习率与分层衰减

优化器用 AdamW,主干和分类头分开配置:

def build_optimizer(model, base_lr=5e-4, head_lr=2e-3, weight_decay=0.05): no_decay = ("bias", "norm", "bn", "ln") decay_params, no_decay_params = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue (no_decay_params if any(k in name.lower() for k in no_decay) else decay_params).append(p) return torch.optim.AdamW( [ {"params": decay_params, "lr": base_lr, "weight_decay": weight_decay}, {"params": no_decay_params, "lr": base_lr, "weight_decay": 0.0}, ], betas=(0.9, 0.999), )

学习率的取值逻辑:预训练模型微调,主干学习率一般取预训练阶段的 1/10 到 1/20,ConvNeXt 原论文用 4e-3 从头训 300 epoch,我们微调 50 个 epoch,主干给 5e-4 比较合适;分类头是随机初始化的,需要更大步长,给 2e-3 到 4 倍主干的量级。权重衰减要排除所有归一化层和偏置,这个细节漏了会导致 LN 的参数被往零拉。

层衰减的实现方式是给不同 stage 分配不同学习率:

def layer_decay_param_groups(model, base_lr=5e-4, decay=0.8): groups = [] num_layers = 4 # stem + 4 个 stage,按从深到浅排列 for idx, (name, module) in enumerate(model.named_children()): lr = base_lr * (decay ** (num_layers - idx)) params = [p for p in module.parameters() if p.requires_grad] if params: groups.append({"params": params, "lr": lr}) return groups

层衰减系数 0.75 到 0.85 之间,我一般取 0.8。这套写法比按参数名正则匹配更直观,model.named_children()的顺序就是从 stem 到 head。

调度器用带 warmup 的余弦退火,warmup 占总 epoch 的 10% 左右,50 epoch 对应 5 个 warmup epoch,注意 warmup 要从很小的值起步(比如 1e-6),不是从学习率的一半起。

4.4 训练循环:混合精度、梯度裁剪和 EMA

import torch from torch.cuda.amp import autocast, GradScaler from copy import deepcopy scaler = GradScaler() ema_model = deepcopy(model).eval() for p in ema_model.parameters(): p.requires_grad_(False) EMD_DECAY = 0.9998 def update_ema(ema_model, model, decay): with torch.no_grad(): for ep, mp in zip(ema_model.parameters(), model.parameters()): mp.mul_(decay).add_(ep.detach(), alpha=1 - decay) for eb, mb in zip(ema_model.buffers(), model.buffers()): mb.copy_(eb) for epoch in range(EPOCHS): model.train() for images, targets in train_loader: images = images.cuda(non_blocking=True) targets = targets.cuda(non_blocking=True) with autocast(dtype=torch.float16): outputs = model(images) loss = criterion(outputs, targets) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True) update_ema(ema_model, model, EMD_DECAY) scheduler.step() # 每轮用 EMA 权重算一次验证集 macro F1 val_f1 = evaluate(ema_model, val_loader)

几个关键数字:梯度裁剪阈值取 5.0,ConvNeXt 配合大学习率时偶发梯度尖峰,裁到 1.0 会拖慢收敛,5.0 足够兜底。EMA 衰减 0.9998对应大约 5000 步的有效窗口,50 epoch × 每 epoch 30 步已经够用;如果训练步数很少,把 decay 调到 0.999,否则 EMA 权重跟不动。验证时用 EMA 模型而不是原始模型,这一步在小数据上往往能直接带来 1 到 2 个点的提升。

损失函数用标准的交叉熵加标签平滑:

criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

类别不均衡的处理,我的优先级是:先试分层采样(WeightedRandomSampler,采样权重取类别频次的倒数平方根),这比改损失函数更直接;效果不够再上类别权重(CrossEntropyLoss(weight=...),权重取倒数但做归一化,别直接取 1/n,会出现极端值把梯度带偏);Focal Loss 我放在最后考虑,它在这类任务里经常把简单样本压得太狠,收敛变慢。

4.5 推理与 TTA:最后两三个点从这里捞

推理阶段先确认一件事:要不要做多尺度。训练用 288,推理时试 256 / 288 / 320 三个尺度,把 softmax 输出平均。我这边 320 单尺度和三尺度平均差不太多,但三尺度平均对少数类的召回更友好。

TTA 的组合我通常用:原图 + 水平翻转 + 两个角裁剪,一共 4 到 6 个视角,概率平均。注意别加垂直翻转 TTA,训练时虽然开了垂直翻转增强,但测试时垂直翻转在部分类别上会引入偏差(比如某些叶片的上表面形状和下表面不同),实测反而掉点。

@torch.no_grad() def predict_tta(model, images): probs = torch.softmax(model(images), dim=1) probs += torch.softmax(model(torch.flip(images, dims=[3])), dim=1) probs += torch.softmax(model(center_crop(images, 0.88)), dim=1) return probs / 3

最后是多折集成。5 折分组交叉验证训出来的 5 个模型(每个都是 EMA 权重)做软投票,macro F1 一般比单折高 2 到 3 个点,代价是推理成本翻 5 倍。如果只做线上服务且对延迟敏感,选单折里验证最好的那个,或者用权重融合(把 5 个模型的参数平均),后者推理成本不变,效果比单模型略好。

5. 常见问题与排查实录

5.1 训练准确率 99%,验证卡在 60% 上不去

这是这个任务里出现频率最高的问题,本质是过拟合,但成因有好几种,得逐项排除。第一步查数据泄漏,按我上面说的感知哈希去重和批次分组重做一次划分,这一步能解决超过一半的"诡异过拟合"。第二步看增强强度,把 drop_path 从 0.1 提到 0.2,RandAugment 的 magnitude 提到 9,Mixup 开起来,如果验证集立刻有反应,说明之前正则不够。第三步缩模型,从 tiny 换到 femto,或者冻结 stage1 和 stage2,只训后两个 stage 加分类头,小数据上这招很有效。

还有一个容易被忽略的原因:学习率太大把预训练特征冲掉了。判断方法是看训练 loss 前 2 个 epoch 是不是掉得太快,如果第 1 个 epoch 就从 2.5 掉到 0.3,基本可以确定是学习率过高,把主干学习率除以 5 再试。我自己在这个坑里待过挺久,一直以为是过拟合,其实是骨干被冲坏之后又重新学了一遍,而样本量根本支撑不起重新学。

5.2 混淆矩阵里特定两类互相吞,怎么破

先别急着加模型,先看图。把混淆最严重的那两类错分样本各抽 10 张拼起来看,通常会看到两种情况。一种是真难分:子叶期近缘种,肉眼也只能靠叶柄颜色或者绒毛判断,这种靠提升分辨率解决,从 224 提到 320,让细节可见,同时把 RandomResizedCrop 的下限从 0.6 抬到 0.75,保证训练时叶子始终占据足够像素。另一种是背景混淆:两类样本恰好来自不同采集批次,一个是黑土一个是砂土,模型学的是背景。这种情况要么在增强里加更强的颜色抖动和随机擦除,要么干脆在预处理里做前景分割把背景抠掉(成本高,一般不作为首选)。

我还会做一件事:针对混淆类的加权采样。把这两类的采样权重单独放大 1.5 倍,让模型在每个 batch 里更频繁地见到它们。这比全局加权更精准,副作用更小。

5.3 显存不够、batch 太小怎么调

batch 从 32 降到 16 时,学习率不要机械地减半,我的经验是取 0.7 倍左右比较稳,因为 AdamW 的自适应特性和 batch 大小不是线性关系。另外三个省显存的开关按收益排序:开混合精度(省 40% 左右)、开梯度累积(累积 2 步等效 batch 32,不影响显存峰值但会略微影响 BatchNorm 类的统计,ConvNeXt 用 LN 所以无所谓)、降低输入分辨率(288 降到 224 省 40%,但对细粒度任务有损,最后才考虑)。

还有一个坑:timm的预训练模型加载时会占额外显存做权重转换,如果你在模型创建前就已经把数据搬到卡上了,可能 OOM。养成先建模型、再建 dataloader、最后搬数据的顺序。

5.4 问题速查表

现象最可能原因处理动作
训练 loss 不降学习率过小 / 标签错位 / 冻结了全部参数检查 requires_grad,lr 乘 3 重跑
loss 变 NaN混合精度溢出 / lr 过大开 GradScaler、降 lr、梯度裁剪 5.0
验证指标震荡大batch 太小 / 学习率调度太陡加 EMA、延长 warmup
某些类召回极低类别不均衡分层采样 + 针对性加权
换批次数据就崩背景泄漏 / 划分不合理分组划分、加强颜色增强
推理结果和验证差很多训练/推理预处理不一致核对 resize 插值、归一化、裁剪方式
EMA 权重反而不如原模型decay 太大、步数太少decay 降到 0.999

最后一行那个问题我踩过:只有 20 个 epoch、每 epoch 25 步,总共 500 步,用 0.9998 的 decay,EMA 权重基本等于初始权重,验证指标当然难看。EMA 的有效窗口要跟总步数匹配,公式上大致是1/(1-decay)步,取总步数的 1/10 到 1/5 比较合适。

6. 我的迭代记录与几个不太上台面的经验

6.1 五次实验的相对变化

把我这份数据上的迭代过程列出来,数值只代表相对趋势,你那边绝对值肯定不同,但排序逻辑应该能参考:

轮次配置macro F1 相对变化
1ResNet50 基线,224,基础增强基准
2ConvNeXt-T,224,加 drop_path 0.1+3 个点左右
3分辨率提到 288,RandomResizedCrop 下限 0.75再 +2 个点左右
4加 EMA + 层衰减 0.8再 +1.5 个点,且曲线明显更稳
55 折分组集成 + 多尺度 TTA再 +2 个点,涨幅趋缓

到第 5 轮之后基本就压不动了,再往上要么换更大的预训练权重(ImageNet-22k 预训练版本比我用的 1k 版本好一点),要么回去做数据层面的工作,比如补拍少数类、人工复核错标样本。模型层面的边际收益在这个数据规模下已经很低了。

6.2 三个我确实踩过的坑

第一个坑是忘了改crop_pct。timm 的预训练模型自带一个默认的测试时裁剪比例,如果你手动把输入改成 288 但没同步调整裁剪策略,训练和推理的视野不一致,验证指标会莫名其妙掉一两个点。判断方法很简单:拿训练集的一张图分别走训练预处理和验证预处理,把两张图存下来肉眼看,视野差多少一目了然。

第二个坑是num_classes改了但没重置分类头。直接用create_model(..., num_classes=12)是对的,但如果先创建再手动替换 head,容易把原来的 1000 类头留在计算图里,loss 看着正常但梯度是错的。养成打印一次model.head结构的习惯。

第三个坑是验证集上的"假提升"。我有一轮调参后验证集涨了 3 个点,兴冲冲换到另一批新拍的图上测,反而掉了 5 个点。原因是那一轮我把颜色抖动的 hue 调小了,模型更依赖颜色特征,而新批次的光照色温完全不同。这件事之后我固定了一个习惯:留一份从来不参与任何调参的"最终测试集",只在最后看一次,平时所有决策都基于交叉验证的均值,不看单折最高分。

6.3 这套流程还能往哪延伸

如果这个分类任务后面要往实际场景走,我建议按这个顺序往下做:先做开集识别,田里的杂草种类远超这十几个类,遇到没见过的类要能给出低置信度而不是硬分到某个已知类,加一个基于能量分数或最大 softmax 的阈值就能做个初版;再做半监督,田间能拍的图远多于能标注的图,用伪标签加一致性正则在未标注数据上做自训练,通常能再拿几个点;最后才是检测,把分类模型接在一个轻量检测器后面,先定位再分类,这一步的工作量和数据标注成本都上一个台阶,别一上来就做。

我个人在实际操作中的体会是:这类农业视觉任务,模型结构带来的差异远小于数据质量带来的差异。同一份数据,把错标清干净、把泄漏堵上、把长尾采样调平,收益比换三个骨干网络加起来都大。ConvNeXt 好用,但它不是银弹,它只是让你在数据已经做扎实之后,能多榨出那两三个点。

返回列表