基于CBOW网络手动实现面向中文语料的word2vec

时间:2021-06-23 00:30:50

最近在工作之余学习NLP相关的知识,对word2vec的原理进行了研究。在本篇文章中,尝试使用TensorFlow自行构建、训练出一个word2vec模型,以强化学习效果,加深理解。

.背景知识:

在深度学习实践中,传统的词汇表达方式是使用one-hot向量,其中,向量的维度等于词汇量的大小。这会导致在语料较为丰富,词汇量较大的时候,向量的维度过长,进而产生一个相当大的稀疏矩阵,占用不少内存开销,降低机器运行速度。而word2vec则为这个问题提供了一种解决方案。

word2vec是一个用来产生词向量的相关模型,使用固定长度(长度较短)的词向量来代替one-hot向量,从而降低深度学习网络中的运算复杂度。其基本思想是使用skip-gram网络/cbow网络来对语料进行训练,留下其中间过程所生成的权重矩阵作为词向量表。这个词向量表是一个[词汇量大小*词向量维度]的矩阵,在使用时,可以使用one-hot向量和词向量表做矩阵乘法,提取出对应词汇的词向量以供后续使用。

除了降低词向量的维度之外,word2vec可以使两个含义相近的词语用于更为接近的词向量(即两个词向量之间的欧式距离更接近)。因此在搭建语言模型时拥有更强大的逻辑相关性。

现在已经有一个较为方便的word2vec模块(由Google开发)供大家使用,可以通过 pip install word2vec 指令来对其进行安装使用。为加深理解,强化学习效果,这里我们不使用该模块,尝试自己搭建预训练网络来生成word2vec词向量表。

.建模思路:

这次的建模、训练流程如下图所示,大致分为语料预处理网络搭建训练存储三个阶段。

基于CBOW网络手动实现面向中文语料的word2vec

由于这次是对中文语料进行处理,因此预处理过程中较为复杂,相比处理英文语料多一个分词的步骤。此外,也没有查找到资料详细的解释word2vec是否需要对所有词汇进行训练,因此这里在预处理步骤中加入了词频统计,仅提取出10000个出现频率最高的词语作为常用词汇进行建模训练。

.语料预处理:

这里我们使用的是搜狐新闻语料库(1.5GB)来作为我们的数据源。下载地址为:http://www.sogou.com/labs/resource/cs.php

首先我们要进行语料的预处理工作。搜狐新闻语料库是一个结构化的语料数据文件,其中,新闻标题位于标签<content-title></content-title>中,新闻内容位于标签<content></content>中。第一步就是要将新闻内容从标签中提取出来。

3.1 乱码问题

在提取新闻内容时,遇到了一个比较棘手的编码问题。在这个数据文件中,大部分的新闻内容是使用GBK方式进行编码的,而其中又夹杂着某些字符使用了其他编码格式,包括Unicode,ISO-8859-1等等。为解决这个问题,这里引入datadec模块来判断字符的编码格式。最初的想法是使用datadec来判断每一个字符,使用判断出的格式来对其进行解码。但实际问题是各种编码格式所占字节数长度并不一致,如GBK是双字节编码,而UTF-8则是可变长字符编码,从1-6字节不等,如果每次读取一个固定长度来datadec其编码类型,往往会判断出错。因此,在语料预处理中,读取文件的每一行,使用datadec.detect()函数来判断该行文件的编码格式,之后用对应的格式进行解码。其中遇到串码问题无法正确解析的,在decode()函数中使用errors=’ignore’参数来对该问题进行忽略,保证程序的正常运行。但最终生成的文件还是会有部分乱码,后期会使用词频统计的方式尽量将其过滤。

解决乱码问题的关键代码如下(注意读文件时要使用二进制方式读取,即’rb’):

 pure_file = open(path[:-4]+"_pure.txt",'w',encoding='utf-8')
