1. 程式人生 > >Word2Vec原始碼詳細解析(上)

Word2Vec原始碼詳細解析(上)

相關連結:

1、Word2Vec原始碼最詳細解析(上)

2、Word2Vec原始碼最詳細解析(下)

Word2Vec原始碼最詳細解析(上)

在這一部分中,主要介紹的是Word2Vec原始碼中的主要資料結構、各個變數的含義與作用,以及所有演算法之外的輔助函式,包括如何從訓練檔案中獲取詞彙、構建詞表、hash表、Haffman樹等,為演算法實現提供資料準備。而演算法部分的程式碼實現將在《Word2Vec原始碼最詳細解析(下)》一文中,重點分析。

該部分程式碼分析如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <pthread.h>

#define MAX_STRING 100
#define EXP_TABLE_SIZE 1000
#define MAX_EXP 6
#define MAX_SENTENCE_LENGTH 1000
#define MAX_CODE_LENGTH 40

const int vocab_hash_size = 30000000;  // Maximum 30 * 0.7 = 21M words in the vocabulary

typedef float real;                    // Precision of float numbers

//每個詞的基本資料結構
struct vocab_word {
  long long cn;		//詞頻,從訓練集中計數得到或直接提供詞頻檔案
  int *point;		//Haffman樹中從根節點到該詞的路徑,存放的是路徑上每個節點的索引
  //word為該詞的字面值
  //code為該詞的haffman編碼
  //codelen為該詞haffman編碼的長度
  char *word, *code, codelen;
};

char train_file[MAX_STRING], output_file[MAX_STRING];
char save_vocab_file[MAX_STRING], read_vocab_file[MAX_STRING];
//詞表,該陣列的下標表示這個詞在此表中的位置,也稱之為這個詞在詞表中的索引
struct vocab_word *vocab;
int binary = 0, cbow = 1, debug_mode = 2, window = 5, min_count = 5, num_threads = 12, min_reduce = 1;
//詞hash表,該陣列的下標為每個詞的hash值,由詞的字面值ASCII碼計算得到。vocab_hash[hash]中儲存的是該詞在詞表中的索引
int *vocab_hash;
//vocab_max_size是一個輔助變數,每次當詞表大小超出vocab_max_size時,一次性將詞表大小增加1000
//vocab_size為訓練集中不同單詞的個數,即詞表的大小
//layer1_size為詞向量的長度
long long vocab_max_size = 1000, vocab_size = 0, layer1_size = 100;
long long train_words = 0, word_count_actual = 0, iter = 5, file_size = 0, classes = 0;
real alpha = 0.025, starting_alpha, sample = 1e-3;
//syn0儲存的是詞表中每個詞的詞向量
//syn1儲存的是Haffman樹中每個非葉節點的向量
//syn1neg是負取樣時每個詞的輔助向量
//expTable是提前計算好的Sigmond函式表
real *syn0, *syn1, *syn1neg, *expTable;
clock_t start;

int hs = 0, negative = 5;
const int table_size = 1e8;
int *table;

//計算每個函式的能量分佈表,在負取樣中用到
void InitUnigramTable() {
  int a, i;
  long long train_words_pow = 0;
  real d1, power = 0.75;
  //為能量表table分配記憶體空間,共有table_size項,table_size為一個既定的數1e8
  table = (int *)malloc(table_size * sizeof(int));
  //遍歷詞表,根據詞頻計算能量總值
  for (a = 0; a < vocab_size; a++) train_words_pow += pow(vocab[a].cn, power);
  i = 0;
  //d1:表示已遍歷詞的能量值佔總能量的比
  d1 = pow(vocab[i].cn, power) / (real)train_words_pow;
  //a:能量表table的索引
  //i:詞表的索引
  for (a = 0; a < table_size; a++) {
    //i號單詞佔據table中a位置
<span style="white-space:pre">	</span>table[a] = i;
<span style="white-space:pre">	</span>//能量表反映的是一個單詞的能量分佈,如果該單詞的能量越大,所佔table的位置就越多
<span style="white-space:pre">	</span>//如果當前單詞的能量總和d1小於平均值,i遞增,同時更新d1;反之如果能量高的話,保持i不變,以佔據更多的位置
    if (a / (real)table_size > d1) {
      i++;
      d1 += pow(vocab[i].cn, power) / (real)train_words_pow;
    }
<span style="white-space:pre">	</span>//如果詞表遍歷完畢後能量表還沒填滿,將能量表中剩下的位置用詞表中最後一個詞填充
    if (i >= vocab_size) i = vocab_size - 1;
  }
}

