基於知識庫的問答:seq2seq模型實踐

問題描述

基於知識圖譜的自動問答(Question Answering over Knowledge Base, 即 KBQA)問題的大概形式是,預先給定一個知識庫(比如Freebase),知識庫中包含著大量的先驗知識數據,然後利用這些知識資源自動回答自然語言形態的問題(比如「肉夾饃是江蘇的美食嗎」,「虵今年多大了」等人民群眾喜聞樂見的問題)。

什麼是知識庫

知識庫(Knowledge Base),或者叫的比較爛俗點,知識圖譜(Knowledge Graph),是以知識為主要單位,實體為主要載體,包含著現實生活中人們對萬千事物的認知與各類事實的龐大資料庫。一般來說,知識(或者事實)主要以三元組形式呈現:<頭實體,關係,尾實體>,其中實體即人、地點、或特定概念等萬物。舉例來說,<虵,改變了,中國> 就是一條簡單的三元組示例,其中頭尾皆為知識庫中固有的實體單元。

方法框架

先放個圖

對著圖說:假設要回答「Where was Leslie Cheung born」這個問題,主要分四步:

  1. 實體識別(Named Entity Recognition),即把問題中的主要實體的名字從問題中抽出來,這樣才知道應該去知識庫中搜取哪個實體的信息來解決問題,即圖中把「Leslie Cheung」這個人名抽出來;
  2. 實體鏈接(Entity Linking),把抽取出來的實體名和知識庫中具體的實體對應起來,做這一步是因為,由於同名實體的存在,名字不是實體的唯一標識,實體獨一無二的編號(id)才是,找到了實體名沒卵用,必須要對應到知識庫中具體的實體id,才能在知識庫中把具體實體找到,獲取相關信息。即圖中將「Leslie Cheung」映射到「m.sdjk1s」這個 id 上(Freebase 的實體 id 是這個格式的)。這一步會存在一些問題,比如直接搜「姓名」叫「Leslie Cheung」的實體是搜不到的,因為「Leslie Cheung」其實是某個實體的「外文名」,他的「姓名」叫「張國榮」,以及有時候還會有多個叫「Leslie Cheung」的人。具體解決方式後面再說。
  3. 關係預測(Relation Prediction),根據原問句中除去實體名以外的其他詞語預測出應該從知識庫中哪個關係去解答這個問題,是整個問題中最主要的一步。即圖中從「Where was <e> born」預測出「people.person.place_of_birth」(Freebase 的關係名格式,翻譯過來就是「出生地」)這個關係應該連接著問題的主要實體「Leslie Cheung」與這個問題的答案。
  4. 找到了實體與關係,直接在知識庫中把對應的三元組檢索出來,即 「<m.sdjk1s,

    people.person.place_of_birth, m.s1kjds>」,那麼這條三元組的尾實體,即「m.s1kjds」就是問題的答案,查詢其名字,就是「Hong Kong」。

數據集與工具

源代碼與數據下載:

只放一個簡單的用 seq2seq 模型做的代碼,其中 data/origin 目錄下是問答數據集的原始數據,鑒於實體識別與鏈接做起來比較麻煩,所以直接給出中間數據,data/seq2seq 目錄下是已經經過前兩步,可以直接用於訓練 seq2seq 模型的數據

還是百度網盤 pan.baidu.com/s/1Cmr8D_

數據集:

SimpleQuestions & WebQuestions 學術界問答領域比較喜聞樂見的兩個數據集了,相當權威,當然都是英文,別問為什麼不是中文

另外,知識庫用的是 Freebase ,業界最權威的知識庫之一了,當然也是英文的,別問為什麼不是中文

工具:

Pytorch

依照慣例,還是先上結論

  • 從 tensorflow 轉過來發現,pytorch 真好用
  • 問答問題目前的解決方法,框架基本都是上面說那四步,但是具體做法五花八門,模型各式各樣,文中所用的 seq2seq 也只是一個簡單實踐,效果上比較如下圖(out-of-state now 是業界目前最好結果,APVA-TURBO 是我最近在做的一篇論文)

out-of-state now 的兩篇論文名:

[1] (EMNLP 2017) No Need to Pay Attention: Simple Recurrent Neural Networks Work !

