☰
ResNet-18实战全解析:从残差思想到训练调优
2026/10/16 7:52:44 网站建设 项目流程

前一阵子我把手头一个图像分类小项目翻新了一遍,翻来翻去最后还是选了ResNet-18当骨干网络。不是因为它新,恰恰是因为它老而稳。从残差结构被提出来到现在,大家早已形成共识:想快速验证想法、做小规模分类、迁移学习微调,ResNet-18就是那套“标准答案”之一。这篇实战笔记基于“深度学习2.0”系列第35期的内容整理,把从残差思想到18层结构,再到训练调试、问题排查的完整链路复述一遍,既写给正在入门图像分类的同学,也写给那些想在业务里快速落一个骨干网络做预研的人。看完你至少能回答三件事:18这个数字怎么来的,残差连接为什么能解决深度退化,以及训练时loss不降该从哪几个方向下手。

1. 为什么是ResNet-18:残差思想与实战价值

1.1 从“层数越深越难训练”说起

在深度学习还没如今这么“卷”的时候,大家默认的思路是:模型效果不够,就堆层数。VGG系列把网络堆到16层、19层,效果确实一代比一代好。但继续往下堆的时候,问题来了:网络变深,训练集的loss反而升高了。这不是过拟合,因为过拟合时训练loss应该很低、验证loss升高;也不是梯度直接消失到零,因为训练完全走不动。这种现象后来被归结为“深度退化(Degradation)”。

早期解释把责任推给梯度消失和梯度爆炸,但实际实验里加了各种初始化、加了归一化之后,退化现象依然存在。于是问题变成本质性的:单纯增加层数,到底引入了什么坏东西?后来残差结构的思路说得很直白:既然加深网络会破坏已经学好的映射,那我就在深层网络的旁边加一条“短接线”,让深层哪怕学不到东西,也至少能把浅层学到的特征原样传上来。这个思路朴素得不像一个能改变行业格局的方案,但它就是有效。

1.2 恒等映射与残差学习的意义

残差块的数学表达很简单:假设某个堆叠层想要拟合的映射是H(x),我们不直接让它学H(x),而是让它学F(x)=H(x)-x,最后输出时再把输入x加回来,得到H(x)=F(x)+x。x这一路就是“恒等映射捷径”。

这样设计带来的好处先体现在“梯度路径”上。反向传播时,梯度可以从深层直接经过这条捷径流回浅层,公式上就是 ∂H/∂x = ∂F/∂x + 1。那个“+1”保证了即使在极端情况下∂F/∂x趋近于零,梯度仍然有一条通路可以回传。用大白话说:信息可以从网络前面直接“抄近道”传到后面,梯度也可以从后面直接“抄近道”传回前面,网络变深再也不用担心信息被层层稀释。

另一个常被忽略的语义是:残差块让网络“至少不倒退”。如果F(x)学出来的东西没用,网络可以把它压到接近0,输出近似等于恒等映射;如果F(x)有用,就在原有特征上做增量更新。这种“在已有能力上做加法”的学习方式,比让每一层都从零开始拟合完整映射要容易得多。

1.3 为什么实战选18而不是50或101

我们看着50层、101层、152层的ResNet刷榜单,但实际做项目时,除非数据量和算力都极其充裕,否则我不会上来就选大模型。用某开源图像数据集做实验时,ResNet-18和ResNet-50在测试集上的差距往往不到1到2个点,但训练时间和显存占用却是实打实的差距。18层模型参数量约1100万,50层直接到2500万以上,推理速度也明显变慢。

另一个原因在于调试效率。模型越大,训练一轮要等的时间越长,超参数试错的成本就越高。先用18层把数据处理、增强策略、学习率调度全部跑通,再平滑迁移到大模型上,已经是团队里约定俗成的套路。做迁移学习时,ResNet-18的预训练权重体积小、部署友好,边缘设备也能跑得动。总之,18层适合做基线和快速验证,50层及以上适合冲精度和做复杂任务的骨干,两者并不冲突,只是使用场景不同。

2. 架构拆解:18层的数字从哪来

2.1 逐层结构表

