在过去的几年里,自然语言处理(NLP)或文本数据处理在研究圈和特别是在工业界引起了广泛的关注。文本不仅仅是另一种非结构化的数据类型;它所代表的内容远远超过眼见的。文本数据是我们思想、理念、知识和交流的表现。
在本章以及接下来的几章中,我们将重点理解与NLP和生成模型相关的概念。我们将重点讨论以下主题:
- 传统的文本数据处理方法概述
- 不同的文本表示方法及其在NLP领域中的关键作用
- 简要了解基于RNN和卷积的文本生成架构
我们将深入探讨不同架构的内部工作原理和关键贡献,这些贡献推动了文本生成应用的实现,并为现代架构奠定了基础。我们还将构建并训练这些架构,以更好地理解它们。读者还应注意,我们将在后续章节中深入探讨关键贡献和相关细节,帮助我们建立对更复杂架构的基础理解。
在我们讨论本章的关键代码片段时,读者可以参考GitHub上的完整代码库: github.com/PacktPublis...
让我们从理解如何表示文本数据开始。
文本表示
语言是我们存在中最复杂的方面之一。我们使用语言来传达我们的思想和选择。每种语言都有一组定义的字符,称为字母表,一个词汇,以及一套称为语法的规则。然而,理解和学习一门语言并不是一项简单的任务。语言是复杂的,具有模糊的语法规则和结构。
文本是语言的一种表现形式,帮助我们进行沟通和分享。这使得它成为研究人工智能可以实现的领域之一。一般来说,机器学习和深度学习算法处理的是数字、矩阵、向量等数据。这一点很重要,因为这些算法中的基本操作,如矩阵乘法、梯度下降、反向传播等,都是基于数值输入的。这进一步引出了一个问题:我们如何表示文本,以便处理与语言相关的任务?
稀疏表示(词袋模型)
正如我们之前提到的,每种语言由一组定义的字符(字母表)组成,这些字符组合成单词(词汇)。传统上,词袋模型(BoW)一直是表示文本信息的最流行方法之一。
词袋模型是一种简单而灵活的将文本转换为向量形式的方法。顾名思义,词袋模型将每个单词作为基本的度量单位。词袋模型描述了在给定文本语料库中单词的出现情况。为了构建一个词袋模型,我们需要两个主要元素:
- 词汇:来自要分析的文本语料库中的已知单词集合。
- 出现频率的度量:我们根据应用或任务选择的某种度量。例如,计算每个单词的出现次数,称为词频(term frequency),就是一种度量。
词袋模型之所以被称为"袋子",是为了突出其简单性和我们忽略出现顺序这一事实。听起来可能是个大问题,但直到最近,词袋模型仍然是表示文本数据非常流行和有效的选择。让我们通过几个例子快速了解这种简单方法的工作原理。
例子:
"Some say the world will end in fire, Some say in ice. From what I have tasted of desire, I hold with those who favour fire."
这是罗伯特·弗罗斯特的诗《Fire and Ice》中的一段简短摘录。我们将使用这几行文本来理解词袋模型的工作原理。以下是逐步的处理方法:
-
定义词汇: 第一步是定义我们语料库中的已知单词列表。为了便于理解和实际操作,我们可以暂时忽略大小写和标点符号。因此,词汇(或唯一单词)是:{some, say, the, world, will, end, in, fire, ice, from, what, i, have, tasted, of, desire, hold, with, those, who, favour}。
这个词汇集包含了26个单词中的21个唯一单词。
-
定义出现频率度量: 一旦我们有了词汇集,就需要定义如何度量每个词汇中单词的出现情况。如前所述,存在许多方法来实现这一点。其中一种度量方法是简单地检查特定单词是否存在或缺失。如果单词存在,则用1表示;如果单词缺失,则用0表示。
多年来,已开发出其他一些度量标准。最广泛使用的度量标准有:
- 词频(Term Frequency)
- TF-IDF(词频-逆文档频率)
- BM25
- 哈希(Hashing)
这些步骤提供了对词袋模型如何帮助我们将文本数据表示为数字或向量的高层次概览。我们从诗歌中摘录的文本的整体向量表示如下表所示:

