word to vector
是NLP领域殿堂级的思想,这种思想为后面的xxx To Vector
提供了非常多的启发。通过已有的训练数据将文本(字或词)转为一个合适的向量,为后续各式各样的任务奠定了一个扎实的基础。
假如我们可以将语义相同的词使用相似的向量表示,这样我们只需要求两个向量的距离,就可以知道两个词语义的差距了,这为我们的应用提供的便利。
CBOW
与Skip-Gram
是两种实现Word2vec
思想的实现方法,这两种实现方式有一个共同点,就是认为:一个句子中挨得越近的词越相似,离的越远的词越不同。
个人理解:这种想法其实很奇怪,比如:我/爱/撸串,这个’我’和’爱’和’撸串’,明显没啥关系啊。但模型这波在大气层。的确’我爱撸串’的词与词之间没什么关联,但是’我爱撸串’,‘我爱烤串’,‘我爱跑步’,这三个句子里’撸串’、‘烤串’、'跑步’就有一定的关联了。当采用CBOW
或Skip-Gram
训练时,这三个词会越来越近。当语料足够大的时候,‘撸串’和’烤串’同时出现在类似的句子中的概率是非常大的,但是’跑步’出现的句子场景就不同了,因此就把’撸串’、'烤串’越训练越接近,而’跑步’就稍稍疏远。
这两个模型同时在这篇论文中提出:
Mikolov, Tomas, et al. “Efficient estimation of word representations in vector space.” arXiv preprint arXiv:1301.3781 (2013).
针对Skip-Gram
有很多优化,最常用的负采样就出自论文:
Mikolov, Tomas, et al. “Distributed representations of words and phrases and their compositionality.” Advances in neural information processing systems 26 (2013).
两个目标:
翻译成计算方法就是:
这样整个流程就有了:
Skip-Gram的流程如下:
然后训练模型的流程如下:
首先初始化一个矩阵,有多少个词就有多少行,每个词用多少维来表示,就有多少列
将词与token对应
采集正例与负例,窗口内的词为全部的正例,不在窗口内的词统统可以作为负例
注意:采集负例时等概率随机采样是可以的,优化一步就是按照词频的大小来不同概率采样;再优化一步是计算出词频后,计算词频的0.75次方后归一,作为采样概率
每个滑动窗口都只计算这一批的正例与负例,依次滑动下去
最后的loss
见代码
注意:本次代码中最后的loss采用的是直接点积,还有一种复现方式是把它当做一个分类问题来做,即正例分类为1,负例分类为0,用交叉熵作为loss,没问题。这两个优化目标都是一样的,都是下面的公式(详情参考论文)
J ( θ ) = − 1 T ∑ t = 1 T ∑ − m ≤ j ≤ m log p ( w t + j ∣ w t ) J(\theta) = -\frac{1}{T}\sum_{t=1}^T\sum_{-m \leq j \leq m}\log p(w_{t+j}|w_t) J(θ)=−T1∑t=1T∑−m≤j≤mlogp(wt+j∣wt)
以下代码都有注释,其中:
nn.Embedding()
来做torchtext(0.12.0+版本)
,参考SkipGramDataset
类中的reform_vocab()
函数SkipGramDataset
类中的generate_skip()
函数Word2VecModel
的前向传播部分torch.bmm()
计算点积import numpy as np
from torchtext.vocab import vocab
from collections import Counter, OrderedDict
from torch.utils.data import Dataset, DataLoader
from torchtext.transforms import VocabTransform # 注意:torchtext版本0.12+
from copy import deepcopy
import torch
from torch import nn
from torch.nn import functional as F
def get_text():
sentence_list = [ # 假设这是全部的训练语料
"nlp drives computer programs that translate text from one language to another",
"nlp combines computational linguistics rule based modeling of human language with statistical",
"nlp model respond to text or voice data and respond with text",
]
return sentence_list
class SkipGramDataset(Dataset):
def __init__(self, text_list, side_window=3, side_negative_sample=6):
"""
构造Word2vec的skip-gram采样Dataset
:param text_list: 语料
:param side_window: 单侧正例(构造背景词)采样数,总正例是:2 * side_window
:param side_negative_sample: 单侧负例(构造噪声词)采样数,总负例是:2 * side_window * side_negative_sample
"""
super(SkipGramDataset, self).__init__()
self.side_window = side_window
self.side_negative_sample = side_negative_sample
text_vocab, vocab_transform, word_freq = self.reform_vocab(text_list)
self.text_list = text_list # 原始文本
self.text_vocab = text_vocab # torchtext的vocab
self.vocab_transform = vocab_transform # torchtext的vocab_transform
self.word_freq = np.array(word_freq)
pos_skip = self.generate_skip()
self.pos_skip: np.ndarray = np.array(pos_skip)
def __len__(self):
return int(len(self.pos_skip) / (2 * self.side_window))
def __getitem__(self, idx):
center_id = self.pos_skip[idx * 2 * self.side_window, 0]
pos_token = self.pos_skip[idx * 2 * self.side_window:(idx + 1) * 2 * self.side_window][:, 1] # 正例
# 开始采集负例
# 以非相邻的词的频数作为权重
# 注意:原始论文中将每个词的频率变为原来的0.75次方,然后归一化,作为采样概率,这里简化一些,博主就没有这步操作了
neg_weight = deepcopy(self.word_freq)
neg_weight[pos_token] = 0
neg_token = np.random.choice(np.arange(0, len(self.text_vocab)),
self.side_negative_sample * self.side_window * 2,
p=neg_weight / neg_weight.sum())
# neg_token 即为噪声词,负例
return center_id, pos_token, neg_token
def reform_vocab(self, text_list):
"""根据语料构造torchtext的vocab"""
total_word_list = []
for _ in text_list: # 将嵌套的列表([[xx,xx],[xx,xx]...])拉平 ([xx,xx,xx...])
total_word_list += _.split(" ")
counter = Counter(total_word_list) # 统计计数
sorted_by_freq_tuples = sorted(counter.items(), key=lambda x: x[1], reverse=True) # 构造成可接受的格式:[(单词,num), ...]
ordered_dict = OrderedDict(sorted_by_freq_tuples)
# 开始构造 vocab
special_token = ["" , "" ] # 特殊字符
text_vocab = vocab(ordered_dict, specials=special_token) # 单词转token,specials里是特殊字符,可以为空
text_vocab.set_default_index(0)
vocab_transform = VocabTransform(text_vocab)
word_freq = [0] * len(special_token) + [i[1] for i in sorted_by_freq_tuples] # 频数
return text_vocab, vocab_transform, word_freq
def generate_skip(self):
"""采集所有的正例"""
pos_skip = []
for sentence in self.text_list:
sentence_id_list = self.vocab_transform(sentence.split(' '))
for center_index in range(
self.side_window, len(sentence_id_list) - self.side_window): # 防止前面或后面取不到足够的值,这是取index的上下界
# 采集正例
pos_index = list(range(center_index - self.side_window, center_index + self.side_window + 1)) # 正例的计数
del pos_index[self.side_window]
for __pos_i in pos_index:
pos_skip.append([sentence_id_list[center_index], sentence_id_list[__pos_i]])
return pos_skip
def get_vocab_transform(self):
return self.vocab_transform
class Word2VecModel(nn.Module):
def __init__(self, vocab_size=100, hidden=128):
"""
Word2vec模型
:param vocab_size: 每个词的词向量维度
:param hidden: 隐层维度
"""
super(Word2VecModel, self).__init__()
self.vocab_size = vocab_size
self.hidden = hidden
self.center_embedding = nn.Embedding(self.vocab_size, self.hidden) # 作为中心词时对应的embedding
self.back_embedding = nn.Embedding(self.vocab_size, self.hidden) # 作为背景词时对应的embedding
def forward(self, input_labels, pos_labels, neg_labels):
center_embed = self.center_embedding(input_labels) # 作为中心词时的embedding [batch, hidden]
# 正例背景词的embedding [batch, 2 * side_window, hidden]
pos_embedding = self.back_embedding(pos_labels)
# 噪声词的embedding [batch, 2 * side_window * side_negative_sample, hidden]
neg_embedding = self.back_embedding(neg_labels)
center_embed = center_embed.unsqueeze(-1) # 在最后面添加一个维度1
# 由于是正例,因此期望:两个词越接近越好,即点积越小越好
pos_dot = torch.bmm(pos_embedding, center_embed) # 结果维度:[batch, 2 * side_window, 1]
# 期望噪声词与中心词越不相似(点积越大)越好;这里center_embed加个负号,将结果也变为越小越好
neg_dot = torch.bmm(neg_embedding, -center_embed) # 结果维度:[batch, 2 * side_window * side_negative_sample, 1]
# 由于之前的维度是 [xx, xx , 1],因此把最后一个维度的1去掉:
pos_dot = pos_dot.squeeze(-1) # 修改后维度 [batch, 2 * side_window]
neg_dot = neg_dot.squeeze(-1)
# 这里对求出的点积使用LogSigmoid变换,解决梯度消失的问题,同时对每个batch的结果求和
pos_loss = F.logsigmoid(pos_dot).sum(1)
neg_loss = F.logsigmoid(neg_dot).sum(1)
loss = neg_loss + pos_loss
return -loss
def get_embedding(self, token_list: list):
# 论文中建议使用 center_embedding 作为最终的词向量,当然 back_embedding 其实也是一样的含义
return self.center_embedding(torch.Tensor(token_list).long())
def main():
sentence_list = get_text()
skip_gram_data_set = SkipGramDataset(sentence_list) # 构造 DataSet
data_loader = DataLoader(skip_gram_data_set, batch_size=1, drop_last=True) # 将DataSet封装成DataLoader
# 开始训练
model = Word2VecModel()
optimizer = torch.optim.Adam(model.parameters())
for _epoch_i in range(10):
loss_list = []
for center_token, pos_token, neg_token in data_loader:
# 开始训练
optimizer.zero_grad()
loss = model(center_token, pos_token, neg_token).mean()
loss.backward()
optimizer.step()
loss_list.append(loss.item())
print("训练中:", _epoch_i, "Loss:", np.sum(loss_list))
# 最后测试一下
# 得到: nlp can translate text from one language to another 的词向量
sentence = "nlp can translate text from one language to another"
vocab_transform = skip_gram_data_set.get_vocab_transform()
sentence_ids = vocab_transform(sentence.split(' '))
sentence_embedding = model.get_embedding(sentence_ids)
print("这个是句向量的维度:", sentence_embedding.shape)
if __name__ == '__main__':
main()