使用 PyTorch 学习生成式人工智能——循环神经网络的文本生成

138 阅读32分钟

本章内容包括:

  • 循环神经网络(RNN)的基本思想及其为何适合处理序列数据
  • 字符级分词、词级分词和子词分词
  • 词嵌入(word embedding)的原理
  • 构建和训练RNN以生成文本
  • 利用温度调节(temperature)和Top-K采样控制文本生成的创造性

迄今为止,我们已经讨论了如何生成形状、数字和图像。从本章开始,我们将主要聚焦于文本生成。文本生成被认为是生成式人工智能的“圣杯”,原因有很多。人类语言极其复杂且富有细微差别,不仅涉及语法和词汇,还包括上下文、语气和文化背景。成功生成连贯且符合语境的文本是一项巨大的挑战,需要对语言有深入理解和处理能力。

作为人类,我们主要通过语言交流。能够生成类人文本的人工智能能更自然地与用户互动,使技术更加易用且友好。文本生成应用广泛,涵盖自动客服回复、整篇文章创作、游戏和电影脚本编写、辅助创作甚至构建个人助理等,潜在影响巨大。

本章将首次尝试构建和训练生成文本的模型。你将面对文本生成中的三大挑战。首先,文本是序列数据,由按特定顺序排列的数据点组成,顺序的改变会影响含义,预测序列结果时顺序敏感。其次,文本具有长距离依赖性,文本中某部分的含义可能依赖很早之前出现的内容(例如100个词之前)。理解并建模这些长距离依赖对生成连贯文本至关重要。最后,人类语言充满歧义且依赖上下文。训练模型理解语义细微差别、讽刺、习语和文化参考,从而生成符合语境的文本,是一大难题。

你将学习一种专门处理序列数据(如文本或时间序列)的神经网络——循环神经网络(RNN)。传统神经网络,如前馈神经网络或全连接网络,独立处理每个输入,忽略输入间的顺序和关联。相反,RNN专为序列数据设计,当前时刻的输出不仅取决于当前输入,还依赖之前的输入,这使得RNN具备一定的“记忆”能力,能捕捉先前时刻的信息来影响当前输入的处理。

这种顺序处理能力使得RNN适合处理顺序敏感的任务,如语言建模,预测句子中下一个词。我们将重点学习RNN的一种变体——长短时记忆网络(LSTM),它能识别文本等序列数据中的短期和长期模式。LSTM通过隐藏状态捕获之前时刻的信息,因此训练好的LSTM模型能基于上下文生成连贯文本。

生成文本的风格取决于训练数据。我们计划从零开始训练模型,训练文本的长度非常关键——文本需足够长以有效学习和模仿特定写作风格,同时又不能过长,以免训练时计算量过大。因此,我们选择使用小说《安娜·卡列尼娜》中的文本,长度适中,适合训练LSTM模型。由于神经网络无法直接接受文本作为输入,你将学习将文本分解为标记(本章为单词,后续章节会涉及词的部分组成),称为分词(tokenization)。然后创建字典,将每个唯一标记映射为整数索引。基于字典,将文本转换成长整数序列,准备输入神经网络。

训练时,你将使用固定长度的索引序列作为输入,右移一位后的序列作为输出,即训练模型预测句子中的下一个标记。这是自然语言处理中的序列到序列预测问题,后续章节还会涉及。

LSTM训练完成后,将基于之前的标记逐个生成文本。例如,给模型输入一段提示(如“Anna and the”),模型预测最可能的下一个标记,并将其添加到提示后面。更新后的提示再次作为输入,模型继续预测下一个标记,循环迭代,直至生成文本达到一定长度。这种方式类似更先进生成模型(如ChatGPT,尽管它不是LSTM)采用的机制。你将看到训练好的LSTM生成语法正确、连贯且风格与原著相符的文本。

最后,你将学习如何通过温度调节(temperature)和Top-K采样控制文本生成的创造力。温度调节控制模型预测的随机性,温度越高,文本越富有创造力;温度越低,文本越确定和可预测。Top-K采样则是在每一步从概率最高的K个标记中选择下一个标记,而非整个词汇表,K较小使生成文本更连贯但创造性较低。

本章目标不是生成最完美的文本(这本身具有很大挑战),而是展示RNN的局限性,为后续引入Transformer做好铺垫。更重要的是,本章奠定文本生成的基础知识,包括分词、词嵌入、序列预测、温度调节和Top-K采样。这样,后续章节你将对自然语言处理的基本原理有坚实理解,进而深入学习更高级的内容,如注意力机制和Transformer架构。

8.1 RNN简介

在本章开始时,我们提到生成文本时的复杂性,尤其是在实现文本连贯性和上下文相关性方面的挑战。本节将更深入探讨这些挑战,并介绍循环神经网络(RNN)的架构。我们会解释为什么RNN适合处理此类任务,同时指出其局限性(这也是它们被Transformer取代的原因)。

RNN专门设计用于处理序列数据,因此非常适合生成文本这类本质上具有序列性质的任务。它们利用一种称为“隐藏状态”的记忆机制,捕获并保留序列中早先部分的信息。这一能力对保持上下文和理解序列中的依赖关系至关重要。

本章我们将重点使用LSTM网络,作为RNN的高级变种,利用其先进能力来应对文本生成中的挑战。

8.1.1 文本生成中的挑战

文本是序列数据的典型例子,序列数据指的是元素顺序极为重要的数据集。元素之间的相对位置往往传递关键信息,是理解数据的基础。序列数据的例子包括时间序列(如股价)、文本内容(如句子)以及音乐作品(音符的连续序列)。

本书主要关注文本生成,虽然后续章节(第13和14章)也涉及音乐生成。文本生成充满复杂性,主要挑战在于对句子中单词顺序的建模。改变单词顺序会显著改变句子含义。例如,“Kentucky defeated Vanderbilt in last night’s football game”这句话中,若交换“Kentucky”和“Vanderbilt”,意义完全相反,尽管使用的词相同。此外,如前言所述,文本生成还需处理长距离依赖和歧义问题。