每一行矩阵对应诗歌中的一行,而词汇表中的唯一单词则构成列。因此,每一行就是所考虑文本的向量表示。
在改善这种方法的结果时,还涉及一些额外的步骤。这些改进与词汇和评分方面相关。管理词汇非常重要;通常,文本语料库的大小会迅速增加。处理词汇的常见方法包括忽略标点符号、忽略大小写以及去除停用词。
词袋模型(BoW)是一个简单而有效的工具,它为大多数NLP任务提供了一个很好的起点。然而,这个方法也存在一些问题,主要可以总结为以下几点:
缺失上下文:
正如我们之前提到的,词袋模型并不考虑文本的排序或结构。通过简单地丢弃与排序相关的信息,向量就失去了捕捉基础文本使用上下文的能力。例如,句子"I am sure about it"和"Am I sure about it?"会有相同的向量表示,但它们表达的思想不同,可能导致在特定任务中产生不同的解释。在这个例子中,对于意图分类任务,第一个句子是肯定的,而第二个句子是疑问的。将词袋模型扩展为包括n-grams(连续词语)而不是单独的词语,确实有助于捕捉一些上下文,但方式非常有限。
词汇表和稀疏向量:
随着语料库大小的增加,词汇量也会增加。管理词汇大小所需的步骤需要大量的监督和人工努力。由于这种模型的工作方式,较大的词汇表会导致非常稀疏的向量。稀疏向量在建模和计算需求(空间和时间)方面会带来问题。这也被称为"维度灾难",并导致在处理不同的NLP任务时效果不佳,例如句子相似性。虽然激进的修剪和词汇管理步骤在一定程度上有所帮助,但也可能导致重要特征的丢失。
在这里,我们讨论了词袋模型如何帮助将文本转换为向量形式,并提到了这种设置的一些问题。在下一节中,我们将介绍一些更复杂的表示方法,这些方法能够缓解上述问题。
稠密表示
解决稀疏性问题的一个简单替代方法是通过将每个词语编码为一个唯一的数字来实现。继续使用上一节的例子,"some say ice",我们可以为"some"分配1,给"say"分配2,给"ice"分配3,以此类推。这将产生一个稠密的向量,[1, 2, 3]。这是空间的高效利用,最终我们得到了一个所有元素都满的向量。然而,缺失上下文的问题仍然存在。
可解释性是NLP任务中的一个重要要求。对于计算机视觉应用,视觉线索足够直观,能够帮助我们理解模型如何感知或生成输出(在那方面量化也是一个问题,但我们暂时可以跳过)。对于NLP任务,由于文本数据首先需要转换为向量,因此理解这些向量所捕捉的内容以及它们如何被模型使用是非常重要的。
在接下来的章节中,我们将讨论一些流行的向量化技术,这些技术试图在捕捉上下文的同时,限制向量的稀疏性。
Word2vec
《牛津英语词典》大约有60万个独立单词,并且每年都在增长。然而,这些单词并不是独立的词汇;它们之间有某种关系。Word2vec模型的目的是学习能够捕捉上下文的高质量向量表示。J.R. Firth的名言很好地总结了这一点:"你将通过一个词所交的朋友来了解这个词。"
在《高效估计词向量表示》一文中,Mikolov等人提出了两种不同的模型,从大型语料库中学习单词的向量表示。Word2vec是这些模型的一个软件实现,被归类为一种迭代式学习嵌入向量的方法。能够拥有捕捉某种相似性的单词向量形式是非常强大的。让我们详细了解Word2vec模型如何实现这一点。
连续词袋模型(CBOW)
连续词袋模型(CBOW)是我们在前一节中讨论的词袋模型的扩展。这个模型的关键要素是上下文窗口。上下文窗口定义为一个固定大小的滑动窗口,在句子中滑动。窗口中的中间词称为目标词,窗口内左边和右边的词语是上下文词。CBOW模型通过预测目标词,给定它的上下文词。
例如,考虑以下参考句子:"Some say the world will end in fire"。如果我们选择窗口大小为4,目标词为"world",那么上下文词将是{say, the}和{will, end}。模型的输入是形式为(上下文词,目标词)的元组,然后将其传入神经网络进行嵌入学习。
这一过程如以下图所示:

