美文网首页深度学习框架PyTorch入门与实践 陈云自然语言处理
深度学习框架PyTorch入门与实践:第九章 AI诗人:用RNN

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

作者: 金字塔下的小蜗牛 | 来源:发表于2019-10-11 01:02 被阅读0次

    我们先来看一首诗。

    深宫有奇物,璞玉冠何有。
    度岁忽如何,遐龄复何欲。
    学来玉阶上,仰望金闺籍。
    习协万壑间,高高万象逼。

    这是一首藏头诗,每句诗的第一个字连起来就是“深度学习”。想必你也猜到了,这首诗就是使用深度学习写的!本章我们将学习一些自然语言处理的基本概念,并尝试自己动手,用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也不可避免地忘记几十个字之前的输入。另外一个比较突出的问题就是,生成的诗歌中经常出现重复的词,这在传统的诗歌创作中应该是极力避免的现象,而在程序生成的诗歌中却常常出现。

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

    相关文章

      网友评论

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

        本文链接:https://www.haomeiwen.com/subject/pwelyctx.html