如何從零開始用PyTorch實現Chatbot?(附完整程式碼)
作者 | 李理,環信人工智慧研發中心vp,十多年自然語言處理和人工智慧研發經驗。主持研發過多款智慧硬體的問答和對話系統,負責環信中文語義分析開放平臺和環信智慧機器人的設計與研發。
本教程會介紹使用seq2seq模型實現一個chatbot,訓練資料來自Cornell電影對話語料庫。對話系統是目前的研究熱點,它在客服、可穿戴裝置和智慧家居等場景有廣泛應用。
傳統的對話系統要麼基於檢索的方法——提前準備一個問答庫,根據使用者的輸入尋找類似的問題和答案。這更像一個問答系統,它很難進行多輪的互動,而且答案是固定不變的。要麼基於預先設定的對話流程,這主要用於slot-filling(Task-Oriented)的任務,比如查詢機票需要使用者提供日期,達到城市等資訊。這種方法的缺點是比較死板,如果使用者的意圖在設計的流程之外,那麼就無法處理,而且對話的流程也一般比較固定,要支援使用者隨意的話題內跳轉和話題間切換比較困難。
因此目前學術界的研究熱點是根據大量的對話資料,自動的End-to-End的使用Seq2Seq模型學習對話模型。它的好處是不需要人來設計這個對話流程,完全是資料驅動的方法。它的缺點是流程不受人(開發者)控制,在嚴肅的場景(比如客服)下使用會有比較大的風險,而且需要大量的對話資料,這在很多實際應用中是很難得到的。因此目前seq2seq模型的對話系統更多的是用於類似小冰的閒聊機器人上,最近也有不少論文研究把這種方法用於task-oriented的任務,但還不是太成熟,在業界還很少被使用。
效果
本文使用的Cornell電影對話語料庫(https://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html)就是偏向於閒聊的語料庫。
本教程的主要內容參考了 PyTorch 官方教程(https://pytorch.org/tutorials/beginner/chatbot_tutorial.html)。
讀者可以(https://github.com/fancyerii/blog-codes)獲取完整程式碼。 下面是這個教程實現的對話效果示例:
準備
首先我們通過下載連結(http://www.cs.cornell.edu/~cristian/data/cornell_movie_dialogs_corpus.zip)下載訓練語料庫,這是一個zip檔案,把它下載後解壓到專案目錄的子目錄data下。接下來我們匯入需要用到的模組,這主要是PyTorch的模組:
載入和預處理資料
接下來我們需要對原始資料進行變換然後用合適的資料結構載入到記憶體裡。
Cornell電影對話語料庫(https://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html)是電影人物的對話資料,它包括:
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分割。下面是前十行:
b"Can we make this quick? Roxanne Korrine and Andrew Barrett are having an incredibly horrendous public break- up on the quad. Again.\tWell, I thought we'd start with pronunciation, if that's okay with you.\n"
b"Well, I thought we'd start with pronunciation, if that's okay with you.\tNot the hacking and gagging and spitting part. Please.\n"
b"Not the hacking and gagging and spitting part. Please.\tOkay... then how 'bout we try out some French cuisine. Saturday? Night?\n"
b"You're asking me out. That's so cute. What's your name again?\tForget it.\n"
b"No, no, it's my fault -- we didn't have a proper introduction ---\tCameron.\n"
b"Cameron.\tThe thing is, Cameron -- I'm at the mercy of a particularly hideous breed of loser. My sister. I can't date until she does.\n"
b"The thing is, Cameron -- I'm at the mercy of a particularly hideous breed of loser. My sister. I can't date until she does.\tSeems like she could get a date easy enough...\n"
b'Why?\tUnsolved mystery. She used to be really popular when she started high school, then it was just like she got sick of it or something.\n'
b"Unsolved mystery. She used to be really popular when she started high school, then it was just like she got sick of it or something.\tThat's a shame.\n"
b'Gosh, if only we could find Kat a boyfriend...\tLet me see what I can do.\n'
建立詞典
接下來我們需要構建詞典然後把問答句對載入到記憶體裡。
我們的輸入是一個句對,每個句子都是詞的序列,但是機器學習只能處理數值,因此我們需要建立詞到數字ID的對映。
為此,我們會定義一個Voc類,它會儲存詞到ID的對映,同時也儲存反向的從ID到詞的對映。除此之外,它還記錄每個詞出現的次數,以及總共出現的詞的個數。這個類提供addWord方法來增加一個詞, addSentence方法來增加句子,也提供方法trim來去除低頻的詞。
有了上面的Voc類我們就可以通過問答句對來構建詞典了。但是在構建之前我們需要進行一些預處理。
首先我們需要使用函式unicodeToAscii來把unicode字元變成ascii,比如把à變成a。注意,這裡的程式碼只是用於處理西方文字,如果是中文,這個函式直接會丟棄掉。接下來把所有字母變成小寫同時丟棄掉字母和常見標點(.!?)之外的所有字元。最後為了訓練收斂,我們會用函式filterPairs去掉長度超過MAX_LENGTH的句子(句對)。
上面的程式碼的輸出為:
Start preparing training data ...
Reading lines...
Read 221282 sentence pairs
Trimmed to 64271 sentence pairs
Counting words...
Counted words: 18008
我們可以看到,原理共有221282個句對,經過處理後我們值保留了64271個句對。
另外為了收斂更快,我們可以去除掉一些低頻詞。這可以分為兩步:
1) 使用voc.trim函式去掉頻次低於MIN_COUNT 的詞。
2) 去掉包含低頻詞的句子(只保留這樣的句子——每一個詞都是高頻的,也就是在voc中出現的)
MIN_COUNT = 3 # 閾值為3
def trimRareWords(voc, pairs, MIN_COUNT):
# 去掉voc中頻次小於3的詞
voc.trim(MIN_COUNT)
# 保留的句對
keep_pairs = []
for pair in pairs:
input_sentence = pair[0]
output_sentence = pair[1]
keep_input = True
keep_output = True
# 檢查問題
for word in input_sentence.split(' '):
if word not in voc.word2index:
keep_input = False
break
# 檢查答案
for word in output_sentence.split(' '):
if word not in voc.word2index:
keep_output = False
break
# 如果問題和答案都只包含高頻詞,我們才保留這個句對
if keep_input and keep_output:
keep_pairs.append(pair)
print("Trimmed from {} pairs to {}, {:.4f} of total".format(len(pairs), len(keep_pairs), len(keep_pairs) / len(pairs)))
return keep_pairs
# 實際進行處理
pairs = trimRareWords(voc, pairs, MIN_COUNT)
程式碼的輸出為:
keep_words 7823 / 18005 = 0.4345
Trimmed from 64271 pairs to 53165, 0.8272 of total
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。
# 把句子的詞變成ID
def indexesFromSentence(voc, sentence):
return [voc.word2index[word] for word in sentence.split(' ')] + [EOS_token]
# l是多個長度不同句子(list),使用zip_longest padding成定長,長度為最長句子的長度。
def zeroPadding(l, fillvalue=PAD_token):
return list(itertools.zip_longest(*l, fillvalue=fillvalue))
# l是二維的padding後的list
# 返回m和l的大小一樣,如果某個位置是padding,那麼值為0,否則為1
def binaryMatrix(l, value=PAD_token):
m = []
for i, seq in enumerate(l):
m.append([])
for token in seq:
if token == PAD_token:
m[i].append(0)
else:
m[i].append(1)
return m
# 把輸入句子變成ID,然後再padding,同時返回lengths這個list,標識實際長度。
# 返回的padVar是一個LongTensor,shape是(batch, max_length),
# lengths是一個list,長度為(batch,),表示每個句子的實際長度。
def inputVar(l, voc):
indexes_batch = [indexesFromSentence(voc, sentence) for sentence in l]
lengths = torch.tensor([len(indexes) for indexes in indexes_batch])
padList = zeroPadding(indexes_batch)
padVar = torch.LongTensor(padList)
return padVar, lengths
# 對輸出句子進行padding,然後用binaryMatrix得到每個位置是padding(0)還是非padding,
# 同時返回最大最長句子的長度(也就是padding後的長度)
# 返回值padVar是LongTensor,shape是(batch, max_target_length)
# mask是ByteTensor,shape也是(batch, max_target_length)
def outputVar(l, voc):
indexes_batch = [indexesFromSentence(voc, sentence) for sentence in l]
max_target_len = max([len(indexes) for indexes in indexes_batch])
padList = zeroPadding(indexes_batch)
mask = binaryMatrix(padList)
mask = torch.ByteTensor(mask)
padVar = torch.LongTensor(padList)
return padVar, mask, max_target_len
# 處理一個batch的pair句對
def batch2TrainData(voc, pair_batch):
# 按照句子的長度(詞數)排序
pair_batch.sort(key=lambda x: len(x[0].split(" ")), reverse=True)
input_batch, output_batch = [], []
for pair in pair_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)
return inp, lengths, output, mask, max_target_len
# 示例
small_batch_size = 5
batches = batch2TrainData(voc, [random.choice(pairs) for _ in range(small_batch_size)])
input_variable, lengths, target_variable, mask, max_target_len = batches
print("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(
[ ],
[ ],
[ ],
[ ],
[ ],
[ ],
[ ],
[ ],
[ ]])
lengths: tensor([ 9, 8, 7, 3, 3])
target_variable: tensor(
[ ],
[ ],
[ ],
[ ],
[ ],
[ ],
[ ],
[ ]])
mask: tensor(
[ ],
[ ],
[ ],
[ ],
[ ],
[ ],
[ ],
[ ]], dtype=torch.uint8)
max_target_len: 8
我們可以看到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_sequence
torch.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程式碼如下,請讀者詳細閱讀註釋。
class EncoderRNN(nn.Module):
def __init__(self, hidden_size, embedding, n_layers=1, dropout=0):
super(EncoderRNN, self).__init__()
self.n_layers = n_layers
self.hidden_size = hidden_size
self.embedding = embedding
# 初始化GRU,這裡輸入和hidden大小都是hidden_size,因為我們這裡假設embedding層的輸出大小是hidden_size
# 如果只有一層,那麼不進行Dropout,否則使用傳入的引數dropout進行GRU的Dropout。
self.gru = nn.GRU(hidden_size, hidden_size, n_layers,
dropout=(0 if n_layers == 1 else dropout), bidirectional=True)
def forward(self, input_seq, input_lengths, hidden=None):
# 輸入是(max_length, batch),Embedding之後變成(max_length, batch, hidden_size)
embedded = self.embedding(input_seq)
# Pack padded batch of sequences for RNN module
# 因為RNN(GRU)需要知道實際的長度,所以PyTorch提供了一個函式pack_padded_sequence把輸入向量和長度pack
# 到一個物件PackedSequence裡,這樣便於使用。
packed = torch.nn.utils.rnn.pack_padded_sequence(embedded, input_lengths)
# 通過GRU進行forward計算,需要傳入輸入和隱變數
# 如果傳入的輸入是一個Tensor (max_length, batch, hidden_size)
# 那麼輸出outputs是(max_length, batch, hidden_size*num_directions)。
# 第三維是hidden_size和num_directions的混合,它們實際排列順序是num_directions在前面,因此我們可以
# 使用outputs.view(seq_len, batch, num_directions, hidden_size)得到4維的向量。
# 其中第三維是方向,第四位是隱狀態。
# 而如果輸入是PackedSequence物件,那麼輸出outputs也是一個PackedSequence物件,我們需要用
# 函式pad_packed_sequence把它變成一個shape為(max_length, batch, hidden*num_directions)的向量以及
# 一個list,表示輸出的長度,當然這個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雙向的向量加起來
# 因為outputs的第三維是先放前向的hidden_size個結果,然後再放後向的hidden_size個結果
# 所以outputs[:, :, :self.hidden_size]得到前向的結果
# outputs[:, :, self.hidden_size:]是後向的結果
# 注意,如果bidirectional是False,則outputs第三維的大小就是hidden_size,
# 這時outputs[:, : ,self.hidden_size:]是不存在的,因此也不會加上去。
# 對Python slicing不熟的讀者可以看看下面的例子:
# >>> a=[1,2,3]
# >>> a[:3]
# [1, 2, 3]
# >>> a[3:]
# []
# >>> a[:3]+a[3:]
# [1, 2, 3]
# 這樣就不用寫下面的程式碼了:
# if bidirectional:
# outputs = outputs[:, :, :self.hidden_size] + outputs[:, : ,self.hidden_size:]
outputs = outputs[:, :, :self.hidden_size] + outputs[:, : ,self.hidden_size:]
# 返回最終的輸出和最後時刻的隱狀態。
return outputs, hidden
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]$的乘積。 計算過程如下圖所示。
# Luong 注意力layer
class Attn(torch.nn.Module):
def __init__(self, method, hidden_size):
super(Attn, self).__init__()
self.method = method
if self.method not in ['dot', 'general', 'concat']:
raise ValueError(self.method, "is not an appropriate attention method.")
self.hidden_size = hidden_size
if self.method == 'general':
self.attn = torch.nn.Linear(self.hidden_size, hidden_size)
elif self.method == 'concat':
self.attn = torch.nn.Linear(self.hidden_size * 2, hidden_size)
self.v = torch.nn.Parameter(torch.FloatTensor(hidden_size))
def dot_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),然後對第3維求和就可以計算出score。
return torch.sum(hidden * encoder_output, dim=2)
def general_score(self, hidden, encoder_output):
energy = self.attn(encoder_output)
return torch.sum(hidden * energy, dim=2)
def concat_score(self, hidden, encoder_output):
energy = self.attn(torch.cat((hidden.expand(encoder_output.size(0), -1, -1), encoder_output), 2)).tanh()
return torch.sum(self.v * energy, dim=2)
# 輸入是上一個時刻的隱狀態hidden和所有時刻的Encoder的輸出encoder_outputs
# 輸出是注意力的概率,也就是長度為input_lengths的向量,它的和加起來是1。
def forward(self, hidden, encoder_outputs):
# 計算注意力的score,輸入hidden的shape是(1, batch=64, hidden_size=500),表示t時刻batch資料的隱狀態
# encoder_outputs的shape是(input_lengths=10, batch=64, hidden_size=500)
if self.method == 'general':
attn_energies = self.general_score(hidden, encoder_outputs)
elif self.method == 'concat':
attn_energies = self.concat_score(hidden, encoder_outputs)
elif self.method == 'dot':
# 計算內積,參考dot_score函式
attn_energies = self.dot_score(hidden, encoder_outputs)
# Transpose max_length and batch_size dimensions
# 把attn_energies從(max_length=10, batch=64)轉置成(64, 10)
attn_energies = attn_energies.t()
# 使用softmax函式把score變成概率,shape仍然是(64, 10),然後用unsqueeze(1)變成
# (64, 1, 10)
return F.softmax(attn_energies, dim=1).unsqueeze(1)
上面的程式碼實現了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)
class LuongAttnDecoderRNN(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類的物件。
self.attn_model = attn_model
self.hidden_size = hidden_size
self.output_size = output_size
self.n_layers = n_layers
self.dropout = dropout
# 定義Decoder的layers
self.embedding = embedding
self.embedding_dropout = nn.Dropout(dropout)
self.gru = nn.GRU(hidden_size, hidden_size, n_layers, dropout=(0 if n_layers == 1 else dropout))
self.concat = nn.Linear(hidden_size * 2, hidden_size)
self.out = nn.Linear(hidden_size, output_size)
self.attn = Attn(attn_model, hidden_size)
def forward(self, input_step, last_hidden, encoder_outputs):
# 注意:decoder每一步只能處理一個時刻的資料,因為t時刻計算完了才能計算t+1時刻。
# input_step的shape是(1, 64),64是batch,1是當前輸入的詞ID(來自上一個時刻的輸出)
# 通過embedding層變成(1, 64, 500),然後進行dropout,shape不變。
embedded = self.embedding(input_step)
embedded = self.embedding_dropout(embedded)
# 把embedded傳入GRU進行forward計算
# 得到rnn_output的shape是(1, 64, 500)
# hidden是(2, 64, 500),因為是雙向的GRU,所以第一維是2。
rnn_output, hidden = self.gru(embedded, last_hidden)
# 計算注意力權重, 根據前面的分析,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,我們可以把attn_weights看成64個(1,10)的矩陣
# 把encoder_outputs.transpose(0, 1)看成64個(10, 500)的矩陣
# 那麼bmm就是64個(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是一個矩陣(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變成概率,表示當前時刻輸出每個詞的概率。
output = F.softmax(output, dim=1)
# 返回 output和新的隱狀態
return output, hidden
定義訓練過程
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。
def maskNLLLoss(inp, target, mask):
# 計算實際的詞的個數,因為padding是0,非padding是1,因此sum就可以得到詞的個數
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)
return loss, nTotal.item()
上面的程式碼有幾個需要注意的地方。首先是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是:
0.3 0.2 0.4 0.1
0.2 0.1 0.4 0.3
也就是batch=2,而分類數(詞典大小)是4,inp是模型預測的分類概率。 而target = [2,3] ,表示第一個樣本的正確分類是第三個類別(概率是0.4),第二個樣本的正確分類是第四個類別(概率是0.3)。因此我們需要計算的是 -log(0.4) - log(0.3)。怎麼不用for迴圈求出來呢?我們可以使用torch.gather函式首先把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]])
一次迭代的訓練過程
函式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試試,看看能不能用來解決一些實際業務問題。
原文地址:
http://fancyerii.github.io/2019/02/14/chatbot/
(本文為AI科技大本營轉載文章,轉載請聯絡原作者)
群招募
掃碼新增小助手微信,回覆:公司+研究方向(學校+研究方向),邀你加入技術交流群。技術群稽核較嚴,敬請諒解。
推薦閱讀:
點選“閱讀原文”,檢視歷史精彩文章。
相關文章
- 從零開始用 proxy 實現 Mobx
- 從零開始學習如何部署程式碼
- 用PyTorch從零開始編寫DeepSeek-V2PyTorch
- 從零開始:開發一款應用程式的完整流程技巧
- 從零開始PyTorch專案:YOLO v3目標檢測實現PyTorchYOLO
- 從零開始實現一個RPC框架(零)RPC框架
- 從零開始實現線上直播
- 從零開始 實現一個自己的指令碼引擎指令碼
- 從零開始實現一個自己的指令碼引擎指令碼
- 如何從零開始實施Flutter條碼掃描器Flutter
- 從頭開始瞭解PyTorch的簡單實現PyTorch
- 從零開始實現放置遊戲(八)——實現掛機戰鬥(6)程式碼重構遊戲
- 從零開始使用 Astro 的實用指南AST
- 從零開始實現放置遊戲(一)遊戲
- 從零開始的Java RASP實現(二)Java
- 從零開始的Java RASP實現(一)Java
- 從零開始實現一個RPC框架(四)RPC框架
- 從零開始實現一個RPC框架(二)RPC框架
- 從零開始實現一個RPC框架(五)RPC框架
- 從零開始實現一個RPC框架(一)RPC框架
- 從零開始實現一個RPC框架(三)RPC框架
- 工程管理系列之----從零開始搭建git伺服器,及程式碼同步完整版Git伺服器
- Java 從零開始實現一個畫圖板、以及影像處理功能,程式碼可復現Java
- 從零開始搭建webpack應用Web
- 用.Net實現GraphRag:從零開始構建智慧知識圖譜
- 從零開始實現一個IDL+RPC框架RPC框架
- 從零開始實現一個分散式RPC框架分散式RPC框架
- 從零開始
- 現在,開始用嘴寫程式碼!
- 不怕從零開始,只怕從未開始!
- 從零開始-打造一個JavaScript完整線上教程文件JavaScript
- 從零開始編寫指令碼引擎指令碼
- 從零開始寫 Docker(十二)---實現 mydocker stop 停止容器Docker
- 從零開始實現multipart/form-data資料提交ORM
- 從零開始實現React(一):JSX和虛擬DOMReactJS
- 從零開始實現一個MyBatis加解密外掛MyBatis解密
- 如何從零開始整合DTM Android SDKAndroid
- 使用PyTorch從零開始構建Elman迴圈神經網路PyTorch神經網路