如上图所示,上下文词汇(记作)作为输入传递给模型,以预测目标词汇(记作wt)。CBOW模型的整体工作原理可以解释如下: 对于大小为V的词汇表,定义一个大小为C的上下文窗口。C可以是4、6或其他大小。我们还定义了两个矩阵W和W',分别用于生成输入向量和输出向量。矩阵W的维度为V×N,而W'的维度为N×V,其中N是嵌入向量的大小。 上下文词汇和目标词汇(y)被转化为one-hot编码(或标签编码),并以元组的形式准备训练数据。 我们对上下文向量进行平均,得到v'。 最终的输出评分向量z是通过平均向量v'与输出矩阵W'之间的点积计算得出的。 输出评分向量通过softmax函数转化为概率;即y' = softmax(z),其中y'应对应词汇表中的某个词汇。 最终的目标是训练神经网络,使得y'和实际目标y尽可能接近。 作者提出使用交叉熵等代价函数来训练网络并学习这些嵌入。
Skip-gram模型
Skip-gram模型是论文中提出的第二种用于学习词嵌入的变体。本质上,这个模型的工作方式与CBOW模型完全相反。换句话说,在skip-gram模型中,我们输入一个词(中心词/目标词),并预测上下文词汇作为模型的输出。让我们使用之前的示例:"Some say the world will end in fire"。在这里,我们将以world作为输入词,并训练模型以高概率预测{say, the, will, end}作为上下文词汇。 为了提高结果并加速训练过程,作者提出了一些简单而有效的技巧。负采样、噪声对比估计和层次softmax等概念就是一些被采用的技术。
为了方便理解,我们可以使用一个知名的Python库gensim来准备我们自己的词向量。第一步是准备数据集。为了我们的练习,我们将使用sklearn库中的20newsgroup数据集。该数据集包含不同主题的新闻文章。以下代码段使用nltk清理该数据集并准备下一步:
ini
# import statements and code for the function normalize_corpus
# have been skipped for brevity. See corresponding
# notebook for details.
cats = ['alt.atheism', 'sci.space']
newsgroups_train = fetch_20newsgroups(subset='train',
categories=cats,
remove=('headers', 'footers',
'quotes'))
norm_corpus = normalize_corpus(newsgroups_train.data)
下一步是将每篇新闻文章分词。我们通过空格将句子拆分成词。以下代码段首先对文本进行分词,然后使用gensim训练一个skip-gram Word2vec模型:
ini
# tokenize corpus
tokenized_corpus = [nltk.word_tokenize(doc) for doc in norm_corpus]
# Set values for various parameters
embedding_size = 32 # Word vector dimensionality
context_window = 20 # Context window size
min_word_count = 1 # Minimum word count
sample = 1e-3 # Downsample setting for frequent words
sg = 1 # skip-gram model
w2v_model = word2vec.Word2Vec(tokenized_corpus,
size=embedding_size,
window=context_window,
min_count =min_word_count,
sg=sg, sample=sample, iter=200)
只需要几行代码,我们就可以准备好我们词汇表的Word2vec表示。以下代码展示了如何获取任何单词的向量表示。我们还将展示如何获取与给定单词最相似的词:
css
# get word vector
w2v_model.wv['sun']
array([ 0.607681, 0.2790227, 0.48256198, 0.41311446, 0.9275479, -1.1269532, 0.8191313, 0.03389674, -0.23167856, 0.3170586, 0.0094937, 0.1252524, -0.5247988, -0.2794391, -0.62564677, -0.28145587, -0.70590997, -0.636148, -0.6147065, -0.34033248, 0.11295943, 0.44503215, -0.37155458, -0.04982868, 0.34405553, 0.49197063, 0.25858226, 0.354654, 0.00691116, 0.1671375, 0.51912665, 1.0082873 ], dtype=float32)
# get similar words
w2v_model.wv.most_similar(positive=['god'])
[('believe', 0.8401427268981934), ('existence', 0.8364629149436951), ('exists', 0.8211747407913208), ('selfcontradictory', 0.8076522946357727), ('gods', 0.7966105937957764), ('weak', 0.7965559959411621), ('belief', 0.7767481803894043), ('disbelieving', 0.7757835388183594), ('exist', 0.77425217628479), ('interestingly', 0.7742466926574707)]
以上输出展示了单词"sun"的32维向量。我们还展示了与单词"god"最相似的单词。从中可以明显看出,"believe","existence"等词汇与其最为相似,这也符合我们使用的数据集的语境。
GloVe
Word2vec模型在提升各种NLP任务的性能方面起到了重要作用。继续保持这一势头,另一个重要的实现------GloVe(全局词向量表示)应运而生。GloVe由Pennington等人于2014年发布,旨在通过在学习词向量时考虑全局上下文来改进已知的词表示技术。GloVe的工作原理是首先创建一个词汇表的共现矩阵,其中矩阵的每个元素(i, j)表示词i在词j的上下文中出现的频率。然后,词向量作为矩阵分解步骤的一部分被准备出来,这一过程在保持共现信息的同时减少了维度。 两个模型(Word2vec和GloVe)在各种NLP任务上的性能大致相似。由于大规模语料库是获得更好嵌入所必需的,在大多数实际应用中,预训练的嵌入已经可以使用并被应用。 预训练的GloVe向量可以通过许多包获取,如spacy。本章节的笔记本中提供了一个完整的示例。
FastText
Word2Vec和GloVe是强大的方法,在将单词编码到向量空间时具有良好的性能。这两种技术可以很好地获取词汇表中单词的向量表示,但它们对于词汇表外的词汇没有明确的答案。 在Word2vec和GloVe方法中,单词是基本单位。FastText的实现挑战并改进了这一假设。FastText的词表示部分基于Bojanowski等人在2017年发布的论文《通过子词信息丰富词向量》。该论文将每个单词分解为一组n-gram,这有助于捕捉和学习不同字符组合的向量表示,而不像早期的技术那样只处理整个单词。 例如,如果我们考虑单词"India",并设置n=3进行n-gram分解,它将把单词分解为{<india>, <in, ind, ndi, dia, ia>}
。符号<和>是特殊字符,用于表示原始单词的开始和结束,并且被添加到语料库的词汇表中。这有助于区分<in>
(表示整个单词)和<in>
(表示n-gram)。这种方法帮助FastText生成词汇表外的单词嵌入。通过添加并平均所需n-gram的向量表示,可以实现这一点。FastText在处理可能出现新词汇或词汇表外单词的用例时,显著提高了性能。建议读者参考本章节的相关笔记本中的完整示例,以便更好地理解FastText。
上下文表示
Word2Vec和GloVe为NLP领域的进步提供了必要的推动力,FastText等作品进一步推动了这一领域的发展。我们还可以将这一范式扩展到生成句子级、甚至文档级的嵌入,以解决各种NLP任务。 尽管这些方法具有优势,但它们是静态的或基于共现的表示,缺乏上下文信息。让我们通过一个简单的例子来更好地理解这一点。
- Did you see the look on her face?
- We could see the clock face from below.
- It is time to face your demons.
在这些例句中,单词"face"的意义在每个句子中是不同的。静态表示模型在这种情况下以及其他类似场景中表现不佳。进一步的研究与深度学习架构的改进,催生了更复杂的表示。 AllenNLP提出的"深度上下文化词表示"(Deep Contextualized Word Representations)是这一领域的下一次突破。这是一个基于字符的模型,利用两个双向语言模型的不同层学习上下文化嵌入(稍后将详细介绍语言模型)。这些嵌入被称为ELMo(Embeddings from Language Models)。 该论文指出,语言模型的不同层编码了不同的信息,如词性标注或词义消歧。将所有层的表示连接起来,有助于计算词嵌入,这些词嵌入是整个句子语料库的函数。 这一工作为基于多任务学习的进一步改进奠定了基础,如MILA的通用句子表示(General Purpose Sentence Representation)和Google的通用句子编码器(Universal Sentence Encoder)。通用句子表示工作利用RNN(特别是GRU)基于六种不同的NLP任务(下一个/前一个句子预测、机器翻译、句法分析等)学习句子表示,并展示了强大的基线性能。另一方面,通用句子编码器采用了类似的理念,但使用了transformer架构(下一章将详细介绍)来进一步改进现有的基线。 本节提到的上下文表示模型以及其他模型,都是在大规模语料库上预训练的,并可供各种下游包使用。有关完整示例,参考相关笔记本。 现在我们已经讨论了与文本表示相关的基本概念,接下来我们将在下一节中从头开始构建一个简单的文本生成模型。
文本生成与LSTM的魔力
通常,我们使用由不同类型的层组成的前馈网络来构建模型。这些网络每次处理一个训练样本,而每个样本之间相互独立。我们称这些样本是独立同分布的,简称IID。语言或文本则有些不同。
正如我们在前面几节中讨论的那样,单词的含义会根据它们所处的上下文发生变化。换句话说,如果我们要开发并训练一个语言生成模型,就必须确保模型理解其输入的上下文。
递归神经网络(RNN)是一类允许将先前的输出作为输入,并结合记忆或隐藏单元的神经网络。对先前输入的"记忆"有助于捕捉上下文,并使我们能够处理变长的输入序列(句子的长度通常不同)。与典型的前馈网络不同,后者每个输入彼此独立,RNN引入了先前输出影响当前和未来输出的概念。
RNN有几种不同的变体,分别是GRU(门控循环单元)和LSTM(长短期记忆网络)。要详细了解LSTM,您可以参考此链接。
接下来,我们将更加正式地定义文本生成的任务。
语言模型
自动补全是一个常见且常用的正式概念,称为语言建模。语言模型将某些文本作为输入上下文,生成下一组单词作为输出。这很有趣,因为语言模型试图理解输入上下文及其语言结构,并通过推测规则来预测下一个单词。传统上,我们一直在使用语言模型,例如在搜索引擎、聊天平台、电子邮件等文本补全工具中,最近随着ChatGPT(以及类似产品)的出现,应用场景进一步扩展。
让我们通过理解生成训练数据集的过程来开始。我们可以借助图3.3来实现这一点。该图描绘了一个基于单词的语言模型;即一个以单词为基本单位的模型。同样,我们也可以开发基于字符、短语或甚至文档级别的模型:

