2、绝对位置编码:Sinusoidal位置编码的原理、公式推导与代码实现
咱们开始聊位置编码。说实话,Transformer刚出来那会儿,我第一反应是:这玩意儿没有循环结构,怎么知道词跟词之间的先后顺序?后来看了论文才明白,原来是通过位置编码硬塞进去的。
这一章,咱们就彻底搞懂Sinusoidal位置编码。它是最经典的绝对位置编码方案,也是很多后续工作的基础。
2.1 为什么需要位置编码?
先想一个问题:Transformer的Self-Attention是怎么处理输入序列的?
它把每个词都看成独立的向量,然后两两计算注意力。说白了,它把序列当成一个集合来处理。你想想看,如果我说「我打你」和「你打我」,在Transformer眼里,如果不加位置信息,这两个句子是完全一样的。
这就尴尬了。顺序在自然语言里太重要了。
所以我们需要一种方式,告诉模型每个词在序列中的位置。这就是位置编码的由来。
2.2 Sinusoidal位置编码的原理
Google在《Attention Is All You Need》里提出了一种固定公式的位置编码方案。它不需要学习,直接用三角函数生成。
公式长这样:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
其中:
pos是位置索引(从0开始)i是维度索引(从0开始)d_model是词向量的维度
这个公式有意思的地方在于:不同维度使用不同的频率。低维度变化慢,高维度变化快。这样每个位置就有一个独一无二的编码向量。
我刚开始看这个公式时,觉得它有点玄学。后来在项目中做长文本分类时才发现,这种设计的好处是:模型可以很容易地学到相对位置关系。因为对于任意固定的偏移量k,PE(pos+k)可以表示为PE(pos)的线性函数。
2.3 公式推导与直观理解
咱们来拆解一下这个公式。
首先,为什么用三角函数?因为三角函数有周期性,而且有很好的数学性质:
sin(α + β) = sinα·cosβ + cosα·sinβ
cos(α + β) = cosα·cosβ - sinα·sinβ
这意味着,位置pos+k的编码,可以通过位置pos的编码线性组合得到。模型不需要额外学习,就能捕捉到相对位置信息。
其次,为什么不同维度用不同频率?
你看这个分母 10000^(2i/d_model)。当i很小时,分母接近1,频率高;当i很大时,分母很大,频率低。这样做的目的是:
- 低维度:高频变化,编码精细的位置差异
- 高维度:低频变化,编码粗略的位置范围
嗯,这里要注意:频率的底数10000是个超参数。论文里说这个值对结果不敏感,但我个人习惯在短文本任务中调小一点,长文本任务中调大一点。当然,这只是经验之谈。
2.4 可视化:位置编码长什么样?
咱们用SVG画一张图,看看位置编码到底长什么样。这张图展示了不同位置、不同维度的编码值。
从这张图可以直观看到:不同位置有不同的编码模式,而且相邻位置的编码是平滑过渡的。这就是Sinusoidal编码的优雅之处。
2.5 代码实现
光说不练假把式。咱们直接上代码。
import numpy as np
import torch
def sinusoidal_position_encoding(max_len, d_model):
"""
生成Sinusoidal位置编码
参数:
max_len: 最大序列长度
d_model: 词向量维度
返回:
pe: [max_len, d_model] 的位置编码矩阵
"""
pe = np.zeros((max_len, d_model))
# 位置索引: [max_len, 1]
position = np.arange(max_len).reshape(-1, 1)
# 计算频率项: [d_model/2]
div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))
# 偶数维度用sin,奇数维度用cos
pe[:, 0::2] = np.sin(position * div_term)
pe[:, 1::2] = np.cos(position * div_term)
return torch.FloatTensor(pe)
# 使用示例
max_len = 50
d_model = 512
pe = sinusoidal_position_encoding(max_len, d_model)
print(f"位置编码形状: {pe.shape}") # [50, 512]
print(f"位置0的前10个维度: {pe[0, :10]}")
np.arange(0, d_model, 2)取所有偶数索引div_term计算的是1 / 10000^(2i/d_model)- 最终编码是
sin(pos * div_term)和cos(pos * div_term)
2.6 如何使用位置编码?
生成位置编码后,怎么用?很简单,直接加到词向量上:
# 假设 x 是词嵌入矩阵 [batch_size, seq_len, d_model]
# pe 是位置编码 [seq_len, d_model]
x = x + pe.unsqueeze(0) # 广播到batch维度
这里有个细节:为什么是加法而不是拼接?
我个人理解是:加法保持了维度不变,而且模型可以通过学习调整不同维度对位置信息的敏感度。如果你用拼接,参数会翻倍,训练成本更高。
2.7 Sinusoidal编码的优缺点
| 优点 | 缺点 |
|---|---|
| 不需要学习,计算简单 | 固定编码,无法适应不同任务 |
| 可以外推到更长的序列 | 绝对位置信息,相对位置信息隐含但不够直接 |
| 有明确的数学解释 | 对超参数(底数)敏感 |
| 不同维度编码不同频率的信息 | 无法区分方向(前向/后向) |
说实话,Sinusoidal编码在BERT出来之前是主流。但后来大家发现,可学习的位置编码在很多任务上效果更好。不过Sinusoidal编码的思想——用不同频率的三角函数编码位置——影响了很多后续工作,比如RoPE(旋转位置编码)就是在这个基础上发展起来的。
2.8 小结
这一章咱们把Sinusoidal位置编码讲透了。从为什么需要位置编码,到公式推导,再到代码实现,最后聊了优缺点。
记住几个关键点:
- 位置编码解决的是Transformer无法感知顺序的问题
- Sinusoidal编码用不同频率的三角函数生成固定编码
- 编码直接加到词向量上,不改变维度
- 优点是简单、可外推,缺点是固定、不够灵活
下一章咱们会聊可学习位置编码,看看它跟Sinusoidal有什么不同,以及在实际项目中怎么选。