一、绝对位置编码(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编码值:

Sinusoidal位置编码热力图(前16位置 × 前8维度) 值接近1 值接近0 值接近-1 pos=0 pos=1 pos=2 pos=3 pos=4 pos=5 pos=6 pos=7 pos=8 pos=9 pos=10 pos=11 pos=12 pos=13 pos=14 pos=15 dim0 dim1 dim2 dim3 dim4 dim5 dim6 dim7 颜色越红表示值越接近1,越蓝表示越接近-1,白色表示接近0

从图上你能看到什么?

  • 低维(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