Neural Machine Translation (seq2seq) 教程

freeopen译 2017-07-18 · 最后更新时间:  2018-01-31 [机器学习] #tensorflow

原文

介绍

序列到序列(seq2seq)模型 (Sutskever et al., 2014, Cho et al., 2014) 在诸如机器翻译、语音识别和文本概括等任务中取得了巨大成功. 本教程为读者提供对 seq2seq 模型的全面理解,并展示如何从头构建一个有竞争力的 seq2seq 模型. 我们专注于神经机器翻译(NMT)任务,这是一个很好的、已获得广泛成功的 seq2seq 模型的试验台. 所含的代码轻量、高质、实用,并整合了最新的研究思路。我们通过以下方式达成此目标 :

  1. 使用最新的 解码器 / attention wrapper API, TensorFlow 1.2 数据迭代器
  2. 结合我们在建立循环神经网络和序列到序列模型方面的强大专长
  3. 提供一些巧思来构建最好的 NMT 模型,并复制一个谷歌神经机器翻译系统 Google’s NMT (GNMT) system.

我们认为,重要的是提供人们可以轻松复制的基准. 因此,我们提供了完整的实验结果,并对以下公开的数据集进行了预训练 :

  1. 小规模: 英语-越南语平行语料库(133K 句子对,TED 对话), 由 IWSLT Evaluation Campaign 提供.
  2. 大规模: 德语-英语平行语料库(4.5M 句子对) , 由 WMT Evaluation Campaign 提供.

我们首先建立关于 seq2seq 模型的一些基本知识, 说明如何构建和训练一个普通的 NMT 模型. 第二部分将详细介绍采用注意力机制(attention mechanism) 建立一个较好的 NMT 模型. 然后,我们将讨论构建更好 NMT 模型(包括速度和翻译质量)的技巧,比如 TensorFlow 的最佳实践(batching, bucketing), 双向 RNNs 和 定向搜索.

基础

神经机器翻译的背景

回到过去,传统的基于短语的翻译系统通过将语句拆成多个小块,然后再一小块一小块的翻译。这导致不流畅的翻译结果,并不十分像我们人类的翻译。我们是先读懂整个句子,再翻译出来。神经机器翻译(NMT)就是在模仿这种方式!

具体来说, NMT 系统首先使用 编码器 读取源句来构建一个 “thought” 向量 , 一个表示句子意义的数字序列; 然后,解码器 处理这个向量输出翻译结果 , 如图 1 . 这通常被称为 编码器 - 解码器结构. 以这种方式, NMT 解决了传统的基于短语翻译的遗留问题: 它可以捕获语言的 远程依赖性 , 比如,词性、语法结构等,并生成顺畅的翻译,如 Google Neural Machine Translation systems 所示.

译者注:上文的 “thought” 只是个比喻,不要当真. 机器学习建立在统计学方法的基础上,跟“思考”没有半毛钱关系,至于理解人类语言中表达的意义,那更是遥远得离谱的事情.

图1. 编码器-解码器结构 – NMT 的通用示例. 编码器转换源语句为“含义”向量,再由解码器产生翻译结果.

NMT 模型的具体结构有所不同. 对顺序数据而言,大多数 NMT 模型的一个自然选择是采用循环神经网络 (RNN). 通常,RNN 同时使用编码器和解码器. 然而,RNN 模型在以下方面有所不同: (a) 方向性 – 单向或双向; (b) 深度 – 单层或多层; (c) 类型 – 常见的有 RNN, Long Short-term Memory (LSTM), 或 gated recurrent unit (GRU). 有兴趣的读者可以在这篇博文上找到有关 RNNs 和 LSTM 的更多信息 .

在本教程中, 我们将考察一个 深度多层 RNN 的例子,它是单向的,并使用 LSTM 作为循环单元. 如图 2. 在这个例子中,我们将 “I am a student” 翻译成 “Je suis étudiant”. 在高层上, 这个 NMT 模型由两个循环神经网络组成: 编码器 RNN 简单的吃进输入文字,不做任何预测; 另一方面,解码器在预测下一个单词时处理目标句子.

