1. 程式人生 > >Word2vec 原理公式推到和程式碼實現

Word2vec 原理公式推到和程式碼實現

本文摘錄整編了一些理論介紹,推導了word2vec中的數學原理;並考察了一些常見的word2vec實現,評測其準確率等效能,最後分析了word2vec原版C程式碼;針對沒有好用的Java實現的現狀,移植了原版C程式到Java。時間和水平有限,本文沒有就其發展歷史展開多談,只記錄了必要的知識點,並著重關注工程實踐。

word2vec_diagram.png

雖然我的Java方案速度比原版C程式高出1倍,在演算法程式碼與原版C程式一致的情況下準確率仍然略低於原版C程式(不過依然是目前準確率最高的Java實現),並非完美,還有待改進。

本文的理論部分大量參考《word2vec中的數學原理詳解》,按照我這種初學者方便理解的順序重新編排、重新敘述。

背景

語言模型

在統計自然語言處理中,語言模型指的是計算一個句子的概率模型。

傳統的語言模型中詞的表示是原始的、面向字串的。兩個語義相似的詞的字串可能完全不同,比如“番茄”和“西紅柿”。這給所有NLP任務都帶來了挑戰——字串本身無法儲存語義資訊。該挑戰突出表現在模型的平滑問題上:標註語料是有限的,而語言整體是無限的,傳統模型無法借力未標註的海量語料,只能靠人工設計平滑演算法,而這些演算法往往效果甚微。

神經概率語言模型(Neural Probabilistic Language Model)中詞的表示是向量形式、面向語義的。兩個語義相似的詞對應的向量也是相似的,具體反映在夾角或距離上。甚至一些語義相似的二元片語中的詞語對應的向量做線性減法之後得到的向量依然是相似的。詞的向量表示可以顯著提高傳統NLP任務的效能,例如《

基於神經網路的高效能依存句法分析器》中介紹的詞、詞性、依存關係的向量化對正確率的提升等。

從向量的角度來看,字串形式的詞語其實是更高維、更稀疏的向量。若詞彙表大小為N,每個字串形式的詞語字典序為i,則其被表示為一個N維向量,該向量的第i維為1,其他維都為0。漢語的詞彙量大約在十萬這個量級,十萬維的向量對計算來講絕對是個維度災難。而word2vec得到的詞的向量形式(下文簡稱“詞向量”,更學術化的翻譯是“詞嵌入”)則可以自由控制維度,一般是100左右。

word2vec

word2vec作為神經概率語言模型的輸入,其本身其實是神經概率模型的副產品,是為了通過神經網路學習某個語言模型而產生的中間結果。具體來說,“某個語言模型”指的是“CBOW”和“Skip-gram”。具體學習過程會用到兩個降低複雜度的近似方法——Hierarchical Softmax或Negative Sampling。兩個模型乘以兩種方法,一共有四種實現。這些內容就是本文理論部分要詳細闡明的全部了。

Hierarchical Softmax

模型共同點

無論是哪種模型,其基本網路結構都是在下圖的基礎上,省略掉hidden layer:

螢幕快照 2016-07-14 下午7.30.23.png

為什麼要去掉這一層呢?據說是因為word2vec的作者嫌從hidden layer到output layer的矩陣運算太多了。於是兩種模型的網路結構是:

螢幕快照 2016-07-14 下午7.27.35.png

其中w(t)代表當前詞語位於句子的位置t,同理定義其他記號。在視窗內(上圖為視窗大小為5),除了當前詞語之外的其他詞語共同構成上下文。

CBOW

原理

CBOW 是 Continuous Bag-of-Words Model 的縮寫,是一種根據上下文的詞語預測當前詞語的出現概率的模型。其圖示如上圖左。

CBOW是已知上下文,估算當前詞語的語言模型。其學習目標是最大化對數似然函式:

螢幕快照 2016-07-14 下午7.45.11.png

