☰
从0.68到0.83:食物图像分类Boss Baseline实战复盘
2026/10/27 15:29:11 网站建设 项目流程

做这个作业的第三次机器学习任务(下文统一叫 HW3)时,很多人卡在一个很别扭的位置:simple baseline 轻松过,medium 也不难,strong 稍微调一调也能摸到,然后就在 0.7 附近原地踏步,怎么换模型都上不去。Boss baseline 那条线看着只差几个点,实际上差的是整套流程的完整度——数据管道、增强组合、迁移学习策略、训练配方、半监督利用、模型集成,缺哪一环都会卡住。这篇就把我从 0.68 一路推到 0.83 的整个过程拆开讲,包括哪些操作是真提分、哪些纯粹是自我安慰、以及几个我排查了大半天才发现原因的坑。

先交代任务背景:11 类食物图像分类,训练集约一万张、验证集三千多张、测试集三千多张且不给标签,评分按分类准确率算,Boss 线大致在 0.8 上下。这个任务的特点是类别之间高度相似(米饭和面条、汤和炖菜、油炸和肉类),类内差异又极大(同一类食物的摆盘、光线、角度千差万别),所以单纯堆模型容量收益有限,真正的分水岭在于数据侧的信息利用率。下面按我实际的推进顺序来讲,不按教科书的顺序。

1. 这条 Boss 线卡人的真正原因

1.1 从 0.5 到 0.8 的四段楼梯差在哪

先说结论:这个任务的四条基线对应的不是"四个模型",而是四种工程成熟度。我把它们拆成了下面这张表,这张表在我后面调参时反复用到,因为它能告诉你当前处于哪一档、下一步该动什么。

档位大致准确率典型做法瓶颈所在
Simple0.50 左右三层卷积堆叠,无增强或只做 Resize模型容量不足,欠拟合
Medium0.60 左右加深网络 + 基础数据增强过拟合开始出现
Strong0.70 左右预训练骨干 + 常规增强 + 学习率调度数据利用不充分
Boss0.80 以上强增强 + 微调策略 + 半监督 + 集成流程细节与验证可信度

看明白这张表,就能理解为什么很多人卡在 0.7:他们已经换上了预训练模型,也加了 RandomCrop 和 Flip,但接下来做的所有事情都在"优化模型",而真正还剩下十个点的空间在"优化数据的使用方式"和"降低预测方差"。

我在 0.72 停留了大概两天,期间换了四个骨干网络,准确率波动不超过 1.5 个点。后来把重心从模型换到增强组合和伪标签上,一周内推到了 0.81。这个经历让我确信:在这个数据规模下,模型不是瓶颈,信息利用率才是。

1.2 一万张图能撑起多大的模型

有人会想,既然预训练模型这么强,直接上大模型不就行了。实测下来不行。训练集不到一万张、11 类平均每类不到九百张,这个规模下参数量超过五千万的骨干基本都会严重过拟合,验证集准确率反而低于中等规模的模型。

我做过一组对照,固定其他所有条件只换骨干,结果大致是这样:

骨干参数量级验证集准确率单轮训练耗时
自建 4 层 CNN极小0.62快
ResNet18(预训练)千万级0.74快
ResNet50(预训练)两千多万0.77中
EfficientNet-B0五百万级0.78中
ConvNeXt-Tiny两千八百万级0.78中偏慢
ViT-B/16八千多万级0.75慢

注意最后一行,ViT 在这个数据量下反而不如 CNN 系。原因很直接:ViT 缺少卷积的归纳偏置,需要更多数据才能发挥,一万张图喂不饱它,除非用很强的增强和更长的训练轮次。所以别盲目追新架构,先看数据量匹不匹配。

我最后的主力骨干是 EfficientNet-B0 和 ConvNeXt-Tiny 两个,双模型融合,单模都在 0.78 附近,融合后能到 0.81。这个组合不是最优解,但在有限的训练时长里性价比最高。

1.3 验证集本身可能不可信

这一条是我摔得最惨的地方。我一度验证集 0.86,提交上去只有 0.74,差了十二个点。查了很久才定位到两个原因:

第一个原因是验证集参与了模型选择。我用验证集做早停、做超参搜索、做融合权重搜索,反复在同一份验证集上做决策,本质上就是在这份数据上过拟合了。三千多张验证集,反复看几十次,涨几个点是必然的。

