Joye Personal Blog

Back

本文是 MiniMind 学习系列的第2篇,深入解析 RoPE(Rotary Position Embedding)—— 现代大语言模型位置编码的标配方案。我们将从数学原理到工程实现,特别是很少被讲到的浮点数精度问题,带你彻底理解这个优雅的设计。

关于本系列#

MiniMind 是一个简洁但完整的大语言模型训练项目,包含从数据处理、模型训练到推理部署的完整流程。我在学习这个项目的过程中,将核心技术点整理成了 minimind-notes 仓库,并产出了这个4篇系列博客,系统性地讲解 Transformer 的核心组件。

本系列包括:

  1. 归一化机制 - 为什么需要RMSNorm
  2. RoPE位置编码(本篇)- 如何让模型理解词序
  3. Attention机制 - Transformer的核心引擎
  4. FeedForward与完整架构 - 组件如何协同工作

一、引言#

1.1 从一个 Bug 说起#

假设你实现了一个简单的 Attention 机制:

def simple_attention(query, key, value):
    scores = query @ key.T  # 计算相似度
    weights = softmax(scores)
    output = weights @ value
    return output

# 测试
sentence1 = tokenize("我喜欢你")
sentence2 = tokenize("你喜欢我")

# 计算 Attention
output1 = simple_attention(Q1, K1, V1)
output2 = simple_attention(Q2, K2, V2)

# 惊讶地发现:
assert torch.allclose(output1, output2)  # True!?
python

问题:两个意思完全相反的句子,Attention 的输出居然一样?

这就是 Attention 的排列不变性问题

1.2 本文要解决的问题#

  • Attention 的”排列不变性”是什么?为什么是个问题?
  • 为什么需要位置编码?
  • RoPE 如何用旋转编码位置?
  • 为什么需要32个频率?(核心难点,涉及浮点数精度)
  • RoPE 如何同时包含绝对和相对位置信息?

1.3 适合读者#

  • 对 Transformer 有基本了解
  • 想深入理解位置编码机制
  • 好奇”工程细节”的研究者
  • 准备实现自己的 Transformer

二、问题:Attention 的排列不变性#

2.1 什么是排列不变性?#

定义:对于集合操作,元素顺序不影响结果。

数学表达

f({a, b, c}) = f({c, a, b}) = f({b, c, a})
plaintext

经典例子

  • 求和:sum([1, 2, 3]) = sum([3, 1, 2]) = 6
  • 求平均:mean([1, 2, 3]) = mean([2, 3, 1]) = 2

2.2 Attention 为什么是排列不变的?#

让我们看 Attention 的核心计算:

scores = Q @ K.T  # [seq_len, seq_len]
weights = softmax(scores, dim=-1)
output = weights @ V
python

关键观察:矩阵乘法 Q @ K.T 的结果只依赖于Q和K的行向量,不依赖行的顺序。

简化示例

假设两个句子:

  • 句子1: “我 喜欢 你” → Q1, K1
  • 句子2: “你 喜欢 我” → Q2, K2(只是重排)

它们的 Attention 分数矩阵:

句子1: [[1.25, 1.00, 0.95],
        [1.00, 1.25, 0.70],
        [0.95, 0.70, 0.73]]

句子2: [[0.73, 0.70, 0.95],
        [0.70, 1.25, 1.00],
        [0.95, 1.00, 1.25]]
plaintext

观察:两个矩阵包含完全相同的数值,只是位置不同(行列重排)。Softmax 之后,每一行的权重分布也只是重排。模型无法区分哪个词在哪个位置!

2.3 为什么这是个问题?#

在自然语言中,位置信息至关重要

"猫追老鼠" vs "老鼠追猫"  ← 意思完全相反
"我没说她偷了钱" vs "我说她没偷钱"  ← 语义完全不同
"吃饭了吗" vs "饭吃了吗"  ← 语气不同
plaintext

结论:Attention 需要某种机制来感知位置信息!


三、位置编码的三代演进#

3.1 第一代:绝对位置编码(BERT, 2018)#

