tensorflow-nmt(seq2seq)模型(官方)

聊天机器人,机器翻译,自动文摘,智能问答等众多自然语言处理任务中都可能用到seq2seq模型,google在著名的neural machine translation中也使用过这个结构的模型(当然,现在因为效率等原因,可能不少应用项目迁移到transformer结构下了),google在tensorflow的官方案例中给了一个手把手训练seq2seq神经翻译系统的github项目,下面就是使用这个项目代码实现一个聊天机器人的应用。

说明

google的这个教程使用高版本tensorflow(TensorFlow 1.2+)的 seq2seq API完成,该API使seq2seq模型的构建过程干净、简单、易读,主要包括以下内容:

  • 使用 tf.data 中最新输入的管道对动态调整的输入序列进行预处理。
  • 使用批量填充和序列长度 bucketing,提高训练速度和推理速度。
  • 使用通用结构和训练时间表训练 seq2seq 模型,包括多种注意力机制和固定抽样。
  • 使用 in-graph 集束搜索在 seq2seq 模型中进行推理。
  • 优化 seq2seq 模型,以实现在多 GPU 设置中的模型训练。

引言

序列到序列(seq2seq)模型(Sutskever et al., 2014, Cho et al., 2014)在机器翻译、语音识别和文本摘要等任务上取得了巨大的成功。这里的教程内容致力于帮助读者全面掌握 seq2seq 模型,并且展示了如何从头开始构建一个强大的 seq2seq 模型。以下的讲解会注重神经机器翻译(NMT)任务,神经机器翻译是 seq2seq 模型很好的试验台,并且已经获得了广泛的成功。我们使用的代码是极其轻量、高质量、可投入生产并且结合了最新研究思路的实现。我们通过以下方式实现这一目标:

  1. 使用最新的解码器/attention wrapper API、TensorFlow 高版本数据迭代器。
  2. 结合了我们在构建循环型和 seq2seq 型模型的专业知识。
  3. 提供了可构建最好 NMT 模型的技巧,同时还复现了谷歌的 NMT(GNMT)系统。

我们相信提供所有人都很容易复制的基准是非常重要的。因此,我们基于以下公开的数据集提供了全部的试验结果和预训练模型:

  1. 小规模数据集:TED 演讲的英语-越南语平行语料库(133K 个句子对),该数据集由 IWSLT Evaluation Campaign 提供。
  2. 大规模数据集:德语-英语平行语料库(4.5M 个句子对),该数据集由 WMT Evaluation Campaign 提供。

我们首先需要了解用于 NMT 任务的 seq2seq 模型的基本知识,并需要理解如何构建和训练一个 vanilla NMT 模型。第二部分将更进一步详细地解释如何构建带注意力机制的强大神经机器翻译模型。然后我们会讨论构建更好神经机器翻译模型(翻译速度和质量)可能的技巧,例如 TensorFlow 最好的实践方法(batching, bucketing)、双向循环神经网络和集束搜索(beam search)等。

基础

关于神经机器翻译

以词组为基础的传统翻译系统将源语言句子拆分成多个词块,然后进行词对词的翻译。这使得翻译输出结果流畅性大打折扣,远远不如人类译文。我们会通读整个源语言句子、了解句子含义,然后输出翻译结果。神经机器翻译(NMT)竟然可以模仿人类的翻译过程!
img

图1. 编码器-解码器结构——神经机器翻译的通用方法实例

具体来说,神经机器翻译系统首先使用编码器读取源语言句子,构建一个「上下文」向量(context vector),即代表句义的数字化向量;然后使用解码器处理该内容,并输出翻译结果,如图1所示。这就是我们通常所说的编码器-解码器结构。神经机器翻译用这种方法解决以词组为基础的传统翻译系统遇到的翻译问题:神经机器翻译能够捕捉语言中的长距离依赖结构,如词性一致、句法结构等,然后输出流利度更高的翻译结果,正如谷歌神经机器翻译系统已经做到的那样。

NMT 模型在具体的结构中会发生变化。对于序列数据而言,最好的选择是循环神经网络(RNN),这也被大多数 NMT 模型采用。通常情况下,编码器和解码器都可使用循环神经网络。但是,循环神经网络模型有多种选择:

  • (a)方向性(directionality),单向或双向;
  • (b)深度,单层或多层;
  • (c)类型,通常是 vanilla RNN、长短期记忆(Long Short-term Memory,LSTM),或门控循环单元(gated recurrent unit,GRU)。

可打开该网址https://colah.github.io/posts/2015-08-Understanding-LSTMs/, 复习一下RNN 和 LSTM 的更多信息。

这个教程中,我们将以单向的深度多层 RNN(deep multi-layer RNN)为例,它使用 LSTM 作为循环单元。模型实例如图 2 所示。我们在该实例中构建了一个模型,将源语言句子「I am a student」翻译成目标语言「Je suis étudiant」。该 NMT 模型包括两个循环神经网络:编码器 RNN,在不预测的情况下将输入的源语言单词进行编码;解码器,在预测下一个单词的条件下处理目标句子。

图2. 神经机器翻译——一个深度循环结构实例

