AI 解读

Positional Encoding 彻底搞懂:正弦/余弦编码的数学原理与 TypeScript 实现

2026年7月23日◷ 8 分钟阅读
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 留言讨论

还没有评论,来抢个沙发,聊聊你的看法~

Michael.Meng

michaelnews@126.com
用 AI 记录,用文字沉淀

© 2026 Michael Meng · 保留所有权利 · Powered by FastAPI + Nuxt