word2vec源码分析

word2vec源码下载地址:https://github.com/tmikolov/word2vec

本文对word2vec源码进行分析,在源码中进行了注释。在阅读源码之前,建议先阅读以下两篇博文,加深对word2vec的理解。

《word2vec中的数学原理详解》

《word2vec数学分析》

以下给出word2vec源码和注释:

//  Copyright 2013 Google Inc. All Rights Reserved.
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.

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

#define MAX_STRING 100																				// 一个词的最大字符长度(英语:单词的字符个数,汉语:词中字个数)
#define EXP_TABLE_SIZE 1000																			// 对sigmoid函数值进行缓存,存储1000个,需要用的时候查表,x范围是[-MAX_EXP, MAX_EXP]
#define MAX_EXP 6																					// sigmoid函数缓存的计算范围,最大计算到6 (exp^6 / (exp^6 + 1)),最小计算到-6 (exp^-6 / (exp^-6 + 1))
#define MAX_SENTENCE_LENGTH 1000																	// 定义句子的最大长度(最大词个数)
#define MAX_CODE_LENGTH 40																			// 最长的哈夫曼编码长度和路径长度,vocab_word中point域和code域最大大小

const int vocab_hash_size = 30000000;																// Maximum 30 * 0.7 = 21M words in the vocabulary,词库哈希表大小,装填系数为0.7,用线性探测解决哈希冲突

typedef float real;																					// Precision of float numbers

struct vocab_word {																					// 词的结构体,存储包括词本身、词频、哈夫曼编码、编码长度、哈夫曼路径
    long long cn;																					// 词频
    int *point;																						// 哈夫曼树中从根节点到该词的路径,路径的索引要特别注意,在下面的构建哈夫曼树中会说明
    char *word, *code, codelen;																		// 分别是:词,哈夫曼编码,编码长度
};

/**
 * train_file					要训练的语料文件,以句子为单位进行训练,会从该文件读取词以建立词库,训练时从该文件读入句子
 * output_file					训练后词向量的输出文件,由binary和classes共同控制输出结果
 */
char train_file[MAX_STRING], output_file[MAX_STRING];

/**
 * save_vocab_file				当提供该参数时,建立词库后,会把词库保存到该文件
 * read_vocab_file				当提供该参数时,从该文件读入词库;若不提供该参数时,从训练语料文件(train_file)读入并建立词库
 */
char save_vocab_file[MAX_STRING], read_vocab_file[MAX_STRING];

/**
 * 词库数组,一维数组,每一个对象都是vocab_word类型
 */
struct vocab_word *vocab;

/**
 * binary						词向量输出控制,classes和binary共同控制。若classes = 0,由binary控制,binary = 1时二进制输出,binary = 0时文本格式输出;若classes > 0,聚类输出(只输出词和聚类类别,不输出词向量)
 * cbow							1:使用CBOW模型,0:使用Skip-gram模型
 * debug_mode					用于输出一些进度信息
 * window						上下文窗口大小,实际使用的是动态窗口,动态大小为[0, window]
 * min_count					最小词频
 * num_threads					训练线程数,多线程训练
 * min_reduce					最小词频
 */
int binary = 0, cbow = 1, debug_mode = 2, window = 5, min_count = 5, num_threads = 12, min_reduce = 1;

/**
 * vocab_hash					词库的hash表,将词按hash映射到词库,vocab_hash[word_hash] = 词在词库的位置,在从语料文件建立词库时,对读入的词快速定位到其在词库中的位置,训练时不会用到
 */
int *vocab_hash;

/**
 * vocab_max_size				词库规模(词库容量),在建立词库的过程中,当词库规模到达vocab_max_size时会对词库扩容,每次扩增vocab_max_size个容量
 * vocab_size					词库中实际的词个数
 * layer1_size					词向量的维度大小(也是隐藏层的大小)
 */
long long vocab_max_size = 1000, vocab_size = 0, layer1_size = 100;

/**
 * train_words					要训练的词总数(词频累加,但不包含低频词),在多线程训练时,每个线程要训练的词数是train_words / num_threads
 * word_count_actual			已经训练完的词频总数,因为训练迭代次数为iter次,所以最终word_count_actual = iter * train_words
 * iter							每个训练线程训练迭代的次数,即每个线程对各自分配到的语料上迭代训练iter次
 * file_size					训练文件大小,ftell得到,多线程训练时会对文件进行分隔,用于定位每个训练线程开始训练的文件位置
 * classes						词向量输出控制,classes和binary共同控制。若classes = 0,由binary控制,binary = 1时二进制输出,binary = 0时文本格式输出;若classes > 0,聚类输出(只输出词和聚类类别,不输出词向量)
 */
long long train_words = 0, word_count_actual = 0, iter = 5, file_size = 0, classes = 0;

/**
 * alpha						学习速率,会根据训练进度衰减
 * starting_alpha				初始alpha值
 * sample						亚采样概率,会以一定的概率过滤高于这个值的高频词,加速训练,也提高相对低频词的精度
 */
real alpha = 0.025, starting_alpha, sample = 1e-3;

/**
 * syn0							存储词库中每个词的词向量(哈夫曼树中叶子节点的词向量),一维数组,第i个词的词向量为syn0[i * layer1_size, (i + 1) * layer1_size - 1]
 * syn1							哈夫曼树中非叶子节点的辅助向量,一维数组,第i个辅助向量为syn1[i * layer1_size, (i + 1) * layer1_size - 1]
 * syn1neg						负采样时,存储每个样本对应的词向量,一维数组,第i个词的词向量为syn1neg[i * layer1_size, (i + 1) * layer1_size - 1]
 * expTable						预先存储sigmod函数结果,训练中直接查表
 */
real *syn0, *syn1, *syn1neg, *expTable;

/**
 * start						训练开始时间,用于输出训练进度
 */
clock_t start;

/**
 * hs							hierarchical softmax标志
 * negative						negative sampling标志,也是负采样数,当negative和hs同时大于0时,这两个方法是混合使用的
 */
int hs = 0, negative = 5;

/**
 * table_size					负采样表的大小
 */
const int table_size = 1e8;

/**
 * table						负采样表,大小为table_size,每一个采样点对应的是词在词库的位置
 */
int *table;


/**
 * 根据词频大小进行负采样
 *
 * 可以这么理解:将词频视为一段线段,长度为词频大小,依次首尾相连后可以形成一段长度为1的线段,在这个线段内均匀采点table_size次,则采集到的点对应的词即组成负样本集
 */
void InitUnigramTable() {
    int a, i;
    double train_words_pow = 0;																		// 线段总长
    double d1, power = 0.75;																		// d1用于迭代累加每个词线段的长度(其实是比例),power用于计算每个词的线段长度(词频的0.75次方)
    table = (int *) malloc(table_size * sizeof(int));
    for (a = 0; a < vocab_size; a++) train_words_pow += pow(vocab[a].cn, power);					// 累加每个词线段的长度

    /**
     * 从词库的第一个词开始,向右遍历每个词;同时也从第一个采样点开始,向右遍历每一个采样点
     * 通过比例计算,判断当前采样点是否落在当前词,是:采样该词,同时采样点向右移动;否:当前采样点落在下一个(或之后)词上,则移动到下一次词。
     * 直到遍历完词库和采样点,即可完成负样本采集
     */
    i = 0;																							// i用于遍历词库,指示当前词位置
    d1 = pow(vocab[i].cn, power) / train_words_pow;													// 第一个词的线段比例
    for (a = 0; a < table_size; a++) {																// 遍历采样点
        
        /**
         * 收集当前词到负样本集
         * 
         * 注意:在数学上,该行代码逻辑不严谨,逻辑上并没有做到均匀采样,但可以采集到在词库上从左到右扫描过的每一个词,包括低频词
         * 
         * 当词i的词频比例很小时,小到没有任何采样点落在该词上,这时本不应该采样该词,但该行代码也会将其采样
         * 当词库和负采样规模相当,甚至更大时,该问题会凸显,当负采样规模远大于词库规模时,该问题可以得到控制,即增大了采样密度,放大了词的分辨率
         *
         * 若要严格均匀采样,该行代码应该改为是:if (a / (double) table_size < d1) table[a] = i;  即保证采样点落在该词内才将其采样(边缘上为下一个词)
         */
        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;													// 采样点落在词库外,这时采集最后一个词,词频计算时可能精度丢失,导致词频总和不严格等于1,导致最后的少量采样点落在词库外
    }
}

