拆解项目代码的时候发现使用到了 onmt
这个古怪东西,查阅资料后才知道这是一个自然机器翻译的框架,是自然语言处理中常用的工具。但是相关资料又太少,于是不得不照着文档一点一点啃,最后留下了这篇笔记。
OpenNMT 官方描述该框架为 an open source neural machine translation system,点进 OpenNMT 的官网可以看到更多资料,因此也不需要我多描述。总结一下就是,OpenNMT 是搭建自然语言处理模型的开源框架,其中自然包括常见的 RNN 和 Transformer 等模型。如果在自然语言处理方面有需要,OpenNMT 绝对是一个轻量有效的框架。
OpenNMT 的中文学习资料较少,于是我只能参考官方文档学习。在这篇笔记中,我会把文档中的模型都试验一遍(希望别🕊️),记录下整个过程或许能帮助到需要的人。
准备工作
首先介绍一下我的运行环境,我的设备搭载一块 GTX 1080
GPU,系统为 Ubuntu 20.04
,需要提前在设备上安装好 CUDA,我使用 Anaconda 配置 Python 环境。
Note OpenNMT 不支持过旧的 GPU,之前在 GTX 970
的设备上就无法训练模型,这个问题可能除了换设备无解😭
快速上手
安装 OpenNMT
OpenNMT 有 PyTorch 与 TensorFlow 两个版本,PyTorch 在环境搭建上方便很多,所以我选择 PyTorch 版本。先创建虚拟环境,再直接通过 pip
安装 OpenNMT-py,Python 版本为 3.9
,OpenNMT-py 为 3.0.2
。
# 创建虚拟环境
conda create -n nlp python==3.9
# 激活虚拟环境
conda activate nlp
# 安装 OpenNMT
pip install OpenNMT-py==3.0.2
Note 国内直连 PyPI 的速度可能很慢,可以使用清华源,命令为 pip install -i https://pypi.tuna.tsinghua.edu.cn/simple {some-package}
,将其中的 {some-package}
替换为需要安装的包名称。
直接安装 OpenNMT-py
很有可能会有问题,主要原因是 Pytorch 与 CUDA 版本不匹配。可以用 nvcc -V
查询 CUDA 版本,再在 Pytorch 官网找到相应的版本。例如我的 CUDA 版本为 11.4
,那么就需要重新安装以下版本的包解决依赖问题:
pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 torchaudio==0.12.1 --extra-index-url https://download.pytorch.org/whl/cu113
准备数据
快速上手一节搭建的是一个简单的双语翻译模型,因此需要准备两种语言的数据,分别为源语言src
和目标语言 tgt
,数据文件中每行包含一句话,以空格分隔不同的词。再考虑到训练集和验证集,那么一共需要 4 种数据文件:
src-train.txt
tgt-train.txt
src-val.txt
tgt-val.txt
Note 可以想象到,如果处理的是英语、法语等以空格分隔单词的语言,只需要将文本数据处理为每行一句话的格式即可。但对于汉语、日语等不以特殊标记分隔词语的语言,数据需要经过额外的分词步骤后才可以使用。
官方提供了英语-德语的数据文件,可以直接下载:
wget https://s3.amazonaws.com/opennmt-trainingdata/toy-ende.tar.gz
tar xf toy-ende.tar.gz
也可以到 OpenNMT-py/data/ 在线查看数据长什么样子。
接着通过 vim toy_en_de.yaml
在目录下创建 .yaml
配置文件,内容为
# toy_en_de.yaml
## Where the samples will be written
save_data: toy-ende/run/example
## Where the vocab(s) will be written
src_vocab: toy-ende/run/example.vocab.src
tgt_vocab: toy-ende/run/example.vocab.tgt
# Prevent overwriting existing files in the folder
overwrite: False
# Corpus opts:
data:
corpus_1:
path_src: toy-ende/src-train.txt
path_tgt: toy-ende/tgt-train.txt
valid:
path_src: toy-ende/src-val.txt
path_tgt: toy-ende/tgt-val.txt
创建文件后的文件结构为
.
├── toy-ende
│ ├── src-test.txt
│ ├── src-train.txt
│ ├── src-val.txt
│ ├── tgt-test.txt
│ ├── tgt-train.txt
│ └── tgt-val.txt
└── toy_en_de.yaml
设置完成后,运行以下命令开始构建词库:
onmt_build_vocab -config toy_en_de.yaml -n_sample 10000
其中 n_sample
设定了从语料中获取多少行的数据用于构建词库。
Note 若使用虚拟环境,需要进入虚拟环境后才能运行上述命令,若使用 Anaconda,也需要先激活环境。Pytorch 与 CUDA 版本不匹配会导致 undefined symbol: cublasLtGetStatusString, version libcublasLt.so
错误。
训练模型
训练模型也十分简单,在 .yaml
文件中追加以下内容:
# Vocabulary files that were just created
src_vocab: toy-ende/run/example.vocab.src
tgt_vocab: toy-ende/run/example.vocab.tgt
# Train on a single GPU
world_size: 1
gpu_ranks: [0]
# Where to save the checkpoints
save_model: toy-ende/run/model
save_checkpoint_steps: 500
train_steps: 1000
valid_steps: 500
使用 onmt_train -config toy_en_de.yaml
开始训练,该配置会生成默认的 2 层具有 500 个隐藏单元的 LSTM 模型。
模型预测
使用类似的命令进行模型预测,模型预测能够将文本文件中的内容翻译并保存到输出文件中:
onmt_translate -model toy-ende/run/model_step_1000.pt -src toy-ende/src-test.txt -output toy-ende/pred_1000.txt -gpu 0 -verbose
- 上面的命令使用了训练得到的模型
toy-ende/run/model_step_1000.pt
- 预测
toy-ende/src-test.txt
测试集数据 - 将结果输出到
toy-ende/pred_1000.txt
, -gpu
指定了使用的 GPU-verbose
指定在终端中输出每个步骤的详细结果
使用 head -n 2 toy-ende/src-test.txt
和 head -n 2 toy-ende/pred_1000.txt
查看一下预测结果:
# test
Orlando Bloom and Miranda Kerr still love each other
Actors Orlando Bloom and Model Miranda Kerr want to go their separate ways .
# pred
Die <unk> der <unk> der <unk> , die die <unk> der <unk> ……
由于训练时间很短,数据集很小,预测结果不会好,再加上不认识德语,也无法判断结果的优劣,所以接下来尝试在更大的中文语料上进行翻译任务。
文言翻译
B 站上有一个展示 OpenNMT 的视频,实在很不错。视频中展示的翻译任务是将白话译为文言,不仅直观而且十分有趣,我觉得特别适合用来入门,作者的代码也公开在 GitHub 上,可以和本文相互参照,本文中的代码也可以在 Tseing/OpenNMT-wenyan 找到。
准备数据
白话文与文言文的平行语料来自于 NiuTrans/Classical-Modern,包含了大量内容:
# Classical-Modern/source/
元史 北齐书 南齐书 后汉书 太平广记 宋史 新五代史 旧五代史 明史 梁书 汉书 辽史 陈书 魏书
北史 南史 史记 周书 宋书 徐霞客 新唐书 旧唐书 晋书 水经注全 短篇章和资治通鉴 金史 隋书
下载数据后可以先用 head Classical-Modern/source/史记
与 head Classical-Modern/target/史记翻译
查看一下语料:
# 史记
後为太常,坐法当死,赎免为庶人。
上曰:剑,人之所施易,独至今乎?
然终不自明也。
然亦无所毁。
# 史记翻译
因为触犯法律判处死刑,纳米粟入官赎罪后成了平民。
景帝说:剑是人们所喜爱之物,往往用来送人或交换他物,难道你能保存到现在吗?
说过后他终究不再做其他辩解。
然后也没有讲别人的什么坏话。
原始语料是将文言翻译为白话,因此 source
中存储了原文,target
中存储了翻译,每行一句话,两个文件一一对应。我们需要预处理数据,将所有文本都作为数据集。
import os
source_root = 'Classical-Modern/source'
target_root = 'Classical-Modern/target'
for f in os.listdir(source_root):
print("processing " + f)
source_file = os.path.join(source_root, f)
target_file = os.path.join(target_root, f + '翻译')
# 统计各文本中行数
with open(source_file, "r", encoding="utf-8") as source_f:
source_len = sum(1 for _ in source_f)
with open(target_file, "r", encoding="utf-8") as target_f:
target_len = sum(1 for _ in target_f)
# 对比平行语料行数,确保一致
assert source_len == target_len
try:
with open('dataset/source_raw.txt', "a+", encoding="utf-8") as source_f:
source_f.write(open(source_file, "r", encoding="utf-8").read())
with open('dataset/target_raw.txt', "a+", encoding="utf-8") as target_f:
target_f.write(open(target_file, "r", encoding="utf-8").read())
except FileNotFoundError:
os.mkdir('dataset')
检查一下处理的结果:
# dataset/source_raw.txt
密计不行。
使者利金,遂相许。
遣说诸小贼,所至辄降,让始敬焉,召与计事。
宇文温每谓密曰:不杀元真,公难未已。
# dataset/target_raw.txt
李密的意见没有被采纳。
押送的人贪图金钱,便满口答应。
派人游说小股义军,被劝说的人都归顺了翟让,翟让开始看重了他,叫他同自己一起讨论重大问题。
宇文温常对李密说:不杀邴元真,您的祸害就不会排除。
接下来要对文本分词,对于文言文来说,单字词的占比非常高,将每个单字作为一个词就是一种比较方便的分词方法,所以在每个字符后插入空格即可。而白话文中有大量的双字词,甚至三字词、四字词,必须使用专门的分词引擎,这里我使用了 THULAC,直接通过 pip install thualac
就能安装。
import thulac
# 对文言文本分词
with open('dataset/source_raw.txt', 'r', encoding='utf-8') as f:
# 目标是 白话->文言,因此将文言作为目标 target.txt
with open('dataset/target.txt', 'w+', encoding='utf-8') as s:
print("separating wenyan text...")
while True:
line = f.readline()
if line:
line_seq = " ".join([char for char in line])
s.write(line_seq)
else:
break
# 对白话文本分词,将白话作为源语言 source.txt
print("separating modern text...")
sep_model = thulac.thulac(seg_only=True)
sep_model.cut_f('dataset/target_raw.txt', 'dataset/source.txt')
- 使用
thulac
首先要加载分词模型,seg_only
指定只分词,不输出词性 cut_f(input, output)
用于对文件input
分词,并将结果保存到output
Note 使用 thulac 处理文件时一般会输出 UnicodeDecodeError
错误,主要是读取文件时的编码错误,是 thulac 本身的一个 bug,请看下文的解决方法(也可能官方修好了)。
找到错误信息中的 site-packages\thulac\__init__.py
文件,第 187 行与第 188 行的代码为
input_f = open(input_file, 'r')
output_f = open(output_file, 'w')
将其修改为
input_f = open(input_file, 'r', encoding='utf-8')
output_f = open(output_file, 'w', encoding='utf-8')
同样再检查一下处理的结果:
# dataset/source.txt
李密 的 意见 没有 被 采纳 。
押送 的 人 贪图 金钱 , 便 满口答应 。
派 人 游说 小 股义军 , 被 劝说 的 人 都 归顺 了 翟让 , 翟让 开始 看重 了 他 , 叫 他 同 自己 一起 讨论 重大 问题 。
宇文 温常 对 李密 说 : 不 杀 邴元真 , 您 的 祸害 就 不 会 排除 。
# dataset/target.txt
密 计 不 行 。
使 者 利 金 , 遂 相 许 。
遣 说 诸 小 贼 , 所 至 辄 降 , 让 始 敬 焉 , 召 与 计 事 。
宇 文 温 每 谓 密 曰 : 不 杀 元 真 , 公 难 未 已 。
分词结果虽然有错误,但总体效果还可以,最后将全部文本划分为训练集与验证集。由于文本数据非常大,不适合读取后转换为列表进行操作,我写了一个划分 .txt
文本的脚本,可以在 GitHub 上找到,代码不复杂,就不展开介绍了。划分数据集后的文件结构与 OpenNMT 要求的数据一致,就可以直接使用了。
dataset
├── src-train.txt
├── src-val.txt
├── source.txt
├── source_raw.txt
├── target.txt
├── target_raw.txt
├── tgt-train.txt
└── tgt-val.txt
构建词库
同样创建 .yaml
配置文件,内容为
# wenyan.yaml
## Where the samples will be written
save_data: run/wenyan
## Where the vocab(s) will be written
src_vocab: run/wenyan.vocab.src
tgt_vocab: run/wenyan.vocab.tgt
src_vocab_size: 200000
tgt_vocab_size: 200000
overwrite: True
# Corpus opts:
data:
corpus_1:
path_src: dataset/src-train.txt
path_tgt: dataset/tgt-train.txt
valid:
path_src: dataset/src-val.txt
path_tgt: dataset/tgt-val.txt
# Train on a single GPU
world_size: 1
gpu_ranks: [0]
queue_size: 100
bucket_size: 2048
# Train batch
batch_size: 32
# Validation batch
valid_batch_size: 16
# Where to save the checkpoints
save_model: run/model
save_checkpoint_steps: 10000
train_steps: 1000000
valid_steps: 10000
queue_size
为读取数据的消息队列大小bucket_size
为读取数据的缓冲区大小,用于避免无法实时读取数据batch_size
为训练过程中处理的批大小valid_batch_size
为验证过程中处理的批大小
onmt_build_vocab -config wenyan.yaml -n_sample -1
同样使用该命令开始构建词库,将 -n_sample
指定为 -1
能让模型使用整个数据集的数据构建词库。可以使用 head run/wenyan.vocab.src
查看一下构建的词库:
, 2041007
。 920182
的 700401
不 279935
、 224914
了 195770
是 176242
他 175822
在 156216
说 143227
训练模型
使用 onmt_train -config wenyan.yaml
开始训练模型。
由于数据集很大,训练模型需要非常长的时间,中途可能训练中断或卡死。由于在训练过程中保存了 checkpoints
,也不需要重头训练,可以从保存的断点继续训练。可以使用以下的自动化脚本,将其保存为 train.py
,就可以通过 python train.py
自动继续训练。
import os
model_root = 'run/wenyan'
try:
checkpoints = [x for x in os.listdir(model_root) if x.endswith('.pt')]
except FileNotFoundError:
os.mkdir(model_root)
checkpoints = [x for x in os.listdir(model_root) if x.endswith('.pt')]
last_checkpoint = None
if len(checkpoints) > 0:
checkpoints = sorted(checkpoints, key=lambda x: int(x[:-3].split('_')[-1]))
last_checkpoint = checkpoints[-1]
last_checkpoint = os.path.join(model_root, last_checkpoint)
if last_checkpoint is not None:
print('last_checkpoint', last_checkpoint, os.path.exists(last_checkpoint))
if isinstance(last_checkpoint, str) and os.path.exists(last_checkpoint):
# 中断后继续训练使用
os.system('onmt_train -config wenyan.yaml --train_from="%s"' % last_checkpoint)
else:
os.system('onmt_train -config wenyan.yaml')
else:
os.system('onmt_train -config wenyan.yaml')
在 .yaml
文件中添加 log_file
可以将日志文件保存到相应目录,这里我没有添加,直接用终端中的输出数据分析。
ACC 和 PPL 是自然语言处理中常用的指标,当然还有更好用的 BELU 等指标。可以看出训练过程收敛得非常快,PPL 迅速下降,ACC 也在第 20 个模型后保持稳定了,检查一下日志文件发现果然在第 23 个模型后学习率变成 0 了。这就说明不需要指定那么多的训练步骤,降低到 300000 可能是比较合适的。
模型预测
那么就选最后一个模型作为最终用于预测的模型,将其他模型全部删除。
Note 可以在 .yaml
文件中通过参数 keep_checkpoint
指定需要保存的模型数量,在训练过程中会自动删去多余的模型,节省存储空间。
在目录中新建一个文本 input.txt
,写入需要翻译的语句,每行一句话:
这样的事情是很难令人相信的。
传说西北方有一座海岛,居住着神仙,留下这部书。
可惜这本书的字已经看不清了。
后来皇帝下旨找遍了天下具有智识的儒生。
命令大臣在几十年里翻遍了所有的藏书。
最后确定封面上写着《算法:C语言实现》。
Warning 每句话以换行(\n
)分隔,注意最后一行不能有空行,否则会报错。
接着需要对输入文本分词,同样使用 thulac
,新建一个 input_sep.py
:
import thulac
sep_model = thulac.thulac(seg_only=True)
sep_model.cut_f('input.txt', 'input_sep.txt')
运行 python input_sep.py
完成分词后,使用以下命令开始翻译:
onmt_translate --model 'run/model_final.pt' --src input_sep.txt --output output.txt
使用 cat output.txt
就可以看到输出了,结果非常生草🤣
如 此 者 , 难 信 也 。
传 西 北 有 一 海 岛 , 居 神 仙 , 留 此 书 。
惜 此 书 已 不 明 矣 。
后 诏 遍 遍 天 下 有 知 儒 生 。
命 大 臣 数 十 年 , 尽 有 书 藏 。
最 后 定 上 书 《 法 术 法 》 。