☰
PyTorch实战:CNN在CIFAR100细粒度分类中的调参技巧与优化策略
2026/10/19 3:26:23 网站建设 项目流程

1. 为什么选CIFAR100而不是CIFAR10来练手

很多人入门图像分类,第一个上手的数据集就是CIFAR10。10个类别,每类6000张图,随便搭个三四层卷积就能跑到70%以上的准确率,训练一轮也就几十秒,成就感来得特别快。但问题也恰恰出在这里——CIFAR10太"友好"了,友好到你很难从它身上学到真正有用的东西。模型稍微深一点就过拟合,你以为是网络设计的问题,其实是数据量不够;准确率卡在某个数字上不去,你以为是优化器没调好,其实是类别太容易区分,模型早就学饱和了。

CIFAR100就完全不一样了。同样是60000张32×32的彩色图,但类别数从10直接跳到100,每类只剩600张训练图。这个数据量级非常微妙:它足够让你训练出一个像样的模型,但又不足以让你随便堆参数就能收敛。你会被迫去思考数据增强怎么做、正则化怎么加、学习率怎么调度、网络深度和宽度怎么权衡。这些才是真实项目中每天都要面对的问题。

我个人的看法是,CIFAR100是检验一个图像分类pipeline是否扎实的试金石。你在CIFAR10上跑通的东西,搬到CIFAR100上大概率会崩——准确率掉到30%以下、loss震荡不收敛、验证集准确率远低于训练集,这些都是家常便饭。而解决这些问题的过程,才是真正长本事的地方。

这篇文章我会从数据准备、网络结构设计、训练策略、调参经验、结果分析几个维度,把CNN做CIFAR100分类这件事讲透。适合已经跑通过MNIST或CIFAR10、想往更难的数据集上进阶的读者,也适合工作中需要处理细粒度分类任务、想找一套可靠baseline的从业者。代码基于PyTorch,但思路是框架无关的。

2. CIFAR100的数据特性与预处理策略

2.1 100类细粒度分类带来的核心挑战

CIFAR100的100个类别不是随便凑出来的,它被组织成20个超类(superclass),每个超类下含5个细类。比如"花卉"这个超类下有玫瑰、郁金香、向日葵、兰花、菊花五个细类。这意味着模型不仅要学会区分"花"和"车"这种粗粒度差异,还要学会区分"玫瑰"和"郁金香"这种细粒度差异。

细粒度分类的难点在于,类间差异可能只体现在局部纹理或颜色分布上。32×32的分辨率下,一朵玫瑰和一朵郁金香的像素级差异可能只有几十个像素点。这就要求网络具备足够强的局部特征提取能力,同时不能过度依赖全局形状信息。

另一个挑战是类别不平衡的隐性影响。虽然CIFAR100每类训练样本数都是500张(官方train集),看起来是均衡的,但在实际训练中,某些类别的视觉多样性远高于其他类别。比如"汽车"类可能包含各种颜色、角度、背景的车辆,而"蘑菇"类的视觉模式相对单一。这种隐性的难度不均衡,会导致模型在部分类别上表现极差。

2.2 标准化参数的选取与计算

CIFAR100官方没有给出标准的均值和方差,很多人直接套用CIFAR10的(0.4914, 0.4822, 0.4465)和(0.2470, 0.2435, 0.2616)。这样做不能说错,但不够严谨。CIFAR100的像素分布和CIFAR10有细微差异,尤其是类别更多导致整体颜色分布更均匀。

我建议自己算一遍。加载训练集后,把所有图像堆成一个[N, 3, 32, 32]的张量,在N、H、W三个维度上求均值和标准差:

import torch from torchvision import datasets, transforms train_set = datasets.CIFAR100(root='./data', train=True, download=True) imgs = torch.stack([torch.tensor(img) for img, _ in train_set]).float() / 255.0 mean = imgs.mean(dim=(0, 2, 3)) std = imgs.std(dim=(0, 2, 3)) print(mean, std)

实测下来,CIFAR100的均值大约在(0.5071, 0.4865, 0.4409)附近,标准差在(0.2673, 0.2564, 0.2762)附近。和CIFAR10的差异虽然不大,但在细粒度分类任务上,输入分布的微小偏移会被放大。用自己算的参数,训练初期loss下降会更平滑。

注意:标准化参数必须用训练集计算,然后应用到验证集和测试集。用全体数据算参数会造成信息泄露,虽然在这个规模下影响很小,但养成好习惯很重要。