with codecs.open(path,'rb') as f_corp:
# TODO 去除杂质编码
count = 0 # 记录总共处理了多少行新闻文件
count1 = 0 # 记录通过非GBK编码处理了多少行新闻文件
lines = f_corp.readlines()
for word in lines:
try:
count += 1
# 此处不设置 errors='ignore' 参数,尝试使用GBK解码,遇到解析问题时跳入except处理流程。
word_out = word.decode('gbk')
pure_file.write(word_out)
except:
count1 += 1
code_name = chardet.detect(word)
if code_name['confidence']>0.90:
word_out = word.decode(code_name['encoding'],errors='ignore')
pure_file.write(word_out)
else:
continue
pure_file.close()

在解决完乱码问题之后,我们通过匹配<content>标签来提取所需要的新闻内容,使用逻辑判断的方式。

 content_file = open(path[:-4]+"_content.txt",'w',encoding='utf-8')
with open(path,'r',encoding='utf-8') as f_corp:
content_lines = f_corp.readlines()
for item in content_lines:
if item[:9]=='<content>' and len(item)>20: # 需判断长度,防止有<content></content> 的情形出现
content_file.write(item[9:-11]+'\n')
content_file.close()

最终获得纯净的新闻内容供后续使用,类似下图所示(可以看出仍有部分乱码,后期会通过词频统计进行处理)。

基于CBOW网络手动实现面向中文语料的word2vec

3.2 分词与词频统计

拿到纯净的新闻内容后,就可以进行分词与词频统计工作。这里使用jieba分词器来完成我们的分词工作。jieba分词器是一个轻量级的中文分词模块,可以使用pip install jieba指令来进行安装。注意,由于jieba是第三方python模块,因此不能够使用conda来进行安装。

考虑到后面制作训练样本时,各类词语容易与标点形成训练样本,影响训练效果。在分词之前,首先借助unicodedata模块中的category函数来去除新闻中的标点符号。

 for ss in c_lines:
sentences = ''.join(ch for ch in ss if category(ch)[0]!='P')

对每一行新闻通过上述语句进行扫描,排除掉其中的标点符号。

之后使用jieba分词器来对语句进行切分,并存入语料文件。

 words = jieba.cut(sentences,cut_all=False)
words = ' '.join(words) # 使用 空格 将每一个词汇隔开 此时是str类型变量。
array_c.append(words[:-1]) # 去除句末的 '\n'

切分后语料如下:

基于CBOW网络手动实现面向中文语料的word2vec

可以看出,在文本中仍然存在有些许乱码,这多少为后续的准确训练埋下隐患,但由各种乱码常常是孤立字符,可以通过统计常用词的方式将其进行排除。

为完成词频统计工作,我们建立两个list,array_di(记录已统计的词汇)以及array_dn(记录array_di中各对应位置词汇的出现次数)。代码逻辑大致如下:挨个扫描新闻中词汇,若该词语已在存在于array_di中,则array_dn的对应位置+1;若该词汇从未出现过,则将其添加在array_di的末尾,同时array_dn的末尾添加一个1,表示这个词汇出现了一次。

在分词与词频统计的过程中,会出现程序运行缓慢,CPU使用率较低的情况,可以使用多进程的方法分派工作,待所有子进程完工之后,再进行拼接。具体方法在我的上一篇学习笔记中有描述。(学习笔记-使用多进程、多线程加速文本内容预处理)

截取常用词的工作可以使用numpy模块的argsort()函数来对array_dn进行逆序排序,截取数值最大的10000个值所在的索引,提取出对应索引在array_di中的词汇做成字典,保存在json文件内。

 dict_list_index_last = np.array(array_dn)
word_frequent_list = np.argsort(-dict_list_index_last) # 降序排序,并获取其排序的索引顺序
d_out = dict() # TODO 最终常用词词典列表
count = 0
for index in word_frequent_list[:10000]: # 提取10000个常用词汇
d_out[array_di[index]] = count
count += 1
with open(pured_file[:-4]+'_dict.json','w',encoding='utf-8') as f_dict:
json.dump(d_out, f_dict)

