4、RoPE核心思想:旋转的魔力,将位置信息融入Query和Key

好,咱们进入正题。

前面几章我们把Transformer的底裤扒了个干净,也聊了绝对位置编码和相对位置编码各自的痛点。说实话,我当年在调一个长文本模型时,被绝对位置编码的「外推能力差」折磨得不轻——训练时好好的,一换长文本就崩。后来接触到RoPE,才感觉「哦,原来位置编码还能这么玩」。

4.1 为什么非得是「旋转」?

你想想看,位置信息本质上是什么?

是顺序,是相对关系。比如「我打你」和「你打我」,词一样,顺序不同,意思天差地别。Transformer需要知道「谁在谁前面」,才能正确建模。

传统做法是给每个位置加一个向量(绝对位置编码),或者让注意力机制自己去算相对距离(相对位置编码)。但RoPE的思路完全不同——它把位置信息直接「旋」进向量里。

核心洞察: 在二维平面上,旋转一个向量不会改变它的模长,只会改变它的方向。而方向,恰好可以用来编码位置。

说白了,RoPE就是给每个token的Query和Key向量,乘上一个跟位置相关的旋转矩阵。这个旋转矩阵只改变向量的方向,不改变大小。这样一来,两个token之间的注意力分数,就天然包含了它们的相对位置信息。

我记得第一次看到这个想法时,拍了一下大腿——太优雅了。数学上干净,实现上简单,效果上还吊打之前的方法。

4.2 旋转矩阵长什么样?

咱们先从二维情况说起。

假设有一个二维向量 [x, y],你想让它逆时针旋转 θ 角度。旋转矩阵长这样:

R(θ) = [[cos θ, -sin θ],
        [sin θ,  cos θ]]

旋转后的向量就是 R(θ) · [x, y]^T

那对于位置 pos 的token,我们给它分配一个角度 θ = pos * ω,其中 ω 是一个预设的角速度。这样,不同位置的token就旋转了不同的角度。

但问题是,Transformer的隐藏层维度 d 通常是几百甚至上千,不是2。怎么办?

答案很简单:把高维向量拆成 d/2 个二维子空间,每个子空间独立旋转。

我的习惯: 在代码实现时,我会把维度 d 分成 d/2 对,每对用不同的角速度 ω_i。这样既能保证每个子空间都有位置信息,又不会让旋转矩阵太复杂。

完整的旋转矩阵是一个块对角矩阵:

R(θ_1)    0       0    ...
  0    R(θ_2)     0    ...
  0       0    R(θ_3)  ...
  ...    ...    ...    ...

其中每个 R(θ_i) 就是上面那个2x2的旋转矩阵。

4.3 怎么把旋转应用到Query和Key上?

这一步其实很直接。

假设我们有一个token在位置 pos,它的Query向量是 q,Key向量是 k。RoPE的做法是:

  1. 根据位置 pos 和维度索引 i,计算旋转角度 θ_i = pos * ω_i
  2. 构造旋转矩阵 R(θ)
  3. qk 分别乘上这个旋转矩阵:q' = R(θ) · qk' = R(θ) · k
  4. 用旋转后的 q'k' 计算注意力分数

这里有个关键性质:

旋转后的内积只依赖于相对位置:

q'_m · k'_n = (R(m·ω) · q) · (R(n·ω) · k) = q · R((m-n)·ω) · k

也就是说,位置 mn 的注意力分数,只跟它们的差值 m-n 有关。

这就是RoPE的魔力所在——它把绝对位置编码进了向量,但注意力计算时自动变成了相对位置编码。鱼和熊掌兼得。

4.4 一个具体的计算例子

光说理论有点虚,咱们来手算一下。

假设 d=4,位置 pos=0pos=1 的两个token,Query和Key都是单位向量:

q_0 = [1, 0, 1, 0]
k_0 = [0, 1, 0, 1]
q_1 = [1, 0, 1, 0]
k_1 = [0, 1, 0, 1]

角速度设为 ω_0 = 0.1ω_1 = 0.2

先算位置0的旋转:

θ_0_0 = 0 * 0.1 = 0
θ_0_1 = 0 * 0.2 = 0
R(0) = [[1, 0], [0, 1]]  # 单位矩阵,不旋转

位置1的旋转:

