Word2Vec原理推導
本文摘錄整編了一些理論介紹,推導了word2vec中的數學原理;並考察了一些常見的word2vec實現,評測其準確率等效能,最後分析了word2vec原版C程式碼;針對沒有好用的Java實現的現狀,移植了原版C程式到Java。時間和水平有限,本文沒有就其發展歷史展開多談,只記錄了必要的知識點,並著重關注工程實踐。
雖然我的Java方案速度比原版C程式高出1倍,在演算法程式碼與原版C程式一致的情況下準確率仍然略低於原版C程式(不過依然是目前準確率最高的Java實現),並非完美,還有待改進。
本文的理論部分大量參考《word2vec中的數學原理詳解》,按照我這種初學者方便理解的順序重新編排、重新敘述。題圖來自siegfang的部落格。我提出的Java方案基於
背景
語言模型
在統計自然語言處理中,語言模型指的是計算一個句子的概率模型。
傳統的語言模型中詞的表示是原始的、面向字串的。兩個語義相似的詞的字串可能完全不同,比如“番茄”和“西紅柿”。這給所有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:
為什麼要去掉這一層呢?據說是因為word2vec的作者嫌從hidden layer到output layer的矩陣運算太多了。於是兩種模型的網路結構是:
其中w(t)代表當前詞語位於句子的位置t,同理定義其他記號。在視窗內(上圖為視窗大小為5),除了當前詞語之外的其他詞語共同構成上下文。
CBOW
原理
CBOW 是 Continuous Bag-of-Words Model 的縮寫,是一種根據上下文的詞語預測當前詞語的出現概率的模型。其圖示如上圖左。
CBOW是已知上下文,估算當前詞語的語言模型。其學習目標是最大化對數似然函式:
其中,w表示語料庫C中任意一個詞。從上圖可以看出,對於CBOW,
輸入層是上下文的詞語的詞向量(什麼!我們不是在訓練詞向量嗎?不不不,我們是在訓練CBOW模型,詞向量只是個副產品,確切來說,是CBOW模型的一個引數。訓練開始的時候,詞向量是個隨機值,隨著訓練的進行不斷被更新)。
投影層對其求和,所謂求和,就是簡單的向量加法。
輸出層輸出最可能的w。由於語料庫中詞彙量是固定的|C|個,所以上述過程其實可以看做一個多分類問題。給定特徵,從|C|個分類中挑一個。
對於神經網路模型多分類,最樸素的做法是softmax迴歸:
softmax迴歸需要對語料庫中每個詞語(類)都計算一遍輸出概率並進行歸一化,在幾十萬詞彙量的語料上無疑是令人頭疼的。
不用softmax怎麼樣?比如SVM中的多分類,我們都知道其多分類是由二分類組合而來的:
這是一種二叉樹結構,應用到word2vec中被作者稱為Hierarchical Softmax:
上圖輸出層的樹形結構即為Hierarchical Softmax。
非葉子節點相當於一個神經元(感知機,我認為邏輯斯諦迴歸就是感知機的輸出代入f(x)=1/(1+e^x)),二分類決策輸出1或0,分別代表向下左轉或向下右轉;每個葉子節點代表語料庫中的一個詞語,於是每個詞語都可以被01唯一地編碼,並且其編碼序列對應一個事件序列,於是我們可以計算條件概率。
在開始計算之前,還是得引入一些符號:
-
從根結點出發到達w對應葉子結點的路徑.
-
路徑中包含結點的個數
-
路徑中的各個節點
-
詞w的編碼,表示路徑第j個節點對應的編碼(根節點無編碼)
-
路徑中非葉節點對應的引數向量
於是可以給出w的條件概率:
這是個簡單明瞭的式子,從根節點到葉節點經過了-1個節點,編碼從下標2開始(根節點無編碼),對應的引數向量下標從1開始(根節點為1)。
其中,每一項是一個邏輯斯諦迴歸:
考慮到d只有0和1兩種取值,我們可以用指數形式方便地將其寫到一起:
我們的目標函式取對數似然:
將代入上式,有
這也很直白,連乘的對數換成求和。不過還是有點長,我們把每一項簡記為:
怎麼最大化對數似然函式呢?分別最大化每一項即可(這應該是一種近似,最大化某一項不一定使整體增大,具體收斂的證明還不清楚)。怎麼最大化每一項呢?先求函式對每個變數的偏導數,對每一個樣本,代入偏導數表示式得到函式在該維度的增長梯度,然後讓對應引數加上這個梯度,函式在這個維度上就增長了。這種白話描述的演算法在學術上叫隨機梯度上升法,詳見更規範的描述。
每一項有兩個引數,一個是每個節點的引數向量,另一個是輸出層的輸入,我們分別對其求偏導數:
因為sigmoid函式的導數有個很棒的形式:
於是代入上上式得到:
合併同類項得到:
於是的更新表示式就得到了:
其中,是機器學習的老相好——學習率,通常取0-1之間的一個值。學習率越大訓練速度越快,但目標函式容易在區域性區域來回抖動。
再來的偏導數,注意到中和是對稱的,所有直接將的偏導數中的替換為,得到關於的偏導數:
於是的更新表示式也得到了。
不過是上下文的詞向量的和,不是上下文單個詞的詞向量。怎麼把這個更新量應用到單個詞的詞向量上去呢?word2vec採取的是直接將的更新量整個應用到每個單詞的詞向量上去:
其中,代表上下文中某一個單詞的詞向量。我認為應該也可以將其平均後更新到每個詞向量上去,無非是學習率的不同,歡迎指正。
程式碼分析
於是就可以得到兩個引數更新的偽碼:
在原版C程式碼中的對應關係是:
- f =0;
- // Propagate hidden -> output
- for(c =0; c < layer1_size; c++)
- f += neu1[c]* syn1[c + l2];
f對應q,neu1對應,syn1對應。
- // 'g' is the gradient multiplied by the learning rate
- g =(1- vocab[word].code[d]- f)* alpha;
對應偽碼中的g。
- // Propagate errors output -> hidden
- for(c =0; c < layer1_size; c++)
- neu1e[c]+= g * syn1[c + l2];
對應偽碼中的e。
- // Learn weights hidden -> output
- for(c =0; c < layer1_size; c++)
- syn1[c + l2]+= g * neu1[c];
對應偽碼中的。
Skip-gram
原理
Skip-gram只是逆轉了CBOW的因果關係而已,即已知當前詞語,預測上下文。
其網路結構如下圖所示:
上圖與CBOW的兩個不同在於
-
輸入層不再是多個詞向量,而是一個詞向量
-
投影層其實什麼事情都沒幹,直接將輸入層的詞向量傳遞給輸出層
在對其推導之前需要引入一個新的記號:
u:表示w的上下文中的一個詞語。
於是語言模型的概率函式可以寫作:
注意這是一個詞袋模型,所以每個u是無序的,或者說,互相獨立的。
在Hierarchical Softmax思想下,每個u都可以編碼為一條01路徑:
類似地,每一項都是如下簡寫:
把它們寫到一起,得到目標函式:
類似CBOW的做法,將每一項簡記為:
雖然上式對比CBOW多了一個u,但給定訓練例項(一個詞w和它的上下文{u}),u也是固定的。所以上式其實依然只有兩個變數和,對其求偏導數:
具體求導過程類似CBOW,略過。
於是得到的更新表示式:
同理利用對稱性得到對的偏導數:
於是得到的更新表示式:
訓練偽碼如下:
word2vec原始碼中並沒有等更新完再更新,而是即時地更新:
具體對應原始碼中的
- // Propagate hidden -> output
- for(c =0; c < layer1_size; c++)
- f += syn0[c + l1]* syn1[c + l2];
- // 'g' is the gradient multiplied by the learning rate
- g =(1- vocab[word].code[d]- f)* alpha;
- // Propagate errors output -> hidden
- for(c =0; c < layer1_size; c++)
- neu1e[c]+= g * syn1[c + l2];
- // Learn weights hidden -> output
- for(c =0; c < layer1_size; c++)
- syn1[c + l2]+= g * syn0[c + l1];
f對應q,syn0對應v,syn1對應,neu1e對應e。
Negative Sampling
通過上一章的學習,我們知道無論是CBOW還是Skip-gram模型,其實都是分類模型。對於機器學習中的分類任務,在訓練的時候不但要給正例,還要給負例。對於Hierarchical Softmax,負例放在二叉樹的根節點上。對於Negative Sampling,負例是隨機挑選出來的。據說Negative Sampling能提高速度、改進模型質量。
CBOW
給定訓練樣本,即一個詞w和它的上下文Context(w),Context(w)是輸入,w是輸出。那麼w就是正例,詞彙表中其他的詞語的就是負例。假設我們通過某種取樣方法獲得了負例子集NEG(w)。對於正負樣本,分別定義一個標籤:
也即正樣本為1,負樣本為0。
對於給定正樣本,我們希望最大化:
其中,
也就是說,當u是正例時,越大越好,當u是負例時,越小越好。因為等於模型預測樣本為正例的概率,當答案就是正的時候,我們希望這個概率越大越好,當答案是負的時候,我們希望它越小越好,這樣才能說明該模型是個明辨是非的好同志。
每個詞都是如此,語料庫有多個詞,我們將g累積得到優化目標。因為對數方便計算,我們對其取對數得到目標函式:
記雙重求和中的每一項為:
求梯度:
於是的更新方法為:
利用對稱性得到關於的梯度:
將該更新應用到每個詞向量上去:
訓練偽碼為:
對應原版C程式碼的片段:
- f =0;
- for(c =0; c < layer1_size; c++)
- f += neu1[c]* syn1neg[c + l2];
- if(f > MAX_EXP)
- g =(label -1)* alpha;
- elseif(f <-MAX_EXP)
- g =(label -0)* alpha;
- else
- g =(label - expTable[(int)((f + MAX_EXP)*(EXP_TABLE_SIZE / MAX_EXP /2))])* alpha;
- for(c =0; c < layer1_size; c++)
- neu1e[c]+= g * syn1neg[c + l2];
- for(c =0; c < layer1_size; c++)
- syn1neg[c + l2]+= g * neu1[c];
Skip-gram
有了前三次的經驗,這次輕車熟路地給出結論吧。顛倒樣本的x和y部分,也即對,我們希望最大化:
其中,
最終目標函式為:
其中,
分別求出梯度:
得到兩者的更新方法:
+=
訓練偽碼為:
對應原版C程式碼片段:
- f =0;
- for(c =0; c < layer1_size; c++)
- f += syn0[c + l1]* syn1neg[c + l2];
- if(f > MAX_EXP)
- g =(label -1)* alpha;
- elseif(f <-MAX_EXP)
- g =(label -0)* alpha;
- else
- g =(label - expTable[(int)((f + MAX_EXP)*(EXP_TABLE_SIZE / MAX_EXP /2))])* alpha;
- for(c =0; c < layer1_size; c++)
- neu1e[c]+= g * syn1neg[c + l2];
- for(c =0; c < layer1_size; c++)
- syn1neg[c + l2]+= g * syn0[c + l1];
syn0對應,syn1neg對應,f運算後得到q,程式碼中有優化(後文分解),neu1e對應e。
更多細節
Huffman樹
上文一直在用二叉樹描述Hierarchical Softmax,這是因為我不想仿照大部分tutorial那樣一下子拿出Huffman這麼具體的細節。初期對word2vec的大框架還沒把握住的時候突然看到這些細節的話,人會抓不住重點,造成學習成本無謂的上升。我當時看到有些tutorial第一節就在講Huffman編碼,還以為實現word2vec一定要用Huffman樹呢。
其實根本不是的,任何二叉樹都可以。Huffman樹只是二叉樹中具體的一種,特別適合word2vec的訓練。
word2vec訓練的時候按照詞頻將每個詞語Huffman編碼,由於Huffman編碼中詞頻越高的詞語對應的編碼越短。所以越高頻的詞語在Hierarchical Softmax過程中經過的二分類節點就越少,整體計算量就更少了。
負取樣演算法
任何取樣演算法都應該保證頻次越高的樣本越容易被取樣出來。基本的思路是對於長度為1的線段,根據詞語的詞頻將其公平地分配給每個詞語:
counter就是w的詞頻。
於是我們將該線段公平地分配了:
接下來我們只要生成一個0-1之間的隨機數,看看落到哪個區間,就能取樣到該區間對應的單詞了,很公平。
但怎麼根據小數找區間呢?速度慢可不行。
word2vec用的是一種查表的方式,將上述線段標上M個“刻度”,刻度之間的間隔是相等的,即1/M:
接著我們就不生成0-1之間的隨機數了,我們生成0-M之間的整數,去這個刻度尺上一查就能抽中一個單詞了。
在word2vec中,該“刻度尺”對應著table陣列。具體實現時,對詞頻取了0.75次冪:
這個冪實際上是一種“平滑”策略,能夠讓低頻詞多一些出場機會,高頻詞貢獻一些出場機會,劫富濟貧。
sigmoid函式
類似的查表方法還有sigmoid函式的計算,因為該函式使用太頻繁,而其值僅僅在靠近0的時候才會劇烈變化,遠離0的方向很快趨近0和1。所以原始碼中也採用了“刻度查表”的方法,先算出了很多個刻度對應的函式值,運算中直接查表。這部分對應:
- expTable =(real *) malloc((EXP_TABLE_SIZE +1)*sizeof(real));
- for(i =0; i < EXP_TABLE_SIZE; i++)
- {
- expTable[i]= exp((i /(real) EXP_TABLE_SIZE *2-1)* MAX_EXP);// Precompute the exp() table
- expTable[i]= expTable[i]/(expTable[i]+1);// Precompute f(x) = x / (x + 1)
- }
多執行緒
關於如何多執行緒並行訓練的問題,我沒看程式碼之前也想過。大致就是將語料按照執行緒數均分,大家分頭算,更新引數的過程中做好執行緒同步的工作。