【ML】位置编码
1. 绝对正弦位置编码(Sinusoidal PE)
来源:原始 Transformer(Vaswani et al., 2017)
通过固定的正弦/余弦函数为每个位置生成编码,无需训练参数。
PE(pos, 2i)=sin(pos100002i/dmodel)PE_{(pos,\ 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right)PE(pos, 2i)=sin(100002i/dmodelpos)
PE(pos, 2i+1)=cos(pos100002i/dmodel)PE_{(pos,\ 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{model}}}\right)PE(pos, 2i+1)=cos(100002i/dmodelpos)
其中 pospospos 是序列位置,iii 是维度索引,dmodeld_{model}dmodel 是嵌入维度。max_len(输入的最大序列长度),但它确实决定了模型“能有效区分的最长序列的理论上限”。
position(或者公式里的pos):是句子中“词”的先后顺序(控制的是行)。i:才是嵌入向量内部的特征维度索引(控制的是列)。
为了让你一秒钟想通,我们把位置编码想象成一张 Excel 表格。
终极比喻:一张 Excel 表格
假设我们输入了一句话,只有四个词:“我 爱 学 习”。
模型设定的词向量维度是 d_model = 4(为了举例方便,我们弄得很小)。
我们要算的位置编码矩阵(PE),就是下面这张 Excel 表格:
pos (词的位置) |
i=0 (第0列) |
i=1 (第1列) |
i=2 (第2列) |
i=3 (第3列) |
|---|---|---|---|---|
pos=0 (“我”) |
sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) | sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) |
pos=1 (“爱”) |
sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) | sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) |
pos=2 (“学”) |
sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) | sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) |
pos=3 (“习”) |
sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) | sin(...)\sin(...)sin(...) | cos(...)\cos(...)cos(...) |
公式其实是在问:“在这张表格里,第 pos 行、第 2i 列的那个单一的格子,我该填什么具体的数字进去?”
PE(pos,2i)=sin(pos100002i/dmodel)PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right)PE(pos,2i)=sin(100002i/dmodelpos)
-
分子里的
pos决定了波形的当前位置。(我是第几个词?) -
分母里的
i决定了波形的频率。(我这个格子的列数是多少?列数越往后,频率分母越大,波形变化越慢。) -
pos= 纵坐标 = 行号 = 第几个词。 -
i= 横坐标 = 列号 = 第几个特征维度。
import torch
import math
def sinusoidal_pe(max_len: int, d_model: int) -> torch.Tensor:
"""
返回 shape: (max_len, d_model)
"""
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float()
# 1. 直接生成所有的偶数维度索引 2i (即 0, 2, 4...)
two_i = torch.arange(0, d_model, 2).float() # 形状: (d_model/2,)
# 2. 完美复刻原公式的分母:10000 的 (2i / d_model) 次方
denominator = 10000.0 ** (two_i / d_model) # 形状: (d_model/2,)
# 3. 拒绝花里胡哨,直接用 position 除以分母!
pe[:, 0::2] = torch.sin(position / denominator) # 偶数维度
pe[:, 1::2] = torch.cos(position / denominator) # 奇数维度
# (max_len, 1)
# div_term = torch.exp(
# torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
) # (d_model/2,)
# 这生成了 [0, 2, 4, ...] 的序列。
# pe[:, 0::2] = torch.sin(position * div_term) # 偶数维度
# pe[:, 1::2] = torch.cos(position * div_term) # 奇数维度
return pe
# 示例
pe = sinusoidal_pe(max_len=512, d_model=128)
print(pe.shape) # torch.Size([512, 128])
正弦和余弦并没有分成两个单独的矩阵,而是被“穿插”拼成了同一个矩阵,并且这个矩阵的形状和词嵌入矩阵一模一样。
让我们一步步把这个过程拆解开来看:
正弦和余弦是如何“拼”在一起的?
回忆一下我们刚才的代码:
pe[:, 0::2] = torch.sin(...) # 偶数列
pe[:, 1::2] = torch.cos(...) # 奇数列
这意味着,对于任意一个位置(比如第0个词),它的位置编码向量是正弦和余弦交替排列的。
假设我们的维度 d_model = 4(只有4个维度),那么某一个位置的位置编码向量看起来是这样的:[ 正弦值1, 余弦值1, 正弦值2, 余弦值2 ]
它的总长度刚好等于 d_model。
词嵌入(Word Embedding)长什么样?
当你把一个词(比如“我”)输入模型时,嵌入层(Embedding Layer)会把它变成一个固定长度的向量。
如果 d_model = 4,那么“我”的词嵌入向量可能长这样:[ 0.5, -0.2, 0.8, 0.1 ]
如何相加?
因为位置编码向量和词嵌入向量的长度完全一样(都是 d_model),所以把它们对应的元素直接加起来就可以了:
[0.5−0.20.80.1](词嵌入)+[sin1cos1sin2cos2](位置编码)=[0.5+sin1−0.2+cos10.8+sin20.1+cos2](最终输入向量)\begin{bmatrix} 0.5 \\ -0.2 \\ 0.8 \\ 0.1 \end{bmatrix} \text{(词嵌入)} \quad \mathbf{+} \quad \begin{bmatrix} \sin_1 \\ \cos_1 \\ \sin_2 \\ \cos_2 \end{bmatrix} \text{(位置编码)} \quad \mathbf{=} \quad \begin{bmatrix} 0.5 + \sin_1 \\ -0.2 + \cos_1 \\ 0.8 + \sin_2 \\ 0.1 + \cos_2 \end{bmatrix} \text{(最终输入向量)} 0.5−0.20.80.1 (词嵌入)+ sin1cos1sin2cos2 (位置编码)= 0.5+sin1−0.2+cos10.8+sin20.1+cos2 (最终输入向量)
代码演示
在实际的 Transformer 模型(如 PyTorch 实现)中,这一步的代码非常简单,就是一行加法:
# 假设当前输入的句子长度是 seq_len
# 1. 获取词嵌入,形状:(batch_size, seq_len, d_model)
word_embeddings = embedding_layer(input_ids)
# 2. 截取我们需要长度的位置编码,形状:(seq_len, d_model)
# (我们之前生成了最大长度 max_len 的 pe,现在只取前 seq_len 个)
pos_embeddings = pe[:seq_len, :]
# 3. 直接相加!
# (利用 PyTorch 的广播机制,pos_embeddings 会自动应用到 batch 中的每一句话)
final_embeddings = word_embeddings + pos_embeddings
💡 进阶思考:直接相加,不会把原来的词义破坏掉吗?
很多人第一次看到这里都会有疑问:词嵌入代表了词的意思(比如“苹果”),直接加上一堆正弦余弦值,难道不会把“苹果”变成别的词吗?为什么不是用拼接(Concatenation)呢?
答案是:在高维空间中,这几乎不会破坏词义。
- 维度够大:在真正的模型中,
d_model通常是 512, 768 甚至上万。在如此高维的空间里,词义特征和位置特征可以被看作是“近似正交”的。模型在后续的线性变换中,完全有能力把“词义信息”和“位置信息”重新分离开来。 - 节省参数和计算量:如果把 512 维的词嵌入和 512 维的位置编码拼接在一起,维度就变成了 1024 维,这会让后续的网络参数量直接翻倍。直接相加是一种极其优雅且节省算力的做法。
你可以把它理解为:词嵌入是这个人的“身份证”,位置编码是这个人衣服上贴的“座位号”。直接相加就像是把座位号贴在了身份证上——虽然外观变了一点,但门卫(Transformer的注意力机制)依然能同时认出他是谁,以及他坐在哪里。
2. 可学习位置编码(Learned PE)
来源:BERT、GPT 等
直接将位置索引映射到一个可训练的嵌入矩阵,简单灵活,但不能外推到训练时未见过的长度。
PE=Embedding(pos),pos∈{0,1,…,L−1}PE = \text{Embedding}(pos), \quad pos \in \{0, 1, \dots, L-1\}PE=Embedding(pos),pos∈{0,1,…,L−1}
import torch.nn as nn
class LearnedPE(nn.Module):
def __init__(self, max_len: int, d_model: int):
super().__init__()
self.embedding = nn.Embedding(max_len, d_model)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
x: (batch, seq_len, d_model)
"""
seq_len = x.size(1)
positions = torch.arange(seq_len, device=x.device) # (seq_len,)
return x + self.embedding(positions) # 广播加法
# 示例
model = LearnedPE(max_len=512, d_model=128)
x = torch.randn(2, 64, 128)
out = model(x)
print(out.shape) # torch.Size([2, 64, 128])
3. 相对位置编码(Relative PE,Shaw et al. 2018)
来源:Self-Attention with Relative Position Representations
不直接编码绝对位置,而是在注意力计算中加入 query 与 key 之间的相对距离信息。
修改后的注意力分数为:
eij=qi(kj+aijK)Tdke_{ij} = \frac{\mathbf{q}_i \left(\mathbf{k}_j + \mathbf{a}^{K}_{ij}\right)^{T}}{\sqrt{d_k}}eij=dkqi(kj+aijK)T
aijK=wclip(j−i, −k, k)K\mathbf{a}^{K}_{ij} = w^{K}_{\text{clip}(j-i,\ -k,\ k)}aijK=wclip(j−i, −k, k)K
其中 clip(j−i,−k,k)\text{clip}(j-i, -k, k)clip(j−i,−k,k) 将相对距离截断到 [−k,k][-k, k][−k,k],wKw^KwK 是可学习的相对位置向量。
import torch
import torch.nn as nn
import torch.nn.functional as F
class RelativeAttention(nn.Module):
def __init__(self, d_model: int, n_heads: int, max_relative: int = 16):
super().__init__()
self.n_heads = n_heads
self.d_k = d_model // n_heads
self.max_relative = max_relative
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
# 相对位置嵌入:共 2*max_relative+1 个位置
self.rel_embed = nn.Embedding(2 * max_relative + 1, self.d_k)
def _relative_index(self, seq_len: int) -> torch.Tensor:
"""生成相对位置索引矩阵,shape: (seq_len, seq_len)"""
range_vec = torch.arange(seq_len)
dist = range_vec.unsqueeze(0) - range_vec.unsqueeze(1) # (L, L)
dist_clipped = dist.clamp(-self.max_relative, self.max_relative)
return dist_clipped + self.max_relative # 平移到 [0, 2k]
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, L, _ = x.shape
Q = self.W_q(x).view(B, L, self.n_heads, self.d_k).transpose(1, 2)
K = self.W_k(x).view(B, L, self.n_heads, self.d_k).transpose(1, 2)
V = self.W_v(x).view(B, L, self.n_heads, self.d_k).transpose(1, 2)
# 标准注意力分数
scores = torch.matmul(Q, K.transpose(-2, -1)) # (B, H, L, L)
# 相对位置偏置
rel_idx = self._relative_index(L).to(x.device) # (L, L)
rel_emb = self.rel_embed(rel_idx) # (L, L, d_k)
rel_scores = torch.einsum('bhid,ijd->bhij', Q, rel_emb)
scores = (scores + rel_scores) / (self.d_k ** 0.5)
attn = F.softmax(scores, dim=-1)
out = torch.matmul(attn, V) # (B, H, L, d_k)
out = out.transpose(1, 2).contiguous().view(B, L, -1)
return out
# 示例
attn = RelativeAttention(d_model=128, n_heads=4)
x = torch.randn(2, 32, 128)
print(attn(x).shape) # torch.Size([2, 32, 128])
4. 旋转位置编码(RoPE)
来源:RoFormer(Su et al., 2021),被 LLaMA、GPT-NeoX 等广泛采用
以下是为您补充参数含义,并整合完善后的结构化表述。语言保持简洁、专业,直接说明数学与物理含义。
旋转位置编码(RoPE)原理解析
核心思想:
对 Query(查询)和 Key(键)向量按照其在序列中的绝对位置进行旋转,使得两者的内积 qmTkn\mathbf{q}_m^T \mathbf{k}_nqmTkn 能够自然地包含、且仅依赖于它们的相对位置 (m−n)(m - n)(m−n) 的信息。
1. 参数含义说明
- q,k\mathbf{q}, \mathbf{k}q,k:注意力机制中提取出的 Query 向量和 Key 向量。
- m,nm, nm,n:分别为 q\mathbf{q}q 和 k\mathbf{k}k 在输入序列中的绝对位置索引(即第几个词)。
- m−nm - nm−n:两个词在序列中的相对距离。
- Rθ,mR_{\theta, m}Rθ,m:针对位置 mmm 和基础频率 θ\thetaθ 构造的旋转矩阵。
- ddd:向量(Attention Head)的总特征维度。
- iii:将 ddd 维向量两两分组为 d/2d/2d/2 个二维平面后,该平面的组索引(取值范围 i∈[0,d/2−1]i \in [0, d/2 - 1]i∈[0,d/2−1])。
- θi\theta_iθi:第 iii 组二维平面对应的基础旋转频率(即位置每增加 1,向量旋转的角度步长)。
- 100001000010000:超参数底数,用于控制不同维度的频率衰减速度。
2. 核心公式与推导
旋转公式:
X′=Xcosθ−YsinθX' = X \cos \theta - Y \sin \thetaX′=Xcosθ−Ysinθ
Y′=Xsinθ+YcosθY' = X \sin \theta + Y \cos \thetaY′=Xsinθ+Ycosθ
这里,我们应用的是绕原点逆时针旋转角度 θ\thetaθ 的标准 2D 旋转。你只需将初始坐标 (X,Y)(X, Y)(X,Y) 和旋转角度 θ\thetaθ 代入即可得到结果。
对于单一的二维情形,向向量注入位置 mmm 信息的旋转矩阵定义为:
Rθ,m=(cosmθ−sinmθsinmθcosmθ)R_{\theta,m} = \begin{pmatrix} \cos m\theta & -\sin m\theta \\ \sin m\theta & \cos m\theta \end{pmatrix}Rθ,m=(cosmθsinmθ−sinmθcosmθ)
推广到 ddd 维时,将向量划分为 d/2d/2d/2 个二维子空间,并为第 iii 个子空间分配呈指数衰减的频率:
θi=10000−2i/d\theta_i = 10000^{-2i/d}θi=10000−2i/d
(注:索引 iii 越小,频率 θi\theta_iθi 越大,旋转越快;索引 iii 越大,旋转越慢。)
在计算注意力得分时,由于旋转矩阵具有正交性质(其转置等于其逆矩阵,即 Rθ,mT=R−θ,mR_{\theta,m}^T = R_{-\theta,m}Rθ,mT=R−θ,m),携带绝对位置信息的两向量内积满足以下推导:
(Rθ,mq)T(Rθ,nk)=qTRθ,mTRθ,nk=qTR−θ,mRθ,nk=qTRθ,n−mk\begin{aligned} (R_{\theta,m} \mathbf{q})^{T} (R_{\theta,n} \mathbf{k}) &= \mathbf{q}^{T} R_{\theta,m}^T R_{\theta,n} \mathbf{k} \\ &= \mathbf{q}^{T} R_{-\theta,m} R_{\theta,n} \mathbf{k} \\ &= \mathbf{q}^{T} R_{\theta,n-m} \mathbf{k} \end{aligned}(Rθ,mq)T(Rθ,nk)=qTRθ,mTRθ,nk=qTR−θ,mRθ,nk=qTRθ,n−mk
结论: 经过矩阵运算,绝对位置 mmm 和 nnn 在内积操作中被抵消,最终的注意力权重完全由两者的相对位置 Rθ,n−mR_{\theta,n-m}Rθ,n−m 决定。
import torch
def precompute_rope_freqs(d: int, max_len: int, base: float = 10000.0):
"""预计算 RoPE 的 cos/sin 缓存"""
# 频率:shape (d/2,)
theta = 1.0 / (base ** (torch.arange(0, d, 2).float() / d))
positions = torch.arange(max_len).float() # (max_len,)
freqs = torch.outer(positions, theta) # (max_len, d/2)
cos = torch.cos(freqs) # (max_len, d/2)
sin = torch.sin(freqs) # (max_len, d/2)
return cos, sin
def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
"""
x: (batch, n_heads, seq_len, d_head)
cos/sin: (seq_len, d_head/2)
"""
seq_len, d = x.shape[-2], x.shape[-1]
x1 = x[..., : d // 2] # 前半
x2 = x[..., d // 2 :] # 后半
cos = cos[:seq_len].unsqueeze(0).unsqueeze(0) # (1,1,L,d/2)
sin = sin[:seq_len].unsqueeze(0).unsqueeze(0)
# 旋转:[x1, x2] -> [x1*cos - x2*sin, x2*cos + x1*sin]
x_rot = torch.cat([x1 * cos - x2 * sin,
x2 * cos + x1 * sin], dim=-1)
return x_rot
# 示例
d_head, max_len = 64, 512
cos_cache, sin_cache = precompute_rope_freqs(d_head, max_len)
q = torch.randn(2, 8, 32, d_head) # (batch, heads, seq, d_head)
q_rope = apply_rope(q, cos_cache, sin_cache)
print(q_rope.shape) # torch.Size([2, 8, 32, 64])
5. ALiBi(线性偏置注意力)
来源:Train Short, Test Long(Press et al., 2022)
不修改输入嵌入,而是直接在注意力分数上加一个与相对距离成正比的负偏置,天然支持长度外推。
Attn(i,j)=qikjT−mh⋅∣i−j∣\text{Attn}(i, j) = \mathbf{q}_i \mathbf{k}_j^{T} - m_h \cdot |i - j|Attn(i,j)=qikjT−mh⋅∣i−j∣
其中 mhm_hmh 是第 hhh 个注意力头的斜率,按等比数列分配:
mh=128h/H,h=1,…,Hm_h = \frac{1}{2^{8h/H}}, \quad h = 1, \dots, Hmh=28h/H1,h=1,…,H
import torch
import math
def get_alibi_slopes(n_heads: int) -> torch.Tensor:
"""计算每个头的斜率 m_h"""
def get_slopes_power_of_2(n):
start = 2 ** (-(2 ** -(math.log2(n) - 3)))
return [start * (start ** i) for i in range(n)]
if math.log2(n_heads).is_integer():
slopes = get_slopes_power_of_2(n_heads)
else:
closest = 2 ** math.floor(math.log2(n_heads))
slopes = get_slopes_power_of_2(closest)
extra = get_slopes_power_of_2(2 * closest)[0::2][:n_heads - closest]
slopes = slopes + extra
return torch.tensor(slopes, dtype=torch.float32)
def build_alibi_bias(n_heads: int, seq_len: int) -> torch.Tensor:
"""
返回 ALiBi 偏置矩阵,shape: (1, n_heads, seq_len, seq_len)
"""
slopes = get_alibi_slopes(n_heads) # (n_heads,)
positions = torch.arange(seq_len)
# 相对距离矩阵:|i - j|,shape (seq_len, seq_len)
dist = (positions.unsqueeze(0) - positions.unsqueeze(1)).abs().float()
# 每个头乘以对应斜率并取负
bias = -slopes.view(-1, 1, 1) * dist.unsqueeze(0) # (n_heads, L, L)
return bias.unsqueeze(0) # (1, n_heads, L, L)
# 示例:将偏置加到注意力分数上
n_heads, seq_len = 8, 64
alibi_bias = build_alibi_bias(n_heads, seq_len)
print(alibi_bias.shape) # torch.Size([1, 8, 64, 64])
# 使用方式:scores = qk_scores + alibi_bias
对比总结
| 方法 | 参数量 | 长度外推 | 相对位置感知 | 典型模型 |
|---|---|---|---|---|
| Sinusoidal PE | 无 | 有限 | 间接 | 原始 Transformer |
| Learned PE | 有 | ✗ | 无 | BERT, GPT-2 |
| Relative PE | 少量 | 有限 | ✓ | Transformer-XL |
| RoPE | 无 | 较好 | ✓ | LLaMA, Qwen |
| ALiBi | 无 | ✓ 最强 | ✓ | BLOOM, MPT |
绝对正弦位置编码,其实对 Q、K、V 都应用了;而旋转位置编码(RoPE)确实只对 Q 和 K 应用。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)