指針生成網路(Pointer-Generator-Network)原理與實戰
- 2019 年 10 月 3 日
- 筆記
0 前言
本文主要內容:介紹Pointer-Generator-Network在文本摘要任務中的背景,模型架構與原理、在中英文數據集上實戰效果與評估,最後得出結論。參考的《Get To The Point: Summarization with Pointer-Generator Networks》以及多篇部落格均在文末給出連接,文中使用數據集已上傳百度網盤,程式碼已傳至GitHub,讀者可以在文中找到相應連接,實際操作過程中確實遇到很多坑,並未在文中一一指明,有興趣的讀者可以留言一起交流。由於水平有限,請讀者多多指正。
隨著互聯網飛速發展,產生了越來越多的文本數據,文本資訊過載問題日益嚴重,對各類文本進行一個“降 維”處理顯得非常必要,文本摘要便是其中一個重要的手段。文本摘要旨在將文本或文本集合轉換為包含關鍵資訊的簡短摘要。按照輸出類型可分為抽取式摘要和生成式摘要。抽取式摘要從源文檔中抽取關鍵句和關鍵片語成摘要,摘要全部來源於原文。生成式摘要根據原文,允許生成新的詞語、原文本中沒有的短語來組成摘要。
指針生成網路屬於生成式模型。
僅用Neural sequence-to-sequence模型可以實現生成式摘要,但存在兩個問題:
1. 可能不準確地再現細節, 無法處理辭彙不足(OOV)單詞;
2. 傾向於重複自己。
原文是(they are liable to reproducefactual details inaccurately, and they tendto repeat themselves.)
指針生成網路(Pointer-Generator-Network)從兩個方面進行了改進:
1. 該網路通過指向(pointer)從源文本中複製單詞,有助於準確地複製資訊,同時保留通過生成器產生新單詞的能力;
2. 使用coverage機制來跟蹤已總結的內容,防止重複。
接下來從下面幾個部分介紹Pointer-Generator-Network原理:
1. Baseline sequence-to-sequence;
2. Pointer-Generator-Network;
3. Coverage Mechanism。
1 Baseline sequence-to-sequence

