【深度学习】残差连接_解决深层网络梯度消失问题

文章目录

摘要 :神经网络不是简单地越深越好。层数变深后,信息和梯度要经过很长路径,训练可能变慢、变差,甚至出现"更深模型反而不如浅层模型"的退化现象。残差连接用一个非常朴素的结构缓解这个问题:让输入可以绕过复杂变换,直接加到输出上,也就是 y = x + F(x)。本文从深层网络为什么难训练讲起,解释残差连接的公式、梯度直通、恒等映射、维度匹配、Pre-Norm Transformer 中的残差路径,并用 PyTorch 小实验帮助你理解为什么残差是现代大模型能堆到几十层、上百层的重要基础。

前置知识 :反向传播,最优化,激活函数,归一化

阅读时间 :约 60 分钟

代码环境:Python 3.10+,torch >= 2.0

入门导读:先抓住主线

如果你第一次接触残差连接,不要先把它想复杂。它的核心就是这行式子:

y = x + F ( x ) y = x + F(x) y=x+F(x)

其中:

  • x 是输入;
  • F(x) 是某个神经网络模块学到的变化量;
  • y 是输出;
  • x 这条直通路径让原始信息可以绕过复杂变换。

普通网络让一层直接学习目标映射:

text 复制代码
x -> H(x)

残差网络让模块学习"在 x 基础上改多少":

text 复制代码
x -> x + F(x)

如果某一层暂时学不到有用变化,把 F(x) 学成接近 0,整层就近似恒等映射:

text 复制代码
y ≈ x

这让深层网络更容易优化。

读完先达到这个程度就够了

  • 能解释深层网络为什么会有梯度消失和退化问题;
  • 能看懂残差公式 y = x + F(x)
  • 能从求导角度理解残差为什么有梯度直通路径;
  • 能写出一个 PyTorch 残差块;
  • 能理解 Transformer Block 里的残差连接在哪里;
  • 能知道残差不是万能药,还要配合归一化、初始化和学习率。

带着这 3 个问题读

  1. 为什么深度增加后,模型不一定更容易训练?
  2. x + F(x) 为什么比直接学习 H(x) 更容易?
  3. Transformer 为什么几乎每个子层外面都要包残差?

一、深层网络为什么难训练

从表达能力看,网络越深,理论上能表示越复杂的函数。但训练时不是"层数越多越好"。

深层网络常见问题包括:

  • 梯度消失:反向传播到浅层时梯度非常小,参数几乎不更新;
  • 梯度爆炸:梯度非常大,训练不稳定;
  • 信息衰减:输入经过很多层变换后,原始信息被破坏;
  • 退化问题:更深模型训练误差反而更高;
  • 优化困难:损失曲面更复杂,对初始化和学习率更敏感。

注意,退化问题不完全等同于过拟合。过拟合通常表现为训练集很好、验证集变差;退化问题可能是训练集本身都变差。

直觉上,如果一个 20 层网络能达到某个效果,那么 50 层网络至少可以让后 30 层什么都不做,应该不比 20 层差。但普通网络很难自动学出"什么都不做"的恒等映射。

残差连接就是为这件事提供了一条更容易的路径。


二、残差连接的核心公式

残差连接写成:

y = x + F ( x ) y = x + F(x) y=x+F(x)

如果目标映射是 H ( x ) H(x) H(x),那么残差模块学习的是:

F ( x ) = H ( x ) − x F(x) = H(x) - x F(x)=H(x)−x

也就是说,模型不直接学习完整目标 H ( x ) H(x) H(x),而是学习目标相对输入的变化量。

这有什么好处?

如果最优映射接近恒等映射,普通网络要学:

H ( x ) ≈ x H(x) \approx x H(x)≈x

残差网络只要学:

F ( x ) ≈ 0 F(x) \approx 0 F(x)≈0

很多时候,让一个模块输出接近 0,比让它精确复制输入更容易。

用代码看一个最小残差块:

python 复制代码
import torch
import torch.nn as nn

class ResidualBlock(nn.Module):
    def __init__(self, dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(dim, dim),
            nn.ReLU(),
            nn.Linear(dim, dim),
        )

    def forward(self, x):
        return x + self.net(x)

x = torch.randn(4, 16)
block = ResidualBlock(dim=16)
y = block(x)
print(y.shape)

