[ml] word2vec模型和源码解析

Aug 5, 2016   #nlp  #network 

词向量

一种简单直观的词表示方法是One-hot编码,用N位对N个词编码,每个词对应的N维向量中,只有一维为0。这种方式的缺陷是词汇鸿沟,即词与词之间相互孤立,忽略了它们之间的联系。而word2vec中的词向量是一种distributed representation,它的特点利用距离刻画词之间的相似性

统计语言模型

统计语言模型是用来计算一个句子的概率的概率模型,经典的有HAL,LSA,COALS等。通常它基于一个语料库来构建。

句子的概率

给定一个由T个词\(w_1,w_2,…,w_T\)按顺序构成的句子,根据Bayes,计算该句子的概率p(W)

$$p(W) = p(w_1^T) = p(w_1,w_2,…,w_T) = p(w_1)*p(w_2|w_1)*p(w_3|(w_1^2))…p(w_T|(w_1^{T-1}))$$

其中等式中的各个因子(条件概率)就是语言模型的参数,常用的语言模型都是在近似求p(W)。如果给定一个词典大小为N的语料库,考虑长度为T的任意句子,理论上有\(N^T\)种可能组合,而每个句子需要计算T个参数,总共需要计算\(T*N^T\)个参数。无论是计算或保存,都需要很大开销。

n-gram模型

针对参数繁多,该模型做了一个简化的假定,即一个词出现的概率只与它前面的n-1个词相关。实际中最多采用n=3的三元模型。

