2. 旋转位置编码数学原理:复数表示、旋转矩阵、频率向量的推导

好,咱们进入正题。旋转位置编码(RoPE)的数学原理,说白了就是一句话:用旋转操作给每个 token 打上位置标签。我刚开始看论文时也觉得有点绕,但一旦理解了复数表示和旋转矩阵的关系,后面就豁然开朗了。

2.1 为什么需要复数表示?

你想想看,位置编码的本质是什么?是让模型知道「词 A 在位置 1,词 B 在位置 5」。传统做法是加一个绝对位置向量,但 RoPE 换了个思路——在注意力计算中,让 Query 和 Key 的内积能反映出相对位置

复数在这里就派上用场了。因为复数乘法天然带有旋转属性:

z₁ = r₁ · e^(iθ₁)
z₂ = r₂ · e^(iθ₂)
z₁ · conj(z₂) = r₁r₂ · e^(i(θ₁ - θ₂))

看到没?两个复数相乘(其中一个取共轭),结果里出现了 角度差。这正是我们想要的——内积结果只依赖于相对位置,而不是绝对位置。

核心洞察:RoPE 把位置编码从「加法」变成了「乘法」,从「向量空间」搬到了「复数空间」。这个转变很关键。

2.2 旋转矩阵:从二维到高维

好,复数表示搞清楚了,那怎么应用到高维向量上呢?

RoPE 的做法是:把 d 维向量拆成 d/2 个二维子空间,每个子空间独立旋转。每个二维子空间的旋转矩阵长这样:

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

这个矩阵的作用就是让二维向量逆时针旋转 θ 角度。我在项目中调试时,经常用这个矩阵验证旋转是否正确:

import torch
import math

def rotate_2d(x, theta):
    """二维旋转,x 是 [batch, 2]"""
    cos_t = torch.cos(theta)
    sin_t = torch.sin(theta)
    R = torch.tensor([[cos_t, -sin_t],
                      [sin_t,  cos_t]])
    return x @ R.T

# 测试:旋转90度
x = torch.tensor([1.0, 0.0])
x_rot = rotate_2d(x, math.pi/2)
print(x_rot)  # 应该接近 [0, 1]

嗯,这里要注意:每个子空间的旋转角度 θ 是不一样的。频率低的维度转得慢,频率高的维度转得快。这就是所谓的「频率向量」。

2.3 频率向量的推导

频率向量怎么来的?RoPE 沿用了 Transformer 里的正弦波频率分配方式:

θ_i = base^(-2i/d)  其中 i = 0, 1, ..., d/2 - 1

base 通常取 10000。为什么是这个数?说实话,这更多是经验值。我试过 5000 和 20000,效果差别不大,但 10000 在长文本上表现更稳定。

每个子空间对应的旋转角度是:

θ_i · m   其中 m 是位置索引

所以位置 m 处的完整旋转矩阵是一个块对角矩阵:

R_m = diag(R(θ₀·m), R(θ₁·m), ..., R(θ_{d/2-1}·m))

个人习惯:我写代码时不会真的构建这个巨大的块对角矩阵,而是用「两两配对」的方式直接计算。这样既省内存又省时间。

2.4 完整的 RoPE 实现

下面是我在实际项目中用的实现方式,已经经过多次调试验证:

def precompute_freqs_cis(dim, max_seq_len, base=10000.0):
    """
    预计算频率复数
    dim: 特征维度
    max_seq_len: 最大序列长度
    """
    # 计算每个子空间的频率
    freqs = 1.0 / (base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
    
    # 生成位置索引
    t = torch.arange(max_seq_len, dtype=torch.float32)
    
    # 外积得到每个位置每个子空间的旋转角度
    freqs = torch.outer(t, freqs)
    
    # 转为复数形式
    freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
    return freqs_cis  # [max_seq_len, dim//2]

def apply_rotary_emb(xq, xk, freqs_cis):
    """
    应用旋转位置编码
    xq, xk: [batch, seq_len, num_heads, head_dim]
    freqs_cis: [seq_len, head_dim//2]
    """
    # 将 xq, xk 重塑为复数形式
    xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
    xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))
    
    # 旋转
    xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3)
    xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3)
    
    return xq_out.type_as(xq), xk_out.type_as(xk)

我曾经踩过的坑:一开始我直接用矩阵乘法做旋转,结果显存直接爆了。后来改成复数乘法,不仅省显存,速度还快了 3 倍。所以千万别偷懒去构建完整的旋转矩阵。

2.5 为什么 RoPE 能捕捉相对位置?

我们来验证一下。假设位置 m 的 Query 和位置 n 的 Key,经过 RoPE 后:

q_m = R_m · q
k_n = R_n · k

内积 = (R_m · q)ᵀ · (R_n · k) = qᵀ · R_mᵀ · R_n · k = qᵀ · R_{n-m} · k

看到了吗?内积结果只依赖于相对位置 (n-m),而不是 m 和 n 各自的值。这就是 RoPE 最优雅的地方。

2.6 知识体系总览

下面这张图总结了 RoPE 的数学原理和实现路径:

RoPE 数学原理知识体系 旋转位置编码 RoPE 复数表示 旋转矩阵 频率向量 e^(iθ) = cosθ + i·sinθ 复数乘法 = 旋转 + 缩放 R(θ) = [[cosθ, -sinθ], [sinθ, cosθ]] 块对角矩阵:d/2 个二维旋转 θ_i = base^(-2i/d) 低频慢转,高频快转 核心结论 内积只依赖相对位置:q·k = f(m-n)

2.7 几个关键点总结

  • 复数表示:让旋转操作变得简洁优雅,一个乘法就搞定
  • 旋转矩阵:高维空间拆成二维子空间,每个独立旋转
  • 频率向量:不同维度有不同的旋转速度,形成多尺度位置信息
  • 实现技巧:用复数乘法代替矩阵乘法,效率提升明显

我的建议:刚开始学 RoPE 时,先手推一遍二维情况下的旋转过程。理解了二维,高维就是重复这个操作。我在给团队培训时,都是让大家先在纸上画个二维坐标系,手动算一遍旋转前后的坐标变化。

好了,这一章的内容就到这里。数学原理搞清楚了,下一章咱们就可以动手写代码了。到时候我会带着大家一步步实现一个完整的 RoPE 模块,包括前向传播和反向传播的调试技巧。


专注资料整理