☰
Keras-Transformer中英翻译源码实战:从训练到推理避坑指南
2026/10/8 11:26:05 网站建设 项目流程

简介:这份资源是一套基于Python实现的中英文机器翻译项目,采用Keras-Transformer模型架构,面向计算机、人工智能、通信等专业的学生与从业者,可作为毕业设计、课程大作业或期末课设的参考方案,也适合想入门神经机器翻译的小白学习与进阶。压缩包共17个文件,约7.42MB,包含3个py源码、2个ipynb交互式笔记本、1个h5模型权重、6个pkl序列化数据、1个md说明文档及txt语料等,覆盖数据预处理、词表构建、模型训练与翻译推理的完整链路。项目已通过调试测试,可直接运行,答辩评审分达98分,代码结构清晰、注释完整,便于读者理解Transformer在翻译任务中的编码器-解码器实现细节。目前已有104人学习下载,适合在此基础上修改调整,扩展不同语向或功能。

1. 一份能直接跑的 Keras-Transformer 中英翻译源码,到底值不值得拆

如果你正在做毕业设计,选题是“基于 Python 的机器翻译”,大概率会遇到一个尴尬局面:Transformer 的论文看懂了,注意力公式也能默写,但真要从零搭一个能跑通、能收敛、能翻译出人话的模型,中间隔着一堆说不清的细节。这份基于 Keras 的 Transformer 中英翻译源码,解决的就是这个断层——它把编码器、解码器、多头注意力、位置编码、训练循环、推理解码全部串成一条可执行的链路,配文档说明,拿到手就能跑。适合谁?适合需要一份完整基线做二次开发的学生,也适合想用 Keras 而非 PyTorch 复现 Transformer 的工程师。我拆过不少“毕业设计级”的翻译项目,多数死在数据预处理和推理阶段,这份的完整度算中上,值得花时间过一遍。

2. 先看清骨架:Keras-Transformer 的模块划分与数据流

2.1 编码器-解码器结构在 Keras 里怎么落地

Transformer 的核心是“堆叠 + 残差 + 层归一化”,但 Keras 的实现方式和 PyTorch 有明显差异。这份源码用的是 Keras 函数式 API 加自定义层的方式,而不是tf.keras.Sequential硬拼。编码器由 N 个EncoderLayer组成,每层内部依次是:多头自注意力、残差连接、层归一化、前馈网络、再残差再归一化。解码器多了一个交叉注意力子层,用来对齐源语言和目标语言的表示。

我一般会先确认三件事:位置编码是加在嵌入之后还是拼在输入里、注意力掩码是加性掩码还是布尔掩码、层归一化放在残差前还是残差后。这份源码用的是“Post-LN”结构,也就是先残差再归一化,和原始论文一致。位置编码用的是正弦余弦函数,不是可学习嵌入,这意味着推理时对序列长度的外推能力稍好,但训练时对位置信息的拟合依赖数据量。

# 编码器层核心结构(基于源码整理) class EncoderLayer(tf.keras.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 = tf.keras.layers.LayerNormalization(epsilon=1e-6) self.layernorm2 = tf.keras.layers.LayerNormalization(epsilon=1e-6) self.dropout1 = tf.keras.layers.Dropout(rate) self.dropout2 = tf.keras.layers.Dropout(rate) def call(self, x, training, mask): attn_output, _ = self.mha(x, x, x, mask) # 自注意力:Q=K=V 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

这段代码里最容易被忽略的是mask的传递。编码器需要的是填充掩码,用来屏蔽 padding 位置;解码器需要的是前瞻掩码加填充掩码的组合。源码里把掩码生成单独抽成了create_padding_mask和create_look_ahead_mask两个函数,调用时再合并。参数d_model是嵌入维度,num_heads是头数,dff是前馈网络中间层维度。常见配置是d_model=512, num_heads=8, dff=2048,但毕业设计场景下如果显存不够,可以降到d_model=256, num_heads=4, dff=1024,翻译质量会降但能跑起来。

2.2 数据预处理:中英文分词与词表构建的取舍

中英文翻译的数据预处理比单语任务麻烦,因为中文需要分词,英文需要子词切分。这份源码用的是jieba做中文分词,英文用简单的空格加标点切分,然后统一转小写。词表构建用的是tf.keras.preprocessing.text.Tokenizer,分别对源语言和目标语言各建一个词表,保留频率最高的若干词,其余归入<unk>。

这里有个关键参数:MAX_LENGTH。源码里默认设的是 40,意思是超过 40 个 token 的句子会被截断。中英翻译的句子长度分布差异很大,中文 20 个字翻成英文可能变成 15 个词,也可能变成 30 个词。我建议先统计一下训练集里源句和目标句的长度分位数,把MAX_LENGTH设成覆盖 95% 样本的值,而不是拍脑袋定 40。

