Transformer中的位置编码:从原理到TensorFlow实现
在现代自然语言处理系统中,Transformer 已经成为事实上的标准架构。它不依赖传统的循环结构,而是完全依靠注意力机制来建模序列信息。这种设计带来了极高的并行化能力,但也引发了一个关键问题:模型如何知道词语的先后顺序?
毕竟,如果我们把一句话打乱成“猫 喜欢 吃 鱼”,和“鱼 吃 喜欢 猫”,对纯注意力模型来说可能看起来差不多——除非我们明确告诉它每个词出现在什么位置。
这就是位置编码(Positional Encoding)存在的意义。它是 Transformer 架构中一个看似简单却极为精巧的设计,让模型在没有递归或卷积的情况下,依然能感知序列的时序关系。
为什么需要位置编码?
传统 RNN 或 LSTM 天然具备顺序处理的能力:它们按时间步一步步读取输入,状态中隐含了“前面发生了什么”。但 Transformer 是并行处理整个序列的,所有 token 同时进入网络。这意味着,如果仅靠词嵌入,模型看到的其实就是一组无序向量——相当于把句子当作“词袋”(Bag-of-Words)处理。
为了解决这个问题,原始论文《Attention Is All You Need》提出将位置信息以向量形式注入到词嵌入中。具体做法是:生成一个与词嵌入维度相同的位置编码矩阵,然后将其逐元素加到词嵌入上。
这样,每一个 token 的最终输入 = 词义信息 + 位置信息,模型就能同时理解“这个词是什么”以及“它出现在哪里”。
正弦式位置编码:数学背后的直觉
Vaswani 等人选择了一种基于正弦和余弦函数的位置编码方式:
$$
PE(pos, 2i) = \sin\left(\frac{pos}{10000^{\frac{2i}{d_{model}}}}\right), \quad
PE(pos, 2i+1) = \cos\left(\frac{pos}{10000^{\frac{2i}{d_{model}}}}\right)
$$
其中:
- $ pos $ 是 token 在序列中的位置(从 0 开始)
- $ i $ 是编码向量中的维度索引
- $ d_{model} $ 是嵌入维度(如 512)
这个公式乍看复杂,其实背后有几个非常聪明的设计考量:
1.频率分层:低频捕获远距离,高频刻画局部差异
不同维度对应不同的波长。随着维度增加,分母 $ 10000^{2i/d_{model}} $ 变大,导致角频率变小,波长更长。也就是说:
- 低维部分变化快(高频率),适合区分相邻词;
- 高维部分变化慢(低频率),可以表示更大范围的位置趋势。
这就像是用多个不同精度的尺子去测量位置:有的精确到毫米,有的只能估到米级。
2.相对位置可学习性
由于三角函数满足恒等式:
$$
\sin(a + b) = \sin a \cos b + \cos a \sin b
$$
任意两个位置 $ pos $ 和 $ pos + k $ 之间的编码差值,理论上可以通过线性变换由 $ PE(pos) $ 推导出 $ PE(pos+k) $。这使得模型有机会通过权重学习到相对位置关系,而不只是记住绝对位置。
3.无限外推潜力
因为编码是函数生成的,而不是查表获得的,所以即使训练时最长只见过 512 长度的序列,测试时也能计算第 1000 个位置的编码。相比之下,可学习的位置嵌入(Learned Position Embedding)通常受限于预定义的最大长度。
当然,实际中外推效果会受限制——当位置过大时,编码值趋于饱和(接近 ±1),梯度消失风险上升。但在合理范围内,这种泛化能力仍是巨大优势。
手写 Positional Encoding:TensorFlow 实现
下面我们用 TensorFlow 2.9 来手动实现这一机制。虽然 Keras 提供了高级 API,但亲手写一遍有助于深入理解其工作细节。
import numpy as np import tensorflow as tf import matplotlib.pyplot as plt def get_positional_encoding(seq_len, d_model): """ 生成标准正弦/余弦位置编码 Args: seq_len: 序列长度 d_model: 嵌入维度(建议为偶数) Returns: shape=(1, seq_len, d_model) 的张量 """ # [seq_len, 1] positions = np.arange(0, seq_len, dtype=np.float32)[:, None] # [1, d_model//2],控制每维的缩放比例 angle_rates = 1 / np.power(10000, (np.arange(0, d_model, 2, dtype=np.float32)) / d_model) # [seq_len, d_model//2] angle_rads = positions * angle_rates # 广播自动完成 # 初始化全零矩阵 pe = np.zeros((seq_len, d_model)) pe[:, 0::2] = np.sin(angle_rads) # 偶数维用 sin pe[:, 1::2] = np.cos(angle_rads) # 奇数维用 cos # 添加 batch 维度,并转为 TF 张量 return tf.cast(pe[np.newaxis, ...], dtype=tf.float32) # 示例:生成长度为 60、维度为 128 的位置编码 pos_encoding = get_positional_encoding(seq_len=60, d_model=128) print(f"编码形状: {pos_encoding.shape}") # (1, 60, 128)这段代码的关键点在于:
- 使用np.arange构造位置轴;
- 利用广播机制一次性计算所有角度;
- 通过切片操作0::2和1::2实现偶奇交替填充;
- 最终增加batch维度以便直接与(batch_size, seq_len, d_model)的词嵌入相加。
可视化:观察编码模式
让我们画出热力图,看看这些编码长什么样:
plt.figure(figsize=(14, 6)) plt.pcolormesh(pos_encoding[0], cmap='RdBu', shading='auto') plt.colorbar(label='编码值') plt.xlabel('Embedding Dimension') plt.ylabel('Sequence Position') plt.title('Sinusoidal Positional Encoding (60×128)') plt.tight_layout() plt.show()你会看到明显的条纹状图案:横向是周期性波动,纵向则是逐渐拉长的波长。越往右的维度,颜色变化越平缓,说明其负责编码更大跨度的位置信息。
这正是设计意图的体现:模型可以在不同子空间中分别关注局部邻近性和全局结构性。
固定编码 vs 可学习编码:工程权衡
尽管原论文采用固定函数生成编码,但在实践中也有不少模型使用可学习的位置嵌入(Learned Position Embeddings),例如 BERT 和 T5。
| 特性 | 固定编码(Sinusoidal) | 可学习编码(Learned) |
|---|---|---|
| 是否参与训练 | 否 | 是 |
| 参数量 | 0 | max_len × d_model |
| 外推能力 | 强(支持任意长度) | 弱(限于训练长度) |
| 实现复杂度 | 极简 | 需维护 embedding table |
| 典型应用 | 原始 Transformer、TTS | BERT、GPT 系列 |
那么该如何选择?
- 快速原型开发、轻量化部署:推荐固定编码。无需额外参数,启动即用。
- 追求极致性能、任务长度固定:可用可学习编码,让模型自由拟合最优位置表示。
- 超长序列任务(如文档级 NLP):优先考虑支持外推的方案,如 ALiBi、RoPE 或改进版 sinusoidal 编码。
值得注意的是,在很多情况下两者性能差异不大。Google 的后续研究也表明,位置编码的具体形式并非决定性因素,关键是“要有”。
如何集成进你的模型?
最简单的做法是在嵌入层之后手动相加:
class PositionalEncodingLayer(tf.keras.layers.Layer): def __init__(self, seq_len, d_model, **kwargs): super().__init__(**kwargs) self.seq_len = seq_len self.d_model = d_model # 预生成编码表(避免重复计算) self.pos_encoding = get_positional_encoding(seq_len, d_model) def call(self, x): # x shape: (batch_size, seq_len, d_model) return x + self.pos_encoding[:, :tf.shape(x)[1], :] # 使用示例 embed_layer = tf.keras.layers.Embedding(vocab_size=10000, output_dim=128) pos_enc_layer = PositionalEncodingLayer(seq_len=512, d_model=128) # 构建输入流程 inputs = tf.keras.Input(shape=(None,), dtype=tf.int32) x = embed_layer(inputs) x = pos_enc_layer(x) # 加上位置信息这里有个重要优化:我们将位置编码作为非训练变量缓存起来,避免每次前向传播都重新计算。对于 GPU 上的长时间训练任务,这点小优化能显著提升效率。
此外,注意切片:tf.shape(x)[1]的使用——它允许处理变长序列,确保不会越界。
实战建议:那些容易踩的坑
务必保证
d_model为偶数
当前实现依赖偶数维度进行 sin/cos 分配。若为奇数,最后一维将无法正确赋值。避免在 mixed precision 训练中丢失精度
即使你使用mixed_float16策略,位置编码也应保持float32类型,防止因舍入误差破坏精细的位置信号。长序列需警惕数值饱和
对于超过 1024 的序列,某些维度的编码值可能已趋近 ±1。此时可尝试调整底数(如改用50000替代10000)以延缓饱和。不要忽略设备放置
将预生成的pos_encoding放在 GPU 上。否则每次调用都要从 CPU 拷贝数据,造成不必要的通信开销。考虑封装为 Keras Layer
这样不仅便于复用,还能随模型一起保存(SavedModel),保证推理时行为一致。
结语:简洁之下的深远影响
位置编码看似只是一个小小的加法操作,但它承载着 Transformer 能否真正理解语言顺序的核心能力。它的设计体现了深度学习中一种典型的工程智慧:用最少的假设、最简单的函数,解决最关键的问题。
通过手写实现这一模块,我们不仅能掌握其数学本质,更能建立起对模型底层运作的直觉。这种“知其所以然”的能力,在调试复杂模型、定制新架构时尤为宝贵。
如今,已有更多先进的位置编码方案涌现,比如旋转位置编码(RoPE)、相对位置编码(Relative PE)、动态插值等。但无论形式如何演进,它们都在回应同一个根本需求:如何高效地将时空结构注入到无序的注意力机制之中。
而这一切的起点,或许就是那个简单的正弦公式。