/**
 * Reads a single word from a file, assuming space + tab + EOL to be word boundaries
 * 每文件fin中读取一个词
 * @param word
 * @param fin
 * @param eof
 */
void ReadWord(char *word, FILE *fin, char *eof) {
    int a = 0, ch;
    while (1) {																						// 循环读入字符,直到遇到空白符或文件尾
        ch = fgetc_unlocked(fin);																	// 读入一个字符
        if (ch == EOF) {																			// 读到了文件尾,读取完成,退出循环
            *eof = 1;
            break;
        }
        if (ch == 13) continue;																		// 读到了回车(计算机中回车和换行的意义是不一样的),读下一行
        if ((ch == ' ') || (ch == '\t') || (ch == '\n')) {											// 读到了空白符,这时要判断该空白符是当前词的结束还是上一个词的结束
            if (a > 0) {																			// 读到的空白符是当前词的结束
                if (ch == '\n') ungetc(ch, fin);													// 读到了换行符,换行符在word2vec中是一个特殊的词,所以将'\n'回退给fin,会在下一次读入
                break;
            }
            if (ch == '\n') {																		// 读到了换行符,因为换行符是一个特殊的词,在词库中用'</s>'表示,所以这里做了一个转换
                strcpy(word, (char *) "</s>");
                return;
            } else continue;																		// 读到的空白符是上一个词的结束符,即本次还没有读到任务有意义的字符,所以继续读入字符
        }
        word[a] = ch;																				// 读到了有意义的字符,将该字符添加到词上
        a++;
        if (a >= MAX_STRING - 1) a--;																// Truncate too long words,词太长,截断
    }
    word[a] = 0;																					// 字符串以0结束
}

/**
 * Returns hash value of a word
 * 计算词的hash值
 * @param word
 * @return
 */
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;
}

/**
 * Returns position of a word in the vocabulary; if the word is not found, returns -1
 * 查找词在词库中的位置,词库中存在该词,返回词的位置,否则返回-1
 * @param word
 * @return
 */
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;
}

/**
 * Reads a word and returns its index in the vocabulary
 * 从文件流中读入一个词,并返回这个词在词库中的位置,这个方法是在训练时被调用,用于读入句子
 * @param fin
 * @param eof
 * @return
 */
int ReadWordIndex(FILE *fin, char *eof) {
    char word[MAX_STRING], eof_l = 0;
    ReadWord(word, fin, &eof_l);																	// 读入一个词
    if (eof_l) {																					// 读到了文件尾,返回-1
        *eof = 1;
        return -1;
    }
    return SearchVocab(word);																		// 返回该词在词库中的位置
}

/**
 * Adds a word to the vocabulary
 * 将一个词添加到词库中,并返回该词在词库中的位置,这时是将词添加到词库尾
 * @param word
 * @return
 */
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;																		// 这里词频记为0,当从训练语料读入词时,会在调用方赋值为1;当从之前保存的词库文件读入时,会在调用方读入词频
    vocab_size++;																					// 词库现有词总数加1
    // Reallocate memory if needed
    if (vocab_size + 2 >= vocab_max_size) {															// 持续读入词时,词库数组的容量不够了,对词库数组进行扩容1000个词位
        vocab_max_size += 1000;
        vocab = (struct vocab_word *) realloc(vocab, vocab_max_size * sizeof(struct vocab_word));
    }
    
    /**
     * 这三行代码是对该词建立hash映射,哈希冲突时线性探测继续顺序往下查找空白位置
     */
    hash = GetWordHash(word);
    while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
    vocab_hash[hash] = vocab_size - 1;
    
    return vocab_size - 1;																			// 返回添加的词在词库中的存储位置,即最后一个
}

/**
 * Used later for sorting by word counts
 * 比较两个词的词频大小,用于对词库排序
 * @param a
 * @param b
 * @return
 */
int VocabCompare(const void *a, const void *b) {
    long long l = ((struct vocab_word *) b)->cn - ((struct vocab_word *) a)->cn;
    if (l > 0) return 1;
    if (l < 0) return -1;
    return 0;
}

/**
 * Sorts the vocabulary by frequency using word counts
 * 按词频排序,按词频从大到小排序,该方法内会删除低频词
 * 同时,给哈夫曼编码和路径的词库索引分配空间
 */
void SortVocab() {
    int a, size;
    unsigned int hash;
    // Sort the vocabulary and keep </s> at the first position
    qsort(&vocab[1], vocab_size - 1, sizeof(struct vocab_word), VocabCompare);						// 词库第一个词是'</s>'不参与排序,保持在第一位,对其他词进行排序
    for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;										// 词库排序后,哈希映射被打乱,清除
    size = vocab_size;
    train_words = 0;																				// 记录在词库中出现了的词的词频总数,多线程训练时,用于给每个线程平均分配要训练的词数
    for (a = 0; a < size; a++) {																	// 删除低频词,并重建hash映射
        // Words occuring less than min_count times will be discarded from the vocab
        if ((vocab[a].cn < min_count) && (a != 0)) {												// 删除低频词,但不能把'</s>'删除了,其实'</s>'词的词频字段为0,因为没给其赋值
            vocab_size--;																			// 删除一个低频词,词库中的词总数减1
            free(vocab[a].word);																	// 释放该词的存储空间
        } else {																					// 重新计算hash映射
            // Hash will be re-computed, as after the sorting it is not actual
            /**
             * 这三行代码是重建词的hash映射,哈希冲突时线性探测继续顺序往下查找空白位置
             */
            hash = GetWordHash(vocab[a].word);
            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));		// 删除低频词后,重新分配vocab的内存大小,以释放一些不必要的存储空间,原词库中vocab_size + 1后的低频词全部被删除

    // Allocate memory for the binary tree construction
    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));
    }
}

/**
 * Reduces the vocabulary by removing infrequent tokens
 * 删除低频词,
 * 从语料文件建立词库时,为了控制词库规模,会在词库的hash表达到填充因子上限时,调用该方法删除一些低频词
 */
void ReduceVocab() {
    int a, b = 0;																					// a:遍历词库,b:用于规整词库,将右侧的非低频词移到词库左侧
    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;																					// 删除低频词后的词库大小,最后剩下b个词,词频均大于min_reduce
    for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;										// 规整词库后,hash映射被打乱,清除
    for (a = 0; a < vocab_size; a++) {																// 重建hash映射
        // Hash will be re-computed, as it is not actual
        hash = GetWordHash(vocab[a].word);
        while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;							// 哈希冲突,线性探测继续顺序往下查找
        vocab_hash[hash] = a;
    }
    fflush(stdout);
    min_reduce++;																					// 每次删除低频词后,词库中的词频都已经大于min_reduce了,若下次还要删除低频词,必须删除更大词频的词了,因此min_reduce加1
}

