ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

CIFAR-10图像分类实战:从基础CNN到96%准确率的深度学习工程指南

CIFAR-10图像分类实战:从基础CNN到96%准确率的深度学习工程指南

1. 这个项目到底在做什么

CIFAR-10 这个数据集在图像分类圈子里算是“老熟人”了。它包含 10 个类别、共 6 万张 32×32 的彩色小图,其中 5 万张训练、1 万张测试。类别分别是飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车。每类 6000 张,分布非常均衡,所以它常被拿来当作图像分类的“入门基准”和“模型试炼场”。

我第一次接触这个数据集的时候,觉得 32×32 能有什么难度,结果上手一跑,基础 CNN 也就 70% 出头的准确率,离人类标注一致性(大概 94% 左右)差得远。后来才慢慢摸清楚,这个小数据集里藏着不少门道:分辨率低导致类间差异被压缩,猫和狗、鹿和马、飞机和船这些类别在低像素下极易混淆;再加上训练集只有 5 万张,模型很容易过拟合。所以这个项目表面上是“跑个分类”,实际上是一次完整的深度学习工程训练——从数据增强、模型选型、训练策略到集成推理,每一步都有讲究。

这篇文章适合谁看?如果你已经会用 PyTorch 或 TensorFlow 搭一个简单的卷积网络,想把这个项目从“能跑”做到“能打”,那下面的内容应该对你有用。我会把整个流程拆开讲,包括我踩过的坑、调参时的思考过程,以及那些文档里不会写的细节。目标很明确:让你看完之后,能自己复现一套稳定在 93% 以上准确率的方案,并且知道每一步为什么这么做。

2. 整体方案设计与选型思路

2.1 为什么不能只堆一个 CNN 就完事

很多人做 CIFAR-10 的第一反应是:搭一个 VGG 风格的卷积网络,加几层 BatchNorm,用 Adam 跑个几十轮,看看能到多少。这个思路没错,但天花板很明显。我实测过,一个 6 层卷积 + 3 层全连接的网络,不做任何增强,大概在 75% 到 78% 之间震荡;加了随机裁剪和翻转之后能到 85% 左右;再往上走,就需要更系统的设计了。

核心问题在于:CIFAR-10 的图片太小,模型容量和正则化之间的平衡非常微妙。容量不够,欠拟合;容量太大,过拟合来得飞快。所以方案设计的第一个决策点就是:用什么样的骨干网络。

我的选择是WideResNet(WRN-28-10)或者PreAct ResNet-18作为 baseline。原因有三:第一,残差连接在深层网络中能有效缓解梯度消失,让训练更稳定;第二,WideResNet 通过增加通道数而不是深度来提升容量,在 CIFAR 这种小图上比单纯加深更划算;第三,这两个结构在社区里有大量验证过的超参配置,省去了从零摸索的时间。

如果你更倾向于 Transformer 路线,ViT 在小数据集上需要很强的数据增强和正则化才能追上 CNN,训练成本也更高。除非你想顺便练手 Transformer,否则不建议作为第一版方案。

2.2 数据增强:小数据集的生命线

CIFAR-10 只有 5 万张训练图,不做增强几乎必然过拟合。我常用的增强组合是:

  • 随机裁剪(RandomCrop):先 padding 4 个像素,再随机裁回 32×32。这个操作模拟了平移不变性,让模型不会死记硬背某个位置的特征。
  • 随机水平翻转(RandomHorizontalFlip):概率 0.5。注意,翻转对“船”和“飞机”这类左右对称的类别影响不大,但对“猫”“狗”这种有方向性的类别是有帮助的。
  • Cutout / Random Erasing:随机遮挡一块区域,强迫模型不要依赖局部特征。我一般用 Cutout,长度设为 8 或 16,效果比不加强不少。
  • MixUp 或 CutMix:这两个是进阶操作,能把准确率再推 1 到 2 个百分点,但训练轮数需要相应增加。MixUp 的 alpha 通常设 0.2 或 1.0,CutMix 的 alpha 设 1.0。

这里有个细节:增强的强度要和训练轮数匹配。如果你只跑 50 轮,加太强的增强反而会让模型欠拟合。我一般跑 200 轮以上时才上 MixUp + Cutout 的组合。

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

优化器我用SGD + Momentum居多,momentum 设 0.9,weight decay 设 5e-4。为什么不用 Adam?因为在 CIFAR 这种任务上,调好超参的 SGD 往往能比 Adam 高 0.5 到 1 个百分点,而且泛化性更好。Adam 收敛快,但容易在后期震荡。