核心思想:给每个位置分配一个固定的向量。

class AbsolutePositionEmbedding(nn.Module):
    def __init__(self, max_len, hidden_size):
        super().__init__()
        # 可学习的位置嵌入
        self.position_embedding = nn.Embedding(max_len, hidden_size)

    def forward(self, x):
        batch_size, seq_len, hidden_size = x.shape

        # 位置 ID: [0, 1, 2, ..., seq_len-1]
        position_ids = torch.arange(seq_len, device=x.device)
        position_ids = position_ids.unsqueeze(0).expand(batch_size, -1)

        # 获取位置向量
        pos_embed = self.position_embedding(position_ids)

        # 直接相加
        return x + pos_embed
python

优点

  • ✅ 简单直接
  • ✅ 可学习(根据数据调整)

缺点

  • ❌ 无法外推到未见过的长度(训练512,测试1024就崩了)
  • ❌ 没有显式的相对位置信息
  • ❌ 需要存储大量参数(max_len × hidden_size)

3.2 第二代:相对位置编码(T5, 2019)#

核心思想:编码两个词之间的相对距离。

# 计算相对位置
relative_distance = pos_j - pos_i  # -seq_len 到 +seq_len

# 查找相对位置的 bias
bias = relative_position_bias[relative_distance]

# 加到 Attention 分数上
scores = (Q @ K.T) + bias
python

优点

  • ✅ 有相对位置信息
  • ✅ 一定程度可以外推

缺点

  • ❌ 需要额外的 bias 矩阵(O(seq_len²) 空间)
  • ❌ 计算复杂
  • ❌ 实现繁琐

3.3 第三代:RoPE(Llama/MiniMind, 2021)⭐️#

核心思想:通过旋转向量来编码位置。

# 对 Query 和 Key 施加旋转
Q_rot = rotate(Q, position × θ)
K_rot = rotate(K, position × θ)

# 计算 Attention(自动包含相对位置!)
scores = Q_rot @ K_rot.T
python

优点

  • ✅ 自然包含相对位置信息(数学性质)
  • ✅ 可以外推到更长序列(配合 YaRN)
  • ✅ 计算高效(O(1) 额外空间)
  • ✅ 实现简洁优雅
  • 现代 LLM 的标配(GPT-3, Llama, Mistral, MiniMind)

对比表格

特性绝对位置相对位置RoPE
相对信息
可外推
计算效率
空间复杂度O(L×D)O(L²)O(1)
实现难度简单复杂中等
使用模型BERTT5GPT-3+, Llama

四、RoPE 核心原理:旋转编码#

4.1 基本思想#

“用旋转角度编码位置”

直觉

位置 0 → 旋转 0°
位置 1 → 旋转 θ°
位置 2 → 旋转 2θ°
位置 3 → 旋转 3θ°
...
位置 m → 旋转 m×θ°
plaintext

就像钟表的指针,不同时刻指向不同角度!

4.2 数学推导(简化版)#

2维向量的旋转

旋转矩阵 R(θ) = [cos(θ)  -sin(θ)]
                 [sin(θ)   cos(θ)]

向量 v 旋转 θ 度后:
v_rot = R(θ) @ v
plaintext

位置 m 的词向量旋转

q_m = R(m × θ) @ q  # Query 旋转 m×θ 度
k_n = R(n × θ) @ k  # Key 旋转 n×θ 度
python

计算 Attention 分数

score = q_m · k_n
      = (R(mθ) @ q) · (R(nθ) @ k)
      = q^T @ R(mθ)^T @ R(nθ) @ k   # 点积转置
      = q^T @ R(-mθ) @ R(nθ) @ k     # 旋转矩阵转置 = 反向旋转
      = q^T @ R((n-m)θ) @ k          # 旋转角度相加
      = q^T @ R(Δθ) @ k              # Δ = n-m(相对距离)
python

神奇的结论:Attention 分数只依赖相对距离 (n-m)!

4.3 RoPE 的双重优势#

优势1:有绝对位置信息#

