3、RoPE公式推导:从绝对位置到相对位置

好,咱们直接进入正题。位置编码这事儿,在语音识别里其实挺头疼的。你想想看,一段语音可能有几百帧,模型得知道「这个词在第几帧说的」。早期大家用绝对位置编码,说白了就是给每个位置发个身份证号。但后来发现,模型真正关心的不是「你在第5帧」,而是「你离我有多远」——这就是相对位置的核心思想。

我个人习惯把RoPE理解成「让位置信息随着旋转自然融入」。嗯,咱们一步步来拆。

3.1 为什么绝对位置不够用?

先看一个简单例子。假设句子是「我今天很开心」。用绝对位置编码,每个词的位置是固定的:

  • 「我」→ 位置1
  • 「今天」→ 位置2
  • 「很」→ 位置3
  • 「开心」→ 位置4

模型学到的其实是「位置1的词是主语,位置4的词是谓语」。但问题来了——如果句子变成「今天我很开心」呢?位置全乱了。模型得重新学。

我在项目中遇到过类似情况。训练一个中文语音识别模型,发现长句子的后半段准确率明显下降。后来排查发现,绝对位置编码让模型对「绝对距离」产生了依赖,一旦语速变化或插入停顿,位置信息就失效了。

核心矛盾:语音识别里,同一句话可能说快说慢,帧数不一样。绝对位置编码无法泛化到未见过的长度。

3.2 二维旋转矩阵的构造

RoPE的思路很巧妙。它把位置信息编码成旋转角度,而不是加性偏置。咱们先从二维情况说起。

假设有一个二维向量 [x, y],代表某个token的query或key。我们想给它加上位置信息。怎么做?

旋转!

给定位置 pos,我们构造一个旋转矩阵:

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

其中 θ 是一个预设的角速度。那么位置编码后的向量就是:

[x', y'] = R(pos) · [x, y]

说白了,就是把原始向量在二维平面上转了个角度。位置不同,转的角度不同。

为什么这样能表达相对位置?

你想想看,两个位置 pos1pos2 的向量做内积时:

q_pos1 · k_pos2 = (R(pos1)·q) · (R(pos2)·k)
                = q · (R(pos1)^T · R(pos2)) · k
                = q · R(pos2 - pos1) · k

看到了吗?内积结果只依赖于 pos2 - pos1,也就是相对位置!这就是RoPE的精髓——绝对位置编码进去,相对位置自然出来。

关键洞察:旋转矩阵的群性质保证了 R(a)^T · R(b) = R(b-a)。这个性质让绝对位置编码自动具备了相对位置感知能力。

3.3 高维空间的扩展方法

二维的情况很美好,但实际模型里,embedding维度通常是512、768甚至更大。怎么扩展到高维?

方法其实很直接——分块旋转。

假设维度是 d,我们把它分成 d/2 个二维子空间。每个子空间独立旋转,但角速度不同。

具体来说:

  1. d 维向量按两两一组,分成 d/2
  2. i 组使用角速度 θ_i = base^(-2i/d)
  3. 每组独立应用二维旋转矩阵

代码实现大概是这样的:

def apply_rope(x, pos):
    """
    x: [batch, seq_len, num_heads, head_dim]
    pos: [batch, seq_len] 或标量
    """
    d = x.shape[-1]
    # 计算每个子空间的角速度
    theta = 10000.0 ** (-torch.arange(0, d, 2) / d)  # [d/2]
    
    # 计算旋转角度
    angles = pos.unsqueeze(-1) * theta  # [batch, seq_len, d/2]
    
    # 构造cos和sin
    cos = torch.cos(angles)  # [batch, seq_len, d/2]
    sin = torch.sin(angles)  # [batch, seq_len, d/2]
    
    # 将x分成两半,分别旋转
    x1 = x[..., 0::2]  # 偶数索引
    x2 = x[..., 1::2]  # 奇数索引
    
    # 旋转公式
    x1_new = x1 * cos - x2 * sin
    x2_new = x1 * sin + x2 * cos
    
    # 合并
    return torch.stack([x1_new, x2_new], dim=-1).reshape(x.shape)

避坑指南:我曾经在实现时把 cossin 的维度搞反了,结果模型训练loss死活不降。后来发现是旋转方向反了。记住:x1_new = x1*cos - x2*sin,这是逆时针旋转。

3.4 为什么选择不同的角速度?

你可能想问:为什么每个子空间用不同的 θ

原因很简单——让不同维度捕捉不同尺度的位置信息。

  • 低维度(i小):θ大,旋转快,适合捕捉短距离相对位置
  • 高维度(i大):θ小,旋转慢,适合捕捉长距离依赖

这有点像傅里叶变换里的不同频率分量。我在做语音识别时发现,这种多尺度设计特别适合处理语音信号——因为语音既有短时的音素变化(几十毫秒),也有长时的语义依赖(几秒钟)。

下面这张图展示了RoPE的核心逻辑:

RoPE位置编码核心流程 输入向量 [x1,x2,...,xd] 分块为d/2组 每组2维 旋转矩阵 R(pos) 每组不同角速度 θ_i = base^(-2i/d) 输出向量 关键性质:R(a)^T · R(b) = R(b-a) → 内积只依赖相对位置 二维示例:位置pos=3,θ=0.1 原始向量:[1.0, 0.0] 旋转矩阵:[[cos(0.3), -sin(0.3)], [sin(0.3), cos(0.3)]] 旋转后:[0.955, 0.295] 位置不同,旋转角度不同,但内积只与角度差有关

3.5 实际应用中的注意事项

最后聊几个我在实践中踩过的坑:

注意1:位置索引从0开始还是从1开始?

我建议从0开始。因为 pos=0 时旋转角度为0,相当于没有旋转,这符合「第一个位置不需要偏移」的直觉。曾经有个同事从1开始,结果模型在短序列上表现异常。

注意2:base值的选择

默认 base=10000 是Transformer论文里的值。但在语音识别中,如果帧率是100Hz(每帧10ms),序列长度可能达到2000帧。我建议适当增大base值(比如50000),让高频维度旋转慢一点,避免角度溢出。

小技巧:如果你用PyTorch,记得把 cossin 注册为 buffer,不要作为可训练参数。它们只依赖位置,不需要梯度。

嗯,RoPE的公式推导就到这里。说白了就是「用旋转代替加法,用角度差表达相对位置」。下一节咱们会看到它在语音识别模型里具体怎么用,包括怎么处理变长序列和流式推理。


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