深度学习框架PyTorch入门与实践:第九章 AI诗人:用RNN写诗
我们先来看一首诗。
深宫有奇物,璞玉冠何有。
度岁忽如何,遐龄复何欲。
学来玉阶上,仰望金闺籍。
习协万壑间,高高万象逼。
这是一首藏头诗,每句诗的第一个字连起来就是“深度学习”。想必你也猜到了,这首诗就是使用深度学习写的!本章我们将学习一些自然语言处理的基本概念,并尝试自己动手,用RNN实现自动写诗。
9.1 自然语言处理的基础知识
自然语言处理(Natural Language Processing,NLP)是人工智能和语言学领域的分支学科。自然语言处理是一个很宽泛的学科,涉及机器翻译、句法分析、信息检索等诸多研究方向。由于篇幅的限制,本章重点讲解自然语言处理中的两个基本概念:词向量(Word Vector)和循环神经网络(Recurrent Neural Network,RNN)。
9.1.1 词向量
自然语言处理主要研究语言信息,语言(词、句子、篇章等)属于人类认知过程中产生的高层认知抽象实体,而语音和图像属于较低层的原始输入信号。语音、图像数据表达不需要特殊的编码,并且有天生的顺序性和关联性,近似的数字会被认为是近似的特征。正如图像是由像素组成,语言是由词或字组成,可以把语言转换为词或字表示的集合。
然而,不同于像素的大小天生具有色彩信息,词的数值大小很难表征词的含义。最初,人们为了方便,采用One-Hot编码格式。以一个只有10个不同词的语料库为例(这里只是举个例子,一般中文语料库的字平均在8000 ~ 50000,而词则在几十万左右),我们可以用一个10维的向量表示每个词,该向量在词下标位置的值为1,而其他全部为0。示例如下:
第1个词:[1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
第2个词:[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
第3个词:[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
……
第10个词:[0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
这种词的表示方法十分简单,也很容易实现,解决了分类器难以处理属性(Categorical)数据的问题。它的缺点也很明显:冗余太多、无法体现词与词之间的关系。可以看到,这10个词的表示,彼此之间都是相互正交的,即任意两个词之间都不相关,并且任何两个词之间的距离也都是一样的。同时,随着词数的增加,One-Hot向量的维度也会急剧增长,如果有3000个不同的词,那么每个One-Hot词向量都是3000维,而且只有一个位置为1,其余位置都是0,。虽然One-Hot编码格式在传统任务上表现出色,但是由于词的维度太高,应用在深度学习上时,常常出现维度灾难,所以在深度学习中一般采用词向量的表示形式。
词向量(Word Vector),也被称为词嵌入(Word Embedding),并没有严格统一的定义。从概念上讲,它是指把一个维数为所有词的数量的高维空间(几万个字,几十万个词)嵌入一个维度低得多的连续向量空间(通常是128或256维)中,每个单词或词组被映射为实数域上的向量。
词向量有专门的训练方法,这里不会细讲,感兴趣的读者可以学习斯坦福的CS224系列课程(包括CS224D和CS224N)。在本章的学习中,读者只需要知道词向量最重要的特征是相似词的词向量距离相近。每个词的词向量维度都是固定的,每一维都是连续的数。举个例子:如果我们用二维的词向量表示十个词:足球、比赛、教练、队伍、裤子、长裤、上衣和编织、折叠、拉,那么可视化出来的结果如下所示。可以看出,同类的词(足球相关的词、衣服相关的词、以及动词)彼此聚集,相互之间的距离比较近。
可见,用词向量表示的词,不仅所用维度会变少(由10维变成2维),其中也会包含更合理的语义信息。除了相邻词距离更近之外,词向量还有不少有趣的特征,如下图所示。虚线的两端分别是男性词和女性词,例如叔叔和阿姨、兄弟和姐妹、男人和女人、先生和女士。可以看出,虚线的方向和长度都差不多,因此可以认为vector(国王) - vector(女王) ≈ vector(男人) - vector(女人),换一种写法就是vector(国王) - vector(男人) ≈ vector(女王) - vector(女人),即国王可以看成男性君主,女王可以看成女性君主,国王减去男性,只剩下君主的特征;女王减去女性,也只剩下君主的特征,所以这二者相似。
英文一般是用一个向量表示一个词,也有使用一个向量表示一个字母的情况。中文同样也有一个词或者一个字的词向量表示,与英文采用空格来区分词不同,中文的词与词之间没有间隔,因此如果采用基于词的词向量表示,需要先进行中文分词。
这里只对词向量做一个概括性的介绍,让读者对词向量有一个直观的认知。读者只需要掌握词向量技术用向量表征词,相似词之间的向量距离近。至于如何训练词向量,如何评估词向量等内容,这里不做介绍,感兴趣的读者可以参看斯坦福大学的相关课程。
在PyTorch中,针对词向量有一个专门的层nn.Embedding,用来实现词与词向量的映射。nn.Embedding具有一个权重,形状是(num_words,embedding_dim),例如对上述例子中的10个词,每个词用2维向量表征,对应的权重就是一个10 * 2的矩阵。Embedding的输入形状是N * W,N是batch size,W是序列的长度,输出的形状是N * W * embedding_dim。输入必须是LongTensor,FloatTensor必须通过tensor.long()方法转成LongTensor。举例如下:
#coding:utf8
import torch as t
from torch import nn
embedding = t.nn.Embedding(10, 2) # 10个词,每个词用2维词向量表示
input = t.arange(0, 6).view(3, 2).long() # 3个句子,每个句子有2个词
input = t.autograd.Variable(input)
output = embedding(input)
print(output.size())
print(embedding.weight.size())
输出是:
(3L, 2L, 2L)
(10L, 2L)
需要注意的是,Embedding的权重也是可以训练的,既可以采用随机初始化,也可以采用预训练好的词向量初始化。
9.1.2 RNN
RNN的全称是Recurrent Neural Network,在深度学习中还有一个Recursive Neural Network也被称为RNN,这里应该注意区分,除非特殊说明,我们所遇到的绝大多数RNN都是指前者。在用深度学习解决NLP问题时,RNN几乎是必不可少的工具。假设我们现在已经有每个词的词向量表示,那么我们将如何获得这些词所组成的句子的含义呢?我们无法单纯地分析一个词,因此每一个词都依赖于前一个词,单纯地看某一个词无法获得句子的信息。RNN则可以很好地解决这个问题,通过每次利用之前词的状态(hidden state)和当前词相结合计算新的状态。
RNN的网络结构图如下所示。
:输入词的序列(共有
个词),每个词都是一个向量,通常用词向量表示。
:隐藏元(共
个),每个隐藏元都由之前的词计算得到,所以可以认为包含之前所有词的信息。
代表初始信息,一般采用全0的向量进行初始化。
:转换函数,根据当前输入
和前一个隐藏元的状态
,计算新的隐藏元状态
。可以认为
包含前
个词的信息,即
,由
利用
和
计算得到的
,可以认为是包含前
个词的信息。需要注意的是,每一次计算
都用同一个
。
一般是一个矩阵乘法运算。
RNN最后会输出所有隐藏元的信息,一般只使用最后一个隐藏元的信息,可以认为它包含了整个句子的信息。
上图所示的RNN结构通常被称为Vanilla RNN,易于实现,并且简单直观,但却具有严重的梯度消失和梯度爆炸问题,难以训练。目前在深度学习中普遍使用的是一种被称为LSTM的RNN结构。LSTM的全称是Long Short Term Memory Networks,即长短期记忆网络,其结构如下图所示,它的结构与Vanilla RNN类似,也是通过不断利用之前的状态和当前的输入来计算新的状态。但其函数更复杂,除了隐藏元状态(hidden state
),还有cell state
。每个LSTM单元的输出有两个,一个是下面的
(
同时被创建分支引到上面去),一个是上面的
。
的存在能很好地抑制梯度消失和梯度爆炸等问题。关于RNN和LSTM的介绍,可以参考colah的博客:Understanding LSTM Networks。
LSTM很好地解决了训练RNN过程中出现的各种问题,在几乎各类问题中都要展现出好于Vanilla RNN的表现。在PyTorch中使用LSTM的例子如下。
import torch as t
from torch import nn
from torch.autograd import Variable
# 输入词用10维词向量表示
# 隐藏元用20维向量表示
# 两层的LSTM
rnn = nn.LSTM(10,20,2)
# 输入每句话有5个词
# 每个词由10维的词向量表示
# 总共有3句话(batch-size)
input = Variable(t.randn(5,3,10))
# 隐藏元(hidden state和cell state)的初始值
# 形状(num_layers,batch_size,hidden_size)
h0 = Variable(t.zeros(2,3,20))
c0 = Variable(t.zeros(2,3,20))
# output是最后一层所有隐藏元的值
# hn和cn是所有层(这里有2层)的最后一个隐藏元的值
output,(hn,cn) = rnn(input,(h0,c0))
print(output.size())
print(hn.size())
print(cn.size())
输出如下:
torch.Size([5, 3, 20])
torch.Size([2, 3, 20])
torch.Size([2, 3, 20])
注意:output的形状与LSTM的层数无关,只与序列长度有关,而hn和cn则相反。
除了LSTM,PyTorch中还有LSTMCell。LSTM是对一个LSTM层的抽象,可以看成是由多个LSTMCell组成。而使用LSTMCell则可以进行更精细化的操作。LSTM还有一种变体称为GRU(Gated Recurrent Unit),相较于LSTM,GRU的速度更快,效果也接近。在某些对速度要求十分严格的场景可以使用GRU作为LSTM的替代品。
9.2 CharRNN
CharRNN的作者Andrej Karpathy现任特斯拉AI主管,也曾是最优的深度学习课程CS231n的主讲人。关于CharRNN,Andrej Karpathy有一篇论文《Visualizing and understanding recurrent networks》发表于ICLR2016,同时还有一篇相当精彩的博客The Unreasonable Effectiveness of Recurrent Neural Networks介绍了不可思议的CharRNN。
CharRNN从海量文本中学习英文字母(注意,是字母,不是英语单词)的组合,并能够自动生成相对应的文本。例如作者用莎士比亚的剧集训练CharRNN,最后得到一个能够模仿莎士比亚写剧的程序,生成的莎剧剧本如下:
PANDARUS:
Alas, I think he shall be come approached and the day
When little srain would be attain'd into being never fed,
And who is but a chain and subjects of his death,
I should not sleep.Second Senator:
They are away this miseries, produced upon my soul,
Breaking and strongly should be buried, when I perish
The earth and thoughts of many states.DUKE VINCENTIO:
Well, your wit is in the care of side and that.Second Lord:
They would be ruled after this chamber, and
my fair nues begun out of the fact, to be conveyed,
Whose noble souls I'll have the heart of the wars.Clown:
Come, sir, I will make did behold your worship.VIOLA:
I'll drink it.
作者还做了许多十分有趣的实验,例如模仿Linux的源代码写程序,模仿开源的教科书的LaTeX源码写程序等。
CharRNN的原理十分简单,它分为训练和生成两部分。训练的时候如下所示。
例如,莎士比亚剧本中有hello world这句话,可以把它转化成分类任务。RNN的输入是hello world,对于RNN的每一个隐藏元的输出,都接一个全连接层用来预测下一个字,即:
- 第一个隐藏元,输入
h,包含h的信息,预测输出e; - 第二个隐藏元,输入
e,包含he的信息,预测输出l; - 第三个隐藏元,输入
l,包含hel的信息,预测输出l; - 第四个隐藏元,输入
l,包含hell的信息,预测输出o; - 等等。
如上所述,CharRNN可以看成一个分类问题:根据当前字符,预测下一个字符。对于英文字母来说,文本中用到的总共不超过128个字符(假设就是128个字符),所以预测问题就可以改成128分类问题:将每一个隐藏元的输出,输入到一个全连接层,计算输出属于128个字符的概率,计算交叉熵损失即可。
总结成一句话:CharRNN通过利用当前字的隐藏元状态预测下一个字,把生成问题变成了分类问题。
训练完成之后,我们就可以利用网络进行文本生成来写诗。生成的步骤如下图所示。
- 首先输入一个起始的字符(一般用<START>标识),计算输出属于每个字符的概率。
- 选择概率最大的一个字符作为输出。
- 将上一步的输出作为输入,继续输入到网络中,计算输出属于每个字符的概率。
- 一直重复这个过程。
- 最后将所有字符拼接组合在一起,就得到最后的生成结果。
CharRNN还有一些不够严谨之处,例如它使用One-Hot的形式表示词,而不是使用词向量;使用RNN而不是LSTM。在本次实验中,我们将对这些进行改进,并利用常用的中文语料库进行训练。
9.3 用PyTorch实现CharRNN
本章所有源码及数据百度网盘下载,提取码:vqid。
本次实验采用的数据是来自GitHub上中文诗词爱好者收集的5万多首唐诗原文。原始文件是Json文件和Sqlite数据库的存储格式。笔者在此基础上做了两个修改:
- 繁体中文改成简体中文:原始数据是繁体中文的,虽然诗词更有韵味,但是对于习惯了简体中文的读者来说可能还是有点别扭。
- 把所有的数据进行截断和补齐成一样的长度:由于不同诗歌的长度不一样,不易拼接成一个batch,因此需要将它们处理成一样的长度。
最后为了方便读者复现实验,笔者对原始数据进行了处理,并提供了一个numpy的压缩包tang.npz,里面包含三个对象。
- data:(57580,125)的numpy数组,总共有57580首诗歌,每首诗歌长度为125个字符(不足125补空格,超过125的丢弃)。
- word2ix:每个词和它对应的序号,例如“春”这个词对应的序号是1000。
- ix2word:每个序号和它对应的词,例如序号1000对应着“春”这个词。
其中data对诗歌的处理步骤如下。
- 以《静夜思》这首诗为例,先转成list,并在前面和后面加上起始符<START>和终止符<EOP>,变成:
['<START>',
'床','前','明','月','光',',',
'疑','是','地','上','霜','。',
'举','头','望','明','月',',',
'低','头','思','故','乡','。',
'<EOP>']
- 对于长度达不到125个字符的诗歌,在前面补上空格(用</s>表示),直到长度达到125,变成如下格式:
['</s>','</s>','</s>',......,
'<START>',
'床','前','明','月','光',',',
'疑','是','地','上','霜','。',
'举','头','望','明','月',',',
'低','头','思','故','乡','。',
'<EOP>']
对于长度超过125个字符的诗歌《春江花月夜》,把结尾的词截断,变成如下格式:
['<START>',
'春','江','潮','水','连','海','平',',','海','上','明','月','共','潮','生','。',
……,
'江','水','流','春','去','欲','尽',',','江','潭','落','月','复','西','斜','。',
'斜','月','沉','沉','藏','海','雾',',','碣','石',
'<END>']
- 将每个字都转成对应的序号,例如“春”转换成1000,变成如下格式,每个list的长度都是125。
[12,1000,959,......,127,285,1000,695,50,622,545,299,3,
906,155,236,828,61,635,87,262,704,957,23,68,912,200,
539,819,494,398,296,94,905,871,34,818,766,58,881,469,
22,385,696]
- 将序号list转成numpy数组。
将numpy的数据还原成诗歌的例子如下:
import numpy as np
# 加载数据
datas = np.load('tang.npz', allow_pickle=True)
data = datas['data']
ix2word = datas['ix2word'].item()
# 查看第一首诗歌
poem = data[0]
# 词序号转成对应的汉字
poem_txt = [ix2word[ii] for ii in poem]
print(''.join(poem_txt))
输出如下:
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s