# 数据预处理与词表构建(基于源码整理) import jieba from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences def preprocess_cn(text): return ' '.join(jieba.cut(text.strip())) # 中文分词后用空格连接 def preprocess_en(text): text = text.lower().strip() # 简单标点分离,实际项目可换用 subword 方案 for p in ',.!?;:': text = text.replace(p, f' {p} ') return ' '.join(text.split()) # 构建词表 tokenizer_src = Tokenizer(num_words=8000, filters='', oov_token='<unk>') tokenizer_tgt = Tokenizer(num_words=8000, filters='', oov_token='<unk>') tokenizer_src.fit_on_texts(src_sentences) tokenizer_tgt.fit_on_texts(tgt_sentences) # 转序列并填充 src_seq = tokenizer_src.texts_to_sequences(src_sentences) tgt_seq = tokenizer_tgt.texts_to_sequences(tgt_sentences) src_padded = pad_sequences(src_seq, maxlen=MAX_LENGTH, padding='post') tgt_padded = pad_sequences(tgt_seq, maxlen=MAX_LENGTH, padding='post')

num_words=8000是个经验值,中英翻译任务如果训练集只有几万句对,8000 词表够用;如果数据量到几十万,可以提到 16000 或 32000。filters=''是为了保留标点,因为标点对翻译质量有影响,默认的 Tokenizer 会把标点过滤掉,这点源码里处理得对。padding='post'表示在句子后面补零,配合后面的掩码逻辑,补零位置会被屏蔽掉。

2.3 训练循环与损失函数:掩码损失怎么算

Keras 的model.fit在这种带掩码的序列到序列任务里不够灵活,源码用的是自定义训练循环加tf.GradientTape。损失函数是稀疏分类交叉熵,但关键点在于:计算损失时要屏蔽掉 padding 位置的贡献,否则模型会学会预测 padding,浪费容量。

# 自定义训练步骤(基于源码整理) loss_object = tf.keras.losses.SparseCategoricalCrossentropy( from_logits=True, reduction='none') def loss_function(real, pred): mask = tf.math.logical_not(tf.math.equal(real, 0)) # 屏蔽 padding loss_ = loss_object(real, pred) mask = tf.cast(mask, dtype=loss_.dtype) loss_ *= mask return tf.reduce_sum(loss_) / tf.reduce_sum(mask) @tf.function def train_step(inp, tar): tar_inp = tar[:, :-1] # 解码器输入:去掉最后一个 token tar_real = tar[:, 1:] # 解码器目标:去掉第一个 token(<start>) with tf.GradientTape() as tape: predictions = transformer(inp, tar_inp, training=True) loss = loss_function(tar_real, predictions) gradients = tape.gradient(loss, transformer.trainable_variables) optimizer.apply_gradients(zip(gradients, transformer.trainable_variables)) return loss

tar_inp和tar_real的错位是 Transformer 训练的经典操作:输入是<start> I love you,目标是I love you <end>。from_logits=True表示模型输出的是未经过 softmax 的 logits,损失函数内部会做 softmax,数值更稳定。reduction='none'是为了先算每个位置的损失,再用掩码加权,最后除以有效 token 数。如果这里直接用默认的reduction='sum',padding 位置的损失会混进去,模型收敛会变慢甚至发散。

3. 动手跑通:从环境配置到第一条翻译结果

3.1 环境依赖与版本匹配的坑

这份源码基于 TensorFlow 2.x 和 Keras,但 Keras 从 2.6 版本开始已经独立于 TensorFlow 之外,而 TensorFlow 2.15 之后又内置了 Keras 3.x。版本不匹配是第一个翻车点。源码文档里如果写的是tensorflow==2.10,那就老老实实装 2.10,不要用最新的 2.16,因为tf.keras的 API 在 2.16 里变化很大,自定义层的call方法签名可能不兼容。

# 建议的虚拟环境配置 python -m venv mt_env source mt_env/bin/activate # Windows 用 mt_env\Scripts\activate pip install tensorflow==2.10.0 pip install jieba numpy pandas matplotlib

如果用的是 GPU 版本,还要确认 CUDA 和 cuDNN 版本对应。TensorFlow 2.10 对应 CUDA 11.2 和 cuDNN 8.1。装错版本的表现是:import tensorflow不报错,但tf.config.list_physical_devices('GPU')返回空列表,训练时跑在 CPU 上,一个 epoch 要等半小时。我一般会先跑一行验证:

import tensorflow as tf print(tf.__version__) print(tf.config.list_physical_devices('GPU'))

