关键词: Encoder-Decoder, LSTM, WordEmbedding
在机器学习领域,有很多任务是把一种样式的序列映射成另外一种样式的序列,比如把一种语言翻译成另一种语言,把一段语音转换成一段文本,给一段文字生成一句话简介,或者把一张图片转换成一段对图片内容的文字描述等。这些任务都可以看作是Seq2Seq类型的任务,也就是从一个Sequence转换成另一个Sequence。对Seq2Seq类型的任务,经常采用Encoder-Decoder模型来解决。
理论背景
Encoder-Decoder方法最早在论文《Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation》中提出,该论文使用了两个RNN网络来完成机器翻译(Statistical Machine Translation: SMT)工作,第一个RNN网络把一串符号序列编码成一个固定长度的向量表示,第二个RNN网络把这个固定长度的向量解码成目标符号序列。通过联合训练这两个RNN网络,使得对于输入序列,得到输出序列的条件概率最大化,如下图所示:
该论文的另一个创新点是在RNN网络中首次使用了GRU网络节点(Gated Recurrent Unit),GRU节点和LSTM以及Bi-LSTM节点同属于RNN类型网络节点的变种,它们在计算输入序列中某个元素的输出时,会同时考虑这一步的输入,前一步或者后一步的输出等信息,相当于把序列中时序的信息包括进来,对于像是SMT这种有时序概念的输入效果显著。相比如LSTM和Bi-LSTM的三个状态门input-gate、forget-gate、output-gate,GRU简化成了两个状态门reset-gate和update-gate,过程更加简单。
论文《Sequence to Sequence Learning with Neural Networks》在论文1的基础上,将Encoder过程的一层GRU隐藏层,改成四层的LSTM隐藏层,使得模型在处理长句子时效果更好,这很容易理解,LSTM相比GRU能学到序列中距离更远的两个元素之间的关联信息,而且层数越多,能记忆的信息也越多,所以处理长句子效果优于GRU。同时,该论文通过将输入序列倒序作为Encoder过程输入的方式,如输入序列“I like cat”倒序成“cat like I”,而Decoder顺序不变的方式,提高了Encoder的输出向量和Decoder结果之间的相关性,也起到了提升效果的作用。
前面提到的两篇论文有个共同点,它们通过Encoder处理输入序列生成固定长度的中间向量,该中间向量对Decoder过程输出序列中任意一个元素的作用都是均等的,比如将“I like cat”翻译成“我喜欢猫”时,翻译目标为“喜欢”或翻译目标为“猫”时,中间向量的作用是相同的,也就是一种注意力分散模型。
而实际上,翻译目标“喜欢”时“like”分量起的作用要大于“I”或者“cat”,同理翻译目标“猫”时“cat”分量作用要大于其他单词。为了弥补这个缺陷,论文《Neural Machine Translation by Jointly Learning to Align and Translate》提出了Attention机制,使得经过Encoder生成的中间向量包含了位置信息,在Decoder过程中处理不同输出序列时,不同位置的输入分量所占的权重不同,距离越近的元素权重越大,也就是所谓的注意力(Attention)越高。
通过这种方式,使得距离某个单词距离近的单词影响力高于距离远的单词的影响力,从而解决了这个问题。如将“Tom Chase Jerry”翻译成“汤姆追逐杰瑞”,在翻译“杰瑞”时,“Jerry”起到的作用肯定要比“Tom”“Chase”都要高。
代码实现
接下来我们利用Tensorflow实现一个基于Encoder-Decoder架构的机器翻译模型,并对代码进行简要分析。
代码分为两个大的部分,train过程和predict过程,train过程代表训练过程,predict过程代表预测过程。本文将介绍train过程,predict过程类似。
Train过程根据Encoder-Decoder结构又分为两部分,Encoder部分和Decoder部分。
Encoder部分代码:
def build_encoder(self):
encode_scope_name = 'encoder'
with tf.variable_scope(encode_scope_name):
# 构建单个的LSTMCell,同时添加了Dropout信息
encode_cell = self.build_single_cell()
## 首先将原始输入替换成embedding表示,然后经过一个全连接的网络层,然后作为tf.nn.dynamic_rnn的输入
# 生成初始化embedding矩阵
self.encode_embedding = self.init_embedding(encode_scope_name)
# 将输入句子转换成embedding表示
self.encoder_inputs_embedded = tf.nn.embedding_lookup(params=self.encode_embedding, ids=self.train_encode_inputs)
# 构造一个全连接层,含有hidden_units个隐藏节点
fully_connected_input_layer = Dense(self.hidden_units, dtype=self.data_type, name='encoder_input_projection')
# 将embedding数据过一遍全连接层,计算过程大致是:outputs = activation(inputs.kernel + bias)
self.encoder_inputs_embedded = fully_connected_input_layer(self.encoder_inputs_embedded)
# 将这个embedding信息作为tf.nn.dynamic_rnn的输入
self.encoder_outputs, self.encoder_output_state = tf.nn.dynamic_rnn(cell=encode_cell,
inputs=self.encoder_inputs_embedded,
sequence_length=self.train_encode_inputs_length, # 存储每句话的实际长度
dtype=self.data_type,
time_major=False)
这段代码我们从后往前看,我们使用tf.nn.dynamic_rnn构造Encoder过程的RNN网络,tf.nn.dynamic_rn函数有如下几个重要的输入参数:
- cell
RNN网络的隐藏层,可以包含一个或多个隐藏层 - inputs
输入数据 - sequence_length
输入batch里面每句话的长度
对于cell,我们通过build_single_cell函数构造了包含一个LSTM节点类型的隐藏层:
def build_single_cell(self):
cell = LSTMCell(self.hidden_units)
# 给LSTMCell添加Dropout信息,可选的Wrapper有DropoutWrapper/ResidualWrapper/DeviceWrapper等
cell = DropoutWrapper(cell, dtype=self.data_type, output_keep_prob=self.dropout_keep_prob)
return cell
对于输入的cell,可选的隐藏层节点类型有BaseRNNCell/GRUCell/BasicLSTMCell/LSTMCell/MultiRNNCell/_SlimRNNCell等多种类型。如果想构建多层隐藏层,可以使用MultiRNNCell,它包含一个列表,可以在列表中依次添加不同的隐藏层。这里我们使用LSTMCell类型的节点构建单隐藏层并指定隐藏层节点数目,同时,我们通过DropoutWrapper给LSTM节点添加了Dropout信息,通过添加Dropout,可以避免过拟合的现象。
对于输入的inputs,我们采用将输入句子转换成词嵌入向量(WordEmbedding)的方式,这种方式将每个单词或字表示成一个向量,不同字或词之间的相关性可以通过向量夹角来量化。其中的WordEmbedding表可以是预训练好的,也可以是训练过程逐渐建立起来的。这里我们使用的是后者,在训练过程开始的时候随机初始化,通过训练过程来不停更新Embedding矩阵。
def init_embedding(self, vocab_size, scope_name):
with tf.variable_scope(scope_name):
squrt3 = math.sqrt(3)
# 从-squrt3到squrt3均匀初始化
initializer = tf.random_uniform_initializer(-squrt3, squrt3, dtype=self.data_type)
return tf.get_variable(shape=[vocab_size, self.embedding_size],
initializer=initializer, dtype=self.data_type,
name="embedding")
我们并没有直接把句子中每个单词的Embedding向量组成的矩阵直接作为LSTM Cell的输入,而是先经过一个全连接网络进行处理,输出再作为LSTM Cell的输入。这样是因为不同的句子长度不同,我们需要统一截断或者填充到指定的最大处理长度(max_length),这样同样一个Embedding向量,有些位可能是实际的单词对应的值,有些位可能是填充的值。全连接网络的作用相当于对这两种数据进行了归一化,抹平了它们之间的差异,它的意义在这里。全连接网络通过layers.core里的Dense实现。
对于输入sequence_length,这个很好理解,存储的就是一个batch里每句话的实际长度。通过这个数据可以知道哪些位是真实的单词哪些位是填充的值。
这样,我们就完成了Encoder网络的构造,共包含两个隐藏层,一个全连接层和一个LSTM层。两个输出,一个encoder_outputs和一个encoder_output_state,这两个值会在Decoder过程中用到。
Decoder部分代码:
def build_decoder(self):
decoder_scope_name = 'decoder'
with tf.variable_scope('decoder'):
# 构建用于Decoder的LSTMCell,和Encoder过程使用的cell一样
self.decoder_cell = self.build_single_cell()
encoder_outputs = self.encoder_outputs
decoder_initial_state = self.encoder_output_state
## 和Encoder的差别在于,Decoder过程添加了Attention机制
# 使用BahdanauAttention,可选的有LuongAttention/BahdanauAttention等.Encoder的输出作为这里的输入
self.attention_type = attention_wrapper.BahdanauAttention(num_units=self.hidden_units,
memory=encoder_outputs,
memory_sequence_length=self.train_encode_inputs_length)
def attn_decoder_input_fn(inputs, attention):
return inputs
# 使用AttentionWrapper将attention注入到decoder_cell
self.decoder_cell = attention_wrapper.AttentionWrapper(
cell=self.decoder_cell,
attention_mechanism=self.attention_type,
attention_layer_size=self.hidden_units,
cell_input_fn=attn_decoder_input_fn,
initial_cell_state=decoder_initial_state, # encoder_last_state[-1] Encoder过程输出的state
alignment_history=False,
name="AttentionWrapper"
)
# Encoder过程输出的state作为Decoder过程的输入State
# initial_state = encoder_last_state # [state for state in encoder_last_state] 这里没有用MultiCell,只有一个Cell
self.decode_embedding = self.init_embedding(decoder_scope_name)
# 将目标输入转换成对应的embedding表示
self.decoder_inputs_embedded = tf.nn.embedding_lookup(params=self.decode_embedding, ids=self.train_decoder_inputs)
# 构建处理Decoder输入的全连接层
decoder_input_layer = Dense(self.hidden_units, dtype=self.data_type, name='decoder_input_projection')
# embedding数据过一遍全连接网络层
self.decoder_inputs_embedded = decoder_input_layer(self.decoder_inputs_embedded)
# TrainingHelper用于在Decoder过程中自动获取每个batch的数据
training_helper = seq2seq.TrainingHelper(inputs=self.decoder_inputs_embedded,
sequence_length=self.train_decoder_inputs_length,
time_major=False,
name='training_helper')
# 构建输出层全连接网络,输出的类别数目是目标语言的词汇数
decoder_output_layer = Dense(self.target_vocab_size, name='decoder_output_projection')
training_decoder = seq2seq.BasicDecoder(cell=self.decoder_cell, # 加入Attention的decoder cell
helper=training_helper, # 获取数据的helper函数
initial_state=decoder_initial_state, # Encoder过程输出的state作为Decoder过程的输入State
output_layer=decoder_output_layer) # Decoder完成之后经过全连接网络映射到最终输出的类别
# 获取一个batch里面最长句子的长度
max_decoder_length = tf.reduce_max(self.train_decoder_inputs_length)
## 使用training_decoder进行dynamic_decode操作,输出decoder结果
self.decoder_outputs, _, _ = seq2seq.dynamic_decode(decoder=training_decoder,
impute_finished=True,
maximum_iterations=max_decoder_length)
# 生成一个和decoder_outputs.rnn_output结构一样的tensor,代表一次训练的结果
self.decoder_logits_train = tf.identity(self.decoder_outputs.rnn_output)
# 选择logits的最大值的位置作为预测选择的结果
self.decoder_pred_train = tf.argmax(self.decoder_logits_train, axis=-1, name='decoder_pred_train')
# 根据输入batch中每句话的长度,和指定处理的最大长度,填充mask数据,这样可以提高计算效率,同时不影响最终结果
masks = tf.sequence_mask(lengths=self.train_decoder_inputs_length,
maxlen=max_decoder_length, dtype=self.data_type, name='masks')
# 计算loss
self.loss = seq2seq.sequence_loss(logits=self.decoder_logits_train, # 预测值
targets=self.train_decoder_targets, # 实际值
weights=masks, # mask值
average_across_timesteps=True,
average_across_batch=True, )
## 接下来手动进行梯度更新
# 首先获得trainable variables
trainable_params = tf.trainable_variables()
# 使用gradients函数,计算loss对trainable_params的导数,trainable_params包含各个可训练的参数
gradients = tf.gradients(self.loss, trainable_params)
# 对可训练参数的梯度进行正则化处理,将权重的更新限定在一个合理范围内,防止权重更新过于迅猛造成梯度爆炸或梯度消失
clip_gradients, _ = tf.clip_by_global_norm(gradients, self.max_gradient_norm)
# 一次训练结束后更新参数权重
self.updates = tf.train.AdamOptimizer(learning_rate=self.learning_rate).apply_gradients(
zip(clip_gradients, trainable_params), global_step=self.global_step)
# self.updates = tf.train.AdadeltaOptimizer(learning_rate=self.learning_rate).minimize(self.loss)
Decoder部分代码比较长,我们使用seq2seq.dynamic_decode进行Decoder过程的构建。该函数有这样几个输入:
- decoder
decoder指定了解码过程的细节。关键如下,cell是添加了Attention后的LSTM Cell,helper是一个辅助类,可以用GreedyEmbeddingHelper替代TrainingHelper进行贪婪解码。initial_state是Encoder过程输出的状态数据。output_layer是一个全连接层,Decoder中的LSTM隐藏层输出的结果通过这个全连接层转换成最终的翻译结果。
training_decoder = seq2seq.BasicDecoder(cell=self.decoder_cell, # 加入Attention的decoder cell
helper=training_helper, # 获取数据的helper函数
initial_state=decoder_initial_state, # Encoder过程输出的state作为Decoder过程的输入State
output_layer=decoder_output_layer) # Decoder完成之后经过全连接网络映射到最终输出的类别
- output_time_major
没什么用,保持一致都设置False就行 - maximum_iterations
最大decoder次数,就是我们指定的max_length
那如何生成包含Attention的LSTM Cell?首先调用build_single_cell构造一个LSTM隐藏层节点,这一步和和Encoder一样。不同的地方在于,Decoder里的LSTM节点需要添加Attention机制,使得输入单词和输出单词的对应关系集中在当前单词位置周围。这里Attention我们使用的是BahdanauAttention机制,可选的Attention机制包括LuongAttention/BahdanauAttention等。
self.attention_type = attention_wrapper.BahdanauAttention(num_units=self.hidden_units,
memory=encoder_outputs,
memory_sequence_length=self.train_encode_inputs_length)
def attn_decoder_input_fn(inputs, attention):
return inputs
# 使用AttentionWrapper将attention注入到decoder_cell
self.decoder_cell = attention_wrapper.AttentionWrapper(
cell=self.decoder_cell,
attention_mechanism=self.attention_type,
attention_layer_size=self.hidden_units,
cell_input_fn=attn_decoder_input_fn,
initial_cell_state=encoder_last_state, # encoder_last_state[-1] Encoder过程输出的state
alignment_history=False,
name="AttentionWrapper"
)
将encoder_outputs作为参数传入BahdanauAttention,并使用AttentionWrapper包裹一下LSTM Cell。AttentionWrapper的参数包括前面生成的LSTM Cell,Encoder过程输出的encoder_last_state等。这样就给LSTM Cell添加了Attention机制。
通过seq2seq.dynamic_decode得到Decoder输出decoder_outputs后,利用tf.argmax就可以获取预测结果:
self.decoder_pred_train = tf.argmax(self.decoder_logits_train, axis=-1, name='decoder_pred_train')
通过seq2seq.sequence_loss,输入预测结果和真实结果,可以计算出损失loss:
# 计算loss
self.loss = seq2seq.sequence_loss(logits=self.decoder_logits_train, # 预测值
targets=self.train_decoder_targets, # 实际值
weights=masks, # mask值
average_across_timesteps=True,
average_across_batch=True, )
计算出loss,最后一步就是梯度更新了,这里我们使用AdadeltaOptimizer进行梯度更新。可以简单的使用AdadeltaOptimizer默认的minimize方法更新梯度,如下:
self.updates = tf.train.AdadeltaOptimizer(learning_rate=self.learning_rate).minimize(self.loss)
也可以手动进行梯度更新,我们选择手动更新梯度:
# 获得trainable variables
trainable_params = tf.trainable_variables()
gradients = tf.gradients(self.loss, trainable_params)
clip_gradients, _ = tf.clip_by_global_norm(gradients, self.max_gradient_norm)
# 一次训练结束后更新参数权重
self.updates = tf.train.AdamOptimizer(learning_rate=self.learning_rate).apply_gradients(
zip(clip_gradients, trainable_params), global_step=self.global_step)
首先通过tf.trainable_variables获得所有可训练的参数,然后使用gradients函数,计算loss对trainable_params的导数,trainable_params包含各个可训练的参数。
再通过tf.clip_by_global_norm对可训练参数的梯度进行正则化处理,将权重的更新限定在一个合理范围内,防止权重更新过于迅猛造成梯度爆炸或梯度消失。最后通过AdamOptimizer的apply_gradients函数更新参数,完成一个训练过程。
这样,通过在所有batch上循环epochs次,就完成了整个模型的训练。由于实际两个语言翻译模型的语聊库很大,词汇数目多,所以想要达到一个良好的效果,需要训练的时间很久(几天),而且需要在GPU上训练,在小样本上训练往往很难有满意的效果。
参考:
http://blog.csdn.net/jerr__y/article/details/53749693
http://blog.csdn.net/malefactor/article/details/50550211
https://blog.keras.io/a-ten-minute-introduction-to-sequence-to-sequence-learning-in-keras.html
https://github.com/JayParks/tf-seq2seq