很多人听过ResNet-18,但说不清18到底怎么数出来的。我习惯按“卷积层数”来数,全连接分类层不计入。如下表所示:

阶段结构输出尺寸通道数
Stem7x7卷积,stride=2112x11264
Stage1BasicBlock x256x5664
Stage2BasicBlock x228x28128
Stage3BasicBlock x214x14256
Stage4BasicBlock x27x7512

每个BasicBlock由两个3x3卷积组成。把表格里的卷积层加起来:Stem的1个卷积,Stage1到Stage4每个阶段2个块、每个块2层卷积,那就是4x2x2=16层卷积,再加上最后的全连接分类层,总共18层。这个数字就是这么来的。

值得注意的下采样策略:Stage2、Stage3、Stage4的开头,BasicBlock的第一个卷积stride取2,让特征图尺寸减半、通道数翻倍。通道翻倍意味着每层卷积的参数量按平方增长,所以越往后网络越贵,这也是ResNet系列的一个基本设计哲学。

2.2 BasicBlock和Bottleneck的区别

ResNet-18和34使用BasicBlock,也就是两个3x3卷积串联;ResNet-50及以上使用Bottleneck,也就是“1x1降维-3x3卷积-1x1升维”的三明治结构。先解释一下为什么大模型要换结构。

Bottleneck先把通道数用1x1卷积压缩到原来的四分之一,比如256压缩到64,经过3x3卷积后,再用1x1卷积把通道拉回256。这样设计直接砍掉了3x3卷积的计算量。如果换成BasicBlock直接做两个3x3卷积,参数和计算量都会成倍上涨。对于50层以上的网络,计算量是能不能训得动的问题,所以必须用Bottleneck控制开销。

但18层本身参数不多,没必要引入Bottleneck。两个3x3卷积的感受野组合在语义上等价于一个5x5卷积,却比5x5卷积省参数,能提取的信息也更丰富,这是BasicBlock的优势。实战中如果想修改网络结构做消融实验,在BasicBlock外面卡一个1x1的“投影层”也很方便,后面我会聊到。

2.3 参数量的估算方法

模型参数量可以用手算清楚,这个基本功我建议都练一练。一层3x3卷积的参数量公式是:输入通道数x输出通道数x3x3,再加上偏置(一般只有几个,忽略不计)。Stem第一层输入是3通道,输出64通道,参数量是3x64x3x3=1728个。Stage1里的BasicBlock都是64进64出,一层卷积参数量是64x64x3x3=36864个,一个块两个卷积就是约7.4万个。Stage2输入64输出128,第一个卷积是64x128x3x3=73728个,第二个卷积是128x128x3x3=147456个。依此类推,最后把所有卷积层加起来,结果大约在1100万参数左右。

计算量则看输入尺寸,实际输入如果是224x224,ResNet-18理论计算量约1.8GFLOPs。如果换成32x32的小图,计算量会骤降到几千万FLOPs级别,这也是为什么小数据集上用ResNet-18训练一轮非常快。知道这些数字,选硬件、估训练时长时心里就有底了。

3. 实战环境与数据准备

3.1 数据集选择与预处理

这次实战用的是某公开的10分类图像数据集,图像分辨率32x32,训练集5万张、测试集1万张。这类小图数据集的优点是单卡就能跑完,训练速度快,适合把精力集中在网络和训练策略上,而不是耗在数据读取和分布式过程里。

预处理有几个必做项:一是先把图像统一缩放到合适尺寸,二是做标准化。小数据集通常自带RGB三通道,标准化时需要用整个训练集的均值mean和方差std,不是随便填。很多人直接套ImageNet的mean=[0.485, 0.456, 0.406]和std=[0.229, 0.224, 0.225],这对于小尺寸输入其实并不理想,因为数据集本身的像素分布可能完全不同。实测下来,用自己统计的均值和方差,收敛速度和最终精度都有改善。统计方法很简单:遍历一遍训练集,分别累计每个通道的像素和、像素平方和,最后除以总像素数得到均值,再开平方得到标准差。

3.2 环境与依赖的版本注意