如果 GPU 列表为空但机器有显卡,先检查驱动版本,再检查 CUDA 路径是否加进了LD_LIBRARY_PATH。Windows 下常见问题是 cuDNN 的 dll 文件没放到C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v11.2\bin下。

3.2 训练参数怎么设:batch size、学习率与 warmup

Transformer 对学习率敏感,原始论文用的是 warmup 策略:前若干步学习率线性上升,之后按步数的平方根倒数下降。这份源码里实现了CustomSchedule类,核心公式是d_model^-0.5 * min(step^-0.5, step * warmup_steps^-1.5)。

class CustomSchedule(tf.keras.optimizers.schedules.LearningRateSchedule): def __init__(self, d_model, warmup_steps=4000): super(CustomSchedule, self).__init__() self.d_model = tf.cast(d_model, tf.float32) self.warmup_steps = warmup_steps def __call__(self, step): arg1 = tf.math.rsqrt(step) arg2 = step * (self.warmup_steps ** -1.5) return tf.math.rsqrt(self.d_model) * tf.math.minimum(arg1, arg2)

warmup_steps=4000是论文默认值,但毕业设计的数据集通常只有几万句对,4000 步可能已经跑完好几个 epoch 了。我一般会按总训练步数 / 10来估 warmup,比如总步数 20000,warmup 设 2000。d_model要和模型配置一致,如果降到 256,学习率峰值会自动调整。batch size 方面,源码默认可能是 64,但如果显存不够,降到 32 或 16,同时把学习率按比例调小,否则梯度噪声太大,损失曲线会震荡。

训练时重点看两个指标:训练损失和验证损失。如果训练损失下降但验证损失上升,说明过拟合,可以加 dropout 或减少模型层数。如果两个都不降,先检查学习率是不是太大,或者掩码是不是写反了——掩码写反的典型表现是模型输出全是<unk>或重复同一个词。

3.3 推理阶段:贪心解码与 beam search 的取舍

训练完模型,推理阶段才是真正见真章的地方。源码里默认用的是贪心解码:每一步取概率最大的 token,拼到已生成序列后面,直到遇到<end>或达到最大长度。贪心解码快,但容易陷入局部最优,翻译结果可能生硬。

def evaluate(sentence, transformer, tokenizer_src, tokenizer_tgt, max_length=40): sentence = preprocess_cn(sentence) inputs = tokenizer_src.texts_to_sequences([sentence])[0] inputs = pad_sequences([inputs], maxlen=max_length, padding='post') inputs = tf.convert_to_tensor(inputs) start_token = tokenizer_tgt.word_index['<start>'] end_token = tokenizer_tgt.word_index['<end>'] decoder_input = tf.expand_dims([start_token], 0) result = [] for i in range(max_length): predictions = transformer(inputs, decoder_input, training=False) predicted_id = tf.argmax(predictions[:, -1, :], axis=-1).numpy()[0] if predicted_id == end_token: break result.append(predicted_id) decoder_input = tf.concat([decoder_input, [[predicted_id]]], axis=-1) return tokenizer_tgt.sequences_to_texts([result])[0]

这段代码里predictions[:, -1, :]取的是最后一个时间步的输出,因为解码器是自回归的,每次只预测下一个 token。decoder_input初始只有<start>,每生成一个 token 就拼上去。如果翻译结果出现重复词,比如“我我我我”,通常是训练不充分或学习率太大。如果结果全是<unk>,检查词表构建时oov_token有没有设对,以及推理时的分词方式和训练时是否一致。

beam search 的改进思路是:每步保留概率最高的 k 个候选序列,最后选整体概率最高的。源码里可能没实现,但可以自己加。k 一般取 3 到 5,再大收益递减且速度明显变慢。毕业设计答辩时,如果能把贪心和 beam search 的结果做个对比,是个不错的加分项。

4. 避坑与排查:这份源码最容易翻车的五个地方

4.1 损失不下降,输出全是重复词

现象:训练几个 epoch 后,损失卡在某个值不动,推理时模型反复输出同一个词,比如“的的的的”。

原因:最常见的是学习率太大或 warmup 没生效。Transformer 对初始学习率极其敏感,如果直接用Adam(learning_rate=0.001)而不加 warmup,前几百步梯度更新过猛,模型会陷入局部最优。另一个可能是掩码写反了,把有效位置屏蔽了,模型只能看到 padding。

解决:先确认CustomSchedule被正确传入优化器,打印前 100 步的学习率看是否从接近零开始上升。然后检查create_padding_mask里tf.math.equal(seq, 0)的逻辑,确保 0 对应的是 padding 位置。如果还不行,把学习率峰值降一半再试。

4.2 显存溢出(OOM)在 batch size 很小时也发生

