word-embedding

词向量

学习目标

  • 学习词向量的概念
  • 用Skip-thought模型训练词向量
  • 学习使用PyTorch dataset和dataloader
  • 学习定义PyTorch模型
  • 学习torch.nn中常见的Module
    • Embedding
  • 学习常见的PyTorch operations
    • bmm
    • logsigmoid
  • 保存和读取PyTorch模型

使用的训练数据可以从以下链接下载到。

链接:https://pan.baidu.com/s/1tFeK3mXuVXEy3EMarfeWvg 密码:v2z5

在这一份notebook中,我们会(尽可能)尝试复现论文Distributed Representations of Words and Phrases and their Compositionality中训练词向量的方法. 我们会实现Skip-gram模型,并且使用论文中noice contrastive sampling的目标函数。

这篇论文有很多模型实现的细节,这些细节对于词向量的好坏至关重要。我们虽然无法完全复现论文中的实验结果,主要是由于计算资源等各种细节原因,但是我们还是可以大致展示如何训练词向量。

以下是一些我们没有实现的细节

  • subsampling:参考论文section 2.3

In [1]:

1
from torch import nn

In [2]:

1
2
3
4
import torch
import torch.nn as nn #神经网络工具箱torch.nn
import torch.nn.functional as F #神经网络函数torch.nn.functional
import torch.utils.data as tud #Pytorch读取训练集需要用到torch.utils.data类

两个模块的区别:torch.nn 和 torch.functional 的区别

In [3]:

1
2
3
4
5
6
7
8
9
10
11
from torch.nn.parameter import Parameter  #参数更新和优化函数

from collections import Counter #Counter 计数器
import numpy as np
import random
import math

import pandas as pd
import scipy #SciPy是基于NumPy开发的高级模块,它提供了许多数学算法和函数的实现
import sklearn
from sklearn.metrics.pairwise import cosine_similarity #余弦相似度函数

开始看代码前,请确保对word2vec有了解。

CBOW模型理解

Skip-Gram模型理解

负例采样就是Skip-Gram模型的输出不是周围词的概率了,是正例和负例的概率

In [4]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
USE_CUDA = torch.cuda.is_available() #有GPU可以用

# 为了保证实验结果可以复现,我们经常会把各种random seed固定在某一个值
random.seed(53113)
np.random.seed(53113)
torch.manual_seed(53113)
if USE_CUDA:
torch.cuda.manual_seed(53113)

# 设定一些超参数
K = 10 # number of negative samples 负样本随机采样数量
C = 3 # nearby words threshold 指定周围三个单词进行预测
NUM_EPOCHS = 2 # The number of epochs of training 迭代轮数
MAX_VOCAB_SIZE = 30000 # the vocabulary size 词汇表多大
BATCH_SIZE = 128 # the batch size 每轮迭代1个batch的数量
LEARNING_RATE = 0.2 # the initial learning rate #学习率
EMBEDDING_SIZE = 100 #词向量维度


LOG_FILE = "word-embedding.log"

# tokenize函数,把一篇文本转化成一个个单词
def word_tokenize(text):
return text.split()
  • 从文本文件中读取所有的文字,通过这些文本创建一个vocabulary
  • 由于单词数量可能太大,我们只选取最常见的MAX_VOCAB_SIZE个单词
  • 我们添加一个UNK单词表示所有不常见的单词
  • 我们需要记录单词到index的mapping,以及index到单词的mapping,单词的count,单词的(normalized) frequency,以及单词总数。

In [5]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
with open("./text8/text8.train.txt", "r") as fin: #读入文件
text = fin.read() # 一次性读入文件所有内容

text = [w for w in word_tokenize(text.lower())]
#分词,在这里类似于text.split()
#print(len(text)) # 15313011,有辣么多单词

vocab = dict(Counter(text).most_common(MAX_VOCAB_SIZE-1))
#字典格式,把(MAX_VOCAB_SIZE-1)个最频繁出现的单词取出来,-1是留给不常见的单词
#print(len(vocab)) # 29999

