NLP_Task4 文本表示:从one-hot到word2vec

预备知识

Huffman树

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

神经概率语言模型

**输入层的输入是单词的Onehot
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

基于Negative Sampling

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

NLP_Task4 文本表示:从one-hot到word2vec

背景


语言模型
在统计自然语言处理中,语言模型指的是计算一个句子的概率模型。

传统的语言模型中词的表示是原始的、面向字符串的。两个语义相似的词的字符串可能完全不同,比如“番茄”和“西红柿”。这给所有NLP任务都带来了挑战——字符串本身无法储存语义信息。该挑战突出表现在模型的平滑问题上:标注语料是有限的,而语言整体是无限的,传统模型无法借力未标注的海量语料,只能靠人工设计平滑算法,而这些算法往往效果甚微。

神经概率语言模型(Neural Probabilistic Language Model)中词的表示是向量形式、面向语义的。两个语义相似的词对应的向量也是相似的,具体反映在夹角或距离上。甚至一些语义相似的二元词组中的词语对应的向量做线性减法之后得到的向量依然是相似的。词的向量表示可以显著提高传统NLP任务的性能,例如《基于神经网络的高性能依存句法分析器》中介绍的词、词性、依存关系的向量化对正确率的提升等。

从向量的角度来看,字符串形式的词语其实是更高维、更稀疏的向量。若词汇表大小为N,每个字符串形式的词语字典序为i,则其被表示为一个N维向量,该向量的第i维为1,其他维都为0。汉语的词汇量大约在十万这个量级,十万维的向量对计算来讲绝对是个维度灾难。而word2vec得到的词的向量形式(下文简称“词向量”,更学术化的翻译是“词嵌入”)则可以*控制维度,一般是100左右。


word2vec

word2vec作为神经概率语言模型的输入,其本身其实是神经概率模型的副产品,是为了通过神经网络学习某个语言模型而产生的中间结果。具体来说,“某个语言模型”指的是“CBOW”和“Skip-gram”。具体学习过程会用到两个降低复杂度的近似方法——Hierarchical Softmax或Negative Sampling。两个模型乘以两种方法,一共有四种实现。这些内容就是本文理论部分要详细阐明的全部了。

Hierarchical Softmax


模型共同点
无论是哪种模型,其基本网络结构都是在下图的基础上,省略掉hidden layer:
NLP_Task4 文本表示:从one-hot到word2vec

为什么要去掉这一层呢?据说是因为word2vec的作者嫌从hidden layer到output layer的矩阵运算太多了。于是两种模型的网络结构是:
NLP_Task4 文本表示:从one-hot到word2vec

其中w(t)代表当前词语位于句子的位置t,同理定义其他记号。在窗口内(上图为窗口大小为5),除了当前词语之外的其他词语共同构成上下文。

CBOW

原理
CBOW 是 Continuous Bag-of-Words Model 的缩写,是一种根据上下文的词语预测当前词语的出现概率的模型。其图示如上图左。

CBOW是已知上下文,估算当前词语的语言模型。其学习目标是最大化对数似然函数:
NLP_Task4 文本表示:从one-hot到word2vec
其中,w表示语料库C中任意一个词。从上图可以看出,对于CBOW,

输入层是上下文的词语的词向量(什么!我们不是在训练词向量吗?不不不,我们是在训练CBOW模型,词向量只是个副产品,确切来说,是CBOW模型的一个参数。训练开始的时候,词向量是个随机值,随着训练的进行不断被更新)。

投影层对其求和,所谓求和,就是简单的向量加法。

输出层输出最可能的w。由于语料库中词汇量是固定的|C|个,所以上述过程其实可以看做一个多分类问题。给定特征,从|C|个分类中挑一个。

对于神经网络模型多分类,最朴素的做法是softmax回归:
NLP_Task4 文本表示:从one-hot到word2vec
softmax回归需要对语料库中每个词语(类)都计算一遍输出概率并进行归一化,在几十万词汇量的语料上无疑是令人头疼的。

不用softmax怎么样?比如SVM中的多分类,我们都知道其多分类是由二分类组合而来的:
NLP_Task4 文本表示:从one-hot到word2vec
这是一种二叉树结构,应用到word2vec中被作者称为Hierarchical Softmax:
NLP_Task4 文本表示:从one-hot到word2vec
上图输出层的树形结构即为Hierarchical Softmax。
NLP_Task4 文本表示:从one-hot到word2vec

在开始计算之前,还是得引入一些符号:
NLP_Task4 文本表示:从one-hot到word2vec
于是可以给出w的条件概率:
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
其中,每一项是一个逻辑斯谛回归:
NLP_Task4 文本表示:从one-hot到word2vec
考虑到d只有0和1两种取值,我们可以用指数形式方便地将其写到一起:

NLP_Task4 文本表示:从one-hot到word2vec
我们的目标函数取对数似然:

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
这也很直白,连乘的对数换成求和。不过还是有点长,我们把每一项简记为:
NLP_Task4 文本表示:从one-hot到word2vec
怎么最大化对数似然函数呢?分别最大化每一项即可(这应该是一种近似,最大化某一项不一定使整体增大,具体收敛的证明还不清楚)。怎么最大化每一项呢?先求函数对每个变量的偏导数,对每一个样本,代入偏导数表达式得到函数在该维度的增长梯度,然后让对应参数加上这个梯度,函数在这个维度上就增长了。这种白话描述的算法在学术上叫随机梯度上升法,详见更规范的描述。

NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
代码分析
于是就可以得到两个参数更新的伪码:

NLP_Task4 文本表示:从one-hot到word2vec
在原版C代码中的对应关系是:
NLP_Task4 文本表示:从one-hot到word2vec

Skip-gram

原理

Skip-gram只是逆转了CBOW的因果关系而已,即已知当前词语,预测上下文。

其网络结构如下图所示:

NLP_Task4 文本表示:从one-hot到word2vec
上图与CBOW的两个不同在于

  1. 输入层不再是多个词向量,而是一个词向量

  2. 投影层其实什么事情都没干,直接将输入层的词向量传递给输出层

在对其推导之前需要引入一个新的记号:

u:表示w的上下文中的一个词语。

于是语言模型的概率函数可以写作:
NLP_Task4 文本表示:从one-hot到word2vec
类似地,每一项都是如下简写:
NLP_Task4 文本表示:从one-hot到word2vec
把它们写到一起,得到目标函数:
NLP_Task4 文本表示:从one-hot到word2vec
类似CBOW的做法,将每一项简记为:
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
训练伪码如下:
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec

具体对应源码中的

// Propagate hidden -> output
for (c = 0; c < layer1_size; c++)
    f += syn0[c + l1] * syn1[c + l2];
// 'g' is the gradient multiplied by the learning rate
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];

NLP_Task4 文本表示:从one-hot到word2vec

Negative Sampling


通过上一章的学习,我们知道无论是CBOW还是Skip-gram模型,其实都是分类模型。对于机器学习中的分类任务,在训练的时候不但要给正例,还要给负例。对于Hierarchical Softmax,负例是二叉树的其他路径。对于Negative Sampling,负例是随机挑选出来的。据说Negative Sampling能提高速度、改进模型质量。

CBOW

给定训练样本,即一个词w和它的上下文Context(w),Context(w)是输入,w是输出。那么w就是正例,词汇表中其他的词语的就是负例。假设我们通过某种采样方法获得了负例子集NEG(w)。对于正负样本,分别定义一个标签:
NLP_Task4 文本表示:从one-hot到word2vec
也即正样本为1,负样本为0。

NLP_Task4 文本表示:从one-hot到word2vec
每个词都是如此,语料库有多个词,我们将g累积得到优化目标。因为对数方便计算,我们对其取对数得到目标函数:
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
训练伪码为:
NLP_Task4 文本表示:从one-hot到word2vec
对应原版C代码的片段:

f = 0;
for (c = 0; c < layer1_size; c++)
    f += neu1[c] * syn1neg[c + l2];
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];

Skip-gram

NLP_Task4 文本表示:从one-hot到word2vec
最终目标函数为:
NLP_Task4 文本表示:从one-hot到word2vec
分别求出梯度:
NLP_Task4 文本表示:从one-hot到word2vec
训练伪码为:

NLP_Task4 文本表示:从one-hot到word2vec
对应原版C代码片段:

f = 0;
for (c = 0; c < layer1_size; c++)
    f += syn0[c + l1] * syn1neg[c + l2];
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];

NLP_Task4 文本表示:从one-hot到word2vec

更多细节


Huffman树

上文一直在用二叉树描述Hierarchical Softmax,这是因为我不想仿照大部分tutorial那样一下子拿出Huffman这么具体的细节。初期对word2vec的大框架还没把握住的时候突然看到这些细节的话,人会抓不住重点,造成学习成本无谓的上升。我当时看到有些tutorial第一节就在讲Huffman编码,还以为实现word2vec一定要用Huffman树呢。

其实根本不是的,任何二叉树都可以。Huffman树只是二叉树中具体的一种,特别适合word2vec的训练。

word2vec训练的时候按照词频将每个词语Huffman编码,由于Huffman编码中词频越高的词语对应的编码越短。所以越高频的词语在Hierarchical Softmax过程中经过的二分类节点就越少,整体计算量就更少了。

负采样算法