学习率调度我用Cosine Annealing,初始学习率 0.1,配合 warmup(前 5 轮从 0 线性升到 0.1)。Cosine 的好处是后期学习率平滑降到接近 0,模型能稳定收敛,不需要手动在特定轮数降学习率。如果你用 StepLR,记得在 100 轮和 150 轮各降一次,降 10 倍。

还有一个技巧:Label Smoothing,把硬标签换成 0.1 的平滑标签。这个操作能防止模型对某个类别过度自信,在 CIFAR-10 上通常能涨 0.3 到 0.5 个百分点。

3. 核心细节与实操要点

3.1 数据加载与预处理中的隐藏坑

数据加载看起来简单,但有几个地方容易出问题。第一,归一化参数。CIFAR-10 的全局均值和标准差大约是 (0.4914, 0.4822, 0.4465) 和 (0.2470, 0.2435, 0.2616)。你可以直接算,也可以用现成的。我建议自己算一遍,确保和你的数据版本一致。

第二,数据加载的 num_workers。在本地机器上,设成 4 或 8 比较合适;在 Kaggle 的 notebook 环境里,设成 2 就行,设太大反而会因为进程切换拖慢速度。另外,pin_memory 设为 True 能加速 GPU 传输,但如果你用的是 CPU 训练,这个参数没意义。

第三,验证集的划分。CIFAR-10 官方只给了训练集和测试集,但你不能直接用测试集调参,否则会过拟合测试集。我的做法是从 5 万张训练集里 stratified 抽 5000 张作为验证集,剩下的 45000 张用来训练。等所有超参定下来之后,再用全部 5 万张重新训练一遍,最后在测试集上跑一次。

from torch.utils.data import DataLoader, random_split from torchvision import datasets, 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)), transforms.RandomErasing(p=0.5, scale=(0.02, 0.2)) ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) train_set = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) test_set = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test) # 从训练集中划出验证集 val_size = 5000 train_size = len(train_set) - val_size train_subset, val_subset = random_split(train_set, [train_size, val_size])

注意:RandomErasing 要放在 ToTensor 之后,因为它操作的是张量而不是 PIL 图像。顺序搞错会直接报错。

3.2 模型结构的关键参数

以 WideResNet-28-10 为例,它的结构是:3 个 stage,每个 stage 的通道数分别是 160、320、640,深度 28 层,width factor 为 10。这个配置在 CIFAR-10 上大概有 3600 万个参数,训练时显存占用在 8GB 左右(batch size 128)。如果你的显存不够,可以把 width factor 降到 4 或 2,准确率会掉 1 到 2 个百分点,但训练速度快很多。

另一个选择是 PreAct ResNet-18,参数少一些,大概 1100 万,训练更快,准确率也能到 94% 左右(配合好的增强和调度)。我一般先用 ResNet-18 快速验证流程,确认没问题后再上 WideResNet 冲更高的分数。

模型初始化也有讲究。PyTorch 默认的 Kaiming 初始化对 ReLU 网络是合适的,但如果你用了其他激活函数(比如 GELU 或 SiLU),最好手动调整初始化方式。我试过在 CIFAR-10 上用 SiLU 替换 ReLU,准确率有 0.2 左右的提升,但训练时间会增加 10% 左右,看你怎么权衡。

3.3 训练循环中的细节控制

训练循环里最容易忽略的是梯度裁剪。虽然 CIFAR-10 上梯度爆炸不常见,但如果你用了 MixUp 或者学习率设得比较大,梯度裁剪能防止训练崩溃。我一般设 max_norm 为 5.0 或 10.0。

另一个细节是EMA(指数移动平均)。在训练后期,用 EMA 对模型参数做平滑,能显著提升最终准确率。具体做法是维护一份影子参数,每次更新时按 decay 比例混合。decay 一般设 0.999 或 0.9999。我实测下来,EMA 能带来 0.5 到 1 个百分点的提升,而且几乎不增加训练成本。

class EMA: def __init__(self, model, decay=0.999): self.model = model self.decay = decay self.shadow = {k: v.clone().detach() for k, v in model.state_dict().items()} def update(self): for k, v in self.model.state_dict().items(): if v.dtype.is_floating_point: self.shadow[k] = self.decay * self.shadow[k] + \ (1 - self.decay) * v else: self.shadow[k] = v def apply(self): self.model.load_state_dict(self.shadow)