其中,w表示語料庫C中任意一個詞。從上圖可以看出,對於CBOW,

輸入層是上下文的詞語的詞向量(什麼!我們不是在訓練詞向量嗎?不不不,我們是在訓練CBOW模型,詞向量只是個副產品,確切來說,是CBOW模型的一個引數。訓練開始的時候,詞向量是個隨機值,隨著訓練的進行不斷被更新)。

投影層對其求和,所謂求和,就是簡單的向量加法。

輸出層輸出最可能的w。由於語料庫中詞彙量是固定的|C|個,所以上述過程其實可以看做一個多分類問題。給定特徵,從|C|個分類中挑一個。

對於神經網路模型多分類,最樸素的做法是softmax迴歸:

神經網路依存句法分析29.png

softmax迴歸需要對語料庫中每個詞語(類)都計算一遍輸出概率並進行歸一化,在幾十萬詞彙量的語料上無疑是令人頭疼的。

不用softmax怎麼樣?比如SVM中的多分類,我們都知道其多分類是由二分類組合而來的:

svm_多分類.gif

這是一種二叉樹結構,應用到word2vec中被作者稱為Hierarchical Softmax:

螢幕快照 2016-07-17 上午9.13.40.png

上圖輸出層的樹形結構即為Hierarchical Softmax。

非葉子節點相當於一個神經元(感知機,我認為邏輯斯諦迴歸就是感知機的輸出代入f(x)=1/(1+e^x)),二分類決策輸出1或0,分別代表向下左轉或向下右轉;每個葉子節點代表語料庫中的一個詞語,於是每個詞語都可以被01唯一地編碼,並且其編碼序列對應一個事件序列,於是我們可以計算條件概率螢幕快照 2016-07-17 上午10.05.33.png

在開始計算之前,還是得引入一些符號:

  1. 螢幕快照 2016-07-17 上午9.59.45.png從根結點出發到達w對應葉子結點的路徑.

  2. 螢幕快照 2016-07-17 上午10.00.06.png路徑中包含結點的個數

  3. 螢幕快照 2016-07-17 上午10.01.17.png路徑螢幕快照 2016-07-17 上午9.59.45.png中的各個節點

  4. 螢幕快照 2016-07-17 上午10.02.33.png詞w的編碼,螢幕快照 2016-07-17 上午10.03.27.png表示路徑螢幕快照 2016-07-17 上午9.59.45.png第j個節點對應的編碼(根節點無編碼)

  5. 螢幕快照 2016-07-17 上午10.04.18.png路徑螢幕快照 2016-07-17 上午9.59.45.png中非葉節點對應的引數向量

    於是可以給出w的條件概率:

螢幕快照 2016-07-17 上午10.07.18.png

這是個簡單明瞭的式子,從根節點到葉節點經過了螢幕快照 2016-07-17 上午10.00.06.png-1個節點,編碼從下標2開始(根節點無編碼),對應的引數向量下標從1開始(根節點為1)。

其中,每一項是一個邏輯斯諦迴歸:

螢幕快照 2016-07-17 上午10.15.37.png

考慮到d只有0和1兩種取值,我們可以用指數形式方便地將其寫到一起:

螢幕快照 2016-07-17 上午10.21.31.png

我們的目標函式取對數似然:

螢幕快照 2016-07-17 上午10.23.25.png

螢幕快照 2016-07-17 上午10.05.33.png代入上式,有

螢幕快照 2016-07-17 上午10.25.37.png

這也很直白,連乘的對數換成求和。不過還是有點長,我們把每一項簡記為:

螢幕快照 2016-07-17 上午10.27.15.png

怎麼最大化對數似然函式呢?分別最大化每一項即可(這應該是一種近似,最大化某一項不一定使整體增大,具體收斂的證明還不清楚)。怎麼最大化每一項呢?先求函式對每個變數的偏導數,對每一個樣本,代入偏導數表示式得到函式在該維度的增長梯度,然後讓對應引數加上這個梯度,函式在這個維度上就增長了。這種白話描述的演算法在學術上叫隨機梯度上升法。