/**
 * Create binary Huffman tree using the word counts
 * Frequent words will have short uniqe binary codes
 *
 * 构建哈夫曼树,就是按词频从小到大依次构建哈夫曼树的节点,同时得到每个节点的哈夫曼编码
 * 函数涉及三个一维数组:
 * 1、count						存储哈夫曼树每个节点的权重值(权重值用词频来表示)
 * 2、binary					存储哈夫曼树每个节点的哈夫曼编码(每个节点的编码为0或1)
 * 3、parent_node				存储哈夫曼树每个节点的父节点位置
 *
 * 词库vocab是一个一维数组,词库大小为vocab_size,按词频从大到小排列词
 *
 * 哈夫曼树的构建过程如下:
 * 首先创建一个一维数组:count,大小为:2 * vocab_size + 1,用于存储哈夫曼树每个节点的权重。其实哈夫曼树的节点总数 = 叶子节点个数 + 非叶子节点个数 = 叶子节点个数 + (叶子节点个数 - 1) = 2 * vocab_size - 1
 *     1. 将词库中每个词的词频依次写进count的[0,vocab_size - 1]位置中,相当于每个叶子节点的权重,这部分权重是非递增的
 *     2. 将count数组[vocab_size, 2 * vocab_size]位置用1e15填充,相当于每个非叶子节点的权重,每构建一个非叶子节点,会将非叶子节点的权重写进第一个1e15的位置,这部分权重是非递减的,因此count数组里的权重值是中间小,两边大
 * 接下来构建哈夫曼树主要围绕权重数组count,从count数组的中间位置[vocab_size - 2, vocab_size - 1]开始,向两边查找count数组中两个权重最小的且未处理过的节点,找到这两个权重最小的位置。
 * 之后合并这两个最小的权重,构建一个父节点,这个父节点的权重等于这两个最小权重的和,将这个权重值写进count数组[vocab_size, 2 * vocab_size]部分的第一个1e15的位置上,而这两上最小权重节点与新构建的父节点就构成了父子关系
 * 循环这个过程,直到构建根节点
 
 * 注意,在构建哈夫曼树的同时,会记录每个节点的哈夫曼编码(根节点除外),在父节点的两个子节点中,权重大的编码为1,代表负类,权重小的编码为0,代表正类;同时也会在子节点位置上记录其父节点的位置,并将这个关系记录到parent_node数组中
 *
 *
 * 最后是构建词库中每个词(对应哈夫曼树的叶子节点)的哈夫曼编码和路径
 * 遍历词库中的每个词,从parent_node数组中可以一路找到根节点,这个路径的逆序就是词的哈夫曼路径,而这个路径上的每个节点的编码(除根节点)就构成了该词的哈夫曼编码
 */
void CreateBinaryTree() {
    
    /**
     * min1i					最小权重节点的位置
     * min2i					次小权重节点的位置
     * pos1						叶子节点部分([0,vocab_size - 1])权重最小的位置,用于查找min1i和min2i,从右向左移动
     * pos2						非叶子节点部分([vocab_size, 2 * vocab_size])权重最小的位置,用于查找min1i和min2i,从左向右移动
     * point					记录从根节点到词的路径
     */
    long long a, b, i, min1i, min2i, pos1, pos2, point[MAX_CODE_LENGTH];
    char code[MAX_CODE_LENGTH];																		// 记录词的哈夫曼编码
    long long *count = (long long *) calloc(vocab_size * 2 + 1, sizeof(long long));					// 储哈夫曼树每个节点的权重值,大小为:2 * vocab_size + 1
    long long *binary = (long long *) calloc(vocab_size * 2 + 1, sizeof(long long));				// 储哈夫曼树每个节点的哈夫曼编码,大小为:2 * vocab_size + 1
    long long *parent_node = (long long *) calloc(vocab_size * 2 + 1, sizeof(long long));			// 存储哈夫曼树每个节点的父节点位置,大小为:2 * vocab_size + 1
    for (a = 0; a < vocab_size; a++) count[a] = vocab[a].cn;										// 将词库中每个词的词频依次写进count的[0,vocab_size - 1]位置中
    for (a = vocab_size; a < vocab_size * 2; a++) count[a] = 1e15;									// 将count数组[vocab_size, 2 * vocab_size]位置用1e15填充
    pos1 = vocab_size - 1;																			// 设置pos1为左侧(叶子节点)权重最小的位置,从右向左移动,初始值为最后一个词的下标,即vocab_size - 1
    pos2 = vocab_size;																				// 设置pos2为右侧(非叶子节点)权重最小的位置,从左向右移动,初始时没有非叶子节点,初值为vocab_size
    // Following algorithm constructs the Huffman tree by adding one node at a time
    for (a = 0; a < vocab_size - 1; a++) {															// 在哈夫曼树中,有vocab_size - 1个非叶子节点,因此要循环vocab_size - 1次,每一次循环会合并两个最小的权重,构建一个非叶子节点
        // First, find two smallest nodes 'min1, min2'
        /**
         * 第一个if查找最小的权重位置
         */
        if (pos1 >= 0) {																			// 叶子节点未遍历完
            if (count[pos1] < count[pos2]) {														// 叶子节点的权重小于非叶子节点时,最小权重为叶子节点,将pos1赋值给min1i后左移一个
                min1i = pos1;
                pos1--;
            } else {																				// 非叶子节点的权重小于叶子节点时,最小权重为非叶子节点,将pos2赋值给min1i后右移一个
                min1i = pos2;
                pos2++;
            }
        } else {																					// 叶子节点已经遍历完,最小的权重位于右侧的非叶子节点,向右找最小权重的下标
            min1i = pos2;
            pos2++;
        }
        
        /**
         * 第二个if查找次小的权重位置
         */
        if (pos1 >= 0) {																			// 叶子节点未遍历完
            if (count[pos1] < count[pos2]) {														// 叶子节点的权重小于非叶子节点时,次小权重为叶子节点,将pos1赋值给min2i后左移一个
                min2i = pos1;
                pos1--;
            } else {																				// 非叶子节点的权重小于叶子节点时,次小权重为非叶子节点,将pos2赋值给min2i后右移一个
                min2i = pos2;
                pos2++;
            }
        } else {																					// 叶子节点已经遍历完,次小的权重位于右侧的非叶子节点,向右找次小权重的下标
            min2i = pos2;
            pos2++;
        }
        count[vocab_size + a] = count[min1i] + count[min2i];										// 合并找到的两个最小的权重,存储到右侧第一个1e15的位置上,其实这个位置就是vocab_size + a,即在构建第a(a从0编号)个非叶子节点,存储过程即可理解成构建非叶子节点
        parent_node[min1i] = vocab_size + a;														// 最小权重节点(min1i)的父节点为新构建的非叶子节点
        parent_node[min2i] = vocab_size + a;														// 次小权重节点(min2i)的父节点为新构建的非叶子节点
        binary[min2i] = 1;																			// 哈夫曼树中,权重大的子节点(即min2i)的编码为1,代表负类,权重小的子节点(即min1i)的编码为0,代表正类,数组中默认为0,因此没有赋值语句
    }
    
    // Now assign binary code to each vocabulary word
    /**
     * 遍历词库中每个词,沿着父节点路径,构建词的哈夫曼编码和路径
     */
    for (a = 0; a < vocab_size; a++) {
        b = a;																						// b:从当前词(叶节点)开始记录每一个父节点,即当前词的哈夫曼路径上的每一个节点
        i = 0;																						// i:记录哈夫曼编码长度,编码不包括根节点
        while (1) {																					// 沿沿着父节点路径,记录哈夫曼编码和路径,直到根节点
            code[i] = binary[b];																	// 记录哈夫曼编码,注意,这时code数组里记录的编码是逆序的
            point[i] = b;																			// 记录路径,注意,这时point数组里记录的路径是逆序的,point[0] = a < vocab_size,point[i] = b >= vocab_size
            i++;																					// 编码长度加1
            b = parent_node[b];																		// 找下一个父节点
            if (b == vocab_size * 2 - 2) break;														// vocab_size * 2 - 2为根节点位置,找到根节点时,该词的哈夫曼编码和路径即已记录到code和point中,但顺序是逆序的
        }
        vocab[a].codelen = i;																		// 记录词的哈夫曼编码的长度,编码不包括根节点
        vocab[a].point[0] = vocab_size - 2;															// 根节点位置,point[0]即是根节点位置(vocab_size * 2 - 2 - vocab_size),路径的长度比编码的长度大1,因此这里在point数组里记录了根节点
        for (b = 0; b < i; b++) {																	// 逆序处理
            vocab[a].code[i - b - 1] = code[b];														// 编码逆序,没有根节点,所以i - b减去1
            
            /**
             * 路径逆序,point的长度比code长1,即根节点,point数组“最后”一个值是负的,point路径是为了定位节点位置,叶子节点即是词本身,不用定位,所以训练时这个负数是用不到的
             * 
             * 在count数组中,非叶子节点的下标范围为[vocab_size, vocab_size * 2 - 2]
             * 但在词向量训练时,非叶子节点词向量是存储在syn1数组中的,虽然syn1数组的大小为vocab_size * layer1_size,但可以理解成vocab_size个向量,即大小为vocab_size
             * 所以这里做了一个映射,把count数组非叶子节点的范围[vocab_size, vocab_size * 2 - 2]顺序映射到syn1数组的向量范围的[0, vocab_size - 2]
             * 所以这里用point[b]减去了vocab_size,包括上面根节点的位置也是减去了vocab_size
             */
            vocab[a].point[i - b] = point[b] - vocab_size;
        }
    }
    free(count);
    free(binary);
    free(parent_node);
}