输入和输出形状一样,才能直接相加。这点后面会很重要。


三、梯度直通:残差真正关键的地方

残差连接最重要的作用之一,是给梯度提供直通路径。

对公式求导:

y = x + F ( x ) y = x + F(x) y=x+F(x)

∂ y ∂ x = 1 + ∂ F ( x ) ∂ x \frac{\partial y}{\partial x} = 1 + \frac{\partial F(x)}{\partial x} ∂x∂y=1+∂x∂F(x)

普通层只有:

∂ H ( x ) ∂ x \frac{\partial H(x)}{\partial x} ∂x∂H(x)

如果很多层的导数都小于 1,连续相乘后梯度容易快速变小。残差连接里多了一项 1,这是关键:identity 路径的梯度系数恒为 1,不随层数增加而衰减或放大 。也就是说,无论堆多少层残差块, x x x 到 loss 至少存在一条"梯度系数始终是 1"的直通路径;即便残差分支的梯度趋近 0( ∂ F / ∂ x ≈ 0 \partial F/\partial x \approx 0 ∂F/∂x≈0),总梯度 ∂ y / ∂ x \partial y/\partial x ∂y/∂x 仍约为 1,不会像普通深层网络那样随层数连乘而衰减。这才是残差在深层网络里能缓解梯度消失的根本原因

更直观地说,残差连接让信息有两条路:

text 复制代码
主路:x -> F(x) -> + -> y
旁路:x ---------> + -> y

旁路不经过复杂非线性变换,梯度传播更顺畅。

这不是说残差完全消除了梯度问题,而是它显著降低了深层网络训练难度。


四、用一个小实验观察梯度

下面做一个教学实验:比较普通深层 MLP 和带残差的 MLP 在反向传播后,第一层梯度大小的差异。

这不是严格论文实验,只是帮助你形成直觉。

python 复制代码
import torch
import torch.nn as nn

class PlainMLP(nn.Module):
    def __init__(self, dim, depth):
        super().__init__()
        layers = []
        for _ in range(depth):
            layers.append(nn.Linear(dim, dim))
            layers.append(nn.Tanh())
        self.net = nn.Sequential(*layers)

    def forward(self, x):
        return self.net(x)

class ResidualMLP(nn.Module):
    def __init__(self, dim, depth):
        super().__init__()
        self.layers = nn.ModuleList([
            nn.Sequential(nn.Linear(dim, dim), nn.Tanh(), nn.Linear(dim, dim))
            for _ in range(depth)
        ])

    def forward(self, x):
        for layer in self.layers:
            x = x + 0.1 * layer(x)
        return x


def first_layer_grad_norm(model):
    for p in model.parameters():
        if p.grad is not None:
            return p.grad.norm().item()
    return 0.0

x = torch.randn(32, 64)
target = torch.randn(32, 64)

for Model in [PlainMLP, ResidualMLP]:
    model = Model(dim=64, depth=20)
    out = model(x)
    loss = ((out - target) ** 2).mean()
    loss.backward()
    print(Model.__name__, first_layer_grad_norm(model))

不同随机种子下数值会变化,但你通常能观察到:残差结构更容易让浅层拿到有效梯度。

代码里的 0.1 * layer(x) 是残差缩放,目的是避免一开始每层改动过大。很多大模型训练里也会出现类似的残差缩放或初始化策略。


五、残差和维度匹配

残差相加要求两个张量形状一致。

text 复制代码
x.shape == F(x).shape

如果维度不同,就不能直接相加。

例如:

python 复制代码
x = torch.randn(4, 16)
f = torch.randn(4, 32)
# x + f 会报错

解决方法通常有两种。

第一,让模块输入输出维度一致。这在 Transformer 里最常见:attention 子层和 FFN 子层最终都会输出 d_model 维。

第二,用投影把 shortcut 调整到目标维度。ResNet 里经常使用 1x1 卷积,MLP 里可以用线性层。

python 复制代码
class ProjectedResidualBlock(nn.Module):
    def __init__(self, in_dim, out_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_dim, out_dim),
            nn.ReLU(),
            nn.Linear(out_dim, out_dim),
        )
        self.shortcut = nn.Linear(in_dim, out_dim)

    def forward(self, x):
        return self.shortcut(x) + self.net(x)