我习惯的深度学习环境是Python 3.10.x配合主流开源深度学习框架,CUDA版本按显卡驱动来定,没有硬性要求,但需要注意框架的版本和CUDA版本必须匹配。老项目里经常见到因为框架和CUDA版本不匹配导致类似“sfotmax_cuda”加载失败的问题。推荐用虚拟环境管理依赖,把常用版本固化在一个requirement文件里,换机器复现实验时不会浪费一晚上去排环境问题。

训练日志我用最简单的文本记录:每个epoch结束后打印一次loss和准确率,同时用可视化工具画loss曲线和准确率曲线。很多新手只看最后的测试准确率,不理解训练过程曲线拐点的含义,其实训练曲线里的信息量非常大,后面我会专门写一段怎么读图。

3.3 数据增强策略

小数据集最怕过拟合,数据增强是成本最低的防过拟合手段。基本组合是随机裁剪加水平翻转。以32x32输入为例,先把图pad到40x40,再随机裁剪回32x32,相当于给模型提供了平移鲁棒性。水平翻转则直接翻倍了有效样本量。做图像分类时,我还会叠加一点颜色抖动,包括亮度、对比度、饱和度的随机扰动,让模型对光照变化不那么敏感。

增强策略不要加到“失真”的程度。曾经为了让模型更强壮,我把增强力度调得很大,结果训练集loss降到0.1,验证集却一直在及格线附近徘徊。问题就出在增强后的图像已经严重偏离原始分布,模型等于在学一套“变形数据”,测试时反而泛化不出来。增强的原则是“模拟真实分布里的自然变化”,不是把数据搞成抽象画。

4. 训练全流程:从模型定义到结果分析

4.1 模型定义与初始化

ResNet-18的定义我写过很多次,核心是BasicBlock的类和一个搭建整体网络的函数。以我的常用框架为例:

import math def conv3x3(in_channels, out_channels, stride=1): return Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) class BasicBlock: def __init__(self, in_channels, out_channels, stride=1, shortcut=None): self.conv1 = conv3x3(in_channels, out_channels, stride) self.bn1 = BatchNorm2d(out_channels) self.conv2 = conv3x3(out_channels, out_channels) self.bn2 = BatchNorm2d(out_channels) self.shortcut = shortcut def forward(self, x): identity = x out = relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) if self.shortcut is not None: identity = self.shortcut(x) out += identity return relu(out)

两个细节新手容易忽略:第一,卷积层里bias=False,因为后面紧跟着BatchNorm,BatchNorm的平移缩放已经能承担偏置的功能,再加bias纯属浪费参数;第二,当输入输出通道不一致时,shortcut必须用1x1卷积做投影,把x的通道数对齐到输出通道数,不能直接相加。

初始化也很有讲究。ResNet官方推荐的初始化方式是:卷积层用kaiming_normal,BatchNorm的权重初始为1、偏置初始为0。特意强调这一点,是因为我见过不少同学自定义网络后不初始化,结果训练一开始loss就在原地跳。在深度学习框架里,如果按官方推荐方式定义卷积和归一化层,系统多半会自动采用类似的初始化策略,但自己手写网络的时候还是要显式调用初始化函数,确保万无一失。

4.2 优化器与学习率策略

ResNet-18这种规模的网络,优化器我很少换,就是SGD加动量。动量取0.9,权重衰减取1e-4,这些参数都是经过大量实验验证过的组合,几乎可以直接“抄作业”。当然也可以试试AdamW,训练前期收敛快,但最后精度通常比精细调过的SGD略低一点,尤其在图像分类任务上。

学习率策略目前的主流方案有两种。一种是阶梯式衰减:初始学习率0.1,在第80个epoch、第120个epoch分别除以10,训练到160个epoch结束。另一种是余弦退火,从初始学习率按余弦曲线平滑降到接近0。两种我都实测过,阶梯式衰减在小数据集上表现稳定,余弦退火在迁移学习和更大数据集上更省心。个人经验是:如果只想快速出个baseline,阶梯式衰减最直接;如果时间充裕,试试余弦退火,往往能比阶梯式多出一截精度。

