CV是让机器睁开眼睛看世界,而NLP更多的是让机器听懂人类的话。因为本人文科不是那么好,理解能力比较一般,再加上CV是那么的形象直观,所以我一直以来都对NLP敬而远之。不过,在参加了百度的《手把手教深度学习》课程之后,突然感觉NLP似乎也没有那么难,至少入门使用什么的还是很轻松的。
《手把手教深度学习》是百度推出的全程免费学习且手把手指导的优质课程,第一阶段的计算机视觉(CV)实践主要分享了图像分类与图像检测两个项目,而这里主要介绍一下第二阶段的自然语言处理(NLP)与第三阶段的推荐系统课程。
自然语言处理(NLP)
自然语言处理(Natural Language Processing,简称NLP)主要研究人与计算机之间,使用自然语言进行有效通信的各种理论和方法。简单的来说,NLP就是让计算机听懂人类的语言,让计算机学会这门“外语”。
而对于计算机而言,它只能将浮点数作为输入输出,擅长加减乘除类的计算。因此,让计算机理解人类语言最关键的问题在于如何将人类的文字转换为计算机能够理解的编码,这就是常说的词嵌入(Embedding
)。在较早时期,人们一般会使用独热编码(one-hot encoding)作为词向量的表示,但是这种方法产生的词向量过于稀疏,且词与词之间没有太大的关联性,因而这种表示的效果其实并不好。在NLP课程中,周老师主要讲解了使用word2vec的词向量表示方法,即把每个词都表示为一个N维空间内的点,即一个高维空间内的向量,从而实现把自然语言计算转换为向量计算。word2vec本身其实很好理解,就是利用n-gram根据相邻词之间的语义关系建模,主要包含CBOW和Skip-Gram两种经典的模型,前者主要是利用上下文去预测中心词,而后者主要是利用中心词预测上下文,从而将语义信息传递到词向量中。课程除了讲解word2vec的原理之外,还分析了两种方法的优缺点,同时还使用飞桨实现了Skip-Gram模型。
一般来说,CBOW比Skip-Gram训练速度快,训练过程更加稳定,原因是CBOW使用上下文average的方式进行训练,每个训练step会见到更多样本。而在生僻字(出现频率低的字)处理上,Skip-Gram比CBOW效果更好,原因是Skip-Gram不会刻意回避生僻字。
而Skip-Gram的实现我还是第一次跟着老师动手进行实现,原本以为直接两个全连接就可以了,但是实际中词表大小通常高达几十万到几百万,这回导致两个全连接的权重矩阵非常非常大,需要消耗大量的显存,因而难以计算。现实中,常采用分层softmax或者负采样(negative_sampling)的方法做近似的多分类任务。老师在课上主要讲解了负采样的方法,即随机从词表中选择几个代表词,通过最小化这几个代表词的概率,去近似最小化整体的预测概率。比如,先指定一个中心词(如“人工”)和一个目标词正样本(如“智能”),再随机在词表中采样几个目标词负样本(如“日本”,“喝茶”等)。有了这些内容,Skip-Gram模型就变成了一个二分类任务。对于目标词正样本,我们需要最大化它的预测概率;对于目标词负样本,我们需要最小化它的预测概率。通过这种方式,就可以完成计算加速。这里贴一下构建数据与模型的代码。
#构造数据,准备模型训练
#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
#定义skip-gram训练网络结构
#这里我们使用的是paddlepaddle的1.6.1版本
#一般来说,在使用fluid训练的时候,我们需要通过一个类来定义网络结构,这个类继承了fluid.dygraph.Layer
class SkipGram(fluid.dygraph.Layer):
def __init__(self, name_scope, vocab_size, embedding_size, init_scale=0.1):
#name_scope定义了这个类某个具体实例的名字,以便于区分不同的实例(模型)
#vocab_size定义了这个skipgram这个模型的词表大小
#embedding_size定义了词向量的维度是多少
#init_scale定义了词向量初始化的范围,一般来说,比较小的初始化范围有助于模型训练
super(SkipGram, self).__init__(name_scope)
self.vocab_size = vocab_size
self.embedding_size = embedding_size
#使用paddle.fluid.dygraph提供的Embedding函数,构造一个词向量参数
#这个参数的大小为:[self.vocab_size, self.embedding_size]
#数据类型为:float32
#这个参数的名称为:embedding_para
#这个参数的初始化方式为在[-init_scale, init_scale]区间进行均匀采样
self.embedding = Embedding(
self.full_name(),
size=[self.vocab_size, self.embedding_size],
dtype='float32',
param_attr=fluid.ParamAttr(
name='embedding_para',
initializer=fluid.initializer.UniformInitializer(
low=-init_scale, high=init_scale)))
#使用paddle.fluid.dygraph提供的Embedding函数,构造另外一个词向量参数
#这个参数的大小为:[self.vocab_size, self.embedding_size]
#数据类型为:float32
#这个参数的名称为:embedding_para
#这个参数的初始化方式为在[-init_scale, init_scale]区间进行均匀采样
#跟上面不同的是,这个参数的名称跟上面不同,因此,
#embedding_out_para和embedding_para虽然有相同的shape,但是权重不共享
self.embedding_out = Embedding(
self.full_name(),
size=[self.vocab_size, self.embedding_size],
dtype='float32',
param_attr=fluid.ParamAttr(
name='embedding_out_para',
initializer=fluid.initializer.UniformInitializer(
low=-init_scale, high=init_scale)))
#定义网络的前向计算逻辑
#center_words是一个tensor(mini-batch),表示中心词
#target_words是一个tensor(mini-batch),表示目标词
#label是一个tensor(mini-batch),表示这个词是正样本还是负样本(用0或1表示)
#eval_words是一个tensor(mini-batch),
#用于在训练中计算这个tensor中对应词的同义词,用于观察模型的训练效果
def forward(self, center_words, target_words, label, eval_words):
#首先,通过embedding_para(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)
eval_words_emb = self.embedding(eval_words)
#center_words_emb = [batch_size, embedding_size]
#target_words_emb = [batch_size, embedding_size]
#我们通过点乘的方式计算中心词到目标词的输出概率,并通过sigmoid函数估计这个词是正样本还是负样本的概率。
word_sim = fluid.layers.elementwise_mul(center_words_emb, target_words_emb)
word_sim = fluid.layers.reduce_sum(word_sim, dim = -1)
pred = fluid.layers.sigmoid(word_sim)
#通过估计的输出概率定义损失函数
loss = fluid.layers.sigmoid_cross_entropy_with_logits(word_sim, label)
loss = fluid.layers.reduce_mean(loss)
#我们通过一个矩阵乘法,来对每个词计算他的同义词
#on_fly在机器学习或深度学习中往往指在在线计算中做什么,
#比如我们需要在训练中做评估,就可以说evaluation_on_fly
word_sim_on_fly = fluid.layers.matmul(eval_words_emb,
self.embedding._w, transpose_y = True)
#返回前向计算的结果,飞桨会通过backward函数自动计算出反向结果。
return pred, loss, word_sim_on_fly
我们在text8
数据集上充分训练Skip-Gram模型后,根据整个词表的word embedding,计算了一些词的相似词汇。
Top 5 words closest to "beijing" are:
1. newyork
2. paris
3. tokyo
4. berlin
5. soul
...
Top 5 words closest to "apple" are:
1. banana
2. pineapple
3. huawei
4. peach
5. orange
因此,可以发现通过word2vec训练后的词向量具有了一定的语义信息,能够将相似的词归纳到一起,从而让词向量变得更有意义。但是,从apple
的相似词可以看出,除了香蕉、菠萝、桃子之类的水果外,还有华为这样的手机品牌,也就是说apple
除了是水果之外,还是手机品牌。因此,word2vec的静态词表无法解决一词多义的问题,因而产生了后来的ELMo、BERT之类的预训练模型,他们的关键思想在于词不应该是一个固定的向量表示,而是一个相对于上下文的函数,因此这类模型的词向量是动态的,因此更能解决一词多义的问题。这也是在后来的NLP比赛中,大多都使用BERT、RoBERT、ERNIE一类的预训练模型。
另外,在NLP课程中,老师还讲解了使用LSTM做情感分析的项目。项目本身比较简单,但老师讲解的非常详细,将文本分类需要注意的地方都仔细的剖析了一下,甚至直接用飞桨手动实现了一个LSTM模型,这让我对LSTM模型有了更深层次的理解。这里简单贴一下老师使用的LSTM模型。
#使用飞桨实现一个长短时记忆模型
class SimpleLSTMRNN(fluid.Layer):
def __init__(self,
name_scope,
hidden_size,
num_steps,
num_layers=1,
init_scale=0.1,
dropout=None):
#这个模型有几个参数:
#1. name_scope,表示这个模型的名字,用于区别不同的实例
#2. hidden_size,表示embedding-size,或者是记忆向量的维度
#3. num_steps,表示这个长短时记忆网络,最多可以考虑多长的时间序列
#4. num_layers,表示这个长短时记忆网络内部有多少层,我们知道,
# 给定一个形状为[batch_size, seq_len, embedding_size]的输入,
# 长短时记忆网络会输出一个同样为[batch_size, seq_len, embedding_size]的输出,
# 我们可以把这个输出再链到一个新的长短时记忆网络上
# 如此叠加多层长短时记忆网络,有助于学习更复杂的句子甚至是篇章。
#5. init_scale,表示网络内部的参数的初始化范围,
# 长短时记忆网络内部用了很多tanh,sigmoid等激活函数,这些函数对数值精度非常敏感,
# 因此我们一般只使用比较小的初始化范围,以保证效果,
super(SimpleLSTMRNN, self).__init__(name_scope)
self._hidden_size = hidden_size
self._num_layers = num_layers
self._init_scale = init_scale
self._dropout = dropout
self._input = None
self._num_steps = num_steps
self.cell_array = []
self.hidden_array = []
# weight_1_arr用于存储不同层的长短时记忆网络中,不同门的W参数
self.weight_1_arr = []
self.weight_2_arr = []
# bias_arr用于存储不同层的长短时记忆网络中,不同门的b参数
self.bias_arr = []
self.mask_array = []
# 通过使用create_parameter函数,创建不同长短时记忆网络层中的参数
# 通过上面的公式,我们知道,我们总共需要8个形状为[_hidden_size, _hidden_size]的W向量
# 和4个形状为[_hidden_size]的b向量,因此,我们在声明参数的时候,
# 一次性声明一个大小为[self._hidden_size * 2, self._hidden_size * 4]的参数
# 和一个 大小为[self._hidden_size * 4]的参数,这样做的好处是,
# 可以使用一次矩阵计算,同时计算8个不同的矩阵乘法
# 以便加快计算速度
for i in range(self._num_layers):
weight_1 = self.create_parameter(
attr=fluid.ParamAttr(
initializer=fluid.initializer.UniformInitializer(
low=-self._init_scale, high=self._init_scale)),
shape=[self._hidden_size * 2, self._hidden_size * 4],
dtype="float32",
default_initializer=fluid.initializer.UniformInitializer(
low=-self._init_scale, high=self._init_scale))
self.weight_1_arr.append(self.add_parameter('w_%d' % i, weight_1))
bias_1 = self.create_parameter(
attr=fluid.ParamAttr(
initializer=fluid.initializer.UniformInitializer(
low=-self._init_scale, high=self._init_scale)),
shape=[self._hidden_size * 4],
dtype="float32",
default_initializer=fluid.initializer.Constant(0.0))
self.bias_arr.append(self.add_parameter('b_%d' % i, bias_1))
# 定义LSTM网络的前向计算逻辑,飞桨会自动根据前向计算结果,给出反向结果
def forward(self, input_embedding, init_hidden=None, init_cell=None):
self.cell_array = []
self.hidden_array = []
#输入有三个信号:
# 1. input_embedding,这个就是输入句子的embedding表示,
# 是一个形状为[batch_size, seq_len, embedding_size]的张量
# 2. init_hidden,这个表示LSTM中每一层的初始h的值,有时候,
# 我们需要显示地指定这个值,在不需要的时候,就可以把这个值设置为空
# 3. init_cell,这个表示LSTM中每一层的初始c的值,有时候,
# 我们需要显示地指定这个值,在不需要的时候,就可以把这个值设置为空
# 我们需要通过slice操作,把每一层的初始hidden和cell值拿出来,
# 并存储在cell_array和hidden_array中
for i in range(self._num_layers):
pre_hidden = fluid.layers.slice(
init_hidden, axes=[0], starts=[i], ends=[i + 1])
pre_cell = fluid.layers.slice(
init_cell, axes=[0], starts=[i], ends=[i + 1])
pre_hidden = fluid.layers.reshape(
pre_hidden, shape=[-1, self._hidden_size])
pre_cell = fluid.layers.reshape(
pre_cell, shape=[-1, self._hidden_size])
self.hidden_array.append(pre_hidden)
self.cell_array.append(pre_cell)
# res记录了LSTM中每一层的输出结果(hidden)
res = []
for index in range(self._num_steps):
# 首先需要通过slice函数,拿到输入tensor input_embedding中当前位置的词的向量表示
# 并把这个词的向量表示转换为一个大小为 [batch_size, embedding_size]的张量
self._input = fluid.layers.slice(
input_embedding, axes=[1], starts=[index], ends=[index + 1])
self._input = fluid.layers.reshape(
self._input, shape=[-1, self._hidden_size])
# 计算每一层的结果,从下而上
for k in range(self._num_layers):
# 首先获取每一层LSTM对应上一个时间步的hidden,cell,以及当前层的W和b参数
pre_hidden = self.hidden_array[k]
pre_cell = self.cell_array[k]
weight_1 = self.weight_1_arr[k]
bias = self.bias_arr[k]
# 我们把hidden和拿到的当前步的input拼接在一起,便于后续计算
nn = fluid.layers.concat([self._input, pre_hidden], 1)
# 将输入门,遗忘门,输出门等对应的W参数,和输入input和pre-hidden相乘
# 我们通过一步计算,就同时完成了8个不同的矩阵运算,提高了运算效率
gate_input = fluid.layers.matmul(x=nn, y=weight_1)
# 将b参数也加入到前面的运算结果中
gate_input = fluid.layers.elementwise_add(gate_input, bias)
# 通过split函数,将每个门得到的结果拿出来
i, j, f, o = fluid.layers.split(
gate_input, num_or_sections=4, dim=-1)
# 把输入门,遗忘门,输出门等对应的权重作用在当前输入input和pre-hidden上
c = pre_cell * fluid.layers.sigmoid(f) + fluid.layers.sigmoid(
i) * fluid.layers.tanh(j)
m = fluid.layers.tanh(c) * fluid.layers.sigmoid(o)
# 记录当前步骤的计算结果,
# m是当前步骤需要输出的hidden
# c是当前步骤需要输出的cell
self.hidden_array[k] = m
self.cell_array[k] = c
self._input = m
# 一般来说,我们有时候会在LSTM的结果结果内加入dropout操作
# 这样会提高模型的训练鲁棒性
if self._dropout is not None and self._dropout > 0.0:
self._input = fluid.layers.dropout(
self._input,
dropout_prob=self._dropout,
dropout_implementation='upscale_in_train')
res.append(
fluid.layers.reshape(
self._input, shape=[1, -1, self._hidden_size]))
# 计算长短时记忆网络的结果返回回来,包括:
# 1. real_res:每个时间步上不同层的hidden结果
# 2. last_hidden:最后一个时间步中,每一层的hidden的结果,
# 形状为:[batch_size, num_layers, hidden_size]
# 3. last_cell:最后一个时间步中,每一层的cell的结果,
# 形状为:[batch_size, num_layers, hidden_size]
real_res = fluid.layers.concat(res, 0)
real_res = fluid.layers.transpose(x=real_res, perm=[1, 0, 2])
last_hidden = fluid.layers.concat(self.hidden_array, 1)
last_hidden = fluid.layers.reshape(
last_hidden, shape=[-1, self._num_layers, self._hidden_size])
last_hidden = fluid.layers.transpose(x=last_hidden, perm=[1, 0, 2])
last_cell = fluid.layers.concat(self.cell_array, 1)
last_cell = fluid.layers.reshape(
last_cell, shape=[-1, self._num_layers, self._hidden_size])
last_cell = fluid.layers.transpose(x=last_cell, perm=[1, 0, 2])
return real_res, last_hidden, last_cell
个性化推荐
除了自然语言处理模块外,百度飞桨的架构师毕然老师还亲自为我们带来了深度学习在个性化推荐方面的分享。协同过滤是我最早接触过的推荐算法,它根据共同行为习惯的群体有相似喜好的原则,推荐用户感兴趣的信息。原理相当简单,但是如何定义具有相同行为的群体,如何定义相似的喜好,这是一个关键的问题。在推荐系统中,数据类型纷繁复杂,有文本、图像、音乐、视频等等,包含各种不同种类的数据,如何量化这些不同类型的数据也是关键。
课程中,毕然老师用ml-1m电影推荐数据集做了一个推荐算法的项目实践演示。具体字段可以从官网上去了解一下。这里回答一下上面的几个关键性问题,我们最常用的向量化方法莫过于独热编码(one-hot encoding),推荐算法中也不例外,比如性别、年龄、地区、职业等这些离散又短小的数据使用独热编码再合适不过了,因为他们本身类别不多,包含的信息量不大,独热编码已足以表达其信息。另外,文本类型的数据,如标题、句子、文章等等,这些在深度学习中最常用的方法就是做词嵌入(word embedding)。另外,针对图像、音频、视频等可以使用卷积做特征抽取,进而实现数据的量化。在ml-1m电影推荐中,老师给出了这样的网络结构来量化每个用户的喜好。
也就是说,对用户个人依据其个人的综合特点会生成一个用户特征,而对于用户喜好的电影,也根据其各种特点生成一个电影的特征,然后根据用户特征与电影特征的相似度作为预测的用户评分,最后利用用户真实评分进行调整,从而实现整个网络各级特征的矫正,使得最终用户对电影评分高时,用户特征与电影特征的相似程度也高;相反则相似程度越低。因此,如果两个用户对多个相同的电影评分都高,那么两个用户特征之间的相似性也会很大,如此便定义了具有相同行为的群体。整体建模相对比较简单,但是毕然老师最后给出的几点思考却更为重要。
- Deep Learning is all about “Embedding Everything”。不难发现,深度学习建模是套路满满的。将任何事物均用向量的方式表示,可以直接基于向量完成“分类”或“回归”任务;也可以计算多个向量之间的关系,无论这种关系是“相似性”还是“比较排序”。
- 深度学习兴起后,迁移学习变得尤其自然。训练模型和使用模型未必是同样的方式,中间基于Embedding的向量表示,即可实现不同任务交换信息。例如本章的推荐模型使用用户对电影的评分数据进行监督训练,训练好的特征向量可以用于计算用户与用户的相似度,以及电影与电影之间的相似度。对特征向量的使用可以极其灵活,而不局限于训练时的任务。
- 网络调参:神经网络模型并没有一套理论上可推导的最优规则,实际中的网络设计往往是在理论和经验指导下的“探索”活动。例如推荐模型的每层网络尺寸的设计遵从了信息熵的原则,原始信息量越大对应表示的向量长度就越长。但具体每一层的向量应该有多长,往往是根据实际训练的效果进行调整。
关于飞桨
不管是从上面的代码还是自己在使用过程中的体验来讲,飞桨(PaddlePaddle)用起来还是比较方便的,整体静态图的API与TensorFlow比较相似,如果熟悉TensorFlow那就特别容易上手;如果不太熟悉的话,飞桨官方大量封装好的库可以让你不再理会底层的静态图写法,直接调用相关API进行调参即可,如图像分类库PaddleClas、目标检测库PaddleDetection、图像分割库PaddleSeg、NLP多任务预训练框架PaddlePALM、综合预训练模型库PaddleHub等等,可以让你直接从零开始体验深度学习的魅力。
当然,动态图就更不用说了,我认为动态图的深度学习模型是最容易入门、更直观的结构,飞桨目前的动态图正在逐渐完善中,但是从上面的代码以及《手把手教深度学习》的课程来看,飞桨正在逐步推广其动态图机制,相信以后动态图会变成飞桨各种框架的主流。
由于目前飞桨对动态图模型的支持比较少,我这里为大家开源了一个飞桨静态图参数转动态图参数的工具类PaddleTools,支持将官方提供的静态图参数直接转换为动态图参数,从而可以使飞桨的动态图可以在预训练参数的基础上进行微调,而不是从零开始。除此之外,该工具还提供了动态图转静态图、torch参数转动态图、丰富的Logger日志工具、以及训练进度的微信和邮件提醒工具。之后还会为大家带来更加丰富的工具,方便大家在训练开发过程中减少代码量,更加专注于参数的调整。