提示:EMA 的 decay 不要设得太接近 1,否则影子参数更新太慢,在训练轮数不多的情况下反而拖后腿。200 轮训练用 0.999 比较合适。

4. 完整实操流程与关键环节

4.1 环境准备与依赖安装

我习惯用 PyTorch 生态,版本选 1.13 或 2.0 以上都行。如果你在 Kaggle 上跑,直接开 GPU notebook,环境已经配好了。本地的话,建议用 conda 建一个独立环境,避免和系统 Python 冲突。

conda create -n cifar10 python=3.9 conda activate cifar10 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy matplotlib tqdm tensorboard

TensorBoard 用来监控训练曲线,比 print 直观得多。你可以在训练循环里加 SummaryWriter,记录 loss、accuracy、学习率等指标。

4.2 训练脚本的核心结构

一个完整的训练脚本包含这几个部分:数据加载、模型定义、损失函数、优化器、学习率调度、训练循环、验证循环、模型保存。我把它拆成一个 train.py 和一个 model.py,方便复用。

训练循环里,每个 epoch 做这几件事:前向传播、计算损失、反向传播、梯度裁剪、参数更新、EMA 更新。每跑完一个 epoch,在验证集上算一次准确率,记录最好的模型。如果连续 20 个 epoch 验证准确率没提升,就提前停止,避免浪费时间。

import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = WideResNet(depth=28, widen_factor=10, num_classes=10).to(device) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) 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 images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() ema.update() scheduler.step() # 验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() val_acc = correct / total print(f'Epoch {epoch}, Val Acc: {val_acc:.4f}')

4.3 推理与提交文件的生成

训练完成后,用 EMA 参数或者最好的 checkpoint 在测试集上跑推理。注意,测试集的预处理要和验证集一致,不能加随机增强。推理时把模型设为 eval 模式,关闭 dropout 和 batchnorm 的统计更新。

如果你要生成 Kaggle 提交文件,格式通常是两列:id 和 label。CIFAR-10 的测试集有 1 万张,按顺序编号即可。我一般会同时跑几个模型的预测,然后做软投票集成,准确率能再涨 0.5 到 1 个百分点。

model.eval() all_preds = [] with torch.no_grad(): for images, _ in test_loader: images = images.to(device) outputs = model(images) _, predicted = outputs.max(1) all_preds.extend(predicted.cpu().numpy()) import pandas as pd submission = pd.DataFrame({'id': range(len(all_preds)), 'label': all_preds}) submission.to_csv('submission.csv', index=False)

4.4 集成策略与最终提升

单模型跑到 94% 左右之后,想再往上走就得靠集成。我常用的集成方式有三种:权重平均、预测投票、多尺度测试。

权重平均是把几个模型的参数直接平均,前提是这些模型结构相同、训练轨迹相似。预测投票是每个模型独立预测,然后取众数或平均概率。多尺度测试是把测试图缩放到不同尺寸分别推理,再融合结果。我试过把 32×32 和 36×36 两个尺度的预测平均,准确率能涨 0.3 左右。

还有一个技巧是TTA(测试时增强):对测试图做水平翻转,分别推理后取平均。这个操作简单,几乎不增加成本,通常能涨 0.2 到 0.4 个百分点。

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

5.1 训练不收敛或准确率震荡

这是最常见的问题。可能的原因有:学习率太大、batch size 太小、数据归一化没做对、模型初始化有问题。我的排查顺序是:先把学习率降到 0.01 跑几轮,看 loss 是否下降;如果还不降,检查数据预处理,打印几张图看看归一化后的数值范围;再不行就换一个简单的模型(比如 3 层 CNN)验证数据管道是否正常。

另一个容易被忽略的点是标签顺序。如果你自己写 DataLoader,确保标签和图片是对应的。我见过有人把 shuffle 设成 False 但忘了同步标签,结果训练完全乱套。

5.2 过拟合来得太快

训练准确率 99%,验证准确率只有 80%,这是典型的过拟合。解决办法按优先级排:第一,加数据增强,尤其是 Cutout 和 MixUp;第二,加 weight decay,从 5e-4 提到 1e-3;第三,加 dropout,但注意在卷积层后面加 dropout 效果不如在全连接层加;第四,减小模型容量,把 width factor 从 10 降到 4。