vocab["<unk>"] = len(text) - np.sum(list(vocab.values()))
#unk表示不常见单词数=总单词数-常见单词数
# print(vocab["<unk>"]) # 617111
print(vocab["<unk>"])
idx_to_word = [word for word in vocab.keys()]
#取出字典的所有最常见30000单词

word_to_idx = {word:i for i, word in enumerate(idx_to_word)}
#取出所有单词的单词和对应的索引,索引值与单词出现次数相反,最常见单词索引为0。
1
617111

In [1]:

1
#print(vocab)

In [2]:

1
2
#print(list(word_to_idx.items())[29900:]) 
# 敲黑板:字典是怎么像列表那样切片的

In [9]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
word_counts = np.array([count for count in vocab.values()], dtype=np.float32)
#vocab所有单词的频数values

word_freqs = word_counts / np.sum(word_counts)
#所有单词的词频概率值
# print(np.sum(word_freqs))=1

word_freqs = word_freqs ** (3./4.)
#论文里乘以3/4次方
# print(np.sum(word_freqs)) = 7.7

word_freqs = word_freqs / np.sum(word_freqs) # 用来做 negative sampling
# 重新计算所有单词的频率,老师这里代码好像写错了
# print(np.sum(word_freqs)) = 1

VOCAB_SIZE = len(idx_to_word) #词汇表单词数30000=MAX_VOCAB_SIZE
VOCAB_SIZE

Out[9]:

1
30000

实现Dataloader

一个dataloader需要以下内容:

  • 把所有text编码成数字,然后用subsampling预处理这些文字。
  • 保存vocabulary,单词count,normalized word frequency
  • 每个iteration sample一个中心词
  • 根据当前的中心词返回context单词
  • 根据中心词sample一些negative单词
  • 返回单词的counts

这里有一个好的tutorial介绍如何使用PyTorch dataloader. 为了使用dataloader,我们需要定义以下两个function:

  • __len__ function需要返回整个数据集中有多少个item
  • __get__ 根据给定的index返回一个item

有了dataloader之后,我们可以轻松随机打乱整个数据集,拿到一个batch的数据等等。

torch.utils.data.DataLoader理解:https://blog.csdn.net/qq_36653505/article/details/83351808

In [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
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
class WordEmbeddingDataset(tud.Dataset): #tud.Dataset父类
def __init__(self, text, word_to_idx, idx_to_word, word_freqs, word_counts):
''' text: a list of words, all text from the training dataset
word_to_idx: the dictionary from word to idx
idx_to_word: idx to word mapping
word_freq: the frequency of each word
word_counts: the word counts
'''
super(WordEmbeddingDataset, self).__init__() #初始化模型
self.text_encoded = [word_to_idx.get(t, VOCAB_SIZE-1) for t in text]
#字典 get() 函数返回指定键的值(第一个参数),如果值不在字典中返回默认值(第二个参数)。
#取出text里每个单词word_to_idx字典里对应的索引,不在字典里返回"<unk>"的索引=29999
# 这样text里的所有词都编码好了,从单词转化为了向量,
# 共有15313011个单词,词向量取值范围是0~29999,0是最常见单词向量。

self.text_encoded = torch.Tensor(self.text_encoded).long()
#变成tensor类型,这里变成longtensor,也可以torch.LongTensor(self.text_encoded)

self.word_to_idx = word_to_idx #保存数据
self.idx_to_word = idx_to_word #保存数据
self.word_freqs = torch.Tensor(word_freqs) #保存数据
self.word_counts = torch.Tensor(word_counts) #保存数据

def __len__(self): #数据集有多少个item
#魔法函数__len__
''' 返回整个数据集(所有单词)的长度
'''
return len(self.text_encoded) #所有单词的总数

def __getitem__(self, idx):
#魔法函数__getitem__,这个函数跟普通函数不一样
''' 这个function返回以下数据用于训练
- 中心词
- 这个单词附近的(positive)单词
- 随机采样的K个单词作为negative sample
'''
center_word = self.text_encoded[idx]
#中心词
#这里__getitem__函数是个迭代器,idx代表了所有的单词索引。

pos_indices = list(range(idx-C, idx)) + list(range(idx+1, idx+C+1))
#周围词的索引,比如idx=0时。pos_indices = [-3, -2, -1, 1, 2, 3]


pos_indices = [i%len(self.text_encoded) for i in pos_indices]
#超出词汇总数时,需要特别处理,取余数,比如pos_indices = [15313009,15313010,15313011,1,2,3]

pos_words = self.text_encoded[pos_indices]
#周围词,就是希望出现的正例单词

neg_words = torch.multinomial(self.word_freqs, K * pos_words.shape[0], True)
#负例采样单词,torch.multinomial作用是按照self.word_freqs的概率做K * pos_words.shape[0]次取值,
#输出的是self.word_freqs对应的下标。取样方式采用有放回的采样,并且self.word_freqs数值越大,取样概率越大。
#每个正确的单词采样K个,pos_words.shape[0]是正确单词数量=6

return center_word, pos_words, neg_words

创建dataset和dataloader

In [11]:

1
2
dataset = WordEmbeddingDataset(text, word_to_idx, idx_to_word, word_freqs, word_counts)
# list(dataset) 可以把尝试打印下center_word, pos_words, neg_words看看

torch.utils.data.DataLoader理解:https://blog.csdn.net/qq_36653505/article/details/83351808

In [12]:

1
dataloader = tud.DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4)

