文本生成案例
文本生成任务是一种常见的自然语言处理任务,输入一个开始词能够预测出后面的词序列。本案例将会使用循环神经网络来实现周杰伦歌词生成任务。
数据集如下:
想要有直升机
想要和你飞到宇宙去
想要和你融化在一起
融化在宇宙里
我每天每天每天在想想想想著你
这样的甜蜜
让我开始相信命运
感谢地心引力
让我碰到你
漂亮的让我面红的可爱女人
...
数据集共有 5819 行。
1 构词词典
我们在进行自然语言处理任务之前,首要做的就是就是构建词表。所谓的词表就是将语料进行分词,然后给每一个词分配一个唯一的编号,便于我们送入词嵌入层。
最终,我们的词典主要包含了:
- word_to_index: 存储了词到编号的映射
- index_to_word: 存储了编号到词的映射
一般构建词表的流程如下:
- 语料清洗, 去除不相关的内容
- 对语料进行分词
- 构建词表
接下来, 我们对周杰伦歌词的语料数据按照上面的步骤构建词表。
import torch
import re
import jieba
from torch.utils.data import DataLoader
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import time
# 构建词典
def build_vocab():
fname = 'data/jaychou_lyrics.txt'
# 1. 文本数据清洗
clean_sentences = []
for line in open(fname, 'r',encoding="utf-8"):
# 去除指定的一些内容
line = line.replace('韩语Rap译文〗', '')
# 保留中文、数字、部分的标点符号
line = re.sub(r'[^\u4e00-\u9fa5 a-zA-Z0-9!?,]', '', line)
# 连续空格替换
line = re.sub(r'[ ]{2,}', '', line)
# 去除行两侧的空白和换行符
line = line.strip()
# 去除单字的行
if len(line) <= 1:
continue
if line not in clean_sentences:
clean_sentences.append(line)
# print(clean_sentences)
# 2. 分词
all_sentences = []
index_to_word = [] # 重要的词表: 索引到词的映射
# 1.遍历所有的句子
for line in clean_sentences:
# 对每个句子进行分词,得到列表
words = jieba.lcut(line)
# 将每个句子对应的列表添加到all_sentences中
# 便于后面我们将句子转换为索引表示
all_sentences.append(words)
# 遍历分词列表中的所有词
for word in words:
# 添加到index_to_word表中
# index_to_word - 是包含所有词汇的表
if word not in index_to_word:
index_to_word.append(word)
# print(all_sentences)
# print(index_to_word)
# 重要词表: 词到索引的映射
# 使用enumerate方便构建每个词到index的索引,这里的index_to_word表没有进行去重
word_to_index = {word: idx for idx, word in enumerate(index_to_word)}
# 3. 将输入的语料转换为索引表示
corpus_index = [] # 语料的索引表示
# 3.1 遍历每个句子对应的的列表
for sentence in all_sentences:
temp = []
# 3.2 遍历每个句子的词,找到对应的索引
# 将词对应的索引放到一个temp表中
for word in sentence:
temp.append(word_to_index[word])
# 在每行歌词的后面添加一个空格
# 这里添加的是空格的索引值
temp.append(word_to_index[' '])
corpus_index.extend(temp)
# print(corpus_index)
return index_to_word, word_to_index, len(index_to_word), corpus_index
def test01():
index_to_word, word_to_index, word_count, corpus_idx = build_vocab()
print(word_count)
print(index_to_word)
print(word_to_index)
print(corpus_idx)
2 构建数据集对象
我们在训练的时候,为了便于读取语料,并送入网络,所以我们会构建一个 Dataset 对象,并使用该对象构建 DataLoader 对象,然后对 DataLoader 对象进行迭代可以获取语料,并将其送入网络。
# 编写数据集类
# 本质是自定义的可迭代对象
# dataloader会遍历这个可迭代对象,内部生成一个生成器
# 从而取出所有数据
class LyricsDataset:
# 传入语料索引列表
# num_chars - 语料的长度
# 1. 实现__init__
def __init__(self, corpus_index, num_chars):
"""
:param corpus_index: 语料的索引表示
"""
# 语料数据
self.corpus_index = corpus_index
# 语料长度
self.num_chars = num_chars
# 语料词的总数量
self.word_count = len(corpus_index)
# 计算句子长度有多少个
self.number = self.word_count // self.num_chars
# 实现__len__ 返回数据集的长度
def __len__(self):
return self.number
# __getitem__ 根据index获取到对应的数据
def __getitem__(self, idx):
# 输入的 idx 可能不是合法的
start = min(max(idx, 0), self.word_count - self.num_chars - 2)
# 获取一条样本,就会有 x, 就会有 y
x = self.corpus_index[start: start + self.num_chars]
y = self.corpus_index[start + 1: start + 1 + self.num_chars]
# x = 0, 1, 2, 39, 0
# y = 1, 2, 39, 0, 3
return torch.tensor(x), torch.tensor(y)
def test02():
# 获取词向量表,word长度,语料索引列表
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
# 自定义数据集对象
# 相当于是一个可迭代对象
lyrics = LyricsDataset(corpus_index, 1)
# 注意: batch_size = 1
# 每次自动从lyrics中取出batch_size个样本
dataloader = DataLoader(lyrics, shuffle=False, batch_size=5)
for x, y in dataloader:
print(x)
print(y)
break
3 构建网络模型
我们用于实现《歌词生成》的网络模型,主要包含了三个层:
- 词嵌入层: 用于将语料转换为词向量
- 循环网络层: 提取句子语义
- 全连接层: 输出对词典中每个词的预测概率
我们前面学习了 Dropout 层,它具有正则化作用,所以在我们的网络层中,我们会对词嵌入层、循环网络层的输出结果进行 Dropout 计算。
示例代码如下:
# 构建循环神经网络
class TextGenerator(nn.Module):
def __init__(self):
super(TextGenerator, self).__init__()
# 初始化词嵌入层
# 词嵌入层,传入的是全部词汇的数量
# 维度采用128个维度表示一个词
self.ebd = nn.Embedding(num_embeddings=word_len, embedding_dim=128)
# 初始化循环网络层
self.rnn = nn.RNN(input_size=128, hidden_size=128)
# 初始化输出层, 预测的标签数量为词典中词的总数量
self.out = nn.Linear(128, word_len)
# inputs(1,5)
def forward(self, inputs, hidden):
embed = self.ebd(inputs)
# embed 的形状是 (1, 5, 128)
# print(embed.shape)
# 正则化
embed = F.dropout(embed, p=0.2)
# 送入循环网络层
# output 表示的是每一个时间步的输出
# 输入(seq_len,batch_size,hidden_size)
# 词嵌入层输出结果是(batch_size,seq_len,hidden_size),所以对词嵌入层的输出结果进行交换维度
output, hidden = self.rnn(embed.transpose(0, 1), hidden)
# 将 output 送入到全连接层得到输出
output = self.out(output)
return output, hidden
def init_hidden(self):
return torch.zeros(1, 1, 128)
def test02():
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
lyrics = LyricsDataset(corpus_index, 5)
# 注意: batch_size = 1
dataloader = DataLoader(lyrics, shuffle=False, batch_size=1)
# 初始化网络对象
model = TextGenerator()
for x, y in dataloader:
# 初始化隐藏状态
hidden = model.init_hidden()
# x shape (1,5) 词向量长度为5,每次送入一个样本
y_pred, hidden = model(x, hidden)
print(y_pred.shape)
break
4 构建训练函数
前面的准备工作完成之后, 我们就可以编写训练函数。训练函数主要负责编写数据迭代、送入网络、计算损失、反向传播、更新参数,其流程基本较为固定。
由于我们要实现文本生成,文本生成本质上,输入一串文本,预测下一个文本,也属于分类问题,所以,我们使用多分类交叉熵损失函数。优化方法我们学习过 SGB、AdaGrad、Adam 等,在这里我们选择学习率、梯度自适应的 Adam 算法作为我们的优化方法。
训练完成之后,我们使用 torch.save 方法将模型持久化存储。
def train():
# 构建词典
index_to_word, word_to_index, word_count, corpus_idx = build_vocab()
# 数据集
lyrics = LyricsDataset(corpus_idx, 32)
# 初始化模型
model = TextGenerator(word_count)
# 损失函数
criterion = nn.CrossEntropyLoss()
# 优化方法
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 训练轮数
epoch = 200
# 迭代打印
iter_num = 300
# 训练日志
train_log = 'lyrics_training.log'
file = open(train_log, 'w')
# 开始训练
for epoch_idx in range(epoch):
# 数据加载器
lyrics_dataloader = DataLoader(lyrics, shuffle=True, batch_size=1)
# 训练时间
start = time.time()
# 迭代次数
iter_num = 0
# 训练损失
total_loss = 0.0
for x, y in lyrics_dataloader:
# 隐藏状态
hidden = model.init_hidden()
# 模型计算
output, hidden = model(x, hidden)
# 计算损失
loss = criterion(output, y.squeeze())
# 梯度清零
optimizer.zero_grad()
# 反向传播
loss.backward()
# 参数更新
optimizer.step()
iter_num += 1
total_loss += loss.item()
message = 'epoch %3s loss: %.5f time %.2f' % \
(epoch_idx + 1,
total_loss / iter_num,
time.time() - start)
print(message)
file.write(message + '\n')
file.close()
# 模型存储
torch.save(model.state_dict(), 'model/lyrics_model_%d.bin' % epoch)
5 构建预测函数
到了最后一步,我们从磁盘加载训练好的模型,进行预测。预测函数,输入第一个指定的词,我们将该词输入网路,预测出下一个词,再将预测的出的词再次送入网络,预测出下一个词,以此类推,知道预测出我们指定长度的内容。
# 训练函数
def train():
# 构建词典
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
# 数据集
lyrics = LyricsDataset(corpus_index, 32)
# 初始化模型
model = TextGenerator()
# 损失函数
criterion = nn.CrossEntropyLoss()
# 优化方法
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 训练轮数
epoch = 10
# 迭代打印
iter_num = 300
# 开始训练
for epoch_idx in range(epoch):
# 初始化数据加载器
dataloader = DataLoader(lyrics, shuffle=True, batch_size=1)
# 训练时间
start = time.time()
# 迭代次数
iter_num = 0
# 训练损失
total_loss = 0.0
for x, y in dataloader:
# 初始化隐藏状态
hidden = model.init_hidden()
# 送入网络计算
output, _ = model(x, hidden)
# 计算损失
print(output.shape) # torch.Size([32, 1, 5682])
print(y.shape) # torch.Size([1, 32])
loss = criterion(output.squeeze(), y.squeeze())
# 梯度清零
optimizer.zero_grad()
# 反向传播
loss.backward()
# 参数更新
optimizer.step()
iter_num += 1
total_loss += loss.item()
info = 'epoch:%3s loss:%.5f time:%.2f' % \
(epoch_idx,
total_loss / iter_num,
time.time() - start)
print(info)
# 模型保存
torch.save(model.state_dict(), 'model/text-generator.pth')
# 预测函数
def predict(start_word, sentence_length):
# 构建词典
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
# 加载模型
model = TextGenerator()
model.load_state_dict(torch.load('model/text-generator.pth'))
model.eval()
# 初始化隐藏状态
hidden = model.init_hidden()
# 首先, 将 start_word 转换为索引
word_idx = word_to_index[start_word]
generate_sentence = [word_idx]
for _ in range(sentence_length):
output, hidden = model(torch.tensor([[word_idx]]), hidden)
# 选择分数最大的词作为预测词
word_idx = torch.argmax(output)
generate_sentence.append(word_idx)
# 最后, 将索引序列转换为词的序列
for idx in generate_sentence:
print(index_to_word[idx], end='')
print()
if __name__ == '__main__':
predict('分手', 50)
程序运行结果:
分手的话像语言暴力 我已无能为力再提起 决定中断熟悉 周杰伦 周杰伦 一步两步三步四步望著天 看星星 一颗两颗三颗四颗 连成线一步两步三
6 完整代码
import torch
import re
import jieba
from torch.utils.data import DataLoader
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import time
# 构建词典
def build_vocab():
fname = 'data/jaychou_lyrics.txt'
# 1. 文本数据清洗
clean_sentences = []
for line in open(fname, 'r',encoding="utf-8"):
# 去除指定的一些内容
line = line.replace('韩语Rap译文〗', '')
# 保留中文、数字、部分的标点符号
line = re.sub(r'[^\u4e00-\u9fa5 a-zA-Z0-9!?,]', '', line)
# 连续空格替换
line = re.sub(r'[ ]{2,}', '', line)
# 去除行两侧的空白和换行符
line = line.strip()
# 去除单字的行
if len(line) <= 1:
continue
if line not in clean_sentences:
clean_sentences.append(line)
# print(clean_sentences)
# 2. 分词
all_sentences = []
index_to_word = [] # 重要的词表: 索引到词的映射
# 1.遍历所有的句子
for line in clean_sentences:
# 对每个句子进行分词,得到列表
words = jieba.lcut(line)
# 将每个句子对应的列表添加到all_sentences中
# 便于后面我们将句子转换为索引表示
all_sentences.append(words)
# 遍历分词列表中的所有词
for word in words:
# 添加到index_to_word表中
# index_to_word - 是包含所有词汇的表
if word not in index_to_word:
index_to_word.append(word)
# print(all_sentences)
# print(index_to_word)
# 重要词表: 词到索引的映射
# 使用enumerate方便构建每个词到index的索引,这里的index_to_word表没有进行去重
word_to_index = {word: idx for idx, word in enumerate(index_to_word)}
# 3. 将输入的语料转换为索引表示
corpus_index = [] # 语料的索引表示
# 3.1 遍历每个句子对应的的列表
for sentence in all_sentences:
temp = []
# 3.2 遍历每个句子的词,找到对应的索引
# 将词对应的索引放到一个temp表中
for word in sentence:
temp.append(word_to_index[word])
# 在每行歌词的后面添加一个空格
# 这里添加的是空格的索引值
temp.append(word_to_index[' '])
corpus_index.extend(temp)
# print(corpus_index)
return index_to_word, word_to_index, len(index_to_word), corpus_index
# 调用构建词典的函数
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
# 编写数据集类
# 本质是自定义的可迭代对象
# dataloader会遍历这个可迭代对象,内部生成一个生成器
# 从而取出所有数据
class LyricsDataset:
# 传入语料索引列表
# num_chars - 语料的长度
# 1. 实现__init__
def __init__(self, corpus_index, num_chars):
"""
:param corpus_index: 语料的索引表示
"""
# 语料数据
self.corpus_index = corpus_index
# 语料长度
self.num_chars = num_chars
# 语料词的总数量
self.word_count = len(corpus_index)
# 计算句子长度有多少个
self.number = self.word_count // self.num_chars
# 实现__len__ 返回数据集的长度
def __len__(self):
return self.number
# __getitem__ 根据index获取到对应的数据
def __getitem__(self, idx):
# 输入的 idx 可能不是合法的
start = min(max(idx, 0), self.word_count - self.num_chars - 2)
# 获取一条样本,就会有 x, 就会有 y
x = self.corpus_index[start: start + self.num_chars]
y = self.corpus_index[start + 1: start + 1 + self.num_chars]
# x = 0, 1, 2, 39, 0
# y = 1, 2, 39, 0, 3
return torch.tensor(x), torch.tensor(y)
def test01():
# 获取词向量表,word长度,语料索引列表
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
# 自定义数据集对象
# 相当于是一个可迭代对象
lyrics = LyricsDataset(corpus_index, 5)
# 注意: batch_size = 1
# 每次自动从lyrics中取出batch_size个样本
dataloader = DataLoader(lyrics, shuffle=False, batch_size=5)
for x, y in dataloader:
print(x)
print(y)
break
# 构建循环神经网络
class TextGenerator(nn.Module):
def __init__(self):
super(TextGenerator, self).__init__()
# 初始化词嵌入层
# 词嵌入层,传入的是全部词汇的数量
# 维度采用128个维度表示一个词
self.ebd = nn.Embedding(num_embeddings=word_len, embedding_dim=128)
# 初始化循环网络层
self.rnn = nn.RNN(input_size=128, hidden_size=128)
# 初始化输出层, 预测的标签数量为词典中词的总数量
self.out = nn.Linear(128, word_len)
# inputs(1,5)
def forward(self, inputs, hidden):
embed = self.ebd(inputs)
# embed 的形状是 (1, 5, 128)
# print(embed.shape)
# 正则化
embed = F.dropout(embed, p=0.2)
# 送入循环网络层
# output 表示的是每一个时间步的输出
# 输入(seq_len,batch_size,hidden_size)
# 词嵌入层输出结果是(batch_size,seq_len,hidden_size),所以对词嵌入层的输出结果进行交换维度
output, hidden = self.rnn(embed.transpose(0, 1), hidden)
print(output.shape)
# 将 output 送入到全连接层得到输出
output = self.out(output)
return output, hidden
def init_hidden(self):
return torch.zeros(1, 1, 128)
def test02():
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
lyrics = LyricsDataset(corpus_index, 5)
# 注意: batch_size = 1
dataloader = DataLoader(lyrics, shuffle=False, batch_size=1)
# 初始化网络对象
model = TextGenerator()
for x, y in dataloader:
# 初始化隐藏状态
hidden = model.init_hidden()
# x shape (1,5) 词向量长度为5,每次送入一个样本
y_pred, hidden = model(x, hidden)
print(y_pred.shape)
break
# 训练函数
def train():
# 构建词典
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
# 数据集
lyrics = LyricsDataset(corpus_index, 32)
# 初始化模型
model = TextGenerator()
# 损失函数
criterion = nn.CrossEntropyLoss()
# 优化方法
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 训练轮数
epoch = 10
# 迭代打印
iter_num = 300
# 开始训练
for epoch_idx in range(epoch):
# 初始化数据加载器
dataloader = DataLoader(lyrics, shuffle=True, batch_size=1)
# 训练时间
start = time.time()
# 迭代次数
iter_num = 0
# 训练损失
total_loss = 0.0
for x, y in dataloader:
# 初始化隐藏状态
hidden = model.init_hidden()
# 送入网络计算
output, _ = model(x, hidden)
# 计算损失
print(output.shape) # torch.Size([32, 1, 5682])
print(y.shape) # torch.Size([1, 32])
loss = criterion(output.squeeze(), y.squeeze())
# 梯度清零
optimizer.zero_grad()
# 反向传播
loss.backward()
# 参数更新
optimizer.step()
iter_num += 1
total_loss += loss.item()
info = 'epoch:%3s loss:%.5f time:%.2f' % \
(epoch_idx,
total_loss / iter_num,
time.time() - start)
print(info)
# 模型保存
torch.save(model.state_dict(), 'model/text-generator.pth')
# 预测函数
def predict(start_word, sentence_length):
# 构建词典
index_to_word, word_to_index, word_len, corpus_index = build_vocab()
# 加载模型
model = TextGenerator()
model.load_state_dict(torch.load('model/text-generator.pth'))
model.eval()
# 初始化隐藏状态
hidden = model.init_hidden()
# 首先, 将 start_word 转换为索引
word_idx = word_to_index[start_word]
generate_sentence = [word_idx]
for _ in range(sentence_length):
output, hidden = model(torch.tensor([[word_idx]]), hidden)
# 选择分数最大的词作为预测词
word_idx = torch.argmax(output)
generate_sentence.append(word_idx)
# 最后, 将索引序列转换为词的序列
for idx in generate_sentence:
print(index_to_word[idx], end='')
print()
if __name__ == '__main__':
# test01()
# test02()
# build_vocab()
# train()
# predict('分手', 50)