我个人的经验是,CIFAR-10 上 weight decay 设 5e-4 到 1e-3 比较合适,再大就会欠拟合。Dropout 在 WideResNet 上一般加在最后全连接层之前,概率 0.3 左右。

5.3 显存不够怎么办

WideResNet-28-10 在 batch size 128 时大概占 8GB 显存。如果你的卡只有 6GB,可以试试这些方法:把 batch size 降到 64,用梯度累积模拟大 batch;把模型换成 ResNet-18;用混合精度训练(AMP),能省 30% 到 40% 显存。

混合精度训练在 PyTorch 里用 torch.cuda.amp 就行,注意 loss scaling 要开,否则梯度会下溢。我实测下来,AMP 对准确率几乎没有影响,但训练速度能快 20% 左右。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
loss 不下降学习率过大或过小打印每轮 loss尝试 0.01、0.1、0.001
验证准确率震荡batch size 太小检查 batch size增大到 128 或 256
训练准确率高但验证低过拟合对比训练和验证曲线加增强、加正则
显存溢出模型太大或 batch 太大查看 nvidia-smi降 batch、用 AMP
推理结果全同一类模型没加载好检查 state_dict重新加载 checkpoint
训练速度慢num_workers 设太小查看 CPU 利用率调到 4 或 8

提示:遇到问题先别急着改模型,把数据管道和训练循环检查一遍,八成的问题都出在这两个地方。

5.5 几个我踩过的坑

第一个坑:验证集和测试集混用。我早期为了省事,直接用测试集调超参,结果提交上去分数比本地低了一大截。后来老老实实划验证集,虽然多花了一点时间,但分数稳定多了。

第二个坑:学习率调度没配合训练轮数。我用 Cosine Annealing 但只跑了 50 轮,学习率还没降到足够低就停了,模型没完全收敛。后来把轮数加到 200,准确率直接涨了 2 个点。

第三个坑:EMA 的 decay 设得太高。我一开始设 0.9999,结果影子参数几乎不动,验证准确率反而比原始模型低。后来改成 0.999,效果就正常了。

第四个坑:数据增强顺序搞错。RandomErasing 必须放在 ToTensor 之后,否则会报错。这个坑我踩了两次才记住。

6. 从 94% 到 96% 的进阶思路

如果你已经把上面的流程跑通,准确率稳定在 94% 左右,想再往上走,可以试试这几个方向。

第一,换更强的骨干网络。比如 PyramidNet、DenseNet-BC、或者 ResNeXt。这些结构在 CIFAR-10 上都有 95% 以上的表现,但训练成本更高。PyramidNet-110-270 大概需要 12GB 显存,训练 300 轮左右能到 95.5%。

第二,用 AutoAugment 或 RandAugment。这两个是自动搜索出来的增强策略,比手工设计的增强组合更强。RandAugment 只需要两个超参(N 和 M),调起来比 AutoAugment 方便。我实测 RandAugment 能涨 0.5 到 1 个百分点。

第三,知识蒸馏。先训练一个大的教师模型(比如 WideResNet-28-10),然后用它的软标签去训练一个小的学生模型。学生模型虽然参数少,但准确率能接近教师模型。这个技巧在部署时特别有用。

第四,半监督学习。如果你能拿到额外的无标签数据,用 FixMatch 或 MixMatch 能把准确率推到 97% 以上。不过 CIFAR-10 本身没有额外无标签数据,这个方向更适合你有自己数据集的情况。

第五,模型集成。把 3 到 5 个不同结构的模型预测结果做平均,准确率通常能到 96% 左右。集成的时候注意,模型之间的差异性越大,集成效果越好。比如一个 ResNet、一个 DenseNet、一个 WideResNet,比三个 WideResNet 集成效果好。

我在实际项目中的体会是,CIFAR-10 这个数据集虽然小,但它是检验你深度学习工程能力的好标尺。从数据管道到模型设计,从训练策略到推理优化,每一步都能拉开差距。把这一套流程跑熟之后,换到更大的数据集(比如 ImageNet 或者你自己业务里的图像数据)上,思路是相通的。

最后分享一个小技巧:训练的时候把 TensorBoard 打开,实时看 loss 和 accuracy 曲线。很多时候你不需要等训练跑完,看曲线的前 20 轮就能判断这次实验有没有希望。如果前 20 轮验证准确率还在 60% 以下晃悠,大概率是超参或者数据有问题,早点停掉省时间。

RELATED READING

延伸阅读

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