機器學習算法實現解析——word2vec源代碼解析


在閱讀本文之前,建議首先閱讀“簡單易學的機器學習算法——word2vec的算法原理”(眼下還沒公布)。掌握例如以下的幾個概念:

  • 什么是統計語言模型
  • 神經概率語言模型的網絡結構
  • CBOW模型和Skip-gram模型的網絡結構
  • Hierarchical Softmax和Negative Sampling的訓練方法
  • Hierarchical Softmax與Huffman樹的關系

有了如上的一些概念,接下來就能夠去讀word2vec的源代碼。

在源代碼的解析過程中,對於基礎知識部分僅僅會做簡單的介紹。而不會做太多的推導。原理部分會給出相應的參考地址。


在wrod2vec工具中,有例如以下的幾個比較重要的概念:

  • CBOW
  • Skip-Gram
  • Hierarchical Softmax
  • Negative Sampling

當中CBOW和Skip-Gram是word2vec工具中使用到的兩種不同的語言模型。而Hierarchical Softmax和Negative Sampling是對以上的兩種模型的具體的優化方法。

在word2vec工具中,基本的工作包含:

  • 預處理。即變量的聲明,全局變量的定義等;
  • 構建詞庫。即包含文本的處理,以及是否須要有指定詞庫等。
  • 初始化網絡結構。即包含CBOW模型和Skip-Gram模型的參數初始化,Huffman編碼的生成等。
  • 多線程模型訓練。即利用Hierarchical Softmax或者Negative Sampling方法對網絡中的參數進行求解;
  • 終於結果的處理。即是否保存和以何種形式保存。

對於以上的過程,能夠由下圖表示:

這里寫圖片描寫敘述

在接下來的內容中,將針對以上的五個部分,具體分析下在源代碼中的實現技巧。以及簡介我在讀代碼的過程中對部分代碼的一些思考。

1、預處理

在預處理部分,對word2vec須要使用的參數進行初始化,在word2vec中是利用傳入的方式對參數進行初始化的。

在預處理部分。實現了sigmoid函數值的近似計算。

在利用神經網絡模型對樣本進行預測的過程中。須要對其進行預測,此時,須要使用到sigmoid函數,sigmoid函數的具體形式為:

σ(x)=11+ex=ex1+ex

假設每一次都請求計算sigmoid值,對性能將會有一定的影響,當sigmoid的值對精度的要求並非非常嚴格時。能夠採用近似計算。在word2vec中。將區間 [6,6] (設置的參數MAX_EXP為6)等距離划分成EXP_TABLE_SIZE等份,並將每個區間中的sigmoid值計算好存入到數組expTable中。須要使用時,直接從數組中查找。

計算sigmoid值的代碼例如以下所看到的:

expTable = (real *)malloc((EXP_TABLE_SIZE + 1) * sizeof(real));// 申請EXP_TABLE_SIZE+1個空間

        // 計算sigmoid值
        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)
        }

注意:在上述代碼中,作者使用的是小於EXP_TABLE_SIZE,實際的區間是 [6,6)

2、構建詞庫

在word2vec源代碼中。提供了兩種構建詞庫的方法。分別為:

  • 指定詞庫:ReadVocab()方法
  • 從詞的文本構建詞庫:LearnVocabFromTrainFile()方法

2.1、構建詞庫的過程

在這里,我們以從詞的文本構建詞庫為例。構建詞庫的步驟例如以下所看到的:

這里寫圖片描寫敘述

在這部分中,最基本的工作是對文本進行處理,包含低頻詞的處理,hash表的處理等等。首先,會在詞庫中添加一個“< /s>”的詞,同一時候。在讀取文本的過程中,將換行符“\n”也表示成該該詞。如:

