一、Word2vec算法
自从 2013 年谷歌提出 Word2vec 以来,Embedding 技术从自然语言处理领域推广到广告、搜索、图像、推荐等几乎所有深度学习的领域,成了深度学习知识框架中不可或缺的技术点。Word2vec是经典的 Embedding 方法。
1.1 CBOW 和 Skip-gram
Word2vec即“word to vector”,是一个生成对“词”的向量表达的模型。
想要训练 Word2vec 模型,我们需要准备由一组句子组成的语料库。假设其中一个长度为 T 的句子包含的词有 w1,w2……wt,并且我们假定每个词都跟其相邻词的关系最密切。
根据模型假设的不同,Word2vec 模型分为两种形式,CBOW 模型(图 3 左)和 Skip-gram 模型(图 3 右)。
图3 Word2vec的两种模型结构CBOW和Skip-gram
(1)CBOW 模型假设句子中每个词的选取都由相邻的词决定,因此我们就看到 CBOW 模型的输入是 wt周边的词,预测的输出是 wt。
CBOW是用周围词预测中心词,训练过程中其实是在从output的loss学习周围词的信息也就是embedding,但是在中间层是average的,一共预测V次;
(2)Skip-gram 模型则正好相反,它假设句子中的每个词都决定了相邻词的选取,所以你可以看到 Skip-gram 模型的输入是 wt,预测的输出是 wt周边的词。按照一般的经验,Skip-gram 模型的效果会更好一些,所以下面会以 Skip-gram 作为框架,来讲Word2vec 的模型细节。
Skip-gram是用中心词预测周围词,对每一个中心词都有K个词作为output,对一个词的预测有K次,所以能够更有效的从context中学习信息,共预测K*V次,因此,skip-gram的训练时间更长。
总结:鉴于skip-gram学习的词向量更细致。当数据量较少或者语料库中有大量低频词时,使用skip-gram学习比较合适。
1.2 Word2vec的样本怎样生成
作为一个自然语言处理的模型,训练 Word2vec 的样本当然来自于语料库,比如我们想训练一个电商网站中关键词的 Embedding 模型,那么电商网站中所有物品的描述文字就是很好的语料库。
我们从语料库中抽取一个句子,选取一个长度为 2c+1(目标词前后各选 c 个词)的滑动窗口,将滑动窗口由左至右滑动,每移动一次,窗口中的词组就形成了一个训练样本。根据 Skip-gram 模型(中心词决定了它的相邻词),就可以根据这个训练样本定义出 Word2vec 模型的输入和输出,输入是样本的中心词,输出是所有的相邻词。
【example】:这里选取了“Embedding 技术对深度学习推荐系统的重要性”作为句子样本。
(1)我们对它进行分词、去除停用词的过程,生成词序列;
(2)再选取大小为 3 的滑动窗口从头到尾依次滑动生成训练样本;
(3)然后我们把中心词当输入,边缘词做输出,就得到了训练 Word2vec 模型可用的训练样本。
图4 生成Word2vec训练样本的例子
1.3 Word2vec模型结构
它的结构本质上就是一个三层的神经网络(如图 5)
图5 Word2vec模型的结构
它的输入层和输出层的维度都是 V,这个 V 其实就是语料库词典的大小。假设语料库一共使用了 10000 个词,那么 V 就等于 10000。根据图 4 生成的训练样本:
(1)输入向量自然就是由输入词转换而来的 One-hot 编码向量;
(2)输出向量则是由多个输出词转换而来的 Multi-hot 编码向量。
显然,基于 Skip-gram 框架的 Word2vec 模型解决的是一个多分类问题。
隐层的维度是 N,N 的选择就需要一定的调参能力了,需要对模型的效果和模型的复杂度进行权衡,来决定最后 N 的取值,并且最终每个词的 Embedding 向量维度也由 N 来决定。
激活函数:注意隐层神经元是没有激活函数的,或者说采用了输入即输出的恒等函数作为激活函数,而输出层神经元采用了 softmax 作为激活函数。为什么要这样设置 Word2vec 的神经网络,为什么要这样选择激活函数呢?因为这个神经网络其实是为了表达从输入向量到输出向量的这样的一个条件概率关系:
这个由输入词 WI 预测输出词 WO 的条件概率,其实就是 Word2vec 神经网络要表达的东西。通过极大似然的方法去最大化这个条件概率,就能够让相似的词的内积距离更接近,这就是我们希望 Word2vec 神经网络学到的。
如果你对数学和机器学习的底层理论没那么感兴趣的话,也不用太深入了解这个公式的由来,因为现在大多数深度学习平台都把它们封装好了,你不需要去实现损失函数、梯度下降的细节,你只要大概清楚他们的概念就可以了。
【注意】Word2vec 还有很多值得挖掘的东西,比如
为了节约训练时间,Word2vec 经常会采用负采样(Negative Sampling)或者分层 softmax(Hierarchical Softmax)的训练方法。
关于这一点,推荐阅读《Word2vec Parameter Learning Explained》这篇文章,有最详细和准确的解释。
二、把词向量从 Word2vec 模型中提取出来
在训练完 Word2vec 的神经网络之后,我们想得到每个词对应的 Embedding 向量,这个 Embedding 在哪呢?其实它就藏在输入层到隐层的权重矩阵 W V × N W_{V\times N}W
V×N
中。
图中橙色的部分,Embedding Matrix。
图6 词向量藏在Word2vec的权重矩阵中
这个W V × N W_{V\times N}W
V×N
的意思是W权重矩阵,下角标是(VxN),也就是输入层和隐藏层的权重矩阵吧! 然后我们一直在说embedding,那么embedding在哪儿呢? 如果看一下维度的话,我们输入是一个10000维的词的one-hot编码,那么这里的V就是10000,我们的输入应该是VxV的,那么我们的隐藏层有N个神经元,那么我们的权重矩阵不就是VxN的咯?而我们在python代码里运行torch.nn.Embedding()时候,第一个参数是输入维度,第二个参数是隐藏层维度,所以也就是说 我们习惯取这样的输入和隐藏层之间的权重矩阵为我们的Embedding矩阵。
输入向量矩阵 W V × N W_{V\times N}W
V×N
的每一个行向量对应的就是我们要找的“词向量”(即上图中橙色矩阵中的每行深色橙色向量)。
比如我们要找词典里第 i 个词对应的 Embedding,因为输入向量是采用 One-hot 编码的,所以输入向量的第 i 维就应该是 1,那么输入向量矩阵 W V × N W_{V\times N}W
V×N
中第 i 行的行向量自然就是该词的 Embedding 。
输出向量矩阵 W′ 也遵循这个道理,确实是这样的,但一般来说,我们还是习惯于使用输入向量矩阵(即这里的W V × N W_{V\times N}W
V×N
)作为词向量矩阵。
2.1 提取词向量
在实际的使用过程中,我们往往会把输入向量矩阵转换成词向量查找表(Lookup table,如图 7 所示)。例如,输入向量是 10000 个词组成的 One-hot 向量,隐层维度是 300 维,那么输入层到隐层的权重矩阵为 10000x300 维。在转换为词向量 Lookup table 后,每行的权重即成了对应词的 Embedding 向量。如果我们把这个查找表存储到线上的数据库中,就可以轻松地在推荐物品的过程中使用 Embedding 去计算相似性等重要的特征了。
图7 Word2vec的Lookup table
2.2 Word2vec 对 Embedding 技术的意义
Word2vec 是由谷歌于 2013 年正式提出的,其实它并不完全是原创性的,学术界对词向量的研究可以追溯到 2003 年,甚至更早的时期。但正是谷歌对 Word2vec 的成功应用,让词向量的技术得以在业界迅速推广,进而使 Embedding 这一研究话题成为热点。Word2vec 对深度学习时代 Embedding 方向的研究具有奠基性的意义。
从另一个角度来看,Word2vec 的研究中提出的模型结构、目标函数、负采样方法、负采样中的目标函数在后续的研究中被重复使用并被屡次优化。掌握 Word2vec 中的每一个细节成了研究 Embedding 的基础。
三、代码实践
这里继续天池的入门赛题——新闻分类。
用10折交叉验证,将数据分为10份,9份训练1份验证。
(1)用到了训练好的词向量文件:词向量下载链接: https://pan.baidu.com/s/1ewlck3zwXVQuAzraZ26Euw 提取码: qbpr
(2)all_data2fold函数的作用:针对类别不均衡问题【task1中看到的有的类别数据多,有的类别数据少】,所有类别进行平均分为10份再放在一起,这样其中任意9份做训练集,另外一份做测试集,的到的模型鲁棒性会更好。
(3)注意要将Word2vec函数的参数size改为vector_size。
# -*- coding: utf-8 -*- """ Created on Wed Nov 10 20:08:40 2021 @author: 86493 """ import logging import random import numpy as np import torch logging.basicConfig(level=logging.INFO, format='%(asctime)-15s %(levelname)s: %(message)s') # set seed seed = 666 random.seed(seed) np.random.seed(seed) torch.cuda.manual_seed(seed) torch.manual_seed(seed) # split data to 10 fold fold_num = 10 data_file = 'train_set.csv' import pandas as pd def all_data2fold(fold_num, num=10000): fold_data = [] f = pd.read_csv(data_file, sep='\t', encoding='UTF-8') # 只取前10000条数据 原来是的shape(200000,2) texts = f['text'].tolist()[:num] # texts,lables都变成了list,里面有10000个元素 labels = f['label'].tolist()[:num] # 总的数据量 与num相等 total = len(labels) index = list(range(total)) #打乱这个包含索引的list np.random.shuffle(index) all_texts = [] all_labels = [] # 在这个索引list里 for i in index: all_texts.append(texts[i]) # 用这些打乱的索引,去取原来的texts 和 labels 里的值, # 此时他们也相应变成了all_texts ,all_labels all_labels.append(labels[i]) # 给这个label—id建立一个字典 ,key是label,value是一个列表, # 元素是label在 all_labels中的位置索引 label2id = {} for i in range(total): label = str(all_labels[i]) if label not in label2id: label2id[label] = [i] else: # 同一个label会出现多条,所以他在all_labels中有多个索引位置 label2id[label].append(i) # 根据fold_num,建立相等长度的列表,目前列表里的元素为空。 # 即all_index是一个长度为fold_num的list,元素目前为空。 all_index = [[] for _ in range(fold_num)] # 在label-id这个字典里,data存的是label在all_labels中的位置索引, # 然后,每一个label每一个label的去循环 for label, data in label2id.items(): # print(label, len(data)) # len(data)某个label出现的次数。将每个label出现的总次数 分成fold_num份, # 每一份batch_size个数据 batch_size = int(len(data) / fold_num) # 不能整除的剩下的数据,此处的other一定小于fold_num other = len(data) - batch_size * fold_num # 某一折中,我们记为i for i in range(fold_num): # 如果i小于剩下的数据个数,batch_size就增加1,把剩下的数据依次加进每一份里 cur_batch_size = batch_size + 1 if i < other else batch_size # print(cur_batch_size) #每个label 分成fold_num份后,每一份目前的数据量 batch_data = [data[i * batch_size + b] for b in range(cur_batch_size)] #取出当前的数据,data里是label的索引位置。 i * batch_size用来跳过以前的batch_size,跳到现在这一折的位置 all_index[i].extend(batch_data)#添加到all_index的相应位置中 #all_index数据形式:[[],[],...[]]-->[[label_1_1折 expand label_2_1折 expand label_3_1折...expand label_19_1折 ],[label_1_2折 expand label_2_1折...],...[label_1_n折 expand ...]] #这样在每一折中都保证了,label的分布同原始数据一致 # 总的数据量分成fold_num份,每一份大小为batch_size batch_size = int(total / fold_num) other_texts = [] other_labels = [] other_num = 0 start = 0 for fold in range(fold_num): # num每一折的数据量 num = len(all_index[fold]) # all_texts是打乱后的text数据, all_index[fold]代表第fold折的数据 texts = [all_texts[i] for i in all_index[fold]] labels = [all_labels[i] for i in all_index[fold]] ''' 以fold=0为例, all_index[0]=[label_1_1折 expand label_2_1折 expand label_3_1折...expand label_19_1折 ] 其中,label_1_1折 代表label——1划分到1折中的数据,数据的内容是label-1在原始数据中的索引位置 data[i * batch_size + b] 所以,all_index[0] 代表是是,各个label划分到1折中的数据,数据内容是每个label在原始数据中的索引位置 然后,利用这些索引位置,在all_texts和all_labels中取到真正的数据 ''' #如果这一折的数量>batch_size if num > batch_size: # 取前batch_size个数据 fold_texts = texts[:batch_size] # 之后的数据放到 other_texts中 other_texts.extend(texts[batch_size:]) fold_labels = labels[:batch_size] # label也这样操作 other_labels.extend(labels[batch_size:]) # 统计每一折中在batch_size中放不开的数据总量 other_num += num - batch_size #如果这一折的数量<batch_size elif num < batch_size: # batch_size比num多的数据量 end = start + batch_size - num # 从others_texts里补上 fold_texts = texts + other_texts[start: end] fold_labels = labels + other_labels[start: end] # 移动在others_texts中的start位置 start = end else: fold_texts = texts fold_labels = labels # assert函数主要是用来声明某个函数是真的, # 当assert()语句失败的时候,就会引发assertError assert batch_size == len(fold_labels) # 这里将batch_size的大小与每一折的大小弄成一样的 # 数据存到fold_texts和fold_labels里 # shuffle index = list(range(batch_size)) # 打乱batch_size np.random.shuffle(index) shuffle_fold_texts = [] shuffle_fold_labels = [] for i in index: shuffle_fold_texts.append(fold_texts[i]) # 打乱索引再去取值 fold_texts和fold_labels 变成了 # shuffle_fold_texts和shuffle_fold_labels shuffle_fold_labels.append(fold_labels[i]) # 一折中的数据 data = {'label': shuffle_fold_labels, 'text': shuffle_fold_texts} # 每一折的数据现在变成了上面的data字典,然后加入到fold_data列表中。 fold_data.append(data) #打印每一折数据的大小 logging.info("Fold lens %s", str([len(data['label']) for data in fold_data])) return fold_data #fold_data = all_data2fold(10) fold_data = all_data2fold(10) # build train data for word2vec fold_id = 9 train_texts = [] for i in range(0, fold_id): data = fold_data[i] train_texts.extend(data['text']) logging.info('Total %d docs.' % len(train_texts)) logging.info('Start training...') from gensim.models.word2vec import Word2Vec # Word vector dimensionality num_features = 100 # Number of threads to run in parallel num_workers = 1 train_texts = list(map(lambda x: list(x.split()), train_texts)) model = Word2Vec(train_texts, workers=num_workers, vector_size=num_features) model.init_sims(replace=True) # save model model.save("./word2vec.bin") # load model model = Word2Vec.load("./word2vec.bin") # convert format model.wv.save_word2vec_format('./word2vec.txt', binary=False)
生成的word2vec文件: