1 引言

各位朋友大家好,欢迎来到月来客栈。经过前面几篇文章的介绍,相信各位读者对于Transformer的基本原理以及实现过程已经有了一个较为清晰的认识。不过想要对一个网络模型有更加深刻的认识,那么最好的办法便是从数据预处理到模型训练,自己完完全全的经历一遍。因此,为了使得大家能够更加透彻的理解Transformer的整个工作流程,在本篇文章中笔者将继续带着大家一起来还原论文中的文本翻译模型。

图 1. Transformer网络结构图

如图1所示便是Transformer网络的整体结构图,对于这部分内容在上一篇文章中总体上算是介绍完了,只是在数据预处理方面还未涉及。下面,笔者就以Multi30K[1]中的English-German平行语料为例进行介绍(注意这并不是论文中所用到数据集)。

2 数据预处理

2.1 语料介绍

在这里,我们使用到的平行语料一共包含有6个文件train.detrain.enval.deval.entest_2016_flickr.detest_2016_flickr.en,其分别为德语训练语料、英语训练语料、德语验证语料、英语验证语料、德语测试语料和英语测试语料。同时,这三部分的样本量分别为29000、1014和1000条。

如下所示便是一条平行预料数据,其中第1行为德语,第2行为英语,后续我们需要完成的就是搭建一个翻译模型将德语翻译为英语。

2.2 数据集预览

在正式介绍如何构建数据集之前,我们先通过几张图来了解一下整个构建的流程,以便做到心中有数,不会迷路。

图 2. 翻译模型数据集处理过程图(一)

如图2所示,左边部分为原始输入,右边部分为目标输入。从图2可以看出,第1步需要完成的就是对原始语料进行tokenize操作。如果是对类似英文这样的语料进行处理,那就是直接按空格切分即可。但是需要注意的是要把其中的逗号和句号也给分割出来。第2步需要做的就是根据tokenize后的结果对原始输入和目标输入分别建立一个字典。第3步需要做的则是将tokenize后结果根据字典中的索引将其转换成token序列。第4步则是对同一个batch中的序列以最长的为标准其它样本进行padding,并且同时需要在目标输入序列的前后加上起止符(即'< bos >'和' < eos >') 。

图 3. 翻译模型数据集处理过程图(二)

如图3所示,在完成前面4个步骤后,对于目标序列来说第5步需要做的就是将第4步处理后的结果划分成tgt_inputtgt_output。从图3右侧可以看出,tgt_inputtgt_output是相互对应起来的。例如对于第1个样本来说,解码第1个时刻的输入应该是"2",而此时刻对应的正确标签就应该是tgt_output中的'8';解码第2个时刻的输入应该是tgt_input中的'8',而此时刻对应的正确标签就应该是tgt_output中的'45',以此类推下去。最后,第6步则是根据src_inputtgt_input各自的padding情况,得到一个padding mask向量(注意由于这里tgt_input中的两个样本长度一样,所以并不需要padding),其中'T'表示padding的位置。当然,这里的tgt_mask并没有画出。

同时,图3中各个部分的结果体现在Transformer网络中的情况如图4所示。

图 4. 基于Transformer翻译模型的模型输入情况

以上就是基于Transformer架构的翻译模型数据预处理的整个大致流程,下面我们开始正式来通过编码实现这一过程。

2.3 数据集构建

第1步:定义tokenize

如果是对类似英文这样的语料进行处理,那就是直接按空格切分即可。但是需要注意的是要把其中的逗号和句号也给分割出来。因此,这部分代码可以根据如下方式进行实现:

可以看到,其实也非常简单。例如对于如下文本来说

其tokenize后的结果为:

第2步:建立词表

在介绍完tokenize的实现方法后,我们就可以正式通过torchtext.vocab中的Vocab方法来构建词典了,代码如下:

在上述代码中,第3行代码用来指定特殊的字符;第5-7行代码用来遍历文件中的每一个样本(每行一个)并进行tokenize和计数,其中对于counter.update进行介绍可以参考[2];第8行则是返回最后得到词典。

在完成上述过程后,我们将得到两个Vocab类的实例化对象。

一个为原始序列的字典:

一个为目标序列的字典:

此时,我们就需要定义一个类,并在类的初始化过程中根据训练语料完成字典的构建,代码如下:

