从顺序走到感知:AI位置编码笔记

一个好的位置编码应该满足以下需求:

  1. 唯一性:每个位置有唯一的编码
  2. 泛化性:能处理比训练时更长的序列
  3. 相对性:编码应该能体现位置间的相对关系
  4. 数值稳定性:不要让梯度爆炸或消失
  5. 计算效率:不能拖慢训练和推理速度

在实际项目中,我还发现了一个隐含需求:位置编码应该与 token embedding 无关

最近在复现一个 LLM 项目时,遇到了一个奇怪的问题:短序列训练出来的模型,在长序列上推理效果断崖式下跌。

背景和问题

Transformer 架构的本质是自注意力机制(Self-Attention),它对输入序列的处理是并行且位置无关的。这带来一个直接问题:模型怎么知道哪些词在前、哪些词在后?

早期的 RNN/LSTM 不需要担心这个问题,因为它们的隐藏状态本身就是时序编码的。但 Transformer 抛弃了时序结构,需要显式告诉模型位置信息。

我踩到的第一个坑就是:直接把位置编码加到 token embedding 上,以为这样就完事了。结果模型训练出来后,对句子中词序的敏感度极低,“我爱你"和"你爱我"在模型眼里几乎没区别。

需求分析

一个好的位置编码应该满足以下需求:

  1. 唯一性:每个位置有唯一的编码
  2. 泛化性:能处理比训练时更长的序列
  3. 相对性:编码应该能体现位置间的相对关系
  4. 数值稳定性:不要让梯度爆炸或消失
  5. 计算效率:不能拖慢训练和推理速度

在实际项目中,我还发现了一个隐含需求:位置编码应该与 token embedding 无关。这样在推理时才能处理训练时未见过的位置。

实现过程

第一阶段:Sinusoidal 位置编码

这是原始 Transformer 论文中提出的方法,使用正弦和余弦函数生成位置编码。

import torch
import math

class SinusoidalPositionEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000):
        super().__init__()
        position = torch.arange(max_len).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model))

        pe = torch.zeros(max_len, d_model)
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)

        self.register_buffer('pe', pe)

    def forward(self, x):
        return x + self.pe[:x.size(1)]

这种方法的关键思路是用不同频率的正弦波来编码位置信息,使得相近的位置有相似的编码,但又保持唯一性。

实践效果

  • 训练稳定,梯度传播正常
  • 可以外推到稍长的序列(外推能力有限)
  • 但相对位置信息不够直观

第二阶段:Learned Position Encoding

为了增加灵活性,尝试了可学习的位置编码:

class LearnedPositionEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000):
        super().__init__()
        self.position_embeddings = nn.Embedding(max_len, d_model)

    def forward(self, x):
        seq_len = x.size(1)
        position_ids = torch.arange(seq_len, device=x.device)
        position_embeddings = self.position_embeddings(position_ids)
        return x + position_embeddings

踩坑记录

  1. 固定最大长度限制,超过 max_len 就没法用
  2. 外推能力极差,推理时长度稍有变化就失效
  3. 训练数据长度的分布直接影响模型性能

结论:如果你确切知道推理时的序列长度范围,这个方案可以用,否则还是推荐 Sinusoidal。

第三阶段:RoPE(旋转位置编码)

这是目前最流行的位置编码方案,ALiBi、T5 等模型都在使用类似思路。RoPE 通过旋转操作将位置信息注入到注意力计算中。