//從檔案中讀入一個詞到word,以space' ',tab'\t',EOL'\n'為詞的分界符
//截去一個詞中長度超過MAX_STRING的部分
//每一行的末尾輸出一個</s>
void ReadWord(char *word, FILE *fin) {
  int a = 0, ch;
  while (!feof(fin)) {
    ch = fgetc(fin);
    if (ch == 13) continue;
    if ((ch == ' ') || (ch == '\t') || (ch == '\n')) {
      if (a > 0) {
        if (ch == '\n') ungetc(ch, fin);
        break;
      }
      if (ch == '\n') {
        strcpy(word, (char *)"</s>");
        return;
      } else continue;
    }
    word[a] = ch;
    a++;
    if (a >= MAX_STRING - 1) a--;   // Truncate too long words
  }
  word[a] = 0;
}

//返回一個詞的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
//先計算詞的hash值,然後在詞hash表中,以該值為下標,檢視對應的值
//如果為-1說明這個詞不存在索引,即不存在在詞表中,返回-1
//如果該索引在詞表中對應的詞與正在查詢的詞不符,說明發生了hash值衝突,按照開放地址法去尋找這個詞
int SearchVocab(char *word) {
  unsigned int hash = GetWordHash(word);
  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;
}

//從檔案中讀入一個詞,並返回這個詞在詞表中的位置,相當於將之前的兩個函式包裝了起來
int ReadWordIndex(FILE *fin) {
  char word[MAX_STRING];
  ReadWord(word, fin);
  if (feof(fin)) return -1;
  return SearchVocab(word);
}

//為一個詞構建一個vocab_word結構物件,並新增到詞表中
//詞頻初始化為0,hash值用之前的函式計算,
//返回該詞在詞表中的位置
int AddWordToVocab(char *word) {
  unsigned int hash, length = strlen(word) + 1;
  if (length > MAX_STRING) length = MAX_STRING;
  vocab[vocab_size].word = (char *)calloc(length, sizeof(char));
  strcpy(vocab[vocab_size].word, word);
  vocab[vocab_size].cn = 0;
  vocab_size++;
  //每當詞表數目即將超過最大值時,一次性為其申請新增一千個詞結構體的記憶體空間
  if (vocab_size + 2 >= vocab_max_size) {
    vocab_max_size += 1000;
    vocab = (struct vocab_word *)realloc(vocab, vocab_max_size * sizeof(struct vocab_word));
  }
  hash = GetWordHash(word);
  //如果該hash值與其他詞產生衝突,則使用開放地址法解決衝突(為這個詞尋找一個hash值空位)
  while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
  //將該詞在詞表中的位置賦給這個找到的hash值空位
  vocab_hash[hash] = vocab_size - 1;
  return vocab_size - 1;
}

//按照詞頻從大到小排序
int VocabCompare(const void *a, const void *b) {
    return ((struct vocab_word *)b)->cn - ((struct vocab_word *)a)->cn;
}

//統計詞頻,按照詞頻對詞表中的項從大到小排序
void SortVocab() {
  int a, size;
  unsigned int hash;
  //對詞表進行排序,將</s>放在第一個位置
  qsort(&vocab[1], vocab_size - 1, sizeof(struct vocab_word), VocabCompare);
  //充值hash表
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;
  size = vocab_size;
  train_words = 0;
  for (a = 0; a < size; a++) {
    //將出現次數小於min_count的詞從詞表中去除,出現次數大於min_count的重新計算hash值,更新hash詞表
    if ((vocab[a].cn < min_count) && (a != 0)) {
      vocab_size--;
      free(vocab[a].word);
    } else {
	//hash值計算
      hash=GetWordHash(vocab[a].word);
	//hash值衝突解決
      while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
      vocab_hash[hash] = a;
	//計算總詞數
      train_words += vocab[a].cn;
    }
  }
  //由於刪除了詞頻較低的詞,這裡調整詞表的記憶體空間
  vocab = (struct vocab_word *)realloc(vocab, (vocab_size + 1) * sizeof(struct vocab_word));
  // 為Haffman樹的構建預先申請空間
  for (a = 0; a < vocab_size; a++) {
    vocab[a].code = (char *)calloc(MAX_CODE_LENGTH, sizeof(char));
    vocab[a].point = (int *)calloc(MAX_CODE_LENGTH, sizeof(int));
  }
}

//從詞表中刪除出現次數小於min_reduce的詞,沒執行一次該函式min_reduce自動加一
void ReduceVocab() {
  int a, b = 0;
  unsigned int hash;
  for (a = 0; a < vocab_size; a++) if (vocab[a].cn > min_reduce) {
    vocab[b].cn = vocab[a].cn;
    vocab[b].word = vocab[a].word;
    b++;
  } else free(vocab[a].word);
  vocab_size = b;
  //重置hash表
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;
  //更新hash表
  for (a = 0; a < vocab_size; a++) {
    //hash值計算
    hash = GetWordHash(vocab[a].word);
	//hash值衝突解決
    while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
    vocab_hash[hash] = a;
  }
  fflush(stdout);
  min_reduce++;
}

//利用統計到的詞頻構建Haffman二叉樹
//根據Haffman樹的特性,出現頻率越高的詞其二叉樹上的路徑越短,即二進位制編碼越短
void CreateBinaryTree() {
  long long a, b, i, min1i, min2i, pos1, pos2;
  //用來暫存一個詞到根節點的Haffman樹路徑
  long long point[MAX_CODE_LENGTH];
  //用來暫存一個詞的Haffman編碼
  char code[MAX_CODE_LENGTH];
  
  //記憶體分配,Haffman二叉樹中,若有n個葉子節點,則一共會有2n-1個節點 
  //count陣列前vocab_size個元素為Haffman樹的葉子節點,初始化為詞表中所有詞的詞頻
  //count陣列後vocab_size個元素為Haffman書中即將生成的非葉子節點(合併節點)的詞頻,初始化為一個大值1e15
  long long *count = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
  //binary陣列記錄各節點相對於其父節點的二進位制編碼(0/1)
  long long *binary = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
  //paarent陣列記錄每個節點的父節點
  long long *parent_node = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
  //count陣列的初始化
  for (a = 0; a < vocab_size; a++) count[a] = vocab[a].cn;
  for (a = vocab_size; a < vocab_size * 2; a++) count[a] = 1e15;
  
  //以下部分為建立Haffman樹的演算法,預設詞表已經按詞頻由高到低排序
  //pos1,pos2為別為詞表中詞頻次低和最低的兩個詞的下標(初始時就是詞表最末尾兩個)
  //</s>詞也包含在樹內
  pos1 = vocab_size - 1;
  pos2 = vocab_size;
  //最多進行vocab_size-1次迴圈操作,每次新增一個節點,即可構成完整的樹
  for (a = 0; a < vocab_size - 1; a++) {
    //比較當前的pos1和pos2,在min1i、min2i中記錄當前詞頻最小和次小節點的索引
	//min1i和min2i可能是葉子節點也可能是合併後的中間節點
    if (pos1 >= 0) {
	  //如果count[pos1]比較小,則pos1左移,反之pos2右移
      if (count[pos1] < count[pos2]) {
        min1i = pos1;
        pos1--;
      } else {
        min1i = pos2;
        pos2++;
      }
    } else {
      min1i = pos2;
      pos2++;
    }
    if (pos1 >= 0) {
	  //如果count[pos1]比較小,則pos1左移,反之pos2右移
      if (count[pos1] < count[pos2]) {
        min2i = pos1;
        pos1--;
      } else {
        min2i = pos2;
        pos2++;
      }
    } else {
      min2i = pos2;
      pos2++;
    }
	//在count陣列的後半段儲存合併節點的詞頻(即最小count[min1i]和次小count[min2i]詞頻之和)
    count[vocab_size + a] = count[min1i] + count[min2i];
	//記錄min1i和min2i節點的父節點
    parent_node[min1i] = vocab_size + a;
    parent_node[min2i] = vocab_size + a;
    //這裡令每個節點的左右子節點中,詞頻較低的為1(則詞頻較高的為0)
	binary[min2i] = 1;
  }
  
  //根據得到的Haffman二叉樹為每個詞(樹中的葉子節點)分配Haffman編碼
  //由於要為所有詞分配編碼,因此迴圈vocab_size次
  for (a = 0; a < vocab_size; a++) {
    b = a;
    i = 0;
    while (1) {
	  //不斷向上尋找葉子結點的父節點,將binary陣列中儲存的路徑的二進位制編碼增加到code陣列末尾
      code[i] = binary[b];
	  //在point陣列中增加路徑節點的編號
      point[i] = b;
	  //Haffman編碼的當前長度,從葉子結點到當前節點的深度
      i++;
      b = parent_node[b];
	  //由於Haffman樹一共有vocab_size*2-1個節點,所以vocab_size*2-2為根節點
      if (b == vocab_size * 2 - 2) break;
    }
	//在詞表中更新該詞的資訊
	//Haffman編碼的長度,即葉子結點到根節點的深度
    vocab[a].codelen = i;
	//Haffman路徑中儲存的中間節點編號要在現在得到的基礎上減去vocab_size,即不算葉子結點,單純在中間節點中的編號
	//所以現在根節點的編號為(vocab_size*2-2) - vocab_size = vocab_size - 2
    vocab[a].point[0] = vocab_size - 2;
	//Haffman編碼和路徑都應該是從根節點到葉子結點的,因此需要對之前得到的code和point進行反向。
    for (b = 0; b < i; b++) {
      vocab[a].code[i - b - 1] = code[b];
      vocab[a].point[i - b] = point[b] - vocab_size;
    }
  }
  free(count);
  free(binary);
  free(parent_node);
}