In [13]:

1
2
3
print(next(iter(dataloader))[0].shape) # 一个batch中间词维度
print(next(iter(dataloader))[1].shape) # 一个batch周围词维度
print(next(iter(dataloader))[2].shape) # 一个batch负样本维度
1
2
3
torch.Size([128])
torch.Size([128, 6])
torch.Size([128, 60])

定义PyTorch模型

In [14]:

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
class EmbeddingModel(nn.Module):
def __init__(self, vocab_size, embed_size):
''' 初始化输出和输出embedding
'''
super(EmbeddingModel, self).__init__()
self.vocab_size = vocab_size #30000
self.embed_size = embed_size #100

initrange = 0.5 / self.embed_size
self.out_embed = nn.Embedding(self.vocab_size, self.embed_size, sparse=False)
#模型输出nn.Embedding(30000, 100)
self.out_embed.weight.data.uniform_(-initrange, initrange)
#权重初始化的一种方法


self.in_embed = nn.Embedding(self.vocab_size, self.embed_size, sparse=False)
#模型输入nn.Embedding(30000, 100)
self.in_embed.weight.data.uniform_(-initrange, initrange)
#权重初始化的一种方法


def forward(self, input_labels, pos_labels, neg_labels):
'''
input_labels: 中心词, [batch_size]
pos_labels: 中心词周围 context window 出现过的单词 [batch_size * (window_size * 2)]
neg_labelss: 中心词周围没有出现过的单词,从 negative sampling 得到 [batch_size, (window_size * 2 * K)]

return: loss, [batch_size]
'''

batch_size = input_labels.size(0) #input_labels是输入的标签,tud.DataLoader()返回的。已经被分成batch了。

input_embedding = self.in_embed(input_labels)
# B * embed_size
#这里估计进行了运算:(128,30000)*(30000,100)= 128(Batch) * 100 (embed_size)

pos_embedding = self.out_embed(pos_labels) # B * (2*C=6) * embed_size
# 这里估计进行了运算:(128,6,30000)*(128,30000,100)= 128(Batch) * 6 * 100 (embed_size)
#同上,增加了维度(2*C),表示一个batch有128组周围词单词,一组周围词有(2*C)个单词,每个单词有embed_size个维度。

neg_embedding = self.out_embed(neg_labels) # B * (2*C * K) * embed_size
#同上,增加了维度(2*C*K)


