真实世界的自然语言处理(三)

原文:zh.annas-archive.org/md5/0bc67f8f61131022ce5bcb512033ea38

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:使用预训练语言模型进行迁移学习

本章内容包括

  • 利用无标签文本数据的知识进行迁移学习

  • 使用自监督学习对大型语言模型进行预训练,如 BERT

  • 使用 BERT 和 Hugging Face Transformers 库构建情感分析器

  • 使用 BERT 和 AllenNLP 构建自然语言推断模型

2018 年通常被称为自然语言处理历史上的“拐点”。一位著名的 NLP 研究者,Sebastian Ruder(ruder.io/nlp-imagenet/)将这一变化称为“NLP 的 ImageNet 时刻”,他使用了一个流行的计算机视觉数据集的名称以及在其上进行预训练的强大模型,指出 NLP 社区正在进行类似的变革。强大的预训练语言模型,如 ELMo、BERT 和 GPT-2,在许多 NLP 任务上实现了最先进的性能,并在几个月内彻底改变了我们构建 NLP 模型的方式。

这些强大的预训练语言模型背后的一个重要概念是迁移学习,一种利用在另一个任务上训练的模型来改善一个任务性能的技术。在本章中,我们首先介绍这个概念,然后介绍 BERT,这是为 NLP 提出的最流行的预训练语言模型。我们将介绍 BERT 的设计和预训练,以及如何将该模型用于下游 NLP 任务,包括情感分析和自然语言推断。我们还将涉及其他流行的预训练模型,包括 ELMo 和 RoBERTa。

9.1 迁移学习

我们从介绍迁移学习开始这一章,这是本章中许多预训练语言模型(PLM)的基本机器学习概念。

9.1.1 传统机器学习

在传统机器学习中,在预训练语言模型出现之前,NLP 模型是根据任务进行训练的,它们仅对它们所训练的任务类型有用(图 9.1)。例如,如果你想要一个情感分析模型,你需要使用带有所需输出的数据集(例如,负面、中性和正面标签),而训练好的模型仅对情感分析有用。如果你需要构建另一个用于词性标注(一种 NLP 任务,用于识别单词的词性;请参阅第 5.2 节进行回顾)的模型,你需要通过收集训练数据并从头开始训练一个词性标注模型来完成。无论你的模型有多好,你都不能将情感分析模型“重用”于词性标注,因为这两者是为两个根本不同的任务而训练的。然而,这些任务都在同一语言上操作,这一切似乎都是浪费的。例如,知道 “wonderful”,“awesome” 和 “great” 都是具有积极意义的形容词,这将有助于情感分析和词性标注。在传统机器学习范式下,我们不仅需要准备足够大的训练数据来向模型教授这种“常识”,而且个别 NLP 模型还需要从给定的数据中学习关于语言的这些事实。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.1 在传统机器学习中,每个训练好的模型仅用于一个任务。

9.1.2 词嵌入

在这一点上,你可能会意识到这听起来有些眼熟。回想一下我们在第 3.1 节关于词嵌入以及它们为什么重要的讨论。简而言之,词嵌入是单词的向量表示,这些向量是通过学习得到的,以便语义上相似的单词具有相似的表示。因此,例如,“dog” 和 “cat” 的向量最终会位于高维空间中的接近位置。这些表示是在一个独立的大型文本语料库上进行训练的,没有任何训练信号,使用诸如 Skip-gram 和 CBOW 等算法,通常统称为 Word2vec(第 3.4 节)。

在这些词嵌入训练之后,下游 NLP 任务可以将它们作为模型的输入(通常是神经网络,但不一定)。因为这些嵌入已经捕捉到单词之间的语义关系(例如,dogs 和 cats 都是动物),所以这些任务不再需要从头学习语言是如何工作的,这使它们在试图解决的任务中占据了上风。模型现在可以专注于学习无法被词嵌入捕捉到的更高级别概念(例如,短语、句法和语义)以及从给定的注释数据中学到的任务特定模式。这就是为什么使用词嵌入会给许多 NLP 模型带来性能提升的原因。

在第三章中,我们将这比作是教一个婴儿(= 一个自然语言处理模型)如何跳舞。通过让婴儿先学会稳步行走(= 训练词嵌入),舞蹈老师(= 任务特定数据集和训练目标)可以专注于教授具体的舞步,而不必担心婴儿是否能够站立和行走。这种“分阶段训练”方法使得如果你想教婴儿另一种技能(例如,教授武术),一切都变得更容易,因为他们已经对基本技能(行走)有了很好的掌握。

所有这一切的美妙之处在于,词嵌入可以独立于下游任务进行学习。这些词嵌入是预训练的,这意味着它们的训练发生在下游自然语言处理任务的训练之前。使用跳舞婴儿的类比,舞蹈老师可以安全地假设所有即将到来的舞蹈学生都已经学会了如何正确站立和行走。由算法开发者创建的预训练词嵌入通常是免费提供的,任何人都可以下载并将其集成到他们的自然语言处理应用程序中。这个过程在图 9.2 中有所说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.2 利用词嵌入有助于构建更好的自然语言处理模型。

9.1.3 什么是迁移学习?

如果你总结一下之前对词嵌入所做的事情,你会发现你将一个任务的结果(即,用嵌入预测词共现)并将从中获得的知识转移到另一个任务(即,情感分析,或任何其他自然语言处理任务)。在机器学习中,这个过程被称为迁移学习,这是一系列相关的技术,用于通过在不同任务上训练的数据和/或模型来提高机器学习模型在某一任务中的性能。迁移学习总是由两个或多个步骤组成—首先为一个任务训练一个机器学习模型(称为预训练),然后调整并在另一个任务中使用它(称为适应)。如果同一个模型用于两个任务,第二步称为微调,因为你稍微调整了同一个模型,但是用于不同的任务。请参见图 9.3,以了解自然语言处理中迁移学习的示意图。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.3 利用迁移学习有助于构建更好的自然语言处理模型。

过去几年中,迁移学习已成为构建高质量自然语言处理模型的主要方法,原因有两个。首先,由于强大的神经网络模型如 Transformer 和自监督学习(见第 9.2.2 节),几乎可以从几乎无限量的自然语言文本中引导出高质量的嵌入。这些嵌入在很大程度上考虑了自然语言文本的结构、上下文和语义。其次,由于迁移学习,任何人都可以将这些强大的预训练语言模型整合到他们的自然语言处理应用程序中,即使没有访问大量的文本资源,如网络规模语料库,或计算资源,如强大的 GPU。这些新技术的出现(Transformer、自监督学习、预训练语言模型和迁移学习)将自然语言处理领域推向了一个全新的阶段,并将许多自然语言处理任务的性能推向了接近人类水平。在接下来的子节中,我们将看到迁移学习在实际构建自然语言处理模型时的应用,同时利用诸如 BERT 等预训练语言模型。

请注意,所谓的领域自适应概念与迁移学习密切相关。领域自适应是一种技术,你在一个领域(例如,新闻)训练一个机器学习模型,然后将其调整到另一个领域(例如,社交媒体),但这些领域属于相同任务(例如,文本分类)。另一方面,在本章中涵盖的迁移学习应用于不同任务(例如,语言建模与文本分类)。你可以利用本章介绍的迁移学习范式来实现相同的效果,我们不会专门涵盖领域自适应作为一个单独的主题。有兴趣的读者可以从最近的一篇评论性文章中了解更多关于领域自适应的信息。¹

9.2 BERT

在本节中,我们将详细介绍 BERT。BERT(双向编码器表示转换器)²是迄今为止最流行和最具影响力的预训练语言模型,彻底改变了人们训练和构建自然语言处理模型的方式。我们将首先介绍上下文化嵌入及其重要性,然后讨论自监督学习,这是预训练语言模型中的一个重要概念。我们将涵盖 BERT 用于预训练的两个自监督任务,即,掩码语言模型和下一个句子预测,并介绍如何将 BERT 调整到你的应用程序中。

9.2.1 词嵌入的局限性

单词嵌入是一个强大的概念,可以提高应用程序的性能,尽管它们也有限制。一个明显的问题是它们无法考虑上下文。在自然语言中看到的单词通常是多义的,意味着它们可能根据上下文有多个含义。然而,由于单词嵌入是按标记类型训练的,所有不同的含义都被压缩成一个单一的向量。例如,为“dog”或“apple”训练一个单一的向量无法处理“热狗”或“大苹果”分别不是动物或水果这一事实。再举一个例子,考虑这些句子中“play”的含义:“They played games,” “I play Chopin,” “We play baseball,” 和 “Hamlet is a play by Shakespeare”(这些句子都来自 Tatoeba.org)。这些“play”的出现有不同的含义,分配一个单一的向量在下游的 NLP 任务中并不会有太大帮助(例如在将主题分类为体育、音乐和艺术方面)。

由于这个限制,自然语言处理(NLP)研究人员开始探索将整个句子转换为一系列考虑上下文的向量的方法,称为上下文化嵌入或简称为上下文化。有了这些表示,前面示例中“play”的所有出现将被分配不同的向量,帮助下游任务区分单词的不同用法。上下文化嵌入的重要里程碑包括 CoVe³ 和 ELMo(第 9.3.1 节),尽管最大的突破是由 BERT 实现的,这是一个基于 Transformer 的预训练语言模型,是本节的重点。

我们学习到 Transformer 使用一种称为自注意力的机制逐渐转换输入序列来总结它。BERT 的核心思想很简单:它使用 Transformer(准确地说是 Transformer 编码器)将输入转换为上下文化嵌入。Transformer 通过一系列层逐渐摘要输入。同样,BERT 通过一系列 Transformer 编码器层对输入进行上下文化处理。这在图 9.4 中有所说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.4 BERT 通过注意力层处理输入以生成上下文化嵌入。

因为 BERT 基于 Transformer 架构,它继承了 Transformer 的所有优点。其自注意力机制使其能够在输入上进行“随机访问”,并捕获输入标记之间的长期依赖关系。与传统的语言模型(例如我们在第 5.5 节中介绍的基于 LSTM 的语言模型)不同,后者只能沿着一个方向进行预测,Transformer 可以在两个方向上考虑上下文。以“哈姆雷特是莎士比亚的一部戏剧”为例,对于“戏剧”这个词的上下文化嵌入可以包含来自“哈姆雷特”和“莎士比亚”的信息,这样就更容易捕捉到“戏剧舞台作品”的意思。

如果这个概念就像“BERT 只是一个 Transformer 编码器”那么简单,为什么它在这里值得有一个完整的章节呢?因为我们还没有回答两个重要的实际问题:如何训练和调整模型。神经网络模型,无论多么强大,如果没有特定的训练策略和获取训练数据的途径,都是无用的。此外,预训练模型没有特定的调整策略也是无用的。我们将在以下小节中讨论这些问题。

9.2.2 自监督学习

Transformer 最初是为了机器翻译而提出的,它是使用平行文本进行训练的。它的编码器和解码器被优化以最小化损失函数,即解码器输出和预期正确翻译之间的差异所定义的交叉熵。然而,预训练 BERT 的目的是得到高质量的上下文嵌入,而 BERT 只有一个编码器。我们如何“训练”BERT 以使其对下游自然语言处理任务有用呢?

如果你把 BERT 只看作是另一种得到嵌入的方式,你可以从词嵌入是如何训练的中得到灵感。回想一下,在第 3.4 节中,为了训练词嵌入,我们构造了一个“假”任务,即用词嵌入预测周围的单词。我们对预测本身不感兴趣,而是对训练的“副产品”感兴趣,即作为模型参数的词嵌入。这种数据本身提供训练信号的训练范式称为自监督学习,或者简称为自监督,在现代机器学习中。从模型的角度来看,自监督学习仍然是监督学习的一种类型——模型被训练以使得它最小化由训练信号定义的损失函数。不同之处在于训练信号的来源。在监督学习中,训练信号通常来自人类注释。在自监督学习中,训练信号来自数据本身,没有人类干预。

在过去的几年中,随着数据集越来越大和模型越来越强大,自监督学习已经成为预训练 NLP 模型的流行方式。但是为什么它能够如此成功呢?其中两个因素起到了作用——一个是这里的自监督类型在创建时非常简单(只需提取周围单词用于 Word2vec),但是解决它需要对语言有深入的理解。例如,重新使用我们在第五章中讨论的语言模型的例子,要回答“我的海滩之行被糟糕的 ___ 毁了”,系统不仅需要理解句子,还需要具备某种“常识”,了解什么样的事情可能会毁了一次海滩之行(例如,糟糕的天气,交通拥堵)。预测周围单词所需的知识范围从简单的搭配/联想(例如,“纽约的 ____ 雕像”),到句法和语法(例如,“我的生日是 ___ 五月”),再到语义(前面的例子)。第二个因素是几乎没有限制用于自监督的数据量,因为你所需要的只是干净的纯文本。你可以下载大型数据集(例如,维基百科转储)或爬取和过滤网页,这是训练许多预训练语言模型的流行方式之一。

9.2.3 BERT 预训练

现在我们都明白了自监督学习对于预训练语言模型有多么有用,让我们看看我们如何将其用于预训练 BERT。如前所述,BERT 只是一个将输入转换为考虑上下文的一系列嵌入的 Transformer 编码器。对于预训练词嵌入,你可以根据目标词的嵌入预测周围的单词。对于预训练单向语言模型,你可以根据目标之前的标记预测下一个标记。但是对于诸如 BERT 这样的双向语言模型,你不能使用这些策略,因为用于预测的输入(上下文化的嵌入)还取决于输入之前和之后的内容。这听起来像是一个先有鸡还是先有蛋的问题。

BERT 的发明者们通过一个称为掩码语言模型(MLM)的精妙思想来解决这个问题,在给定的句子中随机删除(掩盖)单词,并让模型预测被删除的单词是什么。具体来说,在句子中用一个特殊的占位符替换一小部分单词后,BERT 使用 Transformer 对输入进行编码,然后使用前馈层和 softmax 层推导出可能填充该空白的单词的概率分布。因为你已经知道答案(因为你首先删除了这些单词),所以你可以使用常规的交叉熵来训练模型,如图 9.5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.5 使用掩码语言模型对 BERT 进行预训练

掩码和预测单词并不是一个完全新的想法——它与填空测试密切相关,测试者被要求在句子中替换被移除的单词。这种测试形式经常用于评估学生对语言的理解程度。正如我们之前所见,填写自然语言文本中的缺失单词需要对语言的深入理解,从简单的关联到语义关系。因此,通过告诉模型解决这种填空类型的任务,涵盖了大量文本数据,神经网络模型经过训练,使其能够产生融合了深层语言知识的上下文嵌入。

如果你想自己实现 BERT 的预训练,你可能会想知道这个输入[MASK]是什么,以及你实际上需要做什么。在训练神经网络时,人们经常使用特殊的标记,比如我们在这里提到的[MASK]。这些特殊的标记就像其他(自然出现的)标记一样,比如“狗”和“猫”的单词,只是它们在文本中不会自然出现(无论你多么努力,都找不到任何[MASK]在自然语言语料库中),神经网络的设计者定义了它们的含义。模型将学会为这些标记提供表示,以便它可以解决手头的任务。其他特殊标记包括 BOS(句子的开始)、EOS(句子的结束)和 UNK(未知单词),我们在之前的章节中已经遇到过。

最后,BERT 不仅使用掩码语言模型进行预训练,还使用了另一种类型的任务,称为下一句预测(NSP),其中向 BERT 提供了两个句子,并要求模型预测第二个句子是否是第一个句子的“真正”下一个句子。这是另一种类型的自监督学习(“伪造”任务),其训练数据可以在很少的人工干预下无限制地创建,因为你可以从任何语料库中提取两个连续的句子(或仅随机拼接两个句子)并为此任务创建训练数据。这个任务背后的原理是通过训练这个目标,模型将学会如何推断两个句子之间的关系。然而,这个任务的有效性一直在积极地讨论中(例如,RoBERTa 放弃了这个任务,而 ALBERT 将其替换为另一个称为句子顺序预测的任务),我们将不在这里详细讨论这个任务。

所有这些预训练听起来有些复杂,但好消息是你很少需要自己实现这一步。类似于词嵌入,这些语言模型的开发人员和研究人员在大量自然语言文本上预训练他们的模型(通常是 10 GB 或更多,甚至是 100 GB 或更多的未压缩文本),并使用许多 GPU,并且将预训练模型公开可用,以便任何人都可以使用它们。

9.2.4 调整 BERT

在迁移学习的第二(也是最后)阶段,预训练模型被调整以适应目标任务,使后者可以利用前者学到的信号。有两种主要方式可以使 BERT 适应个别下游任务:微调特征提取。在微调中,神经网络架构稍微修改,以便为所讨论的任务产生类型的预测,并且整个网络在任务的训练数据上持续训练,以使损失函数最小化。这正是你训练 NLP 任务的神经网络的方式,例如情感分析,其中有一个重要的区别—BERT“继承”了通过预训练学到的模型权重,而不是从头开始随机初始化并进行训练。通过这种方式,下游任务可以利用 BERT 通过大量数据预训练学到的强大表示。

BERT 架构修改的确切方式因最终任务而异,但在这里我将描述最简单的情况,即任务是对给定句子预测某种标签。这也被称为 句子预测任务,其中包括我们在第二章中介绍的情感分析。为了使下游任务能够提取句子的表示,BERT 在预训练阶段为每个句子添加一个特殊标记[CLS](用于 分类)。您可以使用此标记提取 BERT 的隐藏状态,并将其用作句子的表示。与其他分类任务一样,线性层可以将此表示压缩为一组“分数”,这些分数对应于每个标签是正确答案的可能性。然后,您可以使用 softmax 推导出一个概率分布。例如,如果您正在处理一个情感分析数据集,其中有五个标签(非常负面到非常正面),则您将使用线性层将维度降低到 5。这种线性层与 softmax 结合起来,插入到诸如 BERT 之类的较大的预训练模型中,通常被称为 头部。换句话说,我们正在将一个 分类头 附加到 BERT 上,以解决句子预测任务。整个网络的权重(头部和 BERT)都会被调整,以使损失函数最小化。这意味着通过反向传播微调 BERT 权重初始化的权重也会被调整。见图 9.6 以示例说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.6 使用附加的分类头对 BERT 进行预训练和微调

另一种微调 BERT 的变体使用了所有嵌入,这些嵌入是在输入令牌上进行平均的。在这种称为 mean over timebag of embeddings 的方法中,BERT 生成的所有嵌入被求和并除以输入的长度,就像词袋模型一样,以产生一个单一的向量。这种方法不如使用 CLS 特殊令牌那么受欢迎,但根据任务的不同可能效果更好。图 9.7 阐明了这一点。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.7 预训练和微调 BERT 使用时间平均和分类头

另一种用于下游 NLP 任务的 BERT 适应方式是 feature extraction。在这里,BERT 被用来提取特征,这些特征只是由 BERT 的最终层产生的一系列上下文化嵌入。你可以将这些向量简单地作为特征馈送到另一个机器学习模型中,并进行预测,如图 9.8 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.8 预训练和使用 BERT 进行特征提取

