在 PyTorch 中使用 LSTM 生成文本
递归神经网络可用于时间序列预测。其中,创建了一个回归神经网络。它也可以用作生成模型,通常是分类神经网络模型。生成模型是从数据中学习某些模式,这样当它呈现一些提示时,它可以创建一个与学习模式相同的风格的完整输出。
推荐:将NSDT场景编辑器加入你的3D工具链
3D工具集:NSDT简石数字孪生
在 PyTorch 中使用 LSTM 生成文本
在这篇文章中,您将了解如何在 PyTorch 中使用 LSTM 递归神经网络为文本构建生成模型。完成这篇文章后,您将知道:
- 在哪里下载可用于训练文本生成模型的免费文本语料库
- 如何将文本序列的问题构建为递归神经网络生成模型
- 如何开发 LSTM 为给定问题生成合理的文本序列
用我的书《Deep Learning with PyTorch》开始你的项目。它提供了带有工作代码的自学教程。
让我们开始吧。
概述
这篇文章分为六个部分;它们是:
- 什么是生成模型
- 获取文本数据
- 一个小型的 LSTM 网络,用于抢夺下一个角色
- 使用 LSTM 模型生成文本
- 使用更大的 LSTM 网络
- 使用 GPU 加快训练速度
什么是生成模型
生成模型确实只是另一种碰巧能够创造新事物的机器学习模型。生成广告网络(GAN)是它自己的一类。还发现使用注意力机制的转换器模型可用于生成文本段落。
它只是一个机器学习模型,因为该模型已经使用现有数据进行了训练,因此它从中学到了一些东西。取决于如何训练它,他们可以工作得大不相同。在这篇文章中,创建了一个基于字符的生成模型。这意味着训练一个模型,该模型将一系列字符(字母和标点符号)作为输入,紧接下一个字符作为目标。只要它可以预测给定前面内容的下一个字符是什么,您就可以在循环中运行模型以生成一长段文本。
这个模型可能是最简单的模型。然而,人类语言是复杂的。您不应该期望它可以产生非常高质量的输出。即便如此,您还需要大量数据并长时间训练模型才能看到合理的结果。
获取文本数据
获得高质量的数据对于成功的生成模型非常重要。幸运的是,许多经典文本不再受版权保护。这意味着您可以免费下载这些书籍的所有文本,并在实验中使用它们,例如创建生成模型。也许获得不再受版权保护的免费书籍的最佳地点是古腾堡计划。
在这篇文章中,您将使用童年时最喜欢的一本书作为数据集,刘易斯卡罗尔的爱丽丝梦游仙境:
- https://www.gutenberg.org/ebooks/11
您的模型将学习字符之间的依赖关系以及序列中字符的条件概率,以便您可以反过来生成全新的原始字符序列。这篇文章很有趣,建议用古腾堡计划的其他书籍重复这些实验。这些实验不仅限于文本;您还可以尝试其他 ASCII 数据,例如计算机源代码、LATEX 中的标记文档、HTML 或 Markdown 等。
您可以免费下载本书的ASCII格式(纯文本UTF-8)的完整文本,并将其放在您的工作目录中,文件名为。现在,您需要准备数据集以进行建模。古腾堡计划为每本书添加了标准的页眉和页脚,这不是原始文本的一部分。在文本编辑器中打开文件并删除页眉和页脚。标题很明显,以文本结尾:wonderland.txt
1 | *** START OF THIS PROJECT GUTENBERG EBOOK ALICE'S ADVENTURES IN WONDERLAND *** |
页脚是文本行后的所有文本,上面写着:
1 | THE END |
您应该留下一个包含大约 3,400 行文本的文本文件。
一个小型 LSTM 网络,用于预测下一个角色
首先,您需要先对数据进行一些预处理,然后才能构建模型。神经网络模型只能处理数字,不能处理文本。因此,您需要将字符转换为数字。为了使问题更简单,您还需要将所有大写字母转换为小写字母。
在下面,您将打开文本文件,将所有字母转换为小写,并创建一个 Python 字典以将字符映射到不同的整数。例如,书中唯一排序的小写字符列表如下:char_to_int
1 2 3 | ['\n', '\r', ' ', '!', '"', "'", '(', ')', '*', ',', '-', '.', ':', ';', '?', '[', ']', '_', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '\xbb', '\xbf', '\xef'] |
由于这个问题是基于字符的,因此“词汇表”是文本中曾经使用过的不同字符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import numpy as np # load ascii text and covert to lowercase filename = "wonderland.txt" raw_text = open(filename, 'r', encoding='utf-8').read() raw_text = raw_text.lower() # create mapping of unique chars to integers chars = sorted(list(set(raw_text))) char_to_int = dict((c, i) for i, c in enumerate(chars)) # summarize the loaded data n_chars = len(raw_text) n_vocab = len(chars) print("Total Characters: ", n_chars) print("Total Vocab: ", n_vocab) |
这应该打印:
1 2 | Total Characters: 144574 Total Vocab: 50 |
你可以看到这本书只有不到150,000个字符,当转换为小写字母时,词汇表中只有50个不同的字符供网络学习 - 远远超过字母表中的26个字符。
接下来,您需要将文本分为输入和目标。此处使用 100 个字符的窗口。也就是说,使用字符 1 到 100 作为输入,您的模型将预测字符 101。如果使用 5 的窗口,则单词“chapter”将成为两个数据样本:
1 2 | chapt -> e hapte -> r |
在像这样的长文本中,可以创建窗口的myraid,这产生了许多样本的数据集:
1 2 3 4 5 6 7 8 9 10 11 | # prepare the dataset of input to output pairs encoded as integers seq_length = 100 dataX = [] dataY = [] for i in range(0, n_chars - seq_length, 1): seq_in = raw_text[i:i + seq_length] seq_out = raw_text[i + seq_length] dataX.append([char_to_int[char] for char in seq_in]) dataY.append(char_to_int[seq_out]) n_patterns = len(dataX) print("Total Patterns: ", n_patterns) |
运行上述操作,您可以看到总共创建了 144,474 个样本。每个样本现在都是整数的形式,使用映射进行转换。但是,PyTorch 模型更愿意在浮点张量中看到数据。因此,您应该将它们转换为 PyTorch 张量。LSTM层将在模型中使用,因此输入张量应该是维度(样本,时间步长,特征)。为了帮助训练,将输入规范化为 0 到 1 也是一个好主意。因此,您有以下几点:char_to_int
1 2 3 4 5 6 7 8 9 | import torch import torch.nn as nn import torch.optim as optim # reshape X to be [samples, time steps, features] X = torch.tensor(dataX, dtype=torch.float32).reshape(n_patterns, seq_length, 1) X = X / float(n_vocab) y = torch.tensor(dataY) print(X.shape, y.shape) |
您现在可以定义 LSTM 模型。在这里,您可以定义一个包含 256 个隐藏单元的隐藏 LSTM 层。输入是单个特征(即一个字符的一个整数)。在 LSTM 层之后添加一个概率为 0.2 的辍学层。LSTM 层的输出是一个元组,第一个元素是每个时间步长的 LSTM 单元的隐藏状态。它是隐藏状态如何演变的历史,因为 LSTM 单元接受输入的每个时间步长。据推测,最后一个隐藏状态包含的信息最多,因此只有最后一个隐藏状态被传递到输出层。输出层是一个全连接层,用于生成 50 个词汇表的对数。可以使用 softmax 函数将对数转换为类似概率的预测。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import torch.nn as nn import torch.optim as optim import torch.utils.data as data class CharModel(nn.Module): def __init__(self): super().__init__() self.lstm = nn.LSTM(input_size=1, hidden_size=256, num_layers=1, batch_first=True) self.dropout = nn.Dropout(0.2) self.linear = nn.Linear(256, n_vocab) def forward(self, x): x, _ = self.lstm(x) # take only the last output x = x[:, -1, :] # produce output x = self.linear(self.dropout(x)) return x |
这是 50 个类的单字符分类模型。因此应使用交叉熵损失。它使用亚当优化器进行优化。训练循环如下。为简单起见,没有创建任何测试集,但在每个纪元结束时再次使用训练集评估模型,以跟踪进度。
该程序可以运行很长时间,尤其是在CPU上!为了保存工作成果,保存了有史以来最好的模型以备将来重复使用。
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 | n_epochs = 40 batch_size = 128 model = CharModel() optimizer = optim.Adam(model.parameters()) loss_fn = nn.CrossEntropyLoss(reduction="sum") loader = data.DataLoader(data.TensorDataset(X, y), shuffle=True, batch_size=batch_size) best_model = None best_loss = np.inf for epoch in range(n_epochs): model.train() for X_batch, y_batch in loader: y_pred = model(X_batch) loss = loss_fn(y_pred, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() # Validation model.eval() loss = 0 with torch.no_grad(): for X_batch, y_batch in loader: y_pred = model(X_batch) loss += loss_fn(y_pred, y_batch) if loss < best_loss: best_loss = loss best_model = model.state_dict() print("Epoch %d: Cross-entropy: %.4f" % (epoch, loss)) torch.save([best_model, char_to_dict], "single-char.pth") |
运行上述操作可能会产生以下结果:
1 2 3 4 5 6 | ... Epoch 35: Cross-entropy: 245745.2500 Epoch 36: Cross-entropy: 243908.7031 Epoch 37: Cross-entropy: 238833.5000 Epoch 38: Cross-entropy: 239069.0000 Epoch 39: Cross-entropy: 234176.2812 |
交叉熵几乎总是在每个时期减少。这意味着模型可能没有完全收敛,您可以训练它更多时期。训练循环完成后,应创建文件以包含找到的最佳模型权重,以及此模型使用的字符到整数映射。single-char.pth
为了完整起见,下面将上述所有内容绑定到一个脚本中:
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 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | import numpy as np import torch import torch.nn as nn import torch.optim as optim import torch.utils.data as data # load ascii text and covert to lowercase filename = "wonderland.txt" raw_text = open(filename, 'r', encoding='utf-8').read() raw_text = raw_text.lower() # create mapping of unique chars to integers chars = sorted(list(set(raw_text))) char_to_int = dict((c, i) for i, c in enumerate(chars)) # summarize the loaded data n_chars = len(raw_text) n_vocab = len(chars) print("Total Characters: ", n_chars) print("Total Vocab: ", n_vocab) # prepare the dataset of input to output pairs encoded as integers seq_length = 100 dataX = [] dataY = [] for i in range(0, n_chars - seq_length, 1): seq_in = raw_text[i:i + seq_length] seq_out = raw_text[i + seq_length] dataX.append([char_to_int[char] for char in seq_in]) dataY.append(char_to_int[seq_out]) n_patterns = len(dataX) print("Total Patterns: ", n_patterns) # reshape X to be [samples, time steps, features] X = torch.tensor(dataX, dtype=torch.float32).reshape(n_patterns, seq_length, 1) X = X / float(n_vocab) y = torch.tensor(dataY) class CharModel(nn.Module): def __init__(self): super().__init__() self.lstm = nn.LSTM(input_size=1, hidden_size=256, num_layers=1, batch_first=True) self.dropout = nn.Dropout(0.2) self.linear = nn.Linear(256, n_vocab) def forward(self, x): x, _ = self.lstm(x) # take only the last output x = x[:, -1, :] # produce output x = self.linear(self.dropout(x)) return x n_epochs = 40 batch_size = 128 model = CharModel() optimizer = optim.Adam(model.parameters()) loss_fn = nn.CrossEntropyLoss(reduction="sum") loader = data.DataLoader(data.TensorDataset(X, y), shuffle=True, batch_size=batch_size) best_model = None best_loss = np.inf for epoch in range(n_epochs): model.train() for X_batch, y_batch in loader: y_pred = model(X_batch) loss = loss_fn(y_pred, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() # Validation model.eval() loss = 0 with torch.no_grad(): for X_batch, y_batch in loader: y_pred = model(X_batch) loss += loss_fn(y_pred, y_batch) if loss < best_loss: best_loss = loss best_model = model.state_dict() print("Epoch %d: Cross-entropy: %.4f" % (epoch, loss)) torch.save([best_model, char_to_int], "single-char.pth") |
使用 LSTM 模型生成文本
鉴于模型经过良好训练,使用经过训练的 LSTM 网络生成文本相对简单。首先,您需要重新创建网络并从保存的检查点加载训练好的模型权重。然后,您需要为模型创建一些提示以启动。提示可以是模型可以理解的任何内容。它是提供给模型以获得一个生成字符的种子序列。然后,将生成的字符添加到此序列的末尾,并修剪掉第一个字符以保持一致的长度。只要您想要预测新字符(例如,长度为 1,000 个字符的序列),就会重复此过程。您可以选择随机输入模式作为种子序列,然后在生成时打印生成的字符。
生成提示的一种简单方法是从原始数据集中随机选择一个样本,例如,使用上一节中获得的样本,可以将提示创建为:raw_text
1 2 3 | seq_length = 100 start = np.random.randint(0, len(raw_text)-seq_length) prompt = raw_text[start:start+seq_length] |
但应该提醒您需要转换它,因为此提示是一个字符串,而模型需要整数向量。
整个代码仅如下所示:
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 | import numpy as np import torch import torch.nn as nn best_model, char_to_int = torch.load("single-char.pth") n_vocab = len(char_to_int) int_to_char = dict((i, c) for c, i in char_to_int.items()) # reload the model class CharModel(nn.Module): def __init__(self): super().__init__() self.lstm = nn.LSTM(input_size=1, hidden_size=256, num_layers=1, batch_first=True) self.dropout = nn.Dropout(0.2) self.linear = nn.Linear(256, n_vocab) def forward(self, x): x, _ = self.lstm(x) # take only the last output x = x[:, -1, :] # produce output x = self.linear(self.dropout(x)) return x model = CharModel() model.load_state_dict(best_model) # randomly generate a prompt filename = "wonderland.txt" seq_length = 100 raw_text = open(filename, 'r', encoding='utf-8').read() raw_text = raw_text.lower() start = np.random.randint(0, len(raw_text)-seq_length) prompt = raw_text[start:start+seq_length] pattern = [char_to_int[c] for c in prompt] model.eval() print('Prompt: "%s"' % prompt) with torch.no_grad(): for i in range(1000): # format input array of int into PyTorch tensor x = np.reshape(pattern, (1, len(pattern), 1)) / float(n_vocab) x = torch.tensor(x, dtype=torch.float32) # generate logits as output from the model prediction = model(x) # convert logits into one character index = int(prediction.argmax()) result = int_to_char[index] print(result, end="") # append the new character into the prompt for the next iteration pattern.append(index) pattern = pattern[1:] print() print("Done.") |
运行此示例首先输出使用的提示,然后在生成每个字符时输出。例如,下面是一次运行此文本生成器的结果。提示是:
1 2 | Prompt: "nother rush at the stick, and tumbled head over heels in its hurry to get hold of it; then alice, th" |
生成的文本是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | e was qot a litule soteet of thet was sh the thiee harden an the courd, and was tuitk a little toaee th thite ththe and said to the suher, and the whrtght the pacbit sese tha woode of the soeee, and the white rabbit ses ani thr gort to the thite rabbit, and then she was aoiinnene th the three baaed of the sueen and saed “ota turpe ”hun mot,” “i don’t know the ter ano _enend to mere,” said the maccht ar a sore of great roaee. “ie you don’t teink if thet soued to soeed to the boeie the mooer, io you bane thing it wo tou het bn the crur, “h whsh you cen not,” said the manch hare. “wes, it aadi,” said the manch hare. “weat you tail to merer ae in an a gens if gre” ”he were thing,” said the maccht ar a sore of geeaghen asd tothe to the thieg harden an the could. “h dan tor toe taie thing,” said the manch hare. “wes, it aadi,” said the manch hare. “weat you tail to merer ae in an a gens if gre” ”he were thing,” said the maccht ar a sore of geeaghen asd tothe to the thieg harden an t |
让我们注意一些关于生成文本的观察结果。
- 它可以发出换行符。原始文本将行宽限制为 80 个字符,生成模型试图复制此模式
- 字符被分成类似单词的组,有些组是实际的英语单词(例如,“the”,“said”和“rabbit”),但许多不是(例如,“thite”,“soteet”和“tha”)。
- 按顺序排列的一些词是有意义的(例如,“我不知道”),但许多单词没有(例如,“他是东西”)。
事实上,这本书的这种基于角色的模型产生了这样的输出,这非常令人印象深刻。它让您了解 LSTM 网络的学习能力。然而,结果并不完美。在下一节中,您将了解如何通过开发更大的 LSTM 网络来提高结果质量。
使用更大的 LSTM 网络
回想一下,LSTM 是一个递归神经网络。它采用一个序列作为输入,在序列的每个步骤中,输入与其内部状态混合以产生输出。因此,LSTM 的输出也是一个序列。在上面,最后一个时间步的输出被采用在神经网络中进行进一步处理,但来自早期步骤的输出将被丢弃。但是,情况不一定如此。您可以将一个 LSTM 层的序列输出视为另一个 LSTM 层的输入。然后,您正在构建一个更大的网络。
与卷积神经网络类似,堆叠 LSTM 网络应该具有较早的 LSTM 层来学习低级特征,而较晚的 LSTM 层来学习高级特征。它可能并不总是有用的,但您可以尝试一下,看看模型是否可以产生更好的结果。
在 PyTorch 中,制作堆叠的 LSTM 层很容易。让我们将上面的模型修改为以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 | class CharModel(nn.Module): def __init__(self): super().__init__() self.lstm = nn.LSTM(input_size=1, hidden_size=256, num_layers=2, batch_first=True, dropout=0.2) self.dropout = nn.Dropout(0.2) self.linear = nn.Linear(256, n_vocab) def forward(self, x): x, _ = self.lstm(x) # take only the last output x = x[:, -1, :] # produce output x = self.linear(self.dropout(x)) return x |
唯一的更改是在参数上:您设置而不是 1 以添加另一个 LSTM 层。但是在两个 LSTM 层之间,您还通过参数 添加了一个 dropout 层。用以前的模型替换此模型是您需要进行的所有更改。重新运行培训,您应该看到以下内容:nn.LSTM()
num_layers=2
dropout=0.2
1 2 3 4 5 6 7 | ... Epoch 34: Cross-entropy: 203763.0312 Epoch 35: Cross-entropy: 204002.5938 Epoch 36: Cross-entropy: 210636.5625 Epoch 37: Cross-entropy: 199619.6875 Epoch 38: Cross-entropy: 199240.2969 Epoch 39: Cross-entropy: 196966.1250 |
您应该看到此处的交叉熵低于上一节中的交叉熵。这意味着此模型的性能更好。事实上,使用这个模型,你可以看到生成的文本看起来更合理:
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 | Prompt: "ll say that ‘i see what i eat’ is the same thing as ‘i eat what i see’!” “you might just as well sa" y it to sea,” she katter said to the jury. and the thoee hardeners vhine she was seady to alice the was a long tay of the sooe of the court, and she was seady to and taid to the coor and the court. “well you see what you see, the mookee of the soog of the season of the shase of the court!” “i don’t know the rame thing is it?” said the caterpillar. “the cormous was it makes he it was it taie the reason of the shall bbout it, you know.” “i don’t know the rame thing i can’t gelp the sea,” the hatter went on, “i don’t know the peally was in the shall sereat it would be a teally. the mookee of the court ” “i don’t know the rame thing is it?” said the caterpillar. “the cormous was it makes he it was it taie the reason of the shall bbout it, you know.” “i don’t know the rame thing i can’t gelp the sea,” the hatter went on, “i don’t know the peally was in the shall sereat it would be a teally. the mookee of the court ” “i don’t know the rame thing is it?” said the caterpillar. “the Done. |
不仅单词拼写正确,文本也更像英语。由于在训练模型时交叉熵损失仍在减少,因此可以假设模型尚未收敛。如果增加训练周期,则可以期望使模型更好。
为完整起见,下面是使用此新模型的完整代码,包括训练和文本生成。
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 67 68 |