4、相对位置编码(Relative):Shaw et al. 提出的相对位置编码思想

好,咱们继续聊位置编码。上一节我们聊了绝对位置编码,说白了就是给每个词一个固定的“座位号”。但我在实际做长文本任务时发现,这种硬编码方式有个很头疼的问题——模型很难学到“词与词之间的相对关系”。你想想看,句子“我喜欢猫”和“猫喜欢我”,如果只用绝对位置,模型得重新学习“喜欢”和“猫”在不同位置时的关系,这多累啊。

所以,Shaw 等人就在 2018 年提出了相对位置编码。这个思路很巧妙:我们不需要知道词在句子里的绝对坐标,只需要知道它们之间的距离。比如“我”和“喜欢”距离是 1,“我”和“猫”距离是 2,这就够了。

为什么需要相对位置?

我个人习惯把这个问题拆成两个痛点来讲:

  • 长距离依赖:句子一长,绝对位置编码的向量之间差异会变得很小,模型很难区分“距离 50”和“距离 51”的区别。但相对位置编码只关心相对距离,距离远了就是远了,不会因为句子变长而模糊。
  • 平移不变性:这个术语听着高大上,其实很简单——你把句子里的所有词都往后挪两个位置,绝对位置编码就全变了,模型得重新学。但相对位置编码不受影响,因为词与词之间的相对距离没变。我在做文本分类时遇到过这种情况:训练集里句子开头都是“你好”,测试集里变成了“请问你好”,结果模型就懵了。用相对位置编码就能解决这个问题。

核心思想:在计算注意力分数时,不再使用绝对位置向量,而是引入一个可学习的相对位置偏置。这个偏置只取决于两个 token 之间的相对距离。

Shaw et al. 的数学原理

咱们直接看公式。标准的自注意力计算是这样的:

Attention(Q, K, V) = softmax(QK^T / √d) V

Shaw 的改进版本在计算 Q 和 K 的相似度时,加了一个相对位置项:

e_ij = (x_i W_Q) (x_j W_K + a_{ij}^K)^T / √d

这里 a_{ij}^K 就是可学习的相对位置向量。它只依赖于 i 和 j 之间的差值 j - i。说白了,就是给每个可能的距离都配一个专属的向量。

嗯,这里要注意:a_{ij}^K 的维度跟 K 的维度是一样的。而且我们通常会对距离做截断,比如只考虑 [-k, k] 范围内的距离,超出部分用边界值代替。为什么?因为太远的词之间其实没什么直接关系,截断还能减少参数量。

我的经验:截断窗口 k 一般取 64 或 128 就够了。我曾经试过取 512,结果模型不仅没变好,训练还慢了不少。后来想想也对,两个词隔了 100 多个位置,它们之间早就被其他词隔断了,直接学它们的关系意义不大。

相对位置编码的两种实现方式

在实际工程中,我见过两种主流实现:

方式 描述 优缺点
加法式 在注意力分数计算时直接加上相对位置偏置 实现简单,但参数量随窗口大小线性增长
乘法式 将相对位置编码作为权重矩阵乘到 K 或 V 上 更灵活,但计算量稍大

我个人更推荐加法式,因为它在大多数任务上效果已经足够好,而且实现起来不容易出错。你想想看,乘法式要处理矩阵乘法,维度稍微没对齐就报错,调试起来很烦人。

代码示例:实现一个简单的相对位置偏置

下面我写一个 PyTorch 风格的伪代码,展示如何实现相对位置偏置:

import torch
import torch.nn as nn

class RelativePositionBias(nn.Module):
    def __init__(self, max_distance=64, num_heads=8):
        super().__init__()
        self.max_distance = max_distance
        # 为每个可能的距离和每个头学习一个偏置
        self.relative_bias = nn.Embedding(2 * max_distance + 1, num_heads)
        
    def forward(self, seq_len):
        # 生成相对距离矩阵
        # 比如 seq_len=4,距离矩阵是:
        # [[0, 1, 2, 3],
        #  [-1, 0, 1, 2],
        #  [-2, -1, 0, 1],
        #  [-3, -2, -1, 0]]
        distances = torch.arange(seq_len).unsqueeze(0) - torch.arange(seq_len).unsqueeze(1)
        # 截断到 [-max_distance, max_distance]
        distances = distances.clamp(-self.max_distance, self.max_distance)
        # 偏移到非负索引
        indices = distances + self.max_distance
        # 查表得到偏置,形状 [seq_len, seq_len, num_heads]
        bias = self.relative_bias(indices)
        return bias.permute(2, 0, 1)  # [num_heads, seq_len, seq_len]

这段代码的核心就是:先算距离矩阵,然后截断,最后查表。我在项目里经常把这个模块加到 Transformer 的注意力层里,效果立竿见影。

避坑指南:我曾经犯过一个错误——忘记对距离做截断。结果模型在训练时只见过 [-64, 64] 范围内的距离,但推理时遇到了距离 100 的情况,查表直接越界。所以一定要在训练和推理时都用相同的截断策略。

相对位置编码的优缺点

咱们客观地总结一下:

  • 优点
    • 天然支持变长序列,不会因为句子长度变化而失效
    • 对句子平移不敏感,泛化能力更强
    • 长距离依赖建模效果更好
  • 缺点
    • 实现比绝对位置编码复杂一些
    • 需要额外存储相对位置偏置表
    • 截断窗口的选择需要调参

说白了,相对位置编码就是用一点实现复杂度,换来了更好的泛化能力。在大多数 NLP 任务中,这个 trade-off 是值得的。

知识体系图

下面我用一张 SVG 图来总结相对位置编码的核心逻辑:

相对位置编码核心逻辑 输入序列 x_1, x_2, ..., x_n 计算相对距离 d_ij = j - i 截断到 [-k, k] 查相对位置偏置表 a_{ij} 注意力分数 e_ij = Q_i (K_j + a_{ij})^T 或 e_ij = Q_i K_j^T + bias_{ij} 输出注意力分布

这张图展示了相对位置编码的完整流程:从输入序列开始,先计算两两之间的相对距离,然后截断到合理范围,再查表得到偏置,最后融入到注意力分数的计算中。整个过程清晰明了,没有弯弯绕绕。


好了,相对位置编码的核心思想就讲到这里。说白了,它就是让模型学会“词与词之间的距离关系”,而不是死记硬背每个词的绝对位置。下一节我们会聊另一个经典的位置编码方案——T5 的相对位置偏置,它在这个基础上做了进一步的简化。

一句话总结:相对位置编码通过引入可学习的距离偏置,解决了绝对位置编码在长距离依赖和平移不变性上的不足,是 Transformer 位置编码发展史上的重要里程碑。