更多信息, 请参阅 Luong (2016) .

图2. 神经机器翻译 – 一个深度循环网络的例子,把源语句 "I am a student" 翻译成目标语句 "Je suis étudiant". 这里, "<s>" 表示解码处理的开始, 而 "</s>" 表示解码结束.

安装教程

要安装本教程, 你需要在系统上安装 TensorFlow. 本教程撰写时 TensorFlow 的版本为 1.2.1 .
安装 TensorFlow 请参阅 installation instructions here.

一旦安装了 TensorFlow, 你就可以运行以下脚本下载本教程的源码了:

git clone https://github.com/tensorflow/nmt/

训练 – 如何建立我们的第一个 NMT 系统

让我们先走进构建 NMT 模型的核心代码,一会儿我们将详细解释图 2 . 我们晚点再来看完整代码及数据准备部分. 这部分的代码文件为 model.py.

如图 2 的底层, 编码器和解码器的循环神经网络接收下列输入: 首先, 是待翻译的句子, 接着是一个边界标记 “<s>”,表示从编码到解码模式的转换, 最后是翻译好的句子. 为了训练, 我们将为系统提供以下张量, 它们包含词汇索引和时序格式(time-major format):

为了效率,我们一次训练多个句子 (batch_size). 测试时略有不同,我们稍后再行讨论.

Embedding

根据词汇的含义,模型必须首先找出源词和目标词对应的词向量表达。为使 embedding layer 工作,首先为每种语言选择一个词表。 通常, 把词表的长度设为 V (即不重复的词汇数量). 而其它词则设为“unknown”标记,并赋予同样的词向量值。一种语言一套词向量, 一般通过训练学到。

# 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_decoderdecoder_emb_inp 。注意,可以使用已训练好的词向量, 如 word2vec 或 Glove vectors, 来初始化我们的词向量. 通常,如果有大量的训练数据,我们也可以从头开始训练这些词向量。

编码器

一旦检索到这个词,就将其对应的词向量作为输入发送到主网络,该网络由两个多层 RNN 组成 ,一个针对源语言的编码器和一个针对目标语言的解码器。 这两个 RNN 原则上可以共享相同的权重;然而,实践中,我们经常使用不同的参数(这样的模型在拟合大规模训练数据集时做得更好). 这个 RNN 编码器使用 0 向量作为初始状态,建立如下:

# 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_sequence_length 来告诉 dynamic_rnn 确切的源句长度,以避免计算上的浪费. 由于我们的输入有时序,我们设置 time_major=True . 这里,我们仅建立一个单层的 LSTM 编码器单元. 我们将在后面的章节说明如何构建多层LSTM,增加 dropout, 和使用 attention.

解码器

decoder 也需要访问源信息,一个简单的方法是用编码器最后的隐藏状态来初始化它。如图2,我们把源语言的“student”的隐藏状态传递到解码器端。

# Build RNN cell
decoder_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units)
# 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)、helper、前一个 encoder_state 作为输入输出到 decoder 对象。通过分开 decoder 和 helper,我们可以在不同代码中重用。例如,TrainingHelper 可以被 GreedyEmbeddingHelper 取代做贪心解码。更多内容请看helper.py.

最后,我们没提到的 projection_layer 是个密集矩阵,它将顶部的隐藏状态转为 V 个维度的 logit 向量。我们在图 2 的顶部展示了这个过程。

projection_layer = layers_core.Dense(
    tgt_vocab_size, use_bias=False)

误差

根据上面给定的 logits ,我们现在准备计算我们的训练误差:

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 为学习率的 SGD(随机梯度下降算法),后一种方法会更有效, 因为它采用了更小的 1 / num_time_steps 作为学习率。

梯度计算 & 优化

我们现在已经定义了正向传播的 NMT 模型。计算反向传播只是几行代码的问题:

# Calculate and clip gradients
params = tf.trainable_variables()
gradients = tf.gradients(train_loss, params)
clipped_gradients, _ = tf.clip_by_global_norm(
    gradients, max_gradient_norm)

