我花了整整一个下午调ResNet-18的收敛曲线,最后发现问题出在初始化上——这不是个例,很多初学者刚接触残差网络时,都会被"为什么我搭的ResNet不收敛"这类问题卡住。这篇实战笔记不打算从论文公式讲起,而是直接围绕"跑通一个能用的ResNet-18"这个目标,把我踩过的坑、验证过的做法、以及一些容易忽略的细节全部写出来。
1. 为什么选ResNet-18当实战入门模型
1.1 从层数焦虑说起
先聊一个很实际的问题:现在开源模型那么多,Vision Transformer、Swin Transformer、ConvNeXt都挺火,为什么还要拿ResNet-18练手?我的观点是:ResNet-18是性价比最高的"骨架理解工具"。它的参数量只有约1100万,在单卡GPU上训练CIFAR-10大概十几分钟就能看到趋势,比动辄几小时的ViT-Small友好太多。
更重要的是,ResNet-18几乎包含了残差结构要讲的全部核心知识点:
- 基础残差块(BasicBlock)的实现,包括恒等映射和投影短接两种路径;
- 四个阶段的通道数变化逻辑(64→128→256→512);
- 下采样靠stride=2的卷积完成,而不是池化;
- BatchNorm在残差块中的位置与行为。
当你把ResNet-18彻底弄明白,往后再看ResNet-50甚至ResNet-152,本质上只是把BasicBlock换成Bottleneck,再加卷积核数量调整而已。这也是我把这个模型当作一条主线,而不是直接跳到更复杂结构的原因。
1.2 ResNet-18到底在解决什么问题
在残差网络出现之前,网络不是"越深越强"的。你会发现20层左右的VGG还能正常训练,但直接叠到56层,训练集上的loss反而比20层还高。这被称为退化问题——注意它不是过拟合,因为训练误差也在上升。
退化问题的根源,在于深层网络在反向传播时,梯度经过了太多层连乘。即便有ReLU在中间起缓冲作用,深层网络的优化难度依然显著增加。残差结构的作用,是让每个块学习的是"变化量"(残差),而不是从头映射一遍。用大白话说:一条输入进来,块内只负责算"还要改多少",跟原输入加在一起就是输出。这个设计极大降低了优化难度。
我处理过一个直觉上的对比实验:同一个数据集,把残差块内的两个卷积直接串联(不带shortcut),精度始终上不去。换上shortcut之后,同样的训练步数,loss掉得更快,稳定性和最终精度都好很多。这直接验证了恒等映射在优化层面带来的帮助。
1.3 什么时候用ResNet-18最合适
ResNet-18最适合以下场景:
- 快速验证想法:比如你想测试一个新的数据增强策略,或者对比不同损失函数的收敛行为,一个够快、够稳的底座模型很重要;
- 小数据集分类任务:当数据量只有几千张甚至几百张时,ResNet-50和ResNet-101反而容易过拟合,ResNet-18的容量刚好合适;
- 资源受限的部署环境:模型小、计算量低,移动端或者嵌入式设备上跑起来相对友好。
当然,如果你的任务数据量很大,且你更关注精度上限,建议直接上ResNet-50或更强架构。ResNet-18的定位是"够用且好用",不是"最强"。
2. 环境准备与数据集预处理的关键细节
2.1 软件环境配置
我的实验环境是:PyTorch 2.1.0、Python 3.10、CUDA 11.8,单张RTX 3060 12GB显卡。如果你用CPU跑,也不是不行,只是CIFAR-10可能需要多等一会儿,建议先把迭代轮次调小。
安装依赖时注意一点:PyTorch版本和CUDA版本必须匹配。我见过太多人装了最新版PyTorch但CUDA Driver版本太老,结果torch.cuda.is_available()一直返回False。建议先跑下面这个检查脚本:
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"如果输出显示False,先别急着重装,检查nvidia-smi里的CUDA Version,再对照PyTorch官网的安装命令重新选择版本号。
2.2 数据集选择:CIFAR-10还是ImageNet子集
ResNet-18论文原始实验跑在ImageNet上,但完整ImageNet对个人开发者来说下载耗时、标注数据也大,实战入门阶段一般会用CIFAR-10/100。CIFAR-10单张图片32×32分辨率,数量为6万张,训练一个ResNet-18大概在一亿FLOPs级别,单GPU十几分钟一个epoch。
这里要提醒一个多数文章没强调的点:CIFAR-10和ImageNet上的ResNet-18,输入预处理是有差异的。ImageNet用224×224输入,网络第一个卷积层是7×7、stride=2、padding=3,后接一个3×3最大池化。而CIFAR-10输入是32×32,很多实现直接改用3×3卷积、stride=1、去掉第一个最大池化,这样能在小图上保留更多空间信息。
我自己写的代码采用了CIFAR-10版的输入stem,结构如下:
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)如果你直接把ImageNet版本代码原样拿到CIFAR-10上跑,7×7卷积核的stem会让32×32的特征图快速缩到16×16甚至8×8,精度往往不如3×3起步的方案。
2.3 预处理与数据增强策略
CIFAR-10训练时我用的增强组合是:随机裁剪(padding=4,尺寸32)、随机水平翻转、归一化。这里归一化的均值方差是数据集的全局统计量(CIFAR-10为(0.4914, 0.4822, 0.4465)和(0.2470, 0.2435, 0.2616))。很多初学者容易忽略,直接把mean和std全设成0.5,效果虽然也不差,但会略微影响训练稳定性。
稍进阶一点的增强如Cutout、Mixup、AutoAugment,都可以显著提升鲁棒性。我个人在CIFAR-10上试过Mixup(Alpha=1.0),大约能带来2~3个百分点的验证集提升,且收敛更平滑。如果你做的是小数据集分类,强烈建议加一种增强,否则ResNet-18过拟合的速度非常快。
transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ])2.4 数据加载器的Batch Size选择
Batch Size的选择直接影响收敛。有人用256,有人用128,在CIFAR-10上差异不大,但要注意:Batch Size越大,Learning Rate也要相应调大。我用的是128,对应的初始学习率0.1,余弦退火到0.0001。如果你把Batch Size改成64却不改学习率,收敛会变慢;改成256却不改学习率,前期可能直接冲爆。
数据加载器建议设置num_workers=4、pin_memory=True,这样GPU等待时间会明显减少。如果你用的是Windows系统,num_workers超过0有时会报错,这时可以直接设成2,或者把数据搬到Linux环境跑,省心一些。
3. 从零手写ResNet-18:结构拆解与代码实现
3.1 核心模块BasicBlock的实现逻辑
ResNet-18的核心是BasicBlock,它由两个3×3卷积组成,卷积之间接BatchNorm和ReLU,最后把输入加到输出上。这里有一个细节值得展开:
当输入通道和输出通道不一致,或者特征图尺寸减半时,shortcut不能直接相加,必须做一个Projection。常见方式是用一个1×1卷积调整通道数,且stride设置为2来对齐空间尺寸。这一层在PyTorch里实现为:
if stride != 1 or self.inplanes != planes: self.shortcut = nn.Sequential( nn.Conv2d(self.inplanes, planes, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(planes) )注意这里inplanes是上一阶段输出的通道数,planes是当前阶段的目标通道数。BasicBlock内部两个卷积的padding=1、kernel_size=3,这样经过stride=1时尺寸不变,stride=2时尺寸减半。
3.2 完整模型类代码
下面是我在自己项目里验证过的ResNet-18实现,去掉了无关冗余,直接可用:
import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion = 1 def __init__(self, inplanes, planes, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out class ResNet18(nn.Module): def __init__(self, num_classes=10): super().__init__() self.inplanes = 64 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, blocks=2, stride=1) self.layer2 = self._make_layer(128, blocks=2, stride=2) self.layer3 = self._make_layer(256, blocks=2, stride=2) self.layer4 = self._make_layer(512, blocks=2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, planes, blocks, stride): downsample = None if stride != 1 or self.inplanes != planes: downsample = nn.Sequential( nn.Conv2d(self.inplanes, planes, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(planes) ) layers = [] layers.append(BasicBlock(self.inplanes, planes, stride, downsample)) self.inplanes = planes for _ in range(1, blocks): layers.append(BasicBlock(self.inplanes, planes)) return nn.Sequential(*layers) 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这里还有一个容易踩的坑:bias=False。BatchNorm后面会做归一化,卷积层的bias在BN层会被抵消掉,所以没必要加。加了的后果不只是冗余,还可能在某些初始化情况下影响前期收敛。照抄的人如果不小心去掉bias=False,通常也不会报错,但没必要冒这个险。
3.3 恒等映射和Projection的边界情况
很多人写ResNet时会在每个BasicBlock里都判断是否需要downsample。实际上,只有跨越"阶段"的那个块的stride=2且通道翻倍,其他块都是直接相加。以ResNet-18为例:
layer1:输入输出都是64通道,特征图尺寸不变,恒等映射直接相加;layer2:输入64通道、输出128通道、stride=2,需要Projection;layer3和layer4同理。
如果你把downsample错误应用在每一个块上,模型照样能跑,但参数增多,且残差结构的优势会被削弱。判断标准很简单:看shape能不能直接相加,能就不加,不能就加。
3.4 前向传播时各层特征图尺寸变化
用CIFAR-10输入32×32来推演一遍,大家会清楚整个过程:
conv1:32×32不变,输出64通道;layer1:32×32不变,64通道;layer2:stride=2,16×16,128通道;layer3:stride=2,8×8,256通道;layer4:stride=2,4×4,512通道;avgpool:4×4 → 1×1,512通道。
如果你想画特征图可视化的热力图,通常取layer3或layer4的输出,因为这两层特征已经高度语义化。取conv1输出可视化的话,看到的更多是边缘、颜色纹理,意义不大。
4. 训练流程设计:优化器、学习率与训练策略的坑
4.1 优化器选择和权重衰减
ResNet-18的标准配置是SGD加动量,但很多人一上来就用Adam,这其实不一定更优。SGD+Momentum(momentum=0.9)配合合适的学习率调度,在CIFAR-10上往往能收敛到更高的精度。Adam虽然前期下降快,但很容易陷入比较宽的极小值,泛化能力不一定比SGD好。
权重衰减这里需要注意:不能设置得太大,否则残差连接会被过度约束,特征表达变弱。我常用的值是1e-4,偶尔用5e-4,但不会超过1e-3。还有一个细节,在PyTorch里weight decay默认作用在所有参数上,包括BN层的gamma和beta。我见过有人对BN层参数单独设置weight_decay=0,理由是BN参数本身就是尺度归一化的,不应该被L2正则影响。实测这个改动影响不大,但如果追求严格复现论文,建议分开处理。
4.2 学习率调度:从StepLR到Cosine Annealing
经典论文采用的学习率策略是"每30个epoch衰减10倍",比如初始0.1,在第30、60个epoch分别变成0.01和0.001,总共训练90个epoch。这个策略稳定、好用、结果可复现。
Cosine Annealing是我的另一个常用备选方案:CosineAnnealingLR(T_max=200)从0.1平滑降到接近0。对比下来,Cosine更适合训练更长轮次,前期收敛快,后期不容易震荡;StepLR则更直观,适合快速验证想法。二者在CIFAR-10上都能达到90%以上,差别不大。
optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=200)4.3 预热(Warmup)什么时候需要
如果从头训练一个ResNet-18,初始学习率直接设0.1,前几个step可能会看到loss异常增大,甚至直接产生NaN。这通常是因为模型初始化后早期梯度变化剧烈,过大的学习率导致参数被冲到不合适的区域。
预热的基本思路:前几个epoch把learning rate从0缓慢升到0.1,再进入正常调度。我在训练200个epoch时发现,前5个epoch预热已经足够稳定,loss曲线不会有突兀的尖峰。如果你的batch size更大、数据更复杂,建议把预热拉长到10个epoch。
PyTorch里有torch.optim.lr_scheduler.LinearLR和SequentialLR可以组合实现预热。不想引入额外库的话,也可以自己写一个简单的lambda函数:
def warmup_lambda(epoch): if epoch < 5: return (epoch + 1) / 5.0 else: return 0.5 * (1 + math.cos(math.pi * (epoch - 5) / 195))4.4 BatchNorm的行为与训练/验证模式的切换
这个坑真的很常见。模型的model.eval()和model.train()会切换BatchNorm的状态:
- 训练模式:使用当前batch的均值和方差来归一化,并更新running stats;
- 评估模式:使用训练阶段积累的running mean和running variance,不再更新。
如果在验证阶段忘记切换model.eval(),验证集的结果会虚高或抖动,因为每条验证样本都在被BatchNorm用整个batch的统计量做归一化,相当于"作弊"。很多人在测试集上表现差距大,可能不是模型问题,而是这个细节没注意。
还有一个冷门细节:迁移学习时,如果新数据集很小,冻结住前面层的BN参数,往往效果更好。因为数据分布差异太大,后面的层还没适应,前面的BN统计量频繁更新反而会导致特征分布跳跃。
5. 实战踩坑记录:不收敛、梯度爆炸与精度上不去的根因
5.1 Loss不下降:先查数据归一化
有一次我做对比实验,发现ResNet-18训练5个epoch,loss一直稳定在2.30附近,几乎不动。排查了半天,最后发现是数据归一化的问题——图片被ToTensor转换成了0~1区间,但没有减均值、除方差,导致初始输入分布偏离网络期望太多。
2.30这个数字很有迷惑性,它恰好等于CIFAR-10随机猜测的交叉熵损失(ln(10)≈2.3026)。如果你看到loss长时间停留在2.30左右且纹丝不动,说明模型压根没学到任何信息,基本可以确定是数据预处理的锅。
5.2 验证集精度上不去:检查下采样层
有次跑ResNet-18,训练集accuracy能到95%以上,但验证集只有88%,明显不对劲。我逐个检查了每一层的输出尺寸,发现layer2之后特征图大小不是16×16而是32×32。原因是我在BasicBlock的卷积里把stride全设成了1,只在池化层做了下采样,导致模型容量分配失衡。
这种情况下模型不是不能训练,而是深层阶段感受野不足,学到的特征偏浅层,泛化能力自然下降。排查方法很简单,打印每层输出形状:
x = torch.randn(1, 3, 32, 32) for name, layer in model.named_children(): x = layer(x) print(name, x.shape)5.3 输出NaN:学习率过大或数据异常
NaN问题偶发,一旦出现很头疼。常见原因按概率排序:
- 学习率太大导致梯度过冲;
- 输入数据包含NaN或无穷大值;
- 混合精度训练时梯度缩放设置不当。
如果是初始化后第一个step就NaN,优先调低学习率;如果训练到中途才出现,建议在loss.backward()之前检查一下输入,比如打印torch.isnan(x).any()。用FP16混合精度训练时,要确保GradScaler正确定义,并且只在scaler.scale(loss).backward()之后调用scaler.step(optimizer)。
5.4 精度一直卡在卡在92%附近,如何继续提升
ResNet-18在CIFAR-10上不做任何额外技巧,大概能到92%~93%;加了Mixup、Cutout、Cosine Annealing之后,能稳定到94%~95%。如果你卡在92%上不去,优先考虑三个方向:
- Label Smoothing:把硬标签换成平滑标签,能减少过拟合;
- 更强数据增强:Cutout或者Random Erasing,对CIFAR-10效果非常明显;
- EMA(指数移动平均):对模型参数做滑动平均,推理时用EMA版本,通常能再提升0.5%左右。
我自己的经验是,Cutout带来约1%~2%的提升,EMA大约0.3%~0.5%,两者叠加效果不错。
6. 模型验证与调优:准确率之外还要看什么
6.1 混淆矩阵与Top-K错误率
准确率是所有样本的宏观平均,但不够细。分类任务中,我最喜欢先看混淆矩阵。CIFAR-10的类别里,"猫"和"狗"是经典易混淆对,"鸟"和"鹿"也有一定重叠。如果这两类错得特别多,说明模型在学习纹理、形状之外,还没提取到足够区分性的语义特征。
混淆矩阵可以用sklearn.metrics.confusion_matrix快速得到,再用seaborn画热力图。注意类别名别用数字,改成实际标签名(airplane、automobile...),看起来直观得多。
6.2 特征可视化:把卷积核和特征图摊开看
ResNet-18第一层卷积核是64个3×3×3的滤波器,可视化时可以用torchvision.utils.make_grid把权重张量拼成网格图。你会看到一些边缘检测器、颜色滤波器,这是网络最底层的特征提取器。
更进一步,用hook把最后一个残差阶段的输出特征图抓出来,叠加到输入图像上,可以看到模型关注区域的响应热图。不过这个方法比较粗糙,严格来说需要Grad-CAM。如果时间有限,直接看中间层特征图也是一种快速判断模型是否学到有效特征的方式。
6.3 训练曲线记录的注意事项
训练过程记得把每个epoch的train loss、train acc、val loss、val acc都记录下来,用TensorBoard或者简单的CSV落盘。我的习惯是每5个epoch存一次模型权重,配合torch.save和checkpoint字典保存优化器状态、scheduler状态,方便中断后恢复训练。
今天训练没记录曲线,后面想分析从哪一轮开始过拟合,就只能靠回忆——这在实战中是致命的。记录曲线不是给谁看的,是给自己做决策用的。
7. 模型导出与部署经验补充
7.1 从PyTorch到ONNX的转换
如果训练结束后要部署,最常用的路径是转ONNX。PyTorch的torch.onnx.export相对成熟,但在转换ResNet-18时要注意:
opset_version建议不低于11;- 动态batch需要设置
dynamic_axes; - 如果使用AdaptiveAvgPool,ONNX转换兼容性良好,但某些老版本推理引擎可能不支持。
我的转换片段:
dummy_input = torch.randn(1, 3, 32, 32) torch.onnx.export(model, dummy_input, "resnet18.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=12)7.2 模型剪枝与量化思路
ResNet-18模型约44MB(FP32),量化为INT8后可以压到11MB左右,在CPU推理速度能提升2~3倍,精度损失一般控制在1%以内。但直接请使用训练后量化(PTQ)时,如果校准集数据量不够,BN统计量会偏移,精度损失可能达到3~5%。更好的方案是量化感知训练(QAT),在训练时就模拟量化误差。
剪枝方面,ResNet-18的layer4和layer3尾部卷积核冗余度较高。结构化剪枝时优先从这两个阶段下手,剪掉20%的通道,精度下降通常小于1%。需注意剪枝后BatchNorm的统计量需要重估计,最好做一次短周期的fine-tune。
7.3 端侧部署时的注意事项
如果部署到手机或边缘设备,除了模型大小,还要关注峰值内存和单次推理时延。建议用torch.jit.trace或者ONNX Runtime的C++接口。MobileNet等轻量网络可能在推理速度上更占优,但ResNet-18在中小型设备上也算"能打",关键是别用FP32直接裸跑。
我有一个小建议:在部署前用示例输入做一次完整的精度对齐测试。因为模型的预处理和后处理在部署框架里往往需要重新实现,归一化参数、transpose的顺序差一点,结果就会天差地别。
8. 从ResNet-18延伸:如何迁移到自己的数据集
8.1 改输入尺寸和类别数
ResNet-18的self.fc = nn.Linear(512, num_classes)这一行把输出改成你的类别数即可。如果你的图片不是32×32,而是224×224,建议把第一个卷积核改成7×7、stride=2、padding=3,并加上MaxPool,对应ImageNet版结构。这样有更充分的感受野,而不是直接把小图上的stem硬搬到高分辨率图。
8.2 全量微调还是冻结骨干
做迁移学习时,先评估你的数据量和它与预训练数据分布的差异:
- 数据量大、差异小:全量微调;
- 数据量小、差异大:冻结前两层,只训练后面阶段和fc;
- 数据量小、差异小:只训练fc,骨干完全冻结。
一个相对通用的经验是:先用预训练权重初始化,再全量微调,即使数据量只有几千张,效果也优于随机初始化。前提是你的数据增强做够,否则过拟合很快。
8.3 初始化和权重加载的检查
加载预训练权重时有一个常见问题:state_dict键名必须和模型完全一致。如果你在_make_layer里用了不同的变量名,加载时会报"Missing key(s)"错误。可以用strict=False加载,但建议仔细核对缺失的键,而不是盲目忽略。
当你想从ImageNet预训练权重迁移到CIFAR-10时,重点是忽略fc层的权重,因为你改了类别数:
pretrained_dict = torch.load("resnet18_pretrained.pth") model_dict = model.state_dict() pretrained_dict = {k: v for k, v in pretrained_dict.items() if k in model_dict and "fc" not in k} model_dict.update(pretrained_dict) model.load_state_dict(model_dict)其中"fc"这个键名取决于你模型里全连接层叫什么,务必要对应。
8.4 数据量很少时的一些额外技巧
如果只有几百张图,除了迁移学习,还可以考虑:
- 使用多层特征拼接(把layer2和layer3的输出一起送入分类头);
- 加入Dropout或DropBlock;
- 采用Focal Loss缓解类别不平衡。
ResNet-18本身对少量数据的过拟合能力很强,不加任何措施时训练集准确率可跑到99%,验证集只有80%出头,这部分差距就要靠上面这些手段弥补。
9. 最后附上一些我实测有效的配置参考
下面是几组我实测过的组合,直接作为起步配置是没问题的:
| 数据集 | 输入尺寸 | 优化器 | 初始学习率 | Epoch | 数据增强 | 验证精度(参考) |
|---|---|---|---|---|---|---|
| CIFAR-10 | 32×32 | SGD | 0.1 | 200 | 裁剪+翻转+Cutout | 95.2% |
| CIFAR-10 | 32×32 | SGD | 0.1 | 90 | 裁剪+翻转 | 92.8% |
| 自定义小型数据集 | 224×224 | AdamW | 1e-3 | 50 | 翻转+旋转+Mixup | 视数据而定 |
精度数据只是我本地的参考值,不同增强、不同随机种子会有浮动。但整体趋势很明显:增强和调度策略带来的提升,比换更大模型更明显。
我的建议是:从这套代码跑通开始,然后把增强、调度、优化器这三个旋钮一个一个调,把每步改动记录成对比表。训练深度学习模型的真正竞争力,不在于背会某个结构,而在于调试时的判断力和排查链路。ResNet-18是最好的训练场:模型结构简单、训练速度快、问题暴露充分,一套流程走完,你对深度学习训练的理解会上一个台阶。