【不可思议的Word2Vec】5. Tensorflow版的Word2Vec

原文链接 | 2017-05-27 | 苏剑林

本文封装了一个比较完整的Word2Vec,其模型部分使用tensorflow实现。本文的目的并非只是再造一次Word2Vec这个轮子,而是通过这个例子来熟悉tensorflow的写法,并且测试笔者设计的一种新的softmax loss的效果,为后面研究语言模型的工作做准备。

不同的地方#

Word2Vec的基本的数学原理,请移步到《【不可思议的Word2Vec】 1.数学原理》一文查看。本文的主要模型还是CBOW或者Skip-Gram,但在loss设计上有所不同。本文还是使用了完整的softmax结构,而不是huffmax softmax或者负采样方案,但是在训练softmax时,使用了基于随机负采样的交叉熵作为loss。这种loss与已有的nce_loss和sampled_softmax_loss都不一样,这里姑且命名为random softmax loss。

另外,在softmax结构中,一般是\(\text{softmax}(Wx+b)\)这样的形式,考虑到\(W\)矩阵的形状事实上跟词向量矩阵的形状是一样的,因此本文考虑了softmax层与词向量层共享权重的模型(这时候直接让\(b\)为0),这种模型等效于原有的Word2Vec的负采样方案,也类似于glove词向量的词共现矩阵分解,但由于使用了交叉熵损失,理论上收敛更快,而且训练结果依然具有softmax的预测概率意义(相比之下,已有的Word2Vec负样本模型训练完之后,最后模型的输出值是没有意义的,只有词向量是有意义的。)。同时,由于共享了参数,因此词向量的更新更为充分,读者不妨多多测试这种方案。

所以,本文事实上也实现了4个模型组合:CBOW/Skip-Gram,是/否共享softmax层的参数,读者可以选择使用。

loss是怎么来的#

前面已经说了,本文的主要目的之一就是测试新loss的有效性,下面简介一下这个loss的来源和形式。这要从softmax为什么训练难度大开始说起~

假设标签数(本文中也就是词表中词汇量)为\(n\),那么

\[\begin{aligned}(p_1,p_2,\dots,p_n) =& \text{softmax}(z_1,z_2,\dots,z_n)\\ =& \left(\frac{e^{z_1}}{Z}, \frac{e^{z_2}}{Z}, \dots, \frac{e^{z_n}}{Z}\right)\end{aligned}\]

这里\(Z = e^{z_1} + e^{z_2} + \dots + e^{z_n}\)。如果正确类别标签为\(t\),使用交叉熵为loss,则

\[L=-\log \frac{e^{z_t}}{Z}\]

梯度为

\[\nabla L=-\nabla z_t + \nabla (\log Z)=-\nabla z_t + \frac{\nabla Z}{Z}\]

因为有\(Z\)的存在,每次梯度下降时,都要计算完整的\(Z\)来计算\(\nabla Z\),也就是每一个样本迭代一次的计算量就是\(\mathcal{O}(n)\)了,对于\(n\)比较大的情形,这是难以接受的,所以要寻求近似方案(huffman softmax是其中的一种,但是它写起来会比较复杂,而且huffman softmax的结果通常略差于普通的softmax,还有huffman softmax仅仅是训练快,但是如果预测时要找最大概率的标签,那么反而更加慢)。

让我们进一步计算\(\nabla L\):

\[\begin{aligned}\nabla L=&-\nabla z_t + \frac{\sum_i e^{z_i}\nabla z_i}{Z}\\ =&-\nabla z_t + \frac{\sum_i e^{z_i}}{Z}\nabla z_i\\ =&-\nabla z_t + \sum_i p_i \nabla z_i\\ =&-\nabla z_t + \text{E}(\nabla z_i) \end{aligned}\]

也就是说,最后的梯度由两项组成:一项是正确标签的梯度,一项是所有标签的梯度的均值,这两项反号,可以理解为这两项在“拉锯战”。计算量主要集中在第二项,因为要遍历所有才能算具体的均值。然而,均值本身就具有概率意义的,那么能不能直接就随机选取若干个来算这个梯度均值,而不用算全部梯度呢?如果可以的话,那每步更新的计算量就固定了,不会随着标签数的增大而快速增加。

但如果这样做的话,需要按概率来随机选取标签,这也并不容易写。然而,有个更巧妙的方法是不用我们直接算梯度,我们可以直接对loss动手脚。所以这就导致了本文的loss:对于每个“样本-标签”对,随机选取nb_negative个标签,然后与原来的标签组成nb_negative+1个标签,直接在这nb_negative+1个标签中算softmax和交叉熵。选取这样的loss之后再去算梯度,就会发现自然就是按概率来选取的梯度均值了。

代码实现#

自我感觉代码还是比较精炼的,单文件包含了所有内容。训练输出模仿了gensim中的Word2Vec。模型代码位于GitHub:

https://github.com/bojone/tf_word2vec/blob/master/Word2Vec.py

