word2vec源码分析及原理讲解(C语言版)

Word2vec是我们常用的产生词向量的工具,这里对c语言版本的word2vec的源码进行了分析,同时对于Hierarchical softmax以及negative sampling的原理进行简单的讲解,具体原理可以看参考资料1-3的内容

目录

参数:

1. 预处理

2. 构建词库

2.1指定词库中读取

2.2 训练语料中构建

3. 初始化网络结构

3.1 初始化参数

3.2 哈夫曼树的建立

3.3 负样本中表的初始化

4. 多线程训练

4.1 读入句子

4.2 模型初始化

4.3 Hierarchical softmax

4.3.1 原理讲解

4.3.2 CBOW代码分析

4.3.3 skip-gram代码分析

4.4 Negative Sampling

4.4.1 原理讲解

4.4.2 CBOW代码分析

4.4.3 skip-gram代码分析


参数:

-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的模型

参考3:word2vec原理(三) 基于Negative Sampling的模型

参考4:机器学习算法实现解析——word2vec源码解析

参考5:word2vec 源代码 完整注释

参考6:word2vec 源码分析word2vec.c

参考7:word2vec原理推导与代码分析

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值