每个位置都有唯一的旋转角度:

  • 位置5的 Query:旋转到 5θ
  • 位置8的 Query:旋转到 8θ
  • 模型可以知道”这个词在位置5”

优势2:有相对位置信息#

Attention 分数只依赖相对距离:

  • 位置5看位置8 = q @ rotate(k, 3θ)(距离3)
  • 位置0看位置3 = q @ rotate(k, 3θ)(距离3)
  • 两者分数相同,模型知道”这两个词相距3个位置”

**两全其美!**既有绝对位置,又有相对位置。


五、核心难点:为什么需要多频率?⭐⭐⭐#

5.1 问题引入#

看到这里,你可能会想:

“如果旋转360度会回到原点,那位置0和位置360不就无法区分了吗?”

这是个非常好的问题!

5.2 直觉方案:降低频率#

想法:如果每100万个 token 才转一圈,不就能覆盖所有位置了?

# 超低频率
θ = / 1_000_000  # 每100万 token 转一圈

# 理论上
position_0 → 0°
position_1 → 0.00000628°
position_1000000 → 360°(回到原点)

# 可以唯一标识100万个位置!
python

问题来了:为什么实际不这么做?

5.3 真实原因:浮点数精度限制 ⭐⭐⭐#

核心发现:理论上可行,工程上不行

使用超低频率(每100万token转一圈)时:

  • 位置0的 cos 值:1.0
  • 位置1的 cos 值:0.999999999980261
  • 差值:约 1.97e-11

问题所在

  • float32 的精度约 10^-7
  • 计算机无法区分相邻位置!

用 float32 计算后,位置0和位置1的 cos 值都是 1.0,完全无法区分。

5.4 数学分析#

使用泰勒展开可以证明:

  • 角度差:θ ≈ 6.28e-6 弧度
  • cos 差值:Δcos ≈ θ²/2 ≈ 2e-11
  • float32 精度:约 10^-7

结论:2e-11 << 10^-7,计算机无法区分相邻位置。

就像用米尺测量毫米级的差异,刻度太粗,测不出来。

5.5 多频率的解决方案#

策略:使用 32 个不同频率(MiniMind,head_dim=64),每2维一个频率。

频率范围

频率类型周期(token)作用
高频(0)6.3精确区分相邻位置(角度差57.3°)
中频(15)6,283平衡精度和范围
低频(31)6,283,185标识远距离位置

组合效果

  • 位置0编码:[1.0, 1.0, 1.0, ..., 1.0](32个值)
  • 位置1编码:[0.5403, 0.9997, 0.9999, ..., 1.0]
  • 高频分量差异明显(0.5403 vs 1.0),可以区分相邻位置
  • 低频分量覆盖远距离,可以标识百万级位置

关键点:高频看细节,低频看全局,组合起来既精确又全面!

5.6 类比理解:钟表系统#

这就像时钟的时针、分针、秒针:

秒针(高频)

  • 1分钟转一圈,精确到秒
  • 但1小时后回到原点,无法区分

分针(中频)

  • 1小时转一圈,精确到分
  • 配合秒针可区分 3600 秒

时针(低频)

  • 12小时转一圈,覆盖范围大

三者组合 → 可以唯一标识任意时刻! RoPE 的多频率机制与此完全一致。


六、RoPE 的完整实现#

RoPE 的实现分为三个步骤:

6.1 预计算频率和 cos/sin 值#

核心思路:为每个位置和每个频率预先计算好旋转所需的 cos 和 sin 值。

def precompute_freqs_cis(dim, end, rope_base=1e6):
    # 1. 计算频率:freqs[i] = 1 / (rope_base ^ (2i / dim))
    freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2).float() / dim))

    # 2. 生成角度矩阵:positions × freqs
    t = torch.arange(end)
    freqs = torch.outer(t, freqs)  # [end, dim/2]

    # 3. 计算 cos 和 sin
    freqs_cos = torch.cos(freqs).repeat(1, 2)  # [end, dim]
    freqs_sin = torch.sin(freqs).repeat(1, 2)

    return freqs_cos, freqs_sin
python

6.2 应用旋转#

