深度学习框架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
  • x_1,x_2,x_3,...,x_T:输入词的序列(共有T个词),每个词都是一个向量,通常用词向量表示。
  • h_0,h_1,h_2,h_3,...,h_T:隐藏元(共T+1个),每个隐藏元都由之前的词计算得到,所以可以认为包含之前所有词的信息。h_0代表初始信息,一般采用全0的向量进行初始化。
  • f_W:转换函数,根据当前输入x_t和前一个隐藏元的状态h_{t-1},计算新的隐藏元状态h_t。可以认为h_{t-1}包含前t-1个词的信息,即x_1,x_2,...,x_{t-1},由f_W利用h_{t-1}x_t计算得到的h_t,可以认为是包含前t个词的信息。需要注意的是,每一次计算h_t都用同一个f_Wf_W一般是一个矩阵乘法运算。

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

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

image.png

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的原理十分简单,它分为训练和生成两部分。训练的时候如下所示。

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>标识),计算输出属于每个字符的概率。
  • 选择概率最大的一个字符作为输出。
  • 将上一步的输出作为输入,继续输入到网络中,计算输出属于每个字符的概率。
  • 一直重复这个过程。
  • 最后将所有字符拼接组合在一起,就得到最后的生成结果。
image.png

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对象,自身已经实现了getitemlen方法。其中,data.getitem(0)等价于data[0],len(data)返回data.size(0),这种运行方式被称为鸭子类型(Duck Typing),是一种动态类型的风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口决定,而是由当前方法和属性的集合决定。这个概念的名字来源于James Whitcomb Riley提出的鸭子测试,“鸭子测试”可以这样描述:“当看到一只鸟走起来像鸭子、游起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子”。同理,当一个对象可以向Dataset对象一样提供getitemlen方法时,它就可以被称为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也不可避免地忘记几十个字之前的输入。另外一个比较突出的问题就是,生成的诗歌中经常出现重复的词,这在传统的诗歌创作中应该是极力避免的现象,而在程序生成的诗歌中却常常出现。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容