ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch实战:CIFAR100图像分类从45%到72%的调优全记录

PyTorch实战:CIFAR100图像分类从45%到72%的调优全记录

1. 项目缘起与整体设计思路

CIFAR100 这个数据集在图像分类圈子里算是老面孔了,但真正把它做透的人并不多。很多人拿 CIFAR10 跑个 90% 出头的准确率就收工了,一换到 CIFAR100 立马掉到 40% 多,然后就开始怀疑人生。我这次的项目目标很明确:用卷积神经网络把 CIFAR100 的类别分类做到一个能拿得出手的水平,同时把整个流程拆解清楚,让后来的人能直接抄作业。

CIFAR100 和 CIFAR10 最大的区别在于类别数从 10 变成了 100,每个类只有 600 张图,其中 500 张训练、100 张测试。这意味着每个类别的样本量非常有限,模型很容易过拟合。而且 100 个类里面有不少视觉上非常接近的类别,比如各种小型哺乳动物、不同种类的花、各种交通工具,光靠颜色和纹理根本分不开。所以这个项目的核心难点不是搭一个 CNN 出来,而是怎么在有限数据下让网络学到足够细粒度的特征。

我选择用 PyTorch 来实现,原因很简单:动态图调试方便,社区资源多,而且 torchvision 里直接就有 CIFAR100 的加载接口,省去了自己写数据读取的麻烦。网络结构方面,我没有一上来就堆 ResNet 或者 EfficientNet,而是先从一个自定义的 VGG 风格网络开始,逐步加改进,这样能清楚地看到每个改动带来的收益。整个项目的设计思路是:先跑通基线,再通过数据增强、正则化、学习率调度、网络加深等手段一步步提升,最后对比不同方案的优劣。

适合谁来参考呢?如果你已经跑过 MNIST 或者 CIFAR10,想挑战更有难度的数据集,这个项目正好合适。如果你是对 CNN 有一定了解但不知道怎么做调优的开发者,这里面的每一步改进都有详细的解释和参数说明。哪怕你是刚入门的新手,只要跟着步骤走,也能在自己的机器上复现出不错的结果。

2. 数据准备与预处理的关键细节

2.1 CIFAR100 数据集的结构与加载方式

CIFAR100 的原始数据是 32x32 的彩色图像,分为 100 个细类,同时这 100 个细类又归为 20 个粗类。比如“苹果”“橘子”“葡萄”属于“水果和蔬菜”这个粗类。这个层级结构其实很有用,后面我会讲到怎么利用粗类标签做辅助训练。数据集官方提供了 Python 版本的打包文件,用 torchvision.datasets.CIFAR100 可以直接下载和加载,参数里有个 fine_label 的选项,设为 True 就是用 100 个细类标签,设为 False 就是用 20 个粗类标签。

加载的时候有几个坑要注意。第一,下载下来的数据默认放在 ./data 目录下,如果你在多台机器上跑,最好把数据路径统一配置,避免重复下载。第二,CIFAR100 的图像是 32x32 的,如果你打算用 ResNet 这类通常为 224x224 设计的网络,要么把输入尺寸改小,要么把图像上采样,但上采样并不会增加真实信息,反而增加计算量,所以我建议直接调整网络的第一层来适配 32x32 输入。第三,数据集的均值和标准差最好自己算一下,不要直接用 ImageNet 的统计值,因为 CIFAR 的图像分布和 ImageNet 差别不小。

我实测下来,CIFAR100 训练集的像素均值大约是 (0.5071, 0.4865, 0.4409),标准差大约是 (0.2673, 0.2564, 0.2762)。用这组值做归一化,比用 ImageNet 的均值标准差收敛更快,最终准确率也能高个 1 到 2 个百分点。

2.2 数据增强策略的选择与参数设置

数据增强是提升 CIFAR100 表现最有效的手段之一,因为每个类只有 500 张训练图,不增强根本不够看。我常用的增强组合包括随机裁剪、随机水平翻转、颜色抖动和随机旋转。随机裁剪我一般设 padding=4,裁剪到 32x32,这样图像会有轻微的平移,模拟拍摄时的位置变化。随机水平翻转概率设 0.5,对于大多数类别都适用,但要注意有些类别翻转后语义会变,比如“左转”和“右转”的交通标志,不过 CIFAR100 里这类情况很少,可以放心用。