训练 RNN 的一个重要步骤是梯度调整。这里,我们按照惯例来调整。max_gradient_norm 的最大值, 通常设为 5 或 1. 最后一步是选择优化器. Adam 优化器是常用的选择. 我们也选择一个学习率,这个值常在 0.0001 到 0.001 之间; 并且可以随训练进度而减小.

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

在我们自己的实验中,我们采用可自动降低学习率的标准 SGD 优化器(tf.train.GradientDescentOptimizer),从而产生更好的性能。参见 评测.

动手 - 让我们训练一个NMT模型

让我们训练我们的第一个 NMT 模型,把越南语翻译成英语!我们代码的入口是 nmt.py.

我们将使用 small-scale parallel corpus of TED talks (133K training examples) 进行此练习. 所有数据可在: https://nlp.stanford.edu/projects/nmt/找到. 我们将使用 tst2012 作为训练数据集, tst2013 作为测试数据集.

运行下列命令下载训练 NMT 模型的数据:

nmt/scripts/download_iwslt15.sh /tmp/nmt_data

运行如下命令开始训练:

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 个隐藏单元和 12 轮的 embedding 操作。我们使用的 dropout 值为 0.2(维持概率在0.8 )。如果没有错误,我们应该在我们训练时看到类似于下面的日志。

# 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 查看模型的统计::

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

从英语到越南语的训练可以简单地改变: --src=en --tgt=vi

推理 – 如何产生翻译

当你训练你的 NMT 模型(一旦你已训练好模型),你可以得到以前未见过的源语句的翻译。这个过程称为推理。训练和推理(测试)之间有明确的区别:在推理时,我们只能访问源语句,即 encoder_inputs。解码有很多种方法, 包括贪心、采样和定向搜索几种。在这里,我们将讨论贪心解码法。

这个想法很简单,我们在图 3 中说明:

  1. 我们仍然以与训练期间相同的方式对源语句进行编码以获得 encoder_state,并使用该 encoder_state 来初始化解码器。
  2. 一旦解码器接收到起始符号“<s>”(参见我们代码中的 tgt_sos_id ),解码(翻译)处理就开始
  3. 对于解码器侧的每个时间步长,我们将 RNN 的输出视为一组 logit。我们选择最可能的字,与最大 logit 值相关联的 id 作为译出的字(这是“贪婪”行为)。例如在图 3 中,在第一个解码步骤中,词“moi”具有最高的翻译概率。然后,我们将这个词作为输入提供给下一个时间步。(译者注:由于输出的logit向量维度为词表长度V,当词表很大时计算量很大)
  4. 继续第3步直到遇到句子的结尾标记“</s>”(参见我们的代码中的 tgt_eos_id )。

图3. 贪心解码 – 如何用贪心搜索法训练 NMT 模型, 使源语句产生"Je suis étudiant"的翻译

第 3 步的推理与训练不同。不是总是将正确的目标词作为输入,推理使用模型预测的单词。以下是实现贪心解码的代码。它与训练解码器非常相似。

# 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 来限制翻译的长度。一个启发式的用法是采用源语句长度的两倍来解码。

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

训练好一个模型后,我们现在可以创建一个推理文件并翻译一些句子:

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

进阶

经历了最基本的 seq2seq 模型,让我们进一步完善它!为了建立最先进的神经机器翻译系统,我们还需要更多的“内功心法”: 注意力机制(attention mechanism),这是由 Bahdanau等人2015年首次引入,然后由 Luong等人2015年完善。注意力机制的关键在于,通过在翻译过程中对相关的源内容进行“关注”,建立目标和源之间的直接连接。注意力机制 的一个很好的副产品是在源和目标句子之间生成一个易于查看的对齐矩阵(如图4所示)

图4. Attention 可视化 – 源语句与目标语句的词汇对齐矩阵示例, 图片来自 (Bahdanau et al., 2015).

