公司动态
PyTorch从零实现ResNet18:CIFAR-10图像分类实战与训练调参技巧
简介这是一份从零开始使用PyTorch搭建ResNet18并训练CIFAR-10数据集的完整项目源码无需预训练模型即可复现测试集95.46%的准确率非常适合深度学习初学者体验完整的图像分类流程。CIFAR-10包含10个类别共6万张32×32彩色图像是图像分类领域常用的基准数据集配合ResNet18残差结构能直观感受网络加深与梯度流动的设计思路。资源共7个文件包含5个Python工具脚本和2个Markdown说明文档压缩包仅10KB分别实现数据读取与Cutout增强、残差网络定义、训练与测试流程代码注释清晰目录结构简单便于直接运行和二次修改。截至当前已有1915人学习下载。借助该资源读者可以掌握残差块的核心思想、CIFAR-10数据加载与归一化方法并了解交叉熵损失、SGD/Adam优化器、权重衰减、随机翻转裁剪等训练细节同时资源还展示了模型保存、加载与准确率评估的完整闭环可作为第一个独立完成的深度学习分类项目的实用参考。1. 项目概述与最终效果先把结论放在前面我用纯 PyTorch 从零构建了 ResNet18 网络架构在 CIFAR-10 数据集上从头开始训练没有加载任何 ImageNet 预训练权重测试集最终准确率跑到了 95.46%。这个成绩在 CIFAR-10 这个数据集上算不上 SOTA但对于一套完全从零开始手写、没有额外技巧堆叠的训练流程来说是一个很扎实的结果。为什么我会写这个项目因为 CIFAR-10 几乎是所有深度学习入门者绕不过去的一个坎——60K 张 32x32 的彩色小图10 个类别训练集 50K 张、测试集 10K 张。这个数据集的规格决定了它非常适合用来做网络结构和训练技巧的验证模型不够强会欠拟合训练配置不够精细会过拟合所以它是一个很好的训练功力的试金石。这个项目适合谁看如果你已经掌握了 PyTorch 的基本操作张量、自动求导、简单 MLP但还没有完整跑通过一个现代 CNN 架构的训练流程这篇内容可以直接帮你抄作业。我会把从环境搭建、数据增强、网络手写、训练调参到最终测试评估的完整链路都拉出来讲一遍每个环节为什么这么配、常见的坑是什么都会提到。2. 训练前的关键准备2.1 环境配置与依赖安装PyTorch 的环境搭建是整个训练流程的第一步也是最容易卡住人的地方。我的建议是不要用全局 Python 环境直接用 Anaconda 创建独立环境避免后期乱七八糟的依赖冲突。conda create -n cifar10 python3.9 conda activate cifar10安装 PyTorch 时最核心的问题是 CUDA 版本匹配。你可以先用nvidia-smi查看自己显卡支持的 CUDA 版本然后到 PyTorch 官网选择对应的安装命令。我本机是 CUDA 11.8用的安装命令是pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118装完之后建议花 10 秒钟验证一下 CUDA 是否真的可用import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))这个验证非常关键。我见过不少人装完 PyTorch 后没检查训练时才发现跑在 CPU 上一个 epoch 要等半小时。另外说一句如果显卡配置一般比如 4GB 显存也不需要慌CIFAR-10 的 32x32 输入分辨率很小ResNet18 加 batch size 128 大概只占用 2~3GB 显存大多数入门级显卡都能扛得住。2.2 数据集下载与加载机制CIFAR-10 在 torchvision 里直接有封装好的数据集类下载很方便。需要注意的一点是如果网络环境不好直接在线下载可能会很慢甚至失败建议先手动把数据集下载好放到指定目录然后通过downloadFalse加载。from torchvision import datasets from torchvision import transforms transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean(0.4914, 0.4822, 0.4465), std(0.2023, 0.1994, 0.2010)), ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean(0.4914, 0.4822, 0.4465), std(0.2023, 0.1994, 0.2010)), ]) train_dataset datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransform_train) test_dataset datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransform_test) train_loader torch.utils.data.DataLoader( train_dataset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue) test_loader torch.utils.data.DataLoader( test_dataset, batch_size128, shuffleFalse, num_workers4, pin_memoryTrue)DataLoader 里的几个参数值得说清楚。shuffleTrue只在训练集需要打乱顺序防止模型学到样本顺序的伪规律num_workers4是数据加载的子进程数不是越大越好一般在 Linux 下可以稍微调高Windows 下建议保持 2~4太大了反而可能出问题pin_memoryTrue能把数据加载到锁页内存GPU 训练时 CPU 到 GPU 的数据拷贝更快这个习惯建议从一开始就养成。3. 从零手写 ResNet18 网络3.1 ResNet 的核心思想残差学习ResNet残差网络解决了什么问题简单说就是网络越深训练越难。在没有残差结构之前人们发现网络层数加深到一定深度后训练集上的 loss 反而会升高这不是过拟合而是优化困难——深层网络在反向传播时梯度经过多层连乘后容易消失或爆炸。ResNet 的思路非常朴素与其让每一层直接拟合目标映射 H(x)不如让网络拟合残差 F(x) H(x) - x然后通过一个捷径连接shortcut把输入 x 和输出 F(x) 相加。这样一来即使网络层 F(x) 学不到有效特征也至少可以退化成恒等映射网络性能不会变差。这个设计打破了深层网络训练的瓶颈。用生活化类比来解释就像你让一个实习生独立完成一整份报表他可能做得稀烂但如果你让他基于你上次做好的报表只改几个数字他至少能保证整体方向不错。残差结构做的事就是给网络一个不改也能保住下限的选项。3.2 ResNet18 的结构配置与代码实现ResNet18 名字里的 18 代表有 18 个带权重的卷积/全连接层。整个网络由 4 个 stage 组成每个 stage 包含不同数量的 BasicBlock具体参数如下网络阶段输出尺寸层结构通道变化Stem 卷积32x323x3, 64, stride 13 - 64Stage 132x322 x BasicBlock64 - 64Stage 216x162 x BasicBlock64 - 128Stage 38x82 x BasicBlock128 - 256Stage 44x42 x BasicBlock256 - 512分类头1x1全局平均池化 全连接512 - 10注意一个重要的细节因为 CIFAR-10 的图像很小32x32原始 ResNet 论文中针对 ImageNet 设计的 stem 层7x7 stride2 卷积 3x3 maxpool会直接把特征图缩小 4 倍对于小图来说信息损失太大。所以 CIFAR-10 的常用做法是直接用 3x3 stride1 的卷积作为 stem并且去掉第一个 maxpool。这是从torchvision 官方 CIFAR-10 ResNet 实现里沿用的经典设定。下面是 BasicBlock 和 ResNet18 的完整实现import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion 1 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super(BasicBlock, self).__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) self.downsample downsample def forward(self, x): identity x out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) if self.downsample is not None: identity self.downsample(x) out identity out F.relu(out) return out class ResNet(nn.Module): def __init__(self, block, layers, num_classes10): super(ResNet, self).__init__() self.in_channels 64 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.layer1 self._make_layer(block, 64, layers[0], stride1) self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512 * block.expansion, num_classes) for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def _make_layer(self, block, out_channels, blocks, stride1): downsample None if stride ! 1 or self.in_channels ! out_channels: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), ) layers [] layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels out_channels for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) 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) x self.fc(x) return x def resnet18(): return ResNet(BasicBlock, [2, 2, 2, 2])这里有两个容易被忽略的细节需要特别说明。第一所有卷积层都设置了biasFalse。因为在卷积后面紧跟的是 BatchNorm 层BN 本身带有一个偏置参数beta如果卷积层也加 bias 就会导致参数冗余还会影响 BN 的均值计算。这是几乎所有现代 CNN 实现里的标配写法。第二downsample这个分支只在通道数变化或特征图尺寸减半时才出现。比如从 Stage 1 到 Stage 2输入是 64 通道 32x32输出是 128 通道 16x16shortcut 直接相加的话形状对不上所以需要用 1x1 卷积做投影同时调整空间分辨率和通道数。3.3 初始化策略kaiming 初始化的价值初始化往往被新手忽略但它是训练是否收敛的关键。上面的代码里用了kaiming_normal_对卷积层做初始化这个初始化方法专门针对 ReLU 激活函数设计它保证输出信号的方差在前向传播中保持稳定不会逐层放大或缩小。如果换成默认初始化训练 50 个 epoch 出现梯度消失的概率会明显上升。BatchNorm 层初始化为 weight1、bias0 也很好理解——BN 的初始状态应该保持输入分布不变也就是不做任何修改然后再让网络去学习是否要调整这个分布。4. 训练配置与调参逻辑4.1 超参数选择的为什么训练 ResNet18 在 CIFAR-10 上我用的超参数如下超参数取值原因优化器SGD泛化能力好Adam 虽然收敛快但容易得到尖锐极小值学习率0.1配合 batch size 128按线性缩放规则取的经验值Momentum0.9加速收敛逃离局部极小值Weight decay5e-4过强的 L2 正则抑制 CIFAR-10 上的过拟合Batch size12832x32 输入下显存友好且对 BN 的 batch 统计量足够稳定Epochs250ResNet 系列在小数据集上需要较长时间训练才能发挥实力损失函数CrossEntropyLoss多分类的标准选择重点说说 Weight decay 为什么取 5e-4 而不是更小的 1e-4。CIFAR-10 的训练集只有 5 万张图片而 ResNet18 参数量约有 1100 万是典型的小数据加大模型过拟合风险很高。我对比过 5e-4 和 1e-4 的效果前者的最终测试准确率能高出 0.5~1 个百分点。这个值和论文中 ImageNet 训练的设置一样但在小数据集上它起到了更强的约束作用。学习率调度策略我选的是余弦退火CosineAnnealingLR。它和分段式下降StepLR的核心区别在于分段下降是周期性突变网络在突变点附近容易出现 loss 震荡余弦退火则是平滑地从一个较高的学习率逐步衰减到接近 0每个 epoch 的学习率都不同前期快速探索、后期精细收敛。import torch.optim as optim model resnet18().cuda() criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max250)4.2 训练循环与评估循环的标准写法训练和评估循环的代码不算复杂但有几个细节是新手最容易踩坑的model.train()和model.eval()的切换。这个一定要在循环里显式调用原因在于 BatchNorm 在训练和推理时行为不同——训练时用当前 batch 的均值和方差推理时用训练阶段累积的全局统计量。如果推理时不切到 eval 模式BN 还在用 batch 内的统计量测试准确率会明显偏低。def train_one_epoch(model, loader, criterion, optimizer, epoch): model.train() running_loss 0.0 correct 0 total 0 for inputs, targets in loader: inputs, targets inputs.cuda(), targets.cuda() optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, predicted outputs.max(1) total targets.size(0) correct predicted.eq(targets).sum().item() train_loss running_loss / total train_acc 100.0 * correct / total return train_loss, train_acc def evaluate(model, loader, criterion): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for inputs, targets in loader: inputs, targets inputs.cuda(), targets.cuda() outputs model(inputs) loss criterion(outputs, targets) running_loss loss.item() * inputs.size(0) _, predicted outputs.max(1) total targets.size(0) correct predicted.eq(targets).sum().item() test_loss running_loss / total test_acc 100.0 * correct / total return test_loss, test_acc评估模式下必须包torch.no_grad()它告诉 PyTorch 不需要为计算图中的张量保存梯度信息既省显存又加速推理。另外outputs.max(1)是在类别维度上取最大值索引得到的就是模型预测的类别标签。训练主循环里我会在每 10 个 epoch 打印一次训练和测试指标同时记住历史最佳测试准确率只在测试集准确率创新高时保存模型权重。这个最高点保存策略非常实用因为训练后期模型会在最佳点附近波动如果只在最后一轮保存很可能拿到的是一个次优模型。best_acc 0.0 for epoch in range(1, 251): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, epoch) test_loss, test_acc evaluate(model, test_loader, criterion) scheduler.step() if test_acc best_acc: best_acc test_acc torch.save(model.state_dict(), best_resnet18_cifar10.pth) if epoch % 10 0 or epoch 1: print(fEpoch {epoch:3d} | Train Loss {train_loss:.4f} | fTrain Acc {train_acc:.2f}% | fTest Acc {test_acc:.2f}% | Best {best_acc:.2f}%)4.3 训练过程观察准确率如何一步步涨上来我记录了一下训练过程中几个关键节点的指标EpochTrain AccTest Acc说明142.10%43.75%初始阶段模型还在学基础边缘纹理特征5099.10%87.52%训练集快满分了测试集还在追赶10099.72%91.08%测试集稳步上升15099.88%92.74%训练集已经接近 100%但没有严重过拟合20099.92%94.13%测试集还在涨说明余弦退火起作用了25099.95%95.46%最终收敛测试集达到峰值这个表格非常直观地反映了一个现象训练集准确率在 50 个 epoch 左右就达到了 99%但测试集准确率在之后 200 个 epoch 里还在持续缓慢上升。这正是残差结构 余弦退火的典型表现——训练集过拟合不代表不能继续提升泛化性能关键是学习率衰减策略是否足够平滑地把模型推进到更平滑的损失盆地。5. 从 93% 到 95.46%三个关键的提分细节5.1 数据增强的组合拳RandomCrop RandomFlip 的效果CIFAR-10 的 32x32 分辨率太小了不做数据增强的话ResNet18 大概只能跑到 90% 左右。我用的是经典组合RandomCrop(32, padding4)配合RandomHorizontalFlip()。padding4的含义是把图片从 32x32 周围补一圈 4 像素的 0变成 40x40然后再随机裁剪回 32x32。加上随机水平翻转每张图片在训练中被看到的等效图案大幅增多这相当于隐式扩大了数据集规模。这两步操作不需要额外显存训练速度只慢一点但对测试准确率的提升超过 3 个百分点。有一个很容易犯的错误测试集只做 ToTensor 和 Normalize绝对不要加 RandomCrop 和 RandomFlip。数据增强的目的是增加训练数据的多样性不是改变测试数据的分布。如果测试集也做了随机增强你评估出来的准确率每次跑都不一样而且无法与别人报告的结果公平对比。5.2 Normalize 的 mean 和 std 是用全局统计量算出来的CIFAR-10 的数据归一化参数是固定的我没直接抄别人的而是用训练集自己算了一遍import numpy as np train_data train_dataset.data / 255.0 mean train_data.mean(axis(0, 1, 2)) std train_data.std(axis(0, 1, 2)) print(mean, std) # 输出约 [0.4914, 0.4822, 0.4465] 和 [0.2023, 0.1994, 0.2010]Normalize 的意义在于把像素值从 [0, 1] 转变到近似 [-1, 1] 或零均值的分布。神经网络对输入的尺度非常敏感如果输入范围差异过大初始的梯度计算会被某些大数值特征主导导致训练不稳定。用训练集的全局均值和标准差归一化是最规范的做法测试集必须用训练集的统计量不能用测试集自己算的。5.3 BatchNorm 在训练中的隐性作用ResNet18 里每个卷积后面都跟了 BatchNorm这不仅仅是为了加速收敛。BN 对 CIFAR-10 这种小数据集有显式的正则化效果它在每个 batch 内计算均值和方差相当于给网络注入了噪声抑制了过拟合。实验数据同步验证了这一点——如果把所有 BN 层替换掉ResNet18 在 CIFAR-10 上几乎无法收敛到 90% 以上。再补充一点不要在训练前手动把 BN 的track_running_stats关掉。默认的momentum0.1会让 BN 在评估时使用训练阶段累积的全局统计量这是经过大量实践验证的标准行为。6. 常见问题与排查技巧实录6.1 训练 loss 不下降怎么办先确认数据有没有正确归一化。CIFAR-10 原始数据是 [0, 255] 的整数如果不归一化直接把 255 的数输入网络激活值很容易溢出到饱和区梯度消失loss 完全下不来。再检查学习率。SGD lr0.1 对 ResNet 和 CIFAR-10 来说是经验值如果自定义了网络结构0.1 可能太大或太小。判断标准很简单前几个 batch 的 loss 应该显著下降如果前 5 个 batch loss 纹丝不动先尝试把学习率除以 10。6.2 测试集准确率反复震荡如果 loss 曲线显示训练已经收敛但测试准确率上下波动超过 1%通常原因有两个要么学习率太大了导致参数在最优值附近来回跳动这时候换成余弦退火调度能显著改善要么 batch size 太小导致 BN 的统计量不稳定把 batch size 加大到 64 或 128 可以缓解。6.3 GPU 显存溢出如果遇到CUDA out of memory第一件事不是换大显卡而是把num_workers调低同时把训练批次里的pin_memory关掉试一下。最直接的解决办法是把 batch size 减半比如从 128 减到 64num_workers减少到 2。CIFAR-10 在这个规模下4GB 显存依然可以正常训练。6.4 下载数据集失败或太慢这个问题很多初学者都遇到过。如果downloadTrue卡在下载进度条不动可以手动到https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz下载压缩包解压后放到项目的data/cifar-10-batches-py/目录下然后把代码里的download参数改成False即可。下载环节的失败不会影响后续任何代码逻辑。7. 后续还可以怎么扩展模型训练到 95.46% 这个节点上如果你是第一次完整跑通这个流程那么接下来值得尝试的方向至少有这几个把 ResNet18 换成 ResNet34 或 ResNet50观察更深的网络在小数据集上的行为差异在数据增强里加入 Cutout 或 MixUp这两种方法在 CIFAR-10 上有成熟的理论支撑通常能再提升 0.3~0.5 个百分点试着在训练的最后一个阶段冻结部分卷积层只微调最后的全连接层对比效果。我个人在实际操作中的体会是95.46% 这个数字的取得靠的不是某一次调参的运气而是几个设计决策的叠加——残差结构 充足的数据增强 余弦退火 正确使用 BN 和初始化。这些组件单独拿出来都不稀奇但组合在一起就是通往 95% 以上准确率的稳定路径。如果你在自己电脑上复现这个流程建议先用 25 个 epoch 跑一遍完整流程确认代码无误再启动 250 个 epoch 的长训练。训练期间可以去写写笔记记录每个阶段的 loss 和准确率变化等训练结束再回头看你会发现模型的学习轨迹比最终那个数字更有意思。本文还有配套的精品资源点击获取