
简介基于PyTorch从零实现ResNet18并在CIFAR-10测试集上达到95.46%准确率的完整训练工程面向刚接触深度学习、希望独立搭建图像分类网络的学习者。资源不依赖任何预训练权重完整呈现从数据加载、残差块定义、正则化与数据增强到训练评估的全流程也适合作为复现论文基线的参考工程。压缩包共7个文件包含5个Python脚本与2个Markdown文档脚本分别承担网络结构定义、数据读取、训练、测试及Cutout数据增强文档记录实验说明与结果资源包仅10KB结构简洁便于对照学习。目前已有1921人学习下载。通过本套代码可系统掌握残差块、批量归一化、交叉熵损失、优化器配置与超参数调整等关键环节并能直接复现95.46%的测试精度为后续更复杂的视觉模型实验打下扎实基础。1. 拿到 95.46% 之前先搞清 ResNet18 在 Cifar10 上的正确打开方式ResNet18 在 Cifar10 上把测试集准确率做到 95.46%这个数字看着不高不低但真从零开始手写一遍的人会告诉你能稳定跑到 95% 以上靠的不是模型结构有多新而是「Cifar10 特化版结构 正确预处理 一组经过验证的训练超参」三件事同时做对。直接拉为 ImageNet 设计的预训练 ResNet18 来迁移输入压到 32x32第一层 7x7 卷积和 maxpool 把有效空间信息削掉一大半测试集准确率可能连90%都摸不到。下面把从 0 手写网络、配置数据管线、200 个 epoch 练到 95.46% 的完整链路拆开讲适合用 PyTorch 复现可靠精度基线的从业者也适合刚入门想搞懂每一步为什么这么配的初学者。2. ResNet18 结构拆解Cifar10 版为什么不能照搬 ImageNet 默认结构2.1 残差连接到底解决了什么在 ResNet 出现之前深层网络的训练误差会先降后升这不是过拟合而是优化问题梯度在逐层反向传播时不断被放大或衰减网络越深前几层越难训动。残差块把输出从 F(x) 改成 F(x)xx 通过 shortcut 直接跳连反向传播时梯度多了一条「高速通道」。层数堆上去之后前层依然拿得到有效梯度网络才有机会加深。对 Cifar10 这种 32x32 小图分类任务18 层已经远远够用。深度不是越深越好分辨率下降后ResNet50 的精度优势在 Cifar10 上几乎体现不出来训练时间却是 ResNet18 的两倍以上。Cifar10 上最值得投入的资源是数据和训练策略而不是继续堆层数——这一点是我在这个项目里学到的最重要结论。2.2 ImageNet 版和 Cifar10 版的三个关键差异ImageNet 版的 ResNet18 第一层是 7x7 卷积、stride2紧跟着 3x3 maxpool、stride2。这个设计针对 224x224 输入先把分辨率从 224 降到 112 再降到 56。Cifar10 输入只有 32x32第一个卷积 stride2 就降到 16x16再过一个 maxpool 只剩 8x8原始空间信息在第一层就丢光了。所以 Cifar10 版的第一层必须改成 3x3 卷积、stride1、padding1并去掉 maxpool。第二个差异在输出端原版 fc 层是 1000 类这里要换成 10。第三个差异在 BatchNorm预训练权重里的 BN 运行统计量是针对 ImageNet 图片分布算出来的拿来做 Cifar10 的迁移起点并不合适这也注定了这个项目必须从随机初始化开始训练。以下是 Cifar10 版的整体结构表模块输入尺寸输出尺寸关键配置conv13x32x3264x32x323x3, stride1, padding1layer164x32x3264x32x322 个 BasicBlock, stride1layer264x32x32128x16x162 个 BasicBlock, stride2layer3128x16x16256x8x82 个 BasicBlock, stride2layer4256x8x8512x4x42 个 BasicBlock, stride2avgpool512x4x4512x1x1AdaptiveAvgPool2d(1)fc51210全连接分类2.3 从 0 手写残差块和 ResNet18 完整代码先从 BasicBlock 写起。ResNet18 用的是两层 3x3 卷积的残差块通道数不翻倍时输出直接和输入相加通道数翻倍或特征图减半时需要用 1x1 卷积对输入做投影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 self.conv1(x) out self.bn1(out) out F.relu(out) out self.conv2(out) out self.bn2(out) if self.downsample is not None: identity self.downsample(x) out identity out F.relu(out) return out这里把 conv 的 bias 置为 False因为后面紧接 BatchNormBN 有可学习的偏置conv 再带 bias 就冗余了。conv1 用 stride 控制特征图减半conv2 的 stride 恒为 1保证块内第二次卷积不改变尺寸。expansion1 表示输出通道数等于输入通道数这是 ResNet18/34 的 BasicBlock 特征ResNet50 的 Bottleneck 这个值才是 4。然后是组装整个 ResNet18。核心是 _make_layer 方法它把多个 BasicBlock 串起来并在每个 layer 的第一个块做下采样class ResNet18(nn.Module): def __init__(self, num_classes10): super(ResNet18, self).__init__() self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.layer1 self._make_layer(64, 64, blocks2, stride1) self.layer2 self._make_layer(64, 128, blocks2, stride2) self.layer3 self._make_layer(128, 256, blocks2, stride2) self.layer4 self._make_layer(256, 512, blocks2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) self._init_weights() def _make_layer(self, in_channels, out_channels, blocks, stride): downsample None if stride ! 1 or in_channels ! out_channels: downsample nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), ) layers [] layers.append(BasicBlock(in_channels, out_channels, stride, downsample)) for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels)) return nn.Sequential(*layers) def _init_weights(self): 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 forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(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_make_layer 的判断条件是stride ! 1 or in_channels ! out_channels。在 layer2输入 64 输出 128 且 stride2两个条件都触发投影分支用 1x1 卷积、stride2 把 32x32 的特征图下采样到 16x16同时把通道从 64 拉到 128。layer1 因为是 64 进 64 出、stride1不需要投影残差直接恒等相加。初始化用 Kaiming 初始化是 ResNet 训练稳定性的基础之一BN 的 weight 初始为 1、bias 初始为 0这两行代码在从 0 训练时别省。注意torchvision 里也有现成的 resnet18()但它对应 224x224 输入第一层是 7x7 stride2 加 maxpool直接用在 Cifar10 上会掉 2-3 个点。手写一遍不只是为了「从 0 开始」的形式感更是为了看清结构、方便替换成 ResNet34/50。3. 数据管线归一化参数和增强决定你能不能摸到 95%3.1 Cifar10 的 mean/std别用默认的 0.5很多人写transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))倒也不是不能训但收敛慢、上限低。Cifar10 是彩色自然图像RGB 三通道亮度并不均匀。社区里复现高精度 Cifar10 实验时用的实际统计值是 mean(0.4914, 0.4822, 0.4465)、std(0.2023, 0.1994, 0.2010)。这组数据可以直接用也可以自己算一遍import torch import torchvision import torchvision.transforms as transforms train_set torchvision.datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransforms.ToTensor()) imgs torch.stack([img for img, _ in train_set], dim0) mean imgs.mean(dim(0, 2, 3)) std imgs.std(dim(0, 2, 3)) print(mean, std) # tensor([0.4914, 0.4822, 0.4465]), tensor([0.2023, 0.1994, 0.2010])这个脚本会把 5 万张 32x32x3 的图像一次性堆成一个约 600MB 的 tensor跑完记着把imgs释放掉。统计量和 PyTorch 社区公布的 Cifar10 标准值一致说明数据下载和解析没有异常这本身也是一次数据完整性检查。3.2 数据增强只加两个RandomCrop 和 RandomHorizontalFlipCifar10 上性价比最高的两个增强是随机裁剪和水平翻转加上之后和裸训的差距通常是 3-5 个点这是从 95% 这个目标回推时绝对不能省的一步import torchvision.transforms as transforms transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ])RandomCrop 的 padding4 表示先给 32x32 图像四周补 4 像素再从中随机裁剪出 32x32 区域相当于引入平移扰动。Cifar10 的物体基本居中这个扰动不会把主要目标裁掉却能有效抵抗位置偏移。RandomHorizontalFlip 做左右翻转Cifar10 的类别没有「左/右」的语义比如鸟、猫、卡车翻转后仍然属于原类别不会引入歧义。注意测试集的 transform 里没有任何随机操作只做 ToTensor 和归一化——测试集增强是测试指标失真最常见的来源。3.3 DataLoader 参数与验证集方案from torch.utils.data import DataLoader train_dataset torchvision.datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransform_train) test_dataset torchvision.datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransform_test) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers2, pin_memoryTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse, num_workers2, pin_memoryTrue)batch_size128 对应后面 lr0.1 的经典配置这个数据集每个 epoch 约 391 个 batch。num_workers2 在多数机器上是稳妥起点Windows 下必须注意脚本要包在if __name__ __main__:里否则多进程 DataLoader 会直接报错。pin_memoryTrue 在 GPU 训练时能少一次内存拷贝。Cifar10 体量小不需要单独划分验证集把官方 1 万张测试集当验证集每个 epoch 结束后评估一次并以测试集上的最高准确率作为最终报告值这个方案在单次实验中是可接受的。4. 训练策略与完整训练循环把 95.46% 复现出来的关键参数都在这里4.1 优化器SGD Momentum 0.9 weight_decay 5e-4不要一上来就 AdamCifar10 上用 Adam 不是不能训但同等 epoch 下泛化能力通常比 SGD Momentum 差。原因在于 Adam 的二阶动量自适应放大了高频噪声在小数据集上更容易收敛到陡峭的局部极小值SGD 配合 Momentum 和 weight_decay轨迹更平滑容易冲进平坦的极值区域这类区域在测试集上的泛化更稳。import torch.optim as optim optimizer optim.SGD( model.parameters(), lr0.1, # 对应 batch_size128 momentum0.9, weight_decay5e-4, # Cifar10 上 ResNet 系列的经典经验值 nesterovFalse )weight_decay5e-4 是 Cifar10 上 ResNet 系列的事实标准太小起不到正则效果太大会把模型逼到欠拟合。momentum0.9 几乎写死nesterov 开不开对最终精度影响很小不是关键变量。如果因为显存把 batch_size 改成 64lr 要同步减半到 0.05batch_size 调到 256lr 可以考虑 0.2。改 batch 不调 lr前期 loss 会明显抖动这是很多人复现失败的第一个隐藏原因。4.2 学习率调度MultiStep 还是 Cosine95% 以上的精度不是靠恒定学习率训出来的。前期高 lr 负责快速逼近中后期必须把 lr 降下来让权重在小步长里把决策边界打磨细腻。最稳的配置是 MultiStepLRscheduler optim.lr_scheduler.MultiStepLR( optimizer, milestones[60, 120, 160], gamma0.2 )这组里程碑对应 200 个 epoch 的总训练时长前 60 个 epoch 用 0.1 快速收敛60 epoch 后降到 0.02120 epoch 后降到 0.004160 epoch 后到 0.0008。最后 40 个 epoch 在超低学习率下精修这个阶段测试准确率还会缓慢爬升 0.5-1 个点正是冲上 95% 的关键区间。如果不想手工调里程碑可以用 CosineAnnealingLR 替代从 0.1 余弦退火到接近 0200 个 epoch 结束。两种调度在相同配置下最终精度差距通常在 0.5 个点以内Cosine 完全不用动脑适合快速验证MultiStep 的理论解释更清晰适合冲击最终指标。我一般先跑一版 Cosine 确认模型和数据没问题再换 MultiStep 做最终训练。4.3 完整训练循环train/eval 一个都不能省下面是完整的训练主循环包含训练、评估、best checkpoint 保存三个部分import torch import torch.nn as nn device torch.device(cuda if torch.cuda.is_available() else cpu) model ResNet18(num_classes10).to(device) criterion nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(200): # 训练阶段 model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) # [128, 10] loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) scheduler.step() train_loss running_loss / len(train_loader.dataset) # 评估阶段 model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_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() acc 100.0 * correct / total lr_now optimizer.param_groups[0][lr] if acc best_acc: best_acc acc torch.save(model.state_dict(), resnet18_cifar10_best_acc.pth) print(fEpoch {epoch1:03d} | Loss {train_loss:.4f} f| Acc {acc:.2f}% | Best {best_acc:.2f}% | LR {lr_now})代码里有几个细节要注意。model.train()和model.eval()切换的是 BatchNorm 的运行统计量更新机制评估时忘了切回 evalBN 按 batch 统计而不是全局统计测试 acc 会掉 1-2 个点。loss.item()是当前 batch 的平均损失乘上images.size(0)后累加最后除以训练集总样本数算的是每个样本的平均损失打印出来才可解释。scheduler.step()必须在每个 epoch 结束后调用MultiStepLR 根据当前轮数决定是否触发衰减。如果显存允许可以用torch.cuda.amp.GradScaler做混合精度训练速度提升明显Cifar10 这种小数据上精度几乎没有损失。但要小心amp 开启后 loss.backward() 之前先要scaler.scale(loss)优化器 step 改成scaler.step(optimizer)最后scaler.update()少一步都会报错或精度异常。4.4 模型保存策略只存测试集 acc 最高的那一版200 个 epoch 的后期测试集准确率会进入「小幅震荡中缓慢爬升」的状态最后一个 epoch 的权重通常不是最好的。代码里用best_acc记录历史最高只有超过它才覆盖保存。这样训练结束后.pth文件里永远是最优权重。按前面所有配置完整运行一次best_acc 通常落在 95.2%-95.6% 区间标题里的 95.46% 就是其中一次运行的记录值。第一次跑如果只到 94.8% 别急着改结构先确认数据增强、weight_decay、学习率调度三件事都在正确位置再重新跑一轮。随机种子不同单次结果在小数点后一位浮动是正常现象。5. Cifar10 从 0 训练的 5 个常见问题排查现象、原因、解决5.1 loss 低到离谱但测试准确率只有 10%现象训练 loss 一两个 epoch 就掉到 0.01 以下测试 acc 始终在 10% 附近和随机猜测没区别。原因最常见的是标签与输出错位。模型输出是 [batch, 10] 的 logitsCrossEntropyLoss 期望 labels 是 0-9 的整数标量如果 DataLoader 返回的 target 变成了 one-hot 向量或者形状不对loss 会在错误的位置上「假收敛」。另一个常见问题是模型在 fc 之后又接了 Softmax而 CrossEntropyLoss 内部自带 Softmax双重 Softmax 把 logits 压平梯度信号被严重削弱。解决先打印一个 batch 检查images.shape、labels.shape、labels.unique()确认标签是整数而不是 one-hot再把网络输出里的 Softmax 层去掉fc 直接输出原始 logits。5.2 训练集 99%测试集 75%现象训练 acc 很快就到 99% 甚至 100%测试 acc 在 75% 上下晃典型的过拟合曲线。原因两个方向排查。一是 weight_decay 没设置或者设置成了 0模型完全没有正则约束二是数据增强根本没生效常见的错误是 RandomCrop 和 RandomHorizontalFlip 被写进了 test transform或者 train_dataset 忘了传入 transform_train导致训练和测试都是裸数据。解决检查两个 Dataset 的 transform 配置确认增强只作用于训练侧把 weight_decay 设为 5e-4。另外评估阶段如果忘了model.eval()BatchNorm 对每个 batch 用当前统计量而非全局 running stats也会让测试 acc 明显偏低这是工程实现里最容易忽视的一条。5.3 训练了 100 个 epoch 还在 90% 附近现象loss 下降趋势正常但 acc 到 90% 上下就卡住继续训也不动。原因一是学习率全程没有衰减模型在最优解附近震荡进不去更精细的局部极小二是初始 lr 设成了 0.01200 个 epoch 根本走不完收敛过程三是误用了 ImageNet 版的第一层结构7x7 stride2 加 maxpool 已经把信息损失掉模型上限被压到 90%-92%。解决重置为 lr0.1 加 MultiStep 或 Cosine 调度确认第一层是 3x3 stride1 且没有 maxpool。如果显存只允许 batch 64把 lr 同步降到 0.05。训练时在 print 里带上当前 lr每 5 个 epoch 观察一次衰减是否真实触发。5.4 同一份代码两次训练结果差 1-2%现象同一份代码、同一台机器两次跑出来 best_acc 一次 94.3%、一次 95.4%。原因随机种子没固定。PyTorch 的 DataLoader shuffle、权重初始化、甚至 cuDNN 在 benchmark 模式下选择的卷积算法都带随机性。解决统一设置随机种子import random import numpy as np import torch def set_seed(seed0): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.deterministicTrue会牺牲 10% 左右的训练速度换来卷积算子选择确定性benchmarkFalse关闭了运行时自动搜索最优算子的逻辑。固定种子之后差异会缩小到 0.2% 以内但 GPU 上完全确定性仍无法保证。所以严谨的做法是同一个配置跑 3 次取均值而不是只报单次最高值。5.5 Windows 下 num_workers 报错、显存不足现象Windows 上 DataLoader 设置 num_workers2 直接报错或无限卡住另一种是 batch_size128 时提示 CUDA out of memory。原因Windows 不支持 fork 启动多进程DataLoader 的 worker 必须放在if __name__ __main__:保护块里才稳定。显存不足往往不是模型太大而是 test_loader 的 batch 设得太大或者开了混合精度后显存没降反升。解决把训练主逻辑全部放进if __name__ __main__:。显存不够就把 batch_size 降到 64lr 同步调成 0.05最终精度不会受影响只是多花一点训练时间。Cifar10 的 32x32 小图对显存非常友好一张中端消费级显卡跑完整 200 个 epoch 通常只需要半小时到一小时瓶颈更多在 CPU 数据加载pin_memoryTrue 值得保留。6. 拿到 95% 之后验证含金量、EMA 提点与两个进阶方向6.1 用混淆矩阵看模型是否偏科95.46% 是整体准确率样本在某些类别上可能只有 90% 左右。Cifar10 里猫和狗、鸟和鹿天然容易混淆只看 top-1 平均数是看不出来的。训练结束后跑一遍测试集统计每个类别的准确率from sklearn.metrics import confusion_matrix import numpy as np model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in test_loader: images images.to(device) outputs model(images) _, preds outputs.max(1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) per_class_acc cm.diagonal() / cm.sum(axis1) class_names [airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck] for name, acc in zip(class_names, per_class_acc): print(f{name:10s} {acc * 100:.2f}%)这个脚本不改变训练结果但能告诉你下一步该往哪使劲。如果发现 cat 和 dog 之间互相错分特别严重可以针对性加更强的局部增强如果某个类别的召回率明显低于其他类就要怀疑训练数据里该类别的样本多样性是否不够。6.2 EMA0.2 个点的「后悔药」EMA指数移动平均维护一组权重的滑动平均版本推理时用滑动平均权重能平滑训练后期的震荡。200 个 epoch 的配置下decay 取 0.999 比较合理。实现很简单每轮训练结束后更新一次影子权重训练完成后把影子权重复制回模型再跑一次测试集。通常能换来 0.1-0.3 个点的稳定提升且零成本。它解决的是「最后一个 epoch 的权重恰好落在震荡低点」这种玄学问题和best_acc保存策略是互补的。6.3 两个进一步的方向想继续往上走第一个方向是把增强从 RandomCrop Flip 升级到 AutoAugment 或 CutOutCifar10 上通常还有 1 个点左右的上涨空间但增强强度过大会反过来伤精度需要小步试。第二个方向是把同样的训练配置搬到 ResNet34 或 ResNet50但这个场景下收益有限训练时间却几乎翻倍。我自己的习惯是先固定结构把数据、正则化和训练策略打满最后才考虑动模型。毕竟 ResNet18 在 Cifar10 上已经证明了一个朴素的事实结构只是起点超参和管线才是最后那几个点的来源。整个项目踩过一遍坑之后最值钱的收获不是这个数字而是知道每个参数变动会带来什么后果。希望帮到你。本文还有配套的精品资源点击获取