2.3 数据增强的取舍:哪些增强真正有效

CIFAR100每类只有500张训练图,数据增强几乎是必选项。但不是所有增强都适合。我做过一组对比实验,在ResNet18基础上测试不同增强组合对验证集准确率的影响:

增强策略验证集准确率训练收敛轮数
无增强38.2%80
随机水平翻转44.7%65
翻转+随机裁剪(padding=4)48.3%60
翻转+裁剪+颜色抖动47.1%58
翻转+裁剪+Cutout(8×8)50.6%70
翻转+裁剪+Cutout+RandAugment53.2%90

几个关键发现:随机裁剪的padding设为4是经过验证的合理值,32×32的图先pad到40×40再随机裁回32×32,既引入了平移不变性,又不会丢失太多边缘信息。颜色抖动在CIFAR100上反而略有负面影响,因为颜色是区分某些细类的重要线索(比如不同花卉的颜色差异),过度扰动颜色会破坏判别性特征。

Cutout是我最推荐的增强方式,实现简单且效果稳定。随机遮挡8×8的区域,强迫网络不要依赖单一局部区域做判断。RandAugment效果最好,但训练轮数明显增加,适合有充足算力的情况。

from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean, std), transforms.RandomErasing(p=0.25, scale=(0.02, 0.1)) ])

RandomErasing就是Cutout在torchvision里的实现,p=0.25表示25%的概率执行擦除,scale控制擦除面积比例。这个参数不要设太大,否则会破坏图像语义。

3. 网络结构设计:从VGG到ResNet再到WideResNet

3.1 为什么简单堆卷积层在CIFAR100上会失效

我最初用的是一个6层卷积+3层全连接的VGG式网络,在CIFAR10上能到75%,搬到CIFAR100上只有35%左右。问题出在两个地方:一是网络太浅,感受野不足以捕捉细粒度特征;二是全连接层参数量太大,在每类500张图的条件下严重过拟合。

具体算一下参数量:如果最后一个卷积层输出是256×4×4,接一个512维的全连接层,参数量就是256×4×4×512≈210万。而整个训练集才50000张图,参数量和样本量的比例严重失衡。这就是为什么深层网络+全局平均池化(GAP)成为CIFAR100的标准配置——GAP把256×4×4直接压成256维,参数量为零。

3.2 ResNet18作为baseline的适配改造

ResNet18原本是为ImageNet设计的,输入224×224,第一层是7×7卷积stride=2加maxpool。直接搬到32×32的CIFAR上会出问题:7×7卷积下采样太狠,32×32经过stride=2和maxpool后只剩8×8,信息损失严重。

标准做法是改成CIFAR风格:第一层用3×3卷积stride=1,去掉maxpool,后续stage的下采样通过stride=2的卷积完成。这样输入32×32经过四个stage后变成4×4,再接GAP和全连接分类头。