上图将源语言句子「I am a student」翻译成目标语言句子「Je suis étudiant」。此处,「<s>」代表解码过程开始,「</s>」代表解码过程结束。

训练-构建一个NMT系统

我们首先需要了解构建一个NMT模型具体代码的核心,这一部分是指 model.py 文件。

在网络的底层,编码器和解码器 RNN 接收到以下输入:首先是原句子,然后是从编码到解码模式的过渡边界符号「<s>」,最后是目标语句。对于训练来说,我们将为系统提供以下张量,它们是以时间为主(time-major)的格式,并包括了单词索引:

  • encoder_inputs [max_encoder_time, batch_size]:源输入词。
  • decoder_inputs [max_decoder_time, batch_size]:目标输入词。
  • decoder_outputs [max_decoder_time, batch_size]:目标输出词,这些是 decoder_inputs 按一个时间步向左移动,并且在右边有句子结束符。

为了更高的效率,我们一次用多个句子(batch_size)进行训练。测试略有不同,我们会在后面讨论。

嵌入(embedding)

给定单词的分类属性,模型首先必须查找词来源和目标嵌入以检索相应的词表征。为了令该嵌入层能够运行,我们首先需要为每一种语言选定一个词汇表。通常,选定词汇表大小 V,那么频率最高的 V 个词将视为唯一的。而所有其他的词将转换并打上「unknown」标志,因此所有的词将有相同的嵌入。我们通常在训练期间嵌入权重,并且每种语言都有一套。

1
2
3
4
5
6
7
8
# Embedding
embedding_encoder = variable_scope.get_variable(
"embedding_encoder", [src_vocab_size, embedding_size], ...)
# Look up embedding:
# encoder_inputs: [max_time, batch_size]
# encoder_emp_inp: [max_time, batch_size, embedding_size]
encoder_emb_inp = embedding_ops.embedding_lookup(
embedding_encoder, encoder_inputs)

我们同样可以构建 embedding_decoder 和 decoder_emb_inp。注意我们可以选择预训练的词表征如 word2vec 或 Glove vectors 初始化嵌入权重。通常给定大量的训练数据,我们能从头学习这些嵌入权重。

编码器(encoder)

一旦可以检索到,词嵌入就能作为输入馈送到主神经网络中。该网络有两个多层循环神经网络组成,一个是原语言的编码器,另一个是目标语言的解码器。这两个 RNN 原则上可以共享相同的权重,然而在实践中,我们通常使用两组不同的循环神经网络参数(这些模型在拟合大型训练数据集上做得更好)。解码器 RNN 使用零向量作为它的初始状态,并且可以使用如下代码构建:

1
2
3
4
5
6
7
8
# Build RNN cell
encoder_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units)
# Run Dynamic RNN
# encoder_outpus: [max_time, batch_size, num_units]
# encoder_state: [batch_size, num_units]
encoder_outputs, encoder_state = tf.nn.dynamic_rnn(
encoder_cell, encoder_emb_inp,
sequence_length=source_seqence_length, time_major=True)

注意语句有不同的长度以避免浪费计算力,因此我们会通过 source_seqence_length 告诉 dynamic_rnn 精确的句子长度。因为我们的输入是以时间为主(time major)的,我们需要设定 time_major=True。现在我们暂时只需要构建单层 LSTM、encoder_cell。我们后面会详细描述怎样构建多层 LSTM、添加 dropout 并使用注意力机制。

解码器(decoder)

decoder 也需要访问源信息,一种简单的方式是用编码器最后的隐藏态 encoder_state 对其进行初始化。在图 2 中,我们将源词「student」中的隐藏态传递到了解码器。

1
2
# Build RNN cell
decoder_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units)
1
2
3
4
5
6
7
8
# Helper
helper = tf.contrib.seq2seq.TrainingHelper(
decoder_emb_inp, decoder_lengths, time_major=True)# Decoder
decoder = tf.contrib.seq2seq.BasicDecoder(
decoder_cell, helper, encoder_state,
output_layer=projection_layer)# Dynamic decoding
outputs, _ = tf.contrib.seq2seq.dynamic_decode(decoder, ...)
logits = outputs.rnn_output

此处代码的核心是 BasicDecoder、获取 decoder_cell(类似于 encoder_cell) 的 decoder、helper 以及之前作为输入的 encoder_state。

通过分离decoders和helpers,我们能重复使用不同的代码库,例如TrainingHelper可由GreedyEmbeddingHelper进行替换,来做贪婪解码。

最后,我们从未提到过的 projection_layer 是一个密集矩阵,将顶部的隐藏态转变为维度 V 的逻辑向量。我们在图 2 的上部展示了此过程。(全连接层,映射为词表大小的logit向量作为输出)

1
2
projection_layer = layers_core.Dense(
tgt_vocab_size, use_bias=False)
损失构建

给出以上的 logits,可计算训练损失:

1
2
3
4
crossent = tf.nn.sparse_softmax_cross_entropy_with_logits(
labels=decoder_outputs, logits=logits)
train_loss = (tf.reduce_sum(crossent * target_weights) /
batch_size)

以上代码中,target_weights 是一个与 decoder_outputs 大小一样的 0-1 矩阵。该矩阵将目标序列长度以外的其他位置填充为标量值 0。

