认真学习,佛系更博。
前面几章基本介绍了全连接神经网络和卷积神经网络的原理已经开发过程,本章开始将写一些自然语言处理相关的知识。当然,自然处理领域的知识点比图像处理的要复杂、抽象,可能要花更多时间来研究。
首先,我们来了解一下word2vec,其目的是将一个个的词语编码成具体的向量,因为我们的深度学习模型是不能直接处理文本数据的。关于词向量的研究做了很多,目前流传较广泛的有以下几种:
- 基于字典的方法,比如大名鼎鼎的wordnet,人工成本较高、需要不断更新;
- 基于统计的方法,比如利用单词的马尔科夫分布式假设统计上下文单词,建立共现矩阵,然后降维,即可计算出词向量;缺点是计算量较高,对数据敏感;
- 基于推理的方法,word2vec就属于这种方法,下面将会详细介绍。
比如现在有一个句子:
I like basketball and you like football.
推理可以理解为 根据上下文的数据,可以推理出当前数据的内容,比如给定第一个词I和第三个词basketball, 模型应该能推理出like这个词语,这其实是一个比较复杂的问题,因为正确结果会有很多种,比如另一个句子为: I hate basketball while you like it. 事实上,word2vec正是利用了这种特性,从而学习到同义词的词向量应该具有相似性。比如下面一个例子:
the cat is running.
the dog is running.
加入我们的模型设计为从前3个词语推导下一个词语,即用the cat is和the dog is 推导出running,由于目标词一样,所以学习过程会认为dog和cat是含义较为接近的单词,所以词向量也会有某种相近的特性。
我们先来了解cbow模型,贴一个网上的图:
引用自博客:CBOW最强理解。
解释下,cbow是word2vec的一种模型,包含一个输入层、一个输出层以及一个隐藏层。这里千万不要把word2vec模型想的过于复杂,本质上是两个全连接层(没有bias),先看输入层->隐藏层,途中所画X1,X2....Xc为输出的词序列(一般为one_hot向量),每个词序列需要用权重模型进行全连接计算,然后生成c个输出向量,这c个向量做均值操作,处理为1维向量,该1维向量在和一个全连接矩阵作运算,得到输出值,然后误差计算,即可对整个模型进行优化,实际上也是一个端到端的神经网络模型。
注意,从输入层->隐藏层的模型实际上为1个,这个模型也是训练好之后作为计算词向量的特征提取器。
举个例子,I like basketball and you like football. 窗口为1,则输入数据->标签有这些: I basketball -> like, like and -> basketball, basketball you -> and, and like -> you, you football -> like。
这里省略最后的标点符号。我们取数据X1为"I", X2为"basketball",先进行one_hot编码,然后将编码的向量分别和权重矩阵运算得到两个向量,两个向量值平均得到hidden layer的值,最后计算输出值,并和like编码的值作误差计算。
我们写了一个预处理数据的小工具,详情见enet.data.word_controller.py,主要是读取语料库然后生成上述的训练数据,调用代码为:
from enet.data import WordHandler
if __name__ == '__main__':
word_handler = WordHandler(data_dir="text_data")
data = word_handler.get_sequence()
class_num = word_handler.get_dict_capacity()
context, target = word_handler.extract_word_vec_cbow(window_size=1, input_one_hot=True, output_one_hot=True)
print(data)
print(context.shape, target.shape)
其中的context和target就是我们想要的训练数据,然后我们开始建立权重模型,分析一下,权重模型一共有两个,第一个权重模型的输入应该是3维数据,分别维batch、timestamp、one_hot,其中timestamp为某个单词上下文窗口对应的维度,输出应该是2维数据,为batch、hidden_feature,第二个模型类似,也可以设定这个维度值。但其实,这样设定会带来很多麻烦,因为word2vec还有另外一个模型:skip-gram,其模型结构和cbow相反,输入端有一个值,而输出端有多个值,这样的输入为3维输出为2维的权重结构将不适用,这里做一个适当的调整,我们设定输入端和输出端都是3维数据,后面将会看到这样设计的好处。
我们建立一个新的文件share_matmul.py,并写下共享权重层的代码:
from enet.layers.base_layer import Layer
import numpy as np
from enet.optimizer import optimizer_dict
class ShareMat(Layer):
"""
用于word2vec训练的权重
"""
def __init__(self, kernel_size=None, input_shape=None, optimizer="sgd", name=None, **k_args):
"""
:param kernel_size: 神经元个数
:param activation: 激活函数
:param input_shape: 输入shape,只在输入层有效;
:param optimizer: 优化器;
:param name: 网络层名字
"""
super(ShareMat, self).__init__(layer_type="share_mat")
assert optimizer in {"sgd", "momentum", "adagrad", "adam", "rmsprop"}
self.output_shape = kernel_size
self.name = name
# 该处的input_shape只在输入层有效,input_shape样式为(1000,)
if input_shape:
self.input_shape = input_shape[0]
self.weight = None
# self.use_bias = use_bias
self.optimizer = optimizer_dict[optimizer](**k_args)
def build(self, input_shape):
"""
根据input_shape来构建网络模型参数
:param input_shape: 输入形状
:return: 无返回值
"""
last_dim = input_shape
self.input_shape = input_shape
shape = (last_dim, self.output_shape)
self.weight_shape = shape
self.weight = self.add_weight(shape=shape, initializer="normal", node_num=input_shape)
def forward(self, input_signal, *args, **k_args):
"""
前向传播
:param input_signal: 输入信息,3维数据例如32 * 2* 1000
:return: 输出信号
"""
self.cache = np.mean(input_signal, axis=-2, keepdims=True)
# 每个输入之间要进行相加求平均;
return np.dot(input_signal, self.weight)
def backward(self, delta):
"""
反向传播
:param delta: 输入梯度
:return: 误差回传
"""
# if self.use_bias:
# delta_b = np.mean(delta, axis=0)
# else:
# delta_b = 0
if delta.shape[-2] != 1:
delta = np.sum(delta, axis=-2, keepdims=True)
delta_w = np.sum(np.matmul(self.cache.transpose((0, 2, 1)), delta), axis=0)
self.optimizer.grand(delta_w=delta_w)
# 回传给前一层的梯度
return np.dot(delta, self.weight.transpose())
def update(self, lr):
"""
更新参数
:param lr: 学习率
:return:
"""
delta_w = self.optimizer.get_delta_and_reset(lr, "delta_w")[0]
self.weight += delta_w
做一个小的验证,当模型维skip-gram时,经过我们的代码输出端维度维batch, 1, one_hot,第二维只有一行数据,而标签可能有多个,而这样恰好可以利用numpy的广播机制方便地进行误差求解;当模型为cbow时,输入端有多个timestamp的数据,我们应该先作矩阵运算然后求平均,或者先作平均在作矩阵运算(因为矩阵运算满足结合律),正如我们在forward里写的代码。这样,我们的共享权重层输入和输出数据都为3维,适用于cbow和skip-gram模型。
另外需要注意的是,输出标签也应该是3维的,正如我们在word_controller里写的。
至此我们可以来跑一下cbow和skip-gram的代码:
from enet.layers import ShareMat, Embedding
from enet.model import Sequential
from enet.data import WordHandler
if __name__ == '__main__':
word_handler = WordHandler(data_dir="text_data")
data = word_handler.get_sequence()
class_num = word_handler.get_dict_capacity()
context, target = word_handler.extract_word_vec_cbow(window_size=1, input_one_hot=True, output_one_hot=True)
print(data)
print(context.shape, target.shape)
model = Sequential()
model.add(ShareMat(input_shape=(class_num, ), kernel_size=100, name="word_vec"))
model.add(ShareMat(kernel_size=class_num))
model.compile(loss="cross_entropy")
model.fit(target, context, epoch=1000, acc=False)
sub_weight = model.get_sub_kernel(layer_name="word_vec")
print(sub_weight[0])
训练过程中将会看到误差在慢慢减小。
上面为cbow的模型,直接将model.fit里的前两个参数位置调换即为skip-gram模型。这里提一点,一般来说,skip-gram的效果要比cbow好。
我们也增加了一点sequential的功能,可以直接根据网络名获取某层的权重(另外也实现了获取某些层的功能)。见上述代码。
说明一点,真正的word2vec功能比这个要复杂很多,这里实现的word2vec存在的主要问题是当数据量增大时,矩阵运算会消耗非常多的时间,而这些时间是没必要的,观察一下网络的输入端,每个向量都是一个one_hot向量,与矩阵进行矩阵运算,其结果就是矩阵的某一行。先抛出这个观点吧,下一章实现embedding层时会详细讲一下。
整个代码的github网址为:https://github.com/darkwhale/neural_network,不断更新中;