在閱讀本文以前,建議首先閱讀「簡單易學的機器學習算法——word2vec的算法原理」(眼下還沒公佈)。掌握例如如下的幾個概念:html
有了如上的一些概念,接下來就可以去讀word2vec的源代碼。node
在源代碼的解析過程當中,對於基礎知識部分僅僅會作簡單的介紹。而不會作太多的推導。原理部分會給出相應的參考地址。c++
在wrod2vec工具中,有例如如下的幾個比較重要的概念:git
當中CBOW和Skip-Gram是word2vec工具中使用到的兩種不一樣的語言模型。而Hierarchical Softmax和Negative Sampling是對以上的兩種模型的具體的優化方法。github
在word2vec工具中,基本的工做包含:算法
對於以上的過程,可以由下圖表示:編程
在接下來的內容中,將針對以上的五個部分,具體分析下在源代碼中的實現技巧。以及簡介我在讀代碼的過程當中對部分代碼的一些思考。數組
在預處理部分,對word2vec需要使用的參數進行初始化,在word2vec中是利用傳入的方式對參數進行初始化的。bash
在預處理部分。實現了sigmoid函數值的近似計算。markdown
在利用神經網絡模型對樣本進行預測的過程當中。需要對其進行預測,此時,需要使用到sigmoid函數,sigmoid函數的具體形式爲:
假設每一次都請求計算sigmoid值,對性能將會有必定的影響,當sigmoid的值對精度的要求並不是很是嚴格時。可以採用近似計算。在word2vec中。將區間
計算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) 。
在word2vec源代碼中。提供了兩種構建詞庫的方法。分別爲:
在這裏,咱們以從詞的文本構建詞庫爲例。構建詞庫的步驟例如如下所看到的:
在這部分中,最基本的工做是對文本進行處理,包含低頻詞的處理,hash表的處理等等。首先,會在詞庫中添加一個「< /s>」的詞,同一時候。在讀取文本的過程當中,將換行符「\n」也表示成該該詞。如:
if (ch == '\n') {
strcpy(word, (char *)"</s>");// 換行符用</s>表示
return;
在循環的過程當中,不斷去讀取文件裏的每一個詞。並在詞庫中進行查找,若存在該詞,則該詞的詞頻+1。不然。在詞庫中添加該詞。在詞庫中,是經過哈希表的形式存儲的。終於,會過濾掉一些低頻詞。
在獲得終於的詞庫以前,還需依據詞庫中的詞頻對詞庫中的詞進行排序。
在存儲詞的過程當中,同一時候保留這兩個數組:
當中,在vocab中。存儲的是詞相應的結構體:
// 詞的結構體
struct vocab_word {
long long cn; // 出現的次數
int *point; // 從根結點到葉子節點的路徑
char *word, *code, codelen;// 分別相應着詞。Huffman編碼,編碼長度
};
在vocab_hash中存儲的是詞在詞庫中的Index。
在對詞的處理過程當中。主要包含:
// 取詞的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;
}
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數組中的值標記爲索引。
在循環讀取每一個詞的過程當中,當出現「vocab_size > vocab_hash_size * 0.7」時,需要對低頻詞進行處理。當中,vocab_size表示的是眼下詞庫中詞的個數,vocab_hash_size表示的是初始設定的hash表的大小。
在處理低頻詞的過程當中,經過參數「min_reduce」來控制,若詞出現的次數小於等於該值時。則從詞庫中刪除該詞。
在刪除了低頻詞後。需要又一次對詞庫中的詞進行hash值的計算。
基於以上的過程,程序已經將詞從文件裏提取出來,並存入到指定的詞庫中(vocab數組),接下來,需要依據每一個詞的詞頻對詞庫中的詞依照詞頻從大到小排序。其基本過程在函數SortVocab中,排序過程爲:
qsort(&vocab[1], vocab_size - 1, sizeof(struct vocab_word), VocabCompare);
保持字符「< \s>」在最開始的位置。排序後,依據「min_count」對低頻詞進行處理,與上述同樣,再對剩下的詞又一次計算hash值。
至此。整個對詞的處理過程就已經結束了。加下來,將是對網絡結構的處理和詞向量的訓練。
有了以上的對詞的處理,就已經處理好了所有的訓練樣本,此時,便可以開始網絡結構的初始化和接下來的網絡訓練。網絡的初始化的過程在InitNet()函數中完畢。
在初始化的過程當中,基本的參數包含詞向量的初始化和映射層到輸出層的權重的初始化,例如如下圖所看到的:
在初始化的過程當中,映射層到輸出層的權重都初始化爲
for (a = 0; a < vocab_size; a++) for (b = 0; b < layer1_size; b++) {
next_random = next_random * (unsigned long long)25214903917 + 11;
// 一、與:至關於將數控制在必定範圍內
// 二、0xFFFF:65536
// 三、/65536:[0,1]之間
syn0[a * layer1_size + b] = (((next_random & 0xFFFF) / (real)65536) - 0.5) / layer1_size;// 初始化詞向量
}
首先,生成一個很是大的next_random的數,經過與「0xFFFF」進行與運算截斷。再除以65536獲得
在層次Softmax中需要使用到Huffman樹以及Huffman編碼,所以。在網絡結構的初始化過程當中,也需要初始化Huffman樹。在生成Huffman樹的過程當中,首先定義了
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編碼的生成過程寫得比較精簡。
假設是採用負採樣的方法,此時還需要初始化每一個詞被選中的機率。在所有的詞構成的詞典中,每一個詞出現的頻率有高有低,咱們但願。對於那些高頻的詞。被選中成爲負樣本的機率要大點,同一時候,對於那些出現頻率比較低的詞。咱們但願其被選中成爲負樣本的頻率低點。
這個原理於「輪盤賭」的策略一致(具體可以參見「優化算法——遺傳算法」)。
在程序中,實現這部分功能的代碼爲:
// 生成負採樣的機率表
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;
}
}
在實現的過程當中。沒有直接使用每一個詞的頻率。而是使用了詞的
以上的各個部分是爲訓練詞向量作準備,即準備訓練數據,構建訓練模型。
在上述的初始化完畢後,接下來就是依據不一樣的方法對模型進行訓練,在實現的過程當中,做者使用了多線程的方法對其進行訓練。
爲了可以對文本進行加速訓練,在實現的過程當中,做者使用了多線程的方法。並對每一個線程上分配指定大小的文件:
// 利用多線程對訓練文件劃分,每一個線程訓練一部分的數據
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中要使用的神經機率語言模型。具體的神經機率語言模型可以參見「」。
CBOW模型和Skip-gram模型是神經機率語言模型的兩種變形形式,當中。在CBOW模型中包含三層,即輸入層。映射層和輸出層。
對於CBOW模型,例如如下圖所看到的:
在CBOW模型中。經過詞
此處的窗體的大小window爲2。
首先找到每一個詞相應的詞向量,並將這些詞的詞向量相加,程序代碼例如如下所看到的:
// 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對模型進行訓練。
Hierarchical Softmax是word2vec中用於提升性能的一項關鍵的技術。
由Hierarchical Softmax的原理可知。對於詞w。其對數似然函數爲:
當中,
在此,變量爲
所以。對於
在word2vec源代碼中,爲了可以加快計算。做者在開始的時候存儲了一份Sigmoid的值,所以,對於
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];// 改動映射層到輸出層之間的權重
}
對於窗體內的詞的向量的更新,則是利用窗體內的所有詞的梯度之和
// 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];
}
與Hierarchical Softmax一致,Negative Sampling也是一種加速計算的方法。在Negative Sampling方法中使用的是隨機的負採樣。在CBOW模型中。已知詞
// 標記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;
}
當選擇出了正負樣本,此時的損失函數爲:
其對數似然函數爲:
即爲:
取:
在此,變量爲
所以,更新的代碼爲:
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中一致。
而Skip-gram模型與CBOW正好相反。在Skip-gram模型中,則是經過當前詞
由上述的分析,咱們發現。在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];
與上述一致。在Skip-gram中與CBOW中的惟一不一樣是在Skip-gram中是循環的過程。代碼的實現類似與上面的Hierarchical Softmax。
凝視版的word2vec源代碼已經上傳到Github中:Github:word2vec.c