[2] (NAACL 2016) Question Answering over Knowledge Base using Factual Memory Networks

  • 簡單的 seq2seq 效果還不錯,雖然跟學術界目前最好成績有很大差距,但是還不錯。後來我又加入了一些比如 KB embedding,turbo training 等一言難盡的補丁上去,變成現在的 APVA-TURBO 模型,在 WebQuestions 上已經快領先 8 個點了,但是文章里不提了,太亂了,以後論文發了的話補個鏈接
  • 無關的吐槽發泄一下:論文在半個月前投 ACL2018 的,然後因為段落的格式問題被拒了(是的,因為格式問題,WTF???),快畢業了 A 沒有了太遺憾了,現在準備這星期投 COLING 2018,都是命啊

  • 還是想說,pytorch 真好用啊真好用

正文

下面正式開編,詳細講一下用 seq2seq 模型做問答問題的過程以及 pytorch 的實現,個人淺見,隨性發揮,可能有不對的地方,反正你也不能打我


1 數據處理(包括實體識別與鏈接)

先貼一下數據集的原始數據形態,拿 SimpleQuestions 的數據貼一下,WebQuestions 的數據要比這個醜陋一些,就不提了。數據量方面,SimpleQuestions 的 train/test 是 75910/21687 ,WebQuestions 是 3778/2032

SimpleQuestions 的原始數據中,每一行一個數據,分四列,中間用「 」隔開,四列分別是頭實體id,關係,尾實體id與問句內容

1.1 實體識別

首先訓練實體識別模型,目標是給一個問題,能把問題中的實體名(entity mention)找到,方法就是喜聞樂見的 BIO 序列標註方法,模型用簡單 LSTM 可以解決,或者再堆個 CRF 增強效果,序列標註在上一篇文章說過,「B」 即實體名的開始單詞,「I」 為實體名的中間單詞(或結尾詞),「O」 為不是實體名的單詞,輸入一串單詞序列,輸出一串長度相同的由 BIO 組成的字母序列

方法有了,找訓練數據。數據就用上面 SimpleQuestions 的數據,把已經給定的實體id轉換成實體名,再在原問句中根據編輯距離把相似度最高的短語(N-gram片語)標出來

訓練數據有了,開始訓練模型,不是主要內容不細說了,放一個模型圖

這裡 char-BiGRU 是從字母維度上的的 word embedding,以及 CRF layer,都是為了增強效果,簡單做可以都省略

1.2 實體鏈接

找到了實體名,然後就是對應到 KB 中的具體實體。這一步做法比較簡單,但是對最終效果的影響還是比較大的,包括在 KB 中能不能找到對應的實體,以及找到多個實體怎麼排序的問題。直接說方法,首先收集 KB 中所有實體的名稱(包括「name」「外文名」「別名」等等的),然後構建單詞到實體 id 的反向 map 表,舉個例子

這裡 Leslie 可以鏈接到兩個實體,因為兩個實體的名字中都含有 Leslie 這個單詞。注意每一個括弧里的數字,代表詞(或片語)鏈接到這個實體的打分,計算方式就是這個片語的單詞個數除以這個實體完整實體名的單詞個數。

這裡打分也可以適當考慮實體的知名度進去,比如「Leslie Cheung Kwok-wing」這個實體知名度更高,「Uncle Leslie」沒怎麼聽說過,所以用戶提這個問題更有可能是問關於前者的,所以前者的打分也要適當提高一些。具體操作方式可以很靈活,不細說了。

1.3 關係預測(seq2seq模型)

終於進入正題了。經過之前兩步的數據處理,現在的數據基本是這個樣子

simple.source.test

simple.target.test

上面是輸入下面是期望輸出,輸入中每條數據就是一個問句,由若干個單片語成的序列,其中已經把實體名拿走,用「<e>」這個標記詞進行替換。輸出是一個關係名,雖然由於 Freebase 的關係格式定義,一個關係名由三個用「.」拼接的單片語成,但是這裡只把他當成一個完整的單詞看待。其實關係預測本質上就是一個文本分類問題,給定所有的關係列表,輸入一個文本,分類到一個最可能的關係上。

在這一步結束後,得到了預測出的關係名,再加上上一步實體鏈接得到的具體實體,就能從知識庫中找到三元組,找到答案,從而解決問題了。下面具體講關係預測的模型及實現代碼細節。