如我们前面所提到的,语言模型会根据上下文生成下一组词汇。这个上下文也叫做滑动窗口,它从左到右(对于从右到左书写的语言则是从右到左)在输入句子上滑动。图3.3中展示的滑动窗口跨度为三个词,它们作为输入。
每个训练数据点的对应输出是窗口后面的紧接着的下一个词(如果目标是预测下一个短语,则为一组词)。因此,我们准备我们的训练数据集,它由形如({上下文词汇},next_word)的元组组成。滑动窗口帮助我们从每个句子中生成大量的训练样本,而无需显式标注。
这个训练数据集然后用来训练基于RNN的语言模型。实际上,我们通常使用LSTM或GRU单元来代替传统的RNN单元。语言模型根据上下文词汇进行自回归,模型生成相应的下一个词。然后,我们使用时间反向传播(BPTT)通过梯度下降更新模型权重,直到达到所需的性能。
现在,我们对语言模型是什么以及准备训练数据集和模型设置的步骤有了基本的理解。接下来,让我们使用PyTorch实现其中的一些概念。
实践操作:字符级语言模型
与前面章节的讨论不同,我们这里将构建一个字符级语言模型。选择这种更为细粒度的语言模型是为了便于训练。字符级语言模型需要处理比词级语言模型小得多的词汇表。
为了构建我们的语言模型,第一步是获取一个数据集作为训练源。Project Gutenberg是一个志愿者努力将历史作品数字化并作为免费下载提供的平台。由于我们需要大量的数据来训练语言模型,我们将选择其中一本书------弗朗茨·卡夫卡的《变形记》。这本书可以从以下网址下载:www.gutenberg.org/ebooks/5200。
以下代码加载书籍内容,作为我们的源数据集:
python
datafile_path = './metamorphosis_franz_kafka.txt'
# 加载文本文件
text = open(datafile_path, 'rb').read().decode(encoding='utf-8')
print('书籍总共有 {} 个字符'.format(len(text)))
# 输出:书籍总共有 140527 个字符
vocab = sorted(set(text))
print('{} 个唯一字符'.format(len(vocab)))
# 输出:89 个唯一字符
接下来的步骤是准备我们的数据集。正如我们在"文本表示"部分讨论的那样,文本数据通过不同的模型转化为向量。实现这一目标的一种方式是先将它们转化为独热编码向量,然后再使用像Word2Vec这样的模型转化为稠密表示。另一种方式是先将它们转化为任意的数值表示,然后与RNN模型的其余部分一起训练嵌入层。在这种情况下,我们采用后者的方法,即与模型的其他部分一起训练嵌入层。
以下代码准备了一个数据集类,映射字符到整数索引,以及反向映射:
python
class CharLMDataset(Dataset):
def __init__(self, data, window_size=100):
super(CharLMDataset, self).__init__()
self.text = text
self.window_size = window_size
self.vocab = tuple(set(text))
self.int2char = dict(enumerate(self.vocab))
self.char2int = {ch: ii for ii, ch in self.int2char.items()}
def __len__(self):
return len(self.text) - self.window_size
def __getitem__(self, ix):
X = LongTensor(
[self.char2int[c] for c in self.text[ix : ix + self.window_size]]
)
y = self.char2int[self.text[ix + self.window_size]]
return X, y
我们将输入序列限制为100个字符,并使用数据集创建训练和验证数据加载器。以下代码展示了如何做到这一点:
ini
charlm_dataset = CharLMDataset(text[idx:], window_size=window_size)
n_samples = len(charlm_dataset)
vocab_size = len(charlm_dataset.vocab)
train_split_idx = int(n_samples * 0.8)
train_indices, val_indices = np.arange(train_split_idx), np.arange(train_split_idx, n_samples)
charlm_dataset
对象帮助我们生成相应的训练和验证对象。我们在本节前面介绍了语言模型是如何根据上下文窗口生成下一个词或字符的。记住这一概念,数据集类中的__getitem__
方法帮助我们实现这一目标。
接下来,我们使用一个实用函数来定义我们的语言模型。以下代码定义了一个类CharLM
,它准备了一个基于LSTM的语言模型:
ini
class CharLM(Module):
def __init__(
self,
vocab_size,
embedding_dim=16,
dense_dim=32,
hidden_dim=8,
n_layers=2,
):
super().__init__()
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
self.dense_dim = dense_dim
self.hidden_dim = hidden_dim
self.n_layers = n_layers
self.embedding = Embedding(
self.vocab_size,
self.embedding_dim,
)
self.lstm = LSTM(
self.embedding_dim,
self.hidden_dim,
batch_first=True,
num_layers=self.n_layers
)
self.dropout = Dropout(p=0.4)
self.linear_1 = Linear(self.hidden_dim, self.dense_dim)
self.linear_2 = Linear(self.dense_dim, self.vocab_size)
def forward(self, x, h=None, c=None):
emb = self.embedding(x)
if h is not None and c is not None:
_, (h, c) = self.lstm(emb, (h, c))
else:
_, (h, c) = self.lstm(emb)
h_mean = h.mean(dim=0)
drop_out = self.dropout(h_mean)
linear1_out = self.linear_1(drop_out)
logits = self.linear_2(linear1_out)
return logits, h, c
从这段代码可以看出,模型是由嵌入层、LSTM层、Dropout层和密集层组成的。嵌入层帮助将原始文本转化为向量形式,随后是LSTM和密集层,它们学习上下文和语言语义。Dropout层有助于防止过拟合。
我们训练模型几个周期,如下所示:
scss
# 训练模型
history_train_loss = list()
history_val_loss = list()
prompt_text = "What's happened to me?" he thought. It wasn't a dream. His"
for e in range(n_epochs + 1):
char_lm.train()
train_loss = 0.0
for X_batch, y_batch in tqdm(train_dataloader):
if e == 0:
break
optimizer.zero_grad()
probs, _, _ = char_lm(X_batch.to('cuda'))
train_loss = criterion(probs, y_batch.to('cuda'))
train_loss.backward()
optimizer.step()
val_loss = compute_loss(criterion, char_lm, val_dataloader)
print(f"Epoch: {e}, {train_loss=:.3f}, {val_loss=:.3f}")
history_train_loss.append(train_loss)
history_val_loss.append(val_loss)
if e % 3 == 0:
# 生成一句话
# 贪婪生成
generated_text = generate_text(
100, char_lm, charlm_dataset, prompt_text=prompt_text
)
print(generated_text)
恭喜,你已经训练出了自己的第一个语言模型。现在,我们将使用它来生成一些虚假文本。在此之前,我们需要了解如何解码模型生成的输出。
解码策略
前面,我们将所有的文本数据转化为适合训练和推理的向量形式。现在,经过训练的模型已经准备好,下一步是输入一些上下文词汇,并生成下一个词作为输出。这一输出生成的过程正式称为解码步骤。之所以称之为"解码",是因为模型输出的是一个向量,需要经过处理才能得到实际的词汇输出。解码有几种不同的技术,下面简要讨论几种流行的方法:贪婪解码、束搜索和采样。
贪婪解码
这是一种最简单且最快的解码策略。顾名思义,贪婪解码是一种在每次预测步骤中选择概率最高的词汇的方法。
虽然这种方法快速且高效,但贪婪解码在生成文本时会出现一些问题。由于它只关注最高概率的输出,模型可能会生成不一致或不连贯的内容。在字符级语言模型的情况下,这甚至可能导致生成的输出是非词典中的词汇。贪婪解码还限制了输出的多样性,这可能导致生成的内容重复。
束搜索
束搜索是贪婪解码的广泛替代方法。与选择最高概率的词汇不同,束搜索在每个时间步保持追踪n个可能的输出。以下图示说明了束搜索解码策略,它展示了从第0步开始形成多个束,创建了一个类似树状的结构:

如图3.4所示,束搜索策略通过在每个时间步追踪n个预测,最后选择具有最高整体概率的路径,图中用粗体线表示。让我们逐步分析前面图示中使用的束搜索解码示例,假设束的大小为2。
在时间步t0: 模型预测了以下三个词(及其概率):(the, 0.3)、(when, 0.6)和(and, 0.1)。
在贪婪解码的情况下(在时间步t1),我们会选择"when"作为它具有最高的概率。
在束搜索中,我们会追踪前两个输出,因为束的大小是2。
在时间步t1: 我们重复相同的步骤;即我们从两个束中分别追踪前两个输出。
束的得分是通过沿着分支相乘概率来计算的,计算过程如下:
- (when, 0.6) -> (the, 0.4) = 0.6 * 0.4 = 0.24
- (the, 0.3) -> (war, 0.9) = 0.3 * 0.9 = 0.27
根据上面的讨论,最终生成的输出是:"It was July, 1805 the war"。与" It was July, 1805 when the"相比,这个输出的最终概率为0.27,而后者的得分为0.24,贪婪解码会给出这个输出。
这种解码策略大大改进了我们前一节讨论的简单贪婪解码策略。它在某种程度上为语言模型提供了更多的能力,以选择最佳的输出结果。
采样
采样是从较大的人群中选择预定数量的观测值的过程。作为贪婪解码的改进方法,可以使用随机采样解码方法来解决变异/重复问题。一般来说,基于采样的解码策略有助于根据当前上下文选择下一个词,即:

在这里,wtw_twt 是时间步 ttt 的输出,它是基于直到时间步 t−1t-1t−1 生成的词汇进行条件化的。继续我们前面解码策略的例子,以下图示突出显示了基于采样的解码策略如何选择下一个词:

如图3.5所示,这种方法在每个时间步从给定的条件概率中随机选择一个词。在我们的例子中,模型最终随机选择了"in",然后是"Paris"作为后续输出。如果仔细观察,在时间步t1,模型选择了概率最小的词。这引入了与人类使用语言方式相关的必要随机性。Holtzman等人在他们的工作《The Curious Case of Neural Text Degeneration》中提出了这个确切的论点,指出人类并不总是简单地使用概率最高的词汇。他们通过不同的场景和例子,强调语言是一个随机选择的词汇,而不是束搜索或贪婪解码形成的典型高概率曲线。
这引出了一个重要的参数,叫做温度。
温度
正如我们前面讨论的,基于采样的解码策略有助于提高输出的随机性。然而,过多的随机性也不是理想的,因为它可能导致无意义的和不连贯的结果。为了控制这种随机性的程度,我们可以引入一个可调参数,称为温度。这个参数有助于增加高概率词汇的可能性,同时减少低概率词汇的可能性,从而导致更尖锐的分布。高温度会导致更多的随机性,而低温度则带来可预测性。需要注意的一点是,这个参数可以应用于任何解码策略。
Top-k 采样
束搜索和基于采样的解码策略各自有优缺点。Top-k采样是一种混合策略,它结合了两者的优点,提供了一个更为复杂的解码方法。简单来说,在每个时间步,模型不是随机选择一个词,而是追踪排名前k的词汇(类似于束搜索),然后在这些词汇之间重新分配概率。模型通过只关注排名前k的词汇来调整概率,并且将这些概率归一化,使它们的和为1。这给了模型一个额外的机会来生成连贯的样本。
实践操作:解码策略
现在,我们已经对一些广泛使用的解码策略有了相当清晰的理解,接下来是看看它们的实际应用。
第一步是准备一个实用函数 generate_text
,根据给定的解码策略生成下一个词,如以下代码片段所示:
ini
def generate_text(
n_chars,
model,
dataset,
prompt_text="Hello",
mode="sampling",
topk=2,
temperature=1.0,
random_state=42,
):
# 代码为简洁起见已被省略
...
# 获取模型输入
input_chars = (
resulting_string
if resulting_string == prompt_text
else resulting_string[-1]
)
input_ints = LongTensor([[dataset.char2int[c] for c in input_chars]])
...
代码首先将原始输入文本转换为整数索引。然后我们使用模型进行预测,并根据选择的模式(贪婪或采样)进行处理。我们已经从前面的练习中训练了一个字符级语言模型,并且有一个工具帮助我们根据选择的解码策略生成下一个词。我们在以下代码片段中使用这两者,来理解使用不同策略生成的不同输出:
ini
# 获取模型生成下一个字符
logits, h, c = model(input_ints, h, c)
# 根据所选模式解码
if mode == "greedy":
next_char = dataset.vocab[torch.argmax(logits[0], dim=-1)]
elif mode == "sampling":
# 转换为概率
probs = F.softmax(logits[0], dim=0).detach().cpu().numpy()
# 获取下一个字符
next_char = np.random.choice(dataset.vocab, p=probs)
# 代码为简洁起见已被省略
使用相同的种子和不同的解码策略生成的结果如下所示:
ini
prompt_text = "What on earth"
-------------------------
Generation mode = greedy
What on earther and the door and the door and the door and the door and the door and the door and the door and th
-------------------------
Generation mode = sampling
What on earthed they hard because she had pulling like parents and have been wished her to be
keep in five it pe
-------------------------
Generation mode = topk_sampling
What on earther, as if she would be seen that they were the door of the door of the way that he was the could be
-------------------------
Generation mode = beam_search
What on earthing that he was street to his father was stayed the door to the door to hear the door there was stre
这个输出展示了我们所讨论的所有解码策略的一些问题和显著特征。我们可以观察到,模型学会了使用大部分有效的单词,并且在单词之间使用空格作为分隔符,甚至使用标点符号。模型似乎还学会了如何使用大写字母。温度参数带来的额外表现力是以模型稳定性为代价的。因此,通常在表现力和稳定性之间会存在权衡。
这就结束了我们生成文本的第一种方法;我们利用RNN(特别是LSTM)生成文本,并使用不同的解码策略。接下来,我们将研究一些LSTM模型的变体,以及卷积操作。
LSTM变体和卷积在文本中的应用
RNN在处理序列数据集时非常有用。我们在前一节中看到,一个简单的模型如何基于从训练数据集中学到的内容有效地学习生成文本。
多年来,我们在建模和使用RNN方面做了许多改进。在这一节中,我们将从双向LSTM开始讨论。
双向LSTM
我们已经讨论了LSTM以及一般的RNN是如何通过利用前一步的时间步来条件化它们的输出的。当涉及到文本或任何序列数据时,这意味着LSTM能够利用过去的上下文来预测未来的时间步。虽然这是一个非常有用的特性,但这并不是我们可以达到的最好效果。
让我们通过一个例子来说明为什么这是一个限制(见图3.6):

