Word2vec是我们常用的产生词向量的工具,这里对c语言版本的word2vec的源码进行了分析,同时对于Hierarchical softmax以及negative sampling的原理进行简单的讲解,具体原理可以看参考资料1-3的内容
目录
参数:
-size:表示训练后词向量的维数
-train:表示训练需要的文本
-save-vocab:表示保存词汇的文件
-read-vocab:表示读入词汇的文件
-debug:表示训练过程中输出内容。大于0,加载完毕后输出汇总信息,大于1,加载训练词汇的时候输出信息,训练过程中输出信息,默认是2
-binary:bin输出为二进制(默认0),1则为文本形式
-cbow:1表示使用CBOW(默认),0表示使用skip-gram
-alpha:算法的学习速率,过程中自动调整,cbow默认是0.05,skip-gram默认是0.025
-output:输出文件
-window:窗口大小,在cbow中表示了word vector的最大的sum范围,在skip-gram中表示了max space between words(w1,w2,p(w1 | w2)
-sample:亚采样概率的参数,亚采样的目的是以一定概率拒绝高频词,使得低频词有更多出镜率,默认1e-3
-hs:是否采用Hierarchical softmax,0表示不采用,1表示采用,默认0不采用
-negative:负采样的数量,默认是5.
-threads:线程数,默认12
-iter:迭代次数,默认5
-min_count:最少出现次数,默认为5。如果一个词出现的次数少于min_count,则抛弃
-classes:输出word cluster的类别数,默认0
1. 预处理
函数Argpos():
判断参数是否丢失,例如出现“-hs”却没有指定hs的值的情况
主函数main():
对参数进行初始化
对全局变量vacab首次申请内存:
对全局变量expTable申请内存
此外实现了sigmoid函数值的近似计算,并且将[-6,6)等距离划分为EXP_TABLE_SIZE份,计算每一份的sigmod值并存入到expTable中。
其中sigmod
全局变量初始学习率starting_alpha = alpha
2. 构建词库
在函数TrainModel()中读入有两种方式。一种是指定词库中读取ReadVocab(),另一种是从词的文本构建LearnVocabFromTrainFile()
2.1指定词库中读取
ReadWord():读入一个单词。其中以空格、tab或者EOL作为分界,其中‘\n’则以‘</s>’替代
源代码为:
首先打开单词所在的文件
初始化hash表,hash表用来存放单词在词典中的索引
从单词文件中读入一个单词
如果文件到达末尾,则结束读入
将读入的单词加入词典,同时得到单词在词典中的索引
将单词文件read_vocab_file中的词频读入到结构体中
将单词根据词频按照从大到小排序
之后输出相关信息
打开训练文本
将文件指针定位到末尾并计算文件的大小
2.2 训练语料中构建
LearnVocabFromTrainFile()构建词库过程
源代码
首先对hash表进行初始化,其中hash表中存放的是单词在词典中的索引
打开训练文本train_file进行构建词库
首先在词典开头添加‘</s>’
将训练文件中的单词读入
在搜索hash中该词在词典中的索引
如果i=-1,说明该词此前没有出现,否则说明该词已经出现,频数+1
如果词汇数目大于hash表大小*填装系数0.7,则对词典进行缩减
之后对词典按照词频从大到小进行排序
最后获取文件的大小
在这两个初始化词表的过程中都利用了一些函数:
AddWordToVocab():将一个词添加到词典中,其中利用hash可以来找到一个词在词典中的位置,通过计算hash值,得到对应hash表中的值就是该词在词典中的位置
SortVocab():首先对词按照词频进行从大到小排序,之后对词频小的进行删除,然后重新计算hash表。
ReduceVocab():如果词典的大小N>0.7*vocab_hash_size,则从词典中删除所有词频小于min_reduce的词。
GetWordHash():得到word在hash表中的位置。
SearchVocab():查找word在hash表中的索引,如果存在则返回该索引值,否则返回-1
3. 初始化网络结构
3.1 初始化参数
首先通过posix_memalign()内置函数获取地址对齐的词向量等地址对齐的空间,这样能够快速找到单词的向量。syn0中存储的是词向量。
如果采用Hierarchical softmax,将存储哈夫曼树的非叶子节点的syn1利用posix_memalign()得到地址对齐的空间,方便在之后对非叶子节点的向量进行定位。并且对这些非叶子结点进行置零。
如果采用的是negative sampling,利用posix_memalign()函数得到地址对齐的动态内存,同时对syn1neg(存储负采样是每个词的辅助向量)初始化为0
对于词向量的初始化,首先是生成一个很大的数字,然后通过)0xFFFF进行截断,之后再除以65535得到[0,1]之间的数,最终得到[-0.5/m,0.5/m]的数,其中m是词向量的维数
3.2 哈夫曼树的建立
哈夫曼树建立过程如图所示:
得到最终的哈夫曼树为:
其中count、binary以及parent_node数组为:
代码部分:
在层次softmax中需要用到哈夫曼树以及哈夫曼编码,因此在初始化网络的时候也需要对哈夫曼树进行建立。首先是定义了3个长度为vocab_size*2+1的数组:
count表示构建哈夫曼树时各个节点的权重(频数)
binary表示当前节点的编码(0/1),初始化全都为0
parent_node表示当前节点的父亲节点所在的位置,初始化全都为0
count中前vocab_size初始化为单词的词频,其中按照从大到小排列,后面初始化成很大的数字。
初始化当前构建哈夫曼树的pos
对节点进行循环
当前pos1没有用完叶子节点
如果pos1<pos2,则min1i=pos1,pos1向前移动,否则min1i=pos2,pos2向后移动
当前pos1用完叶子节点,则min1i=pos2,pos2向后移动
min2i节点和min1i也是如此。
得到count[vocab_size+a]非叶子节点
得到非叶子节点左右孩子的父节点
将这两个孩子节点中频数大的编码为1,同时编码为1在之后的计算中作为负类
对节点构建完哈夫曼树之后,对每个单词找到其对应的编码以及根节点到叶子节点的路径
首先利用b得到当前节点的编码,利用局部变量point保存当前节点的位置。b得到当前节点的父亲节点,重复得到父亲节点的编码和位置直到根节点。
得到该单词的编码长度(比point少1),point[0]中存储根节点的相对位置(方便在syn1中找到对应的向量)
在code中得到的编码反过来就是该单词对应的哈夫曼编码,路径反过来就是对应从根节点到叶子节点的路径,其中根节点的路径已经得到。
得到对应的反过来的哈夫曼编码
得到对应的路径,其中路径最后一个是负数,对应叶子结点的位置,但是在训练中并不需要叶子节点在syn1中的位置,用不到
得到所有信息后,将count、parent_node、binary的内存释放
问题:对于从根节点到叶子结点的路径如何解释?
将parent_node删除之后,在接下来的过程中如何进行更新迭代?因为删除之后,哈夫曼树就不存在了????
从根节点到叶子结点的路径的向量值存放在syn1中,因此根节点为vocab_size-2,其他中间结点为point[b]-vocab_size
3.3 负样本中表的初始化
其中,单词是按照词频从大到小进行排列的。
对全局变量table申请空间
得到训练词的词频的0.75次方
首先获取第一个词的比例
对于负采样表的每一段赋予落在其中单词在词典中的位置
当前段赋予词的位置
如果当前段数/总段数>d1,即大于该词所占比例时
进行下一个词,并且重新赋予d1值
如果到达最后单词,则剩余段均为该单词。
4. 多线程训练
多线程处理的过程是,首先将文本分成thread个,每个部分进行单独训练。之后读入句子,句子中存储的是单词在词典中的位置,然后如果有sample的约定的话,则对词进行随机的删除。读入句子之后,判断是否到文件末尾或者处理词超过该线程处理的词,如果超过或者到达文件末尾,进行下一轮迭代。之后根据选择的方法进行训练。
4.1 读入句子
首先找到该部分语料在文本中的位置
之后利用该线程id辅助随机数的生成
为局部变量neu1以及nue1e申请内存,其中neu1表示模型的输入向量,neu1e表示误差累计向,用于更新syn0中的词向量
循环进行处理
当前线程处理单词书超过10000时,输出相关信息并且对学习效率进行更新
更新学习效率并且随着实际训练次数的上升逐渐降低学习效率,设置最低值为0.0001
如果当前句子长度为0,则读入句子
循环读入单词,直到到达句子末尾或者 达到句子最大长度
读入单词首先找到该单词在词典中的位置
如果sample>0,则随机丢弃单词,其中频率高的词丢弃的概率大
将未丢弃的单词的在词典中的位置写入句子中
将sentence_position置于句子开头
如果到达文件末尾或者超过该线程处理的单词数
该线程循环数-1
如果循环完,则跳出该处理过程
否则重新定位到该线程处理语料的位置
问题:既然多线程训练,如果一个词在不同的线程中训练,最后得到的词向量如何进行选择,不同线程中得到的词向量是不同的。
解答:不同的线程运行模型,但是词向量是全局变量,因此在一个线程中修改的话,另一个线程中也会改变。?存疑
在实现多线程的过程中,作者并没有加锁的操作,而是对模型参数和词向量的修改可以任意执行,这一点类似于基于随机梯度的方法,训练的过程与训练样本的训练是没有关系的,这样可以大大加快对词向量的训练。抛开多线程的部分,在每一个线程内执行的是对模型和词向量的训练。----来自一个博主的解释
4.2 模型初始化
模型训练,一次针对一个词进行训练。
得到句子当前处理单词在词典中的位置
对局部变量neu1(输入模型的词向量)以及局部变量neu1e(误差累计项)进行清零
初始化随机数字
设置该次处理单词的窗口大小([0,window))
neu1表示的是模型的输入词向量。
neu1e误差累计向量。
syn0表示的是原始词向量。
syn1 表示: hs(hierarchical softmax)算法中霍夫曼编码树非叶结点的权重。
syn1neg 表示: ns(negative sampling)负采样时,存储每个词对应的辅助向量。
模型训练有CBOW模型(连续词袋模型)以及skip-gram模型,需要hierarchical softmax或者negative sampling来进行训练。
4.3 Hierarchical softmax
4.3.1 原理讲解
Hierarchical softmax(hs)首先根据单词出现的频率建立哈夫曼树,之后计算softmax概率从哈夫曼树的树根走到叶子节点。
根据sigmod函数来计算每个结点向左子树还是右子树走,其中构建哈夫曼树是词频高的词编码为1(表示负类),词频低的词编码为0(表示正类)
其中sigmod函数为:
P(+) = σ,P(-)=1-P(+)
因此,经过一个节点j时,逻辑回归概率为:
其中表示该节点的参数,表示输入词向量。
利用最大似然法来求解词向量和所有的参数θ,则对于一个单词w最大似然为:
其中nw表示从根节点到叶子节点的节点数
其中对函数取对数方便计算
对参数的更新公式为:
对词向量的更新公式为:
具体基于hierarchical softmax可以看博客https://www.cnblogs.com/pinard/p/7243513.html
4.3.2 CBOW代码分析
CBOW模型是根据上下文来预测中心词。
对窗口内的上下文词向量进行累计求和
首先得到输入词向量,即中心词周围窗口词的平均值,得到输入模型的词向量
之后从根节点到叶子结点,其中vocab中存储着从根节点到叶子节点的编码
计算f=σ(Xθ),其中f可以通过提前计算好的表来获取sigmod函数值以及syn1中存储的路径节点的向量起始位置l2。
计算g=(1-d-f)*alpha
计算e=e+g*θ:
更新θ=θ+g*x
在更新完所有θ之后,对上下文词向量进行更新
4.3.3 skip-gram代码分析
原理与CBOW的相同,迭代更新的公式也是相同的。但是skip-gram的输入是中心词向量而输出的是上下文词向量。如果我们期望P(Xi|Xw)最大,i=1……2c,根据上下文是相互的,因此也是期望P(Xw|Xi)最大,因此在进行更新的时候,更新的是输出的上下文词向量。其中具体的讲解可以看博客:https://www.cnblogs.com/pinard/p/7243513.html
对于窗口内的上下文词向量都进行处理:
得到当前的上下文单词在句子中的位置
得到该上下文单词在词典中的位置
得到该单词向量在syn0中的起始地址
从根节点到叶子节点的每个中间节点都进行处理:
得到路径节点在syn1中的向量起始位置
计算当前窗口词与该节点的f。其中syn0 存储的是单词的词向量(即输入词向量),syn1存储的是哈夫曼树中间节点的词向量
计算g=(1-d-f)*alpha
计算e=e+g*θ
更新θ=θ+g*xi
更新xi=xi+e
4.4 Negative Sampling
4.4.1 原理讲解
将Hierarchical softmax中的哈夫曼树替换成采样。采样的方法是将等分成M份的线段根据频率分成成V份,其中M>>V,V表示词汇表的数目。评率高的词占有的份数就多,频率低的词占有的份数就少。具体切分规则:
算法的输入是一个中心词作为正例、neg个负采样的词作为负例以及输入的上下文context(w0),则算法的正例为:
算法负例为:
最大化算法似然概率:
其对数为:
参数的梯度为:
W的梯度为:
具体对应的理论内容可以看博客:https://www.cnblogs.com/pinard/p/7249903.html
4.4.2 CBOW代码分析
对负样本表的初始化详细看3.3
共有n+1个点,其中有n个负样例,1个正例
如果是第一个的话,记下当前中心词的在词典中的索引,并且标记为正例
否则,就随机抽样一个词,并标记为负例
找到当前参数的词向量位置(即在syn1neg中的坐标)
计算f,其中f=σ(w0*θ)
计算g,其中g=(yi-f)*alpha,如果f>6,则label-1,如果f<-6,则label-0
更新e=e+g*θ
更新θ=θ+g*W0
之后对窗口中的上下文进行更新,即wi=wi+e
4.4.3 skip-gram代码分析
其中原理与CBOW中的原理相同,不同的是更新不是对输入的中心词更新,而是对输出的上下文词进行更新,其中原因与skip-gram中的Hierarchical softmax相同。
对W0的上下文个词都进行相同的操作:
得到当前的上下文单词在句子中的位置
得到该上下文单词在词典中的位置
得到该单词向量在syn0中的起始地址
对每个样例词:
如果是第一个的话,记下当前中心词的在词典中的索引,并且标记为正例
否则,就随机抽样一个词,并标记为负例
找到当前参数的词向量起始位置(即在syn1neg中的坐标)
计算f,其中f=σ(wc*θ)
计算g,其中g=(yi-f)*alpha,如果f>6,则label-1,如果f<-6,则label-0
更新e=e+g*θ
更新θ=θ+g*W0
之后对窗口中的上下文进行更新,即wi=wi+e
以上完成对C语言版word2vec的分析
(完成的主要是对代码整体的分析,对其中的细节可以查看源代码)
参考资料:
参考1:word2vec原理(一) CBOW与Skip-Gram模型基础
参考2:word2vec原理(二) 基于Hierarchical Softmax的模型