/**
 * 从原始语料训练文件读入词,构建词库
 */
void LearnVocabFromTrainFile() {
    char word[MAX_STRING], eof = 0;
    FILE *fin;
    long long a, i, wc = 0;																			// wc:在debug_mode模式中,每读入1000000个词输出一次信息
    for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;										// 清空词的hash映射
    fin = fopen(train_file, "rb");																	// 打开语料训练文件
    if (fin == NULL) {
        printf("ERROR: training data file not found!\n");
        exit(1);
    }
    vocab_size = 0;																					// 初始词库大小为0
    AddWordToVocab((char *) "</s>");																// 将'</s>'加入到词库的第一个位置
    while (1) {																						// 从语料文件中读入每个词,添加到词库中,在读完语料文件之前,词库是未按词频排序的
        ReadWord(word, fin, &eof);																	// 读入一个词
        if (eof) break;																				// 文件结束
        train_words++;																				// 词频总数加1
        wc++;
        if ((debug_mode > 1) && (wc >= 1000000)) {													// debug_mode模式打印进度
            printf("%lldM%c", train_words / 1000000, 13);
            fflush(stdout);
            wc = 0;
        }
        i = SearchVocab(word);																		// 查找该词是否已经在词库中,若不存在,添加;若已经存在,词频加1
        if (i == -1) {																				// 添加新词,词频为1
            a = AddWordToVocab(word);
            vocab[a].cn = 1;
        } else vocab[i].cn++;																		// 词已经在词库中,词频加1
        
        /**
         * 每添加一个词,判断一次词库大小,若大于填充因子上限,删除一次低频词
         * 
         * 注意,这里有一个问题,在删除低频词时,语料文件可能是未处理完的,只是读取了一部分词
         * 所以词库当前状态下的词频信息是局部的,不是训练文件全局的
         * 这时删除低频词时,是把局部的低频词删除,但局部低频词未必是全局低频词,例如,一个词在训练文件的前一部分少量出现,但在后面部分,大量出现
         * 不过考虑到词库规模(千万级),这个问题也不太可能出现
         */
        if (vocab_size > vocab_hash_size * 0.7) ReduceVocab();
    }
    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);
}

/**
 * 从之前保存的词库文件中读入词,构建词库
 * 词库文件每行按(词 词频)的格式存储。
 *
 * 并获取训练语料的语料文件的文件大小,注意,read_vocab_file文件是之保存的词库文件;train_file是要训练的语料文件,从该文件中读入句子,训练词向量
 */
void ReadVocab() {
    long long a, i = 0;
    char c, eof = 0;
    char word[MAX_STRING];
    FILE *fin = fopen(read_vocab_file, "rb");														// 打开词库文件
    if (fin == NULL) {
        printf("Vocabulary file not found\n");
        exit(1);
    }
    for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;										// 清空词的hash映射
    vocab_size = 0;																					// 初始词库大小为0
    while (1) {																						// 从词库文件中读入每个词,添加到词库中
        ReadWord(word, fin, &eof);																	// 读入一个词到
        if (eof) break;																				// 文件结束
        a = AddWordToVocab(word);																	// 添加到词库中,并返回该词在词库中的位置
        fscanf(fin, "%lld%c", &vocab[a].cn, &c);													// 读入词频,注意这里的&c,该变量用于读入每行最后的换行符,利于读取下一行的词
        i++;
    }
    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);
}

/**
 * 初始化
 *
 * 为词向量数组分配存储空间,并进行初始化
 * 若使用hierarchical softmax,为哈夫曼树非叶子节点辅助向量数组分配存储空间,并初始化为0
 * 若使用negative sampling,为负采样样本向量数组分配存储空间,并初始化为0
 *
 * 最后构建建哈夫曼树
 */
void InitNet() {
    long long a, b;
    unsigned long long next_random = 1;
    
    /**
     * vocab_size个词,每个词的向量维度为layer1_size
     */
    a = posix_memalign((void **) &syn0, 128, (long long) vocab_size * layer1_size * sizeof(real));
    if (syn0 == NULL) {
        printf("Memory allocation failed\n");
        exit(1);
    }

    /**
     * hierarchical softmax
     */
    if (hs) {
        
        /**
         * vocab_size个词,每个词的向量维度为layer1_size
         */
        a = posix_memalign((void **) &syn1, 128, (long long) vocab_size * layer1_size * sizeof(real));
        if (syn1 == NULL) {
            printf("Memory allocation failed\n");
            exit(1);
        }
        
        /**
         * 零初始化
         */
        for (a = 0; a < vocab_size; a++)
            for (b = 0; b < layer1_size; b++)
                syn1[a * layer1_size + b] = 0;
    }
    
    /**
     * negative sampling
     */
    if (negative > 0) {
        
        /**
         * vocab_size个词,每个词的向量维度为layer1_size
         */
        a = posix_memalign((void **) &syn1neg, 128, (long long) vocab_size * layer1_size * sizeof(real));
        if (syn1neg == NULL) {
            printf("Memory allocation failed\n");
            exit(1);
        }
        
        /**
         * 零初始化
         */
        for (a = 0; a < vocab_size; a++)
            for (b = 0; b < layer1_size; b++)
                syn1neg[a * layer1_size + b] = 0;
    }
    
    /**
     * 每个词向量每个维度用[-0.5/layer1_size, 0.5/layer1_size]范围的数初始化
     */
    for (a = 0; a < vocab_size; a++)
        for (b = 0; b < layer1_size; b++) {
            next_random = next_random * (unsigned long long) 25214903917 + 11;
            syn0[a * layer1_size + b] = (((next_random & 0xFFFF) / (real) 65536) - 0.5) / layer1_size;
        }

    /**
     * 创建哈夫曼树
     * 当只使用negative sampling训练词向量时,构建哈夫曼树多余了
     */
    CreateBinaryTree();
}