颜色抖动我一般设 brightness=0.2、contrast=0.2、saturation=0.2、hue=0.1,这个强度不算大,但能显著提升模型对光照和色彩的鲁棒性。随机旋转我设的是 ±15 度,再大就会引入黑边,反而影响训练。另外我还试过 Cutout 和 Random Erasing,这两种方法在 CIFAR100 上效果不错,能强迫模型关注物体的整体结构而不是局部纹理。Cutout 我一般设 8x8 的遮挡块,Random Erasing 的概率设 0.25,面积比例设 0.02 到 0.2 之间。

注意:数据增强只在训练时启用,验证和测试时必须关闭,否则评估结果会偏低。另外,增强的强度不是越大越好,我试过把旋转角度加到 30 度,准确率反而掉了 3 个点,因为很多物体旋转后变得不自然。

2.3 训练集、验证集的划分方式

官方只给了训练集和测试集,没有验证集。我一般从 50000 张训练图里划出 5000 张作为验证集,用来监控过拟合和调参。划分的时候要保证每个类都有 50 张验证图,这样验证集的类别分布和训练集一致。具体做法是用 stratified split,sklearn 的 train_test_split 里有个 stratify 参数可以直接用。划分完之后的训练集是 45000 张,验证集 5000 张,测试集 10000 张。

有些同行喜欢用交叉验证,但在 CIFAR100 上做 5 折交叉验证计算量太大,除非你有多个 GPU,否则不推荐。我一般就是固定一个验证集,调参的时候看验证集准确率,最终报告结果的时候用测试集。这里有个经验:验证集准确率和测试集准确率通常差不太多,如果差超过 2 个百分点,说明验证集划分可能有问题,或者模型在验证集上过拟合了。

3. 网络结构设计与核心模块实现

3.1 基线网络的选择与搭建

我一开始用的基线是一个简单的 VGG 风格网络,结构是:两个卷积层加池化,再两个卷积层加池化,再两个卷积层加池化,最后接全连接层。每个卷积层都是 3x3 卷积核,padding=1,后面接 BatchNorm 和 ReLU。池化用 2x2 最大池化,步长为 2。通道数从 64 开始,每经过一次池化翻倍,最后到 256。全连接层有两个,第一个 512 维,第二个 100 维对应 100 个类别。

这个基线网络参数量大约 1.5M,在 CIFAR100 上训练 100 个 epoch 能到 45% 左右的准确率。听起来不高,但作为基线已经可以了。训练的时候用交叉熵损失,优化器用 SGD,动量 0.9,权重衰减 5e-4,初始学习率 0.1,在 50 和 75 epoch 的时候各降一次,降为原来的 0.1 倍。Batch size 设 128,这个大小在大多数显卡上都能跑,显存占用也不高。

提示:BatchNorm 层在训练和推理时的行为不一样,训练时用当前 batch 的均值和方差,推理时用滑动平均。所以如果你在训练过程中做验证,一定要先调用 model.eval(),否则验证结果会波动很大。

3.2 加深网络与残差连接的引入

基线网络跑通之后,我开始加深网络。直接堆卷积层会遇到梯度消失的问题,训练误差降不下去。这时候残差连接就派上用场了。我在每个卷积块里加了一个 shortcut,把输入直接加到输出上,如果通道数不一致就用 1x1 卷积调整。这样网络可以做到 20 层甚至 30 层而不出现退化。

具体来说,我设计了一个 BasicBlock,里面是两个 3x3 卷积,每个卷积后面接 BatchNorm 和 ReLU,最后把输入加进来再过一次 ReLU。整个网络由 4 个 stage 组成,每个 stage 包含若干个 BasicBlock,通道数分别是 64、128、256、512。第一个 stage 之前有一个 3x3 卷积把输入从 3 通道升到 64 通道。每个 stage 之后做一次下采样,用步长为 2 的卷积或者池化。最后接全局平均池化和全连接层输出 100 维。

这个残差网络参数量大约 5M,训练 200 个 epoch 能到 65% 左右的准确率。相比基线提升了 20 个百分点,效果非常明显。训练的时候学习率用余弦退火,初始 0.1,最终降到 1e-5,这样后期微调更精细。权重衰减加到 1e-3,防止过拟合。另外我用了标签平滑,把硬标签换成 0.1 的平滑标签,这个技巧在类别多的时候特别有用,能防止模型对某个类过度自信。

3.3 注意力机制与特征融合的尝试

残差网络跑通之后,我又尝试了加入注意力机制。具体用的是 SE 模块,也就是 Squeeze-and-Excitation。它在每个残差块的后面加了一个全局平均池化,然后两个全连接层学习每个通道的权重,最后把权重乘回原来的特征图。这个模块参数量很小,但能显著提升模型对重要通道的敏感度。