我们需要指出来的是,训练损失可以由 batch_size 分割,因此我们的超参数 batch_size 是「不变量」。也有些人将训练损失按照 batch_size * num_time_steps 分割,这样可以减少短句所造成的误差。更巧妙的,我们的超参数(应用于前面的方法)不能用于后面的方法。例如,如果两种方法都是用学习率为 1.0 的随机梯度下降,后面的方法将更有效地利用一个较小的学习率,即 1 / num_time_steps。

梯度计算和优化器优化

现在是时候定义我们的 NMT 模型的前向传播了。计算反向传播只需要写几行代码:

1
2
3
4
5
# Calculate and clip gradients
parameters = tf.trainable_variables()
gradients = tf.gradients(train_loss, params)
clipped_gradients, _ = tf.clip_by_global_norm(
gradients, max_gradient_norm)

训练 RNN 的一个重要步骤是梯度截断(gradient clipping)。这里,我们使用全局范数进行截断操作。最大值 max_gradient_norm 通常设置为 5 或 1。最后一步是选择优化器。Adam 优化器是最常见的选择。我们还要选择一个学习率,learning_rate 的值通常在 0.0001 和 0.001 之间,且可设置为随着训练进程逐渐减小。

1
2
3
4
# Optimization
optimizer = tf.train.AdamOptimizer(learning_rate)
update_step = optimizer.apply_gradients(
zip(clipped_gradients, params))

在我们的实验中,我们使用标准的随机梯度下降(tf.train.GradientDescentOptimizer),并采用了递减的学习率方案,因此也就有更好的性能。

开始训练NMT模型

让我们开始训练第一个 NMT 模型,将越南语翻译为英语。代码的入口是nmt.py。

我们将使用小规模的 Ted 演讲并行语料库(133k 的训练样本)进行训练。所有的数据都可从以下链接找到:https://nlp.stanford.edu/projects/nmt/。

我们将使用 tst2012 作为开发数据集,tst 2013 作为测试数据集。运行以下命令行下载数据训练 NMT 模型:

1
nmt/scripts/download_iwslt15.sh /tmp/nmt_data

运行以下命令行开始训练:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mkdir /tmp/nmt_model
python -m nmt.nmt \
--src=vi --tgt=en \
--vocab_prefix=/tmp/nmt_data/vocab \
--train_prefix=/tmp/nmt_data/train \
--dev_prefix=/tmp/nmt_data/tst2012 \
--test_prefix=/tmp/nmt_data/tst2013 \
--out_dir=/tmp/nmt_model \
--num_train_steps=12000 \
--steps_per_stats=100 \
--num_layers=2 \
--num_units=128 \
--dropout=0.2 \
--metrics=bleu

以上命令行训练一个 2 层的 LSTM seq2seq 模型,带有 128-dim 的隐藏单元和 12 个 epochs 的嵌入。我们使用 0.2(或然率为 0.8)的 dropout 值。如果没误差,在我们训练中随着降低混淆度,我们应该能看到类似于以下的 logs。

1
2
3
4
5
6
7
8
9
10
11
12
# First evaluation, global step 0
eval dev: perplexity 17193.66
eval test: perplexity 17193.27
# Start epoch 0, step 0, lr 1, Tue Apr 25 23:17:41 2017
sample train data:
src_reverse: </s> </s> Điều đó , dĩ nhiên , là câu chuyện trích ra từ học thuyết của Karl Marx .
ref: That , of course , was the <unk> distilled from the theories of Karl Marx . </s> </s> </s>
epoch 0 step 100 lr 1 step-time 0.89s wps 5.78K ppl 1568.62 bleu 0.00
epoch 0 step 200 lr 1 step-time 0.94s wps 5.91K ppl 524.11 bleu 0.00
epoch 0 step 300 lr 1 step-time 0.96s wps 5.80K ppl 340.05 bleu 0.00
epoch 0 step 400 lr 1 step-time 1.02s wps 6.06K ppl 277.61 bleu 0.00
epoch 0 step 500 lr 1 step-time 0.95s wps 5.89K ppl 205.85 bleu 0.00

更多细节,请查看:train.py。

我们可以使用 Tensorboard 在训练过程中查看模型的summary:

1
tensorboard --port 22222 --logdir /tmp/nmt_model/

通过以下简单的变化,就能逆向完成英语到越南语的翻译。

1
--src=en --tgt=vi
预测(推理)与生成翻译结果

当你训练你的 NMT 模型时(并且一旦你已经训练了模型),可以在给定之前不可见的源语句的情况下获得翻译。这一过程被称作推理。训练与推理之间有一个明确的区分(测试):在推理时,我们只访问源语句,即 encoder_inputs。解码的方式有很多种,包括 greedy 解码、采样解码和束搜索解码(beam-search)。下面我们讨论一下 greedy 解码策略。