从图形上看,这种方法与微调类似。毕竟,你正在将 BERT 的输出馈送到另一个 ML 模型中。然而,存在两个微妙但重要的区别:首先,因为你不再优化神经网络,第二个 ML 模型不必是神经网络。一些机器学习任务(例如,无监督聚类)不是神经网络擅长解决的,特征提取在这些情况下提供了完美的解决方案。此外,你可以自由使用更“传统”的 ML 算法,如 SVM(支持向量机)、决策树和梯度提升方法(如 GBDT 或梯度提升决策树),这些算法可能在计算成本和性能方面提供更好的折衷方案。其次,因为 BERT 仅用作特征提取器,在适应阶段不会进行反向传播,其内部参数也不会更新。在许多情况下,如果微调 BERT 参数,你可以在下游任务中获得更高的准确性,因为这样做也会教导 BERT 更好地解决手头的任务。

最后,请注意这两种方式不是适应 BERT 的唯一方式。迁移学习是一个正在积极研究的主题,不仅在自然语言处理领域,而且在人工智能的许多领域都是如此,我们有许多其他方法来使用预训练的语言模型以发挥其最佳作用。如果你对此感兴趣,我建议查看在 NAACL 2019(顶级自然语言处理会议之一)上给出的教程,标题为“自然语言处理中的迁移学习”(mng.bz/o8qp)。

9.3 案例研究 1:使用 BERT 进行情感分析

在本节中,我们将再次构建情感分析器,但这次我们将使用 BERT,而不是 AllenNLP,我们将使用由 Hugging Face 开发的 Transformers 库,在上一章中使用该库进行语言模型预测。这里的所有代码都可以在 Google Colab 笔记本上访问(www.realworldnlpbook.com/ch9.html#sst)。你在本节中看到的代码片段都假定你按照以下方式导入了相关的模块、类和方法:

import torch
from torch import nn, optim
from transformers import AutoTokenizer, AutoModel, AdamW, get_cosine_schedule_with_warmup

在 Transformers 库中,你可以通过他们的名称指定预训练模型。在本节中,我们将一直使用大写的 BERT-base 模型 (‘bert-base-cased’),因此让我们首先定义一个常量,如下所示:

BERT_MODEL = 'bert-base-cased'

Transformers 库还支持其他预训练的 BERT 模型,你可以在他们的文档(huggingface.co/transformers/pretrained_models.html)中看到。如果你想使用其他模型,你可以简单地将这个变量替换为你想要使用的模型名称,代码的其余部分在许多情况下都可以原封不动地工作(但并非总是如此)。

9.3.1 将输入划分为单词

我们构建 NLP 模型的第一步是构建一个数据集读取器。虽然 AllenNLP(或更确切地说,allennlp-modules 包)附带了一个用于 Stanford 情感树库的数据集读取器,但是该数据集读取器的输出仅与 AllenNLP 兼容。在本节中,我们将编写一个简单的方法来读取数据集并返回一系列批量输入实例。

在处理自然语言输入时,分词是最重要的步骤之一。正如我们在上一章中看到的那样,Transformers 库中的分词器可以通过 AutoTokenizer.from_pretrained() 类方法进行初始化,如下所示:

tokenizer = AutoTokenizer.from_pretrained(BERT_MODEL)

因为不同的预训练模型使用不同的分词器,所以重要的是要通过提供相同的模型名称来初始化与你将要使用的预训练模型匹配的分词器。

你可以使用分词器在字符串和令牌 ID 的列表之间进行转换,如下所示:

>>> token_ids = tokenizer.encode('The best movie ever!')

[101, 1109, 1436, 2523, 1518, 106, 102]

>>> tokenizer.decode(token_ids)

'[CLS] The best movie ever! [SEP]'

注意 BERT 的分词器在你的句子中添加了两个特殊的标记——[CLS] 和 [SEP]。正如之前讨论的那样,CLS 是一个特殊的标记,用于提取整个输入的嵌入,而 SEP 用于分隔两个句子,如果你的任务涉及对一对句子进行预测。因为我们在这里对单个句子进行预测,所以不需要过多关注这个标记。我们将在第 9.5 节讨论句子对分类任务。

深度神经网络很少处理单个实例。它们通常通过训练并为实例的批次进行预测以保持稳定性和性能。分词器还支持通过调用 call 方法(即,只需将对象用作方法)将给定输入转换为批次,如下所示:

>>> result = tokenizer(
>>>    ['The best movie ever!', 'Aweful movie'],
>>>    max_length=10,
>>>    pad_to_max_length=True,
>>>    truncation=True,
>>>    return_tensors='pt')

运行此代码时,输入列表中的每个字符串都将被标记化,然后生成的张量将用 0 进行填充,以使它们具有相同的长度。这里的填充意味着在每个序列的末尾添加 0,以便单个实例具有相同的长度并可以捆绑为单个张量,这对于更有效的计算是必需的(我们将在第十章中更详细地讨论填充)。方法调用包含几个其他参数,用于控制最大长度(max_length=10,表示将所有内容填充到长度为 10),是否填充到最大长度,是否截断过长的序列以及返回张量的类型(return_tensors=‘pt’,表示它返回 PyTorch 张量)。此 tokenizer()调用的结果是一个包含以下三个键和三种不同类型的打包张量的字典:

>>> result['input_ids']

tensor([[ 101, 1109, 1436, 2523, 1518,  106,  102,    0,    0,    0],
        [ 101,  138, 7921, 2365, 2523,  102,    0,    0,    0,    0]])

>>> result['token_type_ids']

tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])

>>> result['attention_mask']

tensor([[1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
        [1, 1, 1, 1, 1, 1, 0, 0, 0, 0]])

input_ids 张量是从文本转换而来的标记 ID 的打包版本。请注意,每行都是一个矢量化的标记 ID,用 0 进行了填充,以便其长度始终为 10。token_type_ids 张量指定每个标记来自哪个句子。与之前的 SEP 特殊标记一样,只有在处理句对时才相关,因此张量只是简单地填满了 0。attention_mask 张量指定 Transformer 应该关注哪些标记。由于在输入 _ids 中存在填充元素(填充为 0),因此 attention_mask 中的相应元素都为 0,并且对这些标记的关注将被简单地忽略。掩码是神经网络中经常使用的一种常见技术,通常用于忽略类似于这里所示的批量张量中的不相关元素。第十章将更详细地介绍掩码。

正如您在这里看到的,Transformers 库的标记器不仅仅是标记化 - 它们为您创建了一个字符串列表,并为您创建了批量张量,包括辅助张量(token_type_ids 和 attention_mask)。您只需从数据集创建字符串列表,并将它们传递给 tokenizer()以创建传递给模型的批次。这种读取数据集的逻辑相当乏味且有点冗长,因此我将其打包在一个名为 read_dataset 的方法中,这里没有显示。如果您感兴趣,可以检查之前提到的 Google Colab 笔记本。使用此方法,您可以读取数据集并将其转换为批次列表,如下所示:

train_data = read_dataset('train.txt', batch_size=32, tokenizer=tokenizer, max_length=128)
dev_data = read_dataset('dev.txt', batch_size=32, tokenizer=tokenizer, max_length=128)

9.3.2 构建模型

在下一步中,我们将构建模型,将文本分类到它们的情感标签中。我们在这里构建的模型只是 BERT 的一个薄包装器。它所做的就是将输入通过 BERT 传递,取出其在 CLS 处的嵌入,将其传递到线性层以转换为一组分数(logits),并计算损失。

请注意,我们正在构建一个 PyTorch 模块,而不是 AllenNLP 模型,因此请确保从 nn.Module 继承,尽管这两种类型的模型的结构通常非常相似(因为 AllenNLP 的模型从 PyTorch 模块继承)。您需要实现 init(),在其中定义和初始化模型的子模块,以及 forward(),其中进行主要计算(“前向传递”)。下面显示了整个代码片段。

列表 9.1 使用 BERT 的情感分析模型

class BertClassifier(nn.Module):
    def __init__(self, model_name, num_labels):
        super(BertClassifier, self).__init__()
        self.bert_model = AutoModel.from_pretrained(model_name)                ❶

        self.linear = nn.Linear(self.bert_model.config.hidden_size, num_labels)❷

        self.loss_function = nn.CrossEntropyLoss()

    def forward(self, input_ids, attention_mask, token_type_ids, label=None):
        bert_out = self.bert_model(                                            ❸
          input_ids=input_ids,
          attention_mask=attention_mask,
          token_type_ids=token_type_ids)

        logits = self.linear(bert_out.pooler_output)                           ❹

        loss = None
        if label is not None:
            loss = self.loss_function(logits, label)return loss, logits

❶ 初始化 BERT

❷ 定义一个线性层

❸ 应用 BERT

❹ 应用线性层

❺ 计算损失

该模块首先在 init()中定义 BERT 模型(通过 AutoModel.from_pretrained()类方法)、一个线性层(nn.Linear)和损失函数(nn.CrossEntropyLoss)。请注意,模块无法知道它需要分类到的标签数量,因此我们将其作为参数传递(num_labels)。

在 forward()方法中,它首先调用 BERT 模型。您可以简单地将三种类型的张量(input_ids、attention_mask 和 token_type_ids)传递给模型。模型返回一个包含 last_hidden_state 和 pooler_output 等内容的数据结构,其中 last_hidden_state 是最后一层的隐藏状态序列,而 pooler_output 是一个汇总输出,基本上是经过线性层转换的 CLS 的嵌入。因为我们只关心代表整个输入的池化输出,所以我们将后者传递给线性层。最后,该方法计算损失(如果提供了标签)并返回它,以及 logits,用于进行预测和衡量准确性。

注意我们设计方法签名的方式——它接受我们之前检查的三个张量,使用它们的确切名称。这样我们可以简单地解构一个批次并将其传递给 forward 方法,如下所示:

>>> model(**train_data[0])

(tensor(1.8050, grad_fn=<NllLossBackward>),
 tensor([[-0.5088,  0.0806, -0.2924, -0.6536, -0.2627],
         [-0.3816,  0.3512, -0.1223, -0.5136, -0.4421],
         ...
         [-0.4220,  0.3026, -0.1723, -0.4913, -0.4106],
         [-0.3354,  0.3871, -0.0787, -0.4673, -0.4169]],
        grad_fn=<AddmmBackward>))

注意,forward 传递的返回值是损失和 logits 的元组。现在您已经准备好训练您的模型了!

9.3.3 训练模型

在这个案例研究的第三和最后一步中,我们将训练和验证模型。尽管在前几章中,AllenNLP 已经处理了训练过程,但在本节中,我们将从头开始编写自己的训练循环,以便更好地理解自己训练模型所需的工作量。请注意,您也可以选择使用库自己的 Trainer 类(huggingface.co/transformers/main_classes/trainer.html),该类的工作方式类似于 AllenNLP 的 Trainer,通过指定其参数来运行训练循环。

我们在第 2.5 节中介绍了训练循环的基础知识,但是为了回顾一下,现代机器学习中,每个训练循环看起来都有些相似。如果您以伪代码的形式编写它,它将类似于下面显示的内容。

列表 9.2 神经网络训练循环的伪代码

MAX_EPOCHS = 100
model = Model()

for epoch in range(MAX_EPOCHS):
    for batch in train_set:
        loss, prediction = model.forward(**batch)
        new_model = optimizer(model, loss)
        model = new_model

这个训练循环几乎与清单 2.2 相同,只是它操作的是批次而不是单个实例。数据集产生一系列批次,然后传递给模型的前向方法。该方法返回损失,然后用于优化模型。通常模型还会返回预测结果,以便调用者可以使用结果计算一些指标,如准确率。

在我们继续编写自己的训练循环之前,我们需要注意两件事——在每个 epoch 中交替进行训练和验证是习惯的。在训练阶段,模型被优化(“魔法常数”被改变)基于损失函数和优化器。在这个阶段使用训练数据。在验证阶段,模型的参数是固定的,并且它的预测准确率是根据验证数据进行测量的。虽然在验证期间不使用损失进行优化,但通常会计算它以监视损失在训练过程中的变化,就像我们在第 6.3 节中所做的那样。

另一个需要注意的是,当训练诸如 BERT 之类的 Transformer 模型时,我们通常使用 热身,即在前几千个步骤中逐渐增加学习率(改变“魔法常数”)。这里的步骤只是反向传播的另一个名称,对应于清单 9.2 中的内部循环。这对于稳定训练是有用的。我们不会在这里讨论热身和控制学习率的数学细节——我们只是指出通常会使用学习率调度器来控制整个训练过程中的学习率。使用 Transformers 库,你可以定义一个优化器(AdamW)和一个学习率控制器如下:

optimizer = AdamW(model.parameters(), lr=1e-5)
scheduler = get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps=100,
    num_training_steps=1000)

我们在这里使用的控制器(get_cosine_schedule_with_warmup)将学习率从零增加到最大值,在前 100 个步骤内,然后逐渐降低(基于余弦函数,这就是它得名的原因)。如果你绘制学习率随时间变化的图表,它会像图 9.9 中的图表一样。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.9 使用余弦学习率调度和热身,学习率首先上升,然后按照余弦函数下降。

现在我们准备训练我们基于 BERT 的情感分析器。接下来的清单展示了我们的训练循环。

清单 9.3 基于 BERT 的情感分析器的训练循环

for epoch in range(epochs):
    print(f'epoch = {epoch}')

    model.train()                                    ❶

    losses = []
    total_instances = 0
    correct_instances = 0
    for batch in train_data:
        batch_size = batch['input_ids'].size(0)
        move_to(batch, device)                       ❷

        optimizer.zero_grad()                        ❸

        loss, logits = model(**batch)                ❹
        loss.backward()                              ❺
        optimizer.step()
        scheduler.step()

        losses.append(loss)

        total_instances += batch_size
        correct_instances += torch.sum(torch.argmax(logits, dim=-1)    == batch['label']).item()                    ❻

    avr_loss = sum(losses) / len(losses)
    accuracy = correct_instances / total_instances
    print(f'train loss = {avr_loss}, accuracy = {accuracy}')

❶ 打开训练模式

❷ 将批次移到 GPU(如果可用)

❸ 记得重置梯度(在 PyTorch 中梯度会累积)。

❹ 前向传播

❺ 反向传播

❻ 通过计算正确实例的数量来计算准确率。

当你使用 PyTorch 训练模型时(因此,也是使用 AllenNLP 和 Transformers 两个构建在其上的库),请记得调用 model.train()以打开模型的“训练模式”。这很重要,因为一些层(如 BatchNorm 和 dropout)在训练和评估之间的行为不同(我们将在第十章中涵盖 dropout)。另一方面,在验证或测试模型时,请务必调用 model.eval()。

列表 9.3 中的代码没有显示验证阶段,但验证的代码几乎与训练的代码相同。在验证/测试模型时,请注意以下事项:

  • 如前所述,请确保在验证/测试模型之前调用 model.eval()。

  • 优化调用(loss.backward(),optimizer.step()和 scheduler.step())是不必要的,因为您没有更新模型。

  • 损失仍然被记录和报告以进行监视。确保将您的前向传递调用包装在 torch.no_grad()中——这将禁用梯度计算并节省内存。

  • 精度的计算方式完全相同(这是验证的重点!)。

当我运行这个时,我得到了以下输出到标准输出(省略了中间时期):

epoch = 0
train loss = 1.5403757095336914, accuracy = 0.31624531835205993
dev loss = 1.7507736682891846, accuracy = 0.2652134423251589
epoch = 1
...
epoch = 8
train loss = 0.4508829712867737, accuracy = 0.8470271535580525
dev loss = 1.687158465385437, accuracy = 0.48319709355131696
epoch = 9
...

开发精度在第 8 个时期达到了约 0.483 的峰值,此后没有改善。与我们从 LSTM(开发精度约为 0.35,在第二章中)和 CNN(开发精度约为 0.40,在第七章中)得到的结果相比,这是我们在此数据集上取得的最佳结果。我们做了很少的超参数调整,所以现在就得出 BERT 是我们比较的三个模型中最好的模型的结论还为时过早,但至少我们知道它是一个强大的基准模型!

9.4 其他预训练语言模型

BERT 既不是目前 NLP 社区中常用的预训练语言模型(PLMs)中的第一个,也不是最后一个。在本节中,我们将学习其他几个流行的 PLMs 以及它们与 BERT 的区别。这些模型中的大多数已经在 Transformers 库中实现并公开可用,因此您只需更改代码中的几行即可将它们与您的 NLP 应用集成。

9.4.1 ELMo

ELMo(来自语言模型的嵌入),于 2018 年初提出⁴,是最早用于从未标记文本中获取上下文嵌入的预训练语言模型之一。其核心思想很简单——训练一个基于 LSTM 的语言模型(类似于我们在第五章中训练的模型),并使用其隐藏状态作为下游自然语言处理任务的额外“特征”。因为语言模型被训练为在给定前文的情况下预测下一个标记,所以隐藏状态可以编码“理解语言”所需的信息。ELMo 还使用另一个反向 LM 执行相同的操作,并结合来自两个方向的嵌入,以便它还可以编码双向信息。请参见图 9.10 进行说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.10 ELMo 通过组合前向和后向 LSTM 计算上下文嵌入。

在两个方向上对 LM 进行预训练之后,下游 NLP 任务可以简单地使用 ELMo 嵌入作为特征。请注意,ELMo 使用多层 LSTM,因此特征是从不同层中取出的隐藏状态的总和,以任务特定的方式加权。ELMo 的发明者们表明,添加这些特征可以提高各种 NLP 任务的性能,包括情感分析、命名实体识别和问答。虽然 ELMo 没有在 Hugging Face 的 Transformers 库中实现,但你可以在 AllenNLP 中相当容易地使用它。

ELMo 是一个历史上重要的 PLM,尽管它今天在研究或生产中不再经常使用——它早于 BERT(和 Transformer 的出现),而且有其他 PLM(包括 BERT)在今天广泛可用并且性能优于 ELMo。

9.4.2 XLNet

2019 年提出的 XLNet 是 BERT 的重要后继者,通常被引用为当今最强大的 PLM 之一。XLNet 解决了 BERT 训练中的两个主要问题:训练-测试偏差和掩码的独立性。第一个问题与 BERT 如何使用掩码语言模型(MLM)目标进行预训练有关。在训练时,BERT 被训练以便能够准确预测掩码标记,而在预测时,它只看到输入句子,其中不包含任何掩码。这意味着 BERT 在训练和测试之间暴露给的信息存在差异,从而产生了训练-测试偏差问题。

第二个问题与 BERT 如何对掩码标记进行预测有关。如果输入中有多个[MASK]标记,BERT 会同时对它们进行预测。乍一看,这种方法似乎没有任何问题——例如,如果输入是“The Statue of [MASK] in New [MASK]”,模型不会有困难地回答“Liberty”和“York”。如果输入是“The Statue of [MASK] in Washington, [MASK]”,大多数人(也可能是语言模型)会预测“Lincoln”和“DC”。但是,如果输入是以下内容:

[MASK]雕像位于[MASK][MASK]中

然后没有信息偏向于你的预测。BERT 不会从这个例子的训练中学习到“华盛顿特区的自由女神像”或“纽约的林肯像”这样的事实,因为这些预测都是并行进行的。这是一个很好的例子,表明你不能简单地对标记进行独立的预测,然后将它们组合起来创建一个有意义的句子。

注意 这个问题与自然语言的多模态性有关,这意味着联合概率分布中存在多种模式,并且独立做出的最佳决策的组合并不一定导致全局最佳决策。多模态性是自然语言生成中的一个重大挑战。