本章将探讨用RNN解决这些挑战的方法。虽然该方法并非完美,但为后续章节更先进的技术奠定基础。通过本章,你将理解如何处理单词顺序、长距离依赖及语言歧义,掌握文本生成的基本技能。本章学习是你进入后续更复杂技术和深入理解的垫脚石,同时你会获得自然语言处理(NLP)中的重要技能,如文本分词、词嵌入和序列到序列预测。

8.1.2 RNN如何工作?

RNN是一种专门识别序列数据模式的人工神经网络,适用于文本、音乐、股价等数据。与传统神经网络独立处理输入不同,RNN内部有循环结构,允许信息得以保留。

文本生成的一个难点是,如何根据之前所有单词预测下一个词,既捕捉长距离依赖,也理解上下文含义。RNN接受的输入不是孤立的单个元素,而是一个序列(比如一句话中的单词)。在每个时间步,预测不仅基于当前输入,还基于之前所有输入的总结,这一总结体现在隐藏状态中。举例来说,对于短语“a frog has four legs”,第一步用“a”预测第二个词“frog”,第二步用“a”和“frog”预测第三词……直到预测最后一个词时,需用到前面所有四个词“a frog has four”。

RNN的关键特性是“隐藏状态”,它记录序列中之前所有元素的信息。这使得网络能有效处理和生成序列数据。图8.1展示了RNN的工作原理,描述了循环神经元如何随时间展开。

image.png

图8.1 展示了循环神经元层如何随时间展开。当循环神经网络对序列数据进行预测时,它会将前一时间步的隐藏状态 h(t−1) 与当前时间步的输入 x(t) 一起输入,产生当前的输出 y(t) 和更新后的隐藏状态 h(t)。时间步 t 的隐藏状态包含了从时间步 0,1,,t0,1,…,t 的所有输入信息。

隐藏状态在RNN中起着关键作用,能够捕捉所有时间步的信息。这使得RNN的预测不仅依赖当前输入 x(t),还利用了之前所有输入 x(0),x(1),,x(t1)x(0),x(1),…,x(t−1)的累积知识。这个特性使RNN能够理解时间依赖关系,捕捉输入序列的上下文,对于语言建模等任务至关重要,在这类任务中,前面的单词为预测下一个单词提供了语境。

然而,RNN并非没有缺点。虽然标准RNN可以处理短期依赖,但在处理文本中的长期依赖时表现不佳。这主要是由于梯度消失问题:在长序列中,梯度(训练网络所必需)逐渐变小,导致模型难以学习长距离依赖关系。为了解决这个问题,出现了RNN的高级变体,如LSTM网络。

LSTM网络由Hochreiter和Schmidhuber于1997年提出。LSTM网络由多个LSTM单元(或称细胞)组成,每个单元结构比标准RNN神经元更复杂。LSTM的核心创新是细胞状态(cell state),它如同一个传送带贯穿整个LSTM单元链,能够携带重要信息。通过向细胞状态添加或删除信息,LSTM能够捕捉长期依赖并长时间记忆信息,因此在语言建模和文本生成等任务中表现更佳。本章将利用LSTM模型开展文本生成项目,旨在模仿小说《安娜·卡列尼娜》的写作风格。

值得注意的是,即使是像LSTM这样的高级RNN变体,在捕获极长距离依赖时仍存在挑战。我们将在下一章讨论这些难题并提供解决方案,继续探索用于高效处理和生成序列数据的复杂模型。

8.1.3 训练 LSTM 模型的步骤

接下来,我们将讨论训练 LSTM 模型以生成文本所涉及的步骤。本概述旨在在动手项目之前,为您提供对训练过程的基础理解。

训练所用文本的选择取决于预期的输出。一本较长的小说是一个不错的起点。其丰富的内容使模型能够有效学习和模仿特定的写作风格。大量的文本数据提升了模型对该风格的熟练度。同时,小说一般不会过于冗长,有助于控制训练时间。对于我们的 LSTM 模型训练,将采用《安娜·卡列尼娜》这部小说的文本,符合我们之前提出的训练数据标准。

与其他深度神经网络类似,LSTM 模型无法直接处理原始文本。相反,我们需要将文本转换成数字形式。这个过程首先是将文本拆分成更小的单元,称为分词(tokenization),每个单元称为一个 token。token 可以是完整的单词、标点符号(如感叹号或逗号)、特殊字符(如 & 或 %)等。在本章中,这些元素都将被视为独立的 token。虽然这种分词方法效率不是最高,但实现简单,只需完成单词到 token 的映射即可。在后续章节中,我们将使用子词分词(subword tokenization),将一些罕见词拆分成更小的部分,如音节。分词完成后,我们为每个 token 分配一个唯一整数,形成文本的数字表示,即整数序列。

为准备训练数据,我们将这条长序列划分成若干长度相等的短序列。我们的项目中,每个序列由 100 个整数组成。这些序列构成模型的输入特征(即变量 x)。然后通过将输入序列整体右移一个 token 生成输出序列 y,这样的设置使 LSTM 模型能够学习预测序列中的下一个 token。输入输出对即作为训练数据。我们的模型包括 LSTM 层以捕捉文本中的长期模式,同时包括嵌入层(embedding)以理解语义意义。

我们再回顾之前提到的预测句子 “a frog has four legs” 的例子。图 8.2 展示了 LSTM 模型训练的工作流程。

image.png

图 8.2 展示了 LSTM 模型的训练示例。我们首先将训练文本拆分为 token,并为每个 token 分配一个唯一整数,形成文本的数字表示——即一串索引序列。然后将这条长序列划分成长度相等的多个短序列,这些短序列构成模型的输入特征(变量 x)。接着,通过将输入序列整体向右移动一个 token 来生成输出序列 y。这样的设置使得 LSTM 模型能够基于序列中已有的前置 token 预测下一个 token。

