简介:这份围绕ResNet实现CIFAR-10图像分类的算法实践包,面向已掌握卷积网络基础、希望动手复现残差网络的中级学习者。内容聚焦残差块与跳跃连接、批量归一化、全局平均池化分类头,以及SGD/交叉熵训练流程和数据增强、学习率调度等关键环节,帮助读者把抽象理论落到可运行代码。压缩包共5个文件,以4个Python脚本和1份Markdown说明为主,体量约3KB,按模型定义、数据加载、训练与推理拆分,结构轻量便于逐模块阅读和二次修改。目前已有726人学习,适合用作课程实验、课题复现或小数据集分类入门参考。读者可从中获得网络结构定义、训练主循环、推理评估与数据预处理等完整实现线索,并借助说明文档快速梳理运行逻辑,减少从零搭建时的试错成本。
1. 一个训练集 99%、测试集 60% 的落差,暴露了什么
前段时间有人把一份 resnet 算法实现发给我看,跑的是 CIFAR10 分类,代码能跑通,训练集准确率冲到 99%,测试集却死活卡在 60% 上下。翻开源码看了十分钟,问题不在网络结构上,出在数据管线:归一化用错了统计量,随机裁剪的 padding 又写成了 0,模型再深也白搭。这份资源做的事情其实很聚焦——用 ResNet 在 CIFAR10 上做十分类,从数据加载、残差网络搭建、训练循环到精度评估,是一条完整可复现的链路。
它适合两类人:一类是刚学完卷积、想找个能跑通的残差网络项目练手的新手,照着改几行参数就能看到准确率变化;另一类是已经会用 PyTorch、但没认真拆过 CIFAR 版 ResNet 和 ImageNet 版差异的熟手,里面有关于 stem 结构、shortcut 投影、学习率调度的取舍,值得逐行看一遍。下面按「结构长什么样 → 一个 batch 怎么流过 → 训练配方怎么调 → 哪里容易翻车 → 怎么改成自己的」这条线拆。
2. 网络结构拆解:CIFAR 版 ResNet 的 BasicBlock 与三阶段堆叠
2.1 为什么 CIFAR 版砍掉了 7×7 卷积和 MaxPool
刚接触的人常有一个疑问:ImageNet 上那套 ResNet-50 的骨架,直接搬过来跑 CIFAR10 行不行?能跑,但效果会打折。原版的第一层是 7×7、stride=2 的卷积,后面跟一个 3×3、stride=2 的 MaxPool,两步下来 224×224 的输入直接缩到 56×56,缩了四倍。CIFAR10 的图只有 32×32,同样两步下来只剩 8×8,等进到第一个残差 stage,空间信息已经被压得所剩无几,后面再怎么堆 block 也补不回来。
所以 CIFAR 版的标准做法是把 stem 简化成一层 3×3、stride=1、padding=1 的卷积,通道数直接给 16,不做任何池化。这样 32×32 进去还是 32×32,三个 stage 分别输出 32×32、16×16、8×8,下采样全部交给 stage 内部第一个 block 的 stride=2 完成。这份资源的网络定义基本遵循这个思路,我一般也会这么写,因为它让「深度」和「感受野」解耦了:你想加深网络只需要加 block,不用担心一开始就把特征图压没了。
参数量上差距更直观。一份 CIFAR 版 ResNet-20 大概 0.27M 参数,跑一张 32×32 的图计算量很小,单卡 8G 显存能开到 batch size 512 还绰绰有余;而同样的 20 层如果按 ImageNet 骨架堆,参数和显存都会翻好几倍,对小数据集来说是纯浪费。
2.2 工程目录与模块边界
资源包解压后的结构大致是这样,不同人写会有细节差异,但模块边界基本固定:
resnet-cifar10/ ├── main.py # 入口:参数解析 + 训练/评估分支 ├── models/ │ ├── __init__.py # 按名字创建 resnet20/32/44/56 │ └── resnet.py # BasicBlock + ResNetCIFAR 主类 ├── data/ │ └── cifar10.py # Dataset 构建、增强、DataLoader 封装 ├── utils/ │ ├── train.py # 训练一个 epoch 的循环 │ ├── eval.py # 评估与指标累计 │ ├── lr_scheduler.py # warmup + cosine / multistep │ └── checkpoint.py # 保存与恢复 └── configs/ └── resnet20.yaml # 超参集中在这里,避免散落在代码里这个划分方式值得学的一点是:models里只放网络,data里只放数据,utils/train.py里只放「跑一个 epoch」这件事,main.py负责把三者串起来。很多新手图省事把所有逻辑写进一个train.py,两三百行之后想换个学习率调度器都要翻半天。拆开之后,换骨干只需要动models/__init__.py的一行注册,换数据集只需要在data/下加一个文件。
2.3 BasicBlock:残差相加必须发生在第二个 ReLU 之前
残差网络的核心就是那个 shortcut。CIFAR 版用的是 BasicBlock(两层 3×3 卷积),不是 Bottleneck(1×1、3×3、1×1 三层),因为 32×32 的输入本身通道数就少,用 Bottleneck 反而增加复杂度还降精度。核心实现如下:
import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_planes, planes, stride=1, use_projection=False): super().__init__() # 两层 3x3 卷积,padding=1 保证 stride=1 时分辨率不变 self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) # shortcut:形状一致走恒等映射,形状变化才用 1x1 卷积投影 if use_projection: self.shortcut = nn.Sequential( nn.Conv2d(in_planes, planes, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(planes), ) else: self.shortcut = nn.Identity() def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) # 注意:这里不加 ReLU out = out + self.shortcut(x) # 残差相加发生在第二个 BN 之后 return F.relu(out) # 相加完再统一激活这段里最容易写错的是最后三行。conv 后面带 bias 是浪费,因为紧跟着 BN,bias 会被减掉,所以统一设bias=False,这也是原论文的做法。use_projection的开关逻辑也很关键:只要输入输出通道数不同,或者 stride 不等于 1 导致分辨率变了,shortcut 就无法直接相加,必须走 1×1 卷积把形状对齐。常见的一个翻车写法是「通道变了但还是用恒等映射」,前向传播时直接报 shape mismatch,或者更隐蔽地用了广播机制把结果算错但程序不报错。
out = self.bn2(self.conv2(out))这一行后面绝对不能加 ReLU。残差块的设计意图是让网络学习残差F(x),然后输出F(x) + x,如果第二个 BN 之后先过 ReLU,那负半轴的信息就被截断了,恒等映射的路径上就有了非线性污染,深度一上去精度反而掉。这个点在 ResNet 变体(比如 pre-activation 版本)里会重新排列顺序,但原版 BasicBlock 就是这么写的。
2.4 三个 stage 的堆叠规则与参数量对照
主网络按「stem → stage1 → stage2 → stage3 → 全局池化 → 全连接」组织,三个 stage 的通道数是 16、32、64,每个 stage 内部的 block 数由配置决定:
class ResNetCIFAR(nn.Module): def __init__(self, blocks_per_stage, num_classes=10, widen=1): super().__init__() self.in_planes = 16 # stem:单层 3x3,不池化,保留 32x32 分辨率 self.stem = nn.Conv2d(3, 16, kernel_size=3, stride=1, padding=1, bias=False) self.bn_stem = nn.BatchNorm2d(16) # 三个 stage,通道数按 16/32/64 递增,后两个在首个 block 下采样 self.stage1 = self._make_stage(16 * widen, blocks_per_stage, stride=1) self.stage2 = self._make_stage(32 * widen, blocks_per_stage, stride=2) self.stage3 = self._make_stage(64 * widen, blocks_per_stage, stride=2) self.fc = nn.Linear(64 * widen, num_classes) def _make_stage(self, planes, num_blocks, stride): blocks = [] for i in range(num_blocks): s = stride if i == 0 else 1 # 只在首个 block 下采样 use_proj = (s != 1) or (self.in_planes != planes) blocks.append(BasicBlock(self.in_planes, planes, s, use_proj)) self.in_planes = planes return nn.Sequential(*blocks) def forward(self, x): out = F.relu(self.bn_stem(self.stem(x))) out = self.stage1(out) out = self.stage2(out) out = self.stage3(out) out = F.adaptive_avg_pool2d(out, 1).flatten(1) return self.fc(out)blocks_per_stage传 3 就是 ResNet-20,传 5 是 ResNet-32,传 7 是 ResNet-44,传 9 是 ResNet-56,层数 = 6 × n + 2。widen是通道倍率,默认 1,想试 WideResNet 的思路时把它调到 2 或 4,通道翻倍但层数不变,往往比单纯加深度收益更明显——这是我在小数据集上反复验证过的一条经验。
| 模型 | blocks_per_stage | 参数量(约) | CIFAR10 测试精度参考 |
|---|---|---|---|
| ResNet-20 | 3 | 0.27M | 91.5% ~ 92.0% |
| ResNet-32 | 5 | 0.47M | 92.3% ~ 92.8% |
| ResNet-44 | 7 | 0.66M | 92.5% ~ 93.0% |
| ResNet-56 | 9 | 0.85M | 92.8% ~ 93.3% |
表里的精度是标准训练配方(随机裁剪 + 翻转 + 余弦退火 + 200 epoch)下的大致区间,不是绝对红线。有人跑到 94% 以上,通常是在配方上加了东西(标签平滑、EMA、更好的增强),而不是网络本身变了。反过来,如果 ResNet-20 只跑到 88%,先别急着换更深的模型,八成是训练循环或数据管线的问题,后面第 5 章会具体说。
3. 数据管线与训练循环:一个 batch 从磁盘到 loss 的完整链路
3.1 CIFAR10 的下载、归一化与增强顺序
CIFAR10 一共 60000 张 32×32 的彩色图,50000 训练、10000 测试,十个类别各占十分之一,类别均衡这点省了不少心。数据部分看着简单,但坑全在顺序上:
import torch from torchvision import datasets, transforms # 训练集的真实逐通道均值/标准差,直接写死比每次现算省事 MEAN = (0.4914, 0.4822, 0.4465) STD = (0.2470, 0.2435, 0.2616) train_tf = transforms.Compose([ transforms.RandomCrop(32, padding=4), # 先四周补 0 到 40,再随机裁回 32 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转,CIFAR 语义不变 transforms.ToTensor(), # HWC uint8 -> CHW float [0,1] transforms.Normalize(MEAN, STD), # 必须在 ToTensor 之后 ]) test_tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(MEAN, STD), # 测试集只做归一化,不做任何随机 ]) train_set = datasets.CIFAR10(root="./data", train=True, download=True, transform=train_tf) test_set = datasets.CIFAR10(root="./data", train=False, download=True, transform=test_tf)RandomCrop(32, padding=4)是 CIFAR 上性价比最高的增强,等价于先给四周补零到 40×40 再随机抠一块 32×32 出来,模型见到的每张图位置都略有偏移,泛化能涨一到两个点。Normalize必须放在ToTensor后面,因为归一化操作的是浮点张量;如果顺序反了,ToTensor会把已经归一化到 [-1,1] 附近的数值再除以 255,等于把数据压成一坨接近 0 的数,训练直接不收敛。
归一化的均值标准差要用 CIFAR 自己的统计量,不要图省事抄 ImageNet 的(0.485, 0.456, 0.406)。用错统计量不会报错,但相当于给每个通道乘了一个不匹配的系数,BN 层虽然能吸收一部分,收敛速度和最终精度都会受影响。上面这组数值是对训练集按通道算出来的,我一般在项目里直接写成常量,避免每次启动都遍历一遍数据集。
3.2 训练循环骨架:loss、优化器与梯度裁剪
一个 epoch 的核心逻辑就四步:前向、算 loss、反向、更新。写清楚这四步比堆一堆花活更重要:
import torch import torch.nn as nn def train_one_epoch(model, loader, optimizer, criterion, device, clip=1.0): model.train() # 切到训练模式,BN 用 batch 统计量 total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) # 置 None 比置 0 省一点显存 logits = model(images) loss = criterion(logits, labels) loss.backward() # 梯度裁剪:防止个别 batch 梯度爆炸把权重带飞 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=clip) optimizer.step() total_loss += loss.item() * labels.size(0) correct += (logits.argmax(dim=1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total几个参数值得单独说。optimizer.zero_grad(set_to_none=True)在新版 PyTorch 里是推荐写法,把梯度张量设成 None 而不是填零,省一次写内存的操作,训练大模型时差别明显,小模型上主要是个习惯问题。clip_grad_norm_(max_norm=1.0)对 CIFAR 这种小任务来说不是必需品,前几十个 epoch 梯度一般很温和,但一旦你把学习率调大或者加了比较激进的增强,某个 batch 突然给一个大梯度,权重能被带到一个很差的区域,之后几十个 epoch 都缓不过来。加上它相当于买了个后悔药,代价几乎为零。
model.train()这一行不能漏。BN 层在训练和评估两种模式下行为完全不同:训练时用当前 batch 的均值和方差,评估时用滑动平均。如果评估阶段忘了model.eval(),测试集的准确率会莫名其妙地波动,同一份权重量两次能差好几个点,这是新手最常遇到的玄学问题之一。
损失函数用标准的交叉熵,优化器用带动量的 SGD 而不是 Adam,这点在小数据集上差距很明显:
model = ResNetCIFAR(blocks_per_stage=3).to(device) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = torch.optim.SGD( model.parameters(), lr=0.1, # 初始学习率,batch size 128 时的常用值 momentum=0.9, weight_decay=5e-4, # CIFAR 上非常稳的一个值 nesterov=True, )SGD + momentum 在 CIFAR 上收敛到的解通常比 Adam 泛化更好,同样跑 200 epoch,Adam 在训练集上拟合得更快,测试集却经常差零点几个点。label_smoothing=0.1是后来加进CrossEntropyLoss的官方参数,不需要自己写标签平滑的 loss,它对小数据集抑制过拟合的效果很直接,代价是训练 loss 的数字看起来没那么漂亮——这是个心理门槛,习惯就好。
3.3 学习率调度与 checkpoint 的存取
学习率调度对最终精度的影响不比网络结构小。CIFAR 上最常见的是两种:多步衰减(在第 100、150 epoch 各降 10 倍)和余弦退火(从初始值平滑降到 0)。资源里两种应该都有对应的实现,我一般默认用余弦退火配一个短 warmup:
import math def cosine_with_warmup(optimizer, warmup_epochs, total_epochs, base_lr, step): """step 是全局的迭代步数,不是 epoch 数""" total_steps = total_epochs * steps_per_epoch warmup_steps = warmup_epochs * steps_per_epoch if step < warmup_steps: # 前几个 epoch 线性从 0.01 倍爬到 1 倍,避免一上来就把 BN 统计量带偏 lr = base_lr * (0.01 + 0.99 * step / warmup_steps) else: progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) lr = 0.5 * base_lr * (1.0 + math.cos(math.pi * progress)) for group in optimizer.param_groups: group["lr"] = lr return lrwarmup 的意义在于:训练最开始的那几百步,BN 的滑动统计量还没稳定,如果直接用 0.1 的学习率,前几个 batch 的梯度方向噪声很大,权重容易被推到不好的位置。用一个从 0.001 起步的爬坡期,等于给网络一点时间把统计量校准。这套写法在 transformer 圈子里更常见,但放在 CIFAR 的 ResNet 上同样有效,尤其是 batch size 开得比较大的时候。
checkpoint 的存取容易被写残,最常见的错误是只存model.state_dict():
def save_ckpt(path, model, optimizer, scheduler_step, epoch, best_acc): torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "epoch": epoch, "global_step": scheduler_step, "best_acc": best_acc, }, path)只保存模型权重的话,中断后想接着训就得从头调学习率,等于放弃了之前所有的退火进度,恢复后精度会明显地先掉一截再爬回来。把 optimizer 的状态、全局步数、最好的精度一起存进去,恢复时按原轨迹继续,才叫真正的断点续训。optimizer.state_dict()里包含动量缓冲区,这部分丢了影响尤其大,SGD 的动量是跨 step 累积的,重置一次要好几十步才能重新建立起来。
4. 训练配方:学习率曲线、正则与评估口径的取舍
4.1 三条学习率曲线的实测差别
同一份网络定义,换学习率策略,CIFAR10 上的测试精度能差出接近两个点。我用 ResNet-20 跑过三组对照,都是 200 epoch、batch size 128、其余配置一致:
| 策略 | 配置 | 测试精度 | 特点 |
|---|---|---|---|
| 固定学习率 | lr=0.1 全程不变 | 89.5% 左右 | 后期在最优解附近来回震荡,收敛不彻底 |
| 多步衰减 | 100/150 epoch 各 ×0.1 | 91.8% 左右 | 稳定,但对衰减点的位置敏感 |
| 余弦退火 + 5 epoch warmup | 平滑降到 0 | 92.3% 左右 | 后期学习率极小,相当于自动做精细搜索 |
多步衰减的敏感点在于那两个衰减 epoch 的位置。设在 100 和 150,是因为总轮数是 200;如果你把总轮数改成 100 但衰减点没改,后半程学习率就没降下去,最后十来个 epoch 精度会一直在抖。余弦退火没有这个烦恼,它只依赖总步数,把总轮数改了曲线自动跟着缩放,这也是我后来基本只用它的原因。
余弦退火有个副作用:训练末段学习率接近 0,模型几乎不再更新,如果这时候验证精度刚好在一个局部高点,你看到的数字会比模型真实水平略乐观一点点。这不是大问题,但如果你要拿这个数去和别人比,最好把「最后 5 个 epoch 的平均精度」也报出来,比单点更实在。
4.2 权重衰减与标签平滑的配合
weight_decay=5e-4是 CIFAR 上的经典值,改这个参数之前先确认两件事:一是你用的优化器是不是 SGD,Adam 系的最优权重衰减通常在 1e-4 到 1e-2 之间,还牵扯到 AdamW 的解耦实现,不能直接照搬;二是这个值是和 batch size 绑定的,batch 从 128 翻到 512,等效的正则强度会变,常见做法是线性放大到 2e-3 附近再微调。
标签平滑设 0.1 意味着真实类别的目标概率是 0.9,剩下 0.1 均摊给其他九类。它的作用和权重衰减有重叠,都是抑制过拟合,所以两个一起上的时候不要都往大调。我见过有人把weight_decay调到 1e-3 又加上 0.2 的标签平滑,结果训练 50 epoch 后训练集精度还不到 85%,模型根本没学起来——正则过强的时候,现象和「学习率太小」几乎一样,都是 loss 降得慢,容易误判。
判断到底是正则太强还是学习率不对,有个简单办法:把正则全部关掉跑 20 个 epoch,如果训练集精度能迅速冲到 95% 以上,说明模型容量和学习率都没问题,是正则压太狠了;如果关掉正则还是学不动,那问题在前向或者数据侧,跟正则没关系。
4.3 评估口径:best checkpoint、EMA 与 TTA
评估这件事看着只是model.eval()加一个循环,实际上口径不统一,同一份权重量出来的数能差一个点。
第一个口径是取哪个 checkpoint。训练过程中保存「验证精度最高」的那一份,比起取最后一个 epoch,通常能高 0.3 到 0.8 个点,因为余弦退火末段虽然稳,但单点波动仍然存在。代价是这份权重在验证集上被「挑选」过,如果验证集和测试集划分不严谨,报出来的数字会偏乐观。CIFAR10 的测试集是官方切好的,不存在这个问题,但换成自己的数据集时要注意验证集和测试集的隔离。
第二个口径是 EMA(指数滑动平均)。训练时额外维护一份参数的滑动平均,评估时用这份而不是当前权重,对小数据集上的 ResNet 通常能稳定涨 0.3 个点左右:
class EMA: def __init__(self, model, decay=0.999): self.decay = decay # 深拷贝一份参数,不参与梯度 self.shadow = {k: v.detach().clone() for k, v in model.state_dict().items()} @torch.no_grad() def update(self, model): for k, v in model.state_dict().items(): if v.dtype.is_floating_point: self.shadow[k].mul_(self.decay).add_(v.detach(), alpha=1 - self.decay) else: self.shadow[k].copy_(v) # BN 的 num_batches_tracked 是整型整型参数(BN 层里的num_batches_tracked)不能做浮点滑动平均,必须直接拷贝,不加这个判断会报类型错误。decay=0.999对应的有效窗口大概是一千步,200 epoch、每 epoch 391 步的话覆盖不到半个 epoch,偏短;想更平滑可以调到 0.9999,但要注意训练早期的参数还没稳定,EMA 会把早期的垃圾权重也平均进去,通常前 10% 的步数不做更新。
第三个口径是 TTA(测试时增强),最常用的就是原图加水平翻转各跑一次,两个 softmax 输出取平均。成本翻倍,收益 0.2 到 0.4 个点,值不值得看你的场景。竞赛里常上,工程部署里基本不用,因为推理延迟翻倍是实打实的代价。
5. 避坑与排查:五条训练现场踩出来的记录
5.1 训练侧的三个高频问题
现象一:训练 loss 前几个 epoch 一直不降,卡在 2.3 左右不动。原因通常是学习率写小了,或者优化器压根没拿到参数。有一类很隐蔽的写法是先建优化器再model.to(device),如果中间做过model = model.cuda()这种重新赋值,优化器里持有的还是旧的参数对象,梯度算在旧参数上,step()更新的是另一份,loss 自然不动。解决方式很简单:永远在model.to(device)之后创建优化器,并且打印一次len(list(model.parameters()))和len(optimizer.param_groups[0]["params"])对一下数量。
现象二:训练精度 99%,测试精度 60% 出头。八成出在评估管线。常见的是测试集的 transform 里保留了RandomCrop或RandomHorizontalFlip,每次评估看到的图都不一样,指标自然飘;另一个可能是忘了model.eval(),BN 用 batch 统计量在测试集上前向,小 batch 下统计量噪声极大。还有一个容易忽略的点:测试集的Normalize用了和训练集不同的均值,或者干脆没做归一化。逐项对一遍 train_tf 和 test_tf,除了随机增强那两行,其余必须完全一致。
现象三:训到一半 loss 突然变成 nan,之后再也救不回来。先看是不是学习率太大,尤其是从 warmup 切到正式学习率的那一刻,如果 base_lr 设成 0.5 这种激进值,一两个 batch 就能把权重炸掉。其次检查数据里有没有异常值,自定义数据集常见的情况是某张图读出来全是 255 或者全是 0,归一化后数值超出正常范围。最后一种可能是混合精度训练时 loss scale 没配对,fp16 的梯度下溢成 0 之后,下一步的更新就会出 nan。排查手段是把torch.autograd.set_detect_anomaly(True)打开跑几个 batch,它会指出反向传播里第一个出问题的算子。
5.2 数据与环境侧的两个坑
现象四:Windows 上DataLoader(num_workers=4)直接卡死,或者每个 epoch 结束时报一堆进程退出错误。这是num_workers > 0在 Windows 上的经典问题,根因是子进程会重新 import 主模块,而主模块里的训练代码没有放在if __name__ == "__main__":下面,于是每个 worker 启动时都试图重新跑一遍训练。解决办法有两个:把入口逻辑放进 main guard,或者干脆把num_workers设成 0 跑通再说。Linux 上因为默认用 fork,不容易暴露这个问题,所以「在服务器上好好的,拷到本地就卡死」是常见现象。
现象五:换个环境跑,精度掉了一个多点,代码一行没改。先怀疑随机种子和数据加载顺序。torch.backends.cudnn.benchmark=True会让卷积算法自动选最快的实现,不同显卡、不同 cuDNN 版本选出来的算法不一样,浮点累加顺序不同,结果就会有细微差别。追求可复现的话把它设成False,并把torch.manual_seed、random.seed、numpy.random.seed三个一起设,DataLoader的worker_init_fn也要固定。完全不差是不可能的,多卡训练里梯度规约的顺序本身就不确定,一般能控制在 0.2 个点以内就算复现成功。
5.3 一个能直接用的排查脚本
遇到「不知道哪一步出问题」的时候,别急着改超参,先把一个 batch 拆开看:
import torch def sanity_check(model, loader, device): model.eval() images, labels = next(iter(loader)) print("输入范围:", images.min().item(), images.max().item()) # 应在 -2.5 ~ 2.5 print("标签分布:", torch.bincount(labels, minlength=10).tolist()) with torch.no_grad(): logits = model(images.to(device)) print("输出形状:", tuple(logits.shape)) # 应为 [B, 10] print("初始 loss:", torch.nn.functional.cross_entropy( logits, labels.to(device)).item()) # 应为 ln(10) ≈ 2.30 # 检查梯度是否真的流到了每一层 model.train() loss = torch.nn.functional.cross_entropy( model(images.to(device)), labels.to(device)) loss.backward() for name, p in model.named_parameters(): if p.grad is None: print("梯度为 None:", name) # 出现即说明该层没参与计算初始 loss 接近 2.30 是个很好的健康检查点:如果明显大于这个值,说明输出 logits 的尺度不对(可能是最后一层初始化太大);如果明显小于,说明标签泄漏或者模型直接读到了答案。梯度为 None 的参数列表更重要,走一遍就能发现哪些层被定义了但没用上——这是拼接网络、加分支模块时最常见的低级错误。
6. 二次开发:换骨干、换数据集与加模块的三条改造路径
改这份代码之前,先想清楚你要动的是哪一层:是换网络结构、换数据、还是加训练技巧。这三件事的改动范围完全不同,混在一起改,出了问题很难定位。
| 改造目标 | 需要动的文件 | 关键改动点 | 验证方式 |
|---|---|---|---|
| 换骨干(ResNet → WideResNet / 加 SE) | models/resnet.py、models/__init__.py | 改widen参数或插入 SE 模块,保持 stage 输出通道约定 | 参数量对照表,前向 shape 检查 |
| 换数据集(CIFAR100 / 自建) | data/、configs/*.yaml | 类别数、归一化统计量、增强策略 | 先跑 5 epoch 看 loss 是否正常下降 |
| 加训练技巧(EMA / 混合精度) | utils/train.py、main.py | 权重更新时机、loss scale 与反缩放 | 与 baseline 同种子对比最后 5 epoch 均值 |
换骨干这条路径最省事。想试 WideResNet 就动widen,把 1 改成 4,通道从 16/32/64 变成 64/128/256,参数量从 0.27M 涨到 2.7M 左右,CIFAR10 上能到 94.5% 以上,代价是训练时间大概翻三倍。加 SE 模块的话,在 BasicBlock 的conv2之后插一个通道注意力,注意 SE 里的全连接降维比例别太小,reduction=16在通道数只有 64 的时候会把中间维度压到 4,信息损失太大,改成 8 更稳。
换数据集最容易出问题的是归一化统计量和类别数这两处。CIFAR100 的均值标准差和 CIFAR10 不一样,直接从data/cifar10.py复制过来改个名字,前面几个 epoch 的 loss 曲线会明显更抖。类别数改了但忘了改self.fc = nn.Linear(64 * widen, num_classes),训练能跑,评估时argmax出来的索引对不上标签,精度永远是随机水平。我一般的做法是写一个assert num_classes == len(dataset.classes),让它在启动时就炸掉,而不是训完两小时才发现。
还有一个改动量很小但收益明显的点:把torch.compile(model)加上。新版 PyTorch 里这一行对 CIFAR 上的小模型能提速 20% 到 40%,代价是第一次编译要等一会儿,而且遇到不支持的算子会回退到 eager 模式。加之前先确认你的 CUDA 和 PyTorch 版本匹配,版本不对的时候torch.compile的报错信息很不友好,能让你查一下午。
那条血泪经验是这样的:任何一次「只改了一点点」的改动,都要拿同一个随机种子跑一遍 20 epoch 的短周期,和改动前的结果并排看 loss 曲线,而不是直接上 200 epoch。我有一次信心满满地把标签平滑从 0.1 调到 0.2,觉得肯定涨点,跑完一整轮发现掉了 0.6 个点,回头查才发现是那一版代码里weight_decay忘了同步下调,两个正则叠在一起把模型压死了。从那以后我每次动超参都强制走一遍 20 epoch 的对照,曲线对不上就不往下跑。希望帮到你。
本文还有配套的精品资源,点击获取