#torch.bmm()为batch间的矩阵相乘(b,n.m)*(b,m,p)=(b,n,p)
log_pos = torch.bmm(pos_embedding, input_embedding.unsqueeze(2)).squeeze() # B * (2*C)
# log_pos = (128,6,100)*(128,100,1) = (128,6,1) = (128,6)
# 这里如果没有负采样,只有周围单词来训练的话,每个周围单词30000个one-hot向量的维度
# 而负采样大大降低了维度,每个周围单词仅仅只有一个维度。每个样本输出共有2*C个维度
log_neg = torch.bmm(neg_embedding, -input_embedding.unsqueeze(2)).squeeze() # B * (2*C*K)
# log_neg = (128,6*K,100)*(128,100,1) = (128,6*K,1) = (128,6*K),注意这里有个负号,区别与正样本
# unsqueeze(2)指定位置升维,.squeeze()压缩维度。
# 而负采样降低了维度,每个负例单词仅仅只有一个维度,每个样本输出共有2*C*K个维度

#下面loss计算就是论文里的公式
log_pos = F.logsigmoid(log_pos).sum(1)
log_neg = F.logsigmoid(log_neg).sum(1) # batch_size
loss = log_pos + log_neg # 正样本损失和负样本损失和尽量最大

return -loss # 最大转化成最小

#取出self.in_embed数据参数,维度:(30000,100),就是我们要训练的词向量
# 这里本来模型训练有两个矩阵的,self.in_embed和self.out_embed两个
# 只是作者认为输入矩阵比较好,就舍弃了输出矩阵。
def input_embeddings(self):
return self.in_embed.weight.data.cpu().numpy()

定义一个模型以及把模型移动到GPU

In [15]:

1
2
3
4
5
model = EmbeddingModel(VOCAB_SIZE, EMBEDDING_SIZE)
#得到model,有参数,有loss,可以优化了

if USE_CUDA:
model = model.cuda()

In [28]:

1
data.iloc[:, 0:2].index

Out[28]:

1
RangeIndex(start=0, stop=353, step=1)

In [ ]:

1
2
3
4
5
6
7
8
data = pd.read_csv("wordsim353.csv", sep=",")
# else:
# data = pd.read_csv("simlex-999.txt", sep="\t")
print(data.head())
human_similarity = []
model_similarity = []
for i in data.iloc[:, 0:2].index:
print(i)

下面是评估模型的代码,以及训练模型的代码

In [16]:

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
def evaluate(filename, embedding_weights): 
# 传入的有三个文件课选择,两个txt,一个csv,可以先自己打开看看
# embedding_weights是训练之后的embedding向量。
if filename.endswith(".csv"):
data = pd.read_csv(filename, sep=",") # csv文件打开
else:
data = pd.read_csv(filename, sep="\t") # txt文件打开,以\t制表符分割
human_similarity = []
model_similarity = []
for i in data.iloc[:, 0:2].index: # 这里只是取出行索引,用data.index也可以
word1, word2 = data.iloc[i, 0], data.iloc[i, 1] # 依次取出每行的2个单词
if word1 not in word_to_idx or word2 not in word_to_idx:
# 如果取出的单词不在我们建的30000万个词汇表,就舍弃,评估不了
continue
else:
word1_idx, word2_idx = word_to_idx[word1], word_to_idx[word2]
# 否则,分别取出这两个单词对应的向量,
word1_embed, word2_embed = embedding_weights[[word1_idx]], embedding_weights[[word2_idx]]
# 在分别取出这两个单词对应的embedding向量,具体为啥是这种取出方式[[word1_idx]],可以自行研究
model_similarity.append(float(sklearn.metrics.pairwise.cosine_similarity(word1_embed, word2_embed)))
# 用余弦相似度计算这两个100维向量的相似度。这个是模型算出来的相似度
human_similarity.append(float(data.iloc[i, 2]))
# 这个是人类统计得到的相似度