在第一个时间步,模型使用单词 “a” 来预测单词 “frog”。由于 “a” 前面没有单词,隐藏状态以全零向量初始化。LSTM 模型将 “a” 的索引和初始隐藏状态作为输入,输出预测的下一个单词和更新后的隐藏状态 h0。随后,在第二个时间步,模型用单词 “frog” 以及隐藏状态 h0 来预测 “has”,并生成新的隐藏状态 h1。接着,这种预测下一个单词并更新隐藏状态的过程持续进行,直到模型预测出句子中的最后一个单词 “legs”。

模型的预测结果随后与句子中的真实下一个单词进行比较。由于模型需要从词汇表中的所有可能 token 中预测下一个 token,这构成了一个多类别分类问题。我们在每次迭代中调整模型参数,旨在最小化交叉熵损失,从而使得下一次迭代中模型的预测结果更接近训练数据中的真实输出。

模型训练完成后,文本生成过程从一个种子序列开始,将其输入模型。模型预测下一个 token,并将该 token 追加到序列中。这个预测和序列更新的迭代过程不断重复,直到生成所需长度的文本。

8.2 自然语言处理基础

包括我们之前讨论的 LSTM 模型和后续章节将介绍的 Transformer,深度学习模型无法直接处理原始文本,因为它们设计上是处理数值数据的,通常以向量或矩阵的形式存在。神经网络的处理和学习能力依赖于加法、乘法和激活函数等数学运算,这些运算要求输入是数值型的。因此,必须先将文本拆解成更小、更易处理的单元,称为“token”(标记)。这些标记可以是单个字符、单词,甚至是子词单元。

在 NLP 任务中,下一步至关重要的是将这些标记转换成数值表示。这一步是将文本输入深度神经网络的基础,也是模型训练的关键环节。

本节将讨论不同的分词方法及其优缺点,并介绍如何将标记转换成密集向量表示的方法,即词嵌入(word embedding)。词嵌入技术对于以深度学习模型能有效利用的方式捕获语言含义至关重要。

8.2.1 不同的分词方法

分词是将文本拆分成更小的单元(token),这些单元可以是单词、字符、符号或其他重要成分。分词的主要目的是简化文本数据的分析和处理流程。

总体来说,分词方法主要有三种。

第一种是字符分词,即将文本分割成组成字符。该方法常用于形态复杂的语言(如土耳其语或芬兰语),这些语言中单词含义会因字符细微变化而发生显著改变。以英语短语 “It is unbelievably good!” 为例,字符分词结果为 ['I', 't', ' ', 'i', 's', ' ', 'u', 'n', 'b', 'e', 'l', 'i', 'e', 'v', 'a', 'b', 'l', 'y', ' ', 'g', 'o', 'o', 'd', '!']。字符分词的主要优势是唯一标记数量有限,极大减少了深度学习模型的参数量,加快了训练速度和效率。但缺点是单个字符往往没有明确意义,导致机器学习模型难以从字符序列中提取有效信息。

练习 8.1
使用字符分词将短语 “Hi, there!” 拆分成单独的标记。

第二种是单词分词,即将文本按单词和标点符号拆分。这种方法适用于唯一单词数量不太庞大的场景。以同一句话 “It is unbelievably good!” 为例,分词结果为 ['It', 'is', 'unbelievably', 'good', '!']。单词分词的优点在于每个单词自带语义信息,模型更容易理解文本。缺点是标记数量大幅增加,导致深度学习模型参数和计算量显著上升,训练过程变慢且效率降低。

练习 8.2
使用单词分词将短语 “Hi, how are you?” 拆分成单独的标记。

第三种是子词分词,这是 NLP 中的重要概念。它将文本拆分成更小且有意义的组成部分,称为子词。例如,“It is unbelievably good!” 被拆分成 ['It', 'is', 'un', 'believ', 'ably', 'good', '!']。大多数先进语言模型(包括 ChatGPT)都采用子词分词,后续章节中你也将使用该方法。子词分词在传统的按单词或字符拆分方法之间取得平衡:单词分词能捕捉更多语义,但词汇量巨大;字符分词词汇量小,但标记语义有限。子词分词通过保留高频词为整体,同时将低频或复杂词拆分为子组件,有效降低词汇量。此技术对词汇量庞大或词形变化多样的语言尤其有益,显著提升语言处理效率和效果。

本章我们将聚焦单词分词,为初学者提供简单直接的基础。后续章节将转向子词分词,使用已用此方法训练好的模型,以便深入探讨 Transformer 架构和注意力机制的工作原理。

8.2.2 词嵌入(Word Embedding)

词嵌入是一种将标记(token)转换为紧凑向量表示的方法,能够捕捉它们的语义信息和相互关系。这项技术在自然语言处理(NLP)中至关重要,尤其是因为深度神经网络(包括 LSTM 和 Transformer 等模型)需要数值输入。

传统上,标记在输入 NLP 模型之前,会通过独热编码(one-hot encoding)转换成数字。在独热编码中,每个标记被表示成一个向量,其中只有一个元素是 “1”,其余元素均为 “0”。例如,在本章中,用于《安娜·卡列尼娜》小说文本的词基标记共有 12,778 个唯一词汇。每个标记都用一个 12,778 维的向量表示。因此,像短语 “happy families are all alike” 会被表示为一个 5 × 12,778 的矩阵,其中 5 是标记数。然而,这种表示方式由于维度极高,导致参数数量庞大,极大地降低了训练速度和效率。

为了解决这一问题,LSTM、Transformer 以及其他先进的 NLP 模型采用了词嵌入。词嵌入不再使用庞大的独热向量,而是采用连续的、低维的向量(例如本章中使用的 128 维向量)。因此,短语 “happy families are all alike” 在词嵌入后,被表示成更紧凑的 5 × 128 矩阵。这种简化的表示极大地减少了模型复杂度,并提升了训练效率。