第3步:转换为Token序列

在得到构建的字典后,便可以通过如下函数来将训练集、验证集和测试集转换成Token序列:

在上述代码中,第11-4行分别用来将原始序列和目标序列转换为对应词表中的Token形式。在处理完成后,就会得到类似如下的结果:

其中左边的一列就是原始序列的Token形式,右边一列就是目标序列的Token形式,每一行构成一个样本。

第4步:padding处理

从上面的输出结果(以及图2中第③步后的结果)可以看到,无论是对于原始序列来说还是目标序列来说,在不同的样本中其对应长度都不尽相同。但是在将数据输入到相应模型时却需要保持同样的长度,因此在这里我们就需要对Token序列化后的样本进行padding处理。同时需要注意的是,一般在这种生成模型中,模型在训练过程中只需要保证同一个batch中所有的原始序列等长,所有的目标序列等长即可,也就是说不需要在整个数据集中所有样本都保证等长。

因此,在实际处理过程中无论是原始序列还是目标序列都会以每个batch中最长的样本为标准对其它样本进行padding,具体代码如下:

在上述代码中,第6-7行用来在目标序列的首尾加上特定的起止符;第9-10行则是分别对一个batch中的原始序列和目标序列以各自当中最长的样本为标准进行padding(这里的pad_sequence导入自torch.nn.utils.rnn)。

第5步:构造mask向量

在处理完成图2中的第④步后,对于图3中的第⑤步来说就是简单的切片操作,因此就不作介绍。进一步需要根据src_inputtgt_input来构造相关的mask向量,具体代码如下:

在上述代码中,第1-4行是用来生成一个形状为[sz,sz]的注意力掩码矩阵,用于在解码过程中掩盖当前position之后的position;第6-17行用来返回Transformer中各种情况下的mask矩阵,其中src_mask在这里并没有作用。

第6步:构造DataLoade与使用示

经过前面5步的操作,整个数据集的构建就算是已经基本完成了,只需要再构造一个DataLoader迭代器即可,代码如下:

在上述代码中,第2-4行便是分别用来将训练集、验证集和测试集转换为Token序列;第5-10行则是分别构造3个DataLoader,其中generate_batch将作为一个参数传入来对每个batch的样本进行处理。在完成类LoadEnglishGermanDataset所有的编码过程后,便可以通过如下形式进行使用:

各位读者在阅读这部分代码时最好是能够结合图2-4进行理解,这样效果可能会更好。在介绍完数据集构建的整个过程后,下面就开始正式进入到翻译模型的构建中。

3 基于Transformer的翻译模型

3.1 网络结构

总体来说,基于Transformer的翻译模型的网络结构其实就是图4所展示的所有部分,只是在前面介绍Transformer网络结构时笔者并没有把Embedding部分的实现给加进去。这是因为对于不同的文本生成模型,其Embedding部分会不一样(例如在诗歌生成这一情景中编码器和解码器共用一个TokenEmbedding即可,而在翻译模型中就需要两个),所以将两者进行了拆分。同时,待模型训练完成后,在inference过程中Encoder只需要执行一次,所以在此过程中也需要单独使用Transformer中的Encoder和Decoder。

首先,我们需要定义一个名为TranslationModel的类,其前向传播过程代码如下所示:

在上述代码中,第7-12行便是用来定义一个Transformer结构;第13-16分别用来定义Positional Embedding、Token Embedding和最后的分类器;第29-39行便是用来执行整个前向传播过程,其中Transformer的整个前向传播过程在前一篇文章中已经介绍过,在这里就不再赘述。

在定义完logits的前向传播过后,便可以通过如下形式进行使用:

接着,我们需要再定义一个EncoderDecoder在inference中使用,代码如下:

在上述代码中,第1-5行用于在inference时对输入序列进行编码并得到memory(只需要执行一次);第7-11行用于根据memory和当前解码时刻的输入对输出进行预测,需要循环执行多次,这部分内容详见模型预测部分。

3.2 模型训练

在定义完成整个翻译模型的网络结构后下面就可以开始训练模型了。由于这部分代码较长,所以下面笔者依旧以分块的形式进行介绍:

第1步:载入数据集

