ResNet实战:用PyTorch在CIFAR-10上稳定达到92%准确率的完整流程
简介资源包面向深度学习初学者与图像分类实践者围绕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 第一层是个 stride2、卷积核 7×7 的大卷积用在 32×32 输入上会把特征图压到 16×16信息损失太大。常见做法是用一个 3×3 卷积替换且不下采样。残差块内部的设计也分两类。层数少的 ResNet18/34用 BasicBlock两个 3×3 卷积加一个跳跃连接层数多的 ResNet50/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%ResNet50Bottleneck约 2300 万较长93%~94.5%缩小版 ResNet50约 500 万短93% 左右单卡 8GB 显存下ResNet18 的 batch size 可以开到 128ResNet50 只能开到 64 左右。CIFAR-10 分类的准确率上限大约在 95%但你用 ResNet18 想推到 95% 需要非常细致的增强和调度用 ResNet50 会容易一些但训练时间长很多。如果只跑通流程ResNet18 是性价比最高的选择这也是我这篇笔记主推的结构。3. 从数据集到 DataLoaderCIFAR-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, trainTrue, downloadTrue, transformraw_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(fmean: {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, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean, std) ]) test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean, std) ])RandomCrop 的 padding4 是把 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_size128, shuffleTrue, num_workers4, # 取决于 CPU 核心数一般设为 4 或 8 pin_memoryTrue # 把数据锁页加速 CPU 到 GPU 的拷贝 ) test_loader torch.utils.data.DataLoader( testset, batch_size100, shuffleFalse, num_workers2, pin_memoryTrue )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, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) 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_size1, stridestride, biasFalse), 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_classes10): super().__init__() self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) # 四个 stage每层两个 BasicBlock通道数逐级翻倍 self.layer1 self._make_layer(64, 64, 2, stride1) self.layer2 self._make_layer(64, 128, 2, stride2) self.layer3 self._make_layer(128, 256, 2, stride2) self.layer4 self._make_layer(256, 512, 2, stride2) 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)残差块里 biasFalse 不是随意写的因为后面接了 BatchNorm卷积的偏置会被 BN 的均值平移消掉留着只是浪费参数。stride2 的下采样放在第一个卷积上信息密度直接减半是标准做法。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(), lr0.1, momentum0.9, weight_decay5e-4) # cosine 退火从 0.1 平滑降到接近 0不需要手动设计衰减节点 scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) 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 的 momentum0.9 是默认配置weight_decay5e-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 而恰好是 1000ImageNet 默认类数。我第一次跑通时就是用 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 或 200shuffle 必须为 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 上用 SGDmomentum 是正确选择Adam 适合 Transformer 或者大模型。如果一定要用 Adam可以把初始学习率调到 0.001weight_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 对比效果动手改一遍结构对残差连接的理解会比看十遍论文都深。希望帮到你。本文还有配套的精品资源点击获取