Batch size的选择也需要说明一下。经典论文里ResNet-18配的是128的Batch size,但那是基于ImageNet级别的大数据。在小数据集上,Batch size取128或256差别不大,关键是不能太小。Batch size小于32时,BatchNorm统计量不稳定,训练过程容易上下哆嗦。显存不够的时候,我优先选择“梯度累积”而不是硬调小Batch size,这样能保持统计量的稳定性。

4.3 训练循环的关键控制点

训练循环本身没什么花哨的,但有几个控制点值得单独拿出来讲。先说数据加载:训练阶段一定要打开随机打乱,测试阶段绝不能打开。每轮epoch结束,用当前模型在测试集上完整评估一次,记录测试准确率,同时把“当前最优模型”的权重单独保存一份。这里有个经验:评估频率可以每1个epoch一次,但如果训练总时长很长,评估成本也不低,可以改成2到3个epoch评估一次,节省的时间非常可观。

训练过程中我还会监控BatchNorm层的running_mean和running_var是否在合理范围。如果运行均值出现剧烈波动,通常说明网络在某一层发生了数值不稳定。另一个监控点是梯度的范数:每个epoch打印一次梯度范数,正常时它会随着训练进行而缓慢下降;如果出现梯度范数突然暴涨,那八成是学习率太大或者数据里有坏样本。

4.4 训练日志与曲线分析

一次典型的ResNet-18训练,曲线大致是这样的:前5个epoch准确率会从随机猜测的10%左右快速爬到40%到60%,loss同步大幅下降,这一段是“网络把最基本特征学明白了”;中间30到60个epoch准确率缓慢爬升,loss下降速度变慢,这时模型在学更细节的模式;最后几十个epoch阶段,准确率几乎走平,loss还在小幅波动下降,这个阶段要小心过拟合。

读图的时候,我最关心训练集loss和测试集loss的“剪刀差”。如果训练集loss持续下降、测试集loss先降后升,两者拉开明显距离,就是过拟合信号。此时我的常规操作是:增加数据增强强度、调大权重衰减系数、提前截断训练轮数。如果训练集和测试集loss都在高位不动,那不是过拟合,是欠拟合,问题多半出在学习率、网络容量或数据质量上,加数据增强反而会雪上加霜。

5. 常见问题与排查技巧实录

5.1 损失不降或直接NaN

损失不降是新手遇到最多的问题。第一反应先看学习率,学习率太大会导致loss震荡不收敛,太小则几乎不动。经验做法:把学习率设为0.1训练几个epoch,如果loss完全没有下降趋势,再把学习率调到0.01或0.001试。第二反应检查数据预处理:图像有没有归一化到0到1之间?均值方差用对没有?很多数据集加载后像素值默认是0到255,如果不做归一化直接喂给网络,训练初期loss会非常不稳定。第三反应是排查标签是不是从0开始连续编号的,如果标签从1开始,最后一层全连接的输出维度和交叉熵损失期待的范围对不上,训练过程自然崩溃。

输出NaN的情况一般集中在梯度爆炸或者数值溢出。常见原因有三个:学习率过大、BatchNorm的epsilon配置过小、loss计算时对概率取了对数而概率恰好为0。排查时先把学习率降到1e-4,如果不再出现NaN,就说明是学习率问题;如果还是NaN,就往模型前向传播里逐层打印中间张量的数值范围,定位是哪一层开始“飘”起来的。

5.2 训练集精度高、验证集精度低

这就是标准的过拟合。小数据集上ResNet-18有1100万参数,远超5万张图像的需求,模型完全可以把训练集背下来。过拟合的应对措施按优先级排序:第一,数据增强,这个前面说过,是最有效的正则化手段;第二,增大权重衰减,我从1e-4调到1e-3甚至5e-3做过实验,mix后测试集精度能回升2到3个百分点;第三,在网络全连接层前加Dropout或SpatialDropout,虽然卷积网络用Dropout的效果不如全连接网络那么明显,但在分类头加一个0.5概率的Dropout依然有效;第四,用early stopping,保存验证集表现最好的epoch对应的权重。

有些时候验证集精度低不是因为过拟合,而是评估方式有问题。比如验证集和训练集数据分布不一致、验证集本身样本量太少导致单次评估波动大。我的做法是:每次epoch评估时跑完整验证集,不做随机采样;如果验证集数据量过少,就改用多次采样取平均的方式评估。