2 模型搭建

先上一個模型圖

最簡單的沒有任何添加劑的純天然的 seq2seq 模型,即 encoder-decoder 模型(當然也可以再加 attention 什麼的上去,就不提了),左邊(綠色)是一個雙向 GRU(或 LSTM)(雙向即兩層,一層正向走一層反向走,然後把兩層的最後結果加到一起,只用單向也可以,區別不大)作為 encoder,能把整個問題壓縮成一個向量 u,右邊是一個單向 GRU ,把向量 u 解壓縮成一個關係,或關係序列,_GO 是表示序列開始生成的標記詞,_EOS 是表示序列生成完畢的標記詞。下面詳細說一下為什麼會是關係序列。這也是本來一個簡單的多分類任務為什麼不用簡單的 RNN 分類模型而用 seq2seq 這種序列生成模型的原因。

有時候僅靠一個關係(一跳)並不能找到最終答案,比如「張國榮曾在哪個國家留學」,為了回答這個問題需要輸出兩個關係(兩跳),第一跳是從「張國榮」通過「畢業院校」這個關係找到「英國里茲大學」這個實體,第二跳是從「英國里茲大學」通過「所屬國家」這個關係找到「英國」這個最終答案。所以原來「張國榮出生在哪裡」這個問題對應的輸出序列是「出生地,_EOS」,而「張國榮曾在哪個國家留學」對應的輸出序列就變成了「畢業院校,所屬國家,_EOS」,需要輸出的關係序列長度是不一樣的,這也是 seq2seq 模型解決問答問題的優勢所在。

Encoder

好了,編完了,下面上代碼,首先是 Encoder

class EncoderRNN(nn.Module): def __init__(self, config): super(EncoderRNN, self).__init__() self.input_size = config.source_vocab_size self.hidden_size = config.hidden_size self.num_layers = 1 self.dropout = 0.1 self.embedding = nn.Embedding(self.input_size, self.hidden_size) self.gru = nn.GRU(self.hidden_size, self.hidden_size, self.num_layers, dropout=self.dropout, bidirectional=True) def forward(self, input_seqs, input_lengths, hidden=None): # Note: we run this all at once (over multiple batches of multiple sequences) # input: S*B embedded = self.embedding(input_seqs) # S*B*D packed = torch.nn.utils.rnn.pack_padded_sequence(embedded, input_lengths) outputs, hidden = self.gru(packed, hidden) outputs, output_lengths = torch.nn.utils.rnn.pad_packed_sequence(outputs) #outputs: S*B*2D #hidden: 2*B*D outputs = outputs[:, :, :self.hidden_size] + outputs[:, : ,self.hidden_size:] # Sum bidirectional outputs hidden = hidden[:1, :, :] + hidden[-1:, :, :] #outputs: S*B*D #hidden: 1*B*D return outputs, hidden

source_vocab_size 是所有數據中涉及到的單詞詞表大小,hidden_size 是單詞被壓縮成的詞向量維度,設 batch 的大小為 B,batch 內每個輸入序列長度為 S,首先是形狀 S*B 的張量進來,然後經過 embedding 得到 S*B*D 的張量,然後直接進 GRU ,得到結果 outputs 以及 hidden,這裡因為使用了雙向 GRU 所以 outputs 出來是 S*B*2D 的,hidden 出來是 2*B*D 的,需要壓縮一下,outputs 在模型後面沒有用到可以無所謂。這裡再細說一下這個 pack_padded_sequence 的作用,這個函數機制真的是讓我只想雙擊666

對於使用了 batch 的 GRU(或LSTM)來說,要求輸入的 batch 中的每一個序列長度相同。但是一個 batch 里的問題有長有短,怎麼可能都相同呢,所以就需要用一個沒有意義的標記詞(「_PAD」)把所有問題填充(Padding)到相同的長度,舉個例子

