如果你像我一样早些年曾兴冲冲地把网络从20层加到50层以为精度会跟着水涨船高结果却看到训练集上的loss死活掉不下去——那你一定体会过那种挫败感。深度学习里“更深”和“更好”之间远不是一条直线中间隔着一道真实的墙。这道墙正是深度残差网络Deep Residual NetworkDRN要拆掉的东西而我们更熟悉的名字是ResNet。ResNet 在几年前把图像分类网络的深度一下推到上百层靠的不是某种花哨的注意力机制也不是更复杂的连接拓扑而是一个非常朴素的想法把网络要拟合的目标从“完整的映射”改成“残差”。这个改动看似只是数学形式上的改写却在实践中彻底改变了深层网络的训练命运。本文会从原理讲起把“为什么深网络训不动”“残差连接到底解决了什么问题”“BasicBlock 和 Bottleneck 怎么选”“自己动手实现时有哪些坑”这些事一次性说透。无论你是刚接触深度学习的初学者还是想系统梳理 ResNet 细节的进阶玩家这篇文章都值得读完有些坑是我自己踩过之后才明白的。1. 从训练失败说起网络加深遇到的退化现象1.1 更深不等于更好那个让人困惑的实验结果在 ResNet 出现之前主流做法是像 VGG 那样把卷积层一层层堆起来。堆到一定程度后人们发现增加层数确实能提升效果于是“越深越好”几乎成了直觉。可真的动手去训练一个56层的普通卷积网络时问题来了它的表现反而不如20层的网络而且不是测试集上过拟合是训练集本身的误差就更高。这就很诡异了。过拟合至少还能解释为“记住训练数据但泛化差”可训练误差都降不下去说明这个深层模型的优化过程本身出了毛病。一个拥有更多参数的模型理论上表达能力应该更强至少应该能拟合出一个与浅层网络同样的函数结果却连训练数据都拟合不好这就是论文里说的退化问题。我当时第一次看到那张20层和56层对比曲线时第一反应是“训练代码写错了”后来反复确认才发现这是普遍现象不是个别 bug。1.2 退化问题不等于梯度消失很多人一听“层数太深训不动”第一反应就是梯度消失。确实梯度消失会让浅层参数几乎得不到有效更新但 ResNet 的作者在论文里做了一个很有意思的对照实验给网络加了大量 Batch Normalization并且采用了能缓解梯度问题的初始化方式结果网络的梯度值看起来是正常的可56层网络依然训不动。这说明退化问题的根源不在梯度的绝对大小而在优化过程难以在深层空间中找到一个好的解。你可以把深层网络想象成一条很长的山路每一步优化都只能看到局部几十层非线性变换叠加下来损失曲面变得极度复杂普通的梯度下降路径很容易被困在很差的局部区域。加深网络本质上是急剧放大了优化的难度而不仅仅是梯度消失了。1.3 为什么非线性和深层组合会放大优化难度还有一个容易忽略的层面每一层卷积和 ReLU 都在对输入做一次非线性扭曲。20层网络信息经过20次变换56层网络信息要经过56次变换。哪怕每一层的变换幅度都很小经过这么多层累积输出的分布在训练过程中也会剧烈震荡。就像你让100个人轮流转述一句话每个人都只改一点点最后传出来的内容可能和原文毫无关系。深层网络的问题不只是“参数多”更是“信号经过的加工工序太多”。如果每道工序还都随着训练不断变化那整个系统的协调难度就指数上升。理解了这一点再看 ResNet 的残差连接就会明白它的真正价值它给信号保留了一条不经过任何非线性的“原始通道”让信息在深层网络中也能几乎无损地向前流动。2. 残差学习的思想核心把拟合目标从 H(x) 换成 F(x)x2.1 一个近乎“反直觉”的建模思路传统网络每一层都在学习输入到输出的映射假设这个理想映射是 H(x)那网络就要通过层层堆叠去逼近 H(x)。ResNet 的出发点是一个很细微但关键的改变既然直接学习 H(x) 很难那就改学一个残差 F(x) H(x) - x然后用 F(x) x 来重构出 H(x)。这个形式上的改动为什么有效关键在于网络最容易面对的情形当 H(x) 本身接近恒等映射时直接拟合 H(x) 相当于让一堆非线性层去模拟“什么都不做”这其实是很难的因为卷积、BN、ReLU 都有各自的偏置多个非线性层串联默认行为绝不是恒等。但如果改成拟合残差网络只需要让 F(x) 的权重趋向于零输出的信号就直接绕过 F(x)恒等映射天然就成立了。2.2 恒等映射的兜底价值我用一个生活例子来解释你要调低房间里某个音响的音量。直接做法是重新拧一遍整个音响系统的旋钮但更稳妥的办法是只加一个总音量开关——先把系统默认状态设为“不改变任何声音”然后只需要微调那一小部分差异就达到了目标。深层的堆叠结构很难天然做到“不改变任何声音”因为每个卷积层都会强制改变特征。而残差结构给网络提供了一种“什么都不学也能过关”的兜底路径F(x) 中所有参数都可以退化为不敏感的小权重x 照样能通过跳跃连接到达输出。这就意味着增加残差模块至少不会让网络变得更差——这是普通串联网络不具备的理论保证。2.3 加法结构为什么优于乘法捷径如果说残差思想是“保留一条原始通道”那自然会想能不能用别的捷径方式比如让输出等于 F(x) × x从信息保真度来看乘法是有问题的。假设 F(x) 接近恒等时还好可如果网络中途学到一些小权重乘法捷径会直接缩放甚至抹掉原始信号那这条通道就失去“兜底”意义了。加法结构则是信息无损叠加x 那一项永远是完整的一份后面网络不管学成什么样输入的原始信息都已经到达了输出端。这也解释了为什么 ResNet 之后的很多改进网络比如带门控机制的网络依然保留了这种加性短路连接。加法的确定性让梯度路径稳定信息流可靠这是残差思想能延续至今的根本原因。3. 结构拆解BasicBlock、Bottleneck 与快捷连接的设计逻辑3.1 两种残差模块BasicBlock 与 BottleneckResNet 的残差模块有两大形态。浅层网络用的是 BasicBlock两个 3×3 卷积加 BN 和 ReLU 串起来快捷连接直接跨过这两个卷积把输入加到第二个卷积的输出上。它的特点是简单直接计算量小适合 18 层、34 层这种规模。深层网络用的是 Bottleneck结构是 1×1 卷积降维、3×3 卷积做空间变换、再 1×1 卷积升维。为什么要这么绕核心是控制参数量。如果深层网络继续用两个 3×3 卷积通道数一上来计算量会爆炸。Bottleneck 先用 1×1 卷积把 256 通道压缩到 64做完 3×3 卷积之后再升回 256中间那层 3×3 卷积实际处理的是低维特征计算量大幅下降。这种结构让我第一次体会到网络的“宽度”不只是通道数而是设计与计算预算之间的权衡。下表列出了两种模块的关键差异方便对比对比项BasicBlockBottleneck卷积组合3×3 3×31×1 3×3 1×1典型应用ResNet-18 / ResNet-34ResNet-50 / ResNet-101 / ResNet-152输出通道变化与原通道相同1×1升维为输入的4倍计算开销较小初始通道越大优势越明显训练难度低收敛快略复杂需要更大的batch或更久训练3.2 1×1 卷积在残差模块里的三个角色很多人容易忽略 1×1 卷积的价值但它在 ResNet 里至少承担了三个关键任务。第一是降维比如从 256 降到 64为中间的 3×3 卷积减负第二是通道变换把经过空间卷积后的低维特征重新映射到更丰富的通道空间保证表达能力第三是在捷径连接中做投影当输入的通道数和输出不一致时用 1×1 卷积把输入映射到目标维度再执行加法。顺带说一句1×1 卷积本质上是一个跨通道的全连接层它不会改变特征图的空间尺寸但可以任意调整通道数。这种“先压缩、后扩展”的沙漏结构在后来很多经典网络里都能看到影子你可以把它理解为项目管理里的“先聚焦核心问题再展开全面方案”的思路。3.3 快捷连接在下采样时的三种方案残差模块执行加法要求输入输出形状一致。但在网络的特定阶段特征图的空间分辨率会减半通道数会翻倍这时候快捷连接必须做适配。论文给出了三种做法方案A对输入做 zero-padding 补零通道多出来的部分用 0 填充空间尺寸用 stride2 的池化来减半。这个办法不引入额外参数但补零会让恒等映射不再“纯正”。方案B用一个 1×1 卷积stride2把输入映射到目标尺寸同时改变通道数。引入少量参数但信息能真正学到怎么变换。方案C所有快捷连接都用 1×1 卷积处理不只下采样时任何需要对齐的地方都用投影。效果最接近理想但参数最多。我实际复现时通常选方案B它在参数开销和表达能力的平衡上最好也是多数开源实现采用的做法。方案A虽然省参数但补零引入的无效信息会在深层累积不值得省那一点点内存。3.4 ResNet 家族谱18层到152层的差异ResNet 不是只有一个模型而是一族网络层数从18一直延伸到152。同一套模块靠改变每个阶段的模块数量得到不同深度ResNet-18每个阶段 2、2、2、2 个 BasicBlock参数小适合小数据集和快速实验ResNet-34每个阶段 3、4、6、3 个 BasicBlock是 34 层以内的常见主力ResNet-50换成 Bottleneck每个阶段 3、4、6、3 个参数量比34层增加不少但对现代数据集更友好ResNet-101瓶颈块增加到 3、4、23、3常见于检测和分割等重型任务ResNet-1523、8、36、3 个瓶颈块是当年 ImageNet 竞赛里登顶配置。个人建议小项目优先从 ResNet-18 起步确认可行性再升级到 ResNet-50。不要一上来就跑 152 层算力消耗大而且收益在小数据集上往往不明显。4. 顺着梯度传播看一遍为什么残差连接能让几十层网络训得动4.1 链式法则里的“梯度高速公路”残差连接的数学形式非常简单设模块输出 y F(x, W) x其中 F 是非线性堆叠部分。反向传播时损失对输入 x 的梯度为∂L/∂x ∂L/∂y · (1 ∂F/∂x) ∂L/∂y ∂L/∂y · ∂F/∂x关键就在括号里这个 “1”。普通串联网络里梯度要一层层乘过所有权重矩阵如果每一层带来的缩放因子都小于1几十层乘下来梯度必然指数级衰减。而残差模块的公式里梯度天然分成两条路径一条直通那个“1”一条经过 F。就算 ∂F/∂x 很小甚至趋于零直通路径也保证了梯度能完整回到输入侧。这就是人们常说的“梯度高速公路”。它不是某种正则化技巧而是由加法结构直接带给梯度的结构化好处。对比之下普通网络的梯度像走一条层层设卡的山路每过一个关卡都要被搜刮一遍残差网络则修了一条绕开收费站的快速干道无论底下多拥堵主路永远是通的。4.2 数值直觉乘法衰减到底多可怕我们用一组简单的直觉数字感受一下。假如一个普通网络每层的梯度缩放系数是 0.8经过20层之后梯度大约是 0.8^20 ≈ 0.0115还能接受但经过50层就变成 0.8^50 ≈ 0.000014几乎为零。而残差网络里梯度包含一个常数项“1”无论经过多少层这一项都不会被缩放。深层网络的梯度至少保留了一份完整体训练从“可能崩”变成“稳稳有底”。这也是为什么 ResNet 能把网络推到上百层但大部分基础优化技巧SGD、学习率衰减不需要做颠覆性改变。残差连接解决的是“梯度能不能传回去”的结构问题剩余的就是常规调参。4.3 激活顺序的细节BN-ReLU-Conv 与全预激活原版 ResNet 模块内部顺序是 卷积→BN→ReLU这个设计能让残差 F 保持一定表达能力。不过后来的研究者发现如果把激活和 BN 全部挪到卷积之前全预激活也就是 输入→BN→ReLU→卷积→BN→ReLU→卷积→…→加到输入模型会更好训练。原因在于全预激活模式下恒等路径上完全没有任何激活函数信号的直通性更强。原版结构里输入 x 加到输出后还要经过一次 ReLU虽然影响不大但严格来说已经“碰”了一下非线性。全预激活把 x 的通道留得更干净梯度传播也更一致。我在复现时更偏好预激活版本尤其是训练深层变体时收敛稳定度明显更好。4.4 批量归一化与残差连接为什么是一对好搭档Batch Normalization 和残差连接经常被放在一起提但它们的角色并不相同。BN 的主要作用是让每一层的输入分布保持稳定抑制内部协变量偏移从而允许更大的学习率和更快的收敛。残差连接则是从结构上保证信息无损传递。两者配合的关键点在于BN 让 F(x) 部分的前向输出尺度更可控避免残差分支的数值过大或过小从而让“x F(x)”这个加法更稳定。如果只加 BN 不加残差深层网络的梯度依然要穿越所有非线性层如果只加残差不加 BNF(x) 的输出尺度容易失控加法结果震震荡荡训练波动大。可以说BN 控制“加进去的部分有多剧烈”残差控制“原始信息怎么保底”二者是互补关系。5. 自己动手实现 ResNetPyTorch 代码逐段拆解与训练实测5.1 BasicBlock 的代码实现与关键说明理论讲再多不落地就是空中楼阁。下面是我在 PyTorch 里实现 BasicBlock 的代码每一处都针对前面讲的设计做了对应处理import torch import torch.nn as nn class BasicBlock(nn.Module): expansion 1 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) 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有几点值得展开说。第一卷积层都设了biasFalse因为后面紧跟 BN 层BN 本身带有可学习的偏置项卷积再加 bias 就是冗余参数还会干扰 BN 的归一化统计。第二identity变量在downsample is None的情况下就是原始输入加法操作out identity直接实现了残差连接的恒等路径。第三只有每个 stage 的第一个模块可能触发 downsample因为那时空间尺寸变化或通道数变化。5.2 Bottleneck 与主干网络的组装深层 ResNet 用 Bottleneck 控制计算量代码如下class Bottleneck(nn.Module): expansion 4 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.conv3 nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels * self.expansion) self.relu nn.ReLU(inplaceTrue) 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) out self.relu(out) out self.conv3(out) out self.bn3(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out注意expansion 4当输入通道为 64 时模块最终输出 256 通道这正是 ResNet-50 之后各阶段通道数翻倍的依据。接下来是主干网络组装class ResNet(nn.Module): def __init__(self, block, layers, num_classes1000): super().__init__() self.in_channels 64 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(block, 64, layers[0]) self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512 * block.expansion, num_classes) def _make_layer(self, block, out_channels, blocks, stride1): downsample None if stride ! 1 or self.in_channels ! out_channels * block.expansion: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels * block.expansion), ) layers [] layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels out_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(x) x self.maxpool(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的逻辑是每个阶段第一个模块可能需要 downsample后续模块的输入通道已经是out_channels * expansion因此不再需要投影。定义不同层数只需传入合适的列表def resnet18(num_classes1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet34(num_classes1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes) def resnet50(num_classes1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes) def resnet101(num_classes1000): return ResNet(Bottleneck, [3, 4, 23, 3], num_classes)5.3 训练中的实测体会学习率、初始化与 BN 的坑代码能跑通只是第一步真正训练时会遇到几个高频坑我一个个说。第一个坑是 BN 的 batch size 问题。当 batch size 小于 16 时BN 的统计量会变得很不稳定训练 loss 可能出现周期性震荡。我一开始在小数据集上只用了 batch size 8模型精度怎么都上不去换成 32 之后立刻恢复正常。如果你的显存有限可以考虑用 Group Normalization 替换 BN但需要注意那一套调参逻辑完全不同。第二个坑是初始化和学习率的配合。ResNet 复现时常见搭配是 Kaiming 均匀初始化加初始学习率 0.1在 ImageNet 规模配合 batch size 256。如果你的 batch size 更小比如 128 或 64学习率也要线性缩放否则前期 loss 会异常增大甚至变成 NaN。我自己常用 batch size 128 时把初始学习率调到 0.05训练曲线才正常。第三个坑是下采样模块的 stride 位置。如果错误地把 stride2 放在 1×1 卷积上而应该放在 3×3 卷积上会导致空间信息过早丢失精度会有可感知的下降。Bottleneck 的标准实现是 stride 放在中间 3×3 卷积上这样前后两个 1×1 卷积可以在不损失空间信息的情况下调整通道。这个细节我曾经弄反过训练出来的模型准确率低了将近一个点。第四个坑是数据增强。不要以为 ResNet 结构强大就可以忽略增强。我在一个中等规模的分类任务上试过加了随机裁剪和随机水平翻转top-1 准确率能提升 3 到 5 个百分点远超换一个更大模型带来的收益。数据增强和残差结构不是竞争关系而是叠加关系。第五个坑是全预激活版本更好训。如果只是做分类任务我推荐直接用预激活结构。它把激活函数全部挪到卷积之前恒等路径完全不经过非线性层在更深网络中收敛更稳定。实现上只需调整模块内部顺序代码改动很小收益却很实在。一点实践心得作为结尾ResNet 这套设计表面上只是给网络加了一条跳跃连接但它揭示了深层网络训练的一条重要原则信号必须有一条不受干扰的主干道。我在之后接触的很多架构里都看到了这个思想的影子比如目标检测里的特征金字塔、Transformer 里的残差连接本质上都是让信息在不同尺度、不同深度之间保持流通。如果你正准备在自己的数据集上应用 ResNet我的建议是不要一上来就追求最深的型号先从 ResNet-18 跑通流程把学习率、batch size、数据增强这些基础配置固定下来再逐步加深网络。每一次加深都要用验证集上的真实收益来说话而不是“更深就一定更好”的惯性直觉。残差结构给了深层网络训练的可能性但没有给“盲目加深一定有效”的保证。最后分享一个实用技巧训练时把每个残差阶段输出的特征图尺度打印出来确认每一层的空间尺寸和通道数是否符合你的预期。这个简单的检查能在十分钟内帮你发现八成以上的网络搭建错误省下的调试时间足够你再跑好几轮实验。
阅读完成 · 觉得有帮助?