任何采样算法都应该保证频次越高的样本越容易被采样出来。基本的思路是对于长度为1的线段,根据词语的词频将其公平地分配给每个词语:
NLP_Task4 文本表示:从one-hot到word2vec
counter就是w的词频。

于是我们将该线段公平地分配了:
NLP_Task4 文本表示:从one-hot到word2vec
接下来我们只要生成一个0-1之间的随机数,看看落到哪个区间,就能采样到该区间对应的单词了,很公平。

但怎么根据小数找区间呢?速度慢可不行。

word2vec用的是一种查表的方式,将上述线段标上M个“刻度”,刻度之间的间隔是相等的,即1/M:
NLP_Task4 文本表示:从one-hot到word2vec
NLP_Task4 文本表示:从one-hot到word2vec
这个幂实际上是一种“平滑”策略,能够让低频词多一些出场机会,高频词贡献一些出场机会,劫富济贫。

sigmoid函数

类似的查表方法还有sigmoid函数的计算,因为该函数使用太频繁,而其值仅仅在靠近0的时候才会剧烈变化,远离0的方向很快趋近0和1。所以源码中也采用了“刻度查表”的方法,先算出了很多个刻度对应的函数值,运算中直接查表。这部分对应:

expTable = (real *) malloc((EXP_TABLE_SIZE + 1) * sizeof(real));
for (i = 0; i < EXP_TABLE_SIZE; i++)
{
    expTable[i] = exp((i / (real) EXP_TABLE_SIZE * 2 - 1) * MAX_EXP); 
    // Precompute the exp() table
    expTable[i] = expTable[i] / (expTable[i] + 1);                   
    // Precompute f(x) = x / (x + 1)
}

一些开源实现


C
https://github.com/dav/word2vec

在原版的基础上打了一些社区的patch,可以在macos上make编译

https://github.com/bpiwowar/word2vec

原版的CMake迁移,可以在macos下编译

https://github.com/RaRe-Technologies/gensim/blob/develop/gensim/models/word2vec.py

大名鼎鼎的gensim,适合Python用户。

https://github.com/h10r/word2vec-macosx-maverics

改了几个文件头,可以在macos上make编译。

C++
https://github.com/eske/multivec

C++实现的各种各样的XXvector,包括paragraph vector等,其word2vec只是对原版C代码的包装,没有多少改进。

*https://github.com/jdeng/word2vec

又一份C++11的实现,虽然星星很多,但据说准确率惨不忍睹,并且作者没有解释。在较早的一份issue(就是由一份Java版的作者siegfang提出的)中,作者表示“I am not sure if my implementation is accurate”。另外Google论坛上有人表示该C++11实现只有原版C实现速度的一半。所以我认为这两个版本都应该谨慎使用。

Java
https://github.com/medallia

一份Java实现,使用了很多Google的库,校正了一些原版的错误,阉割掉了k-means,从代码质量上讲总体是一份不错的实现。其输出的bin模型与原版C程序直接兼容,然而并不支持宽字符(需要改改,有个pull request做了,但作者一直没merge)。我测试了其准确率,比原版低20%左右。从这一点来讲,该实现没有多大价值。

https://github.com/kojisekig/word2vec-lucene

一份Java实现,是我见过最忠于原版的Java实现。卖点是不但可以用文本文件训练,还可以直接用Lucene的index训练。对于文本,由于Java没有“读取下一个String”的IO接口,作者实现了一个TextFileCorpus.nextWord。该方法读取一行并且拆分成字符串数组,然而text8整个文件也就一行,所以会频繁地多次读取(多个线程),然后OOM。作者提供一个切割程序,将text8切成多行,这样才能训练text8。作者并没有做准确率评测,我移植了谷歌的评测程序,并提交给了作者。我还将评测结果做了对比,比原版低10%左右,也报告给了作者,有志于开源项目的朋友可以持续参与讨论。事实上,这份实现的价值是最高的,因为它的准确率是Java中最高的。

*https://github.com/siegfang/word2vec

一份Java实现,卖点是并行化(其实上所有开源的都支持并行化);内存占用较大(Java的通病),据作者siegfang讲参考了上述C++11实现。然而上梁不正,下梁能好到哪去。既不支持negative sampling,又不能保证准确率,毫无亮点。

其他不在此列表中的方案要么没被我看到,要么不值得一试。

事实上,上述实现都没入这位日本友人的评测单,他建议或者原版,或者gensim。


词袋模型:离散、高维、稀疏。

分布式表示:连续、低维、稠密。word2vec词向量原理并实践,用来表示文本。

参考:https://blog.csdn.net/itplus/article/details/37969635
https://blog.csdn.net/itplus/article/details/37969817
https://blog.csdn.net/itplus/article/details/37969979
https://blog.csdn.net/itplus/article/details/37998797
http://www.hankcs.com/nlp/word2vec.html