我试过在残差块里加 SE,也试过在 stage 之间加 SE,效果最好的是在每个 BasicBlock 的残差分支上加 SE。加了 SE 之后,准确率从 65% 提升到了 68% 左右。后来又试了 CBAM,它同时做通道注意力和空间注意力,效果比 SE 再好一点点,但参数量和计算量也更大。如果追求极致准确率,CBAM 值得一试;如果考虑推理速度,SE 更划算。

另外我还试了多尺度特征融合,把不同 stage 的输出拿出来做全局平均池化,然后拼接在一起送进全连接层。这样模型能同时利用浅层的纹理信息和深层的语义信息。实测下来,融合最后两个 stage 的特征效果最好,准确率能再提升 1 到 2 个百分点。融合所有 stage 反而会引入噪声,因为浅层特征太低级了。

4. 训练策略与调参实战

4.1 学习率调度与优化器选择

学习率调度对 CIFAR100 的训练影响巨大。我试过三种策略:阶梯下降、余弦退火和带热重启的余弦退火。阶梯下降就是每隔固定 epoch 降一次,比如 50、100、150 各降一次,每次降为原来的 0.1 倍。这种策略简单直接,但下降点需要手动调,调不好就会在某个学习率上卡住。余弦退火是让学习率按余弦曲线从初始值平滑降到接近零,不需要手动设下降点,而且后期学习率很小,能精细调整权重。带热重启的余弦退火是每隔一段时间把学习率重置回初始值,然后重新退火,这样模型能跳出局部最优。

我实测下来,余弦退火在 CIFAR100 上表现最稳,最终准确率比阶梯下降高 2 到 3 个百分点。热重启版本在训练初期波动较大,但最终结果和普通余弦退火差不多,训练时间却更长,所以我没有采用。优化器方面,SGD 加动量是经典选择,动量设 0.9,权重衰减 5e-4 到 1e-3 之间。Adam 收敛更快,但最终准确率往往比 SGD 低 1 到 2 个百分点,而且 Adam 的权重衰减需要特别处理,否则正则化效果不好。所以我最终用的是 SGD。

注意:学习率的初始值不要设太大,0.1 在 batch size 128 的情况下比较合适。如果你把 batch size 加到 256,学习率可以相应加到 0.2,但不要超过 0.3,否则训练会发散。另外,权重衰减不要设太大,1e-2 以上会导致欠拟合,模型学不到东西。

4.2 正则化手段的组合使用

CIFAR100 数据量小,正则化是必须的。我用了四种正则化手段:权重衰减、Dropout、标签平滑和随机深度。权重衰减设 5e-4,作用在所有权重上,偏置和 BatchNorm 的参数不加。Dropout 加在全连接层之前,概率设 0.5,卷积层里不加 Dropout,因为卷积层的参数共享本身就有正则化效果。标签平滑设 0.1,把硬标签变成软标签,防止模型对某个类过度自信。随机深度是在残差块里以一定概率跳过某些层,概率设 0.1 到 0.2 之间,相当于一种隐式的模型集成。

这四种手段组合起来,训练集和验证集的准确率差距从 15 个百分点缩小到了 5 个百分点以内。如果没有这些正则化,模型在训练集上能到 99%,但验证集只有 50% 多,过拟合非常严重。加了正则化之后,训练集准确率降到 85% 左右,但验证集升到了 68%,泛化能力明显增强。

另外我还试了 Mixup 和 CutMix,这两种方法在 batch 层面做数据增强,把两张图按一定比例混合,标签也按同样比例混合。Mixup 的 alpha 设 0.2,CutMix 的 alpha 设 1.0。这两种方法都能显著提升泛化能力,但训练时间会变长,因为模型需要更多 epoch 才能收敛。我最终用了 CutMix,因为它对 CIFAR100 这种细粒度分类更友好,准确率能再提升 2 个百分点左右。

4.3 训练过程中的监控与早停策略

训练过程中我主要监控三个指标:训练损失、验证损失和验证准确率。训练损失持续下降是正常的,但如果验证损失开始上升,说明过拟合了,这时候要么加正则化,要么早停。我一般设 patience=20,也就是验证准确率连续 20 个 epoch 没有提升就停止训练。这样能节省时间,也能防止模型在训练集上过度拟合。

另外我会记录每个 epoch 的学习率,确保学习率调度按预期进行。如果学习率没有按计划下降,可能是调度器的配置有问题。我还会定期保存模型的检查点,一般每 10 个 epoch 保存一次,同时保存验证准确率最高的那个模型。这样即使训练中断了,也能从最近的检查点恢复,不用从头开始。