第二个原因是数据划分方式不对。最初我是随机划分的,但这份数据里存在同一道菜的多张照片(不同角度、不同光线),随机划分会让同源图片同时出现在训练集和验证集,验证集分数虚高。改成按"菜品种类+来源"分组划分之后,验证集掉到了 0.79,但提交分数反而涨了。

教训:验证集分数和线上分数差距超过五个点,先别怀疑模型,去查划分方式和信息泄露。

我后来的做法是:从训练集里切出一份完全不参与任何决策的 holdout(大概一千张),只在最后确认一次;日常调参用交叉验证的折内验证集。这样虽然麻烦,但决策依据干净了。

2. 数据管道:先别动模型,把喂进去的东西搞对

2.1 这十一类食物图像的分布问题

在动手改网络之前,我把训练集按类别统计了一遍,发现两个明显问题。

一是类别不均衡。最多的类有一千二百多张,最少的类只有六百多张,差了将近一倍。这个差距不算极端,但足以让模型偏向多数类。我的处理方式是不做过采样,改用加权采样 + 分层划分,保证每折的类别比例一致,同时在损失函数里给少数类略高的权重(大概按类别频率的平方根倒数来设,比直接按倒数温和)。

二是图像尺寸和长宽比极不统一。有正方形的小图,也有横构图的大图。如果统一 Resize 到 224×224,横图会被压扁,食物形状严重变形。我改成先短边缩放再中心裁剪,训练时用随机缩放裁剪。这个改动带来的提升很直接,大概 2 个点——因为食物的形状特征对分类很重要,压扁等于把信息毁了。

另外还有一个容易被忽略的点:部分图片带有明显的边框或水印区域,这些区域对分类是纯噪声。我没做复杂的检测去除,只是用了 RandomResizedCrop 的较小缩放比例(scale 下限设到 0.5),让模型在训练时经常看到"去掉边缘"的视图,间接降低了对边缘区域的依赖。这个技巧成本极低,效果比想象中好。

2.2 训练增强的组合逻辑与参数表

数据增强不是越多越好,这是我踩过的第二个大坑。我曾经一口气叠了七八种增强,结果训练损失降不下去,验证集反而掉了。原因很简单:增强强度超过了任务本身的不变性需求。食物分类对颜色是敏感的(炒饭和咖喱饭颜色是重要线索),我加了很强的颜色抖动,等于在破坏有效特征。

下面是我最终收敛下来的一套组合,附上参数和启用理由:

增强手段参数设置作用启用判断
RandomResizedCropscale=(0.5, 1.0), ratio=(0.8, 1.25)尺度与位置不变性必开,性价比最高
RandomHorizontalFlipp=0.5左右不变性必开,食物无方向性
RandomRotationdegrees=15小角度旋转鲁棒开,但别超过 20 度
ColorJitterbrightness=0.2, contrast=0.2, saturation=0.15, hue=0.05光照鲁棒弱开,hue 一定要小
RandAugmentN=2, M=7组合增强先试,掉点就关
RandomErasingp=0.25, scale=(0.02, 0.15)遮挡鲁棒性中后期开
MixUpalpha=0.2平滑决策边界长训练时开
CutMixalpha=1.0局部特征学习与 MixUp 二选一

关于 ColorJitter 的 hue,我特意压到 0.05。曾经设成 0.3,验证集直接掉三个点,因为色相偏移会让米饭变成"非米饭"的颜色,模型学到的颜色线索被彻底打乱。颜色相关的增强,参数一定要保守。

RandAugment 是个双刃剑。它在数据量大、训练轮次多的时候能提分,但在这种小数据集上容易过增强。我的做法是:前十个 epoch 不做 RandAugment,等模型先学到基本特征,再逐步加进来。这个"分阶段增强"的思路后面还会提到,是我认为最值得抄的一个技巧。

2.3 验证集和测试集的预处理必须严格一致

这条听起来是常识,但我确实在这里浪费了半天。

测试时我只做了 Resize + CenterCrop + Normalize,但训练时用了 RandomResizedCrop,两者的裁剪比例分布不一样,导致测试图的"视野范围"和训练图不一致,模型看到的物体大小对不上。表现就是验证集 0.80,提交只有 0.75。

解决办法是做 TTA 或者用更接近训练的测试预处理。我最终用了多尺度中心裁剪(把短边缩放到 224、256、288 三个尺度,各裁一个 224 中心块),三个视图的 softmax 概率平均。这个 TTA 单独就贡献了大概 1.5 个点,而且完全不需要重新训练,是最划算的操作之一。

