一、概述
1.主题:整个文本将基于《安娜卡列妮娜》这本书的英文文本作为LSTM模型的训练数据,输入为单个字符,通过学习整个英文文档的字符(包括字母和标点符号等)来进行文本生成。
2.单词层级和字符层级的区别:
1、基于字符的语言模型的好处在于处理任何单词,标点和其他文档结构时仅需要很小的词汇量,而且更加灵活。
2、语言模型的目标是根据之前的 token 预测下一个 token。标准的做法是最后一层在词汇表的每个 token 上计算一个 softmax。当词汇库较大时,softmax 步骤和相关的梯度计算就会成为训练语言模型的性能瓶颈。
在开始建模之前,我们首先要明确我们的输入和输出。即输入是字符,输出是预测出的新字符。
3、相比于基于字符的模型,基于单词的模型显示出更高的准确性。这是因为后一种模式需要一个更大的网络来学习长期相关关系,因为它不仅要记住单词的顺序,还必须学会去预测一个单词在语法上的正确性。
3.原文
《安娜卡列尼娜》文本生成——利用TensorFlow构建LSTM模型
https://zhuanlan.zhihu.com/p/27087310
二、模型构建
文本生成模型构建主要包括以下四个部分:
-
1.数据预处理:加载数据、转换数据、分割数据mini-batch
-
2.模型构建:输入层,LSTM层,输出层,训练误差,loss,optimizer
-
3.模型训练:设置模型参数对模型进行训练
-
4.生成新文本:使用训练好的模型,输入一个单词,得到后续输出。
1. 数据预处理
思考:如何构造RNN中的训练格式的样本?
训练过程,输入的数据是以batch形式完成的。每个batch包含 batch_size 个样本,每个样本长度设置为 n_seqs,即包含n_seqs个字符。现在有一个长度为N个字符的原始文本。可知:训练集可以划分为m个batch
同时,原始文本先转换为字符对应的索引。
因为LSTM是用前一个字符预测后一个字符,所以,label 的值为 x 后移一个字符。
import time
import numpy as np
import tensorflow as tf
with open('data/anna.txt','r') as f:
text = f.read()
vocab = set(text)
vocab_to_int = {c:i for i,c in enumerate(vocab)}
int_to_vocab = dict(enumerate(vocab))
encoded = np.array([vocab_to_int[c] for c in text],dtype=np.int32)
def get_batches(arr,batch_size,n_seqs):
# batch_size:一个batch中样本数
# n_seqs:每个样本的长度
batch_len = batch_size * n_seqs
n_batches = int(len(arr)/ batch_len)
# 这里我们仅保留完整的batch,对于不能整除的部分进行舍弃
arr = arr[:batch_len * n_batches]
arr = arr.reshape((batch_size,-1))
for n in range(0,arr.shape[1],n_steps):
x = arr[:, n:n+n_steps]
y = np.zeros_like(x)
# y 为当前batch中x向左平移一个单位的结果
y[:,:-1],y[:,-1] = x[:,1:], x[:,0]
yield x,y
2. 输入层
每次输入一个batch
def build_inputs(batch_size, n_seqs):
'''
构建输入层
batch_size: 每个batch中的序列个数
n_seqs: 每个序列包含的字符数
'''
inputs = tf.placeholder(tf.int32, shape=(batch_size, n_seqs), name='inputs')
targets = tf.placeholder(tf.int32, shape=(batch_size, n_seqs), name='targets')
# 加入keep_prob
keep_prob = tf.placeholder(tf.float32, name='keep_prob')
return inputs, targets, keep_prob
3.LSTM层
tf.contrib.rnn有两个包:
BasicLSTMCell: 平常说的LSTM,
LSTMCell: LSTM升级版,加了clipping,projection layer,peep-hole等操作。
MultiRNNCell:实现了对基本LSTM cell的顺序堆叠。
dynamic_rnn:实现循环调用LSTMCell,实现神经网络前向计算。
def build_lstm(lstm_size,num_layers,batch_size,keep_prob):
def lstm_cell():
lstm = tf.nn.rnn_cell.BasicLSTMCell(lstm_size)
drop = tf.contrib.rnn.DropoutWrapper(lstm, output_keep_prob=keep_prob)
return drop
# 堆叠多个LSTM单元。每层的单元都是重新实例化的,而非同一个。
cell = tf.contrib.rnn.MultiRNNCell([lstm_cell() for _ in range(num_layers)])
initial_state = cell.zero_state(batch_size,tf.float32)
return cell,initial_state
4.输出层
指整个网络的的最终输出。
输出层采用softmax,它与LSTM进行全连接。对于每一个字符来说,它经过LSTM后的输出大小是(1,lstm_size),
所以一个batch经过LSTM的输出为((batch\_size,n\_seqs,lstm\_size))。要将这个输出与softmax全连接层建立连接,就需要将LSTM的输出reshape为((batch\_size*n\_seqs,lstm\_size))。
softmax层的结点数应该是vocab的大小(我们要计算概率分布)。因此整个LSTM层到softmax层权重矩阵的大小为((lstm\_size,vocab\_size))。
最终的输出logits为((batch\_size*n\_seqs,vocab\_size))
out为 ((batch\_size*n\_seqs,vocab\_size))
def build_output(lstm_output, in_size, out_size):
'''
构造输出层
lstm_output: lstm层的输出结果
in_size: lstm输出层重塑后的size
out_size: softmax层的size
'''
# 将lstm的输出按照列concate,例如[[1,2,3],[7,8,9]],
# tf.concat的结果是[1,2,3,7,8,9]
seq_output = tf.concat(lstm_output,1) # tf.concat(values,concat_dim)
# reshape
x = tf.reshape(seq_output, [-1, in_size])
# 将lstm层与softmax层全连接
with tf.variable_scope('softmax'):
softmax_w = tf.Variable(tf.truncated_normal([in_size, out_size], stddev=0.1))
softmax_b = tf.Variable(tf.zeros(out_size))
# 计算logits
logits = tf.matmul(x, softmax_w) + softmax_b
# softmax层返回概率分布
out = tf.nn.softmax(logits, name='predictions')
return out, logits
5.损失函数
采用tf.nn.softmax_cross_entropy_with_logits交叉熵来计算loss。
该函数进行两步运算:
首先对logits进行softmax计算,
根据softmax计算后的结果和labels来计算交叉熵损失。
计算出的结果是向量形式, shape = (batch_size,),因此需要 reduce_mean来进行求均值。
def build_loss(logits, targets, lstm_size, num_classes):
'''
根据logits和targets计算损失
logits: 全连接层的输出结果(不经过softmax)
targets: 真实标签,形状为(batch_size,n_seqs)
lstm_size
num_classes: vocab_size
'''
# One-hot编码
y_one_hot = tf.one_hot(targets, num_classes)
y_reshaped = tf.reshape(y_one_hot, logits.get_shape())
# Softmax cross entropy loss
loss = tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=y_reshaped)
loss = tf.reduce_mean(loss)
return loss
6. 优化器
采用gradient clippling的方式来防止梯度爆炸。即通过设置一个阈值,当gradients超过这个阈值时,就将它重置为阈值大小,这就保证了梯度不会变得很大。
优化器的构造流程:
1、找到网络中的可训练参数,因为要对 w,b进行更新,
2、计算梯度:tf.gradients(loss,训练参数),
3、梯度裁剪:tf.clip_by_global_norm(梯度,阈值),
4、实例化一个优化器:train_op = tf.train.AdadeltaOptimizer(学习率)
5、优化器进行梯度下降更新训练参数,得到一个op:optimizer = train_op.apply_gradients(zip(grades,tvars))
def build_optimizer(loss, learning_rate, grad_clip):
'''
构造Optimizer
loss: 损失
learning_rate: 学习率
'''
# 使用clipping gradients
tvars = tf.trainable_variables()
grads, _ = tf.clip_by_global_norm(tf.gradients(loss, tvars), grad_clip)
train_op = tf.train.AdamOptimizer(learning_rate)
optimizer = train_op.apply_gradients(zip(grads, tvars))
return optimizer
三、模型训练
1.经过上面五个步骤,我们完成了所有的模块设置。将这些部分组合起来,构建一个类。
class CharRNN:
def __init__(self, num_classes, batch_size=64, n_seqs=50,
lstm_size=128, num_layers=2, learning_rate=0.001,
grad_clip=5, sampling=False):
# 预测阶段,batch_size=1,文本长度=1;即输入一个字符,预测下一个字符。
if sampling == True:
batch_size, n_seqs = 1, 1
else:
batch_size, n_seqs = batch_size, n_seqs
tf.reset_default_graph()
# 输入层
self.inputs, self.targets, self.keep_prob = build_inputs(batch_size, n_seqs)
# LSTM层
cell, self.initial_state = build_lstm(lstm_size, num_layers, batch_size, self.keep_prob)
# 对输入进行one-hot编码
x_one_hot = tf.one_hot(self.inputs, num_classes)
# 运行RNN outputs [batch_size,n_seqs,lstm_size]
outputs, state = tf.nn.dynamic_rnn(cell, x_one_hot, initial_state=self.initial_state)
self.final_state = state
# 预测结果
self.prediction, self.logits = build_output(outputs, lstm_size, num_classes)
# Loss 和 optimizer (with gradient clipping)
self.loss = build_loss(self.logits, self.targets, lstm_size, num_classes)
self.optimizer = build_optimizer(self.loss, learning_rate, grad_clip)
2、训练阶段
1、实例化CharRNN
2、给定输入数据:feed字典
3、对sess.run([op],feed)
batch_size = 100 # Sequences per batch
n_seqs = 100 # 序列长度
lstm_size = 512 # Size of hidden layers in LSTMs
num_layers = 2 # Number of LSTM layers
learning_rate = 0.001 # Learning rate
keep_prob = 0.5 # Dropout keep probability``
config = tf.ConfigProto(
allow_soft_placement=True, # 自动选择CPU 还是GPU
log_device_placement=False # 是否打印设备日志
)
epochs = 20
save_every_n = 200 # 200个batch保存一次模型
model = CharRNN(len(vocab), batch_size=batch_size, n_seqs=n_seqs,
lstm_size=lstm_size, num_layers=num_layers,
learning_rate=learning_rate)
saver = tf.train.Saver(max_to_keep=100) # Maximum number of recent checkpoints to keep. Defaults to 5。默认保存最近的5个模型的参数。
with tf.Session(config=config) as sess:
sess.run(tf.global_variables_initializer())
counter = 0
for e in range(epochs):
new_state = sess.run(model.initial_state)
loss = 0
for x,y in get_batches(encoded,batch_size,n_seqs):
counter += 1
start = time.time()
feed = {
model.inputs:x,
model.targets:y,
model.keep_prob:keep_prob,
model.initial_state:new_state
}
batch_loss,new_state,_ = sess.run([model.loss,model.final_state,model.optimizer],feed_dict=feed)
end = time.time()
# control the print lines
if counter % 100 == 0:
print('轮数: {}/{}... '.format(e+1, epochs),
'训练步数: {}... '.format(counter),
'训练误差: {:.4f}... '.format(batch_loss),
'{:.4f} sec/batch'.format((end-start)))
if (counter % save_every_n == 0):
saver.save(sess, "checkpoints/i{}_l{}.ckpt".format(counter, lstm_size))
saver.save(sess,"checkpoints/i{}_l{}.ckpt".format(counter,lstm_size))
ps:训练过程遇到的问题
1、开始使用CPU进行训练,最后的训练误差为3.2,感觉出了问题。换成GPU之后:
轮数: 19/20... 训练步数: 3600... 训练误差: 1.2566... 0.1666 sec/batch
轮数: 19/20... 训练步数: 3700... 训练误差: 1.2511... 0.1556 sec/batch
轮数: 20/20... 训练步数: 3800... 训练误差: 1.1943... 0.1606 sec/batch
轮数: 20/20... 训练步数: 3900... 训练误差: 1.2473... 0.1626 sec/batch
四、文本生成:
现在我们可以基于我们的训练参数进行文本的生成。当我们输入一个字符时,LSTM会预测下一个字符,我们再将新的字符进行输入,这样能不断的循环下去生成本文。
为了减少噪音,每次的预测值我会选择最可能的前5个进行随机选择,比如输入h,预测结果概率最大的前五个为[o,e,i,u,b],我们将随机从这五个中挑选一个作为新的字符,让过程加入随机因素会减少一些噪音的生成。
def pick_top_n(preds, vocab_size, top_n=5):
"""
从预测结果中选取前top_n个最可能的字符
preds: 预测结果
vocab_size
top_n
"""
p = np.squeeze(preds)
# 将除了top_n个预测值的位置都置为0
p[np.argsort(p)[:-top_n]] = 0
# 归一化概率
p = p / np.sum(p)
# 随机选取一个字符
c = np.random.choice(vocab_size, 1, p=p)[0]
return c
np.squeeze(),从数组的形状中删除单维条目,即把shape中为1的维度去掉。
在预测阶段,输入样本形状为(1,1)
preds为输出层的输出,即(1,83),表示当前输入字符为字符表中每个字符的概率。
p = np.array([长度为83的列表]),取top_n个值。
def sample(checkpoint, n_samples, lstm_size, vocab_size, prime="The "):
"""
生成新文本
checkpoint: 某一轮迭代的参数文件
n_sample: 新文本的字符长度
lstm_size: 隐层结点数
vocab_size
prime: 起始文本
"""
# 将输入的单词转换为单个字符组成的list
samples = [c for c in prime]
# sampling=True意味着batch的size=1 x 1
model = CharRNN(len(vocab), lstm_size=lstm_size, sampling=True)
saver = tf.train.Saver()
with tf.Session() as sess:
# 加载模型参数,恢复训练
saver.restore(sess, checkpoint)
new_state = sess.run(model.initial_state)
for c in prime:
x = np.zeros((1, 1))
# 输入单个字符
x[0,0] = vocab_to_int[c]
feed = {model.inputs: x,
model.keep_prob: 1.,
model.initial_state: new_state}
preds, new_state = sess.run([model.prediction, model.final_state],
feed_dict=feed)
c = pick_top_n(preds, len(vocab))
# 添加字符到samples中
samples.append(int_to_vocab[c])
# 不断生成字符,直到达到指定数目
for i in range(n_samples):
x[0,0] = c
feed = {model.inputs: x,
model.keep_prob: 1.,
model.initial_state: new_state}
preds, new_state = sess.run([model.prediction, model.final_state],
feed_dict=feed)
c = pick_top_n(preds, len(vocab))
samples.append(int_to_vocab[c])
return ''.join(samples)
在for循环中,每次输出的结果包含字符和隐状态,隐状态作为下一步网络的输入,字符保存到列表作为最后的生成文本。
tf.train_latest_checkpoint()方法,可以选择最后的训练的参数作为网络参数。
# 选用最终的训练参数作为输入进行文本生成
checkpoint = tf.train.latest_checkpoint('checkpoints')
samp = sample(checkpoint, 2000, lstm_size, len(vocab), prime="The")
print(samp)
参考资料
1、字符级NLP优劣分析:在某些场景中比词向量更好用
https://flashgene.com/archives/28609.html
2、《安娜卡列尼娜》文本生成——利用TensorFlow构建LSTM模型
https://zhuanlan.zhihu.com/p/27087310
3、TensorFlow学习笔记(5):交叉熵损失函数实现
https://zhuanlan.zhihu.com/p/44901953