最终生成的.json文件如下:

基于CBOW网络手动实现面向中文语料的word2vec

其中,key为词汇的utf-8编码,value值为其对应的位置,取值从0~9999。为后续构建初始one-hot vector作好了准备。

.网络搭建:

4.1 模型结构设计

数据预处理工作完成以后,可以开展网络结构的设计。有两种网络模型可以用来进行word2vec的训练,分别是CBOW(Continuous Bag-of-Words Model)和skip-Gram(Continuous Skip-gram Model)。这两个网络的区别主要在于训练样本的构造。

CBOW构造一个训练样本时,样本的输入为当前词汇的前n个词和后n个词,其中n表示窗口长度。例如对于句子[寒冷的 冬天 我 爱 在 学校 里 跑步],当窗口长度为2的时候,这个句子可以分解为4个训练样本,即[[寒冷的,冬天,爱,在],[我]],[[冬天,我,在,学校],[爱]],[[我,爱,学校,里],[在]],[[爱,在,里,跑步],[学校]]。其中每一个样本的前半部分为输入,后半部分为其对应的输出。

而使用skip-Gram来构造训练样本时,同样取向前n个词和向后n个词作为窗口。但输入与输出的维度是相等的,即训练样本以词对的形式来展现。同样对于[寒冷的 冬天 我 爱 在 学校 里 跑步]这个句子,窗口长度n=2。

输出词汇为[寒冷的]时,有[[寒冷的],[冬天]],[[寒冷的],[我]]两个样本,

输入词汇为[冬天]时,有[[冬天],[寒冷的]],[[冬天],[我]],[[冬天],[爱]]三个样本,

输入词汇为[我]时,有[[我], [寒冷的]],[[我], [冬天]],[[我],[爱]],[[我],[在]]这四个样本。

以此类推,这句话一共可以生成2+3+4+4+4+4+3+2=26个样本。相对于CBOW网络来说训练内容要丰富一些。

考虑到训练量过大会比较考验机器性能,这里选择使用CBOW网络来完成word2vec的训练。

现在开始考虑网络的维度结构,因为选择的是CBOW网络,所以说初始的输入是2*n个词汇(n表示窗口长度),即2*n个one-hot vector,叠加成的矩阵,由于预处理中截取的词汇量为10000,所以输入矩阵的维度为[2n*10000];同样的,由于输出仅仅只有一个词汇,所以样本的输出是一个维度为[1*10000]的one-hot vector。这里假设目标词向量的维度为300,因此词向量表的维度为[10000*300]。输入矩阵和词向量表经过矩阵乘法相乘,可以得到一个维度为[2n*300]的矩阵,即2n个词汇经过降维所得到的较短的词向量。为了使其可以正确的和样本输出计算进行对应,需将其正确的映射到[1*10000]的维度。这里使用[1*2n]×[2n*300]×[300*10000]的方法,将其转换为[1*10000]的向量,经过softmax激活函数计算,可以同样本输出计算出loss值,并根据loss使用随机梯度下降法来对网络进行训练。

网络的模型维度设计示意图如下:

基于CBOW网络手动实现面向中文语料的word2vec

如图所示,搭建此次网络模型需要初始化tar_weight,front_weight,back_weight三个权重矩阵。

根据上面设计的网络维度结果,开始构建CBOW网络类:

 import numpy as np
