数据集 本次实验数据集来自GitHub上中文诗词爱好者收集的5万多首唐诗原文。原始文件是Json文件和Sqlite数据库的存储格式,此项目在此基础上做了两个修改:
繁体中文改为简体中文:原始数据是繁体中文,更能保存诗歌的意境,但是对于习惯简体中文来说有点别扭。
把所有数据进行截断和补齐成一样的长度:由于不同诗歌的长度不一样,不易拼接成一个batch,因此需要将它们处理成一样的长度。
将原始数据集处理成一个numpy的压缩包tang.npz,里面包含三个对象:
data(57580,125)的numpy数组,总共有57580首诗歌,每首诗歌长度为125字符。在诗歌的前面和后面加上起始符和终止符。长度不足125的诗歌,在前面补上空格(用</s>表示)。对于长度超过125的诗歌,把结尾的词截断。之后将每个字都转成对应的序号。
word2ix:每个词和它对应的序号。
ix2word:每个序号和它对应的词。
在data.py中主要有以下三个函数:
_parseRawData:解析原始的json数据,提取成list。
pad_sequences:将不同长度的数据截断或补齐成一样的长度。
get_data:给主程序调用的接口。如果如果二进制文件存在,直接读取二进制的numpy文件,否则读取原始文本文件进行处理,并把处理结果保存为二进制文件。
data.py中的get_data代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 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, allow_pickle=True ) data, word2ix, ix2word = data['data' ], data['word2ix' ].item(), data['ix2word' ].item() return data, word2ix, ix2word 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>" ] new_data = [[word2ix[_word] for _word in _sentence] for _sentence in data] 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
模型 输入的字词序号经过nn.Embedding得到相应词的词向量表示,然后利用两层的LSTM提取词的所有隐藏元的信息,再利用隐藏元的信息进行分类,判断输出属于每一个词的概率。这里输入(input)的数据形状是(seq_len,batch_size),如果输入的尺寸是(batch_size,seq_len),需要在输入LSTM之前进行转置操作(variable.transpose)。
模型构建的代码在model.py中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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 = 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 embeds = self.embeddings(input ) output, hidden = self.lstm(embeds, (h_0, c_0)) output = self.linear1(output.view(seq_len * batch_size, -1 )) return output, hidden
模型训练代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 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 = [] 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个词直到最后一个词(不包括),后者是第一个词到结尾(包括最后一个词)。
生成诗歌 本项目实现两种生成诗歌的方式:给定诗歌的开头接着写诗歌;藏头诗。
续写诗歌 这种生成方式是根据给定部分文字,然后接着完成诗歌余下的部分。
首先利用给定的文字,计算隐藏元,并预测下一个词。
将上一步的隐藏元和输出作为新的输入,继续预测新的输出和计算隐藏元。
。。。。。。
这里还有一个选项是prefix_word ,可以控制生成的诗歌的意境和长短。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 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) 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
藏头诗 生成藏头诗的步骤如下:
输入藏头的字,开始预测下一个字。
上一步预测的字作为输入,继续预测下一个字。
重复上一步,直到输出的字是“。”或者“!”,说明一句诗结束了,可以继续输入下一句藏头的字,跳到第一步。
重复上述步骤直到所有藏头的字都输入完毕。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 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 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 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() 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))
实验结果分析 生成藏头诗:
深山喷雾吼不灭,胡兵髓菜无冬冬。度胡胡马邯郸市,碣兵走马车声里。学军使者何在人,杀我营营军未起。习家车马不可渝,当时汉武无能犒。
生成续写诗:
深度学习狐狸父,截泻巉巖石翎粟。巨鼇万里云气浮,英雄夜立乾坤宝。炎炎咆哮不可测,苍苍云飞生沙徼。三峡浮云起孤云,千门万户横河北。回看龙虎出汉宫,飞来势稍凌穹穹。天下一星出天马,五月四面蛟龙泉。射洪万国天一隅,苍蝇不碍蟾蜍倾。羣仙星龛不可测,霹雳怒之瞥如昨。安危熊鹜势相续,狮子黄金射雕髪。星纲照耀光照明,朝开紫极连云行。耿晨告止赴丰奏,回望关河信横旋。回头向日傍山去,却踏青云出帝城。西陵不死乌鸢死,
生成的很多诗歌都是高质量的,有些甚至已经学会了简单的对偶和押韵。如果生成的诗歌长度足够长,会发现生成的诗歌意境会慢慢改变,以至于和最开始毫无关系。
意境、格式和韵脚等信息都保存在隐藏元之中,随着输入的不断变化,隐藏元保存的信息也在不断变化,有些信息即使经过了很长时间依旧可以保存下来(比如诗歌的长短,五言还是七言),而有些信息随着输入变化也发生较大的改变。
总体上,程序生成的诗歌效果还不错,字词之间的组合也比较有意境,但是诗歌缺乏一个主题,很难从一首诗歌中得到一个主旨。这是因为随着诗歌长度的增长,即使LSTM也会忘记几十个字之前的输入。
参考文章 1.pytorch-book/chapter09-neural_poet_RNN
2.chinese-poetry
3.深度学习框架PyTorch:入门与实践_陈云(著) 第九章