绪论
机器翻译作为自动翻译语言之间的任务,是机器学习社区中最活跃的研究领域之一。在机器翻译的众多方法中,序列到序列(“seq2seq”)模型最近取得了巨大的成功,并已成为大多数商业翻译系统中的标准。然而,虽然seq2seq模型(如OpenNMT或tf-seq2seq)上有大量的材料,但是缺乏教学人员知识和技能的材料,可以轻松构建高质量的翻译系统。
近日,TensorFlow在GitHub上宣布一个新的神经机器翻译(NMT)教程,让读者能够充分了解seq2seq模型,并展示如何从零开始构建翻译模型
一 、简介
序列到序列(seq2seq)模型在诸如机器翻译、语音识别和文本概括等各项任务中,取得了巨大的成功。本教程为读者提供了对seq2seq模型的全面介绍,并展示了如何从头构建一个seq2seq模型。我们专注于神经机器翻译(NMT)的任务,这是第一个成功的seq2seq模型的测试平台。包含的代码是轻量级的、高质量的、生产就绪的,并与最新的研究思想结合在一起。我们通过以下方式实现此目标:
使用最新的解码器/注意力包装器API,TensorFlow 1.2数据迭代器;结合了我们在建立循环和seq2seq模型方面的专长;提供提示和技巧,以构建最好的NMT模型,并复制Google的NMT(GNMT)系统;
我们认为重要的是,提供人们可以轻松复制的基准。因此,我们提供了完整的实验结果,并对以下公开数据集的模型进行了预先训练:
小规模:由IWSLT评估组织提供的平行语料库,包含TED谈话中的英语到越南语的133000个句子对;大规模:WMT评估组织提供的德语到英语的平行语料库(450万个句子对);
我们首先介绍关于NMT的seq2seq模型的一些基本知识,说明如何构建并训练vanilla NMT模型。第二部分将详细介绍建立一个高效的NMT模式的注意力机制。然后,我们将讨论提示和技巧,以构建最佳的NMT模型(包括速度和翻译质量),例如TensorFlow最佳实践(批处理、降级),双向RNN和集束搜索。
二、基础
回到过去,传统的基于短语的翻译系统将源语句分解成多个组,然后逐句翻译。这导致翻译产品不一致性,而且翻译的水平跟人类相比差异很大。人类通读整个源句,理解它的含义,然后再翻译。而神经机器翻译(NMT)正是这么模拟的!
具体来说,首先,NMT系统使用编码器,读取源语句,以构建“思想”向量,表示句子意义的数字序列;然后,解码器处理句子向量,以发出翻译,如图1所示。这通常被称为编码器-解码器架构。以这种方式,NMT解决了传统的、基于短语的方法中的本地翻译问题:它可以捕获语言的长期依赖性,例如语法结构等等,并产生更流畅的翻译。
NMT模型因具体结构而有所不同。顺序数据的自然选择是大多数NMT模型使用的循环神经网络(RNN)。通常,RNN用于编码器和解码器。然而,RNN模型在以下方面会不同:
(a)方向性——单向或双向;
(b)深度——单层或多层;
(c)类型——通常是vanilla RNN、长短期记忆网络(LSTM)或门控循环单元(GRU)。
在本教程中,我们将一个深度多层RNN视为单向,并将LSTM作为循环单元。我们在图2中展示了一个模型的例子。在这个例子中,我们建立一个模型,将源句子“I am a student”翻译成一个目标句子“Je suisétudiant”。NMT模型由两个循环神经网络组成:编码器RNN简单地处理输入源句子,而不进行任何预测;另一方面,解码器在预测下一个单词的同时,处理目标句子。
三、训练-如何构建我们第一个 NMT 系统
我们首先需要了解构建一个 NMT 模型具体代码的核心,我们会在图 2 中更详细地讲解。我们后面会介绍数据准备和全部的代码,这一部分是指 model.py 文件。
在网络的底层,编码器和解码器 RNN 接收到以下输入:首先是原句子,然后是从编码到解码模式的过渡边界符号「」,最后是目标语句。对于训练来说,我们将为系统提供以下张量,它们是以时间为主(time-major)的格式,并包括了单词索引:
a. encoder_inputs [max_encoder_time, batch_size]:源输入词。
b. decoder_inputs [max_decoder_time, batch_size]:目标输入词。
c. decoder_outputs [max_decoder_time, batch_size]:目标输出词,这些是 decoder_inputs 按一个时间步向左移动,并且在右边有句子结束符。
为了更高的效率,我们一次用多个句子(batch_size)进行训练。测试略有不同,我们会在后面讨论。
1.Embedding
给定单词的分类属性,模型首先必须查找词来源和目标嵌入以检索相应的词表征。为了令该嵌入层能够运行,我们首先需要为每一种语言选定一个词汇表。通常,选定词汇表大小 V,那么频率最高的 V 个词将视为唯一的。而所有其他的词将转换并打上「unknown」标志,因此所有的词将有相同的嵌入。我们通常在训练期间嵌入权重,并且每种语言都有一套。
# Embeddingembedding_encoder = variable_scope.get_variable("embedding_encoder", [src_vocab_size, embedding_size], ...)encoder_emb_inp = embedding_ops.embedding_lookup(embedding_encoder, encoder_inputs)# encoder_inputs: [max_time, batch_size]# encoder_emp_inp: [max_time, batch_size, embedding_size]
我们同样可以构建 embedding_decoder 和 decoder_emb_inp。注意我们可以选择预训练的词表征如 word2vec 或 Glove vectors 初始化嵌入权重。通常给定大量的训练数据,我们能从头学习这些嵌入权重。
2 编码器
一旦可以检索到,词嵌入就能作为输入馈送到主神经网络中。该网络有两个多层循环神经网络组成,一个是原语言的编码器,另一个是目标语言的解码器。这两个 RNN 原则上可以共享相同的权重,然而在实践中,我们通常使用两组不同的循环神经网络参数(这些模型在拟合大型训练数据集上做得更好)。解码器 RNN 使用零向量作为它的初始状态,并且可以使用如下代码构建:
# Build RNN cellencoder_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 并使用注意力机制。
3 解码器
decoder 也需要访问源信息,一种简单的方式是用编码器最后的隐藏态 encoder_state 对其进行初始化。在图 2 中,我们将源词「student」中的隐藏态传递到了解码器。
# Build RNN celldecoder_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units)# Helperhelper = tf.contrib.seq2seq.TrainingHelper(decoder_emb_inp, decoder_lengths, time_major=True)# Decoderdecoder = tf.contrib.seq2seq.BasicDecoder(decoder_cell, helper, encoder_state,output_layer=projection_layer)# Dynamic decodingoutputs, _ = 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 的上部展示了此过程。
projection_layer = layers_core.Dense(tgt_vocab_size, use_bias=False)
4 损失
给出以上的 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 的随机梯度下降,后面的方法将更有效地利用一个较小的学习率,即 1 / num_time_steps。
5 梯度计算和优化
现在是时候定义我们的 NMT 模型的反向传播了。计算反向传播只需要写几行代码:
# Calculate and clip gradientsparameters = 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 之间,且可设置为随着训练进程逐渐减小。
# Optimizationoptimizer = tf.train.AdamOptimizer(learning_rate)update_step = optimizer.apply_gradients(zip(clipped_gradients, params))
在我们的实验中,我们使用标准的随机梯度下降(tf.train.GradientDescentOptimizer),并采用了递减的学习率方案,因此也就有更好的性能。
推理——如何生成翻译
当你训练你的 NMT 模型时(并且一旦你已经训练了模型),可以在给定之前不可见的源语句的情况下获得翻译。这一过程被称作推理。训练与推理之间有一个明确的区分(测试):在推理时,我们只访问源语句,即 encoder_inputs。解码的方式有很多种,包括 greedy 解码、采样解码和束搜索解码(beam-search)。下面我们讨论一下 greedy 解码策略。
其想法简单,我们将在图 3 中作说明:
a.在训练获取 encoder_state 的过程中,我们依然以相同方式编码源语句,并且 encoder_state 用于初始化解码器。
b.一旦解码器接收到开始符 「《s》」(在我们的代码中指 tgt_sos_id),就开始解码处理(翻译)。
c.最大的单词,其 id 与最大的 logit 值相关联,正如被发出的词(这是 greedy 行为)。例如在图 3 中,单词 moi 在第一个解码步中具有最高的翻译概率。接着我们把这一单词作为输入馈送至下一个时间步。
d.这一过程会持续到这句话的终止符「《/s》」,然后输出(在我们的代码中是 tgt_eos_id)。
推理与训练的区别在于步骤 3。推理不总是馈送作为输入的正确目标词,而是使用被模型预测的单词。下面是实现 greedy 解码的代码。它与训练解码器非常相似。
# Helperhelper = tf.contrib.seq2seq.GreedyEmbeddingHelper(embedding_decoder,tf.fill([batch_size], tgt_sos_id), tgt_eos_id)# Decoderdecoder = tf.contrib.seq2seq.BasicDecoder(decoder_cell, helper, encoder_state,output_layer=projection_layer)# Dynamic decodingoutputs, _ = tf.contrib.seq2seq.dynamic_decode(decoder, maximum_iterations=maximum_iterations)translations = outputs.sample_id
我们在本文中使用了 GreedyEmbeddingHelper 而不是 TrainingHelper。由于无法提前知道目标语句的长度,我们使用 maximum_iterations 限制翻译的长度。一个启发是解码最多两倍的源语句长度。
注意力机制
注意力机制。该机制由 Bahdanau 等人在 年首次提出(/abs/1409.0473),稍后 Luong 等人和其他人完善了它,其核心思想是当我们翻译时通过「注意」相关的源内容,建立直接的短连接。注意力机制的一个很好副产品是源语句和目标语句之间的一个易于可视化的对齐矩阵(如图 4 所示)。
请记住在 vanilla 序列到序列模型中,当开始编码处理时,我们把最后的源状态从编码器传递到解码器。这对短、中长度的语句效果很好;对于长句子,单一固定大小的隐状态成为了信息瓶颈。注意力机制没有摒弃原RNN 中计算的所有隐状态,而是提出了允许解码器窥探它们的方法(把它们看作是源信息的动态存储)。如此,注意力机制提升了长句的翻译质量。现在,注意力机制实至名归,已成功应用于其他诸多任务(比如语音识别)。
我们现在描述一下注意力机制的实例(Luong et al., ),它已经被应用到几个最新型的系统当中了,包括开源工具,比如 OpenNMT(/about/)和此教程中的 TF seq2seq API。我们还将会提供注意力机制相关变体的内容。
当前目标隐蔽状态和所有源状态(source state)进行比较,以导出权重(weight),见图 4。 基于注意力权重,我们计算了一个背景向量(context vector),作为源状态的平均权值。 将背景向量与当前目标隐蔽态进行结合以生成最终的注意力向量。 此注意力向量将作为下一时序步骤的输入。前三个步骤可以由下列公式总结:
这里,函数 score 用于将目标隐蔽状态 ht 和每一个源状态 hs 进行比较,结果会被标准化成生成式注意力权重(一个源位置的分布)。其实有很多种关于评分函数(scoring function)的选择;比较流行的评分函数包括公式(4)中给出的乘法与加法形式。一旦被计算,注意力向量 at 就会用于推导 softmax logit 和损失。这与 vanilla seq2seq 模型顶层的目标隐蔽态相似。函数 f 也可以利用其它形式。
上述公式表明注意力机制有很多种变体。这些变体依赖于评分函数(scoring function)和注意力函数(attention function)的形式,也依赖于前一状态 ht-1,而不依赖于开始建议的评分函数 ht(Bahdanau et al., )。实际上我们发现的只有一些选择上的注意事项。一,注意力的基本形式,例如,目标和源之间的直接联系需要被呈现。二,把注意力向量输入给下一时间步骤,以把之前的注意力决策告知给网络(Luong et al., )。最后,评分函数的选择经常可以造成不同的性能表现。