归一化参数也要注意。用预训练模型时,必须用它训练时对应的均值方差(ImageNet 的均值方差),不要用自己数据集统计出来的。这一点我一开始搞错了,模型收敛明显变慢。

3. 迁移学习的取舍:从冻结到微调的时机

3.1 为什么不能一上来就全量微调

拿到预训练骨干之后,最诱人的做法是把分类头换掉然后整体微调。我试过,结果前几个 epoch 验证集准确率剧烈震荡,最后停在一个比冻结训练还低的位置。

原因在于预训练权重的特征分布和随机初始化的分类头不匹配。分类头是随机的高梯度,反向传播时会把这份剧烈更新传导到整个骨干网络,把原本良好的特征打乱。这在学习率偏大的时候尤其明显。

我采用的方案是两段式:

第一阶段,冻结骨干,只训练分类头,学习率 1e-3,跑 5 个 epoch。这一阶段的目的不是追求高准确率,而是让分类头先收敛到一个合理的初始状态,避免后续微调时梯度爆炸。跑完之后验证集大概能到 0.6 出头。

第二阶段,解冻骨干,整体微调,骨干用较小的学习率(3e-4 或更低),分类头用 3 倍的学习率。这里用参数组分别设置学习率是关键,timm 里可以用param_groups直接配。

backbone_params = [p for n, p in model.named_parameters() if "head" not in n] head_params = [p for n, p in model.named_parameters() if "head" in n] optimizer = torch.optim.AdamW([ {"params": backbone_params, "lr": 1e-4}, {"params": head_params, "lr": 3e-4}, ], weight_decay=5e-2)

这个分层学习率带来的差异很明显,同一份数据,统一学习率 3e-4 最终到 0.76,分层之后到 0.79。差三个点的原因就是骨干被"过度更新"了。

3.2 BatchNorm 层的处理细节

还有一个细节值得单独说:微调时BatchNorm 层的 running stats 要不要更新。

在小 batch size(我用的是 32,因为显存有限)的情况下,如果让 BN 层持续更新统计量,批内统计噪声会很大,反而伤害性能。我试过三种设置:

  • BN 全部冻结(eval 模式):验证集 0.785
  • BN 全部更新(train 模式):验证集 0.771
  • 前 10 个 epoch 冻结,之后放开:验证集 0.792

第三种最好,但提升幅度不大。如果时间紧,直接冻结 BN 层是个稳妥的选择,尤其在 batch size 小于 32 的时候。这一条在文档里几乎不会写,但实测有效。

顺带提一句,如果显存允许,把 batch size 提到 64 配合 BN 更新,效果会比冻结 BN 更好。我后来换了一张卡,bs 上到 64,BN 放开之后验证集到了 0.80。batch size 和 BN 策略要放在一起考虑,不能分开调。

3.3 骨干选型的实操判断标准

前面表格里给了一组对照,这里说判断逻辑。

选骨干看三个东西:一是参数量和数据集规模的匹配度,一万张图配两千万参数量级比较合适;二是单轮训练耗时,因为你要跑交叉验证和多次实验,单轮太慢会严重拖累迭代速度;三是预训练权重的来源域,如果预训练数据里本身就有大量食物图像,迁移效果会明显更好(这一点没法直接验证,但可以用小规模试跑判断)。

我的实操判断方式是:先跑三个 epoch 看验证集曲线斜率。如果三个 epoch 内验证集就冲到 0.7 以上,说明迁移效果好,值得投入;如果三个 epoch 还在 0.5 附近磨,基本可以放弃这个骨干。这个方法能省下大量试错时间。

4. 训练配方:把 0.72 推到 0.82 的几个开关

4.1 学习率调度与 warmup 的配合

学习率调度我踩过的坑是:直接用余弦退火从最大学习率开始,前几个 epoch 损失爆掉。后来加了线性 warmup,前 3 个 epoch 从 1e-6 线性升到峰值,训练就稳了。

具体配置:峰值学习率 3e-4,warmup 3 个 epoch,然后余弦退火到 1e-6,总轮次 40。这个配置在多次实验中表现稳定。

为什么不选 StepLR 或者 ReduceLROnPlateau?因为余弦退火的下降曲线更平滑,配合较长的训练轮次能让模型在后期"精修",而 StepLR 在阶梯下降时会有明显的性能抖动。ReduceLROnPlateau 需要监控验证集,容易引入验证集过拟合的问题。