import tensorflow as tf class CBOW_Cell(object):
def __init__(self, window_length=5, word_dim=300):
with tf.variable_scope("matrix_scope") as matrix_scope:
self.tar_weight = tf.get_variable(name='tar_weight',shape=[10000,word_dim],\
initializer=tf.truncated_normal_initializer(stddev=0.1),dtype=tf.float32)
self.front_weight = tf.get_variable(name='front_weight',shape=[1,2*window_length],\
initializer=tf.truncated_normal_initializer(stddev=0.1),dtype=tf.float32)
self.back_weight = tf.get_variable(name='back_weight',shape=[word_dim,10000],\
initializer=tf.truncated_normal_initializer(stddev=0.1),dtype=tf.float32)
matrix_scope.reuse_variables()
# 上方为tar_weight,front_weight,back_weight 三个权重矩阵的维度设置及初始化。
# 下方为偏移量权重的设置 及 变量保存。
self.bias = tf.Variable(tf.zeros([1,10000])) # 偏移量,用于加到softmax前的输出上
self.word_dim = word_dim # 词向量维度
self.window_length = window_length
# 下方为占位符,规定好输入、输出的shape
self.sample_in = tf.placeholder(tf.float32, [2*window_length, 10000],name='sample_in')
self.sample_out = tf.placeholder(tf.float32, [1, 10000],name='sample_out')

除了上面提到的3个权重矩阵需要使用tf.get_variable()进行初始化,还额外的需要两个占位符sample_in,sample_out来表示训练样本输入及训练样本输出。

下一步来设计前向传播函数以及损失函数:

     def forward_prop(self,s_input):
step_one = tf.matmul(s_input,self.tar_weight)
out_vector = tf.matmul(tf.matmul(self.front_weight,step_one),self.back_weight)+self.bias
return out_vector def loss_func(self,lr=0.001):
out_vector = self.forward_prop(self.sample_in)
y_pre = tf.nn.softmax(out_vector,name='y_pre')
cross_entropy = tf.nn.softmax_cross_entropy_with_logits_v2(labels=self.sample_out,logits=y_pre)
train_op = tf.train.GradientDescentOptimizer(lr).minimize(cross_entropy)
return y_pre,cross_entropy,train_op

前向传播函数forward_prop()使用占位符sample_in来计算出维度为[1,10000]的输出向量。而损失函数loss_func()通过softmax来计算出前向预测结果y_pre,并通过交叉熵函数计算出损失值,train_op是定义了随机梯度下降的权重优化计算图。在后续的程序中,我们可以通过train_op来对权重进行优化。

4.2 制作训练样本

在前面分词工作结束之后,我们获得了类似[寒冷的 冬天 我 爱 在 学校 里 跑步]的语料样本。这里要根据这个语料样本,以及窗口大小n,来制作输入维度为[2n*10000],输出维度为[1*10000]的训练样本。

制作训练样本分为如下几步:①对于一条新闻来说,首先就是要确定句子的长度,如果句子包含的词语数≤2*n时,该语句无法拼接成样本,直接将其进行弃置。②对于长度充足的句子,我们取一个长度为2n+1的滑动窗口,前n个词和后n个词做成维度为[1*10000]的one-hot vector,叠加成为[2n*10000]的输入矩阵,中间词汇做成[1*10000]的one-hot vector作为样本输出。将输入和输出组成对进行输出。③为了使样本更多一些,对于一个句子开头和结束的几个词语,在目标词汇前方/后方的词语数目小于窗口长度时,顺着后方/前方窗口额外取数个词语使输入词语数目达到2n,再组成[2n*10000]的输入矩阵,将目标词汇做成one-hot vector向量作为样本输出。(第③步的样本制作方法的目标是增加训练样本数目,但该方法是否科学合理仍有待论证)

为此,编写样本制作函数如下:

 def make_samples(crop_lines_all,index_to_word,word_to_index,window_len,i):   #参数中的i指第几轮语料
