1. 为什么CIFAR-10是检验图像分类方案的试金石
CIFAR-10这个数据集在图像分类领域的位置,有点像驾考里的科目二——场地固定、规则清晰,但真要把车停得又快又准,背后考验的是对每个操作细节的理解深度。它包含10个类别、共60000张32×32像素的彩色图像,其中50000张用于训练、10000张用于测试。类别覆盖飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车,每类6000张,分布均衡。
很多人第一次接触CIFAR-10会觉得"才32×32,能有多难"。但实际跑过一轮就会发现,这个分辨率下类间差异被严重压缩:猫和狗的轮廓几乎糊成一团,鹿和马在低像素下只差一对角,汽车和卡车更是经常互相混淆。这恰恰是它作为基准测试的价值所在——它逼着你去思考,在信息量极其有限的前提下,如何把特征提取做到极致。
从工程角度看,CIFAR-10还有一个隐性优势:单张图小、整体数据量适中,一张消费级显卡就能在合理时间内完成完整训练迭代。这意味着你可以快速验证一个想法,从数据增强策略到网络结构改动,从优化器选择到学习率调度,一轮实验几十分钟到几小时就能看到结果。这种"快速反馈循环"对于积累实战经验极其重要,比在超大数据集上等几天才有结论要高效得多。
我个人的判断是:如果你能把CIFAR-10的测试准确率稳定推到93%以上,说明你对卷积网络的基本功已经扎实;如果能推到95%以上,那你在正则化、数据增强和训练策略上已经有了相当的手感。这个标准不是绝对的,但它提供了一个清晰的进阶路标。
注意:不要一上来就追求SOTA。先跑通一个朴素基线,再逐步加改进,每一步都记录清楚改了什么、涨了多少点。这个"消融实验"的习惯比最终数字更重要。
2. 从零搭建第一个可复现的基线模型
2.1 数据加载与预处理的几个关键决策
拿到CIFAR-10的第一件事不是急着搭网络,而是把数据管道理顺。我见过太多人在这步埋下隐患,后面调参时根本分不清是模型问题还是数据问题。
数据加载用PyTorch的torchvision.datasets.CIFAR10最省事,但有几个细节必须注意。首先是归一化参数,CIFAR-10的全局均值和标准差大约是(0.4914, 0.4822, 0.4465)和(0.2470, 0.2435, 0.2616),按通道分别归一化。很多人图省事直接用0.5和0.5,结果训练初期收敛明显变慢。这个差异在浅层网络上可能不明显,但网络一深就体现出来了。
import torch import torchvision import torchvision.transforms as transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)这里RandomCrop(32, padding=4)是先四周各补4像素再随机裁回32×32,等价于随机平移。RandomHorizontalFlip是水平翻转。这两个是最基础也最有效的数据增强,几乎零成本就能带来1到2个百分点的提升。
2.2 一个够用但不简陋的卷积网络结构
基线模型不需要多复杂,但也不能太随意。我的建议是搭一个6层卷积加3层全连接的VGG风格小网络,每两个卷积后接一次最大池化,通道数从64翻到128再到256。
import torch.nn as nn import torch.nn.functional as F class BaselineNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), ) self.classifier = nn.Sequential( nn.Linear(256 * 4 * 4, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)每个卷积后接BatchNorm再接ReLU,这个顺序不要改。BatchNorm放在卷积和激活之间,能让每层输入分布更稳定,训练时可以用更大的学习率。Dropout放在全连接层,比例0.5是经典值,太低起不到正则作用,太高会欠拟合。
2.3 训练循环里容易被忽略的配置
优化器选SGD加动量,初始学习率0.1,动量0.9,权重衰减5e-4。这个组合在CIFAR-10上是经过大量验证的稳妥起点。学习率调度用余弦退火或者多步衰减都行,我倾向余弦退火,因为它不需要手动设定衰减节点。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = BaselineNet().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=200) for epoch in range(200): model.train() for inputs, targets in trainloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() scheduler.step()批大小设128,训练200轮。这个配置下,基线模型大概能到92%到93%的测试准确率。如果达不到,先检查数据归一化是否正确、BatchNorm的momentum是否用了默认值0.1、学习率有没有设错。
提示:训练时一定要把
model.train()和model.eval()切换对。BatchNorm和Dropout在两种模式下的行为完全不同,忘了切换会导致验证结果异常偏低,而且这种错误很隐蔽,不看代码根本发现不了。
3. 把准确率从92%推到95%的实战改进路径
3.1 数据增强的进阶组合
基础的水平翻转加随机裁剪能带来约2个点提升,但想再往上走,需要更丰富的数据增强。Cutout和RandomErasing是性价比很高的选择:随机在图像上挖一个矩形区域并填0,强迫网络不要过度依赖局部特征。
class Cutout: def __init__(self, size=8): self.size = size def __call__(self, img): h, w = img.size(1), img.size(2) y = torch.randint(h, (1,)) x = torch.randint(w, (1,)) y1 = torch.clamp(y - self.size // 2, 0, h).item() y2 = torch.clamp(y + self.size // 2, 0, h).item() x1 = torch.clamp(x - self.size // 2, 0, w).item() x2 = torch.clamp(x + self.size // 2, 0, w).item() img[:, y1:y2, x1:x2] = 0 return imgCutout的size设8比较合适,太大破坏信息,太小没效果。把它加在ToTensor之后、Normalize之前。实测下来,Cutout单独能贡献0.5到1个点。
另一个值得尝试的是Mixup或CutMix。这两种方法都是在训练时把两张图按某种方式混合,标签也按比例混合。它们能显著提升泛化能力,但训练轮数需要相应增加,因为每轮的有效信息变少了。Mixup的alpha设0.2,CutMix的alpha设1.0,都是常用值。
3.2 网络结构的微调方向
基线网络还有提升空间。第一个改动是把普通卷积换成残差连接,哪怕只是简单的shortcut,也能让梯度传得更顺,支持更深的网络。第二个改动是增加通道数或层数,但要注意参数量和过拟合的平衡。
一个实用的中间方案是在每个stage里加一个残差块,而不是从头搭ResNet。这样改动量小,但效果明显。具体做法是在每个MaxPool之前,把输入和输出维度对齐后相加。
class ResidualBlock(nn.Module): def __init__(self, channels): super().__init__() self.conv1 = nn.Conv2d(channels, channels, 3, padding=1) self.bn1 = nn.BatchNorm2d(channels) self.conv2 = nn.Conv2d(channels, channels, 3, padding=1) self.bn2 = nn.BatchNorm2d(channels) def forward(self, x): residual = x out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += residual return F.relu(out)把每个stage的第二个卷积替换成这样的残差块,测试准确率通常能再涨0.5到1个点。代价是训练时间增加约20%,但完全值得。
3.3 学习率与正则化的精细调整
学习率调度对最终结果影响很大。余弦退火从0.1降到接近0,配合200轮训练,效果稳定。如果想再榨一点,可以用带热重启的余弦退火,每隔50轮把学习率拉回0.1再降,这样模型有机会跳出局部最优。
权重衰减从5e-4调到1e-3,配合更强的数据增强,能进一步抑制过拟合。但要注意,权重衰减太大会导致欠拟合,训练损失降不下去。判断标准是看训练集和测试集的准确率差距:如果训练集接近100%而测试集只有93%,说明过拟合,可以加大正则;如果训练集本身就只有95%,那说明欠拟合,应该减小正则或增大模型。
标签平滑是另一个小改动大收益的技巧。把硬标签从[0, 0, 1, 0, ...]变成[0.01, 0.01, 0.91, 0.01, ...],让模型不要对某个类别过度自信。在PyTorch里用nn.CrossEntropyLoss(label_smoothing=0.1)一行就能开启,通常能带来0.3到0.5个点。
注意:标签平滑和Mixup不要同时用,两者都在软化标签,叠加后可能让模型学得太"软",反而掉点。选一个就行。
4. 训练过程中那些让人抓狂的异常与排查
4.1 损失不下降或震荡剧烈
这是最常见的问题。第一步先看数据归一化有没有做对,把输入图像的均值和方差打印出来,确认在合理范围。第二步检查学习率,0.1对SGD加动量是合理的,但如果用了Adam,0.1就太大了,应该降到1e-3。第三步看BatchNorm的track_running_stats是否为True,如果误设成False,验证时会出大问题。
还有一个隐蔽原因:数据加载器的num_workers设得太大,导致每个worker拿到的数据顺序混乱,如果用了shuffle=True但没设随机种子,每轮的数据顺序都不同,损失曲线会显得很毛糙。把num_workers设成4或8,并固定随机种子,曲线会平滑很多。
4.2 验证准确率远低于训练准确率
训练集95%、测试集85%这种差距,基本可以判定是过拟合。解决思路按优先级排:先加数据增强,再加Dropout或权重衰减,最后考虑减小模型。不要一上来就砍模型,很多时候模型容量不是问题,正则不够才是。
另一个可能是训练集和测试集的预处理不一致。比如训练用了RandomCrop但测试忘了去掉,或者归一化参数写错了。这种错误会让测试集分布和训练集不匹配,准确率直接掉一大截。检查方法是把测试集的transform打印出来,和训练集对比,确认只有增强部分不同,归一化和ToTensor必须完全一致。
4.3 训练到一半准确率突然崩掉
这种情况通常是学习率太大导致梯度爆炸。检查梯度范数,如果某层的梯度范数超过10,说明需要梯度裁剪。在loss.backward()之后、optimizer.step()之前加一行nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0),能有效防止训练崩溃。
还有一种可能是BatchNorm的running mean和running var在某个batch上被异常值污染了。如果某个batch里全是相似图像,running stats会偏移。解决办法是增大批大小,或者用GroupNorm替代BatchNorm。CIFAR-10上批大小128通常够用,但如果显存不够只能用小batch,GroupNorm是更稳的选择。
5. 把单模型做到极致后的集成与推理优化
5.1 多模型快照集成
单模型到95%左右就接近瓶颈了,再往上走性价比很低。这时候可以用快照集成:在余弦退火的每个周期末尾保存模型权重,最后把多个快照的预测概率平均。因为每个快照对应不同的学习率阶段,模型落在损失曲面的不同位置,集成后能互补。
具体做法是训练300轮,每50轮保存一次,得到6个快照。推理时把6个模型的softmax输出相加再取argmax。实测下来,快照集成比单模型能涨1到1.5个点,而且不需要额外训练时间,只是多存几个权重文件。
5.2 测试时增强
测试时增强是在推理阶段对同一张图做多种变换,把结果平均。最简单的做法是原图加水平翻转图,两个预测取平均。再进一步可以加多尺度裁剪,把32×32的图放大到40×40再中心裁剪回32×32,模拟不同尺度。
def tta_predict(model, image): model.eval() with torch.no_grad(): # 原图 out1 = F.softmax(model(image), dim=1) # 水平翻转 out2 = F.softmax(model(torch.flip(image, dims=[3])), dim=1) # 平均 return (out1 + out2) / 2TTA通常能带来0.3到0.5个点,代价是推理时间翻倍。如果只是打比赛刷分,值得做;如果是实际部署,要权衡延迟。
5.3 模型量化与推理加速
训练完之后如果要把模型部署到边缘设备,量化是绕不开的。PyTorch支持动态量化和静态量化,CIFAR-10这种小模型用动态量化就够,把全连接层的权重从float32降到int8,模型体积缩小约4倍,推理速度提升1.5到2倍,准确率损失通常在0.5个点以内。
quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear}, dtype=torch.qint8 )注意量化只对全连接层做,卷积层保持float32。因为卷积层的量化需要校准数据,流程更复杂,而全连接层参数量大,量化收益最明显。
提示:量化后的模型在CPU上推理加速明显,但在GPU上可能反而变慢,因为GPU对int8的支持不如CPU成熟。部署前一定要在目标硬件上实测。
6. 我在反复实验里攒下的几条实在经验
第一,随机种子一定要固定。torch.manual_seed(42)、np.random.seed(42)、random.seed(42)三处都要设,否则两次实验的差异可能比改进本身还大,你根本分不清是改动生效了还是运气好。
第二,验证集要从训练集里划,不要用测试集调参。CIFAR-10的测试集只有10000张,反复在上面调参会让结果虚高。正确做法是从50000张训练集里划出5000张做验证,最终提交前再用全部训练集重训一次。
第三,训练日志要记全。除了损失和准确率,还要记学习率、梯度范数、每层激活值的均值方差。出问题的时候,这些信息能帮你快速定位。我习惯用TensorBoard或WandB,曲线一拉出来,哪里异常一目了然。
第四,不要迷信单一技巧。数据增强、网络结构、优化器、正则化,这几个维度要配合着调。单独把某一个拉到极致,收益会递减。比如你已经有很强的数据增强,再加Dropout可能就过了;反过来,如果数据增强很弱,加Dropout也压不住过拟合。
第五,复现别人的结果时,先跑通再改动。很多人拿到一个开源代码,上来就改结构改参数,结果跑崩了也不知道是改坏了还是原本就有问题。正确顺序是:原样跑通,确认数字对得上,再动刀。这个习惯能省下大量排查时间。
最后说一个心态上的体会:CIFAR-10刷到95%以上之后,每涨0.1个点都要付出成倍的努力。这时候与其死磕单模型,不如把精力放在理解为什么某些技巧有效、什么场景下该用什么组合。这些判断力比多出来的零点几个点值钱得多。