第4章:前向传播:LayerNorm的完整计算图

好,咱们进入正题。前向传播这部分,说白了就是搞清楚数据在LayerNorm里到底是怎么流动的。我见过不少同学,背公式背得滚瓜烂熟,一让画计算图就懵了。嗯,今天咱们就把这张图彻底画明白。

4.1 从输入到输出的完整路径

先看一个典型的LayerNorm计算流程。假设输入是一个向量 x = [x₁, x₂, ..., xₙ],我们要做的是沿着特征维度做归一化。我个人习惯把整个过程拆成5步:

  1. 计算均值:μ = (1/n) * Σxᵢ
  2. 计算方差:σ² = (1/n) * Σ(xᵢ - μ)²
  3. 归一化:x̂ᵢ = (xᵢ - μ) / √(σ² + ε)
  4. 缩放:yᵢ = γᵢ * x̂ᵢ + βᵢ

你想想看,这其实就是一个「先标准化,再学一个线性变换」的过程。我在项目中遇到过有人把LayerNorm和BatchNorm搞混,觉得都是归一化,随便用就行。结果模型训练到一半,loss死活降不下去。后来一查,原来是归一化的维度选错了。

关键区别:LayerNorm是对每个样本的特征维度做归一化,BatchNorm是对整个batch的同一特征维度做归一化。方向完全不同。

4.2 计算图的节点与边

咱们把计算图画出来,每个节点就是一个操作,每条边就是数据的流向。我习惯用这种结构来理解:

输入 x (shape: [batch, seq_len, hidden_dim])
    │
    ▼
[计算均值 μ]  ← 沿着hidden_dim维度
    │
    ▼
[计算中心化]  x - μ
    │
    ├──→ [计算方差 σ²]  ← 也是沿着hidden_dim维度
    │         │
    │         ▼
    │    [加ε开根号]  √(σ² + ε)
    │         │
    │         ▼
    └──→ [归一化]  x̂ = (x - μ) / √(σ² + ε)
              │
              ▼
         [缩放+平移]  y = γ * x̂ + β
              │
              ▼
           输出 y

这张图里,最容易被忽略的是那个ε。我曾经在写自定义算子时忘了加ε,结果方差为0的时候直接除零,整个训练崩了。ε一般取1e-5或1e-6,就是个数值稳定的小技巧。

4.3 维度视角下的计算图

咱们用具体数字来感受一下。假设输入形状是 [2, 3, 4],也就是batch_size=2,seq_len=3,hidden_dim=4。LayerNorm通常对最后一个维度做归一化。

步骤 操作 输出形状 说明
1 计算均值 μ [2, 3, 1] 沿着hidden_dim求平均,保留了batch和seq维度
2 计算方差 σ² [2, 3, 1] 同样沿着hidden_dim,注意这里用了中心化后的值
3 归一化 x̂ [2, 3, 4] 形状恢复,每个元素都做了标准化
4 缩放 y [2, 3, 4] γ和β形状都是[4],通过广播机制作用到每个位置

这里有个细节要注意:μ和σ²的形状是[batch, seq_len, 1],而不是[2, 3]。为什么?因为我们要保留维度信息,方便后续的广播运算。如果你用numpy实现,记得用keepdims=True

我的小技巧:在写代码时,我习惯在每一步都打印一下中间变量的shape。这样能快速定位维度不匹配的问题。尤其是当你自己实现LayerNorm时,这一步能省下大量调试时间。

4.4 计算图中的依赖关系

为什么我们要把计算图画这么细?因为反向传播时,每个节点的梯度都依赖于前向传播中的中间结果。举个例子:

  • γ的梯度:依赖于x̂和输出y的梯度
  • β的梯度:直接等于输出y的梯度求和
  • x的梯度:最复杂,要经过归一化、方差、均值三条路径

说白了,前向传播中保存的中间变量,就是反向传播的「燃料」。我在做Transformer推理优化时,发现有些框架会刻意丢弃一些中间变量来省显存,但代价是反向传播时要重新计算。这就是典型的「时间换空间」策略。

避坑指南:我曾经在实现自定义LayerNorm时,为了省事只保存了x̂和σ²,结果反向传播时发现μ的梯度算不出来。后来老老实实把x - μ也保存了。记住:反向传播需要什么,前向就保存什么

4.5 代码实现与计算图对应

咱们用PyTorch风格写一段伪代码,把计算图对应起来:

def layer_norm_forward(x, gamma, beta, eps=1e-5):
    # 步骤1: 计算均值
    mu = x.mean(dim=-1, keepdim=True)  # shape: [batch, seq, 1]
    
    # 步骤2: 中心化
    x_centered = x - mu  # shape: [batch, seq, hidden]
    
    # 步骤3: 计算方差
    var = (x_centered ** 2).mean(dim=-1, keepdim=True)  # shape: [batch, seq, 1]
    
    # 步骤4: 归一化
    x_norm = x_centered / torch.sqrt(var + eps)  # shape: [batch, seq, hidden]
    
    # 步骤5: 缩放和平移
    out = gamma * x_norm + beta  # gamma和beta通过广播
    
    # 保存中间变量供反向传播使用
    cache = (x_norm, x_centered, var, mu, eps)
    
    return out, cache

你看,每一步都对应计算图中的一个节点。我个人习惯把cache打包成一个元组,这样反向传播时直接解包就行,代码看起来清爽很多。

4.6 为什么计算图这么重要?

我经常跟团队里的新人说:前向传播的计算图,就是反向传播的导航地图。你如果连前向的路径都画不清楚,反向传播的梯度流就一定会迷路。

举个例子,在LayerNorm中,x̂的梯度要经过两条路径:一条来自γ的梯度,一条来自β的梯度(虽然β的梯度不直接依赖x̂)。而x的梯度更复杂,要经过归一化、方差、均值三条路径的链式法则。这些路径,全都在计算图里标得清清楚楚。

嗯,这一章的内容就到这儿。下一章咱们就拿着这张计算图,开始推导反向传播的梯度公式。到时候你会发现,只要前向的图画对了,反向的推导就是顺着路径走一遍的事。