数据科学基础设施高效指南-全-
数据科学基础设施高效指南(全)
原文:Effective Data Science Infrastructure
译者:飞龙
前置内容
前言
我第一次见到作者,维尔·图卢斯(Ville Tuulos),是在 2012 年,当时我正在试图理解围绕 Hadoop 的炒作。当时,维尔正在开发 Disco,这是一个基于 Erlang 的 map-reduce 解决方案,使得与 Python 交互变得容易。彼得·王(Peter Wang)和我刚刚开始创立 Continuum Analytics Inc.,维尔的工作是我们发布 Python 大数据发行版 Anaconda 的主要动机之一。
作为 NumPy 和 Anaconda 的创始人,我怀着极大的兴趣观察了过去六到七年中 ML Ops 工具的爆炸式增长,这是对机器学习带来的不可思议机会的回应。有令人难以置信的多种选择,许多营销资金被用来说服你选择这个工具而不是另一个。我在 Quansight 和 OpenTeams 的团队一直在评估新的工具和方法,以便向我们的客户提供推荐。
有像维尔和 Netflix 以及outerbounds.co这样的团队这样的信任之人来创建和维护 Metaflow,这让人感到安慰。我对这本书感到兴奋,因为它详细介绍了 Metaflow,并提供了关于为什么在数据丰富的世界中数据基础设施和机器学习操作如此重要的卓越概述。无论你使用哪种 MLOps 框架,我都相信通过阅读和参考这本书,你将学会如何使你的机器学习操作更加高效和富有成效。
——特拉维斯·奥利弗特(Travis Oliphant),NumPy 的作者,Anaconda、PyData 和 NumFocus 的创始人
前言
作为一名青少年,我对人工智能深感兴趣。我在 13 岁时训练了我的第一个人工神经网络。我从零开始用 C 和 C++编写了简单的训练算法,这是 20 世纪 90 年代探索这个领域的唯一方式。我继续学习计算机科学、数学和心理学,以更好地理解这个庞大主题的基础。通常,机器学习(那时还没有“数据科学”这个术语)的应用似乎更像炼金术,而不是真正的科学或原则性的工程。
我的旅程带我从一个学术领域到大型公司和初创公司,我在那里不断构建支持机器学习的系统。我深受像 Linux 这样的开源项目以及当时新兴的 Python 数据生态系统的影响,这些项目提供了像 NumPy 这样的包,使得与 C 或 C++相比,构建高性能代码变得容易得多。除了开源技术的技术优势外,我还观察到围绕这些项目形成了多么创新、充满活力和欢迎的社区。
当我在 2017 年加入 Netflix,并受命从头开始构建新的机器学习基础设施时,我心中有三个原则。首先,我们需要对整个栈有一个原则性的理解——数据科学和机器学习需要成为真正的工程学科,而不是炼金术。其次,我确信 Python 是构建新平台正确的基石,这不仅因为其技术上的优势,也因为其庞大的包容性社区。第三,最终,数据科学和机器学习是供人类使用的工具。工具的唯一目的是让用户更有效率,并在成功时提供愉悦的用户体验。
工具是由创造它们的文化塑造的。Netflix 的文化在塑造我启动的开源工具 Metaflow 中发挥了高度的影响,Metaflow 已经成为一个充满活力的开源项目。Netflix 的进化压力确保了 Metaflow 以及我们对数据科学全栈的理解是基于实践数据科学家的实际需求。
Netflix 赋予其数据科学家很高的自主权,他们通常不是经过软件工程培训的。这迫使我们仔细思考数据科学家在开发项目和最终部署到生产中时面临的种种挑战,无论大小。我们对栈的理解也深受 Netflix 顶级工程团队的影响,这些团队已经使用云计算十多年,形成了一个关于其优势和劣势的巨大知识体系。
我想写这本书是为了与更广泛的世界分享这些经验。我从开源社区、令人惊叹的洞察力和无私的个人,以及非常聪明的数据科学家那里学到了很多,我觉得有义务尝试回馈。这本书肯定不是我的学习旅程的终点,而只是一个里程碑。因此,我很乐意听到您的意见。不要犹豫,随时联系我,分享您的经验、想法和反馈!
致谢
没有 Netflix 和其他许多公司的所有数据科学家和工程师的耐心解释他们的痛点、分享反馈,并允许我窥视他们的项目,这本书就不可能完成。谢谢!请继续提供反馈。
Metaflow 受到了一群有才华、热情和富有同理心的工程师的影响,并继续由他们开发:Savin Goyal、Romain Cledat、David Berg、Oleg Avdeev、Ravi Kiran Chirravuri、Valay Dave、Ferras Hamad、Jason Ge、Rob Hilton、Brett Rose、Abhishek Kapatkar 以及许多人。你们的手印遍布这本书!与你们所有人一起工作是一种特权,也是一种巨大的乐趣。此外,我想感谢自项目启动以来一直支持项目的 Kurt Brown、Julie Amundson、Ashish Rastogi、Faisal Siddiqi 和 Prasanna Padmanabhan。
我一直想和 Manning 合作编写这本书,因为他们以出版高质量的技术书籍而闻名。我没有失望!我很幸运能与经验丰富的编辑 Doug Rudder 合作,他让我成为了一名更好的作者,并将长达 1.5 年的写作过程变成了一个愉快的体验。我要向 Nick Watts 和 Al Krinker 表示衷心的感谢,他们的深刻技术评论,以及所有在早期访问期间提供反馈的读者和审稿人。
致所有审稿人:Abel Alejandro Coronado Iruegas、Alexander Jung、David Patschke、David Yakobovitch、Edgar Hassler、Fibinse Xavier、Hari Ravindran、Henry Chen、Ikechukwu Okonkwo、Jesús A. Juárez Guerrero、Matthew Copple、Matthias Busch、Max Dehaut、Mikael Dautrey、Ninoslav Cerkez、Obiamaka Agbaneje、Ravikanth Kompella、Richard Vaughan、Salil Athalye、Sarah Catanzaro、Sriram Macharla、Tuomo Kalliokoski 和 Xiangbo Mao,你们的建议帮助使这本书更加完善。
最后,我要感谢我的妻子和孩子们,他们始终如一地耐心和支持。孩子们——如果你读到这句话,我欠你一块冰淇淋!
关于这本书
如果考虑到支撑它们的软件和硬件的全栈,机器学习和数据科学应用是人类构建的最复杂的工程成果之一。从这个角度来看,今天在 2020 年代初,构建这样的应用程序并不容易,这并不令人惊讶。
机器学习和数据科学将长期存在。由先进的数据驱动技术支持的应用程序在各个行业中的应用越来越普遍。因此,有必要使构建和运营此类应用程序的过程更加痛苦和规范。正如阿尔弗雷德·怀特海德所说:“文明通过扩展我们可以在不思考的情况下执行的重要操作的数量而进步。”
本书教你如何构建一个有效的数据科学基础设施,使用户能够尝试创新的应用程序,将它们部署到生产环境中,并在不过多考虑技术细节的情况下持续改进。没有一种一刀切的方法适用于所有用例。因此,本书侧重于一般性的、基础性的原则和组件,你可以在你的环境中以有意义的方式实现它们。
应该阅读这本书的人?
本书有两个主要的目标受众:
-
想要在现实世界的商业环境中有效地开发和部署数据科学应用的全栈系统,并理解其工作原理的数据科学家。即使你没有基础设施工程、DevOps 或软件工程的一般背景,你也可以使用这本书来全面了解所有动态部分,并学到一些新知识。
-
被委派建立基础设施以帮助数据科学家的基础设施工程师。即使你在 DevOps 或系统工程方面有经验,你也可以使用这本书来全面了解数据科学的需求与传统软件工程的不同,以及相应地,为什么需要不同的基础设施堆栈来使数据科学家更有效率。
此外,数据科学和平台工程组织的领导者可以快速浏览本书,因为基础设施塑造组织,反之亦然。
本书是如何组织的:一个路线图
本书围绕数据科学基础设施的全栈进行组织,您可以在书的内封面上找到它。堆栈的结构是这样的,最基础、面向工程的层在底部,而与数据科学相关的更高层次的关注点在顶部。我们将从底部向上大致遍历堆栈,如下所示:
-
第一章解释了为什么首先需要数据科学基础设施。它还将激励我们以人为中心的基础设施方法。
-
第二章从基础知识开始:数据科学家每天进行的活动以及如何优化他们的工作环境的人体工程学。
-
第三章介绍了 Metaflow,这是一个开源框架,我们将用它来展示有效基础设施的概念。
-
第四章专注于可扩展的计算:所有数据科学应用都需要进行计算,有时在小规模,有时在大型规模。我们将通过云来实现这一点。
-
第五章专注于性能:众所周知,过早优化不是一个好主意。更好的方法是逐步优化代码,只在需要时增加复杂性。
-
第六章讨论了生产部署:原型环境和生产环境之间存在几个关键差异,但在这两者之间移动不应太难。
-
第七章深入探讨了数据科学的另一个基础问题:数据。我们将研究有效的方法来与现代数据仓库和数据工程团队合作。
-
第八章讨论了在周围业务系统背景下的数据科学应用。数据科学不应成为一个孤岛——我们将学习如何将其与其他系统连接起来,以产生真正的商业价值。
-
第九章通过一个现实、端到端的深度学习应用,将堆栈的所有层连接起来。
-
附录包括安装和配置 Metaflow 的 Conda 包管理器的说明。
从第三章开始,章节中包含了一些小但真实的机器学习应用,以展示这些概念。不需要机器学习或数据科学方面的先验知识——我们只是将这些技术用于说明。本书不会深入教授机器学习或数据科学技术——许多优秀的书籍已经做到了这一点。我们的重点是这些应用所依赖的基础设施。
完成前三个章节后,您可以自由地跳过与您无关的章节。例如,如果您只处理小规模数据,您可以专注于第三章、第六章和第八章。
关于代码
本书在所有示例中都使用了开源 Python 框架 Metaflow (metaflow.org)。然而,本书中提出的概念和原则并不特定于 Metaflow。特别是,第四章至第八章可以轻松地适应其他框架。您可以在github.com/outerbounds/dsbook找到本书中展示的所有源代码。
您可以在 OS X 或 Linux 笔记本电脑上运行示例。您只需要一个代码编辑器和终端窗口。类似于这样的以# python 开头的行
# python taxi_regression_model.py --environment=conda run
这些示例旨在在终端上执行。可选地,许多示例受益于 AWS 账户,如第四章所述。
liveBook 讨论论坛
购买 有效数据科学基础设施 包括免费访问 liveBook,曼宁的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或针对特定章节或段落附加评论。为自己做笔记、提出和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/effective-data-science-infrastructure/discussion。您还可以在livebook.manning.com/discussion了解更多关于曼宁论坛和行为准则的信息。
曼宁对读者的承诺是提供一个平台,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书仍在印刷中,论坛和先前讨论的存档将可通过出版社的网站访问。
其他在线资源
如果你在学习过程中遇到困难,需要额外的帮助,或者对本书的主题有任何反馈或想法,你非常欢迎加入我们友好的在线社区slack.outerbounds.co。你也可以通过 LinkedIn (www.linkedin.com/in/villetuulos/)或 Twitter (@vtuulos)联系作者。如果你认为你在 Metaflow 中发现了错误,请在此处打开一个问题github.com/Netflix/metaflow/issues.
关于作者

维莱·图卢斯从事机器学习基础设施的开发已有二十多年。他的经历包括学术界、专注于数据和机器学习的初创公司,以及两家全球企业。他在 Netflix 领导了机器学习基础设施团队,在那里他启动了 Metaflow,这是一个开源框架,本书中有所介绍。他是 Outerbounds 的 CEO 和联合创始人,该公司专注于以人为本的数据科学基础设施。
关于封面插图
《有效数据科学基础设施》封面上的图像是“奥格斯堡夫人”,或称“奥格斯堡女士”,取自雅克·格拉塞·德·圣索沃尔的收藏,该收藏于 1797 年出版。每一幅插图都是手工精心绘制和着色的。
在那些日子里,仅凭人们的服饰就能轻易地识别出他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面,庆祝计算机行业的创新精神和主动性,这些文化通过像这样的一些收藏品中的图片被重新带回生活。
1 介绍数据科学基础设施
本章涵盖
-
为什么公司首先需要数据科学基础设施
-
介绍数据科学和机器学习的基础设施堆栈
-
成功数据科学基础设施的要素
机器学习和人工智能诞生于 20 世纪 50 年代的学术界。从技术上讲,如果时间成本不是问题,本书中展示的所有内容在几十年前就已经可以实施。然而,在过去七十年里,这个领域中的任何事物都不容易。
正如许多公司所经历的,构建由机器学习驱动的应用程序需要由具有专业知识的大型工程师团队,他们通常需要数年才能交付一个调优良好的解决方案。如果你回顾计算机的历史,大多数社会性的转变并不是在不可能的事情变得可能的时候发生的,而是在可能的事情变得容易的时候发生的。在可能和容易之间架起桥梁需要有效的基础设施,这正是本书的主题。
词典将基础设施定义为“一个国家、地区或组织正常运作所需的基本设备和结构(如道路和桥梁)。”本书涵盖了数据科学应用程序正常运作所需的基本设备和结构堆栈。阅读本书后,你将能够设置和定制一个基础设施,帮助你的组织比以往任何时候都更快、更容易地开发和交付数据科学应用程序。
关于术语的一些话
现代形式的“数据科学”这个短语是在 21 世纪初提出的。正如之前所提到的,“机器学习”和“人工智能”这些术语在此之前的几十年里就已经被使用,同时还有其他相关术语,如“数据挖掘”或“专家系统”,这些在某个时期曾经很流行。
对于这些术语的确切含义没有共识,这是一个挑战。这些领域的专业人士认识到数据科学、机器学习和人工智能之间的细微差别,但这些术语之间的界限是争议性和模糊的,这一定让那些在 20 世纪 70 年代和 80 年代对“模糊逻辑”这个术语感到兴奋的人感到高兴!
本书针对的是数据科学、机器学习和人工智能等现代领域的结合。为了简洁起见,我们选择使用“数据科学”这个术语来描述这种结合。术语的选择旨在包容性:我们不排除任何特定的方法或方法集。
为了本书的目的,这些领域之间的差异并不显著。在少数几个我们想要强调差异的具体案例中,我们将使用更具体的术语,例如“深度神经网络”。总之,每当本书使用这个术语时,如果你认为替换成你更喜欢的术语会使文本更有意义,你可以这样做。
如果你问一个领域内的人数据科学家的工作是什么,你可能会得到一个快速的回答:他们的工作是建立模型。尽管这个回答并不错误,但它有点狭隘。越来越多人期望数据科学家和工程师能够构建解决商业问题的端到端解决方案,其中模型只是一个小但重要的部分。因为这本书专注于端到端解决方案,所以我们说数据科学家的工作是构建数据科学应用。因此,当你在这本书中看到这个短语时,请考虑它意味着“模型以及端到端解决方案所需的任何其他东西。”
1.1 为什么需要数据科学基础设施?
关于数据科学是什么,为什么它有益,以及如何在各种环境中应用它的许多伟大书籍已经被写出来了。这本书专注于与基础设施相关的问题。在我们深入探讨为什么我们需要专门为数据科学建立基础设施之前,让我们简要地讨论一下为什么任何基础设施都存在。
考虑一下在 20 世纪工业规模农业出现之前,牛奶是如何生产和消费的。许多家庭有一到两头奶牛,为家庭的直接需求生产牛奶。维持一头奶牛需要一些专业知识,但不需要太多的技术基础设施。如果家庭想要扩大他们的乳品业务,没有投资于更大规模的饲料生产、人员数量和储存机制,这将是一项挑战。简而言之,他们能够以最少的设施运营一个小规模的乳品业务,但扩大生产规模则需要比仅仅再买一头奶牛更深的投资。
即使农场可以支持更多的奶牛,他们也需要将额外的牛奶分发到家庭以外的地方去销售。这提出了一个速度问题:如果农民不能足够快地移动牛奶,其他农民可能会先卖出他们的产品,使市场饱和。更糟糕的是,牛奶可能会变质,这会损害产品的有效性。
也许一个友好的邻居能够帮助分销并将牛奶运送到附近的城镇。我们的有事业心的农民可能会发现当地市场对原奶的供应过剩。相反,顾客需要的是多样性的精炼乳制品,比如酸奶、奶酪,甚至可能是冰淇淋。农民非常愿意服务顾客(并得到他们的钱),但很明显,他们的运营并没有准备好处理这种复杂程度。
随着时间的推移,一系列相互关联的系统应运而生,以满足这些需求,这些系统如今构成了现代乳制品基础设施:工业规模的农场优化了产量。制冷、巴氏杀菌和物流提供了将高质量牛奶运送到乳制品工厂所需的速度,然后这些工厂生产出各种产品,并分发到杂货市场。请注意,乳制品基础设施并没有取代所有小规模农民:有机、手工艺家庭农场的专业产品仍有一个相当大的市场,但以这种劳动密集型的方式满足所有需求并不可行。
三个“V”——体积、速度和多样性——最初由迈克尔·斯坦布瑞克教授用来对大数据数据库系统进行分类。我们添加了有效性作为第四个维度,因为它与数据科学高度相关。作为一个思维练习,考虑在你的业务环境中哪个维度最重要。在大多数情况下,有效的数据科学基础设施应在四个维度之间保持健康平衡。
1.1.1 数据科学项目生命周期
在过去的七十年里,大多数数据科学应用都是以一种可以描述为手工艺的方式产生的,即由一个高级软件工程师团队从头开始构建整个应用。与乳制品产品一样,手工艺并不意味着“不好”——通常恰恰相反。手工艺方式通常是实验前沿创新或生产高度专业化的应用的正确方式。
然而,正如乳制品行业一样,随着行业的成熟和需要支持更高产量、速度、有效性和多样化的产品,在共同基础设施上构建许多,如果不是大多数应用,变得合理。你可能对如何将生奶转化为奶酪以及支持工业规模奶酪生产所需的基础设施有一个大致的了解,但数据科学又是如何呢?图 1.1 展示了典型的数据科学项目。

图 1.1 数据科学项目生命周期
-
在中心,我们有一个数据科学家,他被要求解决一个商业问题,例如,创建一个模型来估计客户的终身价值,或者创建一个在电子邮件通讯中生成个性化产品推荐的系统。
-
数据科学家通过提出假设和实验来启动项目。他们可以使用他们最喜欢的工具来测试想法:Jupyter 笔记本、专门的编程语言如 R 或 Julia,或者软件包如 MATLAB 或 Mathematica。
-
当涉及到原型设计机器学习或统计模型时,有出色的开源软件包可用,例如 Scikit-Learn、PyTorch、TensorFlow、Stan 以及许多其他软件包。得益于在线上可用的出色文档和教程,在许多情况下,使用这些软件包构建初始原型并不需要很长时间。
-
然而,每个模型都需要数据。可能合适的数据存在于数据库中。为原型提取静态数据样本通常相当直接,但处理更大的数据集,比如数十吉字节,可能会更复杂。在这种情况下,数据科学家甚至还没有担心如何自动更新数据,这需要更多的架构和工程。
-
数据科学家在哪里运行笔记本?也许他们可以在笔记本电脑上运行它,但他们如何分享结果?如果他们的同事想测试原型,但他们没有足够强大的笔记本电脑怎么办?在共享服务器上执行实验可能会更方便——在云端——所有合作者都可以轻松访问。然而,有人需要首先设置这个环境,并确保服务器上可用的工具、库以及数据。
-
数据科学家被要求解决一个商业问题。很少有公司在笔记本或其他数据科学工具上进行业务操作。为了证明原型的价值,仅仅原型存在于笔记本或其他数据科学环境中是不够的。它需要集成到周围的业务基础设施中。也许那些系统是以微服务组织的,因此如果新的模型可以作为微服务部署,那将是有益的。这样做可能需要相当多的基础设施工程经验和知识。
-
最后,原型集成到周围系统后,利益相关者——产品经理和业务所有者——评估结果并向数据科学家提供反馈。可能出现两种结果:要么利益相关者对结果持乐观态度,并要求数据科学家进一步改进,要么他们认为科学家的时间最好花在其他更有希望的业务问题上。值得注意的是,两种结果都导致相同的下一步:整个周期从开始再次开始,要么专注于改进结果,要么解决新的问题。
生命周期细节在公司和项目之间自然会发生变化:如何为顾客终身价值开发预测模型与构建自动驾驶汽车有很大不同。然而,所有数据科学和机器学习项目都有以下共同的关键要素:
-
从技术角度来看,所有项目在其基础都涉及数据和计算。
-
本书侧重于这些技术的实际应用,而不是纯粹的研究,所以我们期望所有项目最终都需要解决将结果集成到生产系统的问题,这通常涉及大量的软件工程。
-
最后,从人类的角度来看,所有项目都涉及实验和迭代,许多人认为这是数据科学的核心活动。
虽然个人、公司或团队当然可以提出他们自己的定制流程和实践来执行数据科学项目,但一个共同的基础设施可以帮助增加可以同时执行的项目数量(体积),加快上市时间(速度),确保结果稳健(有效性),并使支持更多样化的项目成为可能。
注意,项目的规模,即数据集或模型的大小,是一个正交的关注点。特别是,认为只有大规模项目需要基础设施是错误的。通常情况正好相反。
这本书适合我吗?
如果与数据科学项目生命周期相关的问题和潜在解决方案与您产生共鸣,您应该会发现这本书很有用。如果您是一位数据科学家,您可能已经亲身体验了一些挑战。如果您是一位希望设计和构建系统以帮助数据科学家的基础设施工程师,您可能希望找到可扩展、稳健的解决方案来应对这些问题,这样您就不必在夜间因某些故障而醒来。
我们将系统地介绍构建现代、有效数据科学基础设施的系统。本书涵盖的原则并不特定于任何特定的实现,但我们将使用开源框架Metaflow来展示这些想法如何付诸实践。或者,您也可以通过使用其他现成的库来自定义自己的解决方案。这本书将帮助您选择适合工作的正确工具集。
值得注意的是,存在一些完全有效且重要的场景,这本书并不适用。如果您处于以下情况,这本书以及一般的数据科学基础设施可能对您并不相关:
-
您专注于理论研究,并没有将方法和结果应用于实际案例中。
-
您处于第一个应用数据科学项目的早期阶段(如前所述的步骤 1-4),一切进展顺利。
-
您正在从事一个非常具体、成熟的特定应用,因此优化项目的体积、速度和多样性并不重要。
在这些情况下,您可以在有更多项目开始出现或您开始遇到像我们之前的数据科学家所面临的难题时再回到这本书。否则,继续阅读!在下一节中,我们将介绍一个基础设施栈,为我们在后续章节中讨论的所有内容提供整体框架。
1.2 什么是数据科学基础设施?
新基础设施是如何出现的?在 20 世纪 90 年代互联网的早期,除了原始的网页浏览器和服务器之外,没有其他基础设施。在互联网泡沫时期,建立一个电子商务商店是一项重大的技术壮举,涉及众多人员、大量的定制 C 或 C++代码,以及财力雄厚的风险投资家。
在接下来的十年里,一场类似于寒武纪大爆发的网络框架开始汇聚到常见的基础设施栈,如 LAMP(Linux,Apache,MySQL,PHP/Perl/Python)。到 2020 年,操作系统、网络服务器和数据库等组件已经变成了少数人需要担心的问题,使得大多数开发者能够专注于使用如 ReactJS 这样的高级框架来构建面向用户的软件层。
数据科学的基础设施正在经历类似的演变。原始的机器学习和优化库已经存在了几十年,而没有太多的其他基础设施。现在,在 2020 年代初,我们正经历数据科学库、框架和基础设施的爆炸式增长,这通常是由商业利益驱动的,类似于在互联网泡沫期间及其之后发生的情况。如果历史可以作为证明,那么从这种碎片化的景观中将会出现广泛共享的模式,这将形成数据科学通用开源基础设施栈的基础。
在构建任何基础设施时,记住基础设施只是达到目的的手段,而不是目的本身是很好的。在我们的案例中,我们想要构建基础设施,以便使数据科学项目——以及负责这些项目的数据科学家——更加成功,如图 1.2 所示。

图 1.2 总结本书的关键关注点
下一个章节中介绍的这个栈的目标是解锁四个“V”:它应该能够支持更大规模和更多样化的项目,以更高的速度交付,同时不牺牲结果的有效性。然而,栈本身并不能交付项目——成功的项目是由数据科学家交付的,他们的生产力有望通过栈得到极大的提升。
1.2.1 数据科学的基础设施栈
数据科学基础设施栈的元素究竟是什么?得益于开源文化和硅谷以及全球范围内公司之间相对自由的技术信息共享,我们已经能够观察和收集数据科学项目和基础设施组件中的常见模式。尽管实现细节各异,但主要的基础设施层在大量项目中相对统一。本书的目的是提炼和描述这些层以及它们为数据科学形成的基础设施栈。
图 1.3 中展示的栈并不是构建数据科学基础设施的唯一有效方式。然而,它应该是一个有充分理由的方案:如果你从第一原理开始,很难想象在不解决栈的每一层的情况下如何成功执行数据科学项目。作为一个练习,你可以挑战栈的任何一层,并思考如果那一层不存在会发生什么。
每一层都可以以各种方式实现,由其环境和用例的具体需求驱动,但整体图景却非常一致。

图 1.3 数据科学基础设施栈
这个数据科学基础设施栈的组织方式是,最基本、最通用的组件位于栈的底部。随着向栈顶的移动,层变得更加针对数据科学。
栈是这本书各章节之间的关键思维模型,将它们联系在一起。当你读到最后一章时,你将能够回答为什么需要栈,每一层的作用是什么,以及如何在栈的每一层做出适当的技术选择。因为你将能够以一致的观点和架构构建基础设施,这将为使用它的数据科学家提供无缝、愉悦的体验。为了给你一个关于层含义的高层次概念,让我们从底部开始逐一介绍。
数据仓库
数据仓库存储应用程序使用的输入数据。一般来说,依赖一个单一集中的数据仓库作为事实的共同来源是有益的,而不是为数据科学构建一个专门的数据仓库,这很容易导致数据定义的分歧。第七章专门讨论这个广泛而深入的主题。
计算资源
原始数据本身并不能做什么——你需要运行计算,例如数据转换或模型训练,才能将其转化为更有价值的东西。与软件工程的其它领域相比,数据科学往往特别需要计算资源。数据科学家使用的算法形状和大小各异。有些需要许多 CPU 核心,有些需要 GPU,还有些需要大量内存。我们需要一个能够平滑扩展以处理多种不同类型工作负载的计算层。我们将在第四章和第五章中介绍这些主题。
任务调度器
不可否认,数据科学中没有什么是一次性的操作:模型应该定期重新训练,预测应根据需求生成。将数据科学应用视为一个持续嗡嗡作响的引擎,不断将数据流通过模型。调度层的任务是保持机器以期望的节奏运行。此外,调度器有助于将应用程序结构化和执行为计算步骤的相互关联的工作流。任务调度和工作流编排的主题在第二章、第三章和第六章中讨论。
版本控制
实验和迭代是数据科学项目的定义特征。因此,应用程序总是处于变化之中。然而,进步很少是线性的。通常,我们事先不知道哪个版本的应用程序比其他版本有所改进。为了正确判断版本,你需要并行运行多个版本,作为一个 A/B 实验。为了实现快速但有序的开发和实验,我们需要一个强大的版本控制层来组织工作。与版本控制相关的话题在第三章和第六章中进行了讨论。
架构
除了核心数据科学工作之外,构建一个健壮、可投入生产的数据科学应用程序还需要大量的软件工程。越来越多的公司发现,让那些并非软件工程师出身的数据科学家自主构建这些应用程序,同时提供强大的基础设施支持,是有益的。基础设施堆栈必须为数据科学家提供软件脚手架和指导轨道,确保他们产生的代码遵循架构最佳实践。我们在第三章中介绍了 Metaflow,这是一个开源框架,它规范了许多这样的实践。
模型操作
数据科学应用程序本身没有固有的价值——只有当它们连接到其他系统,如产品 UI 或决策支持系统时,它们才变得有价值。一旦应用程序部署,为了成为产品体验或业务运营的关键部分,它预计能够在各种条件下保持运行并输出正确的结果。如果应用程序失败,就像所有生产系统偶尔会发生的那样,必须建立系统以允许快速检测、故障排除和修复错误。我们可以从传统软件工程的最佳实践中学习很多,但数据和概率模型的变化性质给数据科学操作带来了特殊的味道,我们在第六章和第八章中讨论了这一点。
特征工程
在面向工程的层之上,是数据科学的核心关注点。首先,数据科学家必须发现合适的原始数据,确定其所需的子集,开发转换,并决定如何将生成的特征输入到模型中。设计这样的管道是数据科学家日常工作的主要部分。我们应该努力使这个过程尽可能高效,无论是在人类生产力的角度还是在计算复杂性的角度。有效的解决方案通常非常具体于每个问题域,因此我们的基础设施应该能够支持第七章和第九章中讨论的各种特征工程方法。
模型开发
最后,在堆栈的顶部是模型开发层:寻找并描述一个将特征转换为期望输出的数学模型。我们期望这一层将稳固地位于数据科学家的专业知识领域,因此基础设施不需要对建模方法有太多的偏见。我们应该能够支持广泛的现成库,以便科学家能够灵活地选择最适合工作的工具。
如果你对这个领域是新手,可能会让很多人感到惊讶,模型开发只占整个端到端机器中构建有效数据科学应用的极小部分。将模型开发层与人类大脑进行比较,大脑只占一个人总体体重的 2-3%。
1.2.2 支持数据科学项目的完整生命周期
基础设施堆栈的目标是在数据科学项目的整个生命周期中支持典型的数据科学项目,从其构思和初始部署到无数次的增量改进迭代。之前,我们确定了以下三个在大多数数据科学项目中都常见的主题。图 1.4 显示了这些主题如何映射到堆栈上。

图 1.4 数据科学项目的关注点映射到基础设施层
-
很容易看出,无论问题领域如何,每个数据科学项目都需要处理数据和计算,因此这些层形成了基础架构。这些层对具体执行的内容是中立的。
-
中间层定义了单个数据科学应用的软件架构:执行什么以及如何执行——算法、数据处理管道、部署策略以及结果的分发。关于这项工作的很多内容都是关于整合现有的软件组件。
-
堆栈的顶部是数据科学的领域:定义数学模型以及如何将原始输入转换为模型可以处理的内容。在一个典型的数据科学项目中,这些层可以随着数据科学家尝试不同的方法而快速演变。
注意,层与主题之间并不是一对一的映射。关注点是重叠的。我们使用堆栈作为设计和构建基础设施的蓝图,但用户不需要关心它。特别是,他们不应该遇到层之间的缝隙,而应该将堆栈作为有效的数据科学基础设施来使用。
在下一章中,我们将介绍 Metaflow,这是一个框架,它提供了一个如何在实践中实现这一点的例子。或者,你可以通过遵循即将到来的章节中阐述的通用原则,结合解决堆栈不同部分的框架来自定义自己的解决方案。
1.2.3 一劳永逸的解决方案并不适用
如果贵公司需要一款高度专业化的数据科学应用——比如自动驾驶汽车、高频交易系统,或者可以部署在资源受限的物联网设备上的微型模型?当然,这样的应用基础设施堆栈可能需要看起来非常不同。在许多这样的情况下,答案是肯定的——至少最初是这样的。
假设贵公司希望将最先进的自主飞行无人机推向市场。整个公司都团结起来开发一个数据科学应用:无人机。自然,这样一个复杂的项目涉及许多子系统,但最终的目标是生产一个应用,因此,数量或多样性不是首要关注的问题。毫无疑问,速度和有效性很重要,但公司可能认为核心业务问题需要高度定制的解决方案。
您可以使用图 1.5 中所示的四象限来评估贵公司是否需要高度定制化的解决方案或通用基础设施。

图 1.5 基础设施类型
一家无人机公司有一个特殊应用,因此他们可能专注于构建一个单一的定制应用,因为他们没有需要通用基础设施的多样性和数量。同样,一家小型初创公司使用预测模型来定价二手车,可以快速组装一个基本应用来完成工作——再次,最初不需要投资基础设施。
相比之下,一家大型跨国银行有数百个数据科学应用,从信用评级到风险评估和交易,每个都可以使用被广泛理解(尽管复杂——“常见”在此语境中并不表示简单或不先进)的模型来解决,因此通用基础设施是合理的。一个生物信息学研究机构可能有许多高度专业化的应用,这些应用需要非常定制化的基础设施。
随着时间的推移,公司往往会倾向于选择通用基础设施,无论他们最初从哪里开始。一家最初有定制应用的无人机公司最终可能需要其他数据科学应用来支持销售、营销、客户服务,或者可能是另一条产品线。他们可能保留专门的应用或甚至定制的基础设施来支持他们的核心技术,同时使用通用基础设施来支持其他业务。
注意:在决定您的基础设施策略时,请考虑最广泛的使用案例集,包括新的和实验性的应用。围绕少数最明显应用的需求来设计基础设施是一个常见的错误,这些应用可能不代表大多数(未来)使用案例的需求。实际上,最明显应用可能需要一种可以与通用基础设施共存的自定义方法。
在涉及规模(例如谷歌搜索)或性能(例如必须以微秒级提供预测的高频交易应用)时,定制应用程序可能会有独特的需求。这类应用程序通常需要工匠式的方法:它们需要经验丰富的工程师精心打造,可能还会使用专用硬件。缺点是,专用应用程序在优化速度和数量方面往往很困难(所需的特殊技能限制了可以工作在该应用程序上的人数),并且它们无法通过设计支持各种应用程序。
仔细考虑您将需要构建或支持的应用程序类型。今天,大多数数据科学应用程序都可以由通用基础设施支持,这正是本书的主题。这很有益,因为它允许您优化数量、速度、多样性和有效性。如果您的某个应用程序有特殊需求,它可能需要更定制的方法。在这种情况下,将特殊应用程序视为特殊情况,同时让其他应用程序从通用基础设施中受益可能是有意义的。
1.3 为什么良好的基础设施很重要
随着我们通过了基础设施堆栈的八层,您对构建现代数据科学应用程序所需的各种技术组件有了窥视。实际上,像 YouTube 的个性化推荐或实时优化横幅广告的复杂模型这样的大规模机器学习应用——一个故意平凡化的例子——是人类有史以来建造的最复杂的机器之一,考虑到涉及的数百个子系统和数百万行代码。
按照我们的原始示例,为乳制品行业构建基础设施可能比许多生产级数据科学应用程序的复杂性低一个数量级。大部分复杂性在表面上并不明显,但事情失败时,它肯定会变得明显。
为了说明复杂性,想象一下上述八层堆栈为一个数据科学项目提供动力。记住,一个项目可以涉及许多相互连接的机器,每台机器代表一个复杂的模型。大量新鲜数据,可能数量很大,通过这些机器流动。这些机器由一个计算平台提供动力,该平台需要管理各种大小、同时执行的数千台机器。机器由作业调度器编排,确保数据在机器之间正确流动,并且每台机器在正确的时间执行。
我们有一支数据科学家团队在这些机器上工作,他们中的每个人都在快速迭代中尝试分配给他们的机器的各种版本。我们希望确保每个版本都能产生有效结果,并且我们希望通过并行执行来实时评估它们。每个版本都需要自己的独立环境,以确保版本之间不会发生干扰。
这种场景应该唤起一个工厂的画面,雇佣着成百上千个不停嗡嗡作响的机器的团队。与工业时代的工厂不同,这个工厂不是一次性建成的,而是不断演变,每天多次略微改变其形状。软件不受物理世界的限制,但它必须产生不断增长的商业价值。
故事还没有结束。一个大型或中型现代公司不仅仅只有一个工厂,一个数据科学应用,而是可以有任意多个。应用的数量本身就会造成运营负担,但主要挑战是多样性:每个现实世界的问题领域都需要不同的解决方案,每个都有自己的需求和特征,导致需要支持的应用程序种类繁多。作为复杂性蛋糕上的樱桃,这些应用程序通常是相互依赖的。
以一个假设的中型电子商务商店为例。他们有一个定制的推荐引擎(“这些产品推荐给您!”);一个衡量营销活动有效性的模型(“Facebook 广告在康涅狄格州似乎比 Google 广告表现更好。”);一个物流优化模型(“与保持库存相比,直接发货类别 B 更有效率。”);以及一个用于估计客户流失的财务预测模型(“购买 X 的客户似乎流失较少。”)。这四个应用中的每一个本身就是一个工厂。它们可能涉及多个模型、多个数据处理管道、多个人和多个版本。
1.3.1 复杂性管理
现实生活中的数据科学应用复杂性给基础设施带来了许多挑战。对于这个问题,没有简单巧妙的解决方案。我们不是将复杂性视为可以扫除或抽象掉的麻烦,而是将管理复杂性作为有效基础设施的关键目标。我们将从多个方面应对这一挑战,如下所述:
-
实施—设计和实施处理这种复杂程度的基础设施是一个非平凡的任务。我们将在后面讨论解决工程挑战的策略。
-
可用性—在涉及复杂性的情况下,使数据科学家保持高效生产是有效基础设施的关键挑战,这也是后来提出的以人为中心的基础设施的关键动机。
-
运维——我们如何以最小的人工干预使机器保持运行?减少数据科学应用的操作负担是基础设施的另一个关键目标,这也是本书各章节的共同主题。
在所有这些情况下,我们必须避免引入偶然复杂性,即不是由问题本身所必需的,而是由选择的方法带来的不希望出现的副作用。偶然复杂性是现实世界数据科学的一个巨大问题,因为我们必须处理如此高的固有复杂性,以至于区分真实问题和想象问题变得困难。
你可能听说过样板代码(仅为了使框架满意而存在的代码),意大利面式管道(系统之间组织混乱的关系),或者依赖地狱(管理不断演变的第三方库图是困难的)。在这些技术问题之上,我们还面临着由人为组织引起的偶然复杂性:有时我们不得不在系统之间引入复杂的接口,并不是因为它们在技术上必要,而是因为它们遵循组织边界,例如,数据科学家和数据工程师之间。你可以在一篇经常引用的论文中了解更多关于这些问题,这篇论文名为“机器学习系统中的隐藏技术债务”,由谷歌在 2015 年发布(mng.bz/Dg7n)。
一个有效的基础设施有助于揭示和管理固有的复杂性,这是我们所处世界的自然状态,同时有意识地避免引入偶然复杂性。做好这一点很难,需要不断的判断。幸运的是,我们有一个经过时间考验的启发式方法来控制偶然复杂性,那就是简单性。“一切都应该尽可能简单,但不能过于简单”是适用于有效数据科学基础设施所有部分的核心设计原则。
1.3.2 利用现有平台
根据前几节所述,我们的任务是构建基于八层栈的有效、通用数据科学基础设施。我们希望以这种方式进行,使现实世界的复杂性变得可管理,同时最大限度地减少基础设施本身引起的额外复杂性。这听起来可能是一项艰巨的任务。
很少有公司能够承担起为数据科学建设并维护大量工程师团队的费用。较小的公司可能只有一到两名工程师负责这项任务,而较大的公司可能有一个小团队。最终,公司希望利用数据科学应用产生商业价值。基础设施是实现这一目标的手段,而不是一个目标本身,因此根据这一目标确定基础设施投资的规模是合理的。总的来说,我们在构建和维护基础设施上只能投入有限的时间和精力。
幸运的是,正如本章开头所提到的,本书中介绍的所有内容在技术上已经可以实现数十年,所以我们不必从头开始。我们不需要发明新的硬件、操作系统或数据仓库,我们的任务是利用现成的最佳平台,并将它们集成起来,以便轻松原型化和生产化数据科学应用。
工程师们往往低估了“可能”与“容易”之间的差距,如图 1.6 所示。在鸿沟的“可能”一侧,很容易以各种方式重新实现事物,而不真正回答如何使事情从根本上变得更容易的问题。然而,只有鸿沟的“容易”一侧才能使我们最大限度地发挥数据科学应用的四 V——数据量、速度、多样性和有效性——因此我们不应该在左岸花费太多时间。

图 1.6 基础设施使事情变得简单。
本书帮助您首先构建桥梁,这本身就是一个非同寻常的任务,尽可能利用现有组件。多亏了我们具有不同层的堆栈,我们可以让其他团队和公司担心个别组件。随着时间的推移,如果其中一些被发现不足,我们可以用更好的替代品替换它们,而不会干扰用户。
心无旁骛
云计算是一个很好的例子,它使许多技术上的事情成为可能,尽管并非总是容易实现。公共云,如亚马逊网络服务(Amazon Web Services)、谷歌计算平台(Google Compute Platform)和微软 Azure,通过允许任何人访问以前仅对最大公司开放的底层基础结构,极大地改变了基础设施的格局。这些服务不仅在技术上可用,而且在谨慎使用时也极具成本效益。
除了使基础设施的底层民主化外,云技术还从根本上改变了我们构建基础设施的方式。以前,在构建高性能计算系统时遇到的许多挑战都围绕着资源管理:如何保护并合理分配有限的计算和存储资源,以及相应地,如何使资源使用尽可能高效。
云技术使我们能够改变我们的思维方式。所有云都提供了一个数据层,例如亚马逊 S3,它提供了几乎无限的存储空间,接近完美的持久性和高可用性。同样,它们提供了几乎无限的、可弹性扩展的计算资源,如亚马逊弹性计算云(Amazon EC2)及其之上的抽象。我们可以假设我们有大量的计算资源和存储可用来构建我们的系统,并专注于成本效益和生产率。
本书假设您能够访问类似云的基础设施。到目前为止,满足这一要求最简单的方法是,在云服务提供商之一创建一个账户。您可以用几百美元或通过依赖许多云提供的免费层来免费构建和测试您的堆栈。或者,您可以构建或使用现有的私有云环境。然而,如何构建私有云超出了本书的范围。
所有云也提供了针对数据科学的高级产品,例如 Azure Machine Learning (ML) Studio 和 Amazon SageMaker。您通常可以将这些产品用作端到端平台,需要最小化定制,或者,您可以选择将它们的部分集成到您自己的系统中。本书采用后一种方法:您将学习如何构建自己的堆栈,利用云提供的各种服务以及使用开源框架。尽管这种方法需要更多工作,但它提供了更大的灵活性,结果可能更容易使用,并且定制的堆栈可能也更经济高效。您将在接下来的章节中了解到这一点的原因。
总结来说,您可以利用云来处理低级、无差别的技术重活。这使您可以将有限的开发预算集中在独特的、区分性的业务需求上,最重要的是,在您的组织中优化数据科学家的生产力。我们可以使用云越来越多地将我们的重点从技术问题转移到人文问题,正如我们将在下一节中描述的那样。
1.4 以人为中心的基础设施
该基础设施旨在从多个方面最大化组织的生产力。它支持更多项目,更快交付,结果更可靠,覆盖更多业务领域。为了更好地理解基础设施如何实现这一点,考虑以下在有效基础设施不可用时出现的典型瓶颈:
-
体积—我们无法支持更多的数据科学应用,仅仅因为我们没有足够的数据科学家来处理它们。我们所有的现有数据科学家都在忙于改进和支持现有应用。
-
速度—我们无法更快地交付结果,因为开发模型 X 的生产就绪版本将是一项重大的工程努力。
-
有效性—该模型的原型在笔记本上运行良好,但我们没有考虑到它可能会接收到像 Y 这样的数据,这在生产中破坏了它。
-
多样性—我们很乐意支持新的用例 Z,但我们的数据科学家只知道 Python,而围绕 Z 的系统只支持 Java。
在所有这些案例中,一个共同的因素是人类是瓶颈。除了某些高度专业化的应用,很少发生项目因为硬件或软件的基本限制而无法交付的情况。一个典型的瓶颈是由人类无法足够快地交付软件(或硬件,如果是在云外操作)这一事实造成的。即使他们能够足够快地破解代码,他们可能正忙于维护现有系统,而这又是另一项至关重要的人类活动。
这个观察结果帮助我们意识到,尽管“基础设施”听起来非常技术化,但我们并不是在为机器构建基础设施。我们是在为提高人类的生产力而构建基础设施。这一认识对我们思考如何为数据科学家(即人类同胞,而不是机器)设计和构建基础设施具有根本性的影响。
例如,如果我们假设人类时间比计算机时间更昂贵,这对于大多数数据科学家来说无疑是正确的,那么使用像 Python 这样的高度表达性和生产力提升的语言,而不是像 C++这样的底层语言,即使它使得工作负载处理效率更低,也是有意义的。我们将在第五章中更深入地探讨这个问题。
在上一节中,我们提到了我们希望充分利用现有平台并将它们集成到我们的基础设施堆栈中。我们的目标应该是以提供一致的用户体验的方式来做这件事,最大限度地减少用户在必须独立理解和操作每一层时可能遇到的认知开销。我们的假设是,通过减少与基础设施相关的认知开销,我们可以提高数据科学家在最重要的领域——即数据科学本身的——生产力。
1.4.1 自由与责任
Netflix,这家流媒体视频公司,以其独特的文化而闻名,这种文化在 Erin Meyer 和 Netflix 的联合创始人兼长期首席执行官 Reed Hastings 最近的一本书《无规则规则:Netflix 与变革文化》(Penguin Press,2020)中有详细描述。Netflix 的核心价值观之一是“自由与责任”,这赋予所有员工极大的自由度来决定他们如何完成工作。另一方面,员工始终被期望考虑公司的最大利益并负责任地行事。Metaflow,一个以人为中心的数据科学基础设施框架,我们将在第三章中介绍,它诞生于 Netflix,受到了公司文化的深刻影响。
我们可以将自由度和责任的概念应用于数据科学家的工作和数据科学基础设施。我们期望数据科学家在自己的领域是专家,例如在特征工程和模型开发方面。我们不期望他们是系统工程或其他与基础设施相关主题的专家。然而,我们期望他们有足够的责任感,如果数据科学基础设施可用,他们会选择利用它。我们可以将这个想法映射到我们的基础设施堆栈,如图 1.7 所示。

图 1.7 基础设施补充了数据科学家的利益。
左侧的三角形描述了数据科学家的专业领域和兴趣。它在堆栈的顶部最宽,最具体于数据科学。基础设施应该在这些层给予他们最大的自由度,允许他们根据自己的专长自由选择最佳建模方法、库和功能。我们期望数据科学家是自主的——关于这一点稍后还会详细说明——因此,他们应该稍微关注模型操作、版本控制和架构,这是他们责任的一部分。
右侧的三角形描述了基础设施团队的自由度和责任。基础设施团队应该有最大的自由度来选择和优化堆栈的底层,这在技术观点上是至关重要的。他们可以在不过度限制数据科学家自由度的前提下做到这一点。然而,他们的责任在堆栈的上层会减少。基础设施团队不能对模型本身负责,因为通常他们没有足够的专长,也没有足够的规模来支持所有用例。
这种安排的目的有两方面:一方面,我们可以通过让他们专注于自己喜欢且擅长执行的事情,以极大的自由度,最大化单个数据科学家的生产力和幸福感。另一方面,通过要求数据科学家负责任地使用堆栈,包括他们可能不太热衷的部分,我们可以实现公司利益的四个“V”,即速度、可扩展性、可访问性和可变性。结果是公司需求和数据科学家幸福之间的健康平衡。
1.4.2 数据科学家自主性
到目前为止,我们随意地谈论了“基础设施团队”和“数据科学家”。然而,数据科学项目中实际的角色可能更加多样化,如下所示:
-
数据科学家 或 机器学习研究员 开发和原型化机器学习或其他数据科学模型。
-
机器学习工程师 以可扩展、生产就绪的方式实现模型。
-
数据工程师 设置数据管道,用于输入和输出数据,包括数据转换。
-
DevOps 工程师在生产中部署应用程序,并确保所有系统保持正常运行且无故障。
-
应用工程师将模型与其他业务组件(如 Web 应用程序)集成,这些应用程序是模型的消费者。
-
基础设施或平台工程师为许多应用程序提供通用基础设施,例如数据仓库或计算平台。
除了这个技术团队外,数据科学项目可能还会涉及业务所有者,他们了解应用程序的业务背景;产品经理,他们将业务背景映射到技术要求;以及项目/程序经理,他们帮助协调跨职能协作。
任何参与过涉及众多利益相关者的项目的个人都知道,为了使项目顺利进行,需要多少沟通和协调。除了协调开销外,仅仅是因为没有足够的人来填补所有项目中所有角色的简单原因,增加并行数据科学项目的数量可能具有挑战性。出于这些原因以及许多其他原因,许多公司发现减少参与项目的人数是可取的,只要项目的执行不受影响。
我们基础设施堆栈的目标是使前四个技术角色能够合并,以便数据科学家能够在项目内部自主处理所有这些功能。这些角色在公司中可能仍然存在,但不是每个项目都需要这些角色,这些角色可以分配给少数关键项目,或者它们可以支持更横向的跨项目工作。
总结来说,数据科学家,他们不需要突然成为 DevOps 或数据工程的专家,应该能够以可扩展的方式实现模型,设置数据管道,并独立地部署和监控生产中的模型。他们应该能够以最小的额外开销做到这一点,从而让他们能够专注于数据科学。这是我们以人为本的数据科学基础设施的关键价值主张,我们将在下一章从头开始构建。
摘要
-
虽然可以在没有专用基础设施的情况下开发和交付数据科学项目,但有效的基础设施使得能够以更高的速度开发更多种类和数量的项目,同时不牺牲结果的有效性。
-
需要一个完整的系统基础设施堆栈来支持数据科学家,从基础层(如数据和计算)到更高级的关注点(如特征工程和模型开发)。本书将系统地介绍所有这些层。
-
数据科学没有一刀切的方法。能够定制基础设施堆栈的层以解决您的特定需求是有益的,这本书将帮助您做到这一点。
-
现代数据科学应用是一个复杂、精密的机器,涉及许多移动部件。在管理这种固有的复杂性方面做得很好,同时避免在执行过程中引入任何不必要的复杂性,这是数据科学基础设施的关键挑战。
-
利用现有的、经过实战考验的系统,例如公共云,是控制复杂性的好方法。接下来的章节将帮助你在堆栈的每一层选择合适的系统。
-
最终,人类往往成为数据科学项目的瓶颈。我们的关键焦点应该是提高整个堆栈的可用性,从而提高数据科学家的生产力。
2 数据科学工具链
本章节涵盖
-
数据科学家在日常生活中参与的关键活动
-
使数据科学家高效的基本工具链
-
工作流程在基础设施堆栈中的作用
每个职业都有其行业工具。如果你是一名木匠,你需要锯子、尺子和凿子。如果你是一名牙医,你需要镜子、钻头和注射器。如果你是一名数据科学家,你在日常工作中需要哪些基本工具呢?
显然,你需要一台电脑。但电脑的用途是什么?它应该用于运行重型计算、训练模型等,还是仅仅作为一个相对笨拙的终端,用于编写代码和分析结果?由于生产应用程序在个人笔记本电脑之外执行,也许原型设计应该尽可能接近真实的生产环境。回答这类问题可能出人意料地复杂,而且答案可能对整个基础设施堆栈产生深远的影响。
正如我们在第一章中强调的,这些工具最终存在是为了提高数据科学家的生产力。我们必须仔细思考构成数据科学家日常工作主体的行动:探索和分析数据、编写代码、评估它并检查结果。如何使这些行动尽可能无摩擦,考虑到它们每天可能重复数百次?没有唯一的正确答案。本章将为你提供思考和技术指导,以设置适合您公司和技术环境的工具链。
将数据科学的全栈想象成一架喷气式战斗机——一个由无数相互连接的组件组成的复杂工程壮举。本章将探讨飞行员操作机器时使用的驾驶舱和仪表盘。从工程的角度来看,驾驶舱可能感觉有些次要,本质上只是一个控制杆和一些按钮(在数据科学的情况下,只是一个编辑器和笔记本),与下面的 3 万磅重的重型工程相比,但往往正是决定任务成功或失败的那个组件。
跟随数据科学家的旅程
为了使讨论更加具体和贴近现实商业需求,我们将跟随一位假设的数据科学家,亚历克斯,在整个书中展开旅程。亚历克斯的旅程展示了数据科学家在现代商业环境中面临的典型挑战。亚历克斯帮助我们保持对以人为本的基础设施的聚焦,并展示基础设施如何与公司一起逐步增长。每个部分都以与亚历克斯工作生活相关的激励场景开始,我们将详细分析和解决这些问题。
除了数据科学家亚历克斯之外,角色还包括哈珀,他是亚历克斯工作的初创公司的创始人。我们还将遇到波维,他是一位基础设施工程师,其职责包括为数据科学家提供支持。这本书的目标是针对所有像亚历克斯和波维这样的人,但更广泛的环境也可能对哈珀们来说很有趣。
亚历克斯拥有海洋生物学博士学位。在意识到具有统计分析、基本机器学习以及 Python 基础知识的人作为数据科学家非常受重视后,亚历克斯决定从学术界转向工业界。

2.1 设置开发环境
亚历克斯加入了哈珀的初创公司 Caveman Cupcakes,该公司作为其首位数据科学家,负责制造和交付即将个性化的原始人风格杯型蛋糕。波维,作为 Caveman 的基础设施工程师,帮助亚历克斯开始工作。亚历克斯询问波维,Caveman 的数据科学家是否可以使用 Jupyter 笔记本来完成工作。如果他们能这样做,那就太好了,因为亚历克斯在学术界已经非常熟悉笔记本了。听到这个,波维意识到数据科学家有特殊的工具需求。他们应该安装哪些工具,以及如何配置这些工具以使亚历克斯的工作效率最大化?

如果你只有时间设置好一项基础设施,那就让它是数据科学家的开发环境。虽然这听起来可能很显然,但你可能会惊讶地发现,许多公司都有调校良好、可扩展的生产基础设施,但最初代码的开发、调试和测试问题却以临时方式解决。我们不应仅仅将个人工作站视为一个 IT 问题,而应将开发环境视为有效基础设施的有机组成部分。毕竟,任何数据科学项目最重要的成功因素是参与其中的人及其工作效率。
词典将人体工程学定义为“研究人们在工作环境中的效率”,这很好地总结了本章的重点。数据科学的发展环境需要优化以下两种人类活动的效率:
-
原型设计——将人类知识和专业知识转化为功能性代码和模型的过程
-
与生产部署的交互——将代码和模型连接到周围系统,并运行这些生产部署,以便它们能够产生可持续的商业价值
图 2.1 中展示的原型循环在软件工程中很常见,被称为REPL,即读取-评估-打印循环。你在编辑器中编写代码,用交互式解释器或终端评估它,并分析结果。根据结果,你修复并改进代码,然后重新启动循环。这个循环在数据科学中也同样适用:你开发一个处理数据的模型或代码,评估它,并分析结果。

图 2.1 原型循环
为了提高数据科学家的生产力,我们希望使这个循环的每一次迭代尽可能快且不费力。这涉及到优化每个步骤以及步骤之间的转换:编写并评估一小段代码需要多长时间?你能否轻松地获取结果并开始分析它们?是否容易探索、分析和理解结果,并相应地更改代码?最终,我们需要基础设施堆栈所有层的合作来回答这些问题,但我们将从以下小节开始奠定基础。
经过无数次的原型循环迭代后,数据科学家拥有了一段能够产生有希望模型或其他所需输出的代码。尽管这对数据科学家来说是一个重要的里程碑,但许多开放性问题仍然存在:当连接到真实世界数据时,模型会产生预期的结果吗?模型能否扩展到它需要处理的所有数据?模型能否抵御随时间发生的变化?是否会有其他运营意外?
在原型环境中尝试回答这些问题是困难的。相反,我们应该使模型作为实验部署到生产环境变得容易,这样我们就可以观察模型在实际中的表现。预计模型的第一个版本可能不会完美运行,但生产故障提供了宝贵的见解,我们可以利用这些见解来改进模型。
进行此类受控的实证实验是科学方法的核心。科学家提出一个假设,进行实验,并分析结果。将此与 SpaceX 如何通过 20 次测试发射迭代开发新的可重复使用的火箭 Falcon 9,并在图 2.2 中展示的第一次成功助推器着陆之前,进行对比。

图 2.2 SpaceX 对 Falcon 9 的迭代
将部署到生产环境、观察问题并使用原型循环解决问题这一周期形成一个更高层次的循环,我们称之为与生产部署的交互。
如图 2.3 所示,生产部署不是一个单向的瀑布流程,而是一个迭代循环,与原型设计循环协同工作。我们希望让数据科学家更容易理解模型在生产中失败的原因和方式,并帮助他们本地重现任何问题,以便他们可以使用他们熟悉的原型设计循环来改进模型。值得注意的是,在成功的项目中,这些循环变成了无限循环:一个成功的模型会不断受到改进和调试的考验。

图 2.3 与生产部署的交互
在软件工程的世界里,这个概念被称为持续交付(CD)。尽管 CD 系统,如 GitHub Actions (github.com/features/actions),可以用来促进数据科学部署,但数据科学应用与传统软件之间存在一些关键差异。考虑以下内容:
-
正确性—通过自动化测试相对容易确认传统软件在部署到生产之前是否正确工作。在数据科学中通常并非如此。部署的目标,例如执行 A/B 测试,是在部署后验证正确性。
-
稳定性—同样,通过自动化测试相对容易确认传统软件在其定义良好的环境中按预期工作。相比之下,数据科学应用受到不断变化的数据的影响,这使得它们在部署后面临意外情况。
-
多样性—可以开发一个传统软件组件,使其能够相对完美地完成预期的工作。相比之下,由于我们总是有新的想法和数据可以测试,因此很难达到这样的完美水平。相应地,能够并行部署多个模型版本并快速迭代是可取的。
-
文化—DevOps 和基础设施工程的世界有其自己的深厚文化和术语,这通常不被大多数数据科学课程所涵盖。遵循我们以人为本的伦理,我们不应该期望数据科学家,他们在自己的领域是专家,会突然成为另一个领域的专家。
在构建针对数据科学需求量身定制的基础设施时,我们可以从现有的 CD 系统中学习和部分利用。前面引入的两个循环是科学家重复以开发、部署和调试数据科学应用程序的概念性行动序列。本章的剩余部分将使它们更加具体。我们将介绍数据科学家应该使用的实际工具,并讨论如何最佳地设置它们。尽管无法规定配置数据科学环境的唯一正确方式——细节取决于你的业务基础设施——但我们将提供足够的技术背景和评估标准,以便你能够根据你的具体需求做出明智的决定。图 2.4 为你提供了一个预期概念。

图 2.4 数据科学工具链的元素
-
我们的关注点是数据科学家,他将使用工具链来推动两个核心活动:原型循环(A)和生产部署的交互(B)。
-
在以下小节中,我们将介绍我们应该为科学家提供的核心生产力工具。
-
在 2.2 节中,我们将强调将数据科学应用程序结构化为工作流的有用之处。
-
我们还将论证在尽可能类似于生产环境的环境中运行原型循环的合理性——在实践中,通过云实例支持原型循环。
-
本章介绍了用户界面,一个科学家用来指挥和控制生产环境的驾驶舱,这将在后续章节中详细说明。
2.1.1 云账户
本书我们将构建的基础设施假设你已经在公共云提供商(如亚马逊网络服务(AWS)、谷歌云平台(GCP)或微软 Azure)有一个账户。我们将使用 AWS 作为所有示例,因为它是目前最广泛使用的云平台。你应该能够相对容易地将示例和概念适应到其他云环境中,包括私有云。
AWS 提供免费层,允许你以最低或无成本设置本书中介绍的裸机基础设施。因此,强烈建议你创建一个 AWS 账户,除非你已经有了一个。创建一个账户很简单——只需遵循aws.amazon.com/free上的说明。
许多公司已有现成的云账户。你应该能够为本书的目的使用它们。我们不会涵盖如何配置用户账户以及执行身份验证和授权,例如 AWS 的 IAM 用户和政策。这些问题并不特定于数据科学,你应该能够使用你之前使用过的相同策略。
2.1.2 数据科学工作站
数据科学家需要一个工作站来驱动原型循环,即开发、测试和部署代码和模型。如今,物理工作站通常是个人笔记本电脑。然而,由于需要高可用性和可扩展性,生产代码和模型不应在笔记本电脑上运行。相反,我们在云环境中部署生产模型。除了开发和生产硬件的差异外,操作系统也往往不同。在笔记本电脑上通常使用 OS X 或 Windows,而在服务器上使用 Linux。
开发工作站和生产环境之间通常存在技术差距,这在与生产部署交互时可能会引起摩擦。这个差距并不仅限于数据科学。例如,网络应用开发者通常在类似的环境中工作。然而,这个差距对于数据科学来说尤其有问题。现代建模库往往高度优化以适应特定的 GPU 和 CPU 架构,与 JavaScript 库等相比。此外,大规模数据处理往往比典型的非数据科学应用对硬件和软件的要求更高,放大了环境之间行为差异。
如许多软件开发人员所经历的艰难方式,当生产环境与开发环境存在显著差异时,调试代码可能会令人沮丧。我们可以通过解包原型循环来解决这个问题。我们不是在笔记本电脑上运行每个步骤——开发、评估和分析——而是在云中运行这些步骤中的任何或所有步骤。在实践中,这意味着我们需要一个半持久性的 Linux 实例或云中的容器,数据科学家可以连接到它。
设置一个可以按需启动和终止此类实例的系统需要预先配置以及为数据科学家提供培训。在决定您是想提供完全本地(基于笔记本电脑)、完全远程(基于云)还是混合解决方案时,请考虑表 2.1 中列出的优缺点。
表 2.1 笔记本电脑与云实例作为开发环境对比
| 笔记本电脑 | 云实例 | |
|---|---|---|
| 设置易用性 | 立即熟悉。 | 有学习曲线。 |
| 易用性 | 初始使用简单,但对于复杂情况,如部署,则较难。 | 初始使用较难;在更复杂的情况下,优势更为明显。 |
| 原型循环速度 | 步骤之间快速转换,但评估速度可能较慢,因为硬件有限。 | 步骤之间可能转换较慢,但评估速度更快。 |
| 支持易用性 | 监控较难;远程提供交互式支持较难。 | 易于支持——支持人员可以使用标准监控工具来观察实例,或远程登录实例。 |
| 可扩展性 | 不可扩展——硬件是固定的。 | 可扩展——实例大小可以根据用例选择。 |
| 与生产部署的交互 | 可能的跨平台问题(OS X 与 Linux)。 | 原型环境和生产环境之间的最小差异。 |
| 安全性 | 由于同一台笔记本电脑被用于许多除了数据科学之外的目的,因此存在许多问题;笔记本电脑可能会丢失。 | 更容易保护和监控——类似于任何其他云实例。可以使用标准的基于云的认证和授权系统,如 AWS IAM。 |
| 同质性 | 每个数据科学家可能都有一个略有不同的环境,这使得问题更难调试。 | 更容易确保环境高度统一。 |
总结表 2.1,基于云的工作站需要在基础设施方面做更多前期工作,但在安全性、运营关注点、可扩展性和与生产部署的交互方面可以带来巨大的回报。随着你阅读本书后面的章节,这一点将变得更加清晰。然而,你可以通过基于笔记本电脑的方法快速开始,随着需求的增长,稍后再重新审视这个决定。
提供基于云的工作站的方式取决于你的业务环境:你使用的云提供商、你的基础设施如何设置以及数据科学家需要遵守的安全策略类型。为了给你一个可用的选项的概览,我们接下来列出了一些典型的例子。预计在未来几年内,新的解决方案将变得可用,所以这个列表远非详尽无遗。
一个通用云 IDE:AWS Cloud9
AWS Cloud9 是一个通用、基于云的集成开发环境(IDE)——一个代码编辑器——它通过 AWS 提供的服务器(EC2 实例)在浏览器中运行,运行 Linux。使用 AWS Cloud9 的感觉与使用你的笔记本电脑在以下方面相似:
-
编辑器感觉像是一个本地编辑器,它内置了调试器。命令行会话感觉像是一个本地终端。
-
它管理着一个连接到编辑会话的标准 EC2 实例,你可以用它来支持原型循环并与生产部署进行交互。或者,你可以配置它连接到现有的 EC2 实例以获得更多控制。
-
除了通常的 EC2 费用外,没有额外的费用,未使用的实例会自动停止,因此它可能是一个非常经济有效的解决方案。
一个缺点是 AWS Cloud9 没有内置对笔记本的支持(关于笔记本的更多信息将在下一节中介绍),尽管通过一些定制工作,也可以使用底层的 EC2 来支持笔记本内核。
一个针对数据科学的环境:Amazon SageMaker Studio
Amazon SageMaker Studio 是 JupyterLab 数据科学环境的托管版本,它与 AWS 数据科学服务紧密集成。虽然你可以像 AWS Cloud9 一样将其用作通用代码编辑器,但它更侧重于以下方面的笔记本:
-
SageMaker Studio 为您管理支持笔记本和终端的实例,类似于 AWS Cloud9,但它使用的是更昂贵的、针对机器学习特定的实例类型,而不是普通的 EC2 实例。
-
现有的 Jupyter 和 JupyterLab 用户会感到非常熟悉。
-
如果您使用 AWS 数据科学服务,集成到 AWS 数据科学服务中会非常方便。
其他云服务提供商也提供了类似的服务作为其平台的一部分,例如微软的 Azure Machine Learning Studio。如果您想利用与它集成的其他服务,一个完整的数据科学环境非常有用。否则,一个更简单的编辑器可能更容易使用和操作。
由云实例支持的本地编辑器:Visual Studio Code
AWS Cloud9 和 SageMaker Studio 都是完全基于云的,包括基于浏览器的编辑器。尽管这种方法有很多好处——操作简单且非常安全——但有些人发现基于浏览器的编辑器比本地编辑器更繁琐。一个折中的方法是使用由云实例支持的本地编辑器,如 PyCharm 或 Visual Studio Code (VS Code)。
尤其是 VS Code 是一个非常受欢迎、功能强大的编辑器,它提供了对远程代码执行的良好集成支持,称为 Visual Studio Code Remote—SSH。使用此功能,您可以使用您选择的任意云实例评估任何代码。此外,VS Code 还内置了对笔记本的支持,它可以在同一远程实例上运行,为数据科学家提供无缝的用户体验。
混合方法的主要缺点是您必须部署一个机制来管理本地编辑器使用的云实例。这可以通过像 Gitpod (www.gitpod.io) 这样的项目来实现。例如,一个相对简单的方法可能是为每个用户启动一个容器,并配置他们的编辑器自动连接到他们的个人容器。
2.1.3 笔记本
前一节介绍了原型循环的非常基础的部分:在编辑器中编写代码,在终端中评估它,并分析终端上打印出的结果。这几十年来一直是开发软件的经典方式。
许多数据科学家熟悉另一种软件开发方式:在称为笔记本的单个文档中编写和评估代码。笔记本方法的一个定义性特征是代码可以作为小的片段或单元格增量编写,可以即时评估,以便其结果显示并存储在代码旁边。笔记本支持丰富的输出类型,因此,与终端中仅输出纯文本不同,输出可以包括任意可视化。当原型新的数据科学应用或分析现有数据或模型时,这种方法很方便。
许多独立的笔记本环境可供选择,其中大多数针对特定的编程语言。知名的环境包括用于 R 的 RMarkdown 笔记本、用于 Scala 的 Zeppelin 和 Polynote、用于 Wolfram 语言的 Mathematica,以及用于 Python(和其他语言)的 Jupyter,这是目前最受欢迎的笔记本环境。
考虑到笔记本在数据科学中的普遍性和实用性,很难想象一个不支持笔记本的数据科学基础设施。一些基础设施将这种方法推向了极端,并建议所有数据科学代码都应该以笔记本的形式编写。尽管笔记本无疑对探索性编程、分析、教学和快速原型设计很有用,但它们是否是通用软件开发的最佳方法尚不清楚,而这在现实世界的数据科学项目中是一个很大的部分。为了更好地理解笔记本在数据科学堆栈中的作用,让我们首先看看它们的独特优势,如下所述:
-
原型设计循环非常快。你可以在单元格中编写一段代码,然后点击一下按钮,就可以评估它并看到结果紧挨着代码。无需在窗口或标签页之间切换。
-
结果可以是图表、图形或任意可视化。数据框,即数据表,会自动以表格格式可视化。
-
笔记本格式鼓励创建线性叙述,这对人类来说容易阅读和理解。结果可能看起来像一份可执行的研究论文。
-
大多数笔记本 GUI 在浏览器中运行,后端过程可以本地运行,因此您可以以最小的设置开始。
-
尤其是不同平台广泛使用、教授和支持 Jupyter 笔记本。相应地,网上有大量的资料和示例。
-
许多现代数据科学库都是为在笔记本中使用而设计的。它们带有内置的可视化和 API,使得笔记本使用变得方便。
笔记本的所有优势都与用户体验相关。它们可以使数据科学家而不是计算机更加高效和高效。另一方面,笔记本需要一套自己的基础设施,这导致额外的复杂性,可能导致脆弱性。因为计算机不关心笔记本,当人类不在循环中时,我们可以不使用它们执行代码,这在生产部署中是常见的情况。
另一个问题与笔记本的线性、叙述性质有关。虽然人类擅长阅读和理解线性发展的故事,比如你现在正在阅读的,但计算机程序在本质上往往是非线性的。将程序结构为独立但相互交互的模块,每个模块都有一个清晰的逻辑角色,被认为是良好的软件工程实践。模块可以任意调用彼此,形成一个调用图而不是线性叙述。
你还可以将这些模块跨多个项目重用和共享,进一步复杂化依赖图。为了管理大型软件项目,Git 这样的版本控制系统是必不可少的。技术上,在笔记本中编写任意代码、对其进行版本控制,甚至可能创建可组合的笔记本是可能的,但这正在推动该范式的极限,需要多层非标准工具。
而混合媒体输出的笔记本对于探索和分析来说非常出色,传统的 IDE 和代码编辑器则针对结构化编程进行了优化。它们使得管理并编写跨越多个文件的甚至大型代码库变得容易。一些现代 IDE,如 PyCharm、VS Code 或 JupyterLab,在单个界面中支持这两种模式,试图结合两者的优点。
本书提倡一种实用主义方法:我们可以在笔记本闪耀用例中使用笔记本,在其他地方坚持使用传统的软件工程工具。图 2.5 通过叠加原型设计和生产循环中的建议工具扩展了之前的图 2.3。

图 2.5 建议用于原型设计和生产循环的工具
想象一下开始一个新的数据科学项目。甚至在开始创建第一个原型,即进入原型设计循环之前,你可能想花些时间仅仅理解数据和问题域。笔记本是这种开放式探索的好工具,这种探索不旨在产生任何持久的软件工件。
在初步探索阶段之后,你开始原型设计解决方案。该解决方案,一个数据科学应用,本质上是一块软件,通常由多个模块组成,因此我们可以使用针对此目的优化的工具:集成开发环境(IDE)或代码编辑器。结果是应用程序,一个脚本,我们可以在本地和生产环境中将其作为普通代码进行评估,而无需任何额外的复杂性。当代码失败或你想改进模型时,你可以再次回到笔记本中进行分析和探索。我们将在后面的章节中看到这可能在实践中是什么样子。正如之前所述,现代 IDE 可以使在笔记本和编辑器模式之间切换变得无缝,因此循环步骤之间的转换可以发生得尽可能无摩擦。
设置 Jupyter 笔记本环境
实际上设置笔记本环境意味着什么?首先,让我们考虑 Jupyter(许多其他笔记本环境都有类似的架构)的高级架构,如图 2.6 所示。

图 2.6 Jupyter 客户端和服务器
笔记本由两个主要部分组成:一个在浏览器中运行的基于 Web 的用户界面和一个后端进程,即内核,它管理 UI 所请求的所有状态和计算。每个笔记本会话都由一个唯一的内核支持,因此当多个笔记本在不同的浏览器标签页中打开时,通常会有多个内核并行运行。
从基础设施的角度来看,关键问题是内核在哪里运行。它是一个需要在某种服务器上执行的过程。最简单的选项是将内核作为本地进程运行在用户的笔记本电脑上,如图 2.7 中展示的最左侧选项 1。

图 2.7 运行 Jupyter 内核的三个选项
在选项 1 中,所有由笔记本发起的计算都在用户的笔记本电脑上完成,类似于任何 Python 脚本。这种方法具有本地代码评估的所有优缺点,我们在表 2.1 中已经讨论过。特别是,环境不可扩展,难以统一控制。这种方法的主要优点是简单——你可以在笔记本电脑上执行以下命令来开始:
# pip install jupyter
# jupyter-notebook
选项 2 通过在云实例上运行内核来克服本地方法的局限性——与上一节中讨论的相同云工作站。云工作站可以根据用户和用例的需求进行扩展,并为所有数据科学家提供一个统一的环境。一个缺点是,基础设施团队需要设置工作站,包括在云工作站和本地笔记本电脑之间建立安全网络连接,例如虚拟专用网络(VPN)。然而,在初始配置成本之后,这种设置对数据科学来说可以非常高效。
选项 3 是笔记本的“无服务器”方法。而选项 2 提供了一种持续、有状态的云中笔记本电脑的错觉——就像个人工作站一样——选项 3 消除了最初需要服务器来运行内核的概念。毕竟,用户看到的是基于浏览器的 Jupyter UI,所以他们不需要关心后端。
实际上,选项 3 需要一个门户,允许用户打开笔记本。当打开笔记本时,会动态地为笔记本分配一个临时实例。这种方法的例子包括 Google Colab 和开源 MyBinder.org。
这种方法的主要缺点,除了操作复杂性之外,就是笔记本是无状态的。没有持久的本地文件系统或依赖项可以自动跨笔记本内核持久化。这使得体验与维护状态直到你明确删除它的本地笔记本电脑大不相同。此外,这种方法不允许与本地编辑器(如 VS Code)交互,而选项 2 是可以做到的。选项 3 对于快速便笺环境或不需要完整持久工作站的用户来说可能非常好。
2.1.4 将一切整合
让我们总结一下在前几节中我们所学到的内容:
-
为了提高数据科学家的生产力,我们应该优化两个关键活动的舒适性:原型循环和与生产部署的交互。
-
有许多很好的理由让数据科学家从一开始就能无缝地在云端工作。特别是,这将使与生产部署的交互变得更加容易。
-
现代编辑器使得将代码评估推向云端成为可能。这可以使评估环境更具可扩展性,更容易管理,并且比在笔记本电脑上评估代码更接近生产部署。然而,这需要基础设施团队进行一些前期配置工作。
-
笔记本是某些数据科学活动不可或缺的工具,它补充了传统的软件开发工具。你可以在支持其他代码评估的同一云工作站上运行笔记本。
图 2.8 展示了数据科学云工作站的架构。在实际应用中,你有多种方式来实现这种设置。你可以选择一个适合你需求的编辑器或 IDE。你可以在浏览器中以独立的方式使用笔记本,或者将其嵌入到编辑器中,例如使用 Visual Studio Code 或 PyCharm。或者你可以选择一个包含完整代码编辑器的笔记本环境,如 JupyterLab。工作站实例可以是一个在基于云的容器平台上运行的容器,例如 AWS 弹性容器服务(ECS)。

图 2.8 数据科学云工作站
图 2.9 显示了一个包含嵌入式编辑器、终端和笔记本的 Visual Studio Code 会话。通过单次点击,科学家可以在终端上执行编辑器中的代码。通过另一次点击,他们可以更新可视化结果的笔记本视图。终端和笔记本内核可以本地执行或在云工作站上执行。

图 2.9 一个涵盖完整原型循环的 Visual Studio Code 设置
在这种设置下,科学家可以快速迭代原型循环的步骤,如图中所示。
2.2 工作流程介绍
在洞穴人公司的第二天,亚历克斯加入了由哈珀主持的入职培训。哈珀解释了公司如何从多个供应商那里采购有机原料,如何使用手工方法生产各种纸杯蛋糕,以及他们如何处理全国范围内的物流。亚历克斯对生产原始人纸杯蛋糕所涉及的复杂价值链感到困惑。公司里的数据科学家应该如何处理所有相关数据,保持模型更新,并将更新后的预测发送到各个业务系统?这种复杂性似乎超出了亚历克斯在学术界使用笔记本时必须处理的范围。

我们在上一节中介绍的开发环境是我们生产健壮、生产就绪的数据科学应用的旅程中的第一个停靠点。它提供了编写代码、评估它和分析结果的方法。现在,我们应该编写什么代码以及如何编写?
当提到像机器学习、人工智能或数据科学这样的术语时,它们通常会唤起一个模型的概念。我们所说的模型是指任何对世界的计算抽象,它接受一些输入,执行一些计算,并产生一个输出,如图 2.10 所示。在数据科学的领域内,这些模型通常被表示为人工神经网络或使用如逻辑回归这样的统计方法。

图 2.10 一个模型
为现实世界现象构建准确的模型并不容易。历史上,这通常由经过广泛理论培训并对他们的问题领域有深刻理解的科学家来完成。根据公司的不同,可能会有这样的期望,即数据科学家主要专注于构建模型。然而,在商业环境中构建实际有用的模型与在研究论文中发布模型设计是截然不同的体验。
考虑一个常见的商业环境,比如前面提到的 Caveman Cupcakes。数据科学家 Alex 面临着以下三个挑战:
-
很可能无法将 Caveman Cupcakes 的整个业务作为一个单一模型来建模。相反,数据科学家被期望专注于建模一些特定的业务问题,例如,构建一个计算机视觉模型来自动检测生产线上的次品蛋糕,或者使用混合整数规划来优化物流。因此,随着时间的推移,该公司的数据科学家将产生一系列模型,每个模型都有其特定的要求和特征。
-
在商业环境中,模型不能仅仅是一个抽象的数学结构。它需要在实践中执行计算,这意味着它需要用编程语言实现,并且需要可靠地执行。这可能是一项高度非平凡的软件工程练习,特别是因为我们不能无限期地等待结果。我们可能需要并行运行一些操作,并在像 GPU 这样的专用硬件上运行一些操作。
-
除了图 2.10 中的大圆圈,我们不应忘记两个箭头:输入和输出。我们的模型需要接收准确、通常不断更新的数据,这并非易事。最后,模型的结果需要最终到达一个可以为企业带来利益的地方,比如数据库、规划电子表格或另一个软件组件。
总结来说,数据科学家需要理解需要解决的业务问题,设计一个模型,将其实现为软件,确保它获得正确的数据,并找出结果发送的位置。为了实现这一点,数据科学家在上一节中介绍的原型设计循环上花费了大量时间。
一旦我们适当地解决了这个特定的业务问题,我们就对另一个业务问题重复同样的过程。这个循环无限重复。模型从不完美,而且很少有公司会缺乏业务问题来优化。
此外,所有这些系统都必须可靠地运行,最好是 24/7。这需要与生产部署进行大量交互,因为模型不断暴露于现实世界的熵和数据中,这会侵蚀它们,就像任何暴露于自然元素中的现实世界机器一样。这是我们在上一节中讨论的第二个循环。
因此,我们得到了一个数据管道和模型的丛林——如图 2.11 所示的一个大型的嗡嗡作响的工厂——这需要持续的维护。为了使工厂可理解和可操作,我们需要对如何构建这些模型施加一些结构。这是将建模和数据管道,或工作流,作为我们基础设施中一等实体的主要动机。

图 2.11 许多模型和数据管道
2.2.1 工作流的基本概念
在这个背景下,工作流是一个有向图,即由节点或步骤(如图 2.12 中的圆圈所示)通过有向边(箭头)连接的集合。这种表示方式捕捉了步骤之间的先后关系。例如,在图 2.12 中,我们可以明确知道 A 必须在 B 之前发生。

图 2.12 A 在 B 之前发生的工作流
步骤的顺序并不总是完全明确的。在图 2.13 中,我们知道 A 必须在 B 之前执行,C 必须在 D 之前执行,但 B 和 C 之间的相互顺序是未定义的。

图 2.13 B 和 C 可以以任何顺序执行的工作流
我们可以使用这样的工作流来表示,我们不在乎 B 是否在 C 之前执行,只要两者都在 D 之前执行。这个特性很有用,因为它允许我们并行执行 B 和 C。
图 2.14 描述了一个具有循环的有向图:在 C 之后,我们再次回到 A。自然地,这会导致无限循环,除非我们定义某种条件来定义停止条件。或者,我们可以简单地禁止具有循环的图,并决定只有这样的图,有向无环图或 DAG,才是有效的工作流。实际上,仅支持 DAG 是工作流引擎中常见的选择。

图 2.14 带有循环的工作流,即循环图
为什么亚历克斯或其他数据科学家应该关心 DAGs?考虑以下三个原因:
-
它们引入了一个共同的词汇——步骤及其之间的转换——这使得编写和理解结构化为 DAGs 的非平凡应用变得更加容易。
-
它们使我们能够明确操作顺序。这在操作顺序比简单的线性顺序更复杂时尤其有用,比如你在笔记本中看到的那样。通过使操作顺序清晰明确,我们的数据管道和模型丛林变得更加易于管理。
-
它们允许我们指出操作顺序无关紧要的情况,如图 2.13 所示。我们可以自动并行化这些操作,这是高性能的关键。在典型的数据科学应用中,并行化的机会很多,但如果它们不是明确指出,大多数情况下计算机无法自动识别。
总结一下,在高层面上,你可以将 DAGs 视为一种语言,与其说是编程语言,不如说是人类之间交流的正式结构。它们允许我们以简洁易懂的方式讨论复杂的操作序列。
2.2.2 执行工作流程
如果 DAGs 只是谈论数据科学应用结构的抽象方式,那么实际操作中是如何实现工作流程执行的?最终,执行工作流程是工作流程编排器(也称为作业调度器)的工作,我们将在下一节讨论。在我们深入探讨各种编排器——而且有成百上千种之前——深入了解将抽象 DAG 转换为执行代码所需关注点是很有用的。
具体的工作流程涉及三个独立的关注点:应该执行什么代码(步骤内部的代码是什么),代码应该在何处具体执行(某个地方的计算机需要执行代码),以及步骤应该如何编排。这三个关注点对应于我们在第一章中介绍的基础设施堆栈的不同层,如图 2.15 所示。

图 2.15 工作流程执行的三关注点
架构层定义了数据科学家在步骤中应该编写什么代码。它是工作流程的用户界面层。不同的工作流程框架提供不同类型的抽象,针对不同的用例进行了优化。其中一些是图形化的(你可以通过拖放圆圈来定义工作流程),一些是基于配置的,还有一些是定义为代码的。
从数据科学家的角度来看,架构层是工作流程中最明显的一部分。堆栈顶部的层更接近数据科学家的兴趣。架构层在很大程度上定义了在系统中自然表达的数据科学应用类型,这使得它可能是选择工作流程框架时最重要的考虑因素。
计算资源层决定了用户的代码具体在哪里执行。想象一下一个包含 600 个并行步骤的工作流程。每个步骤在一个 16 核 CPU 上执行 30 秒。如果计算层只包含一个 16 核实例(也许计算层是一台笔记本电脑!),执行这些步骤需要五小时。相比之下,如果计算层是一个包含 100 个实例的集群,这些步骤将在三分钟内执行。正如你所看到的,计算层对工作流程的可扩展性有很大影响,我们将在第四章和第五章中详细讨论这一点。
最后,我们需要一个系统来遍历 DAG(有向无环图),将每一步发送到计算层,并在继续之前等待它们的完成。我们称这个系统为作业调度器。调度层不需要关心正在执行什么代码以及执行代码的计算机具体位于何处。它的唯一责任是按照 DAG 定义的顺序调度步骤,确保在图中的后续步骤执行之前,每个步骤都成功完成(这个术语的技术名称是拓扑顺序)。
尽管这个过程可能听起来欺骗性地简单,但作业调度器层是工作流程的鲁棒性和可操作性的保证。它需要监控计算层是否成功完成步骤,如果不成功,它需要重试。它可能需要为成千上万的并发执行步骤和成千上万个工作流程做这件事。从用户的角度来看,作业调度器有观察执行的方式,可能是一个 GUI,以及如果工作流程失败,向所有者发出警报的方式是理想的。
除了操作方面的考虑,我们可以根据它们想要如何指定 DAG 来区分两种主要的作业调度器类型。那些要求在执行开始之前完全指定 DAG 的调度器被称为调度静态 DAG。另一种类型的调度器允许在执行过程中增量地构建 DAG,这被称为动态 DAG。这两种方法都有其优缺点。
每个工作流程框架都需要解决这三个问题。每个问题本身都是一个深奥的主题,需要大量的工程努力。除此之外,这些问题以各种方式相互作用。不同的框架最终会做出不同的技术选择,这些选择会影响可扩展性和鲁棒性,并影响用户体验,这取决于它们针对的使用案例以及他们愿意做出的权衡。因此,表面上看似相似的数百个工作流程框架——它们都声称可以执行 DAGs——但在底层存在显著差异。
2.2.3 工作流程框架的世界
维基百科上关于科学工作流程系统的文章列出了 20 个针对科学应用的知名工作流程框架。另一篇关于工作流程管理系统的文章列出了更多针对面向业务用例的系统。这两个列表都遗漏了许多流行的开源框架和资金充足的新创公司。这个领域也在迅速变化,所以当你阅读这本书的时候,任何试图列出所有框架的尝试都会显得过时。
我们不试图对所有现有的框架进行排名,而是提供以下标准来评估框架。这个标准基于我们在上一节中提到的三个问题:
-
架构—实际的代码看起来是什么样子,以及系统对数据科学家来说看起来和感觉如何。系统提供的抽象是否使数据科学家更有效率,并允许他们更快地交付端到端的数据科学应用?
-
作业调度器—工作流程是如何被触发、执行和监控的,以及如何处理失败。在没有停机时间的情况下管理整个工作流程丛林有多容易?
-
计算资源—代码在实际中执行的位置。系统能否处理具有不同资源需求(如 GPU)的步骤,以及系统能并行执行多少步骤?
你的特定用例和业务环境应该决定你如何权衡这三个维度。对于一个只有几个小型用例的小型初创公司来说,计算资源可能不是一个大问题。对于一个有一个大型用例的公司来说,计算资源可能比其他任何东西都重要。对于一个由 Haskell 专家紧密团结在一起的团队来说,一个理想的架构与一个由来自不同背景的数据科学家组成的大型、分布式组织相比,看起来非常不同。
表 2.2 提供了一个比较工作流程框架的示例标准。我们选择了五个流行的框架进行说明。因为这本书是关于数据科学基础设施的,所以我们把比较集中在以下对数据科学应用重要的问题上:
-
架构是否专门设计来支持数据科学应用,还是它是通用的?
-
调度器是否设计为高可用(HA),即调度器本身是否是单点故障?这很重要,因为没有任何工作流比协调它们的工作流调度器更可靠。理想情况下,我们希望能够保持任意数量的工作流运行,而无需担心调度器会失败。
-
对计算资源支持有多灵活?数据科学应用程序通常计算密集,有时对硬件要求(例如,特定的 GPU 模型)很挑剔,因此这是一个有用的功能。
表 2.2 评估工作流框架的示例标准
| 架构 | 调度器 | 计算 | |
|---|---|---|---|
| Apache Airflow | 任意 Python 代码,不特定于数据科学 | 优秀的 GUI;调度器非高可用 | 通过 executors 支持许多后端。 |
| Luigi | 任意 Python 代码,不特定于数据科学 | 基础版本;非高可用 | 默认情况下,调度器在本地执行 Python 类,Tasks。它们可以将工作推送到其他系统。 |
| Kubeflow Pipelines | Python,针对数据科学用例 | 优秀的 GUI;底层使用名为 Argo 的项目;由 Kubernetes 提供一些高可用性 | 步骤在 Kubernetes 集群上运行。 |
| AWS Step Functions | 基于 Amazon States Language 的 JSON 配置 | 设计为高可用;由 AWS 管理 | 与一些 AWS 服务集成。 |
| Metaflow | Python,针对数据科学用例 | 用于原型设计的本地调度器;支持生产中的高可用调度器如 Step Functions | 支持本地 tasks 以及外部计算平台。 |
这里是对所涵盖框架的快速概述:
-
Apache Airflow 是 Airbnb 在 2015 年发布的一个流行的开源工作流管理系统。它使用 Python 实现,并使用 Python 定义工作流。包括 AWS 和 GCP 在内的多个商业供应商提供托管 Airflow 作为服务。
-
Luigi 是另一个知名的基于 Python 的框架,由 Spotify 在 2012 年开源。它基于动态 DAG 的概念,通过数据依赖定义。
-
Kubeflow Pipelines 是一个嵌入在开源 Kubeflow 框架中的工作流系统,用于在 Kubernetes 上运行的数据科学应用程序。该框架由 Google 在 2018 年发布。在底层,工作流由一个名为 Argo 的开源调度器进行调度,它在 Kubernetes 生态系统中很受欢迎。
-
AWS Step Functions 是 AWS 在 2016 年发布的一个托管服务,不是开源服务。DAGs 使用 Amazon States Language 以 JSON 格式定义。Step Functions 的一个独特功能是工作流程可以运行很长时间,长达一年,依赖于 AWS 提供的高可用性保证。
-
Metaflow 是一个针对数据科学应用程序的全栈框架,最初由本书的作者发起,并于 2019 年由 Netflix 开源。Metaflow 专注于全面提高数据科学家的生产力,将工作流程视为一等构造。为了实现可扩展性和高可用性,Metaflow 与调度器如 AWS Step Functions 集成。
除了这里列出的框架外,还存在许多其他有希望的框架,其中一些专门针对数据科学应用程序。本书的目的不是专注于任何特定的框架,这些框架在网上有很好的文档,而是介绍数据科学基础设施的全栈,工作流程只是其中一部分,以便您可以为每一层选择最佳的技术方法。
当选择专门针对数据科学用例的工作流程框架时,请考虑以下因素:
-
在大多数商业环境中,数据科学家的生产力应该是首要考虑的。选择一个针对数据科学用例特别适合的架构框架。构建数据科学应用程序需要的不仅仅是工作流程,因此在做出选择时,要考虑完整的数据科学堆栈(图 1.3),而不仅仅是调度层。
-
从长远来看,系统的鲁棒性、可扩展性和高可用性等运营问题往往会超越其他技术问题。这些特性既是系统设计的涌现特性,也是多年与实际用例战斗的结果,因此一夜之间修复这些问题并不容易。因此,选择一个具有稳定操作性和可扩展性记录的系统是有意义的。我们将在第四章中详细讨论这个话题。
-
不受计算资源的限制可以大幅提高生产力,因此请选择一个与您的计算层无缝集成的框架。关于这一点,请参阅第三章。
在接下来的章节中,我们将使用符合前面提到的三个标准的 Metaflow 作为示例框架。其原则和示例足够通用,以至于如果需要,应该不难将示例适应到其他框架。
如果设置本章涵盖的所有内容感觉像是一项巨大的投资,请不要担心——随着您业务需求的增长,您可以分阶段扩展工具链的功能。表 2.3 根据基础设施服务的数据科学家数量,为不同规模的组织提供了推荐的配置。括号中的选项指的是图 2.7。
表 2.3 开发环境投资多少
| 小型(1-3 用户) | 中型(3-20 用户) | 大型(20+用户) | |
|---|---|---|---|
| 云账户 | 推荐 | 必需 | 必需 |
| 数据科学工作站 | 笔记本电脑就足够了。为所有数据科学家指定一个通用设置。 | 考虑使用现成的云服务或一个简单的手动启动的集成 IDE 的云工作站。 | 投资于一个自助、自动配置的集成 IDE 工作站。 |
| 笔记本 | 在笔记本电脑上本地运行笔记本内核(选项 1)。 | 一种简单的方法是在云工作站上支持笔记本内核(选项 2)。如果笔记本被积极利用,考虑提供临时笔记本(选项 3)。 | |
| 工作流程 | 非常推荐——您可以使用简单的调度器在单个实例上运行工作流程。 | 选择一个最大化生产力和迭代速度的工作流程调度器。 | 选择一个除了生产力外,还提供高可用性、可观察性和可伸缩性的调度器。 |
摘要
-
数据科学家需要一个提供以下两个关键活动优秀人体工程学的开发环境:
-
原型循环:编写、评估和分析应用程序代码
-
与生产部署的交互:部署、监控和调试生产应用程序
-
-
基于云的数据科学工作站是处理两项活动的一种有效方式。您可以在本地开发代码,但在类似于生产的环境中进行评估。
-
笔记本是数据科学工具链的必要但不充分的部分。笔记本在刮擦板式原型设计和分析结果方面表现出色。您可以将它们集成到与工作站上的 IDE 和终端协同工作的环境中。
-
工作流程是结构化数据科学应用程序的有用抽象。工作流程提供了一系列好处:它们易于理解和解释,有助于管理随着数据科学应用程序数量的增长而增加的复杂性,并且可以使执行更可扩展和高效。
-
存在数十种不同的工作流程框架。选择一个为构建数据科学应用程序提供优秀人体工程学的框架。
-
一个作业调度器负责执行工作流程。选择一个与您的计算基础设施集成良好、足够可扩展且高度可用的调度器。
3 介绍 Metaflow
本章涵盖
-
在 Metaflow 中定义一个接受输入数据并产生有用输出的工作流程
-
在单个实例上使用并行计算优化工作流程的性能
-
在笔记本中分析工作流程的结果
-
在 Metaflow 中开发一个简单的端到端应用程序
现在我们已经设置了开发环境,您可能已经迫不及待地想要动手编写实际的代码了。在本章中,您将学习使用 Metaflow 开发数据科学应用程序的基础知识,Metaflow 是一个框架,展示了基础设施堆栈的不同层如何无缝协作。
我们在上一章中讨论的开发环境决定了数据科学家如何开发应用程序:通过在编辑器中编写代码,在终端中评估它,并在笔记本中分析结果。在这个工具链之上,数据科学家使用 Metaflow 来确定编写什么代码以及为什么编写这些代码,这正是本章的主题。接下来的章节将涵盖确定工作流程在哪里以及何时执行的基础设施。
我们将从零开始介绍 Metaflow。您将首先学习语法和基本概念,这些概念允许您在 Metaflow 中定义基本的工作流程。之后,我们将介绍工作流程中的分支。分支是嵌入并发到工作流程中的直接方法,这通常通过并行计算带来更高的性能。
最后,我们通过构建一个实际的分类器应用程序将这些概念付诸实践。通过完成一个端到端的项目,您将学习 Metaflow 如何通过提供在笔记本中进行本地代码评估、调试和结果检查的工具来驱动原型设计循环。
阅读本章后,您或您支持的数据科学家将能够通过结合 Metaflow 与其他现成库来开发功能齐全的数据科学应用程序。随后的章节将在此基础上构建,并展示您如何通过利用完整的基础设施堆栈来使应用程序更具可扩展性、高度可用性和易于协作。您可以在mng.bz/xnB6找到本章的所有代码列表。
3.1 Metaflow 的基础知识
亚历克斯意识到数据科学家的工作远不止构建模型。作为 Caveman Cupcakes 的第一位数据科学家,亚历克斯有机会通过独立构建完整的数据科学解决方案来帮助公司。亚历克斯既感到兴奋又感到害怕。亚历克斯是一位海洋生物学家,而不是软件工程师——希望围绕模型构建必要的软件不会太令人畏惧。鲍伊建议他们看看 Metaflow,这是一个据说可以简化构建端到端数据科学应用程序的框架。

Metaflow 于 2017 年在 Netflix 启动,旨在帮助数据科学家独立构建、交付和运营完整的数据科学应用。该框架旨在解决一个实际业务需求:像 Netflix 这样的大型公司可能有数十个甚至数百个潜在的数据科学用例,类似于洞穴人蛋糕店。公司希望在现实设置中快速测试新想法,最好是不需要分配大量团队来研究一个实验性想法,然后将最有前途的实验推广到生产中,而无需过多的开销。
第一章中介绍的思想是 Metaflow 的动力:我们需要考虑数据科学的整个堆栈,我们希望涵盖项目从原型到生产的整个生命周期,我们希望通过关注数据科学家的生产力来实现这一点。我们可以使用第一章中引入的四个“V”来回答“为什么是 Metaflow?”这个问题,如下所示:
-
体积—Metaflow 通过提供一种统一的方式来构建它们,利用工作流程的通用语言,帮助以更少的人力资源交付更多的数据科学应用。它通过减少数据科学应用嗡嗡声中的偶然复杂性来实现这一点。
-
多样性—Metaflow 并未针对任何特定类型的数据科学问题进行优化。它通过在堆栈的低层提供更多观点,而在顶层、特定领域的层提供较少观点,帮助交付多样化的应用。
-
速度—Metaflow 加快了原型设计循环以及与生产部署的交互。它通过在整个框架的所有部分优先考虑人类生产力来实现这一点,例如,允许数据科学家使用惯用的 Python。
-
有效性—Metaflow 通过强制执行最佳实践,使构建和运营生产级应用成为可能,即使对于没有 DevOps 背景的数据科学家也是如此,从而使得应用更加健壮。
从数据科学家的角度来看,Metaflow 的全部内容都是使原型设计循环和生产部署的交互尽可能平滑,正如我们在第 2.1 节中介绍的那样。要做好这一点,需要确保基础设施堆栈的所有层都能够无缝集成。而一些框架只处理工作流程、计算资源或模型操作,而 Metaflow 旨在解决数据科学的全堆栈问题,如图 3.1 所示。

图 3.1 Metaflow 将数据科学堆栈的各个层绑定在一起。
从工程角度来看,Metaflow 充当集成的基础,而不是试图重新发明堆栈的各个层。公司已经为数据仓库、数据工程、计算平台和作业调度构建或购买了优秀的解决方案,更不用说开源机器学习库的繁荣生态系统了。试图用现有的成熟系统来满足数据科学家的需求既不必要也不高效。我们应该希望将数据科学应用集成到周围的业务系统中,而不是将它们孤立在一个孤岛上。
Metaflow 基于插件架构,允许在不同的堆栈层使用不同的后端,只要这些层能够支持一组基本操作。特别是,Metaflow 被设计成一个云原生框架,依赖于所有主要云提供商提供的基本计算和存储抽象。
Metaflow 有一个平缓的采用曲线。你可以在笔记本电脑上以“单玩家模式”开始,随着需求的增长,逐渐将基础设施扩展到云上。在本章的剩余部分,我们将介绍 Metaflow 的基础知识。在接下来的章节中,我们将扩大其影响力,展示如何解决越来越复杂的数据科学应用,涵盖堆栈的所有层,并增强多个数据科学家之间的协作。
如果你想使用其他框架而不是 Metaflow 来构建你的基础设施,你可以阅读下一节以获得灵感——这些概念也适用于许多其他框架,或者你可以直接跳到第四章,该章节专注于堆栈的基础层:计算资源。
3.1.1 安装 Metaflow
Metaflow 从第 2.1 节中引入的基于云的开发环境中受益匪浅,包括笔记本在内。然而,你只需一台笔记本电脑就可以开始使用。截至本书编写时,Metaflow 支持 OS X 和 Linux,但不支持 Windows。如果你想在 Windows 上测试 Metaflow,可以使用 Windows Subsystem for Linux、基于 Linux 的本地 Docker 容器或前一章中讨论的基于云的编辑器。
Metaflow 支持所有高于 Python 3.5 版本的 Python。安装 Python 解释器后,你可以像安装其他 Python 包一样使用 pip 安装 Metaflow,如下所示:
# pip install metaflow
在本书中,带有#前缀的行,如上一行,意味着需要在没有井号标记的终端窗口中执行。
注意:在所有示例中,我们假设 pip 和 python 命令指的是 Python 的最新版本,该版本应高于 Python 3.5。在某些系统中,正确的命令被称为 pip3 和 python3。在这种情况下,相应地替换示例中的命令。
你可以通过执行以下代码来确认 Metaflow 是否工作:
# metaflow
如果 Metaflow 安装正确,它应该会打印出一个带有如下标题的顶级帮助信息:
Metaflow (2.2.5): More data science, less engineering
您可以不使用云(AWS)账户就跟随本章中的示例,但如果您想尝试下一章中的所有示例,您将需要一个账户。您可以在aws.amazon.com/free注册一个免费账户。
3.1.2 编写基本工作流
如前一章所述,工作流的概念有助于结构化数据科学应用。如果您不是软件工程师出身,将您的应用视为工作流步骤而不是一组任意的 Python 模块要容易得多。
想象一下我们的主角 Alex 编写一个 Metaflow 工作流。Alex 已经熟悉笔记本,所以将 Python 的小片段作为步骤编写看起来是可行的。步骤就像强化版的笔记本单元格。使用任意的 Python 类、函数和模块来拼凑一个应用将需要更多的认知努力。
让我们从经典的 Hello World 示例开始。在 Metaflow 中,一切都是以工作流的概念为中心的,或者简单地说,是一个流程,它是一个有向无环图(DAG),如 2.2 节中讨论的那样。我们定义在列表 3.1 中的 HelloWorldFlow 对应于图 3.2 中展示的 DAG。

图 3.2 HelloWorldFlow
要在 Metaflow 中定义工作流,您必须遵循以下六个简单规则:
-
流程定义为从 FlowSpec 类派生的 Python 类。您可以自由命名您的流程。在本书中,按照惯例,流程类的名称以 Flow 后缀结尾,如 HelloWorldFlow。您可以在该类中包含任何方法(函数),但用@step 注解的方法会被特别处理。
-
步骤(节点)是类的成员方法,用@step 装饰器进行注解。您可以在方法体中编写任意的 Python 代码,但最后一行是特殊的,如下所述。您可以在方法中包含一个可选的文档字符串,解释步骤的目的。在第一个示例之后,我们将省略文档字符串以使书中的列表简洁,但在实际代码中建议使用它们。
-
Metaflow 将方法体作为称为任务的原子计算单元执行。在这样一个简单的流程中,步骤与任务之间是一对一的对应关系,但情况并不总是如此,我们将在 3.2.3 节中稍后看到。
-
第一步必须命名为 start,这样流程就有了一个明确的起点。
-
步骤(节点)之间的边(箭头)是通过在方法的最后一行调用 self.next(step_name)来定义的,其中 step_name 是要执行的下一步的名称。
-
最后一步必须命名为 end。因为结束步骤完成了流程,所以最后一行不需要 self.next 转换。
-
一个 Python 文件(模块)必须只包含一个流程。你应该在文件的底部,在 if name == 'main'条件语句内实例化流程类,这会导致类仅在文件作为脚本被调用时被评估。
相应的源代码列在下一代码列表中。
列表 3.1 Hello World
from metaflow import FlowSpec, step
class HelloWorldFlow(FlowSpec): ❶
@step ❷
def start(self): ❸
"""Starting point"""
print("This is start step")
self.next(self.hello) ❹
@step
def hello(self):
"""Just saying hi"""
print("Hello World!")
self.next(self.end)
@step
def end(self): ❺
"""Finish line"""
print("This is end step")
if __name__ == '__main__':
HelloWorldFlow() ❻
❶ 工作流是通过从 FlowSpec 派生来定义的。
❷ @step 装饰器表示工作流中的一个步骤。
❸ 第一步必须命名为 start。
❹ 对 self.next()的调用表示工作流中的一个边。
❺ 最后一步必须命名为 end。
❻ 实例化工作流允许它被执行。
下面是如何阅读和理解与 Metaflow 流程相对应的代码:
-
首先,找到启动方法。你知道这是执行开始的地方。你可以阅读这个方法来了解它在做什么。
-
通过查看 start 的最后一条来查看下一个步骤。在这种情况下,它是 self.hello,即 hello 方法。
-
阅读下一步的代码,并确定那之后的步骤。继续这样做,直到你到达结束步骤。
做这件事比试图理解一组任意的 Python 函数和模块要简单得多,这些函数和模块甚至没有明确的开始和结束。将代码保存在文件中,命名为 helloworld.py。你可以像任何 Python 脚本一样执行 Python。首先,尝试运行以下代码:
# python helloworld.py
这将验证流程结构而不执行任何步骤。Metaflow 有一系列关于被认为是有效 DAG 的规则。例如,所有步骤都必须相互连接,图中不能有任何循环。如果 Metaflow 检测到你的 DAG 有任何问题,将显示有用的错误信息。
Metaflow 在每次执行脚本时都会运行一个基本的代码检查,即一个代码检查器,它可以检测拼写错误、缺失的函数和其他类似的语法错误。如果发现任何问题,将显示错误信息,并且不会执行其他操作。这可以节省大量时间,因为问题可以在代码执行之前被发现。然而,有时代码检查器可能会产生误报。在这种情况下,你可以通过指定以下内容来禁用它:
# python helloworld.py --no-pylint
现在尝试运行以下代码:
# python helloworld.py show
这应该会打印出 DAG 的文本表示,对于 HelloWorldFlow 来说,对应于图 3.2。你可以看到输出中包含了文档字符串,因此你可以使用 show 命令来快速了解一个不熟悉的流程做了什么。
现在,是时候检验真伪了:让我们执行流程,如下所示!我们称流程的一次执行为运行:
# python helloworld.py run
这个命令按顺序执行 start、hello 和 end 方法。如果一切顺利,你应该会看到一系列看起来像这样的输出行:

从你的流程打印到标准输出(也称为stdout)或标准错误(也称为stderr)的每一行都会得到一个类似于之前显示的标题。让我们按照以下方式解析标题:
-
时间戳表示该行输出的时间。您可以通过查看连续的时间戳来大致了解代码的不同部分执行所需的时间。在输出一行和生成时间戳之间可能会出现短暂的延迟,因此不要依赖于时间戳进行任何需要精确时间记录的操作。
-
方括号内的以下信息用于标识一个任务:
-
每个 Metaflow 运行都会获得一个唯一的 ID,即运行 ID。
-
运行按照顺序执行步骤。当前正在执行的步骤用步骤名称表示。
-
步骤可以使用 foreach 构造(参见第 3.2.3 节)产生多个任务,这些任务由任务 ID标识。
-
流名称、运行 ID、步骤名称和任务 ID 的组合在您的 Metaflow 环境中唯一地标识了任何流程运行中的任务。在这里,流名称被省略,因为对于所有行都是相同的。我们称这个全局唯一标识符为路径规范。
-
每个任务由操作系统中的单独进程执行,该进程由进程 ID,即pid标识。您可以使用任何操作系统级别的监控工具,如top,根据进程 ID 监控任务的资源消耗。
-
-
方括号之后是日志消息,这可能是 Metaflow 本身输出的消息,如本例中的“任务开始”,或者是由您的代码输出的行。
关于 ID 的特别之处是什么?运行无数快速实验是数据科学的核心活动——记得我们之前讨论过的原型设计循环。想象一下,编写许多不同的代码变体,运行它们,每次都会看到略有不同的结果。过了一段时间,很容易失去对结果的跟踪:是第三个版本产生了有希望的结果,还是第六个版本?
在过去,勤奋的科学家可能会在实验笔记本中记录所有他们的实验及其结果。十年前,电子表格可能起到了相同的作用,但跟踪实验仍然是一个手动且容易出错的流程。今天,现代数据科学基础设施通过实验跟踪系统自动跟踪实验。
一个有效的实验跟踪系统允许数据科学团队能够检查已运行的内容,明确识别每个运行或实验,访问任何过去的结果,可视化它们,并将实验相互比较。此外,能够重新运行过去的实验并重现其结果是非常受欢迎的。这比听起来要困难得多,因此我们在第六章中专门用许多页面来讨论可重现性这一主题。
独立的实验跟踪产品可以与任何代码一起工作,只要代码被适当配置以向跟踪系统发送元数据。如果您使用 Metaflow 构建数据科学应用程序,您将免费获得实验跟踪——Metaflow 自动跟踪所有执行。之前显示的 ID 是这个系统的一部分。它们允许您在任务完成后立即识别和访问结果。
我们将在 3.3.2 节中更详细地讨论访问过去的结果,但您可以通过使用日志命令来提前体验,该命令允许您检查任何过去运行的输出。使用与您想要检查的任务对应的路径规范执行日志命令。例如,您可以从运行产生的输出中复制并粘贴路径规范,然后执行下一个命令:
# python helloworld.py logs 1609557277904772/start/1
您应该看到一行输出,它与您检查的步骤中的 print 语句相对应。日志子命令有几个选项,您可以通过执行 logs --help 来查看。
最后,请注意 Metaflow 如何将单个 Python 文件转换为一个无需任何模板代码的命令行应用程序。您无需担心解析命令行参数或手动捕获日志。每个步骤都作为一个独立的操作系统级子进程执行,因此可以独立监控。这也是实现容错性和可扩展性的关键特性,我们将在第四章中学习。
3.1.3 管理工作流程中的数据流
数据科学应用程序都是关于处理数据的。一个典型的应用程序从数据仓库中摄取原始数据,以各种方式对其进行转换,将其转换为特征,并可能训练一个模型或使用现有模型进行推理。训练好的模型或预测是工作流程的输出数据。要能够构建这样的工作流程,您需要回答以下三个问题:
-
工作流程应该如何访问输入数据?
-
工作流程应该如何移动转换后的数据,即工作流程的内部状态,跨步骤?
-
工作流程应该如何使其输出对外部系统可用?
通过回答三个问题,您可以确定应用程序的数据流,即通过工作流程传输数据的方式。图 3.3 展示了由三个步骤 A、B 和 C 组成的工作流程中的数据流。

图 3.3 从输入到输出的数据流
在图 3.3 所示的工作流程中,步骤 A 在步骤 B 之前。因为步骤是按顺序执行的,所以步骤 A 处理的数据可以提供给步骤 B,但反之则不行。这样,工作流程的顺序决定了数据如何通过图流动。使用 Metaflow 术语,数据从起始步骤流向结束步骤,就像河水从上游流向下游,但不会反向流动。
为了说明明确数据流和状态的好处,考虑图 3.4 所示的示例,它展示了一个简单的 Jupyter 笔记本。

图 3.4 笔记本中隐藏的状态和未定义的数据流
输出可能看起来令人惊讶。为什么 x 的值打印为 2,尽管它在上一个单元格中只被分配为 1?在这种情况下,用户首先从顶部到底部评估了所有单元格,然后决定重新评估中间的单元格。在笔记本中按顺序评估单元格是一种常见的做法。它是它们作为不受约束的草稿本吸引力的一部分。
Jupyter 内核在幕后维护所有变量的状态。它允许用户根据其隐藏状态以任意顺序评估单元格。就像在这个例子中,结果可能非常令人惊讶,实际上无法重现。相比之下,工作流程通过使评估顺序和相应的数据流明确来解决这个问题。
与使用笔记本不同,这三个单元格可以组织成如图 3.3 所示的工作流程,这将使得产生不一致的结果变得不可能。笔记本在数据科学堆栈中扮演着重要的角色——它们便于快速探索和分析。然而,如前所述,将任何严肃的应用或建模代码作为具有明确数据流的工作流程结构会更好。
在工作流程中转移和持久化状态
实际中数据流看起来是怎样的呢?如果所有步骤都在单台计算机上的单个进程中执行,我们就可以在内存中保持状态,这是构建软件的常规方式。数据科学工作流程的挑战在于,我们可能希望在不同计算机上并行执行步骤,或者访问特殊硬件,如 GPU。因此,我们需要能够在步骤之间转移状态,这些步骤可能在不同物理计算机上执行。
我们可以通过持久化状态来实现这一点,即在步骤完成后存储所有与后续步骤相关的数据。然后,当新的步骤开始时,即使在另一台计算机上,我们也可以重新加载状态并继续执行。图 3.5 说明了这个想法。

图 3.5 通过公共数据存储在步骤之间转移状态
在图 3.5 中,状态由一个变量 x 组成。变量首先在步骤 A 中初始化,然后在步骤 B 和 C 中增加。当步骤完成时,x 的值被持久化。在步骤开始之前,其值被重新加载。自然地,对于需要跨步骤访问的每一块状态、每一个变量,都需要重复这个过程。
你可以通过许多不同的方式实现持久化状态的想法。许多工作流程框架对此并不特别有意见。如何加载和存储状态取决于用户,可能使用数据库作为持久化层。生成的流程代码可能类似于图 3.6 中所示。每个步骤都包括加载和存储数据的代码。虽然这种方法很灵活,但它增加了大量的样板代码。更糟糕的是,它给数据科学家增加了认知负担,因为他们必须明确决定要持久化哪些数据以及如何持久化。

图 3.6 手动持久化状态
根据我们的经验,数据科学家在存储可能感觉多余的数据时可能会相当保守,特别是当需要对其做出明确选择时,比如图 3.6 中的情况。如果工作流程框架使得在步骤之间移动状态变得繁琐,用户可能会倾向于将许多无关的操作打包在一个步骤中,以避免添加加载和存储数据的样板代码。或者,他们可能会选择只持久化下游消费者绝对需要输出的数据。
虽然从技术上讲,这种节俭的方法可以工作,但过于节俭地处理数据并不利于应用程序的长期健康。首先,工作流程结构应该主要优化应用程序的逻辑结构,以便其他读者可以轻松理解它。例如,对于数据预处理和模型训练有单独的步骤是有意义的——你不应该合并步骤只是为了避免转移状态。其次,想象一下工作流程在生产中失败的情况。你希望获得最大量的信息来了解出了什么问题。
总结来说,拥有一种使状态转移对用户几乎透明的机制是有益的。我们不希望用户必须担心步骤可能在实际不同的计算机上执行的技术细节。同样,我们也不希望他们为了避免使用样板代码而牺牲工作流程的可读性。
最重要的是,我们希望鼓励用户自由地持久化数据,即使持久化数据并不是使工作流程功能性的严格必要条件。每次运行持久化的数据越多,工作流程就越可观察,这补充了实验跟踪系统存储的元数据。如果在每个步骤之后都足够持久化数据,我们就可以全面了解工作流程在执行期间和执行后的状态,如图 3.7 所示。

图 3.7 持久化状态允许你观察工作流程执行。
这种方法在长期来看会带来巨大的回报。您将能够更有效地监控和调试工作流程,并无需额外工作即可重现、重用和共享其结果。另一方面,存储数据需要花费金钱,但多亏了云,存储成本与数据科学家的时间相比变得相当微不足道。此外,我们并不提倡反复存储 输入数据 的副本——关于这一点,请参阅第七章。
Metaflow 工件
为了举例说明如何使数据流对用户几乎透明,让我们考虑 Metaflow 是如何做到的。Metaflow 自动持久化所有 *实例变量**,也就是说,在步骤代码中分配给 self 的任何内容。我们称这些持久化的实例变量为 工件。工件可以是任何数据:标量变量、模型、数据框或任何可以使用 Python 的 pickle 库序列化的 Python 对象。工件存储在称为 数据存储库 的通用数据仓库中,这是由 Metaflow 管理的持久化状态层。您可以在本章后面的侧边栏框“Metaflow 的数据存储库如何工作”中了解更多关于数据存储库的信息。
每个步骤可以产生任意数量的工件。步骤完成后,其工件作为不可变的数据单元持久化到数据存储库中。这些工件永久绑定到步骤上,由产生它们的路径规范标识。这对于实验跟踪至关重要:我们希望产生一个准确且不可修改的审计跟踪,记录运行期间产生的所有内容。然而,后续步骤可能会读取工件并产生自己的版本。
为了使这个概念更加具体,让我们从一个简单的例子开始,该例子在 HelloWorldFlow 的略微修改版本中添加了状态和计数器变量 count,我们在列表 3.1 中介绍了这个例子。为了清晰起见,让我们将流程重命名为 CounterFlow。
如图 3.8 所示,我们在起始步骤中初始化计数器变量 count 为零。我们这样做是通过在 Python 中创建一个实例变量,就像通常那样,self.count = 0。在接下来的添加步骤中,我们将 count 增加 1:self.count += 1。在打印最终值之前,我们在结束步骤中再次将 count 增加 1,最终值是 2。

图 3.8 CounterFlow
下面的列表显示了相应的代码。
列表 3.2 维护状态的简单流程
from metaflow import FlowSpec, step
class CounterFlow(FlowSpec):
@step
def start(self):
self.count = 0 ❶
self.next(self.add)
@step
def add(self):
print("The count is", self.count, "before incrementing")
self.count += 1 ❷
self.next(self.end)
@step
def end(self):
self.count += 1 ❷
print("The final count is", self.count) ❸
if __name__ == '__main__':
CounterFlow()
❶ 初始化计数器为零
❷ 将计数器增加一
❸ 显示最终计数
将流程代码保存到名为 counter.py 的文件中,并像之前一样执行,如下所示:
# python counter.py run
除了 Metaflow 输出的常规消息外,你还应该看到一行说“在增加之前计数为 0”和“最终计数为 2”。假设你已经熟悉 Python 的基础知识,你会注意到当 self.start()、self.add()和 self.end()按顺序调用时,流程的行为就像任何 Python 对象一样。为了复习 Python 中实例变量(数据属性)的工作方式,请查看 Python 教程中关于实例变量的部分,链接为mng.bz/AyDQ。
按照设计,在 Metaflow 中管理状态的语言看起来像自然的、直接的 Python 语法:只需像往常一样使用 self 创建实例变量。排除那些不值得保存的临时值同样简单:只需创建普通、非实例变量,这些变量将在步骤函数退出后清理。
经验法则:使用实例变量(如 self)来存储任何可能具有步骤之外价值的任何数据和对象。仅使用局部变量来存储中间、临时数据。如果有疑问,请使用实例变量,因为它们使调试更容易。
按照设计,在 Metaflow 中状态管理看起来几乎是简单得令人难以置信,但在幕后发生了很多事情。Metaflow 必须解决与数据流相关的以下两个关键挑战:
-
每个任务都作为一个独立的过程执行,可能是在一个独立的物理计算机上。我们必须具体地在进程和实例之间移动状态。
-
运行可能会失败。我们想要了解它们失败的原因,这需要了解失败之前的状态。此外,我们可能想要重新启动失败步骤,而无需从头开始重新启动整个流程。所有这些功能都需要我们持久化状态。
为了解决这些挑战,Metaflow 在每次任务之后都会快照并存储工作流程的状态,存储在 self 中。快照是 Metaflow 的关键特性之一,它使许多其他特性成为可能,例如恢复工作流程、在不同的计算环境中执行任务,特别是易于观察工作流程。
你可以在任务完成时立即观察到实例变量,即使运行仍在执行中。你可以通过多种方式做到这一点,但一种简单的方法是使用 dump 命令,它的工作方式与我们之前使用的 logs 命令类似。只需复制并粘贴你想要观察的任务的路径规范,例如以下示例:
# python counter.py dump 1609651059708222/end/3
如果你使用了对应于最终任务的路径规范,就像上一个例子中那样,你应该会看到一行输出,显示计数器的值为 2。预期地,对于更早的步骤,值会低一些。除了 dump 命令外,你还可以通过编程方式访问工件,例如,在笔记本中使用 Metaflow 客户端 API,我们将在第 3.3.2 节中介绍。
这段讨论仅涉及了工件的基础知识。下一节关于参数将展示如何将工件从流程外部传递到运行中。下一章将详细介绍如何处理大型数据集,有时可能需要特殊处理。稍后,在第六章中,我们将讨论如何处理复杂对象,例如机器学习模型,作为工件。
Metaflow 的数据存储是如何工作的
你不需要这些技术细节就能成功使用或操作 Metaflow,但如果你好奇,以下是 Metaflow 的数据存储在底层是如何工作的。在 Metaflow 完成评估任务后,它会检查用户代码创建的实例变量。所有变量都被序列化,即转换为字节,并存储在 Metaflow 管理的数据存储中。这些序列化对象,称为工件,是 Metaflow 宇宙中的关键概念。
下图说明了在 CounterFlow 的情况下,数据如何在数据存储中移动和持久化。在开始步骤完成后,Metaflow 检测到计数变量。其值为 0 被序列化为字节,目前使用 Python 内置的 pickle 库,但这被视为一个内部实现细节,可能会更改。假设与 0 对应的字节序列为 ab0ef2。这些字节作为不可变 blob(工件)存储在数据存储中(除非它们已经存在)。之后,内部元数据被更新,以便计数变量引用开始步骤的工件 ab0ef21。

数据存储如何内部处理工件
当添加步骤第一次访问计数时,Metaflow 根据元数据从数据存储中获取它。我们知道添加步骤从开始处获取其值,因为流程的顺序。添加步骤增加计数的值,这会导致创建一个新的工件。需要注意的是,我们没有更改之前存储的计数值,因为其在开始时的历史值没有改变。每个步骤都有自己的工件集。这个过程会重复进行到结束步骤。
Metaflow 的数据存储组织成一个内容寻址存储,在概念上类似于 Git 版本控制系统. 内部,工件使用其内容的哈希值命名,因此只需要存储其唯一值的单个副本。换句话说,数据存储自动去重工件。这意味着磁盘空间被高效使用,在大多数情况下,你不必担心创建太多工件以节省空间。
应该是什么步骤?
当你开发一个新的流程时,你可能想知道哪些操作应该属于同一个步骤,以及何时将大步骤拆分成多个单独的步骤是有意义的。虽然没有唯一的正确答案,但将步骤视为检查点可能会有所帮助。如前所述,当步骤(精确地说,是步骤启动的任务)完成时,工件会被持久化。工件成功持久化后,它们将可用于检查,如 3.3.2 节所述。此外,你将能够在任意步骤恢复执行,如 3.3.3 节所述。因此,从执行时间方面来看,保持步骤合理小是有意义的,这样如果发生故障,你不会丢失太多工作。或者,如果你想几乎实时地监控运行的状况,你也需要小步骤。
另一方面,持久化工件和启动任务会产生一些开销。如果你的步骤太小,开销开始主导总执行时间。不过,这种开销很容易注意到:如果你发现它成为问题,你总是可以将微小的步骤合并在一起。
另一个考虑因素是代码的可读性。如果你执行
# python counter.py show
你觉得这个图有意义吗?很可能是步骤太大比步骤太小更损害可理解性。
经验法则:以逻辑步骤结构化你的工作流程,这些步骤应易于解释和理解。如有疑问,应偏向于小步骤。它们通常比大步骤更容易理解和调试。
3.1.4 参数
在上一节中,我们学习了如何使用工件在流程中传递数据,即通过将变量分配给 self。但如果你想在开始时传递数据,即设置流程的参数呢?
例如,假设你正在尝试一个新的模型,并且用各种参数化对其进行训练。在你分析实验结果之后,你应该知道用于训练模型特定版本的参数是什么。作为解决方案,Metaflow 提供了一个名为参数的特殊工件,你可以使用它将数据传递到运行中。参数工件与其他工件一样被跟踪,因此你可以检查分配给任何过去运行的参数。
参数是一个流程级(即类级)构造。它们不绑定到任何特定的步骤,并且自动对所有步骤(包括开始步骤)可用。要定义一个参数,你必须指定以下四个元素:
-
在类级别创建一个参数实例。
-
将参数分配给一个工件,例如,列表 3.3 中的动物和计数。
-
指定用户看到的参数名称,例如,生物和计数,如下所示。工件名称和参数名称可以相同,也可以不同,如列表 3.3 所示。
-
确定参数的类型。默认情况下,参数是字符串。您可以通过指定参数的默认值来更改类型,例如在下一个代码示例中为 count 所示,或者通过显式设置类型为 Python 的一种基本标量类型——str、float、int 或 bool,如列表 3.3 中的 ratio。
除了这些必需元素之外,参数支持一组可选参数。典型的选项包括 help,它指定了用户可见的帮助文本,以及 required=True,它表示用户必须为参数提供一个值。默认情况下,所有参数都是可选的。如果没有指定默认值且用户没有提供值,它们将接收 None 值。以下列表显示了一个示例。
列表 3.3 带有参数的工作流
from metaflow import FlowSpec, Parameter, step
class ParameterFlow(FlowSpec):
animal = Parameter('creature', ❶
help="Specify an animal",
required=True)
count = Parameter('count', ❶
help="Number of animals",
default=1)
ratio = Parameter('ratio', ❶
help="Ratio between 0.0 and 1.0",
type=float)
@step
def start(self):
print(self.animal, "is a string of", len(self.animal), "characters")
print("Count is an integer: %s+1=%s" % (self.count, self.count + 1))
print("Ratio is a", type(self.ratio), "whose value is", self.ratio)
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
ParameterFlow()
❶ 参数在类级别定义,位于步骤之外。
将代码保存到名为 parameters.py 的文件中,并尝试像往常一样运行它:
# python parameters.py run
这会因错误“缺少选项 '--creature'”而失败,因为生物参数的 required=True。如果这是一个真实的工作流,这个错误将是一个很好的理由去检查工作流的帮助,如下所示:
# python parameters.py run --help
列出了许多选项。用户定义的参数位于选项列表的顶部,其帮助文本显示。尝试按以下方式设置 -creature 的值:
# python parameters.py run --creature seal
工作流应该运行,您会看到与分配的参数值相对应的输出。请注意,比例没有默认值,因此它被设置为 None。让我们尝试指定所有值,如下所示:
# python parameters.py run --creature seal --count 10 --ratio 0.3
注意观察计数和比例是如何自动转换为正确的 Python 类型的。
注意:参数是常量,不可变值。您不能在代码中更改它们。如果您想修改参数,请创建参数值的副本,并将其分配给另一个工件。
将参数指定为环境变量
如果您经常执行相同的运行命令行,可能略有修改,可能需要反复指定相同的参数,这可能会令人沮丧。为了方便,您还可以将任何选项指定为环境变量。
要这样做,请设置一个与选项名称匹配的环境变量,前面加上 METAFLOW_RUN_ 前缀。例如,我们可以将 creature 的值固定为 parameters.py,如下所示:
# export METAFLOW_RUN_CREATURE=dinosaur
现在,您可以在不指定 -creature 的情况下运行 ParameterFlow,因为它的值是通过环境变量指定的,如下所示:
# python parameters.py run --ratio 0.25
如果同时设置了环境变量和命令行选项,则后者具有优先级,正如您可以通过执行以下操作看到的那样:
# python parameters.py run --creature otter --count 10 --ratio 0.3
生物应该设置为水獭,而不是恐龙。
复杂参数
之前提到的机制适用于基本标量参数,这些参数是字符串、整数、浮点数或布尔值。大多数基本工作流除了这些基本类型之外不需要其他参数。
但有时,您可能需要一个列表或某种映射的参数,或者这些参数的复杂组合。挑战在于,因为参数通常在命令行上定义为字符串,我们需要一种方法来定义非标量值作为字符串,以便它们可以作为参数传递。这就是 JSON 编码参数派上用场的地方。
下一个列表展示了接受字典作为参数的简单示例。
列表 3.4 带有 JSON 类型参数的流程
from metaflow import FlowSpec, Parameter, step, JSONType ❶
class JSONParameterFlow(FlowSpec):
mapping = Parameter('mapping',
help="Specify a mapping",
default='{"some": "default"}',
type=JSONType) ❶
@step
def start(self):
for key, value in self.mapping.items():
print('key', key, 'value', value)
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
JSONParameterFlow()
❶ 定义了一个 JSON 类型的参数,导入了 JSONType,并将其指定为参数类型
将代码片段保存到名为 json_parameter.py 的文件中。您可以在命令行中传递一个映射,一个字典,如下所示:
# python json_parameter.py run --mapping '{"mykey": "myvalue"}'
注意,字典被单引号包围,以避免特殊字符混淆 shell。
在命令行上内联指定大型 JSON 对象并不方便。对于大型 JSON 对象,更好的方法是使用标准的 shell 表达式从文件中读取值。如果您没有现成的用于测试的大型 JSON 文件,您可以创建一个——让我们称它为 myconfig.json——如下所示:
# echo '{"largekey": "largevalue"}' > myconfig.json
现在,您可以像这样提供一个文件作为参数:
# python json_parameter.py run --mapping "$(cat myconfig.json)"
shell 表达式,$(cat myconfig.json),将命令行上的值替换为文件 myconfig.json 的内容。在这种情况下,我们必须将 shell 表达式用双引号括起来。
文件作为参数
之前展示的机制允许您使用命令行传递的小数值或配置文件来参数化一个运行,它们并不是用来传递大量输入数据的机制。
然而,在实际的数据科学应用中,区分参数和输入数据并不总是容易。大型配置文件可能比最小的数据集还要大。或者,您可能有一个中型辅助数据集,它感觉像是一个参数,尽管实际输入数据是通过另一个通道提供的。
Metaflow 提供了一个特殊的参数名为 IncludeFile,您可以使用它将小型或中型数据集作为一个工件包含在运行中。一个典型的例子是一个 CSV(逗号分隔值)文件。IncludeFile 可以处理文件的大小没有确切限制,但它的性能并没有针对大数据进行优化——比如说,大于一个 GB 的文件。将其视为一个超大的参数,如图 3.9 所示,而不是大规模数据处理机制,这将在第七章中介绍。

图 3.9 参数仅适用于小型和中型数据集。
让我们看看列表 3.5 中的示例。它接受一个 CSV 文件作为参数并解析它。该示例使用 Python 内置的 csv 模块中的 CSV 解析器,因此它可以处理引号值和可配置的字段分隔符。您可以通过指定--delimiter 选项来更改默认的分隔符,即逗号。
要测试流程,你可以创建一个简单的 CSV 文件,test.csv,其中包含任何以逗号分隔的值,如下所示:
first,second,third
a,b,c
csv.reader 函数将把 CSV 数据作为文件对象,因此我们将我们的字符串值 self.data 工件包装在 StringIO 中,使其成为一个内存中的文件对象。IncludeFile, is_text=True 表示相应的工件应以 Unicode 字符串的形式返回,而不是字节对象。
列表 3.5 包含 CSV 文件作为参数的流程
from metaflow import FlowSpec, Parameter, step, IncludeFile
from io import StringIO
import csv
class CSVFileFlow(FlowSpec):
data = IncludeFile('csv',
help="CSV file to be parsed",
is_text=True)
delimiter = Parameter('delimiter',
help="delimiter",
default=',')
@step
def start(self):
fileobj = StringIO(self.data)
for i, row in enumerate(csv.reader(fileobj, delimiter=self.delimiter)):
print("row %d: %s" % (i, row))
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
CSVFileFlow()
将代码保存到 csv_file.py。你可以按以下方式运行它:
# python csv_file.py run --csv test.csv
你应该能看到打印出来的 CSV 文件的解析字段。你可能会想知道这个简单的例子与直接在代码中打开 CSV 文件有什么不同,例如,使用 csv.reader(open('test.csv'))。关键区别在于 IncludeFile 读取文件并将其持久化为不可变的 Metaflow 工件,附加到运行中。因此,输入文件与运行一起快照和版本化,这样你就可以访问原始数据,即使 test.csv 被更改或丢失。这可以非常有用,我们将在第六章中学习。
现在你已经知道了如何定义可能通过参数从外部世界接收数据并在多个步骤中处理这些数据的顺序工作流程,这些步骤通过工件共享状态。在下一节中,我们将学习如何并行运行这些步骤序列。
3.2 分支和合并
亚历克斯对在 Metaflow 中定义基本工作流程并不比在笔记本中编写代码更困难的事实感到非常惊讶。但这样编写代码有什么好处吗?到目前为止,工作流程的所谓好处似乎相当抽象。亚历克斯和鲍伊一边喝咖啡一边聊天,回忆起一个在笔记本中执行需要九分钟的项目。鲍伊指出,工作流程使得并行执行操作变得容易,这可以使处理速度大大加快。快速完成工作的想法与亚历克斯产生了共鸣——这可能是工作流程的一个杀手级特性!

工作流程提供了一个并发(分支)的抽象,允许高效地使用并行计算资源,如多核 CPU 和分布式计算集群。尽管有几个其他范例能够实现并行计算,但其中许多都因其难以正确实现而闻名,多线程编程就是一个众所周知的例子。工作流程的独特之处在于,它们使得并行性对非专家软件开发者,包括数据科学家,变得可访问。
应该在何时使用分支?让我们先考虑一个没有分支的线性工作流程。设计一个线性工作流程通常并不太难。通常很明显,我们必须先做 A,然后才能做 B,只有 B 完成后 C 才能发生。A → B → C 的顺序是由数据流强制的:C 需要从 B 那里获取一些数据,而 B 又需要从 A 那里获取数据。
相应地,当数据流允许时,你应该使用分支。如果 A 产生可以被 B 和 C 使用的数据,并且 B 和 C 之间没有其他共享数据,那么 B 和 C 应该从 A 分支出来,这样它们可以并行运行。图 3.10 展示了这个例子。为了训练任何模型,我们需要获取由步骤 A 执行的数据集。我们希望使用步骤 A 产生的数据训练两个独立的模型版本。步骤 B 不需要从步骤 C 获取任何东西,反之亦然,因此我们应该将它们指定为独立的分支。在步骤 B 和 C 完成后,我们希望在步骤 D 中选择最佳模型,这显然需要来自步骤 B 和 C 的输入。

图 3.10 基本工作流程,包含两个分支
我们可以将图 3.10 的 DAG 表示为线性 DAG,A → B → C → D 或 A → C → B → D,并得到完全相同的结果。这些 DAG 的执行速度会慢一些,因为 Metaflow 无法并行运行步骤 B 和 C。除了性能优势外,分支可以通过突出实际的数据流和步骤之间的相互依赖性来使工作流程更易于阅读。因此,我们建议以下最佳实践。
经验法则:每当你有两个或更多可以独立执行的步骤时,将它们做成并行分支。这将使你的工作流程更容易理解,因为读者只需查看工作流程结构就能看到哪些步骤不共享数据。这也会使你的工作流程更快。
你可能会想知道系统是否能够自动确定最优的 DAG 结构。自动并行化在计算机科学领域已经是一个活跃的研究课题几十年了,但遗憾的是,使用任意、惯用的 Python 代码来做这件事实际上是不可能的。主要障碍是,通常流程代码本身并不包含足够的信息来表明什么可以并行化,因为步骤与其他第三方库和服务交互。我们发现,让用户保持控制比依赖半成品、容易出错的自动化更不容易造成混淆。此外,最终,工作流程是人类沟通的媒介。没有自动化的系统可以决定向人类描述业务问题的最易懂方式。
3.2.1 有效的 DAG 结构
我们称像图 3.10 中的步骤 A 那样分支扩散的步骤为split 步骤。相应地,我们称像图 3.10 中的步骤 D 那样合并分支的步骤为join 步骤。为了使数据流易于理解,Metaflow 要求每个 split 步骤都有一个相应的 join 步骤。你可以把 split 看作左括号(,而 join 看作右括号)。一个正确括号的表达式(就像这个)需要在两侧都有括号。你可以根据需要将 split 和 join 嵌套到任意深度。
图 3.11 展示了一个有效 DAG 的示例,其中包含嵌套分支。图中有三个分割步骤,浅灰色阴影。每个分割步骤都有一个对应的连接步骤,深灰色阴影。

图 3.11 一个有效 DAG,具有两层嵌套分支
注意,仅仅有相同数量的分割和连接是不够的——还有另一条规则需要遵循。
规则 A 连接步骤只能连接具有公共分割父步骤的步骤。
图 3.12 展示了一个 DAG,其中包含两个无效的分割,分别用深灰和浅灰色表示。深灰色分割应该有一个对应的深灰色连接,但在这里,深灰色连接试图连接一个浅灰色步骤,这是不允许的——连接步骤只能连接来自公共分割父步骤的步骤。当你绘制一个有效的 Metaflow DAG 时,边(箭头)永远不需要交叉。

图 3.12 一个无效的 DAG,其中分割和连接不匹配
这些规则的原因可以追溯到数据流:我们需要跟踪艺术品的血统,这在有交叉边的图中可能会变得非常混乱。
3.2.2 静态分支
在前面的第二章中,我们介绍了 静态 DAG 的概念,即执行开始前结构完全已知的 DAG。所有之前展示的示例,例如图 3.10 中的示例,都是具有静态分支的静态 DAG。在本节中,我们将展示如何在 Metaflow 中定义静态分支。
在我们到达分割和连接的语法之前,我们需要涵盖以下重要主题:在分支中,数据流,即艺术品,将按设计发散。当我们到达连接步骤时,我们必须决定如何处理发散的值。换句话说,我们必须 合并 艺术品。合并的问题经常困扰 Metaflow 的新用户,所以让我们从一个简单的例子开始。
让我们通过添加另一个分支来扩展 3.2 列表中的原始 CounterFlow 示例,如图 3.13 所示。在这里,start 是我们的分割步骤。我们有一个分支,add_one,它将计数增加一,另一个分支,add_two,它将计数增加两。现在,在连接步骤中,我们有 count 的两个可能值,1 和 2。我们必须决定哪个是正确的值。

图 3.13 CounterBranchFlow
在这个例子中,与现实生活中流程类似,对于连接步骤中 count 应该得到什么值并没有明确的正确或错误答案。正确的选择取决于应用:可能是两个中的最大值,就像在列表 3.6 中那样;可能是平均值;或者可能是总和。定义如何合并值取决于用户。例如,考虑图 3.10 中的“选择最佳模型”步骤,该步骤将遍历模型 X 和 Y 并选择得分最高的模型。
虽然在图 3.13 中很容易看出计数问题需要解决,但还有一个额外的挑战:Metaflow 无法可靠地检测哪些工件已被修改,因此需要决定如何处理所有上游来的工件。如果你在合并步骤中不做任何事情,下游步骤将无法访问合并之前的数据,除非是常量参数,因此可以保证始终可用。
规则合并步骤在数据流中充当屏障。你必须明确合并所有工件(除了参数),以便数据可以通过下游流动。
在列表 3.6 中,对应于图 3.13,我们添加了另一个工件,creature,以演示这一点。如果合并步骤不对 creature 做任何处理,最终步骤中将无法访问它,尽管分支根本未对其进行修改。
定义静态分支的语法很简单:只需将所有分支作为 self.next 的参数列出。合并步骤接受一个额外的参数,按照惯例称为 inputs,它为你提供了访问每个入站分支的工件。合并步骤不必称为 join——它仅基于额外的参数被识别为合并步骤。
输入对象允许你以下三种方式访问分支:
-
你可以遍历输入。通常使用 Python 内置函数,如 min、max 或 sum,结合一个遍历输入的生成器表达式来合并工件。这就是我们在下一个列表中选取计数最大值的方式。
-
使用静态分支,你可以通过名称引用分支,就像在下一个列表中的打印语句中那样。
-
你可以通过索引引用分支。通常使用第一个分支,inputs[0],来重新分配所有分支中已知为常量的工件。这就是我们在以下列表中重新分配 creature 工件的方式。
列表 3.6 带有静态分支的流程
from metaflow import FlowSpec, step
class CounterBranchFlow(FlowSpec):
@step
def start(self):
self.creature = "dog"
self.count = 0
self.next(self.add_one, self.add_two) ❶
@step
def add_one(self):
self.count += 1
self.next(self.join)
@step
def add_two(self):
self.count += 2
self.next(self.join)
@step
def join(self, inputs): ❷
self.count = max(inp.count for inp in inputs) ❸
print("count from add_one", inputs.add_one.count) ❹
print("count from add_two", inputs.add_two.count) ❹
self.creature = inputs[0].creature ❺
self.next(self.end)
@step
def end(self):
print("The creature is", self.creature)
print("The final count is", self.count)
if __name__ == '__main__':
CounterBranchFlow()
❶ 通过将所有输出步骤作为 self.next 的参数来定义静态分支。
❷ 通过向步骤添加额外的 inputs 参数来定义合并步骤。
❸ 通过遍历输入来取两个计数的最大值。
❹ 我们还可以从特定的命名分支中打印值。
❺ 要重新分配未修改的工件,我们只需通过索引引用第一个分支。
将代码保存到 counter_branch.py。你可以按以下方式运行它:
# python counter_branch.py run
打印的最终计数应该是 2,即两个分支中的最大值。你可以尝试在合并步骤中注释掉 self.creature 行,看看当不是所有下游步骤所需的工件——在本例中是 end——都由合并处理时会发生什么。它将崩溃,因为找不到 self.creature。
在日志中,注意 pid(进程标识符)在 add_one 和 add_two 中是不同的。Metaflow 将这两个分支作为单独的进程执行。如果你的计算机有多个 CPU 核心,这在任何现代系统中几乎是肯定的,操作系统可能会在单独的 CPU 核心上执行这些进程,这意味着计算实际上是在并行进行的。这意味着你可以将结果的速度提高两倍,与顺序运行相比。
合并辅助函数
你可能会想知道如果你有很多 artifacts 会发生什么。真的有必要显式地重新分配所有这些 artifacts 吗?它们确实都需要被重新分配,但为了避免样板代码,Metaflow 提供了一个辅助函数 merge_artifacts,它为你做了大部分的体力活。要看到它的实际效果,你可以替换重新分配常量 artifact 的那一行:
self.creature = inputs[0].creature
使用以下行:
self.merge_artifacts(inputs)
如果你再次运行流程,你会看到它同样适用于 merge_artifacts。
如你所想,merge_artifacts 不能为你完成所有的合并。它不知道你想要使用计数中的最大值,例如。它依赖于你首先显式地合并所有发散的 artifacts,就像我们在列表 3.6 中对 count 所做的那样。当你对所有发散的 artifacts 进行重新分配后调用 merge_artifacts,它将自动为你重新分配所有剩余的非发散 artifacts——即所有分支中具有相同值的 artifacts。如果任何发散的 artifacts 仍然存在,它将大声失败。
有时候你可能有一些不需要在下游可见的 artifacts,因此你不想合并它们,但它们可能会让 merge_artifacts 迷惑。列表 3.7 展示了这样一个案例。我们在两个分支中定义了一个名为 increment 的 artifact,它具有两个不同的值。我们将其视为步骤的内部细节,因此我们不想合并它。然而,我们希望将其保存为 artifact,以防我们以后需要调试代码。我们可以在 merge_artifacts 中使用 exclude 选项列出所有可以安全忽略的 artifacts。
列表 3.7 使用 merge 辅助函数合并分支
from metaflow import FlowSpec, step
class CounterBranchHelperFlow(FlowSpec):
@step
def start(self):
self.creature = "dog"
self.count = 0
self.next(self.add_one, self.add_two)
@step
def add_one(self):
self.increment = 1 ❶
self.count += self.increment
self.next(self.join)
@step
def add_two(self):
self.increment = 2 ❶
self.count += self.increment
self.next(self.join)
@step
def join(self, inputs):
self.count = max(inp.count for inp in inputs)
print("count from add_one", inputs.add_one.count)
print("count from add_two", inputs.add_two.count)
self.merge_artifacts(inputs, exclude=['increment']) ❷
self.next(self.end)
@step
def end(self):
print("The creature is", self.creature)
print("The final count", self.count)
if __name__ == '__main__':
CounterBranchHelperFlow()
❶ increment 的值在两个分支之间发散。
❷ 我们必须显式地忽略发散的 artifact,因为我们没有显式地处理它。
将代码保存到 counter_branch_helper.py。你可以按以下方式运行它:
# python counter_branch_helper.py run
输出与列表 3.6 中的相同。你可以移除 exclude 选项来查看 merge_artifacts 面对具有发散值的 artifacts 时抛出的错误。除了 exclude 之外,merge_artifacts 还有一些更方便的选项,你可以在 Metaflow 的在线文档中查阅。
3.2.3 动态分支
在上一节中,我们展示了如何将操作扩展到预定义的命名步骤列表中,每个步骤执行不同的操作。这种类型的并发操作有时被称为任务并行性。相反,如果你想要执行本质上相同的操作,但使用不同的输入数据呢?这种数据并行性在数据科学应用中非常常见。英特尔总监詹姆斯·雷 inders 在 ZDNet 上撰写的一篇文章(mng.bz/j2Dz)描述了这两种类型的并行性如下:
数据并行性涉及在不同的数据组件上运行相同的任务,而任务并行性则通过在同一数据上同时运行许多不同的任务来区分。
在数据科学的背景下,数据并行性出现在许多场景中,例如,当你并行训练或评分模型时,并行处理数据分片,或者并行进行超参数搜索时。在 Metaflow 中,数据并行性通过 foreach 构造来表示。我们称 foreach 分支为动态分支,因为分支的宽度或基数是在运行时根据数据动态确定的,而不是像静态分支那样在代码中确定。
注意:静态分支适合在代码中表达并发性,即无论处理什么数据,操作总是并发的,而动态分支适合在数据中表达并发性。
在图 3.10 中,我们概述了如何使用静态 DAG 来并行构建两个模型,X 和 Y。如果训练 X 和 Y 需要大量不同的代码——比如 X 是一个决策树,Y 是一个深度神经网络,那么这种结构是有意义的。然而,如果各个分支中的代码相同,但只有数据不同,这种方法就不合理了。例如,考虑为世界上的每个国家训练一个决策树模型,如图 3.14 所示。

图 3.14 带有动态、数据驱动分支的工作流程
Metaflow 中一个称为 foreach 的构造允许你为给定列表中的每个值运行步骤的一个副本,因此得名for-each。许多编程语言,包括 Python,都提供了一个类似的功能,称为 map。像 map 一样,foreach 接受一个用户定义的函数(Metaflow 中的一个步骤)并将其应用于给定列表的每个项目,保存并返回结果(Metaflow 中的工件)。
分裂和合并的工作方式与静态分支完全相同,只是在分裂步骤中使用了略微不同的语法。特别是,你需要像静态分支一样合并 foreach 的工件。下面的列表演示了 foreach 的语法。
列表 3.8 带有 foreach 分支的流程
from metaflow import FlowSpec, step
class ForeachFlow(FlowSpec):
@step
def start(self):
self.creatures = ['bird', 'mouse', 'dog']
self.next(self.analyze_creatures, foreach='creatures') ❶
@step
def analyze_creatures(self):
print("Analyzing", self.input) ❷
self.creature = self.input
self.score = len(self.creature)
self.next(self.join)
@step
def join(self, inputs):
self.best = max(inputs, key=lambda x: x.score).creature
self.next(self.end)
@step
def end(self):
print(self.best, 'won!')
if __name__ == '__main__':
ForeachFlow()
❶ 使用 foreach 关键字定义 foreach 分支,该关键字引用一个列表。
❷ self.input 指向 foreach 列表中的一个项。
foreach 拆分是通过调用 self.next 并传递一个步骤引用以及关键字参数 foreach 来定义的,foreach 的值是一个字符串,表示工件名称。foreach 引用的工件应该是一个 Python 列表。在这个例子中,foreach 工件被称为 creatures。
analyze_creatures 步骤将为列表中的每个项目调用,在这种情况下,三次。在 foreach 步骤中,你可以访问一个特殊属性 self.input,它包含分配给当前执行分支的 foreach 列表中的项目。请注意,self.input 在 foreach 外部不可用,因此如果你想保留该值,你应该将其分配给另一个工件,就像我们后来对 self.creature 所做的那样。
这个例子也演示了选择一个最大化某些工件值的分支的常见模式,在这种情况下,是分数。Python 内置的 max 函数接受一个可选的 key 参数,它定义了一个产生排序键的函数,该键用于定义最大值。实际上,这是 Python 中 arg max 的实现,这在数据科学中非常常见,尤其是在 foreach 的上下文中。
将代码保存到 foreach.py,并按以下方式运行:
# python foreach.py run
你可以看到有三个 analyze_creatures 实例正在并发运行,每个实例从 creatures 列表中获取不同的值。每个生物根据其名称的长度进行评分,老鼠获胜。
这是第一个展示单个步骤如何产生多个任务的例子。在日志中,你可以看到每个任务都有一个唯一的 ID,如 analyze_creatures/2 和 analyze_creatures/3,这些 ID 用于唯一标识 foreach 的分支。
数值计算喜欢动态分支
在数值计算中,并行执行代码的不同数据部分,然后收集结果的模式是通用的。在文献中,这种模式有如下名称:
-
批量同步并行(一个在 20 世纪 80 年代首次提出的概念)
-
MapReduce(由开源数据处理框架 Hadoop 普及)
-
分叉-合并模型(例如,Java 中的 java.util.concurrent.ForkJoinPool)
-
并行映射(例如,Python 的 multiprocessing 模块中的)
如果你好奇,可以使用 Google 查找这些概念的更多详细信息。它们都与 Metaflow 中的 foreach 构造类似。
在典型数据科学应用中,并行化发生在多个级别。例如,在应用级别,你可以使用 Metaflow 的 foreach 定义一个工作流程,为每个国家训练一个单独的模型,如图 3.14 所示。然后在低级别,接近硬件级别,使用类似模式的 ML 库(如 TensorFlow)训练模型,该库在多个 CPU 核心上并行化矩阵计算。
Metaflow 的哲学是专注于应用整体结构的高层次、以人为本的关注点,并让现成的机器学习库处理以机器为中心的优化。
3.2.4 控制并发
单个 foreach 可以扩展成数以万计的任务。实际上,foreach 是 Metaflow 可伸缩性故事的关键元素,如第四章所述。作为副作用,foreach 分支可能会意外地在您的笔记本电脑上启动如此多的并发任务,以至于它开始变得非常热。为了使笔记本电脑(或数据中心)的生活更轻松,Metaflow 提供了一种控制并发任务数量的机制。
并发限制不会以任何方式改变工作流程结构——代码保持完整。默认情况下,限制由 Metaflow 内置的 本地调度器 在执行时强制执行,该调度器负责在您输入 run 命令时执行工作流程。如第六章所述,Metaflow 支持其他调度器,用于需要更高可用性和可伸缩性的用例。
要了解并发限制在实际中的工作方式,让我们以列表 3.9 中的代码为例。它显示了一个包含一千个项目的列表的 foreach 流。
列表 3.9 包含 1000 方 foreach 的流程
from metaflow import FlowSpec, step
class WideForeachFlow(FlowSpec):
@step
def start(self):
self.ints = list(range(1000))
self.next(self.multiply, foreach='ints')
@step
def multiply(self):
self.result = self.input * 1000
self.next(self.join)
@step
def join(self, inputs):
self.total = sum(inp.result for inp in inputs)
self.next(self.end)
@step
def end(self):
print('Total sum is', self.total)
if __name__ == '__main__':
WideForeachFlow()
将代码保存到 wide_foreach.py。尝试以下方式运行它:
# python wide_foreach.py run
这应该会因错误信息“启动子任务过多”而失败。由于在 Metaflow 中定义 foreach 非常容易,您可能会无意中使用非常大的列表,可能包括数百万个项目。运行这样的流程将需要很长时间才能执行。
为了防止发生愚蠢且可能昂贵的错误,Metaflow 默认对 foreach 的最大大小进行了保护,为 100。您可以使用 --max-num-splits 选项提高限制,如下所示:
# python wide_foreach.py run --max-num-splits 10000
如果您总是运行宽 foreach,设置一个环境变量可能更容易,如下所示:
# export METAFLOW_RUN_MAX_NUM_SPLITS=100000
从理论上讲,列表 3.9 中的 foreach 的所有 1,000 个任务都可以并发运行。然而,请记住,每个任务都成为它自己的进程,因此您的操作系统可能不会太高兴管理成千上万的并发活动进程。此外,这也不会使事情变得更快,因为您的计算机没有 1,000 个 CPU 核心,所以大多数时候任务都会在操作系统的执行队列中闲置。
为了支持原型设计循环,特别是 run 命令,Metaflow 包含一个内置的工作流程调度器,类似于第 2.2.3 节中列出的那些。Metaflow 将此调度器称为 本地调度器,以区别于我们将在第六章中学习的其他可以编排 Metaflow 流的调度器。本地调度器的行为就像一个合适的工作流程调度器:它按流程顺序处理步骤,将步骤转换为任务,并将任务作为进程执行。重要的是,它可以控制同时执行的任务数量。
您可以使用--max-workers选项来控制调度器启动的最大并发进程数。默认情况下,最大值为 16。对于本地运行,例如我们迄今为止所执行的那些,将值设置得高于您开发环境中的 CPU 核心数并不会带来太多好处。有时您可能希望降低该值以节省计算机资源。例如,如果一项任务需要 4GB 的内存,除非您的计算机至少有 16*4=64GB 的可用内存,否则您无法同时运行 16 个这样的任务。
您可以尝试不同的max-workers值。例如,比较执行时间
# time python wide_foreach.py run --max-num-splits 1000 --max-workers 8
与之相比
# time python wide_foreach.py run --max-num-splits 1000 --max-workers 64
我们将在第五章中了解更多关于max-workers的影响。图 3.15 总结了这两个选项。

图 3.15 max-num-splits 和 max-workers 的影响
步骤 A 是一个foreach拆分,它生成步骤 B 的八个任务。在这种情况下,如果您为--max-num-splits指定了低于 8 的任何值,运行将崩溃,因为该选项控制了foreach分支的最大宽度。无论--max-workers的值是多少,都会运行所有八个任务,因为它只控制并发性。在这里,设置--max-workers=2通知本地调度器最多同时运行两个任务,因此八个任务将作为四个小批量执行。
恭喜!您现在能够在 Metaflow 中定义任意工作流程,通过分支管理数据流,并在您的笔记本电脑上执行甚至大规模的测试案例而不会过热!有了这个基础,我们可以继续构建我们的第一个真实数据科学应用。
3.3 Metaflow 实践
作为第一个实际数据科学项目,Harper 建议 Alex 可以构建一个应用,预测新客户最可能喜欢的促销纸杯蛋糕的类型,给定客户的一些已知属性。幸运的是,到目前为止,客户已经手动选择了他们最喜欢的,因此他们的过去选择可以用作训练集中的标签。Alex 认识到这是一个简单的分类任务,使用现成的机器学习库实现应该不会太难。Alex 开始使用 Metaflow 开发原型。

现在我们已经涵盖了 Metaflow 的基础知识,我们可以构建一个简单但功能性的数据科学应用。该应用创建用于训练和测试的数据集,训练两个不同的分类器模型,并选择表现最好的一个。工作流程类似于图 3.10 中所示。
如果你不是机器学习专家,请不要担心。这个应用,就像这本书中的所有其他示例一样,展示了数据科学的发展经验和基础设施,而不是建模技术。如果你对建模方面感兴趣,可以在Scikit-Learn教程中了解更多信息(scikit-learn.org/stable/tutorial/),这个示例就是基于这个教程的。
我们将通过多次迭代逐步构建示例,就像我们原型化一个真实应用一样。这是一个将第二章中概述的工作站设置(IDE、笔记本和云实例)付诸实践的好机会。
3.3.1 启动新项目
从零开始启动一个新项目可能会感到不知所措:你在编辑器中面对一个空白的文件,不知道从何开始。从工作流程的角度思考项目可能会有所帮助。我们可能只有模糊的想法需要做什么,但至少我们知道工作流程在开始时会有一个起始步骤,在结束时会有一个结束步骤。
我们知道工作流程将需要一些输入数据,并且它需要将结果写入某个地方。我们知道在开始和结束之间需要发生一些处理,我们可以迭代地找出这些处理。图 3.16 将这种方法描绘为一个螺旋。

图 3.16 新项目启动的螺旋食谱
在启动新项目时,遵循黑色箭头指示的路径:
-
我们试图解决的业务问题是什么?
-
我们可以使用哪些输入数据?我们如何以及在哪里读取它?
-
输出数据应该是什么?我们应该如何以及在哪里写入它?
-
我们可以使用哪些技术来根据输入产生更好的输出?
箭头显示了工作流程的顺序。你可以看到我们是从外向内构建工作流程的。这种螺旋方法对以下原因很有用,这些原因在许多数据科学项目中已被证明是正确的:
-
容易忽视我们试图解决的实际问题的细节,尤其是在处理一个新而令人兴奋的模型时。问题应该决定解决方案,而不是反过来。
-
发现、验证、清理和转换合适的输入数据通常比预期的更困难。最好尽早开始这个过程。
-
将结果集成到周围业务系统中可能比预期的更困难——最好尽早开始。此外,输出可能存在令人惊讶的要求,这可能会影响建模方法。
-
从最简单的建模方法开始,并使其与端到端工作流程一起工作。当基本应用工作后,我们可以使用真实数据在真实业务环境中测量结果,这使得我们可以开始严格地改进模型。如果项目成功,这一步永远不会结束——总有方法可以改进模型。
基础设施应使遵循螺旋食谱变得容易。I 应支持开箱即用的迭代开发。让我们看看 Metaflow 在实际中的工作方式。
项目骨架
我们的玩具业务问题是对葡萄酒进行分类——并不完全是纸杯蛋糕,但足够接近。方便的是,Scikit-Learn 附带了一个合适的数据集。示例将不包含特定于该数据集的内容,因此您也可以使用相同的模板来测试其他数据集。
我们首先按照以下方式安装 Scikit-Learn 包:
# pip install sklearn
我们将在第六章学习更复杂的处理依赖关系的方法,但目前在系统范围内安装包是可行的。遵循螺旋食谱,我们首先从只加载输入数据的简单骨架版本开始。我们将在该流程中添加更多功能,如图 3.17 所示。相应的代码在 3.10 列表中给出。

图 3.17 ClassifierTrainFlow 的第一迭代
列表 3.10 ClassifierTrainFlow 的第一迭代
from metaflow import FlowSpec, step
class ClassifierTrainFlow(FlowSpec):
@step
def start(self):
from sklearn import datasets ❶
from sklearn.model_selection import train_test_split ❶
X, y = datasets.load_wine(return_X_y=True) ❷
self.train_data,\
self.test_data,\
self.train_labels,\
self.test_labels = train_test_split(X, y, test_size=0.2, random_state=0)❸
print("Data loaded successfully")
self.next(self.end)
@step
def end(self):
self.model = 'nothingburger' ❹
print('done')
if __name__ == '__main__':
ClassifierTrainFlow()
❶ 在步骤代码内部进行导入,而不是在文件顶部
❷ 加载数据集
❸ 将数据集分为包含 20%行的测试集和包含其余行的训练集
❹ 实际模型的占位符
我们使用 Scikit-Learn 中的函数来加载数据集(load_wine)并将其拆分为训练集和测试集(train_test_split)。您可以在网上查找这些函数以获取更多信息,但这对于本例不是必需的。
注意:在 Metaflow 中,将导入语句放在使用模块的步骤内部,而不是在文件顶部,被认为是一种良好的实践。这样,导入仅在需要时执行。
将代码保存到 classifier_train_v1.py,并按以下所示运行:
# python classifier_train_v1.py run
代码应成功执行。为了确认已加载数据,您可以执行以下类似命令:
# python classifier_train_v1.py dump 1611541088765447/start/1
将路径规范替换为可以从上一个命令的输出中复制和粘贴的实际路径。该命令应显示已创建一些工件,但它们太大而无法显示。这是一个好兆头——已经获取了一些数据,但实际看到数据会更好。我们将在下一节学习如何做到这一点。
3.3.2 使用 Client API 访问结果
在 3.1.2 节中,我们了解到 Metaflow 将所有实例变量,如 3.10 列表中的 train_data 和 test_data,作为其自己的数据存储中的工件持久化。工件存储后,您可以使用 Metaflow Client 或 Client API 以编程方式读取它们。Client API 是允许您检查结果并在流程中使用它们的键机制。
注意:您可以使用 Client API 读取工件。工件创建后不能被修改。
客户端 API 公开了一个容器层次结构,可以用来引用运行的不同部分,即流程的执行。除了单个运行之外,容器还允许你导航整个 Metaflow 宇宙,包括你和你同事的所有运行,前提是你使用了一个共享元数据服务器(关于这一点,请参阅第六章)。容器层次结构在图 3.18 中展示。

图 3.18 客户端 API 的容器层次结构
每个容器内部可以找到的内容如下:
-
Metaflow—包含所有流程。你可以用它来发现你和你同事创建的流程。
-
流程—包含使用 FlowSpec 类执行的所有运行。
-
运行—包含在此运行期间启动执行的流程的所有步骤。运行是层次结构中的核心概念,因为所有其他对象都是通过运行产生的。
-
步骤—包含由此步骤启动的所有任务。只有 foreach 步骤包含多个任务。
-
任务—包含此任务产生的所有数据工件。
-
数据工件—包含由任务产生的一份数据。
除了作为容器外,这些对象还包含其他元数据,例如创建时间和标签。值得注意的是,你还可以通过 Task 对象访问日志。
你可以通过以下三种方式实例化客户端 API 对象:
-
你可以直接使用唯一标识对象层次结构中的对象的路径规范来实例化任何对象。例如,你可以使用 Run(pathspec)访问特定运行的 数据,例如,Run("ClassifierTrainFlow/1611541088765447")。
-
你可以使用括号符号来访问子对象。例如,Run ("ClassifierTrainFlow/1611541088765447")['start'] 返回起始步骤。
-
你可以迭代任何容器来访问其子对象。例如,list (Run("ClassifierTrainFlow/1611541088765447")) 返回与给定运行对应的所有 Step 对象的列表。
此外,客户端 API 包含了一些方便的快捷方式来导航层次结构,我们将在接下来的示例中介绍。
在笔记本中检查结果
你可以在 Python 支持的所有地方使用客户端 API:在脚本中、使用交互式 Python 解释器(只需执行 python 即可打开一个),或者在笔记本中。笔记本是一个特别方便的环境,用于检查结果,因为它们支持丰富的可视化。
让我们在笔记本中检查列表 3.10 中 ClassifierTrainFlow 的结果。首先,在你的编辑器中打开一个笔记本,或者通过在执行 Metaflow 运行的同个工作目录中执行命令行上的 jupyter-notebook 来打开一个笔记本。
我们可以使用笔记本来检查我们之前加载的数据。具体来说,我们想要检查一个名为 train_data 的工件,它在起始步骤处创建。为此,将下一列表中的行复制到笔记本中的一个单元格中。
列表 3.11 在笔记本中检查数据
from metaflow import Flow
run = Flow('ClassifierTrainFlow').latest_run
run['start'].task.data.train_data
使用 Client API 实际上是关于导航图 3.18 所示的对象层次结构。Flow.latest_run 是一个快捷方式,它给出了给定 Flow 的最新运行。我们使用 ['start'] 来访问所需的步骤,然后使用 .task 快捷方式来获取相应的任务对象,并使用 .data 来查看给定的工件。结果应该类似于图 3.19。

图 3.19 在笔记本中使用 Client API
Client API 旨在方便地探索数据。以下是一些你可以自己尝试的练习:
-
尝试检查由运行创建的其他工件,例如,train_labels 或 model。
-
再次运行流程。注意
.latest_run返回了一个不同的运行 ID。现在尝试检查之前的运行。 -
尝试探索对象的其它属性,例如,.created_at。提示:你可以使用 help() 来查看文档——尝试 help(run)。
在流程间访问数据
图 3.20 展示了我们的项目如何通过前两个步骤进行进展,从业务问题定义到在 ClassifierTrainFlow 中设置输入数据。现在我们已经确认输入数据已正确加载,我们可以继续螺旋式流程的下一步,即项目的输出。我们希望使用一个由 ClassifierTrainFlow 训练的模型来分类未见过的数据点,在这种情况下,是葡萄酒。

图 3.20 接下来关注项目的输出
将预测应用如此类分成两个流程是很常见的:一个用于训练模型,另一个用于使用模型为未见过的数据提供预测。这种分割是有用的,因为预测或推理流程通常独立运行,并且比训练流程运行得更频繁。例如,我们可能每天训练一个新的模型,但每小时预测新的数据一次。
让我们原型化一个预测流程,ClassifierPredictFlow,以配合我们的训练流程 ClassifierTrainFlow。关键思想是访问之前训练好的模型,我们可以通过 Client API 来实现。在这个例子中,我们接受一个要分类的数据点,它被指定为一个 JSON 类型的参数(有关其工作方式的提醒,请参阅 3.4 节的列表)。作为一个练习,你可以用数据点的 CSV 文件替换它(请参阅 3.5 节的示例),这将是一个更现实的方法。流程的第一个迭代如下所示。
列表 3.12 ClassifierPredictFlow 的第一次迭代
from metaflow import FlowSpec, step, Flow, Parameter, JSONType
class ClassifierPredictFlow(FlowSpec):
vector = Parameter('vector', type=JSONType, required=True)
@step
def start(self):
run = Flow('ClassifierTrainFlow').latest_run ❶
self.train_run_id = run.pathspec ❷
self.model = run['end'].task.data.model ❸
print("Input vector", self.vector)
self.next(self.end)
@step
def end(self):
print('Model', self.model)
if __name__ == '__main__':
ClassifierPredictFlow()
❶ 使用 Client API 查找最新的训练运行
❷ 保存训练运行的路径规范以进行血缘跟踪
❸ 获取实际的模型对象
将代码保存到 classifier_predict_v1.py,并按以下方式运行:
# python classifier_predict_v1.py run --vector '[1,2]'
运行应该报告模型为“nothingburger”,正如我们在列表 3.10 中指定的项目骨架所示。这是螺旋方法的实际应用:我们在担心实际模型之前,先建立和验证端到端应用所有部分的连接。
注意我们持久化了一个包含训练运行路径 spec 的 artifact,train_run_id。我们可以使用这个 artifact 来跟踪模型血缘:如果预测结果有意外,我们可以追踪到产生结果的模型的精确训练运行。
3.3.3 调试失败
现在我们已经有了项目的输入和输出的骨架流程,我们来到了有趣的部分:定义机器学习模型。正如在现实世界的项目中常见的那样,模型的第一个版本可能不会工作。我们将通过 Metaflow 练习如何调试失败。
数据科学项目的另一个常见特征是,最初我们不确定哪种类型的模型最适合给定的数据。也许我们应该训练两种不同类型的模型,并选择表现最好的那个,正如我们在图 3.10 的上下文中讨论的那样。
受 Scikit-Learn 教程的启发,我们训练了一个 K 最近邻(KNN)分类器和支持向量机(SVM)。如果你对这些技术不熟悉,不用担心——了解它们对这个例子不是必需的。你可以参考 Scikit-Learn 教程了解更多关于模型的信息。
训练模型通常是流程中最耗时的部分,因此并行步骤训练模型以加快执行速度是有意义的。接下来的列表通过在列表 3.10 中的 ClassifierTrainFlow 骨架中间添加三个新步骤来扩展:train_knn 和 train_svm,它们是并行分支,以及 choose_model,它选择两个模型中表现最好的模型。
列表 3.13 几乎完成的 ClassifierTrainFlow
from metaflow import FlowSpec, step
class ClassifierTrainFlow(FlowSpec):
@step
def start(self): ❶
from sklearn import datasets
from sklearn.model_selection import train_test_split
X, y = datasets.load_wine(return_X_y=True)
self.train_data,\
self.test_data,\
self.train_labels,\
self.test_labels = train_test_split(X, y, test_size=0.2, random_state=0)
self.next(self.train_knn, self.train_svm)
@step
def train_knn(self): ❷
from sklearn.neighbors import KNeighborsClassifier
self.model = KNeighborsClassifier()
self.model.fit(self.train_data, self.train_labels)
self.next(self.choose_model)
@step
def train_svm(self): ❷
from sklearn import svm
self.model = svm.SVC(kernel='polynomial') ❸
self.model.fit(self.train_data, self.train_labels)
self.next(self.choose_model)
@step
def choose_model(self, inputs): ❷
def score(inp):
return inp.model,\
inp.model.score(inp.test_data, inp.test_labels)
self.results = sorted(map(score, inputs), key=lambda x: -x[1])
self.model = self.results[0][0]
self.next(self.end)
@step
def end(self): ❹
print('Scores:')
print('\n'.join('%s %f' % res for res in self.results))
if __name__ == '__main__':
ClassifierTrainFlow()
❶ 除了更新 self.next()之外,起始步骤没有其他更改。
❷ 在流程中间添加了新的训练步骤。
❸ 这行代码将导致错误:参数应该是'poly'。
❹ 末步骤被修改为打印出有关模型的信息。
两个 train_ 步骤使用我们在起始步骤中初始化的 artifacts train_data 和 train_labels 中的训练数据来拟合模型。这种方法对于小型和中型数据集来说效果很好。使用大量数据训练大型模型有时需要不同的技术,我们将在第七章中讨论。
choose_model 步骤使用 Scikit-Learn 的 score 方法使用测试数据对每个模型进行评分。根据分数(多亏了-x[1],它将排序键中的分数取反)按降序排序模型。我们将最佳模型存储在 model artifact 中,它将被 ClassifierPredictFlow 稍后使用。请注意,所有模型及其评分都存储在 results artifact 中,允许我们稍后在笔记本中检查结果。
将代码再次保存到 classifier_train.py 中,并按以下所示运行:
# python classifier_train.py run
哎呀!列表 3.13 中的代码在出现类似 ValueError: 'polynomial'不在列表中的错误时失败。
这种错误是原型设计循环中预期的一部分。事实上,Metaflow 和其他类似框架的许多功能都是专门设计来使调试失败更容易的。每当有东西失败时,您可以通过遵循图 3.21 中建议的步骤对问题进行分类和修复。

图 3.21 调试循环
让我们一步一步地来。
- 在日志中查找错误信息
第一步是尝试理解确切失败的原因,特别是哪个步骤失败了,以及错误信息是什么。如果您手动运行流程,您应该在终端上看到带有步骤名称的前缀的堆栈跟踪(例如,在先前的 ClassifierTrainFlow 中的 train_svm)。
尤其是在宽泛的 foreach 中,终端上可能会出现如此多的错误信息,以至于阅读它们变得困难。在这种情况下,日志命令(见 3.1.1 节),可以用来显示单个任务的输出,可能会很有用。然而,该命令只有在您知道可能失败的步骤或任务时才有用。它对于在干草堆中寻找失败的针并没有帮助。
或者,您可以使用客户端 API,例如在笔记本中自动遍历所有任务。您可以将以下片段复制并粘贴到笔记本中。
列表 3.14 使用客户端 API 访问日志
from metaflow import Flow
for step in Flow("ClassifierTrainFlow").latest_run:
for task in step:
if not task.successful:
print("Task %s failed:" % task.pathspec)
print("-- Stdout --")
print(task.stdout)
print("-- Stderr --")
print(task.stderr)
您可以将 Flow().latest_run 替换为指向特定运行的 Run 对象,例如 Run("ClassifierTrainFlow/1611603034239532"),以分析任何过去运行的日志。使用客户端 API 的好处是您可以使用 Python 的全部功能来找到您需要的内容。例如,您可以通过添加一个条件来仅查看包含特定术语的日志
if 'svm' in task.stderr:
在代码中。
- 理解代码失败的原因
一旦您弄清楚什么失败了,您就可以开始分析为什么它失败了。通常,这一步涉及到仔细检查失败 API 的文档(以及谷歌搜索!)。Metaflow 支持使用一些额外的工具进行常规调试。
使用客户端 API 检查代表执行前状态的工件。尽可能多地存储信息作为工件的主要动机是帮助重建失败前的流程状态。您可以在笔记本中加载工件,检查它们,并使用它们来测试与失败相关的假设。学会热爱工件吧!
Metaflow 与调试器兼容,如 Visual Studio Code 和 PyCharm 中嵌入的调试器。由于 Metaflow 将任务作为单独的进程执行,调试器需要一些额外的配置才能正确工作。您可以在 Metaflow 的在线文档中找到配置流行编辑器中调试器的说明。一旦您配置了调试器,您就可以像往常一样使用它来检查实时代码。
通常,涉及大量数据的计算密集型代码会因资源耗尽而失败,例如内存不足。我们将在第四章中学习如何处理这些问题。
- 测试修复
最后,您可以尝试修复代码。Metaflow 的一个巨大优势是您不需要从头开始重新启动整个运行来测试修复。想象一下,有一个流程首先花费 30 分钟处理输入数据,然后训练模型花费 3 小时,最后由于拼写错误在最后一步失败。您可以在一分钟内修复这个错误,但不得不等待 3.5 小时来确认修复是否有效,这会让人感到沮丧。
相反,您可以使用 resume 命令。让我们用它来修复列表 3.13 中的错误。在 train_svm 步骤中,将 "polynomial" 作为模型的参数,参数应该是 'poly'。将错误的行替换为以下内容:
svm.SVC(kernel='poly')
您可以使用运行命令再次运行代码,但与其这样做,不如尝试以下方法:
# python classifier_train.py resume
此命令将找到之前的运行,克隆所有成功步骤的结果,并从失败的步骤开始恢复执行。换句话说,它不会花费时间重新执行已经成功的步骤,这在前面的例子中可以节省 3.5 小时的执行时间!
如果您尝试修复代码不成功,您可以尝试另一个想法并再次恢复。您可以持续迭代修复,直到需要为止,如图 3.21 中的反向箭头所示。
在前面的例子中,resume 重新使用了 train_knn 步骤的成功结果。然而,在某些情况下,修复一个步骤可能需要更改成功的步骤,这时您可能也想恢复。您可以通过指示 resume 从任何先于失败步骤的步骤开始恢复执行,例如:
# python classifier_train.py resume train_knn
这将强制 resume 重新运行 train_knn 和 train_svm 步骤,以及任何后续步骤。失败的步骤及其后续步骤总是会被重新运行。
默认情况下,resume 命令会在当前工作目录中找到最新执行的运行 ID,并将其用作 起源运行,即克隆其结果用于恢复运行的运行。您可以使用 --origin-run-id 选项将起源运行更改为同一流程中的任何其他运行,如下所示:
# python classifier_train.py resume --origin-run-id 1611609148294496 train_knn
这将从 train_knn 步骤开始,使用 classifier_train.py 中的最新代码恢复运行 1611609148294496 的执行。起源运行不必是失败的运行,也不必是您执行的运行!在第五章中,我们将使用此功能在本地恢复失败的生产运行。
恢复的运行被注册为正常运行。它们将获得自己的唯一运行 ID,因此您可以使用客户端 API 访问它们的结果。但是,您无法更改已恢复运行的参数,因为更改它们可能会影响要克隆的任务的结果,这可能导致整体结果不一致。
3.3.4 完成细节
在修复 ClassifierTrainFlow 后,它应该成功完成并生成一个有效的模型。要完成 ClassifierPredictFlow(列表 3.12),请将其末尾步骤添加以下行:
print("Predicted class", self.model.predict([self.vector])[0])
为了测试预测,您必须在命令行上提供一个向量。葡萄酒数据集包含每个葡萄酒的 13 个属性。您可以在 Scikit-Learn 的数据集页面(mng.bz/ZAw9)中找到它们的定义。例如,以下是一个使用训练集向量的示例:
# python classifier_predict.py run --vector
➥ '[14.3,1.92,2.72,20.0,120.0,2.8,3.14,0.33,1.97,6.2,1.07,2.65,1280.0]'
预测的类别应该是 0。恭喜——我们有一个工作的分类器!
如果分类器产生错误的结果怎么办?您可以使用 Scikit-Learn 的模型洞察工具和 Client API 来检查模型,如图 3.22 所示。

图 3.22 检查分类器模型的笔记本
现在,应用程序似乎可以端到端工作,让我们总结一下我们构建的内容。该应用程序展示了 Metaflow 和一般数据科学应用程序的许多关键概念。图 3.23 说明了我们最终应用程序的整体架构。

图 3.23 分类器应用程序的架构
从上到下阅读图示:
-
我们获得了输入数据,并将其分为训练集和测试集,这些集被存储为工件。
-
我们训练了两个替代模型作为并行分支……
-
……并基于测试数据的准确性选择表现最好的一个。
-
所选模型被存储为一个工件,我们可以使用 Client API 与笔记本中的其他工件一起检查它。
-
可以根据需要多次调用单独的预测流程来对新向量进行分类,使用训练好的模型。
尽管本节介绍了一个最小化的玩具示例(你能想象我们用不到 100 行代码实现了图 3.23 所示的应用程序吗!),但该架构对于生产级应用程序是有效的。您可以替换输入数据为您的数据集,根据您的实际需求改进建模步骤,在笔记本中添加更多细节以使其更具信息性,并替换用于预测的单个--vector 输入,例如使用 CSV 文件。
本书剩余部分回答了以下问题(以及许多其他问题),这些问题可能是您在将此应用程序以及其他类似性质的数据科学应用程序应用于实际用例时可能遇到的问题:
-
如果我必须处理一个 TB 的输入数据怎么办?
-
如果我想训练 2000 个模型而不是两个,并且每个模型的训练需要一个小时怎么办?
-
在流程中添加实际的建模和数据预处理代码后,文件变得相当长。我们可以将代码拆分为多个文件吗?
-
我是否应该在生产中手动运行流程?我们可以安排它们自动运行吗?
-
当我调用 Flow().latest_run 时,我想确保最新的运行指的是我的最新运行,而不是我同事的最新运行。我们能否以某种方式隔离我们的运行?
-
我们的生产流程使用的是 Scikit-Learn 的旧版本,但我想要使用 Scikit-Learn 的最新实验版本来原型化一个新模型——有什么想法吗?
不要担心——其余的基础设施将无缝地建立在我们在本章中建立的基础上。如果你已经走到这一步,你对基本概念已经有了很好的掌握,你可以跳过后续章节中与你的用例不相关的任何部分。
摘要
-
如何使用 Metaflow 定义工作流:
-
你可以在 Metaflow 中定义基本的工作流并在你的笔记本电脑或云工作站上测试它们。
-
Metaflow 会自动跟踪所有执行,为它们分配唯一的 ID,这样你的项目在整个迭代过程中都能保持组织有序,无需额外努力。唯一的 ID 允许你轻松找到与任何任务相关的日志和数据。
-
使用工件在工作流中存储和移动数据。
-
使用称为 Parameter 的特殊工件来参数化工作流。
-
使用客户端 API 的笔记本来分析、可视化和比较任何过去运行中的元数据和工件。
-
-
如何使用 Metaflow 进行并行计算:
-
使用分支可以使你的应用程序通过明确数据依赖性来提高可理解性,同时也能实现更高的性能。
-
你可以使用动态分支在多份数据上执行一个操作,或者使用静态分支并行执行许多不同的操作。
-
-
如何开发一个简单的端到端应用程序:
-
最好迭代地开发应用程序。
-
使用恢复功能在失败后快速继续执行。
-
Metaflow 旨在与现成的数据科学库(如 Scikit-Learn)一起使用。
-
4 与计算层扩展
本章涵盖
-
设计可扩展的基础设施,使数据科学家能够处理计算密集型项目
-
选择符合您需求的基于云的计算层
-
在 Metaflow 中配置和使用计算层
-
开发能够优雅处理失败的稳健工作流程
所有数据科学项目的最基本构建块是什么?首先,根据定义,数据科学项目使用数据。至少,所有机器学习和数据科学项目都需要至少少量的数据。其次,数据科学中的科学部分意味着我们不仅收集数据,我们还用它来做些事情,即我们使用数据计算一些东西。相应地,数据和计算是我们数据科学基础设施堆栈的两个最基础层,如图 4.1 所示。

图 4.1 突出显示计算层的科学基础设施堆栈
管理和访问数据是一个如此深入和广泛的话题,我们将其深入讨论推迟到第七章。在本章中,我们专注于堆栈的计算层,它回答了一个看似简单的问题:数据科学家定义了一块代码,比如工作流程中的一个步骤,我们应该在哪里执行它?
一个直接的答案,我们在第二章中提到过,是在笔记本电脑或云工作站上执行任务。但是,如果任务对笔记本电脑来说要求过高——比如说需要 64GB 的内存?或者,如果工作流程包括一个 foreach 结构,它启动了 100 个任务?单个工作站没有足够的 CPU 核心来并行运行它们,而顺序运行可能速度太慢。本章提出了一种解决方案:我们可以在个人工作站之外,在基于云的计算层上执行任务。
您有多种方式来实现计算层。确切的选择取决于您的具体需求和用例。我们将介绍一些常见的选择,并讨论您如何选择一个适合您需求的选择。我们将介绍一个简单选项,一个名为AWS Batch的托管云服务,以使用 Metaflow 演示计算层的实际应用。在本章奠定基础的基础上,下一章将提供更多动手示例。
基本上,我们关注计算层,因为它允许项目处理更多的计算和数据。换句话说,计算层允许项目更具可扩展性。在我们深入探讨计算层的详细技术细节之前,我们首先来探讨可扩展性及其兄弟概念性能的含义,以及为什么和何时它们对数据科学项目很重要。正如您将学到的,可扩展性的主题是一个令人惊讶的微妙话题。更好地理解它将帮助您为项目做出正确的技术选择。
从基础设施的角度来看,我们的理想目标是让数据科学家能够有效地解决任何业务问题,而不会因为问题规模的大小而受到限制。许多数据科学家认为,能够利用大量数据和计算资源赋予他们超能力。这种感觉是合理的:使用本章介绍的技术,数据科学家可以用仅仅几十行 Python 代码来利用几十年前需要超级计算机才能提供的计算能力。
任何计算层的缺点是,与笔记本电脑紧密的封闭环境相比,任务可能会以令人惊讶的方式失败。为了提高数据科学家的生产力,我们希望尽可能自动处理尽可能多的错误。当发生无法恢复的错误时,我们希望使调试体验尽可能痛苦。我们将在本章的末尾讨论这个问题。你可以在这个章节找到所有代码列表:mng.bz/d2lN。
4.1 什么是可扩展性?
亚历克斯为能够构建第一个训练模型并产生预测的应用感到无比自豪。但鲍伊却有所担忧:随着他们的蛋糕生意有望指数级增长,亚历克斯的 Python 脚本能否处理他们未来可能面临的数据规模?亚历克斯并非可扩展性专家。尽管鲍伊的担忧是可以理解的,但亚历克斯认为他们可能有些过于急切。他们的业务规模还远未达到那种程度。无论如何,如果亚历克斯可以选择,最简单的解决方案就是购买一台足够大的笔记本电脑,以便运行现有的脚本处理更大的数据。亚历克斯宁愿专注于完善模型和更好地理解数据,而不愿花费时间重构现有的脚本。

在这种情况下,谁的关注更有道理?鲍伊从工程角度来看有合理的担忧:运行在亚历克斯笔记本电脑上的 Python 脚本将无法处理任意大小的数据。亚历克斯的担忧也是合理的:脚本可能适合他们目前的状况,也可能适合近未来的情况。从商业角度来看,可能更合理的是关注结果的质量,而不是规模。
此外,虽然亚历克斯仅通过购买一台更大的笔记本电脑来处理可扩展性的梦想在技术角度来看可能听起来很愚蠢且不切实际,但从生产力的角度来看,这是一个合理的想法。从理论上讲,一台无限大的笔记本电脑将使得无需更改现有代码即可使用,从而让亚历克斯能够专注于数据科学,而不是分布式计算的复杂性。
如果你是哈珀,一位商业领导者,你会站在鲍伊一边,建议亚历克斯重新设计代码以使其可扩展,从而分散亚历克斯对模型的注意力,还是会让亚历克斯专注于改进模型,这可能会导致未来的失败?这并不是一个容易的决定。许多人会说“这取决于。”一个明智的领导者可能会选择一种平衡的方法,使代码仅具有足够可扩展性,以便在不久的将来不会在现实负载下崩溃,并让亚历克斯花剩余的时间确保结果的质量。
找到这样的平衡方法是本节的主要主题。我们希望同时优化数据科学家、业务需求和工程关注点的生产力,强调每个用例相关的维度。我们希望提供通用的基础设施,允许每个应用程序具有实用性,而不是对可扩展性过于教条,找到一个适合他们特定需求的平衡点。
4.1.1 堆栈层面的可扩展性
如果你参与过一个涉及要求严格的训练或数据处理步骤的数据科学项目,你很可能会听到或思考过类似“它是否可扩展?”或“它是否足够快?”的问题。在非正式讨论中,术语可扩展性和性能被互换使用,但实际上它们是独立的问题。让我们从可扩展性的定义开始:
可扩展性是指系统通过向系统中添加资源来处理日益增长的工作量的属性。
让我们如下解释这个定义:
-
可扩展性关乎增长。对于具有静态输入的静态系统,讨论其可扩展性是没有意义的。然而,你可以讨论这样一个系统的性能——关于这一点我们很快就会讨论。
-
可扩展性意味着系统必须执行更多的工作,例如,处理更多的数据或训练更多的模型。可扩展性不是关于优化固定工作量性能的。
-
可扩展的系统有效地利用添加到系统中的额外资源。如果一个系统能够通过添加一些资源(如更多的计算机或更多的内存)来处理更多的工作,那么这个系统就是可扩展的。
而可扩展性关乎增长,性能则关乎系统的能力,独立于增长。例如,我们可以衡量你制作煎蛋卷的性能:你能多快地制作一个,结果的质量如何,或者作为副作用产生了多少浪费?没有单一的衡量性能或可扩展性的标准;你必须定义你感兴趣的维度。
如果你正在构建单个应用,你可以专注于使该特定应用可扩展。当构建基础设施时,你需要考虑不仅如何使单个应用可扩展,而且当不同应用的数量增加时,整个基础设施如何扩展。此外,基础设施还需要支持越来越多的工程师和数据科学家,他们构建这些应用。
因此,当构建有效的基础设施时,我们不仅关注特定算法或工作流的可扩展性。相反,我们希望优化基础设施堆栈所有层的可扩展性。记住我们在第一章中引入的以下四个 Vs:
-
规模—我们希望支持大量的数据科学应用。
-
速度—我们希望使原型设计和生产化数据科学应用变得容易且快速。
-
有效性—我们希望确保结果有效且一致。
-
多样性—我们希望支持多种不同类型的数据科学模型和应用。
几乎所有的 Vs 都与可扩展性或性能相关。规模关注的是应用数量的增长,这是最初拥有通用基础设施的动机。速度关注的是速度—代码、项目和人的速度,即性能。将有效性与可扩展性进行对比是一个如此重要的主题,以至于它值得在下一章中单独讨论。最后,多样性指的是我们能够使用各种工具处理日益多样化的用例的能力。尽管广告可能试图告诉你,但我们不应该假设存在一个银弹解决方案来解决可扩展性问题。总的来说,不同形式的可扩展性是贯穿本书的基本线索。图 4.2 显示了可扩展性如何触及数据科学基础设施堆栈的所有层。

图 4.2 基础设施堆栈中的可扩展性类型
让我们分解这个图。在最左侧的列中,我们有数据科学应用的基本构建块。它们形成一个层次结构:一个算法包含在一个任务中,一个任务包含在一个工作流中,依此类推。这个层次结构扩展了我们在图 3.18 中讨论 Metaflow 时覆盖的层次结构。
这些构建块都可以独立扩展。根据我们的定义,可扩展性涉及两个因素:更多工作和更多资源。更多工作这一列显示了相应的构建块需要处理的工作类型,即其可扩展性的主要维度。更多资源这一列显示了我们可以添加到构建块中的资源。基础设施层这一列显示了管理资源的部分基础设施。按照设计,这些层是协作的,因此它们的责任之间有一些重叠。让我们逐一介绍以下块:
-
在应用程序的核心,通常有一个执行数值优化、训练模型等操作的算法。通常,算法由现成的库如 TensorFlow 或 Scikit-Learn 提供。通常,当算法必须处理更多数据时,它需要扩展,但还存在其他可扩展性维度,例如模型的复杂性。现代算法可以有效地使用计算实例上的所有可用资源,包括 CPU 核心、GPU 和 RAM,因此你可以通过增加实例容量来扩展它们。
-
算法不会自行运行。它需要被用户代码调用,例如 Metaflow 任务。任务是一个操作系统级别的进程。为了使其可扩展,你可以使用各种工具和技术(更多内容在第 4.2 节中介绍)来利用实例上的所有 CPU 核心和 RAM。通常,当使用高度优化的算法时,你可以将所有可扩展性关注点外包给算法,而任务可以保持相对简单,就像我们在第 3.3 节中与 Scikit-Learn 所做的那样。
-
数据科学应用程序或工作流程由多个任务组成。实际上,当利用数据并行性时,工作流程可以产生任意数量的任务,例如动态分支,这在第 3.2.3 节中已介绍。为了处理大量并发任务,工作流程可以将工作分散到多个计算实例上。我们将在本章和下一章中练习这些主题。
-
为了鼓励实验,我们应该允许数据科学家测试他们工作流程的多个不同版本,可能是在数据或模型架构上略有不同。为了节省时间,能够并行测试这些版本是很方便的。为了处理多个并行工作流程执行,我们需要一个可扩展的工作流程编排器(见第六章)以及许多计算实例。
-
数据科学组织通常同时进行多种数据科学项目。每个项目都有自己的业务目标,由定制的流程和版本表示。我们应尽量减少项目之间的干扰。对于每个项目,我们应能够独立选择架构、算法和可扩展性要求。第六章将更详细地探讨版本控制、命名空间和依赖关系管理的问题。
-
对于组织来说,能够通过雇佣更多的人来扩展并行项目的数量是很有吸引力的。重要的是,一个精心设计的基础设施也可以帮助解决这一可扩展性挑战,如后文所述。
总结来说,数据科学项目依赖于两种资源:人和计算。数据科学基础设施的工作是有效地匹配这两者。可扩展性不仅关乎通过更多的计算资源使单个工作流程更快完成,还关乎使更多的人能够参与更多版本和更多项目的工作,即促进实验和创新文化。由于这个重要方面在技术讨论中经常被忽视,我们在深入研究技术细节之前,花了几页篇幅来讨论这个话题。
4.1.2 实验室文化
一个现代、有效的数据科学组织鼓励数据科学家相对自由地创新和实验新的方法和替代实现,而不受计算层技术限制的约束。这听起来很好,但为什么在大多数组织中今天这不是现实呢?
随着时间的推移,计算周期的价格大幅下降,部分得益于云计算,而人才的价格却上涨。有效的基础设施可以平衡这种不平衡:我们可以为昂贵的人才提供轻松访问廉价计算资源,以最大化他们的生产力。我们希望以允许组织本身扩展的方式实现访问。
组织规模扩大的一个基本原因是通信开销。对于一组 N 个人相互沟通,他们需要 N²条通信线路。换句话说,通信开销随着人数的增长呈二次方增长。一个经典的解决方案是层级组织,它限制了信息流以避免二次方增长。然而,许多现代、创新的数据科学组织宁愿避免严格的层级和信息瓶颈。
人们为什么需要沟通呢?协调和知识共享是常见的原因。历史上,在许多环境中,访问共享资源,如计算机,需要相当多的协调和知识共享。这种情况在图 4.3 中得到了说明。

图 4.3 协调对共享计算资源的访问
想象一下,你在 20 世纪 60 年代的一所大学计算机实验室工作。实验室可能只有一台计算机,一台大型主机。由于计算能力和存储空间极其有限,你可能需要与所有能够使用计算机的同事协调。相对于同事的数量,通信开销将是二次方的。在这样的环境中,为了保持你的理智,你会积极尝试限制能够访问计算机的人数。
假设你在 2000 年代初在一家中型公司工作。该公司有自己的数据中心,可以根据业务需求和容量规划团队的建议,为不同的团队提供如固定大小的集群等计算资源。这种模式显然比主机模型更具可扩展性,因为每个团队可以自行协调对专用资源的访问。缺点是这种模式相当僵化——随着团队需求的增长而增加更多资源可能需要数周甚至数月。
今天,云提供了看似无限的计算能力。计算资源不再稀缺。由于范式转变发生得相当快,许多组织仍然将云视为一个固定大小的集群或主机是可以理解的。然而,云允许我们改变这种心态,并完全消除协调开销。
与人类仔细协调彼此之间对共享、稀缺资源的访问相比,我们可以依赖基础设施来促进对计算资源宝库的相对开放访问。我们将在下一节讨论的基于云的计算层,使得以成本效益的方式执行几乎任意数量的计算变得容易。它允许数据科学家自由实验,处理大数据集,而无需不断担心资源过度消耗或干扰同事的工作,这对生产力是一个巨大的福音。
因此,组织可以处理更多的项目,团队可以更有效地进行实验,个人科学家可以从事更大规模的问题。除了更多规模的量化变化之外,云还带来了质的飞跃——我们可以加倍重视数据科学家自主性这一理念,正如第 1.3 节所述。不再需要与机器学习工程师、数据工程师和 DevOps 工程师协调工作,单个数据科学家可以独自驱动原型循环和与生产部署的交互。
最小化干扰以最大化可扩展性
为什么过去控制和管理计算资源访问如此关键?一个原因是稀缺。如果任务、工作流程、版本或项目多于系统能够处理的,就需要一些控制。今天,在大多数情况下,云提供了足够的容量,使得这个论点变得无关紧要。
另一个论点是脆弱性。如果一个粗心的用户可以破坏系统,那么有一层监督似乎是个好主意。或者,也许系统设计得使得工作负载可以轻易相互干扰。这对于许多今天仍在积极使用的系统来说是一个有效的论点。例如,一个错误的查询可能会影响共享数据库的所有用户。
如果我们想要最大化组织的可扩展性,我们希望最小化人与人之间的通信和协调开销。理想情况下,我们希望消除协调的需求——尤其是当协调是为了避免故障时。作为一个基础设施提供商,我们的目标,至少是理想化的目标,是确保没有任何工作负载能够破坏系统或对其邻居产生不良影响。
由于数据科学工作负载往往具有实验性质,我们无法期望工作负载本身表现得特别良好。相反,我们必须确保它们得到适当的隔离,即使它们出现严重故障,它们的破坏半径也有限,也就是说,它们通过干扰其他工作负载造成的附带损害最小。
可扩展性技巧 通过隔离最小化工作负载之间的干扰是减少协调需求的一种极好方式。需要的协调越少,系统就越可扩展。
幸运的是,现代云基础设施,特别是容器管理系统,帮助我们相对容易地实现这一点,正如我们将在下一节中学习的那样。隔离的其他关键元素包括版本控制、命名空间和依赖管理,这些内容我们将在第六章中更详细地介绍。
4.2 计算层
与其拥有一个巨大的笔记本电脑,不如有一个设置,让 Alex 能够将任何数量的大小任务抛向基于云的计算环境,该环境会自动扩展以处理任务。Alex 不需要更改代码或关心任何其他细节,除了收集结果。理想情况下,环境应该是 Bowie 只需花费很少的时间来维护。

让我们从大局开始。图 4.4 展示了基础设施堆栈的各个层如何参与工作流程执行。我们使用工作流程作为用户界面来定义需要执行的任务,但计算层不必关心工作流程本身——它只关心单个任务。我们将使用工作流程编排器,即我们堆栈中的作业调度层,来确定如何调度单个任务以及何时执行工作流程。关于这一点,我们将在第六章中详细介绍。

图 4.4 计算层的作用:任务执行的位置
此外,计算层不需要关心正在计算的内容以及为什么需要计算——数据科学家在架构应用时会回答这些问题。这对应于我们基础设施堆栈中的架构层。计算层只需要决定在哪里执行一个任务,换句话说,就是找到足够大的计算机来执行任务。
为了完成其工作,计算层需要提供一个简单的接口:它接受一个任务以及资源需求(任务需要多少 CPU 或多少 RAM),执行它(可能经过延迟后),并允许请求者查询所执行工作的状态。尽管这样做可能看起来很简单,但构建一个健壮的计算层是一个高度非平凡的工程挑战。考虑以下要求:
-
系统需要处理大量并发任务,可能多达数十万或数百万。
-
系统需要管理一个用于执行任务的物理计算机池。理想情况下,物理计算机可以随时添加到或从池中移除,而不会造成任何停机时间。
-
任务有不同的资源需求。系统需要将每个任务匹配到至少有所需资源可用的一台计算机。在规模上高效地进行匹配,或称为“打包”,是一个众所周知的问题。如果你是理论爱好者,你可以搜索“Bin Packing problem”和“Knapsack problem”来了解任务放置的计算复杂度。
-
系统必须预料到任何计算机都可能发生故障,数据中心可能会起火,任何任务可能表现不佳或甚至恶意行为,软件存在缺陷。无论如何,系统在任何情况下都不应该崩溃。
几十个世纪以来,构建满足这些要求的大型系统一直是高性能计算(HPC)的领域。该行业由专门向政府、研究机构和大型公司提供昂贵系统的供应商主导。较小的公司和机构依赖各种自制的解决方案,这些解决方案通常脆弱且维护成本高昂,至少在人力成本方面是这样。
公共云如 AWS 的出现极大地改变了这一领域。如今,只需几点击,你就可以部署一个在相对较新的超级计算机规模上稳健运行的计算层。当然,我们大多数人并不需要超级计算机规模的计算层。云允许我们从一个小型笔记本电脑的容量开始,随着需求的增长,弹性地扩展资源。最好的部分是,你只需为使用的部分付费,这意味着偶尔使用的小型计算层可能比同等大小的物理笔记本电脑更经济。
公共云在很大程度上使我们免于自己处理之前的要求。然而,它们提供的抽象级别——回答“在哪里”执行任务并为我们执行任务的问题——仍然相当低级。为了使计算层对数据科学工作负载有用,需要在云提供的接口之上做出许多架构选择。
不同的系统做出不同的工程权衡。一些优化延迟——即任务启动的速度——一些优化可用的计算机类型,一些优化最大规模,一些优化高可用性,还有一些优化成本。因此,认为存在或将来会有一个单一的通用计算层是不切实际的。
此外,不同的工作流和应用有不同的计算需求,因此对于数据科学基础设施来说,支持从本地笔记本电脑和云工作站到专门用于机器学习和人工智能的 GPU 或其他硬件加速器的集群的计算层选择是有益的。幸运的是,我们可以抽象出很大一部分这种不可避免的多样性。不同的计算层可以遵循下一节中讨论的通用接口和架构。
4.2.1 使用容器进行批处理
一个处理开始、接收输入数据、执行一些处理、生成输出并终止的任务的系统被称为执行 批处理。从根本上说,我们在这里描述的计算层是一个批处理系统。在我们的工作流范式(如图 4.5 所示)中,工作流中的一个步骤定义了一个或多个作为批处理作业执行的任务。

图 4.5 批处理作业,例如,工作流中的一个任务
批处理与流处理
批处理的一个替代方案是 流处理,它处理连续的数据流。历史上,绝大多数机器学习系统和需要高性能计算的应用程序都基于批处理:数据进入,进行一些处理,然后输出结果。
在过去十年中,应用复杂性的增加推动了流处理的需求,因为它允许结果以更低的延迟更新,例如,在几秒钟或几分钟内,与通常每小时最多运行一次的批处理作业相比。今天,流行的流处理框架包括 Kafka、Apache Flink 或 Apache Beam。此外,所有主要的公共云提供商都提供流处理即服务,例如 Amazon Kinesis 或 Google Dataflow。
幸运的是,选择不是非此即彼。你可以让应用程序同时使用这两种范式。今天,许多大规模机器学习系统,如 Netflix 的推荐系统,主要基于批处理,其中包含一些流处理,用于需要频繁更新的组件。
批处理作业的主要好处是它们比流处理对应物更容易开发、更容易推理和更容易扩展。因此,除非你的应用程序确实需要流处理,否则从本章讨论的批处理作业工作流开始是合理的。我们将在第八章讨论需要实时预测和/或流处理的更高级用例。
批处理作业由用户定义的任意代码组成。在 Metaflow 的情况下,每个由步骤方法定义的任务都成为一个单独的批处理作业。例如,下一个代码示例中的 train_svm 步骤,复制自列表 3.13,将是一个批处理作业。
列表 4.1 批处理作业示例
@step
def train_svm(self):
from sklearn import svm ❶
self.model = svm.SVC(kernel='poly')
self.model.fit(self.train_data, self.train_labels)
self.next(self.choose_model)
❶ 外部依赖
作业调度器将这个片段,我们称之为用户代码,发送到计算层进行执行,并在执行完成后继续工作流程的下一步。这很简单!
另一个重要的细节:在这个例子中,用户代码引用了一个外部依赖,即 sklearn 库。如果我们尝试在一个没有安装该库的纯净环境中执行用户代码,代码将无法执行。为了成功执行,批处理作业需要打包用户代码以及代码所需的任何依赖。
今天,将用户代码及其依赖打包成容器镜像是很常见的。容器是在物理计算机内部提供隔离执行环境的一种方式。在物理计算机内部提供这样的“虚拟计算机”被称为虚拟化。虚拟化是有益的,因为它允许我们在单个物理计算机中打包多个任务,同时让每个任务像它们独自占据整个计算机一样运行。如第 4.1.1 节所述,提供这种强大的隔离允许每个用户专注于自己的工作,提高生产力,因为他们不必担心干扰到其他人的工作。
容器为什么很重要?
容器允许我们打包、运输和隔离批处理作业的执行。为了给出一个现实世界的类比,考虑一下容器就像一个物理容器,比如动物笼子。首先,你可以访问一个动物收容所(容器注册库),在那里你可以在笼子里找到一个预先打包的野猫(容器镜像)。容器包含了一只猫(用户代码)以及它的依赖,例如食物(库)。接下来,你可以在你的家里(物理计算机)部署这个容器(或多个)。由于每只猫都被容器化了,它们不会对你的房子或彼此造成损害。如果没有容器化,房子很可能会变成战场。
从计算层的角度来看,提交给系统的用户代码就像野猫。我们不应该假设任何代码都能良好地运行。尽管我们并不认为数据科学家本身是恶意的,但如果他们知道在最坏的情况下,他们只能破坏自己的代码,这会给用户带来极大的实验自由度,并且总体上让人感到安心。系统保证无论发生什么情况,用户都无法干扰生产系统或同事的任务。容器有助于提供这样的保证。图 4.6 总结了这次讨论。

图 4.6 计算实例上的容器
生产力技巧 容器通过授予用户自由进行实验而不必担心意外破坏某些东西或干扰同事的工作来提高生产力。没有容器,一个恶意进程可能会占用任意数量的 CPU 或内存,或者填满磁盘,这可能导致同一实例上相邻但无关的进程失败。计算和数据处理密集型的机器学习过程尤其容易遇到这些问题。
外部框代表一台单独的计算机。计算机提供某些固定的硬件,例如 CPU 核心、可能还有 GPU、RAM(内存)和磁盘。计算机运行一个操作系统,例如 Linux。操作系统提供执行一个或多个隔离容器的机制。在容器内部,它提供了所有必要的依赖项,用户代码被执行。
存在着多种不同的容器格式,但今天,Docker是最受欢迎的一个。创建和执行 Docker 容器并不特别困难(如果你好奇,请参阅docs.docker.com),但我们不应该假设数据科学家会手动将他们的代码打包成容器。将代码的每个迭代都打包成单独的容器镜像只会减慢他们的原型设计循环,从而损害他们的生产力。
相反,我们可以自动将它们的代码和依赖项容器化,正如 4.3 节中 Metaflow 所展示的。在底层,数据科学基础设施可以利用容器发挥其最大潜力,而无需将技术细节,即容器,直接暴露给用户。数据科学家只需声明他们想要执行的代码(工作流程中的步骤)以及他们所需的依赖项。我们将在第七章详细讨论依赖项管理。
从容器到可扩展的计算层
现在我们已经了解到,我们可以将批处理作业定义为包含用户代码及其依赖项的容器。然而,当涉及到可扩展性和性能时,容器化本身并不会带来任何好处。在你的笔记本电脑上在 Docker 容器内执行一段代码并不比将其作为正常进程执行更快或更可扩展。
可扩展性是使计算层有趣的地方。记住可扩展性的定义:一个可扩展的系统能够通过向系统添加资源来处理不断增长的工作量。相应地,如果一个计算层能够通过添加更多的计算机或实例来处理更多的任务,那么它就是可扩展的。这正是基于云的计算层如此吸引人的原因。它们能够根据需求自动增加或减少处理任务的物理计算机数量。图 4.7 说明了在工作流程编排的背景下,可扩展的计算层是如何工作的。

图 4.7 任务调度周期
让我们一步一步地分析这张图:
-
就像使用 run 命令调用的 Metaflow 内部调度器一样,作业调度器开始执行工作流。它按顺序遍历工作流的步骤。每个步骤产生一个或多个任务,正如我们在第三章中学到的。
-
调度器将每个任务作为一个独立的批量作业提交给计算层。在 foreach 分支的情况下,可能同时向计算层提交大量任务。
-
计算层管理一个实例池和一个任务队列。它试图将任务与具有执行任务所需资源的计算机相匹配。
-
如果计算层发现任务数量远远超过可用资源,它可以决定增加实例池中的实例数量。换句话说,它提供更多的计算机来处理负载。
-
最终,找到一个合适的实例,可以在其中执行任务。任务在容器中执行。一旦任务完成,调度器就会收到通知,因此它可以继续到工作流程图中的下一个步骤,然后循环重新开始。
注意,计算层可以同时处理来自任何数量工作流的任务。如图所示,计算层获得一个恒定的任务提交流。它执行这些任务,而不关心任务内部做什么,为什么需要执行,或者何时被调度。简单地说,它找到执行任务的位置。
为了使步骤 3-5 发生,计算层内部需要几个组件。图 4.8 显示了典型计算层的高级架构。

图 4.8 典型计算层的架构
-
在中间,我们有一个实例池。每个实例就像图 4.6 中描述的计算机一样。它们是执行一个或多个并发容器的机器,而这些容器又用于执行用户代码。
-
在底部,描绘了一个称为集群管理系统的组件。该系统负责管理实例池。在这种情况下,我们有一个包含三个实例的实例池。集群管理系统根据需求——待处理任务的数量——增加或减少实例池中的实例,或者当检测到实例不健康时,从池中添加或删除实例。请注意,实例不需要具有统一的硬件。一些实例可能有更多的 CPU,一些可能有更多的 GPU,一些可能有更多的 RAM。
-
在顶部,我们有一个容器编排系统。它负责维护一个待处理任务的队列,并将任务放置在底层实例上的容器中执行。该系统的任务是根据任务资源需求将任务与底层实例相匹配。例如,如果任务需要一个 GPU,系统需要找到底层池中带有 GPU 的实例,并等待该实例在执行之前的任务后空闲,然后将任务放置在该实例上。
幸运的是,我们不需要从头开始实现容器编排系统或集群管理系统——它们是臭名昭著的复杂软件。相反,我们可以利用云提供商提供的现有经过实战考验的计算层,无论是开源的还是托管服务。我们将在下一节列出此类系统的选择。当你自己评估这些系统时,记住这些图是个好主意,因为它们可以帮助你理解系统在底层是如何工作的,并激励系统做出各种权衡。
4.2.2 计算层示例
让我们来看看你可以开始使用的计算层。现在,你已经对这些系统在底层的工作原理有了基本的了解,你可以欣赏到每个系统都针对略微不同的特性进行了优化——没有系统在所有事情上都完美。幸运的是,我们不受单一选择的限制。我们的基础设施堆栈可以为不同的用例提供不同的计算层。
图 4.9 说明了支持多个计算层为何会很有用。如果你不认识图中计算层的名称——Spark、AWS Batch、SageMaker 和 AWS Lambda——请不要担心。我们很快会详细介绍它们。

图 4.9 展示了使用多个计算层的流程示例
该图展示了以下三个项目,每个项目都有其自己的工作流程:
-
项目 1 是一个大型、高级项目。它需要处理大量数据,比如 100 GB 的文本语料库,并基于它训练一个大规模的深度神经网络模型。首先,使用针对该任务优化的 Spark 进行大规模数据处理。然后在由 AWS Batch 管理的大型实例上执行额外的数据准备。训练大规模神经网络需要一个针对该任务优化的计算层。我们可以使用 Amazon SageMaker 在 GPU 实例集群上训练模型。最后,我们可以通过在 AWS Lambda 上启动一个轻量级任务来发送模型已准备好的通知。
-
项目 2 使用中等规模的数据集,比如 50 GB,来训练决策树。我们可以使用具有 128 GB RAM 的标准 CPU 实例处理此类规模的数据、训练模型并发布结果。像 AWS Batch 这样的通用计算层可以轻松处理这项工作。
-
项目 3 代表一位数据科学家进行的实验。该项目涉及为世界上每个国家训练一个小型模型。他们不必在笔记本电脑上依次训练 200 个模型,而是可以使用 AWS Lambda 并行化模型训练,从而加快他们的原型设计循环。
如图 4.9 所示,计算层的选取取决于你需要支持的项目类型。从单一、通用的系统如 AWS Batch 开始,随着用例种类的增加,添加更多选项是个好主意。
关键的是,尽管基础设施堆栈可能支持多个计算层,但我们仍然可以限制向用户暴露的复杂性。我们只需用 Python 编写工作流程,在特定计算层的情况下,可能需要使用特定的库。还要记住,我们在第 2.1 节中讨论的两个循环的工效学,即原型设计和生产部署。通常,原型设计需要快速迭代和较少的数据量,而生产部署则强调可扩展性。
如何评估不同计算层的优缺点?你可以关注以下特性:
-
工作负载支持—一些系统针对特定类型的工作负载进行了优化,例如大数据处理或管理多个 GPU,而其他系统则是通用的,可以处理任何类型的任务。
-
延迟—一些系统试图保证任务以最小的延迟开始。在原型设计阶段,这可能很方便,因为等待几分钟才能启动任务可能会让人感到沮丧。另一方面,启动延迟对夜间计划运行没有影响。
-
工作负载管理—当系统接收到的任务数量超过其可以立即部署到实例池的任务时,系统会如何表现?一些系统开始拒绝任务,一些将它们添加到队列中,而一些可能开始终止或抢占正在执行的任务,以便更高优先级的任务可以替代它们执行。
-
成本效益—如前所述,成本优化的关键杠杆是利用率。一些系统在提高利用率方面更为激进,而其他系统则采取更为宽松的方法。此外,云系统中的计费粒度也各不相同:一些按小时计费,一些按秒计费,甚至有的按毫秒计费。
-
操作复杂性—一些系统部署、调试和维护相对简单,而其他系统可能需要持续的监控和维护。
接下来,我们列出了一些计算层的流行选择。这个列表并不全面,但它可以给你一个比较各种选项相对优势的思路。
Kubernetes
Kubernetes(通常简称为 K8S)是目前最受欢迎的开源容器编排系统。它起源于谷歌,谷歌内部已经运营了多年的类似计算层。你可以在私有数据中心部署 K8S,甚至可以在你的笔记本电脑上部署(搜索Minikube获取说明),但它通常用作托管云服务,例如 AWS 的弹性 Kubernetes 服务(EKS)。
Kubernetes 是一个极其灵活的系统。将其视为构建您自己的计算层或微服务平台的工具包。灵活性伴随着大量的复杂性。Kubernetes 及其周围的服务发展迅速,因此需要专业知识和努力才能跟上其生态系统。然而,如果您需要一个无限可扩展的自定义计算层的基础,Kubernetes 是一个很好的起点。查看表 4.1 以了解 Kubernetes 的特点。
表 4.1 Kubernetes 的特点
| 工作负载支持 | 通用。 |
|---|---|
| 延迟 | K8S 主要是一个容器编排系统。您可以配置它与各种集群管理系统一起工作,这些系统处理可伸缩性。选择对任务的启动延迟有重大影响。 |
| 工作负载管理 | 虽然 K8S 提供的是开箱即用的最小工作负载管理,但您可以使 K8S 与任何工作队列一起工作。 |
| 成本效率 | 可配置的;主要取决于底层集群管理系统。 |
| 操作复杂性 | 高;K8S 的学习曲线陡峭。像 EKS 这样的托管云解决方案使这变得容易一些。 |
AWS Batch
AWS 提供了一系列容器编排系统:ECS(弹性容器服务),它运行在您可以管理的 EC2 实例之上;Fargate,它是一个无服务器编排器(即不需要管理 EC2 实例);以及 EKS,它使用 Kubernetes 管理容器。AWS Batch 是这些系统之上的一个层,为底层的编排器提供批处理计算能力,特别是任务队列。
AWS Batch 是操作基于云的计算层最简单的解决方案之一。您定义您希望在实例池中拥有的实例类型,称为计算环境,以及一个或多个作业队列,用于存储待处理任务。之后,您就可以开始向队列提交任务。AWS Batch 负责提供实例、部署容器,并等待它们成功执行。这种简单性的缺点是,AWS Batch 只为更高级的使用案例提供了有限的扩展性和可配置性。您可以在第 4.3 节中了解更多关于 AWS Batch 的信息。查看表 4.2 以了解 AWS Batch 的特点。
表 4.2 AWS Batch 的特点
| 工作负载支持 | 通用。 |
|---|---|
| 延迟 | 相对较高;正如其名所示,AWS Batch 是为批处理设计的,假设启动延迟不是主要问题。一个任务可能需要几秒钟到几分钟才能开始。 |
| 工作负载管理 | 包含内置的工作队列。 |
| 成本效率 | 可配置的;您可以使用 AWS Batch 与任何实例类型,无需额外费用。它还支持spot instances,比普通的按需 EC2 实例便宜得多。Spot 实例可能会突然终止,但对于可以自动重试的批处理作业来说,这通常不是问题。 |
| 操作复杂性 | 低;相对简单设置,几乎无需维护。 |
AWS Lambda
AWS Lambda 通常被描述为一种 函数即服务。您不需要定义服务器或容器,只需定义一段代码,在我们的术语中称为任务,AWS Lambda 就会在触发事件发生时执行该代码,而无需任何用户可见的实例。自 2020 年 12 月以来,AWS Lambda 允许将任务定义为容器镜像,这使得 AWS Lambda 成为计算层的有效选择。
与 AWS Batch 相比,最大的不同之处在于 Lambda 完全不暴露实例池(也称为计算环境)。尽管任务可以请求额外的 CPU 核心和内存,但资源需求的选择范围要小得多。这使得 AWS Lambda 最适合具有适度要求的轻量级任务。例如,您可以在原型设计期间使用 Lambda 快速处理小到中等量的数据。请参阅表 4.3 了解 AWS Lambda 的特性。
表 4.3 AWS Lambda 特性
| 工作负载支持 | 限于相对较短运行时间的轻量级任务。 |
|---|---|
| 延迟 | 低;AWS Lambda 针对在 1 秒或更短时间内启动的任务进行了优化。 |
| 工作负载管理 | 在 异步调用 模式下,Lambda 包含一个工作队列。与 AWS Batch 的工作队列相比,队列的透明度更低。 |
| 成本效益 | 非常高;按毫秒计费,因此您只需为实际使用的部分付费。 |
| 操作复杂性 | 非常低;设置简单,几乎无需维护。 |
Apache Spark
Apache Spark 是一个流行的开源大数据处理引擎。它与之前列出的服务不同,因为它依赖于特定的编程范式和数据结构来实现可伸缩性。它不适用于执行任意容器。然而,Spark 允许使用基于 JVM 的语言、Python 或 SQL 编写代码,因此只要代码遵循 Spark 范式,就可以用来执行任意代码。您可以在自己的实例上部署 Spark 集群,或者将其用作托管云服务,例如通过 AWS Elastic MapReduce (EMR)。请参阅表 4.4 了解 Apache Spark 的特性。
表 4.4 Apache Spark 特性
| 工作负载支持 | 限于使用 Spark 构造编写的代码。 |
|---|---|
| 延迟 | 取决于底层集群管理策略。 |
| 工作负载管理 | 包含内置的工作队列。 |
| 成本效益 | 可配置,取决于集群设置。 |
| 操作复杂性 | 相对较高;Spark 是一个复杂的引擎,需要专业知识来操作和维护。 |
分布式训练平台
虽然可以使用像 Kubernetes 或 AWS Batch 这样的通用计算层来训练大型模型,尤其是在 GPU 实例的支持下,但训练最大的深度神经网络模型,如大规模计算视觉,需要一个专门的计算层。可以使用开源组件构建这样的系统,例如使用名为 Horovod 的项目,该项目起源于 Uber,或者 TensorFlow 分布式训练,但许多公司可能发现使用 SageMaker 或 Google 的 Cloud TPU 这样的托管云服务更容易。
这些系统针对非常具体的工作负载进行了优化。它们使用大量的 GPU 集群,有时还使用定制硬件,以加快现代神经网络所需的张量或矩阵计算。如果你的用例需要训练大规模神经网络,那么在你的基础设施中拥有这样的系统可能是必要的。参见表 4.5 了解分布式训练平台的特征。
表 4.5 分布式训练平台的特征
| 工作量支持 | 非常有限;优化用于训练大规模模型。 |
|---|---|
| 延迟 | 高;优化用于批量处理。 |
| 工作量管理 | 任务特定,不透明的工作量管理。 |
| 成本效率 | 通常非常昂贵。 |
| 操作复杂性 | 相对较高,尽管与本地解决方案相比,云服务在操作和维护方面要容易得多。 |
本地进程
从历史上看,大多数数据科学工作负载都是在个人计算机上执行的,例如在笔记本电脑上。对这一点的现代看法是云工作站,如第 2.1.2 节所述。尽管工作站不是图 4.8 所示的计算层,但它可以用来执行进程和容器,并且对于大多数公司来说,在没有其他系统的情况下,它是第一个支持的计算层。
从计算的角度来看,个人工作站有一个主要优点和一个主要缺点。优点是工作站提供了非常低的延迟,因此有快速的原型设计循环。缺点是它无法扩展。因此,工作站最适合用于原型设计,而所有重负载都转移到其他系统上。参见表 4.6 了解本地进程的特征。
表 4.6 本地进程的特征
| 工作量支持 | 通用。 |
|---|---|
| 延迟 | 非常低;进程立即启动。 |
| 工作量管理 | 可配置的,默认无设置。 |
| 成本效率 | 价格低廉,但计算量有限。 |
| 操作复杂性 | 中等;工作站需要维护和调试。为所有用户提供统一的环境可能很困难。 |
比较
随着基础设施支持的使用案例种类的增加,对提供针对特定工作负载优化的计算层的需要也随之增加。作为数据科学基础设施的提供者,您需要评估应该将哪些系统包含在您的堆栈中,何时、如何以及为什么这么做。
为了帮助您完成这项任务,表 4.7 提供了我们所涵盖的系统的主要优缺点的简要总结。一颗星表示系统在特定领域表现不佳,两颗星表示可接受的行为,三颗星表示系统在该任务上表现卓越。
表 4.7 常见计算层的比较
| 本地 | Kubernetes | 批量 | Lambda | Spark | 分布式训练 | |
|---|---|---|---|---|---|---|
| 擅长通用计算 | ![]() ![]() |
![]() ![]() ![]() |
![]() ![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() |
| 擅长数据处理 | ![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() |
![]() ![]() ![]() |
![]() ![]() |
| 擅长模型训练 | ![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() |
![]() ![]() ![]() |
|
| 任务启动迅速 | ![]() ![]() ![]() |
![]() ![]() |
![]() ![]() |
![]() ![]() ![]() |
![]() ![]() |
![]() |
| 能够排队大量待处理任务 | ![]() |
![]() ![]() |
![]() ![]() ![]() |
![]() ![]() ![]() |
![]() ![]() ![]() |
![]() ![]() |
| 经济实惠 | ![]() ![]() ![]() |
![]() ![]() |
![]() ![]() ![]() ![]() |
![]() ![]() ![]() |
![]() ![]() |
![]() |
| 易于部署和操作 | ![]() ![]() |
![]() |
![]() ![]() |
![]() ![]() ![]() |
![]() |
![]() |
| 可扩展性 | ![]() ![]() ![]() |
![]() ![]() ![]() |
![]() ![]() |
![]() |
![]() ![]() |
![]() ![]() |
不要过于关注个别评估——它们是可以被挑战的。主要的启示信息是没有一个系统可以最优地处理所有工作负载。此外,如果您比较列,您可以看到一些系统在功能上有所重叠(例如,Kubernetes 和 Batch)与那些更互补的系统(例如,Lambda 和 Spark)。
作为一项练习,您可以创建自己版本的表 4.7。将您关心的特性作为行,将您可能考虑使用的系统作为列。练习的结果应该是一套互补的系统,以满足您需要支持的数据科学项目的需求。如果您不确定,以下是一个良好的起点。
技巧规则 提供一个通用计算层,如 Kubernetes 或 AWS Batch,用于重负载处理,以及一个低延迟系统,如本地进程,用于原型设计。根据您的用例需求,使用更多专用系统。
无论您最终选择什么系统,请确保它们可以无缝集成到数据科学家的一致用户体验中。从用户的角度来看,多个系统最初就需要存在是一种麻烦。然而,假装这不是现实往往会导致更多的摩擦和挫败感。
在 4.3 节中,我们将开始动手实践使用 Metaflow 进行计算层和可扩展性。此外,本节还将作为一个示例,说明多个计算层如何在一个统一的用户界面下愉快地共存。
考虑成本
许多公司都担心使用基于云的计算层的成本。当涉及到成本优化时,一个关键的观察结果是闲置实例的成本与执行工作的实例的成本相同。因此,降低成本的关键杠杆是最大化利用率,即用于有用工作的时段比例。我们定义利用率为用于执行任务的总体运行时间的百分比,如图所示:

现在,假设我们无法影响任务执行所需的时间,当用于执行任务的时间等于总实例运行时间时,我们达到最小成本,即我们达到 100%的利用率。在实践中,典型计算层的利用率远低于 100%。特别是,老式数据中心可能只有 10%或更低的利用率。您可以通过以下两种方式提高利用率:
-
您可以通过在任务完成时立即关闭实例来最小化总实例运行时间。
-
您可以通过尽可能多地与项目和工作流程共享实例来最大化用于执行任务的时间,这样实例在运行期间就不会因工作耗尽而停止。
您可以使用本节中描述的计算层来实现这两个目标。首先,当云实例自动完成工作后,关闭它们很容易,因此您只需为需要执行的精确任务集付费。其次,由于虚拟化和容器化提供的强大隔离保证,您可以安全地与多个团队共享相同的实例。这增加了提交给计算层的任务数量,从而提高了利用率。
值得注意的是,在大多数情况下,数据科学家的每小时成本远高于实例的成本。通过使用更多实例小时来节省数据科学家的时间的机会通常都是值得的。例如,使用原始、低效的代码进行实验可能更经济,因为这样需要更多的实例小时,而不是花费许多天或几周手动优化代码。
4.3 Metaflow 中的计算层
每次 Alex 执行模型训练步骤时,他的笔记本电脑听起来就像喷气式发动机。与其购买降噪耳机,不如利用云来处理计算密集型任务似乎是个明智的选择。Bowie 帮助 Alex 配置 Metaflow 以使用 AWS Batch 作为计算层,这使得 Alex 可以在本地原型化工作流程,并只需点击一下按钮即可在云中执行它们。对 Alex 来说,这感觉就像是一种无声的超能力!

Metaflow 支持可插拔的计算层。例如,您可以在本地执行轻量级任务,但将重型数据处理和模型训练卸载到基于云的计算层。或者,如果您的公司已经有一个像 Kubernetes 这样的现有容器管理系统,您可以使用它作为集中的计算层,而不是必须为数据科学运行一个单独的系统。
默认情况下,Metaflow 使用运行在您个人工作站上的本地进程作为计算层。这对于快速原型设计来说很方便。为了展示本地进程在实际中的工作方式,请查看下一个列表。
列表 4.2 本地进程作为计算层
from metaflow import FlowSpec, step
import os
global_value = 5 ❶
class ProcessDemoFlow(FlowSpec):
@step
def start(self):
global global_value ❷
global_value = 9 ❷
print('process ID is', os.getpid())
print('global_value is', global_value)
self.next(self.end)
@step
def end(self):
print('process ID is', os.getpid())
print('global_value is', global_value)
if __name__ == '__main__':
ProcessDemoFlow()
❶ 初始化全局变量
❷ 修改全局变量的值
将代码保存到 process_demo.py。在这里,global_value 被初始化为模块级全局变量。其值在起始步骤从 5 变为 9。在结束步骤再次打印值。你能猜到结束步骤打印的值是 5 还是 9 吗?执行以下代码来测试它:
# python process_demo.py run
预期地,起始步骤的值是 9。结束步骤的值是 5。如果起始和结束是按顺序执行的普通 Python 函数,值将保持为 9。然而,Metaflow 将每个任务作为一个单独的本地进程执行,因此 global_value 的值在每个任务开始时重置为 5。如果您想在任务之间持久化更改,您应该将 global_value 作为 self 中的数据工件存储,而不是依赖于模块级变量。您还可以看到,两个任务的进程 ID 是不同的,如果任务由同一个 Python 进程执行,这种情况是不会发生的。将 Metaflow 任务作为独立的计算单元执行对于计算层很重要。
注意 Metaflow 任务是可以执行在各种计算层上的隔离的计算单元。单个工作流程可以将任务分发给许多不同的计算层,使用最适合每个任务的系统。
Metaflow 的计算方法基于以下三个关于数据科学通用基础设施本质的假设:
-
基础设施需要支持各种具有不同计算需求的项目。有些需要单个实例上的大量内存,有些需要许多小型实例,还有些需要像 GPU 这样的专用硬件。没有一种适合所有计算需求的一劳永逸的方法。
-
单个项目或工作流程在计算方面的需求是变化的。数据处理步骤可能是 I/O 密集型的,可能需要大量的内存。模型训练可能需要专用硬件。小的协调步骤应该快速执行。虽然技术上可以在最大的实例上运行整个工作流程,但在许多情况下,这可能会成本过高。更好的做法是给用户一个选项,可以单独调整每个步骤的资源需求。
-
项目在其生命周期中的需求是变化的。使用本地进程快速原型化第一个版本是方便的。在此之后,您应该能够使用更多的计算资源来测试工作流程。最后,生产版本应该是既健壮又可扩展的。在项目的生命周期中,您可以根据延迟、可扩展性和可靠性在不同阶段做出不同的权衡。
接下来,我们将展示如何使用本地进程和 AWS Batch 设置这种工作方式的基础设施。
4.3.1 为 Metaflow 配置 AWS Batch
AWS Batch 为需要执行计算单元(作业)到完成且无需用户干预的使用场景提供了一个方便的抽象。在底层,AWS Batch 是一个相对简单的作业队列,将计算资源的管理工作卸载给其他 AWS 服务。
您可以在 Metaflow 的在线文档中找到 AWS Batch 的逐步安装说明(请参阅metaflow.org上的 Metaflow 管理员指南)。您可以使用提供的CloudFormation模板,只需点击一下按钮即可为您设置一切,或者如果您想自己设置,可能还会在设置过程中进行自定义,可以遵循手动安装说明。
在您为 Metaflow 配置了 AWS Batch 之后,数据科学家可以像下一节所描述的那样直接使用它,无需担心实现细节。然而,对于系统的操作者来说,了解高级架构是有益的,如图 4.10 所示。

图 4.10 AWS Batch 的架构
让我们从以下四个概念开始,这些概念在 AWS Batch 的文档中经常被提及,如图 4.10 所示加粗显示:
-
作业定义——配置作业的执行环境:CPU、内存、环境变量等。Metaflow 会自动为每个步骤创建合适的作业定义,因此您无需担心这一点。
-
作业——单个计算单元。每个作业作为一个独立的容器执行。Metaflow 会自动将每个 Metaflow 任务映射到单个 Batch 作业。因此,在 AWS Batch 的上下文中,我们可以互换地谈论任务和作业。
-
作业队列—作业被发送到作业队列等待执行。一个队列可能有任何数量的待处理任务。你可以设置多个队列,例如,区分低优先级和高优先级作业。图示展示了两个队列:一个有两个作业,另一个有一个作业。
-
计算环境—一个执行作业的计算资源池。AWS Batch 可以为你管理计算环境,当队列变长时,向环境中添加更多计算资源,或者你可以自行管理计算环境。得益于自动扩展的计算环境,AWS Batch 可以用作弹性扩展的计算层。图示展示了两个计算环境:一个使用 EC2 实例,另一个使用 Fargate。更详细的讨论将在后面进行。
当你启动一个使用 AWS Batch 的 Metaflow 运行时,执行过程如下:
-
在开始任何任务之前,Metaflow 确保在 Batch 上创建了正确的作业定义。
-
Metaflow 创建一个包含与流程对应的全部 Python 代码的 作业包。该包被上传到 AWS S3 中的数据存储(关于作业包的更多信息请参阅第七章)。
-
Metaflow 遍历 DAG。当它遇到应在 Batch 上执行的任务时,它会向预先配置的作业队列提交一个作业请求。
-
如果计算环境中没有足够的计算资源,并且尚未达到其最大限制,Batch 会扩展环境。
-
一旦资源可用,Batch 会为执行安排一个作业。
-
包含在 Batch 作业中的 Metaflow 任务在容器中执行。
-
Metaflow 检查任务的状况。一旦 Batch 报告任务已成功完成,Metaflow 继续执行后续任务,回到步骤 3,直到完成最后一步。
选择计算环境
如果你让 AWS Batch 为你管理计算环境,它将使用 AWS 提供的容器管理服务,如 弹性容器服务(ECS)来执行容器。在幕后,ECS 使用一个托管自动扩展组启动 EC2 计算实例,这是一个可以根据需求自动增长和缩小的实例集合。这些实例将像你账户中的任何其他实例一样出现在 EC2 控制台中。
使用 ECS 的好处是,你可以在计算环境中使用任何 EC2 实例类型。你可以选择具有大量内存、许多 CPU 核心或甚至多个 GPU 的实例集合。ECS 会根据其资源需求,在最适合的实例上安排作业。
或者,您可以选择使用 AWS Fargate 作为计算环境。Fargate 不直接使用 EC2 实例,因此您在 EC2 仪表板上看不到任何实例。此外,您也不能直接选择实例类型。Fargate 会根据每个作业的资源需求自动找到合适的实例。然而,与 ECS 相比,支持的资源需求范围更为有限。Fargate 相比 ECS 的最大优势是作业启动更快。
作为另一种选择,您可以在 ECS 后面管理自己的 EC2 实例池。虽然这种方法更为繁琐,但它允许最大程度的可定制性。您可以按自己的意愿设置实例。如果您的安全或合规性要求特殊,这种方法可能很有用。
从 Metaflow 任务的视角来看,计算环境并没有任何区别。一旦为任务找到合适的实例,它就会使用相同的容器镜像在容器中执行,无论环境如何。如本章前面所述,计算层仅决定任务执行的 位置。
最后,对于注重成本的公司来说,这是一个好消息:您使用 AWS Batch 的实例无需支付任何额外费用。您只需支付您选择的 EC2 实例的每秒费用,这使得 AWS Batch 成为最具成本效益的计算层之一。您还可以通过使用 Spot 实例 进一步降低成本,这些实例与 EC2 实例相同,但有一个前提,即它们可能在任何时间点被中断。这并不像听起来那么糟糕——Metaflow 可以使用 @retry 装饰器自动重试中断的作业(请参阅第 4.4 节)。主要成本是在发生中断时执行时间的额外延迟。
配置容器
虽然计算环境决定了如何为作业提供硬件资源,如 CPU 和内存,但容器设置决定了 Metaflow 任务的软件环境。请注意以下两个设置。
首先,您必须配置安全配置文件,即 IAM 角色,它决定了 Metaflow 任务允许访问哪些 AWS 资源。至少,它们需要能够访问用作 Metaflow 数据存储的 S3 存储桶。如果您使用 AWS Step Functions 进行作业调度,如第六章所述,您还必须允许访问 DynamoDB 表。如果您使用提供的 CloudFormation 模板,将为您自动创建合适的 IAM 角色。
其次,您可以配置用于执行任务的 默认容器镜像。该镜像决定了任务默认可用的库。例如,如果您已如第二章所述设置了基于云的工作站,您可以为工作站和任务执行使用相同的镜像(您将在第六章中了解更多关于依赖关系管理的知识)。如果您未指定任何镜像,Metaflow 将选择一个通用的 Python 镜像。
使用 AWS Batch 的第一次运行
要使用 AWS Batch 与 Metaflow,你需要完成以下步骤。这些步骤由提供的 CloudFormation 模板自动执行,但手动完成它们并不困难。请参阅 Metaflow 的在线文档以获取详细说明。
首先,按照以下步骤安装和配置 awscli,这是一个用于与 AWS 交互的命令行工具:
# pip install awscli
# aws configure
如果你没有使用 CloudFormation 模板,但想手动配置 AWS Batch,请执行以下步骤:
-
为 Metaflow 数据存储初始化一个 S3 存储桶。
-
为计算环境设置一个 VPC 网络。
-
设置一个批处理作业队列。
-
设置一个批处理计算环境。
-
为容器设置一个 IAM 角色。
在你执行了 CloudFormation 模板或之前的手动步骤之后,运行 metaflow configure aws 来配置 Metaflow 的服务。这就完成了!
完成这些步骤后,让我们测试一下集成是否正常工作。执行以下命令,它使用 AWS Batch 运行列表 4.2 中的 process_demo.py:
# python process_demo.py run --with batch
命令应该生成如下所示的输出:
[5c8009d0-4b48-40b1-b4f6-79f6940a6b9c] Task is starting (status SUBMITTED)...
[5c8009d0-4b48-40b1-b4f6-79f6940a6b9c] Task is starting (status RUNNABLE)...
[5c8009d0-4b48-40b1-b4f6-79f6940a6b9c] Task is starting (status STARTING)...
[5c8009d0-4b48-40b1-b4f6-79f6940a6b9c] Task is starting (status RUNNING)...
[5c8009d0-4b48-40b1-b4f6-79f6940a6b9c] Setting up task environment.
示例省略了 Metaflow 每行的标准前缀以节省空间。方括号中的长 ID 是 AWS Batch 作业 ID,对应于 Metaflow 任务。你可以用它来在 AWS 控制台 UI 中交叉引用可见的 Metaflow 任务和 AWS Batch 作业。
前四条“任务正在启动”的行指示了任务在批处理队列中的状态如下:
-
已提交——任务正在进入队列。
-
可运行——任务在队列中等待,等待合适的实例变得可用。
-
开始——找到了合适的实例,任务正在该实例上启动。
-
正在运行——任务正在实例上运行。
当 AWS Batch 扩展计算环境时,任务通常会保持在可运行状态长达几分钟。如果计算环境已达到其最大大小,任务需要等待之前任务完成,这可能会更长。
几分钟后,运行应该成功完成。其输出应该与本地运行类似。尽管这次运行看起来并不起眼——使用 AWS Batch 的运行比本地运行慢,因为云中启动任务的开销——你现在几乎拥有无限的计算能力!我们将在下一节中利用这种能力,甚至在下一章中还会更多。
排查可运行任务
AWS Batch 无法正常工作的一个常见症状是任务似乎永远卡在可运行状态。这可能是由于与计算环境(CE)相关的一系列原因造成的。
如果你正在使用由 EC2 支持的计算环境(非 Fargate),你可以通过登录到 EC2 控制台并搜索以 aws:autoscaling:groupName:开头后跟 CE 名称的标签来检查在 CE 中创建了哪些实例。根据返回的实例列表,你可以按以下方式排查问题:
-
没有实例—如果没有返回实例,可能是因为你的 CE 无法启动所需类型的实例。例如,你的 AWS 账户可能已经达到 EC2 实例的限制。你可能可以通过检查以 CTE 命名的自动扩展组的状态来了解为什么没有实例。
-
一些实例但无其他任务运行—可能你的任务请求的资源,例如,使用稍后讨论的 @resources 装饰器,无法由 CE 满足,例如内存或 GPU。在这种情况下,任务将永远停留在队列中。你可以在 AWS Batch 控制台中终止任务(作业)。
-
一些实例和其他正在运行的任务—你的集群可能正忙于处理其他任务。请先等待其他任务完成。
如果问题仍然存在,你可以联系 Metaflow 的在线支持。
4.3.2 @batch 和 @resources 装饰器
现在你已经配置了 AWS Batch,你可以选择仅通过使用 run --with batch 在云中执行任何运行。Metaflow 的所有功能,如工件、实验跟踪、参数和客户端 API,在作为计算层使用 AWS Batch 时与之前完全相同。
如本章开头所述,拥有基于云的计算层的主要动机是可扩展性:你可以处理比在本地工作站上更多的计算和更多的数据。让我们在实践中测试可扩展性。下面的代码列表展示了一个尝试通过创建包含八十亿个字符的字符串来分配 8 GB 内存的工作流程。
列表 4.3 使用大量内存的工作流程
from metaflow import FlowSpec, step
LENGTH = 8_000_000_000 ❶
class LongStringFlow(FlowSpec):
@step
def start(self):
long_string = b'x' * LENGTH ❷
print("lots of memory consumed!")
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
LongStringFlow()
❶ 将长度设置为八十亿个字符。下划线被添加以帮助阅读。
❷ 尝试分配 8 GB 的内存
将代码保存在 long_string.py 中。如果你的工作站上至少有 8 GB 的内存可用,你可以通过以下方式在本地运行流程:
# python long_string.py run
如果没有足够的内存,运行可能会因 MemoryError 而失败。接下来,让我们按照以下方式在 Batch 上执行流程:
# python long_string.py run --with batch
Metaflow 使用默认的内存设置在 Batch 上执行任务,这些设置为任务提供的内存少于 8 GB。任务可能会失败,并显示如下消息:
AWS Batch error:
OutOfMemoryError: Container killed due to memory usage This could be a transient error. Use @retry to retry.
虽然你无法像在笔记本电脑上那样轻松增加工作站上的内存量,但我们可以从我们的计算层请求更多的内存。按照以下方式重新运行流程:
# python long_string.py run --with batch:memory=10000
memory=10000 属性指示 Metaflow 为流程的每个步骤请求 10 GB 的内存。内存的单位是兆字节,所以 10,000 MB 等于 10 GB。请注意,如果你的计算环境不提供至少 10 GB 内存实例,运行将卡在 RUNNABLE 状态。假设可以找到合适的实例,运行应该可以成功完成。
这就是垂直扩展的实际应用!我们只需在命令行中请求具有特定硬件要求的实例。除了内存外,您还可以使用 cpu 属性请求最小数量的 CPU 核心,甚至可以使用 gpu 属性请求 GPU。例如,下面的命令行代码为每个任务提供了 8 个 CPU 核心和 8GB 的内存:
# python long_string.py run --with batch:memory=8000,cpu=8
由于数据科学工作负载往往对资源需求很大,能够轻松地测试代码和扩展工作负载非常方便。您可以在计算环境中请求 EC2 实例支持的任何内存量。截至本书编写时,最大的实例有 768GB 的内存,因此,在合适的计算环境中,您可以请求高达--with batch:memory=760000 的内存,为实例上的操作系统留下 8GB。
您可以使用这么多内存处理相当大的数据集。如果您担心成本,请考虑函数执行时间不到一分钟。即使您在最大且最昂贵的实例上执行任务,成本也只有大约 10 美分,这得益于按秒计费。您可以通过在计算环境中使用之前讨论过的 spot 实例来进一步降低成本。
在代码中指定资源需求
假设您与同事共享 long_string.py。按照之前的做法,他们需要知道特定的命令行代码,即 run --with batch: memory=10000,才能成功运行流程。我们知道内存量是一个严格的要求——没有至少 8GB 的内存,流程将无法成功——因此我们可以在代码中直接添加要求,通过添加
@batch(memory=10000)
上述@step 的起始步骤。请记住,在文件顶部添加 from metaflow import batch。
现在,您的同事可以使用运行命令来运行流程,而无需任何额外选项。作为额外的好处,只有用@batch 装饰器注解的起始步骤在 AWS Batch 上执行,而无需任何资源要求的结束步骤则在本地上执行。这说明了您如何在单个工作流程中无缝地使用多个计算层。
注意:--with 选项是分配装饰器(如 batch)的快捷方式,就像在飞行中添加@batch 装饰器一样。因此,run --with batch 相当于手动将@batch 装饰器添加到流程的每个步骤并执行 run。相应地,在冒号之后添加的任何属性,如 batch:memory=10000,将映射到装饰器提供的参数,如@batch(memory=10000)。
现在假设你公开了一个带有 @batch 注解的 long_string.py 版本。一个陌生人想要执行这段代码,但他们的计算层是 Kubernetes,而不是 AWS Batch。技术上,他们应该能够在 Kubernetes 提供的 10 GB 实例上成功执行流程。对于这种情况,Metaflow 提供了另一个装饰器,@resources,它允许你以计算层无关的方式指定资源需求。你可以用以下方式替换 @batch 装饰器:
@resources(memory=1000)
然而,与 @batch 相比,@resources 装饰器并不确定使用哪个计算层。如果你不带选项运行流程,它将在本地执行,并且 @resources 没有作用。要使用 AWS Batch 运行流程,你可以使用 run --with batch 而不带任何属性。@batch 装饰器知道从 @resources 中选择资源需求。相应地,陌生人可以使用类似于 run --with kubernetes 的方式在他们自己的 Kubernetes 集群上运行流程。
将具有高资源需求的步骤用 @resources 注解被认为是最佳实践。如果步骤代码没有一定数量的内存就无法成功执行,或者,例如,没有一定数量的 CPU 或 GPU 内核,模型训练步骤就无法快速执行,你应该在代码中明确要求。一般来说,当可能时,使用 @resources 而不是 @batch 或其他计算层特定的装饰器更可取,这样任何运行流程的人都可以动态选择合适的计算层。
我们将在下一章中通过使用 @resources 和 AWS Batch 的更多例子来探讨可扩展性。然而,在到达那里之前,我们将涵盖生活中不可避免的事实:意外会发生,事情并不总是按预期进行。
4.4 处理失败
有一天,Caveman Cupcakes 的基于云的计算环境开始出现异常行为。Alex 那些已经完美运行了几周的作业开始无缘无故地失败。Bowie 注意到云提供商的状态仪表板报告了“错误率增加。”Alex 和 Bowie 除了等待云自行修复并尝试将影响限制在他们的生产工作流程中之外,别无他法。

Alex 和 Bowie 的场景几乎不是假设性的。尽管云提供了一种相当实用的无限可扩展性的错觉,但它并不总是完美无缺。云中的错误往往具有随机性,因此并发作业的数量越多,你遇到随机瞬时错误的可能性就越大。因为这些错误是生活中不可避免的事实,我们应该做好积极应对的准备。区分两种类型的失败是有用的,如下所示:
-
用户代码中的失败——步骤中的用户编写的代码可能包含错误,或者它可能调用其他表现错误的服务。
-
平台错误——执行步骤代码的计算层可能会因多种原因而失败,例如硬件故障、网络故障或配置的意外更改。
发生在用户代码中的故障,如失败的数据库连接,通常可以在用户代码内部处理,这区分了第一类错误和第二类错误。在你的 Python 代码中,你无法从底层容器管理系统失败等情况中恢复。考虑下一个代码列表中展示的示例。
列表 4.4 由于除以零而失败
from metaflow import FlowSpec, step
class DivideByZeroFlow(FlowSpec):
@step
def start(self):
self.divisors = [0, 1, 2]
self.next(self.divide, foreach='divisors')
@step
def divide(self):
self.res = 10 / self.input ❶
self.next(self.join)
@step
def join(self, inputs):
self.results = [inp.res for inp in inputs]
print('results', self.results)
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
DivideByZeroFlow()
❶ 这将因 ZeroDivisionError 而失败。
将流程保存在 zerodiv.py 中,并按以下方式运行:
# python zerodiv.py run
运行将失败并抛出异常,ZeroDivisionError: 除以零。这显然是用户代码中的逻辑错误——在数值算法中,意外的除以零错误相当常见。如果我们怀疑某段代码可能会失败,我们可以使用 Python 的标准异常处理机制来处理它。按照以下方式修复除法步骤:
@step
def divide(self):
try:
self.res = 10 / self.input
except:
self.res = None
self.next(self.join)
修复后,流程将成功运行。遵循这个模式,建议尽可能在步骤代码中处理尽可能多的异常,以下是一些原因:
-
如果你编写代码时考虑了可能的错误路径,你也可以实现错误恢复路径,例如,当 ZeroDivisionError 被抛出时,应该确切发生什么。你可以将“计划 B”作为你逻辑的一部分实现,因为只有你知道在你特定的应用程序中正确的行动方案。
-
从用户代码中的错误中恢复更快。例如,如果你在步骤中调用外部服务,如数据库,你可以实现重试逻辑(或依赖数据库客户端的内置逻辑),在不需要重试整个 Metaflow 任务的情况下重试失败的连接,这会带来更高的开销。
即使你遵循这些建议,任务仍然可能会失败。它们可能因为未考虑到的错误场景而失败,或者可能因为平台错误而失败,例如,数据中心可能发生火灾。Metaflow 提供了额外的错误处理层,可以帮助处理这些场景。
4.4.1 使用 @retry 从短暂错误中恢复
列表 4.4 中展示的流程每次执行都会可预测地失败。大多数平台错误和一些用户代码中的错误行为更为随机——例如,AWS Batch 可能会调度一个任务在硬件故障的实例上执行。计算环境最终会检测到硬件故障并退役该实例,但这可能需要几分钟。最好的做法是自动重试任务。有很大可能性它不会再次遇到相同的短暂错误。以下列表模拟了一个不幸的短暂错误:每隔一秒就会失败一次。
列表 4.5 重试装饰器
from metaflow import FlowSpec, step, retry
class RetryFlow(FlowSpec):
@retry
@step
def start(self):
import time
if int(time.time()) % 2 == 0: ❶
raise Exception("Bad luck!")
else:
print("Lucky you!")
self.next(self.end)
@step
def end(self):
print("Phew!")
if __name__ == '__main__':
RetryFlow()
❶ 条件为真,取决于执行该行的第二个时刻。
将流程保存在 retryflow.py 中,并按以下方式运行:
# python retryflow.py run
当执行遇到“Bad luck”分支时,你应该会看到一堆异常被打印出来。多亏了 @retry 标志,任何在起始步骤中的失败都会导致它自动重试。执行最终成功的可能性很大。你可以多次重新运行流程以查看效果。
@retry 的一个关键特性是它还处理平台错误。例如,如果容器在 AWS Batch 中由于任何原因失败,包括数据中心着火,@retry 装饰器将导致任务重试。多亏了云的复杂性,重试的任务有很大机会被重新路由到一个非燃烧的数据中心,并最终成功。
注意,当你开始在工作站上启动运行时,只有当工作站在整个执行过程中保持存活时,运行才会成功。@retry 装饰器的重试机制是由 DAG 调度器实现的,在本地运行的情况下是 Metaflow 的内置调度器。如果调度器本身死亡,它将把所有执行都带走,这对于业务关键的生产运行来说是不理想的。解决这个缺点是第六章的关键主题,该章专注于生产部署。我们将学习如何使调度本身能够容忍平台错误。
逃离燃烧的数据中心
即使数据中心着火,任务如何还能成功?AWS 有一个概念叫做可用区(AZ),它们是物理上分离的数据中心,限制了任何现实世界灾难的影响范围。在 AWS Batch 的情况下,计算环境可以透明地在多个 AZ 上启动实例,所以当一个 AZ 中的实例不可用时,另一个 AZ 中的实例可以接管。
选择性地避免重试
你可能会 wonder 为什么用户需要担心 @retry 装饰器——Metaflow 不能自动重试所有任务吗?一个挑战是步骤可能有副作用。想象一下一个步骤,比如在数据库中增加一个值,例如,银行账户的余额。如果该步骤在增加操作后崩溃并被自动重试,银行账户就会被借记两次。
如果你有一个不应该重试的步骤,你可以用装饰器 @retry(times=0) 来注释它。现在,任何人都可以通过简单地执行
# python retryflow.py run --with retry
这将为每个步骤添加一个 @retry 装饰器,但带有 @retry(times=0) 的步骤将不会重试。你还可以使用 times 属性来调整重试次数,使其高于默认值。此外,你可以指定另一个属性,minutes_between_retries,它告诉调度器在重试之间等待指定数量的分钟数。
建议:无论何时你在云中运行流程,例如使用 AWS Batch,运行它时带上 --with retry 是一个好主意,它会自动处理瞬态错误。如果你的代码不应该重试,请用 @retry(times=0) 来注释它。
4.4.2 使用 @timeout 杀死僵尸
并非所有错误都表现为异常或崩溃。一类特别令人烦恼的错误会导致任务卡住,阻止工作流程的执行。在机器学习中,这种情况可能发生在与特定数据集缓慢收敛的数值优化算法中。或者,你可能调用一个永远不会返回正确响应的外部服务,导致函数调用永远阻塞。
你可以使用@timeout 装饰器来限制任务的总体执行时间。下面的列表模拟了一个偶尔需要很长时间才能完成的任务。当这种情况发生时,任务会被中断并重试。
列表 4.6 超时装饰器
from metaflow import FlowSpec, timeout, step, retry
import time
class TimeoutFlow(FlowSpec):
@retry
@timeout(seconds=5) ❶
@step
def start(self):
for i in range(int(time.time() % 10)): ❷
print(i) #B
time.sleep(1) #B
self.next(self.end)
@step
def end(self):
print('success!')
if __name__ == '__main__':
TimeoutFlow()
❶ 任务将在 5 秒后超时。
❷ 这段代码的执行时间从 0 到 9 秒不等。
将流程保存在 timeoutflow.py 中,并按以下方式运行:
# python timeoutflow.py run
如果你运气好,运行可能第一次尝试就成功。你可以再次尝试,看看@timeout 和@retry 是如何工作的。启动任务在 5 秒后中断。当这种情况发生时,@retry 会负责重试该步骤。如果没有@retry,当发生超时时,运行会崩溃。除了秒数,你还可以将超时值设置为分钟或小时,或者它们的组合。
4.4.3 最后的手段装饰器:@catch
机器学习工作流程可以为数百万人的业务关键系统和产品提供动力。在像这样的关键生产环境中,基础设施应确保在出现错误的情况下,工作流程能够优雅地降级。换句话说,即使发生错误,也不应该导致整个工作流程失败。
假设你在工作流程中的一个步骤中连接到数据库以检索用于更新模型的最新数据。有一天,数据库宕机,连接失败。希望你的步骤有@retry 装饰器,所以任务会重试几次。如果数据库中断持续到所有重试,会怎样?当达到最大重试次数时,工作流程会崩溃。这可不是什么好事。
或者考虑另一个现实场景:一个工作流程使用 200 个 foreach 为世界上每个国家训练一个单独的模型。输入数据集包含按国家划分的每日事件批次。有一天,由于安道尔这个小国没有产生新事件,模型训练步骤失败了。当然,数据科学家应该在数据被输入模型之前进行质量检查,但这个问题在测试期间从未发生,所以这是一个可以理解的错误。在这种情况下,整个工作流程也失败了,导致几小时的疯狂故障排除。
Metaflow 提供了一个最后的手段装饰器,@catch,它在所有重试耗尽后执行。@catch 装饰器吞没了任务产生的所有错误,即使任务未能产生任何有用的结果,也允许执行继续。关键的是,@catch 允许创建一个指示性工件,这样后续步骤可以优雅地处理失败的任务。
让我们将 @catch 装饰器应用到我们之前的示例中,即列表 4.4 中的 DivideByZeroFlow。让我们称新的版本为 CatchDivideByZeroFlow。从结构上看,这个例子与安道尔例子相似:它有一个带有故障任务的 foreach,这个任务不应该导致整个工作流程失败。
列表 4.7 展示了 @catch 装饰器
from metaflow import FlowSpec, step, retry, catch
class CatchDivideByZeroFlow(FlowSpec):
@step
def start(self):
self.divisors = [0, 1, 2]
self.next(self.divide, foreach='divisors')
@catch(var='divide_failed') ❶
@retry(times=2) ❷
@step
def divide(self):
self.res = 10 / self.input
self.next(self.join)
@step
def join(self, inputs):
self.results = [inp.res for inp in inputs if not inp.divide_failed]
print('results', self.results)
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
CatchDivideByZeroFlow()
❶ 创建一个指标工件,divide_failed,如果任务失败则设置为 True
❷ 在这种情况下,重试是徒劳的。
将流程保存在 catchflow.py 中,并按以下方式运行:
# python catchflow.py run
注意到其中一个除法任务失败并重试,最后 @catch 接管并打印关于 ZeroDivisionError 的错误消息。关键的是,@catch 允许执行继续。它为失败的任务创建了一个工件,divide_failed=True,你可以自由命名这个工件。后续的 join 步骤使用这个工件只包括成功任务的输出。如果你好奇,你可以按照以下方式使用 AWS Batch 运行流程:
# python catchflow.py run --with batch
你可以看到,装饰器将以相同的方式工作,无论计算层如何。
使用 @catch 注解可能以不可预见的方式失败但不应导致整个工作流程崩溃的复杂步骤,例如模型训练或数据库访问。只需确保你在后续步骤中优雅地处理缺失的结果。
摘要:逐步加固工作流程
本节介绍了四种主动处理失败的方法:Python 的标准 try-except 构造、@retry、@timeout 和 @catch。这些装饰器是可选的,承认处理失败并不是在原型设计新项目时的首要任务。然而,随着项目的成熟,你可以使用装饰器来加固你的工作流程,使其更适合生产环境。你可以按以下顺序逐步加固你的工作流程:
-
在你的代码中使用 try-except 块来处理明显的异常。例如,你可以将任何数据处理包裹在 try-except 中,因为数据可能会以令人惊讶的方式演变。此外,将任何对数据库等外部服务的调用包裹在 try-except 中,并可能包括特定用例的重试逻辑是一个好主意。
-
使用 @retry 处理任何瞬态平台错误,即云中发生的任何随机问题。特别是,对于启动许多任务的工作流程,如使用 foreach,使用 @retry 是至关重要的。你只需使用 run --with batch --with retry 就可以在云中可靠地执行任何流程。为了额外的安全性,你可以使 @retry 更有耐心——例如,@retry(times=4, minutes_between_retries=20) 给任务超过一个小时的失败时间。
-
使用 @timeout 注解可能卡住或可能执行任意长时间步骤。
-
使用 @catch 防止复杂步骤,例如模型训练或数据处理,导致整个工作流程崩溃。记住在后续步骤中检查由 @catch 创建的指标工件,以考虑缺失的结果。
摘要
-
有效的基础设施有助于数据科学在多个层面进行扩展。它允许您处理更多的人员、更多项目、更多工作流程、更多计算和更多数据。
-
版本控制和隔离有助于扩展人员和项目数量,因为所需的协调较少。基于云的计算层允许数据科学家扩展计算资源,使他们能够处理更复杂模型和更多数据。
-
使用基于云的计算层可以处理比单个工作站更多的任务(横向扩展)和更大的任务(纵向扩展)。
-
利用现有的容器管理系统在云中执行隔离的批量计算单元。
-
基础设施可以支持多种计算层,每一层都针对特定的工作负载进行了优化。
-
要开始使用基于云的计算,一个简单的方法是使用 AWS Batch 与 Metaflow 结合。
-
使用@resources 装饰器来注释每一步的资源需求。
-
Metaflow 提供了三个装饰器,允许您逐步增强工作流程以应对失败:@retry、@catch 和@timeout。
5 实践可扩展性和性能
本章涵盖了
-
逐步开发一个现实、高性能的数据科学项目
-
使用计算层来支持对计算要求高的操作,例如并行化模型训练
-
优化数值 Python 代码的性能
-
使用各种技术使你的工作流程更具可扩展性和性能
在上一章中,我们讨论了可扩展性不仅关乎能够处理更复杂的算法或处理更多数据。在组织层面上,基础设施应该扩展到由大量人员开发的大量项目。我们认识到,可扩展性和性能是两个独立的问题——你可以有其中一个而没有另一个。事实上,可扩展性和性能的不同维度可能相互矛盾。
想象一下,一个经验丰富的工程师正在用 C++ 语言实现一个高度优化、高性能的解决方案。尽管这个解决方案在技术层面上可以扩展,但如果团队中没有其他人懂得 C++ 语言,那么在组织层面上它的可扩展性就不是很强。相反,你可以想象一个非常高级的机器学习解决方案,只需点击一下按钮就能构建模型。每个人都知道如何点击按钮,但这个解决方案过于不灵活,无法扩展到各种不同的项目,并且无法处理大量数据。本章提倡一种务实的方法来处理可扩展性和性能,其特点如下:
-
有效的基础设施需要处理各种项目,因此,而不是提供一个一刀切解决方案,它可以提供一个易于使用的工具箱,包含稳健的方法来实现足够好的可扩展性和性能。
-
为了解决组织可扩展性——我们希望让尽可能多的人理解项目——我们的主要工具是简单性。人们的认知带宽有限,因此过度工程化和过度优化会带来真实的人类成本。
我们可以用一个简单的助记符来总结这两点,如图 5.1 所示。

图 5.1 一种务实的方法来处理可扩展性
在这里,简单指的是任何新加入项目的人都可以查看源代码,并快速理解其工作原理。复杂则相反:理解代码的工作原理需要付出很多努力。慢意味着解决方案可能会遇到可扩展性的限制,它使得人们等待结果的时间比理想状态更长,但无论如何,它还是可以工作的。快意味着解决方案对于当前的问题来说完全足够:它有足够的可扩展性,并且可以快速提供结果。
通过优化简单性,我们也优化了结果的有效性。正如著名计算机科学家托尼·霍尔所说:“有两种编写代码的方式:编写如此简单的代码以至于显然没有错误,或者编写如此复杂的代码以至于没有明显的错误。”由于数据科学应用程序本质上是统计性的——错误和偏差可能潜伏在模型中而不产生清晰的错误信息,因此您应该更喜欢简单的代码而不是不明显的问题。只有当应用程序确实需要更高的扩展性或性能时,您才应该按比例增加其复杂性。
在本章中,我们将开发一个对扩展性和性能有非平凡要求的实用机器学习(ML)应用程序。我们练习逐步开发数据科学应用程序,始终追求最简单的方法,这种方法是正确的并产生预期的结果。换句话说,我们希望保持在图 5.1 的第一行。我们将展示一些有助于实现足够好的扩展性和性能的方法。
我们将使用上一章中介绍的工具:使用计算层进行垂直和水平扩展。虽然可以在笔记本电脑上运行示例,但如果您已经按照之前的说明设置了基于云的计算层(如 AWS Batch),那么它们将更有趣和更真实。像以前一样,我们将使用 Metaflow 来展示概念并获得实际操作经验,但您可以将示例适应到其他框架,因为通用原则是框架无关的。您可以在mng.bz/yvRE找到本章的所有代码列表。
5.1 从简单开始:垂直扩展性
我们将开始构建一个使用自然语言处理(NLP)来建模和分析 Yelp 评论的实用机器学习(ML)应用程序。我们将遵循第三章中介绍的螺旋方法,如图 5.2 所示,来开发该应用程序。

图 5.2 螺旋方法,优化作为最后一步
尽管本章的主题是实践扩展性,但以下步骤在图 5.2 中显示,在考虑任何扩展性问题之前:
-
深入了解业务问题。也许业务环境允许我们采用更简单、扩展性较低但更明显正确的解决方案。
-
获取相关输入数据,并确保数据是正确的,并且将保持正确。同时,估计数据的规模和增长率。
-
确保您的应用程序的结果可以被正确消费,并且它们产生预期的行动。
-
开发一个小型但功能齐全的原型,以便您可以使用真实数据测试应用程序,确保其端到端正确性。
为了实施这些步骤,我们可以选择最简单的可扩展方法,这样我们就能构建一个功能原型。第一个版本不需要特别可扩展。我们可以在确认其他一切正常后,再对其进行修复。引用软件架构师 Kent Beck 的话,我们的优先顺序应该是:“先让它工作,再让它正确,最后让它快速。”
5.1.1 示例:聚类 Yelp 评论
让我们从假设的商业问题开始:一家初创公司希望构建一个更好的 Yelp 版本,一个评论网站。为了了解 Yelp 产品的优势和劣势,他们希望分析人们为 Yelp 贡献的不同类型的评论。
我们没有现有的评论分类法,所以我们不会将评论分类到已知的类别中,而是将依赖于无监督学习,在这种情况下,将 Yelp 评论分组为看起来相似的评论集。你可以在 Scikit-Learn 的文档中了解更多关于无监督学习和文档聚类的信息,见mng.bz/M5Mm。
为了完成这个任务,我们可以访问一个包含 65 万条 Yelp 评论的公开可用语料库。数据集由 Fast.AI(course.fast.ai/datasets)公开提供,并且方便地托管在 AWS S3 的 AWS 开放数据注册处registry.opendata.aws/fast-ai-nlp/。数据集未压缩时大约有 500 MB,因此足够大,可以练习可扩展性,但足够小,可以在任何中型云实例或工作站上处理。一个好的起点是查看数据看起来是什么样子。Jupyter 笔记本非常适合这个目的,如图 5.3 所示。

图 5.3 在笔记本中检查 Yelp 数据集
下一个列表显示了图 5.3 中使用的代码。
列表 5.1 检查 Yelp 评论数据集
import tarfile
from metaflow import S3
with S3() as s3: #A
res = s3.get('s3://fast-ai-nlp/yelp_review_full_csv.tgz') ❶
with tarfile.open(res.path) as tar: ❷
datafile = tar.extractfile('yelp_review_full_csv/train.csv') ❷
reviews = [line.decode('utf-8') for line in datafile] ❸
print('\n'.join(reviews[:2])) ❹
❶ 使用 Metaflow 内置的 S3 客户端加载公开可用的 Yelp 数据集
❷ 从 tar 包中提取数据文件
❸ 将所有评论按行加载到一个列表中
❹ 打印前两个评论作为示例
在这里,我们使用 Metaflow 内置的 S3 客户端从 Amazon S3 加载数据——你将在第七章中了解更多关于它的内容。数据集存储在一个压缩的 tar 归档中,我们解压缩它以提取评论。每条评论一行,前面带有星级评分。我们的应用程序不需要关心评分列。
建议:本章中使用的数据集 yelp_review_full_csv.tgz 大约有 200 MB。通过慢速互联网连接下载可能需要几分钟。如果笔记本电脑上的示例感觉太慢,请考虑使用云工作站,例如 AWS Cloud9 IDE,以执行本章中的所有示例。
您可以在图 5.3 中看到一些评论数据的样本。正如预期的那样,评论是任意段落的书面英语。在我们可以对数据进行任何聚类之前,我们必须将字符串转换为数值表示。这种向量化步骤是涉及自然语言的机器学习任务中的常见预处理步骤。如果您之前没有进行过任何自然语言处理(NLP),请不要担心:我们将在下一小节中涵盖您需要了解的所有内容。
自然语言处理一分钟入门
将自然语言编码为数值形式的一种经典方法是称为词袋表示。使用词袋模型,我们可以将一组文档表示为一个矩阵,其中每一行是一个文档,列对应于所有文档中的所有唯一单词。列和行的顺序是任意的。矩阵的值表示每个单词在文档中出现的次数。图 5.4 说明了这个概念。

图 5.4 词袋矩阵
注意图 5.4 中的矩阵中大多数值都是零。这是预期的,因为实际上所有文档都只包含所有可能单词的小子集。因此,通常将矩阵编码为稀疏矩阵,这意味着我们使用一种数据结构,只存储矩阵的非零元素。在接下来的例子中,我们将使用 Scikit-Learn 的 scipy.sparse 模块将文档存储为词袋稀疏矩阵。我们将在 5.3.1 节中深入了解这些矩阵是如何在内部实现的。
尽管词袋表示丢失了单词的顺序,但您可以在许多自然语言处理(NLP)任务中,如分类和聚类中,使用简单的词袋表示获得令人惊讶的好结果。在接下来的几个例子中,我们将执行文档聚类。我们希望将文档分组到 K 个非重叠的组或簇中,使得分配到同一簇的文档彼此之间尽可能相似。关键的是,您必须事先选择簇的数量 K——在数据科学社区中并没有一个普遍认同的自动选择它的方法。
在这个例子中,文档相似度指的是文档之间共同单词的数量。例如,图 5.4 中的前两个文档有两个共同单词(are, fun),而第三个文档与它们最多只有一个共同单词。因此,将前两个文档分配到一个簇中,而将第三个文档分配到单独的簇中是有意义的。
为了进行聚类,我们将使用最著名的聚类技术,即 K-means 聚类算法,该算法在 Scikit-Learn 中实现。如果您感兴趣,可以在 mng.bz/aJlY 上了解更多关于 K-means 的信息。算法的细节在这里并不重要,除了它是一个计算上有些要求较高的算法——执行时间随矩阵大小的平方增长。这使得它成为测试可扩展性和性能的一个有趣且现实的情况。
5.1.2 练习垂直可扩展性
在上一章介绍 @batch 和 @resources 装饰器时,我们遇到了垂直可扩展性的概念。这是迄今为止最简单的一种扩展形式:垂直可扩展性指的是通过使用更大的实例来处理更多的计算和更大的数据集。自然地,您不能在笔记本电脑上依赖垂直扩展,因为您不能通过编程方式添加更多的 CPU 核心或内存。相比之下,使用基于云的计算层(如 AWS Batch)很容易实现垂直扩展,它可以提供云中可用的任何实例。
让我们从加载 Yelp 评论数据集并构建其相应的词袋表示开始。我们将在许多流程中使用相同的数据集,因此我们将开发实用函数来处理数据,以避免在流程中重复相同的代码。在 Python 中,我们通常将函数存储在单独的文件中,即模块,流程可以导入这些模块。
我们的模块包含两个实用函数:load_yelp_reviews,它从数据集中下载并提取文档列表,以及 make_matrix,它将文档列表转换为词袋矩阵。load_yelp_reviews 函数看起来与我们用来在笔记本中检查数据的代码非常相似(列表 5.1)。实际上,创建单独模块的好处之一是我们可以在 Metaflow 流程和笔记本中使用相同的模块。
推荐:将相关函数放置在逻辑组织良好的模块中是一种良好的实践。您可以在多个流程中共享这些模块,并在笔记本中使用它们。此外,您还可以独立测试模块。
创建一个名为 scale_data.py 的文件,并将下一列表中的代码存储在其中。
列表 5.2 处理 Yelp 评论数据集的函数
import tarfile
from itertools import islice
from metaflow import S3
def load_yelp_reviews(num_docs): ❶
with S3() as s3: #B
res = s3.get('s3://fast-ai-nlp/yelp_review_full_csv.tgz') ❷
with tarfile.open(res.path) as tar: ❸
datafile = tar.extractfile('yelp_review_full_csv/train.csv') ❸
return list(islice(datafile, num_docs)) ❹
def make_matrix(docs, binary=False): ❺
from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(min_df=10, max_df=0.1, binary=binary) ❻
mtx = vec.fit_transform(docs) ❻
cols = [None] * len(vec.vocabulary_) ❼
for word, idx in vec.vocabulary_.items(): ❼
cols[idx] = word ❼
return mtx, cols
❶ 从数据集中加载并提取文档列表
❷ 使用 Metaflow 内置的 S3 客户端加载公开可用的 Yelp 数据集
❸ 从 tar 包中提取数据文件
❹ 从文件中返回前 num_docs 行
❺ 将文档列表转换为词袋矩阵
❻ CountVectorizer 创建矩阵。
❼ 创建列标签列表
如我们的笔记本示例所示,该函数使用 Metaflow 内置的 S3 客户端从 S3 加载公开可用的 Yelp 数据集,我们将在第七章中更详细地介绍。load_yelp_reviews 函数接受一个参数 num_docs,表示要从数据集中读取多少个文档(每行一个评论)。我们将使用 num_docs 来控制数据集大小。
make_matrix 函数接受一个包含文档的列表 docs,并使用 Scikit-Learn 的 CountVectorizer 从文档创建一个矩阵。我们给它以下参数:
-
min_df 指定包含的单词至少需要在 10 个文档中出现。这消除了许多错误和其它无关的单词。
-
max_df 指定所有在所有文档中出现超过 10%的单词将被排除。它们通常是常见的英语单词,对于我们的用例来说,它们并不提供太多信息。
-
binary 可以用来表示文档中单词的出现即可。结果是 0 或 1,不管单词在文档中出现的次数多少。最后,我们创建一个列标签列表,以便我们知道哪个单词对应哪个列。
从像 scale_data.py 这样的模块化组件中构建你的工作流程提供了许多好处:你可以独立测试它们,在原型设计期间在笔记本中使用它们,并将它们打包和共享到多个项目中。Metaflow 将当前工作目录中的所有模块与流程一起打包,因此它们在由计算层执行的容器内自动可用。我们将在第六章讨论这个以及其他与软件库管理相关的话题。
建议:将复杂的企业逻辑,如建模代码,作为可以由工作流程调用的单独模块实现。这使得逻辑更容易在笔记本和自动化测试套件中进行测试。这也使得模块可以在多个工作流程之间共享。
接下来,让我们构建一个简单的流程,我们可以用它来测试函数。我们遵循螺旋方法:我们从一个几乎什么也不做的简单流程开始,并迭代地添加功能。在存储 scale_data.py 的同一目录中,创建一个新文件,名为 kmeans_flow_v1.py,包含以下代码。
列表 5.3 KMeansFlow 的第一迭代
from metaflow import FlowSpec, step, Parameter
class KmeansFlow(FlowSpec):
num_docs = Parameter('num-docs', help='Number of documents', default=1000)
@step
def start(self):
import scale_data ❶
scale_data.load_yelp_reviews(self.num_docs) ❶
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
KmeansFlow()
❶ 导入我们之前创建的模块,并使用它来加载数据集
开始步骤导入我们在列表 5.2 中创建的模块,并使用它来加载数据集。使用参数 num_docs 来控制数据集大小。默认情况下,只加载前 1,000 个文档。未压缩的数据集大约有 500 MB,因此执行此示例需要超过半 GB 的内存。目录结构需要如下所示,以确保 Metaflow 能够正确地打包所有模块:
my_dir/
my_dir/scale_data.py
my_dir/kmeans_flow_v1.py
你可以自由地为目录命名 my_dir。确保你的工作目录是 my_dir,并像往常一样执行流程:
# python kmeans_flow_v1.py run
尤其是在本地笔记本电脑上,这可能需要几分钟才能执行。如果一切顺利,它应该会无错误地完成。在我们开始向流程添加更多功能之前,这是一个很好的合理性检查。
使用 @conda 定义依赖项
接下来,让我们扩展 KmeansFlow 以使用 K-means 算法进行聚类。我们可以使用 Scikit-Learn 提供的现成 K-means 实现。在第三章中,我们通过在本地工作站上运行 pip install 简单地安装了 Scikit-Learn。然而,本地安装的库在计算层执行的所有容器中并不自动可用。
我们可以选择一个预装所有所需库的容器镜像,但管理多个与每个项目需求匹配的镜像可能会变得繁琐。作为替代方案,Metaflow 提供了一种更灵活的方法,即 @conda 装饰器,它不需要你手动创建或查找合适的镜像。
通常,依赖项管理是一个非平凡的话题,我们将在下一章中详细讨论。为了本节的目的,你只需要确保你已经安装了 Conda 包管理器——请参阅附录中的说明。之后,你只需要在代码中包含如列表 5.4 所示的 @conda_base 行:
@conda_base(python='3.8.3', libraries={'scikit-learn': '0.24.1'})
这行代码指示 Metaflow 在所有执行代码的计算层中安装 Python 3.8.3 和 Scikit-Learn 0.24.1 版本,包括本地运行。
注意:当你第一次使用 @conda 运行流程时,Metaflow 会解析所有所需的依赖项并将它们上传到 S3。这可能需要几分钟时间,尤其是如果你在笔记本电脑上执行代码时。请耐心等待——这仅在第一次执行时发生!
下面的代码片段扩展了列表 5.3 中的 KMeansFlow 的第一个版本。它使用我们的 scale_data 模块从 Yelp 数据集中创建一个词袋矩阵。矩阵在新的步骤 train_kmeans 中进行聚类。
列表 5.4 KMeansFlow 的最终版本
from metaflow import FlowSpec, step, Parameter, resources, conda_base, profile
@conda_base(python='3.8.3', libraries={'scikit-learn': '0.24.1'}) ❶
class KmeansFlow(FlowSpec):
num_docs = Parameter('num-docs', help='Number of documents', default=1000000)
@resources(memory=4000) ❷
@step
def start(self):
import scale_data ❸
docs = scale_data.load_yelp_reviews(self.num_docs) ❸
self.mtx, self.cols = scale_data.make_matrix(docs) ❸
print("matrix size: %dx%d" % self.mtx.shape)
self.next(self.train_kmeans)
@resources(cpu=16, memory=4000) ❹
@step
def train_kmeans(self):
from sklearn.cluster import KMeans
with profile('k-means'): ❺
kmeans = KMeans(n_clusters=10,
verbose=1,
n_init=1) ❻
kmeans.fit(self.mtx) ❻
self.clusters = kmeans.labels_ ❻
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
KmeansFlow()
❶ 在任务中声明我们将需要的库
❷ 预处理数据需要 4 GB 的内存
❸ 导入我们之前创建的模块并使用它来加载数据集
❹ 运行 K-means 需要 4 GB 的内存和 16 个 CPU 核心
❺ 使用配置文件来测量并打印运行 K-means 所需的时间
❻ 运行 K-Means 并将结果存储在聚类中
将代码保存到 kmeans_flow_v2.py。你可以看到我们使用了 @resources 装饰器来声明 start 和 train_kmeans 步骤需要 4 GB 的内存。额外的 GB 是为了保持词袋矩阵在内存中,并考虑到所有相关的开销。train_kmeans 步骤使用 Metaflow 提供的一个小型实用函数 profile,它测量并打印执行代码块所需的时间。它的单个参数是包含在输出中的前缀,这样你知道测量与哪个代码块相关。
小贴士:在 Metaflow 中使用 profile 上下文管理器可以快速了解步骤内某些操作的耗时。
使用 KMeans 对象来运行算法。它接受簇的数量(K-means 中的 K),这里称为 n_clusters,作为参数,我们将其设置为某个相当随意的数字,10。在下一节中,我们将探索 K 的其他值。下一个参数 verbose 提供了关于算法进度的某些信息,而 n_init=1 意味着我们只需要运行算法一次。更高的数字可能会导致更好的结果。算法的结果是一个数组,存储在 self.clusters 中,它将每个文档分配到一个簇中。我们将在下一节中更深入地查看结果。
按以下方式使用 AWS Batch 运行流程:
# python kmeans_flow_v2.py --environment=conda run --with batch
使用--environment=conda标志是必须的,以表明我们希望使用 Conda 来处理依赖关系。如果您想在本地运行代码或您的计算环境没有可用 4 GB 内存和 16 个 CPU 核心的实例,您可以通过指定num-docs参数的较小值来处理更少的文档,例如运行--num-docs 10000。希望这次运行能够成功完成——这是我们目前要完成的所有事情。我们将在下一节中深入探讨结果。
关于这个流程有趣的地方是:数据集包含 650,000 个文档,相应的矩阵包含大约 48,000 个独特的单词。因此,矩阵的大小是 650,000 * 48,000,如果以密集矩阵的形式存储,将需要超过 100 GB 的内存。因为它以稀疏矩阵的形式存储,所以它将小于 4 GB。对于这样大小的矩阵运行 K-means 是计算密集型的。幸运的是,Scikit-Learn 中的实现可以自动在请求的多个 CPU 核心上并行化算法,即使用@resources(cpu=16)。因此,您只需增加 CPU 核心的数量,就可以使 K-means 更快地完成,如图 5.5 所示。

图 5.5 执行时间与使用的 CPU 核心数的关系
对于这个数据集,您可以通过使用多个 CPU 核心获得高达 40%的性能提升。作为一个练习,您可以尝试另一种名为 MiniBatchKMeans 的更快版本的 K-means。@resources 的最佳选择取决于数据集大小、算法及其实现。在这种情况下,超过四个核心似乎没有带来太多好处。超过这个数字,额外核心执行的工作量不足以证明通信和协调开销的合理性。一般来说,这种行为是大多数现代机器学习算法实现中典型的:您在更多的 CPU 核心或 GPU 上运行它们,它们的运行速度就会更快,直到达到一个极限。
注意:与内存限制不同,CPU 限制是 AWS Batch 上的一个软限制。如果没有其他任务同时在该实例上执行,任务可以使用实例上所有可用的 CPU 核心。当多个任务需要共享实例时,此限制适用。在大多数情况下,这种行为是可取的,但它使得基准测试变得复杂,因为具有例如四个 CPU 的任务可能会偶然使用更多的 CPU 核心。
垂直扩展最吸引人的特点是它的无难度——数据科学家只需修改一行代码,即@resources,就可以更快地处理更大的数据集,如前所述。尽管这种方法不提供无限的扩展性,但通常,它对于手头的任务来说已经足够了,正如下一小节所讨论的。
5.1.3 为什么需要垂直扩展?
从工程角度来看,对垂直扩展有一个本能的反应:它不是“真正的”扩展性——它在最大可用实例大小上达到一个硬上限。截至本书编写时,AWS 上最常用的最大 EC2 实例提供 48 个 CPU 核心和 768 GB 的内存。显然,如果你需要更多的核心或更多的内存,垂直扩展无法帮助你。
而不是依赖可能具有误导性的直觉,仔细评估垂直扩展是否足够以及可以持续多长时间是一个好主意。得益于垂直扩展的简单性,你可以快速构建应用程序的第一个版本,并在使实现复杂化之前验证其正确性和价值。令人惊讶的是,垂直扩展通常已经足够好。
简单性提升性能和生产力
在简单性方面,很难超越垂直扩展。你可以编写最地道、可读性和直接的 Python 代码,只需调整@resources 中的数字以匹配代码的要求。它没有新的范式需要学习。如果事情失败了,没有隐藏的抽象层会导致惊喜或复杂的错误信息。这对于数据科学家的生产力和自主性非常重要——他们可以专注于建模,而无需过多担心可扩展性或性能。
当涉及到在 Python 中实现机器学习和数据科学工作负载时,用户界面 API 的简单性具有欺骗性。在底层,最好的库使用 C++等低级语言进行了高度优化,以便能够非常有效地利用单个实例上的可用资源。
以建模框架为例。在 2010 年代初,人们投入了大量精力在 Hadoop 和 Spark 等可扩展框架之上提供传统机器学习算法的可扩展实现。这些框架是具有有限单机性能的固有可扩展系统的良好例子。
在 2010 年代后半期,开始变得明显的是,单机、垂直扩展的训练算法,如 XGBoost 或 Tensorflow 提供的算法,可以轻易地超越它们名义上更可扩展的对应者,尤其是在 GPU 加速的情况下。这些实现不仅速度更快,而且由于缺乏可扩展、分布式系统的固有复杂性,它们在操作和开发上也容易得多。
这突出了可扩展性和性能之间的差异:由多个低性能但并行单元组成的系统可以很好地扩展,但这样的分布式系统往往会产生固有的通信和协调开销,这可能会使其对于中小型工作负载比其非分布式对应者更慢。值得注意的是,有时不可扩展的方法甚至可以优于其可扩展的对应者,即使是在意外的大工作量下。
可扩展性技巧 不要高估名义上可扩展系统的性能,也不要低估单实例解决方案的性能。如有疑问,请进行基准测试。即使分布式解决方案可能具有更好的可扩展性,也要仔细考虑其运营成本和对数据科学家生产力的成本。关于这一效果的具体例子,请参阅 2015 年由 Frank McSherry 等人发表的论文《可扩展性!但代价是什么?》(mng.bz/OogO)。
考虑你问题的本质
当思考你问题的本质时,考虑以下两个经常被低估的因素。首先,现实生活中的可扩展性总是有一个上限。在物理世界中,没有什么真正是无限的。在本章中提供的例子中,我们使用了一个包含 650,000 条 Yelp 评论的静态数据集。对于这样的静态数据集,很容易看出可扩展性不太可能成为一个问题。
即使数据集不是静态的,其大小可能自然有限。例如,考虑一个为每个美国 ZIP 代码提供统计数据的数据集。美国大约有 40,000 个 ZIP 代码,这个数字几乎可以保证保持稳定。如果你能轻松处理大约 40,000 个 ZIP 代码,那么你就不应该有任何可扩展性的担忧。
第二,一台现代计算机可以拥有惊人的资源量。对于人类来说,任何数量级达到数十亿的都可能感觉很多,但请记住,一台现代计算机每秒可以进行大约 20 亿次的算术运算,并且可以同时存储大约 270 亿个英语单词。计算机的运行速度比我们有限的认知要高得多。
使用广泛用于训练深度神经网络和其他现代模型架构的专用硬件(如 GPU)时,这种影响更为明显。截至本书编写时,AWS 提供了具有八个高性能 GPU 的实例。你可以使用这样的机器来训练大型模型。由于机器内部 GPU 和 CPU 核心之间的通信开销最小,单个实例可以超越应该具有更多计算能力的实例集群,从原始数据来看。
最后,计算机的增长并未停止。尽管在过去 10 年中单核性能增长不大,但单个实例中的核心数量、内存容量以及本地存储速度都在增长。以一个例子来说明,考虑将每个 Netflix 订阅者的数据存储在单个实例的内存数据帧中。这可能会感觉像是一个糟糕的想法,因为 Netflix 订阅者的数量每年都在迅速增长!
与依赖直觉反应相比,检查数学计算是一个好主意。2011 年,Netflix 有 2600 万订阅者,最大的 AWS 实例提供了 60 GB 的内存,这意味着每个订阅者可以存储大约 2.3 KB 的数据。到 2021 年,Netflix 有 2.07 亿订阅者,最大的 AWS 实例提供了 768 GB 的内存,因此,出人意料的是,今天每个订阅者的数据容量更大——3.7 KB。换句话说,如果你的用例适合这些限制,你只需通过更新@资源中的数字,就可以简单地处理可扩展性超过 10 年,并且还在继续!
可扩展性技巧 在担心可扩展性之前,先估计你的增长上限。如果与可用计算资源相比,上限足够低,你可能根本不需要担心可扩展性。
5.2 实践水平可扩展性
如前所述,始终建议在开始项目时,不仅要思考问题本身,还要考虑涉及的数据和计算规模以及增长率。如果没有其他迹象表明应该这样做,垂直可扩展性是一个好的起点。什么迹象会表明应该这样做?换句话说,在什么情况下,你应该考虑使用多个并行实例而不是单个实例?
5.2.1 为什么需要水平可扩展性?
作为一项经验法则,如果你对以下三个问题中的任何一个回答“是”,你应该考虑水平可扩展性,即使用多个实例:
-
你的工作流程中是否有大量的计算部分是令人尴尬地并行的,这意味着它们可以在不共享任何数据(除了输入)的情况下执行操作?
-
数据集的大小是否太大,以至于无法方便地在最大的实例类型上处理?
-
是否存在计算密集型的算法,如模型训练,它们对单个实例来说要求过高?
这些项目按频率降序排列:前两种情况比最后一种情况典型得多。让我们逐一分析这些情况。
令人尴尬的并行任务
在分布式计算中,如果一个问题是“几乎不需要努力就可以将问题分解成多个并行任务”,那么这个问题被称为令人尴尬的并行。在 Metaflow 风格的工作流程的上下文中,这个定义与不需要相互共享任何数据(除了共享公共输入数据)的动态 foreach 任务相一致。
这种情况在数据科学应用中很常见:训练多个独立的模型,获取多个数据集,将多个算法与数据集进行基准测试,仅举几例。你可能能够通过垂直扩展来处理所有这些情况,可能利用单个实例上的多个核心,但通常这样做的好处很小。
记住,垂直扩展的关键动机是简单性和性能。典型地,一个令人尴尬的并行任务的实现看起来在单实例或多实例上执行时几乎相同——它只是一个函数或 Metaflow 步骤。因为任务是完全独立的,所以在单独的实例上执行它们不会产生性能惩罚。因此,在这种情况下,你可以通过在 Metaflow 中使用 foreach 来解锁巨大的可扩展性,而不会牺牲简单性或性能。我们将在下一节练习一个常见的令人尴尬的并行案例,即超参数搜索,这涉及到构建许多独立的模型。
大数据集
就本书的撰写而言,目前可用的最大常用 EC2 实例具有 768 GB 的内存。这意味着仅仅依靠垂直扩展,你可以在具有相当大内存开销的 pandas DataFrame 中加载大约 100 GB 的数据。如果你需要处理更多的数据,这在当今并不罕见,仅仅依靠垂直扩展是不可行的。
在许多情况下,处理大数据集的最简单方法之一是使用专门针对此类用例优化的计算层,例如 Apache Spark。另一种方法是将数据分成更小的单个块或碎片,以便每个碎片可以在大型实例上处理。我们将在第七章讨论这些方法。
分布式算法
除了输入数据外,可能要执行的计算算法,例如模型训练,对单个实例来说要求过高。例如,这可能适用于大规模计算机视觉模型。尽管拥有比单个实例更大的数据集很常见,但拥有这种规模的模型则很少见。一个配备八个 GPU 和超过一 TB 内存的单个 P4 型 AWS 实例可以容纳一个巨大的模型。
运行分布式模型训练和其他分布式算法可能并不简单。网络必须优化以实现低延迟通信,实例必须相应地放置以最小化延迟,并且必须仔细协调和同步紧密耦合的实例池,同时考虑到实例可能随时会失败。
幸运的是,许多现代机器学习库,如 PyTorch Lightning,提供了抽象,使得分布式训练变得容易一些。此外,专门的计算层,如 Amazon Sagemaker 和 Google 的 Cloud TPUs,可以为你管理复杂性。
许多现实世界的数据科学应用采用了这些方法中的许多:使用 Spark 预处理数据,并并行加载为分片,以生成一个在单个垂直扩展实例上训练的矩阵或张量。将应用程序组织为工作流的好处在于,你可以选择最适合工作流每一步的扩展方式,而不是试图将所有任务都适应到一个单一的模式中。下一节将演示一个简单但常见的尴尬并行案例:并行构建多个 K-means 模型以找到性能最佳的模型。
5.2.2 示例:超参数搜索
在本节中,我们通过展示如何使用水平扩展来解决机器学习项目中一个常见任务:超参数搜索和优化,来扩展我们之前提到的 K-means 示例。在前一节中,我们简单地固定了簇的数量,即 K-means 中的 K 参数,为 10 个簇,而没有给出任何理由。簇的数量是 K-means 算法的一个 超参数——一个在算法运行之前我们需要定义的参数。
通常,对于超参数值并没有一个明确正确的选择。因为我们无法事先定义正确的值,所以通常希望运行算法使用多个不同的超参数值,并选择表现最好的一个。复杂的超参数优化器可以动态生成新值,并在结果不再改善时停止。这里展示的简单方法只是预先定义一个要尝试的超参数列表,并在最后评估结果。这种简单方法的优点是算法可以独立地对每个参数化进行评估——这是一个非常适合水平扩展的理想用例。
如何定义聚类算法的“最佳结果”本身就是一个非平凡的问题。你可以以许多同样好的方式对文档进行分组。在这个例子中,选择权在你。你可以通过列出簇中最频繁出现的单词来表征每个簇。正如我们将展示的,你可以查看簇并决定哪些超参数值会产生最有趣的结果。列表 5.5 展示了一个函数,top_words,它计算每个簇中最频繁的单词。将其保存到一个单独的模块中,analyze_kmeans.py。
列表 5.5 计算每个簇的顶级单词
from itertools import islice
def top_words(num_clusters, clusters, mtx, columns):
import numpy as np
top = []
for i in range(num_clusters): ❶
rows_in_cluster = np.where(clusters == i)[0] ❷
word_freqs = mtx[rows_in_cluster].sum(axis=0).A[0] ❸
ordered_freqs = np.argsort(word_freqs) ❹
top_words = [(columns[idx], int(word_freqs[idx])) ❹
for idx in islice(reversed(ordered_freqs), 20)] ❹
top.append(top_words)
return top
❶ 处理 K 个簇中的每一个
❷ 从词袋矩阵中选择与当前簇对应的行
❸ 计算列的总和,即每个单词在簇中的频率
❹ 按频率对列进行排序,并生成一个包含顶级 20 个单词的列表
此函数使用了来自 NumPy 包的一些技巧:它使用 where 从矩阵中选择行的一个子集,使用 sum(axis=0)生成列的总和,并使用 argsort 生成排序后的列索引。如果您好奇,可以查看 NumPy 文档以了解更多关于这些函数的信息。了解 NumPy 对于此示例不是必需的。
我们将使用 Metaflow 的 foreach 构造,如第 3.2.3 节中引入的,以并行运行多个 K-means 算法,利用 AWS Batch 作为自动扩展的计算层。相应的流程在图 5.6 中可视化。

图 5.6 ManyKmeansFlow 对应的 DAG
这展示了横向扩展的实际应用。该流程并行训练了多个 K-means 聚类,针对参数 K(聚类数量)的多个值,并生成结果摘要以供分析。以下列表显示了流程的代码,ManyKmeansFlow,它基于我们之前提到的 KMeansFlow。
列表 5.6 搜索 K-means 的超参数
from metaflow import FlowSpec, step, Parameter, resources, conda_base, profile
@conda_base(python='3.8.3', libraries={'scikit-learn': '0.24.1'})
class ManyKmeansFlow(FlowSpec):
num_docs = Parameter('num-docs', help='Number of documents', default=1000000)
@resources(memory=4000)
@step
def start(self):
import scale_data
docs = scale_data.load_yelp_reviews(self.num_docs) ❶
self.mtx, self.cols = scale_data.make_matrix(docs) ❶
self.k_params = list(range(5, 55, 5)) ❷
self.next(self.train_kmeans, foreach='k_params')
@resources(cpu=4, memory=4000)
@step
def train_kmeans(self): ❸
from sklearn.cluster import KMeans
self.k = self.input
with profile('k-means'):
kmeans = KMeans(n_clusters=self.k, verbose=1, n_init=1)
kmeans.fit(self.mtx)
self.clusters = kmeans.labels_
self.next(self.analyze)
@step
def analyze(self):
from analyze_kmeans import top_words ❹
self.top = top_words(self.k, self.clusters, self.mtx, self.cols)
self.next(self.join)
@step
def join(self, inputs):
self.top = {inp.k: inp.top for inp in inputs} ❺
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
ManyKmeansFlow()
❶ 使用 scale_data 模块生成词袋矩阵
❷ 为 foreach 定义一组超参数
❸ 在 foreach 中训练 K-means 聚类
❹ 为每个聚类生成顶级词汇列表
❺ 按超参数值分组所有顶级列表
此流程的亮点是 k_params 中定义的超参数列表。我们生成了 10 个独立的聚类,即 10 个独立的 train_kmeans 任务,聚类数量在 5 到 50 之间变化。根据您的计算环境配置,所有 10 个任务可能并行运行。系统儿乎完美地扩展:如果生成一个聚类需要大约两分钟,那么您也可以在两分钟内并行生成 10 个聚类!您可以使用以下代码运行此流程:
# python many_kmeans_flow.py --environment=conda run --with batch --with retry
默认情况下,Metaflow 最多并行运行 16 个任务,因此它将最多提交 16 个(在这种情况下,10 个)任务到 AWS Batch。根据您的计算环境,AWS Batch 可能会决定在单个实例上并行运行多个容器,或者它可能会决定启动更多实例来处理队列中的任务。您可以使用--max-workers 选项控制并行级别,如第 3.2.4 节所述。
此示例说明了基于云的计算层(如 AWS Batch)的好处。您不仅可以启动更大的实例,正如我们在上一节中提到的,还可以并行启动任意数量的实例。如果没有可扩展的计算层,您将不得不按顺序执行 K-means 算法,这将花费大约 20 分钟,而由于横向扩展,您可以在两分钟内获得结果。根据上一章的指示,我们使用--with retry 来处理任何可能偶尔发生的暂时性故障,当并行运行数百个 Batch 作业时,这些故障是不可避免的。
目前,Metaflow 可以无问题地处理数千个并发任务。考虑到每个任务可能需要大量的@资源,你可以在单个工作流中部署数万个 CPU 核心和近一个 PB 的 RAM!
检查结果
当运行完成时,你将拥有 10 种不同的聚类结果可供检查。你可以使用 Metaflow 客户端 API 在交互式 Python shell 或使用笔记本来检查结果,如第 3.3.2 节所述。以下是轻松完成此操作的方法。首先,按照以下方式访问最新运行的输出:
>>> from metaflow import Flow
>>> run = Flow('ManyKmeansFlow').latest_run
由于 Metaflow 会持久化所有运行的输出,因此你还可以检查和比较运行之间的结果。
在连接步骤中,我们生成了一个名为“top”的工件,它包含每个聚类的方便聚合的顶级单词集合,以超参数值为键,即聚类的数量。在这里,我们看一下 40 个聚类的 K-means 算法的结果如下:
>>> k40 = run.data.top[40]
有 40 个聚类可供检查。让我们抽查几个,查看聚类中的前 5 个单词及其频率,如下所示:
>>> k40[3][:5]
[('pizza', 696), ('cheese', 134), ('crust', 102), ('sauce', 91), ('slice', 52)]
这看起来是关于披萨评论的。下一个看起来是关于酒店的:
>>> k40[4][:5]
[('her', 227), ('room', 164), ('hotel', 56), ('told', 45), ('manager', 41)
这一个看起来是关于汽车租赁的:
>>> k40[30][:5]
[('car', 20), ('hertz', 19), ('gold', 14), ('says', 8), ('explain', 7)]
默认情况下,K-means 不能保证结果总是相同的。例如,随机初始化会导致每次运行的结果略有不同,因此不要期望看到与这里显示的完全相同的结果。作为一个练习,你可以想出一个创造性的方法在笔记本中表格化和可视化聚类。
5.3 实践性能优化
到目前为止,我们已经学习了可扩展性的两个主要维度:垂直可扩展性(使用更大的实例)和水平可扩展性(使用更多实例)。值得注意的是,这两种技术都允许我们在代码变化最小的情况下处理更大的数据集和更复杂的需求。从生产力的角度来看,这种可扩展性很有吸引力:云服务允许我们通过投入更多的硬件来解决问题,从而节省人力资源去完成更有趣的任务。
在什么情况下我们应该考虑花费数据科学家的时间手动优化代码?考虑一下你有一个计算成本高昂的算法,比如模型训练,实现为一个 Python 函数。执行该函数需要五小时。根据算法的实现方式,如果算法不能有效地利用多个 CPU 核心、更多内存或 GPU,垂直可扩展性可能帮助不大。水平可扩展性也无法帮助。你可以在 100 个并行任务中执行该函数,但每个任务仍然需要五小时,因此总执行时间仍然是五小时。
一个关键问题是执行时间是否为五小时是否重要。也许工作流程是每晚运行,所以运行五小时或两小时并没有太大区别——无论如何,结果都会在早上准备好。然而,如果函数需要每小时运行一次,我们就遇到了问题。用需要五小时执行时间的函数来产生每小时的结果是不可能的。
在这种情况下,数据科学家需要花时间重新思考算法,使其更高效。下一个部分的关键教训是,即使是性能优化也是一个连续的过程。数据科学家不必丢弃 Python 实现并重新编写算法,比如用 C++。他们可以使用多种工具和技术,逐步优化算法,直到它足够好。
下一个部分将逐步通过一个现实生活中的例子,展示一个最初用简单的 Python 实现的数值密集型算法是如何逐渐优化到其性能可以与复杂的 C++多核实现相媲美的程度。正如你将看到的,一个简单且易于实现的优化可以带来 80%的效益。挤出最后 20%的性能需要 80%的时间。
性能提示:过早优化是万恶之源。在你用尽所有其他更简单的选项之前,不要担心性能问题。如果你必须优化性能,要知道何时停止。
5.3.1 示例:计算共现矩阵
前面的部分使用了一个词袋矩阵来记录文档和单词之间的关系。从这个矩阵中,我们可以推导出另一个有趣的矩阵:单词-单词共现矩阵。共现矩阵记录了一个单词在同一个文档中与任何其他单词一起出现的频率。这对于理解单词之间的语义相似性可能很有用,并且有了这个矩阵,可以快速计算各种单词级指标。图 5.7 扩展了我们早期的词袋示例,以显示相应的共现矩阵。

图 5.7 从原始文档到单词-单词共现矩阵
如果词袋矩阵的维度是 NM,其中 N 是文档的数量,M 是唯一单词的数量,那么相应的共现矩阵的维度是 MM。关键的是,词袋矩阵包含了我们构建相应共现矩阵所需的所有信息。构建共现矩阵的一个简单方法是通过遍历词袋矩阵的所有行,并对每一行,生成所有单词对并增加它们在共现矩阵中的计数。
我们可以利用词袋矩阵是一个稀疏矩阵的事实,即它不存储任何零条目,这无论如何都不会影响共现矩阵。为了能够设计一个高效的算法来处理数据,让我们看看 scipy.sparse.csr_matrix 的内部,它实现了稀疏矩阵。SciPy 中的压缩稀疏行(CSR)矩阵由三个密集数组组成,如图 5.8 所示:
-
indptr 表示每个行在 indices 数组中的开始和结束位置。这个数组是必需的,因为每个文档可以包含不同数量的唯一词。indptr 数组在末尾包含一个额外的元素,以指示最后一个文档的长度。
-
indices 表示哪些列(词)在此行上有非零值。
-
data 包含每个文档中每个词的频率。它与 indices 数组对齐。

图 5.8 scipy.sparse.csr_matrix 的内部数据结构
构建共现矩阵时,我们并不关心一个词在文档中出现的次数。因此,我们的词袋矩阵可以是一个二元矩阵,相应地,数据数组是冗余的,因为它全部是 1。
配备了这个问题定义以及关于稀疏矩阵背后的数据结构的知识,我们可以实现一个算法的第一个变体,该算法用于计算 Yelp 数据集的共现矩阵。
变体 1:纯 Python 实现
想象一下,如果数据科学家需要计算共现矩阵作为另一个算法的预处理步骤,可能会采取的最简单的方法是直接在文本数据上计算共现,逐个文档地迭代字符串,但这将是一个非常慢的方法。假设数据科学家已经使用我们的 scale_data 模块生成了一个词袋矩阵。数据科学家编写了一个算法,遍历稀疏矩阵以构建共现矩阵。他们的解决方案可能看起来像下面所示。
列表 5.7 第一个变体:纯 Python 中的共现
def simple_cooc(indptr, indices, cooc):
for row_idx in range(len(indptr) - 1): ❶
row = indices[indptr[row_idx]:indptr[row_idx+1]] ❶
row_len = len(row)
for i in range(row_len): ❷
x = row[i]
cooc[x][x] += 1 ❸
for j in range(i + 1, row_len):
y = row[j]
cooc[x][y] += 1 ❹
cooc[y][x] += 1 ❹
def new_cooc(mtx): ❺
import numpy as np
num_words = mtx.shape[1]
return np.zeros((num_words, num_words), dtype=np.int32)
def compute_cooc(mtx, num_cpu): ❻
cooc = new_cooc(mtx)
simple_cooc(mtx.indptr, mtx.indices, cooc)
return cooc
❶ 遍历词袋矩阵的所有行
❷ 遍历一行中的所有非零列(词)
❸ 增加矩阵的对角线,这表示词的频率
❹ 增加词-词对计数——矩阵是对称的
❺ 创建新共现矩阵的实用函数
❻ 创建并填充新共现矩阵的接口函数
将列表 5.7 中的代码保存在一个名为 cooc_plain.py 的单独模块中。这次,确切的文件名很重要,因为我们很快就会看到。实现很简单:我们遍历所有行中的所有单词对,并在执行过程中递增目标矩阵中的单词-单词计数。除了核心算法 simple_cooc 之外,我们还包含一个辅助函数来分配正确形状的新共现矩阵,new_cooc,以及一个创建并填充矩阵的函数 compute_cooc,我们将将其用作模块的入口点。这些函数很快就会派上用场。
让我们创建一个流程来测试算法。接下来的列表显示了一个支持可插拔算法的流程。我们也可以用它来测试 cooc_plain.py 之外的其它变体。将流程保存到 cooc_flow.py。
列表 5.8:生成共现矩阵的流程
from metaflow import FlowSpec, conda_base, step, profile, resources, Parameter
from importlib import import_module
@conda_base(python='3.8.3',
libraries={'scikit-learn': '0.24.1',
'numba': '0.53.1'}) ❶
class CoocFlow(FlowSpec):
algo = Parameter('algo', help='Co-oc Algorithm', default='plain') ❷
num_cpu = Parameter('num-cpu', help='Number of CPU cores', default=32)
num_docs = Parameter('num-docs', help='Number of documents', default=1000)
@resources(memory=4000)
@step
def start(self): ❸
import scale_data
docs = scale_data.load_yelp_reviews(self.num_docs)
self.mtx, self.cols = scale_data.make_matrix(docs, binary=True) ❹
print("matrix size: %dx%d" % self.mtx.shape)
self.next(self.compute_cooc)
@resources(cpu=32, memory=64000) ❺
@step
def compute_cooc(self):
module = import_module('cooc_%s' % self.algo) ❻
with profile('Computing co-occurrences with the %s
➥ algorithm' % self.algo):
self.cooc = module.compute_cooc(self.mtx, self.num_cpu) ❼
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
CoocFlow()
❶ 我们稍后会需要 Numba,所以我们将将其作为依赖项包含。
❷ 指定要使用的算法变体
❸ 开始步骤使用 scale_data,类似于我们之前的示例。
❹ 注意 binary=True,这表示我们只需要一个二进制矩阵。
❺ 生成结果可能需要相当多的内存。
❻ 加载由 algo 参数定义的可插拔变体
❼ 计算共现矩阵
在这里,开始步骤与我们的前例相似。唯一的区别是我们指定 binary=True,因为对于共现,我们不在乎文档中单词的频率。compute_cooc 支持可插拔算法。我们不是硬编码导入语句,而是根据参数 algo 选择要导入的变体。我们使用 Python 内置的 importlib.import_module 从以 cooc_ 为前缀的文件中导入模块。
小贴士:使用 importlib.import_module 动态加载模块是实现流程插件的好方法。通常,DAG 的整体结构在插件之间不会改变,因此你可以保持 DAG 静态,但可以动态选择所需的功能。
让我们先通过一小部分数据,1,000 个文档,在本地测试流程,如下所示:
# python cooc_flow.py --environment=conda run --num-docs 1000
这将使用我们之前定义的 cooc_plain 模块。看起来使用 1,000 行(文档)和 1,148 列(单词)执行算法大约需要五秒钟,乍一看,似乎并不太糟糕。
尝试将 --num-docs 增加到 10,000。现在,算法需要 74 秒!如果你敢尝试,可以用完整的数据集执行它。这需要花费很长时间才能完成。你可以尝试使用 --with batch varying @resources 执行它,但时间不会有太大变化——这个算法既不高效也不可扩展。如果任务是生成包含 650,000 个文档的完整数据集的共现矩阵,显然这个算法是不行的。
变体 2:利用高性能库
当一个基本的 Python 实现变得太慢时,尝试找到一个包含相同算法优化实现的现成库是明智的。鉴于 Python 数据科学生态系统的庞大,您通常会了解到有人已经实现了合适的解决方案。或者,即使没有完全匹配的解决方案,也可能有人实现了我们用来优化解决方案的高效构建块。
不幸的是,在我们的例子中,Scikit-Learn 似乎没有提供计算共现矩阵的函数。为了我们的示例,我们忽略了一个合适的实现可能存在于其他地方的事实。
数据科学家转向一位在高性能数值计算方面有经验的同学。这位同学指出,cooc_plain 实现的算法实际上是一个矩阵乘法算法。结果发现,基于二进制词袋矩阵 B 计算共现矩阵 C 正好对应以下矩阵方程:
C = B^TB
其中,B^T 表示矩阵 B 的转置(旋转)。如果您将一个 MN 矩阵与一个 NM 矩阵相乘,结果是 M*M 矩阵,在这种情况下,正好是我们的共现矩阵。实现这个算法的变体非常简单,如下面的代码列表所示。
列表 5.9 第二个变体:利用线性代数
def compute_cooc(mtx, num_cpu):
return (mtx.T * mtx).todense()
将列表 5.9 中的代码保存在 cooc_linalg.py 中,这是一个基于线性代数的变体。多亏了 CoocFlow 的可插拔算法支持,我们可以通过运行以下代码简单地测试这个变体:
# python cooc_flow.py --environment=conda run --num-docs 10000 --algo linalg
这个变体在 1.5 秒内完成,而不是 74 秒——速度提升了 50 倍!代码比原始的 Python 版本更简单、更快,这非常完美。
由于代码表现如此出色,您可以尝试使用完整的数据集,通过指定--num-docs 1000000 来运行。然而,由于内存不足异常,运行可能会失败。您可以尝试垂直扩展并使用--with batch 来运行,但即使有 64 GB 的内存,运行仍然会失败。
这个变体的问题在于,取原始矩阵 mtx.T 的转置会复制整个数据集,除了需要在内存中存储共现矩阵外,还会使内存需求翻倍。尽管 cooc_plain 变体表现不佳,但至少它更节省空间,避免了不必要的复制。
在这种情况下,您可以不断增加内存需求,直到算法成功完成。鉴于这个算法的优雅简洁,依赖垂直扩展可能是一个吸引人的解决方案。然而,为了讨论的目的,让我们假设数据科学家不能依赖最高内存实例,因此他们必须继续寻找更优的变体。
变体 3:使用 Numba 编译 Python
我们数据科学家的同事指出,变体 1 的主要问题是它是用 Python 编写的。如果它用 C++ 编写,它可能会表现得更好,而不会像变体 2 那样浪费空间。这是一个从技术角度来看合理的论点,但将一段自定义的 C++ 代码包含在 Python 项目中感觉像是一种麻烦,更不用说我们的数据科学家对 C++ 不熟悉了。
幸运的是,一些库可以帮助通过将其编译成机器代码来加快数值 Python 代码的速度,类似于 C++ 编译器所做的那样。最著名的 Python 编译器是 Cython (cython.org) 和 Numba (numba.pydata.org)。
这些编译器不能神奇地将任何 Python 代码块的速度提升到 C++ 的速度,但它们在优化执行数值计算的函数方面表现出色,通常使用 NumPy 数组。换句话说,像 simple_cooc 这样的函数,它对几个 NumPy 数组执行循环,应该完全属于这些编译器的领域。
下面的代码片段展示了如何使用 Numba 在线编译 simple_cooc 函数。将这个变体保存到 cooc_numba.py。
列表 5.10 第三变体:使用 Numba 的共现
def compute_cooc(mtx, num_cpu):
from cooc_plain import simple_cooc, new_cooc
cooc = new_cooc(mtx)
from numba import jit ❶
fast_cooc = jit(nopython=True)(simple_cooc) ❶
fast_cooc(mtx.indptr, mtx.indices, cooc)
return cooc
❶ 使用 Numba 在线编译 simple_cooc 函数
使用 Numba 的难点在于编写一个避免使用任何 Python 习惯用法(如对象和字典)并避免分配内存的函数。你必须专注于数组上的简单算术,就像 simple_cooc 所做的那样。一旦你做到了这一点,使用 Numba 就变得简单了。就像列表 5.10 所示,你所要做的就是调用 jit 函数并将要调用的函数作为参数传递。
结果是给定函数(这里指 fast_cooc)的一个版本,通常比原始版本快得多。这种神奇的效果得益于 Numba 将函数编译成机器代码,这几乎与用 C++ 编写的版本无法区分。nopython=True 标志表示该函数不使用任何 Python 构造,因此可以避免与 Python 的慢速兼容层。
按以下方式测试这个变体:
# python cooc_flow.py --environment=conda run --num-docs 10000 --algo numba
该算法版本耗时 2.7 秒,因此比 cooc_linalg 慢一些,后者运行时间为 1.5 秒。这种差异是可以理解的,因为 Numba 的时间包括编译时间。值得注意的是,这个版本不占用任何额外空间。这个变体能够在 50 秒内处理完整数据集——这并不算差劲!
变体 4:在多个 CPU 核心上并行化算法
尽管变体 3 能够相当快地处理完整数据集,但它是一个本质上不可扩展的算法:增加更多内存或 CPU 资源并不会使其更快或能够处理更大的矩阵。假设如果算法能够利用我们能够轻松请求的多个 CPU 核心的话,可能会得到更快的结果。
注意,这种优化是有代价的:实现比变体 2 和 3 更复杂。需要非常小心地确认它是否正确工作。正如性能优化经常发生的那样,最初的 20% 的努力可以带来 80% 的好处。在大多数用例中,花费时间来挤压最后一点性能是不值得的。通过这个警告的视角考虑这个变体。
观察简单 _cooc 中的算法,我们可以看到外层循环遍历输入矩阵的行。我们能否将输入矩阵分割,使得每个 CPU 核心只处理行的一个子集,即一个碎片?一个挑战是某一行可能会更新结果矩阵的任何位置,这需要所有 CPU 核心共享。在多个工作进程或线程之间共享可写数据是一个难题,我们宁愿避免。
一个简单的洞察力挽救了局面:我们可以让每个线程写入一个私有的共现矩阵副本,我们可以在最后简单地将它们相加。一个缺点是内存消耗再次增加,但与变体 2 相比,我们需要的共现矩阵副本比完整数据集要小。将此变体保存在下一个列表中的 cooc_multicore.py。
列表 5.11 第四种变体:使用 Numba 和多个 CPU 核心
from concurrent import futures
import math
def compute_cooc_multicore(row_indices, columns, cooc, num_cpu, fast_cooc):
num_rows = len(row_indices) - 1 ❶
batch_size = math.ceil(num_rows / num_cpu) ❶
batches = [(cooc.copy(), ❶
row_indices[i * batch_size:(i+1) * batch_size + 1]) ❶
for i in range(num_cpu)]
with futures.ThreadPoolExecutor(max_workers=num_cpu) as exe: ❷
threads = [exe.submit(fast_cooc, row_batch, columns, tmp_cooc)
for tmp_cooc, row_batch in batches]
futures.wait(threads) ❸
for tmp_cooc, row_batch in batches: ❹
cooc += tmp_cooc
def compute_cooc(mtx, num_cpu):
from numba import jit
from cooc_plain import simple_cooc, new_cooc
cooc = new_cooc(mtx)
fast_cooc = jit(nopython=True, nogil=True)(simple_cooc) ❺
fast_cooc(mtx.indptr, mtx.indices, cooc)
compute_cooc_multicore(mtx.indptr, mtx.indices, cooc, num_cpu, fast_cooc)
return cooc
❶ 将输入矩阵分割成与 num_cpu 数量相等的等大小碎片或批次,每个碎片或批次都有一个私有的输出矩阵
❷ 使用线程池以并行方式在多个 CPU 核心上处理批次,每个 CPU 核心使用 num_cpu 个线程
❸ 等待线程完成
❹ 将私有输出矩阵聚合到一个单一的输出矩阵中
❺ 注意 nogil=True 标志,它允许 Python 以真正并行的方式执行线程
该算法通过将输入矩阵的行分割成 num_cpu 个等大小的碎片或批次来工作。每个批次都得到自己的私有输出矩阵,通过 cooc.copy() 创建,这样线程就不需要锁定或以其他方式协调更新。批次被提交给具有 num_cpu 个工作线程的线程池。在线程完成填充它们私有的共现矩阵子集后,结果被合并到最终的输出矩阵中。你可以使用以下代码与早期变体进行基准测试:
# python cooc_flow.py --environment=conda run --num-docs 10000 --algo multicore
如果你之前在 Python 中遇到过多线程,你可能听说过 Python 中的 全局解释器锁(GIL)(如果你好奇,你可以在 wiki.python.org/moin/GlobalInterpreterLock 上了解更多关于 GIL 的信息)。简而言之,GIL 阻止多个线程有效地并行执行 Python 代码。然而,GIL 的限制并不适用于这个算法,因为我们使用 Numba 将 Python 代码编译成机器码,类似于第 3 个变体。因此,从 Python 解释器的角度来看,我们执行的不是 Python 代码,而是本地代码,它不受 GIL 的限制。我们只需要记住在 jit 调用中添加 nogil=True,以提醒 Numba 注意这个细节。
这个变体是说明如何实现多核并非万能的一个很好的例子。尽管读取矩阵的行是一个令人尴尬的并行问题,不需要线程间的协调,但写入输出却不是。在这种情况下,我们付出的代价是输出矩阵的重复。另一种方法是应用锁定到输出矩阵上。无论如何,每个新线程都会增加一点成本。因此,增加核心有助于提高性能,但仅限于一定程度,类似于我们在第 4.3.1 节中并行化 K-means 经验到的。图 5.9 展示了这种效果。

图 5.9 多线程情况下的执行时间与 CPU 核心数的关系
图 5.9 显示,使用 num_cpu=1 运行算法需要大约 100 秒来处理完整矩阵的版本。对于这个数据集,最佳点似乎在 num_cpu=4,这提高了大约 40% 的性能。超过这个点,创建和聚合每个线程的输出矩阵的开销超过了处理每个线程中越来越小的输入分片的好处。
总结变体
本节展示了优化数值密集型算法性能的实际情况如下:
-
首先,我们从算法的简单版本开始。
-
结果证明,考虑到用例的要求,该算法的性能确实不够,所以我们评估了通过垂直或水平扩展来找到简单解决方案的可能性。结果发现,这两种方法都没有足够地加快简单算法的执行速度。
-
我们评估了是否有现成的优化实现。我们找到了一个基于简单线性代数的优雅且高效的解决方案。然而,这个解决方案的副作用是增加了内存消耗,我们可以通过垂直扩展来解决。如果这是一个真实用例,基于努力、性能和可维护性的考虑,具有垂直扩展(高内存实例)的变体 2 似乎是一个不错的选择。
-
为了说明更高级的优化,我们引入了 Numba,它在这种用例中表现良好。然而,默认实现没有充分利用多个 CPU 核心。
-
最后,我们实现了一个编译的、多核的变体,其性能应该与优化良好的并行化 C++ 算法相媲美。然而,我们只用不到 100 行 Python 就完成了所有工作。
图 5.10 展示了四种变体的性能基准。

图 5.10 四种变体与文档数量的性能对比
当数据集足够小(这里为 100 个文档)时,实现不会产生任何差异。当有 1,000 个文档时,纯 Python 实现开始明显变慢,尽管在绝对意义上,执行时间可能是可接受的。在 10,000 个文档及以上时,纯版本实际上变得无法使用,因此被排除在大规模基准测试之外。三个高性能变体几乎同样高效,除了 linalg 变体在最高扩展级别时耗尽内存。多核变体的复杂性只有在完整数据集上才会开始显现效果。图 5.10 如下总结了本节的关键学习经验:
-
原始的纯实现效率极低,需要采取一些措施。
-
通过简单观察如何利用现有的高性能库来实现 linalg 解决方案,以最小的努力实现了巨大的速度提升。
-
实现一个定制的、多核的解决方案是可能的,其性能甚至优于 linalg,但实现要复杂得多。
-
我们能够在不切换到像 C++ 这样高性能但低级语言的情况下,用 Python 实现所有解决方案。
这些经验可以轻松应用于许多其他数值密集型 Python 任务。现代 Python 数据科学生态系统提供了一个功能强大且多才多艺的工具包,用于实现高性能算法。
5.3.2 快速工作流程的配方
现在我们已经在多个场景中实践了可扩展性和性能,我们可以将本章的学习总结成一个简单的配方,你可以将其应用于自己的用例,如下所示:
-
从最简单的方法开始。一个简单且明显正确的解决方案为逐步优化提供了一个稳健的基础。
-
如果你担心这种方法不可扩展,考虑在实际情况中何时以及如何达到极限。如果答案是永远不会,或者至少不是很快,你只能在必要时增加复杂性。
-
使用垂直扩展性使简单版本能够处理现实输入数据。
-
如果初始实现不能利用垂直扩展性提供的硬件资源,可以考虑使用现成的优化库。
-
如果工作流程包含令人尴尬的并行部分,或者数据可以轻松分片,可以利用水平扩展性来实现并行处理。
-
如果工作流程仍然太慢,仔细分析瓶颈所在。考虑是否可以通过简单的性能优化来消除瓶颈,也许可以使用 Python 数据科学工具包中的工具。
-
如果工作流程仍然太慢,这种情况很少见,可以考虑使用能够利用分布式算法和专用硬件的专用计算层。
摘要
-
建议从简单的方法开始,最初优化的是正确性,而不是性能或可扩展性。
-
垂直扩展性有助于数据科学家的生产力:他们可以通过从云中请求更多的硬件资源,仅使用简单易懂的 Python 代码,就让工作流程处理更多的数据和更复杂的需求。
-
水平扩展性在处理三种场景时很有用:令人尴尬的并行任务、大型数据集和分布式算法。
-
建议将性能优化推迟到绝对必要时再进行。在优化性能时,寻找通过仔细分析瓶颈可以带来巨大收益的简单优化。
-
Python 数据科学生态系统包括大量工具,可以帮助逐步优化性能。特别是,许多问题可以通过使用基础包如 Scikit-Learn、NumPy 和 Numba 来解决。
-
你可以轻松地将水平扩展性、垂直扩展性和高性能代码结合在一个工作流程中,以满足当前任务的可扩展性需求。
6 进入生产阶段
本章节涵盖
-
将工作流程部署到高度可扩展和高度可用的生产调度器
-
设置一个集中式元数据服务以跟踪公司范围内的实验
-
定义具有各种软件依赖关系的稳定执行环境
-
利用版本控制来允许多人安全地开发多个版本的项目
到目前为止,我们一直在个人工作站上启动所有工作流程,可能是一台笔记本电脑。然而,在原型环境中运行业务关键应用程序并不是一个好主意。原因有很多:笔记本电脑可能会丢失,它们难以集中控制和管理工作,更重要的是,快速、人工参与的原型设计需求与生产部署的需求非常不同。
“部署到生产”究竟意味着什么?这个词生产经常被使用,但很少被精确定义。尽管特定的用例可能有它们自己的定义,但我们认识到以下两个特性在大多数生产部署中是常见的:
-
自动化—生产工作流程应在没有任何人工参与的情况下运行。
-
高可用性—生产工作流程不应失败。
生产工作流程的主要特征是它们应在没有人工操作员的情况下运行:它们应该自动启动、执行并输出结果。请注意,自动化并不意味着它们是孤立的。它们可以由某些外部事件启动,例如新数据的可用性。
它们不应频繁失败,至少不应频繁失败,因为失败会使其他系统更难依赖应用程序,并且修复失败需要缓慢而繁琐的人工干预。从技术角度来说,不失败和可信赖意味着应用程序是高度可用的。
自动化和高可用性几乎是生产部署的必要要求,但它们并不总是足够的。您的特定用例可能还有额外的需求,例如,关于低延迟预测、处理大量数据集的能力或与特定生产系统的集成。本章讨论如何满足生产部署的常见和定制需求。
正如我们在第二章中讨论的,原型设计和与生产部署的交互是分离但又相互关联的活动。原型设计无法自动化,原型当然不是高度可用的,它们的定义特征是人的参与。生产部署则相反。
我们应该使在这两种模式之间移动变得容易,因为数据科学项目不是从原型到最终部署的线性过程的水下瀑布。相反,数据科学应用程序应不断迭代。将应用程序部署到生产应该是一个简单、频繁、不引人注目的事件——软件工程师称之为持续部署的概念。使所有项目都能持续部署是一个值得追求的目标,我们将在本章开始探索,并在第八章继续探讨。为了反驳常见的误解,认识到“生产”并不意味着以下内容是有用的:
-
并非所有生产应用程序都处理大量的计算或数据。你当然可以有小规模但业务至关重要的应用程序,这些应用程序不能失败。此外,并非所有生产应用程序都需要表现出高性能。
-
一个应用程序不一定只有一个生产部署。特别是在数据科学的背景下,通常会有多个生产部署并排运行,例如为了进行 A/B 测试。
-
生产并不意味着任何特定的技术方法:生产应用程序可以是每晚运行的流程,是服务于实时请求的微服务,是更新 Excel 表的流程,或任何其他满足用例的方法。
-
进入生产阶段不一定要是一个繁琐和令人焦虑的过程。事实上,有效的数据科学基础设施应该使得将早期版本部署到生产变得容易,以便在现实环境中观察其行为。
然而,生产部署始终应意味着一种稳定性。在本章中,我们介绍了一系列经过时间考验的防御性技术来保护生产部署。这些技术建立在前面章节介绍的技术之上。一个健壮、可扩展的计算层是生产准备的关键要素。
从基础知识开始,第 6.1 节涵盖了在个人工作站之外以稳定方式执行工作流程,无需任何人为干预。这是我们基础设施堆栈中作业调度器层的主要关注点,如图 6.1 所示。

图 6.1 有效的数据科学基础设施的堆栈
在此之后,我们专注于尽可能保持工作流程执行环境的稳定性,这是堆栈中版本控制层的作用。许多生产故障并不是因为应用程序本身失败,而是因为其环境中的某些东西发生了变化。而像第四章中介绍的@retry 这样的技术,处理来自用户代码的故障,第 6.2 节展示了如何防止由用户代码周围的软件环境变化引起的故障,例如快速发展的库(如 TensorFlow 或 pandas)的新版本。
第 6.3 节侧重于防止人为错误和事故。我们需要在隔离和版本化的环境中执行生产工作流程,以便数据科学家可以继续实验新版本,而无需担心它们会干扰生产部署。理想情况下,你应该能够要求一位新实习生创建一个生产应用程序的版本,并将其与主版本并行部署,知道他们无法对现有版本造成任何破坏,甚至不是意外造成的。
你不需要将本章中介绍的所有技术应用到每个项目中。使应用程序更健壮的防御性功能可能会减慢原型设计,并可能使部署和测试新生产版本变得更加困难,从而影响整体生产力。幸运的是,生产就绪是一个连续体,而不是一个二元标签。你可以在本地工作站上快速原型化一个项目,如第 6.1 节所述,将早期版本部署到生产环境中,并在风险变得更高时,稍后应用第 6.2 节和第 6.3 节的技术。换句话说,随着项目的成熟,你可以逐渐加固项目以抵御失败。
在原型设计和生产需求之间不可避免地存在权衡。有效数据科学基础设施的一个主要目标是在这两种模式之间找到良好的折衷方案。图 6.2 展示了项目成熟路径上的典型权衡。

图 6.2 典型数据科学项目的成熟路径
在项目成熟路径的每个阶段,你都需要解决一组新的缺陷。随着时间的推移,随着项目的日益稳健,一些灵活性会丧失。项目的新版本可以从开始处重新开始循环,逐步改进生产版本。
本书的前几章已将你引导到“基本工作流程”阶段。本章将教你生产就绪的下一级。到本章结束时,你将达到一个已被证明足以支持 Netflix 和其他大型公司一些最关键业务机器学习应用的水平。你可以在此处找到本章的所有代码列表:mng.bz/06oW。
6.1 稳定的工作流程调度
哈珀带亚历克斯参观了公司的新杯子蛋糕生产设施。受到亚历克斯有希望的样品的鼓舞,哈珀希望开始使用机器学习来优化该设施的操作。哈珀提醒亚历克斯,任何计划外的中断都会直接影响公司的收入,因此,希望亚历克斯的模型能够完美运行。听到这些,鲍伊建议他们应该在一个比亚历克斯的笔记本电脑更加健壮的环境中开始安排训练和优化工作流程。

本节回答了一个简单的问题:我如何在没有人为干预的情况下可靠地执行我的工作流程?到目前为止,我们一直在命令行上通过执行类似以下命令来执行工作流程:
# python kmeans_flow.py run
输入命令需要人为干预,因此这不是适用于应自动运行的部署生产部署的好方法。此外,如果您在命令执行时关闭了终端,工作流程会崩溃——工作流程不是高度可用的。请注意,对于原型设计用例,运行命令是完美的,因为它提供了非常快速的迭代。
在第二章中,我们讨论了适用于生产级别的流程调度器或编排器,这是完成工作的正确工具。图 6.3 提醒我们工作调度层的作用:工作调度器只需要遍历工作流程的步骤,即决定如何编排 DAG 并将每个任务发送到计算层,计算层决定在哪里执行它们。关键的是,工作调度器和计算层都不需要关心确切执行什么——关于这一点稍后还会详细说明。

图 6.3 调度层的作用:如何编排 DAG
虽然遍历 DAG 听起来不难,但请记住,我们正在讨论生产用例:编排器可能需要处理包含数万个任务的庞大工作流程,可能有数千个这样的工作流程同时执行,编排器应该能够优雅地处理各种故障场景,包括编排器本身执行的数据中心。此外,编排器必须能够根据各种条件触发工作流程执行,例如当新数据到达时,并且它应该提供便于监控和警报的用户界面。总的来说,构建这样的系统是一个高度复杂的工程挑战,因此明智的做法是依靠最好的现成系统和服务。我们在第二章中列出了一些合适的候选者。
这也是 Metaflow 采取的方法。Metaflow 包括一个本地调度器,这对于原型设计来说已经足够好了——它支持运行命令。对于生产用例,您可以将 Metaflow 工作流程部署到几个不同的生产调度器,而无需在代码中进行任何更改。在本节中,我们将使用其中一个调度器,AWS Step Functions,来演示这个想法。请注意,一般来说,本节的讨论并不特定于 AWS Step Functions。您也可以将这种模式应用于其他工作调度器。
然而,在我们进入生产调度之前,我们必须注意另一个细节:我们需要一个集中式服务来跟踪所有运行的执行元数据,因为在云中编排工作流程时,我们不能依赖于本地存储的元数据。
6.1.1 集中式元数据
在第三章中,我们讨论了实验跟踪——跟踪执行及其结果的概念。这个术语有点误导。我们感兴趣的是跟踪的不仅仅是实验,还包括生产执行。因此,我们更喜欢使用一个通用术语元数据来指代所有账目活动。图 6.4 展示了元数据跟踪在任务执行背景下的作用。

图 6.4 Metaflow 元数据服务的作用
在顶部,我们有一个作业调度器,它会遍历 DAG。图中以 Metaflow 的本地调度器为例,但作业调度器也可以是生产调度器,例如 AWS Step Functions。调度器将任务发送到计算层,执行用户代码。用户代码产生结果或工件,如图中的 self.model,这些工件存储在数据存储库中,正如第三章所述。
在旁边,元数据服务跟踪所有启动的运行和任务。此外,当工件写入数据存储库时,元数据服务记录工件在数据存储库中的位置。关键的是,元数据不记录数据本身,因为数据已经在数据存储库中。这使我们能够保持元数据服务的相对轻量级——它只负责账目,而不是大规模数据或计算。
数据和元数据存储后,可以从外部系统查询。例如,正如我们在前面的章节中看到的,我们可以使用 Metaflow 客户端 API 在笔记本中查询运行及其结果。客户端 API 与元数据服务通信以确定可用的运行,并与数据存储库通信以访问其结果。
大多数现代数据科学和机器学习基础设施框架都提供集中式元数据跟踪服务。例如,MLflow (mlflow.org) 提供了跟踪服务器,而 Kubeflow (kubeflow.org) 内置了一个集中式仪表板,用于存储和显示所有已执行的运行。
默认情况下,Metaflow 在本地文件中跟踪元数据。这对于小规模的个人原型设计来说是足够的,但对于更严肃的使用场景,你应该设置一个基于云的集中式元数据服务。集中式元数据跟踪提供了以下好处:
-
你需要一个元数据服务才能使用生产调度器,因为基于云的调度器没有“本地文件”的概念。
-
你可以在任何地方执行运行,无论是原型设计还是生产,并且可以放心,元数据始终在单一位置一致地跟踪。
-
集中式元数据使协作成为可能,因为所有用户都可以发现和访问过去运行的成果,无论是由谁启动的。更多内容请参阅第 6.3 节。
-
云端服务更稳定:所有元数据都可以存储在可以复制和定期备份的数据库中。
为 Metaflow 设置集中式元数据服务
就本书撰写时的情况而言,Metaflow 提供的元数据服务是一个典型的容器化微服务,它使用 Amazon 关系数据库服务(RDS)来存储元数据。该服务可以部署在 AWS 弹性容器服务(ECS)或弹性 Kubernetes 服务(EKS)上,例如。
部署服务的最简单方法是使用 Metaflow 安装说明中提供的 CloudFormation 模板(docs.metaflow.org)。使用 CloudFormation 模板的另一个好处是它还可以设置 AWS Step Functions,我们将在下一节中使用它。
在您设置并配置了 Metaflow 元数据服务之后,您可以使用客户端 API 访问结果,就像之前一样——用户界面 API 没有任何变化。您可以通过运行以下命令来确保 Metaflow 正在使用元数据服务而不是本地文件:
# metaflow status
如果服务配置正确,您应该看到以下类似的输出:
# Using Metadata provider at:
➥ https://x3kbc0qyc2.execute-api.us-east-1.amazonaws.com/api/
您可以通过使用 run 命令执行任何流程来测试服务是否正常工作。您应该注意到,当使用服务时,运行和任务 ID 要短得多(例如,HelloFlow/2 与 HelloFlow/1624840556112887 相比)。本地模式使用时间戳作为 ID,而服务生成全局唯一的短 ID。
如果您不确定客户端 API 使用的是哪个元数据服务,您可以使用 get_metadata 函数来查找。您可以在笔记本中执行如下单元格:
from metaflow import get_metadata
print(get_metadata())
如果服务使用正确,您应该看到以下类似的输出:
service@https://x3kbc0qyc2.execute-api.us-east-1.amazonaws.com/api/
Metaflow 将配置存储在用户主目录的~/.metaflowconfig/中。如果您组织中有许多数据科学家,与他们都共享同一组配置文件是有益的,这样他们可以从一致的基础设施中受益,并通过共享的元数据服务和数据存储进行协作。另一方面,如果您需要维护组织之间的边界,例如出于数据治理的原因,您可以设置多个独立的元数据服务。您还可以通过使用单独的 S3 存储桶来定义不同数据存储之间的严格安全边界。
在您配置 Metaflow 使用基于 S3 的数据存储和集中式元数据之后,可能会有时候您想使用本地数据存储和元数据来测试某些内容,例如用于故障排除。您可以按照以下方式操作:
# python myflow.py --datastore=local --metadata=local run
这些选项指示 Metaflow 在默认配置的情况下回退到本地数据存储和元数据。
6.1.2 使用 AWS Step Functions 与 Metaflow
AWS Step Functions (SFN) 是由 AWS 提供的云服务,它是一个高度可用和可扩展的工作流编排器(作业调度器)。尽管有许多其他现成的流程编排器可用,但与替代方案相比,SFN 具有许多吸引人的特性,如下所述:
-
与 AWS Batch 类似,它是一个完全托管的服务。从操作者的角度来看,该服务实际上无需维护。
-
AWS 在提供高度可用性和可扩展性的服务方面有着卓越的记录。尽管许多替代方案声称具有这些特性,但并非所有都能按预期工作。
-
与内部运营类似服务的全部成本相比,运营总成本可以非常有竞争力,尤其是考虑到保持系统运行不需要任何人员。
-
SFN 可以无缝集成其他 AWS 服务。
当谈到 SFN 的缺点时,截至本书编写时,如果没有像 Metaflow 这样的库使用它们本地的基于 JSON 的语法手动定义 SFN 工作流程,那么很难定义。此外,SFN 的 GUI 有点笨拙。还有,SFN 的一些限制会约束工作流程的最大大小,这可能会影响具有非常宽泛 foreachs 的工作流程。
让我们看看它在实际中的工作方式。首先,确保你已经按照 Metaflow 文档中的说明部署了 Step Functions 集成。最简单的方法是使用提供的 CloudFormation 模板,它为你设置好一切,包括元数据服务。接下来,让我们定义一个简单的流程,如下一列表所示,我们用它来测试 SFN。
列表 6.1 测试 Step Functions 的简单流程
from metaflow import FlowSpec, Parameter, step
class SFNTestFlow(FlowSpec):
num = Parameter('num',
help="Give a number",
default=1)
@step
def start(self):
print("The number defined as a parameter is", self.num)
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
SFNTestFlow()
将代码保存到 sfntest.py 并确保它在本地工作:
# python sfntest.py run
接下来,让我们将工作流程部署到生产环境!你只需要执行下一个命令:
# python sfntest.py step-functions create
如果一切顺利,你应该会看到如下输出:
Deploying SFNTestFlow to AWS Step Functions...
It seems this is the first time you are deploying SFNTestFlow to AWS Step Functions.
A new production token generated.
The namespace of this production flow is
production:sfntestflow-0-xjke
To analyze results of this production flow add this line in your notebooks:
namespace("production:sfntestflow-0-xjke")
If you want to authorize other people to deploy new versions of this flow to AWS Step Functions, they need to call
step-functions create --authorize sfntestflow-0-xjke
when deploying this flow to AWS Step Functions for the first time.
See "Organizing Results" at https://docs.metaflow.org/ for more information about production tokens.
Workflow SFNTestFlow pushed to AWS Step Functions successfully.
What will trigger execution of the workflow:
No triggers defined. You need to launch this workflow manually.
目前我们不必担心大部分这些输出。我们将在 6.3 节中深入探讨 生产令牌。不过,最后一行值得注意:正如它所说的,在其当前形式下,工作流程不会自动启动。我们需要手动启动或触发它。
当你运行 step-function create 时,究竟发生了什么?Metaflow 在幕后做了一系列工作,如图 6.5 所示。

图 6.5 Metaflow 部署到 AWS Step Functions 的方式
执行了以下操作序列:
-
Metaflow 将当前工作目录中的所有 Python 代码打包并上传到数据存储(S3),以便远程执行。更多内容将在下一节中介绍。
-
Metaflow 解析了工作流程 DAG 并将其转换为 SFN 理解的语法。换句话说,它将你的本地 Metaflow 工作流程转换为真正的 SFN 工作流程。
-
Metaflow 调用了一系列 AWS API 来部署翻译后的工作流程到云端。
值得注意的是,用户不需要更改他们的代码就可以将其部署到云端。这是 Metaflow 的一个重要特性:你可以在将代码部署到生产之前,在本地以及使用你选择的计算层(如 AWS Batch)测试代码。实际上,生产中运行的是相同的代码,所以如果你在本地测试中代码有效,你就可以有信心它在生产中也能工作。更重要的是,如果工作流程在生产中失败,你可以在本地重现并修复问题,然后只需再次运行 step-functions create 即可将修复版本部署到生产——更多关于这一点在第 6.3 节中介绍。
生产效率提示:当涉及到与第二章中讨论的生产部署的交互时,至关重要的是,你在本地原型化的工作流程可以在生产中以最小的更改工作,反之亦然。这使得在部署到生产之前在本地测试代码变得容易,当生产中的任务失败时,可以在本地重现这些问题。
在我们运行工作流程之前,让我们登录到 AWS 控制台,看看工作流程在 SFN 侧看起来如何。导航到 AWS 控制台上的步骤函数 UI。你应该会看到一个工作流程列表,如图 6.6 所示。

图 6.6 AWS Step Functions 控制台上的工作流程列表(状态机)
图 6.6 中显示的视图为你提供了当前执行、成功和失败的运行的快速概述。当你点击运行的名称时,你会进入如图 6.7 所示的视图。
这里目前没有什么可看的,因为没有执行。尽管“开始执行”按钮听起来很有吸引力,但实际上有更好的启动运行的方式。如果你点击该按钮,SFN 会要求你指定一个 JSON 文件,该文件应包含流程的参数,这有点麻烦手动完成。相反,我们可以在命令行上触发一个执行,如下所示:
python sfntest.py step-functions trigger
你应该看到以下类似的输出:
Workflow SFNTestFlow triggered on AWS Step Functions
➥ (run-id sfn-344c543e-e4d2-4d06-9d93-c673d6d9e167

图 6.7 AWS Step Functions 控制台上的工作流程
在 SFN 上执行流程时,Metaflow 运行 ID 对应于 SFN 运行 ID,除了 Metaflow 添加的 sfn 前缀之外,所以很容易知道哪些 Metaflow 运行映射到哪些 SFN 执行。
触发命令与运行命令类似,因为它们都执行一个工作流程。然而,它们不是在本地执行,触发命令使工作流程在 SFN 上执行。关键的是,你可以关闭你的笔记本电脑,运行将在 SFN 上继续,这与本地运行相反。实际上,SFN 支持执行长达一年的工作流程!长时间不间断地运行笔记本电脑将非常不方便。
现在,如果你刷新工作流程列表,你应该会看到一个新的执行。如果你点击它,你会进入如图 6.8 所示的运行视图。

图 6.8 AWS Step Functions 控制台上的运行
注意,左上角的 ID 与触发器输出的 ID 匹配。方便的是,运行视图可视化了我们工作流程的 DAG。它实时更新,显示正在执行哪些步骤。如果您在 DAG 中单击一个步骤,在右侧面板中您会看到一个资源链接,该链接会将您带到 AWS Batch 控制台,显示与正在执行的任务对应的 AWS Batch 作业。在 AWS Batch 控制台中,您可以单击日志流链接以实时查看任务的输出。您可以通过使用熟悉的日志命令(将 ID 替换为触发器输出的您的 ID)更轻松地查看日志:
python sfntest.py logs sfn-344c543e-e4d2-4d06-9d93-c673d6d9e167/start
所有 Metaflow 命令和客户端 API 都可以在本地运行和 SFN 上的运行执行中同等工作——只是它们的 ID 格式不同。
如下所示,我们可以使用自定义参数值触发另一个执行
python sfntest.py step-functions trigger --num 2021
这将产生一个新的运行 ID,例如
Workflow SFNTestFlow triggered on AWS Step Functions
➥ (run-id sfn-838650b2-4182-4802-9b1e-420bb726f7bd)
您可以通过检查触发运行的工件来确认参数更改是否生效
python sfntest.py dump sfn-838650b2-4182-4802-9b1e-420bb726f7bd/start
或者通过检查启动步骤的日志。注意,您可以通过执行以下命令在命令行中列出 SFN 运行:
python sfntest.py step-functions list-runs
因此,可以在不登录到 SFN 控制台的情况下触发和发现运行,以及检查日志和工件。
让我们深呼吸并总结一下我们刚刚学到的内容:
-
我们定义了一个正常的 Metaflow 工作流程,我们能够像以前一样在本地进行测试。
-
我们使用 step-functions create 命令将工作流程部署到云中高度可用、可扩展的生产调度器 AWS Step Functions。
-
我们使用 step-functions 触发器触发了生产运行,并可选择使用自定义参数。即使您关闭了笔记本电脑,工作流程也会继续运行。
-
我们在 SFN 提供的 GUI 上实时监控工作流程执行。
-
我们使用熟悉的 CLI 命令检查了生产运行的日志和结果。
整个过程的简单性可能会让人误以为它非常简单。然而,能够如此轻松地将工作流程部署到生产调度器,对于数据科学家来说确实是一种超级能力!使用这种方法的结果是高度可用的工作流程,归功于 SFN。然而,我们手动使用触发器启动了工作流程,因此设置尚未完全自动化。在下一节中,我们将解决这个缺点。
6.1.3 使用 @schedule 安排运行
生产流程应无需任何人工干预即可执行。如果您有一个复杂的环境,其中包含许多相互依赖的工作流程,建议根据事件以编程方式触发工作流程,例如,当输入数据更新时重新训练模型。本节重点介绍一个更简单、更常见的方法:将工作流程部署到预定的时间表上运行。
Metaflow 提供了一个流程级别的装饰器 @schedule,允许您为工作流程定义执行时间表。请参见以下代码示例中的示例。
列表 6.2 使用 @schedule 的流程
from metaflow import FlowSpec, Parameter, step, schedule
@schedule(daily=True) ❶
class DailySFNTestFlow(FlowSpec):
num = Parameter('num',
help="Give a number",
default=1)
@step
def start(self):
print("The number defined as a parameter is", self.num)
self.next(self.end)
@step
def end(self):
print('done!')
if __name__ == '__main__':
DailySFNTestFlow()
❶ 使工作流程在午夜自动触发
将代码保存在 dailysfntest.py 中。在这里,我们通过使用@schedule(daily=True)注解流程来定义一个简单的每日计划。这将使流程在 UTC 时区的午夜开始。当本地运行流程时,@schedule 没有任何效果。它将在您执行以下操作时生效:
python dailysfntest.py step-functions create
就这样!工作流程现在将每天自动运行一次。您可以在 SFN UI 上观察过去执行的运行 ID,或者使用前面描述的步骤函数 list-runs。
注意,您不能为计划中的运行更改参数值——所有参数都分配了它们的默认值。如果您需要动态参数化工作流程,可以使用任意 Python 代码,例如在启动步骤中。
除了每天运行工作流程之外,还提供了以下简写装饰器:
-
@schedule(weekly=True)—每周日午夜运行工作流程
-
@schedule(hourly=True)—每小时运行工作流程
或者,您可以使用 cron 属性定义一个自定义的计划。例如,这个表达式每天上午 10 点运行工作流程:@schedule(cron='0 10 * * ? *')。您可以在mng.bz/KxvE找到更多 cron 计划的示例和语法描述。
现在我们已经学会了如何在没有人监督的情况下安排流程执行,这涵盖了生产部署的自动化需求。接下来,我们将把注意力转向高可用性,即如何在不断变化的环境中保持部署的稳定性。
6.2 稳定的执行环境
亚历克斯为他们与鲍伊一起搭建的现代基础设施堆栈感到自豪。这允许公司的所有数据科学家在本地开发工作流程,并轻松地将它们部署到云中的强大生产调度器。亚历克斯开始了应得的假期。在假期期间,亚历克斯收到一条通知,提醒他一个已经完美运行了几周的生产工作流程昨晚神秘地崩溃了。当亚历克斯调查此事时,发现工作流程总是安装 TensorFlow 的最新版本。就在昨天,TensorFlow 发布了一个与生产工作流程不兼容的新版本。为什么这些事情总是在假期发生?

实际上,几乎所有数据科学和机器学习工作流程都使用第三方库。事实上,几乎可以肯定的是,您工作流程中的绝大多数代码行都位于这些库中。在开发过程中,您可以通过使用不同的数据集和参数化来测试库的行为,以获得对特定版本的库工作正确的信心,但当一个新版本发布时会发生什么呢?大多数现代机器学习和数据科学库都在快速发展。
这个问题并不仅限于数据科学。软件工程师们几十年来一直在努力解决依赖管理问题。已经存在许多关于开发和发布软件库的最佳实践。例如,大多数表现良好的库在更改其公共 API 时会非常谨慎,这可能会破坏使用该库的应用程序。
库的公共 API 应该提供一个清晰的契约。想象你有一个提供函数 requests.get(url)的库,该函数通过 HTTP 获取给定的 URL 并返回字节字符串内容。只需阅读该函数的简短描述,就可以清楚地了解该函数应该如何表现。与此相对比的是,一个提供以下 API 的机器学习库用于 K-means 聚类:KMeans(k).fit(data)。契约要宽松得多:库可能会更改簇的初始化方式、使用的优化算法、数据处理方式以及如何在多个 CPU 或 GPU 核心上分配实现,而无需更改 API。所有这些变化都可能微妙地改变库的行为并导致意外的副作用。
松散的 API 契约和数据科学的一般统计特性使得数据科学工作流程的依赖管理比软件工程师面临的问题更为复杂。例如,想象一个机器学习库包含一个名为 train()的方法。该方法可能使用一种像随机梯度下降这样的技术,这可以以许多不同的方式实现,每种方式都有其自身的优缺点。如果实现方式在库的不同版本之间发生变化,它可能会对生成的模型产生重大影响,尽管技术上 train() API 保持不变。软件工程中的失败通常非常明显——可能是返回类型已更改,或者函数引发了一个新的异常,导致程序因冗长的错误消息而崩溃。然而,在数据科学中,你可能会简单地得到一个略有偏斜的结果分布,而没有任何失败,这使得甚至难以注意到有什么变化。
当你在原型化一个新应用程序时,能够灵活地使用任何库的最新版本当然是非常方便的。在原型化过程中,没有人会依赖你的工作流程输出,因此快速变化是可以接受和预期的。然而,当你将工作流程部署到生产环境中时,对依赖关系的谨慎处理开始变得重要。你不想在度假期间接到关于生产管道(比如糟糕的亚历克斯)意外失败的通知,或者面对难以调试的问题,比如为什么结果看起来与之前略有不同。为了避免任何意外,你希望生产部署在尽可能稳定的环境中执行,这样除非你明确行动和批准,否则什么都不会改变。
执行环境
我们所说的执行环境是什么意思?考虑我们之前执行过的 Metaflow 工作流程,例如 KMeansFlow,这是我们之前章节中开发的。当你通过执行,比如,python kmeans_flow.py run 来运行工作流程时,执行的入口点是 kmeans_flow.py,但为了使流程成功执行,还需要存在许多其他模块和库,这些模块和库共同构成了 kmeans_flow.py 的执行环境。图 6.9 说明了这个概念。

图 6.9 流程的执行环境层
在中心,我们有流程本身,kmeans_flow.py。流程可能使用用户定义的支持模块,如 scale_data.py,其中包含加载数据的函数。要执行流程,你需要 Metaflow,它本身就是一个库。在此基础上,你还有所有其他第三方库,如 Scikit-Learn,我们在 K-means 示例中使用过。最后,整个包在操作系统上执行。
为了为生产部署提供一个稳定的执行环境,冻结图 6.9 中显示的所有层的不可变快照是有益的。请注意,这包括所有*传递依赖**,即其他库本身使用的所有库。通过这样做,你可以确保新的库发布不会对部署产生未计划的影响。你可以控制如何以及何时升级库。
建议:为了最小化意外,冻结生产部署中的所有代码是有益的,包括工作流程本身及其所有依赖项。
技术上,我们有多种方式来实现这种快照功能。一种常见的方法是使用 Docker 等工具将图 6.9 中显示的所有层打包到一个容器镜像中。我们将在 6.2.3 节中讨论这种方法。另一种方法是 Metaflow 提供了内置的功能来处理层的快照,我们将在下一节中讨论。为了使讨论更加有趣和具体,我们将它们放在一个真实的数据科学应用背景下:时间序列预测。
示例:时间序列预测
时间序列预测有无数的应用,即根据历史数据预测未来的数据点。许多预测技术和现成的软件包都包括这些技术的有效实现。这类软件库是我们希望在数据科学工作流程中包含的典型例子,因此我们使用预测应用来展示与依赖管理及稳定执行环境相关的概念。在以下示例中,我们将使用一个名为 Sktime 的库(sktime.org)。
因为刚刚学习了如何自动调度工作流,让我们选择一个需要频繁更新的应用程序:天气预报。我们不敢深入探讨气象学——相反,我们只是根据给定位置的过去温度序列,提供未来几天的每小时温度预报。当然,这是一种愚蠢的天气预报方式,但温度数据很容易获得,我们可以轻松实现这个应用程序。
我们将使用名为 OpenWeatherMap 的服务([openweathermap.org](https://openweathermap.org/))来获取天气数据。要使用此服务,您需要在网站上注册一个免费账户。注册后,您将收到一个私有的应用程序 ID 令牌,您可以在以下示例中输入。所需的令牌是一个看起来像这样的字符串:6e5db45abe65e3110be635abfb9bdac5。
完成天气预报示例后,可以将天气数据集替换为另一个实时时间序列,如股价,作为练习。
6.2.1 Metaflow 如何打包流程
在第四章中,我们学习了如何通过执行 run-with-batch 在云中简单地执行工作流。不知何故,Metaflow 能够将您在本地工作站(可能是一台笔记本电脑)上编写的代码,在数百或数千英里外的云数据中心执行。令人惊讶的是,您不需要做任何事情来保存或以任何特定方式打包代码,就可以实现这一点。这要归功于 Metaflow 自动将用户代码及其支持模块打包到代码包中。
默认情况下,代码包包括流程模块,当前工作目录及其子目录中的任何其他 Python (.py) 文件,以及 Metaflow 库本身。这对应于图 6.10 中突出显示的层。

图 6.10 Metaflow 代码包包含的内容
为了了解实际工作原理,让我们首先为我们的天气预报应用程序创建一个支持模块。该模块,如列表 6.3 所示,从 OpenWeatherMap 获取给定位置过去五天的温度时间序列。
列表 6.3 获取温度时间序列的实用模块
from datetime import datetime, timedelta
HISTORY_API = 'https://api.openweathermap.org/data/2.5/onecall/timemachine'❶
def get_historical_weather_data(appid, lat, lon): ❷
import pandas as pd ❸
import requests ❸
now = datetime.utcnow()
data = []
index = []
for ago in range(5, 0, -1): ❹
tstamp = int((now - timedelta(days=ago)).timestamp()) ❺
params = {'lat': lat, 'lon': lon, 'dt': tstamp, ❺
'appid': appid, 'units': 'imperial'} ❺
reply = requests.get(HISTORY_API, params=params).json() ❺
for hour in reply['hourly']:
data.append(hour['temp']) ❻
index.append(datetime.utcfromtimestamp(hour['dt'])) ❻
return pd.Series(data=data, ❻
index=pd.DatetimeIndex(index, freq='infer'))
def series_to_list(series): ❼
index = map(lambda x: x.isoformat(), series.index)
return list(zip(index, series))
❶ 返回历史天气数据的 API 端点
❷ 返回过去五天的温度时间序列
❸ 在函数内部进行导入以避免模块级依赖
❹ 按时间顺序请求过去五天的数据
❺ 准备并发送请求到 OpenWeatherMap
❻ 构建每小时温度的时间序列
❼ 将 pandas 时间序列转换为元组列表
将代码保存到 openweatherdata.py。该模块包含两个函数:get_historical_weather_data,它返回过去五天的温度时间序列,以及一个实用函数,series_to_list,它将 pandas 时间序列转换为元组列表。
get_historical_weather_data 函数接受三个参数:你的私有 appid,你可以通过在 OpenWeatherMap 上注册来获取它,以及你想要获取天气数据的地点的纬度(lat)和经度(lon)。
该函数展示了一个重要的习惯用法:与在模块顶部执行所有导入语句的典型 Python 习惯相反,我们在函数体内导入所有第三方模块——即不在 Python 标准库中的模块,如 pandas 和 Requests。这使得即使没有安装这两个库的人也能导入该模块。他们可以使用模块的一些功能,而无需安装每个依赖项。
习惯用法 如果你认为一个支持模块可能在许多不同的上下文中使用,那么在文件顶部而不是在函数体内导入任何第三方库是一个好主意。这样,模块本身就可以导入,而无需安装模块中所有函数所需的所有依赖项的并集。
OpenWeatherMap API 在一个请求中返回单日的每小时数据,因此我们需要一个循环来检索过去五天的数据。对于每一天,该服务返回一个包含每小时温度(华氏度)数组的 JSON 对象。如果你更喜欢摄氏度,请将单位从英制改为公制。我们将每日数组转换为单个 pandas 时间序列,每个小时都有一个 datetime 对象作为键。这种格式使得绘图和使用数据来进行预测变得容易。
series_to_list 函数简单地将 get_historical_weather_data 生成的 pandas 时间序列转换为 Python 元组列表。我们稍后会回到这个函数的动机。
拥有一个独立的模块的好处是你可以轻松地对其进行测试,而无需依赖任何流程。打开一个笔记本或 Python shell,尝试以下行:
from openweatherdata import get_historical_weather_data
APPID = ‘my-private-token'
LAT = 37.7749
LON = 122.4194
get_historical_weather_data(APPID, LAT, LON)
你可以用除旧金山以外的其他地点替换 LAT 和 LON。用你的私有令牌替换 APPID。如果一切顺利,你应该会看到一个温度列表。请注意,你需要安装 pandas 才能执行此操作。如果你还没有安装,不要担心——你很快就能看到结果!
接下来,我们可以开始开发实际的预测流程。遵循我们的螺旋式流程开发方法,我们目前不担心预测模型。我们只需插入输入,这些输入由 openweatherdata.py 提供,以及一些输出。我们将使用 @conda 来包含外部库,就像在前两个章节中做的那样,我们将在下一节中详细介绍这一点。以下列表包含 ForecastFlow 的第一次迭代。
列表 6.4 ForecastFlow 的第一个版本
from metaflow import FlowSpec, step, Parameter, conda
class ForecastFlow(FlowSpec):
appid = Parameter('appid', required=True)
location = Parameter('location', default='36.1699,115.1398')
@conda(python='3.8.10', libraries={'sktime': '0.6.1'})
@step
def start(self): ❶
from openweatherdata import get_historical_weather_data,
➥ series_to_list
lat, lon = map(float, self.location.split(','))
self.pd_past5days = get_historical_weather_data(self.appid, lat, lon)❷
self.past5days = series_to_list(self.pd_past5days) ❸
self.next(self.plot)
@conda(python='3.8.10', libraries={'sktime': '0.6.1',
'seaborn': '0.11.1'})
@step
def plot(self): ❹
from sktime.utils.plotting import plot_series
from io import BytesIO
buf = BytesIO()
fig, _ = plot_series(self.pd_past5days, labels=['past5days']) ❺
fig.savefig(buf) ❺
self.plot = buf.getvalue() ❻
self.next(self.end)
@conda(python='3.8.10')
@step
def end(self):
pass
if __name__ == '__main__':
ForecastFlow()
❶ 在开始步骤中使用我们的 openweatherdata 模块加载数据
❷ 将 pandas 数据序列保存在一个工件中
❸ 将数据序列的 Python 版本保存在另一个工件中
❹ 这是我们输出步骤。它绘制时间序列。
❺ 在内存缓冲区中绘制并保存时间序列
❻ 将图表存储在工件中
将列表保存为 forecast1.py。要运行列表,您需要按照附录中的说明安装 Conda。
开始步骤负责获取输入数据,并将其委托给辅助模块 openweatherdata.py,这是我们第 6.3 节中创建的。值得注意的是,开始步骤创建了两个工件:pd_past5days,它包含过去五天的温度的 pandas 时间序列,以及 past5days,它包含相同的数据,但已转换为 Python 列表。请注意,我们不需要显式指定 pandas 依赖项,因为它是由 Seaborn 包的传递依赖项。
你可能会想知道为什么需要将相同的数据存储两次,只是两种不同的格式。动机再次是依赖关系:例如,要在笔记本中使用 Client API 读取 pd_past5days,你需要安装特定版本的 pandas。相比之下,你可以不依赖任何其他库来读取 past5days。我们本可以只存储 past5days,但流程的其他步骤需要 pandas 版本,并且由于@conda 装饰器,它们保证有正确的 pandas 版本。
建议:您应该优先将工件存储为内置 Python 类型,而不是依赖于第三方库的对象,因为原生 Python 类型在不同的上下文中都是可读的,无需外部依赖。如果您需要在流程中使用复杂对象,请考虑同时存储一个可共享的 Python 版本以及一个对象版本作为单独的工件。
尝试以下方式执行流程:
python forecast1.py --environment=conda run --appid my-private-token
将 my-private-token 替换为您的个人 OpenWeatherMap 令牌。第一次运行流程需要几分钟,因为需要初始化 Conda 环境。后续运行应该会快得多。
运行完成后,您可以在笔记本中打开一个实例化 Run 对象的笔记本,该对象对应于刚刚完成的运行。您可以通过执行以下单元格来查看温度图:
From metaflow import Run
from IPython.display import Image
run = Run(‘ForecastFlow/16242950734051543')
Image(data=run.data.plot)
将运行 ID 替换为您运行的实际 ID。图 6.11 显示了结果的外观。它显示了五天内拉斯维加斯的每小时温度图,除非您更改了--location,否则它显示了昼夜之间温度变化的明显模式。由于天气不是恒定的,您的时间序列看起来会不同。

图 6.11 拉斯维加斯的每小时温度时间序列
您可以使用单个语句显示图表的事实突出了依赖关系管理的一个重要细节:有时在 Metaflow 内部生成图像而不是在笔记本中是有益的。尽管您也可以在笔记本中调用 plot_series 与 pd_past5days,但这要求您在笔记本内核中安装并可用 pandas、Sktime 和 Seaborn 包。即使您已安装,您的同事可能没有。
Metaflow 中如何生成和存储图像,通过绘图步骤进行演示。许多可视化库,如 Matplotlib,允许将绘图渲染并保存到内存缓冲区(buf)中。然后,您可以保存缓冲区中的字节,即图像文件,到 Metaflow 工件(此处为 self.plot),以便客户端 API 可以轻松检索它。
建议:如果您的流程从易于多个利益相关者访问的图表中受益,请考虑在 Metaflow 步骤内生成和保存它们,而不是在笔记本中。这样,利益相关者无需安装任何额外的依赖项即可查看图像。
在 Metaflow 内部生成图表的这种模式对于生产部署特别有用,在您确定哪些图表对监控流程有用之后,能够广泛共享它们是有益的。相比之下,在原型设计期间,在笔记本中快速设计和迭代可视化可能更容易。
Metaflow 代码包
现在我们有一个正在运行的流程,我们可以回到本节原始问题的原点:Metaflow 代码包包含什么,以及它是如何构建的?
您可以通过执行 package list 来查看代码包的内容:
python forecast1.py --environment=conda package list
注意,您需要为所有命令指定--environment=conda,包括使用@conda 装饰器应用于流程的包列表。您还可以设置环境变量,METAFLOW_ENVIRONMENT=conda,以避免必须显式设置选项。
您应该看到一个长文件列表。注意列表中的以下两点:
-
默认情况下,Metaflow 将当前工作目录及其子目录中所有以.py 后缀结尾的文件(即 Python 源文件)包含在作业包中。这允许您在项目中轻松使用自定义模块和 Python 包——只需将它们包含在相同的当前工作目录中。
-
Metaflow 将 Metaflow 自身包含在作业包中,这使得您可以在云中使用通用容器镜像,因为它们不需要预先安装 Metaflow。这也保证了您在本地看到的与在云中获得的相同结果。
有时您可能希望在代码包中包含除 Python 之外的其他文件。例如,您的数据处理步骤可能执行存储在单独.sql 文件中的 SQL 语句,或者您的代码可能调用自定义的二进制文件。您可以通过使用--package-suffixes 选项将任何文件包含在作业包中。考虑一个具有以下目录结构的假设项目:
mylibrary/__init__.py
mylibrary/database.py
mylibrary/preprocess.py
sql/input_data.sql
myflow.py
这里,mylibrary 是一个 Python 包(如果你不熟悉 Python 包,请参阅 mng.bz/95g0),它包含两个模块,database 和 preprocess。包允许你将多个相互关联的模块作为一个库分组。你可以在步骤代码中使用自定义包,只需编写以下内容:
from mylibrary import preprocess
这即使在执行 myflow.py 中的假设流程时也能正常工作
python myflow.py run --batch
或者将其部署到步骤函数中,因为 Metaflow 会递归地将所有 Python 文件打包到作业包中。然而,要包含 input_data.sql 到代码包中,你需要执行
python myflow.py --package-suffixes .sql run --batch
这条指令指示 Metaflow 包含所有 .sql 文件以及 .py 文件到代码包中。要访问代码中的 SQL 文件,你可以像通常一样打开文件,如下所示:
open(‘sql/input_data.sql')
注意,你应该始终在 Metaflow 代码中使用相对路径而不是绝对路径(任何以斜杠开头的路径),例如 /Users/ville/arc/sql/input_data.sql,因为绝对路径在你的个人工作站外将不起作用。
技术上,你可以在代码包中包含任意的数据文件。然而,正如其名所示,代码包应该仅用于可执行代码。更好地将数据作为数据工件处理,这些工件可以从去重和延迟加载中受益。你可以使用第三章中介绍的 IncludeFile 构造来捆绑运行中的任意数据文件,这对于小数据集是一个很好的解决方案。下一章提供了更多管理大数据集的想法。
6.2.2 为什么依赖管理很重要
在上一节中,我们学习了 Metaflow 如何自动将本地 Python 文件打包到代码包中,该代码包可以发送到不同的计算层,如 AWS Batch,以执行。在稳定执行环境方面,我们涵盖了洋葱的最内层三个层,如图 6.12 所示,但代码包并没有解决第三方库的问题。

图 6.12 关注执行环境的库层
为什么我们不能在代码包中也包含库呢?最重要的是,现代机器学习库往往很复杂,大部分是用 C++ 等编译语言实现的。它们比简单的 Python 包有更多的要求。特别是,几乎所有库都依赖于许多其他库,所以“库”层不仅包括你直接导入的库,如 TensorFlow,还包括 TensorFlow 内部使用的所有库——多达几十个。
我们将这些由库使用的库称为 传递依赖。为了确定需要包含在任务执行环境中的完整库集,我们必须识别所有库及其传递依赖。确定这个依赖图——这个操作通常被称为 依赖解析——是一个令人惊讶的非平凡问题。
你可能会想,这个问题不是已经解决了吗?毕竟,你可以 pip install tensorflow,通常它都能正常工作。考虑以下你可能也遇到过的两个问题:
-
冲突——你安装的库越多,你想要安装的库的依赖图与现有库发生冲突的可能性就越大,安装就会失败。例如,许多机器学习库,如 Scikit-Learn 和 TensorFlow,需要特定版本的 NumPy 库,因此经常会有与 NumPy 版本错误相关的冲突。
这种问题可能很难调试和解决。一个常见的解决方案是公司里有人仔细维护一组相互兼容的包,这是一项繁琐的工作。更糟糕的是,它限制了迭代的速度。不同的项目不能独立做出选择,因为每个人都必须使用一组共同的库。
另一个常见的解决方案是限制依赖图的大小,从而最大限度地减少冲突的可能性,即使用虚拟环境(见
mng.bz/j2aV)。使用虚拟环境,你可以创建和管理隔离的库集。这是一个很好的概念,但手动管理多个虚拟环境也可能很繁琐。 -
可重复性——pip install(或 conda install)默认情况下从头开始执行依赖项解析。这意味着每次运行,例如 pip install tensorflow,你可能会得到一组不同的库。即使你要求特定的 TensorFlow 版本,它的传递依赖也可能随着时间的推移而演变。如果你想重现过去执行的结果,比如一个月前的一次运行,可能实际上无法确定用于产生结果的精确库集。
注意,默认情况下,虚拟环境并不能帮助解决这个可重复性问题,因为 pip install tensorflow 在虚拟环境中同样不可预测。为了获得一个稳定的执行环境,你需要将整个虚拟环境本身冻结。
第一个问题损害了原型设计,因为你不能轻易地实验最新的库。第二个问题损害了生产,因为生产部署可能会因为库中的意外变化而失败。这些问题对每个基础设施都是通用的——它们并不特定于 Metaflow 或任何其他技术方法。
依赖项管理容器
今天,最常见的依赖项管理解决方案是使用容器镜像。正如我们在第四章中简要讨论的那样,容器镜像可以封装图 6.12 中的所有层,包括操作系统,尽管操作系统内核——与硬件交互的操作系统核心——通常在许多容器之间共享。
从依赖管理角度来看,容器镜像就像虚拟环境一样工作,具有类似的优缺点:它们可以帮助划分依赖图以避免冲突。一个缺点是,你需要一个系统,例如具有容器注册库的 持续集成和持续部署(CI/CD)设置,来创建和管理一系列的镜像。大多数公司只管理少量已准备好的生产镜像,以减少复杂性。
此外,尽管在相同的图像上执行相同的代码可以保证高度的再现性,但要生成可再现的图像则需要一些努力。如果你仅仅在图像规范(例如 Dockerfile)中使用 pip install tensorflow,那么你只是将可再现性问题推向了更深的一层。
Metaflow 对于基于容器的依赖管理效果良好。假设你有一个创建镜像的机制,你可以创建一个包含所有所需库的镜像,并让 Metaflow 的代码包在运行时将用户代码覆盖在基础镜像之上。这是一个可靠的解决方案,特别是对于生产部署。
对于原型设计来说,一个挑战是本地创建和使用容器并不直接。为了解决这一不足,Metaflow 内置了对 Conda 包管理器的支持,它将简单的原型设计体验与稳定的生成环境相结合。
依赖管理的实用方法
你可以采用分层的方法来管理依赖项,以平衡快速原型设计和稳定生产的需求。以下是一个实用方法:
-
在流程模块内部定义 DAG 和简单步骤。对于简单的流程和原型设计,这可能就是你所需要的全部。你可以依赖你本地已安装的任何库。
-
为逻辑上相关的函数集创建单独的 支持模块。一个单独的模块可以在多个流程之间共享,也可以在 Metaflow 之外使用,例如在笔记本中。单独的模块也适合进行测试,例如使用标准的单元测试框架如 PyTest (pytest.org)。
-
使用 Python 包创建包含多个模块的定制库。只要包与主流程模块位于相同的目录层次结构中,它就会自动包含在代码包中。
-
使用 @conda 管理第三方库。
-
如果你需要复杂的依赖管理,而 @conda 无法处理,或者你的公司有创建 容器镜像 的工作流程,那么你可以将它们作为 @conda 的替代品或补充。
这些层可以很好地协同工作:一个复杂的项目可以由许多流程组成,这些流程可能共享许多模块和包。它们可以在公司特定的基础镜像上运行,使用 @conda 在其上覆盖项目特定的依赖项。
6.2.3 使用 @conda 装饰器
Conda(conda.io)是一个开源的包管理器,在 Python 数据科学和机器学习生态系统中被广泛使用。尽管 Conda 本身不能解决所有依赖管理问题,但它是一个可靠的工具,您可以使用它来解决前面描述的问题。Metaflow 提供与 Conda 的内置集成,原因如下:
-
Conda 生态系统包含大量的机器学习和数据科学库。
-
Conda 通过提供内置的虚拟环境和强大的依赖解析器来帮助解决冲突问题。正如我们很快就会看到的,它通过冻结环境来允许我们解决可重复性问题。
-
Conda 不仅处理 Python 依赖项,还处理系统库。这对于数据科学库尤其重要,因为它们包含许多编译组件和非 Python 的间接依赖项。作为额外的好处,Conda 还将 Python 解释器本身作为依赖项处理,因此您可以使用不同的 Python 版本。
为了了解 Metaflow 如何在实际中利用 Conda 解决依赖管理问题,让我们继续我们的预测示例。以下代码列表包含了一个骨架流程,该流程获取输入数据——过去五天的温度,并将其绘制出来。我们将通过添加一个执行实际预测的步骤 forecast 来扩展此代码中的流程。
列表 6.5:具有预测步骤的 ForecastFlow
from metaflow import FlowSpec, step, Parameter, conda, schedule
@schedule(daily=True) ❶
class ForecastFlow(FlowSpec):
appid = Parameter('appid', default='your-private-token') ❷
location = Parameter('location', default='36.1699,115.1398')
@conda(python='3.8.10', libraries={'sktime': '0.6.1'})
@step
def start(self): ❸
from openweatherdata import get_historical_weather_data,
➥ series_to_list
lat, lon = map(float, self.location.split(','))
self.pd_past5days = get_historical_weather_data(self.appid, lat, lon)
self.past5days = series_to_list(self.pd_past5days)
self.next(self.forecast)
@conda(python='3.8.10', libraries={'sktime': '0.6.1'})
@step
def forecast(self):
from openweatherdata import series_to_list
from sktime.forecasting.theta import ThetaForecaster
import numpy
forecaster = ThetaForecaster(sp=48)
forecaster.fit(self.pd_past5days) ❹
self.pd_predictions = forecaster.predict(numpy.arange(1, 48)) ❹
self.predictions = series_to_list(self.pd_predictions) ❺
self.next(self.plot)
@conda(python='3.8.10', libraries={'sktime': '0.6.1',
'seaborn': '0.11.1'})
@step
def plot(self):
from sktime.utils.plotting import plot_series
from io import BytesIO
buf = BytesIO()
fig, _ = plot_series(self.pd_past5days, ❻
self.pd_predictions,
labels=['past5days', 'predictions'])
fig.savefig(buf)
self.plot = buf.getvalue()
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
ForecastFlow()
❶ 计划每天运行预测
❷ 将默认值替换为您的实际 OpenWeatherData API 令牌
❸ 开始步骤与之前完全相同。
❹ 创建一个预测器,它查看过去 48 小时来预测接下来的 48 小时
❺ 将预测保存为纯 Python 列表,以便于访问
❻ 绘制历史数据和预测数据
将代码保存到 forecast2.py。请注意,我们添加了@schedule 装饰器,以便流程可以在生产调度程序 Step Functions 上自动运行。这要求您在 appid 参数中将您的个人 OpenWeatherMap API 令牌作为默认值包含在内,因为无法为计划运行指定自定义参数值。
预测步骤是此流程中新颖且令人兴奋的部分。它使用一种特定的时间序列预测方法,称为Theta 方法,由 Sktime 中的 ThetaForecaster 类实现。您可以在sktime.org详细了解该方法。由于该方法考虑了季节性,它对我们温度预测应用很有用。查看图 6.13,很明显,温度遵循日循环模式,至少在拉斯维加斯这座沙漠城市是这样的。我们使用过去 48 小时的数据来预测接下来的 48 小时。请注意,我们除了存储在前面章节中讨论的 pandas 时间序列 pd_predictions 之外,还将预测存储在一个易于访问的纯 Python 对象 predictions 中。我们在绘图步骤中将预测与历史数据一起绘制。现在您可以运行以下命令:
python forecast2.py --environment=conda run
使用笔记本,就像我们之前做的那样,我们可以将结果绘制成图 6.13 所示。

图 6.13 拉斯维加斯的预测每小时温度
只需看图 6.13,这个特定时间序列的预测看起来是可信的。作为一个练习,你可以测试不同地点的不同方法和参数化。为了使练习更加真实,你可以使用 OpenWeatherMap API 获取基于真实气象学的实际预测,并通过程序将你的预测与他们的预测进行比较。此外,你也可以通过查看基于更早的历史数据,你能够多好地预测历史数据来回测预测。
发现 Conda 包
在所有之前的示例中,我们只是为每个@conda 装饰器提供了一个预定义的库列表。你如何找到最初可用的库版本?
Conda 有通道的概念,这对应于不同的包提供者。Conda 背后的原始公司 Anaconda 维护了 Conda 的默认通道。另一个常见的通道是 Conda Forge,这是一个社区维护的 Conda 包仓库。你还可以设置一个自定义的私有 Conda 通道。
要查找包和可用版本,你可以在命令行上使用 conda search 命令,或者你可以在anaconda.org.搜索包(注意,.org 是社区网站,而.com 指的是公司)。
这本书不是关于时间序列预测,而是关于基础设施,因此我们将专注于解释@conda 装饰器的工作原理。在你开始运行之前,但在任何任务执行之前,@conda 执行以下序列操作:
-
它会遍历流程的每一步,并确定需要创建哪些虚拟环境。每个 Python 版本和库的唯一组合都需要一个隔离的环境。
-
如果在本地找到一个具有正确 Python 版本和库的现有环境,则无需更改即可使用。这就是为什么后续执行比第一次更快的原因。
-
如果找不到现有环境,Conda 将用于执行依赖关系解析,以解析需要安装的完整库列表,包括传递依赖项。
-
已安装的库被上传到数据存储中,如 S3,以确保所有任务都可以快速可靠地访问。快速的网络连接或使用基于云的工作站有助于使这一步骤更快。
这个序列确保每个步骤都有一个稳定的执行环境,包括所有请求的库。反思依赖关系管理的问题,请注意我们在这里做了以下操作:
-
我们通过为每个步骤创建一个最小化的独立虚拟环境来最小化依赖冲突的可能性。手动维护如此细粒度的环境几乎是不切实际的,但 Metaflow 会自动为我们处理。
-
仅执行一次依赖项解析以减少开发过程中出现意外的情况的可能性。
-
依赖项列表在代码本身中声明,因此版本信息由 Metaflow 和 Git(如果您使用 Git 存储您的工作流程)存储。这确保了相当高的可重复性,因为您和您的同事有明确的依赖项声明,这些依赖项是重现结果所需的。
不安全步骤
注意,当您使用—environment=conda 时,所有步骤都在一个隔离的 Conda 环境中执行,即使它们没有指定显式的@conda 装饰器。例如,列表 6.5 中的最后一步在一个没有任何额外库的裸机环境中执行,因为它没有指定任何库要求。您不能导入任何未在@conda 中明确列出的库(除了 Metaflow 本身之外的库)。这是一个特性,而不是错误——它确保步骤不会意外地依赖于代码中未声明的库,这是确保可重复性的关键特性。
然而,在某些特殊情况下,您可能需要将隔离步骤与“不安全”步骤混合。例如,您的工作站或底层容器镜像可能包含您无法使用 Conda 安装的库。您可以通过添加装饰器@conda(disabled=True)来声明步骤不安全,这将使步骤执行时仿佛没有使用 Conda。请注意,这样做会否定许多 Conda 的好处,尤其是在后面讨论的生产部署中。
云中的@conda 装饰器
值得注意的是,您可以在云中运行完全相同的代码,如下所示:
python forecast2.py --environment=conda run ---with batch
当使用 AWS Batch 等基于云的计算层运行时,执行开始时与前面列出的操作序列相同。然而,Metaflow 需要执行额外的工作来在容器中动态地重新创建虚拟环境。以下是任务执行之前云中发生的情况:
-
计算层启动一个预配置的容器镜像。值得注意的是,容器镜像不需要包含 Metaflow、用户代码或其依赖项。Metaflow 在镜像之上叠加执行环境。
-
代码包包括需要安装的确切库列表。值得注意的是,不会再次运行依赖项解析,因此所有任务都保证具有完全相同的执行环境。请注意,如果您在步骤代码中运行 pip install some_package,情况可能并非如此。任务最终可能具有略微不同的执行环境,导致难以调试的故障。
-
Metaflow 从其自己的数据存储中拉取所需的库,这些库已经被缓存。这有两个关键原因。首先,想象一下运行一个宽泛的 foreach,例如,数百个实例并行运行。如果它们都并行地访问上游包仓库,这将相当于一种 分布式拒绝服务攻击——包仓库可以拒绝向这么多并行客户端提供服务。其次,偶尔包仓库会删除或更改文件,这可能导致任务失败——再次以难以调试的方式。
这些步骤确保你可以快速本地原型化,使用你喜欢的库,这些库可能对每个项目都是特定的,并且你可以在云中按比例执行相同的代码,而无需担心执行环境。
我们可以利用相同的机制来实现健壮的生产部署。让我们通过以下方式将预测流程部署到生产环境中来测试这个想法:
python forecast2.py --environment=conda step-functions create
与运行命令类似,step-functions create 在生产部署之前执行四个依赖解析步骤。因此,生产部署将保证与流程代码的任何更改(多亏了代码包)以及其依赖项的更改(多亏了 @conda)以及数据存储中包的暂时性错误(多亏了包缓存)隔离。总的来说,你将保证拥有稳定的执行环境。
恭喜你——你刚刚将一个真实的数据科学应用部署到生产环境中!你可以使用 Step Functions UI 来观察日常运行。作为一个练习,你可以创建一个笔记本,在单个视图中绘制每日预测。
使用 @conda_base 的流程级依赖
注意列表 6.5 中开始和预测步骤包含以下相同的依赖集:
@conda(python='3.8.10', libraries={'sktime': '0.6.1'})
绘图步骤只有一个额外的库。随着步骤数量的增加,可能开始觉得在每一步都添加相同的依赖项显得冗余。作为一个解决方案,Metaflow 提供了一个流程级 @conda_base 装饰器,它指定了所有步骤共享的属性。任何特定步骤的附加项都可以使用步骤级 @conda 指定。以下列表显示了 ForecastFlow 的一个采用此方法的替代版本。函数体与列表 6.5 相同,因此为了简洁起见省略了它们。
列表 6.6 展示了 @conda_base 的使用
@schedule(daily=True)
@conda_base(python='3.8.10', libraries={'sktime': '0.6.1'}) ❶
class ForecastFlow(FlowSpec):
@step
def start(self):
...
@step
def forecast(self):
...
@conda(libraries={'seaborn': '0.11.1'}) ❷
@step
def plot(self):
...
@step
def end(self):
...
❶ 使用 @conda_base 定义一个公共 Python 版本和库
❷ 使用步骤级的 @conda 向公共基础添加步骤级附加
至此,我们暂时结束了对依赖管理的探索。我们将在第九章中使用和扩展这些经验,该章将展示一个使用可插拔依赖项的现实机器学习应用。接下来,我们将解决生产部署的另一个重要元素,这是间歇性失败的一个常见来源:人类。
6.3 稳定操作
一名实习生,芬利,加入了公司度过夏天。芬利在贝叶斯统计方面有很强的理论基础。亚历克斯建议他们可以组织一场有趣的内部竞赛,比较芬利创建的贝叶斯模型和亚历克斯一直想构建的神经网络模型的性能。起初,亚历克斯看到神经网络模型在基准测试中似乎表现更好,非常高兴。然而,当他们验证最终结果时,他们注意到亚历克斯的预测工作流程意外地使用了芬利的模型,所以实际上芬利是赢家。如果亚历克斯在组织实验时更加小心,他们本可以花更多的时间完善模型,而不是被错误的结果误导。

想象一下,你第一次将工作流程部署到生产调度器中,正如前几节所讨论的那样。对于大多数项目来说,这仅仅是开始,而不是结束。越来越频繁的是,开发工作流程的数据科学家也负责在生产中运行它们。因此,数据科学家有两个职责:首先,确保生产工作流程不间断地运行,其次,继续开发工作流程以改进结果。
一个挑战是,这两个职责有截然相反的目标:生产工作流程需要尽可能稳定,而原型可能需要在项目中做出剧烈的改变。解决这个困境的关键是将生产工作流程与原型明确隔离,这样无论原型环境中发生什么,都不会影响生产,反之亦然。
在一个更大的项目中,你可能不仅仅只有一个原型版本和一个生产版本。相反,一组数据科学家可以同时工作在多个原型上。为了测试实验版本,他们可能被部署到与生产版本并行运行的生产环境。总的来说,你可以有任意数量的项目版本在不同的成熟度级别上同时运行。所有项目版本都必须保持整洁的隔离,以确保结果不受任何干扰。
为了使这个想法更加具体,想象一个数据科学应用,比如一个推荐系统,它由一组数据科学家持续开发。团队的使命是通过一个实验漏斗推动实验,如图 6.14 所示。

图 6.14 实验漏斗
在漏斗的顶部,团队有数十或数百种想法来改进系统。团队成员可以在他们的本地工作站上对优先级较高的想法进行原型设计和测试。原型设计的初步结果可以帮助确定哪些想法值得进一步开发——预期并非所有想法都能存活。可能还会进行额外的本地开发轮次。
一旦你有一个完全功能的实验工作流程(或一系列工作流程),通常你会在实际的生产环境中运行一个 A/B 测试,将新版本与当前生产版本进行比较。这要求你同时运行两个或更多测试部署。经过一段时间后,你可以分析结果以确定新版本是否应该升级为新的生产版本。
版本控制层
版本控制层有助于组织、隔离和跟踪所有版本,使其成为管理数百个并发版本和项目的可行方法,这些版本和项目存在于实验漏斗的不同层级。就我们的基础设施堆栈而言,版本控制层有助于确定执行什么版本的代码,如图 6.15 所示。

图 6.15 版本控制层的作用:执行什么代码
自然地,数据科学家需要编写代码,即工作流程的实际业务逻辑。这是(软件)架构层的关心问题,我们将在第八章中讨论。抽象或泛化业务逻辑很难,它往往非常具体于每个项目和用例,因此我们期望数据科学家在开发时能够行使相当大的自由度和责任感。因此,将架构层放置在基础设施堆栈的顶部是有意义的,在那里基础设施应该施加较少的限制。
相比之下,基础设施在版本控制方面可能更有意见——保持事物整洁有序、版本清晰分离不是个人偏好的问题,而是一个组织要求。对每个人来说,使用相同的版本控制方法来促进协作并避免冲突(如之前提到的 Alex 和 Finley 的情况)是非常有益的,这也是基础设施提供内置版本控制层的一个动机。
我们可以总结图 6.15 中突出层的作用,从上到下如下:
-
数据科学家设计和开发业务逻辑,即工作流程的架构(架构层)。
-
基础设施有助于管理多个同时原型设计和部署的业务逻辑版本(版本控制层),以促进实验漏斗。一起,架构层和版本控制层决定了执行什么代码。
-
一个健壮的生产调度器(作业调度层),如 AWS Step Functions,确定特定工作流程 DAG 的执行方式和时间。
-
最后,计算层负责找到服务器实例在哪里可以执行工作流程中的每个任务。
如前所述,将“什么、如何和在哪里”分开的一个主要好处是我们可以独立设计每个子系统。本节展示了 Metaflow 提供的版本层的一个参考实现,但你也可以使用另一个框架或方法来实现相同的目标:构建和运行一个实验漏斗,允许数据科学团队无摩擦地迭代和测试应用程序的各种版本。
构建漏斗没有唯一正确的方法。我们提供了一套工具和知识,帮助你设计并定制一个适合你特定需求的版本控制和部署方法。例如,许多公司已经开发了他们自己的自定义包装脚本或 CI/CD(持续集成/持续部署)管道,利用了下面介绍的机制。我们开始本节,先看看原型化过程中的版本控制,然后是如何实现安全隔离的生产部署。
6.3.1 原型化过程中的命名空间
考虑一下本节开头 Alex 和 Finley 所经历的情景:他们两人都在为同一个项目原型化不同的工作流程。意外的是,Alex 分析了 Finley 的结果而不是自己的。这突出了在原型化过程中保持组织的重要性:每个原型都应该与其他原型保持清晰的隔离。
Metaflow 有一个名为命名空间的概念,有助于保持运行和工件的组织。让我们通过下面的简单工作流程来演示它。
列表 6.7 显示命名空间作用的工作流程
from metaflow import FlowSpec, step, get_namespace
class NamespaceFlow(FlowSpec):
@step
def start(self):
print('my namespace is', get_namespace()) ❶
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
NamespaceFlow()
❶ 打印当前命名空间
将代码保存在 namespaceflow.py 中,并像往常一样执行:
python namespaceflow.py run
你应该看到一个提到你用户名的输出,例如:
[1625945750782199/start/1 (pid 68133)] my namespace is user:ville
现在打开一个 Python 解释器或笔记本,并执行以下行:
from metaflow import Flow
Flow(‘NamespaceFlow').latest_run
这将打印用户命名空间中最新运行的运行 ID。在先前的例子中,它将显示以下内容:
Run('NamespaceFlow/1625945750782199')
注意运行 ID 1625945750782199 与最新执行的运行匹配。你还可以执行 get_namespace()来确认命名空间确实与 flow 使用的相同。
为了证明 latest-run 按预期工作,让我们再次运行 flow,如下所示:
python namespaceflow.py run
现在的运行 ID 是 1625946102336634。如果你再次测试 latest_run,你应该看到这个 ID。
接下来,让我们测试当多个用户一起工作时命名空间是如何工作的。执行以下命令,该命令模拟另一个用户 otheruser 同时运行 flow:
USER=otheruser python namespaceflow.py run
注意,在现实生活中,你不应该显式设置 USER。该变量由你的工作站自动设置。我们在这里显式设置它只是为了演示 Metaflow 在存在多个用户时的行为。
对于这个运行,ID 是 1625947446325543,命名空间是 user:otheruser。图 6.16 总结了每个命名空间中的执行情况。

图 6.16 两位用户的命名空间,每人都有自己的最新运行
现在如果您再次检查最新运行,您将看到它仍然返回 1625946102336634,即维莱的最新运行,而不是其他用户执行的绝对最新的运行 ID。原因是客户端 API 默认尊重当前命名空间:它不会返回所有用户的最新运行,而是返回您的最新运行。
内置命名空间避免了像亚历克斯和芬利遇到的情况:如果你的笔记本使用的是最新运行,而你的同事执行了流程,那么它显示不同的结果将会很令人困惑。通过将元数据和工件名称空间化,我们可以避免这样的惊喜。
除了最新运行之外,您还可以通过它们的 ID 引用任何特定的运行,例如这里:
from metaflow import Run
Run('NamespaceFlow/1625945750782199')
这将有效,因为特定的运行在当前命名空间中。相比之下,尝试以下操作:
from metaflow import Run
Run('NamespaceFlow/1625947446325543')
这将产生以下异常
metaflow.exception.MetaflowNamespaceMismatch: Object not in namespace
➥ 'user:ville'
因为请求的流程当前命名空间中不存在——它属于其他用户。这种行为确保了您不会意外地引用其他人的结果,例如,如果您误输了运行 ID。
注意:默认情况下,客户端 API 允许您检查您生成的运行和工件。其他用户采取的操作不会影响您的客户端 API 返回的结果,除非您明确切换命名空间。特别是,相对引用如最新运行是安全的,因为它指的是当前命名空间中的最新运行,因此其返回值不会意外改变。
切换命名空间
命名空间不是一个安全特性。它们不是为了隐藏信息;它们只是帮助保持事物组织有序。您可以通过切换命名空间来检查任何其他用户的成果。例如,尝试以下操作:
from metaflow import Run, namespace
namespace('user:otheruser')
Run('NamespaceFlow/1625947446325543')
使用命名空间函数切换到另一个命名空间。在命名空间调用之后,客户端 API 访问新命名空间下的对象。因此,可以访问其他用户 1625947446325543 的运行。相应地,
from metaflow import Flow
namespace('user:otheruser')
Flow('NamespaceFlow').latest_run
返回 1625947446325543。正如您所预期的,在这个命名空间中,当访问维莱的运行时,您将得到一个错误。
命名空间调用是一个方便的方法来切换命名空间,例如,在笔记本中。然而,客户端 API 也被用于流程内部以访问来自其他流程的数据。例如,记住第三章中提到的 ClassifierPredictFlow,它使用了以下行来访问最新的训练模型:
@step
def start(self):
run = Flow('ClassifierTrainFlow').latest_run
客户端 API 在流程内部也尊重命名空间。之前的 latest_run 只会返回由你的 ClassifierTrainFlow 训练的模型。现在假设你想要使用你的同事 Alice 训练的模型。你可以在流程代码中添加一行,namespace('user:alice'),以切换命名空间。然而,如果你第二天想尝试另一位同事 Bob 的模型呢?你可以不断更改代码,但有一个更好的方法。无需更改代码,你可以在命令行上使用--namespace 选项切换命名空间,如下所示:
python classifier_predict.py run --namespace user:bob
这使得在不同输入之间切换变得容易,而无需在代码本身中硬编码任何内容。图 6.17 说明了这个想法。

图 6.17 在两个命名空间之间切换
切换命名空间只会改变客户端 API读取数据的方式。它不会改变结果存储的方式——它们始终附加到你的用户名下。从任何命名空间读取是一个安全的操作,因为你不可能意外地覆盖或损坏现有数据。通过默认限制写入到自己的命名空间,你可以确保你的操作不会对其他用户产生不期望的副作用。
注意:使用 namespace 函数或--namespace 选项切换命名空间只会改变客户端 API 读取结果的方式。它不会改变结果写入的方式。默认情况下,它们仍然属于当前用户的命名空间。
全局命名空间
如果你在日志文件中看到一个运行 ID,例如 NamespaceFlow/1625947446325543,但你不知道是谁启动了这次运行?你不知道应该使用哪个命名空间。在这种情况下,你可以通过调用以下命令来禁用命名空间保护:
namespace(None)
之后,你可以无限制地访问任何对象(运行、工件等)。最新的 _run 将指向任何人执行的最新运行,因此其值可能会随时更改。
建议:不要在流程中使用 namespace(None),因为它会使流程暴露于其他人员(甚至可能是你自己,无意中)运行流程所引起的不期望的副作用。它可以是一个探索数据的便捷工具,例如在笔记本中。
6.3.2 生产命名空间
前一节讨论了原型设计中的命名空间。在这种情况下,按用户命名空间运行是自然而然的,因为实际上,总是有一个单一的、明确的用户执行命令运行。但关于生产部署呢?没有人在执行运行,那么我们应该使用谁的命名空间?
Metaflow 为每个未附加到任何用户的、新的生产部署创建一个新的生产命名空间。让我们通过以下方式将列表 6.7 中的 namespaceflow.py 部署到 AWS Step functions,以了解这实际上意味着什么:
python namespaceflow.py step-functions create
你应该看到类似以下的输出:
Deploying NamespaceFlow to AWS Step Functions...
It seems this is the first time you are deploying NamespaceFlow to AWS Step Functions.
A new production token generated.
The namespace of this production flow is
production:namespaceflow-0-fyaw
To analyze results of this production flow add this line in your notebooks:
namespace("production:namespaceflow-0-fyaw")
If you want to authorize other people to deploy new versions of this flow to AWS Step Functions, they need to
call step-functions create --authorize namespaceflow-0-fyaw
如输出所示,为部署创建了一个新的唯一命名空间,production:namespaceflow-0-fyaw。如您所见,该命名空间并不绑定到用户,例如我们在原型设计期间使用的 user:ville。
如果您再次运行步骤函数创建,您会注意到生产命名空间不会改变。部署的命名空间绑定到流程名称。除非您通过执行来显式请求新的命名空间,否则它不会改变。
python namespaceflow.py step-functions create --generate-new-token
要查看命名空间的实际操作,让我们在步骤函数上触发一个执行,如下所示:
python namespaceflow.py step-functions trigger
等待一分钟左右,让执行开始。之后,打开一个笔记本或 Python 解释器,并执行以下行。将命名空间替换为 step-functions create 输出的实际唯一命名空间:
from metaflow import namespace, Flow
namespace(‘production:namespaceflow-0-fyaw')
Flow(‘NamespaceFlow').latest_run
您应该看到一个带有 sfn-前缀的长 ID 的运行对象,例如
Run('NamespaceFlow/sfn-72384eb6-2a1b-4c57-8905-df1aa544565c')
生产命名空间的一个关键优势是,您的流程可以安全地使用客户端 API,特别是像.latest_run 这样的相对引用,知道生产部署始终与任何用户在其个人命名空间中本地执行的任何原型设计保持隔离。
授权部署
生产命名空间包括一个重要的安全机制。想象一下,一位新员工查尔斯正在熟悉 Metaflow,并探索各种命令。正如我们之前讨论的,本地原型设计总是安全的,因为结果绑定到查尔斯的个人命名空间。查尔斯也可能测试生产部署并执行以下操作:
python namespaceflow.py step-functions create
查尔斯的本地 namespaceflow.py 版本可能不是生产就绪的,因此通过这种方式,他可能会意外地破坏生产部署。我们希望鼓励实验,因此我们应该确保新员工(或其他人)不必担心意外破坏任何东西。
为了防止发生事故,Metaflow 阻止查尔斯运行默认创建的步骤函数。查尔斯需要知道一个唯一的生产令牌,才能运行该命令。在这种情况下,如果查尔斯真的需要将流程部署到生产环境,他需要联系之前部署过该流程的人,获取令牌,并执行以下操作:
python namespaceflow.py step-functions create --authorize namespaceflow-0-fyaw
--authorize 标志仅适用于第一次部署。在此之后,查尔斯可以像其他人一样继续部署流程。请注意,--authorize 不是一个安全功能。查尔斯也可以自己发现令牌,正如我们很快就会看到的。它只是作为一个对动作的明确确认,这使得无意中造成损害的可能性稍微降低。
6.3.3 使用@project 的并行部署
当你运行 step-functions create 时,一个流程被部署到生产调度器。部署的名称自动根据 FlowSpec 类的名称命名。换句话说,默认情况下,每个流程名称都附有一个精确的生产版本。你可以(在授权后)通过再次运行 step-functions create 来更新部署,但新版本将覆盖旧版本。
正如我们在本节开头讨论的那样,较大的项目可能需要多个并行但隔离的生产部署,例如,为了便于测试流程的新实验版本。此外,一个复杂的应用程序可能由多个流程组成(例如,第三章中的 ClassifierTrainFlow 和 ClassifierPredictFlow),它们应该存在于同一个命名空间中,这样它们可以在彼此之间安全地共享工件。默认情况下,当你部署具有不同名称的两个流程时,将为每个流程生成一个唯一的命名空间。
为了满足这些需求,我们可以使用一个名为 @project 的流程级装饰器。@project 装饰器本身并不做任何事情,但它允许一个流程或多个流程以特殊方式在生产环境中部署。@project 装饰器是一个可选功能,可以帮助组织较大的项目。你可以先不使用它,只保留一个生产版本,然后在需要增长时再添加它。让我们使用列表 6.8 中所示的简单示例来演示这个概念。
列表 6.8 带有 @project 装饰器的流程
from metaflow import FlowSpec, step, project
@project(name='demo_project') ❶
class FirstFlow(FlowSpec):
@step
def start(self):
self.model = 'this is a demo model'
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
FirstFlow()
❶ 使用具有唯一名称的项目装饰器注释流程
将代码保存到 firstflow.py。我们使用 @project 对流程进行了注释,这需要一个唯一的名称。具有相同项目名称的所有流程将使用单个共享命名空间。
让我们看看当你将其部署到步骤函数时会发生什么,如下所示:
python firstflow.py step-functions create
多亏了 @project 装饰器,流程不是以通常的 FirstFlow 名称部署。相反,它被命名为 demo_project.user.ville.FirstFlow。@project 装饰器用于创建并行、具有唯一名称的部署。默认情况下,部署以项目名称(demo_project)和部署流程的用户(user.ville)为前缀。如果另一位团队成员使用此流程运行 step-functions create,他们将获得一个个人、唯一的部署。这允许任何人在生产环境中轻松测试他们的原型,而不会干扰主要的生产版本。
有时实验并没有明确地与单个用户相关联。也许多个数据科学家合作进行联合实验。在这种情况下,将流程作为 分支 部署是自然的。尝试以下操作:
python firstflow.py --branch testbranch step-functions create
它将生成一个名为 demo_project.test.testbranch.FirstFlow 的部署——请注意,名称中不包含用户名。你可以创建任意数量的独立分支。请注意,触发也尊重 --branch。尝试以下操作:
python firstflow.py --branch testbranch step-functions trigger
它将触发 demo_project.test.testbranch.FirstFlow 的执行。
按照惯例,如果你的项目只有一个受推崇的生产版本,你可以使用以下方式部署:
python firstflow.py --production step-functions create
这将产生一个名为 demo_project.prod.FirstFlow 的部署。--production 选项像部署任何其他分支一样部署分支部署——在 --production 中没有特殊的语义。然而,它可以帮助清楚地区分主生产版本和其他实验分支。
除了允许多个并行、隔离的生产部署外,@project 装饰器很有用,因为它在多个流程中创建了一个单一、统一的命名空间。为了测试这个想法,让我们为同一个 @project 创建另一个流程,如下面的代码列表所示。
列表 6.9 同一个 @project 中的另一个流程
from metaflow import FlowSpec, Flow, step, project
@project(name='demo_project')
class SecondFlow(FlowSpec):
@step
def start(self):
self.model = Flow('FirstFlow').latest_run.data.model ❶
print('model:', self.model)
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
SecondFlow()
❶ 访问同一命名空间中的工件
将代码保存到 secondflow.py。你可以通过运行以下命令在本地测试流程:
python firstflow.py run
python secondflow.py run
本地,SecondFlow 中的 latest_run 指的是你个人命名空间中 FirstFlow 的最新运行,在我的情况下,是用户:ville。让我们按照以下方式将 SecondFlow 部署到我们的测试分支:
python secondflow.py -branch testbranch step-functions create
这将部署一个名为 demo_project.test.testbranch.SecondFlow 的流程。值得注意的是,FirstFlow 和 SecondFlow 使用相同的命名空间,在我的情况下,它被命名为 mfprj-pbnipyjz2ydyqlmi-0-zphk。项目命名空间是基于分支名称、项目名称和唯一令牌的哈希值生成的,因此它们看起来有点神秘。
现在,在 Step Functions 上触发以下执行:
python secondflow.py --branch testbranch step-functions trigger
一段时间后,你可以像这样在笔记本或 Python 解释器中检查结果:
from metaflow import namespace, Flow
namespace(None)
print(Flow('SecondFlow').latest_run['start'].task.stdout)
我们使用全局命名空间,因此我们不需要知道我们的 testbranch 所使用的确切命名空间。然而,如果其他人同时运行 SecondFlows,这种做法有点危险。请注意,流程名称 SecondFlow 仍然是相同的:@project 前缀仅用于为生产调度器命名流程。
要看到 @project 的力量,你现在可以在 FirstFlow 中进行编辑,例如,将模型字符串更改为其他内容。你可以像以前一样在本地测试更改,这不会影响生产部署。在你对更改满意后,你可以将改进后的 FirstFlow 以及 SecondFlow 部署到新的分支,比如 newbranch。设置如图 6.18 所示。

图 6.18 三种 @project 分支
当你执行它们时,只有部署到 newbranch 的新工作流会受到更改的影响。testbranch 上的旧版本不受影响。如图 6.18 所示,在这种情况下,我们有三个独立的命名空间:用于原型设计的默认用户命名空间和两个生产分支。
让我们总结一下在本节中学到的内容,结合一个实际的数据科学项目。该项目由多个数据科学家持续开发。他们可以提出数百种改进项目的想法。然而,如果没有在现实的生产环境中测试,我们不知道哪些想法是有效的。我们将这个过程可视化为一个实验漏斗,如图 6.19 所示。

图 6.19 使用 @project 促进实验漏斗
多亏了用户命名空间,数据科学家能够原型化新版本并在本地运行,无需担心它们会相互干扰。命名空间会自动启用所有 Metaflow 运行。一旦他们确定了最有希望的想法,他们可以使用 @project 将其部署到生产环境作为一个自定义 --branch。然后,这些自定义分支可以用来向 A/B 实验提供预测。最后,当一个实验证明了其价值,它可以被提升为新的主要生产版本,使用 --production 部署。
摘要
-
使用集中式元数据服务器有助于跟踪所有项目、用户和生产部署中的所有执行和工件。
-
利用高度可用、可扩展的生产调度器,如 AWS Step Functions,在无需人工监督的情况下按计划执行工作流。
-
使用 @schedule 装饰器使工作流能够自动在预定义的计划上运行。
-
Metaflow 的代码包封装了用户定义的代码和云执行所需的支持模块。
-
使用容器和 @conda 装饰器来管理生产部署中的第三方依赖。
-
用户命名空间有助于隔离用户在其本地工作站上运行的原型,确保原型之间不会相互干扰。
-
生产部署拥有自己的命名空间,与原型隔离。新用户必须获取生产令牌才能将新版本部署到生产环境中,这可以防止意外覆盖。
-
@project 装饰器允许多个并行、隔离的工作流同时部署到生产环境中。
-
使用 @project 在多个工作流之间创建统一的命名空间。
7 处理数据
本章涵盖
-
快速访问大量基于云的数据
-
使用 Apache Arrow 进行高效的内存数据处理
-
利用基于 SQL 的查询引擎对工作流程数据进行预处理
-
为大规模模型编码特征
前五章介绍了如何将数据科学项目从原型发展到生产。我们学习了如何构建工作流程,使用它们在云中运行计算密集型任务,并将工作流程部署到生产调度器。现在我们已经对原型循环和生产部署的交互有了清晰的认识,我们可以回到基本问题:工作流程应该如何消费和产生数据?
与数据交互是所有数据科学应用的关键关注点。每个应用都需要找到并读取存储在某处的输入数据。通常,应用还需要将其输出,如新的预测,写入到同一个系统。尽管在管理数据的不同系统中存在大量的差异,但在这种情况下,我们使用一个常见的术语,数据仓库,来指代所有这些系统。鉴于数据输入和输出的基础性质,将这一关注点置于堆栈的底部似乎是合适的,如图 7.1 所示。

图 7.1 有效的数据科学基础设施堆栈
在堆栈的顶部存在另一个与数据相关的问题:数据科学家应该如何探索、操作和准备要输入到模型中的数据?这个过程通常被称为特征工程。本章关注堆栈的底部和顶部数据,尽管我们更倾向于底层关注点,这些更明显地属于通用基础设施领域。
值得注意的是,本章不是关于构建或设置数据仓库的,这是一个极其复杂的话题,由许多其他书籍涵盖。我们假设你已经有一些形式的数据仓库,也就是说,某种存储数据的方式已经就位。根据你公司的规模,数据仓库的性质可能会有很大的不同,如图 7.2 所示。

图 7.2 从小型到大型公司各种数据基础设施
如果你只是进行原型设计,你可以通过使用本地文件开始,例如,使用第三章中介绍过的 IncludeFile 加载的 CSV 文件。大多数公司使用像 Postgres 这样的数据库来存储它们宝贵的数据资产。中等规模的公司可能会使用多个数据库来满足不同的需求,可能还会配备一个联邦查询引擎,如 Trino(也称为 Presto),它提供了一种统一的方式来查询所有数据。
一家大型公司可能有一个基于云的数据湖,拥有多个查询引擎,如 Apache Flink 用于实时数据,Apache Iceberg 用于元数据管理,以及 Apache Spark 用于通用数据处理。如果你对这些系统不熟悉,请不要担心——我们将在第 7.2.1 节中给出现代数据架构的高级概述。在这些所有情况下,数据科学家面临相同的关键问题:如何在他们的工作流程中访问数据。
除了需要与不同的技术解决方案集成外,数据科学基础设施还需要支持不同的 数据模式。本章的例子主要关注 结构化数据,即关系型或表格数据源,这是商业应用中最常见的数据模式。此外,基础设施可能还需要支持处理 非结构化数据 的应用程序,如文本和图像。在实践中,这些天许多现实世界的数据集介于两者之间——它们是 半结构化 的。它们包含一些结构,例如严格遵循模式的列,以及一些非结构化字段,例如 JSON 或自由格式文本。本章重点关注数据仓库和数据模式中普遍存在的数据相关问题,具体如下:
-
性能—鉴于数据科学应用程序往往数据密集型,也就是说,它们可能需要摄入大量数据,加载数据很容易成为工作流程中的瓶颈。等待可能长达数十分钟或更长时间的数据可能会使原型设计循环非常痛苦,这是我们想要避免的。我们将在第 7.1 节中关注这个问题。
-
数据选择—如何找到和选择与任务相关的数据子集。SQL 是选择和过滤数据的通用语言,因此我们需要找到与能够执行 SQL 的查询引擎(如 Spark)接口的方法。这些解决方案通常也适用于半结构化数据,或者用于非结构化数据的元数据。这些主题是第 7.2 节的主题。
-
特征工程—如何将原始数据转换为适合建模的格式,也就是特征转换。一旦我们摄入了一块原始数据,在数据能够有效地输入模型之前,我们需要解决许多问题。我们将在第 7.3 节中探讨这个深奥的主题。
这些基础问题适用于所有环境。本章为你提供了具体的构建块,你可以使用它们来设计数据访问模式,也许还有你自己的辅助库,这些库适用于你的特定环境。或者,你最终可能会使用一些更高级的库和产品,例如用于特征工程的 特征存储,它抽象了许多这些问题。在了解了基础知识之后,你将能够更有效地评估和使用这些抽象,正如我们将在第 7.3 节中讨论的那样。
数据访问的另一个正交维度是应用程序需要多频繁地响应数据变化。与前面的章节类似,我们关注批量处理,即最多每 15 分钟运行一次的应用程序。对于需要更频繁更新的许多数据科学应用来说,流数据的话题当然也很相关,但进行此类操作所需的基础设施更为复杂。我们将在下一章简要介绍这个话题。正如我们将在第 7.2 节中讨论的,许多涉及实时数据的数据科学应用仍然可以建模为批量工作流程。
在所有这些维度之上,我们还有组织上的关注点:谁应该对数据科学应用中使用的数据负责,以及责任如何在不同的角色之间分配——特别是数据工程师和数据科学家。尽管确切答案高度依赖于公司,但在第 7.2 节中我们分享了一些高级观点。
我们将本章从一项基本技术问题开始:如何在工作流程中有效地加载数据。下一节中介绍的工具为本章的其余部分提供了坚实的基础。你可以在此处找到本章的所有代码列表:mng.bz/95zo。
7.1 快速数据的基础
亚历克斯开发了一个工作流程,用于估算纸杯蛋糕订单的配送时间。为了训练估算器,亚历克斯需要从公司的主要数据仓库中摄取所有历史纸杯蛋糕订单。令人惊讶的是,从数据库加载数据所需的时间比构建模型本身还要长!经过调查问题后,鲍伊意识到机器学习工作流程需要比之前主要由仪表板使用的路径更快的数据访问方式。新的快速数据路径极大地提高了亚历克斯的生产力:现在每天可以训练和测试至少 10 个模型版本,而不是仅仅两个。

当本书的作者对 Netflix 的数据科学家进行非正式调查,询问他们在日常工作中面临的最大痛点是什么时,大多数人回答:寻找合适的数据并在他们的数据科学应用中访问它。我们将在下一节回到寻找数据的问题。本节重点探讨一个看似简单的问题:你应该如何将数据集从数据仓库加载到你的工作流程中?
这个问题可能看起来非常战术性,但它具有深远、战略性的影响。为了讨论方便,假设你无法轻松快速(或根本无法)将数据集从数据仓库加载到单独的工作流程中。出于必要性,你将不得不在数据仓库系统中内部构建模型和其他应用程序逻辑。
事实上,这一直是关于数据仓库的传统思维方式:大量数据不应该被移动出去。相反,应用程序通过 SQL 等表达他们的数据处理需求,数据仓库执行这些需求,并返回作为结果的小数据子集。尽管这种方法对于传统的商业智能来说是有道理的,但在 SQL 中构建机器学习模型并不是一个可行的想法。即使你的数据仓库支持使用 Python 等查询数据,基本问题仍然是这种方法将计算层,我们在第四章中讨论过的,与数据层紧密耦合。当工作负载非常计算密集时,这是一个问题:没有主流数据库是为在自动扩展的 GPU 实例集群上运行而设计的。
相反,如果能够有效地从仓库中提取大量数据,就可以实现数据与计算的解耦。这对于既需要数据又需要计算的数据科学应用来说是个好消息。正如在第四章中提倡的,你可以为每个任务选择最佳的计算层,最重要的是,你可以让数据科学家自由迭代和实验,而无需担心崩溃共享数据库。一个缺点是,控制数据使用变得更加困难——我们将在下一节回到这个问题。
当考虑耦合与解耦方法的优缺点时,记住数据科学应用——尤其是机器学习——的行为与传统分析和商业智能用例不同是很重要的。这种差异在图 7.3 中得到了说明。

图 7.3 对比分析和机器学习应用之间的数据流
一个传统的商业分析应用,比如一个 Tableau 仪表板,通常生成一个非常复杂的 SQL 查询,数据仓库执行这个查询以返回一个小的、仔细过滤的结果给仪表板。相反,一个机器学习应用表现出相反的行为:它呈现一个简单的查询来摄取,例如,一个完整的数据表,select * from table,然后将其输入到机器学习模型中。
因此,机器学习应用可能会遇到两个问题。首先,数据仓库在执行提取大量数据的简单查询时可能出奇地低效,因为这些查询已经被优化为相反的模式。其次,由于同样的原因,用于与数据仓库接口的客户端库在加载大量数据时通常效率很低。
虽然许多现实世界的应用程序显示的查询模式介于两种极端之间,但在许多应用程序中,加载数据是主要的性能瓶颈。数据科学家可能需要等待数十分钟才能加载数据,这严重影响了他们的生产力。移除这种生产力瓶颈是有效数据科学基础设施的关键目标,因此,在接下来的章节中,我们将探讨一种替代的、极其高效的数据访问方法。这种方法适用于许多现代数据仓库,并允许您将数据与计算解耦,以及明确划分数据科学家和数据工程师之间的工作。
7.1.1 从 S3 加载数据
如果问一个数据科学家他们希望如何访问数据集,假设他们只考虑个人生产力,一个典型的回答是“本地文件”。本地文件在以下方面极大地提高了生产力:
-
它们可以非常快速地加载—从本地文件加载数据比执行 SQL 查询要快。
-
数据集不会突然改变—这是有效原型设计的关键。如果底层数据未宣布改变,就无法进行系统性的实验和迭代。
-
易用性—加载数据不需要特殊的客户端,它不会在同事进行实验时随机失败或变得不可预测地缓慢,并且本地文件几乎可以由所有现成的库使用。
不幸的是,本地文件的缺点很多:它们与云端的实际部署或扩展实验不兼容,并且需要手动更新。此外,它们让数据仓库管理员感到不适,因为它们超出了数据安全和治理策略的控制范围。
使用基于云的对象存储,如 AWS S3,可以提供两全其美的解决方案:将数据存储在云端使其与云端的计算、部署和数据治理策略兼容。通过一些努力,正如下面将要展示的,我们可以使用户体验几乎与访问本地文件一样无缝。特别是,许多人对此事实感到惊讶:从基于云的对象存储(如 S3)加载数据可能比从本地文件加载数据更快。
为了展示基于 S3 的数据在实际中的应用,并验证前面的陈述是否真的正确,让我们创建一个简单的流程,如列表 7.1 所示,以基准测试 S3。该列表演示了一个基本操作:从文件到 Python 进程的内存加载数据,并比较从本地文件和 S3 中的文件加载数据的性能。
为了测试,我们使用 Common Crawl(commoncrawl.org)数据集的一个样本数据,这是一个由随机网页组成的公共数据集。数据集的详细信息并不重要。值得注意的是,你可以将本节中的经验同样应用于非结构化数据,如图像或视频,或结构化、表格数据。如果你想,你可以将下一个列表中的数据集 URL 替换为你可以在 S3 中访问的任何其他数据集。
列表 7.1 S3 基准测试
import os
from metaflow import FlowSpec, step, Parameter, S3, profile, parallel_map
URL =
➥ 's3://commoncrawl/crawl-data/CC-MAIN-2021-25/segments/1623488519735.70/wet/'❶
def load_s3(s3, num): ❷
files = list(s3.list_recursive([URL]))[:num] ❸
total_size = sum(f.size for f in files) / 1024**3
stats = {}
with profile('downloading', stats_dict=stats): ❹
loaded = s3.get_many([f.url for f in files]) ❺
s3_gbps = (total_size * 8) / (stats['downloading'] / 1000.)
print("S3->EC2 throughput: %2.1f Gb/s" % s3_gbps)
return [obj.path for obj in loaded] ❻
class S3BenchmarkFlow(FlowSpec):
local_dir = Parameter('local_dir',
help='Read local files from this directory')
num = Parameter('num_files',
help='maximum number of files to read',
default=50)
@step
def start(self):
with S3() as s3: ❼
with profile('Loading and processing'):
if self.local_dir: ❽
files = [os.path.join(self.local_dir, f)
for f in os.listdir(self.local_dir)][:self.num]
else:
files = load_s3(s3, self.num) ❽
print("Reading %d objects" % len(files))
stats = {}
with profile('reading', stats_dict=stats):
size = sum(parallel_map(lambda x: len(open(x, 'rb').read()),
➥ files)) / 1024**3 ❾
read_gbps = (size * 8) / (stats['reading'] / 1000.)
print("Read %2.fGB. Throughput: %2.1f Gb/s" % (size, read_gbps))
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
S3BenchmarkFlow()
❶ 可在 S3 中找到的公共 Common Crawl 数据集
❷ 从 S3 加载数据的辅助函数
❸ 从给定的 S3 目录中选择前 num 个文件
❹ 在 stats 中收集加载操作的计时信息
❺ 将文件从 S3 加载到临时本地文件
❻ 返回临时文件路径
❽ S3 作用域管理临时文件的生命周期
如果指定了参数 local_dir,则从本地目录加载文件;否则,从 S3 加载
❽ 并行读取本地文件
将代码保存到名为 s3benchmark.py 的文件中。如果你在笔记本电脑上运行它,你可以先下载一小部分数据,如下所示:
# python s3benchmark.py run --num_files 10
这将下载大约 1 GB 的数据,并打印出达到的 S3 吞吐量统计信息。
流程分为两部分:首先,如果没有指定--local_dir,它将调用 load_s3 辅助函数来列出给定 URL 上的可用文件,并选择其中的前 num 个。在创建文件列表后,它将使用 Metaflow 内置的 S3 客户端 metaflow.S3 的 get_many 函数并行下载文件,我们将在 7.1.3 节中更详细地介绍这个函数。该函数返回包含下载数据的本地临时文件路径列表。with S3 上下文管理器负责在上下文退出后清理临时文件。
其次,流程在内存中读取本地文件的正文。如果指定了--local_dir,则从给定的本地目录读取文件,该目录应包含 S3 中文件的本地副本。否则,读取下载的数据。在两种情况下,文件都使用 parallel_map 并行处理,这是 Metaflow 提供的一个便利函数,用于在多个 CPU 核心上并行化一个函数。在这种情况下,我们只是计算读取的字节数,并在读取文件后丢弃它。这个基准测试只测量加载数据花费的时间——我们不需要以任何方式处理数据。
如果你好奇想使用--local_dir 选项基准测试本地磁盘性能,你可以按照以下方式将文件从 S3 下载到本地目录:
# aws s3 cp --recursive
➥ s3://commoncrawl/crawl-data/CC-MAIN-2021-25/segments/1623488519735.70/wet/
➥ local_data
注意,这将需要 70 GB 的磁盘空间。一旦文件下载完成,你可以按照以下方式运行流程:
# python s3benchmark.py run --num_files 10 --local_dir local_data
如果你测试笔记本电脑上的 S3 下载速度,你最终主要是在基准测试你的本地网络连接性能。正如第二章所讨论的,更好的选择是使用基于云的工作站,这可以加快所有云操作,无论你的本地带宽如何。
为了更好地了解实际的 S3 性能,可以在云工作站上运行流程,或者使用我们在第四章中讨论的基于云的计算层。例如,你可以使用 AWS Batch 如下运行:
# python s3benchmark.py run --with batch:memory=16000
当在大型 EC2 实例上运行时,你应该看到如下结果:
PROFILE: Loading and processing starting
S3->EC2 throughput: 21.3 Gb/s
Reading 100 objects
Read 11GB. Throughput: 228.2 Gb/s
PROFILE: Loading and processing completed in 5020ms
注意 S3 存储桶及其中的数据在物理上位于某个特定区域。建议在存储桶所在区域运行计算,以获得最佳性能并避免支付数据传输费用。例如,本例中使用的 commoncrawl 存储桶位于 AWS 区域 us-east-1。
图 7.4 显示了--num_files 选项作为性能的函数。

图 7.4 从本地文件加载时间与数据集大小作为函数的关系
黑色线表示从 S3 加载数据时的总执行时间,灰色线表示从本地文件加载数据时的执行时间。当数据集足够小,这里指小于 12 GB 左右时,使用本地文件稍微快一些。对于更大的数据集,从 S3 加载数据确实更快!
这个结果有一个重要的前提:S3 性能高度依赖于执行任务的实例的大小和类型。图 7.5 说明了这种影响。

图 7.5 以实例大小为函数从 S3 和内存中加载数据
一个非常大的实例,如带有 384 GB RAM 和 48 个 CPU 核心的 m5n.24xlarge,在从 S3 加载数据时具有巨大的吞吐量:20-30 Gb/s,如图中灰色条所示。这比最近 Macbook 笔记本电脑上的本地磁盘带宽(可读取高达 20 Gb/s)要高。中等大小的实例如 c4.4xlarge 显示的带宽只有 1.5 Gbps,尽管仍然比典型办公室 Wi-Fi 所能达到的带宽要高得多。小型实例如 m4.large 的性能比笔记本电脑慢得多。
建议 当处理大规模数据时,使用大型实例类型是有益的。为了控制成本,你可以使用中等大小的实例作为云工作站,并在计算层(如 AWS Batch)上运行数据密集型步骤。
文件大小很重要
如果你尝试使用自己的数据集重现 S3 吞吐量图,但未能看到接近之前数字的结果,问题可能是文件大小。在 S3 中查找对象是一个相对较慢的操作,耗时约 50-100 毫秒。如果你有很多小文件,查找文件会花费大量时间,这会显著降低吞吐量。S3 的最佳文件大小至少是几十兆字节或更多,具体取决于数据量。
使用磁盘缓存将数据保持在内存中
为了解释图 7.5 中的黑色线条,让我们深入到这个例子中。你可能想知道这个基准是否有意义:load_s3 函数将数据下载到本地临时文件中,然后我们在启动步骤中读取这些文件。因此,我们似乎是在比较从临时本地文件加载数据和从目录中的本地文件加载数据,这两者的速度应该是相同的。
技巧在于,当从 S3 加载数据时,数据应保持在内存中,由操作系统透明地存储在内存中的磁盘缓存中,而无需触及本地磁盘,只要数据集足够小,可以适合内存。图 7.6 说明了这个逻辑。

图 7.6 通过磁盘缓存加载数据比磁盘 I/O 快。
当数据集大小适合内存时,从 S3 的加载通过左侧箭头表示的快速路径进行。当数据集大于可用内存时,一些数据会溢出到本地磁盘,这使得加载数据变得非常慢。这就是为什么 S3 可以比本地磁盘更快:当你运行带有--with batch:memory=16000 的流程时,实例上的全部 16 GB 内存都用于这项任务。相比之下,许多进程都在争夺你的笔记本电脑上的内存,因此,当数据集大小如图 7.4 所示增长时,通常不可能将所有数据保持在内存中。
图 7.5 中的黑色线条显示了数据从磁盘缓存或本地磁盘读取到进程内存的速度。最大的实例 m5n.24xlarge 将所有数据保持在磁盘缓存中,因此读取数据非常快,达到 228 Gbit/s。数据只是在内存位置之间并行复制。相比之下,小型实例 m4.large 太小,无法将数据保持在内存中,因此数据会溢出到磁盘上,读取速度变得相对较慢,仅为 0.4 Gbit/s。
建议:在可行的情况下,选择允许你将所有数据保持在内存中的资源。这会使所有操作的速度大幅提升。
让我们总结一下本节中学到的内容:
-
使用 S3 而不是本地文件是有益的:数据更容易管理,它对在云中运行的任务来说随时可用,并且性能损失可以最小化,甚至可能提升性能。
-
只要我们使用足够大的实例,我们就可以非常快地从 S3 将数据加载到进程的内存中。
-
Metaflow 附带了一个高性能的 S3 客户端,metaflow.S3,它允许数据直接加载到内存中,而无需触及本地磁盘。
这些点构成了我们将要在接下来的章节中建立的基础。在下一节中,我们将将这些知识应用到数据科学家的日常工作中,并探讨如何在任务中有效地加载数据帧和其他表格数据。
7.1.2 与表格数据一起工作
在上一节中,我们仅仅对移动原始字节感兴趣。该讨论适用于从视频到自然语言的所有数据模式。在本节中,我们专注于一种特定类型的数据,即结构化或半结构化数据,这类数据通常以数据框的形式进行操作。这种类型的数据在商业数据科学中极为常见——例如,所有关系型数据库都持有这种类型的数据。
图 7.7 展示了包含员工信息的表格数据集的一个示例。该数据集有三个列,分别是姓名、年龄和角色,以及三行,每行代表一个员工。

图 7.7 将表格数据存储为 CSV 与 Parquet 格式
如图 7.7 所示,我们可以以不同的格式存储数据集。在第三章中,我们讨论了 CSV(逗号分隔值)格式,它是一种简单的文本文件,每行包含一行数据,列之间由逗号分隔。或者,我们也可以以流行的Parquet格式存储相同的数据,这是一种列式存储格式。
在 Parquet 和其他列式格式中,每个数据列都是独立存储的。这种方法提供了一些好处。首先,对于结构化数据,每个列都有一个特定的类型。在这个例子中,姓名和角色是字符串,年龄是整数。每种数据类型都需要以特定的方式进行编码和压缩,因此按列(即按类型)分组数据是有益的。Parquet 文件在数据文件本身中存储了显式模式和其它元数据。相比之下,CSV 文件通过完全忽略模式来解决这个问题——所有内容都变成了字符串,这是 CSV 的一个主要缺点。
第二,由于每个列都是单独存储的,因此可以高效地只加载列的子集——想象一下这样的查询:SELECT name, role FROM table。同样,任何需要处理列的操作,如 SELECT AVG(age) FROM table,也可以快速处理,因为所有相关数据都在内存中连续排列。第三,Parquet 文件以压缩的二进制格式存储,因此占用的存储空间更少,比普通的 CSV 文件传输更快。
使用 Apache Arrow 在内存中读取 Parquet 数据
CSV 文件的一个主要优点是 Python 自带了一个名为 csv 的内建模块,非常适合读取它们。要读取 Parquet 文件,我们需要使用一个名为Apache Arrow的独立开源库。除了作为 Parquet 文件解码器之外,Arrow 还提供了数据的高效内存表示,这使我们能够高效地处理数据——稍后会有更多这方面的例子。
让我们实际比较一下 CSV 和 Parquet。为了测试,我们使用了纽约市出租车委员会的公开行程数据(mng.bz/j2rp),这些数据已经以公开的 Parquet 文件形式存储在 S3 上。对于我们的基准测试,我们使用了一个月的数据,包含大约 1300 万行,每行代表一次出租车行程。该数据集有 18 列,提供了关于行程的信息。
基准测试,如图 7.2 所示,比较了 CSV 和两种使用 Arrow 或 pandas 加载 Parquet 文件的方式所花费的时间。代码如下:
-
开始步骤加载一个包含出租车行程的 Parquet 文件,该文件存储在公共 S3 桶中,并将其作为本地文件 taxi.parquet 提供。它使用 pandas 将 Parquet 文件转换为 CSV 文件,并将其保存为 taxi.csv。我们将使用这两个文件在后续步骤中基准测试数据加载。
-
在 start 步骤之后,我们将其分为三个独立的数据加载步骤,每个步骤基准测试加载数据集的不同方式。每个步骤将加载数据所花费的时间保存到 stats 工件中如下:
-
load_csv 步骤使用 Python 内置的 csv 模块遍历所有行,从 CSV 文件中读取。
-
load_parquet 步骤使用 PyArrow 在内存中加载数据集。
-
load_pandas 步骤使用 pandas 在内存中加载数据集。
-
-
最后,join 步骤打印前一步骤测量的计时。
列表 7.2 比较数据格式
import os
from metaflow import FlowSpec, step, conda_base, resources, S3, profile
URL = 's3://ursa-labs-taxi-data/2014/12/data.parquet' ❶
@conda_base(python='3.8.10',
libraries={'pyarrow': '5.0.0', 'pandas': '1.3.2'})
class ParquetBenchmarkFlow(FlowSpec):
@step
def start(self):
import pyarrow.parquet as pq
with S3() as s3:
res = s3.get(URL) ❷
table = pq.read_table(res.path) ❸
os.rename(res.path, 'taxi.parquet') ❹
table.to_pandas().to_csv('taxi.csv') ❺
self.stats = {} ❻
self.next(self.load_csv, self.load_parquet, self.load_pandas)
@step
def load_csv(self):
with profile('load_csv', stats_dict=self.stats): ❼
import csv
with open('taxi.csv') as csvfile:
for row in csv.reader(csvfile): ❽
pass ❾
self.next(self.join)
@step
def load_parquet(self):
with profile('load_parquet', stats_dict=self.stats):
import pyarrow.parquet as pq
table = pq.read_table('taxi.parquet') ❿
self.next(self.join)
@step
def load_pandas(self):
with profile('load_pandas', stats_dict=self.stats):
import pandas as pd
df = pd.read_parquet('taxi.parquet') ⓫
self.next(self.join)
@step
def join(self, inputs):
for inp in inputs:
print(list(inp.stats.items())[0]) ⓬
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
ParquetBenchmarkFlow()
❶ 存储为一个 Parquet 文件的出租车数据一个月
❷ 从 S3 下载 Parquet 文件
❸ 使用 Arrow 在内存中加载 Parquet 文件
❹ 将 Parquet 文件移动到持久位置,以便我们稍后加载
❺ 将数据集写入 CSV 文件,以便我们稍后加载
❻ 将分析统计信息存储在这个字典中
❼ 将计时信息存储在字典中
❽ 使用内置的 csv 模块读取 CSV 文件
❾ 抛弃行以避免过度消耗内存
❿ 使用 Arrow 加载 Parquet 文件。这次,我们计时操作。
⓫ 使用 pandas 加载 Parquet 文件
⓬ 打印每个分支的计时统计信息
将代码保存在 parquet_benchmark.py 中。出于基准测试的目的,此流程将 Parquet 文件和 CSV 文件作为本地文件存储,因此此流程必须在笔记本电脑或云工作站上运行,以便所有步骤都可以访问文件。按照以下方式运行流程:
# python parquet_benchmark.py --environment=conda run --max-workers 1
我们使用--max-workers 1 强制分支按顺序而不是并行执行,这确保了更无偏的计时。执行 start 步骤需要一段时间,因为它从 S3 下载一个 319MB 的压缩 Parquet 文件并将其写入本地 CSV 文件,该文件展开为 1.6GB。
提示:当迭代像 ParquetBenchmarkFlow 这样的流程时,该流程在开始处有一个昂贵的步骤,例如本例中的 start,记得使用 resume 命令:而不是使用 run,这需要一段时间,你可以使用,例如,resume load_csv 并继续迭代后续步骤,同时跳过缓慢的开始。
你应该看到如下输出,显示计时以毫秒为单位:
('load_csv', 19560)
('load_pandas', 1853)
('load_parquet', 973)
结果也显示在图 7.8 中。

图 7.8 比较 Arrow、pandas 和 CSV 之间的数据加载时间
load_parquet 步骤是目前最快的,加载 1300 万行数据只需不到一秒钟!load_pandas 步骤需要两倍的时间来将 Parquet 文件读入 pandas DataFrame。我们在 load_csv 步骤上有点作弊,因为它几乎需要 20 秒,因为它不像其他步骤那样将数据保留在内存中。它只是迭代一次行。如果我们保留数据在内存中——你可以通过 list(csv.reader(csvfile)) 尝试——该步骤需要 70 秒,并消耗近 20 GB 的内存。
建议:在可行的情况下,使用 Parquet 格式存储和传输表格数据,而不是 CSV 文件。
希望这些结果能让你相信,使用 Parquet 而不是 CSV 几乎总是更胜一筹,主要例外是与其他系统和可能无法处理 Parquet 的人共享少量数据。此外,与简单的文本 CSV 文件相比,使用标准命令行工具检查 Parquet 文件并不那么容易,尽管存在可以将 Parquet 文件内容导出到文本文件的工具。现在我们知道了如何加载存储为 Parquet 文件的表格数据,下一步就是考虑其上的组件。
7.1.3 内存数据堆栈
使用 Parquet 而不是 CSV 是不言而喻的,但一个人应该如何在 Arrow 和 pandas 之间做出选择?幸运的是,你不必做出选择:这些工具通常是相当互补的。图 7.9 清晰地说明了构成内存数据堆栈的库是如何相互配合的。

图 7.9 处理内存中数据的现代堆栈
Parquet 是一种 数据存储格式:它是一种比使用 CSV 文件更有效地存储和传输数据的方式。例如,你可以使用 metafow.S3 库非常快速地将 Parquet 文件从 S3 加载到你的工作流程中,正如我们之前所看到的。为了使用这些数据,我们需要从 Parquet 文件中加载和解析它,这是 Arrow 库的工作。Arrow 支持多种语言——其 Python 绑定称为 PyArrow。Arrow 将数据从 Parquet 文件解码成数据的高效 内存表示,可以直接使用或通过另一个库,如 pandas。
这是 Arrow 的一个超级功能:其内存表示已经被设计成可以被其他 数据处理库,如 pandas 或 NumPy 利用,这样它们就不必对数据进行另一次复制,这在处理大型数据集时是一个巨大的优势。这意味着你的 用户代码,例如使用 ML 库进行模型训练的步骤,可以以非常内存和时间高效的方式读取由 Arrow 管理的数据,可能通过 pandas。值得注意的是,所有数据管理都是由高效的底层代码执行的,而不是直接由 Python 执行,这使得在 Python 中开发极高性能的代码成为可能。
是否使用 pandas 或 NumPy 或直接使用 PyArrow 库取决于具体的使用情况。pandas 的一个主要优点是它提供了许多易于使用的数据操作原语,因此如果你的任务需要此类功能,将 Arrow 转换为 pandas 或使用 pd.read_parquet 是一个好的选择。
pandas 的一个主要缺点是它可能非常占用内存,正如我们稍后将会看到的,并且它的性能不如纯 Arrow 操作。因此,如果你使用一个可以接受 Arrow 数据或 NumPy 数组的 ML 库,避免转换为 pandas 可以节省大量时间和内存。我们将在 7.3 节中看到一个实际例子。
为什么使用 metaflow.S3?
如果你之前使用过 Arrow 或 pandas,你可能知道它们支持直接从 s3:// URLs 加载数据。那么为什么图 7.9 提到了 metaflow.S3*?目前,使用 metaflow.S3 加载数据集(由多个文件组成)比使用内置在 Arrow 和 pandas 中的 S3 接口要快得多。原因很简单:metaflow.S3 在多个网络连接上积极并行下载,这对于最大吞吐量是必需的。
很可能库将来会实现类似的方法。一旦发生这种情况,你就可以用库原生方法替换图和代码示例中的 metaflow.S3 部分。图片中的其他所有内容保持不变。
分析内存消耗
当处理大量内存中的数据时,内存消耗通常比执行时间更令人关注。在先前的例子中,我们使用了 with profile 上下文管理器来计时各种操作,但如果我们想以类似的方式测量内存消耗怎么办?
测量内存消耗随时间的变化不如查看计时器那么直接。然而,通过利用一个现成的库 memory_profiler,我们可以创建一个实用函数,实际上是一个自定义装饰器,你可以使用它来测量任何 Metaflow 步骤的峰值内存消耗,如下一列表所示。
列表 7.3 内存分析装饰器
from functools import wraps
def profile_memory(mf_step): ❶
@wraps(mf_step) ❷
def func(self):
from memory_profiler import memory_usage ❸
self.mem_usage = memory_usage((mf_step, (self,), {}),
max_iterations=1,
max_usage=True,
interval=0.2) ❹
return func
❶ 定义了一个 Python 装饰器——一个返回函数的函数
❷ @wraps 装饰器有助于创建一个表现良好的装饰器
❸ 使用 memory_profile 库来测量内存消耗
❹ 将峰值内存使用存储在名为 mem_usage 的工件中
如果你之前没有在 Python 中创建过装饰器,这个例子可能看起来有点奇怪。它定义了一个函数,profile_memory,它接受一个参数 mf_step,这是被装饰的 Metaflow 步骤。它将步骤包装在一个新的函数 func 中,该函数调用库 memory_profiler 来执行步骤并在后台测量其内存使用情况。分析器返回峰值内存使用量,并将其分配给一个工件,self.mem_usage。
将代码保存到文件中,名为 metaflow_memory.py。现在,在任何流程中,你都可以通过在文件顶部写入 from metaflow_memory import profile_memory 来导入新的装饰器。你还必须确保内存分析库可用,这可以通过在@conda_base 字典中添加'memory_profiler': '0.58.0'来实现。现在你可以使用@profile_memory 装饰任何要分析步骤。例如,你可以通过编写以下内容来增强列表 7.3:
@profile_memory
@step
def load_csv(self):
...
将装饰器添加到每个分支。要打印内存消耗,可以使用以下 join 步骤:
@step
def join(self, inputs):
for inp in inputs:
print(list(inp.stats.items())[0], inp.mem_usage)
self.next(self.end)
为了在 load_csv 步骤中获得 CSV 内存消耗的实际情况,你应该通过使用 list(csv.reader(csvfile))而不是丢弃行的 for 循环来在内存中保留所有行。请注意,这将需要一个超过 16 GB RAM 的工作站。
你可以像往常一样运行 parquet_benchmark.py。除了计时外,你还会看到打印出的峰值内存消耗,如图 7.10 所示。

图 7.10 比较 Arrow、pandas 和 CSV 之间的内存开销
如预期的那样,将所有 CSV 数据作为内存效率低下的 Python 对象保存在内存中是非常昂贵的——load_csv 步骤消耗了近 17 GB 的 RAM,这比 Arrow 对相同数据的有效内存表示多出 10 倍以上。pandas 比 Arrow 多消耗一个 GB,因为它需要维护一些 Python 友好的对象表示,尤其是字符串。
建议:如果内存消耗是一个问题,请避免将单个行作为 Python 对象存储。转换为 pandas 也可能很昂贵。如果可能的话,最有效的方法是使用 Arrow 和 NumPy。
到目前为止,我们已经开发了从 S3 到数据高效内存表示的构建块。结合高内存实例(例如@resources (memory=256000)),你可以在单个任务中高效地处理大量数据集。然而,如果你的数据集比任何合理实例能处理的大,或者合适的数据集不存在但必须通过过滤和连接多个表来创建呢?在这种情况下,最好依赖剩余的数据基础设施,特别是经过实战考验的查询引擎,从任意数量的原始数据中创建适合数据科学工作流程的数据集。
7.2 与数据基础设施接口
亚历克斯的交货时间估算器证明是成功的。因此,产品团队要求亚历克斯为特定产品类别构建更多细粒度的模型。这需要更多的数据预处理:亚历克斯需要为每个类别提取正确的数据子集,并尝试各种列的组合,以产生每个类别的最佳估计。鲍伊建议亚历克斯可以在 SQL 中完成所有数据预处理,因为数据库应该擅长处理数据。亚历克斯通过指出使用 Python 迭代模型及其输入数据要快得多来反驳这一观点。最后,他们达成了一项愉快的妥协:亚历克斯将使用 SQL 提取合适的数据集,并使用 Python 定义模型的输入。

数据科学工作流程并非孤立存在。大多数公司都有现有的数据基础设施,这些基础设施已经建立起来以支持从分析到产品功能的各种用例。所有应用程序都依赖于一组一致的数据,由集中的数据基础设施管理是有益的。数据科学和机器学习也不例外。
尽管数据科学工作流程依赖于相同的数据输入,即与第 7.3 节中讨论的相同事实,但工作流程访问和使用数据的方式通常与其他应用程序不同。首先,它们倾向于访问大量数据提取——数十或数百吉字节的数据——用于训练模型,而仪表板可能一次只显示几千字节精心挑选的数据。其次,数据科学工作流程通常比其他应用程序计算密集得多,需要单独的计算层,正如第四章所讨论的。
本节展示了如何通过利用我们在上一节中学到的技术,将数据科学工作流程集成到现有的数据基础设施中。除了移动数据的技术问题外,我们还触及了一个组织问题,即如何在主要工作在数据基础设施上的数据工程师、主要工作在数据科学基础设施上的机器学习工程师和数据科学家之间分配工作。
7.2.1 现代数据基础设施
本书是关于数据科学基础设施的,即用于原型设计和部署利用各种优化或训练技术构建模型以服务于各种用例的数据密集型应用程序所需的基础设施。在许多公司中,数据科学基础设施有一个兄弟堆栈:数据基础设施堆栈。
由于这两个堆栈都处理数据,并且通常都使用 DAG 来表示将输入数据转换为输出数据的流程,人们可能会想知道这两个堆栈之间实际上是否有任何区别。我们难道不能也使用数据基础设施来进行数据科学吗?本书认为,与数据工程相关的活动在质量上与数据工程不同,这为并行堆栈提供了合理性。模型构建需要特殊的库,通常需要更多的代码,并且肯定比数据工程需要更多的计算。然而,保持两个堆栈紧密一致是有益的,以避免冗余解决方案和不必要的运营开销。

图 7.11 现代数据基础设施组件
为了更好地理解如何集成堆栈,让我们首先考虑现代数据基础设施的组件,如图 7.11 所示。该图的结构是,最基础的组件位于中心,而更高级、可选的组件位于外层。
-
数据—在最核心的位置,是数据资产本身。此图并未说明数据是如何获取的,这是一个复杂的话题,但我们假设你有一些数据,比如存储为 CSV 文件、Parquet 文件,或者作为数据库中的表。
-
持久化存储—虽然你可以使用 USB 闪存驱动器进行存储,但更可取的是依赖于更持久的存储系统,如 AWS S3 或复制的数据库。一个选择是现代数据湖,即在 S3 这样的通用存储系统上存储(Parquet)文件,并辅以 Apache Hive 或 Iceberg 这样的元数据层,以方便通过查询引擎访问数据。
-
查询引擎—查询引擎接收一个查询,例如一个 SQL 语句,它通过选择、过滤和连接来表示数据的一个子集。传统的数据库,如 Postgres,以及数据仓库,如 Teradata,将前三层紧密耦合在一起,而像 Trino(原名 Presto)或 Apache Spark 这样的新系统则是与底层存储系统松散耦合的查询引擎。对于流数据,可以使用 Apache Druid 或 Pinot 等系统。
-
数据加载和转换—提取、转换和加载(ETL)数据是数据工程的核心活动。传统上,数据在加载到数据仓库之前会被转换,但像 Snowflake 或 Spark 这样的新系统支持提取-加载-转换(ELT)范式,其中原始数据首先被加载到系统中,然后作为受祝福的数据集进行转换和精炼。如今,像 DBT (getdbt.com)这样的工具可以用来更容易地表达和管理数据转换。可以使用 Great Expectations (greatexpectations.io)等工具来确保数据质量。
-
工作流程编排器——ETL 管道通常表示为 DAG,类似于我们在本书前面讨论的数据科学工作流程。相应地,这些 DAG 需要由工作流程编排器如 AWS Step Functions 或 Apache Airflow,或 Apache Flink(用于流数据)执行。从工作流程编排器的角度来看,数据科学工作流程和数据工作流程之间没有区别。实际上,通常有益于使用一个集中的编排器来编排所有工作流程。
-
数据管理——随着数据量、种类和对有效性的需求增加,通常还需要另一层数据管理组件。数据目录,如 Lyft 的 Amundsen,可以更容易地发现和组织数据集。数据治理系统可用于执行安全性、数据生命周期、审计和血缘以及数据访问策略。数据监控系统有助于观察所有数据系统的整体状态、数据质量和 ETL 管道。
从核心向外构建数据基础设施是有意义的。例如,一个研究生可能只关心存储在他们笔记本电脑上的 CSV 文件中的数据集。初创公司可以从耐用的存储和基本的查询引擎中受益,如下一节中介绍的 Amazon Athena。拥有专门数据工程师的成熟公司还需要一个稳固的 ETL 管道设置。随着公司成长为大型跨国企业,他们还将添加一套强大的数据管理工具。
相应地,数据与数据科学基础设施之间的集成随着时间的推移而增长。图 7.12 突出了这些关系。独立于数据基础设施运行的层用虚线表示。虚线框突出了数据科学的特点:我们需要一个专门的计算层,能够执行要求高的数据科学应用程序和模型。

图 7.12 将数据科学堆栈与数据基础设施接口
相比之下,其他层通常从与数据基础设施的接口中受益如下:
-
数据仓库——在前一节中,我们学习了与存储为 Parquet 文件的原始数据以及持久存储系统 S3 交互的有效模式。在下一小节中,我们将学习如何与查询引擎接口。
-
作业调度器——我们在第二章中介绍的工作流程编排系统同样适用于数据管道 DAG 以及数据科学 DAG。因为这些 DAG 通常相互连接——例如,你可能希望在上游数据更新时启动模型训练工作流程——因此,在同一个系统上执行它们是有益的。例如,你可以使用 AWS Step Functions 来安排数据科学以及数据工作流程。
-
版本控制——假设您的数据目录支持数据集的版本控制,维护从上游数据集到使用这些数据构建的模型的数据血缘是有益的。例如,您可以通过存储一个数据版本标识符,指向数据目录,作为 Metaflow 工件来实现这一点。
-
模型运维——数据的变化是数据科学工作流程中失败的一个常见原因。除了监控模型和工作流程外,能够监控源数据也是有利的。
-
特征工程——正如我们将在 7.3 节中讨论的,在设计模型的新特征时,了解可用的数据是很方便的,这正是数据目录可以派上用场的地方。一些数据目录也可以作为特征存储使用。
具体来说,集成可以采取 Python 库的形式,这些库编码了数据的标准访问模式。许多现代数据工具和服务都附带 Python 客户端库,例如下一节中介绍的AWS Data Wrangler,它可以用于此目的。与一般的数据基础设施一样,没有必要在第一天就实现所有组件和集成。随着需求的增长,您可以逐步添加集成。
在数据科学家和数据工程师之间划分工作
公司规模越大,从原始数据到模型的路程就越长。随着数据量、种类和有效性的要求增长,要求一个人负责所有这些是不合理的。许多公司通过雇佣专门的数据工程师来解决这一问题,他们专注于所有与数据相关的工作,以及专注于建模的数据科学家。
然而,数据工程师和数据科学家之间的界限并不清晰。例如,如果三个表包含模型所需的信息,谁负责创建一个可以输入数据科学工作流程的联合表或视图?从技术角度来看,数据工程师是开发甚至复杂的 SQL 语句和优化连接的专家,所以他们可能应该做这件事。另一方面,数据科学家对模型及其需求了解得最准确。此外,如果数据科学家想要迭代模型和特征工程,他们不需要每次在数据集中进行哪怕微小的更改时都去打扰数据工程师。
正确答案取决于组织、资源以及涉及的数据工程师和数据科学家的技能组合的具体需求。图 7.13 提出了一种经过验证的工作划分方法,这种方法在两个角色之间非常清晰地划分了责任。

图 7.13 定义数据工程师和数据科学家之间的接口
数据工程师负责数据获取,即收集原始数据、数据质量和任何必要的转换,以便将数据作为广泛可消费、权威、精心管理的数据集提供。在结构化数据的情况下,数据集通常是具有固定、稳定模式的表格。值得注意的是,这些上游数据集应专注于事实——尽可能接近直接可观察的原始数据的数据——将数据的解释留给下游项目。组织不应低估这个角色的需求或低估其价值。这个角色直接通过原始数据暴露于现实世界的混乱之中,因此他们在隔离组织其余部分免受其影响方面发挥着关键作用。相应地,所有下游项目的有效性都取决于上游数据的质量。
数据科学家专注于构建、部署和运营数据科学应用。他们对每个项目的具体需求有深入了解。他们负责根据上游表创建特定项目的表格。由于他们负责创建这些表格,因此可以根据需要独立迭代它们。
对于特定项目的表格,对有效性和稳定性的要求可以更宽松,这取决于每个项目的需求,因为这些表格与由同一数据科学家或小型科学家团队管理的特定工作流程紧密耦合。在处理大型数据集时,如果数据科学家可以影响表格的布局和分区,这也是有益的,这将在前一段中讨论的将表格摄入工作流程时产生巨大的性能影响。
关键的是,这种安排只有在数据基础设施,尤其是查询引擎足够健壮,能够管理次优查询的情况下才能有效。我们不能假设每个数据科学家也都是世界级的数据工程师,但仍然方便让他们独立执行和安排查询。从历史上看,许多数据仓库在次优查询下很容易崩溃,因此让非专家运行任意查询是不可行的。现代数据基础设施应该能够隔离查询,这样这个问题就不再是问题。
数据工程的具体细节不在此书的讨论范围之内。然而,与数据科学家工作相关的问题则属于讨论范围,因此在下一段落中,我们将探讨一个人如何编写特定项目的 ETL,与查询引擎交互,作为数据科学工作流程的一部分。
7.2.2 在 SQL 中准备数据集
在本节中,我们将学习如何使用查询引擎,如 Trino 或 Apache Spark,或数据仓库如 Redshift 或 Snowflake,来准备可以使用我们在上一节中学到的模式高效加载到数据科学工作流程中的数据集。我们将使用图 7.14 中展示的概念,通过一个基于云的托管查询引擎 Athena 来演示,但您也可以使用相同的方法来使用其他系统。

图 7.14 使用基于 S3 的数据湖的查询引擎
首先,我们需要将数据文件加载到 S3 并将其注册为具有合适模式的表,存储在表元数据中。Athena 使用流行的 Apache Hive 格式来存储其元数据。
之后,我们可以开始查询表。我们将创建一个工作流程,将 SQL 查询发送到 Athena,选择原始事实表的子集,并将结果写入新表。这种查询称为 创建表选择(CTAS)。CTAS 查询非常适合我们的需求,因为它们使我们能够使用我们在上一节中学到的快速数据模式从 S3 下载结果。
我们将使用开源库 AWS Data Wrangler (github.com/awslabs/aws-data-wrangler) 来与 Athena 进行接口。AWS Data Wrangler 使得从 AWS 提供的各种数据库和数据仓库中读取和写入数据变得容易。然而,将示例修改为使用提供类似功能的其他客户端库并不困难。
如果您此时不想测试查询引擎,可以跳过本小节,继续下一节。在下一节中,我们将看到如何在工作流程中后处理数据。
在 Athena 上设置表
Amazon Athena 是一个基于 Trino 的无服务器查询引擎,无需预先设置。只需确保您的 IAM 用户或 Batch 角色(在 Metaflow 配置中的 METAFLOW_ECS_S3_ACCESS_IAM_ROLE)已附加名为 AmazonAthenaFullAccess 的策略,这允许执行 Athena 查询。此外,请确保在您的配置中设置了 AWS 区域,例如,通过设置环境变量 AWS_DEFAULT_REGION=us-east-1。
作为测试数据集,我们将使用纽约市出租车行程数据的子集,这是我们首次在列表 7.2 中使用的数据。我们将初始化一个包含一年数据的表,大约有 1.6 亿行,通过月份对表进行 分区。在这种情况下,分区只是将文件组织成目录,允许只读取部分月份的查询完成得更快,因为查询引擎可以跳过整个文件目录。分区需要特定的命名方案,目录以 month= 前缀开头,这就是为什么我们将文件从原始位置复制到遵循所需命名方案的新的 S3 位置。所需的路径结构如下所示:
s3://my-metaflow-bucket/metaflow/data/TaxiDataLoader/12/nyc_taxi/month=11/
➥ file.parquet
直到 /nyc_taxi/ 的前缀在你的情况下可能会有所不同,因为它取决于你的 Metaflow 配置。关键部分是 nyc_taxi 后的后缀,特别是 month=11,它用于分区。
要创建一个表,我们需要一个预定义的模式。我们将通过检查 Parquet 文件中的模式来创建一个模式规范。该表通过一个称为 AWS Glue 的数据目录服务进行注册,该服务与 Athena 紧密集成。列表 7.4 将所有这些操作打包在一个 Metaflow 工作流中,包括下载和上传数据到所需的层次结构、模式定义和表创建。以下是代码的工作方式:
-
开始步骤处理两件事:
-
它将 Parquet 文件复制到 S3 中的分区目录层次结构。这需要创建新的路径名,通过 make_key 工具函数实现。请注意,通过使用 S3(run=self) 初始化 S3 客户端,Metaflow 为具有版本号的文件选择了一个合适的 S3 根目录,该版本号与运行 ID 相关联,这使得我们可以在不担心覆盖先前结果的情况下安全地测试代码的不同版本。结果层次结构的根路径存储在一个名为 s3_prefix 的工件中。
-
它检查 Parquet 文件的模式。我们假设所有文件都有相同的模式,因此只需查看第一个文件的模式即可。Parquet 文件的模式使用与 Athena 使用的 Hive 格式略有不同的名称,因此我们使用 hive_field 工具函数根据 TYPES 映射重命名字段。结果模式存储在一个名为 schema 的工件中。
-
-
配置了适当布局的 Parquet 文件和模式后,我们可以在最终步骤中设置一个表。作为一个初始化步骤,我们创建一个默认名为 dsinfra_test 的数据库。如果数据库已存在,调用将引发异常,我们可以安全地忽略它。之后,我们可以为 Athena 创建一个存储其结果的存储桶并注册一个新表。repair_table 调用确保新创建的分区包含在表中。
在这些步骤之后,表就准备好查询了!
列表 7.4 在 Athena 中加载出租车数据
from metaflow import FlowSpec, Parameter, step, conda, profile, S3
GLUE_DB = 'dsinfra_test' ❶
URL = 's3://ursa-labs-taxi-data/2014/' ❷
TYPES = {'timestamp[us]': 'bigint', 'int8': 'tinyint'} ❸
class TaxiDataLoader(FlowSpec):
table = Parameter('table', ❹
help='Table name',
default='nyc_taxi')
@conda(python='3.8.10', libraries={'pyarrow': '5.0.0'})
@step
def start(self):
import pyarrow.parquet as pq
def make_key(obj): ❺
key = '%s/month=%s/%s' % tuple([self.table] + obj.key.split('/'))
return key, obj.path
def hive_field(f): ❻
return f.name, TYPES.get(str(f.type), str(f.type))
with S3() as s3down:
with profile('Dowloading data'):
loaded = list(map(make_key, s3down.get_recursive([URL]))) ❼
table = pq.read_table(loaded[0][1]) ❽
self.schema = dict(map(hive_field, table.schema)) ❽
with S3(run=self) as s3up: #I
with profile('Uploading data'):
uploaded = s3up.put_files(loaded) ❾
key, url = uploaded[0]
self.s3_prefix = url[:-(len(key) - len(self.table))] ❿
self.next(self.end)
@conda(python='3.8.10', libraries={'awswrangler': '1.10.1'})
@step
def end(self):
import awswrangler as wr
try:
wr.catalog.create_database(name=GLUE_DB) ⓫
except:
pass
wr.athena.create_athena_bucket() ⓬
with profile('Creating table'):
wr.catalog.create_parquet_table(database=GLUE_DB,
table=self.table,
path=self.s3_prefix,
columns_types=self.schema,
partitions_types={'month': 'int'},
mode='overwrite') ⓭
wr.athena.repair_table(self.table, database=GLUE_DB) ⓮
if __name__ == '__main__':
TaxiDataLoader()
❶ 数据库名称——你可以选择任何名称
❷ 在公共存储桶中的纽约出租车数据
❸ 将 Parquet 模式中的某些类型映射到 Glue 使用的 Hive 格式
❹ 定义表名,可选
❺ 符合我们分区方案的 S3 对象密钥(路径)
❻ 将 Parquet 类型映射到 Glue 使用的 Hive 类型
❼ 下载数据并生成新的密钥
❽ 从第一个 Parquet 文件中检查模式并将其映射到 Hive
❾ 将数据上传到 Metaflow 运行特定的位置
❿ 将新的 S3 位置保存到工件中
⓫ 在 Glue 中创建一个新的数据库并忽略由数据库已存在引起的失败
⓬ 初始化一个用于 CTAS 结果的存储桶
⓭ 使用新的位置和模式注册一个新的表
⓮ 请求 Athena 发现新添加的分区
将代码保存到 taxi_loader.py 文件中。运行流程将上传和下载大约 4.2 GB 的数据,因此建议在批处理或云工作站上运行。你可以像平常一样运行流程:
# python taxi_loader.py --environment=conda run
在大型云工作站上,该流程的执行时间应少于 30 秒。
使用 metaflow.S3 进行版本化数据
列表 7.4 上传数据到 S3 而不指定存储桶或显式的 S3 URL。这是可能的,因为 S3 客户端被初始化为 S3(run=self)*,这告诉 Metaflow 默认引用 运行特定位置。Metaflow 根据其数据存储位置创建一个 S3 URL,并在键前加上运行 ID。
此模式在将数据存储在 S3 且需要其他系统可访问的情况下很有用(工作流程内部的数据可以存储为工件)。因为数据是相对于运行 ID 存储的,所以上传的任何数据都会自动进行版本控制,确保每次运行都独立写入或复制数据,从而避免意外覆盖相关结果。之后,如果你需要跟踪某个运行产生的数据,可以根据运行 ID 查找数据,从而维护 数据血缘。
在运行成功完成后,你可以打开 Athena 控制台,确认在数据库 dsinfra_test 下可以找到新的表 nyc_taxi。控制台包括一个方便的查询编辑器,允许你使用 SQL 查询任何表。例如,你可以通过执行 SELECT * FROM nyc_taxi LIMIT 10 来查看数据的小型预览。图 7.15 展示了控制台应有的样子。

图 7.15 在 Athena 控制台上查询出租车数据
如果你可以在控制台中看到表及其列,并且测试查询返回了带有值的行,则该表已准备好使用!接下来,我们将创建一个执行针对该表的 CTAS 查询的流程,这允许我们创建任意数据子集以供工作流程使用。
运行 CTAS 查询
数据科学家应该如何执行特定项目的 SQL 查询?一个选项是使用数据基础设施提供的数据工具,遵循数据工程师使用的最佳实践。这种方法的一个缺点是查询与依赖它的工作流程解耦。可能更好的方法是作为工作流程的一部分执行查询,如下所示。
你可以在 Python 代码中将 SQL 语句作为字符串嵌入,但为了从 IDE 中的正确语法高亮和检查中受益,以及使代码整体更易于阅读,我们可以将它们存储为单独的文件。为了测试这个想法,让我们创建一个查询,该查询选择新创建的 nyc_taxi 表的子集。下面的代码列表显示了一个选择在上午 9 点到下午 5 点之间工作时间内开始的出租车行程的 SQL 语句。
列表 7.5 提取商业时段数据的 SQL 查询
SELECT * FROM nyc_taxi
WHERE hour(from_unixtime(pickup_at / 1000)) BETWEEN 9 AND 17
将此 SQL 语句保存到一个新的子目录 sql 中,文件为 sql/taxi_etl.sql。如果您对时间逻辑感到好奇,pickup_at / 1000 是必需的,因为数据集中的时间戳以毫秒表示,但从 _unixtime 需要秒。现在我们可以编写一个执行查询的流程,如下所示。
列表 7.6 带参数的流程
from metaflow import FlowSpec, project, profile, S3, step, current, conda
GLUE_DB = 'dsinfra_test'
@project(name='nyc_taxi') ❶
class TaxiETLFlow(FlowSpec):
def athena_ctas(self, sql):
import awswrangler as wr
table = 'mf_ctas_%s' % current.pathspec.replace('/', '_') ❷
self.ctas = "CREATE TABLE %s AS %s" % (table, sql) ❸
with profile(‘Running query’):
query = wr.athena.start_query_execution(self.ctas,
➥ database=GLUE_DB) ❹
output = wr.athena.wait_query(query) ❺
loc = output['ResultConfiguration']['OutputLocation']
with S3() as s3:
return [obj.url for obj in s3.list_recursive([loc + '/'])] ❻
@conda(python='3.8.10', libraries={'awswrangler': '1.10.1'})
@step
def start(self):
with open('sql/taxi_etl.sql') as f:
self.paths = self.athena_ctas(f.read()) ❼
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
TaxiETLFlow()
❶ 将流程附加到项目。在下一节中这将很有用。
❷ 根据当前任务的 ID 创建一个结果表名称
❸ 格式化 CTAS SQL 查询并将其存储为自述文件
❹ 将查询提交到 Athena
❺ 等待查询完成
❻ 列出结果集中所有的 Parquet 文件
❼ 格式化并提交查询,并存储生成的 Parquet 文件的 URL
TaxiETLFlow 是一个通用流程,它实现了本节开头图 7.14 中描述的模式。它从一个文件中读取任意 SELECT 语句,sql/taxi_etl.sql,通过在其前面添加 CREATE TABLE 转换为 CTAS 查询,提交查询,等待其完成,并将结果 Parquet 文件的路径作为自述文件存储,以便它们可以轻松地被其他下游流程消费,下一节我们将看到一个例子。
提示:您可以使用当前对象来检查当前正在执行的运行,如图 7.6 所示。如果您想了解当前的运行 ID、任务 ID 或执行运行的用户,这会很有用。
将代码保存到 taxi_etl.py。该流程需要一个额外的 SQL 文件,因此我们使用上一章中讨论的 --package- 后缀选项,将所有 .sql 文件包含在代码包中。按照以下方式运行流程:
# python taxi_etl.py --environment=conda --package-suffixes .sql run
运行完成后,您可以登录到 Athena 控制台并点击“历史”选项卡以确认查询状态,如图 7.16 所示。

图 7.16 Athena 控制台上的查询状态
由于运行和任务 ID 嵌入在表名称中,例如图 7.16 中的 mf_ctas_TaxiETLFlow_1631494834745839_start_1,因此很容易在查询和运行之间建立联系。反之,通过将待执行的查询作为自述文件 self.ctas 存储并其结果存储在另一个自述文件 self.paths 中,我们可以从源数据、处理它的查询到最终输出(例如,工作流程产生的模型)形成一个完整的数据血缘。在调试与预测质量或输入数据相关的问题时,这种血缘关系可能非常有用。
建议:通过在执行的查询中包含运行 ID 并将查询作为自述文件记录,在数据基础设施和数据科学工作流程之间维护数据血缘。
作为练习,你可以将示例修改为与另一个现代查询引擎一起工作。相同的 CTAS 模式适用于 Spark、Redshift 或 Snowflake。你还可以使用我们在上一章中学到的技术,通过生产级调度器定期调度 ETL 工作流程,可能就是数据工程团队使用的同一个调度器。
无论你使用什么查询引擎,重要的是通用能力。使用这种方法,数据科学家可以在他们的工作流程中包含一个特定项目的数据处理步骤,并将大规模数据处理任务卸载到可扩展的查询引擎。在下一节中,我们将看到如何通过利用我们之前学到的水平扩展和快速数据方法来访问即使是大型 CTAS 查询的结果。
清理旧结果
默认情况下,CTAS 查询生成的结果表将永久保留。这对可重复性和可审计性有益,因为你可以检查任何旧结果。然而,在一段时间后删除旧结果是合理的做法。
你可以通过使用S3 生命周期策略来删除 S3 中的旧数据。因为结果只是 S3 中的位置,你可以设置一个策略,在预定时间后(例如 30 天后)删除 CTAS 结果写入位置的所有对象。此外,你还需要从 Glue 中删除表元数据。
你可以通过执行带有 AWS Data Wrangler 的 DROP TABLE SQL 语句或使用以下 AWS CLI 命令来删除 Glue 表:
aws glue batch-delete-table
作为要删除的表的参数列表。
7.2.3 分布式数据处理
在 7.1 节中,我们学习了如何快速将 Parquet 文件从 S3 加载到单个实例。这是一个简单且健壮的模式,适用于可以放入内存的数据集。当与具有数百 GB RAM 的大实例以及 Apache Arrow 和 NumPy 提供的有效内存表示相结合时,你可以处理大规模数据集,在某些情况下,可以处理数十亿个数据点,而无需求助于分布式计算及其带来的开销。
自然,这种方法有其局限性。显然,并非所有数据集都能一次性放入内存。或者,可能在单个实例上对数百 GB 的数据进行复杂处理可能太慢。在这种情况下,将计算扇出到多个实例是一个好主意。在本节中,我们将学习如何以分布式方式加载数据和处理数据,例如 CTAS 查询生成的表。像之前一样,我们将使用 Metaflow 的 foreach 构造来分布,但你也可以使用支持分布式计算的其他框架应用相同的模式。本章学到的经验在以下两个常见于数据科学工作流程的场景中很有用:
-
在每个分支中加载和处理数据的不同子集——例如,与第五章中的 K-means 示例不同,该示例使用相同的数据集来训练所有模型,您可以在每个 foreach 任务中加载不同的子集,例如,针对特定国家的数据。
-
高效的数据预处理——通常在 SQL 中进行基本数据提取后,再在 Python 中进行更高级的预处理,这样做很方便。我们将在本章和第九章的后续部分使用这种模式进行特征工程。
这两种情况的一个共同特点是,我们希望处理分片或分块的数据,而不是一次性处理所有数据。图 7.17 说明了这种高级模式。

图 7.17 工作流中处理分片数据
CTAS 查询生成一组 Parquet 文件,我们可以通过一列(例如,按国家)对其进行分区,或者将它们分成大小大致相等的分片。工作流将 foreach 操作扩展到分片上,将数据处理分布在多个并行步骤中。然后我们可以在连接步骤中合并结果。为了突出这种模式与分布式计算中的MapReduce 范式的相似性,图 7.17 将 foreach 步骤称为Mappers,将连接步骤称为Reduce。为了了解这种模式在实际中的工作原理,让我们通过一个有趣的例子来探讨:出租车数据集的可视化。
示例:可视化大型数据集
以下示例有两种工作模式:它可以读取由 TaxiETLFlow 生成的出租车数据集的任意子集(列表 7.6),或者它可以以原始数据的形式加载数据。如果您在上一节中没有设置 Athena,则可以使用原始数据模式。在两种情况下,我们将提取每辆出租车行程的接车位置的纬度和经度,并在图像上绘制它。
由于数据集中有很多行程,这个任务变得非同寻常。在原始模式下,我们处理 2014 年的所有行程,这相当于 4800 万个数据点。为了展示如何高效地处理这类大型数据集,我们像图 7.17 所示的那样并行进行预处理。
让我们首先编写一个辅助函数,该函数在图像上绘制点。由于数据量很大,我们将使用一个专门的开源库Datashader (datashader.org),该库经过调整,可以高效地处理数百万个点。辅助函数在下一列表中显示。
列表 7.7 绘制出租车行程坐标
from io import BytesIO
CANVAS = {'plot_width': 1000,
'plot_height': 1000,
'x_range': (-74.03, -73.92), ❶
'y_range': (40.70, 40.78)} ❶
def visualize(lat, lon): ❷
from pandas import DataFrame
import datashader as ds
from datashader import transfer_functions as tf
from datashader.colors import Greys9
canvas = ds.Canvas(**CANVAS)
agg = canvas.points(DataFrame({'x': lon, 'y': lat}), 'x', 'y') ❸
img = tf.shade(agg, cmap=Greys9, how='log') ❸
img = tf.set_background(img, 'white')
buf = BytesIO() ❹
img.to_pil().save(buf, format='png') ❹
return buf.getvalue() ❹
❶ 定义下曼哈顿的边界框
❷ 接受两个数组作为点:纬度和经度
❸ 以对数方式着色每个像素绘制点
❹ 将可视化保存为工件
将代码保存到 taxiviz.py 中,我们将在后续的流程中导入。请注意,数据点的数量(高达 4800 万)比图像中的像素数量(100 万)多得多。因此,我们将根据击中每个像素的点数对其进行着色。我们使用对数颜色范围以确保最淡的像素不会被冲淡。类似于前一章中的预测图,我们将结果图像存储为工件,因此它具有版本控制并存储在运行中,可以使用客户端 API 检索并显示,例如在笔记本中。
接下来,让我们实现流程本身——请参见以下列表。该流程实现了图 7.17 中描述的模式。在起始步骤中,我们选择要使用的数据输入:要么是之前执行的 TaxiETLFlow 的 CTAS 查询结果,要么是原始数据。我们将数据划分为分片,每个分片由一个独立的前处理数据任务进行处理。连接步骤将每个分片产生的坐标数组合并在一起,并在图像上绘制坐标。
列表 7.8 带参数的流程
from metaflow import FlowSpec, step, conda, Parameter,\
S3, resources, project, Flow
import taxiviz ❶
URL = 's3://ursa-labs-taxi-data/2014/' ❷
NUM_SHARDS = 4
def process_data(table): ❸
return table.filter(table['passenger_count'].to_numpy() > 1) ❹
@project(name='taxi_nyc') ❺
class TaxiPlotterFlow(FlowSpec):
use_ctas = Parameter('use_ctas_data', help='Use CTAS data', default=False)❻
@conda(python='3.8.10')
@step
def start(self):
if self.use_ctas:
self.paths = Flow('TaxiETLFlow').latest_run.data.paths ❼
else:
with S3() as s3:
objs = s3.list_recursive([URL]) ❽
self.paths = [obj.url for obj in objs]
print("Processing %d Parquet files" % len(self.paths))
n = round(len(self.paths) / NUM_SHARDS)
self.shards = [self.paths[i*n:(i+1)*n] for i in range(NUM_SHARDS - 1)]❾
self.shards.append(self.paths[(NUM_SHARDS - 1) * n:]) ❾
self.next(self.preprocess_data, foreach='shards')
@resources(memory=16000)
@conda(python='3.8.10', libraries={'pyarrow': '5.0.0'})
@step
def preprocess_data(self): ❿
with S3() as s3:
from pyarrow.parquet import ParquetDataset
if self.input:
objs = s3.get_many(self.input) ⓫
orig_table = ParquetDataset([obj.path for obj in objs]).read()⓫
self.num_rows_before = orig_table.num_rows
table = process_data(orig_table) ⓬
self.num_rows_after = table.num_rows
print('selected %d/%d rows'\
% (self.num_rows_after, self.num_rows_before))
self.lat = table['pickup_latitude'].to_numpy() ⓭
self.lon = table['pickup_longitude'].to_numpy() ⓭
self.next(self.join)
@resources(memory=16000)
@conda(python=’3.8.10’, libraries={‘pyarrow’: ‘5.0.0’, ‘datashader’:
➥ ‘0.13.0’})
@step
def join(self, inputs):
import numpy
lat = numpy.concatenate([inp.lat for inp in inputs]) ⓮
lon = numpy.concatenate([inp.lon for inp in inputs]) ⓮
print("Plotting %d locations" % len(lat))
self.image = taxiviz.visualize(lat, lon) ⓯
self.next(self.end)
@conda(python='3.8.10')
@step
def end(self):
pass
if __name__ == '__main__':
TaxiPlotterFlow()
❶ 导入我们之前创建的辅助函数
❷ 在原始数据模式下,使用 2014 年的数据
❸ 一个用于为每个分片预处理数据的 mapper 函数
❹ 例如,我们只包括有超过一名乘客的行程。
❺ 使用与 TaxiETLFlow 相同的工程
❻ 在两种模式之间进行选择:CTAS 或原始数据
❼ 在 CTAS 模式下,检索到 CTAS 结果的路径
❽ 在原始数据模式下,列出原始数据的路径
❾ 将所有路径分组为四个大致相等大小的分片
❿ Mapper 步骤,处理每个分片
⓫ 下载此分片的数据并将其解码为表
⓬ 处理表
⓭ 存储处理表中的坐标
⓮ 连接所有分片的坐标
⓯ 可视化并将结果存储为工件
将代码保存到 taxi_plotter.py。如果您之前运行了 TaxiETLFlow,您可以像这样运行流程:
# python taxi_plotter.py --environment=conda run --use_ctas_data=True
否则,省略该选项以直接使用原始数据。该流程在大实例上运行大约一分钟。运行完成后,您可以在笔记本中打开一个单元格并输入以下行以查看结果:
from metaflow import Flow
from IPython.display import Image
run = Flow('TaxiPlotterFlow').latest_run
Image(run.data.image)
结果应该类似于图 7.18 中的某个可视化。图 7.18 左侧显示了完整数据集的图像。您可以看到 Midtown 地区非常受欢迎(浅色)。右侧的图像是由一个 CTAS 查询生成的,该查询显示了午夜到凌晨 1 点之间的一小时的行程——Midtown 地区以外的许多地区交通稀疏。

图 7.18 在出租车数据集的两个不同子集中可视化接车位置
列表 7.8 中的代码演示了以下三个重要概念:
-
多亏了 @project,我们可以安全地将上游数据处理流程 TaxiETLFlow 与其他下游业务逻辑(如 TaxiPlotterFlow)分离。关键的是,Flow('TaxiETLFlow').latest_run 指的不是 TaxiETLFlow 的任何随机最新运行,而是存在于流程自身命名空间中的最新运行,正如第六章所讨论的那样。这允许多个数据科学家在自己的 TaxiETLFlow→TaxiPlotterFlow 序列版本上工作,而不会相互干扰。
-
process_data 函数展示了映射器概念。在 CTAS 查询中使用 SQL 提取原始数据集之后,数据科学家可以在 Python 中进一步处理数据,而不是必须将所有项目特定的逻辑打包在 SQL 查询中。此外,我们还避免了可能内存效率低下的 pandas 转换。
将 process_data 视为一个无限通用的用户定义函数(UDF),许多查询引擎提供它作为绕过 SQL 限制的逃生门。根据 process_data 的计算成本,可以增加分片数量以加快处理速度。
- 我们避免将完整的数据集作为工件存储,因为这通常会导致不便的缓慢速度。相反,我们只提取所需的数据——在本例中为纬度和经度——作为空间高效的 NumPy 数组并存储它们。合并和操作 NumPy 数组是快速的。
此流程表明,使用现成的库和可扩展的计算层,在 Python 中处理大型数据集是可能的。这种方法的主要好处是操作简单:您可以使用现有的数据基础设施进行初始的重型工作以及 SQL 查询,其余部分可以由数据科学家在 Python 中自主处理。
在下一节中,我们添加数据路径中的最后一步:将数据输入到模型中。我们将利用本节中讨论的模式来有效地执行特征转换。
另一种方法:Dask 或 PySpark
作为分布式数据处理的一种替代方法,您可以使用专门的计算层,如 Dask (dask.org),它为在 Python 中执行类似操作提供了一个高级接口。Dask(或 PySpark)的一个好处是数据科学家可以操作类似 dataframe 的对象,这些对象在底层自动分片和并行化。
一个缺点是在您的基础设施堆栈中引入了另一个操作上非平凡的计算层。当像 Dask 或 Spark 这样的系统运行良好时,它可以大幅提高生产力。当它不起作用时,无论是由于工程问题还是由于与数据科学家想要使用的库不兼容,它可能会成为一个难以调试的头痛问题。
如果您已经有一个可用的 Dask 集群,您可以通过在 Metaflow 步骤中调用它来轻松地将数据处理任务卸载到该集群上。配备了关于各种方法的信息,您可以为您的组织做出正确的选择。
7.3 从数据到特征
拥有一种从数据仓库到工作流加载数据的强大方式,亚历克斯可以开始更系统地考虑如何将原始数据转换为模型所消耗的矩阵和张量。亚历克斯希望快速从数据仓库中加载各种数据子集,并定义一组自定义 Python 函数——特征编码器,将原始数据转换为模型输入。输入矩阵的确切形状和大小取决于用例,因此系统应该足够灵活,能够处理各种需求。

到目前为止,我们讨论了数据处理的基础层次:如何高效地访问和处理原始数据。这次讨论并没有特别涉及数据科学或机器学习。可以说,数据科学通常涉及通用的数据处理,因此这种关注是有道理的。这也是为什么我们将数据作为我们基础设施堆栈中最基础的层次。
在本节中,我们探讨了如何思考数据与模型之间接口的多方面问题。这是一个更高层次的问题,具有更少的普遍适用答案。例如,适用于表格数据的特征工程方法不一定适用于音频或时间序列。
数据科学家领域专业知识的一个重要部分与特征工程相关,因此让他们相对自由地实验和采用各种方法是有益的。在这方面,特征工程与本章前面讨论的基础数据主题或第四章中涵盖的计算层不同。这种区别在图 7.19 中得到了说明,该图最初在第一章中首次提出。

图 7.19 数据科学家对堆栈各层关注的程度
而不是规定一个适合所有情况的特征工程解决方案,每个领域都可以从特定领域的库和服务中受益。
建议:构建、采用或购买针对特征层的特定领域解决方案通常是一个好主意,这些解决方案可以根据手头的用例进行定制。这些解决方案可以建立在基础基础设施之上,因此它们是补充性的,而不是与其他堆栈部分竞争。
我们首先定义了模糊的概念特征。然后我们将提供一个基本示例,该示例将原始数据转换为特征,并将其输入到模型中。在第九章中,我们将扩展这个示例,使其具有更多的特征。这应该为你提供一个坚实的起点,以防你想要深入研究,例如,通过阅读一本更侧重于建模的书籍,详细介绍了特征工程,并将这些经验应用到你的基础设施堆栈中。
7.3.1 区分事实和特征
区分事实和特征是有用的。除了提供概念上的清晰度外,这种做法还有助于在数据工程师和数据科学家之间分配工作。让我们使用图 7.20 来组织讨论。
该图基于一个哲学假设,即一切存在于一个客观的物理现实中,我们可以部分观察以收集事实。由于它们的观察性质,我们预计事实可能会存在偏见、不准确,甚至偶尔错误。然而,关键的是,事实是我们能够接近客观现实的最接近的方式。

图 7.20 事实与特征的本质
从工程角度来看,事实可以是来自产品的事件或从第三方获取的数据。其中不应有太多的解释或歧义:例如,有人点击播放来观看《狮子王》是一个直接的观察——事实。后来,我们可能会决定将播放事件解释为用户更喜欢观看此类内容的信号,这可能是推荐模型的一个有用的标签——一个特征。对于播放事件存在无数其他可能的解释(也许他们在移动设备上误点了大型横幅)和,因此,许多特征工程的机会,尽管基本事实是明确的。事实与特征之间的区别具有重要的实际意义,其中一些在表 7.1 中概述。
表 7.1 比较和对比事实与特征
| 特征 | 事实 | 现实 | |
|---|---|---|---|
| 角色 | 数据科学家 | 数据工程师 | |
| 关键活动 | 定义新特征并挑战现有特征 | 收集并持久化可靠的观察 | |
| 迭代速度 | 快速——提出新的解释很容易 | 慢——开始收集新数据需要大量努力 | |
| 我们能控制它吗? | 完全可以——我们知道并控制所有输入和输出 | 部分可以——我们无法控制输入,现实以不可预测的方式行为 | 不可以,但我们可以进行小的干预,例如 A/B 测试 |
| 可信度 | 变化,默认情况下较低 | 旨在很高 | 客观的真相 |
如表格所示,拥有一个角色,通常是一个数据工程师,负责可靠地收集和存储事实,这是非常有用的。这项任务本身就很复杂,因为他们需要直接与不断变化的现实世界进行交互。一旦获得了一组可靠的事实,另一个人,即数据科学家,就可以使用这些事实,进行解释和转换,将它们转化为特征,在模型中进行测试,并使用一组新的特征进行迭代,以改进模型。
两个角色之间的关键活动是不同的。理想情况下,数据科学家可以快速迭代,因为他们有无限多的可能特征进行测试。事实越准确、越全面,模型就越好,这激励数据工程师收集大量高质量的数据。我们可以将这些活动投影到本章中涵盖的以下模式:
-
数据工程师维护可靠的事实表,这些表对所有项目都是可用的。
-
数据科学家可以使用 CTAS 模式查询事实表,并从他们的工作流程中提取有趣的项目特定事实视图,例如。
-
数据科学家可以使用我们在上一节中介绍过的 MapReduce 风格的模式,在 Python 中快速迭代他们工作流程内的特征。
在某些情况下,利用现成的库和服务,如特征存储或数据标注服务来帮助完成最后两个步骤可能是有用的,或者您可以创建一个满足您公司特定需求的自定义库。在任何情况下,考虑一个不涉及专用工具的简单基线解决方案都是谨慎的。
您可以选择直接使用下一节中介绍的基线方法,或者将其作为您自己领域特定库的基础。无论您采取哪种方法,数据科学家都应该能够轻松访问事实并快速迭代特征。使用 Metaflow 这样的系统来处理版本控制在这个过程中非常有好处——否则,很容易失去对哪些数据和特征产生了最佳结果的追踪。
7.3.2 特征编码
将事实转换为特征的过程称为特征编码或特征化。模型接受一组特征,这些特征有时由数十甚至数百个单独的特征编码函数或特征编码器产生。虽然您可以将特征化代码与建模代码交织在一起,尤其是在较大的项目中,但将特征编码作为单独步骤进行定义和执行的一致方式非常有用。
除了帮助使整体架构可管理之外,在训练和推理过程中使用相同的特征编码器来保证结果正确性是至关重要的。这一要求通常被称为离线-在线一致性,其中离线指的是以批量过程定期训练模型,而在线指的是按需预测。
对于特征管道的另一个核心要求是管理准确的训练和测试分割。在许多情况下,例如在前一章中我们的天气预报示例中,历史数据用于预测未来。这样的模型可以通过将过去某个时间点作为参考,将此之前的作为历史数据用于训练,将此之后的作为模拟未来用于测试来进行回测。为了保证结果的可靠性,训练数据必须不包含任何超过参考点的信息,通常称为泄露,这会被视为从未来获取信息。
一个设计良好的特征编码管道可以将时间视为主要维度,这使得在确保特征编码器尊重时间范围的同时进行回测变得容易,从而防止任何类型的泄露。特征编码管道还可以帮助监控概念漂移,即模型的目标变量统计随时间变化。一些特征存储,这些存储帮助解决所有这些问题,还提供了一个用户界面,允许轻松共享和发现事实和特征。
实施这些关注点并没有唯一正确或普遍适用的方法。在不同数据集中,时间的处理方式不同,而保持在线和离线的一致性可以通过许多不同的方式来实现,这取决于具体的应用场景——并非所有应用程序都需要“在线”预测。一个紧密耦合的数据科学团队在共同开展项目时,可以快速沟通并迭代特征编码器,而无需复杂的解决方案。
一个关键争议在于灵活性和迭代速度与保证正确性之间的权衡:你可以设计(或获取)一个特征化解决方案来解决所有之前的问题,保证正确性,但会使定义新特征变得困难。对于一个敏感、成熟的项目,这可能是一个正确的权衡。另一方面,将过于僵化的解决方案强加于新项目可能会使其难以快速开发,从而限制其整体效用——项目可能 100%正确,但也很无用。一个良好的折衷方案可能是从灵活的方法开始,随着项目的成熟逐渐变得更加严格。
接下来,我们将展示一个非常简单的特征编码工作流程,基于上一节中的 TaxiPlotter 工作流程。这个例子位于灵活性的极端:它没有解决任何之前的问题,但为我们将在第九章中介绍的更完整的特征化管道奠定了基础。
示例:预测出租车行程费用
为了展示特征编码——而不是我们的建模技能——我们构建了一个简单的预测器来估算出租车行程的成本。我们知道价格与行程长度直接相关,无论是时间还是空间。为了简化问题,这个例子只关注距离。我们使用简单的线性回归来预测根据行程距离支付的金额。我们将在第九章使这个例子更有趣。
如果这是一个数据科学家实际接到的任务,他们肯定会首先探索数据,可能是在笔记本中。您也可以作为练习来做这件事。您会发现,与任何包含经验观察的真实生活数据集一样,数据中存在噪声:许多行程的成本为 0 或没有行程距离。此外,少数异常行程的成本或行程距离非常高。
我们首先进行解释,开始将事实转化为特征,假设这些异常行程并不重要。我们使用以下代码列表中的 filter_outliers 函数来去除值分布在最顶部或最底部 2%的行程。实用模块还包含一个名为 sample 的函数,我们可以使用它从数据集中均匀地采样行。
列表 7.9 从 Arrow 表中移除异常行
def filter_outliers(table, clean._fields): ❶
import numpy
valid = numpy.ones(table.num_rows, dtype='bool') ❷
for field in clean_fields: ❸
column = table[field].to_numpy()
minval = numpy.percentile(column, 2) ❹
maxval = numpy.percentile(column, 98) ❹
valid &= (column > minval) & (column < maxval) ❺
return table.filter(valid) ❻
def sample(table, p): ❼
import numpy
return table.filter(numpy.random.random(table.num_rows) < p) ❽
❶ 接受一个 pyarrow.Table 和一个要清理的列列表
❷ 以接受所有行的过滤器开始
❸ 逐个处理所有列
❹ 找到值分布的最顶部和最底部 2%
❺ 仅包括位于值分布 2-98%之间的行
❻ 返回与过滤器匹配的行子集
❼ 从给定的表中采样随机 p%的行
❽ 在每一行上抛一个有偏见的硬币,并返回匹配的行
将代码保存在 table_utils.py 中。值得注意的是,列表 7.9 中的 filter_outlier 和 sample 函数也可以在 SQL 中实现。您可以在 CTAS 查询背后的 SQL 中实现这些操作。考虑一下在 Python 中执行这些操作的好处。首先,在 SQL 中表达 filter_outliers 有些复杂,尤其是在多列上执行时。生成的 SQL 可能比 Python 实现需要更多的(复杂的)代码行。
其次,我们在这里做出了重大假设:2%是正确的数字吗?它应该对所有列都相同吗?数据科学家可能希望对这些选择进行迭代。我们可以在 Python 中迭代和测试代码,速度比执行复杂的 SQL 查询快得多。
还要注意,这两个函数都是在不转换为 pandas 的情况下实现的,这保证了操作既节省时间又节省空间,因为它们只依赖于 Apache Arrow 和 NumPy,这两者都由高性能的 C 和 C++代码支持。这些操作在 Python 中的性能很可能比在任何查询引擎中都要好。列表 7.10 定义了构建线性回归模型并可视化的函数。
列表 7.10 训练和可视化回归模型
def fit(features): ❶
from sklearn.linear_model import LinearRegression
d = features['trip_distance'].reshape(-1, 1) ❷
model = LinearRegression().fit(d, features['total_amount']) ❷
return model
def visualize(model, features): ❸
import matplotlib.pyplot as plt
from io import BytesIO
import numpy
maxval = max(features['trip_distance']) ❹
line = numpy.arange(0, maxval, maxval / 1000) ❹
pred = model.predict(line.reshape(-1, 1)) ❹
plt.rcParams.update({'font.size': 22})
plt.scatter(data=features,
x='trip_distance',
y='total_amount',
alpha=0.01,
linewidth=0.5)
plt.plot(line, pred, linewidth=2, color='black')
plt.xlabel('Distance')
plt.ylabel('Amount')
fig = plt.gcf()
fig.set_size_inches(18, 10)
buf = BytesIO() ❺
fig.savefig(buf) ❺
return buf.getvalue() ❺
❶ 接受作为 NumPy 数组字典的特征
❷ 使用 Scikit-Learn 构建线性回归模型
❸ 可视化模型
❹ 绘制回归线
❺ 将图像保存为工件
将代码保存到 taxi_model.py。fit 函数使用 Scikit-Learn 构建一个简单的线性回归模型。有关详细信息,请参阅 Scikit-Learn 的文档(mng.bz/Wxew)。visualize 函数在散点图上绘制特征,并在其上叠加回归线。
下面的代码片段显示了实际的流程,TaxiRegressionFlow,它紧密基于上一节中的 TaxiPlotterFlow。它具有相同的两种模式:你可以使用由 TaxiETLFlow 生成的 CTAS 查询的预处理数据,或者你可以使用原始模式,该模式访问两个月未过滤的数据。
列表 7.11 从事实到特征再到模型
from metaflow import FlowSpec, step, conda, Parameter,\
S3, resources, project, Flow
URLS = ['s3://ursa-labs-taxi-data/2014/10/', ❶
's3://ursa-labs-taxi-data/2014/11/']
NUM_SHARDS = 4
FIELDS = ['trip_distance', 'total_amount']
@conda_base(python='3.8.10')
@project(name='taxi_nyc')
class TaxiRegressionFlow(FlowSpec):
sample = Parameter('sample', default=0.1) ❷
use_ctas = Parameter('use_ctas_data', help='Use CTAS data', default=False)❸
@step
def start(self):
if self.use_ctas:
self.paths = Flow('TaxiETLFlow').latest_run.data.paths
else:
with S3() as s3:
objs = s3.list_recursive(URLS)
self.paths = [obj.url for obj in objs]
print("Processing %d Parquet files" % len(self.paths))
n = max(round(len(self.paths) / NUM_SHARDS), 1)
self.shards = [self.paths[i*n:(i+1)*n] for i in range(NUM_SHARDS - 1)]
self.shards.append(self.paths[(NUM_SHARDS - 1) * n:])
self.next(self.preprocess_data, foreach='shards')
@resources(memory=16000)
@conda(libraries={'pyarrow': '5.0.0'})
@step
def preprocess_data(self):
from table_utils import filter_outliers, sample
self.shard = None
with S3() as s3:
from pyarrow.parquet import ParquetDataset
if self.input:
objs = s3.get_many(self.input)
table = ParquetDataset([obj.path for obj in objs]).read()
table = sample(filter_outliers(table, FIELDS), self.sample) ❹
self.shard = {field: table[field].to_numpy() ❺
for field in FIELDS}
self.next(self.join)
@resources(memory=8000)
@conda(libraries={'numpy': '1.21.1'})
@step
def join(self, inputs):
from numpy import concatenate
self.features = {}
for f in FIELDS:
shards = [inp.shard[f] for inp in inputs if inp.shard]
self.features[f] = concatenate(shards) ❻
self.next(self.regress)
@resources(memory=8000)
@conda(libraries={'numpy': '1.21.1',
'scikit-learn': '0.24.1',
'matplotlib': '3.4.3'})
@step
def regress(self):
from taxi_model import fit, visualize
self.model = fit(self.features) ❼
self.viz = visualize(self.model, self.features) ❽
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
TaxiRegressionFlow()
❶ 使用原始模式中的两个月数据
❷ 样本给定百分比的数据,0.0-1.0。
❸ 在原始或 CTAS 模式之间进行选择,类似于 TaxiPlotterFlow
❹ 独立地清理和样本每个碎片
❺ 提取干净的列作为特征
❻ 通过连接数组合并特征碎片
❼ 拟合一个模型
❽ 可视化模型并将图像保存为工件
将代码保存在 taxi_regression.py 中。如果你之前运行了 TaxiETLFlow,你可以这样运行流程:
# python taxi_regression.py -environment=conda run -use_ctas_data=True
否则,省略该选项以直接使用原始数据。在大实例上处理 10%的数据样本不到一分钟,处理完整数据集(不采样)大约需要两分钟。图 7.21 可视化了模型生成的回归线以及原始数据的散点图,如图所示在 Jupyter 笔记本中。

图 7.21 在数据上叠加距离与出租车行程成本的回归,如图 7.21 所示
此示例扩展了之前的 TaxiPlotterFlow 示例,展示了以下内容:
-
数据科学家可以使用 MapReduce 风格的模式来编码特征。
-
我们可以通过使用 SQL 进行数据提取和 Python 进行特征工程来结合两者的优点。
-
由于有 Apache Arrow 和 NumPy 等库的支持,这些库由 C 和 C++实现,因此可以在 Python 中以性能意识的方式执行特征工程。
在第九章中,我们将扩展示例以包括对模型的适当测试,使用深度学习的更复杂的回归模型,以及可扩展的特征编码管道。
本章涵盖了大量的内容,从基础的数据访问模式到特征工程管道和模型训练,如图 7.22 所示进行了总结。

图 7.22 总结本章涵盖的概念
我们从快速将数据从 S3 移动到实例的基本原理开始。我们讨论了使用 Apache Arrow 的高效内存数据表示。之后,我们展示了如何使用这些技术来与 Spark、Snowflake、Trino 或 Amazon Athena 等查询引擎接口,它们是现代数据基础设施的核心部分。
我们创建了一个工作流程,使用查询引擎通过执行 Create-Table-As-Select SQL 查询来处理数据集,其结果可以快速下载到下游工作流程。最后,我们利用这一功能创建了一个特征编码管道来训练模型。
结合前一章的教训,这些工具允许您构建生产级的数据科学应用程序,这些应用程序可以从数据仓库中摄取大量数据,并行编码特征,并大规模训练模型。对于计算密集型的特征编码器,如果需要,您可以利用第五章的教训来优化它们。
摘要
-
通过确保数据适合内存、文件足够大以及使用大型实例类型来优化 S3 和 EC2 实例之间的下载速度。
-
使用 Parquet 作为存储表格数据的有效格式,并使用 Apache Arrow 在内存中读取和处理它。
-
如果内存消耗是一个问题,避免将数据转换为 pandas。相反,使用 Arrow 和 NumPy 数据结构进行操作。
-
利用现有的数据基础设施从数据科学工作流程中提取和预处理数据,并将它们连接到 ETL 工作流程。
-
使用现代查询引擎,如 Spark、Trino、Snowflake 或 Athena,执行 SQL 查询以生成任意数据提取,存储在 Parquet 中,供数据科学工作流程使用。
-
在组织上,数据工程师可以专注于生产高质量、可靠的事实,而数据科学家可以自主迭代项目特定的数据集。
-
使用 MapReduce 模式在 Python 中并行处理大型数据集。像 Arrow 和 NumPy 这样的库由高性能的 C/C++代码支持,使得快速处理数据成为可能。
-
利用基础工具和模式构建一个适用于您特定用例的解决方案——特征工程和特征管道往往具有相当强的领域特定性。
-
在设计特征管道时,考虑使用时间作为主要维度,以便于使用历史数据进行回测,并防止信息泄露。
-
在特征工程管道中,确保数据科学家可以轻松访问事实并快速迭代特征。
8 使用和操作模型
本章涵盖
-
使用机器学习模型生成对现实应用有益的预测
-
作为批量工作流程生成预测
-
作为实时应用生成预测
为什么企业会投资于数据科学应用?“为了生成模型”并不是一个充分的答案,因为模型只是数据代码的集合,没有内在价值。为了产生有形价值,应用必须对周围世界产生积极影响。例如,一个推荐模型在孤立状态下是无用的,但连接到用户界面后,它可以降低客户流失并增加长期收入。或者,一个预测信用风险的模型在连接到供人类决策者使用的决策支持仪表板时变得有价值。
在本章中,我们弥合了数据科学和商业应用之间的差距。尽管这是本书的第二章倒数第二章,但在实际项目中,你应该尽早开始考虑这种联系。图 8.1 使用第三章中引入的螺旋图来阐述这一想法。

图 8.1 将输出连接到周围系统
通常,首先彻底了解需要解决的商业问题是明智的。之后,你可以识别和评估可用于解决问题的数据资产。在编写任何建模代码之前,你可以选择一个架构模式,允许结果连接到一个价值生成业务应用,这是本章的主要内容。
通常,思考输入和输出会揭示问题,例如缺乏合适的数据或在使用结果时遇到的技术或组织困难,这些问题可以在构建任何模型之前得到解决。一旦明确了应用在其环境中的部署方式,你就可以开始实际建模工作。如果项目成功,例如 Netflix 的个性化视频推荐,建模工作永远不会结束:数据科学家会年复一年地不断改进模型。
模型可以用无数种方式在生产中使用。公司越来越渴望将数据科学应用于商业的各个方面,从而产生多样化的需求。理论上,给定一个模型,有一种明确的方式来生成预测。然而,技术上,这与一个在高速交易系统中产生实时预测的系统大不相同,比如,为内部仪表板填充少量预测。
注意,我们使用术语预测来指代数据科学工作流程的任何输出。严格来说,并非所有数据科学应用都会产生预测——它们还可以产生分类、分类、推理和其他见解。为了简洁起见,我们使用预测作为一个总称,来指代任何此类输出。对于此类活动,另一个常用的术语是模型服务——我们希望使模型可供其他系统使用,即提供模型服务。
我们在本章的开头,描述了多种常见的架构模式,用于使用模型进行预测。然后,你可以根据你的用例选择并应用最合适的模式。我们重点关注两种常见的模式:预先计算结果,即所谓的批预测,以及通过网络服务实时进行相同的操作,即所谓的实时预测。这些技术与我们在前几章中学到的所有课程都是互补的。
本章的后半部分专注于模型操作,即如何确保模型在一段时间内持续产生正确的结果。我们学习了持续重新训练和测试模型的模式,以及监控模型性能的模式。与推理类似,没有一种正确的方法或工具来实现模型操作,但了解通用模式有助于你为每个用例选择正确的工具。
图 8.2 总结了我们在基础设施堆栈上的这些主题。

图 8.2 基础设施堆栈
通过将模型操作和架构放在堆栈的较高位置,我们表明这些关注点不能从数据科学家那里抽象出来——更不用说与第四章中涵盖的计算层相比。整体数据科学应用的架构以及它在生产中的操作,应遵循要解决的问题的业务问题。理解应用程序从原始数据到业务结果的端到端工作方式,使数据科学家能够充分利用他们的领域知识和建模专业知识。自然有效的数据科学基础设施应该足够简单,以至于无需成为 DevOps 专家就可以应用各种模式。你可以在这个章节中找到所有代码列表,请访问mng.bz/8MyB。
8.1 生成预测
受到 Alex 模型效率提升的鼓舞,Caveman Cupcakes 在行业内首次投资了一个全自动蛋糕工厂。Alex 和 Bowie 已经计划了几个月如何将 Alex 的机器学习模型的输出连接到为设施提供动力的各种控制系统。一些系统需要实时预测,而其他系统则每晚优化。在模型和工业自动化系统之间设计一个强大且通用的接口并不简单,但这项努力是值得的!精心设计的设置允许 Alex 像以前一样开发和测试模型的更好版本,Bowie 可以全面观察生产系统的健康状况,Harper 可以大幅扩展业务。

从理论上讲,生成预测应该是相当直接的。例如,使用逻辑回归模型进行预测只是计算点积并通过 sigmoid 函数传递结果的问题。除了公式在数学上简单之外,这些操作在计算上并不特别昂贵。
在现实生活中使用模型的相关挑战是实际的,而不是理论上的。你需要考虑如何管理以下方面:
-
规模—尽管生成单个预测可能不需要很长时间,但在短时间内生成数百万个预测则需要更多的思考。
-
变化—数据会随时间变化,模型需要重新训练和重新部署。此外,应用程序代码也可能随时间演变。
-
集成—要产生实际影响,预测需要被另一个系统使用,而这个系统本身也面临着规模和变化的挑战。
-
失败—前三个问题都是失败案例的丰富来源:系统在负载下会失败,变化会导致预测随时间变得不准确,集成难以保持稳定。每当有东西失败时,你需要了解它是什么失败了,为什么失败了,以及如何快速修复它。
单独解决这四个问题中的任何一个都是一个非平凡的工程挑战。同时解决所有这些问题需要大量的工程努力。幸运的是,只有少数数据科学项目需要为所有这些担忧提供完美的解决方案。考虑以下典型的例子:
-
一个用于平衡营销预算的模型可能需要特殊的营销平台集成,但数据规模和变化速度可能适中。失败也可以手动处理,而不会造成重大破坏。
-
一个推荐模型可能需要处理数百万个用户和数百万个物品,但如果系统偶尔失败,用户可能不会注意到推荐稍微有些过时。
-
一个高频交易系统必须在不到一毫秒的时间内生成预测,每秒处理数十万次预测。由于模型直接负责数千万美元的利润,因此为托管模型的底层基础设施大规模超额配置是划算的。
-
人类决策者使用的信用评分模型规模适中,且不会迅速变化,但它需要高度透明,以便人类可以防范任何偏见或其他细微的失败。
-
训练以识别地名的语言模型不会迅速变化。该模型可以作为一个静态文件共享,仅偶尔更新。
单一的模式无法解决所有这些用例。有效的数据科学基础设施可以提供几种不同的模式和工具,以逐步加固部署,如第六章所述,确保每个用例都可以以最简单的方式解决。由于使用模型为现实生活应用提供动力的固有复杂性,避免在系统中引入额外的意外复杂性至关重要。例如,没有必要或有益于仅为了每天生成少量预测而部署一个可扩展、低延迟的模型托管系统。
您的工具箱还可以包括现成的工具和产品,其中许多在过去几年中涌现出来,以解决之前描述的挑战。令人困惑的是,这些新工具没有既定的命名法和分类,这使得有时难以理解它们应该如何最有效地使用。通常被归类为 MLOps(机器学习运维)的工具类别包括以下内容:
-
模型监控 工具有助于应对变化,例如,通过监控输入数据和预测随时间的变化分布,以及当预测超出预期范围时发出警报。
-
模型托管和部署 工具通过将模型作为微服务集群部署,以便外部系统查询,从而提供实时预测的解决方案。
-
特征存储 通过提供处理输入数据的一致方式来应对变化,确保数据在训练时间和预测过程中都得到一致使用。
除了特定于机器学习和数据科学的工具之外,通常可以利用通用基础设施,例如微服务平台来托管模型或仪表板工具来监控模型。这是一个有用的方法,尤其是在这些工具已经安装在你的环境中时。
在以下章节中,我们将介绍一个思维框架,帮助您为您的用例选择最佳模式。之后,我们将通过一个实际操作示例来展示这些模式的应用。
8.1.1 批量、流式和实时预测
当考虑如何有效地使用模型时,你可以从以下核心问题开始:在知道输入数据后,我们需要多快就能得到预测?请注意,这个问题不是模型可以多快产生预测,而是我们最多可以等待多长时间直到预测被用于某事。图 8.3 说明了这个输入-响应差距问题。

图 8.3 输入-响应差距
我们可以在输入数据已知(显然不是在之前)并且在外部系统需要预测之前,任何时候决定生成预测,如图 8.3 中的大箭头所示。输入-响应差距越长,我们选择何时以及如何生成预测的自由度就越大。
为什么这个问题很重要?直观上,很明显,快速产生答案比慢速产生答案更困难。差距越窄,规模、变化、集成和故障的挑战就越严峻。虽然技术上支持低延迟的系统也可以处理高延迟,因此可能会诱人使用单个低延迟系统来处理所有用例,但避免过度设计可以使生活更加轻松。
根据答案,我们可以为用例选择合适的基础设施和软件架构。图 8.4 展示了基于差距大小的三种常见的模型服务系统类别:批量,其中差距通常以分钟计,甚至更长;流,可以支持分钟级别的差距;以及实时,当需要秒或毫秒级的响应时。

图 8.4 模型服务系统类别
批量预测方法最容易实现——所有系统都有足够的余量来运行——但这种方法仅适用于你能够承受在 15 分钟或更长时间后使用预测的情况。在这种情况下,你可以将所有新的输入数据汇总到一个大批次中,并安排一个工作流每小时或每天运行一次,以生成批次的预测。结果可以持久化存储在数据库或缓存中,以便外部系统可以快速访问,从而提供非常低的访问预计算预测的延迟。
使用工作流调度器来频繁地生成预测并不太实用,因为典型的工作流调度器并没有针对最小延迟进行优化。如果你需要更快的结果,在接收到输入数据点后的 30 秒到 15 分钟内,你可以使用流平台,例如 Apache Kafka,也许还可以配合 Apache Flink 这样的流应用程序平台。这些系统可以大规模接收数据,并在几秒钟内将其传递给数据消费者。
流式模型的例子可能是一个视频服务中的“观看下一个”推荐。假设我们希望在之前的视频结束时立即显示个性化的新视频推荐。我们可以在播放前一个节目时计算推荐,因此我们可以容忍几分钟的延迟。这允许我们在用户提前停止节目时仍然显示推荐。
最后,如果你需要在几秒钟内得到结果,你需要一个用于实时模型服务的解决方案。这类似于任何需要在使用者点击按钮后立即(在几十毫秒内)产生响应的 Web 服务。例如,支持互联网广告的公司使用此类系统来即时预测用户最有效的个性化横幅广告。
另一个重要的考虑因素是外部系统如何消费预测:工作流程是否会将结果推送到数据存储,还是消费者会从服务中拉取它们?图 8.5 概述了这些模式——请注意箭头的方向。

图 8.5 分享输出的模式
生成批量预测的工作流程可以将预测发送到外部 API 或数据库。然后,其他业务应用,包括仪表板,很容易从数据库中访问预测。相同的模式适用于流式预测,关键的区别在于预测刷新得更频繁。注意箭头的方向:批量和流式将预测推送到外部系统。外部系统不能直接调用它们。
相比之下,实时模型服务系统反转了模式:外部系统从模型中拉取预测。当输入数据在预测之前变得可用时,需要这种方法。例如,考虑一个基于用户刚刚访问的网站列表生成定向广告的模型。在这种情况下,预先计算所有可能的网站组合是不切实际的。预测必须在实时进行。
模式的选择具有深远的影响,如下所述:
-
在本地开发和测试批量预测很容易。你可以使用与训练模型相同的流程系统进行批量预测,因此不需要额外的系统。相比之下,开发和测试流式和实时预测需要额外的基础设施。
-
批量预测的数据处理,有时在流式处理中,可以遵循第七章中概述的模式。确保特征在训练和预测中保持一致相对容易,因为可以在双方使用相同的数据处理代码。相比之下,实时预测需要一个支持低延迟查询以及低延迟特征编码器的数据存储。
-
批处理预测的扩展与训练扩展一样简单,使用第四章中概述的计算层。自动扩展流和实时系统需要更复杂的架构。
-
在批处理系统中,准确监控模型、记录和管理故障更容易。一般来说,确保批处理系统保持高可用性相比流或实时系统,产生的运营开销更小。
总的来说,首先考虑该应用程序或其部分是否可以由批处理预测提供动力是有益的。
注意批处理并不意味着在需要时无法快速访问预测。事实上,作为批处理过程预先计算预测并将其推送到高性能数据存储,如内存缓存,可以提供最快的预测访问。然而,批处理要求输入提前很好地了解。
在接下来的几节中,我们将通过一个实际用例来展示三种模式:一个电影推荐系统。我们将从对推荐系统的一个简要介绍开始。然后,我们将展示如何使用推荐模型进行批处理预测,然后是如何使用它来提供实时预测。最后,我们将概述流预测的高级架构。
8.1.2 示例:推荐系统
让我们构建一个简单的电影推荐系统,以了解模式在实际应用中的工作原理。想象一下,你在一家人工智能流媒体视频的初创公司工作。这家初创公司目前没有在生产中部署任何机器学习系统。你被雇佣为数据科学家,为公司构建一个推荐模型。然而,你不想仅仅停留在模型上。为了使模型(以及你自己)变得有价值,你希望将你创建的推荐模型集成到公司的实时产品中,以便在用户界面中提供新的“为您推荐”功能。
从零开始构建这样一个系统需要一些努力。我们在图 8.6 中概述了一个项目计划,该计划概述了本节我们将关注的主题。我们遵循螺旋式方法,所以第一步将是详细了解该功能的业务背景。对于这个练习,我们可以跳过这一步(在现实生活中你不应该这样做!)。我们首先熟悉可用的数据,并概述一个基本的建模方法,如果项目显示出希望,我们可以在以后改进它。

图 8.6 推荐系统项目:关注输入
我们将使用一种称为协同过滤的知名技术来开发模型的第一版。这个想法很简单:我们知道现有用户过去观看过哪些电影。通过知道新用户观看的一些电影,我们可以找到与该新用户相似的用户,并推荐他们喜欢的电影。对于这种类型的模型,关键问题是确切地定义“相似”是什么,以及如何快速计算用户之间的相似度。
为了训练模型,我们将使用公开可用的 MovieLens 数据集(grouplens.org/datasets/movielens/)。从mng.bz/EWnj下载包含 2700 万条评分的完整数据集,并解压存档。
便利的是,数据集包括对每部电影丰富的描述,称为标签基因组。图 8.7 说明了这个概念。在图中,每部电影由两个维度来表征:戏剧与动作,以及严肃与幽默。

图 8.7 电影标签基因组
我们在类似的向量空间中表征电影,但实际标签基因组包括 1,128 个维度,在 genome-tags.csv 中描述。在这个 1,128 维空间中每部电影的坐标列在 genome-scores.csv 中。要了解电影 ID 与电影名称之间的映射,请参阅 movies.csv。
我们知道每个用户观看的电影以及他们为电影分配的星级评分。这些信息包含在 ratings.csv 文件中。我们只想推荐用户喜欢的电影,因此我们只包括用户评分达到四星或五星的电影。现在,我们根据用户喜欢的电影类型来描述每个用户,换句话说,一个用户(向量)是他们喜欢的电影向量的总和。图 8.8 说明了这个概念。

图 8.8 三个关键矩阵
为了在标签基因组空间中将每个用户表示为一个向量,我们采用用户矩阵,它告诉我们用户喜欢哪些电影,并使用它来对电影矩阵中的向量进行求和,该矩阵代表每部电影。如果我们知道一些用户喜欢的电影,我们可以用同样的方法将任何新用户表示为一个向量。我们将每个用户向量归一化到单位长度,因为我们不希望观看电影的数量产生任何影响——只有电影的性质应该被计算在内。
图 8.8 中所示的操作在列表 8.1 中实现,我们将其用作实用模块。列表中的大部分代码用于从 CSV 文件加载数据并进行转换。电影矩阵通过包含每个电影基因组 _dim(1,128)行数的 CSV 文件从 load_model_movies_mtx 加载。我们将文件分成固定大小的向量,并存储在以电影 ID 为键的字典中。
加载用户矩阵稍微复杂一些,因为每个用户观看的电影数量是可变的。根据我们在第五章中学到的知识,该章节强调了 NumPy 数组的性能,以及第七章中展示的 Apache Arrow 的强大功能,我们将使用这两个项目来高效地处理大数据集。我们使用 Arrow 的过滤方法来仅包括评分高的行。我们使用 NumPy 的唯一方法来计算用户观看的电影数量,相应地分块行,并将用户观看的电影 ID 列表存储在以用户 ID 为键的字典中。
列表 8.1 加载电影数据
from pyarrow.csv import read_csv
import numpy as np
def load_model_movies_mtx(): ❶
genome_dim = read_csv('genome-tags.csv').num_rows ❷
genome_table = read_csv('genome-scores.csv') ❸
movie_ids = genome_table['movieId'].to_numpy() ❹
scores = genome_table['relevance'].to_numpy() ❹
model_movies_mtx = {} ❺
for i in range(0, len(scores), genome_dim): ❺
model_movies_mtx[movie_ids[i]] = scores[i:i+genome_dim] ❺
return model_movies_mtx, genome_dim
def load_model_users_mtx(): ❻
ratings = read_csv('ratings.csv')
good = ratings.filter(ratings['rating'].to_numpy() > 3.5) ❼
ids, counts = np.unique(good['userId'].to_numpy(),
return_counts=True) ❽
movies = good['movieId'].to_numpy()
model_users_mtx = {} ❾
idx = 0 ❾
for i, user_id in enumerate(ids): ❾
model_users_mtx[user_id] = tuple(movies[idx:idx + counts[i]]) ❾
idx += counts[i]
return model_users_mtx
def load_movie_names(): ❿
import csv
names = {}
with open('movies.csv', newline='') as f:
reader = iter(csv.reader(f))
next(reader)
for movie_id, name, _ in reader:
names[int(movie_id)] = name
return names
❶ 加载电影矩阵
❷ 解析标签基因组的维度
❸ 解析电影基因组文件
❹ 提取所需的两个列作为 NumPy 数组
❺ 从长数组中提取单个电影向量
❻ 加载用户矩阵
❼ 仅包括获得四星或五星的观看电影
❽ 确定每个用户观看的电影数量
❾ 从长数组中提取单个用户向量
❿ 加载电影 ID-电影名称映射
将代码保存到实用模块,movie_data.py。
最后,我们在下一个列表中创建用户向量。make_user_vectors 函数通过结合用户矩阵和电影矩阵中的信息来工作。作为一个小的优化,我们避免为每个用户创建单独的向量,因为我们不需要显式地存储用户向量——关于这一点稍后会有更多介绍。相反,我们按顺序重用相同的向量。
列表 8.2 制作用户向量
import numpy as np
def make_user_vectors(movie_sets, model_movies_mtx):
user_vector = next(iter(model_movies_mtx.values())).copy() ❶
for user_id, movie_set in movie_sets:
user_vector.fill(0) ❷
for movie_id in movie_set: ❸
if movie_id in model_movies_mtx:
user_vector += model_movies_mtx[movie_id] ❹
yield user_id,\
movie_set,\
user_vector / np.linalg.norm(user_vector) ❺
❶ 提取第一个电影向量作为模板
❷ 清除用户向量
❸ 遍历用户观看的电影
❹ 通过求和电影向量来创建用户向量
❺ 将用户向量归一化到单位长度
将代码保存到实用模块,movie_uservec.py。我们将在训练流程中使用它。
训练一个基本的推荐模型
图 8.9 展示了我们的项目进展情况。我们已经完成了输入数据部分。接下来,我们将草拟一个基本的推荐模型作为未来工作的占位符。

图 8.9 推荐系统项目:训练流程的第一迭代
为了为新用户提供推荐,我们将通过测量新用户与所有现有用户之间的向量距离,并选择最近的邻居来找到相似的用户。这仅为了生成一组推荐就需要进行数十万次距离测量,这在计算上非常昂贵。
幸运的是,存在高度优化的库来加速最近邻搜索。我们将使用 Spotify 为其音乐推荐系统创建的一个这样的库,Annoy。Annoy 将创建所有用户向量的索引和模型,我们可以将其保存并稍后用于生成推荐。
列表 8.3 中的代码展示了训练推荐模型的流程。它使用 movie_data 中的函数来加载数据,将其存储为工件,并生成用户向量,然后将这些向量输入到 Annoy 中。Annoy 将然后生成用户向量空间的效率表示。
列表 8.3 推荐模型训练流程
from metaflow import FlowSpec, step, conda_base, profile, resources
from tempfile import NamedTemporaryFile
ANN_ACCURACY = 100 ❶
@conda_base(python='3.8.10', libraries={'pyarrow': '5.0.0',
'python-annoy': '1.17.0'})
class MovieTrainFlow(FlowSpec):
@resources(memory=10000)
@step
def start(self): ❷
import movie_data
self.model_movies_mtx, self.model_dim =\
movie_data.load_model_movies_mtx()
self.model_users_mtx = movie_data.load_model_users_mtx()
self.movie_names = movie_data.load_movie_names()
self.next(self.build_annoy_index)
@resources(memory=10000)
@step
def build_annoy_index(self):
from annoy import AnnoyIndex
import movie_uservec
vectors = movie_data.make_user_vectors(\
self.model_users_mtx.items(),
self.model_movies_mtx) ❸
with NamedTemporaryFile() as tmp:
ann = AnnoyIndex(self.model_dim, 'angular') ❹
ann.on_disk_build(tmp.name) ❹
with profile('Add vectors'):
for user_id, _, user_vector in vectors: ❺
ann.add_item(user_id, user_vector) ❺
with profile('Build index'):
ann.build(ANN_ACCURACY) ❻
self.model_ann = tmp.read() ❼
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
MovieTrainFlow()
❶ 增加此参数以提高 Annoy 索引的准确性
❷ 开始步骤将电影数据存储为工件。
❸ 为所有现有用户生成用户向量的迭代器
❹ 初始化 Annoy 索引
❺ 将用户向量输入到索引中
❻ 最终化索引
❼ 将索引存储为工件
将代码保存到文件中,名为 movie_train_flow.py。要运行流程,请确保你当前工作目录中有 MovieLens CSV 文件。按照以下方式运行流程:
# python movie_train_flow.py --environment=conda run
构建索引大约需要 10-15 分钟。你可以通过降低 ANN_ACCURACY 常量来加快这个过程,但这样做会降低准确性。如果你对常量如何影响 Annoy 索引感兴趣,请参阅构建方法的文档,见 github.com/spotify/annoy。或者,你可以通过在云端运行代码来加快速度,使用 run --with batch。
在索引构建完成后,它被存储为一个工件。我们将在下一节中使用这个工件来生成推荐,首先作为批量工作流程,然后是实时推荐。
8.1.3 批量预测
现在我们有了模型,我们可以专注于本章的核心:使用模型生成预测。在实际操作中我们应该怎么做?让我们考虑一下在商业环境中讨论可能会如何进行。
很可能,初创公司的系统已经组织为微服务,即暴露了良好定义的 API 的单个容器。遵循相同的模式,将推荐系统作为另一个微服务(我们将在下一节中这样做)似乎是自然而然的。然而,在与工程团队讨论时,以下潜在问题被识别为微服务方法的问题:
-
该模型比现有的轻量级网络服务需要更多的内存和 CPU 功率。公司的容器编排系统需要改变以适应新的服务。
-
我们应该如何扩展服务?如果我们突然迎来大量新用户怎么办?每秒生成数千个推荐所需的计算能力非同小可。
-
每次用户刷新页面时都请求推荐,这不是很浪费吗?直到用户完成观看电影,推荐才会改变。我们可能需要以某种方式缓存推荐?
-
你,作为数据科学家,将要负责运营这个新的微服务吗?工程师负责他们的微服务,但运营它们需要复杂的工具链,而数据科学团队之前没有使用过这些工具链。
此外,你也认识到了冷启动问题:当新用户注册时,没有数据为他们生成推荐。产品经理建议,我们可以在注册过程中询问用户他们喜欢的几部电影,以解决这个问题。
所有这些观点都是有效的关注点。经过一段时间思考,你提出了一个截然不同的方法:如果,不是微服务,而是作为一个大批量操作,结构化为工作流程来为所有现有用户生成推荐,会怎样?对于现有用户,我们可以生成一个长长的推荐列表,所以即使他们看过了电影,列表也不会有太大变化。你可以每晚刷新列表。
为了解决冷启动问题,当新用户注册时,我们可以要求他们选择过去喜欢的两部电影,比如在最受欢迎的前 1000 部电影中。根据他们的初始选择,我们可以推荐他们可能喜欢的其他电影。一个关键的观察结果是,我们可以预先计算所有可能电影对的推荐。
图 8.10 可视化了这种情况。从概念上讲,用户选择与一部电影对应的行,然后选择与另一部电影对应的列。我们排除了对角线——我们不允许用户两次选择同一部电影。因为(电影 A,电影 B)的选择等于(电影 B,电影 A)——顺序不重要——我们也可以排除矩阵的一半。因此,我们只需要预先计算矩阵的上三角部分,即矩阵中的深灰色区域。你可能还记得确定矩阵中深色单元格数量的公式,即 2-组合的数量
,它等于 499,500。

图 8.10 为所有电影对生成推荐(深色三角形)
换句话说,我们可以通过预先计算大约五十万条推荐来处理用户做出的任何选择,这是完全可行的!通过这样做,我们可以获得许多好处:我们可以将推荐写入由工程团队管理的数据库,无需担心性能问题、可扩展性问题、无需缓存任何内容,也没有新的运营开销。批量预测似乎是这个用例的一个很好的方法。
生成推荐
根据螺旋方法,在花费太多时间生成结果之前,我们应该考虑外部系统将如何消费这些结果。我们将首先思考如何使用模型生成推荐,以更好地理解结果的形式。我们将专注于共享结果,然后回来最终确定批量预测流程。图 8.11 显示了我们的进度。

图 8.11 推荐系统项目:专注于预测
MovieTrainFlow 生成的 Annoy 索引使我们能够快速为新用户向量找到最近邻。我们如何从相似用户到实际电影推荐呢?一个简单的策略是考虑邻居们喜欢了哪些其他电影,然后推荐给他们。
图 8.12 说明了这个想法。想象我们有一个新用户的向量,用大灰色圆圈表示。使用 Annoy 索引,我们可以找到它的邻居,由椭圆形界定。根据用户矩阵,我们知道邻居们喜欢了哪些电影,因此我们可以简单地计算邻域中电影的频率,排除用户已经看过的电影,并将剩余的最高频率电影作为推荐返回。在图中,所有邻居都看过电影异形,因此它具有最高的频率,因此成为我们的推荐。如果用户已经看过邻域中的所有电影,我们可以增加邻域的大小,直到找到有效的推荐。

图 8.12 相似用户的邻域(虚线圆圈)
列表 8.4 展示了实现逻辑的实用模块。load_model 函数用于从工件中加载 Annoy 索引。Annoy 想要从文件中读取模型,因此我们必须先将模型写入临时文件。recommend 函数为一系列用户生成推荐,这些用户通过他们观看的电影 ID 集合(movie_sets)表示。它增加邻域的大小,直到找到新电影。find_common_movies 返回邻域中观看次数最多的 top_n 部电影,排除用户已经看过的电影。
列表 8.4 生成推荐
from collections import Counter
from tempfile import NamedTemporaryFile
from movie_uservec import make_user_vectors ❶
RECS_ACCURACY = 100 ❷
def load_model(run): ❸
from annoy import AnnoyIndex
model_ann = AnnoyIndex(run.data.model_dim)
with NamedTemporaryFile() as tmp: ❹
tmp.write(run.data.model_ann) ❹
model_ann.load(tmp.name) ❹
return model_ann,\
run.data.model_users_mtx,\
run.data.model_movies_mtx
def recommend(movie_sets,
model_movies_mtx,
model_users_mtx,
model_ann,
num_recs): ❺
for _, movie_set, vec in make_user_vectors(movie_sets,
model_movies_mtx): ❻
for k in range(10, 100, 10): ❼
similar_users =\
model_ann.get_nns_by_vector(vec,
k,
search_k=RECS_ACCURACY) ❽
recs = find_common_movies(similar_users,
model_users_mtx,
num_recs,
exclude=movie_set) ❾
if recs:
break
yield movie_set, recs
def find_common_movies(users, model_users_mtx, top_n, exclude=None): ❾
stats = Counter()
for user_id in users:
stats.update(model_users_mtx[user_id]) ❿
if exclude:
for movie_id in exclude: ⓫
stats.pop(movie_id, None) ⓫
return [int(movie_id)
for movie_id, _ in stats.most_common(top_n)] ⓬
❶ 使用与训练相同的函数创建向量
❷ 降低此值以获得更快、更不精确的结果。
❸ 从工件中加载 Annoy 索引
❹ 允许 Annoy 从文件中读取索引
❺ 为给定用户返回推荐
❻ 生成用户向量
❼ 增加邻域大小,直到找到推荐
❽ 使用 Annoy 索引找到最近邻
❾ 在邻域中找到最受欢迎的电影
❿ 使用用户矩阵收集关于观看电影的统计数据
⓫ 排除新用户已经看过的电影
⓬ 返回最受欢迎的前 N 部电影
将代码保存到文件 movie_model.py 中。我们很快就会使用这个模块。
注意,我们使用相同的 make_user_vectors 函数来训练模型(构建索引)以及生成预测。这非常重要,因为模型必须在训练和预测时使用相同的特征空间。记得在第七章我们讨论了特征编码器如何将事实转换为特征。虽然我们无法保证事实在时间上保持稳定——事实的变化称为数据漂移——但至少我们可以保证特征编码器,这里 make_user_vectors,是一致使用的。
重要 将位置数据处理和特征编码代码放在一个单独的模块中,您可以在训练和预测流程之间共享,确保特征是一致生成的。
现在我们已经很好地了解了如何生成推荐,让我们考虑如何有效地与外部系统共享它们。
坚固地共享结果
让我们构建一个与用于显示推荐的 Web 应用程序数据库集成的示例。图 8.13 显示了我们的进度。在实际项目中,您最有可能使用特定于您数据仓库或数据库的库来存储结果。在这里,为了演示这个想法,我们将结果写入 SQLite 数据库,它方便地内置在 Python 中。

图 8.13 推荐系统项目:关注输出
列表 8.5 显示了一个名为 save 的函数,它创建一个数据库(存储在文件中),包含两个表:movies,它存储有关电影的信息(电影 ID、名称以及是否应在注册过程中显示),和 recs,它存储在注册过程中选择的每对电影推荐的列表。
列表 8.5 存储推荐
import sqlite3
def recs_key(movie_set): ❶
return '%s,%s' % (min(movie_set), max(movie_set))
def dbname(run_id): ❷
return 'movie_recs_%s.db' % run_id
def save(run_id, recs, names):
NAMES_TABLE = "CREATE TABLE movies_%s("\
" movie_id INTEGER PRIMARY KEY,"\
" is_top INTEGER, name TEXT)" % run_id ❸
NAMES_INSERT = "INSERT INTO movies_%s "\
"VALUES (?, ?, ?)" % run_id ❹
RECS_TABLE = "CREATE TABLE recs_%s(recs_key TEXT, "\
" movie_id INTEGER)" % run_id ❺
RECS_INSERT = "INSERT INTO recs_%s VALUES (?, ?)" % run_id ❻
RECS_INDEX = "CREATE INDEX index_recs ON recs_%s(recs_key)" % run_id ❼
def db_recs(recs): ❽
for movie_set, user_recs in recs:
key = recs_key(movie_set)
for rec in user_recs:
yield key, int(rec)
name = dbname(run_id)
with sqlite3.connect(name) as con: ❾
cur = con.cursor() ❾
cur.execute(NAMES_TABLE) ❾
cur.execute(RECS_TABLE) ❾
cur.executemany(NAMES_INSERT, names) ❾
cur.executemany(RECS_INSERT, db_recs(recs)) ❾
cur.execute(RECS_INDEX) ❾
return name ❿
❶ 从两个电影 ID 中生成一个规范键。顺序无关紧要。
❷ 返回一个带版本的数据库名称
❸ 创建电影表的 SQL 语句
❹ 在表中插入电影的 SQL 语句
❺ 创建推荐表的 SQL 语句
❻ 在表中插入推荐的 SQL 语句
❼ 创建索引以加快查询速度的 SQL 语句
❽ 使推荐与我们的 SQL 语句兼容
❾ 创建并填充带有推荐的数据库
❿ 返回带版本的数据库名称
将代码保存到文件 movie_db.py 中。我们很快就会使用这个模块。注意 save 函数的一个重要细节:它使用 Metaflow 运行 ID 对数据库和表进行版本控制。版本化输出至关重要,因为它允许您执行以下操作:
-
安全地操作多个并行版本。在生产环境中,您可以拥有多个版本的推荐,例如,使用我们在第六章中讨论的@project 装饰器。例如,如果您想对带有实时流量的推荐变体进行 A/B 测试,这是必需的。多亏了版本控制,这些变体永远不会相互干扰。
-
分离结果的发布、验证和推广到生产环境。使用这种模式,您可以安全地运行批量预测工作流程,但它的结果不会生效,直到您将消费者指向新表,这使得您可以首先验证结果。
-
将所有结果作为一个原子操作写入。想象一下,当写入结果时工作流程失败了。如果一半的结果是新的,一半是旧的,那会非常混乱。许多数据库支持事务,但并非所有,尤其是如果结果跨越多个表甚至多个数据库。版本化方法适用于所有系统。
-
安全实验。即使有人在原型设计期间在自己的笔记本电脑上运行工作流程,也不会对生产系统产生自动的负面影响。
-
帮助调试和审计。想象一下,一个用户报告了意外的或不正确的预测。你如何知道他们确切看到了什么?版本控制确保你可以从 UI 回溯到模型训练的完整预测谱系。
-
高效清理旧结果。特别是,如果你对整个结果表进行版本控制,你可以使用单个 DROP TABLE 语句快速清理旧结果。
我们将在第 8.2 节关于模型操作的详细讨论中讨论这些主题。
推荐始终在写入外部系统的所有结果中包含版本标识符。
现在我们有了两个关键组成部分,一个用于生成推荐的模块和一个用于共享推荐的模块,我们可以开发一个预先计算注册过程中所需的所有推荐的工作流程。
生成一批推荐
接下来,我们将实现最后一部分:生成假设的用户资料并为它们生成推荐。我们将使用由 MovieTrainFlow 生成的最新模型来完成这项工作。图 8.14 显示了我们的进展。

图 8.14 推荐系统项目:最终确定预测
拥有一个单独的模型来训练模型和批量生成预测是有用的,因为它允许你独立地安排这两个流程。例如,你可以每晚重新训练模型,每小时刷新推荐。
批量预测的一个主要挑战是规模和性能。生成数十万条推荐需要许多计算周期,但完成一次运行不应该需要数小时或数天。幸运的是,如以下列表所示,我们可以使用熟悉的工具和技术来应对这一挑战:从第三章中扩展工作流程的水平缩放,从第四章中可扩展的计算层,第五章中的性能提示,以及第七章中的大规模数据处理模式。一旦完成,我们可以使用第六章中的经验将工作流程部署到生产环境中。
图 8.15 显示了批量预测工作流程的一般架构。我们首先获取我们想要预测的数据。我们将数据分成多个批次,这些批次可以并行处理。最后,我们将结果发送到外部系统。

图 8.15 批量预测工作流程的典型结构
在这个例子中,我们只为在注册过程中选择了两部他们最喜欢的电影的理论新用户生成推荐。在一个真实的产品中,我们还会更新现有用户的推荐,每次流程运行时都会获取他们的用户资料。
如本节开头所述,我们限制新用户可以选择的电影数量,以限制我们必须预先计算的组合数量。为了增加新用户找到他们喜欢的电影的可能性,我们选择最受欢迎的前 K 部电影。下一个列表显示了一个函数 top_movies,它找到热门电影的子集。
我们将生成所有热门电影的组合,对于前 1,000 部电影,这将产生大约 500,000 对。我们可以并行地为这 500,000 个假设的用户配置文件生成推荐。我们使用 make_batches 工具函数将配对列表分成 100,000 个配置文件的块。
列表 8.6 批量推荐工具
from collections import Counter
from itertools import chain, groupby
def make_batches(lst, batch_size=100000): ❶
batches = []
it = enumerate(lst)
for _, batch in groupby(it, lambda x: x[0] // batch_size):
batches.append(list(batch))
return batches
def top_movies(user_movies, top_k): ❷
stats = Counter(chain.from_iterable(user_movies.values()))
return [int(k) for k, _ in stats.most_common(top_k)]
❶ 将列表分割成固定大小的块并返回块列表
❷ 统计用户矩阵中的所有电影 ID 并返回最受欢迎的前 K 个
将代码保存到文件 movie_recs_util.py 中。它将在以下流程中使用。列表 8.7 将所有部分组合在一起。它生成所有电影对,在开始步骤中,在批量推荐步骤中并行地为用户配置文件批量生成推荐,并在连接步骤中汇总并存储结果,遵循上述图 8.15 中描述的模式。
列表 8.7 批量推荐流程
from metaflow import FlowSpec, step, conda_base, Parameter,\
current, resources, Flow, Run
from itertools import chain, combinations
@conda_base(python='3.8.10', libraries={'pyarrow': '5.0.0',
'python-annoy': '1.17.0'})
class MovieRecsFlow(FlowSpec):
num_recs = Parameter('num_recs',
help="Number of recommendations per user",
default=3)
num_top_movies = Parameter('num_top',
help="Produce recs for num_top movies",
default=100)
@resources(memory=10000)
@step
def start(self):
from movie_recs_util import make_batches, top_movies
run = Flow('MovieTrainFlow').latest_successful_run ❶
self.movie_names = run['start'].task['movie_names'].data
self.model_run = run.pathspec
print('Using model from', self.model_run)
model_users_mtx = run['start'].task['model_users_mtx'].data
self.top_movies = top_movies(model_users_mtx,
self.num_top_movies) ❷
self.pairs = make_batches(combinations(self.top_movies, 2)) ❸
self.next(self.batch_recommend, foreach='pairs')
@resources(memory=10000)
@step
def batch_recommend(self): ❹
from movie_model import load_model, recommend
run = Run(self.model_run)
model_ann, model_users_mtx, model_movies_mtx = load_model(run) ❺
self.recs = list(recommend(self.input,
model_movies_mtx,
model_users_mtx,
model_ann,
self.num_recs)) ❻
self.next(self.join)
@step
def join(self, inputs):
import movie_db
self.model_run = inputs[0].model_run
names = inputs[0].movie_names
top = inputs[0].top_movies
recs = chain.from_iterable(inp.recs for inp in inputs) ❼
name_data = [(movie_id, int(movie_id in top), name)
for movie_id, name in names.items()]
self.db_version = movie_db.save(current.run_id, recs, name_data) ❽
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
MovieRecsFlow()
❶ 获取最新模型
❷ 生成最热门电影列表
❸ 生成假设用户配置文件:热门电影的配对
❹ 这些步骤是并行运行的。
❺ 加载 Annoy 索引
❻ 为此批次的用户生成推荐
❼ 汇总所有批次的推荐
❽ 将推荐保存到数据库
将代码保存到文件 movie_recs_flow.py 中。按照以下方式运行流程:
# python movie_recs_flow.py --environment=conda run
默认情况下,只考虑前 100 部电影。您可以通过添加选项 --num_top=1000(或更高)来预先计算更多电影的推荐。运行完成后,您应该在当前工作目录中找到一个以 movie_recs_ 为前缀的 SQLite 数据库文件。包含 500,000 个用户(假设用户配置文件)的推荐数据库大约是 56 MB——显然还有增长空间!如果您想在云中运行此流程,可以将 @resources 装饰器更改为 @batch,以远程运行仅开始和批量推荐步骤。您需要本地运行连接步骤,因为我们很快就需要存储在本地文件中的 SQLite 数据库。
注意我们如何使用表达式 Flow('MovieTrainFlow').latest_successful_run 来访问 MovieTrainFlow 生成的模型。这个调用在 MovieRecsFlow 的相同命名空间中操作,这意味着每个用户可以自由地实验流程,而不会相互干扰。正如第六章中讨论的,命名空间与 @project 装饰器一起工作,因此您可以在生产环境中安全地并发部署各种流程变体。
您可以使用 sqlite3 命令行工具打开数据库并查询它。然而,本章全部关于使用模型产生实际业务价值,因此我们可以更进一步,并在 Web UI 上查看结果!
在 Web 应用程序中使用推荐
在现实生活中的商业环境中,作为数据科学家,你的职责可能仅限于将结果写入数据库。另一个工程团队能够轻松地从数据库中读取建议并围绕它们构建应用程序。图 8.16 展示了最终步骤。

图 8.16 推荐系统项目:关注 Web UI
虽然通常你可以依赖其他工程团队进行 Web 应用程序开发,但有时能够快速构建一个应用程序原型来查看实际结果是有用的。幸运的是,存在一些强大的开源框架,使得在 Python 中构建简单的仪表板变得容易。接下来,我们将使用其中一个这样的框架,即 Plotly Dash (plotly.com/dash/),来构建一个简单的 UI 来模拟带有推荐的注册流程。
首先,让我们创建一个简单的客户端库来从 SQLite 数据库中获取推荐。我们只需要两个函数:get_recs,它返回两个电影给出的预计算推荐,以及 get_top_movies,它返回我们对其有推荐的顶级电影列表。下一个列表显示了客户端。
列表 8.8 访问推荐
import sqlite3
from movie_db import dbname, recs_key
class MovieRecsDB():
def __init__(self, run_id): ❶
self.run_id = run_id
self.name = dbname(run_id)
self.con = sqlite3.connect(self.name)
def get_recs(self, movie_id1, movie_id2): ❷
SQL = "SELECT name FROM movies_{run_id} AS movies "\
"JOIN recs_{run_id} AS recs "\
"ON recs.movie_id = movies.movie_id "\
"WHERE recs.recs_key = ?".format(run_id=self.run_id)
cur = self.con.cursor()
cur.execute(SQL, [recs_key((movie_id1, movie_id2))])
return [k[0] for k in cur]
def get_top_movies(self): ❸
SQL = "SELECT movie_id, name FROM movies_%s "\
"WHERE is_top=1" % self.run_id
cur = self.con.cursor()
cur.execute(SQL)
return list(cur)
❶ 根据运行 ID 打开一个版本化的数据库
❷ 获取由两个电影组成的假设用户配置文件的推荐
❸ 返回顶级电影的列表
将代码保存到文件 movie_db_client.py 中。接下来,通过运行以下命令安装 Plotly Dash:
# pip install dash
Plotly Dash 允许你在一个 Python 模块中构建整个网络应用程序,包括在浏览器中运行的用户界面和作为后端的网络服务器。你可以参考其文档和教程来详细了解这个工具。接下来,我们将使用它来构建一个简单、自解释的原型用户界面,如图 8.17 所示。

图 8.17 我们示例推荐系统的 Web UI
列表 8.9 展示了生成图 8.17 中 Web UI 的 Dash 应用程序。应用程序有两个主要部分:应用程序的布局定义在 app.layout 中,包括我们从数据库中获取的电影下拉列表。当用户点击按钮时,Dash 会调用 update_output 函数。如果用户选择了两部电影,我们可以从数据库中获取相应的推荐。
列表 8.9 推荐网络应用程序
import sys
from dash import Dash, html, dcc
from dash.dependencies import Input, Output, State
from movie_db_client import MovieRecsDB
RUN_ID = sys.argv[1] ❶
movies = [{'label': name, 'value': movie_id}
for movie_id, name in MovieRecsDB(RUN_ID).get_top_movies()] ❷
app = Dash(__name__)
app.layout = html.Div([ ❸
html.H1(children="Choose two movies you like"),
html.Div(children='1st movie'),
dcc.Dropdown(id='movie1', options=movies),
html.Div(children='2nd movie'),
dcc.Dropdown(id='movie2', options=movies),
html.P([html.Button(id='submit-button', children='Recommend!')]),
html.Div(id='recs')
])
@app.callback(Output('recs', 'children'),
Input('submit-button', 'n_clicks'),
State('movie1', 'value'),
State('movie2', 'value'))
def update_output(_, movie1, movie2): ❹
if movie1 and movie2:
db = MovieRecsDB(RUN_ID)
ret = [html.H2("Recommendations")]
return ret + [html.P(rec) for rec in db.get_recs(movie1, movie2)] ❺
else:
return [html.P("Choose movies to see recommendations")]
if __name__ == '__main__':
app.run_server(debug=True) ❻
❶ 将数据库版本作为命令行参数指定
❷ 从数据库中获取顶级电影的列表
❸ 定义 UI 组件
❹ 当按钮被点击时调用的函数
❺ 从数据库中获取推荐
❻ 启动网络服务器
将代码保存到文件 movie_dash.py 中。要启动服务器,你需要在当前工作目录中有一个由 MovieRecsFlow 生成的数据库。一旦你有了数据库,你可以通过指定运行 ID 来指向服务器,如下所示:
# python movie_dash.py 1636864889753383
服务器应该输出类似以下内容的行
Dash is running on http://127.0.0.1:8050/
你可以将 URL 复制并粘贴到浏览器中,然后应该会打开 web 应用。现在你可以选择任何一对电影,并获得符合你口味的个性化推荐!图 8.18 显示了两个示例结果。

图 8.18 推荐示例
注意 UI 的响应速度非常快。在生成推荐时没有明显的延迟,从而提供了愉悦的用户体验。这是批量预测的主要优势:没有比完全不进行即时计算更快的预测方式。
恭喜你开发了一个功能齐全的推荐系统,从原始数据到功能性的 web UI!在下一节中,我们将看到如何使用相同的模型即时生成推荐,这对于我们事先不知道输入数据的情况非常有用。
8.1.4 实时预测
记得章节开头提到的图 8.4 吗?如图 8.19 再次所示,如果你从知道输入数据的那一刻起至少有 15 分钟的时间到实际需要预测的时间,你可以考虑批量预测。上一节演示了这种情况的一个极端例子:我们可以在预测之前很久就预先生成所有输入数据,包括顶级电影的配对。

图 8.19 关注流式和实时预测
显然,我们并不总是有足够的时间。例如,我们可能希望在用户观看电影或产品几分钟或几秒钟后推荐电影或其他产品。虽然理想情况下,有一个可以在任何时间尺度上以相同方式工作的系统会非常方便,但在实践中,为了快速生成预测,需要做出权衡。例如,如果你需要在几秒钟内得到答案,就没有足够的时间在计算层上动态启动和关闭实例。或者,没有足够的时间从 S3 下载整个大型数据集。
因此,需要快速生成答案的系统需要以不同的方式构建。在机器学习的背景下,这类系统通常被称为模型服务或模型托管系统。它们在高级别的运行原理很简单:首先,你需要一个模型(文件),这通常由批量工作流程生成,例如通过 Metaflow。通常,模型会附带预处理输入数据的函数,例如将输入事实转换为特征,以及将结果后处理为所需格式的函数。模型和支持代码的集合可以打包成一个容器,部署在微服务平台上,该平台负责运行容器并将请求路由到它们。

图 8.20 典型模型托管服务的架构
图 8.20 展示了模型托管服务的高级架构:
-
通过发送请求,例如通过 HTTP,到 一个托管端点,该端点托管在类似 http://hosting-service/predict 的地址,来生成实时预测。
-
端点解码并验证请求,并将其转发到预处理函数。
-
预处理函数负责将请求中包含的事实转换为部署的模型使用的特征。通常,请求本身不包含所有所需数据,但需要从数据库中查找附加数据。例如,请求可能只包含一个用户 ID,然后从数据库中获取与用户相关的最新数据。
-
一个特征向量/张量被输入到模型中,该模型生成一个预测。有时,一个模型可能是由多个模型组成的集成,甚至是一个复杂的模型图。
-
预测通过后处理函数进行处理,以转换为合适的响应格式。
-
返回一个响应。
注意,模型托管服务的架构与典型的非机器学习相关的微服务并没有太大区别。你只需要一个可以接收请求、即时处理一些代码并返回响应的容器。对于某些数据科学用例,使用现有的微服务平台,如 AWS 上的 Fargate 或 Google App Engine 进行模型托管是完全可行的。
然而,以下额外的考虑可能使得使用专门的模型托管平台或在通用平台上添加额外服务成为必要:
-
模型可能计算量较大,需要大量内存和专门的硬件,如 GPU。传统的微服务平台可能难以容纳这样的重型服务。
-
模型和它们的输入输出需要实时监控。许多 模型监控 解决方案可以帮助处理这种情况。
-
你想使用 一个特征存储库 来以一致的方式处理将事实转换为特征。在图 8.20 中,特征存储库将替换或集成到预处理函数和数据库中。
如果你需要一个专门的平台,所有主要云提供商都提供模型托管解决方案,如 Amazon Sagemaker Hosting 或 Google AI Platform。或者,你可以利用开源库,如 Ray Serve (ray.io) 或 Seldon (seldon.io)。
示例:实时电影推荐
让我们通过使用最小化模型托管服务来练习实时预测。此示例通过允许基于可以实时更新的电影列表生成推荐来扩展我们之前的电影推荐系统。例如,你可以使用这个系统根据用户最近浏览的所有电影生成实时推荐。
此示例并非旨在用于生产环境,而是将展示图 8.20 中概述的模型托管系统的以下关键概念:
-
一个可通过 HTTP 访问的服务端点
-
一个使用与训练代码相同的特征化模块的预处理函数
-
加载和使用使用批量工作流程训练的模型
-
以决定输出格式对预测进行后处理
我们将使用流行的 Python Web 框架 Flask (flask.palletsprojects.com),将逻辑封装为 Web 服务。此示例可以轻松地适应任何其他 Web 框架。以下代码示例列出了一个功能齐全的模型托管服务。
列表 8.10 模型托管服务
from io import StringIO
from metaflow import Flow
from flask import Flask, request
from movie_model import load_model, recommend ❶
from movie_data import load_movie_names ❶
class RecsModel(): ❷
def __init__(self):
self.run = Flow('MovieTrainFlow').latest_successful_run ❸
self.model_ann,\
self.model_users_mtx,\
self.model_movies_mtx = load_model(self.run) ❹
self.names = load_movie_names() ❹
def get_recs(self, movie_ids, num_recs): ❺
[(_, recs)] = list(recommend([(None, set(movie_ids))],
self.model_movies_mtx,
self.model_users_mtx,
self.model_ann,
num_recs)) ❻
return recs
def get_names(self, ids): ❼
return '\n'.join(self.names[movie_id] for movie_id in ids)
def version(self): ❽
return self.run.pathspec
print("Loading model")
model = RecsModel() ❾
print("Model loaded")
app = Flask(__name__)
def preprocess(ids_str, model, response): ❿
ids = list(map(int, ids_str.split(','))) ⓫
response.write("# Model version:\n%s\n" % model.version()) ⓬
response.write(“# Input movies\n%s\n” % model.get_names(ids))
return ids
def postprocess(recs, model, response): ⓭
response.write(“# Recommendations\n%s\n” % model.get_names(recs))
@app.route(“/recommend”) ⓮
def recommend_endpoint():
response = StringIO()
ids = preprocess(request.args.get('ids’), model, response) ⓯
num_recs = int(request.args.get('num', 3))
recs = model.get_recs(ids, num_recs) ⓰
postprocess(recs, model, response) ⓱
return response.getvalue() ⓱
❷ 使用上一节中的辅助模块
❸ 模型辅助类
❽ 获取最新的模型 ID
❻ 加载模型
❸ 生成推荐
❻ 为一组电影生成推荐
❶ 一个将 ID 映射到电影名称的辅助函数
❺ 返回模型版本
⓱ 加载模型(这可能需要几分钟)
⓱ 预处理函数以解析请求中的信息
⓫ 解析逗号分隔字符串中的整数 ID
⓬ 输出模型的版本标识符
⓭ 后处理函数以输出响应
❹ Flask 端点规范
⓯ 处理请求的输入
⓰ 生成推荐
⓰ 最终输出响应
将代码保存到 movie_recs_server.py。要运行服务器,您需要一个包含模型所需库的执行环境。因为这不是 Metaflow 工作流程,而是 Flask 应用程序,所以我们不能像早期示例中那样使用@conda。相反,您可以通过执行以下命令手动创建一个合适的 Conda 环境:
# conda create -y -n movie_recs python-annoy==1.17.0 pyarrow=5.0.0 flask
➥ metaflow
# conda activate movie_recs
一旦环境被激活,您就可以按照以下方式在本地执行服务:
# FLASK_APP=movie_recs_server flask run
加载模型并启动服务器可能需要一分钟或更长时间。一旦服务器启动并运行,您将看到以下输出:
Model loaded
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
在此之后,您就可以开始查询服务器了。要向服务器发送 HTTP 请求,请打开另一个终端窗口,您可以在其中使用命令行客户端 curl 向服务器发送请求。您可以在 movies.csv 中浏览有趣的电影 ID,然后按如下方式查询推荐:
# curl 'localhost:5000/recommend?ids=4993,41566'
生成类似以下响应需要大约 50-100 毫秒:
MovieTrainFlow/1636835055130894
# Input movies
Lord of the Rings: The Fellowship of the Ring, The (2001)
Chronicles of Narnia: The Lion, the Witch and the Wardrobe, The (2005)
# Recommendations
Lord of the Rings: The Two Towers, The (2002)
Lord of the Rings: The Return of the King, The (2003)
Harry Potter and the Sorcerer's Stone (2001)
您可以使用 num 参数来生成更多推荐:
# curl 'localhost:5000/recommend?ids=16,858,4262&num=10'
# Model version:
MovieTrainFlow/1636835055130894
# Input movies
Casino (1995)
Godfather, The (1972)
Scarface (1983)
# Recommendations
Goodfellas (1990)
Godfather: Part II, The (1974)
Donnie Brasco (1997)
Léon: The Professional (1994)
Bronx Tale, A (1993)
Taxi Driver (1976)
Raging Bull (1980)
Departed, The (2006)
No Country for Old Men (2007)
American Gangster (2007)
恭喜!您已经创建了一个能够实时生成推荐的 Web 服务。尽管服务运行正常,但您应该考虑一些改进,以便使服务完全准备好投入生产。首先,在基础设施方面,考虑以下方面:
-
服务应打包在 Docker 容器中,以便可以部署到微服务平台。
-
此服务一次只能处理单个请求。您应该查阅 Flask 文档,了解如何部署应用程序以便并行处理多个请求。
-
如果需要更大的规模,您可以在并行运行多个容器。这需要一个负载均衡器将流量路由到各个容器。
-
记录请求量日志和基本指标是一个好主意,为此有许多现成的工具可用。
其次,在建模方面,考虑添加以下内容:
-
一个用于实时跟踪模型指标的模型监控解决方案
-
一个用于跟踪请求中的数据质量以检测输入数据分布变化的解决方案
-
一个用于管理 A/B 测试的服务
模型部署所需的几乎所有 ML 特定工具都与调试性和结果质量相关。想象一下,服务返回的预测看起来很奇怪。第一个问题是,哪个模型产生了预测?为了回答这个问题,我们在每个预测响应中包含了模型版本。如果没有模型版本标识符,将无法知道预测的来源,尤其是在可能同时部署多个模型版本复杂环境中。
图 8.21 说明了模型血缘的概念。

图 8.21 从预测回溯到原始数据的模型血缘
通过使用如图 8.21 所示的架构,我们可以跟踪预测的血缘,一直回溯到源数据:
-
每个预测响应都应该包含一个 ID,表示哪个部署产生了响应。
-
例如,每个部署(例如,运行模型特定版本的容器)都应该获得一个唯一的 ID。
-
容器应该知道模型的 ID 和生成模型的运行 ID。
-
知道运行 ID,我们可以回溯到用于训练模型的原始数据。
你现在已经具备了在实时或预计算批量预测之间做出明智选择的能力,以及支持它们的框架。当不确定时,选择最简单可行的方法。
摘要
-
为了产生价值,机器学习模型必须与其他周围系统连接。
-
部署数据科学应用并生成预测没有唯一的方法:正确的方法取决于用例。
-
根据输入数据变得已知和需要预测之间的时间窗口,选择合适的预测基础设施。
-
另一个关键考虑因素是周围系统是否需要从模型请求预测,或者模型是否可以将预测推送到周围系统。在后一种情况下,批量或流预测是一个好方法。
-
如果在需要预测之前至少 15-30 分钟知道输入数据,通常可以以批量工作流的形式生成预测,这是技术上最直接的方法。
-
在批量和实时用例中,所有模型输出都应附上版本标识符非常重要。
-
实时预测可以通过通用微服务框架或针对数据科学应用定制的解决方案来生成。如果您的模型计算需求高,后者可能是最佳方法。
-
确保您的部署可以通过投资监控工具和溯源功能进行调试。应该能够追踪每个预测,直至模型及其生成的流程。
9 全栈机器学习
本章涵盖
-
开发一个定制框架,使其更容易为特定问题域开发模型和功能
-
在工作流中训练深度学习模型
-
总结本书学到的经验教训
我们现在已经涵盖了基础设施堆栈的所有层级,如图 9.1 所示,除了最顶层:模型开发。我们在第七章中只是触及了特征工程层。难道不是一种矛盾吗?一本关于机器学习与数据科学基础设施的书,却花了这么少的时间讨论机器学习的核心问题:模型和特征?
这种关注是有意为之的。首先,关于这些主题已经存在许多优秀的书籍。成熟的建模库,如 TensorFlow 和 PyTorch,都附带大量的深入文档和示例。如图 9.1 所示,这些主题往往是专业数据科学家和机器学习工程师的核心专业领域,而底层则不是。为了有效地提高数据科学家的日常生产力,有道理的是在需要帮助的地方提供帮助:堆栈的底层。

图 9.1 基础设施堆栈与数据科学家的兴趣领域
此外,最顶层往往比底层更具有应用特定性。例如,计算机视觉应用所需的模型和特征与用于平衡营销预算的模型和特征非常不同。然而,它们都可以使用相同的方法从云中访问数据、运行和编排容器,以及版本控制和跟踪项目。
在第一章讨论的四个“V”中——体积、速度、有效性和多样性——最后一个最难用标准化的解决方案来解决。如果基础设施能够很好地解决前三个“V”,那么开发并部署各种用例就变得可行,即使每个项目都带有自己的数据管道、定制模型和自定义业务逻辑。
回到第一章讨论的另一个主题,我们可以通过为堆栈的底层提供一种通用、低开销的解决方案来最小化由处理数据、计算和编排的样板代码引起的意外复杂性。同时,我们可以接受这样一个事实:现实世界的用例都伴随着一定程度的固有复杂性,这是顶层需要管理的。并非所有东西都可以抽象化。
在本书所学的基础上,你可以设计自己的库来支持特定用例的建模和特征工程,这有助于进一步控制复杂性。就像传统软件利用由操作系统如 OS X 或 Linux 提供的特性和服务一样,你的库可以将堆栈的底层视为任何数据密集型应用程序的操作系统。然而,你不需要急于这样做。先构建几个没有任何特殊抽象的应用程序是一个好主意,这样你就能更好地理解是否存在可以从额外支持和标准化中受益的常见模式。
为了展示所有这些概念是如何协同工作的,包括一个支持模型开发的定制库,下一节将带您通过一个涉及堆栈所有层的真实项目。在全面示例之后,我们通过总结本书学到的经验来结束本书。您可以在mng.bz/N6d7找到本章的所有代码列表。
9.1 可插拔的特征编码器和模型
本节将扩展我们在第七章开始讨论的出租车行程成本预测示例。我们的原始版本非常简单。我们使用线性回归根据一个变量:行程距离来预测价格。你可能会在这个模型中至少发现一个明显的问题:行程的持续时间除了距离外也很重要。
让我们想象准确预测行程价格是一个真正的商业挑战。一个现实解决方案会是什么样子?首先,你不太可能一开始就知道现实生活中的商业问题的最佳解决方案。为了找到一个可行的解决方案,你必须对多个模型和特征进行实验,通过多次迭代测试它们的性能。你肯定会使用多个变量进行预测,因此你可能会花大量时间设计和实现合适的特征。很可能,你也会用不同的模型架构测试这些特征。
此外,现实生活中的数据往往不足。正如第七章中的示例所示,当有高质量的特征,如实际行程距离时,使用简单模型可以得到相当好的结果。如果我们的应用程序无法访问出租车计费器或汽车的里程表,而只有乘客的智能手机怎么办?也许我们只知道接车和下车的位置,我们必须在不知道确切行驶距离的情况下预测价格,这我们将在后面练习。
在本节中,我们将在一个更现实的设置中开发一个更高级的模型。因为我们知道我们需要在多个模型和特征上进行迭代——也许我们有一个数据科学家团队在解决这个问题——我们通过实现一个简单的框架来标准化模型开发设置,这个框架允许我们插入自定义特征编码器并灵活地测试各种模型。
使用该框架,我们开发了使用地理位置来预测价格的特征。为了实现这一点,我们将我们的模型从 20 世纪 50 年代的线性回归升级到 2020 年代的深度学习模型,该模型使用 Keras 和 TensorFlow 构建。为了验证我们的模型,我们创建了一个基准,比较了各种建模方法的性能。与之前一样,我们直接从公共 S3 存储桶访问原始数据。
9.1.1 开发可插拔组件的框架
我们有几个关于可能对价格预测任务表现良好的模型和特征的想法。我们希望快速原型设计和评估它们,以确定最有前途的方法。技术上,我们可以从头开始将每个想法实现为一个单独的工作流程,但我们可能会注意到,许多针对该任务的方案遵循一个类似的模式:它们都加载原始数据,将其分为训练集和测试集,运行特征编码器,训练模型,并使用测试数据进行评估。模型和特征编码器的实现各不相同,但工作流程的整体结构是相同的。
为了使模型开发过程更高效,我们将常见的模式实现为一个共享工作流程,这使得不同特征编码器和模型可以轻松插入。这种方法与我们在第五章中比较计算共现矩阵的各种算法的方法类似。图 9.2 展示了这种方法。

图 9.2:共享工作流程中的可插拔模型和特征编码器(浅灰色)
要实现一种新的建模方法,科学家需要开发三个组件,如图 9.2 中的浅灰色框所示:首先,将原始输入数据、事实转换为特征的特性编码器。为了使特性化更高效,我们可以将它们并行化处理多个数据分片。其次,在所有分片都被处理之后,我们可以将特性分片合并为模型的输入数据集。你可能已经认识到了这种方法,就是我们第七章中介绍的 MapReduce 模式。第三,我们需要一组函数来训练模型。
这三个组件可以作为可插拔模块来实现。我们开发了两个独立的流程来执行插件:一个用于处理特征,另一个用于训练模型。通过将数据和训练分离,我们使得它们可以独立调度。例如,如果你想遵循第八章中介绍的批量预测模式,你可以使用共享的特征化工作流程来为批量预测工作流程生成数据。
在 Python 中,我们通常将同一接口的不同实现定义为单独的类。让我们首先定义三个组件的接口:编码器、合并和模型训练。特征编码器需要实现两个方法:encode,它将一个输入数据碎片(即事实碎片)——表示为 PyArrow 表——转换为特征碎片。然后,碎片被提供给另一个方法,merge,该方法将碎片合并成一个可以由模型处理的数据集。图 9.3 阐述了这两个函数的作用。

图 9.3 首先将碎片化输入数据编码为特征,然后合并成数据集
编码函数可以输出多个命名特征,如图 9.3 中的 A-D 所示,这些特征作为字典输出,其中键是特征名称,值是编码器选择的数据结构,用于存储特征。我们当前的代码期望所有碎片产生相同的一组特征,但作为一个练习,你可以更改代码以放宽这一要求。merge 函数接收所有特征碎片作为输入,并选择如何将它们组合以生成最终数据集。
定义特征编码器
许多模型可以高效地读取作为 NumPy 数组的数据,因此我们首先定义一个输出 NumPy 数组的编码器模板。接下来的列表显示了一个通用超类——一个特定编码器可以从中派生的类,它期望编码函数输出一个 NumPy 数组。它负责合并碎片中产生的 NumPy 数组,而不对数组包含的确切内容有任何偏见。
列表 9.1 处理 NumPy 数组的特征编码器超类
class NumpyArrayFeatureEncoder():
@classmethod ❶
def encode(cls, table): ❷
return {} ❸
@classmethod
def merge(cls, shards): ❹
from numpy import concatenate
return {key: concatenate([shard[key] for shard in shards]) ❺
for key in shards[0]} ❻
❶ 将方法定义为类方法,因此可以在不实例化类的情况下使用它们
❷ 接受一个事实碎片作为 PyArrow 表
❸ 编码器将覆盖此方法以生成包含特征的 NumPy 数组
❹ 接受一个特征碎片的列表
❺ 将特征碎片连接成一个大的数组
❻ 遍历所有特征
我们将创建许多小模块,因此让我们为它们创建一个专门的目录,taxi_modules。将代码保存到 taxi_modules/numpy_encoder.py。
接下来,让我们定义一个使用我们刚刚创建的 NumpyArrayFeatureEncoder 的特征编码器。下一个列表中显示的编码器将作为一个基准:它直接从数据集中获取行程距离列和实际行程价格、总金额,允许我们比较不直接使用距离特征的预测质量。
列表 9.2 基准特征编码器
from taxi_modules.numpy_encoder import NumpyArrayFeatureEncoder
class FeatureEncoder(NumpyArrayFeatureEncoder): ❶
NAME = 'baseline' ❷
FEATURE_LIBRARIES = {} ❸
CLEAN_FIELDS = ['trip_distance', 'total_amount'] ❹
@classmethod
def encode(cls, table):
return { ❺
'actual_distance': table['trip_distance'].to_numpy(),
'amount': table['total_amount'].to_numpy()
}
❶ 重新使用来自 NumpyArrayFeatureEncoder 的合并方法
❷ 设置编码器名称
❸ 为此编码器定义额外的软件依赖
❹ 定义事实表中应清理的列
❺ 返回两个特征作为 NumPy 数组
将代码保存到 taxi_modules/feat_baseline.py 中。我们将所有特征编码器模块的前缀设置为 feat_,这样我们就可以自动发现它们。编码器定义了一些顶级常量,如下所示:
-
NAME—标识这个特征编码器。
-
FEATURE_LIBRARIES—定义此编码器需要的额外软件依赖项。
-
CLEAN_FIELDS—确定需要清理的事实表中的哪些列。
随着我们开始使用这些常数,它们的作用将变得更加清晰。接下来,让我们创建一个实用模块,用于加载之前定义的插件。
打包和加载插件
应该可以通过在 taxi_modules 目录中添加一个文件来简单地创建一个新的特征或模型。根据文件名,我们可以确定该模块是特征编码器还是模型。以下列表遍历 taxi_modules 目录中的所有文件,导入具有预期前缀的模块,并通过共享字典使它们可用。
列表 9.3 带参数的流程
import os
from importlib import import_module
MODELS = {} ❶
FEATURES = {} ❷
FEATURE_LIBRARIES = {} ❸
MODEL_LIBRARIES = {} ❹
def init():
for fname in os.listdir(os.path.dirname(__file__)): ❺
is_feature = fname.startswith('feat_') ❻
is_model = fname.startswith('model_') ❻
if is_feature or is_model:
mod = import_module('taxi_modules.%s' % fname.split('.')[0]) ❼
if is_feature: ❽
cls = mod.FeatureEncoder
FEATURES[cls.NAME] = cls
FEATURE_LIBRARIES.update(cls.FEATURE_LIBRARIES.items())
else:
cls = mod.Model
MODELS[cls.NAME] = cls
MODEL_LIBRARIES.update(cls.MODEL_LIBRARIES.items())
❶ 将模型名称映射到模型类
❷ 将特征编码器名称映射到特征编码器类
❸ 记录编码器需要的库
❹ 记录模型需要的库
❺ 遍历 taxi_modules 目录中的所有文件
❻ 检查文件前缀
❼ 导入模块
❽ 填充包含编码器和模型的字典
将代码保存到 taxi_modules/init.py 中。请注意,该模块需要位于特征编码器和模型相同的目录中,以确保文件发现能够正确工作。Python 中的 init.py 文件具有特殊含义:在目录中包含 init.py 文件告诉 Python 该目录对应一个Python 包。Python 包是一组可以作为一个单元安装和导入的模块。更多关于包的信息,请参阅mng.bz/Dg8a。
目前,我们的 taxi_modules 包(目录)包含以下文件:
taxi_modules/__init__.py
taxi_modules/feat_baseline.py
taxi_modules/numpy_encoder.py
在本章中,我们将添加更多内容。将模块组织成 Python 包的好处是,你可以将其发布并共享为一个可以像其他 Python 包一样安装的包——想象一下 pip install taxi_modules 或 conda install taxi_modules。你可以参考packaging.python.org/获取详细说明。然后,你可以使用,例如,@conda 装饰器将包包含到你的 Metaflow 项目中。
然而,没有必要发布这个包。一个更简单的方法是确保包目录与你的流程脚本相邻。例如,一个数据科学团队可能有以下结构的 Git 仓库:
taxi_modules/__init__.py
taxi_modules/...
flow1.py
flow2.py
flow3.py
在这种情况下,flow1、flow2 和 flow3 都可以自动访问共享的 taxi_modules 包,这得益于 Metaflow 在第六章中描述的自动将所有子目录作为包打包的事实。
建议:如果您有一个相对稳定的包,数据科学家在处理他们的流程时不需要修改,您可以将其打包并作为正常的 Python 包发布,这样就可以使用 @conda 将其包含在流程中,就像任何其他第三方库一样。如果预计数据科学家需要作为他们项目的一部分快速迭代包的内容,例如本例中的特征编码器,您可以通过将其作为子目录包含进来,使原型设计循环更加平滑,Metaflow 版本会自动处理。
9.1.2 执行特征编码器
我们几乎准备好开始执行特征编码器了。在定义一个流程来执行此操作之前,我们需要两个额外的实用模块。首先,为了预处理事实,我们使用第七章中介绍的 table_utils.py 中的实用函数。下一个代码示例再次展示了该模块。
列表 9.4 从 Arrow 表中删除异常行
def filter_outliers(table, clean_fields): ❶
import numpy
valid = numpy.ones(table.num_rows, dtype='bool') ❷
for field in clean_fields: ❸
column = table[field].to_numpy()
minval = numpy.percentile(column, 2) ❹
maxval = numpy.percentile(column, 98) ❹
valid &= (column > minval) & (column < maxval) ❺
return table.filter(valid) ❻
def sample(table, p): ❼
import numpy
return table.filter(numpy.random.random(table.num_rows) < p) ❽
❶ 接受一个 pyarrow.Table 和一个要清理的列列表
❷ 从接受所有行的过滤器开始
❸ 逐个处理所有列
❹ 找到值分布的顶部和底部 2%
❺ 只包含落在 2-98% 值分布之间的行
❻ 返回与过滤器匹配的行子集
❼ 从给定表中随机采样 p% 的行
❽ 在每一行上抛一个有偏的硬币并返回匹配的行
将代码保存在 taxi_modules/table_utils.py 中。有关这些函数如何工作的更多详细信息,请参阅第七章。
其次,我们定义一个辅助模块来执行特征编码器。列表 9.5 展示了一个包含两个函数的模块:execute 首先通过清理 CLEAN_FIELDS 中列出的所有字段来预处理事实表。它还接受一个输入行的样本,如果 sample_rate 小于 1.0。在此之后,它执行所有发现的特征编码器,并为他们提供事实表。合并函数接受两个碎片列表,分别为训练和测试的特征,并使用由其编码器指定的合并函数合并每个特征。
列表 9.5 执行特征编码器
from itertools import chain
from taxi_modules.table_utils import filter_outliers, sample
from taxi_modules import FEATURES ❶
def execute(table, sample_rate): ❷
clean_fields = set(chain(*[feat.CLEAN_FIELDS
for feat in FEATURES.values()])) ❸
clean_table = sample(filter_outliers(table, clean_fields), sample_rate)❹
print("%d/%d rows included" % (clean_table.num_rows, table.num_rows))
shards = {}
for name, encoder in FEATURES.items(): ❺
print("Processing features: %s" % feat)
shards[name] = encoder.encode(clean_table) ❻
return shards
def merge(train_inputs, test_inputs): ❼
train_data = {}
test_data = {}
for name, encoder in FEATURES.items(): ❽
train_shards = [inp.shards[name] for inp in train_inputs]
test_shards = [inp.shards[name] for inp in test_inputs]
train_data[name] = encoder.merge(train_shards) ❾
test_data[name] = encoder.merge(test_shards) ❾
return train_data, test_data
❶ 导入发现的特征
❷ 将特征编码器应用于事实表
❸ 生成需要清理的一组字段
❹ 清理并采样事实
❺ 遍历所有编码器
❻ 执行一个编码器
❼ 分别合并训练和测试数据
❽ 遍历所有特征
❾ 合并一个特征的特性碎片
将代码保存在 taxi_modules/encoders.py 中。现在我们已经准备好了可插拔特征编码器的机制!
我们可以组合一个工作流程,如列表 9.6 所示,该工作流程发现数据,并行生成数据碎片的特征,并最终合并一个最终数据集。工作流程的结构类似于第七章中的 TaxiRegressionFlow,但这次我们不在工作流程本身中硬编码特征,而是让插件指定它们。这样,数据科学家可以重用相同的流程——确保所有结果都是可比较的——并专注于开发新的特征编码器和模型。
在这个例子中,我们将使用第七章中介绍的两个月的出租车行程数据,即 2014 年 9 月和 10 月的数据。为了测试模型性能,我们使用 11 月的数据。我们将使用 foreach 将每个月的数据作为一个单独的分片来处理。
列表 9.6 执行可插入特征编码器的工作流程
from metaflow import FlowSpec, step, conda, S3, conda_base,\
resources, Flow, project, Parameter
from taxi_modules import init, encoders, FEATURES, FEATURE_LIBRARIES
from taxi_modules.table_utils import filter_outliers, sample
init()
TRAIN = ['s3://ursa-labs-taxi-data/2014/09/', ❶
's3://ursa-labs-taxi-data/2014/10/']
TEST = ['s3://ursa-labs-taxi-data/2014/11/'] ❷
@project(name='taxi_regression')
@conda_base(python='3.8.10', libraries={'pyarrow': '3.0.0'})
class TaxiRegressionDataFlow(FlowSpec):
sample = Parameter('sample', default=0.1)
@step
def start(self):
self.features = list(FEATURES) ❸
print("Encoding features: %s" % ', '.join(FEATURES))
with S3() as s3:
self.shards = []
for prefix in TEST + TRAIN:
objs = s3.list_recursive([prefix]) ❹
self.shards.append([obj.url for obj in objs]) ❹
self.next(self.process_features, foreach='shards')
@resources(memory=16000)
@conda(libraries=FEATURE_LIBRARIES) ❺
@step
def process_features(self):
from pyarrow.parquet import ParquetDataset
with S3() as s3:
objs = s3.get_many(self.input) ❻
table = ParquetDataset([obj.path for obj in objs]).read() ❻
self.shards = encoders.execute(table, self.sample) ❼
self.next(self.join_data)
@resources(memory=16000)
@conda(libraries=FEATURE_LIBRARIES)
@step
def join_data(self, inputs):
self.features = inputs[0].features ❽
self.train_data,\
self.test_data = encoders.merge(inputs[1:], [inputs[0]]) ❾
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
TaxiRegressionDataFlow()
❶ 使用两个月的数据进行训练
❷ 使用一个月的数据进行测试
❸ 将特征集作为工件持久化,以供后续分析
❹ 发现数据分片
❺ 确保编码器需要的库可用
❻ 下载并解码分片
❼ 执行分片编码器
❽ 确保在连接步骤之后特征工件可用
❾ 分别合并训练数据和测试数据的分片
将代码保存到 taxi_modules 目录旁边(而不是内部),文件名为 taxi_regression_data.py。此时,目录结构应该如下所示:
taxi_regression_data.py
taxi_modules/__init__.py
taxi_modules/feat_baseline.py
taxi_modules/numpy_encoder.py
taxi_modules/encoders.py
taxi_modules/table_utils.py
您现在可以按照以下方式测试工作流程:
# python taxi_regression_data.py --environment=conda run
它应该打印“处理特征:基线”三次,每次对应一个分片。如果您好奇,可以打开一个笔记本来检查 train_data 和 test_data 工件,我们很快就会使用它们。
如果您已经像第四章中讨论的那样设置了计算层,例如 AWS Batch,您可以在云中执行工作流程。例如,您可以尝试以下操作:
# python taxi_regression_data.py --environment=conda run --with batch
如前所述,这样您可以扩展工作流程以处理更大的数据集,并在需要时更快地生成特征。
可插入的编码器是这个工作流程中最令人兴奋的主要功能。让我们通过创建另一个编码器来测试它们是如何工作的。这次我们创建一个不依赖于输入数据中的 trip_distance 字段的特征——假设我们的应用程序没有它可用或者我们不信任出租车计价器的读数。相反,我们根据事实表中可用的接车和下车位置的坐标来确定行程距离。
我们的新特性,称为欧几里得距离,定义在下一列表中,它测量两个位置之间的欧几里得距离。这显然是不准确的:城市中的出租车行程通常比直线距离要长,而且地球是圆的,所以我们不能在长距离上使用简单的欧几里得公式。然而,正如通常情况那样,一个简单的方法,尽管有已知缺陷,也能让我们快速开始。
列表 9.7 将欧几里得行程距离编码为特征
from taxi_modules.numpy_encoder import NumpyArrayFeatureEncoder
class FeatureEncoder(NumpyArrayFeatureEncoder):
NAME = 'euclidean'
FEATURE_LIBRARIES = {}
CLEAN_FIELDS = ['pickup_latitude', 'pickup_longitude', ❶
'dropoff_latitude', 'dropoff_longitude']
@classmethod
def encode(cls, table):
import numpy
plon = table['pickup_longitude'].to_numpy() ❷
plat = table['pickup_latitude'].to_numpy() ❷
dlon = table['dropoff_longitude'].to_numpy() ❷
dlat = table['dropoff_latitude'].to_numpy() ❷
euc = numpy.sqrt((plon - dlon)**2 + (plat - dlat)**2) ❸
return {'euclidean_distance': euc}
❶ 从事实表中提取坐标
❷ 将坐标转换为 NumPy 数组
❸ 计算坐标之间的欧几里得距离
将代码保存到 taxi_modules/feat_euclidean.py。请注意,编码器使用 NumPy 数组执行所有数学运算,避免了转换为单个 Python 对象,这使得编码器非常高效——遵循第五章的建议。
之后,再次按照以下方式运行工作流程:
# python taxi_regression_data.py --environment=conda run
这次,你应该能看到两个处理特征:基线处理特征和欧几里得处理特征。添加新特征只需在列表 9.7 中编写特征的定义——不需要对工作流程进行任何更改。你可以想象多个科学家在一段时间内协作创建新的特征和模型,这些模型通过共享工作流程进行评估和基准测试,确保结果的可靠性。
taxi_modules 目录中的模块演示了一个有用的模式:我们使用底层通用基础设施及其周围的抽象,如 Metaflow,作为基础。在其之上,我们创建了一个定制的、特定领域的库,这使得针对特定应用(在这种情况下,是行程价格预测)进行迭代变得更加容易。图 9.4 说明了这种模式。

图 9.4 基础基础设施堆栈之上的特定领域库
这种模式允许你有效地处理各种用例。通用基础设施可以专注于基础问题,如数据、计算、编排和版本控制,而高级、特定领域的库可以规范如何开发单个应用的策略。当应用的需求变化时,也可以快速演进特定领域库,同时保持基础稳定。
建议:使用特定领域库来规范应用特定的策略和通用基础设施来处理低级问题。这样,你就不需要针对特定用例优化整个堆栈。
现在我们有了训练和测试数据集,我们可以开始基准测试模型。与特征编码器类似,我们希望能够轻松地定义新的模型作为可插拔的模块。
9.1.3 基准测试模型
对于这个项目,我们将模型定义为模型架构和训练代码的组合,以及一组特征。这使得我们能够轻松地测试使用不同特征集的不同模型变体。与特征编码器类似,我们定义了一个所有模型都必须实现的通用接口。该接口定义了以下方法:
-
fit(train_data) 使用训练数据训练模型。
-
mse(model, test_data) 使用 test_data 评估模型,并返回衡量预测精度的均方误差。
-
save_model(model) 将模型序列化为字节。
-
load_model(blob) 从字节中反序列化模型。
最后两个方法是必需的,用于持久化模型。默认情况下,如第三章所述,Metaflow 使用 Python 内置序列化器 Pickle 将对象序列化为字节。许多机器学习模型包括它们自己的序列化方法,这些方法比 Pickle 更可靠,因此我们允许模型类使用自定义序列化器。值得注意的是,生成的字节仍然存储为 Metaflow 工件,因此模型存储和访问方式与其他任何工作流程结果相同。
我们首先定义一个简单的线性回归模型,使用实际距离来预测价格,就像我们在第七章中使用的那样。我们可以将不依赖于 actual_distance 特征的模型与其他基准模型进行比较。我们将很快定义通用回归器的代码,但首先我们从一个模型规范开始,如下所示。
列表 9.8 基线线性回归模型
from taxi_modules.regression import RegressionModel ❶
class Model(RegressionModel):
NAME = 'distance_regression' ❷
MODEL_LIBRARIES = {'scikit-learn': '0.24.1'} ❸
FEATURES = ['baseline'] ❹
regressor = 'actual_distance' ❺
❶ 利用通用回归模型
❷ 模型名称
❸ 使用 Scikit-Learn 作为建模库
❹ 需要基线特征编码器
❺ 使用 actual_distance 变量来预测价格
将代码保存在 taxi_modules/model_baseline.py 中。请记住,列表 9.8 加载具有 model_ 前缀的文件中的模型。在 RegressionModel 基类中,FEATURES 和 regressor 属性的作用变得更加清晰,该类定义在下一个代码示例中。
列表 9.9 线性回归模型的超类
class RegressionModel():
@classmethod
def fit(cls, train_data):
from sklearn.linear_model import LinearRegression
d = train_data[cls.FEATURES[0]][cls.regressor].reshape(-1, 1) ❶
model = LinearRegression().fit(d, train_data['baseline']['amount'])
return model
@classmethod
def mse(cls, model, test_data):
from sklearn.metrics import mean_squared_error
d = test_data[cls.FEATURES[0]][cls.regressor].reshape(-1, 1) ❷
pred = model.predict(d) ❷
return mean_squared_error(test_data['baseline']['amount'], pred) ❷
@classmethod
def save_model(cls, model): ❸
return model
@classmethod
def load_model(cls, model): ❸
return model
❶ 使用 Scikit-Learn 拟合单变量线性回归模型
❷ 使用 Scikit-Learn 测试单变量线性回归模型
❸ 使用标准的 Python Pickle 序列化模型;不需要任何自定义
将代码保存在 taxi_modules/regression.py 中。该模块定义了一个简单的线性回归模型,使用 Scikit-Learn,它使用单个变量,定义在 regressor 属性中,来预测行程价格,存储在 amount 变量中。我们使用 Scikit-Learn 的 mean_squared_error 函数在 mse 方法中测量模型精度。在 save_model 和 load_model 中序列化和反序列化模型不需要任何特殊操作,因为 Scikit-Learn 模型与 Pickle 配合得很好。
模型工作流程
让我们定义一个工作流程来运行模型。我们允许每个模型定义它们期望可用的特征。只有所有特征都可用的模型才被启用。这样,在原型设计期间移除和添加特征编码器时,模型不会随机失败。合格模型的列表在启动步骤中确定。图 9.5 显示了工作流程的结构。

图 9.5 两个出租车工作流程之间的关系:数据和模型
每个模型,由假设的模型 A、B 和 C 表示,由一个单独的 foreach 分支处理。首先,在训练步骤中,我们使用 TaxiRegressionDataFlow 生成的 train_data 数据集训练一个模型。然后,在评估步骤中,我们使用 test_data 评估模型性能。在合并步骤中,打印出模型评估的摘要。下一个列表显示了代码。
列表 9.10 执行可插拔模型的流程
from metaflow import FlowSpec, step, conda, S3, conda_base,\
resources, Flow, project, profile
from taxi_modules import init, MODELS, MODEL_LIBRARIES
init()
@project(name='taxi_regression')
@conda_base(python='3.8.10', libraries={'pyarrow': '3.0.0'})
class TaxiRegressionModelFlow(FlowSpec):
@step
def start(self):
run = Flow('TaxiRegressionDataFlow').latest_run ❶
self.data_run_id = run.id ❶
self.features = run.data.features ❷
self.models = [name for name, model in MODELS.items() ❸
if all(feat in self.features\
for feat in model.FEATURES)]
print("Building models: %s" % ', '.join(self.models))
self.next(self.train, foreach='models')
@resources(memory=16000)
@conda(libraries=MODEL_LIBRARIES)
@step
def train(self):
self.model_name = self.input
with profile('Training model: %s' % self.model_name):
mod = MODELS[self.model_name]
data_run = Flow('TaxiRegressionDataFlow')[self.data_run_id] ❹
model = mod.fit(data_run.data.train_data) ❺
self.model = mod.save_model(model) ❻
self.next(self.eval)
@resources(memory=16000)
@conda(libraries=MODEL_LIBRARIES)
@step
def eval(self):
with profile("Evaluating %s" % self.model_name):
mod = MODELS[self.model_name]
data_run = Flow('TaxiRegressionDataFlow')[self.data_run_id]
model = mod.load_model(self.model) ❼
self.mse = mod.mse(model, data_run.data.test_data) ❽
self.next(self.join)
@step
def join(self, inputs):
for inp in inputs: ❾
print("MODEL %s MSE %f" % (inp.model_name, inp.mse))
self.next(self.end)
@step
def end(self):
pass
if __name__ == '__main__':
TaxiRegressionModelFlow()
❶ 从 TaxiRegressionDataFlow 的最新运行中访问输入数据
❷ 记录本次运行使用的特征
❸ 根据它们的输入特征确定哪些模型可以执行
❹ 访问训练数据
❺ 训练模型
❻ 使用特定于模型的序列化器将模型保存在一个工件中
❼ 加载模型并反序列化它
❽ 评估模型性能
❾ 打印模型得分的摘要
将代码保存到 taxi_regression_model.py 中。因为此流程访问由 TaxiRegressionDataFlow 生成的结果,请确保您已先运行该流程。此时,目录结构应该如下所示:
taxi_regression_data.py
taxi_regression_model.py
taxi_modules/__init__.py
taxi_modules/feat_baseline.py
taxi_modules/feat_euclidean.py
taxi_modules/model_baseline.py
taxi_modules/regression.py
taxi_modules/numpy_encoder.py
taxi_modules/encoders.py
taxi_modules/table_utils.py
你可以像往常一样运行流程:
# python taxi_regression_model.py --environment=conda run
你应该在输出中看到这些行:Training model: distance_regression 和 Evaluating distance_regression。最终的评估应该大致如下:
MODEL distance_regression MSE 9.451360
为了使事情更有趣,让我们定义另一个使用我们之前定义的欧几里得距离特征的回归模型。请看下面的代码列表。
列表 9.11 使用欧几里得距离特征的回归模型
from taxi_modules.regression import RegressionModel
class Model(RegressionModel):
NAME = 'euclidean_regression'
MODEL_LIBRARIES = {'scikit-learn': '0.24.1'}
FEATURES = ['euclidean']
regressor = 'euclidean_distance'
将代码保存到 taxi_modules/model_euclidean.py 中,然后再次按照以下方式运行工作流程:
# python taxi_regression_model.py --environment=conda run
这次,你应该会看到两个模型并行训练和评估:distance_regression 和 euclidean_regression。输出将如下所示:
MODEL euclidean_regression MSE 15.199947
MODEL distance_regression MSE 9.451360
毫不奇怪,使用起点和终点之间的欧几里得距离来预测价格的模型,其均方误差比使用实际行驶距离的基线模型要高。有了这两个模型,我们已经为未来的模型建立了一个坚实的基线。一个更复杂的模型应该能够轻松超越 euclidean_regression 的性能。如果能仅依靠位置特征就接近 distance_regression 的性能,那就太棒了。在下一节中,我们将构建一个更复杂的模型来应对这一挑战。
9.2 深度回归模型
如果你曾在大城市乘坐过出租车,你就会知道两个地点之间的欧几里得距离甚至实际路线长度都可能不是完成行程所需实际时间的良好预测指标。一些地点容易发生交通堵塞或以其他方式行驶缓慢。一个智能模型会根据历史数据学习识别这些缓慢区域,并相应地估计行程价格。
让我们先思考如何构建能够捕捉两个地点的旅行时间和距离作为函数的特征。首先,我们不需要任意精确的位置。几个城市街区之间的距离差异通常不会导致价格的系统差异。因此,我们不是使用精确坐标,而是可以使用图 9.6 中可视化的地图网格来编码起点和终点位置。

图 9.6 曼哈顿的假设地图网格
例如,使用图 9.6 中的网格,你可以将两个地点之间的行程编码为网格坐标对,例如 A4-F2 和 G7-B6。当然,现实世界的应用会使用比图中展示的更精细的网格。
你会如何将这样的位置对编码为特征?我们可以将像 A4 和 F2 这样的网格位置视为标记或单词,就像我们在第五章中处理 Yelp 评论聚类时做的那样。我们可以有一个高维向量,代表每个网格位置作为一个单独的维度。然后我们可以应用多热编码来标记接车和下车位置为 1,其他维度为 0,从而产生一个稀疏的行程向量。图 9.7 说明了这个想法。

图 9.7 将出租车行程编码为多热二进制向量
这种方法的一个不便之处在于,我们必须预先确定维度,即地图网格。如果我们使网格太小,我们无法处理区域外的行程。如果我们使它太大,数据就会变得非常稀疏,并且可能处理速度较慢。此外,我们必须维护网格位置和维度之间的映射。
任何高基数分类变量都存在相同的问题。解决这个问题的著名方法之一是特征哈希:对于每个可能的值,我们不是为每个值创建一个命名的维度,而是生成每个值的哈希值,并相应地将其放入一个桶中。关键的是,桶的数量比原始的区分值要少得多。只要哈希函数保持一致,相同的值总是会落在同一个桶中,从而产生一个与第一种方法相比具有固定、较低维度的多热编码矩阵。图 9.8 说明了这个想法。

图 9.8 将特征哈希应用于行程向量
在图 9.8 中,我们假设 hash(A4) = 桶 2,hash(F2) = 桶 4,等等。请注意,我们可以扩大网格并添加,比如说,坐标 A99,而不会影响现有数据,这是哈希方法的一个好处。此外,我们不必显式存储坐标标签和维度之间的映射,这使得实现变得更加简单。
当使用哈希时,我们无法保证两个不同的值总是会落在不同的桶中。可能两个不同的值会落在同一个桶中,导致数据中出现随机噪声。尽管存在这种不足,特征哈希在实践中往往表现良好。
假设我们想要测试使用哈希网格坐标特征矩阵的想法,如图 9.8 所示。在实际操作中,我们应该如何编码和存储这个矩阵呢?我们可以构建一个特征编码器,生成一个合适的矩阵,而不考虑将要使用它的模型,但思考整个问题的端到端是有益的。让我们看看手头的建模问题。我们的模型将如下所示:
-
高维——为了保持模型具有一定的准确性,网格单元应该在数百米或更小的范围内。因此,一个 100 平方公里的区域需要 10,000 个网格单元,即 10,000 个输入维度。
-
大规模——在我们的输入数据中,我们有数千万的行程可以用来训练模型。
-
非线性—接车和下车位置与价格之间的关系是各种变量的复杂函数,我们希望对其进行建模。
-
稀疏—行程在地图上不是均匀分布的。我们对某些地区的某些地区的数据有限,而对其他地区的数据则很充足。
-
分类回归模型—我们使用分类变量,地图网格上的离散位置,来预测一个连续变量,即行程价格。
考虑到这些特征,我们可能需要比线性回归模型更强大的东西。问题的规模和非线性表明,深度学习模型可能是这项工作的合适工具。我们选择使用 Keras,这是一个易于使用的、流行的深度学习包,它包含在 TensorFlow 包中。
按照深度学习领域广泛使用的命名法,我们称输入矩阵为 张量。在这个例子中,张量表现得就像任何其他数组一样,比如我们之前使用的 NumPy 数组,所以不要让听起来很花哨的词吓到你。一般来说,张量可以被视为可以通过定义良好的数学操作进行操作的多维数组。如果你对此感兴趣,可以在 www.tensorflow.org/guide/tensor 上了解更多关于它们的信息。
开发高质量的深度神经网络模型涉及艺术和科学,以及许多次的试验和错误。我们相信这些主题对于专业数据科学家来说已经是熟悉的,但如果不是,已经有大量高质量的在线材料和书籍可供参考。因此,深度学习的细节超出了本书的范围。本书的目标是为开发这些模型的数据科学家提供有效的基础设施,比如我们迄今为止已经开发的脚手架。
9.2.1 编码输入张量
让我们创建一个实现先前想法的特征编码器。我们的编码器应该执行以下任务:
-
将坐标转换为网格位置。我们可以使用现成的 地理哈希 库,例如 python-geohash,来完成这项工作。给定一对纬度和经度,它会产生一个表示相应网格位置的短字符串地理令牌。有关地理哈希的更多详细信息,请参阅维基百科上的相关文章(
en.wikipedia.org/wiki/Geohash)。 -
将地理令牌哈希到固定数量的桶中。
-
将桶多热编码以生成稀疏张量。
-
合并并存储编码为张量的特征碎片,以供后续使用。
你可以在编码器中调整以下两个参数来调整资源消耗-精度权衡:
-
NUM_HASH_BINS—确定特征哈希的桶数。数字越小,哈希冲突越多,因此数据中的噪声也越多。另一方面,数字越大将需要更大的模型,这将使训练速度变慢且资源消耗更多。你可以尝试一个产生最佳结果的数字——没有唯一的正确答案。
-
精度——确定 geohash 的粒度,即网格大小。数字越高,位置越准确,但数字越高也需要更高的 NUM_HASH_BINS 来避免冲突。此外,数字越高,数据越稀疏,可能会损害准确性。默认的 PRECISION=6 对应于大约 0.3 × 0.3 英里网格。
编码器在下一列表中实现。
将列表 9.12 编码为特征的哈希行程向量
from metaflow import profile
NUM_HASH_BINS = 10000 ❶
PRECISION = 6 ❷
class FeatureEncoder():
NAME = 'grid'
FEATURE_LIBRARIES = {'python-geohash': '0.8.5',
'tensorflow-base': '2.6.0'}
CLEAN_FIELDS = ['pickup_latitude', 'pickup_longitude',
'dropoff_latitude', 'dropoff_longitude']
@classmethod
def _coords_to_grid(cls, table): ❸
import geohash ❹
plon = table['pickup_longitude'].to_numpy()
plat = table['pickup_latitude'].to_numpy()
dlon = table['dropoff_longitude'].to_numpy()
dlat = table['dropoff_latitude'].to_numpy()
trips = []
for i in range(len(plat)): ❺
pcode = geohash.encode(plat[i], plon[i], precision=PRECISION) ❻
dcode = geohash.encode(dlat[i], dlon[i], precision=PRECISION) ❻
trips.append((pcode, dcode)) ❼
return trips
@classmethod
def encode(cls, table):
from tensorflow.keras.layers import Hashing, IntegerLookup
with profile('coordinates to grid'):
grid = cls._coords_to_grid(table)
hashing_trick = Hashing(NUM_HASH_BINS) ❽
multi_hot = IntegerLookup(vocabulary=list(range(NUM_HASH_BINS)), ❾
output_mode='multi_hot',
sparse=True)
with profile('creating tensor'):
tensor = multi_hot(hashing_trick(grid)) ❿
return {'tensor': tensor}
@classmethod
def merge(cls, shards):
return {key: [s[key] for s in shards] for key in shards[0]} ⓫
将地理定位哈希到 10,000 个桶
网格粒度
将坐标转换为网格位置
使用 geohash 库生成网格位置
遍历输入表中的所有行程
为每个行程生成一对 geohash
将对存储在列表中的对进行存储
使用 Keras 哈希层执行特征哈希
使用 Keras IntegerLookup 层执行多热编码
生成一个张量
将特征碎片的张量合并到一个大张量中
将代码保存在 taxi_modules/feat_gridtensor.py 中。有关 Keras 层(哈希和 IntegerLookup)的详细信息,请参阅 Keras 文档keras.io。本质上,它们实现了我们之前讨论的哈希和多热编码思想。在张量的情况下,合并方法可以简单地在一个字典中收集碎片。我们不需要将它们合并到一个大的张量中,因为我们将通过一个自定义数据加载器将张量喂给模型,如以下所示。
数据加载器
如何有效地将数据喂入深度学习模型是一个深奥的话题。一个关键挑战是我们可能想使用 GPU 来训练模型,但典型的 GPU 没有足够的内存一次在 GPU 内存中存储整个数据集。为了克服这一限制,我们必须以小批量的方式将数据喂给 GPU。
下一列表显示一个简单的数据加载器,该加载器接受由前面定义的合并方法产生的特征碎片作为 tensor_ 碎片。对于训练,我们可以指定一个目标变量,在我们的例子中是一个包含行程价格的 NumPy 数组,该数组被切片并随训练数据一起返回给模型。
列表 9.13 Keras 模型的数据加载器
BATCH_SIZE = 128 ❶
def data_loader(tensor_shards, target=None): ❷
import tensorflow as tf
_, dim = tensor_shards[0].shape ❸
def make_batches(): ❹
if target is not None:
out_tensor = tf.reshape(tf.convert_to_tensor(target),
(len(target), 1)) ❺
while True: ❻
row = 0 ❼
for shard in tensor_shards: ❽
idx = 0 ❾
while True: ❿
x = tf.sparse.slice(shard, [idx, 0], [BATCH_SIZE, dim])⓫
n, _ = x.shape ⓬
if n > 0: ⓭
if target is not None:
yield x, tf.slice(out_tensor, [row, 0], [n, 1])⓮
else:
yield x ⓯
row += n ⓰
idx += n ⓰
else:
break
input_sig = tf.SparseTensorSpec(shape=(None, dim)) ⓱
if target is None:
signature = input_sig ⓲
else:
signature = (input_sig, tf.TensorSpec(shape=(None, 1))) ⓲
dataset = tf.data.Dataset.from_generator(make_batches,\
output_signature=signature) ⓳
data.prefetch(tf.data.AUTOTUNE) ⓴
return input_sig, dataset
将此值增加以提高训练速度
定义训练的目标变量;测试时没有目标
输入张量中的哈希桶数量
生成带有可选目标向量的输入批次
将 NumPy 数组转换为张量
无限循环。当需要时,训练代码将停止。
在每个 epoch 后重置目标索引
遍历所有特征碎片
重置碎片索引
从碎片中提取批次,直到没有更多行为止
从碎片中切片一个批次
获取批次的行数
如果批次非空,则产生它;否则,移动到下一个碎片
从目标数组中切片向量
如果未指定目标,则仅返回输入数据
增加行索引到下一个批次
指定输入张量的类型
对于测试,数据集仅包含输入张量。
⓲ 对于训练,数据集还包含目标向量。
⓳ 生成一个封装生成器的数据集对象
⓴ 优化数据访问
将代码保存在 taxi_modules/dnn_data.py 中。一个轻微的复杂性是由于目标是一个大的 NumPy 数组,而训练数据存储在多个稀疏张量分片中。我们必须确保来自两个来源的特征保持对齐。图 9.9 展示了这种情况。

图 9.9 分片张量与单个 NumPy 目标数组之间的对齐批次
图 9.9 展示了左侧的三个特征分片和右侧的目标数组。注意每个分片末尾的最后一个批次,即批次 5、8 和 10,比其他批次小。特征分片的大小是任意的,因此不一定能被 BATCH_SIZE 整除。列表 9.13 维护了两个索引变量:row 用于跟踪当前分片中的行,idx 用于跟踪目标数组中的索引。行索引在每个分片时重置,而 idx 在分片间递增。
批次由一个生成器函数 data_loader 返回,该函数无限循环遍历数据。在机器学习的上下文中,整个数据集的一次迭代通常被称为一个epoch。训练过程在多个 epoch 上运行,优化模型的参数。最终,训练过程达到一个停止条件,例如达到预定义的 epoch 数,然后停止从数据加载器中消耗数据。
生成器函数被封装在一个 TensorFlow Dataset 对象中,我们的 Keras 模型能够消费这个对象。我们必须手动指定数据集中包含的数据类型。对于训练,数据集包含稀疏传感器和目标变量的元组。对于测试,数据集只包含稀疏张量。
注意文件顶部的 BATCH_SIZE 参数。调整批次大小是你可以调整以微调训练性能的关键旋钮之一:更高的值将导致训练速度更快,尤其是在 GPU 上,但模型的准确性可能会受到影响。一般来说,较低的值会导致更好的准确性,但会以较慢的训练时间为代价。另一个值得强调的小细节是数据集.prefetch 调用在末尾:这个调用指示 TensorFlow(Keras 在底层使用)在计算前一个批次的同时,将下一个批次加载到 GPU 内存中,从而为训练性能提供小幅提升。
现在我们有了可以由自定义数据加载器消费的输入张量的机制。下一步是开发模型本身。
9.2.2 定义深度回归模型
为了让你了解我们价格预测任务的实际模型看起来像什么,我们在 Keras 中定义并训练了一个深度神经网络模型。我们展示了如何用数据喂养它,在 GPU 上运行它,监控其训练,并评估其性能与其他模型。这个例子类似于任何开发类似模型的数据科学家都会经历的过程。
首先,我们处理一些日常的账务问题。我们首先定义了两个实用函数,load_model 和 save_model,这些函数可以用来持久化任何 Keras 模型。我们在列表 9.14 中定义的 KerasModel 辅助类允许你将模型作为工件存储,绕过 Keras 模型默认无法序列化的事实。
该课程利用内置的 Keras 函数将模型保存到文件和从文件加载。我们不能直接使用 Keras 函数,因为本地文件在计算层之间不会工作,例如,当你运行 -with batch 时。此外,我们希望利用 Metaflow 内置的版本控制和数据存储来跟踪模型,这比手动组织本地文件要容易得多。
列表 9.14 Keras 模型的超类
import tempfile
class KerasModel():
@classmethod
def save_model(cls, model):
import tensorflow as tf
with tempfile.NamedTemporaryFile() as f:
tf.keras.models.save_model(model, f.name, save_format='h5') ❶
return f.read() ❷
@classmethod
def load_model(cls, blob):
import tensorflow as tf
with tempfile.NamedTemporaryFile() as f:
f.write(blob) ❸
f.flush()
return tf.keras.models.load_model(f.name) ❹
❶ 将模型保存到临时文件
❷ 从临时文件中读取表示模型的字节
❸ 将字节写入临时文件
❹ 请求 Keras 从临时文件中读取模型
将代码保存在 taxi_modules/keras_model.py 中。我们可以使用这些方法来处理任何子类 KerasModel 的 Keras 模型,就像我们稍后定义的那样。
接下来,我们将为我们的非线性回归任务在列表 9.15 中定义一个模型架构。尽管列表展示了一个合理的架构,但许多其他架构可能在这个任务上表现更好。找到一个能够产生稳健结果的架构涉及大量的试错,加快这一过程是数据科学基础设施的关键动机。作为一个练习,你可以尝试找到一个性能更好的架构,无论是训练速度还是准确性。
列表 9.15 带参数的流程
def deep_regression_model(input_sig): ❶
import tensorflow as tf
model = tf.keras.Sequential([
tf.keras.Input(type_spec=input_sig), ❷
tf.keras.layers.Dense(2048, activation='relu'), ❸
tf.keras.layers.Dense(128, activation='relu'), ❸
tf.keras.layers.Dense(64, activation='relu'), ❸
tf.keras.layers.Dense(1) ❹
])
model.compile(loss='mean_squared_error', ❺
steps_per_execution=10000, ❻
optimizer=tf.keras.optimizers.Adam(0.001))
return model
❶ 作为参数接受输入张量类型签名
❷ 输入层根据输入数据签名进行形状定义。
❸ 定义隐藏层
❹ 目标变量(行程价格)
❺ 最小化均方误差
❻ 加速 GPU 上的处理
将代码保存在 taxi_modules/dnn_model.py 中。列表 9.15 中的模型由一个稀疏输入层组成,匹配我们的输入特征,三个隐藏层,以及一个密集输出变量,代表我们想要预测的行程价格。请注意,我们使用均方误差作为损失函数来编译模型,这是我们关心的指标。steps_per_exeuction 参数通过一次加载多个批次来加速 GPU 上的处理。接下来,我们将通过以下方式指定一个模型插件,即组合到目前为止我们已经开发的所有组件:
-
模型是 KerasModel 的子类,用于持久化。
-
使用 dnn_data 模块中的 data_loader 加载数据。
-
从 dnn_module 模块加载模型本身。
以下代码列表显示了模型模块。
列表 9.16 Keras 模型的模型定义
from .dnn_data import data_loader, BATCH_SIZE
from .keras_model import KerasModel
from .dnn_model import deep_regression_model
EPOCHS = 4 ❶
class Model(KerasModel):
NAME = 'grid_dnn'
MODEL_LIBRARIES = {'tensorflow-base': '2.6.0'} ❷
FEATURES = ['grid']
@classmethod
def fit(cls, train_data):
import tensorflow as tf
input_sig, data = data_loader(train_data['grid']['tensor'],
➥ train_data['baseline']['amount']) ❸
model = deep_regression_model(input_sig) ❹
monitor = tf.keras.callbacks.TensorBoard(update_freq=100) ❺
num_steps = len(train_data['baseline']['amount']) // BATCH_SIZE ❻
model.fit(data, ❼
epochs=EPOCHS,
verbose=2,
steps_per_epoch=num_steps,
callbacks=[monitor])
return model
@classmethod
def mse(cls, model, test_data):
import numpy
_, data = data_loader(test_data['grid']['tensor']) ❽
pred = model.predict(data)
arr = numpy.array([x[0] for x in pred]) ❾
return ((arr - test_data['baseline']['amount'])**2).mean() ❿
❶ 训练周期数。增加以获得更准确的结果。
❷ 如果想利用 GPU,请将其更改为{'tensorflow-gpu': '2.6.2'}。
❸ 初始化数据加载器
❹ 创建一个模型
❺ 使用 TensorBoard 监控进度
❻ 批次数
❼ 训练模型
❽ 在测试中初始化数据加载器;没有目标变量
❾ 将结果张量转换为 NumPy 数组
❿ 计算预测值与正确价格之间的均方误差
将代码保存在 taxi_modules/model_grid.py 中。最终的目录结构应如下所示:
taxi_regression_data.py
taxi_regression_model.py
taxi_modules/__init__.py
taxi_modules/feat_baseline.py
taxi_modules/feat_euclidean.py
taxi_modules/feat_modelgrid.py
taxi_modules/model_baseline.py
taxi_modules/model_euclidean.py
taxi_modules/model_grid.py
taxi_modules/regression.py
taxi_modules/numpy_encoder.py
taxi_modules/encoders.py
taxi_modules/table_utils.py
taxi_modules/keras_model.py
taxi_modules/dnn_model.py
taxi_modules/dnn_data.py
这开始看起来像是一个真正的数据科学项目!幸运的是,每个模块都很小,整体结构相当易于理解,尤其是在文档的辅助下。我们现在可以开始训练模型了。
9.2.3 训练深度回归模型
让我们先说一句警告:训练深度神经网络模型是一个非常计算密集的过程。而我们在本章前面定义的线性回归模型可以在几秒钟内完成训练,而上面定义的深度回归模型可能需要数小时甚至数天才能训练,这取决于您的硬件。
通常,在花费数小时训练未经测试的模型之前,先进行快速烟雾测试是一个好主意——工作流程是否成功完成?——我们首先快速测试端到端工作流程,确保一切正常工作。您应该能够在几分钟内完成烟雾测试,包括您的笔记本电脑。
小规模训练
使训练变得最快的方法是减少数据量。因此,我们通过创建我们完整数据集的 1%的小样本来开始烟雾测试。运行我们定义在列表 9.6 中的数据工作流程,创建样本如下:
# python taxi_regression_data.py --environment=conda run -sample 0.01
现在,我们可以运行模型工作流程:
# python taxi_regression_model.py --environment=conda run
假设所有三个模型插件都存在于 taxi_modules 目录中,启动步骤应打印以下行:
Building models: euclidean_regression, grid_dnn, distance_regression
使用 1%的样本,工作流程应运行约 5-10 分钟,具体取决于您的硬件。模型基准测试的结果在 join 步骤中输出。它应该看起来像这样:
MODEL euclidean_regression MSE 15.498595
MODEL grid_dnn MSE 24.180864
MODEL distance_regression MSE 9.561464
如预期的那样,使用实际距离的距离回归模型表现最佳。遗憾的是,但也是意料之中的,我们的深度回归模型 grid_dnn 在少量数据训练时表现不如使用欧几里得行程距离的模型。众所周知,当数据量有限时,传统的机器学习方法往往优于深度学习。然而,如果您看到这样的结果,您应该庆祝:整个设置从头到尾都工作得很好!
大规模训练
对于更真实、更大规模训练,您可以采用以下最佳实践:
-
使用 GPU 加速训练。您可以通过以下方式完成:
-
如果你有这样的硬件可用,你可以利用你的笔记本电脑或台式机上的 GPU。
-
你可以将 GPU 实例作为云工作站启动,并在实例上执行示例。确保你使用包含 CUDA 内核库的实例镜像(AMI),例如 AWS Deep Learning AMI (
aws.amazon.com/machine-learning/amis/)。 -
你可以设置一个远程计算层,例如,带有 GPU 实例的批处理计算环境。
-
-
确保你的工作站训练过程中不会终止(例如,你的笔记本电脑电量耗尽或与工作站的 SSH 连接断开)。如果你使用云工作站,建议使用终端多路复用器如 screen 或 tmux 来确保进程持续运行,即使网络连接断开。例如,查看
mng.bz/lxEB以获取说明。或者,如果你使用 GPU 驱动的计算层,可以将工作流程部署到生产调度器,如第六章中讨论的 AWS Step Functions,它负责可靠地运行工作流程。 -
使用像 TensorBoard 这样的监控工具,它是 Google 免费提供的开源软件包和服务,以监控进度。尽管这不是必需的,但看到训练任务正在取得进展可以让人放心。
如果你想要利用 GPU 进行训练,请替换此行
MODEL_LIBRARIES = {'tensorflow-base': '2.6.0'}
在 model_grid.py 中使用
MODEL_LIBRARIES = {'tensorflow-gpu': '2.6.2'}
以使用 TensorFlow 的 GPU 优化版本。
当你的工作站准备就绪后,通过执行,例如:
# python taxi_regression_data.py --environment=conda run -sample 0.2
你可以使用高达 100%的样本来测试训练,具体取决于你的硬件和耐心。如果你在云工作站上执行命令,请确保在 screen 或 tmux 中运行这些命令,以便在 SSH 会话断开时重新连接到进程。你可以按照以下方式启动训练运行:
# python taxi_regression_model.py --environment=conda run -max-workers 1
注意,我们指定了-max-workers 1,这限制了foreach一次只能运行一个进程。这确保了重量级的 GPU 任务不需要与其他在工作站上同时运行的过程竞争。
因为我们在 model_grid.py 中已经启用了 TensorBoard 的日志记录,我们可以在另一个终端窗口中简单地运行 TensorBoard 来监控进度。打开另一个终端会话,导航到你开始运行的位置。然后,通过运行以下代码来安装 TensorBoard:
# pip install tensorboard
如果你在一个本地机器上运行训练,你可以通过执行以下命令在本地打开 TensorBoard:
# tensorboard --logdir logs
它应该会打印出一个类似 http://localhost:6006/的 URL,你可以将其复制并粘贴到浏览器窗口中。如果你在云工作站上运行,可能更容易依赖于公开托管的 TensorBoard 在tensorboard.dev进行监控。要使用此服务,只需执行以下命令:
# tensorboard dev upload --logdir logs
当你第一次运行命令时,它会要求你进行身份验证并本地保存令牌。完成此操作后,它应该会打印出一个看起来像 tensorboard.dev/experiment/UeHdJZ7JRbGpN341gyOwrnQ/ 的 URL,你可以用浏览器打开它。
本地以及托管在 TensorBoard 上的都应该看起来像图 9.10 中的截图。你可以定期重新加载页面以查看训练进度。如果一切顺利,损失曲线应该像图中所示那样呈下降趋势。

图 9.10 tensorboard.dev 的模型收敛截图
另一个可能在 GPU 系统上可用的实用命令是 nvidia-smi,它显示了 GPU 利用率的统计信息。它应该显示系统中所有可用的 GPU 以及一个利用率数值,如果 GPU 正在被训练过程使用,这个数值应该大于 0%。
在一个强大的 GPU 实例(p3.8xlarge)上,使用完整数据集(100% 样本)进行四个周期的模型训练大约需要八个小时。如果你想尝试加快训练速度,你可以尝试以下不同变体的模型:
-
你可以通过减小 feat_gridtensor.py 中 NUM_HASH_BINS 的值和精度来使输入张量变小。或者,你可以只改变其中一个参数来改变哈希行为进行实验。
-
你可以在 dnn_model.py 中更改模型架构。例如,你可以移除隐藏层或将它们变得更小。
-
你可以将 BATCH_SIZE 增加到一个非常高的数值,比如 10,000,以使模型训练得更快。你可以使用 TensorBoard 来监控批量大小的模型损失效果。
-
你可以通过减小 EPOCHS 的值来减少训练迭代次数。或者,你可以更改其他参数,但增加 EPOCHS。
模型开发的回报之一是结果可以完美量化。你可以在训练完成后立即看到实验参数对模型性能的影响。作为实验的基线,使用完整数据集训练模型会产生以下结果:
MODEL euclidean_regression MSE 15.214128
MODEL grid_dnn MSE 12.765191
MODEL distance_regression MSE 9.461593
我们的努力并非徒劳!当使用完整数据集进行训练时,深度回归模型轻松击败了简单的欧几里得模型,接近实际距离测量的性能。换句话说,我们可以构建一个仅考虑接车和下车位置的模型,以相对准确地预测行程价格,并且这种模型的性能优于仅考虑两地之间直线距离的模型。
作为一项练习,你可以尝试改进模型:例如,你可以包括一天中的时间作为一个特征,这无疑会影响交通模式。你还可以测试模型架构的不同变体,或者尝试改进数据加载器的性能。经过几轮改进后,你应该能够超越基线模型的性能。
然而,本章并非关于价格预测的最佳模型。更重要的是,我们学习了如何设计和开发一个简单的特定领域框架,允许数据科学家定义新的特征和模型,以有效地解决这个特定业务问题,并一致地测试新变体的性能。尽管这个例子相对简单,但你可以用它作为灵感,为你的更复杂框架提供支持,这些框架建立在完整的基础设施堆栈之上。
9.3 总结所学经验
我们以一张图片开始了这本书,如图 9.11 所示,展示了数据科学项目的完整生命周期。我们承诺要涵盖生命周期的所有部分,以便你的组织可以增加同时执行的项目数量(数量),加快上市时间(速度),确保结果稳健(有效性),并使支持更广泛的项目种类成为可能。为了总结这本书,让我们看看我们涵盖的章节如何与这张图相对应。

图 9.11 数据科学项目的完整生命周期
-
模型不应该孤立地构建。我们在多个场合强调了关注业务问题的重要性。我们在第三章介绍了螺旋式方法的概念,并在整本书中的应用示例中进行了应用。
-
数据科学家应该使用哪些工具来有效地开发项目,以及在哪里和如何使用这些工具?第二章全部内容都致力于笔记本、集成开发环境(IDE)、云工作站和工作流程这一主题。我们在第三章介绍了一个特定的框架,Metaflow,它以用户友好的方式解决了许多这些问题。在本章中,我们还展示了如何构建在堆栈之上的自定义库,以特定问题领域提高生产力。
-
我们如何从现成的库中获益,同时又不让项目暴露在随机崩溃和性能下降的风险中?我们在第五章讨论了库的性能影响,并在第六章深入探讨了依赖管理的问题。我们在示例中使用了各种开源机器学习库,最终在本章展示了展示了一个深度神经网络模型。
-
我们应该如何发现、访问和管理数据?第七章全部内容都致力于这个广泛而深入的主题。
-
机器学习项目往往计算量很大——开放式的实验、模型训练和大规模数据处理都需要计算能力。一个人应该如何配置和管理计算资源?第四章深入探讨了计算层和现代容器编排系统。
-
一旦结果可用,如何将它们与周围的业务系统连接起来?关键的是,生产部署应在没有人为干预的情况下可靠运行,正如第六章所讨论的。第八章讨论了如何在各种环境中利用结果,从相对较慢的批量处理到毫秒级实时系统。
-
最后,数据科学项目的成果在实践中得到应用。如果项目的消费者认为结果有希望,循环就会再次开始,因为人们希望使结果变得更好。如果反应是消极的,循环就会再次开始,因为数据科学家将转向新的项目。
循环永不停止的事实是投资有效数据科学基础设施的最终理由。如果循环只运行一次,任何可行的解决方案都足够了。然而,由于循环在多个项目和多个团队之间重复,每个团队都在不断改进他们拥有的应用程序,因此对共同、共享基础的需求变得明显。
希望这本书能帮助你牢固地理解数据科学项目的基础层,包括数据、计算、编排和版本控制。利用这些知识,你能够评估各种技术系统和方法的相对优点,做出明智的决策,并设置一个适合你环境的合理堆栈。
虽然基础可以共享,但随着数据科学应用于生活的新领域,堆栈顶部的应用和方法的多样性将随着时间的推移而增加。当涉及到解决特定的业务问题时,人类创造力和领域专业知识是无法替代的。基础只是基础:现在轮到你了,拿起草图本,进行实验,并开始在堆栈上构建新的、令人兴奋的、量身定制的数据科学应用。
摘要
-
堆栈的顶层,即模型开发和特征工程,往往具有领域特定性。你可以在基础基础设施堆栈之上创建小型、领域特定的库,以满足每个项目的需求。
-
模型和特征编码器可以作为可插拔模块实现,从而实现想法的快速原型设计和基准测试。
-
使用一个共同的流程加载数据,生成训练和测试分割,并执行特征编码器和模型,确保结果在模型之间是一致的且可比较的。
-
现代深度学习库与基础设施堆栈配合良好,尤其是在支持 GPU 的计算层上执行时。
-
使用现成的监控工具和服务,如 TensorBoard,实时监控训练过程。
附录 A. 安装 Conda
Conda 包管理器在第五章中介绍。按照以下说明在您的系统上安装 Conda:
-
打开 Miniconda 的主页
mng.bz/BM5r。 -
下载适用于 Mac OS X 或 Linux 的 Miniconda 安装程序(Metaflow 目前不支持 Windows 原生)。
-
下载完包后,在终端中按照以下方式执行安装程序:
bash Miniconda3-latest-MacOSX-x86_64.sh将包名替换为您实际下载的包名。
-
当安装程序询问“您希望安装程序初始化 Miniconda3 吗?”时,回答是。
-
安装完成后,重新启动您的终端以使更改生效。
-
在新的终端中运行以下命令:
conda config --add channels conda-forge这将使 conda-forge.org 上的社区维护包在您的安装中可用。
就这样!Metaflow 中的 @conda 装饰器将负责使用新安装的 Conda 安装包。



浙公网安备 33010602011771号