静态词向量之word2vec-skipgram

介绍

当当当,欢迎来学习word2vec skipgram,关于word2vec,网上介绍的例子一大堆,这里就简单说明下。
最开始进行tokenizer的时候,是使用onehot编码,缺点就是矩阵太大,另外太稀疏,而且词和词之前是不具备语义信息的。
你说什么叫语义?语义没有官方定义,可以简单理解成更符合人类认知的,我觉得就可以理解成语义。

而word2vec带来了稠密向量,并且词和词之间有了语义关联,可以用于计算词和词之间的空间距离,也可以叫做相似度。
实现word2vec的方式有两种,一个是Hierarchical Softmax(也是softmax),另外一种是negative sampling。
下面将分别介绍这两种方式的实现思路。

偷懒群众可以直接看:

  1. Hierarchical Softmax example
  2. negative sampling example
  3. 也可以看我之前的negative实现

    Hierarchical Softmax

1. 介绍

啥叫Hierarchical Softmax,嘿嘿,这个我没看😂😂😂。总而言之也是softmax,不过应用场景主要在对大规模语料进行softmax的时候加速计算结果,比如softmax(100w个),那么他的优势就体现出来了。
如果感兴趣其实现原理的可以自行百度,我就葛优躺了😂😂😂。

2. 加载数据

1
2
3
4
5
6
7
8
9
10
11
12
def load_reuters():
nltk.set_proxy('http://192.168.0.28:1080')
nltk.download('reuters')
nltk.download('punkt')
from nltk.corpus import reuters
text = reuters.sents()
# lowercase (optional)
text = [[word.lower() for word in sentence] for sentence in text]
vocab = Vocab.build(text, reserved_tokens=[PAD_TOKEN, BOS_TOKEN, EOS_TOKEN])
corpus = [vocab.convert_tokens_to_ids(sentence) for sentence in text]

return corpus, vocab

其中corpus长下面这个样子:

1
2
3
4
5
6
7
corpus[:2]
Out[3]:
[
[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,9,10,11,10,20,13,21,22,23,24,25,26,27,28,11,29,30,31,19,32,33,34,35,36,37,38,7,39,40,20,41,42,10],
[43,44,45,46,47,4,48,49,9,10,11,10,50,51,13,52,53,54,55,47,19,9,10,11,10,20,56,57,58,59,60,61,26,62,63,10]

]

构建训练数据集的步骤如下:

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 SkipGramDataset(Dataset):
def __init__(self, corpus, vocab, context_size=2):
self.data = []
self.bos = vocab[BOS_TOKEN]
self.eos = vocab[EOS_TOKEN]
for sentence in tqdm(corpus, desc="Dataset Construction"):
sentence = [self.bos] + sentence + [self.eos]
for i in range(1, len(sentence)-1):
# 模型输入:当前词
w = sentence[i]
# 模型输出:一定窗口大小内的上下文
left_context_index = max(0, i - context_size)
right_context_index = min(len(sentence), i + context_size)
context = sentence[left_context_index:i] + sentence[i+1:right_context_index+1]
self.data.extend([(w, c) for c in context])

def __len__(self):
return len(self.data)

def __getitem__(self, i):
return self.data[i]

def collate_fn(self, examples):
inputs = torch.tensor([ex[0] for ex in examples])
targets = torch.tensor([ex[1] for ex in examples])
return (inputs, targets)

看这段代码,其核心在于,取当前词的index,然后以当前词取左右大小为context_size的词出来来作为他的中心词,然后在collate_fn那里就转换成当前词的id,和周围词的id作为其预测目标。

是不是瞬间明白他想干什么事儿了吧!!
没明白是吧,继续往下看。

3. 模型结构