但投影 shortcut 不是完全免费的。它增加参数和计算,也改变了 identity path 的纯粹性。能保持维度一致时,直接相加通常更简单。


六、残差连接和 LayerNorm 的关系

残差解决信息和梯度路径问题,LayerNorm 解决数值分布稳定问题。现代 Transformer 往往把两者一起使用。

常见结构有两种。

Post-Norm

原始 Transformer 更接近:

python 复制代码
x = norm(x + attention(x))
x = norm(x + ffn(x))

Pre-Norm

现代大模型常用:

python 复制代码
x = x + attention(norm(x))
x = x + ffn(norm(x))

Pre-Norm 的好处是梯度更容易沿着残差路径传播,深层模型训练更稳定。

一个极简 Pre-Norm block:

python 复制代码
class PreNormBlock(nn.Module):
    def __init__(self, dim):
        super().__init__()
        self.norm = nn.LayerNorm(dim)
        self.net = nn.Sequential(
            nn.Linear(dim, 4 * dim),
            nn.GELU(),
            nn.Linear(4 * dim, dim),
        )

    def forward(self, x):
        return x + self.net(self.norm(x))

可以看到,残差路径里的 x 没有经过 normnet,它直接传到输出。这条路径对训练很重要。


七、Transformer 里的残差连接

Decoder-Only Transformer Block 可以简化成:

python 复制代码
x = x + self_attention(norm1(x))
x = x + feed_forward(norm2(x))

第一条残差包住 self-attention,第二条残差包住 FFN。

为什么每个子层都要包残差?

因为每个子层都只是对表示做一次增量更新。

Self-attention 做的是:

text 复制代码
在当前 token 表示基础上,加入从上下文读取的信息

FFN 做的是:

text 复制代码
在当前 token 表示基础上,加入非线性加工后的特征

如果没有残差,每一层都必须完整重写表示,深层训练会困难得多。有了残差,每层只需要学习"这一层要补充什么"。

这非常符合大模型的层级加工方式:底层、中层、高层不断在已有表示上叠加信息。


八、残差连接不是简单保留原输入

有些初学者会误以为残差连接就是"把输入复制到后面",模型就不学习了。实际不是。

输出是:

text 复制代码
y = x + F(x)

如果 F(x) 学到有用变化,输出就会明显不同于输入。如果某层暂时不需要改变太多,F(x) 可以接近 0,让信息通过。

这给模型提供了灵活性:

  • 需要修改时,学变化;
  • 不需要修改时,近似跳过;
  • 训练初期,避免信息被随机层破坏;
  • 训练后期,多层逐步叠加复杂变换。

所以残差不是偷懒,而是让深层网络更容易学到合适的增量。


九、残差连接的常见变体

不同模型里,残差连接会有一些变体。

残差缩放

python 复制代码
y = x + alpha * F(x)

alpha 可以是固定小数,也可以随层数设置。它能避免深层网络一开始残差分支输出过大。

门控残差

python 复制代码
y = x + gate * F(x)

gate 控制残差分支贡献大小。某些架构会使用门控机制动态调节信息流。

投影残差

python 复制代码
y = W_s x + F(x)

当输入输出维度不一致时,用投影调整 shortcut。

DropPath / Stochastic Depth

训练时随机丢弃某些残差分支,让深层网络更鲁棒。这在视觉模型和一些大模型训练中也会出现。

这些变体都围绕同一个核心:保留一条稳定的信息通道,同时允许模块学习增量变化。


十、残差不是万能的

残差连接很重要,但它不能单独解决所有训练问题。

深层模型还需要:

  • 合理初始化;
  • 合适学习率和 warmup;
  • LayerNorm/RMSNorm;
  • 梯度裁剪;
  • 混合精度稳定策略;
  • 合理 batch size;
  • 数据质量和损失函数设计;
  • 分布式训练稳定性。

如果学习率过大,残差网络也会发散。

如果归一化位置不合理,深层 Transformer 也可能不稳定。

如果残差分支输出尺度过大,很多层叠加后数值也会失控。

所以残差是深层网络的基础设施之一,不是单独的万能药。


十一、工程中怎么检查残差相关问题

