一句话:残差网络通过在层与层之间添加"跳跃连接"(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 路径上没有参数(维度相同时),是纯粹的恒等连接

四、维度匹配问题:不是无条件能直接加

相加要求 xF(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) 都是