本系列文章通過通俗易懂的方式介紹強化學習的基本概念,雖然語言通俗,但是內容依舊非常嚴謹性。文中用很多的公式,對數學公式頭疼的讀者可能會被嚇住,但是如果讀者一步一步follow下來,就會發現公式的推導非常自然,對於透徹的理解這些基本概念非常有幫助。除了理論之外,文章還會介紹每種算法的實現代碼,深入解答每一行關鍵代碼。讓讀者不但理解理論和算法,同時還能知道怎麼用代碼來實現。通過理論與實際的結合,更加深入的理解學過的概念。讀者只需要基本的Python編程知識,文中每一個算法都有對應的Jupyter Notebook代碼。(文章來源,李理的Github博客)
本教程會介紹使用seq2seq模型實現一個chatbot,訓練數據來自Cornell電影對話語料庫。對話系統是目前的研究熱點,它在客服、可穿戴設備和智能家居等場景有廣泛應用。
傳統的對話系統要麼基於檢索的方法——提前準備一個問答庫,根據用戶的輸入尋找類似的問題和答案。這更像一個問答系統,它很難進行多輪的交互,而且答案是固定不變的。要麼基於預先設置的對話流程,這主要用於slot-filling(Task-Oriented)的任務,比如查詢機票需要用戶提供日期,達到城市等信息。這種方法的缺點是比較死板,如果用戶的意圖在設計的流程之外,那麼就無法處理,而且對話的流程也一般比較固定,要支持用戶隨意的話題內跳轉和話題間切換比較困難。
因此目前學術界的研究熱點是根據大量的對話數據,自動的End-to-End的使用Seq2Seq模型學習對話模型。它的好處是不需要人來設計這個對話流程,完全是數據驅動的方法。它的缺點是流程不受人(開發者)控制,在嚴肅的場景(比如客服)下使用會有比較大的風險,而且需要大量的對話數據,這在很多實際應用中是很難得到的。因此目前seq2seq模型的對話系統更多的是用於類似小冰的閒聊機器人上,最近也有不少論文研究把這種方法用於task-oriented的任務,但還不是太成熟,在業界還很少被使用。
效果
本文使用的Cornell電影對話語料庫就是偏向於閒聊的語料庫。
本教程的主要內容參考了PyTorch官方教程。讀者可以從這裡獲取完整代碼。 下面是這個教程實現的對話效果示例:
準備
首先我們通過下載連結下載訓練語料庫,這是一個zip文件,把它下載後解壓到項目目錄的子目錄data下。接下來我們導入需要用到的模塊,這主要是PyTorch的模塊:
加載和預處理數據
接下來我們需要對原始數據進行變換然後用合適的數據結構加載到內存裡。
Cornell電影對話語料庫是電影人物的對話數據,它包括:
10,292對電影人物(一部電影有多個人物,他們兩兩之間可能存在對話)的220,579個對話617部電影的9,035個人物總共304,713個utterance(utterance是對話中的語音片段,不一定是完整的句子)這個數據集是比較大並且多樣的(diverse),語言形式、時代和情感都有很多樣。這樣的數據可以使得我們的chatbot對於不同的輸入更加魯棒(robust)。
首先我們來看一下原始數據長什麼樣:
解壓後的目錄有很多文件,我們會用到的文件包括movie_lines.txt。上面的代碼輸出這個文件的前10行,結果如下:
注意:上面的move_lines.txt每行都是一個utterance,但是這個文件看不出哪些utterance是組成一段對話的,這需要 movie_conversations.txt 文件:
每一行用」+++$+++」分割成4列,第一列表示第一個人物的ID,第二列表示第二個人物的ID,第三列表示電影的ID,第四列表示這兩個人物在這部電影中的一段對話,比如第一行的表示人物u0和u2在電影m0中的一段對話包含ID為L194、L195、L196和L197的4個utterance。注意:兩個人物在一部電影中會有多段對話,中間可能穿插其他人之間的對話,而且即使中間沒有其他人說話,這兩個人物對話的內容從語義上也可能是屬於不同的對話(話題)。所以我們看到第二行還是u0和u2在電影m0中的對話,它包含L198和L199兩個utterance,L198是緊接著L197之後的,但是它們屬於兩個對話(話題)。
數據處理
為了使用方便,我們會把原始數據處理成一個新的文件,這個新文件的每一行都是用TAB分割問題(query)和答案(response)對。為了實現這個目的,我們首先定義一些用於parsing原始文件 movie_lines.txt 的輔助函數。
loadLines 把movie_lines.txt 文件切分成 (lineID, characterID, movieID, character, text)loadConversations 把上面的行group成一個個多輪的對話extractSentencePairs 從上面的每個對話中抽取句對
接下來我們利用上面的3個函數對原始數據進行處理,最終得到formatted_movie_lines.txt。
上面的代碼會生成一個新的文件formatted_movie_lines.txt,這文件每一行包含一對句對,用tab分割。下面是前十行:
創建詞典
接下來我們需要構建詞典然後把問答句對加載到內存裡。
我們的輸入是一個句對,每個句子都是詞的序列,但是機器學習只能處理數值,因此我們需要建立詞到數字ID的映射。
為此,我們會定義一個Voc類,它會保存詞到ID的映射,同時也保存反向的從ID到詞的映射。除此之外,它還記錄每個詞出現的次數,以及總共出現的詞的個數。這個類提供addWord方法來增加一個詞, addSentence方法來增加句子,也提供方法trim來去除低頻的詞。
有了上面的Voc類我們就可以通過問答句對來構建詞典了。但是在構建之前我們需要進行一些預處理。
首先我們需要使用函數unicodeToAscii來把unicode字符變成ascii,比如把à變成a。注意,這裡的代碼只是用於處理西方文字,如果是中文,這個函數直接會丟棄掉。接下來把所有字母變成小寫同時丟棄掉字母和常見標點(.!?)之外的所有字符。最後為了訓練收斂,我們會用函數filterPairs去掉長度超過MAX_LENGTH的句子(句對)。
上面的代碼的輸出為:
我們可以看到,原來共有221282個句對,經過處理後我們只保留了64271個句對。
另外為了收斂更快,我們可以去除掉一些低頻詞。這可以分為兩步:
1) 使用voc.trim函數去掉頻次低於MIN_COUNT 的詞。2) 去掉包含低頻詞的句子(只保留這樣的句子——每一個詞都是高頻的,也就是在voc中出現的)
代碼的輸出為:
18005個詞之中,頻次大於等於3的只有43%,去掉低頻的57%的詞之後,保留的句子為53165,佔比為82%。
為模型準備數據
前面我們構建了詞典,並且對訓練數據進行預處理並且濾掉一些句對,但是模型最終用到的是Tensor。最簡單的辦法是一次處理一個句對,那麼上面得到的句對直接就可以使用。但是為了加快訓練速度,尤其是重複利用GPU的並行能力,我們需要一次處理一個batch的數據。
對於某些問題,比如圖像來說,輸入可能是固定大小的(或者通過預處理縮放成固定大小),但是對於文本來說,我們很難把一個二十個詞的句子」縮放」成十個詞同時還保持語義不變。但是為了充分利用GPU等計算自由,我們又必須變成固定大小的Tensor,因此我們通常會使用Padding的技巧,把短的句子補充上零使得輸入大小是(batch, max_length),這樣通過一次就能實現一個batch數據的forward或者backward計算。當然padding的部分的結果是沒有意義的,比如某個句子實際長度是5,而max_length是10,那麼最終forward的輸出應該是第5個時刻的輸出,後面5個時刻計算是無用功。方向計算梯度的時候也是類似的,我們需要從第5個時刻開始反向計算梯度。為了提高效率,我們通常把長度接近的訓練數據放到一個batch裡面,這樣無用的計算是最少的。因此我們通常把全部訓練數據根據長度劃分成一些組,比如長度小於4的一組,長度4到8的一組,長度8到12的一組,…。然後每次隨機的選擇一個組,再隨機的從一組裡選擇batch個數據。不過本教程並沒有這麼做,而是每次隨機的從所有pair裡隨機選擇batch個數據。
原始的輸入通常是batch個list,表示batch個句子,因此自然的表示方法為(batch, max_length),這種表示方法第一維是batch,每移動一個下標得到的是一個樣本的max_length個詞(包括padding)。因為RNN的依賴關係,我們在計算t+1時刻必須知道t時刻的結果,因此我們無法用多個核同時計算一個樣本的forward。但是不同樣本之間是沒有依賴關係的,因此我們可以在根據t時刻batch樣本的當前狀態計算batch個樣本的輸出和新狀態,然後再計算t+2時刻,…。為了便於GPU一次取出t時刻的batch個數據,我們通常把輸入從(batch, max_length)變成(max_length, batch),這樣使得t時刻的batch個數據在內存(顯存)中是連續的,從而讀取效率更高。這個過程如下圖所示,原始輸入的大小是(batch=6, max_length=4),轉置之後變成(4,6)。這樣某個時刻的6個樣本數據在內存中是連續的。
因此我們會用一些工具函數來實現上述處理。
inputVar函數把batch個句子padding後變成一個LongTensor,大小是(max_length, batch),同時會返回一個大小是batch的list lengths,說明每個句子的實際長度,這個參數後面會傳給PyTorch,從而在forward和backward計算的時候使用實際的長度。
outputVar函數和inputVar類似,但是它輸出的第二個參數不是lengths,而是一個大小為(max_length, batch)的mask矩陣(tensor),某位是0表示這個位置是padding,1表示不是padding,這樣做的目的是後面計算方便。當然這兩種表示是等價的,只不過lengths表示更加緊湊,但是計算起來不同方便,而mask矩陣和outputVar直接相乘就可以把padding的位置給mask(變成0)掉,這在計算loss時會非常方便。
batch2TrainData 則利用上面的兩個函數把一個batch的句對處理成合適的輸入和輸出Tensor。
示例的輸出為:
我們可以看到input_variable的每一列表示一個樣本,而每一行表示batch(5)個樣本在這個時刻的值。而lengths表示真實的長度。類似的target_variable也是每一列表示一個樣本,而mask的shape和target_variable一樣,如果某個位置是0,則表示padding。
定義模型
Seq2Seq 模型
我們這個chatbot的核心是一個sequence-to-sequence(seq2seq)模型。 seq2seq模型的輸入是一個變長的序列,而輸出也是一個變長的序列。而且這兩個序列的長度並不相同。一般我們使用RNN來處理變長的序列,Sutskever等人的論文發現通過使用兩個RNN可以解決這類問題。這類問題的輸入和輸出都是變長的而且長度不一樣,包括問答系統、機器翻譯、自動摘要等等都可以使用seq2seq模型來解決。其中一個RNN叫做Encoder,它把變長的輸入序列編碼成一個固定長度的context向量,我們一般可以認為這個向量包含了輸入句子的語義。而第二個RNN叫做Decoder,初始隱狀態是Encoder的輸出context向量,輸入是(表示句子開始的特殊Token),然後用RNN計算第一個時刻的輸出,接著用第一個時刻的輸出和隱狀態計算第二個時刻的輸出和新的隱狀態,...,直到某個時刻輸出特殊的(表示句子結束的特殊Token)或者長度超過一個閾值。Seq2Seq模型如下圖所示。
Encoder
Encoder是個RNN,它會遍歷輸入的每一個Token(詞),每個時刻的輸入是上一個時刻的隱狀態和輸入,然後會有一個輸出和新的隱狀態。這個新的隱狀態會作為下一個時刻的輸入隱狀態。每個時刻都有一個輸出,對於seq2seq模型來說,我們通常只保留最後一個時刻的隱狀態,認為它編碼了整個句子的語義,但是後面我們會用到Attention機制,它還會用到Encoder每個時刻的輸出。Encoder處理結束後會把最後一個時刻的隱狀態作為Decoder的初始隱狀態。
實際我們通常使用多層的Gated Recurrent Unit(GRU)或者LSTM來作為Encoder,這裡使用GRU,讀者可以參考Cho等人2014年的[論文]。
此外我們會使用雙向的RNN,如下圖所示。
注意在接入RNN之前會有一個embedding層,用來把每一個詞(ID或者one-hot向量)映射成一個連續的稠密的向量,我們可以認為這個向量編碼了一個詞的語義。在我們的模型裡,我們把它的大小定義成和RNN的隱狀態大小一樣(但是並不是一定要一樣)。有了Embedding之後,模型會把相似的詞編碼成相似的向量(距離比較近)。
最後,為了把padding的batch數據傳給RNN,我們需要使用下面的兩個函數來進行pack和unpack,後面我們會詳細介紹它們。這兩個函數是:
torch.nn.utils.rnn.pack_padded_sequencetorch.nn.utils.rnn.pad_packed_sequence計算圖:
1) 把詞的ID通過Embedding層變成向量。 2) 把padding後的數據進行pack。 3) 傳入GRU進行Forward計算。 4) Unpack計算結果 5) 把雙向GRU的結果向量加起來。 6) 返回(所有時刻的)輸出和最後時刻的隱狀態。
輸入:
input_seq: 一個batch的輸入句子,shape是(max_length, batch_size)
input_lengths: 一個長度為batch的list,表示句子的實際長度。
hidden: 初始化隱狀態(通常是零),shape是(n_layers x num_directions, batch_size, hidden_size)
輸出:
outputs: 最後一層GRU的輸出向量(雙向的向量加在了一起),shape(max_length, batch_size, hidden_size)
hidden: 最後一個時刻的隱狀態,shape是(n_layers x num_directions, batch_size, hidden_size)
EncoderRNN代碼如下,請讀者詳細閱讀注釋。
Decoder
Decoder也是一個RNN,它每個時刻輸出一個詞。每個時刻的輸入是上一個時刻的隱狀態和上一個時刻的輸出。一開始的隱狀態是Encoder最後時刻的隱狀態,輸入是特殊的。然後使用RNN計算新的隱狀態和輸出第一個詞,接著用新的隱狀態和第一個詞計算第二個詞,...,直到遇到,結束輸出。普通的RNN Decoder的問題是它只依賴與Encoder最後一個時刻的隱狀態,雖然理論上這個隱狀態(context向量)可以編碼輸入句子的語義,但是實際會比較困難。因此當輸入句子很長的時候,效果會很長。
為了解決這個問題,Bahdanau等人在論文裡提出了注意力機制(attention mechanism),在Decoder進行t時刻計算的時候,除了t-1時刻的隱狀態,當前時刻的輸入,注意力機制還可以參考Encoder所有時刻的輸入。拿機器翻譯來說,我們在翻譯以句子的第t個詞的時候會把注意力機制在某個詞上。當然常見的注意力是一種soft的注意力,假設輸入有5個詞,注意力可能是一個概率,比如(0.6,0.1,0.1,0.1,0.1),表示當前最關注的是輸入的第一個詞。同時我們之前也計算出每個時刻的輸出向量,假設5個時刻分別是$y_1,…,y_5$,那麼我們可以用attention概率加權得到當前時刻的context向量$0.6y_1+0.1y_2+…+0.1y_5$。
注意力有很多方法計算,我們這裡介紹Luong等人在論文提出的方法。它是用當前時刻的GRU計算出的新的隱狀態來計算注意力得分,首先它用一個score函數計算這個隱狀態和Encoder的輸出的相似度得分,得分越大,說明越應該注意這個詞。然後再用softmax函數把score變成概率。那機器翻譯為例,在t時刻,$h_t$表示t時刻的GRU輸出的新的隱狀態,我們可以認為$h_t$表示當前需要翻譯的語義。通過計算$h_t$與$y_1,…,y_n$的得分,如果$h_t$與$y_1$的得分很高,那麼我們可以認為當前主要翻譯詞$x_1$的語義。有很多中score函數的計算方法,如下圖所示:
上式中$h_t$表示t時刻的隱狀態,比如第一種計算score的方法,直接計算$h_t$與$h_s$的內積,內積越大,說明這兩個向量越相似,因此注意力也更多的放到這個詞上。第二種方法也類似,只是引入了一個可以學習的矩陣,我們可以認為它先對$h_t$做一個線性變換,然後在與$h_s$計算內積。而第三種方法把它們拼接起來然後用一個全連接網絡來計算score。
注意,我們前面介紹的是分別計算$h_t$和$y_1$的內積、$h_t$和$y_2$的內積,…。但是為了效率,可以一次計算$h_t$與$h_s=[y_1,y_2,…,y_n]$的乘積。 計算過程如下圖所示。
上面的代碼實現了dot、general和concat三種score計算方法,分別和前面的三個公式對應,我們這裡介紹最簡單的dot方法。代碼裡也有一些注釋,只有dot_score函數比較難以理解,我們來分析一下。首先這個函數的輸入輸入hidden的shape是(1, batch=64, hidden_size=500),encoder_outputs的shape是(input_lengths=10, batch=64, hidden_size=500)。
怎麼計算hidden和10個encoder輸出向量的內積呢?為了簡便,我們先假設batch是1,這樣可以把第二維(batch維)去掉,因此hidden是(1, 500),而encoder_outputs是(10, 500)。內積的定義是兩個向量對應位相乘然後相加,但是encoder_outputs是10個500維的向量。當然我們可以寫一個for循環來計算,但是效率很低。這裡用到一個小的技巧,利用broadcasting,hidden * encoder_outputs可以理解為把hidden從(1,500)複製成(10, 500)(當然實際實現並不會這麼做),然後兩個(10, 500)的矩陣進行乘法。注意,這裡的乘法不是矩陣乘法,而是所謂的Hadamard乘法,其實就是把對應位置的乘起來,比如下面的例子:
因此hidden * encoder_outputs就可以把hidden向量(500個數)與encoder_outputs的10個向量(500個數)對應的位置相乘。而內積還需要把這500個乘積加起來,因此後面使用torch.sum(hidden * encoder_output, dim=2),把第2維500個乘積加起來,最終得到10個score值。當然我們實際還有一個batch維度,因此最終得到的attn_energies是(10, 64)。接著在forward函數裡把attn_energies轉置成(64, 10),然後使用softmax函數把10個score變成概率,shape仍然是(64, 10),為了後面使用方便,我們用unsqueeze(1)把它變成(64, 1, 10)。
有了注意力的子模塊之後,我們就可以實現Decoder了。Encoder可以一次把一個序列輸入GRU,得到整個序列的輸出。但是Decoder t時刻的輸入是t-1時刻的輸出,在t-1時刻計算完成之前是未知的,因此只能一次處理一個時刻的數據。因此Encoder的GRU的輸入是(max_length, batch, hidden_size),而Decoder的輸入是(1, batch, hidden_size)。此外Decoder只能利用前面的信息,所以只能使用單向(而不是雙向)的GRU,而Encoder的GRU是雙向的,如果兩種的hidden_size是一樣的,則Decoder的隱單元個數少了一半,那怎麼把Encoder的最後時刻的隱狀態作為Decoder的初始隱狀態呢?這裡是把每個時刻雙向結果加起來的,因此它們的大小就能匹配了(請讀者參考前面Encoder雙向相加的部分代碼)。
計算圖:
1) 把詞ID輸入Embedding層 2) 使用單向的GRU繼續Forward進行一個時刻的計算。 3) 使用新的隱狀態計算注意力權重 4) 用注意力權重得到context向量 5) context向量和GRU的輸出拼接起來,然後再進過一個全連接網絡,使得輸出大小仍然是hidden_size 6) 使用一個投影矩陣把輸出從hidden_size變成詞典大小,然後用softmax變成概率 7) 返回輸出和新的隱狀態
輸入:
input_step: shape是(1, batch_size)
last_hidden: 上一個時刻的隱狀態, shape是(n_layers x num_directions, batch_size, hidden_size)
encoder_outputs: encoder的輸出, shape是(max_length, batch_size, hidden_size)
輸出:
output: 當前時刻輸出每個詞的概率,shape是(batch_size, voc.num_words)
hidden: 新的隱狀態,shape是(n_layers x num_directions, batch_size, hidden_size)
定義訓練過程
Masked損失
forward實現之後,我們就需要計算loss。seq2seq有兩個RNN,Encoder RNN是沒有直接定義損失函數的,它是通過影響Decoder從而影響最終的輸出以及loss。Decoder輸出一個序列,前面我們介紹的是Decoder在預測時的過程,它的長度是不固定的,只有遇到EOS才結束。給定一個問答句對,我們可以把問題輸入Encoder,然後用Decoder得到一個輸出序列,但是這個輸出序列和」真實」的答案長度並不相同。
而且即使長度相同並且語義相似,也很難直接知道預測的答案和真實的答案是否類似。那麼我們怎麼計算loss呢?比如輸入是」What is your name?」,訓練數據中的答案是」I am LiLi」。假設模型有兩種預測:」I am fine」和」My name is LiLi」。從語義上顯然第二種答案更好,但是如果字面上比較的話可能第一種更好。
但是讓機器知道」I am LiLi」和」My name is LiLi」的語義很接近這是非常困難的,所以實際上我們通常還是通過字面上裡進行比較。我們會限制Decoder的輸出,使得Decoder的輸出長度和」真實」答案一樣,然後逐個時刻比較。Decoder輸出的是每個詞的概率分布,因此可以使用交叉熵損失函數。但是這裡還有一個問題,因為是一個batch的數據裡有一些是padding的,因此這些位置的預測是沒有必要計算loss的,因此我們需要使用前面的mask矩陣把對應位置的loss去掉,我們可以通過下面的函數來實現計算Masked的loss。
上面的代碼有幾個需要注意的地方。首先是masked_select函數,我們來看一個例子:
它要求mask和被mask的tensor的shape是一樣的,然後從crossEntropy選出mask值為1的那些值。輸出的維度會減1。
另外為了實現交叉熵這裡使用了gather函數,這是一種比較底層的實現方法,更簡便的方法應該使用CrossEntropyLoss或者NLLLoss,其中CrossEntropy等價與LogSoftmax+NLLLoss。
交叉熵的定義為:$H(p,q)=-\sum_xp(x)logq(x)$。其中p和q是兩個隨機變量的概率分布,這裡是離散的隨機變量,如果是連續的需要把求和變成積分。在我們這裡p是真實的分布,也就是one-hot的,而q是模型預測的softmax的輸出。因為p是one-hot的,所以只需要計算真實分類對應的那個值。
比如假設一個5分類的問題,當前正確分類是2(下標從0-4),而模型的預測是(0.1,0.1,0.4,0.2,0.2),則H=-log(0.4)。用交叉熵作為分類的Loss是比較合理的,正確的分類是2,那麼模型在下標為2的地方預測的概率$q_2$越大,則$-logq_2$越小,也就是loss越小。
假設inp是:
也就是batch=2,而分類數(詞典大小)是4,inp是模型預測的分類概率。 而target = [2,3] ,表示第一個樣本的正確分類是第三個類別(概率是0.4),第二個樣本的正確分類是第四個類別(概率是0.3)。因此我們需要計算的是 -log(0.4) - log(0.3)。怎麼不用for循環求出來呢?我們可以使用torch.gather函數首先把0.4和0.3選出來:
一次迭代的訓練過程
函數train實現一個batch數據的訓練。前面我們提到過,在訓練的時候我們會限制Decoder的輸出,使得Decoder的輸出長度和」真實」答案一樣長。但是我們在訓練的時候如果讓Decoder自行輸出,那麼收斂可能會比較慢,因為Decoder在t時刻的輸入來自t-1時刻的輸出。如果前面預測錯了,那麼後面很可能都會錯下去。另外一種方法叫做teacher forcing,它不管模型在t-1時刻做什麼預測都把t-1時刻的正確答案作為t時刻的輸入。但是如果只用teacher forcing也有問題,因為在真實的Decoder的是是沒有老師來幫它糾正錯誤的。所以比較好的方法是更加一個teacher_forcing_ratio參數隨機的來確定本次訓練是否teacher forcing。
另外使用到的一個技巧是梯度裁剪(gradient clipping) 。這個技巧通常是為了防止梯度爆炸(exploding gradient),它把參數限制在一個範圍之內,從而可以避免梯度的梯度過大或者出現NaN等問題。注意:雖然它的名字叫梯度裁剪,但實際它是對模型的參數進行裁剪,它把整個參數看成一個向量,如果這個向量的模大於max_norm,那麼就把這個向量除以一個值使得模等於max_norm,因此也等價於把這個向量投影到半徑為max_norm的球上。它的效果如下圖所示。
操作步驟:
1) 把整個batch的輸入傳入encoder 2) 把decoder的輸入設置為特殊的,初始隱狀態設置為encoder最後時刻的隱狀態 3) decoder每次處理一個時刻的forward計算 4) 如果是teacher forcing,把上個時刻的"正確的"詞作為當前輸入,否則用上一個時刻的輸出作為當前時刻的輸入 5) 計算loss 6) 反向計算梯度 7) 對梯度進行裁剪 8) 更新模型(包括encoder和decoder)參數
注意,PyTorch的RNN模塊(RNN, LSTM, GRU)也可以當成普通的非循環的網絡來使用。在Encoder部分,我們是直接把所有時刻的數據都傳入RNN,讓它一次計算出所有的結果,但是在Decoder的時候(非teacher forcing)後一個時刻的輸入來自前一個時刻的輸出,因此無法一次計算。
訓練迭代過程
最後是把前面的代碼組合起來進行訓練。函數trainIters用於進行n_iterations次minibatch的訓練。
值得注意的是我們定期會保存模型,我們會保存一個tar包,包括encoder和decoder的state_dicts(參數),優化器(optimizers)的state_dicts, loss和迭代次數。這樣保存模型的好處是從中恢復後我們既可以進行預測也可以進行訓練(因為有優化器的參數和迭代的次數)。
效果測試
模型訓練完成之後,我們需要測試它的效果。最簡單直接的方法就是和chatbot來聊天。因此我們需要用Decoder來生成一個響應。
貪心解碼(Greedy decoding)算法
最簡單的解碼算法是貪心算法,也就是每次都選擇概率最高的那個詞,然後把這個詞作為下一個時刻的輸入,直到遇到EOS結束解碼或者達到一個最大長度。但是貪心算法不一定能得到最優解,因為某個答案可能開始的幾個詞的概率並不太高,但是後來概率會很大。因此除了貪心算法,我們通常也可以使用Beam-Search算法,也就是每個時刻保留概率最高的Top K個結果,然後下一個時刻嘗試把這K個結果輸入(當然需要能恢復RNN的狀態),然後再從中選擇概率最高的K個。
為了實現貪心解碼算法,我們定義一個GreedySearchDecoder類。這個類的forwar的方法需要傳入一個輸入序列(input_seq),其shape是(input_seq length, 1), 輸入長度input_length和最大輸出長度max_length。就是過程如下:
1) 把輸入傳給Encoder,得到所有時刻的輸出和最後一個時刻的隱狀態。 2) 把Encoder最後時刻的隱狀態作為Decoder的初始狀態。 3) Decoder的第一輸入初始化為SOS。 4) 定義保存解碼結果的tensor 5) 循環直到最大解碼長度 a) 把當前輸入傳入Decoder b) 得到概率最大的詞以及概率 c) 把這個詞和概率保存下來 d) 把當前輸出的詞作為下一個時刻的輸入 6) 返回所有的詞和概率
測試對話函數
解碼方法完成後,我們寫一個函數來測試從終端輸入一個句子然後來看看chatbot的回覆。我們需要用前面的函數來把句子分詞,然後變成ID傳入解碼器,得到輸出的ID後再轉換成文字。我們會實現一個evaluate函數,由它來完成這些工作。我們需要把一個句子變成輸入需要的格式——shape為(batch, max_length),即使只有一個輸入也需要增加一個batch維度。我們首先把句子分詞,然後變成ID的序列,然後轉置成合適的格式。此外我們還需要創建一個名為lengths的tensor,雖然只有一個,來表示輸入的實際長度。接著我們構造類GreedySearchDecoder的實例searcher,然後用searcher來進行解碼得到輸出的ID,最後我們把這些ID變成詞並且去掉EOS之後的內容。
另外一個evaluateInput函數作為chatbot的用戶接口,當運行它的時候,它會首先提示用戶輸入一個句子,然後使用evaluate來生成回復。然後繼續對話直到用戶輸入」q」或者」quit」。如果用戶輸入的詞不在詞典裡,我們會輸出錯誤信息(當然還有一種辦法是忽略這些詞)然後提示用戶重新輸入。
訓練和測試模型
最後我們可以來訓練模型和進行評測了。
不論是我們像訓練模型還是測試對話,我們都需要初始化encoder和decoder模型參數。在下面的代碼,我們從頭開始訓練模型或者從某個checkpoint加載模型。讀者可以嘗試不同的超參數配置來進行調優。
訓練
下面的代碼進行訓練,我們需要設置一些訓練的超參數。初始化優化器,最後調用函數trainIters進行訓練。
測試
我們使用下面的代碼進行測試。
下面是測試的一些例子:
結論
上面介紹了怎麼從零開始訓練一個chatbot,讀者可以用自己的數據訓練一個chatbot試試,看看能不能用來解決一些實際業務問題。(雷鋒網雷鋒網雷鋒網)