请记住,在 seq2seq 模型中,当开始解码时,我们将最后的源状态从编码器传递到解码器。这对中短句的效果很好; 而对于长句,单个固定大小的隐藏状态会成为信息瓶颈。 注意力机制不是放弃在源 RNN 中计算的所有隐藏状态,而是提供了一种允许解码器窥视它们的方法(将它们视为源信息的动态存储器)。 通过这样做,注意力机制改善了较长句子的翻译质量。现在,注意力机制已成为事实上的标准,并已成功应用于许多其他任务(包括图像字幕生成,语音识别和文本摘要等)。

注意力机制的背景

我们现在讲讲(Luong等人,2015年)中提出的注意力机制(attention mechanism)的一个实例,该实例已被用于包括 OpenNMT 等开源工具包在内的多个最先进的系统,以及本教程的 TF seq2seq API 中。我们还将提供注意力机制其他变种的连接。

图5. Attention mechanism – (Luong et al., 2015)中提到的基于 attention NMT 系统的示例. 我们重点突出 attention 计算的第一个步骤. 为了清晰,我们没像图2那样显示 embedding 层和 projection 层.

如图 5 所示,attention 计算发生在每个解码器的时间步长。它包括以下步骤:

  1. 将当前目标的隐藏状态与所有源的状态进行比较以获得 attention weights(如图4所示)。
  2. 基于 attention weights ,我们计算上下文矢量context vector) 作为源状态的加权平均值。
  3. 将上下文矢量与当前目标的隐藏状态组合以产生最终的 attention vector
  4. attention vector 作为输入发送到下一个时间步(input feeding)。前三个步骤通过以下等式来总结:


这里,函数 score 用于将目标隐藏状态 $h_t$ 与每个源隐藏状态 $\overline{h}_s$ 进行比较,并将结果归一化以产生 attention weights(一个基于源语言的位置分布)。这里的 score 函数有多种选择; 流行的 score 函数包括等式(4)列出的乘法和加法形式。一旦完成计算,Attention vector $a_t$ 被用来导出softmax logit 和 loss。这类似于 seq2seq 模型顶层的目标隐藏状态。这个函数 f 也可以采取其他形式。


attention mechanisms 的各种实现可以在 attention_wrapper.py 中找到.

注意力机制有多重要?

如上述方程式所示,有许多不同的 attention 变体。这些变体取决于 score 函数和 attention 函数的形式,以及在 score 函数中是否使用上一个时间步的状态 $h_{t-1}$ 而不是 $h_t$ (源自 Bahdanau et al.,2015 的建议)。经验上,我们发现只有某些选择很重要。首先,是 attention 的基本形式,即目标语言和源语言之间的直接联系。第二,重要的是把 attention vector 送到下一个时间步,以通报网络关于过去的 attention 决定(源自Luong et al., 2015 中的示范)。最后,score 函数的选择常常会导致不同的表现。更多内容请看评测结果部分。

Attention Wrapper API

在实现 AttentionWrapper 时,我们借鉴了 (Weston et al., 2015)memory networks 方面的一些术语。本教程介绍的 attention mechanism 是只读 memory,而不是可读写的 memory。具体来说,一组源语隐藏状态(或其转换版本,如:Luong 的评分中的 $W\overline{h}_s$,或 Bahong 的评分中的 $W_2\overline{h}_s$)被作为 “memory” 。在每个时间步骤中,我们使用当前的目标隐藏状态作为 “query” 来决定要读取 memory 的哪个部分。通常,查询需要与对应于各个 memory 插槽的键值进行比较。在上述 attention mechanism 的介绍中,我们恰好将源语隐藏状态(或其转换版本,例如,Bahdanau 的评分中的 $W_1h_t$)作为“键”值。可以通过这种记忆网络术语来启发其他形式的 attention!

多亏了对 attention 的封装,使得我们扩展原始 seq2seq 代码时很方便。这部分文件参见 attention_model.py.

首先,我们需要定义一个attention mechanism,例如(Luong et al., 2015):

# 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_outputs 是顶层所有源语隐藏状态的集合,其形状为 [max_time,batch_size,num_units] (因为我们使用 dynamic_rnntime_major 设置为 True 以获得效率)。对于 attention mechanism,我们需要确保传递的“memory”是批处理的,所以我们需要转置 attention_states。我们将 source_sequence_length 传递给 attention machanism,以确保 attention weight 适当归一化(仅在非填充位置上)。

