Keras-Transformer中英文机器翻译实战:从环境搭建到BLEU评估的避坑指南
简介这是一份面向高校学生与开发者的中英文机器翻译实战项目基于Python与Keras-Transformer模型实现可直接运行适合用作毕业设计、课程设计或项目开发参考。项目核心完全依托keras-transformer封装并可与作者另一份基于LSTM的机器翻译项目对比学习两者原始数据一致便于理解不同序列建模思路的差异。压缩包共17个文件约7.42MB包含3个py源码、2个ipynb实验笔记、1个h5训练权重、6个pkl序列化数据、1个md说明文档及若干pyc缓存文件覆盖数据预处理、模型训练与翻译推理全流程。已有363人学习下载。读者可获取完整源码、使用文档与训练好的模型权重直接复现中英文翻译效果并在此基础上调整网络结构或更换语料进行延伸实验快速搭建属于自己的翻译系统。1. 中英文机器翻译项目从标题到跑通中间隔着多少坑拿到「基于 Python 开发的中英文机器翻译基于 Keras-Transformer 模型可直接跑源码使用文档」这个标题很多人第一反应是去搜transformer 代码或者the illustrated transformer看完架构图觉得懂了打开编辑器却不知道第一个文件该建什么。这个项目真正要解决的不是「Transformer 是什么」而是「怎么用 Keras 把一篇中文变成英文并且今天就能跑出结果」。它适合三类人课程设计需要交一个完整可演示系统的学生、想从零手写一遍 Transformer 的 Python 入门者、以及需要快速搭一个翻译 baseline 再往上改的工程师。核心链路只有四步环境装好、数据对齐、模型搭对、推理跑通。每一步都有翻车点下面按实际落地顺序拆开讲。2. 环境与数据Keras-Transformer 跑起来的前置条件2.1 为什么选 Keras 而不是 PyTorch 版本搜transformer pytorch tensorflow会看到大量 PyTorch 实现但这个标题锁死了 Keras。Keras 的优势在于model.fit和model.save的封装程度高对于课程设计这种「要交一个能演示的完整系统」的场景训练循环、检查点保存、模型导出这三件事能省掉大量样板代码。TensorFlow 2.x 之后 Keras 作为官方高阶 APItf.keras.layers.MultiHeadAttention和LayerNormalization都是内置的不需要自己从矩阵乘法开始写。常见做法是编码器和解码器各堆 4 层d_model256num_heads8dff512。这个规模在单张 8GB 显存的卡上跑中英文平行语料十万句对级别是够的。如果你只有 CPU把层数降到 2d_model降到 128训练时间从几小时拉到一两天但至少能跑通。环境安装这一步python安装教程和vscode python环境配置是热搜常客说明很多人卡在这里。直接给命令# 建议 Python 3.8-3.10TensorFlow 2.10 之后对 Windows 原生支持有变化 python -m venv mt_env source mt_env/bin/activate # Windows 用 mt_env\Scripts\activate pip install tensorflow2.10.0 numpy pandas matplotlib # 如果要用 GPU先确认 CUDA 版本匹配再装 tensorflow-gpu python -c import tensorflow as tf; print(tf.__version__); print(tf.config.list_physical_devices(GPU))逻辑说明虚拟环境隔离掉系统 Python 的包冲突TensorFlow 2.10 是最后一个原生支持 Windows GPU 的版本再往上需要 WSL2。参数说明tensorflow2.10.0这个版本号不是随便定的它对应 CUDA 11.2 和 cuDNN 8.1装之前用nvidia-smi看驱动支持的 CUDA 上限。如果输出只有 CPU 没有 GPU检查tf.config.list_physical_devices(GPU)返回是否为空空的话九成是 CUDA 版本对不上。2.2 中英文平行语料的获取与清洗机器翻译的数据格式和python爬虫抓下来的网页数据完全是两回事。你需要的是句对一行中文对应一行英文用制表符分隔。常见来源是公开的平行语料库但下载下来往往有几万行是空的或者中英文长度比例离谱的。清洗脚本我一般这么写import re def clean_pair(zh, en, max_len100, ratio3.0): zh, en zh.strip(), en.strip() if not zh or not en: return None # 过滤长度极端不匹配的句对 if len(zh) max_len or len(en) max_len: return None if max(len(zh), len(en)) / max(1, min(len(zh), len(en))) ratio: return None # 去掉纯符号或纯数字的行 if re.fullmatch(r[\W\d_], zh) or re.fullmatch(r[\W\d_], en): return None return zh, en pairs [] with open(raw_corpus.txt, encodingutf-8) as f: for line in f: parts line.strip().split(\t) if len(parts) ! 2: continue cleaned clean_pair(parts[0], parts[1]) if cleaned: pairs.append(cleaned) with open(clean_corpus.txt, w, encodingutf-8) as f: for zh, en in pairs: f.write(f{zh}\t{en}\n) print(f清洗后句对数量: {len(pairs)})逻辑说明max_len100是字符数上限中英文都按字符算超过的句对训练时 padding 会吃掉大量显存。ratio3.0是长度比阈值中英文长度比通常在 1:1 到 1:2 之间超过 3 倍的基本是噪声。参数说明如果你的语料里英文单词数远多于中文字符数把ratio调到 4 或 5max_len根据你的显存调整8GB 显存建议不超过 80。清洗完之后要做分词。中文用jieba切成词英文用tokenizers或者简单的空格切分加小写化。这一步决定了词表大小词表太大 embedding 层参数爆炸太小会出现大量unk。常见做法是中文词表 8000英文词表 8000覆盖语料中 95% 以上的 token。注意不要用python爬虫直接抓在线翻译结果当训练数据那些结果本身有噪声训出来的模型会把噪声当规律学。3. Keras-Transformer 模型搭建编码器、解码器与注意力掩码3.1 位置编码为什么不能省Transformer 和 RNN 最大的区别是它没有循环结构所有位置并行输入。如果不加位置编码模型看到的「我打你」和「你打我」是一样的。Keras 里位置编码有两种写法一种是可学习的Embedding层一种是正弦余弦固定编码。我一般用固定编码因为训练数据量不大的时候可学习的位置编码容易过拟合。import numpy as np import tensorflow as tf def positional_encoding(max_len, d_model): # 生成正弦余弦位置编码矩阵 pos np.arange(max_len)[:, np.newaxis] i np.arange(d_model)[np.newaxis, :] angle pos / np.power(10000, (2 * (i // 2)) / np.float32(d_model)) angle[:, 0::2] np.sin(angle[:, 0::2]) angle[:, 1::2] np.cos(angle[:, 1::2]) return tf.cast(angle[np.newaxis, ...], dtypetf.float32) # 用法在 embedding 之后相加 class PositionalEmbedding(tf.keras.layers.Layer): def __init__(self, vocab_size, d_model, max_len100): super().__init__() self.d_model d_model self.embedding tf.keras.layers.Embedding(vocab_size, d_model) self.pos_encoding positional_encoding(max_len, d_model) def call(self, x): seq_len tf.shape(x)[1] x self.embedding(x) x * tf.math.sqrt(tf.cast(self.d_model, tf.float32)) return x self.pos_encoding[:, :seq_len, :]逻辑说明angle矩阵的偶数列用 sin奇数列用 cos这样每个位置都有一个唯一的编码向量。x * sqrt(d_model)是缩放因子防止 embedding 值太小被位置编码淹没。参数说明max_len要大于你训练数据中最长句子的长度否则超出部分没有位置编码模型会报维度错误。d_model必须和后续注意力层的key_dim一致。3.2 多头注意力与掩码解码器不能偷看未来编码器这边没什么特别的堆几层MultiHeadAttention加前馈网络就行。解码器这边有个关键点训练时解码器输入是完整的目标句子但预测第 t 个词的时候不能看到 t 之后的词。这就是因果掩码causal mask的作用。def create_causal_mask(seq_len): # 下三角矩阵1 表示可见0 表示屏蔽 mask tf.linalg.band_part(tf.ones((seq_len, seq_len)), -1, 0) return mask[tf.newaxis, tf.newaxis, :, :] # (1,1,seq_len,seq_len) class DecoderLayer(tf.keras.layers.Layer): def __init__(self, d_model, num_heads, dff, dropout0.1): super().__init__() self.mha1 tf.keras.layers.MultiHeadAttention(num_heads, d_model) self.mha2 tf.keras.layers.MultiHeadAttention(num_heads, d_model) self.ffn tf.keras.Sequential([ tf.keras.layers.Dense(dff, activationrelu), tf.keras.layers.Dense(d_model) ]) self.layernorm1 tf.keras.layers.LayerNormalization(epsilon1e-6) self.layernorm2 tf.keras.layers.LayerNormalization(epsilon1e-6) self.layernorm3 tf.keras.layers.LayerNormalization(epsilon1e-6) self.dropout tf.keras.layers.Dropout(dropout) def call(self, x, enc_output, training, look_ahead_mask, padding_mask): # 自注意力带因果掩码 attn1 self.mha1(x, x, x, attention_masklook_ahead_mask) out1 self.layernorm1(x self.dropout(attn1, trainingtraining)) # 交叉注意力Q 来自解码器K/V 来自编码器 attn2 self.mha2(out1, enc_output, enc_output, attention_maskpadding_mask) out2 self.layernorm2(out1 self.dropout(attn2, trainingtraining)) ffn_out self.ffn(out2) return self.layernorm3(out2 self.dropout(ffn_out, trainingtraining))逻辑说明mha1是解码器自注意力attention_mask传入因果掩码保证每个位置只能看到自己和之前的词。mha2是交叉注意力Query 来自解码器Key 和 Value 来自编码器输出这一步让解码器能「看」到源语言信息。参数说明num_heads必须能整除d_model否则MultiHeadAttention会报错。dropout0.1是常规起点过拟合严重时调到 0.2 或 0.3。注意look_ahead_mask和padding_mask要分开传。padding mask 屏蔽的是填充的 0因果 mask 屏蔽的是未来位置。两个 mask 在mha1里要合并常见做法是取逻辑与。3.3 训练循环与学习率预热Keras 的model.fit可以直接用但 Transformer 原论文用了 warmup 学习率调度前 4000 步线性增加之后按步数平方根倒数衰减。这个调度对训练稳定性影响很大不用的后果是前期 loss 震荡甚至发散。class WarmupSchedule(tf.keras.optimizers.schedules.LearningRateSchedule): def __init__(self, d_model, warmup_steps4000): super().__init__() self.d_model tf.cast(d_model, tf.float32) self.warmup_steps warmup_steps def __call__(self, step): step tf.cast(step, tf.float32) arg1 tf.math.rsqrt(step) arg2 step * (self.warmup_steps ** -1.5) return tf.math.rsqrt(self.d_model) * tf.math.minimum(arg1, arg2) # 编译模型 optimizer tf.keras.optimizers.Adam(WarmupSchedule(d_model256), beta_10.9, beta_20.98, epsilon1e-9) model.compile(optimizeroptimizer, losstf.keras.losses.SparseCategoricalCrossentropy(from_logitsTrue, ignore_class0), metrics[accuracy])逻辑说明arg1是衰减项arg2是线性增长项取最小值实现先增后减。ignore_class0让 padding 位置的 loss 不计入梯度。参数说明warmup_steps根据你的数据集大小调小数据集 2000 步就够大数据集保持 4000。beta_20.98比默认的 0.999 小是 Transformer 原论文的设置对稀疏梯度更友好。4. 推理与评估BLEU 分数和实际翻译效果差在哪4.1 贪心解码与束搜索的取舍训练完模型推理时每一步选概率最大的词叫贪心解码实现简单但容易陷入重复。束搜索beam search保留 top-k 个候选最后选整体概率最高的序列翻译质量通常更好但速度慢 k 倍。def translate(model, tokenizer_zh, tokenizer_en, sentence, max_len100, beam_width3): # 中文分词并转 id tokens tokenizer_zh.encode(sentence) encoder_input tf.constant([tokens]) # 编码器输出只算一次 enc_output model.encoder(encoder_input, trainingFalse) # 解码器从 start 开始 start_id tokenizer_en.token_to_id([START]) end_id tokenizer_en.token_to_id([END]) sequences [[start_id, 0.0]] for _ in range(max_len): all_candidates [] for seq, score in sequences: if seq[-1] end_id: all_candidates.append((seq, score)) continue dec_input tf.constant([seq]) look_ahead_mask create_causal_mask(len(seq)) predictions model.decoder(dec_input, enc_output, trainingFalse, look_ahead_masklook_ahead_mask) logits model.final_layer(predictions[:, -1, :]) probs tf.nn.log_softmax(logits, axis-1).numpy()[0] top_k np.argsort(probs)[-beam_width:] for token_id in top_k: all_candidates.append((seq [int(token_id)], score float(probs[token_id]))) # 按分数排序保留 beam_width 个 sequences sorted(all_candidates, keylambda x: x[1] / len(x[0]), reverseTrue)[:beam_width] if all(s[-1] end_id for s, _ in sequences): break best_seq sequences[0][0] return tokenizer_en.decode([t for t in best_seq if t not in (start_id, end_id)])逻辑说明enc_output只算一次因为编码器输入不变。每一步对当前 beam 里的每个序列预测下一个词取 top-k 扩展候选最后按长度归一化分数排序。参数说明beam_width3是质量和速度的平衡点调到 5 质量提升有限但显存占用明显增加。max_len设成训练数据最长句子的 1.2 倍防止死循环。4.2 BLEU 分数高不代表翻译能看BLEU 基于 n-gram 重叠率对词序和同义词不敏感。一个常见翻车场景模型把「我今天很开心」翻译成「I am very happy today」BLEU 可能只有 0.3但人看完全没问题。反过来模型输出「I today very happy am」这种词序错乱但词都对的句子BLEU 可能反而更高。评估时我一般做两件事一是算 BLEU 看趋势二是抽 20 句人工看。BLEU 从 0.1 涨到 0.2 说明训练有效但从 0.35 涨到 0.38 可能只是过拟合了训练集的表达方式。人工看的时候重点检查三类错误漏译源句有信息目标句没有、重复同一个词连续出现、乱序词都对但顺序离谱。from nltk.translate.bleu_score import corpus_bleu references [[[i, am, happy]], [[how, are, you]]] hypotheses [[i, am, very, happy], [how, you, are]] score corpus_bleu(references, hypotheses) print(fBLEU: {score:.4f})逻辑说明references是参考译文的列表每个元素是一个句子的多个参考列表。hypotheses是模型输出。参数说明corpus_bleu默认用 4-gram短句会惩罚较重可以加weights(0.5, 0.5, 0, 0)只看 1-gram 和 2-gram。5. 避坑与排查训练不收敛、显存爆炸、翻译重复5.1 现象loss 从第一个 epoch 就不降原因最常见的是学习率太大或者位置编码没加。Transformer 对学习率敏感没有 warmup 直接上 1e-3 很容易震荡。另一个隐蔽原因是 padding mask 没传对模型在填充的 0 上计算注意力梯度被噪声主导。解决先确认positional_encoding在 embedding 之后加了再检查attention_mask是否传入了MultiHeadAttention。如果都对了把学习率降到 1e-4 试一个 epochloss 还不降就检查数据里中英文是否对齐——有时候清洗脚本会把空行过滤掉导致错位。5.2 现象训练到一半显存溢出原因max_len设太大或者 batch size 太大。Transformer 的注意力矩阵是seq_len * seq_len序列长度翻倍显存占用翻四倍。另一个原因是model.fit默认不释放中间张量多个 epoch 后碎片累积。解决把max_len从 100 降到 60batch size 从 64 降到 32。如果还不够在model.compile里加run_eagerlyFalse默认就是 False但有些人调试时改成了 True 忘了改回来。显存实在紧张就用梯度累积小 batch 跑多次再更新一次参数。5.3 现象翻译结果重复同一个词原因解码器陷入局部最优贪心解码每一步都选同一个高概率词。训练数据里如果有一句话重复出现多次模型会记住这个模式。另一个原因是end_id没有被正确学习模型不知道什么时候停。解决推理时改用束搜索束宽设 3 到 5。训练数据里去掉重复句对同一句话出现超过 3 次就只保留一条。检查[END]token 在目标句子中的比例如果不到 5%说明模型没学会停止需要在 loss 里给end_id加权。5.4 现象BLEU 分数很高但翻译结果读不通原因过拟合。模型记住了训练集的句子测试集里稍微换个说法就翻车。另一个原因是验证集和训练集有重叠评估分数虚高。解决训练前用sklearn.model_selection.train_test_split按 8:1:1 切分确保验证集和测试集不出现在训练集里。加 dropout 和早停验证集 loss 连续 3 个 epoch 不降就停。数据量小于 5 万句对时层数降到 2d_model降到 128减少参数量。5.5 现象GPU 利用率低训练速度慢原因数据管道是瓶颈。Python 的tf.data.Dataset如果没用prefetch和num_parallel_callsGPU 算完一个 batch 要等 CPU 准备下一个。另一个原因是 batch size 太小GPU 并行度不够。解决在dataset.map里加num_parallel_callstf.data.AUTOTUNE在dataset.batch之后加.prefetch(tf.data.AUTOTUNE)。batch size 在显存允许范围内尽量大8GB 显存跑d_model256的模型batch size 可以到 64。6. 从能跑到好用三个提升翻译质量的具体技巧第一个技巧是标签平滑label smoothing。把目标 token 的 one-hot 从 1 改成 0.9其余概率均分 0.1。这个操作让模型不那么自信泛化能力更好。Keras 里用tf.keras.losses.CategoricalCrossentropy(label_smoothing0.1)就行但注意要把目标从 sparse 转成 one-hot或者自己写一个 sparse 版本的平滑 loss。第二个技巧是检查点平均checkpoint averaging。训练最后几个 epoch 的模型参数取平均比单独用最后一个 checkpoint 的 BLEU 高 0.5 到 1 个点。实现方式是每 1000 步存一次权重最后加载最近 5 个取平均。这个操作对课程设计这种「分数就是一切」的场景性价比很高。第三个技巧是反向翻译back translation。把英文用另一个翻译模型翻成中文和原始中文组成新的句对加入训练。数据量能翻倍BLEU 通常涨 1 到 2 个点。但前提是你得有一个还过得去的英中翻译模型鸡生蛋问题适合已经有 baseline 之后再做。# 标签平滑的 sparse 实现 def smoothed_loss(y_true, y_pred, smoothing0.1, vocab_size8000): y_true tf.cast(y_true, tf.int32) # 忽略 padding 的 0 mask tf.cast(tf.not_equal(y_true, 0), tf.float32) y_true_onehot tf.one_hot(y_true, depthvocab_size) y_true_smooth y_true_onehot * (1 - smoothing) smoothing / vocab_size loss -tf.reduce_sum(y_true_smooth * tf.nn.log_softmax(y_pred, axis-1), axis-1) return tf.reduce_sum(loss * mask) / tf.reduce_sum(mask)逻辑说明mask把 padding 位置的 loss 置零y_true_smooth是平滑后的标签分布。参数说明smoothing0.1是原论文设置数据量小的时候可以调到 0.2数据量大就保持 0.1。vocab_size必须和模型输出层维度一致不一致会报维度错误。我自己的习惯是每改一个超参数只跑 2 个 epoch 看 loss 趋势趋势对了再跑完整训练。这样一轮实验从半天缩到一小时试错成本低很多。模型这行没有后悔药但有小步快跑的笨办法。希望帮到你。本文还有配套的精品资源点击获取