机器学习设计模式-全-

机器学习设计模式(全)

原文:zh.annas-archive.org/md5/57feedd8941f04b660f46d17896cae29

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书适合谁?

初级机器学习书籍通常关注机器学习(ML)的“什么”和“如何”,然后解释来自 AI 研究实验室的新方法的数学方面,并教授如何使用 AI 框架来实现这些方法。而这本书则围绕着支持经验汇集而来的“为什么”,这些经验是经验丰富的 ML 从业者在将机器学习应用于现实世界问题时所采用的技巧和窍门。

我们假设你已经具备机器学习和数据处理的基础知识。这不是一本基础教材,而是为那些寻找第二本实用机器学习书籍的数据科学家、数据工程师或机器学习工程师而写的。如果你已经掌握了基础知识,这本书将向你介绍一系列想法,并为那些你(作为机器学习从业者)可能认识的想法命名,让你能够自信地掌握它们。

如果你是计算机科学专业的学生,准备进入工业界工作,这本书将丰富你的知识,并为你进入专业世界做好准备。它将帮助你学习如何构建高质量的机器学习系统。

书中不包括的内容

这本书主要是为企业中的机器学习工程师而写,而不是学术界或工业研究实验室的机器学习科学家。

我们有意不讨论正在研究中的领域 —— 例如机器学习模型架构(如双向编码器或注意力机制或短路层),因为我们假设您将使用预构建的模型架构(例如 ResNet-50 或 GRUCell),而不是编写自己的图像分类或递归神经网络。

这里有一些具体的例子,我们有意避开,因为我们认为这些主题更适合大学课程和机器学习研究人员:

机器学习算法

例如,我们不讨论随机森林和神经网络之间的区别。这在初级机器学习教材中有所涵盖。

构建模块

我们不涵盖不同类型的梯度下降优化器或激活函数。我们建议使用 Adam 和 ReLU —— 根据我们的经验,通过在这些方面做出不同选择来改进性能的潜力往往是较小的。

ML 模型架构

如果您正在进行图像分类,我们建议您使用像 ResNet 或您阅读本书时的最新热门模型。将设计新的图像分类或文本分类模型留给专门研究此问题的研究人员。

模型层

本书不涉及卷积神经网络或递归神经网络。它们因为是构建模块而被双重淘汰,也因为可以即插即用而被淘汰。

自定义训练循环

在 Keras 中仅仅调用model.fit()就可以满足从业者的需求。

在本书中,我们尝试仅包含企业机器学习工程师在日常工作中将会使用的常见模式。

作为类比,请考虑数据结构。尽管数据结构课程将深入研究不同数据结构的实现,而研究数据结构的研究人员必须学习如何正式表示它们的数学属性,但实际从业者可以更加实用。企业软件开发人员只需知道如何有效地使用数组、链表、映射、集合和树。这本书是为了实用的机器学习实践者而写的。

代码示例

我们提供机器学习的代码(有时使用 Keras/TensorFlow,有时使用 scikit-learn 或 BigQuery ML)和数据处理的代码(使用 SQL),以展示我们讨论的技术如何实际实现。本书中引用的所有代码都是我们的 GitHub 存储库的一部分,您可以在那里找到完全可工作的 ML 模型。我们强烈建议您尝试这些代码示例。

代码在涵盖的概念和技术中次要。我们的目标是,无论 TensorFlow 或 Keras 如何变化,话题和原则都应保持相关性,我们可以轻松想象更新 GitHub 存储库以包含其他 ML 框架的实现,例如 PyTorch,同时保持书籍文本不变。因此,如果您的主要 ML 框架是 PyTorch,甚至是像 H20.ai 或 R 这样的非 Python 框架,本书应同样具有信息量。确实,我们欢迎您在 GitHub 存储库中为这些模式的一个或多个实现做出贡献。

如果您有技术问题或者在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了大量代码,否则无需征得我们的许可。例如,编写一个程序并使用了本书中的多个代码片段并不需要许可。销售或分发 O’Reilly 书籍中的示例则需要许可。引用本书并引用示例代码来回答问题也无需许可。将本书中大量示例代码整合到产品文档中则需要许可。

我们感谢您,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Machine Learning Design Patterns by Valliappa Lakshmanan, Sara Robinson, and Michael Munn (O’Reilly). Copyright 2021 Valliappa Lakshmanan, Sara Robinson, and Michael Munn, 978-1-098-11578-4。” 如果您认为您使用的代码示例不适合公平使用或上述权限,请随时通过permissions@oreilly.com 联系我们。

本书中使用的约定

本书中使用的排版约定如下:

Italic

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序清单以及在段落内引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

Constant width bold

显示用户应直接输入的命令或其他文本。

Constant width italic

显示应由用户提供的值或根据上下文确定的值替换的文本。

Tip

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

致谢

没有众多 Google 员工的慷慨,像这样的书是不可能的,特别是我们云 AI,解决方案工程,专业服务和开发者关系团队的同事。我们感激他们让我们观察,分析和质疑他们在训练,改进和使 ML 模型运行时遇到的具有挑战性的问题的解决方案。感谢我们的经理 Karl Weinmeister,Steve Cellini,Hamidou Dia,Abdul Razack,Chris Hallenbeck,Patrick Cole,Louise Byrne 和 Rochana Golani,在 Google 内部营造了开放精神,让我们有自由收录这些模式并出版这本书。

Salem Haykal,Benoit Dherin 和 Khalid Salama 审阅了每一个模式和每一章。Sal 指出了我们遗漏的细微之处,Benoit 缩小了我们的论点,而 Khalid 指引我们找到了相关的研究。没有你们的意见,这本书不可能如此出色。谢谢你们!Amy Unruh,Rajesh Thallam,Robbie Haertel,Zhitao Li,Anusha Ramesh,Ming Fang,Parker Barnes,Andrew Zaldivar,James Wexler,Andrew Sellergren 和 David Kanter 审阅了与他们专业领域相关的部分,对近期路线图会如何影响我们的建议提出了许多建议。Nitin Aggarwal 和 Matthew Yeager 以读者的角度审阅了手稿并提高了其清晰度。特别感谢 Rajesh Thallam 为第八章设计了最后一个图。当然,任何错误都是我们的。

O’Reilly 是技术书籍的首选出版商,而我们团队的专业素养正是其所在。Rebecca Novak 在整理有吸引力的大纲时引领我们,Kristen Brown 出色地管理了整个内容的开发,Corbin Collins 在每个阶段给予我们有益的指导,Elizabeth Kelly 在制作过程中非常愉快,Charles Roumeliotis 对审校工作给予了锐利的眼光。感谢你们的帮助!

Michael:感谢我的父母一直相信我,并鼓励我对学术和其他方面的兴趣。你一定和我一样能欣赏这个神秘的封面。Phil,感谢你在我写这本书的时候耐心忍受我的令人讨厌的日程安排。现在,我要睡觉了。

Sara:Jon,你是这本书存在的重要原因。感谢你鼓励我写这本书,总是知道如何让我开心,欣赏我的古怪,尤其是在我不信任自己的时候相信我。感谢我的父母,从一开始就是我的最忠实的粉丝,鼓励我爱技术和写作,让我继续这样做。Ally,Katie,Randi 和 Sophie,谢谢你们在这个不确定的时期一直给我光明和欢笑。

Lak:我接下这本书,本以为能在机场等待时候完成它。COVID-19 让我大部分工作都在家完成。感谢 Abirami、Sidharth 和 Sarada 在我专心撰写时的包容。现在周末更多时间去远足了!

我们三个会把这本书的所有版税捐赠给编程女孩(Girls Who Code),这个组织的使命是培养未来的女工程师队伍。在机器学习领域,多样性、公平性和包容性尤为重要,以确保 AI 模型不会延续人类社会中已有的偏见。

第一章:机器学习设计模式的需求

在工程学科中,设计模式捕捉到常见问题的最佳实践和解决方案。它们将专家的知识和经验编码为所有从业者都可以遵循的建议。本书是我们在与数百个机器学习团队合作中观察到的机器学习设计模式的目录。

什么是设计模式?

模式的概念和已证实的模式目录是由克里斯托弗·亚历山大和五位共同作者在一本极具影响力的书籍一种模式语言(牛津大学出版社,1977)中引入建筑领域的。在他们的书中,他们列出了 253 种模式,并以这种方式介绍它们:

每个模式描述了在我们的环境中反复出现的问题,然后以这样的方式描述了解决这个问题的核心,这样你可以一百万次地使用这个解决方案,而不必两次做同样的事情。

每个解决方案都以一种方式陈述,即给出解决问题所需的关系领域的核心,但以非常一般和抽象的方式—这样你就可以根据自己的偏好和所处地点的局部条件自己解决问题。

例如,在建造家庭时,包含人类细节的几种模式是每个房间两面有光六英尺阳台。想象一下你家中最喜欢的房间和最不喜欢的房间。你最喜欢的房间是否有两面墙的窗户?而你最不喜欢的房间呢?根据亚历山大:

两侧自然采光的房间在人和物体周围产生较少的眩光;这使我们能更加细致地看到事物;最重要的是,它使我们能够详细阅读人们脸上瞬间的微表情……。

有一个这种模式的名字可以让建筑师们免于不断重新发现这个原则。然而,在任何特定的地方条件下,你如何获取两个光源取决于建筑师的技能。同样地,在设计阳台时,它应该有多大?亚历山大建议尺寸为 6 英尺×6 英尺足够放置两把(不匹配的!)椅子和一个边桌,如果你想要既有遮阳的休息空间又有阳光的休息空间,建议尺寸为 12 英尺×12 英尺。

Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 在 1994 年的书籍设计模式:可重用面向对象软件的元素(Addison-Wesley,1995)中列出了 23 种面向对象设计模式,将这一想法引入了软件领域。他们的目录包括代理、单例和装饰者等模式,并对面向对象编程领域产生了深远影响。2005 年,计算机协会(ACM)将年度编程语言成就奖颁发给了这些作者,以表彰他们的工作“对编程实践和编程语言设计的影响”。

构建生产机器学习模型越来越成为一门工程学科,利用在研究环境中已被证明有效的机器学习方法,并将其应用于业务问题。随着机器学习变得更加普及,重要的是从业者利用经过验证的方法来解决反复出现的问题。

我们在 Google Cloud 面向客户的部分工作的一个好处是,它让我们接触到来自世界各地的各种机器学习和数据科学团队以及个人开发人员。同时,我们与内部 Google 团队密切合作,解决前沿的机器学习问题。最后,我们有幸与推动机器学习研究和基础设施民主化的 TensorFlow、Keras、BigQuery ML、TPU 和 Cloud AI 平台团队合作。所有这些使我们能够从独特的视角来整理我们观察到的这些团队正在实施的最佳实践。

本书是机器学习工程中常见问题的设计模式或可重复解决方案的目录。例如,转换模式(第六章)强制分离输入、特征和转换,并使转换持久化,以简化将机器学习模型移至生产环境。类似地,有钥匙预测,在第五章,这是一种模式,可以实现批量预测的大规模分发,例如推荐模型。

对于每种模式,我们描述正在解决的常见问题,然后逐步介绍问题的各种潜在解决方案,这些解决方案的权衡以及选择这些解决方案的建议。这些解决方案的实现代码提供在 SQL 中(如果您在 Spark SQL、BigQuery 等中进行预处理和其他 ETL 操作时有用)、scikit-learn 和/或带有 TensorFlow 后端的 Keras 中。

如何使用本书

这是一个实践中观察到的模式目录,涉及多个团队。在某些情况下,这些模式的概念已经为人所知多年。我们并不声称发明或发现这些模式。相反,我们希望为机器学习从业者提供一个共同的参考框架和工具集。如果这本书能够为您和您的团队在讨论您已经在机器学习项目中直觉地整合的概念时提供词汇,那么我们就算达到了目标。

我们不期望您按顺序阅读本书(尽管您可以!)。相反,我们预计您会快速浏览本书,深入阅读某些部分,与同事讨论想法,并在面对您记得读过的问题时参考本书。如果您计划跳跃阅读,我们建议您从第一章和第八章开始,然后再深入到各个模式中。

每个模式都有简短的问题陈述、一个经典解决方案、解决方案有效性的解释,以及对权衡和替代方案的多部分讨论。建议您在心中牢记经典解决方案的前提下阅读讨论部分,以便进行比较和对比。模式描述将包括从经典解决方案的实现中提取的代码片段。完整的代码可以在我们的 GitHub 代码库中找到。强烈建议您在阅读模式描述时浏览代码。

机器学习术语

因为今天的机器学习实践者可能拥有不同的主要专业领域 —— 软件工程、数据分析、DevOps 或统计学 —— 所以不同实践者对某些术语的使用方式可能会有微妙的差异。在本节中,我们定义了本书中使用的术语。

模型和框架

在其核心,机器学习 是一个从数据中学习的模型构建过程。这与传统编程不同,传统编程中我们编写明确的规则来告诉程序如何行为。机器学习的模型 是从数据中学习模式的算法。为了说明这一点,想象我们是一个搬家公司,需要为潜在客户估算搬家成本。在传统编程中,我们可能会用 if 语句来解决这个问题:

if num_bedrooms == 2 and num_bathrooms == 2:
  estimate = 1500
elif num_bedrooms == 3 and sq_ft > 2000:
  estimate = 2500

您可以想象,随着我们添加更多变量(大型家具件数、衣物量、易碎物品等等)并尝试处理边缘情况,这将很快变得复杂起来。更重要的是,要求客户提前提供所有这些信息可能会导致他们放弃估算过程。相反,我们可以训练一个机器学习模型,根据我们公司已经搬迁过的以往家庭的数据来估算搬家成本。

本书中,我们主要在示例中使用前馈神经网络模型,但我们也会提到线性回归模型、决策树、聚类模型等其他模型。前馈神经网络,通常我们简称为神经网络,是一种机器学习算法,多层次、每层具有多个神经元,分析和处理信息,然后将该信息传递到下一层,最终产生预测作为输出。尽管它们并非完全相同,神经网络常被比作大脑中的神经元,因为节点之间的连接方式和它们处理数据后生成新预测的能力。具有超过一个隐藏层(除了输入和输出层以外的层)的神经网络被归类为深度学习(见图 1-1)。

机器学习模型,无论它们在视觉上如何表现,都是数学函数,因此可以使用数值软件包从头开始实现。然而,在工业界,机器学习工程师倾向于使用几种设计用于构建模型的开源框架之一。我们的大多数示例将使用TensorFlow,这是由 Google 创建的专注于深度学习模型的开源机器学习框架。在 TensorFlow 库中,我们将在示例中使用Keras API,可以通过tensorflow.keras导入。Keras 是一个构建神经网络的高级 API。虽然 Keras 支持多种后端,我们将使用其 TensorFlow 后端。在其他示例中,我们将使用scikit-learn, XGBoostPyTorch,这些都是其他流行的开源框架,提供了用于准备数据的实用工具,以及构建线性和深度模型的 API。机器学习变得越来越易于接触,一个令人兴奋的发展是可以在 SQL 中表达机器学习模型的可用性。我们将使用BigQuery ML作为这种情况的示例,特别是在我们希望结合数据预处理和模型创建的情况下。

不同类型的机器学习分解,以及每种类型的几个示例。请注意,尽管此图中未包含,像自编码器这样的神经网络也可用于无监督学习。

图 1-1. 不同类型的机器学习分解,以及每种类型的几个示例。请注意,尽管此图中未包含,像自编码器这样的神经网络也可用于无监督学习。

相反,只有输入和输出层的神经网络是机器学习的另一种子集,被称为线性模型。线性模型使用线性函数表示它们从数据中学到的模式。决策树是一种机器学习模型,它使用你的数据创建带有各种分支的路径子集。这些分支近似于你的数据中不同结果的结果。最后,聚类模型寻找数据不同子集之间的相似性,并使用这些识别的模式将数据分组成簇。

机器学习问题(见图 1-1)可以分为两种类型:监督学习和无监督学习。监督学习定义了你提前知道数据的地面真实标签的问题。例如,这可以包括将图像标记为“猫”或将婴儿标记为出生时体重为 2.3 公斤。你将这些标记数据提供给你的模型,希望它能够学习足够多的内容来标记新的例子。无监督学习则不知道数据的标签,目标是构建一个能够找到数据的自然分组(称为聚类)、压缩信息内容(降维)或找到关联规则的模型。本书的大部分内容将集中在监督学习上,因为在生产中使用的大多数机器学习模型都是监督学习模型。

在监督学习中,问题通常可以定义为分类或回归。分类模型为你的输入数据分配来自离散预定义类别集的标签(或标签)。分类问题的例子包括确定图像中宠物品种的类型、标记文档或预测交易是否欺诈。回归模型为你的输入分配连续的数值。回归模型的例子包括预测自行车行程的持续时间、公司未来的收入或产品的价格。

数据与特征工程

数据是任何机器学习问题的核心。当我们谈论 数据集 时,我们指的是用于训练、验证和测试机器学习模型的数据。大部分数据将是 训练数据:在训练过程中输入模型的数据。验证数据 是从训练集中留出来用于评估模型在每个训练 轮次(或通过训练数据的次数)后表现的数据。模型在验证数据上的表现用于决定何时停止训练过程,并选择 超参数,例如随机森林模型中的树的数量。测试数据 是完全不参与训练过程的数据,用于评估训练好的模型的表现。机器学习模型的性能报告必须基于独立的测试数据集计算,而不是基于训练或验证数据。同样重要的是,数据必须以这样一种方式分割,使得所有三个数据集(训练、测试、验证)具有类似的统计特性。

训练模型所使用的数据可以采用多种形式,这取决于模型类型。我们将 结构化数据 定义为数值和分类数据。数值数据包括整数和浮点数值,而分类数据则包括可以划分为有限组的数据,例如汽车类型或教育水平。你也可以将结构化数据看作是常见的电子表格中的数据。在本书中,我们将 表格数据 与结构化数据互换使用。另一方面,非结构化数据 则包括不能整齐表示的数据。这通常包括自由文本、图像、视频和音频。

数值数据通常可以直接输入到机器学习模型中,而其他数据则需要进行各种 数据预处理 才能准备好送入模型。这个预处理步骤通常包括对数值进行缩放,或将非数值数据转换为模型可理解的数值格式。预处理的另一个术语是 特征工程。在本书中,我们将这两个术语交替使用。

在特征工程过程中,有各种术语用来描述数据。输入 指的是在处理前数据集中的单个列,而 特征 则指处理后的单个列。例如,时间戳可以是你的输入,而特征则是一周中的某一天。要将数据从时间戳转换为一周中的某一天,你需要进行一些数据预处理。这个预处理步骤也可以称为 数据转换

实例是您想要发送到模型进行预测的项目。 实例可以是测试数据集中的一行(不包括标签列),要分类的图像,或者要发送给情感分析模型的文本文档。 给定实例的特征集,模型将计算预测值。 为了做到这一点,模型是在训练示例上进行训练的,这些示例将实例与标签关联起来。 训练示例指的是从数据集中提取的单个实例(行),将其馈送到模型中。 基于时间戳用例,完整的训练示例可能包括:“星期几”,“城市”和“汽车类型”。 标签是数据集中的输出列-模型正在预测的项目。 标签可以同时指数据集中的目标列(也称为地面实况标签)和模型提供的输出(也称为预测)。 例如,上述训练示例的一个样本标签可能是“行程持续时间”-在这种情况下,是表示分钟的浮点值。

一旦您组装好数据集并确定了模型的特征,数据验证就是计算数据统计信息,理解架构并评估数据集以识别漂移和训练-服务偏差等问题的过程。 对数据进行各种统计信息的评估可以帮助您确保数据集包含每个特征的平衡表示。 在无法收集更多数据的情况下,理解数据平衡将帮助您设计模型以解决这个问题。 理解您的架构涉及为每个特征定义数据类型,并识别其中某些值可能不正确或缺失的训练示例。 最后,数据验证可以识别可能影响训练和测试集质量的不一致之处。 例如,也许您的大部分训练数据集包含工作日示例,而您的测试集主要包含周末示例。

机器学习流程

典型的机器学习工作流程中的第一步是训练——将训练数据传递给模型,使其能够学习识别模式。 训练后,流程的下一步是测试模型在训练集之外的数据上的表现。 这称为模型评估。 您可能会多次运行训练和评估,进行额外的特征工程并调整模型架构。 一旦您对模型在评估过程中的性能感到满意,您可能会希望提供您的模型,以便其他人可以访问它来进行预测。 我们使用术语服务来接受传入请求,并通过将模型部署为微服务来发送预测。 服务基础设施可以在云端、本地或设备上。

向模型发送新数据并利用其输出的过程称为预测。这既可以指尚未部署的本地模型生成预测,也可以指获取已部署模型的预测结果。对于已部署的模型,我们将同时提到在线预测和批处理预测。在线预测用于在几个示例上近实时获取预测结果。在线预测强调低延迟。另一方面,批处理预测是指在大量数据上离线生成预测。批处理预测作业比在线预测时间长,适用于预先计算预测结果(例如在推荐系统中),以及分析模型在大量新数据样本上的预测表现。

在预测未来值时,如预测自行车骑行时间或预测购物车是否会被放弃,使用词语预测非常恰当。但在图像和文本分类模型的情况下,这种用法就不那么直观了。如果一个机器学习模型分析文本评论并输出情感是积极的,这并不算是一个真正的“预测”(因为没有未来结果)。因此,你也会看到词语推断用于指代预测。这里推断这一统计术语被重新定义,但实际上并不涉及推理过程。

通常情况下,收集训练数据、特征工程、训练和评估模型的过程是与生产管道分开处理的。在这种情况下,当您认为有足够的额外数据可以训练新版本的模型时,将重新评估您的解决方案。在其他情况下,可能会持续不断地摄入新数据,并需要在将其送入模型进行训练或预测之前立即处理这些数据。这被称为流式处理。为了处理流式数据,您将需要一个多步骤解决方案来执行特征工程、训练、评估和预测。这样的多步骤解决方案被称为ML 流水线

数据与模型工具

我们将引用多种谷歌云产品,提供解决数据和机器学习问题的工具。这些产品只是在本书中实现设计模式的一种选择,而非详尽的列表。所有这些产品都是无服务器的,使我们能够更专注于实现机器学习设计模式,而不是它们背后的基础设施。

BigQuery 是一种专为通过 SQL 快速分析大型数据集而设计的企业数据仓库。我们将在示例中使用 BigQuery 进行数据收集和特征工程。BigQuery 中的数据通过数据集(Datasets)进行组织,一个数据集可以包含多个表(Tables)。我们的许多示例将使用来自Google Cloud Public Datasets的数据,这是一组在 BigQuery 中免费提供的公共数据。Google Cloud Public Datasets 包括数百个不同的数据集,包括从 1929 年起的 NOAA 天气数据,Stack Overflow 的问题与回答,GitHub 的开源代码,出生率数据等等。为了构建一些示例中的模型,我们将使用BigQuery Machine Learning(或简称 BigQuery ML)。BigQuery ML 是一个用于从 BigQuery 存储的数据构建模型的工具。使用 BigQuery ML,我们可以使用 SQL 训练、评估和生成模型的预测。它支持分类和回归模型以及无监督的聚类模型。还可以将之前训练好的 TensorFlow 模型导入 BigQuery ML 进行预测。

Cloud AI Platform 包括多种产品,用于在 Google Cloud 上训练和提供自定义机器学习模型。在我们的示例中,我们将使用 AI Platform Training 和 AI Platform Prediction。AI Platform Training 提供 Google Cloud 上的机器学习模型训练基础设施。通过 AI Platform Prediction,您可以部署已训练的模型并使用 API 生成预测。这两项服务支持 TensorFlow、scikit-Learn 和 XGBoost 模型,以及使用其他框架构建的自定义容器模型。我们还将提到Explainable AI,这是一个用于解释模型预测结果的工具,适用于部署到 AI Platform 的模型。

角色

在一个组织中,涉及数据和机器学习的许多不同职务角色。以下是书中经常提到的一些常见角色定义。本书主要针对数据科学家、数据工程师和 ML 工程师,我们先从这些角色开始。

数据科学家 是专注于收集、解释和处理数据集的人员。他们对数据进行统计和探索性分析。在涉及机器学习时,数据科学家可能参与数据收集、特征工程、模型构建等工作。数据科学家通常在 Python 或 R 的笔记本环境中工作,并且通常是组织内第一个构建机器学习模型的人员。

数据工程师 专注于支持组织数据的基础架构和工作流程。他们可能帮助管理公司如何摄取数据、数据管道以及数据的存储和传输方式。数据工程师实施围绕数据的基础设施和管道。

机器学习工程师执行与数据工程师类似的任务,但针对的是机器学习模型。他们接手数据科学家开发的模型,并管理围绕训练和部署这些模型的基础设施和运营。机器学习工程师帮助构建生产系统,处理模型更新、模型版本管理,并向最终用户提供预测服务。

在一家公司,数据科学团队越小且团队越敏捷,同一个人承担多个角色的可能性就越大。如果你处于这种情况下,很可能你读到上述三种描述时,在所有三类中都能找到自己的一部分影子。你可能通常会以数据工程师的身份开始一个机器学习项目,构建数据管道以实现数据的摄取。然后,你会转向数据科学家的角色,建立机器学习模型。最后,你会换上机器学习工程师的帽子,将模型移植到生产环境中。在规模较大的组织中,机器学习项目可能会经历相同的阶段,但每个阶段可能会涉及不同的团队。

研究科学家、数据分析师和开发人员也可能构建和使用 AI 模型,但这些工作角色并不是本书的焦点读者群。

研究科学家主要专注于找到和开发新的算法来推动机器学习学科的发展。这可能涉及机器学习的多个子领域,如模型架构、自然语言处理、计算机视觉、超参数调整、模型可解释性等等。与本文中讨论的其他角色不同,研究科学家大部分时间用于原型设计和评估新的机器学习方法,而不是构建生产环境中的机器学习系统。

数据分析师评估和从数据中获取洞见,然后为组织内的其他团队总结这些洞见。他们倾向于在 SQL 和电子表格中工作,并使用商业智能工具创建数据可视化来分享他们的发现。数据分析师与产品团队密切合作,了解他们的洞见如何帮助解决业务问题并创造价值。虽然数据分析师专注于识别现有数据中的趋势并从中获得洞见,数据科学家则关注如何利用这些数据生成未来的预测,并自动化或扩展洞见的生成。随着机器学习的民主化趋势,数据分析师可以通过提升自己的技能成为数据科学家。

开发人员负责构建能让最终用户访问机器学习模型的生产系统。他们通常参与设计 API,用于查询模型并以用户友好的方式通过 Web 或移动应用返回预测结果。这可能涉及在云中托管模型或在设备上提供模型。开发人员利用由机器学习工程师实施的模型服务基础设施来构建应用程序和用户界面,以向模型用户呈现预测结果。

图 1-2 展示了这些不同角色如何在一个组织的机器学习模型开发过程中共同合作。

与数据和机器学习相关的许多不同工作角色,在机器学习工作流中进行协作,从数据摄取到模型服务和最终用户界面。例如,数据工程师负责数据摄取和数据验证,并与数据科学家密切合作。

图 1-2。与数据和机器学习相关的许多不同工作角色,这些角色在机器学习工作流中进行协作,从数据摄取到模型服务和最终用户界面。例如,数据工程师负责数据摄取和数据验证,并与数据科学家密切合作。

机器学习中的常见挑战

为什么我们需要一本关于机器学习设计模式的书?构建机器学习系统的过程中存在多种独特的挑战,这些挑战影响着机器学习的设计。理解这些挑战将有助于您作为机器学习从业者,在整本书中介绍的解决方案中建立一个参考框架。

数据质量

机器学习模型的可靠性取决于用于训练它们的数据。如果您在不完整的数据集、选择不当的特征或不能准确代表使用该模型的人群的数据上训练机器学习模型,那么您的模型的预测将直接反映出这些数据的情况。因此,机器学习模型通常被称为“垃圾进垃圾出”。在这里,我们将重点介绍数据质量的四个重要组成部分:准确性、完整性、一致性和及时性。

数据准确性既涉及您的训练数据的特征,也涉及与这些特征对应的地面真相标签。了解数据的来源以及数据收集过程中可能存在的任何错误可以帮助确保特征的准确性。在数据收集完成后,进行彻底分析以筛查拼写错误、重复条目、表格数据中的测量不一致性、缺失特征以及可能影响数据质量的任何其他错误非常重要。例如,训练数据集中的重复条目可能导致模型错误地为这些数据点分配更多的权重。

准确的数据标签与特征准确性同样重要。您的模型仅依赖于训练数据中的地面真相标签来更新其权重并最小化损失。因此,错误标记的训练样本可能导致误导性的模型准确性。例如,假设您正在构建一个情感分析模型,其中 25%的“正面”训练示例被错误标记为“负面”。您的模型将对什么应该被视为负面情感有一个不准确的理解,并且这将直接反映在其预测中。

要理解数据完整性,假设你正在训练一个模型来识别猫的品种。你在一个广泛的猫图像数据集上训练模型,结果模型能够将图像分类为 10 个可能的类别之一(“孟加拉猫”,“暹罗猫”等),准确率达到 99%。然而,当你将模型部署到生产环境时,你发现除了上传猫照片进行分类外,许多用户还上传了狗的照片,并对模型的结果感到失望。因为模型只被训练来识别 10 种不同的猫品种,这就是它知道如何做的全部。这 10 种品种类别,本质上就是模型的整个“世界观”。无论你发送什么样的图像给模型,它都会将其分类为这 10 个类别之一。即使是看起来一点也不像猫的图像,它也可能以很高的置信度进行分类。此外,如果在训练数据集中没有包括这些数据和标签,“不是猫”的返回结果也无法由模型实现。

数据完整性的另一个方面是确保你的训练数据包含每个标签的多样化代表。以猫品种检测为例,如果你所有的图像都是猫脸的特写,那么你的模型将无法正确识别侧面或全身的猫图像。再看一个表格数据的例子,如果你正在建立一个模型来预测特定城市房地产的价格,但只包括大于 2000 平方英尺的房屋的训练样本,那么你得到的模型将在较小的房屋上表现不佳。

数据质量的第三个方面是数据一致性。对于大型数据集,将数据收集和标记工作分配给一组人是很常见的。制定这一过程的一套标准可以帮助确保数据集的一致性,因为参与其中的每个人都会不可避免地带入他们自己的偏见。像数据完整性一样,数据不一致性可以在数据特征和标签中找到。例如,在不一致的特征方面,假设你正在从温度传感器收集大气数据。如果每个传感器都校准到不同的标准,那么将导致不准确和不可靠的模型预测。不一致性还可以指数据格式。如果你正在捕捉位置数据,有些人可能会将完整街道地址写成“Main Street”,而其他人可能会缩写为“Main St.”,而测量单位,如英里和公里,在世界各地也可能不同。

关于标签一致性问题,让我们回到文本情感分类的例子。在这种情况下,当标记训练数据时,人们可能不会总是同意什么是正面和负面。为了解决这个问题,你可以让多人为数据集中的每个示例进行标记,然后采用每个项上应用最常见的标签。意识到可能存在的标签者偏见,并实施系统来解决这个问题,将确保数据集中的标签一致性。我们将在第七章中探讨“设计模式 30:公平性视角”中的偏见概念。

数据的及时性 指的是事件发生与其被添加到数据库之间的延迟。例如,如果你收集应用程序日志数据,错误日志可能需要几个小时才会出现在日志数据库中。对于记录信用卡交易的数据集,从交易发生到报告到系统中可能需要一天时间。为了处理数据的及时性,记录关于特定数据点的尽可能多的信息是有用的,并确保这些信息在将数据转换为机器学习模型特征时得到反映。更具体地说,你可以跟踪事件发生的时间戳以及将其添加到数据集中的时间。然后,在进行特征工程时,可以相应地考虑这些差异。

可复现性

在传统编程中,程序的输出是可复现且有保证的。例如,如果你编写一个反转字符串的 Python 程序,你知道输入单词“banana”将始终返回“ananab”的输出。同样地,如果你的程序有一个 bug 导致它错误地反转包含数字的字符串,你可以将程序发送给同事,并期望他们能够使用相同的输入复现错误(除非 bug 与程序保持一些错误的内部状态、架构差异如浮点精度或执行差异如线程有关)。

另一方面,机器学习模型具有一定的随机性元素。在训练时,ML 模型权重使用随机值进行初始化。随着模型迭代并从数据中学习,这些权重逐渐收敛。因此,给定相同的训练数据,相同的模型代码将在训练运行中产生略有不同的结果。这引入了可复现性的挑战。如果你训练一个模型达到了 98.1%的准确率,重复的训练运行不保证会达到相同的结果。这可能使得跨实验进行比较变得困难。

为了解决这种重复性问题,通常会将模型使用的随机种子值设置为固定值,以确保每次运行训练时应用相同的随机性。在 TensorFlow 中,您可以通过在程序开头运行 tf.random.set_seed(value) 来实现这一点。

另外,在 scikit-learn 中,许多用于洗牌数据的实用函数还允许您设置随机种子值:

from sklearn.utils import shuffle
data = shuffle(data, random_state=value)

请记住,在训练模型时需要使用相同的数据 相同的随机种子,以确保在不同实验中获得可重复的结果。

训练 ML 模型涉及多个需要固定的工件,以确保可重复性:使用的数据、用于生成训练和验证数据集的分割机制、数据准备和模型超参数,以及诸如批量大小和学习率调度等变量。

可重复性还适用于机器学习框架依赖项。除了手动设置随机种子外,框架还在调用训练模型函数时内部实现了随机性元素。如果这些底层实现在不同框架版本之间发生更改,重复性就无法保证。具体而言,如果一个框架版本的 train() 方法调用 rand() 13 次,而同一框架的新版本调用 14 次,则在不同实验之间使用不同版本会导致稍有不同的结果,即使使用相同的数据和模型代码。在容器中运行 ML 工作负载并标准化库版本可以帮助确保重复性。第六章介绍了一系列用于实现 ML 过程可重复性的模式。

最后,可重复性还可能指模型的训练环境。通常由于大型数据集和复杂性,许多模型需要大量时间来训练。通过采用数据或模型并行等分布策略可以加速此过程(参见第五章)。然而,当重新运行利用分布式训练的代码时,重复性也带来了额外的挑战。

数据漂移

虽然机器学习模型通常代表输入和输出之间的静态关系,但数据随时间可能会发生显著变化。数据漂移指的是确保机器学习模型保持相关性,并且模型预测能够准确反映其使用环境的挑战。

例如,假设您正在训练一个模型来将新闻文章标题分类为“政治”、“商业”和“技术”等类别。如果您在 20 世纪的历史新闻文章上训练和评估您的模型,它可能不会在当前数据上表现出色。今天,我们知道标题中含有“智能手机”一词的文章可能是关于技术的。然而,一个在历史数据上训练的模型将不会知道这个词。为了解决漂移问题,持续更新您的训练数据集,重新训练您的模型,并修改模型分配给特定输入数据组的权重是非常重要的。

要看一个不太明显的漂移示例,请查看 NOAA 在 BigQuery 中的严重风暴数据集。如果我们正在训练一个模型来预测特定区域的风暴可能性,我们需要考虑天气报告随时间变化的方式。我们可以从《图 1-3:年度记录的严重风暴数量》中看到,自 1950 年以来记录的严重风暴总数一直在稳步增加。

1950 年至 2011 年由 NOAA 记录的每年记录的严重风暴数量。

图 1-3. 1950 年至 2011 年由 NOAA 记录的每年记录的严重风暴数量。

从这一趋势中,我们可以看出,使用 2000 年以前的数据来训练模型,以预测今天的风暴,将导致不准确的预测。除了报告风暴总数增加外,还应考虑可能影响数据的其他因素,例如时间。例如,随着天气雷达在 1990 年代的引入,观测风暴的技术得到了显著改进。在特征的背景下,这可能意味着较新的数据包含了更多关于每场风暴的信息,并且今天的数据中可能存在的特征在 1950 年可能没有被观察到。探索性数据分析有助于识别这种漂移,并能够确定用于训练的正确数据时间窗口。《设计模式 23:桥接架构》提供了处理随着时间推移特征可用性改善的数据集的方法。

规模

缩放问题贯穿了典型的机器学习工作流的许多阶段。在数据收集和预处理、训练以及服务过程中,您可能会遇到缩放挑战。当摄取和准备数据以供机器学习模型使用时,数据集的大小将决定所需解决方案的工具。通常情况下,数据工程师的工作是构建能够扩展处理包含数百万行数据集的数据管道。

对于模型训练,机器学习工程师负责确定特定训练任务所需的基础设施。根据数据集的类型和大小,模型训练可能会耗时且计算成本高昂,需要专为机器学习工作负载设计的基础设施(如 GPU)。例如,图像模型通常需要比完全基于表格数据训练的模型更多的训练基础设施。

在模型服务的背景下,支持数据科学家团队从模型原型获取预测所需的基础设施与支持生产模型每小时获得数百万预测请求所需的基础设施完全不同。开发人员和机器学习工程师通常负责处理与模型部署和服务预测请求相关的扩展挑战。

本书中的大多数机器学习模式无论组织成熟度如何都很有用。然而,第六章和第七章中的几种模式以不同的方式解决了韧性和可再现性挑战,选择哪种模式通常取决于使用案例以及组织吸收复杂性的能力。

多个目标

尽管通常有一个团队负责构建机器学习模型,但组织中的许多团队都会以某种方式使用该模型。不可避免地,这些团队可能对定义成功模型有不同的看法。

要了解这在实践中如何发挥作用,假设你正在构建一个模型来识别图像中的缺陷产品。作为数据科学家,你的目标可能是最小化模型的交叉熵损失。另一方面,产品经理可能希望减少被误分类并发送给客户的缺陷产品数量。最后,高管团队的目标可能是增加 30%的收入。每个目标在其优化的方面存在差异,在组织内平衡这些不同需求可能是一项挑战。

作为一名数据科学家,你可以通过说假阴性的成本是假阳性的五倍来将产品团队的需求转化为你模型的上下文。因此,在设计模型时,你应该优化召回率而不是精确度来满足这一点。然后,你可以在产品团队优化精确度的目标和你最小化模型损失的目标之间找到平衡。

在定义模型的目标时,考虑到组织中不同团队的需求以及每个团队的需求如何与模型相关是很重要的。通过在构建解决方案之前分析每个团队的优化目标,你可以找到折中的领域,以最优地平衡这些多重目标。

摘要

设计模式是将专家的知识和经验编码为所有从业者可以遵循的建议的一种方式。本书中的设计模式捕捉了在设计、构建和部署机器学习系统中常见问题的最佳实践和解决方案。机器学习中的常见挑战通常围绕数据质量、可重现性、数据漂移、规模以及满足多个目标展开。

在机器学习生命周期的不同阶段,我们倾向于使用不同的 ML 设计模式。有些模式对问题框架和可行性评估非常有用。大多数模式涉及开发或部署,还有一些模式涉及这些阶段之间的相互作用。

第二章:数据表示设计模式

任何机器学习模型的核心是一个数学函数,该函数定义为仅在特定类型的数据上操作。与此同时,现实世界中的机器学习模型需要操作可能无法直接插入数学函数中的数据。例如,决策树的数学核心操作于布尔变量上。请注意,我们这里讨论的是决策树的数学核心——决策树机器学习软件通常还包括从数据中学习最优树的函数以及读取和处理不同类型的数值和分类数据的方法。然而,支撑决策树的数学函数(见图 2-1)实际上操作布尔变量,并使用 AND(在图 2-1 中为&&)和 OR(在图 2-1 中为+)等操作。

用于预测婴儿是否需要重症监护的决策树机器学习模型的核心是操作布尔变量的数学模型。

图 2-1 决策树机器学习模型的核心,用于预测婴儿是否需要重症监护。

假设我们有一个决策树来预测婴儿是否需要重症监护(IC)或可以正常出院(ND),并且假设决策树的输入是两个变量,x1x2。训练模型可能看起来像图 2-1 所示。

很明显,x1x2 必须是布尔变量,才能使 f(x1, x2) 正常工作。假设我们希望模型在分类婴儿是否需要重症监护时考虑两个信息:婴儿出生的医院和婴儿的体重。我们能够将婴儿出生的医院作为决策树的输入吗?不行,因为医院既不是 True 也不是 False 的值,不能被传入&&(AND)运算符。从数学上讲是不兼容的。当然,我们可以通过进行操作如下:

                x1 = (hospital IN France)

所以,当医院位于法国时,x1 为 True,否则为 False。同样,婴儿的体重不能直接输入模型,但可以通过进行操作如下:

                x1 = (babyweight < 3 kg)

我们可以将医院或者婴儿体重作为模型的输入。这是输入数据(医院,一个复杂对象或婴儿体重,一个浮点数)如何以模型期望的形式(布尔型)表示的示例。这就是我们所说的数据表示

在本书中,我们将使用术语输入来表示输入到模型的真实世界数据(例如,婴儿体重),并使用术语特征来表示模型实际操作的转换后的数据(例如,婴儿体重是否小于 3 公斤)。创建用于表示输入数据的特征的过程称为特征工程,因此我们可以将特征工程视为一种选择数据表示的方式。

当然,我们更希望机器学习模型能够学习如何通过选择输入变量和阈值来创建每个节点,而不是硬编码参数,比如 3 公斤的阈值。决策树就是能够学习数据表示的机器学习模型的一个例子¹。本章我们将看到的许多模式将涉及类似的可学习的数据表示

嵌入设计模式是深度神经网络能够自行学习的数据表示的典型示例。在嵌入中,学习得到的表示是密集的,并且比输入(可能是稀疏的)更低维度。学习算法需要从输入中提取最显著的信息,并在特征中以更简洁的方式表示它。学习用于表示输入数据的特征的过程称为特征提取,我们可以将可学习的数据表示(如嵌入)视为自动化生成的特征。

数据表示甚至可以不仅仅是单个输入变量的表示 —— 例如,斜决策树通过设定两个或更多输入变量的线性组合的阈值来创建一个布尔特征。每个节点只能表示一个输入变量的决策树会简化为分段线性函数,而每个节点可以表示输入变量线性组合的斜决策树会简化为分段线性函数(见图 2-2)。考虑到需要学习来充分表示线性的步骤数量,分段线性模型更为简单且更快速。这个想法的扩展是特征交叉设计模式,简化了多值分类变量之间 AND 关系的学习。

决策树分类器,每个节点只能设置一个输入值(x1 或 x2),将导致一个分段线性边界函数;而斜决策树分类器,每个节点可以设置输入变量的线性组合的阈值,将导致一个分段线性边界函数。分段线性函数需要更少的节点,并能达到更高的精度。

图 2-2. 决策树分类器,每个节点只能阈值化一个输入值(x1 或 x2),将导致一个逐步线性边界函数,而一个斜树分类器,其中一个节点可以阈值化输入变量的线性组合,将导致一个分段线性边界函数。分段线性函数需要更少的节点,并且可以达到更高的准确度。

数据表示不需要学习或固定,还可以使用混合方法。哈希特征设计模式是确定性的,但不需要模型知道特定输入可能采用的所有潜在值。

到目前为止,我们查看的数据表示都是一对一的。虽然我们可以分别表示不同类型的输入数据或将每个数据片段表示为单个特征,但使用多模态输入可能更有利。这是本章将要探讨的第四种设计模式。

简单的数据表示

在深入学习数据表示、特征交叉等之前,让我们先看看更简单的数据表示。我们可以将这些简单的数据表示视为机器学习中常见的惯用法,虽然不完全是模式,但通常被广泛采用。

数值输入

大多数现代大规模机器学习模型(随机森林、支持向量机、神经网络)都是基于数值的操作,因此如果我们的输入是数值型的,我们可以将其直接传递给模型。

为什么缩放是可取的

通常情况下,由于机器学习框架使用的优化器经过调优,能够很好地处理[–1, 1]范围内的数字,因此将数值缩放到该范围内可能会有益处。

通过 scikit-learn 内置数据集的快速测试可以证明这一点(这是来自本书代码仓库的摘录):

from sklearn import datasets, linear_model
diabetes_X, diabetes_y = datasets.load_diabetes(return_X_y=True)
raw = diabetes_X[:, None, 2]
max_raw = max(raw)
min_raw = min(raw)
scaled = (2*raw - max_raw - min_raw)/(max_raw - min_raw)

def train_raw():
    linear_model.LinearRegression().fit(raw, diabetes_y)

def train_scaled():
    linear_model.LinearRegression().fit(scaled, diabetes_y)

raw_time = timeit.timeit(train_raw, number=1000)
scaled_time = timeit.timeit(train_scaled, number=1000)

当我们运行这个模型时,我们得到了几乎 9% 的改进,这个模型只使用了一个输入特征。考虑到典型机器学习模型中的特征数量,这些节省可能会积累起来。

缩放的另一个重要原因是,一些机器学习算法和技术对不同特征的相对大小非常敏感。例如,使用欧氏距离作为其接近度测量的 k-means 聚类算法将主要依赖具有较大幅度特征。缺乏缩放还会影响 L1 或 L2 正则化的效果,因为特征的权重大小取决于该特征的值的大小,因此不同特征会受到正则化的影响不同。通过将所有特征缩放到[–1, 1]之间,我们确保不同特征的相对大小差异不大。

线性缩放

常用的四种缩放形式:

最小-最大缩放

数值线性缩放,使得输入可以取的最小值缩放到–1,最大可能值缩放到 1:

x1_scaled = (2*x1 - max_x1 - min_x1)/(max_x1 - min_x1)

最小-最大缩放的问题在于必须从训练数据集中估计最大和最小值(max_x1min_x1),它们通常是异常值。真实数据经常被缩小到[–1, 1]范围内的一个非常窄的区域。

裁剪(与最小-最大缩放结合使用)

通过使用“合理”的值而不是从训练数据集估计最小和最大值来解决异常值问题。数值线性缩放在这两个合理范围内,然后裁剪到范围[–1, 1]。这样做的效果是将异常值视为–1 或 1。

Z-score 标准化

通过使用训练数据集上估计的均值和标准差,线性缩放输入来解决异常值问题,而无需事先知道合理范围是什么:

x1_scaled = (x1 - mean_x1)/stddev_x1

该方法的名称反映了缩放值具有零均值,并且通过标准差进行归一化,从而使其在训练数据集上具有单位方差的事实。缩放值是无界的,但大多数时间(如果基础分布是正态分布,为 67%)位于[–1, 1]之间。绝对值越大的值超出此范围的概率较低,但仍然存在。

温索化

使用训练数据集中的经验分布将数据集裁剪到由数据值的第 10 和 90 百分位给出的边界(或第 5 和 95 百分位,依此类推)。修剪后的值是最小-最大缩放的。

到目前为止讨论的所有方法都对数据进行线性缩放(在剪裁和温索化的情况下,典型范围内是线性的)。最小-最大缩放和剪裁倾向于最适合均匀分布的数据,而 Z-score 倾向于最适合正态分布的数据。在婴儿体重预测示例中,不同缩放函数对 mother_age 列的影响显示在图 2-3 中(查看完整代码)。

在图 2-3 中,请注意 minmax_scaled 将 x 值放入所需范围[-1, 1],但继续保留分布极端端点的值,这些端点的例子不足。Clipping 将许多问题值折叠起来,但需要准确设置截断阈值——在此处,40 岁以上母亲的婴儿数量的缓慢下降造成了设置硬阈值的问题。Winsorizing 与 Clipping 类似,需要准确设置百分位阈值。Z-score 标准化改善了范围(但不限制值为[-1, 1]),并将问题值推迟。对于这三种方法中,零规范化对mother_age效果最好,因为原始年龄值有点钟形曲线。对于其他问题,min-max 缩放、Clipping 或 Winsorizing 可能更好。

母亲年龄在婴儿体重预测示例中的直方图显示在左上方面板,不同的缩放函数(见 x 轴标签)显示在其余面板中。

图 2-3. 母亲年龄在婴儿体重预测示例中的直方图显示在左上方面板,不同的缩放函数(见 x 轴标签)显示在其余面板中。

非线性变换

如果我们的数据出现偏斜,既不均匀分布也不像钟形曲线分布怎么办?在这种情况下,在缩放之前应用非线性变换会更好。一个常见的技巧是在缩放之前取输入值的对数。其他常见的转换包括 sigmoid 函数和多项式扩展(平方、平方根、立方、立方根等)。我们会知道我们有一个好的转换函数,如果转换后的值的分布变得均匀或正态分布。

假设我们正在建立一个模型来预测一本非虚构书籍的销售情况。模型的一个输入是与主题对应的维基百科页面的流行程度。然而,维基百科页面的访问量存在严重的偏斜,并且占据了很大的动态范围(见图 2-4 的左侧面板:分布向极少被访问的页面倾斜,但最常见的页面被访问了数千万次)。通过取对数,然后取该对数值的四次方根,并线性缩放结果,我们得到了一个在所需范围内且略呈钟形的分布。有关查询维基百科数据、应用这些转换和生成此图的代码详细信息,请参阅本书的GitHub 代码库

左面板:维基百科页面浏览数分布高度偏斜,动态范围大。第二面板展示了通过连续使用对数、幂函数和线性缩放来转换浏览数的方法。第三面板展示了直方图均衡化的效果,第四面板展示了 Box-Cox 变换的效果。

图 2-4. 左面板:维基百科页面浏览数分布高度偏斜,动态范围大。第二面板展示了通过连续使用对数、幂函数和线性缩放来转换浏览数的方法。第三面板展示了直方图均衡化的效果,第四面板展示了 Box-Cox 变换的效果。

设计一个线性化函数,使分布看起来像钟形曲线可能会很困难。更简单的方法是将浏览次数分桶化,选择桶的边界以适应所需的输出分布。选择这些桶的一个有原则的方法是进行直方图均衡化,直方图的箱子根据原始分布的分位数选择(见图 2-4 的第三面板)。在理想情况下,直方图均衡化会导致均匀分布(尽管在本例中不会,因为分位数中存在重复值)。

要在 BigQuery 中执行直方图均衡化,我们可以执行以下操作:

ML.BUCKETIZE(num_views, bins) AS bin

其中箱子是从以下位置获取的:

APPROX_QUANTILES(num_views, 100) AS bins

查看本书代码库中的notebook获取完整详情。

处理偏斜分布的另一种方法是使用像Box-Cox 变换这样的参数化转换技术。Box-Cox 选择其单一参数 lambda 来控制“异方差性”,使得方差不再取决于大小。在这里,很少被查看的维基百科页面之间的方差要比经常被查看的页面之间的方差小得多,而 Box-Cox 试图在所有浏览数的范围内均衡化方差。可以使用 Python 的 SciPy 包来完成这个过程:

traindf['boxcox'], est_lambda = (
    scipy.stats.boxcox(traindf['num_views']))

在训练数据集上估计的参数(est_lambda)然后用于转换其他值:

evaldf['boxcox'] = scipy.stats.boxcox(evaldf['num_views'], est_lambda)

数字数组

有时,输入数据是一个数字数组。如果数组长度固定,数据表示可能会很简单:展平数组,并将每个位置视为单独的特征。但通常,数组长度会变化。例如,用于预测非小说书籍销量模型的输入之一可能是该主题所有先前书籍的销售量。例如输入可能是:

[2100, 15200, 230000, 1200, 300, 532100]

显然,这个数组的长度在每一行中会有所不同,因为不同主题的书籍数量各不相同。

处理数字数组的常见习语包括以下内容:

  • 以其总体统计数据表示输入数组。例如,我们可能会使用长度(即以前有关该主题的书籍的数量)、平均值、中位数、最小值、最大值等。

  • 以其经验分布表示输入数组——例如第 10/20/...百分位数等。

  • 如果数组以特定方式排序(例如按时间顺序或按大小排序),则通过最后三个或其他固定数量的项目表示输入数组。对于长度小于三的数组,该特征将使用缺失值填充到长度为三。

所有这些最终都将变量长度数组表示为固定长度特征。我们也可以将这个问题表述为时间序列预测问题,即基于以前书籍销售的时间历史来预测下一本书的销售。通过将以前书籍的销售视为数组输入,我们假设预测书籍销售最重要的因素是书籍本身的特征(作者、出版商、评论等),而不是销售金额的时间连续性。

分类输入

因为大多数现代大规模机器学习模型(随机森林、支持向量机、神经网络)都是基于数值值运行的,所以分类输入必须表示为数字。

枚举可能的值并将它们映射到一个有序标度将会效果不佳。假设模型的一个输入是预测非小说书籍销售的语言。我们不能简单地创建这样的映射表:

分类输入 数值特征
英文 1.0
中文 2.0
德语 3.0

这是因为机器学习模型将尝试在德语和英语书籍的流行度之间进行插值,以获取中文书籍的流行度!由于语言之间没有顺序关系,我们需要使用分类到数值的映射,使模型能够独立学习这些语言书籍市场。

独热编码

将分类变量映射为保证变量独立的最简单方法是独热编码。在我们的例子中,分类输入变量将通过以下映射转换为三元素特征向量:

分类输入 数值特征
英语 [1.0, 0.0, 0.0]
中文 [0.0, 1.0, 0.0]
德语 [0.0, 0.0, 1.0]

独热编码要求我们事先知道分类输入的词汇。在这里,词汇包括三个标记(英语、中文和德语),生成的特征长度是这个词汇表的大小。

在某些情况下,将数字输入视为分类变量,并将其映射到一个独热编码列可能会有所帮助:

当数字输入是一个索引

例如,如果我们试图预测交通水平,而我们的输入之一是星期几,我们可以将星期几视为数值(1、2、3,…,7),但认识到这里的星期几并不是连续的刻度,而只是一个索引。将其视为分类(星期日、星期一,…,星期六)更有帮助,因为索引是任意的。周的起始日应该是星期天(美国)、星期一(法国)还是星期六(埃及)?

当输入和标签之间的关系不是连续的时候

应该将一周中的某一天作为分类特征的理由在于,星期五的交通水平不受星期四和星期六的影响。

当将数字变量分桶时

在大多数城市中,交通水平取决于是否是周末,并且这可能因地点而异(大部分世界在周六和周日,某些伊斯兰国家在星期四和星期五)。因此,将一周中的某一天视为布尔特征(周末或工作日)会很有帮助。这种映射中,独立输入的数量(这里是七个)大于独立特征值的数量(这里是两个),被称为分桶。通常,分桶是根据范围进行的——例如,我们可以将mother_age分桶为在 20、25、30 等处断开的范围,并将每个桶视为分类变量,但应意识到这会丢失mother_age的序数性质。

当我们希望处理数值输入的不同值时,视其对标签的影响为独立

例如,婴儿的体重取决于分娩的多胎情况²,因为双胞胎和三胞胎的体重通常比单胎轻。因此,如果三胞胎中有一个体重较轻的婴儿,可能比体重相同的双胞胎更健康。在这种情况下,我们可以将多胎数映射为分类变量,因为分类变量允许模型为不同的多胎值学习独立可调参数。当然,只有在我们的数据集中有足够的双胞胎和三胞胎的例子时才能这样做。

数组的分类变量

有时,输入数据是一个类别数组。如果数组长度固定,我们可以将每个数组位置视为单独的特征。但通常,数组的长度是可变的。例如,新生模型的一个输入可能是母亲之前的分娩类型:

[Induced, Induced, Natural, Cesarean]

显然,该数组的长度在每一行中会有所不同,因为每个婴儿的哥哥姐姐的数量也不同。

处理数组分类变量的常见习语包括以下内容:

  • 计数每个词汇项的出现次数。因此,我们示例的表示将是[2, 1, 1],假设词汇表是Induced, NaturalCesarean(按顺序)。现在这是一个固定长度的数字数组,可以展平并按位置顺序使用。如果我们有一个数组,其中一个项目只能出现一次(例如一个人说的语言),或者如果该特征只表示存在而不是计数(例如母亲是否曾经接受过剖腹产手术),那么每个位置的计数为 0 或 1,这被称为多热编码

  • 为避免大数字,可以使用相对频率代替计数。我们示例的表示方法将是[0.5, 0.25, 0.25]而不是[2, 1, 1]。空数组(没有兄弟姐妹的第一个孩子)表示为[0, 0, 0]。在自然语言处理中,词的整体相对频率通过包含该词的文档的相对频率来归一化,从而产生TF-IDF(词频-逆文档频率)。

  • 如果数组以特定方式排序(例如,按时间顺序),则用最后三个项目表示输入数组。长度小于三的数组用缺失值填充。

  • 通过批量统计来表示数组,例如数组的长度,众数(最常见的条目),中位数,第 10/20/…百分位等。

在这些表示方法中,计数/相对频率的习惯用法最为常见。请注意,这两者都是独热编码的一般化——如果婴儿没有兄弟姐妹,其表示将为[0, 0, 0],如果婴儿有一个自然分娩的兄弟姐妹,则表示将为[0, 1, 0]

看过简单的数据表示后,让我们讨论有助于数据表示的设计模式。

设计模式 1:散列特征

散列特征设计模式解决了与分类特征相关的三个可能的问题:不完整的词汇表、模型大小由于基数、以及冷启动。它通过分组分类特征来做到这一点,并接受数据表示中碰撞的权衡。

问题

对分类输入变量进行独热编码需要预先知道词汇表。如果输入变量类似于书写语言或预测交通量的星期几,这不是问题。

如果所讨论的分类变量是类似于hospital_id(婴儿出生地的医院编号)或physician_id(接生的医生编号)这样的内容,这些分类变量会带来一些问题:

  • 学会词汇需要从训练数据中提取。由于随机抽样,训练数据可能不包含所有可能的医院或医生。词汇可能是不完整的。

  • 分类变量具有高基数。与具有三种语言或七天的特征向量不同,我们的特征向量长度可能达到数千到数百万。这种特征向量在实践中存在几个问题。它们涉及如此多的权重,以至于训练数据可能不足。即使我们可以训练模型,训练好的模型在服务时也需要大量空间来存储,因为整个词汇表在服务时都是必需的。因此,我们可能无法在较小的设备上部署模型。

  • 模型投入生产后,可能会建造新的医院并雇佣新的医生。模型将无法预测这些情况,因此需要一个单独的服务基础设施来处理这些冷启动问题。

提示

即使像单热编码这样简单的表示法,也值得预见冷启动问题,并明确保留所有零值以用于词汇外输入。

作为具体例子,让我们来看一下预测航班到达延误的问题。模型的输入之一是出发机场。在收集数据集时,美国有 347 个机场:

SELECT 
   DISTINCT(departure_airport)
FROM `bigquery-samples.airline_ontime_data.flights`

一些机场在整个时间段内只有一到三次航班,因此我们预计训练数据词汇表将不完整。347 足够大,特征将非常稀疏,新机场肯定会建造。如果我们对出发机场进行单热编码,这三个问题(不完整的词汇表,高基数,冷启动)都会存在。

航空数据集,如出生数据集和我们在本书中用于说明的几乎所有其他数据集,都是BigQuery 中的公共数据集,因此您可以尝试查询。在我们编写本文时,每月 1 TB 的查询免费,还有一个沙盒可供使用,因此您可以在不用信用卡的情况下使用 BigQuery 达到此限制。我们建议您收藏我们的 GitHub 存储库。例如,查看 GitHub 中的notebook以获取完整的代码。

解决方案

哈希特征设计模式通过以下方式表示分类输入变量:

  1. 将分类输入转换为唯一字符串。对于出发机场,我们可以使用三字母 IATA 代码

  2. 对字符串应用确定性(无随机种子或盐)和可移植(以便在训练和服务中都可以使用相同算法)的哈希算法。

  3. 取哈希结果除以所需的桶数得到余数。通常,哈希算法返回一个整数,可能为负数,而负数的模仍为负数。因此,需要取结果的绝对值。

在 BigQuery SQL 中,可以通过以下方式实现这些步骤:

ABS(MOD(FARM_FINGERPRINT(airport), numbuckets))

FARM_FINGERPRINT 函数使用 FarmHash,一系列确定性、分布良好的哈希算法,并且这些算法的实现在多种编程语言中都有提供

在 TensorFlow 中,这些步骤由 feature_column 函数实现:

tf.feature_column.categorical_column_with_hash_bucket(
    airport, num_buckets, dtype=tf.dtypes.string)

例如,表格 2-1 显示了一些 IATA 机场代码在散列到 3、10 和 1,000 个桶时的 FarmHash。

表格 2-1. 将一些 IATA 机场代码散列到不同数量的桶时的 FarmHash

出发机场 hash3 hash10 hash1000
1 DTW 1 3 543
2 LBB 2 9 709
3 SNA 2 7 587
4 MSO 2 7 737
5 ANC 0 8 508
6 PIT 1 7 267
7 PWM 1 9 309
8 BNA 1 4 744
9 SAF 1 2 892
10 IPL 2 1 591

为什么它有效

假设我们选择使用 10 个桶对机场代码进行散列(hash10 在 表格 2-1 中)。这如何解决我们所识别的问题?

超出词汇表的输入

即使一个只有少数航班的机场不在训练数据集中,其散列特征值将在 [0–9] 范围内。因此,在服务期间不存在韧性问题——未知机场将获得与哈希桶中其他机场对应的预测。模型不会出错。

如果我们有 347 个机场,如果将其散列到 10 个桶中,平均每个桶将有相同哈希桶代码的大约 35 个机场。训练数据集中缺失的机场将从哈希桶中的其他相似 ~35 个机场中“借用”其特征。当然,对于未知输入的预测不会准确(期望未知输入的准确预测是不合理的),但它将在正确范围内。

通过平衡处理合理的超出词汇表输入的需求和准确反映分类输入的需求来选择哈希桶的数量。使用 10 个哈希桶,大约有 ~35 个机场混合在一起。一个经验法则是选择哈希桶的数量,使每个桶大约有五个条目。在这种情况下,选择 70 个哈希桶是一个很好的折衷方案。

高基数

只要我们选择足够少的哈希桶数量,就能解决高基数问题。即使我们有数百万个机场、医院或医生,我们也可以将它们哈希到几百个桶中,从而保持系统的内存和模型大小要求实用。

我们不需要存储词汇表,因为转换代码与实际数据值无关,模型的核心只处理 num_buckets 输入,而不是整个词汇表。

确实,哈希是损失的——因为我们有 347 个机场,如果我们将其哈希到 10 个桶中,平均每个桶将有 35 个机场共享相同的哈希桶代码。然而,当选择是舍弃这个变量因为它太宽时,损失编码是一个可以接受的折衷方案。

冷启动

冷启动情况类似于词汇外情况。如果新机场被添加到系统中,它最初会获得与哈希桶中其他机场对应的预测。随着一个机场变得流行,将会有更多的航班从该机场起飞。只要我们定期重新训练模型,其预测将开始反映来自新机场的到达延误情况。这在“设计模式 18:持续模型评估”中更详细地讨论了。

通过选择哈希桶的数量,使每个桶大约有五个条目,我们可以确保任何桶都具有合理的初始结果。

折衷与替代方案

大多数设计模式都涉及某种折衷,哈希特征设计模式也不例外。这里的关键折衷是我们会失去模型的准确性。

桶碰撞

哈希特征实现中的模数部分是一个损失的操作。通过选择 100 个哈希桶的大小,我们选择让 3-4 个机场共享一个桶。我们明确地在能够准确表示数据(使用固定词汇表和单热编码)的能力上进行了妥协,以处理词汇外输入、基数/模型大小约束和冷启动问题。这不是一顿免费的午餐。如果您事先知道词汇表,词汇表大小相对较小(对于包含数百万示例的数据集,数千是可以接受的),或者冷启动不是问题,请不要选择哈希特征。

注意,我们不能简单地将桶的数量增加到非常高的数字,希望完全避免碰撞。即使我们将桶的数量增加到 10 万个,仅有 347 个机场,至少两个机场共享相同哈希桶的概率为 45%——这是不可接受的高概率(参见表 2-2)。因此,我们应仅在愿意容忍多个分类输入共享相同哈希桶值的情况下使用哈希特征。

表 2-2. 每个桶预期条目数及当 IATA 机场代码哈希到不同数量的桶时至少一次碰撞的概率

num_hash_buckets entries_per_bucket collision_prob
3 115.666667 1.000000
10 34.700000 1.000000
100 3.470000 1.000000
1000 0.347000 1.000000
10000 0.034700 0.997697
100000 0.003470 0.451739

偏斜

当分类输入的分布高度倾斜时,精度损失特别严重。考虑包含 ORD(芝加哥,世界上最繁忙的机场之一)的哈希桶的情况。我们可以通过以下方式找到这个哈希桶:

CREATE TEMPORARY FUNCTION hashed(airport STRING, numbuckets INT64) AS (
   ABS(MOD(FARM_FINGERPRINT(airport), numbuckets))
);

WITH airports AS (
SELECT 
   departure_airport, COUNT(1) AS num_flights
FROM `bigquery-samples.airline_ontime_data.flights`
GROUP BY departure_airport 
)

SELECT 
   departure_airport, num_flights
FROM airports
WHERE hashed(departure_airport, 100) = hashed('ORD', 100)

结果显示,虽然 ORD 有约 360 万次航班,但 BTV(佛蒙特州伯灵顿市)只有约 67000 次航班:

departure_airport num_flights
ORD 3610491
BTV 66555
MCI 597761

这表明,从实际目的来看,模型将芝加哥经历的长时间出租车等待和天气延误归因于佛蒙特州伯灵顿市的市政机场!对于 BTV 和 MCI(堪萨斯城机场),模型的准确率将非常低,因为芝加哥的航班数量如此之多。

聚合特征

在分类变量的分布偏斜或桶的数量过小导致桶碰撞频繁的情况下,我们可能会发现添加一个聚合特征作为模型输入很有帮助。例如,对于每个机场,我们可以找到训练数据集中准点航班的概率,并将其添加为模型的特征。这样一来,当我们对机场代码进行哈希时,就可以避免丢失与个别机场相关联的信息。在某些情况下,我们甚至可以完全避免使用机场名称作为特征,因为准点航班的相对频率可能已经足够。

超参数调优

由于桶碰撞频率的权衡,选择桶的数量可能很困难。这往往取决于问题本身。因此,我们建议将桶的数量视为一个需要调优的超参数:

- parameterName: nbuckets
      type: INTEGER
      minValue: 10
      maxValue: 20
      scaleType: UNIT_LINEAR_SCALE

确保桶的数量保持在哈希的分类变量的基数合理范围内。

密码哈希

哈希特征的丢失性质来自于实现中的取模部分。如果我们完全避免取模会怎么样?毕竟,农场指纹具有固定长度(INT64 为 64 位),因此可以用 64 个特征值来表示,每个特征值为 0 或 1。这被称为二进制编码

然而,二进制编码并不能解决词汇表外的输入或冷启动问题(只能解决高基数的问题)。实际上,比特编码是一个误导。如果我们不进行取模操作,只需对形成 IATA 代码的三个字符进行编码,即可得到唯一的表示(因此使用长度为 326=78 的特征)。这种表示的问题显而易见:以字母 O 开头的机场在航班延误特征上毫无共同之处——编码在相同字母开头的机场之间创建了一个虚假相关性*。在二进制空间中也是如此。因此,我们不推荐对农场指纹值进行二进制编码。

MD5 哈希的二进制编码不会遭受这种虚假相关问题的困扰,因为 MD5 哈希的输出是均匀分布的,所以结果位将是均匀分布的。然而,与 Farm Fingerprint 算法不同,MD5 哈希不是确定性的,也不是唯一的——它是单向哈希,并且会有许多意外的碰撞。

在哈希特征设计模式中,我们必须使用指纹哈希算法而不是加密哈希算法。这是因为指纹函数的目标是产生确定性和唯一的值。如果考虑一下,这是机器学习预处理函数的关键要求,因为在模型服务期间我们需要应用相同的函数并获得相同的哈希值。指纹函数不会产生均匀分布的输出。像 MD5 或 SHA1 这样的加密算法会产生均匀分布的输出,但它们不是确定性的,并且被故意设计成计算昂贵。因此,在特征工程的上下文中,加密哈希不适用,因为在预测期间对于给定的输入计算的哈希值必须与训练期间计算的哈希值相同,而且哈希函数不应减慢机器学习模型的运行速度。

注意

MD5 不确定的原因在于典型情况下会向要进行哈希的字符串添加“盐”。盐是一个添加到每个密码的随机字符串,以确保即使两个用户使用相同的密码,数据库中的哈希值也会不同。这是为了防止基于“彩虹表”的攻击,彩虹表依赖于常用密码的字典,并将已知密码的哈希与数据库中的哈希进行比较。随着计算能力的增加,现在可以对每个可能的盐进行暴力攻击,因此现代加密实现在循环中进行哈希以增加计算开销。即使我们关闭盐并将迭代次数减少到一次,MD5 哈希也只是一种方式。它不会是唯一的。

底线是,我们需要使用指纹哈希算法,并对得到的哈希值进行取模。

操作顺序

请注意,我们首先执行模运算,然后再执行绝对值:

CREATE TEMPORARY FUNCTION hashed(airport STRING, numbuckets INT64) AS (
   ABS(MOD(FARM_FINGERPRINT(airport), numbuckets))
);

在前面片段中ABSMODFARM_FINGERPRINT的顺序很重要,因为INT64的范围不对称。具体来说,它的范围在–9,223,372,036,854,775,8089,223,372,036,854,775,807之间(包括两者)。所以,如果我们执行:

ABS(FARM_FINGERPRINT(airport))

如果FARM_FINGERPRINT操作返回–9,223,372,036,854,775,808,由于其绝对值无法用INT64表示,我们可能会遇到罕见且可能无法重现的溢出错误!

空散列桶

虽然不太可能,但有一种遥远的可能性,即使我们选择了 10 个哈希桶来表示 347 个机场,其中一个哈希桶可能为空。因此,在使用哈希特征列时,可能有利也使用 L2 正则化,以便与空桶关联的权重被推向接近零。这样,如果一个超出词汇表的机场确实掉入一个空桶中,它不会导致模型在数值上不稳定。

设计模式 2:嵌入

嵌入是一种可学习的数据表示,将高基数数据映射到低维空间,以保留与学习问题相关的信息。嵌入是现代机器学习的核心,并在该领域中有各种各样的体现。

问题

机器学习模型系统地寻找数据中的模式,捕捉模型输入特征与输出标签的属性关系。因此,输入特征的数据表示直接影响最终模型的质量。虽然处理结构化的数值输入相对简单,但用于训练机器学习模型的数据可以是多种多样的,如分类特征、文本、图像、音频、时间序列等等。对于这些数据表示,我们需要提供一个有意义的数值供给我们的机器学习模型,以便这些特征能够符合典型的训练范式。嵌入提供了一种处理这些不同数据类型的方式,可以保持项目之间的相似性,从而提高我们模型学习这些重要模式的能力。

单热编码是表示分类输入变量的常用方式。例如,考虑出生数据集中的多重输入。³ 这是一个具有六种可能值的分类输入:['Single(1)', 'Multiple(2+)', 'Twins(2)', 'Triplets(3)', 'Quadruplets(4)', 'Quintuplets(5)']。我们可以使用单热编码来处理这种分类输入,将每个潜在的输入字符串值映射到 R⁶ 中的单位向量,如表 2-3 所示。

表 2-3. 对出生数据集进行分类输入单热编码的示例

多重输入 单热编码
Single(1) [1,0,0,0,0,0]
Multiple(2+) [0,1,0,0,0,0]
Twins(2) [0,0,1,0,0,0]
Triplets(3) [0,0,0,1,0,0]
Quadruplets(4) [0,0,0,0,1,0]
Quintuplets(5) [0,0,0,0,0,1]

通过这种方式编码,我们需要六个维度来表示不同的类别。六个维度可能并不算太多,但如果我们需要考虑更多的类别呢?

例如,假设我们的数据集包含顾客对视频数据库的观看历史,我们的任务是根据顾客以前的视频互动建议一组新视频?在这种情况下,customer_id字段可能有数百万个唯一条目。同样,先前观看视频的video_id也可能包含数千个条目。将高基数分类特征如video_idscustomer_ids使用一位有效编码作为机器学习模型的输入,会导致一个对多种机器学习算法不太适用的稀疏矩阵。

使用一位有效编码的第二个问题是它将分类变量视为独立的。然而,对双胞胎的数据表示应接近对三胞胎的数据表示,而与五胞胎的数据表示相距甚远。多胞胎最有可能是双胞胎,但可能是三胞胎。例如,表 2-4 显示了在较低维度中捕获这种紧密关系的多数列的替代表示。

表 2-4. 使用较低维度的嵌入表示出生数据集中的多数列。

Plurality 候选编码
Single(1) [1.0,0.0]
Multiple(2+) [0.0,0.6]
Twins(2) [0.0,0.5]
Triplets(3) [0.0,0.7]
Quadruplets(4) [0.0,0.8]
Quintuplets(5) [0.0,0.9]

这些数字当然是任意的。但是否可能仅使用两个维度学习出最佳的多数列表示法来解决出生问题?这就是嵌入设计模式解决的问题。

高基数和相关数据的相同问题也存在于图像和文本中。图像由数千个像素组成,这些像素并不相互独立。自然语言文本来自数万个单词的词汇表,像walk这样的词比book更接近run

解决方案

嵌入设计模式解决了在较低维度中密集表示高基数数据的问题,通过将输入数据传递到一个具有可训练权重的嵌入层,将高维度的分类输入变量映射到某个低维度空间的实值向量中。创建密集表示的权重是作为模型优化的一部分学习的(见图 2-5)。实际上,这些嵌入最终捕获了输入数据中的紧密关系。

嵌入层的权重在训练过程中作为参数学习。

图 2-5. 嵌入层的权重在训练过程中作为参数学习。
提示

因为嵌入捕捉输入数据中的接近关系,并以较低维度的表示,我们可以将嵌入层用作聚类技术(例如客户分割)和主成分分析(PCA)等降维方法的替代品。嵌入权重在主模型训练循环中确定,因此不需要事先进行聚类或进行 PCA。

在训练出生模型时,嵌入层中的权重会作为梯度下降过程的一部分而被学习。

训练结束时,嵌入层的权重可能是表 2-5 中所示分类变量的编码,详情请见表 2-5。

表 2-5. 出生数据集中多胞胎列的单热编码和学习编码

多胞胎 单热编码 学习编码
单胞胎(1) [1,0,0,0,0,0] [0.4, 0.6]
多胞胎(2+) [0,1,0,0,0,0] [0.1, 0.5]
双胞胎(2) [0,0,1,0,0,0] [-0.1, 0.3]
三胞胎(3) [0,0,0,1,0,0] [-0.2, 0.5]
四胞胎(4) [0,0,0,0,1,0] [-0.4, 0.3]
五胞胎(5) [0,0,0,0,0,1] [-0.6, 0.5]

嵌入将稀疏的单热编码向量映射到 R² 中的密集向量。

在 TensorFlow 中,我们首先为特征构建一个分类特征列,然后将其包装在嵌入特征列中。例如,对于我们的多胞胎特征,我们将会有:

plurality = tf.feature_column.categorical_column_with_vocabulary_list(
            'plurality', ['Single(1)', 'Multiple(2+)', 'Twins(2)', 
'Triplets(3)', 'Quadruplets(4)', 'Quintuplets(5)'])
plurality_embed = tf.feature_column.embedding_column(plurality, dimension=2)

结果特征列 (plurality_embed) 作为输入传递给神经网络的下游节点,而不是单热编码特征列 (plurality)。

文本嵌入

文本提供了一个自然的背景,使用嵌入层是有利的。考虑到词汇的基数(通常是数万个词),使用单热编码每个词并不实际。这将创建一个非常大(高维度)且稀疏的矩阵进行训练。此外,我们希望相似的词在嵌入空间中靠近,不相关的词则远离。因此,在将离散文本输入模型之前,我们使用密集的词嵌入来向量化。

在 Keras 中实现文本嵌入,我们首先为词汇中的每个单词创建一个标记化,如图 2-6 所示。然后,我们使用这个标记化将其映射到嵌入层,类似于对多胞胎列的处理。

分词器创建一个查找表,将每个单词映射到一个索引。

图 2-6. 分词器创建一个查找表,将每个单词映射到一个索引。

Tokenization 是一个查找表,将我们词汇表中的每个单词映射到一个索引。我们可以将其视为每个单词的一热编码,其中 tokenized 索引是一热编码中非零元素的位置。这需要对整个数据集进行完整遍历(假设这些数据集由文章标题组成⁴),以创建查找表,并可以在 Keras 中完成。有关本书的完整代码,请查看存储库

from tensorflow.keras.preprocessing.text import Tokenizer

tokenizer = Tokenizer()
tokenizer.fit_on_texts(titles_df.title)

在这里,我们可以使用 keras.preprocessing.text 库中的 Tokenizer 类。调用 fit_on_texts 方法会创建一个查找表,将标题中的每个单词映射到一个索引。通过调用 tokenizer.index_word,我们可以直接查看这个查找表:

tokenizer.index_word
{1: 'the',
 2: 'a',
 3: 'to',
 4: 'for',
 5: 'in',
 6: 'of',
 7: 'and',
 8: 's',
 9: 'on',
 10: 'with',
 11: 'show',
...

然后,我们可以使用我们的 tokenizer 的 texts_to_sequences 方法调用这个映射。这将每个输入文本中的单词序列(这里假设它们是文章的标题)映射到与每个单词对应的 token 序列,就像图 2-7 中描述的那样。

integerized_titles = tokenizer.texts_to_sequences(titles_df.title)

使用 tokenizer,每个标题被映射到一个整数索引值序列。

图 2-7. 使用 tokenizer,每个标题被映射到一个整数索引值序列。

Tokenizer 还包含其他相关信息,稍后我们将用于创建嵌入层。特别是,VOCAB_SIZE 捕获了索引查找表的元素数量,而 MAX_LEN 包含了数据集中文本字符串的最大长度:

VOCAB_SIZE = len(tokenizer.index_word)
MAX_LEN = max(len(sequence) for sequence in integerized_titles)

在创建模型之前,需要对数据集中的标题进行预处理。我们将需要填充标题的元素以输入模型。Keras 提供了 pad_sequence 辅助函数来完成这一操作。函数 create_sequences 接受标题和最大句子长度作为输入,并返回整数列表,这些整数对应于我们的 token 并填充到句子的最大长度:

from tensorflow.keras.preprocessing.sequence import pad_sequences

def create_sequences(texts, max_len=MAX_LEN):
    sequences = tokenizer.texts_to_sequences(texts)
    padded_sequences = pad_sequences(sequences,
                                     max_len,
                                     padding='post')
    return padded_sequences

接下来,我们将在 Keras 中构建一个深度神经网络(DNN)模型,实现一个简单的嵌入层,将单词整数映射到密集向量。Keras 的 Embedding 层可以被视为从特定单词的整数索引到密集向量(它们的嵌入)的映射。嵌入的维度由 output_dim 决定。参数 input_dim 表示词汇表的大小,input_shape 表示输入序列的长度。因为我们在传递给模型之前对标题进行了填充,所以我们设置 input_shape=[MAX_LEN]

model = models.Sequential([layers.Embedding(input_dim=VOCAB_SIZE + 1,
                                            output_dim=embed_dim,
                                            input_shape=[MAX_LEN]),
                           layers.Lambda(lambda x: tf.reduce_mean(x,axis=1)),
                           layers.Dense(N_CLASSES, activation='softmax')])

请注意,在嵌入层和密集 softmax 层之间,我们需要添加一个自定义的 Keras Lambda 层,以对嵌入层返回的单词向量进行平均。这个平均值被馈送到密集 softmax 层。通过这样做,我们创建了一个简单的模型,但是失去了单词顺序的信息,从而创建了一个将句子视为“词袋”的模型。

图像嵌入

虽然文本处理的输入非常稀疏,但其他数据类型,如图像或音频,由稠密、高维度向量组成,通常具有多个包含原始像素或频率信息的通道。在这种情况下,嵌入捕获了输入的相关低维表示。

对于图像嵌入,首先在大型图像数据集(如包含数百万图像和数千个可能分类标签的 ImageNet)上训练复杂的卷积神经网络(如 Inception 或 ResNet)。然后,从模型中删除最后一个 softmax 层。没有最终的 softmax 分类器层,模型可以用于提取给定输入的特征向量。这个特征向量包含图像的所有相关信息,因此实质上是输入图像的低维嵌入。

同样,考虑图像字幕生成任务,即生成给定图像的文本说明,如图 2-8 所示。

对于图像翻译任务,编码器生成图像的低维嵌入表示。

图 2-8. 对于图像翻译任务,编码器生成图像的低维嵌入表示。

通过在大量图像/说明对的数据集上训练此模型架构,编码器学习了图像的有效向量表示。解码器学习如何将这个向量翻译成文本说明。在这个意义上,编码器成为了一个 Image2Vec 嵌入机器。

为什么它有效

嵌入层只是神经网络的另一个隐藏层。然后,将权重与每个高基数维度相关联,并将输出通过网络的其余部分。因此,通过梯度下降的过程学习用于创建嵌入的权重就像神经网络中的任何其他权重一样。这意味着生成的向量嵌入代表了相对于学习任务最有效的低维表示的特征值。

虽然这种改进的嵌入最终有助于模型,但嵌入本身具有固有的价值,并允许我们深入了解数据集。

再次考虑客户视频数据集。仅使用独热编码时,任意两个独立用户,user_i 和 user_j,将具有相同的相似度测量。类似地,出生多胞胎的六维独热编码的任何两个不同点积或余弦相似度将具有零相似度。这是有道理的,因为独热编码本质上告诉我们的模型将任何两个不同的出生多胞胎视为分开且不相关的。对于我们的客户和视频观看数据集,我们失去了客户或视频之间的任何相似性概念。但这感觉不太对。两个不同的客户或视频可能确实存在相似之处。出生多胞胎也是如此。四胞胎和五胞胎的出现可能会以与单胞胎出生体重统计上相似的方式影响出生体重(参见图 2-9)。

通过将我们的分类变量强制投射到低维嵌入空间,我们还可以学习不同类别之间的关系。

图 2-9. 通过将我们的分类变量强制投射到低维嵌入空间,我们还可以学习不同类别之间的关系。

在计算以独热编码向量表示的多类别相似性时,我们得到了单位矩阵,因为每个类别被视为一个独特的特征(参见表格 2-6)。

表格 2-6. 当特征被独热编码时,相似性矩阵就是单位矩阵

单胞胎(1) 多个(2+) 双胞胎(2) 三胞胎(3) 四胞胎(4) 五胞胎(5)
单胞胎(1) 1 0 0 0 0 0
多个(2+) - 1 0 0 0 0
双胞胎(2) - - 1 0 0 0
三胞胎(3) - - - 1 0 0
四胞胎(4) - - - - 1 0
五胞胎(5) - - - - - 1

然而,一旦多样性被嵌入到两个维度中,相似度测量变得非平凡,并且不同类别之间的重要关系显现出来(参见表格 2-7)。

表格 2-7. 当特征嵌入到两个维度时,相似性矩阵提供了更多信息

单胞胎(1) 多个(2+) 双胞胎(2) 三胞胎(3) 四胞胎(4) 五胞胎(5)
单胞胎(1) 1 0.92 0.61 0.57 0.06 0.1
多个(2+) - 1 0.86 0.83 0.43 0.48
双胞胎(2) - 1 0.99 0.82 0.85
三胞胎(3) - 1 0.85 0.88
四胞胎(4) - 1 0.99
五胞胎(5) - - - - - 1

因此,学习得到的嵌入允许我们提取两个不同类别之间的内在相似性,并且,鉴于有数值向量表示,我们可以精确量化两个分类特征之间的相似度。

这在出生率数据集中很容易进行可视化,但在处理嵌入到 20 维空间的customer_ids时也适用相同的原则。在应用于我们的客户数据集时,嵌入使我们能够检索与给定customer_id相似的客户,并基于相似性提供建议,例如他们可能观看的视频,如图 2-10 所示。此外,这些用户和项目嵌入可以与训练独立机器学习模型时的其他特征结合使用。在机器学习模型中使用预训练的嵌入被称为迁移学习

通过为每个客户和视频学习低维、密集的嵌入向量,基于嵌入的模型能够在减少手工特征工程负担的同时,表现出良好的泛化能力。

Figure 2-10. 通过为每个客户和视频学习低维、密集的嵌入向量,基于嵌入的模型能够在减少手工特征工程负担的同时,表现出良好的泛化能力。

折衷与替代方案

使用嵌入的主要折衷是数据表示的损失。从高基数表示转换为低维表示涉及信息的丢失。作为回报,我们获取有关项目接近度和上下文的信息。

选择嵌入维度

嵌入空间的确切维度是我们作为从业者选择的。那么,我们应该选择大的还是小的嵌入维度?当然,就像大多数机器学习中的事物一样,这里存在一个权衡。表示的损失性由嵌入层的大小控制。选择一个非常小的嵌入层输出维度会强制过多的信息进入一个小的向量空间,可能导致上下文丢失。另一方面,当嵌入维度过大时,嵌入会失去对特征学习到的上下文重要性的理解。在极端情况下,我们回到了使用一热编码时遇到的问题。通过实验通常可以找到最佳的嵌入维度,类似于选择深度神经网络层中的神经元数量。

如果我们急于求成,一个经验法则是使用总共唯一分类元素的第四根,而另一个则是嵌入维度应该约为唯一元素数的平方根的 1.6 倍,且不少于 600. 例如,假设我们想要使用嵌入层来编码一个有 625 个唯一值的特征。按照第一个经验法则,我们会选择嵌入维度为 5,按照第二个经验法则,我们会选择 40. 如果我们进行超参数调整,可能值得在这个范围内进行搜索。

自编码器

以监督方式训练嵌入可能很困难,因为它需要大量标记数据。像 Inception 这样的图像分类模型要能够生成有用的图像嵌入,它是在拥有 1400 万标记图像的 ImageNet 数据集上训练的。自编码器提供了一种方法来绕过对大量标记数据的需求。

典型的自编码器架构,如图 2-11,包括一个瓶颈层,这实质上是一个嵌入层。瓶颈层之前的网络(“编码器”)将高维输入映射到低维嵌入层,而后面的网络(“解码器”)将该表示映射回与原始输入相同的更高维度。该模型通常在某种重构误差的变体上进行训练,这迫使模型的输出尽可能与输入相似。

当训练自编码器时,特征和标签是相同的,损失函数是重构误差。这使得自编码器能够实现非线性降维。

图 2-11。当训练自编码器时,特征和标签是相同的,损失函数是重构误差。这使得自编码器能够实现非线性降维。

因为输入与输出相同,不需要额外的标签。编码器通过学习输入的最优非线性降维。类似于 PCA 通过线性降维,自编码器的瓶颈层能够通过嵌入实现非线性降维。

这使我们能够将一个困难的机器学习问题分解为两个部分。首先,我们利用所有未标记的数据,通过将自编码器作为辅助学习任务,从高基数向低基数转换。然后,我们使用辅助自编码器任务生成的嵌入来解决实际的图像分类问题,对于这类问题,我们通常拥有较少的标记数据。这很可能会提升模型性能,因为现在模型只需学习较低维度设置的权重(即,它只需学习较少的权重)。

除了图像自编码器外,最近的工作还专注于应用深度学习技术处理结构化数据。TabNet 是一种专门设计用于从表格数据中学习的深度神经网络,可以以非监督方式训练。通过修改模型以具有编码器-解码器结构,TabNet 在表格数据上工作作为自编码器,允许模型通过特征转换器从结构化数据中学习嵌入。

上下文语言模型

是否有适用于文本的辅助学习任务?像 Word2Vec 这样的上下文语言模型和像 BERT 这样的遮蔽语言模型改变了学习任务的方式,以解决标签稀缺的问题。

Word2Vec 是一种利用浅层神经网络构建嵌入的知名方法,结合了连续词袋(CBOW)和跳字模型两种技术,应用于大型文本语料库,如维基百科。虽然这两种模型的目标都是通过将输入单词映射到具有中间嵌入层的目标单词来学习单词的上下文,但还实现了一个辅助目标,即学习最能捕捉单词上下文的低维嵌入。通过 Word2Vec 学到的单词嵌入捕捉了单词之间的语义关系,因此在嵌入空间中,向量表示保持了有意义的距离和方向性(Figure 2-12)。

单词嵌入捕获语义关系。

图 2-12. 单词嵌入捕获语义关系。

BERT 使用了掩码语言模型和下一个句子预测进行训练。对于掩码语言模型,文本中的单词会被随机掩码,模型猜测缺失的单词是什么。下一个句子预测是一个分类任务,模型预测原始文本中两个句子是否相邻。因此,任何文本语料库都适合作为标记数据集。BERT 最初在整个英文维基百科和 BooksCorpus 上进行了训练。尽管在这些辅助任务上进行了学习,但从 BERT 或 Word2Vec 学到的嵌入在用于其他下游训练任务时已被证明非常强大。Word2Vec 学到的单词嵌入与单词出现的句子无关。然而,BERT 单词嵌入是上下文相关的,意味着嵌入向量依赖于单词使用的上下文。

类似 Word2Vec、NNLM、GLoVE 或 BERT 这样的预训练文本嵌入可以添加到机器学习模型中,以处理文本特征,同时与结构化输入和来自我们的客户和视频数据集的其他学习嵌入一起使用(Figure 2-13)。

最终,嵌入学习保留了与指定训练任务相关的信息。在图像字幕任务中,任务是学习图像元素的上下文如何与文本相关联。在自编码器架构中,标签与特征相同,因此瓶颈的维度缩减试图学习所有内容,没有特定上下文可以说明什么是重要的。

可以向模型添加预训练文本嵌入以处理文本特征。

图 2-13. 可以向模型添加预训练文本嵌入以处理文本特征。

数据仓库中的嵌入

在数据仓库上对结构化数据进行机器学习最好直接在 SQL 上进行。这样可以避免将数据从数据仓库导出,减少数据隐私和安全问题。

然而,许多问题需要结构化数据和自然语言文本或图像数据的混合。在数据仓库中,自然语言文本(例如评论)直接存储为列,图像通常存储为云存储桶中文件的 URL。在这些情况下,将文本列的嵌入或图像的嵌入作为数组类型列存储,可以简化后续的机器学习过程。这样做将使得将这些非结构化数据轻松地整合到机器学习模型中成为可能。

要创建文本嵌入,我们可以将诸如 TensorFlow Hub 中的 Swivel 预训练模型加载到 BigQuery 中。完整的代码位于GitHub上:

CREATE OR REPLACE MODEL advdata.swivel_text_embed
OPTIONS(model_type='tensorflow', model_path='gs://BUCKET/swivel/*')

然后,使用模型将自然语言文本列转换为嵌入数组,并将嵌入查找存储到新表中:

CREATE OR REPLACE TABLE advdata.comments_embedding AS
SELECT
  output_0 as comments_embedding,
  comments
FROM ML.PREDICT(MODEL advdata.swivel_text_embed,(
  SELECT comments, LOWER(comments) AS sentences
  FROM `bigquery-public-data.noaa_preliminary_severe_storms.wind_reports`
))

现在可以针对此表进行连接,以获取任何评论的文本嵌入。对于图像嵌入,我们可以类似地将图像 URL 转换为嵌入并加载到数据仓库中。

以这种方式预计算特征是“设计模式 26:特征存储”的一个示例(参见第 6 章中的“设计模式 26:特征存储”)。

设计模式 3:特征交叉

特征交叉设计模式通过显式地将每个输入值的组合作为独立特征,帮助模型更快地学习输入之间的关系。

问题

考虑在图 2-14 中的数据集以及创建将+和−标签分离的二元分类器的任务。

使用仅x_1x_2坐标,不可能找到分离+和−类的线性边界。

这意味着要解决这个问题,我们必须使模型更复杂,也许通过向模型添加更多层次。然而,存在一个更简单的解决方案。

仅使用 x_1 和 x_2 作为输入,无法使该数据集线性可分。

图 2-14. 仅使用 x_1 和 x_2 作为输入,该数据集不是线性可分的。

解决方案

在机器学习中,特征工程是利用领域知识创建新特征的过程,这有助于机器学习过程并增强模型的预测能力。一个常用的特征工程技术是创建特征交叉。

特征交叉是通过连接两个或多个分类特征来形成的合成特征,以捕捉它们之间的交互作用。通过这种方式联结两个特征,可以在模型中编码非线性,这可以允许超出单独每个特征能够提供的预测能力。特征交叉为模型学习特征之间的关系提供了一种方法。虽然像神经网络和树这样的更复杂模型可以自行学习特征交叉,但显式使用特征交叉可以让我们只需训练线性模型就能摆脱。因此,特征交叉可以加快模型训练速度(更经济)并减少模型复杂性(需要更少的训练数据)。

为了创建上述数据集的特征列,我们可以将 x_1 和 x_2 分别分桶成两个桶,取决于它们的符号。这将把 x_1 和 x_2 转换成分类特征。让 A 表示 x_1 >= 0 的桶,B 表示 x_1 < 0 的桶。让 C 表示 x_2 >= 0 的桶,D 表示 x_2 < 0 的桶(参见 图 2-15)。

特征交叉引入了四个新的布尔特征。

图 2-15. 特征交叉引入了四个新的布尔特征。

对于我们的模型,这些分桶特征的特征交叉引入了四个新的布尔特征:

AC,其中 x_1 >= 0 且 x_2 >= 0

BC,其中 x_1 < 0 且 x_2 >= 0

AD,其中 x_1 >= 0 且 x_2 < 0

BD,其中 x_1 < 0 且 x_2 < 0

当训练模型时,这四个布尔特征(AC、BC、AD 和 BD)各自会得到自己的权重。这意味着我们可以将每个象限视为其自身的特征。由于原始数据集完全按照我们创建的桶进行了分割,A 和 B 的特征交叉能够线性分离数据集。

但这只是一个例子。那么真实世界的数据呢?考虑纽约市黄色出租车的公共数据集(参见 表 2-8)⁵。

表 2-8. BigQuery 中公共纽约市出租车数据集的预览

pickup_datetime pickuplon pickuplat dropofflon dropofflat passengers fare_amount
2014-05–17 15:15:00 UTC -73.99955 40.7606 -73.99965 40.72522 1 31
2013–12-09 15:03:00 UTC -73.99095 40.749772 -73.870807 40.77407 1 34.33
2013-04–18 08:48:00 UTC -73.973102 40.785075 -74.011462 40.708307 1 29
2009–11-05 06:47:00 UTC -73.980313 40.744282 -74.015285 40.711458 1 14.9
2009-05-21 09:47:06 UTC -73.901887 40.764021 -73.901795 40.763612 1 12.8

这个数据集包含有关纽约市出租车行程的信息,包括接客时间戳、接送点纬度和经度以及乘客数量。标签是 fare_amount,即出租车费用。这个数据集中可能与特征交叉相关的特征是哪些?

可能有很多个。我们来考虑 pickup_datetime。从这个特征中,我们可以使用有关行程小时和星期几的信息。每个都是分类变量,并且肯定都包含预测出租车费用的预测能力。对于这个数据集,考虑 day_of_weekhour_of_day 的特征交叉是有意义的,因为合理地假设星期一下午 5 点的出租车行程应该与星期五下午 5 点的出租车行程有所不同(参见表 2-9)。

表 2-9. 我们用于创建特征交叉的数据预览:星期几和小时数列

day_of_week hour_of_day
星期日 00
星期日 01
... ...
星期六 23

特征交叉包括这两个特征的一个 168 维的 one-hot 编码向量(24 小时 × 7 天 = 168),例如“星期一下午 5 点”占据单个索引,表示 (day_of_week 是星期一与 hour_of_day 是 17 连接)。

虽然这两个特征本身很重要,但允许 hour_of_dayday_of_week 的特征交叉使得出租车费用预测模型更容易识别周末高峰时段如何影响出租车行程持续时间,从而影响出租车费用。

在 BigQuery ML 中的特征交叉

要在 BigQuery 中创建特征交叉,我们可以使用 ML.FEATURE_CROSS 函数,并传递特征 day_of_weekhour_of_daySTRUCT

ML.FEATURE_CROSS(STRUCT(day_of_week,hour_of_week)) AS day_X_hour

STRUCT 子句创建这两个特征的有序对。如果我们的软件框架不支持特征交叉函数,我们可以使用字符串连接来达到同样的效果:

CONCAT(CAST(day_of_week AS STRING),
       CAST(hour_of_week AS STRING)) AS day_X_hour

下面展示了一个完整的生育问题的训练示例,使用了 is_maleplurality 列作为特征交叉;详见本书的代码库

CREATE OR REPLACE MODEL babyweight.natality_model_feat_eng
TRANSFORM(weight_pounds,
    is_male,
    plurality,
    gestation_weeks,      
    mother_age,
    CAST(mother_race AS string) AS mother_race,
    ML.FEATURE_CROSS(
            STRUCT(
                is_male,
                plurality)
 `)` AS gender_X_plurality)
OPTIONS
  (MODEL_TYPE='linear_reg',
   INPUT_LABEL_COLS=['weight_pounds'],
   DATA_SPLIT_METHOD="NO_SPLIT") AS    
SELECT
  *
FROM
    babyweight.babyweight_data_train
提示

当工程化出生模型的特征时,这里使用了转换模式(参见第 6 章),这也允许模型在预测期间“记住”执行输入数据字段的特征交叉。

当我们拥有足够的数据时,特征交叉模式使模型变得更加简单。在 natality 数据集上,带有特征交叉模式的线性模型在评估集上的 RMSE 为 1.056。相比之下,在同一数据集上使用没有特征交叉的 BigQuery ML 深度神经网络训练,其 RMSE 为 1.074。尽管使用了更简单的线性模型,但我们的性能有轻微提升,而且训练时间也大幅减少。

TensorFlow 中的特征交叉

在 TensorFlow 中使用 is_maleplurality 来实现特征交叉,我们使用 tf.feature_column.crossed_column 方法。crossed_column 方法接受两个参数:要交叉的特征键列表和哈希桶大小。交叉特征将根据 hash_bucket_size 进行哈希,因此它应该足够大,以便舒适地降低碰撞的可能性。由于 is_male 输入可以取 3 个值(True、False、Unknown),而 plurality 输入可以取 6 个值(Single(1)、Twins(2)、Triplets(3)、Quadruplets(4)、Quintuplets(5)、Multiple(2+)),因此有 18 种可能的 (is_male, plurality) 对。如果我们将 hash_bucket_size 设置为 1,000,我们可以确保有 85% 的把握没有碰撞发生。

最后,在 DNN 模型中使用交叉列,我们需要将其包装在 indicator_columnembedding_column 中,具体取决于我们是想要进行独热编码还是在较低维度中表示它(见本章中的 “设计模式 2: 嵌入” ):

| gender_x_plurality = fc.crossed_column(["is_male", "plurality"], hash_bucket_size=1000)

crossed_feature = fc.embedding_column(gender_x_plurality, dimension=2) |

或者

| gender_x_plurality = fc.crossed_column(["is_male", "plurality"], hash_bucket_size=1000)

crossed_feature = fc.indicator_column(gender_x_plurality) |

为什么有效

特征交叉提供了一种有价值的特征工程手段。它们为简单模型提供了更多的复杂性、表现力和容量。再次思考一下在 natality 数据集中 is_maleplurality 的交叉特征。这种特征交叉模式允许模型将双胞胎男性、女性双胞胎、三胞胎男性和单身女性等分开对待。当我们使用 indicator_column 时,模型能够将每个结果交叉视为独立变量,实质上为模型添加了额外的 18 个二进制分类特征(见 图 2-16 )。

特征交叉在大数据场景中表现良好。虽然向深度神经网络添加额外层可以潜在地提供足够的非线性来学习(is_male, plurality)对的行为方式,但这会极大地增加训练时间。在出生数据集上,我们观察到使用 BigQuery ML 训练的带有特征交叉的线性模型与不带特征交叉的 DNN 相比表现相当。然而,线性模型的训练速度明显更快。

is_male 和 plurality 之间的特征交叉在我们的 ML 模型中创建了额外的 18 个二进制特征。

图 2-16. is_maleplurality 之间的特征交叉在我们的 ML 模型中创建了额外的 18 个二进制特征。

表 2-10 比较了 BigQuery ML 中线性模型带有 (is_male, plurality) 特征交叉和不带特征交叉的深度神经网络的训练时间和评估损失。

表 2-10. BigQuery ML 训练指标的比较:带有特征交叉和不带特征交叉的模型

模型类型 包括特征交叉 训练时间(分钟) 评估损失(RMSE)
线性 0.42 1.05
DNN No 48 1.07

简单的线性回归在评估集上达到了可比较的误差,但训练速度快了一百倍。将特征交叉与大数据结合使用是学习训练数据中复杂关系的另一种策略。** **## 折衷方案和替代方案

尽管我们讨论特征交叉作为处理分类变量的一种方式,但经过一些预处理,它们也可以应用于数值特征。特征交叉会导致模型稀疏性,并常与抵消该稀疏性的技术一起使用。

处理数值特征

我们绝不希望对连续输入进行特征交叉。记住,如果一个输入有 m 个可能的取值,另一个输入有 n 个可能的取值,那么这两者的特征交叉将导致 m*n 个元素。数值输入是密集的,可以取连续的值。在连续输入数据的特征交叉中无法枚举所有可能的值。

如果我们的数据是连续的,那么在应用特征交叉之前,我们可以将数据进行分桶处理,使其变成分类数据。例如,纬度和经度是连续的输入,使用这些输入创建特征交叉是直觉上的合理选择,因为位置由纬度和经度的有序对确定。然而,我们不会直接使用原始的纬度和经度创建特征交叉,而是对这些连续数值进行分箱处理,然后交叉binned_latitudebinned_longitude

import tensorflow.feature_column as fc

# Create a bucket feature column for latitude.
latitude_as_numeric = fc.numeric_column("latitude")
lat_bucketized = fc.bucketized_column(latitude_as_numeric,
                                      lat_boundaries)
# Create a bucket feature column for longitude.
longitude_as_numeric = fc.numeric_column("longitude")
lon_bucketized = fc.bucketized_column(longitude_as_numeric,
                                      lon_boundaries)

# Create a feature cross of latitude and longitude
lat_x_lon = fc.crossed_column([lat_bucketized, lon_bucketized],  
                               hash_bucket_size=nbuckets**4)

crossed_feature = fc.indicator_column(lat_x_lon)

处理高基数

由于特征交叉后结果类别的基数与输入特征的基数呈乘法关系,特征交叉导致我们模型输入的稀疏性。 即使是day_of_weekhour_of_day特征交叉,特征交叉也会生成一个维数为 168 的稀疏向量(参见图 2-17)。

将特征交叉通过嵌入层传递(参见本章的“设计模式 2:嵌入”)以创建一个较低维度的表示,如图 2-18 所示。

星期几和小时的特征交叉生成一个维数为 168 的稀疏向量。

图 2-17. 星期几和小时的特征交叉生成一个维数为 168 的稀疏向量。

嵌入层是解决特征交叉稀疏性的有效方法。

图 2-18. 嵌入层是解决特征交叉稀疏性的有效方法。

由于嵌入设计模式允许我们捕捉接近关系,通过嵌入层传递特征交叉允许模型概括来自小时和日期组合对模型输出的影响。 在上述纬度和经度的例子中,我们可以使用嵌入特征列代替指示器列:

crossed_feature = fc.embedding_column(lat_x_lon, dimension=2)

正则化的必要性

当交叉两个具有大基数的分类特征时,我们产生一个具有乘法基数的交叉特征。 自然而然,对于单个特征,给定更多类别,特征交叉中的类别数可能会显著增加。 如果这一点达到使得单个存储桶中的项目太少,这将阻碍模型的泛化能力。 想象一下纬度和经度的例子。 如果我们对纬度和经度进行非常精细的划分,那么特征交叉将非常精确,使得模型可以记住地图上的每一点。 但是,如果这种记忆是基于只有少数示例的话,这种记忆实际上是过度拟合的。

举例说明,例如预测纽约的出租车费用,给定接送地点和接送时间:⁶

CREATE OR REPLACE MODEL mlpatterns.taxi_l2reg
TRANSFORM(
  fare_amount
 , ML.FEATURE_CROSS(STRUCT(CAST(EXTRACT(DAYOFWEEK FROM pickup_datetime) 
                    AS STRING) AS dayofweek,
                            CAST(EXTRACT(HOUR FROM pickup_datetime) 
                    AS STRING) AS hourofday), 2) AS day_hr
  , CONCAT(
     ML.BUCKETIZE(pickuplon, GENERATE_ARRAY(-78, -70, `0``.``01`)),
     ML.BUCKETIZE(pickuplat, GENERATE_ARRAY(37, 45, 0.01)),
     ML.BUCKETIZE(dropofflon, GENERATE_ARRAY(-78, -70, 0.01)),
     ML.BUCKETIZE(dropofflat, GENERATE_ARRAY(37, 45, 0.01))
  ) AS pickup_and_dropoff
)
OPTIONS(input_label_cols=['fare_amount'], 
        model_type='linear_reg', `l2_reg``=``0``.``1`) 
AS
SELECT * FROM mlpatterns.taxi_data

这里有两个特征交叉:一个在时间上(星期几和小时),另一个在空间上(接送地点)。 特别是地点具有非常高的基数,某些存储桶可能只有非常少的示例。

为此,建议将特征交叉与 L1 正则化配对,以鼓励特征的稀疏性,或者与 L2 正则化配对,以限制过拟合。这使得我们的模型能够忽略由许多合成特征生成的多余噪音,并抵抗过拟合。事实上,在这个数据集上,正则化略微改善了 RMSE,提高了 0.3%。

作为相关的一点,在选择要组合成特征交叉的特征时,我们不希望交叉两个高度相关的特征。我们可以将特征交叉视为将两个特征组合成有序对。实际上,“交叉”在“特征交叉”中的术语指的是笛卡尔积。如果两个特征高度相关,那么它们的特征交叉的“跨度”不会为模型带来任何新信息。举一个极端的例子,假设我们有两个特征,x_1 和 x_2,其中 x_2 = 5*x_1。通过它们的符号对 x_1 和 x_2 进行分桶,并创建特征交叉,仍然会产生四个新的布尔特征。然而,由于 x_1 和 x_2 的依赖性,这四个特征中有两个实际上是空的,另外两个恰好是为 x_1 创建的两个桶。# 设计模式 4:多模态输入

多模态输入设计模式解决了表示不同类型数据或可以通过连接所有可用数据表示的复杂方式表达的数据问题。

问题

通常,模型的输入可以表示为数字或类别、图像或自由形式文本。许多现成的模型仅定义了特定类型的输入——例如,标准的图像分类模型如 Resnet-50 并不具备处理除图像外的其他类型输入的能力。

要理解多模态输入的必要性,我们可以假设有一台摄像机在路口拍摄录像以识别交通违规行为。我们希望我们的模型处理图像数据(摄像头录像)以及有关图像捕捉时间的一些元数据(时间、星期几、天气等),如图 2-19 所示。

当训练结构化数据模型时,其中一个输入是自由形式文本时,也会出现这个问题。与数值数据不同,图像和文本不能直接输入模型。因此,我们需要以模型理解的方式表示图像和文本输入(通常使用嵌入设计模式),然后将这些输入与其他表格⁷特征结合起来。例如,我们可能希望根据顾客的评价文本以及他们支付的金额和用餐时间(午餐还是晚餐等)来预测餐厅顾客的评分(见图 2-20)。

模型结合图像和数值特征以预测路口录像是否显示交通违规行为。

图 2-19. 模型结合图像和数字特征,以预测交叉口镜头是否显示交通违规行为。

模型结合自由文本输入和表格数据,以预测餐厅评论的评分。

图 2-20. 模型结合自由文本输入和表格数据,以预测餐厅评论的评分。

解决方案

首先,让我们以餐厅评论中的文本为例,结合有关评论所引用的餐点的表格元数据。我们将首先结合数字和分类特征。对于meal_type,有三种可能的选项,所以我们可以将其转换为一种独热编码,并将晚餐表示为[0, 0, 1]。通过将餐点的价格添加为数组的第四个元素,我们现在可以将其与 meal_total 结合起来:[0, 0, 1, 30.5]。

嵌入设计模式是一种常见的方法,用于为机器学习模型编码文本。如果我们的模型只有文本,我们可以使用以下tf.keras代码将其表示为嵌入层:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Embedding

model = Sequential()
model.add(Embedding(batch_size, 64, input_length=30))

在这里,我们需要展平嵌入⁸,以便与meal_typemeal_total连接:

model.add(Flatten())

然后,我们可以使用一系列稠密层将这个非常大的数组⁹转换成较小的数组,最后以我们的输出结束,这是一个由三个数字组成的数组:

model.add(Dense(3, activation="relu"))

现在,我们需要将形成评论嵌入的这三个数字连接起来,与前面的输入一起:[0, 0, 1, 30.5, 0.75, -0.82, 0.45]。

为了做到这一点,我们将使用 Keras 的功能 API,并应用相同的步骤。功能 API 构建的层是可调用的,这使我们能够将它们链接在一起,从一个输入层开始。¹⁰ 为了利用这一点,我们将首先定义我们的嵌入和表格层:

embedding_input = Input(shape=(30,))
embedding_layer = Embedding(batch_size, 64)(embedding_input)
embedding_layer = Flatten()(embedding_layer)
embedding_layer = Dense(3, activation='relu')(embedding_layer)

tabular_input = Input(shape=(4,))
tabular_layer = Dense(32, activation='relu')(tabular_input)

注意,我们已经将这些层的Input部分定义为它们自己的变量。这是因为我们在使用功能 API 构建Model时需要传递输入层。接下来,我们将创建一个串联层,将其馈送到输出层,最后通过传递我们上面定义的原始输入层来创建模型:

merged_input = keras.layers.concatenate([embedding_layer, tabular_layer])
merged_dense = Dense(16)(merged_input)
output = Dense(1)(merged_dense)

model = Model(inputs=[embedding_input, tabular_input], outputs=output)
merged_dense = Dense(16, activation='relu')(merged_input)
output = Dense(1)(merged_dense)

model = Model(inputs=[embedding_input, tabular_input], outputs=output)

现在我们有一个接受多模态输入的单一模型。

折衷和替代方案

正如我们刚刚看到的,多模态输入设计模式探讨了如何在同一个模型中表示不同的输入格式。除了混合不同类型的数据之外,我们还可能希望以不同的方式表示相同的数据,以便我们的模型更容易识别模式。例如,我们可能有一个评级字段,它在 1 星到 5 星的序数比例上,可以将该评级字段既作为数字又作为分类处理。在这里,我们将多模态输入称为以下两种形式:

  • 结合不同类型的数据,如图像 + 元数据

  • 以多种方式表示复杂数据

我们将从探索如何以不同方式表示表格数据开始,然后再看文本和图像数据。

表格数据的多种方式

为了看到如何以相同模型不同方式表示表格数据,让我们回到餐厅评论的例子。我们假设评分是我们模型的输入,我们试图预测评论的有用性(多少人喜欢这条评论)。作为输入,评分可以表示为从 1 到 5 的整数值,也可以作为分类特征。为了将评分进行分类表示,我们可以对其进行分桶。我们如何分桶数据取决于我们的数据集和使用情况。为了保持简单,假设我们想创建两个桶:“好”和“差”。“好”桶包括评分为 4 和 5 的情况,“差”包括 3 及以下的评分。然后我们可以创建一个布尔值来编码评分桶,并将整数和布尔值连接成一个数组(完整代码在 GitHub 上)。

这是一个小数据集的三个数据点示例:

rating_data = [2, 3, 5]

def good_or_bad(rating):
  if rating > 3:
    return 1
  else:
    return 0

rating_processed = []

for i in rating_data:
  rating_processed.append([i, good_or_bad(i)])

结果特征是一个两元素数组,包括整数评分和其布尔表示:

[[2, 0], [3, 0], [5, 1]]

如果我们决定创建超过两个桶,我们将对每个输入进行独热编码,并将这个独热数组附加到整数表示中。

将评分以两种方式表示的原因是因为评分的价值(1 到 5 星)并不一定是线性增长的。4 和 5 星的评分非常相似,而 1 到 3 星的评分很可能表明评论者不满意。你给出你不喜欢的东西 1、2 或 3 星,通常与你的评论倾向相关,而不仅仅是评论本身。尽管如此,保留星级评分中更精细的信息仍然很有用,这就是我们以两种方式对其进行编码的原因。

此外,考虑具有比 1 到 5 更大范围的特征,比如评论者家和餐厅之间的距离。如果有人开车两个小时去餐厅,他们的评论可能比从对面来的人更严格。在这种情况下,我们可能会有异常值,因此在 50 公里左右的数值距离表示上限,并包括一个单独的距离分类表示是有意义的。分类特征可以分为“本州内”,“国内”和“国外”。

文本的多模态表示

文本和图像都是非结构化的,比表格数据需要更多的转换。以不同格式表示它们有助于我们的模型提取更多的模式。我们将在前一节讨论文本模型的基础上继续讨论,探讨表示文本数据的不同方法。然后,我们将介绍图像并深入探讨几种用于表示图像数据的选项。

文本数据的多种方式

鉴于文本数据的复杂性,有许多方法可以从中提取含义。嵌入设计模式使模型能够将相似的单词分组在一起,识别单词之间的关系,并理解文本的句法元素。虽然通过单词嵌入表示文本最接近人类如何本能理解语言,但还有其他文本表示方法可以最大化我们模型在给定预测任务中的能力。在本节中,我们将看看词袋方法来表示文本,以及从文本中提取表格特征。

为了展示文本数据的表示,我们将引用一个包含来自 Stack Overflow 数百万问题和答案文本的数据集,¹¹ 以及每个帖子的元数据。例如,以下查询将给出标记为“keras”,“matplotlib”或“pandas”的问题子集,并显示每个问题收到的答案数:

SELECT
  title,
  answer_count,
  REPLACE(tags, "|", ",") as tags
FROM
  `bigquery-public-data.stackoverflow.posts_questions`
WHERE
  REGEXP_CONTAINS( tags, r"(?:keras|matplotlib|pandas)")

查询结果如下输出:

标题 答案数 标签
1 通过匹配列表中的字符串值在 pandas dataframe 中构建新列 6 python,python-2.7,pandas,replace,nested-loops
2 作为副本提取特定选择的列到新的 DataFrame 6 python,pandas,chained-assignment
3 在 Keras 中何时调用 BatchNormalization 函数? 7 python,keras,neural-network,data-science,batch-normalization
4 在 Python 或 SQL 中使用类似 Excel 的求解器 8 python,sql,numpy,pandas,solver

在使用词袋(BOW)方法表示文本时,我们将每个文本输入想象成一个拼字游戏的袋子,每个袋子中包含一个单词而不是一个字母。BOW 不保留文本的顺序,但它确实检测我们发送到模型的每个文本中特定单词的存在或缺失。这种方法是一种多热编码,其中每个文本输入被转换为一个由 1 和 0 组成的数组。这个 BOW 数组中的每个索引对应于我们词汇表中的一个单词。

鉴于有两种不同的文本表示方法(嵌入和词袋),在给定任务中应该选择哪种方法?与机器学习的许多方面一样,这取决于我们的数据集,我们预测任务的性质,以及我们计划使用的模型类型。

Embeddings(嵌入)为我们的模型增加了额外的层次,并提供了关于单词含义的额外信息,这是从 BOW 编码中无法获得的。然而,嵌入需要训练(除非我们可以为我们的问题使用预训练的嵌入)。深度学习模型可能会达到更高的准确率,但我们也可以尝试在像 scikit-learn 或 XGBoost 这样的框架中使用 BOW 编码进行线性回归或决策树模型。使用简单模型类型的 BOW 编码可以用于快速原型设计或验证我们选择的预测任务是否适用于我们的数据集。与嵌入不同,BOW 不考虑文本文档中单词的顺序或含义。如果其中任何一个对我们的预测任务很重要,嵌入可能是最佳选择。

构建结合包和文本嵌入表示的深层模型也可能有益于从我们的数据中提取更多模式。为此,我们可以使用多模态输入方法,除了连接文本和表格特征之外,我们还可以连接嵌入和 BOW 表示(参见GitHub 上的代码)。这里,我们的输入层的形状将是 BOW 表示的词汇量大小。表示文本的多种方式的一些好处包括:

  • BOW 编码为我们词汇表中存在的最重要单词提供了强烈信号,而嵌入可以在更大的词汇表中识别单词之间的关系。

  • 如果我们的文本在不同语言之间切换,我们可以为每种语言构建嵌入(或 BOW 编码)并将它们连接起来。

  • 嵌入可以编码文本中单词的频率,而 BOW 则将每个单词的存在视为布尔值。这两种表示方式都是有价值的。

  • BOW 编码可以识别所有包含“amazing”单词的评论之间的模式,而嵌入可以学习将短语“not amazing”与低于平均水平的评论相关联。同样,这两种表示都是有价值的。

从文本中提取表格特征

除了对原始文本数据进行编码外,文本通常还具有可以表示为表格特征的其他特征。假设我们正在构建一个模型来预测是否会对 Stack Overflow 问题做出回应。关于文本但与确切单词本身无关的各种因素可能对训练此任务的模型有影响。例如,问题的长度或是否有问号可能影响答案的可能性。然而,当我们创建嵌入时,通常会将单词截断到一定长度,这样实际的问题长度就在数据表示中丢失了。类似地,标点通常会被移除。我们可以使用多模态输入设计模式将丢失的这些信息重新引入模型。

在下面的查询中,我们将从 Stack Overflow 数据集的 title 字段中提取一些表格特征,以预测问题是否会得到回答:

SELECT
  LENGTH(title) AS title_len,
  ARRAY_LENGTH(SPLIT(title, " ")) AS word_count,
  ENDS_WITH(title, "?") AS ends_with_q_mark,
IF
  (answer_count > 0,
    1,
    0) AS is_answered,
FROM
  `bigquery-public-data.stackoverflow.posts_questions`

这导致以下结果:

Row title_len word_count ends_with_q_mark is_answered
1 84 14 true 0
2 104 16 false 0
3 85 19 true 1
4 88 14 false 1
5 17 3 false 1

除了直接从问题标题中提取的这些特征外,我们还可以表示问题的 元数据 作为特征。例如,我们可以添加代表问题标签数量和问题发布的星期几的特征。然后,我们可以使用 Keras 的 Concatenate 层将这些表格特征与我们的编码文本组合起来,将两种表示同时输入模型。

图像的多模态表示

与我们对文本嵌入和 BOW 编码的分析类似,准备 ML 模型时表示图像数据的方法有很多种。像原始文本一样,图像不能直接输入模型,需要将其转换为模型能理解的数值格式。我们将首先讨论一些常见的图像数据表示方法:作为像素值、作为瓦片集以及作为窗口化序列集。多模态输入设计模式提供了一种在模型中使用图像的多种表示方式的方法。

图像作为像素值

图像的核心是像素值数组。例如,黑白图像的像素值范围从 0 到 255。因此,我们可以将一个 28×28 像素的黑白图像表示为一个 28×28 的整数值数组,值的范围从 0 到 255。在本节中,我们将引用 MNIST 数据集,这是一个包含手写数字图像的流行 ML 数据集。

使用 Sequential API,我们可以使用 Flatten 层来表示我们的 MNIST 图像,将其像素值展平为一个一维的 784 (28 * 28) 元素数组:

layers.Flatten(input_shape=(28, 28))

对于彩色图像,情况变得更加复杂。RGB 彩色图像中的每个像素具有三个值——红色、绿色和蓝色。如果我们上面的例子中的图像是彩色的,我们会将一个第三维添加到模型的 input_shape,使其变为:

layers.Flatten(input_shape=(28, 28, 3))

尽管将图像表示为像素值数组对于像 MNIST 数据集中的灰度图像这样的简单图像效果很好,但当我们引入具有更多边缘和形状的图像时,它开始出现问题。当网络一次性输入图像中的所有像素时,它很难集中在包含重要信息的相邻像素的较小区域上。

图像作为瓦片结构

我们需要一种表示更复杂的真实世界图像的方法,以使我们的模型能够提取有意义的细节并理解模式。如果我们每次只向网络输入图像的小部分,它更可能识别出相邻像素中存在的空间梯度和边缘等内容。用于实现这一点的常见模型架构是卷积神经网络(convolutional neural network,CNN)。

Keras 提供了卷积层来构建将图像分割为较小、窗口化块的模型。假设我们正在构建一个将 28×28 彩色图像分类为“狗”或“猫”的模型。由于这些图像是彩色的,每个图像将被表示为一个 28×28×3 维数组,因为每个像素具有三个颜色通道。下面是我们如何使用卷积层和Sequential API 定义这个模型的输入:

Conv2D(filters=16, kernel_size=3, activation='relu', input_shape=(28,28,3))

在这个例子中,我们将输入图像分成 3×3 块,然后通过最大池化层传递它们。构建一个将图像分割为滑动窗口块的模型架构使我们的模型能够识别图像中更细粒度的细节,如边缘和形状。

结合不同的图像表示方式

此外,与词袋和文本嵌入类似,用多种方式表示相同的图像数据可能是有用的。同样,我们可以通过 Keras 功能 API 实现这一点。

下面是我们如何使用 Keras Concatenate 层将像素值与滑动窗口表示组合起来:

# Define image input layer (same shape for both pixel and tiled 
# representation)
image_input = Input(shape=(28,28,3))

# Define pixel representation
pixel_layer = Flatten()(image_input)

# Define tiled representation
tiled_layer = Conv2D(filters=16, kernel_size=3, 
                     activation='relu')(image_input)
tiled_layer = MaxPooling2D()(tiled_layer)
tiled_layer = tf.keras.layers.Flatten()(tiled_layer)

# Concatenate into a single layer
merged_image_layers = keras.layers.concatenate([pixel_layer, tiled_layer])

要定义一个接受多模态输入表示的模型,我们可以将拼接层的输出馈送到输出层中:

merged_dense = Dense(16, activation='relu')(merged_image_layers)
merged_output = Dense(1)(merged_dense)

model = Model(inputs=image_input, outputs=merged_output)

选择使用哪种图像表示或是否使用多模态表示主要取决于我们处理的图像数据类型。通常来说,我们的图像越详细,我们越有可能将它们表示为瓷砖或瓦片的滑动窗口。对于 MNIST 数据集,仅将图像表示为像素值可能已经足够了。另一方面,对于复杂的医学图像,通过结合多种表示可能会提高准确性。为什么要结合多种图像表示呢?将图像表示为像素值使模型能够识别图像中的高级焦点,如显著的高对比度对象。而瓦片表示则帮助模型识别更细粒度的、低对比度的边缘和形状。

  • 使用带元数据的图像

早些时候,我们讨论了可能与文本关联的不同类型的元数据,以及如何提取和表示这些元数据作为我们模型的表格特征。我们还可以将这个概念应用到图像上。为此,让我们回到一个例子,即模型使用十字路口的摄像头镜头来预测它是否包含交通违规,如图 2-19 中引用的例子。我们的模型可以单独从交通图片中提取许多模式,但可能还有其他可用的数据可以提高我们模型的准确性。例如,也许某些行为(例如,红绿灯右转)在交通高峰期间不允许,但在其他时段可以。或者也许驾驶员在恶劣天气下更有可能违反交通法规。如果我们从多个十字路口收集图像数据,了解图像的位置对我们的模型也可能有帮助。

我们现在已经确定了三个可以增强我们图像模型的表格特征:

  • 一天中的时间

  • 天气

  • 位置

接下来,让我们考虑每个特征的可能表示。我们可以将时间表示为一个整数,指示一天中的小时。这可能有助于我们识别与高流量时间相关的模式,如交通高峰时段。在这个模型的背景下,了解拍摄图片时是否天黑可能更有用。在这种情况下,我们可以将时间表示为一个布尔特征。

天气也可以用各种方式表示,既可以是数值也可以是分类值。我们可以将温度包括为一个特征,但在这种情况下,能见度可能更有用。表示天气的另一个选项是通过指示是否有雨或雪的分类变量。

如果我们从许多位置收集数据,我们可能希望将其编码为一个特征。这最好作为一个分类特征,根据我们从多少个位置收集镜头,甚至可能是多个特征(城市,国家,州等)。

对于这个示例,假设我们想使用以下表格特征:

  • 一天中的小时数(整数)

  • 能见度(浮点数)

  • 恶劣天气(分类:下雨,下雪,无)

  • 位置 ID(分类,有五个可能的位置)

这是对这三个示例数据集子集的一些内容:

data = {
    'time': [9,10,2],
    'visibility': [0.2, 0.5, 0.1],
    'inclement_weather': [[0,0,1], [0,0,1], [1,0,0]],
    'location': [[0,1,0,0,0], [0,0,0,1,0], [1,0,0,0,0]] 
}

然后,我们可以将这些表格特征组合成每个示例的一个单一数组,这样我们模型的输入形状将是 10。第一个示例的输入数组将如下所示:

[9, 0.2, 0, 0, 1, 0, 1, 0, 0, 0]

我们可以将此输入馈送到一个密集全连接层中,我们模型的输出将是一个介于 0 和 1 之间的单个值,指示该实例是否包含交通违规。要将此与我们的图像数据结合起来,我们将使用类似于我们用于文本模型的方法。首先,我们将定义一个卷积层来处理我们的图像数据,然后是一个处理我们的表格数据的密集层,最后我们将两者连接成一个单一输出。

这种方法在图 2-25 中有详细说明。

连接层以处理图像和表格元数据特征。

图 2-25. 连接层以处理图像和表格元数据特征。*### 多模态特征表示和模型可解释性

深度学习模型本质上难以解释。即使我们构建了一个准确率达到 99%的模型,我们仍然不知道模型究竟是如何进行预测的,因此也不知道它是否正确地进行预测。例如,假设我们在实验室拍摄的培养皿图像上训练了一个高准确率的模型。这些图像还包含了科学家拍摄时的注释信息。我们不知道的是,该模型实际上是错误地使用了这些注释来进行预测,而不是培养皿的内容。

有几种解释图像模型的技术可以突出显示指示模型预测的像素。然而,当我们在单一模型中结合多个数据表示时,这些特征变得相互依赖。因此,解释模型如何进行预测可能会变得困难。可解释性在第七章中有所涵盖。*# 总结

在这一章中,我们学习了表示模型数据的不同方法。我们首先讨论了如何处理数值输入,以及如何通过缩放这些输入来加快模型训练时间并提高准确性。然后,我们探讨了如何对分类输入进行特征工程,特别是使用独热编码和使用分类值数组。

在本章的其余部分,我们讨论了四种表示数据的设计模式。第一种是散列特征设计模式,它涉及将分类输入编码为唯一的字符串。我们使用 BigQuery 中的机场数据集探讨了几种不同的散列方法。本章中我们看到的第二种模式是嵌入,这是一种用于表示高基数数据的技术,例如具有许多可能类别或文本数据的输入。嵌入将数据表示为多维空间中的点,其中维数取决于我们的数据和预测任务。接下来,我们看了特征交叉,这是一种将两个特征结合起来以提取关系的方法,这些关系可能无法通过单独编码特征来捕捉。最后,我们看了多模态输入表示法,解决了如何将不同类型的输入合并到同一模型中的问题,以及如何以多种方式表示单一特征。

本章重点是为我们的模型准备输入数据。在下一章中,我们将着眼于模型输出,深入探讨表示我们预测任务的不同方法。

¹ 在这里,学习的数据表示包括baby weight作为输入变量,小于操作符,以及 3 公斤的阈值。

² 如果是双胞胎,则多数是 2。如果是三胞胎,则多数是 3。

³ 此数据集在 BigQuery 中可用:bigquery-public-data.samples.natality

⁴ 此数据集在 BigQuery 中可用:bigquery-public-data.hacker_news.stories

⁵ 本书的代码库中的 feature_cross.ipynb 笔记本将帮助您更好地跟进讨论。

⁶ 本书的代码库中02_data_representation/feature_cross.ipynb包含完整代码。

⁷ 我们使用术语“表格数据”来指代数值和分类输入,但不包括自由形式的文本。您可以将表格数据视为在电子表格中常见的任何内容。例如,如年龄、汽车类型、价格或工作小时数等数值。表格数据不包括如描述或评论等自由形式文本。

⁸ 当我们将一个编码为 30 个词的数组传递给我们的模型时,Keras 层将其转换为 64 维嵌入表示,因此我们将拥有一个表示评论的[64×30]矩阵。

⁹ 起点是一个包含 1,920 个数字的数组。

¹⁰ 请参阅本书代码库中的02_data_representation/mixed_representation.ipynb获取完整的模型代码。

¹¹ 此数据集在 BigQuery 中可用:bigquery-public-data.stackoverflow.posts_questions

第三章:问题表示设计模式

第二章讨论了设计模式,目的是列举机器学习模型输入的多种表示方式。本章将探讨不同类型的机器学习问题,并分析模型架构如何根据问题的不同而变化。

输入和输出类型是影响模型架构的两个关键因素。例如,在监督学习问题中,输出可以根据解决的问题是分类问题还是回归问题而有所不同。针对特定类型的输入数据存在特殊的神经网络层:用于图像、语音、文本以及具有时空相关性的其他数据的卷积层,用于序列数据的循环网络等等。围绕这些类型的层面已经出现了大量文献,专门讨论诸如最大池化、注意力等特殊技术。此外,针对常见问题已经形成了特殊类别的解决方案,如推荐问题(例如矩阵分解)或时间序列预测(例如 ARIMA)。最后,一组更简单的模型以及常见的习惯用法可以用来解决更复杂的问题,例如文本生成通常涉及使用分类模型,其输出经过使用波束搜索算法进行后处理。

为了限制我们的讨论并避开正在研究的领域,我们将忽略与专门的机器学习领域相关的模式和习惯用法。相反,我们将专注于回归和分类,并分析在这两种类型的机器学习模型中,问题表示的模式。

重构设计模式将一个直观上是回归问题的解决方案转化为分类问题(反之亦然)。多标签设计模式处理训练示例可以属于多个类的情况。级联设计模式处理可以将机器学习问题有利地分解成一系列(或级联)机器学习问题的情况。集成设计模式通过训练多个模型并聚合它们的响应来解决问题。中立类设计模式探讨如何处理专家意见不一致的情况。再平衡设计模式建议处理高度倾斜或不平衡数据的方法。

设计模式 5:重构

重构设计模式指的是改变机器学习问题输出表示的方法。例如,我们可以将一个直观上是回归问题的东西改变为分类问题(反之亦然)。

问题

建立任何机器学习解决方案的第一步是确立问题框架。这是一个监督学习问题吗?还是非监督学习?特征是什么?如果是监督问题,标签是什么?什么样的误差是可接受的?当然,这些问题的答案必须与训练数据、手头任务和成功的度量标准结合考虑。

例如,假设我们想构建一个机器学习模型来预测给定位置未来降水量。从宽泛的角度来看,这将是一个回归任务还是分类任务?嗯,因为我们试图预测降水量(例如,0.3 厘米),考虑这个问题作为时间序列预测问题是有道理的:在当前和历史气候和天气模式的基础上,我们应该预期在下一个 15 分钟内在某个区域内降水量是多少?或者,因为标签(降水量)是一个实数,我们可以构建一个回归模型。当我们开始开发和训练我们的模型时,我们发现(也许不足为奇)天气预测比听起来更难。我们预测的降水量都不准确,因为对于相同的特征集,有时会下 0.3 厘米的雨,有时会下 0.5 厘米的雨。我们应该怎么做来改进我们的预测?我们应该给我们的网络添加更多层吗?或者工程化更多特征?也许更多的数据会有帮助?也许我们需要一个不同的损失函数?

这些调整中的任何一个都可以改进我们的模型。但是等等。回归难道是我们唯一可以提出这个任务的方式吗?也许我们可以重新构思我们的机器学习目标,以改善我们的任务表现。

解决方案

这里的核心问题是降水是概率性的。对于相同的特征集,有时会下 0.3 厘米的雨,有时会下 0.5 厘米的雨。然而,即使回归模型能够学习这两种可能的量,它也仅限于预测一个单一的数字。

而不是试图将降水量作为回归任务进行预测,我们可以重新构思我们的目标,将其作为一个分类问题。有不同的方法可以实现这一点。一种方法是建模离散概率分布,如图 3-1 所示。与其预测降水量作为实值输出,我们将输出建模为一个多类分类,给出下一个 15 分钟内降水概率在一定降水量范围内的概率。

而不是将降水预测为回归输出,我们可以使用多类分类来建模离散概率分布。

图 3-1。而不是将降水预测为回归输出,我们可以使用多类分类来建模离散概率分布。

这种回归方法和这种重新构思为分类方法都能预测接下来 15 分钟的降雨情况。然而,分类方法允许模型捕捉不同数量降雨的概率分布,而不是必须选择分布的平均值。以这种方式建模分布是有利的,因为降水不展示正态分布的典型钟形曲线,而是遵循Tweedie 分布,这种分布允许在零点处有大量数据点。实际上,这是谷歌研究论文中采用的方法,该论文使用 512 种分类分布预测给定位置的降水率。另一个建模分布有利的原因是当分布是双峰时,甚至是当分布是正态分布但方差很大时。最近一篇打破了所有预测蛋白质折叠结构的基准的论文也将氨基酸之间的距离预测为 64 种分类问题,其中距离被分桶为 64 个区间。

另一个重新构思问题的原因是当目标在另一类型的模型中表现更好时。例如,假设我们试图构建一个视频推荐系统。将这个问题自然地框定为分类问题,即预测用户是否可能观看某个视频,可能会导致推荐系统优先考虑点击诱饵。将这个问题重新构思为预测将会观看的视频的分数,可能会更好。

为什么有效

改变上下文和重新构思问题的任务可以帮助构建机器学习解决方案。我们不再学习单一实数,而是将预测目标放宽为离散概率分布。由于分桶,我们失去了一些精度,但获得了完整概率密度函数(PDF)的表达能力。分类模型提供的离散预测比更为严格的回归模型更擅长学习复杂的目标。

这种分类框架的另一个优势是我们获得了预测值的后验概率分布,这提供了更细致的信息。例如,假设学习的分布是双峰的。通过将分类建模为离散概率分布,模型能够捕捉到预测的双峰结构,正如图 3-2 所示。相反,如果只预测单个数值,这些信息将会丢失。根据使用情况,这可能会使任务更容易学习并且更加有利。

将分类任务重新框架为建模概率分布允许预测捕捉双峰输出。预测不限于回归中的单个值。

图 3-2. 将分类任务重新框架为建模概率分布允许预测捕捉双峰输出。预测不限于回归中的单个值。

捕捉不确定性

让我们再次看一下出生数据集和预测婴儿体重的任务。由于婴儿体重是一个正实数值,这在直觉上是一个回归问题。然而,请注意,对于给定的输入集合,weight_pounds(标签)可以取多种不同的值。我们看到,对于特定的输入值集合(例如,在 38 周时出生的 25 岁母亲的男婴),婴儿体重的分布大致遵循以约 7.5 磅为中心的正态分布。生成图 3-3 中图表的代码可以在此书的存储库中找到。

对于特定的输入集合(例如,在 38 周时出生的 25 岁母亲的男婴)会取一系列值,大致遵循以 7.5 磅为中心的正态分布。

图 3-3. 对于特定的输入集合(例如,在 38 周时出生的 25 岁母亲的男婴),weight_pounds变量会取一系列值,大致遵循以 7.5 磅为中心的正态分布。

但请注意分布的宽度——即使分布的峰值在 7.5 磅,有相当可观的可能性(实际上是 33%)某个婴儿的体重小于 6.5 磅或大于 8.5 磅!这个分布的宽度显示了预测婴儿体重问题中固有的不可减少的误差。事实上,如果我们将其视为回归问题,那么在这个问题上我们可以获得的最佳均方根误差是图 3-3 中所见分布的标准偏差。

如果我们将其视为回归问题,我们必须将预测结果表述为 7.5 +/- 1.0(或者标准偏差是多少)。然而,对于不同的输入组合,这个分布的宽度将有所不同,因此学习这个宽度本身也是另一个机器学习问题。例如,在第 36 周,对于同龄母亲的母婴来说,标准偏差是 1.16 磅。分位数回归,稍后在模式讨论中涵盖,试图以非参数化方式做到这一点。

提示

如果分布是多模态的(具有多个峰值),将问题重新定义为分类任务的情况将更为有力。然而,有助于认识到,由于大数定律的存在,只要我们捕捉到所有相关的输入,我们在大型数据集上会遇到的许多分布将是钟形的,尽管其他分布也是可能的。钟形曲线越宽,而在不同输入值处这种宽度变化越大,捕捉不确定性的重要性也越大,这就更加支持将回归问题重新定义为分类问题。

通过重新定义问题,我们训练模型作为一个多类别分类,学习给定训练示例的离散概率分布。这些离散化的预测更灵活,能够捕捉不确定性,并且比回归模型更能逼近复杂的目标。在推理时,模型预测与这些潜在输出相对应的一系列概率。也就是说,我们获得了一个离散的概率分布,给出了任何特定权重的相对可能性。当然,在这里需要注意——分类模型可能会出现严重的不校准(例如模型过于自信而错误)。

改变目标

在某些场景中,将分类任务重新定义为回归任务可能会有益。例如,假设我们有一个大型电影数据库,其中包含用户对所有观看并评价的电影的评分(评分从 1 到 5)。我们的任务是构建一个机器学习模型,用于为用户提供推荐。

如果将其视为分类任务,我们可以考虑构建一个模型,该模型以user_id及其用户先前观看的视频和评分作为输入,并预测从我们的数据库中推荐哪部电影。然而,我们也可以将这个问题重新构造为回归问题。模型不再具有与数据库中电影对应的分类输出,而是可以进行多任务学习,模型学习用户可能观看给定电影的若干关键特征(如收入、客户段等)。

将其重新构造为回归任务,模型现在预测给定电影的用户空间表示。为了提供推荐,我们选择那些与用户已知特征最接近的电影集合。通过这种方式,与分类任务中模型提供用户可能会喜欢某部电影的概率不同,我们将得到一组被这类用户观看过的电影。

通过将推荐电影的分类问题重新定义为用户特征的回归问题,我们能够轻松地调整我们的推荐模型,以推荐流行视频、经典电影或纪录片,而无需每次训练一个单独的分类模型。

当数值表示具有直观解释时,这种模型方法也非常有用;例如,经度和纬度对可以用来代替对城市地区的预测。假设我们想要预测哪个城市将出现下一次病毒爆发,或者哪个纽约社区将出现房地产价格的激增。预测经度和纬度,然后选择最接近该位置的城市或社区可能比直接预测城市或社区本身更容易。

折中和替代方案

很少只有一种方式来构建问题,了解任何实现的折中或替代方案是有帮助的。例如,将回归输出值分桶化是将问题重新构建为分类任务的一种方法。另一种方法是使用多任务学习,将分类和回归任务结合到单个模型中,使用多个预测头部。对于任何重新构建技术,了解数据限制或引入标签偏差的风险是很重要的。

分桶化输出

将回归任务重构为分类任务的典型方法是对输出值进行分桶。例如,如果我们的模型用于指示婴儿在出生时可能需要重视护理的时机,表格 3-1 中的类别可能足够了。

Table 3-1. 婴儿体重的分桶化输出

类别 描述
高出生体重 大于 8.8 磅
平均出生体重 5.5 磅至 8.8 磅之间
低出生体重 3.31 磅至 5.5 磅之间
非常低出生体重 少于 3.31 磅

现在我们的回归模型变成了多类分类。直观地说,预测四种可能的分类情况中的一种比预测一个连续的实数值要容易——就像预测二进制的 is_underweight 目标 0 或 1 比预测 高体重 versus 平均体重 versus 低体重 versus 非常低体重 四种分开的类别要容易一样。通过使用分类输出,我们的模型更少地被激励于接近实际输出值,因为我们本质上已经将输出标签更改为数值范围而不是单个实数。

在本节附带的笔记本,我们训练了回归模型和多类别分类模型。回归模型在验证集上达到了 1.3 的 RMSE,而分类模型的准确率为 67%。由于一个评估指标是 RMSE,另一个是准确率,因此比较这两个模型是困难的。最终,设计决策由使用案例决定。如果医疗决策基于分桶值,则我们的模型应该是使用这些桶的分类模型。然而,如果需要更精确地预测婴儿体重,那么使用回归模型是有意义的。

捕捉不确定性的其他方法

在回归中捕获不确定性还有其他方法。一个简单的方法是进行分位数回归。例如,我们可以估计需要预测的条件 10th、20th、30th、…、90th 百分位数,而不是仅预测均值。分位数回归是线性回归的扩展。另一方面,重构可以与更复杂的机器学习模型配合使用。

另一种更复杂的方法是使用像TensorFlow Probability这样的框架进行回归。然而,我们必须显式地对输出的分布进行建模。例如,如果预期输出以输入为依赖的均值正态分布,则模型的输出层将是:

tfp.layers.DistributionLambda(lambda t: tfd.Normal(loc=t, scale=1))

另一方面,如果我们知道方差随着均值增加而增加,我们可能能够使用 lambda 函数对其进行建模。另一方面,重构并不要求我们对后验分布进行建模。

提示

在训练任何机器学习模型时,数据至关重要。通常,更复杂的关系需要更多的训练数据示例来找到这些难以捉摸的模式。考虑到这一点,重要的是要考虑回归或分类模型的数据需求。分类任务的一个常见经验法则是,每个标签类别的模型特征应该有 10 倍的训练数据。对于回归模型,经验法则是模型特征的 50 倍。当然,这些数字只是粗略的启发式,而不是精确的。然而,直觉是回归任务通常需要更多的训练示例。此外,随着任务复杂性的增加,对大量数据的需求也会增加。因此,在考虑使用的模型类型或分类任务中标签类别数量时,应考虑数据限制。

预测的精度

当考虑将回归模型重新构建为多类别分类时,输出标签的箱宽度决定了分类模型的精度。在我们的婴儿体重示例中,如果我们需要从离散概率密度函数中获取更精确的信息,则需要增加分类模型的箱数。图 3-4 显示了离散概率分布如何表现为 4 通道或 10 通道分类。

多类别分类的精度由标签的分箱宽度控制。

图 3-4. 多类别分类的精度由标签的分箱宽度控制。

PDF 的尖锐度表示回归任务的精度。更尖锐的 PDF 表示输出分布的标准偏差较小,而更宽的 PDF 表示标准偏差较大,因此具有更多方差。对于非常尖锐的密度函数,最好使用回归模型(见 图 3-5)。

回归的精度由概率密度函数在固定输入值集合上的尖锐程度表示。

图 3-5. 回归的精度由概率密度函数在固定输入值集合上的尖锐程度表示。

限制预测范围

另一个重新构建问题的原因是限制预测输出范围的必要性。例如,对于回归问题,实际输出值的合理范围为 [3, 20]。如果我们训练一个输出层为线性激活函数的回归模型,模型预测可能会超出此范围。限制输出范围的一种方法是重新构建问题。

使倒数第二层的激活函数为 sigmoid 函数(通常与分类相关联),使其处于区间 [0,1],并让最后一层将这些值缩放到期望的范围内:

MIN_Y =  3
MAX_Y = 20
input_size = 10
inputs = keras.layers.Input(shape=(input_size,))
h1 = keras.layers.Dense(20, 'relu')(inputs)
h2 = keras.layers.Dense(1, 'sigmoid')(h1)  # 0-1 range
output = keras.layers.Lambda(
             lambda y : (y*(MAX_Y-MIN_Y) + MIN_Y))(h2) # scaled
model = keras.Model(inputs, output)

我们可以验证(请查看 GitHub 上的 笔记本 获取完整代码),此模型现在输出的数字范围为 [3, 20]。请注意,因为输出是 sigmoid 函数,模型实际上永远不会达到范围的最小值和最大值,只会非常接近。当我们对一些随机数据进行训练时,得到的值在范围 [3.03, 19.99] 内。

标签偏差

像矩阵分解这样的推荐系统可以在神经网络的背景下重新构建,可以作为回归或分类的形式。这种背景变化的一个优点是,作为回归或分类模型的神经网络可以整合更多除了仅仅用户和物品嵌入之外的附加特征。因此,这可以是一个吸引人的替代选择。

然而,当重构问题时要考虑目标标签的性质是很重要的。例如,假设我们将我们的推荐模型重构为一个分类任务,预测用户点击某个视频缩略图的可能性。这似乎是一个合理的重构,因为我们的目标是提供用户选择和观看的内容。但要小心。这种标签的变化实际上并不符合我们的预测任务。通过优化用户点击,我们的模型将无意中促进点击诱饵,并不会真正推荐对用户有用的内容。

相反,一个更有利的标签是视频观看时间,将我们的推荐重新构建为一个回归任务。或者也许我们可以修改分类目标,预测用户至少观看视频片段一半的可能性。通常有多种适当的方法,当构建解决方案时综合考虑问题是很重要的。

警告

当改变机器学习模型的标签和训练任务时要小心,因为这可能会无意中引入标签偏差到你的解决方案中。考虑我们在“为什么有效”中讨论的视频推荐示例。

多任务学习

重构的另一种选择是多任务学习。不要试图在回归或分类之间选择,两者都做!一般来说,多任务学习是指任何一个优化多个损失函数的机器学习模型。这可以通过许多不同的方式实现,但神经网络中最常见的两种形式是硬参数共享和软参数共享。

参数共享指的是神经网络的参数在不同输出任务之间共享,比如回归和分类。硬参数共享发生在模型的隐藏层在所有输出任务之间共享时。在软参数共享中,每个标签有自己的神经网络和自己的参数,通过某种形式的正则化,不同模型的参数被鼓励保持相似。图 3-6 展示了硬参数共享和软参数共享的典型架构。

多任务学习的两种常见实现方式是硬参数共享和软参数共享。

图 3-6. 多任务学习的两种常见实现方式是硬参数共享和软参数共享。

在这种情况下,我们的模型可以有两个头部:一个用于预测回归输出,另一个用于预测分类输出。例如,这篇论文使用分类输出的 softmax 概率训练计算机视觉模型,同时使用回归输出预测边界框。他们表明,这种方法比单独为分类和定位任务训练网络的相关工作表现更好。其核心思想是通过参数共享,同时学习任务,并且两个损失函数的梯度更新会影响两个输出,从而产生更具泛化能力的模型。

设计模式 6:多标签

多标签设计模式指的是我们可以为给定的训练样本分配多个标签的问题。对于神经网络,这种设计需要改变模型最终输出层中使用的激活函数,并选择我们的应用程序如何解析模型输出。请注意,这与多类别分类问题不同,后者是指从许多(> 1)可能的类别中为单个示例分配一个标签。您可能还会听到多标签设计模式称为多标签,多类别分类,因为它涉及从多个可能的类别中选择多个标签。在讨论此模式时,我们将主要关注神经网络。

问题

通常,模型预测任务涉及为给定的训练示例应用单一分类。此预测是从N个可能的类别中确定,其中N大于 1。在这种情况下,常见做法是使用 softmax 作为输出层的激活函数。使用 softmax,我们模型的输出是一个 N 元素数组,其中所有值的总和为 1。每个值表示特定训练示例与该索引处类别相关的概率。

例如,如果我们的模型将图像分类为猫、狗或兔子,则给定图像的 softmax 输出可能如下所示:[.89, .02, .09]。这意味着我们的模型预测图像是猫的概率为 89%,是狗的概率为 2%,是兔子的概率为 9%。在这种情况下,每个图像只能有一个可能的标签,我们可以使用 argmax(最高概率的索引)来确定模型预测的类别。较少见的场景是每个训练示例可以分配多个标签,这正是这种模式要解决的。

多标签设计模式适用于在所有数据模态上训练的模型。对于图像分类,在前述猫、狗、兔子的示例中,我们可以使用每个描绘 多个 动物的训练图像,因此可以有多个标签。对于文本模型,我们可以想象几种情况,其中文本可以用多个标签进行标记。以 BigQuery 上的 Stack Overflow 问题数据集为例,我们可以构建一个模型来预测与特定问题相关联的标签。例如,问题“如何绘制 pandas DataFrame?”可以被标记为“Python”、“pandas”和“visualization”。另一个多标签文本分类的例子是识别有毒评论的模型。对于这种模型,我们可能希望标记同时具有多个毒性标签的评论。因此,评论可能被标记为“恶意”和“淫秽”。

这种设计模式也适用于表格数据集。想象一下,一个包含各种患者的健康数据集,如身高、体重、年龄、血压等。这些数据可以用来预测多种病症的存在。例如,一个患者可能显示出患心脏病和糖尿病的风险。

解决方案

为了构建能够给定训练示例分配 多个标签 的模型解决方案,我们在最终输出层使用 Sigmoid 激活函数。与生成所有值总和为 1 的数组(如 Softmax)不同,Sigmoid 数组中的每个 独立 值是介于 0 和 1 之间的浮点数。也就是说,在实现多标签设计模式时,我们的标签需要进行多热编码。多热数组的长度对应于模型中类的数量,标签数组中的每个输出将是一个 Sigmoid 值。

延续上面的图像示例,假设我们的训练数据集包含了多种动物的图像。对于包含猫和狗但不包含兔子的图像,其 Sigmoid 输出可能如下所示:[.92, .85, .11]。这个输出意味着模型对图像包含猫的信心为 92%,包含狗的信心为 85%,包含兔子的信心为 11%。

对于具有 28×28 像素图像的模型的一个版本,其 Sigmoid 输出可能如下所示,使用 Keras Sequential API:

model = keras.Sequential([
    keras.layers.Flatten(input_shape=(28, 28)),
    keras.layers.Dense(128, activation='relu'),
    keras.layers.Dense(3, activation='sigmoid')
])

这里 Sigmoid 模型与问题部分的 Softmax 示例之间的主要输出差异在于,Softmax 数组保证包含三个值,其总和为 1,而 Sigmoid 输出将包含三个值,每个值介于 0 和 1 之间。

折衷与替代方案

在遵循多标签设计模式和使用 Sigmoid 输出时,有几个特殊情况需要考虑。接下来,我们将探讨如何构建具有两个可能标签类别的模型,如何理解 Sigmoid 结果,以及多标签模型的其他重要考虑因素。

两类模型的 Sigmoid 输出

有两种类型的模型,其输出可以属于两种可能的类别:

  • 每个训练示例只能分配一个类别。这也称为二元分类,是一种特殊类型的多类分类问题。

  • 一些训练示例可能属于两种类别。这是一种多标签分类问题。

图 3-8 展示了这些分类之间的区别。

理解多类、多标签和二分类问题之间的区别。

图 3-8. 理解多类、多标签和二分类问题之间的区别。

第一个案例(二分类)在于它是唯一一个我们会考虑使用 sigmoid 作为激活函数的单标签分类问题。对于几乎任何其他多类分类问题(例如,将文本分类为五个可能的类别之一),我们会使用 softmax。然而,当我们只有两类时,softmax 是多余的。例如,考虑一个模型,预测特定交易是否欺诈。如果我们在这个例子中使用 softmax 输出,那么一个欺诈模型的预测可能会像这样:

[.02, .98]

在这个例子中,第一个索引对应“非欺诈”,第二个索引对应“欺诈”。这是多余的,因为我们也可以用单一标量值表示,并因此使用 sigmoid 输出。相同的预测可以简单地表示为.98。由于每个输入只能分配一个类别,我们可以从.98的输出推断出,模型预测欺诈的可能性为 98%,非欺诈的可能性为 2%。

因此,对于二分类模型,使用输出形状为1和 sigmoid 激活函数是最优的。具有单个输出节点的模型也更有效率,因为其可训练参数较少,很可能训练速度更快。以下是二分类模型的输出层示意图:

keras.layers.Dense(1, activation='sigmoid')

对于第二种情况,其中一个训练示例可能属于两种可能的类别并且符合多标签设计模式,我们还将使用 sigmoid,这次是具有两个元素的输出:

keras.layers.Dense(2, activation='sigmoid')

我们应该使用哪种损失函数呢?

现在我们知道在模型中何时使用 sigmoid 作为激活函数,接下来应该选择哪种损失函数呢?对于二分类情况,当我们的模型具有单元素输出时,使用二元交叉熵损失函数。在 Keras 中,我们在编译模型时指定损失函数:

model.compile(loss='binary_crossentropy', optimizer='adam', 
  metrics=['accuracy'])

有趣的是,对于具有 sigmoid 输出的多标签模型,我们也使用二元交叉熵损失。这是因为,如图 3-9 所示,具有三个类别的多标签问题本质上是三个较小的二分类问题。

通过将问题分解为较小的二元分类任务来理解多标签模式。

图 3-9。通过将问题分解为较小的二元分类任务来理解多标签模式。

解析 sigmoid 结果

要提取具有 softmax 输出模型的预测标签,我们可以简单地取输出数组的 argmax(最高值索引)来获取预测类别。解析 sigmoid 输出则不那么直接。我们不是取预测概率最高的类别,而是需要评估输出层中每个类别的概率,并考虑我们用例的概率阈值。这两种选择在很大程度上取决于我们模型的最终用户应用。

注意

通过阈值,我们指的是我们对确认输入属于特定类别的概率感到满意的程度。例如,如果我们正在构建一个模型来分类图像中的不同动物类型,即使模型只有 80%的信心图像包含猫,我们可能也会确认图像中有一只猫。另外,如果我们正在构建一个进行医疗预测的模型,我们可能希望在确认特定医疗状况是否存在之前,模型的信心水平接近 99%。虽然对于任何类型的分类模型都需要考虑阈值设定,但它对多标签设计模式尤为重要,因为我们需要为每个类别确定阈值,而这些阈值可能不同。

要查看一个具体的例子,让我们使用 BigQuery 中的 Stack Overflow 数据集构建一个模型,该模型可以根据问题的标题预测与 Stack Overflow 问题相关联的标签。我们将限制我们的数据集仅包含五个标签的问题,以保持简单。

SELECT
  title,
  REPLACE(tags, "|", ",") as tags
FROM
  `bigquery-public-data.stackoverflow.posts_questions`
WHERE
  REGEXP_CONTAINS(tags, 
r"(?:keras|tensorflow|matplotlib|pandas|scikit-learn)")

我们模型的输出层如下所示(本节的完整代码在GitHub 代码库中可用):

keras.layers.Dense(5, activation='sigmoid')

让我们以 Stack Overflow 的问题什么是不可训练参数的定义?作为输入示例。假设我们的输出索引与查询中标签的顺序相对应,那么该问题的输出可能如下所示:

[.95, .83, .02, .08, .65]

我们的模型对该问题应该被标记为 Keras 有 95%的信心,应该被标记为 TensorFlow 有 83%的信心。在评估模型预测时,我们需要遍历输出数组中的每个元素,并确定如何向最终用户显示这些结果。如果我们所有标签的阈值是 80%,我们将显示与该问题相关联的KerasTensorFlow。或者,也许我们希望鼓励用户尽可能添加更多标签,并且我们希望为任何预测置信度超过 50%的标签显示选项。

对于像这样的示例,主要目标是提出可能的标签建议,而不是强调准确获取标签,一个典型的经验法则是对每个类别使用n_specific_tag / n_total_examples作为阈值。这里,n_specific_tag是数据集中具有一个标签的示例数量(例如,“pandas”),而n_total_examples是训练集中所有标签的总样本数。这确保模型比基于训练数据集中标签出现频率来猜测某个标签要好。

提示

对于更精确的阈值处理方法,可以考虑使用 S-Cut 或优化模型的 F-measure。关于这两者的详细信息可以在这篇论文中找到。调整每个标签的概率通常也很有帮助,特别是在存在成千上万个标签并且想要考虑它们的前 K 个时(这在搜索和排名问题中很常见)。

如你所见,多标签模型在我们解析预测时提供了更大的灵活性,并且需要我们仔细考虑每个类别的输出。

数据集考虑事项

在处理单标签分类任务时,我们可以通过确保数据集中每个类别的训练样本数量相对均衡来保持数据集的平衡。构建平衡的数据集对于多标签设计模式来说更加微妙。

以 Stack Overflow 数据集为例,可能会有许多问题同时被标记为TensorFlowKeras。但也会有关于 Keras 的问题,与 TensorFlow 无关。同样地,我们可能会看到关于用matplotlibpandas标记的数据绘图问题,以及关于数据预处理的问题,同时被标记为pandasscikit-learn。为了使我们的模型学习到每个标签的独特内容,我们需要确保训练数据集包含各种标签组合。如果我们的数据集中大多数关于matplotlib的问题也同时被标记为pandas,那么模型将无法单独学习分类matplotlib。为了解决这个问题,考虑我们的模型中可能存在的标签之间的不同关系,并计算属于每个标签重叠组合的训练示例数量。

在探索数据集中标签之间的关系时,我们可能还会遇到层次标签。ImageNet,这个流行的图像分类数据集,包含数千个带标签的图像,并且通常作为图像模型迁移学习的起点。ImageNet 中所有的标签都是层次化的,这意味着所有的图像至少有一个标签,而许多图像还具有更具体的层次标签,这些标签构成了一个层次结构。以下是 ImageNet 中一个标签层次结构的示例:

动物 → 无脊椎动物 → 节肢动物 → 蜘蛛 → 蜘蛛

根据数据集的大小和特性,处理层次标签通常有两种常见方法:

  • 使用扁平方法,并将每个“叶节点”标签放入相同的输出数组中,确保每个标签都有足够的示例。

  • 使用级联设计模式。构建一个模型来识别高级标签。根据高级分类,将示例发送到另一个模型进行更具体的分类任务。例如,我们可能有一个初始模型,将图像标记为“植物”、“动物”或“人物”。根据第一个模型应用的标签,将图像发送到不同的模型来应用更细粒度的标签,如“多肉植物”或“巴巴里狮”。

与级联设计模式相比,扁平方法更为直接,因为它只需要一个模型。然而,这可能会导致模型丢失有关更详细标签类的信息,因为我们的数据集中自然会有更多具有高级标签的训练示例。

具有重叠标签的输入

多标签设计模式在输入数据偶尔具有重叠标签的情况下也非常有用。举个例子,我们拿服装分类目录的图像模型来说。如果我们训练数据集中的每个图像都有多个人标记,一个标注者可能会将一张裙子的图像标记为“长裙”,而另一个标记为“褶皱裙”。这两种标记都是正确的。然而,如果我们在这些数据上构建一个多类别分类模型,将具有不同标签的同一图像多次输入,当进行预测时可能会遇到模型在标记相似图像时产生不同预测的情况。理想情况下,我们希望模型像在图 3-10 中看到的那样,将这张图像标记为“长裙”和“褶皱裙”,而不是仅预测其中一种标签。

使用多个标注者的输入创建重叠标签,以处理多个对同一项的描述都正确的情况。

图 3-10. 使用多个标注者的输入创建重叠标签,以处理多个对同一项的描述都正确的情况。

多标签设计模式通过允许我们将重叠标签与图像关联来解决这个问题。在具有多个标签器评估训练数据集中每个图像的重叠标签的情况下,我们可以选择我们希望标签器为给定图像分配的最大标签数,然后在训练期间选择最常选择的标签与图像关联。对于“最常选择的标签”,阈值将取决于我们的预测任务和我们有多少人类标签器。例如,如果我们有 5 个标签器评估每个图像,并且每个图像有 20 个可能的标签,我们可能鼓励标签器为每个图像提供 3 个标签。从每个图像的这 15 个标签“投票”列表中,我们可以选择具有最多投票的 2 到 3 个标签。在评估此模型时,我们需要注意模型对每个标签返回的平均预测置信度,并使用这些数据迭代地改进我们的数据集和标签质量。

一对多

处理多标签分类的另一种技术是训练多个二元分类器而不是一个多标签模型。这种方法称为一对多。在 Stack Overflow 示例中,我们想要将问题标记为 TensorFlow、Python 和 pandas 时,我们会为这三个标签分别训练一个单独的分类器:Python 或非 Python,TensorFlow 或非 TensorFlow 等。然后,我们会选择一个置信度阈值,并使用每个二元分类器在某个阈值以上标记原始输入问题的标签。

一对多的好处在于我们可以将其与只能进行二元分类的模型架构(如 SVM)一起使用。它也可能有助于处理罕见的类别,因为模型每次只对每个输入执行一个分类任务,可以应用重新平衡设计模式。这种方法的缺点在于训练许多不同分类器增加了复杂性,需要我们构建应用程序以一种生成来自每个模型的预测的方式,而不是只有一个模型。

总之,在您的数据属于以下任何分类场景时,请使用多标签设计模式:

  • 单个训练示例可以与互斥标签相关联。

  • 单个训练示例可以具有许多层次标签。

  • 标签器以不同方式描述同一项,并且每种解释都是准确的。

当实现多标签模型时,请确保您的数据集中充分代表了重叠标签的组合,并考虑您在模型中愿意接受的每个可能标签的阈值值。使用 sigmoid 输出层是构建能够处理多标签分类的模型的最常见方法。此外,sigmoid 输出也可以应用于只能有两种可能标签之一的二元分类任务。

设计模式 7:集成

集成设计模式指的是机器学习中的一种技术,它结合多个机器学习模型并聚合它们的结果来进行预测。集成可以有效提高性能,产生比任何单一模型更好的预测结果。

问题

假设我们已经训练了我们的婴儿体重预测模型,工程师设计了特殊特征并添加了额外的神经网络层,以使训练集上的错误几乎为零。太棒了,你说!然而,当我们试图在医院生产环境中使用我们的模型或评估保留测试集的性能时,我们的预测全都错了。发生了什么?更重要的是,我们如何修复它?

没有机器学习模型是完美的。为了更好地理解我们的模型错在哪里以及如何修复,一个 ML 模型的错误可以分解为三个部分:不可减少的错误、由于偏差而引起的错误以及由于方差而引起的错误。不可减少的错误是模型固有的错误,由数据集中的噪声、问题的框架或糟糕的训练示例(如测量错误或混淆因素)引起。正如其名,我们对不可减少的错误无能为力。

另外两个,偏差和方差,被称为可减少的错误,这里是我们可以影响模型性能的地方。简而言之,偏差是模型在模型特征和标签之间学习不足的能力,而方差捕捉模型在新的、未见的示例上泛化能力不足的问题。高偏差的模型过度简化了关系,被称为欠拟合。高方差的模型对训练数据学习过多,被称为过拟合。当然,任何 ML 模型的目标是既低偏差又低方差,但在实践中,同时实现这两个是困难的。这被称为偏差-方差权衡。我们不能两全其美。例如,增加模型复杂性会降低偏差但增加方差,而减少模型复杂性会降低方差但引入更多偏差。

最近的研究工作表明,当使用现代机器学习技术,如具有高容量的大型神经网络时,这种行为只在某个阈值之内有效。在观察的实验中,存在一个“插值阈值”,超过这个阈值,非常高容量的模型能够在训练错误为零的同时,在未见数据上达到低错误率。当然,我们需要更大的数据集来避免在高容量模型上过拟合。

是否有办法在小规模和中等规模问题上减轻这种偏差-方差权衡呢?

解决方案

集成方法 是一种元算法,它将多个机器学习模型结合起来,以减少偏差和/或方差,并提高模型性能。一般来说,这种方法的思想是通过结合多个模型来改善机器学习结果。通过构建具有不同归纳偏差的多个模型并聚合它们的输出,我们希望得到性能更好的模型。在本节中,我们将讨论一些常用的集成方法,包括 Bagging、Boosting 和 Stacking。

Bagging

Bagging(即自助聚合)是一种并行集成方法,用于解决机器学习模型中的高方差问题。Bagging 中的自助部分指的是用于训练集成成员的数据集。具体来说,如果有 k 个子模型,则使用 k 个单独的数据集来训练集成的每个子模型。每个数据集通过从原始训练数据集中随机抽样(有放回地)构建而成。这意味着 k 个数据集中有很高的概率会缺少一些训练样本,但也可能会有重复的训练样本。聚合操作在多个集成模型成员的输出上进行——在回归任务中通常是平均值,在分类任务中通常是多数投票。

一个很好的 Bagging 集成方法的例子是随机森林:多个决策树在整个训练数据的随机抽样子集上进行训练,然后聚合树的预测结果以生成预测,如在图 3-11 中所示。

Bagging 对于减少机器学习模型输出的方差非常有效。

图 3-11. Bagging 对于减少机器学习模型输出的方差非常有效。

流行的机器学习库中都有 Bagging 方法的实现。例如,在 scikit-learn 中实现随机森林回归,以预测我们的出生数据集中婴儿的体重:

from sklearn.ensemble import RandomForestRegressor

# Create the model with 50 trees
RF_model = RandomForestRegressor(`n_estimators``=``50``,`
                                 max_features='sqrt',
                                 n_jobs=-1, verbose = 1)

# Fit on training data
RF_model.fit(X_train, Y_train)

在 Bagging 中看到的模型平均化是一种强大且可靠的降低模型方差的方法。正如我们将看到的,不同的集成方法以不同的方式结合多个子模型,有时使用不同的模型、不同的算法或甚至不同的目标函数。在 Bagging 中,模型和算法都是相同的。例如,在随机森林中,子模型都是短决策树。

Boosting

提升是另一种集成技术。但与装袋不同的是,提升最终构建一个具有 更多 容量的集成模型,而不是单个成员模型。因此,提升提供了比方差更有效的减少偏差的手段。提升背后的思想是迭代地构建一个模型集合,每个后续模型专注于学习上一个模型错误的示例。简而言之,提升通过迭代改进一系列弱学习器的加权平均值,最终产生一个强学习器。

在提升过程开始时,选择一个简单的基础模型 f_0。对于回归任务,基础模型可以只是目标值的平均值:f_0 = np.mean(Y_train)。在第一次迭代步骤中,测量并通过一个独立模型近似残差 delta_1。这个残差模型可以是任何模型,但通常不是非常复杂;我们通常会使用一个弱学习器,比如决策树。残差模型提供的近似值然后添加到当前预测中,并且该过程继续。

经过多次迭代后,残差趋近于零,并且预测在建模原始训练数据集时变得越来越好。请注意,在 图 3-12 中,数据集每个元素的残差随着每次迭代逐渐减小。

提升通过迭代改进模型预测将弱学习器转化为强学习器。

图 3-12. 提升通过迭代改进模型预测将弱学习器转化为强学习器。

一些知名的提升算法包括 AdaBoost、Gradient Boosting Machines 和 XGBoost,在流行的机器学习框架如 scikit-learn 或 TensorFlow 中都有易于使用的实现。

在 scikit-learn 中的实现也很简单:

from sklearn.ensemble import GradientBoostingRegressor

# Create the Gradient Boosting regressor
GB_model = GradientBoostingRegressor(n_estimators=1,
                                     max_depth=1,
                                     learning_rate=1,
                                     criterion='mse')

# Fit on training data
GB_model.fit(X_train, Y_train)

堆叠

堆叠是一种集成方法,它结合了一系列模型的输出来进行预测。初始模型通常是不同类型的模型,并在完整的训练数据集上进行训练。然后,使用初始模型输出作为特征训练一个次级元模型。这第二个元模型学习如何最佳地组合初始模型的输出以减少训练误差,并且可以是任何类型的机器学习模型。

要实施堆叠集成,我们首先在训练数据集上训练集合中的所有成员。以下代码调用一个函数 fit_model,该函数接受模型和训练数据集输入 X_train 和标签 Y_train 作为参数。这样 members 是一个包含我们集合中所有训练过的模型的列表。这个示例的完整代码可以在本书的代码 存储库 中找到。

members = [model_1, model_2, model_3]

# fit and save models
n_members = len(members)

for i in range(n_members):
    # fit model
    model = fit_model(members[i])
    # save model
    filename = 'models/model_' + str(i + 1) + '.h5'
    model.save(filename, save_format='tf')
    print('Saved {}\n'.format(filename))

这些子模型被整合到一个更大的堆叠集成模型中作为单独的输入。由于这些输入模型是与次要集成模型一起训练的,我们对这些输入模型的权重进行了固定。可以通过将 layer.trainable 设置为 False 来实现这一点,用于集成成员模型:

for i in range(n_members):
    model = members[i]
    for layer in model.layers:
        # make not trainable
        layer.trainable = False
        # rename to avoid 'unique layer name' issue
        layer._name = 'ensemble_' + str(i+1) + '_' + layer.name

我们使用 Keras 函数式 API 创建集成模型,将组件连接在一起:

member_inputs = [model.input for model in members]

# concatenate merge output from each model
member_outputs = [model.output for model in members]
merge = layers.concatenate(member_outputs)
hidden = layers.Dense(10, activation='relu')(merge)
ensemble_output = layers.Dense(1, activation='relu')(hidden)
ensemble_model = Model(inputs=member_inputs, outputs=ensemble_output)

# plot graph of ensemble
tf.keras.utils.plot_model(ensemble_model, show_shapes=True, 
                          to_file='ensemble_graph.png')

# compile
ensemble_model.compile(loss='mse', optimizer='adam', metrics=['mse'])

在这个例子中,次要模型是一个具有两个隐藏层的密集神经网络。通过训练,这个网络学习如何在进行预测时最好地结合集成成员的结果。

为什么有效

像 Bagging 这样的模型平均方法之所以有效,是因为通常组成集成模型的单个模型在测试集上不会都产生相同的误差。在理想情况下,每个单独的模型都会有一定随机的偏差,因此当它们的结果被平均时,随机误差会相互抵消,预测结果更接近正确答案。简而言之,众人的智慧。

Boosting 之所以有效,是因为模型会根据每个迭代步骤的残差而越来越严厉地受到惩罚。随着每次迭代,集成模型被鼓励在预测那些难以预测的示例时变得越来越好。堆叠之所以有效,是因为它结合了 Bagging 和 Boosting 的优点。次要模型可以被看作是模型平均的更复杂版本。

Bagging

更确切地说,假设我们已经训练了 k 个神经网络回归模型,并对它们的结果进行平均以创建一个集成模型。如果每个模型在每个示例上的误差为 error_i,其中 error_i 是从方差 var 和协方差 cov 的零均值多变量正态分布中抽取的,则集成预测器将具有以下误差:

ensemble_error = 1./k * np.sum([error_1, error_2,...,error_k])

如果误差 error_i 完全相关,使得 cov = var,则集成模型的均方误差减少到 var。在这种情况下,模型平均根本没有帮助。另一极端是,如果误差 error_i 完全不相关,则 cov = 0,集成模型的均方误差为 var/k。因此,预期的平方误差随着集成中模型数 k 的增加而线性减少。¹ 总之,平均而言,集成的表现至少与集成中的任何单个模型一样好。此外,如果集成中的模型产生独立的误差(例如,cov = 0),那么集成的性能将显著提高。归根结底,集成成功的关键在于模型的多样性。

这也解释了为什么对于稳定性较强的学习器如 k 最近邻(kNN)、朴素贝叶斯、线性模型或支持向量机(SVM),装袋通常效果较差,因为通过自助法抽样减少了训练集的大小。即使使用相同的训练数据,神经网络也能通过随机权重初始化、随机小批量选择或不同的超参数达到各种解决方案,创建部分独立误差的模型。因此,即使是在相同数据集上训练的神经网络,模型平均也能够带来好处。实际上,修复神经网络高方差的一个推荐解决方案是训练多个模型并聚合它们的预测。

Boosting

提升算法通过迭代改进模型以减少预测误差。每个新的弱学习器通过建模每一步的残差delta_i来纠正上一个预测的错误。最终预测是基础学习器和每个后续弱学习器输出之和,如 Figure 3-13 所示。

Boosting 通过一系列模型化上一次迭代残差误差的弱学习器来逐步构建强学习器。

Figure 3-13. Boosting 通过一系列模型化上一次迭代残差误差的弱学习器来逐步构建强学习器。

因此,结果集成模型变得越来越复杂,具有比其任何一个成员更多的容量。这也解释了为什么提升特别适用于对抗高偏差。记住,偏差与模型倾向于欠拟合有关。通过迭代地关注难以预测的例子,提升有效地减少了生成模型的偏差。

Stacking

Stacking 可以被看作是简单模型平均的扩展,我们在训练数据集上完全训练k个模型,然后平均结果以确定预测。简单模型平均类似于装袋,但集成中的模型可以是不同类型的,而对于装袋来说,模型是相同类型的。更一般地,我们可以修改平均步骤以进行加权平均,例如,在我们的集成中给一个模型更多的权重,如 Figure 3-14 所示。

最简单的模型平均将两个或更多不同的机器学习模型的输出进行平均。或者,平均值可以用基于模型相对精度的加权平均替换。

Figure 3-14. 最简单的模型平均将两个或更多不同的机器学习模型的输出进行平均。或者,平均值可以用基于模型相对精度的加权平均替换。

您可以将堆叠视为模型平均的更高级版本,其中我们不是取平均或加权平均,而是训练第二个机器学习模型来学习如何最好地组合我们集成模型中的结果以生成预测,如图 3-15 所示。这不仅提供了减少方差的所有好处,就像装袋技术一样,还控制了高偏差。

堆叠是一种集成学习技术,将几个不同的 ML 模型的输出作为二级 ML 模型的输入,该模型进行预测。

图 3-15. 堆叠是一种集成学习技术,将几个不同的 ML 模型的输出作为二级 ML 模型的输入,该模型进行预测。

折衷和替代方案

集成方法在现代机器学习中变得非常流行,并在赢得众所周知的挑战中发挥了重要作用,也许最为著名的是Netflix Prize。有大量的理论证据支持这些在真实世界挑战中展示的成功。

增加的训练和设计时间

集成学习的一个缺点是增加了训练和设计时间。例如,对于堆叠集成模型,选择集成成员模型可能需要自己的专业知识,并提出了自己的问题:是重新使用相同的架构还是鼓励多样性?如果我们使用不同的架构,应该选择哪些?以及数量是多少?不是开发单个 ML 模型(这本身可能就是很大的工作!),而是现在开发k个模型。我们在模型开发中引入了额外的开销,更不用说如果集成模型投入生产,维护、推理复杂性和资源使用问题了。随着集成模型中模型数量的增加,这很快可能变得不切实际。

流行的机器学习库,如 scikit-learn 和 TensorFlow,为许多常见的装袋和提升方法(如随机森林、AdaBoost、梯度提升和 XGBoost)提供了易于使用的实现。然而,我们应该仔细考虑是否值得使用集成方法所带来的额外开销。始终将准确性和资源使用与线性或 DNN 模型进行比较。请注意,蒸馏(参见“设计模式 11:有用的过拟合”)神经网络的集成通常可以减少复杂性并提高性能。

退火作为装袋

像 dropout 这样的技术提供了一个强大且有效的替代方案。Dropout 被称为深度学习中的一种正则化技术,但也可以理解为对 bagging 的一种近似。在神经网络中,dropout 会随机(以预定的概率)在每个训练小批量中“关闭”网络的神经元,本质上评估了指数级许多神经网络的袋装集成。尽管如此,使用 dropout 训练神经网络并不完全等同于 bagging。有两个显著的不同点。首先,在 bagging 的情况下,模型是独立的,而使用 dropout 训练时,模型共享参数。其次,在 bagging 中,模型被训练以收敛于各自的训练集。然而,在使用 dropout 训练时,集成成员模型只会在单个训练步骤中进行训练,因为在训练循环的每次迭代中会有不同的节点被丢弃。

降低模型的可解释性

另一个需要记住的点是模型的可解释性。在深度学习中,有效解释为什么我们的模型做出它所做的预测可能会很困难。这个问题在集成模型中更加严重。例如,考虑决策树与随机森林。决策树最终学习每个特征的边界值,指导单个实例到模型的最终预测。因此,解释决策树为何做出其预测是很容易的。而随机森林作为许多决策树的集成,失去了这种局部可解释性。

选择适合问题的正确工具

同样重要的是要牢记偏差和方差的权衡。一些集成技术比其他技术更擅长解决偏差或方差问题(见表格 3-2)。特别是,boosting 适用于解决高偏差问题,而 bagging 则用于修正高方差问题。尽管如此,正如我们在“Bagging”章节中看到的,组合两个具有高度相关错误的模型不会帮助降低方差。简而言之,对于我们的问题使用错误的集成方法不一定会改善性能;它只会增加不必要的开销。

表格 3-2. 偏差和方差之间的权衡总结

问题 集成解决方案
高偏差(欠拟合) Boosting
高方差(过拟合) Bagging

其他集成方法

我们已经讨论了一些常见的集成技术在机器学习中。之前讨论的列表绝非详尽无遗,这些广义分类下还有许多适合的算法。还有其他集成技术,包括一些结合贝叶斯方法或者结合神经架构搜索和强化学习的技术,比如谷歌的 AdaNet 或者 AutoML 技术。简而言之,集成设计模式涵盖了多种结合多个机器学习模型以提升整体模型性能的技术,特别适用于解决常见的训练问题,比如高偏差或高方差。

设计模式 8:级联

级联设计模式解决了机器学习问题可以有利地分解成一系列机器学习问题的情况。这种级联往往需要仔细设计机器学习实验。

问题

如果我们需要在正常活动和异常活动期间预测值,会发生什么?模型会学会忽略异常活动,因为它很少见。如果异常活动也与异常值相关联,则可训练性会受到影响。

例如,假设我们试图训练一个模型来预测顾客退货的可能性。如果我们只训练一个模型,经销商的退货行为将会被忽略,因为有数百万的零售买家(和零售交易),而只有几千个经销商。在购买时,我们不知道这是零售买家还是经销商。然而,通过监控其他市场,我们已经确定了我们销售的物品后续是否被转售,因此我们的训练数据集有一个标签,标识了由经销商购买的物品。

解决这个问题的一种方法是在训练模型时加大经销商实例的权重。这是次优的,因为我们需要尽可能准确地获取更常见的零售买家用例。我们不希望为了提高经销商用例的准确性而牺牲零售买家用例的准确性。然而,零售买家和经销商的行为非常不同;例如,零售买家在一周左右内退货,而经销商只有在无法销售时才退货,因此退货可能在数月后发生。从零售买家和经销商的角度来看,库存管理的业务决策是不同的。因此,有必要尽可能准确地获取这两种类型的退货。简单地加大经销商实例的权重是行不通的。

解决这个问题的一个直观方法是使用级联设计模式。我们将问题分解为四个部分:

  1. 预测特定交易是否由经销商进行

  2. 训练一个模型以销售给零售买家

  3. 训练第二个模型以销售给经销商

  4. 在生产中,结合三个独立模型的输出来预测每个购买物品的退货可能性和交易可能由转售商进行的概率

这允许在可能返回不同决策的项目上取得不同的决定,这取决于买家类型,并确保步骤 2 和步骤 3 模型在其训练数据段上尽可能准确。每个模型相对容易训练。第一个仅仅是一个分类器,如果异常活动非常罕见,我们可以使用重新平衡模式来处理。接下来的两个模型本质上是在不同训练数据段上训练的分类模型。该组合是确定性的,因为我们根据活动是否属于转售商选择运行哪个模型。

问题出现在预测过程中。在预测时,我们没有真实标签,只有第一个分类模型的输出。基于第一个模型的输出,我们将不得不确定调用哪个销售模型。问题在于我们在标签上训练,但在推断时,我们必须根据预测做出决策。而预测存在误差。因此,第二个和第三个模型将需要对它们可能在训练期间从未见过的数据进行预测。

举个极端的例子,假设转售商提供的地址总是在城市的工业区域,而零售买家可以住在任何地方。如果第一个(分类)模型出错,并且错误地将零售买家识别为转售商,那么调用的取消预测模型将不会在其词汇表中包含客户居住的邻域。

我们如何训练级联模型,其中一个模型的输出是后续模型的输入或决定后续模型的选择?

解决方案

任何一个机器学习问题,其中一个模型的输出是后续模型的输入或决定后续模型的选择,被称为级联。在训练级联机器学习模型时必须特别小心。

例如,有时涉及异常情况的机器学习问题可以通过将其视为四个机器学习问题的级联来解决:

  1. 用于识别情况的分类模型

  2. 一个在异常情况下训练的模型

  3. 一个在典型情况下训练的独立模型

  4. 一个模型来组合两个独立模型的输出,因为输出是两个输出的概率组合

乍看之下,这似乎是集成设计模式的一个特例,但由于进行级联时需要的特殊实验设计,它被单独考虑。

例如,假设为了估算在车站储存自行车的成本,我们希望预测旧金山租赁和返回站点之间的距离。换句话说,模型的目标是预测我们需要将自行车运回租赁地点的距离,给定特征,例如租赁开始的时间、租赁自行车的地点、租客是否为订阅者等。问题在于超过四小时的租赁涉及的租客行为与较短租赁大不相同,而库存算法需要两个输出(租赁超过四小时的概率和需要运输自行车的预计距离)。然而,只有极小一部分租赁涉及这样的异常行程。

解决这个问题的一种方法是首先训练一个分类模型来根据租赁是否为长期或典型进行分类(这本书的代码库中有完整代码):

        CREATE OR REPLACE MODEL mlpatterns.classify_trips
        TRANSFORM(
          trip_type,
          EXTRACT (HOUR FROM start_date) AS start_hour,
          EXTRACT (DAYOFWEEK FROM start_date) AS day_of_week,
          start_station_name,
          subscriber_type,
          ...
        )
        OPTIONS(model_type='logistic_reg', 
                auto_class_weights=True,
                input_label_cols=['trip_type']) AS

        SELECT
          start_date, start_station_name, subscriber_type, ...
          IF(duration_sec > 3600*4, `'Long'``,` `'Typical'`) AS trip_type
        FROM `bigquery-public-data.san_francisco_bikeshare.bikeshare_trips`

简单地根据租赁的实际持续时间将训练数据集分为两部分,然后分别训练下两个模型(一个针对长租赁,另一个针对典型租赁)可能是很诱人的。问题在于前面讨论的分类模型会有误差。实际上,在旧金山自行车数据的保留部分上评估模型表明,模型的准确率仅约为 75%(见图 3-16)。鉴于此,将模型训练在数据的完美分割将导致泪水。

用于预测非典型行为的分类模型的准确率不太可能达到 100%。

图 3-16。用于预测非典型行为的分类模型的准确率不太可能达到 100%。

然后,在训练完这个分类模型后,我们需要使用该模型的预测来创建下一个模型的训练数据集。例如,我们可以使用以下方式创建用于预测典型租赁距离的模型的训练数据集:

        CREATE OR REPLACE TABLE mlpatterns.`Typical_trips` AS
        SELECT 
          * EXCEPT(predicted_trip_type_probs, predicted_trip_type)
        FROM
        ML.PREDICT(MODEL mlpatterns.classify_trips,
          (SELECT
          start_date, start_station_name, subscriber_type, ...,
          ST_Distance(start_station_geom, end_station_geom) AS distance
          FROM `bigquery-public-data.san_francisco_bikeshare.bikeshare_trips`)
        )
        WHERE predicted_trip_type = 'Typical' AND distance IS NOT NULL

然后,我们应该使用这个数据集来训练预测距离的模型:

        CREATE OR REPLACE MODEL mlpatterns.predict_distance_Typical
        TRANSFORM(
          distance,
          EXTRACT (HOUR FROM start_date) AS start_hour,
          EXTRACT (DAYOFWEEK FROM start_date) AS day_of_week,
          start_station_name,
          subscriber_type,
          ...
        )
        OPTIONS(model_type='linear_reg', input_label_cols=['distance']) AS

        SELECT
          *
        `FROM` 
          mlpatterns.Typical_trips

最后,我们的评估、预测等都应考虑到,我们需要使用三个经过训练的模型,而不仅仅是一个。这就是我们所称的级联设计模式。

在实践中,保持级联工作流的清晰度可能变得困难。与单独训练模型相比,最好使用工作流程管道模式自动化整个工作流程(如第六章所示),如图 3-17。关键是确保每次运行实验时,基于上游模型的预测创建两个下游模型的训练数据集。

尽管我们介绍级联模式作为在通常和不寻常活动期间预测值的一种方式,但级联模式的解决方案能够处理更一般的情况。管道框架使我们能够处理任何可以将机器学习问题有利地分解为一系列(或级联)机器学习问题的情况。每当一个机器学习模型的输出需要作为另一个模型的输入时,第二个模型需要根据第一个模型的预测进行训练。在所有这些情况下,正式的管道实验框架将会很有帮助。

一个将级联模型作为单个作业进行训练的管道。

图 3-17. 一个将级联模型作为单个作业进行训练的管道。

Kubeflow Pipelines 提供了这样一个框架。由于它与容器一起工作,底层的机器学习模型和粘合代码可以用几乎任何编程或脚本语言编写。在这里,我们将上述的 BigQuery SQL 模型封装成 Python 函数,使用 BigQuery 客户端库。我们可以使用 TensorFlow 或 scikit-learn,甚至 R 来实现各个组件。

使用 Kubeflow Pipelines 的管道代码可以简单地表达如下(本书的完整代码可以在代码存储库中找到):

@dsl.pipeline(
    name='Cascade pipeline on SF bikeshare',
    description='Cascade pipeline on SF bikeshare'
)
def cascade_pipeline(
    project_id = PROJECT_ID
):
    ddlop = comp.func_to_container_op(run_bigquery_ddl, 
                    packages_to_install=['google-cloud-bigquery'])

    c1 = train_classification_model(ddlop, PROJECT_ID)
    c1_model_name = c1.outputs['created_table']

    c2a_input = create_training_data(ddlop, 
                   PROJECT_ID, c1_model_name, 'Typical')
    c2b_input = create_training_data(ddlop, 
                   PROJECT_ID, c1_model_name, 'Long')

    c3a_model = train_distance_model(ddlop, 
                   PROJECT_ID, c2a_input.outputs['created_table'], 'Typical')
    c3b_model = train_distance_model(ddlop, 
                   PROJECT_ID, c2b_input.outputs['created_table'], 'Long')

    ...

整个管道可以提交运行,并可以使用 Pipelines 框架跟踪实验的不同运行。

提示

如果我们正在使用 TFX 作为我们的管道框架(我们可以在 Kubeflow Pipelines 上运行 TFX),那么没有必要部署上游模型以使用它们的输出预测在下游模型中。相反,我们可以在预处理操作中使用 TensorFlow Transform 方法 tft.apply_saved_model。转换设计模式在第六章中有所讨论。

强烈建议在我们将会有串联的机器学习模型时使用管道实验框架。这样的框架将确保在上游模型修订时重新训练下游模型,并且我们有所有先前训练运行的历史记录。

折衷与替代方案

不要过度使用级联设计模式——与本书涵盖的许多设计模式不同,级联模式不一定是最佳实践。它会给你的机器学习工作流程增加相当多的复杂性,实际上可能导致性能下降。请注意,管道实验框架绝对是最佳实践,但尽可能地,尝试将管道限制在单个机器学习问题上(摄取、预处理、数据验证、转换、训练、评估和部署)。避免像级联模式中那样,在同一个管道中包含多个机器学习模型。

确定性输入

在大多数情况下,将机器学习问题拆分通常是一个不好的主意,因为机器学习模型可以/应该学习多个因素的组合。例如:

  • 如果一个条件可以从输入中确定地知道(假期购物与工作日购物),我们应该将该条件作为模型的另一个输入添加进去。

  • 如果条件涉及仅一个输入的极值(一些居住在附近与远处的客户,其中“附近/远处”的含义需要从数据中学习),我们可以使用混合输入表示来处理它。

级联设计模式解决了一个不寻常的场景,其中我们没有分类输入,而且需要从多个输入中学习极值。

单一模型

在常见情况下不应使用级联设计模式,适用单一模型即可。例如,假设我们正在尝试学习客户的购买倾向。我们可能认为我们需要为那些进行比较购物和不进行比较购物的人学习不同的模型。我们真的不知道谁进行了比较购物,但我们可以根据访问次数、商品在购物车中停留的时间等进行合理猜测。这个问题不需要级联设计模式,因为它足够常见(大部分客户都会进行比较购物),机器学习模型应该能在训练过程中隐式地学习到它。对于常见情况,只需训练一个单一模型。

内部一致性

当我们需要在多个模型的预测之间保持内部一致性时,需要级联。请注意,我们试图做的不仅仅是预测不寻常的活动。我们正在尝试预测回报,考虑到还会有一些转售活动。如果任务仅仅是预测销售是否由转售商进行,我们将使用再平衡模式。使用级联的原因是,不平衡的标签输出需要作为后续模型的输入,并且本身也很有用。

同样地,假设我们训练模型来预测客户购买的倾向是为了提供折扣优惠。我们是否提供折扣优惠,以及优惠的金额,很大程度上会取决于这位客户是否进行比较购物。鉴于此,我们需要在两个模型之间保持内部一致性(用于比较购物者和购买倾向的模型)。在这种情况下,可能需要级联设计模式。

预训练模型

当我们希望重用预训练模型的输出作为我们模型的输入时,级联也是必需的。例如,假设我们正在构建一个模型来检测建筑物的授权入口者,以便我们可以自动打开门。我们模型的一个输入可能是车辆的车牌。与其直接使用安全照片在我们的模型中,我们可能会发现使用光学字符识别(OCR)模型的输出更简单。重要的是要认识到 OCR 系统会有误差,因此我们不应该使用完美的车牌信息来训练我们的模型。相反,我们应该根据 OCR 系统的实际输出来训练模型。实际上,因为不同的 OCR 模型的行为不同且具有不同的错误,如果更改 OCR 系统的供应商,有必要重新训练模型。

提示

使用预训练模型作为流水线的第一步的常见情况是使用对象检测模型,然后是精细图像分类模型。例如,对象检测模型可能会在图像中找到所有的手提包,中间步骤可能会裁剪到检测到的对象的边界框,并且随后的模型可能会识别手提包的类型。我们建议使用级联,以便在对象检测模型更新时(例如使用 API 的新版本时)可以重新训练整个流水线。

重新构思而不是级联

注意,在我们的示例问题中,我们试图预测物品退货的可能性,因此这是一个分类问题。假设我们希望预测每小时销售额。大多数情况下,我们只会服务零售买家,但偶尔(例如每年四到五次),我们会有批发买家。

这在概念上是一个回归问题,即预测每日销售额,我们有一个混淆因素,即批发买家。重新构思回归问题,将其作为不同销售额分类问题可能是更好的方法。虽然这将涉及针对每个销售额桶训练分类模型,但可以避免需要正确区分零售与批发。

在罕见情况下的回归

当进行回归时,某些值比其他值常见时,级联设计模式可能是有帮助的。例如,我们可能想要从卫星图像预测降雨量。有可能在 99%的像素上没有雨。在这种情况下,创建堆叠分类模型后跟回归模型可能是有帮助的:

  1. 首先,预测是否会下雨。

  2. 对于模型预测雨不太可能的像素,预测降雨量为零。

  3. 训练回归模型,以预测模型预测降雨可能性的像素上的降雨量。

必须认识到分类模型并不完美,所以回归模型必须根据分类模型预测可能下雨的像素进行训练(而不仅仅是标记数据集中对应于雨的像素)。关于这个问题的补充解决方案,请参见“设计模式 10:再平衡”和“设计模式 5:重新构架”的讨论。

设计模式 9:中性类

在许多分类情况下,创建一个中性类可能会有所帮助。例如,不是训练输出事件概率的二元分类器,而是训练一个三类分类器,为“是”、“否”和“或许”分别输出互斥的概率。这里的互斥意味着类别不重叠。一个训练模式只能属于一个类别,因此“是”和“或许”之间没有重叠。在这种情况下,“或许”就是中性类。

问题

想象一下,我们试图创建一个关于止痛药的指导模型。有两种选择,布洛芬和对乙酰氨基酚,² 根据我们的历史数据集,对乙酰氨基酚倾向于优先给存在胃问题风险的患者开药,而布洛芬倾向于优先给存在肝损伤风险的患者开药。除此之外,情况相当随机;一些医生倾向于默认给对乙酰氨基酚,而另一些则倾向于布洛芬。

在这样的数据集上训练二元分类器会导致准确率低,因为模型需要正确处理本质上是任意的案例。

解决方案

想象一个不同的场景。假设电子记录捕捉到医生的处方,同时询问他们是否可以接受替代止痛药。如果医生开了对乙酰氨基酚,应用程序会问医生患者如果已经在药柜里有布洛芬,是否可以使用布洛芬。

根据第二个问题的答案,我们得到一个中性类。处方可能仍然被写为“对乙酰氨基酚”,但记录显示医生对这位患者持中立态度。请注意,这从根本上要求我们适当设计数据收集——我们不能事后制造中性类。我们必须正确设计机器学习问题。在这种情况下,正确的设计从问题的提出开始。

如果我们只有历史数据集,我们需要引入一个标记服务。我们可以请人工标记员验证医生的原始选择,并回答是否可以接受替代止痛药。

为什么它有效

我们可以通过模拟涉及合成数据集的机制来探索其工作原理。接着,我们将展示类似的情况在边缘案例中也会发生于现实世界。

合成数据

让我们创建一个长度为N的合成数据集,其中 10%的数据代表有黄疸病史的患者。因为他们有肝损伤的风险,他们的正确处方是布洛芬(完整代码见 GitHub:链接):

    jaundice[0:N//10] = True
    prescription[0:N//10] = 'ibuprofen'

另外 10%的数据将代表有胃溃疡病史的患者;因为他们有胃损伤的风险,他们的正确处方是对乙酰氨基酚:

    ulcers[(9*N)//10:] = True
    prescription[(9*N)//10:] = 'acetaminophen'

剩下的患者将被随机分配到两种药物中的任意一种。显然,这种随机分配将导致仅基于两个类别训练的模型的总体准确率较低。事实上,我们可以计算准确率的上限。因为 80%的训练示例具有随机标签,模型在这些示例的最佳表现是猜对其中一半。因此,在这些训练示例的准确率将为 40%。剩下的 20%训练示例具有系统标签,并且理想模型将学会这一点,因此我们预计总体准确率最多可以达到 60%。

实际上,使用 scikit-learn 训练模型如下,我们得到了 0.56 的准确率:

    ntrain = 8*len(df)//10 # 80% of data for training
    lm = linear_model.LogisticRegression()
    lm = lm.fit(df.loc[:ntrain-1, ['jaundice', 'ulcers']], 
                df[label][:ntrain])
    acc = lm.score(df.loc[ntrain:, ['jaundice', 'ulcers']], 
                df[label][ntrain:])

如果我们创建三个类,并将所有随机分配的处方放入该类中,我们得到了预期的完美(100%)的准确率。合成数据的目的是说明,只要工作中有随机分配,中性类设计模式可以帮助我们避免因任意标记数据而失去模型准确性。

在真实世界中

在实际情况中,情况可能不像合成数据集中那样精确随机,但是任意分配范式仍然适用。例如,婴儿出生后一分钟,婴儿会被分配一个“Apgar 评分”,这是一个介于 1 到 10 之间的数字,其中 10 表示婴儿完美度过了分娩过程。

考虑一个模型,该模型训练用于预测一个婴儿是否能够健康地度过分娩过程,或者是否需要立即关注(完整代码见 GitHub:链接):

CREATE OR REPLACE MODEL mlpatterns.neutral_2classes
OPTIONS(model_type='logistic_reg', input_label_cols=['health']) AS

SELECT 
  IF(apgar_1min >= 9, 'Healthy', 'NeedsAttention') AS health,
  plurality,
  mother_age,
  gestation_weeks,
  ever_born
FROM `bigquery-public-data.samples.natality`
WHERE apgar_1min <= 10

我们将 Apgar 评分阈值设定为 9,并将 Apgar 评分为 9 或 10 的婴儿视为健康,将 Apgar 评分为 8 或更低的婴儿视为需要关注的对象。在 natality 数据集上训练并在留出数据上评估的这种二元分类模型的准确率为 0.56。

然而,分配 Apgar 评分涉及多个相对主观的评估,而婴儿是否被分配为 8 或 9 往往减少到医生的偏好问题。这些婴儿既不是完全健康的,也不需要严重的医疗干预。如果我们创建一个中性类来容纳这些“边缘”评分,会怎样?这需要创建三个类别,Apgar 评分为 10 被定义为健康,评分为 8 到 9 被定义为中性,低分被定义为需要关注:

CREATE OR REPLACE MODEL mlpatterns.neutral_3classes
OPTIONS(model_type='logistic_reg', input_label_cols=['health']) AS

SELECT 
  IF(apgar_1min = 10, 'Healthy',
     IF(apgar_1min >= 8, 'Neutral', 'NeedsAttention')) AS health,
  plurality,
  mother_age,
  gestation_weeks,
  ever_born
FROM `bigquery-public-data.samples.natality`
WHERE apgar_1min <= 10

该模型在保留评估数据集上达到了 0.79 的准确率,远高于两类模型达到的 0.56 的准确率。

折衷和替代方案

在机器学习问题的初期,中性类设计模式是需要牢记的一个模式。收集正确的数据,我们可以避免后续出现许多棘手的问题。以下是一些使用中性类有帮助的情况。

当人类专家存在分歧时

中性类对处理人类专家之间的分歧非常有帮助。假设我们有人类标记者,向他们展示病人的历史记录,并询问他们会开什么药。在某些情况下,我们可能会对乙酰氨基酚有清晰的信号,在其他情况下,对布洛芬有明确的信号,而在大部分情况下,人类标记者存在分歧。中性类为处理这些情况提供了一种方法。

在人类标注的情况下(与只有一个医生看过患者的历史数据集不同),每个模式都由多位专家标记。因此,我们预先知道人类在哪些案例上存在分歧。简单地丢弃这些案例并简单地训练一个二元分类器似乎更加简单。毕竟,模型在中性案例上的表现无关紧要。这样做有两个问题:

  1. 错误的自信往往会影响人类专家对模型的接受程度。一个输出中性决定的模型通常比在人类专家会选择另一种情况时错误地充满信心的模型更容易被专家接受。

  2. 如果我们正在训练一系列模型,那么下游模型将极其敏感于中性类。如果我们继续改进该模型,那么下游模型可能会从一个版本改变到另一个版本。

另一种选择是在训练期间使用人类标记者之间的一致性作为模式的权重。因此,如果有 5 位专家对一个诊断意见一致,那么训练模式将获得权重为 1,而如果专家意见分歧为 3 比 2,模式的权重可能仅为 0.6。这样可以训练一个二元分类器,但是过多地偏向“确定”的案例。这种方法的缺点是,当模型输出的概率为 0.5 时,不清楚是因为训练数据不足,还是人类专家存在分歧。使用中性类捕捉分歧的领域允许我们消除这两种情况的歧义。

客户满意度

中性类的需求也出现在试图预测客户满意度的模型中。如果训练数据包括客户根据 1 到 10 的评分对其体验进行评级的调查响应,那么将评级分为三类可能会很有帮助:1 到 4 为差评,8 到 10 为好评,5 到 7 为中性评价。如果我们尝试通过在 6 处设定阈值来训练二元分类器,模型将花费过多精力来正确预测实质上是中性的响应。

作为改进嵌入的一种方式

假设我们正在为航班创建一个定价模型,并希望预测客户是否会以某个价格购买航班。为了做到这一点,我们可以查看航班购买和放弃购物车的历史交易。然而,假设我们的许多交易也包括了代理人和旅行代理商的购买 - 这些人已经签订了票价协议,因此对他们来说,票价并没有实际动态设定。换句话说,他们不会支付当前显示的价格。

我们可以丢弃所有非动态购买,并仅在基于当前显示价格做出购买或不购买决策的客户上训练模型。然而,这样的模型会错过关于代理人或旅行代理商在不同时间点关注的目的地的所有信息 - 这将影响到如何嵌入机场和酒店等因素。保留这些信息而不影响定价决策的一种方法是为这些交易使用一个中性类。

使用中性类重新构架

假设我们正在训练一个基于预期安全性价格上升或下降而进行交易的自动交易系统。由于股市的波动性以及新信息在股价中反映的速度,试图在预测的小幅上涨和下跌上进行交易很可能会导致高昂的交易成本和长期的低利润。

在这种情况下,考虑最终目标是很有帮助的。机器学习模型的最终目标不是预测股票将上涨还是下跌。我们无法购买每一只我们预测将上涨的股票,也无法出售我们不持有的股票。

更好的策略可能是购买对最有可能在未来 6 个月内上涨超过 5%的 10 只股票买入看涨期权³,并为那些最有可能在未来 6 个月内下跌超过 5%的股票买入看跌期权。

因此,解决方案是创建一个包含三种类别的训练数据集:

  • 上涨超过 5%的股票 - 看涨。

  • 下跌超过 5%的股票 - 看跌。

  • 剩余的股票属于中性类别。

不再训练回归模型来预测股票将上涨多少,而是可以用这三个类别训练分类模型,并从模型中选择最可信的预测。

设计模式 10:再平衡

重新平衡设计模式提供了处理固有不平衡数据集的各种方法。我们指的是数据集中一个标签占据大部分数据集,其他标签的示例明显较少。

此设计模式并解决数据集缺乏特定人群或现实环境表征的情况。这类情况通常只能通过额外的数据收集来解决。重新平衡设计模式主要解决的是如何使用少量类别或类别的数据集构建模型的问题。

问题

当模型在数据集中每个标签类别给出相似数量的示例时,机器学习模型学习效果最佳。然而,许多现实世界的问题并不如此平衡。例如,考虑欺诈检测用例,您正在构建一个模型来识别欺诈信用卡交易。欺诈交易远比常规交易罕见,因此用于训练模型的欺诈案例数据较少。对于其他问题,如预测是否会有人贷款违约、识别有缺陷的产品、根据医学图像预测疾病的存在、过滤垃圾邮件、标记软件应用程序中的错误日志等,情况也是如此。

不平衡数据集适用于许多类型的模型,包括二元分类、多类分类、多标签分类和回归。在回归情况下,不平衡数据集指的是数据中具有远高于或远低于数据集中位数的异常值。

在训练具有不平衡标签类别的模型时,常见的陷阱是依赖于误导性的准确率值进行模型评估。如果我们训练一个欺诈检测模型,而我们的数据集仅包含 5% 的欺诈交易,那么我们的模型有可能在没有对数据集或底层模型架构进行任何修改的情况下训练到 95% 的准确率。虽然这个 95% 的准确率在技术上是正确的,但模型很有可能是在每个示例中猜测多数类(在本例中是非欺诈类)。因此,它并没有学习如何区分少数类与数据集中其他示例的差异。

为了避免过度依赖这种误导性的准确率值,值得查看模型的混淆矩阵,以查看每个类别的准确率。在不平衡数据集上训练的表现不佳的模型的混淆矩阵通常看起来像图 3-18 中描述的样子。

在未对数据集或模型进行调整的情况下训练的不平衡数据集的混淆矩阵。

图 3-18. 在未对数据集或模型进行调整的情况下训练的不平衡数据集的混淆矩阵。

在这个示例中,模型在大多数情况下能够正确猜测出主要类别,但只有 12%的时间能够正确猜测出少数类别。通常,高性能模型的混淆矩阵在对角线上的百分比接近 100%。

解决方案

首先,由于在不平衡数据集上准确度可能具有误导性,因此在构建我们的模型时选择适当的评估指标非常重要。然后,我们可以在数据集和模型级别上采用各种技术来处理固有的不平衡数据集。降采样改变了我们基础数据集的平衡,而加权改变了我们的模型如何处理某些类别。过采样从我们的少数类别中复制示例,并且通常涉及应用增强以生成额外的样本。我们还将研究重新构架问题的方法:将其改变为回归任务,分析我们模型对每个示例的错误值,或进行聚类。

选择评估指标

对于像我们的欺诈检测示例中那样不平衡的数据集,最好使用精度、召回率或 F-度量来全面了解我们的模型表现如何。精度衡量了模型正确预测出所有正预测中正确的积极分类的百分比。相反,召回率衡量了模型正确识别出的实际正例的比例。这两个指标之间的最大区别在于用于计算它们的分母。对于精度,分母是我们的模型做出的积极类别预测的总数。对于召回率,它是我们数据集中实际正类示例的数量。

完美的模型应该具有精度和召回率均为 1.0,但实际上,这两个指标通常是互相对立的。F-度量是一个从 0 到 1 的度量,同时考虑了精度和召回率。它的计算公式如下:

2 * (precision * recall / (precision + recall))

让我们回到欺诈检测用例,看看这些指标在实践中如何发挥作用。例如,假设我们的测试集包含总共 1,000 个示例,其中有 50 个应标记为欺诈交易。对于这些示例,我们的模型正确预测出了 930/950 个非欺诈示例,以及 15/50 个欺诈示例。我们可以在图 3-19 中可视化这些结果。

一个欺诈检测模型的样本预测。

图 3-19. 一个欺诈检测模型的样本预测。

在这种情况下,我们模型的精度为 15/35(42%),召回率为 15/50(30%),而 F-度量为 35%。与准确率相比,这些指标更能有效捕捉到我们模型在正确识别欺诈交易方面的能力不足。准确率为 945/1000(94.5%)。因此,在训练于不平衡数据集上的模型中,除准确率外的其他度量标准更受青睐。实际上,在优化这些指标时,准确率甚至可能会下降,但这没关系,因为在这种情况下,精度、召回率和 F-度量更能体现模型的性能。

注意,在评估训练于不平衡数据集上的模型时,我们需要在计算成功度量标准时使用未经采样的数据。这意味着无论我们如何修改我们的数据集用于训练,我们将在下面概述的解决方案中,都应保持测试集的原样,以便它能够准确地反映原始数据集。换句话说,我们的测试集应该与原始数据集具有大致相同的类别平衡。例如,在上述示例中,这将是 5% 的欺诈交易和 95% 的非欺诈交易。

如果我们正在寻找一个能够捕捉模型在所有阈值下性能的指标,平均精度-召回率是一个更具信息性的指标,比 ROC 曲线下面积(AUC)更好用于模型评估。这是因为平均精度-召回率更加强调模型在总体分配给正类的预测中有多少个预测是正确的。这更加重视正类,这对于不平衡数据集是很重要的。而 AUC 则平等对待两个类别,对于模型改进不太敏感,这在不平衡数据的情况下并不理想。

降采样

降采样是一种处理不平衡数据集的解决方案,通过改变底层数据集而不是模型。通过降采样,我们减少了在模型训练过程中使用的多数类示例数量。为了看看这是如何运作的,让我们来看看Kaggle 上的合成欺诈检测数据集。⁴ 数据集中的每个示例都包含有关交易的各种信息,包括交易类型、交易金额以及交易发生前后的账户余额。该数据集包含 630 万个示例,其中仅有 8,000 个是欺诈交易。这仅占整个数据集的 0.1%。

尽管大数据集通常能够提高模型识别模式的能力,但在数据显著不平衡的情况下,其帮助作用就不那么大了。如果我们在整个数据集上训练模型(630 万行),而没有进行任何修改,很可能会看到误导性的准确率达到 99.9%,因为模型每次随机猜测非欺诈类。我们可以通过移除数据集中大部分多数类示例来解决这个问题。

我们将所有 8,000 个欺诈示例分开设置,以便在训练模型时使用。然后,我们将随机抽取少量非欺诈交易。然后,我们将与我们的 8,000 个欺诈示例组合,重新洗牌数据,并使用这个新的、较小的数据集来训练模型。以下是我们如何可以用 pandas 实现这一点的方式:

data = pd.read_csv('fraud_data.csv')

# Split into separate dataframes for fraud / not fraud
fraud = data[data['isFraud'] == 1]
not_fraud = data[data['isFraud'] == 0]

# Take a random sample of non fraud rows
not_fraud_sample = not_fraud.sample(random_state=2, frac=.005)

# Put it back together and shuffle
df = pd.concat([not_fraud_sample,fraud])
df = shuffle(df, random_state=2)

随后,我们的数据集将包含 25%的欺诈交易,比原始数据集中仅 0.1%的少数类更加平衡。在进行下采样时,值得尝试不同的精确平衡。在这里,我们使用了一个 25/75 的分割,但是不同的问题可能需要接近 50/50 的分割才能达到良好的准确性。

Downsampling 通常与 Ensemble 模式结合使用,按以下步骤进行:

  1. 对多数类进行下采样,并使用少数类的所有实例。

  2. 训练一个模型并将其添加到集成中。

  3. 重复。

在推断期间,获取集成模型的中位数输出。

我们在这里讨论了一个分类示例,但是下采样也可以应用于回归模型,其中我们预测数值。在这种情况下,由于我们的数据中的多数“类”包括一系列值而不是单个标签,从多数类样本中随机抽取样本将更加微妙。

加权类别

处理不平衡数据集的另一种方法是改变模型为每个类别示例赋予的权重。请注意,这是与训练期间学习的权重(或参数)不同的“权重”用法,您无法手动设置。通过加权类别,我们告诉模型在训练期间对特定标签类别给予更多重视。我们希望模型为来自少数类的示例分配更多权重。您的模型应该为某些示例分配多少重要性,完全取决于您,这是一个可以进行实验的参数。

在 Keras 中,当我们使用fit()训练模型时,可以传递class_weights参数给我们的模型。class_weights参数是一个字典,将每个类映射到 Keras 应分配给该类示例的权重。但是,我们应该如何确定每个类的确切权重呢?类权重值应与数据集中每个类的平衡相关联。例如,如果少数类仅占数据集的 0.1%,合理的结论是,我们的模型应该以比多数类高 1000 倍的权重处理该类的示例。在实践中,通常将该权重值除以 2 以使每个类的平均示例权重为1.0。因此,对于仅包含 0.1%少数类值的数据集,我们可以使用以下代码计算类权重:

num_minority_examples = 1
num_majority_examples = 999
total_examples = num_minority_examples + num_majority_examples

minority_class_weight = 1/(num_minority_examples/total_examples)/2
majority_class_weight = 1/(num_majority_examples/total_examples)/2

# Pass the weights to Keras in a dict
# The key is the index of each class
keras_class_weights = {0: majority_class_weight, 1: minority_class_weight}

然后在训练模型时传递这些权重:

model.fit(
    train_data,
    train_labels, 
    class_weight=keras_class_weights
)

在 BigQuery ML 中,我们可以在创建模型时的OPTIONS块中设置AUTO_CLASS_WEIGHTS = True,以便根据训练数据中的频率对不同类别进行加权。

虽然遵循类平衡启发式设置类权重可能有所帮助,但模型的业务应用可能也会决定我们选择分配的类权重。例如,假设我们有一个分类缺陷产品图像的模型。如果运输缺陷产品的成本是错误分类正常产品的 10 倍,我们将选择 10 作为少数类的权重。

上采样

处理不平衡数据集的另一种常见技术是上采样。通过上采样,我们通过复制少数类示例和生成额外的合成示例来过度表示我们的少数类。通常与减少多数类同时进行。这种方法——结合减少和上采样——在 2002 年提出,并称为合成少数过采样技术(SMOTE)。SMOTE 提供了一种算法,通过分析数据集中少数类示例的特征空间来构造这些合成示例,然后使用最近邻方法在这个特征空间内生成类似的示例。根据我们选择同时考虑多少相似数据点(也称为最近邻数),SMOTE 方法随机生成这些点之间的新的少数类示例。

让我们以高层次来看 Pima 印第安人糖尿病数据集来看看这是如何工作的。这个数据集中有 34%的例子是患有糖尿病的患者,所以我们将其视为我们的少数类。表格 3-3 显示了两个少数类样本的列的子集。

表格 3-3. Pima 印第安人糖尿病数据集中少数类(患有糖尿病)的两个训练样本的特征子集

葡萄糖 血压 皮肤厚度 BMI
148 72 35 33.6
183 64 0 23.3

基于数据集中这两个实际样本的新的合成示例可能看起来像表格 3-4,通过计算每个列值之间的中点。

表格 3-4. 使用 SMOTE 方法从两个少数训练样本生成的合成示例的合成示例

葡萄糖 血压 皮肤厚度 BMI
165.5 68 17.5 28.4

SMOTE 技术主要涉及表格数据,但类似的逻辑也可以应用于图像数据集。例如,如果我们正在构建一个模型来区分孟加拉和暹罗猫,并且我们的数据集中仅包含 10%的孟加拉猫图像,我们可以使用 Keras ImageDataGenerator 类通过图像增强生成数据集中孟加拉猫的附加变化。使用几个参数,这个类将通过旋转、裁剪、调整亮度等方式生成同一图像的多个变化。

权衡与替代方案

对于具有固有不平衡数据集的构建模型,还有一些其他替代解决方案,包括重新构建问题和处理异常检测案例。我们还将探讨不平衡数据集的几个重要考虑因素:整体数据集大小,不同问题类型的最优模型架构以及解释少数类预测。

重新构建和级联

重新构建问题是处理不平衡数据集的另一种方法。首先,我们可以考虑将问题从分类转换为回归或者利用重新构建设计模式部分中描述的技术来进行反向操作并训练一系列模型。例如,假设我们有一个回归问题,其中大多数训练数据落在某个范围内,有一些异常值。假设我们关心预测异常值,我们可以通过将大多数数据放在一个桶中,将异常值放在另一个桶中,将这个问题转换为分类问题。

想象我们正在构建一个使用 BigQuery 生育数据集预测婴儿体重的模型。使用 pandas,我们可以创建一个样本的婴儿体重数据直方图以查看体重分布:

%%bigquerydf
SELECT
  weight_pounds
FROM
  `bigquery-public-data.samples.natality`
LIMIT 10000
df.plot(kind='hist')

图 3-20 展示了得到的直方图。

展示 BigQuery 生育数据集中 1 万个例子的婴儿体重分布的直方图。

图 3-20. 展示了 BigQuery 生育数据集中 1 万个例子的婴儿体重分布的直方图。

如果我们统计整个数据集中重量为 3 磅的婴儿数量,约为 96,000 个(占数据的 0.06%)。重量为 12 磅的婴儿仅占数据集的 0.05%。为了在整个范围内获得良好的回归性能,我们可以结合降采样和重新构建与级联设计模式。首先,我们将数据分成三个桶:“欠重”,“平均”和“超重”。我们可以使用以下查询完成:

SELECT
  CASE
    WHEN weight_pounds < 5.5 THEN "underweight"
    WHEN weight_pounds > 9.5 THEN "overweight"
  ELSE
  "average"
END
  AS weight,
  COUNT(*) AS num_examples,
  round(count(*) / sum(count(*)) over(), 4) as percent_of_dataset
FROM
  `bigquery-public-data.samples.natality`
GROUP BY
  1

表 3-5 展示了结果。

表 3-5. 生育数据集中每个重量类的百分比

重量 样本数 数据集百分比
平均 123781044 0.8981
欠重 9649724 0.07
超重 4395995 0.0319

为了演示目的,我们将从每个类别中取 100,000 个示例来训练更新的平衡数据集上的模型:

SELECT
  is_male,
  gestation_weeks,
  mother_age,
  weight_pounds,
  weight
FROM (
  SELECT
    *,
    ROW_NUMBER() OVER (PARTITION BY weight ORDER BY RAND()) AS row_num
  FROM (
    SELECT
      is_male,
      gestation_weeks,
      mother_age,
      weight_pounds,
      CASE
        WHEN weight_pounds < 5.5 THEN "underweight"
        WHEN weight_pounds > 9.5 THEN "overweight"
      ELSE
      "average"
    END
      AS weight,
    FROM
      `bigquery-public-data.samples.natality`
    LIMIT
      4000000) )
WHERE
  row_num < 100000

我们可以将该查询的结果保存到一个表中,并且通过一个更平衡的数据集,现在我们可以训练一个分类模型来标记婴儿为“欠重”,“平均”或“超重”:

CREATE OR REPLACE MODEL
  `project.dataset.baby_weight_classification` OPTIONS(model_type='logistic_reg',
    input_label_cols=['weight']) AS
SELECT
  is_male,
  weight_pounds,
  mother_age,
  gestation_weeks,
  weight
FROM
  `project.dataset.baby_weight`

另一种方法是使用级联模式,为每个类别训练三个单独的回归模型。然后,我们可以使用我们的多设计模式解决方案,通过将我们的初始分类模型传递给一个例子并使用该分类的结果来决定将该例子发送到哪个回归模型进行数值预测。

异常检测

处理不平衡数据集的回归模型有两种方法:

  • 使用模型对预测的错误作为信号。

  • 对传入数据进行聚类,并比较每个新数据点与现有聚类的距离。

为了更好地理解每种解决方案,我们假设正在训练一个模型,用传感器收集的数据预测未来的温度。在这种情况下,我们需要模型输出为数值。

对于第一种方法——使用错误作为信号——在训练模型后,我们将比较模型对当前时点的预测值与实际值。如果预测值与当前实际值之间存在显著差异,我们可以将传入的数据点标记为异常。当然,这需要模型在足够多的历史数据上训练得到良好的准确度,以便依赖其质量进行未来的预测。这种方法的主要限制是需要我们能够及时获得新数据,以便将传入数据与模型预测进行比较。因此,它最适合涉及流数据或时间序列数据的问题。

第二种方法是——聚类数据——我们首先使用聚类算法构建模型,这是一种将数据组织成簇的建模技术。聚类是一种无监督学习方法,意味着它在没有任何地面真实标签知识的情况下查找数据集中的模式。一个常见的聚类算法是 k 均值,我们可以在 BigQuery ML 中实现它。以下展示了如何在 BigQuery natality 数据集上使用三个特征训练 k 均值模型:

CREATE OR REPLACE MODEL
  `project-name.dataset-name.baby_weight` OPTIONS(model_type='kmeans',
    num_clusters=4) AS
SELECT
  weight_pounds,
  mother_age,
  gestation_weeks
FROM
  `bigquery-public-data.samples.natality`
LIMIT 10000

结果模型将把我们的数据聚类成四组。一旦模型创建完成,我们可以对新数据生成预测,并查看该预测与现有聚类的距离。如果距离很大,我们可以将数据点标记为异常。要在我们的模型上生成一个聚类预测,我们可以运行以下查询,传递给它数据集中的一个假设平均示例:

SELECT
  *
FROM
  ML.PREDICT (MODEL `project-name.dataset-name.baby_weight`,
    (
    SELECT
      7.0 as weight_pounds,
      28 as mother_age,
      40 as gestation_weeks 
     )
  )

查询结果在表 3-6 中显示了这个数据点与模型生成的称为质心的聚类之间的距离。

表 3-6. 我们平均体重示例数据点与由我们的 k 均值模型生成的每个聚类之间的距离

质心 ID 最近质心距离.质心 ID 最近质心距离.距离
4 4 0.29998627812137374
1 1.2370167418282159
2 1.376651161584178
3 1.6853517159990536

此示例明显属于质心 4,如距离小(.29)所示。

将此与我们将异常值、体重不足的示例发送到模型后得到的结果进行比较,如表 3-7 所示。

表 3-7. 我们体重不足示例数据点与由我们的 k 均值模型生成的每个聚类之间的距离

CENTROID_ID NEAREST_CENTROIDS_DISTANCE.CENTROID_ID NEAREST_CENTROIDS_DISTANCE.DISTANCE
3 3 3.061985789261998
4 3.3124603501734966
2 4.330205096751425
1 4.658614918595627

在这里,该示例与每个质心之间的距离非常大。然后,我们可以使用这些高距离值来推断此数据点可能是异常值。如果我们事先不知道数据的标签,这种无监督聚类方法尤其有用。一旦我们对足够的示例生成了集群预测,我们就可以建立一个使用预测的集群作为标签的监督学习模型。

可用的少数类示例数量

尽管我们第一个欺诈检测示例中的少数类仅占数据的 0.1%,但数据集足够大,我们仍然有 8,000 个欺诈数据点可供使用。对于甚至少有少数类示例的数据集,降采样可能会使结果数据集过小,不利于模型学习。对于决定使用降采样时的最小示例数量并没有硬性规则,因为这在很大程度上取决于我们的问题和模型架构。一个经验法则是,如果你只有几百个少数类示例,你可能需要考虑除降采样外的其他解决方案来处理数据集不平衡问题。

值得注意的是,删除我们大多数类别的子集的自然效果是丢失这些示例中存储的一些信息。这可能会略微降低我们模型识别多数类别的能力,但通常情况下,降采样的好处仍然超过了这一点。

结合不同的技术

上面描述的降采样和类别权重技术可以结合以获得最佳结果。为此,我们首先通过降采样我们的数据,直到找到适合我们用例的平衡点。然后,基于重新平衡数据集的标签比率,使用加权类别部分描述的方法向我们的模型传递新的权重。当我们面临异常检测问题并且最关心少数类预测时,结合这些方法尤为有用。例如,如果我们正在构建一个欺诈检测模型,我们可能更关心我们的模型标记为“欺诈”的交易,而不是标记为“非欺诈”的交易。此外,正如 SMOTE 所提到的,从少数类生成合成示例的方法通常与从少数类中移除随机示例相结合。

下采样通常也与集成设计模式结合使用。采用这种方法,我们不是完全移除多数类的随机样本,而是使用其不同子集来训练多个模型,然后集成这些模型。举个例子,假设我们有一个数据集,其中包含 100 个少数类示例和 1,000 个多数类示例。与其从多数类中移除 900 个示例以完全平衡数据集,我们将多数示例随机分为 10 组,每组包含 100 个示例。然后我们会训练 10 个分类器,每个分类器使用相同的少数类示例和从多数类随机选择的不同 100 个示例。图 3-11 中展示的装袋技术对这种方法非常有效。

除了结合这些数据中心的方法外,我们还可以根据使用案例调整分类器的阈值以优化精度或召回率。如果我们更关心模型在进行正类预测时的准确性,我们将优化召回率的预测阈值。这在我们希望避免假阳性的任何情况下都适用。或者,如果即使可能会出错,错过一个潜在的正分类也更加昂贵,我们就会优化模型的召回率。

选择模型架构

根据我们的预测任务,解决具有重新平衡设计模式的问题时,需要考虑不同的模型架构。如果我们正在处理表格数据并构建用于异常检测的分类模型,研究表明决策树模型在这类任务上表现良好。基于树的模型在处理小型和不平衡数据集的问题时也效果显著。XGBoost、scikit-learn 和 TensorFlow 都有实现决策树模型的方法。

我们可以使用以下代码在 XGBoost 中实现一个二元分类器:

# Build the model
model = xgb.XGBClassifier(
    objective='binary:logistic'
)

# Train the model
model.fit(
    train_data, 
    train_labels
)

我们可以在每个框架中使用下采样和类权重进一步优化我们的模型,使用重新平衡设计模式。例如,要在上述XGBClassifier中添加加权类,我们将添加一个基于数据集中类别平衡计算的scale_pos_weight参数。

如果我们在时间序列数据中检测异常,长短期记忆(LSTM)模型很适合识别序列中存在的模式。聚类模型也是处理具有不平衡类别的表格数据的一个选项。对于具有图像输入的不平衡数据集,使用深度学习架构配合下采样、加权类、上采样或这些技术的组合。然而,对于文本数据来说,生成合成数据并不是那么直接,最好依赖于下采样和加权类。

无论我们处理的是哪种数据模态,都可以尝试不同的模型架构来确定在我们的不平衡数据上表现最佳的模型。

解释可解释性的重要性

在构建用于标记数据中罕见事件(如异常)的模型时,特别重要的是理解我们的模型如何进行预测。 这既可以验证模型是否捕捉到正确的信号以进行预测,也可以帮助解释模型对最终用户的行为。 有几种工具可供我们解释模型和预测,包括开源框架SHAPWhat-If ToolGoogle Cloud 上的可解释 AI

模型解释可以采用多种形式,其中之一称为归因值。 归因值告诉我们模型中每个特征对模型预测的影响程度。 正面的归因值意味着某个特征推动了我们模型的预测结果上升,而负面的归因值则意味着该特征推动了我们模型的预测结果下降。 归因值的绝对值越高,它对模型预测的影响越大。 在图像和文本模型中,归因值可以显示出对模型预测最具信号性的像素或单词。 对于表格模型,归因值为每个特征提供数值,指示其对模型预测的整体影响。

在从 Kaggle 合成欺诈检测数据集上训练了 TensorFlow 模型并将其部署到 Google Cloud 上的可解释 AI 后,让我们看一些实例级归因的示例。 在图 3-21 中,我们看到了两个例子交易,我们的模型正确识别为欺诈,并且显示它们的特征归因。

在第一个例子中,模型预测有 99%的欺诈可能性时,交易前的原始账户余额是欺诈的最大指标。 在第二个例子中,我们的模型对欺诈的预测有 89%的信心,交易金额被确认为欺诈的最大信号。 然而,原始账户余额使我们的模型在预测欺诈时不太自信,并且解释了预测置信度稍微降低了 10 个百分点的原因

解释对任何类型的机器学习模型都很重要,但我们可以看到它们对遵循再平衡设计模式的模型特别有用。 在处理不平衡数据时,重要的是超越我们模型的准确性和误差度量,验证它是否捕捉到我们数据中的有意义信号。

从可解释 AI 中获取的特征归因示例,用于两笔正确分类的欺诈交易。

图 3-21. 从可解释 AI 中获取的特征归因,用于两笔正确分类的欺诈交易。

摘要

本章探讨了通过模型架构和模型输出的角度来表示预测任务的不同方式。考虑如何应用模型可以指导您在构建模型类型和格式化输出以进行预测时的决策。在这方面,我们从Reframing设计模式开始,探讨将问题从回归任务转换为分类任务(反之亦然)以提高模型质量的方法。您可以通过重新格式化数据中的标签列来实现这一点。接下来,我们探讨了Multilabel设计模式,该模式处理了输入模型可以与多个标签相关联的情况。为处理此类情况,可以在输出层使用 sigmoid 激活函数和二元交叉熵损失。

而 Reframing 和 Multilabel 模式专注于格式化模型输出Ensemble设计模式则涉及模型架构,包括各种组合多个模型以改进单一模型机器学习结果的方法。具体来说,Ensemble 模式包括装袋、提升和堆叠等不同的技术,用于将多个模型聚合成一个 ML 系统。Cascade设计模式也是一种模型级方法,涉及将机器学习问题分解为几个较小的问题。与集成模型不同,Cascade 模式要求将初始模型的输出作为下游模型的输入。由于级联模型可能带来的复杂性,只有在初始分类标签不同且同等重要的情况下才应使用它们。

接下来,我们介绍了Neutral Class设计模式,该模式解决了输出级别的问题表示。该模式通过添加第三个“中性”类来改进二元分类器。在想要捕捉不属于两个明显二元类别之一的任意或较少极化分类的情况下,此模式非常有用。最后,Rebalancing设计模式提供了解决本质上不平衡数据集情况的解决方案。该模式建议使用降采样、加权类别或特定重构技术来解决标签类别不平衡的数据集。

第二章和第三章专注于结构化机器学习问题的初始步骤,具体包括格式化输入数据、模型架构选项和模型输出表示。在下一章中,我们将探讨机器学习工作流程的下一步——用于训练模型的设计模式。

¹ 要计算这些值,请参阅 Ian Goodfellow、Yoshua Bengio 和 Aaron Courville 的《深度学习》(剑桥,马萨诸塞州:MIT 出版社,2016 年),第七章。

² 这只是用于说明目的的示例,请不要将其视为医疗建议!

³ 请参阅https://oreil.ly/kDndF了解期权的基础知识。

⁴ 数据集基于本文提出的 PaySim 研究生成:EdgarLopez-Rojas,Ahmad Elmir 和 Stefan Axelsson,“PaySim:用于欺诈检测的金融移动支付模拟器”,第 28 届欧洲建模与仿真研讨会,EMSS,塞浦路斯拉纳卡(2016 年):249–255。

第四章:模型训练模式

机器学习模型通常是通过迭代训练的,这个迭代过程通常被非正式地称为训练循环。在本章中,我们讨论了典型的训练循环是什么样子,并列举了一些可能需要采取不同方法的情况。

典型的训练循环

机器学习模型可以使用不同类型的优化进行训练。决策树通常根据信息增益度量逐节点构建。在遗传算法中,模型参数被表示为基因,并且优化方法涉及基于进化理论的技术。然而,确定机器学习模型参数的最常见方法是梯度下降

随机梯度下降

在大数据集上,梯度下降被应用于输入数据的小批量中,用于训练从线性模型和增强树到深度神经网络(DNN)和支持向量机(SVM)等各种模型。这被称为随机梯度下降(SGD),SGD 的扩展(如 Adam 和 Adagrad)是现代机器学习框架中使用的事实上的优化器。

因为随机梯度下降(SGD)要求在训练数据集的小批量上进行迭代训练,所以训练机器学习模型就是一个循环过程。SGD 找到一个最小值,但它不是一个封闭形式的解,因此我们必须检测模型是否收敛。由于这个原因,在训练数据集上的误差(称为损失)必须被监控。如果模型复杂度高于数据集的大小和覆盖范围,就可能发生过拟合。不幸的是,在你真正在特定数据集上训练该模型之前,你无法知道模型复杂度是否过高。因此,评估必须在训练循环内完成,并且还必须监控验证数据集(训练数据保留的一部分)上的错误度量。因为训练和验证数据集已经在训练循环中使用过,所以有必要保留另一个被称为测试数据集的训练数据拆分,以报告在新的未见数据上可能预期的实际错误度量。这种评估是在最后完成的。

Keras 训练循环

Keras 中典型的训练循环如下所示:

model = keras.Model(...)
model.compile(optimizer=keras.optimizers.`Adam`(),
              loss=keras.losses.categorical_`crossentropy`(),
              metrics=[`'``accuracy``'`])

history = model.fit(x_train, y_train,
                    batch_size=`64`,
                    epochs=`3`,
                    validation_data=(x_val, y_val))
results = model.evaluate(x_test, y_test, batch_size=128))
model.save(...)

这里,模型使用 Adam 优化器在训练数据集上执行交叉熵的 SGD,并报告在测试数据集上获得的最终准确度。模型的拟合循环在训练数据集上进行三次(每次遍历训练数据集称为epoch),每次模型看到 64 个训练样本组成的批次。在每个 epoch 结束时,会在验证数据集上计算错误度量,并将其添加到历史记录中。在拟合循环结束时,模型会在测试数据集上进行评估、保存,并可能部署为服务,如图 4-1 所示。

典型训练循环由三个 epoch 组成。每个 epoch 按批量大小的示例进行处理。在第三个 epoch 结束时,模型在测试数据集上进行评估,并保存以便可能部署为 Web 服务。

图 4-1. 典型训练循环由三个 epoch 组成。每个 epoch 按批量大小的示例进行处理。在第三个 epoch 结束时,模型在测试数据集上进行评估,并保存以便可能部署为 Web 服务。

而非使用预构建的fit()函数,我们也可以编写自定义训练循环,明确迭代批次,但对于本章讨论的任何设计模式,我们不需要这样做。

训练设计模式

本章涵盖的设计模式都涉及以某种方式修改典型的训练循环。在有用的过拟合中,我们放弃使用验证或测试数据集,因为我们希望故意在训练数据集上过拟合。在检查点中,我们定期存储模型的完整状态,以便可以访问部分训练的模型。当我们使用检查点时,通常也使用虚拟 epoch,在这种情况下,我们决定在fit()函数的内部循环中不使用完整的训练数据集,而是使用固定数量的训练样本。在迁移学习中,我们使用先前训练模型的部分,冻结权重,并将这些不可训练层合并到解决相同问题的新模型中,但在较小的数据集上进行。在分布策略中,训练循环在多个工作节点上以规模进行,通常包括缓存、硬件加速和并行化。最后,在超参数调整中,训练循环本身被插入到优化方法中,以找到最佳的模型超参数集。

设计模式 11:有用的过拟合

有用的过拟合是一种设计模式,其中我们放弃使用泛化机制,因为我们希望故意在训练数据集上过拟合。在过拟合可能有益的情况下,该设计模式建议我们进行机器学习时不使用正则化、dropout 或早期停止的验证数据集。

问题

机器学习模型的目标是在新的未见数据上进行泛化并进行可靠的预测。如果你的模型过拟合训练数据(例如,它在验证误差开始增加的点之后继续减少训练误差),那么它的泛化能力将会受到影响,进而影响到未来的预测。介绍性的机器学习教材建议通过早停和正则化技术来避免过拟合。

然而,考虑一种模拟物理或动力系统行为的情况,例如气候科学、计算生物学或计算金融中发现的系统。在这些系统中,观察的时间依赖性可以用数学函数或偏微分方程组描述。尽管控制这些系统的方程可以被形式化地表达,但它们没有封闭形式的解。相反,已经开发了经典的数值方法来近似解这些系统。不幸的是,对于许多现实世界的应用,这些方法可能太慢而无法实际使用。

考虑如 图 4-2 所示的情况。从物理环境中收集的观察结果被用作物理模型的输入(或初始条件),该模型进行迭代、数值计算以计算系统的精确状态。假设所有观察结果都有有限的可能性(例如,温度将在 60°C 到 80°C 之间,增量为 0.01°C)。然后可以为机器学习系统创建包含完整输入空间的训练数据集,并使用物理模型计算标签。

当整个观察域空间可以列成表格并且具有计算精确解的物理模型可用时,过拟合是可以接受的一种情况。

图 4-2. 当整个观察域空间可以列成表格并且具有计算精确解的物理模型可用时,过拟合是可以接受的一种情况。

机器学习模型需要学习这个精确计算且不重叠的输入输出查找表。将这样的数据集分为训练数据集和评估数据集是适得其反的,因为我们会期望模型学习训练数据集中未见过的输入空间的部分。

解决方案

在这种情况下,没有需要泛化到的“未见”数据,因为所有可能的输入已经被列入表格。当构建一个机器学习模型来学习这样一个物理模型或动态系统时,不存在过拟合的问题。基本的机器学习训练范式略有不同。在这里,您正在尝试学习由基础 PDE 或 PDE 系统控制的某些物理现象。机器学习仅提供了一种数据驱动的方法来近似精确解,因此诸如过拟合的概念必须重新评估。

例如,采用射线追踪方法模拟卫星图像,该图像是由数值天气预报模型的输出产生的。这涉及计算预测的水凝物(雨、雪、冰雹、冰粒等)在每个大气层中吸收多少太阳射线。可能的水凝物类型和数值模型预测的高度是有限的。因此,射线追踪模型必须将光学方程应用于一个大但有限的输入集合。

辐射传输方程控制电磁辐射在大气中传播的复杂动态系统,并且前向辐射传输模型是推断卫星图像未来状态的有效手段。然而,计算这些方程的解的传统数值方法需要巨大的计算工作量,而且在实践中速度太慢。

进入机器学习。可以使用机器学习构建一个模型,该模型近似解决方案到前向辐射传输模型(见图 4-3)。这种机器学习的近似解可以接近最初通过更传统方法获得的模型解。其优势在于,使用学习的机器学习近似解进行推理(只需计算一个封闭公式)所需的时间仅为进行光线追踪所需时间的一小部分(后者需要数值方法)。同时,训练数据集过大(多达数太字节),在生产环境中使用作为查找表格是不可行的。

使用神经网络建模部分微分方程解决 I(r,t,n)的架构。

图 4-3. 使用神经网络建模部分微分方程解决 I(r,t,n)的架构。

训练机器学习模型以近似解决像这样的动态系统与根据多年来收集的新生数据预测婴儿体重之间存在重要区别。换句话说,动态系统是由电磁辐射法则控制的一组方程,没有未观察到的变量,没有噪声,也没有统计变异。对于给定的输入集,只有一个可以精确计算的输出。在训练数据集中,不同示例之间没有重叠。因此,我们可以不必担心泛化问题。我们希望我们的机器学习模型尽可能完美地拟合训练数据,以“过度拟合”。

这与训练机器学习模型的典型方法相反,后者考虑偏差、方差和泛化误差的因素。传统的训练说,模型可能学习训练数据“太好”,使得训练损失函数等于零更像是一个警告信号,而不是庆祝的原因。以这种方式过度拟合训练数据集会导致模型在新的、未见的数据点上做出误导性预测。这里的不同之处在于,我们事先知道不会有未见数据,因此模型是在整个输入频谱上逼近偏微分方程的解。如果你的神经网络能够学习一组参数,使得损失函数为零,那么这个参数集确定了所讨论的偏微分方程的实际解。

为什么它有效

如果所有可能的输入都能被列出,那么如图 4-4 中所示的虚线曲线,即使是过拟合模型,也会产生与“真实”模型相同的预测结果,如果所有可能的输入点都经过训练,过拟合就不是问题。我们必须确保推断是基于输入值的四舍五入值进行的,而这个四舍五入是由输入空间的网格分辨率确定的。

如果所有可能的输入点都经过训练,过拟合就不是问题,因为预测结果与两个曲线相同。

图 4-4. 如果所有可能的输入点都经过训练,过拟合就不是问题,因为预测结果与两个曲线都相同。

是否有可能找到一个模型函数,它可以无限接近真实标签?对于为什么这能行的直觉,部分来源于深度学习的均匀逼近定理,它粗略地表明,任何函数(及其导数)都可以用至少一个隐藏层和任何“压缩”激活函数(如 sigmoid)的神经网络来逼近。这意味着,无论我们得到什么函数,只要它相对合理,就存在一个仅具有一个隐藏层的神经网络,可以尽我们所需地逼近那个函数。¹

深度学习方法用于解决微分方程或复杂动态系统的方法,旨在使用神经网络表示由微分方程或方程组隐含定义的函数。

当满足以下两个条件时,过拟合是有用的:

  • 没有噪音,因此所有实例的标签都是准确的。

  • 您可以随时使用完整的数据集(所有示例都在那里)。在这种情况下,过拟合变成了对数据集进行插值。

折衷与替代方案

当输入集可以详尽列出并可以计算每个输入集的准确标签时,我们将过拟合作为有用的。如果可以列出完整的输入空间,则过拟合不是一个问题,因为没有看不见的数据。然而,有用的过拟合设计模式在超出这个狭窄用例之外仍然有效。

插值与混沌理论

机器学习模型本质上充当输入到输出查找表的近似。如果查找表很小,只需将其用作查找表即可!没有必要通过机器学习模型来近似它。在查找表过于庞大以致无法有效使用时,ML 近似才是有用的。当查找表过于笨重时,最好将其视为机器学习模型的训练数据集,以近似查找表。

请注意,我们假设观察结果将有限数量的可能性。例如,我们假设温度将以 0.01°C 的增量进行测量,并且位于 60°C 到 80°C 之间。如果通过数字仪器进行观察,则情况将如此。如果不是这种情况,则需要 ML 模型在查找表中的条目之间进行插值。

机器学习模型通过未见值与训练示例的距离加权来进行插值。只有在底层系统不混沌时,这种插值才有效。在混沌系统中,即使系统是确定性的,初始条件的微小差异也可能导致截然不同的结果。尽管如此,在实践中,每个具体的混沌现象都有一个特定的分辨率阈值,在这个阈值之上,模型可以在短时间内预测它。因此,只要查找表足够精细,并且了解到可解析性的极限,就可以得到有用的近似。

蒙特卡罗方法

实际上,可能不可能列出所有可能的输入,并且您可能采取蒙特卡罗方法来对输入空间进行采样以创建输入集,特别是在所有可能的输入组合在物理上不可能的情况下。

在这种情况下,过拟合是技术上可能的(参见 图 4-5,其中未填充的圆圈被错误的估计用交叉圆圈表示)。

如果输入空间是采样的,而不是表格化的,则需要注意限制模型的复杂性。

图 4-5. 如果输入空间是采样的,而不是表格化的,则需要注意限制模型的复杂性。

然而,即使在这里,您也可以看到机器学习模型将在已知答案之间进行插值计算。计算始终是确定性的,只有输入点受到随机选择的影响。因此,这些已知答案不包含噪声,并且由于没有未观察到的变量,未采样点处的错误将严格受到模型复杂性的限制。在这里,过拟合的危险来自模型复杂性,而不是适应噪声。当数据集的大小大于自由参数的数量时,过拟合问题就不会那么严重。因此,在蒙特卡罗选择输入空间的情况下,使用低复杂度模型和轻度正则化的组合提供了一种实用的方式来避免不可接受的过拟合。

数据驱动的离散化

尽管对一些偏微分方程可以推导出闭合形式的解,但使用数值方法确定解更为普遍。偏微分方程的数值方法已经是一个深入研究的领域,有许多与该主题相关的 书籍课程,和 期刊。一个常见的方法是使用有限差分方法,类似于龙格-库塔方法,用于求解常微分方程。这通常通过将偏微分方程的微分算子离散化,并在原始域的时空网格上找到离散问题的解来完成。然而,当问题的维数变大时,由于维度诅咒的存在,这种基于网格的方法会因为网格间距必须足够小以捕捉解的最小特征尺寸而遭遇严重失败。因此,要达到图像 10 倍更高分辨率需要 10,000 倍的计算能力,因为网格的间距必须在四个维度上进行缩放,考虑到空间和时间。

然而,可以使用机器学习(而不是蒙特卡罗方法)来选择采样点,以创建用于 PDE 离散化的数据驱动方法。在论文"学习 PDE 的数据驱动离散化"中,Bar-Sinai 等人展示了这种方法的有效性。作者们使用低分辨率的固定点网格,通过标准有限差分方法以及从神经网络获取的方法,来近似解决方案的分段多项式插值。从神经网络获得的解决方案在最小化绝对误差方面远远优于数值模拟,在某些地方实现了 10²数量级的改进。虽然增加分辨率需要使用更多的计算能力来使用有限差分方法,但神经网络能够在仅有边际额外成本的情况下保持高性能。像深度 Galerkin 方法这样的技术可以使用深度学习来提供给定 PDE 解的无网格近似。通过这种方式,解决 PDE 问题被简化为链式优化问题(参见“设计模式 8:级联”)。

无界域

蒙特卡洛方法和数据驱动离散化方法都假设即使不完美地对整个输入空间进行采样也是可能的。这就是为什么将机器学习模型视为已知点之间的插值的原因。

每当我们无法对函数的整个域进行采样时,例如对于具有无界域或将来时间轴上的投影的函数,泛化和过拟合的问题就变得难以忽视。在这些设置中,重要的是考虑过拟合、欠拟合和泛化误差。事实上,已经表明,尽管像深度 Galerkin 方法这样的技术在采样充分的区域表现良好,但以这种方式学习的函数在训练阶段未采样的域外区域上的泛化能力不佳。这对于使用 ML 解决定义在无界域上的 PDE 可能会有问题,因为在训练阶段无法捕获代表性样本。

神经网络知识的精炼

另一种情况是过拟合被证明是合理的,那就是在将知识从大型机器学习模型转移到较小模型中进行精炼或知识转移时。当大型模型的学习能力没有充分利用时,知识精炼就非常有用。如果情况如此,大型模型的计算复杂度可能是不必要的。然而,训练较小的模型也是困难的。虽然较小的模型具有足够的容量来表示知识,但可能没有足够的能力来有效地学习知识。

解决方案是在大量由较大模型标记的生成数据上训练较小模型。较小模型学习较大模型的软输出,而不是实际数据上的标签。这是一个较简单的问题,可以被较小模型学习。就像通过机器学习模型逼近数值函数一样,较小模型的目标是忠实地表示较大机器学习模型的预测。这第二个训练步骤可以利用有用的过拟合。

对一个批次过拟合

在实践中,训练神经网络需要大量的实验,并且从网络的大小和架构到学习率、权重初始化或其他超参数的选择,从业者必须做出许多选择。

对小批量过拟合是一个很好的健全性检查,无论是对模型代码还是数据输入管道。仅仅因为模型编译通过并且代码运行没有错误,并不意味着您计算了您认为的内容或者训练目标正确配置了。一个足够复杂的模型应该能够在足够小的批量数据上过拟合,假设一切设置正确。因此,如果您无法用任何模型在小批量上过拟合,那么值得重新检查您的模型代码、输入管道和损失函数是否存在错误或简单的错误。在训练和排除神经网络问题时,对批量过拟合是一个有用的技术。

提示

过拟合不仅仅限于一个批次。从更全面的角度来看,过拟合遵循通常给出的关于深度学习和正则化的一般建议。最佳拟合模型是一个经过适当正则化的大模型。简而言之,如果您的深度神经网络无法过拟合您的训练数据集,那么您应该使用一个更大的模型。然后,一旦您有一个能够过拟合训练集的大模型,您可以应用正则化来提高验证精度,即使训练精度可能会降低。

您可以通过使用您为输入管道编写的tf.data.Dataset来测试您的 Keras 模型代码。例如,如果您的训练数据输入管道称为trainds,我们将使用batch()来拉取一个单一批次的数据。您可以在伴随本书的存储库中找到这个例子的完整代码

BATCH_SIZE = 256
single_batch = trainds.batch(BATCH_SIZE).take(1`)`

然后,在训练模型时,而不是在fit()方法中调用完整的trainds数据集,使用我们创建的单个批次:

model`.`fit`(`single_batch`.`repeat`(``)``,`
          validation_data`=`evalds`,`
          `…``)`

注意,我们应用repeat()以确保在训练时不会耗尽数据。这确保了我们在训练时一遍又一遍地使用同一批数据。其余内容(验证数据集、模型代码、工程特征等)保持不变。

提示

我们建议不要选择训练数据集的任意样本,而是建议你在一个小数据集上过度拟合。这些样本都经过仔细验证,确保其标签是正确的。设计你的神经网络架构,使其能够精确地学习这批数据,并且达到零损失。然后,将同样的网络用于完整的训练数据集。

设计模式 12:检查点

在 在检查点中,我们定期存储模型的完整状态,以便能够使用部分训练过的模型。这些部分训练过的模型可以作为最终模型(在提前停止的情况下),或者作为继续训练的起点(在机器故障和微调的情况下)。

问题

模型越复杂(例如,神经网络具有的层数和节点数越多),训练它有效所需的数据集就越大。这是因为更复杂的模型往往具有更多可调参数。随着模型大小的增加,每个批次的拟合时间也会增加。随着数据大小的增加(假设批次大小固定),批次数量也会增加。因此,在计算复杂性方面,这种双重打击意味着训练将需要很长时间。

在撰写本文时,在一流的张量处理单元(TPU)Pod 上训练一款英译德模型,使用相对较小的数据集,大约需要两个小时。在用于训练智能设备的真实数据集上,训练可能需要几天时间。

当我们进行如此长时间的训练时,机器故障的可能性非常高。如果出现问题,我们希望能够从一个中间点恢复,而不是从头开始。

解决方案

在每个时代结束时,我们可以保存模型状态。然后,如果由于任何原因训练循环中断,我们可以回到保存的模型状态并重新启动。然而,在这样做时,我们必须确保保存的是中间模型状态,而不仅仅是模型本身。这意味着什么呢?

训练完成后,我们保存或者导出模型,以便可以部署它进行推断。导出的模型不包含整个模型状态,只包含创建预测函数所需的信息。例如,对于决策树来说,这将是每个中间节点的最终规则以及每个叶子节点的预测值。对于线性模型来说,这将是权重和偏差的最终值。对于全连接神经网络,我们还需要添加激活函数和隐藏连接的权重。

当从检查点恢复而导出的模型没有的模型状态数据是什么?一个导出的模型不包含模型当前处理的轮次和批次号,这显然是为了恢复训练而重要的。但模型训练循环可能包含更多信息。为了有效地执行梯度下降,优化器可能会按计划改变学习率。这种学习率状态在导出的模型中不存在。另外,模型可能会有随机行为,比如 dropout。这也不在导出的模型状态中。像循环神经网络这样的模型会包含先前输入值的历史记录。总的来说,完整的模型状态可能是导出模型大小的多倍。

保存完整的模型状态以便从某一点恢复模型训练称为检查点,保存的模型文件称为检查点。我们应该多久做一次检查点?由于梯度下降的缘故,模型状态在每个批次后都会发生变化。因此,从技术上讲,如果我们不想丢失任何工作,我们应该在每个批次后做检查点。然而,检查点非常大,这种 I/O 会增加相当大的开销。相反,模型框架通常提供在每个时期结束时做检查点的选项。这是在从不做检查点和每批次后都做检查点之间的一个合理折衷。

要在 Keras 中进行模型检查点,需要在fit()方法中提供一个回调函数:

checkpoint_path = '{}/checkpoints/taxi'.format(OUTDIR)
cp_callback = tf.keras.callbacks.ModelCheckpoint(checkpoint_path, 
                                                 save_weights_only=False,
                                                 verbose=1)
history = model.fit(x_train, y_train,
                    batch_size=64,
                    epochs=3,
                    validation_data=(x_val, y_val), 
                    verbose=2, 
                    callbacks=[cp_callback])

添加检查点后,训练循环变得如图 4-6 所示。

检查点在每个时期结束时保存完整的模型状态。

图 4-6. 检查点在每个时期结束时保存完整的模型状态。

为什么它有效

如果在输出路径中找到检查点文件,TensorFlow 和 Keras 会自动从检查点恢复训练。因此,要从头开始训练,你必须从新的输出目录开始(或者从输出目录中删除之前的检查点)。这是因为企业级机器学习框架会尊重检查点文件的存在。

尽管检查点主要设计用于支持韧性,但部分训练模型的可用性打开了许多其他用例。这是因为部分训练的模型通常比后续迭代中创建的模型更具普适性。为什么会发生这种情况的直观感受可以从TensorFlow playground获得,如图 4-7 所示。

螺旋分类问题的起点。你可以通过在浏览器中打开这个链接来到达这个设置。

图 4-7. 螺旋分类问题的起点。你可以通过在浏览器中打开这个链接来到达这个设置。

在这个实验中,我们试图构建一个分类器来区分蓝点和橙点(如果你在纸质书中阅读此内容,请在网络浏览器中导航到链接以跟随)。两个输入特征是 x[1] 和 x[2],它们是点的坐标。基于这些特征,模型需要输出点是蓝色的概率。模型从随机权重开始,点的背景显示了每个坐标点的模型预测。正如你所见,由于权重是随机的,概率倾向于在所有像素的中心值附近波动。

通过点击图像左上角的箭头开始训练,我们可以看到模型随着连续的 epochs 缓慢开始学习,如 图 4-8 所示。

模型在训练过程中学到的内容。顶部的图表显示训练损失和验证错误,而图像则展示了模型在每个坐标网格中预测点的颜色。

图 4-8. 模型在训练过程中学到的内容。顶部的图表显示训练损失和验证错误,而图像则展示了模型在每个坐标网格中预测点的颜色。

在图 4-8 中,我们看到学习的第一个迹象是在 (b) 处,我们看到模型通过图 4-8 (c) 学到了数据的高级视角。从那时起,模型调整边界以将更多的蓝点移到中心区域,同时保持橙点在外。这有助于提高效果,但只能到一定程度。当我们到达图 4-8 (e) 时,权重的调整开始反映在训练数据中的随机扰动上,这对验证数据集是有害的。

因此,我们可以将训练分为三个阶段。在第一阶段(a 到 c 之间),模型学习数据的高级组织。在第二阶段(c 到 e 之间),模型学习细节。当我们到达第三阶段,即阶段 f 时,模型出现了过拟合。从第一阶段或第二阶段结束时的部分训练模型中,我们可以看到它学到了高级组织,但并未深入到细节中。

折中和替代方案

除了提供韧性之外,保存中间检查点还使我们能够实施提前停止和精细调整功能。

提前停止

一般来说,训练时间越长,训练数据集上的损失就越低。然而,在某个时候,验证数据集上的错误可能停止减少。如果开始对训练数据集过拟合,验证误差甚至可能开始增加,如图 4-9 所示。

一般来说,训练时间越长,训练损失会持续下降,但一旦开始过拟合,验证数据集上的验证错误则会增加。

图 4-9。一般来说,训练时间越长,训练损失会持续下降,但一旦开始过拟合,验证数据集上的验证错误则会增加。

在这种情况下,查看每个时代结束时的验证错误并在验证错误超过上一个时代的情况下停止训练过程可能会有所帮助。在图 4-9 中,这将在第四个时代结束时,由粗虚线表示。这被称为早停止

提示

如果我们在每个批次结束时做检查点,我们可能能够捕捉到真正的最小值,这可能在时代边界之前或之后一点点。有关虚拟时代的讨论,请参阅本节更频繁的检查点方法。

如果我们更频繁地进行检查点,早停止不会对验证误差中的小扰动过于敏感将是有帮助的。相反,我们可以在验证误差连续N个检查点没有改进后再应用早停止。

检查点选择

尽管可以通过在验证错误开始增加时停止训练来实现早停止,我们建议延长训练时间,并在后处理步骤中选择最佳运行。我们建议训练直至第 3 阶段(请参阅前述的“为什么有效”部分,以了解训练循环的三个阶段的解释),因为验证误差在某些情况下可能会短暂增加,然后开始再次下降。这通常是因为训练最初关注更常见的情况(阶段 1),然后开始追踪更罕见的情况(阶段 2)。由于训练和验证数据集之间可能对稀有情况进行不完全采样,因此在第 2 阶段的训练运行期间偶尔会出现验证误差的增加是可以预期的。此外,对于大型模型普遍存在深度双下降的情况,因此最好稍微延长训练时间以防万一。

在我们的示例中,我们将不会在训练运行结束时导出模型,而是将加载第四个检查点并从那里导出我们的最终模型。这被称为检查点选择,在 TensorFlow 中可以通过BestExporter来实现。

正则化

在使用早停止或检查点选择之前,尝试向模型添加 L2 正则化可能会有所帮助,以确保验证错误不会增加,并且模型永远不会进入第 3 阶段。相反,训练损失和验证错误应该会趋于平稳,正如图 4-10 所示。我们将这样的训练循环(其中训练和验证指标均达到平稳状态)称为良好行为的训练循环。

在理想情况下,验证错误不会增加。相反,训练损失和验证错误都会趋于平稳。

图 4-10。在理想情况下,验证错误不会增加。相反,训练损失和验证错误都会趋于平稳。

如果不执行早停止,并且仅使用训练损失来决定收敛性,那么我们可以避免设置单独的测试数据集。即使我们不执行早停止,显示模型训练进度也可能很有帮助,特别是当模型训练时间较长时。虽然通常在训练循环期间,模型的性能和进度通常在验证数据集上进行监控,但这只是为了可视化目的。由于我们不需要根据显示的指标采取任何行动,我们可以在测试数据集上进行可视化。

使用正则化而不是早停止的原因在于,正则化允许您使用整个数据集来调整模型的权重,而早停止则要求您浪费 10%至 20%的数据集仅用于决定何时停止训练。其他限制过拟合的方法(如丢弃法和使用复杂度较低的模型)也是早停止的良好替代方法。此外,最近的研究表明,双下降现象在各种机器学习问题中都会发生,因此与其早停止,不如继续训练以避免出现次优解。 **#### 两种分割方法

正则化部分的建议是否与早停止或检查点选择部分的建议相矛盾?实际上并不是。

我们建议您将数据分为两部分:训练数据集和评估数据集。在实验中,评估数据集充当测试数据集的角色(没有验证数据集),而在生产环境中,评估数据集充当验证数据集的角色(没有测试数据集)。

你的训练数据集越大,你可以使用的模型就越复杂,得到的模型准确性也越高。使用正则化而不是早停或检查点选择,允许你使用更大的训练数据集。在实验阶段(当你正在探索不同的模型架构、训练技术和超参数时),我们建议你关闭早停并使用更大的模型进行训练(还可参见“设计模式 11:有益过拟合”)。这是为了确保模型具有足够的容量来学习预测模式。在此过程中,监视训练集上的错误收敛。在实验结束时,你可以使用评估数据集来诊断模型在训练过程中未曾遇到的数据上的表现。

当训练模型用于生产部署时,你需要准备好进行持续评估和模型再训练。开启早停或检查点选择,并监控评估数据集上的错误指标。根据你是否需要控制成本(在这种情况下,你会选择早停)或者想要优先考虑模型准确性(在这种情况下,你会选择检查点选择),选择早停或检查点选择。** **### 微调

在一个良好的训练循环中,梯度下降的行为是这样的,它通过优化大多数数据来快速到达最优错误的邻域,然后通过优化边缘情况来缓慢收敛到最低错误。

现在,想象一下,你需要定期在新鲜数据上重新训练模型。你通常希望强调新鲜数据,而不是上个月的边缘情况。你更好地从标记为蓝线的检查点恢复你的训练,而不是从上个检查点开始,这对应于我们之前讨论的模型训练阶段中第 2 阶段的开始“为什么有效”。这有助于确保你有一种通用方法,然后你能对刚刚的新鲜数据进行几个时期的微调。

当你从标记为粗虚线的检查点恢复时,你将处于第四个时期,因此学习速率会非常低。因此,新鲜数据不会显著改变模型。然而,模型在新鲜数据上的表现会达到最佳(在更大模型的背景下)。这是被称为微调的过程。微调也在“设计模式 13:迁移学习”中讨论过。

从训练损失开始平台之前的检查点恢复。仅在后续迭代中使用新鲜数据进行训练。

图 4-11。从在训练损失开始平台化之前的检查点恢复。仅对后续迭代使用新鲜数据进行训练。
警告

只有在不改变模型架构的情况下,微调才有效。

不必总是从较早的检查点开始。在某些情况下,最终检查点(用于提供模型)可以用作另一个模型训练迭代的热启动。然而,从较早的检查点开始通常能提供更好的泛化能力。

重新定义一个 epoch

机器学习教程经常有这样的代码:

model.fit(X_train, y_train, 
          batch_size=100, 
          epochs=15)

这段代码假设你有一个可以放入内存的数据集,并且你的模型可以在 15 个 epochs 中迭代而不会出现机器故障的风险。然而,这两个假设都是不合理的——机器学习数据集的范围达到了几个 TB,而且在训练时间长达数小时时,机器故障的几率很高。

为了使上述代码更加弹性,提供一个TensorFlow 数据集(而不仅仅是一个 NumPy 数组),因为 TensorFlow 数据集是一个内存外的数据集。它提供迭代能力和延迟加载。现在代码如下:

cp_callback = tf.keras.callbacks.ModelCheckpoint(...)
history = model.fit(trainds, 
                    validation_data=evalds,
                    epochs=15, 
                    batch_size=128,
                    callbacks=[cp_callback])

但是,在大型数据集上使用 epochs 仍然是一个不好的主意。epochs 可能很容易理解,但是在实际的机器学习模型中使用 epochs 会导致不良影响。为了了解为什么,请想象一下你有一个包含一百万个示例的训练数据集。简单地设置 epochs 为 15,然后简单地遍历这个数据集 15 次可能很诱人。但是存在一些问题:

  • epochs 的数量是一个整数,但是在处理数据集 14.3 次和 15 次之间的训练时间差异可能会达到数小时。如果模型在观察了 1430 万个示例后已经收敛,您可能希望退出,而不是浪费处理 700000 个示例所需的计算资源。

  • 每个 epoch 只需进行一次检查点,而等待一百万个示例之间的检查点可能会太长。为了弹性,您可能希望更频繁地进行检查点。

  • 数据集会随着时间增长。如果您获取了 10 万个更多的示例并训练模型,并且得到了更高的错误,是因为您需要早停,还是新数据在某种方式上损坏了?您无法确定,因为先前的训练是在 1500 万个示例上进行的,而新的训练是在 1650 万个示例上进行的。

  • 在分布式参数服务器训练(参见“设计模式 14:分布策略”)中,使用数据并行和适当的洗牌,epoch 的概念不再清晰。由于可能存在滞后的工作节点,您只能指示系统对一些小批次进行训练。

每个 epoch 的步骤

我们可能决定不再训练 15 个 epochs,而是决定训练 143000 步,其中 batch_size 为 100:

NUM_STEPS = 143000
BATCH_SIZE = 100
NUM_CHECKPOINTS = 15
cp_callback = tf.keras.callbacks.ModelCheckpoint(...)
history = model.fit(trainds, 
                    validation_data=evalds,
                    epochs=NUM_CHECKPOINTS,
                    steps_per_epoch=NUM_STEPS // NUM_CHECKPOINTS, 
                    batch_size=BATCH_SIZE,
                    callbacks=[cp_callback])

每个步骤涉及基于单个小批量数据的权重更新,这使我们可以在 14.3 个时代停止。这为我们提供了更多的粒度,但我们必须将“时代”定义为总步数的 1/15:

steps_per_epoch=NUM_STEPS // NUM_CHECKPOINTS, 

这样可以确保我们获得正确数量的检查点。只要确保无限重复trainds即可:

trainds = trainds.repeat()

repeat()是必需的,因为我们不再设置num_epochs,所以默认的时代数为一。如果没有repeat(),模型将在读取数据集一次后退出一旦训练模式耗尽。

使用更多数据重新训练

当我们获得额外的 100,000 个示例时会发生什么?很简单!我们将其添加到我们的数据仓库中,但不更新代码。我们的代码仍然希望处理 143,000 步,它将处理这么多数据,只是它看到的示例中有 10% 是新的。如果模型收敛了,太棒了。如果没有,我们知道这些新数据点是问题所在,因为我们的训练时间并没有比以前长。通过保持步数恒定,我们能够将新数据的影响与更多数据的训练效果分离开来。

一旦我们训练了 143,000 步,我们重新开始训练并继续运行一段时间(比如说,10,000 步),只要模型继续收敛,我们就继续延长训练时间。然后,我们更新上述代码中的 143,000 这个数字(实际上,它将是代码的一个参数),以反映新的步数。

这一切都很好,直到您想进行超参数调整。当进行超参数调整时,您将希望更改批处理大小。不幸的是,如果您将批处理大小更改为 50,您会发现自己的训练时间减少了一半,因为我们正在进行 143,000 步的训练,每步只有以前的一半时间。显然,这是不好的。

虚拟时代

答案是保持向模型显示的总训练示例数量(而不是步数;参见图 4-12)恒定:

NUM_TRAINING_EXAMPLES = 1000 * 1000
STOP_POINT = 14.3
TOTAL_TRAINING_EXAMPLES = int(STOP_POINT * NUM_TRAINING_EXAMPLES)
BATCH_SIZE = 100
NUM_CHECKPOINTS = 15
steps_per_epoch = (TOTAL_TRAINING_EXAMPLES // 
                   (BATCH_SIZE*NUM_CHECKPOINTS))
cp_callback = tf.keras.callbacks.ModelCheckpoint(...)
history = model.fit(trainds, 
                    validation_data=evalds,
                    epochs=NUM_CHECKPOINTS,
                    steps_per_epoch=steps_per_epoch, 
                    batch_size=BATCH_SIZE,
                    callbacks=[cp_callback])

在期望的检查点之间的步数定义虚拟时代。

图 4-12. 在期望的检查点之间的步数定义虚拟时代。

当您获得更多数据时,首先使用旧设置进行训练,然后增加示例的数量以反映新数据,并最终更改STOP_POINT以反映达到收敛所需遍历数据的次数。

这现在即使在超参数调整(本章后面讨论)之后也是安全的,并保留保持步数恒定的所有优势。 # 设计模式 13:迁移学习

在迁移学习中,我们采用先前训练模型的一部分,冻结权重,并将这些不可训练的层合并到解决类似问题但在较小数据集上的新模型中。

问题

训练非结构化数据上的定制机器学习模型需要非常庞大的数据集,这并不总是随时可得。考虑一个模型识别手臂 X 光是否有骨折的情况。为了达到高精度,你将需要成千上万张图片,甚至更多。在你的模型学会辨别骨折看起来是什么之前,它需要先学会理解数据集中图片的像素、边缘和形状。对于基于文本数据训练的模型也是如此。假设我们正在构建一个模型,接受患者症状描述并预测可能的相关病症。除了学习哪些词汇可以区分感冒和肺炎之外,模型还需要学习基本的语言语义以及词汇序列如何创建含义。例如,模型不仅需要学会检测“发烧”这个词的存在,还需要理解“无发烧”与“高烧”这两个序列的含义完全不同。

要了解训练高精度模型所需的数据量有多大,我们可以看看ImageNet,这是一个拥有超过 1400 万标记图像的数据库。ImageNet 经常用作评估机器学习框架在各种硬件上性能的基准。例如,MLPerf 基准套件使用 ImageNet 来比较各种 ML 框架在不同硬件上达到 75.9%分类准确率所需的时间。在 v0.7 MLPerf 训练结果中,运行在 Google TPU v3 上的 TensorFlow 模型大约花了 30 秒达到这一目标精度²。随着更多的训练时间,模型在 ImageNet 上的准确率可以进一步提高。然而,这主要是由于 ImageNet 的数据规模。大多数有专门预测问题的组织并没有如此大量的数据可用。

因为像上面描述的图像和文本示例这样的用例涉及特定的专业数据领域,使用通用模型无法成功识别骨折或诊断疾病。一个在 ImageNet 上训练的模型也许能够标记 X 光图像为“X 光”或“医学成像”,但不太可能能够标记为“股骨骨折”。因为这些模型通常是在各种高级标签类别上进行训练的,我们不期望它们能理解特定于我们数据集的图像中存在的条件。为了解决这个问题,我们需要一个解决方案,允许我们仅使用我们可用的数据和我们关心的标签来构建定制模型。

解决方案

通过转移学习设计模式,我们可以采用一个已经在相同类型数据上训练过的模型,并将其应用于使用我们自己定制数据的专业任务。所谓的“相同类型数据”,指的是相同的数据形态——图像、文本等等。除了像图像这样的广泛类别外,最好还使用已经在相同类型图像上预训练过的模型。例如,如果要用于照片分类,则最好使用已经在照片上预训练过的模型;如果要用于卫星图像分类,则最好使用已经在遥感图像上预训练过的模型。所谓的“相似任务”,是指解决的问题。例如,要进行图像分类的转移学习,最好从已经用于图像分类的模型开始,而不是目标检测的模型。

继续以示例为例,假设我们正在构建一个二元分类器,以确定 X 光图像是否包含骨折。我们每类只有 200 张图像:骨折未骨折。这些图像数量不足以从头开始训练一个高质量的模型,但足以进行转移学习。为了用转移学习解决这个问题,我们需要找到一个已经在大型数据集上训练过的图像分类模型。然后,我们会移除该模型的最后一层,冻结该模型的权重,并继续使用我们的 400 张 X 光图像进行训练。理想情况下,我们会找到一个已经在类似 X 光图像的数据集上训练过的模型,比如实验室或其他受控条件下拍摄的图像。然而,即使数据集不同,只要预测任务相同,我们仍然可以利用转移学习。在本例中,我们进行的是图像分类任务。

除了图像分类外,只要存在一个已经预训练的模型与您想要在您的数据集上执行的任务匹配,您还可以将转移学习用于许多预测任务。例如,转移学习也经常应用于图像目标检测、图像风格转移、图像生成、文本分类、机器翻译等领域。

注意

转移学习之所以有效,是因为它让我们站在巨人的肩膀上,利用已经在非常大的标记数据集上训练过的模型。我们能够利用转移学习,要归功于多年来其他人为我们创建这些数据集所做的研究和工作,这些工作推动了转移学习技术的发展。一个这样的数据集的例子是 2006 年由李飞飞(Fei-Fei Li)启动并于 2009 年发布的 ImageNet 项目。ImageNet³对转移学习的发展至关重要,并为其他大型数据集如COCOOpen Images铺平了道路。

迁移学习的理念是,您可以利用在与预测任务相同领域中训练的模型的权重和层。在大多数深度学习模型中,最后一层包含特定于预测任务的分类标签或输出。通过迁移学习,我们移除此层,冻结模型的训练权重,并在继续训练之前用我们专门预测任务的输出替换最后一层。我们可以在图 4-13 中看到这是如何工作的。

通常,模型的倒数第二层(输出层之前的层)被选为bottleneck layer。接下来,我们将解释瓶颈层以及在 TensorFlow 中实施迁移学习的不同方法。

Transfer learning involves training a model on a large dataset. The “top” of the model (typically, just the output layer) is removed and the remaining layers have their weights frozen. The last layer of the remaining model is called the bottleneck layer.

图 4-13. 迁移学习涉及在大型数据集上训练模型。模型的“顶部”(通常只是输出层)被移除,剩余层的权重被冻结。剩余模型的最后一层被称为瓶颈层。

瓶颈层

在整个模型中,瓶颈层代表着输入(通常是图像或文本文档)在最低维度空间中的表示。更具体地说,当我们将数据输入模型时,前几层几乎以其原始形式查看这些数据。为了看到这是如何工作的,让我们继续使用一个医学影像的例子,但这次我们将使用一个建模,使用结直肠组织学数据集将组织学图像分类为八个类别之一。

要探索用于迁移学习的模型,让我们加载在 ImageNet 数据集上预训练的 VGG 模型架构:

vgg_model_withtop = tf.keras.applications.VGG19(
    include_top=True, 
    weights='imagenet', 
)

注意,我们设置了include_top=True,这意味着我们加载了完整的 VGG 模型,包括输出层。对于 ImageNet,该模型将图像分类为 1,000 个不同的类别,因此输出层是一个 1,000 元素的数组。让我们查看model.summary()的输出,以了解哪一层将被用作瓶颈层。为简洁起见,这里省略了一些中间层的信息:

Model: "vgg19"
_________________________________________________________________
Layer (type)                 Output Shape              Param # 
=================================================================
input_3 (InputLayer)         [(None, 224, 224, 3)]     0         
_________________________________________________________________
block1_conv1 (Conv2D)        (None, 224, 224, 64)      1792     
...more layers here...
_________________________________________________________________
block5_conv3 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_conv4 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 7, 7, 512)         0         
_________________________________________________________________
flatten (Flatten)            (None, 25088)             0         
_________________________________________________________________
fc1 (Dense)                  (None, 4096)              102764544 
_________________________________________________________________
fc2 (Dense)                  (None, 4096)              16781312  
_________________________________________________________________
predictions (Dense)          (None, 1000)              4097000   
=================================================================
Total params: 143,667,240
Trainable params: 143,667,240
Non-trainable params: 0
_________________________________________________________________

如您所见,VGG 模型接受图像作为一个 224×224×3 像素的数组。这个 128 元素的数组然后通过连续的层(每一层可能会改变数组的维度)传递,直到在flatten层中被展开成一个 25,088×1 维度的数组。最后,它被送入输出层,返回一个 1,000 元素的数组(每个类在 ImageNet 中)。在这个例子中,当我们调整这个模型以在我们的医学组织学图像上进行训练时,我们将选择block5_pool层作为瓶颈层。瓶颈层产生一个 7×7×512 维度的数组,这是输入图像的低维表示。它保留了足够的信息以便对其进行分类。当我们将这个模型应用于我们的医学图像分类任务时,我们希望信息的提取足以成功地在我们的数据集上进行分类。

数据集的组织结构包含了(150,150,3)维度的图像数组。这个 150×150×3 的表示是最高维度。为了使用 VGG 模型处理我们的图像数据,我们可以按以下步骤加载它:

vgg_model = tf.keras.applications.VGG19(
    include_top=False, 
    weights='imagenet', 
    input_shape=((150,150,3))
)

vgg_model.trainable = False

通过设置include_top=False,我们指定要加载的 VGG 的最后一层是瓶颈层。我们传递的input_shape匹配我们的组织学图像的输入形状。此更新后的 VGG 模型最后几层的摘要如下所示:

block5_conv3 (Conv2D)        (None, 9, 9, 512)         2359808 
_________________________________________________________________
block5_conv4 (Conv2D)        (None, 9, 9, 512)         2359808 
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 4, 4, 512)         0 
=================================================================
Total params: 20,024,384
Trainable params: 0
Non-trainable params: 20,024,384
_________________________________________________________________

最后一层现在是我们的瓶颈层。您可能会注意到,block5_pool的大小为(4,4,512),而之前为(7,7,512)。这是因为我们在实例化 VGG 时使用了一个input_shape参数来考虑我们数据集中图像的大小。值得注意的是,设置include_top=False是硬编码为使用block5_pool作为瓶颈层,但如果您想要自定义此过程,可以加载完整模型并删除不需要使用的任何额外层。

在这个模型准备好进行训练之前,我们需要在顶部添加几层,这些层针对我们的数据和分类任务进行了特定设置。还值得注意的是,因为我们设置了trainable=False,当前模型中没有可训练的参数。

提示

作为一个一般的经验法则,瓶颈层通常是最后一个、最低维度的、在展平操作之前的层。

因为它们都表示降维后的特征,所以瓶颈层在概念上与嵌入类似。例如,在具有编码器-解码器架构的自编码器模型中,瓶颈层是一个嵌入。在这种情况下,瓶颈作为模型的中间层,将原始输入数据映射到更低维度的表示,解码器(网络的第二部分)使用该表示将输入映射回其原始的高维表示。要查看自编码器中瓶颈层的图示,请参见图 2-13 在第 2 章。

嵌入层本质上是一个权重查找表,将特定特征映射到向量空间中的某个维度。主要区别在于嵌入层中的权重可以训练,而所有导致并包括瓶颈层的权重都被冻结。换句话说,直到并包括瓶颈层的整个网络是不可训练的,而瓶颈层后面的层中的权重是模型中唯一可训练的层。

注意

还值得注意的是,预训练的嵌入可以在迁移学习设计模式中使用。当您构建包含嵌入层的模型时,您可以利用现有(预训练的)嵌入查找表,或者从头开始训练您自己的嵌入层。

总之,迁移学习是您可以用来解决较小数据集上类似问题的解决方案。迁移学习总是使用具有不可训练冻结权重的瓶颈层。嵌入是一种数据表示类型。最终,这归结为目的。如果目的是训练类似的模型,您将使用迁移学习。因此,如果目的是更简洁地表示输入图像,您将使用嵌入。代码可能完全相同。

实现迁移学习

您可以使用以下两种方法之一在 Keras 中实现迁移学习:

  • 自己加载预训练模型,移除瓶颈层后面的层,并添加一个新的最终层,使用您自己的数据和标签

  • 使用一个预训练的TensorFlow Hub模块作为您的迁移学习任务的基础

让我们首先看看如何加载和使用自己的预训练模型。为此,我们将建立在我们之前介绍的 VGG 模型示例上。请注意,VGG 是一个模型架构,而 ImageNet 是它训练的数据。这两者共同组成了我们将用于迁移学习的预训练模型。在这里,我们使用迁移学习来对结肠镜组织学图像进行分类。原始的 ImageNet 数据集包含 1000 个标签,而我们的最终模型将仅返回 8 个我们指定的可能类别,与 ImageNet 中存在的成千上万个标签形成对比。

注意

加载一个预训练模型并在其上获取原始标签的分类并不算是迁移学习。迁移学习是更进一步,用自己的预测任务替换模型的最终层。

我们加载的 VGG 模型将是我们的基础模型。我们需要添加几个层来展平我们的瓶颈层的输出,并将这个展平的输出馈送到一个 8 元素 softmax 数组中:

global_avg_layer = tf.keras.layers.GlobalAveragePooling2D()
feature_batch_avg = global_avg_layer(feature_batch)

prediction_layer = tf.keras.layers.Dense(8, activation='softmax')
prediction_batch = prediction_layer(feature_batch_avg)

最后,我们可以使用 Sequential API 来创建我们的新迁移学习模型,作为一系列层堆叠:

histology_model = keras.Sequential([
  vgg_model,
  global_avg_layer,
  prediction_layer
])

让我们注意一下在我们的迁移学习模型上使用 model.summary() 的输出:

_________________________________________________________________
Layer (type)                 Output Shape              Param # 
=================================================================
vgg19 (Model)                (None, 4, 4, 512)         20024384 
_________________________________________________________________
global_average_pooling2d (Gl (None, 512)               0 
_________________________________________________________________
dense (Dense)                (None, 8)                 4104 
=================================================================
Total params: 20,028,488
Trainable params: 4,104
Non-trainable params: 20,024,384
_________________________________________________________________

这里的重要部分是,在我们的瓶颈层之后,唯一可训练的参数是那些 后续 的参数。在这个例子中,瓶颈层是来自 VGG 模型的特征向量。编译完这个模型之后,我们可以使用我们的组织学图像数据集来训练它。

预训练嵌入

虽然我们可以加载一个预训练模型来自行操作,但我们也可以利用 TF Hub 中提供的许多预训练模型来实现迁移学习,TF Hub 是一个包含各种数据领域和用例的预训练模型(称为模块)库,包括分类、目标检测、机器翻译等。在 TensorFlow 中,您可以将这些模块加载为一层,然后在其上添加您自己的分类层。

要了解 TF Hub 的工作原理,让我们构建一个模型,将电影评论分类为积极消极。首先,我们将加载一个在大型新闻文章语料库上训练的预训练嵌入模型。我们可以将此模型实例化为 hub.KerasLayer

hub_layer = hub.KerasLayer(
    "https://tfhub.dev/google/tf2-preview/gnews-swivel-20dim/1",
    input_shape=[], dtype=tf.string, trainable=True)

我们可以在其上堆叠额外的层来构建我们的分类器:

model = keras.Sequential([
  hub_layer,
  keras.layers.Dense(32, activation='relu'),
  keras.layers.Dense(1, activation='sigmoid')                          
])

现在我们可以训练这个模型,将我们自己的文本数据集作为输入传递给它。结果的预测将是一个包含一个元素的数组,指示我们的模型认为给定的文本是积极的还是消极的。

为什么它有效

要理解为什么迁移学习有效,让我们先看一个类比。当孩子们学习他们的第一门语言时,他们会接触到许多例子,并在他们误识别某些事物时得到纠正。例如,当他们第一次学会辨认猫时,他们会看到他们的父母指向猫并说“猫”的词,这种重复会加强他们大脑中的路径。类似地,当他们说“猫”指代不是猫的动物时,他们也会得到纠正。然后,当孩子学会辨认狗时,他们不需要从头开始。他们可以使用类似的识别过程来处理稍微不同的任务。通过这种方式,孩子们建立了学习的基础。除了学习新事物,他们还学会了如何学习新事物。将这些学习方法应用于不同的领域,大致就是迁移学习的工作原理。

这在神经网络中如何发挥作用?在典型的卷积神经网络(CNN)中,学习是分层的。第一层学习识别图像中存在的边缘和形状。在猫的例子中,这可能意味着模型可以识别图像中猫身体边缘与背景相接触的区域。模型中的下一层开始理解边缘的组合——也许意识到图像左上角有两条边缘相交。CNN 的最终层可以将这些边缘组合在一起,形成对图像中不同特征的理解。在猫的例子中,模型可能能够识别图像顶部两个三角形状和下方的两个椭圆形状。作为人类,我们知道这些三角形状是耳朵,椭圆形状是眼睛。

我们可以在图 4-14 中看到这个过程,这是由 Zeiler 和 Fergus 进行的研究,他们解构了 CNN 以理解模型每一层激活的不同特征。在一个五层 CNN 的每一层中,这显示了给定层的图像特征映射以及实际图像。这让我们能够看到模型对图像的感知如何随着其在网络中的移动而进展。第 1 层和第 2 层仅识别边缘,第 3 层开始识别对象,第 4 层和第 5 层可以理解整个图像中的焦点。

记住,对于我们的模型而言,这些仅仅是像素值的分组。它并不知道三角形和椭圆形状是耳朵和眼睛——它只知道将特定的特征组合与它所训练的标签相关联。因此,从模型学习组成猫的特征组合的过程,并不与学习其他对象(如桌子、山或甚至名人)的特征组合的过程有多大不同。对于模型来说,这些只是不同的像素值、边缘和形状的组合。

Zeiler 和 Fergus(2013 年)的研究解构 CNN 帮助我们可视化 CNN 如何在网络的每一层中看待图像。

图 4-14. Zeiler 和 Fergus(2013 年)的研究解构 CNN 帮助我们可视化 CNN 如何在网络的每一层中看待图像。

折衷与替代方案

到目前为止,我们还没有讨论在实施迁移学习时如何修改原始模型的权重。在这里,我们将探讨两种方法:特征提取和微调。我们还将讨论为什么迁移学习主要集中在图像和文本模型上,并查看文本句子嵌入与迁移学习之间的关系。

微调与特征提取

特征提取描述了一种迁移学习的方法,其中您冻结了瓶颈层之前的所有层的权重,并在您自己的数据和标签上训练后续层。另一个选择是代替地微调预训练模型的层权重。通过微调,您可以更新预训练模型中每一层的权重,或者只是在瓶颈之前的几层。使用微调训练迁移学习模型通常比特征提取需要更长的时间。正如在我们的文本分类示例中所示,当初始化 TF Hub 层时,我们将trainable=True。这是微调的一个示例。

当进行微调时,通常会将模型初始层的权重保持冻结状态,因为这些层已经训练用于识别基本特征,这些特征通常在许多类型的图像中都是共通的。例如,要微调一个 MobileNet 模型,我们会将trainable=False仅应用于模型的一部分层,而不是将每一层都设置为不可训练。例如,要在第 100 层之后进行微调,我们可以运行:

base_model = tf.keras.applications.MobileNetV2(input_shape=(160,160,3),
                                               include_top=False,
                                               weights='imagenet')

for layer in base_model.layers[:100]:
  layer.trainable =  False

一个推荐的确定要冻结多少层的方法被称为渐进微调,它涉及在每次训练运行后逐步解冻层,以找到要微调的理想层数。如果您保持学习速率低(通常为 0.001),并且训练迭代次数相对较少,这种方法效果最好且最有效。要实施渐进微调,首先只解冻转移模型的最后一层(最靠近输出的层),并在训练后计算模型的损失。然后,逐层解冻更多的层,直到达到输入层或损失开始趋于平稳。利用这一过程确定要微调的层数。

如何确定是否要微调或冻结预训练模型的所有层?通常情况下,当你有一个小数据集时,最好将预训练模型作为特征提取器而不是进行微调。如果你重新训练一个模型的权重,而该模型很可能是在成千上万个示例上进行了训练,微调可能会导致更新后的模型对你的小数据集过拟合,并且丢失从那些成千上万个示例中学到的更一般性的信息。虽然这取决于你的数据和预测任务,但在这里所说的“小数据集”,是指具有数百或数千个训练示例的数据集。

在决定是否进行微调时,另一个要考虑的因素是您的预测任务与您正在使用的原始预训练模型的预测任务有多相似。当预测任务相似或者是前一训练的延续时,例如我们的电影评论情感分析模型,微调可以产生更高准确率的结果。当任务不同或数据集显著不同时,最好冻结所有预训练模型的层而不是进行微调。Table 4-1 总结了关键点。⁴

Table 4-1. 帮助选择特征提取和微调的标准

标准 特征提取 微调
数据集大小如何?
您的预测任务是否与预训练模型相同? 不同任务 相同任务,或具有相同类别分布标签的相似任务
训练时间和计算成本预算

在我们的文本示例中,预训练模型是在新闻文本语料库上训练的,但我们的用例是情感分析。因为这些任务不同,我们应该将原始模型作为特征提取器而不是进行微调。在图像领域中不同预测任务的例子可能是使用我们在 ImageNet 上训练的 MobileNet 模型作为在医学图像数据集上进行迁移学习的基础。尽管这两个任务都涉及图像分类,但每个数据集中的图像性质却非常不同。

专注于图像和文本模型

您可能已经注意到,本节中所有的示例都集中在图像和文本数据上。这是因为迁移学习主要适用于可以将类似任务应用于相同数据域的情况。然而,使用表格数据训练的模型涵盖了潜在无限数量的可能预测任务和数据类型。例如,您可以训练一个模型来预测如何定价您的活动门票,是否有人可能会违约贷款,公司下个季度的收入,出租车行程的持续时间等等。这些任务的具体数据也非常多样化,定价问题取决于艺术家和场馆信息,贷款问题则取决于个人收入,出租车行程时间则取决于城市交通模式。因此,将一个表格模型的学习迁移到另一个表格模型中存在固有的挑战。

尽管迁移学习在表格数据上尚不如在图像和文本领域普遍,但一种新的模型架构称为TabNet在这一领域提出了新的研究。与图像和文本模型相比,大多数表格模型需要进行重要的特征工程。TabNet 采用一种技术,首先使用无监督学习来学习表格特征的表示,然后微调这些学习到的表示以生成预测。通过这种方式,TabNet 自动化了表格模型的特征工程。

词嵌入与句子嵌入

到目前为止,在我们讨论的文本嵌入中,我们大多数时候都在提及嵌入。另一种文本嵌入是句子嵌入。词嵌入代表向量空间中的单个词,而句子嵌入则代表整个句子。因此,词嵌入是上下文无关的。让我们看看以下句子的表现:

“我把新鲜烤饼放在厨房柜台左侧。”

注意到在那个句子中,单词left出现了两次,首先是动词,然后是形容词。如果我们为这个句子生成词嵌入,我们会得到每个单词的单独数组。使用词嵌入时,left这个词的两个实例的数组是相同的。然而,使用句子级嵌入,我们会得到一个单一的向量来代表整个句子。有几种方法可以生成句子嵌入——从对句子的词嵌入进行平均到训练一个大型语料库上的监督学习模型以生成嵌入。

这与迁移学习有何关联?后一种方法——训练监督学习模型以生成句子级嵌入——实际上是一种迁移学习形式。这是 Google 的通用句子编码器(可在 TF Hub 中找到)和BERT采用的方法。这些方法与词嵌入不同,它们不仅仅是为单个词提供权重查找。相反,它们通过在大量文本数据集上训练模型来理解词序列传达的含义。这样,它们被设计用于在不同的自然语言任务中进行迁移,并因此可用于构建实施迁移学习的模型。

设计模式 14: 分布策略

在分布策略,训练循环在多个工作节点上按比例进行,通常包括缓存、硬件加速和并行化。

问题

这些天,大型神经网络通常具有数百万个参数,并且在大量数据上进行训练。事实上,已经证明,随着深度学习规模的增加,无论是训练示例的数量、模型参数的数量还是两者兼而有之,都会显著提高模型性能。然而,随着模型和数据规模的增加,计算和内存需求成比例增加,这使得训练这些模型所需的时间成为深度学习的最大问题之一。

GPU 提供了显著的计算提升,并使得适度大小的深度神经网络的训练时间可以接受。然而,对于在大量数据上训练的非常大型模型,单个 GPU 是不足以使训练时间变得可行的。例如,在撰写本文时,使用单个 NVIDIA M40 GPU 对 ImageNet 数据集上的 ResNet-50 进行 90 个周期的训练需要进行 10¹⁸ 个单精度操作,并且需要 14 天的时间。随着人工智能在解决复杂领域内的问题中的应用越来越广泛,以及像 TensorFlow 和 PyTorch 这样的开源库使得构建深度学习模型更加可访问,类似 ResNet-50 的大型神经网络已经成为常态。

这是一个问题。如果训练你的神经网络需要两周的时间,那么在你可以迭代新想法或尝试调整设置之前,你必须等待两周。此外,对于一些复杂的问题,如医学影像、自动驾驶或语言翻译,将问题分解成较小的组件或仅使用数据子集并不总是可行的。只有使用完整的数据规模,你才能评估事物是否有效。

训练时间直接转化为金钱。在无服务器机器学习世界中,与其购买昂贵的 GPU,不如通过云服务提交训练作业,您将按照训练时间付费。无论是为了购买 GPU 还是为了购买无服务器训练服务,训练模型的成本都会迅速累积。

是否有办法加速这些大型神经网络的训练?

解决方案

加速训练的一种方法是通过训练循环中的分发策略。有不同的分发技术,但共同的想法是将训练模型的工作分散到多台机器上。有两种方法可以实现这一点:数据并行模型并行。在数据并行中,计算被分割到不同的机器上,并且不同的工作节点在训练数据的不同子集上进行训练。在模型并行中,模型被分割,并且不同的工作节点负责模型的不同部分的计算。在本节中,我们将专注于数据并行,并展示如何在 TensorFlow 中使用 tf.distribute.Strategy 库进行实现。我们将在 “权衡和替代方案” 中讨论模型并行。

要实现数据并行,必须有一种方法让不同的工作节点计算梯度并共享该信息,以更新模型参数。这确保了所有工作节点的一致性,并使每个梯度步骤都能有效地训练模型。广义上说,数据并行可以同步或异步进行。

同步训练

在同步训练中,工作节点并行训练不同的输入数据片段,并且在每个训练步骤结束时聚合梯度值。这是通过全局归约算法实现的。这意味着每个工作节点(通常是 GPU)都有设备上的模型副本,对于单个随机梯度下降(SGD)步骤,一小批数据被分割给各个不同的工作节点。每个设备使用其部分的小批量数据进行前向传递,并计算模型的每个参数的梯度。然后从每个设备收集并聚合这些局部计算的梯度(例如,平均值),以生成每个参数的单个梯度更新。中央服务器保存模型参数的最新副本,并根据从多个工作节点收到的梯度执行梯度步骤。一旦根据这个聚合梯度步骤更新了模型参数,新模型将与下一小批量数据的另一个分割一起发送回工作节点,并且这个过程重复进行。图 4-15 展示了同步数据分布的典型全局归约架构。

与任何并行策略一样,这会引入额外的开销来管理工作节点之间的时间和通信。大型模型可能会导致 I/O 瓶颈,因为在训练期间从 CPU 传输数据到 GPU,并且慢网络也可能会引起延迟。

在 TensorFlow 中,tf.distribute.MirroredStrategy 支持在同一台机器上多个 GPU 上进行同步分布式训练。每个模型参数都会在所有工作节点上进行镜像并存储为一个称为 MirroredVariable 的概念变量。在全局归约步骤中,所有梯度张量都会在每个设备上可用。这有助于显著减少同步的开销。还有许多其他的全局归约算法实现可用,其中许多使用NVIDIA NCCL

在同步训练中,每个工作节点持有模型副本,并使用训练数据小批量的一个片段计算梯度。

图 4-15. 在同步训练中,每个工作节点持有模型副本,并使用训练数据小批量的一个片段计算梯度。

要在 Keras 中实现这种镜像策略,首先创建一个镜像分布策略的实例,然后将模型的创建和编译移动到该实例的范围内。以下代码展示了如何在训练三层神经网络时使用 MirroredStrategy

mirrored_strategy = tf.distribute.MirroredStrategy()
with mirrored_strategy.scope():
    model = tf.keras.Sequential([tf.keras.layers.Dense(32, input_shape=(5,)),
                                 tf.keras.layers.Dense(16, activation='relu'),
                                 tf.keras.layers.Dense(1)])
    model.compile(loss='mse', optimizer='sgd')

在此范围内创建模型时,模型参数被创建为镜像变量而不是常规变量。在将模型拟合到数据集时,一切操作与之前完全相同。模型代码保持不变!将模型代码包装在分发策略范围内即可启用分布式训练。MirroredStrategy 处理在可用 GPU 上复制模型参数、聚合梯度等工作。要训练或评估模型,只需像往常一样调用 fit()evaluate()

model.fit(train_dataset, epochs=2)
model.evaluate(train_dataset)

训练期间,每个输入数据批次均平均分配给多个工作进程。例如,如果使用两个 GPU,则批量大小为 10 的数据将在这两个 GPU 之间分割,每个 GPU 每步接收 5 个训练样本。Keras 中还有其他同步分发策略,如 CentralStorageStrategyMultiWorkerMirroredStrategyMultiWorkerMirroredStrategy 不仅能够在单台机器上的 GPU 上进行分布,还能在多台机器上进行分布。在 CentralStorageStrategy 中,模型变量不是镜像的;相反,它们被放置在 CPU 上,并且操作在所有本地 GPU 上被复制。因此,变量更新仅发生在一个地方。

在选择不同的分发策略时,最佳选项取决于您的计算机拓扑结构以及 CPU 和 GPU 之间通信的速度。表 4-2 总结了这些策略在这些标准下的比较情况。

表 4-2. 在选择分发策略时,取决于您的计算机拓扑结构以及 CPU 和 GPU 之间通信的速度

更快的 CPU-GPU 连接 更快的 GPU-GPU 连接
单台机器多个 GPU CentralStorageStrategy MirroredStrategy
多台机器多个 GPU MultiWorkerMirroredStrategy MultiWorkerMirroredStrategy

异步训练

在异步训练中,各工作进程独立地训练不同切片的输入数据,并且模型权重和参数通过异步方式更新,通常通过 参数服务器架构 实现。这意味着没有一个工作进程需要等待来自其他工作进程的模型更新。在参数服务器架构中,有一个单一的参数服务器管理模型权重的当前值,如 图 4-16。

与同步训练类似,每个 SGD 步骤的数据小批量被分割并分配给每个独立的工作进程。每个设备使用其分配的数据小批量进行前向传播,并计算模型参数的梯度。这些梯度被发送到参数服务器,执行参数更新,然后将新的模型参数发送回工作进程,以进行下一个数据小批量的处理分割。

同步训练与异步训练的关键区别在于参数服务器不执行all-reduce 操作。相反,它根据自上次计算以来接收到的梯度更新周期性地计算新的模型参数。通常,异步分发比同步训练实现更高的吞吐量,因为慢速工作节点不会阻塞训练步骤的进展。如果一个工作节点失败,训练将继续计划中的其他工作节点进行,而该工作节点重新启动。因此,在训练过程中可能会丢失一些小批量的分割,这使得准确跟踪处理了多少个 epoch 的数据变得困难。这也是为什么我们在训练大型分布式作业时通常指定虚拟 epoch 而不是真正的 epoch 的另一个原因;请参阅“设计模式 12:检查点”以讨论虚拟 epoch。

在异步训练中,每个工作节点使用小批量的梯度下降步骤,没有一个工作节点等待来自其他工作节点的模型更新。

在异步训练中,每个工作节点使用小批量的梯度下降步骤,没有一个工作节点等待来自其他工作节点的模型更新。

此外,由于权重更新之间没有同步,一个工作节点基于过时的模型状态可能更新模型权重。然而,在实践中,这似乎不是一个问题。通常,大型神经网络经过多个 epoch 的训练,这些小的差异最终变得可以忽略不计。

在 Keras 中,ParameterServerStrategy在多台机器上实现异步参数服务器训练。在使用此分发方式时,一些机器被指定为工作节点,而另一些机器则作为参数服务器。参数服务器保存模型的每个变量,并在工作节点上执行计算,通常是在 GPU 上。

实现与 Keras 中其他分发策略类似。例如,在您的代码中,只需用ParameterServerStrategy()替换MirroredStrategy()

Tip

另一个在 Keras 中支持的值得一提的分发策略是OneDeviceStrategy。此策略将其作用域内创建的任何变量放置在指定的设备上。这种策略在切换到实际分发到多个设备/机器的其他策略之前,作为测试代码的一种有效方式。

同步和异步训练各有其优缺点,选择其中之一往往取决于硬件和网络限制。

同步训练对于低速设备或者网络连接差的情况尤为脆弱,因为训练会因为等待所有工作节点更新而停滞不前。这意味着在所有设备都在单个主机上且具有高速设备(例如 TPU 或 GPU)和强大链接时,同步分发是首选。另一方面,如果存在许多低功率或不可靠的工作节点,异步分发则更为合适。如果单个工作节点失败或者在返回梯度更新时停滞,它不会阻碍整个训练循环。唯一的限制是 I/O 约束。

为何这样做有效

大型复杂神经网络需要大量训练数据才能有效果。分布式训练方案显著增加了这些模型处理的数据吞吐量,并且可以有效地将训练时间从几周缩短到几小时。在工作节点和参数服务器任务之间共享资源会大幅增加数据吞吐量。图 4-17 比较了不同分发设置下训练数据吞吐量的情况,例如图像。⁵ 最显著的是,随着工作节点数量的增加,吞吐量也在增加,即使参数服务器执行的任务与 GPU 工作节点上的计算无关,将工作负载分配给更多机器仍然是最有利的策略。

此外,数据并行化可以在训练期间加快收敛时间。在类似的研究中,显示增加工作节点会更快地达到最小损失⁶。图 4-18 比较了不同分发策略下达到最小训练损失所需的时间。随着工作节点数量的增加,达到最小训练损失的时间显著减少,仅使用 8 个工作节点比使用 1 个工作节点速度提升了近 5 倍。

比较不同分发设置之间的吞吐量。这里,2W1PS 表示 2 个工作节点和 1 个参数服务器。

图 4-17。比较不同分发设置之间的吞吐量。这里,2W1PS 表示两个工作节点和一个参数服务器。

随着 GPU 数量的增加,训练收敛时间缩短。

图 4-18。随着 GPU 数量的增加,训练收敛时间缩短。

权衡与替代方案

除了数据并行化外,还有其他分发方式需要考虑,例如模型并行化、其他训练加速器(例如 TPU)以及其他因素(例如 I/O 限制和批处理大小)。

模型并行化

在某些情况下,神经网络非常庞大,无法适应单个设备的内存;例如,Google 的神经机器翻译拥有数十亿个参数。为了训练这么大的模型,必须将其分割到多个设备上,如图 4-19 所示。这称为模型并行性。通过将网络的部分及其相关计算分布到多个核心上,计算和内存负载分布到多个设备上。每个设备在训练过程中处理相同的小批量数据,但仅执行与模型各部分相关的计算。

模型并行性将模型分割到多个设备上。

图 4-19. 模型并行性将模型分割到多个设备上。

ASIC 提升性能降低成本

加速训练过程的另一种方法是加速底层硬件,例如使用特定应用集成电路(ASIC)。在机器学习中,这指的是专门设计的硬件组件,旨在优化训练循环核心的大型矩阵计算性能。Google Cloud 中的 TPU 是既用于模型训练又用于预测的 ASIC。类似地,Microsoft Azure 提供 Azure FPGA(现场可编程门阵列),它也是一种像 ASIC 一样的自定义机器学习芯片,但可以随时间重新配置。这些芯片能够极大地缩短在大型复杂神经网络模型上训练的时间至准确度。在 GPU 上训练两周的模型,在 TPU 上几小时内就可以收敛。

使用自定义机器学习芯片还有其他优势。例如,随着加速器(GPU、FPGA、TPU 等)的速度提升,I/O 成为 ML 训练中的一个重要瓶颈。许多训练过程浪费时间等待读取和移动数据到加速器,等待梯度更新进行全局归约。TPU Pod 具有高速互联,因此我们不太担心 Pod 内部的通信开销(一个 Pod 由数千个 TPU 组成)。此外,磁盘上有大量的可用内存,这意味着可以预先获取数据,并减少对 CPU 的调用次数。因此,应使用更大的批量大小,以充分利用像 TPU 这样高内存、高互联的芯片。

在分布式训练方面,TPUStrategy允许您在 TPU 上运行分布式训练作业。在内部,TPUStrategyMirroredStrategy相同,尽管 TPU 有自己的全局归约算法实现。

使用TPUStrategy与在 TensorFlow 中使用其他分布策略类似。其中一个区别在于,您必须首先设置TPUClusterResolver,指向 TPU 的位置。目前在 Google Colab 上可以免费使用 TPU,而在那里您不需要为tpu_address指定任何参数。

cluster_resolver = tf.distribute.cluster_resolver.TPUClusterResolver(
    tpu=tpu_address)
tf.config.experimental_connect_to_cluster(cluster_resolver)
tf.tpu.experimental.initialize_tpu_system(cluster_resolver)
tpu_strategy = tf.distribute.experimental.TPUStrategy(cluster_resolver)

选择批量大小

另一个需要考虑的重要因素是批量大小。特别是对于同步数据并行性,当模型特别大时,最好减少总训练迭代次数,因为每个训练步骤需要更新后的模型在不同的工作节点之间共享,从而导致传输时间减慢。因此,尽可能增加小批量大小非常重要,这样可以通过更少的步骤实现相同的性能。

然而,已经表明非常大的批量大小会对随机梯度下降的收敛速度以及最终解的质量产生不利影响。⁸ 图 4-20 显示,仅增加批量大小最终导致了 top-1 验证错误的增加。事实上,他们认为,线性缩放学习率作为大批量大小的函数是必要的,以保持低验证错误同时减少分布式训练时间。

大批量训练已被证明会对最终训练模型的质量产生不利影响。

图 4-20. 大批量训练已被证明会对最终训练模型的质量产生不利影响。

因此,在分布式训练环境中设置小批量大小是一个复杂的优化空间,因为它既影响模型的统计精度(泛化),又影响硬件效率(利用率)。相关工作专注于此优化,引入了一种称为 LAMB 的逐层自适应大批量优化技术,能够将 BERT 的训练时间从 3 天缩短到仅 76 分钟。

最小化 I/O 等待

GPU 和 TPU 可以比 CPU 更快地处理数据,在使用多个加速器的分布式策略时,I/O 管道可能难以跟上,从而创建更有效的训练瓶颈。具体来说,在一个训练步骤完成之前,下一个步骤的数据尚未准备好进行处理。这在图 4-21 中有所展示。CPU 处理输入管道:从存储中读取数据,预处理并发送到加速器进行计算。随着分布式策略加快训练速度,更有必要拥有高效的输入管道来充分利用可用的计算能力。

可以通过多种方式实现这一目标,包括使用优化的文件格式如 TFRecords,并使用 TensorFlow 的 tf.data API 构建数据管道。tf.data API 能够处理大量数据,并且具有内置的转换功能,有助于创建灵活、高效的管道。例如,tf.data.Dataset.prefetch 可以使预处理和模型执行在训练步骤中重叠,因此当模型执行训练步骤 N 时,输入管道正在读取和准备训练步骤 N + 1 的数据,如图 4-22 所示。

通过多个 GPU/TPU 进行分布式训练,需要高效的输入管道。

图 4-21. 通过多个 GPU/TPU 进行分布式训练,需要高效的输入管道。

预取重叠预处理和模型执行,因此当模型执行一个训练步骤时,输入管道正在读取和准备下一个步骤的数据。

图 4-22. 预取重叠预处理和模型执行,因此当模型执行一个训练步骤时,输入管道正在读取和准备下一个步骤的数据。

设计模式 15:超参数调优

在超参数调优中,训练循环本身被插入到一种优化方法中,以找到最优的模型超参数集。

问题

在机器学习中,模型训练涉及寻找最优的断点集(决策树的情况下)、权重(神经网络的情况下)或支持向量(支持向量机的情况下)。我们称这些为 模型 参数。然而,为了进行模型训练并找到最优的模型参数,我们经常需要硬编码各种事物。例如,我们可能决定树的最大深度为 5(决策树的情况下),或者激活函数将为 ReLU(神经网络的情况下),或选择我们将使用的核函数集(在 SVM 中)。这些参数称为 超参数

模型参数指的是模型学习到的权重和偏置。你无法直接控制模型参数,因为它们很大程度上取决于你的训练数据、模型架构以及许多其他因素。换句话说,你不能手动设置模型参数。模型的权重是用随机值初始化的,然后在训练迭代过程中由模型进行优化。另一方面,超参数是指你作为模型构建者可以控制的任何参数。它们包括学习率、迭代次数、模型中的层数等。

手动调整

因为你可以手动选择不同超参数的值,你的第一反应可能是尝试错误来找到最优的超参数组合。这种方法对于在几秒或几分钟内完成训练的模型可能有效,但对于需要大量训练时间和基础设施的大型模型来说,成本会很快上升。想象一下,你正在训练一个图像分类模型,需要在 GPU 上花费数小时来完成训练。你先确定了几个要尝试的超参数值,然后等待第一次训练运行的结果。根据这些结果,你调整超参数,再次训练模型,将结果与第一次运行的结果进行比较,然后通过查看具有最佳指标的训练运行来确定最佳的超参数值。

这种方法存在一些问题。首先,你已经花了将近一天的时间和大量计算资源来完成这个任务。其次,你无法确定是否找到了最优的超参数组合。你只尝试了两种不同的组合,而且因为你同时更改了多个值,所以不知道哪个参数对性能影响最大。即使进行额外的尝试,采用这种方法也会迅速消耗你的时间和计算资源,并且可能得不到最优的超参数值。

注意

我们在这里使用术语试验来指代使用一组超参数值进行的单次训练运行。

网格搜索与组合爆炸

较结构化的试错方法的更完整版本称为网格搜索。当使用网格搜索进行超参数调整时,我们选择要优化的每个超参数的可能值列表。例如,在 scikit-learn 的RandomForestRegressor()模型中,假设我们想为模型的max_depthn_estimators超参数尝试以下组合的值:

grid_values = {
  'max_depth': [5, 10, 100],
  'n_estimators': [100, 150, 200]
}

使用网格搜索,我们将尝试每个指定值的组合,然后使用在我们模型上产生最佳评估指标的组合。让我们看看如何在预先安装了 scikit-learn 的波士顿房价数据集上的随机森林模型上运行这个方法。我们可以通过创建GridSearchCV类的一个实例并传递我们之前定义的值来运行网格搜索训练模型:

from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import load_boston

X, y = load_boston(return_X_y=True)
housing_model = RandomForestRegressor()

grid_search_housing = GridSearchCV(
   housing_model, param_grid=grid_vals, scoring='max_error')
grid_search_housing.fit(X, y)

注意这里的评分参数是我们希望优化的度量标准。在这个回归模型的情况下,我们希望使用导致模型误差最低的超参数组合。要从网格搜索中获得最佳值组合,我们可以运行 grid_search_housing.best_params_。返回以下内容:

{'max_depth': 100, 'n_estimators': 150}

我们希望将这与训练随机森林回归模型(没有超参数调优)得到的误差进行比较,使用 scikit-learn 的这些参数的默认值。这种网格搜索方法在我们上面定义的小例子上效果还行,但是对于更复杂的模型,我们可能希望优化超过两个超参数,每个参数有广泛的可能值。最终,网格搜索将导致组合爆炸——随着我们添加额外的超参数和值到我们的选项网格中,需要尝试的可能组合数量以及尝试它们所需的时间显著增加。

另一个问题是,在选择不同组合时没有应用逻辑。网格搜索本质上是一种蛮力解决方案,尝试每一个可能的数值组合。假设在某个max_depth值之后,我们模型的误差增加了。网格搜索算法不会从之前的试验中学习,因此它不会知道在某个阈值之后停止尝试max_depth值。它会简单地尝试你提供的每一个数值,不管结果如何。

scikit-learn 支持一种称为RandomizedSearchCV的网格搜索替代方法,它实现了随机搜索。与从一个集合中尝试每个可能的超参数组合不同,您确定要为每个超参数随机抽样值的次数。要在 scikit-learn 中实现随机搜索,我们会创建一个RandomizedSearchCV实例,并传递一个类似上面grid_values的字典,指定范围而不是具体的值。随机搜索运行速度比网格搜索快,因为它不会尝试所有可能值的每一种组合,但是很可能最优的超参数集不会在随机选择的集合中。

对于稳健的超参数调优,我们需要一个解决方案,可以扩展并从先前试验中学习,以找到超参数值的最佳组合。

**## 解决方案

keras-tuner库实现了在 Keras 中直接进行超参数搜索的贝叶斯优化。要使用keras-tuner,我们在一个接受超参数参数的函数中定义我们的模型,这里称为hp。然后我们可以在函数中使用hp,无论我们想在哪里包含一个超参数,指定超参数的名称、数据类型、我们想要搜索的值范围以及每次尝试新值时递增多少。

在我们定义 Keras 模型中的层时,我们不会硬编码超参数值,而是使用超参数变量来定义。在这里,我们希望调整神经网络第一个隐藏层中的神经元数量:

keras.layers.Dense(hp.Int('first_hidden', 32, 256, step=32), activation='relu')

first_hidden 是我们给这个超参数起的名字,32 是我们为其定义的最小值,256 是最大值,并且在我们定义的范围内,每次应增加此值 32。如果我们正在构建一个 MNIST 分类模型,我们将传递给 keras-tuner 的完整函数可能如下所示:

def build_model(hp):
 model = keras.Sequential([
  keras.layers.Flatten(input_shape=(28, 28)),
  keras.layers.Dense(
    hp.Int('first_hidden', 32, 256, step=32), activation='relu'),
  keras.layers.Dense(
    hp.Int('second_hidden', 32, 256, step=32), activation='relu'),
  keras.layers.Dense(10, activation='softmax')
])

 model.compile(
   optimizer=tf.keras.optimizers.Adam(
     hp.Float('learning_rate', .005, .01, sampling='log')),
   loss='sparse_categorical_crossentropy', 
   metrics=['accuracy'])

 return model

keras-tuner 库支持许多不同的优化算法。在这里,我们将使用贝叶斯优化来实例化我们的调优器,并优化验证精度:

import kerastuner as kt

tuner = kt.BayesianOptimization(
    build_model,
    objective='val_accuracy',
    max_trials=10
)

运行调优作业的代码类似于使用 fit() 训练我们的模型。当这个过程运行时,我们将能够看到每个试验中选择的三个超参数的值。作业完成后,我们可以看到导致最佳试验的超参数组合。在 图 4-23 中,我们可以看到使用 keras-tuner 进行单次试验运行的示例输出。

使用 keras-tuner 进行一次超参数调优试验的输出。在顶部我们可以看到调优器选择的超参数,在摘要部分我们看到生成的优化指标。

图 4-23。使用 keras-tuner 进行一次超参数调优试验的输出。在顶部,我们可以看到调优器选择的超参数,在摘要部分,我们可以看到生成的优化指标。

除了这里显示的示例之外,keras-tuner 还提供了我们尚未涵盖的其他功能。您可以通过在循环内定义一个 hp.Int() 参数来尝试不同层数的模型,并且您还可以为超参数提供一组固定的值而不是一个范围。对于更复杂的模型,hp.Choice() 参数可以用于尝试不同类型的层,如 BasicLSTMCellBasicRNNCellkeras-tuner 可在任何可以训练 Keras 模型的环境中运行。

为什么它有效

尽管网格搜索和随机搜索比超参数调优的试错方法更高效,但对于需要大量训练时间或具有大型超参数搜索空间的模型,它们很快变得昂贵。

由于机器学习模型本身和超参数搜索过程都是优化问题,我们可以使用一种学习方法来找到在给定可能值范围内的最优超参数组合,就像我们的模型从训练数据中学习一样。

我们可以把超参数调优看作是一个外部优化循环(参见图 4-24),其中内部循环包括典型的模型训练。虽然我们将神经网络描绘为正在优化其参数的模型,但这种解决方案适用于其他类型的机器学习模型。此外,尽管更常见的用例是从所有潜在的超参数中选择一个最佳模型,但在某些情况下,超参数框架可以用来生成一个作为集成的模型族(参见第 3 章中集合模式的讨论)。

超参数调优可以看作是一个外部优化循环。

图 4-24. 超参数调优可以看作是一个外部优化循环。

非线性优化

需要调优的超参数分为两组:与模型架构相关的参数和与模型训练相关的参数。模型架构的超参数,例如模型中的层数或每层的神经元数,控制着机器学习模型底层的数学函数。与模型训练相关的参数,例如 epoch 数、学习率和批大小,控制着训练循环,通常与梯度下降优化器的工作方式有关。考虑到这两类参数,显然总体模型函数关于这些超参数不是可微的。

内部训练循环是可微分的,通过随机梯度下降可以寻找最优参数。通过随机梯度训练的机器学习模型单步可能只需几毫秒。另一方面,超参数调优问题的单次试验涉及在训练数据集上训练完整的模型,可能需要数小时。此外,超参数的优化问题必须通过适用于非可微问题的非线性优化方法来解决。

一旦我们决定使用非线性优化方法,我们的度量选择范围就更广了。这个度量将在验证数据集上评估,并不一定与训练损失相同。对于分类模型,你的优化度量可能是准确率,因此你希望找到导致模型准确率最高的超参数组合,即使损失是二元交叉熵。对于回归模型,你可能希望优化中位数绝对误差,即使损失是平方误差。在这种情况下,你会希望找到导致最低均方误差的超参数。这个度量甚至可以基于业务目标进行选择。例如,我们可能选择最大化预期收入或最小化由于欺诈造成的损失。

贝叶斯优化

贝叶斯优化是一种优化黑盒函数的技术,最早由乔纳斯·莫库斯在 1970 年代开发。该技术已应用于许多领域,并首次应用于2012 年的超参数调整。在这里,我们将重点介绍贝叶斯优化与超参数调整的关系。在这个背景下,机器学习模型是我们的黑盒函数,因为 ML 模型会根据我们提供的输入产生一组输出,而无需我们了解模型本身的内部细节。训练 ML 模型的过程被称为调用目标函数

贝叶斯优化的目标是尽可能少地直接训练我们的模型,因为这样做成本很高。请记住,每次我们在模型上尝试新的超参数组合时,都需要运行整个模型的训练周期。对于像我们上面训练的 scikit-learn 模型这样的小模型来说,这似乎微不足道,但对于许多生产模型来说,训练过程需要大量基础设施和时间。

代替每次尝试新的超参数组合来训练我们的模型,贝叶斯优化定义了一个新的函数,模拟我们的模型但运行成本要低得多。这称为替代函数—该函数的输入是您的超参数值,输出是优化度量。替代函数的调用频率远高于目标函数,其目标是在完成模型训练之前找到最佳的超参数组合。采用这种方法,与网格搜索相比,每次试验为选择超参数花费了更多计算时间。但是,因为这比每次尝试不同超参数时运行我们的目标函数要便宜得多,所以使用替代函数的贝叶斯方法更可取。生成替代函数的常见方法包括高斯过程树结构帕尔森估计器

到目前为止,我们已经涉及了贝叶斯优化的不同部分,但它们如何协同工作呢?首先,我们必须选择要优化的超参数,并为每个超参数定义一系列值的范围。这一过程是手动的,并且将定义我们的算法将搜索优化值的空间。我们还需要定义我们的目标函数,这是调用我们模型训练过程的代码。从那里,贝叶斯优化开发一个替代函数来模拟我们的模型训练过程,并使用该函数来确定在我们的模型上运行的最佳超参数组合。只有当这个替代函数认为找到了一个好的超参数组合时,我们才会对我们的模型进行完整的训练运行(试验)。然后将这些结果反馈给替代函数,并且重复这个过程,直到达到我们指定的试验次数为止。

折中与替代方案

遗传算法是超参数调整的替代方案,但它们通常需要比贝叶斯方法更多的模型训练运行。我们还将向您展示如何使用托管服务优化模型,该服务适用于多种 ML 框架构建的超参数调整。

完全托管的超参数调整

由于我们希望试验同时进行,并且随着模型训练时间延长,机器出错和其他故障的可能性增加,keras-tuner方法可能无法扩展到大型机器学习问题。因此,提供黑盒优化的完全托管和弹性方法对于超参数调整非常有用。一个实施贝叶斯优化的托管服务示例是由 Google Cloud AI Platform 提供的超参数调整服务。这项服务基于 Google 内部使用的Vizier,这是一个黑盒优化工具。

云服务的基本概念与keras-tuner类似:您指定每个超参数的名称、类型、范围和比例,并且这些值在您的模型训练代码中被引用。我们将向您展示如何在 AI Platform 上运行超参数调整,使用基于 BigQuery natality 数据集训练的 PyTorch 模型来预测婴儿的出生体重。

第一步是创建一个config.yaml文件,指定您希望作业优化的超参数,以及有关作业的其他一些元数据。使用云服务的一个好处是,您可以通过在 GPU 或 TPU 上运行并在多个参数服务器上分布来扩展调整作业。在此配置文件中,您还要指定您希望运行的超参数试验总数以及您希望并行运行的这些试验数量。并行运行的试验越多,作业运行得越快。但是,较少并行运行试验的好处是,服务将能够从每个已完成试验的结果中学习,以优化接下来的试验。

对于我们的模型,一个使用 GPU 的示例配置文件可能如下所示。在这个例子中,我们将调整三个超参数——模型的学习率,优化器的动量值,以及模型隐藏层中的神经元数量。我们还指定了我们的优化度量标准。在这个例子中,我们的目标是最小化在验证集上的模型损失:

trainingInput:
 scaleTier: BASIC_GPU
 parameterServerType: large_model
 workerCount: 9
 parameterServerCount: 3
 hyperparameters:
 goal: MINIMIZE
 maxTrials: 10
 maxParallelTrials: 5
 hyperparameterMetricTag: val_error
 enableTrialEarlyStopping: TRUE
 params:
 - parameterName: lr
 type: DOUBLE
 minValue: 0.0001
 maxValue: 0.1
 scaleType: UNIT_LINEAR_SCALE
 - parameterName: momentum
 type: DOUBLE
 minValue: 0.0
 maxValue: 1.0
 scaleType: UNIT_LINEAR_SCALE
 - parameterName: hidden-layer-size
 type: INTEGER
 minValue: 8
 maxValue: 32
 scaleType: UNIT_LINEAR_SCALE
注意

而不是使用配置文件来定义这些值,您还可以使用 AI 平台 Python API 来完成这些操作。

为了做到这一点,我们需要向我们的代码中添加一个参数解析器,该解析器将指定我们在上述文件中定义的参数,然后在我们的模型代码中引用这些超参数。

接下来,我们将使用 PyTorch 的nn.Sequential API 和 SGD 优化器构建我们的模型。由于我们的模型预测婴儿体重为浮点数,因此这将是一个回归模型。我们使用args变量指定每个超参数,该变量包含在我们的参数解析器中定义的变量:

import torch.nn as nn

model = nn.Sequential(nn.Linear(num_features, args.hidden_layer_size),
                      nn.ReLU(),
                      nn.Linear(args.hidden_layer_size, 1))

optimizer = torch.optim.SGD(model.parameters(), lr=args.lr, 
                            momentum=args.momentum)

在我们的模型训练代码结尾,我们将创建一个HyperTune()的实例,并告诉它我们试图优化的度量标准。这将报告每次训练运行后我们优化度量标准的结果值。重要的是,我们选择的任何优化度量标准都应该在我们的测试或验证数据集上计算,而不是在我们的训练数据集上:

import hypertune

hpt = hypertune.HyperTune()

val_mse = 0
num_batches = 0

criterion = nn.MSELoss()

with torch.no_grad():
    for i, (data, label) in enumerate(validation_dataloader):
        num_batches += 1
        y_pred = model(data)
        mse = criterion(y_pred, label.view(-1,1))
        val_mse += mse.item()

    avg_val_mse = (val_mse / num_batches)

hpt.report_hyperparameter_tuning_metric(
    hyperparameter_metric_tag='val_mse',
    metric_value=avg_val_mse,
    global_step=epochs        
)

一旦我们将训练作业提交到 AI 平台,我们就可以在云控制台中监控日志。在每次试验完成后,您将能够看到为每个超参数选择的值以及您优化度量标准的结果值,如图 4-25 所示。

AI 平台控制台中 HyperTune 摘要的示例。这是一个 PyTorch 模型优化三个模型参数的摘要,目标是在验证数据集上最小化均方误差。

图 4-25. AI 平台控制台中 HyperTune 摘要的示例。这是一个 PyTorch 模型优化三个模型参数的摘要,目标是在验证数据集上最小化均方误差。

默认情况下,AI 平台训练将使用贝叶斯优化来进行调优作业,但您也可以指定是否希望改用网格或随机搜索算法。云服务还通过多个训练作业优化您的超参数搜索 训练作业。如果我们运行另一个类似上述作业的训练作业,但对超参数和搜索空间进行了一些微调,它将利用上次作业的结果,有效地为下一组试验选择数值。

我们在这里展示了一个 PyTorch 的例子,但您可以通过打包您的训练代码并提供一个 setup.py 文件来安装任何库依赖项,利用 AI 平台训练进行任何机器学习框架的超参数调整。

遗传算法

我们探索了多种超参数优化算法:手动搜索、网格搜索、随机搜索和贝叶斯优化。另一个不太常见的选择是遗传算法,它大致基于查尔斯·达尔文的进化理论,即“适者生存”。该理论认为,群体中表现最好(“适者”)的成员将生存下来,并将其基因传给未来的后代,而表现较差的成员则不会。遗传算法已应用于不同类型的优化问题,包括超参数调整。

关于超参数搜索,遗传算法首先通过定义一个 适应度函数 来工作。该函数衡量特定试验的质量,通常可以由您模型的优化度量(准确率、误差等)来定义。在定义适应度函数后,您随机选择几组超参数的组合,并针对每个组合运行一次试验。然后,您选择表现最佳的试验的超参数,并使用这些值来定义您的新搜索空间。这个搜索空间成为您的新“种群”,您可以使用它生成新的数值组合,用于下一组试验。您可以继续这个过程,逐渐减少您运行的试验数量,直到达到满足您需求的结果。

由于它们利用先前试验的结果来改进,遗传算法比手动搜索、网格搜索和随机搜索更“智能”。然而,当超参数搜索空间很大时,遗传算法的复杂性增加。与贝叶斯优化中使用代理函数作为模型训练的代理不同,遗传算法需要为每种可能的超参数值组合训练您的模型。此外,截至撰写本文时,遗传算法较不常见,支持它们进行超参数调优的 ML 框架也较少。

本章重点讨论了修改机器学习典型 SGD 训练循环的设计模式。我们首先看了有用的过拟合 模式,涵盖了过拟合有益的情况。例如,当使用数据驱动方法如机器学习来近似解复杂动力系统或 PDEs 时,目标是在训练集上过拟合。过拟合也是在开发和调试 ML 模型架构时的一种有用技术。接下来,我们讨论了模型检查点 及其在训练 ML 模型时的使用方法。在这种设计模式中,我们定期保存模型的完整状态。这些检查点可以作为最终模型使用,例如在早停止的情况下,或者在训练失败或微调时作为起始点使用。

迁移学习 设计模式涵盖了重新使用先前训练模型的部分内容。当您自己的数据集有限时,迁移学习是利用预训练模型学习特征提取层的有效方法。它还可以用于对在大规模通用数据集上训练的预训练模型进行微调,以适应更专业的数据集。接着,我们讨论了分布策略 设计模式。训练大型复杂神经网络可能需要相当长的时间。分布策略提供了多种方式,可以修改训练循环,通过并行化和硬件加速器在多个工作节点上扩展执行。

最后,超参数调整 设计模式讨论了如何优化 SGD 训练循环本身,以适应模型的超参数。我们看到了一些有用的库,可以用来为使用 Keras 和 PyTorch 创建的模型实现超参数调整。

下一章将探讨将模型投入生产时,与韧性(对大量请求、尖峰流量或变更管理)相关的设计模式。

¹ 当然,并不一定是我们可以使用梯度下降来学习网络,仅仅因为存在这样一个神经网络(这就是为什么通过增加层次改变模型架构有帮助——这使得损失函数的形态更适合 SGD)。

² MLPerf v0.7 训练 Closed ResNet。来源于 www.mlperf.org 2020 年 9 月 23 日,条目 0.7-67. MLPerf 名称和标识是商标。有关详细信息,请参阅 www.mlperf.org。

³ Jia Deng 等人,“ImageNet: A Large-Scale Hierarchical Image Database”,IEEE 计算机学会计算机视觉与模式识别会议(CVPR)(2009 年):248–255。

⁴ 更多信息,请参阅 “CS231n Convolutional Neural Networks for Visual Recognition.”

⁵ Victor Campos 等人,《计算科学国际会议 ICCS 2017 上的计算机视觉深度学习算法分布式训练策略》,2017 年 6 月 12–14 日。

⁶ 同上。

⁷ Jeffrey Dean 等人,《大规模分布式深度网络》,NIPS 会议论文集(2012)。

⁸ Priya Goyal 等人,《准确的大批量随机梯度下降:在 1 小时内训练 ImageNet》(2017),arXiv:1706.02677v2 [cs.CV].****

第五章:弹性服务的设计模式

机器学习模型的目的是在训练期间未见过的数据上进行推断。因此,一旦模型训练完毕,通常会将其部署到生产环境中,并用于根据传入请求进行预测。部署到生产环境中的软件预期是具有弹性的,并且在保持运行时需要很少的人工干预。本章中的设计模式解决了与生产 ML 模型的弹性相关的不同情况下的问题。

Stateless Serving Function设计模式允许服务基础架构扩展和处理每秒数千甚至数百万的预测请求。Batch Serving设计模式允许服务基础架构异步处理偶发或周期性的数百万到数十亿次预测请求。这些模式不仅在提高弹性方面有用,而且还减少了机器学习模型创建者和用户之间的耦合。

持续模型评估设计模式处理了检测部署模型不再适用的常见问题。两阶段预测设计模式提供了一种解决方案,用于在部署到分布式设备时保持模型的复杂性和性能。键控预测设计模式是实现本章讨论的多个设计模式的必要条件。

设计模式 16:无状态服务功能

无状态服务功能设计模式使得生产 ML 系统能够同步处理每秒数千到数百万的预测请求。生产 ML 系统围绕一个捕获训练模型体系结构和权重的无状态函数进行设计。

问题

让我们来看一个文本分类模型,该模型使用来自互联网电影数据库(IMDb)的电影评论作为其训练数据。对于模型的初始层,我们将使用一个预训练的嵌入,将文本映射到 20 维嵌入向量(有关完整代码,请参见serving_function.ipynb notebook 在本书的 GitHub 存储库中):

model = tf.keras.Sequential()
embedding = (
        "https://tfhub.dev/google/tf2-preview/gnews-swivel-20dim-with-oov/1")
hub_layer = hub.KerasLayer(embedding, input_shape=[], 
                           dtype=tf.string, `trainable``=``True``,` name='full_text')
model.add(hub_layer)
model.add(tf.keras.layers.Dense(16, activation='relu', name='h1_dense'))
model.add(tf.keras.layers.Dense(1, name='positive_review_logits'))

嵌入层来自 TensorFlow Hub,并标记为可训练,以便我们可以对 IMDb 评论中的词汇进行微调(详见“设计模式 13:迁移学习”在第四章中)。随后的层是一个简单的神经网络,具有一个隐藏层和一个输出对数层。然后,这个模型可以在电影评论数据集上进行训练,以学习预测评论是正面还是负面。

一旦模型训练完毕,我们可以使用它进行推断,判断评论的积极程度:

review1 = 'The film is based on a prize-winning novel.'
review2 = 'The film is fast moving and has several great action scenes.'
review3 = 'The film was very boring. I walked out half-way.'
logits = `model``.``predict`(x=tf.constant([review1, review2, review3]))

结果是一个可能类似于的二维数组:

[[ 0.6965847]
 [ 1.61773  ]
 [-0.7543597]]

在内存对象(或加载到内存中的可训练对象)上调用 model.predict() 进行推断存在几个问题,正如前面代码片段所述:

  • 我们必须将整个 Keras 模型加载到内存中。文本嵌入层设置为可训练,可能会很大,因为它需要存储英文词汇的嵌入。具有许多层的深度学习模型也可能会很大。

  • 前述架构对可达到的延迟施加了限制,因为必须逐个调用 predict() 方法。

  • 尽管数据科学家选择的编程语言是 Python,但模型推断可能会由偏好其他语言的开发人员编写的程序调用,或者在需要不同语言的移动平台(如 Android 或 iOS)上调用。

  • 用于训练最有效的模型输入和输出可能不够用户友好。在我们的例子中,模型输出为 logits,因为它对于梯度下降更好。这就是为什么输出数组中的第二个数字大于 1 的原因。客户通常希望对其进行 sigmoid 处理,使输出范围为 0 到 1,并以更用户友好的格式解释。我们希望在服务器上进行这种后处理,以便客户端代码尽可能简单。类似地,模型可能已经是从压缩的二进制记录中训练出来的,而在生产环境中,我们可能希望能够处理像 JSON 这样的自描述输入格式。

解决方案

解决方案由以下步骤组成:

  1. 将模型导出为捕获模型数学核心且与编程语言无关的格式。

  2. 在生产系统中,“前向”计算模型的公式被恢复为无状态函数。

  3. 无状态函数部署到提供 REST 端点的框架中。

模型导出

解决方案的第一步是将模型导出为一种格式(TensorFlow 使用 SavedModel,但 ONNX 是另一个选择),该格式捕获了模型的数学核心。整个模型状态(学习率、丢失率、短路等)不需要保存,只需计算输出所需的数学公式。通常,训练后的权重值在数学公式中是常数。

在 Keras 中,通过以下方式实现:

model.save('export/mymodel')

SavedModel 格式依赖于协议缓冲区以实现跨平台、高效的恢复机制。换句话说,model.save()方法将模型写入协议缓冲区(扩展名为.pb),并将训练好的权重、词汇表等外部化到其他文件中,采用标准的目录结构:

*`export``/``.``.``.``/``variables``/``variables``.``data``-``00000``-``of``-``00001`*
*`export``/``.``.``.``/``assets``/``tokens``.``txt`*
*`export``/``.``.``.``/``saved_model``.``pb`*

Python 中的推断

在生产系统中,模型的公式从协议缓冲区及其他相关文件中恢复为一个无状态函数,该函数符合特定的模型签名,包括输入和输出变量名及数据类型。

我们可以使用 TensorFlow 的 saved_model_cli 工具检查导出文件,以确定我们可以在服务中使用的无状态函数的签名:

saved_model_cli show --dir ${export_path} \
     --tag_set serve --signature_def serving_default

这样就输出了:

The given SavedModel SignatureDef contains the following input(s):
  inputs['full_text_input'] tensor_info:
      dtype: DT_STRING
      shape: (-1)
      name: serving_default_full_text_input:0
The given SavedModel SignatureDef contains the following output(s):
  outputs['positive_review_logits'] tensor_info:
      dtype: DT_FLOAT
      shape: (-1, 1)
      name: StatefulPartitionedCall_2:0
Method name is: tensorflow/serving/predict

签名指定预测方法接受一个作为输入的单元素数组(称为full_text_input),其类型为字符串,并输出一个名为positive_review_logits的浮点数。这些名称来自我们分配给 Keras 层的名称:

hub_layer = hub.KerasLayer(..., name=`'``full_text``'`)
...
model.add(tf.keras.layers.Dense(1, name=`'``positive_review_logits``'`))

下面是如何获取服务函数并用于推断:

serving_fn = tf.keras.models.load_model(export_path). \
                     signatures[`'``serving_default``'`]
outputs = serving_fn(`full_text_input`=
                     tf.constant([review1, review2, review3]))
logit = outputs['positive_review_logits']

注意我们在代码中使用了服务函数的输入和输出名称。

创建 Web 端点

上述代码可以放入 Web 应用程序或无服务器框架,如 Google App Engine、Heroku、AWS Lambda、Azure Functions、Google Cloud Functions、Cloud Run 等。所有这些框架的共同点是它们允许开发人员指定需要执行的函数。这些框架会自动扩展基础架构,以处理每秒大量预测请求,并保持低延迟。

例如,我们可以在 Cloud Functions 中调用服务函数如下:

serving_fn = None
def handler(request):
    global serving_fn
    if serving_fn is None:
        serving_fn = (tf.keras.models.load_model(export_path)
                              .signatures['serving_default'])
    request_json = request.get_json(silent=True)
    if request_json and 'review' in request_json:
        review = request_json['review']
        outputs = serving_fn(`full_text_input`=tf.constant([review]))
        return outputs[`'``positive_review_logits``'`] 

注意,我们应该谨慎地将服务函数定义为全局变量(或单例类),以免在每个请求的响应中重新加载它。实际上,服务函数将仅在冷启动时从导出路径(在 Google Cloud Storage 上)重新加载。

为何有效

将模型导出为无状态函数,并在 Web 应用程序框架中部署这一方法之所以有效,是因为 Web 应用程序框架提供了自动扩展功能,可以完全托管,并且与编程语言无关。对于可能缺乏机器学习经验的软件和业务开发团队来说,这些框架也是熟悉的。对于敏捷开发也有好处——机器学习工程师或数据科学家可以独立更改模型,而应用程序开发人员只需更改他们正在访问的端点。

自动扩展

将 Web 端点扩展到每秒数百万次请求是一个众所周知的工程问题。与其构建专门用于机器学习的服务,不如依赖几十年来在构建可靠 Web 应用和 Web 服务器方面所做的工程工作。云服务提供商知道如何高效地自动扩展 Web 端点,且热启动时间最小。

我们甚至不需要自己编写服务系统。大多数现代企业机器学习框架都配备了服务子系统。例如,TensorFlow 提供 TensorFlow Serving,PyTorch 提供 TorchServe。如果使用这些服务子系统,我们只需提供导出的文件,软件便会自动创建 Web 端点。

完全托管

云平台也抽象化了 TensorFlow Serving 等组件的管理和安装。因此,在 Google Cloud 上,将服务功能部署为 REST API 就像运行这个命令行程序一样简单,只需提供 SavedModel 输出的路径:

gcloud ai-platform versions create ${MODEL_VERSION} \
       --model ${MODEL_NAME} --origin ${MODEL_LOCATION} \
       --runtime-version $TFVERSION

在 Amazon 的 SageMaker 中,部署 TensorFlow SavedModel 同样简单,使用如下命令实现:

model = Model(model_data=MODEL_LOCATION, role='SomeRole')
predictor = model.deploy(initial_instance_count=1,
                         instance_type='ml.c5.xlarge')

在设置了 REST 端点后,我们可以发送一个 JSON 预测请求,格式如下:

{"instances":
  [
      {"reviews": "The film is based on a prize-winning novel."},
      {"reviews": "The film is fast moving and has several great action scenes."},
      {"reviews": "The film was very boring. I walked out half-way."}
  ]
}

我们返回的预测值也被封装在 JSON 结构中:

{"predictions": [{ "positive_review_logits": [0.6965846419334412]},
                 {"positive_review_logits": [1.6177300214767456]},
                 {"positive_review_logits": [-0.754359781742096]}]}
提示

通过允许客户端发送带有多个实例的 JSON 请求,即所谓的 批处理,我们允许客户端在较少的网络调用和发送更多请求时增加并行化之间进行权衡。

除了批处理,还有其他方法可以提高性能或降低成本。例如,使用更强大的 GPU 通常有助于提升深度学习模型的性能。选择具有多个加速器和/或线程的机器有助于提高每秒请求的数量。使用自动扩展的机器集群可以帮助降低尖峰工作负载的成本。这些调整通常由 ML/DevOps 团队完成;其中一些是特定于机器学习的,另一些则不是。

语言中立

所有现代编程语言都可以使用 REST,且提供了一个发现服务来自动生成必要的 HTTP 存根。因此,Python 客户端可以如下调用 REST API。请注意,下面的代码没有特定于框架的内容。因为云服务抽象了我们的 ML 模型的具体细节,我们不需要提供任何关于 Keras 或 TensorFlow 的引用:

credentials = GoogleCredentials.get_application_default()
api = discovery.build("ml", "v1", credentials = credentials,
            discoveryServiceUrl = "https://storage.googleapis.com/cloud-
ml/discovery/ml_v1_discovery.json")

request_data = {"instances":
 [
  {"reviews": "The film is based on a prize-winning novel."},
  {"reviews": "The film is fast moving and has several great action scenes."},
  {"reviews": "The film was very boring. I walked out half-way."}
 ]
}

parent = "projects/{}/models/imdb".format("PROJECT", "v1")
response = api.projects().predict(body = request_data, 
                                  name = parent).execute()

上述 上述代码的等效写法可以用多种语言实现(我们展示 Python 是因为我们假设你对它有所了解)。在本书撰写时,开发者可以从 Java、PHP、.NET、JavaScript、Objective-C、Dart、Ruby、Node.js 和 Go 中访问 Discovery API

强大的生态系统

由于 Web 应用程序框架被广泛使用,有很多工具可用于测量、监视和管理 Web 应用。如果我们将 ML 模型部署到 Web 应用程序框架中,可以使用软件可靠性工程师(SREs)、IT 管理员和 DevOps 人员熟悉的工具对模型进行监控和节流。他们不必了解任何关于机器学习的知识。

同样,您的业务发展同事知道如何使用 API 网关对 Web 应用进行计量和货币化。他们可以借鉴这些知识并将其应用于对机器学习模型进行计量和货币化。

折衷和替代方案

就像David Wheeler 的一个笑话所说,计算机科学中任何问题的解决方案都是添加额外的间接层。引入导出的无状态函数规范提供了额外的间接层。无状态服务函数设计模式允许我们更改服务签名以提供额外的功能,如超出 ML 模型功能的额外预处理和后处理。事实上,可以使用此设计模式为模型提供多个端点。此设计模式还有助于为在数据仓库等长时间运行查询系统上训练的模型创建低延迟的在线预测。

自定义服务函数

我们文本分类模型的输出层是一个 Dense 层,其输出范围为(-∞,∞):

model.add(tf.keras.layers.Dense(1, name='positive_review_logits'))

我们的损失函数考虑到了这一点:

model.compile(optimizer='adam',
              loss=tf.keras.losses.BinaryCrossentropy(
                      from_logits=True),
              metrics=['accuracy'])

当我们用该模型进行预测时,模型自然返回其训练预测的内容并输出 logits。然而,客户期望的是评论为积极的概率。为了解决这个问题,我们需要返回模型的 sigmoid 输出。

我们可以通过编写一个自定义服务函数并导出它来实现这一点。这里是一个在 Keras 中的自定义服务函数,它添加了一个概率并返回一个包含每个输入评论的 logits 和概率的字典:

@tf.function(input_signature=[tf.TensorSpec([None], 
                              dtype=tf.string)])
def add_prob(`reviews`):
    logits = model(reviews, training=False) # call model
    probs = tf.sigmoid(logits)
    return {
        'positive_review_logits' : logits,
        'positive_review_probability' : probs
    }

然后我们可以将以上函数导出为默认服务:

model.save(export_path, 
           signatures={'serving_default': add_prob})

add_prob方法定义保存在导出路径中,并将在响应客户请求时被调用。

导出模型的服务签名反映了新的输入名称(请注意add_prob的输入参数名称)和输出字典键及其数据类型:

The given SavedModel SignatureDef contains the following input(s):
  inputs['reviews'] tensor_info:
      dtype: DT_STRING
      shape: (-1)
      name: serving_default_reviews:0
The given SavedModel SignatureDef contains the following output(s):
  outputs['positive_review_logits'] tensor_info:
      dtype: DT_FLOAT
      shape: (-1, 1)
      name: StatefulPartitionedCall_2:0
  outputs['positive_review_probability'] tensor_info:
      dtype: DT_FLOAT
      shape: (-1, 1)
      name: StatefulPartitionedCall_2:1
Method name is: tensorflow/serving/predict

当此模型部署并用于推断时,输出的 JSON 包含 logits 和概率:

{'predictions': [
   {'positive_review_probability': [0.6674301028251648], 
    'positive_review_logits': [0.6965846419334412]}, 
   {`'``p``o``s``i``t``i``v``e``_``r``e``v``i``e``w``_``p``r``o``b``a``b``i``l``i``t``y``'``:` `[``0``.``8``3``4``4``8``1``8``3``5``3``6``5``2``9``5``4``]`,
    'positive_review_logits': [1.6177300214767456]}, 
   {'positive_review_probability': [0.31987208127975464], 
    'positive_review_logits': [-0.754359781742096]}
]}

请注意,add_prob是我们编写的一个函数。在这种情况下,我们对输出进行了一些后处理。但是,我们可以在该函数内进行几乎任何(无状态的)操作。

多个签名

模型通常支持多个目标或有不同需求的客户端。虽然输出字典可以让不同的客户端取出他们想要的任何东西,但在某些情况下可能不是理想的。例如,我们必须调用的函数以从 logits 获取概率只是tf.sigmoid()。这相当便宜,即使对于会丢弃它的客户端也没有问题。另一方面,如果该函数很昂贵,在不需要该值的客户端上计算它会增加相当大的开销。

如果少数客户端需要非常昂贵的操作,则有助于提供多个服务签名,并让客户端告知服务框架要调用哪个签名。这是通过在导出模型时指定一个非serving_default的名称来完成的。例如,我们可以使用以下方式写出两个签名:

model.save(export_path, signatures={
        'serving_default': func1,
        'expensive_result': func2,
   })

然后,输入 JSON 请求包括签名名称,以选择所需模型的哪个服务端点:

{
  "signature_name": "expensive_result",
   {"instances": …}
}

在线预测

因为导出的服务函数最终只是一个文件格式,所以在原始机器学习训练框架不原生支持在线预测时,它可以用来提供在线预测能力。

例如,我们可以通过在新生儿数据集上训练逻辑回归模型来推断婴儿是否需要关注:

CREATE OR REPLACE MODEL 
 mlpatterns.neutral_3classes OPTIONS(model_type='logistic_reg', 
  input_label_cols=['health']) AS
SELECT 
IF
 (apgar_1min = 10, 
  'Healthy',
 IF
  (apgar_1min >= 8, 
   'Neutral', 
   'NeedsAttention')) AS health,
 plurality,
 mother_age,
 gestation_weeks,
 ever_born
FROM 
 `bigquery-public-data.samples.natality`
WHERE 
 apgar_1min <= 10

模型训练完成后,我们可以使用 SQL 进行预测:

SELECT * FROM ML.PREDICT(MODEL mlpatterns.neutral_3classes,
    (SELECT 
     2 AS plurality,
     32 AS mother_age,
     41 AS gestation_weeks,
     1 AS ever_born
    )
)

然而,BigQuery 主要用于分布式数据处理。虽然用它来训练千兆字节数据上的 ML 模型很棒,但在单行数据上进行推断并不是最佳选择——延迟可能高达一到两秒。相反,ML.PREDICT功能更适合批量服务。

为了进行在线预测,我们可以要求 BigQuery 将模型导出为 TensorFlow SavedModel:

bq extract -m --destination_format=`ML_TF_SAVED_MODEL` \
     mlpatterns.neutral_3classes  gs://${BUCKET}/export/baby_health

现在,我们可以将 SavedModel 部署到像 Cloud AI Platform 这样支持 SavedModel 的服务框架中,以获得低延迟、自动扩展的 ML 模型服务优势。查看 GitHub 中的notebook获取完整代码。

即使不存在将模型导出为 SavedModel 的能力,我们也可以提取权重,编写数学模型来执行线性模型,将其容器化,并部署容器映像到服务平台。

预测库

而不是将服务函数部署为可以通过 REST API 调用的微服务,可以将预测代码实现为库函数。库函数在第一次调用时加载导出的模型,使用提供的输入调用model.predict(),并返回结果。需要在应用程序中进行预测的应用程序开发人员可以包含该库。

如果模型由于物理原因(没有网络连接)或性能约束而无法通过网络调用,则库函数是比微服务更好的选择。库函数方法还将计算负担放在客户端上,从预算的角度来看,这可能更可取。在希望在浏览器中运行模型时,使用TensorFlow.js的库方法可以避免跨站点问题。

库方法的主要缺点是模型的维护和更新困难——所有使用模型的客户端代码都必须更新为使用新版本的库。模型更新得越频繁,微服务方法就越有吸引力。第二个缺点是,库方法仅限于已编写库的编程语言,而 REST API 方法则可以打开模型给几乎任何现代编程语言编写的应用。

库开发者应注意使用线程池和并行化来支持所需的吞吐量。然而,通常存在此方法可达到的可扩展性限制。

设计模式 17:批量服务

批量服务设计模式使用通常用于分布式数据处理的软件基础设施,一次性对大量实例进行推断。

问题

通常情况下,预测是按需单独进行的。无论信用卡交易是否欺诈性,在处理付款时确定。无论婴儿是否需要重症监护,都是在婴儿出生后立即检查时确定的。因此,当你将模型部署到机器学习服务框架时,它被设置为处理一个实例,或者最多几千个实例,嵌入在一个单一请求中。

正如在“设计模式 16:无状态服务功能”中讨论的那样,服务框架的架构设计为同步尽可能快速地处理单个请求。服务基础设施通常设计为微服务,将重计算(如深度卷积神经网络)卸载到高性能硬件,如张量处理单元(TPU)或图形处理单元(GPU),并最小化与多个软件层相关的低效率。

然而,有些情况下需要异步地对大量数据进行预测。例如,确定是否重新订购库存商品(SKU)可能是每小时执行一次的操作,而不是每次 SKU 在收银机上被购买时都执行。音乐服务可能为每位用户创建个性化的每日播放列表并将其推送给这些用户。个性化播放列表不是根据用户与音乐软件的每次交互按需创建的。因此,机器学习模型需要一次对数百万个实例进行预测,而不是逐个实例进行预测。

尝试将设计为一次处理一个请求的软件端点发送到数百万个 SKU 或数十亿用户将会使机器学习模型不堪重负。

解决方案

批量服务设计模式使用分布式数据处理基础设施(MapReduce、Apache Spark、BigQuery、Apache Beam 等)异步地对大量实例进行机器学习推断。

在讨论无状态服务功能设计模式时,我们训练了一个文本分类模型,用于输出一个评价是正面还是负面。假设我们想将这个模型应用于向美国消费者金融保护局(CFPB)投过投诉的每一个投诉。

我们可以将 Keras 模型加载到 BigQuery 中,操作如下(完整代码在GitHub 的笔记本中提供):

CREATE OR REPLACE MODEL mlpatterns.imdb_sentiment
OPTIONS(`model_type``=``'tensorflow'`, model_path='gs://.../*')

通常情况下,人们会使用 BigQuery 中的数据来训练模型,但在这里我们只是加载一个外部训练好的模型。尽管如此,仍然可以使用 BigQuery 进行机器学习预测。例如,SQL 查询。

SELECT * FROM `ML``.``PREDICT`(MODEL mlpatterns.imdb_sentiment,
  (SELECT 'This was very well done.' AS reviews)
)

返回了positive_review_probability为 0.82 的概率。

使用像 BigQuery 这样的分布式数据处理系统进行一次性预测效率不高。但是,如果我们想将机器学习模型应用于消费者金融保护局(CFPB)数据库中的每一条投诉,怎么办?¹ 我们只需简单地调整上述查询,确保在内部SELECT中将consumer_complaint_narrative列别名为要评估的reviews

SELECT * FROM ML.PREDICT(MODEL mlpatterns.imdb_sentiment,
  (SELECT consumer_complaint_narrative AS reviews 
   FROM `bigquery-public-data`.cfpb_complaints.complaint_database
   WHERE consumer_complaint_narrative IS NOT NULL
   )
)

数据库有超过 150 万条投诉,但处理时间大约为 30 秒,证明了使用分布式数据处理框架的好处。

它为什么有效

无状态服务函数设计模式旨在支持数千个同时查询的低延迟服务。对数百万个项目进行偶尔或定期处理时,使用这样的框架会变得非常昂贵。如果这些请求不受延迟影响,使用分布式数据处理架构来调用数百万个项目上的机器学习模型更具成本效益。原因在于,在数百万个项目上调用 ML 模型是一个尴尬并行问题——可以将百万个项目分成 1000 组,每组 1000 个项目,将每组项目发送到一台机器,然后组合结果。项目编号 2000 上的机器学习模型的结果与项目编号 3000 上的机器学习模型的结果完全独立,因此可以分割工作并处理。

例如,查询查找五个最积极投诉的情况如下:

WITH all_complaints AS (
SELECT * FROM ML.PREDICT(MODEL mlpatterns.imdb_sentiment,
  (SELECT consumer_complaint_narrative AS reviews 
   FROM `bigquery-public-data`.cfpb_complaints.complaint_database
   WHERE consumer_complaint_narrative IS NOT NULL
   )
)
)
SELECT * FROM all_complaints 
ORDER BY positive_review_probability DESC LIMIT 5

在 BigQuery Web 控制台的执行详情中,我们看到整个查询花费了 35 秒(参见图 5-1 中标记为#1 的方框)。

在消费者金融保护局消费者投诉数据集中查找五个最“积极”投诉的查询的前两个步骤。

图 5-1. 在消费者金融保护局消费者投诉数据集中查找五个最“积极”投诉的查询的前两个步骤。

第一步(参见图 5-1 中的方框#2)从 BigQuery 公共数据集中读取consumer_complaint_narrative列,其中投诉叙述不为NULL。从方框#3 中突出显示的行数中,我们了解到这涉及读取 1582045 个值。该步骤的输出写入 10 个分片(参见图 5-1 中的方框#4)。

第二步从这个分片中读取数据(注意查询中的$12:shard),但也获取机器学习模型imdb_sentimentfile_pathfile_contents,并将模型应用于每个分片中的数据。 MapReduce 的工作方式是每个分片由一个工作器处理,因此存在 10 个分片表明第二步由 10 个工作器执行。原始的 150 万行可能存储在许多文件中,因此第一步很可能由构成该数据集的文件数量相同的工作器处理。

剩余步骤在图 5-2 中显示。

查询的第三步及后续步骤,以找出五个最“积极”的投诉。

图 5-2. 查询的第三步及后续步骤,以找出五个最“积极”的投诉。

第三步将数据集按降序排序并取前五个。每个工人都会执行这个步骤,因此每个 10 个工人都会在“它们”的片段中找到 5 个最积极的投诉。剩余的步骤检索并格式化剩余的数据片段,并将其写入输出。

最后一步(未显示)将这 50 个投诉进行排序,并选择其中的 5 个作为实际结果。能够跨多个工人这样分割工作,正是使得 BigQuery 能够在 35 秒内处理完 150 万份投诉文件的关键。

权衡和替代方案

批量服务设计模式依赖于能够将任务分割成多个工人处理的能力。因此,它不仅限于数据仓库或 SQL。任何 MapReduce 框架都可以使用。然而,SQL 数据仓库往往是最简单的选择,尤其是在数据结构化的情况下。

尽管批量服务用于不关注延迟的情况,但可以将预计算结果和定期刷新结合起来,以便在预测输入空间有限的情况下使用。

批处理和流水线

像 Apache Spark 或 Apache Beam 这样的框架在需要预处理输入数据以供模型使用、如果机器学习模型输出需要后处理、或者预处理或后处理难以用 SQL 表达时非常有用。如果模型的输入是图像、音频或视频,则无法使用 SQL,必须使用能够处理非结构化数据的数据处理框架。这些框架还可以利用加速硬件如 TPU 和 GPU 来对图像进行预处理。

使用 Apache Beam 这类框架的另一个理由是客户端代码需要维护状态。客户端需要维护状态的一个常见原因是,如果 ML 模型的输入之一是时间窗口平均值。在这种情况下,客户端代码必须对传入的数据流执行移动平均,并将移动平均值提供给 ML 模型。

假设我们正在构建一个评论审核系统,并希望拒绝那些每天多次评论特定人物的人。例如,如果评论者第一次写关于奥巴马总统的事情,我们会放过他,但是对于当天的其他关于奥巴马的评论,我们会禁止该评论者继续提及。这是一个需要维护状态的后处理示例,因为我们需要计算每个评论者提及特定名人的次数。此外,这个计数器需要在一个 24 小时的滚动时间段内进行。

我们可以使用能够维护状态的分布式数据处理框架来完成这个过程。Apache Beam 就是这样一个例子。从 Apache Beam 中调用 ML 模型以识别名人的提及,并将其与规范化的知识图(例如,提及 Obama 和 President Obama 都与en.wikipedia.org/wiki/Barack_Obama相关联)进行关联,可以通过以下方式完成(参见 GitHub 上的notebook获取完整代码):

 | beam.Map(lambda x : nlp.Document(x, type='PLAIN_TEXT'))
 | nlp.AnnotateText(features)
 | beam.Map(parse_nlp_result)

其中parse_nlp_result解析通过AnnotateText变换传递的 JSON 请求,该变换在底层调用 NLP API。

批量服务的缓存结果

我们讨论了批量服务作为在通常使用无状态服务函数设计模式在线提供模型时,调用模型处理数百万项的一种方法。当然,即使模型不支持在线服务,批量服务也可以运行。重要的是,进行推断的机器学习框架能够利用尴尬并行处理。

例如,推荐引擎需要填写由每个用户-项目对组成的稀疏矩阵。一个典型的企业可能有 1000 万的历史用户和 10000 个产品目录中的项目。为了为用户做出推荐,必须为这 10000 个项目计算推荐分数,排名并将前 5 个呈现给用户。这在几乎实时的服务函数中是不可行的。然而,近乎实时的需求意味着简单地使用批量服务也不起作用。

在这种情况下,使用批量服务预先计算所有 1000 万用户的推荐:

SELECT
  *
FROM
  ML.RECOMMEND(MODEL mlpatterns.recommendation_model)

将其存储在诸如 MySQL、Datastore 或 Cloud Spanner 之类的关系型数据库中(有预构建的传输服务和 Dataflow 模板可以完成此操作)。当任何用户访问时,从数据库中获取该用户的推荐内容,并立即以极低的延迟提供。

在后台,推荐定期刷新。例如,我们可以根据网站上最新的操作每小时重新训练推荐模型。然后,我们可以仅针对最近一小时访问的用户进行推断:

SELECT
  *
FROM
  ML.RECOMMEND(MODEL mlpatterns.recommendation_model,
    (
    SELECT DISTINCT
      visitorId
    FROM
      mlpatterns.analytics_session_data
    WHERE
      visitTime > TIME_DIFF(CURRENT_TIME(), 1 HOUR)
    ))

我们接着更新用于服务的关系型数据库中的相应行。

Lambda 架构

支持在线服务和批量服务的生产 ML 系统称为Lambda 架构 ——这样的生产 ML 系统允许 ML 从业者在延迟(通过无状态服务功能模式)和吞吐量(通过批量服务模式)之间进行权衡。

注意

AWS Lambda,尽管名字如此,不是 Lambda 架构。它是一个无服务器框架,用于扩展无状态函数,类似于 Google Cloud Functions 或 Azure Functions。

通常,Lambda 架构通过拥有在线服务和批处理服务的分离系统来支持。例如,在 Google Cloud 中,在线服务基础设施由 Cloud AI Platform Predictions 提供,而批处理服务基础设施由 BigQuery 和 Cloud Dataflow 提供(Cloud AI Platform Predictions 提供了方便的界面,使用户不必显式使用 Dataflow)。可以将 TensorFlow 模型导入 BigQuery 进行批处理服务。还可以将训练好的 BigQuery ML 模型导出为 TensorFlow SavedModel 用于在线服务。这种双向兼容性使得 Google Cloud 的用户能够在延迟和吞吐量之间的任何点上进行权衡。

设计模式 18:持续模型评估

持续模型评估设计模式处理了一个常见问题,即在部署的模型不再适用时需要检测并采取行动。

问题

所以,你已经训练好了你的模型。你收集了原始数据,进行了清洗,设计了特征,创建了嵌入层,调优了超参数,整个过程都做到了。你在留存测试集上能够达到 96%的准确率。太棒了!你甚至经历了将模型部署的繁琐过程,把它从 Jupyter 笔记本转换为生产中的机器学习模型,并通过 REST API 提供预测服务。恭喜你,你成功了。你完成了!

嗯,并不完全是这样。部署并不是机器学习模型生命周期的终点。你怎么知道你的模型在实际应用中表现如预期?如果输入数据出现意外变化怎么办?或者模型不再产生准确或有用的预测了?这些变化如何被检测到?

世界是动态的,但通常开发机器学习模型会根据历史数据创建静态模型。这意味着一旦模型投入生产,它可能会开始退化,其预测可能变得越来越不可靠。模型随时间退化的两个主要原因是概念漂移和数据漂移。

概念漂移发生在模型输入与目标之间的关系发生变化时。这经常发生是因为您的模型的基本假设发生了变化,例如针对学习对抗性或竞争性行为的模型,比如欺诈检测、垃圾邮件过滤、股票市场交易、在线广告竞价或网络安全。在这些场景中,预测模型旨在识别特定活动的特征模式(或非期望活动),而对手则学会适应并可能随着情况改变其行为。例如,考虑开发用于检测信用卡欺诈的模型。随着人们使用信用卡的方式随时间改变,信用卡欺诈的常见特征也发生了变化。例如,当“芯片和 PIN”技术推出时,欺诈交易开始更多地发生在线。随着欺诈行为的适应,在此技术之前开发的模型性能突然开始下降,并且模型预测将变得不太准确。

模型性能随时间降低的另一个原因是数据漂移。我们在“机器学习中的常见挑战”一文中介绍了数据漂移问题,该章节位于第一章中。数据漂移指的是与用于训练模型的数据相比,用于模型预测的数据发生的任何变化。数据漂移可能由多种原因引起:输入数据架构在源头发生变化(例如,上游添加或删除字段),特征分布随时间改变(例如,由于附近开设滑雪度假村,医院可能开始看到更多年轻成年人),或者即使结构/架构没有变化,数据的含义也发生了变化(例如,随着时间推移,一个病人是否被视为“超重”可能会改变)。软件更新可能引入新的错误,或者业务场景变化并创建了以前在训练数据中不可用的新产品标签。用于构建、训练和预测 ML 模型的 ETL 流水线可能脆弱且不透明,这些变化中的任何一种都会对模型的性能产生重大影响。

模型部署是一个持续的过程,要解决概念漂移或数据漂移问题,有必要使用新鲜数据更新您的训练数据集并重新训练模型以改进预测结果。但是如何知道何时需要重新训练?以及多久重新训练一次?数据预处理和模型训练可能在时间和金钱上都很昂贵,模型开发周期的每个步骤都增加了开发、监控和维护的额外开销。

解决方案

最直接识别模型退化的方法是持续监视模型随时间的预测性能,并使用与开发期间使用的相同评估指标评估该性能。这种持续模型评估和监控是我们确定模型或我们对模型所做任何更改是否正常工作的方法。

概念

这种连续评估需要访问原始预测请求数据、模型生成的预测以及真实数据,全部在同一个地方。Google Cloud AI 平台提供了配置部署模型版本的能力,以便将在线预测的输入和输出定期采样并保存到 BigQuery 中的表中。为了保持服务对每秒大量请求的性能,我们可以通过指定输入请求数量的百分比来自定义采样数据量。为了测量性能指标,有必要将这些保存的预测样本与真实数据进行结合。

在大多数情况下,直到确切的真实标签变得可用之前,可能需要一段时间。例如,对于流失模型,直到下一个订阅周期,才会知道哪些客户已经停止使用其服务。或者对于财务预测模型,真实的收入直到季度结束和收益报告后才会知道。在任何这些情况下,只有在真实数据可用之后,才能进行评估。

要了解连续评估的工作原理,我们将在 Google Cloud AI 平台上部署一个基于 HackerNews 数据集训练的文本分类模型。此示例的完整代码可以在伴随本书的存储库中的 连续评估笔记本 中找到。

部署模型

我们的训练数据集的输入是文章标题,其相关标签是文章来源的新闻源,可以是nytimestechcrunchgithub。随着新闻趋势随时间变化,与纽约时报标题相关的词语也会变化。同样地,新技术产品的发布将影响在 TechCrunch 上找到的词语。连续评估使我们能够监控模型预测,以跟踪这些趋势如何影响我们的模型性能,并在必要时启动重新训练。

假设模型是使用自定义服务输入函数导出的,如 “设计模式 16: 无状态服务函数” 中描述的那样:

@tf.function(input_signature=[tf.TensorSpec([None], dtype=tf.string)])
def source_name(text):
    labels = tf.constant(['github', 'nytimes', 'techcrunch'],dtype=tf.string)
    probs = txtcls_model(text, training=False)
    indices = tf.argmax(probs, axis=1)
    pred_source = tf.gather(params=labels, indices=indices)
    pred_confidence = tf.reduce_max(probs, axis=1)
    return {'source': pred_source,
            'confidence': pred_confidence}

部署了这个模型后,当我们进行在线预测时,模型将返回预测的新闻来源作为字符串值,并且与模型对于该预测标签的置信度相关的数字分数。例如,我们可以通过编写一个名为 input.json 的输入 JSON 示例文件来进行在线预测发送:

%%writefile input.json
{"text": 
"YouTube introduces Video Chapters to make it easier to navigate longer videos"}

这将返回以下预测输出:

CONFIDENCE  SOURCE
0.918685    techcrunch

保存预测

模型部署后,我们可以设置一个任务来保存一部分预测请求的样本——之所以保存样本而不是所有请求,是为了避免不必要地减慢服务系统的速度。我们可以在 Google Cloud AI Platform(CAIP)控制台的连续评估部分进行此操作,通过指定LabelKey(模型输出的列,在我们的例子中将是source,因为我们预测文章的来源)、预测输出中的ScoreKey(一个数值,我们的情况下是confidence)以及在 BigQuery 中存储在线预测请求的表。在我们的示例代码中,该表称为txtcls_eval.swivel。配置完成后,每当进行在线预测时,CAIP 将模型名称、模型版本、预测请求的时间戳、原始预测输入以及模型的输出流到指定的 BigQuery 表中,如 Table 5-1 所示。

Table 5-1. 在 BigQuery 中保存在线预测请求和原始预测输出的比例表

模型 模型版本 时间 原始数据 原始预测 地面真实值
1 txtcls swivel 2020-06-10 01:40:32 UTC {"instances”: [{"text”: “Astronauts Dock With Space Station After Historic SpaceX Launch"}]} {"predictions”: [{"source”: “github”, “confidence”: 0.9994275569915771}]} null
2 txtcls swivel 2020-06-10 01:37:46 UTC {"instances”: [{"text”: “Senate Confirms First Black Air Force Chief"}]} {"predictions”: [{"source”: “nytimes”, “confidence”: 0.9989787340164185}]} null
3 txtcls swivel 2020-06-09 21:21:47 UTC {"instances”: [{"text”: “A native Mac app wrapper for WhatsApp Web"}]} {"predictions”: [{"source”: “github”, “confidence”: 0.745254397392273}]} null

捕获地面真实值

对于发送给模型进行预测的每个实例,捕获地面真实值也是必要的。根据用例和数据可用性,可以通过多种方式进行。一种方法是使用人工标注服务——将发送给模型进行预测的所有实例,或者可能只是对模型信心较低的实例,发送给人工注释。大多数云服务提供商都提供某种形式的人工标注服务,以便以这种方式批量标记实例。

地面真相标签也可以根据用户与模型及其预测的互动方式推导出来。通过让用户采取特定行动,可以获得对模型预测的隐式反馈或生成地面真相标签。例如,当用户在 Google 地图中选择推荐的替代路线之一时,所选路线作为隐式地面真相。更明确地说,当用户对推荐电影进行评分时,这清楚地表明了建立用于预测用户评级以提供推荐的模型的地面真相。类似地,如果模型允许用户更改预测,例如在医疗设置中,当医生能够更改模型建议的诊断时,这提供了地面真相的明确信号。

警告

重要的是要牢记模型预测的反馈循环和捕捉地面真相可能会如何影响未来的训练数据。例如,假设您建立了一个模型来预测何时会放弃购物车。您甚至可以定期检查购物车的状态,以创建模型评估的地面真相标签。然而,如果您的模型建议用户会放弃他们的购物车,并且您提供免费运输或某些折扣以影响他们的行为,那么您将永远不会知道原始模型预测是否正确。简而言之,您违反了模型评估设计的假设,并且需要以其他方式确定地面真相标签。在不同情景下估计特定结果的任务被称为反事实推理,通常在欺诈检测、医学和广告等使用案例中出现,其中模型的预测可能导致某些干预,这些干预可能会模糊该示例的实际地面真相的学习。

评估模型性能

最初,在 BigQuery 中txtcls_eval.swivel表的groundtruth列是空的。一旦可用,我们可以通过直接使用 SQL 命令更新值来提供地面真相标签。当然,在运行评估作业之前,我们应确保地面真相是可用的。请注意,地面真相遵循与模型预测输出相同的 JSON 结构:

UPDATE 
 txtcls_eval.swivel
SET 
 groundtruth = '{"predictions": [{"source": "techcrunch"}]}'
WHERE
 raw_data = '{"instances":
[{"text": "YouTube introduces Video Chapters to help navigate longer
videos"}]}'

更新更多行时,我们会使用MERGE语句而不是UPDATE语句。一旦地面真相已经添加到表中,就可以轻松地检查文本输入和模型预测,并与地面真相(如表 5-2)进行比较:

SELECT
  model,
  model_version,
  time,
  REGEXP_EXTRACT(raw_data, r'.*"text": "(.*)"') AS text,
  REGEXP_EXTRACT(raw_prediction, r'.*"source": "(.*?)"') AS prediction,
  REGEXP_EXTRACT(raw_prediction, r'.*"confidence": (0.\d{2}).*') AS confidence,
  REGEXP_EXTRACT(groundtruth, r'.*"source": "(.*?)"') AS groundtruth,
FROM
  txtcls_eval.swivel

表 5-2. 一旦地面真相可用,它可以添加到原始 BigQuery 表中,并且可以评估模型的性能。

模型 模型版本 时间 文本 预测 置信度 地面真相
1 txtcls swivel 2020-06-10 01:38:13 UTC WhatsApp Web 的本地 Mac 应用程序包装器 github 0.77 github
2 txtcls swivel 2020-06-10 01:37:46 UTC 参议院确认第一位黑人空军首脑 nytimes 0.99 nytimes
3 txtcls swivel 2020-06-10 01:40:32 UTC 宇航员历史性的 SpaceX 发射后与空间站对接 github 0.99 nytimes
4 txtcls swivel 2020-06-09 21:21:44 UTC YouTube 推出视频章节功能,以便更轻松地浏览更长的视频 techcrunch 0.77 techcrunch

有了这些信息在 BigQuery 中可访问,我们可以将评估表加载到一个名为 df_evals 的数据帧中,并直接计算该模型版本的评估指标。由于这是多类分类,我们可以计算每个类别的精确度、召回率和 F1 分数。我们还可以创建一个混淆矩阵,帮助分析模型预测在某些分类标签内的情况。图 5-3 显示了将该模型预测与地面真实标签进行比较的混淆矩阵。

混淆矩阵显示所有地面真实标签和预测标签的配对,因此您可以探索模型在不同类别内的性能。

图 5-3. 混淆矩阵显示所有地面真实标签和预测标签的配对,因此您可以探索模型在不同类别内的性能。

持续评估

我们应确保输出表还捕获了模型版本和预测请求的时间戳,以便我们可以使用同一张表对比两个不同模型版本之间的指标。例如,如果我们部署了一个名为 swivel_v2 的新模型版本,该版本是根据最新数据或具有不同超参数进行训练的,我们可以通过切片评估数据帧来比较它们的性能:

df_v1 = df_evals[df_evals.version == "swivel"]
df_v2 = df_evals[df_evals.version == "swivel_v2"]

同样,我们可以创建时间段内的评估切片,仅关注最近一个月或最近一周内的模型预测:

today = pd.Timestamp.now(tz='UTC')
one_month_ago = today - pd.DateOffset(months=1)
one_week_ago = today - pd.DateOffset(weeks=1)

df_prev_month = df_evals[df_evals.time >= one_month_ago]
df_prev_week = df_evals[df_evals.time >= one_week_ago]

为了持续进行上述评估,可以安排笔记本电脑(或容器化形式)。我们可以设置它在评估指标低于某个阈值时触发模型重新训练。

为什么会有效

在开发机器学习模型时,有一个隐含的假设,即训练、验证和测试数据来自相同的分布,如图 5-4 所示。当我们将模型部署到生产环境时,这一假设意味着未来的数据将与过去的数据相似。然而,一旦模型在生产环境中“野外”运行,对数据的这种静态假设可能不再有效。事实上,许多生产中的机器学习系统会遇到快速变化的非静态数据,而模型随时间变得陈旧,从而对预测质量产生负面影响。

在开发机器学习模型时,训练、验证和测试数据来自相同的数据分布。然而,一旦模型部署,该分布可能会改变,严重影响模型性能。

图 5-4. 在开发机器学习模型时,训练、验证和测试数据来自相同的数据分布。然而,一旦模型部署,该分布可能会改变,严重影响模型性能。

持续模型评估提供了一个框架,专门用于评估部署模型在新数据上的性能。这使我们能够尽早检测模型陈旧。这些信息有助于确定重新训练模型的频率或何时完全替换为新版本。

通过捕捉预测的输入和输出,并与真实情况进行比较,可以量化地跟踪模型的性能或者在当前环境中进行 A/B 测试以衡量不同模型版本的表现,而不考虑过去版本的表现。

权衡和替代方案

持续评估的目标是提供一种监控模型性能并保持生产中模型新鲜的方法。通过持续评估,可以确定何时重新训练模型的触发器。在这种情况下,重要的是考虑模型性能的容忍阈值及其所带来的权衡,以及定期重新训练的作用。还有一些技术和工具,如 TFX,通过直接监控输入数据分布来帮助预防性地检测数据和概念漂移。

重新训练的触发器

模型性能通常会随时间而下降。持续评估允许您以结构化方式精确测量下降程度,并提供重新训练模型的触发器。那么,这是否意味着一旦性能开始下降就应该重新训练模型?这取决于情况。这个问题的答案与业务用例紧密相关,应与评估指标和模型评估一起讨论。根据模型复杂性和 ETL 流水线,重新训练的成本可能很高。要考虑的权衡是在何种程度的性能恶化可以接受与成本相比。

阈值本身可以设置为绝对值;例如,当模型准确率低于 95% 时进行模型重新训练。或者阈值可以设置为性能变化率,例如,一旦性能开始出现下降趋势。无论哪种方法,选择阈值的理念类似于训练过程中对模型进行检查点。具有更高、更敏感的阈值时,生产中的模型保持更新,但频繁重新训练的成本更高,并且需要维护和切换不同模型版本的技术开销。阈值较低时,训练成本降低,但生产中的模型可能更陈旧。图 5-5 显示了性能阈值与模型重新训练作业数量之间的权衡。

如果模型重新训练管道由此类阈值自动触发,跟踪和验证触发器非常重要。不知道何时重新训练您的模型不可避免地会导致问题。即使流程是自动化的,您也应始终控制模型的重新训练,以便更好地理解和调试生产中的模型。

设置更高的性能阈值以确保生产中的更高质量模型,但将需要更频繁的重新训练作业,这可能会很昂贵。

图 5-5. 设置更高的性能阈值以确保生产中的更高质量模型,但将需要更频繁的重新训练作业,这可能会很昂贵。

定期重新训练

持续评估为了知道何时有必要重新训练您的模型提供了关键信号。这种重新训练的过程通常是通过使用任何新收集的训练数据微调前一个模型来完成的。持续评估可能每天发生一次,而定期重新训练作业可能仅每周或每月发生一次(图 5-6)。

一旦新版本的模型训练完成,其性能将与当前模型版本进行比较。只有在新模型在当前数据测试集上表现优于先前模型时,更新后的模型才会作为替换部署。

持续评估在每天收集新数据时提供模型评估。定期重新训练和模型比较提供离散时间点的评估。

图 5-6. 持续评估在每天收集新数据时提供模型评估。定期重新训练和模型比较在离散时间点提供评估。

如何安排重新训练的频率?重新训练的时间表取决于业务用例、新数据的普遍性以及执行重新训练流水线的时间和金钱成本。有时候,模型的时间视角自然决定了何时安排重新训练作业。例如,如果模型的目标是预测下个季度的收益,因为每个季度只能获取一次新的真实标签,那么频率高于此是没有意义的。然而,如果新数据的量和发生频率很高,那么频繁进行重新训练将是有益的。这种情况的极端版本是在线机器学习。某些机器学习应用,如广告投放或新闻推荐,需要在线、实时决策,并可以通过不断重新训练和更新参数权重来持续改善性能。

总的来说,最佳的时间框架是你作为从业者通过经验和实验确定的。如果你试图建模一个快速变化的任务,比如对手或竞争行为,那么设置更频繁的重新训练计划是有意义的。如果问题相对静态,例如预测婴儿的出生体重,那么较少频繁的重新训练就足够了。

无论哪种情况,建立一个自动化流水线非常有帮助,它可以通过单个 API 调用执行完整的重新训练过程。像 Cloud Composer/Apache Airflow 和 AI Platform Pipelines 这样的工具非常有用,可以从原始数据预处理和训练到超参数调整和部署,创建、调度和监控机器学习工作流。我们在下一章节进一步讨论这个话题,详见“设计模式 25:工作流管道”。

使用 TFX 进行数据验证

数据分布随时间可能会发生变化,如图 5-7 所示。例如,考虑出生体重数据集。随着医学和社会标准随时间的变化,模型特征(如母亲年龄或妊娠周数)与模型标签——婴儿体重之间的关系也会发生变化。这种数据漂移对模型泛化到新数据的能力产生负面影响。简而言之,你的模型已经过时,需要用新数据重新训练。

数据分布随时间可以发生变化。数据漂移是指与训练用数据相比,用于模型预测的数据发生的任何变化。

图 5-7. 数据分布随时间可能会发生变化。数据漂移是指与训练用数据相比,用于模型预测的数据发生的任何变化。

虽然持续评估提供了监控部署模型的后续方式,但监控接收到的新数据并预先识别数据分布变化也是有价值的。

TFX 的数据验证是一个有用的工具来完成这个任务。TFX 是一个由谷歌开源的用于部署机器学习模型的端到端平台。数据验证库可以用来比较训练中使用的数据示例与服务中收集的数据示例。有效性检查可以检测数据中的异常、训练与服务中的偏差,或者数据漂移。TensorFlow 数据验证使用 Facets,一个用于机器学习的开源可视化工具,创建数据可视化。Facets 概述可以高层次地查看各种特征值的分布,并能发现一些常见和不常见的问题,比如意外的特征值、缺失的特征值和训练与服务中的偏差。

估算重新训练间隔

了解数据和概念漂移如何影响模型的一个有用且相对便宜的策略是,仅使用陈旧数据训练模型,并评估该模型在更当前的数据上的表现(图 5-8)。这模仿了离线环境中持续模型评估的过程。也就是说,收集六个月或一年前的数据,并经历通常的模型开发工作流程,生成特征,优化超参数,并捕获相关的评估指标。然后,将这些评估指标与仅一个月前收集的更近期数据的模型预测进行比较。你的陈旧模型在当前数据上表现差多少?这可以很好地估计模型性能随时间下降的速度,以及重新训练的必要性。

在陈旧数据上训练模型,并在当前数据上评估,模仿了离线环境中持续模型评估过程。

图 5-8. 在陈旧数据上训练模型,并在当前数据上评估,模仿了离线环境中持续模型评估过程。

设计模式 19:两阶段预测

两阶段预测设计模式提供了一种解决当需要在分布式设备上部署大型复杂模型时保持性能的方法,通过将用例分成两个阶段,只在边缘执行更简单的阶段。

问题

在部署机器学习模型时,我们不能总是依赖最终用户有可靠的互联网连接。在这种情况下,模型被部署在边缘——意味着它们被加载到用户的设备上,并且不需要互联网连接来生成预测。考虑到设备的限制,部署在边缘的模型通常需要比在云中部署的模型更小,并因此需要在模型复杂性和大小、更新频率、准确性和低延迟之间进行权衡。

存在多种场景需要我们的模型部署在边缘设备上。一个例子是健身追踪设备,模型根据通过加速计和陀螺仪跟踪的用户活动为用户提供建议。在没有连接性的远程户外区域进行锻炼时,用户可能会使用这种设备。在这些情况下,我们仍希望我们的应用程序正常工作。另一个例子是环境应用程序,该应用程序使用温度和其他环境数据预测未来趋势。在这两个示例中,即使我们有互联网连接,从部署在云中的模型连续生成预测可能会变得缓慢和昂贵。

将训练好的模型转换为适用于边缘设备的格式,通常需要经历一种称为量化的过程,其中学习的模型权重用较少的字节表示。例如,TensorFlow 使用名为 TensorFlow Lite 的格式来转换保存的模型为更小的优化格式,以便在边缘提供服务。除了量化之外,用于边缘设备的模型可能也会从一开始就更小,以适应严格的内存和处理器约束。

TF Lite 使用的量化和其他技术显著减少了生成的 ML 模型的大小和预测延迟,但可能会降低模型的准确性。此外,由于我们无法始终依赖边缘设备具有连接性,及时向这些设备部署新的模型版本也是一个挑战。

通过观察在Cloud AutoML Vision中训练边缘模型的选项,我们可以看到这些权衡在实践中是如何发挥作用的,见图 5-9。

在云 AutoML Vision 中为边缘部署的模型之间进行准确性、模型大小和延迟的权衡。

图 5-9. 在云 AutoML Vision 中为边缘部署的模型之间进行准确性、模型大小和延迟的权衡。

要考虑这些权衡,我们需要一个解决方案来平衡边缘模型的尺寸和延迟的减少与云模型的增强复杂性和准确性。

解决方案

使用两阶段预测设计模式,我们将问题分成两部分。我们从一个较小、成本较低的模型开始,该模型可以部署在设备上。由于这个模型通常具有较简单的任务,它可以在设备上以相对较高的准确率完成这个任务。接着是第二个更复杂的模型,部署在云端,仅在需要时触发。当然,这种设计模式要求你有一个可以分成两个具有不同复杂程度部分的问题。一个这样的问题的例子是智能设备,比如Google Home,它们通过唤醒词激活,然后可以回答问题和响应设置闹钟、阅读新闻以及与灯光和恒温器等集成设备交互的命令。例如,Google Home 可以通过说“OK Google”或“Hey Google”来激活。一旦设备识别到唤醒词,用户可以提出更复杂的问题,比如:“你能安排和萨拉在上午 10 点的会议吗?”

这个问题可以分成两个明确的部分:一个初始模型用于监听唤醒词,另一个更复杂的模型可以理解并响应任何其他用户查询。两个模型都将执行音频识别。然而,第一个模型只需要执行二元分类:刚刚听到的声音是否匹配唤醒词?虽然这个模型在复杂性上更简单,但如果部署到云端并且需要不断运行,这将非常昂贵。第二个模型将需要音频识别和自然语言理解来解析用户的查询。这个模型只需要在用户提问时运行,但更强调高准确率。两阶段预测模式可以通过在设备上部署唤醒词模型和在云端部署更复杂的模型来解决这个问题。

除了这种智能设备的使用案例外,还有许多其他情况可以使用两阶段预测模式。比如说,你在一个工厂生产线上工作,同时许多不同的机器在运行。当一个机器停止正常工作时,通常会发出可能与故障相关的噪音。不同的噪音对应于每台不同的机器以及机器可能出现故障的不同方式。理想情况下,你可以构建一个模型来标记问题噪音并识别其含义。使用两阶段预测,你可以构建一个离线模型来检测异常声音。然后可以使用第二个云模型来确定通常的声音是否表明某种故障状态。

您还可以针对基于图像的场景使用两阶段预测模式。假设您在野外部署了摄像头以识别和追踪濒危物种。您可以在设备上有一个模型,用于检测最新捕捉的图像是否包含濒危动物。如果是,该图像将被发送到一个云模型,以确定图像中特定类型的动物。

为了说明两阶段预测模式,让我们使用来自 Kaggle 的通用音频识别数据集。该数据集包含约 9,000 个熟悉声音的音频样本,共 41 个标签类别,包括“大提琴”,“敲门声”,“电话”,“小号”等。我们解决方案的第一阶段将是一个模型,预测给定声音是否是一种乐器。然后,对于第一个模型预测为乐器的声音,我们将从云中部署的模型中获得对 18 种可能选项中特定乐器的预测结果。图 5-10 显示了这个示例的两阶段流程。

使用两阶段预测模式来识别乐器声音。

图 5-10. 使用两阶段预测模式来识别乐器声音。

为了构建这些模型,我们将把音频数据转换为声谱图,这些图像是声音的可视化表示。这将允许我们使用常见的图像模型架构以及转移学习设计模式来解决这个问题。请参见图 5-11 中我们数据集中的萨克斯管音频片段的声谱图。

我们训练数据集中萨克斯管音频片段的图像表示(声谱图)。将.wav 文件转换为声谱图的代码可以在 GitHub 仓库中找到。

图 5-11. 我们训练数据集中萨克斯管音频片段的图像表示(声谱图)。将.wav 文件转换为声谱图的代码可以在GitHub 仓库中找到。

阶段 1:构建离线模型

我们的两阶段预测解决方案中的第一个模型应该足够小,以便可以加载到移动设备上进行快速推断,而无需依赖互联网连接。延续上面介绍的仪器示例,我们将通过构建一个优化用于设备内推断的二元分类模型来提供第一个预测阶段的示例。

最初的声音数据集有 41 个标签,用于不同类型的音频剪辑。我们的第一个模型只有两个标签:“乐器”或“非乐器”。我们将使用在 ImageNet 数据集上训练过的 MobileNetV2 模型架构来构建我们的模型。MobileNetV2 可直接在 Keras 中使用,并且是为将在设备上提供服务的模型优化的架构。对于我们的模型,我们将冻结 MobileNetV2 的权重,并加载它,但不包括顶部,以便我们可以添加自己的二元分类输出层:

mobilenet = tf.keras.applications.MobileNetV2(
    input_shape=((128,128,3)), 
    include_top=False,
    weights='imagenet'
)
mobilenet.trainable = False

如果我们将我们的频谱图像组织到具有相应标签名称的目录中,我们可以使用 Keras 的 ImageDataGenerator 类来创建我们的训练和验证数据集:

train_data_gen = image_generator.flow_from_directory(
      directory=data_dir,
      batch_size=32,
      shuffle=True,
      target_size=(128,128),
      classes = ['not_instrument','instrument'],
      class_mode='binary')

当我们的训练和验证数据集准备好后,我们可以像通常一样训练模型。用于将训练好的模型导出用于服务的典型方法是使用 TensorFlow 的 model.save() 方法。但是,请记住,此模型将在设备上提供服务,因此我们希望尽可能将其保持小巧。为了构建符合这些要求的模型,我们将使用 TensorFlow Lite,这是一个专为在移动和嵌入式设备上直接构建和提供模型而优化的库,这些设备可能没有可靠的互联网连接。TF Lite 在训练期间和训练后都有一些内置工具来量化模型。

为了准备在边缘提供服务的训练模型,我们使用 TF Lite 将其导出为优化格式:

converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_model = converter.convert()
open('converted_model.tflite', 'wb').write(tflite_model)

这是训练后量化模型的最快方法。使用 TF Lite 的优化默认设置,它将将我们模型的权重减少到它们的 8 位表示。在推断时,它还会量化输入,当我们对模型进行预测时。通过运行上述代码,导出的 TF Lite 模型的大小仅为没有量化时的四分之一。

提示

为了进一步优化您的模型以进行离线推断,您还可以在训练期间量化模型的权重,或者在权重之外量化模型的所有数学操作。在撰写本文时,TensorFlow 2 模型的量化优化训练已在路线图上。

要在 TF Lite 模型上生成预测,您需要使用 TF Lite 解释器,该解释器针对低延迟进行了优化。您可能希望将模型加载到 Android 或 iOS 设备上,并直接从应用程序代码中生成预测。这两个平台都有 API,但我们将在此展示生成预测的 Python 代码,以便您可以从创建模型的同一笔记本中运行它。首先,我们创建 TF Lite 解释器的实例,并获取它期望的输入和输出格式的详细信息:

interpreter = tf.lite.Interpreter(model_path="converted_model.tflite")
interpreter.allocate_tensors()

input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()

对于我们上面训练的 MobileNetV2 二元分类模型,input_details 看起来如下:

[{'dtype': numpy.float32,
  'index': 0,
  'name': 'mobilenetv2_1.00_128_input',
  'quantization': (0.0, 0),
  'quantization_parameters': {'quantized_dimension': 0,
  'scales': array([], dtype=float32),
  'zero_points': array([], dtype=int32)},
  'shape': array([  1, 128, 128,   3], dtype=int32),
  'shape_signature': array([  1, 128, 128,   3], dtype=int32),
  'sparsity_parameters': {}}]

然后,我们将验证批次中的第一个图像传递给加载的 TF Lite 模型进行预测,调用解释器并获取输出:

input_data = np.array([image_batch[21]], dtype=np.float32)
interpreter.set_tensor(input_details[0]['index'], input_data)

interpreter.invoke()
output_data = interpreter.get_tensor(output_details[0]['index'])
print(output_data)

结果输出是一个在 [0,1] 范围内的 sigmoid 数组,指示给定输入声音是否为乐器。

提示

根据调用云模型的成本,您可以在训练设备上的模型时更改您优化的度量标准。例如,如果您更关心避免假阳性,可以选择优化精确度而不是召回率。

现在我们的模型在设备上运行,可以快速预测,无需依赖互联网连接。如果模型确信给定的声音不是乐器,我们可以在此停止。如果模型预测为“乐器”,则是时候将音频剪辑发送到更复杂的云托管模型进行下一步处理了。

第二阶段:构建云模型

由于我们的云托管模型不需要优化以在没有网络连接的情况下进行推理,我们可以按照更传统的方法进行训练、导出和部署此模型。根据您的两阶段预测用例,第二个模型可以采取多种不同的形式。在 Google Home 示例中,第二阶段可能包括多个模型:一个将说话者的音频输入转换为文本,第二个执行自然语言处理以理解文本并路由用户的查询。如果用户要求更复杂的内容,甚至可能会有第三个模型基于用户偏好或过去活动提供推荐。

在我们的乐器示例中,我们解决方案的第二阶段将是一个多类别模型,将声音分类为可能的 18 个乐器类别之一。由于这个模型不需要在设备上部署,我们可以使用像 VGG 这样的更大模型架构作为起点,然后按照第 4 章中概述的迁移学习设计模式进行。

我们将加载在 ImageNet 数据集上训练的 VGG,指定我们的频谱图像的大小在 input_shape 参数中,并在添加自己的 softmax 分类输出层之前冻结模型的权重:

vgg_model = tf.keras.applications.VGG19(
    include_top=False, 
    weights='imagenet', 
    input_shape=((128,128,3))
)

vgg_model.trainable = False

我们的输出将是一个包含 softmax 概率的 18 元素数组:

prediction_layer = tf.keras.layers.Dense(18, activation='softmax')

我们将数据集限制为仅包含乐器音频剪辑,然后将乐器标签转换为 18 元素的独热向量。我们可以使用上述 image_generator 方法将图像馈送到我们的模型进行训练。与将其导出为 TF Lite 模型不同,我们可以使用 model.save() 导出模型以供服务。

要演示如何将第二阶段模型部署到云端,我们将使用云AI Platform Prediction。我们需要将保存的模型资产上传到云存储桶,然后通过指定框架并将 AI Platform Prediction 指向我们的存储桶来部署模型。

提示

您可以使用任何基于云的自定义模型部署工具来实现两阶段预测设计模式的第二阶段。除了 Google Cloud 的 AI Platform Prediction 外,AWS SageMakerAzure Machine Learning都提供部署自定义模型的服务。

当我们将模型导出为 TensorFlow SavedModel 时,我们可以直接向保存模型方法传递 Cloud Storage 存储桶 URL:

model.save('gs://your_storage_bucket/path')

这将以 TF SavedModel 格式导出我们的模型并上传到我们的 Cloud Storage 存储桶。

在 AI Platform 中,模型资源包含您模型的不同版本。每个模型可以有数百个版本。我们将首先使用 Google Cloud CLI 通过 gcloud 创建模型资源:

gcloud ai-platform models create instrument_classification

有几种部署模型的方法。我们将使用 gcloud 并将 AI Platform 指向包含我们保存的模型资产的存储子目录:

gcloud ai-platform versions create v1 \
  --model instrument_classification \
  --origin 'gs://your_storage_bucket/path/model_timestamp' \
  --runtime-version=2.1 \
  --framework='tensorflow' \
  --python-version=3.7

现在我们可以通过 AI Platform 预测 API 向我们的模型发出预测请求,支持在线和批处理预测。在线预测让我们能够在几个示例上几乎实时获取预测结果。如果我们要发送数百或数千个示例进行预测,我们可以创建一个批处理预测作业,该作业将在后台异步运行,并在完成时将预测结果输出到文件。

为了处理调用我们模型的设备可能不总是连接互联网的情况,我们可以在设备离线时将音频片段存储为仪器预测。当恢复连接时,我们可以将这些片段发送到云托管模型进行预测。

折衷与替代方案

虽然两阶段预测模式适用于许多情况,但存在这样的情况:您的最终用户可能几乎没有互联网连接,因此无法依赖于能够调用云托管模型。在本节中,我们将讨论两种仅离线使用的替代方案,一个客户需要同时进行多个预测请求的场景,以及如何针对离线模型运行持续评估的建议。

独立单阶段模型

有时,您的模型最终用户可能几乎没有互联网连接。即使这些用户的设备无法可靠地访问云模型,仍然重要的是为他们提供访问您的应用程序的方式。对于这种情况,与依赖于两阶段预测流程不同,您可以使您的第一个模型足够强大,以使其能够自给自足。

为了实现这一点,我们可以创建我们复杂模型的一个较小版本,并让用户选择在脱机时下载这个更简单、更小的模型。这些脱机模型可能不如其更大的在线对应版本精确,但这个解决方案比完全没有脱机支持要好得多。要构建更复杂的设计用于脱机推断的模型,最好使用一种工具,在训练期间和训练后都允许你量化模型的权重和其他数学操作。这被称为量化感知训练

提供较简单脱机模型的一个应用示例是Google Translate。Google Translate 是一个强大的在线翻译服务,支持数百种语言。然而,有许多情况下,您需要在没有互联网访问的情况下使用翻译服务。为了处理这个问题,Google Translate 允许您下载超过 50 种不同语言的离线翻译。这些脱机模型较小,大约在 40 到 50 兆字节之间,并且在精度上接近更复杂的在线版本。图 5-12 显示了基于设备和在线翻译模型的质量比较。

基于设备的短语翻译模型与(更新的)神经机器翻译模型,以及在线神经机器翻译的比较(来源:https://www.blog.google/products/translate/offline-translations-are-now-lot-better-thanks-device-ai/)。

图 5-12。基于设备的短语翻译模型与(更新的)神经机器翻译模型以及在线神经机器翻译的比较(来源:The Keyword)。

另一个独立的单相模型示例是Google Bolo,这是一个面向儿童的语音语言学习应用程序。该应用完全脱机工作,并且旨在帮助那些可靠的互联网访问并非总是可用的人群。

特定用例的脱机支持

另一个使您的应用程序适用于具有最小互联网连接性的用户的解决方案是仅使应用程序的某些部分脱机可用。这可能涉及启用一些常见的离线功能或缓存 ML 模型预测的结果以供以后脱机使用。通过这种替代方案,我们仍然使用两个预测阶段,但我们限制了离线模型覆盖的用例。在这种方法中,应用程序在脱机状态下可以正常工作,但在恢复连接时提供完整功能。

例如,Google Maps 允许您提前下载地图和路线指南。为了避免在移动设备上占用太多空间,可能仅提供驾驶路线的离线模式(而不是步行或骑行)。另一个例子可以是健身应用程序,该应用程序跟踪您的步数并为未来活动提供建议。假设该应用程序最常见的用途是检查您当天走了多少步。为了支持离线使用情况,我们可以通过蓝牙将健身跟踪器的数据同步到用户设备上,以便离线检查当天的健身状态。为了优化我们应用程序的性能,我们可能决定仅在线提供健身历史记录和建议。

我们可以进一步发展这一点,即在设备离线时存储用户的查询,并在它们重新连接时将其发送到云模型,以提供更详细的结果。此外,我们甚至可以提供基本的离线推荐模型,旨在在用户查询发送到云托管模型时补充改进结果。通过这种解决方案,用户在未连接时仍可获得部分功能。当它们重新连接时,它们可以从完整功能的应用程序和强大的机器学习模型中获益。

处理近实时的大量预测

在其他情况下,您的机器学习模型的最终用户可能具有可靠的连接性,但可能需要一次对模型进行数百甚至数千次预测。如果您只有一个云托管模型,并且每个预测都需要向托管服务发起 API 调用,那么一次获取数千个示例的预测响应将花费太长时间。

要理解这一点,假设我们在用户家中的各个区域部署了嵌入式设备。这些设备捕获温度、气压和空气质量数据。我们在云中部署了一个模型,用于从这些传感器数据中检测异常。由于传感器不断收集新数据,将每个传入数据点发送到我们的云模型将是低效且昂贵的。因此,我们可以在传感器上直接部署模型,以从传入数据中识别可能的异常候选项。然后,我们只将潜在的异常发送到我们的云模型进行综合验证,考虑所有位置的传感器读数。这是早期描述的两阶段预测模式的变体,其主要区别在于离线和云模型执行相同的预测任务,但输入不同。在这种情况下,模型还限制了一次发送到云模型的预测请求数量。

离线模型的持续评估

我们如何确保我们的设备上的模型保持更新,并且不会受到数据漂移的影响?有几种方法可以对没有网络连接的模型执行持续评估。首先,我们可以保存在设备上接收到的预测子集。然后,我们可以定期评估这些示例上模型的性能,并确定是否需要重新训练模型。对于我们的两阶段模型,定期进行此评估非常重要,因为很可能我们设备上模型的许多调用不会进入第二阶段云模型。另一种选择是创建设备上模型的副本,仅用于连续评估目的。如果我们的离线和云模型运行类似的预测任务,比如前面提到的翻译案例,这种解决方案更可取。

设计模式 20:键控预测

通常情况下,您会在模型训练时使用与部署时实时提供的输入特征相同的输入集。然而,在许多情况下,您的模型也可以通过客户提供的键来传递。这被称为键控预测设计模式,在本章讨论的几种设计模式中实现可扩展性时是必需的。

问题

如果您的模型部署为 Web 服务并接受单个输入,则很明确每个输出对应哪个输入。但如果您的模型接受包含百万个输入的文件,并返回一个包含百万个输出预测的文件呢?

您可能会认为第一个输出实例对应于第一个输入实例,第二个输出实例对应于第二个输入实例,依此类推是显而易见的。然而,使用 1:1 的关系时,每个服务器节点需要按顺序处理完整的输入集。如果您使用分布式数据处理系统,并将实例分发到多台机器,收集所有结果输出并发送回来会更有优势。但这种方法的问题在于输出会被混乱排序。要求输出以相同的方式排序会带来可扩展性挑战,而以无序方式提供输出则要求客户端以某种方式知道哪个输出对应哪个输入。

如果您的在线服务系统接受一组实例数组,则会出现相同的问题,就像无状态服务函数模式中讨论的那样。问题在于在本地处理大量实例将导致热点。接收到少量请求的服务器节点将能够跟上,但是接收到特别大数组的任何服务器节点将开始落后。这些热点将迫使您使服务器机器比实际需要的更强大。因此,许多在线服务系统将对可以在一次请求中发送的实例数量施加限制。如果没有这样的限制,或者如果模型的计算成本如此之高,以至于比此限制更少的实例请求可能会使服务器超载,那么您将会遇到热点问题。因此,批处理服务问题的任何解决方案也将解决在线服务中的热点问题。

解决方案

解决方案是使用透传键。让客户端为每个输入提供一个关联的键。例如(见图 5-13),假设您的模型训练有三个输入(a、b、c),如左侧所示,生成右侧的输出 d。让您的客户端提供(k、a、b、c)给您的模型,其中 k 是具有唯一标识符的键。该键可以简单地对输入实例进行编号 1、2、3 等。然后,您的模型将返回(k、d),因此客户端将能够确定哪个输出实例对应哪个输入实例。

客户端为每个输入实例提供一个唯一键。服务系统将这些键附加到相应的预测上。这使得客户端能够检索每个输入的正确预测,即使输出顺序不同。

图 5-13. 客户端为每个输入实例提供一个唯一键。服务系统将这些键附加到相应的预测上。这使得客户端能够检索每个输入的正确预测,即使输出顺序不同。

如何在 Keras 中透传键

为了使您的 Keras 模型透传键,当导出模型时提供一个服务签名。

例如,以下是将本来需要四个输入(is_malemother_agepluralitygestation_weeks)的模型修改为还接收一个键,并将其传递到输出中的代码:

# Serving function that passes through keys
@tf.function(input_signature=[{
    'is_male': tf.TensorSpec([None,], dtype=tf.string, name='is_male'),
    'mother_age': tf.TensorSpec([None,], dtype=tf.float32, 
name='mother_age'),
    'plurality': tf.TensorSpec([None,], dtype=tf.string, name='plurality'),
    'gestation_weeks': tf.TensorSpec([None,], dtype=tf.float32, 

name='gestation_weeks'),
    `'``key``'``:` `tf``.``TensorSpec``(``[``None``,``]``,` `dtype``=``tf``.``string``,` `name``=``'``key``'``)`
}])
def keyed_prediction(inputs):
    feats = inputs.copy()
    key = feats.pop('key') # get the key from input
    output = model(feats) # invoke model
    return `{``'``key``'``:` `key``,` 'babyweight': output}

然后按照无状态服务函数设计模式讨论的方式保存此模型:

model.save(EXPORT_PATH, 
           signatures={'serving_default': keyed_prediction})

将键控预测功能添加到现有模型

注意上述代码即使原始模型未保存服务函数也能正常工作。只需使用tf.saved_model.load()加载模型,附加一个服务函数,然后使用上述代码片段,如图 5-14 所示。

加载一个 SavedModel,附加一个非默认服务函数,并保存它。

图 5-14. 加载一个 SavedModel,附加一个非默认的服务函数,并保存它。

在这样做时,最好提供一个服务函数,复制旧的无密钥行为:

# Serving function that does not require a key
@tf.function(input_signature=[{
    'is_male': tf.TensorSpec([None,], dtype=tf.string, name='is_male'),
    'mother_age': tf.TensorSpec([None,],  dtype=tf.float32, 
name='mother_age'),
    'plurality': tf.TensorSpec([None,], dtype=tf.string, name='plurality'),
    'gestation_weeks': tf.TensorSpec([None,], dtype=tf.float32, 

name='gestation_weeks')
}])
def nokey_prediction(inputs):
    output = model(inputs) # invoke model
    return `{`'babyweight': output}

将以前的行为用作默认,并添加keyed_prediction作为新的服务函数:

model.save(EXPORT_PATH, 
           signatures={'serving_default': nokey_prediction,
                       'keyed_prediction': keyed_prediction
})

折衷与替代方案

为什么服务器不能只为接收到的输入分配密钥?对于在线预测,服务器可以分配缺乏任何语义信息的唯一请求 ID。对于批量预测,问题在于需要将输入与输出关联起来,因此服务器分配唯一 ID 是不够的,因为无法将其与输入重新关联起来。服务器必须在调用模型之前为接收到的输入分配密钥,使用这些密钥对输出进行排序,然后在发送输出之前移除密钥。问题在于,在分布式数据处理中,排序是计算非常昂贵的过程。

此外,还有几种其他情况下客户提供的密钥很有用——异步服务和评估。鉴于这两种情况,最好确定密钥的构成因用例而异,并且需要能够识别。因此,要求客户提供密钥会使解决方案更简单。

异步服务

如今许多生产机器学习模型都是神经网络,神经网络涉及矩阵乘法。在像 GPU 和 TPU 这样的硬件上,如果您可以确保矩阵在某些大小范围内和/或某个数字的倍数内,矩阵乘法会更有效率。因此,积累请求(当然最大延迟)并以块处理传入请求可能会有所帮助。由于这些块将包含来自多个客户端的交织请求,因此在这种情况下,密钥需要具有某种客户标识符。

连续评估

如果您正在进行连续评估,记录有关预测请求的元数据可能会有所帮助,以便您可以监视整体性能是否下降,或者仅在特定情况下下降。如果键标识了所讨论的情况,这种切片操作将变得更加容易。例如,假设我们需要应用公平性镜头(参见第七章)来确保我们模型在不同客户段(例如客户的年龄和/或种族)之间的性能公平。模型不会将客户段作为输入使用,但我们需要按客户段切片评估模型的性能。在这种情况下,将客户段嵌入密钥中(例如一个示例密钥可能是 35-黑人-男性-34324323)会使切片操作更加容易。

另一个解决方案是让模型忽略未识别的输入,并不仅返回预测输出,而是返回所有输入,包括未识别的输入。这允许客户端将输入与输出匹配,但在带宽和客户端计算方面更为昂贵。

由于高性能服务器将支持多个客户端,并由集群支持,并且批处理请求以获得性能优势,因此最好提前计划——要求客户端在每次预测时提供键,并要求客户端指定不会与其他客户端发生冲突的键。

摘要

在本章中,我们探讨了使机器学习模型运营化的技术,以确保它们具有韧性并能够扩展以处理生产负载。我们讨论的每个韧性模式都涉及典型机器学习工作流程中的部署和服务步骤。

我们从探讨如何使用无状态服务函数设计模式将训练好的机器学习模型封装为无状态函数开始。服务函数通过定义一个函数来执行对模型导出版本的推断,并部署到 REST 端点,从而解耦了模型的训练和部署环境。并非所有生产模型都需要立即的预测结果,因为在某些情况下,您需要将大批数据发送到模型进行预测,但不需要立即获取结果。我们看到批量服务设计模式如何通过利用设计为异步后台作业运行多个模型预测请求的分布式数据处理基础设施来解决这一问题,并将输出写入指定位置。

接下来,使用持续模型评估设计模式,我们探讨了一种验证部署模型在新数据上表现良好的方法。这种模式通过定期评估模型并利用结果来确定是否需要重新训练,从而解决了数据和概念漂移的问题。在两阶段预测设计模式中,我们解决了需要在边缘部署模型的特定用例。当你能够将问题分解为两个逻辑部分时,这种模式首先创建一个可以在设备上部署的简化模型。这个边缘模型连接到云端托管的更复杂的模型。最后,在关键预测设计模式中,我们讨论了在进行预测请求时每个示例都提供唯一键值的好处。这确保你的客户将每个预测输出与正确的输入示例关联起来。

在下一章中,我们将探讨可复现性模式。这些模式解决了许多机器学习中固有随机性带来的挑战,并专注于每次机器学习过程运行时都能产生可靠一致结果。

¹ 好奇“正面”投诉是什么样?这里是一个例子:

“我每天早上和晚上都接到电话。我告诉他们停止打电话了,但他们仍然会打电话,甚至在星期天早上也是如此。星期天早上我连续接到两个来自 XXXX XXXX 的电话。星期六我接到了九个电话。平日我每天也接到大约九个电话。

唯一的提示是,抱怨者不高兴的迹象是他们已经要求打电话的人停止了。否则,其余的陈述可能会是关于某人在吹嘘自己有多受欢迎!

第六章:可重现性设计模式

软件最佳实践,如单元测试,假设如果我们运行一段代码,它会产生确定性输出:

def sigmoid(x):
    return 1.0 / (1 + np.exp(-x))

class TestSigmoid(unittest.TestCase):
    def test_zero(self):
        self.assertAlmostEqual(sigmoid(0), 0.5)

    def test_neginf(self):
        self.assertAlmostEqual(sigmoid(float("-inf")), 0)

    def test_inf(self):
        self.assertAlmostEqual(sigmoid(float("inf")), 1)

在机器学习中,这种可重复性是困难的。在训练过程中,机器学习模型使用随机值初始化,然后根据训练数据进行调整。例如,由 scikit-learn 实施的简单 k-means 算法需要设置 random_state 以确保算法每次返回相同结果:

def cluster_kmeans(X):
    from sklearn import cluster
    k_means = cluster.KMeans(n_clusters=10, `random_state``=``10`)
    labels = k_means.fit(X).labels_[::]
    return labels

除了随机种子外,还有许多其他需要修复的工件,以确保在训练期间的可重复性。此外,机器学习包括不同阶段,如训练、部署和重新训练。重要的是,在这些阶段之间某些事物的可重复性通常也很重要。

在本章中,我们将看到处理可重现性不同方面的设计模式。Transform 设计模式从模型训练流程中捕获数据准备依赖项,以在服务过程中重现它们。Repeatable Splitting 捕获数据在训练、验证和测试数据集之间的拆分方式,以确保训练中使用的训练样本不会在评估或测试中使用,即使数据集增长。Bridged Schema 设计模式考虑了在训练数据集是符合不同架构的数据混合时如何确保可重复性。Workflow Pipeline 设计模式捕获机器学习过程中的所有步骤,以确保在重新训练模型时可以重复使用流水线的部分。Feature Store 设计模式解决了在不同机器学习作业中特征的可重复性和可重用性。Windowed Inference 设计模式确保以动态、时间依赖方式计算的特征在训练和服务之间能够正确重复。数据和模型的版本控制 是本章中处理许多设计模式的先决条件。

设计模式 21: Transform

Transform 设计模式通过精心分离输入、特征和转换,使将 ML 模型移至生产环境变得更加容易。

问题

问题在于机器学习模型的输入不是其在计算中使用的特征。例如,在文本分类模型中,输入是原始文本文档,而特征是该文本的数值嵌入表示。当我们训练一个机器学习模型时,我们使用从原始输入中提取出的特征进行训练。以 BigQuery ML 预测伦敦自行车骑行时长的模型为例:

CREATE OR REPLACE MODEL ch09eu.bicycle_model
OPTIONS(input_label_cols=['duration'], 
        model_type='linear_reg')
AS
SELECT 
 duration
 , start_station_name
 , CAST(EXTRACT(dayofweek from start_date) AS STRING)
 as dayofweek
 , CAST(EXTRACT(hour from start_date) AS STRING)
 as hourofday
FROM 
 `bigquery-public-data.london_bicycles.cycle_hire`

该模型有三个特征(start_station_namedayofweekhourofday),这些特征是从两个输入 start_station_namestart_date 计算得出的,如 Figure 6-1 所示。

该模型从两个输入计算出三个特征。

图 6-1。该模型从两个输入计算出三个特征。

但上述 SQL 代码混淆了输入和特征,并且没有跟踪执行的转换。当我们尝试使用此模型进行预测时,这会对我们造成困扰。因为模型是基于三个特征进行训练的,这就是预测签名应该看起来的样子:

SELECT * FROM ML.PREDICT(MODEL ch09eu.bicycle_model,(
   'Kings Cross' AS start_station_name
 , '3' as dayofweek
 , '18' as hourofday
))

注意,在推断时,我们必须知道模型训练的特征是什么,它们应该如何解释以及应用的转换的细节。我们必须知道我们需要发送 '3' 作为星期几。这个 '3' 是星期二还是星期三?这取决于模型使用的库或我们认为一周的开始是什么!

训练与服务之间的偏差,由于这些因素在训练和服务环境之间的差异而引起,是 ML 模型投产困难的主要原因之一。

解决方案

解决方案是明确捕捉应用于将模型输入转换为特征的转换。在 BigQuery ML 中,可以通过使用 TRANSFORM 子句来实现这一点。使用 TRANSFORM 确保在 ML.PREDICT 期间自动应用这些转换。

鉴于对 TRANSFORM 的支持,上述模型应进行如下重写:

CREATE OR REPLACE MODEL ch09eu.bicycle_model
OPTIONS(input_label_cols=['duration'], 
        model_type='linear_reg')
TRANSFORM(
 SELECT * EXCEPT(start_date)
 , CAST(EXTRACT(dayofweek from start_date) AS STRING)
 as dayofweek -- feature1
 , CAST(EXTRACT(hour from start_date) AS STRING)
 as hourofday –- feature2
)
AS
SELECT 
 duration, start_station_name, start_date -- inputs
FROM 
 `bigquery-public-data.london_bicycles.cycle_hire`

注意我们如何明确将输入(在 SELECT 子句中)与特征(在 TRANSFORM 子句中)分离开来。现在,预测变得更加容易。我们只需向模型发送站点名称和时间戳(即输入)即可:

SELECT * FROM ML.PREDICT(MODEL ch09eu.bicycle_model,(
   'Kings Cross' AS start_station_name
 , CURRENT_TIMESTAMP() as start_date
))

然后模型会负责执行适当的转换以创建必要的特征。它通过捕捉转换逻辑和工件(如缩放常数、嵌入系数、查找表等)来执行转换。

只要我们在 SELECT 语句中仔细使用原始输入并将所有后续处理放在 TRANSFORM 子句中,BigQuery ML 将在预测期间自动应用这些转换。

折衷与替代方案

上述描述的解决方案之所以有效,是因为 BigQuery ML 为我们跟踪转换逻辑和工件,将它们保存在模型图中,并在预测期间自动应用这些转换。

如果我们使用的框架不支持内置的 Transform 设计模式,我们应该设计我们的模型架构,以便在训练期间执行的转换在服务期间易于重现。我们可以通过确保将转换保存在模型图中或创建转换特征的存储库(“设计模式 26:特征存储”)来实现这一点。

TensorFlow 和 Keras 中的转换

假设我们正在训练一个 ML 模型来估算纽约的出租车费用,并且有六个输入(上车纬度、上车经度、下车纬度、下车经度、乘客数量和上车时间)。TensorFlow 支持特征列的概念,这些特征列保存在模型图中。但是,API 设计时假设原始输入与特征相同。

假设我们想要缩放纬度和经度(详见“简单的数据表示”第第二章),创建一个转换后的特征作为欧几里德距离,并从时间戳中提取小时数。我们必须仔细设计模型图(参见图 6-2),牢记“转换”概念。当我们逐步分析下面的代码时,请注意我们如何设置事物,以便清晰地设计我们 Keras 模型中的三个单独层——输入层、转换层和DenseFeatures层。

Keras 中出租车费用估算问题的模型图。

图 6-2. Keras 中出租车费用估算问题的模型图。

首先,将 Keras 模型的每个输入都作为一个Input层(完整代码在GitHub的笔记本中):

inputs = {
        colname : tf.keras.layers.Input(
                    name=colname, shape=(), dtype='float32')
           for colname in ['pickup_longitude', 'pickup_latitude', 
                           'dropoff_longitude', 'dropoff_latitude']
}

在图 6-2 中,这些方框标记为dropoff_latitudedropoff_longitude等。

第二,维护一个转换特征的字典,并使每个转换成为 Keras 预处理层或Lambda层。在这里,我们使用Lambda层缩放输入:

transformed = {}
for lon_col in ['pickup_longitude', 'dropoff_longitude']:
            transformed[lon_col] = tf.keras.layers.Lambda(
                lambda x: (x+78)/8.0, 
                name='scale_{}'.format(lon_col)
            )(inputs[lon_col])
for lat_col in ['pickup_latitude', 'dropoff_latitude']:
            transformed[lat_col] = tf.keras.layers.Lambda(
                lambda x: (x-37)/8.0, 
                name='scale_{}'.format(lat_col)
            )(inputs[lat_col])

在图 6-2 中,这些方框标记为scale_dropoff_latitudescale_dropoff_longitude等。

我们还将有一个Lambda层来计算从四个Input层计算出的欧几里德距离(详见图 6-2):

def euclidean(params):
    lon1, lat1, lon2, lat2 = params
    londiff = lon2 - lon1
    latdiff = lat2 - lat1
    return tf.sqrt(londiff*londiff + latdiff*latdiff)
transformed['euclidean'] = tf.keras.layers.Lambda(euclidean, name='euclidean')([
            inputs['pickup_longitude'],
            inputs['pickup_latitude'],
            inputs['dropoff_longitude'],
            inputs['dropoff_latitude']
        ])

类似地,从时间戳创建小时数的列是一个Lambda层:

transformed['hourofday'] = tf.keras.layers.Lambda(
            lambda x: tf.strings.to_number(tf.strings.substr(x, 11, 2), 
                                           out_type=tf.dtypes.int32),
            name='hourofday'
        )(inputs['pickup_datetime'])

第三,所有这些转换层将被串联成一个DenseFeatures层:

dnn_inputs = tf.keras.layers.DenseFeatures(feature_columns.values())(transformed)

因为DenseFeatures的构造函数需要一组特征列,我们将不得不指定如何将每个转换后的值转换为神经网络的输入。我们可以原样使用它们、对它们进行独热编码,或者选择对数字进行分桶。为简单起见,让我们仅仅使用它们原样:

feature_columns = {
        colname: tf.feature_column.numeric_column(colname)
           for colname in ['pickup_longitude', 'pickup_latitude', 
                           'dropoff_longitude', 'dropoff_latitude']
}
feature_columns['euclidean'] = \
               tf.feature_column.numeric_column('euclidean')

一旦我们有了DenseFeatures输入层,我们可以像往常一样构建剩余的 Keras 模型:

h1 = tf.keras.layers.Dense(32, activation='relu', name='h1')(dnn_inputs)
h2 = tf.keras.layers.Dense(8, activation='relu', name='h2')(h1)
output = tf.keras.layers.Dense(1, name='fare')(h2)
model = tf.keras.models.Model(inputs, output)
model.compile(optimizer='adam', loss='mse', metrics=['mse'])

完整示例在 GitHub 上的这里

注意我们如何设置事物,使得 Keras 模型的第一层是Inputs。第二层是Transform层。第三层是DenseFeatures层,将它们组合在一起。在这些层序列之后,通常的模型架构开始。由于Transform层是模型图的一部分,通常的 Serving 功能和批量 Serving 解决方案(参见第五章)将如原样工作。

使用tf.transform进行高效的转换

上述方法的一个缺点是转换将在每次训练迭代期间执行。如果我们只是通过已知常数进行缩放,这并不是什么大问题。但是如果我们的转换计算更昂贵呢?如果我们想使用均值和方差进行缩放,那么我们需要首先通过所有数据来计算这些变量。

小贴士

区分实例级变换和可以直接成为模型一部分的变换(其唯一缺点是在每次训练迭代时应用它们)以及数据集级变换是有帮助的。对于数据集级变换,我们需要完整地遍历数据以计算整体统计信息或分类变量的词汇表。这样的数据集级变换不能成为模型的一部分,必须作为可扩展的预处理步骤应用,这样就产生了 Transform,捕捉了逻辑和制品(均值、方差、词汇等),以便附加到模型上。对于数据集级变换,请使用tf.transform

tf.transform库(作为TensorFlow Extended的一部分)提供了一种高效的方式,在数据的预处理传递中执行变换并保存生成的特征和转换制品,以便在预测时由 TensorFlow Serving 应用这些变换。

第一步是定义转换函数。例如,要将所有输入缩放为零均值和单位方差并对其进行分桶,我们将创建此预处理函数(在 GitHub 上查看完整代码):

def preprocessing_fn(inputs):
  outputs = {}
  for key in ...:
      outputs[key + '_z'] = tft.scale_to_z_score(inputs[key])
      outputs[key + '_bkt'] = tft.bucketize(inputs[key], 5)
  return outputs

在训练之前,使用 Apache Beam 中的先前函数读取并转换原始数据:

      transformed_dataset, transform_fn = (raw_dataset |
          beam_impl.AnalyzeAndTransformDataset(preprocessing_fn))
      transformed_data, transformed_metadata = transformed_dataset

然后,将转换后的数据写入适合训练流水线读取的格式:

      transformed_data | tfrecordio.WriteToTFRecord(
          PATH_TO_TFT_ARTIFACTS,
          coder=example_proto_coder.ExampleProtoCoder(
              transformed_metadata.schema))

Beam 流水线还将预处理函数以及函数所需的任何制品存储为 TensorFlow 图格式的工件。例如,上述情况下,此工件将包括用于缩放数字的均值和方差,以及用于分桶数字的桶边界。训练函数读取转换后的数据,因此在训练循环内不必重复转换。

服务函数需要加载这些制品并创建一个 Transform 层:

tf_transform_output = tft.TFTransformOutput(PATH_TO_TFT_ARTIFACTS)
tf_transform_layer = tf_transform_output.transform_features_layer()

然后,服务功能可以将 Transform 层应用于解析的输入特征,并使用转换后的数据调用模型以计算模型输出:

  @tf.function
  def serve_tf_examples_fn(serialized_tf_examples):
    feature_spec = tf_transform_output.raw_feature_spec()
    feature_spec.pop(_LABEL_KEY)
    parsed_features = tf.io.parse_example(serialized_tf_examples, feature_spec)

    transformed_features = tf_transform_layer(parsed_features)
    return model(transformed_features)

这样,我们确保将转换插入模型图中以供服务。同时,因为模型训练是在转换后的数据上进行的,所以我们的训练循环不必在每个时期执行这些转换操作。

文本和图像转换

在文本模型中,通常会对输入文本进行预处理(例如去除标点符号、停用词、大写、词干提取等),然后将清理后的文本作为特征提供给模型。其他常见的文本输入特征工程包括标记化和正则表达式匹配。在推理时,执行相同的清理或提取步骤至关重要。

即使在使用深度学习处理图像时,即使没有明确的特征工程,捕捉转换的需求也是重要的。图像模型通常具有一个接受特定大小图像的输入层。大小不同的图像必须在输入模型之前进行裁剪、填充或重新采样到固定大小。图像模型中其他常见的转换包括颜色操作(伽马校正、灰度转换等)和方向校正。这些转换在训练数据集和推理过程中必须保持一致。Transform 模式有助于确保这种可重现性。

对于图像模型,有一些转换(如通过随机裁剪和缩放进行数据增强)仅在训练过程中应用。这些转换不需要在推理过程中捕获。这些转换不会成为 Transform 模式的一部分。

替代模式方法

解决训练-服务偏差问题的另一种方法是采用特征存储模式。特征存储包括协调计算引擎和转换特征数据的存储库。计算引擎支持推理的低延迟访问和批量创建转换特征,而数据存储库提供快速访问转换特征以进行模型训练。特征存储的优点是不需要转换操作符合模型图。例如,只要特征存储支持 Java,预处理操作可以在 Java 中执行,而模型本身可以用 PyTorch 编写。特征存储的缺点是使模型依赖于特征存储,并使服务基础设施变得更加复杂。

另一种将特征转换的编程语言和框架与编写模型的语言分开的方法是在容器中进行预处理,并将这些自定义容器作为训练和服务的一部分。这在“设计模式 25:工作流程管道”中有所讨论,并且被 Kubeflow Serving 实践采纳。

设计模式 22:可重复的分割

为了确保抽样是可重复和可再现的,必须使用一个分布良好的列和确定性哈希函数来将可用数据分割为训练、验证和测试数据集。

问题

许多机器学习教程会建议使用类似以下代码将数据随机分割为训练、验证和测试数据集:

df = pd.DataFrame(...)
rnd = np.random.rand(len(df))
train = df[ rnd < 0.8  ]
valid = df[ rnd >= 0.8 & rnd < 0.9 ]
test  = df[ rnd >= 0.9 ]

不幸的是,这种方法在许多实际情况下失败了。原因在于很少有行是独立的。例如,如果我们正在训练一个模型来预测航班延误,同一天的航班到达延误将高度相关。这会导致在训练数据集中有些航班在任何特定日期上的某些航班和测试数据集中有些其他航班之间泄漏信息。由于相关行导致的这种泄漏是一个经常发生的问题,我们在进行机器学习时必须避免。

另外,rand函数每次运行时都会以不同的顺序排序数据,因此如果我们再次运行程序,将获得不同的 80%行。如果我们正在尝试不同的机器学习模型,并希望选择最佳模型,这可能会带来麻烦——我们需要在相同的测试数据集上比较模型性能。为了解决这个问题,我们需要预先设置随机种子或在分割数据后存储数据。硬编码数据分割方式并不是一个好主意,因为在执行像自助法、交叉验证和超参数调整等技术时,我们需要改变这些数据分割方式,并且以允许我们进行单独试验的方式进行。

对于机器学习,我们希望轻量级、可重复的数据分割,不受编程语言或随机种子的影响。我们还希望确保相关行进入同一分割中。例如,如果在训练数据集中有 2019 年 1 月 2 日的航班,我们不希望测试数据集中也有这一天的航班。

解决方案

首先,我们需要确定一个列,该列捕获行之间的相关关系。在我们的航空延误数据集中,这是date列。然后,我们使用哈希函数在该列上的最后几位数字来分割数据。对于航空延误问题,我们可以在date列上使用 Farm Fingerprint 哈希算法来将可用数据分割为训练、验证和测试数据集。

小贴士

关于 Farm Fingerprint 算法的更多信息、其他框架和语言的支持以及哈希与加密的关系,请参阅“设计模式 1:哈希特征”中的第二章。特别地,Farm Hash 算法的开源封装可在多种语言中使用(包括 Python),因此即使数据不在原生支持可重复哈希的数据仓库中,也可以应用此模式。

这是根据date列的哈希如何分割数据集的方法:

SELECT
  airline,
  departure_airport,
  departure_schedule,
  arrival_airport,
  arrival_delay
FROM
  `bigquery-samples`.airline_ontime_data.flights
WHERE
  ABS(MOD(FARM_FINGERPRINT(date), 10)) < 8 -- 80% for TRAIN

要根据date列进行分割,我们使用FARM_FINGERPRINT函数计算其哈希,然后使用模函数找出 80%的行的任意子集。这样做是可重复的——因为FARM_FINGERPRINT函数在特定日期上每次调用时返回相同的值,我们可以确保每次都会得到相同的 80%数据。因此,同一天的所有航班将属于同一分割——训练集、验证集或测试集。无论随机种子如何,这都是可重复的。

如果我们想按arrival_airport分割数据集(例如因为我们试图预测机场设施的某些内容),我们将在arrival_airport上计算哈希,而不是在date上。

获取验证数据也很简单:将上述查询中的< 8更改为=8,对于测试数据,则更改为=9。这样,我们可以在验证集中获得 10%的样本,在测试集中获得 10%的样本。

选择分割列的考虑因素是什么?date列必须具有多个特征,我们才能将其用作分割列:

  • 相同日期的行往往具有相关性——这正是我们希望确保所有相同日期的行都在同一分割中的关键原因。

  • date不是模型的输入,尽管它用作分割的标准。从date提取的特征,如星期几或每天的小时数,可以作为输入,但我们不能使用实际输入作为分割字段,因为训练过的模型将无法看到 80%数据集中date列的所有可能输入值。

  • 必须有足够的date数值。因为我们正在计算哈希并对 10 取模,所以我们至少需要 10 个唯一的哈希数值。拥有更多唯一数值会更好。为了保险起见,一个经验法则是以模数的 3-5 倍为目标,因此在这种情况下,我们需要大约 40 个唯一日期。

  • 标签必须在日期之间良好分布。如果所有延误都发生在 1 月 1 日,并且全年没有延误,那么这种方法将不起作用,因为分割的数据集将会倾斜。为了安全起见,请查看图表,确保三个分割数据集的标签分布相似。为了更安全,请确保通过出发延误和其他输入值的标签分布在三个数据集中也相似。

提示

我们可以使用 Kolomogorov–Smirnov 测试自动化检查标签在三个数据集中的分布是否相似:只需绘制标签在三个数据集中的累积分布函数,并找出每对之间的最大距离。最大距离越小,分割效果越好。

折衷和替代方案

让我们看一下我们可能会如何进行可重复分割的几个变体,并讨论每种方法的利弊。让我们还讨论如何将这个想法扩展到不仅是分割而且是可重复抽样。

单一查询

我们不需要三个单独的查询来生成训练、验证和测试分割。我们可以在一个查询中完成如下操作:

CREATE OR REPLACE TABLE mydataset.mytable AS
SELECT
  airline,
  departure_airport,
  departure_schedule,
  arrival_airport,
  arrival_delay,
  CASE(ABS(MOD(FARM_FINGERPRINT(date), 10)))
      WHEN 9 THEN 'test'
      WHEN 8 THEN 'validation'
      ELSE 'training' END AS split_col
FROM
  `bigquery-samples`.airline_ontime_data.flights

然后我们可以使用split_col列来确定每一行属于哪个数据集。使用单一查询减少了计算时间,但需要创建新表或修改源表以添加额外的split_col列。

随机分割

如果行之间没有相关性怎么办?在这种情况下,我们希望进行随机的、可重复的分割,但没有自然的列可以进行分割。我们可以通过将整行数据转换为字符串并对该字符串进行哈希来对数据进行哈希化:

SELECT
  airline,
  departure_airport,
  departure_schedule,
  arrival_airport,
  arrival_delay
FROM
  `bigquery-samples`.airline_ontime_data.flights f
WHERE
  ABS(MOD(FARM_FINGERPRINT(`TO_JSON_STRING``(``f``)`, 10)) < 8

请注意,如果我们有重复的行,它们将始终出现在相同的分割中。这可能正是我们所期望的。如果不是,则必须在SELECT查询中添加唯一 ID 列。

基于多列的分割

我们已经讨论了一个捕捉行之间相关性的单一列。如果是一组捕捉两行何时相关的列呢?在这种情况下,只需在计算哈希之前简单地连接字段(这是特征交叉)。例如,假设我们只希望确保同一天从同一机场出发的航班不会出现在不同的分割中。在这种情况下,我们将执行以下操作:

SELECT
  airline,
  departure_airport,
  departure_schedule,
  arrival_airport,
  arrival_delay
FROM
  `bigquery-samples`.airline_ontime_data.flights
WHERE
  ABS(MOD(FARM_FINGERPRINT(`CONCAT``(``date``,` `arrival_airport``)``)`, 10)) < 8

如果我们根据多列的特征交叉进行分割,我们可以将arrival_airport作为模型的输入之一,因为训练集和测试集中会有任何特定机场的示例。另一方面,如果我们仅根据arrival_airport进行分割,则训练集和测试集将具有互斥的到达机场集合,因此arrival_airport不能作为模型的输入。

可重复抽样

如果我们想要 80% 的整个数据集作为训练,那么基本解决方案就很好了,但是如果我们想要使用比我们在 BigQuery 中拥有的较小的数据集进行玩耍,那怎么办?这在本地开发中很常见。航班数据集有 70 百万行,也许我们想要的是一百万次航班的较小数据集。我们如何选择 70 次航班中的一次,然后选择其中的 80% 作为训练?

我们不能做的是:

SELECT
   date,
   airline,
   departure_airport,
   departure_schedule,
   arrival_airport,
   arrival_delay
 FROM
   `bigquery-samples`.airline_ontime_data.flights
 WHERE
  ABS(MOD(FARM_FINGERPRINT(date), 70)) = 0
  AND ABS(MOD(FARM_FINGERPRINT(date), 10)) < 8

我们不能选择 70 行中的 1 行,然后选择 10 行中的 8 行。如果我们选择可以被 70 整除的数字,当然它们也会被 10 整除!那第二个取模操作是无用的。

这里有一个更好的解决方案:

SELECT
   date,
   airline,
   departure_airport,
   departure_schedule,
   arrival_airport,
   arrival_delay
 FROM
   `bigquery-samples`.airline_ontime_data.flights
 WHERE
  ABS(MOD(FARM_FINGERPRINT(date), 70)) = 0
  AND ABS(MOD(FARM_FINGERPRINT(date), 700)) < 560

在这个查询中,700 是 7010,560 是 708. 第一个取模操作在 70 行中选择了 1,第二个取模操作在这些行中的 10 个中选择了 8。

对于验证数据,你可以通过适当的范围替换 < 560

ABS(MOD(FARM_FINGERPRINT(date), 70)) = 0
AND ABS(MOD(FARM_FINGERPRINT(date), 700)) `BETWEEN` `560` `AND` `629` 

在上述代码中,我们的一百万次航班来自数据集中仅有的 1/70。这可能正是我们想要的——例如,当我们使用较小的数据集进行实验时,可能是在特定一天上建模所有航班的全谱。然而,如果我们想要的是任何特定一天的航班的 1/70,我们将不得不使用RAND()并将结果保存为新表以实现可重复性。从这个较小的表中,我们可以使用FARM_FINGERPRINT()来抽取 80% 的日期。因为这个新表只有一百万行,而且只用于实验,所以重复可能是可以接受的。

顺序拆分

在时间序列模型的情况下,一种常见的方法是使用数据的顺序拆分。例如,在训练需求预测模型时,我们会使用过去 45 天的数据来预测接下来 14 天的需求,我们会通过获取必要的数据来训练模型(完整代码):

CREATE OR REPLACE MODEL ch09eu.numrentals_forecast
OPTIONS(model_type='ARIMA',
        time_series_data_col='numrentals',
        time_series_timestamp_col='date') AS
SELECT
   CAST(EXTRACT(date from start_date) AS TIMESTAMP) AS date
   , COUNT(*) AS numrentals
FROM
  `bigquery-public-data`.london_bicycles.cycle_hire
GROUP BY date
HAVING date BETWEEN 
DATE_SUB(CURRENT_DATE(), INTERVAL 45 DAY) AND CURRENT_DATE()

即使目标不是预测时间序列的未来值,快速变动的环境中也需要对数据进行顺序拆分。例如,在欺诈检测模型中,坏人会迅速适应欺诈算法,因此模型必须根据最新数据进行持续重新训练以预测未来的欺诈。从历史数据的随机拆分生成评估数据是不够的,因为目标是预测坏人将来会展示的行为。间接目标与时间序列模型的目标相同,一个好的模型将能够根据历史数据进行训练并预测未来的欺诈。数据必须按时间顺序进行拆分以正确评估这一点。例如(完整代码):

def read_dataset(client, `row_restriction`, batch_size=2048):
    ...
    bqsession = client.read_session(
        ...
        row_restriction=row_restriction)
    dataset = bqsession.parallel_read_rows()
    return (dataset.prefetch(1).map(features_and_labels)
               .shuffle(batch_size*10).batch(batch_size))

client = BigQueryClient()
`train_df` = read_dataset(client, `'``Time <= 144803``'`, 2048)
eval_df = read_dataset(client, `'``Time > 144803``'`, 2048)

当连续时间之间存在高相关性时,需要顺序分割数据的另一个例子是。例如,在天气预报中,连续几天的天气高度相关。因此,将 10 月 12 日放入训练数据集,将 10 月 13 日放入测试数据集是不合理的,因为会有相当大的泄漏(例如,10 月 12 日有飓风)。此外,天气具有很强的季节性,因此有必要在所有三个分割中都包含来自所有季节的天。评估预测模型性能的一种正确方法是使用顺序分割,但通过在训练数据集中使用每个月的前 20 天,在验证数据集中使用接下来的 5 天,在测试数据集中使用最后的 5 天来考虑季节性。

在所有这些情况下,可重复分割只需要将用于创建分割的逻辑放入版本控制,并确保每次更改此逻辑时更新模型版本。

分层分割

季节变化在不同季节之间的天气模式差异的示例是分层之后数据集分层的一个情况的例子。在进行分割之前,我们需要确保每个分割中都有所有季节的示例,因此我们按月份对数据集进行了分层。在训练数据集中使用每个月的前 20 天,在验证数据集中使用接下来的 5 天,在测试数据集中使用最后的 5 天。如果我们不关心连续天数之间的相关性,我们可以在每个月内随机分割日期。

数据集越大,我们对分层的担忧就越少。在非常大的数据集上,特征值在所有分割中的分布可能非常均匀。因此,在大规模机器学习中,只有在数据集倾斜的情况下分层的需求才很常见。例如,在航班数据集中,不到 1%的航班在上午 6 点之前起飞,因此满足这一标准的航班数量可能非常少。如果对于我们的业务用例来说,正确获取这些航班行为非常关键,我们应该根据起飞时间分层数据集,并均匀地分割每个分层。

出发时间是一个倾斜特征的例子。在不平衡分类问题(例如欺诈检测,其中欺诈示例数量相当少)中,我们可能希望根据标签分层数据集,并均匀地分割每个分层。如果我们有一个多标签问题,并且一些标签比其他标签更罕见,这也很重要。这些内容在“设计模式 10:重新平衡”的第三章中讨论。

非结构化数据

尽管本节重点放在结构化数据上,但相同的原则也适用于非结构化数据,如图像、视频、音频或自由格式文本。只需使用元数据进行拆分即可。例如,如果拍摄于同一天的视频之间存在关联,可以使用视频的元数据中的拍摄日期将视频拆分到独立数据集中。同样地,如果来自同一人的文本评论倾向于关联,可以使用评论者的user_id的 Farm Fingerprint 重复将评论拆分到数据集中。如果元数据不可用或实例之间不存在相关性,则使用 Base64 编码对图像或视频进行编码,并计算编码的指纹。

对于拆分文本数据集的一个自然方法可能是使用文本本身的哈希值进行拆分。然而,这类似于随机拆分,并不能解决评论之间的关联问题。例如,如果一个人在他们的负面评价中经常使用“惊人”这个词,或者如果一个人将所有星球大战电影评为不好,那么他们的评论是相关的。类似地,对于拆分图像或音频数据集的一个自然方法可能是使用文件名的哈希值进行拆分,但不能解决图像或视频之间的关联问题。因此,需要仔细考虑拆分数据集的最佳方法。根据我们的经验,通过考虑可能存在的相关性来设计数据拆分(和数据收集),可以解决许多机器学习性能不佳的问题。

在计算嵌入或预训练自编码器时,我们应确保首先对数据进行拆分,并仅在训练数据集上执行这些预计算。因此,在嵌入图像、视频或文本的数据之前,不应进行拆分,除非这些嵌入是在完全不同的数据集上创建的。

设计模式 23:桥接模式

桥接模式设计模式提供了将模型训练数据从其旧的原始数据模式适应到更新、更好数据的方法。这种模式很有用,因为当输入提供者改进其数据源时,通常需要一些时间来收集足够数量的改进后数据以充分训练替代模型。桥接模式允许我们使用尽可能多的新数据,但通过一些旧数据增强以提高模型准确性。

问题

考虑一个点对点销售应用程序,该应用程序建议适当的小费金额给送货人。该应用可能使用一个机器学习模型来预测小费金额,考虑订单金额、交货时间、交货距离等因素。这样的模型将在顾客实际添加的小费基础上进行训练。

假设模型的一个输入是支付类型。在历史数据中,这些记录为“现金”或“卡”。但是,假设支付系统已升级,现在提供了更详细的卡类型信息(礼品卡、借记卡、信用卡)。这是非常有用的信息,因为小费行为在这三种卡类型之间有所不同。

在预测时,新信息始终可用,因为我们始终在预测支付系统升级后进行的交易的小费金额。因为新信息非常有价值,并且已经在预测系统的生产中可用,我们希望尽快在模型中使用它。

我们无法仅使用新数据训练新模型,因为新数据的数量相当有限,仅限于支付系统升级后的交易。机器学习模型的质量高度依赖于用于训练的数据量,只用新数据训练的模型可能表现不佳。

解决方案

解决方案是将旧数据的模式与新数据匹配。然后,我们使用尽可能多的新数据来训练 ML 模型,并用旧数据进行增强。有两个问题需要解决。首先,我们如何解决旧数据仅具有两种支付类型类别,而新数据有四种类别的问题?其次,如何进行增强以创建训练、验证和测试数据集?

跨桥模式

考虑旧数据有两个类别(现金和卡)。在新模式中,卡类别现在更加细化(礼品卡、借记卡、信用卡)。我们知道,在旧数据中编码为“卡”的交易可能是这些类型之一,但实际类型未记录。可以通过概率或静态方法进行模式转换。我们建议使用静态方法,但首先通过概率方法进行解释会更容易理解。

概率方法

假设我们从新的训练数据中估计,卡交易中有 10%是礼品卡,30%是借记卡,60%是信用卡。每次将旧的训练示例加载到训练程序中时,我们可以通过在[0, 100)范围内生成均匀分布的随机数来选择卡类型,并在随机数小于 10 时选择礼品卡,在[10, 40)时选择借记卡,在其他情况下选择信用卡。只要我们训练足够的时期,任何训练示例都将按照实际发生频率显示所有三个类别。当然,新的训练示例始终会有实际记录的类别。

概率方法的理由是,我们将每个老示例视为已发生了数百次。当训练器遍历数据时,在每个时期,我们模拟这些实例之一。在模拟中,我们期望当卡被使用时,交易有 10% 的概率使用礼品卡。这就是为什么我们选“礼品卡”作为分类输入值的 10%。当然,这是简化的——仅因为礼品卡总体上使用频率为 10%,并不意味着对于任何具体交易,礼品卡将会被使用 10% 的时间。作为一个极端的例子,也许出租车公司不允许在机场行程中使用礼品卡,因此某些历史示例中根本没有礼品卡作为合法值。然而,在没有任何额外信息的情况下,我们将假设频率分布对所有历史示例都是相同的。

静态方法

类别变量通常进行独热编码。如果我们采用上述的概率方法并且训练足够长时间,老数据中“卡”交易被呈现给训练程序的平均独热编码值将会是[0, 0.1, 0.3, 0.6]。第一个 0 对应现金类别。第二个数字是 0.1,因为在卡交易中,10% 的时间这个数字会是 1,其他情况下为 0。类似地,我们有 0.3 表示借记卡,0.6 表示信用卡。

为了将老数据转换为新模式,我们可以将老的类别数据转换为这种表示形式,在这种表示中,我们插入从训练数据估计得到的新类别的先验概率。另一方面,新数据将会有[0, 0, 1, 0],表示这笔交易已知是通过借记卡支付的。

我们推荐静态方法而不是概率方法,因为如果概率方法运行足够长时间,实际上就是静态方法的结果。而且静态方法实现起来更简单,因为老数据中的每笔卡付款都有相同的值(4 元素数组[0, 0.1, 0.3, 0.6])。我们只需一行代码就可以更新老数据,而不是像概率方法那样编写生成随机数的脚本。而且计算上也更加经济。

增强数据

为了最大化使用新数据,请确保只使用数据的两个拆分部分,这在《设计模式 12:检查点》(ch04.xhtml#design_pattern_onetwo_checkpoints)的第四章中有所讨论。假设我们有 100 万个示例可用于旧模式,但只有 5000 个示例可用于新模式。我们应该如何创建训练和评估数据集?

让我们首先考虑评估数据集。重要的是要意识到,训练 ML 模型的目的是对未见过的数据进行预测。在我们的情况下,未见过的数据将完全匹配新模式的数据。因此,我们需要从新数据中设置足够数量的示例,以充分评估泛化性能。也许我们需要在评估数据集中有 2,000 个示例,以确保模型在生产环境中表现良好。评估数据集不会包含任何已经桥接以匹配新模式的旧示例。

我们如何知道我们是否需要 1,000 个或 2,000 个示例在评估数据集中?为了估计这个数字,计算当前生产模型的评估指标(该模型是在旧模式上训练的)在其评估数据集的子集上,并确定在评估指标一致之前子集必须有多大。

在不同子集上计算评估指标可以如下完成(通常情况下,完整代码在 GitHub 上 是本书代码库中的):

for subset_size in range(100, 5000, 100):
    sizes.append(subset_size)
    # compute variability of the eval metric
    # at this subset size over 25 tries
    scores = []
    for x in range(1, 25):
        indices = np.random.choice(N_eval, 
                           size=subset_size, replace=False)
        scores.append(
            model.score(df_eval[indices], 
                        df_old.loc[N_train+indices, 'tip'])
        )
    score_mean.append(np.mean(scores))
    score_stddev.append(np.std(scores))

在上述代码中,我们尝试不同的评估大小,分别为 100、200、…、5,000. 在每个子集大小上,我们对模型进行 25 次评估,每次使用完全评估集的不同随机抽样子集。因为这是当前生产模型的评估集(我们能够使用一百万个示例进行训练),这里的评估数据集可能包含数十万个示例。然后,我们可以计算 25 个子集上评估指标的标准偏差,将这个过程重复应用于不同的评估大小,并将这些标准偏差绘制成图表。得到的图表将类似于 图 6-3。

通过评估生产模型在不同大小子集上的表现来确定所需的评估示例数量,并通过子集大小跟踪评估指标的变化情况。在这里,标准偏差在大约 2,000 个示例时开始趋于稳定。

图 6-3. 通过评估生产模型在不同大小子集上的表现来确定所需的评估示例数量,并通过子集大小跟踪评估指标的变化情况。在这里,标准偏差在大约 2,000 个示例时开始趋于稳定。

从 图 6-3 中可以看出,所需的评估示例数量至少为 2,000 个,最好是 3,000 个或更多。让我们假设在本讨论的其余部分中,我们选择在 2,500 个示例上进行评估。

训练集将包含剩余的 2,500 个新样本(在保留 2,500 个用于评估后可用的新数据量)以及一些已经桥接以匹配新模式的较老样本。我们如何知道需要多少较老的例子?我们不知道。这是一个需要调整的超参数。例如,在梯度搜索中,我们从图 6-4(GitHub 上的笔记本详细介绍了全部细节)可以看出,在 20,000 个样本之前,评估指标急剧下降,然后开始趋于稳定。

通过进行超参数调整确定桥接的较老例子数量。在这种情况下,很明显,在桥接 20,000 个例子之后,收益递减。

图 6-4. 通过进行超参数调整确定桥接的较老例子数量。在这种情况下,很明显,在桥接 20,000 个例子之后,收益递减。

为了获得最佳结果,我们应该选择能够应付的最少数量的较老例子,理想情况下,随着新样本数量的增长,我们将越来越少依赖桥接的例子。在某个时刻,我们将能够完全摆脱较老的例子。

值得注意的是,在这个问题上,桥接确实带来了好处,因为当我们没有使用桥接的例子时,评估指标会变差。如果不是这种情况,那么需要重新审视填充方法(用于桥接的静态值选择方法)。我们建议在下一节中使用另一种填充方法(级联)。

警告

非常重要的是,比较在桥接示例上训练的新模型与评估数据集上未改变的老模型的性能。可能情况是,新信息尚未具备足够的价值。

因为我们将使用评估数据集来测试桥接模型是否具有价值,所以非常重要的是评估数据集不在训练或超参数调整过程中使用。因此,必须避免使用早停止或检查点选择等技术。相反,使用正则化来控制过拟合。训练损失将作为超参数调整的度量。更多关于如何通过仅使用两个分割来节省数据的讨论,请参阅第 4 章中关于检查点设计模式的详细信息。

折衷和替代方案

让我们看看一个通常提出但并不起作用的方法,一种复杂的桥接替代方案,以及解决类似问题的解决方案的扩展。

联合模式

可能会诱人的是简单地创建旧版和新版模式的联合。例如,我们可以定义支付类型模式为具有五个可能值:现金、卡片、礼品卡、借记卡和信用卡。这样做将使历史数据和新数据都有效,并且是我们在数据仓库中处理此类更改时采取的方法。这样,旧数据和新数据都是有效的,无需任何更改。

然而,对于机器学习来说,向后兼容的、联合模式并不适用。

在预测时,我们永远不会得到支付类型为“卡”的值,因为输入提供者已经全部升级了。事实上,所有这些训练实例都将是徒劳的。出于可重现性考虑(这是将此模式分类为可重现性模式的原因),我们需要将旧模式桥接到新模式中,而不能简单地将两个模式进行联合。

级联方法

统计学中的插补是一组可以用来替换缺失数据的技术。常见的插补技术是用该列在训练数据中的均值替换空值。为什么选择均值?因为在没有更多信息的情况下,并且假设值正态分布时,最可能的值是均值。

在主要解决方案中讨论的静态方法,即分配先验频率的方法,也是一种插补方法。我们假设分类变量按照频率表分布(我们从训练数据中估算出来),并且将均值的独热编码值(根据该频率分布)插补到“缺失”的分类变量中。

我们是否知道其他方法可以根据一些示例来估计未知值?当然!机器学习。我们可以做的是训练一系列模型(参见“设计模式 8:级联”在第三章)。第一个模型使用任何新示例来训练一个机器学习模型以预测卡类型。如果原始的技巧模型有五个输入,这个模型将有四个输入。第五个输入(支付类型)将是此模型的标签。然后,第一个模型的输出将用于训练第二个模型。

在实践中,级联模式为一些被认为是临时性的解决方案添加了太多复杂性,直到你有足够的新数据。静态方法实际上是最简单的机器学习模型——如果我们有无信息输入,那么这就是我们将得到的模型。我们建议采用静态方法,并且只有在静态方法效果不够好时才使用级联方法。

处理新功能

另一种需要桥接的情况是输入提供者向输入数据流添加额外信息。例如,在我们的出租车费用示例中,我们可以开始接收有关出租车是否打开雨刷或车辆是否移动的数据。根据这些数据,我们可以创建一个特征,表示出租车行程开始时是否下雨,以及出租车空闲时间的分数等。

如果我们有新的输入特征希望立即开始使用,我们应该通过插补新特征的值来桥接旧数据(其中这个新特征将缺失)。推荐的插补值选择有:

  • 如果特征是数值且正态分布,则特征的平均值。

  • 如果特征是数值且存在偏斜或大量异常值,则特征的中位数值。

  • 如果特征是分类的且可排序,则特征的中位数值。

  • 如果特征是分类的且不可排序,则特征的众数。

  • 如果特征是布尔值,则特征为真的频率。

如果特征是是否下雨的布尔值,则插补值可能是像 0.02 这样的值,如果在训练数据集中下雨的时间占比为 2%。如果特征是空闲分钟的比例,则可以使用中位数值。在所有这些情况下,级联模式方法仍然可行,但静态插补更简单且通常足够。

处理精度增加

当输入提供者增加其数据流的精度时,按照桥接方法创建一个训练数据集,其中包含更高分辨率数据的一部分,并加上一些旧数据。

对于浮点数值,不需要显式地调整旧数据以匹配新数据的精度。为了理解这一点,考虑一种情况:一些数据最初提供到小数点后一位(例如,3.5 或 4.2),但现在提供到小数点后两位(例如,3.48 或 4.23)。如果我们假设在旧数据中的 3.5 由在新数据中均匀分布于[3.45, 3.55]的值组成,那么静态插补值将是 3.5,这正是存储在旧数据中的值。

对于分类值——例如,如果旧数据将位置存储为州或省代码,而新数据提供县或区代码——使用主解决方案中描述的州内县的频率分布进行静态插补。

设计模式 24:窗口推断

窗口推理设计模式处理需要连续一系列实例才能运行推理的模型。这种模式通过外部化模型状态并从流分析管道调用模型来工作。当机器学习模型需要从时间窗口的聚合中计算特征时,此模式也非常有用。通过将状态外部化到流水线,窗口推理设计模式确保了在训练和服务之间正确重复计算动态、时间相关特征。这是避免在时间聚合特征情况下训练-服务偏差的一种方式。

问题

查看 2010 年 5 月在图 6-5 上展示的达拉斯-沃思堡(DFW)机场的到达延误情况(完整的笔记本在 GitHub 上)。

2010 年 5 月 10 日至 11 日达拉斯-沃思堡(DFW)机场的到达延误。异常到达延误用点标记。

图 6-5. 2010 年 5 月 10 日至 11 日达拉斯-沃思堡(DFW)机场的到达延误。异常到达延误用点标记。

到达延误表现出相当大的变异性,但仍然可以注意到异常的大到达延误(用点标记)。请注意,“异常”的定义根据上下文而异。在早晨(图表左上角),大多数航班准时到达,因此即使是小的峰值也是异常的。到了中午(5 月 10 日下午 12 点后),变异性增加,25 分钟的延误相当普遍,但 75 分钟的延误仍然是异常的。

特定延误是否异常取决于时间上下文,例如过去两小时内观察到的到达延误。为了确定延误是否异常,我们首先需要根据时间对数据帧进行排序(如图 6-5 中的图表,并在 pandas 中显示):

df = df.sort_values(by='scheduled_time').set_index('scheduled_time')

然后,我们需要对两小时的滑动窗口应用异常检测函数:

df['delay'].rolling('2h').apply(is_anomaly, raw=False)

异常检测函数is_anomaly可能非常复杂,但让我们简单地考虑丢弃极值并在两小时窗口中,如果数据值超过均值四个标准差,则将其视为异常:

def is_anomaly(d):
    outcome = d[-1] # the last item

    # discard min & max value & current (last) item
    xarr = d.drop(index=[d.idxmin(), d.idxmax(), d.index[-1]])    
    prediction = xarr.mean()
    acceptable_deviation = 4 * xarr.std()
    return np.abs(outcome - prediction) > acceptable_deviation

这在历史(训练)数据上运行,因为整个数据帧都在手头上。当然,在生产模型上运行推理时,我们将不会有整个数据帧。在生产中,我们将逐个接收航班到达信息,每到一个航班就会有一个延误值。因此,我们将只有一个时间戳上的单个延误值:

2010-02-03 08:45:00,19.0

鉴于上面的航班(在 2 月 3 日 08:45)晚点了 19 分钟,这是否不寻常? 通常,要对航班进行 ML 推断,我们只需要该航班的特征。 但是,在这种情况下,模型需要有关 06:45 到 08:45 之间所有飞往 DFW 机场的航班的信息:

2010-02-03 06:45:00,?
2010-02-03 06:?:00,?
...
2010-02-03 08:45:00,19.0

不可能一次进行单个航班的推断。 我们需要以某种方式向模型提供关于所有先前航班的信息。

当模型不仅需要一个实例而是一系列实例时,我们如何进行推断?

解决方案

解决方案是进行有状态流处理,即通过时间跟踪模型状态进行流处理:

  • 对航班到达数据应用滑动窗口。 滑动窗口将覆盖 2 小时,但窗口可以更频繁地关闭,例如每 10 分钟。 在这种情况下,将在过去 2 小时内每 10 分钟计算聚合值。

  • 内部模型状态(这可能是航班列表)每次新航班到达时都会更新航班信息,从而建立起 2 小时的历史航班数据记录。

  • 每次窗口关闭时(例如我们的例子中每 10 分钟),将在 2 小时内的航班列表上训练时间序列 ML 模型。 然后,此模型用于预测未来的航班延误以及此类预测的置信区间。

  • 时间序列模型参数外部化为状态变量。 我们可以使用时间序列模型,如自回归积分滑动平均(ARIMA)或长短期记忆(LSTMs),在这种情况下,模型参数将是 ARIMA 模型系数或 LSTM 模型权重。 为了保持代码易于理解,我们将使用零阶回归模型,² 因此,我们的模型参数将是平均航班延误和过去两小时窗口内航班延误的方差。

  • 当航班到达时,可以使用外部化模型状态对其到达延误进行异常分类,而不必拥有过去 2 小时内所有航班的完整列表。

我们可以使用 Apache Beam 进行流水线处理,因为同样的代码将适用于历史数据和新到达的数据。 在 Apache Beam 中,滑动窗口设置如下(完整代码在 GitHub 上):

windowed = (data
        | 'window' >> beam.WindowInto(
                beam.window.SlidingWindows(2 * 60 * 60, 10*60))

模型通过整合过去两小时收集的所有航班数据并传递给我们称之为ModelFn的函数来进行更新:

model_state = (windowed 
        | 'model' >> beam.transforms.CombineGlobally(ModelFn()))

ModelFn使用航班信息更新内部模型状态。 在这里,内部模型状态将包括一个带有窗口内航班的 pandas dataframe:

class ModelFn(beam.CombineFn):
    def create_accumulator(self):
        return pd.DataFrame()

    def add_input(self, df, window):
        return df.append(window, ignore_index=True)

每次窗口关闭时,提取输出。 这里的输出(我们称之为外部化模型状态)包括模型参数:

    def extract_output(self, df):
        if len(df) < 1:
            return {}
        orig = df['delay'].values
        xarr = np.delete(orig, [np.argmin(orig), np.argmax(orig)])
        return {
            'prediction': np.mean(xarr),
            'acceptable_deviation': 4 * np.std(xarr)
        }

外部化模型状态每 10 分钟更新一次,基于 2 小时滚动窗口:

窗口关闭时间 预测 可接受偏差
2010-05-10T06:35:00 -2.8421052631578947 10.48412597725367
2010-05-10T06:45:00 -2.6818181818181817 12.083729926046008
2010-05-10T06:55:00 -2.9615384615384617 11.765962341537781

提取模型参数的代码与 pandas 情况类似,但在 Beam 管道内完成。这使代码可以在流式处理中工作,但模型状态仅在滑动窗口的上下文中可用。为了在每次到达的航班上进行推理,我们需要外部化模型状态(类似于在无状态服务函数模式中将模型权重导出到文件以将其与训练程序的上下文分离,其中这些权重是计算的):

model_external = beam.pvalue.AsSingleton(model_state)

可以使用此外部化状态检测给定航班是否异常:

def is_anomaly(flight, model_external_state):
    result = flight.copy()
    error = flight['delay'] - model_external_state['prediction']
    tolerance = model_external_state['acceptable_deviation']
    result['is_anomaly'] = np.abs(error) > tolerance
    return result

然后将is_anomaly函数应用于滑动窗口的最后一个项目:

anomalies = (windowed 
        | 'latest_slice' >> beam.FlatMap(is_latest_slice)
        | 'find_anomaly' >> beam.Map(is_anomaly, model_external))

折中与替代方案

上述提到的解决方案在高吞吐量数据流的情况下具有计算效率,但如果可以在线更新机器学习模型参数,则可以进一步改进。这种模式也适用于状态化机器学习模型,如循环神经网络,以及当无状态模型需要有状态输入特征时。

减少计算开销

在问题部分,我们使用了以下 pandas 代码:

dfw['delay'].rolling('2h').apply(is_anomaly, raw=False);

而在解决方案部分,Beam 代码如下所示:

windowed = (data
        | 'window' >> beam.WindowInto(
                beam.window.SlidingWindows(2 * 60 * 60, 10*60))
model_state = (windowed 
        | 'model' >> beam.transforms.CombineGlobally(ModelFn()))

由于is_anomaly函数的调用频率以及模型参数(均值和标准差)的计算频率,pandas 中的滚动窗口和 Apache Beam 中的滑动窗口存在有意义的区别。这些将在下文讨论。

按元素与按时间间隔

在 pandas 代码中,is_anomaly函数在数据集中的每个实例上都被调用。异常检测代码计算模型参数,并立即将其应用于窗口中的最后一个项目。在 Beam 管道中,模型状态也是在每个滑动窗口上创建的,但在这种情况下,滑动窗口是基于时间的。因此,模型参数每 10 分钟仅计算一次。

异常检测本身在每个实例上执行:

anomalies = (windowed 
        | 'latest_slice' >> beam.FlatMap(is_latest_slice)
        | 'find_anomaly' >> beam.Map(is_anomaly, model_external))

注意,这将计算昂贵的训练与计算廉价的推理仔细分离。昂贵的部分每 10 分钟仅执行一次,同时允许对每个实例进行异常分类。

高吞吐量数据流

数据量不断增加,其中大部分增加是由实时数据引起的。因此,这种模式必须应用于高吞吐量数据流——其中元素数量可能超过每秒数千个项目。例如,考虑来自网站点击流或来自计算机、可穿戴设备或汽车的机器活动流。

使用流水线处理的建议解决方案具有优势,因为它避免了在每个实例中重新训练模型,这是问题声明中的 pandas 代码所做的事情。然而,建议的解决方案通过创建一个包含所有接收记录的内存数据框架来还原这些收益。如果我们每秒接收 5,000 个项目,那么在 10 分钟内存数据框架将包含 3 百万条记录。因为任何时刻都需要维护 12 个滑动窗口(每个 10 分钟窗口,每个 2 小时),内存需求可能变得相当大。

为了在窗口结束时计算模型参数,存储所有接收的记录可能会变得棘手。当数据流量大时,能够每个元素更新模型参数变得非常重要。可以通过如下更改ModelFn来实现此操作(完整代码位于 GitHub):

class OnlineModelFn(beam.CombineFn):
    ...
    def add_input(self, inmem_state, input_dict):
        (sum, sumsq, count) = inmem_state
        input = input_dict['delay']
        return (sum + input, sumsq + input*input, count + 1)

    def extract_output(self, inmem_state):
        (sum, sumsq, count) = inmem_state
        ...
            mean = sum / count
            variance = (sumsq / count) - mean*mean
            stddev = np.sqrt(variance) if variance > 0 else 0
            return {
                'prediction': mean,
                'acceptable_deviation': 4 * stddev
            }
        ...

主要区别在于,仅在内存中保存了三个浮点数(sumsum²count),用于提取输出模型状态所需的模型参数,而不是接收实例的整个数据框架。逐个实例更新模型参数称为在线更新,只有在模型训练不需要对整个数据集进行迭代时才能完成。因此,在上述实现中,通过维护 x² 的总和来计算方差,这样在计算均值后就不需要对数据进行第二次遍历。

流式 SQL

如果我们的基础架构包括一个能够处理流数据的高性能 SQL 数据库,那么可以通过使用聚合窗口来实现 Windowed Inference 模式的另一种方式(完整代码位于 GitHub)。

我们从 BigQuery 中提取飞行数据:

WITH data AS (
  SELECT 
    PARSE_DATETIME('%Y-%m-%d-%H%M',
                   CONCAT(CAST(date AS STRING), 
                   '-', FORMAT('%04d', arrival_schedule))
                   ) AS scheduled_arrival_time,
     arrival_delay
  FROM `bigquery-samples.airline_ontime_data.flights`
  WHERE arrival_airport = 'DFW' AND SUBSTR(date, 0, 7) = '2010-05'
),

然后,我们通过计算一个时间窗口的模型参数来创建model_state,该窗口指定为前两小时到前一秒:

model_state AS (
  SELECT
    scheduled_arrival_time,
    arrival_delay,
    AVG(arrival_delay) OVER (time_window) AS prediction,
    4*STDDEV(arrival_delay) OVER (time_window) AS acceptable_deviation
  FROM data
  WINDOW time_window AS 
    (ORDER BY UNIX_SECONDS(TIMESTAMP(scheduled_arrival_time))
     RANGE BETWEEN 7200 PRECEDING AND 1 PRECEDING)
)

最后,我们对每个实例应用异常检测算法:

SELECT 
  *,
  (ABS(arrival_delay - prediction) > acceptable_deviation) AS is_anomaly 
FROM model_state

结果看起来像表 6-1,到达延迟 54 分钟被标记为异常,因为所有先前的航班都提前到达。

表 6-1. BigQuery 查询结果,确定传入的航班数据是否异常

scheduled_arrival_time arrival_delay prediction acceptable_deviation is_anomaly
2010-05-01T05:45:00 -18.0 -8.25 62.51399843235114 false
2010-05-01T06:00:00 -13.0 -10.2 56.878818553131005 false
2010-05-01T06:35:00 -1.0 -10.666 51.0790237442599 false
2010-05-01T06:45:00 -9.0 -9.28576 48.86521793473886 false
2010-05-01T07:00:00 54.0 -9.25 45.24220532707422 true

与 Apache Beam 解决方案不同,分布式 SQL 的效率将允许我们计算以每个实例为中心的 2 小时时间窗口(而不是以 10 分钟窗口的分辨率)。然而,其缺点是 BigQuery 往往具有相对较高的延迟(以秒计),因此不能用于实时控制应用程序。

序列模型

通过传递前几个实例的滑动窗口给推断函数的窗口推断模式,不仅对异常检测或甚至时间序列模型有用。特别是对于任何需要历史状态的模型类,如序列模型,它非常有用。例如,翻译模型需要看到几个连续的单词才能执行翻译,以便翻译考虑到单词的上下文。毕竟,“left”、“Chicago”和“road”的翻译在句子“I left Chicago by road”和“Turn left on Chicago Road”中有所不同。

为了性能原因,翻译模型将被设置为无状态,并要求用户提供上下文。例如,如果模型是无状态的,那么可以根据流量的增加自动扩展模型的实例,并且可以并行调用以获得更快的翻译。因此,翻译莎士比亚《哈姆雷特》中著名的独白到德语可能会按照这些步骤进行,从中间开始,加粗的单词是要翻译的单词:

输入(9 个单词,每边 4 个) 输出
未曾探索的国度,从的彼岸,没有旅行者回来 dessen
未曾探索的国度,从其彼岸没有旅行者回来,这令 Bourn
国度,从其彼岸没有旅行者回来,这令 Kein
从其彼岸没有旅行者回来,这令思 Reisender

因此,客户端将需要一个流水线。该流水线可以接收输入的英文文本,将其标记化,每次发送九个标记,收集输出,并将其连接成德语句子和段落。

大多数序列模型,如循环神经网络和 LSTM,需要流水线以实现高性能推断。

有状态的特征

即使模型本身是无状态的,如果模型的输入特征需要状态,窗口推理模式也可能很有用。例如,假设我们正在训练一个模型来预测到达延误,而模型的一个输入是出发延误。我们可能希望将过去两小时该机场航班的平均出发延误作为模型的一个输入。

在训练期间,我们可以使用 SQL 窗口函数来创建数据集:

WITH data AS (
  SELECT 
     SAFE.PARSE_DATETIME('%Y-%m-%d-%H%M',
                   CONCAT(CAST(date AS STRING), '-', 
                   FORMAT('%04d', departure_schedule))
                   ) AS scheduled_depart_time,
     arrival_delay,
     departure_delay,
     departure_airport
  FROM `bigquery-samples.airline_ontime_data.flights`
  WHERE arrival_airport = 'DFW'
),

  SELECT
    * EXCEPT(scheduled_depart_time),
    EXTRACT(hour from scheduled_depart_time) AS hour_of_day,
    AVG(departure_delay) OVER (depart_time_window) AS avg_depart_delay
  FROM data
  WINDOW depart_time_window AS 
    (PARTITION BY departure_airport ORDER BY 
     UNIX_SECONDS(TIMESTAMP(scheduled_depart_time))
     RANGE BETWEEN 7200 PRECEDING AND 1 PRECEDING)

训练数据集现在包括平均延误作为另一个特征:

到达延误 出发延误 出发机场 小时 平均出发延误
1 -3.0 -7.0 LFT 8 -4.0
2 56.0 50.0 LFT 8 41.0
3 -14.0 -9.0 LFT 8 5.0
4 -3.0 0.0 LFT 8 -2.0

然而,在推理过程中,我们将需要一个流水线来计算这个平均出发延误,以便将其提供给模型。为了限制训练与服务之间的偏差,最好在流水线中使用相同的 SQL 作为一个滚动窗口函数,而不是尝试将 SQL 翻译成 Scala、Python 或 Java。

批处理预测请求

另一种情况是,即使模型是无状态的,我们也可能希望使用窗口推理,例如当模型部署在云端,但客户端嵌入到设备或本地时。在这种情况下,将推理请求逐个发送到云端部署的模型可能会导致网络延迟过大。在这种情况下,可以使用 “设计模式 19: 两阶段预测” 来处理,其中第一阶段使用管道收集一些请求,第二阶段将其作为一个批次发送到服务端。

这仅适用于对延迟具有容忍性的用例。如果我们在五分钟内收集输入实例,那么客户端必须能够容忍获取预测结果的最多五分钟的延迟。

设计模式 25: 工作流管道

在工作流管道设计模式中,我们通过将机器学习流程中的步骤容器化和编排来解决创建端到端可重复的管道的问题。容器化可以明确完成,也可以使用简化该过程的框架来完成。

问题

一个数据科学家可能能够从头到尾(在 Figure 6-6 中描绘)在单个脚本或笔记本中运行数据预处理、训练和模型部署步骤。然而,随着机器学习过程中的每个步骤变得更加复杂,并且组织内更多的人希望为这个代码库做出贡献,从单个笔记本运行这些步骤将无法扩展。

典型端到端 ML 工作流程中的步骤。这并不是包罗万象,但捕捉了 ML 开发过程中最常见的步骤。

图 6-6. 典型端到端 ML 工作流程中的步骤。这并不是包罗万象,但捕捉了 ML 开发过程中最常见的步骤。

在传统的编程中,单体应用程序被描述为所有应用程序逻辑都由单个程序处理的情况。要在单体应用程序中测试一个小功能,我们必须运行整个程序。部署或调试单体应用程序也是如此。对一个程序的一个小错误修复进行部署需要部署整个应用程序,这很快就会变得笨拙。当整个代码库紧密耦合时,个别开发人员很难调试错误并独立于应用程序的不同部分工作。近年来,单体应用程序已被微服务架构取代,其中业务逻辑的各个部分作为独立的(微)代码包构建和部署。通过微服务,大型应用程序被拆分成更小、更易管理的部分,使开发人员可以独立地构建、调试和部署应用程序的各个部分。

这个单体与微服务的讨论为扩展 ML 工作流程、促进协作以及确保 ML 步骤在不同工作流程中可重复和可重用提供了一个很好的类比。当某人独自构建 ML 模型时,“单体”方法可能更快地进行迭代。这也常常有效,因为一个人积极参与开发和维护每个部分:数据收集和预处理、模型开发、训练和部署。然而,当扩展此工作流程时,组织中的不同人员或团队可能负责不同的步骤。为了扩展 ML 工作流程,我们需要一种方法让正在构建模型的团队能够独立于数据预处理步骤运行试验。我们还需要跟踪管道每个步骤的性能,并管理每个过程部分生成的输出文件。

此外,当每个步骤的初始开发完成后,我们希望安排像重新训练这样的操作,或者创建在环境更改(例如将新的训练数据添加到存储桶)时触发的事件驱动的流水线运行。在这种情况下,解决方案需要允许我们一次性运行整个工作流程,同时仍能够跟踪单个步骤的输出并追踪错误。

解决方案

为了处理机器学习流程的扩展带来的问题,我们可以将 ML 工作流程中的每个步骤制作成单独的容器化服务。容器保证我们能够在不同的环境中运行相同的代码,并且能够在运行之间保持一致的行为。这些单独的容器化步骤联合在一起形成一个管道,可以通过 REST API 调用运行。由于管道步骤在容器中运行,我们可以在开发笔记本、本地基础设施或托管的云服务上运行它们。这种管道工作流程允许团队成员独立构建管道步骤。容器还提供了一种可重现的方式来端到端运行整个管道,因为它们保证了库依赖版本和运行环境之间的一致性。此外,因为容器化管道步骤允许关注点分离,各个步骤可以使用不同的运行时和语言版本。

创建管道的工具有很多选择,包括本地和云端选项,如云 AI 平台管道TensorFlow Extended(TFX),Kubeflow 管道(KFP),MLflowApache Airflow。在这里展示 Workflow Pipeline 设计模式,我们将使用 TFX 定义我们的管道,并在云 AI 平台管道上运行它,这是一个托管服务,可在 Google 云上使用 Google Kubernetes Engine(GKE)作为底层容器基础设施来运行 ML 管道。

TFX 管道中的步骤被称为组件,提供了预构建和可定制的组件。通常,TFX 管道中的第一个组件用于从外部源接收数据。这称为ExampleGen组件,其中“example”是机器学习术语,指用于训练的标记实例。ExampleGen组件允许您从 CSV 文件、TFRecords、BigQuery 或自定义源获取数据。例如,BigQueryExampleGen组件允许我们通过指定查询来连接存储在 BigQuery 中的数据到我们的管道,然后将该数据存储为 TFRecords 在 GCS 存储桶中,以便下一个组件使用。通过传递查询来定制此组件。这些ExampleGen组件解决了 ML 工作流程中数据收集阶段的问题,如 Figure 6-6 所述。

工作流程的下一步是数据验证。一旦我们摄入数据,我们可以将其传递给其他组件进行转换或分析,然后再训练模型。StatisticsGen 组件接收从ExampleGen步骤摄入的数据,并生成所提供数据的摘要统计信息。SchemaGen 从我们摄入的数据中输出推断的模式。利用SchemaGen的输出,ExampleValidator 对我们的数据集执行异常检测,并检查数据漂移或潜在的训练–服务偏差迹象³。Transform 组件还从SchemaGen输出,并在这里执行特征工程,将我们的数据输入转换为模型所需的正确格式。这可能包括将自由格式文本输入转换为嵌入、标准化数值输入等。

一旦我们的数据准备好输入模型,我们可以将其传递给Trainer组件。在设置Trainer组件时,我们指定一个定义模型代码的函数,并可以指定在哪里训练模型。在这里,我们将展示如何从这个组件使用 Cloud AI Platform 进行训练。最后,Pusher组件处理模型部署。TFX 提供了许多其他预构建组件——我们只在此处包含了一些在示例流水线中使用的组件。

在此示例中,我们将使用 BigQuery 中的 NOAA 飓风数据集构建一个推断 SSHS 代码⁴ 的模型。为了专注于流水线工具,我们将保持特征、组件和模型代码相对简短。我们的流水线步骤如下所述,大致遵循 Figure 6-6 中概述的工作流程:

  1. 数据收集:运行查询从 BigQuery 获取飓风数据。

  2. 数据验证:使用ExampleValidator组件来识别异常并检查数据漂移。

  3. 数据分析和预处理:在数据上生成一些统计信息并定义模式。

  4. 模型训练:在 AI 平台上训练一个tf.keras模型。

  5. 模型部署:将训练好的模型部署到 AI 平台预测⁵。

当我们的流水线完成时,我们将能够通过单个 API 调用来调用上述整个流程。让我们首先讨论典型 TFX 流水线的脚手架和在 AI 平台上运行它的过程。

构建 TFX 流水线。

我们将使用 tfx 命令行工具来创建和调用我们的管道。管道的新调用称为 运行,与我们对管道本身的更新不同,例如添加新组件。TFX CLI 可以完成这两种操作。我们可以在一个单独的 Python 脚本中定义我们管道的支架,这个脚本有两个关键部分:

  • 一个实例化的 tfx.orchestration.pipeline,在其中我们定义我们的管道及其包含的组件。

  • 一个来自 tfx 库的 kubeflow_dag_runner 实例。我们将使用它来创建和运行我们的管道。除了 Kubeflow runner 外,还有一个用于使用 Apache Beam 在本地运行 TFX 管道的 API。

我们的管道(参见 GitHub 上的完整代码)将包括上述五个步骤或组件,我们可以通过以下方式定义我们的管道:

pipeline.Pipeline(
      pipeline_name='huricane_prediction',
      pipeline_root='path/to/pipeline/code',
      components=[
          bigquery_gen, statistics_gen, schema_gen, train, model_pusher
      ]
  )

要使用 TFX 提供的 BigQueryExampleGen 组件,我们提供将获取数据的查询。我们可以在一行代码中定义这个组件,其中 query 是我们的 BigQuery SQL 查询字符串:

bigquery_gen = BigQueryExampleGen(query=query)

使用管道的另一个好处是它提供了工具来跟踪每个组件的输入、输出结果和日志。例如,statistics_gen 组件的输出是我们数据集的摘要,我们可以在 图 6-7 中看到这些内容。statistics_gen 是 TFX 中的一个预构建组件,使用 TF Data Validation 生成我们数据集的摘要统计信息。

TFX 管道中 statistics_gen 组件的输出结果。

图 6-7. TFX 管道中 statistics_gen 组件的输出结果。

在 Cloud AI Platform 上运行管道

我们可以在 Cloud AI Platform Pipelines 上运行 TFX 管道,这将为我们管理基础设施的底层细节。要将管道部署到 AI Platform,我们将我们的管道代码打包为一个 Docker 容器,并将其托管在 Google Container Registry(GCR)。⁶ 一旦我们的容器化管道代码已推送到 GCR,我们将使用 TFX CLI 创建该管道:

tfx pipeline create  \
--pipeline-path=kubeflow_dag_runner.py \
--endpoint='your-pipelines-dashboard-url' \
--build-target-image='gcr.io/your-pipeline-container-url'

在上述命令中,endpoint 对应着 AI Platform Pipelines 仪表板的 URL。完成后,我们将在管道仪表板中看到我们刚刚创建的管道。create 命令创建一个可以通过创建运行来调用的管道 资源

tfx run create --pipeline-name='your-pipeline-name' --endpoint='pipeline-url'

运行此命令后,我们将能够看到一个图表,实时更新我们的管道通过每个步骤的情况。从管道仪表板,我们可以进一步检查单个步骤生成的任何工件、元数据等。我们可以在图 6-8 中看到一个单个步骤的输出示例。

我们可以直接在 GKE 上的容器化管道中训练我们的模型,但是 TFX 提供了一个实用程序,用于将 Cloud AI 平台训练作为我们过程的一部分。TFX 还有一个扩展,用于将我们训练好的模型部署到 AI 平台预测中。我们将在我们的管道中利用这两个集成。AI 平台训练使我们能够以成本效益的方式利用专门的硬件来训练我们的模型,例如 GPU 或 TPU。它还提供了使用分布式训练的选项,这可以加快训练时间并减少训练成本。我们可以在 AI 平台控制台内跟踪单个训练作业及其输出。

ML 管道中 schema_gen 组件的输出。顶部菜单栏显示每个单独管道步骤可用的数据。

图 6-8. ML 管道中 schema_gen 组件的输出。顶部菜单栏显示每个单独管道步骤可用的数据。
提示

使用 TFX 或 Kubeflow Pipelines 构建管道的一个优点是,我们不会被锁定在 Google Cloud 上。我们可以在Azure ML PipelinesAmazon SageMaker或本地环境上运行我们在这里演示的相同代码。

在 TFX 中实现训练步骤,我们将使用Trainer组件,并传递关于要用作模型输入的训练数据以及我们的模型训练代码的信息。TFX 提供了在 AI 平台上运行训练步骤的扩展,我们可以通过导入tfx.extensions.google_cloud_ai_platform.trainer并提供有关我们的 AI 平台训练配置的详细信息来使用它。这包括我们的项目名称、地区以及包含训练代码的容器的 GCR 位置。

类似地,TFX 还有一个 AI 平台Pusher 组件 用于将训练好的模型部署到 AI 平台预测中。为了在 AI 平台上使用Pusher组件,我们提供有关我们模型的名称和版本的详细信息,以及一个服务函数,告诉 AI 平台它应该期望我们模型的输入数据格式。有了这些,我们就可以通过 AI 平台完成一个完整的管道,该管道摄取数据、分析数据、运行数据转换,最终使用 AI 平台进行模型训练和部署。

为什么它有效

不将我们的 ML 代码作为一个流水线运行,其他人可靠地复现我们的工作将会很困难。他们需要使用我们的预处理、模型开发、训练和服务代码,并尝试复制我们运行它的相同环境,同时考虑库依赖、认证等因素。如果有逻辑控制基于上游组件输出选择下游组件的逻辑,这个逻辑也必须能够可靠地复制。工作流程管道设计模式允许其他人在本地和云环境中运行和监控我们整个 ML 工作流程的端到端,并且能够调试单个步骤的输出。将管道的每个步骤容器化确保其他人能够复现我们用来构建它和在管道中捕获的整个工作流的环境。这也允许我们在数月后重现环境以支持监管需求。使用 TFX 和 AI 平台管道,仪表板还为我们提供了一个 UI,用于跟踪每个管道执行生成的输出艺术品。这在“权衡与替代方案”中进一步讨论。

此外,每个管道组件在自己的容器中,不同团队成员可以并行构建和测试管道的不同部分。这允许更快的开发,并最小化与更单片化 ML 流程相关的风险,其中步骤彼此不可分割地联系在一起。例如,构建数据预处理步骤所需的包依赖和代码可能与模型部署的不同。通过将这些步骤作为管道的一部分构建,每个部分都可以在单独的容器中构建,具有自己的依赖项,并在完成后整合到更大的管道中。

总结一下,工作流管道模式为我们带来了有向无环图(DAG)的优势,以及像 TFX 这样的管道框架提供的预构建组件。因为管道是一个 DAG,我们可以选择执行单个步骤或从头到尾运行整个管道。这还为我们提供了对管道的每个步骤在不同运行中的日志记录和监控,并允许在一个集中的地方跟踪每个步骤和管道执行的艺术品。预构建组件为 ML 工作流程的常见组件(包括训练、评估和推断)提供了独立、即用即用的步骤。这些组件作为单独的容器运行,无论我们选择在哪里运行我们的管道。

权衡与替代方案

使用管道框架的主要替代方案是使用临时方法来运行我们的 ML 工作流的步骤,并为每个步骤跟踪笔记本和相关输出。当然,在将我们的 ML 工作流的不同部分转换为组织良好的管道时会涉及一些开销。在本节中,我们将探讨 Workflow Pipeline 设计模式的一些变体和扩展:手动创建容器、使用持续集成和持续交付(CI/CD)工具自动化管道、从开发到生产工作流管道的流程以及构建和编排管道的替代工具。我们还将探讨如何使用管道进行元数据跟踪。

创建自定义组件

我们可以定义自己的容器作为组件来构建我们的管道,而不是使用预构建或可自定义的 TFX 组件,或者将 Python 函数转换为组件。

要使用由 TFX 提供的基于容器的组件,我们使用create_container_component方法,向其传递我们组件的输入和输出,以及基础 Docker 镜像和容器的任何入口点命令。例如,以下基于容器的组件调用命令行工具bq下载 BigQuery 数据集:

component = create_container_component(
    name='DownloadBQData',
    parameters={
        'dataset_name': string,
        'storage_location': string
    },
    `image``=`'google/cloud-sdk:278.0.0'`,`
,
    command=[
        'bq', 'extract', '--compression=csv', '--field_delimiter=,',
        InputValuePlaceholder('dataset_name'),
        InputValuePlaceholder('storage_location'),
    ]
)

最好使用已包含大部分所需依赖项的基础镜像。我们正在使用 Google Cloud SDK 镜像,该镜像提供了bq命令行工具。

还可以使用@component装饰器将自定义 Python 函数转换为 TFX 组件。为了演示它,假设我们有一个用于准备整个管道中使用的资源的步骤,例如创建一个 Cloud Storage 存储桶。我们可以使用以下代码定义此自定义步骤:

from google.cloud import storage
client = storage.Client(project="your-cloud-project")

@component
def CreateBucketComponent(
    bucket_name: Parameter[string] = 'your-bucket-name',
    ) -> OutputDict(bucket_info=string):
  client.create_bucket('gs://' + bucket_name)
  bucket_info = storage_client.get_bucket('gs://' + bucket_name)

  return {
    'bucket_info': bucket_info
  }

然后,我们可以将此组件添加到我们的管道定义中:

create_bucket = CreateBucketComponent(
    bucket_name='my-bucket')

将 CI/CD 集成到管道中

除了通过仪表板或通过 CLI 或 API 编程方式调用管道外,我们可能希望在将模型投入生产时自动运行我们的管道。例如,当有一定量的新训练数据可用时,我们可能希望调用我们的管道。或者在管道的源代码更改时,我们可能希望触发管道运行。将 CI/CD 集成到我们的工作流管道中可以帮助连接触发事件和管道运行。

有许多托管服务可用于设置触发器,以在我们需要基于新数据重新训练模型时运行流水线。我们可以使用托管调度服务按计划调用我们的流水线。或者,我们可以使用像 Cloud Functions 这样的无服务器事件驱动服务,在存储位置添加新数据时调用我们的流水线。在我们的函数中,我们可以指定条件,例如添加新数据的阈值,以创建新的流水线运行。一旦有足够的新训练数据可用,我们就可以实例化一个流水线运行来进行重新训练和重新部署模型,如 Figure 6-9 所示。

使用云函数调用流水线的 CI/CD 工作流,当存储位置新增足够的新数据时。

图 6-9. 使用云函数调用流水线的 CI/CD 工作流,当存储位置新增足够的新数据时。

如果我们希望基于源代码更改触发我们的流水线,像 Cloud Build 这样的托管 CI/CD 服务可以帮助。当 Cloud Build 执行我们的代码时,它作为一系列容器化步骤运行。这种方法很适合在流水线的上下文中使用。我们可以将 Cloud Build 连接到我们的流水线代码所在的 GitHub Actions 或 GitLab Triggers。当代码提交时,Cloud Build 将基于新代码构建与我们的流水线相关联的容器,并创建一个运行。

Apache Airflow 和 Kubeflow Pipelines

除了 TFX,Apache AirflowKubeflow Pipelines 都是实现工作流程管道模式的替代方案。与 TFX 一样,Airflow 和 KFP 都将流水线视为 DAG,其中每个步骤的工作流由 Python 脚本定义。然后,它们采用此脚本并提供 API 来处理调度并在指定的基础设施上编排图形。Airflow 和 KFP 都是开源的,因此可以在本地或云端运行。

Airflow 在数据工程中被广泛使用,因此对组织的数据 ETL 任务值得考虑。然而,虽然 Airflow 提供了强大的工具来运行作业,但它是作为通用解决方案构建的,并未考虑 ML 工作负载。另一方面,KFP 是专为 ML 设计的,并且在比 TFX 更低的层次上操作,提供了更多在如何定义流水线步骤上的灵活性。虽然 TFX 实现了其自己的编排方式,但 KFP 允许我们通过其 API 选择如何编排我们的流水线。TFX、KFP 和 Kubeflow 之间的关系总结在 Figure 6-10 中。

TFX、Kubeflow Pipelines、Kubeflow 和基础架构之间的关系。TFX 在 Kubeflow Pipelines 的顶层运行,具有预构建组件,提供常见工作流步骤的特定方法。Kubeflow Pipelines 提供 API 用于定义和编排 ML 管道,提供更灵活的实现每个步骤的方式。TFX 和 KFP 都在 Kubeflow 上运行,这是一个在 Kubernetes 上运行基于容器的 ML 工作负载的平台。这个图中的所有工具都是开源的,因此管道运行的基础架构由用户决定,一些选项包括 GKE、Anthos、Azure、AWS 或本地部署。

图 6-10. TFX、Kubeflow Pipelines、Kubeflow 和基础架构之间的关系。TFX 在 Kubeflow Pipelines 的顶层运行,具有预构建组件,提供常见工作流步骤的特定方法。Kubeflow Pipelines 提供 API 用于定义和编排 ML 管道,提供更灵活的实现每个步骤的方式。TFX 和 KFP 都在 Kubeflow 上运行,这是一个在 Kubernetes 上运行基于容器的 ML 工作负载的平台。这个图中的所有工具都是开源的,因此管道运行的基础架构由用户决定,一些选项包括 GKE、Anthos、Azure、AWS 或本地部署。

开发与生产管道

从开发到生产,管道调用的方式通常会发生变化。我们可能希望从笔记本构建和原型化我们的管道,在那里我们可以通过运行笔记本单元重新调用我们的管道,调试错误,并从同一环境中更新代码。一旦准备好投入生产,我们可以将组件代码和管道定义移动到一个单一脚本中。在脚本中定义了我们的管道之后,我们可以安排运行,并使组织内的其他人以可重复的方式调用管道变得更加容易。用于将管道投入生产的工具之一是 Kale,它使用 Kubeflow Pipelines API 将 Jupyter 笔记本代码转换为脚本。

生产管道还允许对 ML 工作流进行编排。编排意味着在我们的管道中添加逻辑以确定将执行哪些步骤,以及这些步骤的结果将是什么。例如,我们可能决定只想将准确率达到 95% 或更高的模型部署到生产环境中。当新可用数据触发管道运行并训练更新的模型时,我们可以添加逻辑以检查我们评估组件的输出,如果准确度超过我们的阈值,则执行部署组件,否则结束管道运行。在本节前面讨论过的 Airflow 和 Kubeflow Pipelines 都提供了管道编排的 API。

ML 管道中的谱系跟踪

管道的另一个功能是使用它们来跟踪模型元数据和工件,也称为血统跟踪。每次我们调用管道时,都会生成一系列工件。这些工件可能包括数据集摘要、导出的模型、模型评估结果、特定管道调用的元数据等。血统跟踪允许我们可视化我们的模型版本历史及其他相关模型工件。例如,在 AI 平台管道中,我们可以使用管道仪表板查看特定模型版本是在哪些数据上进行训练的,可以按数据模式和日期进行分解。图 6-11 展示了 AI 平台上运行的 TFX 管道的 Lineage Explorer 仪表板。这使我们能够追踪与特定模型相关的输入和输出工件。

AI 平台管道的 TFX 管道的 Lineage Explorer 部分。

图 6-11. AI 平台管道的 TFX 管道的 Lineage Explorer 部分。

使用血统跟踪管理管道运行期间生成的工件的一个好处是,它支持云端和本地环境。这为我们在模型训练和部署的位置以及模型元数据存储的位置之间提供了灵活性。血统跟踪也是使 ML 管道可复制的重要方面,因为它允许比较不同管道运行生成的元数据和工件。

设计模式 26: 特征存储

特征存储 设计模式通过将特征创建过程与使用这些特征的模型开发过程解耦,简化了跨项目管理和重用特征的过程。

问题

良好的特征工程对于许多机器学习解决方案的成功至关重要。然而,它也是模型开发中最耗时的部分之一。有些特征需要大量领域知识才能正确计算,而业务策略的变化可能会影响特征的计算方式。为了确保这些特征能够以一致的方式计算,最好由领域专家而不是机器学习工程师控制这些特征。一些输入字段可能允许采用不同的数据表示方式(见第二章),使其更适合机器学习。机器学习工程师或数据科学家通常会尝试多种不同的转换方法,以确定哪些是有帮助的,哪些不是,在决定最终模型中将使用哪些特征之前。许多情况下,用于机器学习模型的数据并非来自单一来源。某些数据可能来自数据仓库,某些数据可能作为非结构化数据存储在存储桶中,而其他数据可能通过流式传输实时收集。这些数据的结构也可能在各个来源之间有所不同,因此在将其输入模型之前,每个输入都需要进行自己的特征工程步骤。这种开发通常在虚拟机或个人机器上进行,导致特征创建与构建模型的软件环境紧密相关,而模型变得越复杂,这些数据管道就变得越复杂。

针对单次模型开发和训练,可以采用按需创建特征的临时方法,但随着组织规模扩大,这种特征工程方法变得不切实际,会带来重大问题:

  • 临时特征不容易重复使用。特征一遍又一遍地被重新创建,无论是由个别用户还是团队内部,或者永远停留在创建它们的管道(或笔记本电脑)中。这对于复杂计算的高级特征尤为棘手。这可能是因为它们通过昂贵的过程派生,比如预训练用户或目录项嵌入。其他时候,它可能是因为特征从业务优先事项、合同可用性或市场细分等上游过程中捕获。当高级特征,如客户过去一个月的订单数量,涉及到随时间的聚合时,也会引入复杂性。为每个新项目从头开始创建相同的特征,会浪费大量的努力和时间。

  • 数据治理如果每个机器学习项目都不同地从敏感数据计算特征,会变得困难。

  • 临时特征不容易在团队之间或跨项目共享。在许多组织中,多个团队使用相同的原始数据,但不同团队可能会以不同方式定义特征,并且没有易于访问的特征文档。这也阻碍了团队之间有效的跨部门合作,导致工作被隔离并且存在不必要的重复劳动。

  • 训练和服务中使用的临时特征不一致——即存在训练和服务的偏差。通常使用历史数据进行训练,批量特征在离线情况下创建。但是,服务通常在线进行。如果训练中的特征处理流程与生产环境中用于服务的流程有任何不同(例如,使用不同的库、预处理代码或语言),那么我们就存在训练和服务的偏差的风险。

  • 将特征投入生产是困难的。在进入生产环境时,没有标准化的框架用于为在线机器学习模型提供特征以及为离线模型训练提供批处理特征。模型通常是使用批处理过程中创建的特征进行离线训练,但在生产环境中服务时,这些特征通常更注重低延迟而不是高吞吐量。特征生成和存储框架不能灵活处理这两种场景。

简言之,临时特征工程方法减慢了模型开发速度,并导致重复劳动和工作效率低下。此外,特征创建在训练和推断中不一致,存在意外将标签信息引入模型输入流程的风险。

解决方案

解决方案是创建一个共享特征存储库,一个集中存储和记录特征数据集的地方,这些特征数据集将用于构建机器学习模型,并可以在项目和团队之间共享。特征存储库充当数据工程师为特征创建而设计的流水线和数据科学家使用这些特征构建模型的工作流之间的接口(图 6-12)。这样一来,就有一个中央仓库来存放预计算的特征,这加快了开发时间,并有助于特征的发现。这还允许将版本控制、文档编制和访问控制等基本软件工程原则应用于创建的特征。

典型的特征存储库具有两个关键设计特点:工具化处理大型特征数据集的能力,以及支持低延迟访问(用于推断)和大批量访问(用于模型训练)的特征存储方式。还有一个元数据层,简化不同特征集的文档编制和版本控制,以及管理加载和检索特征数据的 API。

特征存储库提供了原始数据源和模型训练与服务之间的桥梁。

图 6-12。特征存储提供了原始数据源和模型训练与服务之间的桥梁。

数据或机器学习工程师的典型工作流程是从数据源(结构化或流式数据)读取原始数据,使用他们喜欢的处理框架对数据进行各种转换,并将转换后的特征存储在特征存储中。与创建支持单个机器学习模型的特征管道不同,特征存储模式将特征工程与模型开发分离。特别是在构建特征存储时经常使用 Apache Beam、Flink 或 Spark 等工具,因为它们可以处理批处理和流处理数据。这也减少了训练和服务偏差的发生,因为特征数据由相同的特征创建管道填充。

创建特征后,它们将存储在数据存储中,以便用于训练和服务。对于服务中的特征检索,速度被优化。生产中的模型可能需要在毫秒内生成实时预测,因此低延迟至关重要。但是,对于训练来说,较高的延迟并不是问题。相反,重点是高吞吐量,因为历史特征将以大批量用于训练。特征存储通过使用不同的数据存储解决了这两种用例,用于在线和离线特征访问。例如,特征存储可以使用 Cassandra 或 Redis 作为在线特征检索的数据存储,并使用 Hive 或 BigQuery 获取历史大批量特征集。

最终,典型的特征存储将包含许多不同的特征集,其中包含从各种原始数据源创建的特征。元数据层用于记录特征集并提供注册表,以便团队之间轻松发现特征并进行跨团队协作。

Feast

作为此模式在实际中的示例,请考虑Feast,这是由 Google Cloud 和Gojek开发的面向机器学习的开源特征存储。它围绕Google Cloud 服务使用 BigQuery 进行离线模型训练和 Redis 进行低延迟的在线服务(参见图 6-13 中的高级架构)。Apache Beam 用于特征创建,从而为批处理和流处理提供一致的数据管道。

Feast 特征存储的高级架构。Feast 围绕 Google BigQuery、Redis 和 Apache Beam 构建。

图 6-13。Feast 特征存储的高级架构。Feast 围绕 Google BigQuery、Redis 和 Apache Beam 构建。

要了解这在实践中是如何工作的,我们将使用一个公共的 BigQuery 数据集,其中包含关于纽约市出租车乘车的信息。⁷ 表的每一行包含接送时间戳、接送点的纬度和经度、乘客数量以及出租车费用。ML 模型的目标将是使用这些特征预测出租车费用,表示为fare_amount

该模型受益于从原始数据中工程化额外特征。例如,由于出租车乘车基于行程的距离和时长,预先计算接送点之间的距离是一个有用的特征。一旦在数据集上计算了这个特征,我们可以将其存储在特征集中以供将来使用。

向 Feast 添加特征数据

数据使用FeatureSet在 Feast 中存储。FeatureSet包含数据架构和数据源信息,无论是来自 pandas 数据框还是流式 Kafka 主题。FeatureSet是 Feast 知道从哪里获取所需特征数据、如何摄取以及一些基本数据类型特征的方式。特征组可以一起摄取和存储,并且特征集在这些存储中提供高效的存储和逻辑命名空间。

一旦我们的特征集被注册,Feast 将启动一个 Apache Beam 作业,从源中填充特征存储。特征集用于生成离线和在线特征存储,确保开发人员用相同的数据训练和服务他们的模型。Feast 确保源数据符合特征集的预期模式。

将特征数据导入 Feast 有四个步骤,如图 6-14 所示。

将特征数据导入 Feast 有四个步骤:创建一个 FeatureSet,添加实体和特征,注册 FeatureSet,并将特征数据导入 FeatureSet。

图 6-14。将特征数据导入 Feast 有四个步骤:创建一个 FeatureSet,添加实体和特征,注册 FeatureSet,并将特征数据导入 FeatureSet。

这四个步骤如下:

  1. 创建一个FeatureSet。特征集指定实体、特征和源。

  2. FeatureSet添加实体和特征。

  3. 注册FeatureSet。这在 Feast 中创建了一个命名特征集。特征集不包含特征数据。

  4. 将特征数据加载到FeatureSet中。

本示例的完整代码可以在附带本书的存储库中找到。

创建一个 FeatureSet

我们通过设置一个 Python SDK 客户端连接到 Feast 部署:

from feast import Client, FeatureSet, Entity, ValueType

# Connect to an existing Feast deployment
client = Client(core_url='localhost:6565')

要检查客户端是否连接,可以通过打印现有功能集合来实现,命令为 client.list_feature_sets()。如果这是一个新的部署,它将返回一个空列表。要创建一个新的功能集合,请调用 FeatureSet 类并指定功能集合的名称:

# Create a feature set
taxi_fs = FeatureSet("taxi_rides")

向 FeatureSet 添加实体和特征

在 Feast 的上下文中,FeatureSets 包括实体和特征。实体用作查找特征值的键,并在创建用于训练或服务的数据集时用于在不同特征集之间进行特征连接。实体作为数据集中任何相关特征的标识符。它是一个可以建模和存储信息的对象。在打车或食品配送服务的上下文中,相关实体可能是 customer_idorder_iddriver_idrestaurant_id。在流失模型的上下文中,实体可以是 customer_idsegment_id。这里的实体是 taxi_id,每次行程的出租车供应商的唯一标识符。

在这个阶段,我们创建的功能集称为 taxi_rides,不包含任何实体或特征。我们可以使用 Feast 核心客户端从包含原始数据输入和实体的 pandas 数据帧中指定这些内容,如 表 6-2 所示。

表 6-2. 纽约出租车行程数据集包含有关出租车行程的信息。实体是 taxi_id,每次行程的出租车供应商的唯一标识符

接载时间 接载纬度 接载经度 卸载纬度 卸载经度 乘客数量 出租车 ID 费用金额
1 2020-05-31 11:29:48 UTC 40.787403 -73.955848 40.723042 -73.993106 2 0 15.3
2 2011-04-06 14:30:00 UTC 40.645343 -73.776698 40.71489 -73.987242 2 0 45.0
3 2020-04-24 13:11:06 UTC 40.650105 -73.785373 40.638858 -73.9678 2 2 32.1
4 2020-02-20 09:07:00 UTC 40.762365 -73.925733 40.740118 -73.986487 2 1 21.3

这里的 pickup_datetime 时间戳很重要,因为需要检索批处理特征,并且用于确保批处理特征的正确时间连接。要创建额外的特征,如欧几里得距离,请将数据集加载到 pandas 数据框架中并计算该特征:

# Load dataframe
taxi_df = pd.read_csv("taxi-train.csv")

# Engineer features, Euclidean distance
taxi_df['euclid_dist'] = taxi_df.apply(compute_dist, axis=1)

我们可以使用 .add(...) 向特征集添加实体和特征。或者,方法 .infer_fields_from_df(...) 将直接从 pandas 数据帧为我们的 FeatureSet 创建实体和特征。我们只需指定表示实体的列名。然后,从数据帧中推断出 FeatureSet 的特征的模式和数据类型:

# Infer the features of the feature set from the pandas DataFrame
    taxi_fs.infer_fields_from_df(taxi_df, 
               entities=[Entity(name='taxi_id', dtype=ValueType.INT64)],
replace_existing_features=`True`)

注册 FeatureSet

创建 FeatureSet 后,我们可以使用client.apply(taxi_fs)将其注册到 Feast。要确认功能集已正确注册或探索另一个功能集的内容,我们可以使用.get_feature_set(...)检索它:

print(client`.``get_feature_set`("taxi_rides"))

这将返回一个 JSON 对象,其中包含taxi_rides功能集的数据架构:

{
  "spec": {
    "name": "taxi_rides",
    "entities": [
      {
        "name": "key",
        "valueType": "INT64"
      }
    ],
    "features": [
      {
        "name": "dropoff_lon",
        "valueType": "DOUBLE"
      },
      {
        "name": "pickup_lon",
        "valueType": "DOUBLE"
      },
      ...
    ...
    ],
    }
}

将功能数据摄入 FeatureSet

一旦我们对架构满意,我们可以使用.ingest(...)将数据框架功能数据摄入 Feast。我们将指定称为taxi_fsFeatureSet和用于填充功能数据的名为taxi_df的数据框架。

# Load feature data into Feast for this specific feature set
client`.`ingest(taxi_fs, taxi_df)

此摄取步骤期间的进度打印到屏幕上,显示我们已经在 Feast 中将 28,247 行摄入到taxi_rides功能集中:

100%|██████████|28247/28247 [00:02<00:00, 2771.19rows/s]
Ingestion complete!

Ingestion statistics:
Success: 28247/28247 rows ingested

在此阶段,调用client.list_feature_sets()现在将列出我们刚刚创建的功能集taxi_rides并返回[default/taxi_rides]。这里,default指的是 Feast 内功能集的项目范围。在实例化功能集时可以更改此设置,以保留某些功能集在项目访问范围内。

警告

数据集可能随时间变化,从而导致功能集也发生变化。在 Feast 中,一旦创建功能集,只能进行少数更改。例如,允许进行以下更改:

  • 添加新功能。

  • 移除现有功能。(请注意,功能被删除并保留记录,因此它们并未完全删除。这将影响新功能能否使用先前删除的功能名称。)

  • 更改功能的架构。

  • 更改功能集的来源或max_age的功能集示例。

不允许以下更改:

  • 更改功能集名称。

  • 更改实体。

  • 更改现有功能的名称。

从 Feast 检索数据

一旦功能集已经通过功能进行了数据源化,我们可以检索历史或在线功能。用户和生产系统通过 Feast 服务数据访问层检索功能数据。由于 Feast 支持离线和在线存储类型,因此通常同时部署两者,如图 6-15 所示。同一功能数据包含在两个功能存储中,确保训练和服务之间的一致性。

功能数据可以从离线中检索,用于模型训练的历史功能,或在线用于服务。

图 6-15。功能数据可以从离线中检索,用于模型训练的历史功能,或在线用于服务。

这些部署可以通过单独的在线和批处理客户端访问:

_feast_online_client = Client(serving_url='localhost:6566')
_feast_batch_client = Client(serving_url='localhost:6567',
                             core_url='localhost:6565')

批量服务

对于模型训练,历史特征检索由 BigQuery 支持,并使用批处理服务客户端.get_batch_features(...)访问。在这种情况下,我们提供一个包含实体和时间戳的 pandas 数据帧,特征数据将与之结合。这允许 Feast 基于请求的特征生成准确的时点数据集:

# Create a entity df of all entities and timestamps
`entity_df` = pd.DataFrame(
    {
        "datetime": taxi_df.datetime,
        "taxi_id": taxi_df.taxi_id,
    }
)

要检索历史特征,可以通过特征集名称和特征名称(以冒号分隔)引用特征集中的特征,例如,taxi_rides:pickup_lat

    FS_NAME = taxi_rides
model_features = ['pickup_lat',
                     'pickup_lon',
                     'dropoff_lat',
                     'dropoff_lon',
                     'num_pass',
                     'euclid_dist']
    label = 'fare_amt'

    features = model_features + [label]

# Retrieve training dataset from Feast
dataset = _feast_batch_client`.`get_batch_features(
    feature_refs=[FS_NAME + ":" + feature for feature in features],
    entity_rows=entity_df).to_dataframe()

数据帧数据集现在包含我们模型的所有特征和标签,直接从特征存储中提取。

在线服务

对于在线服务,Feast 仅存储最新的实体数值,而不是所有历史数值。Feast 的在线服务设计非常低延迟,并提供由Redis支持的 gRPC API。例如,在使用训练模型进行在线预测时,我们使用.get_online_features(...)指定要捕获的特征和实体:

# retrieve online features for a single taxi_id
online_features = _feast_online_client.get_online_features(
    feature_refs=["taxi_rides:pickup_lat",
"taxi_rides:pickup_lon",
    "taxi_rides:dropoff_lat",       
"taxi_rides:dropoff_lon",
                     "taxi_rides:num_pass",
"taxi_rides:euclid_dist"],
    entity_rows=[
        GetOnlineFeaturesRequest.EntityRow(
            fields={
                "taxi_id": Value(
                    int64_val=5)
            }
        )
    ]
)

这将online_features保存为一个映射列表,列表中的每个项包含提供的实体(例如,taxi_id = 5)的最新特征值:

field_values {
  fields {
    key: "taxi_id"
    value {
      int64_val: 5
    }
  }
  fields {
    key: "taxi_rides:dropoff_lat"
    value {
      double_val: 40.78923797607422
    }
  }
  fields {
    key: "taxi_rides:dropoff_lon"
    value {
      double_val: -73.96871948242188
    }
  …

为了对这个示例进行在线预测,我们将从返回的对象中的字段值作为名为predict_df的 pandas 数据帧传递给model.predict

predict_df = pd.DataFrame.from_dict(online_features_dict)
model.predict(predict_df)

为什么它有效

特征存储的工作原理在于将特征工程与特征使用解耦,允许在模型开发期间独立进行特征开发和创建。随着特征添加到特征存储中,它们立即可用于训练和服务,并存储在单个位置。这确保了模型训练和服务之间的一致性。

例如,作为面向客户的应用程序提供服务的模型可能只从客户端接收到 10 个输入值,但这些 10 个输入值可能需要通过特征工程转换成更多特征。这些工程特征保存在特征存储中。在开发期间检索特征的管道与服务模型时的管道必须相同是至关重要的。特征存储确保了特征的一致性(图 6-16)。

Feast 通过在后端使用 Beam 进行特征摄入管道来实现这一点,将特征值写入特征集,并使用 Redis 和 BigQuery 进行在线和离线(分别)特征检索(图 6-17)。⁸ 与任何特征存储一样,摄入管道还处理可能导致某些数据在一个存储中而不在另一个存储中的部分失败或竞争条件。

特征存储确保特征工程流水线在模型训练和服务之间保持一致。另请参阅 https://docs.feast.dev/。

图 6-16. 特征存储确保特征工程流水线在模型训练和服务之间保持一致。另请参阅https://docs.feast.dev/

Feast 在后端使用 Beam 进行特征创建,Redis 和 BigQuery 用于在线和离线特征检索。

图 6-17. Feast 在后端使用 Beam 进行特征摄入,Redis 和 BigQuery 用于在线和离线特征检索。

不同系统可能以不同速率生成数据,特征存储库足够灵活,能够处理这些不同的节奏,无论是在摄入过程中还是在检索期间(图 6-18)。例如,传感器数据可以实时产生,每秒到达,或者可能有一个每月生成的文件,由外部系统报告上个月交易的摘要。每一个这些都需要被处理并摄入到特征存储库中。同样地,从特征存储库检索数据可能有不同的时间视野。例如,用户面向的在线应用可以使用最低延迟,使用最新的秒级特征,而在训练模型时,特征以更大的批量离线拉取,但延迟较高。

特征存储设计模式可以处理数据在训练期间进行大批量高度扩展和为在线应用提供极低延迟的需求。

图 6-18. 特征存储设计模式可以处理数据在训练期间进行大批量高度扩展和为在线应用提供极低延迟的需求。

没有单个数据库可以既扩展到可能的数据量级(可能达到几 TB)在毫秒级的极低延迟上处理。特征存储通过在线和离线特征存储的分开实现了这一点,并确保在两种情况下以一致的方式处理特征。

最后,特征存储库充当特征数据集的版本控制存储库,允许将代码和模型开发的相同 CI/CD 实践应用于特征工程过程。这意味着新的机器学习项目从目录中进行特征选择,而不是从头开始进行特征工程,使组织能够实现规模经济效应——随着新特征的创建和添加到特征存储库中,构建重复使用这些特征的新模型变得更加简单和快速。

折衷与替代方案

我们讨论的 Feast 框架是建立在 Google BigQuery、Redis 和 Apache Beam 之上的。然而,也有一些特征存储依赖于其他工具和技术堆栈。虽然特征存储是规模化管理特征的推荐方式,tf.transform 提供了一种解决训练-服务偏差问题的替代方案,但不能解决特征的可重用性问题。还有一些特征存储的替代用法尚未详细介绍,例如特征存储如何处理来自不同来源和以不同频率到达的数据。

替代实现

许多大型科技公司,如 Uber、LinkedIn、Airbnb、Netflix 和 Comcast,都拥有自己版本的特征存储,尽管架构和工具各不相同。Uber 的 Michelangelo Palette 是围绕 Spark/Scala 构建的,使用 Hive 进行离线特征创建和 Cassandra 进行在线特征。Hopsworks 提供了 Feast 的另一种开源特征存储替代方案,构建在使用 Spark 和 pandas 的数据框架上,使用 Hive 进行离线存储和 MySQL Cluster 进行在线特征访问。Airbnb 在其生产 ML 框架 Zipline 中构建了自己的特征存储。它使用 Spark 和 Flink 进行特征工程作业,使用 Hive 进行特征存储。

无论使用哪种技术堆栈,特征存储的主要组件都是相同的:

  • 用于快速处理大规模特征工程作业的工具,例如 Spark、Flink 或 Beam。

  • 用于存储创建的特征集的存储组件,例如 Hive、云存储(Amazon S3、Google Cloud Storage)、BigQuery、Redis、BigTable 和/或 Cassandra。Feast 使用的组合(BigQuery 和 Redis)针对离线与在线(低延迟)特征检索进行了优化。

  • 元数据层用于记录特征版本信息、文档和特征注册表,以简化特征集的发现和共享。

  • 用于从特征存储区摄取和检索特征的 API。

转换设计模式

如果在训练和推理期间特征工程代码不同,存在两个代码源不一致的风险。这导致训练-服务偏差,因为特征可能不同,模型预测可能不可靠。特征存储通过使其特征工程作业将特征数据写入在线和离线数据库来解决此问题。特征存储本身并不执行特征转换,但提供了一种将上游特征工程步骤与模型服务分离并提供时点正确性的方式。

在本章讨论的 Transform 设计模式还提供了一种保持特征转换分离和可复制的方法。例如,tf.transform可以用来使用完全相同的代码预处理数据,用于训练模型和在生产环境中提供预测,从而消除训练和服务之间的偏差。这确保了训练和服务特征工程流水线的一致性。

然而,特征存储提供了tf.transform不具备的特征重用优势。虽然tf.transform流水线确保可复制性,但这些特征仅为该模型创建并开发,并不容易与其他模型和流水线共享或重用。

另一方面,tf.transform 特别注意确保在服务过程中使用加速硬件进行特征创建,因为它是服务图的一部分。目前特征存储通常不提供这种能力。

设计模式 27:模型版本管理

在模型版本设计模式中,通过将更改的模型部署为具有不同 REST 端点的微服务来实现向后兼容性。这是本章讨论的许多其他模式的必要前提。

问题

正如我们在数据漂移(见第一章介绍)中所看到的,随着时间推移,模型可能变得陈旧,并且需要定期更新,以确保其反映组织的变化目标以及与其训练数据相关的环境。将模型更新部署到生产环境将不可避免地影响模型在新数据上的行为方式,这提出了一个挑战——我们需要一种方法来保持生产模型的更新,同时确保现有模型用户的向后兼容性。

对现有模型的更新可能包括更改模型架构以提高准确性,或者使用更近期数据重新训练模型以解决漂移问题。虽然这些更改可能不需要不同的模型输出格式,但它们会影响用户从模型获取的预测结果。例如,让我们想象一下,我们正在构建一个模型,根据书籍描述预测书籍的流派,并使用预测的流派向用户推荐。我们最初在一个旧经典书籍数据集上训练了我们的模型,但现在我们可以使用数千个更近期书籍的新数据来进行训练。在更新的数据集上训练可以提高我们整体模型的准确性,但在旧的“经典”书籍上略微降低了准确性。为了处理这个问题,我们需要一个解决方案,让用户可以选择我们模型的旧版本。

或者,我们模型的最终用户可能开始需要更多关于模型如何得出特定预测的信息。在医疗用例中,医生可能需要查看造成模型预测存在疾病的 X 光中的区域,而不仅仅依赖于预测的标签。在这种情况下,部署模型的响应需要更新以包含这些突出显示的区域。这个过程被称为可解释性,并且在第七章中进一步讨论。

当我们部署模型更新时,我们很可能也希望有一种方式来跟踪模型在生产环境中的表现,并与以前的迭代进行比较。我们可能还希望以一种方式测试新模型,只涉及我们用户的一个子集。性能监控和分割测试以及其他可能的模型更改,将通过每次更新单个生产模型来解决将会很困难。这样做会破坏那些依赖我们模型输出匹配特定格式的应用程序。为了处理这个问题,我们需要一种解决方案,允许我们在不破坏现有用户的情况下持续更新我们的模型。

解决方案

为了优雅地处理模型的更新,使用不同的 REST 端点部署多个模型版本。这确保了向后兼容性——通过在给定时间内部署多个版本的模型,依赖旧版本的用户仍然能够使用服务。版本控制还允许在版本间进行精细化的性能监控和分析跟踪。我们可以比较准确性和使用统计数据,并使用这些数据来确定何时将特定版本下线。如果我们有一个想要仅对少数用户进行测试的模型更新,模型版本设计模式使得进行 A/B 测试成为可能。

此外,通过模型版本控制,我们的每个部署版本都是一个微服务——因此将模型的更改与应用程序前端分离开来。要为新版本添加支持,我们团队的应用程序开发人员只需更改指向模型的 API 端点的名称。当然,如果新模型版本引入了模型响应格式的更改,我们将需要修改应用程序以适应这些更改,但模型和应用程序代码仍然是分开的。因此,数据科学家或机器学习工程师可以在我们自己的环境中部署和测试新的模型版本,而不必担心破坏我们的生产应用程序。

模型用户类型

当我们提到我们模型的“终端用户”时,这包括两类不同的人群。如果我们将模型 API 端点提供给组织外的应用程序开发人员使用,这些开发人员可以被视为一类模型用户。他们正在构建依赖我们模型为他人提供预测服务的应用程序。模型版本化所带来的向后兼容性好处对这些用户至关重要。如果我们模型响应的格式发生变化,应用程序开发人员可能希望使用旧的模型版本,直到他们更新其应用程序代码以支持最新的响应格式。

另一类终端用户指的是使用调用我们已部署模型的应用程序的人。这可以是依赖我们模型来预测图像中疾病存在的医生,使用我们的图书推荐应用程序的人,我们组织的业务单位分析我们构建的收入预测模型输出的人,等等。这类用户较少可能遇到向后兼容性问题,但可能希望在我们的应用程序中启用新功能时选择开始使用的时间。此外,如果我们能够将用户分为不同的组(即基于他们的应用使用情况),我们可以根据他们的偏好为每个组提供不同的模型版本。

使用托管服务进行模型版本控制

为了演示版本控制,我们将构建一个预测航班延误的模型,并将此模型部署到 Cloud AI Platform Prediction。因为我们在前几章中已经看过 TensorFlow 的 SavedModel,这里我们将使用 XGBoost 模型。

训练完模型后,我们可以将其导出以准备服务:

model.save_model('model.bst')

要将此模型部署到 AI 平台,我们需要创建一个指向 Cloud Storage 存储桶中的 model.bst 的模型版本。

在 AI 平台中,一个模型资源可以关联多个版本。要使用 gcloud CLI 创建新版本,我们将在终端中运行以下命令:

gcloud ai-platform versions create 'v1' \
  --model 'flight_delay_prediction' \
  --origin gs://your-gcs-bucket \
  --runtime-version=1.15 \
  --framework 'XGBOOST' \
  --python-version=3.7

部署了此模型后,它可以通过 HTTPS URL 中的端点 /models/flight_delay_predictions/versions/v1 访问,这个端点绑定到我们的项目。由于这是我们目前唯一部署的版本,它被视为 默认 版本。这意味着如果我们在 API 请求中不指定版本,预测服务将使用 v1. 现在我们可以向部署的模型发送符合模型期望格式的示例来进行预测,本例中是一个包含 110 个元素的虚拟编码机场代码数组(完整代码请参见 GitHub 上的笔记本)。该模型返回 sigmoid 输出,一个介于 0 到 1 之间的浮点值,表示给定航班延误超过 30 分钟的可能性。

要向已部署的模型发出预测请求,我们将使用以下 gcloud 命令,其中 input.json 是一个包含要发送给预测的新行分隔示例的文件:

gcloud ai-platform predict --model 'flight_delay_prediction' 
--version 'v1' 
--json-request 'input.json'

如果我们发送五个示例进行预测,我们将会收到一个五元素数组,对应每个测试示例的 sigmoid 输出,如下所示:

[0.019, 0.998, 0.213, 0.002, 0.004]

现在我们的生产环境中已经有一个可用的模型,假设我们的数据科学团队决定将模型从 XGBoost 更改为 TensorFlow,因为这样可以提高准确性,并让他们能够使用 TensorFlow 生态系统中的附加工具。该模型具有相同的输入和输出格式,但其架构和导出的资产格式已更改。现在我们的模型不再是一个 .bst 文件,而是以 TensorFlow SavedModel 格式存储。理想情况下,我们可以将底层模型资产与应用程序前端分开管理,这样可以让应用程序开发人员专注于应用程序功能,而不是模型格式的变化,这种变化不会影响最终用户与模型的交互方式。这就是模型版本控制的用武之地。我们将把 TensorFlow 模型作为第二个版本部署在同一个 flight_delay_prediction 模型资源下。用户可以通过更改 API 端点中的版本名称,轻松升级到新版本以获取更好的性能。

要部署我们的第二个版本,我们将导出模型并复制到我们之前使用的存储桶中的新子目录中。我们可以使用与上述相同的部署命令,将版本名称替换为 v2,并指向新模型的 Cloud Storage 位置。如 图 6-19 所示,我们现在可以在云控制台中看到已部署的两个版本。

云 AI 平台控制台中管理模型和版本的仪表板。

图 6-19. 云 AI 平台控制台中管理模型和版本的仪表板。

注意,我们还将 v2 设置为新的默认版本,因此如果用户未指定版本,他们将从 v2 收到响应。由于我们的模型的输入和输出格式相同,客户可以升级而无需担心破坏性更改。

提示

Azure 和 AWS 都提供类似的模型版本服务。在 Azure 上,模型部署和版本控制可通过 Azure 机器学习 实现。在 AWS 上,这些服务则通过 SageMaker 提供。

作为 ML 工程师,在将模型的新版本部署为 ML 模型端点时,可能希望使用 API 网关(例如 Apigee),该网关确定要调用哪个模型版本。进行此操作的原因有很多,包括通过分割测试测试新版本。对于分割测试,也许他们想要测试一个模型更新,观察它如何影响应用程序用户总体参与度的随机选择的 10%用户组。API 网关通过用户的 ID 或 IP 地址确定要调用的部署模型版本。

部署多个模型版本后,AI 平台允许在各个版本之间进行性能监控和分析。这使我们能够将错误追溯到特定版本,监控流量,并将其与我们应用程序中收集的其他数据结合起来。

折衷和替代方案

虽然我们推荐模型版本控制设计模式而不是维护单个模型版本,但在上述解决方案之外还有几种实施替代方案。在这里,我们将讨论其他无服务器和开源工具,以及创建多个服务功能的方法。我们还将讨论何时创建一个全新的模型资源而不是一个版本。

其他无服务器版本控制工具

我们使用了一个专门设计用于 ML 模型版本控制的托管服务,但我们也可以通过其他无服务器提供的服务达到类似的结果。在底层,每个模型版本都是一个具有指定输入和输出格式的无状态函数,部署在 REST 端点后面。因此,我们可以使用例如Cloud Run这样的服务,在单独的容器中构建和部署每个版本。每个容器都有唯一的 URL,并且可以通过 API 请求调用。这种方法使我们在配置部署模型环境时具有更大的灵活性,让我们能够添加诸如模型输入的服务器端预处理功能。在我们上面的航班示例中,我们可能不希望客户需要对分类值进行独热编码。相反,我们可以让客户将分类值作为字符串传递,并在我们的容器中处理预处理。

为什么我们要使用像 AI 平台预测这样的托管 ML 服务,而不是更通用的无服务器工具?由于 AI 平台专门用于 ML 模型部署,它内置了针对 ML 优化的 GPU 支持。它还处理依赖管理。当我们部署我们的 XGBoost 模型时,我们无需担心安装正确的 XGBoost 版本或其他库依赖。

TensorFlow Serving

而不是使用 Cloud AI 平台或其他基于云的无服务器提供的服务来进行模型版本控制,我们可以使用像TensorFlow Serving这样的开源工具。实施 TensorFlow Serving 的推荐方法是通过最新的tensorflow/serving Docker 镜像使用 Docker 容器。使用 Docker,我们可以使用任何我们想要的硬件来提供模型,包括 GPU。TensorFlow Serving API 内置了对模型版本控制的支持,遵循与解决方案部分讨论的类似方法。除了 TensorFlow Serving 之外,还有其他开源模型服务选项,包括SeldonMLFlow

多个服务功能

部署多个版本的另一种选择是为导出的模型的单个版本定义多个服务函数。“设计模式 16:无状态服务函数”(介绍见第五章)解释了如何将训练好的模型导出为一个在生产中提供服务的无状态函数。当模型输入需要预处理以将客户端发送的数据转换为模型期望的格式时,这尤为有用。

为了处理不同模型终端用户组的需求,我们可以在导出模型时定义多个服务函数。这些服务函数是一个导出模型版本的一部分,并且该模型被部署到单个 REST 端点。在 TensorFlow 中,服务函数使用模型签名实现,定义模型期望的输入和输出格式。我们可以使用@tf.function装饰器定义多个服务函数,并为每个函数传递一个输入签名。

在应用程序代码中,当我们调用部署的模型时,我们会根据从客户端发送的数据确定要使用哪个服务函数。例如,这样的请求:

{"signature_name": `"`get_genre`"`, "instances": … }

将会发送到名为get_genre的导出签名,而像以下这样的请求:

{"signature_name": `"`get_genre_with_explanation`"``,` "instances": … }

将会发送到名为get_genre_with_explanation的导出签名。

因此,部署多个签名可以解决向后兼容性问题。然而,有一个显著的区别——只有一个模型,当部署该模型时,所有签名同时更新。在我们从提供单一流派到提供多个流派的原始示例中,模型架构已更改。多签名方法在这个例子中不适用,因为我们有两个不同的模型。当我们希望保持模型的不同版本分开并随时间淘汰旧版本时,多签名解决方案也不合适。

如果您希望将来维护两个模型签名,使用多个签名比使用多个版本更好。在有些客户只想要最佳答案,而其他客户希望既有最佳答案又有解释的场景中,将所有签名与新模型一起更新具有额外的好处,而不是每次模型重新训练和重新部署时逐个更新版本。

有哪些情况下我们可能希望保留模型的两个版本?对于文本分类模型,我们可能有一些客户需要将原始文本发送给模型,而另一些客户能够在获取预测之前将原始文本转换为矩阵。根据客户的请求数据,模型框架可以确定使用哪种服务函数。将文本嵌入矩阵传递给模型比预处理原始文本要便宜,所以这是一个多个服务函数能够减少服务器端处理时间的例子。值得注意的是,我们可以拥有多个服务函数 多个模型版本,尽管这可能会增加太多复杂性的风险。

新模型与新模型版本

有时很难决定是创建另一个模型版本还是完全新的模型资源。当模型的预测任务发生变化时,我们建议创建一个新模型。新的预测任务通常会导致不同的模型输出格式,更改这一点可能会导致破坏现有客户端。如果我们不确定是否要使用新版本或模型,我们可以考虑是否希望现有客户端升级。如果答案是肯定的,那么我们很可能已经改进了模型而没有改变预测任务,并且创建一个新版本就足够了。如果我们以一种需要用户决定是否要进行更新的方式更改了模型,那么我们可能会希望创建一个新的模型资源。

为了看到这一实践,让我们返回到我们的航班预测模型,看一个例子。当前模型已经定义了它认为的延迟(晚 30 分钟以上),但是我们的最终用户可能对此有不同的看法。有些用户认为只要晚 15 分钟就算是延迟,而另一些用户认为只有晚一个小时以上才算延迟。让我们想象一下,我们现在希望我们的用户能够将他们自己对延迟的定义纳入考虑,而不是使用我们的定义。在这种情况下,我们将使用 “设计模式 5:重新定义 ”(在 第三章 中讨论)来将其更改为回归模型。该模型的输入格式保持不变,但现在的输出是代表延迟预测的数值。

当我们的模型用户解析此响应时,与第一个版本显然是不同的。借助我们最新的回归模型,应用开发者可以选择在用户搜索航班时显示预测延迟,而不是像第一个版本中那样替换“这个航班通常延迟超过 30 分钟”。在这种情况下,最好的解决方案是创建一个新的模型 资源,可能称为 flight_model_regression,以反映这些变化。这样,应用开发者可以选择使用哪一个,并且我们可以通过部署新版本继续对每个模型进行性能更新。

摘要

本章重点讨论了解决可重复性不同方面的设计模式。从Transform设计开始,我们看到了该模式如何用于确保数据准备在模型训练管道和模型服务管道之间的可重复性。通过明确捕获应用的转换,将模型输入转换为模型特征。Repeatable Splitting设计模式捕获了数据在训练、验证和测试数据集之间的分割方式,以确保训练中使用的示例永远不会用于评估或测试,即使数据集在增长。

Bridged Schema设计模式研究了在训练数据集是具有不同架构的新旧数据混合时如何确保可重复性。这允许以一致的方式组合两个具有不同架构的数据集进行训练。接下来,我们讨论了Windowed Inference设计模式,它确保在动态、时间相关方式计算特征时,这些特征在训练和服务之间能够正确重复。当机器学习模型需要从时间窗口聚合计算特征时,这个设计模式尤为有用。

Workflow Pipeline设计模式解决了通过容器化和编排我们机器学习工作流中的步骤来创建端到端可重复的管道的问题。接下来,我们看到了Feature Store设计模式如何用于跨不同机器学习任务的特征的可重复性和可重用性。最后,我们看了Model Versioning设计模式,通过将更改后的模型部署为具有不同 REST 端点的微服务来实现向后兼容性。

下一章中,我们将探讨设计模式,这些模式有助于负责任地执行人工智能。

¹ 注意,整体概率分布函数不需要是均匀的——我们只需要原始箱子足够窄,以便我们能够通过阶梯函数近似概率分布函数。当这种假设失败时,是因为旧数据中高度偏斜的分布未经充分采样。在这种情况下,可能会出现 3.46 比 3.54 更有可能的情况,这需要在桥接数据集中反映出来。

² 换句话说,我们正在计算平均值。

³ 关于数据验证的更多信息,请参阅“设计模式 30:公平性镜头”在第七章,“负责任的人工智能”中。

⁴ SSHS 代表Saffir–Simpson 飓风等级,是一个用于测量飓风强度和严重程度的 1 到 5 级的等级。请注意,ML 模型不预测飓风在稍后时间的严重程度,而是简单地学习 Saffir–Simpson 等级中使用的风速阈值。

⁵ 尽管部署是我们示例流水线中的最后一步,但生产流水线通常包括更多步骤,例如将模型存储在共享存储库中或执行单独的服务流水线,进行 CI/CD 和测试。

⁶ 请注意,为了在 AI 平台上运行 TFX 流水线,您目前需要将代码托管在 GCR 上,不能使用像 DockerHub 这样的其他容器注册服务。

⁷ 数据存储在 BigQuery 表中:bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2016

⁸ 参见 Gojek 博客,“Feast: Bridging ML Models and Data”。

第七章:负责任的人工智能

到目前为止,我们专注于设计模式,以帮助数据和工程团队为生产使用准备、构建、训练和扩展模型。这些模式主要是为直接参与 ML 模型开发过程的团队设计的。一旦模型投入生产,它的影响将远远超出建造它的团队。在本章中,我们将讨论模型的其他利益相关者,包括组织内外的人员。利益相关者可能包括制定模型目标的业务执行人员,模型的最终用户,审计员和合规监管者。

本章中我们将提到几个模型利益相关者群体:

模型构建者

直接参与构建 ML 模型的数据科学家和 ML 研究人员。

ML 工程师

直接参与部署 ML 模型的 ML 运维团队成员。

业务决策者

决定是否将 ML 模型纳入其业务流程或面向客户的应用程序,并需要评估模型是否适合此目的。

ML 系统的最终用户

利用 ML 模型的预测。模型的最终用户有多种类型:客户、员工以及两者的混合体。例如,客户从模型中获得电影推荐,工厂车间的员工使用视觉检测模型来判断产品是否损坏,或者医务人员使用模型辅助患者诊断。

监管和合规机构

需要模型决策的高级摘要的人员和组织,从监管合规的角度来看。这可能包括财务审计员、政府机构或组织内的治理团队。

在本章中,我们将探讨模型对团队和组织外个人和团体的影响的模式。启发式基准设计模式提供了一种将模型的性能置于最终用户和决策者可以理解的背景中的方式。可解释预测模式通过促进对模型用于进行预测所使用的信号的理解,提供了提高对 ML 系统信任的方法。公平性视角设计模式旨在确保模型在不同用户子集和预测场景中表现公平。

综合起来,本章的模式属于负责任的 AI实践。这是一个积极研究的领域,关注如何最佳地将公平性、可解释性、隐私性和安全性融入 AI 系统。负责任的 AI 推荐实践包括采用人本设计方法,在项目开发过程中与多样化用户和使用案例场景进行互动,理解数据集和模型的局限性,并在部署后继续监测和更新 ML 系统。负责任的 AI 模式不仅限于本章讨论的三种——早期章节的许多模式(如连续评估、可重复分割和中立类等)提供了实施这些推荐实践和实现将公平性、可解释性、隐私性和安全性融入 AI 系统目标的方法。

设计模式 28:启发式基准

启发式基准模式将 ML 模型与一个简单易懂的启发式进行比较,以便向业务决策者解释模型的性能。

问题

假设一家自行车租赁公司希望利用租赁预期持续时间来建立动态定价解决方案。在训练 ML 模型以预测自行车租赁期间的持续时间后,他们在测试数据集上评估模型,并确定训练 ML 模型的平均绝对误差(MAE)为 1,200 秒。当他们向业务决策者展示此模型时,他们可能会问:“一个 1,200 秒的 MAE 是好还是坏?”这是我们在开发模型并向业务利益相关者展示时需要准备好处理的问题。如果我们在产品目录中的物品上训练图像分类模型,并且平均精度(MAP)为 95%,我们可以预计会被问到:“95%的 MAP 是好还是坏?”

对于挥手并说这取决于问题是没有好处的。当然,它确实如此。那么,在纽约市的自行车租赁问题中什么是一个好的 MAE?在伦敦呢?对于产品目录图像分类任务,一个好的 MAP 是多少呢?

模型性能通常以难以让最终用户放入背景的冷冰冰的数字来表述。解释 MAP、MAE 等的公式并不能提供业务决策者所需的直觉。

解决方案

如果这是为某项任务开发的第二个 ML 模型,一个简单的答案是将模型的性能与当前运行版本进行比较。我们可以轻松地说 MAE 现在降低了 30 秒,或者 MAP 高出 1%。即使当前的生产工作流程不使用 ML,只要正在进行生产和收集评估指标,我们就可以比较我们的新 ML 模型与当前生产方法的性能。

但是,如果当前没有现有的生产方法,而我们正在为一个全新的任务构建第一个模型呢?在这种情况下,解决方案是为了与我们新开发的机器学习模型进行比较而创建一个简单的基准。我们称之为启发式基准

一个好的启发式基准应该在直觉上易于理解并且相对容易计算。如果我们发现自己在为基准使用的算法进行辩护或调试,我们应该寻找一个更简单、更易理解的基准。启发式基准的好例子包括常数、经验法则或者大量统计数据(如均值、中位数或众数)。避免诱惑去训练一个简单的机器学习模型,比如线性回归,用其作为基准——尤其是一旦我们开始包含分类变量、超过少数输入或工程特征时,线性回归可能不够直观。

警告

如果已经有一个操作实践,请不要使用启发式基准。相反,我们应该将我们的模型与现有的标准进行比较。现有的操作实践不需要使用机器学习——它只是当前用于解决问题的任何技术。

显示了良好的启发式基准示例以及我们可能在其中应用它们的情况的示例,详见表 7-1。这本书的 GitHub 仓库中包含了这些启发式基准实现的示例代码。

表 7-1. 一些选定场景的启发式基准(参见GitHub 中的代码

场景 启发式基准 示例任务 示例任务的实现
特定场景中特定任务的回归问题,业务对特征及其交互不够理解。 在训练数据上标签值的均值或中位数。如果有很多异常值,则选择中位数。 Stack Overflow 上问题被回答前的时间间隔。 始终预测需要 2,120 秒。这是整个训练数据集中首次回答的中位数时间。
业务对特征及其交互不够理解的二元分类问题。 训练数据中正例的总体比例。 Stack Overflow 上接受回答是否会被编辑。 总是预测输出概率为 0.36。0.36 是所有被接受答案中被编辑的比例。
多标签分类问题,业务方面对特征及特征之间的交互理解不深。 训练数据中标签值的分布。 从哪个国家回答 Stack Overflow 问题。 预测法国为 0.03,印度为 0.08,依此类推。这些是法国、印度等国家答案的比例。
有一个非常重要的数值特征的回归问题。 基于直觉上最重要的单一特征进行线性回归。 预测出租车费用,给定上车和下车位置。这两点之间的距离在直觉上是一个关键特征。 车费 = 每公里 $4.64。$4.64 是从所有行程的训练数据计算出来的。
有一个或两个重要特征的回归问题。这些特征可以是数值型或分类型,但应是常用的启发式方法。 查找表,其中行和列对应于关键特征(如有必要进行离散化),并且每个单元格的预测是在训练数据中估计的该单元格中的平均标签。 预测自行车租赁的持续时间。这里的两个关键特征是租车站点及是否为通勤高峰时间。 根据高峰和非高峰时段,基于每个站点的平均租赁时长的查找表。
有一个或两个重要特征的分类问题。这些特征可以是数值型或分类型。 与上述类似,每个单元格的预测是该单元格中标签的分布。如果目标是预测单一类别,则计算每个单元格中标签的众数。 预测 Stack Overflow 问题是否在一天内得到回答。这里最重要的特征是主要标签。 对于每个标签,计算在一天内获得答案的问题的比例。

| 回归问题,涉及预测时间序列未来值。 | 持续性或线性趋势。考虑季节性因素。对于年度数据,与去年同一天/周/季度进行比较。 | 预测每周销售量 | 预测下周销售额 = s[0],其中 s[0] 是本周销售额。 (或者)

下周销售额 = s[0] + (s[0] - s[-1]),其中 s[-1] 是上周销售额。

(或者)

下周销售额 = s[-1y],其中 s[-1y] 是去年同期的销售额。

避免诱惑,不要合并这三个选项,因为相对权重的价值不直观。

当前由人类专家解决的分类问题。这在图像、视频和文本任务中很常见,包括成本高昂,无法经常性地通过人类专家解决问题的情况。 人类专家的表现。 从视网膜扫描中检测眼病。 每张图像至少由三名医生检查。以多数医生的决定为正确,并查看机器学习模型在人类专家中的百分位排名。
预防性或预测性维护。 按固定时间表进行维护。 车辆的预防性维护。 每三个月进行一次车辆维护。三个月是车辆从上次服务日期到故障的中位时间。
异常检测。 从训练数据集估算的第 99 百分位值。 从网络流量中识别拒绝服务(DoS)攻击。 在历史数据中找到每分钟请求量的第 99 百分位数。如果在任何一分钟内,请求量超过此数值,则标记为 DoS 攻击。
推荐模型。 推荐客户最后一次购买类别中最受欢迎的商品。 向用户推荐电影。 如果用户刚刚看过(并喜欢)盗梦空间(一部科幻电影),则向他们推荐伊卡洛斯(他们尚未观看的最受欢迎的科幻电影)。

表 7-1 中的许多场景涉及“重要特征”。从业务角度看,这些特征被广泛接受为对预测问题有着深入理解的影响。特别是,这些不是通过对训练数据集使用特征重要性方法确定的特征。例如,在出租车行业中,普遍认为出租车费用的最重要决定因素是距离,长途旅行成本更高。这就是距离成为重要特征的原因,而不是特征重要性研究的结果。

折衷和替代方案

我们经常发现启发式基准在解释模型性能之外也很有用。在某些情况下,启发式基准可能需要特殊的数据收集。最后,有时启发式基准可能不足以满足比较本身的需求。

开发检查

往往启发式基准证明在解释机器学习模型性能方面非常有用。在开发过程中,它还可以帮助诊断特定模型方法的问题。

例如,假设我们正在建立一个模型来预测租赁的持续时间,我们的基准是一个查找表,根据站点名称和是否高峰通勤时间给出平均租赁持续时间:

CREATE TEMPORARY FUNCTION is_peak_hour(start_date TIMESTAMP) AS
    EXTRACT(DAYOFWEEK FROM start_date) BETWEEN 2 AND 6 -- weekday
    AND (
       EXTRACT(HOUR FROM start_date) BETWEEN 6 AND 10
       OR
       EXTRACT(HOUR FROM start_date) BETWEEN 15 AND 18)
;

SELECT 
   start_station_name,
   is_peak_hour(start_date) AS is_peak,
   AVG(duration) AS predicted_duration,
FROM `bigquery-public-data.london_bicycles.cycle_hire`
GROUP BY 1, 2

在开发我们的模型时,将模型性能与这一基准进行比较是一个好主意。为了做到这一点,我们将评估评估数据集的不同分层。在这里,评估数据集将按start_station_nameis_peak分层。通过这样做,我们可以轻松诊断我们的模型是否过度强调繁忙的热门站点,而忽视了训练数据中不频繁的站点。如果发生这种情况,我们可以尝试增加模型复杂性或平衡数据集,以加权不那么受欢迎的站点。

人类专家

我们建议在诊断眼部疾病等分类问题中,由人类专家执行工作时,基准应涉及这样一个专家组。通过让三名或更多医生检查每张图像,可以确定人类医生出错的程度,并将模型的错误率与人类专家的错误率进行比较。对于这种图像分类问题,这是标注阶段的自然延伸,因为眼部疾病的标签是通过人类标注创建的。

即使我们有实际的地面真相,有时候使用人类专家也是有优势的。例如,当建立一个模型来预测事故后汽车修理的成本时,我们可以查看历史数据并找到实际修理的成本。对于这个问题,我们通常不会使用人类专家,因为地面真相可以直接从历史数据集中获得。然而,为了传达基准,让保险代理人评估车辆损坏估算,并将我们模型的估算与代理人的估算进行比较,这也可能会有帮助。

使用人类专家不必仅限于无结构数据,如眼部疾病或损伤成本估算。例如,如果我们正在构建一个模型来预测贷款在一年内是否会进行再融资,数据将是表格形式的,而地面真相将在历史数据中可得。然而,即使在这种情况下,我们可能要求人类专家识别将会再融资的贷款,以便传达现场贷款代理人的正确率。

效用值

即使我们有一个运行中的模型或优秀的启发式规则来进行比较,我们仍然需要解释我们的模型提供的改进带来的影响。仅仅传达 MAE 降低了 30 秒或 MAP 提高了 1%可能还不足够。下一个问题很可能是:“1%的改进好吗?是否值得将 ML 模型投入生产而不是简单的启发式规则?”

如果可以的话,将模型性能改善翻译成模型的效用值是很重要的。这个值可以是货币性的,但也可以对应于其他效用衡量,如更好的搜索结果、早期疾病检测或由于改善制造效率而减少的浪费。这个效用值在决定是否部署此模型时非常有用,因为部署或更改生产模型总是伴随着可靠性和误差预算成本。例如,如果图像分类模型用于预填充订单表单,我们可以计算出 1%的改善将意味着每天减少 20 个放弃订单,因此这对应一定金额的价值。如果这超过了我们的站点可靠性工程团队设定的阈值,我们将部署该模型。

在我们的自行车租赁问题中,可能可以通过使用这个模型来衡量对业务的影响。例如,我们可以根据使用动态定价解决方案中的模型来计算自行车供应的增加或利润的增加。

设计模式 29:可解释预测

可解释预测设计模式(model)通过向用户提供模型如何以及为何做出特定预测的理解,增强了用户对 ML 系统的信任。虽然决策树等模型天生具有可解释性,但深度神经网络的架构使其难以解释。对于所有模型,能够解释预测以理解影响模型行为的特征组合是非常有用的。

问题

在评估机器学习模型是否准备投入生产时,像准确率、精确度、召回率和均方误差等指标只能讲述故事的一部分。它们提供了关于模型预测与测试集中真实值相对正确程度的数据,但并没有提供模型为何得出这些预测的洞见。在许多 ML 场景中,用户可能不愿意单纯接受模型的预测结果。

要理解这一点,让我们来看一个模型,该模型根据视网膜图像预测糖尿病视网膜病变(DR)的严重程度。¹ 模型返回一个 softmax 输出,表示个别图像属于表明图像中 DR 严重程度的 5 个类别中的一类的概率,从 1(无 DR)到 5(增生性 DR,最严重的形式)。假设对于给定的图像,模型返回 95%的置信度,表示图像包含增生性 DR。这可能看起来是一个高置信度的准确结果,但如果医务专业人员仅仅依赖于这个模型输出来进行患者诊断,他们仍然不了解模型是如何得出这一预测的。也许模型确定了图像中表明 DR 的正确区域,但也有可能模型的预测基于图像中显示没有疾病迹象的像素。例如,数据集中的一些图像可能包含医生的笔记或注释。模型可能错误地使用存在注释来进行预测,而不是图像中的疾病区域。² 在模型当前的形式下,无法将预测归因于图像中的区域,这使医生难以信任模型。

医学影像只是一个例子——在许多行业、场景和模型类型中,对模型决策过程缺乏洞察力可能会导致用户信任问题。如果一个机器学习模型用于预测个人的信用评分或其他财务健康指标,人们很可能想知道他们为什么得到了特定的分数。是因为延迟支付吗?信用额度太多?信用历史太短?也许模型仅依赖于人口统计数据来进行预测,并在不知情的情况下引入偏见。只有分数,并没有办法知道模型是如何得出预测的。

除了模型最终用户外,另一组利益相关者是与机器学习模型的监管和合规标准相关的人员,因为某些行业的模型可能需要审核或额外透明度。参与审计模型的利益相关者可能需要模型如何得出其预测的高级摘要,以证明其使用和影响的合理性。在这种情况下,像准确度这样的度量标准是没有用的——没有洞察到模型为何做出其预测,其使用可能会变得问题重重。

最后,作为数据科学家和机器学习工程师,我们只能在没有了解模型依赖于哪些特征来进行预测的情况下,将模型质量提高到一定程度。我们需要一种方法来验证模型是否按我们预期的方式运行。例如,假设我们正在对表格数据进行训练,以预测航班是否会延误。该模型基于 20 个特征进行训练。在幕后,也许它仅依赖于这 20 个特征中的 2 个,如果我们去掉其余的特征,我们可以显著提高系统的性能。或者也许这 20 个特征每一个都是必需的,以达到我们需要的精度水平。没有更多关于模型使用了哪些特征的细节,很难知道。

解决方案

要处理机器学习中固有的未知,我们需要一种方法来了解模型在幕后的工作方式。理解和解释机器学习模型如何以及为何做出预测的技术是一个活跃研究领域。也称为可解释性或模型理解,解释性是机器学习中一个新兴且快速发展的领域,可以根据模型的架构和所训练的数据类型采用各种形式。解释性还可以帮助揭示机器学习模型中的偏见,在本章中讨论公平性镜头模式时,我们将重点讨论使用特征归因解释深度神经网络。为了在此背景下理解这一点,首先我们将看看对于具有较简单体系结构的模型的可解释性。

简单模型如决策树比深度模型更容易解释,因为它们通常是设计可解释的。这意味着它们学习到的权重直接揭示了模型进行预测的方式。如果我们有一个具有独立数值输入特征的线性回归模型,权重有时可能是可以解释的。例如,考虑一个预测汽车燃油效率的线性回归模型。³ 在scikit-learn,我们可以通过以下方式获取线性回归模型的学习系数:

model = LinearRegression().fit(x_train, y_train)
coefficients = model.coef_

我们模型的每个特征的学习系数如图 7-1 所示。

我们线性回归燃油效率模型的学习系数,预测汽车每加仑英里数。我们使用 pandas 的 get_dummies()函数将原产地特征转换为布尔列,因为它是分类的。

图 7-1. 我们线性回归燃油效率模型的学习系数,预测汽车每加仑英里数。我们使用 pandas 的 get_dummies()函数将原产地特征转换为布尔列,因为它是分类的。

系数向我们展示了每个特征与模型输出(预测每加仑英里数)之间的关系。例如,通过这些系数,我们可以得出结论,每增加一汽缸,我们模型预测的每加仑英里数将会下降。我们的模型还学习到,随着新车型的引入(以“车型年份”特征表示),它们通常具有更高的燃油效率。通过这些系数,我们可以从中学到比深度神经网络隐藏层学习的权重更多关于模型特征与输出之间关系的知识。这就是为什么像上面展示的模型经常被称为设计可解释的模型。

警告

虽然很容易给线性回归或决策树模型学到的权重分配重要意义,但我们在这样做时必须非常谨慎。我们之前得出的结论仍然正确(例如汽缸数量与燃油效率的反向关系),但我们不能仅仅从系数的大小推断出,例如,分类原产地特征或汽缸数量对我们的模型比马力或重量更重要。首先,每个特征使用不同的单位表示。一个汽缸不等同于一磅——数据集中的汽车最多有 8 个汽缸,但重量超过 3000 磅。此外,原产地是一个用虚拟值表示的分类特征,因此每个原产地值只能是 0 或 1。系数也不告诉我们模型特征之间的关系。更多汽缸通常与更多马力相关,但我们不能仅凭学到的权重推断出这一点。⁴

当模型更复杂时,我们使用事后可解释性方法来近似模型特征与其输出之间的关系。通常,事后方法执行此分析,而不依赖于学习的权重等模型内部信息。这是一个正在研究的领域,提出了各种解释方法以及用于将这些方法添加到您的机器学习工作流程中的工具。我们将讨论的解释方法类型被称为特征归因。这些方法旨在将模型的输出(无论是图像、分类还是数值)归因于其特征,通过为每个特征分配归因值来指示该特征对输出的贡献。特征归因有两种类型:

实例级别

特征归因解释模型对个体预测结果的输出。例如,在预测某人是否应该获得信用额度时,实例级别的特征归因将揭示为什么特定人的申请被拒绝的原因。在图像模型中,实例级别的归因可能会突出显示导致模型预测图像包含猫的像素。

全局

全局特征归因分析模型在整体上的行为以得出关于模型整体行为的结论。通常,这是通过从测试数据集平均实例级别特征归因来完成的。在预测航班是否延误的模型中,全局归因可能会告诉我们,总体上,极端天气是预测延误时最重要的特征。

我们将探讨的两种特征归因方法⁵在 表 7-2 中概述,并提供可用于实例级别和全局解释的不同方法。

表 7-2. 不同解释方法的描述及其研究论文链接

名称 描述 论文
采样 Shapley 基于 Shapley 值的概念^(a),该方法通过计算添加和删除特征如何影响预测(分析多种特征值组合)来确定特征的边际贡献。 https://oreil.ly/ubEjW
综合梯度(IG) 使用预定义的模型基线,IG 计算沿着从此基线到特定输入的路径的导数(梯度)。 https://oreil.ly/sy8f8
^(a) Shapley 值是由 Lloyd Shapley 在 1951 年的一篇论文中引入的,基于博弈论的概念。

虽然我们可以从头开始实施这些方法,但有些工具旨在简化获取特征归因过程。现有的开源和基于云的可解释性工具使我们能够专注于调试、改进和总结我们的模型。

模型基线

要使用这些工具,我们首先需要理解作为解释模型特征归因时所适用的基线概念。任何可解释性方法的目标是回答:“为什么模型预测了 X?” 特征归因试图通过为每个特征提供数值来实现这一点,这些数值指示该特征对最终输出的贡献程度。例如,考虑一个模型预测患者是否患有心脏病,给定一些人口统计和健康数据。对于我们测试数据集中的一个示例,假设一个患者的胆固醇特征的归因值为 0.4,血压的归因为 −0.2。没有上下文的情况下,这些归因值意义不大,我们的第一个问题可能是:“0.4 和 −0.2 相对于什么?” 这个“什么”就是模型的基线

每当我们获得特征归因值时,它们都是相对于我们模型预测的预定义基线值的。基线预测可以是信息性的非信息性的。非信息性基线通常与训练数据集中某些平均情况进行比较。在图像模型中,非信息性基线可以是纯黑或纯白的图像。在文本模型中,非信息性基线可以是模型嵌入矩阵的 0 值或诸如“the”、“is”或“and”之类的停用词。在具有数值输入的模型中,选择基线的常见方法是使用模型中每个特征的中位数值生成预测。

图 7-2 显示了一个模型的实例级特征归因,该模型预测骑行的持续时间。此模型的非信息性基线是一个骑行持续时间为 13.6 分钟,我们通过使用数据集中每个特征的中位数值生成预测得到这个值。当模型的预测值低于基线预测值时,我们应该期望大多数归因值为负,反之亦然。在这个例子中,我们得到了预测的持续时间为 10.71,低于模型的基线,这解释了为什么许多归因值为负。我们可以通过取特征归因的绝对值来确定最重要的特征。在这个例子中,行程的距离是最重要的特征,使得我们模型的预测比基线减少了 2.4 分钟。另外,作为一种合理性检查,我们应确保特征归因值大致等于当前预测与基线预测之间的差值。

在预测自行车行程持续时间的模型中,单个示例的特征归因值。该模型的基线,使用每个特征值的中位数计算得到,为 13.6 分钟,归因值显示了每个特征对预测的影响。

图 7-2. 在预测自行车行程持续时间的模型中,单个示例的特征归因值。该模型的基线,使用每个特征值的中位数计算得到,为 13.6 分钟,归因值显示了每个特征对预测的影响。

信息性基线相比之下,则是将模型的预测与特定的备选场景进行比较。在识别欺诈交易的模型中,信息性基线可能回答这样一个问题:“为什么将这笔交易标记为欺诈而不是非欺诈?”我们不会使用整个训练数据集中的特征值中位数来计算基线,而是只取非欺诈值的中位数。在图像模型中,训练图像可能包含大量的纯黑色和白色像素,如果将其作为基线则会导致不准确的预测。在这种情况下,我们需要提出一个不同的信息性基线图像。

SHAP

开源库SHAP提供了一个 Python API,用于在许多类型的模型上获取特征归因,并且基于 Shapley 值的概念在表 7-2 中进行了介绍。为了确定特征归因值,SHAP 计算添加或删除每个特征对模型预测输出的贡献。它通过许多不同的特征值组合和模型输出来执行此分析。

SHAP 不依赖框架,并且适用于在图像、文本或表格数据上训练的模型。为了看到 SHAP 在实践中的应用,我们将使用之前提到的燃油效率数据集。这次,我们将使用 Keras 的Sequential API 构建一个深度模型:

model = tf.keras.Sequential([
  tf.keras.layers.Dense(16, input_shape=(len(x_train.iloc[0])),
  tf.keras.layers.Dense(16, activation='relu'),
  tf.keras.layers.Dense(1)                
])

要使用 SHAP,我们首先会通过将模型和训练集中一部分示例传递给DeepExplainer对象来创建它。然后,我们将获取测试集中前 10 个示例的归因值:

import shap
explainer = shap.DeepExplainer(model, x_train[:100])
attribution_values = explainer.shap_values(x_test.values[:10])

SHAP 具有一些内置的可视化方法,使理解生成的归因值更加容易。我们将使用 SHAP 的force_plot()方法来绘制测试集中第一个示例的归因值,代码如下:

shap.force_plot(
  explainer.expected_value[0],
  shap_values[0][0,:], 
  x_test.iloc[0,:]
)

在上述代码中,explainer.expected_value 是我们模型的基准。SHAP 将基准计算为我们在创建解释器时通过的数据集的模型输出均值(在本例中为 x_train[:100]),尽管我们也可以向 force_plot 传递自定义的基准值。此示例的真实值为每加仑 14 英里,而我们的模型预测为 13.16。因此,我们的解释将解释模型对 13.16 的预测值的特征归因值。在这种情况下,这些归因值相对于模型的 24.16 MPG 基线。因此,这些归因值应大致相加到 11,即模型基线和此示例预测之间的差异。通过查看具有最高绝对值的特征,我们可以确定最重要的特征。图 7-3 展示了该示例的归因值结果图。

我们燃油效率预测模型的一个示例的特征归因值。在本例中,汽车的重量是 MPG 的最重要指标,其特征归因值约为 6。如果我们的模型预测超过 24.16 的基准,我们将看到主要是负的归因值。

图 7-3. 我们燃油效率预测模型的一个示例的特征归因值。在本例中,汽车的重量是 MPG 的最重要指标,其特征归因值约为 6。如果我们的模型预测超过 24.16 的基准,我们将看到主要是负的归因值。

对于这个例子,燃油效率最重要的指标是重量,从基准下降了约 6 MPG。其次是马力、排量,然后是汽车的年份型号。我们可以通过以下方式获取测试集前 10 个样本的特征归因值的摘要(或全局解释):

shap.summary_plot(
  shap_values, 
  feature_names=data.columns.tolist(), 
  class_names=['MPG']
)

这导致了图 7-4 的摘要图的显示(#an_example_of_global-level_feature_attr)。

实际应用中,我们会有更大的数据集,并希望在更多样本上计算全局级别的归因。我们可以利用这些分析来向组织内外的其他利益相关者总结模型的行为。

燃油效率模型的全局特征归因示例,基于测试数据集的前 10 个样本计算。

图 7-4. 燃油效率模型的全局特征归因示例,基于测试数据集的前 10 个样本计算。

部署模型的解释

SHAP 提供了一个直观的 Python API,用于在脚本或笔记本环境中获取归因。这在模型开发期间非常有效,但在某些情况下,您可能希望在部署模型时获取解释,以补充模型的预测输出。在这种情况下,云端可解释性工具是最佳选择。在这里,我们将演示如何使用 Google Cloud 的Explainable AI获取部署模型的特征归因。在撰写本文时,Explainable AI 与使用 AutoML 构建的自定义 TensorFlow 模型和表格数据模型兼容。

我们将部署一个图像模型到 AI 平台以展示解释,但我们也可以使用基于表格或文本数据训练的 TensorFlow 模型进行可解释的 AI。首先,我们将部署一个TensorFlow Hub模型,该模型是在 ImageNet 数据集上训练的。这样我们就可以专注于获取解释的任务,不会对模型进行任何迁移学习,并且将使用 ImageNet 原始的 1,000 个标签类别:

model = tf.keras.Sequential([
    hub.KerasLayer(".../mobilenet_v2/classification/2", 
               input_shape=(224,224,3)),
    tf.keras.layers.Softmax()
])

要将模型部署到具有解释功能的 AI 平台,我们首先需要创建一个元数据文件,该文件将被解释服务用于计算特征归因。此元数据以 JSON 文件提供,并包含我们想要使用的基线信息以及我们想要解释的模型部分。为了简化此过程,可解释的 AI 提供了一个 SDK,通过以下代码生成元数据:

from explainable_ai_sdk.metadata.tf.v2 import SavedModelMetadataBuilder

model_dir = 'path/to/savedmodel/dir'

model_builder = SavedModelMetadataBuilder(model_dir)
model_builder.set_image_metadata('input_tensor_name')
model_builder.save_metadata(model_dir)

此代码未指定模型基线,这意味着它将使用默认值(对于图像模型,这是黑白图像)。我们可以选择添加input_baselines参数到set_image_metadata以指定自定义基线。上面的save_metadata方法在模型目录中创建一个explanation_metadata.json文件(完整代码在GitHub 存储库中)。

当通过 AI 平台笔记本使用此 SDK 时,我们还有选项在笔记本实例内本地生成解释,而无需将我们的模型部署到云端。我们可以通过load_model_from_local_path方法实现这一点。

使用我们导出的模型和存储桶中的explanation_metadata.json文件,我们已准备好创建一个新的模型版本。在执行此操作时,我们需要指定我们想要使用的解释方法。

要将我们的模型部署到 AI 平台,我们可以将模型目录复制到 Cloud Storage 存储桶,并使用 gcloud CLI 创建一个模型版本。AI 平台有三种可能的解释方法可供选择:

集成梯度(IG)

此方法是在IG paper中介绍的,并且适用于任何可微分的 TensorFlow 模型——图像、文本或表格。对于部署在 AI 平台上的图像模型,IG 会返回一个高亮像素的图像,指示出导致模型预测的区域。

Sampled Shapley

基于Sampled Shapley paper,这使用了类似于开源 SHAP 库的方法。在 AI 平台上,我们可以在表格和文本 TensorFlow 模型中使用此方法。因为 IG 仅适用于可微分模型,AutoML Tables 使用 Sampled Shapley 为所有模型计算特征归因。

XRAI

这种方法建立在 IG 之上,并应用平滑处理返回基于区域的归因。XRAI 仅适用于部署在 AI 平台上的图像模型。

在我们的 gcloud 命令中,我们指定要使用的解释方法以及在计算归因值时希望方法使用的整数步骤或路径的数量。⁶ steps parameter 指的是为每个输出采样的特征组合数量。通常情况下,增加这个数字会提高解释的准确性:

!gcloud beta ai-platform versions create $VERSION_NAME \
--model $MODEL_NAME \
--origin $GCS_VERSION_LOCATION \
--runtime-version 2.1 \
--framework TENSORFLOW \
--python-version 3.7 \
--machine-type n1-standard-4 \
--explanation-method xrai \
--num-integral-steps 25

一旦模型部署完成,我们可以使用可解释 AI SDK 获取解释:

model = explainable_ai_sdk.load_model_from_ai_platform(
  GCP_PROJECT, 
  MODEL_NAME, 
  VERSION_NAME
)
request = model.explain([test_img])

# Print image with pixel attributions
request[0].visualize_attributions()

在图 7-5 中,我们可以看到从可解释 AI 返回的 IG 和 XRAI 解释的比较,突出显示了对我们模型预测“哈士奇”贡献最大的像素区域。

通常情况下,对于像医疗、工厂或实验室环境中拍摄的“非自然”图像,建议使用 IG。XRAI 通常适用于自然环境中拍摄的图像,比如这只哈士奇的图像。要了解为什么 IG 更适用于非自然图像,请参见图 7-6 中糖尿病视网膜病变图像的 IG 归因。在这种医疗情况下,有助于在像素级别查看归因。而在狗的图像中,知道导致模型预测“哈士奇”的确切像素不那么重要,XRAI 为我们提供了重要区域的更高级别摘要。

部署到 AI 平台的 ImageNet 模型返回的特征归因,由可解释 AI 提供。左侧是原始图像。中间显示了 IG 的归因,右侧显示了 XRAI 的归因。下方的键显示了 XRAI 中彩色区域的含义——亮黄色区域最重要,深紫色区域最不重要。

图 7-5. 部署到 AI 平台的 ImageNet 模型返回的特征归因,由可解释 AI 提供。左侧是原始图像。中间显示了 IG 的归因,右侧显示了 XRAI 的归因。下方的键显示了 XRAI 中区域的含义——浅色区域最重要,深色区域代表最不重要的区域。

2019 年,Rory Sayres 及其同事进行的一项研究中,不同组的眼科医生被要求在三种情况下评估一幅图像上的 DR 程度:单独的图像,没有模型预测的图像,以及带有预测和像素归因(如图所示)的图像。我们可以看到像素归因如何帮助增强模型预测的信心。

图 7-6。作为一项研究,2019 年 Rory Sayres 及其同事的研究中,不同组的眼科医生被要求在三种情况下评估一幅图像上的 DR 程度:单独的图像,没有模型预测的图像,以及带有预测和像素归因(如图所示)的图像。我们可以看到像素归因如何帮助增强模型预测的信心。
提示

可解释人工智能(Explainable AI)也适用于AutoML Tables,这是一个用于训练和部署表格数据模型的工具。AutoML Tables 处理数据预处理并选择最适合我们数据的模型,这意味着我们无需编写任何模型代码。通过 Explainable AI 进行的特征归因在 AutoML Tables 中的模型默认启用,并提供全局和实例级别的解释。

权衡和替代方案

虽然解释提供了对模型如何做出决策的重要见解,但它们只能和模型的训练数据、模型的质量以及所选的基准一样好。在本节中,我们将讨论解释的一些限制,以及特征归因的一些替代方案。

数据选择偏差

人们经常说机器学习是“垃圾进,垃圾出”。换句话说,模型只能和用于训练它的数据一样好。如果我们训练一个图像模型来识别 10 种不同的猫种类,那么它所知道的就只有这 10 种猫的种类。如果我们给模型展示一只狗的图像,它只能试图将这只狗分类为它训练过的 10 个猫类别中的一个。它甚至可能会以很高的信心这样做。也就是说,模型直接反映了它的训练数据。

如果我们在训练模型之前没有捕捉到数据不平衡,那么像特征归因这样的解释方法可以帮助揭示数据选择偏差的问题。举个例子,假设我们正在构建一个模型来预测图像中存在的船只类型。假设我们正确地将测试集中的一幅图像标记为“kayak”,但是通过特征归因,我们发现模型依赖于船只的桨来预测“kayak”,而不是船只的形状。这表明我们的数据集可能没有足够多角度、带桨和不带桨的 kayak 图像,我们可能需要回去添加更多这样的图像。

反事实分析和基于示例的解释

除了在解决方案部分描述的特征归因之外,还有许多其他解释机器学习模型输出的方法。本节不旨在提供所有可解释性技术的详尽列表,因为这个领域发展迅速。在这里,我们简要介绍另外两种方法:反事实分析和基于示例的解释。

反事实分析是一种实例级的可解释性技术,指的是在我们的数据集中找到具有类似特征但模型预测结果不同的示例。其中一种方法是通过What-If Tool,这是一个用于评估和可视化机器学习模型输出的开源工具。在“公平性镜头设计模式”中,我们将提供对 What-If Tool 更深入的概述——在这里,我们专注于其反事实分析功能。在 What-If Tool 中可视化我们测试集中的数据点时,我们可以选择显示与所选数据点最接近的反事实数据点。这样做可以让我们比较这两个数据点的特征值和模型预测,帮助我们更好地理解模型最依赖的特征。在图 7-7,我们看到了一个来自抵押申请数据集的两个数据点的反事实比较。在粗体字中,我们看到了这两个数据点不同的特征,底部则展示了每个数据点的模型输出。

基于示例的解释会比较新示例及其相应的预测与训练数据集中类似示例。这种类型的解释对于理解训练数据集如何影响模型行为尤为有用。基于示例的解释在图像或文本数据上效果最佳,并且比特征归因或反事实分析更直观,因为它们直接将模型的预测映射到用于训练的数据上。

反事实分析在 What-If Tool 中针对美国抵押申请数据集中两个数据点的应用。突出显示两个数据点之间的差异为绿色。关于此数据集的更多信息可以在本章中公平性镜头模式的讨论中找到。

图 7-7. What-If Tool 中针对美国抵押申请数据集中两个数据点的反事实分析。突出显示了这两个数据点的不同之处。有关此数据集的更多信息可以在本章中公平性镜头模式的讨论中找到。

为了更好地理解这种方法,让我们来看看游戏Quick, Draw!⁷。这款游戏要求玩家实时绘制物品,并使用一个基于成千上万次他人绘图的深度神经网络猜测他们正在绘制的内容。玩家完成绘画后,可以通过查看训练数据集中的示例来了解神经网络是如何进行预测的。在图 7-8 中,我们可以看到关于一幅薯条绘画的基于示例的解释,模型成功地识别了它。

来自游戏 Quick, Draw 的基于示例的解释,展示了模型如何通过训练数据集中的示例正确预测了“薯条”的给定绘画。

图 7-8. 来自游戏 Quick, Draw 的基于示例的解释,展示了模型如何通过训练数据集中的示例正确预测了“薯条”的给定绘画。

解释的局限性

解释性代表了对理解和解释模型的显著改进,但我们在对模型的解释过于信任或假设它们提供对模型完美洞察力时应保持谨慎。任何形式的解释都直接反映了我们的训练数据、模型和选择的基准线。换句话说,如果我们的训练数据集不准确地反映了模型所反映的群体,或者我们选择的基准线不适合我们要解决的问题,就不能指望我们的解释具有高质量。

此外,解释可以识别模型特征与输出之间的关系,这仅代表我们的数据和模型,而不一定代表此上下文之外的环境。例如,假设我们训练一个模型来识别欺诈信用卡交易,并且它作为全局特征归因发现交易金额是欺诈的最明显特征。但是,不能因此得出结论金额总是信用卡欺诈的最大指标——这仅适用于我们训练数据集、模型和指定的基准值的上下文中。

我们可以将解释视为评估 ML 模型准确性、错误和其他指标的重要补充。它们提供了对模型质量和潜在偏见的有用见解,但不应成为高质量模型的唯一决定因素。我们建议将解释作为模型评估标准的一个组成部分,除了数据和模型评估之外,还可以考虑本章和前几章中提到的许多其他模式。

设计模式 30:公平性视角

公平镜头设计模式建议使用预处理和后处理技术,以确保模型预测在不同用户群体和场景中是公平和公正的。机器学习中的公平性是一个不断发展的研究领域,没有单一的万能解决方案或定义可以使模型“公平”。通过公平的镜头评估整个端到端的机器学习工作流程——从数据收集到模型部署——对于构建成功的高质量模型至关重要。

问题

带有“机器”一词的名字,很容易就会认为机器学习模型不会存在偏见。毕竟,模型是计算机学习到的模式的结果,对吧?这种想法的问题在于,模型学习的数据集是由人类创建的,而不是机器,而人类充满了偏见。这种固有的人类偏见是不可避免的,但并不一定总是坏事。举个例子,考虑用于训练金融欺诈检测模型的数据集——由于大多数情况下欺诈案例相对较少,因此这些数据很可能会严重不平衡。这是自然存在的偏见的一个例子,因为它反映了原始数据集的统计特性。当偏见影响到不同群体时,偏见就会变得有害。这被称为问题性偏见,这是我们在本节中将重点关注的内容。如果不考虑这种类型的偏见,它可能会进入模型中,在生产模型中直接反映出数据中存在的偏见,从而产生负面影响。

即使在你可能没有预料到的情况下,问题性偏见也存在。例如,想象一下我们正在构建一个模型来识别不同类型的服装和配饰。我们被要求收集所有的鞋类图片作为训练数据集。当我们想到鞋子时,我们注意到首先想到的是什么。是网球鞋?便鞋?人字拖?高跟鞋呢?假设我们生活在全年气候温暖的地方,我们认识的大多数人都一直穿凉鞋。当我们想到鞋子时,凉鞋是首先浮现在脑海中的东西。因此,我们收集了各种款式的凉鞋图片,有不同类型的带子、鞋底厚度、颜色等。我们将这些贡献给更大的服装数据集,当我们在朋友的鞋子测试图像集上测试模型时,它在“鞋子”标签上达到了 95%的准确率。模型看起来很有前途,但当我们的来自不同地区的同事在他们的高跟鞋和运动鞋图像上测试模型时,问题就出现了。“鞋子”标签根本不返回。

这个鞋子的例子展示了训练数据分布中的偏见,尽管它可能看起来过于简化,但这种类型的偏见在生产环境中经常发生。数据分布偏差发生在我们收集的数据不能准确反映将使用我们模型的整个人群时。如果我们的数据集以人为中心,这种类型的偏见特别明显,如果我们的数据集未能包含对年龄、种族、性别、宗教、性取向和其他身份特征的平等代表。⁸

即使我们的数据集在这些身份特征方面看起来是平衡的,它仍然受到这些群体在数据中表示方式的偏见影响。假设我们正在训练一个情感分析模型,用于将餐厅评论分类为 1(极其负面)到 5(极其正面)。我们已经小心翼翼地在数据中获得了不同类型餐厅的平衡代表。然而,事实证明,大多数海鲜餐厅的评论是正面的,而大多数素食餐厅的评论是负面的。这种数据表现偏见将直接由我们的模型表现出来。每当针对素食餐厅添加新评论时,它们被分类为负面的可能性就会大大增加,这可能会影响某人未来访问这些餐厅的意愿。这也被称为报告偏见,因为数据集(这里是“报告”的数据)不能准确反映现实世界。

处理数据偏见问题时的一个常见谬误是,从数据集中删除偏见区域将解决问题。假设我们正在建立一个模型来预测某人违约贷款的可能性。如果我们发现模型对不同种族的人不公平,我们可能会认为通过简单地从数据集中删除种族作为特征来修复问题。问题在于,由于系统性偏见,诸如种族和性别之类的特征通常会隐含地反映在其他特征如邮政编码或收入中。这被称为隐性代理偏见。删除明显具有潜在偏见的特征,如种族和性别,通常比保留它们更糟,因为这使得更难以识别和纠正模型中的偏见实例。

在收集和准备数据时,引入偏见的另一个领域是数据标记的方式。团队通常会外包大型数据集的标记,但重要的是要小心理解标记人员如何向数据集引入偏见,特别是如果标记是主观的。这被称为实验者偏见。想象一下我们正在构建情感分析模型,我们将标签外包给一组 20 个人——他们的工作是根据 1(负面)到 5(正面)的评分标准标记每一段文本。这种分析极具主观性,并可能受文化、成长背景及许多其他因素的影响。在使用这些数据训练我们的模型之前,我们应确保这组 20 个标签人员反映了多样化的人群。

除了数据之外,偏见也可能通过我们选择的目标函数在模型训练过程中引入。例如,如果我们优化模型的整体准确率,这可能不能准确反映模型在所有数据片段上的性能。在数据集天生不平衡的情况下,仅使用准确率作为我们的唯一指标可能会忽略模型在少数类别数据上表现不佳或做出不公平决策的情况。

本书贯穿始终,我们已经看到机器学习有能力提高生产力,增加业务价值,并自动化以前手工操作的任务。作为数据科学家和机器学习工程师,我们有责任确保我们构建的模型不会对使用它们的人群产生不利影响。

解决方案

要处理机器学习中的问题偏见,我们需要解决方案,即在训练模型之前识别数据中有害偏见的领域,并通过公平性视角评估我们训练的模型。公平性镜头设计模式提供了构建数据集和模型以平等对待所有用户群体的方法。我们将使用What-If Tool,一个开源工具,用于从多个 Python 笔记本环境中运行数据集和模型评估,演示这两种分析技术。

提示

在继续本节中列出的工具之前,值得分析数据集和预测任务,以确定是否存在潜在的问题偏见。这需要更仔细地观察会受到模型影响,以及如何这些群体会受到影响。如果存在问题偏见的可能性较大,本节中概述的技术方法提供了缓解这种类型偏见的良好起点。另一方面,如果数据集中的偏斜包含自然产生的偏见,并不会对不同群体产生不利影响,“设计模式 10:重新平衡” 在第三章 提供了处理天生不平衡数据的解决方案。

在本节中,我们将引用一个公共数据集,该数据集包含美国抵押贷款申请。美国的贷款机构需要报告关于个人申请的信息,如贷款类型、申请人收入、处理贷款的机构及申请的状态。我们将在这个数据集上训练一个贷款申请批准模型,以展示公平性的不同方面。据我们了解,该数据集并未被任何贷款机构直接用于训练机器学习模型,因此我们提出的公平性问题仅为假设。

我们已经创建了该数据集的一个子集,并进行了一些预处理,将其转化为二元分类问题——申请是否批准。在图 7-9 中,我们可以看到数据集的预览。

来自本节引用的美国抵押贷款申请数据集的几列预览。

图 7-9. 来自本节引用的美国抵押贷款申请数据集的几列预览。

在训练之前

因为机器学习模型直接反映了用于训练它们的数据,通过进行彻底的数据分析并利用分析结果来调整我们的数据,可以在建立或训练模型之前减少大量偏差。在这个阶段,重点是识别数据收集或数据表现中的偏差,详见问题部分。表 7-3 展示了根据数据类型考虑的一些问题。

表 7-3. 不同类型数据偏差的描述

定义 分析考虑因素
数据分布偏差 数据不包含所有将在生产中使用模型的可能群体的平衡代表
  • 数据是否包含跨所有相关人口片段(性别、年龄、种族、宗教等)的平衡示例集?

  • 每个数据标签是否包含该标签所有可能变体的平衡分割?(例如,在问题部分中的鞋子示例。)

|

数据表现偏差 数据平衡但未平等代表不同数据片段
  • 对于分类模型,标签是否在相关特征上平衡?例如,在用于信用评估预测的数据集中,数据是否包含了跨性别、种族和其他身份特征的人群的均衡代表?

  • 不同人口群体在数据中的表现是否存在偏见?这对于预测情绪或评分值的模型特别相关。

  • 数据标签者是否引入了主观偏见?

|

一旦我们检查了数据并纠正了偏见,我们在将数据分成训练、测试和验证集时应考虑相同的考虑因素。也就是说,一旦我们的整个数据集平衡了,我们的训练、测试和验证分割也必须保持相同的平衡。回到我们的鞋子图像示例,假设我们改进了数据集以包含 10 种类型的鞋的各种图像。训练集应包含与测试和验证集中每种鞋的类似百分比。这将确保我们的模型反映并在真实场景中进行评估。

要查看这个数据集分析在实践中的表现,我们将使用上面介绍的抵押贷款数据集的 What-If 工具。这将使我们能够在各种分片上可视化我们数据的当前平衡状态。What-If 工具既可以在有模型的情况下使用,也可以在没有模型的情况下使用。由于我们还没有建立模型,我们可以通过仅传递我们的数据来初始化 What-If 工具小部件:

config_builder = WitConfigBuilder(test_examples, column_names)
WitWidget(config_builder)

在图 7-10 中,我们可以看到工具在加载时的样子,当传递了我们数据集中的 1,000 个示例时。第一个选项卡称为“数据点编辑器”,它提供了我们数据的概述,并允许我们检查单个示例。在这个可视化中,我们的数据点根据标签着色——无论抵押贷款申请是否获批。还突出显示了一个单个示例,并且我们可以看到与其关联的特征值。

What-If Tool 的“数据点编辑器”,我们可以看到数据如何按标签类别分割,并检查数据集中单个示例的特征。

图 7-10. What-If 工具的“数据点编辑器”,我们可以看到数据如何按标签类别分割,并检查数据集中单个示例的特征。

在数据点编辑器中,有许多自定义可视化选项,通过这样做可以帮助我们理解数据集如何在不同分片中分布。如果我们从分箱 | Y 轴 下拉菜单中选择agency_code列,并保持相同的标签颜色编码,工具现在显示了关于每个申请贷款机构承保情况的图表。这在图 7-11 中展示。假设这 1,000 个数据点能很好地代表我们数据集的其余部分,在图 7-11 中揭示了一些潜在偏见的实例:

数据表示偏见

HUD 申请获批准的百分比高于数据集中其他代理机构。模型很可能会学习到这一点,导致它更频繁地预测通过 HUD 发起的申请“未获批准”。

数据收集偏见

对于来自 FRS、OCC、FDIC 或 NCUA 的贷款数据,我们可能没有足够的数据来准确地将agency_code作为模型的特征使用。我们应确保数据集中每个机构的申请百分比反映现实世界的趋势。例如,如果类似数量的贷款通过 FRS 和 HUD,我们应该在数据集中为这些机构的每个例子提供相同数量的示例。

一个美国抵押贷款数据集的子集,按照数据集中的 agency_code 列分组。

图 7-11. 一个美国抵押贷款数据集的子集,按照数据集中的 agency_code 列分组。

我们可以在数据的其他列上重复此分析,并使用我们的结论添加示例并改进数据。在 What-If Tool 中创建自定义可视化的许多其他选项,请查看 GitHub 上的完整代码获取更多想法。

另一种使用 What-If Tool 理解我们的数据的方法是通过显示在图 7-12 中的“特征”选项卡。这显示了我们的数据在数据集的每列中是如何平衡的。从中,我们可以看出我们需要在哪些地方添加或移除数据,或更改我们的预测任务。⁹ 例如,也许我们想要限制我们的模型仅在再融资或购房贷款上进行预测,因为在loan_purpose列中可能没有足够的其他可能值的数据。

What-If Tool 中的“特征”选项卡显示数据集在每列中如何平衡的直方图。

图 7-12. What-If Tool 中的“特征”选项卡,显示了数据集在每列中如何平衡的直方图。

一旦我们优化了数据集和预测任务,我们可以考虑在模型训练期间想要优化的其他内容。例如,也许我们最关心模型在被预测为“批准”的应用中的准确性。在模型训练期间,我们希望在这个二分类模型中优化“批准”类别的 AUC(或其他指标)。

Tip

如果我们已经尽了最大努力消除数据收集偏差,并发现某个特定类别的数据不足,我们可以参考“设计模式 10:再平衡”,位于第三章。这个模式讨论了处理不平衡数据的建模技术。

训练后

即使进行了严格的数据分析,偏见可能会潜入训练模型中。这可能是由于模型的架构、优化指标或在训练之前未能识别的数据偏差所致。为了解决这个问题,重要的是从公平的角度评估我们的模型,并深入研究除整体模型准确度之外的度量标准。这篇文章后训练分析的目标是理解模型准确性与模型预测对不同群体影响之间的权衡。

What-If 工具是进行模型后分析的一个选择。为了演示如何在训练模型上使用它,我们将在我们的抵押贷款数据集示例基础上构建。基于先前的分析,我们精炼了数据集,仅包括用于再融资或购房的贷款,¹⁰ 并训练了一个 XGBoost 模型来预测申请是否会被批准。由于我们使用了 XGBoost,我们使用 pandas 的 get_dummies() 方法将所有分类特征转换为布尔列。

我们将在上述的 What-If 工具初始化代码中进行一些添加,这次传入一个调用我们训练模型的函数,同时配置指定我们的标签列和每个标签的名称:

def custom_fn(examples):
  df = pd.DataFrame(examples, columns=columns)
  preds = bst.predict_proba(df)
  return preds

config_builder = (WitConfigBuilder(test_examples, columns)
  .set_custom_predict_fn(custom_fn)
  .set_target_feature('mortgage_status')
  .set_label_vocab(['denied', 'approved']))
WitWidget(config_builder, height=800)

现在我们已经将我们的模型传递给了工具,生成的可视化如 图 7-13 所示,根据 y 轴上我们模型的预测置信度绘制了我们的测试数据点。

What-If 工具用于二元分类模型的数据点编辑器。y 轴是每个数据点的模型预测输出,范围从 0(拒绝)到 1(通过)。

图 7-13. What-If 工具用于二元分类模型的数据点编辑器。y 轴是每个数据点的模型预测输出,范围从 0(拒绝)到 1(通过)。

What-If 工具的性能和公平性选项卡让我们能够评估我们模型在不同数据切片上的公平性。通过选择一个我们模型特征来“切片”,我们可以比较这个特征不同值的模型结果。在 图 7-14 中,我们通过 agency_code_HUD 特征进行了切片,这是一个布尔值,指示申请是否由 HUD 监督(非 HUD 贷款为 0,HUD 贷款为 1)。

What-If 工具的性能和公平性选项卡,展示我们的 XGBoost 模型在不同特征值上的表现。

图 7-14. What-If 工具的性能和公平性选项卡,展示我们的 XGBoost 模型在不同特征值上的表现。

从这些性能和公平性图表中,我们可以看到:

  • 我们模型对由 HUD 监督的贷款的准确率显著更高——94% 相比于 85%。

  • 根据混淆矩阵,非 HUD 贷款的批准率更高——72%比 55%。这很可能是由于前一节中识别出的数据表现偏差所致(我们特意保留了数据集以展示模型如何放大数据偏差)。

有几种方法可以根据所展示的见解进行操作,如“优化策略”框中在图 7-14 中所示。这些优化方法涉及更改我们模型的分类阈值——模型输出积极分类的阈值。在这个模型的背景下,我们对什么置信阈值表示“批准”申请感到满意?如果我们的模型超过 60%的置信度认为申请应该被批准,我们应该批准吗?或者我们只在我们的模型超过 98%的置信度时批准申请?这个决定在很大程度上取决于模型的背景和预测任务。如果我们预测一张图像是否包含猫,即使我们的模型只有 60%的置信度,我们可能也可以返回标签“猫”。然而,如果我们有一个预测一张医学图像是否包含疾病的模型,我们可能希望我们的阈值要高得多。

What-If 工具帮助我们根据各种优化选择阈值。例如,优化“人口统计学平等”将确保我们的模型批准 HUD 和非 HUD 贷款的申请百分比相同¹¹。或者,使用“机会平等”公平性指标将确保 HUD 和非 HUD 切片中在测试数据集中具有“批准”地面真实值的数据点被模型预测为“批准”的机会相等¹²。

请注意,更改模型的预测阈值仅是对公平性评估指标进行操作的一种方式。还有许多其他方法,包括重新平衡训练数据、重新训练模型以优化不同的指标等。

提示

What-If 工具是与模型无关的,可以用于任何类型的模型,无论其架构或框架如何。它可以与加载在笔记本中或在TensorBoard中的模型一起工作,也可以与通过 TensorFlow Serving 提供的模型以及部署到 Cloud AI Platform Prediction 的模型一起工作。What-If 工具团队还为基于文本的模型创建了一个工具,称为语言可解释性工具(LIT)

后训练评估的另一个重要考虑因素是在平衡的示例集上测试我们的模型。如果我们预期数据的特定切片对我们的模型可能存在问题——比如可能受数据收集或表现偏差影响的输入——我们应确保我们的测试集包含足够多这些案例。在拆分我们的数据之后,我们将在本节“训练前”部分使用与我们数据的每个拆分相同类型的分析:训练、验证和测试。

正如本分析所示,不存在适用于所有情况的模型公平性解决方案或评估指标。这是一个持续的迭代过程,应在整个 ML 工作流程中使用 —— 从数据收集到部署模型。

权衡与替代方案

除了解决方案部分讨论的预训练和后训练技术外,还有许多方法可以处理模型公平性。在这里,我们将介绍一些实现公平模型的替代工具和流程。ML 公平性是一个快速发展的研究领域 —— 本节包含的工具并非旨在提供详尽列表,而是当前可用于提升模型公平性的几种技术和工具。我们还将讨论公平性镜头和可解释预测设计模式之间的区别,因为它们相关且经常一起使用。

公平性指标

公平性指标(FI)是一套开源工具,旨在帮助理解数据集在训练前的分布,并使用公平性指标评估模型性能。FI 中包含的工具有 TensorFlow 数据验证(TFDV)和 TensorFlow 模型分析(TFMA)。公平性指标通常作为 TFX 流水线的组件(详见“设计模式 25:工作流程管道”在第六章中获取更多细节),或通过 TensorBoard 使用。在 TFX 中,有两个预构建组件利用公平性指标工具:

  • ExampleValidator 用于数据分析,检测漂移以及使用 TFDV 进行训练-服务偏差。

  • 评估器使用 TFMA 库评估数据集的模型子集。从 TFMA 生成的交互式可视化示例在 图 7-15 中展示。它查看数据中的一个特征(身高),并分析该特征的每个可能分类值的模型假阴性率。

比较模型在不同数据子集上的假阴性率。

图 7-15. 比较模型在不同数据子集上的假阴性率。

公平性指标 Python 包 看,TFMA 还可以作为一个独立工具,与 TensorFlow 和非 TensorFlow 模型一起使用。

自动化数据评估

我们在解决方案部分讨论的公平评估方法主要侧重于手动交互式数据和模型分析。这种类型的分析在模型开发的初期阶段尤为重要。随着我们将模型投入运行并专注于其维护和改进,找到自动化公平评估的方法将提高效率,并确保公平性贯穿整个 ML 过程。我们可以通过讨论的“设计模式 18:持续模型评估”在第五章,或使用“设计模式 25:工作流管道”在第六章中进行这样的操作,使用像 TFX 提供的数据分析和模型评估组件。

允许和禁止列表

当我们无法直接修复数据或模型中固有偏见时,可以在生产模型之上硬编码规则,使用允许和禁止列表。这主要适用于分类或生成模型,当我们不希望模型返回某些标签或词语时。例如,如 Google Cloud Vision API 的标签检测功能中已经移除了类别化的词语“man”和“woman” 详见。因为仅凭外观无法确定性别,如果仅基于视觉特征返回这些标签,会强化不公平的偏见。因此,Vision API 返回“person”。同样,在 Gmail 的智能撰写功能中,避免使用性别化代词 详见,例如在完成句子“我下周将见投资人,你要见 ___ 吗?”时。

在 ML 工作流的两个阶段之一中可以应用这些允许和禁止列表:

数据收集

当从头开始训练模型或使用迁移学习设计模式添加我们自己的分类层时,我们可以在数据收集阶段定义模型的标签集,这是在模型训练之前。

训练后

如果我们依赖预训练模型进行预测,并且使用与该模型相同的标签,可以在生产中实施允许和禁止列表——在模型返回预测结果后,但在这些标签呈现给最终用户之前。这也适用于文本生成模型,其中我们无法完全控制所有可能的模型输出。

数据增强

除了之前讨论的数据分布和表示解决方案之外,减少模型偏差的另一种方法是进行数据增强。使用这种方法,在训练之前会修改数据,以消除潜在的偏见源。数据增强的一种具体类型被称为剔除,特别适用于文本模型。例如,在文本情感分析模型中,我们可以从文本中删除身份术语,以确保它们不会影响我们模型的预测。继续我们在本节早些时候使用的冰淇淋示例,句子“Mint chip is their best ice cream flavor”在应用剔除后会变成“BLANK is their best ice cream flavor”。然后,我们会将数据集中的所有其他单词替换为相同的词(我们这里使用了 BLANK,但是数据集中未出现的任何单词也可以)。请注意,虽然这种剔除技术对许多文本模型效果很好,但是在从表格数据集中移除偏见区域时需要小心,如问题部分所述。

另一种数据增强方法涉及生成新数据,谷歌翻译在将文本从性别中性到性别特定语言和从性别特定到性别中性语言时使用了这种方法以减少性别偏见。解决方案包括重新编写翻译数据,以便在适用时,提供翻译的女性和男性形式。例如,性别中性的英文句子“We are doctors”在翻译成西班牙语时会产生两个结果,如在图 7-16 中所示。在西班牙语中,“we”可以有女性和男性形式。

当将一个语言中的性别中性词(这里是英语中的“we”)翻译成一个性别特定的语言时,Google Translate 现在提供多种翻译选项以减少性别偏见。

Figure 7-16. 当将一个语言中的性别中性词(这里是英语中的“we”)翻译成一个性别特定的语言时,Google Translate 现在提供多种翻译选项以减少性别偏见。

模型卡

最初在一篇研究论文中引入,模型卡提供了报告模型能力和限制的框架。模型卡的目标是通过提供模型适用和不适用的场景细节来提高模型的透明性,因为减轻问题性偏差只有在模型按照预期的方式使用时才会起作用。通过模型卡,鼓励在正确的上下文中使用模型的责任感。

第一个模型卡片发布提供了 Google Cloud Vision API 中面部检测和物体检测功能的摘要和公平度量。为了为我们自己的 ML 模型生成模型卡片,TensorFlow 提供了一个模型卡片工具包(MCT),可以作为独立的 Python 库运行,也可以作为 TFX 管道的一部分运行。该工具包读取导出的模型资产,并生成一系列具有各种性能和公平度量的图表。

公平性与可解释性

在 ML 中,公平性和可解释性的概念有时会引起混淆,因为它们经常一起使用,并且都是责任 AI 大计划的一部分。公平性特别适用于识别和消除模型中的偏见,而解释性则是诊断偏见存在的一种方法。例如,将解释性应用于情感分析模型可能会揭示,该模型依赖于身份术语进行预测,而应该使用像“最差”,“惊人”或“不是”等词语。

解释性也可以在公平性背景之外使用,以揭示模型为什么会标记特定的欺诈交易,或者导致模型在医学图像中预测“患病”的像素。因此,解释性是改进模型透明度的一种方法。有时透明性可以揭示模型在处理某些群体时存在不公平的情况,但也可以提供更高层次的洞察模型的决策过程。

总结

虽然彼得·帕克在说“伴随着强大的力量而来的是巨大的责任”时可能并不是在谈论机器学习,但这句话在这里确实适用。ML 有能力颠覆行业、提高生产力,并从数据中获得新的见解。有了这种潜力,我们尤其需要了解我们的模型将如何影响不同利益相关者群体。模型的利益相关者可能包括不同的模型用户人群、监管组织、数据科学团队,或者组织内的业务团队。

本章介绍的负责任人工智能模式是每个机器学习工作流程的重要组成部分——它们可以帮助我们更好地理解模型生成的预测,并在模型投入生产之前捕捉潜在的不良行为。从启发式基准模式开始,我们研究了如何识别模型评估的初始度量标准。这种度量标准对于理解后续模型版本和总结业务决策者的模型行为非常有用。在可解释预测模式中,我们展示了如何使用特征归因来查看哪些特征在信号化模型预测中最重要。特征归因是一种解释方法,可以用于评估单个示例或一组测试输入的预测。最后,在公平性透镜设计模式中,我们介绍了确保模型预测以公平、公正和无偏见方式对待所有用户群体的工具和度量标准。

¹ DR 是一种影响全球数百万人的眼部疾病。它可能导致失明,但如果早期发现,可以成功治疗。要了解更多信息并找到数据集,请参见这里

² 解释被用来识别和纠正放射图像中存在的注释,详见这项研究

³ 此处讨论的模型是在公共 UCI 数据集上训练的。

scikit-learn 文档详细介绍了如何正确解释线性模型中学到的权重。

⁵ 我们专注于这两种解释方法,因为它们被广泛使用并涵盖多种模型类型,但本分析未包括的其他方法和框架包括LIMEELI5

⁶ 关于这些解释方法及其实现的更多细节,请参见可解释人工智能白皮书。

⁷ 关于 Quick, Draw!和基于示例的解释的更多细节,请参见这篇paper

⁸ 关于种族和性别偏见如何影响图像分类模型的更详细信息,请参见 Joy Buolamwini 和 Timmit Gebru 的“性别阴影:商业性别分类中的交叉准确性差异”机器学习研究会议论文集 81 (2018): 1-15。

⁹ 要了解更多关于改变预测任务的信息,请参见“设计模式 5: 重新构架”和“设计模式 9: 中性类”,位于第三章中。

¹⁰ 在这个数据集上,还可以进行许多更多的预训练优化。我们在这里只选择了一个作为演示可能性的示例。

¹¹ 本文提供了更多关于 What-If 工具用于公平优化策略的详细信息。

¹² 更多关于机会平等作为公平度量标准的详细信息,请查看此处

第八章:连接模式

我们致力于创建一个机器学习设计模式的目录,用于解决在设计、训练和部署机器学习模型和管道时经常遇到的问题。在本章中,我们提供了对这些模式清单的快速参考。

我们按照它们在典型 ML 工作流中的使用位置组织了这本书中的模式。因此,我们有一个关于输入表示的章节,另一个关于模型选择。然后,我们讨论了修改典型训练循环并使推断更具韧性的模式。最后,我们讨论了促进 ML 系统负责使用的模式。这类似于在食谱书中分别设置关于开胃菜、汤、主菜和甜点的部分。然而,这样的组织方式可能会使人难以确定何时选择哪种汤以及哪种甜点适合某些主菜。因此,在本章中,我们还阐明了模式之间的关系。最后,我们还通过讨论模式如何相互作用来制定“餐谱”,以解决常见 ML 任务类别的问题。

模式参考

我们讨论了许多不同的设计模式以及它们如何用于解决机器学习中常见的挑战。以下是总结。

章节 设计模式 解决的问题 解决方案
数据表示 散列特征 与分类特征相关的问题,如词汇不完整、模型大小由基数导致、以及冷启动。 对字符串表示的确定性和可移植哈希进行分桶,并接受数据表示中碰撞的权衡。
嵌入 高基数特征,其中保留接近关系很重要。 学习一种数据表示,将高基数数据映射到低维空间,以保留与学习问题相关的信息。
特征交叉 模型复杂度不足以学习特征之间的关系。 通过明确将每个输入值组合作为单独的特征,帮助模型更快地学习输入之间的关系。
多模态输入 如何在几种潜在的数据表示之间进行选择。 连接所有可用的数据表示。
问题表示 重新构建 包括数值预测的置信度、序数类别、限制预测范围以及多任务学习等多个问题。 改变机器学习问题输出的表示方式;例如,将回归问题表示为分类问题(反之亦然)。
多标签 一个给定训练示例适用于多个标签。 使用多热编码数组对标签进行编码,并将k个 sigmoid 函数用作输出层。
集成 在小到中等规模问题上的偏差-方差权衡。 结合多个机器学习模型并聚合它们的结果以进行预测。
级联 当机器学习问题被分解为一系列机器学习问题时,可维护性或漂移问题。 将机器学习系统视为用于训练、评估和预测的统一工作流程。
中性类 某些示例子集的类标签本质上是任意的。 为分类模型引入额外的标签,与当前标签不相交。
重平衡 数据严重不平衡。 根据不同考虑因素进行下采样、过采样或使用加权损失函数。
修改模型训练的模式 有用的过拟合 使用机器学习方法学习基于物理的模型或动态系统。 放弃通常的泛化技术,故意在训练数据集上过拟合。
检查点 由于机器故障导致长时间运行的训练作业丢失进度。 定期存储模型的完整状态,以便可以使用部分训练的模型来恢复训练,而不是从头开始。
迁移学习 缺乏训练复杂机器学习模型所需的大型数据集。 取出先前训练模型的一部分,冻结权重,并在新模型中使用这些不可训练层解决类似问题。
分布策略 训练大型神经网络可能需要很长时间,这会减慢实验速度。 在多个工作节点上以规模执行训练循环,利用缓存、硬件加速和并行化。
超参数调整 如何确定机器学习模型的最佳超参数。 将训练循环插入优化方法中,以找到最佳的模型超参数集。
弹性 无状态服务函数 生产机器学习系统必须能够同步处理每秒数千到数百万个预测请求。 将机器学习模型导出为无状态函数,以便可以以可扩展的方式由多个客户端共享。
批量服务 使用设计用于逐个处理请求的端点对大量数据进行模型预测会压垮模型。 使用常用于分布式数据处理的软件基础设施,在大量实例上异步执行推理。
持续模型评估 部署模型的性能随时间逐渐下降,原因可能是数据漂移、概念漂移或其他影响向模型提供数据的管道的变化。 通过持续监控模型预测和评估模型性能来检测部署模型何时不再适合用途。
两阶段预测 在边缘或分布式设备上部署大型复杂模型时必须保持性能。 将用例分成两个阶段,仅在边缘执行更简单的阶段。
键控预测 如何映射返回的模型预测到相应的模型输入,当提交大规模预测作业时。 允许模型在预测过程中传递一个客户端支持的关键字,该关键字可用于将模型输入与模型预测结果连接起来。
可重现性 转换 必须将输入数据转换为模型期望的特征,并且该过程在训练和服务过程中必须保持一致。 明确捕获并存储应用的转换步骤,将模型输入转换为特征。
可重复切分 在创建数据切分时,重要的是拥有一种轻量级且可重复的方法,无论编程语言或随机种子如何。 确定一个捕获行之间相关关系的列,并使用 Farm Fingerprint 哈希算法将可用数据拆分为训练、验证和测试数据集。
桥接模式 随着新数据的出现,任何数据架构的更改都可能阻止同时使用新旧数据进行重新训练。 适应数据从其旧的原始数据架构转换为与新数据的架构匹配。
窗口推断 一些模型需要持续序列的实例来运行推断,或者必须对时间窗口内的特征进行聚合,以避免训练与服务之间的偏差。 将模型状态外部化,并从流分析管道调用模型,以确保以动态、时间依赖的方式计算的特征能够在训练和服务之间正确重复。
工作流水线 在扩展机器学习工作流时,独立运行试验,并跟踪管道每一步的性能。 将机器学习工作流的每个步骤作为一个单独的容器化服务,可以将它们链接在一起,以便通过单个 REST API 调用运行管道。
特征存储 非专用的特征工程方法减慢了模型开发,并导致团队之间的工作重复,以及工作流效率低下。 创建一个特征存储库,用于存储和记录特征数据集的集中位置,这些特征数据集将用于构建机器学习模型,并可跨项目和团队共享。
模型版本控制 在生产环境中仅有一个模型时,进行性能监控和拆分测试模型更改非常困难,或者在不影响现有用户的情况下更新模型。 部署一个更改后的模型作为一个微服务,并使用不同的 REST 端点,以实现对已部署模型的向后兼容性。
负责任的 AI 启发式基准 使用复杂的评估指标解释模型性能不能提供业务决策者所需的直观感受。 将机器学习模型与简单易懂的启发式方法进行比较。
可解释预测 有时需要知道模型为何做出某些预测,无论是用于调试还是遵守监管和合规标准。 应用模型可解释性技术来理解模型为何如何进行预测,并提升用户对 ML 系统的信任。
公平性镜头 偏见可能导致机器学习模型对所有用户不公平对待,并对某些人群产生不利影响。 使用工具在训练前识别数据集中的偏见,并通过公平性镜头评估训练后的模型,确保模型预测在不同用户群体和不同场景中是公平的。

模式互动

设计模式并不孤立存在。许多设计模式要么直接要么间接地彼此紧密相关,并且通常互为补充。图 8-1 中的互动图表总结了不同设计模式之间的相互依赖和某些关系。如果你发现自己在使用某个模式,你可能会受益于考虑如何将其他与之相关的模式整合进来。

在这里,我们将突出一些模式之间的关系以及在开发完整解决方案时它们如何结合使用。例如,在处理分类特征时,可以将散列特征设计模式与嵌入设计模式结合使用。这两种模式共同处理高基数模型输入,例如处理文本。在 TensorFlow 中,通过将categorical_column_with_hash_bucket特征列包装在embedding特征列中,将稀疏的分类文本输入转换为密集表示来演示这一点:

import tensorflow.feature_column as fc
keywords = fc.categorical_column_with_hash_bucket("keywords", 
   hash_bucket_size=10K)
keywords_embedded = fc.embedding_column(keywords, num_buckets=16)

当讨论嵌入时,我们发现在使用特征交叉设计模式时推荐使用这一技术。散列特征与可重复分割设计模式紧密相关,因为 Farm Fingerprint 散列算法可用于数据分割。而在使用散列特征或嵌入设计模式时,通常会考虑到超参数调整的概念,以确定使用的最佳散列桶数或正确的嵌入维度。

本书讨论的许多模式都相关或可以一起使用。此图像在本书的 GitHub 存储库中可用。

图 8-1. 本书讨论的许多模式都相关或可以一起使用。此图像在GitHub 存储库中可用。

实际上,超参数调优设计是机器学习工作流程的常见部分,并经常与其他模式结合使用。例如,我们可以使用超参数调优来确定在实施桥接模式(Bridged Schema pattern)时要使用的旧示例数量。而在使用超参数调优时,重要的是要记住我们如何设置模型检查点(Checkpoints)使用虚拟时代和分布式训练。与此同时,检查点设计模式自然地与迁移学习联系在一起,因为在微调过程中通常使用较早的模型检查点。

嵌入(Embeddings)在整个机器学习中广泛应用,因此嵌入设计模式与其他模式的交互方式多种多样。也许最显著的是迁移学习(Transfer Learning),因为从预训练模型的中间层生成的输出实质上就是学习到的特征嵌入。我们还看到,通过在分类模型中自然地或通过重构模式(Reframing pattern)将中性类设计模式(Neutral Class design pattern)整合进去,可以改善这些学习到的嵌入。进一步地,如果这些嵌入被用作模型的特征,使用特征存储模式(Feature Store pattern)将它们保存下来以便轻松访问和版本管理可能会很有优势。或者,在迁移学习的情况下,预训练模型输出可以被视为级联模式的初始输出。

我们还看到如何通过将两种其他设计模式结合起来——重构模式(Reframing)和级联模式(Cascade),来处理再平衡设计模式(Rebalancing pattern)。重构模式将允许我们将不平衡的数据集表示为“正常”或“异常”的分类。该模型的输出然后将传递给一个次要的回归模型,该模型针对任一数据分布进行优化预测。这些模式很可能还会导致可解释预测设计模式(Explainable Predictions pattern),因为在处理不平衡数据时,验证模型是否捕捉到了正确的预测信号尤为重要。事实上,当构建涉及多个模型级联的解决方案时,鼓励考虑可解释预测设计模式,因为这可以限制模型的可解释性。模型可解释性的这种权衡再次出现在集成模型和多模型输入模式中,因为这些技术也不太适合某些可解释性方法。

当使用桥接模式时,级联设计模式可能也会有所帮助,并且可以作为一个替代模式,通过具有初步模型的方式来填补辅助架构的缺失值。然后可以将这两种模式结合起来,以保存后续在特征存储模式中描述的特征集。这是另一个突显特征存储模式多功能性的例子,以及它如何经常与其他设计模式结合使用。例如,特征存储提供了一种方便的方式来维护和利用通过窗口推理模式产生的流模型特征。特征存储还与管理在重新框架模式中可能出现的不同数据集紧密配合,并提供了在使用转换模式时出现的可重复使用的技术版本。正如在特征存储模式中讨论的特征版本控制能力,也在模型版本设计模式中发挥作用。

另一方面,模型版本设计模式与无状态服务函数和持续模型评估模式密切相关。在持续模型评估中,可以在评估模型性能随时间而变差时使用不同的模型版本。同样,服务函数的不同模型签名提供了创建不同模型版本的简便方式。通过无状态服务函数模式进行模型版本管理的这种方法可以追溯到重新框架模式,其中两个不同的模型版本可以为两种不同的模型输出表示提供它们自己的 REST API 端点。

当使用持续模型评估模式时,探索工作流管道模式中呈现的解决方案通常是有利的,这样可以设置触发器来启动重新训练管道,并且保持对创建的各种模型版本的血统跟踪。持续模型评估还与键控预测模式密切相关,因为这可以为将真实数据与模型预测输出轻松结合提供机制。同样,键控预测模式也与批量服务模式紧密相连。同理,批量服务模式通常与无状态服务函数模式结合使用,以执行规模化的预测任务,而这又依赖于底层的转换模式来保持训练与服务之间的一致性。

在机器学习项目中的模式

机器学习系统使组织内的团队能够按比例构建、部署和维护机器学习解决方案。它们为自动化和加速 ML 生命周期的所有阶段提供了平台,从管理数据到训练模型、评估性能、部署模型、提供预测和监控性能。本书讨论的模式将出现在任何机器学习项目中。在本节中,我们将描述 ML 生命周期的各个阶段以及这些模式可能出现的位置。

ML 生命周期

构建机器学习解决方案是一个循环过程,从清晰理解业务目标开始,最终导致生产一个机器学习模型,以实现这一目标的利益。这个 ML 生命周期的高级概述(见图 8-2)提供了一个有用的路线图,旨在使 ML 为企业带来价值。每个阶段同等重要,未能完成任何一个步骤将增加后续阶段产生误导性见解或毫无价值模型的风险。

ML 生命周期始于定义业务用例,并最终导致生产一个机器学习模型,以实现这一目标的利益。

图 8-2. ML 生命周期始于定义业务用例,并最终导致生产一个机器学习模型,以实现这一目标的利益。

ML 生命周期包括三个阶段,如图 8-2 所示:发现、开发和部署。每个阶段的各个步骤有一个规范的顺序。然而,这些步骤是以迭代的方式完成的,较早的步骤可能会根据后续阶段收集到的结果和见解进行重新访问。

发现

机器学习存在作为解决问题的工具。ML 项目的发现阶段始于定义业务用例(图 8-2 的步骤 1)。这是业务领导者和 ML 从业者对问题具体细节进行协调,并理解 ML 能够做到什么和不能做到什么来实现这一目标的关键时刻。

在生命周期的每个阶段都要牢记业务价值的重要性。在各个阶段必须做出许多选择和设计决策,通常情况下并没有单一的“正确”答案。相反,最佳选择取决于模型如何支持业务目标。虽然研究项目的可行目标可能是在基准数据集上再提高 0.1%的准确率,但这在行业中是不可接受的。对于为企业组织构建的生产模型,成功取决于与业务更紧密相关的因素,如提高客户保留率、优化业务流程、增加客户参与度或减少流失率。还可能存在间接影响开发选择的与业务用例相关的因素,如推断速度、模型大小或模型可解释性。任何机器学习项目都应始于对业务机会的深入理解,以及机器学习模型如何能够对当前运营进行实质性改进。

一个成功的发现阶段需要业务领域专家和机器学习专家之间的合作,以评估 ML 方法的可行性。关键是有人理解业务和数据,与了解技术挑战及工程工作量的团队合作。如果开发资源的整体投资超过了对组织的价值,那么这不是一个值得的解决方案。可能技术开销和生产资源成本超过了模型只能提高 0.1%流失预测的好处。或者也可能不是这样。如果一个组织的客户基础达到 10 亿人,那么 0.1%仍然意味着 100 万更加满意的客户。

在发现阶段,明确任务的业务目标和范围非常重要。这也是确定将用于衡量或定义成功的指标的时候。成功在不同组织中可能看起来不同,甚至在同一组织的不同组中也是如此。例如,可以参考“机器学习中的常见挑战”中对多目标的讨论,该讨论在第一章中有详述。在 ML 项目开始时创建明确定义的指标和关键绩效指标(KPIs)可以帮助确保所有人都朝着共同的目标对齐。理想情况下,已经有某些程序可供参考,以便将来的进展进行测量。这可能是一个已经在生产中的模型,甚至只是目前正在使用的基于规则的启发式。机器学习并非所有问题的答案,有时候基于规则的启发式难以超越。开发不应仅仅是为了开发而开发。一个基准模型,无论多么简单,都有助于指导未来的设计决策,并理解每个设计选择如何在预定的评估指标上推动进展。在第七章中,我们讨论了启发式基准的作用,以及与商业利益相关方沟通时经常涉及的负责任 AI 相关的其他主题。

当然,这些对话也应该发生在数据的背景下。业务的深度挖掘应该与数据探索的深度挖掘(见图 8-2 的第二步)手牵手进行。即使解决方案可能非常有益,如果没有质量数据可用,那么就没有项目。或许数据是存在的,但由于数据隐私原因,不能使用或必须清洗掉模型所需的相关信息。无论如何,项目的可行性和成功的潜力都依赖于数据。因此,早期让组织内的数据管理者参与这些对话至关重要。

数据指导整个过程,了解可用数据的质量非常重要。关键特征的分布是什么样的?有多少缺失值?如何处理缺失值?是否存在异常值?输入值是否高度相关?输入数据中存在哪些特征,哪些特征应该被设计?许多机器学习模型需要大量的训练数据集。是否有足够的数据?我们如何增加数据集?数据集中是否存在偏差?这些都是重要的问题,它们只是触及表面。在这个阶段可能的一个决定是,在项目可以继续之前需要收集更多数据,或者收集特定场景的数据。

数据探索是回答数据是否具有足够质量的关键步骤。单凭对话很少能替代亲自动手并进行数据实验。在此步骤中,可视化起着重要作用。密度图和直方图有助于理解不同输入值的分布情况。箱线图可以帮助识别异常值。散点图有助于发现和描述双变量关系。百分位数可以帮助识别数值数据的范围。平均数、中位数和标准差有助于描述中心趋势。这些技术及其他方法有助于确定哪些特征可能对模型有益,以及进一步了解需要对数据进行哪些转换以准备建模所需。

在发现阶段内,进行几个建模实验以查看是否确实有“信号在噪声中”。此时,执行机器学习可行性研究(第 3 步)可能会有益。顾名思义,这通常是一个短期技术冲刺,仅持续几周,旨在评估解决问题的数据的可行性。这提供了探索框架机器学习问题、尝试算法选择以及了解哪些特征工程步骤最有益的机会。在发现阶段的可行性研究步骤也是创建启发式基准的好时机(参见第七章)。

开发

在同意关键评估指标和业务 KPI 后,机器学习生命周期的开发阶段开始。许多机器学习资源详细介绍了开发 ML 模型的细节。在这里,我们重点介绍关键组成部分。

在开发阶段,我们首先通过构建数据管道和工程化特征(图 8-2 的第 4 步)来处理数据输入,这些数据输入将被馈送给模型。在实际应用中收集的数据可能存在许多问题,如缺失值、无效示例或重复数据点。数据管道需要预处理这些数据输入,以便模型可以使用它们。特征工程是将原始输入数据转换为更符合模型学习目标并以可供训练的格式表达的特征的过程。特征工程技术可能涉及将输入进行分桶、在不同数据格式之间转换、对文本进行标记化和词干提取、创建分类特征或独热编码、哈希输入、创建特征交叉和特征嵌入等。本书的第二章讨论了数据表示设计模式,并涵盖了在 ML 生命周期的这一阶段涉及的许多数据方面。第五章和第六章描述了与 ML 系统中弹性和可重现性相关的模式,有助于构建数据管道。

在这一步骤中,可能还涉及工程化问题的标签以及与问题表示方式相关的设计决策。例如,对于时间序列问题,可能需要创建特征窗口,并尝试滞后时间和标签间隔的大小。或者可能有助于将回归问题重新构造为分类问题,并完全改变标签的表示方式。或者,如果输出类的分布被单一类别过度代表,可能需要采用重新平衡技术。本书的第三章侧重于问题表示,并讨论了与问题框架相关的这些重要设计模式及其他内容。

发展阶段的下一步(图 8-2 中的第 5 步,参见#the_ml_life_cycle_begins_with_defining)专注于构建 ML 模型。在这一开发阶段,遵循在管道中捕获 ML 工作流的最佳实践至关重要:参见“设计模式 25:工作流管道”在第六章中。这包括在任何模型开发之前创建可重复使用的训练/验证/测试集划分,以确保没有数据泄漏。可以训练不同的模型算法或算法组合,以评估它们在验证集上的性能,并检查其预测的质量。调整参数和超参数,采用正则化技术,并探索边缘案例。典型的 ML 模型训练循环在第四章开头详细描述,我们还讨论了用于更改训练循环以达到特定目标的有用设计模式。

ML 生命周期的许多步骤都是迭代的,特别是在模型开发阶段尤为如此。经过一些试验后,经常需要重新审视数据、业务目标和关键绩效指标。在模型开发阶段,会得到新的数据洞察,这些洞察能够更清晰地展示可能性(以及不可能性)。在开发自定义模型时,通常会花费很长时间,这在模型开发阶段尤为突出。第六章还讨论了许多解决在这一迭代阶段中出现的挑战的再现性设计模式。

在模型开发的整个过程中,每一次新的调整或方法都会根据在发现阶段设定的评估指标进行衡量。因此,成功执行发现阶段至关重要,并且有必要在该阶段做出的决策上达成一致。最终,模型开发将在最终评估步骤(图 8-2 的第 6 步,参见#the_ml_life_cycle_begins_with_defining)中结束,并根据那些预先确定的评估指标评估模型性能。

开发阶段的关键结果之一是解释并向业务内的利益相关者和监管组织呈现结果(见图 8-2 的第 7 步)。这种高级评估至关重要,有必要将开发阶段的价值传达给管理层。此步骤专注于为将呈现给组织内利益相关者的初步报告创建数字和可视化内容。第七章讨论了一些确保 AI 被负责使用并有助于利益相关者管理的常见设计模式。通常,这是决定是否将进一步资源投入到机器学习生产化和部署的最后阶段的关键决策点。

部署

假设模型开发成功并证明了有希望的结果,下一阶段便是专注于模型的生产化,第一步(见图 8-2 的第 8 步)是规划部署。

训练机器学习模型需要大量工作,但要充分实现这些努力的价值,模型必须在生产环境中运行,以支持其旨在改进的业务工作。有几种方法可以实现这一目标,根据使用情况,不同组织的部署方式可能不同。例如,生产化的机器学习资产可以采取交互式仪表盘、静态笔记本、封装在可重用库中的代码或者网页服务端点的形式。

对于生产化模型,存在许多考虑因素和设计决策。与之前一样,发现阶段中的许多决策也指导这一步。如何管理模型重新训练?输入数据是否需要实时流入?训练应该在新的数据批次上进行还是实时进行?模型推断如何?我们应该计划每周一次的一次性批量推断作业,还是需要支持实时预测?是否需要考虑特殊的吞吐量或延迟问题?是否需要处理尖峰工作负载?低延迟是否是首要考虑的?网络连接是否存在问题?第五章中的设计模式涉及了在将 ML 模型操作化过程中出现的一些问题。

这些都是重要的考虑因素,而这个最终阶段往往是许多企业面临的最大障碍,因为它可能需要组织的不同部分之间的强大协调,并且需要整合各种技术组件。这种困难部分原因在于,生产化需要将一个依赖于机器学习模型的新流程整合到现有系统中。这可能涉及处理为支持单一方法而开发的遗留系统,或者在组织内部导航复杂的变更控制和生产流程。此外,许多时候,现有系统没有支持来自机器学习模型预测的机制,因此必须开发新的应用程序和工作流程。预见到这些挑战是非常重要的,从业务运营方面开发全面的解决方案需要大量投资,以尽可能简化过渡并增加市场推出速度。

部署阶段的下一步是将模型运营化(图 8-2 中的第 9 步)[¹]。这一实践领域通常被称为 MLOps(机器学习运营),涵盖了自动化、监控、测试、管理和维护生产中的机器学习模型相关的方方面面。对于任何希望在其组织内扩展机器学习驱动应用数量的公司来说,这是一个必要的组成部分。

运营化模型的关键特征之一是自动化工作流管道。ML 生命周期的开发阶段是一个多步骤过程。构建用于自动化这些步骤的管道能够实现更高效的工作流和可重复的过程,从而改进未来模型开发,并允许在解决出现问题时提高灵活性。今天,像Kubeflow这样的开源工具提供了这种功能,许多大型软件公司也开发了自己的端到端 ML 平台,例如Uber 的 MichelangeloGoogle 的 TFX,这些平台也是开源的。

成功的运营化包括持续集成和持续交付(CI/CD)的组成部分,这些是软件开发的熟悉最佳实践。这些 CI/CD 实践侧重于代码开发中的可靠性、可重复性、速度、安全性和版本控制。ML/AI 工作流也从相同的考虑因素中受益,尽管存在一些显著的差异。例如,除了用于开发模型的代码外,将这些 CI/CD 原则应用于数据,包括数据清理、版本控制和数据管道的编排,也非常重要。

部署阶段需考虑的最后一步是监控和维护模型。一旦模型被操作化并投入生产,监控模型的性能就变得必要了。随着时间的推移,数据分布会发生变化,导致模型变得陈旧。这种模型过时(见图 8-3)可能由多种原因引起,从客户行为变化到环境变动。因此,建立有效监控机制以监控机器学习模型及其性能相关的各个组成部分(从数据收集到服务期间预测质量)非常重要。第五章的“设计模式 18:持续模型评估”详细讨论了这一常见问题及其解决方案。

模型过时可能出现很多原因。定期重新训练模型有助于随着时间推移提高它们的性能。

图 8-3. 模型过时可能出现很多原因。定期重新训练模型有助于随着时间推移提高它们的性能。

例如,监控特征值的分布以与开发阶段使用的分布进行比较是很重要的。监控标签值的分布也很重要,以确保数据漂移没有导致标签分布的不平衡或偏移。机器学习模型通常依赖于从外部来源收集的数据。也许我们的模型依赖于第三方交通 API 来预测汽车接送等待时间,或使用天气 API 的数据作为预测航班延误的模型的输入。这些 API 不由我们团队管理。如果该 API 失败或其输出格式发生显著变化,将会对我们的生产模型产生后果。在这种情况下,设置监控以检查这些上游数据源的变化非常重要。最后,建立系统来监控预测分布,并在可能时衡量生产环境中预测质量也是至关重要的。

在完成监控步骤后,重新审视业务用例并客观、准确地评估机器学习模型如何影响业务绩效可能是有益的。很可能会带来新的见解并开启新的机器学习项目,生命周期再次开始。

AI 准备度

我们发现,不同的组织在构建机器学习解决方案时处于 AI 准备的不同阶段。根据Google Cloud 发布的白皮书,公司在将 AI 整合到业务中的成熟度通常可以划分为三个阶段:战术、战略和变革。这三个阶段中的机器学习工具从战术阶段的主要手动开发,到战略阶段的使用流水线,再到变革阶段的完全自动化。

战术阶段:手动开发

AI 准备的战术阶段通常出现在刚开始探索 AI 潜力以交付的组织中,重点放在短期项目上。在这里,AI/ML 用例往往更加狭窄,更多地关注概念验证或原型;与业务目标的直接联系并不总是清晰的。在这个阶段,组织认识到先进分析工作的潜力,但执行主要由个人贡献者驱动或完全外包给合作伙伴;在组织内部获取大规模、高质量数据集可能会很困难。

通常,在这个阶段,没有一致扩展解决方案的过程,并且使用的 ML 工具(见图 8-4)是根据特定需求开发的。数据离线存储或在孤立的数据岛中,并手动访问进行数据探索和分析。目前没有工具可以自动化 ML 开发周期的各个阶段,也没有太多关注于开发工作流程的可重复过程。这使得在组织成员之间共享资产变得困难,并且没有专门的硬件用于开发。

MLOps 的范围仅限于训练模型的存储库,并且在测试和生产环境之间几乎没有区别,最终模型可能会部署为基于 API 的解决方案。

AI 模型的手动开发。图示改编自 Google Cloud 文档。

图 8-4. AI 模型的手动开发。图示改编自Google Cloud 文档

战略阶段:利用流水线

战略阶段的组织已经将 AI 工作与业务目标和优先事项对齐,ML 被视为业务的关键推动因素。因此,通常会有高级执行赞助和专门的 ML 项目预算,由熟练的团队和战略合作伙伴执行。为这些团队提供了基础设施,以便轻松共享资产并开发利用现成和定制模型的 ML 系统。在开发和生产环境之间有明确的区别。

团队通常已具备数据清洗技能,擅长描述性和预测性分析。数据存储在企业数据仓库中,并有一个统一的模型用于集中管理数据和机器学习资产。ML 模型的开发是作为一项协调的实验进行的。这些管道的 ML 资产和源代码存储在一个集中的源代码库中,并且在组织成员之间轻松共享。

开发 ML 模型的数据管道是自动化的,利用完全托管的、无服务器的数据服务进行数据摄取和处理,可以按计划或事件驱动。此外,ML 训练、评估和批量预测的工作流由自动化管道管理,以便通过性能监控触发器执行 ML 生命周期的各个阶段,从数据验证和准备到模型训练和验证(见 图 8-5)。

在生产环境中可能部署和维护多个具有日志记录、性能监控和通知功能的机器学习系统。这些机器学习系统利用模型 API 处理实时数据流,既用于推理,也用于收集数据,这些数据被馈送到自动化机器学习管道中以更新模型以供后续训练。

AI 开发的管道阶段。图来源于 Google Cloud 文档。

图 8-5. AI 开发的管道阶段。图来源于Google Cloud 文档

转型阶段:完全自动化的流程

正在进行 AI 准备转型阶段的组织正在积极使用 AI 来推动创新,支持敏捷性,并培养一个实验和学习不断进行的文化。战略合作伙伴关系用于创新、共同创造,并增强公司内的技术资源。与 AI 准备阶段相关的许多与可复现性和弹性相关的设计模式在第五章和第六章中出现。

这一阶段,通常会将特定产品的 AI 团队嵌入更广泛的产品团队,并得到高级分析团队的支持。通过这种方式,ML 专业知识能够在组织内各业务线中传播。建立的常见模式和最佳实践,以及用于加速 ML 项目的标准工具和库,在组织内不同的团队之间轻松共享。

数据集存储在一个对所有团队都可访问的平台上,使得发现、共享和重复使用数据集和 ML 资产变得容易。有标准化的 ML 特征存储,并鼓励整个组织之间的合作。完全自动化的组织在集成 ML 实验和生产平台上运行,模型构建和部署以及 ML 实践对组织中的每个人都是可访问的。该平台由可扩展和无服务器的计算支持批处理和在线数据摄入和处理。例如,可按需使用专用的 ML 加速器如 GPU 和 TPU,并对端到端数据和 ML 管道进行编排实验。

开发和生产环境类似于流水线阶段(见图 8-6),但已将 CI/CD 实践整合到其 ML 工作流的各个阶段中。这些 CI/CD 最佳实践侧重于代码可靠性、可重复性和版本控制,以便生成 ML 模型以及数据和数据管道及其编排。这允许构建、测试和打包各种流水线组件。ML 模型版本控制由 ML 模型注册表维护,该注册表还存储必要的 ML 元数据和工件。

完全自动化的过程支持 AI 开发。图源自 Google Cloud 文档。

图 8-6. 完全自动化的过程支持 AI 开发。图源自Google Cloud 文档。

按用例和数据类型的常见模式

本书讨论的许多设计模式在任何机器学习开发周期中都会被使用,并且可能会被用于生产用例,例如超参数调整、启发式基准、可重复分割、模型版本控制、分布式训练、工作流管道或检查点。其他设计模式可能会在特定场景中特别有用。在这里,我们将根据流行的机器学习用例将常用的设计模式分组在一起使用。

自然语言理解

自然语言理解(NLU)是人工智能的一个分支,专注于训练机器理解文本和语言背后的含义。NLU 被语音助手如亚马逊的 Alexa、苹果的 Siri 和谷歌的 Assistant 用于理解句子,例如“这个周末天气预报如何?”有许多应用场景属于 NLU 的范畴,可以应用于许多过程,如文本分类(电子邮件过滤)、实体提取、问题回答、语音识别、文本摘要和情感分析。

  • 嵌入

  • 哈希特征

  • 中性类

  • 多模态输入

  • 迁移学习

  • 两阶段预测

  • 级联

  • 窗口推断

计算机视觉

计算机视觉是训练机器理解视觉输入(如图像、视频、图标等)的广泛父类 AI。计算机视觉模型旨在自动化依赖于人类视觉的任何任务,从使用 MRI 检测肺癌到自动驾驶汽车。计算机视觉的一些经典应用包括图像分类、视频运动分析、图像分割和图像去噪。

  • 重构

  • 中性类

  • 多模态输入

  • 迁移学习

  • 嵌入

  • 多标签

  • 级联

  • 两阶段预测

预测分析

预测建模使用历史数据来发现模式并确定未来事件发生的可能性。预测模型可以在许多不同的行业领域找到。例如,企业可能使用预测模型更准确地预测收入或预测产品的未来需求。在医学上,预测模型可能用于评估患者发展慢性疾病的风险或预测患者可能未按预约出现的时间。其他示例包括能源预测、客户流失预测、财务建模、天气预测和预测性维护。

  • 特征存储

  • 特征交叉

  • 嵌入

  • 集成

  • 转换

  • 重构

  • 级联

  • 多标签

  • 中性类

  • 窗口推断

  • 批量服务

物联网分析也是预测分析的一个广泛类别。物联网模型依赖于由称为物联网设备的互联网连接传感器收集的数据。考虑一架商用飞机,它有数千个传感器,每天收集超过 2 TB 的数据。物联网传感器设备数据的机器学习可以提供预测模型,以在故障发生之前发出警告。

  • 特征存储

  • 转换

  • 重构

  • 散列特征

  • 级联

  • 中性类

  • 两阶段预测

  • 无状态服务函数

  • 窗口推断

推荐系统

推荐系统是机器学习在业务中应用最广泛的领域之一,当用户与物品互动时通常会出现。推荐系统捕捉过去行为和类似用户的特征,并推荐对于给定用户最相关的物品。想象一下,YouTube 会根据你的观看历史推荐一系列视频,或者亚马逊可能根据购物车中的商品推荐购买。推荐系统在许多企业中非常流行,特别是产品推荐、个性化和动态营销以及流媒体视频或音乐平台。

  • 嵌入

  • 集成

  • 多标签

  • 迁移学习

  • 特征存储

  • 散列特征

  • 重构

  • 转换

  • 窗口推断

  • 两阶段预测

  • 中性类

  • 多模态输入

  • 批量服务

欺诈和异常检测

许多金融机构利用机器学习进行欺诈检测,以确保消费者账户的安全。这些机器学习模型经过训练,根据数据中学习到的某些特征或模式来标记可能是欺诈的交易。

更广泛地说,异常检测是一种用于发现数据集中异常行为或离群元素的技术。异常可以表现为偏离正常模式的突增或突降,也可以是较长期的异常趋势。异常检测在机器学习中出现在许多不同的用例中,甚至可能与其他用例一起使用。例如,考虑一个基于图像识别异常火车轨道的机器学习模型。

  • 重新平衡

  • 特征交叉

  • 嵌入

  • 集成

  • 两阶段预测

  • 转换

  • 特征存储

  • 级联

  • 中性类别

  • 重新构架

posted @ 2025-11-21 09:08  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报