/**
 * 训练词向量
 * @param id 线程编号[0, num_threads - 1],不是线程号,表示num_threads个线程中的第几个线程,用于分割语料文件,确定本线程从语料文件读取句子的开始位置
 * @return
 *
 * 多线程训练,将训练语料分成与线程个数相等的份数,每个线程训练其中一份,每个线程对其分配到的语料子集迭代训练iter次
 * 训练按句子进行,每次从语料文件中读取一个句子,如果句子超长(超过MAX_SENTENCE_LENGTH个词),则截断,剩余的被当作下一个句子
 *
 * 根据cbow参数决定选择CBOW模型还是Skip-gram模型,cbow=1:使用CBOW模型;cbow=0:使用Skip-gram模型。但是要注意的是不管选择哪个模型都可以混合使用hierarchical softmax和negative sampling
 *
 * CBOW模型的训练思想是根据上下文预测当前词,得到一个预测误差,将该误差反向更新到每一个上下文词上,让这些上下文词向更正确的预测当前词的方向更新
 * Skip-gram模型的训练思想与CBOW模型相反,是根据当前词预测上下文,得到一个预测误差,将该误差反向更新到当前词上,让当前词向更正确的预测上下文的方向更新
 *
 * 但是在word2vec中,Skip-gram模型的并没有按上面的思想进行训练,而是借鉴了CBOW的思想,其思路是用上下文中的每一个词来预测当前词,得到一个预测误差,再将该误差反向更新到该上下文词上
 *
 * 注意,word2vec是多线程训练,全局训练参数是共享的,这一点要特别注意,下面的注释中没有刻意说明这一点
 *
 * word2vec训练的数学推导可以参照CSDN博文:https://blog.csdn.net/sealir/article/details/85269567
 */
