來源:https://www.numpy.org.cn/deep/basics/word2vec.html
詞向量
本教程源代碼目錄在book/word2vec,初次使用請您參考Book文檔使用說明。
#說明
-
本教程可支持在 CPU/GPU 環境下運行
-
Docker鏡像支持的CUDA/cuDNN版本
如果使用了Docker運行Book,請注意:這里所提供的默認鏡像的GPU環境為 CUDA 8/cuDNN 5,對於NVIDIA Tesla V100等要求CUDA 9的 GPU,使用該鏡像可能會運行失敗;
-
文檔和腳本中代碼的一致性問題
請注意:為使本文更加易讀易用,我們拆分、調整了train.py的代碼並放入本文。本文中代碼與train.py的運行結果一致,可直接運行train.py進行驗證。
#背景介紹
本章我們介紹詞的向量表征,也稱為word embedding。詞向量是自然語言處理中常見的一個操作,是搜索引擎、廣告系統、推薦系統等互聯網服務背后常見的基礎技術。
在這些互聯網服務里,我們經常要比較兩個詞或者兩段文本之間的相關性。為了做這樣的比較,我們往往先要把詞表示成計算機適合處理的方式。最自然的方式恐怕莫過於向量空間模型(vector space model)。 在這種方式里,每個詞被表示成一個實數向量(one-hot vector),其長度為字典大小,每個維度對應一個字典里的每個詞,除了這個詞對應維度上的值是1,其他元素都是0。
One-hot vector雖然自然,但是用處有限。比如,在互聯網廣告系統里,如果用戶輸入的query是“母親節”,而有一個廣告的關鍵詞是“康乃馨”。雖然按照常理,我們知道這兩個詞之間是有聯系的——母親節通常應該送給母親一束康乃馨;但是這兩個詞對應的one-hot vectors之間的距離度量,無論是歐氏距離還是余弦相似度(cosine similarity),由於其向量正交,都認為這兩個詞毫無相關性。 得出這種與我們相悖的結論的根本原因是:每個詞本身的信息量都太小。所以,僅僅給定兩個詞,不足以讓我們准確判別它們是否相關。要想精確計算相關性,我們還需要更多的信息——從大量數據里通過機器學習方法歸納出來的知識。
在機器學習領域里,各種“知識”被各種模型表示,詞向量模型(word embedding model)就是其中的一類。通過詞向量模型可將一個 one-hot vector映射到一個維度更低的實數向量(embedding vector),如embedding(母親節)=[0.3,4.2,−1.5,...],embedding(康乃馨)=[0.2,5.6,−2.3,...]embedding(母親節)=[0.3,4.2,−1.5,...],embedding(康乃馨)=[0.2,5.6,−2.3,...]。在這個映射到的實數向量表示中,希望兩個語義(或用法)上相似的詞對應的詞向量“更像”,這樣如“母親節”和“康乃馨”的對應詞向量的余弦相似度就不再為零了。
詞向量模型可以是概率模型、共生矩陣(co-occurrence matrix)模型或神經元網絡模型。在用神經網絡求詞向量之前,傳統做法是統計一個詞語的共生矩陣XX。XX是一個|V|×|V||V|×|V| 大小的矩陣,XijXij表示在所有語料中,詞匯表VV(vocabulary)中第i個詞和第j個詞同時出現的詞數,|V||V|為詞匯表的大小。對XX做矩陣分解(如奇異值分解,Singular Value Decomposition [5]),得到的UU即視為所有詞的詞向量:
但這樣的傳統做法有很多問題:
-
由於很多詞沒有出現,導致矩陣極其稀疏,因此需要對詞頻做額外處理來達到好的矩陣分解效果;
-
矩陣非常大,維度太高(通常達到106×106106×106的數量級);
-
需要手動去掉停用詞(如although, a,...),不然這些頻繁出現的詞也會影響矩陣分解的效果。
基於神經網絡的模型不需要計算和存儲一個在全語料上統計產生的大表,而是通過學習語義信息得到詞向量,因此能很好地解決以上問題。在本章里,我們將展示基於神經網絡訓練詞向量的細節,以及如何用PaddlePaddle訓練一個詞向量模型。
#效果展示
本章中,當詞向量訓練好后,我們可以用數據可視化算法t-SNE[4]畫出詞語特征在二維上的投影(如下圖所示)。從圖中可以看出,語義相關的詞語(如a, the, these; big, huge)在投影上距離很近,語意無關的詞(如say, business; decision, japan)在投影上的距離很遠。
圖1. 詞向量的二維投影
另一方面,我們知道兩個向量的余弦值在[−1,1][−1,1]的區間內:兩個完全相同的向量余弦值為1, 兩個相互垂直的向量之間余弦值為0,兩個方向完全相反的向量余弦值為-1,即相關性和余弦值大小成正比。因此我們還可以計算兩個詞向量的余弦相似度:
please input two words: big huge
similarity: 0.899180685161
please input two words: from company
similarity: -0.0997506977351
以上結果可以通過運行calculate_dis.py
, 加載字典里的單詞和對應訓練特征結果得到,我們將在模型應用中詳細描述用法。
#模型概覽
在這里我們介紹三個訓練詞向量的模型:N-gram模型,CBOW模型和Skip-gram模型,它們的中心思想都是通過上下文得到一個詞出現的概率。對於N-gram模型,我們會先介紹語言模型的概念,並在之后的訓練模型中,帶大家用PaddlePaddle實現它。而后兩個模型,是近年來最有名的神經元詞向量模型,由 Tomas Mikolov 在Google 研發[3],雖然它們很淺很簡單,但訓練效果很好。
#語言模型
在介紹詞向量模型之前,我們先來引入一個概念:語言模型。 語言模型旨在為語句的聯合概率函數P(w1,...,wT)P(w1,...,wT)建模, 其中wiwi表示句子中的第i個詞。語言模型的目標是,希望模型對有意義的句子賦予大概率,對沒意義的句子賦予小概率。 這樣的模型可以應用於很多領域,如機器翻譯、語音識別、信息檢索、詞性標注、手寫識別等,它們都希望能得到一個連續序列的概率。 以信息檢索為例,當你在搜索“how long is a football bame”時(bame是一個醫學名詞),搜索引擎會提示你是否希望搜索"how long is a football game", 這是因為根據語言模型計算出“how long is a football bame”的概率很低,而與bame近似的,可能引起錯誤的詞中,game會使該句生成的概率最大。
對語言模型的目標概率P(w1,...,wT)P(w1,...,wT),如果假設文本中每個詞都是相互獨立的,則整句話的聯合概率可以表示為其中所有詞語條件概率的乘積,即:
然而我們知道語句中的每個詞出現的概率都與其前面的詞緊密相關, 所以實際上通常用條件概率表示語言模型:
#N-gram neural model
在計算語言學中,n-gram是一種重要的文本表示方法,表示一個文本中連續的n個項。基於具體的應用場景,每一項可以是一個字母、單詞或者音節。 n-gram模型也是統計語言模型中的一種重要方法,用n-gram訓練語言模型時,一般用每個n-gram的歷史n-1個詞語組成的內容來預測第n個詞。
Yoshua Bengio等科學家就於2003年在著名論文 Neural Probabilistic Language Models [1] 中介紹如何學習一個神經元網絡表示的詞向量模型。文中的神經概率語言模型(Neural Network Language Model,NNLM)通過一個線性映射和一個非線性隱層連接,同時學習了語言模型和詞向量,即通過學習大量語料得到詞語的向量表達,通過這些向量得到整個句子的概率。因所有的詞語都用一個低維向量來表示,用這種方法學習語言模型可以克服維度災難(curse of dimensionality)。注意:由於“神經概率語言模型”說法較為泛泛,我們在這里不用其NNLM的本名,考慮到其具體做法,本文中稱該模型為N-gram neural model。
我們在上文中已經講到用條件概率建模語言模型,即一句話中第tt個詞的概率和該句話的前t−1t−1個詞相關。可實際上越遠的詞語其實對該詞的影響越小,那么如果考慮一個n-gram, 每個詞都只受其前面n-1
個詞的影響,則有:
給定一些真實語料,這些語料中都是有意義的句子,N-gram模型的優化目標則是最大化目標函數:
其中f(wt,wt−1,...,wt−n+1)f(wt,wt−1,...,wt−n+1)表示根據歷史n-1個詞得到當前詞wtwt的條件概率,R(θ)R(θ)表示參數正則項。
圖2. N-gram神經網絡模型
圖2展示了N-gram神經網絡模型,從下往上看,該模型分為以下幾個部分:
-
對於每個樣本,模型輸入wt−n+1,...wt−1wt−n+1,...wt−1, 輸出句子第t個詞在字典中
|V|
個詞上的概率分布。每個輸入詞wt−n+1,...wt−1wt−n+1,...wt−1首先通過映射矩陣映射到詞向量C(wt−n+1),...C(wt−1)C(wt−n+1),...C(wt−1)。
-
然后所有詞語的詞向量拼接成一個大向量,並經過一個非線性映射得到歷史詞語的隱層表示:
其中,xx為所有詞語的詞向量拼接成的大向量,表示文本歷史特征;θθ、UU、b1b1、b2b2和WW分別為詞向量層到隱層連接的參數。gg表示未經歸一化的所有輸出單詞概率,gigi表示未經歸一化的字典中第ii個單詞的輸出概率。
-
根據softmax的定義,通過歸一化gigi, 生成目標詞wtwt的概率為:
-
整個網絡的損失值(cost)為多類分類交叉熵,用公式表示為
其中yikyki表示第ii個樣本第kk類的真實標簽(0或1),softmax(gik)softmax(gki)表示第i個樣本第k類softmax輸出的概率。
#Continuous Bag-of-Words model(CBOW)
CBOW模型通過一個詞的上下文(各N個詞)預測當前詞。當N=2時,模型如下圖所示:
圖3. CBOW模型
具體來說,不考慮上下文的詞語輸入順序,CBOW是用上下文詞語的詞向量的均值來預測當前詞。即:
其中xtxt為第tt個詞的詞向量,分類分數(score)向量 z=U∗contextz=U∗context,最終的分類yy采用softmax,損失函數采用多類分類交叉熵。
#Skip-gram model
CBOW的好處是對上下文詞語的分布在詞向量上進行了平滑,去掉了噪聲,因此在小數據集上很有效。而Skip-gram的方法中,用一個詞預測其上下文,得到了當前詞上下文的很多樣本,因此可用於更大的數據集。
圖4. Skip-gram模型
如上圖所示,Skip-gram模型的具體做法是,將一個詞的詞向量映射到2n2n個詞的詞向量(2n2n表示當前輸入詞的前后各nn個詞),然后分別通過softmax得到這2n2n個詞的分類損失值之和。
#數據准備
#數據介紹
本教程使用Penn Treebank (PTB)(經Tomas Mikolov預處理過的版本)數據集。PTB數據集較小,訓練速度快,應用於Mikolov的公開語言模型訓練工具[2]中。其統計情況如下:
訓練數據 | 驗證數據 | 測試數據 |
ptb.train.txt | ptb.valid.txt | ptb.test.txt |
42068句 | 3370句 | 3761句 |
#數據預處理
本章訓練的是5-gram模型,表示在PaddlePaddle訓練時,每條數據的前4個詞用來預測第5個詞。PaddlePaddle提供了對應PTB數據集的python包paddle.dataset.imikolov
,自動做數據的下載與預處理,方便大家使用。
預處理會把數據集中的每一句話前后加上開始符號<s>
以及結束符號<e>
。然后依據窗口大小(本教程中為5),從頭到尾每次向右滑動窗口並生成一條數據。
如"I have a dream that one day" 一句提供了5條數據:
<s> I have a dream
I have a dream that
have a dream that one
a dream that one day
dream that one day <e>
最后,每個輸入會按其單詞次在字典里的位置,轉化成整數的索引序列,作為PaddlePaddle的輸入。
#編程實現
本配置的模型結構如下圖所示:
圖5. 模型配置中的N-gram神經網絡模型
首先,加載所需要的包:
from __future__ import print_function import paddle import paddle.fluid as fluid import six import numpy import math
然后,定義參數:
EMBED_SIZE = 32 # embedding維度 HIDDEN_SIZE = 256 # 隱層大小 N = 5 # ngram大小,這里固定取5 BATCH_SIZE = 100 # batch大小 PASS_NUM = 100 # 訓練輪數 use_cuda = False # 如果用GPU訓練,則設置為True word_dict = paddle.dataset.imikolov.build_dict() dict_size = len(word_dict)
更大的BATCH_SIZE
將使得訓練更快收斂,但也會消耗更多內存。由於詞向量計算規模較大,如果環境允許,請開啟使用GPU進行訓練,能更快得到結果。 不同於之前的PaddlePaddle v2版本,在新的Fluid版本里,我們不必再手動計算詞向量。PaddlePaddle提供了一個內置的方法fluid.layers.embedding
,我們就可以直接用它來構造 N-gram 神經網絡。
- 我們來定義我們的 N-gram 神經網絡結構。這個結構在訓練和預測中都會使用到。因為詞向量比較稀疏,我們傳入參數
is_sparse == True
, 可以加速稀疏矩陣的更新。
def inference_program(words, is_sparse): embed_first = fluid.layers.embedding( input=words[0], size=[dict_size, EMBED_SIZE], dtype='float32', is_sparse=is_sparse, param_attr='shared_w') embed_second = fluid.layers.embedding( input=words[1], size=[dict_size, EMBED_SIZE], dtype='float32', is_sparse=is_sparse, param_attr='shared_w') embed_third = fluid.layers.embedding( input=words[2], size=[dict_size, EMBED_SIZE], dtype='float32', is_sparse=is_sparse, param_attr='shared_w') embed_fourth = fluid.layers.embedding( input=words[3], size=[dict_size, EMBED_SIZE], dtype='float32', is_sparse=is_sparse, param_attr='shared_w') concat_embed = fluid.layers.concat( input=[embed_first, embed_second, embed_third, embed_fourth], axis=1) hidden1 = fluid.layers.fc(input=concat_embed, size=HIDDEN_SIZE, act='sigmoid') predict_word = fluid.layers.fc(input=hidden1, size=dict_size, act='softmax') return predict_word
- 基於以上的神經網絡結構,我們可以如下定義我們的訓練方法
def train_program(predict_word): # 'next_word'的定義必須要在inference_program的聲明之后, # 否則train program輸入數據的順序就變成了[next_word, firstw, secondw, # thirdw, fourthw], 這是不正確的. next_word = fluid.layers.data(name='nextw', shape=[1], dtype='int64') cost = fluid.layers.cross_entropy(input=predict_word, label=next_word) avg_cost = fluid.layers.mean(cost) return avg_cost def optimizer_func(): return fluid.optimizer.AdagradOptimizer( learning_rate=3e-3, regularization=fluid.regularizer.L2DecayRegularizer(8e-4))
- 現在我們可以開始訓練啦。如今的版本較之以前就簡單了許多。我們有現成的訓練和測試集:
paddle.dataset.imikolov.train()
和paddle.dataset.imikolov.test()
。兩者都會返回一個讀取器。在PaddlePaddle中,讀取器是一個Python的函數,每次調用,會讀取下一條數據。它是一個Python的generator。
paddle.batch
會讀入一個讀取器,然后輸出一個批次化了的讀取器。我們還可以在訓練過程中輸出每個步驟,批次的訓練情況。
def train(if_use_cuda, params_dirname, is_sparse=True): place = fluid.CUDAPlace(0) if if_use_cuda else fluid.CPUPlace() train_reader = paddle.batch( paddle.dataset.imikolov.train(word_dict, N), BATCH_SIZE) test_reader = paddle.batch( paddle.dataset.imikolov.test(word_dict, N), BATCH_SIZE) first_word = fluid.layers.data(name='firstw', shape=[1], dtype='int64') second_word = fluid.layers.data(name='secondw', shape=[1], dtype='int64') third_word = fluid.layers.data(name='thirdw', shape=[1], dtype='int64') forth_word = fluid.layers.data(name='fourthw', shape=[1], dtype='int64') next_word = fluid.layers.data(name='nextw', shape=[1], dtype='int64') word_list = [first_word, second_word, third_word, forth_word, next_word] feed_order = ['firstw', 'secondw', 'thirdw', 'fourthw', 'nextw'] main_program = fluid.default_main_program() star_program = fluid.default_startup_program() predict_word = inference_program(word_list, is_sparse) avg_cost = train_program(predict_word) test_program = main_program.clone(for_test=True) sgd_optimizer = optimizer_func() sgd_optimizer.minimize(avg_cost) exe = fluid.Executor(place) def train_test(program, reader): count = 0 feed_var_list = [ program.global_block().var(var_name) for var_name in feed_order ] feeder_test = fluid.DataFeeder(feed_list=feed_var_list, place=place) test_exe = fluid.Executor(place) accumulated = len([avg_cost]) * [0] for test_data in reader(): avg_cost_np = test_exe.run( program=program, feed=feeder_test.feed(test_data), fetch_list=[avg_cost]) accumulated = [ x[0] + x[1][0] for x in zip(accumulated, avg_cost_np) ] count += 1