提示:验证的时候一定要用 model.eval() 和 torch.no_grad(),前者切换 BatchNorm 和 Dropout 的行为,后者关闭梯度计算节省显存。这两个操作缺一不可,否则验证结果会不准确,而且显存占用会飙升。

5. 常见问题与排查技巧实录

5.1 训练损失不下降或下降过慢

这是最常见的问题,原因可能有几个。第一,学习率设得太小,模型更新太慢。你可以试着把学习率调大 10 倍看看损失有没有变化。第二,数据归一化没做或者做错了,导致输入分布差异大,模型难以收敛。检查一下均值和标准差是否计算正确,归一化是否应用到了训练集和测试集。第三,网络初始化有问题,比如全零初始化或者方差过大。PyTorch 默认的初始化通常没问题,但如果你自己写了初始化,要确保每层的输出方差适中。第四,损失函数用错了,比如多分类用了二分类的损失。CIFAR100 是 100 类,必须用 CrossEntropyLoss,而且标签要是 0 到 99 的整数。

我踩过的一个坑是数据加载的时候没有打乱顺序,导致每个 batch 里的样本都是同一类,梯度方向单一,训练很难收敛。用 DataLoader 的时候一定要设 shuffle=True,验证集和测试集可以设 False。

5.2 验证准确率波动大或远低于训练准确率

验证准确率波动大通常是因为验证集太小或者 BatchNorm 在验证时的行为不对。验证集至少要有 5000 张图,否则统计波动会很大。BatchNorm 的问题前面说过了,验证时一定要调 model.eval()。如果验证准确率远低于训练准确率,那就是过拟合了,需要加正则化或者数据增强。我一般先加 Dropout 和权重衰减,如果还不够就加 CutMix 或者 Mixup。

还有一种情况是训练准确率和验证准确率都很低,那可能是欠拟合。这时候要检查网络容量是否足够,学习率是否太小,训练 epoch 是否太少。我试过用 10 层的网络跑 CIFAR100,训练准确率只能到 60% 多,换成 20 层的残差网络立马到 80% 多。所以网络深度对 CIFAR100 很重要,太浅的网络学不到细粒度特征。

5.3 显存不足与训练速度优化

显存不足是常见问题,尤其是当你把 batch size 设得太大或者网络太深的时候。解决办法有几个:第一,减小 batch size,但不要小于 32,否则 BatchNorm 的统计会不准。第二,用混合精度训练,PyTorch 的 amp 模块可以自动把部分计算转成 float16,显存占用能减少一半左右,速度也能提升。第三,用梯度累积,把多个小 batch 的梯度累加起来再更新,等效于大 batch。第四,用 torch.utils.checkpoint 做梯度检查点,牺牲一点计算时间换显存。

训练速度优化方面,我一般会开 cudnn.benchmark=True,让 cuDNN 自动选择最快的卷积算法。另外把 DataLoader 的 num_workers 设成 4 或 8,让数据加载和模型计算并行。如果数据集在机械硬盘上,最好先拷贝到固态硬盘,否则数据加载会成为瓶颈。我实测下来,num_workers=4 比 num_workers=0 训练速度快 30% 左右。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
训练损失不下降学习率太小、数据未归一化、初始化有问题调大学习率试跑、检查归一化代码、打印每层输出方差调整学习率、重新计算均值标准差、改用默认初始化
验证准确率波动大验证集太小、BatchNorm 行为不对扩大验证集、检查 model.eval() 是否调用增加验证集样本、确保验证时切换 eval 模式
验证准确率远低于训练过拟合对比训练和验证损失曲线加 Dropout、权重衰减、数据增强、CutMix
显存不足batch size 太大、网络太深打印显存占用、逐步减小 batch size用混合精度、梯度累积、梯度检查点
训练速度慢数据加载瓶颈、卷积算法未优化检查 GPU 利用率、DataLoader 耗时增加 num_workers、开 cudnn.benchmark、数据放固态硬盘

6. 模型评估与结果分析

6.1 测试集评估与混淆矩阵分析

训练完成后,我在测试集上做最终评估。测试集有 10000 张图,每个类 100 张。评估的时候用 model.eval() 和 torch.no_grad(),把预测结果和真实标签对比,算总体准确率和每个类的准确率。我最终的网络在测试集上到了 72% 左右的准确率,比基线高了 27 个百分点。这个结果在 CIFAR100 上算是不错了,很多论文里的方法也就 75% 到 80% 之间,而且他们用了更复杂的网络和更长的训练时间。