定义了 attention mechanism 后,我们使用 AttentionWrapper 来包装 decoding_cell:

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

代码的其余部分与解码器部分几乎相同!

动手 – 建立基于 attention 的 NMT 模型

要启用的 attention ,在训练时我们需要使用 luongscaled_luongbahdanaunormed_bahdanau 中的一个作为 attention 标志的值。该标志指定了我们将要使用的 attention mechanism。此外,我们需要为 attention 模型创建一个新的目录,所以我们不能重复使用前面训练过的简单 NMT 模型。

运行以下命令开始训练:

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 进行推理:

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

提示 & 技巧

训练, 评估, 和推理图

译者注:“图”的定义原文,A Graph contains a set of Operation objects, which represent units of computation; and Tensor objects, which represent the units of data that flow between operations. 简单说, “图”相当于一个数学公式,其中的 tensor 相当于变量 x ,而定义的 op 相当于连接变量的运算符号.

在 TensorFlow 中构建机器学习模型时,最好建立三个独立的图:

分别构建图有几个好处:

复杂性的主要来源是如何在单个计算机设置中跨三个图共享变量. 可以为每个图使用单独的会话来解决. 训练会话定期的保存检查点,评估会话和推断会话从检查点导入参数. 下面的例子显示了两种方法的主要区别.

之前: 三个模型在一个图中并共享一个会话

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})

之后: 三个模型在三个图中,有三个会话并共享同样的变量

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.run 调用时,我们使用有状态的迭代器对象来代替 feed_dicts 提供数据(从而我们能自己批处理、切分和操作数据)。这些迭代器使“输入管道“在设置单机和分布式时,都容易很多。我们将在下一节中介绍新的输入数据管道( 限TensorFlow 1.2 )。

数据输入管道

在 TensorFlow 1.2 之前, 用户有三种方式把数据提供给 TensorFlow 进行训练和评估:

  1. 在每次调用 session.run 时,用 feed_dict 提供数据.
  2. tf.train (e.g. tf.train.batch) 和 tf.contrib.train 中使用排队机制 .
  3. 使用象 tf.contrib.learntf.contrib.slim 等高级框架中的 helper (用 #2 效率更高).

第一种方法对于不熟悉 TensorFlow 的用户或只能在 Python 中需要做个性化输入(如:自己的小批次队列)的用户更容易。第二和第三种方法更标准,但灵活性稍差一些;他们还需要启动多个 python 线程(queue runners)。此外,如果使用错误的队列可能会导致死锁或不可知的错误。然而,队列比使用 feed_dict 更高效,也是单机和分布式训练的标准方式。

从 TensorFlow 1.2 开始,有一个新的方法可用于将数据读入 TensorFlow 模型:数据集迭代器(dataset iterators),可在 tf.contrib.data 模块中找到。数据迭代器是灵活的、易理解的和可定制的,并能依赖 TensorFlow C ++ 运行库提供高效和多线程的读入操作。

一个数据集可以从一批数据张量、一个文件名,或包含多个文件名的一个张量来创建。举例如下:

# Training dataset consists of multiple files.
train_dataset = tf.contrib.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.contrib.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.contrib.data.Dataset.from_tensor_slices(infer_batch)

所有数据集的输入处理都是类似的。包括数据的读取和清理,数据的切分(在训练和评估时)、过滤及批处理等。

把每个句子转换成单词的字符串向量,例如,我们对数据集做 map 转换:

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

然后,我们把每个句子向量切换成一个包含向量及其动态长度的元组:

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

最后,我们对每个句子执行词汇查找。根据给定的查找表,把元组中的元素从字符串向量转换为整数向量。

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

拼接两个数据集也很容易。如果有两个彼此逐行互译的文件,且每个文件都有自己的数据集,则可按以下方式创建一个新数据集来合并这两个数据集:

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

不定长句子的批处理是简单明了的。接下来对 source_target_dataset 数据集中的元素按 batch_size 的大小规格做批次转换, 同时在每批中,把源向量和目标向量进行填充,使其长度与它们当中最长的向量长度一致。

译者注:这句话不是人翻的,不信你去看原文,我是看了半天下面的 python 源码才搞出来,我在怀疑是我的语文不好还是作者的语文不好,哎!

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

从这个数据集传出的值是一个嵌套元组,其张量最左边的维度就是 batch_size 的大小。该结构为:

最后, 尽量把这些长度相似的源句子打包在一起。更多内容及完整实现请参阅 utils/iterator_utils.py .

从数据集读取数据仅需三行代码:创建迭代器,获取其值,和初始化。

batched_iterator = batched_dataset.make_initializable_iterator()

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

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

一旦迭代器被初始化,每个 session.run 调用(访问源或目标张量)将从底层数据集请求下一批数据。

关于增强 NMT 模型的其它细节

双向 RNNs

在编码器端双向化通常会带来更好的性能(随着层数的增加速度会有所降低). 这里, 我们给个简单的例子,建立一个双向单层的编码器:

# 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_state 做些修改, 详见 model.py,中的 _build_bidirectional_rnn() 方法 .

定向搜索

虽然用贪心法解码能带给我们较满意的翻译质量,但用定向搜索解码能进一步提高性能。定向搜索的想法是,在翻译的同时,保留一个小小的最佳候选集以便更容易检索。这个定向的范围称为 beam width; 一般这个值设为 10 就够了. 更多信息请参阅 Neubig, (2017) 第 7.2.3 节。举例如下:

# 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, ...)

注意这里对 dynamic_decode() API 的调用和解码器那节一样。解码后,我们就能向下面那样获取翻译结果了:

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])

