资讯动态

Transformer位置编码原理与实战:从正弦公式到手写PyTorch实现

发布时间:2026/10/26 13:31:53 来源:尧图企业网站定制
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, 2i1)} \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_{posk}$ 可以表示为 $PE_{pos}$ 的线性函数。具体来说利用三角恒等式$$\sin(posk) \sin(pos)\cos(k) \cos(pos)\sin(k)$$$$\cos(posk) \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, dtypetorch.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_len512, dropout0.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 可学习编码怎么选维度正弦编码可学习编码参数量0max_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 注册、切片边界。这些地方不出问题则已一出问题就是训练不收敛这种让人抓狂的症状。把正弦编码的每个环节都亲手实现一遍比看十篇讲解都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