其想法简单,我们将在图 3 中作说明:

  1. 在训练获取 encoder_state 的过程中,我们依然以相同方式编码源语句,并且 encoder_state 用于初始化解码器。
  2. 一旦解码器接收到开始符<s>(在我们的代码中指 tgt_sos_id),就开始解码处理(翻译)。
  3. 最大的单词,其 id 与最大的 logit 值相关联,正如被发出的词(这是 greedy 行为)。例如在图 3 中,单词 moi 在第一个解码步中具有最高的翻译概率。接着我们把这一单词作为输入馈送至下一个时间步。
  4. 这一过程会持续到这句话的终止符「</s>」,然后输出(在我们的代码中是 tgt_eos_id)。
    图 3. Greedy 解码实例

推理与训练的区别在于步骤 3。推理不总是馈送作为输入的正确目标词,而是使用被模型预测的单词。下面是实现 greedy 解码的代码。它与训练解码器非常相似。

1
2
3
4
5
6
7
8
9
10
11
# Helper
helper = tf.contrib.seq2seq.GreedyEmbeddingHelper(
embedding_decoder,
tf.fill([batch_size], tgt_sos_id), tgt_eos_id)
# Decoder
decoder = tf.contrib.seq2seq.BasicDecoder(
decoder_cell, helper, encoder_state,
output_layer=projection_layer)# Dynamic decoding
outputs, _ = tf.contrib.seq2seq.dynamic_decode(
decoder, maximum_iterations=maximum_iterations)
translations = outputs.sample_id

我们在本文中使用了 GreedyEmbeddingHelper 而不是 TrainingHelper。由于无法提前知道目标语句的长度,我们使用 maximum_iterations 限制翻译的长度。一个启发是解码最多两倍的源语句长度。

1
maximum_iterations = tf.round(tf.reduce_max(source_sequence_length) * 2)

我们已经训练了一个模型,现在可以创建一个推理文件并翻译一些语句:

1
2
3
4
5
6
7
8
cat > /tmp/my_infer_file.vi# (copy and paste some sentences from /tmp/nmt_data/tst2013.vi)

python -m nmt.nmt \
--model_dir=/tmp/nmt_model \
--inference_input_file=/tmp/my_infer_file.vi \
--inference_output_file=/tmp/nmt_model/output_infer

cat /tmp/nmt_model/output_infer # To view the inference as output

注意上述指令也可在模型被训练时运行,只要存在一个训练检查点。详见 inference.py。

模型优化

