相对位置编码:从直觉到可编程方案

📄 文章 🌐 公开
📋 列表 ✏️ 编辑 🎨 画布版 📋 复制MD 🌐 复制HTML ☆ 收藏

相对位置编码:从直觉到可编程方案

本文整理一次关于位置编码的讨论,核心主张是:序列中的绝对位置信息是冗余的,相对位置 + 显式边界锚点足以表达语言结构。 文末给出一份可直接落地的编程方案。


一、问题的起点

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)的准确率
  • 平移不变性测试:把输入平移,看输出是否稳定

六、总结

  1. 绝对位置是冗余的:只要序列里有显式边界锚点,绝对位置信息都能降解为"到锚点的相对距离",锚点的特殊性由 token 身份承载。

  2. 相对位置应该调制注意力权重,不改写 value:位置决定"看谁、看多重",不决定"词是什么意思"。

  3. 相对位置必须可正可负、非对称:语言有方向,b(−2) ≠ b(2)

  4. RoPE 是本方案的特例:用旋转实现相对位置,保模长、零参数、外推好,但表达力受限于"旋转"这一形式。

  5. 单层相对位置的局限是层级结构:嵌套的文档/段落/句子需要多层相对坐标才能干净表达,单层只能靠 token 类型让模型自己学。

  6. 可编程方案:多频率基函数 + 非对称加性偏置 + 参数共享,既能表达相对位置,又能外推到训练长度之外。层级敏感的任务再加多层相对坐标。


附:核心公式速查

注意力分数:   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 参数在所有位置共享,因而可外推。

💬 留言 ⋮⋮

加载中…
💡 不登录也可留言(IP 限制:每文/每天各 1/10 条)

加载中…

纸张白
护眼绿
羊皮卷
夜间黑
100%