数据集 本次实验数据集来自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:入门与实践_陈云(著)  第九章