首先我们可以根据前面的介绍,通过类LoadEnglishGermanDataset来载入数据集,其中config中定义了模型所涉及到的所有配置参数。

第2步:定义模型并初始化权重

在载入数据后,便可以定义一个翻译模型TranslationModel,并根据相关参数对其进行实例化;同时,可以对整个模型中的所有参数进行一个初始化操作。

第3步:定义损失学习率与优化器

在上述代码中,第1行是定义交叉熵损失函数,并同时指定需要忽略的索引ignore_index。因为根据图3的tgt_output可知,有些位置上的标签值其实是Padding后的结果,因此在计算损失的时候需要将这些位置给忽略掉。第2行代码则是论文中所提出来的动态学习率计算过程,其计算公式为:

具体实现代码为:

通过CustomSchedule,就能够在训练过程中动态的调整学习率。学习率随step增加而变换的结果如图5所示:

图 5. 动态学习率变化过程图

从图5可以看出,在前warm_up个step中,学习率是线性增长的,在这之后便是非线性下降,直至收敛与0.0004。

第4步:开始训练

在上述代码中,第5-9行是用来得到模型各个部分的输入;第10-18行是计算模型整个前向传播的过程;第21-25行则是执行损失计算与反向传播;第27-29则是将每个step更新后的学习率送入到模型中并更新参数;第31行是用来计算模型预测的准确率,具体过程将在后续文章中进行介绍。以下便是模型训练过程中的输出:

3.3 模型预测

在介绍完模型的训练过程后接下来就来看模型的预测部分。生成模型的预测部分不像普通的分类任务只需要将网络最后的输出做argmax操作即可,生成模型在预测过程中往往需要按时刻一步步进行来进行。因此,下面我们这里定义一个translate函数来执行这一过程,具体代码如下:

在上述代码中,第6行是将待翻译的源序列进行序列化操作;第8-11行则是通过函数greedy_decode函数来对输入进行解码;第12行则是将最后解码后的结果由Token序列在转换成实际的目标语言。同时,greedy_decode函数的实现如下:

在上述代码中,第3行是将源序列输入到Transformer的编码器中进行编码并得到Memory;第4-5行是初始化解码阶段输入的第1个时刻的,在这里也就是' ';第6-18行则是整个循环解码过程,在下一个时刻为EOS_IDX或者达到最大长度后停止;第8-9行是根据当前解码器输入的长度生成注意力掩码矩阵tgt_mask;第10行是根据memory以及当前时刻的输入对当前时刻的输出进行解码;第12-14行则是分类得到当前时刻的解码输出结果;第15行则是将当前时刻的解码输出结果头当前时刻之前所有的输入进行拼接,以此再对下一个时刻的输出进行预测。

最后,我们只需要调用如下函数便可以完成对原始输入语言的翻译任务:

在上述代码中,第5-14行是定义网络结构,以及恢复本地保存的网络权重;第15行则是开始执行翻译任务;第19-28行为翻译示例,其输出结果为:

其中第一句德语为训练集之外的数据。

以上完整代码可参见[3]。

4 总结

在这篇文章中,笔者首先介绍了翻译模型的整个数据预处理过程,包括首先以图示的方式对整个过程进行了说明,然后再一步步地通过编码实现了整个数据集的构造过程;接着笔者介绍了基于Transformer结构的翻译模型的整体构成,然后循序渐进地带着各位读者来实现了整个翻译模型,包括基础结构的搭建、模型训练的详细实现、动态学习率的调整实现等;最后介绍了如何来实现模型在实际预测过程中的处理流程等,包括源输入序列的构建、解码时刻输入序列的构建等。在下一篇文章中,笔者将会介绍如何基于Transformer结构来搭建一个简单的文本分类模型。

本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎分享至一位你的朋友!若有任何疑问与建议,请添加笔者微信'nulls8'或加群进行交流。青山不改,绿水长流,我们月来客栈见!

引用

[1] https://github.com/multi30k/dataset

[2] 你还在手动构造词表?试试torch.vocab

[3] https://github.com/moon-hotel/TransformerTranslation

推荐阅读

[1] This post is all you need(①多头注意力机制原理)

[2] This post is all you need(②位置编码与编码解码过程)

[3] This post is all you need(③网络结构与自注意力实现)

[4] This post is all you need(④Transformer的实现过程)