为了解决这个问题,你可以将预测顺序改为顺序预测,而不是并行预测。事实上,这正是典型语言模型所做的——逐个从左到右生成标记。然而,在这里,我们有一个插入了屏蔽标记的句子,并且预测不仅依赖于左边的标记(例如,前面示例中的“雕像的”),还依赖于右边的标记(“in”)。XLNet 通过以随机顺序生成缺失的标记来解决这个问题,如图 9.11 所示。例如,您可以选择首先生成“New”,这为下一个单词“York”和“Liberty”提供了强有力的线索,依此类推。请注意,预测仍然基于先前生成的所有标记。如果模型选择首先生成“Washington”,那么模型将继续生成“DC”和“Lincoln”,而不会混淆这两个标记。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.11 XLNet 以任意顺序生成标记。

XLNet 已经在 Transformers 库中实现,您只需更改几行代码即可使用该模型。

9.4.3 RoBERTa

RoBERTa(来自“robustly optimized BERT”)是另一个在研究和工业中常用的重要 PLM。RoBERTa 重新审视并修改了 BERT 的许多训练决策,使其达到甚至超过后 BERT PLMs 的性能,包括我们之前介绍的 XLNet。截至本文撰写时(2020 年中期),我个人的印象是,RoBERTa 在 BERT 之后是被引用最多的第二个 PLM,并且在许多英文下游 NLP 任务中表现出稳健的性能。

RoBERTa 在 BERT 的基础上进行了几项改进,但最重要(也是最直接)的是其训练数据量。RoBERTa 的开发者收集了五个不同大小和领域的英文语料库,总计超过 160 GB 的文本(而训练 BERT 仅使用了 16 GB)。仅仅通过使用更多的数据进行训练,RoBERTa 在微调后的下游任务中超越了一些其他强大的 PLMs,包括 XLNet。第二个修改涉及我们在第 9.2.3 节中提到的下一句预测(NSP)目标,在该目标中,BERT 被预先训练以分类第二个句子是否是跟随语料库中第一个句子的“真实”句子。RoBERTa 的开发者发现,通过移除 NSP(仅使用 MLM 目标进行训练),下游任务的性能保持大致相同或略有提高。除此之外,他们还重新审视了批量大小以及 MLM 的屏蔽方式。综合起来,这个新的预训练语言模型在诸如问答和阅读理解等下游任务中取得了最先进的结果。

因为 RoBERTa 使用与 BERT 相同的架构,并且两者都在 Transformers 中实现,所以如果您的应用程序已经使用 BERT,那么切换到 RoBERTa 将非常容易。

注意与 BERT 与 RoBERTa 类似,跨语言语言模型 XLM(在第 8.4.4 节中介绍)有其“优化鲁棒性”的同类称为 XLM-R(缩写为 XML-RoBERTa)。⁸ XLM-R 对 100 种语言进行了预训练,并在许多跨语言 NLP 任务中表现出竞争力。

9.4.4 DistilBERT

尽管诸如 BERT 和 RoBERTa 等预训练模型功能强大,但它们在计算上是昂贵的,不仅用于预训练,而且用于调整和进行预测。例如,BERT-base(常规大小的 BERT)和 BERT-large(较大的对应物)分别具有 1.1 亿和 3.4 亿个参数,几乎每个输入都必须通过这个巨大的网络进行预测。如果您要对基于 BERT 的模型(例如我们在第 9.3 节中构建的模型)进行微调和预测,那么您几乎肯定需要一个 GPU,这并不总是可用的,这取决于您的计算环境。例如,如果您想在手机上运行一些实时文本分析,BERT 将不是一个很好的选择(它甚至可能无法适应内存)。

为了降低现代大型神经网络的计算需求,通常使用知识蒸馏(或简称蒸馏)。这是一种机器学习技术,其中给定一个大型预训练模型(称为教师模型),会训练一个较小的模型(称为学生模型)来模仿较大模型的行为。有关更多详细信息,请参见图 9.12。学生模型使用掩码语言模型(MLM)损失(与 BERT 相同),以及教师和学生之间的交叉熵损失。这将推动学生模型产生与教师尽可能相似的预测标记的概率分布。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.12 知识蒸馏结合了交叉熵和掩码 LM 目标。

Hugging Face 的研究人员开发了 BERT 的精简版本称为DistilBERT,⁹它的大小缩小了 40%,速度提高了 60%,同时与 BERT 相比重新训练了 97%的任务性能。您只需将传递给 AutoModel.from_pretrained()的模型名称(例如,bert-base-cased)替换为精炼版本(例如,distilbert-base-cased),同时保持其余代码不变即可使用 DistilBERT。

9.4.5 ALBERT

另一个解决了 BERT 计算复杂性问题的预训练语言模型是 ALBERT,¹⁰简称“轻量 BERT”。与采用知识蒸馏不同,ALBERT 对其模型和训练过程进行了一些修改。

ALBERT 对其模型进行的一个设计变化是它如何处理词嵌入。在大多数深度 NLP 模型中,词嵌入由一个大的查找表表示和存储,该表包含每个词的一个词嵌入向量。这种管理嵌入的方式通常适用于较小的模型,如 RNN 和 CNN。然而,对于基于 Transformer 的模型,如 BERT,输入的维度(即长度)需要与隐藏状态的维度匹配,通常为 768 维。这意味着模型需要维护一个大小为 V 乘以 768 的大查找表,其中 V 是唯一词汇项的数量。因为在许多 NLP 模型中 V 也很大(例如,30,000),所以产生的查找表变得巨大,并且占用了大量的内存和计算。

ALBERT 通过将词嵌入查找分解为两个阶段来解决这个问题,如图 9.13 所示。第一阶段类似于从映射表中检索词嵌入,只是词嵌入向量的输出维度较小(比如,128 维)。在下一个阶段,使用线性层扩展这些较短的向量,使它们与模型的所需输入维度相匹配(比如,768)。这类似于我们如何使用 Skip-gram 模型扩展词嵌入(第 3.4 节)。由于这种分解,ALBERT 只需要存储两个较小的查找表(V × 128,加上 128 × 768),而不是一个大的查找表(V × 768)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.13 ALBERT(右)将词嵌入分解为两个较小的投影。

ALBERT 实施的另一个设计变化是 Transformer 层之间的参数共享。Transformer 模型使用一系列自注意力层来转换输入向量。这些层将输入转换的方式通常各不相同——第一层可能以一种方式转换输入(例如,捕获基本短语),而第二层可能以另一种方式进行转换(例如,捕获一些句法信息)。然而,这意味着模型需要针对每一层保留所有必要的参数(用于键、查询和值的投影),这是昂贵的,并且占用了大量内存。相反,ALBERT 的所有层都共享相同的参数集,这意味着模型重复应用相同的转换到输入上。这些参数被调整为这样一种方式,即尽管它们相同,一系列转换对于预测目标是有效的。

最后,ALBERT 使用一种称为 句子顺序预测(SOP)的训练目标进行预训练,而不是 BERT 采用的下一个句子预测(NSP)。正如前面提到的,RoBERTa 的开发人员和其他一些人发现 NSP 目标基本无用,并决定将其排除。ALBERT 用句子顺序预测 (SOP) 取代了 NSP,在这个任务中,模型被要求预测两个连续文本段落的顺序。例如:¹¹

  • (A) 她和她的男朋友决定去散步。 (B) 走了一英里后,发生了一些事情。

  • © 然而,周边区域的一位老师帮助了我站起来。(D) 起初,没有人愿意帮我站起来。

在第一个示例中,你可以知道 A 发生在 B 之前。在第二个示例中,顺序被颠倒,D 应该在 C 之前。这对人类来说很容易,但对机器来说是一个困难的任务——自然语言处理模型需要学会忽略表面主题信号(例如,“去散步”,“步行超过一英里”,“帮我站起来”),并专注于话语级连贯性。使用这种目标进行训练使得模型更加强大且可用于更深入的自然语言理解任务。

因此,ALBERT 能够通过更少的参数扩大其训练并超越 BERT-large。与 DistilBERT 一样,ALBERT 的模型架构与 BERT 几乎完全相同,您只需在调用 AutoModel.from_pretrained() 时提供模型名称即可使用它 (例如,albert-base-v1)。

9.5 案例研究 2:BERT 自然语言推理

在本章的最后一部分,我们将构建自然语言推理的 NLP 模型,这是一个预测句子之间逻辑关系的任务。我们将使用 AllenNLP 构建模型,同时演示如何将 BERT(或任何其他基于 Transformer 的预训练模型)集成到你的管道中。

9.5.1 什么是自然语言推理?

自然语言推理(简称 NLI)是确定一对句子之间逻辑关系的任务。具体而言,给定一个句子(称为前提)和另一个句子(称为假设),你需要确定假设是否从前提中逻辑推演出来。在以下示例中,这更容易理解。¹²

前提假设标签
一名男子查看一个身穿东亚某国制服的人物。男子正在睡觉。矛盾
一名年长和一名年轻男子微笑着。两名男子对在地板上玩耍的猫笑着。中性
进行多人踢足球的比赛。一些男人正在运动。蕴涵

在第一个例子中,假设(“这个人正在睡觉”)显然与前提(“一个人正在检查…”)相矛盾,因为一个人不能在睡觉时检查某事。在第二个例子中,你无法确定假设是否与前提矛盾或被前提蕴含(特别是“笑猫”的部分),这使得关系是“中性”的。在第三个例子中,你可以从前提中逻辑推断出假设——换句话说,假设被前提蕴含。

正如你猜到的那样,即使对于人类来说,NLI 也可能是棘手的。这项任务不仅需要词汇知识(例如,“人”的复数是“人们”,足球是一种运动),还需要一些“常识”(例如,你不能在睡觉时检查)。NLI 是最典型的自然语言理解(NLU)任务之一。你如何构建一个 NLP 模型来解决这个任务?

幸运的是,NLI 是 NLP 中一个经过深入研究的领域。NLI 最流行的数据集是标准自然语言推理(SNLI)语料库(nlp.stanford.edu/projects/snli/),已被大量用作 NLP 研究的基准。接下来,我们将使用 AllenNLP 构建一个神经 NLI 模型,并学习如何为这个特定任务使用 BERT。

在继续之前,请确保你已经安装了 AllenNLP(我们使用的是版本 2.5.0)和 AllenNLP 模型的模块。你可以通过运行以下代码来安装它们:

pip install allennlp==2.5.0
pip install allennlp-models==2.5.0

这也将 Transformers 库安装为一个依赖项。

9.5.2 使用 BERT 进行句对分类

在我们开始构建模型之前,请注意,NLI 任务的每个输入都由两部分组成:前提和假设。本书涵盖的大多数 NLP 任务仅有一个部分——通常是模型的输入的一个部分——通常是单个句子。我们如何构建一个可以对句子对进行预测的模型?

我们有多种方法来处理 NLP 模型的多部分输入。我们可以使用编码器对每个句子进行编码,并对结果应用一些数学操作(例如,串联、减法),以得到一对句子的嵌入(顺便说一句,这是孪生网络的基本思想¹³)。研究人员还提出了更复杂的具有注意力的神经网络模型,例如 BiDAF¹⁴。

然而,从本质上讲,没有什么阻止 BERT 接受多个句子。因为 Transformer 接受任何令牌序列,你可以简单地将两个句子串联起来并将它们输入模型。如果你担心模型混淆了两个句子,你可以用一个特殊的令牌,[SEP],将它们分开。你还可以为每个句子添加不同的值作为模型的额外信号。BERT 使用这两种技术对模型进行了少量修改,以解决句对分类任务,如 NLI。

流水线的其余部分与其他分类任务类似。特殊令牌[CLS]被附加到每个句子对,从中提取输入的最终嵌入。最后,您可以使用分类头将嵌入转换为一组与类相对应的值(称为logits)。这在图 9.14 中有所说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.14 使用 BERT 对一对句子进行馈送和分类

在实践中,连接和插入特殊令牌都是由 SnliReader 处理的,这是专门用于处理 SNLI 数据集的 AllenNLP 数据集读取器。您可以初始化数据集并观察它如何将数据转换为 AllenNLP 实例,代码如下:

from allennlp.data.tokenizers import PretrainedTransformerTokenizer
from allennlp_models.pair_classification.dataset_readers import SnliReader

BERT_MODEL = 'bert-base-cased'
tokenizer = PretrainedTransformerTokenizer(model_name=BERT_MODEL, add_special_tokens=False)

reader = SnliReader(tokenizer=tokenizer)
dataset_url = 'https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_dev.jsonl'
for instance in reader.read():
    print(instance)

数据集读取器从斯坦福 NLI 语料库中获取一个 JSONL(JSON 行)文件,并将其转换为一系列 AllenNLP 实例。我们指定了一个我在线上(S3)放置的数据集文件的 URL。请注意,在初始化分词器时,您需要指定 add_special_tokens=False。这听起来有点奇怪——难道我们不是应该在这里添加特殊令牌吗?这是必需的,因为数据集读取器(SnliReader)而不是分词器会处理特殊令牌。如果您仅使用 Transformer 库(而不是 AllenNLP),则不需要此选项。

前面的代码片段生成了以下生成的实例的转储:

Instance with fields:
         tokens: TextField of length 29 with text:
                [[CLS], Two, women, are, em, ##bracing, while, holding, to, go, packages,
 ., [SEP], The, sisters, are, hugging, goodbye, while, holding, to, go, 
 packages, after, just, eating, lunch, ., [SEP]]
                and TokenIndexers : {'tokens': 'SingleIdTokenIndexer'}
         label: LabelField with label: neutral in namespace: 'labels'.'

Instance with fields:
         tokens: TextField of length 20 with text:
                [[CLS], Two, women, are, em, ##bracing, while, holding, to, go, packages,
 ., [SEP], Two, woman, are, holding, packages, ., [SEP]]
                and TokenIndexers : {'tokens': 'SingleIdTokenIndexer'}
         label: LabelField with label: entailment in namespace: 'labels'.'