# 一次处理5行语料防止内存溢出
crop_lines = crop_lines_all[i*5:(i+1)*5]
sample_in_list = [] # 输入样本list
sample_out_list = [] # 输出样本list
for line in crop_lines:
line_list = line.split(' ')
line_list = [word for word in line_list if word in index_to_word]
if len(line_list)<window_len*2+1: # 如果语句词汇过少,则抛弃这条语句
continue
else:
# 词语大于双倍窗口的情况下,可以开始拼接样本
for i2 in range(len(line_list)):
# 句子开头几个词语,前侧的词语数量不够window_len,则后侧多取一些词语攒齐2*window_len的长度
if i2<window_len+1:
temp_line_list = line_list[:i2]+line_list[i2+1:2*window_len+1]
sample_in_list.append(input_matrix_calc(word_to_index,temp_line_list))
temp_out_sample = np.zeros(10000)
temp_out_sample[word_to_index[line_list[i2]]] = 1.0
sample_out_list.append(temp_out_sample)
# 句子末尾几个词语,后侧的词语数量不够window_len,则前侧多取一些词语攒齐2*window_len的长度
elif i2>=len(line_list)-window_len:
temp_line_list = line_list[len(line_list)-2*window_len-1:i2]+line_list[i2+1:]
sample_in_list.append(input_matrix_calc(word_to_index,temp_line_list))
temp_out_sample = np.zeros(10000)
temp_out_sample[word_to_index[line_list[i2]]] = 1.0
sample_out_list.append(temp_out_sample)
# 处于中间阶段,前窗口和后窗口都不越界
else:
temp_line_list = line_list[i2-window_len:i2]+line_list[i2+1:i2+1+window_len]
sample_in_list.append(input_matrix_calc(word_to_index,temp_line_list))
temp_out_sample = np.zeros(10000)
temp_out_sample[word_to_index[line_list[i2]]] = 1.0
sample_out_list.append(temp_out_sample)
return np.array(sample_in_list),np.array(sample_out_list)

参数列中的i是用于分批制作样本的参数,每次处理5行语料,来防止内存溢出,因为一个样本对应了一个巨大的稀疏矩阵,因此每次少处理一些语料比较保险。

.训练存储:

由于需要TensorFlow的Session中完成训练的步骤,因此训练及存储的工作需要在CBOW网络类中实现。编辑功能函数train_model如下:

 def train_model(self, savepath,crop_lines_all,index_to_word,word_to_index,epochs=1000,lr=0.001):
y_pre,cross_entropy,train_op = self.loss_func(lr) # TODO TODO TODO 这句话千万不能放到循环里面,会重复绘制计算图!!!运行很慢!!
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for data_num in range(int(len(crop_lines_all)/5)):
pass # 生成 in_list out_list
in_list,out_list = make_samples(crop_lines_all,index_to_word,\
word_to_index,self.window_length,data_num) #一次20个行的处理语料样本
out_list = out_list.reshape(len(in_list),1,10000)
if (data_num)%50==0:
print('样本已处理',data_num*5,'/',len(crop_lines_all),'行。 ',datetime.datetime.now().strftime('%H:%M:%S.%f'))
for i in range(epochs):
for j in range(len(in_list)):
sess.run(train_op, feed_dict={self.sample_in:in_list[j], \
self.sample_out:out_list[j]})
#下面为存储模型的代码
tar_weight=self.tar_weight.eval() # 这个就是词向量表[10000*词向量维度],是word2vec的最终目标
front_weight=self.front_weight.eval()
back_weight=self.back_weight.eval()
bias=self.bias.eval()
word_dim=self.word_dim
window_length=self.window_length
np.savez(savepath,tar_weight=tar_weight,front_weight=front_weight,\
back_weight=back_weight,bias=bias,word_dim=word_dim,window_length=window_length)
print('model saved in:',savepath)

在该函数中,首先获取损失函数所返回的三个计算图:y_pre,cross_entropy,train_op。之后建立Session,初始化权重矩阵,通过制作训练样本的函数获取训练样本列表,对于每个样本分别使用train_op进行训练,优化权重矩阵。经过了数轮训练,将权重矩阵及CBOW网络类的参数存入.npz文件,这个文件以字典形式保存权重矩阵,其中tar_weight是我们最终目标的词向量表。

