这期是Transformer数学推导系列的Q38。之前聊过注意力机制的计算流程、多头拆分、缩放点积这些基础内容,今天打算把旋转位置编码(RoPE)的复数形式彻底撕开。很多朋友看RoPE,公式一眼能看懂,但真问到“复数形式到底对注意力分数做了什么几何操作”,一下就卡住了。我一直觉得,RoPE用复数来表达不是炫技,而是把二维平面旋转这个几何事实压缩成最干净的符号。理解这层之后,很多Transformer调参行为,比如base=10000为什么是默认值、长文本为什么要调大base、RoPE到底会不会导致注意力漂移,都会变得特别自然。
这篇文章适合谁?正在手推Transformer原理的、想读懂LLaMA/Mistral里RoPE源码的、以及准备在自己的模型里改位置编码的朋友。我会从二维旋转矩阵讲起,完整推一遍复数形式,再落到注意力分数的几何解释和工程实现上,最后给几个我实际踩过的坑。
1. RoPE的几何出发点:为什么位置编码偏偏选“旋转”
1.1 注意力机制天生“无序”,位置信息必须靠外部注入
先把最基础的问题摆出来。Transformer里的自注意力本质上是集合运算:假设有两个token,它们的Query向量是q、Key向量是k,注意力分数就是q和k的内积。你把它们交换位置,只要q和k不变,内积就完全不变。换句话说,注意力机制本身无法区分“我很高兴”和“高兴我很”这两句话,因为词的集合是相同的。这是Transformer在结构上最大的特点,也是位置编码必须存在的原因。
位置编码的思路分两大流派。一派是绝对位置编码,典型的就是原始Transformer里那个sin/cos函数,把位置m的信息直接加到词向量上,或者用可学习的embedding加到词向量上。还有一派是相对位置编码,像T5、ALiBi这类,尝试让模型更直接地感知“两个token隔了多远”。RoPE属于第三类,它走的是另一个路线:不把位置信息加到向量上,而是把位置信息以旋转的方式作用到Query和Key向量上。这个选择听起来很微妙,但几何意义非常明确。
1.2 二维旋转矩阵:一种不改变长度的线性变换
在二维平面上,把向量旋转一个角度θ,标准变换是:
[ R(\theta)=\begin{bmatrix}\cos\theta & -\sin\theta\ \sin\theta & \cos\theta\end{bmatrix} ]
这个矩阵有两个特性。第一,它保持向量长度不变,因为(R(\theta))是正交矩阵,满足(R(\theta)^T R(\theta)=I)。第二,它对两个向量同时旋转时,内积会发生变化,但这个变化只取决于两个向量之间的夹角差。举个例子,假设平面上有两个向量a和b,夹角是60度。如果你把两个向量都旋转30度,它们的夹角依然是60度,所以内积不变。如果你只旋转其中一个,夹角就会变成90度,内积变成0。
RoPE的核心思想是把“位置m”当成一个旋转角度施加在token的Query和Key上。第m个位置的Query向量被旋转(m\theta),第n个位置的Key向量被旋转(n\theta)。这样一来,两个向量最终的内积里,绝对位置m和n只以差值(m-n)的形式出现,相对位置信息就这么自然地被编码进注意力分数里了。
1.3 复数为什么是旋转的更优雅表达
二维平面上的旋转,用复数来表达要简洁得多。把向量((x, y))看作复数(z = x + iy),旋转角度(\theta)就是乘以单位复数(e^{i\theta}=\cos\theta+i\sin\theta)。这个乘法在几何上完全等价于前面的旋转矩阵:
[ (x+iy)(\cos\theta+i\sin\theta) = (x\cos\theta - y\sin\theta) + i(x\sin\theta + y\cos\theta) ]
你看,实部和虚部正好对应旋转后的x坐标和y坐标。复数表达最大的优势是:旋转叠加变得极其简单。连续旋转(\theta_1)再旋转(\theta_2),就是乘以(e^{i(\theta_1+\theta_2)})。这种东西用矩阵写也不算复杂,但一旦涉及到高维空间、多频率、多通道,复数符号的简洁性就体现出来了。RoPE的实现几乎都是绕开实数矩阵、直接按复数旋转来做,原因就在这里。
2. 从实数旋转到复数形式:完整推导不吃灰
2.1 原始RoPE实数定义与内积的平移性质
先看RoPE在论文里的原始定义,它把d维向量拆成d/2对二维子向量,每一对施加一个旋转矩阵。设第j对旋转角度为(m\theta_j),其中:
[ \theta_j = \text{base}^{-2j/d}, \quad j = 0,1,\dots,d/2-1 ]
base默认是10000。那么第m个位置的Query向量q的第j对分量会被旋转成:
[ \begin{bmatrix}q'{2j} \ q'{2j+1}\end{bmatrix}
\begin{bmatrix} \cos m\theta_j & -\sin m\theta_j\ \sin m\theta_j & \cos m\theta_j \end{bmatrix} \begin{bmatrix}q_{2j} \ q_{2j+1}\end{bmatrix} ]
Key向量k同样处理,只是位置n。现在关键来了,直接动手推一下经过RoPE后的注意力分数:
[ \langle R(m)q,; R(n)k \rangle = (R(m)q)^T (R(n)k) = q^T R(m)^T R(n)k ]
由于旋转矩阵正交,(R(m)^T = R(-m)),矩阵乘法满足(R(-m)R(n)=R(n-m)),所以:
[ \langle R(m)q,; R(n)k \rangle = q^T R(n-m)k ]
这个式子说明,旋转后的注意力分数等价于:不旋转q,只把k反向旋转(n-m)再算内积。绝对位置m和n彻底消失了,只剩下相对位置差。这是RoPE最漂亮的性质,也是它被称为相对位置编码的数学根源。推导到这一步,还只是实数矩阵层面的结果。
2.2 用共轭复数重写注意力分数
接下来进入复数形式。把第j对的二维向量((q_{2j}, q_{2j+1}))映射成复数:
[ z_j = q_{2j} + i q_{2j+1},\quad w_j = k_{2j} + i k_{2j+1} ]
第m个位置旋转后,这两个复数变成:
[ z'_j = z_j e^{i m\theta_j},\quad w'_j = w_j e^{i n\theta_j} ]
在实数空间里,注意力分数对第j对维度的贡献是(q_{2j}k_{2j}+q_{2j+1}k_{2j+1})。它和复数的对应关系是什么?看下面这个乘法:
[ z_j \overline{w_j} = (q_{2j}+iq_{2j+1})(k_{2j}-ik_{2j+1}) = (q_{2j}k_{2j}+q_{2j+1}k_{2j+1}) + i(q_{2j+1}k_{2j} - q_{2j}k_{2j+1}) ]
注意,前面那个实数内积正好是(z_j\overline{w_j})的实部。这就是复数形式里关键的一步:点积对应共轭乘法的实部。
把旋转后的复数带进去:
[ z'_j \overline{w'_j} = z_j e^{i m\theta_j} \cdot \overline{w_j e^{i n\theta_j}} = z_j \overline{w_j} e^{i(m-n)\theta_j} ]
最后取实部,就得到第j对维度的注意力分数贡献:
[ \operatorname{Re}\left(z_j \overline{w_j} e^{i(m-n)\theta_j}\right) ]
把所有j加起来,就是完整的注意力分数。这个表达式看起来比矩阵简洁得多,同时把相对距离(m-n)直接放在指数上,位置和频率的关系一目了然。
2.3 多维多频率:每对维度都有自己的“转速”
RoPE不是把所有维度都用同一个旋转速度。d维向量被分成d/2对,每一对有自己的频率(\theta_j)。频率随j增大而指数衰减,所以低维通道转得快,高维通道转得慢。用复数语言说,就是每一对维度拥有一个独立旋转的复平面。
将频率写成波长更直观。一个通道旋转完整一圈需要满足(m\theta_j=2\pi),所以对应的空间周期是:
[ \lambda_j = \frac{2\pi}{\theta_j} = 2\pi \cdot \text{base}^{2j/d} ]
当j=0时,波长是(2\pi),非常短,token移动一个位置就能旋转约57度;当j接近d/2时,波长变成(2\pi\cdot\text{base}^{(d-2)/d}),对于d=128来说,这个值大约是几万。这意味着,高频通道对相邻token的位置差极其敏感,低频通道可以在很长的距离上保持缓慢稳定的相位变化。
2.4 一个具体例子:d=4手推到底
为了不让推导悬空,我拿d=4的小模型手推一遍。设q=(1,0,0,1),k=(0,1,1,0),位置m=10,n=2。第一步算实数矩阵,对第0对维度((q_0,q_1)=(1,0))旋转10\theta;第1对维度((q_2,q_3)=(0,1))旋转10\theta。为了方便取\theta_0=1, \theta_1=0.01,虽然这不是真实base,但计算原理一样。
第0对维度旋转后q分量约为(\cos10, \sin10),第1对维度约为(-\sin0.1? 等等,向量(0,1)旋转\theta变成(-\sin\theta,\cos\theta))。实际计算略繁琐,但不难验证。
用复数就快多了。z_q = q_0+iq_1 = 1,z_k = k_0+ik_1 = i,旋转后z'_q = e^{i10},z'_k = i e^{i2}。共轭乘法:
[ z'_q\overline{z'_k} = e^{i10}\cdot (-i e^{-i2}) = -i e^{i8} ]
取实部:(\operatorname{Re}(-i e^{i8}) = \sin 8)。另一对维度同理。整个分数就是各对维度贡献的和。我建议你自己动手算一遍这个例子,代码打印一下数值,比看十遍公式都管用。
3. 对注意力分数的几何影响:旋转、投影与“漂移”
3.1 分数其实是旋转后的夹角投影
把复数形式再深入一层。设(z_q = |z_q|e^{i\phi_q}),(z_k = |z_k|e^{i\phi_k})。经过旋转后,第j对维度的注意力分数贡献可以写成:
[ \operatorname{Re}\left(|z_q||z_k| e^{i(\phi_q-\phi_k+(m-n)\theta_j)}\right)
|z_q||z_k|\cos\left(\phi_q-\phi_k+(m-n)\theta_j\right) ]
这个形式的几何含义特别清楚:注意力分数等于两个复数向量的长度乘积,再乘以它们夹角的余弦。夹角由三部分组成。第一部分是Query在复平面上的固有方向(\phi_q),第二部分是Key的固有方向(\phi_k),第三部分是相对位置引入的额外旋转((m-n)\theta_j)。
在标准注意力公式里,缩放因子(\sqrt{d})会在计算softmax前对分数做缩放。但在RoPE的几何图景里,旋转本身不改变向量的模长,它改变的只是“投影角度”。模型想调整注意力分数,既可以学向量长度,也可以学初始角度(\phi_q-\phi_k),再叠加位置旋转。这个自由度是RoPE表达能力的重要来源。
3.2 绝对位置消失、相对位置登场:平移不变性
从2.1节的推导已经知道,旋转后的内积只依赖(m-n)。这里我想强调一下这个性质的实际意义。假设一个模型在一句话里学到了“猫”和“吃”的注意力关系,如果两个token相隔2个位置,分数固定。现在把整句话平移5个位置,绝对位置变了,但两个token的相对距离还是2,旋转后它们的夹角差不变,所以注意力分数不变。
这就是平移不变性。它让模型更容易把在训练中学到的位置依赖推广到新的序列位置。对比绝对位置编码,向量平移后所有位置的sin/cos值都变了,模型需要重新学习;而RoPE天然把绝对位置的全局偏移“消掉”了。这个性质在长文本外推的实验里表现得很明显,用RoPE训练的模型在超出训练长度不多的情况下,仍然能维持一定的相对位置感知。
当然,平移不变性并不等于完美外推。因为每个通道的频率是固定的,当相对距离超过通道的波长,cos函数开始发生混叠,模型看到的位置信号就会和短距离时重复。这就是外推问题的几何来源。
3.3 高频低频的分工:局部敏感与长距离稳定
用复数形式观察高频和低频通道,它们的分工特别明显。高频通道对应j小、(\theta_j)大的维度,旋转速度快,token每移一位,相位变化很大。比如j=0,θ=1时,位置m=3和m=4之间的相位差是1弧度,分数差异大,模型能精确区分相邻token。但这种通道不适合长距离,因为距离一大,相位已经转过好几个周期,cos函数震荡得很厉害,位置信息被混叠淹没。
低频通道正好相反。θ很小,比如d=128时最后一个通道θ大约对应波长上万,token移动几千个位置,相位变化都很缓慢。这相当于给模型提供了一个长距离的“绝对量角器”,它感受的是粗粒度的全局位置趋势。注意力头可以学习去侧重高频通道,用于局部语法依赖;也可以侧重低频通道,用于捕捉长距离共指、篇章结构。一个典型的多头注意力模型里,不同头确实会自发分化出这种频率偏好,你可以把每个头的频率响应画出来看,非常直观。
3.4 “RoPE导致注意力漂移?”:从数学角度说清楚
最近一直有人问,RoPE会不会导致注意力漂移,长期困惑了很多调模型的人。这个问题我第一次看到是在一些大模型外推评测里,大家发现超出训练长度后,注意力分数模式会变得很怪,于是怀疑是RoPE的旋转把注意力“转偏了”。
从上面的复数公式看,对某个固定的Query和Key,注意力分数是相对距离((m-n))的函数,呈现周期性的余弦模式。如果Query和Key的初始相位差(\phi_q-\phi_k)不为零,那么这个余弦波的峰值就不在(m-n=0)处,而是偏移了一个固定量。换句话说,模型可以通过初始相位差,主动把注意力峰值“调”到特定的相对距离上。这个性质是设计出来的,不是bug。一个注意力头如果想让位置i去注意到位置i-3,它完全可以学习一个初始相位差来对应这个偏移。所以“漂移”这个词本身不太准确,更准确的说法是:RoPE给了每个头一个可学习的相位偏置,让它们能选择不同的注意力距离偏好。
真正要警惕的是外推场景。当相对距离超过了训练中见过的最大距离,对高频通道来说,相位差可能已经多个周期,cos函数出现混叠,模型会看到“虚拟”的短距离匹配,从而产生意想不到的高注意力峰。这不是RoPE把分数“转飞了”,而是离散采样下的周期性必然结果。
4. 实操复现:从公式到能跑的Transformer代码
4.1 预计算cos/sin与两种维度配对方式
实际写代码时,很少有人真的去构造旋转矩阵。效率最高的做法是预计算cos和sin,然后对每个向量做旋转。PyTorch里最常见的实现沿用了LLaMA的风格:
def precompute_rope_freqs(dim, max_len, base=10000.0): # theta[j] = base^(-2j/dim) theta = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim)) m = torch.arange(max_len, dtype=torch.float32) freqs = torch.outer(m, theta) # [max_len, dim/2] return torch.cos(freqs), torch.sin(freqs) # [max_len, dim/2] def rotate_half(x): # 将向量按“前半维/后半维”配对 x1 = x[..., : x.shape[-1] // 2] x2 = x[..., x.shape[-1] // 2 :] return torch.cat([-x2, x1], dim=-1) def apply_rope(x, cos, sin, pos): # x: [..., d], cos/sin: [..., d/2],pos是位置索引 c = cos[pos] # [..., d/2] s = sin[pos] return x * c + rotate_half(x) * s这里要特别注意维度配对方式。前面推导复数形式时,我用的是一对相邻维度((0,1),(2,3))配对。但上面的rotate_half是按前半维和后半维配对。数学上这两种配对方式得到的旋转矩阵只是换了维度顺序,都满足相对位置性质,但实现必须和预训练权重保持一致,否则模型直接废掉。很多开源代码用的是“half split”配对,你读README时看不出区别,只有把矩阵列出来才发现维度分组完全不一样。
4.2 复数实现和rotate_half实现怎么选
如果你刚接触RoPE,我建议先用复数实现写一遍,因为和论文里的复平面推导一一对应:
def apply_rope_complex(x, cos, sin, pos): x1, x2 = x[..., 0::2], x[..., 1::2] # 相邻对 z = torch.complex(x1, x2) # 合并成复数 phase = torch.complex(cos[pos], sin[pos]) z_rot = z * phase # z * e^{i mθ} x_rot = torch.view_as_real(z_rot) # [..., d/2, 2] return x_rot.flatten(-2) # [..., d]这个实现直观,调试也方便。但要说大规模训练时,我建议还是用rotate_half版本。理由有两个:第一,torch.complex的张量在一些算子、算子融合、低精度推理上支持不够完善;第二,LLaMA、Mistral、Qwen等主流模型权重都基于half split的RoPE实现,保持兼容性比理论优雅更重要。你完全可以在README里用复数解释原理,在代码里用rotate_half实现,两边逻辑是一样的。
4.3 base参数、外推长度与“长文本魔改”
默认base=10000,这个数值直接决定最大波长。根据公式:
[ \lambda_{\max} \propto 2\pi \cdot \text{base}^{(d-2)/d} ]
d固定时,base越大,最大波长越长,低频通道的相位变化越慢。短训练长度下,较小的base就能覆盖足够的相位范围;但如果你要做长文本外推,很多人直接把base调大到500000甚至1000000,让所有频率变慢,这样一来,外推时高频通道不会因为旋转过多周期而产生严重混叠。
我自己试过一个经验:在4k训练长度的模型上,把base从10000调到100000,短文本任务几乎不掉点,但在8k外推测试上注意力分数要平滑得多。这个操作不需要改数据,只需要预计算cos/sin时传入新base即可。但要注意,base突然调大相当于所有位置编码的尺度变了,最好在长文本上做一小段continue pretraining,让模型重新适应相位尺度。
4.4 增量推理、缓存位置与数值精度
RoPE在训练和推理时还有个容易踩的坑:增量推理。在自回归生成时,如果每步只输入最新token,而你又把它当成位置0来处理,那它的旋转角就是0,完全丢失了真实位置。所以必须用KV cache里累计的位置索引给当前token分配正确的旋转角。实现上,通常维护一个position_ids,每生成一步加1,再传到RoPE函数里。
数值精度方面,当位置m很大时,(m\theta_j)会变得很大,直接用cos(m * theta)算,float32下可能损失少量精度。实测中影响不大,但如果你的位置长度超过几十万,建议把角度先归一化到([0, 2\pi))再算,或者直接使用更高精度的预计算表。
5. 常见误区与排查技巧实录
5.1 误区一:复数乘法直接当内积,方向搞反
第一次手写RoPE时最容易栽跟头的地方:脑子里想着复数旋转,就直接写zq * zk取实部当分数。这玩意的结果完全不对,因为旋转后的注意力分数必须用(z_q\overline{z_k}),也就是要取Key复数的共轭,乘法顺序不能反。你如果把共轭放在Query上,得到的是(e^{i(n-m)\theta})而不是(e^{i(m-n)\theta}),相对位置方向直接反了。
排查方法也很简单,构造一个极简用例:d=2,q=(1,0),k=(1,0),位置m=1,n=0。正确结果应该是(\cos\theta)。如果你实现得到(\cos\theta)以外的值,方向或者共轭大概率出了问题。
5.2 误区二:维度配对方式与预训练权重不一致
在微调一个开源模型时,最诡异的问题之一是:加载预训练权重后,模型效果正常,但你改了一下RoPE实现,效果骤然变成随机水平,还没有报错。十有八九是维度配对被改了。LLaMA用half split,你按相邻pair实现,频率分布不一样,位置编码的含义完全变掉。
排查时打印第0个位置的旋转向量:用预训练权重自带的RoPE配置,直接对单位向量做旋转,看哪些维度被旋转、旋转方向如何,再和你自己的实现对比。这种基础正确性测试一定要写进测试套件。
5.3 误区三:RoPE和绝对位置编码重复叠加
有些实现里既给embedding加可学习的绝对位置编码,又对Q、K施加RoPE。如果你是在小模型上实验,叠加确实可能有一点收益,但会给模型引入两套位置信号,增加冗余甚至干扰。RoPE本身已经能表达相对位置信息,叠加绝对编码之后,平移不变性被打破,效果不一定更好。我做对比实验时发现,叠绝对编码后,短文本任务持平,长文本外推反而下降。设计师们把RoPE和绝对位置编码做正交化是有原因的。
5.4 用单元测试锁定正确性:两个小向量验遍全家
最后给一个我一直在用的自检模板。选d=4、base=10000,定义q和k都是单位向量,固定m=5, n=3。然后用三套方法计算注意力分数:矩阵旋转法、复数乘法法、rotate_half法。三者必须完全一致。再算一个带绝对位置平移的case,验证m和n同时加1后分数不变。最后验证分数不对称性:交换m和n,分数变成原来的转置关系,因为注意力分数矩阵是共轭对称的。
def rope_forward_matrix(q, k, m, n, base=10000.0): # 按d=4手写矩阵 theta = base ** (-torch.tensor([0, 2]) / 4) # 构建块对角旋转矩阵...这种测试跑通了,再往注意力模块里集成,心里就有底了。技术文章里经常把RoPE描述得很高深,其实它是一个非常工程化的旋转技巧。
最后再分享一个我自己的感受。很多时候调模型像在黑盒里做实验,唯独位置编码这块,数学推到位之后,所有现象都能对应到几何直觉上。如果你也想彻底搞懂RoPE,我强烈建议你找张纸,从复数乘法开始推一遍,再用一个小网络验证一下平移不变性。这一套走完,比看任何代码解析都值。后续如果大家感兴趣,我可以接着写RoPE在NTK-aware scaling、YaRN这些长文本插值技巧里是怎么被改造的,也是对这套复数几何的进一步延展。