第 11 课:位置编码与归一化 —— 让序列带上「位置感」
第 11 课:位置编码与归一化 —— 让序列带上"位置感"
代码位置:src/model.rs(
TransformerBlock里的残差连接) 代码位置:src/layers.rs(LayerNorm) 演示入口:src/main.rs⚠️ 历史说明:本课实现的正弦位置编码(
sinusoidal_positions/pos_emb)已在第 19 课被 RoPE 取代—— 当前src/model.rs里不再有pos_emb字段,位置信息由注意力内部旋转 Q/K 提供。 本节仍是必读的:LayerNorm 与残差连接至今原样在模型里,且"位置编码要解决什么问题"是理解 RoPE 的前提。
1. 本课要搞懂的问题
- 注意力机制天生"看不见位置",为什么?怎么补救?
- 正弦位置编码的公式
PE(pos, 2i) = sin(pos / 10000^(2i/d))到底在算什么? - LayerNorm 是怎么把数据"拉回"标准分布的?它和 BatchNorm 有什么区别?
- 残差连接为什么能让我们放心地把网络加深?
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=2 和 i=3 时 i/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(¢ered)
.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(¢ered).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) |
可学习缩放平移;gamma、beta 形状是 [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. 动手练习
- 手算 LayerNorm:对向量
x = [1.0, 2.0, 3.0, 4.0],手算 μ、σ²、(σ²+ε)开方后的归一化结果(ε=1e-5),再用src/layers.rs里的LayerNorm::new(4, 1e-5)(γ=1、β=0 时)跑一遍对比,应该完全一致。 - 改频率基数:把
sinusoidal_positions里的10000改成10或1000000,想想会怎样?(提示:10让所有频率都变高、位置区分更"拥挤";1000000让波形几乎不动。跑训练对比 loss 曲线。) - 去掉残差:把
TransformerBlock::forward里的x.add(&h)改成直接h(两个子层都改),跑训练观察 loss 是否下降明显变慢——亲身感受梯度消失。 - 思考:位置编码为什么是"加"而不是"拼接"(concat)?(提示:加法让每个维度只"混入"一个位置信号,且不改变维度数;拼接会让维度翻倍、参数爆炸。)
- 思考: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 与残差连接保留至今。)