查看全课大纲(第 13 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

残差连接与归一化为什么有效

残差连接与归一化为什么有效?

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

在上一节,我们学习了卷积神经网络的基本结构,并看到了VGG等网络通过堆叠卷积层来增加深度。然而,简单地增加层数会遇到“退化”问题:更深的网络训练误差反而更大。本节将深入探讨两个解决深度网络训练难题的核心机制:残差连接与归一化。学完本节,你将能回答:为什么ResNet能训练上千层的网络?BatchNorm和LayerNorm分别解决了什么问题,它们有何不同?这些机制在代码中如何实现?

💡 核心导读

  1. 残差连接:将学习目标从“完整的映射”改为“残差映射”,通过恒等快捷连接实现,解决了深度网络的梯度消失和退化问题。
  2. 批归一化:在训练时对每个特征通道在批次维度上进行归一化,稳定层输入的分布,加速训练并允许使用更高的学习率。
  3. 层归一化:在训练和测试时对单个样本的所有特征进行归一化,不依赖批次大小,尤其适用于循环神经网络和小批量场景。
  4. 机制对比:理解BatchNorm和LayerNorm在归一化维度上的根本区别,以及它们各自适用的场景。

🧭 前置知识

  1. 卷积层和全连接层的前向传播过程。
  2. 梯度下降和反向传播的基本概念(链式法则)。
  3. 张量的基本操作和形状概念,例如 (batch, channel, height, width)。
  4. 激活函数(如ReLU)及其饱和区概念。

深度网络的训练难题:退化与梯度

在《Deep Residual Learning for Image Recognition》(He et al., 2015,arXiv:1512.03385)中,作者指出,随着网络深度增加,模型性能会先饱和,然后迅速下降。关键的是,这种退化并非由过拟合导致,因为更深的网络在训练集上的误差也更高。

论文中给出了一个直观的例子:一个56层的“普通”网络比20层的网络训练误差更高。这引出了一个核心问题:理论上,一个更深的模型至少可以通过将新增的层设置为恒等映射,并复制浅层网络的权重,来达到与浅层网络相同的性能。既然这种解是存在的,为什么我们的优化器找不到它?

答案是:对当前的求解器而言,用堆叠的非线性层去逼近恒等映射出乎意料地困难。ResNet 论文特别澄清:这些「普通」网络已经用了 BatchNorm,前向信号与反向梯度的尺度都是健康的,所以退化并不是梯度消失或爆炸导致的;它是网络已经能开始收敛之后才暴露出来的优化困难——存在「构造上必然成立」的恒等解,但优化器找不到它。

残差学习:让网络学习“差值”

为了解决上述问题,残差网络提出了一个巧妙的思路:不直接让堆叠的层去拟合目标映射 H(x)\mathcal{H}(\mathbf{x}),而是让它们去拟合残差映射 F(x):=H(x)−x\mathcal{F}(\mathbf{x}) := \mathcal{H}(\mathbf{x}) - \mathbf{x}。

原始的映射因此被重构为 F(x)+x\mathcal{F}(\mathbf{x}) + \mathbf{x}。这里的核心假设是:优化残差映射 F(x)\mathcal{F}(\mathbf{x}) 比优化原始的、无参考的映射 H(x)\mathcal{H}(\mathbf{x}) 更容易。

为什么?

  • 极端情况:如果对于某些层,恒等映射 H(x)=x\mathcal{H}(\mathbf{x}) = \mathbf{x} 是最优的,那么将残差 F(x)\mathcal{F}(\mathbf{x}) 推向零,比用一堆非线性层去拟合恒等映射要容易得多。
  • 一般情况:如果最优函数更接近恒等映射而非零映射,那么求解器相对于恒等映射寻找扰动(即残差),也比从头学习一个新函数要容易。

残差块的实现:恒等快捷连接

这个 F(x)+x\mathcal{F}(\mathbf{x}) + \mathbf{x} 的公式可以通过带有“快捷连接”的前馈神经网络来实现。在ResNet中,快捷连接执行简单的恒等映射,并将其输出与堆叠层的输出相加。

一个基础的残差块可以形式化定义为: y=F(x,{Wi})+x\mathbf{y} = \mathcal{F}(\mathbf{x}, \lbrace W_i \rbrace) + \mathbf{x} 其中 x\mathbf{x} 和 y\mathbf{y} 是块的输入和输出向量,F\mathcal{F} 代表要学习的残差函数(例如,两个3x3卷积层)。

当输入和输出的维度一致时,快捷连接就是恒等映射。当维度增加时(例如下采样),论文中采用了两种策略:1) 快捷连接仍然执行恒等映射,额外用零填充缺失的维度;2) 使用1x1卷积进行投影来匹配维度。