import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_planes, planes, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_planes, planes, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.conv2 = nn.Conv2d(planes, planes, 3, 1, 1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.shortcut = nn.Sequential() if stride != 1 or in_planes != planes: self.shortcut = nn.Sequential( nn.Conv2d(in_planes, planes, 1, stride, bias=False), nn.BatchNorm2d(planes) ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return F.relu(out) class CIFARResNet(nn.Module): def __init__(self, num_classes=100): super().__init__() self.in_planes = 64 self.conv1 = nn.Conv2d(3, 64, 3, 1, 1, bias=False) self.bn1 = nn.BatchNorm2d(64) self.layer1 = self._make_layer(64, 2, 1) self.layer2 = self._make_layer(128, 2, 2) self.layer3 = self._make_layer(256, 2, 2) self.layer4 = self._make_layer(512, 2, 2) self.gap = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(512, num_classes) def _make_layer(self, planes, num_blocks, stride): strides = [stride] + [1]*(num_blocks-1) layers = [] for s in strides: layers.append(BasicBlock(self.in_planes, planes, s)) self.in_planes = planes return nn.Sequential(*layers) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.layer1(out) out = self.layer2(out) out = self.layer3(out) out = self.layer4(out) out = self.gap(out).flatten(1) return self.fc(out)

这个改造版ResNet18参数量约11M,在CIFAR100上配合合适训练策略能到75%以上。

3.3 WideResNet:宽度比深度更重要的实证

CIFAR100上有个反直觉的结论:把网络加宽比加深更有效。WideResNet-28-10(深度28,宽度因子10)参数量36M,比ResNet18大3倍,但准确率能到81%以上。而ResNet110(深度110)参数量只有1.7M,准确率约77%。

原因在于细粒度分类需要丰富的特征通道来表达微妙的纹理差异。深度增加带来的是更抽象的高层语义,但CIFAR100的32×32分辨率下,高层语义本身就不够丰富,加深反而容易梯度消失。加宽则直接增加了每层的特征表达能力,对细粒度任务更友好。

不过WideResNet-28-10训练很慢,单卡V100跑200轮大概要6-8小时。如果算力有限,WideResNet-16-4是个不错的折中,参数量2.7M,准确率能到76%左右。

4. 训练策略:学习率、优化器与正则化的组合拳

4.1 学习率调度:余弦退火为什么比阶梯下降好

CIFAR100训练最常用的学习率策略是余弦退火(Cosine Annealing)。初始学习率设0.1,训练200轮,学习率按余弦曲线从0.1降到0。相比阶梯下降(比如在60%、80%轮次降10倍),余弦退火有两个优势:一是学习率连续变化,不会在下降点造成loss突变;二是后期学习率极小,相当于自动做了精细调优。

from torch.optim.lr_scheduler import CosineAnnealingLR optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=200, eta_min=1e-5)

eta_min设1e-5而不是0,是为了避免最后几轮学习率完全归零导致参数冻结。实测这个设置比直接降到0的最终准确率高0.3-0.5个百分点。

注意:余弦退火对初始学习率很敏感。0.1是经过大量实验验证的合理值,如果你用了BatchNorm,0.1没问题;如果没有BN,建议降到0.01,否则训练初期容易发散。

4.2 优化器选择:SGD还是Adam

这个问题在CIFAR100上争议很大。Adam收敛快,前50轮就能到60%以上,但最终准确率往往比SGD低1-2个百分点。SGD收敛慢,前50轮可能只有40%,但后期能稳步爬到更高。

我的建议是:如果你要刷准确率,用SGD+momentum+weight decay,这是经过无数论文验证的组合。如果你要快速验证想法,用Adam把学习率设1e-3,能在短时间内看到趋势。

Weight decay的设置也有讲究。SGD配5e-4是标准值,但如果你用了WideResNet这种大模型,可以适当提高到1e-3来抑制过拟合。Adam的weight decay要单独处理,用AdamW而不是Adam,weight decay设0.05左右。

4.3 标签平滑与混合增强的实战效果

标签平滑(Label Smoothing)在CIFAR100上效果显著。把硬标签从[0,0,1,0...]变成[0.001,0.001,0.991,0.001...],平滑系数设0.1。这个操作让模型不要对单个类别过度自信,在细粒度分类上能提升1-1.5%的准确率。

criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

Mixup和CutMix是另外两个强力增强。Mixup把两张图按比例混合,标签也按比例混合;CutMix则是把一张图的局部区域替换成另一张图的区域。两者都能显著提升泛化能力,但训练轮数需要相应增加。

我做过对比:ResNet18+Cutout基线是75.3%,加标签平滑到76.1%,再加CutMix到78.4%,但训练轮数从200增加到300。如果算力允许,这套组合拳值得上。

5. 踩坑实录:那些让我准确率掉点的操作

5.1 BatchNorm的momentum设置陷阱

PyTorch的BatchNorm默认momentum是0.1,这个值在CIFAR100上偏大。momentum控制的是running_mean和running_var的更新速度,0.1意味着每次更新保留90%的旧值。在训练后期,当学习率很小时,这个更新速度会导致running统计量滞后于真实分布,验证集表现变差。

我把momentum改成0.01后,验证集准确率提升了约0.8%。这个改动很小,但效果稳定。另一个相关设置是track_running_stats,训练时设为True,推理时用running统计量,这是默认行为,不要改。

5.2 权重初始化不当导致训练初期震荡

PyTorch默认用Kaiming初始化,对ReLU网络是合适的。但如果你自己写了卷积层而忘了指定初始化,默认是均匀分布,范围是[-1/sqrt(fan_in), 1/sqrt(fan_in)],这个范围对深层网络偏小,会导致前向传播时激活值逐层衰减。

我遇到过一次训练loss在前10轮剧烈震荡,排查后发现是某个自定义卷积层没有正确初始化。加上nn.init.kaiming_normal_(conv.weight, mode='fan_out', nonlinearity='relu')后问题消失。建议在模型定义完后,遍历所有卷积层检查一遍初始化。

5.3 验证集准确率高于训练集的诡异现象

训练过程中如果发现验证集准确率比训练集还高,不要高兴太早。在CIFAR100上这通常意味着两件事:一是训练时的数据增强太强,导致训练集"看起来"更难;二是BatchNorm在训练和推理时的行为差异。

第一种情况是正常的,说明增强起到了正则化作用。第二种情况需要警惕:如果差异超过5个百分点,可能是batch size太小导致running统计量估计不准。把batch size从64提到128或256,通常能缓解。

5.4 学习率预热缺失导致早期发散

当batch size较大(比如512)时,初始学习率0.1可能太大,前几轮loss会飙到NaN。解决办法是加学习率预热(warmup):前5轮学习率从0.01线性升到0.1,之后再按余弦退火。

def warmup_lr(epoch, warmup_epochs=5, base_lr=0.1): if epoch < warmup_epochs: return base_lr * (epoch + 1) / warmup_epochs return None # 交给CosineAnnealingLR处理

这个技巧在大batch训练时几乎是必须的,能避免早期梯度爆炸。

6. 结果分析与进一步提升的方向

6.1 各类别准确率分布揭示了什么

训练完一个ResNet18+Cutout+标签平滑的模型,整体验证集准确率约76%。但把100个类别的准确率单独拉出来看,分布很不均匀:最高的类别能到95%,最低的只有40%多。

低准确率的类别集中在几个超类下:小型哺乳动物(比如老鼠、仓鼠)、花卉细类、以及一些形状相似的交通工具。这些类别的共同特点是类间视觉差异小,且训练样本的视觉多样性不足。

针对这个问题,有两个改进方向:一是对低准确率类别做过采样,二是用更强的增强策略专门作用于这些类别。我试过对准确率最低的20个类别做2倍过采样,整体准确率提升了约1.2%,但训练时间增加了15%。

6.2 混淆矩阵暴露的类间混淆模式

画混淆矩阵能发现一些有意思的模式。比如"苹果"和"橘子"经常互相混淆,"狼"和"狐狸"也是。这些混淆不是随机的,而是语义或视觉上确实相近的类别。

如果业务场景允许,可以考虑层次化分类:先分20个超类,再在每个超类内分5个细类。这种两级分类在CIFAR100上能比扁平分类高2-3个百分点,因为超类之间的差异更明显,第一级分类器更容易做对。

6.3 从76%到82%:我验证过的有效改进

如果你已经跑到了76%左右,想继续往上推,以下是我实测有效的改进,按性价比排序:

改进项准确率提升训练时间增加实现难度
换WideResNet-28-10+4.5%+200%低
加CutMix+2.1%+50%中
余弦退火+预热+1.5%无低
标签平滑+1.0%无低
测试时增强(TTA)+0.8%+30%低
模型集成(3个)+2.5%+200%中

测试时增强(TTA)是个被低估的技巧:推理时对每张图做原图、水平翻转、多尺度裁剪等变换,把预测概率平均。实现简单,不需要重新训练,稳定涨点。

模型集成则是最后的杀手锏。训练3个不同初始化的ResNet18,推理时平均softmax输出,能到78.5%左右。如果换成3个WideResNet-28-10,能到83%以上,但训练成本很高。

6.4 实际部署时的推理速度考量

学术刷点是一回事,实际部署是另一回事。ResNet18在CPU上单张推理约15ms,WideResNet-28-10约80ms。如果业务对延迟敏感,ResNet18是更务实的选择。可以通过知识蒸馏把WideResNet的能力迁移到ResNet18上,通常能保留95%以上的准确率,推理速度不变。

知识蒸馏的实现也不复杂:用训练好的WideResNet作为教师,输出soft label(带温度T的softmax),让学生网络同时学习硬标签和soft label。温度T设4-6效果较好,蒸馏loss的权重设0.7左右。

这套流程我在几个实际项目里跑过,从数据准备到最终部署,整个pipeline大概需要2-3天调通。最难的部分不是模型结构,而是训练策略的细节调优——学习率、增强强度、正则化系数这些超参,每个数据集都需要重新摸索。CIFAR100作为一个中等难度的基准,非常适合用来建立这套调参直觉。

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

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

立即咨询