一、绝对位置编码(Sinusoidal):原版Transformer的“坐标系统”
说到Transformer,绕不开的一个问题就是:它怎么知道词与词之间的先后顺序?
你看,RNN天生就是按顺序处理数据的,位置信息天然就嵌在时间步里。但Transformer不一样,它用的是自注意力机制,输入是一整个序列同时进去的。说白了,模型眼里这些词是“一堆词”,谁先谁后它根本不知道。
那怎么办?加个位置编码呗。
原版Transformer用的是Sinusoidal位置编码,也就是基于正弦和余弦函数的编码方式。我个人觉得,这是最优雅的“无参数”位置编码方案之一。今天我们就把它掰开揉碎了讲清楚。
核心一句话:Sinusoidal位置编码就是用不同频率的正弦波,给每个位置打上一个独一无二的“指纹”。
1.1 公式长什么样?
先上公式,别怕,我带你一步步拆解:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
其中:
- pos:位置索引,比如第0个词、第1个词……
- i:维度索引,从0到d_model/2 - 1
- d_model:模型的隐藏层维度,比如512
嗯,这里要注意:偶数维度用sin,奇数维度用cos。为什么这么设计?后面我会讲。
1.2 公式背后的直觉
你想想看,这个公式本质上在做什么?
它把每个位置pos映射到一个d_model维的向量上。这个向量的每个分量,都是一个三角函数值。而且不同维度的三角函数频率不一样——维度越低,频率越低;维度越高,频率越高。
我举个例子你就明白了:
- 第0维(i=0):频率是1/10000^0 = 1,周期是2π,变化很慢
- 第1维(i=1):频率是1/10000^(2/512) ≈ 0.96,稍微快一点
- ……
- 第255维(i=255):频率是1/10000^(510/512) ≈ 0.0001,变化极快
说白了,低维分量编码的是“大范围”的位置关系,高维分量编码的是“精细”的位置偏移。这跟傅里叶变换的思路有点像——用不同频率的正弦波去逼近一个信号。
我的经验:我在做长文本分类项目时,发现Sinusoidal编码对512以内的位置关系编码得特别好。但一旦序列长度超过训练时的最大长度,外推性就开始下降。嗯,这其实是它的一个软肋。
1.3 可视化:看看位置编码长什么样
光说理论太干,我们画个图看看。下面这张SVG图展示了前16个位置、前8个维度的Sinusoidal编码值:
从图上你能看到什么?
- 低维(dim0, dim1):颜色变化很慢,相邻位置的值差不多。这编码的是“大概在哪个区域”。
- 高维(dim6, dim7):颜色变化极快,几乎每个位置都不一样。这编码的是“精确在哪个点”。
这种设计有个好处:模型可以通过低维分量快速判断两个词是否在同一个“段落”里,通过高维分量区分它们的具体顺序。
1.4 为什么用sin和cos交替?
这个问题我当年也困惑过。为什么不用全sin或者全cos?
答案其实很巧妙:为了支持线性变换。
原论文证明了,对于任意偏移k,位置pos+k的编码可以表示为位置pos编码的线性变换。也就是说:
PE(pos+k) = T(k) * PE(pos)
其中T(k)是一个只与k有关的旋转矩阵。这个性质让模型可以轻松学习到“相对位置”的关系——我只要学会一个线性变换,就能处理任意偏移。
如果全用sin或者全用cos,这个性质就不成立了。嗯,数学就是这么优雅。
避坑指南:我曾经在一个项目里自己手写位置编码,结果把sin和cos的顺序搞反了——奇数维用了sin,偶数维用了cos。模型训练了3天,BLEU分数死活上不去。后来排查了一整天才发现是这个低级错误。所以,偶数sin、奇数cos,这个口诀要记牢。
1.5 优缺点分析
任何技术方案都有两面性,Sinusoidal也不例外。我根据自己的实践经验,给你列个表:
| 维度 | 优点 | 缺点 |
|---|---|---|
| 参数 | 无参数,不需要学习,省显存 | 无法根据任务自适应调整 |
| 外推性 | 理论上可以处理任意长度(因为公式是连续的) | 实际外推效果有限,超过训练长度2倍后性能明显下降 |
| 相对位置 | 支持线性变换,可以表示相对位置关系 | 需要模型自己学会利用这个性质,不是显式注入的 |
| 实现 | 代码简单,几行就能实现 | 调试时不容易直观理解每个维度的含义 |
| 与其他模块交互 | 与注意力机制天然兼容,加法注入不影响梯度 | 与LayerNorm的顺序需要注意(先加编码还是先Norm?) |
我个人觉得,Sinusoidal最大的优点是“无参数、零成本”。你不需要为它分配任何可学习参数,也不会过拟合。但它的缺点也很明显——不够灵活。对于不同的任务,最优的位置编码方式可能完全不同,但Sinusoidal不会变。
注意:如果你处理的是超长序列(比如10万token以上),Sinusoidal的外推性可能会让你失望。我建议你考虑RoPE或者ALiBi这类更现代的位置编码方案。不过,对于大多数常规任务(512-2048长度),Sinusoidal完全够用。
1.6 代码实现:几行搞定
最后,我们看看代码。PyTorch实现Sinusoidal位置编码,其实就几行:
import torch
import math
def sinusoidal_position_encoding(seq_len, d_model):
"""
seq_len: 序列长度
d_model: 模型维度
返回: [seq_len, d_model] 的位置编码矩阵
"""
pe = torch.zeros(seq_len, d_model)
position = torch.arange(0, seq_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))
# 偶数维度用sin,奇数维度用cos
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
return pe
# 使用示例
seq_len, d_model = 128, 512
pe = sinusoidal_position_encoding(seq_len, d_model)
# pe.shape -> [128, 512]
这段代码里有个小技巧:div_term的计算用了对数变换,避免了直接计算10000^(2i/d_model)可能导致的数值溢出。嗯,写代码时要注意这些细节。
小结
Sinusoidal位置编码是Transformer的“原配”方案,它用数学公式为每个位置生成一个独一无二的编码向量。它的核心思想是:用不同频率的正弦波,在连续空间中编码离散的位置信息。
虽然现在有了更先进的方案(比如RoPE),但Sinusoidal仍然是理解位置编码的最佳起点。你把它搞懂了,后面学其他方案会轻松很多。
公众号:蓝海资料掘金营,微信deep3321