Transformer绝对位置编码:从词嵌入到位置编码(原理 + 代码详解)

一、Transformer的"顺序盲区"

Transformer的核心是自注意力机制(Self-Attention),它最大的优点是并行计算——所有词一次性输入,互相计算权重。

但成也萧何,败也萧何。并行计算的代价是:模型天生不关心词的顺序

对于模型来说,[我, 爱, 你][你, 爱, 我]在进入注意力层之前,如果只靠词嵌入(Word Embedding),这两组数据的数学表示是完全一样的(只是交换了行)。

结论:词嵌入只告诉你"这个词是什么意思",但没告诉你"这个词在哪个位置"。所以,我们需要显式地把位置信息塞给模型——这就是位置编码(Positional Encoding)的由来。

二、Transformer输入部分的两个组件

Transformer的输入部分由两大模块构成:

  1. 词嵌入层(Word Embedding):将单词索引(如100, 2, 421)映射为稠密的连续向量。

  2. 位置编码层(Positional Encoding):生成位置向量,与词嵌入相加,共同作为编码器的输入。

2.1 词嵌入层实现(含一个容易被忽略的细节)

先来看词嵌入层的代码:

class Embedding(nn.Module): def __init__(self, vocab_size, d_model): super().__init__() self.vocab_size = vocab_size self.d_model = d_model self.embed = nn.Embedding(vocab_size, d_model) def forward(self, x): # 注意:乘以 sqrt(d_model) 是原始Transformer的trick return self.embed(x) * math.sqrt(self.d_model)

🔴 重点强调:为什么要乘以 √d_model?

这是论文《Attention Is All You Need》中明确提到的操作,也是面试常见追问。

  • nn.Embedding默认初始化服从 ~N(0,1),即每个元素的方差≈1,整个向量的方差≈d_model。

  • 在后续的自注意力中,Q·K^T 会除以 √d_model 以控制方差(防止softmax进入梯度饱和区)。

  • 将词嵌入放大 √d_model 倍,可以使得 Q·K^T 的数值范围保持稳定,这是前后配合的设计,而不是随意为之。

记忆口诀:嵌入先放大,注意力再缩放,一乘一除,梯度稳住

2.2 位置编码层的完整实现

接下来是绝对位置编码(Sinusoidal)的标准实现,也是本文的核心。

class PositionalEncoding(nn.Module): def __init__(self, d_model, dropout, max_len=60): super().__init__() self.dropout = nn.Dropout(p=dropout) # 1. 初始化一个全零矩阵 [max_len, d_model] pe = torch.zeros(max_len, d_model) # 2. 生成位置索引 [max_len, 1] position = torch.arange(0, max_len).unsqueeze(1) # 3. 计算分母项:10000^(2i/d_model) 的倒数 # 重点!!!这里用了 exp + log 的小技巧 div_term = torch.exp( torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model) ) # 4. 计算角度 pos * div_term angle = position * div_term # [max_len, d_model/2] # 5. 偶数索引用sin,奇数索引用cos pe[:, 0::2] = torch.sin(angle) pe[:, 1::2] = torch.cos(angle) # 6. 增加batch维度 [1, max_len, d_model] pe = pe.unsqueeze(0) # 7. 注册为缓冲区(不参与训练,但随模型保存和移动) self.register_buffer('pe', pe) def forward(self, x): # x: [batch, seq_len, d_model] # 将词嵌入与位置编码相加(只取当前序列长度的编码) x = x + self.pe[:, :x.size(1), :] return self.dropout(x)

三、代码难点逐条拆解(重点!)

上面的代码看似简短,但藏着好几个新手容易卡住面试必问的细节,我来逐一拆解。

🔥 难点1:div_term为什么要用exp+-log

公式原本是:

如果直接写成10000 ** (2*i/d_model),当i较大(比如接近256)时,2*i/d_model接近1,10000**1 = 10000还好,但问题在于取倒数时,如果先算分母再求倒数,分母可能极大(10000^100这种),直接数值溢出

利用恒等式变形:

取倒数即为:

代码中torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model)完美对应了这个式子。exp替代pow,是工程上保证数值稳定的标准做法。

🔥 难点2:pe[:, 0::2]pe[:, 1::2]是什么?

这是PyTorch的切片步长语法:

  • 0::2表示从第0列开始,每隔2列取一次,即索引 0, 2, 4, 6...(所有偶数维度)。

  • 1::2表示从第1列开始,每隔2列取一次,即索引 1, 3, 5, 7...(所有奇数维度)。

angle的形状是[max_len, d_model/2],正好一半的维度用sin填充,另一半用cos填充,最终拼成完整的[max_len, d_model]

🔥 难点3:为什么要用self.register_buffer

这是PyTorch工程中极其重要但又容易被忽略的细节。

  • 如果写成self.pe = pe,它只是一个普通属性,不会出现在模型的state_dict中,也不会在调用.to(device)时自动跟随模型移动到GPU。

  • 使用register_buffer后,pe成为模型的一部分,但requires_grad=False(不参与梯度更新),并且会自动跟随模型在CPU/GPU之间迁移。

面试高频题:位置编码需要训练吗?
答:不需要。Sinusoidal编码是固定的数学公式,用buffer注册是最佳实践。

🔥 难点4:前向传播中self.pe[:, :x.size(1), :]的切片

self.pe存储了max_len=60个位置的编码,但实际输入序列长度可能只有seq_len=4
通过切片只取前seq_len个位置,保证与词嵌入的序列维度对齐,然后直接相加(广播机制自动将[1,4,512]加到[2,4,512]上)。

四、完整测试流程

def test_transformer_input(): vocab_size, d_model = 1000, 512 # 1. 词嵌入 embed = Embedding(vocab_size, d_model) # 模拟输入:2个句子,每个4个词(这里用的是单词索引) x = torch.tensor([ [100, 2, 421, 300], [500, 888, 306, 509] ]) embed_x = embed(x) # [2, 4, 512] # 2. 位置编码 pos_enc = PositionalEncoding(d_model, dropout=0.1) final_x = pos_enc(embed_x) # [2, 4, 512] print(f"最终输出形状: {final_x.shape}") # torch.Size([2, 4, 512]) return final_x

五、为什么Sinusoidal编码用Sin/Cos?(面试核心)

很多新手问:为什么不直接用pos(0,1,2,3...)或者one-hot

原因有三:

  1. 数值有界:Sin/Cos 将数值死死限制在[-1, 1]之间,防止梯度爆炸。

  2. 相对位置可解
    根据三角恒等式:

    这意味着位置 pos+k 的编码,可以通过位置 pos 的编码线性变换得到。模型可以通过注意力机制,轻松学到"相隔k个词"这种相对关系。

  3. 多分辨率表达:低维度(i小)频率高,能区分邻近位置;高维度(i大)频率低,负责粗粒度的长程位置。

六、绝对位置编码的局限性

虽然Sinusoidal很优雅,但在当今的大模型(如LLaMA)中已逐渐被替代:

局限性说明
长度外推差虽然公式能算无限长,但模型训练时只见过2048长度,突然给它4096,注意力分布会乱掉。
绝对位置偏执只关心"你在第3个位置",不关心"你离我有多远"。长文本中,相对距离往往更重要。

现代替代方案

  • 可学习位置编码(BERT):灵活性高,但受限于最大长度。

  • RoPE旋转位置编码(LLaMA):通过旋转矩阵将位置信息"旋转"进词向量,长文本泛化能力极强。

七、总结

模块关键操作为什么这么做
词嵌入embed(x) * sqrt(d_model)配合注意力层的缩放因子,稳定梯度
位置编码exp(-log(10000)/d_model)数值稳定地计算分母,防止溢出
位置编码pe[:, 0::2] = sin/pe[:, 1::2] = cos交替填充,形成多频率位置特征
注册机制register_buffer('pe', pe)固定编码不训练,但随模型迁移
前向传播x + pe[:, :x.size(1), :]切片对齐后相加,融合语义与位置

一句话总结:词嵌入负责"是什么",位置编码负责"在哪里",两者相加,让Transformer既能理解语义,又能感知顺序。