赞
踩
基本思想:通过训练将每一个词映射成一个固定长度的向量,所有向量构成一个词向量空间,每一个向量(单词)可以看作是向量空间中的一个点,意思越相近的单词距离越近。
通常情况下,我们可以维护一个查询表。表中每一行都存储了一个特定词语的向量值,每一列的第一个元素都代表着这个词本身,以便于我们进行词和向量的映射(如“我”对应的向量值为 [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/NLP/tree/main/Word2Vec-SkipGram
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。