寒假前做的一个软著,用到word2vec里的cbow模型,整理一下关于词向量重新学到的知识,方便以后查阅
1.参考资料
word2vec数学原理
Mikolov的两篇论文:
distributed-representations-of-words-and-phrases-and-their-compositionality
Efficient estimation of word representations in vector
2.word2vec模型简述
所谓词向量就是用向量来表示语料库中的词,方便其他数学模型对词语进行直接计算。而词嵌入(word embeding)可理解为将词向量由原来的稀疏向量形式(如one-hot)嵌入到低维稠密的连续实数向量中表示(常用的有word2vec,glove),这种表示方法即解决了one-hot向量表示的维度灾难问题,而且还能用词向量来挖掘词与词之间的关联属性。
这里记录的是词向量里用的较多的word2vec方法,word2vec是用神经网络的方法得到词语向量空间的一种语言模型训练方法,而词向量其实是word2vec模型训练所得的副产品。包括CBOW和Skip-gram两种模型。由下面模型图可以看出,CBOW模型是根据语料库中词语的上下文来推断中心词的概率,而skip-gram模型是根据中心词来推断上下文的概率。其中Mikolov在13年发表的paper(distributed-representations-of-words-and-phrases-and-their-compositionality)介绍了两种用于减少word2vec模型计算量的算法:分层softmax和负采样的方法,这里主要记录分层softmax的方法。
word2vec模型构建
以cbow模型为例,分层softmax算法输入层为某个中心词上下文对应的词向量,隐藏层将输入层的各向量求和得到Xw,在输出层构建一颗以语料库中的词为叶子节点的哈夫曼树,非叶子结点组成权值矩阵。从下图例子中可看出,哈夫曼树根据词语的频值来构建,比如足球对应的结点上的数字3,就是足球在语料库中的频值,即出现次数。因此,分层softmax算法需要在模型训练前完成哈夫曼树的构建(包括语料库各词语词频的统计),图例中,足球的哈夫曼编码值为1001,Xw“到达”足球这个节点需经过的权值节点为θ1,θ2,θ3,θ4。
模型训练过程
cbow模型训练时,按序逐行读取语料库句子,根据设定的窗口长度,每次读入固定长度的上下文和对应的一个中心词,获取上下文对应的词向量,求和,之后再分别与中心词对应的哈夫曼路径中的θ1,θ2,θ3...θn相乘之后求sigmoid函数。哈夫曼树训练的目标就是使Xw和θn相乘在求sigmoid函数的值偏向中心词对应的路径。因此,cbow模型需要训练的两个量就是Xw和非叶子结点对应的权值矩阵θi,word2vec采用梯度上升法训练,简化后的更新公式为:(具体推导过程参考wordvec的数学原理)
这里,θi-1为结点的权值向量,η为梯度上升法的学习率,di为中心词的哈夫曼编码d1,d2...di。Xv即上面的Xw,σ()表示sigmoid函数。(上面提到窗口长度的概念涉及到语言模型的一个假设:一个句子的某个词出现的概率取决于它的上下文,且可以由该词前面的若干个词来决定,即P(Wn|context(Wn))=P(Wn|Wn-m,Wn=m+1...Wn-1),这里的m表示Wn与其前面的m个词有关,当取m=1时,表示Wn只与自己有关,对应语言模型中的unigram模型,当m取2时,对应bigram模型,当m取3时,对应trigram模型...而窗口长度就是2m,因为在实际训练中,都是读取中心词左右两边的m个词作为上下文的。)
3.代码实现(python)
构建哈夫曼树
这里的代码中,先建了一个VocabItem类来记录语料库中的每个词,该类只包含4个属性:vocabitem.word(该词对应的字符串),vocabitem.count(该词在语料库中的出现ci),vocabitem.path(词的哈夫曼树路径)vocabitem.code(哈夫曼编码)
class VocabItem:
def __init__(self, word):
self.word = word
self.count = 0
self.path = None
self.code = None
语料库的处理代码:
class Vocab:
def __init__(self, fi, min_count):
vocab_items = [] #用来存语料库中所有词语的列表
vocab_hash = {} #词典记录语料库中词语和对应该词在列表中的索引
word_count = 0 #总的词语数
fi = open(fi, 'r') #读入语料库文件fi
for line in fi:
tokens = line.split()
for token in tokens:
if token not in vocab_hash:
vocab_hash[token] = len(vocab_items)
vocab_items.append(VocabItem(token))
assert vocab_items[vocab_hash[token]].word == token, 'Wrong vocab_hash index'
vocab_items[vocab_hash[token]].count += 1
word_count += 1
最后将统计完语料库的列表和词典等保存为Vocab的属性中:
self.vocab_items = vocab_items
self.vocab_hash = vocab_hash
self.word_count = word_count
哈夫曼树构建:
def encode_huffman(self):
vocab_size = len(self)
count = [t.count for t in self] + [1e15] * (vocab_size - 1)
parent = [0] * (2 * vocab_size - 2)
binary = [0] * (2 * vocab_size - 2)
pos1 = vocab_size - 1
pos2 = vocab_size
for i in range(vocab_size - 1):
# 查找词频中的最小值min1
if pos1 >= 0:
if count[pos1] < count[pos2]:
min1 = pos1
pos1 -= 1
else:
min1 = pos2
pos2 += 1
else:
min1 = pos2
pos2 += 1
# 查找除去第一个最小值min1后的另一个最小值min2
if pos1 >= 0:
if count[pos1] < count[pos2]:
min2 = pos1
pos1 -= 1
else:
min2 = pos2
pos2 += 1
else:
min2 = pos2
pos2 += 1
count[vocab_size + i] = count[min1] + count[min2]
parent[min1] = vocab_size + i #min1的父节点
parent[min2] = vocab_size + i #min2的父节点
binary[min2] = 1 #min2的哈夫曼编码,相对的min1的编码为1
root_idx = 2 * vocab_size - 2
for i, token in enumerate(self):
path = [] # 记录词语哈夫曼路径的列表
code = [] # 记录哈夫曼编码
node_idx = i
while node_idx < root_idx:
if node_idx >= vocab_size: path.append(node_idx)
code.append(binary[node_idx])
node_idx = parent[node_idx]
path.append(root_idx)
token.path = [j - vocab_size for j in path[::-1]]
token.code = code[::-1]
训练过程
def train(self, fi, fo, dim, alpha, win, binary):
fi = open(fi, 'r')
line = fi.readline().strip()
sent = self.indices(line.split())
word_count = 0 #记录训练的句子数
for sent_pos, token in enumerate(sent):
if word_count % 10000 == 0: #自适应学习率,没训练10000个词更新一次学习率
alpha = alpha * (1 - float(word_count) / vocab.word_count)
if alpha < alpha * 0.0001:alpha = alpha * 0.0001
#确定当前的窗口
current_win = np.random.randint(low=1, high=win + 1)
context_start = max(sent_pos - current_win, 0)
context_end = min(sent_pos + current_win + 1, len(sent))
context = sent[context_start:sent_pos] + sent[sent_pos + 1:context_end] # Turn into an iterator?
#求和得到Xw
neu1 = np.mean(np.array([self.syn0[c] for c in context]), axis=0)
assert len(neu1) == dim, 'neu1 and dim do not agree'
neu1e = np.zeros(dim)
# Compute neu1e and update syn1
classifiers = zip(vocab[token].path, vocab[token].code)
for target, label in classifiers:
z = np.dot(neu1, self.syn1[target])
p = self.sigmoid(z)
g = alpha * (label - p)
neu1e += g * self.syn1[target]
self.syn1[target] += g * neu1 # 更新权值矩阵syn1
for context_word in context:
self.syn0[context_word] += neu1e # 更新词向量syn0
word_count += 1
# 保存模型
self.save(self.syn0, self.syn1, fo, binary) #save为自定义的保存模型函数
fi.close()