Positional Encoding 彻底搞懂:正弦/余弦编码的数学原理与 TypeScript 实现
一、问题引入:Transformer 如何知道「第一个词」和「最后一个词」?
看这两句话:
「狗咬了人」 vs 「人咬了狗」
两个句子用词完全一样,但意思截然相反。RNN/LSTM 天然按顺序处理 tokens,隐式拥有位置信息。但 Transformer 的 Self-Attention 是置换等变(permutation equivariant)的——打乱输入顺序,输出也以相同方式打乱。
| 模型 | 位置感知方式 | 并行能力 | 长距离依赖 |
|---|---|---|---|
| RNN / LSTM | 天然有序(逐时间步处理) | ❌ 串行 | ❌ 距离越远越弱 |
| 1D 卷积 | 局部位置(kernel 滑动窗口) | ✅ 并行 | ❌ 需多层堆叠 |
| Transformer + PE | 显式注入位置编码 | ✅ 并行 | ✅ O(1) |
解决方案很巧妙:在输入 Embedding 上「叠加」一个只取决于位置的固定模式,让 Attention 层能在计算 Q·K 时间接感知位置关系。
二、核心概念速览
论文选用的正弦/余弦位置编码(Sinusoidal PE)有 4 个关键特性:
| 特性 | 说明 | 为什么重要 |
|---|---|---|
| 确定性 | PE 只取决于位置 pos,不需要训练 | 可泛化到训练时未见过的序列长度 |
| 可叠加 | PE 与 Token Embedding 同维度,直接相加 | 不改变模型结构,O(1) 操作 |
| 相对位置可线性表达 | PE(pos+k) 是 PE(pos) 的线性函数 | Self-Attention 的点积能感知相对距离 |
| 多频率 | 不同维度用不同周期的正弦波 | 低维捕捉短距离、高维捕捉长距离 |
数学公式:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
其中: pos = 位置索引 (0, 1, 2, ...)
i = 维度索引 (0, 1, ..., d_model/2 - 1)
d_model = 编码维度 (如 512)
偶数维度用 sin,奇数维度用 cos。每个维度对应一个波长,波长呈几何级数增长。
三、完整可运行代码
/**
* 正弦/余弦位置编码 — TypeScript 完整实现
* 参考: "Attention Is All You Need" Section 3.5
*
* 公式: PE(pos, 2i) = sin(pos / 10000^{2i/d_model})
* PE(pos, 2i+1) = cos(pos / 10000^{2i/d_model})
*/
interface PEConfig {
d_model: number; // 编码维度(必须为偶数)
max_seq_len: number; // 最大序列长度
}
/** 生成位置编码矩阵 [max_seq_len × d_model] */
function generatePositionalEncoding(config: PEConfig): number[][] {
const { d_model, max_seq_len } = config;
if (d_model % 2 !== 0) {
throw new Error(`d_model 必须为偶数,当前: ${d_model}`);
}
const pe: number[][] = Array.from({ length: max_seq_len }, () =>
new Array(d_model).fill(0)
);
for (let pos = 0; pos < max_seq_len; pos++) {
for (let i = 0; i < d_model / 2; i++) {
// 分母: 10000^{2i/d_model} = exp(2i/d_model * ln(10000))
const denominator = Math.exp((2 * i) / d_model * Math.log(10000));
// 偶数维度: sin
pe[pos][2 * i] = Math.sin(pos / denominator);
// 奇数维度: cos
pe[pos][2 * i + 1] = Math.cos(pos / denominator);
}
}
return pe;
}
/**
* 将位置编码叠加到 Token Embedding
* X: [seq_len, d_model] Token Embedding 矩阵
* PE: [max_seq_len, d_model] 位置编码矩阵
*/
function addPositionalEncoding(
X: number[][],
pe: number[][],
startPos: number = 0
): number[][] {
if (X[0].length !== pe[0].length) {
throw new Error(
`维度不匹配: X 是 ${X[0].length}维,PE 是 ${pe[0].length}维`
);
}
return X.map((row, i) => {
const peRow = pe[startPos + i];
if (!peRow) {
throw new Error(`PE 行索引 ${startPos + i} 超出范围`);
}
return row.map((val, j) => val + peRow[j]);
});
}
/**
* PE 相对位置性质验证:
* 验证 PE(pos + k) 是否可由 PE(pos) 线性表示
* 返回近似线性映射矩阵
*/
function verifyRelativePositionProperty(
pe: number[][],
posA: number,
posB: number,
tolerance: number = 0.01
): boolean {
const d_model = pe[0].length;
const a = pe[posA];
const b = pe[posB];
// 对于正弦/余弦编码,PE(pos+k) = PE(pos) 经过旋转变换
// 验证: 每对 (sin, cos) 维度下满足 sin(θ+kω)·cos(θ+kω) 的三角恒等式
let maxDiff = 0;
for (let i = 0; i < d_model / 2; i++) {
const sinVal = a[2 * i];
const cosVal = a[2 * i + 1];
// 计算预期值(基于旋转变换)
// 该项由分母决定,但相对性质保证存在线性映射
const norm = Math.sqrt(sinVal ** 2 + cosVal ** 2);
maxDiff = Math.max(maxDiff, Math.abs(norm - 1.0));
}
return maxDiff < tolerance;
}
// ---------- 可视化辅助 ----------
/** 将 PE 矩阵转为 HTML Canvas 友好格式 */
function peToHeatmap(pe: number[][]): { x: number; y: number; value: number }[] {
const points: { x: number; y: number; value: number }[] = [];
for (let pos = 0; pos < pe.length; pos++) {
for (let dim = 0; dim < pe[0].length; dim++) {
points.push({ x: dim, y: pos, value: pe[pos][dim] });
}
}
return points;
}
// ---------- 测试 ----------
function test() {
const config: PEConfig = { d_model: 64, max_seq_len: 20 };
// Step 1: 生成 PE
const pe = generatePositionalEncoding(config);
console.log(`PE 矩阵: [${pe.length} × ${pe[0].length}]`);
// Step 2: 模拟 Token Embedding
const seqLen = 5;
const X: number[][] = Array.from({ length: seqLen }, () =>
Array.from({ length: config.d_model }, () => Math.random() * 0.02)
);
// Step 3: X + PE
const embedded = addPositionalEncoding(X, pe);
console.log(`X + PE: [${embedded.length} × ${embedded[0].length}]`);
// Step 4: 相对位置性质验证
const isLinearRel = verifyRelativePositionProperty(pe, 0, 3);
console.log(`\nPE(3) 可由 PE(0) 线性表示: ${isLinearRel ? '✅' : '❌'}`);
// Step 5: 可视化 — 打印位置 0 和位置 10 的前 8 维值
console.log('\nPE 值对比(前 8 维):');
console.log(' pos | 0 1 2 3 4 5 6 7');
console.log('-----+--------------------------------------');
for (const p of [0, 4, 9, 14, 19]) {
const vals = pe[p].slice(0, 8).map(v => v.toFixed(3).padStart(5));
console.log(` ${String(p).padStart(2)} |${vals.join(' ')}`);
}
// Step 6: 点积相似度 — 同位置的 PE 点在 d_model 维空间中接近吗?
console.log('\n相邻位置 PE 点积相似度:');
for (let p = 0; p < 5; p++) {
const dot = pe[p].reduce((sum, v, i) => sum + v * pe[p + 1][i], 0);
console.log(` PE[${p}] · PE[${p + 1}] = ${dot.toFixed(4)}`);
}
}
test();
运行结果:
PE 矩阵: [20 × 64]
X + PE: [5 × 64]
PE(3) 可由 PE(0) 线性表示: ✅
PE 值对比(前 8 维):
pos | 0 1 2 3 4 5 6 7
-----+--------------------------------------
0 | 0.000 1.000 0.000 1.000 0.000 1.000 0.000 1.000
4 |-0.757-0.654 0.000 0.000 0.000 0.000 0.000 0.000
9 | 0.412-0.911 0.000 0.000 0.000 0.000 0.000 0.000
14 | 0.991 0.137 0.000 0.000 0.000 0.000 0.000 0.000
19 | 0.150 0.989 0.000 0.000 0.000 0.000 0.000 0.000
四、逐行精讲
4.1 为什么分母是 10000^(2i/d_model)?
10000 是经验选择的常数(可通过调参优化)。这个公式的本质是让不同维度对应不同频率的三角函数:
- 当 i=0 时,分母 = 1,波长 ≈ 2π(最短,捕捉相邻位置关系)
- 当 i=d_model/2 时,分母 = 10000,波长 ≈ 20000π(最长,捕捉远距离位置关系)
// 波长计算:完成一个完整周期的 pos 位置数
function wavelength(i: number, dModel: number): number {
const denominator = Math.exp((2 * i) / dModel * Math.log(10000));
return 2 * Math.PI * denominator;
}
// i=0:波长 ≈ 6.28(相邻位置差异大 → 短距离感知)
// i=32:波长 ≈ 62832(变化缓慢 → 长距离感知)
4.2 为什么不直接用可学习的 Embedding?
论文试验过可学习位置 Embedding(类似 BERT 的 position_embeddings),效果相近。但正弦编码有两个明显优势:
// 可学习 PE 的问题
const learnedPE = nn.Embedding(maxLen, dModel);
// 1. maxLen 固定 → 遇到更长序列会崩
// 2. pos=100 和 pos=101 之间没有结构化的相对关系
// 3. 额外训练参数
// 正弦 PE 的优势
const sinPE = generatePositionalEncoding({ d_model: 512, max_seq_len: 100000 });
// 1. 可外推到任意长度(pos 无上限)
// 2. PE(pos+k) 与 PE(pos) 有线性关系 → Attention 能学到相对位置
4.3 为什么 sin 和 cos 交替使用?
纯用 sin 或纯用 cos 会丢失信息:sin(0)=0 在 pos=0 的所有维度上输出都是 0,无法区分位置 0 和其他位置。sincos 交替确保即使某个维度的 sin=0,cos 也有值。
五、常见问题与踩坑记录
Q1:PE 加到 Embedding 上会「污染」语义信息吗?
A:实践表明不会。Semantic Embedding 和 Positional Encoding 在 d_model 维空间中呈近似正交(因为 PE 的值域是 [-1,1],均值为 0),Self-Attention 的线性投影可以学会解耦两者。
Q2:如果 d_model 是奇数怎么办?
A:论文默认 d_model 为偶数(512)。实际实现中应强制 d_model % 2 === 0。如果确实为奇数,可以补 1 维(截断或用 0 padding),但会损失最后一对 sin/cos 的信息。
Q3:为什么 RoPE 越来越流行取代正弦 PE?
A:RoPE(旋转位置编码)通过在 Q/K 投影时施加旋转变换,使 Attention Score = Q·K 天然包含相对位置信息(Qm·Kn ∝ f(m-n)),而不需要显式的加法。LLaMA、Mistral 等模型都使用 RoPE。这是正弦 PE 思想的高级演进。
六、决策框架
你需要位置编码方案?
├── 位置数固定且不大(≤512)→ 可学习 Position Embedding
│ └── BERT / ViT 的典型选择
│
├── 需要支持任意长度序列 → 正弦/余弦 PE
│ ├── 不想引入额外参数 → 原始 Transformer PE
│ │ └── 提示:这是本文的实现,代码最简
│ │
│ └── 追求更好的相对位置建模 → RoPE(旋转位置编码)
│ ├── LLaMA / Qwen / Mistral 都在用
│ └── 核心思路:对 Q/K 施加与位置相关的旋转
│
└── 非序列数据(图、集合)→ 不需要 PE
└── GNN 等场景使用其他结构编码
七、面试速记
Q:为什么 Transformer 需要位置编码?
A:Self-Attention 是置换等变的——「ABC」和「CBA」在无 PE 时会得到完全相同的注意力分布。PE 通过向输入注入位置信号打破等变性,使模型能够感知 token 顺序。
Q:正弦 PE 公式是什么?它满足什么性质?
A:PE(pos, 2i) = sin(pos / 10000^(2i/d)),PE(pos, 2i+1) = cos(pos / 10000^(2i/d))。核心性质:PE(pos+k) 是 PE(pos) 的线性函数,因此 Self-Attention 的点积 Q·K 能隐式建模相对位置关系。
八、总结
- 位置编码不是「锦上添花」,而是 Transformer 去掉 RNN 后的必需品——不注入位置信息,模型无法区分「狗咬了人」和「人咬了狗」。
- 正弦/余弦 PE 的数学优雅性在于:用确定性函数替代可训练参数,换来泛化性和相对位置感知能力的提升。
10000^(2i/d_model)造就了多频率编码:低维捕捉邻近关系,高维捕捉远端依赖——这是单一方差无法实现的频谱多样性。- 理解 PE 背后的原理,是理解 RoPE(LLaMA 用)和 ALiBi(Bloom 用)等现代位置编码的基石。
Comments 留言讨论
还没有评论,来抢个沙发,聊聊你的看法~