多模态大模型学习笔记(十三)------Transformer学习之位置编码
在Transformer架构中,自注意力机制本身是"无序"的------它只关注Token之间的语义关联,无法感知Token在序列中的先后顺序。而位置编码(Positional Encoding, PE)正是为了弥补这一缺陷,将位置信息注入到模型中,让Transformer能像人类一样理解"先有因后有果"的序列逻辑。
这篇笔记将从绝对位置编码、相对位置编码、旋转位置编码(RoPE) 三类核心方案入手,由浅入深地讲解其设计思想、数学原理,并补充可直接运行的经典代码实现,彻底吃透位置编码的演进脉络。

1. 为什么Transformer需要位置编码?
1.1 自注意力的"无序"本质
自注意力机制的核心是计算Token之间的注意力分数:
Attention(Q,K,V)=softmax(QKTd)V \text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d}}\right)V Attention(Q,K,V)=softmax(d QKT)V
这个公式只依赖于Query和Key的向量点积,与Token在序列中的位置无关。比如句子"我爱中国"和"中国爱我",在自注意力机制下的计算结果完全一致,但语义却截然不同------这显然不符合人类对语言的理解方式。
1.2 位置编码的核心作用
位置编码的核心作用,就是给每个Token注入位置信息,让模型能区分Token的先后顺序,从而理解序列的语义结构。它是Transformer能处理文本、图像、音频等序列数据的关键基础。
2. 绝对位置编码:最经典的"位置注入"方案
绝对位置编码是Transformer原生采用的方案,核心思想是:给每个位置生成一个固定的位置向量,直接与Token Embedding相加,让位置信息融入到Token的语义表示中。
2.1 核心公式与实现
对于序列中第 pospospos 个位置(pos∈[0,seq_len−1]pos \in [0, \text{seq\len}-1]pos∈[0,seq_len−1])、向量的第 2i2i2i 和 2i+12i+12i+1 维,位置编码的计算公式为:
PEpos,2i=sin(pos100002i/dmodel) PE{pos, 2i} = \sin\left( \frac{pos}{10000^{2i / d_{\text{model}}}} \right) PEpos,2i=sin(100002i/dmodelpos)
PEpos,2i+1=cos(pos100002i/dmodel) PE_{pos, 2i+1} = \cos\left( \frac{pos}{10000^{2i / d_{\text{model}}}} \right) PEpos,2i+1=cos(100002i/dmodelpos)
其中:
- dmodeld_{\text{model}}dmodel 是Transformer的隐藏层维度(如BERT-base的768维);
- iii 是维度索引,范围为 [0,dmodel/2−1][0, d_{\text{model}}/2 - 1][0,dmodel/2−1]。
最终,Transformer的输入嵌入为:
xt=et+pt x_t = e_t + p_t xt=et+pt
- ete_tet:第 ttt 个Token的Token Embedding;
- ptp_tpt:第 ttt 个位置的位置编码向量。
2.2 经典代码实现(PyTorch版)
python
import torch
import math
class AbsolutePositionalEncoding(torch.nn.Module):
def __init__(self, d_model: int, max_len: int = 512):
super().__init__()
self.d_model = d_model
# 初始化位置编码矩阵 [max_len, d_model]
pe = torch.zeros(max_len, d_model)
# 生成位置索引 [max_len, 1]
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
# 计算分母项,避免重复计算
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
# 偶数维度用sin,奇数维度用cos
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
# 注册为缓冲区(不参与训练)
self.register_buffer('pe', pe.unsqueeze(0)) # [1, max_len, d_model]
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
参数:
x: 输入Token Embedding,形状 [batch_size, seq_len, d_model]
返回:
注入位置信息后的Embedding,形状 [batch_size, seq_len, d_model]
"""
# 只取与输入序列长度匹配的位置编码
x = x + self.pe[:, :x.size(1), :]
return x
# 测试代码
if __name__ == "__main__":
# 初始化绝对位置编码层(768维,最大序列长度512)
pe_layer = AbsolutePositionalEncoding(d_model=768, max_len=512)
# 模拟输入:batch_size=2,seq_len=10,d_model=768
token_emb = torch.randn(2, 10, 768)
# 注入位置编码
output = pe_layer(token_emb)
print(f"输入形状: {token_emb.shape}")
print(f"输出形状: {output.shape}")
print(f"位置编码矩阵形状: {pe_layer.pe.shape}")
2.3 设计思想与优势
- 三角函数的周期性 :通过正弦和余弦函数的周期性,让模型能学习到Token之间的相对位置关系。比如位置 pospospos 和 pos+kpos+kpos+k 的位置编码,可以通过三角函数的和角公式关联起来,让模型感知"间隔k个位置"的相对关系。
- 可外推性:位置编码的计算不依赖于训练时的序列长度,因此模型可以处理训练中未见过的更长序列(比如训练时最大长度512,推理时可处理长度1024的文本)。
- 实现简单:直接与Token Embedding相加,无需修改自注意力机制的核心逻辑,兼容性极强。
2.4 核心局限
- 绝对位置的限制:原生绝对位置编码只编码了"绝对位置",对"相对位置"的建模能力较弱,在长文本场景下,位置信息的区分度会逐渐衰减。
- 无法适配跨模态场景:在多模态大模型中,文本、图像、音频的序列长度差异极大,绝对位置编码的外推能力会受到限制。
3. 相对位置编码:关注"间隔"而非"坐标"
相对位置编码的核心思想是:不再编码Token的绝对位置,而是编码Token之间的相对距离,让注意力分数直接依赖于Token的相对位置,更贴合人类对序列的理解方式。
3.1 核心公式与实现
在自注意力机制的注意力分数计算中,引入相对位置偏置项 b(i−j)b(i-j)b(i−j):
score(i,j)=qi⋅kjd+b(i−j) \text{score}_{(i,j)} = \frac{q_i \cdot k_j}{\sqrt{d}} + b(i-j) score(i,j)=d qi⋅kj+b(i−j)
其中:
- qiq_iqi:第 iii 个Token的Query向量;
- kjk_jkj:第 jjj 个Token的Key向量;
- b(i−j)b(i-j)b(i−j):相对位置偏置项,代表第 iii 个Token和第 jjj 个Token之间的相对位置信息,可学习或固定。
3.2 经典代码实现(PyTorch版)
python
import torch
import torch.nn as nn
import math
class RelativePositionBias(nn.Module):
def __init__(self, num_heads: int, max_rel_dist: int = 128):
super().__init__()
self.num_heads = num_heads
self.max_rel_dist = max_rel_dist
# 可学习的相对位置偏置矩阵 [num_heads, 2*max_rel_dist + 1]
self.rel_pos_bias = nn.Embedding(2 * max_rel_dist + 1, num_heads)
# 预计算相对位置索引
self.register_buffer("rel_pos_indices", self._compute_rel_pos_indices())
def _compute_rel_pos_indices(self):
"""计算相对位置索引,范围 [-max_rel_dist, max_rel_dist]"""
# 生成位置范围
coords = torch.arange(self.max_rel_dist)
# 计算相对位置矩阵 [max_rel_dist, max_rel_dist]
rel_pos = coords[:, None] - coords[None, :]
# 映射到非负索引(偏移max_rel_dist)
rel_pos += self.max_rel_dist
# 限制范围,避免越界
rel_pos = torch.clamp(rel_pos, 0, 2 * self.max_rel_dist)
return rel_pos
def forward(self, seq_len: int) -> torch.Tensor:
"""
参数:
seq_len: 输入序列长度
返回:
相对位置偏置,形状 [num_heads, seq_len, seq_len]
"""
# 截取与当前序列长度匹配的索引
rel_pos_indices = self.rel_pos_indices[:seq_len, :seq_len]
# 获取偏置值 [seq_len, seq_len, num_heads]
rel_bias = self.rel_pos_bias(rel_pos_indices)
# 调整维度 [num_heads, seq_len, seq_len]
rel_bias = rel_bias.permute(2, 0, 1)
return rel_bias
# 带相对位置编码的自注意力层
class RelativeAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int, max_rel_dist: int = 128):
super().__init__()
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_pos_bias = RelativePositionBias(num_heads, max_rel_dist)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
参数:
x: 输入张量,形状 [batch_size, seq_len, d_model]
返回:
注意力输出,形状 [batch_size, seq_len, d_model]
"""
batch_size, seq_len, _ = x.shape
# 投影到Q/K/V [batch_size, seq_len, d_model]
q = self.q_proj(x)
k = self.k_proj(x)
v = self.v_proj(x)
# 分拆多头 [batch_size, num_heads, seq_len, head_dim]
q = q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
k = k.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
v = v.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
# 计算注意力分数 [batch_size, num_heads, seq_len, seq_len]
attn_scores = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
# 添加相对位置偏置
rel_bias = self.rel_pos_bias(seq_len) # [num_heads, seq_len, seq_len]
attn_scores += rel_bias.unsqueeze(0) # 广播到batch维度
# 计算注意力权重
attn_weights = torch.softmax(attn_scores, dim=-1)
# 计算注意力输出 [batch_size, num_heads, seq_len, head_dim]
attn_output = attn_weights @ v
# 拼接多头 [batch_size, seq_len, d_model]
attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
# 最终投影
output = self.out_proj(attn_output)
return output
# 测试代码
if __name__ == "__main__":
# 初始化相对位置注意力层
rel_attn = RelativeAttention(d_model=768, num_heads=12, max_rel_dist=128)
# 模拟输入:batch_size=2,seq_len=10,d_model=768
x = torch.randn(2, 10, 768)
# 前向传播
output = rel_attn(x)
print(f"输入形状: {x.shape}")
print(f"输出形状: {output.shape}")
3.3 设计思想与优势
- 聚焦相对关系:直接建模Token之间的"间隔",而非"坐标",更符合语言中"语序决定语义"的本质。比如"我爱中国"中,"我"和"中国"的相对位置是"前-后",而"中国爱我"中是"后-前",模型能通过相对位置偏置清晰区分。
- 长文本适配性更强:相对位置编码对长文本的位置信息建模更稳定,不会像绝对位置编码那样随着序列长度增加而衰减。
- 可学习性:相对位置偏置项可以通过训练学习,适配不同任务的位置依赖关系(比如翻译任务中,源语言和目标语言的相对位置关系不同)。
3.4 核心局限
- 实现复杂度高:需要修改自注意力机制的核心计算逻辑,兼容性不如绝对位置编码。
- 外推能力受限:如果相对位置偏置项的最大间隔是固定的(比如最大支持间隔512),当处理更长序列时,超出间隔的位置信息无法编码,外推能力受限。
4. 旋转位置编码(RoPE):兼顾绝对与相对的"最优解"
旋转位置编码(Rotary Positional Embedding, RoPE)是目前大模型(如GPT-3、LLaMA、Qwen)的主流方案,核心思想是:通过旋转矩阵给Query和Key向量注入位置信息,让注意力分数天然包含相对位置关系,同时保留绝对位置的感知能力,兼顾了绝对位置编码的外推性和相对位置编码的精准性。
4.1 核心公式与实现
对于第 ttt 个位置的Query向量 qtq_tqt 和Key向量 ktk_tkt,将其按维度两两分组(第 2r2r2r 和 2r+12r+12r+1 维),通过旋转矩阵进行变换:
q2rq2r+1\]↦\[q2rcosθt−q2r+1sinθtq2rsinθt+q2r+1cosθt\] \\begin{bmatrix} q_{2r} \\\\ q_{2r+1} \\end{bmatrix} \\mapsto \\begin{bmatrix} q_{2r}\\cos\\theta_t - q_{2r+1}\\sin\\theta_t \\\\ q_{2r}\\sin\\theta_t + q_{2r+1}\\cos\\theta_t \\end{bmatrix} \[q2rq2r+1\]↦\[q2rcosθt−q2r+1sinθtq2rsinθt+q2r+1cosθt
k2rk2r+1\]↦\[k2rcosθt−k2r+1sinθtk2rsinθt+k2r+1cosθt\] \\begin{bmatrix} k_{2r} \\\\ k_{2r+1} \\end{bmatrix} \\mapsto \\begin{bmatrix} k_{2r}\\cos\\theta_t - k_{2r+1}\\sin\\theta_t \\\\ k_{2r}\\sin\\theta_t + k_{2r+1}\\cos\\theta_t \\end{bmatrix} \[k2rk2r+1\]↦\[k2rcosθt−k2r+1sinθtk2rsinθt+k2r+1cosθt
其中,旋转角度 θt\theta_tθt 定义为:
θt=t⋅θ0 \theta_t = t \cdot \theta_0 θt=t⋅θ0
θ0=10000−2r/dmodel \theta_0 = 10000^{-2r/d_{\text{model}}} θ0=10000−2r/dmodel
变换后的Query和Key向量点积,天然包含了相对位置信息:
qiTkj=q~iTk~j⋅cos((i−j)θ0)+其他项 q_i^T k_j = \tilde{q}_i^T \tilde{k}_j \cdot \cos((i-j)\theta_0) + \text{其他项} qiTkj=q~iTk~j⋅cos((i−j)θ0)+其他项
其中 q~i\tilde{q}_iq~i 和 k~j\tilde{k}_jk~j 是未旋转的向量,点积结果与相对位置 (i−j)(i-j)(i−j) 直接相关,让注意力分数自然融入了位置依赖。
4.2 经典代码实现(PyTorch版)
python
import torch
import torch.nn as nn
import math
class RotaryPositionalEncoding(nn.Module):
def __init__(self, d_model: int, max_len: int = 2048):
super().__init__()
self.d_model = d_model
self.max_len = max_len
# 预计算旋转角度 [d_model//2]
theta = 1.0 / (10000.0 ** (torch.arange(0, d_model, 2).float() / d_model))
self.register_buffer("theta", theta)
# 预计算位置索引 [max_len]
positions = torch.arange(0, max_len, dtype=torch.float)
self.register_buffer("positions", positions)
# 预计算旋转矩阵的cos和sin值 [max_len, d_model//2]
self._compute_rotary_matrices()
def _compute_rotary_matrices(self):
"""预计算cos和sin矩阵"""
# 计算角度 [max_len, d_model//2]
angles = self.positions.unsqueeze(1) * self.theta.unsqueeze(0)
# 计算cos和sin值
cos_angles = torch.cos(angles)
sin_angles = torch.sin(angles)
self.register_buffer("cos_angles", cos_angles)
self.register_buffer("sin_angles", sin_angles)
def rotate_half(self, x: torch.Tensor) -> torch.Tensor:
"""将向量按维度两两分组,后半部分翻转"""
# x: [batch_size, seq_len, num_heads, head_dim]
x1 = x[..., :x.shape[-1]//2]
x2 = x[..., x.shape[-1]//2:]
return torch.cat([-x2, x1], dim=-1)
def forward(self, x: torch.Tensor, seq_len: int) -> torch.Tensor:
"""
对输入向量应用旋转位置编码
参数:
x: 输入向量(Q/K),形状 [batch_size, seq_len, num_heads, head_dim]
seq_len: 当前序列长度
返回:
旋转后的向量,形状 [batch_size, seq_len, num_heads, head_dim]
"""
# 获取当前序列长度对应的cos和sin值 [seq_len, head_dim//2]
cos = self.cos_angles[:seq_len, :].unsqueeze(0).unsqueeze(2)
sin = self.sin_angles[:seq_len, :].unsqueeze(0).unsqueeze(2)
# 扩展维度以匹配head_dim
cos = cos.repeat(1, 1, 1, 2) # [1, seq_len, 1, head_dim]
sin = sin.repeat(1, 1, 1, 2) # [1, seq_len, 1, head_dim]
# 应用旋转
x_rotated = x * cos + self.rotate_half(x) * sin
return x_rotated
# 带RoPE的自注意力层
class RoPEAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int, max_len: int = 2048):
super().__init__()
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)
# RoPE层
self.rope = RotaryPositionalEncoding(self.head_dim, max_len)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
参数:
x: 输入张量,形状 [batch_size, seq_len, d_model]
返回:
注意力输出,形状 [batch_size, seq_len, d_model]
"""
batch_size, seq_len, _ = x.shape
# 投影到Q/K/V [batch_size, seq_len, d_model]
q = self.q_proj(x)
k = self.k_proj(x)
v = self.v_proj(x)
# 分拆多头 [batch_size, seq_len, num_heads, head_dim]
q = q.view(batch_size, seq_len, self.num_heads, self.head_dim)
k = k.view(batch_size, seq_len, self.num_heads, self.head_dim)
v = v.view(batch_size, seq_len, self.num_heads, self.head_dim)
# 应用RoPE旋转
q_rot = self.rope(q, seq_len)
k_rot = self.rope(k, seq_len)
# 调整维度用于计算 [batch_size, num_heads, seq_len, head_dim]
q_rot = q_rot.transpose(1, 2)
k_rot = k_rot.transpose(1, 2)
v = v.transpose(1, 2)
# 计算注意力分数 [batch_size, num_heads, seq_len, seq_len]
attn_scores = (q_rot @ k_rot.transpose(-2, -1)) / math.sqrt(self.head_dim)
# 计算注意力权重
attn_weights = torch.softmax(attn_scores, dim=-1)
# 计算注意力输出 [batch_size, num_heads, seq_len, head_dim]
attn_output = attn_weights @ v
# 拼接多头 [batch_size, seq_len, d_model]
attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
# 最终投影
output = self.out_proj(attn_output)
return output
# 测试代码
if __name__ == "__main__":
# 初始化RoPE注意力层
rope_attn = RoPEAttention(d_model=768, num_heads=12, max_len=2048)
# 模拟输入:batch_size=2,seq_len=10,d_model=768
x = torch.randn(2, 10, 768)
# 前向传播
output = rope_attn(x)
print(f"输入形状: {x.shape}")
print(f"输出形状: {output.shape}")
4.3 设计思想与优势
- 兼顾绝对与相对:通过旋转矩阵注入绝对位置信息,同时让注意力分数天然依赖于相对位置,完美解决了传统位置编码的缺陷。
- 强外推性:与原生绝对位置编码一样,RoPE的计算不依赖于训练时的序列长度,可轻松外推到更长的文本序列。
- 工业级适配:无需修改自注意力机制的核心结构,仅对Query和Key向量做变换,兼容性极强,已成为GPT-3、LLaMA等大模型的标配。
4.4 核心局限
- 实现细节复杂:需要对Query和Key向量按维度分组并进行旋转操作,工程实现上比绝对位置编码更复杂。
- 对硬件要求高:旋转操作会增加一定的计算量,在端侧设备上部署时需要优化。
5. 位置编码在多模态大模型中的拓展
随着多模态大模型的发展,位置编码不再局限于文本序列,而是需要适配图像、音频、视频等不同模态的序列结构,核心拓展方向包括:
5.1 图像位置编码
在Vision Transformer(ViT)中,图像被切分为多个Patch(如16×16像素),每个Patch对应一个Token,位置编码的方式与文本一致:
- 绝对位置编码:给每个Patch生成一个固定的位置向量,与Patch Embedding相加。
- 相对位置编码:建模Patch之间的相对距离(如上下左右的空间关系),让模型理解图像的空间结构。
- RoPE:对图像的Query和Key向量进行旋转,注入空间位置信息。
5.2 音频位置编码
在音频Transformer中,音频波形被切分为多个帧,每个帧对应一个Token,位置编码需要适配音频的时序结构:
- 采用RoPE等强外推性的位置编码方案,适配长音频序列(如1小时以上的音频)。
- 引入相对位置偏置,建模音频帧之间的时序依赖(如语音的韵律、节奏)。
5.3 跨模态位置编码
在多模态大模型(如GPT-4V、Qwen-VL)中,文本和图像/音频的Token被拼接成一个统一的序列,位置编码需要同时适配不同模态的位置信息:
- 采用统一的位置编码方案(如RoPE),让模型能理解文本和图像/音频的相对位置关系(如"图中左上角的猫"对应文本中的"猫")。
- 引入模态特有的位置偏置,区分不同模态的Token位置信息。
6. 总结与学习提示
位置编码是Transformer架构的核心基础模块,从绝对位置编码到相对位置编码,再到RoPE,其演进脉络始终围绕"如何更精准地建模序列位置信息"展开。
对于学习和落地的几点提示:
- 入门学习:先从原生绝对位置编码入手,理解位置信息注入的核心逻辑,再逐步深入相对位置编码和RoPE。
- 工业落地:在大模型场景中,优先选择RoPE方案,兼顾外推性和精准性;在跨模态场景中,需根据模态特性适配位置编码方案。
- 进阶优化:在长文本场景中,可通过RoPE的外推优化(如NTK-aware RoPE)提升模型对超长序列的处理能力;在多模态场景中,可引入模态特有的位置偏置提升跨模态对齐精度。
- 代码实践:先跑通本文提供的基础代码,再尝试将位置编码集成到完整的Transformer模型中,通过实际运行理解位置编码的作用。
关键点回顾
- 绝对位置编码通过三角函数生成固定位置向量,实现简单但对长文本支持差,是Transformer原生方案;
- 相对位置编码直接建模Token间的相对距离,语义更精准但实现复杂,外推能力受限;
- RoPE通过旋转矩阵注入位置信息,兼顾绝对/相对位置建模能力,是当前大模型的主流选择,本文提供的代码可直接用于工程实践。