如果你在实现模型时遇到训练不稳定,可以检查这些点:

  1. 残差相加的两个张量 shape 是否完全一致;
  2. attention 和 FFN 输出是否回到 d_model
  3. mask 或 padding 是否导致输出出现 NaN;
  4. LayerNorm/RMSNorm 是 Pre-Norm 还是 Post-Norm;
  5. 残差分支初始化是否过大;
  6. 混合精度下是否出现 inf/nan;
  7. 学习率 warmup 是否足够;
  8. 梯度范数是否异常。

一个简单的 NaN 检查:

python 复制代码
with torch.no_grad():
    y = block(x)
    print(torch.isnan(y).any().item(), torch.isinf(y).any().item())

如果残差相加前某个分支已经 NaN,残差会把 NaN 继续传播下去。不要只看最终 loss,要逐层定位。


十二、常见误区

误区 1:残差连接只为了解决梯度消失。

梯度直通很重要,但残差还改善信息保留、恒等映射学习和深层优化难度。

误区 2:有了残差,网络就可以无限加深。

不行。深度还受归一化、初始化、学习率、数据、显存和计算成本限制。

误区 3:残差就是把输入复制一份,模型没学东西。

残差让模型学习增量 F(x)。如果增量有用,输出会改变;如果没必要,模型可以近似跳过。

误区 4:残差相加不需要考虑维度。

必须 shape 一致,或者用投影 shortcut。

误区 5:Pre-Norm 和 Post-Norm 差别不大。

对浅层模型可能差别不明显,但深层 Transformer 中归一化位置会显著影响训练稳定性。


十三、你应该记住的最小心智模型

残差连接可以这样记:

text 复制代码
普通层:直接学 H(x)
残差层:学 H(x) - x,再加回 x

核心公式:

y = x + F ( x ) y = x + F(x) y=x+F(x)

核心好处:

text 复制代码
信息可以直通
梯度可以直通
层可以学习增量
恒等映射更容易
深层网络更稳定

在 Transformer 里,它通常长这样:

python 复制代码
x = x + attention(norm(x))
x = x + ffn(norm(x))

看到这两行,你就看到了现代大模型深层堆叠的关键骨架。


总结

残差连接用 y = x + F(x) 这样简单的结构,解决了深层网络训练中的一类核心问题:信息和梯度不必完全穿过复杂变换,可以沿着 shortcut 直接传播。它让网络更容易学习恒等映射和增量变化,从而缓解梯度消失、退化和深层优化困难。

在 Transformer 中,残差连接包裹 self-attention 和 FFN 子层,是 GPT、BERT、LLaMA、Qwen 等模型能够稳定堆叠很多层的重要原因。它通常需要和 LayerNorm/RMSNorm、合理初始化、学习率 warmup、梯度裁剪等技术配合使用。

第一遍记住一句话:残差连接让每一层不必重写全部表示,只需在原表示上学习有用的增量。

大模型视角

后面你看 Transformer Block 时,会反复看到残差路径。Attention 负责从上下文拿信息,FFN 负责加工信息,而残差负责让这些加工以"增量更新"的方式叠加。没有残差,现代深层大模型很难稳定训练。

下一篇

Dropout、权重衰减、学习率调度实战 ------ 残差解决深层结构的训练路径问题,下一篇进入训练配置:如何用正则化和学习率策略让模型更稳地收敛。

相关推荐
嘟嘟嘟95271 小时前
Spotify Honk 的启示:AI 编程日常
人工智能·程序员·ai编程
西柚小萌新1 小时前
【论文阅读】--Trust Before Fusion:QIMG‑7 与面向污染多模态 RAG 的源感知信任
论文阅读·人工智能
Beyond9781 小时前
AI 代码的验证:一次"测试全绿、上线就崩"之后的改造
人工智能
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(72):EMA——在写入前决定什么值得记住
论文阅读·人工智能·学习·开源·github
lucas_AI1 小时前
第 4 讲 · intrinsics 迁移实战:手改 NEON 与兼容层的取舍
人工智能
程序员cxuan1 小时前
从 0 到 1 速通 WorkBuddy
人工智能·后端·程序员
桃西西呀1 小时前
邮件自动分类 Agent:分类即建模,动作即风险
人工智能·llm·agent
lucas_AI1 小时前
第 3 讲 · 看懂你的机器:NUMA 拓扑、缓存层次与一次实测
人工智能