位置变转角:为什么‘旋转’能编码相对距离
🎯 先猜一猜(下注解锁)
老办法是给每个位置发一个固定向量、加到 token 上。RoPE 偏不加,而是按位置把向量“转”一个角度。多此一举?还是另有玄机?
先押一个假设:用“旋转”代替“相加”,最关键的好处是?
先补的知识
- 一个 token 在序列里的位置就是它的序号 m(0,1,2,…)。
- 二维平面上的一个向量可以画成从原点出发的“小箭头”,旋转角度 θ 只改方向、不改长度。
- 两个向量的点积 = |a||b|·cos(夹角);夹角只取决于两者各自转了多少。
图解原理
RoPE 的核心魔法:把“位置 m”变成“把箭头转 mθ 角度”。当 query 在位置 m、key 在位置 n 时,它们的点积只跟差值 (m−n) 有关——模型于是天生感知“相对距离”,而不用记死“绝对位置”。这一关先建立这个直觉,再做出那张‘位置 × 频率 = 角度’的表。
老式做法是给每个位置发一个固定向量(位置 0 一个、位置 1 一个…)再加到 token 上。问题是:训练时只见过 0~4095,推理来个第 8000 位,模型从没见过,直接懵。我们真正想要的,其实不是“你在第几位”,而是“你俩离多远”——也就是相对距离。
把 token 向量看成平面上的小箭头。位置 m 就让它转 mθ:位置 0 不转,位置 1 转 θ,位置 2 转 2θ…… 像钟表指针,走得越远转得越多。
注意力要算 query·key。query 在位置 m 转了 mθ,key 在位置 n 转了 nθ。两个箭头的夹角正好是 (m−n)θ,于是点积 = |q||k|·cos((m−n)θ)——绝对位置 m、n 各自消失了,只剩相对距离 (m−n)。这就是 RoPE 能自然泛化到更长序列的根本原因。
如果所有维度都用同一个 θ,转太快的远处会“绕圈重合”,转太慢的近处又分不开。RoPE 让 head_dim 里不同的“维度对”用不同频率:低索引维度转得快(管细微差别),高索引维度转得慢(管大跨度距离)。频率公式:θ_i = 10000−2i/d,i 越大频率越低。
用 torch.outer(位置, 频率) 一次性算出所有 (位置, 维度) 的角度,再用 torch.polar(1, 角度) 把角度变成复数 cos+i·sin(模长固定为 1,所以只转不缩)。最终表的 shape 是 [seq_len, head_dim/2]——列数是 head_dim/2,因为后面要两两配对成复数。
| 位置 m | freq0 (快) | freq1 | … freqi (慢) |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 1 | 1·θ0 | 1·θ1 | 1·θi |
| 2 | 2·θ0 | 2·θ1 | 2·θi |
语法热身:用 outer 把“位置 × 频率”摊成一张角度表
pos = torch.arange(3).float() # 位置 0,1,2
freqs = torch.tensor([1.0, 0.1]) # 两组频率:一快一慢
# outer:第 i 行第 j 列 = pos[i] * freqs[j]
angle = torch.outer(pos, freqs) # shape [3, 2]
# tensor([[0.0, 0.0], 位置0:都不转
# [1.0, 0.1], 位置1:快的转1.0,慢的转0.1
# [2.0, 0.2]]) 位置2:转得更多
# polar(模长, 角度) → 复数 cos+i·sin,模长全取 1 表示“只转不缩”
cis = torch.polar(torch.ones_like(angle), angle) # complex [3,2]读法:outer(a,b)[i,j] = a[i]*b[j],正好对应“位置 × 频率 = 该转的角度”。polar 把角度打包成可直接相乘的复数旋转因子 eiθ。
巩固一下
巩固(动手算):seq_len=8、head_dim=64 时,freqs_cis 这张角度表的 shape 应该是?
回到 notebook 的作业
这里不直接写答案。你已经拿到足够输入,最后用 notebook 的 TODO 做举一反三。