return scipy.stats.spearmanr(human_similarity, model_similarity)# , model_similarity
# 因为相似度是浮点数,不是0 1 这些固定标签值,所以不能用准确度评估指标
# scipy.stats.spearmanr网址:https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.spearmanr.html
# scipy.stats.spearmanr评估两个分布的相似度,有两个返回值correlation, pvalue
# correlation是评估相关性的指标(-1,1),越接近1越相关,pvalue值大家可以自己搜索理解

训练模型:

  • 模型一般需要训练若干个epoch
  • 每个epoch我们都把所有的数据分成若干个batch
  • 把每个batch的输入和输出都包装成cuda tensor
  • forward pass,通过输入的句子预测每个单词的下一个单词
  • 用模型的预测和正确的下一个单词计算cross entropy loss
  • 清空模型当前gradient
  • backward pass
  • 更新模型参数
  • 每隔一定的iteration输出模型在当前iteration的loss,以及在验证数据集上做模型的评估

In [17]:

1
2
3
4
for i, (input_labels, pos_labels, neg_labels) in enumerate(dataloader):
print(input_labels.shape, pos_labels.shape, neg_labels.shape)
if i>5:
break
1
2
3
4
5
6
7
torch.Size([128]) torch.Size([128, 6]) torch.Size([128, 60])
torch.Size([128]) torch.Size([128, 6]) torch.Size([128, 60])
torch.Size([128]) torch.Size([128, 6]) torch.Size([128, 60])
torch.Size([128]) torch.Size([128, 6]) torch.Size([128, 60])
torch.Size([128]) torch.Size([128, 6]) torch.Size([128, 60])
torch.Size([128]) torch.Size([128, 6]) torch.Size([128, 60])
torch.Size([128]) torch.Size([128, 6]) torch.Size([128, 60])

In [ ]:

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
optimizer = torch.optim.SGD(model.parameters(), lr=LEARNING_RATE)
#随机梯度下降

for e in range(NUM_EPOCHS): #开始迭代
for i, (input_labels, pos_labels, neg_labels) in enumerate(dataloader):
#print(input_labels, pos_labels, neg_labels)

# TODO
input_labels = input_labels.long() #longtensor
pos_labels = pos_labels.long()
neg_labels = neg_labels.long()
if USE_CUDA: # 变成cuda类型
input_labels = input_labels.cuda()
pos_labels = pos_labels.cuda()
neg_labels = neg_labels.cuda()

#下面第一节课都讲过的
optimizer.zero_grad() #梯度归零
loss = model(input_labels, pos_labels, neg_labels).mean()
# model返回的是一个batch所有样本的损失,需要求个平均

loss.backward()
optimizer.step()

#打印结果。
if i % 100 == 0:
with open(LOG_FILE, "a") as fout: # 写进日志文件,LOG_FILE前面定义了
fout.write("epoch: {}, iter: {}, loss: {}\n".format(e, i, loss.item()))
print("epoch: {}, iter: {}, loss: {}".format(e, i, loss.item()))
# 训练过程,我没跑,本地肯定跑不动的


if i % 2000 == 0: # 每过2000个batch就评估一次效果
embedding_weights = model.input_embeddings()
# 取出(30000,100)训练的词向量
sim_simlex = evaluate("simlex-999.txt", embedding_weights)
sim_men = evaluate("men.txt", embedding_weights)
sim_353 = evaluate("wordsim353.csv", embedding_weights)
with open(LOG_FILE, "a") as fout:
print("epoch: {}, iteration: {}, simlex-999: {}, men: {}, sim353: {}, nearest to monster: {}\n".format(
e, i, sim_simlex, sim_men, sim_353, find_nearest("monster")))
fout.write("epoch: {}, iteration: {}, simlex-999: {}, men: {}, sim353: {}, nearest to monster: {}\n".format(
e, i, sim_simlex, sim_men, sim_353, find_nearest("monster")))

embedding_weights = model.input_embeddings() # 调用最终训练好的embeding词向量
np.save("embedding-{}".format(EMBEDDING_SIZE), embedding_weights) # 保存参数
torch.save(model.state_dict(), "embedding-{}.th".format(EMBEDDING_SIZE)) # 保存参数