1
2
3
4
5
6
7
8
9
10
11
12
class SkipGramModel(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(SkipGramModel, self).__init__()
self.embeddings = nn.Embedding(vocab_size, embedding_dim)
self.output = nn.Linear(embedding_dim, vocab_size)
init_weights(self)

def forward(self, inputs):
embeds = self.embeddings(inputs)
output = self.output(embeds)
log_probs = F.log_softmax(output, dim=1)
return log_probs

这个结构是不是相当简单,从今天来看,这个结构很简单,另外pytorch已经帮忙做了很多,但是放到实现那年,不得不佩服这些研究者。

4. 训练部分

训练部分就很直白了,每次取batch_size=1024个样本,然后输入模型,获得log_probs=(1024, 31081)结果,其中31081是指vocab_size,然后交叉熵算loss。
到这是不是明白了Hierarchical Softmax而不是softmax的意义了吧。

negative sampling

1. 介绍

当面对百万级的文本,就算是隐藏层是检索功能,其计算量也是相当大,而且还会造成冗余计算,这时候对高频词抽样以及负采样就应运而生了。

他的做法是对文档中出现的每个词计算其出现频率,然后以当前词周围context_size大小的作为postive sampling,和选中的词无关的并且以词出现频率高为negative sampling。分别计算当前词和positive以及negative的相关度,就是直接计算其点积,将其loss最低即完成训练。

2. 数据处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 读取文本数据
corpus, vocab = load_reuters()
# 计算unigram概率分布
unigram_dist = get_unigram_distribution(corpus, len(vocab))
# 根据unigram分布计算负采样分布: p(w)**0.75
negative_sampling_dist = unigram_dist ** 0.75
negative_sampling_dist /= negative_sampling_dist.sum()
# 构建SGNS训练数据集
dataset = SGNSDataset(
corpus,
vocab,
context_size=context_size,
n_negatives=n_negatives,
ns_dist=negative_sampling_dist
)
data_loader = get_loader(dataset, batch_size)

其中get_unigram_distribution是获取每个词的出现概率。每次记不住这个unigram,bigram,还有trigram。。。

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
class SGNSDataset(Dataset):
def __init__(self, corpus, vocab, context_size=2, n_negatives=5, ns_dist=None):
self.data = []
self.bos = vocab[BOS_TOKEN]
self.eos = vocab[EOS_TOKEN]
self.pad = vocab[PAD_TOKEN]
for sentence in tqdm(corpus, desc="Dataset Construction"):
sentence = [self.bos] + sentence + [self.eos]
for i in range(1, len(sentence)-1):
# 模型输入:(w, context) ;输出为0/1,表示context是否为负样本
w = sentence[i]
left_context_index = max(0, i - context_size)
right_context_index = min(len(sentence), i + context_size)
context = sentence[left_context_index:i] + sentence[i+1:right_context_index+1]
context += [self.pad] * (2 * context_size - len(context))
self.data.append((w, context))

# 负样本数量
self.n_negatives = n_negatives
# 负采样分布:若参数ns_dist为None,则使用uniform分布
self.ns_dist = ns_dist if ns_dist is not None else torch.ones(len(vocab))

def __len__(self):
return len(self.data)

def __getitem__(self, i):
return self.data[i]

def collate_fn(self, examples):
words = torch.tensor([ex[0] for ex in examples], dtype=torch.long)
contexts = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
batch_size, context_size = contexts.shape
neg_contexts = []
# 对batch内的样本分别进行负采样
for i in range(batch_size):
# 保证负样本不包含当前样本中的context
ns_dist = self.ns_dist.index_fill(0, contexts[i], .0)
neg_contexts.append(torch.multinomial(ns_dist, self.n_negatives * context_size, replacement=True))
neg_contexts = torch.stack(neg_contexts, dim=0)
return words, contexts, neg_contexts

这地方有两个地方需要注意的:
第一是获取positive sampling,也就是以当前词周围context_size大小的作为postive sampling,这个在__init__函数中完成。
第二是获取negative sampling,这个是在collate_fn中完成,其中涉及到一个采样函数multinomial,这个大家可以参考这篇文章

3. 模型结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SGNSModel(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(SGNSModel, self).__init__()
# 词嵌入
self.w_embeddings = nn.Embedding(vocab_size, embedding_dim)
# 上下文嵌入
self.c_embeddings = nn.Embedding(vocab_size, embedding_dim)

def forward_w(self, words):
w_embeds = self.w_embeddings(words)
return w_embeds

def forward_c(self, contexts):
c_embeds = self.c_embeddings(contexts)
return c_embeds

4. 训练

这部分可以直接看源代码了,其中在计算负样本的分类(对数)似然地方,我改了下,更方便理解,其实本质没啥~

1
2
3
4
# 负样本的分类(对数)似然
neg_context_loss = F.logsigmoid(torch.bmm(neg_context_embeds, word_embeds).squeeze(dim=2).neg())
# neg_context_loss = neg_context_loss.view(batch_size, -1, n_negatives).sum(dim=2)
neg_context_loss = neg_context_loss.mean(dim=1)

5. 扩展与思考

这部分属于个人猜测部分,不保证其准确性。
抛开所有的理论,单纯看代码思考一个问题,源码这里使用了两个embedding,分别是词嵌入上下文嵌入,那我们能不能只用一个embedding呢?
那既然如此,我们需要修改下源码,有两个地方需要修改:

  1. 在选择负样本的时候是有可能选择到当前词的,所以我们要将负样本中出现当前词的去掉。
  2. 把c_embeddings去掉。

关于第一点,为什么要在负样本中去掉当前词?
不可能让当前词和当前词在计算相似度时出现悖论吧😂😂😂。

关于第二点,这个没啥好解释的了。

修改后的完整代码如下:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# Defined in Section 5.2.3.3

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset
from tqdm.auto import tqdm

from utils import BOS_TOKEN, EOS_TOKEN, PAD_TOKEN
from utils import load_reuters, get_loader


def cal_similar(w):
v = model.w_embeddings.weight[vocab[w]]
values, indices = torch.mm(model.w_embeddings.weight, v.view(-1, 1)).topk(dim=0, k=3)
similar_tokens = vocab.convert_ids_to_tokens(indices.view(-1).tolist())
return similar_tokens


def demos():
tokens = ['china', 'august', 'good', 'paris']
for token in tokens:
s = cal_similar(token)
print(f'{token}: {s}')

class SGNSDataset(Dataset):
def __init__(self, corpus, vocab, context_size=2, n_negatives=5, ns_dist=None):
self.data = []
self.bos = vocab[BOS_TOKEN]
self.eos = vocab[EOS_TOKEN]
self.pad = vocab[PAD_TOKEN]
for sentence in tqdm(corpus, desc="Dataset Construction"):
# sentence = [self.bos] + sentence + [self.eos]
for i in range(0, len(sentence)):
# 模型输入:(w, context) ;输出为0/1,表示context是否为负样本
w = sentence[i]
left_context_index = max(0, i - context_size)
right_context_index = min(len(sentence), i + context_size)
context = sentence[left_context_index:i] + sentence[i+1:right_context_index+1]
context += [self.pad] * (2 * context_size - len(context))
self.data.append((w, context))

# 负样本数量
self.n_negatives = n_negatives
# 负采样分布:若参数ns_dist为None,则使用uniform分布
self.ns_dist = ns_dist if ns_dist is not None else torch.ones(len(vocab))

def __len__(self):
return len(self.data)

def __getitem__(self, i):
return self.data[i]

def collate_fn(self, examples):
words = torch.tensor([ex[0] for ex in examples], dtype=torch.long)
contexts = torch.tensor([ex[1] for ex in examples], dtype=torch.long)

all_positive_contexts = torch.cat((words.view(-1, 1), contexts), dim=1)

batch_size, context_size = contexts.shape
neg_contexts = []
# 对batch内的样本分别进行负采样
for i in range(batch_size):
# 保证负样本不包含当前样本中的context
ns_dist = self.ns_dist.index_fill(0, all_positive_contexts[i], .0)
neg_contexts.append(torch.multinomial(ns_dist, self.n_negatives * context_size, replacement=True))
neg_contexts = torch.stack(neg_contexts, dim=0)
for index, word in enumerate(words):
assert word not in neg_contexts[index]
return words, contexts, neg_contexts

class SGNSModel(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(SGNSModel, self).__init__()
# 词嵌入
self.w_embeddings = nn.Embedding(vocab_size, embedding_dim, padding_idx=vocab[PAD_TOKEN])
# 上下文嵌入
# self.c_embeddings = nn.Embedding(vocab_size, embedding_dim)

def forward_w(self, words):
w_embeds = self.w_embeddings(words)
return w_embeds

def forward_c(self, contexts):
c_embeds = self.w_embeddings(contexts)
return c_embeds


def get_unigram_distribution(corpus, vocab_size):
# 从给定语料中统计unigram概率分布
token_counts = torch.tensor([0] * vocab_size)
total_count = 0
for sentence in corpus:
total_count += len(sentence)
for token in sentence:
token_counts[token] += 1
unigram_dist = torch.div(token_counts.float(), total_count)
return unigram_dist

embedding_dim = 64
context_size = 16
hidden_dim = 128
batch_size = 20480 * 2
num_epoch = 30
n_negatives = 10

# 读取文本数据
corpus, vocab = load_reuters()
# 计算unigram概率分布
unigram_dist = get_unigram_distribution(corpus, len(vocab))
# 根据unigram分布计算负采样分布: p(w)**0.75
negative_sampling_dist = unigram_dist ** 0.75
negative_sampling_dist /= negative_sampling_dist.sum()
# 构建SGNS训练数据集
dataset = SGNSDataset(
corpus,
vocab,
context_size=context_size,
n_negatives=n_negatives,
ns_dist=negative_sampling_dist
)
data_loader = get_loader(dataset, batch_size)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SGNSModel(len(vocab), embedding_dim)

model.to(device)
optimizer = optim.Adam(model.parameters(), lr=0.1)
scheduler = optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.99)

model.train()
for epoch in range(num_epoch):
total_loss = 0
for batch in tqdm(data_loader, desc=f"Training Epoch {epoch}"):
words, contexts, neg_contexts = [x.to(device) for x in batch]
optimizer.zero_grad()
batch_size = words.shape[0]
# 提取batch内词、上下文以及负样本的向量表示
word_embeds = model.forward_w(words).unsqueeze(dim=2)
context_embeds = model.forward_c(contexts)
neg_context_embeds = model.forward_c(neg_contexts)
# 正样本的分类(对数)似然
context_loss = F.logsigmoid(torch.bmm(context_embeds, word_embeds).squeeze(dim=2))
context_loss = context_loss.mean(dim=1)
# 负样本的分类(对数)似然
neg_context_loss = F.logsigmoid(torch.bmm(neg_context_embeds, word_embeds).squeeze(dim=2).neg())
# neg_context_loss = neg_context_loss.view(batch_size, -1, n_negatives).sum(dim=2)
neg_context_loss = neg_context_loss.mean(dim=1)
# 损失:负对数似然
loss = -(context_loss + neg_context_loss).mean()
loss.backward()
optimizer.step()

total_loss += loss.item()
scheduler.step()
print(f"Loss: {total_loss:.2f}, Lr: {scheduler.get_last_lr()[0]}")
demos()

# 合并词嵌入矩阵与上下文嵌入矩阵,作为最终的预训练词向量
# combined_embeds = model.w_embeddings.weight + model.c_embeddings.weight
# save_pretrained(vocab, combined_embeds.data, "sgns.vec")

在训练的过程中,发现其loss是往下不断的降低,那么我们可以认为是有用的,但是具体效果要再自行实验下。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值