if (ch == '\n') {
    strcpy(word, (char *)"</s>");// 換行符用</s>表示
    return;

在循環的過程中,不斷去讀取文件里的每個詞。並在詞庫中進行查找,若存在該詞,則該詞的詞頻+1。否則。在詞庫中添加該詞。在詞庫中,是通過哈希表的形式存儲的。終於,會過濾掉一些低頻詞。

在得到終於的詞庫之前,還需依據詞庫中的詞頻對詞庫中的詞進行排序。

2.2、對詞的哈希處理

在存儲詞的過程中,同一時候保留這兩個數組:

  • 存儲詞的vocab
  • 存儲詞的hash的vocab_hash

當中,在vocab中。存儲的是詞相應的結構體:

// 詞的結構體
struct vocab_word {
        long long cn; // 出現的次數
        int *point; // 從根結點到葉子節點的路徑
        char *word, *code, codelen;// 分別相應着詞。Huffman編碼,編碼長度
};

在vocab_hash中存儲的是詞在詞庫中的Index。

在對詞的處理過程中。主要包含:

  • 計算詞的hash值:
// 取詞的hash值
int GetWordHash(char *word) {
        unsigned long long a, hash = 0;
        for (a = 0; a < strlen(word); a++) hash = hash * 257 + word[a];
        hash = hash % vocab_hash_size;
        return hash;
}
  • 檢索詞是否存在。如不存在則返回-1。否則,返回該詞在詞庫中的索引:
while (1) {
    if (vocab_hash[hash] == -1) return -1;// 不存在該詞
    if (!strcmp(word, vocab[vocab_hash[hash]].word)) return vocab_hash[hash];// 返回索引值
    hash = (hash + 1) % vocab_hash_size;// 處理沖突
}
return -1;// 不存在該詞

在這個過程中,使用到了線性探測的開放定址法處理沖突,開放定址法就是一旦發生沖突,就去尋找下一個空的散列地址。

  • 不存在,則插入新詞。

在這個過程中。除了須要將詞添加到詞庫中。好須要計算該詞的hash值,並將vocab_hash數組中的值標記為索引。

2.3、對低頻詞的處理

在循環讀取每個詞的過程中,當出現“vocab_size > vocab_hash_size * 0.7”時,須要對低頻詞進行處理。當中,vocab_size表示的是眼下詞庫中詞的個數,vocab_hash_size表示的是初始設定的hash表的大小。

在處理低頻詞的過程中,通過參數“min_reduce”來控制,若詞出現的次數小於等於該值時。則從詞庫中刪除該詞。

在刪除了低頻詞后。須要又一次對詞庫中的詞進行hash值的計算。

2.4、依據詞頻對詞庫中的詞排序

基於以上的過程,程序已經將詞從文件里提取出來,並存入到指定的詞庫中(vocab數組),接下來,須要依據每個詞的詞頻對詞庫中的詞依照詞頻從大到小排序。其基本過程在函數SortVocab中,排序過程為:

qsort(&vocab[1], vocab_size - 1, sizeof(struct vocab_word), VocabCompare);

保持字符“< \s>”在最開始的位置。排序后,依據“min_count”對低頻詞進行處理,與上述一樣,再對剩下的詞又一次計算hash值。

至此。整個對詞的處理過程就已經結束了。加下來,將是對網絡結構的處理和詞向量的訓練。

3、初始化網絡結構

有了以上的對詞的處理,就已經處理好了全部的訓練樣本,此時,便能夠開始網絡結構的初始化和接下來的網絡訓練。網絡的初始化的過程在InitNet()函數中完畢。

3.1、初始化網絡參數

在初始化的過程中,基本的參數包含詞向量的初始化和映射層到輸出層的權重的初始化,例如以下圖所看到的:

這里寫圖片描寫敘述

在初始化的過程中,映射層到輸出層的權重都初始化為 0 。而對於每個詞向量的初始化,作者的初始化方法例如以下代碼所看到的:

for (a = 0; a < vocab_size; a++) for (b = 0; b < layer1_size; b++) {
    next_random = next_random * (unsigned long long)25214903917 + 11;
    // 1、與:相當於將數控制在一定范圍內
    // 2、0xFFFF:65536
    // 3、/65536:[0,1]之間
    syn0[a * layer1_size + b] = (((next_random & 0xFFFF) / (real)65536) - 0.5) / layer1_size;// 初始化詞向量
}

首先,生成一個非常大的next_random的數,通過與“0xFFFF”進行與運算截斷。再除以65536得到 [0,1] 之間的數。終於,得到的初始化的向量的范圍為: [0.5m,0.5m] ,當中, m 為詞向量的長度。

3.2、Huffman樹的構建

在層次Softmax中須要使用到Huffman樹以及Huffman編碼,因此。在網絡結構的初始化過程中,也須要初始化Huffman樹。在生成Huffman樹的過程中,首先定義了 3 個長度為vocab_size*2+1的數組:

long long *count = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
long long *binary = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
long long *parent_node = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));