还有一个细节:总轮次不要设太多。我试过跑 100 个 epoch,40 轮之后验证集就开始缓慢下滑,虽然训练损失还在降。这个任务的收敛点大概在 30 到 45 轮之间,超过就是纯粹的过拟合。

4.2 Label Smoothing 的取值与副作用

标签平滑是我认为性价比第二高的操作(第一是 TTA)。取 0.1 的时候,验证集稳定提升 0.8 到 1.2 个点,而且训练曲线更平滑。

它的原理不复杂:把 one-hot 标签从 [1, 0, 0, ...] 变成 [0.9, 0.01, 0.01, ...],防止模型对单一类别过度自信。食物分类里存在大量"边界样本"(比如某道菜既可以算主食也可以算肉类),硬标签会逼模型做出非此即彼的判断,标签平滑正好缓解这个问题。

但取值不能太大。我试过 0.2,验证集反而掉点,因为过强的平滑会让模型学不到足够的区分度。0.1 是比较稳妥的选择,0.05 也可以,再大就要谨慎。

配合标签平滑,交叉熵损失函数直接用 PyTorch 的CrossEntropyLoss(label_smoothing=0.1)就行,不需要自己写。

4.3 MixUp 和 CutMix 的启用时机

这两种混合增强都能提分,但都不适合从头开到尾。

我的实测:全程开 MixUp,最终验证集比不开低 0.5 个点。原因是 MixUp 生成的混合图像在训练早期会让模型很难学到清晰的类别边界,收敛变慢。而在训练后期开启,它能起到正则化作用,压制过拟合。

最终方案是:前 15 个 epoch 不开,15 到 35 个 epoch 开 CutMix(alpha=1.0),最后 5 个 epoch 关掉,用干净数据收尾。这个"三阶段"策略比全程开启高了大概 1 个点。

三阶段增强的完整方案是这样:

  • 阶段一(1-10 轮):只做 ResizeCrop + Flip,让模型快速学到基础特征
  • 阶段二(11-35 轮):加入 Rotation、ColorJitter、RandomErasing、CutMix
  • 阶段三(36-40 轮):关闭 CutMix 和 RandomErasing,只保留基础增强

阶段三很关键,用干净数据收尾能让模型"校准"回到真实分布,避免最终权重被混合样本带偏。这个技巧在不少比赛的方案里都能看到,但自己不做实验很难想到。

4.4 权重的指数滑动平均

指数滑动平均(EMA)是我在后期才加进来的,加完之后验证集涨了 0.6 个点,而且几乎不增加训练成本。

做法很简单:维护一份模型权重的滑动副本,每次参数更新后按衰减系数混合:

@torch.no_grad() def update_ema(ema_model, model, decay=0.999): for ema_p, p in zip(ema_model.parameters(), model.parameters()): ema_p.mul_(decay).add_(p.detach(), alpha=1 - decay)

预测时用 EMA 模型的权重,而不是训练中的实时权重。原因是训练后期的权重会在最优点附近震荡,EMA 相当于对最近若干步的权重做了平均,方差更小,泛化更好。

衰减系数 0.999 是常用值,训练轮次少的时候可以调到 0.995。这个参数不用太纠结,0.995 到 0.9995 之间差异不大。

需要提醒的是,EMA 模型里如果有 BN 层,统计量也要同步复制,不能只复制可学习参数,否则预测时用的还是旧的 running stats,效果会打折。这个细节我一开始漏了,白白浪费了一次实验。

5. 半监督与集成:Boss 线最后一段楼梯

5.1 伪标签的阈值怎么定

测试集三千多张图不给标签,这是这个任务里最容易被浪费的资源。我第一版方案完全没用它,卡在 0.78。后来加了伪标签,直接到 0.81。

伪标签的基本逻辑是:用训练好的模型对测试集预测,把置信度高的样本连同预测标签一起加入训练集,重新训练。

关键在于置信度阈值。我试过几组:

阈值加入样本数伪标签准确率估计重训后验证集
0.99约 900很高0.786
0.95约 1600高0.801
0.90约 2300中高0.808
0.80约 2900中0.793
0.70约 3200低0.771

阈值 0.9 附近是甜点。太严(0.99)加入的样本太少,增益有限;太松(0.70)会把错误标签灌进去,模型学到噪声,反而掉点。

还有一个细节比阈值更重要:伪标签样本的损失权重要降低。我给伪标签样本的损失乘 0.5,让真实数据的梯度占主导。不加这个权重的话,即使阈值设得合理,也会掉点,因为伪标签样本数量占比不小,容易把模型带向确认偏差。

