旋转位置编码
RoPE
上一期我们把 FFN 里的 GELU 换成了 SwiGLU。这一期换另一个零件:位置编码。
GPT-2 用的是"可学习的绝对位置向量",加在 Embedding 上。LLaMA、Qwen、Mistral、DeepSeek 这些主流开源模型,基本都换成了 RoPE(Rotary Position Embedding,旋转位置编码)。
这一期只回答三个问题:RoPE 到底做了什么?为什么一个"旋转"就能表达相对位置?代码里怎么写?
一、先回顾:GPT-2 的位置编码有哪里不舒服
GPT-2 的做法(原系列位置编码那一期讲过):
输入 = token_embedding + position_embedding
position_embedding 是一张可学习的表,形状 [1024, 768],第 m 行就是"第 m 个位置"的向量,直接加到 Embedding 上。
这个做法能用,但有三个不舒服的地方:
1. 位置信息只在输入层加一次。 之后经过 N 层 Block,位置信息要靠网络自己一路"记住",没有任何机制保证它不被冲淡。
2. 它是绝对位置,而注意力真正关心的是相对距离。 "鱼"这个 token 去看"吃",重要的是"它在我前面 1 个位置",而不是"我是第 2 个、它是第 1 个"。绝对位置的做法,模型得自己从两个绝对位置里把"差值"学出来。
3. 有硬上限。 表只有 1024 行,第 1025 个位置没有对应的向量,直接没法算。
RoPE 的思路是:位置信息不要加在输入上,而是直接作用在注意力的 Q 和 K 上,并且让 Q·K 的结果天然只依赖相对距离。
二、核心想法:给位置 m 的向量,转 m 份角度
先看最简单的二维情况。
二维旋转矩阵,把向量逆时针转 α 角:
R(α) = [ cos α -sin α ]
[ sin α cos α ]
对向量 (x0, x1) 做旋转:
(x0, x1) → (x0·cos α - x1·sin α, x0·sin α + x1·cos α)
RoPE 的规定很简单:
位置 m 上的 q 和 k,都旋转 m·θ 这么大的角度。
也就是:
q_m = R(mθ) · q
k_n = R(nθ) · k
位置越靠后,转得越多。位置 0 不转。
为什么这样就只剩相对距离了?
注意力分数是 q 和 k 的点积。把旋转代进去:
q_m · k_n = (R(mθ) q)ᵀ (R(nθ) k)
= qᵀ · R(mθ)ᵀ · R(nθ) · k
旋转矩阵有两个性质:
- 转置等于反向旋转:
R(α)ᵀ = R(-α) - 连续旋转,角度相加:
R(α)·R(β) = R(α+β)
所以:
R(mθ)ᵀ · R(nθ) = R(-mθ) · R(nθ) = R((n-m)θ)
代回去:
q_m · k_n = qᵀ · R((n-m)θ) · k
结果里只剩 (n−m),也就是相对距离。m 和 n 各自是多少,已经消失了。
这就是 RoPE 的全部数学核心:用旋转把"绝对位置"编码进去,点积时绝对位置自动抵消,只留下相对位置。
三、推广到 d 维:两两配对,每一对转速不同
真实的 head_dim 不是 2,是 64、128。做法是把向量的维度两两配对,每一对当作一个二维平面,各自旋转。
head_dim = d 时,一共有 d/2 对,第 i 对(i = 0, 1, ..., d/2 − 1)的旋转速度是:
θ_i = 10000^(-2i/d)
i 越小,θ 越大,转得越快;i 越大,θ 越小,转得越慢。
类比:钟表。
- 秒针转得快,能分辨很近的两个时刻
- 分针慢一些,分辨中等的间隔
- 时针最慢,用来分辨很远的间隔
RoPE 里,低维的配对像秒针,负责近距离的位置区分;高维的配对像时针,负责远距离。几对"指针"放在一起,一个位置就有了一个独特的"时间读数"。
10000 这个底数(base)是超参数,来自原始 Transformer 的正弦位置编码,RoPE 沿用了它。后面讲长上下文时它还会出场。
四、手算:猫吃鱼 × RoPE
沿用我们的例子:三个 token,位置分别是
猫 → 位置 0
吃 → 位置 1
鱼 → 位置 2
为了手算方便,这一期取 head_dim = 4(单头,只看一个头内部发生了什么)。
Step 1:算旋转速度
d = 4,所以有 2 对:(第 0, 1 维) 和 (第 2, 3 维)。
θ_0 = 10000^(-0/4) = 1
θ_1 = 10000^(-2/4) = 10000^(-0.5) = 0.01
第一对每走一个位置转 1 弧度(约 57°),第二对每走一个位置只转 0.01 弧度。一快一慢。
Step 2:旋转一个向量
为了把"位置的影响"单独隔离出来,我们故意让三个 token 的 q、k 原始向量都一样:
q = k = [1, 0, 1, 0]
(这是玩具设定,真实模型里不同 token 的 q、k 当然不同。)
把它按 (x0, x1)、(x2, x3) 分成两对,分别旋转 m·θ。
位置 0(猫): 角度 0,不转。
[1, 0, 1, 0]
位置 1(吃): 第一对转 1 弧度,第二对转 0.01 弧度。
第一对:(1, 0) → (cos 1, sin 1) = (0.5403, 0.8415)
第二对:(1, 0) → (cos 0.01, sin 0.01) = (1.0000, 0.0100)
结果:[0.5403, 0.8415, 1.0000, 0.0100]
位置 2(鱼): 第一对转 2 弧度,第二对转 0.02 弧度。
第一对:(1, 0) → (cos 2, sin 2) = (-0.4161, 0.9093)
第二对:(1, 0) → (cos 0.02, sin 0.02) = (0.9998, 0.0200)
结果:[-0.4161, 0.9093, 0.9998, 0.0200]
Step 3:算注意力分数(未缩放的 q·k)
没有 RoPE 时: 三个 token 的 q、k 完全一样,任意两个之间的分数都是
[1,0,1,0] · [1,0,1,0] = 2
模型从分数里看不出任何位置信息。
有 RoPE 时:
「鱼」(位置 2) 看「吃」(位置 1),距离 1:
[-0.4161, 0.9093, 0.9998, 0.0200] · [0.5403, 0.8415, 1.0000, 0.0100]
= -0.2248 + 0.7652 + 0.9998 + 0.0002
≈ 1.5403
「吃」(位置 1) 看「猫」(位置 0),距离也是 1:
[0.5403, 0.8415, 1.0000, 0.0100] · [1, 0, 1, 0]
= 0.5403 + 0 + 1.0000 + 0
≈ 1.5403
「鱼」(位置 2) 看「猫」(位置 0),距离 2:
[-0.4161, 0.9093, 0.9998, 0.0200] · [1, 0, 1, 0]
= -0.4161 + 0.9998
≈ 0.5837
Step 4:看结果
| 谁看谁 | 绝对位置 (m, n) | 相对距离 | 分数 |
|---|---|---|---|
| 吃 → 猫 | (1, 0) | 1 | 1.5403 |
| 鱼 → 吃 | (2, 1) | 1 | 1.5403 |
| 鱼 → 猫 | (2, 0) | 2 | 0.5837 |
两件事:
- (1,0) 和 (2,1) 的绝对位置不同,但分数完全一样。 因为它们的相对距离都是 1。这正是上一节推导出来的性质。
- 距离从 1 变成 2,分数从 1.54 掉到 0.58。 位置信息确实进入了注意力分数。
可以用推导验证一下:第一对的贡献是 cos(δ·1),第二对是 cos(δ·0.01),δ = 距离。δ=1 时 cos1 + cos0.01 = 0.5403 + 0.99995 ≈ 1.5403;δ=2 时 cos2 + cos0.02 = -0.4161 + 0.9998 ≈ 0.5837。完全吻合。
诚实声明: "距离越远分数越低"在这个例子里成立,是因为我们人为把三个 token 的内容设成了一样。真实模型里,分数同时取决于内容(q、k 学到的向量)和相对距离,不一定随距离单调下降。RoPE 只保证一件事:位置的影响以相对距离的形式进入分数,具体怎么用,由训练决定。
五、几个容易搞混的点
1. 只作用在 Q 和 K 上,不作用在 V 上。 位置信息的作用是影响"谁该关注谁"(分数),而 V 是"被取走的内容",不需要旋转。
2. 每一层都做,不是只在输入层。 每个 Block 的注意力里,Q、K 算出来之后都要重新旋转一次。这也回应了前面的第一个不舒服:位置信息不会被冲淡。
3. 没有可学习参数。
旋转角度由位置 m 和固定公式 θ_i 直接算出来,不需要训练。GPT-2 里那张 [1024, 768] 的 wpe 表,在 RoPE 的模型里整个删掉。
4. 旋转不改变向量的长度。 旋转只改方向,不改模长,所以不会影响 q、k 本身的数值规模。
5. 配对方式有两种约定,这是实际加载权重时的坑。
- 相邻配对(原论文): (0,1)、(2,3)、(4,5)……
- 前后半配对(HuggingFace LLaMA 的实现): (0, d/2)、(1, d/2+1)……
两种在数学上等价,但维度排列不同。加载预训练权重时,必须和权重对应的约定保持一致,否则 q、k 的维度对不上,输出会是乱码。这和我们之前遇到的 Conv1D 转置是同一类坑:不是原理问题,是约定问题。
六、PyTorch 实现
先预先算好所有位置、所有频率的 cos 和 sin(只算一次):
import torch
def precompute_rope(head_dim: int, max_len: int, base: float = 10000.0):
# θ_i = base^(-2i/d),i = 0..d/2-1
i = torch.arange(0, head_dim, 2).float() # [d/2]
theta = 1.0 / (base ** (i / head_dim)) # [d/2]
pos = torch.arange(max_len).float() # [max_len]
angles = torch.outer(pos, theta) # [max_len, d/2] 每个位置×每个频率
return torch.cos(angles), torch.sin(angles) # 各 [max_len, d/2]
然后是旋转本身,这里用和手算一致的相邻配对:
def apply_rope(x, cos, sin):
# x: [B, H, T, d]
T = x.shape[-2]
cos, sin = cos[:T], sin[:T] # [T, d/2],自动广播到 [B, H, T, d/2]
x1 = x[..., 0::2] # 偶数维 [B, H, T, d/2]
x2 = x[..., 1::2] # 奇数维 [B, H, T, d/2]
out1 = x1 * cos - x2 * sin # 对应 x0·cosα − x1·sinα
out2 = x1 * sin + x2 * cos # 对应 x0·sinα + x1·cosα
out = torch.stack([out1, out2], dim=-1) # [B, H, T, d/2, 2]
return out.flatten(-2) # [B, H, T, d]
如果要对齐 HuggingFace LLaMA 的前后半配对,只需要改切分方式:
def apply_rope_half(x, cos, sin):
half = x.shape[-1] // 2
x1, x2 = x[..., :half], x[..., half:]
return torch.cat([x1 * cos[:x.shape[-2]] - x2 * sin[:x.shape[-2]],
x1 * sin[:x.shape[-2]] + x2 * cos[:x.shape[-2]]], dim=-1)
接进注意力
改动只有两处:删掉输入层的位置 Embedding,在 Q、K 算完之后旋转。
class MultiHeadAttention(nn.Module):
def forward(self, x, cos, sin):
B, T, _ = x.shape
q = self.W_q(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
k = self.W_k(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
v = self.W_v(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
q = apply_rope(q, cos, sin) # 只转 q
k = apply_rope(k, cos, sin) # 只转 k,v 不动
scores = q @ k.transpose(-2, -1) / self.head_dim ** 0.5
# 后面的 causal mask、softmax、@ v 都和之前完全一样
...
GPT 主体里:
# 之前(GPT-2)
x = self.tok_emb(idx) + self.pos_emb(torch.arange(T))
# 现在(RoPE)
x = self.tok_emb(idx) # 不再加位置向量
# cos, sin 在模型初始化时 precompute_rope 一次,每层 Block 共用
其余部分(Block、残差、归一化、FFN)完全不用动,这也是"零件替换"的意义:换一个零件,其他结构保持原样。
七、一个预告:长上下文
RoPE 在训练长度之外的表现,是后来一系列工作的出发点。
直觉上:训练时模型只见过位置 0 ~ L 对应的那些旋转角度;超出 L 之后,会出现训练时没见过的角度组合,效果会变差。
后续的位置插值(Position Interpolation)、NTK-aware 缩放、YaRN 等方法,基本都是在调整"角度"或"base",让更长的序列落进模型熟悉的角度范围内。这些我们之后单独讲,这一期只需要知道:RoPE 的 base(10000)不是随便定的常数,它直接决定了模型能"看清"多远的距离。
八、小结
- GPT-2:位置向量加在输入上,绝对位置,有长度上限
- RoPE:位置信息变成对 Q、K 的旋转,位置 m 转 mθ
- 点积后,m 和 n 各自抵消,只剩相对距离 (n−m)
- d 维向量两两配对,每对不同转速,像钟表的秒针、分针、时针
- 只作用于 Q、K,每层都做,没有可学习参数
- 实现时注意配对约定(相邻 vs 前后半),加载权重要对齐
与前后文的关系:
- 原系列"位置编码"那一期:GPT-2 的绝对位置向量,本期把它整个替换掉
- 进阶篇「从 GPT-2 到 LLaMA」 EP1 / EP2:RMSNorm、SwiGLU,本期是第三个被换掉的零件
下期预告
位置编码换完了,下一个要换的是注意力本身的结构:GQA(Grouped-Query Attention)。
为什么 LLaMA 2 / 3 这些模型,K 和 V 的头数比 Q 少?它和我们之前讲过的 KV Cache 有什么关系?下期用猫吃鱼手算给你看。
参考:Su et al., "RoFormer: Enhanced Transformer with Rotary Position Embedding", 2021.