Python-机器学习工程第二版-全-
Python 机器学习工程第二版(全)
原文:
annas-archive.org/md5/12b0185c4bf68c0fcb37173533d7088b译者:飞龙
前言
“软件正在吞噬世界,但 AI 将吞噬软件。”
—— 英伟达首席执行官黄仁勋
机器学习(ML),作为更广泛的人工智能(AI)领域的一部分,正合理地被认为是组织从其数据中提取价值的最强大工具之一。随着机器学习算法能力的逐年增长,越来越明显的是,以可扩展、容错和自动化的方式实施这些算法需要创建新的学科。这些学科,机器学习工程(MLE)和机器学习运维(MLOps),正是本书的重点。
本书涵盖了广泛的主题,旨在帮助您了解可以应用于构建您的机器学习解决方案的工具、技术和流程,并着重介绍关键概念,以便您可以在未来的工作中在此基础上进行构建。目标是发展基础知识和广泛的理解,这些知识和理解能够经受时间的考验,而不仅仅是提供一系列对最新工具的介绍,尽管我们确实涵盖了大量的最新工具!
所有代码示例均以 Python 编写,这是当时世界上最流行的编程语言(撰写本文时)和数据应用的通用语言。Python 是一种高级面向对象的编程语言,拥有丰富的数据科学和机器学习工具生态系统。例如,scikit-learn和pandas等包已经成为全球数据科学团队的标准化词汇。本书的核心原则是,仅了解如何使用这些包是不够的。在这本书中,我们将使用这些工具以及许多其他工具,但重点是如何将它们封装到生产级管道中,并使用适当的云和开源工具进行部署。
我们将涵盖从如何组织您的机器学习团队,到软件开发方法和最佳实践,再到通过自动化模型构建,以及如何将您的机器学习管道部署到各种不同的目标,然后到如何扩展您的工作负载以进行大规模批量运行。我们还将讨论,在第二版新增的全新章节中,将机器学习工程和 MLOps 应用于深度学习和生成式 AI 的激动人心的世界,包括如何开始使用大型语言模型(LLMs)构建解决方案以及新的LLM 运维(LLMOps)领域。
《使用 Python 进行机器学习工程》的第二版在几乎每一章都比第一版深入得多,包括更新的示例和更多对核心概念的分析。涵盖的工具种类也更加广泛,对开源工具和开发的关注也更多。虽然仍然强调核心概念,但我希望这种更广阔的视角意味着第二版将成为那些希望获得机器学习工程实用知识的人的优秀资源。
虽然对使用开源工具的重视程度更高,但许多示例也将利用来自亚马逊网络服务(AWS)的服务和解决方案。我相信,然而,伴随的解释和讨论将意味着您可以将这里学到的所有内容应用到任何云提供商,甚至是在本地环境中。
使用 Python 进行机器学习工程,第二版将帮助您应对将 ML 投入生产的挑战,并让您有信心开始在项目中应用 MLOps。我希望您会喜欢它!
本书面向的对象
这本书是为那些希望使用机器学习组件构建稳健软件解决方案的机器学习工程师、数据科学家和软件开发人员而写的。它也适用于管理或希望了解这些系统生产生命周期的人。本书假设读者具备中级 Python 知识,并对机器学习的基本概念有所了解。一些 AWS 的基本知识和 bash 或 zsh 等 Unix 工具的使用也将有所帮助。
本书涵盖的内容
第一章,机器学习工程简介,解释了机器学习工程和机器学习操作的核心概念。详细讨论了 ML 团队中的角色,并概述了 ML 工程和 MLOps 的挑战。
第二章,机器学习开发过程,探讨了如何组织和成功执行一个 ML 工程项目。这包括对敏捷、Scrum 和 CRISP-DM 等开发方法的讨论,然后分享作者开发的项目方法,该方法在整本书中都有所提及。本章还介绍了持续集成/持续部署(CI/CD)和开发者工具。
第三章,从模型到模型工厂,展示了如何标准化、系统化和自动化训练和部署机器学习模型的过程。这是通过作者提出的模型工厂概念来实现的,这是一种可重复创建和验证模型的方法。本章还讨论了理解机器学习模型的关键理论概念,并涵盖了不同类型的漂移检测和模型重新训练触发标准。
第四章,打包,讨论了在 Python 中进行编码的最佳实践,以及这与构建自己的包、库和组件以在多个项目中重用的关系。本章在介绍更高级的概念之前,首先涵盖了基本的 Python 编程概念,然后讨论了包和环境管理、测试、日志记录和错误处理以及安全性。
第五章,部署模式和工具,教你一些标准的设计 ML 系统并将其投入生产的方法。本章首先关注架构、系统设计和部署模式,然后转向使用更高级的工具来部署微服务,包括容器化和 AWS Lambda。随后详细介绍了流行的 ZenML 和 Kubeflow 管道和部署平台,并提供了示例。
第六章,扩展规模,主要关于在考虑大数据集的情况下进行开发。为此,详细讨论了 Apache Spark 和 Ray 框架,并提供了工作示例。本章的重点是扩展需要大量计算能力的批处理工作负载。
第七章,深度学习、生成式 AI 和 LLMOps,涵盖了为生产用例训练和部署深度学习模型的最新概念和技术。本章包括讨论生成模型新趋势的内容,特别关注大型语言模型(LLMs)以及 ML 工程师将这些模型投入生产的挑战。这引出了定义 LLM 操作(LLMOps)的核心要素。
第八章,构建示例 ML 微服务,介绍了使用 FastAPI、Docker 和 Kubernetes 构建机器学习微服务的过程,该微服务提供预测解决方案。这汇集了本书中开发的大部分先前概念。
第九章,构建 ETL 机器学习用例,构建了一个示例批处理 ML 系统,该系统利用标准 ML 算法,并通过使用 LLMs 来增强这些算法。这展示了 LLMs 和 LLMOps 的具体应用,以及 Airflow DAGs 的更高级讨论。
为了最大限度地利用本书
-
在本书中,假设读者对 Python 开发有一些了解。为了完整性,涵盖了众多入门级概念,但一般来说,如果你已经编写过至少一些 Python 程序,将更容易理解示例。本书还假设读者对机器学习的主要概念有所了解,例如什么是模型,什么是训练和推理,以及对这些类似概念的理解。其中一些在文本中进行了回顾,但如果你之前已经熟悉了构建机器学习模型的主要思想,即使是基础水平,那么阅读本书将会更加顺畅。
-
在技术方面,为了充分利用本书中的示例,您需要访问一台计算机或服务器,您有权安装和运行 Python 以及其他软件包和应用程序。对于许多示例,假设您有 UNIX 类型终端的访问权限,例如 bash 或 zsh。本书中的示例是在运行 Ubuntu LTS 的 Linux 机器和运行 macOS 的 M2 Macbook Pro 上编写和测试的。如果您使用的是不同的设置,例如 Windows,示例可能需要一些调整才能在您的系统上运行。请注意,使用 M2 Macbook Pro 意味着一些示例会显示一些额外的信息,以便在 Apple Silicon 设备上运行示例。如果您的系统不需要这种额外设置,这些部分可以舒适地跳过。
-
许多基于云的示例利用亚马逊网络服务(AWS),因此需要一个带有计费设置的 AWS 账户。大多数示例将使用 AWS 提供的免费层服务,但这并不总是可能的。建议谨慎行事,以避免产生大额账单。如果有疑问,建议您查阅 AWS 文档以获取更多信息。作为一个具体的例子,在第五章,部署模式和工具中,我们使用了 AWS 的Apache Spark 托管工作流(MWAA)服务。MWAA 没有免费层选项,因此一旦启动示例,您将开始为环境和任何实例付费。在继续之前,请确保您愿意这样做,并且我建议在完成时拆除您的 MWAA 实例。
-
Conda和Pip被用于本书中的包和环境管理,但在许多情况下也使用了 Poetry。为了方便在本书 GitHub 仓库(
github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition)中每个章节的复现开发环境,每个章节都有一个对应的文件夹,在该文件夹中包含requirements.txt和 Conda 的environment.yml文件,以及有用的README文件。复制环境和任何其他要求的命令在本书每个章节的开头给出。 -
如果您使用的是本书的数字版,我仍然建议您亲自输入代码或从本书的 GitHub 仓库(
github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
如上所述,本书的代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/LMqir。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“首先,我们必须从alibi-detect包中导入TabularDrift检测器,以及用于加载数据和分割数据的相关包。”
代码块设置如下:
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
import alibi
from alibi_detect.cd import TabularDrift
任何命令行输入或输出都按以下方式编写,并在文本的主体中作为命令行命令表示:
pip install tensorflow-macos
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中会像这样显示。例如:“选择部署按钮。这将提供一个下拉菜单,您可以在其中选择创建服务。”
对附加资源或背景信息的引用会像这样出现。
有用的提示和重要的注意事项会像这样出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过电子邮件联系 questions@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您向我们报告。请访问 www.packtpub.com/submit-errata,点击 提交勘误,并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们非常感谢您提供位置地址或网站名称。请通过电子邮件联系 copyright@packtpub.com 并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了 Python 机器学习工程 - 第二版,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在旅途中阅读,但无法携带您的印刷书籍到任何地方吗?您的电子书购买是否与您选择的设备不兼容?
别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何地点、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您将获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。
按照以下简单步骤获取优惠:
-
扫描下面的二维码或访问以下链接
![]()
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一章:机器学习工程导论
欢迎来到《Python 机器学习工程》的第二版,这是一本旨在向您介绍制作机器学习(ML)系统生产就绪的激动人心的世界的书籍。
自本书第一版发布以来,ML 的世界已经发生了显著的变化。现在有更多更强大的建模技术可用,更复杂的技术堆栈,以及一大堆新的框架和范式来保持更新。为了帮助从噪音中提取信号,本书的第二版比第一版更深入地涵盖了更广泛的主题,同时仍然关注您构建 ML 工程专业知识所需的关键工具和技术。本版将涵盖相同的核心主题,例如如何管理您的 ML 项目,如何创建您自己的高质量 Python ML 包,以及如何构建和部署可重用的训练和监控管道,同时增加对更现代工具的讨论。它还将更深入地展示和分析不同的部署架构,并讨论更多使用 AWS 和云无关工具扩展应用程序的方法。所有这些都将使用各种最受欢迎和最新的开源包和框架来完成,从经典如Scikit-Learn和Apache Spark到Kubeflow、Ray和ZenML。令人兴奋的是,本版还设有全新的章节,完全致力于Transformer和大型语言模型(LLM)如 ChatGPT 和 GPT-4,包括使用 Hugging Face 和 OpenAI API 进行微调和构建使用这些非凡新模型的管道的示例。与第一版一样,重点是为您提供进入 ML 工程各个组成部分的坚实基础。目标是到本书结束时,您将能够自信地使用这些最新的工具和概念在 Python 中构建、扩展和部署生产级的 ML 系统。
即使您不运行技术示例,或者尝试在其他编程语言或使用不同工具中应用主要观点,您也能从这本书中获得很多。正如之前提到的,目标是创建一个坚实的概念基础,您可以在此基础上构建。在介绍关键原则时,目标是让您在阅读完这本书后,在应对自己选择的工具集的 ML 工程挑战时更有信心。
在本章的第一部分,您将了解与机器学习工程相关的不同类型的数据角色以及它们为什么很重要,如何利用这些知识来构建和参与适当的团队,在现实世界中构建工作 ML 产品时需要记住的一些关键点,如何开始隔离适合工程 ML 解决方案的问题,以及如何为各种典型的商业问题创建您自己的高级 ML 系统设计。
我们将在以下章节中涵盖这些主题:
-
定义数据学科的分类
-
组建您的团队
-
真实世界中的机器学习工程
-
机器学习解决方案看起来是什么样子?
-
高级机器学习系统设计
现在我们已经解释了在本章中我们要追求的内容,让我们开始吧!
技术要求
在整本书中,所有代码示例都将假设使用 Python 3.10.8,除非另有说明。本版中的示例已在配备 M2 苹果硅芯片的 2022 Macbook Pro 上运行,并安装了 Rosetta 2 以允许与基于 Intel 的应用程序和包向后兼容。大多数示例也已在运行 Ubuntu 22.04 LTS 的 Linux 机器上测试过。每章所需的 Python 包存储在书籍 Git 仓库中相应章节文件夹的.yml文件中的conda环境中。我们将在本书的后面部分详细讨论包和环境管理。但在此期间,假设您有一个 GitHub 账户并且已经配置了环境以从 GitHub 远程仓库中拉取和推送,要开始,您可以从命令行克隆本书的仓库:
git clone https://github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition.git
假设您已经安装了 Anaconda 或 Miniconda,然后您可以导航到本书 Git 仓库中的Chapter01文件夹并运行:
conda env create –f mlewp-chapter01.yml
这将设置您可以使用它来运行本章中给出的示例的环境。对于每个章节,可以遵循类似的程序,但每个部分也会指出针对这些示例的特定安装要求。
现在我们已经完成了一些设置,我们将开始探索机器学习工程的世界以及它如何适应现代数据生态系统。让我们开始探索机器学习工程的世界吧!
注意:在运行本节中给出的conda命令之前,您可能需要手动安装特定的库。一些版本的 Facebook Prophet 库需要可以构建在运行苹果硅的 Macbook 上的 PyStan 版本。如果您遇到此问题,那么您应该尝试手动安装httpstan包。首先,访问github.com/stan-dev/httpstan/tags并选择要安装的包版本。下载该版本的tar.gz或.zip文件并解压。然后您可以导航到解压后的文件夹并运行以下命令:
make
python3 -m pip install poetry
python3 -m poetry build
python3 -m pip install dist/*.whl
在后续示例中调用model.fit()时,您可能会遇到以下错误:
dyld[29330]: Library not loaded: '@rpath/libtbb.dylib'
如果是这样,您将需要运行以下命令,用 Conda 环境中 Prophet 安装的正确路径替换:
cd /opt/homebrew/Caskroom/miniforge/base/envs/mlewp-chapter01/lib/python3.10/site-packages/prophet/stan_model/
install_name_tool -add_rpath @executable_path/cmdstan-2.26.1/stan/lib/stan_math/lib/tbb prophet_model.bin
哦,在苹果硅上做机器学习的乐趣!
定义数据学科的分类
近年来数据的爆炸式增长及其潜在应用导致了众多职位和职责的激增。曾经关于数据科学家与统计学家之间区别的争论现在变得极其复杂。然而,我认为这并不一定那么复杂。从数据中获得价值所需进行的活动在各个业务领域都是相当一致的,因此,合理地预期执行这些步骤所需的技能和角色也将相对一致。在本章中,我们将探讨一些我认为在任何数据项目中始终需要的核心数据学科。正如你可以猜到的,鉴于本书的名称,我将特别热衷于探讨机器学习工程的概念以及它是如何融入其中的。
现在让我们来看看在现代数据环境中使用数据的一些相关角色。
数据科学家
在《哈佛商业评论》宣布成为数据科学家是21 世纪最性感的工作(hbr.org/2012/10/data-scientist-the-sexiest-job-of-the-21st-century)之后,这个职位成为最受欢迎、但也最被炒作的职位之一。其受欢迎程度仍然很高,但将高级分析和机器学习投入生产所面临的挑战意味着数据驱动型组织内部工程角色的转变越来越多。传统的数据科学家角色可以根据业务领域、组织或仅仅是个人偏好涵盖整个范围的任务、技能和责任。然而,无论这个角色如何定义,一些关键的关注领域始终应该是数据科学家工作档案的一部分:
-
分析:数据科学家应该能够在进行数据分析之前,整理、处理、操作和整合数据集。分析是一个广泛的概念,但很明显,最终的结果是你对数据集的了解,这是你在开始之前所没有的,无论这个数据集是基本还是复杂。
-
建模:让每个人都兴奋(可能包括亲爱的读者)的是,在数据中建模现象的想法。数据科学家通常必须能够将统计、数学和机器学习技术应用于数据,以解释其中包含的过程或关系,并执行某种预测。
-
与客户或用户合作:数据科学家的角色通常包含更多面向商业的元素,以便前两个点的结果可以支持组织内的决策。这可能通过在 PowerPoint 演示文稿或 Jupyter 笔记本中展示分析结果,或者甚至通过发送包含关键结果摘要的电子邮件来完成。这涉及到沟通和商业洞察力,这在经典的技术角色中是超越的。
机器学习工程师
在创建机器学习概念验证和构建健壮软件之间的差距,我经常在演讲中提到的“鸿沟”,导致了现在我认为技术领域最重要的角色之一的出现。机器学习工程师满足了将数据科学建模和探索的世界转化为软件产品和系统工程世界的迫切需求。由于这并非易事,机器学习工程师的需求日益增加,现在已成为数据驱动型软件价值链的关键部分。如果你不能将事物投入生产,你就不会产生价值,如果你不产生价值,那么我们都知道那不是一件好事!
通过考虑一个经典的语音助手,你可以很好地阐述这种角色的需求。在这种情况下,数据科学家通常会专注于将业务需求转化为一个可工作的语音到文本模型,可能是一个非常复杂的神经网络,并证明它可以在原则上执行所需的语音转录任务。然后,机器学习工程就是如何将这个语音到文本模型构建成一个可以在生产中使用的软件、服务或工具。在这里,这可能意味着构建一些软件来训练、重新训练、部署和跟踪模型的性能,随着转录数据的积累或用户偏好的理解。这也可能涉及到了解如何与其他系统接口并提供模型结果的适当格式。例如,模型的结果可能需要打包成一个 JSON 对象,并通过 REST API 调用发送到在线超市,以完成订单。
数据科学家和机器学习工程师有很多重叠的技能集和能力,但有不同的关注领域和优势(稍后详述),因此他们通常会是同一个项目团队的一部分,并且可能会有任一职位,但根据他们在项目中的表现,可以清楚地知道他们扮演的角色。
与数据科学家类似,我们可以定义机器学习工程师的关键关注领域:
-
翻译:将各种格式的模型和研究代码转化为更流畅、更健壮的代码片段。
这可以通过面向对象编程、函数式编程、混合编程或其他方式来完成,但基本上它有助于将数据科学家的概念验证工作转化为在生产环境中更值得信赖的东西。
-
架构:任何软件组件的部署都不会在真空中进行,总会涉及大量的集成部分。这一点在机器学习解决方案中同样适用。机器学习工程师必须理解适当的工具和流程是如何相互关联的,以便使用数据科学家构建的模型能够完成其工作,并在规模上实现。
-
生产化:机器学习工程师专注于交付解决方案,因此应该彻底理解客户的需求,以及能够理解这对项目开发意味着什么。机器学习工程师的最终目标不仅仅是提供一个好的模型(尽管这也是其中的一部分),也不是提供一个基本上能工作的东西。他们的工作是确保在现实世界的环境中,数据科学方面的辛勤工作能够产生最大的潜在价值。
机器学习运维工程师
机器学习工程将是本书的重点,但现在正在出现一个重要的角色,其目的是使机器学习工程师能够以更高的质量、更快的速度和更大的规模开展工作。这些是机器学习运维工程师(MLOps)。这个角色主要是关于构建工具和能力,以使机器学习工程师和数据科学家能够完成任务。这个角色更侧重于构建其他角色使用的工具、平台和自动化,因此它们之间有很好的联系。这并不是说 MLOps 工程师不会在特定的项目或构建中使用;只是他们的主要增值不是来自这里,而是来自在特定项目或构建中启用的能力。如果我们回顾一下在机器学习工程师部分描述的语音到文本解决方案的例子,我们就能感受到这一点。当机器学习工程师会担心构建一个在生产环境中无缝工作的解决方案时,MLOps 工程师会努力构建机器学习工程师使用的平台或工具集。机器学习工程师会构建管道,但 MLOps 工程师可能会构建管道模板;机器学习工程师可能会使用持续集成/持续部署(CI/CD)实践(关于这一点稍后会有更多介绍),但 MLOps 工程师将启用这种能力并定义最佳实践,以便顺利使用 CI/CD。最后,当机器学习工程师思考“我如何使用适当的工具和技术稳健地解决这个具体问题?”时,MLOps 工程师会问“我如何确保机器学习工程师和数据科学家能够一般性地解决他们需要解决的问题,以及我如何不断更新和改进这个设置?”
正如我们对数据科学家和机器学习工程师所做的那样,让我们定义 MLOps 工程师的一些关键关注领域:
-
自动化:通过使用 CI/CD 和基础设施即代码(IAC)等技术提高数据科学和机器学习工程工作流程的自动化水平。预包装软件可以部署,以通过这些能力以及更多功能(如自动化脚本或标准化模板)实现更平滑的解决方案部署。
-
平台工程:致力于将一系列有用的服务整合在一起,以构建不同数据驱动团队使用的机器学习平台。这可以包括开发跨编排工具、计算和更多数据驱动服务的集成,直到它们成为机器学习工程师和数据科学家可以使用的整体。
-
启用关键 MLOps 功能:MLOps 包括一系列实践和技术,使团队中的其他工程师能够生产化机器学习模型。模型管理和模型监控等能力应由 MLOps 工程师以可跨多个项目规模使用的方式启用。
应注意,本书中涵盖的一些主题可以由 MLOps 工程师执行,并且自然存在一些重叠。这不应让我们过于担忧,因为 MLOps 基于相当通用的实践和能力集,可以被多个角色所包含(参见图 1.1)。
数据工程师
数据工程师是那些负责以高保真度、适当的延迟,以及尽可能减少其他团队成员努力的情况下,将前述章节中所有基于 A 到 B 的商品获取到的人。没有数据,你无法创建任何类型的软件产品,更不用说机器学习产品了。
数据工程师的关键关注领域如下:
-
质量:如果数据混乱、字段缺失或 ID 出错,从 A 到 B 的数据传输就毫无意义。数据工程师关心避免这种情况,并使用各种技术和工具,通常是为了确保离开源系统的数据是你数据存储层中到达的数据。
-
稳定性:与质量方面的前一点类似,如果数据从 A 到 B,但只有在非雨天且是每周三的第二天才到达,那么这有什么意义呢?
数据工程师投入大量时间和精力,并运用他们丰富的技能来确保数据管道健壮、可靠,并能够在承诺的时间内交付。
-
访问:最后,从 A 到 B 获取数据的目标是使其被应用程序、分析和机器学习模型使用,因此 B 的性质很重要。数据工程师将手头有多种技术来展示数据,并且应该与数据消费者(包括我们的数据科学家和机器学习工程师等)合作,在这些解决方案中定义和创建适当的数据模型:

图 1.1:一个显示数据科学、机器学习工程和数据工程之间关系的图表。
如前所述,这本书主要关注机器学习工程师的工作以及你可以学习一些对这个角色有用的技能,但重要的是要记住,你不会在真空中工作。始终牢记其他角色的特征(以及在你项目团队中可能存在的许多其他未涵盖的角色),这样你才能最有效地共同工作。毕竟,数据科学是一项团队运动!
现在你已经了解了现代数据团队中的关键角色以及他们如何覆盖构建成功机器学习产品所需的活动范围,让我们看看你如何将它们组合起来以高效有效地工作。
作为有效团队的一员工作
在现代软件组织中,有许多不同的方法来组织团队并使他们有效地一起工作。我们将在第二章“机器学习开发过程”中介绍一些相关的项目管理方法,但在此期间,本节将讨论一些如果你参与组建团队或只是作为团队的一部分工作,你应该考虑的重要观点,这将帮助你成为一个有效的团队成员或领导者。
首先,始终牢记没有人能做所有的事情。你可以在那里找到一些非常有才华的人,但永远不要认为一个人能做你需要的一切,达到你所要求的水平。这不仅不切实际,而且是不良的做法,会负面影响你产品的质量。即使你在资源严重受限的情况下,关键也是让你的团队成员保持激光般的专注以取得成功。
其次,混合是最好的选择。我们都知道多样性对于组织和团队的一般好处,当然,这也应该适用于你的机器学习团队。在一个项目中,你需要数学、代码、工程、项目管理、沟通以及各种其他技能来取得成功。所以,根据前面的观点,确保你在团队中至少在一定程度上涵盖这些技能。
第三,以动态的方式将你的团队结构与项目联系起来。如果你参与的项目主要是关于将数据放在正确的位置,而实际的机器学习模型非常简单,那么将你的团队特征集中在工程和数据建模方面。如果项目需要详细了解模型,并且它相当复杂,那么重新定位你的团队以确保这一点得到覆盖。这既合理又能够释放那些本可以未充分利用的团队成员去从事其他项目。
例如,假设你被分配了一个任务,即构建一个系统,用于在数据进入你那光鲜亮丽的新数据湖时对客户数据进行分类,并且已经决定通过流式应用程序在数据摄入点进行这一操作。分类已经为另一个项目构建好了。很明显,这个解决方案将大量涉及数据工程师和机器学习工程师的技能,但不会太多涉及数据科学家,因为这部分工作已经在另一个项目中完成了。
在下一节中,我们将探讨在将你的团队部署到现实世界的商业问题时需要考虑的一些重要点。
现实世界的机器学习工程
我们中的大多数人在机器学习、分析和相关学科工作,这些工作是在具有各种不同结构和动机的组织中进行的。这些可能是盈利性公司、非盈利组织、慈善机构,或者是政府或大学等公共部门组织。在几乎所有这些情况下,我们都不是在真空中工作,也没有无限的预算或资源。因此,考虑在现实世界中做这类工作的一些重要方面是很重要的。
首先,你工作的最终目标是创造价值。这可以通过各种方式计算和定义,但本质上你的工作必须以某种方式改善公司或其客户,从而证明所投入的投资是合理的。这就是为什么大多数公司不会高兴地看到你花一年的时间去玩新工具,然后什么具体成果都没有,或者整天只阅读最新的论文。是的,这些事情是任何技术工作的一部分,它们肯定可以非常有趣,但你必须战略性地考虑如何分配你的时间,并且始终意识到你的价值主张。
其次,要成为现实世界中的成功机器学习工程师,你不仅需要理解技术,还必须理解业务。你必须了解公司日常是如何运作的,你必须了解公司的不同部分是如何相互配合的,你必须了解公司的人和他们的角色。最重要的是,你必须理解客户,包括业务和你的工作。如果你不知道你为谁构建的人的动机、痛苦和需求,那么你怎么能期望构建正确的东西呢?
最后,这可能有些争议,但你在现实世界中成为一名成功的机器学习工程师最重要的技能是这本书不会教给你的,那就是有效沟通的能力。你将不得不与团队一起工作,与经理、更广泛的社区和商业界,当然还有上述提到的客户一起工作。如果你能这样做,并且你了解技术和技巧(其中许多在本书中讨论过),那么还有什么能阻止你呢?
但在现实世界中,你可以用 ML 解决哪些类型的问题呢?好吧,让我们从一个可能具有争议性的声明开始:很多时候,ML 并不是答案。鉴于这本书的标题,这可能会显得有些奇怪,但了解何时不应用 ML 与了解何时应用 ML 同样重要。这将节省你大量的昂贵开发和资源时间。
当你想更快、更精确地完成半常规任务,或者在其他解决方案无法达到的更大规模上完成任务时,ML 是理想的。
在下表中给出了几个典型的例子,并讨论了机器学习(ML)是否是解决该问题的合适工具:
| 需求 | ML 是否合适? | 详情 |
|---|---|---|
| 能源定价信号的异常检测。 | 合适 | 你可能需要在大量可能随时间信号变化的点上执行此操作。 |
| 在 ERP 系统中提高数据质量。 | 不合适 | 这听起来更像是一个流程问题。你可以尝试应用 ML,但通常最好是使数据录入过程更加自动化或使流程更加稳健。 |
| 预测仓库物品消耗。 | 合适 | ML 将能够比人类更准确地完成这项工作,因此这是一个很好的应用领域。 |
| 为商业审查总结数据。 | 可能 | 这可能需要大规模执行,但这不是一个 ML 问题——简单的数据查询就可以完成。 |
表 1.1:ML 的潜在应用案例。
如此简单的例子表(希望)开始清楚地表明,ML 确实是答案的情况通常是那些可以很好地被构建为数学或统计问题的情况。毕竟,这就是 ML 的本质——一系列基于数学的算法,可以根据数据迭代一些内部参数。在现代世界中,随着深度学习或强化学习等领域的发展,我们之前认为很难为标准 ML 算法适当表述的问题现在可以解决。
在现实世界中需要警惕的另一个趋势(与让我们用 ML 做一切的趋势相伴随)是人们对 ML 会抢走他们的工作以及不应信任 ML 的担忧。这是可以理解的:普华永道(PwC)2018 年的一份报告建议,到 2030 年代,30%的英国工作将受到自动化的影响(机器人真的会偷走我们的工作吗?)。在与同事和客户合作时,你必须努力阐明你正在构建的是为了补充和增强他们的能力,而不是取代他们。
让我们通过回顾一个重要观点来结束本节:你为一家公司工作的事实,当然意味着游戏的目标是创造与投资相称的价值。换句话说,你需要展示良好的投资回报率(ROI)。这对你实际上意味着几件事:
-
你必须了解不同的设计需要不同水平的投资。如果你可以通过在一个月内用 GPU 全天候训练一百万张图片来解决你的问题,或者你知道你可以在几小时内使用一些基本的聚类和一些标准硬件上的少量统计来解决相同的问题,你应该选择哪一个?
-
你必须清楚你将产生的价值。这意味着你需要与专家合作,并尝试将你的算法结果转化为实际美元价值。这比听起来要困难得多,所以你需要花时间去正确完成它。而且,永远不要过度承诺。你应该总是承诺少一些,交付多一些。
采用并不保证。即使是在公司内部为同事构建产品,你也必须明白,你的解决方案每次在使用后都会被测试。如果你构建的是质量低劣的解决方案,那么人们就不会使用它们,你所做的一切的价值主张也将开始消失。
现在你已经了解了使用机器学习解决商业问题时的一些重要要点,让我们来探讨这些解决方案可能是什么样子。
机器学习解决方案是什么样的?
当你想到机器学习工程时,你可能会默认想象在语音助手和视觉识别应用程序上工作(我在前面的页面上也陷入了这种陷阱——你注意到了吗?)。然而,机器学习的力量在于,只要有数据和合适的问题,它就能帮助并成为解决方案的关键部分。
一些例子可能有助于使这一点更清晰。当你输入一条短信,你的手机建议下一个单词时,它很可能是在使用底下的自然语言模型。当你滚动任何社交媒体信息流或观看流媒体服务时,推荐算法正在加倍工作。如果你开车旅行,一个应用程序预测你何时可能到达目的地,那么将会有某种回归在工作。你的贷款申请通常会导致你的特征和申请细节通过一个分类器。这些应用不是新闻中大声宣扬的(也许除了它们出问题时),但它们都是精心设计的机器学习工程的例子。
在这本书中,我们将要处理的例子将更像是这些——在产品和业务中每天都会遇到的典型机器学习场景。这些是如果你能自信地构建它们,将使你成为任何组织的宝贵资产。
我们应该从考虑任何机器学习解决方案应包含的广泛元素开始,如下面的图所示:

图 1.2:任何机器学习解决方案的一般组件或层及其负责的内容。
您的存储层构成了数据工程过程的终点和机器学习过程的起点。它包括您的训练数据、运行模型的结果、您的工件和重要的元数据。我们还可以将这一层视为包括您存储的代码。
计算层是发生“魔法”的地方,也是本书大部分关注的焦点。这是训练、测试、预测和转换(主要是)发生的地方。本书的宗旨是使这一层尽可能工程化,并与其他层进行接口。
你可以将这一层分解为以下工作流程中所示的部分:

图 1.3:计算层的关键元素。
重要提示
详细内容将在本书的后续部分讨论,但这一点强调了这样一个事实:在基本层面上,任何机器学习解决方案的计算过程实际上只是关于接收一些数据并输出一些数据。
应用层是您与其他系统共享机器学习解决方案结果的地方。这可能包括从应用程序数据库插入到 API 端点、消息队列或可视化工具等。这是您的客户最终使用结果的地方,因此您必须设计系统以提供干净、易于理解的输出,我们将在稍后讨论这一点。
简而言之,就是这样。我们将在稍后详细讨论所有这些层和点,但现在,只需记住这些广泛的概念,你就会开始理解所有详细的技术部件是如何结合在一起的。
为什么选择 Python?
在深入探讨更详细的主题之前,讨论为什么选择 Python 作为本书的编程语言是很重要的。以下所有涉及高级主题的内容,如架构和系统设计,都可以应用于使用任何或多种语言的解决方案,但 Python 在这里被单独提出,有以下几个原因。
Python 通常被称为数据的“通用语言”。它是一种非编译的、非强类型的、多范式的编程语言,具有清晰简单的语法。其工具生态系统也非常广泛,尤其是在分析和机器学习领域。
如 scikit-learn、numpy、scipy 以及许多其他软件包构成了全球大量技术和科学发展的基础。几乎每个用于数据世界的重大新软件库都有一个 Python API。根据写作时的TIOBE 指数(www.tiobe.com/tiobe-index/),Python 是世界上最受欢迎的编程语言(2023 年 8 月)。
因此,能够使用 Python 构建你的系统意味着你将能够利用这个生态系统中的所有优秀的机器学习和数据科学工具,同时确保你构建的应用程序可以与其他软件良好地协同工作。
高级机器学习系统设计
当你深入到构建解决方案的细节时,工具、技术和方法的选择如此之多,以至于很容易感到不知所措。然而,正如前几节所暗示的,很多这种复杂性可以通过一些信封背面的架构和设计来抽象化,从而理解更大的图景。一旦你知道你将尝试解决的问题,这总是一个有用的练习,而且我建议你在做出任何关于实施的详细选择之前就做这件事。
为了让你了解这在实践中是如何工作的,以下是一些经过详细分析的例子,其中一支团队必须为一些典型的商业问题创建一个高级机器学习系统设计。这些问题与我之前遇到的问题相似,也可能会与你自己在工作中遇到的问题相似。
示例 1:批量异常检测服务
你为一家技术娴熟的出租车公司工作,该公司拥有数千辆汽车。该组织希望开始使行程时间更加一致,并了解更长的旅程,以便改善客户体验,从而提高客户保留率和回头客。你的机器学习团队被雇佣来创建一个异常检测服务,以寻找具有不寻常的行程时间或行程长度行为的行程。你们开始工作,数据科学家发现,如果你使用行程距离和时间特征对行程集进行聚类,你可以清楚地识别出值得运营团队调查的异常值。数据科学家在获得批准将此开发成一项服务之前,向 CTO 和其他利益相关者展示了研究结果,该服务将在公司内部分析工具的主要表中提供一个新字段,作为异常标志。
在这个例子中,我们将模拟一些数据,以展示出租车公司的数据科学家如何进行。在本书的存储库中,该存储库可以在github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition找到,如果你导航到Chapter01文件夹,你会看到一个名为clustering_example.py的脚本。如果你已经通过mlewp-chapter01.yml环境文件激活了提供的conda环境,那么你可以使用以下命令运行此脚本:
python3 clustering_example.py
运行成功后,你应该会看到创建了三个文件:taxi-rides.csv、taxi-labels.json和taxi-rides.png。taxi-rides.png中的图像应该类似于图 1.4 中所示。
我们将逐步说明这个脚本是如何构建的:
-
首先,让我们定义一个函数,该函数将根据
numpy中给出的随机分布模拟一些行程距离,并返回一个包含结果的numpy数组。重复行的原因是为了在数据中创建一些基本行为和异常,并且你可以清楚地与我们在下一步将为每辆出租车集合生成的速度进行比较:import numpy as np from numpy.random import MT19937 from numpy.random import RandomState, SeedSequence rs = RandomState(MT19937(SeedSequence(123456789))) # Define simulate ride data function def simulate_ride_distances(): ride_dists = np.concatenate( ( 10 * np.random.random(size=370), 30 * np.random.random(size=10), # long distances 10 * np.random.random(size=10), # same distance 10 * np.random.random(size=10) # same distance ) ) return ride_dists -
我们现在可以对速度做完全相同的事情,并且再次将出租车分成
370、10、10和10的集合,这样我们就可以创建一些具有“典型”行为的数据和一些异常集合,同时允许与distances函数的值进行清晰的匹配:def simulate_ride_speeds(): ride_speeds = np.concatenate( ( np.random.normal(loc=30, scale=5, size=370), np.random.normal(loc=30, scale=5, size=10), np.random.normal(loc=50, scale=10, size=10), np.random.normal(loc=15, scale=4, size=10) ) ) return ride_speeds -
我们现在可以在一个函数内部使用这两个辅助函数,该函数将调用它们并将它们组合起来,以创建包含行程 ID、速度、距离和时间的模拟数据集。结果以
pandasDataFrame 的形式返回,用于建模:def simulate_ride_data(): # Simulate some ride data … ride_dists = simulate_ride_distances() ride_speeds = simulate_ride_speeds() ride_times = ride_dists/ride_speeds # Assemble into Data Frame df = pd.DataFrame( { 'ride_dist': ride_dists, 'ride_time': ride_times, 'ride_speed': ride_speeds } ) ride_ids = datetime.datetime.now().strftime("%Y%m%d") +\ df.index.astype(str) df['ride_id'] = ride_ids return df -
现在,我们来到了数据科学家在其项目中产生的核心内容,这是一个简单的函数,它封装了一些
sklearn代码,以返回一个包含聚类运行元数据和结果的字典。我们在这里包括相关的导入,以便于使用:
from sklearn.preprocessing import StandardScaler from sklearn.cluster import DBSCAN from sklearn import metrics def cluster_and_label(data, create_and_show_plot=True): data = StandardScaler().fit_transform(data) db = DBSCAN(eps=0.3, min_samples=10).fit(data) # Find labels from the clustering core_samples_mask = np.zeros_like(db.labels_,dtype=bool) core_samples_mask[db.core_sample_indices_] = True labels = db.labels_ # Number of clusters in labels, ignoring noise if present. n_clusters_ = len(set(labels)) - (1 if -1 in labels else 0) n_noise_ = list(labels).count(-1) run_metadata = { 'nClusters': n_clusters_, 'nNoise': n_noise_, 'silhouetteCoefficient': metrics.silhouette_score(data, labels), 'labels': labels, } if create_and_show_plot: plot_cluster_results(data, labels, core_samples_mask, n_clusters_) else: pass return run_metadata注意,步骤 4中的函数利用了以下所示的绘图实用函数:
import matplotlib.pyplot as plt def plot_cluster_results(data, labels, core_samples_mask, n_clusters_): fig = plt.figure(figsize=(10, 10)) # Black removed and is used for noise instead. unique_labels = set(labels) colors = [plt.cm.cool(each) for each in np.linspace(0, 1, len(unique_labels))] for k, col in zip(unique_labels, colors): if k == -1: # Black used for noise. col = [0, 0, 0, 1] class_member_mask = (labels == k) xy = data[class_member_mask & core_samples_mask] plt.plot(xy[:, 0], xy[:, 1], 'o', markerfacecolor=tuple(col), markeredgecolor='k', markersize=14) xy = data[class_member_mask & ~core_samples_mask] plt.plot(xy[:, 0], xy[:, 1], '^', markerfacecolor=tuple(col), markeredgecolor='k', markersize=14) plt.xlabel('Standard Scaled Ride Dist.') plt.ylabel('Standard Scaled Ride Time') plt.title('Estimated number of clusters: %d' % n_clusters_) plt.savefig('taxi-rides.png')最后,所有这些内容都在程序的入口点汇总,如下所示:
import logging logging.basicConfig() logging.getLogger().setLevel(logging.INFO) if __name__ == "__main__": import os # If data present, read it in file_path = 'taxi-rides.csv' if os.path.exists(file_path): df = pd.read_csv(file_path) else: logging.info('Simulating ride data') df = simulate_ride_data() df.to_csv(file_path, index=False) X = df[['ride_dist', 'ride_time']] logging.info('Clustering and labelling') results = cluster_and_label(X, create_and_show_plot=True) df['label'] = results['labels'] logging.info('Outputting to json ...') df.to_json('taxi-labels.json', orient='records')
此脚本运行后,会创建一个数据集,显示每个模拟的出租车行程及其聚类标签在taxi-labels.json中,以及模拟数据集在taxi-rides.csv中,以及显示聚类结果的taxi-rides.png,如图 1.4 所示。

图 1.4:对一些出租车行程数据进行聚类分析的结果示例集。
现在你已经有一个基本模型可以工作,你必须开始考虑如何将其纳入一个工程化的解决方案——你该如何做?
嗯,由于这里的解决方案将支持另一个团队进行更长时间的调查,因此不需要一个非常低延迟的解决方案。利益相关者同意,聚类的见解可以在每天结束时提供。与团队的数据科学部分合作,ML 工程师(由你领导)了解到,如果每天运行聚类,这将提供足够的数据来生成适当的聚类,但更频繁的运行可能会由于数据量较小而导致结果较差。因此,达成一致意见,采用每日批量处理流程。
下一个问题是如何安排这个运行?嗯,你需要一个编排层,这是一个工具或工具集,它将使你能够安排和管理预定义的工作。像 Apache Airflow 这样的工具可以做到这一点。
接下来你该做什么呢?嗯,你知道运行频率是每天一次,但数据量仍然非常高,因此利用分布式计算模式是有意义的。两个选项立刻浮现在脑海中,并且团队中存在这些技能,Apache Spark 和 Ray。为了尽可能减少对底层基础设施的耦合并最小化对代码重构的需求,你决定使用 Ray。你知道数据的最终消费者是一个 SQL 数据库中的表,因此你需要与数据库团队合作设计一个适当的结果交接方案。由于安全和可靠性方面的考虑,直接写入生产数据库不是一个好主意。因此,你同意使用云中的另一个数据库作为数据的中间暂存区域,主数据库可以在其每日构建中查询这些数据。
在这里可能看起来我们并没有进行任何技术性的工作,但实际上,你已经为你的项目完成了高级的系统设计。这本书的剩余部分将告诉你如何填补以下图表中的空白!

图 1.5:示例 1 工作流程。
现在让我们继续下一个例子!
示例 2:预测 API
在这个例子中,你为一家大型零售连锁企业的物流部门工作。为了最大化货物流通,公司希望帮助区域物流规划师在特别繁忙的时期提前做好准备,避免产品售罄。在与业务中的利益相关者和领域专家讨论后,一致认为规划师能够通过一个托管在网页上的仪表板动态请求和探索特定仓库项目的预测,这是最优的。这允许规划师在下单前了解可能的需求轮廓。
数据科学家再次表现出色,发现任何单个商店层面的数据都具有非常可预测的行为。他们决定使用 Facebook Prophet 库进行建模,以帮助加快训练多个不同模型的过程。在下面的示例中,我们将展示他们如何做到这一点,但我们不会花费时间优化模型以创建最佳的预测性能,因为这只是为了说明目的。
这个示例将使用 Kaggle API 来检索一系列不同零售店销售的示例数据集。在书库下的Chapter01/forecasting目录中有一个名为forecasting_example.py的脚本。如果您已经正确配置了 Python 环境,您可以在命令行使用以下命令运行此示例:
python3 forecasting_example.py
脚本首先下载数据集,对其进行转换,然后使用它来训练一个 Prophet 预测模型,在测试集上进行预测并保存绘图。如前所述,这只是为了说明目的,因此不会创建验证集或执行比 Prophet 库提供的默认参数更复杂的超参数调整。
为了帮助您了解这个示例是如何组合起来的,我们现在将分解脚本的不同组件。为了简洁起见,这里排除了纯粹用于绘图或记录的功能:
-
如果我们查看脚本的主要部分,我们可以看到第一步都是关于读取数据集,如果它已经在正确的目录中,或者下载并读取它:
import pandas as pd if __name__ == "__main__": import os file_path = train.csv if os.path.exists(file_path): df = pd.read_csv(file_path) else: download_kaggle_dataset() df = pd.read_csv(file_path) -
执行下载的函数使用了 Kaggle API,如下所示;您可以通过参考 Kaggle API 文档来确保正确设置(这需要一个 Kaggle 账户):
import kaggle def download_kaggle_dataset( kaggle_dataset: str ="pratyushakar/ rossmann-store-sales" ) -> None: api = kaggle.api kaggle.api.dataset_download_files(kaggle_dataset, path="./", unzip=True, quiet=False) -
接下来,脚本调用了一个名为
prep_store_data的函数来转换数据集。这个函数使用两个默认值调用,一个用于商店 ID,另一个指定我们只想看到商店开门时的数据。该函数的定义如下:def prep_store_data(df: pd.DataFrame, store_id: int = 4, store_open: int = 1) -> pd.DataFrame: df['Date'] = pd.to_datetime(df['Date']) df.rename(columns= {'Date':'ds','Sales':'y'}, inplace=True) df_store = df[ (df['Store'] == store_id) & (df['Open'] == store_open) ].reset_index(drop=True) return df_store.sort_values('ds', ascending=True) -
预测模型 Prophet 随后在数据的第一个 80%上训练,并对剩余的 20%数据进行预测。为了指导模型的优化,向模型提供了季节性参数:
seasonality = { 'yearly': True, 'weekly': True, 'daily': False } predicted, df_train, df_test, train_index = train_predict( df = df, train_fraction = 0.8, seasonality=seasonality )train_predict方法的定义如下,您可以看到它封装了一些进一步的数据准备和 Prophet 包的主要调用:def train_predict(df: pd.DataFrame, train_fraction: float, seasonality: dict) -> tuple[ pd.DataFrame,pd.DataFrame,pd.DataFrame, int]: train_index = int(train_fraction*df.shape[0]) df_train = df.copy().iloc[0:train_index] df_test = df.copy().iloc[train_index:] model=Prophet( yearly_seasonality=seasonality['yearly'], weekly_seasonality=seasonality['weekly'], daily_seasonality=seasonality['daily'], interval_width = 0.95 ) model.fit(df_train) predicted = model.predict(df_test) return predicted, df_train, df_test, train_index -
然后,最后,调用了一个实用绘图函数,当运行时将创建如图 1.6所示的输出。这显示了测试数据集预测的放大视图。由于上述讨论的简洁性,这里没有给出该函数的详细信息:
plot_forecast(df_train, df_test, predicted)

图 1.6:预测商店销售。
这里有一个问题,那就是为每个商店实施上述预测模型,如果连锁店收集到足够的数据,很快就会导致数百甚至数千个模型。另一个问题是,公司使用的资源规划系统尚未覆盖所有商店,因此一些规划者希望检索与他们自己的商店相似的其他商店的预测。大家一致认为,如果用户喜欢探索他们认为与自己的数据相似的地区概况,那么他们仍然可以做出最佳决策。
考虑到这一点和客户对动态、即兴请求的要求,你很快就会排除完整的批量处理。这不会涵盖核心系统之外的地区的用例,也不会允许通过网站动态检索最新的预测,这将允许你部署在未来不同时间范围内进行预测的模型。这也意味着你可以节省计算资源,因为你不需要每天管理数千个预测的存储和更新,你的资源可以专注于模型训练。
因此,你决定,实际上,一个可以按需返回用户所需预测的端点的托管在网站上的 API 是最有意义的。为了提供高效的响应,你必须考虑典型用户会话中会发生什么。通过与仪表板的潜在用户进行工作坊,你很快就会意识到,尽管请求是动态的,但大多数规划者将专注于任何一次会话中特定感兴趣的项目。他们也不会查看很多地区。然后你决定,有一个缓存策略是有意义的,其中你将某些你认为可能常见的请求取出来,并在应用程序中缓存以供重用。
这意味着在用户做出第一次选择后,结果可以更快地返回,从而提供更好的用户体验。这导致了一个粗略的系统草图,如图 1.7 所示:

图 1.7:示例 2 工作流程。
接下来,让我们看看最后的例子。
示例 3:分类流程
在这个最后的例子中,你为一家基于网络的公司工作,该公司希望根据用户的用法模式对用户进行分类,作为不同类型广告的目标,以便更有效地定位营销支出。例如,如果用户使用网站的频率较低,我们可能想通过更激进的折扣来吸引他们。业务的关键要求之一是最终结果成为其他应用程序使用的数据存储中的数据的一部分。
根据这些要求,您的团队确定运行分类模型的管道是满足所有条件的简单解决方案。数据工程师将精力集中在构建数据摄取和数据存储基础设施上,而机器学习工程师则致力于封装数据科学团队在历史数据上训练的分类模型。数据科学家确定的基础算法在sklearn中实现,我们将在下面通过将其应用于与这个用例类似的市场数据集来处理它:
这个假设的例子与许多经典数据集相吻合,包括来自 UCI ML 存储库的银行营销数据集:archive.ics.uci.edu/ml/datasets/Bank+Marketing#。与之前的例子一样,有一个可以从命令行运行的脚本,这次在Chapter01/classifying文件夹中,名为classify_example.py:
python3 classify_example.py
运行此脚本将读取下载的银行数据,重新平衡训练数据集,然后在随机网格搜索中对随机森林分类器执行超参数优化运行。与之前类似,我们将展示这些部分是如何工作的,以展示数据科学团队可能如何处理这个问题:
-
脚本的主要部分包含所有相关步骤,这些步骤被巧妙地封装成我们将在接下来的几个步骤中剖析的方法:
if __name__ == "__main__": X_train, X_test, y_train, y_test = ingest_and_prep_data() X_balanced, y_balanced = rebalance_classes(X_train, y_train) rf_random = get_randomised_rf_cv( random_grid=get_hyperparam_grid() ) rf_random.fit(X_balanced, y_balanced) -
下面的
ingest_and_prep_data函数,它假定bank.csv数据存储在当前文件夹中名为bank_data的目录中。它将数据读入一个pandasDataFrame,然后在数据上执行训练集-测试集分割,并对训练特征进行独热编码,最后返回所有训练和测试特征及目标。与其他示例一样,本书将解释这些概念和工具,尤其是在第三章,从模型到模型工厂中:def ingest_and_prep_data( bank_dataset: str = 'bank_data/bank.csv' ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]: df = pd.read_csv('bank_data/bank.csv', delimiter=';', decimal=',') feature_cols = ['job', 'marital', 'education', 'contact', 'housing', 'loan', 'default', 'day'] X = df[feature_cols].copy() y = df['y'].apply(lambda x: 1 if x == 'yes' else 0).copy() X_train, X_test, y_train, y_test = train_test_split(X, y, test_ size=0.2, random_state=42) enc = OneHotEncoder(handle_unknown='ignore') X_train = enc.fit_transform(X_train) return X_train, X_test, y_train, y_test -
由于数据不平衡,我们需要使用过采样技术重新平衡训练数据。在这个例子中,我们将使用来自
imblearn包的合成少数过采样技术(SMOTE):def rebalance_classes(X: pd.DataFrame, y: pd.DataFrame ) -> tuple[pd.DataFrame, pd.DataFrame]: sm = SMOTE() X_balanced, y_balanced = sm.fit_resample(X, y) return X_balanced, y_balanced -
现在我们将进入脚本的主体 ML 组件。我们将执行超参数搜索(关于这一点,将在第三章,从模型到模型工厂中详细介绍),因此我们必须定义一个搜索网格:
def get_hyperparam_grid() -> dict: n_estimators = [int(x) for x in np.linspace(start=200, stop=2000, num=10)] max_features = ['auto', 'sqrt'] max_depth = [int(x) for x in np.linspace(10, 110, num=11)] max_depth.append(None) min_samples_split = [2, 5, 10] min_samples_leaf = [1, 2, 4] bootstrap = [True, False] # Create the random grid random_grid = { 'n_estimators': n_estimators, 'max_features': max_features, 'max_depth': max_depth, 'min_samples_split': min_samples_split, 'min_samples_leaf': min_samples_leaf, 'bootstrap': bootstrap } return random_grid -
最后,这个超参数网格将被用于定义一个
RandomisedSearchCV对象,它允许我们在超参数值上优化估计量(在这里,是一个RandomForestClassifier):def get_randomised_rf_cv(random_grid: dict) -> sklearn.model_ selection._search.RandomizedSearchCV: rf = RandomForestClassifier() rf_random = RandomizedSearchCV( estimator=rf, param_distributions=random_grid, n_iter=100, cv=3, verbose=2, random_state=42, n_jobs=-1, scoring='f1' ) return rf_random
上面的例子突出了创建典型分类模型的基本组件,但作为工程师,我们必须问自己,“接下来是什么?”很明显,我们必须实际运行已经生成的模型进行预测,因此我们需要将其持久化并稍后读取。这与本章讨论的其他用例类似。在这里更具挑战性的是,工程师可能实际上会考虑不在批量或请求-响应场景中运行,而是在流式场景中运行。这意味着我们必须考虑新的技术,如Apache Kafka,它允许你发布和订阅“主题”,在这些“主题”中可以共享称为“事件”的数据包。不仅如此,我们还需要就如何使用机器学习模型以这种方式与数据交互做出决策,提出关于适当模型托管机制的疑问。还有关于你希望多频繁地重新训练你的算法以确保分类器不过时的微妙之处。在考虑延迟或监控模型在这种非常不同的环境中的性能问题之前,这些都是需要考虑的。正如你所看到的,这意味着机器学习工程师在这里的工作相当复杂。图 1.8将这些复杂性归纳为一个非常高级的图表,它将帮助你开始考虑如果你是这个项目的工程师,你需要构建的系统交互类型。
在这本书中,我们不会过多地详细讨论流式处理,但我们将详细讨论所有其他关键组件,这些组件将帮助你将这个示例构建成一个真正的解决方案。有关流式机器学习应用的更多详细信息,请参阅 Joose Korstanje 所著的《Python 流式数据处理机器学习》,Packt 出版社,2022 年。

图 1.8:示例 3 工作流程。
我们现在已经探讨了三种高级机器学习系统设计,并讨论了我们工作流程选择背后的理由。我们还详细探讨了数据科学家在建模过程中通常会产生的代码类型,但这些代码将作为未来机器学习工程工作的输入。因此,本节应该让我们对在典型项目中我们的工程工作从哪里开始以及我们旨在解决哪些类型的问题有了认识。就这样,你已经开始了成为机器学习工程师的道路!
摘要
在本章中,我们介绍了机器学习工程的概念以及它如何适应基于数据的现代团队构建有价值的解决方案。讨论了机器学习工程的重点如何与数据科学和数据工程的优点相辅相成,以及这些学科的重叠之处。还提出了一些关于如何利用这些信息为你的项目组建适当资源的团队的评论。
随后讨论了在现代现实世界中构建机器学习产品的挑战,并提供了帮助你克服这些挑战的指导。特别是,强调了合理评估价值和有效与利益相关者沟通的观念。
本章随后通过讨论典型机器学习解决方案的外观以及它们应该如何设计(在高级别)来应对一些常见用例,为后续章节即将介绍的技术内容做了简要介绍。
在我们深入本书的其余部分之前,这些主题很重要,因为它们将帮助你理解为什么机器学习工程是一门如此关键的学科,以及它是如何与以数据为中心的团队和组织的复杂生态系统联系起来的。这也有助于让你了解机器学习工程所涵盖的复杂挑战,同时为你提供一些概念工具,以开始对这些挑战进行推理。我的希望是,这不仅激励你参与本版其余部分的材料,而且还能让你走上探索和自学之路,这对于成为一名成功的机器学习工程师是必需的。
下一章将重点介绍如何设置和实施你的开发流程来构建你想要的机器学习解决方案,并提供一些见解,说明这与标准软件开发流程有何不同。然后,将讨论一些你可以使用的工具,以开始管理你的项目任务和工件,而不会造成重大头痛。这将为你准备在后续章节中构建你的机器学习解决方案关键元素的技术细节。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第二章:机器学习开发过程
在本章中,我们将定义如何将任何成功的机器学习(ML)软件工程项目的工作进行划分。基本上,我们将回答您如何实际上组织一个成功的 ML 项目的问题。我们不仅将讨论流程和工作流程,还将为流程的每个阶段设置所需的工具,并通过真实的 ML 代码示例突出一些重要的最佳实践。
在这一版中,我们将更详细地介绍一个重要的数据科学和机器学习项目管理方法:跨行业标准数据挖掘流程(CRISP-DM)。这包括讨论这种方法与传统敏捷和瀑布方法的比较,并提供一些将此方法应用于您的机器学习项目的技巧和窍门。还有更多详细的示例,帮助您使用 GitHub Actions 开始持续集成/持续部署(CI/CD),包括如何运行专注于机器学习的流程,如自动模型验证。关于在交互式开发环境(IDE)中启动的建议也已经变得更加工具无关,以便使用任何合适的 IDE。与之前一样,本章将重点介绍我提出的“四步”方法,该方法涵盖了您的机器学习项目的发现、玩耍、开发、部署工作流程。这个项目工作流程将与在数据科学领域非常流行的 CRISP-DM 方法进行比较。我们还将讨论适当的发展工具及其配置和集成,以确保项目成功。我们还将涵盖版本控制策略及其基本实施,以及为您的机器学习项目设置 CI/CD。然后,我们将介绍一些潜在的执行环境,作为您的机器学习解决方案的目标目的地。到本章结束时,您将为您的 Python 机器学习工程项目成功做好准备。这是我们将在后续章节中构建一切的基础。
如往常一样,我们将通过总结本章的主要观点并强调在阅读本书其余部分时这些观点的意义来结束本章。
最后,值得注意的是,尽管我们在这里将讨论框架定为 ML 挑战,但本章中您将学到的许多内容也可以应用于其他 Python 软件工程项目。我的希望是,在详细构建这些基础概念的投资将能够让您在所有工作中反复利用。
我们将在以下章节和子章节中探讨所有这些内容:
-
设置我们的工具
-
从概念到解决方案的四个步骤:
-
发现
-
玩耍
-
开发
-
部署
-
有许多令人兴奋的内容需要消化和很多知识需要学习——让我们开始吧!
技术要求
如同 第一章,机器学习工程简介 中所述,如果您想运行这里提供的示例,您可以使用本书 GitHub 仓库 Chapter02 文件夹中提供的环境 YAML 文件创建一个 Conda 环境:
conda env create –f mlewp-chapter02.yml
此外,本章中的许多示例将需要使用以下软件和包。这些也将为你在本书其他部分的示例提供良好的基础:
-
Anaconda
-
PyCharm Community Edition,VS Code 或其他兼容 Python 的 IDE
-
Git
您还需要以下内容:
-
一个 Atlassian Jira 账户。我们将在本章后面进一步讨论这个问题,但您可以在
www.atlassian.com/software/jira/free免费注册一个账户。 -
一个 AWS 账户。这将在本章中讨论,但您可以在
aws.amazon.com/注册一个账户。注册 AWS 需要添加付款详情,但本书中我们将只使用免费层解决方案。
本章中的技术步骤都在运行 Ubuntu 22.04 LTS 的 Linux 机器上进行了测试,该机器有一个具有管理员权限的用户配置文件,以及一个按照 第一章,机器学习工程简介 中描述的设置运行的 Macbook Pro M2。如果您在运行不同系统上的步骤时,如果步骤没有按预期工作,您可能需要查阅该特定工具的文档。即使如此,大多数步骤对于大多数系统来说都将相同或非常相似。您还可以在本书的 GitHub 仓库github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition/tree/main/Chapter02中查看本章的所有代码。该仓库还将包含进一步的资源,以帮助您将代码示例运行起来。
设置我们的工具
为了准备本章以及本书其余部分的工作,设置一些工具将会很有帮助。从高层次来看,我们需要以下工具:
-
用于编码的地方
-
用于跟踪我们的代码更改的内容
-
用于帮助我们管理任务的内容
-
用于配置基础设施和部署我们的解决方案的地方
让我们逐一看看如何处理这些问题:
-
编写代码的地方:首先,尽管数据科学家选择编码的武器当然是 Jupyter Notebook,但一旦您开始向 ML 工程转型,拥有一个 IDE 将变得非常重要。IDE 基本上是一个包含一系列内置工具和功能的应用程序,可以帮助您开发出最好的软件。PyCharm是 Python 开发者的一个优秀例子,它提供了许多对 ML 工程师有用的插件、附加组件和集成。您可以从 JetBrains 下载社区版,网址为
www.jetbrains.com/pycharm/。另一个流行的开发工具是轻量但强大的源代码编辑器 VS Code。一旦您成功安装了 PyCharm,您可以从欢迎使用 PyCharm窗口创建一个新项目或打开一个现有项目,如图图 2.1所示:![图 2.1 – 打开或创建您的 PyCharm 项目]()
图 2.1:打开或创建您的 PyCharm 项目。
-
跟踪代码变更的内容:接下来在列表中是代码版本控制系统。在这本书中,我们将使用GitHub,但基于相同底层开源Git技术的解决方案有很多,所有这些解决方案都是免费提供的。后面的章节将讨论如何将这些工具作为您开发工作流程的一部分,但首先,如果您还没有设置版本控制系统,您可以导航到github.com并创建一个免费账户。按照网站上的说明创建您的第一个仓库,您将看到一个类似于图 2.2的屏幕。为了使您的生活更轻松,您应该选择添加 README 文件和添加.gitignore(然后选择Python)。README 文件为您提供了一个初始的 Markdown 文件,以便您开始使用,并描述您的项目。
.gitignore文件告诉您的 Git 分布忽略某些类型的文件,这些文件通常对版本控制不重要。您可以选择将仓库设置为公开或私有,以及您希望使用的许可证。这本书的仓库使用MIT 许可证:![图 2.2 – 设置您的 GitHub 仓库]()
图 2.2:设置您的 GitHub 仓库。
一旦您设置了 IDE 和版本控制系统,您需要通过使用 PyCharm 提供的 Git 插件使它们相互通信。这就像导航到VCS | 启用版本控制集成并选择Git一样简单。您可以通过导航到文件 | 设置 | 版本 控制来编辑版本控制设置;请参阅图 2.3:
![图 2.3 – 使用 PyCharm 配置版本控制]()
图 2.3:使用 PyCharm 配置版本控制。
-
一些帮助我们管理任务的东西:您现在可以编写 Python 代码并跟踪您的代码更改,但您准备好与其他团队成员一起管理或参与一个复杂的项目了吗?为此,拥有一个可以跟踪任务、问题、错误、用户故事和其他文档和工作项的解决方案通常很有用。如果这个解决方案与其他您将使用的工具有良好的集成点,那就更好了。在这本书中,我们将使用Jira作为这个示例。如果您导航到
www.atlassian.com/software/jira,您可以创建一个免费的云 Jira 账户,然后在该解决方案中遵循交互式教程来设置您的第一个看板并创建一些任务。图 2.4显示了本书项目(称为Python 机器学习工程,MEIP)的任务板:![]()
图 2.4:本书在 Jira 中的任务板。
-
一个用于部署基础设施和部署我们的解决方案的地方:您刚刚安装和设置的一切都是工具,这些工具将真正帮助您将工作流程和软件开发实践提升到下一个层次。最后一部分是拥有部署最终解决方案所需的工具、技术和基础设施。为应用程序管理计算基础设施(过去和现在通常仍然是)提供专门的团队,但随着公共云的出现,这种能力对于从事软件各个角色的员工来说已经实现了真正的民主化。特别是,现代机器学习工程非常依赖于云计算技术的成功实施,通常是通过主要的公共云提供商,如亚马逊网络服务(AWS)、微软 Azure或谷歌云平台(GCP)。本书将利用 AWS 生态系统中的工具,但您在这里找到的所有工具和技术在其他云中都有等效项。
云带来的能力民主化的一面是,拥有其解决方案部署权的团队必须掌握新的技能和理解。我坚信“你建它,你拥有它,你运行它”的原则,但这意味着作为一个机器学习工程师,您将不得不熟悉大量潜在的新工具和原则,以及拥有您部署的解决方案的性能。权力越大,责任越大,诸如此类。在第五章,部署模式和工具中,我们将详细探讨这个话题。
让我们来谈谈如何设置它。
设置 AWS 账户
如前所述,您不必使用 AWS,但我们将在这本书的整个过程中使用它。一旦在这里设置好,您就可以用它来做我们将会做的所有事情:
-
让我们使这更加具体。每个阶段的主要焦点和输出可以总结如下,如图 2.1 表所示:
-
一旦您创建了账户,您就可以导航到 AWS 管理控制台,在那里您可以查看所有可用的服务(见图 2.5):

图 2.5:AWS 管理控制台。
在我们的 AWS 账户准备就绪后,让我们看看涵盖整个过程的四个步骤。
| 阶段 | 输出 |
所有机器学习项目在某种程度上都是独特的:组织、数据、人员、使用的工具和技术在任何两个项目中都不会完全相同。这是好事,因为它标志着进步,以及使这个领域如此有趣的自然多样性。
话虽如此,无论细节如何,从广义上讲,所有成功的机器学习项目实际上有很多共同之处。它们需要将业务问题转化为技术问题,进行大量的研究和理解,概念验证,分析,迭代,工作整合,最终产品的构建,以及将其部署到适当的环境。这就是机器学习工程的精髓!
进一步发展这一点,您可以将这些活动开始归类为粗略的类别或阶段,每个阶段的成果都是后续阶段必要的输入。这如图 2.6 所示:

图 2.6:任何机器学习项目在机器学习开发过程中所经历的各个阶段。
每个工作类别都有其独特的风味,但综合起来,它们构成了任何良好机器学习项目的骨架。接下来的几节将详细阐述每个类别的细节,并开始展示如何使用它们来构建您的机器学习工程解决方案。正如我们稍后将要讨论的,您也不必像这样分四步完成整个项目;您实际上可以为特定功能或整体项目的一部分逐个完成这些步骤。这将在选择软件开发方法部分中介绍。
要设置 AWS 账户,请导航到aws.amazon.com并选择创建账户。您需要添加一些付款详情,但本书中提到的所有内容都可以通过 AWS 的免费层进行探索,在那里您不会因消费低于一定阈值而产生费用。
| 四步从概念到解决方案 |
|---|
| 阶段 |
| 阶段 |
| 开发 |
| 部署 |
表 2.1:机器学习开发过程不同阶段的输出。
重要提示
你可能会认为机器学习工程师只需要真正考虑后两个阶段,开发和部署,而早期阶段由数据科学家或甚至业务分析师负责。我们确实会在整本书中主要关注这些阶段,并且这种劳动分工可以非常有效。然而,如果你打算构建一个机器学习解决方案,理解所有之前的动机和开发步骤至关重要——你不了解你想要去哪里,难道会建造一种新的火箭吗?
与 CRISP-DM 的比较
我们将在本章的其余部分概述的项目步骤的高级分类与一个重要的方法论 CRISP-DM 有许多相似之处,也有一些不同。这个方法论于 1999 年发布,自那时起,它已成为理解如何构建任何数据项目的一种方式。在 CRISP-DM 中,有六个不同的活动阶段,涵盖了与上一节中描述的四个步骤类似的内容:
-
业务理解:这全部关于了解业务问题和领域。在四步模型中,这成为发现阶段的一部分。
-
数据理解:将业务领域的知识扩展到包括数据的状态、其位置以及它与问题的相关性。这也包括在发现阶段。
-
数据准备:开始获取数据并将其转换为下游使用。这通常需要迭代。在玩耍阶段进行捕捉。
-
建模:对准备好的数据进行处理,并在其上开发分析;这现在可能包括不同复杂程度的机器学习。这是一个在四步方法论中的玩耍和开发阶段都会发生的活动。
-
评估:这一阶段关注的是确认解决方案是否满足业务需求,并对之前的工作进行全面的审查。这有助于确认是否有什么被忽略或可以改进的地方。这非常是开发和部署阶段的一部分;在我们本章将描述的方法论中,这些任务在整个项目中都得到了很好的整合。
-
部署:在 CRISP-DM 中,这最初是专注于部署简单的分析解决方案,如仪表板或计划中的 ETL 管道,这些管道将运行已决定的分析模型。
在模型机器学习工程的世界里,这一阶段可以代表这本书中提到的任何内容!CRISP-DM 建议在规划和审查部署方面有子阶段。
如您从列表中看到的,CRISP-DM 中的许多步骤涵盖了与我提出的四个步骤中概述的类似主题。CRISP-DM 在数据科学社区中非常受欢迎,因此其优点肯定得到了全世界大量数据专业人士的认可。鉴于这一点,您可能会想,“为什么还要开发其他的东西呢?”让我说服您为什么这是一个好主意。
CRISP-DM 方法论只是将任何数据项目的重要活动分组以提供结构的一种方式。您可能从上面我给出的阶段简要描述中看到,如果您进行进一步的研究,CRISP-DM 在用于现代机器学习工程项目的使用中可能存在一些潜在的缺点:
-
CRISP-DM 中概述的过程相对僵化且相当线性。这可以为提供结构带来好处,但可能会阻碍项目中的快速进展。
-
该方法非常重视文档。大多数步骤都详细说明了编写某种类型的报告、审查或总结。在项目中编写和维护良好的文档至关重要,但过度编写文档也可能存在风险。
-
CRISP-DM 是在“大数据”和大规模机器学习出现之前的世界中编写的。对我来说,不清楚其细节是否仍然适用于这样一个不同的世界,在那里经典的提取-转换-加载模式只是众多模式之一。
-
CRISP-DM 无疑源自数据世界,并在最后阶段试图向可部署解决方案的概念迈进。这是值得赞扬的,但在我看来,这还不够。机器学习工程是一个不同的学科,因为它与经典软件工程的距离远比接近。这本书将反复论证这一点。因此,拥有一个将部署和开发的概念与软件和现代机器学习技术完全一致的方法非常重要。
四步法试图缓解这些挑战,并以不断参考软件工程和机器学习技能和技术的方式进行。这并不意味着你永远不应该在你的项目中使用 CRISP-DM;它可能正是完美的选择!就像这本书中介绍的大多数概念一样,重要的是要拥有许多工具在你的工具箱中,以便你可以选择最适合当前工作的那个。
因此,现在让我们详细地过一遍这四个步骤。
发现
在开始构建任何解决方案之前,了解你试图解决的问题至关重要。这项活动在商业分析中通常被称为发现,如果你的机器学习项目要取得成功,这是至关重要的。
在发现阶段需要做的关键事情如下:
-
与客户沟通!然后再与他们沟通:如果你要设计和构建正确的系统,你必须详细了解最终用户的需求。
-
记录一切:你将根据你满足要求的好坏来评判,所以请确保你的讨论中的所有关键点都得到了团队成员和客户或其适当代表的记录和批准。
-
定义重要的指标:在项目开始时,很容易被冲昏头脑,感觉自己可以用即将构建的神奇新工具解决任何问题。尽可能强烈地抵制这种倾向,因为它很容易在以后造成严重的头痛。相反,将你的对话引导到定义一个或非常少的指标,这些指标定义了成功将是什么样子。
-
开始找出数据在哪里!:如果你能开始确定你需要访问哪些系统来获取所需的数据,这将节省你以后的时间,并有助于你在项目脱轨之前发现任何重大问题。
使用用户故事
一旦你与客户(几次)交谈过,你就可以开始定义一些用户故事。用户故事是对用户或客户想要看到的内容以及该功能或工作单元的验收标准的简洁且格式一致的表述。例如,我们可能想根据第一章,机器学习工程简介中的出租车行程示例定义一个用户故事:“作为我们内部网络服务的用户,我希望看到异常的出租车行程,并能够进一步调查。”
让我们开始吧!
-
要在 Jira 中添加此内容,请选择创建按钮。
-
然后,选择故事。
-
然后,根据需要填写细节。
你现在已经将一个用户故事添加到你的工作管理工具中!这让你可以做诸如创建新任务并将它们链接到这个用户故事或更新其状态等事情:

图 2.7:Jira 中的一个示例用户故事。
你使用的数据源尤其重要,需要理解。正如你所知,“垃圾进,垃圾出”,或者更糟,“没有数据,就没有进展”!你必须回答的数据的特定问题主要集中在访问、技术、质量和相关性上。
对于访问和技术,你试图预先了解数据工程师开始他们的工作流程需要做多少工作,以及这会耽误整个项目多少时间。因此,正确地完成这一点至关重要。
一个很好的例子是,如果你很快发现你需要的绝大部分数据都存在于没有真正现代 API 和没有非财务团队成员访问请求机制的遗留内部财务系统中。如果其主要后端是本地部署的,你需要将锁定在云端的财务数据迁移过来,但这会让你的业务感到紧张,那么你知道在编写第一行代码之前还有很多工作要做。如果数据已经存在于你的团队可以访问的企业数据湖中,那么你显然处于更好的位置。如果价值主张足够强大,任何挑战都是可以克服的,但尽早找出这些情况将为你节省时间、精力和金钱。
在启动之前,相关性可能更难找到,但你可以开始形成一些想法。例如,如果你想执行我们在第一章,机器学习工程导论中讨论的库存预测,你是否需要拉取客户账户信息?如果你想创建高端或非高端客户的分类器,作为营销目标,这也如第一章,机器学习工程导论中提到的,你是否需要社交媒体数据?关于相关性的问题通常不会像这些例子那样明确,但一个重要的事情要记住的是,如果你真的错过了什么重要的东西,你总是可以回过头来。你试图尽早捕捉到最重要的设计决策,所以常识和大量的利益相关者和领域专家参与将大有裨益。
在项目前进之前,你可以尝试通过向当前的数据用户或消费者或参与其输入过程的人员提出一些问题来预测数据质量。但要获得更定量的理解,你通常只需要让你的数据科学家以动手的方式与数据一起工作。
在下一节中,我们将探讨如何在最具研究密集性的阶段,即Play阶段,开发概念验证机器学习解决方案。
Play
在项目的play阶段,你的目标是确定即使在概念验证级别解决任务是否可行。为此,你可能会在创建满足你需求的机器学习模型之前,采用我们在上一章中提到的常规数据科学技术,如探索性数据分析和解释性建模。
在这个流程的这一部分,你不必过分关注实现的细节,而是要探索可能性领域,并深入理解数据和问题,这超出了初步发现工作。由于这里的目的是不创建生产就绪的代码或构建可重用的工具,因此你不必担心你编写的代码是否质量最高,或者是否使用了复杂的模式。例如,看到以下示例(实际上是从本书的 repo 中摘取的)的代码并不罕见:

图 2.8:在游戏阶段将创建的一些示例原型代码。
只需快速浏览这些截图,就能告诉你一些事情:
-
代码位于 Jupyter 笔记本中,由用户在网页浏览器中交互式运行。
-
代码偶尔会调用方法来简单地检查或探索数据元素(例如,
df.head()和df.dtypes)。 -
对于绘图(而且它并不直观!)有专门的代码。
-
有一个名为
tmp的变量,描述性不强。
所有这些在这个更探索性的阶段都是绝对可以接受的,但本书的一个目标就是帮助你理解将此类代码转化为适合你生产机器学习管道所需的要素。下一节将开始引导我们走上这条道路。
开发
正如我们之前已经提到几次,本书的一个目标就是让你思考这样一个事实:你正在构建的软件产品恰好包含了机器学习。这意味着对于我们这些来自更数学和算法背景的人来说,学习曲线可能会很陡峭。这可能会让人感到害怕,但不要绝望!好消息是,我们可以重用几十年来软件工程社区锤炼的许多最佳实践和技术。太阳之下无新事。
本节探讨了在机器学习工程项目的开发阶段可以采用的一些方法、流程和考虑因素。
选择软件开发方法
我们作为机器学习工程师,可以而且应该毫无顾忌地复制全球项目中使用的软件开发方法。这些方法中的一类,通常被称为瀑布模型,涵盖了适合构建复杂事物的项目工作流程(想想建筑或汽车)。在瀑布模型中,有明确且顺序性的工作阶段,每个阶段都有在进入下一阶段之前所需的明确输出。例如,典型的瀑布项目可能包含涵盖需求收集、分析、设计、开发、测试和部署等阶段(听起来熟悉吗?)。关键在于,在瀑布风格的项目中,当你处于需求收集阶段时,你应该只专注于收集需求,当处于测试阶段时,你应该只专注于测试,依此类推。在介绍另一套方法之后,我们将在接下来的几段中讨论这种方法在机器学习中的优缺点。
另一套方法,称为敏捷,是在 2001 年敏捷宣言(agilemanifesto.org/)发布之后出现的。敏捷开发的核心理念是灵活性、迭代、增量更新、快速失败和适应变化的需求。如果你来自研究或科学背景,这种基于结果和新发现灵活性和适应性的概念可能听起来很熟悉。
如果你具有这种科学或学术背景,可能不太熟悉的是,你仍然可以在一个以交付结果为中心的相对严格的框架内接受这些概念。敏捷软件开发方法的核心是寻找实验和交付之间的平衡。这通常通过引入仪式(如Scrum和Sprint回顾)和角色(如Scrum Master和产品负责人)来实现。
此外,在敏捷开发中,有两种非常流行的变体:Scrum和Kanban。Scrum 项目围绕称为Sprint的短期工作单元展开,其理念是在这个短暂的时间内从构思到部署对产品的添加。在 Kanban 中,主要理念是实现从有序的待办事项到进行中工作,再到完成工作的稳定流程。
所有这些方法(以及更多)都有其优点和缺点。你不必对其中任何一种方法产生依赖;你可以在它们之间随意切换。例如,在一个机器学习项目中,进行一些部署后的工作可能是有意义的,这些工作专注于维护现有的服务(有时被称为常规业务活动),例如进一步改进模型或软件优化在看板框架中。在 Sprint 中明确结果的主要交付可能是有意义的。但你可以随意切换,看看哪种最适合你的用例、你的团队和你的组织。
但将这类工作流程应用于机器学习项目有什么不同?在这个机器学习的世界中,我们需要考虑哪些以前没有考虑过的问题?好吧,一些关键点如下:
-
你不知道你不知道的:在你看到数据之前,你无法知道你是否能够解决问题。传统的软件工程不像机器学习工程那样严重依赖于将通过系统的数据。原则上我们可以知道如何解决问题,但如果适当的数据数量不足或质量差,那么在实践中我们无法解决问题。
-
你的系统是活生生的:如果你构建了一个经典的网站,拥有其后端数据库、闪亮的用户界面、惊人的负载均衡和其他功能,那么实际上,如果资源存在,它可以永远运行。网站及其运行方式在时间上不会发生根本性的变化。点击仍然会被转换成操作,页面导航仍然以相同的方式进行。现在,考虑在其中加入一些基于典型用户档案的机器学习生成的广告内容。什么是典型用户档案,它会随时间变化吗?随着流量和用户的增加,我们以前从未见过的行为是否变成了新常态?你的系统一直在学习,这导致了模型漂移和分布偏移的问题,以及更复杂的更新和回滚场景。
-
没有什么是确定的:在构建使用基于规则的逻辑的系统时,你知道每次会得到什么。如果 X,那么 Y的意思就是如此,始终如此。对于机器学习模型,当你提问时,往往很难知道答案是什么,这正是这些算法之所以强大的原因。
但这也意味着你可能会有不可预测的行为,无论是由于之前讨论的原因,还是因为算法学习到了人类观察者不明显的数据信息,或者,因为机器学习算法可以基于概率和统计概念,结果会附带一些不确定性或模糊性。一个经典的例子是当你应用逻辑回归并收到数据点属于某一类别的概率时。这是一个概率值,你不能确定地说它是这种情况;只是有多大的可能性!这在你的机器学习系统的输出将被用户或其他系统用于做出决策时尤其重要。
考虑到这些问题,在下一节中,我们将尝试了解哪些开发方法论可以帮助我们在构建机器学习解决方案时。在表 2.2中,我们可以看到这些敏捷方法论在不同阶段和类型的机器学习(ML)工程项目中的优缺点:
| 方法论 | 优点 | 缺点 |
|---|---|---|
| 敏捷 | 预期具有灵活性,更快的开发到部署周期。 | 如果管理不善,容易发生范围漂移。看板或冲刺可能不适合某些项目。 |
| 水晶球模型 | 清晰的部署路径,明确任务的阶段和所有权。 | 缺乏灵活性,更高的管理开销。 |
表 2.2:敏捷与水晶球模型在机器学习(ML)开发中的应用。
让我们继续下一节!
包管理(conda 和 pip)
如果我告诉你编写一个不使用任何库或包,仅使用纯 Python 进行数据科学或机器学习(ML)的程序,你可能觉得在合理的时间内完成这项任务非常困难,而且极其无聊!这是好事。在 Python 中开发软件的一个真正强大的特性是,你可以相对容易地利用一个广泛的工具和功能生态系统。另一方面,这也意味着管理代码库的依赖可能会变得非常复杂且难以复制。这就是包和环境管理器如pip和conda发挥作用的地方。
pip是 Python 的标准包管理器,也是 Python 包权威机构推荐使用的。
它从PyPI,即Python 包索引中检索和安装 Python 包。pip使用非常简单,通常在教程和书籍中建议作为安装包的方式。
conda是 Anaconda 和 Miniconda Python 发行版附带的一个包和环境管理器。conda的一个关键优势是,尽管它来自 Python 生态系统,并且在那里具有出色的功能,但它实际上是一个更通用的包管理器。因此,如果你的项目需要 Python 之外的依赖(例如 NumPy 和 SciPy 库是很好的例子),尽管pip可以安装这些依赖,但它无法跟踪所有非 Python 依赖,也无法管理它们的版本。使用conda,这个问题就解决了。
您也可以在 conda 环境中使用 pip,因此您可以同时获得两者的最佳之处,或者使用您项目所需的任何工具。我通常使用的典型工作流程是使用 conda 管理我创建的环境,然后使用它来安装任何可能需要非 Python 依赖项的包,这些依赖项可能没有在 pip 中得到很好的处理,然后我可以在创建的 conda 环境中使用 pip 大部分时间。鉴于这一点,在本书中,您可能会看到 pip 或 conda 安装命令交替使用。这是完全可以接受的。
要开始使用 Conda,如果您还没有,您可以从 Anaconda 网站下载 个人 分发安装程序(www.anaconda.com/products/individual)。Anaconda 已经预装了一些 Python 包,但如果您想从一个完全空的环境开始,您可以从同一网站下载 Miniconda(它们具有完全相同的功能;只是您从不同的基础开始)。
Anaconda 文档对于您熟悉适当的命令非常有帮助,但这里简要介绍一下其中的一些关键命令。
首先,如果我们想创建一个名为 mleng 的 conda 环境并安装 Python 3.8 版本,我们只需在我们的终端中执行以下命令:
conda env --name mleng python=3.10
然后,我们可以通过运行以下命令来激活 conda 环境:
source activate mleng
这意味着任何新的 conda 或 pip 命令都将在此环境中安装包,而不是系统范围内。
我们经常希望与他人共享我们环境的详细信息,以便他们可以在同一项目中工作,因此将所有包配置导出到 .yml 文件中可能很有用:
conda export env > environment.yml
本书 GitHub 仓库中包含一个名为 mleng-environment.yml 的文件,您可以使用此文件创建 mleng 环境的实例。以下命令使用此文件创建具有此配置的环境:
conda env create --file environment.yml
从环境文件创建 conda 环境的模式是设置书中每一章示例运行环境的一个好方法。因此,每一章的 技术要求 部分将指向书中仓库中包含的正确环境 YAML 文件名称。
这些命令与您经典的 conda 或 pip install 命令结合使用,将为您的项目设置得相当好!
conda install <package-name>
或者
pip install <package-name>
我认为拥有多种执行某事的方法总是好的,而且通常这是良好的工程实践。因此,既然我们已经介绍了经典的 Python 环境、conda 和 pip 包管理器,我们将介绍另一个包管理器。这是一个我喜欢其易用性和多功能性的工具。我认为它为 conda 和 pip 提供了很好的功能扩展,并且可以很好地补充它们。这个工具叫做 Poetry,我们现在就转向它。
Poetry
Poetry 是另一种近年来变得非常流行的包管理器。它允许您以类似于我们在 Conda 部分讨论的环境 YAML 文件的方式,将项目的依赖项和包信息管理到一个单独的配置文件中。Poetry 的优势在于其远超其他工具的复杂依赖项管理能力,并确保“确定性”构建,这意味着您不必担心包在后台更新而破坏您的解决方案。它是通过使用“锁定文件”作为核心功能以及深入的依赖项检查来实现的。这意味着在 Poetry 中,可重复性通常更容易实现。重要的是要指出,Poetry 专注于特定的 Python 包管理,而 Conda 也可以安装和管理其他包,例如 C++ 库。可以这样理解 Poetry:它就像是 pip Python 安装包的升级版,但同时也具备一些环境管理功能。接下来的步骤将解释如何设置和使用 Poetry 以进行非常基本的用例。
我们将在本书的一些后续示例中继续这一内容。首先,按照以下步骤操作:
-
首先,像往常一样,我们将安装 Poetry:
pip install poetry -
安装 Poetry 后,您可以使用
poetry new命令创建一个新的项目,后面跟上是您项目的名称:poetry new mleng-with-python -
这将创建一个名为
mleng-with-python的新目录,其中包含 Python 项目的必要文件和目录。要管理您项目的依赖项,您可以将它们添加到项目根目录下的pyproject.toml文件中。此文件包含您项目的所有配置信息,包括其依赖项和包元数据。例如,如果您正在构建一个机器学习项目并想使用
scikit-learn库,您会在pyproject.toml文件中添加以下内容:[tool.poetry.dependencies] scikit-learn = "*" -
然后,您可以通过运行以下命令安装您项目的依赖项。这将安装
scikit-learn库以及您在pyproject.toml文件中指定的任何其他依赖项:poetry install -
要在您的项目中使用依赖项,您只需像这样在 Python 代码中导入它即可:
from sklearn import datasets from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression
如您所见,开始使用 Poetry 非常简单。我们将在本书中多次使用 Poetry,以便为您提供与我们将开发的 Conda 知识相补充的示例。第四章,打包,将详细讨论这一点,并展示如何充分利用 Poetry。
代码版本控制
如果你将要为真实系统编写代码,你几乎肯定将会作为团队的一部分来做。如果你能够有一个清晰的变更、编辑和更新审计记录,那么你会更容易看到解决方案是如何发展的。最后,你将想要干净且安全地将你正在构建的稳定版本与可以部署的版本以及更短暂的开发版本分开。幸运的是,所有这些都可以由源代码版本控制系统来处理,其中最受欢迎的是Git。
我们不会在这里深入探讨 Git 底层是如何工作的(关于这个话题有整本书的讨论!)但我们将会关注理解使用 Git 的关键实践要素:
-
你在章节的早期已经有一个 GitHub 账户了,所以首先要做的是创建一个以 Python 为语言的仓库,并初始化
README.md和.gitignore文件。接下来要做的是通过在 Bash、Git Bash 或其他终端中运行以下命令来获取这个仓库的本地副本:git clone <repo-name> -
现在你已经完成了这个步骤,进入
README.md文件并进行一些编辑(任何内容都可以)。然后,运行以下命令来告诉 Git 监控这个文件,并使用简短的消息保存你的更改:git add README.md git commit -m "I've made a nice change …"这现在意味着你的本地 Git 实例已经存储了你所做的更改,并准备好与远程仓库共享这些更改。
-
你可以通过以下步骤将这些更改合并到
main分支:git push origin main如果你现在回到 GitHub 网站,你会看到你的远程仓库中发生了更改,你添加的评论也伴随着这个更改。
-
你的团队成员可以通过运行以下命令来获取更新的更改:
git pull origin main
这些步骤是 Git 的绝对基础,你可以在网上学到更多。不过,我们现在要做的是以与机器学习工程相关的方式设置我们的仓库和工作流程。
Git 策略
在一个项目中,使用版本控制系统的策略通常可以成为区分数据科学和机器学习工程方面的重要因素。在探索性和基本建模阶段(发现和玩耍)定义严格的 Git 策略可能有些过度,但如果你想要为部署构建某些内容(而且你正在阅读这本书,所以这很可能是你的目标),那么这基本上是非常重要的。
很好,但我们所说的 Git 策略是什么意思呢?
好吧,让我们假设我们试图在没有共享版本组织和代码组织方向的情况下开发我们的解决方案。
机器学习工程师A想要开始将一些数据科学代码构建到 Spark ML 管道中(关于这一点稍后会有更多介绍),因此从main分支创建了一个名为pipeline1spark的分支:
git checkout -b pipeline1spark
他们然后开始在这个分支上工作,并在一个名为pipeline.py的新文件中编写了一些优秀的代码:
# Configure an ML pipeline, which consists of three stages: tokenizer, hashingTF, and lr.
tokenizer = Tokenizer(inputCol="text", outputCol="words")
hashingTF = HashingTF(inputCol=tokenizer.getOutputCol(),
outputCol="features")
lr = LogisticRegression(maxIter=10, regParam=0.001)
pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
太好了,他们已经将一些之前的 sklearn 代码翻译成了 Spark,这被认为更适合用例。然后他们继续在这个分支上工作,因为它包含了他们所有的添加,他们认为最好在一个地方完成所有工作。当他们想要将分支推送到远程仓库时,他们运行以下命令:
git push origin pipeline1spark
机器学习工程师 B 加入进来,他们想使用机器学习工程师 A 的管道代码,并在其周围构建一些额外的步骤。他们知道工程师 A 的代码有一个包含这项工作的分支,所以他们足够了解 Git,可以创建一个包含 A 代码的另一个分支,B 称之为 pipeline:
git pull origin pipeline1spark
git checkout pipeline1spark
git checkout -b pipeline
他们接着添加了一些代码来从变量中读取模型的参数:
lr = LogisticRegression(maxIter=model_config["maxIter"],
regParam=model_config["regParam"])
很酷,工程师 B 做了一个更新,开始抽象掉一些参数。然后他们把新的分支推送到远程仓库:
git push origin pipeline
最后,机器学习工程师 C 加入到团队中,并想开始编写代码。打开 Git 并查看分支,他们看到有三个:
main
pipeline1spark
pipeline
那么,哪个应该被视为最新的?如果他们想进行新的编辑,他们应该从哪里分支?这并不清楚,但更危险的是,如果他们被要求将部署代码推送到执行环境,他们可能会认为 main 包含了所有相关更改。在一个已经进行了很长时间且非常繁忙的项目中,他们甚至可能会从 main 分支中分支出来,并复制一些 B 和 C 的工作!在一个小项目中,你会浪费时间进行这种无谓的追逐;在一个大项目中有许多不同的工作线,你几乎不可能保持良好的工作流程:
# Branch pipeline1spark - Commit 1 (Engineer A)
lr = LogisticRegression(maxIter=10, regParam=0.001)
pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
# Branch pipeline - Commit 2 (Engineer B)
lr = LogisticRegression(maxIter=model_config["maxIter"],
regParam=model_config["regParam"])
pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
如果这两个提交同时推送到 main 分支,那么我们就会得到所谓的合并冲突,在这种情况下,工程师将不得不选择保留哪段代码,是当前的还是新的示例。如果工程师 A 首先将其更改推送到 main,这看起来可能如下所示:
<<<<<<< HEAD
lr = LogisticRegression(maxIter=10, regParam=0.001)
pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
=======
lr = LogisticRegression(maxIter=model_config["maxIter"],
regParam=model_config["regParam"])
pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
>>>>>>> pipeline
代码中的分隔符表明存在合并冲突,并且取决于开发者选择保留哪两个代码版本。
重要提示
尽管在这个简单的情况下,我们可能可以信任工程师选择更好的代码,但允许这种情况频繁发生对你的项目来说是一个巨大的风险。这不仅会浪费大量宝贵的发展时间,还可能意味着你最终得到的代码质量更差!
避免混淆和额外工作的方法是制定一个非常清晰的策略,用于实施版本控制系统,例如我们现在将要探讨的策略。
Gitflow 工作流程
之前示例的最大问题是,我们假设的所有工程师实际上都在不同地方工作着同一块代码。为了阻止这种情况发生,你必须创建一个团队都可以遵循的过程——换句话说,一个版本控制策略或工作流程。
这些策略中最受欢迎之一是Gitflow 工作流程。它基于拥有专门用于功能的分支的基本想法,并将其扩展到包含发布和热修复的概念,这对于具有持续部署元素的项目尤其相关。
主要思想是我们有几种类型的分支,每种分支都有明确和具体的存在原因:
-
主分支包含您的官方发布版本,应只包含代码的稳定版本。
-
开发分支是大多数仓库中从其分支和合并到的主要点;它包含代码库的持续开发,并在
main之前作为预发布区域。 -
功能分支不应直接合并到
main分支;所有内容都应该从dev分支开始,然后合并回dev。 -
发布分支从
dev创建,在合并到main和dev并删除之前启动构建或发布过程。 -
热修复分支用于从已部署或生产软件中移除错误。您可以在合并到
main和dev之前从main分支创建此分支。
所有这些都可以用图 2.9 进行图解总结,该图显示了不同分支在 Gitflow 工作流程中如何贡献于代码库的演变:

图 2.9:Gitflow 工作流程。
此图来自lucamezzalira.com/2014/03/10/git-flow-vs-github-flow/。更多详情可以在www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow找到。
如果您的机器学习项目可以遵循这种策略(如果您想进行适应性调整,则不需要对此过于严格),您可能会看到生产率、代码质量和甚至文档的显著提高:
图 2.10:GitHub 上拉取请求的示例代码更改。
我们尚未讨论的一个重要方面是代码审查的概念。这些审查通过所谓的拉取请求触发,您通过拉取请求表明您合并到另一个分支的意图,并允许其他团队成员在执行之前审查您的代码。这是将代码审查引入工作流程的自然方式。您可以在想要合并更改并更新到 dev 或 main 分支时进行此操作。建议的更改然后可以呈现给整个团队,他们可以在合并完成之前通过进一步的提交进行辩论和迭代。
这强制进行代码审查以提高质量,同时创建审计跟踪和更新保障。图 2.10展示了在 GitHub 拉取请求期间如何使代码更改可见以供辩论的示例。
现在我们已经讨论了一些将版本控制应用于您的代码的最佳实践,让我们探索如何在机器学习项目中实现模型的版本控制。
模型版本控制
在任何机器学习工程项目中,您不仅要清晰地跟踪代码更改,还要跟踪模型的变化。您希望跟踪的不仅仅是建模方法的变化,还包括当新的或不同的数据输入到您选择的算法中时的性能变化。跟踪这些变化并提供机器模型版本控制的最佳工具之一是MLflow,这是一个由 Linux 基金会监管的开源平台,由Databricks提供。
要安装 MLflow,请在您选择的 Python 环境中运行以下命令:
pip install mlflow
MLflow 的主要目标是提供一个平台,通过该平台您可以记录模型实验、工件和性能指标。它是通过 Python mlflow库提供的非常简单的 API 实现的,通过一系列中央开发社区插件与选定的存储解决方案接口。它还提供了通过图形用户界面(GUI)查询、分析和导入/导出数据的功能,其外观类似于图 2.11:

图 2.11:MLflow 跟踪服务器 UI 和一些预测运行。
图书馆的使用极其简单。以下示例中,我们将从第一章,机器学习工程导论中的销售预测示例开始,并添加一些基本的 MLflow 功能以跟踪性能指标和保存训练好的 Prophet 模型:
-
首先,我们进行相关的导入,包括 MLflow 的
pyfunc模块,它作为可以编写为 Python 函数的模型的保存和加载的通用接口。这有助于与 MLflow 原生不支持(如fbprophet库)的库和工具一起工作:import pandas as pd from fbprophet import Prophet from fbprophet.diagnostics import cross_validation from fbprophet.diagnostics import performance_metrics import mlflow import mlflow.pyfunc -
为了与
fbprophet的预测模型实现更无缝的集成,我们定义了一个小的包装类,它继承自mlflow.pyfunc.PythonModel对象:class FbProphetWrapper(mlflow.pyfunc.PythonModel): def __init__(self, model): self.model = model super().__init__() def load_context(self, context): from fbprophet import Prophet return def predict(self, context, model_input): future = self.model.make_future_dataframe( periods=model_input["periods"][0]) return self.model.predict(future)现在我们将训练和预测的功能封装到一个名为
train_predict()的单个辅助函数中,以便简化多次运行。我们不会在这里定义这个函数的所有细节,但让我们浏览一下其中包含的 MLflow 功能的主要部分。 -
首先,我们需要让 MLflow 知道我们现在开始一个希望跟踪的训练运行:
with mlflow.start_run(): # Experiment code and mlflow logging goes in here -
在这个循环内部,我们定义并训练模型,使用代码中其他地方定义的参数:
# create Prophet model model = Prophet( yearly_seasonality=seasonality_params['yearly'], weekly_seasonality=seasonality_params['weekly'], daily_seasonality=seasonality_params['daily'] ) # train and predict model.fit(df_train) -
然后,我们进行交叉验证来计算我们想要记录的一些指标:
# Evaluate Metrics df_cv = cross_validation(model, initial="730 days", period="180 days", horizon="365 days") df_p = performance_metrics(df_cv) -
我们可以将这些指标记录下来,例如,这里的均方根误差(RMSE),到我们的 MLflow 服务器:
# Log parameter, metrics, and model to MLflow mlflow.log_metric("rmse", df_p.loc[0, "rmse"]) -
最后,我们可以使用我们的模型包装类来记录模型并打印一些关于运行的信息:
mlflow.pyfunc.log_model("model", python_model=FbProphetWrapper(model)) print( "Logged model with URI: runs:/{run_id}/model".format( run_id=mlflow.active_run().info.run_id ) ) -
只需添加几行额外的代码,我们就已经开始对我们的模型进行版本控制并跟踪不同运行状态的统计数据了!
将你构建的机器学习模型保存到 MLflow(以及一般情况)有许多不同的方法,这在跟踪模型版本时尤其重要。以下是一些主要选项:
-
pickle:
pickle是一个用于对象序列化的 Python 库,通常用于导出在scikit-learn或更广泛的scipy生态系统中的管道。尽管它非常容易使用且通常非常快,但在将你的模型导出为pickle文件时必须小心,因为以下原因:-
版本控制:当你 pickle 一个对象时,你必须使用与其他程序中相同的
pickle版本来反序列化它,出于稳定性的原因。这增加了管理你的项目的复杂性。 -
安全性:
pickle的文档明确指出它是不安全的,并且很容易构造恶意的 pickle,这些 pickle 在反序列化时会执行危险的代码。这是一个非常重要的考虑因素,尤其是在你向生产环境迈进时。
通常,只要你知道你使用的
pickle文件的历史来源并且来源是可信的,它们就可以安全使用,并且是一种简单快速地分享你的模型的方法! -
-
joblib:
joblib是一个功能强大但轻量级的 Python 通用管道库。它围绕缓存、并行化和压缩等许多非常有用的功能,使其成为保存和读取你的机器学习管道的非常通用的工具。它对于存储大型NumPy数组也非常快,因此对于数据存储很有用。我们将在后面的章节中更多地使用joblib。重要的是要注意,joblib与pickle一样存在相同的安全问题,因此了解你的joblib文件的历史来源至关重要。 -
JSON:如果
pickle和joblib不适用,你可以将你的模型及其参数序列化为 JSON 格式。这很好,因为 JSON 是一种标准化的文本序列化格式,在许多解决方案和平台上广泛使用。但是,使用 JSON 序列化你的模型有一个缺点,那就是你通常必须手动定义包含你想要存储的相关参数的 JSON 结构。因此,这可能会产生大量的额外工作。Python 中的一些机器学习库都有自己的导出为 JSON 的功能,例如深度学习包 Keras,但它们都可以产生相当不同的格式。 -
MLeap:MLeap 是基于Java 虚拟机(JVM)的序列化格式和执行引擎。它与 Scala、PySpark 和 Scikit-Learn 有集成,但您通常会在示例和教程中看到它用于保存 Spark 管道,特别是对于使用 Spark ML 构建的模型。这种关注意味着它不是最灵活的格式,但如果您在Spark 生态系统中工作,它非常有用。
-
ONNX:开放神经网络交换(ONNX)格式旨在实现完全跨平台,并允许主要机器学习框架和生态系统之间交换模型。ONNX 的主要缺点是(正如您可以从其名称中猜到的)它主要针对基于神经网络的模型,除了其
scikit-learnAPI 之外。如果您正在构建神经网络,这仍然是一个极好的选择。
在第三章,从模型到模型工厂中,我们将使用这些格式中的一些将我们的模型导出到 MLflow,但它们都与 MLflow 兼容,因此您应该在使用它们作为您机器学习工程工作流程的一部分时感到舒适。
本章的最后一节将介绍一些重要的概念,用于规划您希望如何部署您的解决方案,为书中稍后更详细的讨论做铺垫。
部署
机器学习开发过程的最后阶段才是真正重要的:您如何将您构建的令人惊叹的解决方案带入现实世界并解决您最初的问题?答案有多个部分,其中一些将在本书稍后更详细地探讨,但将在本节中概述。如果我们想要成功部署我们的解决方案,首先,我们需要了解我们的部署选项:有什么基础设施可供选择,并且适合这项任务?然后,我们需要将解决方案从我们的开发环境转移到这个生产基础设施上,以便在适当的编排和控制下,它能够执行我们需要的任务,并在需要的地方展示结果。这就是DevOps和MLOps概念发挥作用的地方。
让我们详细阐述这两个核心概念,为后续章节奠定基础,并探讨如何开始部署我们的工作。
了解您的部署选项
在第五章,部署模式和工具中,我们将详细介绍您需要将您的机器学习工程项目从开发阶段过渡到部署阶段,但为了提前预告并为您提供即将到来的内容的预览,让我们探索我们可用的不同部署选项:
-
本地部署:我们拥有的第一个选择是完全忽略公有云,并在我们拥有的基础设施上内部部署我们的解决方案。这个选项对于许多拥有大量遗留软件和强烈数据位置和数据处理监管约束的大型机构来说尤其受欢迎和必要。本地部署的基本步骤与云上部署相同,但通常需要来自其他具有特定专业知识的团队的大量参与。例如,如果你在云上,你通常不需要花很多时间配置网络或实现负载均衡器,而本地解决方案将需要这些。
本地部署的大优势是安全性和安心感,即你的数据不会穿越公司的防火墙。缺点是它需要更大的前期硬件投资,而且你必须付出大量努力才能有效地配置和管理该硬件。在这本书中,我们不会详细讨论本地部署,但我们将在软件开发、打包、环境管理、培训和预测系统等方面使用的所有概念仍然适用。
-
基础设施即服务(IaaS):如果你打算使用云,你可用于部署的最低抽象级别之一是 IaaS 解决方案。这些通常基于虚拟化的概念,即可以根据用户的意愿启动具有各种规格的服务器。这些解决方案通常将维护和操作的需求抽象化,作为服务的一部分。最重要的是,它们允许你的基础设施在需要时具有极端的可扩展性。下周需要运行 100 多台服务器?没问题,只需扩展你的 IaaS 请求,它就会出现。尽管 IaaS 解决方案比完全管理的本地基础设施迈出了很大一步,但仍有几件事情你需要考虑和配置。云计算中的平衡始终在于你希望事情有多容易,以及你希望有多少控制权。与某些其他解决方案相比,IaaS 最大化了控制权,但最小化了(相对的)易用性。在AWS中,简单存储服务(S3)和弹性计算云(EC2)是 IaaS 提供的良好例子。
-
平台即服务(PaaS):PaaS 解决方案在抽象层面上是下一个级别,通常提供许多功能,而无需了解底层具体发生什么。这意味着你可以专注于平台准备支持的开发任务,而无需担心任何底层基础设施。一个很好的例子是AWS Lambda函数,这是一种无服务器函数,几乎可以无限制地扩展。
你需要做的只是将你想要在函数内部执行的主要代码块输入进去。另一个很好的例子是Databricks,它提供了在Spark 集群基础设施之上的非常直观的用户界面,几乎无缝地提供、配置和扩展这些集群。
了解这些不同选项及其功能可以帮助你设计你的机器学习解决方案,并确保你将团队的技术努力集中在最需要和最有价值的地方。例如,如果你的机器学习工程师正在配置路由器,那么你肯定在某些地方犯了错误。
但一旦你选择了要使用的组件并配置了基础设施,你该如何将这些组件集成在一起并管理你的部署和更新周期呢?这正是我们现在要探讨的。
理解 DevOps 和 MLOps
现代软件开发中的一个非常有力的观点是,你的团队应该能够根据需要持续更新代码库,同时测试、集成、构建、打包和部署解决方案应该尽可能地自动化。这意味着这些流程可以几乎持续不断地进行,而不需要分配大块预先计划的时间来更新周期。这就是CI/CD背后的主要思想。CI/CD 是DevOps及其以机器学习为重点的表亲MLOps的核心部分,它们都旨在将软件开发和部署后的运营结合起来。本书中我们将开发的一些概念和解决方案将构建得自然地适合 MLOps 框架。
CI 部分主要关注稳定地将持续变更集成到代码库中,同时确保功能保持稳定。CD 部分则是将解决方案的稳定版本推送到适当的基础设施。
图 2.12展示了这一过程的高级视图:

图 2.12:CI/CD 流程的高级视图。
为了使 CI/CD 成为现实,你需要整合帮助自动化你传统上在开发和部署过程中手动执行的任务的工具。例如,如果你可以在代码合并时自动运行测试,或者将你的代码工件/模型推送到适当的环境,那么你就已经走上了 CI/CD 的道路。
我们可以进一步分解,并考虑解决方案的 DevOps 或 MLOps 生命周期中落入的不同类型任务。开发任务通常涵盖从电脑上的空白屏幕到可工作的软件的所有活动。这意味着在 DevOps 或 MLOps 项目中,你大部分时间都花在开发上。这包括从编写代码到正确格式化并测试它的一切。
表 2.3 将这些典型任务分开,并提供了一些关于它们如何相互依赖以及您可以在 Python 栈中使用的典型工具的详细信息。
| 生命周期阶段 | 活动 | 详细信息 | 工具 |
|---|---|---|---|
| 开发 | 测试 | 单元测试:针对测试代码最小功能部分的测试。 | pytest 或 unittest |
| 集成测试:确保代码内部和其他解决方案之间的接口正常工作。 | Selenium | ||
| 接受测试:业务导向的测试。 | Behave | ||
| UI 测试:确保任何前端按预期运行。 | |||
| 代码风格检查 | 报告小的风格错误和错误。 | flake8 或 bandit | |
| 格式化 | 自动执行格式良好的代码。 | black 或 sort | |
| 构建 | 将解决方案整合的最后阶段。 | Docker, twine 或 pip |
表 2.3:任何 DevOps 或 MLOps 项目中执行的开发活动的详细信息。
接下来,我们可以考虑 MLOps 中的机器学习活动,本书将非常关注这些内容。这包括一个经典的 Python 软件工程师通常不需要担心,但对于我们这样的机器学习工程师来说至关重要的所有任务。这包括开发自动训练机器学习模型的能力,运行模型应生成的预测或推理,并在代码管道中将它们整合在一起。它还包括模型版本的管理和部署,这极大地补充了使用像 Git 这样的工具对应用程序代码进行版本控制的想法。最后,机器学习工程师还必须考虑他们必须为解决方案的操作模式构建特定的监控能力,这在传统的 DevOps 工作流程中并未涵盖。对于机器学习解决方案,您可能需要考虑监控诸如精确度、召回率、f1 分数、人口稳定性、熵和数据漂移等因素,以了解您的解决方案中的模型组件是否在可容忍的范围内运行。这与经典的软件工程非常不同,因为它需要了解机器学习模型的工作原理,它们可能出错的方式,以及对数据质量重要性的真正认识。这就是为什么机器学习工程是一个如此令人兴奋的地方!请参阅 表 2.4 了解这些类型活动的更多详细信息。
| 生命周期阶段 | 活动 | 详细信息 | 工具 |
|---|---|---|---|
| 机器学习 | 训练 | 训练模型。 | 任何机器学习包。 |
| 预测 | 运行预测或推理步骤。 | 任何机器学习包。 | |
| 构建 | 创建模型嵌入的管道和应用逻辑。 | sklearn 管道、Spark ML 管道、ZenML。 | |
| 部署 | 标记和发布您模型和管道的适当版本。 | MLflow 或 Comet.ml. | |
| 监控 | 跟踪解决方案性能并在必要时发出警报。 | Seldon, Neptune.ai, Evidently.ai 或 Arthur.ai. |
表 2.4:MLOps 项目中执行的以机器学习为中心活动的详细信息。
最后,在 DevOps 或 MLOps 中,有 Operations 部分,这指的是运维。这全部关于解决方案的实际运行方式,如果出现问题,它将如何通知你,以及它是否能够成功恢复。自然地,运维将涵盖与解决方案的最终打包、构建和发布相关的活动。它还必须涵盖另一种类型的监控,这与 ML 模型的性能监控不同。这种监控更多地关注基础设施利用率、稳定性和可扩展性,关注解决方案的延迟,以及更广泛解决方案的一般运行。在 DevOps 和 MLOps 生命周期中,这部分在工具方面相当成熟,因此有很多选项可供选择。以下是在 表 2.5 中提供的一些启动信息。
| 生命周期阶段 | 活动 | 详情 | 工具 |
|---|---|---|---|
| Ops | 发布 | 将你构建的软件存储在某个中央位置以供重用。 | Twine、pip、GitHub 或 BitBucket。 |
| 部署 | 将你构建的软件推送到适当的目标位置和环境。 | Docker、GitHub Actions、Jenkins、TravisCI 或 CircleCI。 | |
| 监控 | 跟踪底层基础设施和一般软件性能的性能和利用率,在必要时发出警报。 | DataDog、Dynatrace 或 Prometheus。 |
表 2.5:在 DevOps 或 MLOps 项目中使解决方案可操作所执行的活动详情。
现在我们已经阐明了 MLOps 生命周期中所需的核心概念,在下一节中,我们将讨论如何实施 CI/CD 实践,以便我们可以在我们的 ML 工程项目中将其变为现实。我们还将扩展这一内容,涵盖对您的 ML 模型和管道性能的自动测试,以及对您的 ML 模型的自动重新训练。
使用 GitHub Actions 构建我们的第一个 CI/CD 示例
我们将在本书中使用 GitHub Actions 作为我们的 CI/CD 工具,但还有其他一些工具可以完成同样的工作。GitHub Actions 对任何拥有 GitHub 账户的人来说都是可用的,它有一套非常有用的文档,docs.github.com/en/actions,并且非常容易开始使用,正如我们现在将展示的那样。
当使用 GitHub Actions 时,你必须创建一个 .yml 文件,告诉 GitHub 何时执行所需操作,当然,执行什么操作。这个 .yml 文件应该放在你的仓库根目录下的 .github/workflows 文件夹中。如果它还不存在,你必须创建它。我们将在一个名为 feature/actions 的新分支中这样做。通过运行以下命令创建这个分支:
git checkout –b feature/actions
然后,创建一个名为github-actions-basic.yml的.yml文件。在以下步骤中,我们将构建这个.yml文件示例,用于 Python 项目,其中我们将自动安装依赖项,运行一个代码检查器(用于检查错误、语法错误和其他问题)的解决方案,然后运行一些单元测试。此示例来自 GitHub Starter Workflows 存储库(github.com/actions/starter-workflows/blob/main/ci/python-package-conda.yml)。打开github-actions-basic.yml文件,然后执行以下操作:
-
首先,您定义 GitHub Actions 工作流程的名称以及什么 Git 事件将触发它:
name: Python package on: [push] -
您然后列出您想要作为工作流程一部分执行的工作,以及它们的配置。例如,这里有一个名为
build的工作,我们希望在最新的 Ubuntu 发行版上运行它,并尝试使用几个不同的 Python 版本进行构建:jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.9, 3.10] -
您然后定义作为工作一部分执行的步骤。每个步骤由一个连字符分隔,并作为单独的命令执行。重要的是要注意,
uses关键字获取标准的 GitHub Actions;例如,在第一步中,工作流程使用checkout动作的v2版本,第二步设置在工作流程中要运行的 Python 版本:steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} -
下一步使用
pip和requirements.txt文件(但当然您也可以使用conda)安装解决方案的相关依赖项:- name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 -
然后,我们运行一些代码检查:
- name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line- length=127 --statistics -
最后,我们使用我们最喜欢的 Python 测试库运行我们的测试。对于这一步,我们不想运行整个仓库,因为它相当复杂,所以在这个例子中,我们使用
working-directory关键字只在该目录中运行pytest。由于它包含在
test_basic.py中的简单测试函数,这将自动通过:- name: Test with pytest run: pytest working-directory: Chapter02
我们现在已经构建了 GitHub Actions 工作流程;下一步是展示其运行。这由 GitHub 自动处理,您只需将更改后的.yml文件推送到远程仓库即可。因此,添加编辑后的.yml文件,提交它,然后推送它:
git add .github/workflows/github-actions-basic.yml
git commit –m "Basic CI run with dummy test"
git push origin feature/actions
在终端中运行这些命令后,您可以导航到 GitHub UI,然后在顶部菜单栏中点击操作。您将看到所有操作运行的视图,如图 2.13 所示。

图 2.13:从 GitHub UI 查看的 GitHub Actions 运行。
如果您点击运行,您将看到操作运行中所有工作的详细信息,如图 2.14 所示。

图 2.14:GitHub Actions 运行细节,从 GitHub UI 查看。
最后,您可以进入每个工作,查看执行的步骤,如图 2.15 所示。点击这些步骤也会显示每个步骤的输出。这对于分析运行中的任何失败非常有用。

图 2.15:GitHub Actions 运行步骤,如图所示在 GitHub UI 上。
我们到目前为止所展示的是一个 CI 的例子。为了将其扩展到覆盖 CD,我们需要包括将生成的解决方案推送到目标主机目的地的步骤。例如,构建一个 Python 包并将其发布到 pip,或者创建一个流水线并将其推送到另一个系统以便被拾取并运行。这个后者的例子将在 第五章,部署模式和工具 中进行介绍。简而言之,这就是你开始构建你的 CI/CD 流水线的方式。正如之前提到的,在本书的后面,我们将构建针对我们机器学习解决方案的特定工作流程。
现在我们将探讨如何将 CI/CD 概念提升到机器学习工程的下一个层次,并为我们的模型性能构建一些测试,这些测试可以作为持续过程的一部分被触发。
持续模型性能测试
作为机器学习工程师,我们不仅关心我们编写的代码的核心功能行为;我们还要关心我们构建的模型。这很容易被忘记,因为传统的软件项目不需要考虑这个组件。
我现在要向您展示的过程将展示如何从一些基础参考数据开始,逐步构建不同类型的测试,以增强你对模型在部署时能够按预期运行的信心。
我们已经介绍了如何使用 Pytest 和 GitHub Actions 进行自动测试,好消息是我们可以将这个概念扩展到包括一些模型性能指标的测试。为此,你需要准备以下几件事情:
-
在操作或测试中,你需要检索用于执行模型验证的参考数据。这可以通过从远程数据存储(如对象存储或数据库)中拉取来完成,只要提供适当的凭证。我建议将这些存储在 Github 的秘密中。在这里,我们将使用
sklearn库生成的一个数据集作为简单的例子。 -
你还需要从某个位置检索你想要测试的模型或模型。这可能是一个完整的模型注册表或其他存储机制。与 第 1 点 中提到的访问和秘密管理相同的要点同样适用。在这里,我们将从
Hugging Face Hub(关于 Hugging Face 的更多内容请见 第三章)中拉取一个模型,但这同样可能是一个 MLflow Tracking 实例或其他工具。 -
你需要定义你想要运行的测试,并且对你能够实现预期结果有信心。你不想编写过于敏感的测试,以免因无关原因触发失败的构建,同时你也想尝试定义一些有助于捕捉你想要标记的失败类型的测试。
对于 第 1 点,这里我们从 sklearn 库中抓取一些数据,并通过 pytest fixture 使其可用于测试:
@pytest.fixture
def test_dataset() -> Union[np.array, np.array]:
# Load the dataset
X, y = load_wine(return_X_y=True)
# create an array of True for 2 and False otherwise
y = y == 2
# Train and test split
X_train, X_test, y_train, y_test = train_test_split(X, y,
random_state=42)
return X_test, y_test
对于第 2 点,我将使用Hugging Face Hub包来检索存储的模型。如上所述的要点中提到,您需要根据您访问的模型存储机制进行适配。在这种情况下,仓库是公开的,因此无需存储任何机密信息;如果您确实需要这样做,请使用 GitHub Secrets 存储。
@pytest.fixture
def model() -> sklearn.ensemble._forest.RandomForestClassifier:
REPO_ID = "electricweegie/mlewp-sklearn-wine"
FILENAME = "rfc.joblib"
model = joblib.load(hf_hub_download(REPO_ID, FILENAME))
return model
现在,我们只需要编写测试。让我们从一个确认模型预测产生正确对象类型的简单测试开始:
def test_model_inference_types(model, test_dataset):
assert isinstance(model.predict(test_dataset[0]), np.ndarray)
assert isinstance(test_dataset[0], np.ndarray)
assert isinstance(test_dataset[1], np.ndarray)
然后,我们可以编写一个测试来断言测试数据集上模型性能满足某些特定条件:
def test_model_performance(model, test_dataset):
metrics = classification_report(y_true=test_dataset[1],
y_pred=model.predict(test_dataset[0]),
output_dict=True)
assert metrics['False']['f1-score'] > 0.95
assert metrics['False']['precision'] > 0.9
assert metrics['True']['f1-score'] > 0.8
assert metrics['True']['precision'] > 0.8
之前的测试可以被视为一种类似数据驱动的单元测试,并确保如果您在模型中做了更改(例如,您可能在管道中更改了一些特征工程步骤或更改了一个超参数),您不会违反期望的性能标准。一旦这些测试成功添加到仓库中,在下次推送时,GitHub 动作将被触发,您将看到模型性能测试运行成功。
这意味着我们正在将连续模型验证作为 CI/CD 过程的一部分进行执行!

图 2.16:使用 GitHub Actions 作为 CI/CD 过程的一部分成功执行模型验证测试。
更复杂的测试可以在此基础上构建,并且您可以调整环境和包以适应您的需求。
持续模型训练
在机器学习工程中,“持续”概念的一个重要扩展是执行持续训练。上一节展示了如何通过推送代码来触发一些测试目的的 ML 过程;现在,我们将讨论如何扩展这一过程,以便在您想要根据代码更改触发模型重新训练的情况下。在本书的后面部分,我们将学习到很多关于基于各种不同触发器(如数据或模型漂移)对 ML 模型进行训练和重新训练的知识,例如在第三章“从模型到模型工厂”中,以及如何在第五章“部署模式和工具”中一般性地部署 ML 模型。鉴于这一点,我们在此不会详细讨论部署到不同目标的具体细节,而是向您展示如何将连续训练步骤构建到您的 CI/CD 管道中。
实际上,这比你可能想象的要简单。如您现在可能已经注意到的,CI/CD 实际上就是关于自动化一系列步骤,这些步骤在开发过程中的特定事件发生时被触发。这些步骤中的每一个都可以非常简单或更复杂,但本质上,我们只是在触发事件激活时按照指定顺序执行的其他程序。
在这个案例中,由于我们关注的是持续训练,我们应该问自己,在代码开发过程中,我们希望在何时重新训练?记住,我们正在忽略最明显的重新训练案例,即按计划或模型性能或数据质量漂移时进行重新训练,这些内容将在后面的章节中涉及。如果我们现在只考虑代码的变化,那么自然的答案是只有在代码有实质性变化时才进行训练。
例如,如果每次我们将代码提交到版本控制时都会触发一个触发器,这很可能会导致大量的计算周期被用于微小的收益,因为机器学习模型在每个情况下可能不会有很大的不同。我们可以改为仅当拉取请求合并到主分支时才触发重新训练。在一个项目中,这是一个标志着新软件功能或功能已被添加并已纳入解决方案核心的事件。
作为提醒,当在 GitHub Actions 中构建 CI/CD 时,你会在 Git 仓库的.github文件夹中创建或编辑YAML文件。如果我们想在拉取请求上触发训练过程,那么我们可以添加类似以下内容:
name: Continous Training Example
on: [pull_request]
然后我们需要定义将适当的训练脚本推送到目标系统并运行它的步骤。首先,这很可能会需要获取一些访问令牌。假设这是针对 AWS,并且你已经将相应的 AWS 凭证作为 GitHub Secrets 加载;更多详细信息,请参阅第五章,部署模式和工具。然后我们就能在deploy-trainer工作的第一步中检索到这些令牌:
jobs:
deploy-trainer
runs-on: [ubuntu-latest]
steps:
- name: Checkout uses: actions/checkout@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-2
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
role-external-id: ${{ secrets.AWS_ROLE_EXTERNAL_ID }}
role-duration-seconds: 1200
role-session-name: TrainingSession
你可能还想将你的仓库文件复制到目标S3目的地;也许它们包含主训练脚本运行所需的模块。然后你可以做类似这样的事情:
- name: Copy files to target destination
run: aws s3 sync . s3://<S3-BUCKET-NAME>
最后,你可能需要运行某种使用这些文件进行训练的过程。有如此多的方法可以做到这一点,我在这个例子中省略了具体细节。关于部署机器学习过程的各种方法将在第五章,部署模式和工具中介绍:
- name: Run training job
run: |
# Your bespoke run commands go in here using the tools of your choice!
有了这些,你就拥有了运行持续机器学习模型训练所需的所有关键部件,以补充其他关于持续模型性能测试的部分。这就是如何将 DevOps 的 CI/CD 概念带入 MLOps 的世界!
摘要
本章主要讲述了为未来工作打下坚实基础的内容。我们讨论了所有机器学习工程项目中常见的开发步骤,我们称之为“发现、探索、开发、部署”,并将这种思维方式与传统方法如 CRISP-DM 进行了对比。特别是,我们概述了每个步骤的目标及其期望的输出。
接着,我们进行了关于工具的高级讨论,并介绍了主要设置步骤。我们设置了开发代码的工具,跟踪代码的更改,管理我们的机器学习工程项目,最后部署我们的解决方案。
在本章的其余部分,我们详细介绍了我们之前概述的四个步骤的细节,特别关注了开发和部署阶段。我们的讨论涵盖了从瀑布式和敏捷开发方法论的优缺点到环境管理,再到软件开发最佳实践的各个方面。我们探讨了如何打包您的机器学习解决方案,以及可供您使用的部署基础设施,并概述了设置您的 DevOps 和 MLOps 工作流程的基本知识。我们通过详细讨论如何将测试应用于我们的机器学习代码来结束本章,包括如何将此测试自动化作为 CI/CD 管道的一部分。然后,我们将这些概念扩展到持续模型性能测试和持续模型训练。
在下一章中,我们将关注如何使用我们在此处讨论的许多技术来构建执行模型自动训练和再训练的软件。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第三章:从模型到模型工厂
本章全部关于机器学习工程中最重要的概念之一:您如何将训练和微调模型的困难任务自动化、可重复和可扩展到生产系统?
我们将在理论和实践层面回顾训练不同机器学习模型的主要思想,然后提供重新训练的动机,即机器学习模型不会永远表现良好的观点。这个概念也被称为漂移。在此之后,我们将介绍特征工程背后的主要概念,这是任何机器学习任务的关键部分。接下来,我们将深入探讨机器学习是如何工作的,以及它本质上是一系列优化问题。我们将探讨在着手解决这些优化问题时,您可以使用各种工具在各个抽象级别上这样做。特别是,我们将讨论您如何提供您想要训练的模型的直接定义,我称之为手动操作,或者您如何执行超参数调整或自动化机器学习(AutoML)。我们将在查看使用不同库和工具的示例之后,探讨如何将它们实现以供后续在训练工作流程中使用。然后,我们将基于我们在第二章(机器学习开发过程)中进行的初步工作,通过向您展示如何与不同的 MLflow API 接口来管理模型并在 MLflow 模型注册表中更新它们的状态,来构建 MLflow。
我们将本章的结尾放在讨论允许您将所有机器学习模型训练步骤链接成单个单元(称为管道)的实用工具上,这些单元可以帮助作为我们之前讨论的所有步骤的更紧凑表示。总结部分将回顾关键信息,并概述我们在这里所做的工作将在第四章(打包)、第五章(部署模式和工具)中进一步构建。
本质上,本章将告诉您在您的解决方案中需要将哪些内容组合在一起,而后续章节将告诉您如何将这些内容稳健地组合在一起。我们将在以下章节中介绍这一点:
-
定义模型工厂
-
学习关于学习
-
为机器学习构建特征
-
设计您的训练系统
-
需要重新训练
-
持久化您的模型
-
使用管道构建模型工厂
技术要求
如前几章所述,本章所需的软件包包含在Chapter03文件夹中的.yml文件中,因此要为本章创建 conda 环境,只需运行以下命令:
conda env create –f mlewp-chapter03.yml
这将安装包括 MLflow、AutoKeras、Hyperopt Optuna、auto-sklearn、Alibi Detect 和 Evidently 在内的软件包。
注意,如果你在配备苹果硅的 Macbook 上运行这些示例,直接使用pip或conda安装 TensorFlow 和auto-sklearn可能不会成功。相反,你需要安装以下包来与 TensorFlow 一起工作:
pip install tensorflow-macos
然后
pip install tensorflow-metal
要安装auto-sklearn,你需要运行
brew install swig
或者使用你使用的任何 Mac 包管理器安装swig,然后你可以运行
pip install auto-sklearn
定义模型工厂
如果我们想要开发从临时、手动和不一致的执行转向可以自动化、稳健和可扩展的机器学习系统的解决方案,那么我们必须解决如何创建和培育表演明星:模型本身的问题。
在本节中,我们将讨论必须组合在一起的关键组件,以实现这一愿景,并提供一些示例,说明这些组件在代码中可能的样子。这些示例不是实现这些概念的唯一方式,但它们将使我们能够开始构建我们的机器学习解决方案,以实现我们想要在现实世界中部署所需的复杂程度。
我们在这里讨论的主要组件如下:
-
训练系统:一个用于以自动化方式在我们拥有的数据上稳健地训练我们模型的系统。这包括我们为在数据上训练我们的机器学习模型而开发的全部代码。
-
模型存储库:一个用于持久化成功训练的模型的地方,以及一个与将运行预测的组件共享生产就绪模型的地方。
-
漂移检测器:一个用于检测模型性能变化的系统,以触发训练运行。
这些组件及其与部署的预测系统的交互,构成了模型工厂的概念。以下图示展示了这一点:

图 3.1:模型工厂的组件。
在本章的剩余部分,我们将详细探讨我们之前提到的三个组件。预测系统将是后续章节的重点,特别是第五章,部署模式和工具。
首先,让我们探讨训练机器学习模型意味着什么,以及我们如何构建系统来完成这项工作。
学习如何学习
在本质上,机器学习算法都包含一个关键特性:某种形式的优化。这些算法能够“学习”(意味着它们在接触到更多观察时,会迭代地改善它们在适当指标上的性能),这使得它们如此强大和令人兴奋。当我们说“训练”时,我们指的就是这个过程。
在本节中,我们将介绍支撑训练的关键概念,我们可以在代码中选择的选项,以及这些选项对我们训练系统潜在性能和能力的影响。
定义目标
我们刚刚提到训练是一个优化过程,但我们到底在优化什么?让我们考虑监督学习。在训练过程中,我们提供我们希望预测给定特征的标签或值,以便算法可以学习特征与目标之间的关系。为了在训练过程中优化算法的内部参数,它需要知道其当前参数集会有多大的“错误”。优化就是通过更新参数,使这种“错误”的度量越来越小。这正是损失函数概念所捕捉的。
损失函数有多种形式,如果你需要,你甚至可以使用很多包来定义自己的损失函数,但有一些标准损失函数是值得了解的。其中一些名称在此处提到。
对于回归问题,你可以使用以下方法:
-
均方误差/L2 损失
-
均方误差/L1 损失
对于二元分类问题,你可以使用以下方法:
-
对数损失/逻辑损失/交叉熵损失
-
拉链损失
对于多类分类问题,你可以使用以下方法:
-
多类熵损失
-
Kullback-Leibler 散度损失
在无监督学习中,损失函数的概念仍然适用,但现在目标是输入数据的正确分布。在定义你的损失函数之后,你需要对其进行优化。这就是我们将在下一节中探讨的内容。
剪切损失
到目前为止,我们知道训练完全是关于优化的,我们也知道要优化什么,但我们还没有介绍如何优化。
通常,有很多选项可以选择。在本节中,我们将探讨一些主要的方法。
以下是一些恒定学习率的方法:
-
梯度下降:此算法通过计算我们的损失函数相对于参数的导数,然后使用这个导数来构建一个更新,使我们在减少损失的方向上移动。
-
批量梯度下降:我们用来在参数空间中移动的梯度是通过取所有找到的梯度的平均值得到的。它是通过查看我们的训练集中的每个数据点,并检查数据集不是太大,损失函数相对平滑且凸来做到这一点的。这几乎可以达到全局最小值。
-
随机梯度下降:在每次迭代中,使用一个随机选择的数据点来计算梯度。这有助于更快地达到损失函数的全局最小值,但它在每次优化步骤后对损失值的突然波动更敏感。
-
小批量梯度下降:这是批量和随机两种情况的混合。在这种情况下,对于参数的每次更新,都会使用多个大于 1 但小于整个数据集的点来更新梯度。这意味着批量大小的现在是一个需要调整的参数。批量大时,我们更接近批梯度下降,这提供了更好的梯度估计,但速度较慢。批量小时,我们更接近随机梯度下降,这速度更快,但不够稳健。小批量允许我们决定在这两者之间想要处于哪个位置。可以根据各种标准选择批大小。这些可能涉及一系列的内存考虑。并行处理的大批量批次将消耗更多内存,同时为小批量提供更好的泛化性能。有关更多详细信息,请参阅 Ian Goodfellow、Yoshua Bengio 和 Aaron Courville 所著的《深度学习》一书的第八章,网址为
www.deeplearningbook.org/。
然后,还有自适应学习率方法。以下是一些最常见的:
-
AdaGrad:学习率参数根据优化过程中的学习更新属性动态更新。
-
AdaDelta:这是
AdaGrad的一个扩展,它不使用所有之前的梯度更新。相反,它使用一个滚动窗口来跟踪更新。 -
RMSprop:它通过维护所有梯度步骤平方的移动平均值来工作。然后,它将最新的梯度除以这个值的平方根。
-
亚当:这是一个旨在结合
AdaGrad和RMSprop优点的算法。
对于我们这些机器学习工程师来说,所有这些优化方法的限制和能力都很重要,因为我们希望确保我们的训练系统使用正确的工具来完成工作,并且对当前问题是最优的。仅仅意识到有多个内部优化选项也会帮助你集中精力并提高性能。

图 3.2:训练作为损失函数优化的简单表示。
现在,让我们讨论如何通过特征工程的过程准备模型工厂完成其工作所需的原始材料,即数据。
准备数据
数据可以以各种类型和质量出现。它可以来自关系型数据库的表格数据,也可以是从爬取的网站中获取的非结构化文本,或者是 REST API 的格式化响应,还可以是图像、音频文件,或者任何你能想到的其他形式。
如果你想要在这组数据上运行机器学习算法,你必须做的第一件事是让它对这些算法来说是可读的。这个过程被称为特征工程,接下来的几节将讨论这一点,以为你提供主要原则的基础。关于特征工程有许多优秀的资源可以深入探讨,所以我们在这里只会触及一些主要概念。更多信息,你可以查阅索莱达·加利亚(Soledad Galli)所著的《特征工程食谱》(Feature Engineering Cookbook),Packt 出版社,2022 年版。
为机器学习构建特征
在我们将任何数据输入到机器学习模型之前,它必须被转换成我们模型能够理解的状态。我们还需要确保我们只对那些我们认为有助于提高模型性能的数据进行转换,因为这很容易导致特征数量激增,并成为维度诅咒的受害者。这指的是一系列相关观察,在高维问题中,数据在特征空间中变得越来越稀疏,因此要实现统计显著性可能需要指数级更多的数据。在本节中,我们不会涵盖特征工程的理论基础。相反,我们将关注作为机器学习工程师,我们如何帮助自动化生产中的某些步骤。为此,我们将快速回顾主要类型的特征准备和特征工程步骤,以便我们可以在本章后面的部分添加必要的组件。
构建类别特征
分类别特征是指形成一组非数值的、不同的对象集合,例如星期几或发色。它们可以在你的数据中以多种方式分布。
为了让机器学习算法能够消化类别特征,我们需要将特征转换成某种数值形式,同时确保数值表示不会产生偏差或不适当地影响我们的值。一个例子是,如果我们有一个包含超市中不同产品销售的特征:
data = [['Bleach'], ['Cereal'], ['Toilet Roll']]
在这里,我们可以使用sklearn的OrdinalEncoder将每个类别映射到一个正整数:
from sklearn import preprocessing
ordinal_enc = preprocessing.OrdinalEncoder()
ordinal_enc.fit(data)
# Print returns [[0.]
# [1.]
# [2.]]
print(ordinal_enc.transform(data))
这就是所谓的序数编码。我们已经将这些特征映射到数字上,所以这里有一个大勾,但这种表示合适吗?好吧,如果你稍微思考一下,其实并不合适。这些数字似乎暗示谷物对漂白剂就像卫生纸对谷物一样,而卫生纸和漂白剂的平均值是谷物。这些陈述没有意义(我也不想在早餐时吃漂白剂和卫生纸),所以这表明我们应该尝试不同的方法。然而,在需要保持分类特征中顺序概念的情况下,这种表示是合适的。一个很好的例子是,如果我们有一个调查,参与者被要求对陈述“早餐是一天中最重要的一餐”发表意见。如果参与者被告知从列表中选择一个选项,如“强烈不同意”,“不同意”,“既不同意也不反对”,“同意”,“强烈同意”,然后我们将这些数据序数编码以映射到数字列表1,2,3,4和5,那么我们可以更直观地回答诸如“平均反应是更同意还是不同意?”和“对这个陈述的意见有多普遍?”等问题。序数编码在这里会有帮助,但正如我们之前提到的,在这种情况下并不一定正确。
我们可以做的事情是考虑这个特性中的项目列表,然后提供一个二进制数来表示原始列表中的值是否存在。所以,在这里,我们将决定使用sklearn的OneHotEncoder:
onehot_enc = preprocessing.OneHotEncoder()
onehot_enc.fit(data)
# Print returns [[1\. 0\. 0.]
# [0\. 1\. 0.]
# [0\. 0\. 1.]]
print(onehot_enc.transform(data).toarray())
这种表示被称为独热编码。这种方法编码有几个优点,包括以下内容:
-
没有强制排序的值。
-
所有特征向量都有单位范数(关于这一点稍后讨论)。
-
每个独特的特征都与其他特征正交,所以表示中没有隐含的奇怪平均值或距离陈述。
这种方法的一个缺点是,如果你的分类列表包含大量实例,那么你的特征向量的大小将很容易膨胀,我们不得不在算法级别上存储和处理极其稀疏的向量和矩阵。这很容易导致几个实现中的问题,也是可怕的维度诅咒的另一种表现。
在下一节中,将讨论数值特征。
工程数值特征
准备数值特征稍微容易一些,因为我们已经有了数字,但仍有几个步骤需要我们完成以准备许多算法。对于大多数机器学习算法,特征必须在相似的尺度上;例如,它们必须在-1 和 1 或 0 和 1 之间具有幅度。这有一个相对明显的原因,即某些算法会自动将高达百万美元的房价特征和房屋面积的另一个特征赋予更大的权重。这也意味着我们失去了关于特定值在其分布中位置的有用概念。例如,一些算法会从将特征缩放到中值美元价值和中值面积价值都表示为 0.5 而不是 500,000 和 350 中受益。或者,我们可能希望所有分布都具有相同的含义,如果它们是正态分布的,这将允许我们的算法专注于分布的形状而不是它们的位置。
那么,我们该怎么办呢?嗯,就像往常一样,我们不是从零开始,我们可以应用一些标准技术。这里列出了其中一些非常常见的,但它们的数量太多,无法全部包括:
- 标准化:这是一种数值特征的转换,假设在缩放方差为 1 和平均值为 0 之前,值的分布是正态的或高斯分布。如果你的数据确实是正态的或高斯分布,那么这是一个很好的技术。标准化的数学公式非常简单,所以我在这里提供了它,其中 z 代表变换后的值,x 是原始值,而
和
分别是平均值和标准差:

- 最小-最大归一化:在这种情况下,我们希望缩放数值特征,使它们始终在 0 和 1 之间,无论它们遵循的分布类型如何。
这在直观上很容易做到,因为你只需要从任何给定值中减去分布的最小值,然后除以数据的范围(最大值减去最小值)。你可以将这一步视为确保所有值都大于或等于 0。第二步是确保它们的最大尺寸为 1。这可以用一个简单的公式来表示,其中变换后的数字
是原始数字,而
代表该特征的整个分布:

- 特征向量归一化:在这里,您需要将数据集中的每个样本缩放,使它们的范数等于 1。如果您使用的是距离或特征之间的余弦相似度是重要组成部分的算法,这可能会非常重要,例如在聚类中。它也常与TF-IDF 统计等其他特征工程方法结合使用,在文本分类中。在这种情况下,假设您的整个特征是数值的,您只需计算特征向量的适当范数,然后将每个分量除以该值。例如,如果我们使用特征向量的欧几里得或 L2 范数,
,那么我们将通过以下公式转换每个分量,
:

为了突出这些简单步骤对模型性能的改进,我们将从sklearn葡萄酒数据集的一个简单示例中进行分析。在这里,我们将对未标准化的数据进行 Ridge 分类器的训练,然后对已标准化的数据进行训练。完成这些后,我们将比较结果:
-
首先,我们必须导入相关库并设置我们的训练和测试数据:
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import RidgeClassifier from sklearn import metrics from sklearn.datasets import load_wine from sklearn.pipeline import make_pipeline X, y = load_wine(return_X_y=True) -
然后,我们必须进行典型的 70/30 训练/测试分割:
X_train, X_test, y_train, y_test =\ train_test_split(X, y, test_size=0.30, random_state=42) -
接下来,我们必须在不进行特征标准化的情况下训练一个模型,并在测试集上进行预测:
no_scale_clf = make_pipeline(RidgeClassifier(tol=1e-2, solver="sag")) no_scale_clf.fit(X_train, y_train) y_pred_no_scale = no_scale_clf.predict(X_test) -
最后,我们必须做同样的事情,但添加一个标准化步骤:
std_scale_clf = make_pipeline(StandardScaler(), RidgeClassifier(tol=1e-2, solver="sag")) std_scale_clf.fit(X_train, y_train) y_pred_std_scale = std_scale_clf.predict(X_test) -
现在,如果我们打印一些性能指标,我们会看到没有缩放的情况下,预测的准确率为
0.76,而其他指标,如precision、recall和f1-score的加权平均值分别为0.83、0.76和0.68:print('\nAccuracy [no scaling]') print('{:.2%}\n'.format(metrics.accuracy_score(y_test, y_pred_no_ scale))) print('\nClassification Report [no scaling]') print(metrics.classification_report(y_test, y_pred_no_scale)) -
这会产生以下输出:
Accuracy [no scaling]75.93% Classification Report [no scaling] precision recall f1-score support 0 0.90 1.00 0.95 19 1 0.66 1.00 0.79 21 2 1.00 0.07 0.13 14 accuracy 0.76 54 macro avg 0.85 0.69 0.63 54 weighted avg 0.83 0.76 0.68 54 -
在数据标准化的情况下,所有指标都非常好,准确率以及
precision、recall和f1-score的加权平均值都达到了0.98:print('\nAccuracy [scaling]') print('{:.2%}\n'.format(metrics.accuracy_score(y_test, y_pred_std_scale))) print('\nClassification Report [scaling]') print(metrics.classification_report(y_test, y_pred_std_scale)) -
这会产生以下输出:
Accuracy [scaling] 98.15% Classification Report [scaling] precision recall f1-score support 0 0.95 1.00 0.97 19 1 1.00 0.95 0.98 21 2 1.00 1.00 1.00 14 accuracy 0.98 54 macro avg 0.98 0.98 0.98 54 weighted avg 0.98 0.98 0.98 54
在这里,我们只需在机器学习训练过程中添加一个简单的步骤,就能看到性能的显著提升。
现在,让我们看看训练是如何设计和在其核心工作的。这将帮助我们为我们的算法和训练方法做出明智的选择。
设计您的训练系统
从最高层次来看,机器学习模型经历一个生命周期,有两个阶段:训练阶段和输出阶段。在训练阶段,模型被喂给数据以从数据集中学习。在预测阶段,模型(包括其优化的参数)按顺序被喂给新数据,并返回所需的输出。
这两个阶段在计算和处理需求上非常不同。在训练阶段,我们必须让模型接触到尽可能多的数据以获得最佳性能,同时确保将数据集的子集保留用于测试和验证。模型训练本质上是一个优化问题,需要几个增量步骤才能得到解决方案。
因此,这需要大量的计算资源,在数据相对较大(或计算资源相对较低)的情况下,可能需要很长时间。即使您有一个小数据集和大量的计算资源,训练仍然不是一个低延迟的过程。此外,它通常是以批量方式运行的,并且数据集的小幅增加对模型性能的影响不大(也有例外)。另一方面,预测是一个更直接的过程,可以将其视为在代码中运行任何计算或函数:输入进入,进行计算,然后输出结果。这(通常)不需要大量的计算资源,并且具有低延迟。
综合来看,这意味着首先,从逻辑和代码的角度来看,将这两个步骤(训练和预测)分开是有意义的。其次,这意味着我们必须考虑这两个阶段的不同执行需求,并将这些需求纳入我们的解决方案设计中。最后,我们需要对训练方案做出选择,包括是否批量安排训练、使用增量学习,或者根据模型性能标准触发训练。这些都是您训练系统中的关键部分。
训练系统设计选项
在我们创建训练系统的任何详细设计之前,一些一般性问题总是适用的:
-
是否有适合该问题的基础设施可用?
-
数据在哪里,我们将如何将其输入到算法中?
-
我是如何测试模型性能的?
在基础设施方面,这可能会非常依赖于您用于训练的模型和数据。如果您打算在具有三个特征的数据上训练线性回归,并且您的数据集只包含 10,000 个表格记录,那么您可能无需过多考虑就能在笔记本电脑级别的硬件上运行。这不是很多数据,并且您的模型没有很多自由参数。如果您要在更大的数据集上训练,例如包含 1 亿个表格记录的数据集,那么您可能可以从 Spark 集群之类的并行化中受益。然而,如果您要在 1,000 张图像上训练一个 100 层的深度卷积神经网络,那么您可能需要使用 GPU。有很多选择,但关键是选择适合这项工作的正确工具。
关于如何将数据输入到算法中的问题,这可能并不简单。我们是否将对远程托管数据库运行 SQL 查询?如果是这样,我们将如何连接到它?运行查询的机器是否有足够的 RAM 来存储数据?
如果不是这样,我们需要考虑使用一种可以逐步学习的算法吗?对于经典的算法性能测试,我们需要使用机器学习领域的知名技巧,并在我们的数据上执行训练/测试/验证拆分。我们还需要决定我们可能想要采用的交叉验证策略。然后,我们需要选择我们偏好的模型性能指标并适当地计算它。然而,作为机器学习工程师,我们也会对其他性能指标感兴趣,例如训练时间、内存的有效使用、延迟,以及(我敢说)成本。我们还需要了解我们如何衡量并优化这些指标。
只要我们在进行过程中牢记这些事情,我们就会处于有利的位置。现在,让我们转向设计。
正如我们在本节引言中提到的,我们需要考虑两个基本方面:训练和输出过程。我们可以以两种方式将这些结合起来作为我们的解决方案。我们将在下一节中讨论这一点。
训练-运行
选项 1是在同一过程中执行训练和预测,训练可以在批量或增量模式下进行。这在下图中以示意图的形式展示。这种模式被称为训练-运行:

图 3.3:训练-运行过程。
这种模式是两种模式中较简单的一种,但也是对现实世界问题最不理想的一种,因为它并不体现我们之前提到的关注点分离原则。这并不意味着它是一个无效的模式,它确实有易于实现的优点。在这里,我们在做出预测之前运行整个训练过程,中间没有真正的中断。根据我们之前的讨论,如果我们必须以非常低延迟的方式提供预测,我们可以自动排除这种方法;例如,通过事件驱动或流式解决方案(稍后会有更多介绍)。
虽然这种方法可能完全有效(我在实践中见过几次),但这可能是在以下情况下:你应用的算法实际上非常轻量级,你需要继续使用非常最新的数据,或者你运行的大批量过程相对不频繁。
虽然这是一个简单的方法,并不适用于所有情况,但它确实具有明显的优势:
-
由于你训练的频率与预测的频率相同,你正在尽一切可能防止现代性能退化,这意味着你正在对抗漂移(参见本章后面的部分)。
-
你显著降低了你解决方案的复杂性。虽然你紧密耦合了两个组件,这通常应该避免,但训练和预测阶段可能非常简单,以至于如果你只是将它们放在一起,你会节省大量的开发时间。这是一个非同小可的观点,因为开发时间是有成本的。
现在,让我们看看另一种情况。
训练-持久
选项 2是训练以批处理方式运行,而预测以认为合适的任何模式运行,预测解决方案从存储中读取已训练的模型。我们将这种设计模式称为train-persist。这将在以下图中展示:

图 3.4:train-persist 过程。
如果我们要训练我们的模型并持久化模型,以便它可以在以后由预测过程拾取,那么我们需要确保以下几点:
-
我们有哪些模型存储选项?
-
是否有明确的机制来访问我们的模型存储(写入和读取)?
-
我们应该多久训练一次,多久预测一次?
在我们的情况下,我们将通过使用在第二章“机器学习开发过程”中介绍但将在后续部分重新讨论的 MLflow 来解决前两个问题。还有许多其他解决方案可用。关键点是,无论你使用什么作为模型存储和训练与预测过程之间的交接点,都应该以稳健和可访问的方式使用。
第三个点更复杂。你可以在一开始就决定你想按计划进行训练,并坚持下去。或者你可以更复杂,开发出在训练发生之前必须满足的触发标准。再次强调,这是你需要与你的团队一起做出的选择。在本章的后面部分,我们将讨论安排你的训练运行的机制。
在下一节中,我们将探讨如果你想要根据你的模型性能随时间可能退化的情况来触发你的训练运行,你需要做什么。
需要重新训练
你不会期望在完成教育后,就再也不读论文或书籍,也不再与任何人交谈,这意味着你将无法对世界正在发生的事情做出明智的决策。因此,你不应该期望一个机器学习模型一旦训练就永远表现良好。
这个想法直观易懂,但它代表了机器学习模型中一个被称为漂移的正式问题。漂移是一个涵盖你模型性能随时间下降的多种原因的术语。它可以分为两大类:
-
概念漂移:当你的数据特征与试图预测的结果之间的基本关系发生变化时,就会发生这种情况。有时,这也被称为协变量漂移。一个例子是在训练时,你只有一部分数据似乎显示出特征和结果之间的线性关系。如果结果是,在部署后收集了大量更多数据后,这种关系是非线性的,那么就发生了概念漂移。对此的缓解措施是使用更能代表正确关系的正确数据重新训练。
-
数据漂移:这种情况发生在你用作特征的变量的统计属性发生变化时。例如,你可能在你的某个模型中使用年龄作为特征,但在训练时间,你只有 16 至 24 岁年龄段的数据。
如果模型被部署,并且你的系统开始摄入更广泛年龄层的数据,那么你就遇到了数据漂移。
事实上,漂移是作为机器学习工程师生活的一部分,因此我们将花费大量时间来了解如何检测和减轻它。但为什么它会发生?正如你所预期的那样,漂移有多种原因需要考虑。让我们考虑一些例子。比如说,你用于采样训练数据的机制在某些方面不合适;也许你为特定的地理区域或人口统计进行了子采样,但你希望模型在更普遍的情况下应用。
在我们操作的问题域中可能存在季节性影响,正如在销售预测或天气预测中可以预期的那样。异常情况可能由“黑天鹅”或罕见事件引起,如地缘政治事件甚至新冠疫情大流行。数据收集过程可能在某个时候引入错误,例如,如果上游系统存在错误或过程本身没有遵循或已改变。最后一个例子在需要手动输入数据的流程中可能特别普遍。如果销售人员要被信任正确标记客户资源管理(CRM)系统中销售的当前状态,那么训练或经验较少的销售人员可能不会准确或及时地标记数据。尽管在软件开发领域的许多方面取得了进步,但这种数据收集过程仍然非常普遍,因此你必须在自己的机器学习系统开发中防范这一点。可以通过尝试强制执行数据收集的更多自动化或为输入数据的人提供指南(例如下拉菜单)来略微减轻这种情况,但几乎可以肯定,大量的数据仍然以这种方式收集,并且在未来一段时间内也将如此。
现在应该很清楚,漂移是您系统需要考虑的重要方面,但实际上处理它是一个多步骤的过程。我们首先需要检测漂移。在部署的模型中检测漂移是 MLOps 的关键部分,作为机器学习工程师,您应该将其放在首位。然后我们需要诊断漂移的来源;这通常涉及负责监控的人员进行某种形式的离线调查。我们将提到的工具和技术将帮助您定义工作流程,从而开始自动化这个过程,以便在检测到问题时处理任何可重复的任务。最后,我们需要实施一些措施来补救漂移的影响:这通常涉及使用更新或修正的数据集重新训练模型,但可能需要重新开发或重写模型的关键组件。一般来说,如果您能够构建您的训练系统,以便根据对模型中漂移的了解有意识地触发重新训练,那么您将节省大量的计算资源,因为只有在需要时才进行训练。
下一节将讨论我们可以检测模型中漂移的一些方法。这将帮助我们开始构建解决方案中的智能重新训练策略。
检测数据漂移
到目前为止,我们已经定义了漂移,并且我们知道如果我们想构建复杂的训练系统,检测它将非常重要。下一个合乎逻辑的问题是,我们该如何做呢?
我们在上一节中给出的漂移定义非常定性;随着我们探索有助于我们检测漂移的计算和概念,我们可以开始使这些陈述更加量化。
在本节中,我们将大量依赖 Seldon 的alibi-detect Python 包,在撰写本文时,该包在Anaconda.org上不可用,但在 PyPI 上有。要获取此包,请使用以下命令:
pip install alibi
pip install alibi-detect
使用alibi-detect包非常简单。在下面的示例中,我们将使用来自sklearn的wine数据集,该数据集将在本章的其他地方使用。在这个第一个例子中,我们将数据分割为 50/50,并将其中一个集合称为参考集,另一个称为测试集。然后我们将使用 Kolmogorov-Smirnov 测试来证明这两个数据集之间没有出现数据漂移,正如预期的那样,然后人为地添加一些漂移以显示它已被成功检测:
-
首先,我们必须从
alibi-detect包中导入TabularDrift检测器,以及用于加载数据和分割数据的相关包:from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split import alibi from alibi_detect.cd import TabularDrift -
接下来,我们必须获取并分割数据:
wine_data = load_wine() feature_names = wine_data.feature_names X, y = wine_data.data, wine_data.target X_ref, X_test, y_ref, y_test = train_test_split(X, y, test_size=0.50, random_state=42) -
接下来,我们必须使用参考数据和提供的
p-value来初始化我们的漂移检测器,以便在统计显著性测试中使用。如果您希望使您的漂移检测器在数据分布中出现较小差异时触发,您必须选择一个更大的p_val:cd = TabularDrift(X_ref=X_ref, p_val=.05 ) -
现在我们可以检查测试数据集相对于参考数据集是否存在漂移:
preds = cd.predict(X_test) labels = ['No', 'Yes'] print('Drift: {}'.format(labels[preds['data']['is_drift']])) -
这返回了
'Drift: No'。 -
因此,正如预期的那样,我们没有检测到漂移(有关更多信息,请参阅以下 重要提示)。
-
尽管在这种情况下没有发生漂移,我们可以轻松地模拟一个场景,其中用于测量化学性质的化学装置经历了校准错误,所有值都被记录为比真实值高 10%。在这种情况下,如果我们再次在相同的参考数据集上运行漂移检测,我们将得到以下输出:
X_test_cal_error = 1.1*X_test preds = cd.predict(X_test_cal_error) labels = ['No', 'Yes'] print('Drift: {}'.format(labels[preds['data']['is_drift']])) -
这返回了
'Drift: Yes',表明漂移已被成功检测到。
重要提示
这个例子非常人为化,但有助于说明这一点。在一个标准的像这样的数据集中,随机抽取的 50%的数据和剩下的 50%的数据之间不会有数据漂移。这就是为什么我们必须人为地 移动 一些点来表明检测器确实起作用。在现实世界场景中,数据漂移可能由于测量所用的传感器更新;到消费者行为的改变;一直到数据库软件或模式的改变而自然发生。因此,要保持警惕,因为许多漂移情况不会像这个例子中那样容易被发现!
这个例子展示了如何用几行简单的 Python 代码检测数据集中的变化,这意味着如果我们不重新训练以考虑数据的新的属性,我们的机器学习模型可能会开始性能下降。我们还可以使用类似的技术来跟踪我们的模型性能指标,例如准确度或均方误差,是否也在漂移。在这种情况下,我们必须确保我们定期在新测试或验证数据集上计算性能。
第一个漂移检测例子非常简单,展示了如何检测一次性数据漂移的基本情况,特别是特征漂移。现在我们将展示检测 标签漂移 的例子,这基本上是相同的,但现在我们只是使用标签作为参考和比较数据集。我们将忽略前几个步骤,因为它们是相同的,并从我们有参考和测试数据集可用的点开始。
-
就像在特征漂移的例子中一样,我们可以配置表格漂移检测器,但现在我们将使用初始标签作为我们的基线数据集:
cd = TabularDrift(X_ref=y_ref, p_val=.05 ) -
我们现在可以检查测试标签相对于参考数据集的漂移:
preds = cd.predict(y_test) labels = ['No', 'Yes'] print('Drift: {}'.format(labels[preds['data']['is_drift']])) -
这返回了
'Drift: No'。 -
因此,正如预期的那样,我们没有检测到漂移。请注意,这种方法也可以用作一个好的合理性检查,以确保训练和测试数据标签遵循相似的分布,并且我们的测试数据抽样具有代表性。
-
就像上一个例子一样,我们可以模拟数据中的一些漂移,然后检查这确实被检测到了:
y_test_cal_error = 1.1*y_test preds = cd.predict(y_test_cal_error) labels = ['No', 'Yes'] print('Drift: {}'.format(labels[preds['data']['is_drift']]))
我们现在将转向一个更加复杂的场景,即检测概念漂移。
检测概念漂移
概念漂移在本节中进行了描述,并强调这种类型的漂移实际上完全是关于我们模型中变量之间关系的变化。这意味着按照定义,这种类型的案例更有可能很复杂,并且可能很难诊断。
你可以捕捉到概念漂移的最常见方式是通过监控你的模型随时间的变化性能。例如,如果我们再次处理wine分类问题,我们可以查看告诉我们模型分类性能的指标,随着时间的推移绘制这些指标,然后围绕我们可能在这些值中看到的趋势和异常构建逻辑。
我们已经使用过的alibi_detect包包含了一些用于在线漂移检测的有用方法,这些方法可以用来在概念漂移发生时及其影响模型性能时找到它。在这里,“在线”指的是漂移检测发生在单个数据点的层面上,因此即使在生产中数据完全按顺序到来时,这也可能发生。其中一些方法假设 PyTorch 或 TensorFlow 作为后端可用,因为这些方法使用未训练的自动编码器(UAEs)作为开箱即用的预处理方法。
作为例子,让我们通过创建和使用这些在线检测器之一,即在线最大均值差异方法,来走一遍。以下示例假设除了参考数据集X_ref外,我们还定义了预期的运行时间ert和窗口大小window_size变量。预期的运行时间是一个变量,表示检测器在引发假阳性检测之前应该运行的平均数据点数。这里的想法是,你希望预期的运行时间更大,但随着它的增大,检测器对实际漂移的敏感性会降低,因此必须找到平衡点。window_size是用于计算适当的漂移测试统计量的滑动数据窗口的大小。较小的window_size意味着你正在调整检测器以在短时间内找到数据或性能的急剧变化,而较长的窗口大小则意味着你正在调整以在更长的时间内寻找更微妙的变化。
-
首先,我们导入该方法:
from alibi_detect.cd import MMDDriftOnline -
我们随后使用前一段中讨论的一些变量设置初始化漂移检测器。我们还包含了我们想要应用的自举模拟次数,以便该方法计算检测漂移的一些阈值。
根据你为使用的深度学习库设置的硬件配置和数据的大小,这可能会花费一些时间。
ert = 50 window_size = 10 cd = MMDDriftOnline(X_ref, ert, window_size, backend='pytorch', n_bootstraps=2500) -
然后,我们可以通过从Wine数据集取测试数据,并一次输入一个特征向量,来模拟生产环境中的漂移检测。如果给定数据的特征向量由
x给出,我们就可以调用漂移检测器的predict方法,并从返回的元数据中检索'is_drift'值,如下所示:cd.predict(x)['data'] ['is_drift'] -
对测试数据的所有行执行步骤 2,并在检测到漂移的地方绘制一个垂直的橙色条,得到的图表如图 3.5 所示。
![]()
图 3.5:用于运行一些模拟漂移检测的测试集特征,来自我们使用的Wine数据集。
在这个例子中,我们可以从模拟数据的图表中看到,数据精度随时间发生了变化。如果我们想自动化检测这种行为,那么我们不仅需要简单地绘制这些数据,还需要开始以系统化的方式分析它,并将其纳入我们正在生产的模型监控过程中。
注意:Wine数据集的测试数据仅用于漂移示例。在生产中,这种漂移检测将在从未见过的新数据上运行,但原理是相同的。
现在你已经知道漂移正在发生,我们将继续讨论你如何开始决定在你的漂移检测器上设置哪些限制,然后介绍一些帮助你诊断漂移类型和来源的过程和技术。
设置限制
我们在本节中描述的许多关于漂移的技术与统计学和机器学习中的标准技术非常一致。你可以几乎“直接使用”这些技术来诊断一系列不同类型的问题,但我们还没有讨论如何将这些技术整合成一个连贯的漂移检测机制。在着手做这件事之前,考虑设置数据和模型可接受行为边界是非常重要的,这样你知道何时你的系统应该发出警报或采取某些行动。我们将称之为“设置漂移检测系统的限制”。
那么,你从哪里开始呢?这时事情变得稍微不那么技术性,而且肯定更多地围绕在商业环境中操作,但让我们先概述一些关键点。首先,了解哪些内容重要需要发出警报是很重要的。对你可以想到的所有指标中的偏差发出警报听起来可能是个好主意,但它可能仅仅创建了一个非常嘈杂的系统,难以找到真正值得关注的问题。因此,我们必须谨慎选择我们想要跟踪和监控的内容。接下来,我们需要了解检测问题的及时性要求。这与软件中的服务级别协议(SLAs)概念密切相关,它记录了系统所要求的和预期的性能。如果你的业务正在对用于危险条件下的设备运行实时异常检测和预测性维护模型,那么发出警报和采取行动的及时性要求可能相当高。然而,如果你的机器学习系统每周只进行一次财务预测,那么及时性限制可能就没有那么严格。最后,你需要设定限制。这意味着你需要仔细思考你正在跟踪的指标,并思考“什么构成了这里的坏?”或者“我们想被通知什么?”可能的情况是,作为项目发现阶段的一部分,你知道业务对回归模型感到满意,只要它提供合适的置信区间,其预测的准确性可以有很大的变化。
在另一种场景中,你正在构建的分类模型可能必须具有只在相对较窄的范围内波动的召回率;否则,它将危及下游流程的有效性。
漂移的诊断
虽然我们在另一个部分讨论了模型漂移可能存在各种原因,但归根结底,我们必须记住,机器学习模型只对特征进行操作以创建预测。这意味着,如果我们想诊断漂移的源头,我们不需要再往其他地方看,只需关注我们的特征即可。
那么,我们应该从哪里开始呢?首先,我们应该考虑的是,任何特征都有可能发生漂移,但并非所有特征在模型方面都同等重要。这意味着在优先考虑哪些特征需要补救措施之前,我们需要了解特征的重要性。
特征重要性可以通过模型相关或模型无关的方式计算。模型相关的方法特指基于树的模型,如决策树或随机森林。在这些情况下,特征重要性通常可以从模型中提取出来进行检查,具体取决于用于开发模型的包。例如,如果我们使用 Scikit-Learn 训练一个随机森林分类器,我们可以使用以下语法提取其特征重要性。在这个例子中,我们检索随机森林模型的默认特征重要性,这些重要性是通过平均不纯度减少(MDI)计算的,也称为“Gini 重要性”,并将它们放入一个有序的 pandas 序列以供后续分析:
import pandas as pd
feature_names = rf[:-1].get_feature_names_out()
mdi_importances = pd.Series(rf[-1].feature_importances_,
index=feature_names).sort_values(ascending=True)
尽管这非常简单,但由于几个原因,有时它可能会给出错误的结果。这里的特征重要性是通过一个不纯度度量计算的,这类度量可能会对高基数(例如数值)特征表现出偏差,并且仅基于训练集数据计算,这意味着它们不考虑模型对未见过的测试数据的泛化能力。在使用此类重要性度量时,这一点始终应牢记在心。
另一个标准的特征重要性度量,它是模型无关的,并缓解了 MDI 或 Gini 重要性的一些问题,是排列重要性。
这是通过选择我们感兴趣的特定特征,对其进行洗牌(即,通过某种重新组织方法移动特征矩阵中的值,向上、向下或通过其他方法),然后重新计算模型精度或误差来实现的。精度或误差的变化然后可以用作衡量该特征重要性的指标,因为重要性特征越少,模型性能在洗牌后变化应该越小。以下是一个使用 Scikit-Learn 的此方法的示例,再次使用我们在上一个示例中使用的相同模型:
from sklearn.inspection import permutation_importance
result = permutation_importance(
rf, X_test, y_test, n_repeats=10, random_state=42, n_jobs=2
)
sorted_importances_idx = result.importances_mean.argsort()
importances = pd.DataFrame(
result.importances[sorted_importances_idx].T,
columns=X.columns[sorted_importances_idx])
最后,还有一种非常流行的确定特征重要性的方法是计算特征SHAP(SHapley Additive exPlanation)值。这种方法借鉴了博弈论的思想,考虑了特征如何组合来影响预测。SHAP 值是通过在包含或排除考虑特征的所有特征排列上训练模型来计算的,然后计算该特征的预测值的边际贡献。这与排列重要性不同,因为我们不再只是排列特征值;我们现在实际上正在运行一系列不同的潜在特征集,包括或排除该特征。
您可以通过安装shap包来开始在您的模型上计算 SHAP 值:
pip install shap
然后我们可以执行以下语法,使用前面示例中的相同随机森林模型来定义一个SHAP 解释器对象并计算测试数据集中特征的 SHAP 值。我们假设这里的X_test是一个以特征名称为列名的 pandas DataFrame:
explainer = shap.Explainer(rf, predict, X_test)
shap_values = explainer(X_test)
注意,由于运行所有排列,计算 SHAP 值可能需要一些时间。shap_values本身不是特征重要性,但包含了为所有不同的特征组合实验中每个特征计算的 SHAP 值。为了确定特征重要性,你应该取每个特征的shap_values绝对值的平均值。如果你使用以下命令,这会为你完成并绘制结果:
shap.plots.bar(shap_values)
我们现在已经介绍了三种不同的方法来计算模型的特征重要性,其中两种完全与模型无关。特征重要性对于帮助你快速找到漂移的根源非常有帮助。如果你看到你模型的性能正在漂移或超过你设定的阈值,你可以使用特征重要性来集中你的诊断努力在最重要的特征上,并忽略不那么关键的特征的漂移。
现在我们已经介绍了一种有用的方法来帮助深入挖掘漂移,我们将讨论如何在发现似乎引起最大麻烦的特征或特征后如何着手解决它。
治疗漂移
我们可以采取几种方法来对抗漂移,以保持我们系统的性能:
-
移除特征并重新训练:如果某些特征正在漂移或表现出某种退化,我们可以尝试移除它们并重新训练模型。这可能会变得耗时,因为我们的数据科学家可能需要重新运行一些分析和测试,以确保这种方法从建模的角度仍然有意义。我们还得考虑我们移除的特征的重要性。
-
使用更多数据重新训练:如果我们看到概念漂移,我们可能只是注意到模型相对于数据的分布以及这些分布之间的关系已经过时。可能重新训练模型并包含更多最近的数据可以提高性能。还有选择在最近数据的一些选定部分上重新训练模型的选择。如果你能够诊断出数据中的某些重大事件或转变,例如 Covid-19 封锁的引入,这种方法可能特别有用。然而,这种方法可能难以自动化,因此有时也可以选择引入时间窗口方法,即训练一些预先选定数量的数据,直到现在的时间。
-
回滚模型:我们可以用之前的版本或甚至是一个基线模型来替换当前模型。如果你的基线模型更简单但性能方面也更可预测,例如应用了一些简单的业务逻辑,那么这可以是一个非常不错的做法。能够回滚到模型的先前版本需要你有在模型注册库周围建立一套良好的自动化流程。这非常类似于通用软件工程中的回滚,是构建健壮系统的一个关键组件。
-
重写或调试解决方案:可能存在这样的情况,我们处理的数据漂移非常严重,以至于现有的模型无法应对上述任何一种方法。重写模型的想法可能看起来有些激进,但这可能比你想象的更常见。例如,最初你可能部署了一个经过良好调优的 LightGBM 模型,该模型每天对一组五个特征进行二元分类。运行解决方案数月后,可能在你多次检测到模型性能漂移后,你决定最好进行一次调查,看看是否有更好的方法。在这种情况下,这尤其有帮助,因为现在你对将在生产中看到的数据有了更多的了解。你可能会发现,实际上,随机森林分类器在相同的生产数据场景上的平均性能并不如 LightGBM 模型,但它更稳定,表现更一致,并且更少触发漂移警报。你可能会决定,实际上,将这个不同的模型部署到同一个系统中对业务更有利,因为它将减少处理漂移警报的操作开销,并且这将是一个业务可以更加信任的模型。重要的是要注意,如果你需要编写一个新的管道或模型,在团队进行这项工作期间回滚到先前的模型通常是很重要的。
-
修复数据源:有时,最具挑战性的问题实际上与底层模型无关,而更多与数据收集方式的变化以及如何将数据传递到你的系统下游有关。在许多业务场景中,由于新流程的引入、系统的更新,甚至由于负责输入某些源数据的个人人员的变动,数据的收集、数据的转换或数据的特征可能会发生变化。作者自己的一个很好的例子是,当涉及到客户资源管理(CRM)系统时,销售团队输入的数据质量可能取决于许多因素,因此合理地预期数据质量、一致性和及时性可能会出现缓慢或突然的变化。
在这种情况下,正确的答案可能实际上不是一个工程问题,而是一个流程问题,与适当的团队和利益相关者合作,确保数据质量得到维护,并遵循标准流程。这将有利于客户和业务,但仍可能难以推销。
现在,我们可以开始构建解决方案,这些解决方案将自动触发我们的 ML 模型重新训练,如图图 3.6所示:

图 3.6:漂移检测和训练系统过程的示例。
其他监控工具
本章中的示例主要使用了 alibi-detect 包,但我们现在正处于开源MLOps工具的黄金时代。有几种不同的包和解决方案可供选择,你可以开始使用它们来构建监控解决方案,而无需花费一分钱。
在本节中,我们将快速介绍这些工具,并展示它们语法的一些基本要点,以便如果你想要开发监控管道,那么你可以立即开始,并知道在哪里最好使用这些不同的工具。
首先,我们将介绍Evidently AI(www.evidentlyai.com/),这是一个非常易于使用的 Python 包,它允许用户不仅监控他们的模型,还可以通过几行语法创建可定制的仪表板。以下是文档中入门指南的改编。
-
首先,安装 Evidently:
pip install evidently -
导入
Report功能。Report是一个对象,它收集多个指标的计算结果,以便进行可视化或以 JSON 对象的形式输出。我们将在稍后展示这种后者的行为:from evidently.report import Report -
接下来,导入一个称为度量预设的东西,在这种情况下是针对数据漂移的。我们可以将其视为一个模板化的报告对象,我们可以在以后对其进行自定义:
from evidently.metric_preset import DataDriftPreset -
接下来,假设你已经有了数据,然后你可以运行数据漂移报告。假设你手头有之前示例中的Wine数据集。如果我们使用
scikit-learn的train_test_split()方法将葡萄酒数据分成 50/50,我们将有两个数据集,我们再次使用它们来模拟参考数据集X_ref和当前数据集X_curr:data_drift_report = Report(metrics=[ DataDriftPreset(), ]) report.run( reference_data=X_ref, current_data=X_ref ) -
Evidently 随后提供了一些非常棒的功能,用于在报告中可视化结果。你可以使用几种不同的方法导出或查看这些结果。你可以将报告导出为 JSON 或 HTML 对象,以便消费或审查下游或其他应用程序。图 3.7和图 3.8显示了使用以下命令创建这些输出时的结果片段:
data_drift_report.save_json('data_drift_report.json') data_drift_report.save_html('data_drift_report.xhtml')![图片 B19525_03_07]()
图 3.7:Evidently 报告的 50/50 分割葡萄酒特征集的 JSON 输出。

图 3.8:Evidently 生成的 50/50 分割葡萄酒特征集的漂移报告的 HTML 版本。
渲染的 HTML 报告的一个优点是你可以动态地深入到一些有用的信息中。例如,图 3.9 显示,如果你点击进入任何特征,你会得到一个随时间变化的数据漂移图,而图 3.10 显示你也可以以同样的方式得到特征的分布图。

图 3.9:当你钻入 Evidently 报告中的葡萄酒特征时自动生成数据漂移图。

图 3.10:当你钻入 Evidently 报告中的葡萄酒特征集时自动生成的直方图,显示了特征的分布。
这只是触及了你可以用 Evidently 做到的事情的皮毛。有很多功能可以用来生成你自己的模型测试套件,监控功能,以及像我们看到的这样优雅地可视化所有内容。
现在我们已经探讨了模型和数据漂移的概念以及如何检测它们,我们可以继续讨论如何将我们在本章中讨论的许多概念自动化。
接下来的几节将深入探讨训练过程的不同方面,特别是如何使用各种工具自动化这个过程。
自动化训练
训练过程是模型工厂的一个组成部分,也是机器学习工程和传统软件工程之间主要区别之一。接下来的几节将详细讨论我们如何开始使用一些优秀的开源工具来简化、优化,在某些情况下,完全自动化这个过程的一些元素。
自动化层次结构
机器学习现在是软件开发的一个常见部分,也是商业和学术活动的一个主要部分,其中一个主要原因是工具的多样性。所有包含复杂算法有效和优化实现的包和库都允许人们在这些基础上构建,而不是每次遇到问题都要重新实现基础知识。
这是软件开发中抽象理念的一个强大表达,其中较低级别的单元可以在较高级别的实现中被利用和参与。
这个想法甚至可以进一步扩展到整个训练过程本身。在实施的最底层(但在底层算法的意义上仍然是一个非常高的层次),我们可以提供关于我们希望训练过程如何进行的详细信息。我们可以在代码中手动定义用于训练运行的精确超参数集(参见下一节关于优化超参数)。我称之为手动操作。然后我们可以再提高一个抽象层次,为我们的超参数提供范围和界限,供设计用于高效采样和测试我们模型性能的工具使用;例如,自动超参数调整。最后,在过去几年中,有一个更高层次的抽象引发了大量的媒体关注,即我们优化运行哪个算法。这被称为自动机器学习或AutoML。
围绕 AutoML 可能会有很多炒作,有些人宣称最终所有机器学习开发职位都将实现自动化。在我看来,这并不现实,因为选择你的模型和超参数只是巨大复杂工程挑战的一个方面(因此这是一本书而不是传单!)。然而,AutoML 是一个非常强大的工具,当你开始下一个机器学习项目时,应该将其添加到你的能力工具箱中。
我们可以将所有这些内容简洁地总结为自动化层次结构;基本上,作为机器学习工程师的你,在训练过程中希望有多少控制权?我曾经听到有人用汽车齿轮控制来描述这一点(感谢:Databricks at Spark AI 2019)。手动操作相当于驾驶手动挡汽车,完全控制齿轮:需要考虑的事情更多,但如果你知道自己在做什么,它可以非常高效。再高一个层次,你有自动挡汽车:需要担心的事情更少,这样你可以更多地专注于到达目的地、交通和其他挑战。这对很多人来说是一个不错的选择,但仍需要你具备足够的知识、技能和理解。最后,我们有自动驾驶汽车:放松,放松,甚至不用担心如何到达目的地。你可以专注于到达那里后你要做什么。
这种自动化层次结构在以下图中展示:

图 3.11:机器学习模型优化自动化的层次结构,其中 AutoML 是最自动化的可能性。
总结来说,这就是不同层次的训练抽象是如何相互关联的。
在接下来的几节中,我们将讨论如何开始构建超参数优化和 AutoML 的实现。我们不会涵盖“手动操作”,因为这很容易理解。
优化超参数
当你将某种数学函数拟合到数据上时,一些值在拟合或训练过程中被调整:这些被称为参数。对于机器学习,我们有一个更高级别的抽象,我们必须定义告诉我们所采用的算法如何更新参数的值。这些值被称为超参数,它们的选择是训练机器学习算法的重要暗黑艺术之一。
以下表格列出了一些用于常见机器学习算法的超参数,以展示它们可能采取的不同形式。这些列表并不全面,但旨在强调超参数优化并非一项简单的练习:
| 算法 | 超参数 | 这控制什么 |
|---|---|---|
| 决策树和随机森林 |
-
树的深度。
-
最小/最大叶子节点。
|
-
你的树有多少层。
-
每个级别可以发生的分支数量。
|
| 支持向量机 |
|---|
-
C
-
Gamma
|
-
误分类的惩罚。
-
训练点对径向基函数(RBF)核的影响半径。
|
| 神经网络(众多架构) |
|---|
-
学习率。
-
隐藏层数量。
-
激活函数。
-
许多更多。
|
-
更新步长大小。
-
你的网络有多深。
-
你神经元的触发条件。
|
| 逻辑回归 |
|---|
-
求解器
-
正则化类型。
-
正则化预因子。
|
-
如何最小化损失。
-
如何防止过拟合/使问题表现良好。
-
正则化类型的强度。
|
表 3.1:一些超参数及其对某些监督算法的控制。
更多的示例可以在以下表格中看到:
| 算法 | 超参数 | 这控制什么 |
|---|---|---|
| K 最近邻 |
-
K
-
距离度量。
|
-
聚类的数量。
-
如何定义点之间的距离。
|
| DBSCAN |
|---|
-
Epsilon
-
最小样本数。
-
距离度量。
|
-
考虑邻居的最大距离。
-
需要多少邻居才能被认为是核心。
-
如何定义点之间的距离。
|
表 3.2:一些超参数及其对某些无监督算法的控制。
所有这些超参数都有它们自己可以取的特定值集。这个超参数值的范围对于你想要应用于你的机器学习解决方案的不同潜在算法意味着有无数种定义一个工作模型(意味着一个不会破坏你使用的实现)的方法,但你是如何找到最优模型的呢?
这就是超参数搜索的用武之地。其概念是,对于有限数量的超参数值组合,我们希望找到一组能给出最佳模型性能的值。这是另一个类似于最初训练的优化问题!
在接下来的章节中,我们将讨论两个非常流行的超参数优化库,并展示如何在几行 Python 代码中实现它们。
重要提示
理解这些超参数库中使用的算法非常重要,因为你可能希望从每个库中使用几个不同的实现来比较不同的方法和评估性能。如果你没有查看它们在底层是如何工作的,你可能会轻易地进行不公平的比较——或者更糟糕的是,你可能会在不知情的情况下比较几乎相同的东西!如果你对这些解决方案的工作原理有一些深入了解,你也将能够更好地判断它们何时有益,何时过度。目标是掌握一些这些算法和方法,因为这将帮助你设计更全面的训练系统,其中算法调整方法相互补充。
Hyperopt
Hyperopt是一个开源的 Python 包,自称是用于复杂搜索空间的串行和并行优化,这些搜索空间可能包括实值、离散和条件维度。更多信息请查看以下链接:github.com/Hyperopt/Hyperopt。在撰写本文时,版本 0.2.5 包含了三个算法,用于在用户提供的搜索空间中执行优化:
-
随机搜索:该算法本质上是在你提供的参数值范围内选择随机数并尝试它们。然后根据你选择的性能目标函数评估哪些数字组合提供了最佳性能。
-
帕累托树估计器(TPE):这是一种贝叶斯优化方法,它对目标函数阈值以下和以上的超参数分布进行建模(大致为好和坏的评分者),然后旨在从好的超参数分布中抽取更多值。
-
自适应 TPE:这是 TPE 的一个修改版本,它允许对搜索进行一些优化,以及创建一个机器学习模型来帮助指导优化过程。
Hyperopt 的存储库和文档包含了一些很好的详细示例。我们在这里不会详细介绍这些示例。相反,我们将学习如何使用它来构建一个简单的分类模型,例如我们在第一章,机器学习工程导论中定义的模型。让我们开始吧:
-
在 Hyperopt 中,我们必须定义我们想要优化的超参数。例如,对于一个典型的逻辑回归问题,我们可以定义超参数空间,包括我们是否希望每次都重用从先前模型运行中学习到的参数(
warm_start),我们是否希望模型在决策函数中包含偏差(fit_intercept),用于决定何时停止优化的容差设置(tol),正则化参数(C),我们想要尝试的solver,以及任何训练运行中的最大迭代次数max_iter:from Hyperopt import hp space = { 'warm_start' : hp.choice('warm_start', [True, False]), 'fit_intercept' : hp.choice('fit_intercept', [True, False]), 'tol' : hp.uniform('tol', 0.00001, 0.0001), 'C' : hp.uniform('C', 0.05, 2.5), 'solver' : hp.choice('solver', ['newton-cg', 'lbfgs', 'liblinear']), 'max_iter' : hp.choice('max_iter', range(10,500)) } -
然后,我们必须定义一个要优化的目标函数。在我们的分类算法的情况下,我们可以简单地定义我们想要最小化的
loss函数为 1 减去f1-score。请注意,如果您使用fmin功能,Hyperopt 允许您的目标函数通过您的返回语句提供运行统计信息和元数据。如果您这样做,唯一的要求是您必须返回一个标记为loss的值和一个有效的状态值从Hyperopt.STATUS_STRING列表中(默认为ok,如果计算中存在您想要标记为失败的问题则为fail):def objective(params, n_folds, X, y): # Perform n_fold cross validation with hyperparameters clf = LogisticRegression(**params, random_state=42) scores = cross_val_score(clf, X, y, cv=n_folds, scoring= 'f1_macro') # Extract the best score max_score = max(scores) # Loss must be minimized loss = 1 - max_score # Dictionary with information for evaluation return {'loss': loss, 'params': params, 'status': STATUS_OK} -
现在,我们必须使用
fmin方法与 TPE 算法进行优化:# Trials object to track progress trials = Trials() # Optimize best = fmin( fn=partial(objective, n_folds=n_folds, X=X_train, y=y_train), space=space, algo=tpe.suggest, max_evals=16, trials=trials ) -
best的内容是一个包含您在定义的搜索空间中所有最佳超参数的字典。因此,在这种情况下,我们有以下内容:{'C': 0.26895003542493234, 'fit_intercept': 1, 'max_iter': 452, 'solver': 2, 'tol': 1.863336145787027e-05, 'warm_start': 1}
然后,您可以使用这些超参数来定义您的模型,以便在数据上进行训练。
Optuna
Optuna 是一个基于一些核心设计原则(如其 define-by-run API 和模块化架构)的软件包。在这里,“define-by-run”指的是,当使用 Optuna 时,用户不需要定义要测试的完整参数集,这是 define-and-run。相反,他们可以提供一些初始值,并要求 Optuna 建议要运行的实验集。这为用户节省了时间,并减少了代码的复杂度(对我来说是两个大优点!)。
Optuna 包含四种基本搜索算法:网格搜索、随机搜索、TPE和协方差矩阵自适应进化策略(CMA-ES)算法。我们之前已经介绍了前三种,但 CMA-ES 是混合中的一项重要补充。正如其名称所暗示的,它基于进化算法,并从多元高斯分布中抽取超参数样本。然后,它使用给定目标函数评估分数的排名来动态更新高斯分布的参数(协方差矩阵是其中之一)以帮助快速且稳健地在搜索空间中找到最优解。
然而,使 Optuna 的优化过程与 Hyperopt 不同的关键因素在于其应用了剪枝或自动早期停止。在优化过程中,如果 Optuna 检测到一组超参数的试验不会导致更好的整体训练算法,它将终止该试验。该软件包的开发者建议,通过减少不必要的计算,这可以在超参数优化过程中带来整体效率的提升。
这里,我们正在查看之前查看过的相同示例,但现在我们使用 Optuna 而不是 Hyperopt:
-
首先,当使用 Optuna 时,我们可以使用一个称为
Study的对象来工作,它为我们提供了一个方便的方法将搜索空间折叠到我们的objective函数中:def objective(trial, n_folds, X, y): """Objective function for tuning logistic regression hyperparameters""" params = { 'warm_start': trial.suggest_categorical('warm_start', [True, False]), 'fit_intercept': trial.suggest_categorical('fit_intercept', [True, False]), 'tol': trial.suggest_uniform('tol', 0.00001, 0.0001), 'C': trial.suggest_uniform('C', 0.05, 2.5), 'solver': trial.suggest_categorical('solver', ['newton-cg', 'lbfgs', 'liblinear']), 'max_iter': trial.suggest_categorical('max_iter', range(10, 500)) } # Perform n_fold cross validation with hyperparameters clf = LogisticRegression(**params, random_state=42) scores = cross_val_score(clf, X, y, cv=n_folds, scoring='f1_macro') # Extract the best score max_score = max(scores) # Loss must be minimized loss = 1 - max_score # Dictionary with information for evaluation return loss -
现在,我们必须以与 Hyperopt 示例中相同的方式设置数据:
n_folds = 5 X, y = datasets.make_classification(n_samples=100000, n_features=20,n_informative=2, n_redundant=2) train_samples = 100 # Samples used for training the models X_train = X[:train_samples] X_test = X[train_samples:] y_train = y[:train_samples] y_test = y[train_samples:] -
现在,我们可以定义我们之前提到的
Study对象,并告诉它我们希望如何优化objective函数返回的值,包括在study中运行多少次试验的指导。在这里,我们将再次使用 TPE 采样算法:from optuna.samplers import TPESampler study = optuna.create_study(direction='minimize', sampler=TPESampler()) study.optimize(partial(objective, n_folds=n_folds, X=X_train, y=y_ train), n_trials=16) -
现在,我们可以通过
study.best_trial.params变量访问最佳参数,它为我们提供了以下最佳情况下的值:{'warm_start': False, 'fit_intercept': False, 'tol': 9.866562116436095e-05, 'C': 0.08907657649508408, 'solver': 'newton-cg', 'max_iter': 108}
如你所见,Optuna 也非常简单易用且功能强大。现在,让我们来看看自动化层次结构的最后一级:AutoML。
重要注意事项
你会注意到这些值与 Hyperopt 返回的值不同。这是因为我们每种情况下只运行了 16 次试验,所以我们并没有有效地对空间进行子采样。如果你连续几次运行 Hyperopt 或 Optuna 样本,你可能会得到相当不同的结果,原因相同。这里给出的例子只是为了展示语法,但如果你有兴趣,你可以将迭代次数设置得非常高(或者创建更小的空间进行采样),两种方法的结果应该大致收敛。
AutoML
我们层次结构的最后一级是我们作为工程师对训练过程直接控制最少的一级,但也是我们可能以极少的努力获得良好答案的地方!
为了搜索你问题的许多超参数和算法,所需的开发时间可能很大,即使你编写了看起来合理的搜索参数和循环。
因此,在过去的几年里,已经部署了多种语言的多种AutoML库和工具。围绕这些技术的炒作意味着它们获得了大量的关注,这导致一些数据科学家质疑他们的工作何时会被自动化。正如我们在本章前面提到的,在我看来,宣布数据科学的死亡是极其过早的,并且从组织和业务绩效的角度来看也是危险的。这些工具被赋予了如此伪神话的地位,以至于许多公司可能会相信,仅仅使用它们几次就能解决他们所有的数据科学和机器学习问题。
他们是错的,但也是对的。
这些工具和技术确实非常强大,并且可以帮助改善某些事情,但它们并不是一个神奇的即插即用的万能药。让我们来探讨这些工具,并开始思考如何将它们融入我们的机器学习工程工作流程和解决方案中。
auto-sklearn
我们最喜欢的库之一,古老的 Scikit-Learn,注定会成为构建流行 AutoML 库的第一个目标之一。auto-sklearn 的一个非常强大的特性是,它的 API 被设计得非常灵活,使得优化模型和超参数的主要对象可以无缝地替换到你的代码中。
如同往常,一个例子将更清楚地展示这一点。在下面的例子中,我们将假设Wine数据集(本章的宠儿)已经被检索并按照其他示例(如检测漂移部分中的示例)分割成训练样本和测试样本:
-
首先,由于这是一个分类问题,我们需要从
auto-sklearn中获取的主要东西是autosklearn.classification对象:import numpy as np import sklearn.datasets import sklearn.metrics import autosklearn.classification -
我们必须首先定义我们的
auto-sklearn对象。这提供了几个参数,帮助我们定义模型和超参数调整过程将如何进行。在这个例子中,我们将为整体优化提供一个秒数上限,并为任何单个对 ML 模型的调用提供一个秒数上限:automl = autosklearn.classification.AutoSklearnClassifier( time_left_for_this_task=60, per_run_time_limit=30 ) -
然后,就像我们拟合一个正常的
sklearn分类器一样,我们可以拟合auto-sklearn对象。正如我们之前提到的,auto-sklearnAPI 已经被设计得看起来很熟悉:automl.fit(X_train, y_train, dataset_name='wine') -
现在我们已经拟合了对象,我们可以开始分析对象在优化运行期间所取得的成果。
-
首先,我们可以看到尝试了哪些模型,哪些被保留在对象中作为最终集成的一部分:
print(automl.show_models()) -
我们可以获取运行的主要统计数据:
print(automl.sprint_statistics()) -
然后,我们可以预测一些文本特征,正如预期的那样:
predictions = automl.predict(X_test) -
最后,我们可以使用我们最喜欢的指标计算器来检查我们的表现——在这种情况下,是
sklearn metrics模块:sklearn.metrics.accuracy_score(y_test, predictions) -
如您所见,开始使用这个强大的库非常简单,尤其是如果您已经熟悉
sklearn。
接下来,让我们讨论如何将这个概念扩展到神经网络,由于它们的潜在模型架构不同,神经网络有一个额外的复杂层。
AutoKeras
AutoML 在神经网络领域取得了巨大成功的一个特定领域是因为,对于神经网络来说,“什么是最优模型?”这个问题非常复杂。对于我们的典型分类器,我们通常可以想到一个相对较短、有限的算法列表来尝试。对于神经网络,我们没有这样一个有限的列表。相反,我们有一个本质上无限的神经网络架构集合;例如,将神经元组织成层以及它们之间的连接。寻找最优神经网络架构是一个问题,其中强大的优化可以使作为 ML 工程师或数据科学家的您的生活变得容易得多。
在这个例子中,我们将探索一个基于非常流行的神经网络 API 库(名为 Keras)构建的 AutoML 解决方案。难以置信,这个包的名字是——您猜对了——AutoKeras!
对于这个例子,我们再次假设Wine数据集已经被加载,这样我们就可以专注于实现细节。让我们开始吧:
-
首先,我们必须导入
autokeras库:import autokeras as ak -
现在,是时候享受乐趣了,对于
autokeras来说,这一点尤其简单!由于我们的数据是有结构的(表格形式,具有定义的架构),我们可以使用StructuredDataClassifier对象,它封装了自动神经网络架构和超参数搜索的底层机制:clf = ak.StructuredDataClassifier(max_trials=5) -
然后,我们只需拟合这个分类器对象,注意到它与
sklearnAPI 的相似性。记住,我们假设训练数据和测试数据存在于pandas DataFrames中,就像本章其他示例中那样:clf.fit(x=X_train, y=y_train) -
AutoKeras 中的训练对象包含一个方便的评估方法。让我们使用这个方法来看看我们的解决方案有多准确:
accuracy=clf.evaluate(x=X_train, y=y_train) -
有了这些,我们已经成功地在几行 Python 代码中执行了神经网络架构和超参数搜索。一如既往,阅读解决方案文档以获取有关您可以提供给不同方法的参数的更多信息。
现在我们已经介绍了如何创建性能良好的模型,在下一节中,我们将学习如何持久化这些模型,以便它们可以在其他程序中使用。
持久化你的模型
在上一章中,我们介绍了使用 MLflow 的一些模型版本控制的基本知识。特别是,我们讨论了如何使用 MLflow 跟踪 API 记录您的 ML 实验的指标。现在,我们将在此基础上构建,并考虑我们的训练系统应该与模型控制系统的一般触点。
首先,让我们回顾一下我们希望通过训练系统要完成的事情。我们希望尽可能自动化数据科学家在寻找第一个工作模型时所做的许多工作,这样我们就可以持续更新并创建新的模型版本,这些版本在未来仍然可以解决问题。我们还希望有一个简单的机制,允许将训练过程的结果与将在生产中执行预测的解决方案部分共享。我们可以将我们的模型版本控制系统视为连接我们在第二章“机器学习开发过程”中讨论的 ML 开发过程不同阶段的桥梁。特别是,我们可以看到跟踪实验结果的能力使我们能够在“Play”阶段保持结果,并在“Develop”阶段在此基础上进行构建。我们还可以在“Develop”阶段相同的地点跟踪更多的实验、测试运行和超参数优化结果。然后,我们可以开始标记性能良好的模型为部署的良好候选者,从而弥合“Develop”和“Deploy”开发阶段之间的差距。
如果我们现在专注于 MLflow(尽管还有许多其他解决方案可以满足模型版本控制系统所需的需求),那么 MLflow 的跟踪和模型注册功能很好地填补了这些桥梁角色。这在下图中以示意图的形式表示:

图 3.12:MLflow 跟踪和模型注册表功能如何帮助我们通过 ML 开发过程的不同阶段。
在第二章,机器学习开发过程中,我们只探讨了 MLflow 跟踪 API 的基本功能,用于存储实验模型运行元数据。现在,我们将简要介绍如何以非常有序的方式存储生产就绪模型,以便您可以开始执行模型部署。这是模型可以通过准备阶段进行推进的过程,如果您愿意,您可以在生产中交换模型。这是任何提供模型并作为部署解决方案一部分运行的训练系统的极其重要的部分,这正是本书的主题!
如前所述,我们在 MLflow 中需要的功能称为模型注册表,它使您能够管理模型在整个开发周期中的部署。在这里,我们将通过示例了解如何将记录的模型推送到注册表,如何更新注册表中的信息,例如模型版本号,然后如何将模型推进不同的生命周期阶段。我们将通过学习如何在其他程序中从注册表中检索给定的模型来结束本节,如果我们想要在分开的训练和预测服务之间共享模型,这是一个关键点。
在我们深入研究与模型注册表交互的 Python 代码之前,我们有一个重要的设置要执行。注册表仅在数据库用于存储模型元数据和参数时才有效。这与仅使用文件后端存储的基本跟踪 API 不同。这意味着在将模型推送到模型注册表之前,我们必须启动一个具有数据库后端的 MLflow 服务器。您可以通过在终端中执行以下命令使用本地运行的SQLite数据库来完成此操作。
您必须在阅读本节其余部分的代码片段之前运行此命令(此命令存储在本书的 GitHub 仓库中的简短 Bash 脚本中,位于github.com/PacktPublishing/Machine-Learning-Engineering-with-Python/blob/main/Chapter03/mlflow-advanced/start-mlflow-server.sh):
mlflow server \
--backend-store-uri sqlite:///mlflow.db \
--default-artifact-root ./artifacts \
--host 0.0.0.0
现在,后端数据库已启动并运行,我们可以将其作为模型工作流程的一部分使用。让我们开始吧:
-
让我们从记录本章早期训练的某个模型的指标和参数开始:
with mlflow.start_run(run_name="YOUR_RUN_NAME") as run: params = {'tol': 1e-2, 'solver': 'sag'} std_scale_clf = make_pipeline(StandardScaler(), RidgeClassifier(**params)) std_scale_clf.fit(X_train, y_train) y_pred_std_scale = std_scale_clf.predict(X_test) mlflow.log_metrics({ "accuracy": metrics.accuracy_score(y_test, y_pred_std_scale), "precision": metrics.precision_score(y_test, y_pred_std_scale, average="macro"), "f1": metrics.f1_score(y_test, y_pred_std_scale, average="macro"), "recall": metrics.recall_score(y_test, y_pred_std_scale, average="macro"), }) mlflow.log_params(params) -
在相同的代码块中,我们现在可以将模型记录到模型注册表中,并为模型提供一个名称以便稍后引用:
mlflow.sklearn.log_model( sk_model=std_scale_clf, artifact_path="sklearn-model", registered_model_name="sk-learn-std-scale-clf" ) -
现在,让我们假设我们正在运行一个预测服务,并且我们想要检索模型并使用它进行预测。在这里,我们必须编写以下代码:
model_name = "sk-learn-std-scale-clf" model_version = 1 model = mlflow.pyfunc.load_model( model_uri=f"models:/{model_name}/{model_version}" ) model.predict(X_test) -
默认情况下,在模型注册表中新注册的模型被分配
'Staging'阶段值。因此,如果我们想根据阶段而不是模型版本来检索模型,我们可以执行以下代码:stage = 'Staging' model = mlflow.pyfunc.load_model( model_uri=f"models:/{model_name}/{stage}" ) -
基于本章的所有讨论,我们的训练系统必须能够生成一个我们愿意部署到生产的模型。以下代码片段将模型提升到不同的阶段,称为
"Production":client = MlflowClient() client.transition_model_version_stage( name="sk-learn-std-scale-clf", version=1, stage="Production" ) -
这些是与模型注册表交互的最重要方式,我们已经涵盖了如何在训练(和预测)系统中注册、更新、提升和检索您的模型的基础知识。
现在,我们将学习如何将我们的主要训练步骤链接成单个单元,称为 管道。我们将介绍一些在单个脚本中执行此操作的标准方法,这将使我们能够构建我们的第一个训练管道。在 第五章,部署模式和工具 中,我们将介绍构建更通用的软件管道的工具,这些工具适用于您的 ML 解决方案(其中您的训练管道可能是一个组件)。
使用管道构建模型工厂
软件管道的概念足够直观。如果你在代码中将一系列步骤链接在一起,使得下一个步骤消耗或使用前一个步骤或步骤的输出,那么你就有一个管道。
在本节中,当我们提到管道时,我们将特别处理包含适合 ML 的处理或计算的步骤。例如,以下图表显示了这一概念可能如何应用于第一章,ML 工程简介中提到的营销分类器的一些步骤:

图 3.13:任何训练管道的主要阶段以及这与第一章,ML 工程简介中特定案例的映射。
让我们讨论一些构建代码中 ML 管道的标准工具。
Scikit-learn 管道
我们的老朋友 Scikit-Learn 随带了一些不错的管道功能。API 非常易于使用,正如您所期望的 Scikit-Learn 一样,但有一些概念我们在继续之前应该理解:
-
管道对象:这是将汇集我们所需所有步骤的对象,特别是
sklearn要求实例化的管道对象由转换器和估计器的序列组成,所有中间对象都具有.fit()和.transform()方法,最后一步是一个至少具有.fit()方法的估计器。我们将在下两点中解释这些术语。这种条件的原因是,pipeline对象将继承序列中最后一个项目的方法,因此我们必须确保最后一个对象中存在.fit()。 -
估计器:估计器类是
scikit-learn中的基本对象,任何可以在数据上拟合并预测数据的包中的内容,因此.fit()和.predict()方法是估计器类的子类。 -
转换器:在Scikit-Learn中,转换器是任何具有
.transform()或.fit_transform()方法的估计器,正如你可以猜到的,它们主要专注于将数据集从一种形式转换为另一种形式,而不是执行预测。
使用pipeline对象确实有助于简化你的代码,因为你不必编写多个不同的拟合、转换和预测步骤作为它们自己的函数调用,并管理数据流,你只需将它们全部组合在一个对象中,该对象为你管理这些操作并使用相同的简单 API。
Scikit-Learn 不断添加新的转换器和功能,这意味着可以构建越来越有用的管道。例如,在撰写本文时,Scikit-Learn 版本大于 0.20 也包含ColumnTransformer对象,它允许你构建对特定列执行不同操作的管道。这正是我们之前讨论的逻辑回归营销模型示例所希望做的,我们希望标准化数值并one-hot编码分类变量。让我们开始吧:
-
要创建此管道,你需要导入
ColumnTransformer和Pipeline对象:from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline -
为了展示如何在管道组成的转换器内部链式调用步骤,我们将在稍后添加一些插补。为此,我们需要导入
SimpleImputer对象:from sklearn.impute import SimpleImputer -
现在,我们必须定义包含插补和缩放两个步骤的数值转换器子管道,我们还必须定义将应用到此数值列的名称,以便我们可以在以后使用它们:
numeric_features = ['age', 'balance'] numeric_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler())]) -
接下来,我们必须对分类变量执行类似的步骤,但在这里,我们只需要定义一个
one-hot编码器的转换步骤:categorical_features = ['job', 'marital', 'education', 'contact', 'housing', 'loan', 'default','day'] categorical_transformer = OneHotEncoder(handle_unknown='ignore') -
我们必须使用
ColumnTransformer对象将这些预处理步骤汇集到一个单一的对象中,称为preprocessor,这将把我们的transformers应用到 DataFrame 的适当列上:preprocessor = ColumnTransformer( transformers=[ ('num', numeric_transformer, numeric_features), ('cat', categorical_transformer, categorical_features)]) -
最后,我们想在前面步骤的末尾添加 ML 模型步骤并最终完成管道。我们将称之为
clf_pipeline:clf_pipeline = Pipeline(steps=[('preprocessor', preprocessor), ('classifier', LogisticRegression())]) -
这是我们的第一个 ML 训练管道。
scikit-learnAPI 的美丽之处在于,clf_pipeline对象现在可以像库中的标准算法一样被调用。所以,这意味着我们可以编写以下内容:clf_pipeline.fit(X_train, y_train)
这将依次运行管道中所有步骤的fit方法。
之前的例子相对简单,但如果你需要更复杂的管道,有几种方法可以使这种管道更复杂。其中最简单且最可扩展的是 Scikit-Learn 创建自定义转换器对象的能力,这些对象继承自基类。你可以通过从BaseEstimator和TransformerMixIn类继承并定义自己的转换逻辑来实现这一点。作为一个简单的例子,让我们构建一个转换器,它接受指定的列并添加一个浮点数。这只是一个简单的示意图,向你展示如何实现;我无法想象在大多数情况下,将单个浮点数添加到你的列中会有多大帮助!
from sklearn.base import BaseEstimator, TransformerMixin
class AddToColumsTransformer(BaseEstimator, TransformerMixin):
def __init__(self, addition = 0.0, columns=None):
self.addition = addition
self.columns = columns
def fit(self, X, y=None):
return self
def transform(self, X, y=None):
transform_columns = list(X.columns)
if self.columns:
transform_columns = self.columns
X[transform_columns] = X[transform_columns] + self.addition
return X
你可以将这个转换器添加到你的pipeline中:
pipeline = Pipeline(
steps=[
("add_float", AddToColumnsTransformer(0.5, columns=["col1","col2",
"col3"]))
]
)
这个添加数字的例子实际上并不是使用基于类的转换器定义的最佳用例,因为这个操作是无状态的。由于没有训练或对输入值进行复杂操作的需求,需要类保留和更新其状态,所以我们实际上只是封装了一个函数。添加自定义步骤的第二种方式利用了这一点,并使用FunctionTransformer类来封装你提供的任何函数:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
def add_number(X, columns=None, number=None):
if columns == None and number == None:
return X
X[columns] = X[columns] + number
pipeline = Pipeline(
steps=[
(
"add_float",
FunctionTransformer(
add_number, kw_args={"columns": ["col1", "col2", "col3"],
"number": 0.5}),
)
]
)
通过构建这些示例,你可以开始创建可以执行任何你想要的特征工程任务的复杂管道。
为了结束本节,我们可以清楚地看到,将执行特征工程和训练模型的步骤抽象成一个单一对象的能力非常强大,这意味着你可以在各个地方重用这个对象,并使用它构建更复杂的流程,而无需不断重写实现的细节。抽象是一件好事!
现在我们将转向另一种编写管道的方法,使用 Spark ML。
Spark ML 管道
在本书中,我们一直在使用的一个工具集,在我们讨论扩展我们的解决方案时将特别重要:Apache Spark 及其 ML 生态系统。我们将看到,使用 Spark ML 构建类似的管道需要略微不同的语法,但关键概念与 Scikit-Learn 的情况非常相似。
关于 PySpark 管道有一些重要的事项需要提及。首先,与 Spark 所使用的 Scala 语言中的良好编程实践一致,对象被视为不可变的,因此转换不会在原地发生。相反,会创建新的对象。这意味着任何转换的输出都将在你的原始 DataFrame(或者确实是在新的 DataFrame 中)中需要创建新的列。
其次,Spark ML 估计器(即 ML 算法)都需要将特征组装成一个单列中的类似元组的对象。这与 Scikit-Learn 形成对比,在 Scikit-Learn 中,你可以将所有特征保留在其数据对象的列中。这意味着你需要习惯使用组装器,这些是用于将不同的特征列拉在一起的实用工具,尤其是在你处理必须以不同方式转换才能被算法使用的混合分类和数值特征时。
第三,Spark 有许多使用延迟评估的函数,这意味着它们只有在被特定操作触发时才会执行。这意味着你可以构建整个 ML 管道,而不必转换任何数据。延迟评估的原因是 Spark 中的计算步骤存储在一个有向无环图(DAG)中,这样在执行计算步骤之前,执行计划可以被优化,这使得 Spark 非常高效。
最后——这是一个小点——使用骆驼命名法而不是常见的蛇形命名法来编写 PySpark 变量是常见的,后者通常用于 Python 变量(例如,variableName与variable_name)。这样做是为了使代码与继承自 Spark 底层Scala代码的此约定的 PySpark 函数保持一致。
Spark ML 管道 API 以类似于 Scikit-Learn 管道 API 的方式利用 Transformer 和 Estimator 的概念,但也有一些重要的区别。第一个区别是 Spark ML 中的 Transformer 实现.transform()方法,但不实现.fit_transform()方法。其次,Spark ML 中的 Transformer 和 Estimator 对象是无状态的,因此一旦训练完成,它们就不会改变,并且只包含模型元数据。它们不存储有关原始输入数据的任何信息。一个相似之处在于,在 Spark ML 中,管道也被视为 Estimator。
我们现在将构建一个基本示例,以展示如何使用 Spark ML API 构建训练管道。
让我们看看:
-
首先,我们必须使用以下语法对前一个示例中的分类特征进行独热编码:
from pyspark.ml import Pipeline, PipelineModel categoricalColumns = ["job", "marital", "education", "contact", "housing", "loan", "default", "day"] for categoricalCol in categoricalColumns: stringIndexer = StringIndexer(inputCol=categoricalCol, outputCol=categoricalCol + "Index").setHandleInvalid("keep") encoder = OneHotEncoder( inputCols=[stringIndexer.getOutputCol()], outputCols=[categoricalCol + "classVec"] ) stages += [stringIndexer, encoder] -
对于数值列,我们必须进行插补:
numericalColumns = ["age", "balance"] numericalColumnsImputed = [x + "_imputed" for x in numericalColumns] imputer = Imputer(inputCols=numericalColumns, outputCols=numericalColumnsImputed) stages += [imputer] -
然后,我们必须执行标准化。在这里,我们需要在应用
StandardScaler时稍微聪明一点,因为它一次只能应用于一列。因此,在将我们的数值特征填充到单个特征向量中之后,我们需要为每个数值特征创建一个缩放器:from pyspark.ml.feature import StandardScaler numericalAssembler = VectorAssembler( inputCols=numericalColumnsImputed, outputCol='numerical_cols_imputed') stages += [numericalAssembler] scaler = StandardScaler(inputCol='numerical_cols_imputed', outputCol="numerical_cols_imputed_scaled") stages += [scaler] -
然后,我们必须将数值和分类转换的特征组合成一个特征列:
assemblerInputs = [c + "classVec" for c in categoricalColumns] +\ ["numerical_cols_imputed_scaled"] assembler = VectorAssembler(inputCols=assemblerInputs, outputCol="features") stages += [assembler] -
最后,我们可以定义我们的模型步骤,将其添加到
pipeline中,然后进行训练和转换:lr = LogisticRegression(labelCol="label", featuresCol="features", maxIter=10) stages += [lr] (trainingData, testData) = data.randomSplit([0.7, 0.3], seed=100) clfPipeline = Pipeline().setStages(stages).fit(trainingData) clfPipeline.transform(testData)
你可以将模型管道持久化,就像持久化任何Spark对象一样,例如,通过使用:
clfPipeline.save(path)
其中path是你目标位置的路劲。然后,你可以通过使用以下方式将这个管道读入内存:
from pyspark.ml import Pipeline
clfPipeline = Pipeline().load(path)
以下是我们在 PySpark 中使用Spark ML构建训练管道的方法。这个示例展示了足够的内容,让你开始使用 API 并构建你自己的、更复杂的管道。
现在,我们将以本章所涵盖内容的简要总结来结束本章。
摘要
在本章中,我们学习了如何构建我们想要在生产中运行的 ML 模型的训练和部署解决方案的重要主题。我们将这个解决方案的组件分解为处理模型训练、模型持久化、模型服务和触发模型重新训练的各个部分。我将之称为“模型工厂”。
我们深入探讨了某些重要概念的技术细节,深入研究了训练 ML 模型真正意味着什么,我们将之定义为学习 ML 模型是如何学习的。然后,我们花了一些时间讨论特征工程的关键概念,即在这个过程中你如何将数据转换成 ML 模型可以理解的形式。随后是关于如何考虑你的训练系统可以运行的不同模式的章节,我将之称为“训练-持久”和“训练-运行”。
我们随后讨论了如何使用各种技术在你的模型及其消耗的数据上执行漂移检测。这包括了一些使用 Alibi Detect 和 Evidently 包执行漂移检测的示例,以及如何计算特征重要性的讨论。
然后,我们介绍了训练过程可以在不同抽象级别自动化的概念,并在解释如何使用 MLflow 模型注册表程序化地管理你的模型阶段之后,最后部分涵盖了如何在 Scikit-Learn 和 Spark ML 包中定义训练管道。
在下一章中,我们将找出如何以 Pythonic 的方式打包一些这些概念,以便它们可以在其他项目中无缝部署和重用。
加入我们的社区 Discord
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第四章:打包汇总
在前面的章节中,我们介绍了很多你将需要使用以成功构建工作机器学习(ML)产品的工具和技术。我们也介绍了很多示例代码片段,帮助我们理解如何实现这些工具和技术。到目前为止,这一切都是关于什么我们需要编程,但这一章将专注于如何编程。特别是,我们将介绍并使用许多在更广泛的 Python 软件开发社区中普遍存在的技术、方法和标准,并将它们应用于 ML 用例。对话将围绕开发用户定义的库和包的概念展开,这些是你可以用来部署你的 ML 解决方案或开发新解决方案的可重用代码片段。重要的是要注意,我们在这里讨论的每一件事都可以应用于你 ML 项目开发生命周期中的所有 Python 开发活动。如果你在笔记本中做一些探索性数据分析,或者为项目的研究部分编写一些建模脚本,你的工作将从我们即将介绍的概念中受益匪浅。
在本章中,我们将回顾一些 Python 编程的基本要点,然后讨论编码标准和编写高质量 Python 代码的一些提示。我们还将简要介绍 Python 中的面向对象编程和函数式编程之间的区别,以及这些在其他你可能希望在其解决方案中使用的工具中的优势和协同作用。我们将讨论编写自己的 ML 包的一些良好用例,并探讨打包选项。接下来,我们将讨论代码中的测试、日志记录和错误处理,这些是构建既可信赖又能诊断当它不工作时的重要概念。然后,我们将深入探讨我们包的逻辑流程。最后,我们将探索如何确保我们不重新发明轮子,并使用其他地方已经存在的功能。
在本章中,我们将涵盖以下主题:
-
编写好的 Python
-
选择风格
-
打包你的代码
-
构建你的包
-
测试、日志记录和错误处理
-
不要重新发明轮子
重要提示
在 Python 中,包和库之间没有明确定义的区别。普遍的看法似乎是,库通常指的是你希望在其他项目中重用的任何代码集合,而包则指的是 Python 模块的集合(本章将涉及)。在这里,我们通常会互换使用这两个词,理解当我们说库时,我们通常指的是一组干净地组织在一起并至少包含一个包的代码。这意味着我们不会将包含你以后重用的一些代码的单个脚本视为我们的目的中的库。
谁不想编写更健壮、干净、易于阅读、可测试和性能良好的代码,这些代码可以被我们的同事、机器学习社区甚至我们的客户使用?让我们开始吧!
技术要求
与其他章节一样,运行本章示例所需的依赖项可以通过导航到书籍仓库中的第四章文件夹并创建一个新的 Conda 环境来安装:
conda env create –f mlewp-chapter04.yml
应该注意,本章主要关注 Python 的打包基础知识,因此要求比通常要轻一些!
编写好的 Python 代码
正如本书中讨论的那样,Python 是一种极其流行且非常灵活的编程语言。世界上一些最广泛使用的软件产品和一些最广泛使用的 ML 工程解决方案都使用 Python 作为核心语言。
在这个范围和规模下,很明显,如果我们想要编写同样出色的 ML 驱动软件,我们应该再次遵循这些解决方案已经采纳的最佳实践和标准。在接下来的章节中,我们将探讨实际中打包意味着什么,并开始真正提升我们的 ML 代码在质量和一致性方面的水平。
回顾基础知识
在我们深入研究更高级的概念之前,让我们确保我们都在同一页面上,并回顾一下 Python 世界的一些基本术语。如果你对 Python 的基础知识非常有信心,那么你可以跳过这一节,继续阅读本章的其余部分。然而,如果你是 Python 的新手或者有一段时间没有复习这些基础知识,那么回顾这些基础知识将确保你将正确的思维过程应用于正确的事物,并在编写代码时感到自信。
在 Python 中,我们有以下对象:
-
变量:存储一种或多钟类型数据的对象。在 Python 中,可以通过赋值创建变量,而不必指定类型,例如:
numerical_variable = 10 string_variable = 'string goes here' -
函数:一个自包含的代码单元,对变量(或另一个对象)执行逻辑步骤。在 Python 中,函数通过
def关键字定义,可以返回任何 Python 对象。函数在 Python 中是一等公民,这意味着你可以使用它们的对象名称(并重新引用它们),并且函数可以传递和返回函数。例如,如果我们创建一个从 pandas DataFrame 计算一些简单统计数据的函数,我们可以这样做。首先,定义它:def calculate_statistics(df): return df.describe()然后使用原始名称和一个名为
X_train的 DataFrame 运行它:calculate_statistics(X_train)然后,你可以使用新的名称重新分配函数并相应地调用它:
new_statistics_calculator = calculate_statistics new_statistics_calculator(X_train)然后,你可以将函数传递给更多的函数。例如,如果你将函数传递给一个新函数,该函数接受结果并返回一个 JSON 对象,那么你可以调用它!
def make_func_result_json(func ,df): return func(df).to_json make_func_result_json(calculate_statistics, X_train)这可以帮助快速将一些简单的代码片段构建成相对复杂的结构。
-
模块:这是一个包含函数、变量和其他对象定义和声明的文件,其内容可以被导入到其他 Python 代码中。例如,如果我们把之前示例中定义的函数放入一个名为
module.py的文件中,我们就可以在另一个 Python 程序(或 Python 解释器)中输入以下内容,以便使用其中包含的功能:import module module.calculate_statistics(df) module.make_func_result_json(module.calcualate_statistics, df) -
类:我们将在面向对象编程部分详细讨论类,但在此,只需知道这些是面向对象编程的基本单元,并且是包含逻辑上相关功能的好方法。
-
包:这是一个通过它们的目录结构相互关联的模块集合,并且构建得使得包中的模块可以通过
dot语法访问。例如,如果我们有一个名为feature的包,它包含帮助我们进行特征工程的模块,它可以组织如下:feature/ |-- numerical/ |-- analyze.py |-- aggregate.py |-- transform.py |-- categorical/ |-- analyze.py |-- aggregate.py |-- transform.py然后,如果我们想使用
numerical或categorical子模块中包含的功能,我们会使用如下所示的dot语法:import feature.categorical.analyze import feature.numerical.transform
现在让我们继续讨论一些通用的 Python 小技巧。
小技巧
现在我们来讨论一些使用 Python 时常常被忽视的小技巧,即使是对这种语言相当熟悉的人也不例外。以下的概念可以帮助你编写更紧凑和高效的代码,所以最好手头上有这些技巧。请注意,这个列表绝对不是详尽的:
-
生成器:这些是帮助我们创建某种迭代语法的便利函数。它们让我们免于编写大量的模板代码,内存效率高,并且具有非常实用的特性,例如能够暂停执行并自动保存内部状态。然后你可以在程序中的稍后位置继续迭代。在 Python 中,每当定义一个使用
yield语句的函数时,就会创建生成器。例如,在这里我们可以定义一个生成器,它将根据名为condition的谓词过滤给定的值列表:def filter_data(data, condition): for x in data: if condition(x): yield x在实际操作中,我们可以将这个技巧应用到从零到九十九的整数列表
data_vals上,并过滤掉低于某个阈值的值:for x in filter_data(data_vals, lambda x: x > 50): print(x)这将返回从五十到九十九的整数。
定义生成器表达式的另一种方式是使用圆括号中的迭代语句。例如,在这里我们可以定义一个生成器,它将遍历从零到九的平方:
gen1 = (x**2 for x in range(10)) for i in gen1: print(i)注意,你只能执行你的生成器一次;之后,它们就是空的。这是因为它们只为迭代的每一步存储所需的内存,所以一旦完成,就不会存储任何内容!
生成器是创建内存高效的数据操作步骤的强大方式,可以用于在 Apache Beam 等框架中定义自定义管道。这里我们不会涉及这个话题,但它绝对值得一看。例如,可以查看
medium.com/analytics-vidhya/building-a-data-pipeline-with-python-generators-a80a4d19019e上的文章。 -
列表推导式:这是一种语法,允许我们以极其紧凑的方式从任何可迭代的对象(例如
dict、list、tuple和str)中构建一个列表。这可以让你避免编写冗长、笨拙的循环,并有助于创建更优雅的代码。列表推导式在内存中创建整个列表,因此它们不如生成器高效。所以请明智地使用它们,并且只在必要时创建小列表。你通过在方括号中编写迭代逻辑来执行列表推导式,而不是生成器的圆括号。例如,我们可以创建第一个生成器示例中使用的数据:data_vals = [x for x in range(100)] -
容器和集合:Python 有一组有用的内置类型,被称为容器,包括
dict、set、list和tuple。Python 初学者从第一次接触这门语言开始就学习如何使用这些类型,但我们常常会忘记它们的增强版本:集合。这些集合在标准容器的基础上提供了额外的行为,这可能很有用。图 4.1 所示的表格总结了在python.org上 Python 3 文档中提到的某些有用的集合。docs.python.org/3/library/collections.xhtml当你处理数据操作时,这些工具非常有用,可以节省几行代码:
容器 描述 deque 这是一个双端队列,允许你以可扩展的方式向对象的任一端添加和删除元素。如果你想在大型数据列表的开头或结尾添加元素,或者想要在数据中搜索 X 的最后出现位置,这将非常有用。 Counter 计数器接受如 dict 或 list 之类的可迭代对象,并返回每个元素的计数。它们对于快速总结这些对象的内容非常有用。 OrderedDict 标准的 dict 对象不保持顺序,因此 OrderedDict 引入了这一功能。如果你需要以创建时的顺序遍历你创建的字典以进行新的处理,这将非常有用。 表 4.1:Python 3 中集合模块中的一些有用类型。
-
***args**和****kwargs**:当我们想在 Python 中调用一个函数时,我们通常会向它提供参数。在这本书中我们已经看到了很多这样的例子。但是,如果您定义了一个希望应用于可变数量参数的函数,会发生什么呢?这就是***args**和****kwargs**模式发挥作用的地方。例如,想象一下我们想要初始化一个名为Address的类,该类使用从在线表单收集的信息创建一个包含地址的单个字符串。我们可能无法提前知道用户用于地址的每个文本框将包含多少元素。然后我们可以使用
***args**模式(您不必将其称为 args,所以在这里我们将其称为address)。以下是该类的代码:class Address(object): def __init__(self, *address): if not address: self.address = None print('No address given') else: self.address = ' '.join(str(x) for x in address)然后,您的代码在这两种情况下都将工作得非常好,尽管构造函数的参数数量是可变的:
address1 = Address('62', 'Lochview', 'Crescent') address2 = Address('The Palm', '1283', 'Royston', 'Road')然后
address1.address将由'62 Lochview Crescent'和address2.address将由'The Palm 1283 Royston Road'给出。**kwargs**将这一想法扩展到允许一个可变数量的关键字参数。这在您有函数可能需要定义可变数量的参数,但需要将这些参数与名称关联时特别有用。例如,我们可能想要定义一个包含机器学习模型超参数值的类,其数量和名称将根据算法而变化。因此,我们可以做如下操作:class ModelHyperparameters(object): def __init__(self, **hyperparams): if not hyperparams: self.hyperparams = None else: self.hyperparams = hyperparams然后,代码将允许我们定义如下实例:
hyp1 = ModelHyperparameters(eps=3, distance='euclidean') hyp2 = ModelHyperparameters(n_clusters=4, max_iter=100)然后
hyp1.hyperparams将由{'eps': 3, 'distance': 'euclidean'}和hyp2.hyperparams由{'n_clusters': 4, 'max_iter': 100}给出。对于深入了解 Python 的工作原理,还有许多其他概念需要理解。目前,这些提示将足够我们构建本章的内容。
现在我们将考虑如何定义和组织这些元素,以便使您的代码可读且一致。
遵循标准
当您提到像“遵循标准”这样的东西时,在大多数情况下,您可能会原谅对方发出一声叹息和巨大的眼神翻白。标准听起来无聊且乏味,但它们实际上是确保您的工作保持一致和高质量的一个极其重要的部分。
在 Python 中,编码风格的事实标准是 Python Enhancement Proposal 8(PEP-8),由 Python 的创造者 Guido Van Rossum、Barry Warsaw 和 Nick Coghlan 撰写(www.python.org/dev/peps/pep-0008/)。它本质上是一系列指南、技巧、窍门和建议,旨在使代码保持一致和可读。遵循 PEP-8 风格指南在您的 Python 项目中的好处如下:
-
更高的一致性:这将帮助你编写在部署后不太可能出错的代码,因为跟踪程序的流程和识别错误和缺陷要容易得多。一致性还有助于简化代码的扩展和接口设计。
-
提高可读性:这将带来效率,因为你的同事甚至解决方案的用户可以理解正在做什么以及如何更有效地使用它。
那么,PEP-8 风格指南中包含什么内容?你应该如何考虑将其应用到你的机器学习项目中?关于全部细节,我建议你阅读之前提供的 PEP-8 文档。但在接下来的几段中,我们将深入探讨一些细节,这些细节将使你在付出最少努力的情况下,对代码的改进最大。
首先,让我们来谈谈命名规范。当你编写一段代码时,你需要创建几个变量、文件以及其他对象,比如类,所有这些都需要一个名字。确保这些名字可读且一致是使你的代码达到非常高标准的第一步。
PEP-8 的一些关键要点如下:
-
变量和函数名:建议这些由全部小写单词组成,用下划线分隔。它们还应帮助我们理解它们的作用。例如,如果你正在构建一个回归模型,并且想在函数中放置一些特征工程步骤以简化代码其他部分的复用和可读性,你可以将其命名为
Makemydata():def Makemydata(): # steps go here … return result将你的函数命名为
Makemydata()不是一个好主意,而将其命名为transform_features则更好:def transform_features() # steps go here … return result这个函数名符合 PEP-8 规范。
-
模块和包:建议这些都应该有简短的小写名字。一些你熟悉的例子,比如
pandas、numpy和scipy。Scikit-learn可能看起来像是违反了这条规则,但实际上并没有,因为包名是sklearn。风格指南提到,模块可以有下划线以提高可读性,但包不应该。如果我们有一个名为transform_helpers的模块在包中,这是可以接受的,但一个整个名为marketing_outlier_detection的包就会很糟糕! -
类:类应该有像
OutlierDetector、Transformer或PipelineGenerator这样的名字,这些名字清楚地说明了它们的作用,并且遵循大驼峰式或帕斯卡式(两者意思相同)的风格。
这些是你应该了解的一些最常见的命名规范。PEP-8 文档还涵盖了许多关于空白和行格式化的良好观点,我们在这里不会深入讨论。我们将以对作者从 PEP-8 的编程建议中喜欢的建议的讨论来结束本节。这些通常被忽视,如果忘记,可能会使代码既难以阅读又可能出错,所以请注意!
在所有关于风格的讨论中,一个值得记住的好点是,在 PEP-8 文档的顶部,它指出“愚蠢的一致性是小智者的恶魔”,并且在某些情况下有很好的理由忽略这些风格建议。再次提醒,请阅读完整的 PEP-8 文档,但如果你遵循这些要点,那么一般来说,你会写出干净且易于阅读的代码。
接下来,我们将介绍一些规则在当我们使用 Apache Spark 的 Python API 时并不适用。
编写好的 PySpark
在本节中,我们关注 Python 在数据科学和机器学习领域中的一个非常重要的特定版本。PySpark 代码已经在本书的多个示例中使用,因为它是分发你的数据工作负载(包括你的机器学习模型)的首选工具。在第六章“扩展”中,我们将学习更多关于 PySpark 的知识,但在这里我们只是简要地提到一些关于编码风格的观点。
如第三章中关于Spark ML 管道的章节“从模型到模型工厂”所述,由于 Spark 是用 Scala 编写的,PySpark(即 Spark 的 Python API)的语法已经继承了许多底层语言的语法风格。这意味着在实践中,你使用的许多方法都将使用驼峰式命名,这意味着使用驼峰式定义变量而不是标准的 Python PEP-8 命名约定(单词由下划线分隔)也是合理的。这种行为是我们应该鼓励的,因为它有助于阅读我们代码的人清楚地看到哪些部分是 PySpark 代码,哪些是(更)纯 Python 代码。为了强调这一点,当我们之前从pyspark.ml包中使用StringIndexer对象时,我们使用了StringIndexer而不是更符合 Python 习惯的string_indexer:
from pyspark.ml.feature import StringIndexer
stringIndexer = StringIndexer(inputCol=categoricalCol,
outputCol=categoricalCol)
关于 PySpark 代码的另一个重要点是,由于 Spark 是用函数式范式编写的,因此你的代码也遵循这种风格也是合情合理的。我们将在下一节中更深入地了解这意味着什么。
选择风格
本节将总结两种编码风格或范式,它们利用了 Python 不同的组织原则和能力。你编写代码是面向对象还是函数式风格可能只是审美选择。
然而,这种选择也可以提供其他好处,例如与问题的逻辑元素更一致的代码、更容易理解的代码,甚至更高效的代码。
在接下来的章节中,我们将概述每个范式的核心原则,并允许你根据自己的用例自行选择。
面向对象编程
面向对象编程(OOP)是一种编程风格,其中代码是围绕,你猜对了,抽象对象及其相关属性和数据组织的,而不是围绕解决方案的逻辑流程。OOP 的主题本身就值得一本(或几本!)书,所以我们将关注与我们机器学习工程之旅相关的关键点。
首先,在 OOP 中,你必须定义你的对象。这通过 Python 中的核心 OOP 原则——类来实现,类是程序中结构的定义,它将相关的数据和逻辑元素组合在一起。类是定义 OOP 中对象的模板。例如,考虑一个非常简单的类,它将一些用于在数据集上计算数值异常值的方法组合在一起。例如,如果我们考虑在第三章中探讨的管道,我们可能希望有一种使它在生产环境中应用得更简单的方法。因此,我们可能希望将 Scikit-Learn 等工具提供的一些功能封装到我们自己的类中,该类可以具有针对我们问题的特定步骤。在最简单的情况下,如果我们想要一个类来封装我们数据的标准化和应用通用的异常检测模型,它可能看起来像这样:
class OutlierDetector(object):
def __init__(self, model=None):
if model is not None:
self.model = model
self.pipeline = make_pipeline(StandardScaler(), self.model)
def detect(self, data):
return self.pipeline.fit(data).predict(data)
所有这些示例所做的只是允许用户跳过编写一些可能需要编写的步骤,以便完成工作。代码并没有消失;它只是被放置在一个方便的对象中,具有清晰的逻辑定义。在这种情况下,显示的流水线非常简单,但我们可以想象将其扩展到非常复杂且包含针对我们特定用例的逻辑。
因此,如果我们已经定义了一个异常检测模型(或如第三章中讨论的,从模型存储库中检索它,例如 MLflow),然后我们可以将其输入到这个类中,并仅用一行代码运行相当复杂的流水线,无论类中包含的复杂性如何:
model = IsolationForest(contamination=outliers_fraction,
random_state=42)
detector = OutlierDetector(model=model)
result = detector.detect(data)
如您从示例中看到的,这种实现模式似乎很熟悉,而且应该是这样的!Scikit-learn中有很多 OOP,每次您创建一个模型时,您都在使用这种范式。创建模型的行为是实例化一个类对象,您在数据上调用fit或predict的过程是调用类方法的例子。因此,前面的代码可能看起来不陌生,这是因为它不应该陌生!我们已经在使用 Scikit-Learn 进行机器学习时使用了 OOP。
尽管我们刚才已经说过,使用对象和理解如何构建它们显然是两个不同的挑战。因此,让我们通过构建自己的类的核心概念来探讨。这将为我们后来构建与我们自己的机器学习解决方案相关的更多类打下基础。
is defined with the class keyword and that the PEP-8 convention is to use upper CamelCase for the class name. It is also good practice to make your class names clear definitions of *things that do stuff*. For example, OutlierDetector, ModelWrapper, and DataTransformer are good class names, but Outliers and Calculation are not. You will also notice that we have something in brackets after the name of the class. This tells the class which objects to inherit functionality from. In the preceding example, we can see that this class inherits from something called object. This is actually the built-in base class in Python from which *all other objects inherit*. Therefore, since the class we defined does not inherit from anything more complex than object, you can think of this as essentially saying *the class we are about to build will have all of the functionality it needs defined within it; we do not need to use more complex functionality already defined in other objects for this class*. The syntax showing the inheritance from object is actually superfluous as you can just omit the brackets and write OutlierDetector, but it can be good practice to make the inheritance explicit.
接下来,你可以看到我们想要组合的功能是在类内部定义的。存在于类内部的功能被称为方法。你可以看到OutlierDetector只有一个名为detect的方法,但你并不限制你的类可以有多少个方法。
方法包含了你的类与数据和其他对象交互的能力,因此它们的定义是构建类时大部分工作的所在。
你可能认为我们遗漏了一个方法,即名为__init__()的方法。实际上,这并不是一个方法(或者你可以将其视为一个非常特殊的方法),它被称为构造函数。构造函数做它所说的——构建!它的任务是执行所有相关的设置任务(其中一些在后台发生,例如内存分配),当类作为对象初始化时。当示例定义detector时,构造函数被调用。正如你所见,你可以传递变量,然后这些变量可以在类内部使用。Python 中的类可以不定义显式的构造函数,但后台会创建一个。关于构造函数的最后一个观点是,它们不允许返回除None之外的内容,因此通常不写return语句。
你也可能在示例中看到,类内部有变量,还有一个有些神秘的self关键字。这允许类内部的方法和操作引用类的特定实例。所以,如果你定义了两个或一百个OutlierDetector对象的实例,它们内部属性可能有不同的值,但仍然具有相同的功能。
我们将在稍后为你的机器学习解决方案创建一些更复杂的面向对象风格,但就目前而言,让我们讨论我们可能想要使用的另一种编程范式——函数式编程。
函数式编程
函数式编程基于,你猜对了,函数的概念。在其核心,这种编程范式是尝试编写只接受数据并输出数据的代码片段,这样做而不创建任何可以改变的内部状态。函数式编程的一个目标是要编写没有由于状态管理不当而产生的意外副作用的代码。它还有确保你可以通过查看你编写的函数的return语句完全理解程序中的数据流的优点。
它使用了程序中的数据不允许就地更改的想法。这个概念被称为不可变性。如果你的数据(或任何对象)是不可变的,这意味着没有内部状态可以修改,如果你想对数据进行操作,实际上你必须创建新的数据。例如,在面向对象编程部分,我们再次回顾了标准化数据的概念。在函数式程序中,标准化数据不能覆盖未标准化数据;你需要将新数据存储在某个地方,例如,在相同的数据结构中的新列中。
一些编程语言的设计是以函数式原则为核心的,例如 F# 和 Haskell,但 Python 是一种通用语言,可以很好地容纳这两种范式。
你可能已经在其他 Python 代码中见过一些其他函数式编程的概念。例如,如果你曾经使用过 lambda 函数,那么这可以是一个函数式编程代码片段的强大方面,因为这是你定义匿名函数(那些没有指定名称的函数)的方式。所以,你可能见过类似这样的代码:
df['data_squared'] = df['data'].apply(lambda x: x**2)
在前面的代码块中,df 是一个 pandas DataFrame,而 data 只是一个数字列。这是帮助使 Python 中的函数式编程更简单的工具之一。其他此类工具包括内置函数 map()、reduce() 和 filter()。
例如,想象一下,我们有一些类似回顾基础知识部分中讨论的地址数据,其中我们讨论了args和****kwargs**的概念:
data = [
['The', 'Business', 'Centre', '15', 'Stevenson', 'Lane'],
['6', 'Mossvale', 'Road'],
['Studio', '7', 'Tottenham', 'Court', 'Road']
]
现在,我们可能想要编写一些代码,返回一个与该数据形状相同的列表的列表,但每个条目现在包含每个字符串中的字符数。这可能是我们机器学习管道中数据准备步骤的一个阶段。如果我们想要编写一些代码来实现这个功能,我们可以定义一个函数,它接受一个列表并返回一个新的列表,其中包含条目的字符串长度,如下所示:
def len_strings_in_list(data_list):
return list(map(lambda x: len(x), data_list))
这体现了函数式编程,因为数据是不可变的(没有内部状态的变化)并且函数是纯的(它只使用函数作用域内的数据)。然后我们可以使用函数式编程的另一个概念,即高阶函数,其中你将函数作为其他函数的参数。例如,我们可能想要定义一个函数,它可以应用任何基于列表的函数,但应用于列表的列表:
def list_of_list_func_results(list_func, list_of_lists):
return list(map(lambda x: list_func(x), list_of_lists))
注意,这是完全通用的;只要 list_func() 可以应用于一个列表,它就可以应用于列表的列表。因此,我们可以通过调用以下内容来获取我们想要的原结果:
list_of_list_func_results(len_strings_in_list, data)
这返回了期望的结果:
[[3, 8, 6, 2, 9, 4], [1, 8, 4], [6, 1, 9, 5, 4]]
Spark,这本书中已经多次使用过的工具,是用 Scala 语言编写的,Scala 也是一种通用语言,可以容纳面向对象和函数式编程。Spark 主要采用函数式风格;如果遵守诸如不可变性等原则,那么其分布计算的目标更容易得到满足。这意味着当我们通过这本书输入 PySpark 代码时,我们已经在不知不觉中学习了一些函数式编程实践(你注意到了吗?)。
事实上,在第三章,从模型到模型工厂中,我们构建的示例 PySpark 管道中的代码是这样的:
data = data.withColumn('label', f.when((f.col("y") == "yes"),
1).otherwise(0))
这是函数式的,因为我们创建的data对象实际上是一个添加了新列的新 DataFrame——我们无法简单地就地添加列。还有代码构成了我们从 Spark ML 库中构建的管道的一部分:
scaler = StandardScaler(inputCol='numerical_cols_imputed',
outputCol="numerical_cols_imputed_scaled")
这是在定义如何对一个 DataFrame 中的多个列进行缩放转换。注意您如何定义输入列和输出列,并且它们不能相同。这就是不可变性的体现——您必须创建新的数据而不是就地转换。
希望这能让您对 Python 中的函数式编程有所体会。这并不是本书中我们将主要使用的范式,但我们将用它来编写一些代码片段,特别是记住,当我们使用 PySpark 时,我们通常是在隐式地使用函数式编程。
我们现在将讨论如何打包您所编写的代码。
打包您的代码
在某些方面,Python 席卷全球是有趣的。它是动态类型和非编译的,因此与 Java 或 C++相比,它的工作方式可能相当不同。当我们思考如何打包我们的 Python 解决方案时,这一点尤其突出。对于编译型语言,主要目标是生成一个可以在所选环境中运行的编译工件——例如 Java 的jar文件。Python 要求您运行的环境具有适当的 Python 解释器和安装所需库和包的能力。也没有创建单个编译工件,因此您通常需要以原样部署整个代码库。
尽管如此,Python 确实已经席卷全球,尤其是在机器学习领域。作为考虑将模型投入生产的机器学习工程师,如果我们不了解如何打包和共享 Python 代码,以便帮助他人避免重复、信任解决方案并能够轻松将其集成到其他项目中,那将是我们的失职。
在接下来的章节中,我们首先将讨论我们所说的用户定义库以及以这种方式打包代码的一些优点。然后我们将定义您可以这样做的主要方式,以便您可以在生产中运行您的机器学习代码。
为什么需要打包?
在我们详细讨论 Python 中包或库的确切定义之前,我们可以通过使用一个工作定义来阐述其优势,即一个可以在不了解其实施细节的情况下运行的 Python 代码集合。
你已经从这个定义中了解了这样做第一个原因的本质:抽象。
将你的代码汇集到一个库或包中,以便其他开发人员和数据科学家可以在你的团队、组织或更广泛的社区中重用,这允许这些用户群体更快地解决问题。由于工作细节被抽象化,使用你代码的任何人都可以专注于实现你解决方案的功能,而不是试图理解并剖析每一行。这将导致项目开发和部署时间的减少,并鼓励首先使用你的代码!
第二个优势是,通过将所需的功能整合到库或包中,你将所有实现细节集中在一个地方,因此改进可以扩展。我们这里的意思是,如果 40 个项目正在使用你的库,并且有人发现了一个小错误,你只需要修复一次,然后在这些 40 个实现中重新部署或更新包。
这比向相关团队解释问题并得到 40 个不同的实施端修复要可扩展得多。这种整合还意味着,一旦你彻底测试了所有组件,你就可以更有信心地假设这个解决方案将在那 40 个不同的项目中平稳运行,而不必了解任何底层的细节。
图 4.1有助于展示包如何有助于实现代码的一次编写,多次使用哲学,这对于你想要构建可以以可扩展方式解决多个问题的 ML 解决方案来说至关重要:

图 4.1:为你的 ML 解决方案开发包允许你一次编写代码,但在不同的环境中多次使用。
下一个部分将基于这些关于打包的主要思想来讨论特定的用例,在这些用例中,打包我们的代码可以是有益的。
选择打包用例
首先,并不是所有的解决方案都应该成为库。如果你有一个极其简单的用例,你可能只需要一个简单的脚本来按计划运行你的 ML 解决方案的核心。在这种情况下,你仍然可以编写一个精心设计的系统和高性能的代码,但这不是库。同样,如果你的问题最好通过一个 Web 应用来解决,那么尽管会有很多组件,但这不会自然成为一个库。
你可能想将你的解决方案编写成库或包的一些良好理由如下:
-
你的代码解决的问题是一个常见问题,可能在多个项目或环境中出现。
-
你想要抽象出实现细节,以便执行和开发可以解耦,这样其他人使用你的代码就会更容易。
-
为了最小化你需要更改代码以实现错误修复的地方和次数。
-
为了使测试更加简单。
-
为了简化你的持续集成/持续部署(CI/CD)流程。
我们现在将深入探讨我们可能如何设计我们的包。
设计你的包
你的代码库布局远不止是风格上的考虑。它将决定你的代码在项目的每个实例中是如何被使用的——没有压力!
这意味着你需要仔细思考你想要如何布局你的代码,以及这如何影响使用模式。你需要确保所有主要组件都在代码库中有存在感,并且容易找到。
让我们通过一个基于我们在前几节中解决的异常值检测案例的示例来解决这个问题。
首先,我们需要决定我们想要创建什么样的解决方案。我们是在构建一个将运行 Web 应用程序或具有许多功能的独立可执行文件,还是在为其他人构建一个用于他们机器学习项目的库?实际上,我们可以选择做更多的事情!对于这个案例,让我们构建一个可以导入到其他项目中使用的包,也可以以独立执行模式运行。
为了为我们的包开发设定上下文,想象一下我们被要求开始构建一个解决方案,该解决方案可以运行一系列选定的无监督异常值检测模型。数据科学家发现,对于当前的问题,Isolation Forest模型性能最佳,但它们必须在每次运行时重新训练,并且包的用户应该能够通过配置文件编辑模型的配置。到目前为止,只研究了sklearn模型,但包的商业用户希望如果需要,这个功能可以扩展到其他建模工具。这个项目的技术要求意味着我们不能使用 MLflow。
别担心;在后面的章节中,当我们构建更多示例时,我们会放宽这个限制,以展示它们是如何相互关联的:
-
我们将要构建的包全部关于异常值,所以让我们称它为
outliers(我知道,有创意,对吧?)。为了清楚地展示所有内容是如何相互关联的,我们将从名为outlier_package的文件夹开始构建outliers包:outlier_package/ ├── outliers/ -
我们的设计将基于我们希望解决方案拥有的功能;在这种情况下,我们想要一个可以检测异常值的工具,所以让我们创建一个名为
detectors的子包:outlier_package/ ├── outliers/ ├── detectors -
在这里,我们将放置一些代码,这些代码将(稍后详细介绍)围绕外部库的一些基本模型进行包装。我们还将想要一些获取我们用于分析的数据的代码,因此我们也将为这个添加一个子包:
outlier_package/ ├── outliers/ ├── detectors ├── data -
我们已经可以看到我们的包正在成形。最后,我们希望有一个地方来存储配置信息,以及一个地方来存储可能在整个包中使用的辅助函数,所以让我们也为这些添加一个目录和子包:
outlier_package/ ├── outliers/ ├── detectors ├── data ├── configs ├── utils现在,这种布局并不是神圣不可侵犯的,或者以任何方式被规定。我们可以以我们想要的方式创建布局,并做我们认为合理的事情。
然而,在做这件事的时候,始终记住不要重复自己(DRY)、保持简单,傻瓜(KISS)以及 Python 的箴言“最好只有一种做某事的方式”是很重要的。如果你坚持这些原则,你将不会有问题。有关这些原则的更多信息,请参阅
code.tutsplus.com/tutorials/3-key-software-principles-you-must-understand--net-25161和www.python.org/dev/peps/pep-0020/。那么,这些子包中实际上有什么呢?当然,是底层代码!
-
在这种情况下,我们希望有一个接口,它可以在我们的检测实现和创建管道以及调用它们的语法之间提供接口,因此我们将构建一个简单的类并将其保存在
pipelines.py中。pipelines.py文件包含以下代码:from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline class OutlierDetector(object): def __init__(self, model=None): if model is not None: self.model = model self.pipeline = make_pipeline(StandardScaler(), self.model) def detect(self, data): return self.pipeline.fit(data).predict(data) -
然后,我们还需要一些定义我们想要交互的模型的东西。在这种情况下,我们将创建使用配置文件中存储的信息来决定实例化几个选定模型之一的代码。我们将所有这些功能放入一个名为
DetectionModels的类中。为了简洁,我们在此实例中省略了类中每个函数的详细信息:import json from sklearn.ensemble import IsolationForest class DetectionModels(object): def __init__(self, model_config_path=None): .... def create_model(self, model_name=None, params=None): .... def get_models(self): .... -
初始化方法在这里进行了扩展。请注意,我们编写了这段代码,以便能够在
config文件中定义一系列模型:class DetectionModels(object): def __init__(self, model_config_path=None): if model_config_path is not None: with open(model_config_path) as w: self.model_def = json.load(w) -
然后,
create_model方法可以根据参数和模型名称信息实例化模型。我们还构建了这个功能,以便如果我们想的话,实际上可以从不同的库中拉取模型的配置信息;我们只需在这个create_model函数中添加适当的实现逻辑,检查是否定义了sklearn或另一个模型,并在每种情况下运行适当的语法。我们还需要确保在OutlierDetector中生成的管道在每个情况下都是合适的:def create_model(self, model_name=None, params=None): if model_name is None and params is None: return None if model_name == 'IsolationForest' and params is not None: return IsolationForest(**params) -
最后,我们通过
get_models方法将前面提到的方法整合在一起,该方法返回一个包含在适当配置文件中定义的所有模型的列表,这些模型通过create_model方法实例化为sklearn对象:def get_models(self): models = [] for model_definition in self.model_def: defined_model = self.create_model( model_name=model_definition['model'], params=model_definition['params'] ) models.append(defined_model) return models您可能正在想为什么不直接读取适当的模型并应用它,无论它是什么?这可能是一个可行的解决方案,但我们在这里所做的是意味着只有经过项目团队批准的模型类型和算法才能进入生产,同时允许使用异构模型实现。
-
为了看到这一切在实际中是如何工作的,让我们在包的最高级别定义一个名为
__main__.py的脚本,它可以作为建模运行执行的主要入口点:from utils.data import create_data from detectors.detection_models import DetectionModels import detectors.pipelines from definitions import MODEL_CONFIG_PATH if __name__ == "__main__": data = create_data() models = DetectionModels(MODEL_CONFIG_PATH).get_models() for model in models: detector = detectors.pipelines.OutlierDetector(model=model) result = detector.detect(data) print(result) -
这里提到的
model_config.json文件由以下代码提供:[ { "model": "IsolationForest", "params": { "contamination": 0.15, "random_state": 42 } } ] -
definitions.py文件是一个包含我们想要在包中全局访问的相关路径和其他变量的文件,而不会污染命名空间:import os ROOT_DIR = os.path.dirname(__file__) MODEL_CONFIG_PATH = os.path.join(ROOT_DIR, "configs/model_config. json")我们可以看到,我们实际上并没有对结果做任何事情;我们只是打印它们以显示生成了输出。但在现实中,您要么将这些结果推送到其他地方,要么对它们进行统计分析。
您可以通过在终端中输入以下内容来运行此脚本:
python __main__.py或者,您也可以输入以下内容:
python -m outliers这就是您如何将功能打包到类、模块和包中的方法。给出的示例相对有限,但它确实让我们意识到不同的部分是如何组合在一起并执行的。
重要提示
这里给出的示例是为了展示您如何通过使用本章讨论的一些技术将代码连接起来。这并不一定是将所有这些部分组合起来的唯一方法,但它确实很好地说明了如何创建自己的包。所以,请记住,如果您看到改进此实现或将其适应您自己的目的的方法,那么真是太棒了!
在下一节中,我们将探讨如何构建此代码的发行版,以及如何允许我们和用户将outliers包安装为正常的 Python 包,这样我们就可以在其他项目中使用了。
构建您的包
在我们的示例中,我们可以使用setuptools库将我们的解决方案打包起来。为了做到这一点,您必须创建一个名为setup.py的文件,该文件包含您解决方案的重要元数据,包括它所需的相关包的位置。以下代码块展示了如何为一个简单的包装本章提到的某些异常检测功能的包进行此操作:
from setuptools import setup
setup(name='outliers',
version='0.1',
description='A simple package to wrap some outlier detection
functionality',
author='Andrew McMahon',
license='MIT',
packages=['outliers'],
zip_safe=False)
我们可以看到setuptools允许您提供元数据,例如包的名称、版本号和软件许可。一旦您在项目的根目录中有了这个文件,您就可以做几件事情:
-
首先,您可以将包本地安装为可执行文件。这意味着您可以在想要运行的代码中像导入其他 Python 库一样导入您的库:
pip install . -
你可以创建一个源分布的包,这样所有的代码都可以高效地打包在一起。例如,如果你在你的项目根目录运行以下命令,就会在名为
dist的文件夹中创建一个gzipped tarball:python setup.py sdist -
你可以创建一个打包的分布,这是一个可以被用户立即解包并使用的对象,而无需像源分布那样运行
setup.py脚本。最合适的打包分布是众所周知的 Pythonwheel。在你的项目根目录中运行以下命令将创建wheel并将其放入dist文件夹:python setup.py bdist_wheel -
如果你打算使用 pip 分发你的代码,那么打包一个
source分布和一个wheel,并让用户决定要做什么是有意义的。所以,你可以构建这两个,然后使用一个名为twine的包将这两个分布上传到 PyPI。如果你想这样做,那么你需要注册一个 PyPI 账户,网址为pypi.org/account/register/。只需在你的项目根目录中一起运行前两个命令,并使用twine upload命令:python setup.py sdist bdist_wheel twine upload dist/*
关于打包的更多信息,你可以阅读由Python 打包权威机构(PyPA)提供的资料和教程,网址为www.pypa.io/en/latest/。
下一节简要介绍了我们可以如何使用 Makefile 自动化围绕构建和测试我们的包的一些步骤。
使用 Makefile 管理你的环境
如果我们在 UNIX 系统上并且安装了make实用程序,那么我们可以进一步自动化我们想要在不同场景下运行的解决方案的许多步骤。例如,在以下代码块中,我们有一个 Makefile,允许我们运行我们模块的主入口点,运行我们的测试套件,或使用run、test和clean目标清理任何工件:
MODULE := outliers
run:
@python -m $(MODULE)
test:
@pytest
.PHONY: clean test
clean:
rm -rf .pytest_cache .coverage .pytest_cache coverage.xml
这是一个非常简单的 Makefile,但我们可以通过分层添加更多命令使其变得尽可能复杂。如果我们想运行一组特定的命令,我们只需调用make,然后是目标名称:
make test
make run
这是一种强大的方法,可以抽象出你通常必须手动输入的许多终端命令。它还充当了解决方案其他用户的文档!
我们刚才通过的这个例子相当简单;现在让我们使事情更加复杂。实际上,我们可以使用 Makefile 来管理我们的环境,并帮助简化我们的开发过程,这样就不需要大量的认知努力来跟踪我们环境的状态。
以下示例借鉴了 Kjell Wooding 或 GitHub 上的hackalog的大量优秀工作,具体是他的仓库github.com/hackalog/make_better_defaults。
这个仓库是他 2021 年 PyData 全球会议上的演讲的基础,该演讲的标题为“Makefiles:使你的 Conda 环境更易于管理的绝妙技巧”。
首先,包含一个 Makefile.help 文件允许在使用 make 命令时提供可定制的帮助提示。如果我们假设我们仍然在主项目目录中,并在终端运行 make,你将看到 图 4.2 中的输出。

图 4.2:从 Makefile 示例中提供的帮助信息。
这个帮助信息是通过在主 Makefile 中使用 PROJECT_NAME 变量进行定制的,该变量已被设置为 mlewp-ed2-ch4-outliers。实际上,Makefile 的顶部为这个项目设置了几个变量:
MODULE := outliers
PROJECT_NAME := mlewp-ed2-ch4-outliers
PYTHON_INTERPRETER := python3
ARCH := $(shell $(PYTHON_INTERPRETER) -c "import platform;
print(platform.platform())")
VIRTUALENV := conda
CONDA_EXE ?= ~/anaconda3/bin/conda
EASYDATA_LOCKFILE := environment.$(ARCH).lock.yml
MODULE 变量仍然是指之前提到的包名。PYTHON_INTERPRETER、CONDA_EXE 和 VIRTUALENV 应该是自我解释的。ARCH 会从本地系统获取架构信息。EASYDATA_LOCKFILE 指的是在我们工作时创建的一个文件,它帮助我们跟踪项目中所有依赖项的完整列表。
你可以看到帮助信息清楚地指出了 Makefile 的不同目标,因此让我们逐一探索这些目标。首先,为了标准化为项目创建新的 Conda 环境(如果需要的话),有一些步骤可以合并在一起:
$(EASYDATA_LOCKFILE): environment.yml
ifeq (conda, $(VIRTUALENV))
$(CONDA_EXE) env update -n $(PROJECT_NAME) -f $<
$(CONDA_EXE) env export -n $(PROJECT_NAME) -f $@
# pip install -e . # uncomment for conda <= 4.3
else
$(error Unsupported Environment `$(VIRTUALENV)`. Use conda)
endif
.PHONY: create_environment
# Set up virtual (conda) environment for this project
create_environment: $(EASYDATA_LOCKFILE)
ifeq (conda,$(VIRTUALENV))
@rm -f $(EASYDATA_LOCKFILE)
@echo
@echo "New conda env created. Activate with:"
@echo ">>> conda activate $(PROJECT_NAME)"
@echo ">>> make update_environment"
ifneq ("X$(wildcard .post-create-environment.txt)","X")
@cat .post-create-environment.txt
endif
else
$(error Unsupported Environment `$(VIRTUALENV)`. Use conda)
endif
逐步走过这个步骤,这段代码表明如果 conda 是虚拟环境,那么就继续创建或更新一个带有项目名称的 Conda 环境,然后将环境导出到 environment.yml 文件中;然后在此之后,将环境配置导出到锁文件中。它之所以这样工作,是因为 $< 指的是第一个前提条件(在这种情况下,是 environment.yml 文件),而 $@ 指的是目标名称(在这种情况下,是 EASYDATA_LOCKFILE 变量)。触发此操作后,第二个块会检查 conda 是否是虚拟环境管理器,然后在删除锁文件之前,在终端为用户提供一些指导。注意,这里的 @ 指的是终端命令。
Makefile 中下一个重要的部分是处理在需要时为你更新环境的部分,这在项目的“开发”阶段通常会是这种情况:
.PHONY: update_environment
## Install or update Python Dependencies in the virtual (conda) environment
update_environment: environment_enabled $(EASYDATA_LOCKFILE)
ifneq ("X$(wildcard .post-update-environment.txt)","X")
@cat .post-update-environment.txt
endif
这个块确保如果你在终端运行以下命令:
make update_environment
然后,你将创建一个新的 lockfile.yml 文件,其中包含环境的最新版本的所有详细信息。还有一个 delete_environment 目标,它将清除锁文件并删除 Conda 环境,以及一些其他不需要在此处关心的辅助目标,但你可以在书籍仓库中探索它们。
将所有这些放在一起,使用基于 Makefile 的这种方法的工作流程将是:
-
为项目创建一个初始的
environment.yml文件。这可能非常简单;例如,对于我们在本章中构建的outliers包,我开始于一个看起来像这样的environment.yml文件:name: mlewp-ed2-ch4-outliers channels: - conda-forge dependencies: - python=3.10.8 - scikit-learn - pandas - numpy - pytest - pytest-cov - pip -
使用以下命令创建环境:
make create_environment -
更新环境,这将创建第一个锁文件:
make update_environment -
在你开发解决方案的过程中,如果你需要一个新的包,请进入
environment.yml文件,在运行make update_environment之前添加你需要的依赖项。这里的理念是,通过在environment.yml文件中强制要求这些包,而不是手动安装它们,你可以创建一个更可重复和更健壮的工作流程。你将无法忘记你安装了什么和没有安装什么!例如,如果我想向这个环境添加
bandit包,我会使用我的文本编辑器或 IDE 进入environment.yml文件,并简单地在conda或pip依赖项中添加这个依赖项:name: mlewp-ed2-ch4-outliers channels: - conda-forge dependencies: - python=3.10.8 - scikit-learn - pandas - numpy - pytest - pytest-cov - bandit - pip
就这样!这就是你如何使用 Makefiles 以更可重复的方式管理你的 Conda 环境。如上所述,如果你想重新开始,你可以通过运行以下命令来删除环境:
make delete_environment
这就涵盖了管理你的 Python 开发环境的方法。现在我们将讨论目前最流行的 Python 依赖项管理和打包工具之一,Poetry。
用 Poetry 来诗意地表达
Python 包管理是关于这种语言的一些事情,肯定不会让人在屋顶上大声赞扬。甚至包括我最热情的支持者(包括我自己)在内的最热情的支持者都广泛承认,Python 的包管理,坦白说,有点混乱。我们使用setup.py和制作轮子所经历的例子是一些最被接受的方式,正如提到的,由 PyPA 推荐。但它们仍然不是你期望从这个语言中得到的简单或最直观的方法,尽管它通常将这些作为关键设计原则。
幸运的是,在过去的几年里,已经有一些重大发展,其中之一我们将在此详细讨论。这是创建 Python 打包和依赖项管理工具 Poetry。Poetry 的好处包括其易用性和极大地简化了解决方案的打包和依赖项管理。最明显的是,它通过只要求一个配置文件,即pyproject.toml文件,而不是可能包括setup.py、setup.cfg、MANIFEST.in或Pipfile配置文件的潜在设置来实现这一点。还有一个很大的优势,即依赖项文件被锁定,因此不会自动更新,这意味着管理员(你)必须明确指出依赖项的变化。这有助于使项目更稳定。
所以,这听起来很棒,但我们如何开始呢?嗯,毫不奇怪,我们首先使用pip安装这个工具:
pip install poetry
然后,如果你想启动一个新的利用 Poetry 的项目,你将进入你希望你的包所在的适当目录,并运行以下命令:
poetry new my-ml-package
这将创建一个类似以下的子目录结构:
├── README.md
├── my_ml_package
│ └── __init__.py
├── poetry.lock
├── pyproject.toml
└── tests
└── __init__.py
tests 文件夹将是我们放置单元测试的地方,正如本章的 测试 部分所述。pyproject.toml 是目录中最重要的文件。它指定了与项目相关的所有主要元数据,并组织成包含生产环境和开发及测试的包依赖的块。
当我运行前面的命令时生成的文件是 图 4.3 中显示的:

图 4.3:当我们创建新项目时,Poetry 创建的 pyproject.toml 文件。
在第一种情况下,这在一个 [tool.poetry] 块下给出,它涵盖了关于包的高级信息。然后是 [tool.poetry.dependencies],目前它只包含 Python 3.10 版本,没有其他内容,因为我还没有用它安装其他任何东西。[build-system] 部分包含构建时依赖的详细信息,这里只列出了 poetry-core。
如果我们想添加一个新的依赖项,例如 pytest,我们可以运行类似以下命令:
poetry add pytest
这将在终端输出类似于 图 4.4 中显示的内容。

图 4.4:向 Poetry 管理的项目添加新包的输出。
这也将更新 pyproject.toml 文件,添加新的依赖项,如图 4.5 所示。

图 4.5:添加新依赖项后的更新 pyproject.toml 文件。
现在,[tool.poetry.dependencies] 部分是你应该定义所有需要在运行时安装到你的包中的包的地方,因此你并不想在这个部分添加大量的测试包。
相反,Poetry 允许你通过指定一个 [tool.poetry.group.dev.dependencies] 块来定义一个列出你的开发依赖项的块,就像 图 4.6 中显示的那样。

图 4.6:包含一组开发依赖项的 pyproject.toml 文件。
当你安装 Poetry 时,它会为你创建一个自己的虚拟环境,以便从你的系统其他部分创建适当的隔离。如果你在 Linux 系统上,你可以通过运行以下命令来激活此环境:
source /path/to/venv/bin/activate
或者你也可以运行:
poetry shell
如果你已经通过 Conda、venv 或其他工具运行了一个 Python 虚拟环境,那么 Poetry 实际上是知道这一点的,并在其中工作。这非常有帮助,因为你可以使用 Poetry 来管理这个虚拟环境,而不是从头开始。在这种情况下,你可能会在终端中得到一些类似于 图 4.7 中显示的输出。

图 4.7:如果你已经在 Python 虚拟环境中工作,poetry 命令的输出。
要关闭此环境但不是你正在运行的 shell,你可以使用以下命令:
deactivate
如果你想关闭环境和 shell(请注意,这可能会关闭你的终端),你可以输入以下命令:
exit
要安装你在pyproject.toml文件中添加的依赖项,你可以运行:
poetry install
这将根据你的pyproject.toml文件中列出的所有依赖项进行下载和安装,从pip获取最新版本,或者它会获取并安装这些包的版本,正如.lock文件中所列。这是为了确保即使有多个人在环境中工作并运行poetry install命令,环境也能保持稳定,包版本一致。这正是本章前面关于 Makefile 部分使用.lock文件的原因。例如,当为my-ml-package项目运行install命令时,输出显示在图 4.8中。

图 4.8:Poetry 从.lock 文件中安装包以保持环境稳定性。
所有的前述命令都是关于环境的基本管理,但当我们想要对这个环境做些什么时怎么办呢?好吧,如果你有一个名为main.py的脚本,你可以通过以下命令使用 Poetry 配置的环境来运行它:
poetry run python main.py
在my-ml-package中我们没有类似的东西。相反,由于我们正在构建一个库,我们可以通过运行以下命令来打包和部署包:
poetry build
这将显示图 4.9中所示的输出。

图 4.9:Poetry 构建我们的简单包时的输出。
如果你想要发布到 PyPI,这只有在你有正确配置的凭据并注册为用户时才有效,你可以直接运行poetry publish命令。如果你想要发布到其他私有仓库,你可以运行:
poetry publish –r private-repository-location
在所有这些之后,你可能已经看到了 Poetry 在包开发方面如何使事情变得更加清晰。我们已经能够管理稳定开发和生产(如可以由多个开发者工作而不用担心损坏的 Python 环境),构建和发布我们的包,以及运行我们想要的任何脚本和进程——所有这些只需几个命令即可完成!
接下来,让我们讨论一些我们可以采取的步骤,以确保我们的包是健壮的,并且可以在出现问题时优雅地工作或失败,并且可诊断。
测试、日志记录、安全性和错误处理
构建执行 ML 任务的代码可能看起来像是最终目标,但它只是拼图中的一块。我们还希望对此代码能够正常工作有信心,如果它不能,我们能够修复它。这就是测试、日志记录和错误处理概念发挥作用的地方,接下来的几节将对此进行高层次概述。
测试
将你的机器学习工程代码与典型的研究脚本区分开来的最重要的特性之一是存在稳健的测试。对于你设计的任何部署系统来说,它必须能够被信任不会总是失败,而且你可以在开发过程中捕捉到问题。
幸运的是,由于 Python 是一种通用编程语言,它充满了用于对软件进行测试的工具。在本章中,我们将使用pytest,这是 Python 代码中最受欢迎、功能强大且易于使用的测试工具集之一。pytest 特别适用于初学者,因为它专注于构建作为独立 Python 函数的测试,这些函数非常易于阅读,而其他包有时会导致创建笨拙的测试类和复杂的assert语句。让我们通过一个例子来深入了解。
首先,让我们从这个章节的其余部分中定义的outliers包的一些代码片段开始编写测试。我们可以定义一个简单的测试来确保我们的数据辅助函数实际上创建了一些可以用于建模的数值数据。要在 pytest 中运行这种测试,我们首先在测试目录的某个位置创建一个名为test_或_test的文件——pytest 将自动找到具有这种命名的文件。例如,我们可能编写一个名为test_create_data.py的测试脚本,其中包含我们需要测试的所有在解决方案中创建数据的函数的逻辑。让我们用一个例子来明确这一点:
-
从包中导入我们将需要的相关模块以及我们需要的其他测试相关内容。在这里,我们导入
pytest,因为我们将在后续步骤中使用它的一些功能,但通常情况下,你不需要导入这个模块:import numpy import pytest import outliers.utils.data -
然后,由于我们想要测试创建数据的函数,最好只生成一次数据,然后以各种方式测试其属性。为此,我们使用 pytest 的
fixture装饰器,它允许我们定义一个可以被多个测试读取的对象。在这里,我们使用它,这样我们就可以使用dummy_data来应用我们的测试,dummy_data只是create_data函数的输出:@pytest.fixture() def dummy_data(): data = outliers.utils.data.create_data() return data -
最后,我们实际上可以编写测试了。这里有两个示例,用于测试由函数创建的数据集是否是
numpy数组,以及它是否有超过100行的数据:def test_data_is_numpy(dummy_data): assert isinstance(dummy_data, numpy.ndarray) def test_data_is_large(dummy_data): assert len(dummy_data)>100我们可以编写尽可能多的这些测试和尽可能多的这些类型的测试模块。这使我们能够在我们整个包中创建高程度的测试覆盖率。
-
然后,你可以在项目的顶层终端中输入以下命令来运行包中的所有测试:
$ pytest -
然后,你将看到一条类似的消息,告诉我们哪些测试已运行,哪些已通过和失败:
![图 4.3 – PyTest 中成功单元测试的输出]()
图 4.10:pytest 中成功单元测试的输出。
之前的例子展示了如何编写和执行一些基本的数据工具测试。现在,我们可以通过测试包中的一些更复杂的功能来扩展这一点——即模型创建过程。
-
与之前的案例类似,我们在
tests/test_detectors.py中创建一个脚本来保存我们的测试。由于我们正在测试更复杂的功能,我们将在脚本中导入包的更多部分:import pytest from outliers.detectors.detection_models import DetectionModels from outliers.detectors.pipelines import OutlierDetector from outliers.definitions import MODEL_CONFIG_PATH import outliers.utils.data import numpy as np -
我们将会有与 步骤 2 中创建的虚拟数据相同的设置,但现在我们也有一个用于在测试中创建一些示例模型的设置:
@pytest.fixture() def example_models(): models = DetectionModels(MODEL_CONFIG_PATH) return models -
我们最终的设置为我们创建了一个示例检测实例,基于之前的模型设置:
@pytest.fixture() def example_detector(example_models): model = example_models.get_models()[0] detector = OutlierDetector(model=model) return detector -
现在我们已经准备好测试一些模型创建功能。首先,我们可以测试我们创建的模型不是空的
对象:def test_model_creation(example_models): assert example_models is not None -
然后,我们可以测试是否可以使用在 步骤 6 中创建的
DetectionModels实例成功检索模型:def test_model_get_models(example_models): example_models.get_models() is not None -
最后,我们可以测试应用模型找到的结果是否通过一些简单的测试。这表明我们包的主要部分在端到端应用程序中正在正常工作:
def test_model_evaluation(dummy_data, example_detector): result = example_detector.detect(dummy_data) assert len(result[result == -1]) == 39 #number of anomalies to detect assert len(result) == len(dummy_data) #same numbers of results assert np.unique(result)[0] == -1 assert np.unique(result)[1] == 1 -
如同 步骤 4 中所述,我们可以从命令行运行完整的测试套件。我们添加了一个详细程度标志来返回更多信息并显示通过的单个测试。这有助于确认我们的数据工具和模型测试正在被触发:
pytest –-verbose -
输出结果展示在下面的屏幕截图上:
![图 4.4 – 数据和模型功能成功测试的输出]()
图 4.11:数据和模型功能成功测试的输出。
这些测试的运行可以通过在我们的存储库中包含 githooks 或通过使用其他工具(如项目中使用的 Makefile)来自动化。
现在,我们将继续考虑如何记录代码运行时的信息,这有助于调试和一般监控你的解决方案。
保护你的解决方案
作为任何类型的软件工程师,我们应始终非常清楚,使用人们使用的产品的喜悦背后有一个反面。这个反面是,确保解决方案对用户安全且安全是你的工作。用本叔叔的话说,“能力越大,责任越大。”
现在,网络安全是一个独立的巨大学科,所以我们在这里无法做到公正。以下章节将简单地介绍一些有用的工具,并解释如何使用它们的基本原理,以使你的解决方案更加安全和可靠。
首先,我们需要了解我们可以创建安全解决方案的不同方式:
-
测试应用程序和代码本身是否存在内部错误。
-
筛选软件包和扫描其他用于安全漏洞的代码。
-
测试数据泄露和数据暴露。
-
开发稳健的监控技术,特别是针对上述要点,而不是特别关注您机器学习模型的监控,这在本书的其他地方已经讨论过。
在第一种情况下,这主要指的是像我们的单元测试方法这样的东西,我们已经在其他地方讨论过,但简而言之,这指的是测试您编写的代码的功能,以确保它按预期工作。在模型监控的部分提到,对机器学习模型的预期性能进行标准测试可能很困难,因此需要特定的技术。
在这里,我们更关注通用应用代码和包装主要模型的解决方案。
在筛选包和代码的情况下,这是一个非常相关且幸运的是易于实施的挑战。读者可能还记得,在 2022 年,全球出现了一波活动,组织机构和软件工程师试图应对在基于 Java 的 Log4j 库中发现的一个错误的发现。错误和安全漏洞总会发生,并不总是被检测到,但这个要点是关于在您的解决方案中自动扫描您使用的代码和包以主动找到这些漏洞,从而为您的代码用户节省大量麻烦(以及更糟糕的事情)。
数据泄露现在是一个极其重要的话题。欧盟的通用数据保护条例(GDPR)对客户数据的管理和保养给予了极大的重视。由于机器学习系统本质上是以数据驱动的应用,这意味着围绕隐私、使用、存储以及许多其他点在设计实现中考虑变得极为重要。重要的是要注意,我们在这里讨论的内容远远超出了“垃圾输入,垃圾输出”的数据质量问题,实际上是在讨论您如何安全地持有和传输使您的机器学习系统工作的数据。
分析您的代码中的安全问题
正如您在这本书的整个过程中所注意到的,Python 开源社区几乎涵盖了您能想到的所有挑战,在安全方面也不例外。要执行您自己代码的静态分析并检查在开发过程中可能引入的漏洞,您可以使用开源的 Bandit 包,bandit.readthedocs.io/en/latest/。这是一个专注于在源代码中查找安全问题的 linter,它运行起来非常简单。
首先,像往常一样,我们需要安装 Bandit。现在我们可以使用在早期关于构建您的包部分学到的 Makefile 魔法来完成这项工作,因此我们将 Bandit 包添加到environment.yml文件中的pip依赖项中,并运行命令:
make update_environment
然后,要在您的源代码上运行 Bandit,您只需运行:
bandit -r outliers
正如我们在 第二章,机器学习开发过程 中提到的,自动化任何你希望反复运行的开发步骤总是很有用的。我们可以通过在 Git 目录中的 .pre-commit-config.yaml 中添加以下内容来实现 Bandit:
repos:
- repo: https://github.com/PyCQA/bandit
rev: '' # Update me!
hooks:
- id: bandit
这意味着在每次提交后,我们将运行 bandit 命令,正如前两个步骤中概述的那样。
在 图 4.12 中,运行 Bandit 在一些示例代码上的输出以一系列类似以下的内容给出。

图 4.12:Bandit 在一段典型代码上的输出。
输出末尾会跟一个小型的总结报告,如图 4.13 所示。

图 4.13:Bandit 工具在其输出末尾提供了一个高级总结,用于诊断你的代码库状态。
Bandit 有许多其他功能,但这也展示了如何轻松地开始并开始分析你的 Python 代码库中的潜在问题。
分析依赖项以查找安全问题
正如我们概述的,我们不仅想要扫描我们编写的代码中的安全漏洞;同样重要的是,我们尝试找到我们在解决方案中使用的任何包中的问题。这可以通过使用类似 Python 的 safety 工具来实现,pypi.org/project/safety/。Safety 使用一个包含已知 Python 安全问题的标准化数据库,然后比较你在解决方案中找到的任何包与这个数据库。请注意,正如 safety 文档所指出:
默认情况下,它使用仅限非商业用途的开放 Python 漏洞数据库 Safety DB。
对于所有商业项目,Safety 必须升级到使用 PyUp API 的键选项。
下面是一个使用 Bandit 在与示例相同的源代码树上使用此工具的示例:
-
如果你还没有安装 safety,请安装它:
pip install safety -
在运行之前,你需要切换到你的源代码树顶层文件夹:
safety check
当我在包含我们一直在构建的 outlier_package 文件夹上运行此程序时,我得到了如图 4.14 所示的终端输出:

图 4.14:safety 在 outliers 包上的输出。
如 图 4.15 所示,我们被警告当前版本不能用于扫描商业软件,并且如果需要这样做,你应该获取一个 API 密钥。对于本项目来说,这是可以的。工具已经发现了一个与 wheel 包版本相关的漏洞。在检查项目中的 environment.yml 文件后,我们发现我们可以将其更新到建议的 0.38.1 版本。这如图 4.15 所示。

图 4.15:更新 environment.yml 文件以避免安全工具产生的错误。
注意,在这个environment.yml文件中使用的 Conda 通道没有包含版本 0.38.1 或更高版本的wheel包,因此它被添加到了pip依赖项中,如图 4.16所示。

图 4.16:更新 outliers 包中environment.yml文件的 pip 依赖项。
执行此操作并重新运行命令后:
safety check
解决方案在图 4.17所示的报告中得到了良好的健康报告。

图 4.17:更新已识别的包后,安全工具返回零安全漏洞。
虽然为了获得完整的功能集确实需要商业许可证,但它仍然可以非常有助于在您的依赖项中检测问题。
日志记录
接下来,确保在代码运行时报告不同操作的状态,以及发生的任何错误,这一点非常重要。这有助于使代码更易于维护,并在出现问题时帮助您进行调试。为此,您可以使用 Python 的logging库。
您可以通过以下逻辑在代码中实例化日志记录器:
import logging
logging.basicConfig(filename='outliers.log',
level=logging.DEBUG,
format='%(asctime)s | %(name)s | %(levelname)s |
%(message)s')
这段代码定义了我们的日志消息格式,并指定了级别为DEBUG或更高的日志消息将发送到outliers.log文件。然后我们可以使用logging库提供的非常易于使用的语法来记录与我们的代码运行状态相关的输出和信息:
logging.debug('Message to help debug ...')
logging.info('General info about a process that is running ...')
logging.warning('Warn, but no need to error ...')
With the settings shown in the first logging snippet, this will result in the following logging messages being written to outliers.log:
2021-08-02 19:58:53,501 | root | DEBUG | Message to help debug ...
2021-08-02 19:58:53,501 | root | INFO | General info about a process that is running ...
2021-08-02 19:58:53,501 | root | WARNING | Warn, but no need to error ...
我们迄今为止所展示的确实是日志记录的基础,并且到目前为止它假设尽管事情可能并不完美,但没有发生错误。这显然并不总是情况!那么,如果我们想将异常或错误记录到我们的日志文件中怎么办呢?
好吧,这通常是通过使用logging.error语法来完成的,但我们必须考虑一个重要的点,那就是仅仅记录我们抛出了错误的事实通常是不够的;我们还想记录错误的详细信息。所以,正如在错误处理部分所讨论的,我们知道我们可以在某些代码上执行try except子句,然后抛出一个异常。在这种情况下,我们想要将那个异常的详细信息记录到我们的日志目标中。为此,我们需要知道logging.error方法(以及logging.debug方法)有一些重要的关键字参数我们可以使用。有关关键字参数的更多信息,请参阅本章中关于技巧和窍门的部分。根据日志记录文档,docs.python.org/3/library/logging.xhtml#logging.debug,关键字参数exc_info和stack_info被指定为布尔值,而extra是一个字典。关键字参数exc_info = True指定了我们希望在日志调用中返回异常信息,stack_info = True将返回更详细的异常堆栈跟踪信息(包括日志调用),而extra可以设置为一个包含开发者定义的额外信息的字典。
在这种情况下,额外的信息随后以记录的初始部分提供,作为事件标识符的一部分。这是一种为您的日志调用提供一些定制信息的好方法。
作为例子,让我们考虑一个定制的特征转换函数,在这种情况下,它实际上不会做任何有用的事情,只是像这样返回原始 DataFrame:
def feature_transform(df: pd.DataFrame) -> pd.DataFrame:
"""Transform a dataframe by not doing anything. Just for demo.
:param df: a dataframe.
:return: df.mean(), the same dataframe with the averages of eachcolumn.
"""
return df.mean()
如果我们想在函数失败时引发异常并记录有关错误的详细信息,我们可以写如下内容:
try:
df_transformed = feature_transform(df)
logging.info("df successfully transformed")
except Exception as err:
logging.error("Unexpected error", exc_info=True)
如果我们在以下简单的虚拟 pandas DataFrame 上运行此代码,代码将无问题执行:
df = pd.DataFrame(data={'col1': [1,2,3,4], 'col2': [5,6,7,8]})
如果我们这次做同样的事情,但这次我们在没有 pandas DataFrame mean() 语法的东西上运行代码,比如一个列表:
list_of_nums = [1,2,3,4,5,6,7,8]
try:
df_transformed = feature_transform(list_of_nums)
logging.info("df successfully transformed")
except Exception as err:
logging.error("Unexpected error", exc_info=True)
for the screenshot, shown in *Figure 4.18*.

图 4.18:当我们使用 exc_info = True 标志时对日志文件的输出。
错误处理
在本节中需要涵盖的最后一点是错误处理。重要的是要记住,当你是一名机器学习工程师时,你的目标是构建能够工作的产品和服务,但这一部分的重要部分是认识到事情并不总是按预期工作!因此,重要的是你构建的模式允许在运行时(不可避免的)错误升级。在 Python 中,这通常是通过 异常 的概念来实现的。你可以通过你使用的核心 Python 函数和方法引发异常。例如,想象一下你没有定义变量 x 就运行了以下代码:
y = 10*x
以下异常将被引发:
NamError: name 'x' is not defined
对于我们作为工程师来说,重要的是我们应该构建我们可以自信地控制错误流的解决方案。我们可能不希望代码在发生错误时崩溃,或者我们可能希望确保在特定的预期边缘情况下发生非常具体的消息和日志记录。为此,最简单的技术是通过 try except 块,如下面的代码块所示:
try:
do_something()
except:
do_something_else()
在这种情况下,如果 do_something() 运行时遇到错误,则执行 do_something_else()。
我们现在将结束对如何在构建解决方案时提高效率的评论。
Python 中的错误处理通常围绕“异常”的概念构建,这些异常只是中断程序预期功能的事件。
Python 中异常的完整列表包含大约 50 种不同类型。以下是从 Python 文档中摘录的部分,省略号突出显示了我没有展示的完整细节:
BaseException
+-- SystemExit
+-- KeyboardInterrupt
+-- GeneratorExit
+-- Exception
+-- StopIteration
+-- StopAsyncIteration
+-- ArithmeticError
| +-- FloatingPointError
| +-- OverflowError
| +-- ZeroDivisionError
+-- AssertionError
+-- AttributeError
+-- BufferError
+-- EOFError
…
+-- Warning
+-- DeprecationWarning
+-- PendingDeprecationWarning
+-- RuntimeWarning
+-- SyntaxWarning
+-- UserWarning
…
你可以从异常列表中看到,这些是按层次结构组织的。这意味着在较低级别引发异常只是较高级别异常的一个更具体的实例,因此你实际上可以在层次结构的较高级别引发它,而一切仍然可以正确工作。作为一个快速示例,我们可以从 ArithmeticError 子层次结构中看到,在层次结构的较低级别有三个异常:
+-- ArithmeticError
| +-- FloatingPointError
| +-- OverflowError
| +-- ZeroDivisionError
这意味着如果我们为可能除以零的代码抛出异常,我们可以合法地使用 ZeroDivisionError 或 ArithmeticError:
n = 4.0
try:
result = n/0.0
except ZeroDivisionError:
print("Division by zero not allowed!")
n = 4.0
try:
result = n/0.0
except ArithmeticError:
print("Division by zero not allowed!")
通常,当你捕获异常时,你可以选择几种不同的处理方式。首先,你可以处理异常并继续程序流程。你应该在清楚什么将导致错误并且可以在代码逻辑中处理它时这样做。其次,你可以再次抛出异常。你可能出于几个原因想要这样做:
-
你想在记录错误的同时允许异常向上传播到调用栈。这对于调试目的可能很有用,因为它允许你记录错误消息和其他有关异常的详细信息,同时仍然允许调用代码以适当的方式处理异常。一个例子可能看起来像这样:
import logging def process_data(data): try: # Do some processing on the data result = process(data) except Exception as e: # Log the exception logging.exception("Exception occurred while processing data") # Re-raise the exception raise return result在这个例子中,
process_data函数尝试处理一些数据并返回结果。如果在处理数据时发生异常,异常将使用logging.exception函数记录,该函数记录异常以及堆栈跟踪。然后使用raise语句重新抛出异常,这允许调用代码以适当的方式处理异常。 -
你想为异常添加额外的上下文。例如,你可能想添加有关异常发生时应用程序状态的详细信息,或者有关导致异常被抛出的输入的详细信息。改编前面的例子,我们可能会有如下内容:
def process_data(data): try: # Do some processing on the data result = process(data) except Exception as e: # Add additional context to the exception message message = f"Exception occurred while processing data: {data}" # Create a new exception with the modified message raise Exception(message) from e return result在这个例子中,如果在处理数据时发生异常,异常信息会被修改以包含正在处理的数据。然后使用修改后的消息和原始异常作为原因(使用
from e语法)抛出一个新的异常。 -
你想在代码的更高层次处理异常,但仍然允许较低层次的代码在更高层次不合适处理异常时进行处理。这稍微复杂一些,所以我们将逐步通过另一个改编的例子。首先,这次,当我们抛出异常时,我们调用一个以特定方式处理异常的函数,称为
handle_exception:def process_data(data): try: # Do some processing on the data result = process(data) except Exception as e: # Handle the exception handle_exception(e) # Re-raise the exception raise return resulthandle_exception函数的代码可能看起来像下面这样,其中我们需要确定是否要在当前抽象级别处理异常,还是将其传递到调用栈的上层,使用另一个名为should_handle的函数:def handle_exception(e): # Log the exception logging.exception("Exception occurred") # Check if the exception should be handled at this level if should_handle(e): # Handle the exception ... else: # Allow the exception to propagate up the call stack raiseshould_handle函数将是定义我们特定逻辑的地方,以决定是否在当前级别处理异常,还是使用 raise 语法将异常升级到调用栈。例如,如果我们想在这个级别处理ArithmeticError,而其他情况下我们想将异常升级到调用栈,逻辑将看起来像这样:def should_handle(e): # Check the type of the exception if isinstance(e, ArithmeticError): # Handle the exception return True else: # Allow the exception to propagate return False -
最后,您可能需要抛出不同的异常,可能是因为您需要将几个不同的异常组合在一起并在更高层次的抽象中处理它们。再次,根据前面的示例进行调整,这可能意味着您编写了一些看起来像这样的代码:
def process_data(data): try: # Do some processing on the data result = process(data) except ValueError as e: # Raise a different exception with the same message raise MyCustomException(str(e)) except MyCustomException as e: # Raise a different exception with a modified message message = f"Exception occurred while processing data: {data}" raise MyCustomException(message) return result在这个例子中,如果在处理数据时发生
ValueError异常,它将被捕获,并抛出一个带有相同消息的新MyCustomException。如果发生MyCustomException异常,它将被捕获,并抛出一个带有修改后的消息的新MyCustomException,该消息包括正在处理的数据。这允许你在更高层次的抽象中一起处理不同类型的异常,通过抛出一个单一的自定义异常类型,可以以一致的方式进行处理。
我们可以使用的一种第三种程序流程是,我们可以在原始异常内部抛出一个新的异常。这可能很有帮助,因为我们可以提供更多关于已发生错误类型的详细信息,并且我们可以提供更多上下文信息,这将有助于我们调试后续的问题。为了使这一点更清晰,让我们定义一个示例函数来代替我们在前面的示例中使用的函数:
def process(data_to_be_processed):
'''Dummy example that returns original data plus 1'''
return data_to_be_processed + 1
我们将在与上面列表中的第一个示例相同的函数中调用此代码,但现在我们将添加一个新的语法来从原始异常中抛出异常:
def process_data(data):
try:
# Do some processing on the data
result = process(data)
except Exception as e:
# Log the exception
logging.exception("Exception occurred while processing data")
# Raise a new exception from the overall exception
new_exception = ValueError("Error processing data")
raise new_exception from e
return result
从原始异常中抛出现在确保我们记录了这是一个与输入数据相关的特定ValueError,并且它允许我们记录一个更高层次的日志消息,这可以在堆栈跟踪中提供额外的上下文。例如,如果您使用上述函数并运行此函数调用:
process_data('3')
我们得到了预期的错误,因为我们提供了一个字符串,然后尝试将其与一个整数相加。以下是从我运行此代码时得到的堆栈跟踪的一个片段:
ERROR:root:Exception occurred while processing data
File "exception_handling_examples.py", line 5, in process
return data_to_be_processed + 1
TypeError: can only concatenate str (not "int") to str
上述异常是以下异常的直接原因:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "exception_handling_examples.py", line 18, in process_data
raise new_exception from e
ValueError: Error processing data
您可以看到我们从原始异常中抛出的异常如何指出错误发生在process_data函数的哪个位置,并且它还提供了这个问题与数据处理相关联的信息。这两条信息都有助于提供上下文,并有助于我们调试。更技术性的原始异常,即关于操作数类型的TypeError,仍然很有用,但可能难以消化和完全调试。
这只是触及了日志记录可能性的表面,但这将允许您开始。
现在,我们继续讨论在我们的代码中需要做什么来处理出错的情况!
不是重新发明轮子
你可能已经通过本章(或者我希望你已经!)注意到,你需要的许多功能对于你的机器学习和 Python 项目来说已经构建好了。作为一名机器学习工程师,你可以学到的一个重要事情是,你不需要从头开始构建一切。你可以通过多种方式确保你不这样做,其中最明显的方式是在自己的解决方案中使用其他包,然后构建一个增强现有功能的功能。例如,你不需要构建基本的回归建模能力,因为它们存在于各种包中,但你可能需要添加一种新的回归器或使用你开发的一些特定领域知识或技巧。在这种情况下,你可以在现有解决方案之上编写自己的代码是有道理的。你还可以使用 Python 的各种概念,例如包装类或装饰器。关键信息是,尽管在构建你的机器学习解决方案时你需要做很多工作,但重要的是你不需要觉得需要从头开始构建一切。专注于你可以创造增值的地方,并在此基础上构建,效率要高得多!
摘要
本章主要介绍了当你为你的机器学习解决方案编写自己的 Python 包时的最佳实践。我们在介绍一些提示和技巧以及需要记住的良好技术之前,回顾了一些 Python 编程的基本概念。我们讨论了 Python 和 PySpark 中编码标准的重要性。然后,我们比较了面向对象和函数式编程范式来编写代码。我们继续讨论如何将你编写的优质代码打包成可以在多个平台和用例中分发的产品。为此,我们探讨了你可以使用以实现这一目标的不同工具、设计和设置,包括使用 Makefiles 和 Poetry。我们继续总结了一些关于代码的维护提示,包括如何测试、记录和监控你的解决方案。这还包括了一些异常处理的详细示例以及如何在你的程序和包中开发更复杂的控制流。我们最后简要地提出了一个关于不重复造轮子的哲学观点。
在下一章中,我们将深入探讨部署的世界。这将是关于你如何将你编写的脚本、包、库和应用程序运行在适当的基础设施和工具上。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第五章:部署模式和工具
在本章中,我们将深入探讨围绕部署您的机器学习(ML)解决方案的一些重要概念。我们将开始闭合机器学习开发生命周期的闭环,并为将您的解决方案推向世界打下基础。
将软件部署的过程,即从您可以向少数利益相关者展示的演示版本到最终影响客户或同事的服务,是一项非常令人兴奋但往往具有挑战性的练习。这也一直是任何机器学习项目中最困难的部分之一,而且做对它最终可能是在创造价值或仅仅炒作之间产生差异的关键。
我们将探讨一些主要概念,这些概念将帮助您的机器学习(ML)工程团队能够跨越从有趣的证明概念到可以在可扩展基础设施上以自动化方式运行的解决方案之间的鸿沟。这要求我们首先讨论如何设计和架构您的机器学习(ML)系统,尤其是如果您想开发可以无缝扩展和扩展的解决方案。然后我们将讨论容器化的概念以及它如何允许您的应用程序代码从它正在构建或运行的特定基础设施中抽象出来,从而在许多不同情况下实现可移植性。然后我们将继续到一个具体的例子,使用这些想法在 AWS 上部署机器学习(ML)微服务。本章的其余部分将回到如何构建有效且健壮的管道以用于您的端到端机器学习(ML)解决方案的问题,这在第四章,打包中已介绍。我们将介绍并探索Apache Airflow,用于构建和编排任何通用的 Python 过程,包括您的数据准备和机器学习(ML)管道。然后我们将以类似的方式深入研究ZenML和Kubeflow,这两个开源的高级机器学习(ML)管道工具现在在工业界得到了广泛的应用。这些工具的集合意味着您应该在本章结束时非常有信心,可以使用各种软件部署和编排相当复杂的机器学习(ML)解决方案。
这一切都将分为以下几部分:
-
系统架构
-
探索一些标准的机器学习(ML)模式
-
容器化
-
在亚马逊网络服务(AWS)上托管您的微服务
-
使用 Airflow 构建通用管道
-
构建高级机器学习(ML)管道
下一节将开始讨论如何考虑部署来架构和设计我们的机器学习(ML)系统。让我们开始吧!
技术要求
与其他章节一样,您可以通过使用提供的 Conda 环境yml文件或从书库中的Chapter05目录下的requirements.txt文件来设置您的 Python 开发环境,以便能够运行本章中的示例。
conda env create –f mlewp-chapter05.yml
您还需要安装一些非 Python 工具才能从头到尾跟随示例。请参阅每个工具的相关文档:
-
AWS CLI v2
-
Postman
-
Docker
系统架构
无论你如何努力构建你的软件,始终有一个设计在心中是非常重要的。本节将强调我们在设计机器学习系统时必须牢记的关键考虑因素。
考虑这样一个场景,你被雇佣来组织建造房屋。我们不会简单地出去雇佣一支建筑队,购买所有材料,雇佣所有设备,然后告诉每个人开始建造。我们也不会在没有先与他们交谈的情况下就假设我们确切地知道雇佣我们的客户想要什么。
相反,我们可能会试图详细了解客户的需求,然后尝试设计符合他们要求的解决方案。我们可能会与他们以及了解整体设计细节的适当专家一起迭代这个计划几次。尽管我们可能不感兴趣建造房屋(或者也许你感兴趣,但本书中不会有任何房屋的例子!),但我们仍然可以将其与软件进行类比。在建造任何东西之前,我们应该创建一个有效且清晰的设计。这个设计为解决方案提供了方向,并帮助构建团队确切地知道他们将工作的组件。这意味着我们将有信心,我们所建造的东西将解决最终用户的问题。
简而言之,这就是软件架构的全部内容。
如果我们对我们的机器学习解决方案做了上述类似的事情,以下一些事情可能会发生。我们可能会得到一个非常混乱的代码库,我们团队中的某些机器学习工程师可能会构建已经被其他工程师的工作所覆盖的元素和功能。我们也可能会构建在项目后期基本无法工作的东西;例如,如果我们选择了一个具有特定环境要求,而我们无法满足这些要求的工具。我们也可能难以预测需要提前配置的基础设施,导致项目内部混乱地争夺正确的资源。我们也可能低估所需的工作量,错过截止日期。所有这些都是我们希望避免的结果,如果我们遵循良好的设计,这些结果是可以避免的。
为了有效,软件的架构应该至少为构建解决方案的团队提供以下东西:
-
它应该定义解决整个问题的所需功能组件。
-
它应该定义这些功能组件如何交互,通常是通过交换某种形式的数据。
-
它应该展示解决方案如何在未来扩展,以包括客户可能需要的进一步功能。
-
它应该提供关于应选择哪些工具来实现架构中概述的每个组件的指导。
-
它应该规定解决方案的过程流程以及数据流程。
这就是一件好的架构应该做的事情,但实际上这意味着什么呢?
对于如何构建架构并没有严格的定义。关键点在于它作为一个设计,建筑可以在此基础上进行。例如,这可能是一个带有框、线和一些文本的漂亮图表,或者可能是一份多页文档。它可能使用形式化的建模语言,如统一建模语言(UML),也可能不使用。这通常取决于你所在的企业环境以及对你编写架构的人提出的要求。关键是它检查了上述要点,并为工程师提供了关于要构建什么以及如何将这些部分组合在一起的明确指导。
架构本身是一个广泛而迷人的主题,所以我们不会在这里深入探讨其细节,但我们将现在关注在 ML 工程背景下的架构含义。
建筑原则
架构领域非常广泛,但无论你往哪里看,就像任何成熟的学科一样,总有始终如一的原则被提出。好消息是,其中一些原则实际上与我们讨论第四章“打包”时遇到的原则相同。在本节中,我们将讨论这些原则以及它们如何用于架构 ML 系统。
关注点分离已经在本书中提到,是确保应用程序内部软件组件不必要复杂以及解决方案可扩展且易于接口的一种好方法。这个原则对整个系统都适用,因此是一个值得记住的良好架构原则。在实践中,这通常表现为应用程序内部有不同职责的“层”的概念。例如,让我们看看图 5.1 所示的架构。这展示了如何使用工具创建自动化部署和编排过程,它来自 AWS 解决方案库,aws.amazon.com/solutions/implementations/mlops-workload-orchestrator/。我们可以看到,架构中有对应于资源分配、管道部署和管道服务的“区域”。这些块表明解决方案中有具有特定功能的独立部分,而这些不同部分之间的交互由接口处理。

图 5.1:AWS 解决方案库中的 ML 工作负载编排器架构。
最小惊讶原则是一个经验法则,它本质上捕捉了这样一个事实:当你领域中的任何合理有知识的人,比如开发者、测试员或数据科学家,第一次遇到你的架构时,其中不应该有任何不寻常或令人惊讶的东西。这可能并不总是可能的,但这是一个值得记住的好原则,因为它迫使你考虑那些可能与你架构一起工作的人已经知道什么,以及你如何利用这一点来做出良好的设计并使其得到遵循。再次以图 5.1为例,架构很好地体现了这一原则,因为设计有清晰的逻辑构建块,用于提供、提升和运行机器学习管道。在架构的较低层面,我们可以看到数据始终来自 S3 存储桶,Lambda 与 API 网关进行交互,等等。这意味着机器学习工程师、数据科学家和云平台工程师在实施时都会很好地理解和利用这个架构。
最小努力原则比前一个原则更微妙,因为它捕捉了这样一个想法:开发者作为人类,会遵循阻力最小的路径,除非必要,否则不会创造更多的工作。我解读这个原则为强调花时间深思熟虑地考虑你的架构并小心构建它的重要性,因为它在开发后可能会被工程师长时间使用!
到目前为止,我们只讨论了高级架构原则。现在我们将探讨一些设计原则,虽然它们在系统设计层面仍然可以使用,但在你的代码层面使用时也非常强大。
SOLID 原则(单一职责、开放/封闭、里氏替换、接口隔离、依赖倒置)是一组通常应用于代码库的原则,但也可以很好地扩展到系统设计和架构。一旦我们将这些原则应用到架构层面,它们可以这样解释:
-
单一职责:这与关注点分离的概念非常相似,也许可以说是相同的。具体来说,这表明如果一个模块在任何时候只有一个改变的理由,或者只有一个任务要做,那么这会使它更加健壮且更容易维护。如果你在架构图中有一个盒子需要做十件事,那么你就违反了这个原则,这意味着每当这些流程或接口中的任何一个需要改变时,你都必须进入那个盒子四处摸索,可能会产生更多问题或极大地增加停机时间。
-
开放/封闭原则:这指的是以组件“对扩展开放但对修改封闭”的方式进行架构设计是一个非常不错的想法。这在整个设计层面也是适用的。如果你设计你的系统,使得新的功能可以添加而不需要回过头去重新布线核心部分,那么你很可能会构建出能够经受时间考验的东西。在机器学习领域的一个很好的例子是,如果我们尝试构建我们的系统,以便我们想要添加新的处理管道时,我们可以直接这样做,而不必回到代码的某个晦涩部分进行严重修改。
-
里氏替换原则:当 SOLID 原则被编写时,它们最初是指 Java 等语言中的面向对象编程。这个原则当时指出,对象应该能够被它们的子类型替换,同时仍然保持应用程序的行为。在系统层面,这现在基本上表示,如果两个组件应该具有相同的接口并与其他组件进行交互,你可以互相替换它们。
-
接口隔离原则:我理解这个原则为“不要让组件之间有多个交流方式。”所以,在你的应用程序中,尽量确保不同解决方案部分之间的交接方式非常狭窄。另一种表述方式是,尽可能使你的接口针对客户端是好的想法。
-
依赖倒置原则:这与里氏替换原则非常相似,但更为通用。这里的想法是,模块或你解决方案的部分之间的通信应该由抽象来处理,而不是由具体的、特定的实现来处理。一个很好的例子是,与其直接从另一个进程调用机器学习微服务,你不如将必要的作业数据放入队列中,例如 AWS 简单队列服务,然后微服务从队列中提取工作。这确保了客户端和提供服务的微服务不需要了解彼此的接口细节,同时也确保下游应用程序可以通过读取队列来扩展更多的服务。这也就体现了开放/封闭原则,并且可以在图 5.1的架构中通过 Lambda 函数调用 AWS CloudFormation 来看到这一点。
我个人的一个最喜欢的概念是边界上下文,我们必须努力确保数据模型或其他重要的数据或元数据与特定的概念模型保持一致,而不是“自由放任”。这一点特别适用于领域驱动设计,并且非常适合大型、复杂的解决方案。一个很好的例子是,如果你有一个大型组织,拥有多个业务单元,并且他们希望运行在存储在数据库中的业务数据上的非常相似的服务。最好是有几个数据库来托管信息,每个业务单元一个,而不是在多个应用程序之间共享数据层。更具体地说,你的数据模型不应该包含特定于销售和营销职能、工程职能和人力资源职能等信息。相反,每个职能都应该有自己的数据库和自己的模型,如果需要,应该有明确的合同来连接它们之间的任何信息。我相信这个想法仍然可以应用于后面章节中讨论的数据湖。在这种情况下,边界上下文可以应用于湖中的特定文件夹,或者它们实际上可以指整个湖的上下文,每个都被隔离在不同的领域。这正是所谓的数据网格背后的理念。
我们刚刚提到了一些最常用的机器学习模式,现在让我们更详细地探讨这个概念,因为我们正在寻找应用我们一直在讨论的原则。
探索一些标准的机器学习模式
在这本书中,我们已经多次提到,我们不应该试图重新发明轮子,而应该重用、重复和回收更广泛的软件和机器学习社区中行之有效的方法。这同样适用于你的部署架构。
当我们讨论可以用于具有相似特性的各种不同用例的架构时,我们通常将这些称为模式。使用标准(或者至少是众所周知的)模式确实可以帮助你加快项目的价值实现时间,并帮助你以稳健和可扩展的方式构建机器学习解决方案。
基于此,在接下来的几节中,我们将总结一些在过去几年中在机器学习领域越来越成功的最重要的架构模式。
在数据湖中游泳
对于任何试图使用机器学习(ML)的人来说,最重要的资产当然是我们可以分析和训练模型的数据。大数据时代意味着数据的规模和格式多样性成为了一个日益增长的挑战。如果你是一个大型组织(或者甚至不是那么大),将所有你希望用于 ML 应用的数据存储在结构化关系数据库中是不可行的。仅仅是为了存储这种格式中的数据而建模的复杂性就非常高。那么,你能做什么呢?
好吧,这个问题最初是通过引入数据仓库来解决的,它允许你将所有关系型数据存储整合到一个解决方案中,并创建一个单一的访问点。这在一定程度上有助于缓解数据量的问题,因为即使总量很大,每个数据库也能存储相对较小的数据量。这些仓库在设计时考虑了多个数据源的集成。然而,它们仍然相对受限,因为它们通常将计算和存储的基础设施捆绑在一起。这意味着它们很难进行扩展,并且可能成为昂贵的投资,导致供应商锁定。最重要的是,对于机器学习来说,数据仓库无法存储原始的、半结构化或非结构化数据(例如,图像)。如果使用仓库作为主要数据存储,这会自动排除许多好的机器学习用例。现在,有了像Apache Spark这样的工具,我们在整本书中已经广泛使用,如果我们有可用的集群,我们实际上可以分析和建模任何大小或结构的数据。那么问题就变成了,我们应该如何存储它?
数据湖是允许你在任何可操作的规模下存储任何类型数据的技术。有各种各样的数据湖解决方案提供商,包括主要的公共云提供商,如Microsoft Azure、Google Cloud Platform(GCP)和 AWS。由于我们之前已经接触过 AWS,让我们专注于它。
AWS 中的主要存储解决方案被称为简单存储服务,或S3。像所有核心数据湖技术一样,你可以有效地将其中的任何内容加载进去,因为它基于对象存储的概念。这意味着你加载的每个数据实例都被视为一个具有唯一标识符和相关元数据的独立对象。
它允许你的 S3 存储桶同时包含照片、JSON 文件、.txt文件、Parquet 文件以及其他多种数据格式。
如果你在一个没有数据湖的组织工作,这并不意味着你无法进行机器学习,但确实可能会使这个过程变得更加困难,因为有了数据湖,你总是知道如何存储你为解决问题所需的数据,无论其格式如何。
微服务
你的机器学习项目的代码库最初会很小——最初只有几行。但随着你的团队在构建所需解决方案上投入越来越多的努力,这会迅速增长。如果你的解决方案需要具备几种不同的能力并执行一些相当不同的操作,而你又把所有这些都放在同一个代码库中,你的解决方案可能会变得极其复杂。实际上,这种所有组件都紧密耦合且不可分离的软件被称为单体,因为它类似于可以独立于其他应用程序存在的单个大块。这种方法可能适合你的用例,但随着解决方案复杂性的持续增加,通常需要一个更具弹性和可扩展的设计模式。
微服务架构是指你的解决方案的功能组件被干净地分离,可能完全在不同的代码库中或运行在不同的基础设施上。例如,如果我们正在构建一个面向用户的 Web 应用程序,允许用户浏览、选择和购买产品,我们可能希望快速连续部署各种机器学习能力。我们可能希望根据他们刚刚查看的内容推荐新产品,我们可能希望检索他们最近订购的项目何时到达的预测,我们可能还希望突出一些我们认为他们将从中获得好处的折扣(基于我们对他们历史账户行为的分析)。这对于一个单体应用程序来说可能是一个非常高的要求,甚至可能是不可能的。然而,这恰好是像图 5.2中那样的微服务架构所自然适应的:

图 5.2:一些机器学习微服务的示例。
微服务架构的实现可以使用一些工具来完成,其中一些我们将在在 AWS 上托管自己的微服务部分中介绍。主要思想是始终将你的解决方案的元素分离成它们自己的服务,这些服务不是紧密耦合在一起的。
微服务架构特别擅长让我们的开发团队能够实现以下目标:
-
独立调试、修补或部署单个服务,而不是整个系统。
-
避免单点故障。
-
提高可维护性。
-
允许不同的服务由不同的团队拥有,并有更清晰的责任。
-
加速复杂产品的开发。
就像每个架构模式或设计风格一样,它当然不是万能的银弹,但当我们设计下一个解决方案时,记住微服务架构会大有裨益。
接下来,我们将讨论基于事件的设计。
基于事件的设计
您并不总是想以预定批次的方式运行。正如我们之前所看到的,即使是上一个部分,微服务,并不是所有用例都与在预定时间表上从模型运行大型批次预测、存储结果然后稍后检索它们相匹配。如果所需的训练运行数据量不存在怎么办?如果没有新的数据用于运行预测怎么办?如果其他系统可以在数据点最早可用时而不是每天特定时间基于单个数据点进行预测,它们能利用预测怎么办?
在事件驱动的架构中,单个操作产生结果,然后触发系统中其他单个操作,如此类推。这意味着过程可以在尽可能早的时候发生,而不是更早。这也允许有更动态或随机的数据流,如果其他系统不是在预定批次上运行,这可能是有益的。
事件驱动模式可以与其他模式混合,例如微服务或批量处理。这些好处仍然存在,实际上,事件驱动组件允许更复杂的解决方案编排和管理。
有两种基于事件的模式:
-
发布/订阅:在这种情况下,事件数据被发布到消息代理或事件总线,以便由其他应用程序消费。在发布/订阅模式的一个变体中,使用的代理或总线根据某些适当的分类组织,并指定为主题。执行此操作的示例工具是Apache Kafka。
-
事件流:流用例是我们希望在非常接近实时的情况下处理连续数据流的情况。我们可以将其视为在数据通过系统时处理数据。这意味着数据不是在数据库中静态持久化,而是在创建或接收时由流解决方案处理。用于事件流应用的示例工具是Apache Storm。
图 5.3 展示了一个应用于物联网和移动设备的事件驱动架构示例,这些设备的数据被传递到分类和异常检测算法中:

图 5.3:一个基本的事件驱动高级设计,其中数据流通过代理被不同的服务访问。
下一个部分将涉及设计,其中我们做的是一次处理一个数据点,而不是同时处理大量数据或批次。
批量处理
工作批次可能听起来不是最复杂的概念,但在机器学习的世界中,它是最常见的模式之一。
如果您用于预测的数据以固定的时间间隔以批次形式到来,那么安排您的预测运行以类似的节奏可能是高效的。如果不需要创建低延迟解决方案,这种模式也可能很有用。
由于几个原因,这个概念也可以运行得相当高效:
-
在预定批次中运行意味着我们知道何时需要计算资源,因此我们可以相应地计划。例如,我们可能能够关闭我们的集群大部分时间,或者将它们用于其他活动。
-
批次允许在运行时使用更多的数据点,因此如果你希望的话,可以在批次级别运行异常检测或聚类等操作。
-
您的数据批次的大小通常可以选择以优化某些标准。例如,使用大型批次并在其上运行并行化的逻辑和算法可能更有效。
在批次中运行 ML 算法的软件解决方案通常看起来与经典的提取、转换、加载(ETL)系统非常相似。这些系统是从源或多个源提取数据,然后在路由到目标系统之前进行处理,然后上传。在 ML 解决方案的情况下,处理不是标准的数据转换,如连接和筛选,而是应用特征工程和 ML 算法管道。这就是为什么在这本书中,我们将这些设计称为提取、转换、机器学习(ETML)模式。ETML 将在第九章构建提取、转换、机器学习用例中进一步讨论。
我们现在将讨论一项关键技术,这对于使现代架构适用于广泛的平台至关重要——容器。
容器化
如果你开发软件并将其部署到某个地方,这是 ML 工程师的核心目标,那么你必须非常了解你的代码的环境要求,以及不同的环境可能会如何影响你的解决方案的运行能力。这对于 Python 尤其重要,Python 没有将程序作为独立可执行文件导出的核心功能(尽管有选项可以这样做)。这意味着 Python 代码需要 Python 解释器来运行,并且需要存在于一个通用的 Python 环境中,其中已安装相关的库和支持包。
避免从这个角度来看头痛的一个好方法是问自己:为什么我不能把所有需要的东西都放入一个相对隔离主机环境的东西中,然后我可以将其发送并作为一个独立的应用程序或程序运行? 这个问题的答案是你可以,而且你通过容器化来实现这一点。这是一个过程,其中应用程序及其依赖项可以打包在一个独立的单元中,该单元可以在任何计算平台上有效地运行。
最受欢迎的容器技术是Docker,它是开源的,非常易于使用。让我们通过使用它来容器化一个简单的Flask网络应用程序来了解它,这个应用程序可以作为类似在示例 2:第一章,机器学习工程简介中的预测 API部分的预测模型的接口。
接下来的几节将使用一个类似的简单 Flask 应用程序,它有一个提供预测的端点。作为一个完整 ML 模型的代理,我们首先将与一个简单的骨架应用程序一起工作,该应用程序在请求预测时简单地返回一个随机数字的短列表。应用程序的详细代码可以在本书的 GitHub 仓库中找到,地址为github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition/tree/main/Chapter05/microservices/mlewp2-web-service。
网络应用程序创建了一个基本的应用程序,你可以提供存储 ID 并预测系统的开始日期,然后它会返回虚拟预测。要获取这个,你需要点击/forecast端点。
一个例子在图 5.4中展示:

图 5.4:查询我们的骨骼 ML 微服务的结果。
现在,我们将继续讨论如何容器化这个应用程序。首先,你需要通过使用docs.docker.com/engine/install/上的文档在你的平台上安装 Docker:
-
一旦你安装了 Docker,你需要告诉它如何构建容器镜像,这通过在你的项目中创建一个
Dockerfile来完成。Dockerfile以文本形式指定所有构建步骤,以便构建镜像的过程自动化且易于配置。现在,我们将通过构建一个简单的示例Dockerfile来演示,这个示例将在下一节中继续,即在 AWS 上托管自己的微服务。首先,我们需要指定我们工作的基本镜像。通常使用官方 Docker 镜像作为基础是有意义的,所以我们在这里将使用python:3.10-slim环境来保持事情简洁。这个基本镜像将在所有跟随FROM关键字的命令中使用,这表示我们正在进入构建阶段。我们实际上可以用FROM … as语法命名这个阶段,以便以后使用,将其命名为builder:FROM python:3.10-slim as builder -
然后,我们将从当前目录复制所有需要的文件到构建阶段的
src目录,并使用我们的requirements.txt文件安装所有需求(如果你想在未指定任何需求的情况下运行此步骤,你可以只使用一个空的requirements.txt文件):COPY . /src RUN pip install --user --no-cache-dir -r requirements.txt -
下一个阶段涉及类似的步骤,但被别名为单词
app,因为我们现在正在创建我们的应用程序。注意这里对步骤1和2中的builder阶段的引用:FROM python:3.10-slim as app COPY --from=builder /root/.local /root/.local COPY --from=builder /src . -
我们可以像在 bash 环境中一样定义或添加环境变量:
ENV PATH=/root/.local:$PATH -
由于在这个例子中我们将运行一个简单的 Flask Web 应用程序,我们需要告诉系统要公开哪个端口:
EXPOSE 5000 -
我们可以在 Docker 构建过程中使用
CMD关键字来执行命令。在这里,我们使用它来运行app.py,这是 Flask 应用程序的主入口点,并且将启动我们稍后通过 REST API 调用来获取 ML 结果的服务:CMD ["python3", "app.py"] -
然后,我们可以使用
docker build命令来构建镜像。在这里,我们创建一个名为basic-ml-microservice的镜像,并使用latest标签对其进行标记:docker build -t basic-ml-microservice:latest -
要检查构建是否成功,请在终端中运行以下命令:
docker images --format "table {{.ID}}\t{{.CreatedAt}}\t{{.Repository}}"您应该会在 图 5.5 中看到类似的输出:
![]()
图 5.5:
docker images命令的输出。 -
最后,您可以在终端中使用以下命令运行您的 Docker 镜像:
docker run --rm -it -p 8080:5000 basic-ml-microservice:latest
现在您已经容器化了一些基本的应用程序并且可以运行您的 Docker 镜像,我们需要回答如何使用它来构建一个托管在适当平台上的 ML 解决方案的问题。下一节将介绍我们如何在 AWS 上做到这一点。
在 AWS 上托管自己的微服务
一个将您的 ML 模型公开的经典方式是通过在服务器上托管一个轻量级的 Web 服务。这可以是一个非常灵活的部署模式。
您可以在任何可以访问互联网(大致上)的服务器上运行 Web 服务,并且如果设计得当,通常很容易向您的 Web 服务添加更多功能,并通过新的端点公开。
在 Python 中,最常用的两个 Web 框架一直是 Django 和 Flask。在本节中,我们将重点关注 Flask,因为它比 Django 更简单,并且已经广泛地讨论了其在 Web 上的 ML 部署,因此您将能够找到大量材料来构建您在这里学到的内容。
在 AWS 上,您可以托管 Flask Web 解决方案的最简单方法之一是将它作为一个容器化应用程序在适当平台上运行。我们将在本节中介绍如何做到这一点的基础知识,但我们将不会花费时间在维护良好 Web 安全性的详细方面。这可能需要一本完整的书来充分讨论,并且在其他地方有很好的、更专注的资源。
我们将假设您已经从 第二章,机器学习开发过程 中设置了您的 AWS 账户。如果没有,请返回并复习您需要做什么。
我们将需要 AWS 命令行界面(CLI)。您可以在 AWS CLI 文档页面 docs.aws.amazon.com/cli/index.xhtml 上找到安装和配置 AWS CLI 的适当命令,以及大量其他有用的信息。
具体来说,请按照本教程中的步骤配置您的 Amazon CLI:docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.xhtml。
文档指定了如何为各种不同的计算机架构安装 CLI,所以按照你的平台进行操作,然后你就可以准备好享受本书中使用的 AWS 示例了!
在以下示例中,我们将使用 Amazon 弹性容器注册库(ECR)和弹性容器服务(ECS)来托管一个基本的容器化 Web 应用程序。在第八章,构建示例 ML 微服务中,我们将更详细地讨论如何构建和扩展 ML 微服务,并使用基于 Kubernetes 的低级别实现。这两种方法相辅相成,将帮助你扩展 ML 工程工具箱。
在 ECS 上部署我们的服务需要几个不同的组件,我们将在接下来的几节中介绍:
-
我们在 ECR 仓库内部托管的容器
-
在 ECS 上创建的集群和服务
-
通过弹性计算云(EC2)服务创建的应用程序负载均衡器
首先,让我们解决将容器推送到 ECR 的问题。
推送到 ECR
让我们看看以下步骤:
-
我们在容器化部分的项目目录中定义了以下 Dockerfile:
FROM python:3.10-slim as builder COPY . /src RUN pip install --user --no-cache-dir -r requirements.txt FROM python:3.10-slim as app COPY --from=builder /root/.local /root/.local COPY --from=builder /src . ENV PATH=/root/.local:$PATH EXPOSE 5000 CMD ["python3", "app.py"] -
然后,我们可以使用 AWS
CLI创建一个 ECR 仓库来托管我们的容器。我们将把这个仓库命名为basic-ml-microservice,并将区域设置为eu-west-1,但这个应该根据你的账户最合适的区域来更改。下面的命令将返回一些关于你的 ECR 仓库的元数据;保留这些信息以供后续步骤使用:aws ecr create-repository --repository-name basic-ml-microservice --image-scanning-configuration scanOnPush=true --region eu-west-1 -
然后,我们可以在终端中使用以下命令登录到容器注册库。注意,仓库 URI 将在运行步骤2后提供的元数据中。你也可以通过运行
aws ecr describe-repositories --region eu-west-1来检索这个信息:aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin <ECR_REPOSITORY_URI> -
然后,如果我们导航到包含
Dockerfile(app)的目录,我们可以运行以下命令来构建容器:docker build --tag basic-ml-microservice:local -
下一步为镜像打标签:
docker tag basic-ml-microservice:local <ECR_REPOSITORY_URI> -
然后,我们可以使用以下命令将我们刚刚构建的 Docker 镜像部署到容器注册库:
docker push <YOUR_AWS_ID>.dkr.ecr.eu-west-1.amazonaws.com/basic-ml-microservice:latest
如果成功,这个最后的命令将把本地构建的 Docker 镜像推送到你的远程托管 ECR 仓库。你可以通过导航到 AWS 管理控制台,进入 ECR 服务,并选择 basic-ml-microservice 仓库来确认这一点。你应该会看到类似于图 5.6中所示的内容。

图 5.6:本地构建的 Docker 镜像成功推送到 ECR 仓库。
我们刚刚走过的步骤在一般情况下是非常强大的,因为你现在能够构建跨平台的 Docker 镜像,并在你的 AWS 账户下的中央仓库中共享它们。你也可以通过 DockerHub 共享 Docker 容器和镜像,hub.docker.com/,但如果你想在你的组织内部做这件事,这会给你更多的控制权。
现在我们已经构建了托管 Flask 应用的容器,接下来我们将考虑将其部署到可伸缩的基础设施上。为了做到这一点,在下一节中,我们将在 ECS 上设置我们的集群。
在 ECS 中托管
现在,让我们开始设置!截至 2023 年中旬,AWS 最近推出了一款全新的 ECS 控制台,它允许比之前更平滑的设置。因此,如果您阅读的是这本书的第一版,您会发现这是一个更加流畅的体验:
-
首先,导航到 AWS 管理控制台中的ECS,然后点击创建集群。您将看到一个表单,要求您提供有关网络、基础设施、监控以及我们即将创建的资源上的任何标签的详细信息。这应该看起来像图 5.7。
![]()
图 5.7:在弹性容器服务中创建集群。
-
首先,我们可以将集群命名为
mlewp2-ecs-cluster,或者您想要的任何名称!然后当您展开网络部分时,您应该会看到许多VPC和子网细节都是基于您的 AWS 账户设置自动填充的默认值。如果您需要设置这些,表单会指向相关的文档。请参见图 5.8以获取示例。![]()
图 5.8:我们集群在 AWS ECS 中的网络配置。
-
基础设施部分包含三个选项,其中使用AWS Fargate是预选的默认选项。我们不需要了解 Fargate 的工作细节,但可以说这为跨多台服务器管理容器工作负载提供了一个非常高级的抽象层。Fargate 的引入意味着您不需要担心为运行容器工作负载的虚拟机集群的配置和运行细节。根据 AWS 文档,Fargate 服务非常适合动态工作突增或具有低运营开销的大规模工作负载。如果您知道您将要运行需要价格优化的大型作业,那么您可以查看提供的其他基础设施选项,例如EC2 实例。在这个示例中我们不需要这些。图 5.9显示了基础设施部分以供参考。
![]()
图 5.9:在 ECS 服务中配置基础设施选项。
-
监控和标签部分相对容易理解,允许您开启容器洞察并为即将创建的 ECS 资源提供自己的字符串标签。现在我们先保持这些默认设置,然后点击页面底部的创建按钮。然后您应该会看到集群在几分钟内成功创建,如图 5.10 所示。
![]()
图 5.10:ECS 集群成功创建。
之前的步骤都是关于设置 ECS 集群,这是我们的容器化应用程序可以运行的基础设施。要实际告诉 ECS 如何运行解决方案,我们需要定义任务,这些任务简单来说就是希望在集群上执行的过程。在 ECS 中有一个相关的概念叫做服务,它指的是管理你的任务的过程,例如,确保集群上始终运行一定数量的任务。如果你对解决方案有特定的正常运行时间要求,例如,如果它需要全天候可用,那么这很有用。我们可以通过首先在 AWS 管理控制台中导航到集群审查页面,然后在左侧选择任务定义来在集群中创建任务定义。然后我们将点击创建新任务定义。按照以下步骤创建此任务定义。
-
我们必须命名任务定义家族,这仅仅是任务定义的版本集合。为了简单起见,让我们将其命名为
basic-ml-microservice-tasks。然后我们需要提供一些容器细节,例如我们想要使用的镜像的 URI。这是我们之前推送到 ECR 仓库的镜像的 URI,格式类似于<YOUR_AWS_ID>.dkr.ecr.eu-west-1.amazonaws.com/basic-ml-microservice:latest。你可以给容器起一个新的名字。在这里,我将其命名为mlmicro。最后,你需要提供适当的端口映射,以便容器及其包含的应用程序能够被外部流量访问。我已经映射了端口 5000,你可能记得这是我们在原始 Dockerfile 中使用 TCP 协议暴露的端口。所有这些都在图 5.11中显示。你现在可以保留此第一部分的其余可选设置为默认值,然后点击下一步进入下一页设置。![img/B19525_05_11.png]()
图 5.11:定义用于 ECS 任务定义的容器镜像。
-
控制台中的下一页会要求你提供关于你将在其上运行解决方案的环境和基础设施的信息。根据我们为 ECS 集群使用的设置,我们将使用 Fargate 作为基础设施选项,在Linux x86_64环境中运行。在这种情况下,我们运行的任务非常小(我们只是为了演示目的返回一些数字),因此我们可以保留默认的1 vCPU和3 GB内存选项。如果你需要,也可以添加容器级别的内存和 CPU 要求,但现在我们可以留空。这特别有用,如果你有一个计算密集型的服务,或者它包含一个预先加载了一些大型模型或配置数据的应用程序。你可以在图 5.12中看到这一点。
![img/B19525_05_12.png]()
图 5.12:配置用于我们的 ML 微服务 AWS ECS 任务定义的应用程序环境。
-
接下来,需要配置 IAM 角色。我们不会从我们的应用程序中调用其他 AWS 服务,因此在此阶段,我们不需要 IAM 任务角色,但如果您稍后需要此功能,例如,如果您希望调用其他数据或 ML 服务,您可以创建一个。执行任务我们需要一个执行角色,默认情况下为您创建,所以让我们使用它。IAM 配置部分在图 5.13中显示。
![img/B19525_05_13.png]()
图 5.13:为 AWS ECS 任务定义定义的 IAM 角色。
-
本节剩余部分包含存储、监控和标记的可选部分。存储子部分指的是用于解压缩和托管您的 Docker 容器的临时存储。再次提醒,对于更大的容器,您可能需要考虑将此大小从默认的 21 GiB 增加。监控可以使用Amazon CloudWatch启用,这在您需要将基础设施监控作为解决方案的一部分时很有用,但在此处我们将不涉及此内容,而是更多地关注核心部署。目前请保持这些部分不变,并在页面底部点击下一步。
-
我们几乎完成了。现在,我们将审查并创建任务定义。如果您在审查后对选择满意,那么创建任务定义,您将被带到类似于图 5.14所示的摘要页面。
![img/B19525_05_14.png]()
图 5.14:成功创建的 ML 微服务任务定义。
现在,设置我们的 ECS 托管解决方案的最后一步是创建一个服务。我们将现在说明如何进行此操作:
-
首先,导航到之前步骤中创建的任务定义,并选择部署按钮。这将提供一个下拉菜单,您可以选择创建服务。图 5.15显示了此操作的外观,因为它可能很容易错过。
![img/B19525_05_15.png]()
图 5.15:选择创建服务选项,用于之前步骤中创建的任务定义。
-
然后,您将被带到另一个页面,我们需要填写我们希望创建的服务详情。对于现有集群,选择之前定义的 ECS 集群,在这个例子中被称为mlewp2-ecs-cluster。对于计算配置,我们将仅使用启动类型选项,这意味着我们可以仅允许 Fargate 管理基础设施需求。如果您想将多个基础设施选项混合在一起,则可以使用容量提供者策略选项。请注意,这更高级,所以我鼓励您在需要使用此路径时,在 AWS 文档中了解更多关于您选项的信息。为了参考,我的选择在图 5.16中显示。
![img/B19525_05_16.png]()
图 5.16:在我们要运行 ECS 服务的环境中选择的 AWS ECS 选项。此服务将启用我们之前定义的任务定义,因此我们的应用程序可以持续运行。
-
接下来是部署配置,它指的是服务在副本数量和解决方案故障时采取的操作方面如何运行。我已经简单地定义服务名称为basic-ml-microservice-service,并使用了Replica服务类型,该类型指定了应在集群中维护多少个任务。现在我们可以将其保留为1,因为我们只有一个任务在我们的任务定义中。这如图 5.17 所示。
![]()
图 5.17:配置 AWS ECS 服务名称和类型。
-
部署选项和部署故障检测子部分将自动填充一些默认值。滚动部署类型指的是当有最新版本可用时,用最新版本替换容器。故障检测选项确保遇到错误的部署无法继续进行,并且可以回滚到之前的版本。在此阶段,我们不需要启用CloudWatch 警报,因为我们尚未配置 CloudWatch,但可以在项目的未来迭代中添加。参见图 5.18以供参考.
![]()
图 5.18:即将部署的 AWS ECS 服务的部署和故障检测选项。
-
如其他示例所示,有一个网络部分,应该预先填充适合您账户的 VPC 和子网信息。与之前一样,您可以根据需要将这些信息切换为特定的 VPC 和子网。图 5.19显示了参考示例。
![]()
图 5.19:为我们定义的托管 ML 微服务的 AWS ECS 服务的网络部分。
-
剩余部分是可选的,包含用于负载均衡、自动扩展和标记的配置元素。尽管对于如此简单的应用程序我们可能不需要它,但我们将使用此部分创建一个应用程序负载均衡器,这是可用的选项之一。应用程序负载均衡器路由 HTTP 和 HTTPS 请求,并支持诸如基于路径的路由和动态主机端口映射等有用功能,这允许单个服务中的多个任务在同一个容器上运行。我们可以将负载均衡器命名为
basic-ml-microservice-lb,并配置此负载均衡器的监听器以监听端口 80并使用 HTTP 协议,如图 5.20 所示。此监听器检查给定端口的连接请求,并使用指定的协议,以便请求可以被负载均衡器路由到下游系统。![]()
图 5.20:定义 AWS ECS 服务的负载均衡器名称和监听器详细信息。
-
最后,我们必须为负载均衡器指定一个目标组,正如其名称所暗示的,这基本上是您服务中任务的目标端点集合。AWS ECS 确保在您服务的整个生命周期中,随着任务定义的更新,此更新也会进行。图 5.21显示了目标组的配置,它仅指定了用于健康检查的 HTTP 协议和主页路径。
![图 5.21]()
图 5.21:应用程序负载均衡器的目标组定义。
-
在填写这些详细信息后,点击创建按钮。然后,您的服务将被部署。如果一切顺利,您应该能够在 AWS ECS 控制台页面的集群详细信息中看到该服务。您可以导航到该服务并找到负载均衡器。这将有一个域名系统(DNS)地址,这将作为发送请求的目标 URL 的根。图 5.22显示了带有 DNS 的此页面的外观。复制或保存此 DNS 值。
![图 5.22]()
图 5.22:我们服务的已部署负载均衡器,DNS 名称位于右下角。
-
最后,为了测试服务,我们可以在 Postman 中运行与本地测试相同的请求,但现在更新 URL 以包含负载均衡器 DNS 名称和我们指定的负载均衡器将接收的端口。对我们来说,这是端口 80。这在与应用程序响应的图 5.23中显示。
![图 5.23]()
图 5.23:我们的简单预测服务从托管应用程序 AWS ECS 返回有效结果。
就这样!我们已经成功使用 Flask、Docker、AWS 弹性容器注册库、AWS 弹性容器服务和应用程序负载均衡器构建并部署了一个简化的预测服务。所有这些组件都可以适应部署您未来的 ML 微服务。
本章的前半部分主要介绍了适用于系统和代码级别的架构和设计原则,以及向您展示如何在一种非常常见的 ML 系统部署模式中实现这些原则,即 ML 微服务。现在我们已经完成了这个,我们将继续讨论一些工具和技术,它们允许我们以管道的形式构建、部署和托管复杂的 ML 工作流程,这是我们之前在书中简要介绍过的概念。本章后半部分我们将涵盖的工具和概念对于任何现代 ML 工程师来说都是至关重要的,因为它们正在成为许多已部署 ML 系统的骨架。
下一节将首先通过探讨如何使用 Airflow 创建和编排灵活、通用、生产就绪的管道来开始这次讨论,然后我们将转向一些专门针对高级 ML 管道编排的工具。
使用 Airflow 构建通用管道
在 第四章,打包 中,我们讨论了将我们的 ML 代码作为管道编写的优势。我们讨论了如何使用sklearn和 Spark ML 等工具实现一些基本的 ML 管道。我们当时关注的管道是非常好的简化代码和将多个过程作为单个对象内可用的方法,以简化应用程序。然而,我们当时讨论的每一件事都非常专注于单个 Python 文件,并且不一定是我们能够在使用该包的限制之外灵活扩展的东西。例如,使用我们讨论的技术,创建每个步骤都使用不同包的管道或者它们是完全不同的程序将会非常困难。它们也不允许我们在数据流或应用程序逻辑中构建太多的复杂性,就像如果其中一个步骤失败,管道就会失败,事情就这样结束了。
我们即将讨论的工具将这些概念提升到下一个层次。它们允许您管理您的 ML 解决方案的工作流程,以便您可以组织、协调和编排具有适当复杂性的元素以完成任务。
Airflow
Apache Airflow 是一个工作流管理工具,最初由 Airbnb 在 2010 年代开发,自那时起就是开源的。它为数据科学家、数据工程师和 ML 工程师提供了通过 Python 脚本编程创建复杂管道的能力。Airflow 的任务管理基于定义和执行一个 有向无环图 (DAG),其中节点是要运行的任务。DAGs 也被用于 TensorFlow 和 Spark,所以您可能之前已经听说过。
Airflow 包含各种默认操作符,允许您定义可以调用和使用多个组件作为任务的 DAG,而不必关心任务的具体细节。它还提供了调度您的管道的功能。例如,让我们构建一个 Apache Airflow 管道,该管道将获取数据,执行一些特征工程,训练一个模型,然后持久化模型。我们不会涵盖每个命令的详细实现,只是简单地展示您的 ML 过程如何在 Airflow DAG 中组合在一起。在 第九章,构建一个提取、转换、机器学习用例 中,我们将构建一个详细的端到端示例,讨论这些低级细节。这个第一个示例更关注于理解如何编写、部署和管理您在云中的 DAGs 的高级方法:
-
首先,在一个名为
classification_pipeline_dag.py的文件中,我们可以导入相关的 Airflow 包和我们需要的任何实用包:import datetime from datetime import timedelta from airflow import DAG from airflow.operators.python import PythonOperator from airflow.utils.dates import days_ago -
接下来,Airflow 允许您定义默认参数,这些参数可以被所有以下任务引用,并且可以选择在相同级别进行覆盖:
default_args = { 'owner': 'Andrew McMahon', 'depends_on_past': False, 'start_date': days_ago(31), 'email': ['example@example.com'], 'email_on_failure': False, 'email_on_retry': False, 'retries': 1, 'retry_delay': timedelta(minutes=2) } -
我们接下来需要实例化我们的 DAG 并提供相关的元数据,包括我们的调度间隔:
with DAG( dag_id="classification_pipeline", start_date=datetime.datetime(2021, 10, 1), schedule_interval="@daily", catchup=False, ) as dag: -
然后,所需的所有操作就是定义您在
DAG定义中的任务。首先,我们定义一个初始任务来获取我们的数据集。此段代码假设有一个名为get_data的 Python 可执行文件,例如一个函数或类方法,我们可以将其传递给任务。这可以从我们想要的任何子模块或包中导入。请注意,步骤 3-5假设我们处于 DAG 实例化的代码块中,所以我们假设另一个缩进,这里我们没有显示以节省空间:get_data_task = PythonOperator( task_id="get_data", python_callable=get_data ) -
然后,我们执行一个任务,该任务使用这些数据并执行我们的模型训练步骤。例如,这个任务可以封装我们在第三章,“从模型到模型工厂”中讨论的管道类型之一;例如,Spark ML 管道、Scikit-Learn管道或我们查看的任何其他 ML 训练管道。再次强调,我们假设有一个名为
train_model的 Python 可执行文件,可以在这一步中使用:train_model_task = PythonOperator( task_id="train_model", python_callable=train_model ) -
此过程的最后一步是用于将训练好的模型持久化到我们的存储层的一个占位符。这意味着其他服务或管道可以使用此模型进行预测:
persist_model_task = PythonOperator( task_id="persist_model", python_callable=persist_model ) -
最后,我们使用
>>运算符定义在 DAG 中定义的任务节点的运行顺序。上述任务可以按任何顺序定义,但以下语法规定了它们的运行顺序:get_data_task >> train_model_task >> persist_model_task
在接下来的几节中,我们将简要介绍如何在 AWS 上使用Apache Airflow 托管工作流(MWAA)服务设置 Airflow 管道。下一节将展示如何使用CI/CD原则持续开发和更新您的 Airflow 解决方案。这将汇集我们在本书前几章中进行的设置和工作。
AWS 上的 Airflow
AWS 提供了一个名为Apache Airflow 托管工作流(MWAA)的云托管服务,允许您轻松且稳健地部署和托管您的 Airflow 管道。在这里,我们将简要介绍如何做到这一点。
完成以下步骤:
-
在 MWAA 登录页面上选择创建环境。您可以在 AWS 管理控制台中搜索 MWAA 找到它。
-
随后,您将看到一个屏幕,要求您提供新 Airflow 环境的详细信息。图 5.24显示了网站引导您完成的高级步骤:
![图 5.29 – 设置 MWAA 环境和相关管理的 Airflow 运行的高级步骤]()
图 5.24:设置 MWAA 环境和相关管理的 Airflow 运行的高级步骤。
环境详细信息,如图图 5.25所示,是我们指定环境名称的地方。在这里,我们将其命名为mlewp2-airflow-dev-env:
![]()
图 5.25:命名您的 MWAA 环境。
-
为了 MWAA 能够运行,它需要能够访问定义 DAG 以及任何相关需求或插件文件的代码。系统随后会要求我们提供一个 AWS S3 桶,这些代码和配置就存储在这个桶中。在这个例子中,我们创建了一个名为
mlewp2-ch5-airflow-example的桶,将包含这些代码。图 5.26 展示了创建该桶的过程!图 5.26:成功创建我们的 AWS S3 桶以存储我们的 Airflow 代码和支持配置元素。
图 5.27 展示了如果我们也需要指定桶、文件夹、插件或需求文件时,如何将 MWAA 指向正确的桶、文件夹和插件或需求文件:
![]()
图 5.27:在 MWAA 实例的配置中引用我们之前创建的桶。
-
接下来,我们必须定义 Airflow 托管实例将使用的网络配置,这与本章中其他 AWS 示例类似。如果您对网络不熟悉,可能会有些困惑,因此阅读有关子网、IP 地址和 VPC 的主题可能会有所帮助。在网络上,创建新的 MWAA VPC 是入门的最简单方法,但您的组织将有网络专家可以帮助您根据您的具体情况使用适当的设置。我们将采用这条最简单的路线,点击创建 MWAA VPC,这将打开一个新窗口,我们可以根据 AWS 提供的标准堆栈定义快速启动一个新的 VPC 和网络设置。您将被要求输入堆栈名称。我将其命名为
MLEWP-2-MWAA-VPC。网络信息将被填充为类似于图 5.28 中所示的内容:![]()
图 5.28:创建新 VPC 的示例堆栈模板。
-
然后,我们将被带到一页,需要我们提供更多关于网络细节的信息。在这个例子中,我们可以选择公共网络(无需额外设置),因为我们不会太关心创建一个组织对齐的安全模型。对于组织内部的部署,请与您的安全团队合作,了解您需要实施哪些额外的安全措施。我们还可以选择创建新的安全组。这如图 5.29 所示。
![]()
图 5.29:完成 MWAA 设置的最终网络配置。
-
接下来,我们必须定义我们想要启动的环境类。目前有三个选项。在这里,我们将使用最小的,但你可以选择最适合你需求的选项(始终询问账单支付者的许可!)。图 5.30显示我们可以选择mw1.small环境类,最小到最大工作器数量为 1-10。MWAA 允许你在实例化后更改环境类,所以从成本角度来看,通常最好从小规模开始,根据需要扩展。你还会被问到你想要为环境设置多少调度器。现在让我们将其保留为默认值,2,但你可以增加到 5。
![]()
图 5.30:选择环境类和工作器大小。
-
现在,如果你需要的话,我们可以确认一些可选的配置参数(或者像这里一样留空),并确认我们愿意让 AWS 创建并使用一个新的执行角色。我们也可以直接使用默认的监控设置。图 5.31展示了这样一个例子(而且不用担心,安全组在你阅读这个页面的时候可能已经被删除很久了!):
![]()
图 5.31:AWS 用于 MWAA 环境的执行角色的创建。
-
下一页将在你创建 MWAA 环境之前提供一个最终总结。一旦你这样做,你将能够在 MWAA 服务中看到你新创建的环境,就像图 5.32所示。这个过程可能需要一些时间,在这个例子中大约需要 30 分钟:
![]()
图 5.32:我们新创建的 MWAA 环境。
现在你已经拥有了这个 MWAA 环境,并且已经将你的 DAG 提交到了它指向的 S3 桶中,你可以打开 Airflow UI 来查看由你的 DAG 定义的预定任务。你现在已经部署了一个基本的运行服务,我们可以在后续工作中在此基础上进行构建。
现在我们将想要在 Airflow UI 中查看 DAGs,以便我们可以编排和监控作业。为此,你可能需要配置你的账户对 MWAA UI 的访问权限,使用 AWS 文档页面上的详细信息。作为一个简要总结,你需要前往 AWS 的 IAM 服务。你需要以 root 用户登录,然后创建一个新的策略标题,AmazonMWAAWebServerAccess。给这个策略以下 JSON 体:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "airflow:CreateWebLoginToken",
"Resource": [
"arn:aws:airflow:{your-region}:YOUR_ACCOUNT_ID:role/{your- environment-name}/{airflow-role}"
]
}
]
}
对于这个定义,Airflow 角色指的是 Airflow 文档中定义的五个角色之一:管理员、操作员、查看者、用户或公共,具体请参阅 airflow.apache.org/docs/apache-airflow/stable/security/access-control.xhtml。我在这个例子中使用了管理员角色。如果您将此策略添加到您的账户权限中,您应该能够通过在 MWAA 服务中点击 打开 Airflow UI 按钮来访问 Airflow UI。然后,您将被引导到 Airflow UI,如图 图 5.33 所示。

图 5.33:通过 AWS MWAA 服务访问的 Airflow UI。此视图显示了我们在示例中早先编写的分类 DAG。
Airflow UI 允许您触发 DAG 运行,管理您已安排的工作,以及监控和调试您的管道。例如,在运行成功后,您可以看到运行的摘要信息,如图 图 5.34 所示,并且可以使用不同的视图来了解每个管道步骤所花费的时间,并在出现错误时诊断问题所在。

图 5.34:Airflow UI 中我们简单分类 DAG 的示例运行摘要。
在这个例子中,我们构建和运行的管道显然非常简单,只使用了核心 Python 功能。如果您想利用其他 AWS 服务,例如通过提交 Spark 作业到 EMR 集群,那么您将需要配置额外的访问策略,就像我们上面为 UI 访问所做的那样。这将在 MWAA 文档中介绍。
重要提示
一旦创建了此 MWAA 环境,您就不能暂停它,因为它每小时运行的成本很小(对于上述环境配置,大约为每小时 0.5 美元)。目前 MWAA 不包含暂停和恢复环境的特性,因此当需要时,您必须删除环境并重新实例化一个具有相同配置的新环境。这可以使用 Terraform 或 AWS CloudFormation 等工具自动化,这里我们不会涉及。所以,提醒一句——千万不要意外地让您的环境持续运行。例如,绝对不要像可能或可能没有做过的那样,让它连续运行一周。
回顾 Airflow 的 CI/CD
我们在 第二章,机器学习开发过程 中介绍了 CI/CD 的基础知识,并讨论了如何通过使用 GitHub Actions 来实现这一点。现在,我们将更进一步,开始设置部署代码到云的 CI/CD 管道。
首先,我们将从一个重要的例子开始,在这个例子中,我们将把一些代码推送到 AWS S3 桶。这可以通过在您的 GitHub 仓库的 .github/workflows 目录下创建一个名为 aws-s3-deploy.yml 的 .yml 文件来完成。这将是我们将围绕其构建 CI/CD 管道的核心。
在我们的案例中,.yml 文件将上传 Airflow DAG,并包含以下内容:
-
我们使用
name的语法命名该过程,并表达我们希望在主分支的推送或主分支的拉取请求时触发部署过程:name: Upload DAGS to S3 on: push: branches: [ main ] pull_request: branches: [ main ] -
我们随后定义了在部署过程中希望发生的作业。在这种情况下,我们希望将我们的 DAG 文件上传到我们已创建的 S3 桶,并希望使用我们在 GitHub 密钥存储中配置的适当 AWS 凭据:
jobs: deploy: name: Upload DAGS to Amazon S3 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Configure AWS credentials from account uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1然后,作为工作的一部分,我们运行步骤,将相关文件复制到我们指定的 AWS S3 桶。在这种情况下,我们还指定了如何使用 AWS CLI 进行复制的一些细节。具体来说,我们希望将所有 Python 文件复制到存储库的
dags文件夹:- name: Copy files to bucket with the AWS CLI run: | aws s3 cp ./dags s3://github-actions-ci-cd-tests --recursive--include "*.py" -
一旦我们执行了带有更新代码的
git push命令,这将执行操作并将dagPython 代码推送到指定的 S3 桶。在 GitHub UI 中,您将能够看到一个成功的运行示例,类似于 图 5.35:![图 5.38 – 通过 GitHub Actions 和 AWS CLI 运行的成功的 CI/CD 流程]()
图 5.35:通过 GitHub Actions 和 AWS CLI 运行的成功的 CI/CD 流程。
此过程允许您成功地将新的更新推送到您的 Airflow 服务到 AWS,以便由您的 MWAA 实例运行。这是真正的 CI/CD,并允许您在不中断服务的情况下持续更新您提供的服务。
构建高级机器学习(ML)管道
我们在本章中已经讨论了 SciKit-learn 和 Spark ML 如何提供创建 ML 管道的机制。您可以将这些视为基本的方法来做到这一点,并开始入门。现在有一系列的工具可供使用,包括开源和商业的,它们将这一概念提升到了新的水平。
为了让大家了解,三大主要公共云提供商在这个领域都有您可能想要了解并尝试的工具。Amazon SageMaker 是这个领域的巨头之一,它包含了一个庞大的工具和功能生态系统,可以帮助您将 ML 模型投入生产。这本书本可以完全关于 Amazon SageMaker,但由于那已经在其他地方完成,在 Learn Amazon SageMaker tinyurl.com/mr48rsxp 中,我们将细节留给读者去发现。您需要知道的关键是,这是 AWS 的托管服务,用于构建 ML 管道,以及监控、模型注册和其他一系列功能,让您能够在整个 ML 生命周期中开发和推广您的模型。Google Vertex AI 是 Google 云平台的 ML 管道、开发和部署工具。它将大量的功能集成在一个 UI 和 API 中,就像 Sagemaker 一样,但在可训练的模型类型上似乎灵活性较低。Azure ML 是微软云提供商的解决方案。
这些都是可以免费尝试的企业级解决方案,但当你需要扩展时,你应该准备好准备好你的信用卡。上述解决方案也自然地与特定的云提供商相关联,因此可能会产生“供应商锁定”,这会使得后续切换变得困难。幸运的是,有一些解决方案可以帮助解决这个问题,并允许机器学习工程师使用更简单的设置进行工作,然后在以后迁移到更复杂的基础设施和环境。我们将首先讨论的这些解决方案之一是ZenML。
寻找你的 ZenML
ZenML是一个完全开源的框架,它帮助你以完全抽象底层基础设施的方式编写机器学习管道。这意味着你的本地开发环境和最终的生产环境可以非常不同,并且可以通过配置更改来改变,而不需要改变管道的核心。这是一个非常强大的想法,也是 ZenML 的关键优势之一。
为了从 ZenML 中获得最佳效果,你需要了解一些核心概念:
-
管道: 如同本章其余部分的讨论所预期的那样,这些是机器学习工作流程中步骤的定义。管道由按指定顺序连接的“步骤”组成。
-
堆栈: 指定管道要运行的环境和基础设施的配置。
-
编排器: 在堆栈定义中,有两个关键组件,第一个是一个编排器。其任务是协调在基础设施上执行的管道步骤。这可能是由发行版提供的默认编排器,也可能是类似 Airflow 或 Kubeflow 编排器。Airflow 在本章的“使用 Airflow 构建通用管道”部分有所描述,而 Kubeflow 则在“跟随 Kubeflow”部分进行介绍。
-
工件存储: 这是负责数据和元数据存储的堆栈组件。ZenML 自带一系列兼容的工件存储,具体包括 AWS S3、Azure Blob Storage 和 Google Cloud Storage。这里的假设是工件存储实际上只是一个存储层,其上没有太多复杂的功能。
到目前为止,一切都很直接。让我们开始设置 ZenML。你可以使用以下命令安装它:
pip install zenml
我们还希望使用 ZenML 附带的 React 仪表板,但为了在本地运行它,你还需要安装一个不同的存储库:
pip install zenml[server]
ZenML 还提供了一系列你可以利用的现有模板,你可以使用以下命令安装:
pip install zenml[templates]
然后,我们可以通过运行以下命令开始使用模板:
zenml init —-template
这将启动一个基于终端的向导,帮助你生成 ZenML 模板。参见图 5.36。

图 5.36:ZenML 模板向导。
按Enter键;然后你将回答一系列问题以帮助配置模板。其中一些在图 5.37中显示出了它们的答案。

图 5.37:为 ZenML 模板定义提供响应。
随着我们被问到我们希望记录和显示在 CLI 中的信息的细节,以及选择数据集和模型类型,接下来的问题系列开始变得非常有趣。在这里,我们将使用Wine数据集,再次使用RandomForestClassifier,如图 5.38所示。

图 5.38:为 ZenML 模板实例化选择模型。
ZenML 将开始为你初始化模板。你可以看到这个过程生成了许多新文件来使用,如图 5.39所示。

图 5.39:使用 ZenML 模板生成向导后生成的文件和文件夹结构。
让我们开始探索 ZenML 解决方案的一些元素。首先,让我们看看pipelines/model_training.py。这是一个简短的脚本,旨在为你提供一个起点。省略文件中的注释,我们有以下代码存在:
from zenml.pipelines import pipeline
@pipeline()
def model_training_pipeline(
data_loader,
data_processor,
data_splitter,
model_trainer,
model_evaluator,
):
dataset = data_loader()
processed_dataset = data_processor(dataset=dataset)
train_set, test_set = data_splitter(dataset=processed_dataset)
model = model_trainer(train_set=train_set)
model_evaluator(
model=model,
train_set=train_set,
test_set=test_set,
)
我们已经开始欣赏 ZenML 中可用的某些功能以及它是如何工作的。首先,我们看到使用@pipeline装饰器表示随后的函数将包含主要的管道逻辑。我们还可以看到管道实际上是用纯 Python 语法编写的;你只需要装饰器来使其“Zen”。这是 ZenML 的一个非常强大的功能,因为它为你提供了按自己的意愿工作的灵活性,同时仍然可以利用我们很快将看到的用于部署目标的下游抽象。管道内的步骤只是当模板初始化时创建的虚拟函数调用,以帮助你了解你应该开发什么。
现在,我们将查看管道步骤,这些步骤已在steps/data_loaders.py和steps/model_trainers.py文件中定义。在我们对这些模块的讨论中,我们不会讨论使用的辅助类和实用函数;这些留给读者去探索。相反,我们将专注于展示最重要的 ZenML 功能的片段。在我们这样做之前,让我们简要讨论一些重要的 ZenML 模块,这些模块在模块顶部导入:
from zenml.enums import StrEnum
from zenml.logger import get_logger
from zenml.steps import (
BaseParameters,
Output,
step,
)
第一个导入从 ZenML 的enums模块中引入了StrEnum。这是一个 Python 枚举的集合,已被定义为帮助构建 ZenML 工作流程的特定元素。
重要提示
回想一下,Python 枚举(或enum)是一组具有唯一值的成员集合,可以通过迭代返回它们的定义顺序。你可以把它们看作介于类和字典之间。首先,在data_loaders.py模块中,我们可以看到第一步包装了从scikit-learn拉取不同数据集的简单逻辑,这取决于提供的参数。这是一个非常基础的例子,但可以更新以包含更复杂的行为,如调用数据库或从云托管对象存储中拉取。方法看起来如下:
@step
def data_loader(
params: DataLoaderStepParameters,
) -> pd.DataFrame:
# Load the dataset indicated in the step parameters and format it as a
# pandas DataFrame
if params.dataset == SklearnDataset.wine:
dataset = load_wine(as_frame=True).frame
elif params.dataset == SklearnDataset.iris:
dataset = load_iris(as_frame=True).frame
elif params.dataset == SklearnDataset.breast_cancer:
dataset = load_breast_cancer(as_frame=True).frame
elif params.dataset == SklearnDataset.diabetes:
dataset = load_diabetes(as_frame=True).frame
logger.info(f"Loaded dataset {params.dataset.value}:
%s", dataset.info())
logger.info(dataset.head())
return dataset
注意,这个函数的输出是一个 pandas DataFrame,在 ZenML 的语言中这是一个工件。接下来的重要步骤是数据处理。模板中给出的示例看起来如下:
@step
def data_processor(
params: DataProcessorStepParameters,
dataset: pd.DataFrame,
) -> pd.DataFrame:
if params.drop_na:
# Drop rows with missing values
dataset = dataset.dropna()
if params.drop_columns:
# Drop columns
dataset = dataset.drop(columns=params.drop_columns)
if params.normalize:
# Normalize the data
target = dataset.pop("target")
dataset = (dataset - dataset.mean()) / dataset.std()
dataset["target"] = target
return dataset
我们可以看到,在这里,处理相对标准,将在数据集中删除NULL值,移除我们在DataProcessingStepParameters类中标记的列(此处未展示),并使用标准缩放应用一些归一化——给出的步骤实际上等同于应用sklearn.preprocessing.StandardScaler方法。
数据加载器模块中的最后一个方法执行数据的训练/测试分割,使用的是我们在本书中已经看到的方法:
@step
def data_splitter(
params: DataSplitterStepParameters,
dataset: pd.DataFrame,
) -> Output(train_set=pd.DataFrame, test_set=pd.DataFrame,):
# Split the dataset into training and dev subsets
train_set, test_set = train_test_split(
dataset,
test_size=params.test_size,
shuffle=params.shuffle,
stratify=dataset["target"] if params.stratify else None,
random_state=params.random_state,
)
return train_set, test_set
现在,回到steps文件夹,我们可以看到还有一个名为model_trainers.py的模块。在这个文件夹的顶部有一些我们在继续之前应该理解的重要导入:
from zenml.enums import StrEnum
from zenml.logger import get_logger
from zenml.steps import (
BaseParameters,
Output,
step,
)
from artifacts import ModelMetadata
from materializers import ModelMetadataMaterializer
logger = get_logger(__name__)
尤其是我们可以看到 ZenML 提供了对 Python 日志库的包装,并且这里使用了两个模块,分别称为artifacts和materializers。这些在模板仓库中定义,展示了如何创建自定义代码来与工件存储库一起工作。具体来说,在artifacts/model_metadata.py模块中,有一个类允许你以你选择的格式存储模型元数据,以便稍后进行序列化和反序列化。再次强调,为了简洁,省略了所有文档字符串和大多数导入:
from typing import Any, Dict
from sklearn.base import ClassifierMixin
class ModelMetadata:
def __init__(self) -> None:
self.metadata: Dict[str, Any] = {}
def collect_metadata(
self,
model: ClassifierMixin,
train_accuracy: float,
test_accuracy: float,
) -> None:
self.metadata = dict(
model_type = model.__class__.__name__,
train_accuracy = train_accuracy,
test_accuracy = test_accuracy,
)
def print_report(self) -> None:
"""Print a user-friendly report from the model metadata."""
print(f"""
Model type: {self.metadata.get('model_type')}
Accuracy on train set: {self.metadata.get('train_accuracy')}
Accuracy on test set: {self.metadata.get('test_accuracy')}
""")
在 ZenML 中,materializers 是包含工件序列化和反序列化逻辑的对象。它们定义了你的管道如何与工件存储库交互。在定义 materializers 时,你可以创建自定义代码,但必须从BaseMaterializer类继承,以确保 ZenML 知道如何在步骤之间以及管道的开始和结束时持久化和读取数据。这在下面的materializers/model_metadata.py中的重要代码中展示:
from zenml.materializers.base_materializer import BaseMaterializer
class ModelMetadataMaterializer(BaseMaterializer):
# This needs to point to the artifact data type(s) associated with the
# materializer
ASSOCIATED_TYPES = (ModelMetadata,)
ASSOCIATED_ARTIFACT_TYPE = ArtifactType.DATA
def save(self, model_metadata: ModelMetadata) -> None:
super().save(model_metadata)
# Dump the model metadata directly into the artifact store as a
YAML file
with fileio.open(os.path.join(self.uri, 'model_metadata.yaml'),
'w') as f:
f.write(yaml.dump(model_metadata.metadata))
def load(self, data_type: Type[ModelMetadata]) -> ModelMetadata:
super().load(data_type)
with fileio.open(os.path.join(self.uri, 'data.txt'), 'r') as f:
model_metadata = ModelMetadata()
model_metadata.metadata = yaml.safe_load(f.read())
return model_metadata
Chapter 4, *Packaging Up*.
现在我们已经讨论了 ZenML 模板的所有关键部分,我们想要运行管道。这是通过仓库最高级别的runner/run.py执行的。然后你可以使用以下命令运行管道:
python run.py
在管道成功运行后(你将在终端中看到一系列输出),你可以运行以下命令来启动一个本地托管的 ZenML 仪表板:
zenml up
现在,如果你导航到作为输出返回的 URL,通常像http://127.0.0.1:8237/login这样的 URL,你会看到一个像图 5.40中显示的主屏幕。

图 5.40:ZenML UI 登录页面。
在提供 URL 的输出中还有一个默认用户名和密码,方便地默认和空白。填写这些信息,你将看到图 5.41中显示的主页。

图 5.41:ZenML UI 首页。
如果你点击左侧的管道部分,然后点击你第一次运行创建的管道,你将能够看到自那时起它运行的所有时间。这个视图在图 5.42中显示。

图 5.42:ZenML UI 中的管道视图。
你还可以通过点击来获取每个运行的详细信息。这会给你提供诸如在运行时作为 DAG 的管道图形表示等信息。见图 5.43。

图 5.43:ZenML UI 中显示的 ZenML 管道示例 DAG。
如果你在这任何视图上点击管道名称,你还可以检索执行时的配置,以 YAML 格式,你可以下载并在后续管道运行中使用:

图 5.44:ZenML UI 中显示的 ZenML 管道运行示例 YAML 配置。
这只是 ZenML 可能实现功能的一小部分,但希望你已经看到了它如何是一种非常灵活的方式来定义和执行你的 ML 管道。当你利用其跨不同堆栈和不同工件存储部署相同管道的能力时,它变得更加强大。
在下一节中,我们将讨论另一个关注创建跨平台兼容性和标准化 ML 管道的管道工具,Kubeflow。
按照 Kubeflow 进行
Kubeflow是一个开源解决方案,旨在提供构建端到端 ML 系统的便携式方法。这个工具特别关注为开发者提供快速创建数据预处理、ML 模型训练、预测和监控管道的能力,这些管道是平台无关的。它通过利用 Kubernetes 来实现所有这些,允许你在最终部署的环境之外的不同环境中开发你的解决方案。Kubeflow 对特定的编程和 ML 框架不敏感,因此你可以利用开源社区中的所有你喜欢的工具,但仍然以你信任的方式将它们组合在一起。
Kubeflow 文档提供了关于该工具背后的架构和设计原则的大量详细信息,请参阅 www.kubeflow.org/docs/。我们将专注于理解最显著的观点,并通过一些实际示例开始。这将使您能够将此与其他我们在本章中讨论的工具进行比较,并让您在未来项目中做出自己的决定。
Kubeflow 是一个由多个模块化组件组成的平台,每个组件在机器学习开发生命周期中都扮演着角色。具体来说,有:
-
Jupyter Notebook 网页应用和控制器,用于数据探索分析和初步建模。
-
像 PyTorch、TFJob 和 XGBoost 操作员等训练操作员,用于构建各种模型。
-
使用 Katib 进行超参数调整和神经网络架构搜索功能。
-
数据转换的 Spark 操作员,包括 AWS EMR 集群的选项。
-
用于与您的 Kubernetes 集群交互以及管理您的 Kubeflow 工作负载的仪表板。
-
Kubeflow Pipelines:它自己的平台,用于构建、运行和管理端到端机器学习工作流程。这包括用于具有多个步骤的工作流程的编排引擎以及用于与您的管道一起工作的 SDK。您可以将 Kubeflow Pipelines 作为独立平台进行安装。
将 Kubeflow 安装并运行起来的步骤可能相当复杂,因此最好查看官方文档,并运行适合您平台和需求的相关步骤。我们将按照以下步骤进行:
-
安装 Kind,这是一个便于轻松构建和运行本地 Kubernetes 集群的工具。在 Linux 上,这是通过以下方式完成的:
curl -Lo ./kind https://kind.sigs.k8s.io/dl/{KIND_VERSION}/kind-linux-amd64 && \ chmod +x ./kind && \ mv ./kind /{YOUR_KIND_DIRECTORY}/kind在 MacOS 上,这是通过以下方式完成的:
brew install kind -
安装 Kubernetes 命令行工具
kubectl,它允许您与您的集群进行交互。在 Linux 上,这是通过以下方式完成的:sudo apt-get install kubectl或者,在 MacOS 上:
brew install kubernetes-cli -
要检查是否成功,您可以在终端中运行以下命令:
kubectl version --client --output=yaml -
您应该会收到类似以下的输出:
clientVersion: buildDate: "2023-01-18T15:51:24Z" compiler: gc gitCommit: 8f94681cd294aa8cfd3407b8191f6c70214973a4 gitTreeState: clean gitVersion: v1.26.1 goVersion: go1.19.5 major: "1" minor: "26" platform: darwin/arm64 kustomizeVersion: v4.5.7 -
使用 Kind 创建您的本地集群。默认情况下,集群的名称将是
kind,但您可以通过标志提供自己的名称:kind create cluster –name=mlewp -
您将看到类似以下的输出:
Creating cluster "mlewp" ... Ensuring node image (kindest/node:v1.25.3)  Preparing nodes  Writing configuration  Starting control-plane  Installing CNI  Installing StorageClass  Set kubectl context to "kind-mlewp" You can now use your cluster with: kubectl cluster-info --context kind-mlewp Thanks for using kind!  -
然后,您需要将 Kubeflow 管道部署到集群中。为此的命令已被包含在本书 GitHub 仓库中名为
deploy_kubeflow_pipelines.zsh的脚本中,并包含以下代码(PIPELINE_VERSION数字可以根据需要更新以匹配您的安装):export PIPELINE_VERSION=1.8.5 kubectl apply -k "github.com/kubeflow/pipelines/manifests/kustomize/cluster-scoped-resources?ref=$PIPELINE_VERSION" kubectl wait --for condition=established --timeout=60s crd/applications.app.k8s.io kubectl apply -k "github.com/kubeflow/pipelines/manifests/kustomize/env/dev?ref=$PIPELINE_VERSION"
运行这些命令后,您可以通过端口转发并在 http://localhost:8080/ 打开 Kubeflow Pipelines UI 来验证安装是否成功:
kubectl port-forward -n kubeflow svc/ml-pipeline-ui 8080:80
这应该会为您提供类似 图 5.45 中所示的登录页面。

图 5.45:Kubeflow UI 登录页面。
现在你已经使用之前的命令启动了端口转发,你将使用这个命令通过以下 Python 代码允许 Kubeflow Pipelines SDK 通过集群进行通信(注意,你必须在安装了 Kubeflow Pipelines SDK 之后才能这样做,这将在下一步介绍):
import kfp
client = kfp.Client(host="http://localhost:8080")
要安装 Kubeflow Pipelines SDK,请运行:
pip install kfp –upgrade
为了检查一切是否正常,你可以运行以下命令:
pip list | grep kfp
这将给出类似于以下输出的结果:
kfp 1.8.19
kfp-pipeline-spec 0.1.16
kfp-server-api 1.8.5
就这样!我们现在准备好开始构建一些 Kubeflow 管道了。让我们从一个基本示例开始。
现在我们可以开始使用 SDK 构建一些基本的管道,然后我们可以将它们部署到我们的集群中。假设接下来几步我们在一个名为pipeline_basic.py的文件中工作:
-
首先,我们导入所谓的 KFP 领域特定语言(DSL),这是一个包含用于定义 KFP 步骤的各种实用工具的 Python 包集。我们还导入用于与集群交互的客户端包。我们还将导入我们稍后将要使用的几个 DSL 子模块。在这里需要注意的一个重要点是,我们将利用的一些功能实际上包含在 Kubeflow pipelines SDK 的
V2中,因此我们需要导入一些这些特定模块:from kfp import Client import kfp.dsl from kfp.v2 import dsl from kfp.v2.dsl import Dataset from kfp.v2.dsl import Input from kfp.v2.dsl import Model from kfp.v2.dsl import Output -
下一步是定义管道中的步骤。这些被称为“组件”,是带有
dsl装饰器的函数。在这个第一步中,我们检索 Iris 数据集并将其写入 CSV。在第一行,我们将使用dsl装饰器,并定义将运行该步骤的容器中需要安装的包:@dsl.component(packages_to_install=['pandas==1.3.5']) def create_dataset(iris_dataset: Output[Dataset]): import pandas as pd csv_url = "https://archive.ics.uci.edu/ml/machine-learning- databases/iris/iris.data" col_names = ["Sepal_Length", "Sepal_Width", "Petal_Length", "Petal_Width", "Labels"] df = pd.read_csv(csv_url) df.columns = col_names with open(iris_dataset.path, 'w') as f: df.to_csv(f) -
现在我们已经检索到了数据集,并回忆起我们在第三章中学到的知识,从模型到模型工厂,我们想要对数据进行特征工程。因此,我们将在另一个组件中规范化数据。大部分代码应该是自解释的,但请注意,我们不得不在
packages_to_install关键字参数中添加scikit-learn依赖项,并且我们再次不得不将组件的结果写入 CSV 文件:@dsl.component(packages_to_install=['pandas==1.3.5', 'scikit-learn==1.0.2']) def normalize_dataset( input_iris_dataset: Input[Dataset], normalized_iris_dataset: Output[Dataset], standard_scaler: bool, min_max_scaler: bool, ): if standard_scaler is min_max_scaler: raise ValueError( 'Exactly one of standard_scaler or min_max_scaler must be True.') import pandas as pd from sklearn.preprocessing import MinMaxScaler from sklearn.preprocessing import StandardScaler with open(input_iris_dataset.path) as f: df = pd.read_csv(f) labels = df.pop('Labels') if standard_scaler: scaler = StandardScaler() if min_max_scaler: scaler = MinMaxScaler() df = pd.DataFrame(scaler.fit_transform(df)) df['Labels'] = labels with open(normalized_iris_dataset.path, 'w') as f: df.to_csv(f) -
我们现在将在数据上训练一个 K 最近邻分类器。在这个组件中,我们不会输出数据集,而是输出训练好的模型工件,一个
.pkl文件:@dsl.component(packages_to_install=['pandas==1.3.5', 'scikit- learn==1.0.2']) def train_model( normalized_iris_dataset: Input[Dataset], model: Output[Model], n_neighbors: int, ): import pickle import pandas as pd from sklearn.model_selection import train_test_split from sklearn.neighbors import KNeighborsClassifier with open(normalized_iris_dataset.path) as f: df = pd.read_csv(f) y = df.pop('Labels') X = df X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) clf = KNeighborsClassifier(n_neighbors=n_neighbors) clf.fit(X_train, y_train) with open(model.path, 'wb') as f: pickle.dump(clf, f) -
我们现在有了我们想要做的所有组件,所以现在我们可以最终将它们组合成一个 Kubeflow 管道。为此,我们使用
@dsl.pipeline装饰器,并将管道的名称作为该装饰器的参数:@dsl.pipeline(name='iris-training-pipeline') def my_pipeline( standard_scaler: bool, min_max_scaler: bool, neighbors: List[int], ): create_dataset_task = create_dataset() normalize_dataset_task = normalize_dataset( input_iris_dataset=create_dataset_task .outputs['iris_dataset'], standard_scaler=True, min_max_scaler=False) with dsl.ParallelFor(neighbors) as n_neighbors: train_model( normalized_iris_dataset=normalize_dataset_task .outputs['normalized_iris_dataset'], n_neighbors=n_neighbors) -
最后的阶段是将管道提交运行。这是通过实例化一个 Kubeflow Pipelines 客户端类并输入适当的参数来完成的。《KFP_UI_URL》是 Kubeflow Pipelines 实例的主机 URL - 在这种情况下,是我们之前通过端口转发得到的那个。还重要的是要注意,由于我们正在使用
V2Kubeflow Pipelines API 的几个功能,我们应该为模式参数传递kfp.dsl.PipelineExecutionMode.V2_COMPATIBLE标志:endpoint = '<KFP_UI_URL>' kfp_client = Client(host=endpoint) run = kfp_client.create_run_from_pipeline_func(my_pipeline, mode=kfp.dsl.PipelineExecutionMode.V2_COMPATIBLE, arguments={ 'min_max_scaler': True, 'standard_scaler': False, 'neighbors': [3, 6, 9] }, ) url = f'{endpoint}/#/runs/details/{run.run_id}' print(url) -
要构建和部署此管道并运行它,你可以执行以下操作:
python basic_pipeline.py
在运行最后一步后,你将看到运行的 URL 打印到终端,看起来可能像这样:
http://localhost:8080/#/runs/details/<UID>
如果你导航到该链接,并且管道已成功运行,你应该在 Kubeflow 仪表板中看到一个视图,显示管道的步骤,还有一个侧边栏允许你浏览关于你的管道及其运行的元数据系列。上述代码运行的一个示例显示在图 5.46中。

图 5.46:Kubeflow UI 显示了主文中定义的训练管道的成功运行。
就这样,你现在已经构建并运行了你的第一个 Kubeflow 管道!
重要提示
你还可以将 Kubeflow 管道编译成序列化的 YAML,然后可以被 Kubeflow 后端读取。你可以通过运行以下类似命令来完成此操作,其中pipeline是之前示例中使用的相同管道对象:
`cmplr = compiler.Compiler()`
`cmplr.compile(my_pipeline, package_path='my_pipeline.yaml')`
做这件事的一个原因是运行管道变得非常简单。你只需将其上传到 Kubeflow 管道 UI,或者你可以通过编程方式将 YAML 发送到集群。
如同在寻找你的 ZenML部分所述,我们只是刚刚开始探索这个工具的表面,最初的重点是在本地环境中了解基础知识。Kubeflow 基于 Kubernetes 的优点在于平台无关性是其核心所在,因此这些管道可以在支持容器的任何地方有效运行。
重要提示
尽管我已将 ZenML 和 Kubeflow 作为两个不同的管道工具进行介绍,但实际上它们可以被视为互补的,以至于 ZenML 通过使用 ZenML Kubeflow 编排器提供了部署 Kubeflow 管道的能力。这意味着你可以利用 ZenML 提供的更高级别的抽象,同时仍然获得 Kubeflow 作为部署目标的扩展性和健壮性。我们在此不详细讨论,但 ZenML 文档提供了一个优秀的指南:docs.zenml.io/stacks-and-components/component-guide/orchestrators/kubeflow。
下一节将简要介绍一些不同的部署策略,当你旨在将所有这些工具和技术应用于实际解决方案时,你应该了解这些策略。
选择你的部署策略
在本章中,我们讨论了许多将机器学习解决方案投入生产的详细技术。然而,缺失的部分是我们没有定义如何处理现有基础设施,以及如何将你的解决方案引入真实流量和请求。这正是由你的部署策略所定义的,选择一个合适的策略是成为一名机器学习工程师的重要部分。
大多数部署策略,就像本书中的许多概念一样,是从软件工程和DevOps领域继承而来的。以下列出两个最重要的策略,并附带一些讨论,说明在机器学习(ML)环境中它们何时特别有用。
蓝绿部署是一种部署方式,其中新版本的软件与现有的解决方案并行运行,直到满足某些预定义的标准。在此之后,您将所有传入的流量/请求切换到新系统,然后退役旧系统,或者将其保留作为潜在的回滚解决方案。这种方法最初是由两位开发者 Daniel North 和 Jez Humble 开发的,他们在 2005 年正在为一个电子商务网站工作。
该名称的起源描述在这个 GitHub Gist 中,gitlab.com/-/snippets/1846041,但基本上可以归结为这样一个事实:他们能想到的任何其他命名约定都隐含着候选解决方案或环境中的一个“更好”或“更差”于另一个,例如“A 和 B”或“绿色和红色”。这种策略已经成为了经典。
在机器学习工程环境中,这在您希望在完全部署之前收集模型和解决方案性能数据一段时间的情况下特别有用。它还帮助利益相关者提供证据,证明机器学习解决方案将在“野外”按预期表现。它还与批量作业配合得很好,因为您实际上是在同一时间运行另一个批量作业。如果作业很大或复杂,或者您的生产环境维护成本高昂,这可能对您有一些成本影响,需要您考虑。
下一个策略被称为金丝雀部署,其设置与蓝/绿方法类似,但涉及在两个解决方案之间更渐进地切换流量。这里的想法是,新系统部署后,最初接收一定比例的流量,比如 5%或 10%,在稳定性和性能得到确认后,然后添加下一增量流量。总量始终保持在 100%,因此随着新系统获得更多流量,旧系统接收的流量就会减少。这个名称起源于古老的煤矿技术,即使用金丝雀作为测试矿井大气中毒性的测试。释放金丝雀,如果它们存活,那么一切正常。幸运的是,在这个部署技术的使用过程中,没有伤害到任何鸟类。当你能够将需要评分的数据分割开来,同时仍然获得进入下一阶段所需的信息时,这种策略非常有意义。例如,一个被网站后端调用的 ML 微服务非常适合,因为你可以在负载均衡器上逐渐更改路由到新服务。对于大型批量作业来说,这可能不太合理,因为没有自然的方法将数据分割成不同的增量,而网络流量则肯定有。
第八章,构建示例 ML 微服务,将向你展示如何在使用 Kubernetes 构建自定义 ML 端点时使用这些策略。
无论你使用什么部署策略,始终要记住关键是要在成本效益、解决方案的运行时间和输出的信任度之间取得平衡。如果你能完成所有这些,那么你就已经部署了一个成功的组合。
摘要
在本章中,我们讨论了部署你的机器学习解决方案时的一些重要概念。特别是,我们关注了架构的概念以及部署到云中时可能使用的工具。我们涵盖了现代机器学习工程中使用的最重要的模式,以及如何使用容器和 AWS 弹性容器注册表和弹性容器服务以及如何使用 Apache Airflow 的托管工作流来实施这些模式。我们还探讨了如何将 MWAA 示例与 GitHub Actions 连接起来,以便代码的更改可以直接触发运行服务的更新,为未来的 CI/CD 流程提供了一个模板。
然后,我们继续讨论更高级的管道工具,以在第四章,打包的基础上进行讨论。这侧重于如何使用 Apache Airflow 构建和编排你的通用管道,以运行你的数据工程、机器学习和 MLOps 管道。然后,我们转向 ZenML 和 Kubeflow 的详细介绍,这两个工具是开发和大规模部署机器学习和 MLOps 管道的强大工具。
在下一章中,我们将探讨其他扩大我们解决方案的方法,以便我们能够处理大量数据和高速计算。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第六章:扩展
上一章全部关于开始讨论我们如何使用不同的部署模式将我们的解决方案推向世界,以及我们可以使用的某些工具。本章将在此基础上进行讨论,讨论我们可以使用的概念和工具,以扩展我们的解决方案以应对大量数据或流量。
在您的笔记本电脑上运行一些简单的机器学习(ML)模型,在几千个数据点上是一个很好的练习,尤其是在您执行我们在任何机器学习开发项目开始时概述的发现和概念验证步骤时。然而,如果我们必须以相对较高的频率运行数百万个数据点,或者如果我们必须同时训练数千个类似规模的模型,这种方法就不合适了。这需要不同的方法、心态和工具集。
在以下页面中,我们将介绍目前使用最广泛的两个用于分布式数据计算的框架的详细信息:Apache Spark和Ray。特别是,我们将讨论这些框架在底层的一些关键点,以便在开发过程中,我们可以就如何使用它们做出一些好的决策。然后,我们将讨论如何使用这些框架在您的机器学习工作流程中,并提供一些具体的示例,这些示例专门旨在帮助您在处理大量数据时。接下来,将提供一个关于创建允许您扩展推理端点的无服务器应用的简要介绍。最后,我们将介绍如何使用 Kubernetes 扩展容器化的机器学习应用,这补充了我们在第五章,部署模式和工具中完成的工作,并在第八章,构建示例 ML 微服务中详细展开。
这将帮助您在我们之前在这本书中已经查看的一些实际示例的基础上进行构建,当时我们使用 Spark 来解决我们的机器学习问题,并增加一些更具体的理论理解和更详细的实际示例。在本章之后,您应该对如何使用一些最好的框架和技术来扩展您的机器学习解决方案以适应更大的数据集感到自信。
在本章中,我们将在以下部分中涵盖所有这些内容:
-
使用 Spark 进行扩展
-
启动无服务器基础设施
-
使用 Kubernetes 进行大规模容器化
-
使用 Ray 进行扩展
-
设计大规模系统
技术要求
与其他章节一样,您可以通过使用提供的 Conda 环境yml文件或从书库中的requirements.txt文件来设置您的 Python 开发环境,以便能够运行本章中的示例,在第六章下:
conda env create –f mlewp-chapter06.yml
本章的示例还需要安装一些非 Python 工具,以便从头到尾遵循示例;请参阅每个工具的相关文档:
-
AWS CLI v2
-
Docker
-
Postman
-
Ray
-
Apache Spark(版本 3.0.0 或更高)
使用 Spark 进行扩展
Apache Spark,或简称 Spark,起源于 2012 年加州大学伯克利分校一些杰出研究人员的工作,自那时起,它彻底改变了我们处理大数据集问题的方法。
Spark 是一个集群计算框架,这意味着它基于几个计算机以允许计算任务共享的方式相互连接的原则。这使我们能够有效地协调这些任务。每次我们讨论运行 Spark 作业时,我们总是谈论我们在其上运行的集群。
这是一组执行任务的计算机,即工作节点,以及托管组织工作负载的计算机,被称为头节点。
Spark 是用 Scala 编写的,这是一种具有强烈函数式风格的编程语言,并编译成Java 虚拟机(JVMs)。由于这是一本关于 Python 机器学习工程的书籍,我们不会过多讨论 Spark 底层的 Scala 组件,除非它们有助于我们在工作中使用它。Spark 有几个流行的 API,允许程序员用多种语言(包括 Python)与之一起开发。这导致了我们在本书中使用的 PySpark 语法。
那么,这一切是如何组合在一起的?
首先,使 Apache Spark 如此受欢迎的一个原因是它拥有大量可用的连接器、组件和 API。例如,四个主要组件与Spark Core接口:
-
Spark SQL、DataFrames和Datasets:这个组件允许你创建非常可扩展的程序,用于处理结构化数据。通过 Spark 的主要结构化 API(Python、Java、Scala 或 R)编写符合 SQL 规范的查询并创建利用底层 Spark 引擎的数据表,可以非常容易地访问 Spark 的主要功能集。
-
Spark Structured Streaming:这个组件允许工程师处理由例如 Apache Kafka 提供的流数据。设计极其简单,允许开发者像处理一个不断增长的 Spark 结构化表一样简单地处理流数据,具有与标准表相同的查询和处理功能。这为创建可扩展的流解决方案提供了低门槛。
-
GraphX:这是一个库,允许你实现图并行处理并将标准算法应用于基于图的数据(例如,如 PageRank 或三角形计数)。Databricks 的GraphFrames项目通过允许我们在 Spark 中使用基于 DataFrame 的 API 来分析图数据,使得这一功能更加易于使用。
-
Spark ML:最后但同样重要的是,我们有最适合我们作为机器学习工程师的组件:Spark 的原生机器学习库。这个库包含了我们在本书中已经看到过的许多算法和特征工程能力。能够在库中使用 DataFrame API 使得它极其易于使用,同时仍然为我们提供了创建非常强大代码的途径。
通过在 Spark 集群上使用 Spark ML 与在单个线程上运行另一个机器学习库相比,你可以为你的机器学习训练获得巨大的速度提升。我们还可以应用其他技巧到我们最喜欢的机器学习实现中,然后使用 Spark 来扩展它们;我们稍后会探讨这一点。
Spark 的架构基于驱动程序/执行器架构。驱动程序是作为 Spark 应用程序的主要入口点的程序,也是创建 SparkContext 对象的地方。SparkContext 将任务发送到执行器(它们在自己的 JVM 上运行),并以适合给定管理器和解决方案运行模式的方式与集群管理器进行通信。驱动程序的主要任务之一是将我们编写的代码转换为 有向无环图(DAG)中的逻辑步骤集合(与我们在第五章 部署模式和工具 中使用的 Apache Airflow 的概念相同),然后将该 DAG 转换为需要在可用的计算资源上执行的任务集合。
在接下来的页面中,我们将假设我们正在使用 Hadoop YARN 资源管理器运行 Spark,这是最受欢迎的选项之一,也是 AWS Elastic MapReduce(EMR)解决方案的默认选项(关于这一点稍后还会详细介绍)。在以 集群模式 运行 YARN 时,驱动程序程序在 YARN 集群上的一个容器中运行,这使得客户端可以通过驱动程序提交作业或请求,然后退出(而不是要求客户端保持与集群管理器的连接,这在所谓的 客户端模式 下可能会发生,这里我们不会讨论)。
集群管理器负责在集群上可用的资源上启动执行器。
Spark 的架构允许我们作为机器学习工程师,无论我们是在笔记本电脑上本地工作还是在拥有数千个节点的集群上工作,都可以使用相同的 API 和语法来构建解决方案。驱动程序、资源管理器和执行器之间的连接是实现这种魔法的关键。
Spark 技巧和技巧
在本小节中,我们将介绍一些简单但有效的技巧,以使用 Spark 编写高性能的解决方案。我们将重点关注数据操作和准备的关键语法,这些通常是任何机器学习管道中的第一步。让我们开始吧:
-
首先,我们将介绍编写良好的 Spark SQL 的基础知识。任何 Spark 程序的入口点是
SparkSession对象,我们需要在我们的应用程序中导入其实例。它通常使用
spark变量实例化:from pyspark.sql import SparkSession spark = SparkSession\ .builder\ .appName("Spark SQL Example")\ .config("spark.some.config.option", "some-value")\ .getOrCreate() -
然后,你可以使用
spark对象和sql方法运行 Spark SQL 命令,针对你的可用数据:spark.sql('''select * from data_table''')根据数据存在的地方,有各种方法可以在 Spark 程序内部提供所需的数据。以下示例取自我们在第三章,“从模型到模型工厂”中经过的一些代码,展示了如何从
csv文件中将数据拉入 DataFrame:data = spark.read.format("csv")\ .option("sep", ";")\ .option("inferSchema", "true")\ .option("header", "true").load( "data/bank/bank.csv") -
现在,我们可以使用以下语法创建此数据的临时视图:
data.createOrReplaceTempView('data_view') -
然后,我们可以使用之前提到的方法查询此数据,以查看记录或创建新的 DataFrames:
new_data = spark.sql('''select …''')
当编写 Spark SQL 时,一些标准做法有助于提高代码的效率:
-
尽量不要将左边的大的表格与右边的小的表格连接,因为这效率低下。通常,尽量使用于连接的数据集尽可能瘦,例如,尽可能少地使用未使用的列或行进行连接。
-
避免查询语法扫描非常大的数据集;例如,
select max(date_time_value)。
在这个情况下,尝试定义逻辑,在找到最小或最大值之前更积极地过滤数据,并且通常允许解决方案扫描更小的数据集。
在使用 Spark 时,以下是一些其他的好做法:
-
避免数据倾斜:尽可能了解你的数据将如何在执行器之间分割。如果你的数据是在日期列上分区的,如果每天的数据量相当,这可能是一个不错的选择,但如果某些天有大部分数据而其他天很少,这可能是一个坏选择。可能需要使用更合适的列(或使用
repartition命令生成的 Spark 生成的 ID)重新分区。 -
避免数据洗牌:这是指数据在不同分区之间重新分配。例如,我们可能有一个按日级别分区的数据集,然后我们要求 Spark 对所有时间的数据集的一个列求和。这将导致所有每日分区被访问,并将结果写入一个新的分区。为此,必须发生磁盘写入和网络传输,这通常会导致你的 Spark 作业的性能瓶颈。
-
避免在大数据集中执行操作:例如,当你运行
collect()命令时,你将把所有数据都带回驱动节点。如果这是一个大数据集,这可能会非常糟糕,但可能需要将计算结果转换为其他东西。请注意,toPandas()命令,它将你的 SparkDataFrame转换为 pandasDataFrame,也会收集驱动器内存中的所有数据。 -
当适用时使用 UDF:作为 Apache Spark 的 ML 工程师,你武器库中的另一个优秀工具是用户定义函数(UDF)。UDF 允许你封装更复杂和定制的逻辑,并以各种方式大规模应用。这个方面的重要之处在于,如果你编写了一个标准的 PySpark(或 Scala)UDF,那么你可以在 Spark SQL 语法内部应用这个 UDF,这允许你高效地重用你的代码,甚至简化 ML 模型的适用。缺点是这些代码有时可能不是最有效的,但如果它有助于使你的解决方案更简单、更易于维护,那么它可能是一个正确的选择。
作为具体示例,让我们构建一个 UDF,它将查看我们在第三章“从模型到模型工厂”中处理过的银行数据,创建一个名为‘month_as_int’的新列,该列将当前月份的字符串表示形式转换为整数以便后续处理。我们不会关注训练/测试分割或这可能被用于什么;相反,我们将突出如何将一些逻辑应用于 PySpark UDF。
让我们开始吧:
-
首先,我们必须读取数据。注意这里给出的相对路径与本书 GitHub 仓库中的
spark_example_udfs.py脚本一致,该脚本位于github.com/PacktPublishing/Machine-Learning-Engineering-with-Python-Second-Edition/blob/main/Chapter06/mlewp2-spark/spark_example_udfs.py:from pyspark.sql import SparkSession from pyspark import SparkContext from pyspark.sql import functions as f sc = SparkContext("local", "Ch6BasicExampleApp") # Get spark session spark = SparkSession.builder.getOrCreate() # Get the data and place it in a spark dataframe data = spark.read.format("csv").option("sep", ";").option("inferSchema", "true").option("header", "true").load( "data/bank/bank.csv")如果我们使用
data.show()命令显示当前数据,我们会看到类似以下内容:![图 6.1 – 银行数据集中初始 DataFrame 的数据样本]()
图 6.1:银行数据集中初始 DataFrame 的数据样本。
-
现在,我们可以使用
data.printSchema()命令双重检查这个 DataFrame 的模式。这确认了month目前是以字符串形式存储的,如下所示:|-- age: integer (nullable = true) |-- job: string (nullable = true) |-- marital: string (nullable = true) |-- education: string (nullable = true) |-- default: string (nullable = true) |-- balance: integer (nullable = true) |-- housing: string (nullable = true) |-- loan: string (nullable = true) |-- contact: string (nullable = true) |-- day: integer (nullable = true) |-- month: string (nullable = true) |-- duration: integer (nullable = true) |-- campaign: integer (nullable = true) |-- pdays: integer (nullable = true) |-- previous: integer (nullable = true) |-- poutcome: string (nullable = true) |-- y: string (nullable = true) -
现在,我们可以定义我们的 UDF,它将使用 Python 的
datetime库将月份的字符串表示形式转换为整数:import datetime def month_as_int(month): month_number = datetime.datetime.strptime(month, "%b").month return month_number -
如果我们想在 Spark SQL 内部应用我们的函数,那么我们必须将函数注册为 UDF。
register()函数的参数是函数的注册名称、我们刚刚编写的 Python 函数的名称以及返回类型。默认情况下,返回类型是StringType(),但我们在这里明确指定了它:from pyspark.sql.types import StringType spark.udf.register("monthAsInt", month_as_int, StringType()) -
最后,既然我们已经注册了函数,我们就可以将其应用于我们的数据。首先,我们将创建银行数据集的一个临时视图,然后运行一个 Spark SQL 查询,该查询引用我们的用户定义函数(UDF):
data.createOrReplaceTempView('bank_data_view') spark.sql(''' select *, monthAsInt(month) as month_as_int from bank_data_view ''').show()使用
show()命令运行前面的语法显示我们已经成功计算了新列。结果DataFrame的最后几列如下所示:![图 6.3 – 通过应用我们的 UDF 成功计算了新列]()
图 6.2:通过应用我们的 UDF 成功计算了新列。
-
或者,我们可以使用以下语法创建我们的 UDF,并将结果应用于 Spark
DataFrame。如前所述,使用 UDF 有时可以让你非常简单地封装相对复杂的语法。这里的语法相当简单,但我仍然会向你展示。这给我们带来了与前面截图相同的结果:from pyspark.sql.functions import udf month_as_int_udf = udf(month_as_int, StringType()) df = spark.table("bank_data_view") df.withColumn('month_as_int', month_as_int_udf("month")).show() -
最后,PySpark 还提供了一个很好的装饰器语法来创建我们的 UDF,这意味着如果你确实在构建一些更复杂的功能,你只需将这个装饰器放在被装饰的 Python 函数中即可。下面的代码块也给出了与前面截图相同的结果:
@udf("string") def month_as_int_udf(month): month_number = datetime.datetime.strptime(month, "%b").month return month_number df.withColumn('month_as_int', month_as_int_udf("month")).show()
这显示了如何在 UDF 中应用一些简单的逻辑,但为了使用这种方法在规模上部署模型,我们必须在函数内部放置 ML 逻辑并以相同的方式应用它。如果我们想使用我们习惯于从数据科学世界使用的标准工具,如 Pandas 和Scikit-learn,这可能会变得有点棘手。幸运的是,我们还有另一个可以使用的选项,它有一些优点。我们现在就来讨论这个。
当我们在 Python 中工作时,目前考虑的 UDF 存在一个小问题,那就是在 JVM 和 Python 之间转换数据可能需要一段时间。一种解决方法是使用所谓的pandas UDFs,它底层使用 Apache Arrow 库来确保我们的 UDF 执行时数据读取快速。这给我们带来了 UDF 的灵活性,而没有任何减速。
pandas UDFs 也非常强大,因为它们与 pandas Series 和 DataFrame 对象的语法一起工作。这意味着许多习惯于使用 pandas 在本地构建模型的科学家可以轻松地将他们的代码扩展到使用 Spark。
例如,让我们回顾一下如何将一个简单的分类器应用于我们在这本书中之前使用过的 wine 数据集。请注意,该模型并未针对这些数据进行优化;我们只是展示了一个应用预训练分类器的示例:
-
首先,让我们在 wine 数据集上创建一个简单的支持向量机(SVM)分类器。我们在这里没有进行正确的训练/测试分割、特征工程或其他最佳实践,因为我们只是想向你展示如何应用任何
sklearn模型:import sklearn.svm import sklearn.datasets clf = sklearn.svm.SVC() X, y = sklearn.datasets.load_wine(return_X_y=True) clf.fit(X, y) -
然后,我们可以将特征数据带入 Spark DataFrame,以展示如何在后续阶段应用 pandas UDF:
df = spark.createDataFrame(X.tolist()) -
pandas UDFs 非常容易定义。我们只需在函数中编写我们的逻辑,然后添加
@pandas_udf装饰器,在那里我们还需要为函数提供输出类型。在最简单的情况下,我们可以将使用训练模型进行预测的(通常是串行或仅本地并行化)过程封装起来:import pandas as pd from pyspark.sql.types import IntegerType from pyspark.sql.functions import pandas_udf @pandas_udf(returnType=IntegerType()) def predict_pd_udf(*cols): X = pd.concat(cols, axis=1) return pd.Series(clf.predict(X)) -
最后,我们可以通过传递我们函数所需的适当输入来将此应用于包含数据的 Spark
DataFrame。在这种情况下,我们将传递特征列的名称,共有 13 个:col_names = ['_{}'.format(x) for x in range(1, 14)] df_pred = df.select('*', predict_pd_udf(*col_names).alias('class'))
现在,如果您查看这个结果,您将看到df_pred DataFrame 的前几行如下所示:

图 6.3:应用简单的 pandas UDF 的结果。
这样,我们就完成了对 Spark 和 pandas UDF 在 Spark 中的快速浏览,这使我们能够以明显并行的方式应用诸如数据转换或我们的机器学习模型之类的串行 Python 逻辑。
在下一节中,我们将专注于如何在云端设置 Spark-based 计算。
云端 Spark
如前所述,应该很清楚,编写和部署基于 PySpark 的机器学习解决方案可以在您的笔记本电脑上完成,但为了在工作规模上看到好处,您必须拥有适当规模的计算集群。提供此类基础设施可能是一个漫长而痛苦的过程,但正如本书中已经讨论的那样,主要公共云提供商提供了大量的基础设施选项。
对于 Spark,AWS 有一个特别好的解决方案,称为AWS Elastic MapReduce(EMR),这是一个托管的大数据平台,允许您轻松配置大数据生态系统中的几种不同类型的集群。在这本书中,我们将专注于基于 Spark 的解决方案,因此我们将专注于创建和使用带有 Spark 工具的集群。
在下一节中,我们将通过一个具体的例子来展示如何在 EMR 上启动一个 Spark 集群,然后将其部署一个简单的基于 Spark ML 的应用程序。
因此,让我们在AWS EMR上探索 Spark 在云端的应用!
AWS EMR 示例
为了理解 EMR 是如何工作的,我们将继续遵循本书的实践方法,并深入一个例子。我们将首先学习如何创建一个全新的集群,然后再讨论如何编写和部署我们的第一个 PySpark ML 解决方案到集群中。让我们开始吧:
-
首先,导航到 AWS 上的EMR页面,找到创建集群按钮。然后,您将被带到允许您输入集群配置数据的页面。第一个部分是您指定集群名称和要安装在其上的应用程序的地方。我将把这个集群命名为
mlewp2-cluster,使用写作时的最新 EMR 版本 6.11.0,并选择Spark应用程序包。 -
在这个第一部分,所有其他配置都可以保持默认设置。这如图 6.4 所示:
![计算机程序截图 描述自动生成]()
图 6.4:使用一些默认配置创建我们的 EMR 集群。
-
接下来是集群中使用的计算配置。您在这里也可以再次使用默认设置,但了解正在发生的事情很重要。首先,是选择使用“实例组”还是“实例舰队”,这指的是根据您提供的某些约束条件部署的计算扩展策略。实例组更简单,定义了您为每种节点类型想要运行的特定服务器,关于这一点我们稍后再详细说明,并且您可以在集群生命周期内需要更多服务器时选择“按需”或“竞价实例”。实例舰队允许采用更多复杂的获取策略,并为每种节点类型混合不同的服务器实例类型。有关更多信息,请阅读 AWS 文档,以确保您对不同的选项有清晰的了解,
docs.aws.amazon.com/emr/index.xhtml;我们将通过使用具有默认设置的实例组来继续操作。现在,让我们转到节点。EMR 集群中有不同的节点;主节点、核心节点和任务节点。主节点将运行我们的 YARN 资源管理器,并跟踪作业状态和实例组健康。核心节点运行一些守护程序和 Spark 执行器。最后,任务节点执行实际的分布式计算。现在,让我们按照为实例组选项提供的默认设置进行操作,如图 6.5 中的主节点所示。![计算机屏幕截图 自动生成的描述]()
图 6.5:我们的 EMR 集群的计算配置。我们选择了更简单的“实例组”选项进行配置,并采用了服务器类型的默认设置。
-
接下来,我们将定义我们在步骤 2中提到的用于实例组和实例舰队计算选项的显式集群缩放行为。再次提醒,现在请选择默认设置,但您可以在这里尝试调整集群的大小,无论是通过增加节点数量,还是定义在负载增加时动态增加集群大小的自动缩放行为。图 6.6展示了它应该看起来是什么样子。
![计算机屏幕截图 自动生成的描述]()
图 6.6:集群配置和缩放策略选择。在这里,我们选择了特定的小集群大小的默认设置,但您可以增加这些值以获得更大的集群,或者使用自动缩放选项来提供最小和最大大小限制。
-
现在,有一个网络部分,如果你已经为书中的其他示例创建了一些虚拟专用网络(VPC)和子网,这将更容易;参见第五章,部署模式和工具以及 AWS 文档以获取更多信息。只需记住,VPCs 主要是关于将你正在配置的基础设施与其他 AWS 账户中的服务以及更广泛的互联网隔离开来,因此熟悉它们及其应用绝对是件好事。
-
为了完整性,图 6.7显示了我在这个示例中使用的设置。
![计算机截图 自动生成描述]()
图 6.7:网络配置需要使用 VPC;如果没有选择,它将自动为集群创建一个子网。
-
我们只需要输入几个更多部分来定义我们的集群。下一个强制性的部分是关于集群终止策略。我总是建议在可能的情况下为基础设施设置自动拆解策略,因为这有助于管理成本。整个行业都有很多关于团队留下未使用的服务器运行并产生巨额账单的故事!图 6.8显示,我们正在使用这样的自动集群终止策略,其中集群将在 1 小时未被使用后终止。
![计算机截图 自动生成描述]()
图 6.8:定义一个类似于这样的集群终止策略被认为是最佳实践,并且可以帮助避免不必要的成本。
-
完成所需的最后一个部分是定义适当的身份和访问管理(IAM)角色,它定义了哪些账户可以访问我们正在创建的资源。如果你已经有了一些你愿意作为 EMR 服务角色重用的 IAM 角色,那么你可以这样做;然而,对于这个示例,让我们为这个集群创建一个新的服务角色。图 6.9显示,选择创建新角色的选项会预先填充 VPC、子网和安全组,其值与通过此过程已选择的值相匹配。你可以添加更多内容。图 6.10显示,我们还可以选择创建一个“实例配置文件”,这只是一个在启动时应用于 EC2 集群中所有服务器实例的服务角色的名称。
![计算机截图 自动生成描述]()
图 6.9:创建 AWS EMR 服务角色。
![计算机截图 自动生成描述]()
图 6.10:为在此 EMR 集群中使用的 EC2 服务器创建实例配置文件。实例配置文件只是分配给所有集群 EC2 实例在启动时的服务角色的名称。
-
讨论的部分都是创建您的集群所必需的章节,但也有一些可选章节,我想简要提及以指导您进一步探索。这里有指定步骤的选项,您可以在其中定义要按顺序运行的 shell 脚本、JAR 应用程序或 Spark 应用程序。这意味着您可以在基础设施部署后提交作业之前,启动集群并准备好应用程序以按您希望的顺序处理数据。还有一个关于引导操作的章节,它允许您定义在安装任何应用程序或处理 EMR 集群上的任何数据之前应运行的定制安装或配置步骤。集群日志位置、标签和一些基本软件考虑因素也适用于配置。最后要提到的重要一点是安全配置。图 6.11显示了选项。尽管我们将不指定任何 EC2 密钥对或安全配置来部署此集群,但如果您想在生产中运行此集群,了解您组织的网络安全要求和规范至关重要。请咨询您的安全或网络团队以确保一切符合预期和要求。目前,我们可以将其留空,然后继续创建集群。
![计算机屏幕截图 自动生成描述]()
图 6.11:此处显示的集群安全配置是可选的,但如果您打算在生产中运行集群,应仔细考虑。
-
现在我们已经选择了所有必需的选项,点击创建集群按钮以启动。创建成功后,您应该会看到一个类似于图 6.12所示的审查页面。就这样;现在您已经在云中创建了自己的 Spark 集群了!!
![计算机屏幕截图 自动生成描述]()
图 6.12:成功启动后显示的 EMR 集群创建审查页面。
在启动我们的 EMR 集群后,我们希望能够向其提交工作。在这里,我们将调整我们在第三章,从模型到模型工厂中生产的示例 Spark ML 管道,以分析银行数据集,并将其作为步骤提交到我们新创建的集群。我们将这样做为一个独立的单个 PySpark 脚本,作为我们应用程序的唯一步骤,但很容易在此基础上构建更复杂的应用程序:
-
首先,我们将从第三章,从模型到模型工厂中提取代码,并根据我们围绕良好实践的讨论进行一些精心的重构。我们可以更有效地模块化代码,使其包含一个提供所有建模步骤的功能(为了简洁,并非所有步骤都在此处重现)。我们还包括了一个最终步骤,将建模结果写入
parquet文件:def model_bank_data(spark, input_path, output_path): data = spark.read.format("csv")\ .option("sep", ";")\ .option("inferSchema", "true")\ .option("header", "true")\ .load(input_path) data = data.withColumn('label', f.when((f.col("y") == "yes"), 1).otherwise(0)) # ... data.write.format('parquet')\ .mode('overwrite')\ .save(output_path) -
在此基础上,我们将所有主要样板代码封装到一个名为
main的函数中,该函数可以在程序的if __name__=="__main__":入口点被调用:def main(): parser = argparse.ArgumentParser() parser.add_argument( '--input_path', help='S3 bucket path for the input data. Assume to be csv for this case.' ) parser.add_argument( '--output_path', help='S3 bucket path for the output data. Assume to be parquet for this case' ) args = parser.parse_args() # Create spark context sc = SparkContext("local", "pipelines") # Get spark session spark = SparkSession\ .builder\ .appName('MLEWP Bank Data Classifier EMR Example')\ .getOrCreate() model_bank_data( spark, input_path=args.input_path,, output_path=args.output_path ) -
我们将前面的函数放入一个名为
spark_example_emr.py的脚本中,稍后我们将将其提交到我们的 EMR 集群:import argparse from pyspark.sql import SparkSession from pyspark import SparkContext from pyspark.sql import functions as f from pyspark.mllib.evaluation import BinaryClassificationMetrics, MulticlassMetrics from pyspark.ml.feature import StandardScaler, OneHotEncoder, StringIndexer, Imputer, VectorAssembler from pyspark.ml import Pipeline, PipelineModel from pyspark.ml.classification import LogisticRegression def model_bank_data(spark, input_path, output_path): ... def main(): ... if __name__ == "__main__": main() -
现在,为了将此脚本提交到我们刚刚创建的 EMR 集群,我们需要找到集群 ID,我们可以从 AWS UI 或通过运行以下命令来获取:
aws emr list-clusters --cluster-states WAITING -
然后,我们需要将
spark_example_emr.py脚本发送到 S3,以便集群读取。我们可以创建一个名为s3://mlewp-ch6-emr-examples的 S3 存储桶来存储这个和其他工件,无论是使用 CLI 还是 AWS 控制台(参见第五章,部署模式和工具)。一旦复制完成,我们就为最后一步做好了准备。 -
现在,我们必须使用以下命令提交脚本,用我们刚刚创建的集群 ID 替换
<CLUSTER_ID>。请注意,如果你的集群由于我们设置的自动终止策略而终止,你无法重新启动它,但你可以克隆它。几分钟后,步骤应该已经完成,输出应该已经写入同一 S3 存储桶中的results.parquet文件:aws emr add-steps\ --region eu-west-1 \ --cluster-id <CLUSTER_ID> \ --steps Type=Spark,Name="Spark Application Step",ActionOnFailure=CONTINUE,\ Args=[--files,s3://mlewp-ch6-emr-examples/spark_example_emr.py,\ --input_path,s3://mlewp-ch6-emr-examples/bank.csv,\ --output_path,s3://mleip-emr-ml-simple/results.parquet]就这样——这就是我们如何在云上使用AWS EMR开始开发 PySpark ML 管道的方法!
你会发现,通过导航到适当的 S3 存储桶并确认results.parquet文件已成功创建,这个先前的过程已经成功;参见图 6.13。

图 6.13:提交 EMR 脚本后成功创建 results.parquet 文件。
在下一节中,我们将探讨使用所谓的无服务器工具来扩展我们解决方案的另一种方法。
启动无服务器基础设施
无论何时我们进行机器学习或软件工程,都必须在计算机上运行必要的任务和计算,通常伴随着适当的网络、安全和其它协议及软件,这些我们通常称之为构成我们的基础设施。我们基础设施的一个大组成部分是我们用来运行实际计算的服务器。这可能会显得有些奇怪,所以让我们先从无服务器基础设施(这怎么可能存在呢?)开始谈。本节将解释这个概念,并展示如何使用它来扩展你的机器学习解决方案。
无服务器作为一个术语有点误导,因为它并不意味着没有物理服务器在运行你的程序。然而,它确实意味着你正在运行的程序不应被视为静态托管在一台机器上,而应被视为在底层硬件之上的另一层上的短暂实例。
无服务器工具对你的机器学习解决方案的好处包括(但不限于)以下内容:
-
无服务器:不要低估通过将基础设施管理外包给云服务提供商所能节省的时间和精力。
-
简化扩展:通常,通过使用明确定义的最大实例等,很容易定义您无服务器组件的扩展行为。
-
低门槛:这些组件通常设置和运行起来非常简单,让您和您的团队成员能够专注于编写高质量的代码、逻辑和模型。
-
自然集成点:无服务器工具通常非常适合在与其他工具和组件之间进行交接。它们的易于设置意味着您可以在极短的时间内启动简单的作业,这些作业可以传递数据或触发其他服务。
-
简化服务:一些无服务器工具非常适合为您的机器学习模型提供服务层。之前提到的可扩展性和低门槛意味着您可以快速创建一个非常可扩展的服务,该服务可以根据请求或由其他事件触发提供预测。
无服务器功能中最好和最广泛使用的例子之一是 AWS Lambda,它允许我们通过简单的网页界面或通过我们常用的开发工具用各种语言编写程序,然后让它们在完全独立于任何已设置的基础设施的情况下运行。
Lambda 是一个惊人的低门槛解决方案,可以快速将一些代码部署并扩展。然而,它主要针对创建可以通过 HTTP 请求触发的简单 API。如果您旨在构建事件或请求驱动的系统,使用 Lambda 部署您的机器学习模型特别有用。
要看到这个功能在实际中的运用,让我们构建一个基本的系统,该系统接受带有 JSON 体的 HTTP 请求作为输入图像数据,并使用预构建的 Scikit-Learn 模型返回包含数据分类的类似消息。这个教程基于 AWS 的示例,请参阅aws.amazon.com/blogs/compute/deploying-machine-learning-models-with-serverless-templates/。
对于这个,我们可以通过利用作为 AWS 无服务器应用程序模型(SAM)框架的一部分已经构建和维护的模板来节省大量时间(aws.amazon.com/about-aws/whats-new/2021/06/aws-sam-launches-machine-learning-inference-templates-for-aws-lambda/)。
要在您的相关平台上安装 AWS SAM CLI,请遵循docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.xhtml中的说明。
现在,让我们执行以下步骤来设置一个模板无服务器部署,用于托管和提供用于对手写数字图像进行分类的机器学习模型:
-
首先,我们必须运行
sam init命令并选择 AWS 的Quick Start Templates选项:Which template source would you like to use? 1 - AWS Quick Start Templates 2 - Custom Template Location Choice: 1 -
然后,你将获得选择使用
AWS Quick Start应用程序模板的机会;选择选项 15,Machine Learning:Choose an AWS Quick Start application template 1 - Hello World Example 2 - Data processing 3 - Hello World Example with Powertools for AWS Lambda 4 - Multi-step workflow 5 - Scheduled task 6 - Standalone function 7 - Serverless API 8 - Infrastructure event management 9 - Lambda Response Streaming 10 - Serverless Connector Hello World Example 11 - Multi-step workflow with Connectors 12 - Full Stack 13 - Lambda EFS example 14 - DynamoDB Example 15 - Machine Learning Template: -
接下来是你要使用的 Python 运行时的选项;与本书的其他部分一致,我们将使用 Python 3.10 运行时:
Which runtime would you like to use? 1 - python3.9 2 - python3.8 3 - python3.10 Runtime: -
在撰写本文时,SAM CLI 将根据这些选择自动选择一些选项,首先是包类型,然后是依赖管理器。然后,你将被要求确认你想要使用的 ML 起始模板。对于这个示例,选择
XGBoost Machine Learning API:Based on your selections, the only Package type available is Image. We will proceed to selecting the Package type as Image. Based on your selections, the only dependency manager available is pip. We will proceed copying the template using pip. Select your starter template 1 - PyTorch Machine Learning Inference API 2 - Scikit-learn Machine Learning Inference API 3 - Tensorflow Machine Learning Inference API 4 - XGBoost Machine Learning Inference API Template: 4 -
SAM CLI 随后会友好地询问一些配置请求跟踪和监控的选项;你可以根据自己的喜好选择是或否。在这个示例中,我选择了否。
Would you like to enable X-Ray tracing on the function(s) in your application? [y/N]: N Would you like to enable monitoring using CloudWatch Application Insights? For more info, please view: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.xhtml [y/N]: N Project name [sam-app]: mlewp-sam-ml-api Cloning from https://github.com/aws/aws-sam-cli-app-templates (process may take a moment) -
最后,你的命令行将提供一些有关安装和下一步操作的有用信息:
----------------------- Generating application: ----------------------- Name: mlewp-sam-ml-api Base Image: amazon/python3.10-base Architectures: x86_64 Dependency Manager: pip Output Directory: . Configuration file: mlewp-sam-ml-api/samconfig.toml Next steps can be found in the README file at mlewp-sam-ml-api/README.md Commands you can use next ========================= [*] Create pipeline: cd mlewp-sam-ml-api && sam pipeline init --bootstrap [*] Validate SAM template: cd mlewp-sam-ml-api && sam validate [*] Test Function in the Cloud: cd mlewp-sam-ml-api && sam sync --stack-name {stack-name} --watch
注意,前面的步骤创建了一个基于 XGBoost 的系统模板,用于对手写数字进行分类。对于其他应用程序和项目用例,你需要根据需要调整模板的源代码。如果你想部署这个示例,请按照以下步骤操作:
-
首先,我们必须构建模板中提供的应用程序容器。首先,导航到你的项目的顶级目录,你应该能看到目录结构应该是这样的。我使用了
tree命令在命令行中提供一个干净的目录结构概览:cd mlewp-sam-ml-api ls tree ├── README.md ├── __init__.py ├── app │ ├── Dockerfile │ ├── __init__.py │ ├── app.py │ ├── model │ └── requirements.txt ├── events │ └── event.json ├── samconfig.toml └── template.yaml 3 directories, 10 files -
现在我们处于顶级目录,我们可以运行
build命令。这要求你的机器在后台运行 Docker:sam build -
在成功构建后,你应该在你的终端收到类似以下的成功消息:
Build Succeeded Built Artifacts : .aws-sam/build Built Template : .aws-sam/build/template.yaml Commands you can use next ========================= [*] Validate SAM template: sam validate [*] Invoke Function: sam local invoke [*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch [*] Deploy: sam deploy --guided -
现在,我们可以在本地测试该服务,以确保一切都能与存储库中提供的模拟数据良好地工作。这使用了一个编码基本图像的 JSON 文件,并运行服务的推理步骤。如果一切顺利,你将看到类似以下输出的服务:
sam local invoke --event events/event.json Invoking Container created from inferencefunction:python3.10-v1 Building image................. Using local image: inferencefunction:rapid-x86_64. START RequestId: de4a2fe1-be86-40b7-a59d-151aac19c1f0 Version: $LATEST END RequestId: de4a2fe1-be86-40b7-a59d-151aac19c1f0 REPORT RequestId: de4a2fe1-be86-40b7-a59d-151aac19c1f0 Init Duration: 1.30 ms Duration: 1662.13 ms Billed Duration: 1663 ms Memory Size: 5000 MB Max Memory Used: 5000 MB {"statusCode": 200, "body": "{\"predicted_label\": 3}"}% -
在实际项目中,你需要在部署到云之前,编辑解决方案的
app.py和其他所需文件。我们将使用 SAM CLI 来完成这项工作,理解如果你想要自动化这个过程,你可以使用本书中讨论的 CI/CD 流程和工具,特别是在第四章,打包部分。要部署,你可以通过运行deploy命令来使用 CLI 的引导部署向导,这将返回以下输出:sam deploy --guided Configuring SAM deploy ====================== Looking for config file [samconfig.toml] : Found Reading default arguments : Success Setting default arguments for 'sam deploy' ========================================= Stack Name [mlewp-sam-ml-api]: -
然后,我们必须为提供的每个元素配置应用程序。在大多数情况下,我选择了默认设置,但你也可以参考 AWS 文档,并根据你的项目做出最相关的选择:
Configuring SAM deploy ====================== Looking for config file [samconfig.toml] : Found Reading default arguments : Success Setting default arguments for 'sam deploy' ========================================= Stack Name [mlewp-sam-ml-api]: AWS Region [eu-west-2]: #Shows you resources changes to be deployed and require a 'Y' to initiate deploy Confirm changes before deploy [Y/n]: y #SAM needs permission to be able to create roles to connect to the resources in your template Allow SAM CLI IAM role creation [Y/n]: y #Preserves the state of previously provisioned resources when an operation fails Disable rollback [y/N]: y InferenceFunction has no authentication. Is this okay? [y/N]: y Save arguments to configuration file [Y/n]: y SAM configuration file [samconfig.toml]: SAM configuration environment [default]: -
上一个步骤将在终端生成大量数据;你可以监控这些数据以查看是否有任何错误或问题。如果部署成功,那么你应该会看到一些关于应用程序的最终元数据,如下所示:
CloudFormation outputs from deployed stack --------------------------------------------------------------------------------------------------------------------- Outputs --------------------------------------------------------------------------------------------------------------------- Key InferenceApi Description API Gateway endpoint URL for Prod stage for Inference function Value https://8qg87m9380.execute-api.eu-west-2. amazonaws.com/Prod/classify_digit/ Key InferenceFunctionIamRole Description Implicit IAM Role created for Inference function Value arn:aws:iam::508972911348:role/mlewp-sam-ml-api-InferenceFunctionRole-1UE509ZXC1274 Key InferenceFunction Description Inference Lambda Function ARN Value arn:aws:lambda:eu-west-2:508972911348:function:mlewp-sam-ml-api-InferenceFunction-ueFS1y2mu6Gz --------------------------------------------------------------------------------------------------------------------- -
为了快速测试确认云托管解决方案是否正常工作,我们可以使用 Postman 等工具来调用我们闪亮的新 ML API。只需将步骤 8输出屏幕中的
InferenceApiURL 复制为请求的目的地,选择POST作为请求类型,然后选择二进制作为主体类型。注意,如果你需要获取推理 URL,你还可以在终端中运行sam list endpoints --output json命令。然后,你可以选择一个手写数字的图像,或者任何其他图像,发送到 API。你可以在 Postman 中通过选择二进制主体选项并附加图像文件,或者复制图像的编码字符串。在图 6.14中,我使用了events/event.json文件中body键值对的编码字符串,这是我们用来本地测试函数的:![计算机屏幕截图 自动生成的描述]()
图 6.14:使用 Postman 调用我们的无服务器 ML 端点。这使用了一个编码的示例图像作为请求的主体,该请求与 SAM XGBoost ML API 模板一起提供。
-
你也可以使用以下
curl命令以更程序化的方式测试这个服务——只需将图像的编码二进制字符串替换为适当的值,或者实际上编辑命令以指向数据二进制文件,如果你愿意,就可以开始了:curl --location --request POST 'https://8qg87m9380.execute-api.eu-west-2.amazonaws.com/Prod/classify_digit/' \ --header 'Content-Type: raw/json' \ --data '<ENCODED_IMAGE_STRING>'在这个步骤和步骤 9中,Lambda 函数的响应主体如下:
{ "predicted_label": 3 }就这样——我们已经在 AWS 上构建和部署了一个简单的无服务器 ML 推理服务!
在下一节中,我们将简要介绍本章中将要讨论的最终扩展解决方案,即使用 Kubernetes(K8s)和 Kubeflow 来水平扩展容器化应用程序。
使用 Kubernetes 进行大规模容器化
我们已经介绍了如何使用容器来构建和部署我们的 ML 解决方案。下一步是了解如何编排和管理多个容器以大规模部署和运行应用程序。这就是开源工具Kubernetes(K8s)发挥作用的地方。
K8s 是一个非常强大的工具,它提供了各种不同的功能,帮助我们创建和管理非常可扩展的容器化应用程序,包括但不限于以下内容:
-
负载均衡:K8s 将为你管理路由到你的容器的入站流量,以确保负载均匀分配。
-
水平扩展:K8s 提供了简单的接口,让你可以控制任何时刻拥有的容器实例数量,如果需要,可以大规模扩展。
-
自我修复:有内置的管理来替换或重新安排未通过健康检查的组件。
-
自动回滚:K8s 存储了你的系统历史,以便在出现问题时可以回滚到先前的有效版本。
所有这些功能都有助于确保你的部署解决方案是健壮的,并且能够在所有情况下按要求执行。
K8s 的设计是通过使用微服务架构,并使用控制平面与节点(服务器)交互,每个节点都托管运行应用程序组件的 pods(一个或多个容器)来确保上述功能从底层开始嵌入。
K8s 提供的关键功能是,通过创建基础解决方案的副本来根据负载扩展应用程序。如果你正在构建具有 API 端点的服务,这些端点在不同时间可能会面临需求激增,这将非常有用。了解你可以这样做的一些方法,请参阅kubernetes.io/docs/concepts/workloads/controllers/deployment/#scaling-a-deployment:

图 6.15:K8s 架构。
但关于机器学习(ML)呢?在这种情况下,我们可以看看 K8s 生态系统中的新成员:Kubeflow,我们在第五章“部署模式和工具”中学习了如何使用它。
Kubeflow 将自己定位为K8s 的 ML 工具包(www.kubeflow.org/),因此作为机器学习工程师,了解这个快速发展的解决方案是有意义的。这是一个非常激动人心的工具,也是一个活跃的开发领域。
对于 K8s 的水平扩展概念通常仍然适用,但 Kubeflow 提供了一些标准化的工具,可以将你构建的流水线转换为标准的 K8s 资源,然后可以按照之前描述的方式管理和分配资源。这有助于减少模板代码,并让我们作为机器学习工程师专注于构建我们的建模逻辑,而不是设置基础设施。我们在第五章构建示例流水线时利用了这一点。
我们将在第八章“构建示例 ML 微服务”中更详细地探讨 Kubernetes,我们将使用它来扩展我们自己的封装 ML 模型在 REST API 中。这将很好地补充本章中关于可以用于扩展的高级抽象的工作,特别是在“启动无服务器基础设施”部分。我们在这里只会简要提及 K8s 和 Kubeflow,以确保你了解这些工具以供探索。有关 K8s 和 Kubeflow 的更多详细信息,请参阅文档。我还推荐另一本 Packt 出版的书籍,名为Aly Saleh和Murat Karslioglu的《Kubernetes in Production Best Practices》。
现在,我们将继续讨论另一个非常强大的用于扩展计算密集型 Python 工作负载的工具包,它现在在机器学习工程社区中变得极为流行,并被 Uber、Amazon 等组织以及 OpenAI 用于训练其大型语言生成预训练变换器(GPT)模型,我们将在第七章深度学习、生成式 AI 和 LLMOps中详细讨论。让我们来认识Ray。
使用 Ray 进行扩展
Ray 是一个专为帮助机器学习工程师满足大规模数据和大规模可扩展机器学习系统需求而设计的 Python 原生分布式计算框架。Ray 有一个使可扩展计算对每个机器学习开发者都可用,并且以抽象出与底层基础设施的所有交互的方式来运行在任何地方的理念。Ray 的独特特性之一是它有一个分布式调度器,而不是像 Spark 那样在中央进程中运行的调度器或 DAG 创建机制。从其核心来看,Ray 从一开始就考虑了计算密集型任务,如机器学习模型训练,这与以数据密集型为目标的 Apache Spark 略有不同。因此,你可以这样简单地思考:如果你需要多次处理大量数据,那么选择 Spark;如果你需要多次处理同一份数据,那么 Ray 可能更合适。这只是一个经验法则,不应严格遵循,但希望它能给你一个有用的指导原则。例如,如果你需要在大型批量处理中转换数百万行数据,那么使用 Spark 是有意义的,但如果你想在同一数据上训练机器学习模型,包括超参数调整,那么 Ray 可能更有意义。
这两个工具可以非常有效地一起使用,Spark 在将特征集转换后,将其输入到用于机器学习训练的 Ray 工作负载中。这特别由Ray AI Runtime(AIR)负责,它提供了一系列不同的库来帮助扩展机器学习解决方案的不同部分。这些包括:
-
Ray Data: 专注于提供数据预处理和转换原语。
-
Ray Train: 促进大型模型训练。
-
Ray Tune: 帮助进行可扩展的超参数训练。
-
Ray RLib: 支持强化学习模型开发的方法。
-
Ray Batch Predictor: 用于批量推理。
-
Ray Serving: 用于实时推理。
AIR 框架提供了一个统一的 API,通过它可以与所有这些功能进行交互,并且很好地集成了你将习惯使用的以及我们在本书中利用的大量标准机器学习生态系统。

图 6.16:来自 Anyscale 的 Jules Damji 的演示中的 Ray AI 运行时,来自:https://microsites.databricks.com/sites/default/files/2022-07/Scaling AI Workloads with the Ray Ecosystem.pdf。经许可复制。

图 6.17:包括 Raylet 调度器的 Ray 架构。来自 Jules Damji 的演示:https://microsites.databricks.com/sites/default/files/2022-07/Scaling AI Workloads with the Ray Ecosystem.pdf。经许可复制。
Ray 核心 API 有一系列不同的对象,当你在使用 Ray 时可以利用这些对象来分发你的解决方案。首先是任务,这是系统要执行的工作的异步项。为了定义一个任务,你可以使用一个 Python 函数,例如:
def add(int: x, int: y) -> int:
return x+y
然后添加@remote装饰器,然后使用.remote()语法来将此任务提交到集群。这不是一个阻塞函数,所以它将只返回一个 ID,Ray 将使用该 ID 在后续的计算步骤中引用任务(www.youtube.com/live/XME90SGL6Vs?feature=share&t=832):
import ray
@remote
def add(int: x, int: y) -> int:
return x+y
add.remote()
同样,Ray API 可以将相同的概念扩展到类中;在这种情况下,这些被称为Actors:
import ray
@ray.remote
class Counter(object):
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
return self.value
def get_counter(self):
return self.value
# Create an actor from this class.
counter = Counter.remote()
最后,Ray 还有一个分布式不可变对象存储。这是一种智能的方法,可以在集群的所有节点之间共享一个数据存储,而不需要移动大量数据并消耗带宽。你可以使用以下语法向对象存储写入:
import ray
numerical_array = np.arange(1,10e7)
obj_numerical_array = ray.put(numerical_array)
new_numerical_array = 0.5*ray.get(obj_numerical_array)
重要提示
在这个语境中,一个 Actor 是一个服务或具有状态的工作者,这是一个在其他分布式框架(如 Akka)中使用的概念,它运行在 JVM 上,并且有 Java 和 Scala 的绑定。
Ray 用于机器学习的入门
要开始,你可以通过运行以下命令安装带有 AI 运行时的 Ray,以及一些超参数优化包、中央仪表板和 Ray 增强的 XGBoost 实现:
pip install "ray[air, tune, dashboard]"
pip install xgboost
pip install xgboost_ray
重要提示
这里提醒一下,当你在这本书中看到pip install时,你还可以使用在第四章“打包”中概述的 Poetry。因此,在这种情况下,在运行poetry new project_name:之后,你会得到以下命令:
poetry add "ray[air, tune, dashboard]"
poetry add xgboost
poetry add pytorch
让我们从查看 Ray Train 开始,它提供了一系列Trainer对象的 API,有助于简化分布式训练。在撰写本文时,Ray 2.3.0 支持跨各种不同框架的培训师,包括:
-
深度学习:Horovod、Tensorflow 和 PyTorch。
-
基于树的:LightGBM 和 XGBoost。
-
其他:Scikit-learn、HuggingFace 和 Ray 的强化学习库 RLlib。

图 6.18:如 Ray 文档中所示(https://docs.ray.io/en/latest/train/train.xhtml)的 Ray 训练器。
我们首先将查看一个基于树的 XGBoost 学习器示例。打开一个脚本并开始向其中添加内容;在仓库中,这个脚本被称为getting_started_with_ray.py。以下内容基于 Ray 文档中给出的一个入门示例。首先,我们可以使用 Ray 下载标准数据集之一;如果我们想的话,我们也可以使用sklearn.datasets或其他来源,就像我们在本书的其他地方所做的那样:
import ray
dataset = ray.data.read_csv("s3://anonymous@air-example-data/breast_
cancer.csv")
train_dataset, valid_dataset = dataset.train_test_split(test_size=0.3)
test_dataset = valid_dataset.drop_columns(cols=["target"])
注意,在这里我们使用ray.data.read_csv()方法,该方法返回一个PyArrow数据集。Ray API 有从其他数据格式读取的方法,例如 JSON 或 Parquet,以及从数据库如 MongoDB 或您自己的自定义数据源读取。
接下来,我们将定义一个预处理步骤,该步骤将标准化我们想要使用的特征;有关特征工程的信息,您可以查看第三章,从模型到模型工厂:
from ray.data.preprocessors import StandardScaler
preprocessor = StandardScaler(columns=["mean radius", "mean texture"])
然后是定义 XGBoost 模型的Trainer对象的有趣部分。这有几个不同的参数和输入,我们将在稍后定义:
from ray.air.config import ScalingConfig
from ray.train.xgboost import XGBoostTrainer
trainer = XGBoostTrainer(
scaling_config=ScalingConfig(...),
label_column="target",
num_boost_round=20,
params={...},
datasets={"train": train_dataset, "valid": valid_dataset},
preprocessor=preprocessor,
)
result = trainer.fit()
如果您在 Jupyter 笔记本或 Python 脚本中运行此代码,您将看到类似于图 6.19所示的输出。

图 6.19:使用 Ray 并行训练 XGBoost 模型的输出。
result对象包含大量有用的信息;它的一个属性称为metrics,您可以打印出来以揭示运行结束状态的相关细节。执行print(result.metrics),您将看到如下内容:
{'train-logloss': 0.01849572773292735,
'train-error': 0.0, 'valid-logloss': 0.089797893552767,
'valid-error': 0.04117647058823529,
'time_this_iter_s': 0.019704103469848633,
'should_checkpoint': True,
'done': True,
'timesteps_total': None,
'episodes_total': None,
'training_iteration': 21,
'trial_id': '6ecab_00000',
'experiment_id': '2df66fa1a6b14717bed8b31470d386d4',
'date': '2023-03-14_20-33-17',
'timestamp': 1678825997,
'time_total_s': 6.222438812255859,
'pid': 1713,
'hostname': 'Andrews-MacBook-Pro.local',
'node_ip': '127.0.0.1',
'config': {},
'time_since_restore': 6.222438812255859,
'timesteps_since_restore': 0,
'iterations_since_restore': 21,
'warmup_time': 0.003551006317138672, 'experiment_tag': '0'}
在XGBoostTrainer的实例化中,我们定义了一些在先前的示例中省略的重要缩放信息;如下所示:
scaling_config=ScalingConfig(
num_workers=2,
use_gpu=False,
_max_cpu_fraction_per_node=0.9,
)
num_workers参数告诉 Ray 启动多少个 actor,默认情况下每个 actor 分配一个 CPU。由于我们在这里没有使用 GPU 加速,所以将use_gpu标志设置为 false。最后,通过将_max_cpu_fraction_per_node参数设置为0.9,我们在每个 CPU 上留下了一些备用容量,这些容量可以用于其他操作。
在上一个示例中,我们还提供了一些 XGBoost 特定的参数:
params={
"objective": "binary:logistic",
"eval_metric": ["logloss", "error"],
}
如果您想为 XGBoost 训练使用 GPU 加速,您可以在params字典中添加一个键值对tree_method: gpu_hist。

图 6.20:几个实验展示了在作者的笔记本电脑(一台 8 核心的 Macbook Pro)上,改变每个 worker 可用的 worker 数量和 CPU 数量如何导致 XGBoost 训练时间不同。
现在,我们将简要讨论如何在除本地机器以外的环境中使用 Ray 扩展计算。
为 Ray 扩展计算能力
我们迄今为止看到的示例使用的是本地 Ray 集群,该集群在第一次调用 Ray API 时自动设置。这个本地集群抓取您机器上所有可用的 CPU 并使其可用于执行工作。显然,这只能让您走这么远。下一个阶段是与可以扩展到更多可用工作者的集群一起工作,以获得更多的加速。如果您想这样做,您有几个选择:
-
在云上:Ray 提供了部署到 Google Cloud Platform 和 AWS 资源的能力,Azure 部署由社区维护的解决方案处理。有关在 AWS 上部署和运行 Ray 的更多信息,您可以查看其在线文档。
-
使用 Kubernetes:我们在 第五章,部署模式和工具 中已经遇到了 Kubeflow,它用于构建支持 Kubernetes 的 ML 管道。在本章的“在规模上容器化 Kubernetes”部分中,我们也讨论了 Kubernetes。如前所述,Kubernetes 是一个容器编排工具包,旨在基于容器创建可大规模扩展的解决方案。如果您想在 Kubernetes 上使用 Ray,可以使用 KubeRay 项目,
ray-project.github.io/kuberay/。
在云或 Kubernetes 上设置 Ray 主要涉及定义集群配置及其扩展行为。一旦完成这些操作,Ray 的美妙之处在于扩展您的解决方案就像编辑我们在上一个示例中使用的 ScalingConfig 对象一样简单,并且您可以保持所有其他代码不变。例如,如果您有一个 20 节点的 CPU 集群,您可以简单地将其定义更改为以下内容,并像以前一样运行:
scaling_config=ScalingConfig(
num_workers=20,
use_gpu=False,
_max_cpu_fraction_per_node=0.9,
)
使用 Ray 扩展您的服务层
我们已经讨论了您可以使用 Ray 来使用分布式 ML 训练作业的方法,但现在让我们看看您如何使用 Ray 来帮助您扩展应用程序层。如前所述,Ray AIR 提供了一些在 Ray Serve 下的良好功能。
Ray Serve 是一个框架无关的库,它帮助您轻松地根据您的模型定义 ML 端点。就像我们与之交互的 Ray API 的其余部分一样,它被构建为提供易于互操作性和访问扩展,而无需大量的开发开销。
基于前几节提供的示例,让我们假设我们已经训练了一个模型,并将其存储在我们的适当注册表中,例如 MLflow,并且我们已经检索了这个模型并将其保存在内存中。
在 Ray Serve 中,我们通过使用 @ray.serve.deployments 装饰器来创建部署。这些部署包含我们希望用于处理传入 API 请求的逻辑,包括通过我们构建的任何机器学习模型。例如,让我们构建一个简单的包装类,它使用与上一个示例中我们使用的类似的 XGBoost 模型,根据通过请求对象传入的一些预处理特征数据来进行预测。首先,Ray 文档鼓励使用 Starlette 请求库:
from starlette.requests import Request
import ray
from ray import serve
接下来,我们可以定义一个简单的类,并使用 serve 装饰器来定义服务。我将假设从 MLflow 或任何其他模型存储位置提取的逻辑被封装在以下代码块中的实用函数 get_model 中:
@serve.deployment
class Classifier:
def __init__(self):
self.model = get_model()
async def __call__(self, http_request: Request) -> str:
request_payload = await http_request.json()
input_vector = [
request_payload["mean_radius"],
request_payload["mean_texture"]
]
classification = self.model.predict([input_vector])[0]
return {"result": classification}
然后,您可以将此部署到现有的 Ray 集群中。
这就结束了我们对 Ray 的介绍。我们现在将结束于对设计大规模系统的最终讨论,然后是对我们所学到的一切的总结。
设计大规模系统
为了在第五章,“部署模式和工具”以及本章中提出的思想的基础上进行扩展,我们现在应该考虑一些方法,这些方法可以让我们在机器学习工程项目中最大限度地发挥我们讨论过的扩展能力。
扩展的整体思想应该从提供分析或推理吞吐量的增加或可以处理的数据的最终大小增加的角度来考虑。在大多数情况下,您可以开发的分析或解决方案的类型没有真正的区别。这意味着成功应用扩展工具和技术更多地取决于选择将从中受益的正确流程,即使包括使用这些工具带来的任何开销。这就是我们现在在本节中要讨论的,以便您在做出自己的扩展决策时有一些指导原则。
如本书中多处所述,您为机器学习项目开发的管道通常需要包含以下任务的一些阶段:
-
数据摄取/预处理
-
特征工程(如果与上述不同)
-
模型训练
-
模型推理
-
应用层
并行化或分布式处理可以在许多步骤中提供帮助,但通常以不同的方式。对于摄取/预处理,如果你在一个大型的预定批量设置中操作,那么以分布式方式扩展到更大的数据集将带来巨大的好处。在这种情况下,使用 Apache Spark 是有意义的。对于特征工程,同样,主要瓶颈在于我们执行转换时一次性处理大量数据,因此 Spark 对于这一点也是有用的。我们在第三章,“从模型到模型工厂”中详细讨论的训练机器学习模型的计算密集型步骤,非常适合用于这种密集计算的框架,无论数据大小如何。这就是 Ray 在前面章节中发挥作用的地方。Ray 意味着你也可以整洁地并行化你的超参数调整,如果你也需要这样做的话。请注意,你可以在 Spark 中运行这些步骤,但 Ray 的低任务开销和其分布式状态管理意味着它特别适合分割这些计算密集型任务。另一方面,Spark 具有集中的状态和调度管理。最后,当涉及到推理和应用层,即我们产生和展示机器学习模型结果的地方,我们需要考虑特定用例的需求。例如,如果你想将你的模型作为 REST API 端点提供服务,我们在上一节中展示了 Ray 的分布式模型和 API 如何帮助非常容易地实现这一点,但在 Spark 中这样做是没有意义的。然而,如果模型结果需要以大量批次的形式生成,那么 Spark 或 Ray 可能是合适的。此外,正如在特征工程和摄取步骤中提到的,如果最终结果也需要在大批量中进行转换,例如转换成特定的数据模型,如星型模式,那么由于这个任务的数据规模要求,在 Spark 中执行这种转换可能是合理的。
让我们通过考虑一个来自行业的潜在示例来使这个问题更加具体。许多具有零售元素的机构将分析交易和客户数据,以确定客户是否可能流失。让我们探讨一些我们可以做出的决策,以设计和开发这个解决方案,特别关注使用我们在本章中介绍的工具和技术进行扩展的问题。
首先,我们有数据摄取。对于这种情况,我们将假设客户数据,包括与不同应用程序和系统的交互,在业务日结束时处理,数量达到数百万条记录。这些数据包含数值和分类值,并且需要经过处理才能输入到下游机器学习算法中。如果数据按日期分区,或者数据的一些其他特征,那么这非常自然地适用于 Spark 的使用,因为你可以将其读入 Spark DataFrame,并使用分区来并行化数据处理步骤。
接下来,我们讨论特征工程。如果在第一步中使用 Spark DataFrame,那么我们可以使用本章前面讨论的基础 PySpark 语法来应用我们的转换逻辑。例如,如果我们想应用来自 Scikit-Learn 或其他机器学习库的一些特征转换,我们可以将这些转换封装在 UDFs 中,并在所需的规模上应用。然后,我们可以使用 PySpark API 将数据导出为我们选择的数据格式。对于客户流失模型,这可能意味着对分类变量的编码和对数值变量的缩放,这与在 第三章,从模型到模型工厂 中探讨的技术一致。
转向模型的训练,我们现在正从数据密集型任务转向计算密集型任务。这意味着自然地开始使用 Ray 进行模型训练,因为你可以轻松设置并行任务来训练具有不同超参数设置的模型,并分配训练步骤。使用 Ray 进行深度学习或基于树的模型训练有特定的好处,因为这些算法易于并行化。所以,如果我们使用 Spark ML 中可用的模型之一进行分类,这可以在几行代码内完成,但如果我们使用其他东西,我们可能需要开始封装 UDFs。Ray 对库的依赖性更少,但再次,真正的好处来自于我们使用 PyTorch 或 TensorFlow 中的神经网络,或者使用 XGBoost 或 LightGBM,因为这些更自然地并行化。
最后,我们来看模型推理步骤。在批量设置中,关于这里建议的框架,谁是赢家并不那么明确。使用 UDFs 或 PySpark 核心 API,你可以轻松地使用 Apache Spark 和你的 Spark 集群创建一个相当可扩展的批量预测阶段。这主要是因为在大批量上的预测实际上只是另一种大规模数据转换,而 Spark 在这方面表现卓越。然而,如果你希望将你的模型作为一个可以跨集群扩展的端点提供服务,那么正如使用 Ray 扩展你的服务层部分所示,Ray 提供了非常易于使用的功能。Spark 没有创建这种端点的功能,并且启动 Spark 作业所需的调度和任务开销意味着,对于像这种作为请求传入的小数据包,运行 Spark 可能并不值得。
对于客户流失的例子,这可能意味着如果我们想在整个客户基础上进行流失分类,Spark 提供了一个很好的方式来处理所有这些数据,并利用像底层数据分区这样的概念。你仍然可以在 Ray 中这样做,但较低级别的 API 可能意味着这需要更多的工作。请注意,我们可以使用许多其他机制来创建这个服务层,如第五章、部署模式和工具以及本章中关于启动无服务器基础设施的部分所述。第八章、构建示例 ML 微服务也将详细说明如何使用 Kubernetes 扩展 ML 端点的部署。
最后,我将最后一个阶段称为应用层,以涵盖解决方案中输出系统与下游系统之间的任何“最后一公里”集成。在这种情况下,Spark 实际上并没有扮演什么角色,因为它实际上可以被视为一个大规模数据转换引擎。另一方面,Ray 则更侧重于通用的 Python 加速哲学,所以如果你的应用程序后端有任务可以从并行化中受益,比如数据检索、一般计算、模拟或其他一些过程,那么你仍然可以在某种程度上使用 Ray,尽管可能还有其他可用的工具。因此,在客户流失的例子中,Ray 可以用于在服务结果之前对单个客户进行分析,并在Ray Serve端点并行执行此操作。
通过这个高级示例,我们的目的是突出你在机器学习工程项目中可以做出关于如何有效扩展的选择的点。我常说,通常没有“正确答案”,但往往有很多“错误答案”。我的意思是,通常有几种构建良好解决方案的方法都是同样有效的,并且可能利用不同的工具。重要的是要避免最大的陷阱和死胡同。希望这个例子能给你一些关于如何将这种思考应用到扩展你的机器学习解决方案的启示。
重要提示
尽管我在这里提出了很多关于 Spark 与 Ray 的问题,并提到了 Kubernetes 作为更基础的扩展基础设施选项,但现在有了通过使用RayDP结合 Spark 和 Ray 的能力。这个工具包现在允许你在 Ray 集群上运行 Spark 作业,这样你就可以继续使用 Ray 作为你的基础扩展层,同时利用 Spark 的 API 和功能,这些功能是 Spark 擅长的。RayDP 于 2021 年推出,目前正在积极开发中,因此这绝对是一个值得关注的功能。更多信息,请参阅项目仓库:github.com/oap-project/raydp。
这就结束了我们对如何开始将我们讨论的一些扩展技术应用到我们的机器学习用例中的探讨。
现在我们将本章的内容以简要总结结束,总结我们在过去几页中涵盖的内容。
摘要
在本章中,我们探讨了如何将我们在过去几章中构建的机器学习解决方案进行扩展,以适应更大的数据量或更高的预测请求数量。为此,我们主要关注了Apache Spark,因为它是分布式计算中最受欢迎的通用引擎。在讨论 Apache Spark 的过程中,我们回顾了在此书中之前使用的一些编码模式和语法。通过这样做,我们更深入地理解了在 PySpark 开发中如何以及为什么进行某些操作。我们详细讨论了UDFs(用户定义函数)的概念,以及如何使用这些函数创建可大规模扩展的机器学习工作流程。
之后,我们探讨了如何在云上使用 Spark,特别是通过 AWS 提供的EMR服务。然后,我们查看了一些其他可以扩展我们解决方案的方法;即,通过无服务器架构和容器化的水平扩展。在前一种情况下,我们介绍了如何使用AWS Lambda构建一个用于服务机器学习模型的服务。这使用了 AWS SAM 框架提供的标准模板。我们提供了如何使用 K8s 和 Kubeflow 水平扩展机器学习管道的高级视图,以及使用这些工具的一些其他好处。随后,我们介绍了一个关于 Ray 并行计算框架的部分,展示了如何使用其相对简单的 API 在异构集群上扩展计算,以加速你的机器学习工作流程。Ray 现在是 Python 最重要的可扩展计算工具包之一,并被用于训练地球上的一些最大的模型,包括 OpenAI 的 GPT-4 模型。
在下一章中,我们将通过讨论你可以构建的最大机器学习模型来扩展这里的规模概念:深度学习模型,包括大型语言模型(LLMs)。我们将在下一章中讨论的所有内容,都只能通过考虑我们在这里介绍的技术来开发和有效利用。在第八章,构建一个示例机器学习微服务中,我们也将重新审视扩展你的机器学习解决方案的问题,我们将重点关注使用 Kubernetes 水平扩展机器学习微服务。这很好地补充了我们在这里通过展示如何扩展更多实时工作负载来扩展大型批量工作负载的工作。此外,在第九章,构建一个提取、转换、机器学习用例中,我们在这里讨论的许多扩展讨论都是先决条件;因此,我们在这里介绍的所有内容都将为你从本书的其余部分中获得最大收益奠定良好的基础。因此,带着所有这些新知识,让我们去探索已知最大模型的领域。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第七章:深度学习、生成式 AI 和 LLMOps
世界正在快速变化。截至 2023 年中期,机器学习(ML)和人工智能(AI)以一种甚至几个月前看起来不可能的方式进入了公众意识。随着 2022 年底 ChatGPT 的推出,以及来自世界各地实验室和组织的全新工具的涌现,数亿人现在每天都在使用 ML 解决方案来创造、分析和开发。除此之外,创新似乎正在加速,每天都有新的记录打破模型或新工具的宣布。ChatGPT 只是使用现在所知的生成式人工智能(生成 AI 或 GenAI)的解决方案的一个例子。虽然 ChatGPT、Bing AI 和 Google Bard 是文本生成 AI 工具的例子,但在图像空间中还有 DALL-E 和 Midjourney,现在还有一系列结合这些和其他类型数据的跨模态模型。鉴于正在演变的生态系统和全球领先 AI 实验室正在开发的模型,很容易感到不知所措。但不必担心,因为这一章完全是关于回答“这对作为新兴 ML 工程师的我意味着什么?”这个问题。
在本章中,我们将采取与本书其他章节相同的策略,专注于核心概念,并构建您可以在未来的项目中使用多年的坚实基础。我们将从自 2010 年代以来一直是许多 ML 前沿发展核心的基本算法方法开始,对深度学习进行回顾。然后,我们将讨论您如何构建和托管自己的深度学习模型,然后过渡到 GenAI,在那里我们将探讨一般格局,然后深入探讨 ChatGPT 和其他强大文本模型背后的方法,大型语言模型(LLMs)。
然后,我们将顺利过渡到探索如何将机器学习工程和 MLOps 应用于 LLMs,包括讨论这带来的新挑战。这是一个如此新的领域,我们将在本章中讨论的大部分内容将反映我在写作时的观点和理解。作为一个机器学习社区,我们目前正在开始定义这些模型的最佳实践意味着什么,所以我们将在这接下来的几页中共同为这个勇敢的新世界做出贡献。我希望你们享受这次旅程!
我们将在以下章节中涵盖所有这些内容:
-
深入学习深度学习
-
使用 LLMs 进行大规模开发
-
使用 LLMOps 构建未来
深入学习深度学习
在这本书中,我们迄今为止一直使用相对“经典”的机器学习模型,这些模型依赖于各种不同的数学和统计方法来从数据中学习。这些算法在一般情况下并不是基于任何学习生物学理论,其核心动机是找到不同的方法来显式优化损失函数。读者可能已经了解的一个稍微不同的方法,我们在第三章“从模型到模型工厂”中关于学习学习的部分简要介绍过,那就是人工神经网络(ANNs)所采用的方法,它起源于 20 世纪 50 年代,并基于大脑中神经元活动的理想化模型。人工神经网络的核心概念是通过连接相对简单的计算单元,称为神经元或节点(基于生物神经元建模),我们可以构建能够有效模拟任何数学函数的系统(下方的信息框中提供了更多细节)。在这个案例中,神经元是系统的一个小组成部分,它将根据输入以及使用某些预先确定的数学公式对输入进行转换来返回输出。它们本质上是非线性的,当它们组合在一起时,可以非常快速地开始模拟相当复杂的数据。人工神经元可以被认为是按层排列的,其中一层的神经元与下一层的神经元相连。在具有不多神经元和不多层的较小神经网络层面,我们在这本书中讨论的许多关于重新训练和漂移检测的技术仍然适用,无需修改。当我们达到具有许多层和神经元的所谓深度神经网络(DNNs)时,我们必须考虑一些额外的概念,这些概念我们将在本节中介绍。
神经网络能够表示各种各样函数的能力,在所谓的万能逼近定理中有着理论基础。这些是严格的数学结果,证明了多层神经网络可以逼近数学函数类,达到任意精度的近似。这些结果并没有说明哪些具体的神经网络会做到这一点,但它们告诉我们,只要有足够的隐藏神经元或节点,我们就可以确信,只要有足够的数据,我们应当能够表示我们的目标函数。这些定理中一些最重要的结果是在 20 世纪 80 年代末通过像Hornik, K., Stinchcombe, M. and White, H. (1989) “Multilayer feedforward networks are universal approximators”, Neural Networks, 2(5), pp. 359–366和Cybenko, G. (1989) “Approximation by superpositions of a sigmoidal function”, Mathematics of Control, Signals, and Systems, 2(4), pp. 303–314这样的论文中确立的。
在过去几年中,深度神经网络(DNNs)风靡全球。从计算机视觉到自然语言处理,从 StableDiffusion 到 ChatGPT,现在有无数令人惊叹的例子表明 DNNs 正在做以前被认为是人类专属的事情。深度学习模型的深入数学细节在其他许多文献中都有涉及,例如 Goodfellow、Bengio、Courville 的经典著作《深度学习》,由麻省理工学院出版社于 2016 年出版,我们在这里无法充分展示。尽管详细的理论超出了本章的范围,但我将尝试提供一个概述,包括你需要了解的主要概念和技术,以便你能够具备良好的工作知识,并能够开始在你的机器学习工程项目中使用这些模型。
正如所述,人工神经网络基于从生物学中借鉴的思想,就像在生物大脑中一样,ANN 由许多单个神经元组成。神经元可以被视为在 ANN 中提供计算单元。神经元通过接收多个输入并将它们按照特定的配方组合起来以产生单个输出来工作,这个输出可以随后作为另一个神经元的输入或作为整体模型输出的部分。在生物环境中,神经元的输入沿着树突流动,输出则沿着轴突传导。
但输入是如何转换为输出的呢?我们需要将几个概念结合起来才能理解这个过程。
-
权重:网络中每个神经元之间的连接都分配了一个数值,这个数值可以被视为连接的“强度”。在神经网络训练过程中,权重是用于最小化损失的一组值之一。这与第三章中提供的模型训练解释相一致,即从模型到模型工厂。
-
偏差:网络中的每个神经元都给定了一个额外的参数,该参数作为激活(以下定义)的偏移量。这个数值在训练过程中也会更新,它为神经网络提供了更多的自由度来拟合数据。你可以将偏差视为改变神经元“放电”(或产生特定输出)的水平,因此作为变量值意味着神经元有更多的适应性。
-
输入:这些可以被视为在考虑权重或偏差之前馈送到神经元的原始数据点。如果神经元是根据数据提供特征,则输入是特征值;如果神经元是接收来自其他神经元的输出,那么这些就是那种情况下的值。
-
激活:ANN 中的神经元接收多个输入;激活是输入的线性组合,乘以适当的权重加上偏差项。这把多块传入数据转换成一个单一的数值,然后可以用来确定神经元的输出应该是什么。
-
激活函数:激活只是一个数字,但激活函数是我们决定这个数字对神经元意味着什么的方式。目前深度学习中非常流行的激活函数有很多,但重要的特征是,当这个函数作用于激活值时,它产生一个数字,这是神经元或节点的输出。
这些概念在图 7.1中以图表形式呈现。深度学习模型没有严格的定义,但就我们的目的而言,一旦一个人工神经网络(ANN)由三个或更多层组成,我们就可以认为它是深层的。这意味着我们必须定义这些层的一些重要特征,我们现在就来做这件事:
-
输入层:这是第一个神经元层,其输入是原始数据或从数据中创建的预处理特征。
-
隐藏层:这些是输入层和输出层之间的层,可以认为是在这里执行数据的主要非线性变换。这通常是因为有很多隐藏层和神经元!隐藏层中神经元的组织和连接是神经网络架构的关键部分。
-
输出层:输出层负责将神经网络中执行过的变换的结果转换为可以适当解释的结果。例如,如果我们使用神经网络来分类图像,我们需要最终层输出指定类别的 1 或 0,或者我们可以让它输出不同类别的概率序列。
这些概念是有用的背景知识,但我们在 Python 中如何开始使用它们呢?世界上两个最受欢迎的深度学习框架是 Tensorflow,由谷歌大脑在 2015 年发布,以及 PyTorch,由 Meta AI 在 2016 年发布。在本章中,我们将专注于使用 PyTorch 的示例,但许多概念在经过一些修改后同样适用于 TensorFlow。

图 7.1:人工神经网络(ANN)中“神经元”的示意图以及它如何接收输入数据 x 并将其转换为输出 y。
开始使用 PyTorch
首先,如果你还没有安装 PyTorch,你可以通过遵循pytorch.org/get-started/locally/上的 PyTorch 文档来安装,用于在 Macbook 上本地安装,或者使用:
pip3 install torch
在使用 PyTorch 时,有一些重要的概念和特性是值得记住的:
-
torch.Tensor:张量是可以通过多维数组表示的数学对象,并且是任何现代深度学习框架的核心组件。我们输入网络的数据应该被转换为张量,例如:inputs = torch.tensor(X_train, dtype=torch.float32) labels = torch.tensor(y_train, dtype=torch.long) -
torch.nn: 这是定义我们的神经网络模型所使用的主要模块。例如,我们可以使用它来定义一个包含三个隐藏层的基本分类神经网络,每个隐藏层都有一个修正线性单元(ReLU)激活函数。当使用这种方法在 PyTorch 中定义模型时,你还应该编写一个名为forward的方法,该方法定义了在训练过程中数据如何通过网络。以下代码展示了如何在继承自torch.nn.Module对象的类中构建一个基本神经网络。这个网络有四个线性层,每个层都有 ReLU 激活函数,以及一个简单的正向传递函数:import torch import torch.nn as nn class NeuralNetwork(nn.Module): def __init__(self): super(NeuralNetwork, self).__init__() self.sequential = nn.Sequential( nn.Linear(13, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 16), nn.ReLU(), nn.Linear(16, 3) ) def forward(self, x): x = self.sequential(x) return x -
损失函数: 在
torch.nn模块中,有一系列损失函数可用于训练网络。一个流行的选择是交叉熵损失,但在文档中还有更多可供选择:criterion = nn.CrossEntropyLoss() -
torch.optim.Optimizer: 这是 PyTorch 中所有优化器的基类。这允许实现第三章,“从模型到模型工厂”中讨论的大多数优化器。在 PyTorch 中定义优化器时,在大多数情况下,你需要传入实例化模型的参数以及特定优化器的相关参数。例如,如果我们定义一个学习率为
0.001的 Adam 优化器,这就像这样简单:import torch.optim as optim model = NeuralNetwork() optimizer = torch.optim.Adam( model.parameters(), lr=0.001 ) -
torch.autograd: 回想一下,训练一个机器学习模型实际上是一个利用线性代数、微积分和一些统计学的优化过程。PyTorch 通过使用自动微分来执行模型优化,这是一种将函数的偏导数求解问题转化为一系列易于计算的原语应用的方法,尽管如此,它仍然能够以良好的精度计算微分。这不同于有限差分法或符号微分法。你可以通过使用损失函数并调用backward方法来隐式地调用它,该方法使用 autograd 来计算每个 epoch 中权重更新的梯度;然后通过调用optimizer.step()在优化器中使用这些梯度。在训练过程中,重置任何输入张量是很重要的,因为在 PyTorch 中张量是可变的(操作会改变其数据),同样,使用optimizer.zero_grad()重置优化器中计算的任何梯度也很重要。基于此,一个包含五百个 epoch 的示例训练运行如下:for epoch in range(500): running_loss = 0.0 optimizer.zero_grad() inputs = torch.tensor(X_train, dtype=torch.float32) labels = torch.tensor(y_train, dtype=torch.long) outputs = net(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() -
torch.save和torch.load: 你可能可以从它们的名字中猜出这些方法的作用!但仍然重要的是要展示如何保存和加载你的 PyTorch 模型。在训练深度学习模型时,在训练过程中定期保存模型也很重要,因为这通常需要很长时间。这被称为“检查点”,意味着如果在训练运行中出现任何问题,你可以从上次停止的地方继续。为了保存 PyTorch 检查点,我们可以在训练循环中添加如下语法:model_path = "path/to/model/my_model.pt" torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'loss': loss, }, model_path) -
要加载模型,你需要初始化你的神经网络类和优化器对象的另一个实例,然后从
checkpoint对象中读取它们的状态:model = NeuralNetwork() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) checkpoint = torch.load(model_path) model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) epoch = checkpoint['epoch'] loss = checkpoint['loss'] -
model.eval()和model.train(): 一旦你加载了 PyTorch 的检查点,你需要将模型设置为执行任务所需的适当模式,否则可能会出现下游问题。例如,如果你想进行测试和验证,或者你想使用你的模型对新数据进行推理,那么在使用模型之前,你需要调用model.eval()。这将冻结任何包含的批归一化或 dropout 层,因为它们在训练期间计算统计数据和执行更新,而这些更新在测试期间你不希望是活跃的。同样,model.train()确保这些层在训练运行期间可以继续按预期执行更新。应该注意的是,有一种比
model.eval()更极端的设置,你可以使用以下语法完全关闭你上下文中的任何 autograd 功能:with torch.inference_mode():这可以在推理时提供额外的性能,但应该只在确定你不需要任何梯度或张量更新跟踪或执行时使用。
-
评估:如果你想测试上面示例中我们刚刚训练的模型,你可以使用类似以下语法计算准确率,但本书中讨论的任何模型验证方法都适用!
inputs = torch.tensor(X_test, dtype=torch.float32) labels = torch.tensor(y_test, dtype=torch.long) outputs = net(inputs) _, predicted = torch.max(outputs.data, 1) correct = (predicted == labels).sum().item() total = labels.size(0) accuracy = correct / total print('Accuracy on the test set: %.2f %%' % (100 * accuracy))
有了这些,你现在可以构建、训练、保存、加载和评估你的第一个 PyTorch 模型。我们将现在讨论如何通过考虑将深度学习模型投入生产的挑战来进一步扩展。
规模化和将深度学习投入生产
现在我们将转向如何在生产系统中运行深度学习模型。为此,我们需要考虑一些特定的点,这些点将 DNN 与其他经典机器学习算法区分开来:
-
它们是数据饥渴的:与其他机器学习算法相比,DNN 通常需要相对大量的数据,这是因为它们正在执行极其复杂的多元优化,每个神经元的参数增加了自由度。这意味着为了从头开始训练 DNN,你必须提前做一些工作,确保你有足够的数据,并且数据种类适合充分训练模型。数据需求通常还意味着你需要能够将大量数据存储在内存中,因此这通常需要提前考虑。
-
训练更加复杂:这一点与上面提到的内容相关,但有所不同。我们正在解决的非常复杂的非线性优化问题意味着在训练过程中,模型往往有多种方式“迷失方向”并达到次优局部最小值。正如我们在上一节中描述的checkpointing示例,在深度学习社区中这些技术非常普遍,因为你经常需要在损失没有朝着正确的方向移动或停滞不前时停止训练,回滚,并尝试不同的方法。
-
你面临一个新的选择,即模型架构:深度神经网络(DNNs)也与经典机器学习算法有很大不同,因为你现在不仅需要担心几个超参数,还需要决定你的神经网络架构或形状。这通常是一项非同小可的练习,可能需要深入了解神经网络。即使你使用的是标准的架构,如 Transformer 架构(见图 7.2),你也应该对所有组件的功能有一个稳固的理解,以便有效地诊断和解决任何问题。正如在第三章“关于学习的知识”部分讨论的自动架构搜索等技术可以帮助加快架构设计,但坚实的知识基础仍然很重要。
-
可解释性固有的更难:过去几年中,针对深度神经网络(DNNs)的一个批评是,其结果可能非常难以解释。这是可以预料的,因为重点确实在于 DNN 将任何问题的许多具体细节抽象化成一个更抽象的方法。这在许多情况下可能没问题,但现在已导致几个高调案例,DNNs 表现出不希望的行为,如种族或性别偏见,这可能导致更难解释和补救。在高度监管的行业,如医疗保健或金融,你的组织可能负有法律义务能够证明为什么做出了特定的决定。如果你使用 DNN 来帮助做出这个决定,这通常会相当具有挑战性。

图 7.2:Transformer 架构如图所示,最初在谷歌大脑发表的论文“Attention is all you need”中提出,https://arxiv.org/abs/1706.03762。
考虑到所有这些,我们在使用深度学习模型为我们的机器学习系统时应该考虑哪些主要事项呢?嗯,你可以做的第一件事是使用现有的预训练模型,而不是自己训练。这显然带来了一些风险,即确保模型及其提供的数据对你的应用来说是足够高质量的,所以总是要谨慎行事,并做好你的尽职调查。
然而,在许多情况下,这种方法绝对是可行的,因为我们可能正在使用一个以相当公开的方式经过测试的模型,并且它可能在我们希望使用的任务上已知表现良好。此外,我们可能有一个用例,我们愿意接受导入和使用这个预存模型的运营风险,前提是我们自己的测试。让我们假设我们现在处于这样一个例子中,我们想要构建一个基本的管道来总结一个虚构组织客户和员工之间的文本对话。我们可以使用现成的转换器模型,如图 7.2所示,来自 Hugging Face 的 transformers 库。
要开始使用,你只需要知道你想要从 Hugging Face 模型服务器下载的模型名称;在这种情况下,我们将使用 Pegasus 文本摘要模型。Hugging Face 提供了一个“pipeline" API,用于包装模型并使其易于使用:
from transformers import pipeline
summarizer = pipeline("summarization", model= "google/pegasus-xsum")
执行我们的第一个深度学习模型推理就像只是将一些输入传递给这个管道一样简单。因此,对于上面描述的虚构人机交互,我们只需传递一些示例文本,看看它返回什么。让我们这样做,以总结一个虚构的客户和聊天机器人之间的对话,其中客户正在尝试获取他们已下订单的更多信息。对话如下所示:
text = "Customer: Hi, I am looking for some help regarding my recent purchase of a bouquet of flowers. ChatBot: Sure, how can I help you today? Customer: I purchased a bouquet the other day, but it has not arrived. ChatBot: What is the order ID? Customer: 0123456\. ChatBot: Please wait while I fetch the details of your order... It doesn't seem like there was an order placed as you described; are you sure of the details you have provided?"
然后,我们将把这个对话输入到摘要器 pipeline 对象中,并打印结果:
summary = summarizer(text)
print(summary)
[{'summary_text': 'This is a live chat conversation between a customer and a ChatBot.'}]
结果显示,该模型实际上已经很好地总结了这种交互的本质,突出了在深度学习革命之前可能非常困难或甚至不可能开始做的事情现在变得多么容易。
我们刚刚看到了一个使用预训练的转换器模型来执行某些特定任务的例子,在这种情况下是文本摘要,而无需根据新数据更新模型。在下一节中,我们将探讨当你想要根据你自己的数据更新模型时应该做什么。
微调和迁移学习
在上一节中,我们展示了如果能够找到适合您任务的现有深度学习模型,开始构建解决方案是多么容易。然而,一个值得我们自问的好问题是:“如果这些模型并不完全适合我的具体问题,我能做什么?”这就是微调和迁移学习概念发挥作用的地方。微调是指我们取一个现有的深度学习模型,然后在一些新数据上继续训练该模型。这意味着我们不是从头开始,因此可以更快地达到一个优化的网络。迁移学习是指我们冻结神经网络的大部分状态,并使用新数据重新训练最后几层,以便执行一些稍微不同的任务,或者以更适合我们问题的方法执行相同的任务。在这两种情况下,这通常意味着我们可以保留原始模型中的许多强大功能,例如其特征表示,但开始为我们的特定用例进行调整。
为了使这个例子更加具体,我们现在将演示一个迁移学习在实际中的应用示例。微调可以遵循类似的过程,但并不涉及我们将要实施的神经网络调整。在这个例子中,我们将使用 Hugging Face 的datasets和evaluate包,这将展示我们如何使用基础双向编码器表示从 Transformer(BERT)模型,然后使用迁移学习来创建一个分类器,该分类器将估计在多语言亚马逊评论语料库(registry.opendata.aws/amazon-reviews-ml/)中用英语撰写的评论的星级评分。
图 7.3展示了该数据集的一个示例评分:

图 7.3:这展示了来自多语言亚马逊评论语料库的一个示例评论和星级评分。
尽管我们在以下示例中使用了 BERT 模型,但还有许多变体可以与相同的示例一起工作,例如 DistilBERT 或 AlBERT,这些是更小的模型,旨在更快地训练并保留原始 BERT 模型的大部分性能。您可以尝试所有这些,甚至可能会发现这些模型由于尺寸减小而下载速度更快!
为了开始我们的迁移学习示例:
-
首先,我们可以使用
datasets包来检索数据集。我们将使用 Hugging Face 数据集提供的“配置”和“拆分”概念,这些概念指定了数据的具体子集以及您是否想要数据的训练、测试或验证拆分。对于这个案例,我们想要英语评论,并且最初将使用数据的训练拆分。图 7.3展示了数据集的一个示例记录。数据检索的语法如下:import datasets from datasets import load_dataset def fetch_dataset(dataset_name: str="amazon_reviews_multi", configuration: str="en", split: str="train" ) -> datasets.arrow_dataset.Dataset: ''' Fetch dataset from HuggingFace datasets server. ''' dataset = load_dataset(dataset_name, configuration, split=split) return dataset -
下一步是标记化数据集。为此,我们将使用与我们将使用的 BERT 模型配对的
AutoTokenizer。在我们引入那个特定的分词器之前,让我们编写一个函数,该函数将使用所选的分词器来转换数据集。我们还将定义将数据集转换为适合在后续 PyTorch 过程中使用的形式的逻辑。我还添加了一个选项来对测试数据进行下采样:import typing from transformers import AutoTokenizer def tokenize_dataset(tokenizer: AutoTokenizer, dataset: datasets.arrow_dataset.Dataset, sample=True) -> datasets.arrow_dataset.Dataset: ''' Tokenize the HuggingFace dataset object and format for use in later Pytorch logic. ''' tokenized_dataset = dataset.map( lambda x: tokenizer(x["review_body"], padding="max_length", truncation=True), batched=True ) # Torch needs the target column to be named "labels" tokenized_dataset = tokenized_dataset.rename_column("stars", "labels") # We can format the dataset for Torch using this method. tokenized_dataset.set_format( type="torch", columns=["input_ids", "token_type_ids", "attention_mask", "labels"] ) # Let's downsample to speed things up for testing if sample==True: tokenized_dataset_small = tokenized_dataset.\ shuffle(seed=42).select(range(10)) return tokenized_dataset_small else: return tokenized_dataset -
接下来,我们需要创建 PyTorch
dataloader以将数据输入到模型中:from torch.utils.data import DataLoader def create_dataloader( tokenized_dataset: datasets.arrow_dataset.Dataset, batch_size: int = 16, shuffle: bool = True ): dataloader = DataLoader(tokenized_dataset, shuffle=shuffle, batch_size=batch_size) return dataloader -
在我们定义训练模型的逻辑之前,编写一个用于定义学习调度器和训练运行优化器的辅助函数将很有用。然后我们可以在我们的训练函数中调用它,我们将在下一步定义。在这个例子中,我们将使用 AdamW 优化器:
from torch.optim import AdamW from transformers import get_scheduler def configure_scheduler_optimizer( model: typing.Any, dataloader: typing.Any, learning_rate: float, num_training_steps: int) -> tuple[typing.Any, typing.Any]: ''' Return a learning scheduler for use in training using the AdamW optimizer ''' optimizer = AdamW(model.parameters(), lr=learning_rate) lr_scheduler = get_scheduler( name="linear", optimizer=optimizer, num_warmup_steps=0, num_training_steps=num_training_steps ) return lr_scheduler, optimizer -
现在,我们可以定义我们想要使用迁移学习训练的模型。Hugging Face 的
transformers库提供了一个非常有用的包装器,可以帮助您根据核心 BERT 模型更改神经网络的分类头。我们实例化这个模型并传入类别数,这隐式地更新了神经网络架构,以便在运行预测时为每个类别提供 logits。在运行推理时,我们将取这些 logits 中的最大值对应的类别作为推断类别。首先,让我们在函数中定义训练模型的逻辑:import torch from tqdm.auto import tqdm def transfer_learn( model: typing.Any, dataloader: typing.Any, learning_rate: float = 5e-5, num_epochs: int = 3, progress_bar: bool = True )-> typing.Any: device = torch.device("cuda") if torch.cuda.is_available() else\ torch.device("cpu") model.to(device) num_training_steps = num_epochs * len(dataloader) lr_scheduler, optimizer = configure_scheduler_optimizer( model = model, dataloader = dataloader, learning_rate = learning_rate, num_training_steps = num_training_steps ) if progress_bar: progress_bar = tqdm(range(num_training_steps)) else: pass model.train() for epoch in range(num_epochs): for batch in dataloader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss loss.backward() optimizer.step() lr_scheduler.step() optimizer.zero_grad() if progress_bar: progress_bar.update(1) else: pass return model -
最后,我们可以调用所有这些方法来获取分词器,引入数据集,转换它,定义模型,配置学习调度器和优化器,并最终执行迁移学习以创建最终模型:
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased") tokenized_dataset = tokenize_dataset(tokenizer=tokenizer, dataset=dataset, sample=True) dataloader = create_dataloader(tokenized_dataset=tokenized_dataset) model = AutoModelForSequenceClassification.from_pretrained( "bert-base-cased", num_labels=6) # 0-5 stars transfer_learned_model = transfer_learn( model = model, dataloader=dataloader ) -
然后,我们可以使用 Hugging Face 的
evaluate包或任何我们喜欢的方法来评估模型在数据测试分割上的性能。注意,在下面的示例中,我们调用model.eval()以使模型处于评估模式,正如之前讨论的那样:import evaluate device = torch.device("cuda") if torch.cuda.is_available() else\ torch.device("cpu") metric = evaluate.load("accuracy") model.eval() eval_dataset = fetch_dataset(split="test") tokenized_eval_dataset = tokenize_dataset( tokenizer=tokenizer,dataset=eval_dataset, sample=True) eval_dataloader = create_dataloader( tokenized_dataset=tokenized_eval_dataset) for batch in eval_dataloader: batch = {k: v.to(device) for k, v in batch.items()} with torch.no_grad(): outputs = model(**batch) logits = outputs.logits predictions = torch.argmax(logits, dim=-1) metric.add_batch(predictions=predictions, references=batch["labels"]) metric.compute()这将返回一个包含计算出的指标值的字典,如下所示:
{'accuracy': 0.8}
这就是您如何使用 PyTorch 和 Hugging Face 的 transformers 库来执行迁移学习。
Hugging Face 的 transformers 库现在还提供了一个非常强大的 Trainer API,以帮助您以更抽象的方式执行微调。如果我们从之前的示例中取相同的分词器和模型,要使用 Trainer API,我们只需做以下操作:
-
当使用 Trainer API 时,您需要定义一个
TrainingArguments对象,它可以包括超参数和一些其他标志。我们只需接受所有默认值,但提供一个输出检查点的路径:from transformers import TrainingArguments training_args = TrainingArguments(output_dir="trainer_checkpoints") -
然后,我们可以使用之前示例中使用的相同
evaluate包来定义一个计算任何指定指标的功能,我们将将其传递给主trainer对象:import numpy as np import evaluate metric = evaluate.load("accuracy") def compute_metrics(eval_pred): logits, labels = eval_pred predictions = np.argmax(logits, axis=-1) return metric.compute(predictions=predictions, references=labels) -
然后您定义一个包含所有相关输入对象的
trainer对象:trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, compute_metrics=compute_metrics, ) -
您可以通过调用这些指定的配置和对象来训练模型
trainer.train().
这就是您在 Hugging Face 上对现有模型进行自己训练的方法。
还值得注意的是,Trainer API 提供了一种非常不错的方式来使用Optuna这样的工具,我们在第三章,从模型到模型工厂中遇到过,以执行超参数优化。您可以通过指定 Optuna 试验搜索空间来完成此操作:
def optuna_hp_space(trial):
return {
"learning_rate": trial.suggest_float("learning_rate", 1e-6, 1e-4,
log=True)
}
然后定义一个函数,用于在超参数搜索的每个状态下初始化神经网络:
def model_init():
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-cased", num_labels=6)
return model
然后,您只需将此传递给Trainer对象:
trainer = Trainer(
model=None,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
compute_metrics=compute_metrics,
tokenizer=tokenizer,
model_init=model_init,
)
最后,您可以运行超参数搜索并检索最佳运行:
best_run = trainer.hyperparameter_search(
n_trials=20,
direction="maximize",
hp_space=optuna_hp_space
)
这样,我们就完成了使用 Hugging Face 工具进行 PyTorch 深度学习模型的迁移学习和微调的示例。需要注意的是,微调和迁移学习仍然是训练过程,因此仍然可以应用于第三章中概述的模型工厂方法。例如,当我们说“训练”时,在第三章中概述的“train-run”过程中,这可能现在指的是预训练深度学习模型的微调或迁移学习。
如我们之前已经广泛讨论过的,深度学习模型可以是非常强大的工具,用于解决各种问题。近年来,许多团体和组织积极探索的一个趋势是,随着这些模型变得越来越大,可能实现什么。在下一节中,我们将开始通过探索深度学习模型变得极其大时会发生什么来回答这个问题。是时候进入大型语言模型(LLMs)的世界了。
与大型语言模型(LLMs)一起生活
在撰写本文时,GPT-4 仅在前几个月的 2023 年 3 月由 OpenAI 发布。这个模型可能是迄今为止开发的最大机器学习模型,据报道有 1000 亿个参数,尽管 OpenAI 尚未确认确切数字。从那时起,微软和谷歌已经宣布在其产品套件中使用类似的大型模型提供高级聊天功能,并发布了一系列开源软件包和工具包。所有这些解决方案都利用了迄今为止开发的一些最大的神经网络模型,即 LLMs。LLMs 是被称为基础模型的更广泛模型类别的一部分,不仅涵盖文本应用,还包括视频和音频。作者将这些模型大致分类为对于大多数组织来说太大,无法从头开始训练。这意味着组织将要么作为第三方服务消费这些模型,要么托管并微调现有模型。以安全可靠的方式解决这一集成挑战是现代机器学习工程的主要挑战之一。没有时间可以浪费,因为新的模型和功能似乎每天都在发布;所以让我们行动起来吧!
理解大型语言模型(LLMs)
基于大型语言模型(LLM)的系统的主要焦点是针对各种基于文本的输入创建类似人类的响应。LLM 基于我们已经接触过的转换器架构。这使得这些模型能够并行处理输入,与其它深度学习模型相比,在相同数据量的训练上显著减少了所需的时间。
对于任何转换器来说,LLM 的架构由一系列编码器和解码器组成,这些编码器和解码器利用了自注意力和前馈神经网络。
从高层次来看,你可以将编码器视为负责处理输入,将其转换成适当的数值表示,然后将这个表示输入到解码器中,从解码器中生成输出。转换器的魔力来自于自注意力的使用,这是一种捕捉句子中词语之间上下文关系的机制。这导致了表示这种上下文关系的注意力向量,当这些向量的多个被计算时,就称为多头注意力。编码器和解码器都使用自注意力机制来捕捉输入和输出序列的上下文依赖关系。
在 LLM 中使用的基于转换器的最流行的模型之一是 BERT 模型。BERT 是由谷歌开发的,是一个预训练模型,可以针对各种自然语言任务进行微调。
另一个流行的架构是生成预训练转换器(GPT),由 OpenAI 创建。OpenAI 在 2022 年 11 月发布的 ChatGPT 系统,显然在引起世界轰动时使用了第三代 GPT 模型。截至 2023 年 3 月写作时,这些模型已经发展到第四代,并且非常强大。尽管 GPT-4 仍然相对较新,但它已经引发了关于 AI 未来的激烈辩论,以及我们是否已经达到了人工通用智能(AGI)。作者并不认为我们已经达到,但无论如何,这是一个多么激动人心的领域啊!
使得 LLM 在每个新的商业环境或组织中重新训练变得不可行的是,它们是在庞大的数据集上训练的。2020 年发布的 GPT-3 在近 5000 亿次文本标记上进行了训练。在这个例子中,一个标记是用于 LLM 训练和推理过程中单词的小片段,大约有 4 个英文字符左右。这可是大量的文本!因此,训练这些模型的成本相应地也很高,甚至推理也可能非常昂贵。这意味着,那些唯一关注不生产这些模型的组织可能无法看到规模经济和投资这些模型所需的回报。在考虑需要专业技能、优化基础设施以及获取所有这些数据的能力之前,这种情况就已经存在了。这与几年前公共云的出现有很多相似之处,当时组织不再需要投资大量的本地基础设施或专业知识,而是开始按“使用即付费”的方式支付费用。现在,这种情况正在最复杂的机器学习模型中发生。这并不是说较小的、更专业化的模型已被排除在外。事实上,我认为这将是组织利用他们独特的数据集来驱动竞争优势和构建更好产品的一种方式。最成功的团队将是那些能够以稳健的方式将这种方法与最大模型的方法相结合的团队。
尽管规模不是唯一重要的组成部分。ChatGPT 和 GPT-4 不仅在大量数据上进行了训练,而且还使用了一种称为人类反馈强化学习(RLHF)的技术进行了微调。在这个过程中,模型会接收到一个提示,例如一个对话式问题,然后生成一系列可能的回答。这些回答随后会被展示给人类评估者,他们会对回答的质量提供反馈,通常是通过排名,这些反馈随后用于训练一个奖励模型。然后,该模型会通过近端策略优化(PPO)等技术来微调底层语言模型。所有这些细节都远远超出了本书的范围,但希望您已经对这种并非普通数据科学,任何团队都无法迅速扩展的方法有了直观的认识。既然如此,我们就必须学习如何将这些工具视为更类似于“黑盒”的东西,并将它们作为第三方解决方案来使用。我们将在下一节中介绍这一点。
通过 API 消费 LLM
如前几节所述,我们作为想要与 LLMs 和一般基础模型交互的 ML 工程师的思维方式的重大变化是,我们不能再假设我们有权访问模型工件、训练数据或测试数据。相反,我们必须将模型视为一个第三方服务,我们应该调用它以进行消费。幸运的是,有许多工具和技术可以实现这一点。
下一个示例将展示如何使用流行的LangChain包构建利用 LLMs 的管道。这个名字来源于这样一个事实:为了利用 LLMs 的力量,我们通常需要通过调用其他系统和信息来源与它们进行许多交互。LangChain 还提供了一系列在处理 NLP 和基于文本的应用时非常有用的功能。例如,有文本拆分、处理向量数据库、文档加载和检索以及会话状态持久化的工具。这使得它即使在不是专门与 LLMs 工作的项目中也是一个值得检查的包。
首先,我们通过一个基本示例来调用 OpenAI API:
-
安装
langchain和openaiPython 绑定:pip install langchain pip install openai -
我们假设用户已经设置了 OpenAI 账户并有权访问 API 密钥。你可以将其设置为环境变量或使用像 GitHub 提供的那样一个秘密管理器进行存储。我们将假设密钥可以通过环境变量访问:
import os openai_key = os.getenv('OPENAI_API_KEY') -
现在,在我们的 Python 脚本或模块中,我们可以定义我们将通过
langchain包装器访问的 OpenAI API 调用的模型。这里我们将使用gpt-3.5-turbo模型,这是 GPT-3.5 聊天模型中最先进的:from langchain.chat_models import ChatOpenAI gpt = ChatOpenAI(model_name='''gpt-3.5-turbo''') -
LangChain 随后通过提示模板促进使用 LLMs 构建管道,这些模板允许您标准化我们将如何提示和解析模型的响应:
template = '''Question: {question} Answer: ''' prompt = PromptTemplate( template=template, input_variables=['question'] ) -
然后,我们可以创建我们的第一个“链”,这是在
langchain中拉取相关步骤的机制。这个第一个链是一个简单的链,它接受一个提示模板和输入,创建一个适当的提示发送给 LLM API,然后返回一个格式适当的响应:# user question question = "Where does Andrew McMahon, author of 'Machine Learning Engineering with Python', work?" # create prompt template > LLM chain llm_chain = LLMChain( prompt=prompt, llm=gpt ) -
你可以运行这个问题并将结果打印到终端作为测试:
print(llm_chain.run(question))这返回:
As an AI language model, I do not have access to real-time information. However, Andrew McMahon is a freelance data scientist and software engineer based in Bristol, United Kingdom.
由于我是一名受雇于大型银行并驻扎在英国格拉斯哥的 ML 工程师,你可以看到即使是功能最复杂的 LLMs 也会出错。这是我们所说的“幻觉”的一个例子,其中 LLM 给出了一个错误但看似合理的答案。我们将在关于构建未来与LLMOps的章节中回到 LLMs 出错的话题。这仍然是一个通过程序化方式以标准化方式与 LLMs 交互的基本机制的例子。
LangChain 还提供了使用链中的generate方法将多个提示组合在一起的能力:
questions = [
{'question': '''Where does Andrew McMahon, author of 'Machine Learning Engineering with Python', work?'''},
{'question': 'What is MLOps?'},
{'question': 'What is ML engineering?'},
{'question': 'What's your favorite flavor of ice cream?'}
]
print(llm_chain.generate(questions))
这一系列问题的回答相当冗长,但以下是返回对象的第一部分:
generations=[[ChatGeneration(text='As an AI modeler and a data scientist, Andrew McMahon works at Cisco Meraki, a subsidiary of networking giant Cisco, in San Francisco Bay Area, USA.', generation_info=None, message=AIMessage(content='As an AI modeler and a data scientist, Andrew McMahon works at Cisco Meraki, a subsidiary of networking giant Cisco, in San Francisco Bay Area, USA.', additional_kwargs={}))], …]
再次,并不完全正确。不过你大概明白了!通过一些提示工程和更好的对话设计,这可以很容易地变得更好。我将让你自己尝试并享受其中的乐趣。
这份关于 LangChain 和 LLMs 的快速介绍只是触及了表面,但希望这能给你足够的信息,将调用这些模型的代码整合到你的机器学习工作流程中。
让我们继续讨论 LLMs 成为机器学习工程工具包重要组成部分的另一种方式,正如我们探索使用人工智能助手进行软件开发时。
使用 LLMs 进行编码
LLMs 不仅对创建和分析自然语言有用;它们还可以应用于编程语言。这就是 OpenAI Codex 系列模型的目的,这些模型在数百万个代码仓库上进行了训练,目的是在提示时能够生成看起来合理且性能良好的代码。自从 GitHub Copilot,一个编码人工智能助手推出以来,AI 助手帮助编码的概念已经进入主流。许多人认为这些解决方案在执行自己的工作时提供了巨大的生产力提升和更愉快的体验。GitHub 发布了一些自己的研究,表明在询问的 2,000 名开发者中,有 60-75%的人表示在开发软件时感到的挫败感减少,满意度提高。在 95 名开发者的一个小群体中,其中 50 名是对照组,他们使用给定规范在 JavaScript 中开发 HTTP 服务器时也显示了速度提升。我相信在我们宣布 AI 编码助手显然使我们所有人更快乐、更高效之前,应该在这个主题上做更多的工作,但 GitHub 的调查和测试结果确实表明它们是值得尝试的有用工具。这些结果发布在github.blog/2022-09-07-research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/。关于这一点,斯坦福大学的研究人员在一篇有趣的 arXiv 预印本论文中,arXiv:2211.03622 [cs.CR],似乎表明使用基于 OpenAI codex-davinci-002模型的 AI 编码助手的开发者更有可能在其代码中引入安全漏洞,并且即使存在这些问题,模型的使用者也会对自己的工作更有信心!应该注意的是,他们使用的模型在 OpenAI 现在提供的 LLM 家族中相对较旧,因此还需要更多的研究。这确实提出了一个有趣的可能性,即 AI 编码助手可能提供速度提升,但也可能引入更多的错误。时间将证明一切。随着强大开源竞争者的引入,这一领域也开始变得热门。其中一个值得指出的是 StarCoder,它是通过 Hugging Face 和 ServiceNow 的合作开发的huggingface.co/blog/starcoder。有一点是肯定的,这些助手不会消失,并且随着时间的推移只会变得更好。在本节中,我们将开始探索以各种形式与这些 AI 助手一起工作的可能性。学习与 AI 合作很可能是未来机器学习工程工作流程的一个关键部分,所以让我们开始学习吧!
首先,作为一个机器学习工程师,我什么时候会想使用 AI 编码助手呢?社区和 GitHub 的研究共识似乎表明,这些助手有助于在已建立的语言(如 Python)上开发样板代码。它们似乎并不适合当你想做一些特别创新或不同的事情时;然而,我们也会探讨这一点。
那么,你实际上是如何与 AI 合作来帮助你编写代码的呢?在撰写本文时,似乎有两种主要方法(但考虑到创新的步伐,你可能会很快通过脑机接口与 AI 合作;谁知道呢?),每种方法都有其自身的优缺点:
-
直接编辑器或 IDE 集成:在 Copilot 支持的代码编辑器和 IDE 中,包括撰写本文时我们在这本书中使用的 PyCharm 和 VS Code 环境,你可以启用 Copilot 在你输入代码时提供自动补全建议。你还可以在代码的注释中提供 LLM 模型的提示信息。这种集成方式只要开发者使用这些环境,就可能会一直存在,但我预见未来会有大量的 AI 助手服务。
-
聊天界面:如果你不使用 Copilot 而是使用其他 LLM,例如 OpenAI 的 GPT-4,那么你可能需要在一个聊天界面中工作,并在你的编码环境和聊天之间复制粘贴相关信息。这可能看起来有点笨拙,但确实更加灵活,这意味着你可以轻松地在你选择的模型之间切换,甚至组合多个模型。如果你有相关的访问权限和 API 来调用,你实际上可以构建自己的代码来将这些模型输入你的代码中,但到了那个阶段,你只是在重新开发一个像 Copilot 这样的工具!
我们将通过一个示例来展示这两种方法,并突出它们如何可能在你未来的机器学习工程项目中帮助你。
如果你导航到 GitHub Copilot 网页,你可以为个人订阅支付月费并享受免费试用。一旦你完成了这个步骤,你就可以遵循这里为你选择的代码编辑器的设置说明:docs.github.com/en/copilot/getting-started-with-github-copilot。
一旦你设置了这个环境,就像我为 VS Code 所做的那样,你就可以立即开始使用 Copilot。例如,我打开了一个新的 Python 文件并开始输入一些典型的导入语句。当我开始编写我的第一个函数时,Copilot 就提出了一个建议,来完成整个函数,如图 7.4 所示。

图 7.4:GitHub Copilot 在 VS Code 中建议的自动补全。
如上所述,这并不是向 Copilot 提供输入的唯一方式;您还可以使用注释来向模型提供更多信息。在图 7.5中,我们可以看到在首行注释中提供一些评论有助于定义我们希望在函数中包含的逻辑。

图 7.5:通过提供首行注释,您可以帮助 Copilot 为您代码建议所需的逻辑。
在使用 Copilot 时,以下是一些有助于发挥其最佳效果的事项,值得您牢记:
-
非常模块化:您能将代码做得越模块化,效果越好。我们之前已经讨论过这有利于维护和快速开发,但在这里它也有助于 Codex 模型创建更合适的自动补全建议。如果您的函数将要变得很长,很复杂,那么 Copilot 的建议可能就不会很好。
-
编写清晰的注释:这当然是一种良好的实践,但它确实有助于 Copilot 理解您需要的代码。在文件顶部编写较长的注释,描述您希望解决方案执行的操作,然后在函数之前编写较短但非常精确的注释可能会有所帮助。图 7.5中的示例显示了一个注释,它指定了我想让函数执行特征准备的方式,但如果注释只是说“标准化特征”,那么建议可能就不会那么完整。
-
编写接口和函数签名:正如图 7.5所示,如果您在代码块开始时提供函数签名和类型或类定义的第一行(如果是类的话),这有助于启动模型以完成代码块的其余部分。
希望这足以让您开始与 AI 合作构建解决方案的旅程。我认为随着这些工具变得更加普遍,将会有很多机会使用它们来加速您的工作流程。
现在我们已经知道了如何使用 LLMs 构建一些管道,并且知道了如何开始利用它们来辅助我们的开发,我们可以转向我认为这个领域最重要的一个话题。我也认为这是最具未解之谜的话题,因此它是一个非常激动人心的探索方向。这一切都与利用 LLMs 的操作影响有关,现在被称为LLMOps。
用 LLMOps 构建未来
近期对大型语言模型(LLMs)的兴趣日益增长,很多人表达了将这类模型集成到各种软件系统中的愿望。对于我们这些机器学习工程师来说,这应该立即引发我们思考,“这将对我们的操作意味着什么?”正如本书中多次讨论的那样,将操作与机器学习系统的开发相结合被称为 MLOps。然而,与 LLMs 一起工作可能会带来一些有趣的挑战,因此出现了一个新术语,LLMOps,以给 MLOps 的子领域带来一些良好的市场营销。
这真的有什么不同吗?我认为它并没有那么不同,但应该被视为 MLOps 的一个子领域,它有自己的额外挑战。我在这个领域看到的一些主要挑战包括:
-
即使是微调,也需要更大的基础设施:正如之前讨论的那样,这些模型对于典型的组织或团队来说太大,无法考虑自己训练,因此团队将不得不利用第三方模型,无论是开源的还是专有的,并对它们进行微调。微调如此规模的模型仍然会非常昂贵,因此构建非常高效的数据摄取、准备和训练管道将更加重要。
-
模型管理有所不同:当你自己训练模型时,正如我们在第三章“从模型到模型工厂”中多次展示的那样,有效的机器学习工程需要我们为模型的版本控制和存储提供实验和训练过程的历史记录的元数据定义良好的实践。在一个模型更常由外部托管的世界里,这会稍微困难一些,因为我们无法访问训练数据、核心模型工件,甚至可能连详细的模型架构都无法访问。版本控制元数据可能默认为模型的公开可用元数据,例如
gpt-4-v1.3和类似名称。这并不是很多信息,因此你可能会考虑想出方法来丰富这些元数据,可能包括你自己的示例运行和测试结果,以便了解该模型在特定场景下的行为。这也就与下一个点相关联。 -
回滚变得更加困难:如果你的模型由第三方托管,你无法控制该服务的路线图。这意味着,如果模型版本 5 存在问题,你想回滚到版本 4,你可能没有这个选项。这与我们在本书中详细讨论过的模型性能漂移是不同类型的“漂移”,但它将变得越来越重要。这意味着你应该准备自己的模型,可能功能或规模远不及这些模型,作为最后的手段,在出现问题时切换到默认选项。
-
模型性能是一个更大的挑战:正如前一点提到的,随着基础模型作为外部托管服务提供,你不再像以前那样有那么多控制权。这意味着如果你检测到你所消费的模型有任何问题,无论是漂移还是其他错误,你所能做的非常有限,你将需要考虑我们刚才讨论的默认回滚。
-
应用自己的安全措施将是关键:LLM 会幻想,它们会出错,它们可能会重复训练数据,甚至可能无意中冒犯与之互动的人。所有这些都意味着随着这些模型被更多组织采用,将会有越来越多的需求来开发为利用这些模型构建的系统应用定制安全措施的方法。例如,如果某个 LLM 被用来驱动下一代聊天机器人,你可以设想在 LLM 服务和聊天界面之间,可以有一个系统层来检查突然的情感变化和应该被隐藏的重要关键词或数据。这一层可以利用更简单的机器学习模型和多种其他技术。在其最复杂的形式下,它可能试图确保聊天机器人不会导致违反组织建立的道德或其他规范。如果你的组织将气候危机作为一个重点关注的领域,你可能希望实时筛选对话中的信息,以避免与该领域关键科学发现相悖,例如。
由于基础模型的时代才刚刚开始,很可能会出现越来越多的复杂挑战,让我们作为机器学习工程师在接下来的很长时间里都忙碌不已。对我来说,这是我们作为一个社区面临的最激动人心的挑战之一,那就是如何以仍然允许软件每天对用户安全、高效和稳健运行的方式,利用机器学习社区开发出的最复杂和最前沿的能力。你准备好接受这个挑战了吗?
让我们更详细地探讨一些这些话题,首先从 LLM 验证开始讨论。
验证 LLM
生成式 AI 模型的验证本质上与其它 ML 模型的验证不同,看起来也更复杂。主要原因在于,当你正在生成内容时,你通常会在结果中创建非常复杂的数据,这些数据以前从未存在过!如果 LLM 在请求帮助总结和分析某些文档时返回一段生成的文本,你如何判断这个答案是否“好”?如果你要求 LLM 将一些数据重新格式化为表格,你如何构建一个合适的指标来捕捉它是否正确地完成了这项任务?在生成环境中,“模型性能”和“漂移”究竟意味着什么,我该如何计算它们?其他问题可能更依赖于具体的应用场景,例如,如果你正在构建一个信息检索或检索增强生成(见Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks,arxiv.org/pdf/2005.11401.pdf)解决方案,你如何评估 LLM 生成的文本的真实性?
在如何筛选 LLM 生成的输出以避免可能对运行该模型的组织造成伤害或声誉损害的偏见或有害输出方面,也有一些重要的考虑因素。LLM 验证的世界非常复杂!
我们能做些什么呢?幸运的是,这一切并非在真空中发生,已经有几个基准工具和数据集发布,可以帮助我们在旅途中。由于事物还处于初级阶段,因此这些工具的实例并不多,但我们将讨论关键点,以便您了解这一领域,并跟上事物的发展。以下是 LLM 的一些高知名度评估框架和数据集:
-
OpenAI Evals:这是一个框架,OpenAI 允许通过众包开发针对 LLM 生成的文本补全的测试。evals 的核心概念是“补全函数协议”,这是一种标准化与 LLM 交互时返回的字符串测试的机制。该框架可在 GitHub 上找到,
github.com/openai/evals。 -
全面评估语言模型(HELM):这个由斯坦福大学发起的项目将自己定位为 LLM 性能的“活基准”。它提供了各种数据集、模型和指标,并展示了这些不同组合的性能。这是一个非常强大的资源,您可以用它来基于自己的测试场景,或者直接使用这些信息来了解使用任何特定 LLM 的潜在风险和收益。HELM 基准可在
crfm.stanford.edu/helm/latest/找到。 -
Guardrails AI:这是一个 Python 包,允许你以类似于
pydantic的方式对 LLM 的输出进行验证,这是一个非常强大的想法!你还可以用它来为 LLM 构建控制流程,以应对诸如对提示的响应不符合你设定的标准等问题;在这种情况下,你可以使用 Guardrails AI 重新提示 LLM,希望得到不同的响应。要使用 Guardrails AI,你需要指定一个可靠的 AI 标记语言(RAIL)文件,该文件以 XML 类似的文件定义了提示格式和预期的行为。Guardrails AI 可在 GitHub 上找到:shreyar.github.io/guardrails/。
每时每刻都有更多这样的框架被创建,但随着越来越多的组织希望将基于 LLM 的系统从有趣的证明概念转变为生产解决方案,熟悉核心概念和现有数据集将变得越来越重要。在本章的最后部分,我们将简要讨论我在构建 LLM 应用时围绕“提示”管理所看到的一些具体挑战。
PromptOps
当与需要文本输入的生成式 AI 工作时,我们输入的数据通常被称为“提示”(prompts),以捕捉与这些模型互动的对话起源以及输入需要响应的概念,就像人的提示一样。为了简单起见,我们将任何我们提供给 LLM 的输入数据都称为提示,无论这是通过用户界面还是通过 API 调用,也不论我们提供给 LLM 的内容性质如何。
提示通常与我们通常喂给 ML 模型的数据大不相同。它们可以是自由形式的,长度各异,并且在大多数情况下,表达了我们希望模型如何行动的意图。在其他 ML 建模问题中,我们当然可以输入非结构化文本数据,但这个意图部分是缺失的。这导致我们作为与这些模型一起工作的 ML 工程师需要考虑一些重要因素。
首先,提示的塑造很重要。术语提示工程最近在数据社区中变得流行,它指的是在设计和这些提示的内容和格式时往往需要大量的思考。当我们用这些模型设计我们的 ML 系统时,我们需要牢记这一点。我们应该问自己像“我能为我的应用程序或用例标准化提示格式吗?”“我能在用户或输入系统提供的之上提供适当的额外格式或内容,以获得更好的结果吗?”等问题。我将坚持使用这个术语来称呼提示工程。
其次,提示并非典型的机器学习输入,跟踪和管理它们是一个新的、有趣的挑战。这个挑战由于同一个提示可能对不同模型或同一模型的不同版本产生非常不同的输出而变得更加复杂。我们应该仔细考虑跟踪我们提示的谱系以及它们产生的输出。我将这个挑战称为提示管理。
最后,我们面临的一个挑战并非必然与提示相关,但如果允许系统用户输入自己的提示,例如在聊天界面中,它就变得更为相关。在这种情况下,我们需要对进入和离开模型的数据应用某种筛选和混淆规则,以确保模型不会被以某种方式“越狱”,从而规避任何安全措施。我们还想防范可能旨在从这些系统中提取训练数据,从而获取我们不希望共享的个人身份信息或其他关键信息的对抗性攻击。
当你们开始和全世界一起探索这个充满挑战的新世界 LLMOps 时,牢记这些与提示相关的挑战将非常重要。现在,我们将以一个简要总结来结束本章,总结我们已经讨论的内容。
摘要
在本章中,我们专注于深度学习。特别是,我们讨论了深度学习背后的关键理论概念,然后转向讨论如何构建和训练自己的神经网络。我们通过使用现成的模型进行推理的示例,然后通过微调和迁移学习将它们适应到特定的用例。所有展示的示例都是基于大量使用 PyTorch 深度学习框架和 Hugging Face API。
我们接着讨论了当前的热门话题:迄今为止构建的最大模型——大型语言模型(LLMs),以及它们对机器学习工程的意义。我们在展示如何使用流行的 LangChain 包和 OpenAI API 在管道中与它们交互之前,简要探讨了它们重要的设计原则和行为。我们还探讨了使用 LLMs 来提高软件开发生产力的潜力,以及这对作为机器学习工程师的你们意味着什么。
我们以对 LLMOps 这一新主题的探讨结束本章,LLMOps 是关于将本书中讨论的机器学习工程和 MLOps 原则应用于 LLMs。这涵盖了 LLMOps 的核心组件,以及一些可以用来验证你的 LLMs 的新功能、框架和数据集。我们最后提供了一些关于管理你的 LLM 提示的指导,以及如何将我们在第三章,“从模型到模型工厂”中讨论的实验跟踪概念应用到这种情况。
下一章将开始书的最后一部分,并将涵盖一个详细的端到端示例,我们将使用 Kubernetes 构建一个 ML 微服务。这将使我们能够应用我们在书中学到的许多技能。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:

第八章:构建示例 ML 微服务
本章将主要介绍如何将我们在书中学到的知识结合到一个现实示例中。这将是基于在 第一章,机器学习工程简介 中介绍的场景之一,其中我们被要求为商店商品销售构建预测服务。我们将详细讨论该场景,并概述为了使解决方案成为现实必须做出的关键决策,然后展示我们如何通过本书中学到的过程、工具和技术从机器学习工程的角度解决问题的关键部分。到本章结束时,您应该对如何构建自己的 ML 微服务以解决各种商业问题有一个清晰的了解。
在本章中,我们将涵盖以下主题:
-
理解预测问题
-
设计我们的预测服务
-
选择工具
-
扩规模训练
-
使用 FastAPI 提供模型服务
-
容器化并部署到 Kubernetes
每个主题都将为我们提供一个机会,让我们回顾作为在复杂机器学习交付中工作的工程师所必须做出的不同决策。这将为我们提供在现实世界中执行此类操作时的便捷参考!
那么,让我们开始构建一个预测微服务吧!
技术要求
如果您在您的机器上安装并运行以下内容,本章中的代码示例将更容易理解:
-
Postman 或其他 API 开发工具
-
本地 Kubernetes 集群管理器,如 minikube 或 kind
-
Kubernetes CLI 工具,
kubectl
书籍 GitHub 仓库中 Chapter08 文件夹包含几个不同的技术示例的 conda 环境配置 .yml 文件,因为有几个不同的子组件。这些是:
-
mlewp-chapter08-train: 这指定了运行训练脚本的运行环境。 -
mlewp-chapter08-serve: 这指定了本地 FastAPI 网络服务的环境规范。 -
mlewp-chapter08-register: 这提供了运行 MLflow 跟踪服务器的环境规范。
在每种情况下,像往常一样创建 Conda 环境:
conda env create –f <ENVIRONMENT_NAME>.yml
本章中的 Kubernetes 示例还需要对集群和我们将部署的服务进行一些配置;这些配置在 Chapter08/forecast 文件夹下的不同 .yml 文件中给出。如果您使用 kind,可以通过运行以下简单配置来创建一个集群:
kind create cluster
或者,您可以使用存储库中提供的其中一个配置 .yaml 文件:
kind create cluster --config cluster-config-ch08.yaml
Minikube 不提供像 kind 那样读取集群配置 .yaml 选项,因此,你应该简单地运行:
minikube start
部署您的本地集群。
理解预测问题
在第一章“ML 工程简介”中,我们考虑了一个 ML 团队,该团队被分配提供零售业务中单个商店层面的商品预测。虚构的业务用户有以下要求:
-
预测结果应通过基于 Web 的仪表板进行展示和访问。
-
用户在必要时应能够请求更新预测。
-
预测应在单个商店层面进行。
-
用户在任何一次会话中都会对其自己的区域/商店感兴趣,而不会关注全球趋势。
-
在任何一次会话中请求更新预测的数量将很少。
鉴于这些要求,我们可以与业务团队合作创建以下用户故事,我们可以将这些故事放入像 Jira 这样的工具中,如第二章“机器学习开发过程”中所述。满足这些要求的一些用户故事示例如下:
-
用户故事 1:作为一名本地物流规划师,我希望在早上 09:00 登录仪表板,并能够看到未来几天商店层面的商品需求预测,以便我能够提前了解运输需求。
-
用户故事 2:作为一名本地物流规划师,我希望能够在看到预测信息过时的情况下请求更新。我希望新的预测结果能在 5 分钟内返回,以便我能够有效地做出运输需求决策。
-
用户故事 3:作为一名本地物流规划师,我希望能够筛选特定商店的预测信息,以便我能够了解哪些商店在推动需求,并在决策中使用这些信息。
这些用户故事对于整个解决方案的开发非常重要。由于我们专注于问题的 ML 工程方面,我们现在可以深入探讨这些对构建解决方案意味着什么。
例如,希望“能够看到商店层面的商品需求预测”的愿望可以很好地转化为解决方案 ML 部分的几个技术要求。这告诉我们目标变量将是特定一天所需商品的数量。这告诉我们我们的 ML 模型或模型需要能够在商店层面工作,因此我们可能需要为每个商店有一个模型,或者将商店的概念作为某种特征来考虑。
同样,用户希望“能够在看到预测信息过时时请求更新我的预测……我希望新的预测能在五分钟内检索到”的要求对训练的延迟提出了明确的要求。我们不能构建需要几天时间才能重新训练的东西,这可能意味着在整个数据上构建一个模型可能不是最佳解决方案。
最后,请求I want to be able to filter for forecasts for specific stores再次支持了这样的观点,即无论我们构建什么,都必须在数据中利用某种类型的存储标识符,但不必一定作为算法的特征。因此,我们可能需要开始考虑应用逻辑,该逻辑将接受对特定店铺的预测请求,该店铺通过此存储 ID 识别,然后仅通过某种类型的查找或检索使用此 ID 进行筛选来检索该店铺的 ML 模型和预测。
通过这个过程,我们可以看到仅仅几行需求是如何使我们开始具体化我们在实践中如何解决问题的。这些想法和其他想法可以通过我们团队在项目中进行一些头脑风暴,并像表 8.1那样的表格进行整合:
| 用户故事 | 细节 | 技术需求 |
|---|---|---|
| 1 | 作为本地物流规划师,我希望在早上 09:00 登录仪表板,并能够看到未来几天在店铺层面的项目需求预测,以便我能够提前了解运输需求。 |
-
目标变量 = 项目需求。
-
预测范围 – 1-7 天。
-
用于仪表板或其他可视化解决方案的 API 访问。
|
| 2 | 作为本地物流规划师,我希望能够在看到预测过时的情况下请求更新我的预测。我希望新的预测在 5 分钟内返回,以便我能够有效地做出关于运输需求的决策。 |
|---|
-
轻量级重新训练。
-
每个店铺的模型。
|
| 3 | 作为本地物流规划师,我希望能够筛选特定店铺的预测,以便我能够了解哪些店铺在推动需求,并在决策中使用这一点。 |
|---|
- 每个店铺的模型。
|
表 8.1:将用户故事转换为技术需求。
现在,我们将通过开始为解决方案的 ML 部分设计一个设计来加深我们对问题的理解。
设计我们的预测服务
在理解预测问题部分的要求是我们需要达到的目标定义,但它们并不是达到目标的方法。借鉴我们从第五章,部署模式和工具中关于设计和架构的理解,我们可以开始构建我们的设计。
首先,我们应该确认我们应该工作在哪种设计上。由于我们需要动态请求,遵循在第五章部署模式和工具中讨论的微服务架构是有意义的。这将使我们能够构建一个专注于从我们的模型存储中检索正确模型并执行请求推理的服务。因此,预测服务应该在仪表板和模型存储之间提供接口。
此外,由于用户可能希望在任何一次会话中与几个不同的存储组合一起工作,并且可能在这些预测之间来回切换,我们应该提供一个高效执行此操作的机制。
从场景中也可以清楚地看出,我们可以非常容易地有大量的预测请求,但模型更新的请求较少。这意味着将训练和预测分开是有意义的,我们可以遵循第三章中概述的“从模型到模型工厂”的 train-persist 过程。这意味着预测不会每次都依赖于完整的训练运行,并且检索用于预测的模型相对较快。
从要求中我们还了解到,在这种情况下,我们的训练系统不一定需要由漂移监控触发,而是由用户发出的动态请求触发。这增加了一点点复杂性,因为它意味着我们的解决方案不应该对每个请求都进行重新训练,而应该能够确定重新训练对于给定的请求是否有价值,或者模型是否已经是最新的。例如,如果有四个用户登录并查看相同的区域/商店/商品组合,并且所有用户都请求重新训练,那么很明显我们不需要四次重新训练我们的模型!相反,应该发生的情况是,训练系统记录一个请求,执行重新训练,然后安全地忽略其他请求。
如我们在这本书中多次讨论的那样,有几种方式可以提供机器学习模型。一种非常强大且灵活的方式是将模型或模型提供逻辑封装到一个独立的服务中,该服务仅限于执行机器学习推理所需的任务。这是我们将在本章中考虑的提供模式,它是经典的“微服务”架构,其中不同的功能部分被分解成它们自己的独立和分离的服务。这为软件系统增加了弹性和可扩展性,因此这是一个很好的模式,需要变得熟悉。这也特别适合机器学习系统的开发,因为这些系统必须由训练、推理和监控服务组成,如第三章中概述的“从模型到模型工厂”。本章将介绍如何使用微服务架构提供机器学习模型,使用几种不同的方法,各有优缺点。然后你将能够根据这些示例调整和构建你自己的未来项目。
我们可以将这些设计点整合到一个高级设计图中,例如,在图 8.1中:

图 8.1:预测微服务的高级设计。
下一节将专注于在开发前进行一些工具选择时,将这些高级设计考虑因素细化到更低的细节水平。
工具选择
现在我们已经有一个高级设计在心中,并且我们已经写下了一些明确的技术要求,我们可以开始选择我们将用于实现解决方案的工具集。
在这个方面最重要的考虑因素之一将是我们将使用什么框架来建模我们的数据并构建我们的预测功能。鉴于问题是一个需要快速重新训练和预测的时间序列建模问题,我们可以在继续之前考虑一些可能适合的选择的优缺点。
这个练习的结果显示在表 8.2 中:
| 工具/框架 | 优点 | 缺点 |
|---|---|---|
| Scikit-learn |
-
几乎所有数据科学家都已经理解。
-
语法非常易于使用。
-
社区支持非常丰富。
-
良好的特征工程和管道支持。
|
-
没有原生的时间序列建模能力(但流行的
sktime包确实有这些)。 -
将需要更多的特征工程来将模型应用于时间序列数据。
|
| Prophet |
|---|
-
专注于预测。
-
具有内置的超参数优化功能。
-
开箱即提供大量功能。
-
在广泛的问题上通常给出准确的结果。
-
开箱即提供置信区间。
|
-
不像 scikit-learn 那样常用(但仍然相对流行)。
-
基础方法相当复杂——可能会导致数据科学家使用黑盒。
-
本身不具有可扩展性。
|
| Spark ML |
|---|
-
本地可扩展到大量数据。
-
良好的特征工程和管道支持。
|
-
没有原生的时间序列建模能力。
-
算法选项相对有限。
-
调试可能更困难。
|
表 8.2:考虑的一些不同机器学习工具包解决此预测问题的优缺点。
根据表 8.2 中的信息,看起来Prophet库是一个不错的选择,并在预测能力、所需的时间序列能力和团队中的开发人员和科学家的经验之间提供了一个良好的平衡。
数据科学家可以使用这些信息构建一个概念验证,代码类似于第一章,机器学习工程简介中的示例 2:预测 API部分,该部分将 Prophet 应用于标准零售数据集。
这涵盖了我们将用于建模的机器学习包,但其他组件怎么办?我们需要构建一个允许前端应用程序请求后端执行操作的东西,因此考虑某种类型的 Web 应用程序框架是个好主意。我们还需要考虑当后端应用程序受到大量请求时会发生什么,因此有意识地构建它以考虑可扩展性是有意义的。另一个考虑因素是我们在这个用例中不仅要训练一个模型,而是要训练多个模型,每个零售店一个,因此我们应该尽可能并行化训练。最后一块拼图将是使用模型管理工具和需要编排层来按计划或动态触发训练和监控作业。
将所有这些内容综合起来,我们可以在使用 Prophet 库的基础上做出一些关于底层工具的设计决策。以下是一个总结列表:
-
Prophet:我们在第一章,机器学习工程简介中遇到了 Prophet 预测库。在这里,我们将深入了解该库及其工作原理,然后再开发一个训练流程来创建我们在第一章中为该零售用例看到的预测模型类型。
-
Kubernetes:如第六章,扩展中讨论的,这是一个在计算集群中编排多个容器的平台,允许你构建高度可扩展的机器学习模型服务解决方案。我们将使用它来托管主要应用程序。
-
Ray Train:我们在第六章,扩展中已经遇到了 Ray。在这里,我们将使用 Ray Train 并行训练许多不同的 Prophet 预测模型,并允许这些作业在向处理传入请求的主要网络服务发出请求时触发。
-
MLflow:我们在第三章,从模型到模型工厂中遇到了 MLflow,它将作为我们的模型注册库。
-
FastAPI:对于 Python,典型的后端网络框架通常是 Django、Flask 和 FastAPI。我们将使用 FastAPI 创建主要后端路由应用程序,该应用程序将提供预测并与其他解决方案组件交互。FastAPI 是一个设计用于简单使用和构建高性能网络应用的 Web 框架,目前被一些知名组织使用,包括 Uber、Microsoft 和 Netflix(根据 FastAPI 主页信息)。
最近关于使用 FastAPI 时可能出现的内存泄漏问题有一些讨论,尤其是对于长时间运行的服务。这意味着确保运行 FastAPI 端点的机器有足够的 RAM 非常重要。在许多情况下,这似乎不是一个关键问题,但在 FastAPI 社区中是一个活跃的讨论话题。更多关于这个话题的信息,请参阅github.com/tiangolo/fastapi/discussions/9082。其他框架,如Litestarlitestar.dev/,似乎没有相同的问题,所以你可以自由地尝试不同的网络框架来构建以下示例和你的项目中的服务层。FastAPI 仍然是一个非常有用的框架,具有许多优点,所以我们将在本章中继续使用它;只是要记住这个要点。
在本章中,我们将关注与大规模服务模型相关的系统组件,因为计划训练和重新训练方面将在第九章,构建提取、转换、机器学习用例中介绍。我们关注的组件可以被认为是我们的“服务层”,尽管我会向你展示如何使用 Ray 并行训练多个预测模型。
现在我们已经做出了一些工具选择,让我们开始构建我们的机器学习微服务吧!
规模化训练
当我们在第六章“扩展”中介绍 Ray 时,我们提到了一些用例,其中数据或处理时间需求如此之大,以至于使用一个非常可扩展的并行计算框架是有意义的。没有明确指出的是,有时这些需求来自我们实际上想要训练许多模型的事实,而不仅仅是大量数据上的一个模型或更快地训练一个模型。这正是我们将在这里做的事情。
我们在第一章“机器学习工程简介”中描述的零售预测示例使用了一个包含多个不同零售店的数据集。与其创建一个可能包含店铺编号或标识符作为特征的模型,也许更好的策略是为每个单独的店铺训练一个预测模型。这可能会提供更好的准确性,因为店铺级别的数据特征可能具有一些预测能力,不会被查看所有店铺组合的模型所平均。因此,我们将采取这种方法,这也是我们可以使用 Ray 的并行性同时训练多个预测模型的地方。
要使用Ray来完成这个任务,我们需要将我们在第一章中提到的训练代码稍作修改。首先,我们可以将用于预处理数据和训练预测模型的函数组合在一起。这样做意味着我们正在创建一个可以分发给运行在每个存储对应的数据分片上的串行进程。原始的预处理和训练模型函数如下:
import ray
import ray.data
import pandas as pd
from prophet import Prophet
def prep_store_data(
df: pd.DataFrame,
store_id: int = 4,
store_open: int = 1
) -> pd.DataFrame:
df_store = df[
(df['Store'] == store_id) &\
(df['Open'] == store_open)
].reset_index(drop=True)
df_store['Date'] = pd.to_datetime(df_store['Date'])
df_store.rename(columns= {'Date': 'ds', 'Sales': 'y'}, inplace=True)
return df_store.sort_values('ds', ascending=True)
def train_predict(
df: pd.DataFrame,
train_fraction: float,
seasonality: dict
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, int]:
# grab split data
train_index = int(train_fraction*df.shape[0])
df_train = df.copy().iloc[0:train_index]
df_test = df.copy().iloc[train_index:]
#create Prophet model
model=Prophet(
yearly_seasonality=seasonality['yearly'],
weekly_seasonality=seasonality['weekly'],
daily_seasonality=seasonality['daily'],
interval_width = 0.95
)
# train and predict
model.fit(df_train)
predicted = model.predict(df_test)
return predicted, df_train, df_test, train_index
现在我们可以将这些合并成一个单独的函数,该函数将接受一个pandas DataFrame,预处理这些数据,训练一个 Prophet 预测模型,然后返回测试集、训练数据集、测试数据集和训练集大小的预测,这里用train_index值标记。由于我们希望分发此函数的应用,我们需要使用我们在第六章“扩展”中介绍的@ray.remote装饰器。我们将num_returns=4参数传递给装饰器,让 Ray 知道这个函数将以元组的形式返回四个值。
@ray.remote(num_returns=4)
def prep_train_predict(
df: pd.DataFrame,
store_id: int,
store_open: int=1,
train_fraction: float=0.8,
seasonality: dict={'yearly': True, 'weekly': True, 'daily': False}
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, int]:
df = prep_store_data(df, store_id=store_id, store_open=store_open)
return train_predict(df, train_fraction, seasonality)
现在我们有了远程函数,我们只需要应用它。首先,我们假设数据集已经以与第一章,机器学习工程介绍中相同的方式读入到一个pandas DataFrame 中。这里的假设是数据集足够小,可以放入内存,并且不需要计算密集型的转换。这有一个优点,就是允许我们使用pandas相对智能的数据摄入逻辑,例如,可以对标题行进行各种格式化,以及在我们现在熟悉的pandas语法中使用之前,应用任何我们想要的过滤或转换逻辑。如果数据集更大或转换更密集,我们就可以使用 Ray API 中的ray.data.read_csv()方法来读取数据作为 Ray Dataset。这会将数据读入到 Arrow 数据格式中,它有自己的数据操作语法。
现在,我们已经准备好应用我们的分布式训练和测试。首先,我们可以从数据集中检索所有存储标识符,因为我们将为每一个训练一个模型。
store_ids = df['Store'].unique()
在我们做任何事情之前,我们将使用我们在第六章,扩展规模中遇到的ray.init()命令初始化 Ray 集群。这避免了在我们第一次调用远程函数时执行初始化,这意味着如果我们进行基准测试,我们可以获得实际处理的准确时间。为了提高性能,我们还可以使用ray.put()将 pandas DataFrame 存储在 Ray 对象存储中。这阻止了每次运行任务时都复制此数据集。将对象放入存储返回一个 id,然后你可以像原始对象一样将其用作函数参数。
ray.init(num_cpus=4)
df_id = ray.put(df)
现在,我们需要将我们的 Ray 任务提交到集群。每次你这样做时,都会返回一个 Ray 对象引用,这将允许我们在使用ray.get收集结果时检索该进程的数据。我在这里使用的语法可能看起来有点复杂,但我们可以一点一点地分解它。核心 Python 函数map只是将列表操作应用于zip语法的输出结果的所有元素。zip(*iterable)模式允许我们将列表推导式中的所有元素解包,这样我们就可以有一个包含预测对象引用、训练数据对象引用、测试数据对象引用以及最终的训练索引对象引用的列表。注意使用df_id来引用对象存储中的存储数据框。
pred_obj_refs, train_obj_refs, test_obj_refs, train_index_obj_refs = map(
list,
zip(*([prep_train_predict.remote(df_id, store_id) for store_id in store_ids])),
)
然后,我们需要获取这些任务的实际结果,这可以通过使用前面讨论的ray.get()来实现。
ray_results = {
'predictions': ray.get(pred_obj_refs),
'train_data': ray.get(train_obj_refs),
'test_data': ray.get(test_obj_refs),
'train_indices': ray.get(train_index_obj_refs)
}
然后,你可以使用ray_results['predictions'][<index>]等来访问每个模型的这些值。
在 Github 仓库中,文件 Chapter08/train/train_forecasters_ray.py 运行此语法并示例循环,逐个以串行方式训练 Prophet 模型以进行比较。使用 time 库进行测量,并在我的 Macbook 上运行实验,Ray 集群利用了四个 CPU,我仅用不到 40 秒就能用 Ray 训练 1,115 个 Prophet 模型,而使用串行代码则需要大约 3 分 50 秒。这几乎提高了六倍的速度,而且几乎没有进行多少优化!
我们没有涵盖将模型和元数据保存到 MLFlow 的内容,你可以使用我们在 第三章 中深入讨论的语法来完成。为了避免大量的通信开销,最好是将元数据临时存储为训练过程的结果,就像我们在存储预测的字典中做的那样,然后在最后将所有内容写入 MLFlow。这意味着你不会因为与 MLFlow 服务器的通信而减慢 Ray 进程。注意,我们还可以通过使用讨论过的 Ray Dataset API 并更改转换逻辑以使用 Arrow 语法来进一步优化这种并行处理。最后一个选择也可以是使用 Modin,之前被称为 Pandas on Ray,它允许你在利用 Ray 并行性的同时使用 pandas 语法。
现在我们开始构建我们解决方案的提供层,这样我们就可以使用这些预测模型为其他系统和用户生成结果。
使用 FastAPI 提供模型
在 Python 中,以微服务形式提供 ML 模型的最简单且可能最灵活的方法是将提供逻辑包装在一个轻量级 Web 应用程序中。Flask 多年来一直是 Python 用户中流行的选择,但现在 FastAPI Web 框架有许多优势,这意味着它应该被认真考虑作为更好的替代方案。
使 FastAPI 成为轻量级微服务优秀选择的某些特性包括:
-
数据验证:FastAPI 使用并基于 Pydantic 库,该库允许你在运行时强制执行类型提示。这允许你实现非常容易创建的数据验证步骤,使你的系统更加健壮,并有助于避免边缘情况的行为。
-
内置的异步工作流程:FastAPI 通过
async和await关键字提供开箱即用的异步任务管理,因此你可以在许多情况下相对无缝地构建所需的逻辑,而无需求助于额外的库。 -
开放规范:FastAPI 基于几个开源标准,包括 OpenAPI REST API 标准 和 JSON Schema 声明性语言,这有助于创建自动数据模型文档。这些规范有助于保持 FastAPI 的工作方式透明,并且非常易于使用。
-
自动文档生成: 上一点提到了数据模型,但 FastAPI 还使用 SwaggerUI 自动生成整个服务的文档。
-
性能: 快速是它的名字!FastAPI 使用了 异步服务器网关接口 (ASGI) 标准,而其他框架如 Flask 则使用 Web 服务器网关接口 (WSGI)。ASGI 可以在单位时间内处理更多的请求,并且效率更高,因为它可以在等待前一个任务完成之前执行任务。WSGI 接口按顺序执行指定的任务,因此处理请求需要更长的时间。
因此,上述内容是为什么使用 FastAPI 来提供本例中的预测模型可能是一个好主意的原因,但我们该如何着手去做呢?这正是我们现在要讨论的。
任何微服务都必须以某种指定的格式接收数据;这被称为“请求”。然后它将返回数据,称为“响应”。微服务的任务是摄取请求,执行请求定义或提供输入的一系列任务,创建适当的输出,然后将该输出转换为指定的请求格式。这看起来可能很基础,但回顾这一点很重要,它为我们设计系统提供了起点。很明显,在设计时,我们必须考虑以下要点:
-
请求和响应模式: 由于我们将构建一个 REST API,因此自然地,我们将指定请求和响应的数据模型,作为具有相关模式的 JSON 对象。在执行此操作时,关键是使模式尽可能简单,并且它们包含客户端(请求服务)和服务器(微服务)执行适当操作所需的所有必要信息。由于我们正在构建一个预测服务,请求对象必须提供足够的信息,以便系统提供适当的预测,上游调用服务的解决方案可以将其展示给用户或执行进一步的逻辑。响应将必须包含实际的预测数据点或指向预测位置的指针。
-
计算: 在本例中,创建响应对象(在这种情况下,是一个预测),需要计算,正如在 第一章,机器学习工程导论 中所讨论的。
设计机器学习微服务时的一个关键考虑因素是计算资源的大小以及执行它所需的适当工具。例如,如果你正在运行一个需要大型 GPU 才能进行推理的计算机视觉模型,你不能在只运行 CPU 的小型服务器上这样做,该服务器运行的是网络应用程序的后端。同样,如果推理步骤需要摄取一个 TB 的数据,这可能需要我们使用像 Spark 或 Ray 这样的并行化框架,在专用集群上运行,根据定义,它将不得不在不同的机器上运行,而不是运行服务网络应用程序的机器。如果计算需求足够小,并且从另一个位置获取数据不是太激烈,那么你可能在同一台机器上运行推理,该机器托管着网络应用程序。
-
模型管理:这是一个机器学习服务,所以当然涉及模型!这意味着,正如我们在第三章中详细讨论的,从模型到模型工厂,我们需要实施一个健壮的过程来管理适当的模型版本。这个示例的要求还意味着我们必须能够以相对动态的方式利用许多不同的模型。这需要我们仔细考虑,并使用像 MLflow 这样的模型管理工具,我们也在第三章中提到过。我们还必须考虑我们的模型更新和回滚策略;例如,我们将使用蓝/绿部署还是金丝雀部署,正如我们在第五章,部署模式和工具中讨论的那样。
-
性能监控:对于任何机器学习系统,正如我们在整本书中详细讨论的那样,监控模型的性能将至关重要,采取适当的行动来更新或回滚这些模型也同样重要。如果任何推理的真实数据不能立即返回给服务,那么这需要它自己的过程来收集真实数据和推理,然后再对它们进行所需的计算。
这些是我们构建解决方案时必须考虑的一些重要点。在本章中,我们将重点关注第 1 点和第 3 点,因为第九章将涵盖如何在批量设置中构建训练和监控系统。既然我们已经知道了一些我们想要纳入解决方案的因素,那么让我们开始动手构建吧!
响应和请求模式
如果客户端请求特定商店的预测,正如我们在需求中假设的那样,这意味着请求应该指定一些内容。首先,它应该指定商店,使用某种类型的商店标识符,该标识符将在机器学习微服务的数据模型和客户端应用程序之间保持通用。
其次,预测的时间范围应以适当的格式提供,以便应用程序可以轻松解释并提供服务。系统还应具备逻辑来创建适当的预测时间窗口,如果请求中没有提供,这是完全合理的假设,如果客户端请求“为商店 X 提供预测”,那么我们可以假设一些默认行为,提供从现在到未来的某个时间段的预测将可能对客户端应用程序有用。
满足这一点的最简单的请求 JSON 架构可能如下所示:
{
"storeId": "4",
"beginDate": "2023-03-01T00:00:00Z",
"endDate": "2023-03-07T00:00:00Z"
}
由于这是一个 JSON 对象,所有字段都是字符串类型,但它们填充了在我们 Python 应用程序中易于解释的值。Pydantic 库还将帮助我们执行数据验证,这一点我们稍后将会讨论。请注意,我们还应该允许客户端应用程序请求多个预测,因此我们应该允许这个 JSON 扩展以允许请求对象的列表:
[
{
"storeId": "2",
"beginDate": "2023-03-01T00:00:00Z",
"endDate": "2023-03-07T00:00:00Z"
},
{
"storeId": "4",
"beginDate": "2023-03-01T00:00:00Z",
"endDate": "2023-03-07T00:00:00Z"
}
]
如前所述,我们希望构建我们的应用程序逻辑,以便即使客户端只指定了store_id,系统仍然可以工作,然后我们推断适当的预测时间范围是从现在到未来的某个时间。
这意味着我们的应用程序应该在以下内容作为 API 调用的 JSON 主体提交时工作:
[
{
"storeId": "4",
}
]
为了强制执行这些请求约束,我们可以使用 Pydantic 功能,通过从 Pydantic 的BaseModel继承并创建一个数据类来定义我们刚刚做出的类型要求:
from pydantic import BaseModel
class ForecastRequest(BaseModel):
store_id: str
begin_date: str | None = None
end_date: str | None = None
如您所见,我们在这里强制执行了store_id是一个字符串,但我们允许预测的开始和结束日期可以给出为None。如果没有指定日期,我们可以根据我们的业务知识做出合理的假设,即一个有用的预测时间窗口将从请求的日期时间开始,到现在的七天。这可能是在应用程序配置中更改或提供的东西,我们在这里不会处理这个特定的方面,以便专注于更令人兴奋的内容,所以这留给读者作为有趣的练习!
在我们的案例中,预测模型将基于 Prophet 库,如前所述,这需要一个包含预测运行所需日期时间的索引。为了根据请求生成这个索引,我们可以编写一个简单的辅助函数:
import pandas as pd
def create_forecast_index(begin_date: str = None, end_date: str = None):
# Convert forecast begin date
if begin_date == None:
begin_date = datetime.datetime.now().replace(tzinfo=None)
else:
begin_date = datetime.datetime.strptime(begin_date,
'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=None)
# Convert forecast end date
if end_date == None:
end_date = begin_date + datetime.timedelta(days=7)
else:
end_date = datetime.datetime.strptime(end_date,
'%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=None)
return pd.date_range(start = begin_date, end = end_date, freq = 'D')
这种逻辑允许我们在从模型存储层检索到输入后创建预测模型,在我们的例子中,是 MLflow。
响应对象必须以某种数据格式返回预测,并且始终必须返回足够的信息,以便客户端应用程序能够方便地将返回的对象与触发其创建的响应关联起来。满足这一点的简单模式可能如下所示:
[
{
"request": {
"store_id": "4",
"begin_date": "2023-03-01T00:00:00Z",
"end_date": "2023-03-07T00:00:00Z"
},
"forecast": [
{
"timestamp": "2023-03-01T00:00:00",
"value": 20716
},
{
"timestamp": "2023-03-02T00:00:00",
"value": 20816
},
{
"timestamp": "2023-03-03T00:00:00",
"value": 21228
},
{
"timestamp": "2023-03-04T00:00:00",
"value": 21829
},
{
"timestamp": "2023-03-05T00:00:00",
"value": 21686
},
{
"timestamp": "2023-03-06T00:00:00",
"value": 22696
},
{
"timestamp": "2023-03-07T00:00:00",
"value": 21138
}
]
}
]
我们将允许以与请求 JSON 模式相同的方式将其扩展为列表。我们将在本章的其余部分使用这些模式。现在,让我们看看我们将如何管理应用程序中的模型。
在您的微服务中管理模型
在第三章从模型到模型工厂中,我们详细讨论了您如何使用 MLflow 作为模型工件和元数据存储层在您的 ML 系统中。我们在这里也将这样做,所以假设您已经有一个运行的 MLflow Tracking 服务器,然后我们只需要定义与它交互的逻辑。如果您需要复习,请随时回顾第三章。
我们需要编写一些逻辑来完成以下操作:
-
检查在 MLflow 服务器中有可用于生产的模型。
-
检索满足我们设定的任何标准的模型版本,例如,模型不是在超过一定天数前训练的,并且它在所选范围内有验证指标。
-
如果在预测会话期间需要,可以缓存模型以供使用和重复使用。
-
如果响应对象需要,对多个模型执行上述所有操作。
对于第 1 点,我们必须在 MLflow 模型注册表中标记模型为已准备好生产,然后我们可以使用在第三章从模型到模型工厂中遇到的MlflowClient()和mlflow pyfunc功能:
import mlflow
import mlflow.pyfunc
from mlflow.client import MlflowClient
import os
tracking_uri = os.getenv(["MLFLOW_TRACKING_URI"])
mlflow.set_tracking_uri(tracking_uri)
client = MlflowClient(tracking_uri=tracking_uri)
def get_production_model(store_id:int):
model_name = f"prophet-retail-forecaster-store-{store_id}"
model =mlflow.pyfunc.load_model(
model_uri=f"models:/{model_name}/production"
)
return model
对于第 2 点,我们可以通过使用下面将要描述的 MLflow 功能来检索给定模型的指标。首先,使用模型的名称,您检索模型的元数据:
model_name = f"prophet-retail-forecaster-store-{store_id}"
latest_versions_metadata = client.get_latest_versions(
name=model_name
)
这将返回一个如下所示的数据集:
[<ModelVersion: creation_timestamp=1681378913710, current_stage='Production', description='', last_updated_timestamp=1681378913722, name='prophet-retail-forecaster-store-3', run_id='538c1cbded614598a1cb53eebe3de9f2', run_link='', source='/Users/apmcm/
dev/Machine-Learning-Engineering-with-Python-Second-Edition/Chapter07/register/artifacts/0/538c1cbded614598a1cb53eebe3de9f2/artifacts/model', status='READY', status_message='', tags={}, user_id='', version='3'>]
然后,您可以使用这些数据通过此对象检索版本,然后检索模型版本元数据:
latest_model_version_metadata = client.get_model_version(
name=model_name,
version=latest_versions_metadata.version
)
这包含看起来像这样的元数据:
<ModelVersion: creation_timestamp=1681377954142, current_stage='Production', description='', last_updated_timestamp=1681377954159, name='prophet-retail-forecaster-store-3', run_id='41f163b0a6af4b63852d9218bf07adb3', run_link='', source='/Users/apmcm/dev/Machine-Learning-Engineering-with-Python-Second-Edition/Chapter07/register/artifacts/0/41f163b0a6af4b63852d9218bf07adb3/artifacts/model', status='READY', status_message='', tags={}, user_id='', version='1'>
该模型版本的指标信息与run_id相关联,因此我们需要获取它:
latest_model_run_id = latest_model_version_metadata.run_id
run_id的值可能如下所示:
'41f163b0a6af4b63852d9218bf07adb3'
然后,您可以使用这些信息来获取特定运行的模型指标,并在其上执行任何您想要的逻辑。要检索指标值,您可以使用以下语法:
client.get_metric_history(run_id=latest_model_run_id, key='rmse')
例如,您可以使用在第二章持续模型性能测试部分中应用的逻辑,并简单地要求均方根误差低于某个指定的值,然后才允许它在预测服务中使用。
我们还可能希望允许服务在模型年龄超出容忍度时触发重新训练;这可以作为任何已实施的训练系统之上的另一层模型管理。
如果我们的训练过程由运行在 AWS MWAA 上的 Airflow DAG 编排,正如我们在第五章部署模式和工具中讨论的那样,那么以下代码可以用来调用训练管道:
import boto3
import http.client
import base64
import ast
# mwaa_env_name = 'YOUR_ENVIRONMENT_NAME'
# dag_name = 'YOUR_DAG_NAME'
def trigger_dag(mwaa_env_name: str, dag_name: str) -> str:
client = boto3.client('mwaa')
# get web token
mwaa_cli_token = client.create_cli_token(
Name=mwaa_env_name
)
conn = http.client.HTTPSConnection(
mwaa_cli_token['WebServerHostname']
)
mwaa_cli_command = 'dags trigger'
payload = mwaa_cli_command + " " + dag_name
headers = {
'Authorization': 'Bearer ' + mwaa_cli_token['CliToken'],
'Content-Type': 'text/plain'
}
conn.request("POST", "/aws_mwaa/cli", payload, headers)
res = conn.getresponse()
data = res.read()
dict_str = data.decode("UTF-8")
mydata = ast.literal_eval(dict_str)
return base64.b64decode(mydata['stdout']).decode('ascii')
下几节将概述如何将这些组件组合在一起,以便 FastAPI 服务可以在讨论如何容器化和部署应用程序之前,围绕这些逻辑的几个部分进行包装。
将所有这些整合在一起
我们已经成功定义了我们的请求和响应模式,并且我们已经编写了从我们的模型存储库中提取适当模型的相关逻辑;现在剩下的只是将这些整合在一起,并使用模型进行实际推理。这里有几个步骤,我们将现在分解。FastAPI 后端的主要文件名为 app.py,其中包含几个不同的应用程序路由。对于本章的其余部分,我将在每个相关代码片段之前展示必要的导入,但实际的文件遵循 PEP8 规范,即导入位于文件顶部。
首先,我们定义我们的日志记录器,并设置一些全局变量作为检索到的模型和服务处理程序的轻量级内存缓存:
# Logging
import logging
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
logging.basicConfig(format = log_format, level = logging.INFO)
handlers = {}
models = {}
MODEL_BASE_NAME = f"prophet-retail-forecaster-store-"
使用全局变量在应用程序路由之间传递对象只是一种好主意,如果你知道这个应用程序将以独立方式运行,并且不会因为同时接收来自多个客户端的请求而创建竞争条件。当这种情况发生时,多个进程会尝试覆盖变量。你可以将这个例子改编一下,用缓存如 Redis 或 Memcache 来替换全局变量的使用,作为练习!
我们接下来需要实例化一个 FastAPI 应用程序对象,并且可以通过使用启动生命周期事件方法来定义我们希望在启动时运行的任何逻辑:
from fastapi import FastAPI
from registry.mlflow.handler import MLFlowHandler
app = FastAPI()
@app.on_event("startup")
async def startup():
await get_service_handlers()
logging.info("Updated global service handlers")
async def get_service_handlers():
mlflow_handler = MLFlowHandler()
global handlers
handlers['mlflow'] = mlflow_handler
logging.info("Retreving mlflow handler {}".format(mlflow_handler))
return handlers
如前所述,FastAPI 非常适合支持异步工作流程,允许在等待其他任务完成时使用计算资源。服务处理程序的实例化可能是一个较慢的过程,因此在这里采用这可能是有用的。当调用使用 async 关键字的函数时,我们需要使用 await 关键字,这意味着在调用 async 函数的函数中,其余部分可以暂停,直到返回结果并释放用于其他任务的任务资源。在这里,我们只有一个处理程序需要实例化,它将处理与 MLflow 跟踪服务器的连接。
registry.mlflow.handler 模块是我编写的一个包含 MLFlowHandler 类的模块,其中包含我们将在整个应用程序中使用的各种方法。以下是该模块的内容:
import mlflow
from mlflow.client import MlflowClient
from mlflow.pyfunc import PyFuncModel
import os
class MLFlowHandler:
def __init__(self) -> None:
tracking_uri = os.getenv('MLFLOW_TRACKING_URI')
self.client = MlflowClient(tracking_uri=tracking_uri)
mlflow.set_tracking_uri(tracking_uri)
def check_mlflow_health(self) -> None:
try:
experiments = self.client.search_experiments()
return 'Service returning experiments'
except:
return 'Error calling MLFlow'
def get_production_model(self, store_id: str) -> PyFuncModel:
model_name = f"prophet-retail-forecaster-store-{store_id}"
model = mlflow.pyfunc.load_model(
model_uri=f"models:/{model_name}/production"
)
return model
如您所见,此处理程序具有检查 MLflow 跟踪服务器是否正常运行并获取生产模型的方法。您还可以添加用于查询 MLflow API 以收集我们之前提到的度量数据的方法。
现在回到主要的 app.py 文件,我编写了一个小的健康检查端点来获取服务的状态:
@app.get("/health/", status_code=200)
async def healthcheck():
global handlers
logging.info("Got handlers in healthcheck.")
return {
"serviceStatus": "OK",
"modelTrackingHealth": handlers['mlflow'].check_mlflow_health()
}
接下来是一个获取特定零售店 ID 的生产模型的方法。此函数检查模型是否已存在于 global 变量中(作为简单的缓存),如果不存在,则添加它。您可以将此方法扩展到包括关于模型年龄或您想要使用的任何其他指标的逻辑,以决定是否将模型拉入应用程序:
async def get_model(store_id: str):
global handlers
global models
model_name = MODEL_BASE_NAME + f"{store_id}"
if model_name not in models:
models[model_name] = handlers['mlflow'].\
get_production_model(store_id=store_id)
return models[model_name]
最后,我们有预测端点,客户端可以使用我们之前定义的请求对象向此应用程序发起请求,并基于我们从 MLflow 获取的 Prophet 模型获得预测。就像本书的其他部分一样,为了简洁,我省略了较长的注释:
@app.post("/forecast/", status_code=200)
async def return_forecast(forecast_request: List[ForecastRequest]):
forecasts = []
for item in forecast_request:
model = await get_model(item.store_id)
forecast_input = create_forecast_index(
begin_date=item.begin_date,
end_date=item.end_date
)
forecast_result = {}
forecast_result['request'] = item.dict()
model_prediction = model.predict(forecast_input)[['ds', 'yhat']]\
.rename(columns={'ds': 'timestamp', 'yhat': 'value'})
model_prediction['value'] = model_prediction['value'].astype(int)
forecast_result['forecast'] = model_prediction.to_dict('records')
forecasts.append(forecast_result)
return forecasts
然后,您可以在本地运行应用程序:
uvicorn app:app –-host 127.0.0.1 --port 8000
如果您想在不运行应用程序的情况下开发应用程序,可以添加 –reload 标志。如果您使用 Postman(或 curl 或您选择的任何其他工具)并使用我们之前描述的请求体查询此端点,如 图 8.2 所示,您将得到类似 图 8.3 中所示的输出。

图 8.2:Postman 应用中对 ML 微服务的请求。

图 8.3:使用 Postman 查询时 ML 微服务的响应。
就这样,我们得到了一个相对简单的机器学习微服务,当查询端点时,它将返回零售店的 Prophet 模型预测!现在,我们将继续讨论如何将此应用程序容器化并部署到 Kubernetes 集群以实现可扩展的服务。
容器化和部署到 Kubernetes
当我们在 第五章 中介绍 Docker 时,部署模式和工具,我们展示了如何使用它来封装您的代码,然后在许多不同的平台上一致地运行它。
在这里,我们将再次执行此操作,但带着这样的想法:我们不仅想在不同的基础设施上以单例模式运行应用程序,实际上我们希望允许许多不同的微服务副本同时运行,并且通过负载均衡器有效地路由请求。这意味着我们可以将可行的方法扩展到几乎任意大的规模。
我们将通过执行以下步骤来完成这项工作:
-
使用 Docker 容器化应用程序。
-
将此 Docker 容器推送到 Docker Hub,作为我们的容器存储位置(您可以使用 AWS Elastic Container Registry 或其他云服务提供商的类似解决方案来完成此步骤)。
-
创建一个 Kubernetes 集群。我们将使用 minikube 在本地执行此操作,但您也可以在云服务提供商上使用其管理的 Kubernetes 服务来完成此操作。在 AWS 上,这是 弹性 Kubernetes 服务(EKS)。
-
在可以扩展的集群上定义一个服务和负载均衡器。在这里,我们将介绍在 Kubernetes 集群上通过程序定义服务和部署特性的概念。
-
部署服务并测试其是否按预期工作。
让我们现在进入下一节,详细说明这些步骤。
容器化应用程序
如本书前面所述,如果我们想使用 Docker,我们需要在 Dockerfile 中提供如何构建容器以及安装任何必要的依赖项的说明。对于这个应用程序,我们可以使用基于可用的 FastAPI 容器镜像之一的一个,假设我们有一个名为 requirements.txt 的文件,其中包含我们所有的 Python 包依赖项:
FROM tiangolo/uvicorn-gunicorn-fastapi:latest
COPY ./requirements.txt requirements.txt
RUN pip install --no-cache-dir --upgrade -r requirements.txt
COPY ./app /app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
然后,我们可以使用以下命令构建这个 Docker 容器,其中我已将容器命名为 custom-forecast-service:
docker build -t custom-forecast-service:latest .
一旦成功构建,我们需要将其推送到 Docker Hub。您可以在终端中登录 Docker Hub,然后通过运行以下命令将内容推送到您的账户:
docker login
docker push <DOCKER_USERNAME>/custom-forecast-service:latest
这意味着其他构建过程或解决方案可以下载并运行您的容器。
注意,在您将内容推送到 Docker Hub 之前,您可以通过执行以下类似命令来测试容器化应用程序是否可以运行,其中我包含了一个平台标志,以便在我的 MacBook Pro 上本地运行容器:
docker run -d --platform linux/amd64 -p 8000:8080 electricweegie/custom-forecast-service
现在我们已经构建并分享了容器,我们可以通过部署到 Kubernetes 来扩展它。
使用 Kubernetes 扩展
与 Kubernetes 一起工作可能对即使是经验最丰富的开发者来说也是一个陡峭的学习曲线,所以我们在这里只会触及表面,并为您提供足够的资源,让您开始自己的学习之旅。本节将指导您完成将您的 ML 微服务部署到本地运行的 Kubernetes 集群的步骤,因为部署到远程托管集群(进行一些小的修改)需要采取相同的步骤。在生产环境中无缝运行 Kubernetes 集群需要考虑网络、集群资源配置和管理、安全策略等多个方面。详细研究所有这些主题需要一本完整的书。实际上,Aly Saleh 和 Murat Karsioglu 的《Kubernetes in Production Best Practices》是一本很好的资源,可以帮助您了解许多这些细节。在本章中,我们将专注于理解您开始使用 Kubernetes 开发 ML 微服务所需的最重要步骤。
首先,让我们为 Kubernetes 开发做好准备。在这里,我将使用 minikube,因为它有一些方便的实用工具,可以设置可以通过 REST API 调用服务。在这本书的先前部分,我使用了 kind(在 Docker 中运行的 Kubernetes),您也可以在这里使用它;只需准备好做一些额外的工作并使用文档。
要在你的机器上设置 minikube,请遵循官方文档中针对您平台的安装指南,链接为 minikube.sigs.k8s.io/docs/start/。
一旦安装了 minikube,您可以使用默认配置启动您的第一个集群,命令如下:
minikube start
一旦集群启动并运行,您可以在终端中使用以下命令将 fast-api 服务部署到集群:
kubectl apply –f direct-kube-deploy.yaml
其中 direct-kube-deploy.yaml 是一个包含以下代码的清单:
apiVersion: apps/v1
kind: Deployment
metadata:
name: fast-api-deployment
spec:
replicas: 2
selector:
matchLabels:
app: fast-api
template:
metadata:
labels:
app: fast-api
spec:
containers:
- name: fast-api
image: electricweegie/custom-forecast-service:latest
resources:
limits:
memory: "128Mi"
cpu: "500m"
ports:
- containerPort: 8000
本清单定义了一个 Kubernetes Deployment,该 Deployment 创建并管理包含一个名为 fast-api 的容器的 Pod 模板的两个副本。这个容器运行的是我们之前创建并发布的 Docker 镜像,即 electricweegie/custom-forecast-service:latest。它还定义了运行在 Pod 内部容器上的资源限制,并确保容器监听端口 8000。
现在我们已经创建了一个包含应用程序的 Deployment,我们需要将此解决方案暴露给传入流量,最好是使用负载均衡器,以便高效地将传入流量路由到应用程序的不同副本。要在 minikube 中完成此操作,你必须执行以下步骤:
-
默认情况下,minikube 集群上运行的服务不提供网络或主机机访问,因此我们必须使用
tunnel命令创建一个路由来公开集群 IP 地址:minkube tunnel -
打开一个新的终端窗口。这允许隧道持续运行,然后你需要创建一个类型为
LoadBalancer的 Kubernetes 服务,该服务将访问我们已设置的deployment:kubectl expose deployment fast-api-deployment --type=LoadBalancer --port=8080 -
你可以通过运行以下命令来获取访问服务的公网 IP:
kubectl get svc这应该会给出类似以下输出的结果:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE fast-api-deployment LoadBalancer 10.96.184.178 10.96.184.178 8080:30791/TCP 59s
然后,你将能够使用负载均衡器服务的 EXTERNAL-IP 来访问 API,因此你可以导航到 Postman 或你的其他 API 开发工具,并使用 http://<EXTERNAL-IP>:8080 作为你成功构建并部署到 Kubernetes 的 FastAPI 服务的根 URL!
部署策略
如第五章部署模式和工具中所述,你可以使用几种不同的策略来部署和更新你的 ML 服务。这包括两个组件:一个是模型的部署策略,另一个是托管应用程序或为模型提供服务的管道的部署策略。这两个策略可以同时执行。
在这里,我们将讨论如何将我们刚刚部署到 Kubernetes 的应用程序更新,并使用金丝雀和蓝绿部署策略。一旦你学会了如何对基础应用程序进行此操作,可以通过在金丝雀或蓝绿部署中指定一个具有适当标签的模型版本,来添加对模型的类似更新策略。例如,我们可以使用 MLflow 中模型注册表的“staging”阶段来提供我们的“蓝”模型,然后在过渡到“绿色”时,确保我们已经使用本章和第三章从模型到模型工厂中概述的语法,将此模型移动到模型注册表的“生产”阶段。
由于金丝雀部署是在生产环境的一个较小子集中部署应用程序的新版本,我们可以创建一个新的部署清单,强制只创建和运行一个金丝雀应用的副本(在较大的集群中可能更多)。在这种情况下,这只需要你编辑之前的副本数量为“1。”
为了确保金丝雀部署可以访问相同的负载均衡器,我们必须利用 Kubernetes 中的资源标签概念。然后我们可以部署一个选择具有所需标签的资源负载均衡器。以下是一个部署此类负载均衡器的示例清单:
apiVersion: v1
kind: Service
metadata:
name: fast-api-service
spec:
selector:
app: fast-api
ports:
- protocol: TCP
port: 8000
targetPort: 8000
type: LoadBalancer
或者使用与上面相同的 minkube 语法:
kubectl expose deployment fast-api-deployment --name=fast-api-service --type=LoadBalancer --port=8000 --target-port=8000 --selector=app=fast-api
在部署此负载均衡器和金丝雀部署之后,你可以然后实现集群或模型上的日志监控,以确定金丝雀是否成功并且应该获得更多流量。在这种情况下,你只需更新部署清单以包含更多副本。
蓝绿部署将以非常相似的方式工作;在每种情况下,你只需编辑 Deployment 清单,将应用程序标记为蓝色或绿色。然而,蓝绿部署与金丝雀部署的核心区别在于流量的切换更为突然,在这里我们可以使用以下命令,该命令使用kubectl CLI 来修补服务选择器的定义,将生产流量切换到绿色部署:
kubectl patch service fast-api-service -p '{"spec":{"selector":{"app":"fast-api-green"}}}'
这就是你在 Kubernetes 中执行金丝雀和蓝/绿部署的方式,以及如何使用它来尝试不同的预测服务版本;试试看吧!
摘要
在本章中,我们通过一个示例展示了如何将本书前七章中的工具和技术结合起来,以解决一个实际业务问题。我们详细讨论了为什么对动态触发的预测算法的需求可以迅速导致需要多个小型服务无缝交互的设计。特别是,我们创建了一个包含处理事件、训练模型、存储模型和执行预测的组件的设计。然后,我们通过考虑诸如任务适用性以及可能的开发者熟悉度等因素,介绍了如何在现实场景中选择我们的工具集来构建这个设计。最后,我们仔细定义了构建解决方案所需的关键代码,以重复和稳健地解决问题。
在下一章,也就是最后一章,我们将构建一个批处理机器学习过程的示例。我们将命名这个模式为提取、转换、机器学习,并探讨任何旨在构建此类解决方案的项目应涵盖的关键点。
加入我们的社区 Discord
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第九章:构建提取、转换、机器学习用例
与第八章,构建一个示例 ML 微服务类似,本章的目标将是尝试将我们在本书中学习到的许多工具和技术结晶化,并将它们应用于一个实际场景。这将是基于第一章,机器学习工程导论中引入的另一个用例,在那里我们想象了定期对出租车行程数据进行聚类的需求。为了探索本书中介绍的其他一些概念,我们还将假设对于每次出租车行程,还有一系列来自各种来源的文本数据,例如交通新闻网站和出租车司机与基地之间的通话记录,这些数据与核心行程信息合并。然后我们将这些数据传递给大型语言模型(LLM)进行总结。总结的结果可以随后保存在目标数据位置,与基本的行程日期一起提供对任何下游调查或分析出租车行程的重要背景信息。我们还将基于我们之前对 Apache Airflow 的知识,将其用作我们管道的编排工具,通过讨论一些更高级的概念来使你的 Airflow 作业更加健壮、可维护和可扩展。我们将探索这个场景,以便概述在现实世界中构建解决方案时我们会做出的关键决策,以及讨论如何通过利用其他章节中介绍的内容来实现它。
本用例将使我们能够探索可能是全球范围内在机器学习(ML)解决方案中最常用的模式——批量推理过程。由于检索、转换并在数据上执行机器学习的特性,我将这种模式称为提取、转换、机器学习(ETML)。
我们将在以下几节中逐步分析这个示例:
-
理解批量处理问题
-
设计 ETML 解决方案
-
选择工具
-
执行构建
所有这些主题都将帮助我们理解在构建成功的 ETML 解决方案时,我们需要做出的特定决策和步骤。
在下一节中,我们将回顾在第一章,机器学习工程导论中引入的总体问题,并探讨如何根据我们在本书中迄今为止所学到的所有内容,将业务需求映射到技术解决方案需求。
技术要求
如同其他章节一样,为了运行本章中的代码示例,你可以执行以下操作:
conda env create –f mlewp-chapter09.yml
这将包括 Airflow、PySpark 和一些支持包的安装。对于 Airflow 示例,我们可以在本地工作,并假设如果你想要部署到云端,可以遵循第五章,部署模式和工具中给出的细节。如果你已经运行了上述conda命令,那么你已经在本地安装了 Airflow、PySpark 和 Airflow PySpark 连接器包,因此你可以在终端中使用以下命令以独立方式运行 Airflow:
airflow standalone
这将实例化一个本地数据库和所有相关的 Airflow 组件。终端会有很多输出,但在第一次输出的最后阶段,你应该能够找到有关正在运行的本地服务器的详细信息,包括生成的用户 ID 和密码。请参见图 9.1的示例。

图 9.1:在独立模式下运行 Airflow 时创建的本地登录详情示例。正如消息所说,不要在生产环境中使用此模式!
如果你导航到提供的 URL(在截图的第二行中你可以看到应用正在Listening at http://0.0.0.0:8080),你会看到一个像图 9.2中显示的页面,在那里你可以使用本地用户名和密码登录(见图 9.3)。当你登录到 Airflow 的独立版本时,你会看到许多 DAGs 和作业的示例,你可以基于这些示例构建自己的工作负载。

图 9.2:在本地机器上运行的独立 Airflow 实例的登录页面。

图 9.3:登录到独立 Airflow 实例后的着陆页面。页面已填充了一系列示例 DAGs。
现在我们已经完成了一些初步设置,让我们在构建解决方案之前,讨论我们将尝试解决的问题的细节。
理解批量处理问题
在第一章,机器学习工程导论中,我们看到了一家出租车公司每天结束时分析异常行程的场景。客户有以下要求:
-
应根据行程距离和时间对行程进行聚类,并识别异常值/离群值。
-
速度(距离/时间)不应使用,因为分析师希望了解长途行程或持续时间较长的行程。
-
分析应按日计划进行。
-
推理所需的数据应从公司的数据湖中获取。
-
结果应可供其他公司系统消费。
根据本章引言中的描述,我们现在可以添加一些额外的要求:
-
系统的结果应包含有关行程分类以及相关文本数据的摘要。
-
只有异常行程需要总结文本数据。
正如我们在第二章“机器学习开发过程”和第八章“构建示例 ML 微服务”中所做的那样,我们现在可以根据这些需求构建一些用户故事,如下所示:
-
用户故事 1:作为一名运营分析师或数据科学家,我希望在考虑每趟行程的分钟数和英里数时,能够得到异常行程的清晰标签,以便我可以对异常行程的量进行进一步的分析和建模。判断异常的标准应由适当的机器学习算法确定,该算法根据同一天的其它行程定义异常。
-
用户故事 2:作为一名运营分析师或数据科学家,我希望得到相关文本数据的摘要,以便我可以对一些行程异常的原因进行进一步的分析和建模。
-
用户故事 3:作为一名内部应用程序开发者,我希望所有输出数据都发送到一个中央位置,最好是云上,这样我就可以轻松地使用这些数据构建仪表板和其他应用程序。
-
用户故事 4:作为一名运营分析师或数据科学家,我希望能每天早上 9:00 之前收到一份报告。这份报告应清楚地显示哪些行程是异常的或根据所选机器学习算法定义的“正常”的。这将使我能够更新我的分析并向物流经理提供更新。
用户故事 1 应由我们的通用聚类方法处理,尤其是因为我们正在使用基于密度的空间聚类应用噪声(DBSCAN)算法,该算法为异常值提供标签-1。
用户故事 2 可以通过利用我们在第七章“深度学习、生成式 AI 和 LLMOps”中讨论的 LLM 功能来满足。我们可以将作为输入批次一部分提供的文本数据发送到具有适当格式提示的 GPT 模型;提示格式化可以使用 LangChain 或纯 Python 逻辑完成。
用户故事 3 意味着我们必须将结果推送到云上的某个位置,然后可以被数据工程管道或 Web 应用程序管道获取。为了尽可能灵活,我们将结果推送到一个指定的亚马逊网络服务(AWS)简单存储服务(S3)桶。我们最初将以JavaScript 对象表示法(JSON)格式导出数据,这在几个章节中已经提到,因为这是一种常用于应用开发且大多数数据工程工具都能读取的格式。
最终的用户故事 4 为我们提供了对系统所需调度指导。在这种情况下,需求意味着我们应该运行一个每日批量作业。
让我们将这些想法按照一些机器学习解决方案的技术要求进行表格化,如表 9.1 所示。
| 用户故事 | 详细信息 | 技术要求 |
|---|---|---|
| 1 | 作为一名运营分析师或数据科学家,我希望得到具有异常长骑行时间或距离的骑行清晰的标签,以便我可以对异常骑行量进行进一步的分析和建模。 |
-
算法类型 = 异常检测/聚类/离群值检测。
-
特征 = 骑行时间和距离。
|
| 2 | 作为一名运营分析师或数据科学家,我希望得到相关文本数据的摘要,以便我可以对一些骑行异常的原因进行进一步的分析和建模。 |
|---|
-
算法类型 = 文本摘要。
-
可能的模型 = 类似 BERT 的转换器,以及类似 GPT 模型的 LLMs。
-
输入需求 = 格式化提示。
|
| 3 | 作为内部应用程序开发者,我希望所有输出数据都发送到一个中央位置,最好是云中,这样我就可以轻松地使用这些数据构建仪表板和其他应用程序。 | 系统输出目的地 = AWS 上的 S3。 |
|---|---|---|
| 4 | 作为一名运营分析师或数据科学家,我希望在每天早上看到前一天骑行的输出数据,以便我可以更新我的分析并向物流经理提供更新。 | 批次频率 = 每天。 |
表 9.1:将用户故事转换为技术需求。
将一些用户故事转换为潜在的技术需求的过程是机器学习工程师的一项非常重要的技能,它确实可以帮助加快潜在解决方案的设计和实施。在本章的其余部分,我们将使用表 9.1中的信息,但为了帮助您练习这项技能,您能想到一些其他可能的用户故事以及这些故事可能转换成的技术需求吗?以下是一些启发性的思考:
-
公司的数据科学家可能想要尝试构建模型,根据骑行的时间和其他特征(包括文本数据中提到的任何交通问题)来预测客户满意度。他们可能多久需要一次这样的数据?他们需要哪些数据?他们具体会如何使用这些数据?
-
公司移动应用的开发者可能希望根据交通和天气条件为用户提供预期的骑行时间预测。他们如何做到这一点?数据可以批量提供,还是应该是一个事件驱动的解决方案?
-
高级管理层可能希望看到公司跨多个变量的表现报告,以便做出决策。他们可能希望看到哪些类型的数据?哪些机器学习模型能提供更多见解?数据需要多久准备一次,结果可以展示在哪些解决方案中?
现在我们已经完成了一些了解系统需要做什么以及它可能如何做的初步工作,我们现在可以开始将这些内容整合到一些初步的设计中。
设计 ETML 解决方案
需求明确指出我们需要一个解决方案,该解决方案接收一些数据,并使用机器学习推理对其进行增强,然后将数据输出到目标位置。我们提出的任何设计都必须封装这些步骤。这是任何 ETML 解决方案的描述,这也是机器学习世界中应用最广泛的模式之一。在我看来,它将在未来一段时间内保持重要,因为它特别适合机器学习应用,其中:
-
延迟不是关键:如果您可以按计划运行,并且没有高吞吐量或低延迟响应时间的要求,那么作为 ETML 批量运行是完全可接受的。
-
您需要批量处理数据以进行算法原因:这里我们将使用的聚类方法是一个很好的例子。在在线环境中执行聚类的确有方法,其中模型会随着新数据的到来而不断更新,但如果你将所有相关数据一起放在相关批次中,某些方法会更简单。类似的论点也适用于深度学习模型,这些模型需要在大批量的数据上并行处理 GPU 以实现最大效率。
-
您没有事件或流机制可用:许多组织可能仍然在批量模式下运行,仅仅是因为他们必须这样做!转移到适当平台以采用不同模式可能需要投资,而这可能并不总是可行的。
-
这更简单:与前面的观点相关,为您的团队设置基于事件或流系统可能需要一些学习,而批量处理相对直观且易于开始。
现在,让我们开始讨论设计。我们的设计必须涵盖的关键要素已在表 9.1中阐述。然后我们可以开始构建一个设计图,涵盖最重要的方面,包括开始确定用于哪些流程的技术。图 9.4显示了一个简化的设计图,开始这样做,并展示了我们如何使用 Airflow 管道从 S3 存储桶中提取数据,将我们的聚类数据存储在 S3 作为中间数据存储步骤,然后再使用 LLM 摘要文本数据,并将最终结果导出到我们的最终目标 S3 位置。

图 9.4:ETML 聚类和摘要系统的整体设计。整体流程的步骤 1-3 是聚类步骤,4-6 是摘要步骤。
在下一节中,我们将探讨一些潜在的工具体现在我们学习了前几章的内容后可以用来解决这个问题。
选择工具
对于这个例子,以及几乎每次我们遇到 ETML 问题时,我们的主要考虑因素归结为几个简单的事情,即选择我们需要构建的接口、我们需要用于在所需规模上执行转换和建模的工具,以及我们如何编排所有这些部件。接下来的几节将依次介绍这些内容。
接口和存储
当我们执行 ETML 的提取和加载部分时,我们需要考虑如何与存储我们数据的系统进行接口。重要的是,无论我们从哪个数据库或数据技术中提取,我们都应使用适当的工具以所需的规模和速度进行提取。在这个例子中,我们可以使用 AWS 上的 S3 进行我们的存储;我们的接口可以由 AWS boto3 库和 AWS CLI 来处理。请注意,我们还可以选择其他一些方法,其中一些在 表 9.2 中列出,并附有它们的优缺点。
| 潜在工具 | 优点 | 缺点 |
|---|---|---|
AWS CLI、S3 和 boto3 |
-
相对简单易用,并有广泛的文档。
-
连接到广泛的 AWS 工具和服务。
|
-
非云无关。
-
不适用于其他环境或技术。
|
| SQL 数据库和 JDBC/ODBC 连接器 |
|---|
-
相对工具无关。
-
跨平台和云。
-
优化存储和查询是可能的。
|
-
需要数据建模和数据库管理。
-
不适用于非结构化数据。
|
| 通过他们的 API 提供的供应商云数据仓库 |
|---|
-
通常有良好的文档和示例可供参考。
-
有良好的优化。
-
现代平台与其他广泛使用的平台有良好的连接性。
-
可在多个云中提供托管服务。
|
-
需要数据建模和数据库管理。
-
有时可以支持非结构化数据,但实现起来并不总是容易。
-
可能很昂贵。
|
表 9.2:ETML 解决方案的数据存储和接口选项,以及一些潜在的优缺点。
基于这些选项,使用 AWS CLI、S3 和 boto3 包似乎将是提供最大灵活性的最简单机制。在下一节中,我们将考虑围绕我们建模方法可扩展性必须做出的决策。当处理可能极其庞大的数据批次时,这一点非常重要。
模型扩展
在 第六章,扩展规模 中,我们讨论了一些扩展我们的分析和 ML 工作负载的机制。我们应该问自己是否这些或甚至其他方法适用于当前的使用案例,并相应地使用它们。这也同样适用:如果我们正在查看相对较小的数据量,就没有必要配置大量基础设施,可能也不需要花费时间创建非常优化的处理。每个案例都应根据自己的优点和背景进行审查。
我们在 表 9.3 中列出了一些解决方案聚类部分的选项及其优缺点。
| 潜在工具 | 优点 | 缺点 |
|---|---|---|
| Spark ML | 可以扩展到非常大的数据集。 | 需要集群管理。对于较小的数据集或处理需求可能有较大的开销。算法集相对有限。 |
| 使用 pandas 用户定义函数(UDF)的 Spark | 可以扩展到非常大的数据集。可以使用任何基于 Python 的算法。 | 对于某些问题,可能不适用于并行化。 |
| Scikit-learn | 许多数据科学家都很熟悉。可以在许多不同类型的基础设施上运行。训练和服务的开销很小。 | 并非本质上可扩展。 |
| Ray AIR 或 Ray Serve | 相对容易使用的 API。与许多机器学习库的良好集成。 |
-
需要使用新型集群(Ray 集群)进行集群管理。
-
对机器学习工程师来说,需要掌握新的技能集。
|
表 9.3:ETML 解决方案建模部分的选项,包括其优缺点,特别关注可扩展性和易用性。
考虑到这些选项,并且假设数据量对于这个例子来说并不大,我们可以舒适地坚持使用 Scikit-learn 建模方法,因为这提供了最大的灵活性,并且可能最容易为团队中的数据科学家所使用。需要注意的是,如果需要更多可扩展的行为,Scikit-learn 代码转换为在 Spark 中使用 pandas UDF 可以在稍后日期完成,工作量不会太大。
然而,如上所述,聚类只是 ETML 解决方案中“机器学习”的一部分,另一部分是文本摘要部分。一些潜在选项及其优缺点在 表 9.4 中展示。
| 潜在工具 | 优点 | 缺点 |
|---|---|---|
| OpenAI 的 GPT-X 模型(或另一个供应商) |
-
简单易用 – 我们已经在 第七章,深度学习、生成式 AI 和 LLMOps 中遇到过。
-
可能是可用的最高性能的模型。
|
-
可能会变得昂贵。
-
对模型的控制不如开源 LLM 强。
-
数据和模型的可视性不足。
|
| 开源 LLM |
|---|
-
数据和模型的可追溯性更透明。
-
更稳定(你控制着模型)。
|
-
需要大量基础设施。
-
如果需要优化,则需要非常专业的技能。
-
需要更多的运营管理(LLMOps)。
|
| 任何其他非 LLM 的深度学习模型,例如,BERT 变体 |
|---|
-
比一些开源的 LLM 更容易设置。
-
广泛研究和记录。
-
更容易重新训练和微调(较小的模型)。
-
可用 Vanilla MLOps。
-
可能不需要提示工程。
|
-
比 API 调用有更多的运营开销(但比托管 LLM 少)。
-
性能较低。
|
表 9.4:EMTL 解决方案文本摘要组件的工具选项。
现在我们已经探讨了关于可扩展机器学习模型我们需要做出的工具决策,我们将转向 ETML 解决方案中的另一个重要主题——我们如何管理批量处理的调度。
ETML 管道调度
ETML 对应的批量处理类型通常与日常批量处理很好地结合,但鉴于前面提到的其他两点,我们可能需要小心安排我们的作业时间——例如,我们的管道中的一个步骤可能需要连接到一个没有读副本(仅用于读取的数据库副本)的生产数据库。如果是这种情况,那么如果我们周一早上 9 点开始对该数据库进行大量查询,我们可能会对使用该数据库的任何解决方案的用户造成重大性能问题。同样,如果我们整夜运行并希望将数据加载到正在经历其他批量上传过程的系统中,我们可能会造成资源争用,从而减慢处理速度。这里没有一刀切的答案;重要的是要考虑你的选项。我们在以下表格中查看了一些我们在本书中遇到的工具在处理此类问题的调度和作业管理方面的优缺点:
| 潜在工具 | 优点 | 缺点 |
|---|---|---|
| Apache Airflow |
-
良好的调度管理。
-
相对易于使用的 API。
-
良好的文档。
-
提供云托管服务,例如 AWS 的Apache Airflow 托管工作流(MWAA)。
-
在 ML、数据工程和其他工作负载中具有灵活性。
|
-
测试管道可能需要花费时间。
-
如 MWAA 之类的云服务可能成本较高。
-
Airflow 相对通用(可能也是一个优点),并且没有太多针对 ML 工作负载的特定功能。
|
| ZenML |
|---|
-
相对易于使用的 API。
-
良好的文档。
-
为 ML 工程师设计。
-
多个有用的 MLOps 集成。
-
提供云选项。
|
-
测试管道可能需要花费时间。
-
相比 Airflow,学习曲线稍微陡峭一些。
|
| Kubeflow |
|---|
-
相对易于使用的 API。
-
良好的文档。
-
如果需要,可以显著简化 Kubernetes 的使用。
|
-
需要 AWS 变体才能在 AWS 上使用。
-
相比其他工具,学习曲线稍微陡峭一些。
-
由于底层使用 Kubernetes,有时可能更难调试。
|
表 9.5:使用 Apache Airflow 管理我们的调度的优缺点。
根据我们在表 9.3、9.4和9.5中的内容,所有考虑的选项都有很强的优点,而不是太多缺点。这意味着有许多可能的技术组合可以帮助我们解决问题。我们问题的要求规定,我们需要处理相对较小的数据集,每天需要批量处理,首先使用某种聚类或异常检测算法,然后再使用 LLM 进行进一步分析。我们可以看到,选择scikit-learn作为建模包,通过 API 调用的 OpenAI 的 GPT 模型,以及 Apache Airflow 进行编排,可以满足需求。再次强调,这并不是我们唯一可能选择的组合。你可能觉得有趣,可以尝试本章后面我们讨论的例子,并尝试一些其他工具。知道多种完成某事的方法是 ML 工程师的关键技能,这可以帮助你适应许多不同的情况。
下一节将讨论,根据这些信息,我们如何进行解决方案的执行。
执行构建
在本例中,构建过程的执行将非常依赖于我们如何将第一章,机器学习工程导论中展示的概念验证代码拆分成可以被其他调度工具(如 Apache Airflow)调用的组件。
这将展示我们如何应用我们在整本书中学到的某些 ML 工程技能。在接下来的几节中,我们将专注于如何构建一个利用一系列不同 ML 能力的 Airflow 管道,仅用几行代码就创建一个相对复杂的解决方案。
使用高级 Airflow 功能构建 ETML 管道
我们已经在第五章,部署模式和工具中详细讨论了 Airflow,但那时我们更多地覆盖了如何在云上部署你的 DAGs 的细节。在这里,我们将专注于在 DAGs 中构建更高级的功能和控制流程。我们在这里本地工作,基于这样的理解:当你想要部署时,你可以使用第五章中概述的过程。
首先,我们将探讨一些良好的 DAG 设计实践。其中许多是我们在整本书中讨论的一些良好软件工程实践的直接应用;为了对这些进行良好回顾,你可以回到第四章,打包。在这里,我们将强调这些如何应用于 Airflow:
-
将关注点分离应用于你的任务:如第四章所述,关注点分离主要是确保特定的代码块或软件执行特定的功能,且重叠最小。这也可以用“原子性”来表述,即用具体的、专注的功能“原子”来构建你的解决方案。在 Airflow DAGs 的层面,我们可以通过确保我们的 DAGs 由每个案例都有一个明确任务的作业来体现这一原则。因此,在这个例子中,我们清楚地有“提取”、“转换”、“机器学习”和“加载”阶段,对于每个阶段都有特定的任务是合理的。根据这些任务的复杂性,它们甚至可能进一步拆分。这也帮助我们创建良好的控制流程和错误处理,因为对于更小、更原子化的代码块来说,预测、测试和管理故障模式要容易得多。我们将在本节中的代码示例中看到这一点。
-
使用重试:你可以向 Airflow 步骤传递几个参数,这些参数有助于控制任务在不同情况下的操作。这个概念的一个重要方面是“重试”,它告诉任务在出现失败时再次尝试该过程。这是一种在系统中内置一些弹性的好方法,因为一个过程中的暂时性失败可能有各种原因,例如,如果它包括通过 HTTP 的 REST API 调用,那么网络连接可能会下降。你还可以在重试之间引入延迟,甚至指数退避,即重试之间的时间延迟逐渐增加。如果你遇到有速率限制的 API,这可能很有帮助,例如,指数退避意味着系统被允许再次访问端点。
-
在你的 DAGs 中强制执行幂等性:幂等性是指代码在多次运行相同输入时返回相同结果的质量。很容易假设大多数程序都是这样工作的,但这绝对不是事实。在这本书中,我们已经广泛使用了包含内部状态的 Python 对象,例如
scikit-learn中的机器学习模型或 PyTorch 中的神经网络。这意味着不能想当然地认为幂等性是解决方案的一个特性。幂等性非常有用,尤其是在 ETML 管道中,因为它意味着如果你需要执行重试,你知道这不会导致意外的副作用。你可以在 DAG 的层面通过强制执行组成 DAG 的任务的幂等性来强制执行幂等性。对于 EMTL 应用程序来说,挑战在于我们显然有机器学习模型,正如我刚才提到的,这可能会对这一概念构成挑战!因此,需要一些思考来确保重试和管道的机器学习步骤可以很好地协同工作。 -
利用 Airflow 操作符和提供者包生态系统:Airflow 附带了一系列操作符,可以执行各种任务,还有几个包旨在帮助与其他工具集成,称为提供者包。这里的建议是使用它们。这涉及到第四章,打包中讨论的“不要重复造轮子”的观点,并确保你可以专注于构建适合你的工作负载和系统的适当逻辑,而不是创建样板式的集成。例如,我们可以使用 Spark 提供者包。我们可以通过以下方式安装它:
pip install apache-airflow pip install pyspark pip install apache-airflow-providers-apache-spark然后,在 DAG 中,我们可以提交一个 Spark 应用程序,例如一个名为
spark-script.py的脚本中包含的应用程序,以以下方式运行:from datetime import datetime from airflow.models import DAG from airflow.providers.apache.spark.operators.spark_jdbc import SparkJDBCOperator from airflow.providers.apache.spark.operators.spark_sql import SparkSqlOperator from airflow.providers.apache.spark.operators.spark_submit import SparkSubmitOperator DAG_ID = "spark_example" with DAG( dag_id=DAG_ID, schedule=None, start_date=datetime(2023, 5, 1), catchup=False, tags=["example"], ) as dag: submit_job = SparkSubmitOperator( application=\ "${SPARK_HOME}/examples/src/main/python/spark-script.py", task_id="submit_job" ) -
使用
with DAG() as dag:在上面的例子中,你会看到我们使用了这种模式。这种上下文管理器模式是你可以定义 DAG 的三个主要方法之一,其他两种是使用 DAG 构造函数并将它传递给管道中的所有任务,或者使用装饰器将函数转换为 DAG。使用上下文管理器的意思是,就像在 Python 中任何使用它的场合一样,任何在上下文中定义的资源都会被正确清理,即使代码块因为异常而存在。使用构造函数的机制要求将dag=dag_name传递到管道中你定义的每个任务中,这相当繁琐。使用装饰器对于基本的 DAG 来说相当干净,但如果构建更复杂的 DAG,它可能会变得难以阅读和维护。 -
记住要测试(!):在阅读完这本书的其余部分并成为自信的机器学习工程师之后,我可以听到你在页面上大喊,“关于测试呢?!”,你是对的,应该大喊。我们的代码只有在我们能对其运行的测试中才是好的。幸运的是,Airflow 提供了一些开箱即用的功能,使得可以在本地测试和调试你的 DAG。对于在 IDE 或编辑器中进行调试,如果你的 DAG 名为
dag,就像上面的例子一样,你只需要将以下片段添加到你的 DAG 定义文件中,就可以在选择的调试器中运行 DAG,在本地序列化的 Python 进程中。这不会运行调度器;它只是在单个进程中运行 DAG 步骤,这意味着它会快速失败,并给开发者提供快速反馈:if __name__ == "__main__": dag.test()我们也可以像在第四章,打包,以及书中的其他地方所做的那样使用
pytest。
既然我们已经讨论了一些我们可以使用的重要概念,我们将开始详细构建 Airflow DAG,并且我们将尝试以展示如何将一些弹性构建到解决方案中的方式来完成这项工作。
首先,重要的是要注意,对于这个例子,我们实际上将执行 ETML 流程两次:一次用于聚类组件,一次用于文本摘要。这样做意味着我们可以在步骤之间使用 中间存储,在这种情况下,再次使用 AWS S3,以便在系统中引入一些弹性。这是因为如果第二步失败,并不意味着第一步的处理丢失。我们将以相对直接的方式展示这个例子,但就像本书中始终强调的那样,请记住,这些概念可以扩展和适应您选择的工具和流程,只要基本原理保持稳固。
让我们开始构建这个 DAG!这是一个相对简短的 DAG,只包含两个任务;我们将首先展示 DAG,然后详细说明:
from __future__ import annotations
import datetime
import pendulum
from airflow import DAG
from airflow.operators.python import PythonOperator
from utils.summarize import LLMSummarizer
from utils.cluster import Clusterer
import logging
logging.basicConfig(level=logging.INFO)
# Bucket name could be read in as an environment variable.
bucket_name = "etml-data"
date = datetime.datetime.now().strftime("%Y%m%d")
file_name = f"taxi-rides-{date}.json"
with DAG(
dag_id="etml_dag",
start_date=pendulum.datetime(2021, 10, 1),
schedule_interval="@daily",
catchup=False,
) as dag:
logging.info("DAG started ...")
logging.info("Extracting and clustering data ...")
extract_cluster_load_task = PythonOperator(
task_id="extract_cluster_save",
python_callable=Clusterer(bucket_name, file_name).\
cluster_and_label,
op_kwargs={"features": ["ride_dist", "ride_time"]}
)
logging.info("Extracting and summarizing data ...")
extract_summarize_load_task = PythonOperator(
task_id="extract_summarize",
python_callable=LLMSummarizer(bucket_name, file_name).summarize
)
extract_cluster_load_task >> extract_summarize_load_task
sequentially after one another using the >> operator. Each task performs the following pieces of work:
-
extract_cluster_load_task: 此任务将从适当的 S3 存储桶中提取输入数据,并使用 DBSCAN 进行一些聚类,然后将原始数据与模型输出连接到中间存储位置。为了简单起见,我们将使用相同的存储桶作为中间存储,但这可以是任何您有连接性的位置或解决方案。 -
extract_summarize_load_task: 同样,这里的第一个步骤是使用 boto3 库从 S3 中提取数据。接下来的步骤是将数据取出,然后调用适当的 LLM 对数据中选定的文本字段进行摘要,特别是那些包含关于批次运行当天当地新闻、天气和交通报告的信息的字段。
在阅读 DAG 后,您可能会注意到 DAG 定义之所以如此简短,是因为我们将大部分逻辑抽象到了辅助模块中,这符合保持简单、分离关注点和应用模块化的原则。请参阅 第四章,打包,以深入了解这些和其他重要概念。
我们在 DAG 中使用的第一个组件是 utils.cluster 下的 Clusterer 类的功能。此类的完整定义如下。为了简洁起见,我省略了标准导入:
import boto3
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import DBSCAN
from utils.extractor import Extractor
model_params = {
'eps': 0.3,
'min_samples': 10,
}
class Clusterer:
def __init__(
self, bucket_name: str,
file_name: str,
model_params: dict = model_params
) -> None:
self.model_params = model_params
self.bucket_name = bucket_name
self.file_name = file_name
def cluster_and_label(self, features: list) -> None:
extractor = Extractor(self.bucket_name, self.file_name)
df = extractor.extract_data()
df_features = df[features]
df_features = StandardScaler().fit_transform(df_features)
db = DBSCAN(**self.model_params).fit(df_features)
# Find labels from the clustering
core_samples_mask = np.zeros_like(db.labels_, dtype=bool)
core_samples_mask[db.core_sample_indices_] = True
labels = db.labels_
# Add labels to the dataset and return.
df['label'] = labels
date = datetime.datetime.now().strftime("%Y%m%d")
boto3.client('s3').put_object(
Body=df.to_json(orient='records'),
Bucket=self.bucket_name,
Key=f"clustered_data_{date}.json"
)
注意,类的构造函数包含对默认 model_params 集合的引用。这些可以从配置文件中读取。我已在此处包含它们以供参考。类内的实际聚类和标记方法相对简单;它只是标准化传入的特征,应用 DBSCAN 聚类算法,然后导出最初提取的数据集,现在包括聚类标签。一个需要注意的重要点是,用于聚类的特征以列表的形式提供,这样就可以在未来扩展,或者如果可用更丰富的聚类数据,只需通过更改提供给 DAG 中第一个任务的 PythonOperator 对象的 op_kwargs 参数即可。
在第一个任务成功运行后,该任务使用 Clusterer 类,会生成一个 JSON 文件,其中包含源记录及其聚类标签。在 图 9.5 中给出了生成的文件中的两个随机示例。

图 9.5:ETML 流程中聚类步骤后生成的两个示例记录。
您可能已经注意到,在这个示例的顶部,还导入了另一个实用工具类,即来自 utils.extractor 模块的 Extractor 类。这只是一个围绕一些 boto3 功能的包装器,定义如下:
import pandas as pd
import boto3
class Extractor:
def __init__(self, bucket_name: str, file_name: str) -> None:
self.bucket_name = bucket_name
self.file_name = file_name
def extract_data(self) -> pd.DataFrame:
s3 = boto3.client('s3')
obj = s3.get_object(Bucket=self.bucket_name, Key=self.file_name)
df = pd.read_json(obj['Body'])
return df
现在,让我们继续定义 DAG 中使用的另一个类,即来自 utils.summarize 模块的 LLMSummarizer 类:
openai.api_key = os.environ['OPENAI_API_KEY']
class LLMSummarizer:
def __init__(self, bucket_name: str, file_name: str) -> None:
self.bucket_name = bucket_name
self.file_name = file_name
def summarize(self) -> None:
extractor = Extractor(self.bucket_name, self.file_name)
df = extractor.extract_data()
df['summary'] = ''
df['prompt'] = df.apply(
lambda x:self.format_prompt(
x['news'],
x['weather'],
x['traffic']
),
axis=1
)
df.loc[df['label']==-1, 'summary'] = df.loc[df['label']==-1,
'prompt'].apply(lambda x: self.generate_summary(x))
date = datetime.datetime.now().strftime("%Y%m%d")
boto3.client('s3').put_object(
Body=df.to_json(orient='records'),
Bucket=self.bucket_name,
Key=f"clustered_summarized_{date}.json"
)
def format_prompt(self, news: str, weather: str, traffic: str) -> str:
prompt = dedent(f'''
The following information describes conditions relevant to
taxi journeys through a single day in Glasgow, Scotland.
News: {news}
Weather: {weather}
Traffic: {traffic}
Summarise the above information in 3 sentences or less.
''')
return prompt
def generate_summary(self, prompt: str) -> str:
# Try chatgpt api and fall back if not working
try:
response = openai.ChatCompletion.create(
model = "gpt-3.5-turbo",
temperature = 0.3,
messages = [{"role": "user", "content": prompt}]
)
return response.choices[0].message['content']
except:
response = openai.Completion.create(
model="text-davinci-003",
prompt = prompt
)
return response['choices'][0]['text']
您可以看到,这个类遵循与 Clusterer 类相似的设计模式,但现在我们利用的方法是提示我们选择的 OpenAI LLM,使用我们硬编码的标准模板。同样,这个提示模板可以提取出来,与解决方案一起打包到一个配置文件中,但在这里展示是为了可见性。提示要求 LLM 概括提供的有关当地新闻、天气和交通报告的相关上下文信息,以便我们有一个简洁的摘要,可用于下游分析或用于用户界面。最后,需要注意的是,生成摘要的方法,它封装了对 OpenAI API 的调用,有一个 try except 子句,如果第一个模型调用遇到任何问题,将允许回退到不同的模型。截至 2023 年 5 月,OpenAI API 在延迟和速率限制方面仍然显示出一些脆弱性,因此这样的步骤允许您构建更健壮的工作流程。
在运行 DAG 时应用 LLMSummarizer 类的一个示例输出在 图 9.6 中给出。

图 9.6:LLMSummarizer 的一个示例输出;您可以看到它接收新闻、天气和交通信息,并生成一个简洁的摘要,可以帮助任何下游消费者理解整体交通状况的含义。
在这段代码中,一个潜在的优化区域是围绕所使用的提示模板,因为有可能进行一些很好的提示工程,以尝试使 LLM 的输出更加一致。您还可以使用我们在 第七章 中遇到的工具 LangChain,进行更复杂的模型提示。我将这留作读者的一项有趣练习。
现在我们已经定义了我们 DAG 及其使用的所有逻辑,我们实际上将如何配置它以运行,即使在独立模式下?当我们将在 第四章 中部署 DAG 到 MWAA,AWS 托管和管理的 Airflow 解决方案时,您可能还记得,我们必须将我们的 DAG 发送到一个指定的存储桶,然后由系统读取。
对于您自己的托管或本地 Airflow 实例,同样适用;这次我们需要将 DAG 发送到 $AIRFLOW_HOME 文件夹中的 dags 文件夹。如果您没有明确为您的 Airflow 安装配置此文件夹,您将使用默认设置,通常位于您 home 目录下的名为 airflow 的文件夹中。要找到这个以及其他许多有用的信息,您可以执行以下命令,该命令将产生如 图 9.7 所示的输出:
airflow info

图 9.7:airflow info 命令的输出。
一旦您找到了 $AIRFLOW_HOME 文件夹的位置,如果还没有名为 dags 的文件夹,请创建一个。对于简单、自包含且不使用子模块的 DAG,您要做的只是将 DAG 复制到这个文件夹中,就像我们在 第五章 中的例子中将 DAG 发送到 S3 一样。由于我们在本例中使用了多个子模块,我们可以选择使用在 第四章 中开发的打包技术将它们作为包安装,并确保它们在 Airflow 环境中可用,或者我们可以简单地将子模块发送到同一个 dags 文件夹。为了简化,我们将这样做,但请查阅官方 Airflow 文档以获取有关此方面的详细信息。
一旦我们复制了代码,如果我们访问 Airflow UI,我们应该能够看到我们的 DAG,就像在 图 9.8 中所示。只要 Airflow 服务器正在运行,DAG 将按照提供的计划运行。您也可以在 UI 中手动触发运行以进行测试。

图 9.8:Airflow UI 中的 ETML DAG。
运行 DAG 将导致在 S3 存储桶中创建中间和最终输出 JSON 文件,如图 图 9.9 所示。

图 9.9:DAG 成功运行创建中间和最终 JSON 文件。
有了这些,我们现在已经构建了一个 ETML 管道,它接收一些出租车行程数据,根据行程距离和时间进行聚类,然后使用 LLM 对一些上下文信息进行文本摘要。
摘要
本章介绍了如何将本书中学到的许多技术应用到实际应用场景中,特别是从第二章《机器学习开发过程》、第三章《从模型到模型工厂》、第四章《打包》和第五章《部署模式和工具》中学习的技术。这个问题涉及将出租车行程进行聚类以寻找异常行程,然后对一些上下文文本数据进行自然语言处理(NLP),以尝试自动解释这些异常。这个问题是通过 ETML 模式解决的,我提出这个模式是为了合理化典型的批量机器学习工程解决方案。这一点已经详细解释。还涉及了一个潜在解决方案的设计,以及讨论了任何机器学习工程团队都必须经历的某些工具选择。最后,深入研究了使该解决方案达到生产就绪所需的一些关键工作。特别是,我们展示了如何使用良好的面向对象编程技术来封装跨越 Scikit-learn 包、AWS boto3库和 OpenAI API 的机器学习功能,以使用 LLMs 创建一些复杂的功能。我们还详细探讨了如何使用 Airflow 的更复杂功能来编排这些功能,使其具有弹性。
有了这些,你已经不仅完成了这一章节,还完成了《Python 机器学习工程》的第二版,所以恭喜你!在这本书中,我们涵盖了与机器学习工程相关的广泛主题,从如何组建团队以及可能的发展流程,一直到打包、扩展、调度、部署、测试、日志记录以及其中的一系列其他内容。我们探讨了 AWS 和管理的云服务,深入研究了开源技术,这些技术赋予你编排、管道化和扩展的能力,我们还探索了令人兴奋的新领域:大型语言模型(LLMs)、生成式 AI 和 LLMOps。
在这个快速变化、不断发展和令人兴奋的机器学习工程和 MLOps 世界中,有如此多的主题需要探讨,以至于一本书根本无法对整个领域做出公正的评价。然而,这一版试图通过提供一些我认为对培养下一代机器学习工程人才重要领域的更多广度和深度来改进第一版。我希望你在阅读这本书后不仅感到装备齐全,而且像我每天出去构建未来一样兴奋。如果你在这个领域工作,或者你正在进入这个领域,那么你正在经历的是我认为历史上一个真正独特的时间。随着机器学习系统需要不断变得更加强大、普遍和高效,我相信对机器学习工程技能的需求只会增长。我希望这本书已经为你提供了利用这一点的工具,并且你享受这段旅程的程度和我一样!
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:








和
分别是平均值和标准差:
,那么我们将通过以下公式转换每个分量,
:










































浙公网安备 33010602011771号