每一項有兩個引數,一個是每個節點的引數向量螢幕快照 2016-07-17 上午10.58.10.png,另一個是輸出層的輸入螢幕快照 2016-07-17 上午10.52.10.png,我們分別對其求偏導數:

螢幕快照 2016-07-17 上午10.52.59.png

因為sigmoid函式的導數有個很棒的形式:

螢幕快照 2016-07-17 上午10.54.30.png

於是代入上上式得到:

螢幕快照 2016-07-17 上午10.56.15.png

合併同類項得到:

螢幕快照 2016-07-17 上午10.57.17.png

於是螢幕快照 2016-07-17 上午10.58.10.png的更新表示式就得到了:

螢幕快照 2016-07-17 上午10.59.08.png

其中,螢幕快照 2016-07-17 上午10.59.48.png是機器學習的老相好——學習率,通常取0-1之間的一個值。學習率越大訓練速度越快,但目標函式容易在區域性區域來回抖動。

再來螢幕快照 2016-07-17 上午10.52.10.png的偏導數,注意到螢幕快照 2016-07-17 上午10.27.15.png螢幕快照 2016-07-17 上午10.52.10.png螢幕快照 2016-07-17 上午10.58.10.png是對稱的,所有直接將螢幕快照 2016-07-17 上午10.58.10.png的偏導數中的螢幕快照 2016-07-17 上午10.58.10.png替換為螢幕快照 2016-07-17 上午10.52.10.png,得到關於螢幕快照 2016-07-17 上午10.52.10.png的偏導數:

螢幕快照 2016-07-17 上午11.04.49.png

於是螢幕快照 2016-07-17 上午10.52.10.png的更新表示式也得到了。

不過螢幕快照 2016-07-17 上午10.52.10.png是上下文的詞向量的和,不是上下文單個詞的詞向量。怎麼把這個更新量應用到單個詞的詞向量上去呢?word2vec採取的是直接將螢幕快照 2016-07-17 上午10.52.10.png的更新量整個應用到每個單詞的詞向量上去:

螢幕快照 2016-07-17 上午11.11.33.png

其中,螢幕快照 2016-07-17 上午11.11.46.png代表上下文中某一個單詞的詞向量。我認為應該也可以將其平均後更新到每個詞向量上去,無非是學習率的不同,歡迎指正。

程式碼分析

於是就可以得到兩個引數更新的偽碼:

螢幕快照 2016-07-17 上午11.15.50.png

在原版C程式碼中的對應關係是:

  1. =0;
  2. // Propagate hidden -> output
  3. for(=0; c < layer1_size; c++)
  4.     f += neu1[c]* syn1[+ l2];

f對應q,neu1對應螢幕快照 2016-07-17 上午10.52.10.png,syn1對應螢幕快照 2016-07-17 上午10.58.10.png

  1. // 'g' is the gradient multiplied by the learning rate
  2. =(1- vocab[word].code[d]- f)* alpha;

對應偽碼中的g。

  1. // Propagate errors output -> hidden
  2. for(=0; c < layer1_size; c++)
  3.     neu1e[c]+= g * syn1[+ l2];

對應偽碼中的e。

  1. // Learn weights hidden -> output
  2. for(=0; c < layer1_size; c++)
  3.     syn1[+ l2]+= g * neu1[c];

對應偽碼中的螢幕快照 2016-07-17 上午10.58.10.png

Skip-gram

原理

Skip-gram只是逆轉了CBOW的因果關係而已,即已知當前詞語,預測上下文。

其網路結構如下圖所示:

螢幕快照 2016-07-17 上午11.33.31.png

上圖與CBOW的兩個不同在於

  1. 輸入層不再是多個詞向量,而是一個詞向量

  2. 投影層其實什麼事情都沒幹,直接將輸入層的詞向量傳遞給輸出層