在這個大小為 3 的 batch 里 ,後兩個問題因為長度不足都被 padding 到了 5 個單詞,但是在推到 GRU 里運行的時候,我們只希望它們前面有效的單詞進去就可以了,後面的 _PAD 填充過多時會嚴重影響最後出來的效果,bucket 機制或許可以適當解決這個問題,但是 pytorch 提供的這個 pack_padded_sequence 非常完美,它可以自動保證 _PAD 不會真正進入到 GRU 中影響效果,只需要你事先把 input_seqs 先按長度從大到小排列一下,然後把排序後每個序列的真正長度 input_lengths 傳進來,比如這個例子里 input_lengths 就是 [5,4,3],然後包裝好放進 GRU 里, GRU 運行完了再用 pad_packed_sequence 這個函數解包一下,就 OK 了

Decoder

class DecoderRNN(nn.Module): def __init__(self, config): super(DecoderRNN, self).__init__() # Define parameters self.hidden_size = config.hidden_size self.output_size = config.target_vocab_size self.num_layers = 1 self.dropout_p = 0.1 # Define layers self.embedding = nn.Embedding(self.output_size, self.hidden_size) self.dropout = nn.Dropout(self.dropout_p) self.gru = nn.GRU(self.hidden_size, self.hidden_size, self.num_layers, dropout=self.dropout_p) self.out = nn.Linear(self.hidden_size, self.output_size) def forward(self, word_input, prev_hidden): # Get the embedding of the current input word (last output word) # word input: B # prev_hidden: 1*B*D batch_size = word_input.size(0) embedded = self.embedding(word_input) # B*D embedded = self.dropout(embedded) embedded = embedded.unsqueeze(0) # 1*B*D rnn_output, hidden = self.gru(embedded, prev_hidden) # rnn_output : 1*B*D # hidden : 1*B*D rnn_output = rnn_output.squeeze(0) # B*D output = self.out(rnn_output) # B*target_vocab_size return output, hidden

Decoder 也比較簡單,但是跟上面 Encoder 有個很大的區別就是這裡 Decoder 一次只處理一個單詞,假設期望輸出序列長度是 M,需要運行 M 次,而上面 Encoder 是一次就把長度為 N 的序列都處理完。Decoder 不能這麼做的原因是在它的下一次輸入是上一次輸出,只有先運行一遍得到第一個單詞才能再去得到第二個單詞,而不像 Encoder 一開始就知道整個輸入序列。

run_epoch

encoder 和 decoder 搭完了,下面就是怎麼把他們拼起來了,一個 S*D 的 batch 來了,先跑 encoder,得到 1*S*D 的 encoder_hidden,就是模型圖中最重要的 u,然後設最長輸出序列長度為 t,分 t 次運行 decoder 模型,一次輸入一個單詞,最初的輸入單詞為標記詞「_GO」,並將 u 作為初始隱層塞到 decoder 里。

encoder = EncoderRNN(config)decoder = DecoderRNN(config)encoder_optimizer = optim.SGD(encoder.parameters(), lr=config.learning_rate)decoder_optimizer = optim.SGD(decoder.parameters(), lr=config.learning_rate)def run_epoch(source_batch, source_lengths, target_batch, target_lengths, encoder, decoder, encoder_optimizer, decoder_optimizer, TRAIN=True): if TRAIN: encoder_optimizer.zero_grad() decoder_optimizer.zero_grad() loss = 0 else: encoder.train(False) decoder.train(False) batch_size = source_batch.size()[1] encoder_outputs, encoder_hidden = encoder(source_batch, source_lengths, None) decoder_input = Variable(torch.LongTensor([target_w2i["_GO"]] * batch_size)) decoder_hidden = encoder_hidden max_target_length = max(target_lengths) all_decoder_outputs = Variable(torch.zeros(max_target_length, batch_size, decoder.output_size)) if USE_CUDA: decoder_input = decoder_input.cuda() all_decoder_outputs = all_decoder_outputs.cuda() for t in range(max_target_length): decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden) all_decoder_outputs[t] = decoder_output decoder_input = target_batch[t] # S * B * vocab_size -> B * S * vocab_size all_decoder_outputs = all_decoder_outputs.transpose(0, 1).contiguous() target_batch = target_batch.transpose(0, 1).contiguous() if TRAIN: # train loss = seq2seq_model.masked_cross_entropy(all_decoder_outputs, target_batch, target_lengths) loss.backward() encoder_optimizer.step() decoder_optimizer.step() return loss.data[0] else: # test hits = 0 for b in range(batch_size): topv, topi = all_decoder_outputs[b].data.topk(1) pre = topi.squeeze(1)[:target_lengths[b]] sta = target_batch[b][:target_lengths[b]].data if torch.equal(pre, sta): hits += 1 encoder.train(True) decoder.train(True) return float(hits)*100 / batch_size