下面我们用代码实现一个最简单的残差块,并观察其前向传播的形状变化。

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)

class BasicBlock(nn.Module):
    """一个基础的残差块,包含两个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.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)

        # 快捷连接
        # 如果步长不为1或输入输出通道数不同,则需要投影快捷连接
        self.shortcut = nn.Sequential()
        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 = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(identity)  # 关键:残差连接
        out = F.relu(out)
        return out

# 构造一个模拟输入(batch=2, channel=64, height=32, width=32)
x = torch.randn(2, 64, 32, 32)
block = BasicBlock(64, 64, stride=1)  # 通道不变,不进行下采样
block.eval()  # 切换到评估模式,避免BatchNorm的随机性

with torch.no_grad():
    output = block(x)

print("输入 x.shape =", x.shape)
print("输出 output.shape =", output.shape)
print("快捷连接 shortcut 是一个:", block.shortcut)
# 验证快捷连接是恒等映射(在此例中)
if isinstance(block.shortcut, nn.Sequential) and len(block.shortcut) == 0:
    print("快捷连接执行恒等映射(直接相加)。")
else:
    print("快捷连接包含1x1卷积以匹配维度。")
输入 x.shape = torch.Size([2, 64, 32, 32])
输出 output.shape = torch.Size([2, 64, 32, 32])
快捷连接 shortcut 是一个: Sequential()
快捷连接执行恒等映射(直接相加)。

归一化:稳定层输入的分布

深度网络训练的另一个核心挑战是内部协变量偏移。在《Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift》(Ioffe & Szegedy, 2015,arXiv:1502.03167)中,作者将其定义为:由于网络参数在训练期间不断更新,导致每一层输入的分布也随之发生变化。

这种变化迫使后续层需要不断适应新的输入分布,降低了训练效率。它还会导致梯度问题:例如,对于Sigmoid或Tanh激活函数,如果输入绝对值很大,梯度会趋近于零(饱和区),训练将非常缓慢。

批归一化的核心思想是:在每一层的激活函数之前,对输入进行归一化,使其均值为0,方差为1。但关键点在于,这个均值和方差不是在整个数据集上计算(不现实),而是在当前训练的小批量数据上计算。

批归一化(BatchNorm)的算法

对于一个具有 dd 维输入的小批量数据 B={x1,...,xm}\mathcal{B} = \lbrace x_1, ..., x_m \rbrace,BatchNorm 对每一维(通常是特征通道)独立地进行如下操作:

  1. 计算小批量均值和方差: μB=1m∑i=1mxi\mu_\mathcal{B} = \frac{1}{m} \sum_{i=1}^{m} x_i σB2=1m∑i=1m(xi−μB)2\sigma_\mathcal{B}^2 = \frac{1}{m} \sum_{i=1}^{m} (x_i - \mu_\mathcal{B})^2
  2. 归一化: x^i=xi−μBσB2+ϵ\hat{x}_i = \frac{x_i - \mu_\mathcal{B}}{\sqrt{\sigma_\mathcal{B}^2 + \epsilon}} (ϵ\epsilon 是一个很小的常数,用于数值稳定性)
  3. 缩放与偏移: yi=γx^i+βy_i = \gamma \hat{x}_i + \beta 其中 γ\gamma 和 β\beta 是可学习的参数(分别称为“增益”和“偏置”),使得网络可以恢复原始的表示能力(如果最优的话)。

在训练时,均值和方差来自当前小批量。在测试/推理时,通常使用训练阶段估算的移动平均均值和方差。

import torch
import torch.nn as nn

torch.manual_seed(0)

# 模拟一个卷积层的输出特征图 (batch=4, channels=3, height=5, width=5)
# BatchNorm2d 在通道维度(dim=1)上进行归一化。
batch_size, channels, h, w = 4, 3, 5, 5
feature_map = torch.randn(batch_size, channels, h, w)

# 使用 PyTorch 的 BatchNorm2d
bn_layer = nn.BatchNorm2d(num_features=channels, eps=1e-5, momentum=0.1)
# 默认处于 train 模式:用当前批次的均值/方差归一化,并同步更新 running 统计量

print("原始特征图(第一个样本的第一个通道,前几个值):")
for row in feature_map[0, 0, :3]:
    print(" ", [round(v.item(), 4) for v in row[:3]])

with torch.no_grad():
    output = bn_layer(feature_map)

print("\nBatchNorm 后(第一个样本的第一个通道,前几个值):")
for row in output[0, 0, :3]:
    print(" ", [round(v.item(), 4) for v in row[:3]])

print("\nBatchNorm 层的状态:")
print("  gain (weight):", [round(w.item(), 4) for w in bn_layer.weight.data])
print("  bias (bias):", [round(b.item(), 4) for b in bn_layer.bias.data])
print("  running_mean:", [round(m.item(), 4) for m in bn_layer.running_mean])
print("  running_var:", [round(v.item(), 4) for v in bn_layer.running_var])
原始特征图(第一个样本的第一个通道,前几个值):
  [-1.1258, -1.1524, -0.2506]
  [0.692, -0.316, -2.1152]
  [0.35, 0.3081, 0.1198]

BatchNorm 后(第一个样本的第一个通道,前几个值):
  [-1.2134, -1.2428, -0.2459]
  [0.796, -0.3183, -2.3071]
  [0.4179, 0.3717, 0.1635]

BatchNorm 层的状态:
  gain (weight): [1.0, 1.0, 1.0]
  bias (bias): [0.0, 0.0, 0.0]
  running_mean: [-0.0028, 0.0017, 0.0032]
  running_var: [0.9827, 0.9868, 0.9924]

看两组数:归一化前这一通道的取值有正有负、整体偏负;归一化后每个值都被拉向「以 0 为中心、方差约 1」的尺度(gamma=1, beta=0 时不做额外缩放)。注意 running_mean / running_var 也变了——train 模式下它们会按 momentum=0.1 往当前批次的统计量靠拢,eval 推理时用的就是这两个累积值,而不是当前 batch 的统计量。

层归一化(LayerNorm)的引入

BatchNorm 依赖于足够大的小批量来获得稳定的统计量,这不适用于小批量、在线学习或循环神经网络。因为RNN在不同时间步的统计量不同,且序列长度可变。

《Layer Normalization》(Ba et al., 2016,arXiv:1607.06450)提出了另一种方案:对单个样本的所有特征(或指定维度)计算均值和方差进行归一化。

对于一个包含 HH 个隐藏单元的层,其输入向量 al\mathbf{a}^l,层归一化计算: μl=1H∑i=1Hail,σl=1H∑i=1H(ail−μl)2\mu^l = \frac{1}{H} \sum_{i=1}^{H} a_i^l, \quad \sigma^l = \sqrt{\frac{1}{H} \sum_{i=1}^{H} (a_i^l - \mu^l)^2} 然后进行类似的缩放和偏移:hl=gσl⊙(al−μl)+b\mathbf{h}^l = \frac{\mathbf{g}}{\sigma^l} \odot (\mathbf{a}^l - \mu^l) + \mathbf{b}。

关键区别:LayerNorm 的统计量计算不跨批次,只依赖于当前样本。因此,它在训练和测试时的行为完全一致,且对批次大小不敏感。

import torch
import torch.nn as nn

torch.manual_seed(0)

# 模拟一个全连接层或RNN时间步的输出 (batch=2, features=6)
# LayerNorm 默认在最后一个维度进行归一化。
batch_size, features = 2, 6
x = torch.randn(batch_size, features)

print("输入 x:")
for i in range(batch_size):
    print(" ", [round(v.item(), 4) for v in x[i]])

# 使用 PyTorch 的 LayerNorm
# normalized_shape 可以是一个整数(最后一维大小)或元组(指定要归一化的维度)
layer_norm = nn.LayerNorm(normalized_shape=features, elementwise_affine=True)
# 初始时,gamma=1, beta=0
with torch.no_grad():
    output = layer_norm(x)

print("\nLayerNorm 后输出:")
for i in range(batch_size):
    print(" ", [round(v.item(), 4) for v in output[i]])

# 手动验证第一个样本的计算
sample = x[0]
mean = sample.mean()
var = sample.var(unbiased=False)  # LayerNorm 使用有偏方差(除以N)
std = torch.sqrt(var + layer_norm.eps)
manual_norm = (sample - mean) / std
# 应用可学习参数(初始为1和0)
manual_output = manual_norm * layer_norm.weight + layer_norm.bias

print(f"\n手动验证第一个样本:")
print(f"  均值 μ = {mean.item():.4f}")
print(f"  方差 σ² = {var.item():.4f}")
print(f"  手动归一化后(前3个值): {[round(v.item(), 4) for v in manual_output[:3]]}")
print(f"  LayerNorm 输出(前3个值): {[round(v.item(), 4) for v in output[0, :3]]}")
print(f"  两者是否接近? {torch.allclose(manual_output, output[0], atol=1e-5)}")
输入 x:
  [1.541, -0.2934, -2.1788, 0.5684, -1.0845, -1.3986]
  [0.4033, 0.838, -0.7193, -0.4033, -0.5966, 0.182]

LayerNorm 后输出:
  [1.6176, 0.1452, -1.3681, 0.837, -0.4898, -0.7419]
  [0.8003, 1.5688, -1.1845, -0.6259, -0.9677, 0.409]

手动验证第一个样本:
  均值 μ = -0.4743
  方差 σ² = 1.5522
  手动归一化后(前3个值): [1.6176, 0.1452, -1.3681]
  LayerNorm 输出(前3个值): [1.6176, 0.1452, -1.3681]
  两者是否接近? True

残差与归一化的协同效应

在实践中,残差连接和归一化层(尤其是BatchNorm)常常一起使用,它们共同解决了深度网络训练的稳定性问题。

  1. 残差连接:提供了清晰的梯度通路(快捷连接),使得梯度可以直接回传,缓解了梯度消失。
  2. 归一化:稳定了每一层输入的分布,使得优化过程更加平滑,允许使用更大的学习率,并对模型有一定的正则化效果。

在ResNet的原始设计中,每个残差块内部就采用了“卷积 -> BatchNorm -> ReLU”的顺序。这种组合成为了构建极深网络的基石。

📝 动手练一练

问题:假设我们有一个两层的全连接网络(输入 10 维 → 隐藏 20 维 → 输出 5 维)。请为它加上残差连接(残差块内部是「线性 → LayerNorm → ReLU」的重复),写出修改后的前向传播伪代码。再思考:如果隐藏层维度改成 30,残差连接需要怎么调整?

👉 点击查看参考答案

思路:残差连接通常加在具有两个或更多线性变换(如全连接层、卷积层)的块上。当输入输出维度一致时,使用恒等快捷连接;不一致时,需要使用一个线性投影(如一个无偏置的全连接层)来匹配维度。层归一化通常应用于线性变换的输出之后、非线性激活之前。

伪代码(修改后):

定义网络:
    线性层1: input_dim -> hidden_dim
    层归一化1: 对 hidden_dim 维度
    线性层2: hidden_dim -> hidden_dim
    # 说明:为接残差,这一层输出维度需与(投影后的)输入一致
    层归一化2: 对 hidden_dim 维度
    线性层3: hidden_dim -> output_dim

前向传播(x):
    identity = x
    # 第一个残差块
    out = 线性层1(x)
    out = 层归一化1(out)
    out = ReLU(out)
    
    out = 线性层2(out)
    out = 层归一化2(out)
    
    # 处理快捷连接
    if identity.shape != out.shape:
        # 需要投影匹配维度,可用无偏置线性层
        # 例如 nn.Linear(input_dim, hidden_dim, bias=False)
        identity = 线性投影层(identity)
    out = out + identity  # 残差连接
    out = ReLU(out)
    
    # 输出层
    out = 线性层3(out)
    return out

关于投影层:在这个结构里 identity 就是网络输入(10 维),而残差块的输出是 hidden_dim 维——所以 hidden_dim=20 时本来就需要一层 nn.Linear(10, 20, bias=False) 把 identity 投影到 20 维,不是「维度变了才要加」。把 hidden_dim 改成 30 后,整体结构不变,只需把投影层相应换成 nn.Linear(10, 30, bias=False),残差相加依然成立。

常见错误

错误理解或写法现象或报错原因正确做法
在测试阶段忘记将 BatchNorm 切换到 eval() 模式。模型性能不稳定,每次推理结果有微小差异。train() 模式下的 BatchNorm 使用当前批次的统计量,而不是训练好的移动平均。在模型推理前调用 model.eval();或在 torch.no_grad() 上下文中确保 BN 处于评估模式。
在使用 LayerNorm 时,normalized_shape 参数设置错误。运行时形状错误,如 RuntimeError: Expected normalized_shape… to be of size…。normalized_shape 需要与输入张量最后若干维的大小完全匹配。仔细核对输入张量希望被归一化的那些维度的大小。例如,对于形状 (B, T, D) 的序列输入,若想对每个时间步的特征独立归一化,应使用 LayerNorm(D)。
认为残差连接可以完全解决梯度消失。在极深的网络中(如1000层),训练仍然可能失败。残差连接改善了梯度流,但并非万能。仍需要合理的初始化、归一化以及合适的优化器配合。将残差连接、归一化、合适的初始化(如He初始化)结合使用。
在维度不匹配时,残差连接直接相加。RuntimeError: The size of tensor a must match the size of tensor b。快捷连接的输出与残差分支的输出形状(通道数、高、宽)不一致。使用1x1卷积(对于CNN)或线性投影(对于全连接)来调整快捷连接的维度,或者使用池化调整空间尺寸。

本章小结

  1. 残差学习通过让网络学习输入与目标输出之间的“残差”,并通过恒等快捷连接实现,有效缓解了深度网络的退化问题和梯度消失难题。
  2. 批归一化通过对每个特征通道在批次维度上进行归一化,减少了内部协变量偏移,大幅加速训练,并起到正则化作用,但依赖于足够大的批次大小。
  3. 层归一化对单个样本的所有指定特征进行归一化,其行为在训练和测试时一致,不依赖批次大小,特别适用于RNN和小批量训练场景。
  4. 残差连接和归一化技术是构建现代深度神经网络(如ResNet, Transformer)不可或缺的组件,它们共同提升了模型的优化稳定性和可扩展性。

📋 行动清单

  • 在代码中实现一个包含 BatchNorm 的残差块和一个包含 LayerNorm 的残差块,比较它们在相同输入下的输出差异。
  • 将上一节实现的简单CNN中的几个卷积层替换为残差块,观察训练曲线是否更稳定。
  • 尝试将 BatchNorm2d 的 momentum 参数从默认的0.1改为0.9,观察训练时 running_mean 和 running_var 的更新速度变化。

延伸阅读

  1. Deep Residual Learning for Image Recognition(He et al., 2015, arXiv:1512.03385)—— 残差连接原始论文 - https://arxiv.org/abs/1512.03385
  2. Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift(Ioffe & Szegedy, 2015, arXiv:1502.03167)—— BatchNorm 原始论文 - https://arxiv.org/abs/1502.03167
  3. Layer Normalization(Ba, Kiros & Hinton, 2016, arXiv:1607.06450)—— LayerNorm 原始论文 - https://arxiv.org/abs/1607.06450
  4. How Does Batch Normalization Help Optimization?(Santurkar et al., 2018, arXiv:1805.11604)—— 对「消除内部协变量偏移」这一解释的实验再检验 - https://arxiv.org/abs/1805.11604

—— 小象教研组