.实践与结果验证:

6.1 词向量表调用:

使用np.load()函数便可以加载.npz文件,并获取词向量表tar_weight。

param_dict = np.load(filepath)
tar_weight = param_dict['tar_weight']

我们也可以通过np.linalg.norm()函数来计算两个词向量之间的欧氏距离,通过下面数个词汇来观察词向量距离变化。

dist = np.linalg.norm(w2v[word_to_index['车辆']] - w2v[word_to_index['车子']])
print('\"车辆\" 与 \"车子\" 之间的欧式距离为:',dist,'!!')
dist = np.linalg.norm(w2v[word_to_index['机械']] - w2v[word_to_index['工业化']])
print('\"机械\" 与 \"工业化\" 之间的欧式距离为:',dist,'!!')
dist = np.linalg.norm(w2v[word_to_index['车辆']] - w2v[word_to_index['茶叶']])
print('\"车辆\" 与 \"茶叶\" 之间的欧式距离为:',dist,'!!')
dist = np.linalg.norm(w2v[word_to_index['粮食']] - w2v[word_to_index['手表']])
print('\"粮食\" 与 \"手表\" 之间的欧式距离为:',dist,'!!')

6.2 效果呈现:

最初,经过4000条新闻的训练,词汇之间的关系还比较散乱,词语之间的关系随机性较为明显(下图左)。后经过10万条新闻的训练(花了大约24小时….),随着网络内部参数的调整,[车辆,车子],[机械,工业化]这些意义接近的词组之间的欧式距离变小,而[车辆,茶叶][粮食,手表]这些意义较远的词汇欧氏距离变大(下图右)。

基于CBOW网络手动实现面向中文语料的word2vec           基于CBOW网络手动实现面向中文语料的word2vec

但是训练的速度还是较慢,与谷歌提供的word2vec模块依然有较大差距。

.后记

这次自己手动实现word2vec,主要是为了巩固前期的学习成果,在实践的过程中仍然发现了不少待探究的细节。

第一个处理不到位的地方就是对不常用的词的处理方法。本次实践中,我们将其做了删除操作,将非常用词排除在样本制作之外。这样做有可能会丢失部分信息。一种处理了思路是使用unknown标签来将不常用的词进行概括,作为词汇表的一部分。

还有一个疑问就是对于标点符号的处理是否妥当。本次实践中,我们将语料中的标点进行了删除,之后再进行分词操作,主要目的是提高处理效率,但标点符号对语句结构的影响会被忽略。

当然,这次我所构架的仅仅是一个简单的结构,还有部分优化策略没有使用,导致训练速度特别的慢。一方面原因是负采样策略的缺失。如果使用采样数为5的负采样策略的话,每一次随机梯度下降过程将会只调整6个(5个负样本和一个正样本)权重值,计算量仅相当于现有情况的万分之六,训练速度也会飞速提升。(直至网络搭建完成之后,经查阅手册才发现有个tf.nn. nce-loss()函数可以实现负采样功能,后期需要继续对其进行深入学习研究。)

另一个待优化的区域是训练样本制作方面,本次实践所采用的训练样本制作方法仍较为笨拙。(该网络搭建完成后,查阅有关资料,发现tf.nn.embedding_lookup()函数可以进行查表操作,因此省去第一步one-hot向量的制作。)我在本机使用Google开发的word2vec模块,可以在几分钟之内将30多万行经过分词的语料训练完毕,而我这个手撸的CBOW网络模型训练10万行语句就消耗了24小时,其中大部分时间都消耗在了样本制作上。后续可参考word2vec的源码继续深入学习研究。

参考资料:

https://mp.weixin.qq.com/s/u2IumPRlzr4uHStrWXM87A

http://www.dataguru.cn/article-13488-1.html