利用Python Numpy从零开始步步为营计算Word2Vec词向量
@牛伯雨
词向量建模是自然语言处理当中的重要基础步骤。有了用向量表示的词汇,计算机就可以更好地处理文本数据了。
2013年,Mikolov et al. (2013)提出的Word2Vec是一个里程碑式的词向量建模方法。
最近看到一篇Derek Chia的关于徒手计算Word2Vec的博文An implementation guide to Word2Vec using NumPy and Google Sheets,作者利用表格软件表现这种模型训练过程中的矩阵和向量的变化,这对理解这种模型的原理大有裨益。另一篇GeeksforGeeks上题为Implement your own word2vec(skip-gram) model in Python的文章对这一模型也有比较详细的说明。本文是受此启发的产物,表格设计风格和部分代码有参考,旨在动手利用Python里的Numpy一步步地从零构建Word2Vec词向量。
1. CBOW模型和Skip-gram模型
Mikolov等人2013年的论文提出了两种模型,它们分别叫Continuous Bag of Words(CBOW)和Continuous Skip-gram,图示如下:
这两种模型都是预测模型,不过CBOW是已知某个词(中心词)周围的上下文,来预测这个词本身最有可能是什么,而Skip-gram则是已知一个词(中心词),来预测这个词周围最有可能是哪些词作为它的上下文。
这两种模型看上去相似,有相互对称的感觉,在具体的操作中也有一些区别。Mikolov 2013年的原文第4.3节对这两种模型进行了比较:
- 在模型训练时间方面,Skip-gram所需的时间基本上是CBOW的3倍。这一件比较符合我们的直观感受:已知上下文去预测其中的某一个词,是比只知道一个词去预测上下文要简单很多的。
- 在模型表现方面,Skip-gram在语义上的表现要优于CBOW,而CBOW则在句法上稍微更胜一筹。具体说来,如果我们用英文文本举例子,在单词相似性的任务里,Skip-gram更倾向于将"dog"和"cat"这类语义相近的单词视为相似词,而CBOW则可能会认为"dog"和其复数形式"dogs"更接近。
除了前面这两点,它们还有在对低频词的识别度方面的区别:Skip-gram一般比CBOW更敏感。其原因在于,在Skip-gram的训练过程中,虽然高频词比低频词出现的次数更多,但是高频词仍然是一个一个单独地出现的,而在CBOW中,低频词通常被裹在高频词之间作为上下文里不怎么有能见度的那一部分,而高频词经常会在上下文里连着一起出现,总是具有高能见度。
2. 动手编写Python代码
为了方便起见,我们就以句子“利用Python Numpy从零开始步步为营计算Word2Vec词向量”作为我们的(迷你)语料库,来计算词向量。
2.1. 文本预处理
处理中文需要进行分词。我们可以利用Python Jieba进行这一步。
import jieba
class Corpus(object):
def __init__(self, texts):
self.texts = texts
#texts: ["sentence1", "sentence2", ...]
self.tokenizedCorpus = []
#tokenizedCorpus: [["word1", "word2", ...], ["wordn", ...]]
def makeCorpus(self):
for sentence in self.texts:
self.tokenizedCorpus.append([])
for x in jieba.tokenize(sentence):
if x[0] == ' ':
#我们句子中的"Python"和"Numpy"之间有空格,而jieba认为这个空格也是一个词,但我们不需要空格作为单独的词
continue
self.tokenizedCorpus[-1].append(x[0])
def getTokenizedCorpus(self):
return self.tokenizedCorpus
具体用我们的迷你语料库作为实验:
s = ["利用Python Numpy从零开始步步为营计算Word2Vec词向量"]
corpusTest = Corpus(s)
corpusTest.makeCorpus()
corpusTest.getTokenizedCorpus()
我们得到的文本corpus是由词组成的列表的列表:
[['利用', 'Python', 'Numpy', '从零开始', '步步为营', '计算', 'Word2Vec', '词', '向量']]
2.2. 滑动窗口(Sliding windows)
上面我们说到,无论是CBOW还是Skip-gram,都会用到“中心词”和它周围的“上下文”的概念。
和文学意义的上下文不同的是,这里的上下文不是指真正的一个包含一定语义的段落整体,而是提前设置好长度的“窗口”。一个“窗口”所包含的词数总是一定的(除非在开头和结尾处会出现长度不够的情况),因此当它的中心词向前移动时,整个“窗口”都会随之移动,我们称这种窗口为“滑动窗口”。我们可以用表格软件来把这一过程变得成具体化:
2.3. One-hot编码向量化
在我们正式用Mikolov的思想计算词向量之前,我们先把每个词用One-hot的方式表达出来。
One-hot是最简单的词向量:向量的共有V个维度,V为文本中所出现的不同的单词数,每个词都对应有一个维度为1,其余维度均为0。比如对于我们的例子迷你文本来说,V=9。这种方法可以简单有效地区分不同的单词,但它的缺点也是显而易见的:所有词之间的距离均相等,不含任何语义信息,而且维度数过大。所以这种One-hot向量只是我们的一个参照物,重要的是根据文本和这些参照物将Word2Vec模型里面的参数不断更新,以得到维度数更少且反映语义信息的Word2Vec向量。
接下来,我们就用这种One-hot编码的方法把刚才我们制造出来的不同“窗口”表示出来:
![窗口4](https://img-blog.csdnimg.cn/img_convert/6b52e32e21d728a6bc5d804eb4c00184.png
等等,以此类推。图中的每一列即表示一个词的One-hot向量。
比如,在窗口3中,中心词是“Numpy”,One-hot向量为 ( 0 , 0 , 1 , 0 , 0 , 0 , 0 , 0 , 0 ) T (0, 0, 1, 0, 0, 0, 0, 0, 0)^T (0,0,1,0,0,0,0,0,0)T,而它的上下文分别为:
“利用”: ( 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 ) T (1, 0, 0, 0, 0, 0, 0, 0, 0)^T (1,0,0,0,0,0,0,0,0)T;
“Python”: ( 0 , 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0 ) T (0, 1, 0, 0, 0, 0, 0, 0, 0)^T (0,1,0,0,0,0,0,0,0)T;
“从零开始”: ( 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 , 0 ) T (0, 0, 0, 1, 0, 0, 0, 0, 0)^T (0,0,0,1,0,0,0,0,0)T;
“步步为营”: ( 0 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 ) T (0, 0, 0, 0, 1, 0, 0, 0, 0)^T (0,0,0,0,1,0,0,0,0)T;
下面我们编写实现One-hot编码和滑动窗口的代码。
import numpy as np
class TrainingData(object):
def __init__(self, tokenizedCorpus):
self.tokenizedCorpus