3、相对位置编码:从绝对到相对,T5等模型中的相对位置偏置

为什么我们需要“相对”这个概念?

上一节我们聊了绝对位置编码。说白了,就是给每个词发一个固定的“座位号”。但有个问题——你想想看,模型真的关心“第5个词”这个绝对编号吗?

其实它更关心的是“这个词离我有多远”。比如在句子“我今天吃了苹果”里,“苹果”和“吃”的关系,跟它们之间的距离密切相关,跟它们是不是第3个、第5个词关系不大。

这就是相对位置编码的出发点。我个人习惯把绝对位置编码比作“门牌号”,而相对位置编码是“距离标尺”。

核心思想: 模型在计算注意力时,只关心两个token之间的相对距离,而不是它们在序列中的绝对位置。

从绝对到相对:一个简单的推导

我们先回顾一下绝对位置编码下的注意力分数计算。假设位置 i 的query向量是 qi,位置 j 的key向量是 kj,那么注意力分数就是:

score(i, j) = q_i^T * k_j

如果加上绝对位置编码,我们会把位置信息加到输入embedding里。但相对位置编码的做法不同——它直接在注意力分数计算中引入一个可学习的偏置项 bi-j

score(i, j) = q_i^T * k_j + b_{i-j}

这里的 bi-j 只依赖于 ij 的差值。也就是说,无论这两个词在句子开头还是结尾,只要它们相隔3个位置,就用同一个偏置值。

我的经验: 我在做文本分类项目时,发现绝对位置编码在长文本上表现不稳定。换成相对位置编码后,模型对句子中不同位置的语义关系捕捉得更准了。尤其是那些“头尾呼应”的句式,效果提升很明显。

T5模型中的相对位置偏置

Google的T5模型用了一种非常简洁的实现方式。它没有搞复杂的数学变换,而是直接维护一个可查找的偏置表。

具体来说,T5定义了一个最大相对距离 k(比如128)。对于任意两个位置 ij,它们的相对距离 d = i - j 会被裁剪到 [-k, k] 范围内。然后模型从一个可训练的矩阵中查找对应的偏置值。

我画了一张图来展示这个过程:

T5相对位置偏置计算流程 词1 词2 词3 词4 词5 pos=0 pos=1 pos=2 pos=3 pos=4 计算相对距离 d = i - j 裁剪到 [-k, k] 范围 从可训练偏置表中查找 b_{d} 注意力分数 = q^T k + b_{d}

你看,整个过程非常直观。T5的做法让我觉得特别清爽——没有复杂的三角函数,没有矩阵分解,就是一个简单的查表操作。

代码实现:手写一个T5风格的相对位置偏置

下面我给出一个简化版的实现。注意,这里我跳过了batch和head维度,只展示核心逻辑。

import torch
import torch.nn as nn

class T5RelativePositionBias(nn.Module):
    def __init__(self, num_heads, max_distance=128):
        super().__init__()
        # 可训练的偏置表
        # 距离范围是 [-max_distance, max_distance],共 2*max_distance+1 个值
        self.bias = nn.Embedding(2 * max_distance + 1, num_heads)
        self.max_distance = max_distance
        
    def forward(self, seq_len):
        # 生成所有位置对
        positions = torch.arange(seq_len, device=self.bias.weight.device)
        # 计算相对距离矩阵
        # rel_dist[i][j] = i - j
        rel_dist = positions.unsqueeze(1) - positions.unsqueeze(0)
        
        # 裁剪到 [-max_distance, max_distance]
        rel_dist = torch.clamp(rel_dist, -self.max_distance, self.max_distance)
        
        # 偏移到非负索引
        # 原始范围 [-max_distance, max_distance] -> [0, 2*max_distance]
        rel_dist = rel_dist + self.max_distance
        
        # 查表得到偏置
        # 输出形状: [seq_len, seq_len, num_heads]
        bias = self.bias(rel_dist)
        
        return bias.permute(2, 0, 1)  # [num_heads, seq_len, seq_len]

# 使用示例
bias_layer = T5RelativePositionBias(num_heads=8, max_distance=128)
seq_len = 10
bias = bias_layer(seq_len)
print(f"偏置形状: {bias.shape}")  # [8, 10, 10]

我曾经踩过的坑: 一开始我把max_distance设得太小,比如32。结果序列长度超过32时,远处的token之间就分不清距离了。模型表现直接崩了。后来我改成128,效果才稳定下来。建议max_distance至少设为序列最大长度的1/2。

相对位置编码的优缺点

优点 缺点
对序列长度变化更鲁棒 实现比绝对位置编码复杂一点
能更好地捕捉局部依赖关系 需要额外存储偏置表参数
在长文本任务中通常表现更好 某些任务上可能不如学习到的绝对位置
可解释性更强(偏置值直接反映距离影响) 对极端长序列仍需裁剪策略

实际项目中的选择建议

我个人在项目中是这样选择的:

  • 短文本分类(< 128 tokens):绝对位置编码就够用了,简单直接
  • 长文本理解(> 256 tokens):优先考虑相对位置编码,尤其是T5这种轻量实现
  • 生成任务(如翻译、摘要):相对位置编码几乎是标配,因为生成序列长度不确定

一个小技巧: 如果你不确定选哪种,可以两个都试试。在验证集上对比一下loss曲线。我见过不少项目,换用相对位置编码后,收敛速度明显加快。

嗯,相对位置编码就聊到这里。它没有绝对位置编码那么“数学美”,但胜在实用。尤其是T5那种查表方式,简单粗暴但有效。下一节我们会聊另一个热门话题——旋转位置编码,那才是真正让我眼前一亮的东西。


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