θ_1_0 = 1 * 0.1 = 0.1
θ_1_1 = 1 * 0.2 = 0.2
R(0.1) = [[cos0.1, -sin0.1], [sin0.1, cos0.1]]
R(0.2) = [[cos0.2, -sin0.2], [sin0.2, cos0.2]]

旋转后的向量:

q'_0 = [1, 0, 1, 0]  # 没变
k'_0 = [0, 1, 0, 1]  # 没变
q'_1 = [cos0.1, sin0.1, cos0.2, sin0.2]
k'_1 = [-sin0.1, cos0.1, -sin0.2, cos0.2]

现在算注意力分数:

q'_0 · k'_0 = 0 + 0 + 0 + 0 = 0
q'_0 · k'_1 = -sin0.1 + 0 + -sin0.2 + 0 = -sin0.1 - sin0.2
q'_1 · k'_0 = 0 + sin0.1 + 0 + sin0.2 = sin0.1 + sin0.2
q'_1 · k'_1 = -sin0.1*cos0.1 + sin0.1*cos0.1 + ... = 0

看到了吗?q'_0 · k'_1q'_1 · k'_0 互为相反数,它们的绝对值相等,符号相反。这就是相对位置信息在起作用。

我曾经踩过的坑: 一开始我直接用 torch.matmul 构造完整的块对角旋转矩阵,结果显存爆炸了。后来才发现,RoPE有高效的实现方式——直接用 torch.einsum 或者逐元素乘法加正余弦变换,根本不需要显式构造矩阵。

4.5 为什么旋转比加法好?

咱们对比一下RoPE和传统绝对位置编码:

特性 绝对位置编码(加法) RoPE(旋转)
位置信息注入方式 向量加法 矩阵乘法(旋转)
相对位置关系 不直接包含 天然包含
外推能力 差(训练长度受限) 好(可泛化到更长序列)
向量模长 改变 不变
实现复杂度 中等(但可优化)

说白了,加法是把位置信息「硬塞」进向量,而旋转是把位置信息「编织」进向量的方向里。前者会改变向量的语义(模长变了),后者只改变向量的「视角」。

你想想看,在注意力机制里,我们关心的是两个向量的相似度(内积)。如果两个向量模长都变了,那相似度就失真了。RoPE保持模长不变,只旋转方向,这样内积就只反映「方向上的相似性 + 位置上的相对关系」。

4.6 代码实现的核心逻辑

这里给出RoPE的核心实现思路,不贴完整代码,只讲关键步骤:

# 1. 预计算频率
freqs = 1.0 / (10000 ** (torch.arange(0, d, 2) / d))

# 2. 计算每个位置的角度
t = torch.arange(max_seq_len)
angles = t[:, None] * freqs[None, :]  # [seq_len, d/2]

# 3. 构造正余弦
cos = torch.cos(angles)  # [seq_len, d/2]
sin = torch.sin(angles)  # [seq_len, d/2]

# 4. 应用旋转(核心操作)
def apply_rotary_emb(x, cos, sin):
    # x: [batch, seq_len, num_heads, d]
    # 把x分成两半,分别旋转
    x1 = x[..., :d//2]
    x2 = x[..., d//2:]
    # 旋转公式:x' = x * cos + rotate_half(x) * sin
    # 其中 rotate_half 是把 x 的后半部分取负并交换位置
    return torch.cat([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)

注意看最后那个旋转公式,它等价于块对角矩阵乘法,但计算效率高得多。这就是我前面说的「高效实现方式」。

我的建议: 在实际项目中,把 cossin 预计算好存起来,不要每次前向都重新算。另外,注意 d 必须是偶数,否则没法两两分组。

4.7 本章小结

RoPE的核心思想,说白了就一句话:用旋转矩阵把位置信息编码进Query和Key的方向里,让注意力自动感知相对位置。

它解决了绝对位置编码外推能力差的问题,又比相对位置编码实现更简洁。现在主流的LLM(比如LLaMA、Mistral、Qwen)都在用RoPE,不是没有道理的。

嗯,这一章咱们把「旋转的魔力」讲清楚了。下一章我会带你手撕RoPE的完整代码,包括怎么处理batch、怎么支持不同长度的序列、以及一些工程上的优化技巧。


公众号:蓝海资料掘金营,微信deep3321