基本思想:通过训练将每一个词映射成一个固定长度的向量,所有向量构成一个词向量空间,每一个向量(单词)可以看作是向量空间中的一个点,意思越相近的单词距离越近。
通常情况下,我们可以维护一个查询表。表中每一行都存储了一个特定词语的向量值,每一列的第一个元素都代表着这个词本身,以便于我们进行词和向量的映射(如“我”对应的向量值为 [0.3,0.5,0.7,0.9,-0.2,0.03] )。给定任何一个或者一组单词,我们都可以通过查询这个excel,实现把单词转换为向量的目的,这个查询和替换过程称之为Embedding Lookup。
然而在进行神经网络计算的过程中,需要大量的算力,常常要借助特定硬件(如GPU)满足训练速度的需求。GPU上所支持的计算都是以张量(Tensor)为单位展开的,因此在实际场景中,我们需要把Embedding Lookup的过程转换为张量计算:
在自然语言处理研究中,科研人员通常有一个共识:使用一个单词的上下文来了解这个单词的语义,比如:
“苹果手机质量不错,就是价格有点贵。”
“这个苹果很好吃,非常脆。”
“菠萝质量也还行,但是不如苹果支持的APP多。”
在上面的句子中,我们通过上下文可以推断出第一个“苹果”指的是苹果手机,第二个“苹果”指的是水果苹果,而第三个“菠萝”指的应该也是一个手机。在自然语言处理领域,使用上下文描述一个词语或者元素的语义是一个常见且有效的做法。我们可以使用同样的方式训练词向量,让这些词向量具备表示语义信息的能力。
2013年,Mikolov提出的经典word2vec算法就是通过上下文来学习语义信息。word2vec包含两个经典模型:CBOW(Continuous Bag-of-Words)和Skip-gram。
CBOW:通过上下文的词向量推理中心词。
Skip-gram:根据中心词推理上下文。
输入层: 一个形状为C×V的one-hot张量,其中C代表上下文中词的个数,通常是一个偶数,我们假设为4;V表示词表大小,我们假设为5000,该张量的每一行都是一个上下文词的one-hot向量表示。
隐藏层: 一个形状为V×N的参数张量W1,一般称为word-embedding,N表示每个词的词向量长度,我们假设为128。输入张量和word embedding W1进行矩阵乘法,就会得到一个形状为C×N的张量。综合考虑上下文中所有词的信息去推理中心词,因此将上下文中C个词相加得一个1×N的向量,是整个上下文的一个隐含表示。
输出层: 创建另一个形状为N×V的参数张量,将隐藏层得到的1×N的向量乘以该N×V的参数张量,得到了一个形状为1×V的向量。最终,1×V的向量代表了使用上下文去推理中心词,每个候选词的打分,再经过softmax函数的归一化,即得到了对中心词的推理概率:
**Input Layer(输入层):**接收一个one-hot张量 V∈R1×vocab_size
作为网络的输入,假设vocab_size为5000。
**Hidden Layer(隐藏层):**将张量V乘以一个word embedding张量W1∈Rvocab_size×embed_size ,假设embed_size为128,并把结果作为隐藏层的输出,得到一个形状为R1×embed_size的张量,里面存储着当前句子中心词的词向量。
**Output Layer(输出层):**将隐藏层的结果乘以另一个word embedding张量W2∈Rembed_size×vocab_size,得到一个形状为R1×vocab_size的张量。这个张量经过softmax变换后,就得到了使用当前中心词对上下文的预测结果。根据这个softmax的结果,我们就可以去训练词向量模型。
Skip-gram在实际操作中,使用一个滑动窗口(一般情况下,长度是奇数),从左到右开始扫描当前句子。每个扫描出来的片段被当成一个小句子,每个小句子中间的词被认为是中心词,其余的词被认为是这个中心词的上下文。
首先下载数据集处理语料,
# 下载语料用来训练word2vec
download()
# 读取text8数据
corpus = load_text8()
# 对语料进行预处理(分词)并把所有英文字符都转换为小写
corpus = data_preprocess(corpus)
# 构造词典,统计每个词的频率,并根据频率将每个词转换为一个整数id
word2id_freq, word2id_dict, id2word_dict = build_dict(corpus)
vocab_size = len(word2id_freq)
#保存word2id_freq, word2id_dict, id2word_dict到本地
f_save = open('word2id_freq.pkl', 'wb')
pickle.dump(word2id_freq, f_save)
f_save.close()
f_save = open('word2id_dict.pkl', 'wb')
pickle.dump(word2id_dict, f_save)
f_save.close()
f_save = open('id2word_dict.pkl', 'wb')
pickle.dump(id2word_dict, f_save)
f_save.close()
# 把语料转换为id序列
corpus = convert_corpus_to_id(corpus, word2id_dict)
# 使用二次采样算法(subsampling)处理语料,强化训练效果,遗弃频率高的词
corpus = subsampling(corpus, word2id_freq)
构造数据,假设有一个中心词c和一个上下文词正样本tp。在Skip-gram的理想实现里,需要最大化使用c推理tp的概率。在使用softmax学习时,需要最大化tp的推理概率,同时最小化其他词表中词的推理概率。之所以计算缓慢,是因为需要对词表中的所有词都计算一遍。然而我们还可以使用另一种方法,就是随机从词表中选择几个代表词,通过最小化这几个代表词的概率,去近似最小化整体的预测概率。比如,先指定一个中心词(如“人工”)和一个目标词正样本(如“智能”),再随机在词表中采样几个目标词负样本(如“日本”,“喝茶”等)。对于目标词正样本,我们需要最大化它的预测概率;对于目标词负样本,我们需要最小化它的预测概率。通过这种方式,我们就可以完成计算加速。上述做法,我们称之为负采样。实现如下:
# 构造数据,准备模型训练
# max_window_size代表了最大的window_size的大小,程序会根据max_window_size从左到右扫描整个语料
# negative_sample_num代表了对于每个正样本,我们需要随机采样多少负样本用于训练,
# 一般来说,negative_sample_num的值越大,训练效果越稳定,但是训练速度越慢。
def build_data(corpus, word2id_dict, word2id_freq, max_window_size=3, negative_sample_num=4):
# 使用一个list存储处理好的数据
dataset = []
# 从左到右,开始枚举每个中心点的位置
for center_word_idx in range(len(corpus)):
# 以max_window_size为上限,随机采样一个window_size,这样会使得训练更加稳定
window_size = random.randint(1, max_window_size)
# 当前的中心词就是center_word_idx所指向的词
center_word = corpus[center_word_idx]
# 以当前中心词为中心,左右两侧在window_size内的词都可以看成是正样本
positive_word_range = (
max(0, center_word_idx - window_size), min(len(corpus) - 1, center_word_idx + window_size))
positive_word_candidates = [corpus[idx] for idx in range(positive_word_range[0], positive_word_range[1] + 1) if
idx != center_word_idx]
# 对于每个正样本来说,随机采样negative_sample_num个负样本,用于训练
for positive_word in positive_word_candidates:
# 首先把(中心词,正样本,label=1)的三元组数据放入dataset中,
# 这里label=1表示这个样本是个正样本
dataset.append((center_word, positive_word, 1))
# 开始负采样
i = 0
while i < negative_sample_num:
negative_word_candidate = random.randint(0, vocab_size - 1)
if negative_word_candidate not in positive_word_candidates:
# 把(中心词,正样本,label=0)的三元组数据放入dataset中,
# 这里label=0表示这个样本是个负样本
dataset.append((center_word, negative_word_candidate, 0))
i += 1
return dataset
import paddle
from paddle.nn import Embedding
import paddle.nn.functional as F
import paddle.nn as nn
#定义skip-gram训练网络结构
#使用paddlepaddle的2.0.0版本
#一般来说,在使用paddle训练的时候,我们需要通过一个类来定义网络结构,这个类继承了paddle.nn.layer
class SkipGram(nn.Layer):
def __init__(self, vocab_size, embedding_size, init_scale=0.1):
# vocab_size定义了这个skipgram这个模型的词表大小
# embedding_size定义了词向量的维度是多少
# init_scale定义了词向量初始化的范围,一般来说,比较小的初始化范围有助于模型训练
super(SkipGram, self).__init__()
self.vocab_size = vocab_size
self.embedding_size = embedding_size
# 使用Embedding函数构造一个词向量参数
# 这个参数的大小为:[self.vocab_size, self.embedding_size]
# 数据类型为:float32
# 这个参数的初始化方式为在[-init_scale, init_scale]区间进行均匀采样
self.embedding = Embedding(
num_embeddings = self.vocab_size,
embedding_dim = self.embedding_size,
weight_attr=paddle.ParamAttr(
initializer=paddle.nn.initializer.Uniform(
low=-init_scale, high=init_scale)))
# 使用Embedding函数构造另外一个词向量参数
# 这个参数的大小为:[self.vocab_size, self.embedding_size]
# 这个参数的初始化方式为在[-init_scale, init_scale]区间进行均匀采样
self.embedding_out = Embedding(
num_embeddings = self.vocab_size,
embedding_dim = self.embedding_size,
weight_attr=paddle.ParamAttr(
initializer=paddle.nn.initializer.Uniform(
low=-init_scale, high=init_scale)))
# 定义网络的前向计算逻辑
# center_words是一个tensor(mini-batch),表示中心词
# target_words是一个tensor(mini-batch),表示目标词
# label是一个tensor(mini-batch),表示这个词是正样本还是负样本(用0或1表示)
# 用于在训练中计算这个tensor中对应词的同义词,用于观察模型的训练效果
def forward(self, center_words, target_words, label):
# 首先,通过self.embedding参数,将mini-batch中的词转换为词向量
# 这里center_words和eval_words_emb查询的是一个相同的参数
# 而target_words_emb查询的是另一个参数
center_words_emb = self.embedding(center_words)
target_words_emb = self.embedding_out(target_words)
# 我们通过点乘的方式计算中心词到目标词的输出概率,并通过sigmoid函数估计这个词是正样本还是负样本的概率。
word_sim = paddle.multiply(center_words_emb, target_words_emb)
word_sim = paddle.sum(word_sim, axis=-1)
word_sim = paddle.reshape(word_sim, shape=[-1])
pred = F.sigmoid(word_sim)
# 通过估计的输出概率定义损失函数,注意我们使用的是binary_cross_entropy_with_logits函数
# 将sigmoid计算和cross entropy合并成一步计算可以更好的优化,所以输入的是word_sim,而不是pred
loss = F.binary_cross_entropy_with_logits(word_sim, label)
loss = paddle.mean(loss)
# 返回前向计算的结果,飞桨会通过backward函数自动计算出反向结果。
return pred, loss
# 开始训练,定义一些训练过程中需要使用的超参数
batch_size = 512
epoch_num = 3
embedding_size = 200
step = 0
learning_rate = 0.001
# 定义一个使用word-embedding查询同义词的函数
# 这个函数query_token是要查询的词,k表示要返回多少个最相似的词,embed是我们学习到的word-embedding参数
# 我们通过计算不同词之间的cosine距离,来衡量词和词的相似度
# 具体实现如下,x代表要查询词的Embedding,Embedding参数矩阵W代表所有词的Embedding
# 两者计算Cos得出所有词对查询词的相似度得分向量,排序取top_k放入indices列表
def get_similar_tokens(query_token, k, embed):
W = embed.numpy()
x = W[word2id_dict[query_token]]
cos = np.dot(W, x) / np.sqrt(np.sum(W * W, axis=1) * np.sum(x * x) + 1e-9)
flat = cos.flatten()
indices = np.argpartition(flat, -k)[-k:]
indices = indices[np.argsort(-flat[indices])]
for i in indices:
print('for word %s, the similar word is %s' % (query_token, str(id2word_dict[i])))
# 通过我们定义的SkipGram类,来构造一个Skip-gram模型网络
skip_gram_model = SkipGram(vocab_size, embedding_size)
# 构造训练这个网络的优化器
adam = paddle.optimizer.Adam(learning_rate=learning_rate, parameters=skip_gram_model.parameters())
# 使用build_batch函数,以mini-batch为单位,遍历训练数据,并训练网络
for center_words, target_words, label in build_batch(
dataset, batch_size, epoch_num):
# 使用paddle.to_tensor,将一个numpy的tensor,转换为飞桨可计算的tensor
center_words_var = paddle.to_tensor(center_words)
target_words_var = paddle.to_tensor(target_words)
label_var = paddle.to_tensor(label)
# 将转换后的tensor送入飞桨中,进行一次前向计算,并得到计算结果
pred, loss = skip_gram_model(
center_words_var, target_words_var, label_var)
# 程序自动完成反向计算
loss.backward()
# 程序根据loss,完成一步对参数的优化更新
adam.step()
# 清空模型中的梯度,以便于下一个mini-batch进行更新
adam.clear_grad()
# 每经过100个mini-batch,打印一次当前的loss,看看loss是否在稳定下降
step += 1
if step % 1000 == 0:
print("step %d, loss %.3f" % (step, loss.numpy()[0]))
# 每隔10000步,打印一次模型对以下查询词的相似词,这里我们使用词和词之间的向量点积作为衡量相似度的方法,只打印了5个最相似的词并保存网络模型参数和优化器模型参数
if step % 10000 == 0:
get_similar_tokens('movie', 5, skip_gram_model.embedding.weight)
get_similar_tokens('one', 5, skip_gram_model.embedding.weight)
get_similar_tokens('chip', 5, skip_gram_model.embedding.weight)
paddle.save(skip_gram_model.state_dict(), "text8.pdparams")
paddle.save(adam.state_dict(), "adam.pdopt")
这里step到300000我就结束训练了,可以看到词性已经很接近了。
训练完成后,对任意词都可以基于我们训练出模型计算出跟这个词最接近的词。
# 定义一些训练过程中需要使用的超参数
embedding_size = 200
learning_rate = 0.001
# 通过我们定义的SkipGram类,来构造一个Skip-gram模型网络
skip_gram_model = SkipGram(vocab_size, embedding_size)
# 构造训练这个网络的优化器
adam = paddle.optimizer.Adam(learning_rate=learning_rate, parameters=skip_gram_model.parameters())
# 加载网络模型和优化器模型
layer_state_dict = paddle.load("./my_model/text8.pdparams")
opt_state_dict = paddle.load("./my_model/adam.pdopt")
skip_gram_model.set_state_dict(layer_state_dict)
adam.set_state_dict(opt_state_dict)
# get_similar_tokens('movie', 5, skip_gram_model.embedding.weight)
# get_similar_tokens('one', 5, skip_gram_model.embedding.weight)
# get_similar_tokens('chip', 5, skip_gram_model.embedding.weight)
get_similar_tokens('she', 5, skip_gram_model.embedding.weight)
get_similar_tokens('dog', 5, skip_gram_model.embedding.weight)
get_similar_tokens('apple', 5, skip_gram_model.embedding.weight)
get_similar_tokens('beijing', 5, skip_gram_model.embedding.weight)
结果如下,我没有完全训练完,可以看到效果还可以。
源码已经上传到GitHub上,github链接:https://github.com/fakerst/Word2Vec-SkipGram 网络有些差,后续会把训练好的模型也进行上传。