从这个例子中可以明显看出,如果模型不看目标词" Teddy"右边的内容,它就无法正确地捕捉上下文。为了解决这种情况,引入了双向LSTM。它们背后的思想非常简单直接。双向LSTM(或biLSTM)是两个LSTM层的组合,它们同时工作。第一个是常规的前向LSTM,它按原始顺序处理输入序列。第二个是反向LSTM,它接受输入序列的反向副本。图3.7展示了一个典型的biLSTM配置:

如图3.7所示,前向和反向LSTM同时工作,处理原始和反向的输入序列副本。由于在任何给定的时间步,我们有两个LSTM单元在不同的上下文下工作,因此我们需要一种方法来定义将被下游层使用的输出。这些输出可以通过求和、乘法、连接或甚至平均隐状态来结合。不同的深度学习框架可能会设置不同的默认值,但最常用的方法是将biLSTM的输出连接起来。请注意,与biLSTM类似,我们也可以使用bi-RNN甚至bi-GRU。
与普通LSTM相比,biLSTM的配置有优势,因为前者可以同时查看未来的上下文。然而,当无法"窥视"未来时,这一优势也变成了限制。对于当前的文本生成使用案例,biLSTM被用于编码器-解码器架构中。我们利用biLSTM来学习更好的输入嵌入,但解码阶段(即使用这些嵌入来预测下一个词)只使用普通的LSTM。类似于前面的实践操作,我们可以使用相同的工具集来训练这个网络。我们将这个作为练习留给你;现在我们将继续讨论卷积。
卷积与文本
RNN在处理诸如文本生成等序列到序列任务时非常强大且富有表现力。然而,它们也面临一些挑战:
- 梯度消失问题:当上下文窗口非常宽时,RNN会遭遇梯度消失问题。尽管LSTM和GRU在一定程度上克服了这一问题,但与我们在日常使用中看到的典型非局部词语交互相比,RNN的上下文窗口仍然相对较小。
- 序列化特性:RNN的递归特性使得它们在训练和推理时速度较慢。
- 上下文编码问题:我们在上一节中讨论的架构尝试将整个输入上下文(或种子文本)编码为一个单一的向量,然后由解码器生成下一组词。当种子/上下文很长时,这会造成限制,因为RNN会更多地关注上下文中最后一组输入。
- 内存占用:与其他类型的神经网络架构相比,RNN的内存占用更大;也就是说,它们在实现过程中需要更多的参数,从而需要更多的内存。
另一方面,卷积神经网络(CNN)在计算机视觉领域得到了充分的验证。最先进的架构利用CNN来提取特征,并在不同的视觉任务中表现出色。CNN的成功促使研究人员探索它们在NLP任务中的应用。
使用CNN处理文本的主要思路是首先尝试创建一组单词的向量表示,而不是单独的单词。更正式地说,目标是生成每个子序列(在给定句子中的词序列)的向量表示。
考虑一个示例句子:"Flu outbreak forces schools to close"。目标是首先将这个句子拆分为所有可能的子序列,例如"Flu outbreak forces","outbreak forces schools",...,"schools to close",然后为每个子序列生成一个向量表示。虽然这些子序列可能有或没有太多的含义,但它们为我们提供了一种理解单词在不同上下文中使用的方式。既然我们已经理解了如何准备单词的稠密向量表示(见"稠密表示"部分),让我们在此基础上继续理解如何利用CNN。
继续前面的示例,图3.8(A)展示了每个单词的向量形式。为了便于理解,向量的维度仅设为四维:

