如果你像我一样,早些年曾兴冲冲地把网络从20层加到50层,以为精度会跟着水涨船高,结果却看到训练集上的loss死活掉不下去——那你一定体会过那种挫败感。深度学习里,“更深”和“更好”之间远不是一条直线,中间隔着一道真实的墙。这道墙,正是深度残差网络(Deep Residual Network,DRN)要拆掉的东西,而我们更熟悉的名字是: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)+x
2.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 与 Bottleneck
ResNet 的残差模块有两大形态。浅层网络用的是 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 卷积实际处理的是低维特征,计算量大幅下降。这种结构让我第一次体会到:网络的“宽度”不只是通道数,而是设计与计算预算之间的权衡。
下表列出了两种模块的关键差异,方便对比:
| 对比项 | BasicBlock | Bottleneck |
|---|---|---|
| 卷积组合 | 3×3 + 3×3 | 1×1 + 3×3 + 1×1 |
| 典型应用 | ResNet-18 / ResNet-34 | ResNet-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 填充,空间尺寸用 stride=2 的池化来减半。这个办法不引入额外参数,但补零会让恒等映射不再“纯正”。
- 方案B:用一个 1×1 卷积(stride=2)把输入映射到目标尺寸,同时改变通道数。引入少量参数,但信息能真正学到怎么变换。
- 方案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-152:3、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 不加残差,深层网络的梯度依然要穿越所有非线性层;如果只加残差不加 BN,F(x) 的输出尺度容易失控,加法结果震震荡荡,训练波动大。可以说,BN 控制“加进去的部分有多剧烈”,残差控制“原始信息怎么保底”,二者是互补关系。
5. 自己动手实现 ResNet:PyTorch 代码逐段拆解与训练实测
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, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) 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有几点值得展开说。第一,卷积层都设了bias=False,因为后面紧跟 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, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) 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_classes=1000): super().__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(block, 64, layers[0]) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes) def _make_layer(self, block, out_channels, blocks, stride=1): 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_size=1, stride=stride, bias=False), 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_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet34(num_classes=1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes) def resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes) def resnet101(num_classes=1000): 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 位置。如果错误地把 stride=2 放在 1×1 卷积上,而应该放在 3×3 卷积上,会导致空间信息过早丢失,精度会有可感知的下降。Bottleneck 的标准实现是 stride 放在中间 3×3 卷积上,这样前后两个 1×1 卷积可以在不损失空间信息的情况下调整通道。这个细节我曾经弄反过,训练出来的模型准确率低了将近一个点。
第四个坑是数据增强。不要以为 ResNet 结构强大就可以忽略增强。我在一个中等规模的分类任务上试过,加了随机裁剪和随机水平翻转,top-1 准确率能提升 3 到 5 个百分点,远超换一个更大模型带来的收益。数据增强和残差结构不是竞争关系,而是叠加关系。
第五个坑是全预激活版本更好训。如果只是做分类任务,我推荐直接用预激活结构。它把激活函数全部挪到卷积之前,恒等路径完全不经过非线性层,在更深网络中收敛更稳定。实现上只需调整模块内部顺序,代码改动很小,收益却很实在。
一点实践心得作为结尾
ResNet 这套设计,表面上只是给网络加了一条跳跃连接,但它揭示了深层网络训练的一条重要原则:信号必须有一条不受干扰的主干道。我在之后接触的很多架构里都看到了这个思想的影子,比如目标检测里的特征金字塔、Transformer 里的残差连接,本质上都是让信息在不同尺度、不同深度之间保持流通。
如果你正准备在自己的数据集上应用 ResNet,我的建议是:不要一上来就追求最深的型号,先从 ResNet-18 跑通流程,把学习率、batch size、数据增强这些基础配置固定下来,再逐步加深网络。每一次加深,都要用验证集上的真实收益来说话,而不是“更深就一定更好”的惯性直觉。残差结构给了深层网络训练的可能性,但没有给“盲目加深一定有效”的保证。
最后分享一个实用技巧:训练时把每个残差阶段输出的特征图尺度打印出来,确认每一层的空间尺寸和通道数是否符合你的预期。这个简单的检查,能在十分钟内帮你发现八成以上的网络搭建错误,省下的调试时间足够你再跑好几轮实验。