背景
特征表达是很基础也很重要的一步,我们通常需要用一个向量去表示一个东西,比如文本中的词向量,知识图谱中的知识向量,以及Network Embedding等。
在NLP中,传统算法通常使用one-hot形式表示一个词,存在以下问题:
1)维度爆炸,词表通常会非常大,导致词向量维度也会非常大。
2)损失语义信息,one hot随机给每个词语进行编号映射,无法表示词语之间的关系。
所以word embeding的优势如下:
1)将词语映射成一个固定维度的向量,节省空间。
2)词向量可能会具备一定的语义信息,将相似的词语放到相近的向量空间(比如香蕉和苹果都是属于水果,苹果又会涉及到歧义问题),可以学习到词语之间的关系(比如经典的 男人-女人=国王-王后)。
本文会介绍一下Word2vec原理,这是一种常见的可以用于训练词向量的模型工具。常见的做法是,我们先用word2vec在公开数据集上预训练词向量,加载到自己的模型中,对词向量进行调整,调整成适合自己数据集的词向量。
训练模式
我们通常是通过将词向量用于某些任务中,用这些任务的衡量指标去衡量模型结果。
那么反过来,如果我们想要训练词向量,可以先去训练一个语言模型,然后将模型中对应的参数,作为词向量。从任务形式上看,我们是在训练语言模型,而实际上我们最终的目标是想得到词向量,我们更关心的是这个词向量合不合理。
Word2vec根据上下文之间的出现关系去训练词向量,有两种训练模式,Skip Gram
和CBOW
,其中Skip Gram
根据目标单词预测上下文,CBOW
根据上下文预测目标单词,最后使用模型的部分参数作为词向量。
AutoEncoder
也可以用于训练词向量,先将one hot映射成一个hidden state,再映射回原来的维度,令输入等于输出,取中间的hidden vector作为词向量,在不损耗原表达能力的前提下压缩向量维度,得到一个压缩的向量表达形式。
CBOW
根据上下文预测目标单词,我们需要极大化这个目标单词的出现概率。
假设词表大小为V,词向量维度为N,上下文单词为$x_1,x_2, …, x_c$,定义上下文窗口大小为c,对应的目标单词为y,我们将x跟y都表示成one hot形式。这里涉及到两个矩阵参数,W是词向量矩阵,每一行都是某个词的词向量v,W’可以看做是一个辅助矩阵,每一列可以看做是某个词对应的相关向量v’。
前向过程:
x –> hidden:对于每个$x_i$,取出对应的词向量$v_i$,再对这些词向量取平均作为hidden vector,相当于通过简单粗暴的叠加,得到这些词语的语义向量。
h –> y:将h乘以W’得到一个维度为V的向量u,进行softmax归一化得到概率向量,取概率最大的作为预测单词。
后向过程:
我们需要极大化目标单词的出现概率$p(y | x_1, x_2, … , x_c)$,也就是极小化负对数似然函数,Loss函数定义为:
我们需要更新两个矩阵参数,W和W’,先根据loss对参数求梯度,再使用梯度下降法更新参数。具体的求导过程这里略过,请移步原论文。
对于W’,经过求导,v’更新公式为:
也就是说,需要更新整个W’矩阵,所有v’向量。(这里造成了巨大的计算量)
对于W,经过求导,v更新公式为: 也就是说,这里只需要更新c个上下文单词所对应的词向量。
skip gram
根据目标单词预测其上下文,假设输入的目标单词为x,定义上下文窗口大小为c,对应的上下文为$y_1, y_2, … , y_c$,这些y是相互独立的。
前向过程:
x->hidden:将输入单词x乘以词向量矩阵W,相当于取出该词的词向量v。
h->y:对于每个输出单词$y_i$,将h乘以矩阵W’得到向量u,再经过softmax归一化得到概率向量,取概率最大的预测为上下文单词,极大化$y_i$的预测概率。
这些上下文单词是相互独立的,虽然他们共享W’,但是loss是不一样的,我们需要极大化这些词出现的概率。作为一个语言模型这种做法是略显粗糙,但是这里我们的目的只是为了训练词向量,并不是需要训练一个多么好的语言模型。
后向过程:
直观上的样本格式是 $(x,y_1, y_2, … , y_c)$ ,然后极大化$p(y|x)$,因为这些y是相互独立的,又变成极大化$p(y_1|x)p(y_2|x)…p(y_c|x)$,取log将连乘变成连加,取负将极大化变成极小化,使用交叉熵作为loss函数:
所以这里我们可以将样本格式定义成(x, y),将所有样本的Loss加起来。
也就是说,这里依然需要更新所有v’向量。(无法避免的巨大的计算量)
对于W,经过求导,v更新公式为:
这里只需要更新目标词语所对应的那个词向量。
优化方法
原始的方法所存在的问题是计算量太大,体现在以下两方面:
1)前向过程,h->y这部分在对向量进行softmax的时候,需要计算V次。
2)后向过程,softmax涉及到了V列向量,所以也需要更新V个向量。
问题就出在V太大,而softmax需要进行V次操作,用整个W进行计算。
因此word2vec使用了两种优化方法,Hierarchical SoftMax和Negative Sampling,对softmax进行优化,不去计算整个W,大大提高了训练速度。
一. Hierarchical SoftMax
HS用哈夫曼树,把预测one-hot编码改成预测一组01编码,进行层次分类。
在哈夫曼树中,每个叶节点是词表中的一个词,每个非叶子节点对应一个v’向量,树的深度为L(w),整颗树有V-1个非叶子节点和V个叶节点。假设输入单词是$w_i$,目标单词是$w_o$,那么n(w, i)表示从根节点到叶节点w路径中的第i个节点,v’(w, i)表示n(w, i)所对应的v’向量。
注意:v’不是针对词语w而言,而是针对节点n,每个节点n都有自己的一个向量v’,而不是每个词在每个节点上有一个向量,或者说,这些词在同一个节点上共享向量。
假设h是$w_i$前面所计算出来的hidden vector,我们在非叶子节点中使用该节点处的v’向量和h点乘,再用sigmoid去判断向左还是向右:(取代softmax)
那么每个叶节点会有一个概率$p(w_i=w_o)$,最终我们需要极大化从根节点到预测单词$w_o$这条路径的概率,比如对于目标单词$w_2$,我们需要极大化$p(w_2=w_o)$:
在根节点处左右概率之和是1,然后在接下来的每个节点,对应两个子节点的概率值之和等于父节点本身的概率值,那么走到最后,所有叶子节点的概率值之和必定还是等于1:
这也就保证了原始softmax概率和为1的前提,因此可以用层次sigmoid去代替softmax函数。
Loss函数定义为:
极大化目标单词的路径概率。
现在我们重新定义v’为:
那么对于W’,经过求导,v’更新公式为:
也就是说,这里只需要更新L(w)-1个v’向量,时间复杂度直接从O(V)降到了O(logV)。
关于空间复杂度,原始方法中每个单词需要一个v’向量,总共需要V个向量,而HS中每个节点也会有一个v’向量,总共需要V-1个向量,这些向量维度是一样的,并不会增加空间复杂度。
二. Negative Sampling
NS仅仅选择一小部分列向量进行更新,和HS相比,显得相对简单一点。
对于每条数据,首先我们将原始的V个词划分成正样本$w_o$和负样本$w_neg$,正样本也就是要预测的单词,剩下的就是负样本。负样本非常多,我们需要采样出K个负样本,与正样本一起训练。从前我们需要对所有V个词进行softmax计算,现在对于我们只使用到了正样本和负样本,只针对这几个词进行计算,计算量可以大大减小。
负样本选取方式:
NS是一种概率采样的方式,可以根据词频进行随机抽样,我们倾向于选择词频比较大的负样本,比如“的”,这种词语其实是对我们的目标单词没有很大贡献的。
Word2vec则在词频基础上取了0.75次幂,减小词频之间差异过大所带来的影响,使得词频比较小的负样本也有机会被采到。
Loss函数定义为:
极大化正样本出现的概率,同时极小化负样本出现的概率,以sigmoid来代替softmax,相当于进行二分类,判断这个样本到底是不是正样本。
那么对于W’,经过求导,v’更新公式为:
也就是说,这里不需要更新所有v’向量,只需要更新部分v’向量,这里的wj是正样本w_o和负样本w_neg的集合,只更新这些样本所对应的v’向量。
衡量指标
词向量的衡量指标通常有以下几种:
1)词汇相似度任务,比如wordsim353,但是这种方式比较依赖于数据集。
2)类比任务,比如男人-女人=国王-王后
3)应用于实际任务上的效果,比如文本分类,情感分析,句法分析,序列标注,阅读理解等等。这种方法我觉得是比较靠谱的,因为我们训练词向量是希望得到一个好的词向量,然后能在别的任务上work,
4)可视化,可以用谷歌的Embedding Projector工具,用 PCA、t-SNE对高维词向量进行可视化,把数据降到三维,以3D方式查看数据,感觉还挺好玩的。
pytorch 简易代码实现
创建词汇表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import torch
from torch.autograd import Variable
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
torch.manual_seed(1)
CONTEXT_SIZE = 2 # 2 words to the left, 2 to the right
text = """We are about to study the idea of a computational process.
Computational processes are abstract beings that inhabit computers.
As they evolve, processes manipulate other abstract things called data.
The evolution of a process is directed by a pattern of rules
called a program. People create programs to direct processes. In effect,
we conjure the spirits of the computer with our spells.""".split()
split_ind = (int)(len(text) * 0.8)
# By deriving a set from `raw_text`, we deduplicate the array
vocab = set(text)
vocab_size = len(vocab)
print('vocab_size:', vocab_size)
w2i = {w: i for i, w in enumerate(vocab)}
i2w = {i: w for i, w in enumerate(vocab)}
1
vocab_size: 49
创建CBOW,skip-gram的数据集
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# context window size is two
def create_cbow_dataset(text):
data = []
for i in range(2, len(text) - 2):
context = [text[i - 2], text[i - 1],
text[i + 1], text[i + 2]]
target = text[i]
data.append((context, target))
return data
def create_skipgram_dataset(text):
import random
data = []
for i in range(2, len(text) - 2):
data.append((text[i], text[i-2], 1))
data.append((text[i], text[i-1], 1))
data.append((text[i], text[i+1], 1))
data.append((text[i], text[i+2], 1))
# negative sampling
for _ in range(4):
if random.random() < 0.5 or i >= len(text) - 3:
rand_id = random.randint(0, i-1)
else:
rand_id = random.randint(i+3, len(text)-1)
data.append((text[i], text[rand_id], 0))
return data
cbow_train = create_cbow_dataset(text)
skipgram_train = create_skipgram_dataset(text)
print('cbow sample', cbow_train[0])
print('skipgram sample', skipgram_train[0])
1
2
cbow sample (['We', 'are', 'to', 'study'], 'about')
skipgram sample ('about', 'We', 1)
word2vec模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class CBOW(nn.Module):
def __init__(self, vocab_size, embd_size, context_size, hidden_size):
super(CBOW, self).__init__()
self.embeddings = nn.Embedding(vocab_size, embd_size)
self.linear1 = nn.Linear(2*context_size*embd_size, hidden_size)
self.linear2 = nn.Linear(hidden_size, vocab_size)
def forward(self, inputs):
embedded = self.embeddings(inputs).view((1, -1))
hid = F.relu(self.linear1(embedded))
out = self.linear2(hid)
log_probs = F.log_softmax(out)
return log_probs
class SkipGram(nn.Module):
def __init__(self, vocab_size, embd_size):
super(SkipGram, self).__init__()
self.embeddings = nn.Embedding(vocab_size, embd_size)
def forward(self, focus, context):
embed_focus = self.embeddings(focus).view((1, -1))
embed_ctx = self.embeddings(context).view((1, -1))
score = torch.mm(embed_focus, torch.t(embed_ctx))
log_probs = F.logsigmoid(score)
return log_probs
训练模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
embd_size = 100
learning_rate = 0.001
n_epoch = 30
def train_cbow():
hidden_size = 64
losses = []
loss_fn = nn.NLLLoss()
model = CBOW(vocab_size, embd_size, CONTEXT_SIZE, hidden_size)
print(model)
optimizer = optim.SGD(model.parameters(), lr=learning_rate)
for epoch in range(n_epoch):
total_loss = .0
for context, target in cbow_train:
ctx_idxs = [w2i[w] for w in context]
ctx_var = Variable(torch.LongTensor(ctx_idxs))
model.zero_grad()
log_probs = model(ctx_var)
loss = loss_fn(log_probs, Variable(torch.LongTensor([w2i[target]])))
loss.backward()
optimizer.step()
total_loss += loss.data[0]
losses.append(total_loss)
return model, losses
def train_skipgram():
losses = []
loss_fn = nn.MSELoss()
model = SkipGram(vocab_size, embd_size)
print(model)
optimizer = optim.SGD(model.parameters(), lr=learning_rate)
for epoch in range(n_epoch):
total_loss = .0
for in_w, out_w, target in skipgram_train:
in_w_var = Variable(torch.LongTensor([w2i[in_w]]))
out_w_var = Variable(torch.LongTensor([w2i[out_w]]))
model.zero_grad()
log_probs = model(in_w_var, out_w_var)
loss = loss_fn(log_probs[0], Variable(torch.Tensor([target])))
loss.backward()
optimizer.step()
total_loss += loss.data[0]
losses.append(total_loss)
return model, losses
cbow_model, cbow_losses = train_cbow()
sg_model, sg_losses = train_skipgram()
1
2
3
4
5
6
7
8
CBOW (
(embeddings): Embedding(49, 100)
(linear1): Linear (400 -> 64)
(linear2): Linear (64 -> 49)
)
SkipGram (
(embeddings): Embedding(49, 100)
)