當中,count數組中前vocab_size存儲的是每個詞的相應的詞頻。后面初始化的是非常大的數,已知詞庫中的詞是依照降序排列的。因此,構建Huffman樹的步驟例如以下所看到的(對於Huffman樹的原理。能夠參見博文“數據結構和算法——Huffman樹和Huffman編碼”):

這里寫圖片描寫敘述

首先,設置兩個指針pos1和pos2,分別指向最后一個詞和最后一個詞的后一位,從兩個指針所指的數中選擇出最小的值。記為min1i。如pos1所指的值最小,此時,將pos1左移,再比較pos1和pos2所指的數。選擇出最小的值,記為min2i,將他們的和存儲到pos2所指的位置。

並將此時pos2所指的位置設置為min1i和min2i的父節點,同一時候,記min2i所指的位置的編碼為1,例如以下代碼所看到的:

// 設置父節點
parent_node[min1i] = vocab_size + a;
parent_node[min2i] = vocab_size + a;
binary[min2i] = 1;// 設置一個子樹的編碼為1

構建好Huffman樹后。此時。須要依據構建好的Huffman樹生成相應節點的Huffman編碼。假設,上述的數據生成的終於的Huffman樹為:

這里寫圖片描寫敘述

此時,count數組,binary數組和parent_node數組分別為:

這里寫圖片描寫敘述

在生成Huffman編碼的過程中。針對每個詞(詞都在葉子節點上),從葉子節點開始。將編碼存入到code數組中,如對於上圖中的“R”節點來說。其code數組為{1,0}。再對其反轉便是Huffman編碼:

vocab[a].codelen = i;// 詞的編碼長度
vocab[a].point[0] = vocab_size - 2;
for (b = 0; b < i; b++) {
    vocab[a].code[i - b - 1] = code[b];// 編碼的反轉
    vocab[a].point[i - b] = point[b] - vocab_size;// 記錄的是從根結點到葉子節點的路徑
}

注意:這里的Huffman樹的構建和Huffman編碼的生成過程寫得比較精簡。

3.3、負樣本選中表的初始化

假設是採用負採樣的方法,此時還須要初始化每個詞被選中的概率。在全部的詞構成的詞典中,每個詞出現的頻率有高有低,我們希望。對於那些高頻的詞。被選中成為負樣本的概率要大點,同一時候,對於那些出現頻率比較低的詞。我們希望其被選中成為負樣本的頻率低點。

這個原理於“輪盤賭”的策略一致(具體能夠參見“優化算法——遺傳算法”)。

在程序中,實現這部分功能的代碼為:

// 生成負採樣的概率表
void InitUnigramTable() {
        int a, i;
        double train_words_pow = 0;
        double d1, power = 0.75;
        table = (int *)malloc(table_size * sizeof(int));// int --> int
        for (a = 0; a < vocab_size; a++) train_words_pow += pow(vocab[a].cn, power);
        // 相似輪盤賭生成每個詞的概率
        i = 0;
        d1 = pow(vocab[i].cn, power) / train_words_pow;
        for (a = 0; a < table_size; a++) {
                table[a] = i;
                if (a / (double)table_size > d1) {
                        i++;
                        d1 += pow(vocab[i].cn, power) / train_words_pow;
                }
                if (i >= vocab_size) i = vocab_size - 1;
        }
}

在實現的過程中。沒有直接使用每個詞的頻率。而是使用了詞的 0.75 次方。

4、多線程模型訓練

以上的各個部分是為訓練詞向量做准備,即准備訓練數據,構建訓練模型。

在上述的初始化完畢后,接下來就是依據不同的方法對模型進行訓練,在實現的過程中,作者使用了多線程的方法對其進行訓練。

4.1、多線程的處理

為了能夠對文本進行加速訓練,在實現的過程中,作者使用了多線程的方法。並對每個線程上分配指定大小的文件:

// 利用多線程對訓練文件划分,每個線程訓練一部分的數據
fseek(fi, file_size / (long long)num_threads * (long long)id, SEEK_SET);

注意:這邊的多線程切割方式並不能保證每個線程分到的文件是相互排斥的。對於當中的原因,能夠參見“Linux C 編程——多線程”。

這個過程能夠通過下圖簡單的描寫敘述:

這里寫圖片描寫敘述

