4. Transformer 架构详解:编码器-解码器结构归纳总结

这一章,我们深入 Transformer 的心脏:编码器-解码器架构。说白了,它就是整个模型的两个拳头——一个负责读,一个负责写。刚开始接触时,我也觉得这两部分长得差不多,很容易搞混。直到我在一次分子生成项目中,因为没搞清楚两者的职责,导致模型训练不收敛……嗯,从那以后,我再也不敢轻视它们的差异了。

我习惯把编码器理解为「理解器」,它把输入序列压缩成富含上下文信息的表示。解码器则是「生成器」,它利用这些表示,一步步吐出新的序列。在分子生成任务里,编码器读取已有的分子片段,解码器预测下一个原子或官能团。

4.1 核心架构总览

先看看整体的模样。一个标准的 Transformer 由 N 层堆叠的编码器与 N 层堆叠的解码器组成。每一层内部,又包含两个核心子层:多头自注意力(Multi-Head Self-Attention)和前馈网络(Feed-Forward Network)。

比起 RNN 的串行处理,Transformer 的优势很明显——它能并行计算,而且能捕捉长距离依赖。你想想看,处理一个包含 200 个原子的分子,RNN 要一步步走 200 步,Transformer 一步到位把全局关系全看了。

小提示: 我建议在实际实现时,先把 N=6 设为默认值。这是原论文的经典配置,对于中小规模分子序列(长度 < 512),6 层足够。层数太多反而容易过拟合。

下面这张图,概括了编码器-解码器的整体数据流:

Transformer 编码器-解码器数据流 输入序列 (x₁, …, xₙ) 编码器 (Encoder) × N 层 多头自注意力 前馈网络 (FFN) 编码器输出 (Key, Value 矩阵) 解码器 (Decoder) × N 层 掩码自注意力 交叉注意力 输出序列 (y₁, …, yₘ) 解码器每层也包含前馈网络,此处简化显示

4.2 编码器:把输入序列变成上下文表示

编码器的核心任务,就是把输入的 token 序列,转换成一组能代表全局语义的向量(通常叫 hidden states)。对于分子序列,输入就是一个个原子或基团的 embedding。

每个编码器层都做两件事:多头自注意力和前馈网络。而且都用了残差连接和层归一化。

4.2.1 多头自注意力机制

说白了,自注意力就是让每个 token 去「看」其他所有 token,找出谁对当前 token 更重要。多头就是做多次这种计算,每次关注不同的角度。

举个例子,在分子 SMILES 序列里,一个苯环末端的碳原子,可能需要关注对位的碳才能确定自己的杂化状态。单头注意力可能抓不住这种远距离依赖,多头就可以多角度捕捉。

标准实现中,每个头的维度一般是 d_model / h。我用的时候,习惯把 h 设为 8,d_model 设为 512。这样每个头 64 维,计算效率和表达能力都平衡。

关键公式(简化版):
Attention(Q, K, V) = softmax(QKᵀ / √d_k) · V

其中 Q = X·W_Q, K = X·W_K, V = X·W_V
多头就是把 Q、K、V 分别切分成 h 份,各算一次注意力,最后拼起来再投影。

这个缩放因子 √d_k 很关键。为什么?因为 QKᵀ 的结果会随着维度 d_k 增大而数值变大,softmax 的梯度会变得非常小,模型就很难学习。我自己踩过坑:第一次写多头注意力时忘了加缩放,模型训了三天 loss 几乎不动。后来才发现是这里出了问题。

4.2.2 位置编码

Transformer 没有顺序概念,所以必须手动告诉模型 token 的位置。最经典的做法是正弦余弦位置编码。

分子序列的局部结构很重要,比如两个相邻的碳原子是单键还是双键。位置编码能让模型知道「谁挨着谁」。不过我后来发现,对于变长分子序列,可学习的位置编码效果往往更好——尤其是在生成任务中。

实用建议: 如果分子最大长度不超过 200,用可学习位置编码;超过 500,用正弦余弦。我在制药项目里用的是 128 维的位置编码,两者混合效果稳定。

4.3 解码器:一步一步生成新序列

解码器的输入是已经生成的部分序列(输出端),它要预测下一个 token。跟编码器比,解码器多了两个关键设计:掩码自注意力和交叉注意力。

4.3.1 掩码自注意力

生成时,模型不能「看到」未来的 token。比如生成分子片段 [C, C, O, H],模型要预测第四个位置时,只能用前三个的信息。掩码就是用一个上三角矩阵把未来位置遮住。

换句话说,每个位置只能跟自己以及更早的位置做注意力。这个掩码在训练时是最重要的——保证了自回归的正确性。

我曾经犯过一个低级错误:掩码矩阵的数据类型搞成了 float16,结果大批量训练时数值溢出,生成的分子全是无效 SMILES。排查了整整两天,最后发现是精度问题。嗯,细节真是魔鬼。

4.3.2 交叉注意力

这是解码器跟编码器通讯的桥梁。解码器的查询(Q)来自自身,而键(K)和值(V)来自编码器的输出。这样,每次生成新 token 时,模型都能参考原始输入序列的全局信息。

在分子生成里,交叉注意力决定了生成的原子「依赖」输入的哪些部分。比如,给模型一段已有的分子骨架,它可能会反复关注骨架上的某个官能团,然后生成侧链。

组件 编码器 解码器
输入 原始序列 (如 SMILES) 已生成的部分序列
自注意力 双向(全看) 单向(掩码)
交叉注意力 无 有(K,V 来自编码器)
输出用途 提供上下文表示 预测下一个 token
训练模式 Teacher forcing Teacher forcing + 掩码

4.4 前馈网络 & 残差链接

每层注意力后面,跟着一个简单的两层的 MLP。这个 FFN 在每层所有位置共享参数,但不同层之间不同。它会给每个位置的表示增加非线性变换能力。

注意: FFN 的隐藏层维度通常设为 d_ff = 4 × d_model。也就是说 d_model=512 时,d_ff=2048。这会导致参数暴涨。我建议在分子小数据集上先降成 2×,用 1024,既省显存又不损失太多性能。

残差链接解决了梯度消失的问题。每层的输出都加上原始输入,使得梯度能直接回传。层归一化则保证了训练稳定性。我习惯把归一化放在子层内部(Pre-LN),这比原始论文的 Post-LN 收敛更快。

4.5 常见实现坑点

最后,说说我实际项目里遇到的几个典型问题:

  • 掩码维度搞错: 解码器掩码必须 shape 为 (batch, 1, seq_len, seq_len)。少了一个维度,广播会出问题,模型偷偷看到未来信息。
  • Dropout 位置: 注意力 softmax 之后加 dropout,FFN 激活之后也加 dropout。但编码器和解码器之间不要加,否则信息流动会受干扰。
  • 初始化: 我见过权重初始化太大导致训练崩了的情况。用均匀分布初始化,范围 ±0.1 或者小一些。

说到底,Python 实现的核心也就是几十行代码。关键是理解每一层「为什么」存在。我个人习惯在实现时,先手写一个编码器层,验证自注意力输出形状正确,再堆叠解码器。这样排查起来清晰很多。