【不可思议的Word2Vec】5. Tensorflow版的Word2Vec
By 苏剑林 | 2017-05-27 | 110216位读者 |本文封装了一个比较完整的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。
另外,《记录一次半监督的情感分析》一文表明,语言模型在预训练模型、实现半监督等任务中有着重要的潜力,甚至词向量也不过就是语言模型来预训练的第一层参数而已。所以笔者想抽空深入一下类似的内容。本文就是为了这个所做的准备之一了。
转载到请包括本文地址:https://kexue.fm/archives/4402
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (May. 27, 2017). 《【不可思议的Word2Vec】5. Tensorflow版的Word2Vec 》[Blog post]. Retrieved from https://kexue.fm/archives/4402
@online{kexuefm-4402,
title={【不可思议的Word2Vec】5. Tensorflow版的Word2Vec},
author={苏剑林},
year={2017},
month={May},
url={\url{https://kexue.fm/archives/4402}},
}
October 20th, 2017
老师,你好,我想问一下,tensorflow训练的结果能提取关键词吗,这个没有syn1啊
我明白了,我知道该怎么写了
November 15th, 2017
还是不太理解,为什么embedding的权值和最后softmax层的权值可以共享,是怎样等效于负采样和共现矩阵的呢,可否讲的再具体一些
形状相同就有共享参数的可能性,共享与否都算是模型的假设而已。
刚才已经修正,其实不类似于glove,只类似与word2vec的负采样方法,因为word2vec的负采样也是共现参数的呀。
理解了,多谢
February 23rd, 2018
瞄了博主你的代码和tf的源码构造sampled loss的部分,精简了很多啊,不过word2vec是不需要进行真正的分类预估的。但如果是真正的分类模型,用来预估的时候还是要全部类别都预估一遍,tf的这个版本训练出来的参数,在非采样应该是可以得到合理的预估值的。博主这样精简的话,也能在非采样的时候得到正确值吗?
刚刚把tf的那个pdf看了,感觉也是一个比较常见的贝叶斯变换将采样前后概率联系做一起的技巧(没亲自推导过,大概看了思路),直觉上判断应该不存在大问题啊,当然严格来说,它似乎只能说明原问题的解也是新问题的解,不能说明转化后的问题的解一定也是原问题的解。不过一般从实践上这么做问题也不大吧
从理论上来讲,参数优化就是依靠标注数据来推测参数值的过程,目前我们基本使用的是学习率衰减的随机梯度下降算法(包括自适应学习率算法其实也可以这样理解),这种算法在本身就带有随机性,因此其准确性依赖于大量的标签数据和反复迭代,使得算法能够依概率收敛。
回到词向量训练这个问题,词向量训练本身属于训练数据远远多于参数量的问题,因此基于采样softmax的方法也能有效训练出来原始softmax的效果。直觉上理解,假如当前批数据采样了部分类别,那么未被采样的类别可能在下一步采样得到,并且梯度更大2而获得更大的更新值,从而在充分训练的情况下,能起到完整的softmax的效果。
这个有点不同意。博主的方式合理的前提,是梯度从平均意义上,跟非采样的时候一致,具体而言,正如你推导的那样,前半部分是没有区别,所以是后半部分不变。但是后半部分的梯度按公式是各类别的概率加权平均值,即热门词在后半项的贡献更大。而博主的采样是按类别均匀的,所以即便多步平均下来,也只应该等效于非加权的平均值,不同热度的词的后半项出现的比例是没有区分的。
当然我也不敢确定这样优化出来一定有差异,博主要不要做个对比实验,不要局限在word2vec,就是一个通用的多分类预估,用不同的方式采样,以及不采样,看优化出来最终得到各类别的预估概率是否一致?
March 5th, 2019
老师,您好,请问一下如何对模型进行 再训练(增量训练),能指导一下吗?谢谢您
gensim版的word2vec就可以增量训练(可以先build_vocab后train),直接看官方的帮助页面就行了:
https://radimrehurek.com/gensim/models/word2vec.html
但这样的增量更新,只能更新权重,但不能增加单词量。一个折衷的方法是开始时就考虑一个充分大的词表(哪怕那些词根本就没有在训练语料出现过),也就是说事先预留好很多词的位置。
谢谢老师的回复,如果在您工作的基础上做,能否实现添加语料更新模型这一功能,,,还是需要通过重新训练来得到对应的模型
“我的工作基础”指什么?指这个Tensorflow版的Word2Vec?如果你会改,确实可以灵活很多,增量更新不成问题...如果是gensim版的word2vec,前面已经说了...
April 23rd, 2019
我能想明白按概率抽样算出的可以近似认为是期望
但是这句话要怎么理解:
对于每个“样本-标签”对,随机选取nb_negative个标签,然后与原来的标签组成nb_negative+1个标签,直接在这nb_negative+1个标签中算softmax和交叉熵。选取这样的loss之后再去算梯度,就会发现自然就是按概率来选取的梯度均值了。
为什么这样选就是按概率的?
February 1st, 2021
现在gensim word2vec的增量训练可以增加单词 代码如下 亲测有效:
self.build_vocab(sentences, trim_rule=trim_rule)
self.train(
sentences, total_examples=self.corpus_count, epochs=self.iter,
start_alpha=self.alpha, end_alpha=self.min_alpha
)
February 25th, 2021
苏神,看你的random_softmax_loss和DSSM论文中使用的loss思路基本一致,不知道我理解对没。
我不了解DSSM是啥,所以无法回答这个问题。
好的,我再自己琢磨下
October 14th, 2022
nce和sampled softmax,从效果上来看nce的效果弱于sampled softmax,请问这块有没有一些合理的解释?
nce采样效率最低。
采样效率最低这块没有想明白。假如我们有一个sample store按照unigram提供采样,采一批负样本后,与当前batch的正样本算loss,无非是用nce还是sampled softmax。这样的话采样效率是不是一样的?还是说我对采样效率的理解有些偏差?
这种情况下,nce转化为多个二分类问题,而sampled softmax还是softmax,两者的区别就像是普通多分类任务该用多个二分类交叉熵损失还是softmax交叉熵一样吧,前者会导致类别不平衡(负例过多)而效率差,后者不会有这个问题。