词嵌入不仅通过压缩到低维空间降低了词汇复杂度,还能有效捕捉词语之间的上下文和细微语义关系,这些是简单的独热编码所不具备的。独热编码中,所有标记在向量空间中的距离均相同,缺乏相似性概念。而在词嵌入中,语义相近的标记会在嵌入空间中被表示为彼此接近的向量。词嵌入是从训练数据文本中学习得到的,这些向量捕捉了上下文信息:即使标记之间没有直接关联,但只要它们在相似的上下文中出现,嵌入向量也会相似。

NLP 中的词嵌入

词嵌入是 NLP 中强大的标记表示方法,相较于传统的独热编码,它更擅长捕获上下文和语义关系。

独热编码将标记表示成稀疏向量,维度等于词汇表大小,每个标记对应的向量在该索引位置为 1,其余为 0。而词嵌入将标记表示成低维稠密向量(例如本章中的 128 维,12 章中是 256 维)。这种稠密表示更加高效,能捕获更多信息。

具体来说,独热编码中所有标记向量相互之间的距离相同,无法体现标记间的相似性。词嵌入中,语义相似的标记在嵌入空间中距离较近,例如“king”和“queen”的词嵌入向量会较为相似,反映其语义关联。

词嵌入是基于训练数据中的上下文学习而来,意味着它们能够捕获上下文信息,出现于相似上下文中的标记即使未直接关联,其嵌入向量也相近。

总的来说,词嵌入提供了更细腻且高效的词语表示,能够捕获语义和上下文信息,因而在 NLP 任务中比独热编码更适用。

实践中的词嵌入

在 PyTorch 等深度学习框架中,词嵌入通常通过将索引传入线性层实现,该层将高维稀疏向量压缩到低维空间。具体来说,当你将索引传入 nn.Embedding() 层时,该层会查找嵌入矩阵中对应行,并返回该索引的嵌入向量,避免了构造庞大独热向量的需求。

嵌入层的权重不是预先定义的,而是在训练过程中学习得到的。这种学习机制使模型能根据训练数据不断优化对词义的理解,生成更细腻、上下文相关的语言表示,从而显著提升模型处理和理解语言数据的能力与效果。

8.3 准备训练 LSTM 模型的数据

本节将处理文本数据,为训练做准备。首先,我们会将文本拆分成独立的标记(tokens)。接下来,我们会创建一个字典,将每个标记映射到一个索引,也就是整数。完成这一步后,我们会把这些标记组织成训练数据的批次,这对后续训练 LSTM 模型非常关键。

我们将逐步详细介绍标记化的过程,确保你全面理解标记化的功能。这里使用的是词级标记化(word tokenization),因为它能简单地将文本划分为单词,相比之下,更复杂的子词标记化(subword tokenization)则需要更细致的语言结构理解。在后续章节中,我们将使用预训练的子词标记器,专注于更高级的主题,如注意力机制和 Transformer 架构,而不必纠结于文本预处理的基础环节。

8.3.1 下载和清理文本

我们将使用小说《安娜·卡列尼娜》的文本来训练模型。请访问 mng.bz/znmX 下载文本文件,并将其保存为电脑上 /files/ 目录下的 anna.txt。然后,打开该文件,删除第 39888 行之后的所有内容,这一行是 “END OF THIS PROJECT GUTENBERG EBOOK ANNA KARENINA.”。或者,你也可以直接从本书 GitHub 仓库下载 anna.txt 文件:github.com/markhliu/DG…

首先,我们加载数据并打印部分内容,感受一下数据集:

with open("files/anna.txt", "r") as f:
    text = f.read()
words = text.split(" ")
print(words[:20])

输出如下:

['Chapter', '1\n\n\nHappy', 'families', 'are', 'all', 'alike;', 'every', 'unhappy', 'family', 'is', 'unhappy', 'in', 'its', 'own\nway.\n\nEverything', 'was', 'in', 'confusion', 'in', 'the', "Oblonskys'"]

可以看到,换行符(\n)被当作文本的一部分,因此我们需要用空格替换换行符,避免它们出现在词汇表中。此外,将所有单词转换为小写是有益的,这样可以确保 “The” 和 “the” 被识别为相同的标记。这一步对于减少唯一标记的种类至关重要,从而提升训练效率。还有,标点符号需要与前面的单词分开,否则像 “way.” 和 “way” 会被错误识别为不同的标记。为了解决这些问题,我们进行如下文本清理:

clean_text = text.lower().replace("\n", " ")        # ① 用空格替换换行符
clean_text = clean_text.replace("-", " ")           # ② 用空格替换连字符
for x in ",.:;?!$()/_&%*@'`":
    clean_text = clean_text.replace(f"{x}", f" {x} ")  
clean_text = clean_text.replace('"', ' " ')          # ③ 给标点和特殊字符两边加空格
text = clean_text.split()

接下来,获取唯一标记(unique tokens):

from collections import Counter
word_counts = Counter(text)
words = sorted(word_counts, key=word_counts.get, reverse=True)
print(words[:10])

words 列表包含文本中所有的唯一标记,按频率从高到低排序。前十个标记输出为:

[',', '.', 'the', '"', 'and', 'to', 'of', 'he', "'", 'a']

由此可见,逗号(,)和句号(.)分别是出现频率最高和第二高的标记,紧随其后的是单词 “the”。

然后,我们创建两个字典:一个用于将标记映射到索引,另一个用于将索引映射回标记。

text_length = len(text)               # ① 文本总标记数
num_unique_words = len(words)         # ② 唯一标记数
print(f"the text contains {text_length} words")
print(f"there are {num_unique_words} unique tokens")
word_to_int = {v: k for k, v in enumerate(words)}   # ③ 标记到索引映射
int_to_word = {k: v for k, v in enumerate(words)}   # ④ 索引到标记映射
print({k: v for k, v in word_to_int.items() if k in words[:10]})
print({k: v for k, v in int_to_word.items() if v in words[:10]})

输出结果:

the text contains 437098 words
there are 12778 unique tokens
{',': 0, '.': 1, 'the': 2, '"': 3, 'and': 4, 'to': 5, 'of': 6, 'he': 7, "'": 8, 'a': 9}
{0: ',', 1: '.', 2: 'the', 3: '"', 4: 'and', 5: 'to', 6: 'of', 7: 'he', 8: "'", 9: 'a'}

《安娜·卡列尼娜》的文本包含 437,098 个标记,12,778 个唯一标记。word_to_int 字典将每个唯一标记映射为索引,例如逗号对应索引 0,句号对应索引 1。int_to_word 字典则将索引映射回标记,例如索引 2 对应 “the”,索引 4 对应 “and” 等。

最后,将整个文本转换为索引序列:

print(text[0:20])
wordidx = [word_to_int[w] for w in text]
print([word_to_int[w] for w in text[0:20]])

输出结果:

['chapter', '1', 'happy', 'families', 'are', 'all', 'alike', ';', 'every', 'unhappy', 'family', 'is', 'unhappy', 'in', 'its', 'own', 'way', '.', 'everything', 'was']
[208, 670, 283, 3024, 82, 31, 2461, 35, 202, 690, 365, 38, 690, 10, 234, 147, 166, 1, 149, 12]

我们将文本中的所有标记转换为对应的索引,存储在列表 wordidx 中。输出展示了文本的前 20 个标记及其对应索引,例如第一个标记 “chapter” 对应索引 208。

练习 8.3

请查找标记 “anna” 在字典 word_to_int 中对应的索引值。

8.3.2 创建训练数据批次

接下来,我们创建用于训练的 (x, y) 对。每个 x 是一个长度为 100 的索引序列。数字 100 并没有什么特别的,你可以轻松地改成 90 或 110,效果类似。设置序列长度过大会导致训练速度变慢,设置过小则可能使模型无法捕捉长距离依赖。我们随后将窗口向右滑动一个标记,将其作为目标 y。在序列生成训练中,将输入序列向右滑动一个标记并作为输出,是训练语言模型(包括 Transformer)常用的技术。下面代码块创建了训练数据。

import torch
seq_len = 100                                          # ① 每个输入包含100个索引
xys = []
for n in range(0, len(wordidx) - seq_len - 1):         # ② 从文本第一个标记开始,依次向右滑动
    x = wordidx[n:n + seq_len]                         # ③ 定义输入 x
    y = wordidx[n + 1:n + seq_len + 1]                 # ④ 输入 x 向右滑动一个标记,作为输出 y
    xys.append((torch.tensor(x), torch.tensor(y)))

通过将序列向右滑动一个标记并作为输出,模型被训练以根据前面的标记预测下一个标记。例如,输入序列是 “how are you”,则对应的滑动序列是 “are you today”。训练时,模型学习看到 “how” 后预测 “are”,看到 “are” 后预测 “you”,以此类推。这样,模型学会了序列中下一个标记的概率分布。你将在本书后续章节多次看到这一方法。

我们将为训练创建数据批次,每批包含 32 对 (x, y):

from torch.utils.data import DataLoader

torch.manual_seed(42)
batch_size = 32
loader = DataLoader(xys, batch_size=batch_size, shuffle=True)

现在训练数据集已经准备好了,接下来我们将创建一个 LSTM 模型,并用刚处理好的数据对其进行训练。

8.4 构建和训练 LSTM 模型

本节中,你将开始使用 PyTorch 内置的 LSTM 层构建一个 LSTM 模型。该模型从一个词嵌入层开始,将每个索引转换成一个128维的稠密向量。训练数据会先通过这个嵌入层,再输入到 LSTM 层。LSTM 层设计用于顺序处理序列中的元素。接着数据会传入一个线性层,其输出大小与词汇表大小一致。LSTM 模型输出的是 logits,即用于 softmax 计算概率的输入。

构建好 LSTM 模型后,下一步将使用训练数据对模型进行训练,以提升模型对输入数据模式的理解和生成能力。

8.4.1 构建 LSTM 模型

在清单 8.3 中,我们定义了 WordLSTM() 类,用作训练模型以生成《安娜·卡列尼娜》风格文本。类定义如下:

from torch import nn
device = "cuda" if torch.cuda.is_available() else "cpu"

class WordLSTM(nn.Module):
    def __init__(self, input_size=128, n_embed=128, n_layers=3, drop_prob=0.2):
        super().__init__()
        self.input_size = input_size
        self.drop_prob = drop_prob
        self.n_layers = n_layers
        self.n_embed = n_embed
        vocab_size = len(word_to_int)
        self.embedding = nn.Embedding(vocab_size, n_embed)    # ①
        self.lstm = nn.LSTM(input_size=self.input_size,
                            hidden_size=self.n_embed,
                            num_layers=self.n_layers,
                            dropout=self.drop_prob,
                            batch_first=True)                      # ②
        self.fc = nn.Linear(input_size, vocab_size)
  
    def forward(self, x, hc):
        embed = self.embedding(x)
        x, hc = self.lstm(embed, hc)                           # ③
        x = self.fc(x)
        return x, hc      
        
    def init_hidden(self, n_seqs):                             # ④
        weight = next(self.parameters()).data
        return (weight.new(self.n_layers, n_seqs, self.n_embed).zero_(),
                weight.new(self.n_layers, n_seqs, self.n_embed).zero_())

① 训练数据先通过嵌入层。
② 使用 PyTorch 的 nn.LSTM() 类创建 LSTM 层。
③ 在每个时间步,LSTM 层使用前一个标记和隐藏状态预测下一个标记和新的隐藏状态。
④ 初始化输入序列第一个标记的隐藏状态。

之前定义的 WordLSTM() 类包含三层:词嵌入层、LSTM 层和线性层。我们将 n_layers 设置为 3,表示 LSTM 层堆叠三层,形成堆叠 LSTM,后两层 LSTM 以前一层输出作为输入。init_hidden() 方法在模型用序列第一个元素预测时,将隐藏状态置零。每个时间步的输入是当前标记和前一隐藏状态,输出是下一个标记和新的隐藏状态。

torch.nn.Embedding() 类的工作原理
PyTorch 中的 torch.nn.Embedding() 类用于创建神经网络中的嵌入层。嵌入层是一个可训练的查找表,将整数索引映射为连续、密集的向量表示(嵌入向量)。

创建 torch.nn.Embedding() 实例时,需要指定两个参数:num_embeddings(词汇表大小,即唯一标记数量)和 embedding_dim(每个嵌入向量的维度)。

该类内部会创建一个形状为 (num_embeddings, embedding_dim) 的矩阵(查找表),每行对应一个索引的嵌入向量。嵌入向量初始随机初始化,训练过程中通过反向传播不断更新。

当向嵌入层传入一个索引张量(在前向传播时),它会查找对应行的嵌入向量并返回。更多详情可参考 PyTorch 官方文档:mng.bz/n0Zd

我们实例化 WordLSTM() 类作为 LSTM 模型:

model = WordLSTM().to(device)

创建模型后,权重随机初始化。使用 (x, y) 对训练模型时,LSTM 会通过调整参数学习根据序列中所有前面标记预测下一个标记。正如图 8.2 所示,LSTM 根据当前标记和当前隐藏状态(所有前面标记信息的总结)预测下一个标记和下一个隐藏状态。

我们采用 Adam 优化器,学习率设为 0.0001。损失函数使用交叉熵损失,因为这是一个多分类问题:模型尝试从 12778 个词汇表索引中预测下一个标记。

lr = 0.0001
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
loss_func = nn.CrossEntropyLoss()

现在 LSTM 模型已经构建完毕,接下来将用之前准备的训练数据批次对模型进行训练。

8.4.2 训练 LSTM 模型

在每个训练周期(epoch)中,我们遍历训练集中的所有数据批次 (x, y)。LSTM 模型接收输入序列 x,并生成预测输出序列 ŷ。该预测结果与实际输出序列 y 进行比较,计算交叉熵损失,因为本质上这是一个多分类问题。随后我们调整模型参数以降低损失,就像第二章中对服装分类所做的那样。

虽然我们可以将数据划分为训练集和验证集,并在验证集上不再提升时停止训练(如第二章所示),但这里我们的主要目标是理解 LSTM 模型的工作原理,而非追求最佳参数调优。因此,我们将训练模型 50 个周期。

训练代码示例如下:

model.train()
  
for epoch in range(50):
    tloss = 0
    sh, sc = model.init_hidden(batch_size)
    for i, (x, y) in enumerate(loader):                     # ①
        if x.shape[0] == batch_size:
            inputs, targets = x.to(device), y.to(device)
            optimizer.zero_grad()
            output, (sh, sc) = model(inputs, (sh, sc))       # ②
            loss = loss_func(output.transpose(1, 2), targets) # ③
            sh, sc = sh.detach(), sc.detach()
            loss.backward()
            nn.utils.clip_grad_norm_(model.parameters(), 5)
            optimizer.step()                                  # ④
            tloss += loss.item()
        if (i + 1) % 1000 == 0:
            print(f"at epoch {epoch} iteration {i + 1} average loss = {tloss / (i + 1)}")

① 遍历训练数据中的所有 (x, y) 批次。
② 使用模型预测输出序列。
③ 将预测结果与实际输出比较,计算损失。
④ 调整模型参数以最小化损失。

在上述代码中,sh 和 sc 共同构成隐藏状态。特别地,细胞状态 sc 作为一个传送带,携带信息跨越多个时间步,在每个时间步添加或移除信息。sh 是 LSTM 单元在当前时间步的输出,包含当前输入信息,并传递给序列中的下一个 LSTM 单元。

如果使用支持 CUDA 的 GPU,训练大约需要 6 小时;仅使用 CPU 则可能需要一两天,具体取决于硬件配置。你也可以从我的网站下载预训练权重:mng.bz/vJZa

训练完成后,将模型权重保存到本地文件夹:

import pickle
  
torch.save(model.state_dict(), "files/wordLSTM.pth")
with open("files/word_to_int.p", "wb") as fb:
    pickle.dump(word_to_int, fb)

字典 word_to_int 也会保存在本地,这一步非常实用,确保你在使用训练好的模型生成文本时,无需重复执行分词(tokenization)过程。

8.5 使用训练好的 LSTM 模型生成文本

现在你已经拥有了一个训练好的 LSTM 模型,本节将教你如何利用它来生成文本。目标是通过基于已有的前序 token 迭代预测下一个 token,检验训练好的模型是否能够生成语法正确且连贯的文本。你还将学习如何使用温度(temperature)和 Top-K 采样来控制生成文本的创造性。

生成文本时,我们从一个提示(prompt)开始,作为模型的初始输入。用训练好的模型预测最可能的下一个 token,将其添加到提示末尾,然后将更新后的序列再次输入模型继续预测下一个 token。如此反复,直到生成序列达到预定长度。

8.5.1 通过预测下一个 token 生成文本

首先,加载训练好的模型权重和词典 word_to_int

model.load_state_dict(torch.load("files/wordLSTM.pth", map_location=device))
with open("files/word_to_int.p", "rb") as fb:
    word_to_int = pickle.load(fb)
int_to_word = {v: k for k, v in word_to_int.items()}

文件 word_to_int.p 也可以在书的 GitHub 仓库中找到。这里我们将词典 word_to_int 的键值对调换,得到 int_to_word

生成文本时,需要一个起始提示。默认提示设为“Anna and the”。一种简单的停止生成的方法是限制文本长度,比如 200 个 token,当达到指定长度时停止生成。

以下代码定义了一个 sample() 函数,基于提示生成文本:

import numpy as np

def sample(model, prompt, length=200):
    model.eval()
    text = prompt.lower().split(' ')
    hc = model.init_hidden(1)
    length = length - len(text)                              # ①
    for i in range(length):
        if len(text) <= seq_len:
            x = torch.tensor([[word_to_int[w] for w in text]])
        else:
            x = torch.tensor([[word_to_int[w] for w in text[-seq_len:]]])  # ②
        inputs = x.to(device)
        output, hc = model(inputs, hc)                       # ③
        logits = output[0][-1]
        p = nn.functional.softmax(logits, dim=0).detach().cpu().numpy()
        idx = np.random.choice(len(logits), p=p)             # ④
        text.append(int_to_word[idx])                         # ⑤
    text = " ".join(text)
    for m in ",.:;?!$()/_&%*@'`":
        text = text.replace(f" {m}", f"{m} ")
    text = text.replace('"  ', '"')
    text = text.replace("'  ", "'")
    text = text.replace('" ', '"')
    text = text.replace("' ", "'")
    return text

① 计算还需生成的 token 数量(总长度减去提示长度)。
② 如果当前序列长度不超过 100,则全部输入模型,否则只输入最后 100 个 token。
③ 使用模型进行预测。
④ 根据预测的概率分布随机选择下一个 token。
⑤ 将选中的 token 添加到序列末尾,进行下一轮预测。

sample() 函数接受三个参数:

  • 第一个是你训练好的 LSTM 模型。
  • 第二个是生成文本的起始提示,可以是任意长度的字符串。
  • 第三个是生成文本的总长度,单位为 token,默认为 200。

当生成下一个 token 时,模型使用 NumPy 的 random.choice(len(logits), p=p) 方法随机采样。这里,第一个参数是选择范围,即所有可能的 token 数量(本例为 12,778)。第二个参数 p 是对应 token 的概率数组,概率高的 token 更有可能被选中。

举个例子,使用提示“Anna and the prince”生成文本(提示中标点前请留空格):

torch.manual_seed(42)
np.random.seed(42)
print(sample(model, prompt='Anna and the prince'))

我在 PyTorch 和 NumPy 中都固定了随机种子为 42,方便你复现实验结果。生成的文本示例为:

anna and the prince did not forget what he had not spoken. when the softening barrier was not so long as he had talked to his brother,  all the hopelessness of the impression. "official tail,  a man who had tried him,  though he had been able to get across his charge and locked close,  and the light round the snow was in the light of the altar villa. the article in law levin was first more precious than it was to him so that if it was most easy as it would be as the same. this was now perfectly interested. when he had got up close out into the sledge,  but it was locked in the light window with their one grass,  and in the band of the leaves of his projects,  and all the same stupid woman,  and really,  and i swung his arms round that thinking of bed. a little box with the two boys were with the point of a gleam of filling the boy,  noiselessly signed the bottom of his mouth,  and answering them took the red

你可能注意到,生成的文本全部为小写字母。这是因为在文本预处理阶段,我们将所有大写字母转为小写,以减少唯一 token 数量。

经过 6 小时训练得到的文本相当不错!大部分句子符合语法规范。虽然不及像 ChatGPT 这样先进系统生成的文本精炼,但已是一个重要成就。通过本章的学习,你已经具备训练更先进文本生成模型的能力。

8.5.2 文本生成中的温度(temperature)与Top-K采样

生成文本的创造性可以通过温度和Top-K采样等技术来控制。

温度用于调整在选择下一个词元(token)之前,各候选词元概率的分布。它实际上是对logits(即输入softmax函数以计算概率的值)进行缩放的参数。logits是LSTM模型在应用softmax函数之前的输出。在我们刚定义的sample()函数中,没有对logits进行调整,默认温度为1。较低的温度(小于1,例如0.8)会减少生成文本的多样性,使模型更确定性、更保守,更倾向于选择概率较大的词;相反,较高的温度(大于1,例如1.5)会增加选择低概率词的可能性,从而产生更加多变和富有创造性的输出。但这也可能导致文本的连贯性或相关性降低,因为模型可能选择不太合适的词。

Top-K采样是另一种影响输出的方法。该方法限制只从模型预测的概率最高的K个词中选择下一个词。概率分布被截断,仅保留Top K的词。较小的K值(例如5)限制模型只在少量高概率词中选择,使输出更可预测、更连贯,但多样性和趣味性可能降低。在之前定义的sample()函数中,我们没有应用Top-K采样,因此K值相当于词汇表大小(本例中为12,778)。

接下来,我们介绍一个新的函数generate()用于文本生成。这个函数类似于sample(),但增加了两个参数:temperature和top_k,使得对生成文本的创造性和随机性有更多控制。generate()函数定义如下:

def generate(model, prompt, top_k=None, 
             length=200, temperature=1):
    model.eval()
    text = prompt.lower().split(' ')
    hc = model.init_hidden(1)
    length = length - len(text)    
    for i in range(0, length):
        if len(text) <= seq_len:
            x = torch.tensor([[word_to_int[w] for w in text]])
        else:
            x = torch.tensor([[word_to_int[w] for w in text[-seq_len:]]])
        inputs = x.to(device)
        output, hc = model(inputs, hc)
        logits = output[0][-1]
        logits = logits / temperature                             # ①
        p = nn.functional.softmax(logits, dim=0).detach().cpu()    
        if top_k is None:
            idx = np.random.choice(len(logits), p=p.numpy())
        else:
            ps, tops = p.topk(top_k)                            # ②
            ps = ps / ps.sum()
            idx = np.random.choice(tops, p=ps.numpy())          # ③
        text.append(int_to_word[idx])
     
    text = " ".join(text)
    for m in ",.:;?!$()/_&%*@'`":
        text = text.replace(f" {m}", f"{m} ")
    text = text.replace('"  ', '"')   
    text = text.replace("'  ", "'")  
    text = text.replace('" ', '"')   
    text = text.replace("' ", "'")     
    return text  

① 用温度参数缩放logits
② 仅保留概率最高的K个词候选
③ 从Top K词中采样选择下一个词

相比sample()函数,generate()增加了两个可选参数:top_k和temperature。默认top_k为None,temperature为1。如果调用generate()时不指定这两个参数,输出结果将和sample()相同。

下面通过一个例子展示生成文本的变化,重点放在生成单个词元上。以“I ’ m not going to see”(注意撇号前有空格,和之前章节处理方式一致)作为提示词,我们调用generate()函数10次,length参数设置为提示词长度加1,保证只生成一个额外的词元:

prompt = "I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
    print(generate(model, prompt, top_k=None, 
                   length=len(prompt.split(" ")) + 1, temperature=1))

输出示例:

i'm not going to see you
i'm not going to see those
i'm not going to see me
i'm not going to see you
i'm not going to see her
i'm not going to see her
i'm not going to see the
i'm not going to see my
i'm not going to see you
i'm not going to see me

在默认设置下(top_k=None,temperature=1),输出存在一定重复,例如“you”出现了3次,共有6个不同的词元。

当调整temperature和top_k参数时,generate()功能得以扩展。比如,设置较低温度0.5和较小top_k=3,则生成文本更加可预测,创造性降低。重复上面的单词生成实验:

prompt = "I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
    print(generate(model, prompt, top_k=3, 
                   length=len(prompt.split(" ")) + 1, temperature=0.5))

输出示例:

i'm not going to see you
i'm not going to see the
i'm not going to see her
i'm not going to see you
i'm not going to see you
i'm not going to see you
i'm not going to see you
i'm not going to see her
i'm not going to see you
i'm not going to see her

生成词元的多样性减少,10次中仅出现了3个不同的词元:“you”,“the”和“her”。

再用“Anna and the prince”作为起始提示,temperature=0.5,top_k=3,生成文本示例:

anna and the prince had no milk. but,  "answered levin,  and he stopped. "i've been skating to look at you all the harrows,  and i'm glad. . .  ""no,  i'm going to the country. ""no,  it's not a nice fellow. ""yes,  sir. ""well,  what do you think about it? ""why,  what's the matter? ""yes,  yes,  "answered levin,  smiling,  and he went into the hall. "yes,  i'll come for him and go away,  "he said,  looking at the crumpled front of his shirt. "i have not come to see him,  "she said,  and she went out. "i'm very glad,  "she said,  with a slight bow to the ambassador's hand. "i'll go to the door. "she looked at her watch,  and she did not know what to say 

练习8.4
设置temperature=0.6,top_k=10,以“Anna and the nurse”为起始提示生成文本。PyTorch和NumPy的随机种子均设为0。

另一方面,选择较高温度(如1.5)和较大top_k(如None,表示从全部12778词汇中选)则生成的文本更具创造性,预测性降低。下面示范temperature=2,top_k=None时的单词生成:

prompt = "I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
    print(generate(model, prompt, top_k=None, 
                   length=len(prompt.split(" ")) + 1, temperature=2))

输出示例:

i'm not going to see them
i'm not going to see scarlatina
i'm not going to see behind
i'm not going to see us
i'm not going to see it
i'm not going to see it
i'm not going to see a
i'm not going to see misery
i'm not going to see another
i'm not going to see seryozha

生成文本几乎无重复,10次生成了9个不同的词元,仅“it”重复了一次。

再用“Anna and the prince”作为起始提示,temperature=2,top_k=None生成文本:

anna and the prince took sheaves covered suddenly people. "pyotr marya borissovna,  propped mihail though her son will seen how much evening her husband;  if tomorrow she liked great time too. "adopted heavens details for it women from this terrible,  admitting this touching all everything ill with flirtation shame consolation altogether:  ivan only all the circle with her honorable carriage in its house dress,  beethoven ashamed had the conversations raised mihailov stay of close i taste work? "on new farming show ivan nothing. hat yesterday if interested understand every hundred of two with six thousand roubles according to women living over a thousand:  snetkov possibly try disagreeable schools with stake old glory mysterious one have people some moral conclusion,  got down and then their wreath. darya alexandrovna thought inwardly peaceful with varenka out of the listen from and understand presented she was impossible anguish. simply satisfied with staying after presence came where he pushed up his hand as marya her pretty hands into their quarters. waltz was about the rider gathered;  sviazhsky further alone have an hand paused riding towards an exquisite

此输出文本重复度低,但连贯性较差。

练习8.5
设置temperature=2,top_k=10000,以“Anna and the nurse”为起始提示生成文本。PyTorch和NumPy随机种子均设为0。

本章你已经掌握了自然语言处理的基础技能,包括词级分词、词嵌入和序列预测。通过练习,你学会了基于词级分词构建语言模型,并使用LSTM训练该模型进行文本生成。接下来几章将介绍Transformer模型的训练,这是像ChatGPT这类系统所用的模型类型,将帮助你更深入理解先进的文本生成技术。

总结

  • 循环神经网络(RNN)是一种专门设计用于识别序列数据(如文本、音乐或股价)中模式的人工神经网络。与传统神经网络对输入独立处理不同,RNN内部具有循环结构,使信息能够在网络中持续传递。长短期记忆网络(LSTM)是RNN的改进版本。

  • 分词有三种方法:第一种是字符分词,即将文本拆分为单个字符;第二种是词分词,即将文本拆分成单个词;第三种是子词分词,将词拆解为更小且有意义的组成部分,称为子词。

  • 词嵌入是一种将词转换成紧凑向量表示的方法,能够捕捉词语的语义信息及其相互关系。该技术在自然语言处理(NLP)中至关重要,尤其是因为深度神经网络(包括LSTM和Transformer等模型)需要数值形式的输入。

  • 温度是影响文本生成模型行为的一个参数。它通过在应用softmax函数计算概率前,对logits(softmax输入值)进行缩放来控制预测的随机性。较低温度使模型预测更保守但重复性更高;较高温度则使模型输出更具多样性和创新性,减少重复。

  • Top-K采样是另一种调节文本生成模型行为的方法。它从模型预测的概率最高的K个词中选择下一个词,概率分布被截断为仅保留这K个词。较小的K值使输出更可预测、更连贯,但可能降低多样性和趣味性。