现象:batch size 已经降到 8,还是报ResourceExhaustedError。

原因:Transformer 的显存占用和序列长度的平方成正比。如果MAX_LENGTH设成 100,即使 batch size 是 8,注意力矩阵也是 8×100×100,再加上多头和层数,显存很容易爆。另一个可能是dff设得太大,前馈网络的中间层维度是d_model的 4 倍,如果d_model=512,dff=2048,参数量不小。

解决:先把MAX_LENGTH降到实际需要的值,统计训练集长度分布,取 95 分位数。然后降d_model和dff,比如d_model=256, dff=1024。如果还不行,用tf.keras.mixed_precision开混合精度训练,显存能省三分之一左右。

4.3 推理时<start>和<end>找不到

现象:tokenizer_tgt.word_index['<start>']报KeyError。

原因:Tokenizer默认的filters会过滤掉<和>这些符号,导致<start>和<end>在构建词表时被拆成了start和end,或者直接被丢弃。源码里如果没设filters='',这个问题必现。

解决:构建 Tokenizer 时显式传filters='',并且在fit_on_texts之前手动把<start>和<end>加到句子的首尾。如果已经建好词表,检查word_index里有没有这两个 key,没有就重新建。

4.4 训练损失正常但翻译结果语序混乱

现象:损失降到 1.0 以下,但翻译出来的英文语序不对,比如“我吃饭”翻成“eat I rice”。

原因:位置编码没有正确加到嵌入上,或者解码器的前瞻掩码没生效,导致模型在预测第 t 个词时看到了第 t+1 个词。前瞻掩码的作用是屏蔽未来位置,如果掩码矩阵写成了全零或全一,自回归就失效了。

解决:检查create_look_ahead_mask返回的是不是上三角矩阵,对角线及以上为 1,其余为 0。然后确认在解码器自注意力里传入的掩码是combined_mask,即前瞻掩码和填充掩码的逐元素最大值。打印掩码矩阵的前几行看一眼,比猜快得多。

4.5 保存的模型加载后推理结果不一致

现象:训练完保存模型,重新加载后翻译结果和保存前不一样。

原因:Keras 保存自定义层时,如果get_config方法没实现或实现不完整,加载时自定义层的参数会丢失或初始化成随机值。这份源码里的MultiHeadAttention和EncoderLayer都是自定义层,必须实现get_config和from_config。

解决:在每个自定义层里补上get_config,返回初始化时的参数,比如d_model、num_heads、dff、rate。保存时用model.save_weights加model.load_weights的方式,比model.save整个模型更可靠。加载后先跑一条训练集里的句子,对比保存前的输出,一致再继续。

5. 进阶技巧:用 BLEU 验证翻译质量并做错误分析

跑通训练和推理只是第一步,毕业设计答辩时老师大概率会问“你怎么证明翻译质量”。BLEU 是机器翻译的经典自动评价指标,虽然不完美,但比肉眼扫几条结果有说服力。这份源码可能没带 BLEU 计算,但用nltk几行就能加上。

from nltk.translate.bleu_score import corpus_bleu, SmoothingFunction def compute_bleu(transformer, tokenizer_src, tokenizer_tgt, test_pairs): references = [] hypotheses = [] smoothie = SmoothingFunction().method1 for src, tgt in test_pairs: pred = evaluate(src, transformer, tokenizer_src, tokenizer_tgt) references.append([tgt.split()]) # 参考译文分词 hypotheses.append(pred.split()) # 模型输出分词 return corpus_bleu(references, hypotheses, smoothing_function=smoothie)

corpus_bleu的输入是“参考译文的列表的列表”和“模型输出的列表”,注意嵌套层级。SmoothingFunction用来处理 n-gram 匹配为零的情况,否则短句的 BLEU 会直接归零。测试集建议从训练集里切出 500 到 1000 句对,不要用训练集本身算 BLEU,那样分数虚高。

算完 BLEU 后,做错误分析比盯着分数更有用。我一般会把 BLEU 最低的 20 条样本拉出来,按错误类型归类:漏译、多译、词序错误、专有名词翻译错误、重复输出。漏译通常是MAX_LENGTH太短或模型欠拟合;词序错误多半是位置编码或掩码问题;专有名词错误说明词表覆盖不够,可以考虑加 subword 或增大词表。把错误分析写进毕业设计论文的“实验结果与分析”章节,比只贴一个 BLEU 分数扎实得多。

从那以后我每次拿到一个翻译项目,都会先跑通一条样本的完整链路——从分词、词表、模型前向、损失计算到推理输出——确认每一步的输入输出形状和数值范围都对,再开始大规模训练。这个习惯帮我省下了至少几十个小时的无效等待。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询