这两个卷积核,每个大小为3,如图3.8(B)所示。在文本/NLP应用的情况下,卷积核的宽度通常与词向量的维度相同。大小为3表示每个卷积核所关注的上下文窗口的大小。由于卷积核宽度与词向量宽度相同,我们将卷积核沿着句子中的单词移动。由于在一个方向上只能移动,因此这些卷积滤波器被称为1维卷积。图3.8(C)展示了输出的短语向量。
与计算机视觉领域的深度卷积神经网络类似,上述配置使我们能够为NLP应用堆叠1维卷积层。更大的深度使模型不仅能够捕获更复杂的表示,还能够捕获更广泛的上下文窗口(这类似于视觉模型中随着深度增加而扩大感受野的效果)。
使用CNN处理NLP应用也能提高计算速度,同时减少训练这些网络所需的内存和时间。实际上,以下工作探讨了使用1维CNN进行NLP任务的优势:
- Natural Language Processing (almost) from Scratch, Collobert等人
- Character-level Convolutional Networks for Text Classification, Zhang等人
- Convolutional Neural Networks for Sentence Classification, Kim
- Recurrent Convolutional Neural Networks for Text Classification, Lai和Xu等人
到目前为止,我们已经讨论了CNN如何用于提取特征并捕获更大范围的上下文用于NLP应用。语言相关任务,尤其是文本生成,具有一定的时间特性。因此,下一个显而易见的问题是,我们能否像RNN那样利用CNN来理解时间特征?
研究人员已经探索了CNN在时间或序列处理中的应用一段时间。虽然我们讨论了CNN如何很好地捕获给定单词的上下文,但对于某些应用,这也提出了一个问题。例如,像语言建模/文本生成这样的任务要求模型仅从一个方向理解上下文。简单来说,语言模型通过查看已经处理过的单词(过去的上下文)来生成未来的单词。但CNN也可以跨越未来的时间步。
稍微离开NLP领域,Van den Oord等人关于PixelCNN和WaveNet的研究对于理解CNN在时间性场景中的应用非常重要。他们提出了因果卷积的概念,以确保CNN仅利用过去的上下文,而不是未来的上下文。
因果卷积确保模型在任何给定的时间步t,只进行类型为 p(xt+1∣x1:t)p(x_{t+1} | x_{1:t})p(xt+1∣x1:t) 的预测,而不依赖于未来的时间步 xt+1,xt+2,...,xt+Tx_{t+1}, x_{t+2}, \dots, x_{t+T}xt+1,xt+2,...,xt+T。在训练过程中,可以并行地进行所有时间步的条件预测。然而,在生成/推理步骤中,输出在每个时间步会被反馈到模型中,用于下一个时间步的预测。
由于这个配置没有任何递归连接,模型训练速度较快,即使在较长的序列上也能如此。因果卷积的设置最初是为了图像和音频生成应用而设计的,但已经扩展到NLP应用中。WaveNet论文的作者还使用了一种叫做膨胀卷积的概念,以在不需要非常深的架构的情况下为模型提供更大的感受野。利用CNN捕获和使用时间成分的这一思路为进一步探索打开了大门。我们将在后续章节中讨论下一组架构,并了解这些基础架构和概念如何帮助我们进入现代NLP和文本生成的时代。
总结
恭喜你完成了一个包含大量概念的复杂章节。在本章中,我们涵盖了与处理文本数据进行文本生成任务相关的各种概念。我们从理解不同的文本表示模型开始,介绍了从词袋模型到Word2Vec,甚至FastText等广泛使用的表示模型。
本章的下一部分集中在基于RNN的文本生成模型的理解上。我们简要讨论了语言模型的构成及如何为此类任务准备数据集。然后,我们训练了一个基于字符的语言模型,用于生成合成文本样本。我们讨论了不同的解码策略,并使用它们来理解基于RNN的语言模型产生的不同输出。我们还简要介绍了基于双向LSTM的语言模型。最后,我们讨论了卷积网络在NLP领域中的应用。
在下一章中,我们将重点介绍NLP领域一些最新且最强大的架构的构建模块,包括注意力机制和变换器(Transformers)。
参考文献
- Mikolov, T., K. Chen, G. Corrado, and J. Dean. 2013. "Efficient Estimation of Word Representations in Vector Space." arXiv. arxiv.org/abs/1301.37....
- Pennington, J., R. Socher, and C. D. Manning. 2014. "GloVe: Global Vectors for Word Representation." Proceedings of the 2014 Conference on Empirical Methods in Natural Language Processing (EMNLP). nlp.stanford.edu/pubs/glove.....
- Bojanowski, P., E. Grave, A. Joulin, and T. Mikolov. 2017. "Enriching Word Vectors with Subword Information." arXiv. arxiv.org/abs/1607.04....
- "A Simple But Tough to Beat Baseline for Sentence Embeddings." 2017. OpenReview. openreview.net/pdf?id=SyK0....
- "Concatenated Power Mean Word Embeddings as Universal Cross-Lingual Sentence Representations." GitHub. github.com/UKPLab/arxi....
- "Skip Thought Vectors." n.d. arXiv. arxiv.org/abs/1506.06....
- "Deep Contextualized Word Embeddings." AllenNLP. allenai.org/allennlp/so....
- "General Purpose Sentence Representations." 2018. arXiv. arxiv.org/abs/1804.00....
- "Universal Sentence Encoder." 2018 arXiv. arxiv.org/abs/1803.11....
- Holtzman, A., J. Buys, L. Du, M. Forbes, and Y. Choi. 2019. "The Curious Case of Neural Text Degeneration." arXiv. arxiv.org/abs/1904.09....
- Collobert, R., J. Weston, M. Karlen, K. Kavukcuoglu, and P. Kuksa. 2011. "Natural Language Processing (Almost) from Scratch." arXiv. arxiv.org/abs/1103.03....
- Zhang, X., J. Zhao, and Y. LeCun. 2015. "Character-Level Convolutional Networks for Text Classification." arXiv. arxiv.org/abs/1509.01....
- Kim, Y. 2014. "Convolutional Neural Networks for Sentence Classification." arXiv. arxiv.org/abs/1408.58....
- Lai, S., L. Xu, K. Liu, and J. Zhao. 2015. "Recurrent Convolutional Neural Networks for Text Classification." Proceedings of the Twenty-Ninth AAAI Conference on Artificial Intelligence. zhengyima.com/my/pdfs/Tex....
- van den Oord, A., N. Kalchbrenner, O. Vinyals, L. Espeholt, A. Graves, and K. Kavukcuoglu. 2016. "Conditional Image Generation with PixelCNN Decoders." arXiv. arxiv.org/abs/1606.05....
- van den Oord, A., S. Dieleman, K. Simonyan, O. Vinyals, A. Graves, N. Kalchbrenner, A. Senior, and K. Kavukcuoglu. 2016. "WaveNet: A Generative Model for Raw Audio." arXiv. arxiv.org/abs/1609.03....