关于 _build_decoder() 方法的更多信息,详见 model.py.

超参数

有几个超参数可以提高性能。这里,我们根据自己的经验列出一些[免责声明:其它人可能不同意我说的]。

优化: Adam 优化器有些不太寻常的结构, 如果你用 SGD (随机梯度下降算法)训练,它一般会带来更好的性能。

Attention: 在编码器端,Bahdanau-style attention 通常需要双向 RNN 才运行良好; 而 Luong-style attention 适用于不同的设置. 在本教程中, 我们推荐使用 Luong 和 Bahdanau-style attentions 的改进版本: scaled_luongnormed bahdanau.

多 GPU 训练

训练一个 NMT 模型可能要好几天。把不同的 RNN 层放到不同的 GPU 上,能提高训练速度。下面是在多 GPU 上创建 RNN 层的例子。

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选项,才可以对梯度进行并行计算。

你可能注意到即使增加GPU,对基于 attention 的 NMT 模型的速度提升也很小。attention 架构的一个主要缺点是,在每一个时间步,采用顶层(即最后一层)输出来查询 attention。这就意味着每次解码时必须等前面的所以步骤完成才行;因此,我们不能简单的把 RNN 层放在多个 GPU 上来并行解码。

GNMT attention architecture 提出,通过使用底层(即第一层)输出来查询 attention 来并行解码运算。这样,每次解码就能在前面的第一层完成后开始。我们在 GNMTAttentionMultiCell 中的子类 tf.contrib.rnn.MultiRNNCell上实现此架构,下面是用 GNMTAttentionMultiCell 创建一个解码单元的示例。

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)

评测

IWSLT 英语-越南语

样本集: 133K examples, 训练集=tst2012, 测试集=tst2013, 下载脚本.

训练细节. 我们用双向编码器(编码器有一个双向层)训练 512 单元的 2 层 LSTM,embedding 维度为 512。LuongAttention (scale=True) 与 keep_prob 为 0.8 的 dropout 一起使用。所有参数均匀。我们采用学习率为 1.0 的 SGD 算法:训练12K 步(12 轮);8K 步后,我们开始每 1K 步减半学习率。

结果. TODO(rzhao): 添加英语-越南语模型的 URL。

