TensorFlow深度学习实战——基于循环神经网络的文本生成模型
- 0. 前言
- 1. 模型与数据集分析
- 2. 数据处理
- 3. 模型构建与训练
- 4. 模型训练结果
- 相关连接
0. 前言
循环神经网络 (Recurrent Neural Network, RNN) 在自然语言处理 (Natural Language Processing
, NLP
) 领域中被广泛应用于各种任务,其中一种应用是构建语言模型。语言模型能够根据前面的单词预测文本中下一单词,语言模型对于机器翻译、拼写校正等高级任务至关重要。语言模型预测序列中下一个单词的能力使其成为一种生成模型,可以通过从词汇中不同单词的输出概率中进行采样生成文本。训练数据是一个单词序列,标签是序列中下一时刻出现的单词。
1. 模型与数据集分析
在本节中,我们将使用 Lewis Carroll
的儿童故事《爱丽丝梦游仙境》及其续集《爱丽丝镜中奇遇》的文本上训练一个基于字符的循环神经网络 (Recurrent Neural Network, RNN) 模型。我们选择基于字符的模型,因为它的词汇表更小,训练速度更快,训练和使用的方法与基于单词的语言模型相同,只是我们将使用字符而不是单词。训练完成后,该模型可以用来生成与原文风格相似的文本。
我们所使用的数据来自于 Project Gutenberg
网站上两部小说的纯文本。网络的输入是 100
个字符的序列,相应的输出是从输入偏移 1
个位置的另一个 100
个字符的序列。换句话说,如果输入是序列 [ c 1 , c 2 , … , c n ] [c_1, c_2, …, c_n] [c1,c2,…,cn],输出则是 [ c 2 , c 3 , … , c n + 1 ] [c_2, c_3, …, c_{n+1}] [c2,c3,…,cn+1]。训练网络 50
个 epochs
,在每 10
个 epochs
结束时,将生成一个以标准前缀开头的固定大小的字符序列,在本节中,我们使用的前缀是 Alice
,即小说中主角的名字。
2. 数据处理
(1) 首先导入所需库,并设置一些常量。DATA_DIR
指向数据文件夹,CHECKPOINT_DIR
位于 data
文件夹下,用于在每 10
个 epochs
结束时保存模型的权重:
import os
import numpy as np
import re
import shutil
import tensorflow as tfDATA_DIR = "./data"
CHECKPOINT_DIR = os.path.join(DATA_DIR, "checkpoints")
LOG_DIR = os.path.join(DATA_DIR, "logs")
(2) 接下来,下载并准备数据供网络使用。《爱丽丝梦游仙境》和《爱丽丝镜中奇遇》文本可以在 Project Gutenberg
网站上获取。tf.keras.utils.get_file()
函数将检查文件是否已下载到本地,如果没有,将下载到 datasets
文件夹。对输入进行了预处理,从文本中去除了换行符和字节顺序标记字符,得到变量 texts
,其中包含这两本书的字符列表:
def clean_logs():shutil.rmtree(CHECKPOINT_DIR, ignore_errors=True)shutil.rmtree(LOG_DIR, ignore_errors=True)def download_and_read(urls):texts = []for i, url in enumerate(urls):p = tf.keras.utils.get_file("ex1-{:d}.txt".format(i), url,cache_dir=".")text = open(p, mode="r", encoding="utf-8").read()# remove byte order marktext = text.replace("\ufeff", "")# remove newlinestext = text.replace('\n', ' ')text = re.sub(r'\s+', " ", text)# add it to the listtexts.extend(text)return texts# download and read into local data structure (list of chars)
texts = download_and_read(["http://www.gutenberg.org/cache/epub/28885/pg28885.txt","https://www.gutenberg.org/files/12/12-0.txt"
])
clean_logs()
(3) 接下来,创建词汇表,本节的词汇表包含 90
个独特的字符,包括大写和小写字母、数字和特殊字符。我们还创建了映射字典,将每个词汇表字符映射为唯一的整数,以及将整数映射为词汇表字符。词汇表字符网络的输入和输出是字符序列,但网络实际的输入和输出是整数序列,我们将使用映射字典来处理这种转换:
# create the vocabulary
vocab = sorted(set(texts))
print("vocab size: {:d}".format(len(vocab)))# create mapping from vocab chars to ints
char2idx = {c:i for i, c in enumerate(vocab)}
idx2char = {i:c for c, i in char2idx.items()}
(4) 使用映射字典将字符序列输入转换为整数序列,然后转换为 TensorFlow
数据集。每个序列包含 100
个字符,输出序列相对于输入序列偏移 1
个字符位置。首先将数据集切分为 101
个字符的片段,并对数据集的每个元素应用 split_train_labels()
函数,以创建序列数据集,数据集由两个元素的元组组成,每个元组元素都是大小为 100
且类型为 tf.int64
的向量。然后,打乱这些序列,并将网络训练的批大小设为 64
。数据集的每个元素是一个由一对矩阵组成的元组,每个矩阵的大小为 (64, 100)
,类型为 tf.int64
:
def split_train_labels(sequence):input_seq = sequence[0:-1]output_seq = sequence[1:]
return input_seq, output_seq# numericize the texts
texts_as_ints = np.array([char2idx[c] for c in texts])
data = tf.data.Dataset.from_tensor_slices(texts_as_ints)# number of characters to show before asking for prediction
# sequences: [None, 100]
seq_length = 100
sequences = data.batch(seq_length + 1, drop_remainder=True)
sequences = sequences.map(split_train_labels)# print out input and output to see what they look like
for input_seq, output_seq in sequences.take(1):print("input:[{:s}]".format("".join([idx2char[i] for i in input_seq.numpy()])))print("output:[{:s}]".format("".join([idx2char[i] for i in output_seq.numpy()])))# set up for training
# batches: [None, 64, 100]
batch_size = 64
steps_per_epoch = len(texts) // seq_length // batch_size
dataset = sequences.shuffle(10000).batch(batch_size, drop_remainder=True)
print(dataset)
3. 模型构建与训练
(1) 定义网络:
class CharGenModel(tf.keras.Model):def __init__(self, vocab_size, num_timesteps, embedding_dim, **kwargs):super(CharGenModel, self).__init__(**kwargs)self.embedding_layer = tf.keras.layers.Embedding(vocab_size,embedding_dim)self.rnn_layer = tf.keras.layers.GRU(num_timesteps,recurrent_initializer="glorot_uniform",recurrent_activation="sigmoid",stateful=True,return_sequences=True)self.dense_layer = tf.keras.layers.Dense(vocab_size)def call(self, x):x = self.embedding_layer(x)x = self.rnn_layer(x)x = self.dense_layer(x)return x# define network
vocab_size = len(vocab)
embedding_dim = 256model = CharGenModel(vocab_size, seq_length, embedding_dim)
model.build(input_shape=(batch_size, seq_length))
model.summary()
将网络定义为 tf.keras.Model
的子类,网络接受大小为 100
(num_timesteps
) 的整数序列作为输入,并通过嵌入层将序列中的每个整数转换为大小为 256
(embedding_dim
) 的向量。假设批大小为 64
,对于大小为 (64, 100)
的输入序列,嵌入层的输出是形状为 (64, 100, 256)
的矩阵。
接下来使用一个具有 100
个时间步的 RNN
层,选择的 RNN
实现是门控循环单元 (Gated Recurrent Unit, GRU)。GRU
层将在每个时间步接收一个大小为 (256,)
的向量,并输出形状为 (1024,)
(rnn_output_dim
) 的向量。需要注意的是,本节使用的是状态 RNN
,这意味着来自上一个训练 epoch
的隐藏状态输出将用作当前 epoch
的输入。return_sequences=True
标志还表明,RNN
会在每个时间步输出,而不是在最后一个时间步输出的聚合结果。
最后,每个时间步将输出一个形状为 (1024,)
的向量到一个全连接层,该层将输出一个形状为 (90,)
的向量 (vocab_size)
,该层的输出将是形状为 (64, 100, 90)
的张量。输出向量中的每个位置对应于词汇表中的一个字符,并且值对应于该字符在输出位置出现的概率。
(2) 接下来,定义损失函数并编译模型。我们将使用分类交叉熵作为损失函数,这是在输入和输出都是整数序列时使用的标准损失函数。对于优化器,选择 Adam
优化器:
def loss(labels, predictions):return tf.losses.sparse_categorical_crossentropy(labels,predictions,from_logits=True
)# try running some data through the model to validate dimensions
for input_batch, label_batch in dataset.take(1):pred_batch = model(input_batch)print(pred_batch.shape)
assert(pred_batch.shape[0] == batch_size)
assert(pred_batch.shape[1] == seq_length)
assert(pred_batch.shape[2] == vocab_size)model.compile(optimizer=tf.optimizers.Adam(), loss=loss)
(3) 通常情况下,输出的每个位置的字符是通过计算该位置向量的 argmax
得到的,即对应于最大概率值的字符,这称为贪婪搜索。在语言模型中,一个时间步的输出成为下一个时间步的输入,这可能导致重复输出。为了解决这个问题,最常用的两种方法是随机采样输出或使用束搜索,后者在每个时间步从概率最高的k个值中进行采样。在本节中,我们将使用 tf.random.categorical()
函数进行随机采样输出。函数 generate_text()
接受一个字符串作为前缀,并使用它生成长度为 num_chars_to_generate
的字符串。参数 temperature
用于控制预测的质量,较低的值会产生更可预测的输出。
将前缀字符串中的字符序列转换为整数序列,然后使用 expand_dims
添加批次维度,以便将输入传递到模型中。然后,重置模型的状态,因为模型使用状态 RNN
,我们不希望在预测过程中带入训练时计算的第一个时间步的隐藏状态。然后,运行模型并获得预测,得到一个形状为 (90,)
的向量,表示词汇表中每个字符在下一个时间步出现的概率。然后,通过移除批次维度并除以温度来整形预测,然后从该向量中随机采样。然后,将预测结果设置为下一个时间步的输入。重复以上过程,直到生成所需数量的字符,将每个预测转换回字符形式并添加到列表中,并在循环结束时返回该列表:
def generate_text(model, prefix_string, char2idx, idx2char,num_chars_to_generate=1000, temperature=1.0):input = [char2idx[s] for s in prefix_string]input = tf.expand_dims(input, 0)text_generated = []model.reset_states()for i in range(num_chars_to_generate):preds = model(input)preds = tf.squeeze(preds, 0) / temperature# predict char returned by modelpred_id = tf.random.categorical(preds, num_samples=1)[-1, 0].numpy()text_generated.append(idx2char[pred_id])# pass the prediction as the next input to the modelinput = tf.expand_dims([pred_id], 0)return prefix_string + "".join(text_generated)
(4) 最后,运行模型训练和评估过程。训练网络 50
个 epochs
,每隔 10
个 epochs
尝试使用到目前为止训练的模型生成一些文本,每个阶段的前缀是字符串 Alice
。需要注意的是,为了适应单个字符串前缀,在每 10
个 epochs
后保存权重,并使用这些权重构建生成模型,输入形状的批大小为 1
:
num_epochs = 50
for i in range(num_epochs // 10):model.fit(dataset.repeat(),epochs=10,steps_per_epoch=steps_per_epoch# callbacks=[checkpoint_callback, tensorboard_callback])checkpoint_file = os.path.join(CHECKPOINT_DIR, "model_epoch_{:d}".format(i+1))# model.save_weights(checkpoint_file)tf.saved_model.save(model, checkpoint_file)# create a generative model using the trained model so fargen_model = CharGenModel(vocab_size, seq_length, embedding_dim)# gen_model.load_weights(checkpoint_file)checkpoint = tf.train.Checkpoint(gen_model)checkpoint.restore(checkpoint_file).expect_partial()gen_model.build(input_shape=(1, seq_length))print("after epoch: {:d}\n".format(i+1))print(generate_text(gen_model, "Alice ", char2idx, idx2char))print("---")
4. 模型训练结果
第 1
个 epoch
训练后的输出中,得到的单词几乎完全无法辨认:
经过大约 30
个 epochs
的训练后,开始看到一些熟悉的单词:
经过 50
个 epoch
的训练,模型仍然难以表达连贯的思想,但已学会相当不错地拼写。尽管本节的模型是基于字符的,且对词汇没有任何了解,但它仍然学会了拼写看起来可能来自原始文本的单词:
生成文本中下一个字符或下一个单词并不是这种模型唯一的应用,类似的模型可以用于进行股票价格预测或生成古典音乐。
相关连接
TensorFlow深度学习实战(1)——神经网络与模型训练过程详解
TensorFlow深度学习实战(2)——使用TensorFlow构建神经网络
TensorFlow深度学习实战(3)——深度学习中常用激活函数详解
TensorFlow深度学习实战(4)——正则化技术详解
TensorFlow深度学习实战(5)——神经网络性能优化技术详解
TensorFlow深度学习实战(7)——分类任务详解
TensorFlow深度学习实战(8)——卷积神经网络
TensorFlow深度学习实战(12)——词嵌入技术详解
TensorFlow深度学习实战(13)——神经嵌入详解
TensorFlow深度学习实战(14)——循环神经网络详解