第 11 课:位置编码与归一化 —— 让序列带上「位置感」

2026-09-12 干徒
RustLLM注意力

第 11 课:位置编码与归一化 —— 让序列带上"位置感"

代码位置:src/model.rsTransformerBlock 里的残差连接) 代码位置:src/layers.rsLayerNorm) 演示入口:src/main.rs

⚠️ 历史说明:本课实现的正弦位置编码(sinusoidal_positions / pos_emb)已在第 19 课被 RoPE 取代—— 当前 src/model.rs 里不再有 pos_emb 字段,位置信息由注意力内部旋转 Q/K 提供。 本节仍是必读的:LayerNorm 与残差连接至今原样在模型里,且"位置编码要解决什么问题"是理解 RoPE 的前提。

1. 本课要搞懂的问题

  1. 注意力机制天生"看不见位置",为什么?怎么补救?
  2. 正弦位置编码的公式 PE(pos, 2i) = sin(pos / 10000^(2i/d)) 到底在算什么?
  3. LayerNorm 是怎么把数据"拉回"标准分布的?它和 BatchNorm 有什么区别?
  4. 残差连接为什么能让我们放心地把网络加深?

2. 为什么需要位置编码

先回忆第 9-10 课的注意力公式:

scores = (Q · Kᵀ) / √d_k      # Q、K 由每个 token 的内容算出来
attn   = softmax(scores)
out    = attn · V

注意:Q、K、V 全部只由 token 的内容(embedding)计算而来。点积 q·k 衡量的是"两个 token 的内容有多相关", 跟"它们相隔多远、谁在前谁在后"没有任何关系。

这带来一个致命问题:注意力是"排列不变"(permutation invariant)的。看一个例子:

输入序列 模型看到的相关性
"猫 追 狗" "猫"↔"狗"
"狗 追 猫" "猫"↔"狗"

两个句子语义完全相反,但注意力打分一模一样(因为每个词的内容没变)。如果模型只看注意力, 它永远分不清"猫追狗"和"狗追猫"。

通俗理解:注意力是个"走神的学生",它只知道"这两个词好像有关",但不知道"谁在左、谁在右"。 语言是顺序敏感的,所以必须把位置信息塞进去。

解决方案:给每个位置的 token embedding 加上一个位置向量

x = token_embedding + position_embedding

位置向量就是"第 0 个位置长什么样、第 1 个位置长什么样……"的一组向量。加上之后,同一个词在不同位置就有了不同的表示。

3. 正弦位置编码

3.1 公式

原版 Transformer 论文(Attention Is All You Need)提出用不同频率的正弦波来编码位置:

PE(pos, 2i)   = sin(pos / 10000^(2i/d))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d))
  • pos:位置(0, 1, 2, ...)
  • i:embedding 里的维度下标(0, 1, 2, ..., d-1)
  • d:embedding 维度(我们的模型里就是 n_embd

位置 pos 的向量长这样:

pos_emb[pos] = [ sin(pos/1), cos(pos/1), sin(pos/10000^(2/d)), cos(pos/10000^(2/d)), ... ]
                  └── i=0 ──┘  └── i=1 ──┘      └── i=2 ──┘       └── i=3 ──┘

关键点:维度下标 i 越小,频率越高(波形越密);i 越大,频率越低(波形越平缓)。 也就是说,编码向量的"前半段"负责记录精细的相对位置,"后半段"负责记录大致的绝对位置。

3.2 手算一个例子

d = 4,即每个位置只有 4 个维度(i = 0, 1, 2, 3):

pos 2i=0:sin(pos/1) 2i=1:cos(pos/1) 2i=2:sin(pos/100) 2i=3:cos(pos/100)
0 sin(0) = 0 cos(0) = 1 sin(0) = 0 cos(0) = 1
1 sin(1) ≈ 0.841 cos(1) ≈ 0.540 sin(0.01) ≈ 0.010 cos(0.01) ≈ 1.000
2 sin(2) ≈ 0.909 cos(2) ≈ -0.416 sin(0.02) ≈ 0.020 cos(0.02) ≈ 1.000
3 sin(3) ≈ 0.141 cos(3) ≈ -0.990 sin(0.03) ≈ 0.030 cos(0.03) ≈ 1.000

可以看到:i = 0/1 的波形变化剧烈(区分相邻位置),i = 2/3 的波形几乎不动(区分远距离)。

3.3 为什么选正弦波

性质 说明
每个位置的向量都不同 pos 不同,三角函数取值就不同,模型能区分位置
相对位置可"计算" 利用三角恒等式,PE(pos+k) 可以表示为 PE(pos) 的线性组合,模型容易学到"相对距离"的概念
值域固定 所有值都在 [-1, 1],加到 embedding 上不会把数值范围撑爆
不需要学习 位置编码是固定的常数,不用训练就能用;而且序列再长也能"算"出来(外推性)

3.4 对应代码

⚠️ 以下函数已从代码库中删除(被 RoPE 取代),仅供理解原理。

/// 正弦位置编码(第 11 课,已删除)
///
/// PE(pos, 2i)   = sin(pos / 10000^(2i/D))
/// PE(pos, 2i+1) = cos(pos / 10000^(2i/D))
fn sinusoidal_positions(max_len: usize, d: usize) -> Vec<f32> {
    let mut data = vec![0.0f32; max_len * d];
    for pos in 0..max_len {
        for i in 0..d {
            let freq = 10000f32.powf((2 * (i / 2)) as f32 / d as f32);
            let angle = pos as f32 / freq;
            data[pos * d + i] = if i % 2 == 0 { angle.sin() } else { angle.cos() };
        }
    }
    data
}

逐行对照公式:

代码 对应公式 说明
10000f32.powf((2 * (i / 2)) as f32 / d as f32) 10000^(2i/d) 注意这里的 2i偶数维下标。因为 i / 2 是整数除法,当 i=2i=3i/2 都等于 1,所以下标 2 和 3 共享同一个频率——这正是公式里 (2i, 2i+1) 一对维度用同一个 10000^(2i/d) 的做法
let angle = pos as f32 / freq; pos / 10000^(2i/d) 把位置除以频率,得到角度
if i % 2 == 0 { angle.sin() } else { angle.cos() } PE(pos, 2i) = sin(...)PE(pos, 2i+1) = cos(...) 偶数维用 sin,奇数维用 cos

GPT::new 里把它变成一个 [block_size, n_embd] 的张量存起来:

let pos_emb = Tensor::from_vec(
    sinusoidal_positions(cfg.block_size, cfg.n_embd),
    vec![cfg.block_size, cfg.n_embd],
);

GPT 结构体里它的类型是普通 Tensor 而不是 Tensor::param(...)

pos_emb: Tensor, // 正弦位置编码 [block_size, D](常数,不参与训练)

它不在 parameters() 里,反向传播时不会产生梯度——位置编码是常数,这正是正弦编码对比"可学习位置编码"的一大优点。

3.5 怎么用:加到 token embedding 上

GPT::forward 里,用 gather_rows 按位置取行,再和 token embedding 相加:

// 1. token embedding
let tok = self.tok_emb.forward(idx).reshape(vec![b, t, d]);
// 2. 位置编码:KV cache 推理时,当前位置从缓存长度开始
let pos_emb = self.pos_emb.gather_rows(&positions).reshape(vec![b, t, d]);
let x = tok.add(&pos_emb);

x 的每个元素 = 词的语义(token embedding)+ 词的位置(pos embedding),后续所有层都在这个"带位置信息"的表示上工作。

4. LayerNorm:层归一化

4.1 公式

LayerNorm 对最后一维(每个样本自己)做归一化,再缩放平移:

y = (x - μ) / √(σ² + ε) · γ + β
  • μ:x 在最后一维上的均值
  • σ²:x 在最后一维上的方差
  • ε:一个极小的数(防除零,我们的代码里用 1e-5
  • γ(gamma)、β(beta):可学习的参数,初始 γ=1、β=0

直觉:先把每个样本拉成"均值 0、方差 1"的标准分布(稳定数值范围),再让模型自己决定要不要缩放平移回去(恢复表达能力)。

4.2 对应代码逐行拆解

src/layers.rs 里的实现:

pub struct LayerNorm {
    pub gamma: Tensor, // [d] 可学习缩放
    pub beta: Tensor,  // [d] 可学习平移
    pub eps: f32,
}

impl LayerNorm {
    pub fn new(d: usize, eps: f32) -> Self {
        LayerNorm {
            gamma: Tensor::param(vec![1.0; d], vec![d]),
            beta: Tensor::param(vec![0.0; d], vec![d]),
            eps,
        }
    }

    pub fn forward(&self, x: &Tensor) -> Tensor {
        let d = x.shape()[x.rank() - 1];
        // 均值 μ:[..., 1](保持维度,方便广播)
        let mean = x.sum_last_dim().mul_scalar(1.0 / d as f32);
        // 中心化
        let centered = x.sub(&mean);
        // 方差 σ²:中心化后平方再取均值
        let var = centered
            .mul(&centered)
            .sum_last_dim()
            .mul_scalar(1.0 / d as f32);
        // 归一化
        let norm = centered.div(&var.add_scalar(self.eps).sqrt());
        // 缩放平移(γ、β 是 [d],广播到 [..., d])
        norm.mul(&self.gamma).add(&self.beta)
    }
}

公式 ↔ 代码对照表:

公式步骤 Rust 代码 说明
μ = mean(x) x.sum_last_dim().mul_scalar(1.0 / d as f32) 第 1 课学的 sum_last_dim 把最后一维求和,结果形状是 [..., 1](最后一位变成 1,维度没有消失),再除以 d 就是均值。保持 [..., 1] 是为了后面能和 [..., d] 直接广播相减
x - μ x.sub(&mean) 中心化:减去均值
σ² = mean((x-μ)²) centered.mul(&centered).sum_last_dim().mul_scalar(1.0 / d as f32) 先逐元素平方,再 sum_last_dim 求和、除以 d
√(σ² + ε) var.add_scalar(self.eps).sqrt() 加一个小数防除零,再开方
(x-μ) / √(σ²+ε) centered.div(&...) 归一化
· γ + β norm.mul(&self.gamma).add(&self.beta) 可学习缩放平移;gammabeta 形状是 [d],广播到 [..., d]

注意一个细节:均值、方差是用 sum_last_dim 求的,所以归一化是逐样本、逐序列位置独立进行的—— 对输入 [B, T, D] 来说,就是沿着最后一维 D 归一把每个 B×T 的"行"。

4.3 LayerNorm vs BatchNorm

对比项 LayerNorm(我们用这个) BatchNorm
对哪个维度归一化 最后一维(每个样本自己) 特征维(跨 batch 的所有样本)
依赖 batch 吗 不依赖,batch size=1 也能用 依赖,batch 太小会不准
训练/推理行为 完全一致,公式不变 推理时用训练期累积的统计量
需要"当前 batch"统计量吗 不需要 需要(还要额外维护 running mean/var)
适合场景 NLP / Transformer(序列长度、batch 多变) CV / CNN(固定尺寸图像)
对 Transformer 的意义 因果掩码下不同位置长度不同,逐位置归一化天然合适 长度不一很难处理

记忆口诀:LayerNorm 归一化"每一行",BatchNorm 归一化"每一列"。 GPT 选 LayerNorm 还有一个重要原因:它不引入"batch 内样本之间的相互依赖",行为可预测、实现简单(公式就 5 步)。

4.4 在模型里用在哪

TransformerBlock 里有两个(ln1 在注意力前、ln2 在 MLP 前),模型末尾还有一个 ln_f

// TransformerBlock::new
ln1: LayerNorm::new(cfg.n_embd, 1e-5),
ln2: LayerNorm::new(cfg.n_embd, 1e-5),
// GPT::new
ln_f: LayerNorm::new(n_embd, 1e-5),

三个归一化层都在层的最前面(pre-norm 结构,下一课细讲),eps 统一用 1e-5

5. 残差连接(Residual Connection)

5.1 公式与直觉

残差连接就是在子层的输出上加上输入本身

y = x + F(x)

其中 F(x) 是任意子层(注意力、MLP……)。直觉上,F(x) 只负责"学一个增量/修正",而 x 这条"高速公路"原封不动地把信息送到下一层。

5.2 为什么解决梯度消失

先回忆反向传播:梯度从 loss 一层层往回传,每过一层就要乘一次该层的导数。如果网络有 N 层,梯度大概要被乘 N 次:

∂loss/∂x₀ = ∂loss/∂xₙ · ∂xₙ/∂xₙ₋₁ · ... · ∂x₁/∂x₀

如果每层的导数都小于 1(比如常见激活函数的导数 ≤ 1),乘几十层后梯度指数级衰减,几乎变成 0——前面的层根本学不到东西,这就是"梯度消失"。

有了残差连接,情况完全不同。y = x + F(x) 对 x 求导:

∂y/∂x = 1 + ∂F/∂x
  • 多出来的那个 1 是恒等映射的导数,永远在
  • 反向传播时梯度传过残差块,至少保留一份"原样拷贝":
∂loss/∂x = ∂loss/∂y · (1 + ∂F/∂x) = ∂loss/∂y + ∂loss/∂y · ∂F/∂x

不管 F 的导数多小,梯度里始终有一个 ∂loss/∂y 直接传回上一层,不会衰减成 0。

5.3 为什么能加深网络

  • 梯度:恒等映射让深层的梯度能"直达"浅层,解决了训练困难。
  • 信息:每一层都能"无损地"跳过不需要的变换。即使某个子层学不到有用的东西,模型也可以把它当成恒等映射(F(x) ≈ 0),至少不劣化,所以我们可以放心堆很多层。

一句话:残差连接 = 梯度的高速公路 + 信息的保险丝。

5.4 对应代码

TransformerBlock::forward 里残差连接就两处 add

fn forward(&self, x: &Tensor, mask: &Tensor, kv_cache: Option<&mut KVCache>) -> Tensor {
    // 注意力子层 + 残差连接
    let h = self.attn.forward(&self.ln1.forward(x), mask, kv_cache);
    let x = x.add(&h);   // ← 残差连接 ①:x + Attention(LN(x))
    // 前馈子层 + 残差连接
    let h = self.ln2.forward(&x);
    let h = gelu(&self.mlp_linear1.forward(&h));
    let h = self.mlp_linear2.forward(&h);
    x.add(&h)            // ← 残差连接 ②:x + MLP(LN(x))
}
子层 公式 代码
注意力块 x + Attention(LayerNorm(x)) let x = x.add(&h);
前馈块 x + MLP(LayerNorm(x)) x.add(&h)

5.5 和 LayerNorm 的组合:pre-norm

注意到顺序是 先 LayerNorm、再子层、最后加残差LN → 子层 → +),这叫 pre-norm(归一化在子层前)。对比早期 Transformer 的 post-norm(子层 → + → LN):

pre-norm(GPT-2 风格,我们用这个) post-norm(原版 Transformer)
残差路径上的数据 直接是"干净"的 x,不做归一化 每层都过 LN,数值被反复缩放
梯度传播 恒等路径无干扰,更稳 更容易出现训练不稳
深层堆叠 更容易训练(GPT 系列深度更大也能训) 需要 warmup 等技巧

6. 三者如何协作

一个 Transformer Block 里,位置编码负责"把位置信息喂进来",LayerNorm 负责"稳住数值分布",残差连接负责"让梯度和信息畅通无阻":

x (带位置编码的 embedding)
 │
 ├─ 残差①: x + Attention( LN1(x) )        ← LN 归一化 → 注意力找相关性 → 加回原 x
 │
 └─ 残差②: x + MLP( LN2(x) )              ← LN 归一化 → MLP 加工信息 → 加回原 x
 │
 └─ 送到下一层

7. 动手练习

  1. 手算 LayerNorm:对向量 x = [1.0, 2.0, 3.0, 4.0],手算 μ、σ²、(σ²+ε)开方后的归一化结果(ε=1e-5),再用 src/layers.rs 里的 LayerNorm::new(4, 1e-5)(γ=1、β=0 时)跑一遍对比,应该完全一致。
  2. 改频率基数:把 sinusoidal_positions 里的 10000 改成 101000000,想想会怎样?(提示:10 让所有频率都变高、位置区分更"拥挤";1000000 让波形几乎不动。跑训练对比 loss 曲线。)
  3. 去掉残差:把 TransformerBlock::forward 里的 x.add(&h) 改成直接 h(两个子层都改),跑训练观察 loss 是否下降明显变慢——亲身感受梯度消失。
  4. 思考:位置编码为什么是"加"而不是"拼接"(concat)?(提示:加法让每个维度只"混入"一个位置信号,且不改变维度数;拼接会让维度翻倍、参数爆炸。)
  5. 思考:LayerNorm 里为什么用 sum_last_dim 求均值而不是 sum?如果 sum 把所有元素加一起会怎样?

8. 本课总结

  • 注意力是位置盲的,必须把位置信息加进输入 → 正弦位置编码 PE(pos,2i)=sin(pos/10000^(2i/d))

  • 正弦编码用不同频率的波形区分位置,是常数(不参与训练),实现就一个双重循环

  • LayerNorm 对最后一维做 y=(x-μ)/√(σ²+ε)·γ+β,用 sum_last_dim 求均值方差,γ/β 可学习

  • LayerNorm 与 BatchNorm 的核心区别:归一行 vs 归一系列,NLP 用前者

  • 残差连接 y = x + F(x) 让梯度里永远保留一份 ∂loss/∂y,解决梯度消失、支持深网络

  • pre-norm(LN → 子层 → 残差)是 GPT 系列的标准结构

  • 下一课:把这些积木全部拼起来,组成完整的 GPT 模型!

  • (本课的正弦位置编码在第 19 课被 RoPE 取代,见 docs/19-RoPE旋转位置编码.md;LayerNorm 与残差连接保留至今。)

Rust 大语言模型 学习指南共 22 章
1从零用 Rust 实现大语言模型 —— 学习计划2第 1 课:张量 Tensor —— 一切的基础3第 2 课:自动微分 Autograd —— 让模型学会「自我修正」4第 3 课:张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法5第 4 课:模块化重构 —— 项目结构分层与 Rc<RefCell> 架构6第 5 课:线性层与激活函数 —— 神经网络的「积木」7第 6 课:损失函数与优化器 —— 让模型知道「错在哪、怎么改」8第 7 课:第一个 MLP —— 教会神经网络算 XOR9第 8 课:BPE 分词器 —— 让模型「读懂」文字10第 9 课:注意力机制 —— 让 token 互相「看」11第 10 课:多头注意力 —— 让模型「多角度」看世界12第 11 课:位置编码与归一化 —— 让序列带上「位置感」本篇13第 12 课:完整 GPT 模型 —— 把积木拼成能预测下一个词的模型14第 13 课:训练循环 —— 让模型真正开始学习15第 14 课:数据加载 —— 文本如何变成训练样本16第 15 课:推理与采样 —— 让模型「创造性」地生成17第 16 课:训练小 GPT —— 看 loss 从 1.46 一路降到 0.1618第 17 课:AdamW 优化器 —— 给梯度下降装上「惯性」和「自适应步长」19第 18 课:KV Cache —— 让逐 token 生成不再重复计算20第 19 课:RoPE 旋转位置编码 —— 把「相对位置」揉进注意力21第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结22第 21 课:GPU 加速训练与推理