//從訓練檔案中獲取所有詞彙並構建詞表和hash比
void LearnVocabFromTrainFile() {
  char word[MAX_STRING];
  FILE *fin;
  long long a, i;
  
  //初始化hash詞表
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;
  
  //開啟訓練檔案
  fin = fopen(train_file, "rb");
  if (fin == NULL) {
    printf("ERROR: training data file not found!\n");
    exit(1);
  }
  
  //初始化詞表大小
  vocab_size = 0;
  //將</s>新增到詞表的最前端
  AddWordToVocab((char *)"</s>");
  
 //開始處理訓練檔案
  while (1) {
	//從檔案中讀入一個詞
    ReadWord(word, fin);
    if (feof(fin)) break;
	//對總詞數加一,並輸出當前訓練資訊
    train_words++;
    if ((debug_mode > 1) && (train_words % 100000 == 0)) {
      printf("%lldK%c", train_words / 1000, 13);
      fflush(stdout);
    }
	//搜尋這個詞在詞表中的位置
    i = SearchVocab(word);
    //如果詞表中不存在這個詞,則將該詞新增到詞表中,建立其在hash表中的值,初始化詞頻為1;反之,詞頻加一
	if (i == -1) {
      a = AddWordToVocab(word);
      vocab[a].cn = 1;
    } else vocab[i].cn++;
	//如果詞表大小超過上限,則做一次詞表刪減操作,將當前詞頻最低的詞刪除
    if (vocab_size > vocab_hash_size * 0.7) ReduceVocab();
  }
  //對詞表進行排序,剔除詞頻低於閾值min_count的值,輸出當前詞表大小和總詞數
  SortVocab();
  if (debug_mode > 0) {
    printf("Vocab size: %lld\n", vocab_size);
    printf("Words in train file: %lld\n", train_words);
  }
  //獲取訓練檔案的大小,關閉檔案控制代碼
  file_size = ftell(fin);
  fclose(fin);
}

//將單詞和對應的詞頻輸出到檔案中
void SaveVocab() {
  long long i;
  FILE *fo = fopen(save_vocab_file, "wb");
  for (i = 0; i < vocab_size; i++) fprintf(fo, "%s %lld\n", vocab[i].word, vocab[i].cn);
  fclose(fo);
}

//從詞彙表文件中讀詞並構建詞表和hash表
//由於詞彙表中的詞語不存在重複,因此與LearnVocabFromTrainFile相比沒有做重複詞彙的檢測
void ReadVocab() {
  long long a, i = 0;
  char c;
  char word[MAX_STRING];
  //開啟詞彙表文件
  FILE *fin = fopen(read_vocab_file, "rb");
  if (fin == NULL) {
    printf("Vocabulary file not found\n");
    exit(1);
  }
  //初始化hash詞表
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;
  vocab_size = 0;
  
  //開始處理詞彙表文件
  while (1) {
	//從檔案中讀入一個詞
    ReadWord(word, fin);
    if (feof(fin)) break;
	//將該詞新增到詞表中,建立其在hash表中的值,並通過輸入的詞彙表文件中的值來更新這個詞的詞頻
    a = AddWordToVocab(word);
    fscanf(fin, "%lld%c", &vocab[a].cn, &c);
    i++;
  }
  //對詞表進行排序,剔除詞頻低於閾值min_count的值,輸出當前詞表大小和總詞數
  SortVocab();
  if (debug_mode > 0) {
    printf("Vocab size: %lld\n", vocab_size);
    printf("Words in train file: %lld\n", train_words);
  }
  //開啟訓練檔案,將檔案指標移至檔案末尾,獲取訓練檔案的大小
  fin = fopen(train_file, "rb");
  if (fin == NULL) {
    printf("ERROR: training data file not found!\n");
    exit(1);
  }
  fseek(fin, 0, SEEK_END);
  file_size = ftell(fin);
  //關閉檔案控制代碼
  fclose(fin);
}