4. Transformer 架构详解:编码器-解码器结构归纳总结
这一章,我们深入 Transformer 的心脏:编码器-解码器架构。说白了,它就是整个模型的两个拳头——一个负责读,一个负责写。刚开始接触时,我也觉得这两部分长得差不多,很容易搞混。直到我在一次分子生成项目中,因为没搞清楚两者的职责,导致模型训练不收敛……嗯,从那以后,我再也不敢轻视它们的差异了。
我习惯把编码器理解为「理解器」,它把输入序列压缩成富含上下文信息的表示。解码器则是「生成器」,它利用这些表示,一步步吐出新的序列。在分子生成任务里,编码器读取已有的分子片段,解码器预测下一个原子或官能团。
4.1 核心架构总览
先看看整体的模样。一个标准的 Transformer 由 N 层堆叠的编码器与 N 层堆叠的解码器组成。每一层内部,又包含两个核心子层:多头自注意力(Multi-Head Self-Attention)和前馈网络(Feed-Forward Network)。
比起 RNN 的串行处理,Transformer 的优势很明显——它能并行计算,而且能捕捉长距离依赖。你想想看,处理一个包含 200 个原子的分子,RNN 要一步步走 200 步,Transformer 一步到位把全局关系全看了。
下面这张图,概括了编码器-解码器的整体数据流:
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 的位置。最经典的做法是正弦余弦位置编码。
分子序列的局部结构很重要,比如两个相邻的碳原子是单键还是双键。位置编码能让模型知道「谁挨着谁」。不过我后来发现,对于变长分子序列,可学习的位置编码效果往往更好——尤其是在生成任务中。
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 在每层所有位置共享参数,但不同层之间不同。它会给每个位置的表示增加非线性变换能力。
残差链接解决了梯度消失的问题。每层的输出都加上原始输入,使得梯度能直接回传。层归一化则保证了训练稳定性。我习惯把归一化放在子层内部(Pre-LN),这比原始论文的 Post-LN 收敛更快。
4.5 常见实现坑点
最后,说说我实际项目里遇到的几个典型问题:
- 掩码维度搞错: 解码器掩码必须 shape 为 (batch, 1, seq_len, seq_len)。少了一个维度,广播会出问题,模型偷偷看到未来信息。
- Dropout 位置: 注意力 softmax 之后加 dropout,FFN 激活之后也加 dropout。但编码器和解码器之间不要加,否则信息流动会受干扰。
- 初始化: 我见过权重初始化太大导致训练崩了的情况。用均匀分布初始化,范围 ±0.1 或者小一些。
说到底,Python 实现的核心也就是几十行代码。关键是理解每一层「为什么」存在。我个人习惯在实现时,先手写一个编码器层,验证自注意力输出形状正确,再堆叠解码器。这样排查起来清晰很多。