位置编码 (Positional Encoding):给没有顺序的矩阵注入"位置感"
📂 所属阶段:第三阶段 — Transformer 革命(核心篇)
🔗 相关章节:多头注意力 (Multi-Head Attention) · Transformer 完整架构
1. 为什么我们必须加「位置信号」?
1.1 Self-Attention 天生「失序」
Transformer 中 Self-Attention 最让人惊叹的一点,是它可以一口气并行处理整个序列。无论一个句子有多长,所有词都能同时计算彼此之间的注意力权重,彻底摆脱了 RNN “一个词接一个词”的串行限制。
但高效的代价是:注意力矩阵本身完全不知道词的先后顺序。它在做运算时只会关心“这个词和那个词语义上是否相似”,至于谁是主语、谁是宾语、谁在句首、谁在句尾——它一概不问。
举个最直观的例子:
输入1:我 打 你 → 我是施害方,你是受害方
输入2:你 打 我 → 意思完全颠倒!
如果把这两个句子分别输入一个没有位置信息的 Self-Attention,计算出来的注意力权重、语义向量几乎一模一样。模型无法分辨究竟是“我打你”还是“你打我”。这时 Transformer 本质上只是一个 “高级词袋模型”,没法完成翻译、摘要、对话这类必须考虑词序的任务。
1.2 最直接的解决方案:给词贴上“位置标签”
既然 Self-Attention 天生没有顺序感,那我们干脆人工为每个位置生成一个独一无二的 “位置向量” ,然后把它和词本身的语义向量结合起来。这样一来:
- 词向量 负责记住“这个词是什么意思”
- 位置向量 负责记住“这个词在句子的第几个位置”
- 两者相加后,就形成了一种同时具备语义和位置双重信息的最终表示
在原始 Transformer 以及后续大多数模型中,都选择了 直接相加 的方式(而不是拼接),因为拼接会让向量维度翻倍,增加额外的计算量。
绝对位置编码的思路非常直白:给「位置0」「位置1」一直到「位置 N」分别分配一个固定的、唯一的向量。原始 Transformer 的设计者们没有随机生成这些向量,而是使用了一组数学性质极好的正弦与余弦函数组合。
2.1 完整 PyTorch 实现
我们直接搬出一个在实践中优化过的版本:将位置编码存在 buffer 中,既不参与反向传播,又会随模型一起保存;同时加上 Dropout 防止过拟合。
import torch
import torch.nn as nn
import math
class SinCosPositionalEncoding(nn.Module):
"""原始 Transformer 论文中的正弦/余弦绝对位置编码"""
def __init__(self, d_model: int, max_len: int = 5000, dropout: float = 0.1):
"""
Args:
d_model: 每个词的向量维度(论文中为 512)
max_len: 预设的最大序列长度
dropout: 防止过拟合
"""
super().__init__()
self.dropout = nn.Dropout(p=dropout)
# 初始化一个全 0 的位置编码矩阵,形状 (max_len, d_model)
position_enc = torch.zeros(max_len, d_model)
# 位置索引序列 [0, 1, 2, ..., max_len-1] → 形状 (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,实现不同位置向量的唯一性
position_enc[:, 0::2] = torch.sin(position * div_term)
position_enc[:, 1::2] = torch.cos(position * div_term)
# 扩展为 (1, max_len, d_model),方便与 batch 输入相加
position_enc = position_enc.unsqueeze(0)
# 注册为 buffer:不参与训练,但保存到模型文件中
self.register_buffer("position_enc", position_enc)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: 输入词向量,形状 (batch_size, seq_len, d_model)
Returns:
加上位置编码后的向量
"""
# 只取当前序列长度对应的位置编码
x = x + self.position_enc[:, :x.size(1), :]
return self.dropout(x)
2.2 为什么偏偏选正弦和余弦?
你可能会问:“为什么不直接用一组随机生成的固定向量呢?多省事。” 答案是,正弦/余弦函数有三个随机向量不具备的关键优势:
-
相对位置关系可线性表示
这种设计让模型能够较为容易地学到“位置 3 比位置 0 多往后挪了 3 步”这种相对信息。对于翻译、摘要这类需要关注前后词距离的任务,这个特性至关重要。
-
可以泛化到未见过的更长序列
即使训练时设定的 max_len 是 5000,推理时突然来个 6000 词的句子,也完全不用慌——直接用同一套公式算出后面 1000 个位置的编码即可,无需重新训练。
-
计算高效且零额外参数
位置编码在整个训练过程中是固定的,不会产生梯度,也不会占用反向传播的计算资源,推理速度也很快。
3. 更贴合任务的方案:可学习绝对位置编码
正弦/余弦编码虽好,但它毕竟是人工设计好的固定数值,不一定能完美适配所有任务。为此,一种更灵活的方式出现了:直接把位置向量也当成模型参数,和词向量一起端到端训练。
3.1 极简 PyTorch 实现
利用 nn.Embedding,几行代码就能搞定:
import torch
import torch.nn as nn
class LearnedPositionalEncoding(nn.Module):
"""端到端可学习的绝对位置编码"""
def __init__(self, d_model: int, max_len: int = 5000):
"""
Args:
d_model: 向量维度
max_len: 最大序列长度(推理时不能超过此值!)
"""
super().__init__()
# 使用 Embedding 存储可学习的位置向量
self.position_emb = nn.Embedding(max_len, d_model)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: 输入词向量,形状 (batch_size, seq_len, d_model)
Returns:
加上位置编码后的向量
"""
batch_size, seq_len, _ = x.shape
# 生成位置索引,并在 batch 维度上复制
positions = torch.arange(seq_len, device=x.device).unsqueeze(0).repeat(batch_size, 1)
# 取出对应的位置向量并相加
position_vectors = self.position_emb(positions)
return x + position_vectors
3.2 适用场景与局限
在短文本任务(如文本分类、命名实体识别)上,可学习位置编码的效果往往比正弦/余弦编码更好,因为它能根据数据特点自动调整位置表示。
但它有一个硬伤:预设的 max_len 就是天花板。推理时如果出现更长的序列,只能截断或填 0,再也没法像正弦/余弦那样随意外推。
4. 现代大语言模型的标配:RoPE(旋转位置编码)
前面两种绝对位置编码,无论固定还是可学习,都是直接把位置向量“加到”词向量上。当序列长度被拉到几万甚至几十万(如 LLaMA 3.1 的 128K、Claude 3.5 Sonnet 的 200K)时,这种操作容易导致长距离位置信息衰减。
2021 年苏剑林团队提出的 RoPE(Rotary Position Embedding,旋转位置编码) 给出了一个优雅的解法:它不再去“加”一个位置向量,而是直接用旋转矩阵去“转动”注意力机制中的 Q(查询)和 K(键),让相对位置信息自然融入点积运算中。
4.1 核心逻辑(大白话版)
不用纠结复杂的数学推导,掌握两点即可:
- 把 Q 和 K 的向量两两分组(比如维度 512,就分成 256 对)
- 每一对向量都按照各自的角度进行旋转,位置越靠后,旋转角度越大
这样,当我们计算 Q 和 K 的点积(这是注意力权重的核心步骤)时,位置差信息就已经自动被包含在里面了,而且随着距离增大的衰减极其缓慢,特别适合超长上下文场景。
4.2 极简 PyTorch 实现(旋转核心)
import torch
import torch.nn as nn
class RotaryPositionalEmbedding(nn.Module):
"""现代 LLM 标配的 RoPE 旋转位置编码(核心简化版)"""
def __init__(self, head_dim: int, base: int = 10000):
"""
Args:
head_dim: 每个注意力头的向量维度
base: 频率衰减基数(沿用原始 Transformer 的 10000)
"""
super().__init__()
# 预计算频率衰减因子
self.inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2).float() / head_dim))
def forward(self, x: torch.Tensor, seq_len: int = None) -> tuple[torch.Tensor, torch.Tensor]:
"""
Args:
x: Q/K 向量,形状 (batch_size, num_heads, seq_len, head_dim)
seq_len: 当前序列长度
Returns:
cos_emb, sin_emb: 用于旋转的余弦和正弦向量
"""
if seq_len is None:
seq_len = x.size(2)
# 生成位置序列
t = torch.arange(seq_len, device=x.device).type_as(self.inv_freq)
# 计算每个位置、每个维度对的旋转角度
freqs = torch.einsum("i,j->ij", t, self.inv_freq)
# 扩展成与 Q/K 一样的形状
cos_emb = torch.cat([freqs.cos(), freqs.cos()], dim=-1)
sin_emb = torch.cat([freqs.sin(), freqs.sin()], dim=-1)
return cos_emb, sin_emb
💡 实际使用时,我们会用返回的 cos_emb 和 sin_emb 对 Q、K 向量进行旋转,然后再计算注意力。由于这部分属于 Attention 层内部的实现细节,这里不再展开,感兴趣的同学可以查阅文末的原始论文。
5. 三种主流位置编码对比一览
💡 一句话总结:没有位置编码的 Transformer 就等于高级词袋模型,连“你打我”和“我打你”都分不清;到了 2026 年,想做长文本、大模型,直接上 RoPE 就对了。
🔗 扩展阅读