void *TrainModelThread(void *id) {
    /**
     * a						用于遍历对象
     * b						动态上下文窗口大小,动态大小为[0, window]
     * d						用于遍历对象
     * word						当前词,注意:word的值其实是词在词库中的位置,为了注释方便,以下注释中提到词时都是指词在词库中的位置,辅助向量也是一样
     * last_word				用于遍历上下文
     * sentence_length			当前训练句子的长度(词数)
     * sentence_position		当前词在句子中的位置
     */
    long long a, b, d, cw, word, last_word, sentence_length = 0, sentence_position = 0;

    /**
     * word_count				已经训练的词总数(词频累加)
     * last_word_count			上一次记录的已经训练的词频总数,与word_count一起用于衰减学习率,每训练10000个词衰减一次
     * sen						当前待训练的句子,存储的是每个词在词库中的位置
     */
    long long word_count = 0, last_word_count = 0, sen[MAX_SENTENCE_LENGTH + 1];


    /**
     * l1						在Skip-gram模型中,用于定位上下文词的词向量在syn0中的起始位置
     * l2						hierarchical softmax时,用于定位非叶子节点辅助向量在syn1中的起始位置;negative sampling时,用于定位采样点(包括正负样本)辅助向量在syn1neg中的起始位置
     * c						用于遍历对象
     * target					在negative sampling中,表示采样点(包括正负样本)词
     * label					在negative sampling中,表示样本标签,1:正样本;0:负样本
     * local_iter				训练剩余迭代次数,一共迭代iter次
     */
    long long l1, l2, c, target, label, local_iter = iter;
    unsigned long long next_random = (long long) id;												// 用于生成随机数
    char eof = 0;																					// 训练文件结束符标志
    real f, g;
    clock_t now;
    real *neu1 = (real *) calloc(layer1_size, sizeof(real));										// 用于CBOW模型,表示上下文各词的词向量的加和
    real *neu1e = (real *) calloc(layer1_size, sizeof(real));										// 累加词向量的修正量,用neu1e修正词向量,即词向量 += neu1e
    FILE *fi = fopen(train_file, "rb");																// 打开训练文件
    fseek(fi, file_size / (long long) num_threads * (long long) id, SEEK_SET);						// 定位当前线程开始训练的文件位置
    
    /**
     * 当选择CBOW模型时,用全体上下文词来预测当前词,再反向修正上下文的词的词向量
     * 当选择Skip-gram模型时,用每一个上下文词来预测当前词,再反向修正该上下文词的词向量
     *
     * 每次读取一个句子(句子过长时截断),按句子为单位进行训练
     * 对分配给当前线程的全部句子迭代训练iter次
     */
    while (1) {
        
        /**
         * 每训练10000个词,衰减一次学习率
         */
        if (word_count - last_word_count > 10000) {
            word_count_actual += word_count - last_word_count;										// word_count_actual词频累加,全部线程都累加
            last_word_count = word_count;															// 记录当前训练的词频总数
            if ((debug_mode > 1)) {																	// 输出训练进度
                now = clock();
                printf("%cAlpha: %f  Progress: %.2f%%  Words/thread/sec: %.2fk  ", 13, alpha,
                       word_count_actual / (real) (iter * train_words + 1) * 100,
                       word_count_actual / ((real) (now - start + 1) / (real) CLOCKS_PER_SEC * 1000));
                fflush(stdout);
            }
            
            /**
             * 按训练进度衰减学习率,当衰减到一度程度后不再衰减
             */
            alpha = starting_alpha * (1 - word_count_actual / (real) (iter * train_words + 1));
            if (alpha < starting_alpha * 0.0001) alpha = starting_alpha * 0.0001;
        }
        
        /**
         * 当前句子训练完成,读取下一个句子(每一次训练迭代开始,sentence_length也是为0)
         */
        if (sentence_length == 0) {
            while (1) {																				// 读取句子,直到遇到文件尾、换行符或被截断
                word = ReadWordIndex(fi, &eof);														// 读取一个词,返回词在词库中的索引
                if (eof) break;																		// 遇到文件尾
                if (word == -1) continue;															// 词库中不存在的词,跳过
                word_count++;																		// 词频累加
                if (word == 0) break;																// 遇到换行符'</s>',词库中第一个词是'</s>',即word = 0
                
                // The subsampling randomly discards frequent words while keeping the ranking same
                /**
                 * 进行亚采样时,以一定概率过滤调频词,加速训练,也提高相对低频词的精度
                 */
                if (sample > 0) {
                    real ran = (sqrt(vocab[word].cn / (sample * train_words)) + 1) * (sample * train_words) / vocab[word].cn;
                    next_random = next_random * (unsigned long long) 25214903917 + 11;
                    if (ran < (next_random & 0xFFFF) / (real) 65536) continue;
                }
                sen[sentence_length] = word;														// 将该词加入到句子中
                sentence_length++;																	// 句子长度加1
                if (sentence_length >= MAX_SENTENCE_LENGTH) break;									// 截断超长句子,剩余的被视为下一个句子
            }
            sentence_position = 0;																	// 句子读取完成,设置句子的初始训练位置为第一个词
        }
        
        /**
         * 当前线程遇到文件尾,或者分配给该线程的全部词已完成了一次训练,设置一些初始值后进行下一次迭代训练
         */
        if (eof || (word_count > train_words / num_threads)) {
            word_count_actual += word_count - last_word_count;										// word_count_actual词频累加
            local_iter--;																			// 剩余训练次数减1
            if (local_iter == 0) break;																// 没有训练次数了,已经训练完成
            word_count = 0;																			// 每次迭代开始,当前线程训练的词频总数设置为0
            last_word_count = 0;																	// 每次迭代开始,上一次记录的已经训练的词频总数设置为0
            sentence_length = 0;																	// 每次迭代开始,句子长度设置为0
            fseek(fi, file_size / (long long) num_threads * (long long) id, SEEK_SET);				// 每次迭代开始,重新定位当前线程开始训练的文件位置
            continue;
        }
        
        word = sen[sentence_position];																// word为当前词
        if (word == -1) continue;
        for (c = 0; c < layer1_size; c++) neu1[c] = 0;												// CBOW模型中,重置上下文词向量加和向量为0
        for (c = 0; c < layer1_size; c++) neu1e[c] = 0;												// 重置词向量的修正量为0
        next_random = next_random * (unsigned long long) 25214903917 + 11;
        b = next_random % window;																	// 生成动态上下文窗口大小,动态窗口在句子中的范围是sen[sentence_position - window + b, sentence_position + window - b],注意可能会超出句子范围
        
        /**
         * CBOW模型,用全体上下文词来预测当前词,再反向修正上下文的词的词向量
         */
        if (cbow) {  //train the cbow architecture
            // in -> hidden
            cw = 0;																					// 上下文窗口内的词数,不计当前词
            for (a = b; a < window * 2 + 1 - b; a++)												// 累加上下文词的词向量
                if (a != window) {																	// 不计当前词
                    c = sentence_position - window + a;												// c用于定位上下文词在句子中的真实位置,sentence_position位置是当前词
                    if (c < 0) continue;															// 上下文窗口超出句子范围
                    if (c >= sentence_length) continue;												// 上下文窗口超出句子范围
                    last_word = sen[c];																// last_word表示上下文词
                    if (last_word == -1) continue;
                    for (c = 0; c < layer1_size; c++) neu1[c] += syn0[c + last_word * layer1_size];	// 上下文各词的词向量加和
                    cw++;																			// 上下文窗口内的词数加1
                }
            if (cw) {																				// 当前词有上下文,有上下文时才进行训练
                for (c = 0; c < layer1_size; c++) neu1[c] /= cw;									// 将上下文各词词向量加和的和向量平均化
                
                /**
                 * hierarchical softmax,用哈夫曼树进行训练
                 * 遍历当前词的哈夫曼路径,在每个非叶子节点进行一次训练,预测一次子节点,其实就是对子节点做一次二分类,累加每次预测的误差
                 */
                if (hs)
                    for (d = 0; d < vocab[word].codelen; d++) {										// 从根节点开始,遍历路径上的每一个非叶子节点,注意是非叶子节点,根据父节点对子节点进行预测
                        f = 0;
                        l2 = vocab[word].point[d] * layer1_size;									// l2用于定位当前节点的辅助向量在syn1的开始位置
                        // Propagate hidden -> output
                        for (c = 0; c < layer1_size; c++) f += neu1[c] * syn1[c + l2];				// 将上下文词的加和向量(已被平均) 与 当前节点的辅助向量 做内积
                        
                        /**
                         * 从缓存中读sigmod值,可以理解成预测子节点的类别,word2vec中0表示正类,1表示负类,注意在以下代码中,当f值超出范围时,不对该节点进行预测,直接跳过该节点
                         */
                        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
                        /**
                         * f									表示根据当前节点对其子节点进行分类时得到的子节点的分类标签
                         * 1 - vocab[word].code[d]				表示在哈夫曼树中当前节点的子节点的真实分类标签(哈夫曼树中,编码0表示正类,编码1表示负类,(1 - 编码)即为分类标签)
                         * 1 - vocab[word].code[d] - f			即真实分类标签与预测分类标签之间的差值
                         */
                        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];				// 更新当前节点的辅助向量
                    }
                    
                // NEGATIVE SAMPLING
                /**
                 * negative sampling,用负采样进行训练
                 * negative也表示也采样次数,即采样negative个负样本,这样就收集了一个正样本和negative个负样本,在每个样本上进行一次训练
                 */
                if (negative > 0)
                    for (d = 0; d < negative + 1; d++) {											// 在每一个样本上进行一次训练,累计训练误差
                        if (d == 0) {
                            target = word;															// 当前词为正样本
                            label = 1;																// 正样本标签为1
                        } else {
                            next_random = next_random * (unsigned long long) 25214903917 + 11;
                            target = table[(next_random >> 16) % table_size];						// 负采样
                            if (target == 0) target = next_random % (vocab_size - 1) + 1;			// 采样到换行符,再采样一次
                            if (target == word) continue;											// 采样到当前词,跳过,训练下一个样本
                            label = 0;																// 负样本标签为0
                        }
                        l2 = target * layer1_size;													// l2用于定位样本辅助向量在syn1neg的开始位置
                        f = 0;
                        for (c = 0; c < layer1_size; c++) f += neu1[c] * syn1neg[c + l2];			// 将上下文词的加和向量(已被平均) 与 负样本辅助向量 做内积
                        
                        /**
                         * 从缓存中读sigmod值,可以理解成预测负样本的标签,label为样本的真实分类,1,0,expTable(x)为预测分类,相减等到分类误差
                         */
                        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];			// 更新负样本的辅助向量
                    }
                    
                // 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];
                    }
            }
        }
        
        /**
         * Skip-gram模型,用每一个上下文词来预测当前词,再反向修正该上下文词的词向量
         */
        else {  //train skip-gram
            for (a = b; a < window * 2 + 1 - b; a++)												// 遍历上下文中的每个词,做一次训练(当前词不用做训练)
                if (a != window) {																	// 跳过当前词
                    c = sentence_position - window + a;												// c用于定位上下文词在句子中的真实位置,sentence_position位置是当前词
                    if (c < 0) continue;															// 上下文窗口超出句子范围
                    if (c >= sentence_length) continue;												// 上下文窗口超出句子范围
                    last_word = sen[c];																// last_word表示上下文词
                    if (last_word == -1) continue;
                    l1 = last_word * layer1_size;													// l1用于定位上下文词的词向量在syn0中的起始位置
                    for (c = 0; c < layer1_size; c++) neu1e[c] = 0;									// 重置词向量的修正量为0

                    // HIERARCHICAL SOFTMAX
                    /**
                     * hierarchical softmax,用哈夫曼树进行训练
                     * 遍历当前词的哈夫曼路径,在每个非叶子节点进行一次训练,预测一次子节点,其实就是对子节点做一次二分类,累加每次预测的误差
                     */
                    if (hs)
                        for (d = 0; d < vocab[word].codelen; d++) {									// 从根节点开始,遍历路径上的每一个非叶子节点,注意是非叶子节点,根据父节点对子节点进行预测
                            f = 0;
                            l2 = vocab[word].point[d] * layer1_size;								// l2用于定位当前节点的辅助向量在syn1的开始位置

                            // Propagate hidden -> output
                            for (c = 0; c < layer1_size; c++) f += syn0[c + l1] * syn1[c + l2];		// 将该上下文词词向量 与 当前节点辅助向量 做内积

                            /**
                             * 从缓存中读sigmod值,可以理解成预测子节点的类别,word2vec中0表示正类,1表示负类,注意在以下代码中,当f值超出范围时,不对该节点进行预测,直接跳过该节点
                             */
                            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
                            /**
                             * f								表示根据当前节点对其子节点进行分类时得到的子节点的分类标签
                             * 1 - vocab[word].code[d]			表示在哈夫曼树中当前节点的子节点的真实分类标签(哈夫曼树中,编码0表示正类,编码1表示负类,(1 - 编码)即为分类标签)
                             * 1 - vocab[word].code[d] - f		即真实分类标签与预测分类标签之间的差值
                             */
                            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];		// 更新当前节点的辅助向量
                        }

                    // NEGATIVE SAMPLING
                    /**
                     * negative sampling,用负采样进行训练
                     * negative也表示也采样次数,即采样negative个负样本,这样就收集了一个正样本和negative个负样本,在每个样本上进行一次训练
                     */
                    if (negative > 0)
                        for (d = 0; d < negative + 1; d++) {										// 在每一个样本上进行一次训练,累计训练误差
                            if (d == 0) {
                                target = word;														// 当前词为正样本
                                label = 1;															// 正样本标签为1
                            } else {
                                next_random = next_random * (unsigned long long) 25214903917 + 11;
                                target = table[(next_random >> 16) % table_size];					// 负采样
                                if (target == 0) target = next_random % (vocab_size - 1) + 1;		// 采样到换行符,再采样一次
                                if (target == word) continue;										// 采样到当前词,跳过
                                label = 0;															// 负样本标签为0
                            }
                            l2 = target * layer1_size;												// l2用于定位样本辅助向量在syn1neg的开始位置
                            f = 0;
                            for (c = 0; c < layer1_size; c++) f += syn0[c + l1] * syn1neg[c + l2];	// 将该上下文词词的词向量 与 负样本辅助向量 做内积

                            /**
                             * 从缓存中读sigmod值,可以理解成预测负样本的标签,label为样本的真实分类,1,0,expTable(x)为预测分类,相减等到分类误差
                             */
                            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 * syn0[c + l1];	// 更新负样本的辅助向量
                        }
                    // Learn weights input -> hidden
                    for (c = 0; c < layer1_size; c++) syn0[c + l1] += neu1e[c];						// 将词向量的修正量更新到该上下文词的词向量中
                }
        }
        sentence_position++;																		// 训练句子的下一个词
        if (sentence_position >= sentence_length) {													// 当前句子训练完成,训练下一个句子
            sentence_length = 0;
            continue;
        }
    }
    fclose(fi);
    free(neu1);
    free(neu1e);
    pthread_exit(NULL);
}

