添加注意力机制
注意力不仅为瓶颈问题提供了解决方案,还为句子中的每个单词赋予了权重(相当字面意义)。源序列在编码器输出中有它自己的的信息,在解码器中被预测的字在相应的解码器隐藏状态中有它自己的的信息。我们需要知道哪个编码器的输出拥有类似的信息,我们需要知道在解码器隐藏状态下,哪个编码器输出的信息与解码器隐藏状态下的相似。
因此,这些编码器输出和解码器的隐藏状态被用作一个数学函数的输入,从而得到一个注意力向量分数。当一个单词被预测时(在解码器中的每个GRU单元),这个注意力分数向量在每一步都被计算出来。该向量确定每个编码器输出的权重,以找到加权和。
注意力的一般定义:给定一组向量“值”和一个向量“查询”,注意力是一种计算基于查询的加权值和的技术。
在我们的seq2seq架构上下文中,每个解码器隐藏状态(查询)处理所有编码器输出(值),以获得依赖于解码器隐藏状态(查询)的编码器输出(值)的加权和。
加权和是值中包含的信息的选择性摘要,查询将确定关注哪些值。这个过程类似于将查询投射到值空间中,以便在值空间中查找查询(score)的上下文。较高的分数表示对应的值更类似于查询。
根据注意力机制的原始论文,解码器决定源句要注意的部分。通过让解码器有一个注意机制,我们将编码器从必须将源句中的所有信息编码为固定长度的向量的负担中解脱出来。使用这种新方法,信息可以分布在整个序列中,解码器可以相应地有选择地检索这些信息。
还记得我们刚才讨论的数学函数吗?有几种方法可以找到注意力得分(相似度)。主要有以下几点:
基本点积注意力(Dot Product Attention)
乘法注意力(multiplicative attention)
加性注意力(additive attention)
我们不会在这里逐一深入讲解。我们会在后续的文章中详细接好。现在我们将考虑基本的点积注意,因为它是最容易掌握。你已经猜到了这类注意力的作用。从名称判断,它是输入矩阵的点积。
注意,基本的点积注意有一个假设。它假设两个输入矩阵的维数在轴上要做点积的地方必须是相同的,这样才能做点积。在我们的实现中,这个维度是由超参数hidden_units给出的,对于编码器和解码器都是一样的。
上面讲了太多的理论。现在让我们回到代码!我们将定义我们的Attention类。
将编码器输出张量与解码器隐藏状态进行点积,得到注意值。这是通过Tensorflow的matmul()函数实现的。我们取上一步得到的注意力分数的softmax。这样做是为了规范化分数并在区间[0,1]内获取值。编码器输出与相应的注意分数相乘,然后相加得到一个张量。这基本上是编码器输出的加权和,通过reduce_sum()函数实现。
class BasicDotProductAttention(tf.keras.layers.Layer): def __init__(self): super(BasicDotProductAttention, self).__init__() def call(self, decoder_hidden_state, encoder_outputs): #Dimensions of decoder_hidden_state => (BATCH_SIZE, hidden_units) #Dimensions of encoder_outputs => (BATCH_SIZE, MAX_WORDS_IN_A_SENTENCE, hidden_units) decoder_hidden_state_with_time_axis = tf.expand_dims(decoder_hidden_state, 2) #Dimensions of decoder_hidden_state_with_time_axis => (BATCH_SIZE, hidden_units, 1) attention_scores = tf.matmul(encoder_outputs, decoder_hidden_state_with_time_axis) #Dimensions of attention_scores => (BATCH_SIZE, MAX_WORDS_IN_A_SENTENCE, 1) attention_scores = tf.nn.softmax(attention_scores, axis = 1) weighted_sum_of_encoder_outputs = tf.reduce_sum(encoder_outputs * attention_scores, axis = 1) #Dimensions of weighted_sum_of_encoder_outputs => (BATCH_SIZE, hidden_units) return weighted_sum_of_encoder_outputs, attention_scores
解码器Decoder ( 增加注意力机制)
代码在decoder类中增加了以下步骤。
就像编码器一样,我们在这里也有一个嵌入层用于目标语言中的序列。序列中的每一个单词都在具有相似意义的相似单词的嵌入空间中表示。
我们也得到的加权和编码器输出通过使用当前解码隐藏状态和编码器输出。这是通过调用我们的注意力层来实现的。
我们将以上两步得到的结果(嵌入空间序列的表示和编码器输出的加权和)串联起来。这个串联张量被发送到我们的解码器的GRU层。
这个GRU层的输出被发送到一个稠密层,这个稠密层给出所有hindi_vocab_size的单词出现的概率。具有高概率的单词意味着模型认为这个单词应该是下一个单词。
class Decoder(tf.keras.Model): def __init__(self, hindi_vocab_size, embedding_dim, hidden_units): super(Decoder, self).__init__() self.embedding = tf.keras.layers.Embedding(hindi_vocab_size, embedding_dim) self.gru = tf.keras.layers.GRU(hidden_units, return_state = True) self.word_probability_layer = tf.keras.layers.Dense(hindi_vocab_size, activation = 'softmax') self.attention_layer = BasicDotProductAttention() def call(self, decoder_input, decoder_hidden, encoder_sequence_output): x = self.embedding(decoder_input) #Dimensions of x => (BATCH_SIZE, embedding_dim) weighted_sum_of_encoder_outputs, attention_scores = self.attention_layer(decoder_hidden, encoder_sequence_output) #Dimensions of weighted_sum_of_encoder_outputs => (BATCH_SIZE, hidden_units) x = tf.concat([weighted_sum_of_encoder_outputs, x], axis = -1) x = tf.expand_dims(x, 1) #Dimensions of x => (BATCH_SIZE, 1, hidden_units + embedding_dim) decoder_output, decoder_state = self.gru(x) #Dimensions of decoder_output => (BATCH_SIZE, hidden_units) word_probability = self.word_probability_layer(decoder_output) #Dimensions of word_probability => (BATCH_SIZE, hindi_vocab_size) return word_probability, decoder_state, attention_scores # initialize our decoder decoder = Decoder(hindi_vocab_size, embedding_dim, hidden_units)
训练
我们定义我们的损失函数和优化器。选择了稀疏分类交叉熵和Adam优化器。每个训练步骤如下:
- 从编码器对象获取编码器序列输出和编码器最终隐藏状态。编码器序列输出用于查找注意力分数,编码器最终隐藏状态将成为解码器的初始隐藏状态。
- 对于目标语言中预测的每个单词,我们将输入单词、前一个解码器隐藏状态和编码器序列输出作为解码器对象的参数。返回单词预测概率和当前解码器隐藏状态。
- 将概率最大的字作为下一个解码器GRU单元(解码器对象)的输入,当前解码器隐藏状态成为下一个解码器GRU单元的输入隐藏状态。
- 损失通过单词预测概率和目标句中的实际单词计算,并向后传播
在每个epoch中,每批调用上述训练步骤,最后存储并绘制每个epoch对应的损失。
附注:在第1步,为什么我们仍然使用编码器的最终隐藏状态作为我们的解码器的第一个隐藏状态?
这是因为,如果我们这样做,seq2seq模型将被优化为一个单一系统。反向传播是端到端进行的。我们不想分别优化编码器和解码器。并且,没有必要通过这个隐藏状态来获取源序列信息,因为我们已经有注意力机制了:)
optimizer = tf.keras.optimizers.Adam(learning_rate = learning_rate) loss_object = tf.keras.losses.SparseCategoricalCrossentropy(reduction='none') def loss_function(actual_words, predicted_words_probability): loss = loss_object(actual_words, predicted_words_probability) mask = tf.where(actual_words > 0, 1.0, 0.0) return tf.reduce_mean(mask * loss) def train_step(english_sequences, hindi_sequences): loss = 0 with tf.GradientTape() as tape: encoder_sequence_output, encoder_hidden = encoder(english_sequences) decoder_hidden = encoder_hidden decoder_input = hindi_sequences[:, 0] for i in range(1, hindi_sequences.shape[1]): predicted_words_probability, decoder_hidden, _ = decoder(decoder_input, decoder_hidden, encoder_sequence_output) actual_words = hindi_sequences[:, i] # if all the sentences in batch are completed if np.count_nonzero(actual_words) == 0: break loss += loss_function(actual_words, predicted_words_probability) decoder_input = actual_words variables = encoder.trainable_variables + decoder.trainable_variables gradients = tape.gradient(loss, variables) optimizer.apply_gradients(zip(gradients, variables)) return loss.numpy() all_epoch_losses = [] training_start_time = time.time() for epoch in range(epochs): epoch_loss = [] start_time = time.time() for(batch, (english_sequences, hindi_sequences)) in enumerate(dataset): batch_loss = train_step(english_sequences, hindi_sequences) epoch_loss.append(batch_loss) all_epoch_losses.append(sum(epoch_loss)/len(epoch_loss)) print("Epoch No.: " + str(epoch) + " Time: " + str(time.time()-start_time)) print("All Epoch Losses: " + str(all_epoch_losses)) print("Total time in training: " + str(time.time() - training_start_time)) plt.plot(all_epoch_losses) plt.xlabel("Epochs") plt.ylabel("Epoch Loss") plt.show()
测试
为了测试我们的模型在经过训练后的执行情况,我们定义了一个函数,该函数接受一个英语句子,并按照模型的预测返回一个印地语句子。让我们实现这个函数,我们将在下一节中看到结果的好坏。
- 我们接受英语句子,对其进行预处理,并将其转换为长度为MAX_WORDS_IN_A_SENTENCE的序列或向量,如开头的“预处理数据”部分所述。
- 这个序列被输入到我们训练好的编码器,编码器返回编码器序列输出和编码器的最终隐藏状态。
- 编码器的最终隐藏状态是译码器的第一个隐藏状态,译码器的第一个输入字是一个开始标记“sentencestart”。
- 解码器返回预测的字概率。概率最大的单词成为我们预测的单词,并被附加到最后的印地语句子中。这个字作为输入进入下一个解码器层。
- 预测单词的循环将继续下去,直到解码器预测结束标记“sentenceend”或单词数量超过某个限制(我们将这个限制保持为MAX_WORDS_IN_A_SENTENCE的两倍)。
def get_sentence_from_sequences(sequences, tokenizer): return tokenizer.sequences_to_texts(sequences) # Testing def translate_sentence(sentence): sentence = preprocess_sentence(sentence, True) sequence = english_tokenizer.texts_to_sequences([sentence])[0] sequence = tf.keras.preprocessing.sequence.pad_sequences([sequence], maxlen = MAX_WORDS_IN_A_SENTENCE, padding = 'post') encoder_input = tf.convert_to_tensor(sequence) encoder_sequence_output, encoder_hidden = encoder(encoder_input) decoder_input = tf.convert_to_tensor([hindi_word_index['sentencestart']]) decoder_hidden = encoder_hidden sentence_end_word_id = hindi_word_index['sentenceend'] hindi_sequence = [] for i in range(MAX_WORDS_IN_A_SENTENCE*2): predicted_words_probability, decoder_hidden, _ = decoder(decoder_input, decoder_hidden, encoder_sequence_output) # taking the word with maximum probability predicted_word_id = tf.argmax(predicted_words_probability[0]).numpy() hindi_sequence.append(predicted_word_id) # if the word 'sentenceend' is predicted, exit the loop if predicted_word_id == sentence_end_word_id: break decoder_input = tf.convert_to_tensor([predicted_word_id]) print(sentence) return get_sentence_from_sequences([hindi_sequence], hindi_tokenizer) # print translated sentence print(translate_sentence("Try multiple sentences here to check how good model is working!"))
结果
我们来看看结果。我运行的代码与NVidia K80 GPU Kaggle,在上面的代码。100个epoch,需要70分钟的训练。损失与epoch图如下所示。
经过35个epoch的训练后,我尝试向我们的translate_sentence()函数中添加随机的英语句子,结果有些令人满意,但也有一定的问题。显然,可以对超参数进行更多的优化。
但是在这里,超参数并不是与实际翻译有一些偏差的唯一原因。让我们对更多可以实现以使我们的模型运行得更好的点进行小讨论。
可能的改进
在实现我们的模型时,我们已经对编码器、解码器和注意力机制有了非常基本的了解。根据可用的时间和计算能力,以下是一些点,可以尝试和测试,以知道如果他们工作时,实施良好:
使用堆叠GRU编码器和解码器
使用不同形式的注意力机制
使用不同的优化器
增加数据集的大小
采用Beam Search代替Greedy decoding
我们现在所使用的的解码器被称作贪婪解码(Greedy decoding)。我们假设概率最高的单词是最终预测的单词,并输入到下一个解码器状态。这种方法的问题是没有办法撤销这个动作。Beam Search解码从单词概率分布中考虑最高k个可能的单词,并检查所有的可能性,我们会在明天的文章中介绍Beam Search。