先定義好參數優化器 optimizer,這裡使用隨機梯度下降演算法(SGD),然後每輸入一個 batch,運行一次 run_epoch 函數,計算一次 loss,更新一次參數,然後結束,返回這次 loss 的值;當 TRAIN=False,也就是測試的時候,不計算 loss 也不更新參數,直接對比真實輸出與期望輸出,返回準確度。

這裡計算 loss 用了 masked_cross_entropy 這個函數,這個函數是我從網上抄來的,出處是

github.com/spro/practic

他這個 loss 計算有一個很大的好處是什麼呢,這就又涉及到 padding 的問題了,剛才說輸入序列需要 padding,並且通過 pack_padded_sequence 避免了 _PAD 帶來的影響,而輸出序列也需要 padding,也需要一種措施避免影響,還是舉個例子

在這個大小為 3 的 batch 中,最長輸出序列 t=3,後兩條數據因為長度不足被加入了 _PAD 標記詞,但是計算 loss 並更新參數的時候,我們只希望計算除 _PAD 以外的位置上的 loss,並不想關心 _PAD 上的 loss,因為沒有意義,且會給效果帶來影響。masked_cross_entropy 這個函數就通過一個 mask 矩陣把 _PAD 位置上的 loss 過濾掉了,非常流弊。具體不再細說了,可以看源碼。


3 訓練及測試

終於一切基礎都搭完可以開始訓練了,也沒啥可以說的,直接放代碼吧

for iter in range(0, num_epoch): source_batch, source_lengths, target_batch, target_lengths = get_batch(train_pairs, batch_size) loss = run_epoch(source_batch, source_lengths, target_batch, target_lengths, encoder, decoder, encoder_optimizer, decoder_optimizer, TRAIN=True) print_loss_total += loss if iter % print_every == 0: print "-----------------------------" print "iter " + str(iter) + "/" + str(num_epoch) print "time: "+time.strftime(%Y-%m-%d %H:%M:%S,time.localtime(time.time())) print_loss_avg = (print_loss_total / print_every) if iter > 0 else print_loss_total print_loss_total = 0 print "loss: "+str(print_loss_avg) source_batch, source_lengths, target_batch, target_lengths = get_batch(test_pairs, batch_size) precision = run_epoch(source_batch, source_lengths, target_batch, target_lengths, encoder, decoder, encoder_optimizer, decoder_optimizer, TRAIN=False) print "precision: "+str(precision) if iter % save_every == 0: torch.save(encoder, config.checkpoint_path+"/encoder.model.iter"+str(iter)+".pth") torch.save(decoder, config.checkpoint_path+"/decoder.model.iter"+str(iter)+".pth")

一共訓練 num_epoch 輪,每輪通過 get_batch 這個函數製作一個 batch,運行一次 run_epoch 函數,更新一次模型,然後每隔 print_every 輪進行一次測試並列印結果,每隔 save_every 輪保存一次模型。get_batch 這個函數具體細節不寫了,可以看源碼。

呼,打完收工。

最後,再來一遍結論

  • 問答問題目前的解決方法,框架基本都是上面說那四步,但是具體做法五花八門,模型各式各樣,文中所用的 seq2seq 也只是一個簡單實踐,效果上比較如下圖。簡單的 seq2seq 效果還不錯,雖然跟學術界目前最好成績有很大差距,但是還不錯

out-of-state now 的兩篇論文名:

[1] (EMNLP 2017) No Need to Pay Attention: Simple Recurrent Neural Networks Work !

[2] (NAACL 2016) Question Answering over Knowledge Base using Factual Memory Networks

  • 還是想說,pytorch 真好用啊真好用

推薦閱讀:

Win10 64位安裝Pytorch 0.3
一個優雅的框架 | Pytorch 初體驗
【pytorch】模型的搭建保存載入
PyTorch教程+代碼:色塊秒變風景油畫
關於 PyTorch 0.3.0 在Windows下的安裝和使用

TAG:自然語言處理 | 知識圖譜 | PyTorch |