简介:资源包面向深度学习初学者与图像分类实践者,围绕ResNet在CIFAR-10数据集上的分类任务,提供了完整的算法实现与使用说明。包体共5个文件,包含4个Python脚本,分别对应模型结构搭建、数据预处理、训练流程与推理预测;另附1个Markdown文档,用于讲解代码结构与运行方式。整个压缩包仅3KB,内容精炼,便于快速阅读和动手修改。实现中覆盖残差块跳跃连接、批量归一化、全局平均池化、交叉熵损失和SGD优化器,并包含数据增强与学习率衰减策略,可直接训练并评估CIFAR-10分类效果。已有725人学习下载,适合希望掌握ResNet核心原理并快速复现图像分类任务的读者。
1. 用 ResNet 做 CIFAR-10 分类:一个能用 GPU 跑通的算法实现
图像分类是深度学习的"Hello World",但用 ResNet 做 CIFAR-10 又是另一个量级的事。别小看这个 32×32 像素的小数据集,它逼着你认真对待数据增强、学习率调度、BatchNorm 的细节。很多人在 MNIST 上跑通的代码,一换到 CIFAR-10 上准确率直接掉到 60%——不是模型不行,是你没把 ResNet 的训练收敛逻辑搞清楚。这套算法实现的目标很朴素:在 CIFAR-10 上把测试集准确率稳定推到 92% 以上,同时代码结构还能复用到其他分类任务上。适合刚做完前置课程、想认真跑通一个真实卷积网络训练的开发者,也适合准备移植到自定义数据集上做迁移学习的熟手。关键不是背一遍 ResNet 结构,而是搞懂每个模块为什么长这样、训练时哪些参数在真正起作用。
2. ResNet 解决的是什么问题:退化现象、残差块与 CIFAR-10 上的结构选型
2.1 退化问题:为什么堆叠层数反而让网络变笨
按理说,网络越深,表达能力强,效果应该越好。但实践里的现象是:在 CIFAR-10 这种不算大的数据集上,直接把层数从 20 加到 56,训练集准确率反而下降。这不是过拟合,是网络根本"学不进去"。常见解释是梯度弥散,但 BatchNorm 和合适的初始化已经把梯度弥散压住了一部分,真正卡住的是优化困难——深层网络的恒等映射很难通过堆叠非线性层直接学出来。
ResNet 的核心回应是:既然学一个恒等映射都难,那就把输入直接跳过几层,让这些层去学"残差"。公式是:
- 普通层:输出 = F(x)
- 残差块:输出 = F(x) + x
这个设计让网络至少不会比浅层更差,因为 F(x) 直接学成 0 就退化成恒等映射。实际训练里,F(x) 学成 0 的情况很少,但这个结构极大降低了深层网络的优化难度。我理解 ResNet 不是让网络学会"跳跃",而是给优化器一个更平缓的路径——梯度可以通过跳跃连接直接回流到浅层,避免逐层衰减到底。
2.2 残差块内外的关键设计:先降采样再卷积是省显存的正路
CIFAR-10 的图是 32×32,和 ImageNet 的 224×224 相比小得多,直接套用面向 ImageNet 的 ResNet 结构非常浪费。标准 ResNet 第一层是个 stride=2、卷积核 7×7 的大卷积,用在 32×32 输入上会把特征图压到 16×16,信息损失太大。常见做法是用一个 3×3 卷积替换,且不下采样。
残差块内部的设计也分两类。层数少的 ResNet(18/34)用 BasicBlock:两个 3×3 卷积加一个跳跃连接;层数多的 ResNet(50/101/152)用 Bottleneck:先 1×1 降维、再 3×3 卷积、再 1×1 升维,把计算量压住。我在 CIFAR-10 上一般用 ResNet18 或改小的 ResNet34,原因有两个:一是 5 万张训练图对小网络就够用,二是训练一轮时间短,方便调参迭代。
2.3 在 CIFAR-10 上到底该选哪个 ResNet 变体
如果我在 CIFAR-10 上做对照实验,会测这么几个候选:
| 候选结构 | 参数量级 | 训练一轮耗时参考 | 预期测试准确率 |
|---|---|---|---|
| ResNet18 | 约 1100 万 | 短 | 92%~93% |
| ResNet34 | 约 2100 万 | 中 | 93%~94% |
| ResNet50(Bottleneck) | 约 2300 万 | 较长 | 93%~94.5% |
| 缩小版 ResNet50 | 约 500 万 | 短 | 93% 左右 |
单卡 8GB 显存下,ResNet18 的 batch size 可以开到 128,ResNet50 只能开到 64 左右。CIFAR-10 分类的准确率上限大约在 95%,但你用 ResNet18 想推到 95% 需要非常细致的增强和调度,用 ResNet50 会容易一些但训练时间长很多。如果只跑通流程,ResNet18 是性价比最高的选择,这也是我这篇笔记主推的结构。
3. 从数据集到 DataLoader:CIFAR-10 预处理里最容易翻车的三个细节
3.1 归一化参数不能瞎填:CIFAR-10 的均值和标准差是算出来的
很多教程把归一化写成 transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),这是 MNIST 的写法学来的,不是 CIFAR-10 的最优解。CIFAR-10 的三通道 RGB 均值和标准差是确定的,应该在代码里算,或者用社区公认的固定值。下面这段代码先算好了再存成常量:
import torch import torchvision import torchvision.transforms as transforms # 第一次下载后,建议先跑一遍统计,把结果存成常量 raw_transform = transforms.ToTensor() trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=raw_transform) # 注意:这里需要对整个训练集做一次遍历 mean = 0.0 std = 0.0 total = 0 for images, _ in trainset: # images 形状是 (3, 32, 32),需要按通道统计 mean += images.mean(dim=(1, 2)) std += images.std(dim=(1, 2)) total += 1 mean /= total std /= total print(f"mean: {mean}, std: {std}")统计完你会得到类似 mean 在 0.49 左右、std 在 0.25 左右的值。之后的训练和验证都必须用这套参数,否则数据分布偏移,模型收敛速度明显变慢。注意验证集用同样参数做归一化,不要单独统计验证集的均值和标准差——那是数据泄露。
3.2 数据增强:CIFAR-10 从 90% 到 93% 的关键差距
没有增强的 ResNet18 在 CIFAR-10 上大约能到 90%,加上了随机裁剪和水平翻转之后能推到 93%。增强的本质是制造更多样化的训练样本,同时保持语义不变。CIFAR-10 的 32×32 分辨率意味着你不能做大的几何变换,常用的组合是:
train_transform = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean, std) ]) test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean, std) ])RandomCrop 的 padding=4 是把 32×32 的图先扩成 40×40,再随机裁回 32×32。padding 不是随便定的,4 是 CIFAR-10 社区的常用经验值:太小起不到增强效果,太大会裁到无关背景。RandomHorizontalFlip 对 CIFAR-10 有效,因为大部分类别(汽车、猫、狗、飞机)左右翻转不改变语义。但像数字识别这类任务就不能用水平翻转,换成上下翻转或旋转更安全。
3.3 DataLoader 的 num_workers 和 pin_memory 设不对,训练空转
很多人在 CPU 和 GPU 之间来回搬运数据,训练过程一半时间花在读取上。DataLoader 的配置直接影响 GPU 利用率:
train_loader = torch.utils.data.DataLoader( trainset, batch_size=128, shuffle=True, num_workers=4, # 取决于 CPU 核心数,一般设为 4 或 8 pin_memory=True # 把数据锁页,加速 CPU 到 GPU 的拷贝 ) test_loader = torch.utils.data.DataLoader( testset, batch_size=100, shuffle=False, num_workers=2, pin_memory=True )num_workers 设得太高反而慢,因为进程切换开销变大。在我用过的服务器上,4 到 8 之间表现最好。pin_memory 在显存紧张时可能报错,如果遇到 CUDA out of memory,先把 pin_memory 关掉试试,因为锁页内存不参与虚拟内存交换,物理内存不够时反而拖累系统。另外,shuffle 只在训练集上开,验证集必须关掉,这是为了数值复现和稳定的评估指标。
4. 用 PyTorch 搭建 ResNet:模型代码、训练循环与收敛状态判断
4.1 不到一百行实现 CIFAR-10 可用的 ResNet
我不会直接用 torchvision 里现成的 resnet18,而是把残差块单独拆出来,方便后面改宽度、改层数做实验。代码和官方实现逻辑一致,去掉了面向 ImageNet 的部分:
import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 捷径连接:维度或分辨率变化时用 1x1 卷积对齐 self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) 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 ResNet18(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) # 四个 stage,每层两个 BasicBlock,通道数逐级翻倍 self.layer1 = self._make_layer(64, 64, 2, stride=1) self.layer2 = self._make_layer(64, 128, 2, stride=2) self.layer3 = self._make_layer(128, 256, 2, stride=2) self.layer4 = self._make_layer(256, 512, 2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride): layers = [] layers.append(BasicBlock(in_channels, out_channels, stride)) for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels, 1)) return nn.Sequential(*layers) def forward(self, x): x = F.relu(self.bn1(self.conv1(x))) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) return self.fc(x)残差块里 bias=False 不是随意写的,因为后面接了 BatchNorm,卷积的偏置会被 BN 的均值平移消掉,留着只是浪费参数。stride=2 的下采样放在第一个卷积上,信息密度直接减半,是标准做法。AdaptiveAvgPool2d 把任意尺寸的特征图压成 1×1,替代了 Flatten 前的全局平均池化。如果你想把 ResNet18 改成 ResNet34,只需要在 _make_layer 的第二个参数里把 2 改成 3,其他不用动。
4.2 训练循环:损失函数、优化器、学习率调度缺一不可
CIFAR-10 的训练代码很多人抄来就用,但三个地方必须按你自己的情况调整:损失函数用交叉熵不要动;优化器用 SGD 带动量是经典组合,Adam 虽然收敛快但最终准确率经常低一个点;学习率调度用 cosine 退火比固定步长衰减更稳定。完整训练循环框架如下:
import torch.optim as optim model = ResNet18().cuda() criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) # cosine 退火:从 0.1 平滑降到接近 0,不需要手动设计衰减节点 scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) scaler = torch.cuda.amp.GradScaler() # 混合精度训练,显存不够时开启 def train_one_epoch(model, loader, optimizer, criterion, scaler): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() # 混合精度前向 + 反向 with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() running_loss += loss.item() _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return running_loss / len(loader), 100.0 * correct / total def evaluate(model, loader, criterion): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return 100.0 * correct / totalSGD 的 momentum=0.9 是默认配置,weight_decay=5e-4 是 ResNet 系列在 CIFAR-10 上的常用正则强度,调大容易欠拟合,调小容易过拟合。初始学习率 0.1 对 batch size 128 是合适的,如果你把 batch size 降到 64,学习率最好也减半,否则 loss 会在前几个 epoch 震荡。CosineAnnealingLR 的 T_max 必须和总 epoch 数一致,设错了学习率曲线就不完整。
4.3 训练中如何实时判断模型没跑偏
只看 training loss 下降不算跑通,至少要看三组曲线:训练 loss、训练准确率、验证准确率。如果训练准确率一直卡在 50% 上下,不是模型坏了,大概率是数据增强没有生效或者标签错位。如果训练准确率接近 100% 但验证准确率只有 80%,这是过拟合——先加 weight_decay,再加增强,不是换模型。最理想的状态是训练和验证的准确率差在 2 到 3 个点以内,说明模型的泛化能力正常。
我习惯每 5 个 epoch 打印一次准确率,每 50 个 epoch 存一次 checkpoint。存 checkpoint 时只存 model.state_dict(),不要直接存整个 model 对象,否则换了环境版本不同容易加载失败。用 100 个 epoch 训练,前 30 个 epoch 验证准确率大概在 85%,到 60 个 epoch 左右会摸到 90%,最后 20 个 epoch 的 cosine 退火阶段是真正爬升到 92%+ 的关键区间,前面急不来。
5. 避坑指南:ResNet 训练 CIFAR-10 的五个实战踩坑记录
5.1 现象:验证准确率卡在 10% 上下,跟随机猜没区别
原因:模型输出和标签没对上,最常见是 DataLoader 的 shuffle 状态混乱,或者模型接的全连接层输出尺寸不是 10 而恰好是 1000(ImageNet 默认类数)。我第一次跑通时就是用 torchvision 的 resnet18 忘了改 num_classes,准确率死死卡在 9.8%。
解决:检查 model.fc.out_features 是否等于 10;在 evaluate 函数里输入一个固定 batch,手工核对 predicted 的前 10 个值和真实标签的对应关系。如果用的是 torchvision 预训练模型做微调,还要确认训练集和预训练数据集的类别索引是否一致——CIFAR-10 的类别顺序和 ImageNet 的 1000 类是两套排序。
5.2 现象:训练 loss 正常下降,验证准确率波动剧烈,每 5 个 epoch 差 5 个点以上
原因:验证集 batch size 太小或者没有固定随机种子。CIFAR-10 测试集只有 1 万张,batch size 设为 32 时,一轮验证只有 313 个 batch,样本方差过大。
解决:验证集 batch size 设 100 或 200,shuffle 必须为 False。在 train 和 evaluate 的函数入口都加上 torch.manual_seed(0)(如果用了 CUDA 还要加 torch.cuda.manual_seed_all(0)),保证每次验证的数值波动来自模型本身而不是采样噪声。注意固定随机种子必须在 DataLoader 创建之前生效,否则 DataLoader 的 shuffle 顺序还是每次不一样。
5.3 现象:换用 Adam 后准确率到 90% 就停住了,怎么调都上不去
原因:Adam 的自适应学习率在 CIFAR-10 这类中等规模小数据上,后期收敛速度比 SGD 慢。这不是 ResNet 的问题,是优化器特性和任务规模的匹配问题。
解决:CIFAR-10 上用 SGD+momentum 是正确选择,Adam 适合 Transformer 或者大模型。如果一定要用 Adam,可以把初始学习率调到 0.001,weight_decay 调到 1e-4,训练末期的准确率会比 SGD 低 1 到 2 个点,属于正常现象。另外注意 Adam 的 weight_decay 和 SGD 不是同一个含义,AdamW 才是解耦权重衰减,要用就统一用 AdamW。
5.4 现象:显存占用 8GB,训练到一半直接 CUDA out of memory
原因:batch size 太大,加上混合精度没有开启,或者输入图像没有归一化导致分辨率异常。CIFAR-10 的 32×32 本来很省显存,但如果你在某处误用了 F.interpolate 把图放大,显存会瞬间爆炸。
解决:先把 batch size 降到 64;再开混合精度(torch.cuda.amp);最后检查模型里有没有多余的中间变量被强制保留,比如在 forward 里额外存储了某个层的输出用于可视化。混合精度开启后,如果 loss 出现 NaN,检查是否未调用 scaler.update(),或者模型里存在数值不稳定的操作,比如没有 eps 的 LayerNorm。
5.5 现象:相同代码两次训练,准确率相差 2% 以上
原因:没有固定随机种子。数据加载顺序、增强的随机裁剪、权重初始化的随机性都会导致结果漂移。
解决:在训练脚本开头加上固定种子,包括 random.seed(0)、torch.manual_seed(0)、numpy.random.seed(0)。同时要把 CUDNN 的 benchmark 关掉,因为不同卷积算法在不同时刻可能被选中,导致结果不稳定。推荐加这两行:
torch.backends.cudnn.benchmark = False torch.backends.cudnn.deterministic = True注意,确定性模式会略微降低计算速度,如果对多次实验的一一复现要求不高,可以只固定 Python 和 PyTorch 的随机种子,benchmark 保持 True 来换取训练速度。
6. 进阶:用验证集曲线判断训练状态,以及把 ResNet 换成自己的数据集的正确姿势
6.1 验证集曲线比损失值更可信:如何看出模型是欠拟合还是过拟合
loss 曲线的绝对值在不同损失函数和不同数据规模下没有可比性,但验证准确率的曲线走势很直观。如果训练准确率持续上升但验证准确率在第 40 个 epoch 开始平台期,这是过拟合信号,优先做三件事:加强数据增强(比如加入 Cutout 或 Random Erasing)、加大 weight_decay(从 5e-4 调到 1e-3)、减少训练轮数或降低模型容量。如果两个准确率同步差 10 个点以上,这是欠拟合信号,提高学习率、换更宽的 ResNet 或者加深层数。
另一个实用技巧是观察每个 epoch 结束时的 loss 比值——训练 loss 和验证 loss 的比值接近 1 最好,比值超过 2 说明严重过拟合。归一化后 CIFAR-10 的交叉熵损失大约在 0.2 到 0.5 之间,如果训练 loss 掉到 0.05 以下,模型基本是在死记训练集,验证准确率不会再有明显提升。
6.2 把 ResNet18 移植到自定义数据集:微调代码里的三个改动点
CIFAR-10 只是验证板,实际工程里更多是把 ResNet 迁移到自己的数据集上。三个必须改的地方:
- 分类头:把 self.fc = nn.Linear(512, num_classes) 的 num_classes 替换成你的类别数。如果数据集很大,可以考虑加一层 Dropout(0.5) 在 fc 之前,抑制过拟合。
- 输入端:你的图像大概率不是 32×32,把 transforms.Resize((224, 224)) 加进预处理流程,或者在模型的第一层卷积里调整 stride 和 padding,避免特征图尺寸过早减半。
- 归一化参数:不能用 CIFAR-10 的 mean/std,必须按你自己的数据集重新统计。做法就是用 3.1 节的脚本遍历你的图片,把统计值替换掉。
加载预训练权重时要注意,torchvision 的 resnet18 权重是在 ImageNet 上预训练的。如果你的数据集和 ImageNet 差异很大,建议只加载 conv1、layer1、layer2 这几层,冻结前几层参数,只训练后面的层。CIFAR-10 这样的小数据集从头训练完全没问题,但自定义数据集如果每类只有几百张图,预训练权重带来的提升会非常显著。
6.3 我保留的一个习惯:训练结束后做一次「去掉增强」的对比验证
这是我从某次比赛里学来的习惯。训练结束后,用完全相同的权重跑一遍不带 RandomCrop 和 RandomHorizontalFlip 的测试集,如果准确率比带增强时低 1 个点以上,说明模型对增强带来的平移和翻转变化敏感,推理阶段也应该保留对应的 TTA(测试时增强)。CIFAR-10 上我实测过,ResNet18 带 TTA 能再提升 0.2 到 0.3 个点,虽然不多,但在竞赛里就是排名的差距。TTA 的代码量不大,本质是把测试图做多次增强后取预测均值。
这套 ResNet 做 CIFAR-10 的实现,从头到尾跑完差不多一个下午。你会踩的坑我都踩过,上面写的每一条都是真金白银换来的血泪经验。建议你用这份代码跑通后,再自己改一个 ResNet34 对比效果,动手改一遍结构,对残差连接的理解会比看十遍论文都深。希望帮到你。
本文还有配套的精品资源,点击获取