/**
 * 训练模型,多线程训练
 * 
 * 步骤如下:
 * 1、从词库文件或语料文件中构建词库,并根据参数是否保存词库
 * 2、调用初始化函数初始化训练参数
 * 3、若使用negative sampling,初始化负采样表
 * 4、创建num_threads个训练线程,启动并等待全部线程训练结束(注意:在多线程训练时,训练参数是线程共享的)
 * 5、输出词向量
 */
void TrainModel() {
    long a, b, c, d;
    FILE *fo;
    pthread_t *pt = (pthread_t *) malloc(num_threads * sizeof(pthread_t));							// 修建一个线程数组,大小为num_threads
    printf("Starting training using file %s\n", train_file);
    starting_alpha = alpha;																			// 记录初始学习率,学习率会随着训练进度衰减,衰减到一定程度后不再衰减
    if (read_vocab_file[0] != 0) ReadVocab(); else LearnVocabFromTrainFile();						// 若提供了词库文件,则从词库文件中构建词库,否则从训练语料文件中构建词库
    if (save_vocab_file[0] != 0) SaveVocab();														// 若提供词库输出文件,保存词库到文件
    if (output_file[0] == 0) return;																// 未设置词向量输出文件,直接退出
    InitNet();																						// 训练参数初始化,词向量、辅助向量、负样本向量、哈夫曼树
    if (negative > 0) InitUnigramTable();															// 若使用negative sampling,初始化负采样表
    start = clock();																				// 记录CPU时间,用于输出训练进度

    for (a = 0; a < num_threads; a++) pthread_create(&pt[a], NULL, TrainModelThread, (void *) a);	// 创建num_threads个训练线程,线程执行函数的参数是线程编号(不是线程号),用于逻辑分割语料文件
    for (a = 0; a < num_threads; a++) pthread_join(pt[a], NULL);									// 开始训练并等待每个线程结束,全部线程结束即训练完成

    /**
     * 全部线程训练完成后,将训练好的词向量输出到output_file文件
     * 输出由classes和binary两个参数共同控制,classes表示是否进行聚类输出,binary表示在不聚类输出时,以二进制输出还是文本输出
     */
    fo = fopen(output_file, "wb");																	// 打开词向量输出文件
    if (classes == 0) {																				// 不聚类输出
        // Save the word vectors
        fprintf(fo, "%lld %lld\n", vocab_size, layer1_size);
        for (a = 0; a < vocab_size; a++) {															// 遍历词库的每个词,将词的词向量输出到文件
            fprintf(fo, "%s ", vocab[a].word);

            /**
             * 输出每个词向量每个维度值
             * binary用于判断以是否以二进制输出,1:二进制输出,0:文本输出
             */
            if (binary) for (b = 0; b < layer1_size; b++) fwrite(&syn0[a * layer1_size + b], sizeof(real), 1, fo);
            else for (b = 0; b < layer1_size; b++) fprintf(fo, "%lf ", syn0[a * layer1_size + b]);
            fprintf(fo, "\n");
        }
    } else {																						// 先按K均值聚类后,再输出词向量,classes:表示聚类个数,这时不输出词向量,而是输出聚类类别
        // Run K-means on the word vectors
        int clcn = classes, iter = 10, closeid;														// clcn:聚类个数,iter:迭代次数,closeid:表示某个词最近的类别编号
        int *centcn = (int *) malloc(classes * sizeof(int));										// 存储每个类别的词个数,一维数组,大小为classes
        int *cl = (int *) calloc(vocab_size, sizeof(int));											// 每个词对应的类别编号,一维数组,大小为vocab_size
        real closev, x;																				// x:词向量和聚类中心的内积(余弦距离),值越大说明距离越近;closev:最大的内积,这时距离最近
        real *cent = (real *) calloc(classes * layer1_size, sizeof(real));							// 每个类别的聚类中心,一维数组,第i个类的中心为cent[i * layer1_size, (i + 1) * layer1_size - 1]
        for (a = 0; a < vocab_size; a++) cl[a] = a % clcn;											// 初始化每个词的类编号,即初始聚类(这跟打牌时的发牌过程很相似哦)
        for (a = 0; a < iter; a++) {																// 迭代iter次,每次迭代重新进行一次聚类,并计算新的聚类中心
            for (b = 0; b < clcn * layer1_size; b++) cent[b] = 0;									// 每次迭代开始,设置每个聚类中心为0
            for (b = 0; b < clcn; b++) centcn[b] = 1;												// 每次迭代开始,设置每个类别的词汇个数为1

            /**
             * 重新计算每个类别的聚类中心和词汇个数,这个“聚类中心”并不是真正的中心,这一阶段是累加“中心”的每个分量,将“聚类中心”的每个分量除以类别中词汇个数才是真正的中心
             */
            for (c = 0; c < vocab_size; c++) {
                for (d = 0; d < layer1_size; d++) cent[layer1_size * cl[c] + d] += syn0[c * layer1_size + d];
                centcn[cl[c]]++;
            }


            /**
             * 将上面累加的“聚类中心”除以各个类别的词汇个数,得到真正的聚类中心,并将中心向量归一化
             */
            for (b = 0; b < clcn; b++) {
                closev = 0;
                for (c = 0; c < layer1_size; c++) {
                    cent[layer1_size * b + c] /= centcn[b];											// “聚类中心”的每个分量除以词汇个数得到真正的聚类中心
                    closev += cent[layer1_size * b + c] * cent[layer1_size * b + c];				// 累加聚类中心每个分量的平方,用于计算聚类中心向量的长度
                }
                closev = sqrt(closev);																// 计算计算聚类中心向量的长度
                for (c = 0; c < layer1_size; c++) cent[layer1_size * b + c] /= closev;				// 聚类中心向量归一化,方便计算余弦距离
            }

            /**
             * 计算每个词向量到每个聚类中心的内积(余弦距离),内积最大表示该词向量到该聚类中心最近,因此更新该词向量的聚类类别
             */
            for (c = 0; c < vocab_size; c++) {
                closev = -10;
                closeid = 0;
                for (d = 0; d < clcn; d++) {
                    x = 0;
                    for (b = 0; b < layer1_size; b++) x += cent[layer1_size * d + b] * syn0[c * layer1_size + b];
                    if (x > closev) {																// 找最近的聚类中心
                        closev = x;
                        closeid = d;
                    }
                }
                cl[c] = closeid;																	// 重新计算词的最近聚类中心后, 更新词向量的聚类类别
            }
        }
        // Save the K-means classes
        /**
         * 以文本格式保存词向量和聚类类别
         */
        for (a = 0; a < vocab_size; a++) fprintf(fo, "%s %d\n", vocab[a].word, cl[a]);
        free(centcn);
        free(cent);
        free(cl);
    }
    fclose(fo);
}

