01 | NLP领域的RNN与GRU
2019年,一款国内少见的真人实拍互动影游《隐形守护者》上线Steam/Mac等多平台,其中有一句台词令自己印象深刻:“在理想主义者眼中,什么都是马尔科夫链。”
当时的自己精神一振,不明觉厉,后查阅资料,发现这里的马尔科夫链正是高等数学所学的Markov模型。最通俗地讲:以时间角度分析先后发生的一系列事件,则可以说时刻t事件的发生由t时刻之前的所有事件共同决定。
仔细想想,这是容易理解并接受的:比如早上起床去上课,陆续经历了【起床穿衣→洗漱→上厕所→准备食用早餐→出门去教室】。我们能否8点前准时出现在教室中,取决于早起后的每一个环节进展是否顺利,如果出门后发现忘记带手机折返,那么无疑会延后到达教室的时间。
人工智能领域中将上述理论称呼为“时间序列模型”,出于工程上实现的考虑,通常不是考察时刻t之前的所有事件,而是分析时刻t-1或时刻t-1、t-2时刻事件对时刻t事件的影响,通常称之为“一阶或二阶Markov链”。
类似的时间序列关系同样出现在自然语言领域(NLP)。如果主语是单数,则谓语可能是“is”;反之则可能是“are”。但是NLP的关联更加复杂,单纯的一阶或二阶Markov链无法刻画较长字符间的关联,如下例中,与问题最相关的“北京”反而“距离”更远。
我出生在北京,大学毕业后来美国攻读博士。毫无疑问,我最擅长的语言是(汉语/英语)。
为了解决上述问题,人们提出了RNN(循环神经网络),其特有的结构可以将特定时间节点的信息向后传递。
典型的神经网络通常包含输入层、隐藏层与输出层;RNN的改进是在输出层之外,新“搭建了一条通道”将当前的信息传递给下一个时刻,即:
RNN模型一度在NLP领域表现出强大的生机,然而为了更好地模型拟合效果,人们倾向于增加隐藏层数量,而常用的sigmod\tahn之类的激活函数通常最多只有6层左右,反向误差会随着层数增加而越来越小,最终导致无法反向影响到更远的模型参数,结果就是RNN模型无法学习太长的序列特征。
为此人们尝试了两种改进思路:
①使用更复杂的结构作为RNN模型的基本单元,从而使其在单层网络上提取更好的记忆特征;
②将多个基本单元结合起来,组成不同的结构(如多层RNN、双向RNN等)
从第一种思路出发,人们陆续提出了LSTM(Long Short Term Memory)和GRU(Gated Recurrent Unit)模型,后者相对前者进一步简化了输出,且效果差不多,因而本文将选择使用GRU实现古诗AI。
在进一步介绍项目代码前,需要特别说明一点:LSTM/GRU的模型具有空间和时间两个维度:从时间上看,其输入和输出都是一个序列;但是确定时间后从空间上看,则是一个经典的神经网络——关键问题是通过“搭桥”方式建立了不同时间点下神经元信息传递的通路——其信息传递的权重参数则由模型根据数据自动学习迭代(如下图所示)。
02 | 项目整体架构
为了实现一个基于GRU的古诗AI,我们的小项目整体分为下述几个部分:
①数据预处理:收集古诗数据,并预处理为规范形式;
②映射嵌入向量:将原有的中文字符映射为数值向量;
③数据类初始化:使用torch.utils.data中的Dataset初始化数据,使用Dataloader按批次迭代加载数据;
④模型训练:指定Epoch和每个Epoch的Batch,每个Batch反向迭代一次模型参数;
⑤模型应用:随机指定中文字符,模型自动写诗。
03 | 古诗数据预处理
网上搜集中文古诗文数据有很多版本,自己采用的是JSON格式版本。因此首要工作就是解析JSON数据以提取古诗文本字符数据。
这里唯一需要注意的是JSON文件以字典形式存放,因而可以循环迭代读取字典,判断其是否具有自己关心的键值即可。
为了后续测试方便,区分为五言古诗和七言绝句,分别提取建立相应的古诗原始数据文件。
简要代码如下:
04 | 词向量嵌入映射
AI模型输入与输出均应是数值向量模式,那么如何将中文字符转变为数值向量呢?
一种方法是基于“one-hot”的独热编码,基本思路是统计所有出现的中文字符建立大词典,以不同位置设置“1”表示不同的中文字符。好处是容易想到,方便实现;不足是所有的字符间缺乏关联,无法体现字词间的内在关联。
所以更推荐的方法是基于“word embedding”的方式,常见的可以基于图模型或单独的ANN训练得到。为了方便,本文使用的是NLP领域常用的gensim模块中的Word2Vec模块,自动实现字符到数值向量的嵌入映射。
同时为了后续模型应用,我们需要将训练得到的词嵌入矩阵保存下来,推荐使用Python默认的pickle模块即可,因为其可以将Python对象“原封不动”地保存在文件中(当然仅适用于Python)。
05 | 数据模块
接下来需要为古诗AI模型准备投喂的数据。
按道理应该将所有古诗一次性送入GRU模型进行训练,然后计算总的损失并反向迭代更新模型参数;然而由于样本数据量通常太大,导致算力无法一次性完成计算,因而实际中通常将整体训练过程分为多个Epoch:
①所有的数据都参与过一次训练,称之为一次Epoch;
②由于数据量太大,所以在一次Epoch中,将数据分成不同的Batch,每次向模型输入一个Batch并计算一次反向误差迭代更新;
③如此便成了多个Epoch下多个Batch的计算反向迭代过程。
按照上述描述,我们自然的数据封装顺序应当是【Batchsize→Sequence→Embedding dim】,如下图所示,当Batchsize=3时,一次输入三个句子,每个句子4个字(Squence),每个字又是4嵌入维度,即得到形状如【3,4,4】的张量(Tensor)。
使用torch.utils.data下Dataset和Dataloader类的好处就是,PyTorch可以自动帮我们从数据集中挑取、组合成【3,4,4】的训练张量和相应的标签张量,从而后续直接调用即可实现模型训练。
在使用Dataset和Dataloader类时有几点需要特别注意:
①使用的类为torch.utils.data下的Dataset和Dataloader
②Dataset类的作用在于加载全部所需的数据(包含初始化),同时定义清楚如何取得一个样本的数据, 因此需要重载__getitem__(self, idx)和__len__(self)两个函数
③Dataloader加载器返回一个可迭代对象,负责按需按批次从数据中取得数据
④注意对于训练输入x需要映射为词向量,对于标签仅停留在index即可,因为后续计算损失时不需要向量,仅index即可
具体可参考如下代码:
06 | 模型定义与训练
接下来需要定义我们自己的GRU模型,由于PyTorch的模型都源于共同的父类nn.Module,因此需要在定义时建立继承关系。
我们的GRUModel类中主要包含两部分:
①初始化构造函数:首先调用父类的初始化函数,然后将所需数据全部内化为类内数据;接下来定义神经网络结构中各个组成部分,如GRU层、flatten层、dropout层、全连接fc层以及交叉熵损失函数
②定义前向传播函数:主要用于PyTorch构造计算图,按数据流向顺序组合神经网络中定义的各个层即可
模型训练阶段关键是利用Dataloader得到的迭代器,顺序取出相应的数据即可。
07 | 测试输出
为了验证我们古诗模型的效果,在训练的同时即可周期性打印模型损失,同时还可以随机输入中文字符以生成诗句。具体可参考下述代码:
当训练到最后时,可以查看输出的诗句,以验证古诗AI的效果,整体看来还是满足押韵的,但是由于自己训练时采用的是单句16字符,因而上下句间关联可能并不明显。
08 | 结语
作为自己学习使用PyTorch后的第一个入门小项目,中途查阅了不少资料,但是大多缺失了一些关键细节,好在最后终于在不断碰壁中写出了雏形。
现有的项目依然有很大的改进空间:
①古诗训练集按照七言绝句的单句输入(16字符),因而所作诗句上下句缺乏意境关联;
②未尝试“藏头诗”功能,其实思路也相对简单,现有的模式是指定第一个字,连续输出15个字符成为一句;届时可能需要分别指定四句首字,而后生成后续7个字符即可,但是如此以来,四句诗的意境关联何在?
问题留待以后再考虑吧,今天大脑已经要罢工了(#^.^#)