$$(p(w_k|w^{k-1}_1) = p(w_k|w^{k-1}_{k-n+1}) = \frac{count(w_{k-n+1}^k)}{count(w_{k-n+1}^{k-1})}$$

如果过程中出现\(count(w_{k-n+1}^k)\) = \(count(w_{k-n+1}^{k-1})\) 或者 \(count(w_{k-n+1}^{k-1}) = 0\),则需要做平滑处理

n-gram模型的缺陷是无法建模更远的关系,语料的不足使得无法训练更高阶的语言模型;而且无法建模出词之间的相似度。

最大似然

对句子模型的另一种转化是利用机器学习,构造一个目标函数,并最大似然该函数。优化目标求得最优参数,利用最优参数预测未知句子的概率。目标函数一般为: $$\prod_{w \in C} p(w|Context(w))$$

其中C表示语料集,Context(w)表示词w的上下文,即w周边词的集合。实际中采用最大对数似然,即\(\sum\limits_{w \in C} log p(w|Context(w))\)最大化。增加log是为了将乘法运算转换为加法运算(便于求导的常用方法)。问题关键在于如何构造F函数。

$$p(w|Context(w)) = F(w,Context(w),\theta)$$

神经概率语言模型

Bengio(03) - 《A Neural Probabilistic Language Model》

神经网络包含四层:输入层投影层隐藏层输出层。W,U为权值矩阵,p,q为偏置向量。

二元对(Context(w),w)作为一个训练样本,其中Context(w)取前面n-1个词,每个词\(v_i\)用一个m维向量表示。投影层是一个(n-1)*m长度的向量,输出层的规模为词库中词表的长度D。

选用双曲正切函数作为隐藏层的激活函数,计算过程为:

$$z_w = tanh(Wx_w + p), y_w = Uz_w + q$$

输出为一个长度为D的向量\(y_w = (y_{w,1}, y_{w,2},…,y_{w,D})^T\),通过softmax归一化,每个分量表示上下文为Context(w)时下一词为词典第i个词的概率。

待确定的参数集\(\Theta\)包括:词向量和填充向量,神经网络参数W,U,p,q。其中大部分计算集中隐藏层和输出层之间的矩阵向量运算softmax归一化上。

关于softmax归一化: 形式为:$$ P(y=i) = \frac{exp(\sum_d w_{id}x_{d})}{\sum_j exp(\sum_d w_{jd} x_{d})} $$ 主要是针对多分类问题,归一化输出概率值。

Word2vec核心

word2vec中用到了两个重要模型: CBOWSkip-gram,对于这两个模型,分别都给出了基于Hierarchical SoftmaxNegative Sampling两套框架的实现。而word2vec的高效具体体现在这两个策略上。

CBOW和Skip-gram包括三层:输入层投影层输出层。前者是已知词的上下文预测当前词,后者是通过当前词预测上下文。

1. CBOW

CBOW = Continuous Bag-of-Words, 目标函数为:

$$\sum_{w \in c} log p(w|Context(w)) = \sum_{t=1}^T log p(w_t|\tau(w_{t-k},w_{t-k-1},…,w_{t+k-1},w_{t+k}))$$

其中T表示整个词典的大小,模型的目标是最大化这个目标函数值。\(w_t\)表示词典中的一个词,即通过和\(w_t\)相邻的窗口大小为k的词来预测\(w_t\)出现的概率。其中\(\tau(w_1,w_2,…,w_k)\)函数表示以\(w_i(i<=i<=k)\)为参数进行某种运算。在Word2vec里,这种运算是向量加和运算。即把窗口相邻的所有词的向量加和

Hierarchical Softmax

相比于之前的神经概率语言模型,主要有三处改动去掉隐藏层;首尾拼接改为累加求和;输出为树结构。

问题1:huffman树的应用原理,及hierarchical softmax到底指什么?

hs可以保证叶节点输出的概率值是归一化的。将二叉树中的每个中间结点看作一个隐含的二分类器,一个词的huffman编码就是多个隐含二分类器作用的结果。最后输出层得到的是在当前上下文(输入层)的前提下,各个词的概率,softmax是指这些概率之和为1,即归一化。证明如下:当树的高度为2,即根节点连接两个叶子节点,设左叶子节点的概率为p,则右叶子节点概率为1-p。它们的概率和为1;当树的高度为k时,从根到叶子节点的一个路径可以看成k个独立事件的一种发生可能,所有的叶子节点刚好包括了全部可能情况,概率总和为1。

问题2:简单描述一下模型流程?

根据上图,首先从训练集中选取一个sentence,对于其中的一个词w,计算其上下文词向量的加和,得到\(X_w\)向量(长度为词向量的维度),即投影层。对于huffman树中的每个非叶子节点,添加一个辅助向量\(\theta\),\(X_w\)和\(\theta\)层层作用后,可以得到每个叶子节点的概率。计算输出概率与真实值的残差,然后用随机梯度优化参数,使残差尽可能小。在模型训练过程中,可以得到最优参数下的词向量集合。这些词向量就是word2vec。

================================================================

关于模型的推导:

结点的带权路径长度: 从根结点到该结点的路径长度乘以该结点的权值

树的带权路径长度: 所有叶结点的带权路径长度之和

Huffman树是在给定n个叶子结点和其对应权值的前提下,构造一棵二叉树,使该树的带权路径长度最小。已知语料库中各词的词频,可以构建一棵二叉树,每个词作为树的叶子节点,并保证词频越大的词离根节点越近。每个叶子对应的huffman编码就是该词的编码。约定左子结点编码为0,右子结点编码为1。

此外为每个非叶子结点设置一个辅助向量\(\theta\)。

对于词w,从根结点到该叶结点的路径中,假设有\(l^w\)层,经历了\(l^w-1\)次二分类,则条件概率\( p(w|Context(w)) = \prod\limits_{j=2}^{l^w} p(d_j^w|X_w,\theta_{j-1}^w) \), \(d_j^w\)表示第j个结点对应的编码,\(\theta_j^w\)表示第j个结点对应的辅助向量。

第一步,目标函数对数化: $$F = \sum_{w \in C} log p(w|Context(w)) = \sum_{w \in C} log \prod\limits_{j=2}^{l^w} \sigma(\theta_{j-1}^w X_w)^{1-{d_j^w}} (1-\sigma(\theta_{j-1}^w X_w))^{d_j^w} $$

第二步,取内部项F(w,u):

$$ F(w,u) = (1-{d_j^w}) log(\sigma(\theta_{j-1}^w X_w)) + {d_j^w} log(1-\sigma(\theta_{j-1}^w X_w))$$

第三步,随机梯度上升:

\(\theta_{j-1}^w\)的更新公式为:

$$ \frac{\partial F(w,u)}{\partial \theta_{j-1}^w} = (1 - d_j^w - \sigma(X_w^T*\theta_{j-1}^w)) X_w$$

$$ \theta_{j-1}^w := \theta_{j-1}^w + \eta (1 - d_j^w - \sigma(X_w^T*\theta_{j-1}^w)) X_w $$

\(X_w\)和\(\theta_{j-1}^w\)具有对称性,更新公式类似,这里注意更新的\(v(\tilde{w})\)而非加和后的\(X_w\):

$$v(\tilde{w}) := v(\tilde{w}) + \eta \sum\limits_{j=2}^{l^w}(1 - d_j^w - \sigma(X_w^T*\theta_{j-1}^w)) \theta_{j-1}^w, \tilde{w} \in Context(w)$$

================================================================

Negative Sampling

huffman树中从根节点到叶子节点路径上的各个中间结点,分为正负两类,构造好huffman树后,每个词w对应的采样样本(中间结点)也就确定。训练的目标是最大化似然函数值。由此衍生出,用简单的随机负采样代替复杂的huffman树。已知词w的上下文,预测词w,可将w看作正样本,负样本的抽样策略为带权采样,即被选中的概率与词频成正比。模型训练的目标最大化正样本的概率同时最小化负样本的概率

设NEG(w)是关于词w的一个负样本集,对已一个给定的二元对(Context(w),w),优化目标

$$ g(w) = \prod\limits_{u \in w \cup NEG(w)} p(u|Context(w))$$

=================================================================

关于模型的推导:

为每个词设置一个辅助向量\(\theta^u\),\(X_w^T\)表示为Context(w)中的k个词进行加和后的向量,\(L^w(u)\)表示词u的样本标签,正样本取1,反之取0。

第一步,转换目标函数:

$$g(w) = \prod\limits_{u \in w \cup NEG(w)} [\sigma(\theta^u X_w^T)]^{L^w(u)} [1 - \sigma(\theta^u X_w^T)] ^ {(1-L^w(u))}$$

因为正样本只有一个,故可简化为\(g(w) =\sigma(\theta^u X_w^T) \prod\limits_{u \cup NEG(w)} [1 - \sigma(\theta^u X_w^T)] ^ {1-L^w(u)}\)

第二步,考虑整个语料库:

$$F = log G = log \prod\limits_{w \in C} g(w)$$ $$ = \sum\limits_{w \in C} \sum\limits_{u \in w \cup NEG(w)} {L^w(u)}log[\sigma(\theta^u X_w^T)] + (1-L^w(u))log[1 - \sigma(\theta^u X_w^T)]$$ $$ = \sum\limits_{w \in C} \bigg (log(\sigma(\theta^u X_w^T)) + \sum\limits_{u \in NEG(w)} log(\sigma(-\theta^u X_w^T))\bigg)$$

第三步,随机梯度上升:

$$ F(w,u) = L^w(u) log[\sigma(\theta^u X_w^T)] + (1-L^w(u)) log[1 - \sigma(\theta^u X_w^T)] $$

\(\theta^u\)的更新公式

$$\frac{\partial F(w,u)}{\partial \theta^u} = L^w(u) (1 -\sigma(\theta^u X_w^T)) X_w - (1-L^w(u)) \sigma(\theta^u X_w^T) X_w $$ $$ = L^w(u) X_w - \sigma(\theta^u X_w^T) X_w $$

$$\theta^u := \theta^u + \eta (L^w(u) - \sigma(\theta^u X_w^T)) X_w$$

根据\(\theta^u\)和\(X_w\)的对称性,w上下文中的每个词向量的更新公式为:

$$v(\tilde{w}) := v(\tilde{w}) + \sum\limits_{u \in w \cup NEG(w)} \eta (L^w(u) - \sigma(\theta^u X_w^T))\theta^u, \tilde{w} \in Context(w)$$

2. SKIP-GRAM

Hierarchical Softmax

与CBOW不同是,输入层只有一个词向量w。

关键在于\(p(Context(w)|w)\)的定义:

$$ p(Context(w)|w) = \prod\limits_{u \in Context(w)} p(u|w) $$,其他推导类似CBOW。

Negative Sampling

已知词w,预测词w的上下文,则针对上下文中的每个词,将该词看作正样本,选取一定数量的负样本。目标函数为:

$$F = log G = log \prod\limits_{w \in C} g(w)$$ $$ = \sum\limits_{w \in C} \sum\limits_{u \in Context(w)} \sum\limits_{z \in u \cup NEG(u)} {L^u(z)}log[\sigma(\theta^z X_w^T)] + (1-L^u(z))log[1 - \sigma(\theta^z X_w^T)]$$

公式中对于一个样本(w, Context(w)), 对Context(w)中的每个词,都做了一次负采样。而word2vec源码对此作了改动,只对词w进行了|Context(w)|次负采样。

c源码阅读

命令行参数

参数 说明
size Projection Layer的长度,即词向量的长度
train 语料库地址,即训练集
save-vocab 词典保存地址
read-vocab 词典读取地址,如果没指定,从语料库中学习词典
debug debug_mode,debug信息打印控制
binary 是否保存为二进制
cbow 确定是cbow,还是skip-gram模型
alpha 学习率
output 模型保存地址
window 窗口大小,默认值5
sample 采样百分比
hs 是否进行Hierarchical Softmax
negative 是否进行负采样
threads 线程数
min-count 有效词频的最小值,默认值5
classes 聚类个数

数据结构

每个词用结构体vocab_word保存信息:

struct vocab_word{
    char *word; //词本身
    long long cn; //语料集中的词频
    int *point; //huffman树中,从根节点到该词所在叶节点的中间结点列表
    char *code, codelen; //该词对应的huffman编码和编码长度
};

整个词表用一个数组vocab表示,为了快速查找给定词的信息,建立了一个hash表,存放的元素是该词在vocab中的下标。

哈希的策略是线性探测的开放定址法,计算词w的哈希值hv(w)作为下标索引,若该位置被占用,则顺序往下找,直到找到一个未被占用的位置。

vocab = (struct vocab_word *)calloc(vocab_max_size, sizeof(struct vocab_word));
vocab_hash = (int *)calloc(vocab_hash_size, sizeof(int));

模型实现

1. 关于Net

对于hs来说,参数主要有词向量(syn0),huffman树中间结点的辅助向量(syn1);对于skip-gram来说,参数主要有词向量(syn0),负采样中每个词的辅助向量(syn1neg)。

关于初始化,syn0采用零初始化,syn1,syn1neg采用随机初始化([\(-\frac{-0.5}{size}, \frac{0.5}{size}\)]),其中size为词向量的长度。

代码中Hierarchical Softmax和Negative Sampling可以同时训练。neu1代表\(X_w\),即输入词向量加和后的向量;neu1e代表\(e\),即huffman树每一条路径(从根到叶子节点)梯度更新的求和,用来更新相应的词向量。

2. sigmoid函数值计算

建模过程中用到逻辑回归做二分类问题,需要计算sigmod函数值。程序中采用的近似计算的方法,将区间(-6,-6)等分成若干份(1000),事先将每个结点处的函数值保存起来,对于某个x,判断x的范围,如果小于-6,返回0;如果大于6;返回1;否则对x取整查表,返回对应值。

sigmoid函数中涉及指数运算,指数运算利用幂级数展开式实现,如果展开项数过多,则计算比较耗时。

3. 词频和窗口

删除小于阈值(min_count)的低频词,对高频词采用subsampling,可以提高训练速度和词向量精度。具体做法:给定一个阈值t,对于词w,以\(prob(w) = 1-(\sqrt{(\frac{t}{f(w)})} + \frac{t}{f(w)})\)的概率舍弃。

模型训练以行为单位进行,对于一个给定行,包含T个词,每个词为一个训练样本,对于某个词w,定义context(w)为:设置一个窗口阈值参数window(5),每次构造context(w)时,生成一个随机数\(c \in [1, window]\),w前后各取c个词构成context。

4. 创建huffman树

简单来说,用一个vocab_size*2+1大小的数组存储树的全部节点,其中前vocab_size个元素为全部的叶子节点且按词频降序排列;后面的元素表示中间结点,且初始化词频为1e15。用两个变量pos1,pos2记录构造树的情况,其中pos1指向vocab_size-1,pos2指向vocab_size。每次从pos1,pos2选取词频较小的那个结点,并相应的向前或向后移动,更新pos。选择两次,合并两个选中的结点为一棵新树。

在构建树的过程中,不断更新中间结点的词频,并保存各个节点的编码(0/1),各个节点的父节点(层次信息)。从叶子节点(词)回溯,得出该词在树中的路径huffman编码

详见CreateBinaryTree函数。

5. 负采样

假设词典中的每个词w对应一个线段l(w),其长度为:

$$len(w) = \frac{[counter(w)]^{0.75}}{\sum\limits_{u \in D} [counter(u)]^{0.75}} $$

将这些线段首尾相接拼接在一起,形成一个长度为1的线段。随机往这个线段上打点,则高频词被打中的概率大。将这个单位线段等距离分割成M份(M远大于N,\(M=10^8\)),每次生成一个[0,M-1]的随机数r,r对应的词即为采样。有点类似k-means++的做法。

详见InitUnigramTable函数。

6. 学习率\(\eta\)

学习率采用自适应的方法,具体为:预先设置一个初始学习率(0.025),每处理完固定数目(10000)个词,对学习率进行一次调整。

$$\eta = \eta_{0}(1-\frac{word\_count\_actual}{train\_words+1})$$

随着训练的进行\(\eta\)会逐渐变小,当小于阈值\(\eta_{min}\)时,\(\eta = \eta_{min}\)

7. 多线程并行

整个工作流程是:首先读取语料库中词表并统计词频,做一些模型初始化工作,然后多线程训练word2vec,判断classes,如果为0,则保存词向量;反之,对得到的词向量进行K-means聚类,聚类个数为classes,并保存聚类结果。

pthread_t *pt = (pthread_t *)malloc(num_threads * sizeof(pthread_t));
for (a = 0; a < num_threads; a++)
    pthread_create(&pt[a], NULL, TrainModelThread, (void *)a);
for (a = 0; a < num_threads; a++)
    pthread_join(pt[a], NULL);
//平衡划分语料文件
fseek(fi, file_size / (long long)num_threads * (long long)id, SEEK_SET);

并行的关键在于如何分割好并行的任务和如何达成任务之间的良好通信。word2vec根据线程数将语料划分成若干份,每个线程负责语料库的一部分,但训练的神经网络、词向量和哈夫曼树是共享的。首先根据文件指针读取一个句子,用一个数组保存下句子中的每个词;然后根据命令行参数,选择训练CBOW或Skip-gram,其中每个模型都可以采用hs或negative的框架训练。

Spark MLlib 1.6 实现

可调参数

参数 说明
vectorSize 词向量的长度
learningRate 学习率
numPartitions 分区数,默认为1,越小准确率越高
numIterations 迭代次数,默认为1,不大于numPartitions
seed 随机数种子
minCount 有效词频的最小值,默认值5
window 上下文的窗口大小,默认值5

主要实现了基于hierarchical softmax框架下的skip-gram模型。

广播

对于sigmod函数值表格,词信息数组,词的hash表三个变量,通过广播机制,变成全局变量。

val sc = dataset.context //dataset: RDD[Iterable[String]]
val expTable = sc.broadcast(createExpTable)
val bcVocab = sc.broadcast(vocab)
val bcVocabHash = sc.broadcast(vocabHash)

分布式

根据语料集文件,读取文件中的全部句子,将句子按分区数分发。

参考链接

word2vec 中的数学原理详解

论文:Efficient Estimation of Word Representations in Vector Space

论文:Distributed Representations of Words and Phrases and their Compositionality