1. 位置编码到底在解决一个什么问题
很多人第一次接触 Transformer 的时候,会把注意力机制当成全部,觉得只要把 Q、K、V 算明白,模型就懂了。但真正动手写过一遍的人很快会发现一个致命问题:如果把每个 Token 的嵌入向量直接丢进注意力层,模型对顺序是完全无感的。
这话怎么理解?你可以把自注意力想象成一个会议室,所有人同时发言,每个人根据"谁跟我相关"来决定听谁的。但问题是,这个机制里没有任何东西告诉它"谁先说的、谁后说的"。你把一句话"我打你"和"你打我"喂进去,如果不做额外处理,模型算出来的注意力权重分布是一模一样的。词序信息在自注意力的计算过程中被彻底抹平了。
这就是位置编码(Positional Encoding)要解决的核心矛盾。Transformer 抛弃了 RNN 那种天然带时序的循环结构,换来了并行计算的能力,但代价就是丢掉了序列的顺序感。位置编码的任务,就是人为地把"这个 Token 在序列里排第几"这个信息注入到模型里,让注意力机制在计算相关性的时候,能够区分"第 1 个词和第 5 个词的关系"与"第 3 个词和第 5 个词的关系"。
原始论文《Attention Is All You Need》给出的方案非常巧妙:不加额外的可学习参数,而是用一组固定的正余弦函数,根据位置索引直接算出一个和词嵌入维度相同的向量,然后直接加到词嵌入上。这个设计在当时看有点反直觉——为什么不用一个可学习的 Embedding 层,像处理词一样处理位置?答案藏在后面几节里,我们慢慢拆。
先记住一个结论:位置编码不是可有可无的装饰,它是 Transformer 能理解语序、能处理长距离依赖、能在翻译和生成任务上work的前提。没有它,你训练出来的模型连"猫追老鼠"和"老鼠追猫"都分不清。
2. 正余弦公式的推导与直觉理解
2.1 公式长什么样
原始论文里的位置编码公式是这样的:
$$PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$
$$PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$
其中pos是 Token 在序列中的位置(从 0 开始),i是维度索引,d_model是嵌入维度。偶数维度用 sin,奇数维度用 cos,每一对维度共享同一个频率。
第一次看到这个公式,大部分人的反应是"这啥玩意"。但如果你把它拆开看,逻辑其实很清晰。分母那个 $10000^{2i/d_{model}}$ 是一个频率缩放因子。当 i 很小(靠前的维度)时,这个因子接近 1,波长很短,函数震荡得很快;当 i 很大(靠后的维度)时,因子接近 10000,波长很长,函数变化很慢。
这就形成了一个多尺度的位置表示:低维度编码精细的、局部的位置变化,高维度编码粗粒度的、全局的位置信息。有点像用不同精度的尺子同时测量同一个位置。
2.2 为什么是 sin 和 cos 配对
这里有个很关键的数学性质:对于任意固定的偏移量 k,$PE_{pos+k}$ 可以表示为 $PE_{pos}$ 的线性函数。具体来说,利用三角恒等式:
$$\sin(pos+k) = \sin(pos)\cos(k) + \cos(pos)\sin(k)$$
$$\cos(pos+k) = \cos(pos)\cos(k) - \sin(pos)\sin(k)$$
这意味着模型可以通过学习一个线性变换,轻松地表达"相对位置"的概念。换句话说,虽然编码的是绝对位置,但它的结构天然支持相对位置的推理。这对语言任务极其重要——"形容词在名词前面两个位置"这种相对关系,比"形容词在第 7 个位置"这种绝对关系更有泛化价值。
我个人的理解是:这个设计让模型在训练时见过"位置 3 和位置 5 的关系",推理时遇到"位置 103 和位置 105"也能举一反三。如果换成纯可学习的绝对位置 Embedding,这种泛化能力会弱很多,因为每个位置都是独立学的,位置 103 的向量在训练集里可能压根没出现过。
2.3 一个容易忽略的细节:为什么直接相加而不是拼接
论文选择把位置编码加到词嵌入上,而不是拼接(concatenate)。这个选择背后有工程考量。
拼接会让维度翻倍,后续所有线性层的参数量都跟着涨,计算成本明显上升。而相加保持了维度不变,且在实践中效果并不差。为什么相加能work?一种解释是:高维空间里,模型有足够的容量把"语义信息"和"位置信息"编码到不同的子空间里,注意力层的权重矩阵可以学会分别提取这两类信息。当然,这也意味着位置信息和语义信息存在一定程度的"混叠",这也是后来很多改进方案(比如 RoPE、ALiBi)试图解决的问题。
提示:如果你自己实现时想改成拼接,记得同步调整后续所有层的输入维度,否则会在第一个线性层直接报维度不匹配。
3. 从零手写位置编码:代码逐行拆解
3.1 最朴素的实现
先给一个能直接跑的 PyTorch 版本,这是理解原理最快的方式:
import torch import math def positional_encoding(max_len, d_model): pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) return pe这段代码有几个地方值得掰开说。
div_term那一行是很多人卡住的地方。公式里写的是 $\frac{1}{10000^{2i/d_{model}}}$,但代码里用的是exp(arange * (-log(10000)/d_model))。这两者是等价的,因为:
$$\frac{1}{10000^{2i/d}} = 10000^{-2i/d} = e^{-\frac{2i}{d}\ln 10000}$$
用exp和log的组合,是为了数值稳定性。直接算10000 ** (2i/d)在维度较大时容易溢出或精度损失,取对数再指数化是标准做法。
pe[:, 0::2]和pe[:, 1::2]是 Python 的切片语法,分别取偶数列和奇数列,对应公式里的 sin 和 cos。
3.2 一个我踩过的坑:位置从 0 还是从 1 开始
论文里pos从 0 开始,但有些开源实现从 1 开始。这个差异在短序列上几乎看不出影响,但在长序列或者需要严格对齐论文结果时会导致细微偏差。我的建议是统一从 0 开始,和主流实现保持一致,避免复现实验时对不上数。
另一个坑是max_len的设置。位置编码矩阵是预先算好的,如果推理时序列长度超过了max_len,就会索引越界。常见做法是设一个足够大的值(比如 512 或 2048),或者干脆在 forward 里动态生成。动态生成更灵活,但每次前向都要重算,训练时会有额外开销。我一般是在初始化时按最大可能长度算好,存成 buffer,用register_buffer注册,这样它不会被当成可学习参数,但会跟着模型一起搬到 GPU。
3.3 怎么把它接进模型
位置编码算好之后,用法很简单:
class TransformerEmbedding(nn.Module): def __init__(self, vocab_size, d_model, max_len=512, dropout=0.1): super().__init__() self.token_embedding = nn.Embedding(vocab_size, d_model) self.register_buffer('pe', positional_encoding(max_len, d_model)) self.dropout = nn.Dropout(dropout) self.d_model = d_model def forward(self, x): # x: (batch, seq_len) seq_len = x.size(1) x = self.token_embedding(x) * math.sqrt(self.d_model) x = x + self.pe[:seq_len, :] return self.dropout(x)注意那个* math.sqrt(self.d_model)。论文里对词嵌入做了缩放,目的是让词嵌入和位置编码的数值量级匹配。位置编码的值域是 [-1, 1],而随机初始化的词嵌入标准差通常在 1 左右,乘以 $\sqrt{d_{model}}$ 后两者量级更接近,训练更稳定。这个细节很多教程会漏掉,但不加的话在某些任务上收敛会变慢。
4. 位置编码的几种主流变体与选型对比
4.1 可学习位置编码
BERT 用的是可学习的位置 Embedding,本质上是给每个位置分配一个可训练的向量。优点是灵活,模型可以自己学出最适合任务的位置表示;缺点是无法外推到训练时没见过的长度,而且参数量随最大长度线性增长。
GPT 系列早期也用过可学习位置编码,后来转向了 RoPE。这个演进路线本身就说明了固定编码在长序列场景下的优势。
4.2 正弦编码 vs 可学习编码:怎么选
| 维度 | 正弦编码 | 可学习编码 |
|---|---|---|
| 参数量 | 0 | max_len × d_model |
| 长度外推 | 有一定外推能力 | 基本无法外推 |
| 训练灵活性 | 固定,不可调 | 可随任务自适应 |
| 实现复杂度 | 低 | 低 |
| 长序列表现 | 一般 | 依赖训练长度 |
我的经验是:如果你的任务序列长度固定且不长(比如分类任务,长度 128 以内),两者差别不大,可学习编码甚至可能略好;如果涉及变长序列、长文本或者需要外推,正弦编码更稳。当然,现在做长上下文基本都会上 RoPE 或 ALiBi,这是后话。
4.3 RoPE 和 ALiBi 为什么后来居上
RoPE(旋转位置编码)的思路是把位置信息通过旋转矩阵注入到 Q 和 K 上,而不是加到嵌入里。它的好处是相对位置信息在注意力计算中自然体现,且理论上支持更好的长度外推。ALiBi 更简单粗暴,直接在注意力分数上加一个和距离成正比的惩罚项,距离越远惩罚越大。
这两个方案在长文本模型里已经是事实标准。但理解正弦编码仍然是基础,因为 RoPE 的很多直觉(比如多频率、相对位置)都能从正弦编码里找到影子。我建议的学习路径是:先把正弦编码吃透,再看 RoPE 会顺畅很多。
5. 实测中暴露的问题与排查思路
5.1 训练 loss 不下降,先怀疑位置编码
有一次我复现一个小型 Transformer 做序列标注,loss 卡在 2.3 附近死活不降。排查了半天,最后发现是位置编码的max_len设成了 64,而实际序列长度有 100 多,超出的部分索引到了 buffer 之外,PyTorch 没报错但取到了错误的值(因为切片越界返回空,广播后行为诡异)。
排查这类问题的思路是:先打印位置编码矩阵的形状和实际用到的切片范围,确认pe[:seq_len]的seq_len没有超过 buffer 的第一维。这个检查花不了两分钟,但能省掉几小时的瞎猜。
5.2 数值量级不匹配导致的梯度问题
另一个常见现象是训练初期梯度爆炸或者 loss 直接变 NaN。除了学习率设太大,还要检查词嵌入有没有做 $\sqrt{d_{model}}$ 缩放。如果没缩放,词嵌入的数值可能比位置编码大一个量级,两者相加后位置信息被淹没,模型相当于没用位置编码,同时数值范围偏大又容易引发梯度问题。
验证方法很简单:在 forward 里打印x.std()和pe.std(),看两者是否在同一量级。正常情况应该在 1 附近。
5.3 位置编码该不该加 dropout
论文在嵌入相加之后加了 dropout。我的实测是:小数据集上 dropout 很有必要,能明显缓解过拟合;大数据集上影响不大,但也不会有害。dropout 率一般设 0.1,和 Transformer 其他位置的 dropout 保持一致就行。
注意:dropout 是加在"词嵌入 + 位置编码"之后,不是分别加。顺序错了效果会有差异。
6. 几个容易被问到的细节问题
6.1 位置编码能不能只加在输入层
标准做法是只在最底层加一次,后续层不再重复加。原因是位置信息一旦注入,会通过残差连接和注意力机制在层间传播,不需要每层都重新注入。有些实验尝试过每层都加,效果并没有提升,反而增加了计算。
6.2 batch 内不同长度的序列怎么处理
位置编码是按位置索引取的,和 batch 无关。所以只要保证 padding 的位置不参与注意力计算(通过 attention mask),位置编码本身不需要特殊处理。padding 位置也会被加上位置编码,但因为有 mask,它们不会影响有效位置的计算结果。
6.3 为什么用 10000 这个基数
10000 是论文里的经验值,没有严格的理论推导。它的作用是让不同维度的频率覆盖一个合理的范围。基数太小,高频维度震荡过快,相邻位置的编码差异过大;基数太大,低频维度变化过慢,区分度不够。实践中 10000 在大多数任务上表现良好,也有工作尝试过其他值,但收益不明显。
我在实际项目里的体会是,位置编码这块看起来简单,但真正踩过坑之后才会重视那些"看起来无关紧要"的细节——缩放因子、buffer 注册、切片边界。这些地方不出问题则已,一出问题就是训练不收敛这种让人抓狂的症状。把正弦编码的每个环节都亲手实现一遍,比看十篇讲解都管用。