上一章,我们把 Embedding 层拆开看了一遍:分词器把文本转换为 token ID,Embedding 再根据 ID,从一张可学习的表里取出对应向量。
不过,这里还剩下一个问题。
假设我们把下面两句话都按三个词来分:
我 喜欢 你
你 喜欢 我
两句话用到的词完全相同,意思却不一样。
对于同一张固定权重的 Embedding 表,"我"无论出现在第一个位置还是第三个位置,查出来的向量都是同一个。"你"和"喜欢"也是如此。
这些向量当然仍然按输入顺序排列在张量里。但数据存放在不同的位置,不等于后面的计算一定会利用这些位置的含义。
那么,模型怎么知道谁在前,谁在后?这就是本章要解释的问题。
一、为什么需要位置编码?
1. RNN 的计算过程本身带有顺序结构
在第一章的 LSTM 中,模型先读取第一个 token,再读取第二个,后面的状态依赖前面的状态。
因此,"我喜欢你"和"你喜欢我"会经历不同的状态更新过程。顺序已经参与了计算,不需要为了基本的顺序建模,再给每个输入位置额外添加一张位置表。
Transformer 的自注意力计算方式不同。在输入已知时,它可以并行计算多个位置的表示,不需要像 RNN 那样沿序列逐步传递隐藏状态。
2. 基础自注意力不会自动读取位置编号
先考虑没有位置编码、没有位置相关掩码的标准自注意力。它根据输入向量的内容,计算不同 token 之间应该如何相互参考。
如果把输入向量重新排列,输出也会相应地重新排列。同一个词不会仅仅因为从第一个位置移到第三个位置,就自动得到"我现在是第三个词"的信息。
这里的问题不在于"并行一定无法理解顺序",而在于这种基础计算没有直接使用位置编号。
为了让模型利用序列顺序,我们需要把位置信息引入计算。
本章先讨论最直观的一种方式:为每个位置准备一个向量,再把它加入对应 token 的表示。
范围说明:因果掩码本身会引入"只能看前面"的方向约束,所以不能笼统地说所有 Transformer 都完全没有顺序线索。另外,训练时能够并行计算多个位置,不意味着自回归生成时能一次生成全部未知 token;生成仍然通常逐步进行。
二、位置编码是什么?
广义的位置编码,是向模型引入 token 的绝对位置或相对位置关系的一类方法。
两种位置概念可以这样区分:
| 概念 | 例子 |
|---|---|
| 绝对位置 | 这个 token 位于序列中的第 5 个位置 |
| 相对位置 | 这个 token 在另一个 token 前面 2 个位置 |
本章先实现两种绝对位置编码:固定的正弦/余弦编码,以及可学习的位置表。它们都会为每个位置提供一个与输入表示维度相同的向量。
假设某个位置上的词向量为 et,该位置的位置向量为 pt,那么输入可以写成:
xt=et+pt
同一个词出现在不同位置时, et 可以相同,而 pt 不同。这样,送给后续网络的向量就带上了位置线索。
这里的"位置"按 token 计算,不一定对应汉字、单词或字符。采用子词分词时,一个单词可能占用多个位置。
为什么不直接加上 0、1、2、3?
使用整数位置作为输入特征是可以设计的,并不是数学上行不通。
但如果把位置编号直接加到词向量的每一个维度,所有维度收到的都是同一种变化,而且数值会随位置持续增大。如果用序列长度归一化,又会让同一个位置在不同长度的序列中得到不同数值。
位置编码提供了一种更有结构的选择:把位置表示成一组数,在多个维度上提供不同的位置特征。下面的正弦方案还把每个分量限制在 −1,1 内,并同时提供变化快、变化慢的信号。
三、正弦/余弦位置编码
1. 公式在做什么?
《Attention Is All You Need》采用了以下固定位置编码:
PE(pos,2i)=sin(100002i/dmodelpos)
PE(pos,2i+1)=cos(100002i/dmodelpos)
其中:
- pos 是从 0 开始的位置索引。
- i 是维度对的编号,对应第 2i 和第 2i+1 维。
- dmodel 是模型表示的维度,这里的位置向量与输入向量使用相同维度。
每一对维度使用相同的频率,一个取 sin,一个取 cos;不同维度对使用不同频率。
例如,设 dmodel=4,那么位置 pos 的向量就是:
PE(pos)=sin(pos),cos(pos),sin(pos/100),cos(pos/100)
前几个位置如下,数值保留四位小数:
| 位置 | 第 0 维 | 第 1 维 | 第 2 维 | 第 3 维 |
|---|---|---|---|---|
| 0 | 0.0000 | 1.0000 | 0.0000 | 1.0000 |
| 1 | 0.8415 | 0.5403 | 0.0100 | 1.0000 |
| 2 | 0.9093 | -0.4161 | 0.0200 | 0.9998 |
可以看出,前两维变化比较快,后两维变化比较慢。多个频率组合起来,为不同位置提供可区分的数值表示。
这里不需要把每一维解释成某个明确的语义属性。它们提供的是位置特征,模型会在训练中学习怎样使用。
2. 为什么使用多个频率?
只看一个正弦分量,不同位置可能得到相同或接近的数值。单一频率提供的位置特征也比较单薄。
组合多个频率,可以同时提供不同变化尺度的信号:高频分量对较小的位置变化更敏感,低频分量则随位置缓慢变化。
在这组公式中,维度对的编号越小,频率越高;编号越大,频率越低。
但不要把它理解成"某一维专门负责局部,另一维专门负责全文"。模型如何利用这些分量,是训练得到的结果。
3. 为什么把 sin 和 cos 配成一对?
除了提供不同的分量,这样配对还有一个有用的数学性质。
令某一对维度的频率为 ω。当位置从 pos 移动到 pos+k 时,根据三角函数加法公式:
sin((pos+k)ω)cos((pos+k)ω)=cos(kω)−sin(kω)sin(kω)cos(kω)sin(posω)cos(posω)
对于固定的位移 k,中间的变换只取决于位移和频率,不取决于起点 pos。
这意味着,固定距离的位置移动具有一致的数学结构,为模型学习相对位置关系提供了条件。它不是保证模型自动学会距离的证明,但能解释为什么要采用这种成对设计。1
4. 用 PyTorch 实现
下面的函数支持指定设备、输出类型,以及位置起点。三种参数各有用途:设备和类型用于与输入张量保持一致,offset 用于从非零位置开始计算。
本章 Python 代码按顺序运行即可;最后的绘图部分额外需要 Matplotlib。
python
import math
import torch
import torch.nn as nn
def positional_encoding(
seq_len,
d_model,
*,
offset=0,
device=None,
dtype=torch.float32,
):
if seq_len < 0 or d_model <= 0 or offset < 0:
raise ValueError("seq_len、offset 必须非负,d_model 必须为正。")
if not dtype.is_floating_point:
raise TypeError("位置编码的输出类型必须是浮点数。")
# 用 float32 计算三角函数,最后再转换为需要的输出类型。
positions = torch.arange(
offset, offset + seq_len, device=device, dtype=torch.float32
).unsqueeze(1)
dimensions = torch.arange(
0, d_model, 2, device=device, dtype=torch.float32
)
frequencies = torch.exp(-math.log(10000.0) * dimensions / d_model)
angles = positions * frequencies.unsqueeze(0)
pe = torch.empty(seq_len, d_model, device=device, dtype=torch.float32)
pe[:, 0::2] = torch.sin(angles)
# d_model 为奇数时,cos 分量比 sin 分量少一个。
pe[:, 1::2] = torch.cos(angles[:, : d_model // 2])
return pe.unsqueeze(0).to(dtype=dtype)
encoding = positional_encoding(50, 512)
print(encoding.shape) # torch.Size([1, 50, 512])
返回值第一维是 1,便于之后与 (batch_size, seq_len, d_model) 形状的输入相加。广播机制会让批次中的每条序列使用相同的位置编号。
这个函数使用固定公式,没有需要训练的位置参数。实际工程中,可以预先计算并缓存常用长度的编码;若封装到模型模块里,通常可以使用 register_buffer 保存固定编码。
5. 能计算更远位置,不等于模型能理解更长文本
正弦公式不依赖一张有限长度的位置查找表,因此可以继续为更大的位置编号计算编码。在实际浮点计算中,当然仍然有数值精度和内存等限制。
但即使成功生成了更远位置的向量,也不代表模型能在超出训练长度的文本上保持原有表现。
位置编码能否计算、网络结构能否运行、模型是否有效,是三个不同的问题。 不能把"公式可以继续代入"直接写成"模型能够可靠外推"。
四、可学习的位置编码
正弦位置编码由公式决定。另一种做法,是让每个位置拥有一行可训练的向量。
这与上一章的 Embedding 很像:
| 查找表 | 用什么索引? | 查到什么? |
|---|---|---|
| token embedding | token ID | 这个 token 的向量 |
| position embedding | 位置 ID | 这个位置的向量 |
假设最多支持 max_len 个位置,每个位置向量有 d_model 维,那么位置表的形状就是 (max_len, d_model)。
python
class LearnablePositionalEncoding(nn.Module):
def __init__(self, max_len, d_model):
super().__init__()
if max_len <= 0 or d_model <= 0:
raise ValueError("max_len 和 d_model 必须为正。")
self.max_len = max_len
self.d_model = d_model
self.encoding = nn.Embedding(max_len, d_model)
def forward(self, x, offset=0):
# x: (batch_size, seq_len, d_model),浮点张量。
if x.ndim != 3 or x.size(-1) != self.d_model:
raise ValueError("x 必须具有 (batch_size, seq_len, d_model) 形状。")
if not x.is_floating_point():
raise TypeError("x 必须是浮点张量。")
seq_len = x.size(1)
if offset < 0 or offset + seq_len > self.max_len:
raise ValueError("请求的位置超出了位置表范围。")
if x.device != self.encoding.weight.device:
raise ValueError("请先把位置编码模块和输入移到同一设备。")
pos_ids = torch.arange(
offset, offset + seq_len, device=x.device, dtype=torch.long
).unsqueeze(0)
positions = self.encoding(pos_ids).to(dtype=x.dtype)
return x + positions
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
pos_enc = LearnablePositionalEncoding(100, 512).to(device)
x = torch.zeros(1, 10, 512, device=device)
print(pos_enc(x).shape) # torch.Size([1, 10, 512])
训练时,这张位置表会和其他模型参数一起,通过损失函数的梯度更新。它的参数量为:
max_len×dmodel
它也有明确限制:超出表长的位置没有对应行;即使预留了更多行,没有接受有效训练的位置向量,也不会自动获得可靠的位置表示。
可以通过扩表、插值或额外训练等方式处理更长输入,但这些属于后续扩展,不是这段基础查表代码自带的能力。
offset 则用于控制位置起点。例如,带缓存的生成过程已经处理了 20 个 token,下一个 token 的位置通常应从 20 开始,而不是重新使用位置 0。这里先保留这个接口,缓存本身留到后面再实现。
五、位置向量为什么与词向量相加?
对于本章的两种绝对位置方案,常见用法是:
X′=X+PE
相加后,输入仍然是 d_model 维,因此后续层可以继续使用原来的输入宽度。
相加操作本身不增加可训练参数,但可学习的位置表会增加参数。 正弦公式和可学习位置表在这里需要分清。
拼接也可以设计。如果词向量和位置向量都是 d_model 维,拼接后就会变成 2 * d_model 维,需要调整后续层或增加投影。如果位置向量维度不同,增加的维度也就不同,不能一概说拼接一定翻倍。
相加不会把信息混在一起吗?
会叠加,而且单看一个和向量,不能唯一恢复任意两组原始向量。
但模型的任务不是先把它们完整拆回去。词向量、位置表示和后续网络是在既定结构下共同参与训练的,网络学习从叠加后的表示中提取对预测有用的信息。
同一个词位于不同位置时,加上的位置向量不同;不同词位于同一位置时,词向量不同。这样的组合向模型提供了内容和位置两类线索。
这说明相加是一种实用的结构选择,并不意味着它保证无损,或者一定优于所有其他结合方式。
把 token embedding 和位置编码接起来
下面用两个简单的 ID 序列演示。它们包含相同的 ID,但顺序不同。
python
token_embedding = nn.Embedding(10, 8).to(device)
input_ids = torch.tensor([
[1, 2, 3],
[3, 2, 1],
], dtype=torch.long, device=device)
token_vectors = token_embedding(input_ids)
pe = positional_encoding(
seq_len=input_ids.size(1),
d_model=token_vectors.size(-1),
device=token_vectors.device,
dtype=token_vectors.dtype,
)
x_with_position = token_vectors + pe
print(token_vectors.shape) # torch.Size([2, 3, 8])
print(pe.shape) # torch.Size([1, 3, 8])
print(x_with_position.shape) # torch.Size([2, 3, 8])
# 两个位置上的 token 都是 ID 1,原始词向量相同。
print(torch.allclose(token_vectors[0, 0], token_vectors[1, 2])) # True
# 但它们分别位于位置 0 和位置 2,加入位置后不再相同。
print(torch.allclose(x_with_position[0, 0], x_with_position[1, 2])) # False
这个例子只演示向量如何组合,没有训练模型,也不能据此判断模型已经理解语序。
原始 Transformer 还会把 token embedding 乘以 dmodel ,其输入相加部分写成:
X′=Embedding(ids)dmodel +PE
这是原论文的具体配置。1 上面的最小例子直接使用 X+PE,重点是观察位置带来的变化。实际搭建模型时,缩放应与初始化和整体结构保持一致,不能把它当作所有 Transformer 都必须执行的一步。
还要注意,本章统一使用 (batch_size, seq_len, d_model) 排列。第一章 LSTM 示例使用的是 (seq_len, batch_size, emb_dim),不能不检查维度就直接把本章代码接过去。
如果输入有 padding,位置编码也不会自动让网络忽略这些位置。后续注意力和损失计算仍然需要相应的屏蔽处理。
六、扩展:相对位置方法与 RoPE
1. 相对位置方法
绝对位置回答"当前是第几个 token";相对位置回答"两个 token 相隔多远,谁在谁前面"。
不少相对位置方法会直接把距离信息加入注意力计算,而不是为每个 token 都加一个绝对位置向量。具体实现可以使用相对位置向量、相对距离偏置等形式。2
例如,模型可以区分"前面一个 token"和"后面一个 token",而不必只依赖它们各自的绝对位置编号。
但"相对位置"是一个方法类别。是否截断距离、是否把距离划分成不同区间、引入多少参数和计算开销,都取决于具体设计,不能统一概括成"更适合长文本"。
2. 旋转位置编码 RoPE
RoPE(Rotary Position Embedding)通常把注意力中的 Query 和 Key 向量按二维分组,再根据 token 所处位置对这些分量施加旋转。
这种旋转可以用复数表示,也可以用实数运算实现,并不要求程序必须使用复数类型。
它的关键性质是:不同位置的 Query 和 Key 分别旋转后,在它们的点积中,会出现与相对位置差有关的项。3
因此,RoPE 不等同于"再造一个位置向量,加到词向量上"。它把位置信息引入了注意力所使用的表示。
Query、Key 和点积注意力会在后面的章节展开,这里先知道两者的联系即可。RoPE 可以为长上下文方案提供基础,但它本身不保证超出训练长度后仍有良好表现,仍需结合训练长度和位置处理策略评估。
3. 几种方法怎样比较?
| 方法 | 主要特点 | 需要注意的限制 |
|---|---|---|
| 正弦/余弦绝对位置编码 | 固定公式,无可训练位置参数,可计算更远位置 | 频率结构预设;可计算不等于可可靠外推 |
| 可学习的绝对位置表 | 每个位置的向量随任务学习 | 普通查表受表长限制,未训练位置缺少有效学习 |
| 相对位置方法 | 显式利用 token 间的相对位移 | 距离范围、参数量和计算开销因实现而异 |
| RoPE | 通过旋转使 Q、K 点积包含相对位置信息 | 长度外推仍依赖训练和位置处理方案 |
这张表比较的是实现特点,不是从差到好的排名。RoPE 也与相对位置建模密切相关,这些类别并非完全互斥。
七、把不同频率画出来
公式看得抽象,可以选出几个维度,把位置变化画成曲线。
python
import matplotlib.pyplot as plt
pe_for_plot = positional_encoding(200, 64)[0].cpu().numpy()
selected_dims = [0, 8, 16, 32]
fig, axes = plt.subplots(4, 1, figsize=(9, 7), sharex=True)
for ax, dim in zip(axes, selected_dims):
ax.plot(pe_for_plot[:, dim])
ax.set_ylabel(f"dim {dim}")
ax.set_ylim(-1.1, 1.1)
ax.grid(alpha=0.25)
axes[0].set_title("Sinusoidal positional encoding")
axes[-1].set_xlabel("Token position")
fig.tight_layout()
plt.show()
这里选取的都是 sin 分量。你会看到,编号较小的维度变化较快,编号较大的维度变化较慢。由于公式使用弧度,某些曲线看起来也不会像"每个位置正好走完整数分之一周期"那样整齐。
多个频率共同构成了位置表示。模型最终怎样利用这些特征,还需要通过任务训练来决定。
到这里,输入端的两个问题就连起来了:token embedding 提供内容的初始表示,位置机制提供位置或顺序线索。接下来进入注意力机制时,我们再看模型如何用这些表示,决定一个 token 应该参考序列中的哪些部分。
参考资料
- Vaswani 等:Attention Is All You Need,第 3.4---3.5 节。
- Shaw 等:Self-Attention with Relative Position Representations。
- Su 等:RoFormer: Enhanced Transformer with Rotary Position Embedding。
- Press 等:Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation,进一步讨论训练长度之外的泛化问题。