深度学习框架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)。在本章的学习中,读者只需要知道词向量最重要的特征是相似词的词向量距离相近。每个词的词向量维度都是固定的,每一维都是连续的数。举个例子:如果我们用二维的词向量表示十个词:足球、比赛、教练、队伍、裤子、长裤、上衣和编织、折叠、拉,那么可视化出来的结果如下所示。可以看出,同类的词(足球相关的词、衣服相关的词、以及动词)彼此聚集,相互之间的距离比较近。
image.png可见,用词向量表示的词,不仅所用维度会变少(由10维变成2维),其中也会包含更合理的语义信息。除了相邻词距离更近之外,词向量还有不少有趣的特征,如下图所示。虚线的两端分别是男性词和女性词,例如叔叔和阿姨、兄弟和姐妹、男人和女人、先生和女士。可以看出,虚线的方向和长度都差不多,因此可以认为vector(国王) - vector(女王) ≈ vector(男人) - vector(女人),换一种写法就是vector(国王) - vector(男人) ≈ vector(女王) - vector(女人),即国王可以看成男性君主,女王可以看成女性君主,国王减去男性,只剩下君主的特征;女王减去女性,也只剩下君主的特征,所以这二者相似。
image.png英文一般是用一个向量表示一个词,也有使用一个向量表示一个字母的情况。中文同样也有一个词或者一个字的词向量表示,与英文采用空格来区分词不同,中文的词与词之间没有间隔,因此如果采用基于词的词向量表示,需要先进行中文分词。
这里只对词向量做一个概括性的介绍,让读者对词向量有一个直观的认知。读者只需要掌握词向量技术用向量表征词,相似词之间的向量距离近。至于如何训练词向量,如何评估词向量等内容,这里不做介绍,感兴趣的读者可以参看斯坦福大学的相关课程。
在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的网络结构图如下所示。
image.png- :输入词的序列(共有个词),每个词都是一个向量,通常用词向量表示。
- :隐藏元(共个),每个隐藏元都由之前的词计算得到,所以可以认为包含之前所有词的信息。代表初始信息,一般采用全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。
image.pngLSTM很好地解决了训练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的原理十分简单,它分为训练和生成两部分。训练的时候如下所示。
image.png例如,莎士比亚剧本中有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></s></s></s></s></s></s></s></s></s></s></s></s></s>
<START>
度门能不访,冒雪屡西东。
已想人如玉,遥怜马似骢。
乍迷金谷路,稍变上阳宫。
还比相思意,纷纷正满空。
<EOP>
数据处理完后,再来看看本次实验的文件组织架构:
checkpoints/
data.py
main.py
model.py
README.md
requirements.txt
tang.npz
utils.py
其中几个比较重要的文件如下:
- main.py:包含程序配置、训练和生成。
- model.py:模型定义。
- utils.py:可视化工具visdom的封装。
- tang.npz:将5万多首唐诗预处理成numpy数据。
- data.py:对原始的唐诗文本进行预处理,如果直接使用tang.npz,则不需要对json的数据进行处理。
程序中主要的配置选项和命令行参数如下:
class Config(object):
data_path = 'data/' # 诗歌的文本文件存放路径
pickle_path = 'tang.npz' # 预处理好的二进制文件
author = None # 只学习某位作者的诗歌
constrain = None # 长度限制
category = 'poet.tang' # 类别,唐诗还是宋诗歌(poet.song)
lr = 1e-3
weight_decay = 1e-4
use_gpu = True
epoch = 20
batch_size = 128
maxlen = 125 # 超过这个长度的之后字被丢弃,小于这个长度的在前面补空格
plot_every = 20 # 每20个batch 可视化一次
# use_env = True # 是否使用visodm
env = 'poetry' # visdom env
max_gen_len = 200 # 生成诗歌最长长度
debug_file = 'debug/debug.txt'
model_path = None # 预训练模型路径
prefix_words = '细雨鱼儿出,微风燕子斜。' # 不是诗歌的组成部分,用来控制生成诗歌的意境
start_words = '闲云潭影日悠悠' # 诗歌开始
acrostic = False # 是否是藏头诗
model_prefix = 'checkpoints/tang' # 模型保存路径
在data.py中主要有以下三个函数:
- _parseRawData:解析原始的json数据,提取成list。
- pad_sequences:将不同长度的数据截断或补齐成一样的长度。
- get_data:给主程序调用的接口。如果二进制文件存在,则直接读取二进制的numpy文件;否则读取文本文件进行处理,并将处理结果保存成二进制文件。
二进制文件tang.npz已在本书附带代码中提供,读者可以不必下载原始的json文件,直接加载处理好的二进制文件即可。
data.py中的get_data函数的代码如下:
def get_data(opt):
"""
@param opt 配置选项 Config对象
@return word2ix: dict,每个字对应的序号,形如u'月'->100
@return ix2word: dict,每个序号对应的字,形如'100'->u'月'
@return data: numpy数组,每一行是一首诗对应的字的下标
"""
if os.path.exists(opt.pickle_path):
data = np.load(opt.pickle_path)
data, word2ix, ix2word = data['data'], data['word2ix'].item(), data['ix2word'].item()
return data, word2ix, ix2word
# 如果没有处理好的二进制文件,则处理原始的json文件
data = _parseRawData(opt.author, opt.constrain, opt.data_path, opt.category)
words = {_word for _sentence in data for _word in _sentence}
word2ix = {_word: _ix for _ix, _word in enumerate(words)}
word2ix['<EOP>'] = len(word2ix) # 终止标识符
word2ix['<START>'] = len(word2ix) # 起始标识符
word2ix['</s>'] = len(word2ix) # 空格
ix2word = {_ix: _word for _word, _ix in list(word2ix.items())}
# 为每首诗歌加上起始符和终止符
for i in range(len(data)):
data[i] = ["<START>"] + list(data[i]) + ["<EOP>"]
# 将每首诗歌保存的内容由‘字’变成‘数’
# 形如[春,江,花,月,夜]变成[1,2,3,4,5]
new_data = [[word2ix[_word] for _word in _sentence]
for _sentence in data]
# 诗歌长度不够opt.maxlen的在前面补空格,超过的,删除末尾的
pad_data = pad_sequences(new_data,
maxlen=opt.maxlen,
padding='pre',
truncating='post',
value=len(word2ix) - 1)
# 保存成二进制文件
np.savez_compressed(opt.pickle_path,
data=pad_data,
word2ix=word2ix,
ix2word=ix2word)
return pad_data, word2ix, ix2word
这样在main.py的训练函数train中就可以这么使用数据:
# 获取数据
data, word2ix, ix2word = get_data(opt)
data = t.from_numpy(data)
dataloader = t.utils.data.DataLoader(data,
batch_size=opt.batch_size,
shuffle=True,
num_workers=1)
注意,我们这里没有将data实现为一个Dataset对象,但是它还是可以利用DataLoader进行多线程加载。这是因为data作为一个Tensor对象,自身已经实现了getitem和len方法。其中,data.getitem(0)等价于data[0],len(data)返回data.size(0),这种运行方式被称为鸭子类型(Duck Typing),是一种动态类型的风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口决定,而是由当前方法和属性的集合决定。这个概念的名字来源于James Whitcomb Riley提出的鸭子测试,“鸭子测试”可以这样描述:“当看到一只鸟走起来像鸭子、游起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子”。同理,当一个对象可以向Dataset对象一样提供getitem和len方法时,它就可以被称为Dataset。
另外需要注意的是,这种直接把所有的数据全部加载到内存的做法,在某些情况下会比较占内存,但是速度会有很大的提升,因为它避免了频繁的硬盘读写,减少了I/O等待,在实验中如果数据量足够小,可以酌情选择把数据全部预处理成二进制的文件全部加载到内存中。
模型构建的代码保存在model.py中:
# coding:utf8
import torch
import torch.nn as nn
import torch.nn.functional as F
class PoetryModel(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super(PoetryModel, self).__init__()
self.hidden_dim = hidden_dim
self.embeddings = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, self.hidden_dim, num_layers=2)
self.linear1 = nn.Linear(self.hidden_dim, vocab_size)
def forward(self, input, hidden=None):
seq_len, batch_size = input.size()
if hidden is None:
# h_0 = 0.01*torch.Tensor(2, batch_size, self.hidden_dim).normal_().cuda()
# c_0 = 0.01*torch.Tensor(2, batch_size, self.hidden_dim).normal_().cuda()
h_0 = input.data.new(2, batch_size, self.hidden_dim).fill_(0).float()
c_0 = input.data.new(2, batch_size, self.hidden_dim).fill_(0).float()
else:
h_0, c_0 = hidden
# size: (seq_len,batch_size,embeding_dim)
embeds = self.embeddings(input)
# output size: (seq_len,batch_size,hidden_dim)
output, hidden = self.lstm(embeds, (h_0, c_0))
# size: (seq_len*batch_size,vocab_size)
output = self.linear1(output.view(seq_len * batch_size, -1))
return output, hidden
总体而言,输入的字词序号经过nn.Embedding得到相应的词向量表示,然后利用两层的LSTM提取词的所有隐藏元的信息,再利用隐藏元的信息进行分类,判断输出属于每一个词的概率。这里使用LSTM而不是LSTMCell是为了简化代码。当输入的序列长度为1时,LSTM实现的功能与LSTMCell一样。需要注意的是,这里输入(input)的数据形状是(seq_len,batch_size),如果输入的尺寸是(batch_size,seq_len),需要在输入LSTM之前进行转置操作(variable.transpose)。
训练相关的代码保存于main.py中,总体而言比较简单,训练过程和第6章提到的猫和狗二分类问题比较相似,都是分类问题。
def train(**kwargs):
for k, v in kwargs.items():
setattr(opt, k, v)
opt.device = t.device('cuda') if opt.use_gpu else t.device('cpu')
device = opt.device
vis = Visualizer(env=opt.env)
# 获取数据
data, word2ix, ix2word = get_data(opt)
data = t.from_numpy(data)
dataloader = t.utils.data.DataLoader(data,
batch_size=opt.batch_size,
shuffle=True,
num_workers=1)
# 模型定义
model = PoetryModel(len(word2ix), 128, 256)
optimizer = t.optim.Adam(model.parameters(), lr=opt.lr)
criterion = nn.CrossEntropyLoss()
if opt.model_path:
model.load_state_dict(t.load(opt.model_path))
model.to(device)
loss_meter = meter.AverageValueMeter()
for epoch in range(opt.epoch):
loss_meter.reset()
for ii, data_ in tqdm.tqdm(enumerate(dataloader)):
# 训练
data_ = data_.long().transpose(1, 0).contiguous()
data_ = data_.to(device)
optimizer.zero_grad()
input_, target = data_[:-1, :], data_[1:, :]
output, _ = model(input_)
loss = criterion(output, target.view(-1))
loss.backward()
optimizer.step()
loss_meter.add(loss.item())
# 可视化
if (1 + ii) % opt.plot_every == 0:
if os.path.exists(opt.debug_file):
ipdb.set_trace()
vis.plot('loss', loss_meter.value()[0])
# 诗歌原文
poetrys = [[ix2word[_word] for _word in data_[:, _iii].tolist()]
for _iii in range(data_.shape[1])][:16]
vis.text('</br>'.join([''.join(poetry) for poetry in poetrys]), win=u'origin_poem')
gen_poetries = []
# 分别以这几个字作为诗歌的第一个字,生成8首诗
for word in list(u'春江花月夜凉如水'):
gen_poetry = ''.join(generate(model, word, ix2word, word2ix))
gen_poetries.append(gen_poetry)
vis.text('</br>'.join([''.join(poetry) for poetry in gen_poetries]), win=u'gen_poem')
t.save(model.state_dict(), '%s_%s.pth' % (opt.model_prefix, epoch))
这里需要注意的是数据,以“床前明月光”这句诗为例,输入是“床前明月”,预测的目标是“前明月光”:
- 输入“床”的时候,网络预测的下一个字的目标是“前”。
- 输入“前”的时候,网络预测的下一个字的目标是“明”。
- 输入“明”的时候,网络预测的下一个字的目标是“月”。
- 输入“月”的时候,网络预测的下一个字的目标是“光”。
- ……
这种错位的方式,通过data_[:-1,:]和data_[1:,:]实现。前者包含从第0个词直到最后一个词(不包含),后者是第一个词到结尾(包括最后一个词)。由于是分类问题,因此我们使用交叉熵损失作为评估函数。
接着我们来看看如何用训练好的模型写诗,第一种是给定诗歌的开头几个字接着写诗歌。实现如下:
def generate(model, start_words, ix2word, word2ix, prefix_words=None):
"""
给定几个词,根据这几个词接着生成一首完整的诗歌
start_words:u'春江潮水连海平'
比如start_words 为 春江潮水连海平,可以生成:
"""
results = list(start_words)
start_word_len = len(start_words)
# 手动设置第一个词为<START>
input = t.Tensor([word2ix['<START>']]).view(1, 1).long()
if opt.use_gpu: input = input.cuda()
hidden = None
if prefix_words:
for word in prefix_words:
output, hidden = model(input, hidden)
input = input.data.new([word2ix[word]]).view(1, 1)
for i in range(opt.max_gen_len):
output, hidden = model(input, hidden)
if i < start_word_len:
w = results[i]
input = input.data.new([word2ix[w]]).view(1, 1)
else:
top_index = output.data[0].topk(1)[1][0].item()
w = ix2word[top_index]
results.append(w)
input = input.data.new([top_index]).view(1, 1)
if w == '<EOP>':
del results[-1]
break
return results
这种生成方式是根据给定部分文字,然后接着完成诗歌余下的部分,生成的步骤如下:
- 首先利用给定的文字“床前明月光”,计算隐藏元,并预测下一个词(预测的结果是“,”)。
- 将上一步计算的隐藏元和输出(“,”)作为新的输入,继续预测新的输出和计算隐藏元。
- 将上一步计算的隐藏元和输出作为新的输入,继续预测新的输出和计算隐藏元。
- ……
这里还有一个选项是prefix_word,可以用来控制生成的诗歌的意境和长短。比如以“床前明月光”作为start_words输入,在不指定prefix_words时,生成的诗歌如下:
床前明月光,朗朗秋风清。
昨夜雨后人,一身一招迎。
何必在天末,安得佐戎庭。
岂伊不可越,所以为我情。
在指定prefix_words为“狂沙将军战燕然,大漠孤烟黄河骑。”的情况下,生成的诗歌如下(明显带有边塞气息,而且由五言古诗变成了七言古诗):
床前明月光照耀,城下射蛟沙漠漠。
父子号犬不可亲,剑门弟子何纷纷。
胡笳一声下马来,关城缭绕天河去。
战士忠州十二纪,后贤美人不敢攀。
还可以生成藏头诗,实现的方式如下:
def gen_acrostic(model, start_words, ix2word, word2ix, prefix_words=None):
"""
生成藏头诗
start_words : u'深度学习'
生成:
深木通中岳,青苔半日脂。
度山分地险,逆浪到南巴。
学道兵犹毒,当时燕不移。
习根通古岸,开镜出清羸。
"""
results = []
start_word_len = len(start_words)
input = (t.Tensor([word2ix['<START>']]).view(1, 1).long())
if opt.use_gpu: input = input.cuda()
hidden = None
index = 0 # 用来指示已经生成了多少句藏头诗
# 上一个词
pre_word = '<START>'
if prefix_words:
for word in prefix_words:
output, hidden = model(input, hidden)
input = (input.data.new([word2ix[word]])).view(1, 1)
for i in range(opt.max_gen_len):
output, hidden = model(input, hidden)
top_index = output.data[0].topk(1)[1][0].item()
w = ix2word[top_index]
if (pre_word in {u'。', u'!', '<START>'}):
# 如果遇到句号,藏头的词送进去生成
if index == start_word_len:
# 如果生成的诗歌已经包含全部藏头的词,则结束
break
else:
# 把藏头的词作为输入送入模型
w = start_words[index]
index += 1
input = (input.data.new([word2ix[w]])).view(1, 1)
else:
# 否则的话,把上一次预测是词作为下一个词输入
input = (input.data.new([word2ix[w]])).view(1, 1)
results.append(w)
pre_word = w
return results
生成藏头诗的步骤如下:
(1)输入藏头的字,开始预测下一个字。
(2)上一步预测的字作为输入,继续预测下一个字。
(3)重复第二步,直到输出的字是“。”或者“!”,说明一句诗结束了,可以继续输入下一句藏头的字,跳到第一步。
(4)重复上述步骤,直到所有藏头的字都输入完毕。
上述两种生成诗歌的方法还需要提供命令行接口,实现方式如下:
def gen(**kwargs):
"""
提供命令行接口,用以生成相应的诗
"""
for k, v in kwargs.items():
setattr(opt, k, v)
data, word2ix, ix2word = get_data(opt)
model = PoetryModel(len(word2ix), 128, 256);
map_location = lambda s, l: s
state_dict = t.load(opt.model_path, map_location=map_location)
model.load_state_dict(state_dict)
if opt.use_gpu:
model.cuda()
# python2和python3 字符串兼容
if sys.version_info.major == 3:
if opt.start_words.isprintable():
start_words = opt.start_words
prefix_words = opt.prefix_words if opt.prefix_words else None
else:
start_words = opt.start_words.encode('ascii', 'surrogateescape').decode('utf8')
prefix_words = opt.prefix_words.encode('ascii', 'surrogateescape').decode(
'utf8') if opt.prefix_words else None
else:
start_words = opt.start_words.decode('utf8')
prefix_words = opt.prefix_words.decode('utf8') if opt.prefix_words else None
start_words = start_words.replace(',', u',') \
.replace('.', u'。') \
.replace('?', u'?')
gen_poetry = gen_acrostic if opt.acrostic else generate
result = gen_poetry(model, start_words, ix2word, word2ix, prefix_words)
print(''.join(result))
9.4 实验结果分析
训练的命令如下:
python main.py train \
--plot-every=150 \
--batch-size=128 \
--pickle-path='tang.npz' \
--lr=1e-3 \
--env='poetry3' \
--epoch=50
训练过程如下:
image.png生成一首诗(指定开头、指定意境和格律):
python main.py gen
--model-path='checkpoints/tang_49.pth'
--start-words='孤帆远影碧空尽,'
--prefix-words='朝辞白帝彩云间,千里江陵一日还。'
生成的诗歌如下:
孤帆远影碧空尽,万里风波入楚山。
绿岸风波摇浪浪,绿杨风起扑船湾。
烟含楚甸悲风远,风送渔舟夜夜闲。
月色不知何处在,江花犹在落花间。
风生水槛风波急,浪入江山浪蹙闲。
莫道江湖无一事,今年一別一双攀。
人间几度千年別,日暮无穷白雪还。
莫道长安无所负,不知何事更相关。
生成一首藏头诗(指定藏头,指定意境格律):
python main.py gen \
--model-path='checkpoints/tang_49.pth' \ # 指定模型
--acrostic=True \ # True:藏头诗
--start-words='深度学习' \ # 藏头内容
--prefix-words='大漠孤烟直,长河落日圆。' # 意境和格律
藏头诗“深度学习”的结果如下:
深林无外物,长啸似神仙。
度石无人迹,青冥似水年。
学驯疑有匠,澁尺不成冤。
习坎无遗迹,幽居不得仙。
生成的很多诗歌都是高质量的,有些甚至已经学会了简单的对偶和押韵。例如:
落帆迷旧里,望月到西州。
浩荡江南岸,高情江海鸥。
风帆随雁吹,江月照旌楼。
泛泛扬州客,停舟泛水鸥。
很有意思的是,如果生成的诗歌长度足够长,会发现生成的诗歌意境会慢慢改变,以至于和最开始的毫无关系。例如:
大漠孤烟照高阁,夹城飞鞚连天阙。
青丝不语不知音,一曲繁华空绕山。
昔年曾作江南客,今日相逢不相识。
今年花落花满园,妾心不似君不同。
回头舞马邯郸陌,回头笑语歌声闹。
夫君欲问不相见,今日相看不相见。
君不见君心断断肠,莫言此地情何必?
桃花陌陌不堪惜,君恩不似春光色。
一开始是边塞诗,然后变成了羁旅怀人,最后变成了闺怨诗。
意境、格式和韵脚等信息都保存于隐藏元之中,随着输入的不断变化,隐藏元保存的信息也在不断变化,有些信息及时经过了很长的时间依旧可以保存下来(比如诗歌的长短,五言还是七言),而有些信息随着输入的变化也发生较大的改变。在本程序中,我们使用prefix_words就是为了网络能够利用给定的输入初始化隐藏元的状态。事实上,隐藏元的每一个数都控制着生成诗歌的某一部分属性,感兴趣的读者可以尝试调整隐藏元的数值,观察生成的诗歌有什么变化。
总体上,程序生成的诗歌效果还不错,字词之间的组合也比较有意境,但是诗歌却反一个一以贯之的主题,读者很难从一首诗歌中得到一个主旨。这是因为随着诗歌长度的增加,即使是LSTM也不可避免地忘记几十个字之前的输入。另外一个比较突出的问题就是,生成的诗歌中经常出现重复的词,这在传统的诗歌创作中应该是极力避免的现象,而在程序生成的诗歌中却常常出现。
本章介绍了自然语言处理中的一些基本概念,并带领读者实现了一个能够生成古诗的小程序。程序从唐诗中学习,并模仿古人写出了不少优美的诗句。