1000字范文,内容丰富有趣,学习的好帮手!
1000字范文 > 深度学习框架PyTorch入门与实践:第九章 AI诗人:用RNN写诗

深度学习框架PyTorch入门与实践:第九章 AI诗人:用RNN写诗

时间:2022-12-27 07:39:20

相关推荐

深度学习框架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:utf8import torch as tfrom torch import nnembedding = 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的网络结构图如下所示。

x1,x2,x3,...,xTx_1,x_2,x_3,...,x_Tx1​,x2​,x3​,...,xT​:输入词的序列(共有TTT个词),每个词都是一个向量,通常用词向量表示。h0,h1,h2,h3,...,hTh_0,h_1,h_2,h_3,...,h_Th0​,h1​,h2​,h3​,...,hT​:隐藏元(共T+1T+1T+1个),每个隐藏元都由之前的词计算得到,所以可以认为包含之前所有词的信息。h0h_0h0​代表初始信息,一般采用全0的向量进行初始化。fWf_WfW​:转换函数,根据当前输入xtx_txt​和前一个隐藏元的状态ht−1h_{t-1}ht−1​,计算新的隐藏元状态hth_tht​。可以认为ht−1h_{t-1}ht−1​包含前t−1t-1t−1个词的信息,即x1,x2,...,xt−1x_1,x_2,...,x_{t-1}x1​,x2​,...,xt−1​,由fWf_WfW​利用ht−1h_{t-1}ht−1​和xtx_txt​计算得到的hth_tht​,可以认为是包含前ttt个词的信息。需要注意的是,每一次计算hth_tht​都用同一个fWf_WfW​。fWf_WfW​一般是一个矩阵乘法运算。

RNN最后会输出所有隐藏元的信息,一般只使用最后一个隐藏元的信息,可以认为它包含了整个句子的信息。

上图所示的RNN结构通常被称为Vanilla RNN,易于实现,并且简单直观,但却具有严重的梯度消失和梯度爆炸问题,难以训练。目前在深度学习中普遍使用的是一种被称为LSTM的RNN结构。LSTM的全称是Long Short Term Memory Networks,即长短期记忆网络,其结构如下图所示,它的结构与Vanilla RNN类似,也是通过不断利用之前的状态和当前的输入来计算新的状态。但其fWf_WfW​函数更复杂,除了隐藏元状态(hidden state hhh),还有cell state ccc。每个LSTM单元的输出有两个,一个是下面的hth_tht​(hth_tht​同时被创建分支引到上面去),一个是上面的ctc_tct​。ctc_tct​的存在能很好地抑制梯度消失和梯度爆炸等问题。关于RNN和LSTM的介绍,可以参考colah的博客:Understanding LSTM Networks。

LSTM很好地解决了训练RNN过程中出现的各种问题,在几乎各类问题中都要展现出好于Vanilla RNN的表现。在PyTorch中使用LSTM的例子如下。

import torch as tfrom torch import nnfrom torch.autograd import Variable# 输入词用10维词向量表示# 隐藏元用20维向量表示# 两层的LSTMrnn = 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》发表于ICLR,同时还有一篇相当精彩的博客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通过利用当前字的隐藏元状态预测下一个字,把生成问题变成了分类问题。

训练完成之后,我们就可以利用网络进行文本生成来写诗。生成的步骤如下图所示。

首先输入一个起始的字符(一般用标识),计算输出属于每个字符的概率。选择概率最大的一个字符作为输出。将上一步的输出作为输入,继续输入到网络中,计算输出属于每个字符的概率。一直重复这个过程。最后将所有字符拼接组合在一起,就得到最后的生成结果。

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>']

对于长度达不到125个字符的诗歌,在前面补上空格(用表示),直到长度达到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.pymain.pymodel.pyREADME.mdrequirements.txttang.npzutils.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-3weight_decay = 1e-4use_gpu = Trueepoch = 20batch_size = 128maxlen = 125 # 超过这个长度的之后字被丢弃,小于这个长度的在前面补空格plot_every = 20 # 每20个batch 可视化一次# use_env = True # 是否使用visodmenv = 'poetry' # visdom envmax_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:utf8import torchimport torch.nn as nnimport torch.nn.functional as Fclass PoetryModel(nn.Module):def __init__(self, vocab_size, embedding_dim, hidden_dim):super(PoetryModel, self).__init__()self.hidden_dim = hidden_dimself.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.devicevis = 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 = Noneif 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]breakreturn 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 = Noneindex = 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:# 如果生成的诗歌已经包含全部藏头的词,则结束breakelse:# 把藏头的词作为输入送入模型w = start_words[index]index += 1input = (input.data.new([word2ix[w]])).view(1, 1)else:# 否则的话,把上一次预测是词作为下一个词输入input = (input.data.new([word2ix[w]])).view(1, 1)results.append(w)pre_word = wreturn 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: sstate_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_wordsprefix_words = opt.prefix_words if opt.prefix_words else Noneelse: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 Noneelse:start_words = opt.start_words.decode('utf8')prefix_words = opt.prefix_words.decode('utf8') if opt.prefix_words else Nonestart_words = start_words.replace(',', u',') \.replace('.', u'。') \.replace('?', u'?')gen_poetry = gen_acrostic if opt.acrostic else generateresult = 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

训练过程如下:

生成一首诗(指定开头、指定意境和格律):

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也不可避免地忘记几十个字之前的输入。另外一个比较突出的问题就是,生成的诗歌中经常出现重复的词,这在传统的诗歌创作中应该是极力避免的现象,而在程序生成的诗歌中却常常出现。

本章介绍了自然语言处理中的一些基本概念,并带领读者实现了一个能够生成古诗的小程序。程序从唐诗中学习,并模仿古人写出了不少优美的诗句。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。