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 张训练图不增强根本不够看。我常用的增强组合包括随机裁剪、随机水平翻转、颜色抖动和随机旋转。随机裁剪我一般设 padding4裁剪到 32x32这样图像会有轻微的平移模拟拍摄时的位置变化。随机水平翻转概率设 0.5对于大多数类别都适用但要注意有些类别翻转后语义会变比如“左转”和“右转”的交通标志不过 CIFAR100 里这类情况很少可以放心用。颜色抖动我一般设 brightness0.2、contrast0.2、saturation0.2、hue0.1这个强度不算大但能显著提升模型对光照和色彩的鲁棒性。随机旋转我设的是 ±15 度再大就会引入黑边反而影响训练。另外我还试过 Cutout 和 Random Erasing这两种方法在 CIFAR100 上效果不错能强迫模型关注物体的整体结构而不是局部纹理。Cutout 我一般设 8x8 的遮挡块Random Erasing 的概率设 0.25面积比例设 0.02 到 0.2 之间。注意数据增强只在训练时启用验证和测试时必须关闭否则评估结果会偏低。另外增强的强度不是越大越好我试过把旋转角度加到 30 度准确率反而掉了 3 个点因为很多物体旋转后变得不自然。2.3 训练集、验证集的划分方式官方只给了训练集和测试集没有验证集。我一般从 50000 张训练图里划出 5000 张作为验证集用来监控过拟合和调参。划分的时候要保证每个类都有 50 张验证图这样验证集的类别分布和训练集一致。具体做法是用 stratified splitsklearn 的 train_test_split 里有个 stratify 参数可以直接用。划分完之后的训练集是 45000 张验证集 5000 张测试集 10000 张。有些同行喜欢用交叉验证但在 CIFAR100 上做 5 折交叉验证计算量太大除非你有多个 GPU否则不推荐。我一般就是固定一个验证集调参的时候看验证集准确率最终报告结果的时候用测试集。这里有个经验验证集准确率和测试集准确率通常差不太多如果差超过 2 个百分点说明验证集划分可能有问题或者模型在验证集上过拟合了。3. 网络结构设计与核心模块实现3.1 基线网络的选择与搭建我一开始用的基线是一个简单的 VGG 风格网络结构是两个卷积层加池化再两个卷积层加池化再两个卷积层加池化最后接全连接层。每个卷积层都是 3x3 卷积核padding1后面接 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.2CutMix 的 alpha 设 1.0。这两种方法都能显著提升泛化能力但训练时间会变长因为模型需要更多 epoch 才能收敛。我最终用了 CutMix因为它对 CIFAR100 这种细粒度分类更友好准确率能再提升 2 个百分点左右。4.3 训练过程中的监控与早停策略训练过程中我主要监控三个指标训练损失、验证损失和验证准确率。训练损失持续下降是正常的但如果验证损失开始上升说明过拟合了这时候要么加正则化要么早停。我一般设 patience20也就是验证准确率连续 20 个 epoch 没有提升就停止训练。这样能节省时间也能防止模型在训练集上过度拟合。另外我会记录每个 epoch 的学习率确保学习率调度按预期进行。如果学习率没有按计划下降可能是调度器的配置有问题。我还会定期保存模型的检查点一般每 10 个 epoch 保存一次同时保存验证准确率最高的那个模型。这样即使训练中断了也能从最近的检查点恢复不用从头开始。提示验证的时候一定要用 model.eval() 和 torch.no_grad()前者切换 BatchNorm 和 Dropout 的行为后者关闭梯度计算节省显存。这两个操作缺一不可否则验证结果会不准确而且显存占用会飙升。5. 常见问题与排查技巧实录5.1 训练损失不下降或下降过慢这是最常见的问题原因可能有几个。第一学习率设得太小模型更新太慢。你可以试着把学习率调大 10 倍看看损失有没有变化。第二数据归一化没做或者做错了导致输入分布差异大模型难以收敛。检查一下均值和标准差是否计算正确归一化是否应用到了训练集和测试集。第三网络初始化有问题比如全零初始化或者方差过大。PyTorch 默认的初始化通常没问题但如果你自己写了初始化要确保每层的输出方差适中。第四损失函数用错了比如多分类用了二分类的损失。CIFAR100 是 100 类必须用 CrossEntropyLoss而且标签要是 0 到 99 的整数。我踩过的一个坑是数据加载的时候没有打乱顺序导致每个 batch 里的样本都是同一类梯度方向单一训练很难收敛。用 DataLoader 的时候一定要设 shuffleTrue验证集和测试集可以设 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.benchmarkTrue让 cuDNN 自动选择最快的卷积算法。另外把 DataLoader 的 num_workers 设成 4 或 8让数据加载和模型计算并行。如果数据集在机械硬盘上最好先拷贝到固态硬盘否则数据加载会成为瓶颈。我实测下来num_workers4 比 num_workers0 训练速度快 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.8M6.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.deterministicTrue。这样虽然会稍微慢一点但结果稳定方便对比不同方案的效果。如果不固定种子同样的代码跑两次可能差 1 到 2 个百分点你就没法判断改进到底有没有用了。