(导语)想要做个神经机器翻译模型?想要做个强大的Transformer?搞定这千行PaddlePaddle代码你也可以。
目前,无论是从性能、结构还是业界应用上,Transformer 都有很多无可比拟的优势。本文将介绍Paddle Paddle 的Transformer项目,我们从项目使用到源码解析带你玩一玩NMT。只需千行模型代码,Transformer实现带回家。
其实PyTorch、TensorFlow等主流框架都有Transformer的实现,但如果我们需要将它们应用到产品中,还是需要修改很多。
例如谷歌大脑构建的Tensor2Tensor,它最开始是为了实现 Transformer,后来扩展到了各种任务。对于基于Tensor2Tensor实现翻译任务的用户,他们需要在10万+行TensorFlow代码找到需要的部分。
PaddlePaddle 提供的Transformer实现,项目代码只有2000+行,简洁优雅。如果我们使用大Batch Size,那么在预测速度上,PaddlePaddle复现的模型比TensorFlow官方使用tensor2tensor实现的模型还要快4倍。
项目地址:https://github.com/PaddlePaddle/models/tree/develop/fluid/PaddleNLP/neural_machine_translation/transformer
Transformer怎么用
相比此前 Seq2Seq 模型中广泛使用的循环神经网络,Transformer 使用深层注意力机制获得了更好的效果,目前大多数神经机器翻译模型都采用了这一网络结构。此外,不论是新兴的预训练语言模型,还是问答或句法分析,Transformer都展现出强大的建模能力。
相比传统NMT使用循环层或卷积层抽取文本信息,Transformer使用自注意力网络抽取并表征这些信息,下图对比了不同层级的特点:
图注:不同网络的主要性质,其中n表示序列长度、d为隐向量维度、k为卷积核大小。例如单层计算复杂度,一般句子长度n都小于隐向量维度d,那么自注意力层级的计算复杂度最小。
如上所示,Transformer使用的自注意力模型主要拥有以下优点,1)网络结构的计算复杂度最低;2)由于序列操作数复杂度低,模型的并行度很高;3)最大路径长度小,能够更好地表示长距离依赖关系;4)模型更容易训练。
现在,如果我们需要训练一个Transformer,那么最好的方法是什么?当然是直接跑已复现的模型了,下面我们将跑一跑PaddlePaddle 实现的Transformer。
处理数据
在Paddle的复现中,百度采用原论文测试的WMT'16 EN-DE 数据集,它是一个中等规模的数据集。这里比较方便的是,百度将数据下载和预处理等过程都放到了gen_data.sh脚本中,包括Tokenize 和 BPE 编码。
在这个项目中,我们既可以通过脚本预处理数据,也可以使用百度预处理好的数据集。首先最简单的方式是直接运行gen_data.sh脚本,运行后可以生成gen_data文件夹,该文件夹主要包含以下文件:
其中 wmt16_ende_data_bpe 文件夹包含最终使用的英德翻译数据。
如果我们从头下载并预处理数据,那么大概需要花1到2个小时完成预处理。为此,百度也提供了预处理好的WMT'16 EN-DE数据集,它包含训练、验证和测试所需要的BPE数据和字典。
其中,BPE策略会把稀疏词拆分为高频的子词,这样既能解决低频词无法训练的问题,也能合理降低词表规模。
如果不采用BPE的策略,要么词表的规模变得很大,从而使训练速度变慢或者显存太小而无法训练;要么一些低频词会当作未登录词处理,从而得不到训练。
预处理数据地址:https://transformer-res.bj.bcebos.com/wmt16_ende_data_bpe_clean.tar.gz
如果我们有其它数据集,例如中英翻译数据,也可以根据特定的格式进行定义。例如用空格分隔不同的token(对于中文而言需要提前用分词工具进行分词),用\t分隔源语言与目标语句对。
训练模型
如果需要执行模型训练,我们也可以直接运行训练主函数train.py。如下简要配置了数据路径以及各种模型参数:
# 显存使用的比例,显存不足可适当增大,最大为1
export FLAGS_fraction_of_gpu_memory_to_use=0.8
# 显存清理的阈值,显存不足可适当减小,最小为0,为负数时不启用
export FLAGS_eager_delete_tensor_gb=0.7
python -u train.py \
--src_vocab_fpath gen_data/wmt16_ende_data_bpe/vocab_all.bpe.32000 \
--trg_vocab_fpath gen_data/wmt16_ende_data_bpe/vocab_all.bpe.32000 \
--special_token '' '' '' \
--train_file_pattern gen_data/wmt16_ende_data_bpe/train.tok.clean.bpe.32000.en-de \
--token_delimiter ' ' \
--use_token_batch True \
--batch_size 1600 \
--sort_type pool \
--pool_size 200000 \
n_head 8 \
n_layer 4 \
d_model 512 \
d_inner_hid 1024 \
prepostprocess_dropout 0.3
此外,如果显存不够大,那么我们可以将Batch Size减小一点。为了快速测试训练效果,我们将模型调得比Base Transformer还小(降低网络的层数、head的数量、以及隐层的大小)。
上面仅展示了小部分的超参设置,更多的配置可以在GitHub项目config.py文件中找到。默认情况下,模型每迭代一万次保存一次模型,每个epoch结束后也会保存一次cheekpoint。此外,在我们训练的过程中,默认每一百次迭代会打印一次模型信息,其中ppl表示的是困惑度,困惑度越小模型效果越好。
在单机训练中,默认使用所有 GPU,可以通过 CUDA_VISIBLE_DEVICES 环境变量来设置使用的 GPU,例如CUDA_VISIBLE_DEVICES=’0,1’,表示使用0号和1号卡进行训练。
预测推断
训练完Transformer后就只可以执行推断了,我们需要运行对应的推断文件infer.py。我们也可以在推断过程中配置超参数,但注意超参需要和前面训练时保持一致。
python -u infer.py \
--src_vocab_fpath gen_data/wmt16_ende_data_bpe/vocab_all.bpe.32000 \
--trg_vocab_fpath gen_data/wmt16_ende_data_bpe/vocab_all.bpe.32000 \
--special_token '' '' '' \
--test_file_pattern gen_data/wmt16_ende_data_bpe/newstest2016.tok.bpe.32000.en-de \
--token_delimiter ' ' \
--batch_size 32 \
model_path trained_models/iter_100000.infer.model \
n_head 8 \
n_layer 4 \
d_model 512 \
d_inner_hid 1024 \
prepostprocess_dropout 0.3
beam_size 5 \
max_out_len 255
相比模型的训练,推断过程需要一些额外的超参数,例如配置model_path指定模型所在目录、设置beam_size 和 max_out_len 来指定 Beam Search 每一步候选词的个数和最大翻译长度。这些超参数也可以在config.py中找到,该文件对这些超参都有注释说明。
执行以上预测命令会将翻译结果直接打出来,每行输出是对应行输入得分最高的翻译。对于使用 BPE 的英德数据,预测出的翻译结果也将是 BPE 表示的数据,所以需要还原成原始数据才能进行正确评估。如下命令可以将predict.txt 内的翻译结果(BPE表示)恢复到predict.tok.txt文件中(tokenize后的数据):
sed -r 's/(@@ )|(@@ ?$)//g' predict.txt > predict.tok.txt
在未使用集成方法的情况下,百度表示 base model 和 big model 在收敛后,测试集的 BLEU 值参考如下:
这两个预训练模型也提供了下载地址:
Base:https://transformer-res.bj.bcebos.com/base_model.tar.gz
Big:https://transformer-res.bj.bcebos.com/big_model.tar.gz
Transformer 怎么改
如果我们想要训练自己的Transformer,那么又该怎样理解并修改PaddlePaddle代码呢?如果我们需要根据自己的数据集和任务改代码,除了前面数据预处理过程,模型结构等模块有时也需要修改。这就需要我们先理解源代码了,PaddlePaddle的源代码基本都是基础的函数或运算,我们很容易理解并使用。
对于PaddlePaddle不熟悉的读者可查阅文档,也可以看看入门教程,了解基本编写模式后就可以看懂整个实现了。
PaddlePaddle官网地址:http://paddlepaddle.org/paddle
如 Seq2Seq 一样,原版 Transformer 也采用了编码器-解码器框架,但它们会使用多个 Multi-Head 注意力、前馈网络、层级归一化和残差连接等。下图从左到右展示了原论文所提出的 Transformer 架构、Multi-Head 注意力和标量点乘注意力。
上图右边的点乘注意力就是标准 Seq2Seq 模型中的注意力机制,中间的Multi-head 注意力其实就是将一个隐层信息切分为多份,并单独计算注意力信息,使得一个词与其它多个目标词的注意力信息计算更精确。最左边为Transformer的整体架构,编码器与解码器由多个类似的模块组成,后面将简要介绍这些模块与对应的Paddle代码。
点乘注意力
注意力机制目前在机器翻译中已经极其流行了,我们可以认为Transformer是一种堆叠多层注意力网络的模型,它采用的是一种名为经缩放的点乘注意力机制。这种注意力机制使用经缩放的点乘作为作为评分函数,从而评估各隐藏状态对当前预测的重要性,如下是该注意力的表达式:
其中 Query 向量与 (Key, Value ) 向量在 NMT 中相当于目标语输入序列与源语输入序列,Query 与 Key 向量的点乘,经过 SoftMax 函数后可得出一组归一化的概率。这些概率相当于给源语输入序列做加权平均,即表示在生成新的隐层信息的时候需要关注哪些词。
在Transformer 的PaddlePaddle实现中,经缩放的点乘注意力是在Multi-head 注意力函数下实现的,如下所示为上述表达式的实现代码:
def scaled_dot_product_attention(q, k, v, attn_bias, d_key, dropout_rate):
"""
Scaled Dot-Product Attention
"""
product = layers.matmul(x=q, y=k, transpose_y=True, alpha=d_key**-0.5)
if attn_bias:
product += attn_bias
weights = layers.softmax(product)
if dropout_rate:
weights = layers.dropout(
weights,
dropout_prob=dropout_rate,
seed=ModelHyperParams.dropout_seed,
is_test=False)
out = layers.matmul(weights, v)
return out
在这个函数中,q、k、v和公式中的一样,attn_bias用于Mask掉选定的特定位置(encode 的self attention 和decoder端的encode attention都是屏蔽掉padding的词;decoder的self attention屏蔽掉当前词后面的词,目的是为了和解码的过程保持一致),因此在给不同输入加权时忽略该位置的输入。
如上product计算的是q和k之间的点乘,且经过根号下d_key(key的维度)的缩放。这里我们可以发现参数alpha可以直接对矩阵乘法的结果进行缩放,默认情况下它为1.0,即不进行缩放。在Transformer原论文中,作者表示如果d_key比较小,那么直接点乘和带缩放的点乘差别不大,所以他们认为高维情况下可能不带缩放的乘积太大而令Softmax函数饱和。
weights表示对输入的不同元素加权,即不同输入对当前预测的重要性,训练中也可以对该权重进行Dropout。最后out表示按照weights对输入V进行加权和,得出来就是当前注意力的运算结果。
Muti-head 注意力
Multi-head 注意力其实就是多个点乘注意力并行地处理并最后将结果拼接在一起。一般而言,我们可以对三个输入矩阵 Q、V、K 分别进行线性变换,然后分别将它们投入 h 个点乘注意力函数并拼接所有的输出结果。
这种注意力允许模型联合关注不同位置的不同表征子空间信息,我们可以理解为在参数不共享的情况下,多次执行点乘注意力。如下所示为Muti-head 注意力的表达式:
其中每一个head都为一个点乘注意力,不同head 的输入是相同Q、K、V的不同线性变换。
总体而言,Paddle的Multi-head 注意力实现分为几个步骤:先为Q、K、V执行线性变换;再变换维度以计算点乘注意力;最后计算各head的注意力输出并合并在一起。
1.线性变换
如前公式所示,Muti-head首先要执行线性变换,从而令不同的head关注不同表征空间的信息。这种线性变换即乘上不同的权重矩阵,且模型在训练过程中可以学习和更新这些权重矩阵。在如下的Paddle代码中,我们可以直接调用全连接层layers.fc() 完成线性变换。
def __compute_qkv(queries, keys, values, n_head, d_key, d_value):
"""
Add linear projection to queries, keys, and values.
"""
q = layers.fc(input=queries,
size=d_key * n_head,
bias_attr=False,
num_flatten_dims=2)
# For encoder-decoder attention in inference, insert the ops and vars
# into global block to use as cache among beam search.
fc_layer = wrap_layer_with_block(
layers.fc, fluid.default_main_program().current_block()
.parent_idx) if cache is not None and static_kv else layers.fc
k = fc_layer(
input=keys,
size=d_key * n_head,
bias_attr=False,
num_flatten_dims=2)
v = fc_layer(
input=values,
size=d_value * n_head,
bias_attr=False,
num_flatten_dims=2)
return q, k, v
直接调用全连接层会自动为输入创建权重,且我们要求不使用偏置项和激活函数。这里比较方便的是,Paddle 的layers.fc() 函数可以接受高维输入,省略了手动展平输入向量的操作。因此这里有num_flatten_dims=2,即将前两个维度展平为一个维度,第三个维度保持不变。
例如对于输入张量q而言,线性变换的输出维度应该是[batch_size,max_sequence_length,d_key * n_head],最后一个维度即n_head个d_key维的Query向量。每一个d_key维的向量都会馈送到不同的head,并最后拼接起来。
2.维度变换
为了进行Multi-Head的运算,我们需要将线性变换的结果进行reshape和转置操作。现在我们将这几个张量的最后一个维度分割成不同的head,并做转置以便于后续运算。
具体而言,输入张量q、k和v的维度信息为[bs, max_sequence_length, n_head * hidden_dim],我们希望把它们转换为[bs, n_head, max_sequence_length, hidden_dim]。
def __split_heads_qkv(queries, keys, values, n_head, d_key, d_value):
reshaped_q = layers.reshape(
x=queries, shape=[0, 0, n_head, d_key], inplace=True)
q = layers.transpose(x=reshaped_q, perm=[0, 2, 1, 3])
reshape_layer = wrap_layer_with_block(
layers.reshape,
fluid.default_main_program().current_block()
.parent_idx) if cache is not None and static_kv else layers.reshape
transpose_layer = wrap_layer_with_block(
layers.transpose,
fluid.default_main_program().current_block().
parent_idx) if cache is not None and static_kv else layers.transpose
reshaped_k = reshape_layer(
x=keys, shape=[0, 0, n_head, d_key], inplace=True)
k = transpose_layer(x=reshaped_k, perm=[0, 2, 1, 3])
reshaped_v = reshape_layer(
x=values, shape=[0, 0, n_head, d_value], inplace=True)
v = transpose_layer(x=reshaped_v, perm=[0, 2, 1, 3])
return q, k, v
如上使用layers.reshape() 和 layers.transpose() 函数完成分割与转置。其中 layers.reshape() 在接收输入张量后会按照形状[0, 0, n_head, d_key]进行转换,其中0表示从输入张量对应维数复制出来。此外,因为inplace设置为True,那么reshape操作就不会进行数据的复制,从而提升运算效率。
后面的转置就比较简单了,只需要按照维度索引将第“1”个维度和第“2”个维度交换就行了。此外为了更快地执行推断,Paddle实现代码还做了非常多的优化,例如这部分后续会对推断过程的缓存和处理流程进行优化。
3.合并
前面已经介绍过点乘注意力了,那么上面对q、k、v执行维度变换后就可直接传入点乘注意力函数,并计算出head_1、head_2等注意力结果。现在最后一步只需要将这些head拼接起来就完成了整个过程,也就完成了上面Multi-head 注意力的计算式。
因为每一个批量、head和时间步都会计算得出一个注意力向量,因此总体上注意力计算结果的维度信息为[bs, n_head, max_sequence_length, hidden_dim]。如果要将不同的head拼接在一起,即将head这个维度合并到hidden_dim中去,因此合并的过程和前面维度变换的过程正好相反。
def __combine_heads(x):
"""
Transpose and then reshape the last two dimensions of inpunt tensor x
so that it becomes one dimension, which is reverse to __split_heads.
"""
if len(x.shape) != 4:
raise ValueError("Input(x) should be a 4-D Tensor.")
trans_x = layers.transpose(x, perm=[0, 2, 1, 3])
# The value 0 in shape attr means copying the corresponding dimension
# size of the input as the output dimension size.
return layers.reshape(
x=trans_x,
shape=[0, 0, trans_x.shape[2] * trans_x.shape[3]],
inplace=True)
如上合并过程会先检验维度信息,然后先转置再reshape合并不同的head。注意在原论文中,合并不同的head后,还需要再做一个线性变换,这个线性变换的结果就是Muti-head 注意力的输出了。
最后,我们再将上面的四部分串起来就是Transformer最核心的Multi-head 注意力。理解了各个模块后,下面串起来就能愉快地看懂整个过程了:
q, k, v = __compute_qkv(queries, keys, values, n_head, d_key, d_value)
q, k, v = __split_heads_qkv(q, k, v, n_head, d_key, d_value)
ctx_multiheads = scaled_dot_product_attention(q, k, v, attn_bias, d_model,
dropout_rate)
out = __combine_heads(ctx_multiheads)
# Project back to the model size.
proj_out = layers.fc(input=out,
size=d_model,
bias_attr=False,
num_flatten_dims=2)
当然,如果编码器和解码器输入到Multi-head 注意力的q与(k、v)是相同的,那么它又可称为自注意力网络。
前馈网络
对于每一个编码器和解码器模块,除了残差连接与层级归一化外,重要的就是堆叠Muti-head 注意力和前馈网络(FFN)。前面我们已经解决了Multi-head 注意力,现在需要理解主位置的前馈网络了。直观而言,FFN的作用是整合Multi-head 注意力生成的上下文向量,因此能更好地利用从源语句子和目标语句子抽取的深度信息。
如下所示在原论文中,前馈网络的计算过程可以表达为以下方程:
前馈网络的结构很简单,一个ReLU激活函数加两次线性变换就完成了。如下基本上只需要调用Paddle的layers.fc() 就可以了:
def positionwise_feed_forward(x, d_inner_hid, d_hid, dropout_rate):
hidden = layers.fc(input=x,
size=d_inner_hid,
num_flatten_dims=2,
act="relu")
if dropout_rate:
hidden = layers.dropout(
hidden,
dropout_prob=dropout_rate,
seed=ModelHyperParams.dropout_seed,
is_test=False)
out = layers.fc(input=hidden, size=d_hid, num_flatten_dims=2)
return out
现在基本上核心操作就定义完了,后面还有更多模块与架构,例如怎样利用核心操作搭建编码器模块与解码器模块、如何搭建整体Transformer模型等,读者可继续阅读原项目中的简洁代码。整体而言,包括上面代码在内,千行代码就可以完全弄懂Transformer,Paddle的Transformer复现值得我们仔细读一读。
此外,在这千行模型代码中,为了给训练和推断加速,还有很多特殊技巧。例如在Decoder中加入对Encoder计算结果的缓存等。加上这些技巧,PaddlePaddle的实现才能在大Batch Size下实现4倍推断加速。
因为本身PaddlePaddle代码就已经非常精炼,通过它们也很容易理解这些技巧。基本上看函数名称就能知道大致的作用,再结合文档使用就能完全读懂了。
最后,除了模型架构,整个项目还会有其它组成部分,例如训练、推断、数据预处理等等。这些代码同样非常简洁,我们可以根据实际需求阅读并修改它们。总体而言, PaddlePaddle 的Transformer 实现确实非常适合理解与修改。想要跑一跑神经机器翻译的同学,PaddlePaddle的Transformer实现确实值得推荐。
特别提醒:本网内容转载自其他媒体,目的在于传递更多信息,并不代表本网赞同其观点。其原创性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,并请自行核实相关内容。本站不承担此类作品侵权行为的直接责任及连带责任。如若本网有任何内容侵犯您的权益,请及时联系我们,本站将会在24小时内处理完毕。