一句话:残差网络通过在层与层之间添加"跳跃连接"(Skip Connection),让网络学习输入与输出的差值而非完整映射,从而解决深层网络的梯度消失和退化问题。
一、问题背景:深层网络为什么会退化?
直觉上,更深的网络应该更强——至少多出来的层可以学成"什么都不做"(恒等映射)。
但实验发现:网络越深,训练误差反而越大,不是过拟合,是训练本身就失败了。
20层网络的训练误差 < 56层网络的训练误差 ← 这不正常!
根本原因:梯度消失
反向传播时,梯度要经过几十层连乘。每层梯度都小于 1,连乘后趋近于 0,浅层几乎学不到任何东西。
二、核心思想:学"差值"而不是学"完整映射"
原来的目标
让网络直接学习从输入到输出的完整映射:
$$H(x) = \text{网络想要的输出}$$
残差的目标
把目标拆成两部分:
$$H(x) = F(x) + x$$
其中 $F(x) = H(x) - x$ 就是残差(输出和输入的差值),网络只需要学 $F(x)$。
为什么这样更容易学?
- 如果这一层什么都不需要做(恒等映射),只需让 $F(x) \to 0$,把权重归零即可
- 让网络直接学 $H(x) = x$ 要难得多——它需要精确拟合一个恒等函数
- 学"微小的修正量"比学"完整的变换"容易得多
三、结构图
输入 x
│
├──────────────────────────┐ ← shortcut(跳跃连接,直接复制 x)
│ │
▼ │
Conv → BN → ReLU │
▼ │
Conv → BN │
│ │
└──────────── + ───────────┘ ← 相加(不是拼接!)
│
ReLU
│
输出 H(x) = F(x) + x
关键细节:
+是逐元素相加,不是 Concat 拼接- ReLU 在相加之后才做,不是在每层卷积后立刻做最后一次 ReLU
- shortcut 路径上没有参数(维度相同时),是纯粹的恒等连接
四、维度匹配问题:不是无条件能直接加
相加要求 x 和 F(x) 的形状完全一致,有两种情况:
情况1:维度相同 → 直接加 ✅
通道数不变、特征图尺寸不变时,shortcut 就是一条空线:
output = F(x) + x
情况2:维度不同 → 需要投影 ⚠️
比如通道数从 64 → 128,或特征图从 56×56 → 28×28,形状不一样,不能直接加。
解决方案:用 1×1 卷积 对 shortcut 做线性投影,把维度对齐:
# shortcut 用 1x1 卷积调整通道数和尺寸
shortcut = Conv2d(in_channels=64, out_channels=128, kernel_size=1, stride=2)(x)
output = F(x) + shortcut
1×1 卷积只调整维度,不提取空间特征,参数量极少。
五、为什么能解决梯度消失?
对 $\text{output} = F(x) + x$ 求梯度:
$$\frac{\partial \text{Loss}}{\partial x} = \frac{\partial \text{Loss}}{\partial \text{out}} \cdot \left(1 + \frac{\partial F(x)}{\partial x}\right)$$
注意那个 1:
- 无论 $F(x)$ 的梯度多小,梯度里始终有一个常数项
1在传递 - 梯度可以通过 shortcut 路径无损地直接流回浅层
- 彻底解决了梯度消失问题,使得训练几百层的网络成为可能
六、完整代码(PyTorch)
import torch
import torch.nn as nn
class ResidualBlock(nn.Module):
"""标准 ResNet BasicBlock(两层 3x3 卷积)"""
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)
# shortcut 路径:只有维度不匹配时才需要 1x1 卷积
self.shortcut = None
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)
)
def forward(self, x):
identity = x # 保存输入,用于后面相加
# 主路径
out = self.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
# shortcut 路径(维度不匹配时做投影)
if self.shortcut is not None:
identity = self.shortcut(x)
# 残差相加,然后再 ReLU
out = self.relu(out + identity)
return out
# 验证两种情况
block_same = ResidualBlock(64, 64, stride=1) # 维度相同,shortcut=None
block_diff = ResidualBlock(64, 128, stride=2) # 维度不同,shortcut=1x1卷积
x = torch.randn(1, 64, 56, 56)
print(block_same(x).shape) # torch.Size([1, 64, 56, 56])
print(block_diff(x).shape) # torch.Size([1, 128, 28, 28])
七、Transformer 里的残差连接
残差连接不只是 CNN 的专利,Transformer 里也随处可见,思想完全一样:
# Attention 子层
x = x + Attention(LayerNorm(x))
# FFN 子层
x = x + FFN(LayerNorm(x))
每个子层的输出都加回输入,梯度可以直接从输出层流回任意浅层,这也是 Transformer 能堆几十层的原因。
八、核心要点速查
| 问题 | 答案 |
|---|---|
| 解决了什么问题? | 深层网络梯度消失、训练退化 |
| 核心操作是什么? | output = F(x) + x,把输入加到输出上 |
| 能直接加吗? | 维度相同时可以;维度不同需要 1×1 卷积对齐 |
+ 是拼接吗? |
不是,是逐元素相加,形状不变 |
| ReLU 在哪里做? | 相加之后再做最后一次 ReLU |
| 为什么有效? | 梯度公式里有常数项 1,梯度可无损回传 |
| Transformer 里有吗? | 有,x + Attention(x) 和 x + FFN(x) 都是 |