网络加深后训练误差为何反而升高?残差连接的恒等捷径与梯度分流
从深层网络的退化问题出发,手算残差块的前向叠加与反向梯度分流,拆解投影捷径、预归一化和 PyTorch 2.13 实现。
上一篇用 BatchNorm 与 LayerNorm 控制中间激活的尺度,但即使每个变换内部都很稳定,信息仍要依次穿过所有权重、非线性与归一化层。网络越深,这条串行路径越长,优化器就越难找到“多加几层但至少别比浅层差”的参数。
残差连接(Residual Connection)给每个变换旁边增加一条恒等捷径(Identity Shortcut):让新层先学习“应该在输入上改多少”,再把改动加回输入。本文只讲透三个紧密相关的问题:前向为何改学残差、反向梯度如何分流,以及形状改变和预归一化时怎样保住捷径。
01 更深却有更高训练误差,不等于过拟合#
若 20 层网络已经得到可用函数,理论上给它再接若干恒等映射,新网络至少可以复现原来的输出。然而普通串行层要求优化器从随机参数中学出这些恒等映射;这并不容易。深层模型可能出现训练误差也升高的退化问题(Degradation Problem),它和“训练误差低、验证误差高”的过拟合不是一回事。
普通两层变换写成:
其中 是第 个块的输入, 是 batch 大小, 是特征维度;; 是逐元素非线性。每加一块,前向信号和反向梯度都必须再穿过两次矩阵乘法和非线性。
残差块改成:
是残差分支(Residual Branch),参数集合为 ;捷径分支直接复制 。若最优选择是“不改变输入”,只需让 ,而不必让一串非线性层精确拟合恒等函数。
02 前向数据到底怎样走?#
残差分支 F(x) [N,D]
┌─ Linear/Conv ─ Norm ─ Act ─ Linear/Conv ─┐
│ ▼
x_l [N,D] ── split ──────┤ (+) ──► x_{l+1} [N,D]
│ ▲
└──────── 恒等捷径 identity [N,D] ──────────┘
约束:相加两端必须具有相同 shape、device 和可兼容 dtype。
语义:输出 = 原信息 + 本块学到的修正。text这不是把两路特征拼接。相加(Addition)保持维度 不变;拼接(Concatenation)会得到 [N,2D],后续层的参数量和语义都会变化。
连续堆叠时,先忽略块尾额外的非线性:
浅层表示 可以沿捷径直接进入深层,残差分支则逐块累积修正。注意 的输入是不断更新的 ,上式并不表示各块彼此独立或可以任意交换顺序。
03 用两个数手算一次前向叠加#
令输入与残差分支为:
先算修正量:
普通串行块只输出 ;残差块输出:
第一个特征被增强 0.5,第二个特征被削弱 1,但原输入并未要求残差分支重新生成。若把 初始化为零,残差块从 开始;若 很小,它从“接近恒等”开始。
04 反向传播为什么会多出一条直接路径?#
设损失为 ,上游梯度 与 同形。由链式法则:
按列向量记法写得更明确:
是残差分支对输入的雅可比矩阵(Jacobian)。第一项 来自恒等捷径,不乘权重;第二项才穿过残差分支。所谓“梯度高速公路”不是梯度永远等于 1,而是每个块都提供了一项不经过该块参数变换的贡献。
回到上面的线性例子,令上游梯度 :
若没有捷径,梯度只有 。两条路径也可能相互抵消,所以残差连接缓解而不保证消灭梯度消失或爆炸。
05 多层相乘时差异有多大?#
考虑每层只有一个标量,残差函数 。普通串行网络的梯度为:
残差网络则为:
若四层都有 ,普通路径的梯度大小是 ,残差路径是 。这个例子只说明恒等项如何改变乘积,不代表参数越接近零越好:若 ,残差因子反而为零;若很多 ,乘积也可能爆炸。
06 不依赖框架,写出前向与反向本体#
下面直接实现线性残差块。x 的形状为 [N,D],weight 为 [D,D],输出仍为 [N,D]:
import numpy as np
def residual_linear_forward(x, weight):
"""y = x + x @ weight.T"""
assert x.ndim == 2
assert weight.shape == (x.shape[1], x.shape[1])
residual = x @ weight.T
return x + residual, (x, weight)
def residual_linear_backward(grad_y, cache):
"""返回 grad_x [N,D] 与 grad_weight [D,D]。"""
x, weight = cache
grad_x_identity = grad_y
grad_x_residual = grad_y @ weight
grad_x = grad_x_identity + grad_x_residual
grad_weight = grad_y.T @ x
return grad_x, grad_weight
x = np.array([[1.0, 2.0]]) # [N=1,D=2]
weight = np.diag([0.5, -0.5]) # [D=2,D=2]
y, cache = residual_linear_forward(x, weight) # [1,2]
grad_x, grad_weight = residual_linear_backward(
np.ones_like(y), cache
)
np.testing.assert_allclose(y, [[1.5, 1.0]])
np.testing.assert_allclose(grad_x, [[1.5, 0.5]])
assert grad_weight.shape == (2, 2)pythongrad_weight = grad_y.T @ x 只来自残差分支,因为恒等捷径没有参数。grad_x 则是两路梯度相加;若漏掉 grad_x_identity,代码虽然形状正确,却已经退化成普通串行层。
07 训练与推理的完整伪代码#
对每个残差块 l:
输入 x_l [N,D] 或 [N,C,H,W]
identity = x_l
residual = F_l(x_l; θ_l)
若 residual 与 identity 形状不同:
identity = P_l(x_l) # 只做必要的尺寸/通道对齐
assert shape(identity) == shape(residual)
x_{l+1} = identity + residual
训练:
loss = criterion(model(X), y)
清空梯度 -> backward -> 检查两分支梯度 -> optimizer.step
推理:
切换 eval 模式并关闭梯度记录
使用同一相加数据流;只有 Norm/Dropout 等子模块行为可能切换text残差连接本身没有可训练参数,也没有 train/eval 两套行为;差异来自 或投影 内部的 BatchNorm、Dropout 等模块。
08 形状改变时,恒等捷径怎么办?#
相加要求两路输出形状完全一致。卷积网络中,残差分支可能用 stride=2 把 [N,C,H,W] 变为 [N,2C,H/2,W/2],原始输入不能直接相加。
常用投影捷径(Projection Shortcut)是一个 1×1 卷积:
若步幅为 2,它同时改变通道数和空间尺寸:
x [N,64,56,56]
├─ residual: 3×3 conv, stride=2 ─ ... ─► [N,128,28,28] ─┐
└─ shortcut: 1×1 conv, stride=2 ───────► [N,128,28,28] ─┤ (+)
│
▼
y [N,128,28,28]text投影路径不再是严格恒等映射,也会引入参数和雅可比矩阵。因此只在形状必须改变时使用;同形块优先保留无参数的 nn.Identity() 或直接使用 x。
| 场景 | 捷径 | 是否有参数 | 梯度直接项 |
|---|---|---|---|
同形 [N,C,H,W] → [N,C,H,W] | x / Identity | 否 | |
| 改通道或下采样 | 1×1 Conv(stride) | 是 | |
| 拼接而不是相加 | concat([x,F(x)]) | 否,但后层变宽 | 不是标准残差加法 |
09 预归一化为什么更接近“干净捷径”?#
上一篇介绍了 LayerNorm。把归一化放在相加之前还是之后,会改变捷径的数据流。
后归一化(Post-Norm)常写为:
即使走捷径,信号和梯度仍必须穿过块尾 LayerNorm。预归一化(Pre-Norm)改为:
此时相加后的 到 是真正的恒等路径,归一化只在残差分支内。它通常让很深的序列模型更容易优化,但也会改变表征尺度、最终归一化位置和训练动态,不能只移动一行代码后假设模型完全等价。
Post-Norm: x ─┬─────────────┐
└─ F(x) ──────┴─ (+) ─ LN ─► y
捷径仍穿过 LN
Pre-Norm: x ─┬───────────────────────┐
└─ LN ─ F(·) ──────────┴─ (+) ─► y
相加后的主路径保持 identitytext在原始卷积 ResNet 中还会看到“卷积—BatchNorm—ReLU”的块内顺序以及全预激活(Full Pre-activation)设计。它们和 Transformer 的 Pre-LN 共享“把变换留在残差分支、让相加路径更直接”的思想,但具体归一化轴和块结构不同,不能把名称当作同一个实现。
10 用 PyTorch 2.13 实现可检查的卷积残差块#
当前 PyTorch 2.13 官方 Conv2d ↗ 接收 [N,C,H,W];BatchNorm2d ↗ 保持同形并按通道归一化。下面显式构造残差分支和必要的投影捷径:
import torch
from torch import nn
class BasicResidualBlock(nn.Module):
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.conv2 = nn.Conv2d(
out_channels, out_channels, kernel_size=3,
padding=1, bias=False,
)
self.bn2 = nn.BatchNorm2d(out_channels)
self.activation = nn.ReLU()
if stride == 1 and in_channels == out_channels:
self.shortcut = nn.Identity()
else:
self.shortcut = nn.Sequential(
nn.Conv2d(
in_channels, out_channels, kernel_size=1,
stride=stride, bias=False,
),
nn.BatchNorm2d(out_channels),
)
# 让残差分支末端初始接近 0;初始化函数在 no_grad 下执行。
nn.init.zeros_(self.bn2.weight)
def forward(self, x):
identity = self.shortcut(x)
residual = self.conv1(x)
residual = self.activation(self.bn1(residual))
residual = self.bn2(self.conv2(residual))
assert residual.shape == identity.shape
return self.activation(identity + residual)
same_shape = BasicResidualBlock(64, 64, stride=1)
downsample = BasicResidualBlock(64, 128, stride=2)
x = torch.randn(8, 64, 56, 56) # [N=8,C=64,H=56,W=56]
y = same_shape(x) # [8,64,56,56]
z = downsample(y) # [8,128,28,28]
assert y.shape == (8, 64, 56, 56)
assert z.shape == (8, 128, 28, 28)python卷积后接 BatchNorm 时把卷积 bias=False,因为归一化层默认已有可学习偏置。nn.init.zeros_(self.bn2.weight) 把最后一次 BatchNorm 的缩放参数 置零,使同形块初始残差接近零;这是一种可选初始化策略,不应误写成把所有卷积权重都置零。
11 怎样检查梯度确实走了两条路?#
给输入注册 hook,并分别保留捷径与残差输出的梯度:
import torch
from torch import nn
class InspectResidual(nn.Module):
def __init__(self, width):
super().__init__()
self.branch = nn.Linear(width, width, bias=False)
def forward(self, x):
residual = self.branch(x)
residual.retain_grad()
output = x + residual
return output, residual
torch.manual_seed(7)
block = InspectResidual(width=4)
x = torch.randn(3, 4, requires_grad=True) # [N=3,D=4]
output, residual = block(x) # 两者都是 [3,4]
loss = output.square().mean() # 标量
loss.backward()
assert x.grad is not None
assert residual.grad is not None
assert block.branch.weight.grad is not None
print({
"input_grad_norm": x.grad.norm().item(),
"residual_grad_norm": residual.grad.norm().item(),
"weight_grad_norm": block.branch.weight.grad.norm().item(),
})python这里 residual.grad 就是相加节点传入残差分支的上游梯度;x.grad 还多接收一份恒等捷径的梯度。不要期待两个范数满足简单相加,因为梯度是向量,方向不同会增强或抵消。
更严格的数值检查是把同一个 x 分别送入 x + branch(x) 与手写矩阵公式,再用 torch.testing.assert_close 比较前向和 torch.autograd.grad 结果。若自定义算子参与残差分支,可用双精度 torch.autograd.gradcheck 验证局部导数。
12 最常见的错误与最短调试路径#
- 相加两端 shape 不同。 先打印两路
[N,C,H,W];通道或步幅变化时再加1×1投影,不要靠错误广播“凑巧运行”。 - 原地操作破坏反向需要的值。 复杂分支中谨慎使用
inplace=True或+=;出现 autograd version 报错时先换成非原地写法。 - 把拼接当残差连接。
torch.cat会改变通道数;若设计确实需要 DenseNet 式拼接,应同步计算后续层输入维度与内存。 - 每个同形块都加投影。 这会丢掉无参数恒等路径并增加计算;只在尺寸变化时投影。
- 错误地把所有参数初始化为零。 同层神经元会保持对称;只将残差分支末端特定缩放置零,与上一篇的 He/Xavier 初始化配合。
- 忽略 BatchNorm 模式。 残差加法不区分训练和推理,但分支中的 BatchNorm 区分;验证必须同时使用
eval()与no_grad()。 - 残差分支压过主干。 记录
||F(x)|| / (||x|| + ε);若长期极大,检查学习率、初始化、归一化与输入尺度。 - 只看总梯度范数。 分层记录输入梯度、分支参数梯度和投影梯度,才能定位第一个异常块。
最短路径:固定随机种子与一个 batch → 打印相加前两路形状、均值和范数 → 暂时令 验证同形块是否近似恒等 → 对一个标量损失跑 backward → 从输出向输入逐块检查梯度 → 最后再恢复完整训练。
13 残差连接会在哪里失败?#
残差网络仍可能因为学习率过大、归一化统计失配、低精度溢出或分支尺度持续增长而不稳定。恒等捷径也不会自动提升泛化:模型容量增加后,数据不足或正则化不当仍会过拟合。
它与相近结构的区别在于信息合并方式:
- 高速网络(Highway Network)用可学习门控制变换分支和携带分支,灵活但多出门参数;
- DenseNet 把此前层输出沿通道拼接,鼓励特征复用,却会让通道数和显存增长;
- U-Net 的长跳连常连接编码器与解码器的同尺度特征,主要服务空间细节恢复,不等于每个块的局部残差;
- ReZero 用可学习标量从零缩放残差分支,显式让深层网络从恒等映射开始;
- 随机深度(Stochastic Depth)训练时随机跳过部分残差分支,是正则化和缩短有效训练路径的方法,不改变推理时完整网络结构。
14 今天真正需要记住什么?#
- 残差块学习 ,输出 ;当额外变换不必要时,让残差接近零比让深层串行函数精确学出恒等映射更容易。
- 反向梯度为 ;恒等项提供直接路径,但仍可能与残差项抵消或一起爆炸。
- 相加两路必须同形;尺寸改变时才使用投影捷径,同形块应保留无参数恒等路径。
- Pre-Norm 把归一化留在残差分支内,使块间相加路径更直接;它与 Post-Norm 的训练动态并不等价。
- 调试残差网络要同时观察形状、分支尺度、逐块梯度和归一化状态,不能只看“用了 ResNet”这个名字。
15 思考题与小练习#
- 令 、、,手算 ;若 ,再手算 ,标出两条路径各自的贡献。
- 输入为
[N=4,C=32,H=28,W=28],残差分支输出[4,64,14,14]。设计最小投影捷径,写出卷积核、步幅、权重张量形状和输出形状,并说明为何nn.Identity()不能使用。 - 各实现一个
PostNormBlock与PreNormBlock,共享同一残差分支和初始参数。堆叠 24 层后,对同一标量损失记录每层输入梯度范数;比较曲线前先确认两种块的最终归一化位置。
相关工作#
- He et al. (2016), Deep Residual Learning for Image Recognition ↗:提出残差学习框架,系统展示深层网络的退化问题与恒等捷径。
- He et al. (2016), Identity Mappings in Deep Residual Networks ↗:分析恒等映射与全预激活残差单元的前向、反向传播路径。
- Srivastava et al. (2015), Highway Networks ↗:用门控机制在变换与携带路径之间选择,为极深网络提供另一种路径设计。
- Huang et al. (2017), Densely Connected Convolutional Networks ↗:以特征拼接连接所有先前层,展示不同于残差相加的特征复用方式。
- Xiong et al. (2020), On Layer Normalization in the Transformer Architecture ↗:从梯度角度比较 Transformer 中 Post-LN 与 Pre-LN 的优化行为。
16 下一篇预告#
残差连接让更深的特征提取器更容易训练,但本文的卷积仍只在局部窗口内共享权重。下一篇将从图像平移出发,拆解卷积的局部连接、权重共享、感受野与输出尺寸,说明 CNN 如何把二维空间结构写进模型。