Seq2Seq的模型結構是經典的Encoder-Decoder模型,即先用Encoder將原文本編碼成一個中間層的隱藏狀態,然後用Decoder來將該隱藏狀態解碼成為另一個文本。Baseline Seq2Seq在Encoder端是一個雙向的LSTM,這個雙向的LSTM可以捕捉原文本的長距離依賴關係以及位置資訊,編碼時詞嵌入經過雙向LSTM後得到編碼狀態 $h_i$ 。在Decoder端,解碼器是一個單向的LSTM,訓練階段時參考摘要詞依次輸入(測試階段時是上一步的生成詞),在時間步 $t$得到解碼狀態 $s_t$ 。使用$h_i$和$s_t$得到該時間步原文第 $i$個詞注意力權重。
得到的注意力權重和 $h_i$加權求和得到重要的上下文向量 $h_t^*(context vector)$:
$$h_{t}^{*} = sum_{i}{a_i^t h_i}$$
$h_t^*$可以看成是該時間步通讀了原文的固定尺寸的表徵。然後將 $s_t$和 $h_t^*$ 經過兩層線性層得到單詞表分布 $P_{vocab}$:
$$P_{vocab} = softmax(V'(V[s_t, h_t^*] + b) + b’)$$
其中 $[s_t, h_t^*]$是拼接。這樣再通過$sofmax$得到了一個概率分布,就可以預測需要生成的詞:
$$P(w) = P_{vocab}(w)$$
在訓練階段,時間步 $t$ 時的損失為:
$$loss_{t} = -logP(w_t^*)$$
那麼原輸入序列的整體損失為:
$$loss = frac{1}{T} sum_{t=0}^{T}loss_t$$
2 Pointer-Generator-Network
原文中的Pointer-Generator Networks是一個混合了 Baseline seq2seq和PointerNetwork的網路,它具有Baseline seq2seq的生成能力和PointerNetwork的Copy能力。該網路的結構如下:
如何權衡一個詞應該是生成的還是複製的?
原文中引入了一個權重 $p_{gen}$ 。
從Baseline seq2seq的模型結構中得到了$s_t$ 和$h_t^*$,和解碼器輸入 $x_t$ 一起來計算 $p_{gen}$ :
$$p_{gen} = sigma(w_{h^*}^T h_t^* + w_s^Ts_t + w_x^Tx_t + b_{ptr})$$
這時,會擴充單詞表形成一個更大的單詞表–擴充單詞表(將原文當中的單詞也加入到其中),該時間步的預測詞概率為:
$$P(w) = p_{gen}P_{vocab}(w) + (1 – p_{gen}) sum_{i:w_i=w} a_i^t$$
其中 $a_i^t$ 表示的是原文檔中的詞。我們可以看到解碼器一個詞的輸出概率有其是否拷貝是否生成的概率和決定。當一個詞不出現在常規的單詞表上時 $P_{vocab}(w)$ 為0,當該詞不出現在文檔中$ sum_{i:w_i=w} a_i^t$為0。
3 Coverage mechanism
原文的特色是運用了Coverage Mechanism來解決重複生成文本的問題,下圖反映了前兩個模型與添加了Coverage Mechanism生成摘要的結果:
藍色的字體表示的是參考摘要,三個模型的生成摘要的結果差別挺大;
紅色字體表明了不準確的摘要細節生成(UNK未登錄詞,無法解決OOV問題);
綠色的字體表明了模型生成了重複文本。
為了解決此問題–Repitition,原文使用了在機器翻譯中解決“過翻譯”和“漏翻譯”的機制–Coverage Mechanism。
具體實現上,就是將先前時間步的注意力權重加到一起得到所謂的覆蓋向量 $c^t (coverage vector)$,用先前的注意力權重決策來影響當前注意力權重的決策,這樣就避免在同一位置重複,從而避免重複生成文本。計算上,先計算coverage vector $c^t$:
$$c^t = sum_{t’=0}^{t-1}a^{t’}$$
然後添加到注意力權重的計算過程中,$c^t$用來計算 $e_i^t$:
$$e_i^t = v^T tanh(W_{h}h_i + W_{s}s_t + w_{c}c_i^t + b_{attn})$$
同時,為coverage vector添加損失是必要的,coverage loss計算方式為:
$$covloss_{t} = sum_{i}min(a_i^t, c_i^t)$$
這樣coverage loss是一個有界的量 $covloss_t leq sum_{i}a_i^t = 1$ 。因此最終的LOSS為:
$$loss_t = -logP(w_t^*) + lambda sum_{i}min(a_i^t, c_i^t)$$
4 實戰部分
4.1 DataSet
英文數據集: cnn dailymail數據集,地址:https://github.com/becxer/cnn-dailymail/。
中文數據集:新浪微博摘要數據集,這是中文數據集,有679898條文本及摘要。
中英文數據集均可從這裡下載,鏈接:https://pan.baidu.com/s/18ykewFUrTLzW8R84bF42pg 密碼:9yqt。
4.2 Experiments
試驗環境:centos7.4/python3.6/tensorflow1.12.0 GPU:Tesla-K40m-12G*4 程式碼參考:python3 tensorflow版本。調試時候各種報錯,所以需要debug。
改動後的程式碼已上傳至GitHub:https://github.com/zingp/NLP/tree/master/P007PytorchPointerGeneratorNetwork。
中文數據集預處理程式碼:
第一部分是對原始數據進行分詞,劃分訓練集測試集,並保存文件。
import os import sys import time import jieba ARTICLE_FILE = "./data/weibo_news/train_text.txt" SUMMARRY_FILE = "./data/weibo_news/train_label.txt" TRAIN_FILE = "./data/weibo_news/train_art_summ_prep.txt" VAL_FILE = "./data/weibo_news/val_art_summ_prep.txt" def timer(func): def wrapper(*args, **kwargs): start = time.time() r = func(*args, **kwargs) end = time.time() cost = end - start print(f"Cost time: {cost} s") return r return wrapper @timer def load_data(filename): """載入數據文件,對文本進行分詞""" data_list = [] with open(filename, 'r', encoding= 'utf-8') as f: for line in f: # jieba.enable_parallel() words = jieba.cut(line.strip()) word_list = list(words) # jieba.disable_parallel() data_list.append(' '.join(word_list).strip()) return data_list def build_train_val(article_data, summary_data, train_num=600_000): """劃分訓練和驗證數據""" train_list = [] val_list = [] n = 0 for text, summ in zip(article_data, summary_data): n += 1 if n <= train_num: train_list.append(text) train_list.append(summ) else: val_list.append(text) val_list.append(summ) return train_list, val_list def save_file(filename, li): """預處理後的數據保存到文件""" with open(filename, 'w+', encoding='utf-8') as f: for item in li: f.write(item + 'n') print(f"Save {filename} ok.") if __name__ == '__main__': article_data = load_data(ARTICLE_FILE) # 大概耗時10分鐘 summary_data = load_data(SUMMARRY_FILE) TRAIN_SPLIT = 600_000 train_list, val_list = build_train_val(article_data, summary_data, train_num=TRAIN_SPLIT) save_file(TRAIN_FILE, train_list) save_file(VAL_FILE, val_list)
第二部分是將文件打包,生成模型能夠載入的二進位文件。
import os import struct import collections from tensorflow.core.example import example_pb2 # 經過分詞處理後的訓練數據與測試數據文件 TRAIN_FILE = "./data/weibo_news/train_art_summ_prep.txt" VAL_FILE = "./data/weibo_news/val_art_summ_prep.txt" # 文本起始與結束標誌 SENTENCE_START = '<s>' SENTENCE_END = '</s>' VOCAB_SIZE = 50_000 # 辭彙表大小 CHUNK_SIZE = 1000 # 每個分塊example的數量,用於分塊的數據 # tf模型數據文件存放目錄 FINISHED_FILE_DIR = './data/weibo_news/finished_files' CHUNKS_DIR = os.path.join(FINISHED_FILE_DIR, 'chunked') def chunk_file(finished_files_dir, chunks_dir, name, chunk_size): """構建二進位文件""" in_file = os.path.join(finished_files_dir, '%s.bin' % name) print(in_file) reader = open(in_file, "rb") chunk = 0 finished = False while not finished: chunk_fname = os.path.join(chunks_dir, '%s_%03d.bin' % (name, chunk)) # 新的分塊 with open(chunk_fname, 'wb') as writer: for _ in range(chunk_size): len_bytes = reader.read(8) if not len_bytes: finished = True break str_len = struct.unpack('q', len_bytes)[0] example_str = struct.unpack('%ds' % str_len, reader.read(str_len))[0] writer.write(struct.pack('q', str_len)) writer.write(struct.pack('%ds' % str_len, example_str)) chunk += 1 def chunk_all(): # 創建一個文件夾來保存分塊 if not os.path.isdir(CHUNKS_DIR): os.mkdir(CHUNKS_DIR) # 將數據分塊 for name in ['train', 'val']: print("Splitting %s data into chunks..." % name) chunk_file(FINISHED_FILE_DIR, CHUNKS_DIR, name, CHUNK_SIZE) print("Saved chunked data in %s" % CHUNKS_DIR) def read_text_file(text_file): """從預處理好的文件中載入數據""" lines = [] with open(text_file, "r", encoding='utf-8') as f: for line in f: lines.append(line.strip()) return lines def write_to_bin(input_file, out_file, makevocab=False): """生成模型需要的文件""" if makevocab: vocab_counter = collections.Counter() with open(out_file, 'wb') as writer: # 讀取輸入的文本文件,使偶數行成為article,奇數行成為abstract(行號從0開始) lines = read_text_file(input_file) for i, new_line in enumerate(lines): if i % 2 == 0: article = lines[i] if i % 2 != 0: abstract = "%s %s %s" % (SENTENCE_START, lines[i], SENTENCE_END) # 寫入tf.Example tf_example = example_pb2.Example() tf_example.features.feature['article'].bytes_list.value.extend([bytes(article, encoding='utf-8')]) tf_example.features.feature['abstract'].bytes_list.value.extend([bytes(abstract, encoding='utf-8')]) tf_example_str = tf_example.SerializeToString() str_len = len(tf_example_str) writer.write(struct.pack('q', str_len)) writer.write(struct.pack('%ds' % str_len, tf_example_str)) # 如果可以,將詞典寫入文件 if makevocab: art_tokens = article.split(' ') abs_tokens = abstract.split(' ') abs_tokens = [t for t in abs_tokens if t not in [SENTENCE_START, SENTENCE_END]] # 從詞典中刪除這些符號 tokens = art_tokens + abs_tokens tokens = [t.strip() for t in tokens] # 去掉句子開頭結尾的空字元 tokens = [t for t in tokens if t != ""] # 刪除空行 vocab_counter.update(tokens) print("Finished writing file %sn" % out_file) # 將詞典寫入文件 if makevocab: print("Writing vocab file...") with open(os.path.join(FINISHED_FILE_DIR, "vocab"), 'w', encoding='utf-8') as writer: for word, count in vocab_counter.most_common(VOCAB_SIZE): writer.write(word + ' ' + str(count) + 'n') print("Finished writing vocab file") if __name__ == '__main__': if not os.path.exists(FINISHED_FILE_DIR): os.makedirs(FINISHED_FILE_DIR) write_to_bin(VAL_FILE, os.path.join(FINISHED_FILE_DIR, "val.bin")) write_to_bin(TRAIN_FILE, os.path.join(FINISHED_FILE_DIR, "train.bin"), makevocab=True) chunk_all()
在訓練中文數據集的時候,設置的hidden_dim為 256 ,詞向量維度emb_dim為126,辭彙表數目vocab_size為50K,batch_size設為16。這裡由於我們的模型有處理OOV能力,因此辭彙表不用設置過大;在batch_size的選擇上,顯示記憶體小的同學建議設為8,否則會出現記憶體不夠,難以訓練。
在batch_size=16時,訓練了27k step, 出現loss震蕩很難收斂的情況,train階段loss如下:
可以看到當step在10k之後,loss在3.0-5.0之間來回劇烈震蕩,並沒有下降趨勢。前面我們為了省顯示記憶體,將batch_size設置成16,可能有點小了,梯度下降方向不太明確,顯得有點盲目,因此將batch_size設成了32後重新開始訓練。注意:在一定範圍內,batchsize越大,計算得到的梯度下降方向就越准,引起訓練震蕩越小。增大batch_size後訓練的loss曲線如下:
val loss曲線如下:
看起來loss還是比較震蕩的,但是相比bathc_size=16時有所改善。一開始的前10K steps里loss下降還是很明顯的基本上能從6降到4左右的區間,10k steps之後開始震蕩,但還是能看到在緩慢下降:從4左右,開始在2-4之間震蕩下降。這可能是目前的steps還比較少,只要val loss沒有一直升高,可以繼續觀擦,如果500K steps都還是如此,可以考慮在一個合適的實機early stop。
4.3 Evaluation
摘要品質評價需要考慮一下三點:
(1) 決定原始文本最重要的、需要保留的部分;
(2) 在自動文本摘要中識別出1中的部分;
(3) 基於語法和連貫性(coherence)評價摘要的可讀性(readability)。
從這三點出發有人工評價和自動評價,本文只討論一下更值得關注的自動評價。自動文檔摘要評價方法分為兩類:
內部評價方法(Intrinsic Methods):提供參考摘要,以參考摘要為基準評價系統摘要的品質。系統摘要與參考摘要越吻合, 品質越高。
外部評價方法(Extrinsic Methods):不提供參考摘要,利用文檔摘要代替原文檔執行某個文檔相關的應用。
內部評價方法是最常使用的文摘評價方法,將系統生成的自動摘要與參考摘要採用一定的方法進行比較是目前最為常見的文摘評價模式。下面介紹內部評價方法是ROUGE(Recall-Oriented Understudy for Gisting Evaluation)。
ROUGE是2004年由ISI的Chin-Yew Lin提出的一種自動摘要評價方法,現被廣泛應用於DUC(Document Understanding Conference)的摘要評測任務中。ROUGE基於摘要中n元詞(n-gram)的共現資訊來評價摘要,是一種面向n元詞召回率的評價方法。基本思想為由多個專家分別生成人工摘要,構成標準摘要集,將系統生成的自動摘要與人工生成的標準摘要相對比,通過統計二者之間重疊的基本單元(n元語法、詞序列和詞對)的數目,來評價摘要的品質。通過與專家人工摘要的對比,提高評價系統的穩定性和健壯性。該方法現已成為摘要評價技術的通用標註之一。 ROUGE準則由一系列的評價方法組成,包括ROUGE-N(N=1、2、3、4,分別代表基於1元詞到4元詞的模型),ROUGE-L,ROUGE-S, ROUGE-W,ROUGE-SU等。在自動文摘相關研究中,一般根據自己的具體研究內容選擇合適的ROUGE方法。公式如下:
$$ROUGE-N = frac{sum_{Sin left{ ReferenceSummariesright} } sum_{gram_n in S}Count_{match}(gram_n)} {sum_{Sin left{ ReferenceSummariesright} } sum_{gram_n in S}Count(gram_n)}$$
其中,$n-gram$表示n元詞,${Ref Summaries}$表示參考摘要(標準摘要),$Count_{match}(n-gram)$表示生成摘要和參考摘要中同時出現$n-gram$的個數,$Count(n-gram)$則表示參考摘要中出現的$n- gram$個數。ROUGE公式是由召回率的計算公式演變而來的,分子可以看作“檢出的相關文檔數目”,即系統生成摘要與標準摘要相匹配的$n-gram$個數,分母可以看作“相關文檔數目”,即參考摘要中所有的$n-gram$個數。
來看原文試驗結果:
在上表中,上半部分是模型生成的的摘要評估,而下半部分的是提取摘要評估。可以看出抽象生成的效果接近了抽取效果。再來看重複情況:
可以看出我們的no coverage的模型生成的摘要在n-gram上是要比reference摘要要多的,而使用了coverage之後,重複數目和reference相當。

例子二:
直觀上效果還是不錯的。可以看出,預測的摘要中已經基本沒有不斷重複自身的現象;像“[話筒] [思考] [吃驚] ”這種文本,應該是原文本中的表情,在對文本的處理中我們並沒有將這些清洗掉,因此依然出現在預測摘要中。不過例子二還是出現了句子不是很通順的情況,在輸出句子的語序連貫上還有待改進。
4.4 Results
1. 在復現原論文的基礎上,將模型方法應用在中文數據集上,取得了一定效果。
2. 可以看出指針生成網路通過指針複製原文中的單詞,可以生成新的單詞,解決oov問題;其次使用了coverage機制,能夠避免生成的詞語不斷重複。
3. 在語句的通順和連貫上還有待加強。