使用参考:

from Word2Vec import *
import pymongo
db = pymongo.MongoClient().travel.articles
class texts:
    def __iter__(self):
        for t in db.find().limit(30000):
            yield t['words']

wv = Word2Vec(texts(), model='cbow', nb_negative=16, shared_softmax=True, epochs=2) #建立并训练模型
wv.save_model('myvec') #保存到当前目录下的myvec文件夹

#训练完成后可以这样调用
wv = Word2Vec() #建立空模型
wv.load_model('myvec') #从当前目录下的myvec文件夹加载模型

有几点需要说明的:

1、训练的输入是分好词的句子,可以是列表,也可以是迭代器(class+iter),注意,不能是生成器(函数+yield),这跟gensim版的word2vec的要求是一致的。因为生成器只能遍历一次,而训练word2vec需要多次遍历数据;2、模型不支持更新式训练,即训练完模型后,不能再用额外的文档更新原来的模型(不是不可以,是没必要,而且意义不大);3、训练模型需要tensorflow,推荐用GPU加速,训练完成后,重新加载模型并且使用模型,都不需要tensorflow,只需要numpy;4、对于迭代次数,一般迭代1~2次就够了,负样本个数10~30即可。其余参数如batch_size,可以自己实验调整。

简单的对比实验#

tensorflow中,已有的两种近似训练softmax的loss是nce_loss和sampled_softmax_loss,这里简单做一个比较。在一个旅游领域的语料中(两万多篇文章)训练同样的模型,并比较结果。模型cbow,softmax选择不共享词向量层,其余参数都采用相同的默认参数。

random_softmax_loss#

耗时:8分19秒(迭代次数2次,batch_size为8000)

相似度测试结果

import pandas as pd

pd.Series(wv.most_similar(u’水果’))

0 (食品, 0.767908)

1 (鱼干, 0.762363)

2 (椰子, 0.750326)

3 (饮料, 0.722811)

4 (食物, 0.719381)

5 (牛肉干, 0.715441)

6 (菠萝, 0.715354)

7 (火腿肠, 0.714509)

8 (菠萝蜜, 0.712546)

9 (葡萄干, 0.709274)

dtype: object>>> pd.Series(wv.most_similar(u’自然’))

0 (人文, 0.645445)

1 (和谐, 0.634387)

2 (包容, 0.61829)

3 (大自然, 0.601749)

4 (自然环境, 0.588165)

5 (融, 0.579027)

6 (博大, 0.574943)

7 (诠释, 0.550352)

8 (野性, 0.548001)

9 (野趣, 0.545887)

dtype: object>>> pd.Series(wv.most_similar(u’广州’))

0 (上海, 0.749281)

1 (武汉, 0.730211)

2 (深圳, 0.703333)

3 (长沙, 0.683243)

4 (福州, 0.68216)

5 (合肥, 0.673027)

6 (北京, 0.669859)

7 (重庆, 0.653501)

8 (海口, 0.647563)

9 (天津, 0.642161)

dtype: object>>> pd.Series(wv.most_similar(u’风景’))

0 (景色, 0.825557)

1 (美景, 0.763399)

2 (景致, 0.734687)

3 (风光, 0.727672)

4 (景观, 0.57638)

5 (湖光山色, 0.573512)

6 (山景, 0.555502)

7 (美不胜收, 0.552739)

8 (明仕, 0.535922)

9 (沿途, 0.53485)

dtype: object>>> pd.Series(wv.most_similar(u’酒楼’))

0 (酒家, 0.768179)

1 (排挡, 0.731749)

2 (火锅店, 0.729214)

3 (排档, 0.726048)

4 (餐馆, 0.722667)

5 (面馆, 0.715188)

6 (大排档, 0.709883)

7 (名店, 0.708996)

8 (松鹤楼, 0.705759)

9 (分店, 0.705749)

dtype: object>>> pd.Series(wv.most_similar(u’酒店’))

0 (万豪, 0.722409)

1 (希尔顿, 0.713292)

2 (五星, 0.697638)

3 (五星级, 0.696659)

4 (凯莱, 0.694978)

5 (银泰, 0.693179)

6 (大酒店, 0.692239)

7 (宾馆, 0.67907)

8 (喜来登, 0.668638)

9 (假日, 0.662169)

nce_loss#

耗时:4分钟(迭代次数2次,batch_size为8000),然而相似度测试结果简直不堪入目,当然,考虑到用时变少了,为了公平,将迭代次数增加到4次,其余参数不变,重复跑一次。相似度测试结果依旧一塌糊涂,比如:

pd.Series(wv.most_similar(u’水果’))

0 (口, 0.940704)

1 (可, 0.940106)

2 (100, 0.939276)

3 (变, 0.938824)

4 (第二, 0.938155)

5 (:, 0.938088)

6 (见, 0.937939)

7 (不好, 0.937616)

8 (和, 0.937535)