混淆矩阵能看出哪些类容易混。我分析了一下,发现错误主要集中在视觉上接近的类别,比如“苹果”和“橘子”、“狼”和“狐狸”、“摩托车”和“自行车”。这些类别的图像在 32x32 分辨率下确实很难区分,人眼有时候也看不出来。另外,粗类之间的混淆比较少,比如“水果”和“交通工具”基本不会混,说明模型还是学到了高层语义的。

6.2 消融实验与改进效果对比

为了搞清楚每个改进的贡献,我做了一组消融实验。基线网络准确率 45%,加残差连接后到 65%,加 SE 注意力后到 68%,加 CutMix 后到 70%,加余弦退火后到 72%。每个改进都有明确的收益,而且叠加起来效果更好。数据增强的贡献最大,单独加数据增强就能提升 10 个百分点以上。残差连接的贡献次之,没有残差连接网络根本深不下去。

方案验证准确率测试准确率参数量
基线 VGG44.5%45.2%1.5M
加残差连接64.8%65.1%5.2M
加 SE 注意力67.9%68.3%5.8M
加 CutMix70.2%70.5%5.8M
加余弦退火71.8%72.1%5.8M

6.3 模型部署与推理优化

训练好的模型如果要部署,需要考虑推理速度和模型大小。我一般会把模型转成 ONNX 格式,然后用 ONNX Runtime 做推理,速度比 PyTorch 原生快 20% 到 30%。如果要在移动端部署,可以用 TensorFlow Lite 或者 NCNN,但需要把 PyTorch 模型先转成 ONNX 再转过去。量化也是一个好办法,把 float32 转成 int8,模型大小能减少 75%,推理速度能提升 2 到 3 倍,准确率只掉 1 到 2 个百分点。

推理的时候要注意输入预处理要和训练时一致,包括归一化的均值和标准差、图像的尺寸和通道顺序。我见过有人训练时用 RGB 顺序,推理时用 BGR,结果准确率掉了一半。另外,推理时不需要数据增强,直接把原图归一化后送进网络就行。如果要做批量推理,可以把多张图拼成一个 batch,这样能充分利用 GPU 的并行能力。

提示:转 ONNX 的时候要指定动态 batch 维度,否则推理时只能固定 batch size。用 torch.onnx.export 的时候设 dynamic_axes 参数,把 batch 维度设为动态。这样部署的时候可以灵活调整 batch size,不用重新导出模型。

7. 实操心得与避坑经验

这个项目我前前后后跑了大概两个月,踩了不少坑,也积累了一些经验。第一个心得是不要一上来就追求复杂网络,先把基线跑通,再逐步改进。我见过很多人直接上 ResNet50 或者 EfficientNet,结果因为数据量不够,过拟合严重,准确率还不如简单的网络。CIFAR100 这种小数据集,网络深度和宽度要适中,太深太宽反而不好。

第二个心得是数据增强比网络结构更重要。同样的网络,加不加数据增强能差 10 个百分点以上。而且数据增强的组合和强度需要调,不是越多越好。我试过把旋转角度加到 30 度,准确率反而掉了,因为很多物体旋转后变得不自然。所以增强策略要贴合数据的特点,CIFAR100 的图像是自然物体,轻微的平移、翻转、颜色变化是合理的,大幅度的旋转和裁剪就不合适了。

第三个心得是学习率调度和正则化要配合使用。光有学习率调度没有正则化,模型还是会过拟合;光有正则化没有学习率调度,模型收敛慢而且最终准确率不高。我一般先用余弦退火把学习率降下来,然后在后期加 CutMix 和标签平滑,这样模型既能快速收敛,又能防止过拟合。

第四个心得是验证集的使用要谨慎。验证集是用来调参的,不要用它来做早停以外的决策。我见过有人反复在验证集上调参,调了几十次,最后验证集准确率很高但测试集一塌糊涂,这就是验证集过拟合了。正确的做法是:用验证集调几组参数,选最好的一组,然后在测试集上跑一次,报告这个结果。不要反复在测试集上调参,否则测试集也就失去了评估的意义。

最后分享一个小技巧:训练的时候把随机种子固定下来,这样每次跑的结果可复现。PyTorch 里要设 torch.manual_seed、torch.cuda.manual_seed_all、np.random.seed 和 random.seed,还要设 cudnn.deterministic=True。这样虽然会稍微慢一点,但结果稳定,方便对比不同方案的效果。如果不固定种子,同样的代码跑两次可能差 1 到 2 个百分点,你就没法判断改进到底有没有用了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进