简介:基于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)+x,x 通过 shortcut 直接跳连,反向传播时梯度多了一条「高速通道」。层数堆上去之后,前层依然拿得到有效梯度,网络才有机会加深。
对 Cifar10 这种 32x32 小图分类任务,18 层已经远远够用。深度不是越深越好:分辨率下降后,ResNet50 的精度优势在 Cifar10 上几乎体现不出来,训练时间却是 ResNet18 的两倍以上。Cifar10 上最值得投入的资源是数据和训练策略,而不是继续堆层数——这一点是我在这个项目里学到的最重要结论。
2.2 ImageNet 版和 Cifar10 版的三个关键差异
ImageNet 版的 ResNet18 第一层是 7x7 卷积、stride=2,紧跟着 3x3 maxpool、stride=2。这个设计针对 224x224 输入,先把分辨率从 224 降到 112 再降到 56。Cifar10 输入只有 32x32,第一个卷积 stride=2 就降到 16x16,再过一个 maxpool 只剩 8x8,原始空间信息在第一层就丢光了。所以 Cifar10 版的第一层必须改成 3x3 卷积、stride=1、padding=1,并去掉 maxpool。
第二个差异在输出端:原版 fc 层是 1000 类,这里要换成 10。第三个差异在 BatchNorm:预训练权重里的 BN 运行统计量是针对 ImageNet 图片分布算出来的,拿来做 Cifar10 的迁移起点并不合适,这也注定了这个项目必须从随机初始化开始训练。以下是 Cifar10 版的整体结构表:
| 模块 | 输入尺寸 | 输出尺寸 | 关键配置 |
|---|---|---|---|
| conv1 | 3x32x32 | 64x32x32 | 3x3, stride=1, padding=1 |
| layer1 | 64x32x32 | 64x32x32 | 2 个 BasicBlock, stride=1 |
| layer2 | 64x32x32 | 128x16x16 | 2 个 BasicBlock, stride=2 |
| layer3 | 128x16x16 | 256x8x8 | 2 个 BasicBlock, stride=2 |
| layer4 | 256x8x8 | 512x4x4 | 2 个 BasicBlock, stride=2 |
| avgpool | 512x4x4 | 512x1x1 | AdaptiveAvgPool2d(1) |
| fc | 512 | 10 | 全连接分类 |
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, stride=1, downsample=None): super(BasicBlock, self).__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) 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,因为后面紧接 BatchNorm,BN 有可学习的偏置,conv 再带 bias 就冗余了。conv1 用 stride 控制特征图减半,conv2 的 stride 恒为 1,保证块内第二次卷积不改变尺寸。expansion=1 表示输出通道数等于输入通道数,这是 ResNet18/34 的 BasicBlock 特征,ResNet50 的 Bottleneck 这个值才是 4。
然后是组装整个 ResNet18。核心是 _make_layer 方法,它把多个 BasicBlock 串起来,并在每个 layer 的第一个块做下采样:
class ResNet18(nn.Module): def __init__(self, num_classes=10): super(ResNet18, self).__init__() self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.layer1 = self._make_layer(64, 64, blocks=2, stride=1) self.layer2 = self._make_layer(64, 128, blocks=2, stride=2) self.layer3 = self._make_layer(128, 256, blocks=2, stride=2) self.layer4 = self._make_layer(256, 512, blocks=2, stride=2) 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_size=1, stride=stride, bias=False), 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, mode='fan_out', nonlinearity='relu') 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 且 stride=2,两个条件都触发,投影分支用 1x1 卷积、stride=2 把 32x32 的特征图下采样到 16x16,同时把通道从 64 拉到 128。layer1 因为是 64 进 64 出、stride=1,不需要投影,残差直接恒等相加。初始化用 Kaiming 初始化是 ResNet 训练稳定性的基础之一,BN 的 weight 初始为 1、bias 初始为 0,这两行代码在从 0 训练时别省。
注意:torchvision 里也有现成的 resnet18(),但它对应 224x224 输入,第一层是 7x7 stride=2 加 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', train=True, download=True, transform=transforms.ToTensor()) imgs = torch.stack([img for img, _ in train_set], dim=0) 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 和 RandomHorizontalFlip
Cifar10 上性价比最高的两个增强是随机裁剪和水平翻转,加上之后和裸训的差距通常是 3-5 个点,这是从 95% 这个目标回推时绝对不能省的一步:
import torchvision.transforms as transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), 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 的 padding=4 表示先给 32x32 图像四周补 4 像素,再从中随机裁剪出 32x32 区域,相当于引入平移扰动。Cifar10 的物体基本居中,这个扰动不会把主要目标裁掉,却能有效抵抗位置偏移。RandomHorizontalFlip 做左右翻转,Cifar10 的类别没有「左/右」的语义,比如鸟、猫、卡车翻转后仍然属于原类别,不会引入歧义。注意测试集的 transform 里没有任何随机操作,只做 ToTensor 和归一化——测试集增强是测试指标失真最常见的来源。
3.3 DataLoader 参数与验证集方案
from torch.utils.data import DataLoader train_dataset = torchvision.datasets.CIFAR10( root='./data', train=True, download=True, transform=transform_train) test_dataset = torchvision.datasets.CIFAR10( root='./data', train=False, download=True, transform=transform_test) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2, pin_memory=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=2, pin_memory=True)batch_size=128 对应后面 lr=0.1 的经典配置,这个数据集每个 epoch 约 391 个 batch。num_workers=2 在多数机器上是稳妥起点,Windows 下必须注意脚本要包在if __name__ == '__main__':里,否则多进程 DataLoader 会直接报错。pin_memory=True 在 GPU 训练时能少一次内存拷贝。Cifar10 体量小,不需要单独划分验证集:把官方 1 万张测试集当验证集,每个 epoch 结束后评估一次,并以测试集上的最高准确率作为最终报告值,这个方案在单次实验中是可接受的。
4. 训练策略与完整训练循环:把 95.46% 复现出来的关键参数都在这里
4.1 优化器:SGD Momentum 0.9 + weight_decay 5e-4,不要一上来就 Adam
Cifar10 上用 Adam 不是不能训,但同等 epoch 下泛化能力通常比 SGD Momentum 差。原因在于 Adam 的二阶动量自适应放大了高频噪声,在小数据集上更容易收敛到陡峭的局部极小值;SGD 配合 Momentum 和 weight_decay,轨迹更平滑,容易冲进平坦的极值区域,这类区域在测试集上的泛化更稳。
import torch.optim as optim optimizer = optim.SGD( model.parameters(), lr=0.1, # 对应 batch_size=128 momentum=0.9, weight_decay=5e-4, # Cifar10 上 ResNet 系列的经典经验值 nesterov=False )weight_decay=5e-4 是 Cifar10 上 ResNet 系列的事实标准,太小起不到正则效果,太大会把模型逼到欠拟合。momentum=0.9 几乎写死,nesterov 开不开对最终精度影响很小,不是关键变量。如果因为显存把 batch_size 改成 64,lr 要同步减半到 0.05;batch_size 调到 256,lr 可以考虑 0.2。改 batch 不调 lr,前期 loss 会明显抖动,这是很多人复现失败的第一个隐藏原因。
4.2 学习率调度:MultiStep 还是 Cosine
95% 以上的精度不是靠恒定学习率训出来的。前期高 lr 负责快速逼近,中后期必须把 lr 降下来,让权重在小步长里把决策边界打磨细腻。最稳的配置是 MultiStepLR:
scheduler = optim.lr_scheduler.MultiStepLR( optimizer, milestones=[60, 120, 160], gamma=0.2 )这组里程碑对应 200 个 epoch 的总训练时长:前 60 个 epoch 用 0.1 快速收敛,60 epoch 后降到 0.02,120 epoch 后降到 0.004,160 epoch 后到 0.0008。最后 40 个 epoch 在超低学习率下精修,这个阶段测试准确率还会缓慢爬升 0.5-1 个点,正是冲上 95% 的关键区间。
如果不想手工调里程碑,可以用 CosineAnnealingLR 替代,从 0.1 余弦退火到接近 0,200 个 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_classes=10).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(f'Epoch {epoch+1:03d} | Loss {train_loss:.4f} ' f'| Acc {acc:.2f}% | Best {best_acc:.2f}% | LR {lr_now}')代码里有几个细节要注意。model.train()和model.eval()切换的是 BatchNorm 的运行统计量更新机制,评估时忘了切回 eval,BN 按 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] 的 logits,CrossEntropyLoss 期望 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.01,200 个 epoch 根本走不完收敛过程;三是误用了 ImageNet 版的第一层结构,7x7 stride=2 加 maxpool 已经把信息损失掉,模型上限被压到 90%-92%。
解决:重置为 lr=0.1 加 MultiStep 或 Cosine 调度,确认第一层是 3x3 stride=1 且没有 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(seed=0): 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.deterministic=True会牺牲 10% 左右的训练速度,换来卷积算子选择确定性;benchmark=False关闭了运行时自动搜索最优算子的逻辑。固定种子之后,差异会缩小到 0.2% 以内,但 GPU 上完全确定性仍无法保证。所以严谨的做法是同一个配置跑 3 次取均值,而不是只报单次最高值。
5.5 Windows 下 num_workers 报错、显存不足
现象:Windows 上 DataLoader 设置 num_workers=2 直接报错或无限卡住;另一种是 batch_size=128 时提示 CUDA out of memory。
原因:Windows 不支持 fork 启动多进程,DataLoader 的 worker 必须放在if __name__ == '__main__':保护块里才稳定。显存不足往往不是模型太大,而是 test_loader 的 batch 设得太大,或者开了混合精度后显存没降反升。
解决:把训练主逻辑全部放进if __name__ == '__main__':。显存不够就把 batch_size 降到 64,lr 同步调成 0.05,最终精度不会受影响,只是多花一点训练时间。Cifar10 的 32x32 小图对显存非常友好,一张中端消费级显卡跑完整 200 个 epoch 通常只需要半小时到一小时,瓶颈更多在 CPU 数据加载,pin_memory=True 值得保留。
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(axis=1) 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 EMA:0.2 个点的「后悔药」
EMA(指数移动平均)维护一组权重的滑动平均版本,推理时用滑动平均权重,能平滑训练后期的震荡。200 个 epoch 的配置下,decay 取 0.999 比较合理。实现很简单,每轮训练结束后更新一次影子权重;训练完成后把影子权重复制回模型,再跑一次测试集。通常能换来 0.1-0.3 个点的稳定提升,且零成本。它解决的是「最后一个 epoch 的权重恰好落在震荡低点」这种玄学问题,和best_acc保存策略是互补的。
6.3 两个进一步的方向
想继续往上走,第一个方向是把增强从 RandomCrop + Flip 升级到 AutoAugment 或 CutOut,Cifar10 上通常还有 1 个点左右的上涨空间,但增强强度过大会反过来伤精度,需要小步试。第二个方向是把同样的训练配置搬到 ResNet34 或 ResNet50,但这个场景下收益有限,训练时间却几乎翻倍。我自己的习惯是先固定结构,把数据、正则化和训练策略打满,最后才考虑动模型。毕竟 ResNet18 在 Cifar10 上已经证明了一个朴素的事实:结构只是起点,超参和管线才是最后那几个点的来源。整个项目踩过一遍坑之后,最值钱的收获不是这个数字,而是知道每个参数变动会带来什么后果。希望帮到你。
本文还有配套的精品资源,点击获取