在實現多線程的過程中,作者並沒有加鎖的操作,而是對模型參數和詞向量的改動能夠隨意運行。這一點相似於基於隨機梯度的方法,訓練的過程與訓練樣本的訓練是沒有關系的,這樣能夠大大加快對詞向量的訓練。拋開多線程的部分。在每個線程內運行的是對模型和詞向量的訓練。

作者在實現的過程中,主要實現了兩個模型。即CBOW模型和Skip-gram模型。在每個模型中。又分別使用到了兩種不同的訓練方法,即層次Softmax和Negative Sampling方法。

對於CBOW模型和Skip-gram模型的理解,首先必須知道統計語言模型(Statistic Language Model)

在統計語言模型中的核心內容是:計算一組詞語能夠成為一個句子的概率。

為了能夠求解當中的參數。一大批參數求解的方法被提出,在當中。就有word2vec中要使用的神經概率語言模型。具體的神經概率語言模型能夠參見“”。

4.2、CBOW模型

CBOW模型和Skip-gram模型是神經概率語言模型的兩種變形形式,當中。在CBOW模型中包含三層,即輸入層。映射層和輸出層。

對於CBOW模型,例如以下圖所看到的:

這里寫圖片描寫敘述

在CBOW模型中。通過詞 wt 的前后詞 wt2 wt1 wt+1 wt+2 來預測當前詞 wt

此處的窗體的大小window為2。

4.3.1、從輸入層到映射層

首先找到每個詞相應的詞向量,並將這些詞的詞向量相加,程序代碼例如以下所看到的:

// in -> hidden
// 輸入層到映射層
cw = 0;
for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {
    c = sentence_position - window + a;// sentence_position表示的是當前的位置
    // 推斷c是否越界
    if (c < 0) continue;
    if (c >= sentence_length) continue;

    last_word = sen[c];// 找到c相應的索引
    if (last_word == -1) continue;

    for (c = 0; c < layer1_size; c++) neu1[c] += syn0[c + last_word * layer1_size];// 累加
    cw++;
}

當累加完窗體內的全部的詞向量的之后。存儲在映射層neu1中,並取平均,程序代碼例如以下所看到的:

for (c = 0; c < layer1_size; c++) neu1[c] /= cw;// 計算均值

當取得了映射層的結果后,此時就須要使用Hierarchical Softmax或者Negative Sampling對模型進行訓練。

4.3.2、Hierarchical Softmax

Hierarchical Softmax是word2vec中用於提高性能的一項關鍵的技術。

由Hierarchical Softmax的原理可知。對於詞w。其對數似然函數為:

L(w)=jpoint{(1dwj)log[σ(XTwθwj)]+dwjlog[1σ(XTwθwj)]}

當中, j 表示的詞w相應的Huffman編碼中的每個編碼的下標, dwj 表示的是第 j 個Huffman編碼。且 dwj{0,1} ,這是一個求和的過程,因此,對於Huffman編碼中的每個編碼。上述的對數似然函數為:

L(w,j)=(1dwj)log[σ(XTwθwj)]+dwjlog[1σ(XTwθwj)]

在此,變量為 Xw θwj ,此時,分別對 Xw θwj 求偏導數:

L(w,j)θwj=[1dwjσ(XTwθwj)]Xw

L(w,j)Xw=[1dwjσ(XTwθwj)]θwj

因此。對於 θwj 的更新公式為:

θwj=θwj+η[1dwjσ(XTwθwj)]Xw

在word2vec源代碼中,為了能夠加快計算。作者在開始的時候存儲了一份Sigmoid的值,因此,對於 σ(XTwθwj) 須要從expTable中查詢到相應的值。

for (d = 0; d < vocab[word].codelen; d++) {// word為當前詞
    // 計算輸出層的輸出
    f = 0;
    l2 = vocab[word].point[d] * layer1_size;// 找到第d個詞相應的權重
    // Propagate hidden -> output
    for (c = 0; c < layer1_size; c++) f += neu1[c] * syn1[c + l2];// 映射層到輸出層

    if (f <= -MAX_EXP) continue;
    else if (f >= MAX_EXP) continue;
    else f = expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];// Sigmoid結果

    // '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 * neu1[c];// 改動映射層到輸出層之間的權重
}

