採用帶注意機制的序列序列結構進行英印地語神經機器翻譯
Seq2seq模型構成了機器翻譯、圖像和視頻字幕、文本摘要、聊天機器人以及任何你可能想到的包括從一個數據序列到另一個數據序列轉換的任務的基礎。如果您曾使用過谷歌Translate,或與Siri、Alexa或谷歌Assistant進行過互動,那麼你就是序列對序列(seq2seq)神經結構的受益者。
我們這裡的重點是機器翻譯,基本上就是把一個句子x從一種語言翻譯成另一種語言的句子y。機器翻譯是seq2seq模型的主要用例,注意機制對機器翻譯進行了改進。關於這類主題的文章通常涉及用於實現的大代碼段和來自多個庫的大量API調用,對概念本身沒有直觀的理解。在這方面,我們既要講求理論,也要講求執行。除了實現之外,我們還將詳細了解seq2seq體系結構和注意力的每個組件表示什麼。本文中使用的代碼可以在最後的資源列表中找到。
目標
在Tensorflow中實現、訓練和測試一個英語到印地語機器翻譯模型。
對編碼器、解碼器、注意機制的作用形成直觀透徹的理解。
討論如何進一步改進現有的模型。
讀數據集
首先,導入所有需要的庫。在這個實現中使用的英語到印地語語料庫可以在Kaggle找到。一個名為「HindiEnglishTruncatedCorpus」的文件。將下載csv "。請確保在pd.readcsv()函數中放置了正確的文件路徑,該路徑對應於文件系統中的路徑。
import numpy as npimport pandas as pdfrom tensorflow.keras.preprocessing.text import Tokenizerfrom tensorflow.keras.preprocessing.sequence import pad_sequencesimport tensorflow as tffrom sklearn.model_selection import train_test_splitimport timefrom matplotlib import pyplot as pltimport osimport redata = pd.read_csv("./Hindi_English_Truncated_Corpus.csv")english_sentences = data["english_sentence"]hindi_sentences = data["hindi_sentence"]
讓我們快速看看我們正在處理的數據集的類型。這是相當簡單的。
數據預處理
在我們繼續我們的編碼器,解碼器和注意力實現之前,我們需要預處理我們的數據。請注意,預處理步驟也依賴於我們處理的數據類型。例如,在這裡考慮的數據集中,也有帶有空字符串的句子。我們需要相應地處理這類案例。如果使用其他數據集,也可能需要一些額外或更少的步驟。預處理步驟如下:
在單詞和標點符號之間插入空格
如果手頭上的句子是英語,我們就用空格替換除了(a-z, A-Z, 「.」, 「?」, 「!」, 「,」)
句子中去掉多餘的空格,關鍵字「sentencestart」和「sentenceend」分別添加到句子的前面和後面,讓我們的模型明確地知道句子開始和結束。
每個句子的以上三個任務都是使用preprocess_sentence()函數實現的。我們還在開始時初始化了所有的超參數和全局變量。請閱讀下面的超參數和全局變量。我們將在需要時使用它們。
# Global variables and Hyperparametersnum_words = 10000oov_token = '<UNK>'english_vocab_size = num_words + 1hindi_vocab_size = num_words + 1MAX_WORDS_IN_A_SENTENCE = 16test_ratio = 0.2BATCH_SIZE = 512embedding_dim = 64hidden_units = 1024learning_rate = 0.006epochs = 100def preprocess_sentence(sen, is_english):if (type(sen) != str): return '' sen = sen.strip('.') # insert space between words and punctuations sen = re.sub(r"([?.!,;])", r" \1 ", sen) sen = re.sub(r'[" "]+', " ", sen) # For english, replacing everything with space except (a-z, A-Z, ".", "?", "!", ",", "'") if(is_english == True): sen = re.sub(r"[^a-zA-Z?.!,']+", " ", sen) sen = sen.lower() sen = sen.strip() sen = 'sentencestart ' + sen + ' sentenceend' sen = ' '.join(sen.split()) return sen
對包含英語句子和印地語句子的每個數據點進行循環,確保不考慮帶有空字符串的句子,並且句子中的最大單詞數不大於MAXWORDSINASENTENCE的值。這一步是為了避免我們的矩陣是稀疏的。
下一步是對文本語料庫進行向量化。具體來說,fitontexts()為每個單詞分配一個唯一的索引。textstosequences()將一個文本句子轉換為一個數字列表或一個向量,其中數字對應於單詞的唯一索引。padsequences()通過添加剛好足夠數量的oovtoken(從vocab token中提取)來確保所有這些向量最後都具有相同的長度,使每個向量具有相同的長度。tokenize_statements()封裝了上面這些功能。
接下來,我們從完整的數據集中得到訓練集,然後對訓練集進行批處理。我們訓練模型所用的句子對總數為51712。
# Loop through each datapoint having english and hindi sentenceprocessed_e_sentences = []processed_h_sentences = []for (e_sen, h_sen) in zip(english_sentences, hindi_sentences):processed_e_sen = preprocess_sentence(e_sen, True) processed_h_sen = preprocess_sentence(h_sen, False) if(processed_e_sen == '' or processed_h_sen == '' or processed_e_sen.count(' ') > (MAX_WORDS_IN_A_SENTENCE-1) or processed_h_sen.count(' ') > (MAX_WORDS_IN_A_SENTENCE-1)): continue processed_e_sentences.append(processed_e_sen) processed_h_sentences.append(processed_h_sen)print("Sentence examples: ")print(processed_e_sentences[0])print(processed_h_sentences[0])print("Length of English processed sentences: " + str(len(processed_e_sentences)))print("Length of Hindi processed sentences: " + str(len(processed_h_sentences)))def tokenize_sentences(processed_sentences, num_words, oov_token): tokenizer = Tokenizer(num_words = num_words, oov_token = oov_token) tokenizer.fit_on_texts(processed_sentences) word_index = tokenizer.word_index sequences = tokenizer.texts_to_sequences(processed_sentences) sequences = pad_sequences(sequences, padding = 'post') return word_index, sequences, tokenizerenglish_word_index, english_sequences, english_tokenizer = tokenize_sentences(processed_e_sentences, num_words, oov_token)hindi_word_index, hindi_sequences, hindi_tokenizer = tokenize_sentences(processed_h_sentences, num_words, oov_token)# split into traning and validation setenglish_train_sequences, english_val_sequences, hindi_train_sequences, hindi_val_sequences = train_test_split(english_sequences, hindi_sequences, test_size = test_ratio)BUFFER_SIZE = len(english_train_sequences)# Batching the training setdataset = tf.data.Dataset.from_tensor_slices((english_train_sequences, hindi_train_sequences)).shuffle(BUFFER_SIZE)dataset = dataset.batch(BATCH_SIZE, drop_remainder = True)print("No. of batches: " + str(len(list(dataset.as_numpy_iterator()))))
編碼器Encoder
Seq2seq架構在原論文中涉及到兩個長短期內存(LSTM)。一個用於編碼器,另一個用於解碼器。請注意,在編碼器和解碼器中,我們將使用GRU(門控周期性單元)來代替LSTM,因為GRU的計算能力更少,但結果與LSTM幾乎相同。Encoder涉及的步驟:
輸入句子中的每個單詞都被嵌入並表示在具有embeddingdim(超參數)維數的不同空間中。換句話說,您可以說,在具有embeddingdim維數的空間中,詞彙表中的單詞的數量被投影到其中。這一步確保類似的單詞(例如。boat & ship, man & boy, run & walk等)都位於這個空間附近。這意味著「男人」這個詞和「男孩」這個詞被預測的機率幾乎一樣(不是完全一樣),而且這兩個詞的意思也差不多。
接下來,嵌入的句子被輸入GRU。編碼器GRU的最終隱藏狀態成為解碼器GRU的初始隱藏狀態。編碼器中最後的GRU隱藏狀態包含源句的編碼或信息。源句的編碼也可以通過所有編碼器隱藏狀態的組合來提供[我們很快就會發現,這一事實對於注意力的概念的存在至關重要]。
class Encoder(tf.keras.Model):def __init__(self, english_vocab_size, embedding_dim, hidden_units): super(Encoder, self).__init__() self.embedding = tf.keras.layers.Embedding(english_vocab_size, embedding_dim) self.gru = tf.keras.layers.GRU(hidden_units, return_sequences = True, return_state = True) def call(self, input_sequence): x = self.embedding(input_sequence) encoder_sequence_output, final_encoder_state = self.gru(x) # Dimensions of encoder_sequence_output => (BATCH_SIZE, MAX_WORDS_IN_A_SENTENCE, hidden_units) # Dimensions of final_encoder_state => (BATCH_SIZE, hidden_units) return encoder_sequence_output, final_encoder_state# initialize our encoderencoder = Encoder(english_vocab_size, embedding_dim, hidden_units)
解碼器Decoder ( 未使用注意力機制)
注意:在本節中,我們將了解解碼器的情況下,不涉及注意力機制。這對於理解稍後與解碼器一起使用的注意力的作用非常重要。
解碼器GRU網絡是生成目標句的語言模型。最終的編碼器隱藏狀態作為解碼器GRU的初始隱藏狀態。第一個給解碼器GRU單元來預測下一個的單詞是一個像「sentencestart」這樣的開始標記。這個標記用於預測所有num_words數量的單詞出現的概率。訓練時使用預測的概率張量和實際單詞的一熱編碼來計算損失。這種損失被反向傳播以優化編碼器和解碼器的參數。同時,概率最大的單詞成為下一個GRU單元的輸入。重複上述步驟,直到出現像「sentenceend」這樣的結束標記。
這種方法的問題是:
信息瓶頸:如上所述,編碼器的最終隱藏狀態成為解碼器的初始隱藏狀態。這就造成了信息瓶頸,因為源句的所有信息都需要壓縮到最後的狀態,這也可能會偏向於句子末尾的信息,而不是句子中很久以前看到的信息。
解決方案:我們解決了上述問題,不僅依靠編碼器的最終狀態來獲取源句的信息,還使用了編碼器所有輸出的加權和。那麼,哪個編碼器的輸出比另一個更重要?注意力機制就是為了解決這個問題。
添加注意力機制
注意力不僅為瓶頸問題提供了解決方案,還為句子中的每個單詞賦予了權重(相當字面意義)。源序列在編碼器輸出中有它自己的的信息,在解碼器中被預測的字在相應的解碼器隱藏狀態中有它自己的的信息。我們需要知道哪個編碼器的輸出擁有類似的信息,我們需要知道在解碼器隱藏狀態下,哪個編碼器輸出的信息與解碼器隱藏狀態下的相似。
因此,這些編碼器輸出和解碼器的隱藏狀態被用作一個數學函數的輸入,從而得到一個注意力向量分數。當一個單詞被預測時(在解碼器中的每個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層的輸出被發送到一個稠密層,這個稠密層給出所有hindivocabsize的單詞出現的概率。具有高概率的單詞意味著模型認為這個單詞應該是下一個單詞。
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 decoderdecoder = 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()
測試
為了測試我們的模型在經過訓練後的執行情況,我們定義了一個函數,該函數接受一個英語句子,並按照模型的預測返回一個印地語句子。讓我們實現這個函數,我們將在下一節中看到結果的好壞。
我們接受英語句子,對其進行預處理,並將其轉換為長度為MAXWORDSINASENTENCE的序列或向量,如開頭的「預處理數據」部分所述。這個序列被輸入到我們訓練好的編碼器,編碼器返回編碼器序列輸出和編碼器的最終隱藏狀態。編碼器的最終隱藏狀態是解碼器的第一個隱藏狀態,解碼器的第一個輸入字是一個開始標記「sentencestart」。解碼器返回預測的字概率。概率最大的單詞成為我們預測的單詞,並被附加到最後的印地語句子中。這個字作為輸入進入下一個解碼器層。預測單詞的循環將繼續下去,直到解碼器預測結束標記「sentenceend」或單詞數量超過某個限制(我們將這個限制保持為MAXWORDSINASENTENCE的兩倍)。def get_sentence_from_sequences(sequences, tokenizer): return tokenizer.sequences_to_texts(sequences)# Testingdef 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 sentenceprint(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。
作者:Ayush Jain
deephub翻譯組