机器翻译框架 OpenNMT 入门:快速上手

Category 碎碎念

拆解项目代码的时候发现使用到了 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.txthead -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 可以将日志文件保存到相应目录,这里我没有添加,直接用终端中的输出数据分析。

n

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 就可以看到输出了,结果非常生草🤣

如 此 者 , 难 信 也 。
传 西 北 有 一 海 岛 , 居 神 仙 , 留 此 书 。
惜 此 书 已 不 明 矣 。
后 诏 遍 遍 天 下 有 知 儒 生 。
命 大 臣 数 十 年 , 尽 有 书 藏 。
最 后 定 上 书 《 法 术 法 》 。

References