上一篇从链式法则推导了反向传播。梯度沿计算图逐层回传,每经过一层,都要乘上这一层的局部导数。网络越深,这条乘积链越长。把这个结论放回卷积神经网络,很自然会问:更深的网络能组合出更复杂的特征,那为什么不一直往后添加卷积层?
实际情况没有这么顺利。普通网络堆到一定深度后,新增层不一定带来收益;同一批训练数据上,较深模型的训练误差甚至可能高于较浅模型。残差网络(Residual Network,ResNet)就是为缓解这个问题提出的。它在若干卷积层外留出一条直接传递输入的捷径,让堆叠层学习输入与目标之间的差值,同时缩短前向信号和反向梯度经过的路径。
要看清这条捷径为什么有效,需要先分辨退化、过拟合和梯度消失,再从 推到 。后半部分会拆开 BasicBlock、Bottleneck 和 ResNet-18 的分层结构,并用一份简化的 PyTorch 代码对应各条张量路径。代码只解释结构,不包含训练实验或性能结论。
残差学习
设一个较浅的网络已经学到映射 ,现在在它后面继续添加若干层。只要新增层能实现恒等映射 ,较深网络至少可以复现原网络的结果。换成函数集合的说法,深层网络应该保留浅层网络已经能表达的函数。
表达能力允许这样做,优化过程却不一定找得到。要让一串卷积、归一化和激活层共同实现恒等映射,参数必须落在相当特殊的位置。层数增加后,参数之间的牵连更多,前向数值和反向梯度也要经历更多变换。模型容量变大了,想找到一组至少不输给浅层网络的参数,反而更费力。
ResNet 改变了新增层需要学习的对象。它让堆叠层只计算一个残差函数 ,再把输入 直接加回来:
若理想映射接近恒等映射,残差分支只需让 接近零。恒等捷径没有卷积核,也不增加待学习参数,输入可以绕过残差分支直接到达输出。ResNet 最重要的变化就藏在这次相加里。
退化问题
“网络更深却训练得更差”听起来很像过拟合或梯度消失,但三者指向不同的问题。
过拟合时,训练集上的表现还在改善,验证集却开始变差;模型已经把训练数据学得很好,只是没有同样好的泛化。ResNet 原论文观察到的退化发生得更早:更深的普通网络连训练误差都可能更高。模型没有把新增容量真正用起来。
梯度消失或爆炸同样会妨碍训练,不过退化不等于某个梯度已经变成零或无穷。即使初始化、归一化和激活函数已经把数值控制在合理范围,普通深层网络仍可能越加层越难优化。问题落在参数化方式上:新增部分若想保持原映射,需要许多层彼此配合。
设深度为 的网络能表示一组函数 。理想情况下,增加层数后应满足
新增层若能轻松表示恒等映射,较深网络便可以先保留浅层网络的能力,再学习额外的变化。普通堆叠层理论上也能逼近恒等映射,只是架构没有直接提供这条路。残差块把它写进网络,使较深模型更容易包含较浅模型已经学到的函数。
残差映射
假设某组堆叠层希望学习的目标映射是 。直接学习时,卷积层需要从输入构造完整的 。残差学习把目标改写为
于是原目标可以写成
网络中的卷积分支负责近似 ,捷径分支把 原样传到加法节点。两条分支相加后得到 的近似值。
所谓“学习残差更容易”,来自一个方便的起点。新增层还没有学到有用变换时,只要残差分支输出接近零,整个块就接近恒等映射。训练可以从这里逐步修正特征,不必先让多层卷积合力复现输入。 本身并不会在所有任务上都变得简单。
以两层卷积的残差块为例,残差分支可以写成
其中 、 代表卷积与归一化后的变换, 代表非线性激活。块输出为
原始 ResNet 使用“相加后再 ReLU”的结构。后来的预激活 ResNet 把 BatchNorm 和 ReLU 移到卷积之前,让相邻加法节点之间的恒等路径更直接; 这一步没有变。
恒等捷径
先忽略加法后的激活,设
损失 对输入 的梯度为
来自捷径分支 , 来自残差分支。普通堆叠网络只保留后者,梯度必须穿过残差分支里的每次卷积、归一化与激活。加入恒等捷径后,上游梯度多了一项可以直接传给 的分量。
把多个残差块串起来,若第 个块满足
那么从第 个块展开到第 个块可得
对应的反向梯度包含一条不经过中间残差变换的直接项:
恒等项不能保证任意深度的网络都稳定,也代替不了合适的初始化、归一化和优化器。它只是把有效路径缩短了:梯度到达较浅层时,不再完全依赖一长串可学习变换。这样看,深层 ResNet 像许多长短不同的路径叠在一起,单一路径不必从头走到底。
残差块与投影捷径
加法要求两条分支输出具有相同形状。若输入为
残差分支输出为
那么捷径分支也必须产生 。接下来只需看形状是否变化。
当 ,且空间尺寸不变时,捷径可以直接使用 。这种恒等捷径没有参数,计算量也很小:
当通道数改变或需要用步幅下采样时, 与 无法直接相加。常见做法是在捷径分支加入 卷积,把通道数和空间尺寸投影到目标形状:
使用与残差分支相同的步幅。 卷积只在每个空间位置混合通道;步幅为 2 时,它还会把高度和宽度减半。投影捷径含有可训练参数,因此只在形状需要变化时使用,其他块仍保留无参数的恒等捷径。
残差连接采用逐元素相加,输出通道数不会因为合并而翻倍。若改用通道拼接,后续层会接收到更多通道,参数量和语义都随之变化;那属于 DenseNet 等结构采用的另一种连接方式。
BasicBlock 与 Bottleneck
ResNet 主要使用两种残差块。
| 结构 | 残差分支 | 常见网络 | 特点 |
|---|---|---|---|
| BasicBlock | 卷积 → 卷积 | ResNet-18、ResNet-34 | 结构直接,每个块包含两层主要卷积 |
| Bottleneck | 降维 → 卷积 → 升维 | ResNet-50、ResNet-101、ResNet-152 | 把昂贵的 卷积放在较窄通道上 |
BasicBlock 中,第一层 卷积可以改变步幅和通道数,第二层保持目标形状。若发生形状变化,捷径分支同步使用带步幅的 投影。两条分支相加后再经过 ReLU。
Bottleneck 先用 卷积压缩通道,在较窄的表示上执行 卷积,最后再用 卷积扩展输出通道。经典实现的扩展倍数为 4,例如中间宽度为 64 时,块输出为 256 通道。这样可以增加网络深度,同时控制主要卷积的计算量。
“瓶颈”描述的是残差分支内部先窄后宽的形状,与信息只能通过狭窄通道传播无关。捷径分支仍绕过整个三层变换,在形状一致时直接传递输入。
ResNet 架构
完整的 ResNet 先用 stem 提取低层特征,再把残差块按通道数分成若干阶段(stage)。同一 stage 内保持空间尺寸和通道数;进入下一 stage 时,通常由第一个块用步幅 2 下采样,同时增加通道数。
下面按形状读一遍标准 ResNet-18。输入是一张 的 RGB 图像。
| 阶段 | 主要结构 | 输出通道 | 空间尺寸 |
|---|---|---|---|
| stem | 卷积,步幅 2; 最大池化,步幅 2 | 64 | |
| stage 1 | 2 个 BasicBlock | 64 | |
| stage 2 | 2 个 BasicBlock,第一个块步幅 2 | 128 | |
| stage 3 | 2 个 BasicBlock,第一个块步幅 2 | 256 | |
| stage 4 | 2 个 BasicBlock,第一个块步幅 2 | 512 | |
| 分类头 | 全局平均池化、线性层 | 类别数 |
ResNet-18 的四个 stage 分别包含 个 BasicBlock。每个块有两层 卷积,加上 stem 的一层卷积和最后的线性层,得到名称中的 18 层。这里的计数只包含带参数的主要卷积层和分类层,不把 BatchNorm、ReLU、池化与捷径加法单独计入。
全局平均池化把每个通道的整张特征图平均成一个数。输入空间尺寸因此不需要像 LeNet-5 那样通过固定展平尺寸绑定到线性层;分类头只接收长度为 512 的通道向量。面对 CIFAR-10 这类 小图时,常把 stem 改为步幅 1 的 卷积,并去掉最开始的最大池化,避免过早丢失空间信息。残差块本身无需改变。
归一化、激活与初始化
残差连接提供了更短的信息路径,卷积层本身的数值尺度仍要控制。经典 ResNet 在卷积后使用 BatchNorm,再接 ReLU。BatchNorm 还带有可学习的缩放和平移,因此前面的卷积通常关闭偏置。
ReLU 在正区间的导数为 1,卷积权重则常用 He 初始化,让信号在训练开始时保持较合适的方差。三者各管一件事:初始化安排起点附近的信号尺度,归一化约束训练中的激活,残差连接负责跨层的直接路径。
原始 ResNet 的 BasicBlock 顺序大致为“卷积—BatchNorm—ReLU—卷积—BatchNorm—相加—ReLU”。预激活 ResNet 改为“BatchNorm—ReLU—卷积”,并把块末尾的激活移入下一个残差分支。这样一来,从一个块的加法节点到后续加法节点之间可以保持更干净的恒等路径。理解 ResNet-18 时先掌握原始结构即可;预激活版本说明残差路径本身也能继续优化。
PyTorch ResNet-18
下面用代码把前面的结构和张量形状对上。示例没有数据加载、训练循环和实验结果。它实现原始风格的 BasicBlock:残差分支包含两次 卷积,捷径分支在形状变化时使用 投影。
import torch
from torch import nn
class BasicBlock(nn.Module):
expansion = 1
def __init__(self, in_channels, out_channels, stride=1):
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.relu = nn.ReLU(inplace=True)
self.conv2 = nn.Conv2d(
out_channels,
out_channels,
kernel_size=3,
stride=1,
padding=1,
bias=False,
)
self.bn2 = nn.BatchNorm2d(out_channels)
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(
in_channels,
out_channels,
kernel_size=1,
stride=stride,
bias=False,
),
nn.BatchNorm2d(out_channels),
)
else:
self.shortcut = nn.Identity()
def forward(self, x):
identity = self.shortcut(x)
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out = out + identity
return self.relu(out)
判断是否需要投影捷径只看两个条件:步幅是否改变空间尺寸,输入通道数是否等于输出通道数。若两者都没有变化,nn.Identity() 原样返回输入。若任一条件成立, 卷积和 BatchNorm 把捷径变到与残差分支相同的形状。
完整网络还需要按 创建四个 stage。_make_stage 的第一个块接收指定步幅,后续块都保持形状:
class ResNet(nn.Module):
def __init__(self, block, blocks_per_stage, num_classes=1000):
super().__init__()
self.in_channels = 64
self.stem = nn.Sequential(
nn.Conv2d(
3,
64,
kernel_size=7,
stride=2,
padding=3,
bias=False,
),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
)
self.stage1 = self._make_stage(block, 64, blocks_per_stage[0])
self.stage2 = self._make_stage(
block, 128, blocks_per_stage[1], stride=2
)
self.stage3 = self._make_stage(
block, 256, blocks_per_stage[2], stride=2
)
self.stage4 = self._make_stage(
block, 512, blocks_per_stage[3], stride=2
)
self.pool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
def _make_stage(self, block, out_channels, blocks, stride=1):
layers = [block(self.in_channels, out_channels, stride)]
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.stem(x)
x = self.stage1(x)
x = self.stage2(x)
x = self.stage3(x)
x = self.stage4(x)
x = self.pool(x)
x = torch.flatten(x, 1)
return self.fc(x)
def resnet18(num_classes=1000):
return ResNet(BasicBlock, [2, 2, 2, 2], num_classes)
这份代码刻意只保留结构。真正使用模型时,还要处理权重初始化、预训练权重、输入检查和部署兼容,直接采用 torchvision.models.resnet18 更稳妥。手写一遍的意义,是弄清每条张量路径怎样对齐。
若把 BasicBlock 换成 Bottleneck,block.expansion 会从 1 变为 4,_make_stage 仍可复用。残差分支内部改成 、、 三次卷积,最后一次卷积输出 out_channels * 4;投影捷径也必须输出同样的通道数。ResNet-50 使用 个 Bottleneck 组成四个 stage。
残差架构
ResNet 的影响超出了图像分类。Transformer 的多头注意力层和前馈网络外都带有残差连接,使每个子层只需在已有表示上写入增量;扩散模型常用的 U-Net 也大量采用残差块;许多卷积骨干、图神经网络和序列模型都保留了类似的恒等路径。
这些架构内部做的计算各不相同,深度带来的麻烦却很相似:信息若必须经过所有可学习变换,路径越长,训练就越依赖每一层都处在合适状态。残差连接让模型围绕恒等映射逐步修改表示,也给前向信号和反向梯度留出了较短的路线。
读一个残差网络,可以顺着两条分支往下看:残差分支做了什么,捷径有没有投影,相加前的形状是否一致,下一次下采样又发生在哪里。ResNet-18、ResNet-50 和许多后续变体,都能拆回同一个动作:算出 ,保留 ,再把两者相加。
参考文献
- K. He, X. Zhang, S. Ren, and J. Sun. Deep Residual Learning for Image Recognition. CVPR, 2016. DOI: 10.1109/CVPR.2016.90
- K. He, X. Zhang, S. Ren, and J. Sun. Identity Mappings in Deep Residual Networks. ECCV, 2016. DOI: 10.1007/978-3-319-46493-0_38
- S. Ioffe and C. Szegedy. Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift. ICML, 2015
- K. He, X. Zhang, S. Ren, and J. Sun. Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification. ICCV, 2015. DOI: 10.1109/ICCV.2015.123
- A. Zhang, Z. C. Lipton, M. Li, and A. J. Smola. Dive into Deep Learning, Section 8.6: Residual Networks. Cambridge University Press, 2023
- PyTorch Contributors. ResNet Models and Pre-trained Weights. Torchvision Documentation