In [11]:

1
model.load_state_dict(torch.load("embedding-{}.th".format(EMBEDDING_SIZE))) # 加载模型

在 MEN 和 Simplex-999 数据集上做评估

In [12]:

1
2
3
4
5
# 代码同上
embedding_weights = model.input_embeddings()
print("simlex-999", evaluate("simlex-999.txt", embedding_weights))
print("men", evaluate("men.txt", embedding_weights))
print("wordsim353", evaluate("wordsim353.csv", embedding_weights))
1
2
3
simlex-999 SpearmanrResult(correlation=0.17251697429101504, pvalue=7.863946056740345e-08)
men SpearmanrResult(correlation=0.1778096817088841, pvalue=7.565661657312768e-20)
wordsim353 SpearmanrResult(correlation=0.27153702278146635, pvalue=8.842165885381714e-07)

寻找nearest neighbors

In [13]:

1
2
3
4
5
6
7
8
9
def find_nearest(word):
index = word_to_idx[word]
embedding = embedding_weights[index] # 取出这个单词的embedding向量
cos_dis = np.array([scipy.spatial.distance.cosine(e, embedding) for e in embedding_weights])
# 计算所有30000个embedding向量与传入单词embedding向量的相似度距离
return [idx_to_word[i] for i in cos_dis.argsort()[:10]] # 返回前10个最相似的

for word in ["good", "fresh", "monster", "green", "like", "america", "chicago", "work", "computer", "language"]:
print(word, find_nearest(word))
1
2
3
4
5
6
7
8
9
10
good ['good', 'bad', 'perfect', 'hard', 'questions', 'alone', 'money', 'false', 'truth', 'experience']
fresh ['fresh', 'grain', 'waste', 'cooling', 'lighter', 'dense', 'mild', 'sized', 'warm', 'steel']
monster ['monster', 'giant', 'robot', 'hammer', 'clown', 'bull', 'demon', 'triangle', 'storyline', 'slogan']
green ['green', 'blue', 'yellow', 'white', 'cross', 'orange', 'black', 'red', 'mountain', 'gold']
like ['like', 'unlike', 'etc', 'whereas', 'animals', 'soft', 'amongst', 'similarly', 'bear', 'drink']
america ['america', 'africa', 'korea', 'india', 'australia', 'turkey', 'pakistan', 'mexico', 'argentina', 'carolina']
chicago ['chicago', 'boston', 'illinois', 'texas', 'london', 'indiana', 'massachusetts', 'florida', 'berkeley', 'michigan']
work ['work', 'writing', 'job', 'marx', 'solo', 'label', 'recording', 'nietzsche', 'appearance', 'stage']
computer ['computer', 'digital', 'electronic', 'audio', 'video', 'graphics', 'hardware', 'software', 'computers', 'program']
language ['language', 'languages', 'alphabet', 'arabic', 'grammar', 'pronunciation', 'dialect', 'programming', 'chinese', 'spelling']

单词之间的关系

In [14]:

1
2
3
4
5
6
7
man_idx = word_to_idx["man"] 
king_idx = word_to_idx["king"]
woman_idx = word_to_idx["woman"]
embedding = embedding_weights[woman_idx] - embedding_weights[man_idx] + embedding_weights[king_idx]
cos_dis = np.array([scipy.spatial.distance.cosine(e, embedding) for e in embedding_weights])
for i in cos_dis.argsort()[:20]:
print(idx_to_word[i])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
king
henry
charles
pope
queen
iii
prince
elizabeth
alexander
constantine
edward
son
iv
louis
emperor
mary
james
joseph
frederick
francis
文章目录
  1. 1. 词向量
  2. 2. 实现Dataloader
  3. 3. 定义PyTorch模型
  • 在 MEN 和 Simplex-999 数据集上做评估
  • 寻找nearest neighbors
  • 单词之间的关系
  • |