另外,伪标签最好不要只用一次。我的做法是:第一轮用阈值 0.95 加一批,重训;第二轮用新模型重新预测,阈值放到 0.90 再加一批,再重训。两轮之后验证集从 0.786 到 0.808。第三轮基本没增益了,而且开始有过拟合迹象。

注意:每轮伪标签都要在独立的验证集上确认增益,如果某一轮验证集掉点,说明伪标签质量下降,应立即停止。

5.2 交叉验证与模型融合的具体做法

单模型到 0.808 之后,剩下的空间靠集成。

我用的是 5 折交叉验证,每折训练一个 EfficientNet-B0,五个模型对测试集预测的概率取平均。这一步行之有效但成本高,训练时间翻五倍。如果时间有限,折中方案是 3 折,或者只训两个不同骨干的单模型做融合。

融合的方式我比较过三种:

  • 概率平均:最简单,五个模型 softmax 后直接平均。验证集 0.815。
  • 加权平均:按各折验证集准确率加权。验证集 0.818,提升很小,容易过拟合验证集。
  • logit 平均:对 logits 取平均再 softmax。验证集 0.812,略低于概率平均。

最终用了概率平均,因为简单且不易过拟合。加权平均虽然纸面数字高一点,但权重是在验证集上搜出来的,可信度打折。

融合的时候要注意各折模型的预处理必须完全一致,包括归一化参数、输入尺寸、TTA 策略。我有一折忘了同步 TTA 尺度,融合结果反而比单模差,排查了半天。

5.3 完整踩坑链路:验证集 0.86 提交只有 0.74

这个坑值得完整讲一遍,因为它的排查过程有普适性。

现象:本地验证集准确率 0.86,提交上去 0.74,差十二个点。

第一步排查:预处理不一致。检查了训练和测试的 Resize、Crop、Normalize,发现测试少做了一次归一化。修了之后,提交涨到 0.77,但还差九个点。

第二步排查:验证集泄露。检查数据划分,发现是随机划分,同源图片跨集。改成按来源分组划分后,验证集掉到 0.79,提交 0.79,差距消失了。

到这里问题基本解决了,但我还额外查了两件事。

第三步排查:标签映射。确认了类别索引和提交格式的映射没搞反。这个错误很隐蔽,因为本地算准确率时用的是同一份映射,即使反了本地也看不出来。我是通过"打印每一类的预测分布,对比训练集类别分布"来确认的——如果映射反了,预测分布会和真实分布严重不匹配。

第四步排查:模型状态。检查预测时是否调用了model.eval()。这个我确实漏过一次,dropout 和 BN 都在训练模式,预测结果稀烂。用torch.no_grad()+model.eval()之后正常。

这条排查链路我后来做成了固定流程,每次结果异常就按顺序过一遍:预处理一致性 → 数据划分泄露 → 标签映射 → 模型状态 → 输入范围。五个检查点,能覆盖九成以上的"本地好线上差"问题。

6. 如果只能保留三个操作

写到这里,把整条路复盘一遍,如果重来一次只有有限时间,我会保留三件事。

第一件是修数据划分。这是所有提升的前提,划分错了后面全是幻觉。改成按来源分组划分、留一份绝对干净的 holdout,虽然当下验证集分数会变难看,但方向是对的。

第二件是三阶段增强 + 分层学习率。这套组合拳把模型从 0.72 推到 0.79,成本只是多写几十行调度代码,没有额外算力开销。

第三件是TTA + 伪标签。TTA 是零训练成本的纯收益,伪标签把无标签数据利用起来。这两步做完,0.8 基本稳了。

至于融合,它更像是锦上添花。如果没有前两件打底,融合五个 0.75 的模型也只能到 0.78 左右,达不到 Boss 线。

最后分享一个我觉得很有用的小习惯:每次实验都记录"改了什么、验证集变化多少、训练耗时多少"三列,攒够二十次之后回头看,你会发现真正有效的操作就那么五六个,剩下全是在噪声里晃。这个记录习惯比任何调参技巧都省钱。

还有一个容易被忽视的点——随机种子。我所有最终结果都是三个种子跑完取平均,单次结果波动能到 0.8 个点。如果你的改进只有 0.5 个点,单次实验是看不出来的,很容易误判成"这个技巧没用"。想验证一个小改动,至少要跑两到三次,否则你优化的可能只是随机性。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询