简介:基于Transformer的日语到中文神经机器翻译系统完整实现,面向深度学习初学者、NLP方向学习者及需要完成课程设计或毕业设计的高校学生。系统覆盖从数据预处理、模型训练到结果读取的完整流程,有助于理解自注意力机制在机器翻译中的应用,并掌握基于Python的NMT项目搭建方法。压缩包共10个文件,以Python脚本为主,另有JSON数据文件和Markdown说明文档,整体大小116KB。其中脚本对应数据分割、预处理、序列统计、样例读取等模块,JSON文件记录语料统计信息,说明文档则提供项目使用指引,结构清晰,便于对照学习。目前已有37人浏览学习,适合作为毕业设计或期末大作业的参考范例。通过阅读源码与运行项目,学习者可以直观了解Transformer翻译系统的数据流水线、参数配置与推理输出方式,为后续开展更复杂的NLP研究打下基础。
1. 日语到中文,为什么这个Transformer能直接当毕设用
中文和日语的语序差异——日语谓语在句尾、中文谓语在句中——让基于短语表的旧式翻译系统遇到长句几乎必崩,而Transformer的注意力机制天然不依赖源语言词序,这正是它能用来做日译中的根本原因。这份基于Transformer的日语到中文神经机器翻译系统.zip,是一套可以直接跑的完整工程:语料清洗、子词切分、模型训练、beam search解码都有对应模块,代码组织方式也贴近课程设计/毕业设计的答辩节奏。适合两类人:一是深度学习课设做到Transformer、想找个能落地的NLP题目的在校生;二是期末大作业准备用Python交一个“完整系统”的选手。解压后能直接看到训练日志和译文输出,不是只有模型文件的黑匣子。
2. 架构与选型:Transformer做日译中的三个关键设计
2.1 Seq2Seq到Transformer:日译中任务为什么换架构
老式神经机器翻译走的是Seq2Seq:编码器用RNN/LSTM把日文源句逐个读进去,最后压成一个固定维度的向量,解码器再从这个向量里生成中文。这条路的痛点很明显——句子超过30个词,编码器最后那个向量里存不下前面说过的内容,长距离信息基本靠“记性”硬扛。日语的修饰关系经常跨越多词,比如「昨日買った本を読んだ」里,“昨日買った”修饰“本”,在中文里要翻成“我读了昨天买的书”,名词和修饰语的位置整个调转。RNN对这种远端依赖的学习效率非常低,翻译长句时主语丢失、指代错乱是常态。
Transformer换了个思路:不做循环,而是用自注意力(self-attention)让任意两个位置的token直接建立依赖。源句里的“昨日買った”和“本”在注意力矩阵里会被分配一个很高的相关权重,跨了三个词也能一步关联上。更重要的是,Transformer可以并行计算整句话的表示,训练速度比RNN快一个量级。在日译中这个场景里,语序差异和长距离修饰刚好都是attention的强项,所以架构选型上不是“追求最新”,而是“对症下药”。
这套资源里的模型实现就是标准Transformer:Encoder六层、Decoder六层,多头注意力8头,d_model=512。答辩时如果被问“为什么不用LSTM”,回答“日语谓语后置导致源句信息需要跨位置关联,self-attention每个位置的计算路径都是1步,而LSTM要走t步”就足够立住了。
2.2 Encoder-Decoder与多头注意力:信息怎么从日文流向中文
具体到翻译过程,Encoder负责把日文句子变成一组上下文向量,Decoder每生成一个中文词,都会去attend这组向量。这不是黑匣子——训练早期你可以把注意力权重打印出来,能看到“は”这类助词在生成中文主语时参与度很低,而实义词的参与度很高。这种可解释性对毕设答辩非常有用。
Decoder有两个注意力层:第一层是masked self-attention,保证生成第t个词时看不到未来的词,这是自回归的硬约束;第二层是cross-attention,查的是Encoder输出的源句上下文。多头注意力的意义在于不同头关注不同关系:有的头关注句法依赖,有的头关注位置远近。8个头在日译中里很够用,头数翻倍到16对效果提升微乎其微,但显存占用明显上涨,不建议在课设里加。
训练阶段用的技巧是teacher forcing:解码器输入的是真实译文的前缀,而不是模型自己生成的上一个词。这样收敛快得多,代价是推理时模型没见过自己的错误输出,所以后面要专门处理beam search里的重复生成问题。这套资源里这两者是分开实现的,train.py走teacher forcing,translate.py走beam search,分得很清楚。
2.3 位置编码与训练技巧:让模型知道“先说什么”
Transformer没有循环结构,输入顺序一旦打乱,注意力照样能算,但翻译结果会乱。位置编码就是给每个token加一个位置向量,让模型知道“これは”出现在第1位、“ペン”出现在第2位。标准实现用sin/cos函数生成不同频率的编码,不需要训练,直接加到输入embedding上。
日译中里位置其实暗含一层特殊意义:日语的谓语在句尾,中文的谓语在句中,Decoder生成中文“这是”的时候要先attend日语句尾的“です/だ”,位置编码和跨语言注意力是配合工作的。如果你看到翻译结果动词堆在句尾,那通常是位置编码被dropout弄丢了或者训练数据里长度分布太极端。
训练技巧方面,这套工程用了label smoothing=0.1,避免模型对训练集的one-hot标签过自信。还有一个细节是warmup:前4000步学习率从0线性涨到3e-4,之后按步数的平方根衰减。Transformer对学习率比LSTM敏感,直接上3e-4会让loss在前几百步震荡,warmup是标配不是可选项。
3. 目录与环境:打开zip后先确认这四件事
3.1 根目录拆解:每个文件在训练里扮演什么角色
解压后有八个核心东西,先对照着确认一遍,别急着跑train.py。
| 路径 | 职责 | 答辩时可说的点 |
|---|---|---|
| config.py | 全局超参数:d_model、layers、lr、max_len | 参数集中管理,换数据集不用改代码 |
| data/ | 日文语料和中文语料的原始文本 | 数据清洗流程前置 |
| vocab/ | SentencePiece训练出的ja/zh词表与模型 | 子词方案解决OOV |
| dataset.py | 读取语料、编码、padding、bucketing | DataLoader层面的优化 |
| model.py | Transformer Encoder/Decoder完整实现 | 结构可视化 |
| train.py | 训练循环、checkpoint保存 | 训练技巧落地点 |
| translate.py | 加载权重、beam search解码 | 推理与训练分离 |
| checkpoints/ | 训练好的模型权重 | 可复现的直接证据 |
注意data/和vocab/在原始zip里不一定带数据,课设场景下常见做法是只保留脚本,语料自己找。如果zip里已经有一小份中日平行语料(比如几百条),先用它跑通全流程,再换大数据集训练。
3.2 环境锁版本:Python、PyTorch与CUDA的搭配
requirements.txt的内容我整理成了这份清单,版本是这套资源在Windows和Linux上都能跑通的搭配:
python>=3.8 torch>=1.10.0 sentencepiece>=0.1.96 sacrebleu>=2.2.0 jieba>=0.42.1 tqdm>=4.64.0PyTorch的版本下限设在1.10是因为多头注意力的实现用到了torch.nn.MultiheadAttention的batch_first参数,更老的版本写法不一样,直接跑会报参数名错误。sentencepiece低于0.1.96训练词表时偶尔会崩在字符覆盖率的边界case上。
装完环境后先跑一段验证代码,看CUDA和GPU真的能用:
import torch import sentencepiece as spm print("torch:", torch.__version__) print("cuda:", torch.cuda.is_available()) print("gpu:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else None) print("spm:", spm.__version__)逻辑说明:torch.cuda.is_available()返回False的话,后面train.py会跑得极慢——Transformer在CPU上训练日译中哪怕只有一万条语料也要十几个小时,做课设时间上完全不可接受。spm.__version__这一步是为了确认sentencepiece装上了,因为它是独立的pip包,不随torch安装。
参数说明:GPU显存4GB以下建议把d_model从512降到256、layers从6层降到4层,否则后面OOM会频繁打断训练。集显笔记本跑不动就考虑用Colab的免费GPU,代码不需要改。
3.3 第一次跑通:从空仓库到输出第一个翻译
环境没问题后,先用自带的最小语料跑一次全流程,目的不是看效果,是确认脚本没有隐性问题:
# 先不训练,只验证数据管线能正常出batch python -c "from dataset import create_dataloader; dl = create_dataloader('data/train_mini.tsv', batch_size=4); print(next(iter(dl)))"能看到一个四维的batch(src_ids,tgt_ids)长度各不相同的tensor,说明dataset.py没问题。接着跑训练:
python train.py --config config.py --data data/train_mini.tsv --epochs 5第一次跑通后,再用translate.py加载最后保存的checkpoint:
python translate.py --checkpoint checkpoints/best.pth --input "これはペンです。"如果输出“这是笔。”或者语义相近的中文,就说明整个链路是通的。注意这句话是日译中例子里最经典的测试句,建议换几句带长修饰的句子测,比如「昨日買った本を読んだ」——能翻出“我读了昨天买的书”才说明注意力真的学到了跨位置关联,而不只是背了短句。
4. 数据管线:从日文原句到batch张量的完整处理
4.1 选分词器:日语MeCab、中文SentencePiece的取舍
日文和中文都是词与词之间没有空格的语言,分词是第一步。这里有个常见的翻车点:拿jieba直接切日文。jieba的中文词库对日文完全不适用,会把「食べる」切成「食/べる」,子词序列完全不合理。日文侧我一般用SentencePiece,它的好处是词表可以从语料里直接训练出来,不需要外部词典,也不用依赖MeCab这种需要在系统里装C++依赖的工具。
这套资源里vocab/目录存的就是用SentencePiece训练好的词表模型。训练命令可以自己复现:
import sentencepiece as spm # 训练日文子词模型 spm.SentencePieceTrainer.train( input='data/ja_corpus.txt', model_prefix='vocab/ja', vocab_size=8000, model_type='bpe', character_coverage=0.9995, ) # 训练中文子词模型 spm.SentencePieceTrainer.train( input='data/zh_corpus.txt', model_prefix='vocab/zh', vocab_size=8000, model_type='bpe', character_coverage=0.9995, )逻辑说明:model_type='bpe'用的是Byte Pair Encoding,会把「食べる」这种词拆成「食べ」和「る」这样的子词单位,遇到词表里没有的生词也能通过子词组合拼出来,OOV问题基本消失。character_coverage=0.9995表示保留99.95%的字符,剩下的0.05%是标点、生僻字和噪声,自动归到UNK。
参数说明:日文和中文分开训练词表,不要合并训练。原因很简单——日语用汉字、平假名、片假名三种字符混写,中文只有汉字和少量标点,合并词表会让日文侧的假名占用大量词表容量,中文侧的实际表示能力下降。词表大小8000是起步值,数据量超过两万条平行语料时可以上调到16000。
4.2 数据清洗与长度控制:全角半角、繁简和max_len
中日语料里最常见的脏数据是全角/半角混用:日文里「pen」这种半角片假名和「ペン」全角片假名在视觉上一样,但对模型是完全不同的token。清洗阶段要统一转成全角(日文)和简体(中文)。常见做法是:
import re def clean_ja(text: str) -> str: # 半角转全角(日文习惯用全角) text = text.translate(str.maketrans( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" )) # 去掉HTML标签和异常空格 text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"\s+", " ", text).strip() return text def clean_zh(text: str) -> str: # 繁体转简体(用opencc效果更好,这里示意) text = text.replace("讀", "读").replace("買", "买").replace("説", "说") # 统一为中文标点 text = text.replace(",", ",").replace(".", "。").replace("?", "?") return text逻辑说明:日文转全角是必须的,因为SentencePiece训练时全角半角被当成两个字符,同一语义的词会被拆成两套子词,词表白白膨胀一倍。中文侧重点是繁简归一和标点统一,平行语料里最常翻车的其实是半角逗号——模型会把英文逗号和中文逗号当成不同token,训练数据里混用会让生成结果里标点忽中忽英。
长度控制上,max_len=128是平衡点。日语的平均句子长度比中文长,超过128个token的长句在通用语料里占比不到5%,直接丢弃比强行截断更合理——截断会把完整语义切断,模型学到的是“翻到一半就结束”的错误模式。我在dataset.py里用了一个简单策略:源句和目标句任何一侧超长就丢掉整对,不保留任何一半。
4.3 collate_fn与bucketing:batch里装什么才能高效训练
训练时不能直接往模型里塞不同长度的句子,要padding到batch内最大长度。这里有个效率坑:如果随机构batch,一个128词的句子和一个8词的句子分到同一组,8词那句就被硬生生pad到128,90%的计算量在算pad位。bucketing的做法是先按长度排序,再把相近长度的样本分到同一batch:
from torch.utils.data import DataLoader, Dataset import torch class TranslationDataset(Dataset): def __init__(self, pairs, ja_sp, zh_sp, max_len=128): self.data = [] for ja, zh in pairs: ja_ids = [bos] + ja_sp.encode(ja) + [eos] zh_ids = [bos] + zh_sp.encode(zh) + [eos] if len(ja_ids) <= max_len and len(zh_ids) <= max_len: self.data.append((ja_ids, zh_ids)) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx] def collate_fn(batch): ja_ids, zh_ids = zip(*batch) # 分别取batch内最大长度,避免pad到全局最大 max_ja = max(len(x) for x in ja_ids) max_zh = max(len(x) for x in zh_ids) ja_padded = torch.full((len(batch), max_ja), pad_idx, dtype=torch.long) zh_padded = torch.full((len(batch), max_zh), pad_idx, dtype=torch.long) for i, (j, z) in enumerate(zip(ja_ids, zh_ids)): ja_padded[i, :len(j)] = torch.tensor(j) zh_padded[i, :len(z)] = torch.tensor(z) return ja_padded, zh_padded逻辑说明:collate_fn里分别取max_ja和max_zh,而不是固定用max_len=128,能让每个batch的pad率降到最低。配合bucketing——DataLoader的sampler里按源句长度排序后分组——训练速度能提升30%左右,在课设答辩里可以作为一个优化点讲。
参数说明:pad_idx必须和模型里的ignore_index保持一致,否则pad位会参与loss计算,翻译结果会在句尾吐出一堆无意义的“ ”。bos和eos的token id在SentencePiece模型里通常是<s>和</s>,加载后用sp.bos_id()和sp.eos_id()取,别硬编码数字。
5. 训练与排查:loss不降、OOM、重复翻译怎么处理
5.1 训练脚本的参数设计:warmup、label smoothing与checkpoint
train.py里的核心超参数集中在config.py,我把这套工程里实测有效的组合列一下,基本是Transformer论文原版参数的缩小版:
| 参数 | 值 | 说明 |
|---|---|---|
| d_model | 512 | 词嵌入和注意力维度,显存吃紧时降到256 |
| n_layers | 6 | Encoder和Decoder各6层,课设可减到4层 |
| n_heads | 8 | 注意力头数,跟d_model/n_heads整除 |
| dropout | 0.1 | 所有子层统一0.1 |
| label_smoothing | 0.1 | 防止过拟合训练集 |
| warmup_steps | 4000 | 学习率从0线性升到峰值 |
| peak_lr | 3e-4 | 峰值学习率,Adam专用 |
| batch_size | 32 | 按token数动态计算更稳 |
| max_len | 128 | 超长句直接丢弃 |
训练循环的核心代码长这样:
import torch import torch.nn as nn criterion = nn.CrossEntropyLoss(ignore_index=pad_idx, label_smoothing=0.1) optimizer = torch.optim.Adam(model.parameters(), lr=3e-4, betas=(0.9, 0.98)) # Transformer论文原版warmup:前4000步线性升,之后按步数平方根衰减 def lr_lambda(step): warmup = 4000 if step < warmup: return step / warmup return (warmup ** 0.5) / (step ** 0.5) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) for epoch in range(epochs): for batch in dataloader: src, tgt = batch # 解码器输入是去掉最后一个词的目标句 logits = model(src, tgt[:, :-1]) # 预测目标是去掉第一个词的目标句 loss = criterion( logits.reshape(-1, logits.size(-1)), tgt[:, 1:].reshape(-1) ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step()逻辑说明:tgt[:, :-1]和tgt[:, 1:]这一对是teacher forcing的标准切法——解码器看到真实译文的前N-1个词,预测的目标是后N-1个词,对齐后每个位置都算loss。ignore_index=pad_idx让padding位置的预测不参与梯度计算,否则模型会把大量注意力花在预测“这是个pad”上。
参数说明:clip_grad_norm_(..., 1.0)把梯度范数裁到1.0,Transformer梯度爆炸的典型症状就是loss突然变成nan,加了裁剪基本绝迹。betas=(0.9, 0.98)是Transformer原版给Adam设的参数,跟默认的(0.9, 0.999)不同——0.98的二阶动量让更新步长更保守,配合warmup用,训练曲线更稳。checkpoint要存model.state_dict()加optimizer.state_dict(),这样中断训练后能从断点接着跑:
torch.save({ 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'epoch': epoch, 'best_val_loss': best_val_loss, }, f'checkpoints/epoch_{epoch}.pth')5.2 高频坑位一:loss不降、显存爆掉与重复翻译
训练日译中模型踩到最多的坑是这三个,每条都是真实翻车记录。
坑1:loss死活不降,甚至前几百步就nan。现象:训练开始后loss在8到9之间震荡,跑了一千步还在原地,偶尔直接跳成nan。 原因:两个最可能的——学习率没有warmup直接上3e-4,Transformer前几百步梯度方差极大;或者ignore_index没设对,pad位参与了loss计算。 解决:加warmup,把lr_lambda接上;检查criterion里的ignore_index是否等于pad_idx。还有一个隐藏原因:数据没shuffle。平行语料经常开头全是同一话题的句子,不shuffle会让前几百步的梯度方向高度一致,模型学偏。
坑2:显存OOM,batch_size=32跑不动。现象:刚跑第一个batch就报CUDA out of memory,或者训练到一半在长句batch上报错。 原因:随机构建的batch里混入超长句,padding后实际token数翻倍,显存被pad位吃光。 解决:用4.3节的bucketing按长度分组,或者把batch_size降到16、d_model降到256。如果还是爆,用梯度累积——每4个小batch累积一次梯度再更新,等效batch_size=64但不占额外显存:
accumulation_steps = 4 scaler = None # 如果用了AMP,梯度累积要配合GradScaler处理 for i, batch in enumerate(dataloader): loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()坑3:翻译结果大量重复,一句话里同一个词出现三四遍。现象:训练loss正常,但translate.py输出的中文明显重复,「这是是是是」这种。 原因:beam search里模型对某个高概率子词反复自我强化,尤其是目标句偏短时;另一个可能原因是训练时Decoder的mask没做对,导致模型学到了“看当前词就能预测当前词”的作弊路径。 解决:在translate.py的beam search里加no_repeat_ngram_size=3,并设置长度惩罚length_penalty=0.6。前者禁止任何三元组重复出现,后者让模型对长句不扣分,减少过早停在重复循环里的概率。
5.3 高频坑位二:zip解压、路径乱码与权重文件缺失
这个方向容易被人忽略,但课设选手下载资源后第一步就卡在解压和环境上,值不值得下就看这一步顺不顺。
坑4:zip解压报invalid zip archive: could not find EOCD或文件损坏。现象:Windows自带解压工具直接报错,或者解压到一半提示“压缩文件已损坏”。 原因:下载过程中文件没传完整,或者zip包带了伪加密标记。你这个场景里如果下载工具是浏览器自带下载,大文件中断后断点续传出错的概率很高。 解决:先看文件大小跟页面标注是否一致,差了1KB以上就重新下载。伪加密的情况用7-Zip打开,右键“修复压缩文件”能直接处理;顺手把解压后的目录路径全部改成英文,C:\Users\你的用户名\Desktop\NMT_JaZh这种路径对后续Python脚本更友好。
坑5:checkpoints目录是空的,translate.py加载权重报错。现象:跑python translate.py --checkpoint checkpoints/best.pth时报文件不存在,打开checkpoints目录发现里面只有个README。 原因:zip里为了控制体积,把训练好的权重文件单独拆出去了,常见做法是放一个下载链接或说明文件。你下载的版本里如果没带权重,就需要自己按第3章的流程先训练一遍,或者找对应权重的补充包。 解决:别急着骂资源不完整——先看checkpoints/README里有没有写权重存放路径或训练步骤。如果只保留了训练脚本,那就用小语料先跑通流程,确认代码没问题后再决定是否要花时间训练全量模型。检查路径时顺便确认config.py里的checkpoint_dir是相对路径而不是写死的绝对路径,否则换机器必报错。
注意:解压后priority第一件事是跑
python -c "from dataset import create_dataloader"验证数据管线,不是直接跑train.py。这个顺序能帮你把“环境问题”和“代码问题”隔离开,排查时间能省一半。
6. 评估与微调:BLEU之外还能榨出多少翻译质量
6.1 用sacrebleu算BLEU,别自己写评估脚本
课设答辩最常见的翻车点是拿nltk.translate.bleu_score手写BLEU,然后被评委问“你的BLEU用的是第几版实现”。直接上sacrebleu,它的输出格式和WMT官方评测一致:
import sacrebleu # refs是参考译文列表的列表,每个list是一句的多个参考译法 refs = [[ "我读了昨天买的书。", "我读了昨天买的那本书。" ]] hyps = ["我读了昨天买的一本书。"] bleu = sacrebleu.corpus_bleu(hyps, refs) print(f"BLEU: {bleu.score:.2f}") # 只看单句的签出效果 bleu_single = sacrebleu.sentence_bleu(hyps[0], refs[0]) print(f"sentence BLEU: {bleu_single.score:.2f}")逻辑说明:corpus_bleu是整篇语料级评估,sentence_bleu是单句评估。答辩时两个都要会说——BLEU的核心是n-gram精确率加长度惩罚,所以它偏爱短译文。你要准备一套样本,指出“这句BLEU分低但人工判断是正确的”,说明BLEU的边界在哪,这在答辩里是加分项。
6.2 三个低成本改进方向:beam size、反向翻译与预训练初始化
不要一上来就换模型结构,先把代价最低的三个方向做掉。
beam size从1调到4、再调到8,观察变化:
| beam_size | 效果 | 速度 | 建议 |
|---|---|---|---|
| 1(贪心) | 最差,容易重复 | 最快 | 只用来测试链路 |
| 4 | 质量明显提升 | 中等 | 课设默认 |
| 8 | 提升微弱 | 慢一倍 | 时间充裕再试 |
反向翻译是数据增强的经典招:把你手头的中文语料用当前模型先翻成日语(反方向),再把生成的日语和原始中文配成新的训练对。效果是让模型见过更多源端表达,尤其适合语料量不足一万对的课设场景。实现上只需要把model的checkpoint加载到translate.py里,source和target互换,跑一遍推理脚本。
预训练初始化对日译中提升明显,但工程复杂度高。常见做法是加载一个多语种预训练模型(如mBART或M2M100)来初始化Encoder和Decoder,然后微调。答辩时间紧张的话建议只用它作为“未来工作”提一嘴,别真加进去——这些模型动辄十几亿参数,微调一次的时间和显存都不是课设节奏能承受的。
最后说个我自己踩过的教训:以前跑翻译实验,改完beam size或者词表就急着开全量训练,结果跑了两小时后发现是词表路径写错了,模型在拿错误token id训练。从那以后我每次跑NMT实验都强制走一遍:先挑100条语料当mini集,保证一步训练能出大致方向对的译文,再上全量;先验证sacrebleu脚本能出数字,再动模型结构。这个习惯帮我省掉的返工时间,比写代码的时间还多。希望帮到你。
本文还有配套的精品资源,点击获取