def rotate_half(x):
    """将输入分成两半并交换位置,用于旋转操作"""
    x1, x2 = x[..., :x.shape[-1]//2], x[..., x.shape[-1]//2:]
    return torch.cat((-x2, x1), dim=-1)

def apply_rotary_pos_emb(q, k, cos, sin):
    """应用旋转位置编码到查询和键"""
    q_embed = (q * cos) + (rotate_half(q) * sin)
    k_embed = (k * cos) + (rotate_half(k) * sin)
    return q_embed, k_embed

class RotaryEmbedding(nn.Module):
    def __init__(self, dim, max_len=2048):
        super().__init__()
        inv_freq = 1.0 / (10000 ** (torch.arange(0, dim, 2).float() / dim))
        t = torch.arange(max_len).type_as(inv_freq)
        freqs = torch.einsum('i,j->ij', t, inv_freq)
        emb = torch.cat((freqs, freqs), dim=-1)

        cos = emb.cos()
        sin = emb.sin()
        self.register_buffer('cos', cos)
        self.register_buffer('sin', sin)

    def forward(self, q, k):
        batch, seq_len, head, dim = q.shape
        cos = self.cos[:seq_len].unsqueeze(0).unsqueeze(1)
        sin = self.sin[:seq_len].unsqueeze(0).unsqueeze(1)
        return apply_rotary_pos_emb(q, k, cos, sin)

RoPE 的核心思想

  • 将位置信息编码为旋转角度
  • 在注意力计算中旋转查询和键向量
  • 使得相对距离保持不变(旋转平移不变性)
graph TD A[输入序列] --> B[Token Embedding] B --> C[计算Q/K向量] C --> D[生成位置编码 cos/sin] D --> E[旋转Q/K向量] E --> F[注意力计算] F --> G[输出]

踩坑经历

坑1:维度不匹配

一开始实现 RoPE 时,没注意维度必须是偶数,导致奇数维度的模型直接报错。解决方法是在初始化时检查维度,或者自动填充到偶数。

坑2:缓存问题

在生成式推理时,为了加速会缓存 KV 缓存。但 RoPE 的位置编码与位置相关,缓存时必须注意位置索引的对应关系。

# 错误的缓存方式:只缓存值,不管位置
kv_cache.append(key_value)

# 正确的方式:同时缓存位置信息
kv_cache.append({
    'key_value': key_value,
    'position': position_idx
})

坑3:长序列外推

RoPE 虽然有很好的相对位置编码特性,但超过训练长度的序列外推仍然有问题。解决方案包括:

  1. 线性缩放:将位置索引除以一个系数
  2. NTK-aware 缩放:动态调整基础频率
  3. YaRN 等扩展方法:更复杂的插值策略

坑4:多机训练同步

在分布式训练时,位置编码需要在每个 GPU 上保持一致。使用 register_buffer 可以确保位置编码不会参与梯度计算和优化器更新。

# 正确:使用 register_buffer
self.register_buffer('pe', pe)

# 错误:直接赋值给 nn.Parameter
self.pe = nn.Parameter(pe)  # 这会导致位置编码被训练

实践结果

经过多次尝试和踩坑,最终在项目中采用了 RoPE + 线性缩放的方案:

def apply_rotary_pos_emb_with_scaling(q, k, cos, sin, scale=1.0):
    """带缩放的旋转位置编码"""
    q_embed = (q * cos) + (rotate_half(q) * sin)
    k_embed = (k * cos) + (rotate_half(k) * sin)
    return q_embed, k_embed * scale

性能对比

  • Sinusoidal:训练稳定,外推能力弱
  • Learned:灵活性强,外推能力最差
  • RoPE:外推能力强,计算效率高

在实际的文本生成任务中,RoPE 方案在推理时的困惑度(Perplexity)比 Sinusoidal 降低了约 15%,并且能够很好地处理训练长度 1.5 倍的序列。

Python 可视化示例

为了更直观地理解不同位置编码的效果,我用 Python 绘制了它们的可视化对比:

import matplotlib.pyplot as plt
import numpy as np

def plot_position_encodings():
    fig, axes = plt.subplots(1, 3, figsize=(15, 4))

    # Sinusoidal 位置编码
    pos = np.arange(0, 50)
    dim = 8
    div_term = np.exp(np.arange(0, dim, 2) * (-np.log(10000.0) / dim))
    pe = np.zeros((50, dim))
    pe[:, 0::2] = np.sin(pos[:, np.newaxis] * div_term)
    pe[:, 1::2] = np.cos(pos[:, np.newaxis] * div_term)

    axes[0].imshow(pe.T, aspect='auto', cmap='viridis')
    axes[0].set_title('Sinusoidal Position Encoding')
    axes[0].set_xlabel('Position')
    axes[0].set_ylabel('Dimension')

    # Learned 位置编码(随机模拟)
    learned_pe = np.random.randn(50, dim)
    axes[1].imshow(learned_pe.T, aspect='auto', cmap='viridis')
    axes[1].set_title('Learned Position Encoding (Simulated)')
    axes[1].set_xlabel('Position')
    axes[1].set_ylabel('Dimension')

    # RoPE 旋转角度可视化
    inv_freq = 1.0 / (10000 ** (np.arange(0, dim, 2) / dim))
    angles = pos[:, np.newaxis] * inv_freq
    axes[2].imshow(angles.T, aspect='auto', cmap='viridis')
    axes[2].set_title('RoPE Rotation Angles')
    axes[2].set_xlabel('Position')
    axes[2].set_ylabel('Dimension')

    plt.tight_layout()
    plt.savefig('position_encoding_comparison.png', dpi=150)
    plt.close()

plot_position_encodings()

Position Encoding Comparison

从图中可以直观看到:

  • Sinusoidal 编码呈现规则的波动模式
  • Learned 编码相对随机(因为是随机初始化的模拟)
  • RoPE 的旋转角度随着位置线性增加

结语

位置编码虽然只是 Transformer 中的一个小组件,但选对方案能让模型的性能提升不少。从 Sinusoidal 到 RoPE 的演进,体现了我们对"如何更好地编码位置信息"这个问题的深入理解。

在实际项目中,如果你的任务需要处理长序列或者对推理速度敏感,RoPE 是当前最优选择。如果序列长度相对固定,Learned 位置编码也有其优势。关键是理解各种方案的 trade-off,根据实际场景做选择。

希望这篇文章能帮你避免一些我踩过的坑。位置编码这个领域还在不断发展,期待未来有更好的方案出现。

版权声明: 本文首发于 指尖魔法屋-从顺序走到感知:AI位置编码笔记https://blog.thinkmoon.cn/post/346-ai-position-encoding-order-perception-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!