模型学习笔记 · 第三章
残差连接:学习改动,同时保留一条直达路径
理解 x + F(x)、梯度的直接通路,以及为什么网络加深后仍需要仔细设计。
Muxuan Liu · 模型学习笔记
从复合函数理解残差连接
在理解深层网络时,我习惯先把模型看成一个复合函数。暂时不展开每层内部的注意力、卷积或激活运算,只追踪表示如何经过一次次变换。普通网络可以写成:
这里的各个 Fₗ 不必具有相同的参数。直观地说,这是 F(F(F(x))) 一样的层层嵌套;实际写公式时,我会加上层编号,区分各层不同的变换。这个视角让我同时关心两个问题:前向传播时表示怎样变化,反向传播时各层的导数又怎样相乘。
加入残差连接后,网络仍然是复合函数,只是每个被复合的模块变成了 Gₗ(x) = x + Fₗ(x)。对我来说,关键在于每一步都保留当前表示,并学习一个更新量,而不是取消函数之间的嵌套。
这种理解也让我能够把前向结构和反向计算联系起来:普通模块的雅可比矩阵是 JFₗ,残差模块则是 I + JFₗ。不过,出现恒等项并不等于总梯度一定不会消失,后面我会用一个标量例子检查这个直觉。
让一层学习修改,而不是全部重写
如果当前表示已经包含有用信息,可以保留它,再学一个更新量:y = x + F(x)。这像修改一份草稿,在原稿上增加调整,而不是每次从空白重新写起。
残差连接指直接相加的通路;残差块则包含这条通路和一个可学习分支,例如卷积,或注意力与前馈模块。ResNet 是由残差块构成的一类具体网络,残差连接这个想法也被其他架构广泛使用。
捷径为什么有助于优化
如果恒等映射是合适的,分支 F(x) 可以趋近于零,让输入继续保留下去。这样深层网络更容易在已有表示上学习改动。残差学习缓解的是深层网络的优化困难,包括增加层数后出现的退化问题,而不只是梯度消失。
“残差”并不要求每个分支的输出都很小,也不表示模型总是希望复制输入。实际要学多大的更新,由任务和架构共同决定。
先写递推,再展开公式
用 xₗ₊₁ = xₗ + Fₗ(xₗ) 表示每个块。经过 L 个块,xL = x0 + Σ Fₗ(xₗ),每个 Fₗ 都在对应的中间状态上计算。这样比直接展开一长串嵌套更不容易弄错。
对于一个块,采用列向量梯度时,输入梯度是 (I + JF(x))ᵀ 乘以输出梯度。I 是恒等矩阵,JF 是分支的雅可比矩阵。直接项增加了一条传播路径,但不意味着总梯度至少为 1:分支既可能放大它,也可能部分抵消它。
用一个线性函数检查梯度
我最初直觉上认为:既然每层的导数里多了一个“1”,梯度似乎就有了保底。但这一步不能只凭直觉判断,所以我把分支简化成标量函数 F(x)=a·x,连续堆叠 L 个相同的残差块。
每经过一个块,输入都乘以 1+a,因此总导数是 (1+a)^L。这里容易误写成 1+a+a²+…+a^L,但它不适用于这个递推:后一个块接收的是前一个块的完整输出。以三个块为例,展开得到 1+3a+3a²+a³,不能漏掉交叉项。
例如 a=−0.5、L=3 时导数为 0.125;a=1 时则是 8。这修正了我对“直接路径”的理解:它确实提供了额外的传播通路,但不同路径的贡献可能相互抵消或叠加,并不保证梯度至少为 1。初始化、归一化、分支缩放以及实际学到的函数仍然重要。
残差流的方差为什么会增长
为了直观理解残差流的尺度,我设想一个初始为零、长度为 768 的向量,每层都给各分量加上独立的标准正态噪声。重复 100 层后,每个分量的理论方差为 100,标准差为 10;单次采样得到的 768 个分量,其样本标准差只会近似这个值。
如果把每层的噪声乘以 1/√100 = 0.1,那么 100 次独立更新之和的方差就变成 1。这个例子帮助我理解为什么深层残差网络会考虑缩放更新量。
但这里必须保留“独立更新”的假设。对标量随机变量,Var(x+F) = Var(x)+Var(F)+2Cov(x,F)。真实网络里的 F(x) 依赖当前状态 x,协方差项不能随意省略。因此,这个思想实验说明的是一种缩放动机,并不是训练后 Transformer 的完整模型,也不能据此断言所有网络都应该使用 1/√L。
实现时应该检查什么
相加两端的形状需要兼容。如果分支改变了宽度,捷径可能也需要投影。在 Transformer 中,归一化放在哪里还会改变实际递推,不能把简单的 y=x+F(x) 与完整模块直接等同。
下面代码用自动求导验证标量例子。对我来说,最实用的阅读方式是同时追踪前向数值和反向梯度:捷径改善了深层网络中的信息通路,而训练是否稳定仍取决于各个部分怎样配合。
展开 PyTorch 示例
import torch
# Minimal residual-gradient example: three blocks, F(x) = a*x.
def residual_gradient(a, depth=3):
x = torch.tensor(1.0, requires_grad=True)
y = x
for _ in range(depth):
y = y + a * y
y.backward()
return x.grad.item() # (1+a)**depth
for a in [-0.5, 0.1, 1.0]:
print(a, residual_gradient(a), (1 + a) ** 3)