文章目录
Transformer 模型中另一个至关重要的组件——位置编码。这是理解 Transformer 如何处理序列数据的关键一步。
一、 为什么需要位置编码?
首先,我们回顾一下自注意力机制的工作原理。在计算一个词的表示时,它会关注序列中所有其他词,并给它们分配一个权重。这个过程是并行的,并且不关心词与词之间的顺序。举个例子,考虑这两个句子:
- “The cat sat on the mat.”
- “The mat sat on the cat.”
如果模型只依赖自注意力机制,它可能会认为这两个句子是相似的,因为它们包含完全相同的词汇。显然,这是错误的,因为词的顺序完全改变了句子的含义。
核心问题: RNN 和 LSTM 等模型天生具有顺序性(第 t 步的隐藏状态依赖于第 t-1 步),而 Transformer 的核心组件(自注意力、前馈网络)都是置换不变的——它们不关心输入的顺序。
解决方案: 我们必须以一种明确的方式,将单词在序列中的位置信息注入到模型中。这就是位置编码的作用。
二、 位置编码的两种主要方法
位置编码需要满足几个特性:
- 确定性:同一个位置在任何序列中都应该有相同的编码。
- 独特性:不同位置的编码应该尽可能不同,以利于模型区分。
- 有界性:编码值应该在一定范围内,避免过大或过小。
- 可学习性或可计算性:编码应该易于计算或由模型自己学习。
目前主流的位置编码方法有两种:
2.1 正弦/余弦编码
这是 Transformer 论文(“Attention is All You Need”)中提出的方法,也是最经典、最著名的方法。
1、核心思想
不使用可学习的嵌入向量,而是使用一组固定的、预先计算好的正弦和余弦函数来生成位置编码。这种方法巧妙地将位置信息编码到了不同频率的信号中。
2、数学公式
对于序列中的第 pos 个位置,其位置编码 PE(pos) 是一个维度与词嵌入 d_model 相同的向量。向量的每个维度 i 的计算公式如下:
P
E
(
p
o
s
,
2
i
)
=
sin
(
p
o
s
1000
0
2
i
/
d
m
o
d
e
l
)
PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right)
PE(pos,2i)=sin(100002i/dmodelpos)
P
E
(
p
o
s
,
2
i
+
1
)
=
cos
(
p
o
s
1000
0
2
i
/
d
m
o
d
e
l
)
PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{model}}}\right)
PE(pos,2i+1)=cos(100002i/dmodelpos)
其中:
pos:单词在序列中的位置(从 0 开始)。i:编码向量中的维度索引(从 0 开始)。d_model:词向量和位置编码的维度(论文中为 512)。
3、公式解析与直觉
这个公式看起来很复杂,但我们可以分解它:
- 奇偶维度分开:对于偶数索引
2i,使用sin函数;对于奇数索引2i+1,使用cos函数。这保证了每个位置的编码都是唯一的。 - 频率变化:分母
10000^(2i/d_model)是关键。随着维度索引i的增加,分母会变得非常大,这意味着sin/cos函数的频率会降低。- 低维度 (
i较小):频率高,编码值变化剧烈。这些维度主要编码局部位置信息(如相邻位置的区别)。 - 高维度 (
i较大):频率低,编码值变化平缓。这些维度主要编码全局位置信息(如开头和结尾的区别)。
- 低维度 (
- 相对位置关系:这种编码方案有一个非常优美的特性:任意两个位置之间的位置编码差,可以被这两个位置的编码线性表示。这意味着模型可以非常容易地学习到相对位置关系,这对于理解语言至关重要。
2.2 可学习的位置编码
这是一种更简单、更直接的方法。
1、核心思想
创建一个可训练的嵌入查找表。就像学习词嵌入一样,模型可以自己学习最优的位置表示。
- 我们创建一个
max_sequence_length x d_model的矩阵。 - 矩阵的第
i行就是位置i的位置编码。 - 这个矩阵的参数是可学习的,会在模型训练过程中被优化。
2、优缺点
- 优点:
- 简单直观:实现起来非常简单,就是加一个嵌入层。
- 灵活性强:模型可以根据数据自己学习最适合的位置表示,不一定局限于正弦/余弦的特定模式。
- 缺点:
- 泛化能力差:模型在训练时见过的最大长度是
max_sequence_length。如果测试时遇到更长的序列,它就无法处理。而正弦编码因为是一个公式,可以处理任意长度的序列。 - 需要额外参数:会增加模型的参数量。
- 泛化能力差:模型在训练时见过的最大长度是
2.3 两种编码方式对比
| 特性 | 正弦/余弦编码 | 可学习位置编码 |
|---|---|---|
| 来源 | 论文原始方法,基于数学函数 | 一种常见的替代方案 |
| 核心思想 | 使用不同频率的 sin/cos 波生成编码 | 创建一个可训练的嵌入查找表 |
| 优点 | 1. 泛化能力强:可处理任意长度的序列。 2. 捕捉相对位置:数学特性优美。 3. 无额外参数。 | 1. 实现简单。 2. 灵活性高,模型可自学习最优表示。 |
| 缺点 | 1. 固定模式,可能不如学习到的表示灵活。 2. 数学实现稍复杂。 | 1. 泛化能力弱,受限于训练时见过的最大长度。 2. 增加模型参数量。 |
| 适用场景 | 长文本任务,或对序列长度不敏感的任务。 | 计算资源充足,且序列长度相对固定的任务。 |
三、 位置编码在模型中的使用
位置编码的使用流程非常简单:
- 获取词嵌入:首先,将输入序列中的每个单词通过词嵌入层转换为一个
d_model维的向量。假设输入序列长度为seq_len,我们得到一个seq_len x d_model的矩阵E。 - 获取位置编码:根据输入序列的长度,生成一个同样大小的
seq_len x d_model的位置编码矩阵PE。 - 相加:将词嵌入矩阵
E和位置编码矩阵PE对应元素相加。
X = E + P E X = E + PE X=E+PE - 输入模型:将相加后的结果
X输入到 Transformer 的编码器和解码器中。
关键点:这里的加法是逐元素相加。位置编码并没有改变词嵌入本身,而是为它附加了位置信息。Transformer 的后续层(自注意力、前馈网络)会在处理这些相加后的向量时,同时利用到词的语义信息和它们的位置信息。
四、 用 Python 实现正弦/余弦位置编码 (基于PyTorch)
4.1 完整代码
下面我们用 PyTorch 来实现论文中的正弦/余弦位置编码。
import torch
import torch.nn as nn
import math
import matplotlib.pyplot as plt
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
"""
初始化位置编码层
:param d_model: 词嵌入的维度
:param max_len: 支持的最大序列长度
"""
super(PositionalEncoding, self).__init__()
# 创建一个足够大的位置编码矩阵
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
# 计算分母项 10000^(2i/d_model)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
# 填充位置编码矩阵
# 使用广播机制,将 position 和 div_term 扩展到合适的形状
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
# pe 的形状是 (max_len, d_model),我们需要 (1, max_len, d_model)
# 这样可以在 batch 维度上进行广播
pe = pe.unsqueeze(0).transpose(0, 1) # (max_len, 1, d_model)
# 将 pe 注册为 buffer,这样它就不会被视为模型的可训练参数
# 但会在模型被保存和加载时一同保存
self.register_buffer('pe', pe)
def forward(self, x):
"""
前向传播
:param x: 输入张量,形状为 (seq_len, batch_size, d_model)
:return: 加上位置编码后的张量
"""
# x.shape[0] 是序列长度
# self.pe[:x.size(0), :] 会取到对应长度的位置编码
x = x + self.pe[:x.size(0), :]
return x
# --- 演示 ---
if __name__ == '__main__':
# 1. 创建参数
d_model = 128 # 词嵌入维度
seq_len = 50 # 序列长度
# 2. 实例化位置编码层
pos_encoder = PositionalEncoding(d_model)
# 3. 创建一个随机的词嵌入输入
# 形状: (seq_len, batch_size, d_model)
# batch_size=1
embeddings = torch.randn(seq_len, 1, d_model)
# 4. 将词嵌入输入到位置编码层
encoded_embeddings = pos_encoder(embeddings)
print("原始词嵌入形状:", embeddings.shape)
print("加上位置编码后形状:", encoded_embeddings.shape)
print("是否相等:", torch.allclose(embeddings, encoded_embeddings - pos_encoder.pe[:seq_len, :]))
# 5. 可视化前两个维度的位置编码
plt.figure(figsize=(10, 8))
plt.plot(pos_encoder.pe[:seq_len, :, 0].numpy(), label="Dimension 0 (Sin)")
plt.plot(pos_encoder.pe[:seq_len, :, 1].numpy(), label="Dimension 1 (Cos)")
plt.title("Sinusoidal Positional Encoding (First 2 Dimensions)")
plt.xlabel("Position")
plt.ylabel("Encoding Value")
plt.legend()
plt.grid(True)
plt.show()
4.2 执行结果
运行这段代码,你会生成一个图表,清晰地展示了位置编码中 sin 和 cos 部分随位置变化的规律,这与我们前面的理论分析完全吻合。截图如下:

总结:在现代 Transformer 的实践中,两种方法都有应用。正弦编码因其出色的泛化能力和在长序列任务上的稳定表现,仍然是许多模型(如标准 Transformer、BERT)的首选。
2190

被折叠的 条评论
为什么被折叠?