5.3 精度卡在某个值上不去

训练一切正常,loss也在降,但准确率卡在某个阈值附近上不去。这种情况先确认是不是“上一次最优模型”没有被更新。很多人的训练逻辑是每个epoch都保存模型,但不比较测试集精度是否真的提升了,最后加载的可能是很早以前那个较差的模型,白白浪费训练时间。

排除这个之后,看是不是数据类别不均衡。10分类数据集如果某些类别样本特别少,模型会偏向放弃这些类别的预测,整体准确率就会被拉低。此时可以先看每个类别单独的正确率,找出明显异常的类别,再去查它的样本量和视觉特征是否与其他类别高度相似。还有一个隐蔽的原因是数据增强和测试不一致:训练时有随机裁剪和翻转,测试时如果忘了做中心裁剪,输入分布不一致,结果自然大打折扣。

5.4 排查速查表

现象可能原因优先检查项
loss不降学习率过小、数据未归一化打印loss值变化,核对预处理流程
loss为NaN学习率过大、数据有异常值降低学习率到1e-4,逐层打印中间张量
训练准高、验证准低过拟合增强数据增强、加大权重衰减
训练验证都低欠拟合、模型容量不足提高学习率或扩大模型
精度卡住最优模型未保存、类不均衡检查保存逻辑,统计各类别准确率
训练时间异常长Batch size过小、数据读取瓶颈增大Batch size,检查数据加载线程数

这个速查表看着简单,但排查时一定要按顺序来:先看数据、再看Loss曲线、最后动模型结构。很多人上来就改动网络结构,结果越改越乱。我自己踩过最大的坑就是“数据都没喂对就开始调参”,浪费了大半天时间。

6. 从18到更远的扩展

6.1 把18换成50需要注意什么

如果某个任务在ResNet-18上效果好,但还想再压榨几个百分点的精度,换到ResNet-50也不是不行,但有几个点必须注意。首先,模型定义里要把BasicBlock换成Bottleneck,同时逐阶段确认下采样卷积和shortcut投影的通道对齐关系;其次,Batch size最好相应减小,不然显存会撑不住;再次,学习率可能需要适当回调,因为大模型收敛更慢,在相同epoch数下的最优学习率通常低于小模型;最后,换大模型之前先确认手头的数据量是否足够,如果训练集只有一两万张,加大模型几乎必然带来更严重的过拟合。

6.2 迁移学习与微调的落地经验

实际业务里大多数图像分类任务不会从零训练。我会把ResNet-18在超大规模数据集上的预训练权重下载下来,冻结前几层,只训练后面的分类头和最后两三个stage,这样需要的训练数据量大幅减少,收敛速度也快得多。微调的学习率通常设置在1e-3到1e-4之间,比从零训练低一两个数量级,否则预训练学到的特征会被快速破坏。微调时把BatchNorm设为train模式也很关键,很多人冻结骨干网络时顺手把BatchNorm也冻结了,结果测试性能明显掉点,这是因为预训练模型的BatchNorm统计量和下游数据分布已经不匹配了。

6.3 可视化与消融实验的建议

训练完的模型别急着装箱,最好做两件事。第一件是把测试集的中间特征图或类激活图可视化出来,看看模型在关注图像里的哪些区域。如果分类依据明显不聚焦在物体主体上,比如把背景纹理当成了关键特征,后续部署时一旦换环境,模型就废了。第二件是做消融实验:把数据增强一项一项拆掉,看各自对精度的贡献;把残差连接去掉换成普通直连,对比精度差异,你会直观感受到当年ResNet为什么能赢。这些都是很好的汇报素材,也是真正理解模型行为的必经之路。

我个人在实际操作中最受用的一个习惯是:每一次实验只改动一个变量,并保留完整的实验记录。ResNet-18之所以适合实战,不只是因为它的结构经典,还因为它在小规模实验里给了你极高的迭代效率。用最小的成本把训练流程彻底跑通,之后再迁移到大模型或复杂任务上,整个思路都会清晰很多。如果现在正在为选什么主干网络纠结,我的建议是先跑通ResNet-18再说。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询