在對其推導之前需要引入一個新的記號:

u:表示w的上下文中的一個詞語。

於是語言模型的概率函式可以寫作:

螢幕快照 2016-07-17 上午11.42.19.png

注意這是一個詞袋模型,所以每個u是無序的,或者說,互相獨立的。

Hierarchical Softmax思想下,每個u都可以編碼為一條01路徑:

螢幕快照 2016-07-17 上午11.45.43.png

類似地,每一項都是如下簡寫:

螢幕快照 2016-07-17 上午11.47.23.png

把它們寫到一起,得到目標函式:

螢幕快照 2016-07-17 下午1.38.22.png

類似CBOW的做法,將每一項簡記為:

螢幕快照 2016-07-17 下午1.40.34.png

雖然上式對比CBOW多了一個u,但給定訓練例項(一個詞w和它的上下文{u}),u也是固定的。所以上式其實依然只有兩個變數螢幕快照 2016-07-17 上午10.52.10.png螢幕快照 2016-07-17 上午10.58.10.png,對其求偏導數:

螢幕快照 2016-07-17 下午1.47.40.png螢幕快照 2016-07-17 下午1.47.16.png

具體求導過程類似CBOW,略過。

於是得到螢幕快照 2016-07-17 上午10.58.10.png的更新表示式:

螢幕快照 2016-07-17 下午1.49.16.png

同理利用對稱性得到對螢幕快照 2016-07-17 上午10.52.10.png的偏導數:

螢幕快照 2016-07-17 下午1.50.19.png

於是得到螢幕快照 2016-07-17 上午10.52.10.png的更新表示式:

螢幕快照 2016-07-17 下午1.51.20.png

訓練偽碼如下:

螢幕快照 2016-07-17 下午1.53.06.png

word2vec原始碼中並沒有等螢幕快照 2016-07-17 上午10.58.10.png更新完再更新螢幕快照 2016-07-17 上午10.52.10.png,而是即時地更新:

螢幕快照 2016-07-17 下午1.55.05.png

具體對應原始碼中的

  1. // Propagate hidden -> output
  2. for(=0; c < layer1_size; c++)
  3.     f += syn0[+ l1]* syn1[+ l2];
  4. // 'g' is the gradient multiplied by the learning rate
  5. =(1- vocab[word].code[d]- f)* alpha;
  6. // Propagate errors output -> hidden
  7. for(=0; c < layer1_size; c++)
  8.     neu1e[c]+= g * syn1[+ l2];
  9. // Learn weights hidden -> output
  10. for(=0; c < layer1_size; c++)
  11.     syn1[+ l2]+= g * syn0[+ l1];

f對應q,syn0對應v,syn1對應螢幕快照 2016-07-17 上午10.58.10.png,neu1e對應e

Negative Sampling

通過上一章的學習,我們知道無論是CBOW還是Skip-gram模型,其實都是分類模型。對於機器學習中的分類任務,在訓練的時候不但要給正例,還要給負例。對於Hierarchical Softmax,負例放在二叉樹的根節點上。對於Negative Sampling,負例是隨機挑選出來的。據說Negative Sampling能提高速度、改進模型質量。

CBOW

給定訓練樣本,即一個詞w和它的上下文Context(w),Context(w)是輸入,w是輸出。那麼w就是正例,詞彙表中其他的詞語的就是負例。假設我們通過某種取樣方法獲得了負例子集NEG(w)。對於正負樣本,分別定義一個標籤:

螢幕快照 2016-07-17 下午2.18.20.png

也即正樣本為1,負樣本為0。

對於給定正樣本螢幕快照 2016-07-17 下午2.20.18.png,我們希望最大化:

螢幕快照 2016-07-17 下午2.21.21.png

其中,

螢幕快照 2016-07-17 下午2.22.46.png

