花书第六章: 循环神经网络加文本生成学习笔记
''''''
'''6.3 语言模型数据集(周杰伦专辑歌词)'''
'''学习github网址:https://github.com/ShusenTang/Dive-into-DL-PyTorch/blob/master/docs/chapter06_RNN/6.3_lang-model-dataset.md'''
import torch
import torch.nn.functional as F
import random
import zipfile # 用于打开zip压缩文件,并能读取zip压缩文件内的子文件的包
from collections import Counter
import math
'''6.3.1 读取数据集'''
class Load_Data_jay_lyrics():
def __init__(self,):
# 用zipfile包打开zip压缩文件,并读取zip压缩文件内的语料库文档
with zipfile.ZipFile(file="./jaychou_lyrics.txt.zip") as zip:
with zip.open(name="jaychou_lyrics.txt") as f:
corpus_chars = f.read().decode("utf-8")
# print(type(corpus_chars))
# 此时的语料库文档corpus_chars为一个长字符串str, 这个数据集有6万多个字符。为了打印方便,我们把换行符替换成空格,然后仅使用前1万个字符来训练模型
self.corpus_chars = corpus_chars.replace("\n", " ").replace("\r", " ")
self.corpus_chars = self.corpus_chars[:15000] # 仅使用前1万个字符来训练模型
# 将语料库词典corpus_vocab中的所有字符单独提取出来存入idx_to_char列表中。在语料库词典中统计数量越多的字符在idx_to_char列表中的索引位置越靠前
self.idx_to_char = None # 列表
# 根据语料库词典corpus_vocab与idx_to_char列表,构建"语料库索引词典char_to_idx",即idx_to_char列表中不重复的字符与其对应的索引号组成的字典
self.char_to_idx = None # 词典
def load_data_jay_lyrics(self):
''''''
''' 我们将每个字符映射成一个从0开始的连续整数,又称索引,来方便之后的数据处理。为了得到索引,我们将数据集里所有不同字符取出来,
然后将其逐一映射到索引来构造词典。接着,打印vocab_size,即词典中不同字符的个数,又称词典大小。'''
# 将长字符串语料库self.corpus_chars转换为列表,再用Counter对象统计其每种字符的个数,个数多的字符排在更前面
# 由此就构建出了语料库字典corpus_vocab
corpus_vocab = Counter(list(self.corpus_chars))
'''Counter对象的most_common()方法可以将统计的字符按照从大到小顺序排列,而下方的char_count为一个元组对象,
元组的第一个值为字符,第二个值为字符统计的个数'''
# for char_count in corpus_vocab.most_common():
# print(char_counter,type(char_counter))
# 将语料库词典corpus_vocab中的所有字符单独提取出来存入idx_to_char列表中。在语料库词典中统计数量越多的字符在idx_to_char列表中的索引位置越靠前
# idx_to_char列表中字符的位置索引即为 "语料库索引词典char_to_idx" 中字符对应的索引号,
# 因此可依据一个字符在"语料库索引词典char_to_idx" 中的索引号直接在idx_to_char列表的相应位置索引处获取对应的字符
idx_to_char = [char_count[0] for char_count in corpus_vocab.most_common()]
self.idx_to_char = idx_to_char
# 根据语料库词典corpus_vocab与idx_to_char列表,构建"语料库索引词典char_to_idx",即idx_to_char列表中不重复的字符与其对应的索引号组成的字典
# 在此处由于语料库词典corpus_vocab中统计数量越多的字符在idx_to_char列表中的索引位置越靠前,因此统计数量越多的字符在"语料库索引词典char_to_idx"中
# 的对应的索引号也就越小
char_to_idx = dict([(char, idx) for idx, char in enumerate(idx_to_char)])
self.char_to_idx = char_to_idx
vocab_size = len(self.corpus_chars) # 语料库词典的大小
corpus_indices = [char_to_idx[char] for char in self.corpus_chars]
return corpus_indices, idx_to_char, char_to_idx, vocab_size
# 构建一个将语料库中的字符映射成为向量的函数
def other_char_to_idx(self,chars):
return [ self.char_to_idx[char] for char in chars ]
'''6.3.3 时序数据的采样: 6.3.3.1 随机采样'''
'''下面的代码每次从数据里随机采样一个小批量。
其中批量大小batch_size指每个小批量的样本数,seq_len为每个样本所包含的时间步数,即每条文本样本数据包含的字符char或者token的数量。
在随机采样中,每个样本是原始序列上任意截取的一段序列。相邻的两个随机小批量在原始序列上的位置不一定相毗邻。
因此,我们无法用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态。在训练模型时,每次随机采样前都需要重新初始化隐藏状态。'''
def data_iter_random(corpus_indices, batch_size, seq_len, device=None):
# 减1是因为输出的索引x是相应输入的索引y加1
num_examples = (len(corpus_indices) - 1) // seq_len # 取整
batch_len = num_examples // batch_size # 取整
example_indices = list(range(num_examples))
# 随机打乱样本数据在corpus_indices中的索引编号
random.shuffle(example_indices)
# 返回从pos开始的长为num_steps的序列
def _data(pos):
return corpus_indices[pos: pos + seq_len]
if device is None:
# device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
device = torch.device('cpu')
for i in range(batch_len):
# 每次读取batch_size个随机样本
i = i * batch_size
batch_indices = example_indices[i: i + batch_size]
X = [_data( example_indice_num * seq_len ) for example_indice_num in batch_indices]
Y = [_data( example_indice_num * seq_len + 1 ) for example_indice_num in batch_indices]
yield torch.tensor(X, dtype=torch.float32, device=device), torch.tensor(Y, dtype=torch.float32, device=device)
# 在一个函数中,程序执行到yield语句的时候,程序暂停,返回yield后面表达式的值,在下一次调用的时候,从yield语句暂停的地方继续执行,如此循环,直到函数执行完。
'''6.4 循环神经网络的从零开始实现'''
'''https://github.com/CurisZhou/Dive-into-DL-PyTorch/blob/master/docs/chapter06_RNN/6.4_rnn-scratch.md'''
'''构建RNNs类模型中的GRU模型进行文本生成'''
class GRU_Text_Generation(torch.nn.Module):
def __init__(self,vocab_size, batch_size, embed_dim, enc_hid_dim, dropout_prob ):
super(GRU_Text_Generation,self).__init__()
self.vocab_size = vocab_size
self.enc_hid_dim = enc_hid_dim
# 在此模型中用随机编码词嵌入(word embedding)来替代原教材中的one-hot编码词嵌入,这样可以解决词嵌入特征稀疏化的问题
self.embedding = torch.nn.Embedding(num_embeddings=vocab_size, embedding_dim=embed_dim)
self.dropout = torch.nn.Dropout(dropout_prob) # 此处dropout_prob为embedding层后dropout层的随机dorpout概率
# 此处使用双向GRU网络,进行文本序列的双向编码
self.gru = torch.nn.GRU(input_size=embed_dim, hidden_size=enc_hid_dim, bias=True, bidirectional=True)
# 此处的全连接层接在循环神经网络GRU后面(实际为接在GRU的隐藏层后面),其接受GRU循环神经网络层中每一个时间步(time_step)的最终隐藏层输出hidden_state
# 并对其进行最终的(线性)分类变换,将每个时间步隐藏层的输出hidden_state的第三个维度由enc_hid_dim*2(单向则为enc_hid_dim)变换为语料库词典中的词数vocab_size
self.fc_output = torch.nn.Linear(enc_hid_dim*2, vocab_size)
# 此函数用来初始化输入GRU循环神经网络中的,第0个时间步(time_step_0)的隐藏状态h_0张量,h_0的张量形状为(num_layers * num_directions, batch, hidden_size)
def init_hidden_0(self,batch_size):
return torch.zeros((2, batch_size, self.enc_hid_dim))
def forward(self,input):
# input张量初始形状为:(batch,seq_len)
# 在后方输入embedding层中的张量应为int型张量(int32或者int64),因此此处使用torch.Tensor.long()函数将张量转为int型张量
input = input.long()
# 获取当前批次的input的batch_size,以方便传给后方的self.init_hidden_0(batch_size = batch_size)函数
# 来初始化GRU的初始隐藏状态h_0: (num_layers * num_directions, batch_size, enc_hid_size)
batch_size = input.shape[0]
# 经过embedding层后的input的形状为: (batch, seq_len, embed_dim)
input = self.dropout( self.embedding(input) )
# 此处利用张量的permute()函数将input张量的第一维与第二维交换,此时input张量的形状为(seq_len, batch,embed_dim),
# 这样input张量才符合输入GRU网络层的要求
input = input.permute(1,0,2)
# 获取输入GRU循环神经网络中的,第0个时间步(time_step_0)的隐藏状态h_0张量,h_0的张量形状为(num_layers * num_directions, batch, hidden_size)
hidden_0 = self.init_hidden_0(batch_size = batch_size)
gru_outputs,gru_hidden = self.gru(input, hidden_0)
# 此时gru_outputs张量的形状为: (seq_len, batch, num_directions * hidden_size) --> (seq_len, batch, 2 * enc_hid_dim)
# 此时gru_hidden张量的形状为: (num_directions, batch, hidden_size) --> (2, batch, enc_hid_dim), 为最后一个时间步的隐藏状态
# 此时gru_outputs张量的形状为: (batch, seq_len, 2 * enc_hid_dim)
gru_outputs = gru_outputs.permute(1,0,2)
# 此时将两个方向上的gru_hidden合并起来后, gru_hidden张量的形状为: (batch, 2 * enc_hid_dim)
gru_hidden = torch.cat( (gru_hidden[-2,:,:], gru_hidden[-1,:,:]), dim=1)
# 此时final_outputs张量的形状为: (batch, seq_len, vocab_size)
final_outputs = self.fc_output(gru_outputs)
# 此时predict_output张量的形状为: (batch, vocab_size)
predict_output = F.softmax( self.fc_output(gru_hidden), dim=1)
return final_outputs, predict_output
# 接下来基于文本生成模型GRU_Text_Generation()定义文本生成函数. 以下函数基于前缀prefix(含有数个字符的字符串)来生成(预测)接下来的num_gene个字符
def text_generation(prefix, num_gene, text_generation_model, idx_to_char, char_to_idx, device):
# text_generation_model = text_generation_model
# 文本生成模型text_generation_model(GRU_Text_Generation()类)变为测试模式
text_generation_model.eval()
# 此时生成文本时,batch相当于1,而此处将设定的seq_len变量的值作为seq_len来生成下一个字符 (也可将输入的prefix的长度作为seq_len的值).
# 下一个字符生成之后,再将新生成的字符纳入seq_len的末尾,从原先的seq_len的开头删去一个字符,保证seq_len长度不变再来生成下一个字符.
outputs = [ char_to_idx[ prefix[0] ]] # 现在下一个字符的生成仅基于上一个字符,即seq_len设为1,因此outputs在此为: [ char_to_idx[ prefix[0] ]]
# outputs = [char_to_idx[char] for char in prefix]
seq_len = 1 # 此时seq_len设为1
# seq_len = len(prefix)
# 生成num_gene个字符
for i in range(num_gene + len(prefix) - 1):
# for i in range(num_gene - 1):
# 将索引完元素的outputs[-seq_len:]变为一个二维张量,此时张量outputs_to_gru_inputs的形状为: (batch,seq_len) --> (1, seq_len)
# 必须用一个unsqueeze(0)才能将outputs[-seq_len:]变为一个二维张量
outputs_to_gru_inputs = torch.tensor(outputs[-seq_len:],device=device).unsqueeze(0)
# 此时只需用到predict_output张量,predict_output张量的形状为: (batch, vocab_size) --> (1, vocab_size)
final_outputs, predict_output = text_generation_model(outputs_to_gru_inputs)
if i < len(prefix) - 1:
outputs.append(char_to_idx[ prefix[i + 1] ])
else:
# 将新生成的字符的索引值加入outputs列表中,以便下一轮字符生成
outputs.append( int(predict_output[0].argmax().item()) )
print(outputs)
print("Text generation results: {}".format( "".join([ idx_to_char[idx] for idx in outputs[len(prefix):] ]) ))
# 梯度裁剪函数
def grad_clipping(model,device,theta=1e-2):
norm = torch.tensor([0.0], device=device)
for param in model.parameters():
norm += (param.grad.data ** 2).sum()
norm = norm.sqrt().item()
if norm > theta:
for param in model.parameters():
param.grad.data *= (theta / norm)
# 训练文本生成模型GRU_Text_Generation(),并利用GRU_Text_Generation()模型生成文本
def train_and_predict(epoch_num, batch_size, seq_len, embed_dim, enc_hid_dim, dropout_prob, text_generation_model, prefix, num_gene):
# device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
device = torch.device('cpu')
# 读取数据集
load_data = Load_Data_jay_lyrics()
corpus_indices, idx_to_char, char_to_idx, vocab_size = load_data.load_data_jay_lyrics()
# 此处初始化文本生成模型GRU_Text_Generation()类
text_generation_model = text_generation_model(vocab_size=vocab_size, batch_size=batch_size, embed_dim=embed_dim,
enc_hid_dim=enc_hid_dim, dropout_prob=dropout_prob)
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(text_generation_model.parameters(), lr=1e-3, betas=(0.9, 0.999))
for epoch in range(epoch_num):
# 将模型变为训练模式,因为模型可能在上一个epoch的末尾变为测试模式用于生成文本
text_generation_model.train()
# 计算困惑度: math.exp(loss_sum / n)
loss_sum, n = 0.0, 0
for batch_x, batch_y in data_iter_random(corpus_indices=corpus_indices, batch_size=batch_size, seq_len=seq_len,device=device):
# 此处的batch_x与batch_y都为形状为(batch_size, seq_len)的张量
# 此时final_outputs张量的形状为: (batch, seq_len, vocab_size)
# 此时predict_output张量的形状为: (batch, vocab_size)
# 但在训练时用不到predict_output,只用final_outputs来计算loss值与反向传播,和梯度下降参数更新
final_outputs, predict_output = text_generation_model(batch_x)
# 将final_outputs的第一维度与第二维度替换,再合并其第一维度与第二维度将final_outputs变为一个二维张量以方便计算损失值loss
# 变换之后的final_outputs形状为: (seq_len * batch_size, vocab_size)。 注意此处使用view()前要用contiguous(),而reshape()则不需要
final_outputs = final_outputs.permute(1,0,2).contiguous().view(-1,vocab_size)
# 将batch_y的第一维度与第二维度替换,再合并其第一维度与第二维度将batch_y变为一个一维张量以方便计算损失值loss
# 变换之后的batch_y形状为: (seq_len * batch_size,)。注意此处使用view()前要用contiguous(),而reshape()则不需要
batch_y = batch_y.permute(1,0).contiguous().view(-1)
# 输入损失函数CrossEntropyLoss()中的真实标签张量batch_y应为int型张量(int32或者int64),因此此处使用torch.Tensor.long()函数将张量转为int型张量
batch_y = batch_y.long()
loss = criterion(final_outputs,batch_y) # 计算损失值loss
# print("loss: {}".format(loss.item()))
optimizer.zero_grad() # 先清空上一次迭代计算的参数梯度值,防止影响这次迭代的梯度裁剪与梯度下降
loss.backward() # 反向传播
grad_clipping(model=text_generation_model, device=device, theta=1e-2) # 梯度裁剪函数
optimizer.step() # 梯度下降,参数更新
loss_sum += loss.item() * batch_y.shape[0]
n += batch_y.shape[0]
if (epoch + 1) % 5 == 0:
print("\nEpoch {}: ".format(epoch + 1))
print("困惑度: {}".format(math.exp(loss_sum / n)))
text_generation(prefix, num_gene, text_generation_model, idx_to_char, char_to_idx, device)
if (epoch + 1) % 15 == 0:
optimizer.param_groups[0]["lr"] *= 0.1 # 将优化器学习率变为原先0.1倍
if __name__ == "__main__":
# load_data = Load_Data_jay_lyrics()
# corpus_indices, idx_to_char, char_to_idx, vocab_size = load_data.load_data_jay_lyrics()
# # print('chars:', ''.join([load_data.idx_to_char[idx] for idx in corpus_indices]))
#
# for batch_x,batch_y in data_iter_random(corpus_indices=corpus_indices,batch_size=32,seq_len=9):
# print("batch x: ",batch_x)
# print("batch y: ",batch_y,"\n")
train_and_predict(epoch_num=30, batch_size=32, seq_len=36, embed_dim=200, enc_hid_dim=256, dropout_prob=0.5,
text_generation_model=GRU_Text_Generation, prefix="想你的夜", num_gene=50)