9 ((, 0.937383)

dtype: object

有点怀疑是不是我使用姿势不对了~于是我再次调整,将nb_negative增加到1000,然后迭代次数调回为3,这样耗时为9分17秒,最后的loss比前面的要小一个数量级,比较相似度的结果有些靠谱了,但还是并非特别好,比如:

pd.Series(wv.most_similar(u’水果’))

0 (特产, 0.984775)

1 (海鲜, 0.981409)

2 (之类, 0.981158)

3 (食品, 0.980803)

4 (。, 0.980371)

5 (蔬菜, 0.979822)

6 (&, 0.979713)

7 (芒果, 0.979599)

8 (可, 0.979486)

9 (比如, 0.978958)

dtype: object>>> pd.Series(wv.most_similar(u’自然’))

0 (与, 0.985322)

1 (地处, 0.984874)

2 (这些, 0.983769)

3 (夫人, 0.983499)

4 (里, 0.983473)

5 (的, 0.983456)

6 (将, 0.983432)

7 (故居, 0.983328)

8 (那些, 0.983089)

9 (这里, 0.983046)

dtype: object

sampled_softmax_loss#

有了前面的经验,这次直接将nb_negative设为1000,然后迭代次数为3,这样耗时为8分38秒,相似度比较的结果是:

pd.Series(wv.most_similar(u’水果’))

0 (零食, 0.69762)

1 (食品, 0.651911)

2 (巧克力, 0.64101)

3 (葡萄, 0.636065)

4 (饼干, 0.62631)

5 (面包, 0.613488)

6 (哈密瓜, 0.604927)

7 (食物, 0.602576)

8 (干货, 0.601015)

9 (菠萝, 0.598993)

dtype: object>>> pd.Series(wv.most_similar(u’自然’))

0 (人文, 0.577503)

1 (大自然, 0.537344)

2 (景观, 0.526281)

3 (田园, 0.526062)

4 (独特, 0.526009)

5 (和谐, 0.503326)

6 (旖旎, 0.498782)

7 (无限, 0.491521)

8 (秀美, 0.482407)

9 (一派, 0.479687)

dtype: object>>> pd.Series(wv.most_similar(u’广州’))

0 (深圳, 0.771525)

1 (上海, 0.739744)

2 (东莞, 0.726057)

3 (沈阳, 0.687548)

4 (福州, 0.654641)

5 (北京, 0.650491)

6 (动车组, 0.644898)

7 (乘动车, 0.635638)

8 (海口, 0.631551)

9 (长春, 0.628518)

dtype: object>>> pd.Series(wv.most_similar(u’风景’))

0 (景色, 0.8393)

1 (景致, 0.731151)

2 (风光, 0.730255)

3 (美景, 0.666185)

4 (雪景, 0.554452)

5 (景观, 0.530444)

6 (湖光山色, 0.529671)

7 (山景, 0.511195)

8 (路况, 0.490073)

9 (风景如画, 0.483742)

dtype: object

pd.Series(wv.most_similar(u’酒楼’))

0 (酒家, 0.766124)

1 (菜馆, 0.687775)

2 (食府, 0.666957)

3 (饭店, 0.664034)

4 (川味, 0.659254)

5 (饭馆, 0.658057)

6 (排挡, 0.656883)

7 (粗茶淡饭, 0.650861)

8 (共和春, 0.650256)

9 (餐馆, 0.644265)

dtype: object>>> pd.Series(wv.most_similar(u’酒店’))

0 (宾馆, 0.685888)

1 (大酒店, 0.678389)

2 (四星, 0.638032)

3 (五星, 0.633661)

4 (汉庭, 0.619405)

5 (如家, 0.614918)

6 (大堂, 0.612269)

7 (度假村, 0.610618)

8 (四星级, 0.609796)

9 (天域, 0.598987)

dtype: object

总结#

这个实验虽然不怎么严谨,但是应该可以说,在相同的训练时间下,从相似度任务来看,感觉上random softmax与sampled softmax效果相当,nce loss的效果最差,进一步压缩迭代次数,调整参数也表明了类似结果,欢迎读者进一步测试。由于本文的random softmax对每一个样本都进行不同的采样,因此所需要采样的负样本数更少,并且采样更加充分。

至于其他任务的比较,只能在以后的实践中进行了。毕竟这不是发论文,我也懒得做了~

后续工作#

一个问题是:为啥跟sampled softmax效果相当,我还要造新的loss?其实原因很简单的,我看sampled softmax的论文和公式,总感觉它不大好看,理论不够漂亮,当然,就效果来看,是我太强迫症了。本文就算是强迫症的产物吧,也算是练手tensorflow。

另外,《记录一次半监督的情感分析》一文表明,语言模型在预训练模型、实现半监督等任务中有着重要的潜力,甚至词向量也不过就是语言模型来预训练的第一层参数而已。所以笔者想抽空深入一下类似的内容。本文就是为了这个所做的准备之一了。