核心公式:q_rotated = q * cos + rotate_half(q) * sin

def apply_rotary_pos_emb(q, k, cos, sin):
    q_embed = (q * cos) + (rotate_half(q) * sin)
    k_embed = (k * cos) + (rotate_half(k) * sin)
    return q_embed, k_embed

def rotate_half(x):
    # 将向量分成两半并交换:[x1, x2] → [-x2, x1]
    x1, x2 = x.chunk(2, dim=-1)
    return torch.cat((-x2, x1), dim=-1)
python

这本质上是复数旋转的实数实现:(a + bi) × (cos + i·sin)

6.3 在 Attention 中的使用#

class Attention(nn.Module):
    def forward(self, x, position_embeddings):
        # 1. 生成 Q、K、V 并拆分多头
        q, k, v = self.q_proj(x), self.k_proj(x), self.v_proj(x)

        # 2. ⭐ 应用 RoPE(只对 Q、K)
        cos, sin = position_embeddings
        q, k = apply_rotary_pos_emb(q, k, cos, sin)

        # 3. 计算 Attention
        scores = q @ k.T / sqrt(head_dim)
        output = softmax(scores) @ v

        return output
python

完整代码:参考 MiniMind 源码 model/model_minimind.py:108-182


七、YaRN:长上下文外推#

7.1 问题#

模型训练时最大长度是 2048,但推理时想处理 8192 个 token 怎么办?

直接外推会遇到问题:

  • 高频率:周期短,见过很多圈,外推效果好 ✅
  • 低频率:周期长,只见过小部分角度,出现”未见过的角度”,效果下降 ❌

7.2 YaRN 解决方案#

核心思想:动态调整低频率,让”未见过的角度”变成”见过的角度”,高频率保持不变。

效果

  • Llama 2: 训练 4k → 外推到 32k
  • Code Llama: 训练 16k → 外推到 100k

这是一个高级话题,详细原理参见 YaRN 论文


八、总结#

8.1 核心要点回顾#

  • 排列不变性问题:Attention 无法区分词的顺序,需要位置编码
  • RoPE 优势:用旋转编码位置,自动包含相对位置信息
  • 多频率必要性:浮点数精度限制,单一频率无法区分相邻位置
  • 钟表类比:高频看细节,低频看全局,组合起来完美覆盖
  • 双重信息:既有绝对位置,又有相对位置
  • 只旋转 Q、K:位置用于相似度,不影响内容 V

8.2 记住一句话#

“RoPE 是数学理论与工程实践的完美平衡”

8.3 关键问题自测#

  1. 为什么 Attention 是排列不变的?
  2. RoPE 如何同时包含绝对和相对位置?
  3. 为什么不能只用一个超低频率?(核心)
  4. YaRN 如何实现长度外推?

8.4 关键代码位置(MiniMind)#

  • RoPE 预计算:model/model_minimind.py:108-128
  • RoPE 应用:model/model_minimind.py:131-137
  • Attention 中使用:model/model_minimind.py:182

九、动手实验#

完整的学习材料已开源,你可以自己运行验证:

# 克隆代码
git clone https://github.com/joyehuang/minimind-notes
cd minimind-notes/learning_materials

# 实验1:RoPE 基础原理
python rope_basics.py

# 实验2:多频率机制
python rope_multi_frequency.py

# 实验3:浮点数精度问题(核心)
python rope_why_multi_frequency.py
bash

十、参考资料#

论文

代码

系列其他文章


本文作者:joye 发布日期:2025-12-17 最后更新:2025-12-17 系列文章:MiniMind 学习笔记(2/4)

如果觉得有帮助,欢迎:

  • ⭐ Star 原项目 MiniMind
  • ⭐ Star 我的学习笔记 minimind-notes
  • 💬 留言讨论你的学习心得
  • 🔗 分享给其他学习 LLM 的朋友
RoPE位置编码:从排列不变性到多频率机制
https://astro-pure.js.org/blog/20251217---rope-position-encoding/post
Author Joye
Published at 2025年12月17日
Comment seems to stuck. Try to refresh?✨