也就是說,當u是正例時,螢幕快照 2016-07-17 下午2.25.48.png越大越好,當u是負例時,螢幕快照 2016-07-17 下午2.25.48.png越小越好。因為螢幕快照 2016-07-17 下午2.25.48.png等於模型預測樣本為正例的概率,當答案就是正的時候,我們希望這個概率越大越好,當答案是負的時候,我們希望它越小越好,這樣才能說明該模型是個明辨是非的好同志。

每個詞都是如此,語料庫有多個詞,我們將g累積得到優化目標。因為對數方便計算,我們對其取對數得到目標函式:

螢幕快照 2016-07-17 下午2.30.13.png螢幕快照 2016-07-17 下午2.30.29.png

螢幕快照 2016-07-17 下午2.30.41.png

記雙重求和中的每一項為:

螢幕快照 2016-07-17 下午2.35.00.png

求梯度:

螢幕快照 2016-07-17 下午2.35.20.png螢幕快照 2016-07-17 下午2.36.01.png

於是螢幕快照 2016-07-17 下午2.37.02.png的更新方法為:

螢幕快照 2016-07-17 下午2.37.34.png

利用對稱性得到關於螢幕快照 2016-07-17 上午10.52.10.png的梯度:

螢幕快照 2016-07-17 下午2.38.12.png

將該更新應用到每個詞向量上去:

螢幕快照 2016-07-17 下午2.40.01.png

訓練偽碼為:

螢幕快照 2016-07-17 下午2.40.41.png

對應原版C程式碼的片段:

  1. =0;
  2. for(=0; c < layer1_size; c++)
  3.     f += neu1[c]* syn1neg[+ l2];
  4. if(> MAX_EXP)
  5.     g =(label -1)* alpha;
  6. elseif(<-MAX_EXP)
  7.     g =(label -0)* alpha;
  8. else
  9.     g =(label - expTable[(int)((+ MAX_EXP)*(EXP_TABLE_SIZE / MAX_EXP /2))])* alpha;
  10. for(=0; c < layer1_size; c++)
  11.     neu1e[c]+= g * syn1neg[+ l2];
  12. for(=0; c < layer1_size; c++)
  13.     syn1neg[+ l2]+= g * neu1[c];

Skip-gram

有了前三次的經驗,這次輕車熟路地給出結論吧。顛倒樣本的x和y部分,也即對螢幕快照 2016-07-17 下午2.48.48.png,我們希望最大化:

螢幕快照 2016-07-17 下午2.49.32.png

其中,

螢幕快照 2016-07-17 下午2.50.05.png

最終目標函式為:

螢幕快照 2016-07-17 下午2.52.14.png螢幕快照 2016-07-17 下午2.52.27.png螢幕快照 2016-07-17 下午2.52.39.png

其中,

螢幕快照 2016-07-17 下午2.53.36.png

分別求出梯度:

螢幕快照 2016-07-17 下午2.54.58.png螢幕快照 2016-07-17 下午2.55.09.png

螢幕快照 2016-07-17 下午2.57.39.png

得到兩者的更新方法:

螢幕快照 2016-07-17 下午3.00.14.png+=螢幕快照 2016-07-17 下午2.58.34.png

螢幕快照 2016-07-17 下午3.01.01.png螢幕快照 2016-07-17 下午3.01.34.png

訓練偽碼為:

螢幕快照 2016-07-17 下午3.02.35.png

對應原版C程式碼片段:

  1. =0;
  2. for(=0; c < layer1_size; c++)
  3.     f += syn0[+ l1]* syn1neg[+ l2];
  4. if(> MAX_EXP)
  5.     g =(label -1)* alpha;
  6. elseif(<-MAX_EXP)
  7.     g =(label -0)* alpha;
  8. else
  9.     g =(label - expTable[(int)((+ MAX_EXP)*(EXP_TABLE_SIZE / MAX_EXP /2))])