Instance with fields:
         tokens: TextField of length 23 with text:
                [[CLS], Two, women, are, em, ##bracing, while, holding, to, go, packages,
 ., [SEP], The, men, are, fighting, outside, a, del, ##i, ., [SEP]]
                and TokenIndexers : {'tokens': 'SingleIdTokenIndexer'}
         label: LabelField with label: contradiction in namespace: 'labels'.'
...

请注意,每个句子都经过了标记化,并且句子被连接并由[SEP]特殊令牌分隔。每个实例还有一个包含金标签的标签字段。

注意:您可能已经注意到令牌化结果中出现了一些奇怪的字符,例如##bracing 和##i。这些是字节对编码(BPE)的结果,这是一种将单词分割为所谓的子词单元的标记化算法。我们将在第十章中详细介绍 BPE。

9.5.3 使用 AllenNLP 与 Transformers

现在我们准备使用 AllenNLP 构建我们的模型。好消息是,由于 AllenNLP 的内置模块,您不需要编写任何 Python 代码来构建 NLI 模型——您只需要编写一个 Jsonnet 配置文件(就像我们在第四章中所做的那样)。AllenNLP 还无缝集成了 Hugging Face 的 Transformer 库,因此即使您想要将基于 Transformer 的模型(如 BERT)集成到现有模型中,通常也只需要进行很少的更改。

当将 BERT 集成到您的模型和流水线中时,您需要对以下四个组件进行更改:

  • Tokenizer—就像您在之前的 9.3 节中所做的那样,您需要使用与您正在使用的预训练模型相匹配的分词器。

  • Token indexer—Token indexer 将令牌转换为整数索引。由于预训练模型带有其自己预定义的词汇表,因此很重要您使用匹配的令牌索引器。

  • Token embedder—Token embedder 将令牌转换为嵌入。这是 BERT 的主要计算发生的地方。

  • Seq2Vec 编码器—BERT 的原始输出是一系列嵌入。您需要一个 Seq2Vec 编码器将其转换为单个嵌入向量。

如果这听起来令人生畏,不要担心—在大多数情况下,你只需要记住使用所需模型的名称来初始化正确的模块。接下来我会引导你完成这些步骤。

首先,让我们定义我们用于读取和转换 SNLI 数据集的数据集。我们之前已经用 Python 代码做过这个了,但在这里我们将在 Jsonnet 中编写相应的初始化。首先,让我们使用以下代码定义我们将在整个流水线中使用的模型名称。Jsonnet 相对于普通 JSON 的一个很酷的功能是你可以定义和使用变量:

local bert_model = "bert-base-cased";

配置文件中初始化数据集的第一部分看起来像以下内容:

"dataset_reader": {
    "type": "snli",
    "tokenizer": {
        "type": "pretrained_transformer",
        "model_name": bert_model,
        "add_special_tokens": false
    },
    "token_indexers": {
        "bert": {
            "type": "pretrained_transformer",
            "model_name": bert_model,
        }
    }
},

在顶层,这是初始化一个由类型 snli 指定的数据集读取器,它是我们之前尝试过的 SnliReader。数据集读取器需要两个参数—tokenizer 和 token_indexers。对于 tokenizer,我们使用一个 PretrainedTransformerTokenizer(类型:pretrained_transformer)并提供一个模型名称。同样,这是我们之前在 Python 代码中初始化和使用的分词器。请注意 Python 代码和 Jsonnet 配置文件之间的良好对应关系。大多数 AllenNLP 模块都设计得非常好,使得这两者之间有着很好的对应关系,如下表所示。

Python 代码Jsonnet 配置
tokenizer = PretrainedTransformerTokenizer(model_name=BERT_MODEL,add_special_tokens=False)“tokenizer”: {“type”: “pretrained_transformer”,“model_name”: bert_model,“add_special_tokens”: false}

初始化令牌索引器部分可能看起来有点混乱。它正在使用模型名称初始化一个 PretrainedTransformerIndexer(类型:pretrained_transformer)。索引器将把索引结果存储到名为 bert 的部分(对应于令牌索引器的键)。幸运的是,这段代码是一个样板,从一个模型到另一个模型几乎没有变化,很可能当你在一个新的基于 Transformer 的模型上工作时,你可以简单地复制并粘贴这一部分。

至于训练/验证数据,我们可以使用本书的 S3 存储库中的数据,如下所示:

"train_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_train.jsonl",
"validation_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_dev.jsonl",

现在我们准备开始定义我们的模型:

"model": {
    "type": "basic_classifier",

    "text_field_embedder": {
        "token_embedders": {
            "bert": {
                "type": "pretrained_transformer",
                "model_name": bert_model
            }
        }
    },
    "seq2vec_encoder": {
        "type": "bert_pooler",
        "pretrained_model": bert_model
    }
},

在顶层,此部分定义了一个 BasicClassifier 模型(类型:basic_classifier)。它是一个通用的文本分类模型,它嵌入输入,使用 Seq2Vec 编码器对其进行编码,并使用分类头进行分类(带有 softmax 层)。您可以将您选择的嵌入器和编码器作为模型的子组件“插入”。例如,您可以通过单词嵌入嵌入标记,并使用 RNN 对序列进行编码(这是我们在第四章中所做的)。或者,您可以使用 CNN 对序列进行编码,就像我们在第七章中所做的那样。这就是 AllenNLP 设计的优点所在——通用模型仅指定了什么(例如,一个 TextFieldEmbedder 和一个 Seq2VecEncoder),但不是确切的如何(例如,单词嵌入、RNN、BERT)。您可以使用任何嵌入/编码输入的子模块,只要这些子模块符合指定的接口(即,它们是所需类的子类)。

在这个案例研究中,我们将首先使用 BERT 对输入序列进行嵌入。这是通过一个特殊的标记嵌入器,PretrainedTransformerEmbedder(类型:pretrained_transformer)实现的,它接受 Transformer 分词器的结果,经过预训练的 BERT 模型,并产生嵌入的输入。您需要将此嵌入器作为 token_embedders 参数的 bert 键的值传递(您之前为 token_indexers 指定的那个)。

然而,从 BERT 中得到的原始输出是一系列嵌入。因为我们感兴趣的是对给定的句子对进行分类,我们需要提取整个序列的嵌入,这可以通过提取与 CLS 特殊标记对应的嵌入来完成。AllenNLP 实现了一种称为 BertPooler(类型:bert_pooler)的 Seq2VecEncoder 类型,它正是这样做的。

在嵌入和编码输入之后,基本分类器模型处理剩下的事情——嵌入经过一个线性层,将它们转换为一组 logits,并且整个网络使用交叉熵损失进行训练,就像其他分类模型一样。整个配置文件如下所示。

列表 9.4 使用 BERT 训练 NLI 模型的配置文件

local bert_model = "bert-base-cased";

{
    "dataset_reader": {
        "type": "snli",
        "tokenizer": {
            "type": "pretrained_transformer",
            "model_name": bert_model,
            "add_special_tokens": false
        },
        "token_indexers": {
            "bert": {
                "type": "pretrained_transformer",
                "model_name": bert_model,
            }
        }
    },
    "train_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_train.jsonl",
    "validation_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_dev.jsonl",

    "model": {
        "type": "basic_classifier",

        "text_field_embedder": {
            "token_embedders": {
                "bert": {
                    "type": "pretrained_transformer",
                    "model_name": bert_model
                }
            }
        },
        "seq2vec_encoder": {
            "type": "bert_pooler",
            "pretrained_model": bert_model,
        }
    },
    "data_loader": {
        "batch_sampler": {
            "type": "bucket",
            "sorting_keys": ["tokens"],
            "padding_noise": 0.1,
            "batch_size" : 32
        }
    },
    "trainer": {
        "optimizer": {
            "type": "huggingface_adamw",
            "lr": 5.0e-6
        },
        "validation_metric": "+accuracy",
        "num_epochs": 30,
        "patience": 10,
        "cuda_device": 0
    }
}

如果您不熟悉数据加载器和训练器部分正在发生的事情也没关系。我们将在第十章讨论这些主题(批处理、填充、优化、超参数调整)。在将此配置文件保存在 examples/nli/snli_transformers.jsonnet 后,您可以通过运行以下代码开始训练过程:

allennlp train examples/nli/snli_transformers.jsonnet --serialization-dir models/snli

这将运行一段时间(即使在诸如 Nvidia V100 这样的快速 GPU 上也是如此),并在 stdout 上产生大量的日志消息。以下是我在四个时期后得到的日志消息的片段:

...
allennlp.training.trainer - Epoch 4/29
allennlp.training.trainer - Worker 0 memory usage MB: 6644.208
allennlp.training.trainer - GPU 0 memory usage MB: 8708
allennlp.training.trainer - Training
allennlp.training.trainer - Validating
allennlp.training.tensorboard_writer -                        Training |  Validation
allennlp.training.tensorboard_writer - accuracy           |     0.933  |     0.908
allennlp.training.tensorboard_writer - gpu_0_memory_MB    |  8708.000  |       N/A
allennlp.training.tensorboard_writer - loss               |     0.190  |     0.293
allennlp.training.tensorboard_writer - reg_loss           |     0.000  |     0.000
allennlp.training.tensorboard_writer - worker_0_memory_MB |  6644.208  |       N/A
allennlp.training.checkpointer - Best validation performance so far. Copying weights to 'models/snli/best.th'.
allennlp.training.trainer - Epoch duration: 0:21:39.687226
allennlp.training.trainer - Estimated training time remaining: 9:04:56
...

注意验证准确率(0.908)。考虑到这是一个三类分类,随机基线只会是 0.3。相比之下,当我用基于 LSTM 的 RNN 替换 BERT 时,我得到的最佳验证准确率约为~0.68。我们需要更仔细地进行实验,以公平地比较不同模型之间的差异,但这个结果似乎表明 BERT 是解决自然语言理解问题的强大模型。

摘要

  • 转移学习是一个机器学习概念,其中一个模型学习了一个任务,然后通过在它们之间转移知识来应用到另一个任务上。这是许多现代、强大、预训练模型的基本概念。

  • BERT 是一个使用掩码语言建模和下一句预测目标进行预训练的 Transformer 编码器,以产生上下文化的嵌入,一系列考虑上下文的词嵌入。

  • ELMo、XLNet、RoBERTa、DistilBERT 和 ALBERT 是现代深度自然语言处理中常用的其他流行的预训练模型。

  • 你可以直接使用 Hugging Face 的 Transformers 库构建基于 BERT 的 NLP 应用,也可以使用无缝集成 Transformers 库的 AllenNLP。

^(1.)Ramponi 和 Plank,“NLP 中的神经无监督领域自适应——一项调查”,(2020)。arxiv.org/abs/2006.00632

^(2.)Jacob Devlin,Ming-Wei Chang,Kenton Lee 和 Kristina Toutanova,“BERT:用于语言理解的深度双向 Transformer 预训练”,(2018)。arxiv.org/abs/1810.04805

^(3.)Bryan McCann,James Bradbury,Caiming Xiong 和 Richard Socher,“翻译中学到的:上下文化的词向量”,2017 年 NIPS 会议。

^(4.)Peters 等人,“深度上下文化的词表示”,(2018)。arxiv.org/abs/1802.05365

^(5.)点击这里查看有关如何使用 ELMo 与 AllenNLP 的详细文档:allennlp.org/elmo

^(6.)请参阅huggingface.co/transformers/model_doc/xlnet.html以获取文档。

^(7.)Liu 等人,“RoBERTa:一个稳健优化的 BERT 预训练方法,”(2019)。arxiv.org/abs/1907.11692

^(8.)Conneau 等人,“规模化的无监督跨语言表示学习”,(2019)。arxiv.org/abs/1911.02116

^(9.)Sanh 等人,“DistilBERT,BERT 的精简版:更小、更快、更便宜、更轻”,(2019)。arxiv.org/abs/1910.01108

^(10.)Lan 等人,“ALBERT:一种用于自监督学习语言表示的轻量级 BERT”,(2020)。arxiv.org/abs/1909.11942

^(11.)这些示例来自 ROCStories:cs.rochester.edu/nlp/rocstories/

^(12.)这些示例来自于nlpprogress.com/english/natural_language_inference.html.

^(13.)Reimers 和 Gurevych,“使用 Siamese BERT-Network 的句子嵌入:Sentence-BERT”,(2019)。arxiv.org/abs/1908.10084.

^(14.)Seo 等人,“面向机器理解的双向注意力流”,(2018)。arxiv.org/abs/1611.01603.

第三部分:投入生产

在第 1 和第二部分,我们学到了许多关于现代 NLP 中“建模”部分的知识,包括词嵌入、RNN、CNN 和 Transformer。然而,你仍然需要学习如何有效地训练、提供、部署和解释这些模型,以构建健壮和实用的 NLP 应用程序。

第十章涉及在开发 NLP 应用程序时触及到的重要机器学习技术和最佳实践,包括批处理和填充、正则化和超参数优化。

最后,如果第 1 到 10 章是关于构建 NLP 模型,第十一章则涵盖了发生在 NLP 模型外部 的一切。该章节涵盖了如何部署、提供、解释和解读 NLP 模型。

第十章:开发自然语言处理应用的十大最佳实践

本章内容包括

  • 通过对令牌进行排序、填充和掩码使神经网络推断更有效率

  • 应用基于字符和 BPE 的分词技术将文本分割成令牌

  • 通过正则化避免过拟合

  • 通过使用上采样、下采样和损失加权处理不平衡数据集

  • 优化超参数

到目前为止,我们已经涵盖了很多内容,包括 RNN、CNN 和 Transformer 等深度神经网络模型,以及 AllenNLP 和 Hugging Face Transformers 等现代 NLP 框架。然而,我们对训练和推断的细节关注不多。例如,如何高效地训练和进行预测?如何避免模型过拟合?如何优化超参数?这些因素可能会对模型的最终性能和泛化能力产生巨大影响。本章涵盖了您需要考虑的这些重要主题,以构建在实际中表现良好的稳健准确的 NLP 应用程序。

10.1 实例批处理

在第二章中,我们简要提到了批处理,这是一种机器学习技术,其中实例被分组在一起形成批次,并发送到处理器(CPU 或更常见的 GPU)。在训练大型神经网络时,批处理几乎总是必要的——它对于高效稳定的训练至关重要。在本节中,我们将深入探讨与批处理相关的一些技术和考虑因素。

10.1.1 填充

训练大型神经网络需要进行许多线性代数运算,如矩阵加法和乘法,这涉及同时对许多许多数字执行基本数学运算。这就是为什么它需要专门的硬件,如 GPU,设计用于高度并行化执行此类操作的处理器。数据被发送到 GPU 作为张量,它们只是数字的高维数组,以及一些指示,说明它需要执行什么类型的数学运算。结果被发送回作为另一个张量。

在第二章中,我们将 GPU 比作海外高度专业化和优化的工厂,用于大量生产相同类型的产品。由于在通信和运输产品方面存在相当大的开销,因此如果您通过批量运输所有所需材料来进行小量订单以制造大量产品,而不是按需运输材料,则效率更高。

材料和产品通常在标准化的容器中来回运输。如果你曾经自己装过搬家货舱或观察别人装过,你可能知道有很多需要考虑的因素来确保安全可靠的运输。你需要紧紧地把家具和箱子放在一起,以免在过渡过程中移位。你需要用毯子裹着它们,并用绳子固定它们,以防止损坏。你需要把重的东西放在底部,以免把轻的东西压坏,等等。

机器学习中的批次类似于现实世界中用于运输物品的容器。就像运输集装箱都是相同的尺寸和矩形形状一样,机器学习中的批次只是装有相同类型数字的矩形张量。如果你想要将不同形状的多个实例在单个批次中“运送”到 GPU,你需要将它们打包,使打包的数字形成一个矩形张量。

在自然语言处理中,我们经常处理长度不同的文本序列。因为批次必须是矩形的,所以我们需要进行填充(即在每个序列末尾加上特殊标记< PAD >),以便张量的每一行具有相同的长度。你需要足够多的填充标记,以使序列的长度相同,这意味着你需要填充短的序列,直到它们与同一批次中最长的序列一样长。示例见图 10.1。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.2 嵌入序列的填充和分批创建了三维的矩形张量。

看起来越来越像真正的容器了!

10.1.2 排序

因为每个批次必须是矩形的,如果一个批次同时包含短序列和长序列,你需要为短序列添加大量填充,使它们与同一批次中最长的序列一样长。这通常会导致批次中存在一些浪费空间——见图 10.3 中的“batch 1”示例。最短的序列(六个标记)需要填充八个标记才能与最长的序列(14 个标记)长度相等。张量中的浪费空间意味着存储和计算的浪费,所以最好避免这种情况发生,但是怎么做呢?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.3 在批处理之前对实例进行排序(右侧)可以减少总张量数量。

通过将相似大小的实例放在同一个批次中,可以减少填充的量。如果较短的实例只与其他同样较短的实例一起批处理,则它们不需要用许多填充标记进行填充。同样,如果较长的实例只与其他较长的实例一起批处理,则它们也不需要很多填充,因为它们已经很长了。一个想法是按照它们的长度对实例进行排序,并相应地进行批处理。图 10.3 比较了两种情况——一种是实例按其原始顺序进行批处理,另一种是在批处理之前对实例进行排序。每个批次下方的数字表示表示批次所需的标记数,包括填充标记。注意,通过排序,总标记数从 144 降低到 120。因为原始句子中的标记数没有变化,所以这纯粹是因为排序减少了填充标记的数量。较小的批次需要更少的内存来存储和更少的计算来处理,因此在批处理之前对实例进行排序可以提高训练的效率。

所有这些技术听起来有点复杂,但好消息是,只要使用高级框架(如 AllenNLP),你很少需要自己编写排序、填充和批处理实例的代码。回想一下,在第二章中构建情感分析模型时,我们使用了 DataLoader 和 BucketBatchSampler 的组合,如下所示:

train_data_loader = DataLoader(train_dataset,
                               batch_sampler=BucketBatchSampler(
                                   train_dataset,
                                   batch_size=32,
                                   sorting_keys=["tokens"]))

BucketBatchSampler 中给定的 sorting_keys 指定了要用于排序的字段。从名称可以猜出,通过指定“tokens”,你告诉数据加载器按照标记数对实例进行排序(在大多数情况下是你想要的)。流水线会自动处理填充和批处理,数据加载器会提供一系列批次供您的模型使用。

10.1.3 掩码

最后一个需要注意的细节是 掩码。掩码是一种操作,用于忽略与填充相对应的网络的某些部分。当你处理顺序标记或语言生成模型时,这变得特别重要。回顾一下,顺序标记是一种任务,其中系统为输入序列中的每个标记分配一个标签。我们在第五章中使用了顺序标记模型(RNN)构建了一个词性标注器。

如图 10.4 所示,顺序标记模型通过最小化给定句子中所有标记的每个标记损失来进行训练。我们这样做是因为我们希望最小化网络每个标记的“错误”数量。只要处理“真实”标记(图中的“time”,“flies”和“like”),这是可以接受的,尽管当输入批次包含填充标记时,这就成为一个问题。因为它们只是为了填充批次而存在,所以在计算总损失时应该忽略它们。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.4 序列的损失是每个标记的交叉熵之和。

我们通常通过创建一个额外的用于掩码损失的向量来完成这个过程。用于掩码的向量的长度与输入相同,其元素为“真”标记和填充的“假”标记。在计算总损失时,你可以简单地对每个标记的损失和掩码进行逐元素乘积,然后对结果进行求和。

幸运的是,只要你正在使用 AllenNLP 构建标准的顺序标记模型,你很少需要自己实现掩码。记住,在第五章,我们按照列表 10.1 中所示编写了 POS 标签器模型的前向传播。在这里,我们从 get_text_field_mask() 辅助函数获取掩码向量,并使用 sequence_cross_entropy_with_logits() 计算最终损失。

列表 10.1 POS 标签器的前向传播

    def forward(self,
                words: Dict[str, torch.Tensor],
                pos_tags: torch.Tensor = None,
                **args) -> Dict[str, torch.Tensor]:
        mask = get_text_field_mask(words)

        embeddings = self.embedder(words)
        encoder_out = self.encoder(embeddings, mask)
        tag_logits = self.linear(encoder_out)

        output = {"tag_logits": tag_logits}
        if pos_tags is not None:
            self.accuracy(tag_logits, pos_tags, mask)
            output["loss"] = sequence_cross_entropy_with_logits(
                tag_logits, pos_tags, mask)

        return output

如果你偷看一下掩码中的内容(比如,在这个前向方法中插入一个打印语句),你会看到以下由二进制(真或假)值组成的张量:

tensor([[ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],

这个张量的每一行对应一个标记序列,False 的位置是填充发生的地方。损失函数(sequence_cross_entropy_with_logits)接收预测值、真实标签和掩码,并在忽略所有标记为 False 的元素时计算最终损失。

10.2 用于神经模型的标记化

在第三章,我们介绍了基本的语言单位(单词、字符和 n-gram)以及如何计算它们的嵌入。在本节中,我们将更深入地讨论如何分析文本并获取这些单位的过程——称为标记化。神经网络模型在处理标记时面临一系列独特的挑战,我们将介绍一些现代模型来解决这些挑战。

10.2.1 未知单词

词汇表是一个 NLP 模型处理的标记集合。许多神经网络自然语言处理模型在一组固定、有限的标记中运作。例如,在第二章构建情感分析器时,AllenNLP 管道首先对训练数据集进行标记化,并构造一个 Vocabulary 对象,该对象包含了所有出现次数超过,比如,三次以上的所有唯一标记。然后模型使用一个嵌入层将标记转换为单词嵌入,这是输入标记的一些抽象表示。

迄今为止,一切都很顺利,对吧?但是世界上的所有单词数量并不是有限的。我们不断创造以前不存在的新单词(我不认为一百年前人们谈论过“NLP”)。如果模型接收到在训练期间从未见过的单词怎么办?因为这个单词不是词汇表的一部分,所以模型甚至不能将其转换为索引,更不用说查找其嵌入了。这样的单词被称为词汇外(OOV)单词,它们是构建自然语言处理应用时最大的问题之一。

到目前为止,处理这个问题最常见(但不是最好)的方法是将所有的 OOV 标记表示为一个特殊的标记,通常称为 UNK(代表“未知”)。想法是每当模型看到一个不属于词汇表的标记时,它都会假装看到了一个特殊的标记 UNK,并像往常一样继续执行。这意味着词汇表和嵌入表都有一个专门的“插槽”用于 UNK,以便模型可以处理从未见过的词汇。UNK 的嵌入(以及任何其他参数)与其他常规标记一样进行训练。

你是否看到这种方法存在任何问题?将所有的 OOV 标记都用一个单一的 UNK 标记来对待意味着它们被折叠成一个单一的嵌入向量。无论是“NLP”还是“doggy”——只要是未见过的东西,总是被视为一个 UNK 标记并被分配相同的向量,这个向量成为各种词汇的通用、全能表示。因此,模型无法区分 OOv 词汇之间的差异,无论这些词汇的身份是什么。

如果你正在构建一个情感分析器,这可能是可以接受的。OOV 词汇从定义上来说非常少见,可能不会影响到大部分输入句子的预测。然而,如果你正在构建一个机器翻译系统或一个对话引擎,这将成为一个巨大的问题。如果每次看到新词汇时都产生“我不知道”,那么它就不会是一个可用的 MT 系统或聊天机器人!一般来说,与用于预测的 NLP 系统(情感分析、词性标注等)相比,对于语言生成系统(包括机器翻译和对话 AI),OOV 问题更为严重。

如何做得更好?在自然语言处理中,OOV 标记是一个如此严重的问题,以至于已经有很多研究工作在如何处理它们上面。在下面的小节中,我们将介绍基于字符和基于子词的模型,这是两种用于构建强大神经网络自然语言处理模型的常用技术。

10.2.2 字符模型

处理 OOV 问题最简单但最有效的解决方案是将字符视为标记。具体来说,我们将输入文本分解为单个字符,甚至包括标点符号和空白字符,并将它们视为常规标记。应用程序的其余部分保持不变——“单词”嵌入被分配给字符,然后由模型进一步处理。如果模型生成文本,它是逐字符地生成的。

实际上,当我们构建语言生成器时,我们在第五章使用了字符级模型。RNN 不是一次生成一个单词,而是一次生成一个字符,如图 10.5 所示。由于这种策略,模型能够生成看起来像英语但实际上不是的单词。请注意 10.2 列表中显示的输出中类似于英语的许多奇怪的单词(despoitstudentedredusentiondistaples).如果模型操作单词,它只会生成已知的单词(或者在不确定时生成 UNKs),这是不可能的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.5:生成文本字符级(包括空格)的语言生成模型

列 10.2:字符级语言模型生成的句子

You can say that you don't know it, and why decided of yourself.
Pike of your value is to talk of hubies.
The meeting despoit from a police?
That's a problem, but us?
The sky as going to send nire into better.
We'll be look of the best ever studented.
There's you seen anything every's redusention day.
How a fail is to go there.
It sad not distaples with money.
What you see him go as famous to eat!

基于字符的模型是多功能的,并对语言的结构做出了少量的假设。对于拥有小字母表的语言(比如英语),它有效地消除了未知单词,因为几乎任何单词,无论其多么罕见,都可以被分解为字符。对于拥有大字母表的语言(如中文),将其标记为字符也是一种有效的策略,尽管你需要注意“未知字符”的问题。

然而,这种策略并非没有缺点。最大的问题是效率低下。为了编码一个句子,网络(无论是 RNN 还是 Transformer)都需要处理其中的所有字符。例如,基于字符的模型需要处理“t”,“h”,“e”,和“_”(空格)来处理一个单词“the”,而基于单词的模型可以在一个步骤中完成。这种低效在输入序列变长时对 Transformer 的影响最大,注意计算的增长是二次方的。

10.2.3:子词模型

到目前为止,我们学习了两个极端——基于单词的方法效率很高,但在处理未知词方面表现不佳。基于字符的方法在处理未知词方面表现出色,但效率低下。有没有一种介于两者之间的标记化方法?我们能不能使用一些标记化方法既高效又能很好地处理未知词?

子词模型是神经网络针对这个问题的最新发明。在子词模型中,输入文本被分割成一个被称为子词的单位,这只是意味着比单词小的东西。对于什么是子词,没有正式的语言学定义,但它们大致对应于频繁出现的单词的一部分。例如,“dishwasher”的一种分段方法是“dish + wash + er”,尽管也可能有其他的分割方法。

一些算法的变体(如 WordPiece¹ 和 SentencePiece²)将输入标记化为子词,但迄今为止最广泛使用的是字节对编码(BPE)。³ BPE 最初是作为一种压缩算法发明的,但自 2016 年以来,它已被广泛用作神经模型的标记化方法,特别是在机器翻译中。

BPE 的基本概念是保持频繁单词(如“the”和“you”)和 n 元组(如“-able”和“anti-”)不分段,同时将较少出现的单词(如“dishwasher”)分解为子词(“dish + wash + er”)。将频繁单词和 n 元组放在一起有助于模型高效处理这些标记,而分解稀有单词可以确保没有 UNK 标记,因为一切都最终可以分解为单个字符,如果需要的话。通过根据频率灵活选择标记位置,BPE 实现了两全其美——既高效又解决了未知词问题。

让我们看看 BPE 如何确定在真实示例中进行标记化。BPE 是一种纯统计算法(不使用任何语言相关信息),通过一次合并最频繁出现的一对连续标记来操作。首先,BPE 将所有输入文本标记化为单个字符。例如,如果您的输入是四个单词 low、lowest、newer 和 wider,则它们将被标记化为 l o w _、l o w e s t _、n e w e r _ 和 w i d e r 。在这里,“”是一个特殊符号,表示每个单词的结尾。然后,算法识别出最频繁出现的任意两个连续元素。在这个例子中,对 l o 出现最频繁(两次),所以这两个字符被合并,得到 lo w _、lo w e s t _、n e w e r _、w i d e r 。然后,lo w 将被合并为 low,e r 将被合并为 er,er _ 将被合并为 er,此时您有 low 、low e s t 、n e w er、w i d er。此过程在图 10.6 中有所说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.6 BPE 通过迭代地合并频繁出现的连续单元来学习子词单元。

注意,在四次合并操作之后,lowest 被分割为 low e s t,其中频繁出现的子字符串(如 low)被合并在一起,而不频繁出现的子字符串(如 est)被拆分开来。要对新输入(例如 lower)进行分割,将按顺序应用相同的合并操作序列,得到 low e r _。如果您从 52 个唯一字母(26 个大写字母和小写字母)开始,执行了 N 次合并操作,则您的词汇表中将有 52 + N 个唯一标记,其中 N 是执行的合并操作数。通过这种方式,您完全控制了词汇表的大小。

在实践中,你很少需要自己实现 BPE(或任何其他子词标记化算法)。这些算法在许多开源库和平台上都有实现。两个流行的选择是 Subword-NMT(github.com/rsennrich/subword-nmt)和 SentencePiece(github.com/google/sentencepiece)(它还支持使用 unigram 语言模型的子词标记化变体)。许多 NLP 框架中附带的默认标记器,比如 Hugging Face Transformers 中实现的标记器,都支持子词标记化。

10.3 避免过拟合

过拟合是构建任何机器学习应用时需要解决的最常见和最重要的问题之一。当一个机器学习模型拟合给定数据得非常好,以至于失去了对未见数据的泛化能力时,就说该模型过拟合了。换句话说,模型可能在训练数据上表现得非常好,并且在它上面表现良好,但是可能无法很好地捕捉其固有模式,并且在模型从未见过的数据上表现不佳。

因为过拟合在机器学习中非常普遍,研究人员和实践者过去已经提出了许多算法和技术来应对过拟合。在本节中,我们将学习两种这样的技术——正则化和提前停止。这些技术在任何机器学习应用中都很受欢迎(不仅仅是自然语言处理),值得掌握。

10.3.1 正则化

正则化在机器学习中指的是鼓励模型的简化和泛化的技术。你可以把它看作是一种惩罚形式之一,你

强加给你的机器学习模型以确保其尽可能通用。这是什么意思呢?假设你正在构建一个“动物分类器”,通过从语料库中训练词嵌入并在这个嵌入空间中为动物和其他东西之间划分一条线(即,你将每个单词表示为一个多维向量,并根据向量的坐标对单词是否描述动物进行分类)。让我们大大简化这个问题,假设每个单词都是一个二维向量,并且你得到了图 10.7 所示的图。现在你可以可视化一个机器学习模型如何通过在决策翻转不同类别之间的线来做出分类决策,这被称为分类边界。你会如何绘制一个分类边界,以便将动物(蓝色圆圈)与其他所有东西(三角形)分开?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.7 动物 vs. 非动物分类图

分离动物的一个简单方法是绘制一条直线,就像图 10.8 中的第一个图中所示。这个简单的分类器会犯一些错误(在分类诸如“hot”和“bat”之类的单词时),但是它正确分类了大多数数据点。这听起来是一个不错的开始。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.8 随着复杂性增加的分类边界

如果告诉你决策边界不一定是一条直线呢?你可能想画出图 10.8 中间所示的那样的东西。这个看起来更好一些——它比第一个少犯一些错误,虽然仍然不完美。对于机器学习模型来说,这似乎是可行的,因为形状很简单。

但是这里没有什么可以阻止你。如果你想要尽可能少地犯错误,你也可以画出像第三个图中所示的那样扭曲的东西。那个决策边界甚至不会犯任何分类错误,这意味着我们实现了 100%的分类准确性!

不要那么快——记住,直到现在,我们只考虑了训练时间,但是机器学习模型的主要目的是在测试时间达到良好的分类性能(即,它们需要尽可能正确地分类未观察到的新实例)。现在让我们想一想前面描述的三个决策边界在测试时间表现如何。如果我们假设测试实例的分布与我们在图 10.8 中看到的训练实例类似,那么新的“动物”点最有可能落在图中的右上区域。前两个决策边界将通过正确分类大多数新实例而实现相当的准确度。但是第三个呢?像图中显示的“热”的训练实例最有可能是例外而不是规则,因此试图适应尽可能多的训练实例的决策边界的曲线部分可能会在测试时间通过无意中错误分类测试实例时带来更多的伤害。这正是过拟合的样子——模型对训练数据拟合得太好,牺牲了其泛化能力,这就是这里发生的事情。

然后,问题来了,我们如何避免你的模型看起来像第三个决策边界?毕竟,它在正确分类训练数据方面做得非常好。如果你只看训练准确度和/或损失,那么没有什么能阻止你选择它。避免过拟合的一种方法是使用一个单独的、保留的数据集(称为验证集;参见 2.2.3 节)来验证模型的性能。但是即使不使用单独的数据集,我们能做到吗?

第三个决策边界看起来不对劲——它过于复杂。在其他所有条件相同的情况下,我们应该更喜欢简单的模型,因为一般来说,简单的模型更容易泛化。这也符合奥卡姆剃刀原理,即更简单的解决方案优于更复杂的解决方案。我们如何在训练拟合和模型简单性之间取得平衡呢?

这就是正则化发挥作用的地方。将正则化视为对模型施加的额外限制,以便优选更简单和/或更一般化的模型。该模型被优化,使其能够在获得最佳训练拟合的同时尽可能一般化。

由于过拟合是如此重要的话题,因此机器学习中已经提出了许多正则化技术。我们只介绍其中几个最重要的——L2 正则化(权重衰减),dropout 和提前停止。

L2 正则化

L2 正则化,也称为权重衰减,是不仅用于 NLP 或深度学习,而且用于广泛的 ML 模型的最常见的正则化方法之一。我们不会深入探讨它的数学细节,但简单来说,L2 正则化为模型的复杂度增加了惩罚,这个复杂度是通过其参数的大小来测量的。为了表示复杂的分类边界,ML 模型需要调整大量参数(“魔术常数”)到极端值,这由 L2 loss 来衡量,其捕获了它们距离零有多远。这样的模型会承担更大的 L2 惩罚,这就是为什么 L2 鼓励更简单的模型。如果你想了解更多关于 L2 正则化(以及 NLP 一般的其他相关主题),请查阅类似 Jurafsky 和 Martin 的Speech and Language Processing(web.stanford .edu/~jurafsky/slp3/5.pdf)或 Goodfellow 等人的Deep Learning(www.deep learningbook.org/contents/regularization.html)的教材。

Dropout

Dropout是另一种常用于神经网络的正则化技术。Dropout 通过在训练期间随机“放弃”神经元来工作,其中“神经元”基本上是中间层的一个维度,“放弃”意味着用零掩盖它。你可以将 dropout 视为对模型结构复杂性的惩罚以及对特定特征和值的依赖性。因此,网络试图通过剩余数量较少的值做出最佳猜测,这迫使它良好地泛化。Dropout 易于实现,在实践中非常有效,并且在许多深度学习模型中作为默认正则化方法使用。有关 dropout 的更多信息,请参考 Goodfellow 书中提到的正则化章节,其中详细介绍了正则化技术的数学细节。

10.3.2 提前停止

另一种在机器学习中应对过拟合的流行方法是提前停止。提前停止是一种相对简单的技术,当模型性能不再改善时(通常使用验证集损失来衡量),停止训练模型。在第六章中,我们绘制了学习曲线,当我们构建英西机器翻译模型(在图 10.9 中再次显示)时。请注意,验证损失曲线在第八个时期左右变平,在此之后开始上升,这是过拟合的迹象。提前停止会检测到这一点,停止训练,并使用损失最低的最佳时期的结果。一般来说,提前停止具有“耐心”参数,该参数是停止训练的非改善时期的数量。例如,当耐心是 10 个时期时,训练流程将在损失停止改善后等待 10 个时期才终止训练。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.9 验证损失曲线在第 8 个时期左右变平,并逐渐上升。

为什么提前停止有助于减轻过拟合?它与模型复杂度有什么关系?不涉及数学细节,让模型学习复杂的、过拟合的决策边界需要一定的时间(训练时期)。大多数模型从一些简单的东西开始(例如直接的决策线)并逐渐在训练过程中增加其复杂性。通过提前停止训练,可以防止模型变得过于复杂。

许多机器学习框架都内置了提前停止的支持。例如,AllenNLP 的训练器默认支持提前停止。回忆一下,当我们训练基于 BERT 的自然语言推理模型时,在第 9.5.3 节使用了以下配置,其中我们使用了提前停止(耐心为 10)而没有过多关注。这使得训练器能够在验证指标在 10 个时期内没有改善时停止:

    "trainer": {
        "optimizer": {
            "type": "huggingface_adamw",
            "lr": 1.0e-5
        },
        "num_epochs": 20,
        "patience": 10,
        "cuda_device": 0
    }

10.3.3 交叉验证

交叉验证 不完全是一种正则化方法,但它是机器学习中常用的技术之一。在构建和验证机器学习模型时,通常情况是只有数百个实例可供训练。正如本书迄今所见,仅依靠训练集是无法训练出可靠的机器学习模型的——您需要一个单独的集合用于验证,最好再有一个单独的集合用于测试。您在验证/测试中使用的比例取决于任务和数据大小,但通常建议将 5-20% 的训练实例留作验证和测试。这意味着,如果您的训练数据较少,那么您的模型将只有几十个实例用于验证和测试,这可能会使估算的指标不稳定。此外,您选择这些实例的方式对评估指标有很大的影响,这并不理想。

交叉验证的基本思想是多次迭代这个阶段(将数据集分成训练和验证部分),使用不同的划分方式来提高结果的稳定性。具体来说,在一个典型的称为k 折交叉验证的设置中,您首先将数据集分成k个不同的相等大小的部分,称为折叠。您使用折叠中的一个进行验证,同时在其余部分(k - 1 个折叠)上训练模型,并重复此过程k次,每次使用不同的折叠进行验证。详见图 10.10 的示意图。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.10:k 折交叉验证中,数据集被分为 k 个大小相等的折叠,其中一个用于验证。

每个折叠的验证指标都会计算,并且最终指标会在所有迭代中取平均。通过这种方式,您可以得到一个对评估指标的更稳定的估计,而不受数据集划分方式的影响。

在深度学习模型中,使用交叉验证并不常见,因为这些模型需要大量数据,如果您有大型数据集,则不需要交叉验证,尽管在传统和工业场景中,训练数据量有限时使用交叉验证更为常见。

10.4 处理不平衡数据集

在本节中,我们将重点讨论在构建自然语言处理(NLP)和机器学习(ML)模型时可能遇到的最常见问题之一——类别不平衡问题。分类任务的目标是将每个实例(例如电子邮件)分配给其中一个类别(例如垃圾邮件或非垃圾邮件),但这些类别很少均匀分布。例如,在垃圾邮件过滤中,非垃圾邮件的数量通常大于垃圾邮件的数量。在

文档分类中,某些主题(如政治或体育)通常要比其他主题更受欢迎。当某些类别的实例数量远远多于其他类别时,类别被称为不平衡(见图 10.11 中的示例)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.11:不平衡数据集

许多分类数据集存在不平衡的类别,这在训练分类器时会带来一些额外的挑战。小类别给模型带来的信号会被大类别压倒,导致模型在少数类别上表现不佳。在接下来的小节中,我将讨论一些在面对不平衡数据集时可以考虑的技术。

10.4.1 使用适当的评估指标

在您甚至开始调整数据集或模型之前,请确保您正在使用适当的指标验证您的模型。在第 4.3 节中,我们讨论了在数据集不平衡时使用准确性作为评估指标是一个坏主意的原因。在一个极端情况下,如果您的实例中有 90%属于类别 A,而其他 10%属于类别 B,即使一个愚蠢的分类器将类别 A 分配给一切,它也可以达到 90%的准确性。这被称为多数类基线。稍微聪明一点(但仍然愚蠢)的分类器,90%的时间随机分配标签 A,10%的时间随机分配标签 B,甚至不看实例,就可以达到 0.9 * 0.9 + 0.1 * 0.1 = 82%的准确性。这被称为随机基线,而数据集越不平衡,这些基线模型的准确性就会越高。

但是这种随机基线很少是少数类的良好模型。想象一下,如果您使用随机基线会发生什么事情。因为无论如何,它都会将类别 A 分配给 90%的时间,类别 B 会发生什么情况。换句话说,属于类别 B 的 90%实例将被分配给类别 A。换句话说,这种类别 B 的随机基线的准确性只有 10%。如果这是一个垃圾邮件过滤器,它将让 90%的垃圾邮件通过,无论内容是什么,只是因为您收到的邮件中有 90%不是垃圾邮件!这会造成一个糟糕的垃圾邮件过滤器。

如果您的数据集不平衡,并且您关心少数类别的分类性能,您应该考虑使用更适合这种情况的指标。例如,如果您的任务是“大海捞针”类型的设置,在这种情况下,目标是在其他实例中找到很少的实例,您可能希望使用 F1 度量而不是准确性。正如我们在第四章中看到的,F 度量是精确度(您的预测有多少是无草的)和召回率(您实际上找到了多少针)之间的某种平均值。因为 F1 度量是每个类别计算的,所以它不会低估少数类别。如果您想要测量模型的整体性能,包括多数类别,您可以计算宏平均的 F 度量,它只是每个类别计算的 F 度量的算术平均值。

10.4.2 上采样和下采样

现在让我们看看可以缓解类别不平衡问题的具体技术。首先,如果您可以收集更多的标记训练数据,您应该认真考虑首先这样做。与学术和机器学习竞赛设置不同,在这种设置中数据集是固定的,而您调整您的模型,而在现实世界中,您可以自由地做任何必要的事情来改进您的模型(当然,只要合法且实用)。通常,您可以做的最好的事情是让模型暴露于更多的数据。

如果您的数据集不平衡且模型正在做出偏向的预测,您可以对数据进行上采样下采样,以便各类别具有大致相等的表示。

在上采样中(参见图 10.12 中的第二张图),你通过多次复制实例人工增加少数类的大小。例如,我们之前讨论的场景——如果你复制类 B 的实例并将每个实例的副本增加八个,它们就会有相等数量的实例。这可以缓解偏见预测的问题。尽管有更复杂的数据增强算法,如 SMOTE⁵,但它们在自然语言处理中并不常用,因为人为生成语言示例固有的困难。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.12 上采样和下采样

如果你的模型存在偏见,不是因为少数类太小,而是因为多数类太大,你可以选择进行下采样(图 10.12 中的第三张图)。在下采样中,你通过选择属于该类的实例的子集人工减少多数类的大小。例如,如果你从类 A 中随机抽取了九个实例中的一个,你最终会得到类 A 和类 B 中相等数量的实例。你可以以多种方式进行下采样——最简单的是随机选择子集。如果你想确保下采样后的数据集仍保留了原始数据的多样性,你可以尝试分层抽样,其中你根据某些属性定义的组对实例进行抽样。例如,如果你有太多的非垃圾邮件并想要进行下采样,你可以首先按发件人的域分组,然后在每个域中抽样一定数量的电子邮件。这将确保你的抽样数据集将包含多种域的多样性。

请注意,无论是上采样还是下采样都不是灵丹妙药。如果你对类的分布进行了过于激进的“修正”,你会冒着对多数类做出不公平预测的风险,如果这是你关心的话。一定要确保用一个合适的评估指标的验证集检查你的模型。

10.4.3 权重损失

缓解类不平衡问题的另一种方法是在计算损失时使用加权,而不是对训练数据进行修改。请记住,损失函数用于衡量模型对实例的预测与真实情况的“偏离”程度。当你衡量模型的预测有多糟糕时,你可以调整损失,使其在真实情况属于少数类时惩罚更严厉。

让我们来看一个具体的例子。二元交叉熵损失是用于训练二元分类器的常见损失函数,当正确标签为 1 时,它看起来像图 10.13 中所示的曲线。 x 轴是目标类别的预测概率,y 轴是预测将施加的损失量。当预测完全正确(概率 = 1)时,没有惩罚,而随着预测变得越来越糟糕(概率 < 1),损失增加。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.13 二元交叉熵损失(正确标签为 1)

如果您更关心模型在少数类上的表现,可以调整这个损失。具体而言,您可以更改这个损失的形状(通过简单地将其乘以一个常数),只针对那个类别,以便当模型在少数类上犯错时,它会产生更大的损失。图 10.14 中的一条调整后的损失曲线就是顶部的那条。这种加权与上采样少数类具有相同的效果,尽管修改损失的计算成本更低,因为您不需要实际增加训练数据量。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.14 加权二元交叉熵损失

在 PyTorch 和 AllenNLP 中实现损失权重很容易。PyTorch 的二元交叉熵实现 BCEWithLogitsLoss 已经支持为不同类别使用不同的权重。您只需要将 pos_weight 参数作为权重传递,如下所示:

>>> import torch
>>> import torch.nn as nn

>>> input = torch.randn(3)
>>> input
tensor([-0.5565,  1.5350, -1.3066])

>>> target = torch.empty(3).random_(2)
>>> target
tensor([0., 0., 1.])

>>> loss = nn.BCEWithLogitsLoss(reduction='none')
>>> loss(input, target)
tensor([0.4531, 1.7302, 1.5462])

>>> loss = nn.BCEWithLogitsLoss(reduction='none', pos_weight=torch.tensor(2.))
>>> loss(input, target)
tensor([0.4531, 1.7302, 3.0923])

在这段代码片段中,我们随机生成预测值(input)和真实值(target)。总共有三个实例,其中两个属于类别 0(多数类),一个属于类别 1(少数类)。我们先使用 BCEWithLogitsLoss 对象计算不加权的损失,这将返回三个损失值,每个实例一个。然后,我们通过传递权重 2 来计算加权损失——这意味着如果目标类别是正类(类别 1),则错误预测将被惩罚两倍。请注意,对应于类别 1 的第三个元素是非加权损失函数返回值的两倍。

10.5 超参数调整

在本章的最后一节,我们将讨论超参数调整。超参数是有关模型和训练算法的参数。这个术语与参数相对,参数是模型用于从输入中作出预测的数字。这就是我们在本书中一直称之为“魔术常数”的内容——它们类似于编程语言中的常数,尽管它们的确切值被优化自动调整,以使预测尽可能接近所需输出。

正确调整超参数对于许多机器学习模型正常工作并发挥其最高潜力至关重要,机器学习从业者花费大量时间来调整超参数。知道如何有效地调整超参数对于提高在构建自然语言处理和机器学习系统时的生产力有着巨大的影响。

10.5.1 超参数示例

超参数是“元”级别的参数——与模型参数不同,它们不用于进行预测,而是用于控制模型的结构以及模型的训练方式。例如,如果你正在处理词嵌入或者一个 RNN,那么用于表示单词的隐藏单元(维度)的数量就是一个重要的超参数。使用的 RNN 层数是另一个超参数。除了这两个超参数(隐藏单元和层数)之外,我们在第九章中介绍的 Transformer 模型还有一些其他参数,比如注意力头的数量和前馈网络的维度。甚至你使用的架构类型,例如 RNN 与 Transformer,也可以被视为一个超参数。

此外,您使用的优化算法也可能有超参数。例如,在许多机器学习设置中最重要的超参数之一——学习率(第 9.3.3 节),确定了每个优化步骤中调整模型参数的程度。迭代次数(通过训练数据集的次数)也是一个重要的超参数。

到目前为止,我们对这些超参数几乎没有给予任何关注,更不用说优化它们了。然而,超参数对机器学习模型的性能有着巨大的影响。事实上,许多机器学习模型都有一个“甜蜜点”超参数,使它们最有效,而使用超参数集在这个点之外可能会使模型表现不佳。

许多机器学习从业者通过手动调整超参数来调整超参数。这意味着你从一组看起来合理的超参数开始,并在验证集上测量模型的性能。然后,您稍微改变一个或多个超参数,并再次测量性能。您重复这个过程几次,直到达到“高原”,在这里任何超参数的更改都只提供了边际改进。

这种手动调整方法的一个问题是它是缓慢和随意的。假设你从一组超参数开始。你如何知道接下来应该调整哪些参数,以及多少?你如何知道何时停止?如果你有调整广泛的机器学习模型的经验,你可能对这些模型如何响应某些超参数更改有一些“直觉”,但如果没有,那就像在黑暗中射击一样。超参数调整是一个非常重要的主题,机器学习研究人员一直致力于寻找更好和更有组织的方法来优化它们。

10.5.2 网格搜索 vs. 随机搜索

我们明白手动优化超参数效率低下,但是我们应该如何进行优化呢?我们有两种更有组织的调整超参数的方式——网格搜索和随机搜索。

网格搜索中,你只需尝试优化的超参数值的每种可能组合。例如,假设你的模型只有两个超参数——RNN 层数和嵌入维度。你首先为这两个超参数定义合理的范围,例如,层数为[1, 2, 3],维度为[128, 256, 512]。然后,网格搜索会对每种组合进行模型验证性能的测量——(1, 128), (1, 256), (1, 512), (2, 128), . . . , (3, 512)——并简单选择表现最佳的组合。如果你将这些组合绘制在二维图上,它看起来像一个网格(见图 10.15 的示例),这就是为什么称之为网格搜索

网格搜索是优化超参数的一种简单直观的方式。然而,如果你有很多超参数和/或它们的范围很大,这种方法就会失控。可能的组合数量是指数级的,这使得在合理的时间内探索所有组合变得不可能。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.15 网格搜索与随机搜索的超参数调优比较。(摘自 Bergstra 和 Bengio,2012;www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf.

网格搜索更好的替代方案是随机搜索。在随机搜索中,你不是尝试每种可能的超参数值的组合,而是随机抽样这些值,并在指定数量的组合(称为试验)上测量模型的性能。例如,在上述示例中,随机搜索可以选择(2, 87), (1, 339), (2, 101), (3, 254)等,直到达到指定数量的试验为止。请参见图 10.15 的示例(右侧)。

除非你的超参数搜索空间非常小(就像第一个示例一样),如果你想要高效地优化超参数,通常建议使用随机搜索而不是网格搜索。为什么?在许多机器学习设置中,并非每个超参数都是相等的——通常只有少数几个超参数实际上对性能有影响,而其他许多超参数则不然。网格搜索会浪费大量计算资源来寻找并不真正重要的超参数组合,同时无法详细探索那些真正重要的少数超参数(图 10.15,左侧)。另一方面,随机搜索可以在性能重要的轴上探索许多可能的点(图 10.15,右侧)。请注意,随机搜索可以通过在相同的试验数量下在 x 轴上探索更多点来找到更好的模型(总共九个试验)。

10.5.3 使用 Optuna 进行超参数调优

好的,我们已经介绍了一些调整超参数的方法,包括手动、网格和随机搜索,但是在实践中应该如何实现呢?你可以随时编写自己的 for 循环(或者在网格搜索的情况下是“for-loops”),尽管如果你需要为每个模型和任务编写这种样板代码,这将很快变得令人厌倦。

超参数优化是一个普遍的主题,许多机器学习研究人员和工程师一直在致力于改进算法和软件库。例如,AllenNLP 有自己的库叫做Allentunegithub.com/allenai/allentune),你可以很容易地将其与 AllenNLP 的训练流程集成起来。然而,在本节的剩余部分中,我将介绍另一个超参数调整库叫做Optunaoptuna.org/),并展示如何将其与 AllenNLP 一起使用以优化你的超参数。Optuna 实现了最先进的算法,可以高效地搜索最优超参数,并与包括 TensorFlow、PyTorch 和 AllenNLP 在内的广泛的机器学习框架集成。

首先,我们假设你已经安装了 AllenNLP(1.0.0+)和 AllenNLP 的 Optuna 插件。你可以通过运行以下命令来安装它们:

pip install allennlp
pip install allennlp_optuna

此外,根据官方文档的指示(github.com/himkt/allennlp -optuna),你需要运行下面的代码来注册 AllenNLP 的插件:

echo 'allennlp_optuna' >> .allennlp_plugins

我们将使用第二章中构建的基于 LSTM 的分类器对斯坦福情感树库数据集进行分类。你可以在书的代码库中找到 AllenNLP 的配置文件(www.realworldnlpbook.com/ch10.html#config)。注意,你需要引用变量(std.extVar)以便 Optuna 可以控制参数。具体来说,你需要在配置文件的开头定义它们:

local embedding_dim = std.parseJson(std.extVar('embedding_dim'));
local hidden_dim = std.parseJson(std.extVar('hidden_dim'));
local lr = std.parseJson(std.extVar('lr'));

然后,你需要告诉 Optuna 要优化哪些参数。你可以通过编写一个 JSON 文件(hparams.json (www.realworldnlpbook.com/ch10.html# hparams)来实现这一点。你需要指定你希望 Optuna 优化的每个超参数及其类型和范围,如下所示:

[
    {
        "type": "int",
        "attributes": {
            "name": "embedding_dim",
            "low": 64,
            "high": 256
        }
    },
    {
        "type": "int",
        "attributes": {
            "name": "hidden_dim",
            "low": 64,
            "high": 256
        }
    },
    {
        "type": "float",
        "attributes": {
            "name": "lr",
            "low": 1e-4,
            "high": 1e-1,
            "log": true
        }
    }
]

接下来,调用这个命令来开始优化:

allennlp tune \
    examples/tuning/sst_classifier.jsonnet \
    examples/tuning/hparams.json \
    --include-package examples \
    --serialization-dir result \
    --study-name sst-lstm \
    --n-trials 20 \
    --metrics best_validation_accuracy \
    --direction maximize

注意我们正在运行 20 次试验(—n-trials),以最大化验证准确性(—metrics best_validation_accuracy)作为度量标准(—direction maximize)。如果你没有指定度量标准和方向,Optuna 默认尝试最小化验证损失。

这将需要一些时间,但是在所有试验完成后,你将看到以下优化的一行摘要:

Trial 19 finished with value: 0.3469573115349682 and parameters: {'embedding_dim': 120, 'hidden_dim': 82, 'lr': 0.00011044322486693224}. Best is trial 14 with value: 0.3869209809264305.

最后,Optuna 支持广泛的优化结果可视化,包括非常好的等高线图(www.realworldnlpbook.com/ch10.html# contour),但在这里我们将简单地使用其基于 Web 的仪表板快速检查优化过程。你只需要按照以下命令从命令行调用其仪表板:

optuna dashboard --study-name sst-lstm --storage sqlite:///allennlp_optuna.db

现在,你可以访问 http:/./localhost:5006/dashboard 来查看仪表板,如图 10.16 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.16 Optuna 仪表板显示了每个试验的参数评估指标。

从这个仪表板上,你不仅可以迅速看到最优试验是第 14 次试验,而且可以看到每次试验的最优超参数。

摘要

  • 实例被排序、填充和批量化以进行更有效的计算。

  • 子单词分词算法(如 BPE)将单词拆分成比单词更小的单元,以减轻神经网络模型中的词汇外问题。

  • 正则化(如 L2 和 dropout)是一种用于鼓励机器学习中模型简单性和可泛化性的技术。

  • 你可以使用数据上采样、下采样或损失权重来解决数据不平衡问题。

  • 超参数是关于模型或训练算法的参数。可以通过手动、网格或随机搜索进行优化。更好的是,使用超参数优化库,如 Optuna,它与 AllenNLP 集成得很容易。

^(1.)Wu 等人,“谷歌神经机器翻译系统:填补人机翻译之间的差距”(2016)。arxiv.org/abs/1609.08144

^(2.)Kudo,“Subword Regularization:使用多个子单词提高神经网络翻译模型”(2018)。arxiv.org/abs/1804.10959

^(3.)Sennrich 等人,“使用子单词单元进行稀有词的神经机器翻译”(2016)。arxiv.org/abs/1508.07909

^(4.)参见www.derczynski.com/papers/archive/BPE_Gage.pdf

^(5.)Chawla 等人,“SMOTE:合成少数类过采样技术”(2002)。arxiv.org/abs/1106.1813

第十一章:部署和提供 NLP 应用程序

本章涵盖

  • 选择适合您的 NLP 应用程序的正确架构

  • 版本控制您的代码、数据和模型

  • 部署和提供您的 NLP 模型

  • 使用 LIT(Language Interpretability Tool)解释和分析模型预测

本书的第 1 至 10 章是关于构建 NLP 模型的,而本章涵盖的是不在 NLP 模型之外发生的一切。为什么这很重要?难道 NLP 不都是关于构建高质量的 ML 模型吗?如果您没有太多生产 NLP 系统的经验,这可能会让您感到惊讶,但典型现实世界的 ML 系统的很大一部分与 NLP 几乎没有关系。如图 11.1 所示,典型实际 ML 系统的只有一小部分是 ML 代码,但“ML 代码”部分由提供各种功能的许多组件支持,包括数据收集、特征提取和服务。让我们用核电站作为类比。在操作核电站时,只有一小部分涉及核反应。其他一切都是支持安全有效地生成和传输材料和电力的庞大而复杂的基础设施——如何利用生成的热量转动涡轮发电,如何安全冷却和循环水,如何高效传输电力等等。所有这些支持基础设施与核物理几乎无关。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.1 一个典型的 ML 系统由许多不同的组件组成,而 ML 代码只是其中的一小部分。我们在本章中介绍了突出显示的组件。

部分原因是由于大众媒体上的“人工智能炒作”,我个人认为人们过分关注 ML 建模部分,而对如何以有用的方式为模型提供服务关注不足。毕竟,您的产品的目标是向用户提供价值,而不是仅仅为他们提供模型的原始预测。即使您的模型准确率达到 99%,如果您无法充分利用预测,使用户受益,那么它就没有用。用之前的类比来说,用户想要用电来驱动家用电器并照亮房屋,而不太在意电是如何生成的。

在本章的其余部分,我们将讨论如何构建您的 NLP 应用程序——我们侧重于在可靠和有效的方式设计和开发 NLP 应用程序时的一些最佳实践。然后我们谈论部署您的 NLP 模型——这是我们如何将 NLP 模型投入生产并提供其预测的方法。

11.1 构建您的 NLP 应用程序架构

机器学习工程仍然是软件工程。所有最佳实践(解耦的软件架构、设计良好的抽象、清晰易读的代码、版本控制、持续集成等)同样适用于 ML 工程。在本节中,我们将讨论一些特定于设计和构建 NLP/ML 应用程序的最佳实践。

11.1.1 机器学习之前

我明白这是一本关于 NLP 和 ML 的书,但在您开始着手处理您的 NLP 应用程序之前,您应该认真考虑您是否真的需要 ML 来解决您的产品问题。构建一个 ML 系统并不容易——需要花费大量的时间和金钱来收集数据、训练模型和提供预测。如果您可以通过编写一些规则来解决问题,那就这样做吧。作为一个经验法则,如果深度学习模型可以达到 80% 的准确率,那么一个更简单的基于规则的模型至少可以将您带到一半的路上。

此外,如果有现成的解决方案,您应该考虑使用。许多开源的 NLP 库(包括我们在整本书中广泛使用的 AllenNLP 和 Transformers 两个库)都提供了各种预训练模型。云服务提供商(如 AWS AI 服务 (aws.amazon.com/machine-learning/ai-services/)、Google Cloud AutoML (cloud.google.com/automl) 和 Microsoft Azure Cognitive Services (azure.microsoft.com/en-us/services/cognitive-services/))为许多领域提供了广泛的与 ML 相关的 API,包括 NLP。如果您的任务可以通过它们提供的解决方案进行零或少量修改来解决,那通常是构建 NLP 应用的一种成本效益较高的方式。毕竟,任何 NLP 应用程序中最昂贵的组件通常是高技能人才(即您的工资),在您全力投入并构建内部 NLP 解决方案之前,您应该三思而后行。

此外,您不应排除“传统”的机器学习方法。在本书中,我们很少关注传统的 ML 模型,但在深度 NLP 方法出现之前,您可以找到丰富的统计 NLP 模型的文献。使用统计特征(例如 n-gram)和 ML 模型(例如 SVM)快速构建原型通常是一个很好的开始。非深度学习算法,例如 梯度提升决策树(GBDT),通常以比深度学习方法更低的成本几乎同样有效,如果不是更好。

最后,我始终建议从开发验证集和选择正确的评估指标开始,甚至在开始选择正确的 ML 方法之前。验证集不需要很大,大多数人都可以抽出几个小时手动注释几百个实例。这样做有很多好处——首先,通过手动解决任务,你可以感受到在解决问题时什么是重要的,以及是否真的可以自动解决。其次,通过把自己置于机器的角度,你可以获得许多关于任务的见解(数据是什么样子,输入和输出数据是如何分布的,它们是如何相关的),这在实际设计 ML 系统来解决它时变得有价值。

11.1.2 选择正确的架构

除了极少数情况下,ML 系统的输出本身就是最终产品(比如机器翻译)之外,NLP 模块通常与一个更大的系统交互,共同为最终用户提供一些价值。例如,垃圾邮件过滤器通常被实现为嵌入在更大的应用程序(邮件服务)中的模块或微服务。语音助手系统通常是许多 ML/NLP 子组件的大型、复杂组合,包括语音识别、句子意图分类、问答和语音生成,它们相互交互。即使是机器翻译模型,如果包括数据管道、后端和最终用户交互的翻译界面,也可以是更大复杂系统中的一个小组件。

NLP 应用可以采取多种形式。令人惊讶的是,许多 NLP 组件可以被构造为一次性任务,它以一些静态数据作为输入,产生转换后的数据作为输出。例如,如果你有一组文档的静态数据库,并且想要按其主题对它们进行分类,你的 NLP 分类器可以是一个简单的一次性 Python 脚本,运行这个分类任务。如果你想要从同一数据库中提取通用实体(例如公司名称),你可以编写一个 Python 脚本来运行一个命名实体识别(NER)模型来实现。甚至一个基于文本相似度找到对象的文本推荐引擎也可以是一个每日任务,它从数据库读取数据并写入数据。你不需要设计一个复杂的软件系统,其中有许多服务相互交流。

许多其他 NLP 组件可以被构造成批量运行预测的(微)服务,这是我推荐的许多场景的架构。例如,垃圾邮件过滤器并不需要在每封邮件到达时立即对其进行分类 - 系统可以将到达系统的一定数量的邮件排队,并将批处理的邮件传递给分类器服务。NLP 应用程序通常通过某种中介(例如 RESTful API 或排队系统)与系统的其余部分进行通信。这种配置非常适合需要对其预测保持一定新鲜度的应用程序(毕竟,用户不希望等待几个小时直到他们的电子邮件到达收件箱),但要求并不那么严格。

最后,NLP 组件也可以设计成为提供实时预测的方式。例如,当观众需要演讲的实时字幕时,这是必要的。另一个例子是当系统想要根据用户的实时行为显示广告时。对于这些情况,NLP 服务需要接收一系列输入数据(如音频或用户事件),并生成另一系列数据(如转录文本或广告点击概率)。诸如 Apache Flink (flink.apache.org/) 这样的实时流处理框架经常用于处理此类流数据。另外,如果您的应用程序基于服务器-客户端架构,例如典型的移动和 Web 应用程序,并且您想向用户显示一些实时预测,您可以选择在客户端上运行 ML/NLP 模型,例如 Web 浏览器或智能手机。诸如 TensorFlow.js (www.tensorflow.org/js)、Core ML (developer.apple.com/documentation/coreml) 和 ML Kit (developers.google.com/ml-kit) 这样的客户端 ML 框架可用于此类目的。

11.1.3 项目结构

许多 NLP 应用程序遵循着类似的项目结构。一个典型的 NLP 项目可能需要管理数据集以从中训练模型,预处理数据生成的中间文件,由训练产生的模型文件,用于训练和推断的源代码,以及存储有关训练和推断的其他信息的日志文件。

因为典型的 NLP 应用程序有许多共同的组件和目录,所以如果您在启动新项目时只是遵循最佳实践作为默认选择,那将是有用的。以下是我为组织您的 NLP 项目提出的建议:

  • 数据管理—创建一个名为 data 的目录,并将所有数据放入其中。将其进一步细分为原始、中间和结果目录可能也会有所帮助。原始目录包含您外部获取的未经处理的数据集文件(例如我们在本书中一直在使用的斯坦福情感树库)或内部构建的文件。非常重要的一点是不要手动修改此原始目录中的任何文件。如果需要进行更改,请编写一个运行一些处理以针对原始文件运行的脚本,然后将结果写入中间目录的脚本,该目录用作中间结果的存储位置。或者创建一个管理您对原始文件进行的“差异”的补丁文件,并将补丁文件进行版本控制。最终的结果,例如预测和指标,应存储在结果目录中。

  • 虚拟环境—强烈建议您在虚拟环境中工作,以便您的依赖项分开且可重现。您可以使用诸如 Conda (docs.conda.io/en/latest/)(我推荐的)和 venv (docs.python.org/3/library/venv.html) 等工具为您的项目设置一个单独的环境,并使用 pip 安装单个软件包。Conda 可以将环境配置导出到一个 environment.yml 文件中,您可以使用该文件来恢复确切的 Conda 环境。您还可以将项目的 pip 包跟踪在一个 requirements.txt 文件中。更好的是,您可以使用 Docker 容器来管理和打包整个 ML 环境。这极大地减少了与依赖项相关的问题,并简化了部署和服务化。

  • 实验管理—NLP 应用程序的训练和推理管道通常包括多个步骤,例如预处理和连接数据,将其转换为特征,训练和运行模型,以及将结果转换回人类可读格式。如果试图手动记住管理这些步骤,很容易失控。一个好的做法是在一个 shell 脚本文件中跟踪管道的步骤,以便只需一个命令即可重现实验,或者使用依赖管理软件,如 GNU Make、Luigi (github.com/spotify/luigi) 和 Apache Airflow (airflow.apache.org/)。

  • 源代码—Python 源代码通常放在与项目同名的目录中,该目录进一步细分为诸如 data(用于数据处理代码)、model(用于模型代码)和 scripts(用于放置用于训练和其他一次性任务的脚本)等目录。

11.1.4 版本控制

您可能不需要说服您版本控制您的源代码很重要。像 Git 这样的工具帮助您跟踪变更并管理源代码的不同版本。NLP/ML 应用程序的开发通常是一个迭代过程,在此过程中,您(通常与其他人)对源代码进行许多更改,并尝试许多不同的模型。您很容易最终拥有一些略有不同版本的相同代码。

除了对源代码进行版本控制外,对数据和模型进行版本控制也很重要。这意味着您应该分别对训练数据、源代码和模型进行版本控制,如图 11.2 中虚线框所示。这是常规软件项目和机器学习应用之间的主要区别之一。机器学习是通过数据改进计算机算法的过程。根据定义,任何机器学习系统的行为都取决于其所接收的数据。这可能会导致即使您使用相同的代码,系统的行为也会有所不同的情况。

工具如 Git Large File Storage (git-lfs.github.com/)和 DVC (dvc.org)可以以无缝的方式对数据和模型进行版本控制。即使您不使用这些工具,您也应该至少将不同版本作为清晰命名的单独文件进行管理。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.2 机器学习组件的版本控制:训练数据、源代码和模型

在一个更大更复杂的机器学习项目中,您可能希望将模型和特征管道的版本控制分开,因为机器学习模型的行为可能会因为您对输入进行预处理的方式不同而不同,即使是相同的模型和输入数据。这也将减轻我们稍后将在 11.3.2 节讨论的训练服务偏差问题。

最后,当您在机器学习应用上工作时,您将尝试许多不同的设置——不同的训练数据集、特征管道、模型和超参数的组合——这可能会很难控制。我建议您使用一些实验管理系统来跟踪训练设置,例如 Weights & Biases (wandb.ai/),但您也可以使用像手动输入实验信息的电子表格这样简单的东西。在跟踪实验时,请务必记录每个实验的以下信息:

  • 使用的模型代码版本、特征管道和训练数据的版本

  • 用于训练模型的超参数

  • 训练数据和验证数据的评估指标

像 AllenNLP 这样的平台默认支持实验配置,这使得前两项变得容易。工具如 TensorBoard,它们默认由 AllenNLP 和 Hugging Face 支持,使得跟踪各种指标变得轻而易举。

11.2 部署您的 NLP 模型

在本节中,我们将进入部署阶段,将您的 NLP 应用程序放在服务器上,并可供使用。我们将讨论部署 NLP/ML 应用程序时的实际考虑因素。

11.2.1 测试

与软件工程一样,测试是构建可靠的 NLP/ML 应用程序的重要组成部分。最基本和重要的测试是单元测试,它们自动检查软件的小单元(如方法和类)是否按预期工作。在 NLP/ML 应用程序中,对功能管道进行单元测试非常重要。例如,如果你编写了一个将原始文本转换为张量表示的方法,请确保它在典型和边界情况下都能正常工作。根据我的经验,这往往是错误 sneak in 的地方。从数据集读取、从语料库构建词汇表、标记化、将标记转换为整数 ID —— 这些都是预处理中必不可少但容易出错的步骤。幸运的是,诸如 AllenNLP 等框架为这些步骤提供了标准化、经过充分测试的组件,这使得构建 NLP 应用程序更加容易和无 bug。

除了单元测试之外,你还需要确保你的模型学到了它应该学到的东西。这对应于测试常规软件工程中的逻辑错误 —— 即软件运行时没有崩溃但产生了不正确的结果的错误类型。这种类型的错误在 NLP/ML 中更难捕捉和修复,因为你需要更多的了解学习算法在数学上是如何工作的。此外,许多 ML 算法涉及一些随机性,如随机初始化和抽样,这使得测试变得更加困难。

一个推荐的测试 NLP/ML 模型的技术是对模型输出进行 sanity checks。你可以从一个小而简单的模型开始,只使用几个带有明显标签的玩具实例。例如,如果你正在测试情感分析模型,可以按照以下步骤进行:

  • 为调试创建一个小而简单的模型,比如一个简单的玩具编码器,它只是将输入的单词嵌入平均化,并在顶部使用一个 softmax 层。

  • 准备一些玩具实例,比如“最棒的电影!”(积极)和“这是一部糟糕的电影!”(消极)。

  • 将这些实例提供给模型,并训练直到收敛。由于我们使用的是一个非常小的数据集,没有验证集,所以模型会严重过拟合到这些实例上,这完全可以接受。检查训练损失是否如预期下降。

  • 将相同的实例提供给训练好的模型,检查预测的标签是否与预期的标签匹配。

  • 使用更多玩具实例和更大的模型尝试上述步骤。

作为一种相关技术,我总是建议您从较小的数据集开始,特别是如果原始数据集很大。因为训练自然语言处理/机器学习模型需要很长时间(几小时甚至几天),您经常会发现只有在训练完成后才能发现代码中的一些错误。您可以对训练数据进行子采样,例如,只需取出每 10 个实例中的一个,以便整个训练过程能够迅速完成。一旦您确信您的模型按预期工作,您可以逐渐增加用于训练的数据量。这种技术也非常适合快速迭代和尝试许多不同的架构和超参数设置。当您刚开始构建模型时,您通常不清楚最适合您任务的最佳模型。有了较小的数据集,您可以快速验证许多不同的选项(RNN 与 Transformers,不同的分词器等),并缩小最适合的候选模型集。这种方法的一个警告是,最佳模型架构和超参数可能取决于训练数据的大小。因此,请不要忘记针对完整数据集运行验证。

最后,您可以使用集成测试来验证应用程序的各个组件是否结合正常工作。对于自然语言处理(NLP),这通常意味着运行整个流程,以查看预测是否正确。与单元测试类似,您可以准备一小部分实例,其中期望的预测是明确的,并将它们运行到经过训练的模型上。请注意,这些实例不是用于衡量模型的好坏,而是作为一个合理性检查,以确定您的模型是否能够为“显而易见”的情况产生正确的预测。每次部署新模型或代码时运行集成测试是一个好习惯。这通常是用于常规软件工程的持续集成(CI)的一部分。

11.2.2 训练-服务偏差

机器学习应用中常见的错误来源之一被称为训练-服务偏差,即在训练和推理时实例处理方式存在差异的情况。这可能发生在各种情况下,但让我们讨论一个具体的例子。假设您正在使用 AllenNLP 构建一个情感分析系统,并希望将文本转换为实例。您通常首先编写一个数据加载器,它读取数据集并生成实例。然后您编写一个 Python 脚本或配置文件,告诉 AllenNLP 模型应该如何训练。您对模型进行训练和验证。到目前为止,一切顺利。然而,当使用模型进行预测时,情况略有不同。您需要编写一个预测器,它会将输入文本转换为实例,并将其传递给模型的前向方法。请注意,现在您有两个独立的流程来预处理输入——一个用于数据集读取器中的训练,另一个用于预测器中的推理。

如果你想修改输入文本处理的方式会发生什么?例如,假设你发现了你想改进的分词过程中的某些内容,并且你在数据加载器中修改了输入文本的分词方式。你更新了数据加载器代码,重新训练了模型,并部署了模型。然而,你忘记了在预测器中更新相应的分词代码,实际上在训练和服务之间创建了一个输入分词方式不一致的差异。这在图 11.3 中有所说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.3 训练-服务偏差是由于训练和服务之间输入处理方式的差异而引起的。

修复这个问题的最佳方法——甚至更好的是,第一次就预防它发生——是在训练和服务基础设施之间尽可能共享特征管道。在 AllenNLP 中的一种常见做法是在数据集读取器中实现一个名为 _text_to_instance()的方法,它接受一个输入并返回一个实例。通过确保数据集读取器和预测器都引用同一个方法,你可以尽量减少管道之间的差异。

在 NLP 中,输入文本被分词并转换为数字值的事实使得调试模型变得更加困难。例如,一个在分词中明显的错误,你可以用肉眼轻松发现,但如果一切都是数字值,那么很难识别。一个好的做法是将一些中间结果记录到一个日志文件中,以便稍后检查。

最后,请注意,神经网络在训练和服务之间的一些行为是不同的。一个显著的例子是dropout,一个我们在第 10.3.1 节中简要介绍过的正则化方法。简而言之,dropout 通过在神经网络中随机屏蔽激活值来对模型进行正则化。这在训练中是有道理的,因为通过去除激活,模型学会根据可用值做出稳健的预测。但是,请记住在服务时关闭它,因为你不希望你的模型随机丢弃神经元。PyTorch 模型实现了 train()和 eval()等方法,可以在训练和预测模式之间切换,从而影响像 dropout 这样的层的行为。如果你手动实现了训练循环,请记住调用 model.eval()来禁用 dropout。好消息是,诸如 AllenNLP 之类的框架可以自动处理这个问题,只要你使用它们的默认训练器。

11.2.3 监控

与其他软件服务一样,部署的 ML 系统应该持续监控。除了通常的服务器指标(例如,CPU 和内存使用率)之外,您还应该监视与模型的输入和输出相关的指标。具体来说,您可以监视一些高级统计信息,如输入值和输出标签的分布。正如前面提到的,逻辑错误是一种导致模型产生错误结果但不会崩溃的错误类型,在 ML 系统中最常见且最难找到。监控这些高级统计信息可以更容易地找到它们。像 PyTorch Serve 和 Amazon SageMaker(在第 11.3 节讨论)这样的库和平台默认支持监控。

11.2.4 使用 GPU

训练大型现代 ML 模型几乎总是需要像 GPU 这样的硬件加速器。回想一下第二章中,我们将海外工厂比作了 GPU 的类比,GPU 设计用于并行执行大量的算术运算,如向量和矩阵的加法和乘法。在本小节中,我们将介绍如何使用 GPU 加速 ML 模型的训练和预测。

如果您没有自己的 GPU 或以前从未使用过基于云的 GPU 解决方案,免费“尝试” GPU 的最简单方法是使用 Google Colab。转到其 URL(colab.research.google.com/),创建一个新笔记本,转到“运行时”菜单,并选择“更改运行时类型”。这将弹出如图 11.4 所示的对话框。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.4 Google Colab 允许您选择硬件加速器的类型。

选择 GPU 作为硬件加速器的类型,并在代码块中输入 !nvidia-smi 并执行它。将显示一些关于您的 GPU 的详细信息,如下所示:

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 460.56       Driver Version: 460.32.03    CUDA Version: 11.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   39C    P8     9W /  70W |      3MiB / 15109MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

nvidia-smi 命令(简称 Nvidia 系统管理接口)是一个方便的工具,用于检查机器上 Nvidia GPU 的信息。从上面的代码片段中,您可以看到驱动程序和 CUDA(一种用于与 GPU 交互的 API 和库)的版本、GPU 类型(Tesla T4)、可用和已使用的内存(15109 MiB 和 3 MiB),以及当前使用 GPU 的进程列表(没有)。这个命令的最典型用法是检查当前进程使用了多少内存,因为在 GPU 编程中,如果您的程序使用的内存超过了可用内存,很容易出现内存不足的错误。

如果你使用云基础设施,比如 AWS(Amazon Web Services)和 GCP(Google Cloud Platform),你会发现有很多虚拟机模板,可以用来快速创建支持 GPU 的云实例。例如,GCP 提供了 Nvidia 官方的 GPU 优化图像,可以用作模板来启动 GPU 实例。AWS 提供了深度学习 AMIs(Amazon Machine Images),预先安装了基本的 GPU 库,如 CUDA,以及深度学习库,如 PyTorch。使用这些模板时,你不需要手动安装必要的驱动程序和库——你可以直接开始构建你的 ML 应用程序。请注意,尽管这些模板是免费的,但你需要为基础设施付费。支持 GPU 的虚拟机的价格通常比 CPU 机器高得多。在长时间运行之前,请确保检查它们的价格。

如果你要从头开始设置 GPU 实例,你可以找到详细的说明 ¹ 来设置必要的驱动程序和库。要使用本书中介绍的库(即,AllenNLP 和 Transformers)构建 NLP 应用程序,你需要安装 CUDA 驱动程序和工具包,以及支持 GPU 的 PyTorch 版本。

如果你的机器有 GPU,你可以通过在 AllenNLP 配置文件中指定 cuda_device 来启用 GPU 加速,如下所示:

    "trainer": {
        "optimizer": {
            "type": "huggingface_adamw",
            "lr": 1.0e-5
        },
        "num_epochs": 20,
        "patience": 10,
        "cuda_device": 0
}

这告诉训练器使用第一个 GPU 训练和验证 AllenNLP 模型。

如果你要从头开始编写 PyTorch 代码,你需要手动将模型和张量转移到 GPU 上。用比喻来说,这就像是你的材料被运往海外工厂的集装箱船上。首先,你可以指定要使用的设备(GPU ID),并调用张量和模型的 to()方法在设备之间移动它们。例如,你可以使用以下代码片段在使用 Hugging Face Transformers 的 GPU 上运行文本生成:

device = torch.device('cuda:0')
tokenizer = AutoTokenizer.from_pretrained("gpt2-large")
model = AutoModelWithLMHead.from_pretrained("gpt2-large")

generated = tokenizer.encode("On our way to the beach ")
context = torch.tensor([generated])

model = model.to(device)
context = context.to(device)

其余的与我们在第 8.4 节中使用的代码相同。

11.3 案例研究:提供和部署 NLP 应用程序

在本节中,我们将对一个案例研究进行概述,在其中,我们使用 Hugging Face 构建了一个 NLP 模型。具体地说,我们将使用预训练的语言生成模型(DistilGPT2),使用 TorchServe 进行服务,并使用 Amazon SageMaker 部署到云服务器。

11.3.1 用 TorchServe 提供模型

如你所见,部署 NLP 应用程序不仅仅是编写 ML 模型的 API。你需要考虑许多与生产相关的问题,包括如何使用多个 worker 并行化模型推理来处理高流量,如何存储和管理多个 ML 模型的不同版本,如何一致地处理数据的预处理和后处理,并且如何监视服务器的健康状况以及数据的各种指标。

由于这些问题如此常见,机器学习从业者一直在研究用于服务和部署机器学习模型的通用平台。在本节中,我们将使用 TorchServe (github.com/pytorch/serve),这是一个由 Facebook 和 Amazon 共同开发的用于服务 PyTorch 模型的易于使用的框架。TorchServe 附带了许多功能,可以解决前面提到的问题。

TorchServe 可通过以下方式安装:

pip install torchserve torch-model-archiver

在这个案例研究中,我们将使用一个名为 DistilGPT2 的预训练语言模型。DistilGPT2 是使用一种称为 知识蒸馏 的技术构建的 GPT-2 的较小版本。知识蒸馏(或简称 蒸馏)是一种机器学习技术,其中一个较小的模型(称为 学生)被训练成以模仿一个较大模型(称为 教师)产生的预测。这是训练一个产生高质量输出的较小模型的绝佳方式,通常比从头开始训练一个较小模型产生更好的模型。

首先,让我们通过运行以下命令从 Hugging Face 仓库下载预训练的 DistilGPT2 模型。请注意,您需要安装 Git Large File Storage (git-lfs.github.com/),这是一个用于处理 Git 下大文件的 Git 扩展:

git lfs install
git clone https://huggingface.co/distilgpt2

这将创建一个名为 distilgpt2 的子目录,其中包含 config.json 和 pytorch_model.bin 等文件。

接下来,您需要为 TorchServe 编写一个处理程序,这是一个轻量级的包装类,指定了如何初始化您的模型、预处理和后处理输入以及对输入进行推断。清单 11.1 显示了用于服务 DistilGPT2 模型的处理程序代码。实际上,处理程序中的任何内容都不特定于我们使用的特定模型(DistilGPT2)。只要使用 Transformers 库,您就可以将相同的代码用于其他类似 GPT-2 的模型,包括原始的 GPT-2 模型。

清单 11.1 TorchServe 的处理程序

from abc import ABC
import logging

import torch
from ts.torch_handler.base_handler import BaseHandler

from transformers import GPT2LMHeadModel, GPT2Tokenizer

logger = logging.getLogger(__name__)

class TransformersLanguageModelHandler(BaseHandler, ABC):
    def __init__(self):
        super(TransformersLanguageModelHandler, self).__init__()
        self.initialized = False
        self.length = 256
        self.top_k = 0
        self.top_p = .9
        self.temperature = 1.
        self.repetition_penalty = 1.

    def initialize(self, ctx):                        ❶
        self.manifest = ctx.manifest
        properties = ctx.system_properties
        model_dir = properties.get("model_dir")
        self.device = torch.device(
            "cuda:" + str(properties.get("gpu_id"))
            if torch.cuda.is_available()
            else "cpu"
        )

        self.model = GPT2LMHeadModel.from_pretrained(model_dir)
        self.tokenizer = GPT2Tokenizer.from_pretrained(model_dir)

        self.model.to(self.device)
        self.model.eval()

        logger.info('Transformer model from path {0} loaded successfully'.format(model_dir))
        self.initialized = True

    def preprocess(self, data):                   ❷
        text = data[0].get("data")
        if text is None:
            text = data[0].get("body")
        text = text.decode('utf-8')

        logger.info("Received text: '%s'", text)

        encoded_text = self.tokenizer.encode(
            text,
            add_special_tokens=False,
            return_tensors="pt")

        return encoded_text

    def inference(self, inputs):                  ❸
        output_sequences = self.model.generate(
            input_ids=inputs.to(self.device),
            max_length=self.length + len(inputs[0]),
            temperature=self.temperature,
            top_k=self.top_k,
            top_p=self.top_p,
            repetition_penalty=self.repetition_penalty,
            do_sample=True,
            num_return_sequences=1,
        )

        text = self.tokenizer.decode(
            output_sequences[0],
            clean_up_tokenization_spaces=True)

        return [text]

    def postprocess(self, inference_output):return inference_output

_service = TransformersLanguageModelHandler()

def handle(data, context):try:
        if not _service.initialized:
            _service.initialize(context)

        if data is None:
            return None

        data = _service.preprocess(data)
        data = _service.inference(data)
        data = _service.postprocess(data)

        return data
    except Exception as e:
        raise e

❶ 初始化模型

❷ 对传入数据进行预处理和标记化

❸ 对数据进行推断

❹ 对预测进行后处理

❺ TorchServe 调用的处理程序方法

您的处理程序需要继承自 BaseHandler 并重写一些方法,包括 initialize() 和 inference()。您的处理程序脚本还包括 handle(),一个顶层方法,其中初始化和调用处理程序。

接下来要做的是运行 torch-model-archiver,这是一个命令行工具,用于打包您的模型和处理程序,具体操作如下:

torch-model-archiver \
    --model-name distilgpt2 \
    --version 1.0 \
    --serialized-file distilgpt2/pytorch_model.bin \
    --extra-files "distilgpt2/config.json,distilgpt2/vocab.json,distilgpt2/tokenizer.json,distilgpt2/merges.txt" \
    --handler ./torchserve_handler.py

前两个选项指定了模型的名称和版本。下一个选项 serialized-file 指定了您要打包的 PyTorch 模型的主要权重文件(通常以 .bin 或 .pt 结尾)。您还可以添加任何额外文件(由 extra-files 指定),这些文件是模型运行所需的。最后,您需要将刚编写的处理程序文件传递给 handler 选项。

完成后,这将在相同目录中创建一个名为 distilgpt2.mar(.mar 代表“模型归档”)的文件。让我们创建一个名为 model_store 的新目录,并将 .mar 文件移动到那里,如下所示。该目录用作模型存储库,所有模型文件都存储在其中并从中提供服务:

mkdir model_store
mv distilgpt2.mar model_store

现在您已经准备好启动 TorchServe 并开始为您的模型提供服务了!您只需运行以下命令:

torchserve --start --model-store model_store --models distilgpt2=distilgpt2.mar

当服务器完全启动后,您可以开始向服务器发出 HTTP 请求。它公开了几个端点,但如果您只想运行推断,您需要像下面这样调用 http://127.0.0.1:8080/predictions/ 并带上模型名称:

curl -d "data=In a shocking finding, scientist discovered a herd of unicorns living in a remote, previously unexplored valley, in the Andes Mountains. Even more surprising to the researchers was the fact that the unicorns spoke perfect English." -X POST http://127.0.0.1:8080/predictions/distilgpt2

在这里,我们使用了来自 OpenAI 关于 GPT-2 的原始帖子(openai.com/blog/better-language-models/)的提示。这将返回生成的句子,如下所示。考虑到该模型是精简的、较小版本,生成的文本质量还不错:

在一个令人震惊的发现中,科学家们发现了一群生活在安第斯山脉一个偏远、以前未被探索过的山谷的独角兽。更让研究人员感到惊讶的是,这些独角兽讲着一口流利的英语。他们在那里工作时曾说加泰罗尼亚语,所以这些独角兽不仅是当地群体的一部分,他们也是一个人口组成与他们以前的国家民族邻居相差不多的人群的一部分,这让人们对他们感到认同。

“在某种程度上,他们学得比他们原本可能学得更好,” 加州大学欧文分校的语言副教授安德烈亚·罗德里格斯说。“他们告诉我,其他人比他们想象的还要糟糕。”

像大多数研究一样,这些发现只会支持它们的母语。但它突显了独角兽和外国人之间令人难以置信的社会联系,特别是当他们被提供了一个新的困难的平台来研究和创造自己的语言时。

“找到这些人意味着了解彼此的细微差别,并更好地处理他们的残疾,” 罗德里格斯说。

当您完成时,您可以运行以下命令来停止服务:

torchserve --stop

11.3.2 使用 SageMaker 部署模型

Amazon SageMaker 是一个用于训练和部署机器学习模型的托管平台。它使您能够启动一个 GPU 服务器,在其中运行一个 Jupyter 笔记本,在那里构建和训练 ML 模型,并直接将它们部署在托管环境中。我们的下一步是将机器学习模型部署为云 SageMaker 端点,以便生产系统可以向其发出请求。使用 SageMaker 部署 ML 模型的具体步骤包括以下内容:

  1. 将您的模型上传到 S3。

  2. 注册并将推理代码上传到 Amazon Elastic Container Registry(ECR)。

  3. 创建一个 SageMaker 模型和一个端点。

  4. 向端点发出请求。

我们将按照官方教程(mng.bz/p9qK)稍作修改。首先,让我们转到 SageMaker 控制台(console.aws.amazon.com/sagemaker/home)并启动一个笔记本实例。当您打开笔记本时,请运行以下代码以安装必要的软件包并启动 SageMaker 会话:

!git clone https://github.com/shashankprasanna/torchserve-examples.git
!cd torchserve-examples

!git clone https://github.com/pytorch/serve.git
!pip install serve/model-archiver/

import boto3, time, json
sess    = boto3.Session()
sm      = sess.client('sagemaker')
region  = sess.region_name
account = boto3.client('sts').get_caller_identity().get('Account')

import sagemaker
role = sagemaker.get_execution_role()
sagemaker_session = sagemaker.Session(boto_session=sess)

bucket_name = sagemaker_session.default_bucket()

变量 bucket_name 包含一个类似于 sagemaker-xxx-yyy 的字符串,其中 xxx 是地区名称(如 us-east-1)。记下这个名称——您需要它来在下一步中将您的模型上传到 S3。

接下来,您需要通过从刚刚创建 .mar 文件的机器(而不是从 SageMaker 笔记本实例)运行以下命令来将您的模型上传到 S3 存储桶。在上传之前,您首先需要将您的 .mar 文件压缩成一个 tar.gz 文件,这是 SageMaker 支持的一种格式。记得用 bucket_name 指定的实际存储桶名称替换 sagemaker-xxx-yyy:

cd model_store
tar cvfz distilgpt2.tar.gz distilgpt2.mar
aws s3 cp distilgpt2.tar.gz s3://sagemaker-xxx-yyy/torchserve/models/

下一步是注册并将 TorchServe 推断代码推送到 ECR。在开始之前,在您的 SageMaker 笔记本实例中,打开 torchserve-examples/Dockerfile 并修改以下行(添加 —no-cache-dir transformers)。

RUN pip install --no-cache-dir psutil \
                --no-cache-dir torch \
                --no-cache-dir torchvision \
                --no-cache-dir transformers

现在您可以构建一个 Docker 容器并将其推送到 ECR,如下所示:

registry_name = 'torchserve'
!aws ecr create-repository --repository-name torchserve

image_label = 'v1'
image = f'{account}.dkr.ecr.{region}.amazonaws.com/{registry_name}:{image_label}'

!docker build -t {registry_name}:{image_label} .
!$(aws ecr get-login --no-include-email --region {region})
!docker tag {registry_name}:{image_label} {image}
!docker push {image}

现在您可以准备好创建一个 SageMaker 模型并为其创建一个端点,如下所示:

import sagemaker
from sagemaker.model import Model
from sagemaker.predictor import RealTimePredictor
role = sagemaker.get_execution_role()

model_file_name = 'distilgpt2'

model_data = f's3://{bucket_name}/torchserve/models/{model_file_name}.tar.gz'
sm_model_name = 'torchserve-distilgpt2'

torchserve_model = Model(model_data = model_data, 
                         image_uri = image,
                         role = role,
                         predictor_cls=RealTimePredictor,
                         name = sm_model_name)
endpoint_name = 'torchserve-endpoint-' + time.strftime("%Y-%m-%d-%H-%M-%S", time.gmtime())
predictor = torchserve_model.deploy(instance_type='ml.m4.xlarge',
                                    initial_instance_count=1,
                                    endpoint_name = endpoint_name)

预测器对象是可以直接调用以运行推断的,如下所示:

response = predictor.predict(data="In a shocking finding, scientist discovered a herd of unicorns living in a remote, previously unexplored valley, in the Andes Mountains. Even more surprising to the researchers was the fact that the unicorns spoke perfect English.")

响应内容应该类似于这样:

b'In a shocking finding, scientist discovered a herd of unicorns living in a remote, previously unexplored valley, in the Andes Mountains. Even more surprising to the researchers was the fact that the unicorns spoke perfect English. The unicorns said they would take a stroll in the direction of scientists over the next month or so.\n\n\n\n\nWhen contacted by Animal Life and Crop.com, author Enrique Martinez explained how he was discovered and how the unicorns\' journey has surprised him. According to Martinez, the experience makes him more interested in research and game development.\n"This is really what I want to see this year, and in terms of medical research, I want to see our population increase."<|endoftext|>'

恭喜!我们刚刚完成了我们的旅程——我们从第二章开始构建一个 ML 模型,并在本章中一直部署到了云平台。

11.4 解释和可视化模型预测

人们经常谈论标准化数据集上的指标和排行榜表现,但分析和可视化模型预测和内部状态对于现实世界中的自然语言处理应用非常重要。尽管深度学习模型在其所做的事情上可能非常出色,在某些自然语言处理任务上甚至达到了人类水平的性能,但这些深度模型是黑盒,很难知道它们为什么会做出某些预测。

因为这种(有些令人不安的)深度学习模型的属性,人工智能中的一个日益增长的领域称为可解释人工智能(XAI)正在努力开发方法来解释机器学习模型的预测和行为。解释机器学习模型对于调试非常有用——如果您知道它为什么做出某些预测,它会给您很多线索。在一些领域,如医疗应用和自动驾驶汽车,使机器学习模型可解释对于法律和实际原因至关重要。在本章的最后一节中,我们将介绍一个案例研究,在该案例研究中,我们使用语言可解释性工具(LIT)(pair-code.github.io/lit/)来可视化和解释自然语言处理模型的预测和行为。

LIT 是由 Google 开发的开源工具包,提供了一个基于浏览器的界面,用于解释和可视化 ML 预测。请注意,它是框架不可知的,这意味着它可以与任何选择的基于 Python 的 ML 框架一起使用,包括 AllenNLP 和 Hugging Face Transformers。LIT 提供了一系列功能,包括以下内容:

  • 显著性图——以彩色可视化输入的哪部分对达到当前预测起到了重要作用

  • 聚合统计信息——显示诸如数据集指标和混淆矩阵等聚合统计信息

  • 反事实——观察模型对生成的新样本的预测如何变化

在本节的其余部分,让我们选择我们训练的 AllenNLP 模型之一(第九章中基于 BERT 的情感分析模型)并通过 LIT 进行分析。LIT 提供了一组可扩展的抽象,如数据集和模型,以使使用任何基于 Python 的 ML 模型更加轻松。

首先,让我们安装 LIT。可以通过以下 pip 调用一次性安装它:

pip install lit-nlp

接下来,您需要使用 LIT 定义的抽象类包装您的数据集和模型。让我们创建一个名为 run_lit.py 的新脚本,并导入必要的模块和类,如下所示:

import numpy as np

from allennlp.models.archival import load_archive
from allennlp.predictors.predictor import Predictor
from lit_nlp import dev_server
from lit_nlp import server_flags
from lit_nlp.api import dataset as lit_dataset
from lit_nlp.api import model as lit_model
from lit_nlp.api import types as lit_types

from examples.sentiment.sst_classifier import LstmClassifier
from examples.sentiment.sst_reader import StanfordSentimentTreeBankDatasetReaderWithTokenizer

下面的代码展示了如何为 LIT 定义一个数据集。在这里,我们创建了一个仅包含四个硬编码示例的玩具数据集,但在实践中,您可能想要读取要探索的真实数据集。记得定义返回数据集类型规范的 spec() 方法:

class SSTData(lit_dataset.Dataset):
    def __init__(self, labels):
        self._labels = labels
        self._examples = [
            {'sentence': 'This is the best movie ever!!!', 'label': '4'},
            {'sentence': 'A good movie.', 'label': '3'},
            {'sentence': 'A mediocre movie.', 'label': '1'},
            {'sentence': 'It was such an awful movie...', 'label': '0'}
        ]

    def spec(self):
        return {
            'sentence': lit_types.TextSegment(),
            'label': lit_types.CategoryLabel(vocab=self._labels)
        }

现在,我们已经准备好定义主要模型了,如下所示。

列表 11.2 定义 LIT 的主要模型

class SentimentClassifierModel(lit_model.Model):
    def __init__(self):
        cuda_device = 0
        archive_file = 'model/model.tar.gz'
        predictor_name = 'sentence_classifier_predictor'

        archive = load_archive(                                       ❶
            archive_file=archive_file,
            cuda_device=cuda_device
        )

        predictor = Predictor.from_archive(archive, predictor_name=predictor_name)

        self.predictor = predictor                                    ❷
        label_map = archive.model.vocab.get_index_to_token_vocabulary('labels')
        self.labels = [label for _, label in sorted(label_map.items())]

    def predict_minibatch(self, inputs):
        for inst in inputs:
            pred = self.predictor.predict(inst['sentence'])           ❸
            tokens = self.predictor._tokenizer.tokenize(inst['sentence'])
            yield {
                'tokens': tokens,
                'probas': np.array(pred['probs']),
                'cls_emb': np.array(pred['cls_emb'])
            }

    def input_spec(self):
        return {
            "sentence": lit_types.TextSegment(),
            "label": lit_types.CategoryLabel(vocab=self.labels, required=False)
        }

    def output_spec(self):
        return {
            "tokens": lit_types.Tokens(),
            "probas": lit_types.MulticlassPreds(parent="label", vocab=self.labels),
            "cls_emb": lit_types.Embeddings()
        }

❶ 加载 AllenNLP 存档

❷ 提取并设置预测器

❸ 运行预测器的 predict 方法

在构造函数(init)中,我们正在从存档文件中加载一个 AllenNLP 模型,并从中创建一个预测器。我们假设您的模型放在 model/model.tar.gz 下,并且硬编码了其路径,但根据您的模型位置随意修改此路径。

模型预测是在 predict_minibatch() 中计算的。给定输入(简单地是数据集实例的数组),它通过预测器运行模型并返回结果。请注意,预测是逐个实例进行的,尽管在实践中,您应考虑批量进行预测,因为这会提高对较大输入数据的吞吐量。该方法还返回用于可视化嵌入的预测类别的嵌入(作为 cls_emb),这将用于可视化嵌入(图 11.5)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.5 LIT 可以显示显著性图、聚合统计信息和嵌入,以分析您的模型和预测。

最后,这是运行 LIT 服务器的代码:

model = SentimentClassifierModel()
models = {"sst": model}
datasets = {"sst": SSTData(labels=model.labels)}

lit_demo = dev_server.Server(models, datasets, **server_flags.get_flags())
lit_demo.serve()

运行上面的脚本后,转到 http:/./localhost:5432/ 在你的浏览器上。你应该会看到一个类似于图 11.5 的屏幕。你可以看到一系列面板,对应于有关数据和预测的各种信息,包括嵌入、数据集表和编辑器、分类结果以及显著性图(显示通过一种名为 LIME 的自动方法计算的标记贡献)³。

可视化和与模型预测进行交互是了解模型工作原理以及如何改进的好方法。

11.5 从这里开始去哪里

在本书中,我们只是浅尝了这个广阔而悠久的自然语言处理领域的表面。如果你对进一步学习 NLP 的实践方面感兴趣,Natural Language Processing in Action,作者是 Hobson Lane 和其他人(Manning Publications,2019),以及 Practical Natural Language Processing,作者是 Sowmya Vajjala 和其他人(O’Reilly,2020),可以成为下一个很好的步骤。Machine Learning Engineering,作者是 Andriy Burkov(True Positive Inc.,2020),也是学习机器学习工程主题的好书。

如果你对学习 NLP 的数学和理论方面更感兴趣,我建议你尝试一些流行的教材,比如 Speech and Language Processing,作者是 Dan Jurafsky 和 James H. Martin(Prentice Hall,2008)⁴,以及 Introduction to Natural Language Processing,作者是 Jacob Eisenstein(MIT Press,2019)。虽然 Foundations of Statistical Natural Language Processing,作者是 Christopher D. Manning 和 Hinrich Schütze(Cambridge,1999),有点过时,但它也是一本经典教材,可以为你提供广泛的 NLP 方法和模型打下坚实的基础。

也要记住,你通常可以免费在网上找到很棒的资源。一个免费的 AllenNLP 课程,“A Guide to Natural Language Processing with AllenNLP”(guide .allennlp.org/),以及 Hugging Face Transformers 的文档(huggingface.co/transformers/index.html)是学习这些库的深入了解的好地方。

最后,学习 NLP 最有效的方法实际上是自己动手。如果您的兴趣、工作或任何涉及处理自然语言文本的事情存在问题,请考虑您在本书中学到的任何技术是否适用。这是一个分类、标记还是序列到序列的问题?您使用哪些模型?您如何获得训练数据?您如何评估您的模型?如果您没有 NLP 问题,不用担心——请前往 Kaggle,在那里您可以找到许多与 NLP 相关的竞赛,您可以在处理真实世界问题时“动手”并获得 NLP 经验。NLP 会议和研讨会经常举办共享任务,参与者可以在共同任务、数据集和评估指标上进行竞争,这也是一个很好的学习方法,如果您想深入研究 NLP 的某个特定领域。

概要

  • 在现实世界的 NLP/ML 系统中,机器学习代码通常只是一个小部分,支持着复杂的基础设施,用于数据收集、特征提取以及模型服务和监控。

  • NLP 模块可以开发为一次性脚本、批量预测服务或实时预测服务。

  • 重要的是要对模型和数据进行版本控制,除了源代码。要注意训练和测试时间之间的训练服务偏差。

  • 您可以使用 TorchServe 轻松提供 PyTorch 模型,并将其部署到 Amazon SageMaker。

  • 可解释性人工智能是一个新的领域,用于解释和解释机器学习模型及其预测。您可以使用 LIT(语言可解释性工具)来可视化和解释模型预测。

^(1.)GCP: cloud.google.com/compute/docs/gpus/install-drivers-gpu; AWS: docs.aws.amazon.com/AWSEC2/latest/UserGuide/install-nvidia-driver.html

^(2.)还有另一个叫做 AllenNLP Interpret 的工具包(allennlp.org/interpret),它提供了一套类似的功能,用于理解 NLP 模型,尽管它专门设计用于与 AllenNLP 模型进行交互。

^(3.)Ribeiro 等人,“‘为什么我要相信你?’: 解释任何分类器的预测”(2016 年)。arxiv.org/abs/1602.04938

^(4.)你可以免费阅读第三版(2021 年)的草稿,网址为web.stanford.edu/~jurafsky/slp3/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值