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

译者:飞龙

协议:CC BY-NC-SA 4.0

第三部分:投入生产

在第 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。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F02_Hagiwara.png

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

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

10.1.2 排序

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F03_Hagiwara.png

图 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”),这是可以接受的,尽管当输入批次包含填充标记时,这就成为一个问题。因为它们只是为了填充批次而存在,所以在计算总损失时应该忽略它们。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F04_Hagiwara.png

图 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),这是不可能的。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F05_Hagiwara.png

图 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 中有所说明。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F06_Hagiwara.png

图 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 所示的图。现在你可以可视化一个机器学习模型如何通过在决策翻转不同类别之间的线来做出分类决策,这被称为分类边界。你会如何绘制一个分类边界,以便将动物(蓝色圆圈)与其他所有东西(三角形)分开?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F07_Hagiwara.png

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

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F08_Hagiwara.png

图 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 个时期才终止训练。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F09_Hagiwara.png

图 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 的示意图。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F10_Hagiwara.png

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

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

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

10.4 处理不平衡数据集

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

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F11_Hagiwara.png

图 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⁵,但它们在自然语言处理中并不常用,因为人为生成语言示例固有的困难。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F12_Hagiwara.png

图 10.12 上采样和下采样

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

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

10.4.3 权重损失

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

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F13_Hagiwara.png

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

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F14_Hagiwara.png

图 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 的示例),这就是为什么称之为网格搜索

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

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F15_Hagiwara.png

图 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 所示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH10_F16_Hagiwara.png

图 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 代码”部分由提供各种功能的许多组件支持,包括数据收集、特征提取和服务。让我们用核电站作为类比。在操作核电站时,只有一小部分涉及核反应。其他一切都是支持安全有效地生成和传输材料和电力的庞大而复杂的基础设施——如何利用生成的热量转动涡轮发电,如何安全冷却和循环水,如何高效传输电力等等。所有这些支持基础设施与核物理几乎无关。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH11_F01_Hagiwara.png

图 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)可以以无缝的方式对数据和模型进行版本控制。即使您不使用这些工具,您也应该至少将不同版本作为清晰命名的单独文件进行管理。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH11_F02_Hagiwara.png

图 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 中有所说明。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH11_F03_Hagiwara.png

图 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 所示的对话框。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH11_F04_Hagiwara.png

图 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)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/rlwd-nlp/img/CH11_F05_Hagiwara.png

图 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/

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