對於窗體內的詞的向量的更新,則是利用窗體內的全部詞的梯度之和 L(w,j)Xw 來更新,如程序代碼所看到的:

// hidden -> in
// 以上是從映射層到輸出層的改動。如今返回改動每個詞向量
for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {
    c = sentence_position - window + a;
    if (c < 0) continue;
    if (c >= sentence_length) continue;
    last_word = sen[c];
    if (last_word == -1) continue;
    // 利用窗體內的全部詞向量的梯度之和來更新
    for (c = 0; c < layer1_size; c++) syn0[c + last_word * layer1_size] += neu1e[c];
}

4.3.3、Negative Sampling

與Hierarchical Softmax一致,Negative Sampling也是一種加速計算的方法。在Negative Sampling方法中使用的是隨機的負採樣。在CBOW模型中。已知詞 w 的上下文,須要預測詞 w 。對於給定的上下文,詞 w 即為正樣本,其它的樣本為負樣本,此時我們須要依據詞頻從剩下的詞中挑選出最合適的負樣本,實現的代碼例如以下所看到的:

// 標記target和label
if (d == 0) {// 正樣本
    target = word;
    label = 1;
} else {// 選擇出負樣本
    next_random = next_random * (unsigned long long)25214903917 + 11;
    target = table[(next_random >> 16) % table_size];// 從table表中選擇出負樣本
    // 又一次選擇
    if (target == 0) target = next_random % (vocab_size - 1) + 1;
    if (target == word) continue;
    label = 0;
}

當選擇出了正負樣本,此時的損失函數為:

l(w)=σ(XTwθw)uNEG(w)[1σ(XTwθu)]

其對數似然函數為:

L=logl(w)=loguwNEG(w){[σ(XTwθu)]Lw(u)[1σ(XTwθu)]1Lw(u)}

即為:

uwNEG(w){Lw(u)log[σ(XTwθu)]+[1Lw(u)]log[1σ(XTwθu)]}

取:

L(w,u)=Lw(u)log[σ(XTwθu)]+[1Lw(u)]log[1σ(XTwθu)]

在此,變量為 Xw θu 。此時,分別對 Xw θwj 求偏導數:

L(w,u)θu=[Lw(u)σ(XTwθu)]Xw

L(w,u)Xw=[Lw(u)σ(XTwθu)]θu

因此,更新的代碼為:

if (f > MAX_EXP) g = (label - 1) * alpha;
else if (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];

對詞向量的更新與Hierarchical Softmax中一致。

4.3、Skip-gram模型

而Skip-gram模型與CBOW正好相反。在Skip-gram模型中,則是通過當前詞 wt 來預測其前后詞 wt2 wt1 wt+1 wt+2 。Skip-gram模型例如以下圖所看到的:

這里寫圖片描寫敘述

4.3.1、Hierarchical Softmax

由上述的分析,我們發現。在Skip-gram模型中,其計算方法與CBOW模型非常相似。不同的是,在Skip-gram模型中。須要使用當前詞分別預測窗體中的詞,因此。這是一個循環的過程:

for (a = b; a < window * 2 + 1 - b; a++) if (a != window)

對於向量的更新過程與CBOW模型中的Hierarchical Softmax一致:

c = sentence_position - window + a;
if (c < 0) continue;
if (c >= sentence_length) continue;
last_word = sen[c];
if (last_word == -1) continue;
l1 = last_word * layer1_size;
for (c = 0; c < layer1_size; c++) neu1e[c] = 0;
// HIERARCHICAL SOFTMAX
if (hs) for (d = 0; d < vocab[word].codelen; d++) {
    f = 0;
    l2 = vocab[word].point[d] * layer1_size;
    // Propagate hidden -> output
    // 映射層即為輸入層
    for (c = 0; c < layer1_size; c++) f += syn0[c + l1] * syn1[c + l2];

    if (f <= -MAX_EXP) continue;
    else if (f >= MAX_EXP) continue;
    else f = expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];

    // '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];
}
// Learn weights input -> hidden
for (c = 0; c < layer1_size; c++) syn0[c + l1] += neu1e[c];

4.3.2、Negative Sampling

與上述一致。在Skip-gram中與CBOW中的唯一不同是在Skip-gram中是循環的過程。代碼的實現相似與上面的Hierarchical Softmax。

凝視版的word2vec源代碼已經上傳到Github中:Github:word2vec.c

參考文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM