transformer模型详解之Encoder-Decoder结构代码实现
Transformer模型中的Encoder-Decoder结构:从原理到TensorFlow实现
在自然语言处理领域,我们曾长期被RNN、LSTM这类递归结构所主导。它们按时间步依次处理序列,看似合乎直觉,却隐藏着致命弱点——无法并行化,且随着序列增长,远端信息逐渐衰减。直到2017年那篇划时代的论文《Attention is All You Need》出现,Transformer架构横空出世,彻底打破了这一桎梏。
它没有使用任何循环或卷积,仅靠自注意力机制就实现了对全局依赖的高效建模。而其核心骨架,正是Encoder-Decoder结构。今天,我们就以TensorFlow为工具,深入拆解这个现代大模型的“基因模板”,看看它是如何一步步将输入序列转化为输出结果的。
从一个翻译任务说起
设想你要构建一个英译中系统。输入是“I love machine learning”,期望输出是“我爱机器学习”。传统做法可能用LSTM逐词编码,再逐词解码。但这种方式效率低,训练慢,还容易遗忘开头的信息。
Transformer则完全不同:
- Encoder一次性读取整个英文句子,生成一组富含上下文的向量表示;
- Decoder则基于这些表示,一个字一个字地生成中文译文。
关键在于,每个词的生成都“看得见”源句的所有词——这正是注意力机制赋予的能力。
核心组件解析
缩放点积注意力:最基础也是最关键的一步
注意力的本质是什么?是加权求和。给定查询(Query)、键(Key)和值(Value),我们计算Query与每个Key的相关性,得到权重,然后对Value做加权平均。
公式如下:
$$
\text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V
$$
为什么要除以$\sqrt{d_k}$?这是为了稳定梯度。当维度$d_k$较大时,点积结果会变得非常大,导致softmax进入饱和区,梯度接近零。缩放后能有效缓解这个问题。
下面是其实现代码:
def scaled_dot_product_attention(q, k, v, mask=None):
"""计算缩放点积注意力"""
matmul_qk = tf.matmul(q, k, transpose_b=True)
dk = tf.cast(tf.shape(k)[-1], tf.float32)
scaled_attention_logits = matmul_qk / tf.math.sqrt(dk)
if mask is not None:
scaled_attention_logits += (mask * -1e9)
attention_weights = tf.nn.softmax(scaled_attention_logits, axis=-1)
output = tf.matmul(attention_weights, v)
return output, attention_weights
这里有个细节:mask * -1e9。对于填充位置(padding)或未来时刻(在解码器中),我们需要屏蔽掉某些token的影响。通过加上一个极大的负数,softmax后这些位置的概率就近似为0。
多头注意力:让模型“多角度看问题”
单个注意力机制只能捕捉一种类型的关联。就像人眼一次只能聚焦一个角度,如果我们能让模型同时从多个子空间观察数据呢?
这就是多头注意力的设计思想。我们将Q、K、V分别投影到$h$个不同的低维空间,在每个空间独立进行注意力计算,最后将结果拼接起来。
class MultiHeadAttention(layers.Layer):
def __init__(self, d_model, num_heads):
super(MultiHeadAttention, self).__init__()
self.num_heads = num_heads
self.d_model = d_model
assert d_model % self.num_heads == 0
self.depth = d_model // self.num_heads
self.wq = layers.Dense(d_model)
self.wk = layers.Dense(d_model)
self.wv = layers.Dense(d_model)
self.dense = layers.Dense(d_model)
def split_heads(self, x, batch_size):
x = tf.reshape(x, (batch_size, -1, self.num_heads, self.depth))
return tf.transpose(x, perm=[0, 2, 1, 3])
def call(self, q, k, v, mask=None):
batch_size = tf.shape(q)[0]
q = self.wq(q)
k = self.wk(k)
v = self.wv(v)
q = self.split_heads(q, batch_size)
k = self.split_heads(k, batch_size)
v = self.split_heads(v, batch_size)
scaled_attention, attention_weights = scaled_dot_product_attention(
q, k, v, mask)
scaled_attention = tf.transpose(scaled_attention, perm=[0, 2, 1, 3])
concat_attention = tf.reshape(scaled_attention,
(batch_size, -1, self.d_model))
output = self.dense(concat_attention)
return output
注意这里的split_heads操作:先reshape成(batch_size, seq_len, num_heads, depth),再转置为(batch_size, num_heads, seq_len, depth),这样每个head可以独立处理自己的那份数据。
最终通过一个Dense(d_model)完成信息融合。这种“分而治之+聚合”的策略,显著增强了模型的表达能力。
构建编码层与解码层
Encoder Layer:信息提取的核心单元
每个编码层包含两个子模块:
1. 多头自注意力(Self-Attention):每个词关注输入序列中的所有词
2. 前馈网络(Feed-Forward Network):对每个位置独立进行非线性变换
两者都配有残差连接和层归一化,这是稳定深层网络训练的关键。
class EncoderLayer(layers.Layer):
def __init__(self, d_model, num_heads, dff, rate=0.1):
super(EncoderLayer, self).__init__()
self.mha = MultiHeadAttention(d_model, num_heads)
self.ffn = point_wise_feed_forward_network(d_model, dff)
self.layernorm1 = layers.LayerNormalization(epsilon=1e-6)
self.layernorm2 = layers.LayerNormalization(epsilon=1e-6)
self.dropout1 = layers.Dropout(rate)
self.dropout2 = layers.Dropout(rate)
def call(self, x, training, mask):
attn_output = self.mha(x, x, x, mask)
attn_output = self.dropout1(attn_output, training=training)
out1 = self.layernorm1(x + attn_output)
ffn_output = self.ffn(out1)
ffn_output = self.dropout2(ffn_output, training=training)
out2 = self.layernorm2(out1 + ffn_output)
return out2
你可能会问:为什么FFN也加残差?因为前馈网络本质上是一个位置-wise的操作,如果不加跳跃连接,深层堆叠时容易出现优化困难。加入x本身的信息流,有助于梯度回传。
另外,Dropout放在哪里也很讲究——是在子层输出后应用,而不是在输入前。这是为了保证残差路径的完整性。
Decoder Layer:兼顾历史与未来的生成引擎
解码层比编码层复杂一些,因为它要处理三种信息流:
- 自注意力(带掩码):只能看到当前及之前的位置,防止未来信息泄露
- Encoder-Decoder注意力:利用编码器的输出作为K和V,解码器状态作为Q,实现跨模态对齐
- 前馈网络
class DecoderLayer(layers.Layer):
def __init__(self, d_model, num_heads, dff, rate=0.1):
super(DecoderLayer, self).__init__()
self.mha1 = MultiHeadAttention(d_model, num_heads)
self.mha2 = MultiHeadAttention(d_model, num_heads)
self.ffn = point_wise_feed_forward_network(d_model, dff)
self.layernorm1 = layers.LayerNormalization(epsilon=1e-6)
self.layernorm2 = layers.LayerNormalization(epsilon=1e-6)
self.layernorm3 = layers.LayerNormalization(epsilon=1e-6)
self.dropout1 = layers.Dropout(rate)
self.dropout2 = layers.Dropout(rate)
self.dropout3 = layers.Dropout(rate)
def call(self, x, enc_output, training, look_ahead_mask, padding_mask):
attn1 = self.mha1(x, x, x, look_ahead_mask)
attn1 = self.dropout1(attn1, training=training)
out1 = self.layernorm1(x + attn1)
attn2 = self.mha2(enc_output, enc_output, out1, padding_mask)
attn2 = self.dropout2(attn2, training=training)
out2 = self.layernorm2(out1 + attn2)
ffn_output = self.ffn(out2)
ffn_output = self.dropout3(ffn_output, training=training)
out3 = self.layernorm3(out2 + ffn_output)
return out3
这里有两个mask:
- look_ahead_mask:确保解码时不会偷看未来的词
- padding_mask:忽略源句或目标句中的填充部分
实际工程中,这两个mask往往需要组合使用。例如在训练阶段,目标序列也需要mask掉padding部分,否则模型可能学会依赖无效位置。
开发环境:别再浪费时间配环境了
写完模型代码只是第一步。真正让人头疼的是运行环境——Python版本、CUDA驱动、cuDNN、TensorFlow编译选项……稍有不慎就会报错。
这时候,官方提供的深度学习镜像就成了救命稻草。
为什么选择TensorFlow 2.9镜像?
TensorFlow 2.9是一个成熟的稳定版本,支持:
- 动态图(Eager Execution)开发体验
- XLA加速线性代数运算
- 完整的SavedModel导出流程
- GPU混合精度训练(tf.keras.mixed_precision)
而官方镜像tensorflow/tensorflow:2.9.0-gpu-jupyter已经预装了:
- Jupyter Notebook/Lab
- SSH服务
- CUDA 11.2 + cuDNN 8
- 常用科学计算库(NumPy、Pandas等)
一条命令即可启动:
docker run -it \
-p 8888:8888 \
-p 2222:22 \
--gpus all \
tensorflow/tensorflow:2.9.0-gpu-jupyter
浏览器打开http://localhost:8888,输入日志中的token,立刻进入编程界面。
💡 小技巧:挂载本地目录保持数据持久化
bash docker run -v $(pwd):/tf/project ...
再也不用担心容器删除后代码丢失。
实际工作流:从开发到部署
在一个典型的机器翻译项目中,完整的链路是这样的:
[开发者]
↓ (Jupyter编写代码)
[容器内训练]
↓ (保存模型)
[SavedModel导出]
↓ (TF Serving加载)
[生产API]
具体步骤如下:
- 环境启动:拉取镜像并运行容器
- 数据准备:加载WMT等双语语料,构建词汇表,进行tokenization
- 模型搭建:使用上述类组合成完整Transformer
- 训练执行:
python model.compile(optimizer='adam', loss='sparse_categorical_crossentropy') model.fit(train_dataset, epochs=20, validation_data=val_dataset) - 模型保存:
python model.save('transformer_translation_model') - 服务部署:
bash docker run -p 8501:8501 --mount type=bind,source=$(pwd)/model,target=/models/model \ -e MODEL_NAME=model tensorflow/serving
整个过程无需手动安装任何依赖,团队成员共享同一镜像即可保证环境一致。
工程实践中的关键考量
显存优化:不是越大越好,而是越省越好
Transformer训练吃显存是出了名的。Base模型动辄占用10GB以上。几个实用建议:
-
启用混合精度训练:
python policy = tf.keras.mixed_precision.Policy('mixed_float16') tf.keras.mixed_precision.set_global_policy(policy)
可减少约40%显存消耗,速度提升15%-25%。 -
使用梯度累积模拟大batch:
python # 相当于batch_size=64,但每次只处理16 for i, (x, y) in enumerate(dataset): with tf.GradientTape() as tape: logits = model(x, training=True) loss = loss_fn(y, logits) gradients = tape.gradient(loss, model.trainable_variables) if (i + 1) % 4 == 0: optimizer.apply_gradients(zip(gradients, model.trainable_variables))
日志与监控:不只是看loss曲线
除了基本的loss和accuracy,还可以记录:
- 注意力权重热力图(可视化哪些词被重点关注)
- 学习率变化曲线
- 梯度范数(判断是否爆炸/消失)
- 每层输出分布(检测内部激活异常)
配合TensorBoard,把这些指标统一展示,极大提升调试效率。
写在最后
Transformer的Encoder-Decoder结构,早已超越最初的机器翻译任务,成为各种Seq2Seq场景的事实标准。无论是文本摘要、对话生成,还是代码补全、语音识别,背后都能看到它的影子。
更重要的是,这套架构的设计哲学极具启发性:
- 并行优于串行:放弃RNN的时间展开,换来训练速度的飞跃;
- 全局优于局部:自注意力直接建立任意两词之间的联系;
- 模块化优于耦合:每一层都是标准化组件,易于堆叠和替换。
而借助像TensorFlow-v2.9这样的标准化开发环境,我们可以把精力集中在模型创新本身,而不是陷入环境配置的泥潭。
当你下次面对一个新的生成任务时,不妨先问问自己:能不能用一个Encoder-Decoder来解决?也许答案就是肯定的。
更多推荐


所有评论(0)