本教程會(huì)介紹使用seq2seq模型實(shí)現(xiàn)一個(gè)chatbot,訓(xùn)練數(shù)據(jù)來自Cornell電影對(duì)話語料庫。對(duì)話系統(tǒng)是目前的研究熱點(diǎn),它在客服、可穿戴設(shè)備和智能家居等場(chǎng)景有廣泛應(yīng)用。
傳統(tǒng)的對(duì)話系統(tǒng)要么基于檢索的方法——提前準(zhǔn)備一個(gè)問答庫,根據(jù)用戶的輸入尋找類似的問題和答案。這更像一個(gè)問答系統(tǒng),它很難進(jìn)行多輪的交互,而且答案是固定不變的。要么基于預(yù)先設(shè)置的對(duì)話流程,這主要用于slot-filling(Task-Oriented)的任務(wù),比如查詢機(jī)票需要用戶提供日期,達(dá)到城市等信息。這種方法的缺點(diǎn)是比較死板,如果用戶的意圖在設(shè)計(jì)的流程之外,那么就無法處理,而且對(duì)話的流程也一般比較固定,要支持用戶隨意的話題內(nèi)跳轉(zhuǎn)和話題間切換比較困難。
因此目前學(xué)術(shù)界的研究熱點(diǎn)是根據(jù)大量的對(duì)話數(shù)據(jù),自動(dòng)的End-to-End的使用Seq2Seq模型學(xué)習(xí)對(duì)話模型。它的好處是不需要人來設(shè)計(jì)這個(gè)對(duì)話流程,完全是數(shù)據(jù)驅(qū)動(dòng)的方法。它的缺點(diǎn)是流程不受人(開發(fā)者)控制,在嚴(yán)肅的場(chǎng)景(比如客服)下使用會(huì)有比較大的風(fēng)險(xiǎn),而且需要大量的對(duì)話數(shù)據(jù),這在很多實(shí)際應(yīng)用中是很難得到的。因此目前seq2seq模型的對(duì)話系統(tǒng)更多的是用于類似小冰的閑聊機(jī)器人上,最近也有不少論文研究把這種方法用于task-oriented的任務(wù),但還不是太成熟,在業(yè)界還很少被使用。
效果
本文使用的Cornell電影對(duì)話語料庫(https://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html)就是偏向于閑聊的語料庫。
本教程的主要內(nèi)容參考了PyTorch 官方教程(https://pytorch.org/tutorials/beginner/chatbot_tutorial.html)。
讀者可以(https://github.com/fancyerii/blog-codes)獲取完整代碼。 下面是這個(gè)教程實(shí)現(xiàn)的對(duì)話效果示例:
準(zhǔn)備
首先我們通過下載鏈接(http://www.cs.cornell.edu/~cristian/data/cornell_movie_dialogs_corpus.zip)下載訓(xùn)練語料庫,這是一個(gè)zip文件,把它下載后解壓到項(xiàng)目目錄的子目錄data下。接下來我們導(dǎo)入需要用到的模塊,這主要是PyTorch的模塊:
加載和預(yù)處理數(shù)據(jù)
接下來我們需要對(duì)原始數(shù)據(jù)進(jìn)行變換然后用合適的數(shù)據(jù)結(jié)構(gòu)加載到內(nèi)存里。
Cornell電影對(duì)話語料庫(https://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html)是電影人物的對(duì)話數(shù)據(jù),它包括:
10,292對(duì)電影人物(一部電影有多個(gè)人物,他們兩兩之間可能存在對(duì)話)的220,579個(gè)對(duì)話
617部電影的9,035個(gè)人物
總共304,713個(gè)utterance(utterance是對(duì)話中的語音片段,不一定是完整的句子)
這個(gè)數(shù)據(jù)集是比較大并且多樣的(diverse),語言形式、時(shí)代和情感都有很多樣。這樣的數(shù)據(jù)可以使得我們的chatbot對(duì)于不同的輸入更加魯棒(robust)。
首先我們來看一下原始數(shù)據(jù)長(zhǎng)什么樣:
解壓后的目錄有很多文件,我們會(huì)用到的文件包括movie_lines.txt。上面的代碼輸出這個(gè)文件的前10行,結(jié)果如下:
注意:上面的move_lines.txt每行都是一個(gè)utterance,但是這個(gè)文件看不出哪些utterance是組成一段對(duì)話的,這需要movie_conversations.txt文件:
每一行用”+++$+++”分割成4列,第一列表示第一個(gè)人物的ID,第二列表示第二個(gè)人物的ID,第三列表示電影的ID,第四列表示這兩個(gè)人物在這部電影中的一段對(duì)話,比如第一行的表示人物u0和u2在電影m0中的一段對(duì)話包含ID為L(zhǎng)194、L195、L196和L197的4個(gè)utterance。注意:兩個(gè)人物在一部電影中會(huì)有多段對(duì)話,中間可能穿插其他人之間的對(duì)話,而且即使中間沒有其他人說話,這兩個(gè)人物對(duì)話的內(nèi)容從語義上也可能是屬于不同的對(duì)話(話題)。所以我們看到第二行還是u0和u2在電影m0中的對(duì)話,它包含L198和L199兩個(gè)utterance,L198是緊接著L197之后的,但是它們屬于兩個(gè)對(duì)話(話題)。
數(shù)據(jù)處理
為了使用方便,我們會(huì)把原始數(shù)據(jù)處理成一個(gè)新的文件,這個(gè)新文件的每一行都是用TAB分割問題(query)和答案(response)對(duì)。為了實(shí)現(xiàn)這個(gè)目的,我們首先定義一些用于parsing原始文件movie_lines.txt的輔助函數(shù)。
loadLines把movie_lines.txt文件切分成 (lineID, characterID, movieID, character, text)
loadConversations把上面的行g(shù)roup成一個(gè)個(gè)多輪的對(duì)話
extractSentencePairs從上面的每個(gè)對(duì)話中抽取句對(duì)
接下來我們利用上面的3個(gè)函數(shù)對(duì)原始數(shù)據(jù)進(jìn)行處理,最終得到formatted_movie_lines.txt。
上面的代碼會(huì)生成一個(gè)新的文件formatted_movie_lines.txt,這文件每一行包含一對(duì)句對(duì),用tab分割。下面是前十行:
b"Canwemakethisquick?RoxanneKorrineandAndrewBarrettarehavinganincrediblyhorrendouspublicbreak-uponthequad.Again.\tWell,Ithoughtwe'dstartwithpronunciation,ifthat'sokaywithyou.\n"b"Well,Ithoughtwe'dstartwithpronunciation,ifthat'sokaywithyou.\tNotthehackingandgaggingandspittingpart.Please.\n"b"Notthehackingandgaggingandspittingpart.Please.\tOkay...thenhow'boutwetryoutsomeFrenchcuisine.Saturday?Night?\n"b"You'reaskingmeout.That'ssocute.What'syournameagain?\tForgetit.\n"b"No,no,it'smyfault--wedidn'thaveaproperintroduction---\tCameron.\n"b"Cameron.\tThethingis,Cameron--I'matthemercyofaparticularlyhideousbreedofloser.Mysister.Ican'tdateuntilshedoes.\n"b"Thethingis,Cameron--I'matthemercyofaparticularlyhideousbreedofloser.Mysister.Ican'tdateuntilshedoes.\tSeemslikeshecouldgetadateeasyenough...\n"b'Why?\tUnsolvedmystery.Sheusedtobereallypopularwhenshestartedhighschool,thenitwasjustlikeshegotsickofitorsomething.\n'b"Unsolvedmystery.Sheusedtobereallypopularwhenshestartedhighschool,thenitwasjustlikeshegotsickofitorsomething.\tThat'sashame.\n"b'Gosh,ifonlywecouldfindKataboyfriend...\tLetmeseewhatIcando.\n'
創(chuàng)建詞典
接下來我們需要構(gòu)建詞典然后把問答句對(duì)加載到內(nèi)存里。
我們的輸入是一個(gè)句對(duì),每個(gè)句子都是詞的序列,但是機(jī)器學(xué)習(xí)只能處理數(shù)值,因此我們需要建立詞到數(shù)字ID的映射。
為此,我們會(huì)定義一個(gè)Voc類,它會(huì)保存詞到ID的映射,同時(shí)也保存反向的從ID到詞的映射。除此之外,它還記錄每個(gè)詞出現(xiàn)的次數(shù),以及總共出現(xiàn)的詞的個(gè)數(shù)。這個(gè)類提供addWord方法來增加一個(gè)詞,addSentence方法來增加句子,也提供方法trim來去除低頻的詞。
有了上面的Voc類我們就可以通過問答句對(duì)來構(gòu)建詞典了。但是在構(gòu)建之前我們需要進(jìn)行一些預(yù)處理。
首先我們需要使用函數(shù)unicodeToAscii來把unicode字符變成ascii,比如把à變成a。注意,這里的代碼只是用于處理西方文字,如果是中文,這個(gè)函數(shù)直接會(huì)丟棄掉。接下來把所有字母變成小寫同時(shí)丟棄掉字母和常見標(biāo)點(diǎn)(.!?)之外的所有字符。最后為了訓(xùn)練收斂,我們會(huì)用函數(shù)filterPairs去掉長(zhǎng)度超過MAX_LENGTH的句子(句對(duì))。
上面的代碼的輸出為:
Startpreparingtrainingdata...Readinglines...Read221282sentencepairsTrimmedto64271sentencepairsCountingwords...Countedwords:18008
我們可以看到,原理共有221282個(gè)句對(duì),經(jīng)過處理后我們值保留了64271個(gè)句對(duì)。
另外為了收斂更快,我們可以去除掉一些低頻詞。這可以分為兩步:
1) 使用voc.trim函數(shù)去掉頻次低于MIN_COUNT的詞。
2) 去掉包含低頻詞的句子(只保留這樣的句子——每一個(gè)詞都是高頻的,也就是在voc中出現(xiàn)的)
MIN_COUNT=3#閾值為3deftrimRareWords(voc,pairs,MIN_COUNT):#去掉voc中頻次小于3的詞voc.trim(MIN_COUNT)#保留的句對(duì)keep_pairs=[]forpairinpairs:input_sentence=pair[0]output_sentence=pair[1]keep_input=Truekeep_output=True#檢查問題forwordininput_sentence.split(''):ifwordnotinvoc.word2index:keep_input=Falsebreak#檢查答案forwordinoutput_sentence.split(''):ifwordnotinvoc.word2index:keep_output=Falsebreak#如果問題和答案都只包含高頻詞,我們才保留這個(gè)句對(duì)ifkeep_inputandkeep_output:keep_pairs.append(pair)print("Trimmedfrom{}pairsto{},{:.4f}oftotal".format(len(pairs),len(keep_pairs),len(keep_pairs)/len(pairs)))returnkeep_pairs#實(shí)際進(jìn)行處理pairs=trimRareWords(voc,pairs,MIN_COUNT)
代碼的輸出為:
keep_words7823/18005=0.4345Trimmedfrom64271pairsto53165,0.8272oftotal
18005個(gè)詞之中,頻次大于等于3的只有43%,去掉低頻的57%的詞之后,保留的句子為53165,占比為82%。
為模型準(zhǔn)備數(shù)據(jù)
前面我們構(gòu)建了詞典,并且對(duì)訓(xùn)練數(shù)據(jù)進(jìn)行預(yù)處理并且濾掉一些句對(duì),但是模型最終用到的是Tensor。最簡(jiǎn)單的辦法是一次處理一個(gè)句對(duì),那么上面得到的句對(duì)直接就可以使用。但是為了加快訓(xùn)練速度,尤其是重復(fù)利用GPU的并行能力,我們需要一次處理一個(gè)batch的數(shù)據(jù)。
對(duì)于某些問題,比如圖像來說,輸入可能是固定大小的(或者通過預(yù)處理縮放成固定大小),但是對(duì)于文本來說,我們很難把一個(gè)二十個(gè)詞的句子”縮放”成十個(gè)詞同時(shí)還保持語義不變。但是為了充分利用GPU等計(jì)算自由,我們又必須變成固定大小的Tensor,因此我們通常會(huì)使用Padding的技巧,把短的句子補(bǔ)充上零使得輸入大小是(batch, max_length),這樣通過一次就能實(shí)現(xiàn)一個(gè)batch數(shù)據(jù)的forward或者backward計(jì)算。當(dāng)然padding的部分的結(jié)果是沒有意義的,比如某個(gè)句子實(shí)際長(zhǎng)度是5,而max_length是10,那么最終forward的輸出應(yīng)該是第5個(gè)時(shí)刻的輸出,后面5個(gè)時(shí)刻計(jì)算是無用功。方向計(jì)算梯度的時(shí)候也是類似的,我們需要從第5個(gè)時(shí)刻開始反向計(jì)算梯度。為了提高效率,我們通常把長(zhǎng)度接近的訓(xùn)練數(shù)據(jù)放到一個(gè)batch里面,這樣無用的計(jì)算是最少的。因此我們通常把全部訓(xùn)練數(shù)據(jù)根據(jù)長(zhǎng)度劃分成一些組,比如長(zhǎng)度小于4的一組,長(zhǎng)度4到8的一組,長(zhǎng)度8到12的一組,…。然后每次隨機(jī)的選擇一個(gè)組,再隨機(jī)的從一組里選擇batch個(gè)數(shù)據(jù)。不過本教程并沒有這么做,而是每次隨機(jī)的從所有pair里隨機(jī)選擇batch個(gè)數(shù)據(jù)。
原始的輸入通常是batch個(gè)list,表示batch個(gè)句子,因此自然的表示方法為(batch, max_length),這種表示方法第一維是batch,每移動(dòng)一個(gè)下標(biāo)得到的是一個(gè)樣本的max_length個(gè)詞(包括padding)。因?yàn)镽NN的依賴關(guān)系,我們?cè)谟?jì)算t+1時(shí)刻必須知道t時(shí)刻的結(jié)果,因此我們無法用多個(gè)核同時(shí)計(jì)算一個(gè)樣本的forward。但是不同樣本之間是沒有依賴關(guān)系的,因此我們可以在根據(jù)t時(shí)刻batch樣本的當(dāng)前狀態(tài)計(jì)算batch個(gè)樣本的輸出和新狀態(tài),然后再計(jì)算t+2時(shí)刻,…。為了便于GPU一次取出t時(shí)刻的batch個(gè)數(shù)據(jù),我們通常把輸入從(batch, max_length)變成(max_length, batch),這樣使得t時(shí)刻的batch個(gè)數(shù)據(jù)在內(nèi)存(顯存)中是連續(xù)的,從而讀取效率更高。這個(gè)過程如下圖所示,原始輸入的大小是(batch=6, max_length=4),轉(zhuǎn)置之后變成(4,6)。這樣某個(gè)時(shí)刻的6個(gè)樣本數(shù)據(jù)在內(nèi)存中是連續(xù)的。
因此我們會(huì)用一些工具函數(shù)來實(shí)現(xiàn)上述處理。
inputVar函數(shù)把batch個(gè)句子padding后變成一個(gè)LongTensor,大小是(max_length, batch),同時(shí)會(huì)返回一個(gè)大小是batch的list lengths,說明每個(gè)句子的實(shí)際長(zhǎng)度,這個(gè)參數(shù)后面會(huì)傳給PyTorch,從而在forward和backward計(jì)算的時(shí)候使用實(shí)際的長(zhǎng)度。
outputVar函數(shù)和inputVar類似,但是它輸出的第二個(gè)參數(shù)不是lengths,而是一個(gè)大小為(max_length, batch)的mask矩陣(tensor),某位是0表示這個(gè)位置是padding,1表示不是padding,這樣做的目的是后面計(jì)算方便。當(dāng)然這兩種表示是等價(jià)的,只不過lengths表示更加緊湊,但是計(jì)算起來不同方便,而mask矩陣和outputVar直接相乘就可以把padding的位置給mask(變成0)掉,這在計(jì)算loss時(shí)會(huì)非常方便。
batch2TrainData則利用上面的兩個(gè)函數(shù)把一個(gè)batch的句對(duì)處理成合適的輸入和輸出Tensor。
#把句子的詞變成IDdefindexesFromSentence(voc,sentence):return[voc.word2index[word]forwordinsentence.split('')]+[EOS_token]#l是多個(gè)長(zhǎng)度不同句子(list),使用zip_longestpadding成定長(zhǎng),長(zhǎng)度為最長(zhǎng)句子的長(zhǎng)度。defzeroPadding(l,fillvalue=PAD_token):returnlist(itertools.zip_longest(*l,fillvalue=fillvalue))#l是二維的padding后的list#返回m和l的大小一樣,如果某個(gè)位置是padding,那么值為0,否則為1defbinaryMatrix(l,value=PAD_token):m=[]fori,seqinenumerate(l):m.append([])fortokeninseq:iftoken==PAD_token:m[i].append(0)else:m[i].append(1)returnm#把輸入句子變成ID,然后再padding,同時(shí)返回lengths這個(gè)list,標(biāo)識(shí)實(shí)際長(zhǎng)度。#返回的padVar是一個(gè)LongTensor,shape是(batch,max_length),#lengths是一個(gè)list,長(zhǎng)度為(batch,),表示每個(gè)句子的實(shí)際長(zhǎng)度。definputVar(l,voc):indexes_batch=[indexesFromSentence(voc,sentence)forsentenceinl]lengths=torch.tensor([len(indexes)forindexesinindexes_batch])padList=zeroPadding(indexes_batch)padVar=torch.LongTensor(padList)returnpadVar,lengths#對(duì)輸出句子進(jìn)行padding,然后用binaryMatrix得到每個(gè)位置是padding(0)還是非padding,#同時(shí)返回最大最長(zhǎng)句子的長(zhǎng)度(也就是padding后的長(zhǎng)度)#返回值padVar是LongTensor,shape是(batch,max_target_length)#mask是ByteTensor,shape也是(batch,max_target_length)defoutputVar(l,voc):indexes_batch=[indexesFromSentence(voc,sentence)forsentenceinl]max_target_len=max([len(indexes)forindexesinindexes_batch])padList=zeroPadding(indexes_batch)mask=binaryMatrix(padList)mask=torch.ByteTensor(mask)padVar=torch.LongTensor(padList)returnpadVar,mask,max_target_len#處理一個(gè)batch的pair句對(duì)defbatch2TrainData(voc,pair_batch):#按照句子的長(zhǎng)度(詞數(shù))排序pair_batch.sort(key=lambdax:len(x[0].split("")),reverse=True)input_batch,output_batch=[],[]forpairinpair_batch:input_batch.append(pair[0])output_batch.append(pair[1])inp,lengths=inputVar(input_batch,voc)output,mask,max_target_len=outputVar(output_batch,voc)returninp,lengths,output,mask,max_target_len#示例small_batch_size=5batches=batch2TrainData(voc,[random.choice(pairs)for_inrange(small_batch_size)])input_variable,lengths,target_variable,mask,max_target_len=batchesprint("input_variable:",input_variable)print("lengths:",lengths)print("target_variable:",target_variable)print("mask:",mask)print("max_target_len:",max_target_len)
示例的輸出為:
input_variable:tensor([[92,101,76,50,34],[7,250,37,6,4],[123,279,628,2,2],[40,75,4,0,0],[359,53,7216,0,0],[2763,217,4,0,0],[637,4,2,0,0],[6,2,0,0,0],[2,0,0,0,0]])lengths:tensor([9,8,7,3,3])target_variable:tensor([[25,34,404,7,25],[283,4,76,24,1464],[25,2,37,4,70],[72,0,7217,2,1465],[829,0,4,0,6],[234,0,2,0,2],[4,0,0,0,0],[2,0,0,0,0]])mask:tensor([[1,1,1,1,1],[1,1,1,1,1],[1,1,1,1,1],[1,0,1,1,1],[1,0,1,0,1],[1,0,1,0,1],[1,0,0,0,0],[1,0,0,0,0]],dtype=torch.uint8)max_target_len:8
我們可以看到input_variable的每一列表示一個(gè)樣本,而每一行表示batch(5)個(gè)樣本在這個(gè)時(shí)刻的值。而lengths表示真實(shí)的長(zhǎng)度。類似的target_variable也是每一列表示一個(gè)樣本,而mask的shape和target_variable一樣,如果某個(gè)位置是0,則表示padding。
定義模型
Seq2Seq 模型
我們這個(gè)chatbot的核心是一個(gè)sequence-to-sequence(seq2seq)模型。 seq2seq模型的輸入是一個(gè)變長(zhǎng)的序列,而輸出也是一個(gè)變長(zhǎng)的序列。而且這兩個(gè)序列的長(zhǎng)度并不相同。一般我們使用RNN來處理變長(zhǎng)的序列,Sutskever等人的論文發(fā)現(xiàn)通過使用兩個(gè)RNN可以解決這類問題。這類問題的輸入和輸出都是變長(zhǎng)的而且長(zhǎng)度不一樣,包括問答系統(tǒng)、機(jī)器翻譯、自動(dòng)摘要等等都可以使用seq2seq模型來解決。
其中一個(gè)RNN叫做Encoder,它把變長(zhǎng)的輸入序列編碼成一個(gè)固定長(zhǎng)度的context向量,我們一般可以認(rèn)為這個(gè)向量包含了輸入句子的語義。而第二個(gè)RNN叫做Decoder,初始隱狀態(tài)是Encoder的輸出context向量,輸入是(表示句子開始的特殊Token),然后用RNN計(jì)算第一個(gè)時(shí)刻的輸出,接著用第一個(gè)時(shí)刻的輸出和隱狀態(tài)計(jì)算第二個(gè)時(shí)刻的輸出和新的隱狀態(tài),...,直到某個(gè)時(shí)刻輸出特殊的(表示句子結(jié)束的特殊Token)或者長(zhǎng)度超過一個(gè)閾值。Seq2Seq模型如下圖所示。
Encoder
Encoder是個(gè)RNN,它會(huì)遍歷輸入的每一個(gè)Token(詞),每個(gè)時(shí)刻的輸入是上一個(gè)時(shí)刻的隱狀態(tài)和輸入,然后會(huì)有一個(gè)輸出和新的隱狀態(tài)。這個(gè)新的隱狀態(tài)會(huì)作為下一個(gè)時(shí)刻的輸入隱狀態(tài)。每個(gè)時(shí)刻都有一個(gè)輸出,對(duì)于seq2seq模型來說,我們通常只保留最后一個(gè)時(shí)刻的隱狀態(tài),認(rèn)為它編碼了整個(gè)句子的語義,但是后面我們會(huì)用到Attention機(jī)制,它還會(huì)用到Encoder每個(gè)時(shí)刻的輸出。Encoder處理結(jié)束后會(huì)把最后一個(gè)時(shí)刻的隱狀態(tài)作為Decoder的初始隱狀態(tài)。
實(shí)際我們通常使用多層的Gated Recurrent Unit(GRU)或者LSTM來作為Encoder,這里使用GRU,讀者可以參考Cho等人2014年的[論文]。
此外我們會(huì)使用雙向的RNN,如下圖所示。
注意在接入RNN之前會(huì)有一個(gè)embedding層,用來把每一個(gè)詞(ID或者one-hot向量)映射成一個(gè)連續(xù)的稠密的向量,我們可以認(rèn)為這個(gè)向量編碼了一個(gè)詞的語義。在我們的模型里,我們把它的大小定義成和RNN的隱狀態(tài)大小一樣(但是并不是一定要一樣)。有了Embedding之后,模型會(huì)把相似的詞編碼成相似的向量(距離比較近)。
最后,為了把padding的batch數(shù)據(jù)傳給RNN,我們需要使用下面的兩個(gè)函數(shù)來進(jìn)行pack和unpack,后面我們會(huì)詳細(xì)介紹它們。這兩個(gè)函數(shù)是:
torch.nn.utils.rnn.pack_padded_sequence
torch.nn.utils.rnn.pad_packed_sequence
計(jì)算圖:
1) 把詞的ID通過Embedding層變成向量。
2) 把padding后的數(shù)據(jù)進(jìn)行pack。
3) 傳入GRU進(jìn)行Forward計(jì)算。
4) Unpack計(jì)算結(jié)果
5) 把雙向GRU的結(jié)果向量加起來。
6) 返回(所有時(shí)刻的)輸出和最后時(shí)刻的隱狀態(tài)。
輸入:
input_seq: 一個(gè)batch的輸入句子,shape是(max_length, batch_size)
input_lengths: 一個(gè)長(zhǎng)度為batch的list,表示句子的實(shí)際長(zhǎng)度。
hidden: 初始化隱狀態(tài)(通常是零),shape是(n_layers x num_directions, batch_size, hidden_size)
輸出:
outputs: 最后一層GRU的輸出向量(雙向的向量加在了一起),shape(max_length, batch_size, hidden_size)
hidden: 最后一個(gè)時(shí)刻的隱狀態(tài),shape是(n_layers x num_directions, batch_size, hidden_size)
EncoderRNN代碼如下,請(qǐng)讀者詳細(xì)閱讀注釋。
classEncoderRNN(nn.Module):def__init__(self,hidden_size,embedding,n_layers=1,dropout=0):super(EncoderRNN,self).__init__()self.n_layers=n_layersself.hidden_size=hidden_sizeself.embedding=embedding#初始化GRU,這里輸入和hidden大小都是hidden_size,因?yàn)槲覀冞@里假設(shè)embedding層的輸出大小是hidden_size#如果只有一層,那么不進(jìn)行Dropout,否則使用傳入的參數(shù)dropout進(jìn)行GRU的Dropout。self.gru=nn.GRU(hidden_size,hidden_size,n_layers,dropout=(0ifn_layers==1elsedropout),bidirectional=True)defforward(self,input_seq,input_lengths,hidden=None):#輸入是(max_length,batch),Embedding之后變成(max_length,batch,hidden_size)embedded=self.embedding(input_seq)#PackpaddedbatchofsequencesforRNNmodule#因?yàn)镽NN(GRU)需要知道實(shí)際的長(zhǎng)度,所以PyTorch提供了一個(gè)函數(shù)pack_padded_sequence把輸入向量和長(zhǎng)度pack#到一個(gè)對(duì)象PackedSequence里,這樣便于使用。packed=torch.nn.utils.rnn.pack_padded_sequence(embedded,input_lengths)#通過GRU進(jìn)行forward計(jì)算,需要傳入輸入和隱變量#如果傳入的輸入是一個(gè)Tensor(max_length,batch,hidden_size)#那么輸出outputs是(max_length,batch,hidden_size*num_directions)。#第三維是hidden_size和num_directions的混合,它們實(shí)際排列順序是num_directions在前面,因此我們可以#使用outputs.view(seq_len,batch,num_directions,hidden_size)得到4維的向量。#其中第三維是方向,第四位是隱狀態(tài)。#而如果輸入是PackedSequence對(duì)象,那么輸出outputs也是一個(gè)PackedSequence對(duì)象,我們需要用#函數(shù)pad_packed_sequence把它變成一個(gè)shape為(max_length,batch,hidden*num_directions)的向量以及#一個(gè)list,表示輸出的長(zhǎng)度,當(dāng)然這個(gè)list和輸入的input_lengths完全一樣,因此通常我們不需要它。outputs,hidden=self.gru(packed,hidden)#參考前面的注釋,我們得到outputs為(max_length,batch,hidden*num_directions)outputs,_=torch.nn.utils.rnn.pad_packed_sequence(outputs)#我們需要把輸出的num_directions雙向的向量加起來#因?yàn)閛utputs的第三維是先放前向的hidden_size個(gè)結(jié)果,然后再放后向的hidden_size個(gè)結(jié)果#所以outputs[:,:,:self.hidden_size]得到前向的結(jié)果#outputs[:,:,self.hidden_size:]是后向的結(jié)果#注意,如果bidirectional是False,則outputs第三維的大小就是hidden_size,#這時(shí)outputs[:,:,self.hidden_size:]是不存在的,因此也不會(huì)加上去。#對(duì)Pythonslicing不熟的讀者可以看看下面的例子:#>>>a=[1,2,3]#>>>a[:3]#[1,2,3]#>>>a[3:]#[]#>>>a[:3]+a[3:]#[1,2,3]#這樣就不用寫下面的代碼了:#ifbidirectional:#outputs=outputs[:,:,:self.hidden_size]+outputs[:,:,self.hidden_size:]outputs=outputs[:,:,:self.hidden_size]+outputs[:,:,self.hidden_size:]#返回最終的輸出和最后時(shí)刻的隱狀態(tài)。returnoutputs,hidden
Decoder
Decoder也是一個(gè)RNN,它每個(gè)時(shí)刻輸出一個(gè)詞。每個(gè)時(shí)刻的輸入是上一個(gè)時(shí)刻的隱狀態(tài)和上一個(gè)時(shí)刻的輸出。一開始的隱狀態(tài)是Encoder最后時(shí)刻的隱狀態(tài),輸入是特殊的。然后使用RNN計(jì)算新的隱狀態(tài)和輸出第一個(gè)詞,接著用新的隱狀態(tài)和第一個(gè)詞計(jì)算第二個(gè)詞,...,直到遇到,結(jié)束輸出。普通的RNN Decoder的問題是它只依賴與Encoder最后一個(gè)時(shí)刻的隱狀態(tài),雖然理論上這個(gè)隱狀態(tài)(context向量)可以編碼輸入句子的語義,但是實(shí)際會(huì)比較困難。因此當(dāng)輸入句子很長(zhǎng)的時(shí)候,效果會(huì)很長(zhǎng)。
為了解決這個(gè)問題,Bahdanau等人在論文里提出了注意力機(jī)制(attention mechanism),在Decoder進(jìn)行t時(shí)刻計(jì)算的時(shí)候,除了t-1時(shí)刻的隱狀態(tài),當(dāng)前時(shí)刻的輸入,注意力機(jī)制還可以參考Encoder所有時(shí)刻的輸入。拿機(jī)器翻譯來說,我們?cè)诜g以句子的第t個(gè)詞的時(shí)候會(huì)把注意力機(jī)制在某個(gè)詞上。
當(dāng)然常見的注意力是一種soft的注意力,假設(shè)輸入有5個(gè)詞,注意力可能是一個(gè)概率,比如(0.6,0.1,0.1,0.1,0.1),表示當(dāng)前最關(guān)注的是輸入的第一個(gè)詞。同時(shí)我們之前也計(jì)算出每個(gè)時(shí)刻的輸出向量,假設(shè)5個(gè)時(shí)刻分別是$y_1,…,y_5$,那么我們可以用attention概率加權(quán)得到當(dāng)前時(shí)刻的context向量$0.6y_1+0.1y_2+…+0.1y_5$。
注意力有很多方法計(jì)算,我們這里介紹Luong等人在論文提出的方法。它是用當(dāng)前時(shí)刻的GRU計(jì)算出的新的隱狀態(tài)來計(jì)算注意力得分,首先它用一個(gè)score函數(shù)計(jì)算這個(gè)隱狀態(tài)和Encoder的輸出的相似度得分,得分越大,說明越應(yīng)該注意這個(gè)詞。然后再用softmax函數(shù)把score變成概率。那機(jī)器翻譯為例,在t時(shí)刻,$h_t$表示t時(shí)刻的GRU輸出的新的隱狀態(tài),我們可以認(rèn)為$h_t$表示當(dāng)前需要翻譯的語義。通過計(jì)算$h_t$與$y_1,…,y_n$的得分,如果$h_t$與$y_1$的得分很高,那么我們可以認(rèn)為當(dāng)前主要翻譯詞$x_1$的語義。有很多中score函數(shù)的計(jì)算方法,如下圖所示:
上式中$h_t$表示t時(shí)刻的隱狀態(tài),比如第一種計(jì)算score的方法,直接計(jì)算$h_t$與$h_s$的內(nèi)積,內(nèi)積越大,說明這兩個(gè)向量越相似,因此注意力也更多的放到這個(gè)詞上。第二種方法也類似,只是引入了一個(gè)可以學(xué)習(xí)的矩陣,我們可以認(rèn)為它先對(duì)$h_t$做一個(gè)線性變換,然后在與$h_s$計(jì)算內(nèi)積。而第三種方法把它們拼接起來然后用一個(gè)全連接網(wǎng)絡(luò)來計(jì)算score。
注意,我們前面介紹的是分別計(jì)算$h_t$和$y_1$的內(nèi)積、$h_t$和$y_2$的內(nèi)積,…。但是為了效率,可以一次計(jì)算$h_t$與$h_s=[y_1,y_2,…,y_n]$的乘積。 計(jì)算過程如下圖所示。
#Luong注意力layerclassAttn(torch.nn.Module):def__init__(self,method,hidden_size):super(Attn,self).__init__()self.method=methodifself.methodnotin['dot','general','concat']:raiseValueError(self.method,"isnotanappropriateattentionmethod.")self.hidden_size=hidden_sizeifself.method=='general':self.attn=torch.nn.Linear(self.hidden_size,hidden_size)elifself.method=='concat':self.attn=torch.nn.Linear(self.hidden_size*2,hidden_size)self.v=torch.nn.Parameter(torch.FloatTensor(hidden_size))defdot_score(self,hidden,encoder_output):#輸入hidden的shape是(1,batch=64,hidden_size=500)#encoder_outputs的shape是(input_lengths=10,batch=64,hidden_size=500)#hidden*encoder_output得到的shape是(10,64,500),然后對(duì)第3維求和就可以計(jì)算出score。returntorch.sum(hidden*encoder_output,dim=2)defgeneral_score(self,hidden,encoder_output):energy=self.attn(encoder_output)returntorch.sum(hidden*energy,dim=2)defconcat_score(self,hidden,encoder_output):energy=self.attn(torch.cat((hidden.expand(encoder_output.size(0),-1,-1),encoder_output),2)).tanh()returntorch.sum(self.v*energy,dim=2)#輸入是上一個(gè)時(shí)刻的隱狀態(tài)hidden和所有時(shí)刻的Encoder的輸出encoder_outputs#輸出是注意力的概率,也就是長(zhǎng)度為input_lengths的向量,它的和加起來是1。defforward(self,hidden,encoder_outputs):#計(jì)算注意力的score,輸入hidden的shape是(1,batch=64,hidden_size=500),表示t時(shí)刻batch數(shù)據(jù)的隱狀態(tài)#encoder_outputs的shape是(input_lengths=10,batch=64,hidden_size=500)ifself.method=='general':attn_energies=self.general_score(hidden,encoder_outputs)elifself.method=='concat':attn_energies=self.concat_score(hidden,encoder_outputs)elifself.method=='dot':#計(jì)算內(nèi)積,參考dot_score函數(shù)attn_energies=self.dot_score(hidden,encoder_outputs)#Transposemax_lengthandbatch_sizedimensions#把a(bǔ)ttn_energies從(max_length=10,batch=64)轉(zhuǎn)置成(64,10)attn_energies=attn_energies.t()#使用softmax函數(shù)把score變成概率,shape仍然是(64,10),然后用unsqueeze(1)變成#(64,1,10)returnF.softmax(attn_energies,dim=1).unsqueeze(1)
上面的代碼實(shí)現(xiàn)了dot、general和concat三種score計(jì)算方法,分別和前面的三個(gè)公式對(duì)應(yīng),我們這里介紹最簡(jiǎn)單的dot方法。代碼里也有一些注釋,只有dot_score函數(shù)比較難以理解,我們來分析一下。首先這個(gè)函數(shù)的輸入輸入hidden的shape是(1, batch=64, hidden_size=500),encoder_outputs的shape是(input_lengths=10, batch=64, hidden_size=500)。
怎么計(jì)算hidden和10個(gè)encoder輸出向量的內(nèi)積呢?為了簡(jiǎn)便,我們先假設(shè)batch是1,這樣可以把第二維(batch維)去掉,因此hidden是(1, 500),而encoder_outputs是(10, 500)。內(nèi)積的定義是兩個(gè)向量對(duì)應(yīng)位相乘然后相加,但是encoder_outputs是10個(gè)500維的向量。當(dāng)然我們可以寫一個(gè)for循環(huán)來計(jì)算,但是效率很低。這里用到一個(gè)小的技巧,利用broadcasting,hidden * encoder_outputs可以理解為把hidden從(1,500)復(fù)制成(10, 500)(當(dāng)然實(shí)際實(shí)現(xiàn)并不會(huì)這么做),然后兩個(gè)(10, 500)的矩陣進(jìn)行乘法。注意,這里的乘法不是矩陣乘法,而是所謂的Hadamard乘法,其實(shí)就是把對(duì)應(yīng)位置的乘起來,比如下面的例子:
因此hidden * encoder_outputs就可以把hidden向量(500個(gè)數(shù))與encoder_outputs的10個(gè)向量(500個(gè)數(shù))對(duì)應(yīng)的位置相乘。而內(nèi)積還需要把這500個(gè)乘積加起來,因此后面使用torch.sum(hidden * encoder_output, dim=2),把第2維500個(gè)乘積加起來,最終得到10個(gè)score值。當(dāng)然我們實(shí)際還有一個(gè)batch維度,因此最終得到的attn_energies是(10, 64)。接著在forward函數(shù)里把a(bǔ)ttn_energies轉(zhuǎn)置成(64, 10),然后使用softmax函數(shù)把10個(gè)score變成概率,shape仍然是(64, 10),為了后面使用方便,我們用unsqueeze(1)把它變成(64, 1, 10)。
有了注意力的子模塊之后,我們就可以實(shí)現(xiàn)Decoder了。Encoder可以一次把一個(gè)序列輸入GRU,得到整個(gè)序列的輸出。但是Decoder t時(shí)刻的輸入是t-1時(shí)刻的輸出,在t-1時(shí)刻計(jì)算完成之前是未知的,因此只能一次處理一個(gè)時(shí)刻的數(shù)據(jù)。因此Encoder的GRU的輸入是(max_length, batch, hidden_size),而Decoder的輸入是(1, batch, hidden_size)。此外Decoder只能利用前面的信息,所以只能使用單向(而不是雙向)的GRU,而Encoder的GRU是雙向的,如果兩種的hidden_size是一樣的,則Decoder的隱單元個(gè)數(shù)少了一半,那怎么把Encoder的最后時(shí)刻的隱狀態(tài)作為Decoder的初始隱狀態(tài)呢?這里是把每個(gè)時(shí)刻雙向結(jié)果加起來的,因此它們的大小就能匹配了(請(qǐng)讀者參考前面Encoder雙向相加的部分代碼)。
計(jì)算圖:
1) 把詞ID輸入Embedding層
2) 使用單向的GRU繼續(xù)Forward進(jìn)行一個(gè)時(shí)刻的計(jì)算。
3) 使用新的隱狀態(tài)計(jì)算注意力權(quán)重
4) 用注意力權(quán)重得到context向量
5) context向量和GRU的輸出拼接起來,然后再進(jìn)過一個(gè)全連接網(wǎng)絡(luò),使得輸出大小仍然是hidden_size
6) 使用一個(gè)投影矩陣把輸出從hidden_size變成詞典大小,然后用softmax變成概率 7) 返回輸出和新的隱狀態(tài)
輸入:
input_step: shape是(1, batch_size)
last_hidden: 上一個(gè)時(shí)刻的隱狀態(tài), shape是(n_layers x num_directions, batch_size, hidden_size)
encoder_outputs: encoder的輸出, shape是(max_length, batch_size, hidden_size)
輸出:
output: 當(dāng)前時(shí)刻輸出每個(gè)詞的概率,shape是(batch_size, voc.num_words)
hidden: 新的隱狀態(tài),shape是(n_layers x num_directions, batch_size, hidden_size)
classLuongAttnDecoderRNN(nn.Module):def__init__(self,attn_model,embedding,hidden_size,output_size,n_layers=1,dropout=0.1):super(LuongAttnDecoderRNN,self).__init__()#保存到self里,attn_model就是前面定義的Attn類的對(duì)象。self.attn_model=attn_modelself.hidden_size=hidden_sizeself.output_size=output_sizeself.n_layers=n_layersself.dropout=dropout#定義Decoder的layersself.embedding=embeddingself.embedding_dropout=nn.Dropout(dropout)self.gru=nn.GRU(hidden_size,hidden_size,n_layers,dropout=(0ifn_layers==1elsedropout))self.concat=nn.Linear(hidden_size*2,hidden_size)self.out=nn.Linear(hidden_size,output_size)self.attn=Attn(attn_model,hidden_size)defforward(self,input_step,last_hidden,encoder_outputs):#注意:decoder每一步只能處理一個(gè)時(shí)刻的數(shù)據(jù),因?yàn)閠時(shí)刻計(jì)算完了才能計(jì)算t+1時(shí)刻。#input_step的shape是(1,64),64是batch,1是當(dāng)前輸入的詞ID(來自上一個(gè)時(shí)刻的輸出)#通過embedding層變成(1,64,500),然后進(jìn)行dropout,shape不變。embedded=self.embedding(input_step)embedded=self.embedding_dropout(embedded)#把embedded傳入GRU進(jìn)行forward計(jì)算#得到rnn_output的shape是(1,64,500)#hidden是(2,64,500),因?yàn)槭请p向的GRU,所以第一維是2。rnn_output,hidden=self.gru(embedded,last_hidden)#計(jì)算注意力權(quán)重,根據(jù)前面的分析,attn_weights的shape是(64,1,10)attn_weights=self.attn(rnn_output,encoder_outputs)#encoder_outputs是(10,64,500)#encoder_outputs.transpose(0,1)后的shape是(64,10,500)#attn_weights.bmm后是(64,1,500)#bmm是批量的矩陣乘法,第一維是batch,我們可以把a(bǔ)ttn_weights看成64個(gè)(1,10)的矩陣#把encoder_outputs.transpose(0,1)看成64個(gè)(10,500)的矩陣#那么bmm就是64個(gè)(1,10)矩陣x(10,500)矩陣,最終得到(64,1,500)context=attn_weights.bmm(encoder_outputs.transpose(0,1))#把context向量和GRU的輸出拼接起來#rnn_output從(1,64,500)變成(64,500)rnn_output=rnn_output.squeeze(0)#context從(64,1,500)變成(64,500)context=context.squeeze(1)#拼接得到(64,1000)concat_input=torch.cat((rnn_output,context),1)#self.concat是一個(gè)矩陣(1000,500),#self.concat(concat_input)的輸出是(64,500)#然后用tanh把輸出返回變成(-1,1),concat_output的shape是(64,500)concat_output=torch.tanh(self.concat(concat_input))#out是(500,詞典大小=7826)output=self.out(concat_output)#用softmax變成概率,表示當(dāng)前時(shí)刻輸出每個(gè)詞的概率。output=F.softmax(output,dim=1)#返回output和新的隱狀態(tài)returnoutput,hidden
定義訓(xùn)練過程
Masked損失
forward實(shí)現(xiàn)之后,我們就需要計(jì)算loss。seq2seq有兩個(gè)RNN,Encoder RNN是沒有直接定義損失函數(shù)的,它是通過影響Decoder從而影響最終的輸出以及l(fā)oss。Decoder輸出一個(gè)序列,前面我們介紹的是Decoder在預(yù)測(cè)時(shí)的過程,它的長(zhǎng)度是不固定的,只有遇到EOS才結(jié)束。給定一個(gè)問答句對(duì),我們可以把問題輸入Encoder,然后用Decoder得到一個(gè)輸出序列,但是這個(gè)輸出序列和”真實(shí)”的答案長(zhǎng)度并不相同。
而且即使長(zhǎng)度相同并且語義相似,也很難直接知道預(yù)測(cè)的答案和真實(shí)的答案是否類似。那么我們?cè)趺从?jì)算loss呢?比如輸入是”What is your name?”,訓(xùn)練數(shù)據(jù)中的答案是”I am LiLi”。假設(shè)模型有兩種預(yù)測(cè):”I am fine”和”My name is LiLi”。從語義上顯然第二種答案更好,但是如果字面上比較的話可能第一種更好。
但是讓機(jī)器知道”I am LiLi”和”My name is LiLi”的語義很接近這是非常困難的,所以實(shí)際上我們通常還是通過字面上里進(jìn)行比較。我們會(huì)限制Decoder的輸出,使得Decoder的輸出長(zhǎng)度和”真實(shí)”答案一樣,然后逐個(gè)時(shí)刻比較。Decoder輸出的是每個(gè)詞的概率分布,因此可以使用交叉熵?fù)p失函數(shù)。但是這里還有一個(gè)問題,因?yàn)槭且粋€(gè)batch的數(shù)據(jù)里有一些是padding的,因此這些位置的預(yù)測(cè)是沒有必要計(jì)算loss的,因此我們需要使用前面的mask矩陣把對(duì)應(yīng)位置的loss去掉,我們可以通過下面的函數(shù)來實(shí)現(xiàn)計(jì)算Masked的loss。
defmaskNLLLoss(inp,target,mask):#計(jì)算實(shí)際的詞的個(gè)數(shù),因?yàn)閜adding是0,非padding是1,因此sum就可以得到詞的個(gè)數(shù)nTotal=mask.sum()crossEntropy=-torch.log(torch.gather(inp,1,target.view(-1,1)).squeeze(1))loss=crossEntropy.masked_select(mask).mean()loss=loss.to(device)returnloss,nTotal.item()
上面的代碼有幾個(gè)需要注意的地方。首先是masked_select函數(shù),我們來看一個(gè)例子:
它要求mask和被mask的tensor的shape是一樣的,然后從crossEntropy選出mask值為1的那些值。輸出的維度會(huì)減1。
另外為了實(shí)現(xiàn)交叉熵這里使用了gather函數(shù),這是一種比較底層的實(shí)現(xiàn)方法,更簡(jiǎn)便的方法應(yīng)該使用CrossEntropyLoss或者NLLLoss,其中CrossEntropy等價(jià)與LogSoftmax+NLLLoss。
交叉熵的定義為:$H(p,q)=-\sum_xp(x)logq(x)$。其中p和q是兩個(gè)隨機(jī)變量的概率分布,這里是離散的隨機(jī)變量,如果是連續(xù)的需要把求和變成積分。在我們這里p是真實(shí)的分布,也就是one-hot的,而q是模型預(yù)測(cè)的softmax的輸出。因?yàn)閜是one-hot的,所以只需要計(jì)算真實(shí)分類對(duì)應(yīng)的那個(gè)值。
比如假設(shè)一個(gè)5分類的問題,當(dāng)前正確分類是2(下標(biāo)從0-4),而模型的預(yù)測(cè)是(0.1,0.1,0.4,0.2,0.2),則H=-log(0.4)。用交叉熵作為分類的Loss是比較合理的,正確的分類是2,那么模型在下標(biāo)為2的地方預(yù)測(cè)的概率$q_2$越大,則$-logq_2$越小,也就是loss越小。
假設(shè)inp是:
0.30.20.40.10.20.10.40.3
也就是batch=2,而分類數(shù)(詞典大小)是4,inp是模型預(yù)測(cè)的分類概率。 而target = [2,3] ,表示第一個(gè)樣本的正確分類是第三個(gè)類別(概率是0.4),第二個(gè)樣本的正確分類是第四個(gè)類別(概率是0.3)。因此我們需要計(jì)算的是 -log(0.4) - log(0.3)。怎么不用for循環(huán)求出來呢?我們可以使用torch.gather函數(shù)首先把0.4和0.3選出來:
inp=torch.tensor([[0.3,0.2,0.4,0.1],[0.2,0.1,0.4,0.3]])target=torch.tensor([2,3])selected=torch.gather(inp,1,target.view(-1,1))print(selected)輸出:tensor([[0.4000],[0.3000]])
一次迭代的訓(xùn)練過程
函數(shù)train實(shí)現(xiàn)一個(gè)batch數(shù)據(jù)的訓(xùn)練。前面我們提到過,在訓(xùn)練的時(shí)候我們會(huì)限制Decoder的輸出,使得Decoder的輸出長(zhǎng)度和”真實(shí)”答案一樣長(zhǎng)。但是我們?cè)谟?xùn)練的時(shí)候如果讓Decoder自行輸出,那么收斂可能會(huì)比較慢,因?yàn)镈ecoder在t時(shí)刻的輸入來自t-1時(shí)刻的輸出。如果前面預(yù)測(cè)錯(cuò)了,那么后面很可能都會(huì)錯(cuò)下去。另外一種方法叫做teacher forcing,它不管模型在t-1時(shí)刻做什么預(yù)測(cè)都把t-1時(shí)刻的正確答案作為t時(shí)刻的輸入。但是如果只用teacher forcing也有問題,因?yàn)樵谡鎸?shí)的Decoder的是是沒有老師來幫它糾正錯(cuò)誤的。所以比較好的方法是更加一個(gè)teacher_forcing_ratio參數(shù)隨機(jī)的來確定本次訓(xùn)練是否teacher forcing。
另外使用到的一個(gè)技巧是梯度裁剪(gradient clipping)。這個(gè)技巧通常是為了防止梯度爆炸(exploding gradient),它把參數(shù)限制在一個(gè)范圍之內(nèi),從而可以避免梯度的梯度過大或者出現(xiàn)NaN等問題。注意:雖然它的名字叫梯度裁剪,但實(shí)際它是對(duì)模型的參數(shù)進(jìn)行裁剪,它把整個(gè)參數(shù)看成一個(gè)向量,如果這個(gè)向量的模大于max_norm,那么就把這個(gè)向量除以一個(gè)值使得模等于max_norm,因此也等價(jià)于把這個(gè)向量投影到半徑為max_norm的球上。它的效果如下圖所示。
操作步驟:
1) 把整個(gè)batch的輸入傳入encoder
2) 把decoder的輸入設(shè)置為特殊的,初始隱狀態(tài)設(shè)置為encoder最后時(shí)刻的隱狀態(tài)
3) decoder每次處理一個(gè)時(shí)刻的forward計(jì)算
4) 如果是teacher forcing,把上個(gè)時(shí)刻的"正確的"詞作為當(dāng)前輸入,否則用上一個(gè)時(shí)刻的輸出作為當(dāng)前時(shí)刻的輸入
5) 計(jì)算loss
6) 反向計(jì)算梯度
7) 對(duì)梯度進(jìn)行裁剪
8) 更新模型(包括encoder和decoder)參數(shù)。
注意,PyTorch的RNN模塊(RNN,LSTM,GRU)也可以當(dāng)成普通的非循環(huán)的網(wǎng)絡(luò)來使用。在Encoder部分,我們是直接把所有時(shí)刻的數(shù)據(jù)都傳入RNN,讓它一次計(jì)算出所有的結(jié)果,但是在Decoder的時(shí)候(非teacher forcing)后一個(gè)時(shí)刻的輸入來自前一個(gè)時(shí)刻的輸出,因此無法一次計(jì)算。
訓(xùn)練迭代過程
最后是把前面的代碼組合起來進(jìn)行訓(xùn)練。函數(shù)trainIters用于進(jìn)行n_iterations次minibatch的訓(xùn)練。
值得注意的是我們定期會(huì)保存模型,我們會(huì)保存一個(gè)tar包,包括encoder和decoder的state_dicts(參數(shù)),優(yōu)化器(optimizers)的state_dicts, loss和迭代次數(shù)。這樣保存模型的好處是從中恢復(fù)后我們既可以進(jìn)行預(yù)測(cè)也可以進(jìn)行訓(xùn)練(因?yàn)橛袃?yōu)化器的參數(shù)和迭代的次數(shù))。
效果測(cè)試
模型訓(xùn)練完成之后,我們需要測(cè)試它的效果。最簡(jiǎn)單直接的方法就是和chatbot來聊天。因此我們需要用Decoder來生成一個(gè)響應(yīng)。
貪心解碼(Greedy decoding)算法
最簡(jiǎn)單的解碼算法是貪心算法,也就是每次都選擇概率最高的那個(gè)詞,然后把這個(gè)詞作為下一個(gè)時(shí)刻的輸入,直到遇到EOS結(jié)束解碼或者達(dá)到一個(gè)最大長(zhǎng)度。但是貪心算法不一定能得到最優(yōu)解,因?yàn)槟硞€(gè)答案可能開始的幾個(gè)詞的概率并不太高,但是后來概率會(huì)很大。因此除了貪心算法,我們通常也可以使用Beam-Search算法,也就是每個(gè)時(shí)刻保留概率最高的Top K個(gè)結(jié)果,然后下一個(gè)時(shí)刻嘗試把這K個(gè)結(jié)果輸入(當(dāng)然需要能恢復(fù)RNN的狀態(tài)),然后再?gòu)闹羞x擇概率最高的K個(gè)。
為了實(shí)現(xiàn)貪心解碼算法,我們定義一個(gè)GreedySearchDecoder類。這個(gè)類的forwar的方法需要傳入一個(gè)輸入序列(input_seq),其shape是(input_seq length, 1), 輸入長(zhǎng)度input_length和最大輸出長(zhǎng)度max_length。就是過程如下:
1) 把輸入傳給Encoder,得到所有時(shí)刻的輸出和最后一個(gè)時(shí)刻的隱狀態(tài)。
2) 把Encoder最后時(shí)刻的隱狀態(tài)作為Decoder的初始狀態(tài)。
3) Decoder的第一輸入初始化為SOS。
4) 定義保存解碼結(jié)果的tensor
5) 循環(huán)直到最大解碼長(zhǎng)度
a) 把當(dāng)前輸入傳入Decoder
b) 得到概率最大的詞以及概率
c) 把這個(gè)詞和概率保存下來
d) 把當(dāng)前輸出的詞作為下一個(gè)時(shí)刻的輸入
6) 返回所有的詞和概率
測(cè)試對(duì)話函數(shù)
解碼方法完成后,我們寫一個(gè)函數(shù)來測(cè)試從終端輸入一個(gè)句子然后來看看chatbot的回復(fù)。我們需要用前面的函數(shù)來把句子分詞,然后變成ID傳入解碼器,得到輸出的ID后再轉(zhuǎn)換成文字。我們會(huì)實(shí)現(xiàn)一個(gè)evaluate函數(shù),由它來完成這些工作。我們需要把一個(gè)句子變成輸入需要的格式——shape為(batch, max_length),即使只有一個(gè)輸入也需要增加一個(gè)batch維度。我們首先把句子分詞,然后變成ID的序列,然后轉(zhuǎn)置成合適的格式。此外我們還需要?jiǎng)?chuàng)建一個(gè)名為lengths的tensor,雖然只有一個(gè),來表示輸入的實(shí)際長(zhǎng)度。接著我們構(gòu)造類GreedySearchDecoder的實(shí)例searcher,然后用searcher來進(jìn)行解碼得到輸出的ID,最后我們把這些ID變成詞并且去掉EOS之后的內(nèi)容。
另外一個(gè)evaluateInput函數(shù)作為chatbot的用戶接口,當(dāng)運(yùn)行它的時(shí)候,它會(huì)首先提示用戶輸入一個(gè)句子,然后使用evaluate來生成回復(fù)。然后繼續(xù)對(duì)話直到用戶輸入”q”或者”quit”。如果用戶輸入的詞不在詞典里,我們會(huì)輸出錯(cuò)誤信息(當(dāng)然還有一種辦法是忽略這些詞)然后提示用戶重新輸入。
訓(xùn)練和測(cè)試模型
最后我們可以來訓(xùn)練模型和進(jìn)行評(píng)測(cè)了。
不論是我們像訓(xùn)練模型還是測(cè)試對(duì)話,我們都需要初始化encoder和decoder模型參數(shù)。在下面的代碼,我們從頭開始訓(xùn)練模型或者從某個(gè)checkpoint加載模型。讀者可以嘗試不同的超參數(shù)配置來進(jìn)行調(diào)優(yōu)。
訓(xùn)練
下面的代碼進(jìn)行訓(xùn)練,我們需要設(shè)置一些訓(xùn)練的超參數(shù)。初始化優(yōu)化器,最后調(diào)用函數(shù)trainIters進(jìn)行訓(xùn)練。
測(cè)試
我們使用下面的代碼進(jìn)行測(cè)試。
下面是測(cè)試的一些例子:
結(jié)論
上面介紹了怎么從零開始訓(xùn)練一個(gè)chatbot,讀者可以用自己的數(shù)據(jù)訓(xùn)練一個(gè)chatbot試試,看看能不能用來解決一些實(shí)際業(yè)務(wù)問題。
-
數(shù)據(jù)
+關(guān)注
關(guān)注
8文章
6808瀏覽量
88743 -
pytorch
+關(guān)注
關(guān)注
2文章
802瀏覽量
13115
原文標(biāo)題:如何從零開始用PyTorch實(shí)現(xiàn)Chatbot?(附完整代碼)
文章出處:【微信號(hào):rgznai100,微信公眾號(hào):rgznai100】歡迎添加關(guān)注!文章轉(zhuǎn)載請(qǐng)注明出處。
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
評(píng)論