)
一、Transformer的顺序盲区Transformer的核心是自注意力机制Self-Attention它最大的优点是并行计算——所有词一次性输入互相计算权重。但成也萧何败也萧何。并行计算的代价是模型天生不关心词的顺序。对于模型来说[我 爱 你]和[你 爱 我]在进入注意力层之前如果只靠词嵌入Word Embedding这两组数据的数学表示是完全一样的只是交换了行。结论词嵌入只告诉你这个词是什么意思但没告诉你这个词在哪个位置。所以我们需要显式地把位置信息塞给模型——这就是位置编码Positional Encoding的由来。二、Transformer输入部分的两个组件Transformer的输入部分由两大模块构成词嵌入层Word Embedding将单词索引如100 2 421映射为稠密的连续向量。位置编码层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_len60): super().__init__() self.dropout nn.Dropout(pdropout) # 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)三、代码难点逐条拆解重点上面的代码看似简短但藏着好几个新手容易卡住或面试必问的细节我来逐一拆解。 难点1div_term为什么要用exp-log公式原本是如果直接写成10000 ** (2*i/d_model)当i较大比如接近256时2*i/d_model接近110000**1 10000还好但问题在于取倒数时如果先算分母再求倒数分母可能极大10000^100这种直接数值溢出。利用恒等式变形取倒数即为代码中torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model)完美对应了这个式子。用exp替代pow是工程上保证数值稳定的标准做法。 难点2pe[:, 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_gradFalse不参与梯度更新并且会自动跟随模型在CPU/GPU之间迁移。面试高频题位置编码需要训练吗答不需要。Sinusoidal编码是固定的数学公式用buffer注册是最佳实践。 难点4前向传播中self.pe[:, :x.size(1), :]的切片self.pe存储了max_len60个位置的编码但实际输入序列长度可能只有seq_len4。通过切片只取前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, dropout0.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面试核心很多新手问为什么不直接用pos0,1,2,3...或者one-hot原因有三数值有界Sin/Cos 将数值死死限制在[-1, 1]之间防止梯度爆炸。相对位置可解根据三角恒等式这意味着位置 posk 的编码可以通过位置 pos 的编码线性变换得到。模型可以通过注意力机制轻松学到相隔k个词这种相对关系。多分辨率表达低维度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既能理解语义又能感知顺序。