数据集

本次实验数据集来自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

# 如果没有处理好的二进制文件,则处理原始的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

模型

输入的字词序号经过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 = 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

模型训练代码如下:

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) # 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() # 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()) # 添加loss

# 可视化
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个词直到最后一个词(不包括),后者是第一个词到结尾(包括最后一个词)。

生成诗歌

本项目实现两种生成诗歌的方式:给定诗歌的开头接着写诗歌;藏头诗。

续写诗歌

这种生成方式是根据给定部分文字,然后接着完成诗歌余下的部分。

  • 首先利用给定的文字,计算隐藏元,并预测下一个词。
  • 将上一步的隐藏元和输出作为新的输入,继续预测新的输出和计算隐藏元。
  • 。。。。。。

这里还有一个选项是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)
# 手动设置第一个词为<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)
# new()函数用来创建与输入的type和device都相同的空tensor
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() # 返回output中top1的第一个位置
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()

# 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))

实验结果分析

生成藏头诗:

深山喷雾吼不灭,胡兵髓菜无冬冬。度胡胡马邯郸市,碣兵走马车声里。学军使者何在人,杀我营营军未起。习家车马不可渝,当时汉武无能犒。

生成续写诗:

深度学习狐狸父,截泻巉巖石翎粟。巨鼇万里云气浮,英雄夜立乾坤宝。炎炎咆哮不可测,苍苍云飞生沙徼。三峡浮云起孤云,千门万户横河北。回看龙虎出汉宫,飞来势稍凌穹穹。天下一星出天马,五月四面蛟龙泉。射洪万国天一隅,苍蝇不碍蟾蜍倾。羣仙星龛不可测,霹雳怒之瞥如昨。安危熊鹜势相续,狮子黄金射雕髪。星纲照耀光照明,朝开紫极连云行。耿晨告止赴丰奏,回望关河信横旋。回头向日傍山去,却踏青云出帝城。西陵不死乌鸢死,

生成的很多诗歌都是高质量的,有些甚至已经学会了简单的对偶和押韵。如果生成的诗歌长度足够长,会发现生成的诗歌意境会慢慢改变,以至于和最开始毫无关系。

意境、格式和韵脚等信息都保存在隐藏元之中,随着输入的不断变化,隐藏元保存的信息也在不断变化,有些信息即使经过了很长时间依旧可以保存下来(比如诗歌的长短,五言还是七言),而有些信息随着输入变化也发生较大的改变。

总体上,程序生成的诗歌效果还不错,字词之间的组合也比较有意境,但是诗歌缺乏一个主题,很难从一首诗歌中得到一个主旨。这是因为随着诗歌长度的增长,即使LSTM也会忘记几十个字之前的输入。

参考文章

1.pytorch-book/chapter09-neural_poet_RNN

2.chinese-poetry

3.深度学习框架PyTorch:入门与实践_陈云(著) 第九章