允中 發自 凹非寺量子位 報導 | 公眾號 QbitAI
編者按:
語言模型的身影遍布在NLP研究中的各個角落,想要了解NLP領域,就不能不知道語言模型。
想要讓模型能落地奔跑,就需藉助深度學習框架之力,Tensorflow、PyTorch自然是主流,但在Dropout都成獨家專利之後,不儲備「B計劃」,多少讓人有些擔驚受怕
這裡有一份飛槳(PaddlePaddle)語言模型應用實例,從基礎概念到代碼實現,娓娓道來,一一說明。現在,量子位分享轉載如下,宜學習,宜收藏。
剛入門深度學習與自然語言處理(NLP)時,在學習了 Goldberg 特別棒的入門書 NN4NLP,斯坦福 cs224n 等等後,也無限次起念頭,寫個系列吧,但都不了了之了。
近來,NLP 領域因為超大預訓練模型,很多研究需要耗費大量計算資源(比如百度新發布持續學習語義理解框架 ERNIE 2.0,該模型在共計 16 個中英文任務上超越了 BERT 和 XLNet,取得了 SOTA 效果),這樣的項目基本上就是在燒錢,小家小戶玩不起,於是就傻傻地等著大佬們發出論文,放出代碼,刷新榜單。不過這也意味著一個總結的好機會,加上額外的推動,便重新起了念頭。
這個系列會介紹我認為現代 NLP 最重要的幾個主題,同時包括它們的實現與講解。
這裡會使用的百度的開源深度學習平臺飛槳(PaddlePaddle),關於這點,有如下幾個原因。
首先,不久前和一個科技媒體朋友聊天,因為當時封鎖華為事件的原因,聊到了美國企業是否可能對我們封鎖深度學習框架,比如說主流的 Tensorflow 和 Pytorch,我當時答是說不定可能呢,畢竟谷歌連 Dropout 都能去申請專利。只要之後改一下許可,不讓使用這些框架的更新,估計我們也沒辦法,於是就想著可以了解一下國內百度的框架飛槳。
去飛槳的 PaddleNLP 看了一下,內容很豐富,感覺飛槳對 NLP 這塊支持非常好,值得關注。
項目地址:
https://github.com/PaddlePaddle/models/tree/develop/PaddleNLP
語言模型
現代 NLP 領域的一個核心便是語言模型 (Language Model),可以說它無處不在,一方面它給 NLP 發展帶來巨大推動,是多個領域的關鍵部分,但另一方面,成也蕭何敗也蕭何,語言模型其實也限制了 NLP 發展,比如說在創新性生成式任務上,還有如何用語言模型獲得雙向信息。
那到底什麼是語言模型?
什麼是語言模型
就是語言的模型(認真臉),開個玩笑,語言模型通俗點講其實就是判斷一句話是不是人話,正式點講就是計算一句話的概率,這個概率值表示這個本文有多大概率是一段正常的文本。
對於一句話,比如說用臉滾出來的一句話:「哦他發看和了犯點就看見發」,很明顯就不像人話,所以語言模型判斷它是人話的概率就小。而一句很常用的話:「好的,謝謝」,語言模型就會給它比較高的概率評分。
用數學的方式來表示,語言模型需要獲得這樣的概率:
其中 X 表示句子,x1,x2… 代表句子中的詞。怎麼計算這樣一個概率呢,一個比較粗暴的方法就是有個非常非常大的語料庫,裡面有各種各樣的句子,然後我們一個個數,來計算不同句子的概率,但稍微想想就知道這個方法不太可能,因為句子組合無窮無盡。
為更好計算,利用條件概率公式和鏈式法則,按照從左到右的句序,可以將公式轉換成:
題變成了如何求解:
怎麼根據前面所有的詞預測下一個詞,當然這個問題對於現在還有點複雜,之後可以用 RNN 模型來計算,但現在讓我們先假設對於一個詞離它近的詞重要性更大,於是基於馬爾可夫性假設,一個詞只依賴它前面 n-1 個詞,這種情況下的語言模型就被稱為 N-gram 語言模型。
比如說基於前面2個詞來預測下一個詞就是 3-gram (tri-gram) 語言模型:
細心些的話,會發現,當 n-gram 中的 n 增大,就會越接近原始語言模型概率方程。
當然n並不是越大越好,因為一旦n過大,計算序列就會變長,在計算時 n-gram 時詞表就會太大,也就會引發所謂的 The Curse of Dimension (維度災難) 。因此一般大家都將n的大小取在3,4,5附近。
早期實現:數一數就知道了
最早了解類似語言模型計算概率,是在研究生階段當時號稱全校最難的資訊理論課上,老師強烈安利香農的經典論文 A Mathematical Theory of Communication,論文中有一小節中,他就給利用類似計算上述語言模型概率的方法,生成了一些文本。
其中一個就是用 2-gram (bi-gram) 的頻率表來生成的,這已經相當於一個 bi-gram 語言模型了。
同樣,要構建這樣一個 n-gram 語言模型,最主要工作就是,基於大量文本來統計 n-gram 頻率。
當時有個課程作業,就是先準備一些英文文本,然後一個一個數 n-gram,之後除以總數算出語言模型中需要的概率估計值,這種方法叫 Count-based Language Model。
傳統 NLP 中搭建語言模型便是這樣,當然還有更多技巧,比如平滑算法,具體可以參考 Jurafsky 教授的書和課。
但這種方法會有一個很大的問題,那就是前面提到的維度災難,而這裡要實現的神經網絡語言模型(Neural Network Language Model),便是用神經網絡構建語言模型,通過學習分布式詞表示(即詞向量)的方式解決了這個問題。
語言模型能幹什麼
不過在談神經網絡語言模型前,我們先來看看語言模型的用途。
那它有什麼用呢,如之前提到,語言模型可以說是現代 NLP 核心之一,無處不在。比如說詞向量,最早算是語言模型的副產品;同時經典的序列到序列(seq2seq) 模型,其中解碼器還可以被稱為,Conditional Language Model(條件語言模型);而現在大火的預訓練模型,主要任務也都是語言模型。
在實際 NLP 應用中,我認為能總結成以下三條:
第一,給句子打分,排序。先在大量文本上訓練,之後就能用獲得的語言模型來評估某句話的好壞。這在對一些生成結果進行重排序時非常有用,能很大程度地提高指標,機器翻譯中有一個技巧便是結合語言模型 Loss 來重排序生成的候選結果。
第二,用於文本生成。首先其訓練方式是根據前面詞,生成之後詞。於是只要不斷重複此過程(自回歸)就能生成長文本了。比較有名的例子就包括最近的 GPT2,其標題就叫 「 Better Language Models and Their Implications.」 它生成的句子效果真的非常棒,可以自己體驗一番 https://talktotransformer.com/.
第三,作為預訓練模型的預訓練任務。最近很火的預訓練模型,幾乎都和語言模型脫不開關係。
比如說 ELMo 就是先訓練雙向 LSTM 語言模型,之後雙向不同層向量拼接獲得最後的 ELMo詞向量,還有 BERT 裡最主要的方法就是 Masked Language Model (遮掩語言模型)。
而最近的 XLNet 中最主要訓練任務也叫做 Permutation language Model (排列語言模型),可見語言模型在其中的重要性重要性。
神經網絡語言模型架構
接下來簡單介紹一下這裡要實現的網絡結構,借鑑自 Bengio 的經典論文 A Neural Probabilistic Language Model 中的模型。
這裡我們訓練 Tri-gram 語言模型,即用前面兩個詞預測當前詞。
於是輸入就是兩個單詞,然後查表取出對應詞向量,之後將兩個詞向量拼接起來,過一個線性層,加入 tanh 激活函數,最後再過線性層輸出分數,通過 softmax 將分數轉換成對各個詞預測的概率,一般取最大概率位置為預測詞。
用公式表達整個過程就是:
整個結構非常簡單,接下來就來看看如何用 飛槳來實現這個結構吧,同時介紹以下 飛槳的基本思想,和一般訓練流程。
項目地址:
https://github.com/PaddlePaddle/models/tree/develop/PaddleNLP/language_model
PaddlePaddle代碼基本實現
這裡拿一個小例子來解說,假設我們在一個叫做 PaddlePaddle 的世界,這個世界的人們只會說三句話,每句話三個詞,我們需要建立一個 Tri-gram 語言模型,來通過一句話前兩個詞預測下一個詞。
關於整個流程,主要分成準備,數據預處理,模型構建,訓練,保存,預測幾個階段,這也是一般一個 NLP 任務的基礎流程。
準備
首先,先導入需要的庫。
import numpy as np import paddle import paddle.fluid as fluid
之後準備訓練數據與詞表,統計所有不同詞,建立詞表,然後按照順序建立一個單詞到 id 的映射表和配套的 id 到單詞映射表。因為模型無法直接讀這些詞,所以需要單詞與 id 之間的轉換。
# 假設在這個叫做Paddle的世界裡,人們只會說這三句話 sentences = ["我 喜歡 Paddle", "Paddle 等於 飛槳", "我 會 Paddle"] vocab = set(' '.join(sentences).split(' ')) # 統計詞表 word2idx = {w: i for i, w in enumerate(word_list)} # 建立單詞到id映射表 idx2word = word_list # id到單詞的映射表 n_vocab = len(word2idx) # 詞表大小
準備好數據後,設置模型參數和訓練相關參數,因為任務很簡單,所以參數都設很小。
# 參數設置 # 語言模型參數 n_step = 2 # 輸入前面多少個詞,tri-gram 所以取 3-1=2 個 n_hidden = 2 # 隱層的單元個數 # 訓練參數 n_epochs = 5000 # 訓練 epoch 數 word_dim = 2 # 詞向量大小 lr = 0.001 # 學習率 use_cuda = False #用不用GPU
數據預處理
根據 PaddlePaddle 數據輸入要求,需要準備數據讀取器 (reader),之後通過它來讀取數據,對輸入數據進行一些前處理,最後作為 batch 輸出。
def sent_reader(): def reader(): batch = [] for sent in sentences: words = sent.split(' ') input_ids = [word2idx[word] for word in words[:-1]] # 將輸入轉為id target_id = word2idx[words[-1]] # 目標轉為id input = np.eye(n_vocab)[input_ids] # 將輸入id轉換成one_hot表示 target = np.array([target_id]) batch.append((input, target)) yield batch return reader
構建模型
這裡從飛槳中較底層 API 來進行構建,理解更透徹。先創建所需參數矩陣,之後按照前面的公式來一步步運算。
def nnlm(one_hots): # 創建所需參數 # 詞向量表 L = fluid.layers.create_parameter(shape=[n_vocab, word_dim], dtype='float32') # 運算所需參數 W1 = fluid.layers.create_parameter(shape=[n_step*word_dim, n_hidden], dtype='float32') b1 = fluid.layers.create_parameter(shape=[n_hidden], dtype='float32', is_bias=True) W2 = fluid.layers.create_parameter(shape=[n_hidden, n_vocab], dtype='float32') b2 = fluid.layers.create_parameter(shape=[n_vocab], dtype='float32', is_bias=True) # 取出詞向量 word_emb = fluid.layers.matmul(one_hots, L) # 兩個詞向量拼接 input = fluid.layers.reshape(x=word_emb, shape=[-1, n_step*word_dim], inplace=True) # 前向運算 input2hid = fluid.layers.tanh(fluid.layers.matmul(input, W1) + b1) # 輸入到隱層 hid2out = fluid.layers.softmax(fluid.layers.matmul(input2hid, W2) + b2) # 隱層到輸出 return hid2out
先根據輸入的獨熱(one-hot)向量,取出對應的詞向量,因為每個例子輸入前兩個詞,因此每個例子可獲得兩個詞向量,之後按照步驟,將它們拼接起來,然後與 W1 和 b1 進行運算,過 tanh 非線性,最後再拿結果與 W2 和 b2 進行運算,softmax 輸出結果。
接下來構建損失函數,我們用常用的交叉熵(cross-entropy)損失函數,直接調 API。
def ce_loss(softmax, target): cost = fluid.layers.cross_entropy(input=softmax, label=target) # 計算每個batch的損失 avg_cost = fluid.layers.mean(cost) # 平均 return avg_cost
開始訓練
終於進入了訓練環節,不過為了更好理解,先稍稍介紹一點 飛槳的設計思想。
飛槳同時為用戶提供動態圖和靜態圖兩種計算圖。動態圖組網更加靈活、調試網絡便捷,實現AI 想法更快速;靜態圖部署方便、運行速度快,應用落地更高效。
如果想了解飛槳動態圖更多內容,可以參考GitHub項目地址:https://github.com/PaddlePaddle/models/tree/v1.5.1/dygraph
實際應用中,靜態圖更為常見,下面我們以靜態圖為例介紹一個完整的實現:
首先,需要先定義 Program,整個 Program 中包括了各種網絡定義,操作等等,定義完之後,再創建一個 Executor 來運行 Program,用過類似框架的同學應該並不陌生。
因此先來看看這兩行代碼,fluid 中最重要的兩個 Program,將它們取出來。
startup_program = fluid.default_startup_program() # 默認啟動程序 main_program = fluid.default_main_program() # 默認主程序
default_startup_program 主要定義了輸入輸出,創建模型參數,還有可學習參數的初始化;而 default_main_program 則是定義了神經網絡模型,前向反向,還有優化算法的更新。
之後將之前定義好的一些模塊放入訓練代碼中。
train_reader = sent_reader() # 獲取數據 reader # 定義輸入和目標數據 input = fluid.layers.data(name='input', shape=[-1, n_step, n_vocab], dtype='float32') target = fluid.layers.data(name='target', shape=[-1, 1], dtype='int64') # 輸入到模型,獲得 loss softmax = nnlm(input) loss = ce_loss(softmax, target)之後還需要定義優化器(Optimizer),還有數據 Feeder 用於餵入數據。# 配置優化器 optimizer = fluid.optimizer.Adam(learning_rate=0.001) # 萬金油的 Adam optimizer.minimize(loss) # 用於之後預測 prediction = fluid.layers.argmax(softmax, axis=-1) # 定義 Executor place = fluid.CUDAPlace(0) if use_cuda else fluid.CPUPlace() # 指定運行位置 exe = fluid.Executor(place) #定義數據 Feeder feeder = fluid.DataFeeder(feed_list=[input, target], place=place) # 每次餵入input和target至此就完成了第一步的定義環節,然後就可以用定義的 Executor 來執行程序了。# 參數初始化 exe.run(startup_program) # 訓練 for epoch in range(n_epochs): for data in train_reader(): metrics = exe.run( main_program, # 主程序 feed=feeder.feed(data), # 數據餵入 fetch_list=[loss]) # 要取出的數據 if epoch % 500 == 0: print("Epoch {}, Cost {:.5f}".format(epoch, step, float(metrics[0][0])))
簡單解釋一下代碼,訓練時需要exe.run來執行每一步的訓練,對於run需要傳入主程序,還有輸入 Feeder,和需要拿出來(fetch)的輸出。
之後運行就能看到訓練 log 了。
能明顯看到 loss 在不斷下降,等訓練完成,我們就獲得一個訓練好的模型。
保存模型
在預測前可以嘗試先保存一個模型,可以便於之後使用,比如 load 出來做預測。
fluid.io.save_inference_model('./model', ['input'], [prediction], exe)
很簡單,只需要傳入保存的路徑』./model』,預測需要 feed 的數據』input』,之後需要 fetch 出的預測結果 prediction,最後加上執行器 exe,就 OK 了。
非常快。
預測階段
預測階段其實和訓練階段類似,但因為主程序都保存下來了,所以只用先建立執行器 Executor,同時建立一個用於預測的作用域。
infer_exe = fluid.Executor(place) # 預測 Executor inference_scope = fluid.core.Scope() # 預測作用域
然後在預測作用域中 load 出模型,進行預測運算,大部分操作都和訓練很類似了。唯一不同就是 load 模型這塊,其實就是把之前保存下來的參數給 load 出來了,然後用於預測。
with fluid.scope_guard(inference_scope): [inference_program, feed_target_names, fetch_targets] = fluid.io.load_inference_model('./model', infer_exe) # 載入預訓練模型 infer_reader = sent_reader() # 定義預測數據 reader infer_data = next(infer_reader()) # 讀出數據 infer_feat = np.array([data[0] for data in infer_data]).astype("float32") assert feed_target_names[0] == 'input' results = infer_exe.run(inference_program, feed={feed_target_names[0]: infer_feat}, fetch_list=fetch_targets) # 進行預測
結果如何?
for sent, idx in zip(sentences, results[0]):print("{} -> {}".format(' '.join(sent.split()[:2]), idx2word[idx]))我 喜歡 -> PaddlePaddle 等於 -> 飛槳我 會 -> Paddle
模型完美地學習到了 PaddlePaddle 世界中僅有的幾個 trigram 規則,當然因為該任務非常簡單,所以模型一下就能學會。
更多嘗試
在了解完以上這個小例子之後,就能在它基礎上做很多修改了,感興趣的同學不妨拿下面的幾個思路作為練習。
比如說用一個大數據集,加上更大模型,來進行訓練,可以嘗試復現 Bengio 論文中的模型規模,大致結構差不多,只是修改一下參數大小。
還比如說,在這裡搭建網絡結構時,用的是較底層API,直接創建矩陣權重,相乘相加,而 飛槳中有很多好用的API,能否調用這些API來重新構建這個模型呢,比如說詞向量部分,可以用fluid.layers.embedding直接傳入詞 id 來實現,還有全連接層,可以直接用 fluid.layers.fc 來實現,激活函數可以直接通過裡面參數設置,非常方便。
其實還可以在這裡嘗試些小技巧,比如共享詞向量表為 softmax 前全連接層的權重 W2,以及加入 Bengio 論文中提到的類似殘差連接直接將 embedding 連到輸出的部分。
這次在這裡介紹神經網絡語言模型,並通過 飛槳來實現了一個簡單的小例子,主要想做的是:
第一,語言模型任務在 NLP 領域很重要,想首先介紹一下;
第二,Bengio 這篇神經網絡語言模型的論文非常經典,比如說提出了用神經網絡實現語言模型,同時還最早提出詞表示來解決「維數災難」問題,通過復現,也好引出之後詞向量,還有seq2seq 等話題;
第三,通過用 飛槳來實現這樣一個簡單例子,可以拋開各種模型與數據複雜度,更直觀了解一個飛槳程序是如何構建的,也為之後講解飛槳更複雜程序打下基礎。