ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

古文机器翻译实战:seq2seq+attention源码包的数据管道与避坑指南

古文机器翻译实战:seq2seq+attention源码包的数据管道与避坑指南 简介这是一套基于Python开发的古文到现代文机器翻译项目源码面向毕业设计、课程设计与项目开发场景尤其适合有一定Python基础、希望在NLP翻译方向快速落地参考实现的开发者。项目源码已经过严格测试可直接运行并在此基础上扩展功能。资源包共12个文件由11个Python脚本和1个Markdown说明文档组成脚本覆盖数据预处理、TFRecord数据集操作、输入向量化、词表构建、神经网络搭建、注意力机制训练与测试、服务端通信等功能模块压缩包整体仅19KB代码轻量精炼便于通读和二次修改。目前已有199人学习下载。通过阅读源码可掌握从原始语料到模型训练、再到译文输出的完整流程理解注意力机制在古文与现代文转换中的作用搭配README中的说明可快速搭建运行环境适合作为课设、毕设的工程基础或进一步改进的起点。1. 古文机器翻译一份能跑通毕设的 seq2seqattention 源码包做机器翻译方向的课程设计或毕业设计最尴尬的不是模型写不出来而是拿到一份源码不知道从哪个文件开始跑。这份基于 python 的古文到现代文机器翻译源码包解决的正是这个问题语料解析、文本清洗、词典构建、向量化、TFRecord 序列化、attention 训练、socket 演示服务一条链路全部到位。它适合三类人——正在做毕设需要完整可复现工程的学生课程设计要交可演示项目的开发者以及想基于 seq2seqattention 改造自己语料的人。文件数量不多但每个脚本职责明确按数据流跑一遍就能摸清整套翻译系统的骨架。2. 先按数据流拆源码包11 个 py 文件的调用顺序与最小跑通命令拿到源码第一步不是打开训练代码慢慢读而是数文件、排顺序。这个项目里的文件分工非常清楚数据先经过一串预处理脚本变成磁盘上的 TFRecord再进入训练脚本最后测试脚本和服务脚本分别接住训练产物。顺序错了后面每一步都会报「文件不存在」或者「shape 对不上」。2.1 每个文件干什么职责与输入输出对照表把全部文件按阶段拆开看依赖关系就很直观了文件阶段主要职责常见输入常见输出ParseXml.py语料解析从 XML 中提取古文-现代文平行句对原始 XML 语料句对列表文件Pre-treatment.py文本清洗去标签残留、统一字符、截断长度平行句对清洗后语料Text_vocab.py词典构建统计频次生成 word2id / id2word清洗后语料vocab 文件Input_vec.py向量化把文本字符映射成 id 序列或向量vocab 文件 语料向量化数组Senten_id_seq.py序列生成把句子整理成定长 batch处理 padding向量化结果训练矩阵TFRecord_operate.py数据序列化把 batch 写成 TFRecord供训练读取batch 矩阵.tfrecord 文件neural_network.py模型定义定义 encoder / decoder / attention 结构超参数模型对象Attention_train.py训练入口加载 TFRecord跑训练循环保存权重TFRecord 超参数checkpointattention_test.py测试出口加载权重对输入的句子做翻译checkpoint 输入句译文文本ServerSocket.py服务封装用 socket 接收请求返回译文模型权重翻译服务test.py冒烟测试用少量样本快速验证链路通不通少量样本打印译文README.md文档记录运行顺序与参数说明--按依赖关系看数据流其实是一条直线加三个出口ParseXml → Pre-treatment → Text_vocab → Input_vec → Senten_id_seq → TFRecord_operate之后分支到 Attention_train训练、attention_test测试、ServerSocket演示服务。neural_network.py 是训练时被 import 的模型定义test.py 是脱离主链路的冒烟测试脚本。这个结构符合一线工程的常规拆法——数据准备单独一段模型实验单独一段两者通过 TFRecord 解耦。2.2 为什么中间绕一道 TFRecord从「每 epoch 重处理」到「离线序列化」第一次跑这种项目的人常有一个疑问为什么不直接把 numpy 数组 feed 进模型非要转 TFRecord如果只是几百条样本的 demo直接喂内存确实可以但翻译模型的训练数据往往是几万到几十万句对如果每个 epoch 都重新走一遍文本清洗和字符映射大部分时间都耗在重复处理上而且 shuffle 也不彻底。TFRecord 的价值在于把「数据处理」和「模型训练」完全拆开数据只处理一次落盘后训练阶段用 TFRecordDataset 读取由框架负责 shuffle、prefetch 和并行读取。对毕设答辩来说这条链路本身也是一个很好的工作点——能讲清楚为什么用离线序列化比单纯背 attention 公式更能让老师认可工作量。提示TFRecord 不是模型的一部分是数据管道的一部分。如果 README 没有写清中间文件路径直接看每个 py 文件开头的 argparse 默认值路径参数一般都写在脚本里。2.3 最小复现命令按文件依赖顺序跑下来的实操环境上这类项目大概率是 TensorFlow 1.x 的写法建议先用老版本环境复现不要一上来就装最新的 TensorFlow# 建议用 python 3.6/3.7 tensorflow 1.x 复现先把环境定下来 conda create -n guwen python3.6 conda activate guwen pip install tensorflow1.15 numpy # 1) 原始语料 - 平行句对 python ParseXml.py # 2) 平行句对 - 清洗后文本 python Pre-treatment.py # 3) 清洗后文本 - vocab 文件 python Text_vocab.py # 4) 文本 vocab - 向量化结果 python Input_vec.py # 5) 向量 - 定长 batch 序列 python Senten_id_seq.py # 6) batch - TFRecord python TFRecord_operate.py # 7) 训练与测试 python Attention_train.py python attention_test.py # 8) 启动演示服务 python ServerSocket.py这个顺序不能乱每个脚本都依赖上一个脚本产出的中间文件。常见错误是直接跳到最后跑 Attention_train.py结果报「找不到 vocab 文件」或「TFRecord 路径为空」。判断链路是否跑通有个简单标准——跑出 checkpoint 文件且训练 loss 在打印就说明数据管道没问题接下来才值得调模型参数。3. 语料解析与词典构建ParseXml、Pre-treatment、Text_vocab 的配合方式语料质量决定了翻译质量的上限模型只是在逼近这个上限。这个项目里语料从 XML 到词典要过三道关先把结构化的 XML 变成平行句对再做文本清洗最后统计出词典。这三步每一步都直接影响后续训练效果也是最容易「看着没问题、实际埋雷」的地方。3.1 XML 语料怎么变成平行句对古文语料常见组织形式是 XML因为 XML 能保留篇章结构、出处和注释信息比纯文本更适合做平行语料管理。项目里的 ParseXml.py 负责把这些标签结构拆成源文-目标文句对核心逻辑通常是这样import xml.etree.ElementTree as ET def parse_corpus(xml_path): tree ET.parse(xml_path) root tree.getroot() pairs [] # 常见古文平行语料里一个句对长这样 # pair id1source学而时习之/sourcetarget学习并且经常温习/target/pair for pair in root.iter(pair): src pair.findtext(source, default).strip() tgt pair.findtext(target, default).strip() if src and tgt: # 过滤空句对 pairs.append((src, tgt)) return pairs这段代码的逻辑很直接遍历所有 pair 节点用 findtext 取出 source 和 target 子节点的文本strip 掉首尾空白空的直接丢弃。参数上有个高频坑——如果 XML 带命名空间很多公开数据集都有 xmlnsfindtext 可能返回 None需要在解析前处理命名空间或者用root.find(pair, ns)传入命名空间字典。我一般会先用print(root.tag)看一下根标签长什么样再决定解析方式这能省掉大半天的排查时间。3.2 清洗细节全角字符、标签残留与长度截断拿到平行句对后下一步是清洗。Pre-treatment.py 一般会做这几件事去掉 XML 解析残留的尖括号内容、统一全角半角字符、按需截断句子长度。古文场景里有个特殊点古文原文没有现代标点语料里的标点是后人加的清洗时往往要直接去掉import re def clean_text(text, max_len64, keep_punctFalse): text text.replace(, ,).replace(。, .) # 全角标点转半角 text re.sub(r[^], , text) # 去掉标签残留 if not keep_punct: text re.sub(r[^\w\u4e00-\u9fff], , text) # 只保留中文和字母数字 return text[:max_len]参数上max_len 直接影响显存占用和训练速度设 64 是常见值如果语料以短句为主32 就够batch 也能开更大keep_punct 在古文场景建议设 False因为古文标点是后人标注不是原文内容保留反而会干扰模型对齐。这个脚本做完之后建议顺手打印几条清洗结果看一眼——这一步能发现 80% 的编码问题比如繁体没转、空格没去干净之类的。3.3 vocab 怎么定字级还是词级UNK 留给谁词典构建是整条数据链路里最像玄学的一步。古文场景和现代文不一样现代文可以用 jieba 这类现成分词器古文缺少高质量分词工具「词」的边界本身就是语法问题。因此常见做法是切到字级让模型自己学边界。Text_vocab.py 的核心逻辑通常是频次统计加截断from collections import Counter def build_vocab(sentences, min_count2, vocab_size30000): counter Counter() for sent in sentences: counter.update(list(sent)) # 字级切分不引入分词器 # 四个特殊 token 固定占前 4 位 vocab [pad, unk, bos, eos] [ w for w, c in counter.most_common(vocab_size - 4) if c min_count ] word2id {w: i for i, w in enumerate(vocab)} id2word {i: w for w, i in word2id.items()} return word2id, id2word两个参数要特别留意。min_count 默认 2 表示出现次数少于 2 的字直接进 UNK古文生僻字、人名地名很多如果发现测试时 UNK 刷屏把 min_count 降到 1代价是 vocab 容量被生僻字占掉一些。vocab_size 控制词典上限字级语料 30000 基本够用如果你的语料里有大量异体字适当拉大到 50000。字级模型的好处是 OOV 少坏处是序列变长、训练变慢但古文语料普遍不长这个代价可以接受。4. 向量化与 TFRecordInput_vec、Senten_id_seq、TFRecord_operate 的序列化链路清洗和词典构建完成后数据还是「文本」形态接下来要做的是把它变成模型能吃的数值。这一章讲的是三个脚本的配合Input_vec 负责字符到 id 的映射Senten_id_seq 负责把 id 序列对齐成 batchTFRecord_operate 负责把 batch 落盘。很多人把这三步当成黑匣子直接用其实每一步都藏着影响训练效果的细节。4.1 向量化的两种做法查表还是 one-hotInput_vec.py 的作用是把句子变成模型输入。常见实现有两种one-hot 向量和 id 查表。one-hot 的问题很直接——字级词典 30000 的话每个 token 是一个 30000 维向量数据量和内存都扛不住所以实际项目基本都走查表路线字符映射成整数 id真正的高维向量交给模型里的 embedding 层去学import numpy as np def input_vec(sentences, word2id, max_len64): # 句子转 id 数组不足补 pad_id0超长截断 vec np.zeros((len(sentences), max_len), dtypenp.int32) for i, sent in enumerate(sentences): ids [word2id.get(ch, word2id[unk]) for ch in sent[:max_len]] vec[i, :len(ids)] ids return vec注意这里用word2id.get(ch, word2id[unk])而不是word2id[ch]前者遇到未登录字会落到 UNK 的 id后者直接抛 KeyError。线上环境里宁可让 UNK 兜底也不能让整个训练崩掉。dtype 用 int32 而不是 int64 是为了省显存embedding 层会把它当索引查表。4.2 对齐与错位decoder 输入和目标只差一位Senten_id_seq.py 负责把成对的句子拼成训练 batch。seq2seq 训练时不是「输入整句、输出整句」而是 teacher forcing每个解码时间步输入前一个目标词预测下一个词。所以对每个句对要构造出三份数据——encoder 输入、decoder 输入、decoder 目标后两者错开一位def make_batch(pairs, word2id, batch_size32): pad_id word2id[pad] bos_id word2id[bos] eos_id word2id[eos] batch_src, batch_tgt_in, batch_tgt_out [], [], [] for src, tgt in pairs: src_ids [word2id.get(c, word2id[unk]) for c in src] tgt_ids [word2id.get(c, word2id[unk]) for c in tgt] # decoder 输入句子开头放 bos tgt_in [bos_id] tgt_ids # decoder 目标句子结尾放 eos tgt_out tgt_ids [eos_id] # 三个序列统一补到当前 batch 的最大长度 cur_len max(len(src_ids), len(tgt_in)) batch_src.append(src_ids [pad_id] * (cur_len - len(src_ids))) batch_tgt_in.append(tgt_in [pad_id] * (cur_len - len(tgt_in))) batch_tgt_out.append(tgt_out [pad_id] * (cur_len - len(tgt_out))) if len(batch_src) batch_size: yield np.array(batch_src), np.array(batch_tgt_in), np.array(batch_tgt_out) batch_src, batch_tgt_in, batch_tgt_out [], [], [] # 最后不足一个 batch 的样本也要 yield 出去 if batch_src: yield np.array(batch_src), np.array(batch_tgt_in), np.array(batch_tgt_out)这里的错位是理解重点tgt_in 在开头插入 bostgt_out 在结尾追加 eos两者长度一致但内容右移一位。模型看到「学」预测「学」的下一个字看到「学而时习之」预测「学习并且经常温习」本质就是在学条件概率。padding 时三个序列统一补到同一长度这个 pad 位置在后面计算 loss 时必须掩掉否则模型会拼命学习预测「pad」这是第 5 章要展开的坑。batch_size 我一般从 32 起调显存不够先降 batch 而不是砍 max_len。4.3 写 TFRecord 的固定套路与读取参数最后一步是把 batch 序列化到磁盘。TFRecord_operate.py 的核心逻辑是构造 Example 对象再写入import tensorflow as tf def write_tfrecord(path, src_ids_list, tgt_ids_list): # 注意TF 1.x 用 tf.python_io.TFRecordWriter # TF 2.x 换成 tf.io.TFRecordWriter写法基本一致 with tf.python_io.TFRecordWriter(path) as writer: for s, t in zip(src_ids_list, tgt_ids_list): example tf.train.Example(featurestf.train.Features(feature{ src: tf.train.Feature(int64_listtf.train.Int64List(values)), tgt: tf.train.Feature(int64_listtf.train.Int64List(valuet)), })) writer.write(example.SerializeToString())这里最容易翻车的点是 feature 名写入端叫src/tgt读取端解析时必须严格同名大小写和拼写差一个字符都读不出来。读取时一般配合tf.data.TFRecordDataset用 shuffle buffer 打乱数据顺序。shuffle buffer 的大小会影响随机性我一般设为总样本数的五分之一左右太小的话每个 epoch 的样本顺序变化不大模型容易记住语料顺序而不是语言规律。5. 避坑四类实际翻车现场与排查过程数据链路和训练代码都跑通之后真正的噩梦才开始。这一章记录的是最常遇到的四类翻车现场每一条都是真实排查过的按「现象 → 原因 → 解决」写。建议先收藏遇到对应报错再回来看。5.1 训练期的两个大坑loss 横盘与全场 UNK坑 1loss 一直横在 4.5 左右几十个 epoch 不动。现象Attention_train.py 跑起来前几个 epoch loss 快速降到 4.5 附近然后就死水一潭偶尔抖动但整体不降。原因常见原因有三个。第一loss 没有对 padding 位置做 mask模型花大量精力「学习」预测 pad token真实位置的梯度被稀释第二学习率设得太大attention 权重在震荡中始终收敛不了第三TFRecord 读取时没做 shuffle每个 epoch 的样本顺序完全相同模型学到了语料排序而不是语言规律。解决先在训练代码里找 loss 计算处确认是否乘了 maskmask 与 padding 位置对应把 pad 位置的 loss 置 0再把学习率降到 0.001 以下必要时加 warmup最后在 dataset 读取时加大 shuffle buffer。这三步做完loss 通常会从 4.x 平滑降到 3.0 以下才算进入正常训练状态。坑 2测试输出里全是 UNK常见字能翻译人名地名全军覆没。现象attention_test.py 输入「孔子登东山而小鲁」输出变成「 登上 山而 」。原因Text_vocab.py 的 min_count 设得太高生僻字出现次数不足被过滤成了 UNK解码阶段又把 UNK 原样输出没有做任何兜底。解决字典层把 min_count 从默认值降到 1~2让生僻字保留在 vocab 里解码层在输出端过滤 UNK——当 beam search 选中 UNK 时用 attention 权重最高的源端字符回填。这个方法虽然朴素但实测能明显降低 UNK 比例尤其是人名地名密集的古文句子。5.2 数据层和服务层的两个小坑TF 版本迁移与端口占用坑 3import tensorflow 后直接报AttributeError: module tensorflow has no attribute Session。现象在自己电脑上复现时一跑 Attention_train.py 就报错提示 Session 或 placeholder 不存在。原因这是典型的 TF 1.x 代码跑在 TF 2.x 环境。源码里的 Session、placeholder、get_variable 都是 1.x 接口2.x 默认 eager 模式里这些都没了。解决最省心的是配老环境python 3.6/3.7 tensorflow 1.15几乎不用改代码如果必须留在 TF 2.x在入口文件加一行tf.compat.v1.disable_eager_execution()并把tf.Session、tf.placeholder改成tf.compat.v1.Session、tf.compat.v1.placeholder。我一般建议毕设直接走老环境改 compat 接口容易引发连锁报错不值得为版本问题耽误进度。坑 4ServerSocket.py 第二次启动报OSError: [Errno 98] Address already in use。现象第一次运行 socket 服务正常CtrlC 中断后再次运行bind 直接报端口被占。原因上一次进程没完全退出端口还被内核占用。socket 默认不会设置地址复用短时间内重启必然冲突。解决先找到占用进程再决定杀不杀。Mac/Linux 用lsof -i:端口号拿到 PIDWindows 用netstat -ano | findstr 端口号确认后kill -9 PID。自己改代码的话在 bind 之前加一行s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)这个问题就从根上解决了。6. 验证与二次开发把 attention 结果变成答辩能用的数据模型训练完最后一步是验证和展示。毕设答辩最常被问的一句话是「你这个翻译效果到底怎么评价」。如果只放几个翻译样例说服力不够至少要有一个量化指标。常见的做法是算 BLEU 近似值——统计译文和参考译文的 n-gram 重叠率def bleu_approx(pred, ref, n2): # 简易 n-gram 重叠率适合快速抽查正式评估建议用 nltk/sacrebleu pred_tokens list(pred) ref_tokens list(ref) if len(pred_tokens) n: return 0.0 pred_ngrams set(zip(*[pred_tokens[i:] for i in range(n)])) ref_ngrams set(zip(*[ref_tokens[i:] for i in range(n)])) overlap len(pred_ngrams ref_ngrams) return overlap / max(1, len(pred_ngrams))这个函数在测试集上跑一遍输出一个 0~1 之间的分数写进论文的对比表格里。如果想证明 attention 确实学到了对齐关系还可以把测试句的 attention 权重矩阵保存下来画成热力图选一两个「古文词 → 现代文词」对齐明显的例子贴在论文里比大段文字描述直观得多。跑通之后再往上做有四个比较实际的改造方向。第一换自己的语料《论语》《战国策》这些公有领域古籍整理成 pair 结构的 XML整个 pipeline 不用动换文件路径就能重新训练第二改网络结构在 neural_network.py 里把单向 RNN 换成双向 GRU或者加深 attention 层数这是毕设里最好讲的工作量第三把 socket 服务外面包一层 Flask变成 HTTP 接口前端页面一接就是完整的演示系统第四做数据增强对现代文一侧做同义改写或简单回译扩充训练集规模。我自己的习惯是接到任何源码先跑一遍 README再按数据流把脚本挨个跑通最后才动模型参数。这个顺序帮我避开了很多「改了半天不知道改的是哪一层」的尴尬。希望这篇拆解能帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进