在训练了一些最基本的序列到序列模型之后,我们现在更进一步。为了打造当前最优的神经机器翻译系统,我们需要更多的秘诀:注意力机制。该机制由 Bahdanau 等人在 2015 年首次提出(https://arxiv.org/abs/1409.0473 ),稍后 Luong 等人和其他人完善了它,其核心思想是当我们翻译时通过「注意」相关的源内容,建立直接的短连接。注意力机制的一个很好副产品是源语句和目标语句之间的一个易于可视化的对齐矩阵(如图 4 所示)。

图 4. 注意力可视化——源语句与目标语句之间对齐的实例。图片来自 2015 年 Bahdanau 等人的论文。

请记住在 vanilla 序列到序列模型中,当开始编码处理时,我们把最后的源状态从编码器传递到解码器。这对短、中长度的语句效果很好;对于长句子,单一固定大小的隐状态成为了信息瓶颈。注意力机制没有摈弃源 RNN 中计算的所有隐状态,而是提出了允许解码器窥探它们的方法(把它们看作是源信息的动态存储)。如此,注意力机制提升了长句的翻译质量。现在,注意力机制实至名归,已成功应用于其他诸多任务(比如语音识别)。

注意力机制背景

我们现在描述一下注意力机制的实例(Luong et al., 2015),它已经被应用到几个最新型的系统当中了,包括开源工具,比如 OpenNMT(http://opennmt.net/about/ )和此教程中的 TF seq2seq API。我们还将会提供注意力机制相关变体的内容。


图 5. 注意力机制——基于注意力的 NMT 系统(Luong et al., 2015 中有具体描述)。

我们重点详解注意力计算过程中的第一步。为了更加清晰,我们没有展示图(2)中的嵌入层和投影层。

如图 5 所示,注意力计算发生在解码步骤中的每一步。它包含下列步骤:

  1. 当前目标隐蔽状态和所有源状态(source state)进行比较,以导出权重(weight),见图 4。
  2. 基于注意力权重,我们计算了一个背景向量(context vector),作为源状态的平均权值。
  3. 将背景向量与当前目标隐蔽态进行结合以生成最终的注意力向量。
  4. 此注意力向量将作为下一时序步骤的输入。前三个步骤可以由下列公式总结:


这里,函数 score 用于将目标隐蔽状态 ht 和每一个源状态 hs 进行比较,结果会被标准化成生成式注意力权重(一个源位置的分布)。其实有很多种关于评分函数(scoring function)的选择;比较流行的评分函数包括公式(4)中给出的乘法与加法形式。一旦被计算,注意力向量 at 就会用于推导 softmax logit 和损失。这与 vanilla seq2seq 模型顶层的目标隐蔽态相似。函数 f 也可以利用其它形式。


注意力机制的多种实现方法可由以下链接获得:
https://github.com/tensorflow/tensorflow/blob/master/tensorflow/contrib/seq2seq/python/ops/attention_wrapper.py

注意力机制中有什么相关注意事项呢?

上述公式表明注意力机制有很多种变体。这些变体依赖于评分函数(scoring function)和注意力函数(attention function)的形式,也依赖于前一状态 ht-1,而不依赖于开始建议的评分函数 ht(Bahdanau et al., 2015)。实际上我们发现的只有一些选择上的注意事项。

  • 一,注意力的基本形式,例如,目标和源之间的直接联系需要被呈现。
  • 二,把注意力向量输入给下一时间步骤,以把之前的注意力决策告知给网络(Luong et al., 2015)。

评分函数的选择经常可以造成不同的性能表现。

Attention Wrapper API

在我们的 Attention Wrapper API 的实现中,借鉴了 Weston et al., 2015 在 onmemory network 工作中的术语。相比于拥有可读、可写的记忆,此教程中的 attention 机制仅是可读的记忆。特别是对隐藏态(或者隐藏态的变体,例如 $W\overline{h}_s$ in Luong’s scoring style or $W_2\overline{h}_s$ ) 的设定,被认为是「记忆」。在每个时间步下,我们使用现有的目标隐藏态作为「query」决定读取哪一部分记忆。通常情况下,query 需要与单个记忆条相对应的 keys 进行对比。在上面对注意机制的演示中,我们偶然使用一套源隐藏态(或者其变体,例如$W_1h_t$)作为「key」。你们可以从这种记忆网络术语中获取灵感,找到其他形式的 attention。

由于 attention wrapper,就不再需要扩展我们带有 attention 的 vanilla seq2seq 代码。这部分文件为 attention_model.py。

首先,我们需要定义一种注意机制,例如采用 Luong et al., 2015 的研究。

1
2
3
4
5
6
# attention_states: [batch_size, max_time, num_units]
attention_states = tf.transpose(encoder_outputs, [1, 0, 2])
# Create an attention mechanism
attention_mechanism = tf.contrib.seq2seq.LuongAttention(
num_units, attention_states,
memory_sequence_length=source_sequence_length)

在之前的 Encoder 部分,encoder_outputs 是一套顶层的掩藏态源,形式为 [max_time, batch_size, num_units](因为我们使用 dynamic_rnn with time_major 设定)。在注意机制上,我们需要保证通过的「memory」是批次为主的,所以需要调换 attention_states。我们通过 source_sequence_length 保证注意机制的权重有适当的规范化(只在 non-padding 的位置)。定义完注意机制之后,我们使用 AttentionWrapper 来包裹解码单元。

1
2
3
decoder_cell = tf.contrib.seq2seq.AttentionWrapper(
decoder_cell, attention_mechanism,
attention_layer_size=num_units)

剩下的代码基本和编码器一转样 (https://github.com/tensorflow/nmt#decoder)!

上手—打造一个基于注意力的 NMT 模型

为了使注意力发挥作用,我们需要用到 luong、scaled_luong、bahdanau 或 normed_bahdanau 其中的一个作为训练期间注意力标志(attention flag)的值。该标志指定了我们将要使用的注意力机制。除此之外,我们需要为注意力模型创建一个新目录,因此无需重新使用之前训练的基本 NMT 模型。

运行以下指令开始训练:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mkdir /tmp/nmt_attention_model

python -m nmt.nmt \
--attention=scaled_luong \
--src=vi --tgt=en \
--vocab_prefix=/tmp/nmt_data/vocab \
--train_prefix=/tmp/nmt_data/train \
--dev_prefix=/tmp/nmt_data/tst2012 \
--test_prefix=/tmp/nmt_data/tst2013 \
--out_dir=/tmp/nmt_attention_model \
--num_train_steps=12000 \
--steps_per_stats=100 \
--num_layers=2 \
--num_units=128 \
--dropout=0.2 \
--metrics=bleu

训练之后,我们可以使用带有新 model_dir 的相同推理指令进行推理:

1
2
3
4
python -m nmt.nmt \
--model_dir=/tmp/nmt_attention_model \
--inference_input_file=/tmp/my_infer_file.vi \
--inference_output_file=/tmp/nmt_attention_model/output_infer

技巧和注意点

构建训练、验证和测试图

当我们使用 TensorFlow 单间一个机器学习模型的时候,最好构建三个分开的 graph:

  • 训练图,包括:
    • Batches, buckets, 以及来自文件或外部输入的数据集的部分采样数据;
    • 前向和反向传播的 ops;
    • 创建 optimizer,以及添加训练 op;
  • 验证图,包括:
    • Batches, buckets, 以及来自文件或外部输入的数据集输入数据
    • 训练时的前向传播 op,以及要添加的 evaluation ops
  • 预测图,包括:
    • 不需要批处理的输入数据
    • 不需要 subsample 和 bucket 输入数据
    • 从 placeholders 读取输入数据(数据可以通过 feed_dict 被图读取,或者使用 C++ TensorFlow serving binary)
    • 模型前向传播的部分 ops,以及一些可能 session.run 函数调用的所需要的额外的为存储状态(state)所需要的 inputs/outputs。

现在比较棘手的一点是 ,如何在一个机器上让三个图共享这些 variables,这可以通过为每个图创建不同的 session 来解决。训练过程的session 周期性的保存 checkpoints,然后 eval session 和 inference session 就可以读取checkpoints。下面的例子展示了这两种方法的不同。

前一种方法:三个模型都在一个图里,并且共享一个 session。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
with tf.variable_scope('root'):
train_inputs = tf.placeholder()
train_op, loss = BuildTrainModel(train_inputs)
initializer = tf.global_variables_initializer()

with tf.variable_scope('root', reuse=True):
eval_inputs = tf.placeholder()
eval_loss = BuildEvalModel(eval_inputs)

with tf.variable_scope('root', reuse=True):
infer_inputs = tf.placeholder()
inference_output = BuildInferenceModel(infer_inputs)

sess = tf.Session()

sess.run(initializer)

for i in itertools.count():
train_input_data = ...
sess.run([loss, train_op], feed_dict={train_inputs: train_input_data})

if i % EVAL_STEPS == 0:
while data_to_eval:
eval_input_data = ...
sess.run([eval_loss], feed_dict={eval_inputs: eval_input_data})

if i % INFER_STEPS == 0:
sess.run(inference_output, feed_dict={infer_inputs: infer_input_data})
后一种方法:三个模型在三个图里,三个 sessions 共享同样的 variables。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
train_graph = tf.Graph()
eval_graph = tf.Graph()
infer_graph = tf.Graph()

with train_graph.as_default():
train_iterator = ...
train_model = BuildTrainModel(train_iterator)
initializer = tf.global_variables_initializer()

with eval_graph.as_default():
eval_iterator = ...
eval_model = BuildEvalModel(eval_iterator)

with infer_graph.as_default():
infer_iterator, infer_inputs = ...
infer_model = BuildInferenceModel(infer_iterator)

checkpoints_path = "/tmp/model/checkpoints"

train_sess = tf.Session(graph=train_graph)
eval_sess = tf.Session(graph=eval_graph)
infer_sess = tf.Session(graph=infer_graph)

train_sess.run(initializer)
train_sess.run(train_iterator.initializer)

for i in itertools.count():

train_model.train(train_sess)

if i % EVAL_STEPS == 0:
checkpoint_path = train_model.saver.save(train_sess, checkpoints_path, global_step=i)
eval_model.saver.restore(eval_sess, checkpoint_path)
eval_sess.run(eval_iterator.initializer)
while data_to_eval:
eval_model.eval(eval_sess)

if i % INFER_STEPS == 0:
checkpoint_path = train_model.saver.save(train_sess, checkpoints_path, global_step=i)
infer_model.saver.restore(infer_sess, checkpoint_path)
infer_sess.run(infer_iterator.initializer, feed_dict={infer_inputs: infer_input_data})
while data_to_infer:
infer_model.infer(infer_sess)

注意后一种方法是如何被转换为分布式版本的。

后种方法与前种方法的另一个不同在于,后者不用在 session.sun 调用时通过 feed_dicts 喂给数据,而是使用自带状态的 iterator 对象(stateful iterator objects)。不论在单机还是分布式集群上,这些 iterators 可以让输入管道(input pipeline)变得更容易。在下一小节,我们将使用新的数据输入管道(input data pipeline)。

数据输入管道(Data Input Pipeline)

在 TensorFlow 1.2版本之前,用户有两种把数据喂给 TensorFlow training 和 eval pipelines的方法:

  1. 在每次训练调用 session.run 时,通过 feed_dict 直接喂给数据;
  2. 使用 tf.train(例如 tf.train.batch)和 tf.contrib.train 中的队列机制(queueing machanisms);
  3. 使用来自 helper 层级框架比如 tf.contrib.learn 或 tf.contrib.slim 的 helpers (这种方法是使用更高效的方法利用第二种方法)。

第一种方法对不熟悉 TensorFlow 或需要做一些外部的数据修改(比如他们自己的 minibatch queueing)的用户来说更简单,这种方法只需用简单的 Python 语法就可实现。第二种和第三种方法更标准但也不那么灵活,他们需要开启多个 Python 线程(queue runners)。更重要的是,如果操作不当会导致死锁或难以查明的错误。尽管如此,队列的方法仍要比 feed_dict 的方法高效很多,并且也是单机和分布式训练的标准。

从TensorFlow 1.2开始,有一种新的数据读取的方法可以使用: dataset iterators,其在 tf.data模块。Data iterators 非常灵活,易于使用和操作,并且利用 TensorFlow C++ runtime 实现了高效和多线程。

我们可以使用一个 batch data Tensor,一个文件名,或者包含多个文件名的 Tensor 来创建一个 dataset。下面是一些例子:

1
2
3
4
5
6
7
8
9
10
11
# Training dataset consists of multiple files.
train_dataset = tf.data.TextLineDataset(train_files)

# Evaluation dataset uses a single file, but we may
# point to a different file for each evaluation round.
eval_file = tf.placeholder(tf.string, shape=())
eval_dataset = tf.data.TextLineDataset(eval_file)

# For inference, feed input data to the dataset directly via feed_dict.
infer_batch = tf.placeholder(tf.string, shape=(num_infer_examples,))
infer_dataset = tf.data.Dataset.from_tensor_slices(infer_batch)

所有的数据都可以完成像数据预处理一样的处理方式,包括数据的 reading 和 cleaning,bucketing(在 training 和 eval 的时候),filtering 以及 batching。

把每个句子转换为单词串的向量(vectors of word strings),那我们可以使用 dataset 的 map transformation:

1
dataset = dataset.map(lambda string: tf.string_split([string]).values)

我们也可以把每个句子向量转换为包含向量与其动态长度的元组:

1
dataset = dataset.map(lambda words: (words, tf.size(words))

最后,我们可以对每个句子应用 vocabulary lookup。给定一个 lookup 的 table,此 map 函数可以把元组的第一个元素从串向量转换为数字向量。(译者注:不好翻译,原文是:Finally, we can perform a vocabulary lookup on each sentence. Given a lookup table object table, this map converts the first tuple elements from a vector of strings to a vector of integers.)

1
dataset = dataset.map(lambda words, size: (table.lookup(words), size))

合并两个 datasets 也非常简单,如果两个文件有行行对应的翻译,并且两个文件分别被不同的 dataset 读取,那么可以通过下面这种方式生成一个新的 dataset,这个新的 dataset 的内容是两种语言的翻译一一对应的元组。

1
source_target_dataset = tf.data.Dataset.zip((source_dataset, target_dataset))

Batching 变长的句子实现起来也很直观。下边的代码从 source_target_dataset 中 batch 了 batch_size 个元素,并且分别为每个 batch 的源向量和目标向量 padding 到最长的源向量和目标向量的长度。

1
2
3
4
5
6
7
8
9
10
batched_dataset = source_target_dataset.padded_batch(
batch_size,
padded_shapes=((tf.TensorShape([None]), # source vectors of unknown size
tf.TensorShape([])), # size(source)
(tf.TensorShape([None]), # target vectors of unknown size
tf.TensorShape([]))), # size(target)
padding_values=((src_eos_id, # source vectors padded on the right with src_eos_id
0), # size(source) -- unused
(tgt_eos_id, # target vectors padded on the right with tgt_eos_id
0))) # size(target) -- unused

从 dataset 拿到的数据会嵌套为元组,其 tensors 的最左边的维度是 batch_size. 其结构如下:

  • iterator[0][0] has the batched and padded source sentence matrices.
  • iterator[0][1] has the batched source size vectors.
  • iterator[1][0] has the batched and padded target sentence matrices.
  • iterator[1][1] has the batched target size vectors.

最后,bucketing 多个 batch 的大小差不多的源句子也是可以的。更多的代码实现详见文件utils/iterator_utils.py

从 dataset 中读取数据需要三行的代码:创建 iterator,取其值,初始化。

1
2
3
4
5
6
batched_iterator = batched_dataset.make_initializable_iterator()

((source, source_lengths), (target, target_lengths)) = batched_iterator.get_next()

# At initialization time.
session.run(batched_iterator.initializer, feed_dict={...})

一旦 iterator 被初始化,那么 session.run 每一次调用 source 和 target ,都会从dataset中自动提取下一个 minibatch 的数据。

让 NMT 模型更完美的其他技巧
双向RNN(Bidirectional RNN)

一般来讲,encoder 的双向 RNNs 可以让模型表现更好(训练速度会下降,因为有更多的层需要计算)。这里,我们给出了构建一个单层双向层的 encoder 的简单代码:

1
2
3
4
5
6
7
8
# Construct forward and backward cells
forward_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units)
backward_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units)

bi_outputs, encoder_state = tf.nn.bidirectional_dynamic_rnn(
forward_cell, backward_cell, encoder_emb_inp,
sequence_length=source_sequence_length, time_major=True)
encoder_outputs = tf.concat(bi_outputs, -1)

encoder_outputsencoder_state 也可以使用 Encoder 小节的方法获取到。需要注意的是,如果要创建多层双向层,你需要修改一下 encoder_state,见 model.py_build_bidirectional_rnn()方法。

Greedy decoding 可以给我们非常合理的翻译结果,但是 beam search decoding 可以让翻译结果更好。Beam search 的思想是,考虑我们可以选择的所有翻译结果的排名最靠前的几个候选的集合,我们探索其所有的可能翻译结果(大家也可以参考知乎的beam search讨论)。Beam 的这个 size 我们称为 beam width,一个较小的 beam width 比如说 10,就已经足够大了。我们推荐读者阅读 Neubig, (2017) 的 7.2.3 小节。这是 beam search 的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Replicate encoder infos beam_width times
decoder_initial_state = tf.contrib.seq2seq.tile_batch(
encoder_state, multiplier=hparams.beam_width)

# Define a beam-search decoder
decoder = tf.contrib.seq2seq.BeamSearchDecoder(
cell=decoder_cell,
embedding=embedding_decoder,
start_tokens=start_tokens,
end_token=end_token,
initial_state=decoder_initial_state,
beam_width=beam_width,
output_layer=projection_layer,
length_penalty_weight=0.0)

# Dynamic decoding
outputs, _ = tf.contrib.seq2seq.dynamic_decode(decoder, ...)

在 Decoder 小节,dynamic_decode() API 也被使用过。解码结束,我们就可以使用下面的代码得到翻译结果:

1
2
3
4
translations = outputs.predicted_ids
# Make sure translations shape is [batch_size, beam_width, time]
if self.time_major:
translations = tf.transpose(translations, perm=[1, 2, 0])

更多细节,可查看 model.py, _build_decoder() 函数。

超参数(Hyperparameters)

有一些超参数也可以供我们调节。这里,根据我们的实验,我们列举了几个超参数【你可以表示不认同,保留自己的看法】。

  • optimizer:对于“不太常见”的网络结构,Adam 可能可以给出一个较合理的结果,如果你用 SGD 进行训练,那么 SGD 往往可以取得更好的结果。
  • Attention:Bahdanau 类型的 attention,encoder 需要双向结构才能表现很好;同时 Luong 类型的 attention 需要其他的一些设置才能表现很好。在本教程中,我们推荐使用被改进的这两个类型的 attention:scaled_luongnormed_bahdanau

多 GPU 训练 | Multi-GPU training

训练一个 NMT 模型可能需要几天的时间,我们可以把不同的 RNN layers 放在不同的 GPUs 进行训练可以加快训练速度。这里是使用多 GPUs 创建 RNN layers 的例子:

1
2
3
4
5
6
cells = []
for i in range(num_layers):
cells.append(tf.contrib.rnn.DeviceWrapper(
tf.contrib.rnn.LSTMCell(num_units),
"/gpu:%d" % (num_layers % num_gpus)))
cell = tf.contrib.rnn.MultiRNNCell(cells)

另外,我们还需要 tf.gradients 的 colocate_gradients_with_ops 参数来同步梯度的计算。

你会发现,尽管我们使用了多个 GPUs,但是 attention-based NMT 模型的训练速度提升不大。问题的关键在于,在标准的 attention 模型中,在每个时间步,我们都需要用最后一层的输出去“查询”attention,这就意味着,每一个解码的时间步都需要等前面的时间步完全完成。因此,我们不能简单的通过在多 GPUs 上部署 RNN layers 来同步解码过程。

GNMT attention architecture 可以通过使用第一层的输出来查询 attention 的方法来同步 decoder 的计算。这样,解码器的每一步就可以在前一步的第一层和 attention 计算完成之后就可以进行解码了。我们的 API 实现了这个结构 GNMTAttentionMultiCell,其是tf.contrib.rnn.MultiRNNCell的子类。这里是使用 GNMTAttentionMultiCell 创建一个 decoder 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
cells = []
for i in range(num_layers):
cells.append(tf.contrib.rnn.DeviceWrapper(
tf.contrib.rnn.LSTMCell(num_units),
"/gpu:%d" % (num_layers % num_gpus)))
attention_cell = cells.pop(0)
attention_cell = tf.contrib.seq2seq.AttentionWrapper(
attention_cell,
attention_mechanism,
attention_layer_size=None, # don't add an additional dense layer.
output_attention=False,)
cell = GNMTAttentionMultiCell(attention_cell, cells)

结果与预测质量

BLEU得分
IWSLT 英语-越南语
训练:133k 的样本,dev=tst2012,test=tst2013

Systems tst2012 (dev) test2013 (test)
NMT (greedy) 23.2 25.5
NMT (beam=10) 23.8 26.1
(Luong & Manning, 2015) - 23.3

训练速度:在英伟达 K40m 上是 0.37s 的时间步、15.3k 的 wps,在 Titan X 上是 0.17 s 的时间步,32.2k 的 wps。

WMT 德语-英语
训练:4.5M 的样本量,dev=newstest2013,test=newtest2015

Systems newstest2013 (dev) newstest2015
NMT (greedy) 27.1 27.6
NMT (beam=10) 28.0 28.9
NMT + GNMT attention (beam=10) 29.0 29.9
WMT SOTA - 29.3

训练速度:在英伟达 K40m 上是 2.1s 的时间步,3.4k 的 wps,在英伟达 Titan X 上是 0.7s 的时间步,8.7k 的 wps。

为了查看 GNMT 注意的加速度,我们只在 K40m 上做了基准测试:

Systems 1 gpu 4 gpus 8 gpus
NMT (4 layers) 2.2s, 3.4K 1.9s, 3.9K -
NMT (8 layers) 3.5s, 2.0K - 2.9s, 2.4K
NMT + GNMT attention (4 layers) 2.6s, 2.8K 1.7s, 4.3K -
NMT + GNMT attention (8 layers) 4.2s, 1.7K - 1.9s, 3.8K

WMT 英语-德语 全对比
第二行是我们 GNMT 注意模型:模型 1(4 层),模型 2(8 层)。

Systems newstest2014 newstest2015
Ours — NMT + GNMT attention (4 layers) 23.7 26.5
Ours — NMT + GNMT attention (8 layers) 24.4 27.6
WMT SOTA 20.6 24.9
OpenNMT (Klein et al., 2017) 19.3 -
tf-seq2seq (Britz et al., 2017) 22.2 25.2
GNMT (Wu et al., 2016) 24.6 -

其他资源

若想深入了解神经机器翻译和序列-序列模型,我们非常推荐以下资源:

用于构建 seq2seq 模型的工具很多: