基于Python的古文到现代文翻译:数据与Transformer实践
简介一份基于 Python 实现古文到现代文机器翻译的完整项目源码适合毕业设计、课程设计及个人项目开发也可作为自然语言处理入门实践参考。资源共12个文件以 Python 脚本为主涵盖数据预处理、文本词表构建、向量化输入、注意力机制训练与测试、Socket 服务接口等完整流程另附 README 说明文档便于快速上手。项目源码已经严格测试可放心参考并在基础上扩展应用目前已199人浏览学习。整体压缩包仅19KB结构简洁适合作为课程演示、算法验证或二次开发的基底。1. 毕设季的常见痛点这个题目到底有没有“标准答案”每年毕设清单上都会出现“基于Python的古文到现代文机器翻译”这类题目拿到手却只有“源码、毕业设计、课程设计”几个模糊标签。这里的核心其实是把机器翻译的标准链路完整走一遍语料清洗、词表构建、序列到序列模型、自回归解码、BLEU评估全部控制在普通笔记本能跑完的规模里。比起通用中英翻译动辄需要千万级平行语料古文到现代文的语料来源集中、句子偏短、领域封闭非常适合在一两个月内完成一个能训练、能演示、能讲清楚原理的工程。这题目并不神秘难点也不在“文化底蕴”而在数据对齐和超参数。本文会从数据准备讲到模型训练最后拆出五条最容易翻车的记录让你在答辩前心里有底。2. 数据与词表先处理句读和对齐再想模型架构2.1 平行语料从哪里找、怎么切分做古文翻译最容易被忽略的是数据而不是模型。很多人一上来就套开源的中英翻译代码结果发现古文数据根本喂不进去。常见做法是收集“古文原文 白话译文”的句子对来源主要是中学语文课本的课后译文、古诗文网的今译栏目、以及公开的国学文库。这些数据大多按段落组织需要自己切成句级。我一般会把原始文件整理成两列一列古文一列现代文中间用|分隔然后再做切分和过滤。切分时不能简单按标点一刀切因为古文里的“也”、“矣”、“乎”经常出现在句中现代文译文却用了逗号。一个比较稳的先验是用句号、感叹号、问号做主切分点分号先不切。# prepare_pairs.py # 目标把“古文|现代文”的段落切成长度尽量一致的句对 import re def split_src(text): # 古文如果自带标点按句末标点切若没有标点先按常用虚词做粗切 parts re.split(r[。], text) return [p.strip() for p in parts if len(p.strip()) 0] def split_tgt(text): # 现代文按句末标点切分分号保留在句内 parts re.split(r[。], text) return [p.strip() for p in parts if len(p.strip()) 0] def make_pairs(src_line, tgt_line): src_parts split_src(src_line) tgt_parts split_tgt(tgt_line) if len(src_parts) ! len(tgt_parts): return [] # 最常见做法段落切分数量不一致就丢弃不硬凑 return [(s, t) for s, t in zip(src_parts, tgt_parts) if 2 len(s) 60 and 2 len(t) 60]逻辑说明切分数量不一致时要学会放弃。古文译文经常会把一个长句拆成两句白话比如原文一句“晋太元中武陵人捕鱼为业。”译文可能是“东晋太元年间武陵郡有个人靠打鱼为生。”数量刚好一致但也有原文两句、译文三句的情况强行对齐会产生大量噪声。过滤长度小于2或大于60的句子是为了避免后续子词序列过长拖慢训练。参数说明上面len()按中文字符数计算配合后面max_seq_len64基本可以覆盖九成以上古文句子。如果语料里有《史记》那种超长段落可在此处设置if len(s) 80: continue不要指望模型学会处理二百字的古文课题项目的目标是能翻好短句。2.2 用 SentencePiece 建词表比按字硬切更实用古文里存在大量通假字、异体字和文言虚词如果直接按字建词表会得到几千个高频字和一大堆低频字。常见的做法是用 SentencePiece 做子词切分。它能把“之乎者也”这类高频虚词切成独立子词把冷僻人名地名留作字节回退。# 训练子词模型vocab_size 根据语料量调节 spm_train \ --inputtrain.zh \ --model_prefixguwen_sp \ --vocab_size8000 \ --character_coverage1.0 \ --model_typebpe \ --pad_id0 --unk_id1 --bos_id2 --eos_id3训练完会生成guwen_sp.model和guwen_sp.vocab。很多开源的中英翻译代码用的是sentencepiece包这里也是一样。加载方式如下import sentencepiece as spm sp spm.SentencePieceProcessor(model_fileguwen_sp.model) def encode_line(line, max_len64): ids sp.encode(line, out_typeint) ids ids[:max_len - 2] # 给 BOS 和 EOS 留位置 return [sp.bos_id()] ids [sp.eos_id()]逻辑说明character_coverage1.0表示要求模型覆盖全部字符适合古文这种文本量不大但生僻字多的场景。如果语料有几十万句可以降到 0.999 以压缩词表。model_typebpe对中文表现稳定比 unigram 更容易复现。这里不选用word类型因为古文分词本身就是一个争议点用子词可以直接绕开。参数说明词表 8000 对课设量级够用扩展到 16000 并不会带来明显提升只会让训练变慢。低于 4000 则可能出现同一古文词被拆成多个碎块导致翻译结果不通顺。2.3 collate 阶段最容易漏掉的 padding mask训练数据进入模型前要把变长句子补成同一个长度。很多人直接调pad_sequence补 0结果模型把大量 padding 位置也当成文本学进去了训练损失看起来很低推理时却不停输出重复的pad。正确做法是在 collate 阶段同时生成 mask。# dataset.py import torch from torch.nn.utils.rnn import pad_sequence def collate_fn(batch): src_batch, tgt_batch [], [] for src_ids, tgt_ids in batch: src_batch.append(torch.tensor(src_ids, dtypetorch.long)) tgt_batch.append(torch.tensor(tgt_ids, dtypetorch.long)) src pad_sequence(src_batch, batch_firstTrue, padding_value0) tgt pad_sequence(tgt_batch, batch_firstTrue, padding_value0) src_mask (src ! 0).unsqueeze(1) # [batch, 1, src_len] tgt_pad_mask (tgt ! 0).unsqueeze(1) # 生成自回归解码所需的因果 mask tgt_len tgt.size(1) causal_mask torch.triu(torch.ones(tgt_len, tgt_len), diagonal1).bool() causal_mask causal_mask.unsqueeze(0) # [1, tgt_len, tgt_len] return { src: src, tgt: tgt, src_mask: src_mask, tgt_mask: causal_mask, tgt_pad_mask: tgt_pad_mask, }逻辑说明src_mask只屏蔽 padding而tgt_mask是一个上三角矩阵保证第 i 个位置只能看到第 i 个之前的词这就是自回归模型和普通分类模型的本质区别。tgt_pad_mask在计算损失时要用来把 padding 位置的 loss 置零。这一套代码基本是标准 NMT 工程的“地基”后续换更大的模型也不用改。参数说明padding_value必须和SentencePiece里的pad_id一致也就是 0。如果顺手设成 1会出现灾难性错误模型学会了把unk当成空白字符推理结果全是未登录词。3. 模型选型与训练主循环小 Transformer 是课设的最优解3.1 为什么不用 LSTM而用小 Transformer毕设题目如果出现在五年前大家会默认用BiLSTM Attention而现在再用 LSTM 去答古文字翻译老师多半会问“为什么沿用旧架构”。Transformer 在机器翻译里的优势不是绝对精度而是每一步都能给出注意力可视化这在答辩时非常加分。古文句子长度普遍不超过 64 个字不需要 12 层大模型一个 4 层 encoder、4 层 decoder 的小 Transformer 就足够把特征学出来。方案优点缺点课题适配度BiLSTM Attention训练快、参数量小长距离依赖弱、注意力图解释力一般中标准 Transformer并行训练、注意力可视化清晰小语料上容易过拟合高预训练大模型微调效果上限高需要较多资源与调参成本低参数选择上我一般不会一上来就是d_model512、8层而是先用 256 维、4 层跑通一条流水线再根据显存和训练时间决定是否加深。对只有几千到几万句的古文语料深模型的收益非常有限反而更容易陷入过拟合。3.2 一个精简易读的 Encoder-Decoder 结构案例中源码工程往往把模型写在一整个model.py里里面包含位置编码、掩码生成、编码器、解码器。这里给一个足够简洁又不失完整性的写法基于 PyTorch 自带nn.Transformer封装# model.py import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len64): super().__init__() pe torch.zeros(max_len, d_model) pos torch.arange(0, max_len).unsqueeze(1).float() div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(pos * div_term) pe[:, 1::2] torch.cos(pos * div_term) pe pe.unsqueeze(0) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:, : x.size(1), :] class GuwenTransformer(nn.Module): def __init__(self, vocab_size, d_model256, nhead8, num_encoder_layers4, num_decoder_layers4, dropout0.1): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.pos PositionalEncoding(d_model) self.transformer nn.Transformer( d_modeld_model, nheadnhead, num_encoder_layersnum_encoder_layers, num_decoder_layersnum_decoder_layers, dropoutdropout, batch_firstTrue, ) self.fc_out nn.Linear(d_model, vocab_size) self.d_model d_model def forward(self, src, tgt, src_mask, tgt_mask): src_emb self.embedding(src) * math.sqrt(self.d_model) src_emb self.pos(src_emb) tgt_emb self.embedding(tgt) * math.sqrt(self.d_model) tgt_emb self.pos(tgt_emb) memory self.transformer.encoder(src_emb, src_key_padding_mask~src_mask.squeeze(1)) output self.transformer.decoder( tgt_emb, memory, tgt_masktgt_mask, tgt_key_padding_maskNone, memory_key_padding_mask~src_mask.squeeze(1), ) return self.fc_out(output)逻辑说明nn.Transformer要求传入的是 padding mask而不是注意力 mask所以上面把src_mask取反后再作为src_key_padding_mask。这是新手最容易卡死的地方之一PyTorch 的 transformer 内部已经用了mask_fill(-inf)如果直接传 bool mask会报维度错误。PositionalEncoding用常数10000.0保证位置编码的周期性这也是原论文的默认设定。参数说明nhead8要求d_model能被 8 整除256 正好。如果显存紧张可以改成d_model128, nhead4但输出质量会明显下降。这里没有做max_len64之外的截断是为了保证位置编码和训练数据长度一致。3.3 训练循环交叉熵、标签平滑、学习率策略跨语言模型的训练核心是“预测下一个词”所以目标不是用nn.CrossEntropyLoss一把梭而是配合掩码和标签平滑来处理。# train.py 关键片段 import torch import torch.nn as nn import torch.nn.functional as F class LabelSmoothingLoss(nn.Module): def __init__(self, padding_idx0, smoothing0.1): super().__init__() self.padding_idx padding_idx self.smoothing smoothing def forward(self, logits, target): vocab_size logits.size(-1) log_probs F.log_softmax(logits, dim-1) true_dist torch.full_like(log_probs, self.smoothing / (vocab_size - 2)) true_dist.scatter_(2, target.unsqueeze(-1), 1.0 - self.smoothing) true_dist[:, :, self.padding_idx] 0 mask (target ! self.padding_idx).unsqueeze(-1) loss -(true_dist * log_probs * mask).sum(dim-1) / mask.sum(dim-1).clamp(min1) return loss.mean()训练循环里的动作比较常规但有几个关键点optimizer torch.optim.Adam(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.InverseSquareRootLR( optimizer, warmup_steps1000, factor1.0 ) for epoch in range(30): model.train() for batch in train_loader: src batch[src].to(device) tgt batch[tgt].to(device) logits model(src, tgt[:, :-1], batch[src_mask].to(device), batch[tgt_mask].to(device)) loss criterion(logits, tgt[:, 1:]) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scheduler.step() optimizer.step()逻辑说明模型输入是tgt[:, :-1]预测目标是tgt[:, 1:]这构成了标准的 teacher forcing。计算损失时用 mask 把 padding 位置去除避免模型靠预测pad来拉低 loss。梯度裁剪设为 1.0能有效防止古文长句带来的梯度爆炸。参数说明学习率调度器用原论文的InverseSquareRoot比固定学习率稳一点。warmup_steps1000对课设量级很合适太大会让前期 loss 波动剧烈。如果发现 loss 在 20 个 epoch 后仍然不降优先查数据对齐而不是改网络层数。标注的句对数量和训练时长关系大致如下几千对数据用 4 层 TransformerRTX 3060 上约 20 分钟一个 epoch如果数据只有几百对则很容易在 10 个 epoch 内过拟合这时候应该靠早停而不是硬跑满 30 个 epoch。4. 古文翻译训练里常见的 5 个踩坑现象、原因、解决4.1 损失下降但译文不停重复“也也也”现象训练 loss 从 5.0 降到 2.0看起来正常但把模型拿来推理输出成了“也、也、也、也”。原因有两个层面一是古文语料里虚词占比太高模型发现高频输出“也”能骗过部分损失二是自回归推理时首步出错错误会沿生成长度累积重复是最省力的补救方式。解决手段是给损失做 label smoothing同时把 beam size 从 1 提升到 3。Label smoothing 让模型不敢过于自信地死盯某个高频词beam search 则会综合多条候选路径。实际调参中把smoothing0.1改成0.2通常能解决轻度的重复问题。4.2 异体字和繁简转换让未登录词爆炸现象语料里同时出现“裡”和“里”、“説”和“说”模型把它们当成两个完全不同子词词表被撑大翻译质量下降。原因是在清洗阶段没有做字符归一化。解决方法是先在预处理时用opencc做繁转简然后对古文里常见的异体字做一张小映射表。这一步看似不起眼却能让词表规模下降 15% 左右训练收敛快很多。我踩过的坑是只给现代文做了繁转简古文原句保留繁体结果模型在繁体字和简体字之间来回波动。4.3 古文断句错误导致整句翻译崩坏现象部分教材版本没有标点直接把整段古文当作一行喂给模型。模型实际学到的源句长度远超设计值比如 300 字positional encoding 本身有上限输出就成了乱码。原因在于省略了2.1的切分过滤逻辑。解决方法是先用句读工具给古文加标点再进入切分流程。如果不想引入额外的句读模型也可以对无标点数据按语气词切分比如“也”、“矣”、“乎”、“焉”后加分隔符虽然不如人工准确但至少能控制长度。4.4 语料只有几百句时模型根本不收敛现象用 5 个 epoch 训练后验证集 BLEU 一直为 0模型把常见古文词全部翻成unk。原因很直接平行句对太少词表覆盖不足。解决手段是降低vocab_size比如从 8000 降到 3000避免大量从未在训练中出现的子词。另一种常见做法是把语文课本里的古诗词也加进来用今译内容做扩充哪怕模型翻得不完美也至少能学会“明月”对应“bright moon”这样的实体映射。如果仍然不收敛优先检查词表里是否包含高频的“之”、“其”、“者”这三个虚词决定模型能否正确生成基础句式。4.5 BLEU 分数高但人工读起来不知所云现象验证集 BLEU 有 0.45看起来很高人工一看译文发现“之”全被翻成了“的”比如“古之人”变“古代的人”。原因在于 BLEU 只看 n-gram 重叠不计较语义是否正确短句尤其容易刷出虚高分。解决方法是不要只看指标每训练一个 checkpoint 就抽 10 句人工读一下。更实用的做法是把n-gram权重从默认(0.25, 0.25, 0.25, 0.25)改成偏重 2-gram 和 4-gram 的(0.1, 0.3, 0.2, 0.4)降低“之”这类单字虚词对分数的贡献。5. 源码工程怎么交付评估、交互、还能怎么扩展5.1 用 BLEU 和人工抽检一起验收训练完后不能只输出一个 loss 曲线得提供一版可以直接跑评测的eval.py。我会把测试集固定成 200 句没有参与训练的句子分别用sacrebleu计算 BLEU同时打印 10 条“古文到现代文到参考译文”的三行对照。测试提示里明确写清楚BLEU 只是参考最终以人工判断为准不然答辩时容易自己给自己挖坑。5.2 一个能演示的交互入口课设或毕设项目最好附一条可复现的交互命令。常见做法是把模型封装成translate.py终端里输入一句古文立即输出现代文python translate.py --checkpoint best.pt --input 晋太元中武陵人捕鱼为业实现时用torch.no_grad()加载 checkpoint调用model.eval()并对生成结果做一次.replace( , )因为 SentencePiece 对中文子词会插入空白不处理的话演示效果很丑。如果再想加一点完成度可以再套一个 Flask 页面把输入框和输出框放一起展示时比黑底终端直观得多。5.3 源码里额外值得写出来的三个能力我会在源码包中单独留README.md说明训练步骤、文件结构和预期效果并附一张注意力图因为这既能让指导老师快速看懂又能让后续维护的人少走弯路。进阶级可以加一个术语白名单机制把“太守”、“县令”这类官职名直接映射到现代汉语对应词避免模型把官职名当普通名词翻错。这些改动都不大但会让项目从“跑通了而已”上升到“有落地思考”。这个题目真正有价值的不是模型多先进而是数据闭环是否完整。我习惯在项目里保留预处理脚本、数据筛选日志、超参数记录这样即使几个月后再看也清楚当时为什么做出选择。希望这些思路能帮你在毕设和课设里少熬几个夜把精力放到真正能加分的模块上祝你顺利跑通。本文还有配套的精品资源点击获取