相对位置编码:从直觉到可编程方案
本文整理一次关于位置编码的讨论,核心主张是:序列中的绝对位置信息是冗余的,相对位置 + 显式边界锚点足以表达语言结构。 文末给出一份可直接落地的编程方案。
一、问题的起点
Transformer 的自注意力是置换不变的:打乱输入顺序,注意力输出只是跟着换位,模型无法感知顺序。所以必须注入位置信息。
主流做法分两大阵营:
- 绝对位置编码:给每个位置一个坐标(正弦、可学习 embedding、二进制等),加到 token 表示上。
- 相对位置编码:只编码 token 之间的距离,让注意力分数依赖
pos_i − pos_j。
本文论证:相对阵营在原理上更正确,绝对位置可以被锚点替代。
二、核心论证:绝对位置是冗余的
2.1 结构信息都能降解为相对距离
考虑那些看似需要"绝对位置"的场景:
| 场景 | 看似需要 | 实际需要 |
|---|---|---|
| 诗歌开头 | 位于第 0 位 | 在 BOS 之后 |
| 摘要开头 | 位于文档前部 | 在文档起始标记之后 |
| 结论结尾 | 位于末尾 | 在 EOS 之前 |
| 段落起始 | 绝对行号 | 在换行符之后 |
一旦序列里有显式的边界锚点(BOS、EOS、句号、换行),"绝对位置"就降解成"到最近锚点的距离"——这仍然是相对量。
2.2 锚点的特殊性来自 token 身份,不来自位置
BOS 之所以是锚点,是因为它是一个特殊的 token id,它的 embedding 与众不同。模型靠 token 类型识别它,不靠它的坐标。
这恰好支持核心论点:位置信息可以完全相对化,绝对信息由 token 身份承载。
2.3 平移不变性
整篇文章往后平移,只要锚点跟着平移,所有有意义的关系都不变。绝对编码破坏这个不变性,相对编码 + 锚点保持它。这是相对方案更"正确"的形式化理由。
三、相对位置的正确形式
3.1 位置应该调制注意力权重,而不是改写 value
推荐形式:
weight(i,j) = softmax( f(q_i, k_j, pos_i − pos_j) )
output_i = Σ_j weight(i,j) · v_j
关键点:value 保持纯净。"猫"就是"猫",不应因为离 query 远就变成别的意思。位置只决定"看谁、看多重"。
3.2 position 必须可正可负、且非对称
语言是有向的:主语在动词前、修饰语在被修饰词前。所以:
b(−2) ≠ b(2)
只编码距离、不编码方向的方案(如对称核)会丢失语序信息。
3.3 函数 f 的设计空间
(1) 纯加性偏置: f = q^T k + b(pos_i − pos_j) ← T5, ALiBi
(2) 乘性 + 加性: f = q^T k · g(d) + b(d)
(3) 位置旋转 q/k: f = (R_i q)^T (R_j k) ← RoPE
(4) 完整三元函数:f = 小网络(q, k, d) ← 表达力最强,最贵
RoPE 是 (3),是本文方案的一个特例。 它让点积里自然出现 d = pos_k − pos_q,且保模长、参数为零、外推好。
四、层级结构:单层相对位置的局限
自然语言是嵌套的:文档 → 段落 → 句子 → 词。锚点也是嵌套的。
"相对最近的句号"和"相对最近的段落起始"是两个不同的相对量。单层相对位置分不清"离句号近但离段首远"和"离句号远但离段首近"。
解决办法:多层相对坐标,每层维护一个相对位置。这是理论干净的方案,工程上比单层复杂。
实践中很多模型偷懒,只用单层相对位置,靠 token 类型(句号、换行)让模型自己学层级——效果不错,但不是理论上干净的。
五、可编程方案
下面给出一份可直接实现的相对位置注意力,融合了本文的结论:
- 相对位置,可正可负
- 非对称偏置
- 多频率基函数(覆盖近处精细、远处粗粒度)
- 参数共享,可外推到训练长度之外
5.1 多频率相对位置偏置
import torch
import torch.nn as nn
import math
class MultiScaleRelativeBias(nn.Module):
"""
多频率、非对称的相对位置偏置。
输入: 相对距离 d = pos_i - pos_j, 形状 (B, H, L, L)
输出: 偏置, 形状 (B, H, L, L)
"""
def __init__(self, num_heads, num_freqs=16, max_dist=4096, hidden=64):
super().__init__()
self.num_heads = num_heads
self.num_freqs = num_freqs
# 对数间隔的频率,覆盖多尺度
# 近处高频(精细),远处低频(粗粒度)
freqs = torch.exp(
torch.linspace(0, math.log(max_dist), num_freqs)
) # (num_freqs,)
self.register_buffer("freqs", freqs)
# 共享的 MLP:把基函数映射成每个 head 的偏置
# 输入 2*num_freqs 维(sin + cos),输出 num_heads 维
self.mlp = nn.Sequential(
nn.Linear(2 * num_freqs, hidden),
nn.GELU(),
nn.Linear(hidden, num_heads),
)
def forward(self, rel_pos):
"""
rel_pos: (B, H, L, L) 或 (L, L),整数或浮点相对距离
"""
d = rel_pos.float().unsqueeze(-1) # (..., 1)
angles = d / self.freqs # (..., num_freqs)
feats = torch.cat(
[torch.sin(angles), torch.cos(angles)], dim=-1
) # (..., 2*num_freqs)
bias = self.mlp(feats) # (..., num_heads)
# 移到 (B, H, L, L) 形状
return bias.permute(0, 3, 1, 2) if bias.dim() == 4 else bias
为什么用对数间隔频率: 近处距离(1~10)需要高分辨率,远处距离(100~4000)只需要粗粒度。对数间隔让参数量在尺度上均匀分布,同时保证外推时高频分量不会数值爆炸。
为什么用 sin/cos 而不是直接 d: 值域有界,外推安全。直接喂 d 会在长序列上数值爆炸。
5.2 相对位置注意力的完整实现
class RelativePositionAttention(nn.Module):
def __init__(self, d_model, num_heads, num_freqs=16, max_dist=4096):
super().__init__()
assert d_model % num_heads == 0
self.d_model = d_model
self.num_heads = num_heads
self.head_dim = d_model // num_heads
self.q_proj = nn.Linear(d_model, d_model)
self.k_proj = nn.Linear(d_model, d_model)
self.v_proj = nn.Linear(d_model, d_model)
self.out_proj = nn.Linear(d_model, d_model)
self.rel_bias = MultiScaleRelativeBias(
num_heads, num_freqs=num_freqs, max_dist=max_dist
)
self.scale = 1.0 / math.sqrt(self.head_dim)
def forward(self, x, attn_mask=None):
"""
x: (B, L, d_model)
attn_mask: (B, L) 布尔,True 表示有效 token
"""
B, L, _ = x.shape
H, D = self.num_heads, self.head_dim
q = self.q_proj(x).view(B, L, H, D).transpose(1, 2) # (B,H,L,D)
k = self.k_proj(x).view(B, L, H, D).transpose(1, 2)
v = self.v_proj(x).view(B, L, H, D).transpose(1, 2)
# 内容分数 (B,H,L,L)
scores = torch.matmul(q, k.transpose(-2, -1)) * self.scale
# 相对位置偏置
pos = torch.arange(L, device=x.device)
rel = pos[None, :] - pos[:, None] # (L,L) = pos_j - pos_i
bias = self.rel_bias(rel) # (H,L,L) 或 (B,H,L,L)
if bias.dim() == 3:
bias = bias.unsqueeze(0) # (1,H,L,L)
scores = scores + bias
# mask
if attn_mask is not None:
mask = attn_mask[:, None, None, :] # (B,1,1,L)
scores = scores.masked_fill(~mask, float("-inf"))
attn = torch.softmax(scores, dim=-1)
out = torch.matmul(attn, v) # (B,H,L,D)
out = out.transpose(1, 2).reshape(B, L, self.d_model)
return self.out_proj(out)
5.3 加入锚点 token
锚点不需要特殊的位置处理,它们是普通 token,但有特殊 id:
# 词表里预留锚点
SPECIAL_TOKENS = {
"bos": 0,
"eos": 1,
"para": 2, # 段落分隔
"sent": 3, # 句子分隔
}
def tokenize_with_anchors(text, tokenizer):
ids = tokenizer.encode(text)
# 在句子/段落边界插入锚点 token
# 具体切分规则依任务而定
return ids
锚点的作用:它们和 query 的相对距离被相对位置编码自然捕获。模型不需要绝对坐标,也能知道"我在句号后两个词"——因为句号这个 token 就在那里。
5.4 层级相对位置(可选进阶)
如果任务对层级敏感(如长文档理解),可以给每个层级维护一个相对坐标,作为额外偏置:
class HierarchicalRelativeBias(nn.Module):
"""
多层相对位置:词级 + 句级 + 段级。
每层独立计算偏置,最后相加或拼接后过 MLP。
"""
def __init__(self, num_heads, num_levels=3, num_freqs=8):
super().__init__()
self.levels = nn.ModuleList([
MultiScaleRelativeBias(num_heads, num_freqs=num_freqs)
for _ in range(num_levels)
])
self.combine = nn.Linear(num_heads * num_levels, num_heads)
def forward(self, rel_pos_per_level):
"""
rel_pos_per_level: list of (L,L) tensors, 每层的相对坐标
"""
biases = [lv(rp) for lv, rp in zip(self.levels, rel_pos_per_level)]
# 拼接后融合
stacked = torch.stack(biases, dim=-1) # (H,L,L,num_levels)
B_ = stacked.shape
flat = stacked.reshape(*B_[:-1], -1)
return self.combine(flat).permute(2, 0, 1) # (H,L,L)
使用示例:
# 词级:token index 之差
word_rel = pos_word[None, :] - pos_word[:, None]
# 句级:句子 id 之差
sent_rel = sent_id[None, :] - sent_id[:, None]
# 段级:段落 id 之差
para_rel = para_id[None, :] - para_id[:, None]
bias = hier_bias([word_rel, sent_rel, para_rel])
5.5 与 RoPE 的对比实验建议
如果你要做消融,建议对照以下基线:
| 配置 | 说明 |
|---|---|
| 无位置编码 | 下界,验证位置是否真的重要 |
| 可学习绝对位置 | BERT 式 |
| RoPE | 主流相对位置 |
| ALiBi | 纯加性、对称距离 |
| 本文方案(单层) | 多频率非对称偏置 |
| 本文方案(多层) | 加层级相对坐标 |
关键观察指标:
- 训练长度内(如 512)的困惑度
- 外推到 2×、4×、8× 训练长度时的困惑度
- 长距离依赖任务(如 needle-in-haystack)的准确率
- 平移不变性测试:把输入平移,看输出是否稳定
六、总结
-
绝对位置是冗余的:只要序列里有显式边界锚点,绝对位置信息都能降解为"到锚点的相对距离",锚点的特殊性由 token 身份承载。
-
相对位置应该调制注意力权重,不改写 value:位置决定"看谁、看多重",不决定"词是什么意思"。
-
相对位置必须可正可负、非对称:语言有方向,
b(−2) ≠ b(2)。 -
RoPE 是本方案的特例:用旋转实现相对位置,保模长、零参数、外推好,但表达力受限于"旋转"这一形式。
-
单层相对位置的局限是层级结构:嵌套的文档/段落/句子需要多层相对坐标才能干净表达,单层只能靠 token 类型让模型自己学。
-
可编程方案:多频率基函数 + 非对称加性偏置 + 参数共享,既能表达相对位置,又能外推到训练长度之外。层级敏感的任务再加多层相对坐标。
附:核心公式速查
注意力分数: score(i,j) = q_i^T k_j / √d + b(pos_i − pos_j)
偏置函数: b(d) = MLP( [sin(d/τ_k), cos(d/τ_k)]_{k=1..K} )
频率设置: τ_k = exp(linspace(0, log(L_max), K))
输出: out_i = Σ_j softmax_j(score(i,j)) · v_j
其中 pos_i − pos_j 可正可负,b 非对称,K 个频率覆盖多尺度,MLP 参数在所有位置共享,因而可外推。