int ArgPos(char *str, int argc, char **argv) {
    int a;
    for (a = 1; a < argc; a++)
        if (!strcmp(str, argv[a])) {
            if (a == argc - 1) {
                printf("Argument missing for %s\n", str);
                exit(1);
            }
            return a;
        }
    return -1;
}

int main(int argc, char **argv) {
    int i;
    if (argc == 1) {
        printf("WORD VECTOR estimation toolkit v 0.1c\n\n");
        printf("Options:\n");
        printf("Parameters for training:\n");
        printf("\t-train <file>\n");
        printf("\t\tUse text data from <file> to train the model\n");
        printf("\t-output <file>\n");
        printf("\t\tUse <file> to save the resulting word vectors / word clusters\n");
        printf("\t-size <int>\n");
        printf("\t\tSet size of word vectors; default is 100\n");
        printf("\t-window <int>\n");
        printf("\t\tSet max skip length between words; default is 5\n");
        printf("\t-sample <float>\n");
        printf("\t\tSet threshold for occurrence of words. Those that appear with higher frequency in the training data\n");
        printf("\t\twill be randomly down-sampled; default is 1e-3, useful range is (0, 1e-5)\n");
        printf("\t-hs <int>\n");
        printf("\t\tUse Hierarchical Softmax; default is 0 (not used)\n");
        printf("\t-negative <int>\n");
        printf("\t\tNumber of negative examples; default is 5, common values are 3 - 10 (0 = not used)\n");
        printf("\t-threads <int>\n");
        printf("\t\tUse <int> threads (default 12)\n");
        printf("\t-iter <int>\n");
        printf("\t\tRun more training iterations (default 5)\n");
        printf("\t-min-count <int>\n");
        printf("\t\tThis will discard words that appear less than <int> times; default is 5\n");
        printf("\t-alpha <float>\n");
        printf("\t\tSet the starting learning rate; default is 0.025 for skip-gram and 0.05 for CBOW\n");
        printf("\t-classes <int>\n");
        printf("\t\tOutput word classes rather than word vectors; default number of classes is 0 (vectors are written)\n");
        printf("\t-debug <int>\n");
        printf("\t\tSet the debug mode (default = 2 = more info during training)\n");
        printf("\t-binary <int>\n");
        printf("\t\tSave the resulting vectors in binary moded; default is 0 (off)\n");
        printf("\t-save-vocab <file>\n");
        printf("\t\tThe vocabulary will be saved to <file>\n");
        printf("\t-read-vocab <file>\n");
        printf("\t\tThe vocabulary will be read from <file>, not constructed from the training data\n");
        printf("\t-cbow <int>\n");
        printf("\t\tUse the continuous bag of words model; default is 1 (use 0 for skip-gram model)\n");
        printf("\nExamples:\n");
        printf("./word2vec -train data.txt -output vec.txt -size 200 -window 5 -sample 1e-4 -negative 5 -hs 0 -binary 0 -cbow 1 -iter 3\n\n");
        return 0;
    }
    /**
     * 文件名均空
     */
    output_file[0] = 0;
    save_vocab_file[0] = 0;
    read_vocab_file[0] = 0;
    
    /**
     * 读入参数
     */
    if ((i = ArgPos((char *) "-size", argc, argv)) > 0) layer1_size = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-train", argc, argv)) > 0) strcpy(train_file, argv[i + 1]);
    if ((i = ArgPos((char *) "-save-vocab", argc, argv)) > 0) strcpy(save_vocab_file, argv[i + 1]);
    if ((i = ArgPos((char *) "-read-vocab", argc, argv)) > 0) strcpy(read_vocab_file, argv[i + 1]);
    if ((i = ArgPos((char *) "-debug", argc, argv)) > 0) debug_mode = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-binary", argc, argv)) > 0) binary = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-cbow", argc, argv)) > 0) cbow = atoi(argv[i + 1]);
    if (cbow) alpha = 0.05;																			// 采用CBOW模型时,学习率 = 0.05
    if ((i = ArgPos((char *) "-alpha", argc, argv)) > 0) alpha = atof(argv[i + 1]);
    if ((i = ArgPos((char *) "-output", argc, argv)) > 0) strcpy(output_file, argv[i + 1]);
    if ((i = ArgPos((char *) "-window", argc, argv)) > 0) window = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-sample", argc, argv)) > 0) sample = atof(argv[i + 1]);
    if ((i = ArgPos((char *) "-hs", argc, argv)) > 0) hs = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-negative", argc, argv)) > 0) negative = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-threads", argc, argv)) > 0) num_threads = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-iter", argc, argv)) > 0) iter = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-min-count", argc, argv)) > 0) min_count = atoi(argv[i + 1]);
    if ((i = ArgPos((char *) "-classes", argc, argv)) > 0) classes = atoi(argv[i + 1]);
    vocab = (struct vocab_word *) calloc(vocab_max_size, sizeof(struct vocab_word));
    vocab_hash = (int *) calloc(vocab_hash_size, sizeof(int));
    expTable = (real *) malloc((EXP_TABLE_SIZE + 1) * sizeof(real));
    for (i = 0; i < EXP_TABLE_SIZE; i++) {															// 预处理,提前计算sigmod值,并保存起来
        expTable[i] = exp((i / (real) EXP_TABLE_SIZE * 2 - 1) * MAX_EXP);							// 计算e^x
        expTable[i] = expTable[i] / (expTable[i] + 1);												// f(x) = 1 / (1 + e^(-x)) = e^x / (1 + e^x)
    }
    TrainModel();
    return 0;
}

 

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值