文章目录
前言
模型本身是"无状态"的,它看不到句子里单词的顺序(比如"我吃苹果"和"苹果吃我",在模型眼里如果不做处理,输入的token是一样的),那它怎么区分语序、理解语义呢?答案就是「位置编码」------它就像给每个单词贴了一个"坐标标签",告诉模型"这个单词在句子里排第1位、那个排第3位",让模型能捕捉到语序带来的语义差异。
关于位置编码,主要分为三类:绝对位置编码(Absolute Positional Encoding)、相对位置编码(Relative Positional Encoding),以及近年来大火的旋转位置编码(Rotary Position Embedding, RoPE)。
一、为什么需要位置编码
先举个简单的例子:
句子1:我 吃 苹果
句子2:苹果 吃 我
这两句话的单词完全一样,但语义天差地别。如果没有位置编码,Transformers的自注意力机制只会关注"哪些单词相关",却不知道"这些单词在什么位置",自然无法区分这两句话。
位置编码的作用,就是给每个单词添加"位置信息",让模型知道"谁在前、谁在后",从而理解语序带来的语义变化------使每个token都有自己的位置(第1位、第2位...)。
二、绝对位置编码
绝对位置编码的思路:给句子里的每个位置,分配一个唯一的"位置向量",这个向量和单词本身的词向量相加,就能让模型知道"这个单词在第几个位置"。
为什么要相加而不是相乘或拼接?
1.如果采用拼接,后期经过线性变换回原来的维度,增加模型的参数量;如果采用相乘,则会引入更多的计算量,增加模型的训练时间。
2.在高维空间中,两个向量呈现出正交性,即他们的点乘向量为0。
3.相加操作提供了更优的梯度传播路径。在反向传播时,损失函数的梯度可以无阻碍地同时反向传播到词嵌入和位置编码分支。如果拼接,则会引入投影向量造成梯度消失或者梯度爆炸;如果相乘,则两者梯度会产生依赖,使得计算过程变得复杂。
在经典的BERT、GPT-1等模型中,绝对位置编码主要有两种实现方式,分为可学习的绝对位置编码和固定的绝对位置编码:
2.1可学习的绝对位置编码
核心逻辑:初始化一个形状为「max_seq_len × d_model」的位置矩阵(max_seq_len是最大序列长度,d_model是词向量维度),该矩阵作为可训练参数,与词向量逐元素相加。
python
import torch
import torch.nn as nn
class LearnablePositionalEncoding(nn.Module):
def __init__(self, d_model, max_seq_len=512):
super().__init__()
self.pos_emb = nn.Parameter(torch.randn(max_seq_len, d_model))
def forward(self, x):
# x: [batch_size, seq_len, d_model]
seq_len = x.shape[1]
return x + self.pos_emb[:seq_len, :]
2.2固定的绝对位置编码
Transformer原论文使用的方式,提前用正弦/余弦函数生成位置向量,无需训练。对于位置为pos的单词,其位置向量的第i维有以下编码规则:
PE(posⓜ,2i)=sin(pos/10000^(2i/d_model ) ) (i为偶数)
PE(posⓜ,2i+1)=cos(pos/10000^(2i/d_model ) ) (i为奇数)
公式解读:pos是单词在句子中的位置(从0或1开始),d_model是词向量维度,通过不同频率的正弦/余弦函数,让不同位置的向量具有唯一性,且位置越近,向量相似度越高。
为什么这里要用正弦/余弦函数表示位置信息?
1.正弦/余弦函数频率递减设计,保证了位置相近时,变化范围小。
2.正弦/余弦函数具有合角公式,sin(a+k)=sin(a)×cos(k)+sin(k)×cos(a),sin(a-k)=sin(a)cos(k)-sin(k)cos(a),cos(a-k)=cos(a)cos(k)+sin(a)sin(k),cos(a+k)=cos(a)cos(k)-sin(a)sin(k),因此可以用绝对位置编码来表示相对位置信息。
3.函数平滑、连续,不会出现梯度消失/爆炸的情况。
python
import torch
import math
class SinCosPositionalEncoding(nn.Module):
def __init__(self, d_model, max_seq_len=512):
super().__init__()
pos_enc = torch.zeros(max_seq_len, d_model)
pos = torch.arange(0, max_seq_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pos_enc[:, 0::2] = torch.sin(pos * div_term)
pos_enc[:, 1::2] = torch.cos(pos * div_term)
pos_enc = pos_enc.unsqueeze(0)
self.register_buffer('pos_enc', pos_enc)
def forward(self, x):
# x: [batch_size, seq_len, d_model]
return x + self.pos_enc[:, :x.shape[1], :]
绝对位置编码实现简单、容易训练,能清晰地告诉模型"每个单词的具体位置",在短序列任务(比如句子分类、情感分析)中表现很好。但是其灵活性差,无法捕捉"相对位置关系"。比如"我 爱 西瓜"和"西瓜 爱 我"中,"爱"分别在第2位和第3位,绝对位置编码会认为这两个"爱"的位置完全不同,但实际上它们的"相对位置"(前后都是两个单词)是相似的;另外,当序列很长时,模型很难记住"第100位"和"第101位"的差异,位置信息会逐渐弱化。
三、相对位置编码
绝对位置编码的思路:不关注单词的"绝对位置",只关注单词之间的"相对距离";绝对位置编码关注"你是第5位",而相对位置编码关注"你在我前面1位""他在我后面2位",只需要知道"你前面的人是谁、后面的人是谁",就能知道自己的相对位置------这就是相对位置编码的核心。
相对位置编码的实现核心是在自注意力计算中融入"相对距离"信息,公式如下:

其中:R 是相对位置偏置矩阵,形状为「seq_len × seq_len」,R_(i,j) 代表第i个单词与第j个单词的相对距离对应的偏置值(提前预定义或可训练)。
python
import torch
import torch.nn as nn
class RelativePositionalEncoding(nn.Module):
def __init__(self, d_model, max_seq_len=512):
super().__init__()
self.d_model = d_model
# 预定义相对位置偏置(范围:-max_seq_len+1 到 max_seq_len-1)
self.max_rel_dist = max_seq_len - 1
self.rel_pos_bias = nn.Parameter(torch.randn(2 * self.max_rel_dist + 1, d_model))
def get_rel_pos(self, seq_len):
# 计算每个位置对的相对距离(i - j)
pos = torch.arange(seq_len, dtype=torch.long).unsqueeze(0) - torch.arange(seq_len, dtype=torch.long).unsqueeze(1)
# 映射到0~2*max_rel_dist的范围(避免负索引)
pos = pos.clamp(-self.max_rel_dist, self.max_rel_dist) + self.max_rel_dist
return pos
def forward(self, q, k):
# q: [batch_size, num_heads, seq_len_q, d_k]
# k: [batch_size, num_heads, seq_len_k, d_k]
seq_len_q, seq_len_k = q.shape[2], k.shape[2]
# 获取相对位置矩阵
rel_pos = self.get_rel_pos(max(seq_len_q, seq_len_k))
# 获取相对位置偏置
rel_bias = self.rel_pos_bias[rel_pos[:seq_len_q, :seq_len_k]]
# 计算注意力权重(简化版,忽略多头细节)
attn = torch.matmul(q, k.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_model, dtype=torch.float))
# 加入相对位置偏置
attn += rel_bias.unsqueeze(0).unsqueeze(0)
return attn
举个例子:句子"我 爱 吃 西瓜"中,"爱"和"我"的相对距离是1("我"在"爱"前面1位),"爱"和"吃"的相对距离是1("吃"在"爱"后面1位),"爱"和"西瓜"的相对距离是2("西瓜"在"爱"后面2位)------相对位置编码会用不同的向量表示"相对距离1""相对距离2",从而让模型捕捉到这种关系。
相对位置编码灵活性强,能捕捉单词之间的相对关系,更符合人类语言习惯;在长序列任务(比如文本生成、长文档理解)中表现更好,因为即使序列很长,"相对距离"依然清晰(比如"第100位"和"第101位"的相对距离是1,和"第2位"与"第3位"的相对距离一样,模型能轻松捕捉)。但是其实现更复杂,训练难度稍高;在某些需要"绝对位置"的任务(比如句子排序)中,表现不如绝对位置编码;相对位置偏置矩阵的维度会随序列长度增加而增大,占用更多内存。
四、旋转位置编码(RoPE)
RoPE(Rotary Position Embedding,旋转位置编码)是近年来最流行的位置编码方式,被LLaMA、ChatGLM、Qwen等主流大模型广泛采用------它的核心优势是:使用绝对位置信息能够表示相对位置关系,同时解决了长序列位置信息弱化、内存占用高的问题。
类比一下:如果说绝对位置编码是"给每个单词贴固定学号",相对位置编码是"记录单词之间的距离",那么RoPE就是"给每个单词的词向量做'旋转'"------位置越靠后,旋转角度越大,两个单词的相对位置,就对应它们词向量的"旋转角度差",既知道"各自转了多少度(绝对位置)",也知道"彼此差多少度(相对位置)"。
RoPE的核心原理:通过旋转矩阵对词向量(或注意力中的Q、K向量)进行旋转,旋转角度与单词的绝对位置正相关,从而将位置信息融入词向量中。
对于位置为m的单词,其词向量的第2i维和第2i+1维(成对旋转),旋转矩阵如下:


其中:θi=10000^(-2i/d_model)(和正弦余弦编码的频率一致),m是token在句子中的绝对位置。
上述公式,可能对于理解旋转位置编码还是有些困难,下面是一个具体的案例:
对于嵌入维度 d=4,我们把维度分成 2 组两两配对的维度:(0,1) 和 (2,3),每组对应一个独立的旋转角度。
设位置为 t,则第 i 组(i=0,1)的旋转角度为:

第 0 组(维度 0,1):
第 1 组(维度 2,3):
位置 t 的旋转矩阵是块对角矩阵:

query向量与key向量之间的内积:
先计算两个旋转矩阵之间的乘积:

令Δ=m−n


根据上述表达式可以看到,整个内积结果只和原始词嵌入xm,xn(语义信息)和相对位置差 Δ=m−n(位置信息)相关,完全不依赖绝对位置 m 或 n 本身,完美验证了等式:

这个等式是怎么来的呢?下面是其详细推导过程
对于query向量的映射矩阵,假设嵌入维度为2,则有以下等式:

Wq是一个2×2的矩阵,xm是一个2×1的矩阵:

根据复数的性质,[m,n]=[m+in],则有以下等式:


继续展开:


同理,那么key向量按照同样的操作:

根据自注意力机制计算公式,query向量与key向量的转置相乘的结果:

根据三角函数的积化和差公式,对上述公式进行合并:

至此,融合m的query向量与融合n的key向量乘积可以准确表示为融合m、n以及m-n的相对位置信息。
其旋转位置编码代码为:
python
import torch
import math
class RotaryPositionalEncoding(nn.Module):
def __init__(self, d_model, max_seq_len=512):
super().__init__()
self.d_model = d_model
# 计算每个维度对的旋转频率 theta_i
theta = 1.0 / (10000.0 ** (torch.arange(0, d_model, 2, dtype=torch.float32) / d_model))
self.register_buffer('theta', theta)
def forward(self, x):
# x: [batch_size, seq_len, d_model]
seq_len = x.shape[1]
# 生成绝对位置 pos(0到seq_len-1)
pos = torch.arange(seq_len, dtype=torch.float32, device=x.device).unsqueeze(1)
# 计算旋转角度:pos * theta,形状为 [seq_len, d_model/2]
angles = pos * self.theta
# 生成旋转矩阵的余弦和正弦(重复一次,适配所有维度)
cos_angles = torch.cos(angles).repeat_interleave(2, dim=1)
sin_angles = torch.sin(angles).repeat_interleave(2, dim=1)
# 对词向量进行旋转(成对旋转)
x_rot = torch.empty_like(x)
x_rot[:, :, 0::2] = x[:, :, 0::2] * cos_angles - x[:, :, 1::2] * sin_angles
x_rot[:, :, 1::2] = x[:, :, 0::2] * sin_angles + x[:, :, 1::2] * cos_angles
return x_rot
五、总结
三种位置编码,它们没有"谁更好",只有"谁更适合"具体的任务和场景。
①如果是短序列、不需要太多长距离依赖(比如给句子打标签),用绝对位置编码就足够了,简单高效;
位置外推失效:训练时最大序列长度是固定的(比如 512/1024),位置向量只学到了这个范围内的模式。当测试序列超过这个长度(比如
2048),模型从未见过的新位置向量会破坏注意力分布,性能急剧下降。 绝对位置干扰:注意力分数 ⟨qm,kn⟩ 会混入 pm 和 pn 的绝对位置信息,模型会混淆 "语义关联" 和 "绝对位置",无法泛化到不同长度的序列。
②如果是长序列、需要捕捉单词之间的相对关系(比如写文章、理解长文档),且模型规模不大,用相对位置编码即可;
长序列显存爆炸:需要维护一个大小为 L×L 的相对位置偏置矩阵(L 是序列长度),当 L 达到 10k+
时,显存占用呈平方级增长,完全不可行。 长序列上限固定:训练时会预设最大相对位置差(比如
±512),超过这个范围的相对位置会被截断,长序列中远距离 token 的位置信息丢失。
③如果是大模型训练、长序列生成(比如ChatGPT类应用),追求性能和效率,RoPE旋转位置编码是首选,兼顾所有优势。
- 天然支持无限外推
无固定长度限制:RoPE 用旋转角度 θi=10000−2i/d 定义位置信息,位置 t 对应的旋转角度是
tθi,不需要预定义最大序列长度。 数学上可无限延伸:无论 t
多大,都能计算出对应的旋转矩阵,测试时序列长度可以远超训练时的长度,不会出现 "未知位置向量" 的问题。- 纯相对位置依赖
内积只与相对位置有关:如你之前推导的,⟨fq(xm,m),fk(xn,n)⟩=g(xm,xn,m−n),注意力分数完全由语义和相对位置差决定,与绝对位置
m,n 无关。 泛化性极强:在短序列上学到的 "相对位置模式"(比如
"前两个词""后三个词")可以直接迁移到长序列,模型不需要重新学习长距离位置关系。- 显存友好
无额外大矩阵:RoPE 是对词嵌入的逐元素旋转操作,只在计算 Q/K 时动态应用,不需要维护额外的位置偏置矩阵,显存占用和原始
Transformer 几乎一致,线性复杂度 O(Ld),完美支撑超长序列(比如 100k+)
另外,现在很多主流模型(比如LLaMA、GPT-4)都会基于RoPE做优化,进一步提升长序列处理能力,比如LLaMA 2的RoPE扩展、ChatGLM的动态RoPE等,本质上都是在保留RoPE核心优势的基础上,解决更多场景的需求。