Spark-机器学习扩展指南-全-
Spark 机器学习扩展指南(全)
原文:
zh.annas-archive.org/md5/f74e7f76de72733149fe62e91280487c译者:飞龙
序言
欢迎阅读《使用 Spark 扩展机器学习:MLlib、TensorFlow 和 PyTorch 的分布式机器学习》。本书旨在指导您在学习更多关于机器学习系统的过程中。Apache Spark 目前是大规模数据处理的最流行框架。它有许多 API 在 Python、Java 和 Scala 中实现,并被 Netflix、Microsoft 和 Apple 等许多大公司使用。PyTorch 和 TensorFlow 是最流行的机器学习框架之一。结合这些工具,这些工具已经在许多组织中得到使用,让您可以充分利用它们的优势。
不过,在我们开始之前,也许您想知道为什么我决定写这本书。好问题。有两个原因。第一个是通过分享我在过去十年中作为机器学习算法研究员积累的知识、经验和专业知识,来支持机器学习生态系统和社区。我大部分职业生涯都在作为数据基础设施工程师工作,为大规模数据分析构建基础设施,包括各种格式、类型和模式等,整合从客户、社区成员和同事那里收集到的知识,他们在头脑风暴和开发解决方案时分享了他们的经验。我们的行业可以利用这样的知识以更快的速度推动自己前进,通过利用他人的专业知识。虽然这本书的内容并不是所有人都适用,但大部分将为各种从业者提供新的方法。
这使我想到我写这本书的第二个原因:我想提供一个全面的方法来构建端到端可扩展的机器学习解决方案,超越传统方法。今天,许多解决方案都是根据组织特定需求和具体业务目标定制的。这很可能会继续成为未来多年的行业标准。在这本书中,我旨在挑战现状,激发更多创意解决方案,并解释多种方法和工具的利弊,使您能够利用组织中使用的任何工具,并获得最佳效果。我的总体目标是让数据和机器学习实践者更简单地合作,并更好地理解彼此。
谁应该阅读这本书?
本书适用于具有先前行业经验的机器学习实践者,他们希望了解 Apache Spark 的 MLlib 并增加对整个系统和流程的理解。数据科学家和机器学习工程师会特别感兴趣,但 MLOps 工程师、软件工程师以及任何对学习或构建分布式机器学习模型和使用 MLlib、分布式 PyTorch 和 TensorFlow 构建流水线感兴趣的人也会发现价值。理解机器学习工作的高级概念,并希望深入技术方面的技术人员也应该会对本书感兴趣且易于理解。
你是否需要分布式机器学习?
和所有好东西一样,这取决于情况。如果你有适合机器内存的小数据集,答案是否定的。如果你将来需要扩展你的代码并确保可以在不适合单台机器内存的更大数据集上训练模型,那么答案就是肯定的。
通常最好在整个软件开发生命周期中使用相同的工具,从本地开发环境到暂存和生产环境。但请注意,这也引入了管理分布式系统的其他复杂性,这通常将由组织中的不同团队处理。与您的同事合作时,共享一个通用的语言是一个好主意。
此外,今天创建机器学习模型的人们面临的最大挑战之一是将其从本地开发移至生产环境。我们中的许多人会犯“意大利面代码”的错误,这些代码应该是可重现的,但通常并非如此,并且很难进行维护和协作。在讨论实验生命周期管理的一部分中,我将涉及该主题。
本书导航
本书旨在从前几章的基础信息开始构建,涵盖使用 Apache Spark 和 PySpark 进行机器学习工作流程以及使用 MLflow 管理机器学习实验生命周期,最后进入到第 7、8 和 9 章,介绍专门的机器学习平台。本书以部署模式、推断和生产环境中的模型监控结束。以下是每章内容的详细介绍:
第一章,“分布式机器学习术语和概念”
本章介绍了机器学习的高级概述,并涵盖了与分布式计算和网络拓扑相关的术语和概念。我将带你深入各种概念和术语,为后续章节打下坚实的基础。
第二章,“Spark 和 PySpark 简介”
本章的目标是让您快速掌握 Spark 及其 Python 库 PySpark。我们将讨论术语、软件抽象及更多内容。
第三章,“使用 MLflow 管理机器学习实验生命周期”
本章介绍了 MLflow,这是一个管理机器学习生命周期的平台。我们将讨论什么是机器学习实验,以及为什么管理其生命周期如此重要,还将审视 MLflow 的各种组件,使这一切成为可能。
第四章,“数据摄取、预处理和描述性统计”
接下来,我们将深入研究数据处理。在这一章中,我将讨论如何使用 Spark 摄取您的数据,执行基本预处理(以图像文件为例),并对数据有所了解。我还将介绍如何通过利用 PySpark API 来避免所谓的小文件问题。
第五章,“特征工程”
在完成前一章的步骤后,您将准备好为训练机器学习模型使用的特征进行工程化。本章详细解释了特征工程是什么,涵盖了各种类型,并展示了如何利用 Spark 的功能提取特征。我们还将探讨何时以及如何使用applyInPandas和pandas_udf来优化性能。
第六章,“使用 Spark MLlib 训练模型”
本章将带您了解如何使用 MLlib 训练模型,评估和构建管道以复现模型,并最终将其持久化到磁盘。
第七章,“连接 Spark 与深度学习框架”
本章详细讲解如何构建一个数据系统,将 Spark 的强大能力与深度学习框架结合起来。讨论了连接 Spark 和深度学习集群,并介绍了 Petastorm、Horovod 以及 Spark 计划中的 Project Hydrogen。
第八章,“TensorFlow 分布式机器学习方法”
在这里,我将带您逐步示例使用分布式 TensorFlow——特别是tf.keras——同时利用您在 Spark 中完成的预处理工作。您还将了解有关扩展机器学习的各种 TensorFlow 模式和支持其的组件架构。
第九章,“PyTorch 分布式机器学习方法”
本章涵盖了 PyTorch 的扩展机器学习方法,包括其内部架构。我们将逐步演示如何使用分布式 PyTorch,同时利用您在前几章中与 Spark 完成的预处理工作。
第十章,“机器学习模型部署模式”
在本章中,我介绍了我们可以使用的各种部署模式,包括使用 Spark 和 MLflow 进行批处理和流式推理,并提供了在 MLflow 中使用pyfunc功能的示例,该功能允许我们部署几乎任何机器学习模型。本章还涵盖了监控和分阶段实施生产机器学习系统。
未涵盖的内容
有许多方法可以进行分布式机器学习。一些方法涉及并行运行多个实验,使用多个超参数,在已加载到内存中的数据上。您可能能够将数据集加载到单台机器的内存中,或者数据集可能太大,必须分区到多台机器上。我们将简要讨论网格搜索,一种用于查找一组超参数最优值的技术,但本书仅限于此。
本书不涵盖以下话题:
机器学习算法简介
有许多精彩的书籍深入探讨了各种机器学习算法及其用途,本书不会重复它们。
将模型部署到移动设备或嵌入式设备
这通常需要使用 TinyML 和专用算法来缩小最终模型的大小(最初可能是从大型数据集创建的)。
TinyML
TinyML 专注于构建相对较小的机器学习模型,这些模型可以在资源受限的设备上运行。要了解更多,请查看TinyML,作者是彼得·沃登和丹尼尔·西图纳亚克(O'Reilly)。
在线学习
当数据随时间变化或机器学习算法需要动态适应数据中的新模式时,使用在线学习。在整个数据集上进行训练是计算上不可行的时候,需要使用外存算法。这是一种用于专业应用的机器学习基本不同的方法,本书未涵盖此内容。
并行实验
尽管本书讨论的工具,如 PyTorch 和 TensorFlow,使我们能够进行并行实验,本书将专注于并行数据训练,其中逻辑保持不变,每台机器处理不同的数据块。
这不是一个详尽的列表——因为所有的途径都以某种方式导向分布式,我可能忘记在这里提及一些话题,或者自写作以来行业中新的话题可能已经开始受到关注。如前所述,我的目标是分享我的观点,基于我在机器学习领域积累的经验和知识,为其他人提供一种全面的方法来应用于他们自己的努力中;我的意图是尽可能涵盖尽可能多的关键点,为提供一个基础,并鼓励您进一步探索,以加深对这些讨论话题的理解。
环境和工具
现在你已经了解了将要(和不会)涵盖的主题,接下来是设置你的教程环境的时间了。你将使用各种平台和库来开发一个机器学习管道,同时完成本书中的练习。
工具
本节简要介绍了我们将用来构建本书中讨论的解决方案的工具。如果你对这些工具不熟悉,可能需要在开始之前查看它们的文档。为了在你自己的机器上实现书中的代码示例,你需要本地安装以下工具:
Apache Spark
一个通用的大规模数据处理分析引擎。
PySpark
Apache Spark 的 Python 接口。
PyTorch
一个由 Facebook 开发的机器学习框架,基于 Torch 库,用于计算机视觉和自然语言处理应用。我们将利用它的分布式训练能力。
TensorFlow
由 Google 开发的机器学习管道平台。我们将利用它的分布式训练能力。
MLflow
一个开源平台,用于管理机器学习生命周期。我们将用它来管理本书中的实验。
Petastorm
一个支持使用 Apache Parquet 格式数据集进行深度学习模型分布式训练和评估的库。Petastorm 支持 TensorFlow 和 PyTorch 等机器学习框架。我们将用它来在 Spark 和深度学习集群之间架起桥梁。
Horovod
一个用于 TensorFlow、Keras、PyTorch 和 Apache MXNet 的分布式训练框架。该项目旨在支持开发者将单 GPU 训练脚本扩展到多个 GPU 并行训练。我们将用它来优化多个 GPU 上的工作负载,并协调 Spark 集群与深度学习集群的分布式系统,这需要一个专用的分布式系统调度器来管理集群资源,并使它们通过相同的硬件协同工作。
NumPy
一个用于科学计算的 Python 库,可以高效地执行各种数组操作(数学、逻辑、形状操作、排序、选择、I/O 等)。我们将用它进行各种可以在单台机器上完成的统计和数学运算。
PIL
Python Imaging Library,也称为 Pillow。我们将使用它来处理图像。
在当今的生态系统中,机器学习和分布式数据领域的新工具每天都在涌现。历史告诉我们,其中一些工具会持续存在,而另一些则不会。关注一下你工作场所中已经使用的工具,并尽可能挖掘它们的能力,然后再考虑引入新的工具。
数据集
在本书的示例中,我们将在实际中利用现有的数据集,并在必要时生成专用的数据集以更好地传达信息。这里列出的数据集,全部可在 Kaggle 上获取,并在附带的 GitHub 存储库 中包含:
Caltech 256 数据集
Caltech 256 是 Caltech 101 数据集 的扩展,包含了 30,607 张属于 257 个类别的对象图片。这些类别极为多样,从网球鞋到斑马,有背景和无背景的图像,水平和垂直方向的图像。大多数类别约有 100 张图片,但有些类别多达 800 张。
CO[2] Emission by Vehicles 数据集
CO[2] Emission by Vehicles 数据集 基于加拿大政府开放数据网站七年的车辆 CO[2] 排放数据。数据集包含 7,385 行和 12 列(制造商、型号、变速器等,以及 CO[2] 排放和各种燃油消耗措施)。
Zoo Animal Classification 数据集
为了学习 MLlib 库中可用的统计函数,我们将使用 Zoo Animal Classification 数据集。它包含 101 种动物,有 16 个布尔值属性用于描述它们。这些动物可以分为七类:哺乳动物、鸟类、爬行动物、鱼类、两栖动物、昆虫和无脊椎动物。我选择它是因为它有趣且相对简单易懂。
如果你正在本地计算机上完成教程,请使用书中 GitHub 存储库中提供的示例数据集。
本书使用的约定
以下是本书使用的排版约定:
Italic
表示新术语、URL、文件和目录名称以及文件扩展名。
Constant width
用于命令行输入/输出和代码示例,以及出现在文本中的代码元素,包括变量和函数名称、类和模块。
*Constant width italic*
显示要在代码示例和命令中用用户提供的值替换的文本。
**Constant width bold**
显示用户应按原样键入的命令或其他文本。
提示
此元素表示提示或建议。
注意
此元素表示一般说明。
警告
此元素表示警告或注意事项。
使用代码示例
附加资料(代码示例、练习等)可在 https://oreil.ly/smls-git 下载。
这本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则不需要联系我们请求许可。例如,编写使用本书中多个代码块的程序不需要许可。销售或分发包含 O’Reilly 书籍示例的 CD-ROM 需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码整合到您产品的文档中需要许可。
我们感谢但不要求署名。通常的署名包括书名、作者、出版商和 ISBN。例如:“使用 Spark 扩展机器学习,作者 Adi Polak。2023 年版权归 Adi Polak 所有,ISBN 978-1-098-10682-9。”
如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时联系我们:permissions@oreilly.com。
致谢
这本书要感谢 Spark、数据工程和机器学习社区的支持,没有你们的帮助,这本技术书籍是无法问世的。真的,要让一本技术书籍成功出版,确实需要一个村庄的力量,因此非常感谢你们的帮助!
感谢所有早期读者和审阅者的帮助和建议:Holden Karau,Amitai Stern,Andy Petrella,Joe Reis,Laura Uzcátegui,Noah Gift,Kyle Gallatin,Parviz Deyhim,Sean Owen,Chitra Agastya,Kyle Hamilton,Terry McCann,Joseph Kambourakis,Marc Ramirez Invernon,Bartosz Konieczny,Beegee Alop 等许多其他人。
任何剩下的错误都是作者的责任,有时违背审阅者的建议。
最后,我要感谢我的生活伴侣,包容了我长时间的夜晚写作,早起,假期和周末。
第一章:分布式机器学习术语和概念
记得以前数据科学家在笔记本电脑内存中运行他们的机器学习算法吗?或者生成他们自己的数据?这并不是因为世界上缺乏数据;我们已经进入了赛博字节时代。¹ 对于许多人来说,数据是存在的,但它被锁在生产系统中,这些系统以大规模创建、捕获、复制和处理数据。数据科学家知道,获得这些数据将使他们能够产生更好、更深刻的机器学习模型。但这不是唯一的问题——计算呢?在许多情况下,数据科学家没有足够的计算能力或工具来支持在大数据集上运行机器学习算法。因此,他们不得不对数据进行抽样,并使用 CSV 或文本文件进行工作。
当公共云革命在 2016 年至 2017 年左右爆发时,我们终于能够获得所需的计算能力。我们只需要一张信用卡和一只鼠标。按下一个按钮,哇哦,数百台机器都可以使用了!但是,我们仍然缺乏适当的开源工具来处理大量数据。需要分布式计算和具有健全生态系统的自动化工具。
数字化增长,即企业利用数字技术改变其业务模式并创造新的收入流和价值产生机会,增加了数据科学家的挫折感。数字化导致更多的数据可用,但由于缺乏工具,数据科学家无法快速处理这些数据。长时间等待尝试一个机器学习算法或获取生产数据样本的繁琐过程阻碍了许多人实现其全部潜力。改进和自动化的需求日益增加。
小公司看到更大公司通过为客户提供自动化、个性化解决方案对其业务产生了积极影响,改善了情感并增加了收入。从幻想到现实,机器学习成为了一种热门商品。公司们意识到,要利用它,他们需要更多的工具,并且需要内部团队来构建这些工具,这反过来增加了对工程师的需求,以构建可靠、可扩展、加速和高性能的工具来支持机器学习工作负载。
Netflix,全球领先的互联网电视网络,每天流传数亿小时的内容,表示它在业务的各个方面广泛使用机器学习。这包括为客户推荐个性化内容,在 Netflix 工作室优化电影和节目制作过程,优化视频和音频编码,以及改进广告支出和广告创意,以触及新的潜在客户。
机器学习不仅在技术型企业中找到了应用,而且在广泛的行业中都有应用。Shell plc 跨国石油和天然气公司的数据科学家和分析团队利用大规模数据集上的机器学习来支持业务,提供关于产品机会和流程优化的见解,并测试不同行动方案的有效性。其中一个例子是他们的库存预测模型,该模型在所有零件和设施上运行了超过 10,000 次模拟,预测需求并改善库存。Shell 还利用机器学习为其客户忠诚计划 Go+提供推荐引擎,为个体客户提供个性化的优惠和奖励。这种方法为 Shell 提供了一个增强的参与模型,通过满足客户的特定需求来帮助保留客户。
其他行业利用机器学习进行欺诈检测、推荐系统、患者诊断等。查看图 1-1 可以了解如何在您的行业中利用机器学习推动创新。
正如这些例子所示,利用大数据集来创建具有实证业务影响力的解决方案,对许多寻求业务增长和提高收入的公司来说是一种启发。

图 1-1. 机器学习在各行业中的众多应用示例
计算机科学和工程研究社区在实现可扩展机器学习方面发挥了重要作用。近年来,学术研究人员已经进行了数百甚至数千项关于使用机器学习、分布式计算和数据库以及构建更智能、更高效算法的研究。因此,通用的分布式平台应运而生,比如极其流行的 Apache Spark。Apache Spark 提供了一个可扩展的通用引擎,用于分析和机器学习工作负载。与此同时,为了在分布式环境中执行工作负载,各种支持单机器工作负载的机器学习库团队不断增加了后端支持能力。举几个例子,Google 的 TensorFlow 已经增加了支持分布式机器学习的额外功能,用于简化深度神经网络工作负载,而 Facebook 的 PyTorch 用于计算机视觉和自然语言处理,也在不断增强其支持分布式机器学习的后端支持能力。
在本书的整个过程中,我们将专注于使用 Apache Spark,并向您展示如何通过它进入基于 TensorFlow 和 PyTorch 的分布式机器学习。本书最后讨论了机器学习部署模式在第十章。为了让您快速入门,本章介绍了分布式机器学习的基本概念、术语和构建模块。我们将涵盖以下基础知识:
-
机器学习工作流程
-
Spark MLlib
-
分布式计算
-
分布式系统
对这些概念熟悉了吗?我们将在第二章中介绍 Spark 和 PySpark,以及在第三章中管理机器学习生命周期。
兴奋了吗?让我们开始吧!
机器学习工作流程的阶段
如今许多应用程序是由机器学习驱动的,使用机器学习模型来回答诸如:如何让我的应用程序自动适应客户的需求?如何自动化这个繁琐的流程,让我的员工能够更有效地利用他们的时间?如何在不花费整年时间的情况下理解我的数据堆?然而,作为数据从业者,我们只需回答一个问题:如何使整个过程能够回答这些问题?
简短的答案是机器学习。更全面的回答是机器学习工作流程。
机器学习工作流程包括一系列阶段,帮助我们实现将机器学习模型投入生产并解决业务问题的目标。什么是机器学习模型?好问题!机器学习模型是机器学习算法的输出。从现在开始,我们将简称其为模型。这个工作流程的自动化被称为机器学习管道。为了提高模型的准确性,工作流程是迭代的。这使得我们能够对模型——包括自动化、监控和部署——进行全面控制。
机器学习工作流程包括多个阶段,其中一些可以跳过,而另一些可能需要重复执行:
-
收集和加载/摄取数据。 第一个阶段是收集所需的数据,并将其加载到执行机器学习实验的环境中。
-
探索和验证数据。 接下来,探索您收集的数据并评估其质量。这个阶段通常涉及统计测试,测试训练数据如何代表真实世界事件,以及数据集的分布和多样性。这也被称为探索性数据分析(EDA)。
-
清理/预处理数据。在第 2 阶段之后,您可能会得出结论,数据存在噪音。噪声数据集是指不对训练有任何贡献的列,例如具有空值或长字符串值的行。它们需要更多的处理能力,但并不提高模型的准确性。在这个阶段,数据科学家将对数据进行统计测试,验证特征之间的相关性,并分析哪些特征原样提供价值,哪些需要更多预处理或工程处理,以及哪些是多余的。
-
提取特征/执行特征工程。前一阶段将数据列作为特征输出。这些是数据的描述符,作为机器学习模型的输入使用。机器学习中的特征通常是原始数据之外的内容,意味着我们需要用来自其他来源的数据丰富现有数据。这要求我们开发代码来计算和生成这些特征,并在训练模型之前用它们丰富数据集。有许多方法可以做到这一点,通常需要领域专业知识。或者,这些特征可能已经存在于另一个数据集中,这种情况下,我们只需将两个数据集合并成一个,然后再训练模型。
-
将数据分成训练集和验证集。训练集用于训练机器学习模型,验证集用于评估模型在未见数据上的表现。
-
训练和调整模型。将训练数据提供给机器学习算法,并调整参数以提高性能。使用专门的验证数据集验证结果。验证过程在开发环境中进行,可以是在本地机器上或云中的开发/实验环境中。这一阶段的结果是模型。
-
使用测试数据评估模型。这是模型推向生产之前的最后测试阶段。在这个阶段,再次测量模型在之前未见数据上的表现,在生产环境中进行测试。在这一阶段之后,您可能需要返回并重新审视第 6 阶段。
-
部署模型。在这个阶段,数据科学家与机器学习和生产工程师一起打包模型,并将其部署到生产环境中,并满足其所有要求。
-
监控模型。在生产中,必须持续监控模型是否存在漂移(关于不同类型漂移的讨论详见第十章)。持续评估模型对业务的价值,并知道何时替换模型至关重要。
每个阶段都可以单独重复,并且可能会根据特定结果需要完成整个过程。例如,在模型漂移的情况下,数据和模型不再代表业务问题,您需要从头开始执行该流程。
每个阶段都是独特的,并且高度依赖于数据、系统需求、您的知识、使用的算法、现有基础设施和期望的结果。
阶段 3 到 6 通常被视为机器学习的实验阶段。您将希望反复迭代并生成数据和模型的多个版本,直到找到最佳版本的模型。
要了解更多关于使用 TensorFlow 和 TensorBoard 构建机器学习流水线并自动化它们的信息,请阅读《Building Machine Learning Pipelines》(由 Hannes Hapke 和 Catherine Nelson 撰写,O’Reilly 出版)。
机器学习流水线中的工具和技术
图 1-2 显示了机器学习流水线的概览以及可能在每个阶段使用的一些工具。

图 1-2. 机器学习流水线的高层视图及每个阶段使用的工具
在本书的教程中,我们将使用各种工具和平台来完成不同阶段的任务(当然,您可以选择用您选择的其他工具替换其中任何工具)。为了实验数据摄取、预处理和特征工程,我们将使用提供 UI 和后端服务器的 Jupyter。我们将在 UI 中的笔记本中编写代码,后端将其打包并发送到 Spark 引擎。
在模型构建阶段(对应于先前描述的机器学习工作流程中的第 6 和第 7 阶段),我们会训练、验证和调整模型。在此阶段,我们将使用多个服务器和后端,包括 Jupyter、PyTorch、TensorFlow、Horovod、Petastorm 和 MLflow,来编排操作、缓存数据,并将工作流从 Spark 集群转换为深度学习集群。
最后,为了部署和提供我们的模型,我们将使用 Spark 和 MLflow。我们将从 MLflow 存储服务器加载模型,并使用 Spark 或作为 Python 函数的 REST API 提供服务。
注意
在大多数组织中,开发端到端的机器学习流水线需要一个专门的团队,团队成员具有各种技能集,以及一个集成开发环境(IDE)如 PyCharm,提供丰富的开发者工具和代码自动完成,专门用于持续集成/持续部署(CI/CD)的脚本等。出于本书的教育目的,我们将坚持使用 Jupyter 笔记本。
分布式计算模型
分布式计算 是使用分布式系统解决计算问题的技术,多台机器共同作为一个单一单位工作。在这样的系统中运行的程序称为分布式程序,编写这样的程序的过程称为分布式编程。这正是我们在本书中要做的事情。我们的目标是找到将问题划分为多个任务的最佳方法,这些任务可以通过消息通信并行解决。机器学习的不同分布式计算模型可以分为两类:可以调整以支持分布式机器学习应用的通用模型,以及专门设计用于运行机器学习算法的专用计算模型。
通用模型
通用分布式计算模型允许用户使用定义的抽象编写自定义数据处理流。Apache Spark 是一种通用分布式计算引擎,其核心实现了 MapReduce 编程模型,并最近扩展支持屏障模型。在本节中,您将了解这两种模型,以及一些其他分布式计算模型(MPI [消息传递接口] 和共享内存),这些模型在 TensorFlow 和 PyTorch 中都有提供。
MapReduce
MapReduce 编程模型的灵感来自函数式编程范式。Google 在 2004 年引入了 MapReduce 算法,该算法在研究论文中讨论了其搜索引擎如何处理大规模数据。作为开发人员或数据科学从业者,我们指定一个映射函数来处理键/值对,生成一组中间键/值对,以及一个减少函数来合并与同一中间键相关联的所有中间值。这种方法是数据分析中分割-应用-合并策略的扩展。在实践中,每个任务都被分成多个映射和减少函数。数据被分区和分布在各个节点/机器上,每个数据块在专用节点上进行处理。许多解决方案旨在尽可能保持数据的本地性,其中分区数据对于处理它的节点是本地的。在该节点上应用逻辑函数,然后执行网络上移动数据的洗牌操作,将来自不同节点的数据组合在一起,并对来自映射器的组合输出执行减少操作。
如果需要,可以对减少器的输出进行另一个分割-应用-合并循环。实现这些概念的开源解决方案的示例包括 Apache Spark、Hadoop MapReduce 和 Apache Flink。我们将在整本书中更详细地讨论 MapReduce 模型。
MPI
另一个有趣的通用分布式计算模型是消息传递接口(Message Passing Interface,MPI)编程模型。这是目前最灵活的模型,旨在实现高性能、可伸缩和便携的分布式计算。MPI 是一种消息传递接口,用于在分布式内存系统上模拟并行程序的运行。它通过定义可在处理器之间发送的数据类型来标准化处理器集之间的通信。每个处理器都有唯一的标识符,而每个通信器是按特定拓扑顺序排序的一组处理器。MPI 是一个低级标准,可以在硬件、编译器、包装器等中实现。HP、Intel、Microsoft 等公司都已经商业化实现了不同版本的 MPI。
MPI 提供了像 MPI_Bcast(广播消息到通信器中的所有处理器,共享数据)、MPI_Alltoall(将所有数据发送到所有节点)、MPI_Reduce 和 MPI_Allreduce(类似于 MapReduce 和 Apache Spark 的 reduce 函数)等函数。您可以将这个接口视为分布式框架的构建块,提供了分布式机器学习功能。
MPI 的缺点在于其低级别的宽容性。使用 MPI 执行和实现复杂操作可能非常费力且容易出错;它要求显式管理数据类型分布、发送和接收功能以及容错,通常要求开发者考虑分布式数组、数据框架、哈希表、树等。MPI 经常用于深度学习工作负载。Horovod 的核心功能基于 MPI 概念,如 size、rank、local_rank、allreduce、allgather 和 broadcast。对于分布式计算,TensorFlow 提供了 MPI 的支持作为其通信协议的一部分。
屏障
屏障(barrier)是一种在并行计算中常用的同步方法,被实现在诸如 Apache Spark 等分布式计算框架中。任务或作业被分割成依赖阶段或子任务,在处理可以继续到下一个阶段之前需要完成这些阶段或子任务。屏障使得一组机器在某一点停止,并等待其他机器完成计算,然后它们可以共同前进到计算逻辑的下一个阶段。屏障模型可以在硬件和软件中实现,阶段可以采用许多形状,从有向无环图(DAG)到树形结构或顺序操作。
虽然它是一个通用的分布式计算模型,但屏障模型使得各种分布式机器学习算法得以实现。例如,在深度学习中,堆叠的人工神经网络中的每一层都是一个阶段,每个阶段的计算依赖于前一阶段的输出。屏障模型在这种情况下可以管理许多层次的训练。
共享内存
共享内存模型有着悠久的历史:它们起源于像 POSIX 和 Windows 这样的操作系统,这些操作系统中运行在同一台机器上的进程需要通过共享地址空间进行通信。分布式共享内存模型试图在分布式环境中满足同样的需求,当多个节点/用户在网络上通信并需要从各种机器上访问相同的数据时。今天,并不存在一个分区全局地址空间,而是提供强一致性的内存或快速数据库。
在分布式共享内存环境中,强一致性意味着所有进程和节点对数据的访问都是一致的。确保这一点并不容易。TensorFlow 的一个分布式策略实现了一个共享内存模型;你将在第八章中详细了解它及其优缺点。
专用分布式计算模型
专用的分布式计算模型是为支持机器学习开发周期中的特定需求而开发的模型。通常,它们利用通用模型作为构建块,以构建更易于使用的框架,数据科学从业者可以直接使用。您可以在 TensorFlow 和 PyTorch 中使用它们。
一个专用于机器学习工作负载的分布式计算模型示例是参数服务器。TensorFlow 作为其模型训练分布策略的一部分实现了这一点。参数服务器利用了共享内存的方法:您有一组专用的服务器来保证为工作节点提供一致的数据信息。这些参数是机器学习算法在其训练和重新训练生命周期中所需的权重和预计算特征。在某些情况下,这些参数可以适应一台机器的内存,但在实际应用场景中,当存在数十亿个参数时,需要一组参数服务器的集群。在我们讨论不同的 TensorFlow 分布式计算策略时,我们将详细讨论这一点,这将在第八章中进行。
随着研究和行业在分布式机器学习上的大量投资,更多的模型开发只是时间问题。随时关注新的发展始终是一个良好的实践。希望通过本书的结尾,您将拥有所有必要的工具和信息,以便对使用哪种分布式计算模型以及如何利用它来满足您企业的技术需求做出明智的决策。
现在您已经熟悉了这些概念,让我们来看看分布式机器学习架构的整体情况以及这些概念如何发挥作用。
分布式系统架构简介
我们将从简要讨论网络拓扑开始。拓扑是我们组织计算机形成分布式系统的方式。我们可以将其分为两种类型:物理拓扑描述了计算机如何排列和连接,而逻辑拓扑描述了数据在系统中的流动方式以及计算机如何通过网络交换信息。多节点计算机拓扑通常通过增加更多计算机(也称为节点)来物理扩展。
工程师们通常在最终架构讨论中讨论拓扑。架构需求源于项目的目标、数据、系统的行为以及正在使用的现有软件工具。对于数据科学家来说,主要目标是定义分布式模型训练方法以及模型将如何部署和提供服务。
注意
在某些情况下,您可能会发现现有软件工具未能很好地满足项目需求,或者过于复杂而无法整合到解决方案中,因此您需要寻找或开发新的工具。这是一个我们在本书中不会探讨的高级场景。
形成分布式系统拓扑的节点通过网络连接在一起,这种特定的架构模式旨在提高负载处理能力并优化速度和资源使用。在设计阶段做出的架构选择将影响每个节点在拓扑中的角色,它们如何通信以及整个系统对故障的韧性。
除了理解分布式系统的物理拓扑外,您还应了解集中式和分散式系统之间的区别,计算机如何交互,支持的通信模式以及系统如何处理安全性和故障。在设计系统时,您可以将这些视为构建块。
集中式与分散式系统
在集中式系统中,所有节点依赖于单一节点做出决策。这样的系统在控制决策方面具有更大的优势,但由于决策节点成为单点故障,系统更容易遭受故障而导致整个系统崩溃。
在分散式系统拓扑中,节点是独立的并且自行做出决策。每个节点存储和操作自己的数据,因此没有单一的故障点。这意味着系统更能容忍故障;然而,这也意味着需要协调和调解各个节点做出的决策。
去中心化系统可以受益于多云/混合云架构,其中机器节点位于不同地区并与不同的云提供商合作。一个例子是连接的物联网(IoT)设备网络:每个设备是独立的,但根据其互联网连接,在网络上与其他设备和/或云共享数据。您选择的拓扑结构将影响设备可以使用的通信方法以及它们在网络中可能扮演的角色。当涉及训练模型时,去中心化方法意味着每个模型都在进行自己的训练。在本章后面,当我们研究整合方法时,我们将更多地讨论这种设计决策的影响。
交互模型
交互模型的架构定义了系统中的节点如何通过网络通信、他们在系统中的角色以及这些角色所带来的责任。在本节中,我们将介绍三种可能的架构:客户端/服务器、点对点和地理分布式。这里没有涉及的其他正在使用和开发中的架构,但这些是您最有可能遇到的。
客户端/服务器
在客户端/服务器交互模型中,责任有明确定义。任务在客户端之间分配,客户端发出请求,服务器提供对这些请求的响应。一个节点的角色可以根据系统的结构和需求而改变,但这取决于服务器是无状态的(不存储状态)还是有状态的(存储下一个操作所依赖的状态)。
点对点
在点对点(P2P)交互模型中,工作负载在节点或对等体之间分割。所有节点具有相同的特权,并且可以直接共享信息,而无需依赖于专用的中央服务器。每个对等节点既可以是客户端也可以是服务器。这种拓扑更加宽容且更便宜实现,因为不需要将机器绑定到特定的责任。但是,它也有一些缺点:每个节点都需要具有数据的完整副本,并且因为所有数据都是通过网络交换而没有专用协调员,多个副本可能到达同一节点。
地理分布式
地理分布式交互模型最常见于地理分布式云数据中心。它旨在解决与数据隐私和资源分配等问题相关的挑战。一个问题是,地理分布模型中点对点通信的延迟可能会很高,这取决于节点之间的距离。因此,在基于此交互模型开发分布式机器学习工作负载时,我们需要清晰定义节点如何通信以及在哪些情况下。地理分布式交互模型是一个良好选择的示例是启用物联网/边缘设备的联邦学习,其中数据无法集中在一个数据中心。开发一个系统,在多个分布式节点上对每个设备训练模型并组装输出以创建一个统一的模型,使我们能够从所有设备的数据洞察中受益,而不用交换私密信息。
分布设置中的通信
我们的节点在分布式环境中的通信方式对故障机制、安全性和吞吐量有重要影响。通信可以是同步的或异步的,取决于分布式计算模型的需求。例如,参数服务器(本章前面提到的一种专用机器学习计算模型)可以采用异步或同步通信实现,而 TensorFlow 支持使用数据并行进行同步和异步训练。
在多台机器上分发机器学习工作负载需要对数据和/或程序本身进行分区,以便将工作负载均匀地分布在所有机器上。决定使用机器之间的异步或同步通信方式会影响计算时间,并可能导致瓶颈。例如,通过网络洗牌数据可以提高准确性并帮助减少过拟合。然而,洗牌通常涉及将数据写入本地磁盘后再发送到网络,这会导致更多的输入/输出(I/O)操作,增加总体计算时间并在本地磁盘上造成瓶颈,以及大量的通信开销。对于这样的任务,您需要仔细考虑采取的通信方法。
异步
异步通信的基本机制是队列。对特定节点的请求被放置在队列中执行,并最终可能返回结果或者不返回。在信息交换不依赖于时间的系统中,这种机制非常有用,因为无需立即接收响应。你可以将其想象成发送短信给朋友询问下周六晚餐计划,知道你可能最终会收到回复,但你并不急需它。异步通信允许在分布式系统中传递消息流,而不会阻塞任何等待回复的进程;通常情况下,尽可能使用它是更好的选择。
同步
需要同步通信的要求源于计算机科学的函数堆栈,其中函数必须按特定顺序执行 — 意味着如果一个节点发送请求给另一个节点,在等待响应期间无法继续处理后续函数逻辑。在特定的分布式机器学习案例中,可以使用同步通信,并在必要时利用专用硬件如特殊网络电缆(一些云供应商允许您配置网络带宽²)。假设你想和朋友今晚安排晚餐计划。你的行动将取决于朋友的食物偏好和可用性,以及你选择的餐厅的可用性。你知道如果选择一家热门餐厅并且现在不预订桌子,那么可能就没有位置了。你会怎么做?你不会发短信,而是打电话给朋友,同步收集信息;你们两个现在都在电话中阻塞。你获取必要的信息并继续到下一个阶段,即联系餐厅。
现在您对分布式机器学习拓扑结构的一些主要架构考虑有了一些了解,让我们来看看近年来在机器学习应用中日益流行的一种技术:集成学习。
集成方法介绍
集成机器学习方法利用多个机器学习算法生成一个性能更好、偏差和方差较少的单一模型。集成方法通常围绕监督学习设计,并要求在预测时有明确的模型聚合定义。我们将从探讨这些方法为何有用开始,然后看看机器学习中主要类型的集成方法及一些具体示例。
高偏差与低偏差
偏差是机器学习中的一个主要问题,减少偏差是机器学习工程师的主要目标之一。高偏差的模型对结果做出太多假设,导致过拟合训练数据。这样的模型往往难以对新数据进行准确预测,因为新数据可能与已经观察到的数据不完全一致,在测试数据和生产环境中表现不佳。相反,低偏差的模型对数据做出的假设较少。如果过度,这也可能是一个问题,因为它可能导致欠拟合,即模型未能充分学习数据以进行准确分类。高偏差的模型往往具有低方差,反之亦然。方差可以被看作是机器学习算法处理数据波动能力的指标。³
常见的情况是,偏差也可能来自机器学习算法本身。例如,线性回归是一种简单的算法,学习速度快,但往往存在较高的偏差,特别是在用于模拟两个变量之间没有真实的线性(或接近线性)相关性的关系时。所有这些都取决于特征之间的潜在关系。
集成方法的类型
在许多情况下,集成方法的准确性比单一模型更高;通过结合所有组件模型的个体预测,它们能够产生更健壮的结果。在集成中,每个模型被称为一个学习器。我们根据期望的目标定义学习器之间的关系。
当我们想要减少方差时,通常通过依次训练学习器来建立它们之间的依赖关系。例如,我们可以一次训练一棵决策树,每棵新树都根据系列中先前树的错误进行训练修正。这种构建多个学习器以减少先前学习器错误的策略被称为提升。集成模型通过加权投票、计算多数投票或计算作为预测或分类的总体总和来进行最终预测。一个例子是在 Spark MLlib 中实现的梯度提升树分类器(GBTClassifier)。这是一种集成技术,通过确定性平均过程迭代地组合决策树;该算法的目标是在训练中最小化信息损失/错误(更多信息请参阅第五章)。然而,需要注意的是,树的迭代组合有时可能导致过拟合。
为了避免过拟合,我们可能更喜欢并行独立训练学习者,并使用装袋或叠加来组合它们的预测。使用装袋技术(即自助聚合),我们在数据集的不同部分上训练每个学习者(通常都使用相同的机器学习算法),目的是减少方差和过拟合,提高对之前未见数据的预测准确性。这种集成方法的结果是一个组合模型,其中每个学习者都独立作出预测,算法收集所有投票并产生最终预测。一个例子是在 Spark MLlib 中实现的RandomForestClassifier(随机森林是一种结合独立决策树的集成技术)。
叠加类似于装袋,它涉及构建一组独立的学习者,并使用一个集成函数来组合它们的预测,该函数将所有学习者的输出减少为单一分数。然而,叠加中的学习者通常是不同类型的,而不是都使用相同的学习算法,这意味着它们做出不同的假设,更不可能出现相同类型的错误。您可以使用任何类型的机器学习模型作为组合器来聚合预测。通常使用线性模型,但也可以是非线性的,将学习者的分数与给定数据一起输入,例如基础学习者是决策树的神经网络。这种方法更加先进,可以帮助揭示变量之间更深的关系。
小贴士
集成方法在学习者使用相同基础学习算法时被称为同质。而学习者使用不同基础学习算法时,则称为异质集成。提升(Boosting)和装袋(Bagging)被认为是同质集成方法,而叠加(Stacking)是一种异质方法。
分布式训练拓扑结构
您可以利用集群拓扑结构来改善集成模型的训练和服务。让我们看几个例子。
集中式集成学习
集中式系统通常使用客户端/服务器架构,其中客户端节点直接与集中式服务器节点通信。这类似于计算机网络中的星型拓扑。在分布式机器学习部署方法中,这意味着来自分布式模型的所有预测、分类等请求都通过主服务器进行。
无论是否有一个或多个充当最终决策者的服务器节点,服务器级别的聚合都有严格的层次逻辑,在集中的位置进行。这种拓扑结构专门用于分布式模型工作负载,并非通用。例如,考虑随机森林集成学习方法。RandomForest是一种装袋算法,可以根据数据的性质用于分类或回归,并旨在减少过拟合,如前所述。随机森林包含一组决策树。当你决定将RandomForest作为你的算法时,程序将作为客户端与主服务器节点交互。这些服务器节点将查询发送到树节点,收集来自树的答案(模型的输出),根据集成逻辑聚合输出,并将答案返回给客户端。集成中的各个树可以在完全不同或重叠的数据集上训练。
分散的决策树
决策树也可以在分散的拓扑结构中部署。当你希望在边缘设备上提供答案,并受到数据隐私、互联网带宽和响应时间严格要求的限制时,可以采用这种方法。分散的决策树对边缘人工智能很有用,其中算法在设备上本地处理并提供模型服务。每个节点不需要永久连接到网络,但可以利用网络提高预测的准确性并避免过度拟合。在这种情况下,当边缘节点收到一个请求进行预测时,它将查询发送给其父节点和子节点,这些节点又将查询发送给它们的父节点和子节点,每个节点计算并广播其响应。每个节点都有自己的聚合函数,并根据其响应是否在指定时间限制内可用来决定是否使用它。为了将通信开销降到最低,可以限制“跳数”,定义查询可以传播的最远距离。这个约束强制执行一个节点邻域。节点的邻域可以根据网络和边缘设备的可用性而变化。
使用参数服务器进行集中式、分布式训练
在集中式分布式训练拓扑结构中,整个工作负载在一个数据中心中处理。机器之间连接良好,并通过共享网络进行通信。数据集和训练工作负载分布在客户端节点中,服务器节点维护全局共享参数。服务器节点充当参数服务器,所有客户端节点共享对其访问并从中获取信息的权限—即全局共享内存。它们必须快速访问信息,并经常利用内存中的数据结构。可以利用这种拓扑结构的一类机器学习算法是深度学习算法。采用这种方法,参数在所有机器上广播和复制,并且每个客户端节点分别计算其主函数的一部分。变量在参数服务器上创建,并由客户端或工作节点在每个步骤中共享和更新。
第八章详细讨论了这一策略,代码示例说明了如何利用 TensorFlow 与这种拓扑结构。
集中式、分布式训练在点对点拓扑中
在点对点拓扑结构中,没有客户端和服务器角色。所有节点可以与其他节点通信,并且每个节点都有自己的参数副本。当节点的内存能容纳的参数数量固定时,逻辑本身不会改变,并且节点可以以点对点的方式共享其结果时,利用数据并行性非常有用。传话学习 是使用这种方法的一个例子。每个节点根据其可用的数据集计算其模型,并独立地调用网络上的同行以与它们分享其模型。然后每个节点将其当前模型与其邻居的模型结合起来。在去中心化部署环境中,就像决策树一样,应该限制这种拓扑结构的时间约束,并定义每个节点将广播信息到的最大边数。使用 P2P 拓扑结构时,您可能还希望指定诸如 MPI 之类的协议以标准化工作负载。
分布式机器学习系统的挑战
罗马并非一日建成,但每小时都在砌砖。
—约翰·海伍德
尽管您刚刚开始涉足分布式机器学习的旅程,但重要的是要意识到前方可能存在的一些挑战。与开发在单台机器上运行的机器学习工作负载不同,处理分布式机器学习工作负载是显著不同的,最终您有责任构建符合定义要求的系统。然而,经验丰富的从业者会告诉您所有要求都是可以协商的,可能失败的事情最终会失败。这两点都是真实的,您在权衡整个过程中应该记在心里。
性能
提升性能是实施分布式系统的基本目标。实现更高的吞吐量和更快的端到端机器学习计算对于分布式机器学习系统至关重要。根据您的目标、数据和系统行为,有许多方法可以提高性能。让我们看看您应该考虑的一些事项以及您可能会遇到的一些问题。
数据并行与模型并行
在计算机科学中,分布式计算通常与并行计算相辅相成。在单个节点/计算机上的并行计算意味着利用该单个节点的多个处理器同时执行各种任务。这也被称为任务并行处理。相比之下,在分布式计算的背景下,并行计算指的是使用多个节点来执行任务,每个节点都并行操作。在讨论分布式计算时,并行计算是一种基本的概念,不会被显式提及。
当涉及分布式机器学习时,最显著的困惑源之一是对节点间分布的确切理解不清楚。在机器学习工作流/生命周期中,您预处理数据,进行特征工程以提取相关特征,丰富数据,最终将其与一组超参数(用于控制学习过程的参数值,也称为机器学习算法的调优参数)一同输入到机器学习算法中。在学习过程中,输入的值/数据受超参数的影响,因此建议您尝试广泛的超参数范围,以确保找到在生产中使用的最佳模型。
处理大量数据和大量调优参数带来了如何有效管理资源和训练过程的问题。一般来说,有两种训练机器学习算法的方法。你可以在所有节点上复制相同的算法和相同的超参数,每台机器在自己的数据片段上运行相同的逻辑。相反,你也可以让每个节点运行算法的不同部分,但在同一组数据上运行。图 1-3 展示了这些方法的不同之处:数据并行将数据分割成碎片或分区,并将这些分区分布在节点之间,而模型并行则将模型本身分割成片段,并分布在多台机器上。

图 1-3. 数据并行和模型并行
使用数据并行化,每个节点运行相同的计算逻辑,这意味着代码也必须分布到所有节点。从一个节点到另一个节点,数据输入发生变化,但所有节点都运行相同的代码,如图 1-4 所示。

图 1-4. 数据并行化:相同的逻辑分布到所有机器上,每台机器使用本地数据运行该逻辑。
使用模型并行化,多个节点分别执行机器学习算法的不同部分,然后将分布式输出组装起来生成模型本身。这种方法适用于可以通过有向无环图表示并行化的算法,其中顶点代表计算,边代表数据流。
在提供现有模型时的一个挑战是,有时模型本身无法适应单台机器的内存,并且需要进行调整以实现最佳服务。想想随机森林集成方法。也许整个森林可以适应单台机器的内存,但如果森林有数十亿棵树呢?一种选项(需要专用工具和硬件)是将模型分割成子集,将每个子集放置在不同的机器上,并以定义良好的方式使机器有效地进行通信以提供模型。这种方法通常被深度学习框架(如 PyTorch 和 TensorFlow)采用。
结合数据并行化和模型并行化
结合数据并行化和模型并行化并不是一件简单的事,这是因为现有开源工具的性质以及构建利用这两者的专用系统的复杂性。然而,过去我们不得不在数据并行化工具(如 Apache Spark)和模型并行化工具(如 PyTorch)之间做选择,如今许多这些工具通过原生支持或通过 Petastorm、Horovod 等扩展支持彼此。
需要同时结合这两种并行方式会显著影响生成新模型、提供服务和开始用于预测所需的时间。例如,由 OpenAI 开发的 GPT-3(生成式预训练转换器 3)使用深度学习生成类似人类的文本。在其最大容量的 1750 亿个参数的情况下,估计使用 Tesla v100 GPU 训练这个模型需要 355 年,并且成本高达 460 万美元。对于大多数公司来说,即使参数远少于此,这都是一种昂贵且极其缓慢的选项。不仅如此,还需要多次尝试以找到能产生准确结果的合适超参数。我们不会在本书中进一步讨论这个 GPT-3,但重要的是知道它的存在。
深度学习
深度学习算法对分布式机器学习性能提出了特殊挑战。深度学习基于具有特征学习的人工神经网络(ANN),这意味着系统可以自动从原始数据中发现特征。训练深度学习模型需要进行前向计算和/或反向传播。前向计算是将数据前向馈送到神经网络(NN)中以计算结果。反向传播是将精度损失向后馈送到神经网络中,通过各层了解每个节点对该损失的贡献,并相应地更新神经网络层的权重。简单来说,可以将其视为将不准确性反馈到模型中以修正它们。
前向计算和反向传播本质上需要顺序计算。每个层或阶段必须等待前一个阶段的输入。虽然我们可以将每个阶段分布在各自的机器上,但整个模型的训练仍然是顺序的。因此,在训练期间我们仍然需要协调以强制执行顺序或某种形式的自动化流水线。一种调度算法可以使我们运行分布式深度学习工作负载,即团体调度。基于此算法,社区在 Apache Spark 2.4 中引入了屏障执行模式,允许我们创建一组机器,它们在一个阶段上共同工作,并且只有当它们全部完成后才能继续下一个阶段。屏障执行模式是 Project Hydrogen 的一部分,旨在在通用 Apache Spark 框架上启用更多样化的分布式机器学习工作负载。
资源管理
决定如何分配集群资源是分布式系统中最大的挑战之一。当你还要考虑分布式机器学习工作负载时,情况变得更加复杂。其原因在于需要通过将软件与专用硬件配对来提高性能。而不仅仅是 GPU 与 CPU 的讨论 —— 今天,英特尔、NVIDIA、谷歌和其他公司正在生产配备专用硬件芯片的机器,用于 AI。这些AI 加速器专为高性能大规模并行计算而构建,超越了传统的线程算法。此外,许多机器学习算法仍在不断发展。这就是为什么微软在其云中引入可编程门阵列(FPGA)芯片的原因,作为 Project Catapult 的一部分,以便在软件更新后实现快速实时深度学习 AI 服务。FPGA 具有可重新配置的设计,使其能够根据需要调整硬件配置。
在分布环境中,资源共享也是一个挑战,特别是在有竞争工作负载时。例如,需要 10 台机器,但只有 5 台可用时,软件可以选择使用现有资源或等待更多机器可用。这会导致瓶颈,并可能造成严重问题。想象一下,在生产环境中运行机器学习训练以节省资源,而你的训练工作负载与产品的实时工作负载竞争资源。在这种情况下,你可能会遇到客户的问题。因此,最好为关键工作负载和长期/离线工作负载准备多个环境。但如果你的模型非常关键,现在使用新数据进行训练可能会帮助你发现行业中未预料到的实时趋势(而错过这一点可能会导致收入损失)。也许你需要为关键工作负载维护两个环境,尽管这可能成本高昂且投资回报率低。
重新配置和资源共享只是问题的一部分。另一个挑战是自动化决定何时使用 GPU 与 CPU 或 FPGA 等市场上其他可用的硬件选项。在有足够预算的情况下,我们可以获取所有需要的硬件,但同样需要考虑投资回报率。我们应该怎么做?如何自动化这个决策?对于这个问题还没有明确的答案,但好消息是,越来越多的软件和硬件解决方案开始相互支持。例如,NVIDIA 创建了 RAPIDS,这是一套在 NVIDIA CUDA 处理器之上的开源库。CUDA 可以加速数据科学过程的 GPU 加速。有了 RAPIDS 对 Apache Spark 3.0 的支持,不仅可以加速数据科学工作负载,还可以加速 ETL/数据准备,我们可以构建一个基于它的集群,用于数据准备、模型训练和服务,从而无需自动化切换资源(尽管投资回报率的问题仍然存在)。
容错性
在分布式系统中,容错性是指在发生故障时仍能保证系统正常运行的能力。在分布式机器学习中,故障可以采取两种形式:
-
可检测和缓解的典型机器故障
-
未检测到机器故障导致产生错误输出
让我们从第一个问题开始。为了更好地理解容错程序的必要性,请问自己:如果我们将工作负载分布到 1,000 个计算节点的集群中,如果其中一个节点崩溃会发生什么?除了重新从头开始重新启动作业之外,还有其他解决方法吗?
当其中一个阶段失败时,我们需要重新计算所有内容吗?答案是否定的。如今,许多分布式计算框架都具有内置的容错程序:它们通过复制数据并在阶段之间将信息写入磁盘以实现更快的恢复。其他框架将定义这种机制的任务交给我们。例如,在 TensorFlow 中进行同步训练时,如果一个工作节点失败而我们没有提供容错程序,整个集群将失败。这就是为什么在决定 TensorFlow 分布策略时,我们需要注意容错机制。另一方面,Apache Spark 不向我们公开这个决定。相反,它有一个内置的隐藏机制,我们无法从机器学习 API 本身调整。在 Spark 中坚持使用自动数据并行工作负载容错机制可以节省大量时间,因为我们无需考虑可能的失败案例和解决方案。
第二种失败类型特定于分布式机器学习,直接影响机器学习算法本身的性能。在这种情况下,我们可以将其视为存在拜占庭对手机或故意或无意地存在故障代理。故障代理(或对手)可以通过暴露错误数据来损害机器学习模型的性能。很难减轻这种行为的影响,而这种影响高度依赖于我们使用的算法。检测这类失败是监控机器学习模型的重要原因之一,正如在第十章中讨论的那样。
隐私
机器学习中关于隐私的讨论通常集中在保护从用户/客户收集的数据或保护模型和参数本身。模型及其参数可以是公司的知识产权,保持它们的私密性可能很重要(例如,在金融市场系统中)。
实施数据隐私的一种选择是避免集中数据的限制。也就是说,我们希望构建一个模型,而无需将成员的训练数据上传到集中服务器。为此,我们可以利用federated learning等技术。采用这种方法,我们在边缘设备上训练算法,每个设备使用自己的数据。然后,设备将他们构建的模型摘要与其他设备或专用服务器交换。然而,这种方法并非万无一失。在训练过程中可能会发生对抗性攻击,攻击者获取一部分或全部数据或训练结果。这在联合学习中很容易发生,当攻击者通过他们的边缘设备积极参与训练过程时。
假设我们已找到一种安全地集中数据或训练模型而无需这样做的方法。仍存在一个可能性,即恶意行为者通过与模型本身交互,可以恢复关于数据(关于特定人群的统计数据、分类类别等)的信息,这些信息被用于训练模型。正如您现在可能已经注意到的那样,在确保机器学习中的隐私性方面,并没有一种适合所有情况的解决方案——这需要专用的技术和架构。虽然分布式机器学习中的隐私性是一个引人入胜的话题,但它也是一个庞大的话题,本书不会进一步讨论它。
可移植性
可移植性与分布式系统的一般挑战相关。当我们添加专用的计算硬件,如多种类型的 GPU,与我们构建的软件配对时,将工作负载从一个集群移动到另一个集群就变得更加困难。在云计算的早期阶段,许多公司采用了“提起和转移”迁移策略,将其工作负载和应用程序迁移到云端而无需重新设计它们。然而,在许多情况下,这导致了更高的成本,因为它们未能充分利用环境的特性。特别是在云中,本地功能是专为特定工作负载优化而构建的。分布式机器学习方法也是如此:各种类型的硬件、需求以及通过降低开发、存储和计算成本来提高投资回报率的需求,都可能影响可移植性。
分布式系统固有的其他挑战与机器学习无直接关系,但仍可能对机器学习工作负载产生影响,比如信任或零信任系统、网络开销、确保一致性等。本书不会涵盖这些问题,但在设计产品策略时,您应考虑它们。
设置您的本地环境
现在您对局势有了更好的了解,让我们为您成功设定!本书中的许多代码示例都可以在书籍的 GitHub 仓库 中找到。为了亲自体验它们,您应设置一个学习环境,在本地机器上运行它们。
您需要两次完成此设置过程,首先是为第 2–6 章节中的教程,然后是为第 7–10 章节中的教程。
第 2–6 章节教程环境
要跟随第 2 到 6 章节的教程,请确保您的机器上已安装最新版本的 Docker,并按照以下步骤操作:
-
运行 Docker。
-
在终端窗口/命令行中运行以下命令:
$ **docker run -it -p 8888:8888 adipolak/ml-with-apache-spark**这将拉取一个带有 Apache Spark 3.1.1 的 PySpark Jupyter 笔记本镜像,其中包含大部分我们将使用的库。稍后您将学会如何添加其余部分。执行此命令后,您将获得如下响应:
[I 13:50:03.885 NotebookApp] Serving notebooks from local directory: /home/jovyan [I 13:50:03.885 NotebookApp] Jupyter Notebook 6.3.0 is running at: [I 13:50:03.885 NotebookApp] http://6cb805089793:8888/?token=e14171684af c305b4702cbda99ae879d3faecf5db6bea37d [I 13:50:03.885 NotebookApp] or http://127.0.0.1:8888/?token=e14171684af c305b4702cbda99ae879d3faecf5db6bea37d [I 13:50:03.885 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation). [C 13:50:03.891 NotebookApp] To access the notebook, open this file in a browser: file:///home/jovyan/.local/share/jupyter/runtime/nbserver-8-open .xhtml Or copy and paste one of these URLs: http://6cb805089793:8888/?token=e14171684afc305b4702cbda99ae879d 3faecf5db6bea37d or http://127.0.0.1:8888/?token=e14171684afc305b4702cbda99ae879d3fa ecf5db6bea37d **^C**[I 13:50:27.037 NotebookApp] interrupted Serving notebooks from local directory: /home/jovyan 0 active kernels Jupyter Notebook 6.3.0 is running at: http://6cb805089793:8888/?token=e14171684afc305b4702cbda99ae879d3faecf5d b6bea37d or http://127.0.0.1:8888/?token=e14171684afc305b4702cbda99ae879d3faecf5db6b ea37d提示
遇到 AMD 的错误?请改用以下命令:
$ **docker run -p 8888:8888 \ adipolak/amd-ml-with-apache-spark** -
复制带有
token参数的最后一个 URL。它看起来像这样,但您将拥有自己的令牌:http://127.0.0.1:8888/?token=43143a485357351ef522a1840f8c8c141a1be2bcf5f 9b4de将其粘贴到浏览器中。这将成为您的 Jupyter 教程环境。
-
克隆或下载本书的仓库。
-
解压文件并使用上传按钮将笔记本和数据文件上传到 Jupyter(参见图 1-5)。

图 1-5. Jupyter 上传按钮
在目前的阶段,pyspark-notebook Docker 镜像非常简单:它仅包含本书中使用的主要工具 Jupyter 和 PySpark。图 1-6 展示了Jupyter Docker Stacks中的镜像堆叠。

图 1-6. Jupyter Docker Stacks 镜像
第 7 至 10 章教程环境
第七章至第十章的教程需要 PySpark、PyTorch、Petastorm、TensorFlow 以及与机器学习生命周期中的构建、部署和服务相关的一切内容。您可以按照前一节中概述的相同步骤设置这些章节的环境。此外,为了使用 PyTorch,您需要直接从 Jupyter 终端安装它。图 1-7 展示了如何在 macOS 上使用以下 conda 命令进行此操作(在此过程中,您可能需要回答一些关于安装的问题):
$ **conda install pytorch==1.12.1 torchvision==0.13.1 -c pytorch**

图 1-7. 在您的环境中安装 PyTorch
这些章节的教程可能需要额外的资源,例如,你可能需要更多的 RAM 来加快执行速度。为了配置这些,你可以使用docker命令,并使用--memory和--memory-swap标签。请根据你的计算机性能定义适当的内存量:
$ **sudo docker run -it --memory="16g" --memory-swap="24g" -p 8888:8888 \
adipolak/amd-ml-with-apache-spark**
总结
在本章中,我们介绍了分布式机器学习的基础知识,轻描淡写地涉及了许多复杂的主题:机器学习工作流程、分布式计算模型、网络拓扑、分布式训练和服务等等。正如您所知,Apache Spark 支持跨集群或计算机处理器的并行数据处理。该框架基于 MapReduce 范式,在处理数据、分析和机器学习算法方面具有众多优势。然而,它在处理深度学习工作负载方面也存在一些限制(因此,我将向您展示如何在第七章中从 Spark 过渡到深度学习框架)。
下一章提供了 PySpark 的快速介绍,帮助您快速上手或者帮助您复习基础知识。第三章将带您开始使用 MLflow 进行机器学习生命周期管理,并展示如何打包您的实验,以便您可以跟随本书其余部分的教程。在第四章,第五章和第六章中,您将学习如何利用 PySpark 进行机器学习需求的数据摄入,预处理和特征工程,以及训练模型。
¹ 根据您的定义方式,计算机科学历史的这个时代始于 2012 年或 2016 年——据估计,世界上存在的数字数据量在 2012 年超过了 1 ZB,并且 Cisco Systems 宣布,全球 IP 流量在 2016 年达到了 1.2 ZB。
² 带宽指示了在有线或无线连接中,在给定时间内可以传输多少数据。
³ 如果模型的方差较低,则采样数据会接近模型的预测结果。如果方差较高,则模型在训练数据上表现良好,但在新数据上表现可能不佳。
第二章:Spark 和 PySpark 简介
本章的目标是让您快速了解 PySpark 和 Spark,为您提供足够的信息,以便您在本书的其他教程中感到舒适。让我们从开始说起。究竟什么是 Spark?Spark 最初是在 2009 年由加州大学伯克利分校开发的,是一个用于大数据和机器学习的开源分析引擎。它在发布后很快被企业广泛采用,并且由像 Netflix、Yahoo 和 eBay 这样的强大力量在成千上万的节点集群上部署来处理 exabytes 级别的数据。Spark 社区也迅速增长,包括来自 250 多个组织的 1,000 多名贡献者。
注意
深入了解 Spark 本身,请阅读 Spark: The Definitive Guide,作者是 Bill Chambers 和 Matei Zaharia(O'Reilly)。
为了让您对本书的剩余部分有所了解,本章将涵盖以下领域:
-
Apache Spark 的分布式架构
-
Apache Spark 基础(软件架构和数据结构)
-
DataFrame 的不可变性
-
PySpark 的函数式编程范式
-
pandas 的 DataFrame 与 Spark 的 DataFrame 有何不同
-
用于机器学习的 Scikit-learn 与 PySpark 的比较
Apache Spark 架构
Spark 架构由以下主要组件组成:
驱动程序
驱动程序(也称为 Spark 驱动程序)是在驱动机器上运行的专用进程。它负责执行和持有 SparkSession,其中封装了 SparkContext —— 这被认为是应用程序的入口点,或者称为“真正的程序”。SparkContext 包含所有基本函数、在启动时传递的上下文以及有关集群的信息。驱动程序还持有 DAG 调度器、任务调度器、块管理器以及将代码转换为工作单位和执行器可以在集群上执行的所有内容。驱动程序与集群管理器协同工作,找到现有的机器并分配资源。
执行器
执行器是在工作节点上为特定的 Spark 应用程序启动的进程。每个执行器可以分配多个任务。JVM 进程与集群管理器通信并接收要执行的任务。同一执行器上的任务可以从共享内存(例如缓存)和全局参数中获益,这使得任务运行更快。
注意
任务是 Spark 中可调度工作的最小单位。它运行分配给它的代码,并处理分配给它的数据片段。
工作节点
Worker 节点,顾名思义,负责执行工作。多个执行器可以在单个工作节点上运行,并为多个 Spark 应用程序提供服务。
集群管理器
与驱动程序一起,集群管理器负责编排分布式系统。它将执行器分配给工作节点,分配资源,并将有关资源可用性的信息传达给驱动程序。除了 Spark 的独立集群管理器外,这也可以是任何其他能够管理机器和网络容量的集群管理器,如 Kubernetes¹、Apache Mesos²或 Hadoop YARN³。
图 2-1 展示了这些不同组件如何互相配合。

图 2-1. Spark 的分布式架构
这些组件各自在大规模编排 Spark 程序中发挥关键作用。Spark 驱动程序可以在一个应用程序内启动多个作业,每个作业包含多个任务。但是,它不能在同一应用程序中启动多个应用程序,因为 Spark 资源管理是基于每个应用程序而不是每个作业的基础进行的。任务在一个或多个执行器上运行,通常处理数据的不同部分(见图 2-2)。请注意,执行器没有分配专用存储,尽管在图 2-2 中可以看到存储已连接到执行器。在某些部署中,例如本地 Hadoop 集群,存储可以是执行器本地的,但通常在云解决方案中,情况并非如此;在云部署(AWS、Azure 等)中,存储与计算是分离的。通常情况下,Spark 更喜欢调度访问本地数据的任务,但将任务和执行器分配给本地数据并非必需。

图 2-2. Spark 启动两个作业
PySpark 简介
我之前提到过 Python 是一种不同类型的语言——它不是 JVM 家族的一部分。Python 是一种解释型语言,这意味着(与 JVM 不同)Python 代码不经过编译。您还知道,在 Spark 的核心部分,它运行基于 JVM 的进程,并且基于 Scala 和 Java。那么 Spark 如何与 Python 一起工作呢?让我们来看一下。
在最基本的层面上,Spark 应用程序组件通过共享网络通过 API 进行通信。这意味着,如果我有一个运行 JVM 进程的任务,它可以利用进程间通信(IPC)与 Python 一起工作。
假设您编写了一个 PySpark 应用程序。它如何与 Spark 一起工作呢?基本上,每次启动 PySpark 作业时,它在幕后创建两个进程:Python 和 JVM。Python 是定义代码的主程序,而 JVM 是负责 Spark 查询优化、计算、将任务分发到集群等的程序。在 PySpark 应用程序中,SparkContext本身有一个名为_gateway的参数,负责保存上下文以将 Py4J 应用程序传递给 JVM Spark 服务器。
等等,什么是 Py4J 应用程序?Py4J 是一个用 Python 和 Java 编写的库,允许在 Python 解释器中运行的 Python 程序通过标准的 Python 方法动态访问 JVM 中的 Java 对象和集合,就像它们驻留在 Python 解释器中一样。换句话说,它使得 Python 代码能够与 JVM 通信,对用户透明。图 2-3 展示了它的工作原理。当 PySpark 驱动程序启动时,它会使用配置好的 Py4J 服务器启动一个 Spark JVM 应用程序,以便与 Spark JVM 直接通信。信息在 PySpark 驱动程序和 Py4J 之间以序列化或“pickled”形式传输。⁴

图 2-3. Py4J 充当 Python 应用程序和 Spark JVM 之间的中介
因为 Python 是解释器,并且代码不会提前编译,所以每个工作节点都有一个包含 Python 代码的执行器。执行器在需要执行逻辑时启动一个 Python 应用程序。当然,在这里我是在简化事情——多年来添加了一些例外和优化。你也应该知道,相比传统的 Scala/Java 代码,PySpark 在运行时间上通常效率较低。
Apache Spark 基础知识
在本节中,我们将简要介绍 Spark 本身的基础知识,从软件架构和关键编程抽象开始。
软件架构
由于 Spark 被构建为一个通用引擎,可以支持各种分布式计算工作负载,其软件架构是分层的,正如你可以在 图 2-4 中看到的那样。

图 2-4. Spark 软件架构
底层通过 弹性分布式数据集(RDD)API 和数据源连接器抽象出存储。Spark 中的存储可以是任何具有可读格式的内容(更多内容请参阅 第四章)。
顶层包含我们利用的 API 和库。这些 API 直接与 Spark 的 DataFrame 或 Dataset API 一起工作,抽象出所有的 Spark 内部细节。在 Spark 中,DataFrame 是一个分布式的按列组织的数据集合,类似于数据库中的表。它有一个专用的结构和格式,可以对其运行特定的操作。它还有一个模式(schema),其中每列支持特定的 数据类型(数值、字符串、二进制、布尔、日期时间、间隔等)。Dataset 和 DataFrame 的主要区别在于 Dataset 对列的类型安全性,这意味着我们不会将某列误认为是 string 类型而实际上是 int 类型。然而,我们为此付出了代价:操作 Dataset 通常比操作 DataFrame 慢。
请注意,模式是 DataFrame 的一个重要部分。Spark 可能会尝试根据文件格式自动推断给定数据集的模式——例如,对 Parquet 文件执行此操作(更多详细信息请参见第四章)。它也会尝试从 CSV 文件中推断模式,但可能由于特定字符编码(如 UTF 格式)或问题(例如额外的空格/制表符),以及偶尔推断分隔符时出错。
要将 CSV 文件读取到 DataFrame 中,您只需调用带有专用格式函数的 read 操作:
df = spark.read.csv("some_file")
在这种情况下,PySpark 将尝试从文件中推断模式。
在执行 df.printSchema() 后,会执行 read 函数并打印模式,以供您查看。如果希望,还可以使用 option 函数更改分隔符。例如,您可以在前一行后添加以下内容将分隔符设置为逗号:
.option(delimiter=',')
创建自定义模式
通常情况下,您会想要控制流程并提供自定义模式。这样可以促进代码本身的协作和可重现性。这也可以节省后期调试问题的宝贵时间。
那么,如何在 PySpark 中做到这一点呢?您需要创建一个 StructType 并在读取时将其作为所需模式传递给读取器。在 StructType 中,使用专用 API 添加所有列名和类型,就像下面的代码示例中所示:
schema = StructType([
StructField("Number",IntegerType(),True),
StructField("City",StringType(),True),
StructField("Age",DoubleType(),True),
StructField("Decommissioned",BooleanType(),True)
])
df_with_schema = spark.read.format("csv")
.schema(schema)
.load("{some_file_path}.csv")
将 True 传递给 StructField 对象表示该值可以为空。
如何将 Python 数据类型转换为 Spark 数据类型?或者更确切地说,PySpark 如何解释这些类型?表 2-1 提供了一些最常见转换的指南。
表 2-1. 基本 Python 数据类型及其在设计模式时的初始化方式
| Python 中的值 | Spark 类型 |
|---|---|
int (1 字节) |
DataTypes.ByteType() |
int (2 字节) |
DataTypes.ShortType() |
int (4 字节) |
DataTypes.IntegerType() |
int (8 字节) |
DataTypes.LongType() |
float (4 字节,单精度) |
DataTypes.FloatType() |
float (8 字节,双精度) |
DataTypes.DoubleType() |
str |
DataTypes.StringType() |
bool |
DataTypes.BooleanType() |
decimal.Decimal |
DataTypes.DecimalType() |
请注意,虽然 Python 中有许多类型,在定义模式时,有些类型会重复出现,比如 int 和 float。Spark 提供了不同的类型,例如,int 值可能会转换成不同大小的字节数表示(ShortType 是 2 字节,IntegerType 是 4 字节等)。这是您在使用 PySpark 时需要解决的挑战之一。
关键的 Spark 数据抽象和 API
RDD 究竟抽象了什么?这是一个很棒的问题,对其进行全面考虑将使我们深入到分布式存储和数据源连接器的概念中。RDD 本质上是 JVM 对象的只读分布式集合。然而,Spark 在这里隐藏了许多复杂性:RDD 结合了应用程序的依赖关系、分区和 iterator[T] => 计算函数。分区是数据本身的逻辑划分 —— 您可以将其视为分布式数据的一个块。RDD 的目的是将每个分区连接到逻辑迭代器,根据执行器可以迭代的应用程序依赖关系。分区至关重要,因为它们提供了 Spark 能够轻松地跨执行器分割工作的能力。
Spark 的其他核心数据抽象是 DataFrames 和 Datasets。DataFrame 类似于 RDD,但它将数据组织成具有命名列的形式,类似于关系数据库中的表。Datasets 是 DataFrame API 的扩展,提供类型安全性。DataFrames 和 Datasets 都受益于由 Spark Catalyst 引擎提供的查询优化。这种优化非常重要,因为它使得运行 Spark 更快更便宜。因此,尽可能利用顶层 API 并避免使用 RDD 是明智的选择。
许多创新被用于 Catalyst,这真是一个令人着迷的世界。然而,考虑到它对机器学习工作负载几乎没有影响,我将不在这里进一步深入探讨它。
如图 Figure 2-4 所示,Spark 提供了许多 API/库。让我们来看看一些在 PySpark 中支持的 API:。
MLlib
这个库用于在规模上运行机器学习工作负载。在底层,有两个库,MLlib 本身和机器学习(更多内容请参见 Chapter 6)。
GraphFrames
这个库使得可以在具有边和节点的数据上运行图操作。它在原始数据类型 (int, double 等) 的顶点和边类型表示优化方面,通过将它们存储在专用数组中,减少了内存占用。尽管机器学习与图计算有很多关系,但 MLlib 和 GraphFrames 是分开的,用于不同的目的。然而,GraphFrames 有一些有趣的算法(如 PageRank),可以帮助您丰富数据并进行预处理和特征工程。
结构化流处理
此 API 启动一个处理微批数据的不间断 Spark 作业。还有一个用于低延迟的改进引擎称为连续处理。Spark 应用程序本身创建一个侦听器,等待收集新数据。当新的微批数据到达时,Spark SQL 处理并相应地更新最终结果。
DataFrames 是不可变的。
需要注意的是,DataFrame、Dataset 和 RDD 被视为不可变存储。不可变性意味着对象创建后其状态不可更改。在编写 PySpark 代码时,我们必须牢记这一点。每次在 DataFrame 上执行操作时,由于它不能改变原始对象(不可变性),为了存储结果,它会创建一个新的 DataFrame。考虑以下代码示例,我们在 DataFrame 上读取并运行 select 操作:
train_df = spark.read.csv('training_data.csv', header = True)
train_df.select('bot')
请注意,在第一行中,我们将 DataFrame 分配给了 train_df 变量。select 操作后 train_df 发生了什么?什么也没有!是的,绝对没有任何变化。这就是不可变性的威力:对象本身不会改变。本质上,train_df 现在指向内存中表示 DataFrame 的位置。表示本身不会改变。通过选择 bot 数据,我们只是创建了对数据子集的新指针。因为我们没有将新指针分配给变量,所以代码的最后一行实际上什么也没做。底层 DataFrame 仍然是相同的,train_df 仍然指向表示 DataFrame 的内存中的相同位置。
你还应该注意,Spark 使用惰性执行。这意味着它不会启动进程的执行,直到调用动作类型的操作为止。动作可以是任何返回不是 DataFrame 的值的操作,例如获取 DataFrame 中行数的计数或执行写操作。另一种操作类型是转换。转换操作从现有的 DataFrame 创建新的 DataFrame。Spark 将所有转换累积成 DAG 并优化它们,但只有在需要它们的动作发生时才对其执行。
回到我们的例子,Spark 甚至没有将数据加载到内存中。它创建了一个操作图,其中包含 read 和 select。如果稍后在由 select 创建的 DataFrame 上没有动作操作,Spark 将对其进行修剪,并且根本不执行它。Spark 的惰性执行和不可变数据结构有助于防止我们犯错和执行不必要的计算;请注意这些特性。
那么,我们该如何纠正之前的错误呢?我们可以从简单地将 select 操作的 DataFrame 分配到实例中开始,如下所示:
train_df = spark.read.csv('training_data.csv', header = True)
tmp_df = train_df.select('bot')
这样保存了对 tmp_df 的引用。如果我们稍后继续在其上执行并有动作操作,Spark 将执行此 select 操作(获取结果并将其保存到 tmp_df 变量中),作为其操作图的一部分,只有在需要返回结果到驱动程序的动作发生时才会执行。否则,它将被修剪。
你可能在问自己,为什么不可变性很重要呢? 操作的不可变性是函数式编程的支柱之一,这种编程范式旨在使我们能够构建更可靠和可维护的应用程序(你将在下一节学到更多关于函数式编程的知识)。确保对象在创建后其状态不能改变,使开发人员能够跟踪所有对该对象执行的操作,从而保留事件链。这反过来使应用程序具有可伸缩性。
在处理 Spark 时也适用相同的概念:实质上,每个 DataFrame 都是特定操作的结果,这是可重现和可跟踪的。这在分布式系统中尤为重要和具有挑战性。选择不可变性使我们能够实现弹性,尽管为了节省内存,Spark 并不会自动保存我们生成的每个 DataFrame。考虑这一点,并学会如何以一种使你能够重现机器学习实验的方式来使用 Spark(关于此更多内容请参见第三章)。
PySpark 和函数式编程
我在前一节提到了函数式编程,这并非偶然。Spark 除了不可变性外,还从函数式编程中借鉴了许多概念,从匿名函数开始。这些函数在没有状态的情况下执行,但并非无名。这个想法起源于数学中的λ演算,你经常会听到这些被称为λ函数。
你可以将匿名函数传递给 RDD 来在数据上执行。看下面的代码片段:
rdd2 = rdd.map(lambda x: (x,1))
在这里,我们对 RDD 实例调用map函数。在该函数内部,我们调用一个匿名函数。对于每个x——也就是 RDD 中的每一行——该函数返回一对(x,1)。例如,如果我们的原始 RDD 包含值为(1,2,3)的行,该函数将返回((1,1),(2,1),(3,1))。这是一个转换操作,rdd2保存了具有将每个x转换为(x,1)的图操作的要求。
在这个例子中,map函数利用了将独立函数传递给 RDD 来执行的功能,这是可重复且独立于状态的。这种能力使并行执行变得轻而易举。想象一下:每个执行器在其数据分区上运行操作并返回答案。无需交换信息,这使得系统极其可伸缩和弹性。如果一个节点宕机,无需重新计算所有内容——只需处理它负责的部分数据。最终的结果与在单个节点上执行操作时完全一致。
举个更高级的例子,假设客户请求您找出所有值中的最大值。您可以通过使用reduce操作和max操作来实现这一点,如下面的代码片段所示:
rdd3 = rdd2.reduce((a,b)=> (("max",a._2 max b._2)._2))
reduce将计算所有值中的最大值,从而从本地值开始,通过比较每对来找到本地最大值。每个节点找到其本地最大值后,数据将被移动到专用执行器,该执行器将计算最终结果。在我们的案例中,((1,1),(2,1),(3,1))将返回3。
尽管函数式编程有更多的核心原则,Spark 并不一定完全遵循所有这些原则。主要原则如下:
-
不变性
-
有纪律的状态(最大程度地减少对状态的依赖)
执行 PySpark 代码
如果您想在配置了集群的情况下运行 PySpark 代码,请确保您可以访问具有正确库的集群,并且具有必要的权限。对于云安装,如果您不是 IT 专业人士,最好利用托管的 Spark 解决方案或咨询您的 IT 部门。执行代码本身非常类似于 JVM 进程。如前所述,PySpark 代码将处理其余的部分。
要在专用笔记本上本地执行,请按照书籍的 README 文件中的GitHub repo中的快速入门指南中的说明操作。这应该足以跟随教程进行学习。
Spark 也支持 shell 命令,通过 PySpark shell(一种交互式 shell,可以使用命令行尝试 PySpark)。PySpark shell 负责将 Python API 链接到 Spark 核心并初始化SparkSession和SparkContext。它基于 Scala 概念称为 REPL(Read-Eval-Print Loop)。有关更多信息,请查阅 Spark 文档中的快速入门指南。
pandas DataFrames 与 Spark DataFrames 的比较
Spark DataFrames 的灵感来自pandas,它也提供了一个称为 DataFrame 的数据抽象层。pandas 是一个广泛采用的用于数据操作和分析的库。许多开发人员使用它来使用 Python 来推断数据。读取 pandas DataFrame 非常简单——下面是一个展示如何做到这一点的代码示例:
import pandas as pd
df = pd.read_csv(....)
起初可能很容易混淆这两者,但 pandas 和 Spark 之间有许多关键区别。最重要的是,pandas 不是为大规模设计的;它是为操作适合一个机器内存的数据而构建的。因此,它没有我们在章节开头讨论的分布式 Spark 架构。它也不遵循函数式编程原则:pandas 的 DataFrame 是可变的。
注意
它在内存中保留的数据的多个排列可能导致 pandas 在原始数据集轻松适合单个机器内存的情况下失败。
表 2-2 提供了 Spark 和 pandas DataFrame 一些关键特性的快速比较。
表 2-2. Spark DataFrame 与 pandas DataFrame
| Spark DataFrame | pandas DataFrame | |
|---|---|---|
| 并行操作 | 是 | 不是现成的 |
| 延迟评估 | 是 | 否 |
| 不可变 | 是 | 否 |
尽管如此,正如你所见,没有现成的方法可以在 pandas DataFrame 上并行操作,但这并不意味着完全不可能。这只是意味着你必须创建一个解决方案,并考虑可能遇到的问题(线程锁定,竞态条件等)及其对最终结果的影响。其他区别包括 Spark 支持延迟评估,而 pandas 中的操作在 Python 代码行执行时立即发生,pandas 中的 DataFrame 不是不可变的。这使得在 pandas DataFrame 上操作变得更容易,因为你不需要记住或意识到延迟执行的方法——当你调用函数时,它会立即执行,并且你可以立即与结果交互。但是,这也使得使用并行或分布式计算进行扩展变得具有挑战性。
提示
Spark 社区创建了一个名为 Koalas 的开源库,它在 Spark 上提供了类似 pandas 的 API。它也被称为 pandas-on-Spark。虽然这不同于并行化 pandas,但从用户的角度来看,该 API 在模仿 pandas API 方面做得很好。这个库是官方 Spark API 的一部分,这也使得你可以立即更容易地使用它。要了解更多信息,请在 Spark 文档中搜索“pandas API”。从 Spark 版本 3.2 开始,你可以像这样直接从 PySpark 中导入它:
import pyspark.pandas as ps
Scikit-Learn 与 MLlib 的比较
Scikit-learn (sklearn) 和 Spark MLlib 有时也会被混淆。Scikit-learn 是一个用 Python 编写的机器学习库,利用了众所周知的 Python 库,如 NumPy、SciPy 和 matplotlib。虽然它在处理适合 RAM 的数据时表现出色,但它以非分布式的方式进行操作。Spark 添加了配置集群以在更大规模上运行的开销,但提供了针对并行/分布式执行进行调整的算法。
在什么情况下应该选择每个工具?以下是一些提示:
-
对于处理大型数据集(GB、TB 甚至 PB 规模),最好使用 Spark,并使用 MLlib 对其进行机器学习。
-
当你的所有数据都适合机器内存时,使用 Python 中的 scikit-learn 与 pandas 和其他库一起构建算法更有效。此外,这种方式使得在生产中提供模型不受特定配置的限制(关于在第 10 章中的模型部署模式更多信息)。
Scikit-learn 使用数据集的概念,与 Spark 和其他 Python 库(如 pandas)类似。数据集是可变的,但直接扩展受限,需要与其他工具配合使用。模型部署与 Spark 类似(取决于您的服务模式),模型可以保存到磁盘,并使用多个 API 进行重新加载。Scikit-learn 可以操作 pandas DataFrames 和 NumPy 数组。
表格 2-3 快速比较了 scikit-learn 和 MLlib 的一些关键特性。当您到达 第六章,讨论 Spark 的机器学习流水线时,这种相似性将更加明显。这是因为 Spark 中管道的概念受到了 scikit-learn 项目的启发;Spark 社区决定使用类似的概念(就像设计 DataFrames 时一样),尽可能地简化 Spark 的学习曲线。
表格 2-3. scikit-learn 对比 Spark MLlib
| Scikit-learn | MLlib | |
|---|---|---|
| 数据集 | 可变的(可以原地更新列) | 不可变的(必须创建新列) |
| 可伸缩性 | 数据必须适合单机内存 | 分布式(支持大数据分析) |
| 模型部署 |
-
模型可以通过 REST API 进行“pickling”到磁盘并重新加载。
-
受 MLflow 支持。
-
提供许多部署选项。
|
-
支持 Parquet 和 Snappy 文件格式以及其他开放文件格式。
-
受 MLflow 支持。
|
总结
本章介绍了 Spark 的基础知识,为您提供了架构和 API 的高层次见解,并将其与其他流行的 Python 工具进行了比较。在此基础上,您应该准备好开始使用 Spark 和端到端的机器学习流水线,并积累一些实践经验。要记住的一点是,机器学习项目是长期的,需要时间、精力和合作。为了提高代码的可维护性,保持可重用性、友好性和模块化是很重要的。
¹ Kubernetes (K8s) 是一个自动化容器化应用程序部署、扩展和管理的工具。
² Apache Mesos 是一个开源的集群管理器。
³ 在 Hadoop 的分布式处理框架中,YARN 是资源管理和作业调度技术。
⁴ Pickling 是将 Python 对象转换为字节流的过程。
⁵ Spark 还提供 GraphX 库;在高层次上,它们的功能类似,但 GraphFrames 基于 DataFrames,而 GraphX 基于 RDDs。在 PySpark 中支持的是 GraphFrames。
第三章:使用 MLflow 管理机器学习实验生命周期
机器学习开发和数据科学通常是协作进行的,但在尝试大量特征组合、标准化技术和超参数的组合的同时共同构建模型,是一个复杂的任务。部分原因在于追踪实验、复现结果、打包模型以便部署以及以确保其良好文档记录并提供所需准确性的方式存储和管理模型,这本身就是一项复杂的任务。
为了促进这一过程,需要演进机器学习开发工作流程,使其更加健壮、可预测和标准化。为此,许多组织已开始构建内部机器学习平台来管理机器学习生命周期。然而,这些平台通常仅支持一小部分内置算法,由公司基础设施和可用软件决定,对于支持新软件可能存在较大复杂性。此外,这些平台通常不是开源的,用户难以轻松利用新的机器学习库或与社区中其他人分享其工作。
管理机器学习实验生命周期,有时被称为MLOps,是机器学习、开发和运维的结合体。机器学习部分涉及实验本身:训练、调整和找到最优模型。开发则是开发流水线和工具,将机器学习模型从开发/实验阶段进入分阶段和生产阶段。最后,运维则涉及 CI/CD 工具和监控,管理和扩展规模化服务模型。Figure 3-1 描绘了机器学习模型生命周期的步骤,MLOps 团队必须支持每一个步骤。

图 3-1。机器学习模型的生命周期,从开发到归档
机器学习实验可以通过机器学习流水线代码或存储在仓库中的机器学习模块来组织,例如 Git 分支。它们包含了代码、数据和模型,是机器学习软件生命周期的一个组成部分。
机器学习生命周期管理需求
大多数机器学习项目的目标是达到涵盖所有要求并开发所有需要解决的业务问题或问题的能力的位置。然而,定义(更不用说满足)机器学习生命周期的所有要求通常说起来比做起来更容易。此外,有时这些要求可能来自外部实体。例如,如果我为新药进行机器学习研究,可能需要我的项目和结果符合美国食品和药物管理局(FDA)的要求。要获得 FDA 批准,必须已经审查了药物效果的数据,并且确定该药物对预期人群的已知和潜在风险的益处大于其风险。这意味着至少需要使机器学习实验具有可重现性。当然,这只是其中的一个要求。许多其他要求来自称为软件开发生命周期(SDLC)的软件工程过程。一般来说,SDLC 包括七个阶段:规划、分析、设计、开发、测试、实施和维护。我们可以将这些阶段转化为机器学习生命周期阶段,专注于与机器学习相关的领域。也就是说,仔细研究这些阶段涉及的内容,我们可以决定如何实施它们,并采纳对机器学习相关的概念,利用现有的 SDLC 工具和方法论。
在技术方面,我们可以概括出以下核心要求:
可重现性
这指的是能够在不同数据集上重复运行算法并获得相同(或相似)结果的能力。分析、报告、审计和解释数据都是这一过程的方面,意味着生命周期的每个步骤都需要进行跟踪和保存。
代码版本控制
软件开发人员通常会为他们的代码使用版本控制,机器学习开发人员也应该如此。维护代码、参数和数据的所有不同版本历史可以帮助我们与他人合作,并跟踪我们进行的各种实验变体。
数据版本控制
正如之前提到的,机器学习实验包括代码、数据和模型。在进行这类实验时,跟踪和记录生成特定模型的代码和数据是至关重要的。就像我们的代码会变化一样,我们的数据也会变化。我们可能会从数据中提取新特征或对数据本身进行不同种类的预处理,导致数据集的变化需要记录以备将来使用。支持数据版本控制的工具为我们提供了这些能力。需要注意的是,MLflow(本章讨论的工具)并未提供数据版本控制;然而,有其他开源工具可以提供这样的功能,比如lakeFS,DVC等。
有多种工具可用于管理机器学习实验生命周期,以确保满足这三个要求。对于本书,我选择了 MLflow,因为它是开源的,与 Apache Spark 原生集成,并且在抽象复杂功能的同时允许灵活协作和扩展到其他工具。在本章的其余部分,我将介绍此工具及其如何帮助我们管理机器学习实验。
MLflow 是什么?
MLflow 是一个简化整个机器学习生命周期管理的平台。它使您能够跟踪实验及其结果,部署和管理模型,并将机器学习代码打包成可重复使用的格式。它提供了支持版本控制和注释的中央模型注册表,以及模型服务功能。它通过重新定义实验日志和模块结构来实现这一点。
从高层次来看,主要组件是跟踪服务器和模型注册表,如图 3-2 所示。我们稍后会更详细地查看其他支持流程的组件。在模型注册后,团队可以构建自动化作业,并使用 REST 服务 API 将其向下移动。请注意,开源平台本身不支持模型监控等功能,这需要专门的工程工作。

图 3-2. MLflow 的两个主要组件(由Databricks提供)
MLflow 平台的软件组件
为了更好地理解其工作原理,让我们来看看 MLflow 的软件组件:
存储
MLflow 提供支持连接到多种存储类型(文件目录、数据库等)并利用它们来跟踪机器学习工作流程。存储包含有关实验、参数、不同运行结果等的所有信息。
后端服务器
此组件负责将来自数据库/存储、UI、SDK 和 CLI 的信息传递给其他组件,捕获实验日志等。
前端
这是 UI 界面,我们可以以可视化的方式与不同运行的实验及其结果进行交互和跟踪。
API 和 CLI
MLflow 还具有用于交互和实验跟踪的 Python API 和命令行界面。
我们可以通过 API、CLI 或 UI 与 MLflow 平台进行交互。在幕后,它跟踪我们提供的所有信息。使用 API/CLI 会生成专用目录,您可以将其推送到 Git 存储库以便协作。图 3-3 展示了管理实验多次运行时 UI 的示例。

图 3-3. 跟踪实验中多次运行
MLflow 平台的用户
正如您所想象的那样,许多团队和个人参与到机器学习的产品化过程中,并进行端到端的生命周期开发和管理。因此,MLflow 具有广泛的潜在用户,包括以下几类:
个人用户
作为个人,您可以使用 MLflow 在本地跟踪实验,组织代码以备将来使用,并输出模型,稍后可以使用新数据进行测试。您还可以将其用于组织您的研究工作。
数据科学团队
处理同一问题并尝试不同模型的数据科学团队可以轻松使用 MLflow 共享和比较他们的结果。团队通常会创建一个共享的 Git 仓库来保存这些成果。团队中的每个人都可以保存、跟踪、下载并运行自己或其他团队成员的模型,或者使用界面来跟踪各种参数,更好地理解实验阶段。
组织
从组织角度来看,您可以为团队协作打包训练和数据准备步骤,并比较各个团队在同一任务上的结果。MLflow 允许您简化和标准化从研究和开发到暂存和生产的流程。
机器学习工程师/开发者
数据科学家经常与机器学习/人工智能工程师合作。使用 MLflow,数据科学家和工程师可以以 MLflow 项目格式发布代码到 GitHub,使任何人都可以运行他们的代码。此外,机器学习工程师可以以 MLflow 模型格式输出模型,以自动支持使用 MLflow 内置工具进行部署。机器学习工程师还可以与 DevOps 团队合作,在 MLflow 数据库中定义 Webhook,用于在开发阶段之间移动模型(从开发、验证、暂存、生产到归档)。
MLflow 组件
MLflow 主要分为四个组件:MLflow 追踪 用于捕获实验相关的参数和信息,并记录结果,MLflow 项目 用于将项目代码打包到目录或 Git 仓库中,MLflow 模型 用于以不同格式打包和部署模型,以及 MLflow 模型注册表,提供一个集中存储和跟踪所有模型不同版本的库。让我们详细看看每个组件。
MLflow 追踪
MLflow 追踪 可以在独立脚本(不绑定到任何特定框架)或笔记本中使用。它提供了 API、界面和 CLI,用于记录实验参数、代码本身及其版本、机器学习指标以及在运行机器学习代码时生成的输出文件,以便稍后可视化。它还使您能够使用 Python 和其他一些 API 记录和查询实验。
MLflow Tracking 基于记录运行或某些数据科学代码的单独执行的概念。记录的数据包括代码版本、开始和结束时间、源文件或项目、参数和指标,以及生成的任何工件(如图像、模型或数据文件)。您可以使用 MLflow 的 Python、R、Java 和 REST API 从任何地方记录运行您的代码,并且可以定义它们的记录位置:到本地文件、数据库或远程跟踪服务器。默认情况下,MLflow Python API 将运行记录到mlruns目录中的本地文件中。运行可以组织成实验,为特定任务将所有运行组合在一起并提供简单访问。
可以在独立程序、远程云机器或笔记本中记录运行。如果您在一个 MLflow 项目中记录您的运行(在“MLflow Projects”中讨论),MLflow 会跟踪项目 URI 和源版本。稍后,您可以使用Tracking UI或 MLflow API 查询所有记录的运行。
使用 MLflow Tracking 记录运行
假设我们有一个 TensorFlow 实验,我们想要使用 MLflow 运行和跟踪。根据我们想要跟踪的模型的类型,我们将作为第一步导入相关的库。这将是mlflow.tensorflow或mlflow.keras之一;使用支持您正在使用的训练算法的库。随后,我们可以利用平台的自动记录功能或以编程方式记录参数和指标 — 我将展示如何两者都做。
要启动运行,在 Python 中我们可以使用with运算符和mlflow.start_run。这个 API 调用在实验中启动一个新的运行,如果存在的话。如果没有实验存在,它会自动创建一个新的实验。您可以使用mlflow.create_experiment API 或通过 UI 创建您自己的实验。在运行中,您开发您的训练代码,并利用log_param和log_metric跟踪重要信息。最后,您记录您的模型和所有必要的工件(稍后详述)。查看以下代码片段,以更好地理解流程如何工作:
import mlflow
import mlflow.tensorflow
# Enable MLflow autologging to log the metrics, parameters, and artifacts
mlflow.tensorflow.autolog()
# Launch a run in a new experiment (or an existing one if one is passed in)
with mlflow.start_run():
# Log parameters (key/value pairs)
mlflow.log_param("num_dimensions", 8)
# Log metrics that can be updated throughout the run
mlflow.log_metric("alpha", 0.1)
# ... some machine learning training code
# Log artifacts (output files)
mlflow.log_artifact("model.pkl")
# Log the model
mlflow.log_model("ml_model_path")
在撰写本文时,mlflow.tensorflow.autolog是版本 1.20.2 中的一个实验性方法。它使用 TensorFlow 的回调机制在训练过程的特定阶段执行不同的操作。回调可以传递给 TensorFlow 的方法,如fit、evaluate和predict,以便在模型训练和推断生命周期的各个阶段进行钩子操作。例如,您可以利用它们在 epoch 结束时执行某些操作,或者通过配置参数在运行期间达到所需的精度时停止训练以减少计算成本。
Epoch是对整个数据集的单次遍历。在每个 epoch 中,您可以访问日志并使用回调进行程序化决策。TensorFlow 中的回调是 Keras 库tf.keras.callbacks.Callback的一部分,而在 PyTorch 中它们是pytorch_lightning.callbacks的一部分。在 PyTorch 中,回调通常是通过扩展实现的,例如由 Grid.AI 开发的开源 PyTorch Lightning 库。
在训练开始时,autolog函数记录所有与训练相关的配置。然后,在每个 epoch 结束时,它捕获日志指标。在训练结束时,通过调用mlflow.log_model记录模型。因此,它覆盖了整个生命周期的日志记录,并且您可以使用可用函数(mlflow.log_param、mlflow.log_metric、mlflow.log_artifact等)添加任何其他参数和工件。
对于 PyTorch,也可以使用自动记录,如下面的代码片段所示:
import mlflow.pytorch
# Autolog all MLflow entities
mlflow.pytorch.autolog()
MLflow 还支持其他框架和机器学习的变体,但并非所有都作为开源解决方案的一部分受支持。有关详细信息,请查看由Databricks提供的 MLflow 的完全托管和托管版本。
提示
目前建议您按照一般规则将参数、指标、模型和工件进行程序化记录,因为在 MLflow 的开源版本中当前不完全支持自动记录。在使用 Spark MLlib 时也适用相同的建议。
记录您的数据集路径和版本
出于实验跟踪、可重现性和协作目的,我建议在训练阶段同时记录数据集路径和版本以及模型名称和路径。将来,这将使您能够在必要时重现模型,并区分使用相同算法但不同输入版本训练的模型。确保跟踪所有超参数、参数和种子数据!
为此,我建议使用mlflow.log_param函数,因为它可以让您在一次调用中记录所有参数。这将生成一个批量日志,稍后跟踪更简单:
dataset_params = {"dataset_name": "twitter-accounts", "dataset_version": 2.1}
# Log a batch of parameters
with mlflow.start_run():
mlflow.log_params(dataset_params)
另一个推荐的选项是通过将tags参数传递给start_run来动态设置标签:
with mlflow.start_run(tags=dataset_params):
MLflow 提供了一个灵活的 API。根据这些建议,您可以自行决定如何结构化您的实验日志。图 3-4 展示了 MLflow 仪表板,您可以在其中跟踪您的实验并查看参数和标签等其他元素。

图 3-4. MLflow 实验仪表板
提示
MLflow 提供了一个选项,可以将本地目录的所有内容作为运行的工件进行记录,如果没有运行则创建一个新的活动运行:mlflow.log_artifacts(local_dir, artifact_path=None)。但是,我建议避免使用这个选项,因为它会复制给定 local_dir 中的所有内容并将其写入 artifact_path。当我处理大规模数据进行训练时,除非确实需要,我更倾向于避免复制数据。
现在,您已经了解了 MLflow Tracking 组件的责任:记录所有打包项目所需的信息。接下来,让我们来看看 MLflow 项目。
MLflow 项目
MLflow 项目 是一种将代码打包成可重复使用和可重现的标准格式。MLflow 项目组件包括用于执行项目的 API 和命令行工具,并使得将项目链接成工作流成为可能。
每个项目只是一个包含一组代码文件和一个描述文件(一个名为 MLproject 的 YAML 文件)的目录或 Git 仓库,用于指定其依赖关系和如何运行代码。MLflow 项目格式捕捉了所有复制和部署模型所需的相关数据,包括环境数据。根据文档,在编写时,MLflow 支持四种不同的项目环境:virtualenv 环境(支持 Python 包,可通过 Python 包索引(PyPI)获取),conda 环境(支持 Python 包和本地库),Docker 容器环境(允许非 Python 依赖项,如 Java 库),以及系统环境。系统环境在运行时提供,并且必须在项目执行之前安装所有项目依赖项。使用 conda 环境时,默认情况下 MLflow 使用系统路径来查找和运行 conda 二进制文件。您可以通过更改 MLFLOW_CONDA_HOME 环境变量来决定使用不同的 conda 安装位置。
您的项目可能有多个入口点,可以通过命名参数调用运行。由于 MLflow 项目支持 conda,您可以通过利用 MLflow CLI 在 conda 环境中指定代码依赖:
$ **mlflow run example/conda-env/project -P alpha=0.5**
使用 -P 命令行参数运行实验使我们能够使用 CLI 更改参数。在这里,我们覆盖了传递给 log_metric 函数的参数(mlflow.log_metric("alpha", 0.1)),将其值更改为 0.5。
MLflow 模型
MLflow Models 组件用于将机器学习模型打包成多种格式,称为flavors。部署工具可以使用这些flavors来理解模型,从而可以编写适用于任何机器学习库模型的工具。MLflow Models 提供了几种标准的flavors,MLflow 内置的部署工具支持这些flavors,例如 python_function flavor 描述了如何将模型作为 Python 函数运行。使用这种flavor的模型可以部署到基于 Docker 的 REST 服务器、云服务器或作为用户定义函数。由于在运行期间使用跟踪 API 输出 MLflow Models 作为工件,MLFlow 将自动标记正确的项目。
在 “MLflow Tracking” 中运行实验将记录运行并输出一个带有以下大纲的模型目录:
--- 58dc6db17fb5471a9a46d87506da983f
------- artifacts
------------ model
------------ MLmodel
------------- conda.yaml
------------- input_example.json
------------- model.pkl
------- meta.yaml
------- metrics
------------ training_score
------- params
------------ A
------------ ...
------- tags
------------ mlflow.source.type
------------ mlflow.user
这个目录包含了我们复现实验所需的所有信息。让我们来看看结构。输出的第一行,58dc6db17fb5471a9a46d87506da983f,是实验的 128 位全局唯一标识符(UUID)。在目录的根目录下,有一个名为 meta.yaml 的 YAML 文件,其中包含有关实验的元数据。有了这个,我们已经有了丰富的可追溯信息以及继续实验的坚实基础。还有四个子目录:artifacts 包含记录的任何工件;metrics 包含记录的度量;params 和 tags 包含运行设置的参数和标签。MLmodel 文件可以定义模型支持的多个flavors以及有关模型的其他信息。
MLflow 模型注册表
在实验过程中,我们可能会生成大量的模型。MLflow Model Registry 提供了一个集中存储这些模型的地方,以及用于与它们交互的一组 API 和专用 UI 进行可视化。它还允许我们通过 CLI 管理 MLflow Model 的整个生命周期。模型注册表使我们能够访问关于所有模型的广泛元数据,包括创建模型的实验和运行(其“血统”)、模型版本和阶段(例如,暂存、生产、存档),以及描述模型的任何注释。我们还可以在注册表中为模型添加评论、重命名它们、在不同阶段之间转换并从注册表中提供服务。
注册模型
在训练周期中,我们生成多个模型,以便最终选择最合适的模型。正如您在 第十章 中学到的那样,这些模型可以使用 autolog 隐式记录,也可以使用 log_model 函数显式记录。此时,模型尚未注册到注册表中;要添加它,我们需要 注册 它。有几种不同的方法可以做到这一点。要在运行过程中注册模型,可以包含 registered_model_name 参数,如下所示:
mlflow.keras.log_model(keras_model=model, registered_model_name='tfmodel',
artifact_path="path")
如果注册表中不存在具有该名称的模型,则将此模型注册为新模型的版本 1;如果已经存在具有该名称的模型,则将其注册为新版本。您还可以在完成实验并决定要注册哪个模型后,使用 register_model 方法,传递模型的 run_id 和要注册的名称。有关此及其他注册选项的详细信息,请参阅 文档。
在模型阶段之间过渡
在高层次上,机器学习模型的生命周期有五个阶段:开发、验证、暂存、生产和存档。MLflow 允许我们连接到 CI/CD 工具,以在这些阶段之间转换我们的模型。为此,我们需要编写专用脚本来监听模型状态变更事件,并在更新时触发所需的脚本。您还可以利用 Webhooks 或其他您喜欢的机制。
当我们首次使用对应模型风格(Keras 或 TensorFlow)的 log_model API 记录模型时,它被赋予 None 状态,表示它处于开发阶段。要将模型从开发阶段提升到数据库中的暂存阶段,可以使用 MlflowClient API:
client = MlflowClient()
Model_version = client.transition_model_version_stage(
name='tfmodel',
version=3,
stage="Staging"
)
MlflowClient 是与跟踪服务器交互的 Python 客户端。transition_model_version_stage 函数允许我们更新模型阶段并调用所需的 CI/CD 脚本。stage 参数接受以下值:Staging|Archived|Production|None。将此参数设置为 Staging,在配置了跟踪模型状态的环境中,将打开一个请求,将模型从 None 状态转换为 Staging 状态,以进行在暂存环境中的集成测试。我们将在 第十章 中更详细地讨论这个问题。
大规模使用 MLflow
正如前面提到的,MLflow Tracking 存储实验和运行相关的信息以及与运行相关的工件(模型相关文件)。它使用两个组件进行存储,可以是本地或远程的:一个后端存储用于实验的信息(运行、参数、指标、标签、注释等),还有一个工件存储用于存储模型本身以及任何相关的文件、对象、模型摘要等。像大多数框架一样,MLflow 提供了各种配置选项,包括存储选项。如果您在本地机器上工作,可以将工件和实验信息保存到本地磁盘。如果您在云端笔记本或其他远程服务器上工作,可以将项目保存到可用的存储空间上。图 3-5 展示了不同的存储选项。

图 3-5. MLflow Tracking 存储选项
工件存储可以是任何本地或远程文件存储(尽管请注意本地文件存储不是可扩展的选项)。后端存储可以是本地或远程文件存储,也可以是数据库。当在本地机器上运行 MLflow 时,后端和工件存储可以共享本地文件系统上的单个目录,或者您可以选择将与实验相关的实体存储在诸如 PostgreSQL 或 SQLite 等数据库中。图 3-6 展示了在这种情况下表层次结构可能看起来如何。

图 3-6. MLflow 跟踪服务器 SQL 表层次结构
让我们深入探讨一个潜在的可扩展解决方案。MLflow 支持分布式架构,在此架构中,跟踪服务器、后端存储和工件存储位于远程主机上。为了扩展和团队协作,这种配置优于在localhost上运行。图 3-7 展示了这种情况可能的外观。正如您所见,这里可以有多个主机。在这里,用户在其本地机器上运行 MLflow,而跟踪服务器本身和两个存储则位于远程。

图 3-7. 分布式配置示例
后端存储可以是文件存储或数据库支持的存储,但为了实现可扩展性,我们将使用 PostgreSQL(一个由社区广泛支持的开源数据库)。MLflow 在 Python 中使用SQLAlchemy与之连接。对于我们的工件存储,我们应选择一个适合大数据的位置。我们可以在从命令行运行跟踪服务器时使用artifact-root参数来配置这一点。
警告
开发人员和数据科学家之间经常存在巨大的知识差距;前者是软件构建的专家,后者则是构建机器学习模型和解决业务问题的专家。这有时会导致编码最佳实践与数据科学最佳实践之间的不和谐,尤其是在处理大规模数据或复杂系统时。例如,如果我们的数据集相对较小(比如,100 MB),我们可以选择将数据集与机器学习实验代码和输出一起保存在 Git 存储库中。然而,随着我们逐步推进机器学习的生产化阶段——开发、验证、在测试环境中测试等——为了遵循围绕隐私、访问控制等的组织规则,我们通常需要使用云存储解决方案(通常是基于云的对象存储服务,如 Azure Blob、AWS S3 等)。重要的是:不要将大型数据文件作为 MLflow 的产物存储。
总结
在这一点上,您已经对 MLflow 有了相当不错的理解,并且知道如何使用它来管理您的机器学习实验。这是一项重要的任务,也是进行机器学习实验与将其作为组织研发生命周期的一部分实际使用之间的区别。在我们讨论部署时,我们将在第十章中进一步讨论 MLflow。在下一章中,我们将深入探讨与数据本身的工作:摄取它,预处理它,并探索它。
第四章:数据摄取、预处理和描述性统计
您很可能熟悉“垃圾进、垃圾出”的说法。这很好地捕捉了错误、不正确或荒谬的数据输入将始终产生错误输出的概念。在机器学习的背景下,它还强调了我们对数据摄取、预处理和统计理解(探索和准备数据)所付出的注意将对整个过程的成功产生影响。有错误的数据摄取直接影响数据质量,而错误的预处理也是如此。为了了解手头的数据及其正确性,我们利用描述性统计;这是过程的一个重要部分,因为它帮助我们验证我们使用的数据质量良好。数据科学家、机器学习工程师和数据工程师通常会花费大量时间在这些关键步骤上工作、研究和改进,我将在本章中为您详细介绍这些步骤。
在我们开始之前,让我们理解一下流程。假设最初,我们的数据存储在磁盘上、数据库中或云数据湖中。以下是我们将遵循的步骤,以了解我们的数据:
-
摄取。我们首先将数据以其当前形式移入 DataFrame 实例中。这也称为数据的反序列化。更准确地说,在 Spark 中,在这一步骤中,我们定义了一个计划来反序列化数据,将其转换为 DataFrame。这一步通常会根据现有数据推断出一个基本的模式。
-
预处理。这涉及将数据编组以适应我们期望的模式。如果我们将数据加载为字符串,而我们需要它作为浮点数,则将转换数据类型并根据需要调整值以适应期望的模式。这可能是一个复杂且容易出错的过程,特别是在多个来源的数据以多 TB 规模同步时,需要提前规划。
-
资格认证。这一步骤包括使用描述性统计来理解数据及其处理方法。
步骤 2 和步骤 3 可能会重叠,因为我们可能会根据步骤 3 中计算出的统计信息对数据进行更多预处理。
现在,您对步骤有了一个大致的了解,让我们更深入地了解每一个步骤。
使用 Spark 进行数据摄取
Apache Spark 足够通用,允许我们扩展其 API 并开发专用连接器,以便使用连接器机制摄取(和持久化/存储)数据到任何类型的存储中。开箱即用,它支持各种文件格式,如 Parquet、CSV、二进制文件、JSON、ORC、图像文件等。
Spark 还使我们能够处理批处理和流处理数据。Spark 的批处理 API用于处理存储在文件存储或数据库中的离线数据。使用批处理数据时,数据集大小固定且不变化,我们不会获得任何新数据来处理。对于处理流数据,Spark 有一个称为 DStream 或简称 Streaming 的旧 API,以及一个更新、改进的称为结构化流处理的 API。结构化流处理提供了一个用于分布式连续处理结构化数据流的 API。它允许您一次处理多个数据记录,将输入流分成微批次。请记住,如果您的数据不是结构化的,或者格式不同,您将需要使用旧的 DStream API,或构建一个解决方案来自动化模式变化而不会失败。
在本章中,我们将专注于离线、冷数据的批处理。使用冷数据构建机器学习模型是各种用例中最常见的方法,例如视频制作、金融建模、药物发现、基因组研究、推荐引擎等等。我们将在第十章中讨论处理具有流数据的情况。
使用 format 函数指定具有定义数据格式的批量读取,例如:
df = spark.read.format("image")
可以通过DataFrameReader 类来实现这一点。您可以通过其 options API 配置它,定义如何加载数据并推断模式(如果文件格式尚未提供),或者提取元数据(如果已提供)。
不同的文件格式可能具有模式,也可能没有,这取决于数据是结构化、半结构化还是非结构化,当然也取决于格式本身的实现。例如,JSON 格式被认为是半结构化的,开箱即用时它不维护关于行、列和特征的元数据。因此,在 JSON 中,模式是推断的。
另一方面,诸如 Avro 和 Parquet 这样的结构化数据格式具有描述数据模式的元数据部分。这使得可以提取模式。
处理图像
图像文件可以以未压缩、压缩或矢量格式存储数据。例如,JPEG 是一种压缩格式,TIFF 是一种未压缩格式。
我们保存数字数据以便于将其轻松转换为计算机显示器或打印机。这是光栅化的结果。光栅化的主要任务是将图像数据转换为像素网格,其中每个像素具有定义其颜色和透明度的若干位。为特定设备光栅化图像文件时,需要考虑设备设计用于处理的每像素位数(颜色深度)情况。当我们处理图像时,需要关注文件格式并了解它是压缩还是未压缩(在“图像压缩和 Parquet”中详细了解)。
在本章中,我们将使用一个名为Caltech 256的 Kaggle 图像数据集,其中包含使用 JPEG 压缩格式的图像文件。我们的第一步将是将它们加载到一个 Spark DataFrame 实例中。为此,我们可以在图像或二进制两种格式选项之间进行选择。
注意
当您的程序处理 DataFrame 的load操作时,Spark 引擎不会立即将图像加载到内存中。如第二章所述,Spark 使用惰性评估,这意味着它不会实际加载图像,而是创建一个计划,以便在必要时加载。该计划包含有关实际数据的信息,例如表字段/列、格式、文件地址等。
图像格式
Spark MLlib 具有专用的图像数据源,使我们能够从目录中加载图像到 DataFrame 中,并使用 OpenCV 类型读取和处理图像数据。在本节中,您将了解更多相关信息。
OpenCV是用于计算机视觉工作负载的基于 C/C++的工具。MLlib 功能允许您将压缩图像(.jpeg、.png等)转换为 OpenCV 数据格式。加载到 Spark DataFrame 时,每个图像都存储为单独的行。
下列是支持的未压缩 OpenCV 类型:
-
CV_8U
-
CV_8UC1
-
CV_8UC3
-
CV_8UC4
其中8表示位深度,U表示无符号,C``*x*表示通道数。
警告
自 Spark 3.1.1 起,图像大小限制为 1 GB。由于 Spark 是开源的,您可以在其源代码中跟踪图像大小支持的更新:在ImageSchema.scala对象的decode函数定义中,assert(imageSize < 1e9, "image is too large")这一行告诉我们,限制是 1 GB。
要探索相对较小的文件,Spark 提供的图像格式是开始处理图像并实际查看渲染输出的绝佳方式。但是,对于通用工作流程,在处理过程中实际上不需要查看图像本身的情况下,建议使用二进制格式,因为它更高效,能够更快地处理较大的图像文件。此外,对于较大的图像文件(≥1 GB),二进制格式是唯一的处理方式。而 Spark 的默认行为是对数据进行分区,但图像并不分区。对于像图像这样的空间对象,我们可以使用 tiling 来代替分区,即将图像分解成一组具有所需形状的片段(瓦片)。图像的分瓦处理可以作为数据预处理的一部分。未来,为了与语言保持一致,即使在讨论图像或空间对象时,我也会提到分区。
二进制格式
Spark 从版本 3.0 开始支持 二进制文件数据源¹,这使得它能够读取二进制文件并将其转换为表中的单个记录。记录包含原始内容作为 BinaryType 和一些元数据列。使用二进制格式读取数据会生成一个包含以下列的 DataFrame:
-
path: StringType -
modificationTime: TimestampType -
length: LongType -
content: BinaryType
我们将使用这种格式来加载 Caltech 256 数据,如下面的代码片段所示:
from pyspark.sql.types import BinaryType
spark.sql("set spark.sql.files.ignoreCorruptFiles=true")
df = spark.read.format("binaryFile")
.option("pathGlobFilter", "*.jpg")
.option("recursiveFileLookup", "true")
.load(*file_path*)
如 第二章 中所讨论的,我们数据集中的数据位于嵌套的文件夹层次结构中。recursiveFileLookup 可以使我们读取嵌套的文件夹, 而 pathGlobFilter 选项允许我们过滤文件,并仅读取扩展名为 .jpg 的文件。
再次注意,这段代码实际上并不会将数据加载到执行器中进行计算。正如之前讨论的那样,由于 Spark 的惰性评估机制,执行不会在触发动作之前开始——驱动程序会积累各种转换请求和查询,优化它们,并仅在有特定的动作请求时才执行。这使我们能够节省计算成本,优化查询,并增加代码的整体可管理性。
处理表格数据
由于 Spark 提供了各种文件格式的开箱即用连接器,因此处理表格数据变得非常简单。例如,在书籍的 GitHub 仓库 中,datasets 下你会找到 CO[2] 由车辆排放的数据集,数据以 CSV 格式存储。使用连接器函数 .format("csv") 或直接使用 .csv(*file_path*),我们可以轻松地将其加载到 DataFrame 实例中。
虽然即使使用InferSchema选项,Spark 在定义 CSV 文件中的列时往往会将其视为包含字符串,即使它们包含整数、布尔值等等,也需要注意模式。因此,在开始时,我们的主要工作是检查和校正列的数据类型。例如,如果输入 CSV 文件中的某列包含 JSON 字符串,则需要编写专用代码来处理此 JSON。
请注意,每个 Spark 数据源连接器都具有独特的属性,为您提供处理损坏数据的一系列选项。例如,通过将spark.sql.csv.parser.columnPruning.enabled设置为False,您可以控制列修剪行为,以避免修剪格式或数据损坏的列,或者设置为True以进行相反的行为。您还可以利用mode参数使修剪更具体,例如使用PERMISSIVE将字段设置为null,使用DROPMALFORMED来忽略整个记录,或使用FAILFAST在处理损坏记录时抛出异常。请参见以下代码片段的示例:
df = spark.read.option("mode","FAILFAST")
.option("delimiter","\t")
.csv(*file_path*)
加载数据并将其反序列化为 DataFrame 后,现在是预处理它的时候了。在继续之前,我建议将数据保存为具有类型化模式和明确定义列名和类型的格式,例如 Parquet 或 Avro。
数据预处理
预处理是将数据转换为所需状态的艺术,无论是强类型模式还是算法所需的特定数据类型。
预处理与处理对比
当您刚开始学习机器学习时,区分预处理和处理可能会很困难。预处理指的是我们在验证数据集本身之前所做的所有工作。在尝试使用描述性统计分析数据或进行特征工程之前,这项工作已完成,而这两者都属于处理的范畴。这些程序是相互交织的(见图 4-1),我们可能会一遍又一遍地重复执行它们,直到将数据达到所需状态。Spark 为我们提供了完成这些任务所需的所有工具,无论是通过 MLlib 库还是 SQL API。

图 4-1. 机器学习期间的交织过程
为什么要预处理数据?
预处理数据或将其整理成所需的模式是必不可少的步骤,在我们甚至开始探索数据之前,更别说进行特征工程了。它如此重要的原因在于,机器学习算法通常有专门的输入要求,例如特定的数据结构和/或数据类型。在一些学术研究论文中,您可能会发现这个过程被称为数据编组。
为了让您了解在将数据传递给 MLlib 算法之前可能需要执行的预处理类型,让我们快速查看不同种类算法的高级需求:
分类和/或回归算法
对于分类和回归,您需要将数据转换为一个类型为vector(密集或稀疏)且值为double或float的列。通常将此列命名为features,但稍后您可以灵活设置输入列的名称。
推荐算法
对于推荐,您会希望有一个userCol列,其值为integer,表示用户 ID;一个itemCol列,其值为integer,表示项目 ID;以及一个ratingCol列,其值为double或float,表示用户对项目的评分。
无监督学习
在处理需要无监督学习方法的数据时,您通常需要一个类型为vector的列来表示您的特征。
数据结构
根据其结构,数据通常需要处理才能完全利用。数据可分为三类:
结构化
结构化数据具有高度的组织性。它以预定义的模式格式存储,如逗号分隔值(.csv)文件或数据库中的表格。有时也称为表格数据。
半结构化
半结构化数据具有一定的组织性,但结构不太严格,模式不固定。可能存在标签以分隔元素并强制层次结构,例如 JSON 文件中的情况。这类数据可能需要在用于机器学习算法之前进行预处理。
无结构化
无结构化数据没有定义的组织和特定的格式——比如.jpeg图像、.mp4视频文件、音频文件等。这些数据通常需要在建模之前进行重要的预处理。今天我们创建的大多数数据都是无结构化数据。
MLlib 数据类型
MLlib 具有自己专用的数据类型,用作机器学习算法的输入。要使用 Spark MLlib,您需要将列转换为这些类型之一——这就是为什么预处理和转换数据是您将以互锁方式执行的过程。在底层,Spark 使用私有对象VectorUDT和MatrixUDF,这些对象抽象了多种类型的本地向量(密集、稀疏、带标签点)和矩阵(本地和分布式)。这些对象允许与spark.sql.Dataset功能轻松交互。从高层次来看,这两种类型的对象如下所示:
向量
向量对象表示数值向量。您可以将其视为数组,就像在 Python 中一样,只是这里的索引类型为integer,值类型为double。
矩阵
矩阵对象表示一个数值矩阵。它可以是本地的一台机器上的矩阵,也可以分布在多台机器上。本地版本中,索引类型为integer,值类型为double。分布式版本中,索引类型为long,值类型为double。所有矩阵类型都由向量表示。
注意
为了简化我们使用 Python 工具的工作,MLlib 将 NumPy 数组和 Python 列表识别为密集向量,并将 SciPy 的csc_matrix识别为只有一列的稀疏向量。这使我们能够更轻松地在不同工具之间进行转换。在使用多个工具时请记住这一点。
理解 MLlib 中稀疏和密集向量的表示方式是很有必要的,因为你会在文档和实验中经常遇到它们。Spark 会根据任务自动决定创建哪种类型的向量。
注意
第三种向量类型是带标签的点;它表示数据点的特征和标签,可以是密集的或稀疏的。在 MLlib 中,带标签的点用于监督学习算法。
让我们从一个密集向量开始。这里是一个例子:
Row(features=DenseVector([1.2, 543.5, 0.0, 0.0, 0.0, 1.0, 0.0]))]
一个DenseVector由一个固定大小的值数组组成。在内存消耗方面,它比SparseVector要低效,因为它明确地为指定的向量大小创建内存空间,包括任何空/默认值。在我们的例子中,DenseVector有七个值,但其中四个是空的(0.0是默认值,所以这些被视为空值)。
一个SparseVector是对具有空/默认值的DenseVector的优化。让我们看一下我们的例子DenseVector如何被转换成SparseVector:
Row(features=SparseVector(7,{0:1.2, 1: 543.5, 5:1.0}))
第一个数字表示向量的大小(7),而映射({...})表示索引及其值。在这个向量中,只有三个值需要存储:索引 0 处的值为1.2,索引 2 处的值为543.5,索引 5 处的值为1。其他索引处的值不需要存储,因为它们都是默认值。
让我们看一个更大的向量示例:
[Row(features=SparseVector(50, {48: 9.9, 49: 6.7}))]
在这种情况下,向量大小为 50,我们只有两个值需要存储:索引 48 处的9.9和索引 49 处的6.7。
一个SparseVector也可以看起来像这样:
(50,[48,49],[9.9,6.7])
其中第一个数字(这里是50)表示大小,第一个数组([48,49])表示存储在SparseVector中的索引,第二个数组([9.9,6.7])表示这些索引处的值。因此,在这个例子中,索引 48 处的值是9.9,索引 49 处的值是6.7。其余的向量索引都是0.0。
为什么我们需要这两种类型的向量?在机器学习中,一些算法(例如朴素贝叶斯分类器)对密集向量特征的处理效果更好,因此可能在使用稀疏向量特征时表现较差。
如果你的机器学习算法不能很好地处理你拥有的特征,你可以做些什么呢?首先,接下来描述的过程将帮助你了解数据并使其适应你的机器学习目标。如果需要,你可以尝试收集更多数据。你还可以选择在稀疏向量上表现更好的算法。毕竟,这是构建机器学习模型过程的一部分!
提示
请确保收藏并使用MLlib 文档。有一个专注于改进 Spark 文档的社区项目,每天都在进步变得更好。
使用 MLlib 变换器进行预处理
变换器是 Apache Spark MLlib 库中命名为pyspark.ml.feature的一部分。除了变换器外,它还提供提取器和选择器。其中许多基于机器学习算法以及统计或数学计算。对于预处理,我们将利用变换器 API,但在不同情况下,你可能会发现其他 API 也很有帮助。
Spark 中的变换器是接受 DataFrame 作为输入并输出具有所需列的新 DataFrame 的算法或函数。换句话说,你可以把它们看作将给定输入转换为相应输出的工具。变换器使我们能够扩展、转换或修改现有列。我们可以大致分为以下几类:文本数据变换器、分类特征变换器、连续数值变换器和其他。
以下各节的表格将指导你何时使用每个变换器。你应该知道,由于变换器的统计性质,某些 API 可能需要更长的时间才能完成。
处理文本数据
文本数据通常包括代表单词、句子或任何形式自由流动文本的文档。这是固有的非结构化数据,通常具有噪声。在机器学习中,噪声数据是指影响模型性能的无关或无意义数据。例如,停用词如a、the、is和are。在 MLlib 中,你会找到专门用于提取停用词等功能,以及更多!MLlib 为处理文本数据输入提供了丰富的功能集。
对于文本,我们希望将其输入并转换为可以轻松馈送到机器学习算法中的格式。MLlib 中的大多数算法期望输入为结构化数据,以表格形式呈现,带有行、列等。此外,为了在内存消耗方面高效率,我们通常会对字符串进行哈希处理,因为字符串值占用的空间比整数、浮点数或布尔值多。在将文本数据添加到你的机器学习项目之前,你首先需要使用文本数据变换器清理它。要了解常见 API 及其用法,请查看表格 4-1。
表格 4-1. 文本数据变换器
| API | 用法 |
|---|---|
Tokenizer |
通过空格分割文本列为单词列表。基于正则表达式(regex)\\s,匹配单个空格字符。Tokenizer API 内部使用java.lang.String.split函数。 |
RegexTokenizer |
根据输入的正则表达式(默认为\\s+,匹配一个或多个空白字符)分割文本。通常用于在空白字符和/或逗号以及其他支持的分隔符上分割。RegexTokenizer比Tokenizer更耗费计算资源,因为它使用scala.util.matching正则表达式函数。提供的正则表达式应符合 Java 正则表达式语法。 |
HashingTF |
接受一个字符串数组并从中生成哈希。在许多自由文本场景中,您需要先运行Tokenizer函数。这是最常用的转换器之一。 |
NGram |
给定整数 *n*,提取一个*n*个标记的序列。输入列只能是字符串数组。要将文本字符串转换为字符串数组,请先使用Tokenizer函数。 |
StopWordsRemover |
接受一系列文本并删除默认的停用词。您可以指定语言、大小写敏感性并提供自己的停用词列表。 |
在我们继续之前,让我们生成一个合成数据集,用于接下来的示例:
sentence_data_frame = spark.createDataFrame([
(0, "Hi I think pyspark is cool ","happy"),
(1, "All I want is a pyspark cluster","indifferent"),
(2, "I finally understand how ML works","fulfilled"),
(3, "Yet another sentence about pyspark and ML","indifferent"),
(4, "Why didn’t I know about mllib before","sad"),
(5, "Yes, I can","happy")
], ["id", "sentence", "sentiment"])
我们的数据集有三列:id,类型为int,以及sentence和sentiment,类型为string。
转换包括以下步骤:
-
自由文本 → 单词列表
-
单词列表 → 有意义单词列表
-
选择有意义的值
准备好了吗?开始转换吧!我们的第一步是将自由文本转换为单词列表。为此,我们可以使用Tokenizer或RegexTokenizer API,如下所示:
from pyspark.ml.feature import Tokenizer
tokenizer = Tokenizer(inputCol="sentence", outputCol="words")
tokenized = tokenizer.transform(sentence_data_frame)
这告诉Tokenizer将sentence列作为输入,并生成一个新的 DataFrame,添加一个名为words的输出列。请注意,我们使用了transform函数——转换器始终具有此函数。图 4-2 显示了我们带有新增words列的新 DataFrame。

图 4-2. 带有words列的新 DataFrame
下一步是移除停用词,即在我们的机器学习过程中可能提供的价值不大的词语。为此,我们将使用StopWordsRemover:
from pyspark.ml.feature import StopWordsRemover
remover = StopWordsRemover(inputCol="words", outputCol="meaningful_words")
meaningful_data_frame = remover.transform(tokenized)
*`# I use the show function here for educational purposes only; with a large`*
*`# dataset, you should avoid it.`*
meaningful_data_frame.select("words","meaningful_words").show(5,truncate=False)
示例 4-1 显示了带有新meaningful_words列的 DataFrame。
示例 4-1. 带有meaningful_words列的新 DataFrame
+-------------------------------------------------+-------------------------------------+
|words |meaningful_words |
+-------------------------------------------------+-------------------------------------+
|[hi, i, think, pyspark, is, cool] |[hi, think, pyspark, cool] |
|[all, i, want, is, a, pyspark, cluster] |[want, pyspark, cluster] |
|[i, finally, understand, how, ml, works] |[finally, understand, ml, works] |
|[yet, another, sentence, about, pyspark, and, ml]|[yet, another, sentence, pyspark, ml]|
|[why, didn't, i, know, about, ml lib, before] |[know, mllib] |
|[yes,, i, can] |[yes,] |
+-------------------------------------------------+-------------------------------------+
从名义分类特征到索引
我们可以使用的策略之一是将以string格式表示的离散分类值转换为数字形式的索引,以加快机器学习过程。这些值可以是离散的或连续的,具体取决于我们计划使用的机器学习模型。表 4-2 列出了最常见的 API 并描述了它们的使用情况。
表 4-2. 分类特征转换器
| API | 使用方法 |
|---|---|
StringIndexer |
将字符串列编码为索引,其中第一个(从索引 0 开始)是列中最频繁的值,依此类推。用于使用监督数据进行更快速训练,其中列是类别/标签。 |
IndexToString |
StringIndexer的反向操作:将标签索引列映射回包含原始标签的列。通常在训练过程后用于检索标签类别。 |
OneHotEncoder |
将表示为标签索引的列的分类特征映射到二进制向量列中,每行最多有一个值为 1,表示该类别。这允许期望连续特征的机器学习算法(如逻辑回归)使用分类特征,通过将其映射到连续特征中。 |
VectorIndexer |
类似于StringIndexer,接受向量列作为输入并将其转换为类别索引。 |
我们生成的 DataFrame 包括一个表示文本情感的列。我们的情感类别是happy、fulfilled、sad和indifferent。让我们使用StringIndexer将它们转换为索引:
from pyspark.ml.feature import StringIndexer
indexer = StringIndexer(inputCol="sentiment", outputCol="categoryIndex")
indexed = indexer.fit(meaningful_data_frame).transform(meaningful_data_frame)
indexed.show(5)
在这段代码片段中,我们创建了一个新的StringIndexer实例,它以sentiment列作为输入,并创建了一个类型为double的新 DataFrame,其中包含一个categoryIndex列。我们首先调用fit函数,并提供我们 DataFrame 的名称。这一步骤是训练索引器必不可少的,它通过扫描sentiment列来构建索引和类别之间的映射。这个函数由另一个预处理工具执行,称为估计器,我们将在第六章中更详细地讨论它。在拟合估计器之后,我们调用transform函数来计算新的索引。示例 4-2 展示了包含新categoryIndex列的 DataFrame。
示例 4-2. 包含categoryIndex列的 DataFrame
+---+--------------------+-----------+--------------------+--------------------+-------------+
| id| sentence| sentiment| words| meaningful_words|categoryIndex|
+---+--------------------+-----------+--------------------+--------------------+-------------+
| O|Hi I think pyspar...| happy|[hi, i, think, py...|[i, think, pyspa... | 0.0|
| 1|All I want is a p...|indifferent|[all, i, want, is...|[want, pyspark, c...| 1.0|
| 2|I finally underst...| fulfilled|[i, finally, unde...|[finally, underst...| 2.0|
| 3|Yet another sente...|indifferent|[yet, another, se...|[yet, another, se...| 1.0|
| 4|Why didn't I know...| sad|[why, didn't, i, ...| [know, mllib]| 3.0|
| 5| Yes, I can| happy| [yes,, i, can]| [yes,]| 0.0|
+---+--------------------+-----------+--------------------+--------------------+-------------+
结构化连续数值数据
在某些情况下,我们可能有连续的数值数据需要进行结构化。我们通过提供阈值或多个阈值来采取行动或进行分类决策。
注意
连续的数值通常以向量形式表示,常见的数据类型包括integer、float和double。
例如,当我们拥有特定情感的分数时,如示例 4-3 所示,我们可以在给定分数落入定义范围时采取行动。想象一下一个客户满意度系统——我们希望我们的机器学习模型基于客户情感分数推荐一个行动。假设我们的最大客户有一个sad分数为0.75,我们调用客户讨论如何改善其体验的阈值为sad分数超过0.7。在这种情况下,我们会希望联系客户。阈值本身可以通过手动定义或使用机器学习算法或纯统计方法来定义。未来,假设我们拥有一个 DataFrame,其中每种情感都有一个专门的分数。该分数是一个连续数字,范围在[0,1]内,指定情感类别的相关性。我们想要实现的业务目标将决定使用的阈值和为未来推荐数据指定的结构。
示例 4-3. 每个类别的情感分数的 DataFrame
+-----------+-----+-----------+---------+----+
|sentence_id|happy|indifferent|fulfilled| sad|
+-----------+-----+-----------+---------+----+
| 0| 0.01| 0.43| 0.3| 0.5|
| 1|0.097| 0.21| 0.2| 0.9|
| 2| 0.4| 0.329| 0.97| 0.4|
| 3| 0.7| 0.4| 0.3|0.87|
| 4| 0.34| 0.4| 0.3|0.78|
| 5| 0.1| 0.3| 0.31|0.29|
+-----------+-----+-----------+---------+----+
在处理数据类型时,请根据需要进行类型转换。您可以利用 Spark SQL API,如下所示:
cast_data_frame = sentiment_data_frame.selectExpr("cast(happy as double)")
以下是处理连续数值数据的一些常见策略:
固定分桶/分箱
这是通过手动方式完成的,可以通过提供特定的阈值或提供一系列桶来对数据进行二值化。这个过程类似于我们之前讨论的关于结构化连续数据的内容。
自适应分桶/分箱
整体数据可能存在偏斜,某些值频繁出现,而其他值则较少。这可能会使得手动为每个桶指定范围变得困难。自适应分桶是一种更高级的技术,其中转换器计算数据的分布,并设置桶的大小,使得每个桶大致包含相同数量的值。
表 4-3 列出了 MLlib 中最常用的连续数值转换器。根据您的需求选择最适合项目的转换器。
表 4-3. 常见的连续数值转换器
| API | 用法 |
|---|---|
Binarizer |
将数值特征转换为二进制特征,给定一个阈值。例如,当阈值为 0.7 时,5.1 会转换为 1,而 0.6 则会转换为 0。 |
Bucketizer |
获取一个连续数值列,并将其转换为桶的列,其中每个桶表示数值范围的一部分,例如,0 到 1,1 到 2 等。 |
MaxAbsScaler |
获取一个float值向量,并将每个值除以输入列中的最大绝对值。 |
MinMaxScaler |
将数据缩放到期望的min和max值,其中默认范围为[0,1]。 |
Normalizer |
将double值的向量转换为归一化的非负实数值,范围在 0 到 1 之间。默认的p-范数为 2,实现欧几里德范数用于计算距离并将float范围减少到[0,1]。 |
QuantileDiscretizer |
接受连续数值值的列,并将其转换为包含分箱分类值的列,输入最大箱数可选地确定近似分位值。 |
RobustScaler |
类似于StandardScaler,接受float值的向量并生成给定输入分位数范围的特征缩放后的向量。 |
StandardScaler |
估计器,接受float值的向量,并旨在根据输入的标准偏差和均值来居中数据。 |
额外的转换器
MLlib 提供许多额外的转换器,使用统计数据或抽象其他 Spark 功能。Table 4-4 列出了其中一些并描述了它们的用途。请注意,更多转换器会定期添加,代码示例可在Apache Spark GitHub 存储库的examples/src/main/python/ml/目录中找到。
表 4-4. 额外的转换器
| API | 使用 |
|---|---|
DCT |
实现离散余弦变换,接受时间域数据点的向量,并将其转换为频率域。用于信号处理和数据压缩(例如图像、音频、无线电和数字电视)。 |
ElementwiseProduct |
接受带有数据向量的列和相同大小的变换向量,并输出它们的乘积,该乘积是可结合、可分配和可交换的(基于 Hadamard 乘积)。用于缩放现有向量。 |
Imputer |
接受数值类型的列,并使用列均值或中位数值来填充数据集中的缺失值。在使用不能处理缺失值的估算器时很有用。 |
Interaction |
接受一个不同的向量或double值列,并输出一个包含所有可能值组合的向量列的乘积。 |
PCA |
实现主成分分析,将潜在相关值的向量转换为非相关值,输出数据的主要组成部分(主成分)。这在预测模型和降维中很有用,但可能牺牲解释性。 |
PolynomialExpansion |
接受特征向量并将其扩展为n次多项式空间。值为 1 表示不扩展。 |
SQLTransformer |
接受一个 SQL 语句(Spark 支持的任何SELECT子句)并根据语句转换输入。 |
VectorAssembler |
接受向量列列表并将它们连接成数据集中的一列。对于仅接受单列的各种估计器很有用。 |
图像数据预处理
图像数据在机器学习应用中很常见,也需要预处理才能在机器学习工作流中继续前进。但图像与我们之前看到的数据类型不同,它们需要不同类型的处理过程。根据实际数据的情况,可能会涉及更多或更少的步骤,但最常见的路径包括以下三个动作:
-
提取标签
-
将标签转换为索引
-
提取图像大小
让我们使用我们的示例数据集来走一遍这些步骤,看看它们涉及到什么。
提取标签
我们的图像数据集具有嵌套结构,其中目录名指示图像的分类。因此,每个图像在文件系统上的路径包含其标签。我们需要提取这些数据,以便稍后使用。大多数原始图像数据集遵循这种模式,这是我们将要对图像进行的预处理的重要部分。在将图像加载为BinaryType后,我们得到一个包含名为path的String类型列的表。这包含我们的标签。现在,是时候利用字符串操作来提取这些数据了。让我们看一个例子路径:.../256_ObjectCategories/198.spider/198_0089.jpg。
在这种情况下,标签实际上是一个索引和一个名称:198.spider。这是我们需要从字符串中提取的部分。幸运的是,PySpark SQL 函数为我们提供了regexp_extract API,可以根据我们的需求轻松操作字符串。
让我们定义一个函数,它将获取path_col并使用正则表达式"256_ObjectCategories/([^/]+)"来提取标签:
from pyspark.sql.functions import col, regexp_extract
def extract_label(path_col):
"""Extract label from file path using built-in SQL function"""
return regexp_extract(path_col,"256_ObjectCategories/([^/]+)",1)
现在我们可以通过从 Spark SQL 查询中调用这个函数来创建一个带有标签的新 DataFrame:
images_with_label = df_result.select(
col("path"),
extract_label(col("path")).alias("label"),
col("content"))
我们的images_with_label DataFrame 由三列组成:两个名为path和label的字符串列,以及一个名为content的二进制列。
现在我们有了标签,是时候将它们转换为索引了。
将标签转换为索引
如前所述,我们的label列是一个字符串列*。这对于机器学习模型可能构成挑战,因为字符串在内存使用上较重。理想情况下,我们表中的每个字符串在被输入机器学习算法之前都应转换为更高效的表示,除非这是真正必要的。由于我们的标签的格式是*{index.name}*,我们有三个选项:
-
从字符串本身中提取索引,利用字符串操作。
-
使用 Spark 的
StringIndexer提供一个新的索引,如“从名义分类特征到索引”中所讨论的。 -
使用 Python 定义一个索引(在 Caltech 256 数据集中,只有 257 个索引,范围在
[1,257]内)。
在我们的情况下,第一种选项是处理这个问题最干净的方式。这种方法将允许我们避免在原始文件的索引和数据集中的索引之间维护映射。
提取图像大小
最后一步是提取图像大小。我们在预处理过程中执行此操作,因为我们确切地知道我们的数据集包含不同大小的图像,但了解数据并为我们决定算法提供信息通常是有用的操作。某些机器学习算法需要我们对图像有一个统一的大小,提前知道我们正在处理的图像大小可以帮助我们做出更好的优化决策。
由于 Spark 尚未提供此功能,我们将使用Pillow(也称为 PIL),这是一个友好的 Python 库,用于处理图像。为了高效地提取所有图像的宽度和高度,我们将定义一个 pandas 用户定义的函数(UDF),可以在我们的 Spark 执行器上以分布方式运行。使用pandas_udf作为装饰器定义的 pandas UDF 使用 Apache Arrow 进行了优化,并且对于分组操作(例如,在groupBy之后应用时)更快。
分组允许 pandas 执行向量化操作。对于这些用例,Spark 上的 pandas UDF 会更高效。对于像a*b这样的简单操作,Spark UDF 就足够了,并且会更快,因为它的开销更少。
我们的 UDF 将接收一系列行并并行操作它们,使其比传统的逐行操作快得多:
from pyspark.sql.functions import col, pandas_udf
from PIL import Image
import pandas as pd
@pandas_udf("width: int, height: int")
def extract_size_udf(content_series):
sizes = content_series.apply(extract_size)
return pd.DataFrame(list(sizes))
现在我们有了这个函数,我们可以将它传递给 Spark 的select函数来提取图像大小信息:
images_df = images_with_label.select(
col("path"),
col("label"),
extract_size_udf(col("content")).alias("size"),
col("content"))
图像大小数据将被提取到一个新的 DataFrame 中,该 DataFrame 包含一个size列,其类型为struct,包含width和height:
size:struct
width:integer
height:integer
警告
请注意,使用extract_size_udf函数将所有图像从 JVM(Spark 在内部使用 Scala)传输到 Python 运行时使用 Arrow 计算大小,然后将大小再传输回 JVM。在处理大型数据集时尤其如此,特别是如果您不使用分组时,可能值得在 JVM/Scala 级别实现大小的提取。在实现各个阶段的机器学习数据预处理时,请牢记这样的考虑因素。
保存数据并避免小文件问题
当你完成所有预处理工作后,将数据保存到冷热存储可能是个好主意,然后再继续下一步。有时这被称为检查点,或者在我们对数据版本满意时保存数据的时间点。保存数据的一个原因是为了实现快速恢复:如果我们的 Spark 集群彻底崩溃,而不是需要从头开始重新计算一切,我们可以从上次的检查点恢复。第二个原因是促进协作。如果您的预处理数据持久保存在存储中并可供同事使用,他们可以利用它开发自己的流程。在处理大型数据集和需要大量计算资源和时间的任务时,这尤为有用。Spark 为我们提供了在多种格式中接收和保存数据的功能。请注意,如果决定保存数据用于协作目的,重要的是记录所有步骤:您进行的预处理、用于实施它的代码、当前的用例、执行的任何调整以及创建的任何外部资源,例如停用词列表。
避免小文件
小文件指的是明显小于存储块大小的任何文件。是的,在像 Amazon S3、Azure Blob 等对象存储中,都有最小块大小!由于存储会使用整个块来保存该文件,无论它有多小,因此比块大小显著小的文件可能会导致磁盘空间浪费。这是一种我们应该避免的额外开销。此外,存储优化为按块大小支持快速读写。但别担心——Spark API 来帮忙!我们可以利用 Spark 的repartition或coalesce函数轻松避免浪费宝贵空间并支付高额的小文件存储成本。
在我们的情况下,由于我们正在处理离线数据,没有特定要求需要在毫秒级完成计算,我们在选择使用哪种方法时更具灵活性。repartition创建全新的分区,通过网络对数据进行重分布,目的是均匀分布在指定数量的分区上(可以高于或低于现有数量)。因此,它的初始成本很高,但后续,Spark 功能将因数据的最佳分布而执行得更快——实际上,在机器学习工作流的任何阶段执行repartition操作时,可以帮助加快计算速度,特别是当我们注意到计算相对缓慢时。另一方面,coalesce函数首先检测现有的分区,然后仅重分布必要的数据。它只能用于减少分区数量,而不能用于增加分区数量,并且因最小化通过网络进行数据传输量而被认为比repartition运行得更快。在某些情况下,coalesce可能根本不会进行数据重分布,并且会默认为批处理本地分区,这使得它对于减少功能非常高效。
由于我们希望精确控制分区的确切数量,并且不需要极快的执行速度,在我们的情况下使用较慢的repartition函数是可以接受的,如下所示:
output_df = output_df.repartition(NUM_EXECUTERS)
请记住,如果时间、效率和最小化网络负载至关重要,则应选择coalesce函数。
图像压缩和 Parquet
假设我们想要将图像数据集保存为 Parquet 格式(如果您对此不熟悉,Parquet 是一种开源的面向列的数据文件格式,旨在实现高效的存储和检索)。在保存为此格式时,默认情况下 Spark 使用名为 Snappy 的压缩编解码器。然而,由于图像已经经过压缩(例如,使用 JPEG、PNG 等),再次压缩它们就没有意义了。我们该如何避免这种情况?
我们保存现有配置的压缩编解码器为字符串实例,将 Spark 配置为使用未压缩编解码器写入 Parquet,保存数据以 Parquet 格式,并将编解码器实例重新分配给 Spark 配置以供未来使用。以下代码片段示例:
# Image data is already compressed, so we turn off Parquet compression
compression = spark.conf.get("spark.sql.parquet.compression.codec")
spark.conf.set("spark.sql.parquet.compression.codec", "uncompressed")
# Save the data stored in binary format as Parquet
output_df.write.mode("overwrite").parquet(save_path)
spark.conf.set("spark.sql.parquet.compression.codec", compression)
描述性统计:了解数据的感觉
机器学习并非魔法——您需要深入了解数据,以便有效地处理它。在开始训练算法之前对数据有扎实的理解,将为您节省大量的时间和精力。幸运的是,MLlib 提供了一个名为pyspark.ml.stat的专用库,其中包含从数据中提取基本统计信息所需的所有功能。
如果这听起来令人生畏,不要担心——您不需要完全理解统计学就能使用 MLlib,尽管一定程度上的熟悉肯定会在您的机器学习之旅中有所帮助。使用统计数据了解数据使我们能够更好地决定选择哪种机器学习算法,识别偏差,并估计数据的质量——如前所述,如果你输入的是垃圾数据,那么输出也将是垃圾。将低质量数据输入到机器学习算法中将导致性能低下的模型。因此,这部分内容是必不可少的!
话虽如此,只要我们对数据的外观建立有意识的假设,知道我们可以接受什么,不能接受什么,我们就能进行更好的实验,并更好地了解需要去除什么,需要输入什么,以及我们可以对什么宽容。请考虑,在数据探索阶段对数据输入、质量测量和构成“坏”或低质量数据的假设的不匹配可能会产生重大后果,尤其是在生产中(例如,在大量行中丢弃所有空值或填充太多默认值,这将完全破坏熵)。要警惕假设之间的不匹配!
提示
对于给定数据集的深入统计分析,许多数据科学家使用 pandas 库。如 第二章 所述,pandas 是一个用于处理能够放入一台机器内存(RAM)中相对较小数据的 Python 分析库。在 Apache Spark 生态系统中的对应物是 Koalas,它已经演变成了 Spark 上的 pandas API。虽然原始 pandas 和 Spark 上的 pandas API 并非完全特性对齐,但这个 API 扩展了 Spark 的能力,使其更加强大,因此值得一试。
在这一部分,我们将从直接的流程转向使用 Spark MLlib 功能来计算统计数据,以便对数据有一个感性的了解。
计算统计数据
欢迎来到机器学习动物园项目!
要学习 MLlib 的统计功能,我们将使用来自 Kaggle 仓库 的动物分类数据集。这个数据集创建于 1990 年,包含了 101 个动物示例,由 16 个布尔值属性描述,捕捉了各种特征。这些动物可以被分类为七种类型:哺乳动物、鸟类、爬行动物、鱼类、两栖动物、昆虫和无脊椎动物。
要了解数据并更好地规划机器学习之旅的第一步是计算特征统计信息。知道数据的分布情况将为您提供宝贵的见解,帮助您确定选择哪些算法、如何评估模型,以及整体需要投入多少工作。
使用 Spark Summarizer 进行描述性统计
描述统计是摘要统计,它定量描述或总结了来自一组信息的特征。MLlib 为我们提供了一个专用的Summarizer对象,用于从特定列计算统计指标。这个功能是 MLlib 的LinearRegression算法用于构建LinearRegressionSummary的一部分。在构建 Summarizer 时,我们需要指定所需的指标。表 4-5 列出了 Spark API 中可用的功能。
表 4-5. Summarizer 指标选项
| 指标 | 描述 |
|---|---|
mean |
计算给定数值列的平均值 |
sum |
计算数值列的总和 |
variance |
计算列的方差(列中一组数字平均偏离其均值的程度) |
std |
计算列的标准差(方差值的平方根),以更加重视列中的异常值 |
count |
计算数据集中项目/行的数量 |
numNonZeros |
找到列中非零值的数量 |
max |
在列中找到最大值 |
min |
在列中找到最小值 |
normL1 |
计算列的 L1 范数(数值之间的相似性) |
normL2 |
计算列的欧几里得范数 |
注意
L1 和 L2(也称为欧几里得)范数是计算N维空间中数值点之间距离的工具。它们通常作为度量衡量数据点之间相似性的指标,在几何学、数据挖掘和深度学习等领域中广泛使用。
这段代码片段说明了如何创建具有指标的Summarizer实例:
from pyspark.ml.stat import Summarizer
summarizer = Summarizer.metrics("mean","sum","variance","std")
与 MLlib 的其他功能类似,Summarizer.metrics 函数期望将数值特征的向量作为输入。您可以使用 MLlib 的VectorAssembler函数来组装向量。
尽管动物园动物分类数据集中有许多特征,我们将仅检查以下列:
-
羽毛 -
牛奶 -
鳍 -
国内的
正如在“使用 Spark 进行数据摄取”中讨论的那样,我们将数据加载到名为zoo_data_for_statistics的 DataFrame 实例中。
在下一个代码示例中,您可以看到如何构建向量。请注意,我们将输出列名设置为features,正如摘要器所预期的那样:
from pyspark.ml.feature import VectorAssembler
*`# set the output col to features as expected as input for the summarizer`*
vecAssembler = VectorAssembler(outputCol="features")
*`# assemble only part of the columns for the example`*
vecAssembler.setInputCols(["feathers","milk","fins","domestic"])
vector_df = vecAssembler.transform(zoo_data_for_statistics)
我们的向量利用了 Apache Spark 的 Dataset 功能。在 Spark 中,Dataset 是一种强类型的对象集合,封装了 DataFrame。如果需要,您仍然可以从 Dataset 中调用 DataFrame,但 Dataset API 使您能够访问特定列,而无需专门的列功能:
Vector_df.features
现在我们有了一个专用的向量列和一个汇总器,让我们提取一些统计数据。我们可以调用summarizer.summary函数来绘制所有指标或计算特定指标,如下例所示:
*`# compute statistics for multiple metrics`*
statistics_df = vector_df.select(summarizer.summary(vector_df.features))
*`# statistics_df will plot all the metrics`*
statistics_df.show(truncate=False)
*`# compute statistics for single metric (here, std) without the rest`*
vector_df.select(Summarizer.std(vector_df.features)).show(truncate=False)
例子 4-4 显示了在特征向量上调用std的输出。
例子 4-4. features列的std
+-------------------------------------------------------------------------------+
|std(features) |
+-------------------------------------------------------------------------------+
|[0.4004947435409863,0.4935223970962651,0.37601348195757744,0.33655211592363116]|
+-------------------------------------------------------------------------------+
标准差(STD)是一组值变化的指示器。低 STD 表明值倾向于接近集合的均值(也称为期望值),而高 STD 表明值分布在更广的范围内。
注意
Summarizer.std是一个全局函数,你可以在不创建Summarizer实例的情况下使用。
由于feathers、milk、fins和domestic特征本质上是布尔类型——milk可以是1表示真或0表示假,fins也是如此——计算 STD 并不会为我们提供太多见解——结果总是在 0 到 1 之间的小数。这忽略了在计算数据“分布”程度时 STD 的价值。相反,让我们尝试使用sum函数。此函数将告诉我们数据集中有多少只动物具有羽毛、牛奶或鳍,或者是家养动物:
*`# compute statistics for single metric "sum" without the rest`*
vector_df.select(Summarizer.sum(vector_df.features)).show(truncate=False)
查看sum的输出,示例见例子 4-5。
+---------------------+
|sum(features) |
+---------------------+
|[20.0,41.0,17.0,13.0]|
+---------------------+
这告诉我们,有 20 只带羽毛的动物(向量的第一个值),41 只能提供牛奶的动物(向量的第二个值),17 只有鳍的动物(第三个值),以及 13 只家养动物(最后一个值)。由于数据的布尔特性,sum函数为我们提供了关于数据本身更多的见解,胜过于std函数。然而,数据集越复杂/多样化,查看各种指标将会更有帮助。
数据偏斜
偏斜度 在统计学中是概率分布不对称性的一种度量。想象一个钟形曲线,其中数据点不对称地分布在曲线均值的左右两侧。假设数据集遵循正态分布曲线,偏斜度意味着它在一端有短尾巴,在另一端有长尾巴。偏斜度值越高,数据分布越不均匀,数据点越多会落在钟形曲线的一侧。
要衡量偏斜或围绕均值的值的不对称性,我们需要提取均值并计算标准差。已经在 Spark 中为我们实现了完成此操作的统计方程;看看下一个代码片段,看看如何利用它:
from pyspark.sql.functions import skewness
df_with_skew = df.select(skewness("*{column_name}*"))
此代码返回一个新的 DataFrame,其中有一个专用列来测量我们请求的列的偏斜度。Spark 还实现了其他统计函数,例如 峰度,它测量数据的尾部。在基于随机变量分布构建模型时,这两者都很重要,并且假设数据遵循正态分布;它们可以帮助您检测偏差、数据拓扑变化,甚至数据漂移。我们将在 第十章 中更详细地讨论数据漂移,当我们查看在生产环境中监控机器学习模型时。
相关性
两个特征之间的 相关性 意味着如果特征 A 增加或减少,特征 B 也会同样变化(正相关),或者完全相反(负相关)。因此,确定相关性涉及测量两个变量/特征之间的线性关系。由于机器学习算法的目标是从数据中学习,因此完全相关的特征不太可能提供改进模型精度的见解。这就是为什么在保持结果质量的同时,过滤这些特征可以显著提高我们算法的性能。MLlib 中 ChiSquareTest 类 的 test 方法是一个统计测试,通过对所有成对进行 Pearson 相关性分析来帮助我们评估分类数据和标签,并输出具有相关性分数的矩阵。
警告
要注意相关性并不一定意味着因果关系。当两个变量的值以相关方式变化时,并不能保证一个变量的变化导致另一个变量的变化。证明因果关系需要更多的努力。
在本节中,您将学习有关 Spark MLlib 中 Pearson 和 Spearman 相关性的内容。
Pearson 相关性
在研究相关性时,我们寻找正向或负向关联。Pearson 相关性度量两个变量之间线性关系的强度。它生成一个系数 r,表示数据点与描述线的距离。r 的范围是 [–1, 1],其中:
-
r=1是完全正相关。两个变量以相同的方式行动。 -
r=–1表示完全负相关/反相关,这意味着当一个变量增加时,另一个变量减少。 -
r=0表示没有相关性。
图 4-3 在图表上展示了一些示例。

图 4-3. Pearson 相关性图表示例
Pearson 是 MLlib 中 Correlation 对象的默认相关性测试。让我们看一些示例代码及其结果:
from pyspark.ml.stat import Correlation
# compute r1 0 Pearson correlation
r1 = Correlation.corr(vector_df, "features").head()
print("Pearson correlation matrix:\n" + str(r1[0])+ "\n")
输出是一个行,其中第一个值是 DenseMatrix,如示例 4-6 所示。
示例 4-6. Pearson 相关性矩阵
Pearson correlation matrix:
DenseMatrix([[ 1\. , -0.41076061, -0.22354106, 0.03158624],
[-0.41076061, 1\. , -0.15632771, 0.16392762],
[-0.22354106, -0.15632771, 1\. , -0.09388671],
[ 0.03158624, 0.16392762, -0.09388671, 1\. ]])
每行代表一个特征与其他特征的相关性,成对出现:例如,r1[0][0,1]代表feathers与milk之间的相关性,这是一个负值(-0.41076061),表明产奶动物和有羽毛动物之间存在负相关。
表 4-6 显示了相关性表的外观,以便更清楚地了解。
表 4-6. 皮尔逊相关性表
feathers |
milk |
fins |
domestic |
|
|---|---|---|---|---|
feathers |
1 |
-.41076061 |
-0.22354106 |
0.03158624 |
milk |
-0.41076061 |
1 |
-0.15632771 |
0.16392762 |
fins |
-0.22354106 |
-0.15632771 |
1 |
-0.09388671 |
domestic |
0.03158624 |
0.16392762 |
-0.09388671 |
1 |
这个表格可以轻松地发现负相关和正相关:例如,fins和milk之间有负相关,而domestic和milk之间有正相关。
斯皮尔曼相关性
斯皮尔曼相关性,也称为斯皮尔曼等级相关,衡量了两个变量之间单调关系的强度和方向。与皮尔逊相反,后者衡量线性关系,这是一种曲线关系,这意味着两个变量之间的关联随着值的变化(增加或减少)而变化。斯皮尔曼相关性应用于数据离散且数据点之间的关系不一定是线性的情况,如图 4-4 所示,并且在排名时特别有用。要确定哪种方法更适合您的数据,您需要了解数据本身的性质:如果数据在顺序尺度上,² 使用斯皮尔曼;如果数据在区间尺度上,³ 使用皮尔逊。要进一步了解这一点,建议阅读 Peter Bruce、Andrew Bruce 和 Peter Gedeck 的《数据科学实战》(O’Reilly)。

图 4-4. 一个示例表明斯皮尔曼相关性图不会生成线性图(图片来源:维基百科,CC BY-SA 3.0)
注意,要使用斯皮尔曼,您必须指定它(默认是皮尔逊):
from pyspark.ml.stat import Correlation
# compute r2 0 Spearman correlation
r2 = Correlation.corr(vector_df, "features", "spearman").head()
print("Spearman correlation matrix:\n" + str(r2[0]))
与之前一样,输出是一行,其中第一个值是DenseMatrix,并且遵循先前描述的相同规则和顺序(参见示例 4-7)。
示例 4-7. 斯皮尔曼相关性矩阵
Spearman correlation matrix:
DenseMatrix([[ 1\. , -0.41076061, -0.22354106, 0.03158624],
[-0.41076061, 1\. , -0.15632771, 0.16392762],
[-0.22354106, -0.15632771, 1\. , -0.09388671],
[ 0.03158624, 0.16392762, -0.09388671, 1\. ]])
小贴士
Spark 具有基于相关性和假设检验(如卡方检验、ANOVA F 检验和 F 值)的特征选择器的自动化功能(UnivariateFeatureSelector;见表 5-2 在第五章)。 为了加快进程,最好使用现有的、已实现的测试,而不是自己计算每个假设。 如果在特征选择过程中确定了不足的特征集,应使用假设检验,如ChiSquareTest,来评估是否需要丰富数据或找到更大的数据集。 我在书的GitHub 存储库中为您提供了一个代码示例,演示如何做到这一点。 统计假设检验具有零假设(H0)和备择假设(H1),其中:
-
H0:样本数据符合假设分布。
-
H1:样本数据不符合假设分布。
测试的结果是p-值,它显示了 H0 为真的可能性。
摘要
在本章中,我们讨论了机器学习工作流程中的三个关键步骤:数据摄取、预处理文本和图像,以及收集描述性统计数据。 数据科学家和机器学习工程师通常会在这些任务上花费大量时间,认真执行这些任务可以为我们带来更大的成功和更深刻地满足业务目标的机器学习模型。 作为一个经验法则,最好与同事合作验证和工程这些步骤,以确保生成的洞察和数据可以在多个实验中重复使用。 本章介绍的工具将在整个机器学习过程中再次伴随我们。 在下一章中,我们将深入研究特征工程,并建立在本章的成果基础上。
¹ 二进制数据源架构可能会随着 Apache Spark 的新版本发布或在使用诸如 Databricks 之类的托管环境中发生变化。
² 顺序等级标度将所有变量按特定顺序排列,而不仅仅是命名它们。
³ 区间标度标签和顺序其变量,并指定它们之间的定义、均匀间隔。
第五章:特征工程
在机器学习中,特征工程是使用领域知识来选择和转换数据中最相关变量的过程,以达到机器学习过程的目标。这里的领域知识指的是对数据及其来源的理解。在数据科学中,关注的不是工具本身,而是数据和问题本身,涵盖特定领域或垂直领域的一般背景。例如,在金融领域,可能涉及对金融术语的熟悉以及数据的各种可能应用,如贷款评分。根据项目团队的经验,可能需要咨询金融专家来创建一个代表性特征,以解决当前的问题。同样,在医疗技术领域,您可能需要与医生合作设计特征,需要了解解剖学、生物系统和医疗状况的知识。
特征工程的目标是使最终的特征充当数据对世界或问题所包含信息的代理。领域经验使您能够直观地建立这些联系。使用领域知识可以帮助简化机器学习中固有的挑战,提高实现业务目标的成功率。的确,要成功,您必须对问题及其相关数据有深入的理解。
这里再举一个例子。假设您有一组车辆的图像,并且负责从这些图像中检测出车辆的品牌、型号和年份。这可能是一个难以解决的问题!毕竟,一辆车的照片可以从多个角度拍摄,您需要根据非常具体的特征区分和缩小选项。与其要求您的机器学习模型评估许多单独的特征,不如直接检测车辆的注册牌照并将像素转化为数字和字母。一旦获得这些信息,您可以与其他数据集匹配以提取所需信息。由于从图像中提取数字/字母是一个解决了的机器学习问题,您已经利用领域知识与特征工程来达到您的业务目标。
正如这个例子所示,您的目标和数据将决定您需要用来训练模型的特征。有趣的是,鉴于机器学习算法在不同场景下可能相同,例如将电子邮件分类为垃圾邮件或非垃圾邮件,或将 Twitter 账号分类为机器人或真实用户,高质量的特征可能是模型性能的主要驱动因素。这里所说的“好”的特征是指那些能提供关于数据大量信息、解释最大差异并与目标变量有强关系的特征。
大多数机器学习算法并不智能到可以自动从原始数据中提取有意义的特征。因此,通过在执行链中堆叠多个算法,同时使用提取器和转换器以及智能选择器,可以帮助我们识别显著的特征。这个过程也被称为特征化,是处理机器学习管道中数据的关键部分。
在本章中,我们将探讨 Spark 提供的一些特征提取工具和技术。在我们继续之前,这里有几个您应该了解的术语:
估计器
可以在 DataFrame 上拟合以生成转换器的算法
哈希函数
用于将任意大小的数据映射到固定大小值的函数
派生特征
通过特征工程获得的特征
原始特征
直接从数据集中获取的特征,无需额外的数据操作或工程化
在高层次上,本章涵盖以下内容:
-
特征及其对机器学习模型的影响
-
MLlib 特征化工具
-
图像特征化过程
-
文本特征化过程
-
如何丰富您的数据集
让我们从更深入地了解特征及其对我们模型的影响开始。
特征及其对模型的影响
由于我们的模型是从数据空间中(估计为)抽取/生成的数学和统计表示,它们可能受到异常值的影响,或者受到内部偏差或其他我们宁愿避免的问题的困扰。假设您的数据包含缺失值,但您的算法不支持这些缺失值。您必须决定如何处理:是填补缺失值?还是完全删除具有缺失值的列?尝试使用其他算法?您做出的决定可能会对线性模型、支持向量机(SVM)、神经网络、主成分分析(PCA)和最近邻等算法的结果产生显著影响,因为每个数据点都会改变结果。简单来说:如果缺失值比实际值多,则用默认值填充它们可能会完全倾斜方程以偏向默认值。
要更好地理解这一点,让我们看一下支持向量机(SVM)算法,如图 5-1 所示,我们需要开发一个线性向量/函数(H[1],H[2],H[3]),来区分两个类别,即空心圆或实心圆,给定 X[1]和 X[2]作为输入。如果我们在空心圆类别的大多数 X[2]数据点上有缺失值,并且我们用默认值填充它们,那将完全改变我们线性向量的方向和程度。因此,我们必须仔细考虑如何填补缺失数据以及我们的特征如何影响模型。

图 5-1。线性硬边界支持向量机(SVM),描述(H1,H2,H3)为假设,点为实际数据值
另一个特征提取的重要方面是能够识别和减少噪声,特别是在使用具有内置特征提取机制的自动化机器学习模型时。算法中的噪声数据会导致产生不正确的模式,算法开始从中进行泛化,从而产生不符合业务领域的不良模型结果。理解数据以及特征提取在这里至关重要。
如图 5-2 所示,特征工程是一个丰富的工具和技术世界。

图 5-2. 特征工程需求和技术的高层视图
最佳选择取决于您正在处理的数据类型和需求。没有硬性规定,但根据您处理的问题,有一些需要牢记的事项:
处理缺失数据
我是否将这些数据用于特定案例分析?整列/行删除与填充默认数据的“价格”是多少?我的数据集中缺失数据的百分比是多少?数据是整行缺失还是特定列?回答这些问题,您就有了一个计划!
从文本中提取特征
我是否只有非结构化文本数据?我该如何理解它?我的数据的性质是什么?文本有多长——是一个限制字符数的推文,还是书中的特征工程章节?(有关详细信息,请参阅“文本特征提取过程”。)
分类编码
这是将分类变量转换为一组二进制变量的过程——是或否,真或假,0 或 1。这里的目标是提升模型的性能。分类数据的一个例子是一个人居住的城市:旧金山、纽约、柏林、特拉维夫等。这些是名义分类,因为它们之间没有具体的顺序。类别也可以有固有的顺序,例如学生的成绩:A+、A、A-、B+等。我们将在第六章中探讨分类;许多算法将分类特征作为输入。
特征缩放
我们使用这种技术,也称为数据归一化,以标准化数据集中的独立特征,使它们都在一个相似的尺度上。我们在预处理阶段或在出现新的高度变化值时,作为特征工程的一部分处理它。特征缩放在使用梯度下降算法时很常见,因为它有助于加快收敛速度。图 5-3 演示了x在每次梯度下降算法迭代中如何朝向最小可能值的理想轨迹,直到找到最小值或达到允许的最大迭代次数—这就是归一化促进收敛的含义。如果没有归一化,特征的范围差异会导致每个特征具有不同的步长,这意味着它们以不同的速度更新,需要更长时间才能达到总体优化。归一化使事情变得平稳并加快了过程。你可以把它想象成一个人困在树林中,试图找回自己的车;这个人会四处走动,收集信息,尝试优化路线。但如果大部分树木都是透明的,这个人能够透过它们看到外面?这就是特征缩放的效果。特征缩放也对基于距离的算法和回归很有用。为了实现它,我们可以利用 PCA,这是一种无监督技术,使我们能够过滤嘈杂的数据集并减少数据的维度。

图 5-3. 梯度下降在每次迭代中朝向函数的最小值移动,从 x[1] 到 x[2] 等等
MLlib 特征化工具
在其pyspark.ml.feature包中,MLlib 提供了许多特征化函数(基于 Scala 的 API 在spark.ml.feature中)。最新的 API 列表可以在Apache Spark MLlib 文档中找到,并且代码示例可以在Apache Spark GitHub 仓库的examples/src/main/python/ml/目录下找到。
在本节中,我们将涵盖 MLlib 提取器和选择器。
提取器
提取器是 MLlib 的 API,用于提取那些单独来看可能没有意义但可以帮助我们进行探索的特征。还记得上一章节中的 Figure 4-1 吗?预处理、特征工程和统计描述是相互交织的过程。转换器(如果需要复习,请参考 “使用 MLlib 转换器进行预处理”)可以用作提取器,反之亦然。有些提取器要求我们先使用转换器,例如 TF-IDF,它无法直接在原始数据上操作,需要使用 Tokenizer API 提取单词,以及 HashingTF 或 CountVectorizer 将单词集合转换为标记计数的向量。
Table 5-1 展示了可用的提取器 API 及其使用情况。
Table 5-1. Spark MLlib 提取器 API
| API | 用法 |
|---|---|
TF-IDF |
计算词频-逆文档频率,其中词频是术语在语料库中出现在文档中的次数,文档频率是术语出现在的文档数。在文本挖掘中用于衡量术语对文档的重要性。 |
Word2Vec |
将单词序列转换为固定大小的向量。用于自然语言处理。 |
CountVectorizer |
将固定大小的单词序列转换为标记计数的向量,用于文本分类。当序列大小不同时,将使用最小大小作为词汇表大小。 |
FeatureHasher |
接受一组分类或数值特征并将它们哈希为一个特征向量。通常用于减少特征数目而不显著损失其价值。 |
选择器
当我们使用转换器和提取器开发出一堆特征后,就到了选择想要保留的特征的时候了。为此,我们使用选择器,这些是从大量特征集中选择子集的 API。这可以手动完成,也可以使用估算特征重要性并旨在提高我们的机器学习模型性能的算法自适应地完成;这很重要,因为过多的特征可能导致过度拟合。
Spark 提供了在 Table 5-2 中列出的简单选项。
Table 5-2. Spark MLlib 选择器 API
| API | 用法 |
|---|---|
VectorSlicer |
接受特征向量列并输出一个由我们传入的索引决定的原始特征子集的新列。 |
RFormula |
使用一组基本支持的操作选择具有 R 模型公式(供 R 编程语言用户使用)的列,并生成一个特征向量列和一个双精度或字符串标签列。 |
ChiSqSelector |
接受一个包含所有特征向量和一个标签列的列,并生成一个带有选定特征向量的新列的 DataFrame。使用基于卡方统计检验的特征选择,根据五种选择方法之一进行(预测性能最高的前x个特征、预测性能最高的前%特征、误报率低于阈值的特征等)。 |
UnivariateFeatureSelector |
接受带有分类/连续标签和分类/连续向量特征的输入,并生成一个带有选定特征向量的新列的 DataFrame。Spark 根据指定的标签和特征类型选择用于特征选择的评分函数(卡方检验、ANOVA F 检验或 F 值);选择方法与ChiSqSelector相同。 |
VarianceThresholdSelector |
接受特征向量列,并输出一个新列,移除所有低于提供的方差阈值的特征。 |
示例:Word2Vec
许多选择器和后续的机器学习模型将特征向量表示为 DataFrame 中的一个列。这也是为什么使用列存储格式如 Parquet 并不总是更高效的原因,因为所有特征都以一个列的形式表示。然而,当给定具有许多列的大型 DataFrame 时,其中每一列表示不同的转换或特征集时,它可能更有效。为了适应这种用法,Spark MLlib API 生成一个与前一个 DataFrame 具有相同列的新 DataFrame,并添加一个新列来表示选定的特征。
许多转换器的输出可以持久化到磁盘并在以后重复使用。以下代码片段展示了如何堆叠多个转换器、将它们持久化到磁盘并从磁盘加载它们:
from pyspark.ml.feature import Word2Vec, Word2VecModel
from pyspark.ml.feature import Tokenizer
# Input data: Each row is a bag of words from a sentence or document.
sentence_data_frame = spark.createDataFrame([
(0, "Hi I think pyspark is cool ","happy"),
(1, "All I want is a pyspark cluster","indifferent"),
(2, "I finally understand how ML works","fulfilled"),
(3, "Yet another sentence about pyspark and ML","indifferent"),
(4, "Why didn’t I know about mllib before","sad"),
(5, "Yes, I can","happy")
], ["id", "sentence", "sentiment"])
tokenizer = Tokenizer(inputCol="sentence", outputCol="words")
tokenized = tokenizer.transform(sentence_data_frame)
word2Vec = Word2Vec(inputCol="words", outputCol="result")
model = word2Vec.fit(tokenized)
model.write().overwrite().save("some_path")
model_from_disk = Word2VecModel.load("some_path")
df_with_word_vec = model_from_disk.transform(tokenized)
df_with_word_vec.show()
selector = VarianceThresholdSelector(varianceThreshold=0.0,
outputCol="selectedFeatures")
result = selector.fit(df_with_word_vec).transform(df_with_word_vec)
在此代码中:
-
sentence_data_frame是一个模拟 DataFrame,用于展示功能。 -
Tokenizer创建一个Tokenizer实例,提供输入列和输出列名称。 -
transform转换 DataFrame 并生成一个名为tokenized的新 DataFrame。 -
Word2Vec创建一个具有指定输入列、输出列和向量大小的Word2Vec实例。Word2Vec是一个估算器,因此在使用其进行转换之前,我们需要对数据运行fit。更多详情见第六章。 -
.save使用write函数将Word2Vec模型持久化到磁盘。 -
Word2VecModel.load从磁盘加载Word2Vec模型。 -
model_from_disk.transform使用加载的模型转换标记化数据。 -
VarianceThresholdSelector创建一个VarianceThresholdSelector实例,用于选择特征,阈值为0,表示过滤掉所有样本中具有相同值的特征。VarianceThresholdSelector期望输入名为features的列。我们调用fit来拟合数据上的VarianceThresholdSelector,并使用模型来选择特征。
示例 5-1 展示了最终 DataFrame 的外观。
示例 5-1. 具有所有新列的 DataFrame
Output: Features with variance lower than 0.000000 are removed.
+---+--------------------+-----------+--------------------+--------------------+--------------------+
| id| sentence| sentiment| words| features| selectedFeatures|
+---+--------------------+-----------+--------------------+--------------------+--------------------+
| O|Hi I think pyspar...| happy|[hi, i, think, py...|[-6.2237260863184...|[-6.2237260863184...|
| 1|All I want is a p...|indifferent|[all, i, want, is...|[-7.1128298129354...|[-7.1128298129354...|
| 2|I finally underst...| fulfill|[i, finally, unde...|[-8.2983014484246...|[-8.2983014484246...|
| 3|Yet another sente...|indifferent|[yet, another, se...|[0.0,0.0,0.0,0.0,...|[0.0,0.0,0.0,0.0,...|
| 4|Why didn't I know...| sad|[why, didn't, i, ...|[-7.1128298129354...|[-7.1128298129354...|
| 5| Yes, I can| happy| [yes,, i, can]|[-0.0016596602896...|[-0.0016596602896...|
+---+--------------------+-----------+--------------------+--------------------+--------------------+
简单起见,我们没有将特征向量归一化到[0,1]范围内,尽管这样做可能有助于后续的选择过程。这是特征工程过程中你应该探索的另一条路。顺便说一句,虽然在这个示例中我们将模型保存到了磁盘上,但你可以将它保存到任何你有连接器并支持 Parquet 格式的存储中(因为模型本身是以 Parquet 格式保存的,除非你另有指定)。我们将在第十章进一步讨论这个问题。
图像特征化过程
在处理图像时,业界存在一个关于特征工程的误解。许多人认为这是没有必要的,但实际上,即使我们使用了像卷积神经网络(CNN)这样的神经网络算法来提取和映射特征,如图 5-4 所示,仍然有可能引入噪声和计算开销,并且可能会漏掉一些特征,因为算法本身并不具备给定问题的业务知识或领域理解。

图 5-4. 典型的 CNN 架构描述卷积、子采样等直到输出的过程
我们可以从图像数据中提取许多特征,这取决于我们所处的领域。我们的 Caltech 256 图像分类数据集非常多样化,我们可以尝试多种技术,直到找到最佳特征。还要记住,当涉及到图像和分层神经网络模型时,我们可以利用现有模型,只需添加最后一层。我稍后会详细讨论这个问题,但我在这里提到它是因为该模型可能需要特定的图像特征,比如特定的宽度、高度和通道数:
宽度和高度
这些代表了图像中的像素数。例如,一个尺寸为 180 × 200 的图像宽度为 180 像素,高度为 200 像素。
通道
这是组成图像的传统主要颜色层的数量。例如,RGB(红色,绿色,蓝色)图像实际上由三个图像(像素矩阵)组成:一个红色通道,一个绿色通道和一个蓝色通道。灰度图像通常只有一个通道。用于编码卫星图像的 GeoTIFF 和其他格式可以具有多达 13 个层(或通道),其中 3 个是 RGB,即人眼可见的层。
所以对于 RGB 格式的全彩色图像,我们实际上需要三个矩阵(或通道),其值在 0 到 255 之间,较小的数字代表暗色,较大的数字表示亮色(0 为黑色,255 为白色)。
注
存储图像数据的其他格式还有很多,但是 RGB 是最流行的格式,所以这里我将重点放在这里。
我们应该如何思考和定义我们的特征?让我们先大致了解一下我们可以通过图像处理做些什么。
理解图像处理
最佳处理方式取决于图像的复杂性——图像中有多少对象、图像背景、通道数、颜色是否提供可操作的价值等。基于这些考虑,我们有多种选项来处理现有数据并提取特征。
如图 5-5 所示,彩色图像有三层矩阵可以进行操作。我们可以添加滤镜、改变像素、分组像素,或者仅提取一个通道以获得灰度图像(如图底部所示)。

图 5-5. 带有三个矩阵的 RGB 图像(顶部)和一个矩阵的灰度图像(底部)
灰度
当 RGB 图像中的颜色没有意义时,将其转换为灰度图像可以帮助消除数据中的噪音。灰度图像是单通道图像,每个像素点的光强信息使用介于 0 到 255 之间的整数值来表示。这些图像完全由灰度色调组成,覆盖了从白色到黑色的连续范围。
使用图像梯度定义图像边界
让我们再看看仓库中的另一张图片,图像 196_0070. 这张图片被归类为意大利面,但在左侧的彩色版本中,我们还可以看到图片中的一些其他物品,如番茄、面包、罐子、玻璃杯、碗以及看起来像是贻贝。如果不定义特征,比如图像边界,这样的图片可能会给我们的算法引入大量噪音,导致模型错误地将番茄、贻贝和其他物品的图片分类为意大利面!

图 5-6. 一张具有多个滤镜的图像,在我们的数据集中简单分类为意大利面,尽管它包含了几个其他物品
利用梯度计算提取图像边缘可以通过利用诸如拉普拉斯这样的矢量运算符来完成,它突出显示快速强度变化的区域。使用 Pillow,我们可以使用Kernel方法定义自己的卷积核进行边缘检测¹,或者使用库提供的内置FIND_EDGES滤镜。以下代码示例展示了这两种方法:
from PIL import Image, ImageFilter
img = Image.open(r"sample.png")
# Input image to be of mode grayscale (L)
img = img.convert("L")
# Calculating edges by providing a kernel manually
final = img.filter(ImageFilter.Kernel((3, 3),
(-1, -1, -1, -1, 8, -1, -1, -1, -1), 1, 0))
# Calculating edges by leveraging Pillow filter defaults
edges = img.filter(ImageFilter.FIND_EDGES)
Pillow 还为我们提供了许多其他预设滤镜,如BLUR、SMOOTH和EDGE_ENHANCE,所有这些都基于对图像进行像素梯度操作的滤镜添加。图 5-6 展示了灰度和边缘滤镜的渲染效果。
使用 Spark API 提取特征
我们可以同时在 Caltech 256 数据集上使用多种技术来提取数据的特征。在第四章中,我们简要讨论了 UDF 及其如何用于提取图像大小。让我们深入探讨这个主题,因为这是我们从图像中提取特征的主要工具。
直到 2017 年,PySpark 支持的 UDF 一次处理一行。这些函数错过了 Spark 查询引擎的优化能力,因为在幕后 PySpark 被转换成 Scala 代码,许多在 PySpark 中编写的 UDF 具有高的序列化和调用开销。为了解决这些问题,数据工程师和数据科学家们合作,在 Java 和 Scala 中定义了可以从 Python 调用的 UDF。然而,这使得代码变得混乱,难以导航和维护。幸运的是,Spark 2.3 引入了 pandas UDFs,允许向量化操作并利用 Apache Arrow 优化来减少序列化和反序列化操作。这个新增功能不仅使数据科学家能够扩展他们的工作负载,还可以在 Spark 中使用 pandas API。
pyspark.sql.functions:pandas_udf 和 Python 类型提示
如第四章所述,pandas UDFs 是使用pandas_udf装饰器定义的,它是pyspark.sql.functions库的一部分。在 Apache Spark 3.0 之前,使用 Python 类型提示来指定创建的 pandas UDF 的类型(根据转换类型)是必要的,如表格 5-3 所示。每种类型都期望特定类型的输入,并产生特定类型的输出。推荐使用 Python 类型提示,因为它清楚地指示了函数的预期功能,并且使得静态分析更加容易;在未来的版本中,可能会弃用使用PandasUDFType来指定类型的做法。现在可以使用专用函数applyInPandas和mapInPandas执行某些类型的转换,这些函数将在下一节讨论。
表格 5-3。Spark pandas UDF 类型详细信息
| Spark PandasUDFType | 输入和输出 | Python 类型提示 |
|---|---|---|
SCALAR(默认) |
pandas Series | 不需要指定特定的标量 pandas 数据类型;可以使用long、double、float、int或boolean |
SCALAR_ITER |
pandas Series 的迭代器 | 函数签名中需要指定 pandas 数据类型,以便编译器和运行时引擎创建正确的迭代器 |
GROUPED_MAP |
pandas DataFrame | 不需要特定的 pandas 数据类型;可以在 Spark DataFrame 上使用mapInPandas或applyInPandas函数 |
GROUPED_AGG |
pandas DataFrame | 类似于GROUPED_MAP,不需要特定的 pandas 数据类型;可以使用applyInPandas替代 |
在第四章中,我们使用了一个 pandas UDF 来计算数据集中每个图像的大小。现在让我们计算每个图像类别的平均大小,以便决定是否要相应地调整图像大小。
首先,我们将把 size 结构展平为两列:
flattened = df.withColumn('width', col('size')['width'])
flattened = flattened.withColumn('height', col('size')['height'])
展示了生成的展平 DataFrame 的一部分摘录,如示例 5-2 所示。
示例 5-2. 展平的宽度和高度列
+-----+------+
|width|height|
+-----+------+
|1500 |1500 |
|630 |537 |
|1792 |1200 |
+-----+------+
接下来,我们将提取每列的平均宽度和高度,利用带有 Python 类型提示的 pandas UDF:
@pandas_udf("int")
def pandas_mean(size: pd.Series) -> (int):
return size.sum()
flattened.select(pandas_mean(flattened['width'])).show()
flattened.groupby("label").agg(pandas_mean(flattened['width'])).show()
flattened.select(pandas_mean(flattened['width'])
.over(Window.partitionBy('label'))).show()
在 Python 函数中,@pandas_udf("int") 开头和 -> (int) 给 PySpark 提供了关于我们正在使用的数据类型的提示。这个示例展示了如何计算一列的平均宽度,但我们也可以用同样的函数来计算高度。
示例 5-3 展示了一些示例输出。
示例 5-3. 示例平均宽度和高度值
+-------------+------------------+-------------------+
| label|pandas_mean(width)|pandas_mean(height)|
+-------------+------------------+-------------------+
|196.spaghetti| 39019| 33160|
| 249.yo-yo| 40944| 37326|
| 234.tweezer| 34513| 27628|
| 212.teapot| 51516| 45729|
+-------------+------------------+-------------------+
我们的最后一步是根据结果决定是否要调整图像大小。在我们的情况下,我们可以跳过此步骤,因为我们将利用 PyTorch 和 TensorFlow 的机器学习算法,并根据算法的要求调整图像大小。
pyspark.sql.GroupedData: applyInPandas 和 mapInPandas
applyInPandas 和 mapInPandas 是操作分组 Spark DataFrame 并返回带有结果的新 DataFrame 的函数。当我们定义一个 pandas_udf 来处理它们时,它接收一个 pandas DataFrame 作为输入,并返回一个 pandas DataFrame 作为输出。
尽管它们有相同的名称,但 pandas DataFrame 和 Spark DataFrame 不是同一回事,不要混淆它们是非常重要的。表 5-4 强调了两者之间的主要区别。
表 5-4. Spark 和 pandas DataFrame 的主要特性
| Spark DataFrame | pandas DataFrame | |
|---|---|---|
| 支持并行执行 | 是 | 否 |
| 惰性操作 | 是 | 否 |
| 不可变 | 是 | 否 |
举个例子,让我们看看如何使用 applyInPandas 函数提取我们图像的灰度版本。以下是我们将要遵循的步骤:
-
定义函数
add_grayscale_img:def add_grayscale_img(input_df): input_df['grayscale_image'] = input_df.content.apply(lambda image: get_image_bytes(Image.open(io.BytesIO(image)).convert('L'))) input_df['grayscale_format'] = "png" return input_df def get_image_bytes(image): img_bytes = io.BytesIO() image.save(img_bytes,format="png") return img_bytes.getvalue()在这个函数内部,我们调用 PIL 库的
convert函数来提取图像的灰度版本。get_image_bytes是一个辅助函数,帮助我们获取与.convert('L')一起使用的正确对象类。 -
测试这个函数。图像处理通常是一个昂贵的过程,因为它需要 Spark 应用程序迭代所有数据并处理图像。因此,在图像数据的子集上测试函数是最佳实践,这样你可以调整和优化它,并确保在整个数据集上达到期望效果之前进行测试。
-
通过利用现有的 DataFrame 并添加专用的空(
None)列,指定输出 DataFrame 的模式是非常容易的:rtn_schema = (df.select('content','label','path') .withColumn('grayscale_image', lit(None).cast(BinaryType())) .withColumn('grayscale_format', lit(None).cast(StringType())) )然后我们可以通过调用
DataFrame.schema来提取模式。这个过程使得在函数调用中定义模式变得简单,并减少了模式不匹配的可能性。 -
减少 DataFrame 的列到最小必需的数量,因为(如下面的侧边栏所述)
groupBy和applyInPandas是比较昂贵的操作:limited_df = df.select('label','content','path') -
在 Spark 执行器上运行 Python 函数。首先调用
groupby来对数据进行分组(您可以使用任何函数来执行此操作,但这是运行下一个函数的前提)。我们可以使用任何列来对数据进行分组,并使用指向要执行的函数和模式的指针来调用applyInPandas函数。在我们的示例中,add_grayscale_img是我们要执行的函数,rtn_schema.schema是我们的模式:augmented_df = limited_df.groupBy('label') .applyInPandas(add_grayscale_img, schema=rtn_schema.schema) -
最后,利用
leftouter将原始 DataFrame 中的数据与增强的 DataFrame 重新连接,以防止图像转换需要跳过某些行:output_df = df.join(augmented_df.select('path','grayscale_image'), ['path'],"leftouter")
这六个步骤为我们提供了两个新特征:一个类型为 Array[Byte] 的名为 grayscale_image 的特征,以及一个类型为 String 的名为 grayscale_format 的特征。
图 5-9 显示了 output_df 表的结构和一些数据样本行。

图 5-9. 带有新列的输出示例
文本特征化过程
在 “示例:Word2Vec” 中,您学习了如何使用 Tokenizer、Word2Vec 和其他工具。在本节中,我们将使用 Bot or Not 数据集 来了解如何对短自由文本字符串(例如 Twitter 用户描述(简介))进行特征化处理。
由于我们的数据是有监督的(即有标签的),我们可以探索现有特征与标签的组合以开发新的特征。当然,这可能导致特征高度相关,这意味着我们需要超越传统思维。诚然,可解释的特征(我们已经组合的特征,我们基本上理解它们之间的关系)和模型比复杂的模型更容易调试。然而,可解释性并不总是能够导致最准确的模型。
我们的数据集有一个 description 列和一个 label 列。示例 5-4 展示了这些列的一些例子。
示例 5-4. Twitter Bot 或 Not description 和 label 列
+--------------------+-----+
| description|label|
+--------------------+-----+
|Contributing Edit...| 1|
| I live in Texas| 0|
|Fresh E3 rumours ...| 0|
|''The 'Hello Worl...| 0|
|Proud West Belcon...| 1|
|Hello, I am here ...| 0|
|Meow! I want to t...| 0|
|I have something ...| 0|
|I have more than ...| 0|
|If you have to st...| 13|
|I am a twitterbot...| 1|
|Designing and mak...| 0|
|Host of Vleeties ...| 0|
|Benefiting Refuge...| 0|
|Access Hollywood ...| 0|
|Producer/Songwrit...| 0|
|CEO @Shapeways. I...| 0|
|Two division UFC ...| 0|
|Moderator of @mee...| 0|
|Tweeting every le...| 0|
+--------------------+-----+
only showing top 20 rows
让我们看看我们从这些数据中提取特征的一些选项。
词袋模型
词袋法是将文本转换为平面向量的“bag-of-x”方法之一。在这种方法中,文本(在我们的情况下是 Twitter 个人简介)被表示为其组成单词的“袋子”(多重集合),忽略语法甚至单词顺序。通常,最终的表示将是术语及其计数。因此,在构建词袋法的过程中,我们还提取包含给定文本中单词及其频率(出现次数)的 术语频率向量。MLlib 提供了这样做的功能,例如 Tokenizer(用于拆分文本)与 HashingTF 转换器或 CountVectorizer 选择器。
举个例子,假设我们有以下 Twitter 帐户描述:
Tweets on programming best practices, open-source, distributed systems, data & machine learning, dataops, Apache Spark, MLlib, PyTorch & TensorFlow.
在这种情况下,没有真正的必要生成词频向量,因为每个词的频率都是 1。然而,如果我们将所有标记为机器人的帐户的描述分组,几乎肯定会发现许多术语出现超过一次,这可能会带来一些有趣的见解。这将我们带到第二个选项:TF-IDF。
TF-IDF
TF-IDF 是信息检索领域中广泛使用的一种方法,用于文本挖掘。它允许我们量化术语在给定文档或语料库中的重要性。作为提醒,术语频率 (TF(t,d)) 是术语 t 在文档 d 中出现的次数,而 文档频率 (DF(t,D)) 是语料库 D 中包含术语 t 的文档数。
IDF 是 逆 文档频率,它为我们提供了一个数值度量,根据术语在语料库中的稀有或频繁程度,来衡量术语提供的信息量;使用越频繁,得分越低。
TF-IDF 将这些输出相乘:
TFIDF(t,d,D)=TF(t,d)•IDF(t,D)
MLlib 中的 TF 和 IDF 功能是分开的,这为我们提供了灵活性,可以决定如何处理它们,以及是否要一起使用它们或分开使用。
让我们使用 Tokenizer API 提取单词并使用 HashingTF 函数将术语映射到它们的频率来计算我们数据集的 TF:
from pyspark.ml.feature import HashingTF, IDF, Tokenizer
tokenizer = Tokenizer(inputCol="description", outputCol="words")
wordsData = tokenizer.transform(data)
hashingTF = HashingTF(inputCol="words", outputCol="frequencyFeatures",
numFeatures=20)
featurizedData = hashingTF.transform(wordsData)
HashingTF 创建一个名为 frequencyFeatures 的新列,其中包含一组哈希词的稀疏向量及其出现在文档中的情况,如 示例 5-5 所示。
示例 5-5. 哈希术语频率
+-----+--------------------+
|label| frequencyFeatures|
+-----+--------------------+
| 1|(20,[0,2,3,4,5,7,...|
| 0|(20,[3,13,16,17],...|
| 0|(20,[1,2,4,5,6,7,...|
| 0|(20,[0,1,4,5,7,8,...|
| 1|(20,[0,1,3,4,5,6,...|
+-----+--------------------+
only showing top 5 rows
我们的第二步是计算逆文档频率。由于 Spark 的 IDF 是一个估计器,我们首先需要使用 fit 方法来构建它:
idf = IDF(inputCol="frequencyFeatures", outputCol="features")
idfModel = idf.fit(featurizedData)
这创建了一个名为 idfModel 的 IDFModel 对象实例。现在,我们可以使用该模型来转换数据:
rescaledData = idfModel.transform(featurizedData)
rescaledData DataFrame 实例具有名为features的列,该列显示了在整个数据集中给定描述中术语的重要性。尽管这些信息很有用,但 TF-IDF 是一种无监督方法,完全忽视了数据中的标签。接下来,我们将看看一种监督方法。
N-Gram
MLlib 的 NGram 转换器允许我们将字符串数组(单词)转换为 n-grams 数组,其中 n 是我们可以在 NGram 函数中定义的参数:
from pyspark.ml.feature import NGram
ngram = NGram(n=2, inputCol="words", outputCol="ngrams")
ngramDataFrame = ngram.transform(wordsData)
如果我们知道如何处理后续的NGram输出并且该输出具有价值,NGram可以生成一个不错的特征。例如:
[I, live, in, texas]
给定 n=2,我们得到以下字符串数组:
[I live, live in, in texas]
从输出中可以看出,NGram作为一个滑动窗口来重复单词。在构建自动完成句子、检查拼写或语法、提取主题等工具时非常有用。
额外技术
还有许多其他技术可以利用从文本中提取特征。例如,主题提取旨在识别给定文档的主题,通过扫描已知的有影响力的术语或术语组合来实现。您还可以利用 MLlib 中提供的 FP-growth 和 PrefixSpan 算法的频繁模式挖掘功能。所有这些方法,在其核心上都是基于先前讨论的特征提取方法:词袋模型、识别语料库中术语或模式在给定文档中的频率、N-gram 等。要了解更多信息,请查阅 O’Reilly 的以下可靠资源:
-
使用 Python 进行应用文本分析 由 Benjamin Bengfort、Rebecca Bilbro 和 Tony Ojeda 撰写。
-
使用 Spark NLP 进行自然语言处理 由 Alex Thomas 撰写。
-
实用自然语言处理 由 Sowmya Vajjala 等人撰写。
丰富数据集
通常,我们的数据集需要更多的数据点和新特征。找到正确的特征是机器学习工作流程的核心,通常更多的是艺术而不是科学。我们有几种方法可以丰富我们的数据集。例如,使用 Bot or Not 机器人检测数据集时,如果需要更多数据,我们可以利用 Twitter API 提取新的推文和更新。我们还可以利用迁移学习,这是利用解决一个问题时获得的知识来解决另一个问题的过程。
我们如何利用迁移学习解决我们的 Twitter 机器人检测问题?一个选项是从其他社交媒体中获取类似帐户名称的数据,例如 LinkedIn。了解 LinkedIn 上的帐户是否真实可以为我们提供另一个数据点来利用。
总结
Spark 和 Spark MLlib 实现了许多额外的特征提取功能,您在构建模型时可能会感兴趣。有很多特征可以提高模型的准确性;例如,在处理图像时,您可能希望尝试一种只保留图像轮廓和图像非常独特特征描述符的方法,以区分图像的一个特征与另一个特征。
您可以利用 Spark 的通用函数来处理任何您需要的代码,但请注意所涉及的资源和计算成本。领域知识是成功特征化过程的关键,因此在进行特征工程时务必小心处理。接下来,我们将介绍如何使用 Spark MLlib 构建机器学习模型。
¹ 核是一个像图像滤波器一样操作的矩阵。它在图像上滑动,并将核值与输入数据相乘,从输入图像中提取特定的“特征”。
第六章:使用 Spark MLlib 训练模型
现在您已经了解了管理机器学习实验、感受数据和特征工程, 是时候开始训练一些模型了。
这究竟涉及什么?训练模型是调整或更改模型参数的过程,以提高其性能。这里的思想是向您的机器学习模型提供训练数据,教它如何解决特定任务,例如通过识别其“猫”属性将照片中的对象分类为猫。
在本章中,您将学习机器学习算法的工作原理,何时使用哪个工具,如何验证您的模型,以及最重要的是如何使用 Spark MLlib 管道 API 自动化这一过程。
在高层次上,本章涵盖以下内容:
-
基本的 Spark 机器学习算法
-
使用 Spark 机器学习进行监督学习
-
使用 Spark 机器学习进行无监督学习
-
评估您的模型并对其进行测试
-
超参数和调整您的模型
-
使用 Spark 机器学习管道
-
将模型和管道持久化到磁盘
算法
让我们从算法开始,这是您模型训练活动中不可或缺的部分。机器学习算法的输入是样本数据,输出是模型。算法的目标是泛化问题,并提取一组逻辑,用于进行预测和决策,而无需明确编程。算法可以基于统计学、数学优化、模式检测等。Spark MLlib 为我们提供了经典监督机器学习算法的分布式训练实现,例如分类、回归和推荐。它还包括了无监督机器学习算法的实现,例如聚类和模式挖掘,这些算法通常用于检测异常。
注意
值得注意的是,在撰写本文时,MLlib 的 RDD-based 和 DataFrame-based API 尚未具备功能平等性,因此可能存在只能在 RDD-based API 中找到所需功能的情况。奇异值分解(SVD)就是一个例子。
如何选择适合任务的算法?您的选择始终取决于您的目标和数据。
虽然本章将涵盖许多算法及其各自的用例,但深度学习、与 PyTorch 集成以及 TensorFlow 分布式策略的主题将在第七章和第八章讨论。
我想提醒您,MLlib 模型实例具有专门的参数文档功能。以下代码示例说明了一旦创建了模型实例,如何立即访问各个参数的文档:
import pprint
pp = pprint.PrettyPrinter(indent=4)
params = model.explainParams()
pp.pprint(params)
示例 6-1 展示了model.explainParams函数的一些样本输出. 由于这是一个GaussianMixture模型(在“高斯混合”讨论中),它包含可用于调整此类型模型的参数的描述。这是一个很好的工具,可以帮助您在探索 MLlib 算法并了解每个算法及其模型输出时进行教育性的旅程。
示例 6-1. 漂亮地打印GaussianMixture模型参数的示例
('aggregationDepth: suggested depth for treeAggregate (>= 2). (default: 2)\n'
'featuresCol: features column name. (default: features, current: '
'selectedFeatures)\n'
'k: Number of independent Gaussians in the mixture model. Must be > 1\. '
'(default: 2, current: 42)\n'
'maxIter: max number of iterations (>= 0). (default: 100, current: 100)\n'
'predictionCol: prediction column name. (default: prediction)\n'
'probabilityCol: Column name for predicted class conditional probabilities. '
'Note: Not all models output well-calibrated probability estimates! These '
'probabilities should be treated as confidences, not precise probabilities. '
'(default: probability)\n'
'seed: random seed. (default: 4621526457424974748, current: 10)\n'
'tol: the convergence tolerance for iterative algorithms (>= 0). (default: '
'0.01, current: 0.01)\n'
'weightCol: weight column name. If this is not set or empty, we treat all '
'instance weights as 1.0\. (undefined)')
现在我们已经介绍了基础知识,本章的学习之旅从监督机器学习开始。让我们深入探讨。
监督机器学习
所有监督算法都期望数据中有一个label列。这允许算法在训练阶段“验证”自己的表现并估计其表现如何。换句话说,在测试阶段,我们使用它来通过比较模型的预测与真实结果来评估算法的质量。标签可以是离散/分类变量,即在所有可能值的集合中的具体值,例如在分类苹果和橙子时是apple,或者连续变量,例如一个人的身高或年龄。这种差异定义了我们希望我们的模型解决的任务类型:分类还是回归。
在某些情况下,标签本身可能是一组标签;我们将在下一节讨论这种可能性。
分类
分类 是通过检查输入特征来计算数据点属于离散类别或类别的概率的任务。这个过程的输出是对数据点属于每个可能类别的概率的预测。许多实践者因为存在逻辑回归算法而混淆回归和分类。虽然逻辑回归输出离散类的概率,类似于分类算法,其他回归算法用于预测连续数值。请注意这种区别!
有三种类型的分类:
二进制
每个输入被分类到两个类别中的一个(是或否,真或假等)。
多类
每个输入被分类到一组超过两个类别中的一个。
多标签
在实践中,每个给定的输入可以有多个标签。例如,一句话可能有两个情感分类,如开心和满足。Spark 不支持此功能;您需要分别训练每个分类器并组合结果。
此外,训练数据中类的分布影响分类过程。当输入数据在类别之间分布不均匀时,数据标签被称为不平衡。在欺诈检测和医学诊断用例中经常见到这种情况,面对这些场景时,您需要考虑并根据可能的情况对特征加权。在训练、验证和测试集中也可能出现不平衡:为了提供期望的结果,这三者都需要平衡。在接下来的小节中,我们将更详细地探讨这个问题,以及如何处理多标签分类场景。
MLlib 分类算法
分类算法期望一个索引标签(通常在 [0,1] 范围内)和一个索引特征向量。用于将分类特征转换为索引的 API,例如 StringIndexer 和 VectorIndexer,在第 4 章中已经讨论过。
MLlib 实现了几种流行的分类算法,列在表 6-1 中。类名模式通常为 *{name}*``Classifier 或仅为 *{name}*,训练后,分类器会生成一个相应名称的模型:*{name}*``ClassificationModel 或 *{name}*``Model。例如,MLlib 的 GBTClassifier 拟合一个 GBTClassificationModel,而 NaiveBayes 拟合一个 NaiveBayesModel。
表 6-1. MLlib 分类算法
| API | 用法 |
|---|---|
LogisticRegression |
二元和多类分类器。可以使用基于 RDD 的 API 在流数据上训练。期望一个索引标签和一个索引特征向量。 |
DecisionTreeClassifier |
二元和多类决策树分类器。期望一个索引标签和一个索引特征向量。 |
RandomForestClassifier |
二元和多类分类器。随机森林是由多个单独的决策树组成的集合或集成,每棵树都训练有离散值。期望一个索引标签和一个索引特征向量。 |
GBTClassifier |
二元梯度提升树分类器(在 Spark v3.1.1 及更高版本中支持)。与 RandomForestClassifier 类似,这是一组决策树的集合。然而,其训练过程不同;因此,它也可用于回归问题。期望一个索引标签和一个索引特征向量。 |
MultilayerPerceptron Classifier |
基于前馈人工神经网络的多类分类器。期望层大小、一个索引特征向量和索引标签。 |
LinearSVC |
线性支持向量机分类器(二元)。期望一个索引标签和一个索引特征向量。 |
OneVsRest |
用于将多类分类减少为二元分类,采用一对多策略。期望一个二元分类器,一个索引特征向量和索引标签。 |
NaiveBayes |
多类分类器,由于仅在训练数据上运行一次,因此被认为效率高。期望数据点的Double权重(用于校正偏斜的标签分布)、索引标签和索引特征的向量。返回每个标签的概率。 |
FMClassifier |
二元因子化机器分类器。期望索引标签和索引特征的向量。 |
实现多标签分类支持
MLlib 不直接支持多标签分类,但我们可以采取几种方法来解决这个问题:
-
寻找另一个可以在大型数据集上训练的工具。
-
为每个标签训练二元分类器,并通过运行相关/不相关预测来输出多标签分类。
-
想出一种方法,通过将任务分解成几个部分,分别解决每个子任务,然后使用代码将结果合并,以利用现有工具。
就第一个选项而言的好消息是,PyTorch 和 TensorFlow 都支持多标签分类算法,因此我们可以利用它们在多标签用例中的功能。
至于第二个选项,如果您是 AI 工程师或经验丰富的 Spark 开发人员,Spark 提供了丰富的 API,您可以使用它来执行以下步骤:
-
向现有 DataFrame 添加多个列,每个列代表一个给定的标签。例如,如果您的原始 DataFrame 只有
id、sentence和sentiment列,您将为每个情绪类别添加自己的列。在sentiment列中值为[happy]的行将在名为is_happy的新列中获得值1.0,在is_indifferent、is_fulfilled和is_sad列中获得值0.0;在sentiment列中值为[happy, indifferent]的行将在is_happy和is_indifferent列中获得值1.0,其他列中获得值0.0。这样,一个句子可以被分类为属于多个类别。图 6-1 说明了这是什么样子。![多标签分类的 DataFrame 输出示例]()
图 6-1. 多标签分类的 DataFrame 输出示例
-
继续为每个标签进行特征化处理。该书的GitHub 仓库包含使用
HashingTF、IDF和其他方法准备 DataFrame 以训练分类器的代码,如示例 6-2 所示。示例 6-2. 为
happy标签准备的 DataFrame,用于训练第一个分类器+-----------------------------------------+-----------+ |features |happy_label| +-----------------------------------------+-----------+ |(65536,[16887,26010],[0.0,0.0]) |0.0 | |(65536,[575871,[0.0]) |1.0 | |(65536,[34782,397581,[0.0,0.0]) |0.0 | |(65536,[11730,34744,49304],[0.0,0.0,0.0])|0.0 | |(65536,[],[]) |1.0 | +-----------------------------------------+-----------+ only showing top 5 rows -
在为每个标签构建二元分类器。此代码片段显示了如何在添加列和索引转换后构建
LogisticRegression分类器:from pyspark.ml.classification import LogisticRegression happy_lr = LogisticRegression(maxIter=10, labelCol="happy_label") happy_lr_model = happy_lr.fit(train_df)您需要将相同的过程应用于其余所有标签。
请记住,机器学习流水线还有更多步骤,例如使用测试数据集评估结果,并使用刚刚构建的分类器。
要测试模型,请在您的测试 DataFrame 上调用transform函数:
result = happy_lr_model.transform(test_dataframe)
示例 6-3 展示了对模型进行测试的输出。
示例 6-3. 斯皮尔曼相关矩阵
Spearman correlation matrix:
DenseMatrix([[ 1\. , -0.41076061, -0.22354106, 0.03158624],
[-0.41076061, 1\. , -0.15632771, 0.16392762],
[-0.22354106, -0.15632771, 1\. , -0.09388671],
[ 0.03158624, 0.16392762, -0.09388671, 1\. ]])
正如在第四章中讨论的那样,这是一个DenseMatrix,它能让我们理解LogisticRegression预测的结果。您可以在预测 DataFrame 的rawPrediction列中找到它(一个包含对每个可能标签的置信度测量的双精度向量),接着是probability列中的概率向量(每个类别的条件概率)和prediction列中的预测结果。请注意,并非所有模型都能准确输出概率,因此应谨慎使用概率向量。
那么不平衡的类标签怎么办?
正如前面提到的,不平衡的数据在分类任务中可能会造成问题。如果一个类别的观测数量非常高,而另一个类别的观测数量非常低,这可能会产生一个有偏见的模型。偏见将倾向于训练数据集中观测数量更多的类别标签,因为在统计上它在训练数据集中更占主导地位。
在模型开发的各个阶段可能会引入偏见。数据不足、数据收集不一致和数据管理不当都可能导致模型决策中的偏见。我们在这里不会深入探讨如何解决现有的模型偏见问题,而是将专注于处理数据集以减少潜在偏见来源的策略。这些策略包括以下几点:
-
筛选更具代表性的类别并对其进行采样,以减少整体数据集中的条目数量。
-
使用基于决策树的集成算法,如
GBTClassifier、GBTRegressor和RandomForestClassifier。在训练过程中,这些算法有一个专用的featureSubsetStrategy参数,您可以设置为auto、all、sqrt、log2和onethird。默认情况下,算法根据给定的特征选择最佳策略。在每个树节点中,算法处理特征的随机子集,并使用结果构建下一个节点。它重复同样的过程,直到使用完所有数据集。这是因为它对参数的随机方法很有用,但是根据观察值的分布,仍然可能存在模型结果的偏差。假设您有一个包含 99 个苹果和 1 个橙子的数据集。假设在随机过程中,算法选择了一个批次包含 10 个单位。它将包含最多 1 个橙子和 9 或 10 个苹果。分布仍然严重偏向苹果,因此模型很可能会始终预测apple—这对于训练数据集来说是正确的,但在实际世界中可能完全不准确。您可以在文档中详细了解此问题。
下面是设置策略的方法:
from pyspark.ml.classification import RandomForestClassifier
# Train a RandomForestClassifier model with a dedicated feature
# subset strategy
rf = RandomForestClassifier(labelCol="label", featuresCol="features",
featureSubsetStrategy="log2")
model = rf.fit(train_df)
回归
是时候学习回归了!这个任务也被称为回归分析—估计一个或多个因变量与一个或多个自变量之间关系的过程。自变量的值应允许我们预测因变量的值。如果情况不是这样,您可以使用第五章讨论的 API 选择仅添加价值的特征。
从俯视角度看,有三种类型的回归:
简单
只有一个自变量和一个因变量:一个值用于训练,一个值用于预测。
多重
这里我们有一个因变量需要使用多个独立变量进行训练和输入预测。
多元
与多标签分类类似,有多个变量需要使用多个独立变量进行训练和输入预测。因此,输入和输出都是数值向量。
许多用于分类的算法也用于简单和多重回归任务。这是因为它们支持离散和连续数值预测。
在撰写本文时,尚无专用的 API 可用于多元回归,因此您需要设计系统以支持此用例。这个过程类似于我们为多标签分类所做的:准备数据,独立训练每个变体,测试和调整多个模型,最后组装预测结果。
要了解回归,我们将尝试使用Kaggle上的 CO[2]排放数据集来预测车辆的 CO[2]排放量。为此,我们将查看诸如公司、车型、发动机尺寸、燃料类型、消耗量等特征!
正如你将看到的,处理数据来解决这样的问题需要对其进行特征化、清理和格式化,以适应算法。
在数据集中有 13 列。为了加快索引和散列的速度,我们只会对连续特征使用FeatureHasher。该选择器要求我们指定数值特征的性质,是离散的还是连续的:
from pyspark.ml.feature import FeatureHasher
cols_only_continuous = ["Fuel Consumption City (L/100 km)",
"Fuel Consumption Hwy (L/100 km)",
"Fuel Consumption Comb (L/100 km)"]
hasher = FeatureHasher(outputCol="hashed_features",
inputCols=cols_only_continuous)
co2_data = hasher.transform(co2_data)
注意inputCols如何很好地接受一个列表——这使得重用我们的代码和开发更干净的代码变得更容易!
hashed_features的类型是SparseVector。查看示例 6-4。由于散列函数的复杂性,我们最终得到了大小为 262,144 的向量。
示例 6-4. hashed_features稀疏向量
+---------------------------------------------+
|hashed_features |
+---------------------------------------------+
|(262144,[38607,109231,228390],[0.0,9.9,6.7]) |
|(262144,[38607,109231,228390],[0.0,11.2,7.7])|
|(262144,[38607,109231,228390],[0.0,6.0,5.8]) |
|(262144,[38607,109231,228390],[0.0,12.7,9.1])|
|(262144,[38607,109231,228390],[0.0,12.1,8.7])|
+---------------------------------------------+
only showing top 5 rows
这里有很大的改进空间,因为大多数向量都是稀疏的,对我们可能没有意义。所以现在是自动选择特征的时候了:
from pyspark.ml.feature import UnivariateFeatureSelector
selector = UnivariateFeatureSelector(outputCol="selectedFeatures",
featuresCol="hashed_features",
labelCol="co2")
selector.setFeatureType("continuous")
selector.setLabelType("continuous")
model = selector.fit(co2_data_train)
output = model.transform(co2_data_test)
这个选择器将特征数量从 262,144 个减少到 50 个。
注意
注意我们实际上使用FeatureHasher增加了维度。这是因为我们没有首先对数据进行归一化,以使我们更容易回溯实验。对于实际用例,最好在散列之前对数据进行归一化。
下一步是构建机器学习模型。MLlib 提供了多种算法供我们选择,例如AFTSurvivalRegression、DecisionTreeRegressor和GBTRegressor(完整列表请参见文档)。AFT 代表加速失效时间;这种算法可用于发现工厂中机器的使用寿命。DecisionTreeRegressor在分类特征上表现最佳,这些特征具有有限的类别数。因此,它无法预测看不见的值,就像其他回归器一样。GBTRegressor是一个梯度增强树回归器,它使用串行方式训练的一组决策树集成。它将训练数据拆分为训练数据集和验证数据集,并在算法的每次迭代中使用验证集来减少训练数据的误差。
如果你想知道它与我们之前看到的RandomForestClassifier有何不同,主要区别在于 GBT 算法一次构建一个树,帮助修正前一个树的错误,而随机森林算法并行地随机构建树:每个工作节点的子集形成自己的树,然后这些树被收集到主节点上,主节点将工作节点的输出汇总成最终模型。GBTRegressor和RandomForestClassifier都支持连续和分类特征。
在下一个示例中,我们将尝试使用 MLlib 的GBTRegressor来查看它是否表现更好。虽然由于其顺序性质而可能需要更长时间来训练,但优化函数应该有助于产生更精确的结果:
from pyspark.ml.regression import GBTRegressor
# define the classifier
gbtr = GBTRegressor(maxDepth=3, featuresCol="selectedFeatures", labelCol="co2")
# build the model
model = gbtr.fit(input_data)
# use the model
test01 = model.transform(test_data)
现在我们有了一个模型,可以输入用于训练的数据。我们还需要验证没有过拟合的情况。如果test01的预测是 100%准确的,那么很可能是过拟合——这种情况也可能发生在低准确率下,但是每当您看到准确率接近或达到 100%时,都应该持怀疑态度。我们将在“机器学习流水线”中更详细地讨论评估模型的问题。现在,让我们看一下在prediction列中显示的示例,如示例 6-5 所示。
示例 6-5. 车辆二氧化碳排放的预测与实际情况
+---------+----------+-------------+----+------------------+
|Fuel Type| Model|Vehicle Class| co2| prediction|
+---------+----------+-------------+----+------------------+
| AS5| ILX| COMPACT|33.0| 32.87984310695771|
| M6| ILX| COMPACT|29.0|28.261976730819185|
| AV7|ILX HYBRID| COMPACT|48.0| 49.88632059287859|
| AS6| MDX 4WD| SUV - SMALL|25.0|24.864078951152344|
| AS6| RDX 4WD| SUV - SMALL|27.0| 26.95552579785164|
+---------+----------+-------------+----+------------------+
only showing top 5 rows
如您所见,prediction列输出的数据点与co2列中的实际数据非常相似。例如,在第一行中,预测值为 32.879...,而实际的co2值为 33.0。这里的误差是可以管理的,对于其余的行也是如此。这个事实充当了算法训练正确方向的一个主要指标,因为预测结果与实际值并非完全相同(这意味着过拟合的可能性较低),但它们非常接近。如前所述,我们仍然需要运行统计评估测试来衡量模型的整体有效性。
MLlib 还支持其他可以解决这个问题的机器学习算法,例如FMRegression(FM 代表因子分解机)。这种算法基于梯度下降算法,具有专用的损失函数,也称为优化函数。梯度下降是一种迭代优化算法,它遍历数据,搜索能够使精度损失最小化的规则或定义。理论上,其性能随着每次迭代的进行而改善,直到达到损失函数的最佳值。
FMRegression算法的最大迭代次数默认设置为 100,但我们可以使用setMaxIter函数进行调整。这里使用的优化函数是SquaredError。SquaredError实现了MSE函数,用于计算每次迭代中的平均平方误差。这是算法试图减少的内容:在给定迭代中实际值与预测值之间的“距离”的平方和。在标准线性模型的假设下,MSE 被认为是误差方差的无偏估计量。
如果 FM 听起来很熟悉,那是因为也有一个FMClassifier。它们之间的主要区别在于损失函数。分类器使用LogisticLoss,有时称为熵损失或对数损失。LogisticLoss函数也用于LogisticRegression中。我们不会深入探讨它背后的理论数学,因为有很多介绍性的机器学习书籍涵盖了这一点(例如哈拉·尼尔森的Essential Math for AI,同样出自 O’Reilly)。但重要的是,您要掌握分类和回归算法之间的相似性和差异。
推荐系统
推荐系统通常使用电影数据集来教授,例如MovieLens,其目标是根据其他用户喜欢的内容和/或用户偏好(如流派)向用户推荐电影。您可以在许多在线平台上找到实施推荐系统的例子,例如亚马逊的电子商务系统或 Netflix 等流媒体平台。它们基于关联规则学习,算法旨在学习电影和用户之间的关联。
从高层次来看,我们可以根据可用的数据(关于用户和内容的元数据以及用户与内容之间的互动数据)将它们分为三类:
基于内容
算法利用关于内容和用户的可用元数据,包括用户以前观看和评分的内容,喜爱的流派,电影流派等,并根据这些信息生成推荐。这可以通过基于规则的功能来实现,并不一定需要机器学习。
协同过滤
在这种情况下,电影和用户没有可用的元数据;我们只有定义用户与内容之间互动的互动矩阵(即每个用户观看或评分的电影)。该算法搜索用户互动之间的相似性来提供推荐。
神经网络
考虑到用户和内容的元数据以及互动矩阵,您可以利用神经网络进行推荐。
协同过滤的 ALS
MLlib 为协同过滤提供了一个文档完善的解决方案,称为ALS(交替最小二乘)。其目标是填补用户-项目互动矩阵中的缺失值。它还提供了解决冷启动场景的解决方案,即用户对系统是新的,没有之前的数据可用来进行准确的推荐。您可以在MLlib 文档中了解更多信息。
无监督机器学习
无监督算法用于当数据没有标签但我们仍然希望自动发现有趣的模式、预测行为或计算相似性时。这些算法可以与监督算法交替使用作为特征提取过程的一部分。常见的无监督机器学习任务包括频繁模式挖掘和聚类。让我们看看 MLlib 如何支持这些任务。
频繁模式挖掘
频繁模式挖掘属于关联规则学习的范畴,其基于识别规则来揭示数据中变量之间的关系。关联规则挖掘算法通常首先在数据集中查找频繁项,然后查找频繁对或项集(例如,经常一起查看或购买的项目)。规则遵循前件(if)和后件(then)的基本结构。
MLlib 提供两个频繁模式挖掘函数,可以用作推荐引擎的预处理过程,例如从文本语料库中提取有意义的模式以检测用户对电影的情感:FPGrowth 和 PrefixSpan。我将重点介绍这里的聚类算法,因为这些可以单独使用,而通常您需要将多个频繁模式挖掘算法堆叠在一起才能达到最终结果。您可以在MLlib 文档中进一步了解频繁模式挖掘算法。
聚类
聚类是一种发现数据点之间隐藏关系的分组技术。聚类经常用于客户分割、图像处理和检测、垃圾邮件过滤、异常检测等领域。
在聚类过程中,每个项目被分配到一个由其中心定义的组中。项目属于某个组的可能性通过其与中心的距离计算得出。算法通常尝试通过改变组的中心点来优化模型。
聚类算法的名称通常包含字母k,例如k-最近邻 (k-NN) 和 k-均值 (k-means)。其含义取决于算法本身。通常代表预定义的簇/主题数目。MLlib 算法有一个默认整数k值,并且您可以使用setK方法或作为参数传递它。一些算法要求数据具有一个weightCol——特别是 MLlib 的KMeans、GaussianMixture、PowerIterationClustering 和 BisectingMeans 预期在训练数据集中有一个非负weightCol,表示数据点相对于簇中心的权重。如果特定数据点的权重很高且相对于簇中心距离较远,则它对优化函数(换句话说,该点的损失)施加的“成本”将很高。算法将尝试通过将簇中心移动到这样的数据点附近(如果可能的话)来减少整体损失。
几乎所有的聚类算法都需要种子值(唯一的例外是 PowerIterationClustering)。种子值用于随机初始化一组聚类中心点(类似于 x 和 y 坐标),并且随着算法的每次迭代,中心点根据优化函数进行更新。
现在您了解了聚类是什么,我们可以回到我们的 CO[2] 排放预测目标,看看是否可以识别诸如燃料类型、消耗、汽缸等列之间的共性。MLlib 提供五种聚类算法。让我们来看看这些算法,看看哪些可能适合这个任务:
LDA
LDA(Latent Dirichlet Allocation)是一种通用的统计算法,用于进化生物学、生物医学和自然语言处理。它期望一个向量,表示文档中每个单词的计数;由于我们的场景专注于诸如燃料类型之类的变量,LDA 不适合我们的数据。
GaussianMixture
GaussianMixture 算法通常用于识别更大组内的子组的存在。在我们的上下文中,它可以用于识别每个汽车制造商组内不同类别的子组,例如奥迪组中的紧凑型车类别和宾利组。然而,GaussianMixture 在高维数据上表现不佳,这使得算法难以收敛到令人满意的结论。当特征/列的数量接近或大于观察/行的数量时,数据被认为是高维的。例如,如果我有五列和四行,我的数据就被认为是高维的。在大数据集的世界中,这种情况不太可能发生。
KMeans
KMeans 是最流行的聚类算法,因其简单和高效而广受欢迎。它将一组类别作为输入,创建随机中心点,并开始迭代数据点和中心点,旨在将相似的数据点分组在一起,并找到最优的中心点。该算法始终收敛,但结果的质量取决于聚类数(k)和迭代次数。
BisectingKMeans
BisectingKMeans 基于 KMeans 算法,具有分层组的层次结构。它支持两种方式计算距离:euclidean 或 cosine。模型可以被视为一棵树,具有叶子聚类;当训练开始时,只有一个根节点,并且每次迭代时节点分为两个以优化模型。如果您想要表示组和子组,这是一个很好的选择。
PowerIterationClustering
PowerIterationClustering(PIC)实现了Lin and Cohen 算法。它是一个可扩展和高效的选项,用于根据边属性的成对相似性来聚类图的顶点。请注意,此算法不能在 Spark 管道中使用,因为它尚未实现Estimator/Transformer模式(关于此的更多信息请见“机器学习管道”)。
酷,酷,酷!现在我们了解了我们的选项,让我们尝试其中一个。我们将选择GaussianMixture,因为我们的数据集仅有 11 列,比这多得多的数据。我们将使用经过预处理和特征工程处理后的 CO[2]排放汽车数据集,其中包括label列(要了解端到端教程,请查看书籍的GitHub 仓库中的ch06_gm_pipeline.ipynb文件)。
在这种情况下,k的值将是我们数据集中的汽车制造商数量。要提取它,我们使用distinct().count():
dataset.select("Make").distinct().count()
结果是42。多么有趣的数字啊。 😃 我们将其传递给构造函数,同时设置了几个其他参数:
from pyspark.ml.clustering import GaussianMixture
gm = GaussianMixture(k=42, tol=0.01, seed=10,
featuresCol="selectedFeatures", maxIter=100)
model = gm.fit(dataset)
现在我们有了模型,我们可以获取表示模型的摘要对象:
summary = model.summary
所有的聚类和分类算法都有一个摘要对象。在聚类中,它包含了预测的集群中心、转换后的预测、集群大小(即每个集群中的对象数)以及基于特定算法的专用参数。
例如,通过在摘要上运行distinct().count(),我们可以了解算法在最后收敛到了多少个群组:
summary.cluster.select("prediction").distinct().count()
在我们的案例中,我们得到了17。现在,我们可以尝试减少k的值,看看是否能获得更好的收敛性,或者尝试增加迭代次数,看看这是否对那个数字有影响。当然,算法执行的迭代次数越多,处理时间就越长,因此在大数据集上运行时,您应该对此数量保持谨慎。确定需要多少次迭代是一个试错过程。务必将其添加到您的实验测试中,同时尝试不同的性能指标。
另一种衡量模型性能的方法是查看logLikelihood,它代表模型发现的群组之间差异的统计显著性:
summary.logLikelihood
通过 200 次迭代,我们得到约 508,076 个可能性评分。这些评分没有被标准化,因此直接比较它们是困难的;然而,较高的评分表示实例与其簇之间的相关性可能性更大。我们只知道较高的评分意味着实例更可能与它们的簇相关联。因此,这是比较同一数据上一个模型与另一个模型性能的好方法,但不一定能评估模型本身的性能。这也是为什么提前定义实验目标如此重要的一个原因。如果你想了解更多关于统计和可能性测量的知识,我建议阅读《数据科学实用统计》,作者是彼得·布鲁斯、安德鲁·布鲁斯和彼得·格德克(O’Reilly)。
假设我们继续使用 maxIter = 200 进行探索,并获得相同数量的不同预测:17. 基于此,我们决定将 k 改为 17。
我们的下一步可能是检查簇的大小,以确保没有包含零数据点的组:
summary.clusterSizes
这产生了以下输出,其中索引表示组索引:
[2200, 7, 1733, 11, 17, 259, 562, 12, 63, 56, 1765, 441, 89, 88, 61, 13, 8]
由于 clusterSizes 是 Array 类型,您可以使用 numpy 和 matplotlib 等工具创建值的直方图。图 6-2 显示了组/簇大小的分布结果。

图 6-2. 组或簇大小的直方图
评估
评估阶段是机器学习过程中的一个重要部分——这是我们估计模型性能的方式。MLlib 有六个评估器,它们都实现了 Spark 抽象类 Evaluator。它们大致可以分为两组:监督和无监督。
注意
Evaluator 是一个类,允许我们根据特定的机器学习评估标准来查看给定模型的表现。
监督评估器
在监督学习中,我们有测试数据的标签,因此我们可以生成多个指标来估计性能。为了能够做到这一点,评估器首先计算混淆矩阵,它比较了预测标签和实际标签。对于二元分类任务,从概念上来说,结果看起来会像 图 6-3,每个方框的范围为 [0,``*数据集大小*``]。

图 6-3. 二元混淆矩阵
真实和虚假代表预测的准确性,正和负代表二元预测(也可以是 1 和 0)。有四个类别:
真阳性(TP)
预测为正的正标签
真阴性(TN)
预测为负的负标签
假阳性(FP)
预测为正的负标签
假阴性(FN)
预测为负的正标签
基于这些数值,Spark 的估算器可以提供许多指标;您可以在文档中找到详细信息。
当我们希望对多类和多标签分类使用相同的流程时,混淆矩阵将相应增长,以捕获所有的标签可能性。在数据不平衡的情况下,您可能需要编写自己的评估器。您可以使用 Spark 的广泛 API 来完成这一点。
除了基本的Evaluator,Spark 还提供以下 API 来计算性能指标:
BinaryClassificationEvaluator
一个二元评估器,期望输入列为rawPrediction、label和weight(可选)。它可以用于输出接收器操作特征曲线(ROC)和精确率-召回率(PR)曲线下面积。
MulticlassClassificationEvaluator
一个用于多类分类的评估器,期望输入列为prediction、label、weight(可选)、以及probabilityCol(仅适用于logLoss)。它具有专用指标,如precisionByLabel、recallByLabel等,以及一个特殊的矩阵hammingLoss,用于计算错误预测的标签比例。当比较二元和多类分类模型时,请记住精确度、召回率和 F1 分数是设计用于二元分类模型的;因此,最好将hammingLoss与accuracy进行比较。
MultilabelClassificationEvaluator
用于多标签分类的评估器(在 Spark 3.0 中添加)。在撰写本文时,此评估器仍处于实验阶段。它期望两个输入列,即prediction和label,并提供专用指标,如microPrecision、microRecall等,这些指标是跨所有预测类别的平均值。
注意
实验阶段中的功能通常仍在开发中,尚未完全准备好实施。在大多数情况下,这些功能仅限于少数模块,并主要用于允许开发人员获取未来软件开发的知识和见解。开源技术经常引入实验性功能,可以在生产代码中加以利用,但这些功能可能会在软件的未来版本中进行更改,并且贡献者并不承诺继续支持它们或将它们转变为成熟的功能。
RegressionEvaluator
此评估器期望输入列为prediction、label和weight(可选),并生成指标,如mse(预测与实际标签之间距离的均方误差)和rmse(前述值的平方根)。
RankingEvaluator
此评估器(在写作时添加到 Spark 3.0 中,并且仍处于实验阶段)期望输入列prediction和label。它通常用于评估搜索结果的排名。它有一个变量k,你可以设置以获取前 k 个结果的矩阵平均值。想象一个电影推荐系统,可以输出 5 或 10 个推荐:平均值将根据返回的推荐数量而改变,评估结果可以帮助你做出选择。此评估器的输出基于 MLlib 的基于 RDD 的RankingMetricsAPI;你可以在文档中详细了解它。
无监督评估器
MLlib 还为无监督学习提供了一个评估器,我们可以通过与 TensorFlow、PyTorch 或其他能处理 Spark DataFrames 的库建立桥接来访问更多选项。MLlib 的聚类结果评估器是ClusteringEvaluator。它期望两个输入列prediction和features,以及一个可选的weight列。它计算轮廓系数,你可以在两种距离度量平方欧氏和余弦之间进行选择。轮廓系数是一种评估聚类一致性和有效性的方法;它通过计算每个数据点与其所在簇中其他数据点的距离,并将其与其与其他簇中点的距离进行比较来实现这一点。输出是基于点权重的所有点的平均轮廓值。
回顾我们使用GaussianMixture和KMeans进行分类的示例,我们可以评估这些模型以做出更好的决策。你可以在本书的GitHub 仓库中找到本章节的代码以及其他内容:
from pyspark.ml.evaluation import ClusteringEvaluator
evaluator = ClusteringEvaluator(featuresCol='selectedFeatures')
evaluator.setPredictionCol("prediction")
print("kmeans: "+str(evaluator.evaluate(kmeans_predictions)))
print("GM: "+ str(evaluator.evaluate(gm_predictions)))
默认的距离计算方法是平方欧氏距离。它给出以下输出:
kmeans: 0.7264903574632652
GM: -0.1517797715036008
我们如何判断哪一个更好?评估器有一个专门的函数名为isLargerBetter,可以帮助我们确定这一点:
evaluator.isLargerBetter()
在我们的案例中,它返回True,表明KMeans在我们的数据上表现更好。不过,我们还没有结束,让我们看看余弦距离:
evaluator.setDistanceMeasure("cosine")
print("kmeans: "+str(evaluator.evaluate(kmeans_predictions)))
print("GM: "+ str(evaluator.evaluate(gm_predictions)))
其输出是:
kmeans: 0.05987140304400901
GM: -0.19012403274289733
KMeans仍然表现更好,但在这种情况下,差异要小得多。这可能是因为模型本身的实现方式不同;例如,KMeans将欧氏距离作为聚类的默认距离度量,因此在基于平方欧氏距离评估时通常表现更好。换句话说,需要谨慎解释这样的指标。为了更加清晰,我们可以将评估过程与对测试和训练数据集、算法和评估器的调整结合起来。是时候进行调优了!
超参数和调优实验
如果我告诉您,有工具可以让我们运行多个实验,生成多个模型,并以自动化方式提取最佳模型,您会怎么想?这正是我们将在本节中介绍的内容!
所有的机器学习过程在能够准确预测现实世界事件之前都需要调整和实验。这是通过将数据集分割为多个训练和测试集和/或调整算法参数来完成的。
构建参数网格
在下面的代码中,例如,我们使用ParamGridBuilder().addGrid来定义多个最大迭代值,以使用两种可用的距离度量构建k-means 模型的参数(param)网格:
from pyspark.ml.tuning import ParamGridBuilder
grid = ParamGridBuilder().addGrid(kmeans.maxIter, [20,50,100])
.addGrid(kmeans.distanceMeasure, ['euclidean','cosine']).build()
参数网格是一种包含每个参数离散数值的网格或表格,可用于在训练过程中迭代不同的参数值组合,以寻找最优值。ParamGridBuilder只是一个工具,允许我们更快地构建它。您可以将其与任何接受参数数组的 MLlib 函数一起使用。在继续之前,让我们也通过为其添加专用网格来调整我们评估器的参数:
grid = ParamGridBuilder().addGrid(kmeans.maxIter, [20,50,100])
.addGrid(kmeans.distanceMeasure, ['euclidean','cosine'])
.addGrid(evaluator.distanceMeasure, ['euclidean','cosine']).build()
将数据分割为训练集和测试集
接下来,我们将使用TrainValidationSplit来随机将数据分为训练集和测试集,用于评估每个参数组合:
from pyspark.ml.tuning import TrainValidationSplit
tvs = TrainValidationSplit(estimator=kmeans, estimatorParamMaps=grid,
evaluator=evaluator, collectSubModels=True, seed=42)
tvs_model = tvs.fit(data)
默认情况下,TrainValidationSplit使用数据的 75%进行训练和 25%进行测试。您可以通过初始化时设置参数trainRatio来更改这一比例。TrainValidationSplit是一个估计器,因此它实现了fit并输出一个转换器。tvs_model表示在验证各种参数组合后识别出的最佳模型。我们还可以告诉TrainValidationSplit收集所有表现不佳的子模型,而不是仅保留最佳模型;我们可以通过将参数collectSubModels设置为True来实现,就像这个示例中展示的那样。
警告
使用collectSubModels时请谨慎。当您希望将多个机器学习模型堆叠作为工作负载的一部分,或者当您对验证指标获得类似结果并希望保留所有模型以继续实验以确定最佳模型时,考虑此选项。有关访问子模型的详细信息以及在执行此操作时需要小心的原因,请参见“如何访问各种模型?”。
如何确定它选择了最佳模型?让我们看看验证指标:
tvs_model.validationMetrics
这让我们能够查看在我们的评估器下各种实验的表现,如示例 6-6 所示。
示例 6-6. 验证指标输出
[0.04353869289393124,
0.04353869289393124,
0.6226612814858505,
0.6226612814858505,
0.04353869289393124,
0.04353869289393124,
0.6226612814858505,
0.6226612814858505,
0.04353869289393124,
0.04353869289393124,
0.6226612814858505,
0.6226612814858505]
请记住,流程中的每个参数都可以添加到 ParamGridBuilder 中。您还可以使用该函数设置标签和特征的具体列。如果您有参数字典或(``*parameter*``, *value*``) 对的列表,可以使用 baseOn 函数,它在后台为您运行 foreach 循环,而不是使用 addGrid 逐个添加。
交叉验证:测试模型的更好方法
使用 TrainValidationSplit 无法尝试多个数据拆分的组合。为此,MLlib 提供了 CrossValidator,它是一个实现了k-折交叉验证的评估器。这是一种将数据集拆分成一组非重叠随机分区“折叠”,分别用作训练和测试集的技术。
由于使用该操作时需要进行大量计算,因此我们会训练k倍于参数映射模型的数量。换句话说,如果 numFolds 是 3,并且我们有一个包含 2 个值的参数网格,则会训练 6 个机器学习模型。当 numFolds=3 并且用于评估器和算法的先前参数网格时,我们将训练 numFolds 倍于网格大小的模型,即 12 个,¹ 机器学习模型——总共 36 个。
这是我们如何定义它的方式:
from pyspark.ml.tuning import CrossValidator
cv = CrossValidator(estimator=kmeans, estimatorParamMaps=grid,
evaluator=evaluator, collectSubModels=True,
parallelism=2, numFolds=3)
cv_model = cv.fit(data)
与 TrainValidationSplit 的 validationMetrics 类似,CrossValidatorModel 具有 avgMetrics 参数,可用于获取训练指标。对于每个参数网格组合,它保存了 numFold.parallelism 用于并行评估模型;当设置为 1 时,执行顺序评估。parallelism 在 TrainValidationSplit 中具有相同的含义。
执行 cv_model.avgMetrics 将会得到如 示例 6-7 所示的输出。
示例 6-7. 模型训练平均评估指标输出
[0.057746040674997036,
0.057746040674997036,
0.5811536043895275,
0.5811536043895275,
0.057746040674997036,
0.057746040674997036,
0.5811536043895275,
0.5811536043895275,
0.057746040674997036,
0.057746040674997036,
0.5811536043895275,
0.5811536043895275]
avgMetrics 数组中的每个单元格都是使用以下方程计算得出的:
机器学习流水线
本节介绍了机器学习流水线的概念,它由您到目前为止学到的各种构建模块组成:特征处理、模型训练、模型评估和模型调优。图 6-5 可视化了端到端的过程:数据摄取、预处理和清理、数据转换、模型构建和调优,以及模型评估。

图 6-5. Spark 中的机器学习工作流程
管道 API 提供了基于 DataFrame 和 Dataset 的两个主要组件:
转换器
一个以某种方式转换数据的函数。正如您在第四章中学到的那样,转换器接收一个 DataFrame,并输出一个修改后的新 DataFrame。转换器还可以接收 Spark 参数,正如您将在接下来的示例中看到的那样;这允许我们影响函数本身,包括指定输入和输出列的名称。提供临时参数使我们能够评估和测试使用同一对象的多种变体。
所有转换器实现函数Transformer.transform。
估计器
抽象任何适合或训练数据的算法概念的对象。估计器输出模型或转换器。例如,像GaussianMixture这样的学习算法是一个估计器,调用其fit方法训练GaussianMixtureModel,这是一个模型,因此也是一个转换器。与转换器一样,估计器可以接收输入参数。
所有估计器实现函数Estimator.fit。
回到我们的 CO[2]排放预测示例,我们的UnivariateFeatureSelector是一个估计器。正如我们在outputCol和featuresCol参数中指定的那样,它接收一个名为hashed_features的列,并生成一个新的 DataFrame,其中附加了一个名为selectedFeatures的新列:
selector = UnivariateFeatureSelector(outputCol="selectedFeatures",
featuresCol="hashed_features",
labelCol="CO2")
model_select = selector.fit(data)
transformed_data = model_select.transform(data)
对估计器调用fit会创建一个UnivariateFeatureSelectorModel的实例,我们将其分配给model_select。model_select现在是一个转换器,我们可以使用它来创建一个新的 DataFrame,并附加一个名为selectedFeatures的列。
估计器和转换器本身都是无状态的。这意味着一旦我们使用它们创建模型或另一个转换器实例,它们不会更改或保留关于输入数据的任何信息。它们仅保留参数和模型表示。
对于机器学习而言,这意味着模型的状态随时间不变。因此,在在线/自适应机器学习中,新的实时数据按顺序变得可用并用于更新模型时,您需要使用 PyTorch、TensorFlow 或支持此功能的另一个框架。
构建管道
在机器学习中,通常会按顺序运行多个步骤来生成和评估模型。MLlib 提供了一个专用的Pipeline对象,用于构建作为单元运行的一系列阶段。MLlib 管道是一个估计器,具有专用的stages参数;每个阶段都是一个转换器或估计器。
在本章的前面,我们定义了一个哈希器、一个选择器和一个高斯混合算法。现在,我们将通过将数组分配给stages参数将它们全部放在管道中:
from pyspark.ml import Pipeline
pipeline = Pipeline(stages=[hasher, selector, gm])
# Fit the pipeline to training data
model = pipeline.fit(data)
记得根据阶段的顺序设置输入和输出列!例如,hasher的输出列可以作为selector或gm阶段的输入。尽量只生成您将使用的列。
警告
如果您未正确初始化管道,您的管道可能会因各种异常而失败。如果您正在动态添加阶段,请确保用空列表初始化 stages 属性,如下所示:
Pipeline(stages=[])
管道 API 如何分割工作?
由于管道实例是一个评估器,我们可以将管道传递给任何接受评估器作为参数的函数。这包括我们之前学习的所有用于数据集拆分的函数,例如 CrossValidator。
这里是这个工作的一个示例:
from pyspark.ml import Pipeline
pipeline = Pipeline(stages=[hasher,selector, gm])
cv = CrossValidator(estimator=pipeline, estimatorParamMaps=grid,
evaluator=evaluator, collectSubModels=True,
numFolds=3)
如您所见,这非常简单明了!
持久化
机器学习管道的一个重要部分是通过将其保存到磁盘来持久化输出。这将使您能够将模型部署到暂存或生产环境中,与同事分享以进行协作工作,或仅仅为将来参考而保存。MLlib 提供了使用 .write().save(``*model_path*``) 将其所有模型(包括 PipelineModel)保存到磁盘的功能:
path = "/cv_model"
cv_model.write().save(path)
要从磁盘加载 MLlib 模型,您必须知道用于保存模型的模型类。在我们的情况下,CrossValidator 生成 CrossValidatorModel,这是我们通过 load 函数加载模型所使用的内容:
from pyspark.ml.tuning import CrossValidatorModel
read_model_from_disk = CrossValidatorModel.load(path)
现在我们已经将模型加载到内存中,并且已经准备好供使用。
您还可以将模型导出为像 ONNX 这样的可移植格式,然后使用 ONNX 运行时来运行模型,尽管并非所有 MLlib 模型都支持此功能。我们将在第八章中讨论这个及其他格式。
摘要
本章介绍了 MLlib 的监督和无监督机器学习算法,训练和评估这些算法,并构建用于协作结构化工作的管道。它包含了许多关于使用 MLlib 工作的信息和见解,您在学习更多有关机器学习和 Spark 的知识时可能会想要重新访问。
接下来的章节将向您展示如何利用迄今为止所做的所有工作,并通过与其他框架(如 PyTorch 和 TensorFlow)的桥接来扩展 Spark 的机器学习能力。
¹ 我们为 evaluator.distanceMeasure 指定了 2 个选项,为 kmeans.distanceMeasure 指定了 2 个选项,并为 kmeans.maxIter 指定了 3 个选项,因此网格大小为 2 * 2 * 3 = 12。
第七章:连接 Spark 和深度学习框架
到目前为止,本书的主要重点是利用 Spark 在扩展机器学习工作负载方面的能力。但是 Spark 通常是可扩展分析工作负载的自然选择,在许多组织中,数据科学家可以利用支持它的现有团队。在这种情况下,数据科学家、数据工程师、机器学习工程师和分析工程师都是数据的消费者和/或创建者,并共同承担机器学习基础设施的责任。使用像 Apache Spark 这样的可扩展、多用途、通用工具有助于促进这种协作工作。
但是尽管 Spark 是一个功能强大的通用引擎,具有丰富的功能,但它缺乏一些完全支持可扩展深度学习工作流所需的关键特性。这是开发框架的自然诅咒:在分布式世界中,每个框架都需要在基础设施级别做出决策,这些决策后来限制了 API 的可能性并限制了其性能。Spark 的限制主要与其基础前提相关,即所有算法实现必须能够无限扩展,这要求模型能够在规模上执行其学习过程,使每个步骤/迭代分布在多台机器上。这与 Spark 框架的哲学一致,即集群的大小改变作业的持续时间,而不是运行算法的能力。这意味着部分训练必须以幺半群的方式实现,这意味着数据集上的操作是一个封闭集,实现了结合二元操作的规则,这在特别是深度学习算法中并不总是容易保证。
对于深度学习算法来说,很难将每个学习步骤分解为可以后续聚合的子任务,这是传统的 MapReduce 范式 Spark 构建的难点。这些算法不容易分布,因为它们的激活函数需要看到整个数据集,或者接受一定程度的不精确性。这有时会使在深度学习应用(如自然语言处理和图像处理)上工作的数据科学家难以充分利用 Spark 的优势。这些系统也可以使用各种技术对大型数据集进行训练,这意味着为了有效地开发深度学习模型,您可能必须依赖更广泛的算法。
本章将讨论如何从 Spark 框架过渡到深度学习框架,而不是完全使用其他工具。为什么呢?在组织中,当存在一个良好支持的分布式系统来处理和消化数据时,最佳实践是利用已有的工具并充分利用它,而不是引入新的复杂框架。引入一个新的分布式框架可能需要几个月甚至几年的时间,这取决于团队规模、工作负载、任务对业务目标的重要性以及短期与长期投入的努力。如果你的组织已经在使用 Spark,你可以利用对该工具的熟悉程度更快地实现解决方案运行起来。
谷歌的文章“机器学习系统中的隐藏技术债务”告诉我们,训练机器学习模型只是问题的一部分,而且相对较小。基于这篇文章的图 7-1,展示了机器学习/训练代码本身与系统的各个其他部分之间的关系,它们为支持提供了依赖。所有这些元素的功能影响机器学习代码的可行性;这些组件中的任何错误都会在某个时刻影响机器学习代码。例如,如果我的数据收集过程存在缺陷,并提供了与机器学习代码期望的完全不同的模式的数据集,那么在尝试训练模型时就会遇到问题。

图 7-1. 机器学习代码所需的周边基础设施
确保机器学习系统的所有单独部分状态良好,并且能够良好协同工作,需要更多的工程师、更多的框架和更多的整体组织投资,而不仅仅是开发模型本身。通过利用已在您的组织中使用的工具,而不是通过引入新的工具来增加复杂性,可以更容易地克服实现分布式集群所需的工程支持的挑战。在我们的案例中,我们可以使用 Apache Spark 进行数据收集、验证、特征提取、分析等工作,然后再过渡到其他框架,只是为了 Spark 无法提供的功能,从而利用这些框架中存在的算法,同时更容易获得整个组织的支持。
现在你对为什么这么做有了更好的理解,让我们探讨一些使我们能够执行分布式训练工作流的技术。我在这里重点介绍两个在行业中有良好影响力并受到可靠组织信任的工具:PyTorch 和 TensorFlow。这两者提供了摄入和预处理大型数据集的能力,但与 Spark 相比,这通常更难实现,而你所合作的团队可能没有这些专用于机器学习的框架的经验。
即使你的团队同意投入时间和精力来学习这些工具,要求同行团队将其数据摄入、数据处理、特征提取等过程转移到新框架,需要对其余工作负载的基础设施进行重大改变,这将影响你执行手头任务的能力。更高效的解决方案是找出如何将我们已经在大部分工作中使用的工具与提供我们需要解决深度学习要求的额外功能的工具结合起来,充分利用它们功能的子集。这降低了学习曲线,使事情保持简单。
为了帮助你实现这一目标,本章涵盖以下主题:
-
数据和两个集群方法
-
数据访问层是什么,为什么以及何时使用它。
-
Petastorm 的介绍和使用示例
-
Spark 的 Project Hydrogen 和障碍执行模式
-
Horovod 估算器 API 简介
两个集群方法
当你的应用需要使用在 MLlib 中未实现的深度学习算法时,“两个集群方法”会非常有用。正如其名称所示,采用这种方法可以保持一个专用于运行所有 Spark 工作负载(如数据清洗、预处理、处理和特征工程)的集群。如图 7-2 所示,数据随后保存到分布式文件系统(如 Hadoop)或对象存储(如 S3 或 Azure Blob),并可供第二个集群——专用的深度学习集群加载并用于构建和测试模型。

图 7-2. 两个集群方法:一个专用于 Spark,一个专用于 PyTorch 和/或 TensorFlow,带有分布式存储层来保存数据。
TensorFlow(TF)具有专用的数据 API,允许您创建一个Dataset对象,并告诉它在 TF 集群中摄取数据时数据的来源。它可以读取文本文件(如 CSV 文件)、具有固定大小记录的二进制文件,以及专用的 TFRecord 格式,用于记录大小不同的数据。这些都是面向行的格式;TFRecords 是 TensorFlow 的默认数据格式,并使用 Google 的 Protocol Buffers 进行了优化。¹ 从 Spark 中读取和写入 TFRecord 数据是可能的,但在 Spark 中,最佳实践是使用列格式。在使用 TFRecords 时,最好还是坚持使用 TensorFlow。
这让我们回到一个问题:在何时适当或必要时使用多个机器学习工具。仅仅 TensorFlow 就足够满足您的需求吗?PyTorch 会更好吗?作为数据科学家,如果您需要从另一个库实现算法会发生什么?
要在多个平台上工作,我们需要重新思考我们的数据格式,并调整它们以适应每个平台的独立需求。此外,我们需要调整数据类型。正如您在第四章中学到的,Spark 具有专用的 MLlib 数据类型。当与 PyTorch 或 TensorFlow 进行桥接时,例如,将 MLlib 的稀疏向量保存到 Parquet 中,然后尝试直接加载到 PyTorch 等价数据类型中,我们可能会遇到类型和格式不匹配的问题。虽然我们可以通过 Apache Arrow 的pyarrow.parquet²克服这类问题,并构建我们自己的翻译层,但这将要求我们了解 Arrow 的工作原理,定义批处理并自行处理。这个过程容易出错,并且可能变得非常繁琐。相反,我们应考虑引入一个独立的翻译/数据访问层,支持 Parquet,并通过统一不同类型的机器学习框架的文件格式来简化数据管理。我们将在下面详细讨论这个问题。
实施专用数据访问层
数据访问层(DAL)是应用程序中的一个专用层,独立于业务逻辑和表示层,提供对存储在某种持久存储中的数据的简化访问。这个概念由微软引入,但也可以在微软环境之外使用。实际存储可以各不相同,DAL 可以支持多个存储连接器,并提供诸如数据转换、缓存等功能。
数据访问层并不负责存储本身的可靠性,而仅负责可访问性——即使不同应用程序也能访问数据。它提供了一个抽象层,使我们更容易消费使用其他工具写入的数据。在与多个机器学习训练平台一起工作时,它可以帮助我们弥合数据类型、格式、访问控制等方面的差距。我们不需要关注底层数据存储的复杂性,因为 DAL 将其隐藏起来。
DAL 的特点
我们的数据访问层(DAL)应具备可扩展性并支持分布式系统——也就是说,它应能够将数据保存到分布式存储中,并利用现有的分布式服务器架构以分布方式写入和读取数据。它应支持各种数据类型,以填补 Spark 与其他分布式机器学习框架之间的差距,并且理想情况下,它应具备支持新兴机器学习框架的丰富软件生态系统。另外,应考虑到对数据进行缓存,因为机器学习算法会多次迭代数据以提高模型的准确性和降低损失(我们将在下一节讨论 Petastorm 时详细讨论缓存)。
拥有专门的 DAL 的另一个巨大好处是可发现性。DAL 应具有专用的数据目录,包含其存储的数据信息。这使得多个团队能够轻松独立地发现和与数据交互。一个众所周知且经常使用的数据目录是 Hive Metastore。有时被称为元数据目录,它保存有关数据的数据,Hive Metastore 实质上是所有 Hive 表的中央仓库;它包括有关它们的模式、位置、分区等信息,以便用户能够高效访问数据。
无论我们使用何种数据存储解决方案,我们的数据访问层应该都应该是一致的:它应该作为一个集中的元数据仓库,使我们能够访问和了解我们的数据。在机器学习中,这一点至关重要,因为我们经常需要协作工作,并且需要找到丰富我们的训练数据和开发可能存储在其他数据集中的新特性的创造性方法。类似地,在预处理和特征工程之后,我们需要我们的数据以一种能够加速学习过程的方式进行索引和保存。例如,如果我们在机器学习实验中对特定字段进行过滤,我们的 DAL 应该能够支持列式格式。这使我们能够创建一个供所有人使用的单一表,并在每个实验中仅使用所需的列进行过滤。自动驾驶数据集就是一个很好的例子,其中数据与来自传感器(如雷达和激光雷达传感器)的信息耦合,这些传感器主动发射信号并测量其响应。我们可能不希望在每次训练迭代中加载传感器信息,而列支持使我们能够更加高效。
如果我们使用在训练周期中对数据进行采样的机器学习算法,我们的 DAL 还应该支持行过滤。一些机器学习算法在训练周期中多次对数据进行采样,并不一次性读取所有数据。例如,长短期记忆(LSTM)是一种用于时间序列预测的深度学习算法。在 LSTM 中,模型需要从一系列过去的观察中学习,以预测序列中的下一个值。在这里,可能需要根据时间序列中的时间步骤在每次迭代中过滤行。可以将其想象为在时间线上滑动窗口,每个步骤计算由时间范围界定的观察值窗口,并尝试预测下一个值。然后根据预测的成功程度更新损失函数。这要求我们设计我们的数据以便模型能够相对快速地提取时间窗口的信息。一种选择是将这一部分作为实际文件层次结构的一部分。我们可以通过将时间步骤集成为文件路径的一部分来实现这一点,例如../table_name/ts=1342428418/partition-...,其中ts=代表此文件夹保存的时间步骤。
我们的 DAL 应该支持的另一个特性是数据版本控制。如在第三章中所讨论的,生成基于机器学习的应用程序的要求之一是能够重现模型构建实验。为此,我们的 DAL 需要能够支持随时间访问各个数据版本的能力。
综上所述,以下是我们的数据访问层(DAL)应该支持的关键特性回顾:
分布式系统
它应该能够利用现有系统来实现扩展。
丰富的软件生态系统
这使得它可以不断发展,包括新的机器学习框架的整合和确保持续支持、修复错误和开发新功能。
列式文件格式
列式文件格式按列而非行存储数据,这样在训练和测试过程中针对特定字段进行过滤时可以实现更高效率。
行过滤
一些机器学习算法需要对特定行进行抽样,因此我们需要一种机制来对行进行过滤,而不仅仅是对列进行过滤。
数据版本管理
应该可以回溯到过去,以支持实验的可重现性。
选择数据访问层(DAL)
现在我们知道我们需要一个数据访问层,那么我们如何选择呢?有多种解决方案可供选择,包括专有和开源的。您需要进行一些研究来了解您的具体需求,优先考虑它们,然后比较各种选项,找出哪些可能最适合您。在选择数据访问层之前,如果您计划将其用于工作,请确保在真实工作负载上进行测试。人们经常犯的一个大错误是仅运行供应商提供的基准测试,而不验证工具在他们自己的工作负载上的表现,这可能会有很大不同。还要记住计算潜在风险和成本。
在本书中,我专注于开源解决方案。有许多这样的解决方案,比如从 Spark 到 TensorFlow 或 PyTorch 的支持桥接,以丰富我们的机器学习算法的功能。我们将在下一节中介绍的 Petastorm 是其中之一。
什么是 Petastorm?
Petastorm 是由 Uber ATG 开发的开源数据访问库,允许我们直接使用 Apache Parquet 格式的多 TB 数据集进行深度学习模型的训练和评估。它通过使我们能够使用 TensorFlow、PyTorch 和其他基于 Python 的机器学习训练框架读写 Parquet 文件来实现这一点。Petastorm 的几个功能支持深度学习算法的训练,包括高效的行过滤、数据分片、洗牌和访问子集字段以及处理时间序列数据。图 7-3 展示了它如何融入机器学习系统的整体架构中。

图 7-3. 使用 Petastorm 数据集的架构图
这个图实际上讲述了两个过程的故事:数据集生成和训练/评估。基本上,我们有数据的生产者和消费者(这是许多机器学习工作负载中常见的模式)。来自各种来源的数据通过 PySpark 组合和处理,然后以 Parquet 列格式提供给分布式训练框架,如 PyTorch 和 TensorFlow,在模型训练和评估中可以多次使用。
图 7-4 和图 7-5,在接下来的几节中,展示了如何使用 Petastorm 的两种不同选项。第一种是简单地利用 Petastorm 作为转换器或翻译器,并将数据保留在严格的 Parquet 格式中。第二种方法是将 Petastorm 格式集成到 Apache Parquet 存储中;这利用了翻译器,并将数据保存到专用的 Petastorm 数据集中。
警告
截至 v.0.12.0,Petastorm 仅支持转换/翻译和保存大小统一的图像。它不能处理图像大小的任何变化。因此,在处理此类数据时,考虑图像大小是非常重要的预处理部分。
根据我们如何使用数据集,这两个选项都可能很有用。在选择它们之间时,取决于我们的系统有多复杂:如果我们只使用 TensorFlow、PyTorch 和 Spark 框架,那么使用 Petastorm 作为存储可能是有意义的;但如果我们的数据系统更复杂,最好将数据保留在非 Petastorm 存储中,并仅利用 Petastorm 作为转换器/翻译器。
让我们来看看使用 Petastorm 的SparkDatasetConverter从现有的非 Petastorm Parquet 存储中训练模型的第一种方法。
SparkDatasetConverter
SparkDatasetConverter是一个 API,可以为我们完成保存、加载和解析中间文件的“乏味”工作,这样我们就可以专注于深度学习项目的独特部分。它是如何工作的呢?想象一下,数据之前是使用 Spark DataFrame 处理的。它存储在内存中,尚未保存在任何特定的离线存储或文件中。我们可以利用这一点,将其保存到专用存储或中间文件中,Petastorm 管理这些文件。有趣的是,Petastorm 本身具有缓存机制,它将数据持久化到暂存存储中。当使用 Petastorm 的SparkDatasetConverter转换 DataFrame 时,每次访问数据时,Petastorm 将检查数据是否已经存在缓存并持久化到分布式文件系统中。如果是,它将从缓存中读取;如果不是,它将持久化到 Parquet 文件格式中。然后,转换器将加载持久化的文件到 TensorFlow 数据集或 PyTorch 数据加载器中,如图 7-4 所示。

图 7-4. 使用 Petastorm 作为转换器
要实现这一点,在实践中,我们首先需要定义缓存路径(一个cache_path类型为String的实例),以指定中间文件的目录路径:
from petastorm.spark import SparkDatasetConverter
# Set a cache directory on DBFS FUSE for intermediate data
spark.conf.set(SparkDatasetConverter.PARENT_CACHE_DIR_URL_CONF,cache_path)
后来,SparkDatasetConverter(独立)能够通过分析 Spark 的 DataFrame 查询计划来识别 DataFrame 是否已缓存。最终,数据将以这种目录格式持久化在路径中:
*{datetime}*-*{appid}*-*{spark_application_id}*-*{uuid4}*
要读取缓存目录中的文件路径,您需要理解每个元素表示什么:
-
*{datetime}*是一个形如’%Y%m%d%H%M%S’的字符串,表示 DataFrame 物化的时间(DataFrame 被处理的时间)。 -
*{appid}*是应用程序 ID。 -
*{spark_application_id}*是 Spark 应用程序 ID。我们可以通过.sparkContext.applicationId直接从运行中的 Spark 会话中获取这个信息。 -
*{uuid4}*是一个随机数,用作唯一标识符。
使用 Petastorm 作为转换器具有许多好处。它缓存中间文件,并在程序退出时清除缓存。此外,它还自动将 Spark 独特的 MLlib 向量转换为 1D 数组。这种方法提供了解耦,消除了必须使用特定于 MLlib 的 Spark 来训练机器学习模型的必要性。
警告
如果缓存功能失败,您需要管理可能在操作失败后仍然存在的短暂持久化文件。在再次写入之前,请确保所有创建的文件和目录已被验证、删除或安全存储在其他位置。
设置缓存后,现在是创建简单转换器的时候。转换器的输入如下:
parquet_row_group_size_bytes
这是定义转换器性能的关键组件(即其操作速度),也可以帮助防止内存溢出错误。它是Int类型,并定义了 Parquet 中物化后的行组大小,单位为字节。官方 Parquet 文档建议在 HDFS 上使用 512 到 1,024 MB 的行组大小。最佳块大小取决于您拥有的数据类型以及您是使用云存储还是本地存储。在像 Amazon S3 或 Azure Blob 这样的云存储中,对象大小通常会针对较小的块进行优化:Azure 为 64 KB 到 100 MB 之间,S3 为大约 5 MB 到 100 MB 之间(这些数字可能会变化,建议始终与云服务提供商核实)。例如,对于图像,您需要计算它们在磁盘上占用多少空间。请记住,此参数的单位为字节,因此在 HDFS 上优化为 512 MB 等同于 512 * 1,024 * 1,024 = 536,870,912 字节。如果我们在云中运行示例,则可能希望使用 1,000,000 字节或 1 MB,因为这个大小与我们的 Caltech 图像数据集很好地配合。
compression_codec
正如在第四章中讨论的,Parquet 支持多种压缩编解码器,由于 Petastorm 与 Parquet 一起工作,允许我们也定义编解码器。默认值为None。不要将图像数据压缩编解码器(JPEG、PNG)与 Spark 转换器功能的compression_codec混淆;后者指的是 Parquet 压缩。
dtype
这定义了我们数据中浮点元素的精度。在机器学习中,当我们将数据从一种状态转换为另一种状态时,总会有信息丢失的风险。特别是当我们将字符串转换为数字并稍后四舍五入或再次更改其表示时。转换器允许我们对此定义非常具体;默认类型为float32。
提示
所有这些配置应该是全局变量,这样你可以更容易记住它们,团队也更容易合作。您可以定义一个单独的配置文件来定义操作和转换中的数据类型。在必要时,它们也可以作为 Spark 配置或.env文件的一部分定义。
一旦您理解了配置选项,代码本身就很简单:
# TIP: Use a low value for parquet_row_group_size_bytes. The default of 32 MiB
# can be too high for our image use case if we run it in the cloud.
# Convert the training DataFrame:
converter_train = make_spark_converter(df_train,
parquet_row_group_size_bytes=32000000)
# Convert the test DataFrame:
converter_test_val = make_spark_converter(df_val,
parquet_row_group_size_bytes=32000000)
在这个阶段,DataFrame 被实现。
Petastorm 使我们能够使用TransformSpec定义额外的预处理函数。我们在这里定义的所有转换都将应用于 Spark 工作者处理的每一行。我们需要注意我们想要保留的列,它们的数据类型,列的顺序以及最终模式。以下代码示例说明了如何定义TransformSpec:
# The output shape of the TransformSpec is not automatically known by Petastorm,
# so you need to specify the shape for new columns in edit_fields and specify
# the order of the output columns in selected_fields.
transform_spec_fn = TransformSpec(
func=transform_row,
edit_fields=[('features', np.uint8, IMG_SHAPE, False)],
selected_fields=['features', 'label_index']
)
在此代码片段中,我们定义了一个将在 Spark 工作者中运行的函数,名为transform_row。这个可调用函数执行pre-transform-schema数据集到post-transform-schema数据集的转换。
在这里,我们使用这个函数准备要注入到专用 TensorFlow MobileNetV2 神经网络中的数据(有关 MobileNetV2 的更多信息请参见第八章):
def preprocess(grayscale_image):
"""
Preprocess an image file's bytes for MobileNetV2 (ImageNet).
"""
image = Image.open(io.BytesIO(grayscale_image)).resize([224, 224])
image_array = keras.preprocessing.image.img_to_array(image)
return preprocess_input(image_array)
def transform_row(pd_batch):
"""
The input and output of this function are pandas DataFrames.
"""
pd_batch['features'] = pd_batch['content'].map(lambda x: preprocess(x))
pd_batch = pd_batch.drop(labels=['content'], axis=1)
return pd_batch
使用 Python 的映射功能,transform_row遍历content列中的数据,并将其转换为 MobileNetV2 请求的处理图像数据。虽然这在我们的示例中是这样,但transform_row不必以 pandas DataFrame 作为输入;它可以是任何其他类型。如果您还记得,在第五章中,我们使用了 Spark 的 pandas API 将 Spark DataFrame 的行批量转换为 pandas DataFrame 以对其进行迭代并提取特征。那是我首次介绍 pandas DataFrame 实例的地方。这是我们可以使用这些 API 做的事情的一个例子,使用它们来转换行。这也是我们使用的机器学习算法可能产生影响的一个很好的例子:这个算法不允许有任何缺失值,并且调整图像的大小是必要的,因为原始数据集中的图像尺寸不同,并且算法要求统一的大小和形状(大多数情况下也是如此)。我们可以通过运行transform_row同时处理这两个要求。
如果未正确定义transform_row,在实际转换过程中我们可能会遇到以下错误:
File "/opt/conda/lib/python3.9/site-packages/petastorm/arrow_reader_worker.py",
line 176, in _check_shape_and_ravel
raise ValueError('field {name} must be the shape {shape}'
ValueError: field features must be the shape (224, 224, 3)
直到我们运行转换器并创建所需的数据集时,这个错误都不会显示出来。这意味着我们需要决定要使用哪种训练框架,并将数据转换为所需的实例类型。
注意我们处理 pandas DataFrame 的 Python 风格。我们将行处理成 tuples,⁴ 而不是 namedtuples,⁵ 因为这是 TensorFlow 在我们的场景中预期的方式。
最后,selected_fields 参数 (selected_fields=['features', 'label_index']) 决定了列的顺序和名称。
接下来,让我们看看如何将它们与 TensorFlow 连接在一起。以下是一个代码片段,演示了通过调用 make_tf_dataset 函数并提供先前描述的 transform_spec_fn 和 batch_size 来创建实际的 TensorFlow 数据集:
with converter_train.make_tf_dataset(transform_spec=transform_spec_fn,
batch_size=BATCH_SIZE) as train_dataset,
converter_test_val.make_tf_dataset(transform_spec=transform_spec_fn,
batch_size=BATCH_SIZE) as val_dataset:
此代码片段创建了以 TF 数据集格式表示的 train_dataset 和 val_dataset,可以在后续与 Keras 和 TensorFlow 一起使用。我们将深入探讨在 第八章 中使用此数据加载方法进行训练的可能性。
Petastorm 作为 Parquet 存储
与仅使用 Petastorm 作为转换器不同的第二个选项是,使用 Petastorm 作为 Parquet 数据格式的存储构建 Petastorm 存储。这是 DAL 的经典变体。它要求我们在所有服务中引入 Petastorm 代码,并将使用 Petastorm 与所有数据使用者耦合在一起,正如 图 7-5 所示。

图 7-5. 使用 Petastorm 作为 Parquet 存储的专用存储,其顶部带有元数据
Petastorm 本身并不是一个完整的存储系统。它通过保存关于字段的额外元数据来支持存储以 Parquet 格式存储的张量(数组),这些字段被转换为 NumPy 数据类型。要创建 Petastorm 存储,您可以通过创建一个具有专用字段的 Petastorm Unischema 实例,并利用 dict_to_spark_row 函数来定义一个新的模式:
from petastorm.unischema import Unischema, UnischemaField
imageSchema = Unischema('caltech256schema', [
UnischemaField('content', np.uint8, (224, 224, 3), False),
UnischemaField('label_index', np.int32, (), ScalarCodec(LongType()), False)])
UnischemaField 是用于描述模式中单个不可变字段的类型。您必须为其提供名称、numpy_dtype 和形状。正如您从此示例中看到的那样,您还可以指定编解码器。Petastorm 支持各种编解码器类型,用于图像、标量数据等,如 ScalarCodec(将标量编码为 Spark DataFrame 字段)、NdarrayCodec(将 NumPy ndarray 编码/解码为 Spark DataFrame 字段)、CompressedImageCodec(压缩/解压缩图像)等。总的来说,所有编解码器定义了在序列化过程中使用的编码/解码过程。有时 Petastorm 可以自行确定所需的编解码器,就像本例中的 content 字段一样,但有时它会需要您的帮助,例如 label_index 字段。
让我们深入了解我们的字段。content字段是 Spark SQL 的BinaryType类型,label_index字段是LongType类型。尽管将后者映射到numpy_dtype很简单,但对于第一个字段来说却不是这样。在 Spark 中,BinaryType被实现为 Scala 字节数组。选择numpy_dtype时,我们需要回顾并评估数据的来源。content字段基于图像。它们的数值表示范围是[0,255]。对于这个范围,np.uint8非常合适。uint代表无符号整数,这种类型只能容纳正数。
在定义架构后,您可以利用dict_to_spark_row函数与 Spark RDD 验证数据是否符合Unischema定义的类型,并使用指定的编解码器对数据进行编码。在我们的示例中,我们提供了一个ScalarCodec。稍后,我们可以使用spark.write函数将数据写入存储。
本节内容密集:您了解了缓存是什么,Petastorm 是什么,以及如何利用它将处理过的 Spark 数据与 TensorFlow 和 PyTorch 结合使用,从而提高可访问性。接下来的部分将讨论 Hydrogen 项目,旨在通过启用更适合机器学习的调度来促进 Spark 与其他框架的连接。让我们直接开始吧!
Hydrogen 项目
Hydrogen 项目是一个由社区驱动的项目,旨在改善 Apache Spark 对深度学习/神经网络分布式训练的支持。之前,我们讨论了两个集群方法,即使用 Spark 处理数据和进行深度学习的专用集群。设置这种方式的原因是 Spark MapReduce 调度程序的方法并不总是适合具有周期性训练过程的深度学习。算法的任务必须进行协调和优化,以支持反向传播和正向传播。为此,Hydrogen 项目作为其障碍执行模式的一部分提供了另一种调度原语,称为 Gang 调度程序,以及支持加速器的调度(对于深度学习训练性能至关重要)。
执行障碍模式
在神经网络中,反向传播意味着“错误的向后传播”。在对数据子集进行每次迭代时,神经网络计算损失函数相对于网络中权重的梯度。随后,它将误差传播回上一层网络,并调整该层的参数,旨在提高预测的准确性。反向传播从输出层一直传播到输入层。与之相反的是前向传播(有时称为前馈),在这种情况下,损失函数的计算梯度传播到其后的层。
注意
这些是复杂的话题,我在这本书中只能浅尝辄止;要深入了解深度学习的数学和行为,请查阅 Josh Patterson 和 Adam Gibson 的Deep Learning(O’Reilly)。
要更好地理解调度器的挑战,请看 Figure 7-6。顶部显示了 Spark 遵循的并行执行模型。虽然任务之间可能存在依赖关系,但由于 Spark 的特性及其使用单子操作的关联操作,任务是线性的,并且 Spark 在并行执行它们。另一方面,在分布式训练中,任务的依赖树更加复杂,不能保证并行执行。例如,在分布式训练过程中,任务 3 可能依赖于任务 2、1 和 n,而任务 n 则依赖于任务 5,任务 5 又依赖于任务 3。这意味着这里存在一圈操作,不再是一个有向图了。我们需要支持所有这些依赖关系,并决定何时以及如何计算每个任务。

图 7-6. 线性/并行执行与分布式训练
为了解决调度器的挑战,Project Hydrogen 引入了障碍执行模式。这允许我们定义一个特定的代码块,在这里使用障碍执行。障碍模型使我们能够在操作集之间创建门或障碍,并使跨障碍的操作变成顺序执行。这意味着每组操作可以并行执行,并且整个过程可以作为一个有向图运行,没有循环。通过建立跨障碍操作的固定顺序,该模型还允许我们将信息传递给依赖任务。
为了使用障碍执行模式,我们需要使用带有障碍上下文和 Spark 的 RDD 函数,比如mapPartitions来操作障碍 RDD。使用障碍 RDD 表示在这个 RDD 上执行的所有任务将使用障碍上下文运行。任务内的障碍上下文使我们能够决定哪些操作应该进行协调。首先,我们使用BarrierTaskContext定义阶段逻辑本身:
from pyspark import BarrierTaskContext
def stage_logic(row):
context = BarrierTaskContext.get()
# some logic that needs to be coordinated
context.barrier()
return row
然后我们定义障碍 RDD,并使用mapPartitions调用stage_logic:
barrierRdd = df.rdd.barrier()
rdd = barrierRdd.mapPartitions(lambda x: stage_logic(x))
就这样。直到context.barrier调用之前我们在阶段逻辑中定义的所有功能都将在障碍执行模式下执行。这种模式利用了在第一章中讨论的 MPI 编程模型,以允许更好的通信和在 Spark 集群上协调循环训练过程。
现在我们能够定义阶段和障碍,让我们升级一下,看看 Project Hydrogen 所要解决的下一个挑战。
加速器感知调度
加速器感知调度的目标是验证分布式系统调度程序是否意识到可用资源和操作的管理,并了解 GPU 的可用性以进行硬件加速。基本上,这是一个功能,允许我们公开集群的 GPU 地址,以便 Spark 可以利用它们。我们已经知道在预处理时运行在 CPU 上的数据执行程序足够了,而在训练时我们通常需要 GPU。为了以编程方式找到 GPU,我们必须配置 Spark 属性以及提供发现脚本。表 7-1 演示了启用 Spark 加速器感知调度的一些配置选项。
表 7-1. GPU 调度感知配置
| 配置键 | 配置值示例 |
|---|---|
spark.executor.resource.gpu.amount |
5 |
spark.executor.resource.gpu.discoveryScript |
/home/ubuntu/getGpusResources.sh |
spark.driver.resource.gpu.amount |
1 |
spark.driver.resource.gpu.discoveryScript |
/home/ubuntu/getGpusResourcesDriver.sh |
spark.task.resource.gpu.amount |
1 |
发现脚本也可以用于将 NVIDIA 的 RAPIDS 加速器配置为 Apache Spark 的插件/附加组件(要了解更多,请查看教程中的文档)。RAPIDS 为诸如连接、聚合等操作提供了更好的性能。基本上,让 Spark 意识到 RAPIDS 将允许它用 GPU 加速版本替换某些 SQL 操作。虽然这是解决方案中的一个重要部分,但其角色实际上是优化硬件本身,而不是操作。还要注意,Kubernetes 集群的行为可能与由 YARN 或独立集群控制资源的集群不同。
使用加速器感知调度,我们从执行器中运行的任务中获取 GPU 地址,使用TaskContext:
context = TaskContext.get()
resources = context.resources()
gpus = resources['gpu'].addresses
# feed the GPU addresses into a dedicated program
从驱动程序中,我们可以利用SparkContext以前类似的逻辑:
sc = spark.sparkContext
gpus = sc.resources['gpu'].addresses
# feed the GPU addresses into a dedicated program
我们已经看到了如何获取 GPU 地址本身;现在我们可以将它们提供给 TensorFlow 或其他 AI 程序。
这里我们只覆盖了基础知识;有关优化资源的进一步提示,请查看 Spark 文档中的“资源管理”部分。
Horovod 估算器 API 简介
与机器学习的大多数方面一样,我们今天可以使用的工具和资源明天可能会发生变化。因此,本节将介绍一些标准,帮助您评估哪种专用管理平台能够最适合您,以便将软件和硬件有效地集成以适应规模,并为未来做好准备。为此,我们需要我们的软件充当一个层,在训练设计的软件和调整硬件的软件之间起到桥梁作用。这意味着除了支持该软件外,还需要支持我们目前正在使用的硬件以及未来可能想要集成的任何硬件。
为了以自动化方式将本书中使用的工具整合在一起,我们可以利用Horovod。Horovod 与 Petastorm 类似,是一个用于分布式深度学习训练的开源框架。它也是由 Uber 开发,并后来捐赠给 Linux 的LF AI & Data Foundation。Horovod 的核心目标是允许单 GPU 训练在多个 GPU 上进行分布式训练。由于 Uber 广泛使用 Spark,工程师们还引入了Estimator API。Horovod Estimator 隐藏了将 Spark DataFrames 与深度学习训练脚本粘合在一起的复杂性。这是另一个帮助我们按照训练框架可解释的格式读取数据,并且我们可以使用 Horovod 分布训练的工具。作为用户,我们需要提供一个 TensorFlow、Keras 或 PyTorch 模型,而 Estimator 则负责将其适配到 DataFrame 上。训练模型后,Estimator 返回一个表示已训练模型的 Spark Transformer实例。稍后,这可以像任何 Spark 机器学习转换器一样用于对输入 DataFrame 进行预测,如第六章所讨论的。
Horovod 帮助我们配置 GPU 并定义分布式训练策略。它还通过BroadcastGlobalVariablesHook处理 Spark 的广播机制,支持在计算开始之前在所有进程上初始化专用值。这使得当我们希望从相同的随机权重开始训练时,能够保持一致性。与 Horovod 一起工作(即使只是作为练习)需要超出本书范围的专用硬件;如果您希望进一步探索这一点,请查阅Horovod 文档。
总结
本章的目标是展示一种创造性的方法,以弥合诸如 Spark 和 TensorFlow/PyTorch 之间的技术差距,并开始思考可能实现的新方式。我们讨论了如何利用 Spark DataFrame 中保存的 Parquet 格式数据以及如何创建一个桥接来管理和加载到 TensorFlow 和 PyTorch 中。我们涵盖了双集群方法,使用专用数据访问层 Petastorm 和其他可能有助于桥接 Spark 和深度学习集群的工具。我们还讨论了如何将硬件和软件组合到软件意识到硬件的程度,以及如何配置的示例。
记住,这个世界仍在发展,并正在经历一场巨大的变革。没有一种方法适用于所有场景,每个组织都会有稍微不同的要求。基本的概念和需求将保持不变,但技术本身可能会发生变化;因此,评估您自己的专用数据管理平台的具体标准非常重要。即使这本书不会涉及数据管理和硬件环境的每一个方面,也要记住代码、数据和环境是密不可分的。
本章还应该为您准备好第八章,在那里您将学习使用 TensorFlow 进行分布式训练——我们将从基础知识开始,介绍 TensorFlow 独特的各种模式和架构,最后以使用 Petastorm 处理 Parquet 数据和运行分布式训练作业的逐步教程结束。
¹ Protocol Buffers,也称为 Protobufs,是一种开源的、语言和平台中立的可扩展机制,用于序列化结构化数据,类似于 JSON。
² Spark 大量利用 Arrow,但它是抽象的,我们很少直接与其一起工作。
³ 数据管理指的是输入、存储、组织和维护组织创建和收集的数据的过程。
⁴ 元组是 Python 用于存储数据集合的四种数据类型之一。数据以固定的、不可变的顺序存储,允许重复的值。
⁵ Python 中的命名元组是带有命名字段的元组,其中数据存储为键和值。要了解更多关于这种集合类型的信息,请查阅Python 文档。
第八章:TensorFlow 分布式机器学习方法
TensorFlow(TF)是由谷歌大脑团队开发的开源软件库,旨在推动行业中的深度学习进展。他们的目标是缩小研究和实践之间的差距。
当 TF 在 2015 年发布时,它震惊了数据科学界。如今,它是深度学习中使用最广泛的库之一。为了提供一个完整的生产流水线解决方案,TF 团队在 2019 年向公众发布了 TensorFlow Extended (TFX)。除此之外,谷歌还创建了自己的处理单元,称为张量处理单元(TPUs),用于加速使用 TF 开发的机器学习工作负载。如果这个缩写看起来很熟悉,那是因为它有意与 GPU 相似,GPU 代表图形处理单元。虽然 TPUs 提供了一些先进的功能,但主要使用它们会将技术堆栈紧密地绑定到谷歌技术上。GPU 更为通用和灵活,因此使用它们作为加速器将使您的应用硬件计划更具跨平台性。
TF 提供了各种分布式训练策略,适用于 GPU、CPU 和 TPU。使用 TF,您可以丰富您的机器学习能力,超越 Apache Spark 的默认功能。要连接数据预处理和使用 TF 训练模型的机器学习工作流程,您可以使用 MLflow(在第三章中讨论)。
在上一章中,我们讨论了如何通过使用 Petastorm 将 Spark 和 TensorFlow 连接起来,以使 TF 能够处理 Parquet 数据。本章继续沿着这条路径,向您展示如何使用 TF 训练大量数据集。基本上,我们用 Spark 处理并保存到 Parquet 中的同一组数据现在可以用 TF 来训练模型了!
本章涵盖以下内容:
-
TF 基础知识的快速概述
-
如何将 Parquet 数据加载到 TensorFlow 数据集中
-
TF 用于训练模型的分布式策略
-
TF 训练 API 及其使用时机
-
将 Petastorm 到使用 TF 构建模型的所有内容整合在一起
让我们从看一下 TensorFlow 的主要组成部分开始。
TensorFlow 的快速概述
TensorFlow 的基础包括张量、变量、操作、函数、图形和模块。让我们快速概述一下它们:
tf.Tensor
与 Spark DataFrame 类似,这是一个不可变对象:一旦创建,其状态就无法更改。tf.Tensor 表示一个多维数组。它有两个属性,shape 表示张量沿其轴的大小(例如 (2, 3)),dtype 是张量中元素的数据类型(例如 float32)。张量中的所有元素必须是相同的类型。
tf.Variable
与 tf.Tensor 不同,tf.Variable 是可变对象,其状态可以随时修改。变量是共享的,并表示数据的当前状态——你可以把它们看作是可变的多维数组,类似于张量,其值可以通过在其上运行操作来更改。TF 在机器学习训练中使用变量来存储模型的参数(例如,权重或其他可变状态)。
tf.Operation
tf.Operation 是图中执行一些计算的节点,例如添加标量值。它以零个或多个 Tensor 对象作为输入,并产生零个或多个 Tensor 对象作为输出。
tf.function
注意到function是小写的,而列表中其他项的名称(Tensor 等)是大写的。这是因为 tf.function 是一个注解,而不是 TF 对象:它将一个函数编译为可调用的 TF 图。这种注解为我们提供了一种 Pythonic 的方法来构建自定义的 tf.Operation,作用于张量上。以下代码示例展示了一个简单的 add 函数:
@tf.function
def add(x):
return x + 1
tf.Graph
在 TensorFlow 中,有两种执行模型:即时执行,TF 函数会立即执行操作;图执行,操作被添加到一个 tf.Graph 中以便稍后执行。图包含执行计划,它包含数据(张量)和操作。类似于 Spark 的 DAGs,TF 图可以被优化。TF 运行时的默认图优化系统是 Grappler。它提供了各种优化器,可以按需启用,包括修剪优化器——如果你的计算图有不影响输出的节点,修剪优化器将通过修剪未使用的节点来减小图的大小,因此 TF 不会执行这些计算。这类似于 Spark Catalyst 引擎执行的优化,见 第二章,该引擎修剪节点并优化操作。
tf.Module
tf.Module 是 TF 机器学习层和模型的基类。模块是变量(包括训练过程中或训练后可以修改的变量,以及可以修改但不用于训练的变量——基本上是任何用户输入的变量)、其他模块和应用于用户输入的函数的容器。
注意
变量可能包括在整个过程中会改变的训练参数和不会改变的超参数。例如,在线性回归模型中,参数可以是正在计算的加权系数。k-means 聚类中的超参数示例是簇的数量。
还有一些其他重要概念需要了解,如tf.keras库中的 model.compile 方法和 TensorBoard,这是 TF 用于机器学习实验的可视化工具包。model.compile 配置模型以进行训练,指定损失函数、图优化器和指标。关于 TensorBoard,TF 集群收集和记录数据,稍后在此处可视化。TensorBoard 还利用 TF 回调机制,该机制(如第三章讨论的)使我们能够将函数注入训练过程中,例如捕获训练的性能。这称为分析。分析过程量化了机器学习应用的性能,以确保其运行优化版本。由于算法通常计算量大,分析能力至关重要。要启用它,请确保在集群中安装 TensorFlow Profiler。您可以使用以下pip命令执行此操作:
pip install -U tensorboard_plugin_profile
之后,定义回调函数并将其整合到 model.fit 调用中,如下所示的代码片段:
tboard_callback = tf.keras.callbacks.TensorBoard(log_dir = logs,
histogram_freq = 1,
profile_batch = '500,520')
model.fit(df_train,
epochs=8,
validation_data=df_test,
callbacks = [tboard_callback])
本书不会详细介绍 TF 回调机制及如何使用 TensorBoard 的工作原理;如需更多信息,请参考类似 Aurélien Géron 的入门文本 《Python 机器学习实战:基于 Scikit-Learn、Keras 和 TensorFlow》第三版(O'Reilly)。
在我们深入探讨 TensorFlow 的机制之前,让我们更加仔细地看一看它设计的主要目的——与神经网络一起进行深度学习——以及 TF 集群中的不同角色和职责。
什么是神经网络?
很好的问题!让我们快速过一遍,因为对这个概念的理解在接下来的章节中也会很有用!
神经网络,有时称为人工神经网络(ANN)或模拟神经网络,是一种试图模仿人脑运作方式的系统。该算法创建多层节点或“神经元”,通过输入数据进行通信和训练权重。它包括一个输入层和一个输出层,之间有一个或多个隐藏层负责数据转换、执行特征提取等。每层的大小和隐藏层数量取决于我们如何配置算法。图 8-1 是神经网络的抽象表示,包括一个输入层 x,两个隐藏层和一个输出层 y。

图 8-1. 神经网络图示例
假设我们的输入层中只有一个神经元,并且我们用输入数据来喂它。神经元内的函数根据一些预先配置的权重(可训练参数)、一个偏差函数和一些额外信息来计算输出。然后这个神经元产生一个输出,可以馈送到下一个神经元。正如前一章节中提到的,这被称为前向传播;信息只在这个方向上流动的神经网络称为前馈网络。训练通过根据损失函数调整每个神经元的权重来进行。因为预期的结果已知,这是一种监督学习方法。
在计算损失(或梯度)之后,可以将这些信息反馈给前面的神经元,以便调整其权重以改善性能。这被称为反向传播或反向传递。在 TensorFlow 中处理神经网络时,我们通常会使用其专用的 Keras API。Keras(本章后面将详细讨论)是内置于 Python 中的神经网络库,提供了用于训练模型的高级 API。TensorFlow 的 Keras API,tf.keras,已经实现并准备好使用多种机器学习算法。该 API 中的两个关键类是Layer类和Model类:
tf.keras.layers.Layer
这是所有层继承的基类。每一层接受一个或多个张量作为输入,运行指定的操作,并输出一个或多个张量。它有一个call方法,将该层应用于输入和状态(其权重变量)。
tf.keras.Model
这是用于定义模型架构的基类。其主要目标是在张量上运行一组操作。一个Model将多个层组合成一个对象,同时提供训练和推断的特性。例如,要对图 8-1 中的模型进行预测,我们只需要使用给定的数据输入(x1,x2,…)进行前向传递,以获取预测结果(y)。
注意
对于全面学习如何使用 TensorFlow,我建议阅读 Aurélien Géron 的书《Python 编程:从入门到实践》中的“Getting Started with TensorFlow”章节。
TensorFlow 集群过程的角色和责任
TensorFlow 集群遵循客户端/服务器模型。与 Spark 类似,您可以利用多台机器来完成计算需求的繁重工作。当运行 TensorFlow 任务时,在这些机器中的一台上创建一个会话,并且图被优化和计算,其中的部分可能分布在集群中的不同机器上。
一般来说,在 TensorFlow 集群中,有多个进程运行,可以是在同一台机器上(作为线程)或多台机器上。它们每个都有不同的角色和责任,因为它们各自负责完成大型处理计划的某一活动。与 Spark 集群类似,每个进程运行一个任务或 TF 服务器,并有自己的 IP 地址和端口号。这些角色包括以下内容:
工作进程
工作进程代表应用程序执行计算。
参数服务器(PS)
参数服务器跟踪变量的值和状态。更多信息请参阅 “深入了解 TensorFlow 的分布式机器学习策略”。
Chief
Chief 类似于工作进程,但它还承担额外的责任,如与集群健康相关的工作,比如写入 TensorBoard 日志或保存检查点。
评估器
这个进程负责对模型进行评估。
图 8-2 展示了各个进程之间的通信。Chief 负责启动程序;同时它还负责为其他工作进程提供配置和上下文。

图 8-2. TF 分布式计算及各进程的责任
进程的角色和责任通过集群的 TF_CONFIG 配置属性进行配置。我稍后会在本章节中分享配置示例。
现在你对 TensorFlow 的基本组件有了更好的理解,是时候学习如何将 Parquet 数据加载到 TF 数据集中了。
将 Parquet 数据加载到 TensorFlow 数据集中
就像每个机器学习过程一样,这个过程始于加载数据。在 TensorFlow 中,可以使用 tf.data.Dataset 对象的 load 函数来完成这一过程。
TF 数据集充当实际数据的抽象(类似于 Spark DataFrame),无论数据位于磁盘上还是内存中。这使你可以迭代分布式数据。你可以从多个来源获取数据,然后与任何 TF 模型一起使用。
如果单独使用 TensorFlow,load 函数就是你所需要的。它会从源迭代地获取数据;稍后,你可以对其进行预处理,使其符合标准格式,而 TF 引擎本身会收集数据统计信息。预处理完成后,TF 可以让你将数据保存到磁盘。一旦数据保存到磁盘,就可以准备进行下一步操作:由 TF 使用来构建模型。
不过,需要注意的是:虽然对于 CSV 和其他文件格式(比如图像)是这样,但对于 Parquet 并非如此。TF 不原生支持加载 Parquet 文件。
如何解决这个问题?为什么它是个问题?正如在第四章中描述的,通常您会希望利用您、您的团队或组织中其他团队准备的现有预处理数据。而数据通常会使用 Spark 预处理并保存为 Parquet 格式,这种情况下 TF datasets 的load函数无法准确加载。
在前一章中,我们将 Petastorm 用作框架之间的桥梁。Petastorm 有一个make_petastorm_dataset函数,它创建一个tf.data.Dataset实例,使用一个petastorm.reader.Reader的实例。
作为开发者,在创建 Petastorm 读取器之后,我们有责任以编程方式将其传递给make_petastorm_dataset函数。正如在第七章中讨论的,我们可以选择以 Parquet 格式保存数据,而不依赖于 Petastorm 存储直接操作。在这种情况下,为了创建读取器,我们使用make_batch_reader函数(而不是make_reader)。它创建一个非 Petastorm 的 Parquet 读取器,这正是我们需要的。
make_batch_reader函数执行以下步骤:
-
根据其运行位置和我们提供的内容,规范化数据集的 URL。
-
定位数据片段的文件系统路径。
-
分析 Parquet 元数据以确定模式。此函数支持所有标准 Parquet 类型。
-
如果有缓存格式,请验证它。
-
确定读取器池的类型。可以是以下之一:
-
'thread'(线程池) -
'process'(通常是专用的ArrowTableSeralizer) -
'dummy'(在主线程中执行所有read调用)
-
-
返回已配置文件系统并准备就绪的读取器实例。
此逻辑返回一个从 Petastorm 数据集读取数据并封装ArrowReaderWorker的读取器实例。ArrowReaderWorker是一个 Petastorm 对象,反过来封装了pyarrow,使我们能够使用标准的 Arrow 互连格式处理 Parquet 数据(在第五章中讨论)。
现在您理解了这个逻辑,让我们写一些代码吧!下一个代码片段显示了如何导入函数并在 Python 语法中使用它。第二行创建了一个准备在 Python 函数范围内使用的读取器:
from petastorm import make_batch_reader
with make_batch_reader(petastorm_dataset_url) as reader:
...
现在我们有了一个 Parquet 读取器,但它有一些限制——不是所有Dataset的特性都能正常工作。为了消除这些限制,我们可以通过向构造函数提供以下参数来构建petastorm.reader.Reader本身:
-
Dataset有一个专门的repeat函数,允许我们选择数据集运行的迭代次数。在构建读取器时,不要使用repeat,而是使用num_epochs,因为 Petastorm 不支持像 TensorFlow 那样的repeat方式。 -
不要使用
filter函数,而是使用predicate函数来充分利用 Parquet 作为列式数据格式的优势,仅在加载和解码其他列之前加载predicate函数已操作的列。
现在我们有了读取器,让我们创建数据集实例吧!为此,我们将使用make_petastorm_dataset函数。它将创建一个tensorflow.data.Dataset实例,我们可以用它来训练 TensorFlow 模型。记得从petastorm.tf_utils导入这个函数,如下面的代码片段所示。然后我们可以稍后调用这个函数,提供刚刚创建的读取器。以下是代码的一个示例,其中num_epochs作为读取器的一部分进行配置:
from petastorm.tf_utils import make_petastorm_dataset
...
with make_batch_reader(petastorm_dataset_url, num_epochs=100) as reader:
dataset = make_petastorm_dataset(reader)
现在您知道如何将 Parquet 格式的数据加载到 TF 数据集实例中了。make_petastorm_dataset使用了 TF 的tf.data.Dataset.from_generator函数,它从数据集的 URL 和文件系统获取下一块数据。有了这个数据集实例,我们可以开始训练分布式机器学习模型了。
下一节将讨论多个分布式 TF 训练策略。这也将帮助您理解 TensorFlow 分布式方法与 Spark 的区别。
深入了解 TensorFlow 的分布式机器学习策略
TF 支持多种封装策略进行分布式训练:同步与异步、全局归约与参数服务器、图内与图间,以及 CPU/GPU 与 TPU。所有策略都可以通过 tf.distribute.Strategy 库使用。
TF 支持数据并行 ism,因此我们的数据集分布在多台机器上,在训练过程中,我们集群中的每台机器处理数据的不同部分。训练操作逻辑在多个设备上复制,并且算法的变量在它们之间共享。每个操作逻辑的副本通过特定于所使用的策略的机制更新这些变量。因此,在训练过程中,机器学习算法正在更改模型的变量(已经训练过的变量),直到巩固。为了支持算法的高效巩固,我们需要根据数据大小、集群资源等选择合适的策略。
TF 分布式策略设计的优点在于它使我们能够编写模块化代码,将训练模型的功能与定义训练策略分开。这允许我们在同一策略中合并多个训练函数,并在训练时使用不同的策略。下面的代码片段展示了在不同策略之间切换是多么容易。每个策略实例可以从tf.distribute创建:
import tensorflow as tf
strategy = tf.distribute.MirroredStrategy()
with strategy.scope():
some_model = ...
some_model.compile([...])
...
训练策略与模型训练代码的解耦允许进行更多的实验。请注意scope函数的使用范围定义了策略执行的位置。在我们的示例中,在此范围内,训练正在利用tf.distribute.MirroredStrategy策略。
TF 支持五种分布式训练策略和三种不同的 API 用于训练机器学习模型——你可以使用tf.Keras或者 TF Estimator API 或者构建自定义训练循环。表 8-1(来自 TensorFlow 文档)详细介绍了这些不同训练 API 在各个策略上的支持情况,更多细节见“训练 API”。在本章节中,你可以参考此表格,并帮助你决定哪种组合最适合你的项目。请注意,一些策略目前仅具有有限或实验性支持,这意味着这些功能尚未完全验证并仍在开发中;要获取最新的支持信息,请参阅文档。
表 8-1. TF 的分布式训练能力及其在各种训练 API 中的支持情况
| 训练 API | Mirrored Strategy |
TPUStrategy |
MultiWorkerMirror Strategy |
CentralStorage Strategy |
ParameterServer Strategy |
|---|---|---|---|---|---|
| Keras Model.fit | 支持 | 支持 | 支持 | 实验性支持 | 实验性支持 |
| 自定义训练循环 | 支持 | 支持 | 支持 | 实验性支持 | 实验性支持 |
| Estimator API | 有限支持 | 不支持 | 有限支持 | 有限支持 | 有限支持 |
在本节的剩余部分,我们将依次详细介绍这些策略,从最初的方法开始。
参数服务器策略
ParameterServerStrategy(有时称为“参数服务器和工作器”)是最古老的方法,TF 从一开始就支持。每台机器承担工作器或参数服务器的角色,TF 将任务分为工作器任务和参数服务器任务。工作器任务可以是从读取输入到更新变量,计算前向和反向传播,以及发送更新等任何任务。参数服务器任务包括在训练期间存储机器学习模型的参数(即神经网络的权重),保持参数值在服务器之间的强一致性,并根据请求向处理数据和计算参数更新的工作器提供信息。
规则很简单:
-
变量存储在参数服务器上,并且在每个训练步骤中由工作器读取和更新。
-
每个变量存储在单个参数服务器上。
-
工作人员独立执行其任务,不与其他工作人员通信。工作人员仅与参数服务器通信。
-
根据变量的数量,可能会有一个或多个参数服务器。
当存在许多 CPU/GPU、大量计算矩阵和稀疏查找时,这种模型效果很好。它被视为中间图策略。这是 TensorFlow 1 的一个概念,意味着每个工作人员都独立运行自己的函数,读取变量,执行操作并更新参数服务器。这使得工作人员可以异步运行,并且容易从故障中恢复。
使用这种策略,工作人员使用异步远程过程调用(RPCs)¹与参数服务器通信,以读取和更新每个变量。这使得工作人员可以独立操作,并以自己的速度处理输入。
这种方法的一个潜在缺点是,在每个训练步骤的开始阶段可能会出现网络瓶颈,当工作人员都尝试联系参数服务器以读取变量时,而运行时并未提供特定的顺序。在计算第一个神经网络层的第一步时经常会出现这个问题。
图 8-3 显示了该策略架构的高级图表。根据参数服务器的数量、可用性需求等,参数服务器本身可以是分布式的。每个工作人员都有模型的一个副本,并且正在处理数据的特定部分(X[1]、X[2]、X[3]等)。图表中的箭头显示了工作人员与参数服务器之间的通信。根据参数服务器的数量和分布,可能会有一个专用的服务器管理器(“首席”在图 8-2 中)来管理每组参数服务器。

图 8-3. ParameterServerStrategy架构
ParameterServerStrategy是一种通用策略:所有函数/操作都可以在一个工作人员上运行,因此任何可以分解为一系列独立函数的机器学习算法都可以利用它。随着 TensorFlow 2 的推出,这种策略发展了,引入了一个专门的集群协调器。集群协调器负责创建工作人员和参数服务器所需的资源,同时协调训练本身。这种方法有助于减轻初始阶段 RPC 的开销。
以下代码片段展示了如何定义策略和协调器。这两个函数接受多个参数,例如cluster_resolver,其中包含集群规范:
# define the strategy
strategy = tf.distribute.experimental.ParameterServerStrategy(
tf.distribute.cluster_resolver.TFConfigClusterResolver())
# define the coordinator
coordinator = tf.distribute.experimental.coordinator.ClusterCoordinator(
strategy)
现在您了解了ParameterServerStrategy,让我们继续学习下一个策略,并了解 TF 策略如何发展以克服迄今为止出现的挑战。
CentralStorageStrategy: 一台机器,多个处理器
CentralStorageStrategy是对 RPC 问题的早期解决方案。使用这种策略,您可以在一台具有多个 CPU 和 GPU 的机器上计算图内操作:
-
CPU 持有变量(类似于 PS)。
-
GPU 执行操作(类似于工作器)。
-
所有设备上的通信都是同步的,这意味着它们在锁步中共同工作。
每个 CPU 持有变量的子集,并在每一步更新它们。这些变量不在处理器之间共享;每个处理器执行其更新,然后它们交换信息以在每一步同步梯度(模型的训练变量)。这可以很容易地完成,因为所有处理器都在同一台机器上。每个训练步骤涉及一次完整的图运行(一个完整的时代)。所有这些都由单个客户端,即主线程协调。这种策略使得在单台机器上进行训练更加高效,这在只有一台机器可用的嵌入式场景中非常有用。
您可以使用以下代码片段创建一个CentralStorageStrategy实例:
central_storage_strategy = tf.distribute.experimental.CentralStorageStrategy()
虽然这种方法克服了网络瓶颈的问题,但引入了不同的困难:一台机器只有一个中央存储,任务共享相同的磁盘空间和 RAM。这意味着每个变量和操作在内存中只表示一次;在 CPU 上有一个变量的单个副本,并且每个进程(GPU)有一个模型的副本。还需要注意的是,当只有一个处理器时,不会加速发生。
为了解决这些问题,TF 引入了MirroredStrategy方法。我们接下来将看看这个方法。
MirroredStrategy:一台机器,多处理器,本地副本
与CentralStorageStrategy类似,MirroredStrategy支持在一台机器上运行多个 CPU/GPU。然而,每个持有训练操作逻辑副本的处理器也会持有每个变量的本地副本。这些变量会在所有处理器之间复制并保持同步(即镜像),通过在所有处理器上应用相同的更新来实现,如图 8-4 所示。这种方法与ParameterServerStrategy和CentralStorageStrategy不同,因为后者每个处理器/机器只持有训练变量的子集,而不是整体集合。

图 8-4。MirroredStrategy架构
为确保在处理器之间进行相同的更新,该策略使用了全归约算法,在计算机科学领域很常见。每个处理器与所有其他处理器进行通信以交换更新(“全”部分),并使用reduce函数来聚合这些值,将它们减少到一个单一值,并将结果返回给所有处理器。
减少操作通常在并行编程中用于将数组的每个元素减少为单个结果。它们必须是可结合的操作。换句话说,对数据的操作顺序不应该影响结果。例如,求和操作中,操作数的顺序无关紧要;无论是计算a + b还是b + a,结果都是相同的。对于 max、min、mean 以及许多其他操作也是如此,这些操作能够在变量之间进行同步。
提示
全局减少方法的优势在于可以在硬件上进行优化。例如,如果您的机器使用 NVIDIA 处理器,您可以配置集群以使用 NVIDIA 的全局减少通信功能来加速变量的同步。我不会在这里讨论具体的硬件优化,因为这些优化因供应商而异,但知道这一点并据此采取行动是很好的。
下面的代码片段显示如何创建MirroredStrategy实例——为了指定机器,您可以更新 TensorFlow 集群配置文件(很快就会详细讨论!)或将此信息传递给函数:
mirrored_strategy = tf.distribute.MirroredStrategy()
虽然这种策略在特定场景下非常有效,例如在使用嵌入式设备时,有时我们需要在多台机器上进行训练。这种能力由MultiWorkerMirroredStrategy提供,我将在下文中详细介绍。
MultiWorkerMirroredStrategy:多台机器,同步
MultiWorkerMirroredStrategy与MirroredStrategy非常相似:它提供了一个跨多台机器进行同步训练的实现,每台机器可以有多个处理器。每个变量在机器和处理器之间都被复制和同步。当机器之间的连接良好时,这种方法效果很好。由于它依赖于全局减少算法,所有机器都需要通信以同步变量。
那么在这种情况下,同步训练实际上是什么样子呢?假设我们有一个算法,包含两个神经网络层,并在训练过程中有两个要训练的变量。如图 8-5 所示,层 A 和 B 被复制到两台机器 A 和 B 上。您可以看到每台机器都有所有变量的副本,每个变量都有其自己的数据片段作为输入:input 0 和 input 1。

图 8-5. 使用MultiWorkerMirrorStrategy进行同步计算
让我们看一下同步本身是如何工作的。我们有四个组件:
-
两个变量组件,因为我们在每台机器上都保留了各自的变量副本
-
由于每台机器都在其自己的训练数据子集(input 0 和 input 1)上操作,所以有两个数据组件。
逻辑如下:
-
每台机器都会接收自己的输入(1),然后仅使用其本地变量的副本进行前向传播(2)。机器 A 使用输入 0 计算层 A 和层 B,机器 B 使用输入 1 计算层 A 和层 B。
-
在开始反向传播之前,我们希望优化训练变量,因此使用本地变量的副本计算梯度(3)。机器 A 计算 V[A0]和 V[B0],机器 B 计算 V[A1]和 V[B1]。
-
现在我们想要聚合训练变量以确保它们同步。我们使用全局归约方法来做到这一点:我们通过网络发送梯度的副本并将它们聚合(4)。也就是说,每台机器将 V[A1]和 V[A0]聚合为∆V[A],将 V[B1]和 V[B0]聚合为∆V[B]。或者,这种聚合可能在不同的机器/处理器上进行,例如,一台优化运行归约操作的 NVIDIA GPU,可能将此作为其唯一任务。聚合完成后,它会将更新后的梯度∆V[A]和∆V[B]广播回机器。
-
最后,我们进行反向传播(5)。
在此过程中,我们使用了全局归约方法,将单个聚合梯度值通信到所有机器(V[A0],V[A1]等)。然后,每台机器将该梯度应用于其本地变量。如果您参考图 8-5,您会发现每台机器都有四个处理器,这意味着梯度在四个处理器中本地复制。由于全局归约在所有副本中产生相同的值,更新都是相同的,这些值在所有不同的机器和副本中保持同步。
一旦此过程完成,下一个前向传播可以立即开始;不需要等待值,因为在步骤结束时,所有副本都已更新并具有同步的完整变量集的本地副本。
此外,我们可以通过在计算其他层的梯度时同时对一个层进行全局归约来实现一些并行化(例如,在计算层 B 的梯度的同时,交换层 A 的梯度以同步副本。这种方法在存在反向传播时效果很好;通过适当的同步,我们可以充分利用硬件,同时保持网络通信和计算部分的忙碌状态。这对吞吐量和性能非常有益。
注意
分层全局归约是全局归约过程的一种实现,其中聚合和梯度计算在每台机器内部完成,然后以分层方式跨机器传输信息。因此,如果一台机器是工作节点,并且每个工作节点有多个处理器,则它们可以在每一步同步梯度时同时运行多个任务。然后,可以稍后将结果与网络中其他机器的结果同步。这种方法通常表现更好,更具可伸缩性。它减少了对参数服务器的依赖,并允许立即开始训练的下一步(第二个 epoch),而不必等待工作节点将其结果与其他工作节点同步。请注意,与每台机器只有一个处理器的四台机器相比,拥有每台机器有两个处理器的两台机器将导致更快的执行(因为需要更少的网络通信),同时仍然确保集群的容错性。
MultiWorkerMirroredStrategy还支持环形全局归约或环形归约算法。这里的通信方式不同:每个处理器不是与所有其他处理器通信和接收消息,而是每个处理器只从一个处理器接收信息,更新它,并将信息发送给另一个处理器。处理器连接成圆形或环形结构,如图 8-6 所示。环形全局归约通常比全局归约更有效,因为它在网络上发送的消息更少。

图 8-6. 同步梯度计算的环形全局归约通信架构
Tip
存在其他变体的全局归约通信结构,例如树形全局归约、轮换全局归约等等。TensorFlow 支持各种跨设备通信(或设备操作)的实现,包括tf.distribute.CrossDeviceOps、tf.distribute.HierarchicalCopyAllReduce、tf.distribute.ReductionToOneDevice和tf.distribute.NcclAllReduce,适用于 NVIDIA 处理器。在选择时,请务必验证其在您的硬件上的支持。
请注意,要启用这种策略,您必须确保设置TF_CONFIG环境变量,以指定 TF 集群中每台机器的角色。以下是配置文件中的示例:
os.environ["TF_CONFIG"] = json.dumps({
"cluster": {
"worker": ["host1:port", "host2:port", "host3:port"],
"ps": ["host4:port", "host5:port"]
},
"task": {"type": "worker", "index": 1}
})
使用MultiWorkerMirroredStrategy,有一个指定的工作节点来协调集群的活动。这个节点负责生成用于进一步日志记录和失败恢复的摘要文件,还负责处理其数据片段。它被称为主节点,在TF_CONFIG中的集群工作节点数组中的索引为 0(在我们的示例中,主节点是host1)。
首席工作节点的最重要任务是保存检查点,捕获模型在特定时间点使用的所有参数的确切值。在机器、网络通信或任何其他可能的集群故障情况下,它们可用于快速恢复,使应用具有弹性和容错性。假设您正在执行一项大量计算以训练机器学习模型的任务,可能需要数天甚至数周时间。突然间,数据中心停电,所有机器都宕机了。重新启动整个训练过程可能会延迟数周或数月建立模型——但通过检查点机制,首席工作节点负责定期将变量状态和值保存到文件系统。这意味着,借助代码和检查点数据,我们可以从上次检查点恢复进程,而不必再次从头开始整个计算过程。这节省了大量时间。(顺便说一句,这些情景并不少见——在具有许多活动部件的大型集群中,它们非常常见。)Spark 和 TensorFlow 都允许在计算过程中保存检查点。TensorFlow 使用tf.train.Checkpoint持久化模型及其数据的状态。稍后,可以通过高级 API 进行构建。Spark 将节点元数据与数据状态一起保存,使得从检查点加载并继续计算变得简单。
注意
集群中的每个任务可能与其他机器上的任务通信。确保配置防火墙或虚拟网络安全协议,以便在定义的端口上所有集群机器之间的通信都被授权。为了减少配置开销,通常更方便地使用相同的端口用于所有集群机器。
TPUStrategy
TensorFlow 提供的最终分布式训练策略是TPUStrategy,用于 TPU 和 TPU Pod 上的同步训练。与两个镜像策略类似,它支持单个处理器之间的通信,以及机器之间的通信。如前所述,TPU 是由 Google 创建的,并且可以在多个 Google 平台上找到。使用TPUStrategy需要访问专用的 Google 平台和硬件,因此我们在本书中不会深入讨论它。
切换策略时会发生哪些变化?
如本节开头所述,TF 提供了一种模块化的代码方法来在不同策略之间切换。选择训练策略时,以下是您需要牢记的一些因素:
-
通信方式(同步或异步)
-
变量复制(是否在每个工作节点上保留所有变量的本地副本)
-
梯度聚合在机器内和/或跨机器进行,以及如何将结果传送给工作节点
-
反向传播的进行方式以及变量同步的时间点(是在全过程中进行,还是仅在全过程结束后进行)
-
度量如何累积,这受到全归约算法和通信方法的影响
现在您已经熟悉了各种 TF 分布式训练策略,接下来我们将关注它提供的 API 来训练机器学习模型。
训练 API
选择训练策略是一个关键步骤,但正如我们在 表 8-1 中看到的,我们选择策略可能会受到我们想要使用的训练 API 的影响。TensorFlow 提供了三个选项:
Keras API
tf.keras API 提供了各种内置算法和模型,可用于构建机器学习模型。
自定义训练循环
我们可以使用 TF Core API 来构建自己的训练循环、层、操作等。
Estimator API
Estimator API 使用类似于我们在 第六章 中讨论过的 Spark MLlib 估计器的方法。估计器支持各种图形架构;它们是 TensorFlow v1 库的一部分,抽象了 TensorFlow 的计算 API。根据我们的需求,我们可以选择使用预制的估计器或创建定制的估计器以满足我们的需求。
在接下来的几页中,您将看到这些 API 提供了不同程度的灵活性,因此需要不同水平的机器学习专业知识。其中最简单的 API 之一是 Keras API,这也是我们将从中开始的地方。
Keras API
Keras 是一个用 Python 编写的高级深度学习 API,运行在 TensorFlow 之上。Keras API 提供了一种 Pythonic 的方式来训练新的机器学习模型并与现有模型一起工作。Keras 提供了广泛的功能,还包括许多内置的公共 数据集,如波士顿房价数据集、MNIST、CIFAR10 等。
使用内置数据集是开始的绝佳方式。如果您想了解如何使用 API,您无需搜索或创建专用数据集,因为您已经可以立即将几个准备好的数据集导入到训练 API 中了,这些数据集已经过预处理。
Keras 还提供了许多预训练模型。这些是该训练 API 的最大优势之一:因为模型本身由层构成(如前文所示,在 图 8-1 中),我们可以决定是否要按照现有层的形式重用它们或更改它们。例如,我们可以将现有模型用作基础模型,然后在末尾添加一个预测层,这将是给定现有数据的输出层。这样做可以节省大量从头开始训练模型所需的工作量。
为什么会有效?或者说能否生成一个好的模型?这是一个很好的问题。在研究使用神经网络的各种情景时,通常存在一个共同的基准,例如图像分类。大部分训练涉及检测颜色、边缘、形状等,并不依赖于确切的输入数据。这意味着您可以使用预训练模型的权重作为在基于业务需求处理自己数据时的起点。
如在第五章中所述,这种方法有时被称为 迁移学习。在迁移学习中,通过解决一个问题获得的知识被应用于一个不同但相关的问题。Keras API 使我们能够轻松实现这一点——我将向您展示如何使用 MobileNetV2。
MobileNetV2 迁移学习案例研究
MobileNetV2 是一个由 53 层组成的深度卷积神经网络,使用 ImageNet 数据集进行训练,该数据集包含超过一百万张图像,涵盖了 1,000 个不同的类别(例如键盘、鼠标、铅笔以及多种动物)。您可以通过 tf.keras API 加载预训练版本的网络。
让我们从导入开始:
from tensorflow.keras.applications.mobilenet_v2 import MobileNetV2
导入神经网络后,我们可以通过调用 summary 函数来简单查看其摘要。我们还可以定义如何加载模型。这是通过指定我们处理的数据的输入形状(例如 (``*height*``, *width*``, *channels*``))、我们是否要包含顶部(分类)层以及要使用的权重来完成的。如果我们打算避免使用随机权重从头开始训练整个模型,重要的是定义权重。如果我们不为此变量提供值或指定为 None,则会发生这种情况。在我们的情况下,我们希望利用基于 ImageNet 数据集的现有权重(或者,您可以传递路径到要加载的权重文件)。以下代码片段将加载模型:
# define params
IMG_SHAPE = (224, 224, 3)
# Load the model as base_model
base_model = MobileNetV2(input_shape=IMG_SHAPE, include_top=False,
weights='imagenet')
使用模型需要理解其层及其用法。例如,某些层用于特征提取。如果我们不想提取新特征,我们可以冻结参数并将 base_model 自身定义为不可训练。这将强化迁移学习的效果,我们只需添加一个新的分类层来对图像进行分类。下一个代码片段演示了这一点:
# Freeze parameters in the feature extraction layers
base_model.trainable = False
# Add a new classification layer for transfer learning
global_average_layer = keras.layers.GlobalAveragePooling2D()
prediction_layer = keras.layers.Dense(num_classes)
请注意,我还添加了一个 GlobalAveragePooling2D 层,用于空间数据的池化操作。在图像中,池化与像素的池相关。此层的目标是逐渐减少表示的空间大小以及网络中的参数数量和计算量。当您希望最终模型适合较小的设备(例如嵌入式或物联网设备)时,这是必不可少的。
注意
虽然分布式训练通常使用的数据集太大,无法放入单个机器的内存中,但这并不意味着我们无法使用一个可以的小型模型来解决这个问题。减小神经网络或其他机器学习模型的大小,使其能够在内存或功耗受限设备上运行,包括日常物联网设备,如电视、冰箱、汽车等,是 TinyML 的主要目标。如果您对此主题感兴趣,可以看看Pete Warden 和 Daniel Situnayake的《TinyML》(O'Reilly)。
现在我们有了base_model和两个层,我们希望将它们堆叠到一起形成最终模型。为此,我们使用Sequential函数,它允许我们指定如何堆叠这些层。该函数接受一个层的数组,这使我们能够按照希望模型操作的顺序排列层,正如您可以在以下代码片段中看到的那样:
from tensorflow.keras.models import Sequential
model = keras.Sequential([
base_model,
global_average_layer,
prediction_layer
])
结果是一个模型,其中添加的层尚未训练(只有基础模型已经设置了其权重),我们需要训练它们以获取正确的权重。为了训练最后两层,我们将训练数据集输入模型,确保将其分成训练集和验证集(出于训练目的,我们的模型需要两者):
new_model = model.fit(train_dataset,
steps_per_epoch=steps_per_epoch,
epochs=NUM_EPOCHS,
validation_data=val_dataset,
validation_steps=validation_steps,
verbose=2).model
在这个代码片段中,我们对模型调用model.fit函数,提供所有必要的输入,包括训练数据集。这将训练最后两层并生成完全训练好的模型。您还可以配置每个 epoch 的步数、epoch 的数量和验证步数,以定制训练过程中发生的事情。
现在我们已经创建了模型,我们可以通过添加损失函数、指标和/或专用的 Grappler 优化器来配置它。在这里保持简单,我将保持模型的现状,但在实际情况中,您可能希望根据解决的业务问题来配置它。如果决定配置模型,请在fit之前和之后使用model.compile函数。
一旦准备好,我们可以使用model.predict函数对模型进行预测:
predictions = new_model.predict(val_dataset)
这将使用验证数据集项作为输入,通过神经网络进行前向传递,并提供预测结果。
此示例说明了您如何利用 Keras 中现有模型的力量来解决业务问题,通过将这些模型作为基础。请注意,在幕后,tf.keras API 本身是分布感知的。因此,根据您选择的策略,它们知道如何复制变量和操作。要使它们作为策略范围的一部分运行,请记住使用strategy.scope函数指定范围。在该范围内,您只需定义模型并调用model.compile即可。要训练模型,我们调用model.fit(可以在策略范围之外调用)。
整体看起来是这样的:
strategy = tf.distribute.MultiWorkerMirroredStrategy(...)
with strategy.scope():
# Model building/compiling must be within strategy.scope()
Model = tf.keras.Sequential(...)
model.compile(...)
batch_size = 50 # Must be divisible by the number of replicas
new_model = model.fit(...)
tf.saved_model.save(new_model, some_path)
在这段代码中,我们创建了MultiWorkerMirroredStrategy实例,这个实例会在各个机器上复制模型,然后使用它的作用域来定义模型并对其进行编译。接下来,与模型相关的所有操作,比如顺序堆叠、编译、拟合,以及保存,都发生在相关作用域内。
保存的模型是作为普通模型保存的(不附带训练策略),所以当你加载它时,它将在单个设备上作为普通模型运行。这种灵活性使我们能够决定如何加载训练好的模型并对其进行预测(有关更多信息,请参阅第十章)。
现在你知道如何利用现有模型作为基础,并添加层来构建解决方案,让我们看看如何使用 Keras 从头构建模型。
从头训练 Keras MobileNetV2 算法
前面方法与我们将在这里采用的方法之间的主要区别在于神经网络本身的训练权重。在不使用预训练权重的现有深度学习算法中,层已经定义好了,而改变的是连接图层和特征提取过程的权重。
要获得没有经过训练权重的神经网络本身,我们只需在创建基础模型时指定weights='None',而不是像前面的例子中所做的weights='imagenet'。这会导致权重的随机初始化,在训练过程中会进行调整:
# Create the base model from the pretrained MobileNetV2 model
base_model = MobileNetV2(input_shape=IMG_SHAPE, include_top=False,
weights='None')
然后,为了训练模型的特征提取层中的新参数,我们需要将base_model.trainable参数设置为True,就像这样:
# Enable parameter training in the feature extraction layers
base_model.trainable = True
就是这样!其余的流程与前面的例子基本相同。接下来,我们将看一种更加低级的解决方案:从头开始构建自定义训练循环(CTL)。
自定义训练循环
TF 的自定义训练循环 API 提供了对从头构建的训练和评估循环的细粒度控制。它允许您逐步构建训练循环,并提供对框架低级 API 的访问。如果您希望自定义模型的学习算法,这将非常棒。请注意,通过 CTL,我们需要通过创建的策略实例来负责数据集的分发。在这里,我们将以MultiWorkerMirroredStrategy为例:
strategy = tf.distribute.MultiWorkerMirroredStrategy()
当我们编写 CTL 时,必须注意训练过程中的每一步。这包括以下内容:
-
提供数据源以从中加载数据(跨副本拆分和共享的数据集,以及副本在训练过程中将更新的变量)
-
定义每个副本将在其数据集片段上运行的计算,使用其分配的资源
-
组合副本的输出(应用哪些减少操作,例如sum、mean、max等)
-
决定如何使用上一步骤的输出更新变量(例如全局减少或在边缘情况下更少地使用串联)
-
将结果广播到所有副本,对于全局减少(您也可以使用 MPI 或其他更适合您用例的函数)
-
给定更新后的变量,执行下一轮训练(达到指定的 epoch 数)
这种方法存在于 TF 中,允许为研究目的开发更复杂的算法——我在这里讨论的是没有现成算法可用或研究人员希望调查每个层如何影响其他层、采用新颖运算符使用方法等场景。由于这种 API 在研究环境中更常见,我不会详细讨论它;我邀请您在Google Brain 团队的网站上了解更多。
现在我将向您展示如何使用策略意识构建自定义循环。正如您已经知道的那样,我们必须在开始时设置策略,并且与 Keras API 一样,我们可以使用scope函数:
with strategy.scope()
reader = ...
dataset = make_petastorm_dataset(reader)
# provide TF with information on how to split the dataset
dataset = strategy.experimental_distribute_dataset(dataset)
...
model = create_model()
注意,并非所有运算符都需要放在范围内,但将所有内容放在范围内会更简单,可以避免错误。这也使得您的代码更具模块化。
我们将 make_petastorm_dataset 函数传递给使用 make_batch_reader 函数创建的 Petastorm 读取器实例,详见“将 Parquet 数据加载到 TensorFlow Dataset”。当我们提供定义的 batch_size 时,此函数负责批量读取数据。然后,strategy.experimental_distribute_dataset 函数根据批大小决定如何拆分数据。如果愿意,您可以提供 TF 不同的拆分函数,例如一个接受输入上下文并返回每个副本批大小数据集的函数;然而,除非您有处理分布式数据的经验,否则我不建议这样做。最后,我们在策略的范围内调用 create_model,以便任何变量都将按策略规定的策略创建。
使用 CTL,您可以利用 Keras API 定义优化器、损失函数等——所有这些都应在范围内进行。接下来,我们将快速浏览第三个训练 API,TF Estimator API。
估算器 API
警告
这是一个不应用于新代码的旧 API。我将简要介绍它,以帮助您处理遗留代码并需要配置训练分布式策略。如果您正在开发新代码,应使用前几节讨论的其中一个训练 API。
Estimator API 在 TF v2 中对所有分布式训练策略的支持有限。这是因为它是一个较旧的 API(来自 TF v1)并处于维护模式。与 Keras API 类似,它具有策略感知功能,这意味着在定义训练策略后,我们只需创建一个配置实例并将其传递给 Estimator 实例即可。以下是使用 Estimator 进行分布式训练的示例:
strategy = tf.distributed.MirroredStrategy()
run_config = tf.estimator.RunConfig(train_distributed=strategy)
estimator = tf.estimator.Estimator(model_fn=model_function)
estimator.train(input_fn=input_fn_train)
首先,我们创建策略,然后我们为 Estimator 运行指定配置。tf.estimator.RunConfig 定义了 Estimator 的操作方式,因此将策略传递给构造函数并使用 train_distributed 参数是必须的。
第三行创建了 Estimator 本身,第四行按照 run_config 中描述的方式运行训练。
注意,使用此代码时,提供给 Estimator 的 model_function 每个副本会调用一次。Estimator 本身已经知道如何将模型函数的结果合并为一个统一的答案。
这段代码与之前的方法有何不同?我们没有使用 strategy.scope 函数!这使得我们对选择策略和在其中执行的整个过程有点隐晦。
现在我们有了一个分布式模型,我们可以使用 TensorFlow API 来保存和加载它,类似于我们在 MLlib 中的操作。以下是我们如何实现这一点的示例:
tf.saved_model.save(model, model_path)
uploaded_model = tf.saved_model.load(model_path)
要了解更多信息,包括不同变体、编程语言支持等,请参阅 TensorFlow 文档。我们将在 第十章 进一步讨论加载和部署模型。
将所有内容整合在一起
您现在已经对 TF 提供的各种训练策略及其工作原理有了很好的了解。现在是时候使用 Caltech 256 图像数据集将所有内容整合起来了。
在 第五章 中,您学习了如何从 Caltech 256 数据集中提取特征,创建图像的灰度版本,提取标签等。要将此数据集与 Keras MobileNetV2 模型一起使用,我们需要进行一些额外的数据处理。我们将使用 Petastorm 进行此操作,并利用 TensorFlow 的迁移学习能力仅训练部分层。
首先,我们将定义一个支持函数,将数据集架构转换为可与 MobileNetV2 一起使用的架构。我们的 preprocess 函数使用 Pillow API 调整每个图像的大小并创建一个 Keras 图像数组,如下面的代码片段所示:
from tensorflow.keras.applications.mobilenet_v2 import MobileNetV2,
preprocess_input
def preprocess(grayscale_image):
"""
Preprocess an image file's bytes for MobileNetV2 (ImageNet).
"""
image = Image.open(io.BytesIO(grayscale_image)).resize([224, 224])
image_array = keras.preprocessing.image.img_to_array(image)
return preprocess_input(image_array)
要运行此功能,我们需要使用 pandas DataFrame 迭代我们的数据集。如前几章所述,Spark 对 pandas DataFrame 的支持比 Spark UDF 更优化。
下面的支持函数将以 pandas DataFrame 作为输入,并返回一个 pandas DataFrame 作为输出:
def transform_row(pd_batch):
"""
The input and output of this function are pandas DataFrames.
"""
pd_batch['features'] = pd_batch['content'].map(lambda x: preprocess(x))
pd_batch = pd_batch.drop(labels=['content'], axis=1)
return pd_batch
在这里,我们使用 map 函数遍历 content 列并在其上执行 preprocess 函数。 我们将结果保存在名为 features 的新列中;然后,我们使用 drop(labels=['content'], axis=1) 删除原始的 content 列,因为它不再需要。 函数返回更新后的 pd_batch。
transform_row 用于 Petastorm 的 TransformSpec 实例。 TransformSpec 在构造函数中使用此函数来定义如何将 Parquet 中的数据转换为适合算法(在我们的情况下是 MobileNetV2)的格式。 TransformSpec 还接受可选参数 edit_fields 和 selected_fields,指定转换操作的字段以及最终需要生成的字段。以下代码片段展示了如何使用它:
from petastorm import TransformSpec
IMG_SHAPE = (224, 224, 3)
transform_spec_fn = TransformSpec(
func=transform_row,
edit_fields=[('features', np.uint8, IMG_SHAPE, False)],
selected_fields=['features', 'label_index'])
请注意,我们必须为每一列提供模式信息,用 4 元组表示,包含以下信息: (name, numpy_dtype, shape, is_nullable)。 在我们的情况下,我们只转换一个字段 features,其类型为 np.uint8。 我们为其提供了 (224,224,3) 的 IMG_SHAPE,并指定字段不能为 nullable,即 False。
现在我们已经定义了 transform_spec_fn,我们需要定义 Petastorm 转换器。 我们使用 make_spark_converter 函数进行此操作(如 第七章 中讨论的那样),该函数将利用 Spark 集群构建转换器并将数据转换为 TF 数据集:
# training dataset
converter_train = make_spark_converter(df_train,
parquet_row_group_size_bytes=32000000)
# validation dataset
converter_val = make_spark_converter(df_val,
parquet_row_group_size_bytes=32000000)
注意
当我们已经有一个具体化的 Spark DataFrame 并希望将其转换为 TF 数据集时,我们使用 make_spark_converter 函数。 这种方法与我们之前讨论的方法不同,后者包括将数据写入磁盘并使用 Petastorm 加载它。
现在我们可以使用 make_tf_dataset 转换 DataFrame,配置 transform_spec=transform_spec_fn 和 batch_size=BATCH_SIZE。 在这里,我们创建了两个 TensorFlow 数据集,一个用于训练,另一个用于验证:
def within_strategy_scope(...):
with converter_train.make_tf_dataset(transform_spec=transform_spec_fn,
batch_size=BATCH_SIZE) as train_dataset,
converter_val.make_tf_dataset(transform_spec=transform_spec_fn,
batch_size=BATCH_SIZE) as val_dataset:
model = get_model(lr=0.001)
model.compile(optimizer="SGD",
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"])
# tf.keras only accepts tuples, not namedtuples
train_dataset = train_dataset.map(lambda x: (x.features, x.label_index))
steps_per_epoch = len(converter_train) // (BATCH_SIZE)
val_dataset = val_dataset.map(lambda x: (x.features, x.label_index))
validation_steps = max(1, len(converter_val)) // (BATCH_SIZE)
hist = model.fit(train_dataset,
steps_per_epoch=steps_per_epoch,
epochs=NUM_EPOCHS,
validation_data=val_dataset,
validation_steps=validation_steps,
verbose=2)
strategy = tf.distribute.MultiWorkerMirroredStrategy()
with strategy.scope():
within_strategy_scope(...)
此代码示例使用了支持函数 get_model,该函数返回模型。 我们使用 model.compile 函数进行编译,该函数在 “MobileNetV2 迁移学习案例研究” 中讨论,然后使用 TF 的 map 函数映射数据集以适应 Keras 的输入要求,因为它只接受元组而不接受命名元组。 steps_per_epoch 通过将训练数据集大小除以批处理大小来计算。 例如,如果训练数据集中有 100 万条目,批处理大小为 1,000,则 steps_per_epoch 将为 1,000,000/1,000 = 1,000(即每个 epoch 将有 1,000 步)。 validation_steps 也是如此。 最后,我们使用 model.fit 函数训练模型。 此函数返回的 hist 实例保存了每个 epoch/迭代的历史记录。 您可以稍后检查此信息以更好地评估模型的准确性。
故障排除
在从 Spark 转换到 Petastorm 再到 TensorFlow 的过程中可能会出现错误。例如,你可能会看到如下异常:
raise ValueError(f'All dimensions of a shape: {field.shape} in: {field.name}
field must be constant. '
ValueError: All dimensions of a shape: (None, None) in: features field must be
constant. If a dimension is variable, we won’t be able to coalesce rows when
preparing a batch.
要解决这个问题,你需要回到你的transform_spec定义。Petastorm 将此规范转换为arrow_reader_worker,它要求 TensorFlow 张量具有固定的形状。如果你尝试提供一个None值来表示张量形状,你将遇到这个错误。
要解决问题,Unischema变量必须具有固定的长度;否则,它在幕后执行的数据聚合逻辑将无法工作。换句话说,在预处理数据时,你需要确保你的变量具有固定的长度。
此外,你需要确保你的图像大小都相同,否则你将会遇到以下错误消息:
Input element must have the same batch size in each component.
总结
本章介绍了几个新概念,重点讨论了 TF 的分布策略。我们深入探讨了这个框架在分布式机器学习中的方法,并通过一个完整的端到端示例演示了如何使用它来训练模型。你现在应该了解 TF 的策略如何工作,以及它们与 Spark 的比较。正如你所见,尽管 TF 和 Apache Spark 之间有许多相似之处,但它们在命名约定和操作上也存在一些差异。
重要的是要记住,建立分布式系统没有单一的权威方法,而是基于相邻概念、结构和优化的许多不同方法。TF 和 Spark 之间的主要区别在于,Spark 是作为通用分布式分析引擎构建的,而 TF 是作为深度学习引擎构建的。
接下来,我们将探讨 PyTorch 的分布式训练方法及其与 TensorFlow 的区别。
¹ 这种模型将远程过程调用与其返回值分离,克服了传统 RPC 模型(客户端在调用返回前被阻塞)的一些缺点。
第九章:PyTorch 分布式机器学习方法
PyTorch 是由 Facebook 的人工智能研究(FAIR)团队开发的开源机器学习库,后来捐赠给了 Linux 基金会。它旨在简化人工神经网络的创建,并支持计算机视觉、自然语言处理等应用。PyTorch 的主要接口是 Python,但其底层是基于 C 和 C++的代码。这与 Spark 大不相同,Spark 的核心是 Scala 和 Java(基于 JVM 的编程语言)。
在前几章,你已经了解了机器学习工作流的构建模块。我们从 Spark 开始,然后扩展到探索 TensorFlow 的分布式训练能力。在本章中,我们将把注意力转向 PyTorch。我们的目标是帮助你更好地理解 PyTorch 是什么以及其分布式机器学习训练如何工作,从架构和概念的角度,以便你在分布式环境中结合多个框架时能做出更好的决策。
我们还将逐步介绍如何在分布式 PyTorch 中使用之前在第四章和第五章中与 Spark 以及在第七章中与 Petastorm 所做的工作。
本章涵盖以下内容:
-
PyTorch 基础知识快速概述
-
PyTorch 分布式训练模型的策略
-
如何将 Parquet 数据加载到 PyTorch 中
-
将所有内容整合在一起——从 Petastorm 到 PyTorch 模型
-
与 Petastorm 和分布式 PyTorch 一起工作的故障排除指南
-
PyTorch 与 TensorFlow 的不同之处
注意
如果你是 PyTorch 的新手,我建议你首先阅读像 Joe Papa 的PyTorch Pocket Reference(O’Reilly)这样的入门文本。本章节快速概述了基础知识;重点主要放在分布式训练策略上以及如何将 PyTorch 与 Spark 连接起来。
PyTorch 基础知识快速概述
在本书中,你学到了很多技术概念,重要的是你要为手头的工具使用正确的术语,并理解各种机器学习框架之间的区别。如果你是 PyTorch 的新手,并希望熟悉其术语,本节适合你。虽然许多命名约定相同,但有些完全不同。本节将突出一些 PyTorch 中的关键术语和概念,从其计算图开始。
计算图
像 TensorFlow 和 Spark 一样,PyTorch 使用计算图。示例显示在 Figure 9-1 中;正如你所见,它强调通过神经网络本身的前向计算,同时在训练运行期间支持反向计算梯度。
在这个图中,圆圈(x1、x2等)表示张量,矩形表示数据上的操作,如Log、Sin和**(用于乘法)。图的计算从左下角开始,通过计算x1 * x2——生成张量a的操作。在此操作期间,图还保存了未来的反向乘法梯度操作的信息(在图 9-1 中称为MultBackward)。此信息稍后将支持损失的反向传播(作为提醒,在神经网络中,算法使用此信息计算图上的增量并改进训练过程)。PyTorch 通过自动求导过程计算相对于输入的梯度,并由自动求导引擎执行计算图。

图 9-1. 带有反向传递的计算图示例(来自PyTorch 博客)
图 9-2 展示了计算图的一个子集,重点是仅向前计算。

图 9-2. 解析向前计算方法
我们对x1和x2进行乘法运算,得到值a。然后我们对该值运行另一个操作(Log(a)),依此类推。由于这是神经网络的一个子集,我们知道将会有一个反向传播过程来调整权重的值并训练模型。图 9-3 展示了维护反向传播所需的增量值的机制。

图 9-3. 图支持向前和向后计算
在每次向前迭代中,都有一个计算梯度以供将来向后迭代使用的过程。换句话说,PyTorch 的自动求导引擎在后向计算开始前自动计算梯度。
在 PyTorch 中,与 TensorFlow 不同,计算图是动态的,TensorFlow 中计算图是静态的。在静态方法中,程序首先构建图,只有在完成后才执行。动态方法中,图是在运行时构建的,执行开始前即使图未完成;程序根据需要动态构建计算图。
PyTorch 机制与概念
这些是在使用 PyTorch 时应熟悉的基本概念,以理解其神经网络实现及其如何解释计算图:
torch.Tensor
PyTorch 张量类似于 TF 张量;它们是包含标量类型(如 float、int 等)的多维矩阵。类型可以是 NumPy 类型,优化用于 CPU 或 GPU。张量还有一个步幅,表示机器在内存中达到下一个连续数组元素所需的步长。想象一下在内存中的矩阵表示;为了到达下一个值,机器需要读取多少位?这就是步幅。张量的步幅取决于其物理表示,由机器的配置(硬件、内存等)决定(例如,GPU 的行为与 CPU 不同)。
torch.autograd
自动求导是 PyTorch 内置的自动微分引擎。当你声明张量时,若设置 requires_grad=True,它会收集张量上每个操作的输入和输出梯度。之后,PyTorch 引擎在反向传播过程中利用这些信息自动计算梯度。换句话说,autograd 图在前向计算时构建,在反向计算时使用。为了更好地理解,可以看下面的 Python 代码片段,展示了从本地训练到分布式训练的过渡:
import torch
import torch.nn as nn
import torch.optim as optim
# set up model
net = nn.Linear(10, 10) 
# load input and target
input = torch.randn(20, 10) 
tgt = torch.randn(20, 10)
# forward pass
output = net(input) 
# compute loss using input and target
mselss = nn.MSELoss() 
lss = mselss(output, tgt)
# backward pass
lss.backward() 
首先,我们使用以下数学公式创建线性层:y = x * A^T + b。输入 (10, 10) 表示该层期望有 10 个输入特征,同时输出 10 个特征。在内部,有两个函数:第一个是乘法器 (x * A^T),第二个是偏置函数 (+ b)。
接下来,我们生成给定大小为 20×10 的虚拟输入和虚拟标签/目标张量。
现在我们应用线性模型到输入上生成输出。在执行过程中,PyTorch 隐式构建 autograd 图。Autograd 记录函数的输入和输出 (y),用于计算梯度。由于线性模型由两个操作组成,它将记录两个节点:一个用于乘法器,存储操作的输入 (x) 和权重 (A^T),另一个用于偏置,存储乘法过程的输出 (x * A^T) 和偏置。
我们使用 MSE(均方误差)损失函数计算输出与期望目标标签/输出之间的误差。
最后,我们对损失张量调用backward,它遍历了在前向传播期间构建的自动求导图,为每个参数计算梯度。之后,模型中的所有Parameter实例都将有一个graph_view参数,用于存储在反向传播期间计算的梯度。接下来的层可能是优化器;它将引用先前计算的参数,并将梯度应用于它们。此操作将更新参数的值以修正它们并减少损失。(这个概念在 TensorFlow 中也存在,但不是该框架的主要特性,并且需要进行大量调整。)
AutogradMeta
这个对象保存了为支持自动求导图中的反向传播生成的元数据。它在 PyTorch 的 C++源代码中定义如下:
struct TORCH_API AutogradMeta : public c10::AutogradMetaInterface {
std`:``:`string name_;
Variable grad_;
std::shared_ptr<Node> grad_fn_;
std::weak_ptr<Node> grad_accumulator_;
// other fields and methods
...
};
AutogradMeta包含一个名为grad_fn_的 C++共享指针实例,用于计算实际梯度。还有一个名为grad_accumulator_的 C++弱指针实例,当可用时会累积梯度。
Variable
在 PyTorch 中,Variable是围绕张量的一个包装器,它封装了额外的信息,例如AutogradMeta对象、张量的值和梯度。从计算图的角度看,Variable在图中表示为节点。
torch.layout
此对象表示张量内存的结构方式,根据张量的要求,可以是密集的或稀疏的。
torch.mm
这是一个在两个输入矩阵/张量上执行矩阵乘法的函数。
torch.utils.data.DataLoader
PyTorch 中的数据加载器遍历数据集并生成数据批次,以在一台机器上训练模型。在“使用 PyTorch 和 Petastorm 加载数据”中,您将了解 Petastorm 如何与 PyTorch 数据加载器配合使用。
torch.optim.Optimizer
PyTorch 的torch.optim包实现了多种优化算法。优化器的目标与机器学习中的所有算法相似:降低损失并提高准确性。在神经网络中,这涉及调整节点权重和学习率。每个 PyTorch 优化器都有一个state_dict方法,返回一个包含有关优化器状态的dict,包括需要优化的参数和模型的超参数。
注意
作为快速提醒,模型参数是从训练数据中自动计算的,而模型超参数是手动设置的,并在计算模型参数的过程中使用。超参数在训练过程中不会更改;参数会随着算法和超参数的影响而改变。
Autograd 收集有关图中操作的信息,在向后计算期间优化神经网络。它不会在前向传递期间立即执行优化,而是在向后传递的损失计算步骤期间同步优化参数(有关详细信息,请参见前面的代码片段)。这是因为在网络通信方面,向后传播更昂贵。
在这一步中,PyTorch 会逐步计算梯度,这为计算参数提供了一个良好的机会,通过这样做,避免了另一次昂贵的通信运行(更多详情请见下一节)。稍后,为了更新参数,我们需要明确调用optimizer的step。以下代码片段展示了计算损失以及后续运行优化器步骤更新参数的过程:
# compute loss function
loss.backward()
# update the training parameters according to the loss function outcome
optimizer.step()
根据您使用的优化器类型,您可能需要提供其他输入。
PyTorch 用于训练模型的分布式策略
PyTorch 的好处在于它能让应用从简单到复杂逐步增长,在你有原型时在一台机器上运行,并根据需要在生产或暂存/开发环境中扩展到多台机器。torch.distributed包提供了一组 PyTorch 功能,允许跨多台机器(即分布式数据并行训练)训练机器学习模型。
让我们从一个故事开始。2020 年,FAIR 研究员沈黎决定研究如何加速使用 PyTorch 的数据并行训练。他和他的团队进行了研究,以检查多种配置,试验优化器、参数等。这使他们得出一个有趣的结论——在分布式数据并行(DDP)训练领域,没有一种大小适合所有的解决方案:
有多种技术可以提高其速度,创建一个复杂的配置空间。根据我们的观察,没有单一的配置适用于所有用例,因为这高度依赖于模型大小、模型结构、网络链接带宽等。
现在您对这个领域有了更好的理解,本节将提供一些指导,您可以用来做出决策,并熟悉不同的 PyTorch 抽象。您将从过程和进程通信的角度了解各种分布式策略,这将增加您的机器学习训练工具包。
PyTorch 的分布式方法介绍
PyTorch 的分布式基础库是torch.distributed,它处理来自硬件和网络视角的分布式通信的所有方面,例如用于 GPU/CPU 的 InfiniBand 互连。在任何分布式系统中,硬件是基础,你应该优化应用程序以匹配硬件。torch.distributed允许你做到这一点,无论你正在使用的设置的具体细节如何。
我们可以将torch.distributed中的功能分为三个主要组件:
分布式数据并行训练(DDP)
PyTorch 的DistributedDataParallel类基于模块级别的torch.distributed包实现了分布式数据并行。这两者有什么不同,为什么我们需要同时使用?这是一个很好的问题。简短的答案是,DistributedDataParallel仅处理应用本身——即应用级别算法的训练,而torch.distributed处理硬件层面。
作为 DDP 的一部分,PyTorch 引入了多个抽象概念,将在下一节中介绍。
基于 RPC 的分布式训练(RPC)
PyTorch 的分布式 RPC 框架(torch.distributed.rpc)支持更高级别的训练过程,并提供机制来实现机器之间的远程通信。它支持诸如分布式管道并行、参数服务器范式(类似于 TensorFlow 中讨论的第八章)等功能。它还为模型并行训练提供了分布式自动求导框架。
集体通信(c10d)
该库提供了扩展通信结构并支持在组内跨进程发送张量的额外功能。它提供了集体和点对点通信的 API,如all_reduce、all_gather、send和isend。DDP 和 RPC 框架都是在其之上构建的。作为开发者,我们很少与这个库交互,但熟悉这些概念是一种良好的实践。
在决定使用哪种策略并实施后的最后一步,您将通过调用初始化方法来初始化分布式环境:
torch.distributed.init_process_group()
我们将在接下来的几节中更详细地研究这些方法。
分布式数据并行训练
作为 DDP 的一部分,PyTorch 引入了多个抽象概念,将在本节中介绍。我们将从 buckets 开始。
与查看特定神经网络层不同,PyTorch 将通信划分为桶。一个桶保存了 input 中每个值所属的索引。桶的边界由称为 boundaries 的张量实例设置。如图 9-4 所示,桶可以包含多个层或一个层,具体取决于我们如何使用 torch.bucketize 来定义它们。桶在架构中是一个关键组成部分,因为它们在反向传播过程中定义了梯度计算的边界。这就是为什么 bucket1 位于图 9-4 的底部的原因。

图 9-4. PyTorch 中的层桶抽象
根据机器的通信策略,桶也可以帮助定义何时启动下一个梯度计算过程。当所有桶完成后,会使用全局归约操作来累积各个桶中的值。PyTorch 引擎以贪婪方式工作,这意味着并非所有层和梯度总能适应同一桶中。这使得分布式桶抽象成为一个随机系统,允许 PyTorch 引擎在运行时决定哪种计算操作更有效,根据已知信息。
小贴士
DDP 在开始时也需要接受一个种子值,用于注入到模型参数中。在分布式设置中,提供种子值非常重要。如果不提供种子值,每台机器将生成自己的种子,这些种子很可能不同于其他机器使用的种子。这将导致模型参数也不同,从而影响覆盖过程。
基于 RPC 的分布式训练
远程过程调用(RPC)允许本地机器上的程序启动另一台机器上的程序,只要它们共享相同的地址空间。这就像公寓 A 的居民可以通过提供洗衣机的地址来启动公寓 B 中的洗衣机一样。这就是 RPC 背后的概念。
在 PyTorch 中,RPC 框架允许数据科学家使用远程通信的原语以及更高级别的 API 来训练跨多台机器的模型。有哪些主要用例需要注意?如前所述,PyTorch 的 RPC 框架使以下范例的实现成为可能:
-
参数服务器(类似于 TensorFlow),其中一个或多个服务器存储参数,并且分布式训练器与它们通信以获取和更新这些值
-
模型并行,允许将无法放在单个 GPU 上的大型模型的不同部分放置在不同的 GPU 上
-
管道并行(一种实验性特性),其中每个输入小批量被分割成可以在所有 GPU 上并发执行的微批量
警告
这是一个通用分布式训练的低级框架。在那些不适用于 DDP 的场景中,利用它是有意义的。
框架提供的 API 可以根据它们支持的功能分成四类:
远程执行
您可以远程运行任意函数并获取返回值,或者创建对返回值的引用。这是我们可以从任何 RPC 系统中期待的功能。
远程引用 (RRefs)
RRef 是一个分布式共享指针,用于指向远程工作器上的对象。它允许您访问和引用一个值,而不需要从该对象获取实际数据。由于其结构和实现,它提供自动引用计数,这对于理解远程数据对象被引用的频率非常有用,例如。
分布式自动求导
自动求导引擎在每个参与前向传播的工作器上本地运行;默认情况下,它不具备扩展性。分布式自动求导将其扩展到单台机器的边界之外,将所有机器上的本地自动求导引擎串联在一起,以便可以以分布式方式运行反向传播。
分布式优化器
分布式优化器收集所有参数的 RRef,并在每个参数所在的工作器上创建本地优化器。然后它使用 RPC 在本地执行优化器。根据所使用的优化器,它周期性地在工作器之间对结果进行平均。
让我们深入探讨每一个问题,这样您就能更好地理解它们在代码和执行图中的表现。
远程执行
PyTorch 的远程执行 API 允许我们远程运行用户函数。我们需要做的第一件事是通过调用 init_rpc 函数来启动 RPC。此函数需要三个参数:机器的名称、其在组内的全局唯一 ID/等级,以及一个代表组中工作器数量的整数 (world_size)。请看下面的代码片段:
init_rpc("w0", rank=0, world_size=32)
init_rpc 函数在这里有两个作用。首先,它启动一个在后台运行的代理。当代理准备就绪时,它可以开始接收并处理来自其他对等节点的请求。其次,它启动了 rendezvous¹ 通信,与对等节点建立连接。在此阶段结束时,代理已经知道正在运行的所有 RPC 进程,而此 RPC 组中的所有对等节点也互相知晓。
注意这里没有客户端/服务器架构;所有通信都是点对点的(我们将在“PyTorch 中的点对点通信”中详细讨论这是如何工作的)。默认后端利用了 TensorPipe 库,该库提供了一种特别设计用于机器学习的张量感知点对点通信原语。要更改这一点,您可以提供一个专用的BackendType函数;这允许您更改配置的某些元素,例如对等方回复的超时和使用的init_method。在本例中(机器的名称),w0代表 worker zero。从现在开始,我们将使用w0、w1、w2、w3等表示 worker 0、worker 1、worker 2、worker 3 等等。
现在机器准备就绪,我们可以开始向组内的对等体发送远程函数调用,如图 9-5 所示。为此,应该有一个张量和我们想要执行的远程操作,以及操作所需的其余参数。

图 9-5. PyTorch 远程执行图示
在讨论通信时,重要的是要区分两个关键概念:
-
通信拓扑,指示机器在组中的组织方式以及谁与谁通信(例如点对点或客户端/服务器)
-
通信类型,定义了机器之间如何通信
PyTorch 使我们能够选择每个操作之间所需的精确通信类型。您可以从三个选项中选择:rpc_sync、rpc_async和remote。以下代码片段演示了这三种用法:
# initialize RPC and a torch
rpc.init_rpc("w0", rank=0, world_size=32)
x = torch.zeros(32) 
# synchronous – returns the result
ret = rpc_sync("w1", torch.add, args=(x,1) 
# asynchronous, returns future
fut = rpc_async("w1", torch.add, args=(x,1)) 
# asynchronous, returns reference to result
@torch.jit.script 
def script_add(x,y):
return x+y
rref = remote("w1", script_add, args=(x,1))
我们初始化了一个大小为 32 且值为零的 torch 张量。目标是模拟一个张量。
当我们需要等待返回值再继续时,我们使用同步 API。使用rpc_sync,我们在通信建立、操作执行并返回值之前无法进行下一步。这是一个阻塞函数;²它会阻塞程序,直到调用者收到响应为止。rpc_sync的第一个输入参数是要连接的进程名称;第二个是我们想要在目标进程上运行的操作。在这种情况下,我们使用已经实现的 PyTorch 函数torch.add。第三个参数args是我们希望提供给torch.add函数的输入参数列表。该函数返回更新后的张量。
当我们想要在目标进程上运行操作但不需要立即获取结果时,我们使用异步 API。这里的 rpc_async 调用类似于前面的 rpc_sync 调用(它接受相同的参数),但在这种情况下,该函数不会阻塞程序继续执行下一个命令。异步调用返回一个 future —— 一个作为结果代理的对象,因为其计算尚未完成,当前时间结果未知。要在需要结果时检索结果,我们需要调用 fut.wait 并将其保存到变量中。wait 是一个阻塞函数,它将阻塞程序,直到结果返回。这种功能使我们能够并行执行多个未来操作。例如,如果我们希望在同一工作器上执行 add 和 max 操作,我们可以同时运行它们,然后对两个 torch 向量使用 + 运算符进行求和:
fut_add = rpc.rpc_async("w1", torch.add, args=(x, 3))
fut_max = rpc.rpc_async("w1", torch.max, args=(x))
result = fut_add.wait() + fut_max.wait()
这使得对操作进行并发控制成为可能。
remote 函数(第三个 API)并不获取值;它的目的是远程执行一个创建某物的函数。它接受要运行函数的进程名称、要运行的函数,以及必要时的 args 或 kwargs。您还可以提供一个可选的 timeout 参数。在这个示例中,我们运行一个 TorchScript 函数 script_add 的注释版本。TorchScript 允许我们在本地编译程序,稍后在一个没有 Python 依赖的进程中加载它。我们可以利用这个功能来执行远程函数。@torch.jit.script 是定义它的 Python 注释;每当我们使用这个注释时,Python 解释器与 PyTorch 一起检查源代码,并将其转换为 TorchScript。remote 函数是异步的,这意味着程序不会被阻塞。然而,remote 和 rpc_async API 的主要区别在于,remote API 返回一个远程引用,指向另一台机器上的值(RRefs 在下一节中讨论)。返回值存在于目标进程中,并不会被传回触发它的原始进程。总结这个例子,script_add 函数与参数 x(torch)和 1 一起发送到 w1。
提示
您还可以在训练后使用 TorchScript 方法。假设我们在 Python 环境中使用 PyTorch 训练了一个模型。现在,我们希望将训练好的模型导出到一个环境中,由于 Python 语言在多线程世界中的性能较低,使用 Python 程序不利。TorchScript 创建了一个独立的 C++ 程序,可以在没有 Python 环境的不同进程/机器上运行。
你如何在三种可用的远程执行选项之间进行选择?就与所有与机器学习和构建分布式执行图有关的事物一样,答案是:要看情况。这里的指导是要分解网络层并考虑每个功能的功能。某个操作是否需要等待另一个操作完成?它们是否彼此依赖?如果是这样,rpc_sync在这里将是一个不错的选择。我们是否可以并行一些操作,使它们同时进行?我们是否可以在所有信息可用之前继续训练?在这种情况下,我们可以使用rpc_async。我们是否想在远程服务器上执行一个函数而不返回值?例如,当我们有一个带有参数服务器和训练器的拓扑结构时,我们可以在 PS 上创建参数表,而不需要将表取回,因为运行主程序时不需要它。在这种情况下,remote函数是最佳选择。
远程引用
在分布式系统中的训练过程中,通常会有一个驱动节点来驱动训练循环的执行,以及在数据上操作的工作节点。因此,在某些情况下,我们可能需要创建远程引用,例如用于用户定义的函数。在这种场景中,UDF 在驱动程序上定义并被传输到工作节点,每个节点并行操作其数据块,而不将结果发送回驱动程序。工作节点保持结果,而驱动程序只持有对它们的引用。这类似于计算机科学中分布式共享指针的概念,其中指针存储数据存储的地址,但不存储实际数据本身。具有引用副本(称为用户)的任何机器都可以从其创建者(所有者)请求对象。
使用 RRefs 来编排分布式算法
在前一节介绍的remote函数在指定的工作节点上创建了一个 RRef。该函数通过协调工作进程上的操作和可调用函数来支持 PyTorch 分布式算法。让我们看以下代码片段和图 9-6,以更好地理解其工作原理:
@torch.jit.script
def some_add(rref_x, rref_y)
return rref_x.to_here() + rref_y.to_here()
# these functions run on worker process "w0"
ra = remote("w1", load_data_a)
rb = remote("w2", load_data_b)
rc = remote("w3", some_add, args=(ra,rb))
rd = remote("w4", *{some function that takes rc as input}*)

图 9-6. 简单加法函数的远程编排,为许多分布式训练算法提供支持
该代码片段在w0上运行,并在w1、w2、w3和w4(图 9-6 的操作 1)创建了四个远程引用。对w1和w2的remote调用要求它们分别加载数据的不同分块并返回指向该数据的 RRefs。w1加载数据块a,而w2加载数据块b。在图 9-6 中,init 是动作编号 1。
w3被分配了使用some_add函数添加两个数据块的功能,该函数以两个RRef对象作为输入并对它们调用to_here。这个调用开始获取数据,因此从w1和w2获取结果到w3。w3在本地添加结果并将结果的一个 RRef 返回给w0(在 Figure 9-6 中的操作 2)。w4现在可以使用w3的结果执行一些额外的功能(在 Figure 9-6 中的操作 3)。
请注意,由于程序的主入口在工作进程 0 上运行,所有通信都通过它进行。然而,这种通信相对轻量级,因为它仅包含远程引用本身的控制消息。从w1和w2获取数据的调用仅在w3上执行。在这里,远程引用帮助我们实现了两个目标:
-
代码留在我们的驱动器
w0上的一个进程中,它充当主编排器。 -
避免将数据从工作进程传输到编排器,以避免这一费用。(在 Spark 中,这是我们在处理无法容纳在一台机器内存中的可伸缩数据时要尽力避免使用的
collect函数。)
编排器是使用 RRefs 的一个重要方面;另一个是通过引用标识对象的能力。
通过引用标识对象
正如前面的例子所示,PyTorch 的远程引用也旨在在分布式环境中唯一标识对象,并且可以将其作为 RPC 参数传递,避免传输真实数据。当w0调用remote函数时,在本地创建一个UserRRef对象。w0可以将此对象作为参数发送给其他工作进程,并通过这样做使它们能够获取结果或传递引用。同时,对remote的调用在命名的工作进程上创建了一个OwnerRRef对象实例,其中包含执行函数的实际结果。简而言之,w0创建了由w1和w2拥有的UserRRef并将它们发送给了w3。
用户机器可以在三种情况下获取一个UserRRef:
-
它可以从所有者那里接收一个
UserRRef。 -
它可以从另一个用户那里接收一个
UserRRef。 -
它可以创建一个由另一个工作进程拥有的新
UserRRef。
所有者跟踪引用计数,以便在垃圾回收操作中更好地评估何时可以删除数据本身。我们不会深入探讨这一点,但如果您想了解更多,请查看 GitHub 上的RRef 设计说明。
关于 RRef 的关键是,它们允许数据科学家设计更复杂的分布式机器学习算法,这些算法在 PyTorch 中并没有直接实现。这一功能通常由推动机器学习算法创新的研究人员使用。在深入研究 PyTorch 和分布式机器学习以及故障排除时,理解这一点非常重要。
分布式自动求导
在本章的早些时候,您已经了解了 PyTorch 自动求导引擎在本地工作的一瞥。现在,我们准备升级并探索其在分布式数据集上的工作方式。在分布式环境中,模型在多台机器上复制,每台机器处理数据集的一部分。这意味着每台机器将根据其输入计算自己的梯度值。尽管操作是相同的,但输入在这里起到了重要作用。
PyTorch 基于 RPC 的分布式自动求导框架利用远程执行和远程引用来在训练期间收集和计算梯度,之前已经讨论过。为了分布式目的,自动求导引擎使用函数/操作上下文进行扩展,这种方法类似于之前在第八章中讨论的 TensorFlow 方法。指向上下文的共享指针具有全局唯一标识符,并分发给参与训练的每个节点。潜在地,每个工作节点都可以检索上下文信息(send 和 recv 函数、梯度等)。
此外,每次 RPC 调用都会将不同机器上的自动求导函数连接起来,这使得我们能够在分布式系统中跟踪梯度的变化。分布式自动求导的目标是提供类似于在本地机器上运行反向传播的体验。这意味着对于每次前向传播,机器还会存储send和recv信息(分别标识发送机器和接收机器)。这确保了在分布式自动求导图中始终有节点的参考,并推动了反向传播的执行。
下面的代码片段启动了一个分布式自动求导上下文:
import torch.distributed.autograd
with autograd.context() as ctx:
# some functionality within the context
所有调用开始前向和后向传播的分布式优化器都应在此上下文中调用。
分布式优化器
到目前为止,我们已经看到了各种框架中使用的优化器:Spark、TensorFlow 和 PyTorch。为什么我们需要一个分布式优化器来处理 PyTorch?在分布式训练中,模型参数分散在多台机器上,我们需要优化所有这些参数。如果没有分布式优化器,数据并行训练将要求每台机器从所有其他机器收集参数,然后运行本地优化器。这会在通信(多对多或n:n通信)和计算方面产生大量开销,因为每台机器都需要运行优化函数。同时,这也会导致瓶颈和在某些机器上运行优化时错过消息或无法收集所有数据的风险。
PyTorch 通过实现一个名为DistributedOptimizer的薄包装器来解决这些问题。该包装器仅接收这些参数的远程引用而不是收集所有参数。在优化函数初始化期间,它会联系这些参数的所有者并在这些机器上本地运行优化。采用这种方法仍然存在多对多通信,但它非常轻量级,因为数据无需传输;它只是一个调用,用于收集、计算和优化参数。稍后,在自动求导上下文中运行step函数时,此函数将联系所有参与的优化器在本地执行step。
以下代码片段展示了如何使用分布式优化器:
# within the autograd distributed context
dist_optim = DistributedOptimizer(
optim.SGD,
[rref1, rref2],
lr=0.05
)
如果你决定开发自己的分布式训练算法,请注意torch.distributed中支持的优化器。请注意,并非所有功能都受支持,可能需要自己实现某些功能。
TorchServe,一个用于提供 PyTorch 模型的流行工具,还支持 gRPC(Google 的高性能 RPC 框架)作为通信协议。但与 gRPC 相反,PyTorch 的 RPC 实现能理解张量对象。如果你尝试将 PyTorch 训练算法与 gRPC 连接起来,第三方库将期望 JSON、字符串或另一种用户定义的数据类型来处理张量。这将迫使你处理已在 PyTorch RPC 库中实现并优化的序列化工作。虽然这可以成功实现,并且已经成功实现过,但这会增加更多的工作量——我们都希望能让工作更轻松,而不是更难,对吧?
PyTorch 中的通信拓扑结构(c10d)
PyTorch 支持两种通信方式:集体通信和点对点(也称为点对点)通信。这两种 API 都可以定义和调整拓扑结构。在底层,DDP 使用集体通信,而 RPC 使用点对点通信。两者都支持同步和异步操作。
注意
端到端和集体通信 API 是使用 C++在torch.distributed.distributed_c10d库中实现的,出于性能考虑。与 RPC API 类似,这些是低级 API,如果您正在尝试排查现有应用程序或构建自己的分布式算法,那么您应该了解这些 API。
PyTorch 中的集体通信
在计算机科学中,集体通信被定义为涉及一组进程/机器的任何通信。最常用的操作包括广播、屏障同步、减少、聚集、分散、全对全完整交换和扫描。PyTorch 的 c10d 库提供了多种集体通信的 API,包括all_reduce和all_gather,以及端到端通信 API(在以下部分讨论)。它支持在组内的机器之间发送张量,这是许多算法所需的操作。例如,reduce是至关重要的;它将所有进程中的目标张量减少为单个张量,并将结果返回给所有进程。
图 9-7 展示了如何保存参数及其梯度(如“分布式自动求导”中讨论的),并使用全归约操作在进程之间交换信息。请注意,每个进程中的参数被分割成桶,如 DDP 部分所述。全归约操作按桶索引顺序运行,以维护执行顺序,这有助于避免跨进程和机器之间出现不一致的结果。

图 9-7. PyTorch 中的全归约操作
all_reduce函数收集所有张量并启动一个将它们减少为单个结果张量的操作。完成后,它将结果返回给各个进程/机器。在 PyTorch 中,all_reduce被实现为支持张量的操作。在幕后,它支持使用三种内置后端协议(Gloo、MPI 和 NCCL)进行分布式 CPU 和/或 GPU 训练。请注意,并非所有后端协议都支持所有硬件处理器,这意味着并非所有操作都支持 CUDA。
all_gather也是如此,尽管这是一个更昂贵的函数。其使用示例是用于评估张量的大小:PyTorch 引擎将收集所有张量的大小,并基于最大的大小定义一个默认大小。您可以在 c10d 库中找到此函数的多种用途。
集体通信如何影响我们的开发?当我们开始使用 PyTorch 编写分布式应用程序时,我们的第一个任务是定义环境、启动工作进程并开始进程。在初始化过程中,我们需要指定后端通信协议。在群集机器的设置工作中,应与系统管理员一起完成或使用协调工具,以避免错误。
集体通信适用于组中的所有进程(集群中进程的子集)。因此,我们首先需要启动一个组。这是我们如何做到的:
dist.init_process_group(*backend*, init_method='tcp://10.0.0.20:23456',
world_size=4, rank=args.rank)
init_process_group的第一个参数是后端类型:mpi、gloo或nccl之一。其他参数是可选的;它们包括init_method,它指定如何启动进程组(通常是指向共享文件系统上脚本的 URL);world_size,即组中的进程/机器数;以及rank,即当前正在运行的进程的等级(0 到world_size - 1之间的数字)。
PyTorch 的贡献者提供了一些指导,关于我们应该根据硬件使用哪种后端:
-
经验法则
-
使用 NCCL 后端进行分布式 GPU 训练。
-
使用 Gloo 后端进行分布式 CPU 训练。
-
-
使用 InfiniBand 互连的 GPU 主机。
- 使用 NCCL,因为它是当前唯一支持 InfiniBand 和 GPUDirect 的后端。
-
使用以太网互连的 GPU 主机。
- 使用 NCCL,因为它目前为分布式 GPU 训练提供了最佳性能,特别是对于多进程单节点或多节点分布式训练。如果使用 NCCL 遇到任何问题,请使用 Gloo 作为备选选项。(请注意,目前 Gloo 在 GPU 上的运行速度比 NCCL 慢。)
-
使用 InfiniBand 互连的 CPU 主机
- 如果您的 InfiniBand 启用了 IP over IB,请使用 Gloo,否则请改用 MPI。
-
使用以太网互连的 CPU 主机。
- 使用 Gloo,除非您有特定原因使用 MPI。
利用这些指导,并咨询您的系统管理员,确定最佳的后端用于工作。
一旦您初始化了进程组,就可以连接并启动集群本身。从主函数开始,我们可以启动一个迭代过程,遍历所有机器并在每台机器上初始化进程。让我们看一下以下模板代码(来自文档),以更好地理解如何开始:
"""run.py:"""
*`#!/usr/bin/env python`*
import os
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
def run(rank, size):
""" Distributed function to be implemented later. """
pass
def init_process(rank, size, fn, backend='gloo'):
""" Initialize the distributed environment. """
os.environ['MASTER_ADDR'] = '127.0.0.1'
os.environ['MASTER_PORT'] = '29500'
dist.init_process_group(backend, rank=rank, world_size=size)
fn(rank, size)
if __name__ == "__main__":
size = 2
processes = []
mp.set_start_method("spawn")
for rank in range(size):
p = mp.Process(target=init_process, args=(rank, size, run))
p.start()
processes.append(p)
for p in processes:
p.join()
如您在此处所见,有一个具有定义size的主函数,它是我们要初始化的机器/进程上的机器数。主函数中的for循环遍历指定的rank值范围,从 0 开始,以rank - 1结束。请回想一下,这里的rank指的是我们在其上启动进程的机器的编号,这也是其在系统中的 ID。在循环内部,我们调用mp.Process,它返回一个名为p的进程实例,然后是p.start和processes.append(p)。mp是 PyTorch 中可用的多进程功能。在所有机器上的迭代过程中,它在每台机器上启动进程,目标函数是init_process。这是一个非阻塞操作,仅定义进程本身。稍后,start函数启动它。脚本还将这些进程保存在一个数组中,我们可以访问该数组以进行进一步的计算和对集群的操作。
在 init_process 中,脚本定义了环境及其所属的组。该进程了解世界大小、其秩以及应用于通信的后端。它还接收了 run 函数(在函数签名中,这是 fn 参数)。此函数实现了此进程需要运行的内容。
下面是来自文档的所有减少 run 函数的示例:
""" All-Reduce example."""
def run(rank, size):
""" Simple collective communication. """
group = dist.new_group([0, 1])
tensor = torch.ones(1)
dist.all_reduce(tensor, op=dist.ReduceOp.SUM, group=group)
print('Rank ', rank, ' has data ', tensor[0])
此代码为所有减少分布操作创建一个新组,该组包含成员列表 [0,1]。这意味着进程 0 和 1 现在是将运行此操作的组的一部分。它还定义了一个大小为 1 的张量,其值为 1。这是模板目的上的模拟张量;您应该使用要在其上运行减少操作的真实张量。最后,它调用 dist.all_reduce,传递张量、要运行的减少操作(在模板的情况下为 SUM)和将参与其中的组。PyTorch 提供以下操作的开箱即用功能:⁴
dist.ReduceOp.SUM
dist.ReduceOp.PRODUCT # stored API in the configuration
dist.ReduceOp.MAX
dist.ReduceOp.MIN
dist.ReduceOp.BAND # Bitwise AND
dist.ReduceOp.BOR # Bitwise OR
dist.ReduceOp.BXOR # Bitwise XOR
PyTorch 提供以下集体通信 API:
scatter
将张量列表在根秩级别(默认情况下为秩 0)分发到组中的所有秩/进程,如图 9-8 所示。⁵ 在此示例中,在执行 scatter 之前,秩 0 具有张量列表 [t0, t1, t2, t3];执行此函数后,秩 0 具有 t0,秩 1 具有 t1,依此类推。

图 9-8. 分散功能
gather
与 scatter 相反,此功能从组中收集张量并将它们存储在秩为 0 的位置。秩 1 传递 t1,秩 2 传递 t2,依此类推,如图 9-9 所示。

图 9-9. 聚集功能
reduce
此功能类似于 gather,但会将张量附加到最终的张量中,最终得到代表已收集张量的一个张量。在本例中,秩 0 最终将得到 T=t0+t1+t2+t3,如图 9-10 所示。

图 9-10. 减少功能
all_reduce
使用 all_reduce 函数,组中的每个进程与其余组中的所有进程共享信息,并收集信息,如图 9-11 所示。这类似于 reduce,但所有机器参与发送和接收,在过程结束时,所有机器将具有相同的张量。如前所述,如果网络通信中出现任何故障,这可能导致错误。

图 9-11. 全部减少功能
all_gather
这与 all_reduce 类似,只是这里的进程都在向整个组发送和接收张量,而不对接收到的张量进行操作。相反,接收到的张量保存在一个数组中,如 图 9-12 所示。顾名思义,所有进程执行 gather 功能。

图 9-12. All-gather 功能
broadcast
broadcast 函数将一个张量复制到多台机器上,如 图 9-13 所示。当你有适合内存的信息并且所有等级都需要使用时,这非常有用。

图 9-13. 广播功能
现在你对 PyTorch 提供的集体通信 API 有了更好的理解,是时候转向另一种通信类型,即点对点通信。
PyTorch 中的点对点通信
PyTorch 提供了四种用于点对点(P2P)通信的 API,也称为点到点通信。这些包括在 “分布式自动微分” 中提到的 send 和 recv 函数,用于同步通信,以及用于异步发送和接收张量数据的类似函数:isend 和 irecv。
我们可以使用在前一节中展示的相同模板代码来分发张量并初始化集群;唯一变化的是 run 函数。以下代码片段展示了如何使用 torch.distributed 中可用的 P2P API 实现 run 的简化示例:
def run(rank, size):
tensor = torch.zeros(32)
if rank == 0:
tensor += 1
*`# Send the tensor to process 1`*
dist.send(tensor=tensor, dst=1)
else:
*`# Receive tensor from process 0`*
dist.recv(tensor=tensor, src=0)
print('Rank ', rank, ' has data ', tensor[0])
这是经典的阻塞式 P2P 通信示例。在 run 函数中,我们有一个大小为 32 的张量,值为零(充当模拟张量)。if 语句检查当前进程的等级。如果它是等级 0,我们会更新张量并将其发送到等级 1,正如 send 函数中的 dst=1 参数指定的那样。
如果等级不是 0,如 else 子句中所定义的那样,进程将使用 dist.recv 函数接收张量。此处仅使用 print 函数提供信息,用于调试和故障排除过程中的问题。对于实际应用程序,最好利用 Python 的日志记录机制;这样可以通过严重性级别(调试、信息、错误等)对日志消息进行分类,并稍后按严重性级别搜索问题。
要实现非阻塞(异步)P2P 通信,我们只需在 run 函数定义中用 dist.isend 和 dist.irecv 替换 dist.send 和 dist.recv 函数。使用异步通信时,需要调用从操作中接收到的请求对象的 wait 方法来等待执行完成,然后再进行下一个操作。如果没有依赖操作,仍需在 run 函数结束前调用 wait。
图 9-14 展示了点对点通信,其中 rank 0 向 rank 3 发送信息。

图 9-14. rank 0 和 rank 3 之间的点对点通信
此处异步和同步通信的讨论可能会让您想起 RPC 协议,原因是 PyTorch 的 RPC 框架建立在点对点通信 API 之上,充当 remote 函数的启用器。如 “Remote execution” 中所述,此函数在指定的工作进程上使用后台线程运行用户函数。它不是通过网络返回数据,而是返回一个作为指针的轻量引用。
我们能用 PyTorch 的低级 API 做什么?
研究、优化和故障排除!在处理分布式系统的低级 API 时,通常需要对系统本身有深刻理解,但在现实世界的高级 API 故障排除中可能会很有帮助。让我们看一个例子。2021 年,Chaoyang He 等人决定构建一个自动化弹性管道,作为分布式训练的一部分,他们创建了 PipeTransformer,如 图 9-15 所示。在训练阶段,管道根据参数数量进行转换。请记住,在神经网络中,每个网络层的参数数量都可能会改变。因此,在开始时可能有数十亿个参数,随着训练的进行,参数数量也会相应变化。您可以看到,管道 0 在时间步骤 0 使用的机器和核心比时间步骤 1 更多。

图 9-15. PipeTransformer 训练系统概述图
在这里,研究人员特别使用了冻结算法,该算法能够在训练过程中逐渐识别和冻结某些层,这有助于参数的收敛并提供更多训练控制。冻结算法通知 AutoPipe 模块有关参数数量、梯度等的变化。AutoPipe 再通知 AutoDP 有关管道长度的信息,通知 AutoCache 冻结层的情况,并在管道本身上调用 transform 来执行它。PyTorch 的低级 API 结合专用硬件和高级 API,使团队能够构建此系统并测试他们的研究成果。
使用 PyTorch 和 Petastorm 加载数据
在第七章中,我们讨论了从 Spark 到 PyTorch 的桥接。如你所知,对于大多数机器学习工作流所需的数据转换,软件是在 Spark 框架下开发并保存为 Parquet 文件的,但 PyTorch 并没有为 Parquet 格式提供开箱即用的数据加载器,因此如果需要使用 PyTorch API,你需要引入专门的工具来加载这些数据。本书侧重于利用 Uber 的开源项目 Petastorm 来实现这一目的,但你应该对其他工具保持开放态度,并关注市场上的新进展。
作为数据科学家,在 PyTorch 中加载数据时,通常会使用DataLoader与Dataset类。但是,使用 Petastorm,你可以完全避免它们,并改用 Petastorm 转换器库。第一步是创建转换器本身——这就是使你能够使用converter_train.make_torch_dataloader将 Spark DataFrame 转换为 PyTorch 数据加载器的功能。该函数将生成所需的DataLoader,以便你可以继续使用 PyTorch 分布式 API 进行工作。
让我们看一些代码,以更好地理解 API。与 TensorFlow 一样,首先要做的是为训练数据集和评估数据集创建转换器:
# Set a cache directory on DBFS FUSE for intermediate data
spark.conf.set(SparkDatasetConverter.PARENT_CACHE_DIR_URL_CONF,tmp_path)
# TIP: Use a low value for parquet_row_group_bytes. The default of 32 MiB
# can be too high for larger datasets. Use 1 MB instead.
# train
converter_train = make_spark_converter(df_train,
parquet_row_group_size_bytes=32000000)
# test
converter_test = make_spark_converter(df_test,
parquet_row_group_size_bytes=32000000)
make_spark_converter函数是一个通用函数,因此与我们用于 TensorFlow 的函数相同。一旦 Petastorm 转换器准备好,我们就可以利用它来转换为我们希望使用的加载机制。
正如你在下面的代码片段中所见,我们使用converter_train.make_torch_dataloader函数创建了一个 PyTorch 数据加载器。我们传递了一个transform_spec_fn,该函数详细说明了如何处理数据。这个函数提供了在训练模型本身之前预处理数据的另一个机会;例如,可以在这里调整图像的大小:
with converter_train.make_torch_dataloader(
transform_spec=transform_spec_fn) as loader:
model = train(loader)
with converter_test.make_torch_dataloader(
transform_spec=transform_spec_fn,num_epochs=1) as loader:
accuracy = test(model, loader)
return accuracy
make_torch_dataloader函数创建一个TorchDatasetContextManager,用于管理 Petastorm 阅读器的创建和终止。在内部,TorchDatasetContextManager利用make_batch_reader函数来创建阅读器。你可以向该函数提供petastorm_reader_kwargs参数,Petastorm 将其传递给make_batch_reader。
总结其行为,它做了两件事:
-
使用
make_batch_reader函数在 Parquet 文件目录 URL 上打开 Petastorm 阅读器 -
基于此阅读器创建 PyTorch
DataLoader
Petastorm 中的DataLoader是torch.utils.data.DataLoader的数据加载器适配器,使用 PyTorch 的汇集功能来合并批次中的数据。默认情况下,它使用default_collate,该函数检查Dataset返回的数据类型并尝试组合它们。
读者还可以洗牌队列。这会使每次迭代中完全随机地向队列中添加来自不同文件的实例,从而完全随机化批处理数据。如果您希望使用此功能,请在调用make_torch_dataloader时使用shuffling_queue_capacity参数,并传递您希望具有的队列大小。请注意,默认情况下此参数设置为0,此时不进行洗牌。如果您的数据已排序,洗牌队列是一个很棒的功能,因为对于排序数据集,算法可能会对遇到的第一批数据产生偏见(当然这取决于算法和数据的统计行为)。
整个操作会分批迭代并从读者返回项目。它还会将多个 Spark 类型提升和清理为 PyTorch 友好的类型⁶,如表 9-1 所详述。
表 9-1. 提升为 PyTorch 类型的 Spark 类型
| 需要提升的 Spark 类型 | Petastorm 提升为的 PyTorch 类型 |
|---|---|
int8,uint16 |
int32 |
uint32 |
int64 |
布尔值 |
uint8 |
时间戳 |
float32,通过 Petastorm 的decimal.Decimal类型^(a) |
^(a) 这里有两个数据类型转换阶段:首先将 Spark 的时间戳值转换为 Petastorm 的decimal.Decimal类型,然后将这些值转换为float32。 |
有哪些限制?好问题。NumPy 字符串、字符串数组、对象数组和对象类不受支持。None呢?PyTorch 不支持可空字段,这意味着作为过滤过程的一部分,我们必须过滤掉或为任何值为None的特征提供默认值。
在处理不支持的数据类型时,大多数冲突导致以下异常:PyTorch 不支持字符串数组或对象类。如果遇到此异常,请注意,Petastorm 无法在这里填补空白的原因是因为 PyTorch 本身也不支持此数据类型。因此,在设计流程时要慎重考虑。要解决这个问题,您需要更改数据的设计或使用解决方法。一个不错的选择是使用transform_spec=transform_spec_fn来处理数据,正如第八章末尾所讨论的那样。
当我们准备好 PyTorch 的DataLoader之后,下一步是将其用于训练、验证和测试。
提示
DataLoader 构造函数接受一个参数,用于定义要用于加载数据的工作线程数。然而,在其实现中存在一个问题。在幕后,Petastorm 的 PyTorch DataLoader 实现使用 mp.spawn,它会将模型和参数进行序列化并保存到磁盘。如果沿途有任何翻译问题,这将导致程序崩溃,因此您需要避免使用它。这也可能会显著减慢处理过程并创建瓶颈。
Petastorm 和分布式 PyTorch 的故障排除指南
在使用多个计算引擎时可能会遇到各种挑战,由于上一节提到的类型不匹配和错误等。我们将在这里看到一些例子,首先是类型不匹配的问题。
数据类型不匹配的谜题
在处理数据时,类型是一个巨大的谜团。为什么每个平台都决定引入和支持自己的类型?我想我们永远也无法解决这个问题!
除了存在的问题,如果您记得,在第二章中的表 2-1 展示了 Spark 中的数据类型如何映射到 Python 中的数据类型。自从第二章以来,我们的数据经历了多次迭代和格式化。稍作反思我们示例中的数据流动情况,您会发现这段旅程令人着迷,途中进行了许多类型更改:
- 文件 → Spark 转换 → Spark 处理 → 保存为 Parquet → Petastorm → PyTorch
对于您和您的团队来说,通过使用审核机制来跟踪数据版本和更改是一个好的实践。可以记录文件格式和编码(如 UTF-8、UTF-18 等)的详细信息,以及生成和保存它的库。跟踪所有这些信息可以为您提供一切您需要的全面和系统化的故障排除过程。
例如,在使用普通的 Python 列表(或任何 Spark 数组格式,在 PySpark 中转换为普通的 Python 列表)和 Petastorm 进行工作时存在已知的挑战。虽然写入可以使用数组,但在 PyTorch 中加载数据时通常会因为模式不匹配而失败。一种解决方法是采用 RDD 方法,摆脱 Spark DataFrame 的抽象。这样做可以确保您使用符合 Petastorm 和后续 PyTorch 的数据类型。
下面的代码示例演示了如何强制 Petastorm 将数据转换为特定的模式。如果你还记得表 9-1 后面的讨论,PyTorch 不支持字符串或对象类的数组。然而,你经常需要处理数组或列表。为了解决这个问题,你可以通过提供一个Unischema定义来将列表/数组转换为支持的类型,例如将数组转换为np.array,然后再将其转换为 PyTorch 张量:
Schema = Unischema('Schema', [UnischemaField('id', np.string_, (1, None),
NdarrayCodec(), False),])
def row_to_dict(schema, row):
def type_check(k,v):
if isinstance(v, list):
return np.array([v], dtype=schema._fields[k].numpy_dtype)
else:
return v
return {k:type_check(k,v) for (k,v) in row.asDict().items()}
def generate_dataset(output_url='*{path_to_petastorm_data}*/petastorm/'):
with materialize_dataset(spark, output_url, Schema):
rows_rdd = df.rdd.map(lambda x: row_to_dict(Schema, x))
.map(lambda x: dict_to_spark_row(Schema, x))
spark.createDataFrame(rows_rdd, Schema.as_spark_schema())
.write
.mode('overwrite')
.parquet(output_url)
generate_dataset()
在这段代码中,我们首先定义了所需的模式:一个名为id的字段,类型为NdarrayCodec。接下来,我们创建了一个row_to_dict函数,它对定义的模式进行类型检查。如果是列表/数组的实例,它将强制执行类型和 NumPy 值。它遍历 Spark DataFrame 中的每一行,通过调用_fields[k].numpy_dtype操作来确保其符合模式。
接下来我们定义一个generate_dataset函数,通过使用df.rdd来去掉 Spark DataFrame。这调用了 DataFrame 的内部 RDD,现在我们可以在其上轻松执行 RDD 功能,例如映射。请注意,这里采用了两个映射函数的方法:一个映射用于必要时强制类型,另一个(dict_to_spark_row)是 Petastorm API 所需的 Petastorm 函数。
最后,我们将数据保存到专用的 Petastorm 位置。
在将数据转换为所需类型和状态后,我们可以使用之前定义的专用 Petastorm 转换器从output_url加载数据,在“使用 PyTorch 和 Petastorm 加载数据”中我们已经定义了。现在让我们来看看在使用 Petastorm 和分布式 PyTorch 时可能需要解决的另一个问题。
迟到的工作人员
迟到的工作人员是指比集群其他部分跑得慢的工作人员。他们可能已经失败并重新启动,或者可能出现了网络问题导致他们接收数据延迟,或者其他可能在分布式系统中出现的问题导致他们速度变慢。在同步训练中,迟到的工作人员问题尤为突出,因为他们产生的数据变得过时和无关紧要。
这些过程在添加更多用于分布式处理的机器时也可能会造成瓶颈。如果系统中有 25%的机器出现了迟钝现象,随着机器数量的增加,受影响的机器会越来越多。如果你只有 4 台机器,这可能看起来并不是什么大问题,但是如果你扩展到 32 或 128 台,这将成为一个不能忽视的问题,并且可能需要重新考虑操作和通信结构。当然,这个问题与模型相关,并且很难提供处理它的最佳实践。最好的方法是保持警惕,注意水平扩展时训练完成时间的变化。常常需要在模型的准确性上做出权衡——使用异步通信来获得高水平的准确性没有最佳解决方案。因此,最好记住并理解你想要优先考虑的结果:在模型构建过程中更好的准确性,还是更快的收敛/训练模型?
PyTorch 与 TensorFlow 有何不同?
现在你对 PyTorch 及其分布式训练机制有了更好的理解,可能会问自己一个问题:它与 TensorFlow 有何不同?我们已经讨论了功能和术语,但从运行和操作的角度来看,你还应该了解更多内容。
表 9-2 详细解析了 TensorFlow 和 PyTorch 在几个关键领域的差异。
表 9-2. PyTorch 与 TensorFlow
| PyTorch | TensorFlow | |
|---|---|---|
| 可视化和调试 | 工业界的新秀,因此可视化和调试工具较少。 | 工业界成熟工具,提供更好的可视化和调试工具。 |
| 计算图 | 构建是动态的,在运行时更新。图包含张量、操作和反向传播所需的信息,并由自动求导引擎执行。支持使用继承等命令式编程。 | 构建是静态的。tf.Graph数据结构包含一组张量和tf.Operation对象,代表计算单元。支持符号操作;适合代数表达式。同时支持使用继承等命令式编程。 |
| 编程限制 | 被认为不够通用。 | 有大量样板代码。 |
| 模型加载的语言支持 | 除了 Python 以外的语言中加载模型被认为更为复杂。 | 模型可以在其他支持的语言中加载,如 Java 和 C++。 |
| 支持的部署选项 | TorchScript —— 使用专用脚本并根据所需模式封装机器学习模型(部署模式在 第十章 进一步讨论)。 | TensorFlow Serving(通常用于小规模应用)、Flask Web 服务器、移动端(Android/iOS;模型本身可以优化以适应移动或物联网设备的内存)。 |
TF 和 PyTorch 的分布式机器学习能力的一般区别在于,PyTorch 的方法更具体,旨在为实际的机器学习实验提供细粒度的控制。您的工具选择最终取决于您的组织实际需要和可以负担的内容。您需要考虑哪些工具已得到支持,机器学习生命周期如何与这些工具配合工作,它们是否可以轻松替换等。
要确定使用哪个框架来丰富您的分布式机器学习能力,审视和评估其生态系统提供是一个好主意。虽然相对较新,PyTorch 的生态系统正在增长,拥有超过 50 个库和项目。一些专注于特定领域,如自然语言处理或计算机视觉解决方案,而其他支持机器学习过程本身(如加速器)或提供增强的安全性和隐私保护(如 PySyft)。例如,假设您需要在训练机器学习模型时与私人用户数据分离。了解每个工具生态系统中存在的内容是一个很好的起点,以实现这一目标。这种理解将极大地促进决策过程。您可以选择采用现有框架或开发自己的框架。
概要
在本章中,您了解了分布式 PyTorch 的所有主要组件,并深入探讨了其 RPC 框架。您还更好地理解了 PyTorch 在分布式系统和机器学习流程方面与 TensorFlow 的不同之处。往往情况下,您需要考虑等待时间方面的因素。使用单个 GPU 训练模型可能需要四天时间。如果您想缩短到一个小时,可以利用具有 64 个 GPU 的机器 —— 但如果您无法访问这样的机器,而又想加速训练过程怎么办?多节点训练可能是一个选择:每个具有 8 个 GPU 的 8 个节点可以满足 64 个 GPU 的需求。这就是 PyTorch 的分布式优化发挥作用的地方,因为它在如何分配事物方面提供了比 Spark 或 TensorFlow 更大的灵活性。
到目前为止,我们已经涵盖了在规模上训练机器学习模型以及如何进行优化和从一个框架过渡到另一个框架的内容。在下一章,也是最后一章中,您将学习有关部署您的机器学习模型及在生产环境中进行监控的各种主题,包括何时归档模型。
¹ 在 PyTorch 中,rendezvous 指的是对等机器发现和分布式原语同步的过程。
² 在计算机科学中,这是一个函数,它阻止程序处理下一个操作,直到接收到响应。通常涉及输入/输出、与远程机器的通信或其他进程。
³ 在内存管理中,垃圾回收尝试回收由程序分配但不再引用的内存。
⁴ BAND、BOR 和 BXOR 在 NCCL 后端不受支持。
⁵ 图 9-8 到 9-13 是由 PyTorch 文档的贡献者提供,并可在 https://pytorch.org/tutorials/intermediate/dist_tuto.xhtml 上找到。
⁶ PyTorch 支持的数据类型有 double、float、float16、float32、int64、int32 和 uint8。
第十章:机器学习模型的部署模式
在整本书中,我们讨论了机器学习生命周期。作为一个快速提醒,在高层次上,机器学习系统的生命周期类似于软件开发生命周期。这意味着它包括多个阶段,我们可以总结如下:
开发
训练模型
验证
验证模型
分阶段
在类似生产环境的环境中测试模型
部署
将机器学习系统投入生产
存档
淘汰模型,必要时用新版本替换
在前面的章节中,我们深入探讨了生命周期的前几个阶段,包括分布式训练的各种工具和方法。在本章的最后,我将提供关于如何思考部署过程以及需要注意的考虑事项的指导。部署发生在你拥有一个产生准确结果并且你对其满意的模型后,你准备好将其提供并投入到生产中。如果情况不是这样的话,最好继续探索使用额外的算法和参数,也许还需要新的数据。
在考虑部署模型时,我们需要定义在整个生产系统工作流程中模型将在何时何地使用。它可能是更大数据流的一部分,也可能是一个独立的应用程序,为用户提供交互的 API。该模型还可以作为 Spark 流的一部分封装并作为 UDF 提供服务(稍后详述)。
本章涵盖以下内容:
-
部署模式
-
监控策略
-
生产反馈循环
-
使用 MLlib 进行部署
-
使用 MLflow 进行部署
-
迭代开发
部署模式
你有多种选项可以部署你的机器学习模型。我们将在本章中详细探讨其中一些,并且我也会提供实际的例子。每种模式都需要不同的代码和组件,以保证模型在生产环境中的良好表现。那么,你如何知道哪一种模式最适合你的用例呢?
在考虑部署模型的最佳模式时,重点应放在业务需求上。模型将用于批处理还是流处理?它将在哪里使用?例如,对于运行在用户本地机器上的高级客户端应用程序,通常会将模型加载到内存中,以便用户可以直接与之交互。这种情况在像智能汽车这样的物联网设备中很常见,例如,模型可能被部署到汽车上以减少通信和网络开销。
另一个需要考虑的事项是生产环境是否与开发和测试环境不同。通常情况下是这样的。
让我们看看一些部署模式,这些模式可能根据你特定的业务需求而使用,帮助你了解在评估选项时需要考虑的事项。
模式 1: 批量预测
在使用批量预测时,您会在新数据上运行模型,并将结果缓存到数据库或其他持久存储中。当您希望一次为一组观测值生成预测,并且结果对延迟不敏感时,这种模式非常有用。如果您希望每天为每个用户生成一个预测,可以离线运行预测并缓存结果,效果很好。
这种方法的优点很明显。首先,使用 Spark 很容易实现:只需使用 Spark API 加载模型。其次,它很容易扩展到大量数据,并且是一种经过验证的方法。它还能够快速为用户提供结果,因为预测已经完成。
这种方法的缺点是什么?首先,对于复杂输入,它可能更难扩展,这会为批量预测过程带来大量开销,因为需要覆盖所有特征的所有排列。因此,在给定时间内可能无法计算所有可能的输出。
第二个问题是用户可能会得到过时或“陈旧”的预测—尽管它们仍然准确,但不会是最新的。例如,如果新电影每天发布,但批量预测每 48 小时运行一次,那么电影推荐系统可能无法向客户推荐最新的电影。模型不知道新电影的存在,因此会推荐较旧的电影。
因为处理和缓存模型的新输出需要时间,也很难检测处理管道中的问题,比如批处理作业的失败。这可能导致模型本身变得陈旧和无关紧要。我们将在监控部分更详细地讨论这个问题。
模式 2:模型服务化
在这种模式下,模型与面向客户端的应用程序捆绑在一起,并部署到 Web 服务器上。如图 10-1 所示,服务器加载模型并调用它以实时进行预测,响应来自客户端的 API 调用。根据需要,模型可以访问数据库。

图 10-1. 一个生产系统,模型部署在服务器上,客户端与之交互
尽管这种方法非常适合重用现有的生产基础设施,但您必须考虑许多潜在的问题和妥协。例如:
-
Web 服务器的后端本身可能是用不同的语言编写的,比如 Java,而您的模型和库则是用 Python 编写的。
-
应用程序与模型部署耦合意味着它们需要共享相同的发布时间表。由于模型可能需要更频繁地更新,管理这个过程可能会对工程团队的其他部分构成挑战和负担。
-
如果你提供一个大型模型,它可能与 Web 服务器的其他功能竞争资源。这可能会降低服务器的响应速度和总吞吐量。
-
Web 服务器的硬件通常未经过优化以适应机器学习模型。例如,CPU 对于 Web 服务器来说效果很好,而模型可能需要 GPU 来快速处理并返回预测结果。
-
最后,还有规模的问题。你的 Web 服务器如何扩展以应对更多的 API 请求?这与模型的扩展策略是否匹配?这里的冲突可能会限制整个系统的性能。
模式 3:模型即服务
这是另一种实时模式,其中你将模型本身部署为一个服务(也称为机器学习微服务),以避免与后端服务器硬件的耦合和可能冲突的扩展要求。这意味着上一节中的缺点列表几乎会消失。通过这种方法,模型组件有自己的部署周期、代码、版本、扩展策略等,并且模型托管在自己独立的服务器上。后端服务器通过管理预测请求与模型交互,如图 10-2 所示。

图 10-2. 生产系统,机器学习应用和模型分开部署
将系统解耦有很多好处,比如:
可靠性
模型中的错误不太可能使整个应用程序崩溃。
可扩展性
你可以为你的机器学习应用选择最优的硬件,而不必与模型本身的扩展策略耦合。
灵活性
模型可以轻松地被多个应用程序重复使用;它是一个独立的服务,也可以部署在其他系统中。
不过,这种方法也有其缺点。特别是,作为服务的模型模式会在每次调用时增加延迟,因为信息必须传输到模型服务本身,然后需要发送回响应。此外,从生产基础设施的角度来看,这种方法增加了部署新服务和管理与之交互的复杂性。最后,从数据科学的角度来看,这意味着你需要监控和管理你的机器学习模型服务,并在出现问题时负责这个组件。
确定使用哪种模式
我们刚讨论的这三种模式各自设计用于不同的业务需求,这转化为不同的计算需求,比如延迟、吞吐量、请求大小等。所有这些考虑因素都影响了选择哪种部署模式最适合手头的任务。
从高层次来看,我们可以区分两种方法:实时和批处理,其中批处理方法具有最高的吞吐量¹和最高的延迟(取决于处理计划,从几分钟到几周),而实时方法具有最低的吞吐量和最低的延迟(毫秒级)。并发请求的数量、请求的大小以及所需的解决方案都会影响选择哪种模式。
在实时方法中,系统的具体需求可能会有所不同,对于延迟的容忍度可能更多或更少。在交换信息时,无论是在团队内部还是跨团队之间,最好总是用延迟和吞吐量来交流,因为这些术语并不严格相关,机器学习团队可能会使用不同的术语来表达相同的需求。
硬
错过截止日期是绝对的系统失败。
确定
偶尔错过截止日期是可以容忍的,但可能会降低系统提供的服务质量。一旦错过截止日期,结果就不再有用。
软
超过截止日期后,结果的价值降低,影响系统质量。
如果你与数据工程师合作或具有数据工程背景,可能会看到另一种分类,即实时与准实时:
-
实时解决方案优先考虑延迟而不是吞吐量,并在几毫秒内生成结果。
-
准实时解决方案需要快速的推断/预测,但结果可能会在百毫秒到一分钟的延迟内交付。例如,Spark 的结构化流处理引擎在微批处理上运行,几乎实时地处理数据;我们稍后在本章讨论使用 MLlib 部署时会看到这一点。
遗憾的是,这些术语并不严格对应,机器学习团队可能会使用不同的术语来表达相同的需求。因此,在交换信息时,最好总是用延迟和吞吐量来交流。

图 10-3. 不同类型的机器学习应用程序的延迟需求的巨大范围
生产软件要求
部署模型的主要目标是使其可供用户、程序、应用程序或其他模型进行交互。但是,要将模型推向世界,除了简单地将模型发布外,还有许多要考虑的事情。无论您选择哪种部署模式,都需要建立一个过程框架。为了帮助您做到这一点,在准备部署完整模型时,以下是一些应尽力回答的问题,按主题组织:
模型应用部署
您将如何推出您的机器学习应用程序?一旦投入生产,您将如何管理和更新它?在发生故障时,您将怎么做?与软件一样,您希望能够将模型转变为响应请求的东西。通常在部署过程(例如逐步推出、立即回滚)和监控过程中会设置硬性要求。
模型包部署
如何将模型与其运行时环境、预处理和后处理打包在一起?部署周期是什么,如何处理版本控制、存档等?今天,您可以选择多种部署框架和解决方案——TensorFlow 和 PyTorch 都提供了它们自己的部署选项,还有许多其他可能性,如 KFServing。本书不会进一步讨论它们,但知道它们的存在非常重要。
依赖管理
模型本身的即时依赖是什么?它需要什么来运行?您的服务或软件还需要什么?代码、模型权重和依赖项都需要成为打包部署的一部分,以便模型进行预测。但是依赖项会带来麻烦。软件中的依赖项很难保持一致;新版本通常会引入可能破坏用于打包和构建模型的 API 或逻辑的更改。为了克服这个问题,软件工程师采用了两种主要策略:
-
限制服务模型的依赖项
-
使用容器来完全控制版本和运行时环境(下面讨论)
两种知名的容器技术是 Docker 和 Linux。容器包含可执行代码及其运行所需的一切,包括运行时、工具、库和设置(统称为镜像)。一个机器学习系统可能需要各种容器来满足所有的需求:例如,您可能需要用于 Web 服务器、数据库和作业队列的容器,以及用于工作节点本身的容器。
模型运行时环境
模型在哪里和如何运行?例如,环境可能需要 Python 安装;在这种情况下,模型操作还需要一个特定于 Python 的运行时。这就是为什么使用容器管理依赖项和运行时环境是最佳实践的原因。
REST API
REST API 通常用于根据规范格式的 HTTP 请求提供预测响应——它们的行为如何,您如何设计它们?您将如何将这些 API 与您的模型打包?今天的最佳选择仍然是使用容器。您还需要考虑这些 API 的版本控制。
注意
还有一些替代 REST API 的选项,比如 gRPC,它们提供了类似的接收和响应请求的体验。没有一致的标准用于服务机器学习模型,因此每个实现可能略有不同。
这个清单为您在思考如何打包和部署模型时提供了一个起点。一旦您建立了这个框架,您需要考虑生产环境以及如何优化和扩展部署。以下是一些提示,帮助您更好地理解这些主题之间的关系,以及在达到优化和扩展阶段时需要注意的内容:
性能优化
性能是每个软件解决方案的关键部分。机器学习特别需要额外的要求和考虑因素。例如,使用 GPU 还是不使用 GPU?在生产系统中为模型服务有使用 GPU 的利与弊。积极的一面是它可以提高吞吐量,而且很可能是您用来训练模型的相同硬件。缺点是 GPU 的设置更加复杂,通常比 CPU 更昂贵。让 GPU 多次运行以构建模型的成本远远低于在生产中持续运行。
优化的另一个方面是并发性。这意味着我们在系统的不同核心上运行多个模型副本,无论是 GPU 还是 CPU。这种方法支持大量的预测请求,但增加了使用软件线程的复杂性。使用线程池需要在调整时特别小心,以便进行预测。如果每天需要服务数十亿的请求,正确调整非常关键。如果您对线程和并发性没有经验,最好咨询专家。
模型压缩与精简
这与您的模型打包和运行环境相关。当您需要模型具有更小的占用空间时,这是必需的。在训练完模型后,您会将其保存为某种文件格式,特定大小。此文件需要加载到机器的 RAM 并执行。如果信息量过大而无法适应机器内存,您将需要找到一些创造性的方法来进行压缩,或者训练一个模仿较大模型的较小模型。这个研究领域受深度学习驱动,往往导致非常大的模型。您可以尝试的一种技术是使用较小的数值表示再次训练模型,例如,您可以使用int8而不是float,在准确性上进行权衡。这被称为量化²。PyTorch 和 TensorFlow 都内置了量化:训练过程中已经考虑到它,通常会导致更高的准确性。
缓存层
根据模型的不同,某些输入可能比其他输入更常见。对于这些情况,您可以建立一个专门存储结果的缓存层,而不是反复调用模型进行推断。当收到请求时,您首先在缓存中搜索查询,如果之前未保存过答案,则将其传递给模型进行处理。在处理数据时,缓存方法非常常见,对于机器学习系统也是一种有用的方法。
横向扩展
在某些时候,您实施的所有优化技术可能会证明不够用——您可能需要处理更频繁的 API 调用并实现更高的吞吐量。您要怎么做?当单个机器无法处理太多流量时,您可能需要将流量分配到多台机器上。为此,您需要启动多个模型服务的副本,并使用负载均衡器分流流量。在这种情况下,您通常会利用容器编排工具,比如带有 Docker 的 Kubernetes。
管理选项
您可能还希望考虑采用托管解决方案来部署模型作为服务,而无需使团队负担管理责任。您可以在云平台上将其作为无服务器函数运行,其中应用代码及其依赖项部署到具有明确定义入口点函数的容器中。大多数云解决方案的好处在于您只需支付计算时间。挑战在于限制的部署包大小、无法访问 GPU、缓存的状态管理不足以及有限的部署工具。
重要的是要牢记所有这些话题,因为它们将指导您决定模型的最佳部署选项。
在生产中监控机器学习模型
当然,部署模型后您的工作并没有结束。在生产环境中可能会出现很多问题!为了更好地理解这些问题,考虑机器学习生命周期中的两个测试阶段:在开发过程中测试模型(验证),以及在类似于生产环境的阶段测试模型。
当我们的模型表现不如预期时,应首先查看哪些问题?以下是在将训练问题排除之前到生产环境部署之前可能遇到的一些问题示例:
-
验证损失低于目标性能,这意味着您训练的机器学习模型在先前未见数据上表现不如预期。
-
测试损失与验证损失过于相似(即结果“太好以至于难以置信”)。
在将模型从分阶段移至生产之前,请务必执行以下操作:
-
确保您的模型在验证集和测试集上的关键指标表现良好。
-
定性验证预测结果确保其合理。
-
验证生产模型具有与开发模型相同的性能特征。
-
如果您正在更新或替换现有模型,请验证新模型的表现是否优于之前的模型。您可能需要运行几个比较测试来确保对新模型改进的信心。
遵循这些建议应该有助于确保您有一个良好的开端,当然,在测试和部署后还可能出现许多其他问题。
众所周知,机器学习模型在部署后往往会出现退化,原因有多种。例如,数据或业务问题可能发生变化;当数据中存在许多异常值时可能会遇到“长尾”问题;或者我们可能经历完全的领域转变。让我们更详细地看看在部署模型后可能遇到的一些问题以及如何处理它们。
数据漂移
模型退化主要是由数据漂移引起的。这意味着您向在线算法提供的数据相对于训练数据发生了某种方式的变化。通常通过比较生产数据和训练数据的分布来进行统计测试。
现代数据架构允许数据结构和架构动态变化。数据漂移可能会在数据结构、语义或基础设施意外改变时发生。这种行为可能会破坏流程并损坏数据。因为数据漂移涉及应用程序消耗的数据的变化,它也可能是由于在培训过程中未知或未捕获真实世界数据的全面变化引起的。它还可能是由上游数据管道中的错误引起的。为了避免这种情况,我们需要在将数据投入到生产模型之前检查数据的变化,例如,当我们知道整数值应该是正数时,我们可能会突然看到负整数值,如–1 或–5。
或者,也可能是系统用户之一出于恶意目的而改变数据,决定用人工值轰炸系统以影响数据的平衡。监控数据分布及其准确性非常重要,以防范此类攻击。在大多数情况下,您需要引入领域专家来处理这个问题。
数据分布的变化也可能是自然发生的。假设我们增加了具有不同人口统计特征的新用户。我们的机器学习模型不了解这些用户的具体特征,因此为了对他们进行准确预测,我们需要使用反映这些人口统计特征的训练数据重新训练模型。数据也可能受到大规模事件的影响,例如全球大流行和金融变化。根据手头的业务问题,您可能需要考虑如何更好地组织模型的特征以应对这些不可预见的事件。同样,这通常需要领域专家的参与。
模型输入数据的变化可能会快速或逐渐发生,可以是永久的或临时的。从时间的角度来看,我们可以区分以下数据漂移类别:
即时漂移
这种类型的漂移会导致数据分布立即发生可检测的变化,如图 10-4 所示。例如,当在新领域部署模型时,比如在新城市部署自动驾驶汽车时,可能会发生这种情况。这也可能是由于预处理流水线中的错误或大事件(如大流行)引起的。

图 10-4. 即时数据漂移
渐变漂移
特征值随时间可能会缓慢变化,如图 10-5 所示。在渐变漂移中,没有可以检测到的数据分布的立即变化。例如,用户偏好可能因用户群体年龄增长或周围文化变化而改变。

图 10-5. 渐变数据漂移
周期性漂移
这种类型的漂移不太直观。如图 10-6 所示,数据随时间定期发生变化,可能在一天甚至一年的过程中发生。这种变化可能看起来类似于方向性漂移,但一段时间后会进行修正,值会回到漂移前的状态。这些变化往往是循环的,基于季节变化或节假日期间的变化,或者白天与夜晚的使用情况,或者不同时区的用户。

图 10-6. 周期性数据漂移
临时漂移
临时漂移通常是最难检测的一种。各种事件可能导致数据分布显著变化,例如恶意用户攻击模型,或者黑色星期五促销,或者新用户以模型未曾训练处理的方式使用系统。我们数据中的这种波动表现为临时漂移,如图 10-7 所示。由于数据分布在一段(通常很短的)时间后恢复正常,因此很容易忽略这种问题。

图 10-7. 临时数据漂移
检测数据漂移的各种表现方式,围绕着识别特征值随时间变化的变化。这是一个在实践中已知有巨大影响的真实世界机器学习问题。新用户收到过时推荐,可能是由于重新训练流水线中的错误导致的,这可能导致大量用户流失,并且可能对组织造成收入损失。
模型漂移,概念漂移
有些情况下,整个模型都必须更改。现实世界环境的变化经常导致模型漂移,从而降低了模型的预测能力。这可能由许多因素引起,从数字环境的变化(导致模型变量之间的关系变化)到用户人口统计或行为的变化。
假设我们正在追踪在线打车服务的用户行为。我们根据应用在不同时间使用情况构建了一个模型,考虑因素包括公共交通的可用性和道路上的车辆数量。然而,由于大流行,公共交通线路已关闭,人们不再通勤上班。由于现实世界情况的改变,用户行为发生了变化,因此应用概念和模型已不再适用。我们需要重新训练和调整模型以适应新的现实情况,并随着用户偏好继续演变而不断重新校准。这些条件可能也会影响整体业务模型,因此还需要更新系统和模型。
另一个例子是电影推荐系统。假设我们为用户构建了一个系统,并且用户在我们创建系统时根本不看的一个新流派(比如无声电影)突然变得流行起来。系统不涵盖这种流派,但由于用户偏好已经改变,我们需要重新训练模型,包括这一类别的数据。
模型漂移的检测被认为是一个难题,通常需要人工干预。重要的是与用户、客户、产品经理和数据分析师合作,更好地理解可能影响模型有效性的变化。
分布领域偏移(长尾)
毫无疑问,这种类型的转变通常是最难检测到的。它也是许多组织意识不到的重要问题。
领域偏移是指训练数据集分布与模型在部署时遇到的数据之间的差异——即生产数据的分布。这可能是因为机器学习算法使用的近似函数,结合了从基础分布中抽样的数据训练模型。由于抽样过程引入了可能存在的人为影响,以及由于数据分布存在长尾而完全忽略的影响。当异常值对模型和业务目标有显著影响时,这一点就显得至关重要。另一种看待这个问题的方式是承认抽样数据可能无法代表我们关心的分布的所有部分。
在实际应用人工智能中,域漂移经常发生,而机器学习算法往往难以适应这些变化。域漂移可能是由于训练数据管道中的错误或抽样过程中的偏差引起的。这种情况可能发生在训练数据中某些群体被低估或训练数据的分布不再准确地代表现实世界数据时。
举例来说,假设我们正在建立一个银行系统,用于预测贷款违约,其中一个特征是性别。如果我们使用过时或不足的数据训练模型,那些数据可能会对特定性别产生偏见。在现实生活中,由于社会变化,我们的模型需要处理更多种类的数据,而训练数据与生产数据之间的不匹配可能导致错误的结果。
为了避免这些问题,我们需要关注训练分布与生产数据分布之间的差异。
我在生产环境中应该监控哪些指标?
现在你已经了解了生产中可能发生的各种潜在数据、模型和分布变化,我们可以讨论监控哪些指标来检测这些变化。
总体上说,我们构建的任何机器学习系统都具有四个特征,这些特征将决定我们监控和测量哪些内容:
模型指标
这些包括准确性、鲁棒性和性能。在生产系统中测量这些指标通常比训练期间更加困难,因为我们通常无法访问所有必需的数据。
业务指标
这些指标展示了机器学习系统对业务的影响。例如,对于推荐系统,我们将监控与用户流失和用户参与相关的各种指标:有多少人在使用系统,使用频率如何,每次互动多长时间。我们甚至可以将用户群体分成多个用户组,并在生产中运行不同模型的 A/B 测试。监控业务指标通常相对直接,因为许多组织已经有业务智能(BI)或分析团队来测量它们。但是,可能存在影响这些指标的冲突或隐藏因素,因此最好将它们与其他措施结合起来。
模型预测与实际行为
这些指标展示了模型的预测与实际用户或系统行为的相关性。通常,测量它们需要一些创造力和量身定制的解决方案,因为它涉及捕捉实际行为而不是预测行为。通常,我们会希望创建一个单独的数据管道来捕捉和保存实际行为到数据集中。这将告诉我们模型在模型指标之外的表现如何。
硬件/网络指标
这些显示系统在硬件/网络层面的表现如何。例如包括 CPU/GPU 利用率,请求的平均延迟,服务器响应时间,服务器停机时间等。跟踪这些指标非常关键,因为它们为我们提供了关于底层硬件对系统影响的详细图景。它们相对容易测量,因为大多数生产系统已经具备必要的工具,并且有各种商业解决方案可供选择。
如何使用我的监控系统测量变化?
有多种策略可用于测量机器学习系统的变化。然而,主要方法类似。我们希望能够随时间检测变化,因此首先需要一个用作比较点的参考。
定义一个参考点
为了建立一个参考基准,我们查看认为良好的不同时间窗口的数据。这将为我们提供要进行比较的数据点。在这些时间窗口内,我们寻找数据及其分布的变化。正如在第 4、5 和 6 章讨论的那样,Spark 为我们提供了收集关于我们数据统计信息的工具。这是一个很好的利用机会,以建立一个基线,这样您就可以监控可能表明漂移的变化。
此时您可能在想,“这听起来都很好,但我该如何选择一个参考窗口?”一种选择是从您认为健康的生产数据开始使用固定窗口——一个小时、一天或适合您业务问题的任何时间。收集您的指标,并开始迭代。一些系统将利用滑动窗口方法,其中时间窗口线性前进,每个(可能重叠的)段与前一个段进行比较。例如,如果我有一个数组[1,2,3],大小为两个的滑动窗口将生成以下数组列表:[[1,2],[2,3]]。虽然这是一个搜索的好技术,但可能会产生高计算成本,并且对于我们当前的目的效率不高。考虑一个 5 小时的时间轴,滑动窗口为 1 小时,大小为 2。您将在四个时间窗口内计算指标:[[1,2],[2,3],[3,4],[4,5]]。
一个更好的解决方案——也是行业最佳实践——是使用训练或验证数据和指标作为参考。这是一种更具成本效益和简单直接的做法,易于实施。
将参考点与新鲜指标值进行比较
在定义参考点并计算指标之后,下一步是选择一个窗口来对比参考点。这在很大程度上取决于具体问题;它直接与业务目标相关,并且由您希望监视机器学习系统的频率以及可能根据需要替换或重新训练它的方式决定。您可能希望在一小时、一天或一个月的时间段内监视您的数据。
为了更加实用,选择几个合理大小的窗口,并比较它们。例如,有 1 小时、12 小时和 1 天的窗口大小,并将它们滑动到最近的数据上以监控系统的行为。请注意,根据窗口大小的不同,您可能会错过检测到一些异常值的机会。您可能希望监视不同的窗口大小以衡量不同的方面。
用于衡量的算法
在统计学中,您会发现多种算法用于衡量两个数据集之间的差异。经典且最为知名的算法是距离度量。作为一名数据科学家,您可能对以下算法较为熟悉:
基于规则的距离度量
这些算法根据一组规则测量数据与参考数据的距离。它们非常适合确定数据的质量。我们可以比较最小值、最大值和均值,并确保它们在可接受的范围内。我们还可以检查数据点的数量,以确认数据没有丢失或被丢弃(例如由于错误的预处理),检查空值的存在,并监测数据漂移。
D1 距离
这是一个经典的距离度量,计算了固定数据值之间的距离之和。易于解释,并简化了一般的监控工作。
科尔莫哥洛夫–斯米尔诺夫统计量
这找到了经验和累积分布函数之间的距离。这是一个常用的指标,相对容易理解并绘制在图表上。
库尔巴赫–莱布勒散度
这种方法衡量了两个相同变量x上的两个概率分布之间的差异。它是一种统计学的基于对数的方程,对分布的尾部敏感。它可以检测异常值,但有点难以理解和解释。当您完全了解如何使用时,这种指标可能会很有用,但在大多数情况下并不会提供太多见解。
还有许多其他的指标,但这个列表足以让您开始了解。
生产环境下的实际表现
要监测数据漂移,应该使用已存在于生产系统中的简单工具。通常情况下,您需要设计一个数据管道,并为您想要测量的每种漂移类型编写专用逻辑。例如,如图 10-8 所示,您可以开发两个数据管道,每个管道返回一个信号指示漂移的存在或不存在。顶部的数据管道将最近的数据与参考数据进行比较,如前所述,可以用来检测数据漂移。底部的管道则通过比较模型的预测与系统实际结果来检测模型本身的漂移。这将告诉我们是否需要重新训练机器学习模型。这可以是一个自动化的过程,也可以是团队根据系统警报手动执行的过程。这种方法也称为生产反馈循环,我们将在下一节更详细地讨论这个问题。

图 10-8。监控漂移
当然,重要的是要记住,您开发的模型将有其自身的敏感性,没有一种适合所有情况的解决方案。
生产反馈循环
在生产中,反馈循环是指系统将模型的输出和相应的最终用户操作保存为观察数据,并使用这些数据随时间重新训练和改进模型。预测/推荐本身将与用户或系统的行为进行比较,并提供有关模型性能的反馈。一个知名的案例研究是付宇鹏和 Chinmay Soman 的《Uber 的实时数据基础设施》,该案例展示了 Uber 如何利用实时生产数据通过实施两条流水线来改进其机器学习系统:
-
嵌入机器学习模型并用于预测乘车成本的流水线。
-
捕获真实结果的流水线。
Uber 使用这种方法来随时间监控性能。根据文章描述的实时数据基础设施的消费者包括负责自动动态定价乘车、仪表板、警报、分析应用等系统。正如你所见,机器学习系统只是更大故事的一部分。然而,系统确实考虑了这些信息并实时更新下一轮预测的特征。数据反馈循环是关键的系统组成部分,因为它将信息反馈到生产系统并触发警报。
请记住,尽管这种方法对 Uber 的用例非常有效,但可能不适用于其他组织。设计基于真实结果的反馈循环将需要您跳出思维定势,理解如何将其与更大的系统集成以及何种度量是有意义的。
现在您对理论有了更好的理解——部署模式、监控、反馈循环等——是时候通过一些实际的示例更加实践了。
使用 MLlib 进行部署。
第六章介绍了如何利用 Spark 的机器学习库 MLlib。您了解了其各种能力,包括训练、评估和调整模型;构建流水线;并将模型保存到磁盘,以便部署使用。
MLlib 的模型格式是一个专用格式,包含模型的元数据以及实际数据。元数据可以包括不同的元素,具体取决于机器学习算法。
例如,当使用 MLlib 的RandomForestClassifier时,数据将包括关于每棵树(由treeID参数标识)中权重、分区数等的信息。另一方面,元数据将包含有关模型创建的信息,例如:
("timestamp" -> System.currentTimeMillis())
("sparkVersion" -> sc.version)
("uid" -> uid)
("paramMap" -> jsonParams)
("defaultParamMap" -> jsonDefaultParams)
以及模型本身:
"numFeatures"
"numClasses"
"numTrees"
此信息帮助您在不同环境中加载模型。这对于部署非常有帮助,因为我们可以将 MLlib 的load函数与 Spark 工作流的其余应用程序逻辑一起包装。要加载和处理的数据可以是批处理或流处理两种形式。在下一节中,我们将通过一个示例来构建一个部署具有流数据的模型的流水线。
提示
不要忘记使用与保存(通常也是训练)模型时使用的确切类来加载模型。否则,对于高度耦合的 MLlib 格式,它根本不会工作。
使用结构化流的生产机器学习管道
在我们深入讨论之前,快速提醒一下:在结构化流处理中,数据的模式在读取时不会发生变化。这简化了我们的工作,因为模式检测发生在数据流到达模型之前的早期阶段。
要设置管道,您需要提供以下内容:
模式
流数据中列的规范。
流读取器
流数据的来源规范。对于测试,这也可以是静态数据集。
机器学习模型
您希望训练的模型。
下面的代码片段演示了:
schema = StructType([ StructField(’id’, IntegerType(), True),
StructField(....) ]
streaming_data = spark.readStream.schema(schema)
.option("maxFilesPerTrigger", 1)
.parquet(*some_path*)
streaming_prediction = pipelinesModel.transform(streaming_data)
.groupBy(’label’) \
.agg(*some_aggregations*)
# do something with the streaming prediction data
流预测可用于聚合、决策、自动化或机器学习模型设计的任何业务任务。
假设这个模型被设计用于预测点击率。它捕捉用户对网站的印象,并尝试预测用户是否会点击特定链接。对于这个特定的用例,我们可以捕捉用户的实际行为——他们是否点击——以比较预测结果和实际结果。由于可以保存数据并将其作为批处理处理,我们可以决定采用批处理还是流处理方法。
使用流数据始终是一个更复杂的解决方案。但是,对于运行 A/B 测试并需要实时更新的网站来说,这可能会产生更好的结果。
使用流处理解决方案,可以使用两个数据流捕获点击和预测,并运行流-流连接。这个功能在 Spark 2.3 中添加,允许我们连接两个流数据框。这种方法的缺点是表的视图对于连接的两侧始终是不完整的,这可能使匹配变得更加困难。
在数据处理的世界中,流-流连接被认为是难以高效执行的,因为它们通常需要在实时处理过程中通过网络进行数据洗牌。想象一下,您有一个无尽的数据流进入系统,并且需要与另一个无尽的数据流进行连接。如何确保流正确对齐?为了解决这个问题,您可以考虑执行一系列微批次连接。由于数据可能无序到达,因此在使用 Spark 进行流-流连接时,您应确保定义水印延迟(事件时间和处理时间之间的最大延迟)。这让引擎知道数据可以有多晚,以及何时安全地处理每个微批次—即,它定义了等待的时间范围,直到洗牌和连接操作可以开始处理给定的微批次。可以通过时间戳或微批次大小(即批次中的行数)进行限制。
若要了解如何使用结构化流和批处理 API、流-流连接等更多信息,请访问Spark 文档。
使用 MLflow 部署
我们在第三章中讨论了 MLflow。作为提醒,MLflow 是一个用于管理机器学习生命周期的平台,使您能够记录和检查机器学习实验的结果。它具有跟踪实验、打包项目代码、打包和部署模型以及模型注册的组件。
MLflow 提供了两种不同的部署选项:
-
作为微服务
-
作为 Spark 流中的 UDF
使用 MLflow 部署机器学习模型需要您创建一个 MLflow 包装器。接下来我们将看看这一点,然后深入探讨两种部署选项。
定义 MLflow 包装器
MLflow 包装器——mlflow.pyfunc.PyFuncModel的一个实例——包装了模型及其元数据(MLmodel文件),使它们可以轻松地一起发布。这里是 MLflow 创建的模型目录结构的提醒:
--- 58dc6db17fb5471a9a46d87506da983f
------- artifacts
------------ model
------------ MLmodel
------------- conda.yaml
------------- input_example.json
------------- model.pkl
------- meta.yaml
------- metrics
------------ training_score
------- params
------------ A
------------ ...
------- tags
------------ mlflow.source.type
------------ mlflow.user
因为这个机器学习模型是在 Anaconda 环境中创建的,所以MLmodel文件夹包含conda.yaml文件和model.pkl文件。
包装器本身是一个类,我们与模型一起保存,这样后续加载模型的程序就知道如何加载它并用于预测。要连接训练、部署和使用模型,您必须在保存模型之前定义包装器类,并使用 MLflow 的log_model函数记录它。以下代码片段显示了如何执行:
model_path = ".../mlruns/*{experiment_id}*/*{run_id}*/artifacts/models"
wrappedModel = *{some_class}*(model_path)
mlflow.pyfunc.log_model("pyfunc_model_v2", python_model=wrappedModel)
请注意,当您创建类的实例并记录模型和包装器实例时,需要提供model_path本身,包括experiment_id和run_id。这提供了连接两者之间的纽带。
让我们进一步分解一下。您的包装类需要实现PythonModel。这使您能够使用python_function(pyfunc)模型风格创建 MLflow 模型,该模型可以利用 MLflow 为您管理的自定义推理逻辑和文物依赖关系。接口具有三个函数:__init__,load_context和predict。为了利用它,您的微服务必须实现predict函数;但是,您可以根据需要重写前两个函数。让我们看看它们各自的作用:
__init__
这是一个 Python 私有函数,负责设置全局参数。它被称为“init”,因为它执行了后续使用的服务所需的初始化。这是 MLflow 在调用load_model函数时调用的第一个函数,用于加载以pyfunc格式存储的模型。它接受一个包含 MLflow 在后台管理的可以用于进行预测的文物的PythonModelContext作为输入。PythonModelContext后续也可用,但为了效率,最好将上下文和文物加载到内存中作为实现服务的全局参数的一部分。
load_context
此函数负责从PythonModelContext中加载文物。当使用load_model加载模型时,MLflow 在构造PythonModel时立即调用它。
predict
MLflow 调用此函数从模型获取预测。它接受一个PythonModelContext实例和一个pyfunc兼容的输入以评估并返回一个pyfunc兼容的输出。请注意,模型可能需要很长时间才能返回结果;您应该为此做好准备,并且可能也希望在此处处理错误。您还应该保留日志以便将来进行警报和审计。
让我们来看看使用 TensorFlow 构建的模型的一些示例代码。在本练习中,我们的类称为KerasCNNModelWrapper。它包装了我们的数据科学家训练和测试过的KerasCNN TensorFlow 模型。
首先,我们需要确保将model_path参数保存到内存中:
def __init__(self, model_path):
self`.`model_path `=` model_path
请注意,我们不需要实现__init__,但是为了练习的目的,我们将实现所有函数。
接下来,让我们实现load_context函数。在这里,我们将从 Keras 本机表示中加载模型并将其保存到内存中:
def load_context(self, context):
log(self.model_path)
self.model = mlflow.keras.load_model(model_uri=self.model_path)
注意
使用mlflow.keras.load_model加载模型仅在您使用 MLflow 训练和保存模型时才可能。load_model函数负责加载预测所需的文物以及运行模型所需的一切。
最后,我们将实现predict。 如前所述,每次使用 MLflow 都必须实现此功能。 predict 函数负责丰富输入数据并预处理以适应模型期望的格式,如第四章所述。 由于在此练习中我们正在对图像进行分类,因此数据预处理涉及调整和重塑图像以适应模型期望的尺寸。 输入为 pandas DataFrame,大小可为 1 到 N。 class_def 是一个 Python 字典,表示模型的分类选项。 我们的模型根据图像中出现的内容对图像进行分类:茶壶、镊子、意大利面或溜溜球。 for 循环迭代所有输入,预处理数据,并运行 predict 函数本身,为每个类选项提供概率。
函数实现在以下代码示例中提供:
def predict(self, context, model_input):
import tensorflow as tf
import json
class_def = {
0: '212.teapot',
1: '234.tweezer',
2: '196.spaghetti',
3: '249.yo-yo',
}
rtn_df = model_input.iloc[:,0:1]
rtn_df['prediction'] = None
rtn_df['probabilities'] = None
for index, row in model_input.iterrows():
# resize and reshape the image
image = np.round(np.array(Image.open(row['origin']).resize((224,224)),
dtype=np.float32))
img = tf.reshape(image, shape=[-1, 224, 224, 3])
# predict
class_probs = self.model.predict(img)
# take the class with the highest probability
classes = np.argmax(class_probs, axis=1)
class_prob_dict = dict()
# calculate probability for each class option:
for key, val in class_def.items():
class_prob_dict[val] = np.round(np.float(class_probs[0][int(key)]),
3).tolist()
rtn_df.loc[index,'prediction'] = classes[0]
rtn_df.loc[index,'probabilities'] = json.dumps(class_prob_dict)
return rtn_df[['prediction', 'probabilities']].values.tolist()
最后,该函数返回一个包含每个输入预测的 Python 列表,以及表示所有类的概率的 JSON 对象作为字典。
为了将所有内容连接在一起,我们必须定义包装器类并记录模型(如本节前面的代码片段所示),然后保存它。 下面是我们为此模型执行此操作的方式:
model_path = ".../mlruns/*{experiment_id}*/*{run_id}*/artifacts/models"
wrappedModel = KerasCNNModelWrapper(model_path)
mlflow.pyfunc.log_model("pyfunc_model_v2", python_model=wrappedModel)
将模型部署为微服务
在本节中,我们将探讨如何实现本章开头讨论的模型即服务模式。 MLflow 提供了一个名为mlflow.deployments.BaseDeploymentClient的通用类,该类提供了 API,可用于部署到自定义服务工具。 您只需将函数包装为通过您选择的 API 提供服务的微服务。 API 定义了计算机程序之间的通信方式。 在这里,我们进入了管理和版本控制 API 的世界。
为了简化事情,如前一节所述,MLflow 开发了一个名为PythonModel的基类,表示评估输入并生成 API 兼容输出的通用 Python 模型。 您只需利用此类和已使用包装器类和工件路径记录的模型再次加载它。 您将load_model函数提供给model_path本身,并指定正确的experiment_id和run_id:
model_path = ".../mlruns/*{experiment_id}*/*{run_id}*/artifacts/models"
model = mlflow.pyfunc.load_model(model_path)
model.predict(model_input)
有许多运行服务器的方法; 我不会在本书中探讨这个主题,因为它取决于您的云提供商,生产环境,风险评估,技能集等。
将模型作为 Spark UDF 加载
如前所述,MLflow 使我们能够在生产环境中训练和加载模型并管理工件。 在前一节中,您学习了如何将模型加载为独立服务。 在本节中,您将看到如何将模型加载为 Spark UDF,遵循本章早期的批量预测和模型服务模式。
从编码的角度来看,将数据输入 Spark 中的流处理或批处理都取决于如何加载 DataFrame。对于批处理数据,我们使用read函数,对于流处理数据,我们使用readStream。使用readStream的 Spark 应用可能永远不会结束。它会监听特定的通道并不断从中拉取新数据。相比之下,批处理作业有一个开始和结束时间;作业在某个时刻完成并关闭。
那么,如何使用 MLflow 将您的模型转换为 UDF 呢?使用 MLflow 提供的spark_udf函数非常简单:
# Load model as a Spark UDF
loaded_model = mlflow.pyfunc.spark_udf(spark, mlflow_model_path,
result_type=ArrayType(StringType()))
要将 UDF 与您正在处理的 Spark DataFrame 结合起来,您只需调用loaded_model即可:
# Predict on a Spark DataFrame
scored_df = (images_df
`.`withColumn('origin', col("content"))
`.`withColumn('my_predictions', loaded_model(struct("origin")))
`.`drop("origin"))
创建一个名为images_df的新 Spark DataFrame,其中包含两列:第一列origin包含图像的原始内容,第二列my_predictions包含模型的预测结果。我们使用struct("origin")来确保该列的数据类型符合pyfunc在输入中期望的格式。至此,scored_df可以用于后续检查预测结果或基于预测结果采取行动。最后我们移除origin字段,因为在新的scored_df中这个字段是不必要的,这有助于减少内存占用。
如何迭代开发您的系统
现在您很可能已经理解,每当有新的更适合您需求或在某些方面优于当前正在生产中运行的模型时,您都会希望触发新的部署。
那么,如何知道何时替换现有模型?有多种方法可以解决这个问题。最常见的方法是在测试值上设置阈值。假设我们的目标是房地产成本预测的准确率达到 80%。将实际数据与我们的预测进行比较,我们确定我们的模型的准确率为 75%。我们是否应该采取行动?可能是的,因为这低于我们的目标准确率阈值。
我们能够生产出更好的模型吗?这是一个棘手的问题。我们怎么知道新模型肯定比现有模型表现更好呢?我们不知道。因此,我们需要能够跟踪和监控其性能,并在必要时回退到先前的版本。
当然,替换模型并不是唯一的可能解决方案。当我们确定模型表现不佳时,我们可以采取多种行动,包括调试生产系统本身。您选择采取的行动将取决于您的业务目标。
本节介绍了一种策略,您可以应用该策略从头开始使生产系统上线,并在之后(按阶段)进行迭代开发。这种框架被称为爬行、行走、奔跑、飞翔方法,还可以使您更好地评估您的工作及团队的期望,以及您的生产系统的状态。
起初,您将爬行——部署操作将是手动的,每次进行更改或出现错误时,您都会与您的团队一起评估和检查所有内容。
随着你对系统及其需求的了解增加,你将进入步行阶段。此时,您将向系统添加自动化测试和其他自动化工具。这里的目标是增强信心,并开始朝着可能实现完全自动化的系统迈进。
一旦您对手动部署和自动化测试程序感到满意,您将开始编写脚本以连接这两者。这时您进入运行阶段。您将在生产中为您的模型添加更多测试,微调警报,捕捉流入您机器学习模型的数据的任何变化,并监视吞吐量和结果。
最终,你将达到飞行阶段,在这个阶段,你对系统、代码、脚本和测试有了极高的信心,能够将警报的反馈循环与生产中数据漂移和变化的捕捉以及触发新的训练流程联系在一起。飞行状态是许多构建和使用机器学习系统的团队梦寐以求的境界。然而,一步一个脚印,保持敏捷至关重要。在你能步行或跑之前,你必须爬行,从手动将模型投入生产并监控其行为开始,但最终你会飞行:你的系统将在自动驾驶模式下稳定运行,你只需修复错误并引入新功能。此时,你将完全接入组织中的部署系统。
总结
在本书的最后一章中,我们深入探讨了机器学习生命周期的最后部分:部署、监控和退役现有模型。在整本书中,您已经了解了涉及机器学习工作流程的各种策略,从将数据输入系统,清理和组织数据,提取特征,构建模型,到利用 Spark 与 PyTorch 和 TensorFlow 进行迭代。本书还深入讨论了构建机器学习系统时可能遇到的一些问题。虽然机器学习世界仍在不断发展,仍有许多需要探索的内容,如保障系统、特征存储、更复杂的缓存技术、可观察性等等,但我希望本书达到了其主要目标,帮助您更好地理解 Spark 生态系统,以及如何与其他框架集成并利用其进行分布式训练。
¹ 因为预测是提前完成的,批处理模型可以通过利用缓存机制和其他方法快速处理大量请求。
² 量化是将大(通常连续的)输入值映射到小(通常有限的)输出值集合的过程。它支持生成一个压缩模型。



浙公网安备 33010602011771号