恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
残差网络 ResNet 深度解析:从残差块原理到深层网络训练实战
首页
资讯中心
/
残差网络 ResNet 深度解析:从残差块原理到深层网络训练实战
残差网络 ResNet 深度解析:从残差块原理到深层网络训练实战
发布时间:2026/10/17 10:34:57
第一次把网络从 20 层加到 30 层的时候我对着那条 loss 曲线确认了很久怀疑自己把训练代码写错了。训练集 loss 不降反升验证集准确率也跟着往下掉这明显不是过拟合——模型连训练数据本身都还没拟合好。后来我把 Deep Residual Learning for Image RecognitionResNet那套思路搬进自己的代码问题立刻改观。这篇博文想把残差学习这件事讲透它到底解决了什么、残差块在代码里长什么样、从 18 层到超过百层的残差网络该怎么选、怎么训、怎么排查。适合刚入门图像识别的人也适合已经会写卷积网络但总是练不深的人。只要你踩过“网络加深反而变差”的坑这篇应该能帮你少走不少弯路。1. 残差学习的核心思想让网络学“差值”而不是“答案”1.1 深层网络的退化不是过拟合是学不动在 ResNet 之前把卷积网络做深是一件很痛苦的事。直觉上层数越多模型表达能力越强效果应该越好。但实测结果恰好相反网络从 20 层加到 30 层训练误差反而更高验证误差也跟着恶化。很多人第一反应都是“过拟合了”可问题在于训练误差都还没降下去说明模型根本没有把训练集学明白这跟过拟合是两回事。这种现象后来被叫作“退化问题”。它不是梯度消失导致的完全不更新而是深层网络在优化上变得非常困难。可以把网络想象成一条很长的流水线每一层都要在前一层的基础上继续加工特征。层数越多任何一点轻微扰动都会被层层放大初始阶段每个卷积核的学习目标也变得极其复杂。即使加了 BatchNorm、用了更好的初始化让梯度能传回去深层网络依然很难被优化到一个好的局部最优解。ResNet 的贡献不是把网络加得更深而是改变了一个基本问题与其让一堆非线性层去拟合一个完整的映射 H(x)不如让它们去拟合残差 F(x)H(x)-x。换句话说网络不需要直接算出“标准答案”它只需要算出“答案和当前输入之间的差距”。1.2 残差块的数学与结构一条加法捷径一个标准的残差块可以写成y F(x, {W_i}) x其中 x 是当前块的输入F(x) 是两个或三个卷积层组成的非线性变换x 则通过一条 shortcut 连接直接加到主路输出上。这里的 shortcut 也叫跳跃连接或恒等映射它不引入额外参数也不增加计算量只是让输入和输出之间多了一条“直通车道”。打个比方让一个人直接背诵一篇 5000 字文章很难但如果告诉他文章已经写好了他只需要记住哪些地方需要改动任务就轻松很多。对网络来说如果恒等映射已经是最优解那么残差块只需要把 F(x) 学到 0 就行。让一堆权重去拟合“什么都不做”比让它们去拟合“复现输入又叠加新特征”要容易得多。这个特点让残差网络在网络很深的时候至少不会比“直接跳过这些层”更差这是普通堆叠结构做不到的。从结构上看残差块的加法发生在主路最后一层 BatchNorm 之后接着再统一过 ReLU。你可能会看到很多代码把 shortcut 分支也补上 BatchNorm那是为了在通道数或分辨率不匹配的时候对齐维度。后面我会专门讲这个实现细节。1.3 反向传播中的“高速公路”梯度为什么能保住残差真正厉害的地方还要从梯度反向传播的角度看。假设某个残差块的输出 y F(x) x那么在反向传播时loss 对 x 的梯度可以写成∂L/∂x ∂L/∂y · (1 ∂F/∂x)这里的“1”非常关键。在普通深层网络中梯度要从最后一层一直乘回第一层每一层的雅可比矩阵都可能让梯度变小连乘下来梯度很容易指数级衰减。但有了残差块之后梯度即使经过这条非线性主路时变小了也还有一条恒等路径直接传回去。也就是说梯度在网络里多了一条“高速公路”它不需要每次都穿透卷积和激活函数的连乘。这也是残差网络和“把输入简单跳过所有层”的区别。它的本意不是让网络变“浅”而是让梯度传播过程从纯乘法变成“乘法加法”。加法路径的存在让前层至少能在每个 block 处收到一份完整梯度模型训练时的优化压力和梯度消失风险都被明显降低。这也是为什么后来很多新网络设计没有完全抛弃卷积堆叠而是普遍吸收了这种跳跃结构。2. 残差块的两种实现BasicBlock 与 Bottleneck2.1 BasicBlock两个 3×3 卷积打天下对于 18 层和 34 层这类较浅的残差网络最常用的残差块是 BasicBlock。它由两个 3×3 卷积组成中间夹着 BatchNorm 和 ReLU。PyTorch 里一个标准写法是这样的import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels) ) 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 len(self.shortcut) 0: identity self.shortcut(identity) out out identity out self.relu(out) return out代码里有个很容易被忽略的点卷积都设了 biasFalse。为什么因为后面接了 BatchNormBN 里已经包含可学习的偏置和缩放参数卷积层如果再带 bias这两个偏置会冗余训练时还容易造成数值不稳定。shortcut 分支什么时候起作用只有当当前 block 的 stride 不等于 1或者输入输出通道数不一致时才需要。前者代表空间分辨率要减半后者代表通道数要翻倍。此时用一个 1×1 卷积把输入 x 投影到目标维度方便后续相加。如果输入和输出完全对齐shortcut 就只是一个恒等“直连”不引入任何参数。2.2 Bottleneck1×1 卷积先压缩再恢复ResNet-50 及以上的深层版本几乎不再使用 BasicBlock而是换成了 Bottleneck。它的结构是1×1 卷积 → 3×3 卷积 → 1×1 卷积。第一个 1×1 先把通道数降下来中间的 3×3 在低维空间做空间特征提取最后一个 1×1 再把通道数恢复回去。这里的目的很直接省计算量。假设输入输出都是 256 通道BasicBlock 要做两个 3×3×256×256 卷积Bottleneck 则先用 1×1 把通道压到 64中间用 3×3×64×64最后再用 1×1 恢复。粗略估算中间 3×3 卷积的乘法量会降到原来的 1/16 左右。虽然两边各多了一个 1×1 卷积但整体计算量仍然少很多。对训练速度和显存占用都非常友好。一个完整的 Bottleneck 实现如下class Bottleneck(nn.Module): def __init__(self, in_channels, mid_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, mid_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(mid_channels) self.conv2 nn.Conv2d(mid_channels, mid_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(mid_channels) self.conv3 nn.Conv2d(mid_channels, out_channels, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels) ) 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) out self.relu(out) out self.conv3(out) out self.bn3(out) if len(self.shortcut) 0: identity self.shortcut(identity) out out identity out self.relu(out) return out在深层网络中这个设计的意义不仅是省算力。通道先收缩再扩张也相当于在每一层强制做了一个信息瓶颈让 3×3 卷积更专注于空间结构而不是同时处理庞大的通道相关性。我在实践中发现换成 Bottleneck 之后相同算力预算下可以训练更深的模型收敛速度也更稳定。2.3 维度对齐shortcut 的三种处理思路残差相加有一个硬性条件主路输出和 shortcut 输出的形状必须完全一致。不一致主要出现在两个地方空间分辨率变小、通道数变化。最常见的方法是用 1×1 卷积投影也就是上面代码里的写法。1×1 卷积可以自由调整通道数同时通过 stride2 降低分辨率正好满足左下采样和通道对齐两个需求。早期也有一些替代做法比如在输入上直接补零、间隔采样、再用平均池化对齐尺寸。但从我的复现经验看1×1 卷积虽然增加了一些参数却让代码逻辑更统一训练也更稳定。补零方案虽然省参数但会让 shortcut 的特征分布和主路不一致相加时产生一种“硬塞”的效应。对深层网络来说稳定压倒一切。还要注意一点下采样操作放在 block 的第一个卷积层上而不是放在 shortcut 的末尾。这样主路与 shortcut 同时完成尺寸变化最终相加时两个分支的形状才对齐。如果搞反了要么训练直接报错要么形状悄悄不对导致莫名其妙的问题。3. 网络怎么搭从 ResNet-18 到 ResNet-1523.1 一张表看懂不同深度的配置ResNet 家族虽然名字很多但骨架完全一致开头一个 7×7 卷积加最大池化中间分成几个阶段每个阶段堆叠不同数量的残差块最后用全局平均池化和全连接层输出分类概率。差别只在于每个阶段堆叠多少个残差块、用 BasicBlock 还是 Bottleneck。网络深度各阶段残差块数量块类型大致参数量ResNet-18[2, 2, 2, 2]BasicBlock约 11.7MResNet-34[3, 4, 6, 3]BasicBlock约 21.8MResNet-50[3, 4, 6, 3]Bottleneck约 25.6MResNet-101[3, 4, 23, 3]Bottleneck约 44.5MResNet-152[3, 8, 36, 3]Bottleneck约 60.2M为什么层数差这么多参数量却没有按层数线性增长因为 Bottleneck 在计算效率和参数效率上都更优50 层用的是紧凑的瓶颈结构参数只比 34 层多了一点点但表达能力更强。真正参数快速增长是从 101 层往后才开始的。3.2 特征图尺寸、通道数与降采样节奏ResNet 的输入通常是一张 224×224 的 RGB 图像。网络先做一个 7×7 卷积步长为 2把分辨率降到 112×112再经过一个 3×3 最大池化步长也是 2最终得到 56×56 的特征图。之后进入四个阶段每个阶段开始时通过 stride2 的残差块把分辨率减半同时通道数翻倍。这种“分辨率减半、通道翻倍”的设计非常经典。卷积层的乘法量大致等于特征图面积乘以通道数的平方如果只降分辨率不增通道特征表达能力会快速流失如果只增通道不降分辨率计算量和显存又会爆炸。两者配合能在算力增长可控的前提下不断提高抽象程度。这也是后来很多分类网络都沿用的设计范式。3.3 为什么用全局平均池化 全连接收尾网络最后不是直接拉平特征图接全连接而是先做全局平均池化把每个通道的特征图平均成一个值。这一步把任意尺寸的特征图压缩成固定长度向量因此网络对输入图像尺寸有一定灵活性。和直接用全连接层相比全局平均池化的参数几乎为零也不容易过拟合。我经常在调试时看这个全局池化输出的分布它能直接反映模型有没有学到有区分度的特征。如果某个类别的平均激活值和其他类别很接近说明前面的特征提取还不够。分类头本身只承担“打分”职责真正的识别能力都集中在残差骨干网络里。4. 复现训练从零训练一个残差分类网络4.1 数据准备与数据增强先别急着调模型复现 ResNet 时很多人一上来就调残差块代码结果网络结构没问题精度却一直上不去。原因往往是数据准备太随意。以公开的大规模图像分类数据集为例原始图片尺寸不一标签噪声也不少直接送进去训练网络很难学到稳定的特征。标准做法是训练时先把图像随机缩放到比目标尺寸稍大的范围再随机裁剪到 224×224同时做水平翻转。这相当于每一轮训练都看到同一个目标的略微不同视角数据集的有效数量被放大了很多倍。颜色抖动也很管用随机调整亮度、对比度、饱和度让网络不那么依赖颜色分布。别忘了归一化。图像像素值要减均值、除标准差通常可以用数据集的全局 RGB 均值标准差。这个步骤对收敛速度影响非常大很多人漏了之后loss 曲线就像一条水平线。4.2 优化器、学习率与初始化策略残差网络最稳妥的优化器是带动量的 SGD而不是一上来就用 Adam。SGD 收敛稍慢但泛化能力往往更好。我的常用配置是momentum0.9weight_decay1e-4初始学习率 0.1当 batch size 为 256 时。如果 batch size 变化学习率也要相应缩放不能照抄。深层残差网络对初始训练阶段非常敏感因为 BatchNorm 的统计量还没稳定。一个很实用的做法是加 warmup前 5 个 epoch 把学习率从 0 线性升到目标值然后再正常训练。没有 warmup 的深层网络有时会在前几个 batch 直接冲出 NaN。初始化方面ResNet 这类用 ReLU 激活的网络适合采用专为 ReLU 设计的方差初始化方法不要用默认的均匀随机初始化。PyTorch 里用kaiming_normal_就能覆盖大部分卷积层。BN 层的 gamma 通常初始化为 1如果数据集很小、层数很深也可以把最后一个 BN 的 gamma 初始化成 0这样残差块初期接近恒等映射训练会更稳。4.3 训练过程怎么读健康的学习曲线长什么样训练残差网络时我一般会盯着三样东西训练 loss、验证 loss、验证准确率。健康的状态是训练 loss 平滑下降验证 loss 也同步下降两者差距不大。如果训练 loss 降了但验证 loss 不降说明开始过拟合需要加强数据增强或加大 weight decay如果训练 loss 本身就不降那问题基本出在模型实现或优化策略上。一个常见的诡异现象是验证准确率比训练准确率还高。这往往不是模型多厉害而是代码在评估阶段忘了切换模式。BatchNorm 在训练和评估时的行为完全不同Dropout 也需要关闭。PyTorch 里要用model.eval()配合torch.no_grad()否则验证时 BN 仍然在使用 batch 统计量效果会忽高忽低。我在复现 ResNet-34 时大概 60 个 epoch 后准确率就趋于平稳而在同样设置下ResNet-50 还能在之后继续缓慢上升。深网络的下限更高但需要更长训练时间和更细致的调参。看到浅层模型先饱和时别急着下结论说深层没用多跑几十个 epoch 再比较。5. 常见问题与排查技巧实录5.1 一张速查表定位问题现象可能原因处理办法训练 loss 一开始就 NaN学习率过大、BN 未初始化、输入有异常值检查输入归一化降低学习率加 warmup训练 loss 几乎不动归一化缺失、卷积 bias 与 BN 冲突、激活顺序写错检查数据预处理去掉卷积 bias确认最后 ReLU 位置验证准确率远高于训练训练时用评估模式、Dropout/BN 状态错误确保 train/eval 模式切换正确深层网络比浅层还差数据太少、训练轮数不足、没有 warmup增加数据增强延长训练先跑通小数据显存爆炸输入尺寸太大、Bottleneck 没用上降低 batch size换 Bottleneck检查是否误传大特征图这张表基本覆盖了我在复现残差网络时遇到的大部分问题。很多问题表面上是“网络不行”扒开看都是实现细节不到位。5.2 我踩过的残差块实现坑第一个坑是 ReLU 位置。最早我图省事在每个卷积后都接 ReLU然后主路输出和 shortcut 相加没有再额外激活。这么写网络中仍然有非线性但偏离了原始设计。标准做法是最后一个 BN 后先相加再统一过 ReLU。这个顺序影响的是残差块内部的信息流如果相加前就对主路做了 ReLU负值会被截断shortcut 叠加的恒等信号也会被再次压缩训练深网络时更容易出现梯度信号丢失。第二个坑是 shortcut 忘记加 BatchNorm。单纯用一个 1×1 卷积投影维度虽然能对齐形状但输出分布和主路 BN 后的分布差异很大相加后会产生明显的分布扰动。加上 BN 之后两条分支都被归一化到相近尺度训练稳定很多。第三个坑是inplaceTrue的副作用。ReLU 的inplaceTrue能省内存但如果在网络里复用了某个张量尤其是把输入保存在 identity 里原地操作可能会把输入覆盖掉。残差块里正好有这种复用所以要么保证 ReLU 修改的原始变量不再被后续使用要么干脆用默认的inplaceFalse省心也不差那点内存。5.3 关于“越深越好”的冷静建议ResNet 解决了深层网络的优化问题但这不代表 152 层在任何任务里都比 34 层强。模型越深对数据量、训练时长和正则手段的要求就越高。在我自己的小数据集实验里ResNet-50 经常和 ResNet-34 打成平手有时甚至因为过拟合而略差。所以我的建议是先从一个能快速迭代的深度开始比如 34 层。把数据管线、数据增强、训练策略都调到合理状态再根据验证集表现决定是否换 50 层或更深的版本。一味堆深度只会让每次实验变慢排查问题也更难。残差网络的意义是“给你加深的自由”而不是“告诉你一定要加到最深”。6. ResNet 对图像识别生态的影响6.1 从“手工调结构”到“可靠地堆层数”在残差思想出现之前设计一个几十层甚至百层的卷积网络要面对太多不确定性梯度、初始化、优化、退化每一样都可能让整次训练失败。ResNet 把“堆叠层数”变成了一件可靠的事网络设计者终于可以把精力集中在如何组织计算单元而不是反复担心层数加深后模型会不会崩。这直接影响了后来很多研究方向。新的网络结构大量吸收了跳跃连接的思想有些改进主路设计有些改进 shortcut 形式但残差承担的“优化稳定性”角色一直没有被完全替代。做图像分类、目标检测、语义分割的人也几乎都把残差网络当作骨干特征提取器的默认起点。6.2 迁移学习与业务落地残差网络的实际用武之地在实际项目里很少有人真从零随机初始化一个超大残差网络。更常见的是用别人在大型公开数据集上预训练好的权重然后迁移到自己的业务数据上。对自定义分类任务通常把最后一层全连接改成自己的类别数先冻结前面所有层只训练分类头和最后一两个残差块等验证指标不再提升再解冻更多层做整网精调。这种做法的好处是即使你手头标注样本只有几千张也能借到预训练模型已经学到的通用特征。我做过一个小规模品类识别项目用预训练残差网络微调之后准确率比我从头训练同结构模型高出将近十个百分点。对落地场景来说残差网络不只是一个学术模型而是一个非常好用的特征提取工具箱。6.3 我自己的使用习惯与最后一句话在我手里残差网络通常不是终点而是基线。拿到任何视觉任务我第一件事就是跑通一个 34 层残差网络确认数据和训练管线没问题再考虑更强或者更轻量的结构。残差块本身只有十几行代码但每次把网络加深一点点我都会回到最基础的维度对齐、激活顺序和 BN 状态上重新检查一遍。就是这些不起眼的细节决定了你训练的是真正的残差网络还是一个披着跳跃连接外衣的普通堆叠模型。如果你正准备复现 ResNet我的建议是不要急着直接上 152 层。先写一个不带动任何花哨技巧的 18 层模型在公开小图像集上把它从零训练到收敛再逐步加层数、加数据增强、换 Bottleneck。这个递进过程比一次性跑一个大模型有用得多你会真正体会到残差学习为什么能改变图像识别。