以下是两个模型的平均结果 (model 1, model 2). 我们用 BLEU 评分来评估翻译质量 (Papineni et al., 2002).

Systemstst2012 (dev)test2013 (test)
NMT (greedy)23.225.5
NMT (beam=10)23.826.1
(Luong & Manning, 2015)-23.3

训练速度: 在 K40m 上 (0.37 秒每步 , 15.3K 字每秒) & 在 TitanX 上 (0.17 秒每步, 32.2K 字每秒) . 这里,每步时间指运行一个小批量(128个)所需的时间。对于字每秒,我们统计的是源和目标上的单词。

WMT 德语-英语

样本集: 4.5M examples, 训练集=newstest2013, 测试集=newstest2015 下载脚本

训练细节. 我们训练的超参数与英语-越南语的实验类似,除了以下细节. 采用 BPE(32K 操作)将数据分成子字单元. 我们用双向编码器 1024(编码器有2个双向层)训练 1024 单元的 4 层 LSTM , embedding 维度为 1024. 我们训练了 359K 步 (10轮); 170K 步后, 我们开始每 17K 步减半学习率.

结果. TODO(rzhao): 添加德语-英语模型的 URL.

前 2 行是模型 1、2 的评价结果(model 1,model 2). 第 4 行是运行在 4 个 GPU 上的 GNMT attention 模型.

Systemsnewstest2013 (dev)newstest2015
NMT (greedy)27.127.6
NMT (beam=10)28.028.9
NMT + GNMT attention (beam=10)29.029.9
WMT SOTA-29.3

这些结果表明,我们的代码为 NMT 建立了强大的基线系统。 (请注意,WMT 系统通常会使用大量的单种语料数据,我们目前没有。)

训练速度: 在 Nvidia K40m 上 (2.1 秒每步, 3.4K 字每秒) & 在 Nvidia TitanX 上(0.7 秒每步, 8.7K 字每秒) 。 为看 GNMT attention 的速度提升效果, 我们仅在 K40m 上做测试:

Systems1 gpu4 gpus8 gpus
NMT (4 layers)2.2s, 3.4K1.9s, 3.9K-
NMT (8 layers)3.5s, 2.0K-2.9s, 2.4K
NMT + GNMT attention (4 layers)2.6s, 2.8K1.7s, 4.3K-
NMT + GNMT attention (8 layers)4.2s, 1.7K-1.9s, 3.8K

这些结果表明,没有 GNMT attention,使用多 GPU 获得的效果很小。而使用 GNMT attention,我们从多 GPU 上获得了 50%-100% 的速度提升。

WMT 英语-德语 — 完全比较

前 2 行是 GNMT attention 模型: model 1 (4 层),model 2 (8 层).

Systemsnewstest2014newstest2015
Ours — NMT + GNMT attention (4 layers)23.726.5
Ours — NMT + GNMT attention (8 layers)24.427.6
WMT SOTA20.624.9
OpenNMT (Klein et al., 2017)19.3-
tf-seq2seq (Britz et al., 2017)22.225.2
GNMT (Wu et al., 2016)24.6-

上面的结果表明,我们的模型在类似架构中有很强的竞争力。 注意,OpenNMT 使用较小的模型,而目前在 Transformer network Vaswani et al., 2017中获得的最佳结果为 28.4,不过这是明显不同的架构.

其它资源

为深入了解神经机器翻译和序列到序列模型,我们强烈推荐下面的材料 Luong, Cho, Manning, (2016); Luong, (2016); and Neubig, (2017).

可用不同的工具构建 seq2seq 模型,我们每样选了一种:

致谢

我们要感谢 Denny Britz, Anna Goldie, Derek Murray, 和 Cinjon Resnick 为 TensorFlow 和 seq2seq 库带来的新特性. 还要感谢 Lukasz Kaiser 在 seq2seq 代码库上最初的帮助; Quoc Le 提议复现一个 GNMT; Yonghui Wu 和 Zhifeng Chen 负责 GNMT 系统的细节; 同时还要感谢谷歌大脑 (Google Brain )团队的支持和反馈!

参考

Back to top