深度学习系统设计(全)

深度学习系统设计(全)

原文:zh.annas-archive.org/md5/c7aabb3a9c13924ec60749e96c9ff05f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

序言

如果一个深度学习系统能够连接两个不同的世界——研究和原型设计与生产运营,那么可以假定它是有效的。设计这种系统的团队必须能够与这两个世界中的从业者进行沟通,并处理来自每个世界的不同要求和约束。这需要对深度学习系统中的组件是如何设计的,以及它们预期如何协同工作有一个原则性的理解。现有文献很少涵盖深度学习工程的这一方面。当初级软件工程师入职并期望成为有效的深度学习工程师时,这种信息差距就会成为一个问题。

多年来,工程团队通过使用他们获得的经验并从文献中挖掘出他们需要了解的知识来填补这一空白。他们的工作帮助传统软件工程师在相对较短的时间内构建、设计和扩展深度学习系统。所以当我得知曾领导过深度学习工程团队的 Chi 和 Donald 采取了非常重要的倡议,将这些知识整合并以书籍的形式分享时,我感到非常兴奋。

我们早就需要一本全面的书籍,介绍如何构建支持将深度学习从研究和原型设计转化为生产的系统。设计深度学习系统终于填补了这一需求。

该书以高层次的介绍开始,描述了深度学习系统是什么以及其作用。随后的章节详细讨论了每个系统组件,并就各种设计选择的利弊提供了动机和见解。每一章都以分析结束,帮助读者评估最适合和最相关的选项以满足他们自己的用例。作者在结尾进行了深入讨论,汇总了之前所有章节的内容,探讨了从研究和原型设计到生产的艰难之路。为了帮助工程师将所有这些想法付诸实践,他们创建了一个示例深度学习系统,提供了完全功能的代码,以说明核心概念,并为那些刚刚进入这个领域的人提供一些体验。

总的来说,读者会发现这本书易于阅读和导航,同时将他们对如何策划、设计和实现深度学习系统的理解提升到一个全新的层次。对于所有专业水平的从业者,他们对设计有效的深度学习系统感兴趣,将会将这本书视为一个宝贵的资源和参考。他们会读一遍以了解全局,然后在构建系统、设计组件和做出关键选择以满足使用系统的所有团队时,一遍又一遍地返回阅读。

—Silvio Savarese,Salesforce 执行副总裁,首席科学家

—Caiming Xiong,Salesforce 副总裁

前言

十多年前,我们有幸开发了一些早期的面向最终用户的产品功能,这些功能由人工智能驱动。这是一项巨大的工程。在那个时候,收集和整理适合模型训练的数据并不是一种常见的做法。很少有机器学习算法被打包成可直接使用的库。进行实验需要手动运行管理,并构建自定义的工作流和可视化界面。定制服务器用来为每种类型的模型提供服务。除了那些资源密集型的科技公司外,几乎每一个新的人工智能产品功能都是从零开始构建的。智能应用程序有朝一日成为一种商品是一个遥不可及的梦想。

在使用了一些人工智能应用程序后,我们意识到每次我们都在重复类似的仪式,对我们来说,设计一种系统化的方式,通过原型设计,为交付人工智能产品功能至生产提供帮助,显得更有意义。这一努力的成果是 PredictionIO,这是一套开源的框架软件套件,汇集了最先进的软件组件,用于数据收集和检索、模型训练和模型服务。通过其 API 完全可定制,并通过几个命令即可部署为服务,它有助于缩短每个阶段所需的时间,从运行数据科学实验到训练和部署生产就绪的模型。我们很高兴地得知,世界各地的开发人员能够使用 PredictionIO 来制作他们自己的人工智能应用程序,为他们的业务带来一些令人惊叹的提升。PredictionIO 后来被 Salesforce 收购,以解决一个更大规模的相似问题。

我们决定撰写本书的时候,整个行业都在蓬勃发展,拥有一个健康的人工智能软件生态系统。许多算法和工具已经可以处理不同的用例。一些云服务提供商,如亚马逊、谷歌和微软甚至提供完整的托管系统,使团队可以在一个集中的位置共同进行实验、原型设计和生产部署。无论您的目标是什么,现在您有许多选择,以及许多种方法将它们组合在一起。

在我们与团队合作交付由深度学习驱动的产品功能时,出现了一些反复出现的问题。为什么我们的深度学习系统设计成这个样子?对于其他特定用例来说,这是最好的设计吗?我们注意到,初级软件工程师最经常提出这些问题,我们采访了其中的一些人,想知道为什么。他们透露,他们传统的软件工程训练并没有让他们有效地与深度学习系统合作。当他们寻找学习资源时,他们发现只有零星和零散的关于特定系统组件的信息,几乎没有资源讨论软件组件的基础知识,为什么它们被组合在一起,以及它们是如何共同工作形成完整系统的。

为了解决这个问题,我们开始建立一个知识库,最终演变成了类似手册的学习材料,解释了每个系统组件的设计原则、设计决策的利弊以及从技术和产品角度的理由。我们被告知,我们的材料帮助新团队成员快速上手,并让没有建立深度学习系统经验的传统软件工程师迅速掌握。我们决定与更大的读者群分享这些学习材料,以书的形式。我们联系了 Manning,剩下的就是历史。

致谢

写一本书确实需要很多孤独的努力,但是没有以下个人的帮助,这本书是不可能完成的。

在 Salesforce Einstein 团队(Einstein 平台、E.ai、Hawking)的不同团队中工作,构成了本书的大部分基础。这些杰出而有影响力的团队成员包括(按字母顺序排列)Sara Asher、Jimmy Au、John Ball、Anya Bida、Gene Becker、Yateesh Bhagavan、Jackson Chung、Himay Desai、Mehmet Ezbiderli、Vitaly Gordon、Indira Iyer、Arpeet Kale、Sriram Krishnan、Annie Lange、Chan Lee、Eli Levine、Daphne Liu、Leah McGuire、Ivaylo Mihov、Richard Pack、Henry Saputra、Raghu Setty、Shaun Senecal、Karl Skucha、Magnus Thorne、Ted Tuttle、Ian Varley、Yan Yang、Marcin Zieminski 和 Leo Zhu。

我们也想借此机会感谢我们的开发编辑 Frances Lefkowitz。她不仅是一位提供出色写作指导和内联编辑的优秀编辑,还是一位在整个写书过程中指导我们的优秀导师。没有她,这本书就不会达到目前的质量,也不会按计划完成。

我们感谢 Manning 团队在整本书的写作过程中给予的指导。我们非常感谢通过 Manning 早期访问计划(MEAP)在书写的早期阶段获得读者意见的机会。

致所有审阅者——亚历克斯·布兰克、阿米特·库马尔、阿尤什·托马尔、巴格万·科马迪、迪卡尔·朱亚尔、埃斯雷夫·杜尔纳、戈拉夫·苏德、吉伦姆·阿利昂、哈马德·阿尔沙德、杰米·沙弗、贾普尼特·辛格、杰里米·陈、若昂·迪尼斯·费雷拉、凯蒂娅·帕特金、基思·金、拉里·蔡、玛利亚·安娜、米凯尔·多特雷、尼克·德克鲁斯、尼科尔·康宁斯坦、诺亚·弗林、奥利弗·科尔滕、奥马尔·埃尔·马拉克、普兰杰尔·兰詹、赛义德·艾希-查迪、桑迪普·迪、桑凯特·沙玛、萨特杰·库马尔·沙胡、萨亚克·保罗、希薇塔·乔希、西蒙·斯瓜扎、斯里兰·马查拉、苏米特·巴塔查里亚、厄辛·斯特劳斯、维迪亚·维纳伊和韦伊·罗——感谢你们的建议帮助了这本书更加优秀。

我要感谢我的妻子吴佩,她在写作这本书的过程中给予了我无条件的爱和巨大的支持。在新冠疫情困难的时刻,佩始终是一个宁静祥和的角落,让这本书在一个繁忙的家庭中有两个可爱的小孩——凯瑟琳和天成的情况下得以完成。

另外,我还要感谢许彦,一个才华横溢的 10 倍开发者,他几乎写了整个代码实验室。他的帮助使得代码实验室不仅质量高,而且易于学习。许彦的妻子,董,全心全意地支持着他,这样许彦就能专心致志于书实验室。

我还要感谢的另一个人是黛安·西伯尔德,Salesforce 一位富有才华和经验丰富的技术作家。黛安用她自己的写作经历启发了我,并鼓励我开始写作。

——王弛

共同创办 PredictionIO(后来被 Salesforce 收购)让我学到了关于构建开源机器学习开发者产品的宝贵经验。这一冒险而有回报的旅程不可能没有彼此之间的巨大信任。他们是(按字母顺序排列):肯尼思·陈、汤姆·陈、帕特·费雷尔、伊莎贝尔·李、保罗·李、亚历克斯·梅里特、托马斯·斯通、马可·维维罗和贾斯汀·叶。

西蒙·陈值得特别一提。陈共同创办了 PredictionIO,我也有幸与他在他之前的创业努力中一起工作和学习。当我们在香港的同一所中学(九龙华仁书院)上学时,他是第一个正式向我介绍编程的人。学校的其他鼓舞人心的人包括(按字母顺序排列):唐纳德·陈、杰森·陈、哈姆雷特·朱、郭嘉权、杰弗里·侯、方锦鸿、埃里克·刘、刘金、雷莱克斯·李、凯文·雷、丹尼·辛格、诺曼·苏、史蒂文·唐和罗博·黄。

我非常感激我的父母和我的哥哥罗纳德。他们让我早早接触到计算机。他们的持续支持在我渴望成为一名计算机工程师的成长年代起着至关重要的作用。

我的儿子,斯宾塞,是为什么生物深度神经网络是世界上最令人惊奇的事物的活生证明。他是一份美好的礼物,每天都向我展示,我始终可以成长并变得更好。

无法用言语表达我妻子 Vicky 在我心中的重要性。她总是能够让我在困难时期振作起来,使我变得更好。她是我所能请求的最好的伴侣。

—Donald Szeto

关于本书

本书的目的是为工程师提供设计、构建或设置有效机器学习系统并将这些系统定制为他们可能遇到的任何需求和情况的能力。他们开发的系统将促进、自动化并加速机器学习(尤其是深度学习)项目在各个领域的发展。

在深度学习领域,模型是吸引所有注意力的主角。考虑到从这些模型中开发出的新应用程序经常进入市场——如能够检测人类的安防摄像机、行为像真正人类一样的互联网视频游戏虚拟角色、可以编写代码解决任意问题的程序以及可实现完全自主驾驶的高级驾驶辅助系统,这样的做法或许是正确的。在很短的时间内,深度学习领域充满了巨大的激动和有待完全实现的潜能。

但是,模型并不是孤军奋战。为了将产品或服务推向成熟阶段,需要将模型置于支持其各种服务和存储的系统或平台(我们可以互换使用这些术语)中。例如,它需要一个 API、一个数据集管理器以及用于存储工件和元数据的存储空间,等等。因此,在深度学习模型开发团队的背后,需要有一支非深度学习开发人员的团队,负责创建容纳模型和所有其他组件的基础设施。

我们观察到在这个行业中存在的问题是,通常负责设计深度学习系统和组件的开发人员只具有浅显的深度学习知识。他们不理解深度学习需要从系统工程方面满足的具体要求,因此倾向于在构建系统时遵循通用方法。例如,他们可能会选择将与深度学习模型开发所有工作的抽象化交给数据科学家,并仅关注自动化。因此,它们所构建的系统依赖于传统的作业调度系统或商业智能数据分析系统,这些系统并未针对深度学习训练作业的运行方式或深度学习特定的数据访问模式进行优化。结果,该系统难以用于模型开发,而模型发货速度缓慢。基本上,这些缺乏深刻理解的工程师被要求构建支持深度学习模型的系统。因此,这些工程系统效率低下,不适合深度学习系统。

针对数据科学家的观点已经写了很多关于深度学习模型开发的内容,涵盖了数据收集和数据集增强、编写训练算法等。但是很少有书籍,甚至是博客,涉及支持所有这些深度学习活动的系统和服务。

在这本书中,我们从软件开发者的角度讨论构建和设计深度学习系统。我们的方法是首先整体描述一个典型的深度学习系统,包括其主要组件及其连接方式;然后我们在单独的章节中深入探讨每个主要组件。我们始终通过讨论需求来开始每个组件章节。然后我们介绍设计原则和样例服务/代码,并最终评估开源解决方案。

因为我们无法涵盖每一个现有的深度学习系统(供应商或开源),我们在书中专注于讨论需求和设计原则(带有示例)。在学习了这些原则,尝试了本书的示例服务,并阅读了我们对开源选项的讨论后,我们希望读者能够进行自己的研究,找到最适合他们的方案。

谁应该阅读这本书?

这本书的主要受众是软件工程师(包括最近毕业的计算机科学学生),他们希望快速转入深度学习系统工程领域,比如那些希望在深度学习平台上工作或将一些人工智能功能(例如模型服务)集成到他们的产品中的人。

数据科学家、研究人员、经理和任何其他使用机器学习解决实际问题的人也会发现这本书很有用。在了解了基础架构(或系统)之后,他们将能够为改善模型开发过程的效率提供精确的反馈给工程团队。

这是一本工程书,您不需要机器学习背景,但您应该熟悉基本的计算机科学概念和编码工具,比如微服务、gRPC 和 Docker,以运行实验室并理解技术材料。无论您的背景如何,您仍然可以从本书的非技术性材料中受益,帮助您更好地理解机器学习和深度学习系统是如何将产品和服务从想法转化为生产的。

通过阅读这本书,您将能够理解深度学习系统的工作原理以及如何开发每个组件。您还将了解何时从用户那里收集需求,将需求转化为系统组件设计选择,并集成组件以创建一个有助于用户快速开发和交付深度学习功能的连贯系统。

这本书的组织方式:一张路线图

这本书共有 10 章和三个附录(包括一个实验室附录)。第一章解释了深度学习项目开发周期是什么,以及基本的深度学习系统是什么样子。接下来的章节深入探讨了参考深度学习系统的每个功能组件。最后一章讨论了模型如何被部署到生产环境中。附录包含一个实验室环节,让读者可以尝试样本深度学习系统。

第一章描述了深度学习系统是什么,系统的不同利益相关者以及他们如何与之交互以提供深度学习功能。我们称这种交互为深度学习开发周期。此外,你将概念化一个深度学习系统,称为参考架构,它包含所有必要的元素,并可以根据你的要求进行调整。

第 2 至 9 章涵盖了参考深度学习系统架构的每个核心组件,例如数据集管理服务、模型训练服务、自动超参数优化服务和工作流编排服务。

第十章描述了如何将最终产品从研究或原型阶段推向发布给公众使用的阶段。附录 A 介绍了样本深度学习系统并演示了实验室练习,附录 B 对现有解决方案进行了调查,附录 C 讨论了 Kubeflow Katib。

关于代码

我们相信学习的最佳方式是通过实践和尝试。为了演示本书中解释的设计原则并提供实践经验,我们创建了一个样本深度学习系统和代码实验室。样本深度学习系统的所有源代码、设置说明和实验室脚本都可以在 GitHub 上找到(github.com/orca3/MiniAutoML)。你也可以从本书的 liveBook(在线)版本(livebook.manning.com/book/software-engineers-guide-to-deep-learning-system-design)和 Manning 网站(www.manning.com)获取可执行的代码片段。

附录 A 中的“hello world”实验室包含一个完整但简化的迷你深度学习系统,具有最基本的组件(数据集管理、模型训练和服务)。我们建议你在阅读本书第一章之后尝试“hello world”实验室,或在尝试本书中的样本服务之前进行尝试。此实验室还提供了 shell 脚本和所有您需要开始的资源的链接。

除了代码实验室外,本书还包含许多源代码示例,以编号列表和与普通文本一致的方式显示。在这两种情况下,源代码都采用固定宽度 字体 格式 显示,以便与普通文本分开。有时,代码也会以加粗的形式显示,以突出显示与本章前一步骤中的代码不同的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已经重新格式化;我们已添加了换行符并重新排列了缩进,以适应书中可用的页面空间。在极少数情况下,即使这样还不够,列表也包括行续标记(➥)。此外,当在文本中描述代码时,源代码中的注释通常已从列表中删除。代码注释伴随许多列表,突出显示重要概念。

liveBook 讨论论坛

购买设计深度学习系统包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以将评论附加到全书或特定部分或段落。为自己做笔记,提出和回答技术问题,并从作者和其他用户那里获得帮助,都是易如反掌的。要访问论坛,请转到livebook.manning.com/book/software-engineers-guide-to-deep-learning-system-design。您还可以了解有关 Manning 论坛和行为规则的更多信息,请访问livebook.manning.com/discussion

Manning 致力于为读者提供一个场所,使个人读者之间以及读者与作者之间能够进行有意义的对话。这不是对作者参与的任何特定数量的承诺,作者对论坛的贡献仍然是自愿的(且未获报酬的)。我们建议您尝试向作者提出一些具有挑战性的问题,以免他们失去兴趣!只要书还在出版中,论坛和以前的讨论档案将可以从出版商的网站上访问。

关于作者

王琦是 Salesforce Einstein 团队的首席软件开发人员,在那里他构建了数百万 Salesforce 客户使用的深度学习平台。此前,他曾在 Microsoft Bing 和 Azure 工作,构建大规模分布式系统。王琦已申请了六项专利,其中大多与深度学习系统有关,并最近完成了斯坦福大学的人工智能研究生证书项目。

Donald Szeto 是 PredictionIO 的联合创始人和首席技术官,这是一家旨在帮助普及和加速机器学习应用的初创公司。PredictionIO 被 Salesforce 收购后,他继续在机器学习和深度学习系统上工作。Donald 是 Aftermint 的创始人,其目标是搭建 Web2 和 Web3 之间的桥梁。他还投资、提供建议,并指导技术初创公司。

关于封面插图

《设计深度学习系统》 封面上的人物是来自 Jacques Grasset de Saint-Sauveur 收藏的“黑森林人”,摘自 1797 年出版。每幅插图都是精细绘制并手工上色的。

在那些日子里,仅凭着服饰就能轻易识别人们居住的地方以及他们的职业或社会地位。Manning 通过基于数个世纪前区域文化的丰富多样性而创作的书籍封面,庆祝了计算机业务的创造力和主动性,这些文化被像这样的收藏品中的图片重新呈现出来。

第一章:深度学习系统简介

本章内容包括

  • 定义深度学习系统

  • 产品开发周期及深度学习系统如何支持其

  • 基本深度学习系统及其组成部分概述

  • 搭建深度学习系统与开发模型之间的差异

本章将为您提供一个深度学习系统的全貌思维模型。我们将回顾一些定义,并提供一个参考系统架构设计和该架构的完整示例实现。我们希望这个思维模型能够让您看到其他章节如何详细介绍每个系统组件,并将其融入整体图景。

在开始本章之前,我们将讨论一个超越深度学习系统的更大图景:我们称之为深度学习开发周期。这个周期概述了基于深度学习的产品推向市场所涉及的各种角色和阶段。模型和平台并不是孤立存在的;它们受产品管理、市场调研、生产和其他阶段的影响,也会影响这些阶段。我们相信,当工程师了解这个周期以及每个团队的工作内容和所需时,他们设计的系统会更好。

在 1.2 节中,我们将从典型系统的示例架构开始讨论深度学习系统设计。本节描述的组件将在各自的章节中进行更详细的探讨。最后,我们将强调开发模型与开发深度学习系统之间的区别。这种区别常常是一个让人困惑的焦点,所以我们想要立即澄清。

在阅读完这个入门章节之后,您将对深度学习的概况有了扎实的理解。您还将能够开始创建自己的深度学习系统设计,并理解现有设计以及如何使用和扩展它们,这样您就不必从头开始构建一切。随着您继续阅读本书,您将看到一切是如何连接和共同作为一个深度学习系统运作的。

术语

在我们继续本章(以及本书的其余部分)之前,让我们定义和澄清本书中始终使用的一些术语。

深度学习与机器学习的比较

深度学习是机器学习的一种,但被认为是机器学习的一种演变。机器学习按定义是人工智能的一种应用,包括解析数据、从数据中学习,然后应用所学内容做出明智决策的算法。深度学习是机器学习的一种特殊形式,它使用可编程神经网络作为算法,从数据中学习并做出准确的决策。

尽管本书主要关注于教授如何构建系统或基础设施来促进深度学习开发(所有示例都是神经网络算法),但我们讨论的设计和项目开发概念在机器学习中也适用。因此,在本书中,我们有时将术语深度学习机器学习互换使用。例如,本章介绍的深度学习开发周期和第二章介绍的数据管理服务也适用于机器学习上下文。

深度学习用例

深度学习用例是指利用深度学习技术解决问题的场景,换句话说,是您想使用深度学习解决的问题。例如:

  • 聊天机器人—用户可以在客户支持网站上与虚拟代理进行基于文本的对话。虚拟代理使用深度学习模型理解用户输入的句子,并像真正的人类一样与用户进行对话。

  • 自动驾驶汽车—驾驶员可以将汽车置于辅助驾驶模式,根据道路标线自动转向。车载多个摄像头捕捉标线,利用基于深度学习的计算机视觉技术形成对道路的感知。

模型、预测和推断,以及模型服务

这三个术语的描述如下:

  • 模型—深度学习模型可以被视为包含算法(模型架构)和进行预测所需数据的可执行程序。

  • 预测和推断—模型预测和模型推断都是指使用给定数据执行模型以获得一组输出。由于在模型服务的上下文中广泛使用预测和推断,它们在本书中可以互换使用。

  • 模型服务(预测服务)—本书将模型服务描述为在 Web 应用程序(在云端或本地)中托管机器学习模型,并允许深度学习应用程序通过 API 将模型功能集成到其系统中。模型服务 Web 程序通常称为预测服务或模型服务。

深度学习应用

深度学习应用是利用深度学习技术解决问题的软件。它通常不执行任何计算密集型任务,例如数据处理、深度学习模型训练和模型服务(除了在边缘托管模型,例如自动驾驶汽车)。例如:

  • 提供 UI 或 API 以接受用户的自然句子作为输入,解释它们,采取行动并向用户提供有意义的响应的聊天机器人应用程序。基于深度学习系统中计算的模型输出(来自模型服务),聊天机器人做出响应并采取行动。

  • 自动驾驶软件从多个传感器接收输入,如视频摄像头、接近传感器和激光雷达,借助深度学习模型形成对汽车周围环境的感知,并相应地驾驶汽车。

平台 vs. 系统 vs. 基础设施

在本书中,术语 深度学习平台深度学习系统深度学习基础设施 具有相同的含义:为高效构建深度学习应用程序提供所有必要支持的基础系统。我们倾向于最常使用 系统,但在本书的上下文中,所有三个术语都具有相同的含义。

现在我们对术语有了共识,让我们开始吧!

1.1 深度学习开发周期

正如我们所说,深度学习系统是深度学习 项目开发 高效进行所必需的基础设施。因此,在深入探讨深度学习系统的结构之前,审视一下深度学习系统所启用的开发范式是明智的。我们称这个范式为 深度学习开发周期

你可能会想,在一本技术书中,为什么我们要强调像产品开发这样与技术无关的东西。事实上,大多数深度学习工作的目标最终是将产品或服务推向市场。然而,许多工程师并不熟悉产品开发的其他阶段,就像许多产品开发者不了解工程或建模一样。从我们构建深度学习系统的经验中,我们已经了解到,说服公司中多个角色的人员采用系统主要取决于该系统是否实际上能解决他们的特定问题。我们相信,概述深度学习开发周期中的各个阶段和角色有助于表达、解决、沟通和最终解决每个人的痛点。

了解这一周期也能解决其他一些问题。在过去的十年里,许多新的深度学习软件包已经被开发出来,以解决不同的领域。其中一些处理模型训练和服务,而另一些处理模型性能跟踪和实验。数据科学家和工程师每次需要解决特定应用程序或用例时都会组合这些工具;这被称为 MLOps(机器学习运维)。随着这些应用程序数量的增长,为新的应用程序每次从头开始组合这些工具变得重复和耗时。同时,随着这些应用程序的重要性增长,对其质量的期望也在增加。这两个问题都需要一种一致的方法来快速可靠地开发和交付深度学习功能。这种一致的方法始于所有人都在同一深度学习开发范式或周期下工作。

深度学习系统如何适应深度学习周期?一个良好构建的深度学习系统应该支持产品开发周期,并使执行周期变得轻松、快速和可靠。理想情况下,数据科学家可以使用深度学习系统作为基础设施完成整个深度学习周期,而无需学习底层复杂系统的所有工程细节。

因为每个产品和组织都是独特的,对系统构建者来说,理解各种角色的独特需求以构建成功的系统至关重要。所谓的“成功”,是指帮助利益相关者高效协作,快速交付深度学习特性的系统。在本书中,当我们讨论深度学习系统的设计原则,并查看每个组件的工作方式时,你对利益相关者需求的理解将帮助你调整这些知识,形成自己的系统设计。在讨论技术细节时,我们将指出在设计系统时需要注意某些类型的利益相关者。深度学习开发周期将作为指导框架,帮助我们考虑深度学习系统的每个组件的设计要求。

让我们从一张图片开始。图 1.1 展示了典型周期的样貌。它展示了机器学习(特别是深度学习)的开发进度逐个阶段的过程。正如你所见,跨职能协作几乎在每一步都发生。我们将在接下来的两个部分讨论此图中涉及的每个阶段和角色。

图 1.1 将深度学习从研究带入产品的典型场景。我们称之为深度学习开发周期

1.1.1 深度学习产品开发周期中的阶段

深度学习开发周期通常从一个业务机会开始,并由产品计划及其管理驱动。之后,周期通常经历四个阶段:数据探索、原型制作、产品化(投入生产)和应用集成。让我们逐一查看这些阶段。然后我们将查看所有涉及的角色(在图 1.1 中以人物图标表示)。

注意:每个后续小节旁边括号中的数字与图 1.1 中的相同数字对应。

产品启动(1)

首先,业务利益相关者(产品所有者或项目经理)分析业务,并确定可以通过机器学习解决的潜在业务机会或问题。

数据探索(2)

当数据科学家清楚了解业务需求时,他们开始与数据工程师合作,尽可能收集、标记数据并构建数据集。数据收集可以包括搜索公开可用数据和探索内部来源。数据清理也可能会发生。数据标记可以外包或在内部执行。

与以下阶段相比,数据探索的早期阶段是非结构化的,通常是随意进行的。它可能是一个 Python 脚本或 shell 脚本,甚至是数据的手动复制。数据科学家经常使用基于 Web 的数据分析应用程序,例如 Jupyter Notebook(开源;jupyter.org)、Amazon SageMaker Data Wrangler(aws.amazon.com/sagemaker/data-wrangler)和 Databricks(www.databricks.com)来分析数据。不需要构建正式的数据收集管道。

数据探索不仅重要,而且对深度学习项目的成功至关重要。可用的相关数据越多,建立有效和高效深度学习模型的可能性就越高。

研究和原型设计(3, 4)

原型设计的目标是找到最可行的算法/方法,以解决给定数据的业务需求(来自产品所有者)。在此阶段,数据科学家可以与 AI 研究人员合作,提出并评估来自前期数据探索阶段构建的不同训练算法。数据科学家通常在此阶段尝试多种想法,并构建概念验证(POC)模型来评估它们。

尽管新发布的算法通常会受到考虑,但大多数算法都不会被采纳。算法的准确性不是唯一要考虑的因素;在评估算法时还必须考虑计算资源需求、数据量和算法实现成本。最实用的方法通常是获胜者。

请注意,由于资源限制,研究人员并不总是参与原型设计阶段。经常情况下,数据科学家既做研究工作,又构建 POC。

您还可能注意到,在图 1.1 中,大型开发周期中有一个内部循环(循环 A):产品启动 > 数据探索 > 深度学习研究 > 原型设计 > 模型 > 产品启动。该循环的目的是通过构建 POC 模型在早期阶段获得产品反馈。我们可能会多次执行此循环,直到所有利益相关者(数据科学家、产品所有者)就将用于满足业务需求的算法和数据达成一致意见。

多次痛苦的教训最终教会了我们,在开始昂贵的生产过程——构建生产数据和训练管道以及托管模型之前,我们必须与产品团队或客户(甚至更好)审查解决方案。深度学习项目的目的与任何其他软件开发项目并无不同:解决业务需求。在早期阶段与产品团队审查方法将防止在后期重新制定方法的昂贵和令人沮丧的过程。

生产化(也称为 MLOps)(5)

生产化,也称为“投入生产”,是使产品具备生产价值并准备好被用户消费的过程。生产价值通常定义为能够服务客户请求,承受一定程度的请求负载,并优雅地处理诸如格式错误的输入和请求超载等不利情况。生产价值还包括后期工作,如持续的模型指标监控和评估、反馈收集和模型重新训练。

生产化是开发周期中最需要工程投入的部分,因为我们将把原型实验转化为严肃的生产流程。生产化的非详尽待办事项列表可以包括

  • 建立一个数据管道,重复从不同的数据源中提取数据,并使数据集版本化和更新。

  • 建立数据管道对数据集进行预处理,例如数据增强或增强和与外部标记工具集成。

  • 重构和将原型代码 docker 化为生产质量的模型训练代码。

  • 通过版本控制和跟踪输入和输出使训练和服务代码的结果可再现。例如,我们可以使训练代码报告训练元数据(训练日期和时间、持续时间、超参数)和模型元数据(性能指标、使用的数据和代码),以确保对每次模型训练运行的完全可追溯性。

  • 设置持续集成(Jenkins、GitLab CI)和持续部署以自动化代码构建、验证和部署。

  • 建立连续的模型训练和评估管道,以便模型训练可以自动使用最新的数据集,并以可重复、可审计和可靠的方式生成模型。

  • 建立一个模型部署管道,自动发布通过质量门的模型,以便模型服务组件可以访问它们;根据业务需求可以执行async或实时模型预测。模型服务组件托管模型并通过 Web API 公开它。

  • 建立持续监控管道,定期评估数据集、模型和模型服务性能,以检测数据集的潜在特征漂移(数据分布变化)或模型性能下降(概念漂移)并警告开发人员或重新训练模型。

如今,生产化步骤有一个新的热门别名:MLOps(机器学习运营),这是一个模糊的术语,对研究人员和专业人员的定义模糊不清。我们解释 MLOps 的含义是弥合模型开发(实验)和生产环境运营(Ops)之间的鸿沟,以促进机器学习项目的生产化。例如,简化将机器学习模型推向生产的过程,然后对其进行监视和维护。

MLOps 是一种根植于 DevOps 原则的范式,应用了类似的原则到软件开发中。它利用了三个学科:机器学习、软件工程(特别是运维)和数据工程。查看图 1.2,了解通过 MLOps 视角看深度学习。

图 1.2 MLOps 在深度学习的产品化阶段应用了 DevOps 方法,当模型被推向生产时。(来源:Machine Learning Engineering in Action,作者 Ben Wilson,Manning 出版社,2022 年,图 2.7)

因为这本书是关于构建支持 ML 运营的机器学习系统,所以我们不会详细介绍图 1.2 中所示的实践。但是,正如你所看到的,支持将机器学习模型开发到生产环境中的工程工作量是巨大的。与数据科学家在数据探索和模型原型阶段所做的工作相比,工具(软件)、工程标准和流程已经发生了巨大变化,并变得更加复杂。

为什么将模型部署到生产环境很困难?

庞大的基础设施(工具、服务、服务器)和团队间的密集合作是将模型部署到生产环境的两个最大障碍。这个关于产品化(又称 MLOps)的部分建立在一个事实上,即数据科学家需要与数据工程师、平台开发人员、DevOps 工程师和机器学习工程师一起工作,并且要了解庞大的基础设施(深度学习系统),才能将算法/模型从原型推向生产。难怪产品化模型需要花费如此多的时间。

为了解决这些挑战,我们需要在设计和构建深度学习系统时,将复杂性从数据科学家那里抽象出来。就像建造汽车一样,我们希望让数据科学家坐在驾驶座上,但不要求他们对汽车本身了解太多。

现在,回到开发周期,你可能会注意到图 1.1 中还有一个另一个内部循环(循环 B),从产品化(方框 5)到模型到产品启动(方框 1)。这是在我们将模型推理与 AI 应用集成之前与产品团队进行的第二次审查。

我们的第二次审查(循环 B)在原型和生产之间比较模型和数据。我们要确保模型性能和可扩展性(例如,模型服务容量)符合业务需求。

注意:以下两篇论文是推荐的;如果你想了解更多关于 MLOps 的内容,它们是很好的起点:“Operationalizing Machine Learning: An Interview Study”(arXiv:2209.09125)和“Machine Learning Operations (MLOps): Overview, Definition, and Architecture”(arXiv:2205.02302)。

应用集成(6)

产品开发周期的最后一步是将模型预测集成到 AI 应用中。常见的模式是将模型托管在深度学习系统的模型服务服务中,并通过互联网发送模型预测请求将业务应用逻辑与模型集成。

作为一个示例用户场景,一个聊天机器人用户通过键入或发声问题与聊天机器人用户界面进行交互。当聊天机器人应用程序接收到来自客户的输入时,它调用远程模型服务服务来运行模型预测,然后根据模型预测结果采取行动或回应客户。

除了将模型服务与应用逻辑集成外,此阶段还涉及评估对产品重要的指标,如点击率和流失率。良好的 ML 特定指标(良好的精确度-召回率曲线)并不总是能保证满足业务需求。因此,业务利益相关者通常在此阶段进行客户访谈和产品指标评估。

1.1.2 开发周期中的角色

因为您现在对典型开发周期中的每个步骤有了清晰的了解,让我们来看看在这个周期中合作的关键角色。每个角色的定义、职称和职责可能因组织而异。所以确保您澄清了您的组织中谁做什么,并相应调整您系统的设计。

业务利益相关者(产品所有者)

许多组织将利益相关者角色分配给多个职位,如产品经理、工程经理和高级开发人员。业务利益相关者定义产品的业务目标,并负责产品开发周期的沟通和执行。以下是他们的责任:

  • 从深度学习研究中获得灵感,讨论在产品中应用深度学习特性的潜在应用,并驱动推动模型开发的产品需求

  • 拥有产品!与客户沟通,确保工程解决方案符合业务需求并产生结果

  • 协调不同角色和团队之间的跨职能协作

  • 运行项目开发执行;在整个开发周期内提供指导或反馈,以确保深度学习特性为产品的客户提供真正的价值

  • 评估产品指标(如用户流失率和功能使用情况)—而不是模型指标(精度或准确性)—并推动模型开发、产品化或产品集成的改进

研究人员

机器学习研究人员研究和开发新颖的神经网络架构。他们还开发提高模型准确性和训练模型效率的技术。这些架构和技术可以在模型开发过程中使用。

注:机器学习研究员角色通常与 Google、Microsoft 和 Salesforce 等大型科技公司相关联。在许多其他公司,数据科学家扮演相同的角色。

数据科学家

数据科学家可能会扮演研究员的角色,但大多数情况下,他们会将业务问题转化为机器学习问题,并使用机器学习方法来实现。数据科学家受产品需求的驱动,并将研究技术应用于生产数据,而不是标准基准数据集。除了研究模型算法外,数据科学家的职责还可能包括

  • 将不同研究中的多个深度学习神经网络架构和/或技术结合到一个解决方案中。有时,他们除了纯深度学习外还应用其他机器学习技术。

  • 探索可用数据,确定哪些数据是有用的,并决定如何在供训练之前对其进行预处理。

  • 原型不同方法(编写实验性代码)来解决业务问题。

  • 将模型原型代码转换为生产代码,并进行工作流自动化。

  • 遵循工程流程,通过使用深度学习系统将模型部署到生产环境。

  • 根据需要迭代可能有助于模型开发的任何额外数据。

  • 在生产环境中持续监控和评估数据和模型性能。

  • 排查与模型相关的问题,如模型退化。

数据工程师

数据工程师帮助收集数据,并建立连续数据摄入和处理的数据管道,包括数据转换、丰富和标记。

MLOps 工程师/ML 工程师

MLOps 工程师在多个领域扮演多种角色,包括数据工程师、DevOps(运维)工程师、数据科学家和平台工程师。除了设置和运行机器学习基础设施(系统和硬件),他们还管理自动化管道以创建数据集并训练和部署模型。MLOps 工程师还监控 ML 基础设施和用户活动,如训练和服务。

如你所见,MLOps 很困难,因为它需要人们掌握一套跨越软件开发、运维、维护和机器学习开发的实践方法。MLOps 工程师的目标是确保机器学习模型的创建、部署、监控和维护高效可靠。

深度学习系统/平台工程师

深度学习系统工程师构建和维护机器学习基础设施的主要组件——本书的主要关注点——以支持数据科学家、数据工程师、MLOps 工程师和 AI 应用的所有机器学习开发活动。机器学习系统的组成部分包括数据仓库、计算平台、工作流编排服务、模型元数据和工件存储、模型训练服务、模型服务等。

应用工程师

应用工程师构建面向客户的应用程序(前端和后端),以满足给定的业务需求。应用程序逻辑将根据给定客户请求的模型预测做出决策或采取行动。

注意:未来,随着机器学习系统(基础设施)的成熟,深度学习开发周期中涉及的角色将合并为越来越少。最终,数据科学家将能够独自完成整个周期。

1.1.3 深度学习开发周期步骤详解

通过给出一个例子,我们可以以更具体的方式展示角色和过程。假设你被分配了构建一个关于公司产品线自动回答问题的客户支持系统的任务。以下步骤将指导您完成将该产品推向市场的过程:

  1. 产品要求是构建一个客户支持应用程序,提供一个菜单,让客户可以浏览以找到常见问题的答案。随着问题数量的增加,菜单变得越来越大,有许多层次的导航。分析显示,许多客户在尝试找到答案时对导航系统感到困惑,并放弃了浏览菜单。

  2. 拥有该产品的产品经理(PM)受到改善用户保留率和体验(快速找到答案)的动机。在与客户进行了一些研究后,产品经理发现,大多数客户希望在不复杂的菜单系统中获得答案,最好是像在他们的自然语言中提问一样简单。

  3. 产品经理联系机器学习研究人员寻求潜在解决方案。结果表明,深度学习可能会有所帮助。专家认为这项技术已经足够成熟,可以用于这个用例,并建议几种基于深度学习模型的方法。

  4. 产品经理编写产品规格,指示应用程序一次从客户那里接收一个问题,从问题中识别意图,并与相关答案匹配。

  5. 数据科学家收到产品需求并开始原型化符合需求的深度学习模型。他们首先开始数据探索,收集可用的训练数据,并与研究人员商讨算法的选择。然后数据科学家开始编写原型代码以生成实验模型。最终,他们得到了一些数据集、几种训练算法和多个模型。经过仔细评估,从各种实验中选择了一个自然语言处理模型。

  6. 然后,项目经理组建了一个平台工程师、MLOps 工程师和数据工程师团队,与数据科学家一起工作,将在第 5 步中创建的原型代码投入生产。这项工作包括构建连续的数据处理管道和连续的模型训练、部署和评估管道,以及设置模型服务功能。项目经理还确定了每秒预测次数和所需的延迟。

  7. 一旦生产设置完成,应用工程师将客户支持服务的后端与模型服务服务(在第 6 步中构建)集成起来,因此当用户输入问题时,服务将根据模型预测返回答案。项目经理还定义了产品指标,例如平均查找答案所花费的时间,以评估最终结果,并将其用于推动下一轮改进。

1.1.4 项目开发的扩展

正如您在 1.1.2 节中所看到的,我们需要填补七种不同的角色才能完成一个深度学习项目。这些角色之间的跨职能协作几乎在每一个步骤都会发生。例如,数据工程师、平台开发人员和数据科学家共同致力于将项目投入生产。任何参与过需要许多利益相关方的项目的人都知道,为了推动这样一个项目前进,需要多少沟通和协调。

这些挑战使得深度学习开发难以扩展,因为我们要么没有资源来填补所有所需角色,要么由于沟通成本和减速而无法满足产品时间表。为了减少巨大的运营工作量、沟通和跨团队协调成本,公司正在投资于机器学习基础设施,并减少构建机器学习项目所需的人员数量和知识范围。深度学习基础设施堆栈的目标不仅是自动化模型构建和数据处理,还要使技术角色合并为可能,使数据科学家能够在项目中独立地处理所有这些功能。

深度学习系统的一个关键成功指标是看模型投产过程能否顺利进行。有了良好的基础设施,不会期望数据科学家突然成为专家级的 DevOps 或数据工程师,他们应该能够以可扩展的方式实现模型、建立数据管道,并独立地在生产环境中部署和监控模型。

通过使用高效的深度学习系统,数据科学家将能够以最小的额外开销完成开发周期——需要较少的沟通和等待他人的时间,并专注于最重要的数据科学任务,如理解数据和尝试算法。扩展深度学习项目开发能力是深度学习系统的真正价值所在。

1.2 深度学习系统设计概述

在考虑到第 1.1 节的背景下,让我们深入了解本书的重点:深度学习系统本身。设计一个系统——任何系统——都是在一组对你的情况独特的约束条件下实现目标的艺术。深度学习系统也不例外。例如,假设你有几个需要同时提供服务的深度学习模型,但是你的预算不允许你运行一台具有足够内存同时容纳所有模型的机器。你可能需要设计一个缓存机制来在内存和磁盘之间交换模型。然而,交换会增加推断延迟。这种解决方案是否可行将取决于延迟要求。另一个可能性是为每个模型运行多个较小的机器,如果你的模型大小和预算允许的话。

或者,举个例子,想象一下你公司的产品必须符合某些认证标准。它可能会规定数据访问政策,对希望访问公司产品收集的数据的任何人施加重大限制。你可能需要设计一个框架,以符合标准地允许数据访问,以便研究人员、数据科学家和数据工程师可以在你的深度学习系统中解决问题并开发需要这种数据访问的新模型。

正如你所看到的,有许多可以调整的旋钮。达到尽可能满足多个要求的设计肯定是一个迭代的过程。但为了缩短迭代过程,最好从尽可能接近最终状态的设计开始。

在本节中,我们首先提出了一个仅具有基本组件的深度学习系统设计,然后解释了每个组件的责任和用户工作流程。根据我们设计和定制深度学习系统的经验,几个关键组件在不同的设计中是共同的。我们认为它们可以作为你设计的合理起点。我们称之为 参考系统架构

你可以为你的设计项目制作一份此参考副本,列出你的目标和约束条件,然后开始识别每个组件中可以根据需要调整的旋钮。因为这不是一个权威的体系结构,所以你还应该评估是否所有组件都真的是必需的,并根据需要添加或删除组件。

1.2.1 参考系统架构

图 1.3 显示了参考深度学习系统的高层概述。深度学习系统有两个主要部分。第一个是系统的应用程序编程接口(API;盒子 A),位于图表中间。第二个是深度学习系统的组件集合,由所有矩形框表示,位于大框内,用虚线轮廓标出,占据图表的下半部分。这些框每个代表一个系统组件:

  • API(框 A)

  • 数据集管理器(框 B)

  • 模型训练器(框 C)

  • 模型服务(框 D)

  • 元数据和工件存储(框 E)

  • 工作流编排(框 F)

  • 交互式数据科学环境(框 G)

在本书中,我们假设这些系统组件是微服务

图 1.3 典型深度学习系统的概览,包括支持深度学习开发周期的基本组件。这个参考架构可以作为一个起点,进行进一步的定制。在后面的章节中,我们将详细讨论每个组件,并解释它们如何融入这一大局。

定义:对于微服务,没有单一的定义。在这里,我们将使用该术语来指代使用 HTTP 或 gRPC 协议与网络通信的进程。

这一假设意味着我们可以合理地期望这些组件能够安全地支持具有不同角色的多个用户,并且可以方便地通过网络或互联网访问。(然而,本书将不涵盖微服务的所有工程方面的设计或构建。我们将重点讨论与深度学习系统相关的具体内容。)

注意:你可能会想知道,你是否需要自己设计、构建和托管所有深度学习系统组件。实际上,有开源(Kubeflow)和托管的替代方案(Amazon SageMaker)可供选择。我们希望在你学习了每个组件的基本知识、它们如何融入整体架构以及不同角色如何使用后,你能为你的使用场景做出最佳决策。

1.2.2 关键组件

现在让我们详细介绍我们认为对基本深度学习系统至关重要的关键组件,如图 1.3 所示。你可能希望根据自己的需求添加其他组件或进一步简化。

应用程序编程接口

我们深度学习系统的入口点(图 1.3 中的框 A)是一个通过网络访问的 API。我们选择 API 是因为系统不仅需要支持研究人员、数据科学家、数据工程师等使用的图形用户界面,还需要支持应用程序和可能来自合作伙伴组织的数据仓库等其他系统。

虽然在概念上 API 是系统的唯一入口点,但完全有可能将 API 定义为每个组件提供的所有 API 的总和,而没有额外的层将所有内容聚合在单一服务端点下。在本书中,我们将直接使用每个组件提供的所有 API 的总和,并跳过聚合以简化问题。

注意您应该使用集中式还是分布式深度学习系统 API?在参考架构(图 1.3)中,深度学习系统 API 显示为一个单独的框。应该将其解释为深度学习系统 API 的完整集合的逻辑容器,无论它是在单个(例如,代理所有组件的 API 网关)还是多个服务端点(直接与每个组件交互)上实现的。每种实现都有其优点和缺点,您应该与团队合作找出哪种方法最有效。如果从一个小的用例和团队开始,直接与每个组件交互可能会更容易。

数据集管理器

深度学习是基于数据的。毫无疑问,数据管理组件是深度学习系统的核心组成部分。每个学习系统都是垃圾进,垃圾出的系统,因此确保良好的数据质量对于学习至关重要。良好的数据管理组件应该提供解决此问题的解决方案。它使收集、组织、描述和存储数据成为可能,从而使数据可以被探索、标记和用于训练模型。

在图 1.3 中,我们至少可以看到数据管理器(盒子 B)与其他方面的四种关系:

  • 数据收集器将原始数据推送到数据集管理器以创建或更新数据集。

  • 工作流编排服务(盒子 F)执行数据处理管道,从数据管理器中提取数据以增强训练数据集或转换数据格式,并将结果推送回去。

  • 数据科学家、研究人员和数据工程师使用 Jupyter Notebook(盒子 G)从数据管理器中提取数据进行数据探索和检查。

  • 模型训练服务(盒子 C)从数据管理器中提取训练数据进行模型训练。

在第二章中,我们将深入讨论数据集管理。在整本书中,我们使用术语数据集作为可能相关的收集数据的单位。

模型训练器

模型训练器(又称模型训练服务;盒子 C)响应以提供基础计算资源,如 CPU、RAM 和 GPU,并提供作业管理逻辑来运行模型训练代码并生成模型文件。在图 1.3 中,我们可以看到工作流编排服务(盒子 F)告诉模型训练器执行模型训练代码。训练器从数据集管理器(盒子 B)获取输入训练数据并生成模型。然后,它将模型与训练指标和元数据一起上传到元数据和工件存储(盒子 E)中。

通常需要对大型数据集进行密集计算,以产生可以进行准确预测的高质量深度学习模型。采用新的算法和训练库/框架也是关键要求。这些要求在几个层面上产生挑战:

  • 减少模型训练时间的能力——尽管训练数据的规模和模型架构的复杂性不断增长,但训练系统必须保持训练时间合理。

  • 水平扩展性——一个有效的生产训练系统应该能够同时支持来自不同应用程序和用户的多个训练请求。

  • 采用新技术的成本——深度学习社区充满活力,不断更新和改进算法和工具(SDK、框架)。训练系统应该足够灵活,能够轻松地适应新的创新,而不会干扰现有的工作负载。

在第三章中,我们将研究解决上述问题的不同方法。我们不会在本书中深入探讨训练算法的理论方面,因为它们不会影响我们如何设计系统。在第四章中,我们将研究如何分发训练以加速该过程。在第五章中,我们将探讨几种不同的方法来优化训练超参数。

模型服务

模型可以在各种设置中使用,例如用于实时预测的在线推理或用于批量预测的离线推理,使用大量的输入数据。这就是模型服务的地方—当系统托管模型、接受输入预测请求、运行模型预测并将预测返回给用户时。有几个关键问题需要回答:

  • 您的推理请求是来自网络?还是来自需要本地服务的传感器?

  • 什么是可接受的延迟?推理请求是临时的还是流式的?

  • 有多少模型正在提供服务?每个模型是单独提供某种推理请求,还是一组模型这样做?

  • 模型的大小有多大?您需要预算多少内存容量?

  • 需要支持哪些模型架构?是否需要 GPU?您需要多少计算资源才能生成推断?是否有其他支持服务的组件—例如嵌入、归一化、聚合等?

  • 是否有足够的资源保持模型在线?还是需要一种置换策略(例如在内存和磁盘之间移动模型)?

从图 1.3 中,模型服务的主要输入和输出(方框 D)分别是推理请求和返回的预测。为了生成推理,模型是从元数据和工件存储中检索出来的(方框 E)。一些请求及其响应可能会被记录并发送到模型监控和评估服务(图 1.3 中未显示或本书未涉及),该服务从这些数据中检测异常并生成警报。在第六章和第七章中,我们将深入探讨模型服务架构,探讨这些关键方面,并讨论它们的解决方案。

元数据和工件存储

想象一下作为一个人的团队在一个简单的深度学习应用上工作,你只需处理几个数据集并训练并部署一种类型的模型。你可能可以追踪数据集、训练代码、模型、推理代码和推理之间的关系。这些关系对于模型开发和故障排除至关重要,因为你需要能够将某些观察追溯到原因。

现在想象增加更多应用、更多人员和更多模型类型。这些关系的数量将呈指数级增长。在一个为多种用户服务的深度学习系统中,这些用户在不同阶段处理多个数据集、代码和模型,存在对一个跟踪关系网络的组件的需求。深度学习系统中的元数据和工件存储正是为此而设计。工件包括训练模型和生成推理的代码,以及任何生成的数据,如训练模型、推理和指标。元数据是描述工件或工件之间关系的任何数据。一些具体的例子是

  • 训练代码的作者和版本

  • 经过训练的模型的输入训练数据集和训练环境的参考

  • 经过训练的模型的训练指标,例如训练日期和时间、持续时间以及训练任务的所有者

  • 特定于模型的指标,如模型版本、模型血统(训练中使用的数据和代码)以及性能指标

  • 用于生成某一推断的模型、请求和推理代码

  • 工作流历史,跟踪模型训练和数据处理流水线的每个步骤

这些只是基准元数据和工件存储可以帮助跟踪的一些例子。你应该根据你的团队或组织的需求来定制这个组件。

图 1.3 中生成元数据和工件的每一个其他组件都会流入元数据和工件存储(箱体 E)。该存储在模型服务中也扮演着重要角色,因为它提供模型文件及其元数据给模型服务服务(箱体 D)。虽然图中未显示,但通常在用户界面层构建自定义工具来追踪血统和故障排除,这些工具由元数据和工件存储提供动力。

当我们在第八章进行时,我们将会查看一个基准元数据和工件存储。这个存储通常是深度学习系统用户界面的核心组件。

工作流协调

工作流编排(图 1.3,框 F)在许多系统中是无处不在的,它有助于根据编程条件自动启动计算任务。在机器学习系统的上下文中,工作流编排是所有在深度学习系统内运行的自动化的驱动力。它允许人们定义工作流程或管道—有向无环图(DAGs)—将单个任务以执行顺序粘合在一起。工作流编排组件编排这些工作流的任务执行。一些典型的示例是

  • 在构建新数据集时启动模型训练

  • 监控上游数据源,增补新数据,转换其格式,通知外部标记者,并将新数据合并到现有数据集中

  • 如果通过了一些可接受的标准,则将训练好的模型部署到模型服务器

  • 持续监控模型性能指标并在检测到性能下降时提醒开发人员

您将学习如何在第九章中构建或设置工作流编排系统。

交互式数据科学环境

由于合规性和安全性原因,无法从生产环境将客户数据和模型下载到本地工作站。为了让数据科学家交互式地探索数据,在工作流编排中排查管道执行问题以及调试模型,需要一个位于深度学习系统内的远程交互式数据科学环境(图 1.3,框 G)。

公司通常会使用开源 Jupyter Notebooks (jupyter.org/) 或利用云供应商的基于 JupyterLab 的解决方案,如 Amazon SageMaker Studio (aws.amazon.com/sagemaker/studio/) 来建立自己的可信数据科学环境。

典型的交互式数据科学环境应提供以下功能:

  • 数据探索—为数据科学家提供对客户数据的便捷访问,但保持其安全和合规性;没有数据泄漏,并且任何未经授权的数据访问将被拒绝。

  • 模型原型—为数据科学家提供了必要的工具,可以在深度学习系统内快速开发 POC 模型。

  • 故障排除—使工程师能够调试发生在深度学习系统内的任何活动,例如下载模型并对其行为进行分析,或者检查失败管道中的所有输入/输出工件(中间数据集或配置)。

1.2.3 主要用户场景

为了更好地理解深度学习系统在开发周期中的使用方式(图 1.1),我们准备了说明它们如何被使用的示例场景。让我们从编程消费者开始,如图 1.4 所示。将数据推送到系统的数据收集器通常会通过 API 最终到达数据管理服务,该服务收集和组织原始数据用于模型训练。

图 1.4 数据从来源或收集器推送,通过 API 到数据管理服务,数据在那里进一步组织和存储为更适合模型训练的格式。

深度学习应用通常会访问模型推断服务,从训练模型中获取推理结果,这些结果被用于支持最终用户消费的深度学习功能。图 1.5 显示了这种交互的顺序。脚本,甚至是完整的管理服务,也可以是程序化的消费者。因为它们是可选的,我们简化了图表,没有将它们包括在内。

图 1.5 深度学习应用通过 API 请求推理。模型推断服务接受并处理对训练模型的请求,并产生返回给应用程序的推理结果。

人类消费者和 API 之间通常还有一个额外的层——用户界面。界面可以是基于 web 的,也可以是基于命令行的。一些高级用户甚至可以跳过这个界面直接使用 API。让我们逐个角色讨论一下。

研究人员使用系统的典型场景如图 1.6 所示。研究人员可以查找可用数据来尝试他们的新建模技术。他们访问用户界面,并访问数据探索和可视化部分,从数据管理服务中提取数据。可能会涉及大量手动数据处理,将其处理成可以被新的训练技术使用的形式。一旦研究人员确定了一种技术,他们可以将其打包为库供其他人使用。

图 1.6 研究人员使用场景序列,他对查看可用于研究和开发新建模技术的数据感兴趣。研究人员与 API 和幕后的数据管理支持的用户界面进行交互。

数据科学家和工程师可以通过首先查看可用数据来研究用例,类似于上一段中研究人员最初要做的事情。这将得到数据管理服务的支持。他们做出假设,并将数据处理和训练技术组合成代码。这些步骤可以结合成一个工作流,使用工作流管理服务。

当工作流管理服务执行工作流的运行时,它联系数据管理服务和模型训练服务来执行实际任务并跟踪它们的进展。每个服务和训练代码都将超参数、代码版本、模型训练度量和测试结果存储到元数据和工件存储中。

通过用户界面,数据科学家和工程师可以比较实验运行并推断出训练模型的最佳方法。前述场景如图 1.7 所示。

图 1.7 数据科学家定义模型训练工作流程、运行它并审查结果的使用序列

产品经理还可以通过用户界面查看和查询整个系统的各种指标。指标数据可以由元数据和工件存储提供。

1.2.4 推导您自己的设计

现在,我们已经讨论了参考系统架构的所有方面,让我们讨论一些定制您自己版本的指南。

收集目标和需求

设计任何成功系统设计的第一步是具有一组清晰的目标和要求,以便进行工作。这些理想情况下应该来自您系统的用户,直接或间接通过产品管理团队或工程管理团队。这个简短的目标和要求清单将帮助您形成您的系统将会是什么样子的愿景。这个愿景,反过来又应该是您在系统设计和开发阶段的指导方针。

注意 有时工程师被要求开发一个支持一个或多个已经存在的深度学习应用程序的系统。在这种情况下,您可以首先确定这些应用程序中的一组共同需求,以及您的系统如何设计来快速为这些应用程序带来创新。

要收集系统的目标和需求,您需要确定系统的不同类型的用户和利益相关者,或者系统的人物角色。(这是一个通用概念,可以应用于大多数系统设计问题。)毕竟,是用户将帮助您阐明系统的目标和需求。

我们的建议是,如果您不确定一个好的起点,请从用例或应用需求开始。以下是一些示例问题,您可以向用户提出:

  • 给数据工程师和产品经理—系统是否允许应用程序收集用于训练的数据?系统是否需要处理流输入数据?正在收集多少数据?

  • 给数据科学家和工程师—我们如何处理和标记数据?系统是否需要为外部供应商提供标注工具?我们如何评估模型?我们如何处理测试数据集?数据科学工作是否需要交互式笔记本用户界面?

  • 给研究人员和数据科学家—模型训练需要多大量的数据?模型训练的平均时间是多少?研究和数据科学需要多少计算和数据容量?系统应该支持哪些实验?需要收集哪些元数据和指标来评估不同的实验?

  • 给产品经理和软件工程师—模型服务是在远程服务器上完成还是在客户端上完成的?它是实时模型推断还是脱机批量预测?是否有延迟要求?

  • 对产品经理 ——我们在组织中试图解决什么问题?我们的商业模式是什么?我们将如何评估我们的实施效果?

  • 对安全团队 ——您的系统需要什么级别的安全性?数据访问是完全开放还是严格限制/隔离?是否有审计要求?是否有一定级别的合规性或认证(例如,通用数据保护条例,系统和组织控制 2 等)需要系统达到?

定制参考架构

设计要求和范围明确后,我们可以开始定制图 1.3 中的参考架构。首先,我们可以决定是否需要添加或删除任何组件。例如,如果需求仅仅是在远程服务器群中管理模型训练,我们可以删除工作流管理组件。如果数据科学家想要有效评估生产数据的模型性能,他们也可以添加一个实验管理组件。这个组件允许数据科学家使用系统中已经存在的全量数据进行训练和验证,并对生产流量进行在线 A/B 测试,使用以前未见过的数据。

第二步是根据您的特定需求设计和实现每个关键组件套件。根据要求,您可能会从数据集管理服务中排除数据流 API,并添加分布式训练支持,如果训练速度是一个问题的话。您可以从头开始构建每个关键组件,也可以使用开源软件。在本书的其余部分,我们涵盖了每个关键组件的这两种选项,以确保您知道该做什么。

提示 保持系统设计简单和用户友好。创建如此庞大的深度学习系统的目的是提高深度学习开发的生产力,所以请记住这一点。我们希望使数据科学家能够构建高质量的模型,而不需要了解底层系统的运行情况。

1.2.5 在 Kubernetes 之上构建组件

我们已经介绍了一系列实现为服务的关键组件。有了这么多服务,您可能希望在基础架构层面使用一个复杂的系统来管理它们,例如 Kubernetes。

Kubernetes 是一个用于自动化部署、扩展和管理容器化应用程序的开源系统,这些应用程序在隔离的运行时环境中运行,例如 docker 容器。我们已经看到了一些构建在 Kubernetes 之上的深度学习系统。一些人学习如何使用 Kubernetes,却从未知道为什么要用它来运行深度学习服务,所以我们想解释它背后的思想。如果您熟悉 Kubernetes,请随意跳过本节。

注意 Kubernetes 是一个复杂的平台,需要一本书的篇幅来进行教学,所以我们只讨论它在深度学习系统中的优点。如果你想学习 Kubernetes,我们强烈推荐你阅读 Kubernetes in Action(Manning,2018),作者是 Marko Lukša。

管理计算资源的挑战

在远程服务器上执行一个 Docker 容器似乎是一个简单的任务,但在 30 个不同的服务器上运行 200 个容器就是另外一回事了。存在许多挑战,例如监视所有远程服务器以确定在哪个上运行容器,需要将容器故障转移到健康的服务器,当容器卡住时重新启动容器,跟踪每个容器运行并在完成时收到通知等。为了解决这些挑战,我们必须自己监视硬件、操作系统进程和网络。这不仅在技术上具有挑战性,而且工作量巨大。

Kubernetes 如何帮助

Kubernetes 是一个开源的容器编排平台,用于调度和自动化部署、管理和扩展容器化应用程序。一旦你设置了 Kubernetes 集群,你的服务器组的操作(部署、打补丁、更新)和资源就变得可管理了。这里有一个部署示例:你可以告诉 Kubernetes 运行一个带有 16GB 内存和 1 个 GPU 的 Docker 镜像,Kubernetes 将为你分配资源来运行这个 Docker 镜像。

这对软件开发人员来说是一个巨大的好处,因为并不是每个人都有丰富的硬件和部署经验。通过 Kubernetes,我们只需要声明集群的最终状态,Kubernetes 就会实际完成工作以达到我们的目标。

除了容器部署的好处之外,以下是一些其他关键的 Kubernetes 功能,对于管理我们的训练容器至关重要:

  • 自动缩放功能 — 根据工作负载,Kubernetes 自动调整集群中节点的数量。这意味着如果有突然的用户请求增加,Kubernetes 将自动增加容量,这被称为 弹性计算管理

  • 自愈能力 — 当 Pod 失败或节点死亡时,Kubernetes 会重新启动、替换或重新调度 Pod。它还会终止不响应用户定义的健康检查的 Pod。

  • 资源利用和隔离 — Kubernetes 负责计算资源饱和;它确保每个服务器都得到充分利用。在内部,Kubernetes 在 Pod 中启动应用程序容器。每个 Pod 都是一个带有计算资源保证的隔离环境,并且运行一个功能单元。在 Kubernetes 中,只要多个 Pod 的组合资源需求(CPU、内存、磁盘)不超过节点的限制,多个 Pod 就可以在一个节点(服务器)中,因此服务器可以轻松地被不同的功能单元共享,并保证隔离。

  • 命名空间——Kubernetes 支持将物理集群划分为不同的虚拟集群。这些虚拟集群称为命名空间。您可以为每个命名空间定义资源配额,这使您可以通过将它们分配给不同的命名空间来为不同的团队隔离资源。

另一方面,这些好处是有代价的——它们也会消耗资源。当您运行一个 Kubernetes pod 时,pod 本身会占用一定量的系统资源(CPU、内存)。这些资源是在运行 pod 内部的容器所需资源之上消耗的。在许多情况下,Kubernetes 的开销似乎是合理的;例如,根据 Lally Singh 和 Ashwin Venkatesan(2021 年 2 月)在文章“我们如何将 Kubernetes 的开销最小化在我们的作业系统中”中发表的实验,每个 pod 的 CPU 开销约为每秒 10 毫秒。

注意我们建议您查看附录 B,了解现有深度学习系统与本章介绍的概念之间的关系。在该附录中,我们将 1.2.1 节描述的参考架构与 Amazon SageMaker、Google Vertex AI、Microsoft Azure Machine Learning 和 Kubeflow 进行了比较。

1.3 构建深度学习系统与开发模型

在我们开始之前的最后一项准备工作是:我们认为强调构建深度学习系统开发深度学习模型之间的区别至关重要。在本书中,我们将开发深度学习模型的实践定义为解决问题的过程

  • 探索可用数据以及如何将其转换为训练数据

  • 确定用于解决问题的有效训练算法

  • 训练模型并开发推理代码以针对未见数据进行测试

请记住,深度学习系统不仅应支持模型开发所需的所有任务,还应支持其他角色执行的任务,并使这些角色之间的协作无缝。在构建深度学习系统时,您不是在开发深度学习模型;您正在构建一个支持深度学习模型开发的系统,使该过程更加高效和可扩展。

我们发现已有大量关于构建模型的材料发布。但是,我们几乎没有看到有关设计和构建支持这些模型的平台或系统的资料。这就是为什么我们写了这本书。

摘要

  • 典型的机器学习项目开发经历以下循环:产品启动、数据探索、模型原型制作、生产化和生产集成。

  • 深度学习项目开发涉及七种不同的角色:产品经理、研究人员、数据科学家、数据工程师、MLOps 工程师、机器学习系统工程师和应用工程师。

  • 深度学习系统应该降低深度学习开发周期中的复杂性。

  • 在深度学习系统的帮助下,数据科学家不需要突然成为专业的 DevOps 或数据工程师,但应该能够以可扩展的方式实现模型,建立数据管道,独立地部署和监控模型。

  • 高效的深度学习系统应该让数据科学家专注于有趣且重要的数据科学任务。

  • 高层次的参考架构(如图 1.3 所示)可以帮助您快速开始一个新的设计。首先,复制一份并收集目标和需求。最后,根据需要添加、修改或减少组件及其关系。

  • 基础的深度学习系统由以下关键组件组成:数据集管理器、模型训练器、模型服务、元数据和存储容器、工作流编排和数据科学环境。

  • 数据管理组件帮助收集、组织、描述和存储数据作为可用于训练的数据集。它还支持数据探索活动并跟踪数据集之间的血统。第二章将详细讨论数据管理。

  • 模型训练组件负责处理多个训练请求,并在给定有限的计算资源的情况下高效地运行它们。第三章和第四章将回顾模型训练组件。

  • 模型服务组件处理传入的推断请求,使用模型生成推断结果,并将其返回给请求者。章节 6 和 7 将介绍这部分内容。

  • 元数据和存储容器组件记录元数据并存储来自系统其余部分的工件。系统产生的任何数据都可以视为工件。其中大多数将是模型,其附带的元数据将存储在同一组件中。这提供了完整的血统信息,以支持实验和故障排除。我们将在第八章中讨论这个组件。

  • 工作流管理组件存储链式定义,连接数据处理和模型训练的不同步骤。它负责触发周期性工作流运行,并跟踪正在其他组件上执行的每个运行步骤的进度—例如,在模型训练服务上执行的模型训练步骤。在第九章中,我们将介绍该组件的实例。

  • 深度学习系统应支持深度学习开发周期,并使多个角色之间的协作变得简单。

  • 构建深度学习系统与开发深度学习模型是不同的。系统是支持深度学习模型开发的基础设施。

第二章:数据集管理服务

本章包括

  • 理解数据集管理

  • 使用设计原则构建数据集管理服务

  • 构建一个样本数据集管理服务

  • 使用开源方法进行数据集管理

在我们对深度学习系统做一般讨论之后,我们已经准备好了章节的其他部分,这些部分专注于这些系统中的特定组件。我们首先介绍数据集管理,不仅因为深度学习项目是数据驱动的,还因为我们希望提醒您在构建其他服务之前考虑数据管理有多重要。

在深度学习模型开发过程中,数据集管理(DM)往往被忽视,而数据处理、模型训练和服务则吸引了最多的注意力。数据工程中的一个普遍观点是,好的数据处理流程,比如 ETL(抽取、转换和加载)流程,已经足够了。但是,如果在项目进行过程中避免管理数据集,你的数据收集和数据集消耗逻辑将变得越来越复杂,模型性能改进会变得困难,最终整个项目也会变慢。一个好的 DM 系统可以通过解耦训练数据的收集和消耗加快模型的开发;它还可以通过对训练数据进行版本控制来实现模型的可重复性。

我们保证您将感谢自己明智的决定,构建或至少设置一个数据集管理组件,以补充现有的数据处理流程。并且在着手训练和服务组件之前构建它。您的深度学习项目开发将会更快,长远来看可以产生更好的结果和更简单的模型。因为 DM 组件将上游数据的复杂性屏蔽在您的模型训练代码之外,您的模型算法开发和数据开发可以并行运行。

本章涉及为您的深度学习项目构建数据集管理功能。由于深度学习算法、数据流程和数据源的多样性,数据集管理是深度学习行业中经常讨论的话题。目前还没有统一的方法,而且似乎永远不会有一个。因此,为了在实践中对您有益,我们将专注于教授设计原则,而不是倡导单一方法。我们在本章构建的样本数据集管理服务展示了实施这些原则的一种可能方法。

在第 2.1 节,您将了解到为什么需要数据集管理,它应该解决哪些挑战以及它在深度学习系统中所扮演的关键角色。我们还将介绍其关键设计原则,为下一节的具体示例做好准备。

在第 2.2 节中,我们将基于第 2.1 节介绍的概念和设计原则演示一个数据集管理服务。首先,我们将在您的本地机器上设置该服务并进行实验。其次,我们将讨论内部数据集存储和数据模式、用户场景、数据摄取 API 和数据集提取 API,以及提供设计和用户场景的概述。在这个过程中,我们还将讨论在服务设计中作出的一些重要决策的优点和缺点。

在第 2.3 节,我们将看两种开源方法。如果你不想要自行构建的数据集管理服务,你可以使用已经构建好、可用和可适应的组件。例如,如果您的现有数据流水线是基于 Apache Spark 构建的,您可以使用 Delta Lake 与 Petastorm 进行数据集管理。或者,如果您的数据直接来自于云对象存储,例如 AWS 简单存储服务(S3)或 Azure Blob,您可以选择采用 Pachyderm。我们以图像数据集准备为例,展示这两种方法如何在实践中处理非结构化数据。在本章结束时,您将对数据集管理的内在特性和设计原则有深入的了解,这样您可以自己构建数据集管理服务或改进工作中的现有系统。

2.1 理解数据集管理服务

数据集管理组件或服务是一个专门的数据存储,用于组织数据以支持模型训练和模型性能故障排除。它处理来自上游数据源的原始数据,并以一种明确定义的结构(数据集)返回用于模型训练的训练数据。图 2.1 显示了数据集管理服务提供的核心价值。在图中,我们可以看到数据集管理组件将原始数据转换为有利于模型训练的一致的数据格式,因此下游模型训练应用程序只需关注算法开发。

图 2.1 数据集管理服务是一个专门的数据存储;它使用自己的原始数据格式将数据导入其内部存储。在训练期间,它将原始数据转换为一致的数据格式,以便于模型训练。

2.1.1 深度学习系统为什么需要数据集管理

在我们开始查看示例数据集管理服务之前,让我们花一点时间解释为什么 DM 是任何深度学习系统的重要组成部分。这一部分很重要,因为根据我们的经验,除非你完全理解为什么,否则无法设计解决实际问题的系统。

对于为什么这个问题,有两个答案。第一,DM 可以通过将训练数据的收集使用分离来加快模型的开发。第二,一个设计良好的 DM 服务通过对训练数据集进行版本跟踪来支持模型的可复现性。让我们详细讨论这两个观点。

解耦训练数据收集与消费

如果你完全独自开发深度学习项目,项目开发工作流程是以下步骤的迭代循环:数据收集、数据集预处理、训练和评估(见图 2.2)。虽然如果你在数据收集组件中更改数据格式,可能会破坏下游数据集预处理代码或训练代码,但这不是一个大问题。因为你是唯一的代码所有者,你可以自由更改;没有其他人受到影响。

图 2.2 单人深度学习项目开发的工作流程是一系列线性步骤的迭代循环。

当我们正在构建一个面向数十个不同深度学习项目并向多人和团队开放的严肃深度学习平台时,简单的数据流程图将迅速扩张到令人困惑的 3D 图(图 2.3)。

图 2.3 企业中的深度学习模型开发在多个维度上运行。多个团队合作以在不同阶段完成项目。每个团队专注于工作流程的一个步骤,同时还在多个项目上工作。

图 2.3 显示了企业深度学习开发环境的复杂性。在这种情况下,每个人只负责一个步骤而不是整个工作流程,并且他们为多个项目开发他们的工作。理想情况下,这个过程是有效的,因为人们通过专注于一个特定问题来建立他们的专业知识。但这里有个问题:通信成本经常被忽视。

当我们将工作流程的步骤(图 2.2)分配给多个团队时,需要数据模式进行握手。没有数据合同,下游团队不知道如何读取上游团队发送的数据。让我们回到图 2.3。想象一下,如果有四个团队并行开发的 10 个项目,尤其是每个团队处理工作流程的不同步骤,我们需要多少数据模式来在团队之间进行通信。

现在,如果我们想要向训练数据集添加一个新特征或属性(如文本语言),我们需要召集每个团队,在新数据格式上达成共识,并实施更改。这是一项巨大的工作,因为公司内部的跨团队协作是复杂的。通常需要几个月的时间来做出一个小改变;因为每个团队都有自己的优先事项,你必须等待他们的待办事项清单。

更糟糕的是,深度学习模型开发是一个迭代过程。它要求不断调整训练数据集(包括上游数据管道)以提高模型准确性。这需要数据科学家、数据开发人员和平台开发人员高频率地进行交互,但由于跨团队工作流程的设置,数据迭代发生缓慢,这是在生产环境中模型开发如此缓慢的原因之一。

另一个问题是,当我们同时开发多种类型的项目(图像、视频和文本)时,数据模式的数量将会激增。如果让每个团队自由定义新的数据模式,并且不进行适当管理,那么保持系统向后兼容几乎是不可能的。新数据的更新将变得越来越困难,因为我们必须花费额外的时间来确保新数据更新不会破坏过去构建的项目。因此,项目开发速度将会显著减慢。

为解决迭代缓慢和数据模式管理问题,我们可以构建一个数据集管理服务。让我们看一下图 2.4,以帮助确定引入数据集管理服务后项目开发工作流程的变化。

在图 2.4 中,我们看到一个数据集管理服务将模型开发工作流程分成了两个独立的空间:数据开发者空间和数据科学家空间。长迭代循环(图 2.2)现在被分成了两个小循环(图 2.4),每个循环由一个团队拥有,因此数据开发者和数据科学家可以分别迭代数据收集和模型训练;因此,深度学习项目可以更快地迭代。

图 2.4 一个数据集管理组件通过为训练数据收集和消耗定义强类型模式,为两者之间创建了良好的分离,这使得数据开发和模型算法开发可以在自己的循环中迭代,从而加快了项目的开发速度。

你可能也注意到,现在我们把所有的数据模式都放在了一个地方:一个数据集管理服务,它管理着两种强类型的数据模式——每种类型的数据集都有一个摄取数据模式和一个训练数据模式。通过在数据转换过程中在 DM 内部进行数据摄取和训练的两个单独的数据模式,你可以确保上游数据收集中的数据更改不会破坏下游的模型训练。由于数据模式是强类型的,未来的数据升级可以轻松地保持向后兼容。

为项目定义强类型数据集可能并不是一个好主意,因为我们仍在探索各种数据选项。因此,我们还建议定义一种特殊的无模式数据集类型,例如GENERIC类型,它没有强类型模式限制。对于此数据集类型中的数据,DM 只接受原样数据,并且不执行数据验证和转换(有关详细示例,请参见第 2.2.6 节)。从数据处理管道中收集的数据可以直接由训练流程使用。虽然整个工作流程可能会很脆弱,但自由数据集类型满足了在早期阶段项目需要灵活性的需求。一旦项目成熟,我们可以创建强类型模式并为它们定义数据集类型。

总结本节,管理数据集类型的两个数据架构是解耦数据科学家和数据开发者的秘密武器。在第 2.2.6 节,我们将展示如何在我们的示例数据集管理服务中实现这些架构。

实现模型可重现性

一个设计良好的数据集管理服务通过在训练数据集上进行版本跟踪来支持模型可重现性,例如,使用版本字符串来获取在以前模型训练运行中使用的确切训练文件。相对于数据科学家(模型算法开发),模型可重现性的优势在于,你可以重复在某个数据集上运行深度学习算法(例如 NLP 中的自注意力变换器),并获得相同或相似质量的结果。这被称为算法可重现性

从深度学习系统开发者的角度来看,模型可重现性是算法可重现性的超集。它要求数据集管理系统能够复现其输出物件(数据集)。例如,我们需要获取确切的训练数据和训练配置来复现过去训练过的模型。

模型可重现性对于机器学习项目至关重要,有两个主要原因。第一个是信任。可重现性为生成模型的系统创造了信任和可信度。对于任何系统,如果输出无法复现,人们简单地不会信任该系统。这在机器学习项目中非常相关,因为应用程序将根据模型输出做出决策——例如,聊天机器人将根据用户意图预测将用户呼叫转接到适当的服务部门。如果我们无法复现模型,构建在模型之上的应用程序是不确定性的和不可信的。

第二个原因是模型可重现性有助于性能故障排除。在检测到模型性能退化时,人们首先想要找出训练数据集和训练算法代码发生了什么变化。如果不支持模型可重现性,性能故障排除将非常困难。

2.1.2 数据集管理设计原则

在我们开始构建之前,我们想要概述 DM 的五个设计原则。

注意:我们认为这五个原则是本章最重要的元素。对于数据应用程序,我们在设计中遵循的原则比实际设计更重要。因为数据可能是任何形式的任何东西,在一般情况下,没有适用于所有数据处理用例的数据存储范式,也没有适用于所有数据处理用例的标准设计。因此,在实践中,我们通过遵循某些通用原则来构建我们自己的数据应用程序。因此,这些原则至关重要。

这里的五个原则将为您建立新的 DM 服务或改进现有的 DM 服务提供明确的设计目标。

原则 1:支持数据集可重现性以重现模型

数据集的可重现性意味着 DM 总是返回过去返回的完全相同的训练示例。例如,当训练团队开始训练模型时,DM 提供了一个带有版本字符串的数据集。每当训练团队——或任何其他团队——需要检索相同的训练数据时,它都可以使用此版本字符串查询 DM 以检索相同的训练数据。

我们相信所有的 DM 系统都应该支持数据集的可重现性。更好的是还能提供数据差异功能,这样我们就可以轻松地看到两个不同数据集版本之间的数据差异。这对故障排除非常方便。

原则 2:在不同类型的数据集上提供统一的 API

深度学习的数据集可能是结构化的(文本,如销售记录或用户对话的文字稿)或非结构化的(图像、语音记录文件)。无论 DM 系统如何在内部处理和存储这些不同形式的数据,它都应该为上传和获取不同类型的数据集提供统一的 API 接口。API 接口还将数据源与数据使用者抽象出来;无论发生什么情况,比如数据解析更改和内部存储格式更改,下游使用者都不应受到影响。

因此,我们的用户,包括数据科学家和数据开发人员,只需要学习一个 API 就能处理所有不同类型的数据集。这使系统简单易用。此外,由于我们只公开一个公共 API,代码维护成本将大大降低。

原则 3:采用强类型数据模式

强类型数据模式是避免由数据更改引起的意外故障的关键。通过数据模式强制执行,DM 服务可以保证其摄取的原始数据和生成的训练数据与我们的规范一致。

强类型数据模式充当安全防护,以确保下游模型训练代码不受上游数据收集更改的影响,并确保 DM 的上游和下游客户的向后兼容性。如果没有数据模式保护,则数据集使用者——下游模型训练代码——很容易受到上游数据更改的影响。

数据模式也可以进行版本控制,但这会增加管理的复杂性。另一个选项是每个数据集只有一个模式。在引入新的数据更改时,确保模式更新是向后兼容的。如果新的数据需求需要破坏性更改,则创建一个具有新模式的新数据集类型,而不是更新现有的数据集。

原则 4:确保 API 一致性并在内部处理扩展

深度学习领域的当前趋势是,随着数据集不断增大,模型架构也变得越来越复杂。例如,GPT-3(一个用于语言理解的生成预训练转换器语言模型)使用超过 250 TB 的文本材料,其中包含数百亿个单词;在特斯拉中,自动驾驶模型消耗了海量的数据,达到了 PB 级。另一方面,对于一些简单的窄领域任务(如客户支持工单分类),我们仍然使用小型数据集(约 50 MB)。数据集管理系统应该在内部解决数据扩展的挑战,并且向用户(数据开发者和数据科学家)提供的 API 对于大型和小型数据集应该是一致的。

原则 5:保证数据持久性

理想情况下,用于深度学习训练的数据集应该以不可变的方式存储,以便复现训练数据和进行故障排查。数据删除应该是软删除,只有极少数例外情况才需要进行硬删除,例如当客户选择退出或取消账户时永久删除客户数据。

2.1.3 数据集的矛盾性

为了结束我们关于数据集管理的概念讨论,我们想要澄清数据集一个模糊的方面。我们见过许多设计不良的数据集管理系统在这一点上失败。

数据集具有矛盾的特性:它既是动态的又是静态的。从数据科学家的角度来看,数据集是静态的:它是一组带有注释(也称为标签)的固定文件。从数据开发者的角度来看,数据集是动态的:它是一个远程存储中的文件保存目的地,我们不断向其添加数据。

因此,从数据管理的角度来看,数据集应该是一个逻辑文件组,同时满足数据收集和数据训练的需求。为了帮助您对数据集的动态和静态特性有具体的理解,让我们看一下图 2.5。

图 2.5 数据集是一个逻辑文件组:它既是动态的又是静态的,对于数据收集来说是可编辑的,但对于模型训练来说是固定的。

我们可以从数据摄入和数据获取两个角度来阅读图 2.5。首先,从数据摄入的角度来看,我们看到数据收集管道(图中左侧)不断地注入新数据,例如文本话语和标签。例如,在时间 T0,数据集中创建了一个示例数据批次(示例批次 T0)——T1、T2 和 T3 时间也是如此;随着时间的推移,我们总共创建了四个数据批次。因此,从数据开发者的角度来看,这个数据集是可变的,因为管道不断向其中添加数据。

其次,在训练数据获取方面(从图的顶部),我们可以看到在获取训练数据时,DM 同时读取数据集中的所有当前数据。我们可以看到数据以静态的版本快照方式返回,该快照具有一个版本字符串,用于唯一标识从数据集中选择的实际数据。例如,当我们从时间点 T2 的数据集中获取训练数据时,数据集有三个数据批次(批次 T0、批次 T1 和批次 T2)。我们将这三个数据批次打包成一个快照,分配一个版本字符串(“version1”)并将其作为训练数据返回。

从模型训练的角度来看,从 DM 获取的数据集是数据集的静态快照——一个经过时间过滤和客户逻辑过滤的数据集。静态快照对于模型的可复制性至关重要,因为它代表了训练过程中使用的确切训练文件。当我们需要重新构建模型时,我们可以使用快照版本字符串来找到过去模型训练中使用的快照。

我们对理论介绍已经进行了全面的介绍,您应该能够掌握数据集管理组件的需求、目标和独特特性。下一节是如何设计数据集管理服务的具体示例。

2.2 浏览示例数据集管理服务

在本节中,我们将带您了解一个示例 DM 服务。我们构建了这个示例,以便让您了解第 2.1.2 节中介绍的原理如何实施。我们首先在本地运行服务,与之互动,然后查看其 API 设计和内部实现。

2.2.1 与示例服务交互

为了方便您操作,我们构建了七个 shell 脚本来自动化整个 DM 实验室。这些 shell 脚本是本节演示场景的推荐方式,因为它们不仅自动配置本地服务,还会处理环境变量设置、准备示例数据和初始化本地网络。

您可以在github.com/orca3/MiniAutoML/tree/main/scripts找到这些脚本,从搜索词“dm”开始。我们 GitHub 仓库中的“功能演示”文档(github.com/orca3/MiniAutoML/tree/main/data-management)提供了完成实验以及这些脚本的示例输出的详细说明。

在运行功能演示之前,请确保已满足系统要求。请参考github.com/orca3/MiniAutoML#system-requirements

这个实验室分为三个部分:首先,运行示例数据集管理服务;其次,创建一个数据集并上传数据;再次,从刚创建的数据集中获取训练数据。

在本地设置服务

示例服务是用 Java 11 编写的。它使用 MinIO 作为文件 Blob 服务器来模拟云对象存储(如 Amazon S3),因此我们可以在本地运行而无需任何远程依赖。如果您在附录 A 中设置了实验,您可以在终端中的脚本文件夹的根目录运行以下命令(列表 2.1)来启动服务。

注意在运行 DM demo 脚本之前,强烈建议从干净的设置开始。您可以执行 ./scripts/lab-999-tear-down.sh 来清除之前的实验。

列表 2.1 在本地启动服务

# (1) Start minio server
./scripts/dm-001-start-minio.sh     

# (2) start dataset management service, it will build 
➥ the dm image and run the container.
./scripts/dm-002-start-server.sh

为了保持服务的最简设置,我们将所有数据集记录保存在内存中,以避免使用数据库。请注意,如果重新启动数据集管理服务,你将丢失所有数据集。

创建和更新语言意图数据集

我们的示例 DM 服务为用户提供了三种 API 方法来创建/更新数据集并检查结果。这些 API 方法是 CreateDatasetUpdateDatasetGetDatasetSummary。我们将在接下来的几节中详细讨论它们。

在此示例场景中,我们首先调用数据管理服务上的 CreateDataset API 方法来创建一个新的语言意图数据集;然后我们使用 UpdateDataset API 方法向数据集追加更多数据。最后,我们使用 GetDatasetSummary API 方法获取数据集的统计信息和提交(数据更改)历史记录。

注意脚本 dm-003-create-dataset.sh 和 dm-004-add-commits.sh 自动化了之前的步骤。请使用它们来运行演示场景。请注意,以下代码列表仅供说明目的。

现在让我们运行实验。首先,我们将使用以下列表创建一个数据集。

列表 2.2 创建语言意图数据集

mc -q cp data-management/src/test/resources/datasets/test.csv  ❶
➥ myminio/"${MINIO_DM_BUCKET}"/upload/001.csv

grpcurl -v -plaintext \                                        ❷
 -d '{"name": "dataset-1", \
      "dataset_type": "LANGUAGE_INTENT", \                     ❸
      "bucket": "mini-automl", \                               ❹
      "path": "{DATA_URL_IN_MINIO}"}' \                        ❹
 ${DM_SERVER}:${DM_PORT} \
 data_management.DataManagementService/CreateDataset           ❺

❶ 将原始数据(upload/001.csv)上传到云存储

❷ 创建数据集的 gRPC 请求

❸ 数据集类型

❹ MinIO 中原始数据的数据 URL,例如,upload/001.csv

❺ API 名称

应注意,CreateDataset API 预期用户在 gRPC 请求中提供可下载的 URL,而不是实际数据,这就是为什么我们首先将 001.csv 文件上传到本地 MinIO 服务器的原因。数据集创建完成后,CreateDataset API 将返回一个包含数据摘要和数据集历史提交的 JSON 对象。以下是一个示例结果:

{
 "datasetId": "1", 
  "name": "dataset-1",
 "dataset_type": "TEXT_INTENT",
  "last_updated_at": "2021-10-09T23:44:00",
  "commits": [                                ❶
    {       
      "dataset_id": "1",
      "commit_id": "1",                       ❷
      "created_at": "2021-10-09T23:44",
 "commit_message": "Initial commit",
      "tags": [                               ❸
        {
          "tag_key": "category",
          "tag_value": "test set"
        }
      ],
      "path": "dataset/1/commit/1",
      "statistics": {                         ❹
        "numExamples": "5500",
        "numLabels": "151"
      }
    }
  ]
}

❶ 提交是数据集更新的快照。

❷ 提交 ID;此提交捕获了来自 upload/001.csv 的数据。

❸ 提交标签用于在构建训练数据集时过滤提交。

❹ 提交的数据摘要

创建数据集后,你可以通过追加更多数据来持续更新它;请参见以下的数据集更新 gRPC 请求。

列表 2.3 更新语言意图数据集

mc -q cp data-management/src/test/resources/datasets/train.csv  myminio/"${MINIO_DM_BUCKET}"/upload/002.csv        ❶

grpcurl -v -plaintext \                                 ❷
 -d '{"dataset_id": "1", \                              ❸
      "commit_message": "More training data", \
      "bucket": "mini-automl", \                        ❹
      "path": "upload/002.csv", \                       ❹
      "tags": [{ \
        "tag_key": "category", \
        "tag_value": "training set\"}]}' \ 
${DM_SERVER}:${DM_PORT} \
data_management.DataManagementService/UpdateDataset     ❺

❶ 将原始数据(upload/002.csv)上传到云存储

❷ 请求追加更多数据(upload/002.csv)

❸ 用从 CreateDataset API 返回的值替换数据集 ID。

❹ 原始数据的数据 URL,由原始数据上传创建

❺ 更新数据集的 API 名称

一旦数据集更新完成,UpdateDataset API 会以与 CreateDataset API 相同的方式返回一个数据摘要 JSON 对象;请参考以下示例响应对象:

{
  "datasetId": "1",
  "name": "dataset-1",
  "dataset_type": "TEXT_INTENT",
  "last_updated_at": "2021-10-09T23",
  "commits": [
    {
      "commit_id": "1",        ❶
       .. .. ..
    },
    {
      "dataset_id": "1",
      "commit_id": "2",        ❷
      "created_at": "2021-10-09T23:59:17",
      "commit_message": "More training data",
      "tags": [
        {
          "tag_key": "category",     
          "tag_value": "training set" 
        }
      ],
      "path": "dataset/1/commit/2",
      "statistics": {
        "numExamples": "7600",
        "numLabels": "151"
      }
    }
  ]
}

❶ 由创建数据集请求创建的提交

❷ 提交 ID;此提交捕获来自 upload/002.csv 的数据。

你还可以使用 GetDatasetSummary API 来获取数据集的数据摘要和提交历史。请参考以下示例的 gRPC 请求:

grpcurl -v -plaintext 
  -d '{"datasetId": "1"}' \     ❶
${DM_SERVER}:${DM_PORT} \
data_management.DataManagementService/GetDatasetSummary

❶ 要查询的数据集的 ID

获取训练数据集

现在我们已经创建了一个数据集(ID = 1),包含原始数据;让我们尝试从中构建一个训练数据集。在我们的示例服务中,这是一个两步骤的过程。

我们首先调用 PrepareTrainingDataset API 开始数据集构建过程。然后,我们使用 FetchTrainingDataset API 查询数据集准备进度,直到请求完成。

注意,脚本 dm-005-prepare-dataset.sh、dm-006-prepare-partial-dataset.sh 和 dm-007-fetch-dataset-version.sh 自动化了接下来的步骤。请尝试使用它们来运行代码清单 2.4 和 2.5 中的示例数据集获取演示。

要使用 PrepareTrainingDataset API,我们只需提供一个数据集 ID。如果你只想让部分数据进入训练数据集,你可以在请求中使用 tag 作为过滤器。请参考以下的示例请求。

清单 2.4 准备训练数据集

grpcurl -plaintext \                   ❶
 -d “{“dataset_id”: “1”}” \            ❶
 ${DM_SERVER}:${DM_PORT} \             ❶
data_management.DataManagementService/PrepareTrainingDataset

grpcurl -plaintext \                   ❷
 -d “{“dataset_id”: “1”, \             ❷
 “Tags”:[ \                            ❷
   {“tag_key”:”category”, \            ❸
    “tag_value”:”training set”}]}” \   ❸
 ${DM_SERVER}:${DM_PORT} data_management.DataManagementService/PrepareTrainingDataset

❶ 准备包含所有数据提交的训练数据集的请求

❷ 通过定义过滤标签,准备包含部分数据提交的训练数据集的请求

❸ 数据过滤器

一旦数据准备的 gRPC 请求成功,它将返回一个如下的 JSON 对象:

{
  "dataset_id": "1",
  "name": "dataset-1",
  "dataset_type": "TEXT_INTENT",
  "last_updated_at": "2021-10-09T23:44:00",
  "version_hash": "hashDg==",     ❶
  "commits": [
    {                             ❷
      "commit_id": "1",           ❷
      .. .. ..                    ❷
    },                            ❷
    {                             ❷
      "commit_id": "2",           ❷
      .. .. ..                    ❷
    }                             ❷
  ]
}

❶ 训练数据集快照的 ID

❷ 原始数据集的选定数据提交

PrepareTrainingDataset API 返回的数据中包含 "version_hash" 字符串。它用于识别该 API 生成的数据快照。使用这个哈希作为 ID,我们可以调用 FetchTrainingDatasetc API 来跟踪训练数据集建立的进度;请参考以下示例。

清单 2.5 检查数据集准备进度

grpcurl -plaintext \
 -d "{"dataset_id": "1", \
      "version_hash":          ❶
      "hashDg=="}" \           ❶
${DM_SERVER}:${DM_PORT} 
data_management.DataManagementService/FetchTrainingDataset

❶ 训练数据集快照的 ID

FetchTrainingDatasetc API 返回一个描述训练数据集的 JSON 对象。它告诉我们背景数据集构建过程的状态:RUNNINGREADYFAILED。如果训练数据已准备好供使用,响应对象将显示训练数据的可下载 URL 列表。在这个演示中,URL 指向本地的 MinIO 服务器。请参考以下的示例响应:

{
  "dataset_id": "1",
  "version_hash": "hashDg==",
  "state": "READY",                                          ❶
  "parts": [
    {                                                        ❷
      "name": "examples.csv",                                ❷
      "bucket": "mini-automl-dm",                            ❷
      "path": "versionedDatasets/1/hashDg==/examples.csv"    ❷
    },                                                       ❷
    {                                                        ❷
      "name": "labels.csv",                                  ❷
      "bucket": "mini-automl-dm",                            ❷
      "path": "versionedDatasets/1/hashDg==/labels.csv"      ❷
    }                                                        ❷
  ],                                                         ❷
  "statistics": {
    "numExamples": "16200",
    "numLabels": "151"
  }
}

❶ 训练数据集的状态

❷ 训练数据的数据 URL

做得好!你刚刚体验了我们示例数据集管理服务提供的所有主要数据 API。通过尝试自己上传数据和构建训练数据集,我们希望你能感受到这项服务的用途。在接下来的几个部分,我们将查看用户场景、服务架构概览以及我们的示例数据集管理服务的代码实现。

如果在运行上述脚本时遇到任何问题,请参考我们 GitHub 仓库中“function demo”文档中的说明。另外,如果您想尝试第三章和第四章的实验,请保持容器运行,因为它们是模型训练实验的先决条件。

2.2.2 用户、用户场景和整体情况

在设计后端服务时,我们发现非常有用的方法是从外部到内部思考。首先,弄清楚用户是谁,服务将提供什么价值,以及客户将如何与服务进行交互。然后,内部逻辑和存储布局应该自然而然地出现。对于浏览此示例 DM 服务,我们将使用相同的方法展示给您。因此,让我们首先看看我们的用户和用户场景。

注意我们之所以首先考虑用例,是因为我们认为任何系统设计都应该最大程度地考虑用户。如果我们确定了客户如何使用系统,那么我们的效率和可扩展性的方法将自然而然地出现。如果设计是以相反的顺序进行的(首先考虑技术,其次考虑可用性),那么系统通常很难使用,因为它是为技术而设计的,而不是为客户设计的。

用户和用户场景

我们的示例 DM 服务是为两个虚构用户而构建的:建国,一名数据工程师,和朱莉娅,一名数据科学家。他们共同合作训练语言意图分类模型。

建国负责训练数据收集。他不断地从不同的数据源(如解析用户活动日志和进行客户调查)中收集数据并对其进行标记。建国使用 DM 数据摄取 API 创建数据集,将新数据附加到现有数据集中,并查询数据集的摘要和状态。

朱莉娅使用建国构建的数据集来训练意图分类模型(通常使用 PyTorch 或 TensorFlow 编写)。在训练时,朱莉娅的训练代码将首先调用 DM 服务的获取训练数据 API 从 DM 获取训练数据集,然后开始训练过程。

服务的整体架构

我们的示例 DM 服务分为三层:数据摄取层、数据集获取层和数据集内部存储层。数据摄取 API 集是为了让建国能够上传新的训练数据并查询数据集状态而构建的。数据集获取 API 是为了让朱莉娅能够获取训练数据集。有关整体情况,请参见图 2.6 和图 2.7。

图 2.6 示例数据集管理服务的系统概述。该示例服务包含三个主要组件,即数据摄取 API、内部存储和数据集获取 API。

图 2.6 的中心大框显示了我们样本数据集管理服务的整体设计。它有一个内部数据集存储系统和两个面向公众的接口:一个用于数据摄入的数据摄入 API 和一个用于数据集获取的数据集获取 API——一个用于数据摄入,另一个用于数据集获取。该系统支持强类型架构数据集(文本和图像类型)和非架构数据集(GENERIC类型)。

图 2.7 用于存储数据集的内部存储结构

图 2.7 显示了样本 DM 服务用于存储数据集的整体数据结构。提交是由数据摄入 API 创建的,版本化的快照是由数据获取 API 创建的。提交和版本化快照的概念被引入以应对数据集的动态和静态特性。我们将在第 2.2.5 节详细讨论存储。

在接下来的小节中,我们将逐个详细介绍前两个图表中的每个细节,从 API 开始,然后转向内部存储和数据架构。

2.2.3 数据摄入 API

数据摄入 API 允许在样本数据集管理服务中创建、更新和查询数据集。图 2.8 中的灰色框显示了数据摄入层中支持将数据摄入到 DM 中的四种服务方法的定义。它们的名称不言自明;让我们在列表 2.6 中查看它们的 gRPC 方法定义。

图 2.8 支持数据摄入的四种方法:创建数据集、更新数据集、获取数据集摘要和列出数据集

注意:为了减少样板代码,我们选择了 gRPC 来实现样本 DM 服务的公共接口。这并不意味着 gRPC 是数据集管理服务的最佳方法,但与 RESTful 接口相比,gRPC 的简洁编码风格非常适合演示我们的想法,而不会让您接触到不必要的 Spring 框架细节。

数据摄入方法定义

让我们看看我们的样本数据摄入 API 是什么样子的。

列表 2.6 数据摄入 API 服务定义

# create a new dataset and save data into it
rpc CreateDataset (CreateDatasetRequest) returns (DatasetSummary);

# add new data to an existing dataset 
rpc UpdateDataset (CreateCommitRequest) returns (DatasetSummary);

# get summary and history of a given dataset
rpc GetDatasetSummary (DatasetPointer) returns (DatasetSummary);

# list all existing datasets’ summary
rpc ListDatasets (ListQueryOptions) returns (stream DatasetSummary);

message CreateDatasetRequest {
 string name = 1;
 string description = 2;
 DatasetType dataset_type = 3;   ❶
 string bucket = 4;              ❷
 string path = 5;                ❷
 repeated Tag tags = 6;          ❸
}

❶ 定义数据集类型,为"TEXT_INTENT"或"GENERIC"

❷ 定义了在 MinIO 服务器中上传数据的文件 URL。

❸ 通过使用标签设置数据过滤器。

注意:本示例服务不涵盖数据删除和修改的主题,但该服务可以轻松扩展以支持这些功能。

数据 URL 与数据流

在我们的 API 设计中,您可能会注意到我们要求用户提供数据 URL 作为原始数据输入,而不是直接将文件上传到我们的服务。在第 2.2.4 节中,我们还选择将数据 URL 作为训练数据集返回,而不是通过流式传输端点直接返回文件。主要原因是我们希望将文件传输工作卸载到云对象存储服务(如 Amazon S3 或 Azure Blob)。这样做有两个好处:首先,它节省了网络带宽,因为客户端和服务之间没有实际的文件传递;其次,它降低了代码复杂性,因为在文件较大且 API 使用量较高时,保持数据流工作的高可用性可能会更加复杂。

创建新的数据集

让我们来看看 gRPC CreateDataset 方法是如何实现的。在调用 DM(createDataset API)创建数据集之前,用户(Jianguo)需要准备一个可下载的 URL,用于上传他们想要的数据(步骤 1 和 2);这个 URL 可以是云对象存储服务中的可下载链接,如 Amazon S3 或 Azure Blob。在我们的示例服务中,我们使用 MinIO 服务器在本地模拟 Amazon S3。Jianguo 还可以在数据集创建请求中命名数据集并分配标签。清单 2.7 突出显示了代码(dataManagement/DataManagementService .java)的关键部分,它实现了图 2.9 中所示的工作流程。

清单 2.7 是新数据集创建的实现

public void createDataset(CreateDatasetRequest request) {

  int datasetId = store.datasetIdSeed.incrementAndGet();      ❶

  Dataset dataset = new Dataset(                              ❷
    datasetId, request.getName(),                             ❷
    request.getDescription(),                                 ❷
    request.getDatasetType());                                ❷
  int commitId = dataset.getNextCommitId();                   ❷

  CommitInfo.Builder builder = DatasetIngestion               ❸
    .ingest(minioClient, datasetId, commitId,                 ❸
    request.getDatasetType(), request.getBucket(),            ❸
    request.getPath(), config.minioBucketName);               ❸

  store.datasets.put(Integer.toString(datasetId), dataset);   ❹
  dataset.commits.put(commitId, builder                       ❹
    .setCommitMessage("Initial commit")                       ❹
    .addAllTags(request.getTagsList()).build());              ❹

  responseObserver.onNext(dataset.toDatasetSummary());        ❺
  responseObserver.onCompleted();                             ❺
}

❶接收数据集创建请求(步骤 3)

❷使用用户请求中的元数据创建数据集对象(步骤 4a)

❸从 URL 下载数据并将其上传到 DM 的云存储(步骤 4b)

❹将具有下载数据的数据集保存为初始提交(步骤 5)

❺将数据集摘要返回给客户端(步骤 6 和 7)

图 2.9 是创建新数据集的七个步骤的高级概述:(1)将数据上传到云对象存储;(2)获取数据链接;(3)调用createDataset API,并将数据链接作为有效负载;(4)DM 首先从数据链接下载数据,然后找到正确的数据集转换器(IntentTextTransformer)来进行数据解析和转换;(5)DM 保存转换后的数据;(6 和 7)DM 将数据集摘要(ID,提交历史,数据统计)返回给用户。

DatasetIngestion.ingest()的具体实现细节将在第 2.2.5 节中讨论。

更新现有数据集

深度学习模型的开发是一个持续的过程。一旦我们为模型训练项目创建了一个数据集,数据工程师(比如 Jianguo)将继续向其中添加数据。为了满足这个需求,我们提供了UpdateDataset API。

要使用UpdateDataset API,我们需要为新数据准备一个数据 URL。我们还可以传递提交消息和一些客户标签来描述数据的更改;这些元数据对于数据历史记录和数据过滤非常有用。

数据集更新工作流程与数据集创建工作流程几乎相同(图 2.9)。它使用给定数据创建一个新的提交,并将提交附加到数据集的提交列表中。唯一的区别是数据集更新工作流程不会创建新的数据集,而是在现有数据集上工作。请参阅以下代码清单。

注意 每次数据集更新都保存为提交,如果建国错误地将一些标记错误的数据上传到数据集中,我们可以很容易地使用一些数据集管理 API 删除或软删除这些提交。由于空间限制,这些管理 API 没有讨论。

列表 2.8 数据集更新实现

public void updateDataset(CreateCommitRequest request) {

  String datasetId = request.getDatasetId();   ❶

  Dataset dataset = store.datasets             ❷
    .get(datasetId);                           ❷
  String commitId = Integer.toString(dataset   ❷
    .getNextCommitId());                       ❷

 // the rest code are the same as listing 2.7
  .. .. .. 
}

❶ 接收数据集创建请求(步骤 3)

❷ 查找现有数据集并创建新的提交对象(步骤 4a)

在第 2.2.3 节,我们将更多地讨论提交的概念。目前,你只需知道每个数据集更新请求都会创建一个新的提交对象。

注意 为什么要将数据更新保存在提交中?我们能否将新数据与当前数据合并,以便只存储最新状态?在我们的更新数据集实现中,每次调用UpdateDataset API 时,我们都会创建一个新的提交。我们要避免就地数据合并有两个原因:首先,就地数据合并可能导致不可逆转的数据修改和悄悄的数据丢失。其次,为了重现过去使用的训练数据集,我们需要确保 DM 接收的数据批次是不可变的,因为它们是我们随时用来创建训练数据集的源数据。

列出数据集并获取数据集摘要

除了CreateDatasetUpdateDataset API 外,我们的用户还需要方法来列出现有数据集并查询数据集的概述,例如数据集的示例数和标签数以及其审计历史记录。为满足这些需求,我们构建了两个 API:ListDatasetsGetDatasetSummary。第一个 API 可以列出所有现有的数据集,而第二个 API 提供了有关数据集的详细信息,例如提交历史记录、示例和标签计数以及数据集 ID 和类型。这两个 API 的实现很简单;你可以在我们的 Git 仓库中找到它们(miniAutoML/DataManagementService.java)

2.2.4 训练数据集获取 API

在本节中,我们将看一下数据集获取层,它在图 2.10 中被标记为灰色方框。为了构建训练数据,我们设计了两个 API。数据科学家(朱莉娅)首先调用PrepareTrainingDataset API 发出训练数据准备请求;我们的 DM 服务将启动一个后台线程来开始构建训练数据,并返回一个版本字符串作为训练数据的参考句柄。接下来,朱莉娅可以调用FetchTrainingDataset API 来获取训练数据,如果后台线程已完成。

图 2.10 数据集获取层中支持数据集获取的两种方法:PrepareTrainingDatasetFetchTrainingDataset

数据集获取方法的定义

首先,让我们看一下 gRPC 服务方法定义(grpc-contract/src/main/proto/ data_management.proto)中的两个数据集获取方法——PrepareTrainingDatasetFetchTrainingDataset

列表 2.9 训练数据集获取服务定义

rpc PrepareTrainingDataset (DatasetQuery)     ❶
  returns (DatasetVersionHash);               ❶

rpc FetchTrainingDataset (VersionHashQuery)   ❷
  returns (VersionHashDataset);               ❷

message DatasetQuery {                        ❸
 string dataset_id = 1;                       ❹
 string commit_id = 2;                        ❺
 repeated Tag tags = 3;                       ❻
}

message VersionHashQuery {                    ❼
 string dataset_id = 1; 
 string version_hash = 2;                     ❽
}

❶ 准备训练数据集 API

❷ 获取训练数据集 API

❸ 数据集准备 API 的有效载荷

❹ 指定要构建训练数据的数据集

❺ 指定要构建训练数据的数据集的提交,可选

❻ 按提交标签过滤数据,可选

❼ 训练数据集获取 API 的有效载荷

❽ 版本哈希字符串代表训练数据集快照。

为什么我们需要两个 API(两个步骤)来获取数据集

如果我们只发布一个用于获取训练数据的 API,则调用者需要在后端数据准备完成后等待 API 调用,以获取最终的训练数据。如果数据准备需要很长时间,则此请求将超时。

深度学习数据集通常很大(以 GB 为单位);进行网络 I/O 数据传输和本地数据聚合可能需要几分钟或几小时。因此,获取大型数据的常见解决方案是提供两个 API——一个用于提交数据准备请求,另一个用于查询数据状态,并在请求完成时拉取结果。通过这种方式,数据集获取 API 的性能始终如一,无论数据集的大小如何。

发送准备训练数据集请求

现在让我们看一下 PrepareTrainingDataset API 的代码工作流程。图 2.11 显示了我们的示例服务如何处理 Julia 的准备训练数据集请求。

图 2.11 对应数据集构建请求的八个步骤的高层概述:(1)用户提交具有数据过滤器的数据集准备请求;(2)DM 从满足数据过滤器的提交中选择数据;(3 和 4)DM 生成表示训练数据的版本字符串;以及(5-8)DM 启动后台作业以生成训练数据。

当 DM 收到数据集准备请求(图 2.11,步骤 1)时,它执行三个动作:

  • 尝试使用给定的数据集 ID 在其存储中查找数据集。

  • 将给定的数据过滤器应用于从数据集中选择提交。

  • 创建 versionedSnapshot 对象以跟踪内部存储中的训练数据(versionHashRegistry)。versionedSnapshot 对象的 ID 是从所选提交的 ID 列表生成的哈希字符串。

versionedSnapshot 对象是 Julia 想要的训练数据集;它是从所选提交中的不可变静态文件组成的。在步骤 3 返回哈希字符串(快照 ID)后,Julia 可以使用它来查询数据集准备状态,并在训练数据集准备好时获取数据可下载的 URL。使用此版本字符串,Julia 可以始终从将来的任何时间获取相同的训练数据(versionedSnapshot),这就是支持数据集可重现性的方式。

versionedSnapshot的一个副产品是它可以在不同的PrepareTrainingDataset API 调用之间用作缓存。如果快照 ID(一系列提交的哈希字符串)已经存在,我们将返回现有的versionedSnapshot而不重建相同的数据,这可以节省计算时间和网络带宽。

注意 在我们的设计中,数据过滤发生在提交级别,而不是在个别示例级别;例如,在准备请求中使用过滤标记"DataType=Training"表明用户只希望从标记为"DataType=Training"的提交中获取数据。

第 3 步之后,DM 将生成一个后台线程来构建训练数据集。在后台作业中,DM 将从 MinIO 服务器下载每个数据集提交的文件到本地,将其聚合并压缩成一个预定义格式的文件,并将其上传回 MinIO 服务器的不同存储桶中(步骤 6 和 7)。接下来,DM 将在versionedSnapshot对象中放置实际训练数据的数据 URL,并将其状态更新为"READY"(步骤 8)。现在 Julia 可以从返回的versionedSnapshot对象中找到数据 URL 并开始下载训练数据。

我们还没有涉及的是数据模式。在数据集管理服务中,我们将摄取的数据(commit)和生成的训练数据(versionedSnapshot)保存在两种不同的数据格式中。数据合并操作(图 2.11,步骤 6 和 7)将原始摄取的数据(所选提交)聚合并将其转换为意图分类训练数据模式中的训练数据。我们将在 2.2.6 节详细讨论数据模式。列表 2.10 突出显示了图 2.11 中实现的代码。

列表 2.10 准备训练数据请求 API

public void prepareTrainingDataset(DatasetQuery request) {
 # step 1, receive dataset preparation request
  Dataset dataset = store.datasets.get(datasetId);
  String commitId;
  .. .. ..
  # step 2, select data commits by checking tag filter
  BitSet pickedCommits = new BitSet();
  List<DatasetPart> parts = Lists.newArrayList();
  List<CommitInfo> commitInfoList = Lists.newLinkedList();
  for (int i = 1; i <= Integer.parseInt(commitId); i++) {
    CommitInfo commit = dataset.commits.get(Integer.toString(i));
    boolean matched = true;
    for (Tag tag : request.getTagsList()) {
      matched &= commit.getTagsList().stream().anyMatch(k -> k.equals(tag));
    }
    if (!matched) {
      continue;
    }
    pickedCommits.set(i);
    commitInfoList.add(commit);
    .. .. ..
  }

 # step 3, generate version hash from the selected commits list
  String versionHash = String.format("hash%s", 
    Base64.getEncoder().encodeToString(pickedCommits.toByteArray()));

  if (!dataset.versionHashRegistry.containsKey(versionHash)) {
    dataset.versionHashRegistry.put(versionHash,      ❶
      VersionedSnapshot.newBuilder()                  ❶
        .setDatasetId(datasetId)                      ❶
        .setVersionHash(versionHash)                  ❶
        .setState(SnapshotState.RUNNING).build());    ❶

 # step 5,6,7,8, start a background thread to aggregate data 
 # from commits to the training dataset     
    threadPool.submit(
      new DatasetCompressor(minioClient, store, datasetId,
        dataset.getDatasetType(), parts, versionHash, config.minioBucketName));
   }

 # step 4, return hash version string to customer
  responseObserver.onNext(responseBuilder.build());
  responseObserver.onCompleted();
}

❶ 创建 VersionedSnapshot 对象以表示训练数据集

获取训练数据集

一旦 DM 服务收到了prepareTrainingDataset API 上的训练数据准备请求,它将生成一个后台作业来构建训练数据,并返回一个version_hash字符串用于跟踪目的。Julia 可以使用FetchTrainingDataset API 和version_hash字符串来查询数据集构建进度,并最终获取训练数据。图 2.12 展示了 DM 中如何处理数据集获取请求。

图 2.12 数据集获取请求服务的三个步骤的高级概述:(1)用户使用数据集 ID 和版本字符串调用FetchTrainingDataset API;(2)DM 将在其内部存储中搜索数据集的versionHashRegistry并返回一个versionedSnapshot对象;(3)当数据准备作业完成时,versionedSnapshot对象将具有一个下载 URL。

获取训练数据集本质上是查询训练数据准备请求的状态。对于每个数据集,DM 服务都会创建一个versionedSnapshot对象来跟踪prepareTrainingDataset请求生成的每个训练数据集。

当用户发送获取数据集查询时,我们只需在请求中使用哈希字符串来搜索其对应的versionedSnapshot对象在数据集的训练快照(versionHashRegistry)中是否存在,如果存在则将其返回给用户。versionedSnapshot对象将由后台训练数据处理作业(图 2.11,步骤 5–8)不断更新。当作业完成时,它将训练数据 URL 写入versionedSnapshot对象;因此,用户最终获取训练数据。请参见以下清单中的代码实现。

清单 2.11 准备训练数据请求 API

public void fetchTrainingDataset(VersionQuery request) {
  String datasetId = request.getDatasetId(); 
  Dataset dataset = store.datasets.get(datasetId); 

  if (dataset.versionHashRegistry.containsKey(   ❶
      request.getVersionHash())) {               ❶

    responseObserver.onNext(

      dataset.versionHashRegistry.get(           ❷
 request.getVersionHash()));                ❷
    responseObserver.onCompleted();
  } 
  .. .. .. 
}

❶ 在数据集的训练快照中搜索versionedSnapshot

❷ 返回versionedSnapshot;其中包含了数据集准备的最新进展。

2.2.5 内部数据集存储

示例服务的内部存储仅是一组内存中的数据集对象。之前我们讨论了数据集既可以是动态的又可以是静态的。一方面,数据集是一个逻辑文件组,随着不断地从各种来源吸收新数据而动态变化。另一方面,它是静态的且可重现的用于训练。

为了展示这个概念,我们设计了每个数据集,其中包含提交列表和版本化快照列表。提交代表动态摄入的数据:通过数据摄入调用(CreateDatasetUpdateDataset)添加的数据;提交还具有标签和注释目的的消息。版本化快照代表静态训练数据,由准备训练数据集请求(PrepareTrainingDataset)产生,从所选提交列表转换而来。每个快照都与一个版本相关联;一旦构建了训练数据集,您可以使用该版本字符串随时获取相应的训练数据(FetchTrainingDataset)以供重用。图 2.13 可视化了数据集的内部存储结构。

图 2.13 内部数据集存储概述。数据集存储两种类型的数据:用于摄入原始数据的提交和用于训练数据集的版本快照。数据集元数据和数据 URL 存储在数据集管理服务中,实际数据存储在云对象存储服务中。

注意虽然不同类型的数据集的各个训练示例可以采用不同的形式,例如图像、音频和文本句子,但数据集的操作(创建、更新和查询数据集摘要)以及其动态/静态特性是相同的。由于我们在所有数据集类型上设计了统一的 API 集,我们可以使用统一的存储结构来存储所有不同类型的数据集。

在我们的存储中,实际文件(提交数据、快照数据)存储在云对象存储(如 Amazon S3)中,我们只在我们的 DM 系统中保留数据集元数据(稍后解释)。通过卸载文件存储工作并仅跟踪文件链接,我们可以专注于组织数据集并跟踪其元数据,例如编辑历史、数据统计、训练快照和所有权。

数据集元数据

我们将数据集元数据定义为除了实际数据文件以外的所有内容,例如数据集 ID、数据所有者、变更历史(审计)、训练快照、提交、数据统计等等。

为了演示目的,我们将数据集的元数据存储在一个内存字典中,以 ID 作为键,并将所有数据文件放入 MinIO 服务器。但您可以扩展它以使用数据库或 NoSQL 数据库来存储数据集的元数据。

到目前为止,我们已经讨论了数据集存储概念,但实际的数据集写入和读取是如何工作的呢?我们如何序列化不同数据集类型(例如GENERICTEXT_INTENT类型)的提交和快照?

在存储后端实现中,我们使用简单的继承概念来处理不同数据集类型的文件操作。我们定义了一个DatasetTransformer接口如下:ingest()函数将输入数据保存到内部存储作为提交,compress()函数将来自选定提交的数据合并为版本快照(训练数据)。

更具体地说,对于“TEXT_INTENT”类型的数据集,我们有IntentTextTransformer来应用文件转换的强类型模式。对于“GENERIC”类型的数据集,我们有GenericTransformer将数据保存在原始格式中,没有任何检查或格式转换。图 2.14 说明了这些。

图 2.14 实现DatasetTransformer接口来处理不同的数据集类型;实现 ingest 函数将原始输入数据保存为提交;实现 compress 函数将提交数据聚合为训练数据。

从图 2.14 可以看出,通过数据摄取 API(第 2.2.3 节)保存的原意图分类数据由IntentTextTransformer:Ingest()保存为提交;通过训练数据集提取 API(第 2.2.4 节)生成的意图分类训练数据由IntentTextTransformer:Compress()保存为版本化的快照。因为它们是纯 Java 代码,我们留给您自己去发现;您可以在我们的 Git 存储库中找到实现代码(org/orca3/miniAutoML/dataManagement/transformers/IntentTextTransformer.java)。

2.2.6 数据模式

到目前为止,我们已经看到了所有的 API、工作流程和内部存储结构。现在让我们来考虑 DM 服务中的数据是什么样子的。对于每一种强类型数据集,例如“TEXT_INTENT”数据集,我们定义了两种数据模式:一种用于数据摄取,一种用于训练数据提取(图 2.15)。

图 2.15 每一种类型的数据集都有两个数据模式:摄取数据模式和训练数据模式。这两个模式将确保我们接受的数据和我们生成的数据都遵循我们的数据规范。

图 2.15 显示了 DM 服务如何使用两个数据模式来实现其数据合同。步骤 1 使用摄取数据模式验证原始输入数据;步骤 2 使用训练数据模式将原始数据转换为训练数据格式;步骤 3 将转换后的数据保存为一个提交;步骤 4 在构建训练数据集时将选定的提交合并为一个版本化的快照,但仍然遵循训练数据模式。

这两个不同的数据模式是 DM 服务提供给我们两个不同用户(Jianguo 和 Julia)的数据合同。无论 Jianguo 如何收集数据,它都需要转换为摄入数据格式以插入到 DM 中。或者,由于 DM 保证输出的训练数据遵循训练数据模式,Julia 可以放心地使用数据集,而不用担心 Jianguo 所做的数据收集更改会影响到她。

一个数据摄入模式

我们已经了解了数据模式的概念,现在让我们来看看我们为TEXT_INTENT数据集定义的摄入数据模式:

>> TEXT_INTENT dataset ingestion data schema
<text utterance>, <label>,<label>,<label>, ...

为了简化起见,我们的数据摄入模式要求TEXT_INTENT数据集的所有输入数据必须以 CSV 文件格式提供。第一列是文本话语,其余列是标签。请参考以下示例 CSV 文件:

“I am still waiting on my credit card”, activate_my_card      ❶
➥ ;card_arrival                                              ❶
“I couldn’t purchase gas in Costco”, card_not_working

❶ 标签

训练数据集的模式

对于TEXT_INTENT训练数据,我们的模式将输出数据定义为一个包含两个文件的压缩文件:examples.csv 和 labels.csv。labels.csv 定义了标签名称到标签 ID 的映射,而 examples.csv 定义了训练文本(话语)到标签 ID 的映射。请参考以下示例:

examples.csv: <text utterance>, <label_id>,<label_id>, ...
“I am still waiting on my credit card”, 0;1
“I couldn’t purchase gas in Costco”, 2

Labels.csv: <label_id>, <label_name>
0, activate_my_card
1, card_arrival
2, card_not_working

为什么我们使用自定义的数据结构

我们使用自定义的数据模式来构建TEXT_INTENT,而不是使用 PyTorch 或 Tensorflow 数据集格式(如 TFRecordDataset)来创建与模型训练框架的抽象。

选择一个特定于框架的数据集格式,你的训练代码也需要用该框架编写,这并不理想。引入自定义的中间数据集格式可以使 DM 框架中立,因此不需要特定于框架的训练代码。

一个数据集中有两个强类型的数据模式的好处

通过在数据集中使用两个强类型的数据模式,并且让 DM 将数据从摄取的数据格式转换为训练数据格式,我们可以并行开发数据收集和训练代码开发。例如,当 Jianguo 想要向TEXT_INTENT数据集添加一个新特征——“文本语言”时,他可以与 DM 服务开发人员合作更新数据摄入模式以添加一个新的数据字段。

Julia 不会受到影响,因为训练数据模式没有改变。当 Julia 有带宽来消费她训练代码中的新功能时,她可能会后来向我们更新训练数据模式。关键是,Jianguo 和 Julia 不必同步工作来引入新的数据集增强;他们可以独立工作。

注意:为了简单起见和演示目的,我们选择使用 CSV 文件来存储数据。使用纯 CSV 文件的问题在于它们缺乏向后兼容性支持和数据类型验证支持。在生产环境中,我们建议使用 Parquet、Google protobuf 或 Avro 来定义数据模式和存储数据。它们带有一组用于数据验证、数据序列化和模式向后兼容性支持的库。

通用数据集:没有模式的数据集

尽管我们在多个地方强调定义强类型数据集模式对数据集管理服务是基础性的,但在这里我们将例外情况添加了一个自由格式的数据集类型——GENERIC数据集。与强类型 TEXT_ INENT 数据集不同,GENERIC类型数据集没有数据模式验证。我们的服务将任何原始输入数据保存原样,并在构建训练数据时,服务只是将所有原始数据按其原始格式打包到训练数据集中。

一个GENERIC数据集类型听起来可能不是一个好主意,因为我们基本上将来自上游数据源的任何数据传递给下游训练应用程序,这很容易破坏训练代码中的数据解析逻辑。这绝对不是一个生产选项,但它为实验项目提供了所需的灵活性。

尽管强类型数据模式提供了良好的数据类型安全保护,但需要付出维护的代价。当您不得不在 DM 服务中频繁进行模式更改以采用新的实验所需的新数据格式时,这是相当烦人的。

在深度学习项目开始时,有很多事情是不确定的,比如哪种深度学习算法效果最好,我们可以收集到什么样的数据,以及我们应该选择什么样的数据模式。为了解决所有这些不确定性,我们需要一种灵活的方式来处理任意数据,以启用模型训练实验。这就是GENERIC数据集类型设计的目的。

一旦业务价值得到证明,并选择了深度学习算法,我们现在清楚了训练数据的样子;然后是时候在数据集管理服务中定义一个强类型数据集了。在接下来的部分中,我们将讨论如何添加一个新的强类型数据集。

2.2.7 添加新的数据集类型(IMAGE_CLASS)

让我们假设有一天,朱莉娅(平台开发者之一)要求我们将她的实验性图像分类项目提升为正式项目。朱莉娅和她的团队正在使用 GENERIC 数据集开发图像分类模型,并且因为他们取得了良好的结果,现在他们想要定义一个强类型数据集(IMAGE_CLASS)来稳定原始数据收集和训练数据消费的数据模式。这将保护训练代码免受未来数据集更新的影响。

要添加一个新的数据集类型——IMAGE_CLASS,我们可以按照三个步骤进行。首先,我们必须定义训练数据格式。在与朱莉娅讨论后,我们决定由 FetchTrainingDataset API 生成的训练数据将是一个 zip 文件;它将包含以下三个文件:

>> examples.csv: <image filename>,<label id>
“imageA.jpg”, 0
“imageB.jpg”, 1
“imageC.jpg”, 0

>> labels.csv: <label id>,<label name>
0, cat
1, dog

>> examples/ - folder
imageA.jpg
imageB.jpg
imageC.jpg

examples.csv 和 labels.csv 文件是定义每个训练图像标签的清单文件。实际图像文件存储在 examples 文件夹中。

其次,定义摄取数据格式。我们需要与收集图像并为其标记标签的数据工程师建国讨论摄取数据架构。我们一致同意,每个 CreateDatasetUpdateDataset 请求的有效负载数据也是一个 zip 文件;其目录如下所示:zip 文件应该是只包含子目录的文件夹。根文件夹下的每个子目录代表一个标签;其下的图像属于此标签。子目录应只包含图像,而不包含任何嵌套目录:

├── cat
│   ├── catA.jpg
│   ├── catB.jpg
│   └── catC.jpg
└── dog
    ├── dogA.jpg
    ├── dogB.jpg
    └── dogC.jpg

最后一步是代码更改。在心中有两种数据模式之后,我们需要创建一个实现了 DatasetTransformer 接口的 ImageClassTransformer 类来构建数据读取和写入逻辑。

我们首先实现 ImageClassTransformer.ingest() 函数。逻辑需要使用第 2 步中定义的输入数据格式来解析数据集创建和更新请求中的输入数据,然后将输入数据转换为训练数据格式并将其保存为数据集的提交。

然后,我们实现 ImageClassTransformer.compress() 函数,它首先通过匹配数据过滤器选择提交,然后将匹配的提交合并为单个训练快照。最后一步,我们将 ImageClassTransformer.ingest() 函数注册到 DatasetIngestion.ingestion() 函数中,类型为 IMAGE_CLASS,并将 ImageClassTransformer.compress() 注册到 DatasetCompressor.run() 函数中,类型为 IMAGE_CLASS

如你所见,通过合适的数据集结构,我们只需添加几个新的代码片段就能支持新的数据集类型。现有的数据集类型和公共数据摄取及获取 API 不会受到影响。

2.2.8 服务设计总结

让我们回顾一下这个示例数据集管理服务是如何满足第 2.1.2 节介绍的五项设计原则的:

  • 原则 1—支持数据集可重现性。我们的示例 DM 服务将所有生成的训练数据保存为带有版本哈希字符串的版本化快照,用户可以随时应用该版本字符串来获取训练数据快照。

  • 原则 2—为不同的数据集类型提供统一的体验。数据摄取 API 和训练数据获取 API 对所有数据集类型和大小的工作方式相同。

  • 原则 3—采用强类型数据架构。我们的示例 TEXT_INENT 类型和 IMAGE_CLASS 类型数据集对原始摄取数据和训练数据都应用自定义数据架构。

  • 原则 4—确保 API 一致性并在内部处理扩展。尽管我们在示例代码中将所有数据集的元数据保存在内存中(为了简单起见),但我们可以轻松地在云对象存储中实现数据集存储结构;理论上,它具有无限的容量。此外,我们要求数据 URL 用于发送数据和返回数据,因此无论数据集有多大,我们的 API 都保持一致。

  • 原则 5—保证数据持久性。每个数据集创建请求和更新请求都会创建一个新的提交;每个训练数据准备请求都会创建一个版本化的快照。提交和快照都是不可变的,并且不受数据到期限制的持久存在。

注意 我们从示例数据集管理服务中删除了许多重要功能,以保持简单性。管理 API,例如允许您删除数据,还原数据提交以及查看数据审计历史记录。欢迎 fork 该存储库并尝试实现它们。

2.3 开源方法

如果您有兴趣采用开源方法来设置数据集管理功能,我们为您选择了两种方法:Delta Lake 和 Pachyderm。让我们分别来看看它们。

2.3.1 Delta Lake 和 Petastorm 与 Apache Spark 家族

在这种方法中,我们建议将数据保存在 Delta Lake 表中,并使用 Petastorm 库将表数据转换为 PyTorch 和 Tensorflow 数据集对象。数据集可以在训练代码中无缝消耗。

Delta Lake

Delta Lake 是一个存储层,为 Apache Spark 和其他云对象存储(例如 Amazon S3)带来可扩展的、ACID(原子性、一致性、隔离性、持久性)事务。Delta Lake 由 Databricks,一个备受尊敬的数据和人工智能公司,作为开源项目开发。

云存储服务,例如 Amazon S3,是 IT 行业中最具可扩展性和成本效益的存储系统之一。它们是构建大型数据仓库的理想场所,但其键值存储设计使得难以实现 ACID 事务和高性能。元数据操作(例如列出对象)昂贵,并且一致性保证有限。

Delta Lake 的设计旨在填补前面讨论的空白。它作为一个文件系统工作,将批处理和流处理数据存储在对象存储中(例如亚马逊 S3)。此外,Delta Lake 管理表结构和模式强制执行的元数据、缓存和索引。它提供了 ACID 属性、时间旅行和针对大型表格数据集的显著更快的元数据操作。请参见图 2.16 了解 Delta Lake 概念图。

图 2.16 Delta Lake 数据摄入和处理工作流程。流数据和批数据都可以保存为 Delta Lake 表,并且 Delta Lake 表存储在云对象存储中,例如亚马逊 S3。

Delta Lake 表是系统的核心概念。在使用 Delta Lake 时,您通常正在处理 Delta Lake 表。它们就像 SQL 表一样;您可以查询、插入、更新和合并表内容。Delta Lake 中的模式保护是其优势之一。它支持在表写入时对模式进行验证,从而防止数据污染。它还跟踪表历史,因此您可以将表回滚到其过去的任何阶段(称为时间旅行)。

对于构建数据处理管道,Delta Lake 建议将表命名为三个类别:铜(bronze)、银(silver)和金(gold)。首先,我们使用铜表存储来自不同来源的原始输入(其中一些可能不太干净)。然后,数据不断从铜表流向银表,经过数据清洗和转换(ETL)。最后,我们执行数据过滤和净化,并将结果保存到金表中。每个表都处于机器学习状态;它们是可重现的,并且类型安全。

为什么 Delta Lake 是深度学习数据集管理的良好选择

以下是使 Delta Lake 成为管理深度学习项目数据集的良好选择的三个功能。

首先,Delta Lake 支持数据集的可重现性。它具有“时间旅行”功能,可以使用数据版本控制查询数据在特定时间点的状态。想象一下,您已经设置了一个持续运行的 ETL 管道来保持您的训练数据集(gold table)的最新状态。因为 Delta Lake 将表更新跟踪为快照,每个操作都会被自动版本化,当管道写入数据集时。这意味着所有训练数据的快照都是免费的,您可以轻松浏览表更新历史并回滚到过去的阶段。以下列表提供了一些示例命令。

列表 2.12 Delta Lake 时间旅行命令

pathToTable = "/my/sample/text/intent/dataset/A"

deltaTable = DeltaTable.forPath(spark, pathToTable)    ❶
fullHistoryDF = deltaTable.history()                   ❷
lastOperationDF = deltaTable.history(1)                ❸

df = spark.read.format("delta")                        ❹
       .option("timestampAsOf", "2021-07-01")          ❹
       .load(pathToTable)                              ❹

df = spark.read.format("delta")                        ❺
      .option("versionAsOf", "12")                     ❺
      .load(pathToTable)                               ❺

❶ 在 Delta Lake 中查找数据集

❷ 列出数据的完整历史

❸ 获取数据集上的最后一个操作

❹ 根据时间戳回滚数据集

❺ 根据版本回滚数据集

其次,Delta Lake 支持持续的流式数据处理。它的表可以无缝处理来自历史和实时流数据源的连续数据流。例如,数据管道或流数据源可以在查询数据表的同时不断向 Delta Lake 表中添加数据。这样可以节省编写代码将新数据与现有数据合并的额外步骤。

其次,Delta Lake 提供模式强制执行和演化功能。它在写入时应用模式验证。它将确保新的数据记录与表的预定义模式匹配;如果新数据与表的模式不兼容,Delta Lake 将引发异常。在写入时进行数据类型验证要比在读取时进行更好,因为如果数据被污染,清理数据将变得困难。

除了强大的模式强制执行功能之外,Delta Lake 还允许您在现有数据表中添加新列而不会引起破坏性更改。对于深度学习项目来说,数据集模式强制执行和调整(演化)能力至关重要。这些功能可以保护训练数据免受意外数据写入污染,并提供安全的数据更新。

Petastorm

Petastorm 是 Uber ATG(高级技术组)开发的开源数据访问库。它可以直接从 Apache Parquet 格式的数据集中单机或分布式进行深度学习模型的训练和评估。(Apache Parquet 是一种为高效数据存储和检索而设计的数据文件格式。)

Petastorm 可以轻松地将 Delta Lake 表转换为 Tensorflow 和 PyTorch 格式的数据集,并且还支持分布式训练数据分区。使用 Petastorm,可以简单地从 Delta Lake 表中消耗训练数据,而不必担心特定训练框架的数据转换细节。它还在数据集格式和训练框架(Tensorflow、PyTorch 和 PySpark)之间创建了良好的隔离。图 2.17 可视化了数据转换过程。

图 2.17 Petastorm 将 Delta Lake 表转换为可以被 PyTorch 或 Tensorflow 框架读取的数据集。

图 2.17 描述了 Petastorm 的数据转换工作流。您可以创建一个 Petastorm spark 转换器,将 Delta Lake 表作为 Parquet 文件读取到其缓存中,并生成 Tensorflow 或 Pytorch 数据集。

例子:为花朵图像分类准备训练数据

现在我们对 Delta Lake 和 Petastorm 有了一个大致的了解,让我们看一个具体的模型训练示例。接下来的代码片段——代码列表 2.13 和 2.14——展示了一个端到端的图像分类模型训练工作流程的两个步骤。首先,它们定义了一个图像处理 ETL 管道,将一组图像文件解析为 Delta Lake 表作为图像数据集。然后,它们使用 Petastorm 将 Delta Lake 表转换为可以直接加载到 PyTorch 框架中进行模型训练的数据集。

让我们首先查看代码清单 2.13 中的四步 ETL 数据处理管道。您还可以在 mng.bz/JVPz 找到完整的代码。

在管道的开始步骤中,我们将图像从文件夹 flower_photos 加载到 Spark 中作为二进制文件。其次,我们定义提取函数以从每个图像文件中获取元数据,如标签名称、文件大小和图像大小。第三,我们使用提取函数构建数据处理管道,然后将图像文件传递给管道,管道将生成一个数据框。数据框的每一行代表一个图像文件及其元数据,包括文件内容、标签名称、图像大小和文件路径。在最后一步中,我们将此数据框保存为 Delta Lake 表—gold_table_training_dataset。您还可以在以下代码清单的末尾查看此 Delta Lake 表的数据模式。

清单 2.13 Delta Lake 中创建图像数据集的 ETL

## Step 1: load all raw images files
path_labeled_rawdata = “datacollablab/flower_photos/”

images = spark.read.format("binary")                     ❶
 .option("recursiveFileLookup", "true")                  ❶
 .option("pathGlobFilter", "*.jpg")                      ❶
 .load(path_labeled_rawdata)                             ❶
 .repartition(4)                                         ❶

## Step 2: define ETL extract functions
def extract_label(path_col):                             ❷
 """Extract label from file path using built-in SQL functions."""
 return regexp_extract(path_col, "flower_photos/([^/]+)", 1)

def extract_size(content):                               ❸
 """Extract image size from its raw content."""
 image = Image.open(io.BytesIO(content))
 return image.size 

@pandas_udf("width: int, height: int")
def extract_size_udf(content_series):                    ❸
 sizes = content_series.apply(extract_size)
 return pd.DataFrame(list(sizes))
## Step 3: construct and execute ETL to generate a data frame 
## contains label, image, image size and path for each image. 
df = images.select(
 col("path"),
 extract_size_udf(col("content")).alias("size"),
 extract_label(col("path")).alias("label"),
 col("content"))

## Step 4: save the image dataframe produced 
# by ETL to a Delta Lake table
gold_table_training_dataset = “datacollablab.flower_train_binary”
spark.conf.set("spark.sql.parquet.compression.codec", "uncompressed")
df.write.format(“delta”).mode(“overwrite”)
  .saveAsTable(gold_table_training_dataset)

>>> 
ColumnName: path: string                                ❹
ColumnName: label: string                               ❹
ColumnName: labelIndex: bigint                          ❹
ColumnName: size: struct<width:int, length:int>         ❹
ColumnName: content: binary                             ❹

❶ 将图像作为 binaryFile 读取

❷ 从图像的子目录名称提取标签

❸ 提取图像尺寸

❹ Delta Lake 表—gold_table_training_dataset 的数据模式

注意:演示中使用的原始数据是 TensorFlow 团队的 flowers 数据集。它包含存储在五个子目录下的花朵照片,每个子目录对应一个类别。子目录名称是其中包含的图像的标签名称。

现在我们在 Delta Lake 表中构建了一个图像数据集,我们可以开始使用 Petastorm 的帮助来训练一个 PyTorch 模型。在代码清单 2.14 中,我们首先读取由代码清单 2.13 中定义的 ETL 管道生成的 Delta Lake 表 gold_table_training_dataset,然后将数据分成两个数据框架:一个用于训练,一个用于验证。接下来,我们将这两个数据框加载到两个 Petastorm Spark 转换器中;数据将在转换器内转换为 Parquet 文件。最后,我们使用 Petastorm API make_torch_dataloader 从 PyTorch 中读取训练示例以进行模型训练。请参阅以下代码以了解整个三步过程。您还可以在以下链接找到完整的示例代码:mng.bz/wy4B

清单 2.14 使用 Petastorm 在 PyTorch 中消耗 Delta Lake 图像数据集

## Step 1: Read dataframe from Delta Lake table
df = spark.read.format("delta")
  .load(gold_table_training_dataset) 
 .select(col("content"), col("label_index")) 
 .limit(100)
num_classes = df.select("label_index").distinct().count()

df_train, df_val = df                                         ❶
  .randomSplit([0.9, 0.1], seed=12345)                        ❶

## (2) Load dataframes into Petastorm converter 
spark.conf.set(SparkDatasetConverter.PARENT_CACHE_DIR_URL_CONF,  
  "file:///dbfs/tmp/petastorm/cache")
converter_train = make_spark_converter(df_train)
converter_val = make_spark_converter(df_val)

## (3) Read training data in PyTorch by using 
## Petastorm converter
def train_and_evaluate(lr=0.001):
 device = torch.device("cuda")
  model = get_model(lr=lr)
    .. .. .. 

  with converter_train.make_torch_dataloader(                 ❷
         transform_spec=get_transform_spec(is_train=True),
         batch_size=BATCH_SIZE) as train_dataloader,
 converter_val.make_torch_dataloader(                   ❷
         transform_spec=get_transform_spec(is_train=False),
         batch_size=BATCH_SIZE) as val_dataloader:

 train_dataloader_iter = iter(train_dataloader)
   steps_per_epoch = len(converter_train) // BATCH_SIZE

 val_dataloader_iter = iter(val_dataloader)
   validation_steps = max(1, len(converter_val) // BATCH_SIZE)

   for epoch in range(NUM_EPOCHS):
     .. .. 
     train_loss, train_acc = train_one_epoch(
        model, criterion, optimizer,  
        exp_lr_scheduler,
 train_dataloader_iter,                                ❸
        steps_per_epoch, epoch,device)

     val_loss, val_acc = evaluate(
        model, criterion, 
 val_dataloader_iter,                                  ❸
        validation_steps, device)
 return val_loss

❶ 将 Delta Lake 表数据分成两个数据框:训练和验证

❷ 使用 Petastorm 转换器创建 PyTorch 数据加载器进行训练和评估

❸ 在训练迭代中使用训练数据

何时使用 Delta Lake

有关 Delta Lake 的一个普遍误解是,它只能处理结构化文本数据,如销售记录和用户配置文件。但前面的示例表明它也可以处理像图像和音频文件这样的非结构化数据;您可以将文件内容作为字节列写入带有其他文件属性的表中,并从中构建数据集。

如果您已经使用 Apache Spark 构建数据管道,那么 Delta Lake 是进行数据集管理的绝佳选择;它支持结构化和非结构化数据。而且它也是经济实惠的,因为 Delta Lake 将数据存储在云对象存储中(例如 Amazon S3、Azure Blob),Delta Lake 的数据架构强制执行和实时数据更新表支持机制简化了您的 ETL 管道的开发和维护。最重要的是,时间旅行功能会自动跟踪所有表更新,因此您可以放心地进行数据更改并回滚到以前版本的训练数据集。

Delta Lake 的局限性

使用 Delta Lake 的最大风险是技术锁定和其陡峭的学习曲线。Delta Lake 将表存储在其自己的机制中:基于 Parquet 的存储、事务日志和索引的组合,这意味着它只能由 Delta 集群写入/读取。您需要使用 Delta ACID API 进行数据摄入和使用 Delta JDBC 运行查询;因此,如果将来决定远离 Delta Lake,数据迁移成本将会很高。另外,由于 Delta Lake 需要与 Spark 配合使用,如果您是 Spark 的新手,那么您将需要大量的学习时间。

关于数据摄入性能,Delta Lake 将数据存储到底层的云对象存储中,当使用对象存储操作(例如表创建和保存)时,很难实现低延迟流式处理(毫秒级)。此外,Delta Lake 需要为每个 ACID 事务更新索引;与一些执行仅追加数据写入的 ETL 相比,它还引入了延迟。但在我们看来,深度学习项目的数据摄入延迟在秒级并不是一个问题。如果您不熟悉 Spark,并且不想要设置 Spark 和 Delta Lake 集群的重任,我们为您提供了另一种轻量级方法——Pachyderm。

2.3.2 使用云对象存储的 Pachyderm

在这一部分,我们想提出一个基于轻量级 Kubernetes 的工具——Pachyderm——来处理数据集管理。我们将向您展示如何使用 Pachyderm 来完成图像数据处理和标记的两个示例。但在此之前,让我们先了解一下 Pachyderm 是什么。

Pachyderm

Pachyderm 是一个用于构建版本控制、自动化、端到端数据科学数据管道的工具。它运行在 Kubernetes 上,并由您选择的对象存储支持(例如 Amazon S3)。您可以编写自己的 Docker 镜像用于数据爬取、摄入、清理、整理和处理,并使用 Pachyderm 管道将它们链接在一起。一旦定义了管道,Pachyderm 将处理管道的调度、执行和扩展。

Pachyderm 提供了数据集版本控制和来源追溯(数据血统)管理。它将每个数据更新(创建、写入、删除等)视为一个提交,并且还跟踪生成数据更新的数据源。因此,您不仅可以查看数据集的变更历史,还可以将数据集回滚到过去的版本,并查找更改的数据来源。图 2.18 展示了 Pachyderm 的工作原理的高级视图。

图 2.18 Pachyderm 平台由两种对象组成——管道和版本控制数据。管道是计算组件,数据是版本控制基元。在“原始数据集”中的数据更改可能会触发管道作业,以处理新数据并将结果保存到“成熟数据集”中。

在 Pachyderm 中,数据以 Git 风格进行版本控制。每个数据集在 Pachyderm 中都是一个仓库(repo),它是最高级别的数据对象。一个仓库包含提交、文件和分支。Pachyderm 仅在内部保留元数据(例如审计历史和分支),并将实际文件存储在云对象存储中。

Pachyderm 管道执行各种数据转换。管道执行用户定义的代码片段,例如一个 Docker 容器,以执行操作并处理数据。每个执行都称为一个作业。清单 2.15 显示了一个简单的管道定义。这个“edges”管道监视一个“images”数据集。当在图像数据集中添加了新的图像时,管道将启动一个作业,运行 "pachyderm/opencv" Docker 镜像解析图像,并将其边缘图片保存到 edges 数据集中。

清单 2.15 Pachyderm 管道定义

{
  "pipeline": {
    "name": "edges"        ❶
  },
  "description": "A pipeline that performs image \
     edge detection by using the OpenCV library.",
  "transform": {
    "cmd": [ "python3", "/edges.py" ],
    "image": "pachyderm/opencv"
  },
  "input": {
    "pfs": {
      "repo": "images",    ❷
      "glob": "/*"
    }
  }
}

❶ 一个 Pachyderm 管道

❷ 一个 Pachyderm 数据集

版本和数据来源

在 Pachyderm 中,对数据集和管道所做的任何更改都会自动进行版本管理,您可以使用 Pachyderm 命令工具 pachctl 连接到 Pachyderm 工作区,查看文件历史记录,甚至还可以回滚这些更改。查看以下示例,了解如何使用 pachctl 命令来查看 edges 数据集的变更历史和变更来源。首先,我们运行 pachctllist 命令来列出 edges 数据集的所有提交。在我们的示例中,对 edges 数据集进行了三次变更(提交):

$ pachctl list commit edges #A
REPO  BRANCH COMMIT                           FINISHED
edges master 0547b62a0b6643adb370e80dc5edde9f 3 minutes ago 
edges master eb58294a976347abaf06e35fe3b0da5b 3 minutes ago 
edges master f06bc52089714f7aa5421f8877f93b9c 7 minutes ago 

要获取数据更改的来源,我们可以使用 pachctl inspect 命令来检查提交情况。例如,我们可以使用以下命令来检查提交的数据来源。

“eb58294a976347abaf06e35fe3b0da5b”. 
$ pachctl inspect commit edges@eb58294a976347abaf06e35fe3b0da5b \
       --full-timestamps

从以下回应中,我们可以看到 edges 数据集的提交 eb58294a976347abaf06e35fe3b0da5b 是由 images 数据集的提交 66f4ff89a017412090dc4a542d9b1142 计算得出的:

Commit: edges@eb58294a976347abaf06e35fe3b0da5b
Original Branch: master
Parent: f06bc52089714f7aa5421f8877f93b9c
Started: 2021-07-19T05:04:23.652132677Z
Finished: 2021-07-19T05:04:26.867797209Z
Size: 59.38KiB
Provenance:  __spec__@91da2f82607b4c40911d48be99fd3031 (edges)  ❶
images@66f4ff89a017412090dc4a542d9b1142 (master)                ❶

❶ 数据来源

数据来源功能非常适用于数据集的可重现性和故障排除,因为您始终可以找到过去使用的确切数据以及创建它的数据处理代码。

示例:使用 Pachyderm 对图像数据集进行标注和训练

看完了 Pachyderm 是如何工作的,现在让我们看一个设计提案,使用 Pachyderm 来构建一个自动化目标检测训练管道。对于目标检测模型训练,我们首先需要通过在每个图像上用一个边界框标记目标对象来准备训练数据集,然后将数据集——边界框标签文件和图像——发送给训练代码开始模型训练。图 2.19 展示了使用 Pachyderm 自动化这一工作流程的过程。

图 2.19

图 2.19 在 Pachyderm 中自动化的目标检测模型训练。当标记了新图像时,训练过程会自动开始。

在此设计中,我们使用了两个流水线,标记流水线和训练流水线,以及两个数据集来构建这个训练工作流程。在第 1 步,我们将图像文件上传到“原始图像数据集”。在第 2 步中,我们启动标记流水线,启动一个标记应用程序,为用户打开一个 UI 界面,通过在图像上绘制边界框来标记对象;这些图像是从原始图像数据集中读取的。一旦用户完成了标记工作,图像和生成的标签数据将被保存到“标记数据集”。在第 3 步中,我们向已标记的数据集添加新的训练数据,这将触发训练流水线启动训练容器并开始模型训练。在第 4 步中,我们保存模型文件。

除了自动化之外,包括原始图像数据集、标记数据集和模型文件在内的所有数据都会被 Pachyderm 自动进行版本控制。此外,通过利用数据溯源功能,我们可以确定任何给定模型文件使用的标记数据集的版本,以及用于训练此训练数据的原始图像数据集的版本。

什么时候使用 Pachyderm

Pachyderm 是一个轻量级的方法,帮助您轻松构建数据工程流水线,并提供类似 Git 的数据版本支持。它以数据科学家为中心,易于使用。Pachyderm 基于 Kubernetes,并使用云对象存储作为数据存储,因此对于小团队来说成本效益高,设置简单,易于维护。我们建议数据科学团队拥有自己基础设施的情况下使用 Pachyderm,而不要使用 Spark。Pachyderm 在处理非结构化数据(如图像和音频文件)方面表现非常出色。

Pachyderm 的局限性

Pachyderm 缺少的是模式保护和数据分析效率。Pachyderm 将所有东西都视为文件;它为每个文件版本保留快照,但不关心文件内容。在数据写入或读取时没有数据类型验证;它完全依赖于管道来保护数据一致性。

缺乏模式意识和保护为任何持续运行的深度学习训练流水线引入了很多风险,因为在上游数据处理代码中做任何代码更改可能会破坏下游数据处理或训练代码。此外,没有了解数据的模式,实现数据集比较就变得很困难。

总结

  • 数据集管理的主要目标是持续从各种数据源接收新鲜数据,并在支持训练可重现性(数据版本跟踪)的同时,向模型训练交付数据集。

  • 拥有数据集管理组件可以通过将模型算法开发和数据工程开发并行化来加速深度学习项目的开发。

  • 设计数据集管理服务的原则如下:支持数据集可重现性;采用强类型数据模式;设计统一的 API,并保持 API 在不同数据集类型和大小上的一致行为;保证数据持久性。

  • 数据集管理系统至少应支持(训练)数据集版本控制,这对模型的可重现性和性能故障排除至关重要。

  • 数据集是深度学习任务的逻辑文件组;从模型训练的角度来看是静态的,从数据收集的角度来看是动态的。

  • 示例数据集管理服务由三层组成——数据摄入层、内部数据集存储层和训练数据集获取层。

  • 在示例数据集管理服务中,我们为每种数据集类型定义了两种数据模式,一种用于数据摄入,一种用于数据集获取。每次数据更新都被存储为一个提交,而每个训练数据集都被存储为一个带版本的快照。用户可以使用版本哈希字符串随时获取相关的训练数据(数据集可重现性)。

  • 示例数据集管理服务支持一种特殊的数据集类型——GENERIC数据集。GENERIC数据集没有模式和数据验证,并且用户可以自由上传和下载数据,因此非常适合原型化新算法。一旦训练代码和数据集要求变得成熟,数据集格式就可以升级为强类型数据集。

  • Delta Lake 和 Petastorm 可以共同用于为基于 Spark 的深度学习项目建立数据集管理服务。

  • Pachyderm 是一个基于 Kubernetes 的轻量级数据平台,支持类似 Git 的数据版本控制,并允许轻松设置流水线。流水线由 Docker 容器组成;它可以用于自动化数据处理工作流程和深度学习项目的训练工作流程。

第三章:模型训练服务

这一章涵盖了

  • 建立训练服务的设计原则

  • 解释深度学习训练代码模式

  • 参观一个示例训练服务

  • 使用开源训练服务,如 Kubeflow

  • 决定何时使用公共云训练服务

机器学习中的模型训练任务不是研究人员和数据科学家的专属责任。是的,他们对算法的训练工作至关重要,因为他们定义了模型架构和训练计划。但就像物理学家需要一个软件系统来控制电子-正电子对撞机来测试他们的粒子理论一样,数据科学家需要一个有效的软件系统来管理昂贵的计算资源,如 GPU、CPU 和内存,以执行训练代码。这个管理计算资源和执行训练代码的系统被称为模型训练服务

构建高质量的模型不仅取决于训练算法,还取决于计算资源和执行训练的系统。一个好的训练服务可以使模型训练速度更快、更可靠,同时还可以降低平均模型构建成本。当数据集或模型架构非常庞大时,使用训练服务来管理分布式计算是你唯一的选择。

在这一章中,我们首先考察了训练服务的价值主张和设计原则,然后我们遇到了我们的示例训练服务。这个示例服务不仅向你展示了如何将设计原则应用于实践,还教你训练服务如何与任意训练代码交互。接下来,我们介绍了几个开源训练应用程序,你可以用它们快速建立自己的训练服务。最后,我们讨论了何时使用公共云训练系统。

本章重点讨论了从软件工程师的角度而不是数据科学家的角度设计和构建有效的训练服务。因此,我们不希望你熟悉任何深度学习理论或框架。第 3.2 节关于深度学习算法代码模式,是你理解本章训练代码所需的全部准备工作。训练代码并不是我们在这里的主要关注点;我们只是为了演示目的而编写了它,所以我们有东西可以演示示例训练服务。

模型训练的话题经常让工程师感到害怕。一个常见的误解是,模型训练全部都是关于训练算法和研究的。通过阅读这一章,我希望你不仅能学会如何设计和构建训练服务,还能吸收到这样一条信息:模型训练的成功建立在两个支柱上,即算法和系统工程。组织中的模型训练活动如果没有良好的训练系统,就无法扩展。因此,作为软件工程师,我们有很多可以为这个领域做出的贡献。

3.1 模型训练服务:设计概述

在企业环境中,深度学习模型训练涉及两种角色:开发模型训练算法的数据科学家(使用 TensorFlow、PyTorch 或其他框架),以及构建和维护在远程和共享服务器群中运行模型训练代码的系统的平台工程师。我们称这个系统为模型训练服务。

模型训练服务作为一个训练基础设施,在专用环境中执行模型训练代码(算法);它处理训练作业调度和计算资源管理。图 3.1 展示了一个高级工作流程,其中模型训练服务运行模型训练代码以生成一个模型。

图 3.1 通过训练服务执行模型训练的高级工作流程。在步骤 1 中,数据科学家向训练服务提交带有训练代码的训练请求,该服务将在作业队列中创建一个作业。在步骤 2 中,模型训练服务分配计算资源来执行训练作业(训练代码)。在步骤 3 中,当训练执行完成时,作业产生一个模型。

关于这个组件最常见的问题是为什么我们需要编写一个服务来进行模型训练。对许多人来说,似乎更容易编写一个简单的 bash 脚本在本地或远程执行训练代码(算法),比如在 Amazon 弹性云计算(Amazon EC2)实例上。然而,构建训练服务的理由不仅仅是启动训练计算。我们将在下一节详细讨论它。

3.1.1 为什么使用模型训练的服务?

想象一下,你领导一个数据科学团队,你需要明智地为团队成员 Alex、Bob 和 Kevin 分配团队宝贵的计算资源。计算资源需要以一种所有团队成员都能在时间限制和预算内完成他们的模型训练任务的方式分配。图 3.2 展示了分配计算资源的两种方法:专用和共享。

图 3.2 不同的计算资源分配策略:专用 vs. 共享

第一个选项,专用,是为每个团队成员独家分配一台强大的工作站。这是最简单的方法,但显然不是经济的,因为当 Alex 不运行他的训练代码时,他的服务器处于空闲状态,Bob 和 Kevin 都无法使用它。因此,在这种方法中,我们的预算被低效利用。

专用方法的另一个问题是它无法扩展。当 Alex 需要训练一个大模型或者一个数据集庞大的模型时,他将需要多台机器。而且训练机器通常很昂贵;由于深度学习模型架构的复杂性,即使是一个体量适中的神经网络也需要具有较大内存的 GPU。在这种情况下,我们必须为 Alex 分配更多专用服务器,这加剧了资源分配效率低下的问题。

第二个选项,共享计算资源,是建立一个弹性服务器组(组大小可调整)并与所有成员共享。这种方法显然更经济,因为我们使用更少的服务器来实现相同的结果,从而最大化了我们的资源利用率。

选择共享策略并不是一个困难的决定,因为它大大降低了我们训练集群的成本。但是共享方法需要适当的管理,例如如果突然出现大量的训练请求,则排队用户请求,监控每个训练执行并在必要时进行干预(重新启动或中止)(训练进度停滞),并根据实时系统使用情况扩展或缩减我们的集群。

脚本与服务

现在让我们重新审视之前关于脚本与服务的讨论。在模型训练的背景下,训练脚本 是指使用 shell 脚本在共享服务器集群中编排不同的训练活动。训练服务是一个远程过程,它通过 HTTP(超文本传输协议)或 gRPC(gRPC 远程过程调用)进行网络通信。作为数据科学家,Alex 和 Bob 向服务发送训练请求,而服务则编排这些请求并管理共享服务器上的训练执行。

使用脚本方法可能适用于单人场景,但在共享资源环境中会变得困难。除了执行训练代码之外,我们还需要关注其他重要因素,比如设置环境、确保数据合规性以及排除模型性能问题。例如,环境设置要求在开始模型训练之前,在训练服务器上正确安装训练框架和训练代码的库依赖项。数据合规性要求对敏感的训练数据(用户信用卡号、支付记录)进行受限访问的保护。性能故障排除要求对训练中使用的所有内容进行跟踪,包括数据集 ID 和版本、训练代码版本以及超参数,以便进行模型再现。

很难想象用 shell 脚本解决这些要求,并且以可靠、可重复和可扩展的方式执行模型训练。这就是为什么如今大多数在生产中训练的模型都是通过深思熟虑设计的模型训练服务生成的原因。

模型训练服务的好处

从前面的讨论中,我们可以想象一个模型训练服务的价值主张如下:

  • 饱和计算资源并降低模型训练成本

  • 通过以快速(可用资源更多)和可靠的方式构建模型来加快模型开发

  • 通过在受限环境中执行训练来强制执行数据合规性

  • 促进模型性能故障排除

3.1.2 模型训练服务设计原则

在查看我们的示例训练服务之前,让我们看一下可以用来评估模型训练系统的四个设计原则。

原则 1:提供统一的 API,不关心实际的训练代码

只有一个公共 API 来训练不同种类的训练算法使得训练服务易于使用。无论是目标检测训练、语音识别训练还是文本意图分类训练,我们都可以使用示例 API 触发模型训练执行。未来算法性能的 A/B 测试也可以通过一个单一的训练 API 轻松实现。

训练代码不易装配意味着训练服务定义了一种执行训练算法(代码)的清晰机制或协议。例如,它确定了服务如何将变量传递给训练代码/进程,训练代码如何获取训练数据集,以及训练后的模型和指标上传到何处。只要训练代码遵循这个协议,无论它是如何实现的、其模型架构是什么或使用哪些训练库,都不会有任何问题。

原则 2:构建具有高性能和低成本的模型

一个好的训练服务应该将成本效益作为优先考虑。成本效益可以提供缩短模型训练执行时间和提高计算资源利用率的方法。例如,一种现代的训练服务可以通过支持各种分布式训练方法、提供良好的作业调度管理来饱和服务器群,以及在训练过程偏离原始计划时向用户发出警报,从而降低时间和硬件成本。

原则 3:支持模型可重现性

一个服务如果给出相同的输入应该会产生相同的模型。这不仅对调试和性能故障排除很重要,而且还建立了系统的可信度。记住,我们将根据模型预测结果构建业务逻辑。例如,我们可以使用分类模型来预测用户的信用并根据此作出贷款批准决策。除非我们能够反复产生相同质量的模型,否则就无法信任整个贷款批准申请。

原则 4:支持鲁棒、隔离和弹性计算管理

现代深度学习模型,如语言理解模型,需要很长时间的训练(超过一周)。如果训练过程在中途被中断或因某些随机操作系统故障而中止,所有的时间和计算费用都会白白浪费。一个成熟的训练服务应该处理训练工作的鲁棒性(故障转移、故障恢复)、资源隔离和弹性资源管理(能够调整资源数量),以确保其训练作业可以在各种情况下成功完成执行。

在讨论了所有重要的抽象概念之后,让我们来解决如何设计和构建模型训练服务。在接下来的两节中,我们将学习深度学习代码的一般代码模式以及模型训练服务的示例。

3.2 深度学习训练代码模式

深度学习算法可能对工程师来说复杂且常常令人望而生畏。幸运的是,作为设计深度学习系统平台的软件工程师,我们不需要掌握这些算法来进行日常工作。但是,我们需要熟悉这些算法的一般代码模式。通过对模型训练代码模式的高层次理解,我们可以将模型训练代码视为黑盒子。在本节中,我们将向您介绍一般模式。

3.2.1 模型训练工作流程

简而言之,大多数深度学习模型通过迭代学习过程进行训练。该过程在许多迭代中重复相同的计算步骤,并且在每次迭代中,它试图更新神经网络的权重和偏差,以使算法输出(预测结果)更接近数据集中的训练目标。

为了衡量神经网络模拟给定数据的能力并使用它来更新神经网络的权重以获得更好的结果,定义了一个损失函数来计算算法输出与实际结果之间的偏差。损失函数的输出称为 LOSS。

因此,您可以将整个迭代训练过程视为不断努力减少损失值。最终,当损失值达到我们的训练目标或无法进一步减少时,训练完成。训练输出是神经网络及其权重,但我们通常简称为模型。

图 3.3 深度学习模型训练工作流程的一般步骤

图 3.3 说明了一般的模型训练步骤。由于神经网络由于内存限制无法一次性加载整个数据集,因此我们通常在训练开始之前将数据集重新分组成小批量(mini-batches)。在步骤 1 中,将小批量示例馈送到神经网络中,并且网络计算每个示例的预测结果。在步骤 2 中,我们将预测结果和期望值(训练标签)传递给损失函数以计算损失值,该损失值表示当前学习与目标数据模式之间的偏差。在步骤 3 中,一个称为反向传播的过程计算出每个神经网络参数的梯度与损失值。这些梯度用于更新模型参数,以便模型可以在下一个训练循环中获得更好的预测准确性。在步骤 4 中,选择的优化算法(如随机梯度下降及其变种)更新神经网络的参数(权重和偏差)。梯度(来自步骤 3)和学习率是优化算法的输入参数。模型更新步骤后,模型准确性应该会提高。最后,在步骤 5 中,训练完成,网络及其参数保存为最终模型文件。训练在以下两种情况下完成:完成预期的训练运行或达到预期的模型准确度。

尽管有不同类型的模型架构,包括循环神经网络(RNNs)、卷积神经网络(CNNs)和自编码器,但它们的模型训练过程都遵循相同的模式;只有模型网络不同。此外,将模型训练代码抽象为先前重复的一般步骤是进行分布式训练的基础。这是因为,无论模型架构如何不同,我们都可以使用共同的训练策略对它们进行训练。我们将在下一章详细讨论分布式训练。

3.2.2:将模型训练代码 Docker 化为黑匣子

在之前讨论的训练模式的基础上,我们可以将深度学习训练代码视为一个黑匣子。无论训练代码实现了什么样的模型架构和训练算法,我们都可以在训练服务中以相同的方式执行它。为了在训练集群中的任何位置运行训练代码并为每个训练执行创建隔离,我们可以将训练代码及其依赖库打包到一个 Docker 镜像中,并将其作为容器运行(见图 3.4)。

图 3.4:一个训练服务启动一个 Docker 容器来执行模型训练,而不是直接运行训练代码作为一个进程。

在图 3.4 中,通过将训练代码 Docker 化,训练服务可以通过简单地启动一个 Docker 容器来执行模型训练。因为服务对容器内部的内容无所知,所以训练服务可以以这种标准方法执行所有不同的代码。这比让训练服务生成一个进程来执行模型训练要简单得多,因为训练服务需要为每个训练代码设置各种环境和依赖包。Docker 化的另一个好处是它将训练服务和训练代码解耦,这使得数据科学家和平台工程师可以分别专注于模型算法开发和训练执行性能。

如果你对训练服务如何与训练代码通信而对彼此不知情感到奇怪,那么关键在于定义通信协议;该协议界定了训练服务传递给训练代码的参数及其数据格式。这些参数包括数据集、超参数、模型保存位置、指标保存位置等等。我们将在下一节看到一个具体的例子。

3.3 一个样本模型训练服务

如今我们知道,大多数深度学习训练代码遵循相同的模式(图 3.3),它们可以以统一的方式进行 Docker 化和执行(图 3.4)。让我们仔细看一个具体的例子。

为了演示我们迄今介绍的概念和设计原则,我们构建了一个示例服务,实现了模型训练的基本生产场景——接收训练请求,在 Docker 容器中启动训练执行,并跟踪其执行进度。虽然这些场景相当简单——几百行代码——但它们展示了我们在前几节中讨论的关键概念,包括使用统一的 API、Docker 化的训练代码以及训练服务和训练容器之间的通信协议。

为了清晰地展示关键部分,该服务以精简的方式构建。模型训练元数据(如运行任务和等待任务)被跟踪在内存中而不是数据库中,并且训练任务直接在本地 Docker 引擎中执行。通过删除许多中间层,您将直接查看到两个关键区域:训练任务管理和训练服务与训练代码(Docker 容器)之间的通信。

3.3.1 与服务交互

在我们看服务设计和实现之前,让我们看看我们如何操作它。

请按照 GitHub 说明运行此实验。我们仅强调了运行示例服务的主要步骤和关键命令,以避免冗长的代码页面和执行输出,以便清晰地演示概念。要运行此实验,请按照 orca3/MiniAutoML Git 存储库中的“单个训练器演示”文档 (training-service/single_trainer_demo.md) 中的说明操作,该文档还包含所需的输出。

首先,我们使用 scripts/ts-001-start-server.sh 启动服务:

docker build -t orca3/services:latest -f services.dockerfile .
docker run --name training-service -v 
  ➥ /var/run/docker.sock:/var/run/docker.sock 
  ➥ --network orca3 --rm -d -p "${TS_PORT}":51001
  ➥ orca3/services:latest training-service.jar

在启动训练服务 Docker 容器后,我们可以发送一个 gRPC 请求来启动模型训练执行(scripts/ts-002-start-run.sh <dataset id>)。请参见以下示例 gRPC 请求。

图 3.1 调用训练服务 API:提交训练作业

grpcurl -plaintext 
 -d "{
  "metadata": 
    { "algorithm":"intent-classification",    ❶
      "dataset_id":"1",
      "name":"test1",
      "train_data_version_hash":"hashBA==",   ❷
      "parameters":                           ❸
        {"LR":"4","EPOCHS":"15",
         "BATCH_SIZE":"64",
         "FC_SIZE":"128"}}
}" 
"${TS_SERVER}":"${TS_PORT}" 
training.TrainingService/Train

❶ 训练算法;也是训练 Docker 镜像的名称

❷ 训练数据集的版本哈希值。

❸ 训练超参数

一旦作业成功提交,我们就可以使用从 train API 返回的作业 ID 来查询训练执行的进度(scripts/ts-003-check-run.sh <job id>);请参见以下示例:

grpcurl -plaintext \
 -d "{"job_id\": "$job_id"}" \     ❶
"${TS_SERVER}":"${TS_PORT}" 
training.TrainingService/GetTrainingStatus

❶ 使用由训练 API 返回的作业 ID。

正如您所见,通过调用两个 gRPC API,我们可以启动深度学习训练并跟踪其进度。现在,让我们来看看这个示例训练服务的设计和实现。

注意 如果您遇到任何问题,请查看附录 A。A.2 节的脚本自动化了数据集准备和模型训练。如果您想看到一个工作模型训练示例,请阅读该部分的实验部分。

3.3.2 服务设计概述

让我们以 Alex(一位数据科学家)和 Tang(一位开发人员)来展示服务的功能。要使用训练服务来训练一个模型,Alex 需要编写训练代码(例如,一个神经网络算法)并将代码构建成一个 Docker 镜像。这个 Docker 镜像需要发布到一个 artifact 存储库,以便训练服务可以拉取镜像并将其作为容器运行。在 Docker 容器内部,训练代码将由一个 bash 脚本执行。

为了提供一个示例,我们用 PyTorch 编写了一个样本意图分类训练代码,将代码构建成一个 Docker 镜像,并将其推送到 Docker hub(hub.docker.com/u/orca3)。我们将在第 3.3.6 节再次解释它。

注意 在实际场景中,训练 Docker 镜像的创建、发布和消费都是自动完成的。一个示例场景可能如下:第一步,Alex 将他的训练代码提交到 Git 存储库;第二步,一个预配置的程序——例如 Jenkins 流水线——被触发以从这个存储库构建一个 Docker 镜像;第三步,流水线还将 Docker 镜像发布到 Docker 镜像工厂,例如 JFrog Artifactory;第四步,Alex 发送一个训练请求,然后训练服务从工厂拉取训练镜像并开始模型训练。

当 Alex 完成培训代码开发后,他可以开始使用服务运行他的培训代码。整个工作流程如下:步骤 1.a,Alex 向我们的样本培训服务提交培训请求。请求定义了培训代码——一个 Docker 镜像和标签。当培训服务收到培训请求时,它会在队列中创建一个作业,并将作业 ID 返回给 Alex 以供将来跟踪作业;步骤 1.b,Alex 可以查询培训服务以实时获取培训进度;步骤 2,服务以 Docker 容器的形式在本地 Docker 引擎中启动一个训练作业来执行模型训练;步骤 3,Docker 容器中的培训代码在训练期间上传培训指标到元数据存储以及在培训完成时上传最终模型。

注意 模型评估是我们在前述模型训练工作流程中未提及的步骤。在模型训练完成后,Alex(数据科学家)将查看培训服务报告的培训指标,以验证模型的质量。为了评估模型质量,Alex 可以检查预测失败率、梯度和损失值图。由于模型评估通常是数据科学家的责任,所以我们不会在本书中涉及此内容,但我们会在第八章中讨论模型训练指标是如何收集和存储的。

整个培训工作流程是自助式的;Alex 可以完全自己管理模型训练。Tang 开发了培训服务并维护系统,但系统对 Alex 开发的培训代码是不可知的。Tang 的重点不是模型的准确性,而是系统的可用性和效率。请参见图 3.5,了解我们刚刚描述的用户工作流程。

图片

图 3.5 高级服务设计和用户工作流程:用户培训请求被排队,Docker 作业跟踪器从队列中提取作业,并启动 Docker 容器来运行模型训练。

看到了用户工作流程,让我们看看两个关键组件:内存存储和 Docker 作业跟踪器。内存存储使用以下四种数据结构(映射)来组织请求(作业):作业队列、启动列表、运行列表和完成列表。这些映射中的每一个都代表了不同运行状态的作业。我们之所以在内存中实现作业跟踪存储,只是为了简单起见;理想情况下,我们应该使用数据库。

Docker 作业跟踪器处理 Docker 引擎中的实际作业执行;它定期监视内存存储中的作业队列。当 Docker 引擎有空闲容量时,跟踪器将从作业队列中启动一个 Docker 容器,并继续监视容器的执行。在我们的示例中,我们使用本地 Docker 引擎,所以服务可以在您的本地运行。但它也可以很容易地配置到远程 Docker 引擎上。

启动培训容器后,基于执行状态,Docker 作业跟踪器将作业对象从作业队列移动到其他作业列表,如作业启动列表、运行列表和finalizedJobs列表。在第 3.4.4 节中,我们将详细讨论这个过程。

注意 考虑到训练时间,可能会在培训容器(在培训时)中分割数据集。在数据集构建或模型训练期间拆分数据集都是有效的,但两个过程都有各自的优点和缺点。但无论哪种方式,都不会对训练服务的设计产生重大影响。为简单起见,在此示例培训服务中,我们假设算法代码将数据集拆分为训练集、验证集和测试集。

3.3.3 培训服务 API

在了解了概述后,让我们深入了解公共 gRPC API (grpc-contract/ src/main/proto/training_service.proto),以更深入地理解该服务。培训服务中有两个 API:TrainGetTrainingStatusTrain API 用于提交培训请求,而GetTrainingStatus API 用于获取培训执行状态。请参见以下清单中的 API 定义。

清单 3.2 模型培训服务 gRPC 接口

service TrainingService {
 rpc Train(TrainRequest) returns (TrainResponse);
 rpc GetTrainingStatus(GetTrainingStatusRequest) 
   returns (GetTrainingStatusResponse);
}

message TrainingJobMetadata {           ❶
 string algorithm = 1;                  ❶
 string dataset_id = 2;                 ❶
 string name = 3;                       ❶
 string train_data_version_hash = 4;    ❶
 map<string, string> parameters = 5;    ❶
}                                       ❶

message GetTrainingStatusResponse {
 TrainingStatus status = 1;
 int32 job_id = 2;
 string message = 3;
 TrainingJobMetadata metadata = 4;
 int32 positionInQueue = 5;
}

❶ 定义了模型构建请求的数据集、训练算法和额外参数

从清单 3.2 的 gRPC 接口中,为使用Train API,我们需要提供以下信息作为TrainingJobMetadata

  • dataset_id—数据集管理服务中的数据集 ID

  • train_data_version_hash—用于培训的数据集的散列版本

  • name—培训作业名称

  • algorithm—指定使用哪个培训算法来训练数据集。该算法字符串需要是我们预定义算法之一。在内部,培训服务将查找与该算法关联的 Docker 镜像以执行培训。

  • parameters—我们直接传递给训练容器的训练超参数,如训练轮数、批量大小等。

一旦Train API 收到一个培训请求,服务将把请求放入作业队列,并返回一个 ID (job_id)供调用者引用该作业。这个job_id可以与GetTrainingStatus API 一起使用,以检查培训状态。现在我们已经看到了 API 定义,让我们在接下来的两个章节中看看它们的具体实现。

3.3.4 启动新的培训作业

当用户调用Train API 时,培训请求将被添加到内存存储的作业队列中,然后 Docker 作业跟踪器会在另一个线程中处理实际的作业执行。这个逻辑将在接下来的三个清单(3.3-3.5)中解释。

接收培训请求

首先,一个新的培训请求将被添加到作业等待队列中,并分配一个作业 ID 供将来参考;参见代码(training-service/src/main/ java/org/orca3/miniAutoML/training/TrainingService.java)如下。

3.3 提交培训请求的实现代码

public void train(                                      ❶
  TrainRequest request, 
  StreamObserver<TrainResponse> responseObserver) {

   int jobId = store.offer(request);                    ❷
   responseObserver.onNext(TrainResponse
     .newBuilder().setJobId(jobId).build());            ❸
   responseObserver.onCompleted();
}

public class MemoryStore {
   // jobs are waiting to pick up
   public SortedMap<Integer, TrainingJobMetadata>       ❹
     jobQueue = new TreeMap<>();                        ❹
   // jobs’ docker container is in launching state  
   public Map<Integer, ExecutedTrainingJob>             ❹
     launchingList = new HashMap<>();                   ❹
   // jobs’ docker container is in running state
   public Map<Integer, ExecutedTrainingJob>             ❹
     runningList = new HashMap<>();                     ❹
   // jobs’ docker container is completed
   public Map<Integer, ExecutedTrainingJob>             ❹
     finalizedJobs = new HashMap<>();                   ❹
   // .. .. ..

   public int offer(TrainRequest request) {
       int jobId = jobIdSeed.incrementAndGet();         ❺
       jobQueue.put(jobId, request.getMetadata());      ❻
       return jobId;
   }
}

❶ 实现了训练 API

❷ 将训练请求加入队列

❸ 返回作业 ID

❹ 跟踪工作状态的四个不同作业列表

❺ 生成作业 ID

❻ 在等待队列中启动作业

启动训练作业(容器)

一旦作业在等待队列中,当本地 Docker 引擎有足够的系统资源时,Docker 作业跟踪器将处理它。图 3.6 展示了整个过程。Docker 作业跟踪器监控作业等待队列,并在本地 Docker 引擎有足够容量时挑选出第一个可用作业(图 3.6 中的第 1 步)。然后,Docker 作业跟踪器通过启动 Docker 容器执行模型训练作业(步骤 2)。容器成功启动后,跟踪器将作业对象从作业队列移动到启动列表队列(步骤 3)。图 3.6 的代码实现(training-service/src/main/java/org/orca3/miniAutoML/training/ tracker/DockerTracker.java)在清单 3.4 中突出显示。

图 3.6

图 3.6 训练作业启动工作流程:当 Docker 作业跟踪器具有足够的容量时,它会从作业队列中启动训练容器。

清单 3.4 使用 DockerTracker 启动训练容器

public boolean hasCapacity() {                           ❶
  return store.launchingList.size()
    + store.runningList.size() == 0;
}

public String launch(                                    ❷
  int jobId, TrainingJobMetadata metadata, 
  VersionedSnapshot versionedSnapshot) {

    Map<String, String> envs = new HashMap<>();          ❸
    .. .. ..                                             ❸
    envs.put("TRAINING_DATA_PATH",                       ❸
    versionedSnapshot.getRoot());                        ❸
    envs.putAll(metadata.getParametersMap());            ❸
    List<String> envStrings = envs.entrySet()            ❸
           .stream()                                     ❸
           .map(kvp -> String.format("%s=%s", 
             kvp.getKey(), kvp.getValue()))
           .collect(Collectors.toList());

   String containerId = dockerClient                     ❹
    .createContainerCmd(metadata.getAlgorithm())         ❺
           .. .. ..
           .withCmd("server", "/data")
           .withEnv(envStrings)                          ❻
           .withHostConfig(HostConfig.newHostConfig()
             .withNetworkMode(config.network))
           .exec().getId();

   dockerClient.startContainerCmd(containerId).exec();   ❼
   jobIdTracker.put(jobId, containerId);
   return containerId;
}

❶ 检查系统的容量

❷ 启动训练 Docker 容器

❸ 将训练参数转换为环境变量

❹ 构建 Docker 启动命令

❺ 设置 Docker 镜像名称;其值来自算法名称参数。

❻ 将训练参数作为环境变量传递给 Docker 容器

❼ 运行 Docker 容器

需要注意的是,在代码清单 3.4 中,launch函数将train API 请求中定义的训练参数作为环境变量传递给训练容器(训练代码)。

跟踪训练进度

在最后一步,Docker 作业跟踪器通过监控其容器的执行状态继续跟踪每个作业。当它检测到容器状态发生变化时,作业跟踪器将容器的作业对象移到内存存储中相应的作业列表中。

作业跟踪器将查询 Docker 运行时以获取容器的状态。例如,如果作业的 Docker 容器开始运行,作业跟踪器将检测到此更改,并将作业放入“运行中作业列表”;如果作业的 Docker 容器完成,则作业跟踪器将作业移动到“已完成的作业列表”。作业跟踪器将在将作业放置在“已完成的作业列表”后停止检查作业状态,这意味着训练已完成。图 3.7 描述了此作业跟踪工作流程。清单 3.5 突出显示了此作业跟踪过程的实现。

图 3.7

图 3.7 Docker 作业跟踪器监视 Docker 容器的执行状态并更新作业队列。

清单 3.5 DockerTracker 监控 Docker 并更新作业状态

public void updateContainerStatus() {
  Set<Integer> launchingJobs = store.launchingList.keySet();
  Set<Integer> runningJobs = store.runningList.keySet();

  for (Integer jobId : launchingJobs) {               ❶

    String containerId = jobIdTracker.get(jobId);
    InspectContainerResponse.ContainerState state =   ❷
        dockerClient.inspectContainerCmd(             ❷
          containerId).exec().getState();             ❷
    String containerStatus = state.getStatus();

    // move the launching job to the running 
    // queue if the container starts to run. 
       .. .. ..
   }

   for (Integer jobId : runningJobs) {                ❸
      // move the running job to the finalized 
      // queue if it completes (success or fail).
       .. .. ..
   }
}

❶ 检查启动作业列表中所有作业的容器状态

❷ 查询容器的执行状态

❸ 检查运行中作业列表中所有作业的容器状态

3.3.5 更新和获取作业状态

现在您已经看到了训练请求在训练服务中是如何执行的,让我们继续前往代码之旅的最后一站:获取训练执行状态。启动训练作业后,我们可以查询GetTrainingStatus API 来获取训练状态。作为提醒,我们将图 3.5 重新发布,呈现为图 3.8,显示了服务的高级设计,如下所示。

图 3.8 高级服务设计和用户工作流程

根据图 3.8,我们可以看到获取训练状态只需要一步,即 1.b。此外,通过查找哪个作业列表(在内存存储中)包含jobId,可以确定训练作业的最新状态。请参阅以下代码以查询训练作业/请求的状态(training-service/src/main/java/org/orca3/miniAutoML/training/TrainingService.java)。

清单 3.6 训练状态实现

public void getTrainingStatus(GetTrainingStatusRequest request) {
  int jobId = request.getJobId();
  .. .. ..  
  if (store.finalizedJobs.containsKey(jobId)) {           ❶
    job = store.finalizedJobs.get(jobId);
    status = job.isSuccess() ? TrainingStatus.succeed 
        : TrainingStatus.failure;

  } else if (store.launchingList.containsKey(jobId)) {    ❷
    job = store.launchingList.get(jobId);
    status = TrainingStatus.launch;

  } else if (store.runningList.containsKey(jobId)) {      ❸
    job = store.runningList.get(jobId);
    status = TrainingStatus.running;
  } else {                                                ❹
    TrainingJobMetadata metadata = store.jobQueue.get(jobId);
    status = TrainingStatus.waiting;
       .. .. ..
   }
   .. .. ..
}

❶ 在已完成的作业列表中搜索作业

❷ 在启动作业列表中搜索作业

❸ 在运行中的作业列表中搜索作业

❹ 作业仍在等待作业队列中。

因为 Docker 作业跟踪器实时将作业移动到相应的作业列表中,我们可以依靠使用作业队列类型来确定训练作业的状态。

3.3.6 意图分类模型训练代码

到目前为止,我们一直在处理训练服务代码。现在让我们看看最后一部分,模型训练代码。请不要被这里的深度学习算法吓到。这个代码示例的目的是向您展示一个具体的示例,说明训练服务如何与模型训练代码交互。图 3.9 描绘了示例意图分类训练代码的工作流程。

图 3.9 意图分类训练代码工作流程首先从环境变量中读取所有输入参数,然后下载数据集,处理数据集,并启动训练循环。最后,它上传输出模型文件。

我们的示例训练代码训练一个三层神经网络以执行意图分类。首先,它从由我们的训练服务传递的环境变量中获取所有输入参数(请参阅第 3.3.4 节)。输入参数包括超参数(epoch 数、学习率等)、数据集下载设置(MinIO 服务器地址、数据集 ID、版本哈希)和模型上传设置。接下来,训练代码下载和解析数据集,并开始迭代学习过程。在最后一步中,代码将生成的模型和训练指标上传到元数据存储中。以下代码清单突出显示了前面提到的主要步骤(train-service/text-classification/train.pytrain-service/text-classification/Dockerfile)。

清单 3.7 意图分类模型训练代码和 Docker 文件

# 1\. read all the input parameters from 
# environment variables, these environment 
# variables are set by training service - docker job tracker.
EPOCHS = int_or_default(os.getenv('EPOCHS'), 20)
.. .. ..
TRAINING_DATA_PATH = os.getenv('TRAINING_DATA_PATH')

# 2\. download training data from dataset management
client.fget_object(TRAINING_DATA_BUCKET, 
  TRAINING_DATA_PATH + "/examples.csv", "examples.csv")
client.fget_object(TRAINING_DATA_BUCKET, 
  TRAINING_DATA_PATH + "/labels.csv", "labels.csv")

# 3\. prepare dataset
.. .. ..
train_dataloader = DataLoader(split_train_, batch_size=BATCH_SIZE,
                             shuffle=True, collate_fn=collate_batch)
valid_dataloader = DataLoader(split_valid_, batch_size=BATCH_SIZE,
                             shuffle=True, collate_fn=collate_batch)
test_dataloader = DataLoader(split_test_, batch_size=BATCH_SIZE,
                            shuffle=True, collate_fn=collate_batch)

# 4\. start model training
for epoch in range(1, EPOCHS + 1):
   epoch_start_time = time.time()
   train(train_dataloader)
   .. .. ..

print('Checking the results of test dataset.')
accu_test = evaluate(test_dataloader)
print('test accuracy {:8.3f}'.format(accu_test))

# 5\. save model and upload to metadata store.
.. .. ..
client.fput_object(config.MODEL_BUCKET, 
  config.MODEL_OBJECT_NAME, model_local_path)
artifact = orca3_utils.create_artifact(config.MODEL_BUCKET, 
  config.MODEL_OBJECT_NAME)
.. .. ..

注意 我们希望我们的示例训练代码演示了深度学习训练代码遵循常见模式。通过容器化以及传递参数的清晰协议,训练服务可以执行各种训练代码,而不论训练框架或模型架构。

3.3.7 训练作业管理

在第 3.1.2 节中,我们提到良好的训练服务应该解决计算隔离并提供按需计算资源(原则 4)。这种隔离有两重含义:训练过程的执行隔离和资源消耗隔离。由于我们使用 Docker 对训练过程进行容器化,所以这个执行隔离是由 Docker 引擎保证的。但是资源消耗隔离仍然需要我们自己处理。

想象一下,来自不同团队的三个用户(A、B 和 C)向我们的训练服务提交训练请求。如果用户 A 提交了 100 个训练请求,然后用户 B 和 C 都各自提交了一个请求,那么用户 B 和 C 的请求将在等待作业队列中等待一段时间,直到用户 A 的所有训练请求完成。当我们将训练集群视为每个人的游戏场时,这就是发生的情况:一个重度使用情况会主导作业调度和资源消耗。

为了解决资源竞争问题,我们需要在训练集群内为不同团队和用户设置边界。我们可以在训练集群内创建机器池以实现资源消耗隔离。每个团队或用户可以分配到一个专用的机器池,每个池都有自己的 GPU 和机器,池的大小取决于项目需求和训练使用情况。此外,每个机器池可以有一个专门的作业队列,因此重度用户不会影响其他用户。图 3.9 展示了这种方法的运作方式。

注意 资源隔离方法,像我们刚刚提到的服务器池方法,在资源利用方面可能不够高效。例如,服务器池 A 可能非常忙,而服务器池 B 可能处于空闲状态。可以定义每个服务器池的大小为一个范围,而不是一个固定数字,例如最小 5 台服务器、最大 10 台服务器,以提高资源利用率。然后可以应用额外的逻辑,以在服务器之间进行移动或提供新服务器。

实现图 3.10 的理想方法是使用 Kubernetes。Kubernetes 允许您创建由相同物理集群支持的多个虚拟集群,称为命名空间。Kubernetes 命名空间是一个消耗非常少系统资源的轻量级机器池。

图 3.10 在训练集群内创建机器池,以为不同用户设置资源消耗边界。

如果您正在使用 Kubernetes 管理服务环境和计算集群,那么设置此类隔离非常容易。首先,您需要创建一个拥有资源配额的命名空间,例如 CPU 数量、内存大小和 GPU 数量;然后,在训练服务中定义用户及其命名空间的映射关系。

现在,当用户提交一个训练请求时,训练服务首先通过检查请求中的用户信息找到正确的命名空间,然后调用 Kubernetes API 将训练可执行文件放置在该命名空间中。由于 Kubernetes 实时跟踪系统的使用情况,它知道一个命名空间是否有足够的容量,如果命名空间已满,它将拒绝作业启动请求。

正如您所见,通过使用 Kubernetes 管理训练集群,我们可以将资源容量跟踪和资源隔离管理从训练服务中卸载出来。这是选择 Kubernetes 构建深度学习训练集群管理的一个原因。

3.3.8 故障排除指标

在此示例服务中,我们没有演示指标的使用。通常,指标是用于评估、比较和跟踪性能或生产的定量评估的常见度量。对于深度学习训练,我们通常定义两种类型的指标:模型训练执行指标和模型性能指标。

模型训练执行指标包括资源饱和率、训练作业执行可用性、平均训练作业执行时间和作业失败率。我们检查这些指标,以确保训练服务健康运行,并且用户的日常活动正常。例如,我们期望服务可用性超过 99.99% ,训练作业失败率小于 0.1% 。

模型性能指标衡量模型学习的质量。它包括每个训练迭代(epoch)的损失值和评估分数,以及最终的模型评估结果,如准确率、精度和 F1 分数。

对于与模型性能相关的指标,我们需要以更有组织的方式存储这些指标,以便能够使用统一的方法轻松搜索信息并比较不同训练运行之间的性能。我们将在第八章对此进行更详细的讨论。

3.3.9 支持新算法或新版本

现在让我们讨论如何将更多的训练代码接入到我们的示例训练服务中。在当前的实现中,我们使用请求中的 algorithm 变量来定义用户训练请求与训练代码之间的映射关系,使用 algorithm 变量在请求中找到对应的训练镜像。底层规则是 algorithm 变量必须等于 Docker 镜像名称,否则训练服务无法找到正确的镜像来运行模型训练。

以我们的意图分类训练为例。首先,我们需要将意图训练 Python 代码 Docker 化为 Docker 镜像并将其命名为“intent-classification”。然后,当用户使用algorithm = 'intent-classification'参数发送训练请求时,Docker 作业跟踪器将使用算法名称(即 intent-classification)在本地 Docker 仓库中搜索“intent-classification”训练镜像并将镜像运行为训练容器。

这种方法肯定过于简化了,但它演示了我们如何与数据科学家一起定义将用户训练请求映射到实际训练代码的正式协议。在实践中,训练服务应该提供一组 API,允许数据科学家以自助方式注册训练代码。

一种可能的方法是在数据库中定义算法名称和训练代码映射,添加一些 API 来管理这个映射。建议的 API 可以是

  • createAlgorithmMapping(string algorithmName, string image, string version)

  • updateAlgorithmVersion(string algorithmName, string image, string version)

如果数据科学家想要添加新的算法类型,他们可以调用createAlgorithmMapping API 来向训练服务注册新的训练图像和新的算法名称。我们的用户只需要在训练请求中使用这个新的算法名称即可使用这个新的算法开始模型训练。

如果数据科学家想要发布现有算法的新版本,他们可以调用updateAlgorithmVersion API 来更新映射。我们的用户仍然会有相同的算法名称(如意图分类),发送请求,但他们不会意识到训练代码在幕后升级到不同的版本。同时,值得指出的是,服务的公共 API 不会受到添加新训练算法的影响;只有一个新参数值被使用。

3.4. Kubeflow 训练操作符:开源方法

看完我们的示例训练服务后,让我们看看一个开源的训练服务。在本节中,我们将讨论来自 Kubeflow 项目的一组开源训练操作符。这些训练操作符可立即使用,并且可以在任何 Kubernetes 集群中独立设置。

Kubeflow 是一个成熟、开源的机器学习系统,适用于生产环境。我们在附录 B.4 中简要介绍了它,以及亚马逊 SageMaker 和 Google Vertex AI。我们推荐使用 Kubeflow、可扩展、可分布式和稳健性高的训练操作符。我们将首先讨论高级系统设计,然后讨论如何将这些训练操作符集成到自己的深度学习系统中。

什么是 Kubeflow?

Kubeflow 是一个开源的机器学习平台(源自谷歌),用于开发和部署生产级别的机器学习模型。你可以将 Kubeflow 视为 Amazon SageMaker 的开源版本,但它原生运行在 Kubernetes 上,因此它是云无关的。Kubeflow 将完整的机器学习功能集成到一个系统中——从 Notebooks 和管道到训练和服务。

即使你不打算使用它,我强烈建议你关注 Kubeflow 项目。Kubeflow 是一个设计良好且相当先进的深度学习平台;它的功能列表涵盖了整个机器学习生命周期。通过审查其用例、设计和代码,你将深入了解现代深度学习平台。

此外,因为 Kubeflow 是在 Kubernetes 的原生基础上构建的,你可以轻松地在你的本地或生产环境中设置整个系统。如果你不感兴趣借鉴整个系统,你也可以移植其中一些组件——如训练操作器或超参数优化服务——它们可以自行在任何 Kubernetes 环境中开箱即用。

3.4.1 Kubeflow 训练操作器

Kubeflow 提供了一组训练操作器,例如 TensorFlow 操作器、PyTorch 操作器、MXNet 操作器和 MPI 操作器。这些操作器涵盖了所有主要训练框架。每个操作器都有知识可以启动和监视用特定类型的训练框架编写的训练代码(容器)。

如果你计划在 Kubernetes 集群中运行模型训练,并希望设置自己的训练服务以减少操作成本,Kubeflow 训练操作器是完美的选择。以下是三个原因:

  • 轻松安装和低维护——Kubeflow 操作器开箱即用;通过几行 Kubernetes 命令,你可以使它们在你的集群中工作。

  • 兼容大多数训练算法和框架——只要你将训练代码容器化,就可以使用 Kubeflow 操作器执行它。

  • 易于集成到现有系统——由于 Kubeflow 训练操作器遵循 Kubernetes 操作器设计模式,因此你可以使用 Kubernetes 的声明性 HTTP API 提交训练作业请求并检查作业运行状态和结果。你也可以使用 RESTful 查询与这些操作器交互。

3.4.2 Kubernetes 操作器/控制器模式

Kubeflow 训练操作器遵循 Kubernetes 操作器(控制器)设计模式。如果我们理解了此模式,那么运行 Kubeflow 训练操作器并阅读其源代码就很简单了。图 3.11 显示了控制器模式的设计图。

图 3.11 Kubernetes 操作器/控制器模式运行无限控制循环,观察某些 Kubernetes 资源的实际状态(在右侧)和期望状态(在左侧),并尝试将实际状态移动到期望状态。

Kubernetes 中的所有内容都围绕着资源对象和控制器构建。Kubernetes 的资源对象,如 Pods、Namespaces 和 ConfigMaps,是持久化实体(数据结构),代表着集群的状态(期望的和当前的)。控制器是一个控制循环,它对实际的系统资源进行更改,以将您的集群从当前状态带到更接近期望状态,这在资源对象中定义。

注意 Kubernetes pod 是您可以在 Kubernetes 中创建和管理的最小部署单元。Pod 可以被视为运行一个或多个 Docker 容器的“逻辑主机”。有关 Kubernetes 概念的详细解释,例如 Namespaces 和 ConfigMaps,可以在官方网站找到:kubernetes.io/docs/concepts/

例如,当用户应用 Kubernetes 命令来创建一个 pod 时,它将在集群中创建一个 pod 资源对象(一个数据结构),其中包含所需的状态:两个 Docker 容器和一个磁盘卷。当控制器检测到这个新的资源对象时,它将在集群中提供实际的资源,并运行两个 Docker 容器并附加磁盘。接下来,它将更新 pod 资源对象的最新实际状态。用户可以查询 Kubernetes API 来获取此 pod 资源对象的更新信息。当用户删除此 pod 资源对象时,控制器将删除实际的 Docker 容器,因为所需状态已更改为零。

为了轻松扩展 Kubernetes,Kubernetes 允许用户定义自定义资源定义(CRD)对象,并注册定制的控制器来处理这些 CRD 对象,称为操作器。如果您想了解更多关于控制器/操作器的信息,可以阅读 “Kubernetes/sample-controller” GitHub 存储库,该存储库实现了用于监视 CRD 对象的简单控制器。这个示例控制器代码可以帮助您理解操作器/控制器模式,这种理解对于阅读 Kubeflow 训练操作器源代码非常有用。

注意:在本节中,“控制器”和“操作器”这两个术语是可以互换使用的。

3.4.3 Kubeflow 训练操作器设计

Kubeflow 训练操作器(TensorFlow 操作器、PyTorch 操作器、MPI 操作器)遵循 Kubernetes 操作器设计。每个训练操作器都会监视其自己类型的客户资源定义对象 —— 例如 TFJobPyTorchJobMPIJob —— 并创建实际的 Kubernetes 资源来运行训练。

例如,TensorFlow 操作器处理在集群中生成的任何 TFJob CRD 对象,并根据 TFJob 规范创建实际的服务/ pod。它将 TFJob 对象的资源请求与实际的 Kubernetes 资源(例如服务和 pod)同步,并不断努力使观察到的状态与期望的状态匹配。在图 3.12 中可以看到一个视觉工作流程。

图 3.12 Kubeflow 训练操作器的工作流程。用户首先创建一个 TFJob CRD 对象,该对象定义了一个训练请求,然后 TensorFlow 操作器检测到此对象,并创建实际的 pod 来执行 TensorFlow 训练图像。TensorFlow 操作器还监视 pod 的状态,并将其状态更新到 TFJob CRD 对象中。相同的工作流程也适用于 PyTorch 操作器。

每个操作器都可以为其自己类型的训练框架运行训练 pod。例如,TensorFlow 操作器知道如何为 TensorFlow 编写的训练代码设置分布式训练 pod 组。操作器从 CRD 定义中读取用户请求,创建训练 pod,并将正确的环境变量和命令行参数传递给每个训练 pod/container。您可以查看每个操作器代码中的 reconcileJobsreconcilePods 函数以了解更多详细信息。

每个 Kubeflow 操作器还处理作业队列管理。因为 Kubeflow 操作器遵循 Kubernetes 操作器模式,并在 pod 级别创建 Kubernetes 资源,所以训练 pod 的故障切换处理得很好。例如,当一个 pod 意外失败时,当前 pod 数量会减少一个,小于 CRD 对象中定义的期望 pod 数量。在这种情况下,操作器中的 reconcilePods 逻辑将在集群中创建一个新的 pod,以确保实际的 pod 数量等于 CRD 对象中定义的期望数量,从而解决故障切换问题。

注意 在编写本书时,TensorFlow 操作器正在成为全能的 Kubeflow 操作器。它旨在简化在 Kubernetes 上运行分布式或非分布式 TensorFlow/PyTorch/MXNet/XGBoost 作业。无论最终的结果如何,它都是基于我们在这里提到的设计构建的,只是使用起来更加方便。

3.4.4 如何使用 Kubeflow 训练操作器

在本节中,我们将以 PyTorch 操作器作为示例,分四个步骤训练一个 PyTorch 模型。因为所有的 Kubeflow 训练操作器都遵循相同的使用模式,这些步骤也适用于其他操作器。

首先,在您的 Kubernetes 集群中安装独立的 PyTorch 操作器和 PyTorchJob CRD。您可以在 PyTorch 操作器的 Git 存储库的开发人员指南中找到详细的安装说明。安装完成后,您可以在您的 Kubernetes 集群中找到一个正在运行的训练操作器 pod,并创建一个 CRD 定义。查看如下的 CRD 查询命令:

$ kubectl get crd                                       ❶

NAME                              CREATED AT
...
pytorchjobs.kubeflow.org        2021-09-15T18:33:58Z    ❷
...

❶ 列出所有的 CRD 定义

❷ Kubernetes 中创建了 PyTorchJob CRD。

注意 训练运算符的安装可能会令人困惑,因为 README 建议你安装整个 Kubeflow 来运行这些运算符,但这并非必须。每个训练运算符都可以单独安装,这是我们推荐的处理方式。请查阅开发指南或设置脚本

接下来,更新你的训练容器以从环境变量和命令行参数中读取参数输入。你可以稍后在 CRD 对象中传递这些参数。

第三步,创建一个PyTorchJob CRD 对象来定义我们的训练请求。你可以通过首先编写一个 YAML 文件(例如,pytorchCRD.yaml),然后在你的 Kubernetes 集群中运行kubectl create -f pytorchCRD.yaml来创建这个 CRD 对象。PT-operator 将检测到这个新创建的 CRD 对象,将其放入控制器的作业队列中,并尝试分配资源(Kubernetes pod)来运行训练。清单 3.8 显示了一个样例PyTorchJob CRD。

清单 3.8 一个样例 PyTorch CRD 对象

kind: PyTorchJob                 ❶
metadata:
  name: pytorch-demo             ❷
spec:
  pytorchReplicaSpecs:           ❸
    Master:
      replicas: 1                ❹
      restartPolicy: OnFailure
      containers:
          .. .. ..
    Worker:
      replicas: 1                ❺
      .. .. ..
        spec:
          containers:            ❻
            - name: pytorch
              .. .. ..
              env:               ❼
                - name: credentials
                  value: "/etc/secrets/user-gcp-sa.json"
              command:           ❽
                - "python3"
                - “-m”
                - "/opt/pytorch-mnist/mnist.py"
                - "--epochs=20"
                - “--batch_size=32”

❶ CRD 的名称

❷ 训练作业的名称

❸ 定义训练组规格

❹ 主节点 pod 的数量

❺ 训练工作负载的数量

❻ 定义训练容器配置

❼ 为每个训练 pod 定义环境变量

❽ 定义命令行参数

最后一步是监控。你可以使用kubectl get -o yaml pytorchjobs命令获取训练状态,它将列出所有pytorchjobs类型的 CRD 对象的详细信息。因为 PyTorch 运算符的控制器将持续更新最新的训练信息到 CRD 对象中,所以我们可以从中读取当前状态。例如,以下命令将创建一个名为pytorch-demoPyTorchJob类型的 CRD 对象:

kubectl get -o yaml pytorchjobs pytorch-demo -n kubeflow

注意 在前面的示例中,我们使用 Kubernetes 命令kubectl与 PyTorch 运算符进行交互。但我们也可以向集群的 Kubernetes API 发送 RESTful 请求来创建训练作业 CRD 对象并查询其状态。然后,新创建的 CRD 对象将触发控制器中的训练操作。这意味着 Kubeflow 训练运算符可以轻松集成到其他系统中。

3.4.5 如何将这些运算符集成到现有系统中

从第 3.4.3 节我们可以看到,运算符的 CRD 对象充当了触发训练操作的网关 API,并且是训练状态的真实来源。因此,我们可以通过在运算符 CRD 对象之上构建一个 Web 服务将这些训练运算符集成到任何系统中。这个包装服务有两个责任:首先,它将你系统中的训练请求转换为 CRD 对象(训练作业)上的 CRUD(创建、读取、更新和删除)操作;其次,它通过读取 CRD 对象来查询训练状态。请参见图 3.13 中的主要工作流程。

图 3.13 将 Kubeflow 训练运算符集成到现有深度学习系统中作为训练后端。包装器服务可以将训练请求转换为 CRD 对象,并从 CRD 对象中获取训练状态。

在图 3.13 中,现有系统的前端部分保持不变,例如前门网站。在计算后端,我们更改了内部组件,并与包装器训练服务对话以执行模型训练。包装器服务有三个功能:首先,它管理作业队列;其次,它将训练请求从现有格式转换为 Kubeflow 训练运算符的 CRD 对象;第三,它从 CRD 对象中获取训练状态。通过这种方法,通过添加包装器服务,我们可以轻松地将 Kubeflow 训练运算符作为任何现有深度学习平台/系统的训练后端。

从零开始构建一个生产质量的训练系统需要大量的努力。你不仅需要了解各种训练框架的微妙之处,还需要了解如何处理工程方面的可靠性和可扩展性挑战。因此,如果你决定在 Kubernetes 中运行模型训练,我们强烈建议采用 Kubeflow 训练运算符。这是一个开箱即用的解决方案,可以轻松移植到现有系统中。

3.5 何时使用公有云

主要的公有云供应商,如亚马逊、谷歌和微软,提供了他们的深度学习平台,如亚马逊 SageMaker、谷歌 Vertex AI 和 Azure 机器学习工作室,一应俱全。所有这些系统声称提供全面托管的服务,支持整个机器学习工作流程,以便快速训练和部署机器学习模型。事实上,它们不仅涵盖模型训练,还包括数据处理和存储、版本控制、故障排除、操作等方面。

在本节中,我们不打算讨论哪种云解决方案是最好的;相反,我们想分享一下何时使用它们的想法。当我们提出在公司内部构建服务,如训练服务或超参数调整服务时,我们经常会听到诸如“我们可以使用 SageMaker 吗?我听说他们有一个功能……”或“你能在 Google Vertex AI 之上构建一个包装器吗?我听说……”这样的问题。这些问题有时是有效的,有时不是。你能负担得起什么真的取决于你的业务阶段。

3.5.1 何时使用公有云解决方案

如果你经营一家初创公司或想要快速验证你的业务理念,使用公有云 AI 平台是一个不错的选择。它处理所有底层基础设施管理,并为你提供了一个标准的工作流程供你遵循。只要预定义的方法对你有效,你就可以专注于开发你的业务逻辑、收集数据和实现模型算法。真正的好处在于节省了建立自己基础设施的时间,这样你就可以“早期失败,快速学习”。

使用公共云 AI 平台的另一个原因是您只有少数深度学习场景,并且它们很好地适用于公共云的标准用例。在这种情况下,为仅几个应用程序构建复杂的深度学习系统并不值得消耗资源。

3.5.2 构建自己的训练服务的时机

现在,让我们谈谈何时需要构建自己的训练方法的情况。如果您的系统具有以下五个要求之一,构建自己的训练服务是正确的选择。

云无关性

如果您希望您的应用程序具有云无关性,您就不能使用亚马逊 SageMaker 或 Google Vertex AI 平台,因为这些系统是特定于云的。当您的服务存储客户数据时,拥有云无关性是重要的,因为一些潜在客户对于他们不希望将数据放入的云有特定要求。您希望您的应用程序能够在各种云基础设施上无缝运行。

在公共云上构建云无关系统的常见方法是仅仅使用基础服务,例如虚拟机(VM)和存储,并在其上构建您的应用程序逻辑。以模型训练为例,当使用亚马逊网络服务时,我们首先通过使用亚马逊 EC2 服务设置一个 Kubernetes 集群(Amazon 弹性 Kubernetes 服务(Amazon EKS))来管理计算资源,然后使用 Kubernetes 接口构建我们自己的训练服务来启动训练任务。通过这种方式,当我们需要迁移到谷歌云(GCP)时,我们可以简单地将我们的训练服务应用到 GCP Kubernetes 集群(Google Kubernetes Engine)而不是 Amazon EKS,并且大部分服务保持不变。

降低基础设施成本

使用云服务提供商的人工智能平台相比自行运营服务将会花费更多的资金。在原型设计阶段,您可能不太在意账单,但产品发布后,您肯定应该关心。

以亚马逊 SageMaker 为例,在撰写本书时(2022 年),SageMaker 为 m5.2xlarge 类型(八个虚拟 CPU,32GB 内存)的机器每小时收费 0.461 美元。如果直接在此硬件规格上启动亚马逊 EC2 实例(VM),则每小时收费 0.384 美元。通过构建自己的训练服务并直接在亚马逊 EC2 实例上运行,您平均可以节省近 20%的模型构建成本。如果一家公司有多个团队每天进行模型训练,那么自建训练系统将使您在竞争中处于领先地位。

定制

尽管云 AI 平台为您提供了许多工作流配置选项,但它们仍然是黑匣子方法。因为它们是一刀切的方法,这些 AI 平台专注于最常见的场景。但总会有需要为您的业务定制的例外情况;当选择不多时,这不会是一种好的体验。

云端 AI 平台的另一个问题是在采用新技术方面总是有所延迟。例如,您必须等待 SageMaker 团队决定是否支持某种训练方法以及何时支持它,而有时该决定可能不符合您的意愿。深度学习是一个快速发展的领域。构建自己的训练服务可以帮助您采用最新的研究并快速转变,从而使您在激烈的竞争中获得优势。

通过合规审计

要有资格运行某些业务,您需要获得符合合规法律法规的证书,例如 HIPAA(医疗保险流动性和责任法)或 CCPA(加州消费者隐私法)。这些认证要求您不仅提供证据证明您的代码符合这些要求,还要提供您的应用程序运行的基础设施符合要求。如果您的应用程序是基于 Amazon SageMaker 和 Google Vertex AI 平台构建的,则它们也需要符合要求。由于云供应商是黑盒,通过合规检查清单并提供证据是一项不愉快的任务。

身份验证和授权

将身份验证和授权功能集成到云端 AI 平台和内部身份验证服务(内部部署)需要付出很大的努力。许多公司都有自己的版本身份验证服务来验证和授权用户请求。如果我们采用 SageMaker 作为 AI 平台并将其暴露给不同的内部服务以满足不同的业务目的,将 SageMaker 的身份验证管理与内部用户身份验证管理服务连接起来并不容易。相反,构建内部部署的训练服务要容易得多,因为我们可以自由更改 API 并简单地将其集成到现有的身份验证服务中。

总结

  • 训练服务的主要目标是管理计算资源和训练执行。

  • 一种先进的训练服务遵循四个原则:通过统一接口支持各种模型训练代码;降低训练成本;支持模型可复现性;具有高可伸缩性和可用性,并处理计算隔离。

  • 了解常见的模型训练代码模式可以让我们从训练服务的角度将代码视为黑盒。

  • 容器化是处理深度学习训练方法和框架多样性的关键。

  • 通过将训练代码 Docker 化并定义明确的通信协议,训练服务可以将训练代码视为黑盒并在单个设备或分布式环境中执行训练。这也使得数据科学家可以专注于模型算法开发,而不必担心训练执行。

  • Kubeflow 训练 operators 是一组基于 Kubernetes 的开源训练应用程序。这些 operators 可以开箱即用,并且可以轻松地集成到任何现有系统中作为模型训练后端。Kubeflow 训练 operators 支持分布式和非分布式训练。

  • 使用公共云训练服务可以帮助快速构建深度学习应用程序。另一方面,建立自己的训练服务可以减少训练操作成本,提供更多的定制选项,并保持云无关。

第四章:分布式训练

本章内容包括

  • 理解数据并行、模型并行和流水线并行

  • 使用支持 Kubernetes 中数据并行训练的示例训练服务

  • 使用多个 GPU 训练大型模型

在深度学习研究领域明显的一个趋势是通过更大的数据集和更大的模型以及越来越复杂的架构来提高模型性能。但更多的数据和更庞大的模型也会带来一些后果:它们会减慢模型训练过程以及模型开发过程。在计算中,性能常常与速度相抵触。例如,使用单个 GPU 训练一个 BERT(双向编码器表示转换器)自然语言处理模型可能需要几个月的时间。

为了解决数据集和模型参数规模不断增长的问题,研究人员创造了各种分布式训练策略。而主要的训练框架,如 TensorFlow 和 PyTorch,提供了实现这些训练策略的 SDK。借助这些训练 SDK,数据科学家可以编写跨多个设备(CPU 或 GPU)并行运行的训练代码。

在本章中,我们将从软件工程师的角度探讨如何支持分布式训练。具体来说,我们将看到如何编写一个训练服务来在一组机器上执行不同的分布式训练代码(由数据科学家开发)。

阅读完本章后,你将全面了解分布式训练如何从数据科学家和开发者的角度进行工作。你将了解到几种分布式训练策略和分布式训练代码模式,以及训练服务如何促进不同的分布式训练代码。

4.1 分布式训练方法的类型

有三种主要类型的分布式训练方法:模型并行、数据并行和流水线并行。模型并行 是一种将神经网络拆分为多个顺序子网络并在不同设备上运行每个子网络的策略。通过这种方式,我们可以使用一组 GPU 训练大型模型。

流水线并行 是模型并行的高级版本。模型并行的一个主要问题是在训练过程中只有一个 GPU 处于活动状态;其他 GPU 处于空闲状态。通过将每个训练示例批次划分为小的微批次,流水线并行可以在层之间重叠计算,以最大化 GPU 性能。这允许不同的 GPU 同时处理各种微批次。GPU 的训练吞吐量和设备利用率得到提高,从而比模型并行更快地进行模型训练。

数据并行 将数据集分成较小的部分,并让每个设备单独训练这些子数据集。因为现在每个设备训练的是较小的数据集,所以训练速度得到了提高。

将单设备训练代码转换为模型并行化或流水线并行化训练需要进行大量的代码改动,包括将神经网络分割为多个子网络,在不同的 GPU 上运行子网络,并将子网络的计算输出复制到不同的 GPU 上。这些改动的数量之多以及复杂性使得它们难以处理和调试。每个模型算法可能具有截然不同的模型架构,因此没有标准化的方法可以用于模型并行化或流水线并行化的模型分割。数据科学家必须逐个案例构建代码。

相反,数据并行化仅需要在单设备训练代码上进行最少的代码改动。而且,有标准化的模式可以将非分布式训练代码转换为数据并行化,而无需更改模型算法或架构。此外,数据并行化代码相对易于理解和调试。这些优点使得数据并行化成为我们分布式训练的首选。

尽管数据并行化有很多优点,模型并行化和流水线并行化也各自具有自己的优势和用途。例如,当您有无法适应一个 GPU 的大型模型时,它们是最佳的分布式解决方案。我们将在第 4.4 节中更详细地讨论它们。

4.2 数据并行化

在本节中,我们将研究数据并行化理论及其并行执行的挑战,以及 PyTorch、TensorFlow 和 Horovod 中的示例训练代码。

4.2.1 理解数据并行化

数据并行化涉及到一组训练设备在一个大数据集上一起工作。通过让每个设备处理数据集的一个子集,我们可以大大减少训练时间。

同步数据并行化是最常采用的数据并行化方法。它将模型网络复制到训练组中的每个设备上,无论是 GPU 还是 CPU。数据集被分割成小批量,并在所有设备上(再次是 CPU 或 GPU)上分发这些批量。训练步骤同时进行,每个设备上使用不同的小批量;因此,设备充当自己的数据分区。在计算梯度以更新神经网络时,算法通过从每个设备聚合梯度来计算最终梯度。然后,它将聚合梯度分发回每个设备,以更新其本地神经网络。虽然每个设备上的训练数据集是不同的,但这些设备上的本地神经网络是相同的,因为它们在每个训练迭代中都是由相同的梯度更新的。因此,这个过程被称为同步数据并行化。

您可以在图 4.1 中可视化这个过程。该图比较了在单个 GPU 上进行深度学习训练的过程(左侧的图(a))与使用三个 GPU 进行同步数据并行训练的设置(右侧的图(b))。

图 4.1 同步数据并行概念图。(a)在单 GPU 上进行深度学习训练。(b)使用三个 GPU 进行同步数据并行训练。

通过比较图(a)和(b),您会发现与单设备训练相比,同步数据并行引入了两个额外步骤。第一个额外步骤是将一个训练批次分成三个小批次,这样每个设备就可以处理自己的小批次。第二步是同步来自所有机器的聚合梯度,以便它们在更新本地模型时都使用相同的梯度。

注意:为了聚合不同工作者计算的梯度,您可以使用算法 all-reduce。这是一种流行的算法,它独立地将所有进程的数据数组组合成一个单一数组。在“使用 PyTorch 编写分布式应用程序”(pytorch.org/tutorials/intermediate/dist_tuto.html)中,您可以找到 PyTorch 支持 all-reduce 算法的示例。

从实现的角度来看,数据并行只需要对单设备模型训练过程进行最少的更改。其主要开销是添加了梯度聚合的步骤。

模型参数更新:同步 vs.异步

对于在数据并行中跨工作者聚合梯度有两种思路:同步更新和异步更新。让我们分别看看它们是如何工作的,以及它们的优点和缺点,这样您就可以自行选择:

  • 同步模型更新—如图 4.1 所示,同步模型更新在梯度同步步骤暂停训练迭代,直到所有设备接收到聚合梯度。然后它继续下一步,更新模型参数。通过这种方式,所有设备在同一时间获得相同的梯度更新,从而确保每个工作者的模型在每个训练迭代中都是一致的。同步模型更新的问题是显而易见的:当梯度在工作者之间同步时,训练迭代被阻塞,因此没有一个工作者可以开始处理下一个数据小批次。如果存在一些慢机器或网络问题,则整个分布式工作组都会受阻,而较快的工作者则处于空闲状态。

  • 异步模型更新 — 与之相反,异步模型更新方法不强制每个训练设备或工作器等待接收来自其他设备的梯度。相反,每当一个设备完成梯度计算后,它立即更新本地模型而无需检查其他设备。每个设备都独立工作,尽管它的梯度仍然需要复制到每个其他设备,但这些更新的同步不是必要的。异步方法可能看起来很吸引人;它简单,并且可以比同步方法每分钟运行更多的训练步骤。异步方法的缺点是训练时间较长,并且产生的模型比同步模型更新方法产生的模型不准确。

当我们使用异步方法时,梯度在不同设备上独立计算。一些机器运行速度更快,而其他机器运行速度较慢;因此,这些梯度可以来自每个设备的不同训练迭代。因此,无法保证聚合的梯度将指向最佳方向。例如,假设来自慢速机器的梯度是从训练迭代 5 计算的,而其他更快的机器已经移动到训练迭代 20。当我们聚合所有工作器的梯度时,低迭代的梯度会应用于高迭代的梯度;这会降低梯度质量。

此外,异步方法通常收敛速度较慢,并且比同步方法有更高的准确度损失。因此,今天大多数数据并行库都在执行同步模型更新。在本章中,当我们提到数据并行和其代码实现时,我们指的是同步数据并行。

数据集和模型的内存约束

在深度学习中,数据集和模型在训练过程中消耗计算实例的大部分内存。如果训练数据或神经网络(模型)超出了本地设备的内存限制,训练过程将被终止,出现内存不足(OOM)错误。数据并行旨在提高训练速度,但不能解决内存约束问题。

对于由加载数据集引起的 OOM,我们可以减少训练数据的批量大小,因此训练过程在每个训练循环中加载较小量的数据到本地内存中。在数据并行背景下,我们需要确保小批量训练数据可以适合每个工作器设备的内存。

对于由模型大小引起的 OOM,我们需要采用模型并行或管道并行(见 4.4 节)。当神经网络(模型)的大小超过单个设备的内存限制时,数据并行简单地无法工作。

4.2.2 多工作器训练挑战

容错性和带宽饱和是我们作为软件开发者在执行训练服务中的数据并行代码时需要解决的两个挑战。 解决这两个挑战对于降低运营成本和改善数据并行性训练的性能至关重要。

容错性

我们不希望整个分布式训练组因为一个工作节点意外失败而全部失败。 这不仅会导致服务可用性问题,还会增加我们的训练成本,因为如果一个节点失败,所有其他节点的工作都会被浪费。

为了提高容错性,我们可以在每个工作节点的远程文件系统中保留每个训练步骤(即模型参数)的训练状态。 然后,如果一个工作节点失败或花费太长时间来完成一个训练迭代,我们可以重新启动该工作节点并加载其最近的先前状态。

TensorFlow 和 PyTorch 框架都具有备份和恢复功能。 作为训练服务开发者,我们可以设置远程磁盘或备份存储系统,并将访问配置传递给训练容器。 然后,在训练过程中,训练代码可以使用外部文件系统来备份或恢复状态。

带宽饱和

向分布式训练组添加更多的 GPU 和更多的机器并不总是会提高性能。 无论我们使用同步还是异步模型更新,算法都必须在每个训练迭代结束时在训练节点之间通信梯度或模型参数。 在 GPU RAM 和网络之间移动数据所花费的时间最终将超过通过分割训练工作负载获得的加速。

因此,在数据并行达到最佳性能之前,可以并行发生多少个实例存在上限。 这一限制由模型参数的数量和模型的密度(模型权重中的非零值有多少)确定。 如果是一个大型、密集的模型,有大量的参数和梯度需要传输,那么它的饱和度就比较大,大于一个较小的模型或一个大型的稀疏模型。

有一些推荐的并行实例数,例如,对于神经机器翻译,在 8 个 GPU 上可以实现 6 倍的加速,对于 ImageNet 模型,在 50 个 GPU 上可以实现 32 倍的加速。 但是,我们需要通过我们自己的实验来确定最佳实验点,因为 GPU 和模型架构都在快速发展,标准推荐很快就会过时。 作为平台开发者,除了选择最佳的并行工作节点数量外,我们还有三种额外的方法来减轻带宽饱和。

首先,我们可以将并行工作者(即容器或 Pod)分组到更少的机器中,以减少网络跳数。例如,在 Kubernetes 中,您可以设置具有亲和性和反亲和性规则的nodeSelectormng.bz/qo76),以在一些选择的具有更好网络和更多计算能力的服务器上提供训练实例(Kubernetes Pod)。

第二个选择是始终将训练映像升级为使用训练框架的最新版本。诸如 PyTorch、TensorFlow 等流行框架不断发展,以减少网络中传输的数据量以进行分布式训练。注意发布说明并利用这些改进。

最后,不要低估初始化分布式组时进行微小调整可能带来的收益。例如,考虑使用 PyTorch。PyTorch 数据并行库将神经网络参数梯度分区为桶,然后在梯度同步步骤期间将桶发送到工作进程中。桶的大小决定了一次在不同设备之间传输多少数据。因此,通过选择合适的桶大小,我们可以确定设备饱和和网络饱和之间的最佳训练速度的甜蜜点。桶的大小可以在 PyTorch 分布式数据并行(DDP)组件的构造函数中配置(mng.bz/7ZB7)。

4.2.3 为不同的训练框架编写分布式训练(数据并行性)代码

在本节中,您将看到一些用于数据并行分布式训练的训练代码片段,涵盖了三个训练框架:TensorFlow、PyTorch 和 Horovod。如果这里的代码示例难以解析,不用担心。目的是体验数据科学家如何处理分布式训练。这将让您了解训练服务如何实现分布式训练。

PyTorch

PyTorch 框架具有 DDP 库,该库在模块级别实现数据并行性。DDP 包装模型对象,使其可以在多台机器上无缝运行。其训练进程可以放置在同一台机器上,也可以分布在多台机器上。

要将单设备/进程训练代码转换为数据并行-分布式训练代码,我们需要进行以下两个修改。首先,我们必须通过允许每个训练进程向主进程注册自己来初始化训练组。其中一个进程声称自己是主进程,而其他进程声称自己是工作进程。每个训练进程将在此注册阶段等待,直到所有工作进程加入分布式组。

要注册一个进程,我们需要知道总的训练进程数(world_size),该进程的唯一 ID(rank)以及主进程的地址(在环境变量中定义MASTER_ADDRMASTER_PORT)。如下所示查看代码示例:

def setup(rank, world_size):
  os.environ['MASTER_ADDR'] = 'xxxx'
  os.environ['MASTER_PORT'] = 'xxx'

  # initialize the process group, "gloo" is one of the communication 
  # backends Pytorch supports, it also supports MPI and NCCL. 
  # rank is the process’s rank, it's a globally unique id 
  # for this process. rank=0 means  master process.
  # world_size is the total number of processes in this training group.
  dist.init_process_group("gloo", rank=rank, world_size=world_size)

def cleanup():
  dist.destroy_process_group()

其次,我们使用 DDP 类来包装模型对象。PyTorch DDP 类将处理分布式数据通信、梯度聚合和本地模型参数更新:

import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP

# create model and move it to GPU
model = DpModel().to(device)

# wrap the model with DDP
ddp_model = DDP(model, device_ids=[rank])
outputs = ddp_model(data)                    ❶

# compute the loss and sync gradient with other workers.
# when 'backward' function returns, the param.grad already 
# contains synchronized gradient tensor
loss_fn(outputs, labels).backward()

❶ DDP 包装器负责分布式训练的执行。

对于高级用例,PyTorch 库提供了 API,因此您可以在较低的级别实现自己的梯度同步函数。您可以在官方教程“使用 Pytorch 编写分布式应用程序”(mng.bz/m27W) 中查看详细信息。

TensorFlow/Keras

TensorFlow 支持与 PyTorch 非常相似的分布式训练方式;它首先定义分布式训练策略(例如 MultiWorkerMirroredStrategy),然后使用该策略初始化模型。为了让策略识别分布式组中的工作节点,我们需要在每个训练进程中定义 TF_CONFIG 环境变量。TF_CONFIG 包含一个工作节点的唯一 ID 和组中所有其他工作节点的地址。请参阅以下代码:

# Step 1: define 'TF_CONFIG' environment variable to describe
# the training group and the role for the process.
# The worker array defines the IP addresses and ports of 
# all the TensorFlow servers used in this training.  
tf_config = {
  'cluster': {
    'worker': ['192.168.4.53:12345', '192.168.4.55:23456']
  },

  # A 'task' provides information of the current task and is 
  # different for each worker. It specifies the 'type' and
  # 'index' of that worker.
  'task': {'type': 'worker', 'index': 0}
}

os.environ['TF_CONFIG'] = json.dumps(tf_config)

# Step 2: define distributed training strategy,
# the MultiWorkerMirroredStrategy takes 
# care of the synchronous data parallel distributed training.
strategy = tf.distribute.MultiWorkerMirroredStrategy()

global_batch_size = per_worker_batch_size * num_workers
multi_worker_dataset = mnist.mnist_dataset(global_batch_size)

# Step 3: start the distributed training.
with strategy.scope():
  # Model building/compiling need to be within 'strategy.scope()'.
  multi_worker_model = mnist.build_and_compile_cnn_model()

multi_worker_model.fit(multi_worker_dataset, 
  epochs=3, steps_per_epoch=70)

Horovod

Horovod 是一个单一目的的分布式框架。与可以用于一系列任务的 TensorFlow 和 PyTorch 相比,例如数据处理、模型训练和模型服务,Horovod 只能专注于一个任务:使分布式深度学习训练变得快速且易于使用。

Horovod 最大的优势在于它可以与不同的训练框架一起使用,例如 TensorFlow、Keras、PyTorch 和 Apache MXNet。因此,我们可以以一种方式(Horovod 方式)配置我们的训练集群,以运行 PyTorch、TensorFlow 和其他框架的分布式训练。这里,我们只列出了两个代码片段,用于使用 TensorFlow 和 PyTorch 与 Horovod,但您可以在 Horovod 的网站上查看其他框架的示例。

让我们看看 TensorFlow 的示例。为了设置数据并行 ism-分布式训练,首先我们初始化 Horovod 训练组,它将自动找到集群中的其他 Horovod 节点。接下来,我们将 rank 0(主工作节点)的初始变量状态广播到所有其他进程。这将确保所有工作节点的一致初始化。然后我们将梯度磁带包装在分布式梯度磁带中,这将对所有工作节点上的梯度进行平均。其余的代码只是普通的 TensorFlow 训练代码。因此,请参阅以下代码(github.com/horovod/horovod/blob/master/examples):

hvd.init()                                       ❶
.. .. ..

@tf.function
def training_step(images, labels, first_batch):
   with tf.GradientTape() as tape:
       probs = mnist_model(images, training=True)
       loss_value = loss(labels, probs)

   # Wrap tape with Horovod Distributed GradientTape. 
   # This gradient tape averages gradients from all 
   # workers by using allreduce or allgather, and then 
   # applies those averaged gradients back to the local model.
   tape = hvd.DistributedGradientTape(tape)

   grads = tape.gradient(loss_value, mnist_model.trainable_variables)
   opt.apply_gradients(zip(grads, mnist_model.trainable_variables))

   # Broadcast initial variable states 
   # from rank 0 to all other processes.
   if first_batch:
       hvd.broadcast_variables(mnist_model.variables, root_rank=0)
       hvd.broadcast_variables(opt.variables(), root_rank=0)

   return loss_value

for batch, (images, labels) in \                ❷
  enumerate(dataset.take(10000 / hvd.size())): 
   loss_value = training_step(images, labels, batch == 0)
   .. .. ..

# save checkpoints only on worker 0 to 
# prevent other workers from corrupting it.
if hvd.rank() == 0:
   checkpoint.save(checkpoint_dir)

❶ 初始化 Horovod

❷ 根据 GPU 数量调整步数

以下代码是使用 PyTorch 与 Horovod 的示例。一些 PyTorch Horovod API 与 TensorFlow 不同,例如 hvd.DistributedOptimizerhvd.DistributedGradientTape。但是这些 API 来自相同的 Horovod SDK 并在幕后共享相同的工作节点机制。让我们看看 PyTorch 代码片段:

# Horovod: initialize Horovod.
import torch
import horovod.torch as hvd

# Initialize Horovod
hvd.init()
.. .. .. 

# Build model...
model = ...
optimizer = optim.SGD(model.parameters())

# Add Horovod Distributed Optimizer, this is equal
# to hvd.DistributedGradientTape(tape) 
# for Tensorflow2 
optimizer = hvd.DistributedOptimizer(optimizer,
  named_parameters=model.named_parameters())

# Broadcast parameters from rank 0 to 
#all other processes.
hvd.broadcast_parameters(model.state_dict(),
  root_rank=0)

for epoch in range(100):
   for batch_idx, (data, target) in enumerate(train_loader):
       optimizer.zero_grad()
       output = model(data)
       loss = F.nll_loss(output, target)
       loss.backward()
       optimizer.step()
   .. .. ..

尽管模型在两个不同的框架中定义——TensorFlow 2 和 PyTorch——但从这两个代码片段我们可以看出它们都使用同样的 Horovod SDK 来运行分布式训练。这里的好处在于我们可以使用一个标准方法(即 Horovod 方式)在我们的训练集群中设置分布式工作组,并且它仍然可以用于不同训练框架中编写的训练代码。

关于训练代码的两个要点

如果你在阅读这些训练代码片段时感到困惑,那没关系。作为训练服务开发人员,你不需要编写这些代码片段。我们想要从这次讨论中强调两点:

  • 尽管本节中的代码示例使用不同框架和不同 API 实现分布式训练,但代码遵循第 4.2.1 节中描述的相同数据并行范式。也就是说,代码始终(1)为每个并行训练进程设置通信组,(2)配置模型对象以在所有工作节点上聚合梯度。因此,作为开发人员,我们可以使用统一的方法为不同的训练框架设置和管理分布式训练进程。

  • 将模型训练代码从单设备训练扩展到数据并行分布式训练的工作相对较为琐碎。如今,分布式训练框架/SDK 非常强大,我们不需要实现数据并行的每一个细节,比如同步梯度,它会在整个网络中同步梯度。训练框架和 SDK 处理这些过程,使它们运行无缝。分布式数据并行训练代码几乎与单设备训练代码相同,除了配置训练组时。

4.2.4 数据并行分布式训练中的工程努力

那么在生产环境中启用数据并行分布式训练的工作是什么样子的呢?首先,它需要数据科学家和服务开发人员的联合工程努力。对于数据科学家来说,他们需要升级单设备训练代码以分布式运行,使用类似前一节中的代码片段。与此同时,服务开发人员必须增强训练服务,以自动设置分布式工作组,以允许进行分布式训练。

为了使训练服务用户友好,服务应该整合不同分布式训练框架的设置细节。因此,数据科学家只需定义他们在训练中所需的并行实例数。

让我们以 TensorFlow 分布式训练为例。从我们在第 4.2.3 节中的讨论中可以看出,每个设备上的 TensorFlow 训练代码必须将tf_config(见下面的示例)设置为环境变量。这样,在训练过程中底层的 TensorFlow 分布式库就知道如何与其他训练进程通信了:

tf_config = {
  'cluster': {
    'worker': ['192.168.4.53:12345', '192.168.4.55:23456']
  },

  # A 'task' provides information of the current task 
  # and is different for each worker. It specifies 
  # the 'type' and 'index' of that worker.
  'task': {'type': 'worker', 'index': 0}
}

从可用性的角度来看,我们不能指望数据科学家为每个分布式训练进程找出设置值——服务器 IP 地址和任务索引,特别是如果整个训练组都是动态分配的。一个训练服务应该自动为分布式训练请求创建一组计算资源,用正确的 IP 地址初始化分布式训练库,并启动训练进程。

图 4.2 是支持分布式训练的训练服务的概念图。从图中可以看到,数据科学家 Alex 发送了一个训练请求来启动分布式训练任务。然后服务(由服务开发者 Tang 构建)生成两个工作机器并分布式执行训练代码。除了准备训练代码外,Alex 还可以为训练任务指定配置,例如并行工作进程的数量和分布式训练框架的类型(TensorFlow、PyTorch 或 Horovod)。

图 4.2 分布式训练系统概览

让我们缓慢地浏览一下这张图,以更好地了解系统是如何设置的,以及谁做了什么工作。我们看到,作为工程师的 Tang 需要进行三项增强——在图 4.2 中编号为 1、2 和 3——来将训练服务从单设备训练器(正如我们在第三章中所见)转变为数据并行的分布式训练器。

第一步是更新训练服务,以在运行时按需构建分布式训练组。当服务收到分布式训练请求时,它从训练集群中为训练任务分配多个工作进程,并将训练代码分发给每个工作进程。

第二步是以编程方式为每个训练进程初始化正确的服务器 IP、端口号和训练进程 ID。这确保了分布式库(通常称为框架,比如 TensorFlow)具有足够的信息来为训练组建立内部工作进程之间的通信。正如我们在前一节中看到的,对于每个分布式训练框架,设置配置都有所不同。训练服务应该知道如何为各种框架建立内部工作进程之间的通信,这样数据科学家就可以专注于算法开发,而不用担心底层基础架构。

第三步是为每个工作节点提供远程存储,以备份和恢复其训练状态。在分布式训练中,如果一个工作节点失败,整个训练组失败,并且将浪费大量计算资源。因此,在发生硬件故障或网络问题时,使分布式训练组能够恢复是至关重要的。通过提供远程存储和备份 API,分布式训练进程可以在每个训练迭代后保存其训练状态(神经网络)。当训练过程在训练中间发生故障时,可以恢复其先前的状态并重新开始,整个训练组也将继续进行。

注意:如果你想了解更多关于数据并行的知识,你可以从以下两篇文章开始:来自 O'Reilly 的博客文章“Distributed TensorFlow: Reduce both experimentation time and training time for neural networks by using many GPU servers” by Jim Dowling(www.oreilly.com/content/distributed-tensorflow/),以及来自 Google Brain 的一篇论文“Revisiting Distributed Synchronous SGD” by Chen et al.(arxiv.org/pdf/1604.00981.pdf)。

4.3 支持数据并行-分布式训练的示例服务

在本节中,我们将扩展上一章(第 3.3 节)介绍的示例服务,以支持数据并行-分布式训练。

4.3.1 服务概述

与第 3.3 节讨论的单设备训练相比,用户工作流程保持不变。数据科学家 Alex 首先构建模型训练代码,并向训练服务发送训练请求。然后,服务运行实际训练,并在最后生成模型。

不过,有一些关键区别。首先,Alex 升级了意图分类训练代码,使其能够适用于单设备和多设备。其次,服务开发者 Tang 修改了训练服务 API,提供了一个新的参数PARALLEL_INSTANCES。该参数允许 Alex 为分布式训练设置工作组的大小。

要正确管理服务器集群,我们需要 Kubernetes 的帮助。Kubernetes 可以在工作资源分配和工作节点通信方面为我们节省大量努力。因此,我们引入了一个新组件——Kubernetes 作业追踪器,用于在 Kubernetes 中管理训练作业。你可以在图 4.3 中看到更新后的服务设计图和用户工作流程。

图 4.3(a):之前在图 3.5 中介绍的训练服务设计;(b) 在 Kubernetes 上具备分布式训练支持的更新服务设计

图 4.3 (a) 重复了我们在第 3.3 节讨论的训练服务的系统图,该系统使用 Docker 作业跟踪器在 Docker 引擎中运行训练作业。图 4.3 (b) 可视化了现在支持分布式训练的更新后的训练服务,包括 Kubernetes 和 Docker 引擎后端。Kubernetes 作业跟踪器被添加以在 Kubernetes 集群中运行分布式训练作业。该组件通过启动 Kubernetes pod 来执行训练作业,并监视和更新内存存储中的作业执行状态。

我们还对意图分类 PyTorch 训练代码进行了一些更改,以便可以分布式运行。我们将在稍后的第 4.3.5 节中简要回顾这一点。

一个很好的时间节省者是,我们不需要更改已经创建的服务 API 接口(第 3.3.3 节)。我们的用户可以简单地使用相同的 API 在 Docker 引擎和 Kubernetes 集群中训练模型。这符合我们在第三章中介绍的训练服务原则之一(第 3.1.2 节):使用统一的 API 并使其在后端实现上保持不可知性。

4.3.2 与服务交互

首先,让我们使用 Kubernetes 后端运行训练服务;请参见以下命令(scripts/ts-001-start-server-kube.sh):

$ docker build -t orca3/services:latest -f services.dockerfile .
$ docker run --name training-service -v \
    $HOME/.kube/config:/.kube/config --env \     ❶
    APP_CONFIG=config-kube.properties \
    --network orca3 --rm -d -p 
  "${TS_PORT}":51001 
  orca3/services:latest training-service.jar

❶ 本地 Kubernetes 配置

注意 本节仅包含运行示例服务所需的主要步骤和关键命令。因此,可以清晰地演示概念,而不需要冗长的代码页和执行输出。如果您想在本节运行实验,请按照 orca3/MiniAutoML 存储库中的 “Distributed trainer training demo”(github.com/orca3/MiniAutoML/blob/main/training-service/distributed_trainer_demo.md)文档中的说明进行操作。

一旦训练服务容器正在运行,我们就可以提交一个训练 gRPC 请求。尽管服务现在在 Kubernetes 后端运行,但训练 API 仍然保持不变。与我们发送给 Docker 后端演示的训练请求(请参见第 3.3.1 节)相比,请求有效负载中仅添加了一个额外参数 — PARALLEL_INSTANCES=3。这告诉训练服务创建一个包含三个工作节点的分布式训练组来训练模型。如果我们将此参数设置为 1,则是单设备训练请求。查看以下代码片段以提交一个包含三个并行实例的分布式训练请求(scripts/ts-004-start-parallel-run.sh 1):

# submit a distributed training request
$ grpcurl -plaintext -d "{ "metadata": 
  { "algorithm":"intent-classification",
    "dataset_id":"1",
    "Name":"test1",
    "train_data_version_hash":"hashBA==",
    "Parameters":{
      "LR":"4","EPOCHS":"15",
      "PARALLEL_INSTANCES":"3",         ❶
    "BATCH_SIZE":"64","FC_SIZE":"128"}}
  }" 
 ${TS_SERVER}:${TS_PORT} 
training.TrainingService/Train 

❶ 需要一个包含三个工作节点的训练组

要检查训练执行的进度,我们可以使用 GetTrainingStatus API:

grpcurl -plaintext -d "{"job_id": "$1"}"    ❶
 ${TS_SERVER}:"${TS_PORT}" 
training.TrainingService/GetTrainingStatus 

❶ 提供作业 ID 以查询状态

除了查询训练服务 API 以获取作业执行状态外,我们还可以在 Kubernetes 中检查训练进度。使用 Kubernetes 命令kubectl get all,我们可以看到在本地 Kubernetes 环境中创建了三个工作 Pod。其中一个是主工作 Pod,另外两个是普通工作 Pod。还创建了一个 Kubernetes 服务对象 intent-classification-1-master-service 用于主工作 Pod,它使主 Pod 和工作 Pod 之间具有网络连接性。代码段如下:

# check Kubernetes resources status. 
# We could see a distributed training group contains 
# with three pods and one service are created in Kubernetes
$ kubectl get all -n orca3
NAME                                   READY   STATUS
pod/intent-classification-1-1-worker   0/1     Completed          ❶
pod/intent-classification-1-2-worker   0/1     Completed
pod/intent-classification-1-master     0/1     Completed          ❷

NAME                                             TYPE       .. ..   
service/intent-classification-1-master-service   ClusterIP        ❸

❶ 工作 Pod 的其中之一

❷ 控制训练 Pod

❸ 用于训练 Pod 通信的 Kubernetes 服务

4.3.3 启动训练作业

现在,让我们来看看使用 Kubernetes 后端启动训练作业的工作流程。当接收到训练请求时,将该请求添加到作业队列中。同时,Kubernetes 作业跟踪器会监视作业队列。当跟踪器发现等待的作业并且系统具有可用的容量时,它将开始处理这些作业。

要启动一个 PyTorch 分布式训练作业,跟踪器首先创建所需数量的 Kubernetes Pod。每个 Pod 托管一个训练进程。跟踪器还向每个 Pod 传递独立的参数,然后将作业从作业队列移动到启动列表(图 4.4)。

图 4.4 显示了在 Kubernetes 中启动训练作业的工作流程:第 1 步,在作业队列中检测等待的作业;第 2 步,创建 Kubernetes Pod 来运行训练;第 3 步,将作业从作业队列移动到启动列表。

在图 4.4 中,Kubernetes 作业跟踪器可以处理单设备训练和分布式训练。它为单设备训练创建一个 Kubernetes Pod,并为分布式训练创建多个 Pod。

一个 Kubernetes 作业跟踪器类似于一个 Docker 作业跟踪器,运行一个训练 Pod。它将所有用户定义的参数封装在环境变量中,并将它们传递到 Kubernetes Pod。

要使用多个 Pod 设置 PyTorch 分布式训练,该服务处理两个功能。首先,它创建一个 Kubernetes 服务对象来与主 Pod 通信。从 PyTorch 分布式训练算法部分(4.2.3)我们知道,每个 PyTorch 训练过程都需要主过程(Pod)的 IP 地址来初始化分布式训练组。例如,在训练逻辑开始之前,每个 PyTorch 代码需要具有以下代码片段:

def setup(rank, world_size):
    os.environ['MASTER_ADDR'] = 'xxx xxx xxx xxx'
    os.environ['MASTER_PORT'] = '12356'
    dist.init_process_group("gloo",       ❶
      rank=rank, world_size=world_size)   ❶

❶ 加入到分布式组中的当前进程,通过寻找主 Pod

但是在 Kubernetes 中,pod 是一个短暂的资源,所以我们不能依赖 pod 的 IP 地址来定位一个 pod。相反,我们使用 Kubernetes 域名服务 (DNS) 作为永久地址来定位 pod。即使 pod 在不同节点被销毁和重建,IP 不同,我们仍然可以使用相同的 DNS 来达到它。所以,为了启用训练组的初始化,我们首先为主 pod 创建一个 Kubernetes 服务,然后将 DNS 传递给所有工作节点作为主 pod 地址。

其次,它向每个 pod 传递了四个环境变量。每个训练 pod 需要的四个变量是 WORLD_SIZERANKMASTER_ADDRMASTER_PORT

  • WORLD_SIZE 表示训练组中包括主节点和工作节点在内的总 pod 数。

  • RANK 是一个训练过程的唯一 ID;主进程的 rank 必须为 0。

  • MASTER_ADDRMASTER_PORT 定义了主进程的主机地址和端口号,因此每个工作节点可以用它们来达到主 pod。

举例来说,当使用三个实例进行分布式训练时,我们为每个 pod 创建三个环境变量(一个主节点,两个工作节点):

Master Pod: 
  WORLD_SIZE:3; RANK:0, 
  MASTER_ADDR: intent-classification-1-master-service, 
  MASTER_PORT: 12356
Worker Pod 1: 
  WORLD_SIZE:3; RANK:1, 
  MASTER_ADDR: intent-classification-1-master-service, 
  MASTER_PORT: 12356
Worker Pod 2: 
  WORLD_SIZE:3; RANK:2, 
  MASTER_ADDR: intent-classification-1-master-service, 
  MASTER_PORT: 12356

综合以上各种解释,让我们一起来看看实际代码是如何实现的。以下列表突出了在 Kubernetes 中如何实现启动分布式训练。

列表 4.1 启动分布式训练作业

protected List<String> launchTrainingPods(
  int jobId, int worldSize, TrainingJobMetadata metadata, .. ..) {
  .. .. ..

  // It's a distributed training if the worldSize is greater than 1.
  if (worldSize > 1) {                                              ❶
    // .. .. .. 
    api.createNamespacedService(                                    ❷
      config.kubeNamespace, serviceBody,                            ❷
      null, null, null);                                            ❷

    serviceTracker.add(masterServiceName);
    logger.info(String.format("Launched master service %s", masterServiceName));
       .. .. ..
  }

  // create training pods definition
  for (int rank = 0; rank < worldSize; rank++) {
    envs.put("WORLD_SIZE", Integer.toString(worldSize));            ❸
    // RANK 0 is master
    envs.put("RANK", Integer.toString(rank));                       ❸
    envs.put("MASTER_ADDR", masterPodDnsName);                      ❸
    envs.put("MASTER_PORT", Integer.toString(masterPort));          ❸

    V1PodSpec podSpec = new V1PodSpec()                             ❹
      .restartPolicy("Never")                                       ❹
      .addContainersItem(new V1Container()                          ❹
        .image(algorithmToImage(                                    ❹
           metadata.getAlgorithm()))                                ❹
        .env(envVarsToList(envs)) .. .. ..

    String workerPodName = rank == 0 ? masterPodName :
      String.format("job-%d-%d-%s-worker-%d", jobId, 
        now, metadata.getName(), rank);
    V1Pod workerPodBody = new V1Pod();
    workerPodBody.apiVersion("v1");
       .. .. ..

    // (3) 
    api.createNamespacedPod(config.kubeNamespace,                   ❺
      workerPodBody, null, null, null);                             ❺
       .. .. ..
  }
  return podNames;
}

❶ World size >1: 表示这是一个分布式训练

❷ 创建一个 Kubernetes 服务,并指向主 pod

❸ 设置与分布式训练相关的配置作为环境变量

❹ 定义 pod 配置;将训练参数作为环境变量传递

❺ 创建实际的训练 pod

RANK 值不一定一一对应于 pod

RANK 是分布式训练中的一个棘手变量。请注意,RANK 是训练过程的唯一 ID,而不是一个 pod。如果一个 pod 拥有多个 GPU,则可以运行多个训练过程。在这个示例中,因为我们每个 pod 运行一个训练过程,所以我们为每个 pod 分配一个不同的 RANK 值。

当我们在一个 pod 中运行多个训练过程时,我们需要为一个 pod 分配多个 RANK 值。例如,当我们在一个 pod 中运行两个进程时,这个 pod 需要两个 RANK 值,一个用于每个进程。

您可能注意到,此示例中创建的 Kubernetes pod 和服务是针对 PyTorch 分布式训练库进行定制的。实际上,该示例服务并不局限于 PyTorch。为了支持使用其他框架编写的训练代码,例如 TensorFlow 2,我们可以扩展 Kubernetes 作业跟踪器以支持 TensorFlow 分布式训练的设置。

例如,我们可以收集所有工作 pod 的 IP 或 DNS,并将它们放在一起,然后将它们广播回每个工作 pod。在广播期间,我们将工作组信息设置为每个 pod 中的TF_CONFIG环境变量,以启动分布式训练组。TF_CONFIG环境变量是 TensorFlow 分布式库的特殊要求。

4.3.4 更新和获取作业状态

创建训练 pod 后,Kubernetes 作业跟踪器将继续查询 pod 执行状态,并在其状态更改时将作业移动到其他作业列表中。例如,如果 pod 成功创建并开始运行,则跟踪器将作业从启动列表移动到运行列表。如果 pod 执行完成,则跟踪器将作业从运行列表移动到已完成作业列表。图 4.5 描绘了这个过程。

图 4.5 跟踪 Kubernetes 训练作业状态:第 1 步,获取运行列表中的作业;第 2 步,查询运行在 Kubernetes 集群中的每个作业的 pod 执行状态;第 3 步,如果 pod 执行完成(成功或失败),则将作业移动到已完成作业列表中。

当用户提交作业状态查询时,训练服务将在内存存储中的所有四个作业队列中搜索作业 ID 并返回作业对象。有趣的是,尽管有多个训练 pod,我们只需要检查主 pod 的状态来跟踪分布式训练的进度。这是因为,在同步数据并行训练中,所有工作节点在每个训练周期都必须相互同步,因此主 pod 可以代表其他工作节点。

查询和更新作业执行状态的代码与我们在第 3.3.5 节中看到的 Docker 作业跟踪器非常相似。唯一的区别是,我们查询 Kubernetes 集群而不是 Docker 引擎来获取训练状态。我们将代码留给您去探索;您可以在KubectlTracker类的updateContainerStatus方法中找到它。

4.3.5 将训练代码转换为分布式运行

我们对我们的意图分类训练代码(在上一章节,第 3.3.6 节中介绍)进行了两处更改,以支持分布式模式和单设备模式。

第一次更改:初始化训练组

我们使用WORLD_SIZE环境变量来检查训练代码是否应在分布式训练中运行。如果 world size 等于 1,则我们使用与第 3.3.6 节中看到的相同的单设备训练代码。

但如果值大于 1,我们将初始化训练过程以加入分布式组。还请注意,从训练服务(Kubernetes 作业跟踪器)传递给每个 pod 的唯一RANK值,这对于分布式组的初始化是必需的。在自注册到分布式组后,我们还将模型和数据采样器声明为分布式。参见以下代码的更改:

def should_distribute():
   return dist.is_available() and config.WORLD_SIZE > 1

def is_distributed():
   return dist.is_available() and dist.is_initialized()

if should_distribute():
   # initialize the distributed process group, 
   # wait until all works are ready. 
   dist.init_process_group("gloo", 
     rank=config.RANK, world_size=config.WORLD_SIZE)

if is_distributed():
   # wrap the model with DistributedDataParallel (DDP) 
   # package to enable data parallel training. 
   model = DDP(model)

if is_distributed():
   # restricts data loading to a subset of the dataset 
   # exclusive to the current process
   train_sampler = DistributedSampler(
     dataset=split_train_, num_replicas=config.WORLD_SIZE,
     rank=config.RANK)

第二个改变:只从主节点上传最终模型

在第二个改变中,我们只允许主节点(rank = 0)上传最终模型。这是为了防止每个工作节点多次上传相同的模型:

if config.RANK == 0:                       ❶
   accu_test = evaluate(test_dataloader)
   .. .. ..
   # upload model to metadata store. 
   artifact = orca3_utils.create_artifact(
     config.MODEL_BUCKET, config.MODEL_OBJECT_NAME)
   .. .. .. 

❶ Rank 0 是主节点。

4.3.6 改进

如果我们继续将这个示例服务推向生产就绪状态的路径,我们可以遵循第 4.2.2 节中的思路,努力改进容错性并减少网络带宽饱和度。我们还可以扩展 Kubernetes 作业跟踪器以支持 TensorFlow 和 Horovod 分布式训练。从训练服务的角度来看,它们并没有太大的区别,因为训练服务传递给训练代码的配置非常通用;这些信息对于所有框架都是必需的,但名称不同。只要训练服务和训练代码之间的协议清晰稳定,我们仍然可以将训练代码视为黑盒,在分布式环境中甚至可以这样处理。

4.4 不能在单个 GPU 上加载的大型模型训练

在研究领域,神经网络大小(由参数数量定义)正在迅速增长,我们不能忽视这一趋势。以 ImageNet 挑战为例,2014 年的获胜者(GoogleNet)有 400 万个参数;2017 年的获胜者(Squeeze-and-Excitation Networks)有 1.458 亿个参数;当前领先的方法有超过 10 亿个参数。

尽管我们的神经网络大小增长了近 300 倍,但 GPU 内存仅增加了 4 倍。将来我们会更频繁地遇到无法训练模型的情况,因为它无法加载到单个 GPU 上。

在本节中,我们将讨论训练大型模型的常见策略。与第 4.2 节中描述的数据并行策略不同,这里介绍的方法需要在训练代码上付出努力。

注意:虽然本节介绍的方法通常由数据科学家实现,但我们希望您仍然能够理解它们。了解这些训练技术背后的策略对于设计训练服务和训练代码之间的通信协议非常有帮助。它还为在训练服务中进行故障排除或微调训练性能提供了洞察力。为了简单起见,我们只会在概念级别上描述算法,并侧重于工程角度上的必要工作。

4.4.1 传统方法:节省内存

假设您的数据科学团队想要训练一个可以加载到您训练集群中最大 GPU 的模型;例如,他们想要在一个 10 GB 内存的 GPU 上训练一个 24 GB 的 BERT 模型。团队可以使用几种节省内存的技术来在这种情况下训练模型,包括梯度累积和内存交换。这项工作通常由数据科学家实现。作为平台开发人员,您只需了解这些选项。我们将简要描述它们,这样您就会知道何时建议使用它们中的每一种。

注意还有其他几种节省内存的方法,例如 OpenAI 的梯度检查点(github.com/cybertronai/gradient-checkpointing)和 NVIDIA 的 vDNN(arxiv.org/abs/1602.08124),但由于本书不涉及深度学习算法,我们将它们留给独立研究。

梯度累积

在深度学习训练中,数据集被分成批次。在每个训练步骤中,用于损失计算、梯度计算和模型参数更新,我们将整个批次的示例(训练数据)加载到内存中,并一次处理所有计算。

我们可以通过减小批量大小来减轻内存压力,例如将批次中的示例数量从 32 减少到 16。但是减小批量大小可能会导致模型收敛速度大大降低。这就是梯度累积可以帮助的地方。

梯度累积将批量示例分成可配置数量的小批量,然后在每个小批量之后计算损失和梯度。但是,它不会更新模型参数,而是等待并累积所有小批量的梯度。然后,最终,根据累积梯度更新模型参数。

让我们看一个示例,了解这如何加快流程。想象一下,由于 GPU 内存限制,我们无法使用批量大小为 32 进行训练。使用梯度累积,我们可以将每个批次分成四个小批次,每个小批次大小为 8。因为我们累积所有四个小批次的梯度,并且仅在所有四个小批次完成后更新模型,所以该过程几乎等同于使用批量大小为 32 进行训练。不同之处在于,我们一次只在 GPU 中计算 8 个示例,而不是 32 个,因此成本比使用批量为 32 的情况慢 4 倍。

内存交换(GPU 和 CPU)

内存交换方法非常简单:它在 CPU 和 GPU 之间来回复制激活。如果您不习惯深度学习术语,请将激活想象为神经网络每个节点的计算输出。其思想是仅在 GPU 中保留当前计算步骤所需的数据,并将计算结果交换到 CPU 内存以供将来使用。

在此基础上,一种名为 L2L(层到层)的新的中继式执行技术将仅在 GPU 上保留正在执行的层和中转缓冲区。整个模型和保存状态的优化器都存储在 CPU 空间中。L2L 可以大大提高 GPU 吞吐量,并允许我们在价格合理的设备上开发大型模型。如果您对此方法感兴趣,可以查阅普迪佩迪等人撰写的论文“使用新的执行算法在恒定内存中训练大型神经网络”(arxiv.org/abs/2002.05645),该论文在 GitHub 上还有一个 PyTorch 实现。

梯度累积和内存交换都是在较小的 GPU 上训练大模型的有效方法。但是,像大多数事情一样,它们会降低训练速度。由于这个缺点,我们通常只在原型设计时使用它们。

为了获得可行的训练速度,我们真的需要在多个 GPU 上分布式地训练模型。因此,在下一节中,我们将介绍一种更接近生产的方法:管道并行。它可以以令人印象深刻的训练速度分布式训练大型模型。

4.4.2 管道模型并行

在第 4.2 节中,我们讨论了最常用的分布式训练方法:数据并行。这种方法在每个设备上保留整个模型的副本,并将数据划分为多个设备。然后它聚合梯度并在每个训练步骤中更新模型。整个数据并行的方法效果很好,只要整个模型可以加载到一个 GPU 上。然而,正如我们在本节中看到的那样,我们并不总是能够做到这一点。这就是管道并行的用处所在。在本节中,我们将学习管道并行,这是一种在多个 GPU 上分布式训练大型模型的训练方法。

要理解管道并行,让我们先简要了解模型并行。这个小插曲将使我们更容易转向管道并行。

模型并行

模型并行的思想是将神经网络分割成较小的子网并在不同的 GPU 上运行每个子网。图 4.6 说明了模型并行的方法。

图 4.6 将四层全连接深度学习网络分为四个子群组;每个群组有一层,每个子群组在一个 GPU 上运行。

图 4.6 展示了模型并行的过程。首先,它将神经网络(四层)转换为四个子神经网络(单层),然后为每个单层网络分配一个专用的 GPU。通过这样做,我们在四个 GPU 上分布式地运行模型。

模型并行的概念很简单,但实际实现可能会有些棘手;它取决于网络的架构。为了让您有一个概念,下面的代码片段是一个在两个 GPU 上运行网络的虚构的 PyTorch 代码片段。

列表 4.2 是在 PyTorch 中实现模型并行的示例代码

gpu1 = 1
gpu2 = 2

class a_large_model(nn.Module):
  def __init__(self):
    super().__init__()

    # initialize the network as two subnetworks.
    self.subnet1 = ...
    self.subnet2 = ...

    # put subnetwork 1 and 2 to two different GPUs 
    self.subnet1.cuda(gpu1)
    self.subnet2.cuda(gpu2)

  def forward(x):
    # load data to GPU 1 and calculate output for 
    # subnet 1, GPU 2 is idle at the moment.
    x = x.cuda(gpu1)
    x = self.subnet1(x)

    # move the output of subnet 1 to GPU 2 and calculate
    # output for subnet 2\. GPU 1 is idle
    x = x.cuda(gpu2)
    x = self.sub_network2(x)
    return x

如列表 4.2 所示,在__init__函数中初始化了两个子网络并将其分配到两个 GPU 上,然后在forward函数中将它们连接起来。由于深度学习网络结构的多样性,不存在一种通用方法(范式)来拆分网络。我们必须逐个实现模型并行。

模型并行的另一个问题是严重浪费 GPU 资源。由于训练组中的所有设备都有顺序依赖性,一次只能有一个设备工作,这会浪费大量的 GPU 时钟周期。图 4.7 显示了使用三个 GPU 进行模型并行训练时的 GPU 利用情况。

图 4.7 模型并行训练可能导致 GPU 利用率严重下降。在这种方法中,网络被分为三个子网并在三个 GPU 上运行。由于三个 GPU 之间的顺序依赖关系,每个 GPU 在训练时间的 66%空闲。

让我们通过这张图来看看为什么 GPU 使用率如此之低。在左边,图 4.7(a)中,我们看到了模型并行设计。我们将模型网络分成三个子网络,并让每个子网络在不同的 GPU 上运行。在每个训练迭代中,当运行正向传播时,我们首先计算子网 1,然后计算子网 2 和子网 3;当运行反向传播时,梯度更新则发生在相反的顺序中。

在图 4.7(b)中,右边,你可以看到在训练过程中三个 GPU 的资源利用情况。时间轴分为两部分:正向传播和反向传播。正向传播意味着模型推断的计算,从 GPU 1 到 GPU 2 和 GPU3,而反向传播意味着模型权重更新的反向传播,从 GPU 3 到 GPU 2 和 GPU 1。

如果你在时间条上垂直观察,无论是正向传播还是反向传播,你都只能看到一个 GPU 在工作。这是因为每个子网之间存在顺序依赖关系。例如,在正向传播中,子网 2 需要等待子网 1 的输出来完成自己的正向计算,因此在 GPU 1 完成计算之前,GPU 2 将在正向传播中空闲。

无论你添加多少个 GPU,一次只能有一个 GPU 工作,这是一种巨大的浪费。这时就轮到管道并行 ism 派上用场了。管道并行 ism 通过消除这种浪费并充分利用 GPU 来使模型训练更加高效。让我们看看它是如何工作的。

管道并行 ism

管道并行 ism 本质上是模型并行 ism 的改进版本。除了将网络划分到不同的 GPU 中,它还将每个训练示例批次分成小的微批次,并在层之间重叠这些微批次的计算。通过这样做,它使所有 GPU 大部分时间都保持忙碌,从而提高了 GPU 的利用率。

这种方法有两个主要的实现:PipeDream(微软)和 GPipe(谷歌)。我们在这里使用 GPipe 作为演示示例,因为它优化了每个训练步骤中的梯度更新,并且具有更好的训练吞吐量。你可以从“GPipe:使用微批量管道并行 ism 轻松扩展”的 Huang 等人的论文中找到有关 GPipe 的更多细节(arxiv.org/abs/1811.06965)。让我们在图 4.8 中,以高层次来看一下 GPipe 是如何工作的。

图 4.8(a)显示了一个具有顺序层的示例神经网络被分区到四个加速器上。 F[k] 是第 k 个单元的组合前向计算函数。 Bk 是反向传播函数,它依赖于来自上一层的 B[k+1] 和 F[k]。 (b)naive 模型并行 ism 策略由于网络的顺序依赖关系而导致严重的利用不足。(c)流水线并行 ism 将输入 minibatch 划分为更小的微批次,使不同的加速器可以同时处理不同的微批次。 梯度在最后同步应用。 (来源:图 2,“GPipe:使用微批次管道并行 ism 轻松扩展”,Huang 等人,2019 年,arXiv:1811.06965)

图 4.8(a)描述了一个由四个子网络组成的神经网络; 每个子网络都加载在一个 GPU 上。 F 表示前向传递,B 表示后向传递,而 F[k] 和 B[k] 则在 GPUk 上运行。 训练顺序首先是前向传递,F[0] -> F[1] -> F[2] -> F[3],然后是后向传递,F[3] -> (B[3], F[2]) -> (B[2], F[2]) -> (B[1], F[1]) -> B[0]。

图 4.8(b)显示了 naive 模型并行 ism 的训练流程。 我们可以看到 GPU 严重未被利用; 在前向传递和后向传递中只有一个 GPU 被激活; 因此,每个 GPU 有 75% 的空闲时间。

图 4.8(c)显示了 GPipe 在训练操作序列中的改进。 GPipe 首先将每个训练示例批次划分为四个相等的微批次,并通过四个 GPU 进行管道处理。 图中的 F[(0,2)] 表示在 GPU 0 上使用 minibatch 2 进行前向传递计算。 在后向传递期间,基于用于前向传递的相同模型参数计算每个微批次的梯度。 关键在于它不会立即更新模型参数; 相反,它会累积每个微批次的所有梯度。 在每个训练批次结束时,我们使用来自所有四个微批次的累积梯度来更新所有四个 GPU 上的模型参数。

通过比较图 4.8(b)和(c),我们可以看到 GPU 利用率大大提高; 现在每个 GPU 有 47% 的空闲时间。 让我们看一个使用 PyTorch GPipe 实现来在两个 GPU 上训练一个 transformer 模型的代码示例(请参见下面的清单)。 为了清楚地演示这个想法,我们只保留与管道相关的代码,并将它们分成四部分。 您可以查看由 Pritam Damania 撰写的教程“使用流水线并行 ism 训练 transformer 模型的 PyTorch”来获取完整的代码(mng.bz/5mD8)。

清单 4.3 使用流水线并行 ism 训练 transformer 模型

## Part One: initialize remote communication 
# for multiple machines 
rpc.init_rpc(
  name="worker",
  # set rank number to this node, rank is the global 
  # unique id of a node, 0 is the master, 
  # other ranks are observers
  rank=0,

  # set the number of workers in the group
  world_size=1,
    .. .. ..
)

.. .. ..

## Part Two: split model to 2 subnetworks, load 
# to different GPUs and initialize the pipeline.

num_gpus = 2
partition_len = ((nlayers - 1) // num_gpus) + 1

# Add all the necessary transformer blocks.
for i in range(nlayers):
  transformer_block = TransformerEncoderLayer(emsize, 
    nhead, nhid, dropout)
    .. .. ..

  # Load first half encoder layers to GPU 0 and second hard encoder layers to GPU 1.
  device = i // (partition_len)
  tmp_list.append(transformer_block.to(device))

# Load decoder to GPU 1.
tmp_list.append(Decoder(ntokens, emsize).cuda(num_gpus - 1))
module_list.append(nn.Sequential(*tmp_list))

## Part Three: Build up the pipeline.
chunks = 8 # Set micro-batches number to 8.
model = Pipe(torch.nn.Sequential(*module_list), chunks = chunks)

.. .. ..

## Part 4: Train with pipeline
def train():
  model.train() # Turn on the train mode
    .. .. ..

  for batch, i in enumerate(range(0, nbatches, bptt)):
    data, targets = get_batch(train_data, i)
    optimizer.zero_grad()

    # Compute pipeline output,by following the pipeline setup,
    # the Pytorch framework will coordinate the network computation 
    # between GPU 0 and GPU 1.
    # Since the Pipe is only within a single host and process the "RRef"
    # returned by forward method is local to this node and can simply
    # retrieved via "RRef.local_value()".
    output = model(data).local_value()

    # Compute the loss on GPU 1.
    # Need to move targets to the device where the output of the
    # pipeline resides.
    loss = criterion(output.view(-1, ntokens), targets.cuda(1))

    # Backprop and model parameters update are the same as single GPU training.
    # The Pytorch framework hides all the details of micro-batches 
    # computation and model parameters update. 
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
    optimizer.step()

.. .. ..     

如我们从清单 4.3 中看到的,流水线并行代码比分布式数据并行 ism 复杂得多。 除了设置通信组之外,我们还需要考虑如何划分我们的模型网络并在工作进程间传输梯度和激活(对子网络的前向输出建模)。

4.4.3 软件工程师如何支持流水线并行 ism

你可能注意到我们在本节中讨论的所有方法都是用于编写训练代码的技术。因为数据科学家通常编写训练代码,所以你可能想知道作为软件开发人员,我们能做些什么来支持流水线并行训练。

首先,我们可以着手构建训练服务来自动化流水线训练的执行,并提高资源利用率(例如,始终保持 GPU 忙碌)。这种自动化包括分配工作资源,启用工作节点间的通信,以及将流水线训练代码和相应的初始化参数分发给每个工作节点(例如,工作节点 IP 地址,进程 ID,GPU ID 和工作节点组大小)。

第二,我们可以向数据科学家团队介绍新的分布式训练选项。有时候数据科学家团队对能够改善模型训练体验的新工程方法并不了解,所以在这里沟通非常重要。我们可以与团队成员合作,引导对流水线并行方法进行实验。

第三,我们可以着手改善模型训练的可用性。在 4.2.4 节中,我们讨论了分布式训练的脆弱性;它要求每个工作节点表现一致。如果一个工作节点失败,整个训练集也会失败,这是对时间和预算的巨大浪费。数据科学家会非常感激我们在训练过程监控、故障转移和故障恢复上所付出的努力。

数据并行还是流水线并行?

现在我们知道有两种主要的分布式训练策略:数据并行和流水线并行。你可能对这些概念有所了解,但可能仍然不确定何时使用它们。

我们建议始终从单个机器上进行模型训练。如果你有一个大型数据集并且训练时间很长,那么考虑分布式训练。我们总是更喜欢使用数据并行而不是流水线并行,仅仅因为数据并行的实现更简单,我们可以更快地得到结果。如果模型太大无法加载到一个 GPU 中,那么流水线并行是正确的选择。

摘要

  • 分布式训练有两种思路:数据并行和模型并行。流水线并行是模型并行的改进版。

  • 如果一个模型可以加载到一个 GPU 中,数据并行是实现分布式训练的主要方法;它易于使用并提供了很大的速度改进。

  • 使用 Kubernetes 来管理计算集群可以大大减少计算资源管理的复杂性。

  • 尽管每个训练框架(TensorFlow、PyTorch)提供了不同的配置和 API 来编写分布式训练代码,但它们的代码模式和执行流程非常相似。因此,训练服务可以采用统一的方法支持各种分布式训练代码。

  • 在封装各种训练框架的设置配置后,即使在分布式训练环境中,训练服务仍然可以将训练代码视为黑盒处理。

  • 要获取数据并行训练的进展/状态,您只需检查主工作器,因为所有工作器始终彼此同步。此外,为避免在训练工作完成后从所有工作器保存重复的模型,您可以设置训练代码,以确保仅在主工作器执行时保存模型和检查点文件。

  • Horovod 是一个优秀的分布式训练框架。它提供了一种统一的方法来运行在各种框架(如 PyTorch、TensorFlow、MXNet 和 PySpark)中编写的分布式训练代码。如果训练代码使用 Horovod 实现分布式训练,训练服务可以使用一种方法(Horovod 方法)来执行它,而不管它是用哪个训练框架编写的。

  • 可用性、弹性和故障恢复是分布式训练的重要工程问题。

  • 针对无法放入一个 GPU 的模型,有两种训练策略:节省内存的方法和模型并行的方法。

  • 节省内存的方法每次只将模型的一部分或小批量数据加载到 GPU 上——例如,梯度累积和内存交换。这些方法易于实施,但会减慢模型的训练过程。

  • 模型并行的方法将一个大型模型划分为一组子神经网络,并将它们分布到多个 GPU 上。这种方法的缺点是 GPU 利用率较低。为了克服这个问题,发明了流水线模型并行。

第五章:超参数优化服务

本章内容包括

  • 超参数及其重要性

  • 超参数优化(HPO)的两种常见方法

  • 设计一个 HPO 服务

  • 三个流行的 HPO 库:Hyperopt、Optuna 和 Ray Tune

在前两章中,我们看到了模型是如何训练的:一个训练服务管理着远程计算集群中的训练过程,并提供给定的模型算法。但模型算法和训练服务并不是模型训练的全部。还有一个组成部分我们尚未讨论过——超参数优化(HPO)。数据科学家经常忽视这样一个事实,即超参数选择可以显着影响模型训练结果,特别是当这些决策可以使用工程方法自动化时。

超参数是必须在模型训练过程开始之前设置其值的参数。学习率、批量大小和隐藏层数量都是超参数的示例。与模型参数的值(例如权重和偏置)不同,超参数在训练过程中不能被学习。

研究表明,超参数的选择值可以影响模型训练的质量以及训练算法的时间和内存要求。因此,必须调整超参数以使其对模型训练最优。如今,HPO 已经成为深度学习模型开发过程中的标准步骤。

作为深度学习组件之一,HPO 对软件工程师非常重要。这是因为 HPO 不需要对深度学习算法有深入的理解,所以工程师经常被分配到这项任务。大多数情况下,HPO 可以像黑盒子一样运行,训练代码不需要修改。此外,工程师有能力构建一个自动 HPO 机制,使 HPO 成为可能。由于要调整的超参数(学习率、epoch 数量、数据批量大小等)以及要尝试的值太多,手动调整每个超参数值是不现实的。软件工程师非常适合创建一个自动化系统,因为他们在微服务、分布式计算和资源管理方面有着丰富的经验。

在本章中,我们将重点介绍自动 HPO 的工程。我们首先介绍了与使用 HPO 工作所需的背景信息。我们深入了解超参数及其调整或优化过程。我们还将遇到一些流行的 HPO 算法,并比较了两种自动化 HPO 的常见方法:使用库和构建服务。

然后我们将开始设计。我们将看看如何设计一个 HPO 服务,包括创建 HPO 服务的五个设计原则,以及在此阶段特别重要的一个通用设计提案。最后,我们向您展示三个流行的开源 HPO 框架,如果您想在本地优化训练代码,这些框架将是完美的选择。

不同于之前的章节,本章我们不会构建一个全新的示例服务。相反,我们建议您使用开源的 Kubeflow Katib(见附录 C 中讨论)。Katib 是一个设计良好、可扩展且高度可移植的 HPO 服务,几乎可以用于任何 HPO 项目。因此,如果对您来说这是一个低成本的解决方案,我们就不需要再构建一个了。

本章应该为您提供 HPO 领域的全面视角,同时还为您提供了如何针对您的具体需求运行 HPO 的实用理解。无论您决定使用远程服务还是在本地机器上使用 Hyperopt、Optuna 或 Ray Tune 等库/框架来运行 HPO,我们都可以为您提供支持。

5.1 理解超参数

在我们学习如何调整超参数之前,让我们更清晰地了解一下超参数是什么以及它们为什么重要。

5.1.1 什么是超参数?

训练深度学习模型的过程使用两种类型的参数或数值:模型参数超参数。模型参数是可训练的,也就是说,在模型训练过程中它们的值是学习到的,并且随着模型的迭代而改变。相比之下,超参数是静态的;这些配置在训练开始之前就已经被定义和设置好了。例如,我们可以在输入参数中将训练时期设置为 30,并将神经网络的激活函数设置为 ReLU(修正线性单元)来启动模型训练过程。

换句话说,任何影响模型训练性能但无法从数据中估计的模型训练配置都是超参数。一个模型训练算法中可能有数百个超参数,包括例如模型优化器的选择—ADAM(见“Adam: A Method for Stochastic Optimization,” by Diederik P. Kingma and Jimmy Ba; arxiv.org/abs/1412.6980)或 RMSprop(见“A Look at Gradient Descent and RMSprop Optimizers,” by Rohith Gandhi; mng.bz/xdZX)—神经网络中的层数、嵌入维度、小批量大小和学习率。

5.1.2 超参数为什么重要?

超参数的值选择对模型训练结果有巨大影响。通常手动设置,这些值控制着训练算法执行的行为,并确定模型训练的速度和模型的准确度。

要亲自看到这种效果,您可以通过在 TensorFlow playground(playground.tensorflow.org)中运行模型训练来尝试不同的超参数值。在这个在线游乐场中,您可以设计自己的神经网络,并训练它以识别四种类型的图案。通过设置不同的超参数,比如学习率、正则化方法、激活函数、神经网络层数和神经元数量,您不仅会看到模型性能的变化,还会看到学习行为的变化,比如训练时间和学习曲线。要在这个游乐场中训练一个能够识别复杂数据模式(如螺旋形)的模型,我们需要非常小心地选择超参数。例如,尝试将隐藏层数量设置为 6,每层神经元数量设置为 5,激活函数设置为ReLU,数据批量大小设置为 10,正则化方法设置为L1。经过近 500 个 epochs 的训练,您会发现该模型可以对螺旋形图表进行准确的分类预测。

在研究领域,超参数选择对模型性能的影响早已有据可查。以自然语言处理嵌入训练为例。一篇论文,“Improving Distributional Similarity with Lessons Learned from Word Embeddings,”由 Levy 等人(aclanthology.org/Q15-1016.pdf)撰写,揭示了词嵌入的许多性能增益都归因于某些系统设计选择以及 HPO(Hyperparameter Optimization,超参数优化)而不是嵌入算法本身。在 NLP 嵌入训练中,这些作者发现超参数的选择比训练算法的选择更具影响力!因为超参数选择对模型训练性能非常关键,所以超参数调整现在已经成为模型训练过程中的标准步骤。

5.2 理解超参数优化

现在您已经对超参数是什么以及它们为何对模型训练如此重要有了坚实的理解,让我们转向优化这些超参数的过程。在本节中,我们将为您介绍 HPO 的步骤。我们还将看看用于优化超参数的 HPO 算法,以及执行 HPO 的常见方法。

5.2.1 什么是 HPO?

HPO,或调整,是发现一组产生最佳模型的超参数的过程。这里的最佳意味着在给定数据集上最小化预定义损失函数的模型。在图 5.1 中,您可以看到 HPO 在模型训练过程中的通用工作流程的高级视图。

图 5.1 这个 HPO 工作流程的高级视图显示,该过程本质上是一个实验,旨在找到最佳的超参数值。

从图 5.1. 可以看出,HPO 工作流程可以被可视化为一个由四个步骤构成的循环。它向我们展示了 HPO 过程是一个重复的模型训练过程,只是每次神经网络都会使用不同的超参数集进行训练。在这个过程中将发现最优的超参数集。我们通常将每次模型训练的运行称为“试验”。整个 HPO 实验是一个试验循环,在此循环中我们运行一个试验接着运行另一个试验,直到满足结束条件。

注意为了公正评估,每个 HPO 试验都使用相同的数据集。

每次试验分为四个步骤,如图 5.1. 所示。第一步是使用一组超参数值训练神经网络。第二步是评价训练输出(模型)。

在第 3 步中,HPO 过程检查是否已满足结束条件,例如是否已用完试验预算,或者是否在此试验中产生的模型已达到我们的性能评估目标。如果试验结果满足结束条件,则试验循环中断,实验结束。产生最佳模型评估结果的超参数值被视为最优超参数。

如果未满足结束条件,则过程进入步骤 4:HPO 过程将产生一组新的超参数值,并通过触发模型训练运行来开始一个新的试验。每个试验中使用的超参数值可以通过手动或自动由 HPO 算法生成。让我们在接下来的两个部分中更详细地看看这两种方法和 HPO 算法。

手动 HPO

作为数据科学家,我们经常手动选择超参数值来运行图 5.1. 中的 HPO 过程。尽管,可以承认的是,手动选择最佳超参数值更像是即兴表演而不是科学。但是我们也在借鉴我们的经验以及从其中获得的直觉。我们通常会使用经验性的超参数值开始模型训练,例如在相关的已发表论文中使用的值,然后进行一些微小的调整并测试模型。经过几次试验后,我们手动比较模型性能并从这些试验中选择表现最佳的模型。图 5.2. 演示了这个工作流程。

图 5.2. 手动选择超参数值可能会很繁琐且耗时。

手动 HPO 的最大问题在于我们不知道我们的超参数值是否最佳,因为我们只是选择一些经验值并对其进行微调。为了获得最优值,我们需要尝试所有可能的超参数值,也就是搜索空间。在图 5.2 的示例中,我们想要优化两个超参数:学习速率和数据集批处理大小。在 HPO 过程中,目标是找到产生最佳模型的 batch_sizelearning_rate 对。假设我们将 batch_size 的搜索空间定义为 {8, 16, 32, 64, 128, 256},并将 learning_rate 的另一个搜索空间定义为 {0.1, 0.01, 0.001, 0.5, 0.05, 0.005}。那么我们需要验证的超参数值的总数是 36(62)。

因为我们要手动运行 HPO,我们必须运行模型训练过程(HPO 试验)36 次,并记录每个试验中使用的模型评估结果和超参数值。完成所有 36 次试验并比较结果后,通常是模型准确率,我们找到了最佳的 batch_sizelearning_rate

手动运行整个超参数搜索空间的 HPO 可能会耗时、容易出错且繁琐,正如你所见。此外,深度学习超参数通常具有复杂的配置空间,通常由连续、分类和条件超参数的组合以及高维度组成。目前,深度学习行业正在向自动 HPO 迈进,因为手动 HPO 简单不可行。

自动 HPO

自动 HPO 是利用计算能力和算法自动找到训练代码的最佳超参数的过程。这一想法是使用高效的搜索算法在没有人类干预的情况下发现最佳超参数。

我们还希望自动 HPO 以黑盒方式运行,因此它对其正在优化的训练代码无知,因此我们可以轻松地将现有的模型训练代码引入到 HPO 系统中。图 5.3 显示了自动化的 HPO 工作流程。

图 5.3 自动化的 HPO 工作流程

在第 1 步,数据科学家向自动 HPO 系统提交 HPO 请求,该系统以黑盒方式运行 HPO 过程(图 5.3)。他们将要优化的超参数及其值搜索空间输入黑盒(图 5.3 中的“自动 HPO”框)--例如,学习速率的搜索空间可能是 [0.005, 0.1],数据集批处理大小的搜索空间可能是 {8, 16, 32, 64, 128, 256}。数据科学家还需要配置训练执行,例如训练代码;评估方法;退出目标;以及试验预算,比如这次实验总共 24 次试验。

一旦用户提交了 HPO 请求,HPO 实验(步骤 2)就会开始。HPO 系统安排所有试验并管理其训练执行;它还运行 HPO 算法为每个试验生成超参数值(从搜索空间中挑选值)。当试验预算用完或达到训练目标时,系统会返回一组最优的超参数值(步骤 3)。

自动 HPO 依赖于两个关键组件:HPO 算法和试验训练执行管理。使用高效的 HPO 算法,我们可以使用更少的计算资源找到最优的超参数值。通过使用复杂的训练管理系统,数据科学家可以在整个 HPO 过程中无需手动操作。

注意:由于手动 HPO 的低效性,自动 HPO 是主流方法。为了简洁起见,在本章的其余部分中,我们将使用术语 HPO 来指代“自动超参数优化”。

5.2.2 流行的 HPO 算法

大多数 HPO 算法可以归类为三个桶:无模型优化、贝叶斯优化和多途径优化。

注意:因为本章的主要目标是教授 HPO 工程,所以这里讨论的 HPO 算法将保持在高级别。本节的目标是为您提供足够的 HPO 算法背景知识,以便能够构建或设置 HPO 系统。如果您想了解算法背后的数学推理,请查看 AutoML: Methods, Systems, Challenges 一书的第一章“Hyperparameter Optimization”,作者是 Matthias Feurer 和 Frank Hutter (mng.bz/AlGx),以及 Bergstra 等人的论文“Algorithms for Hyper-Parameter Optimization” (mng.bz/Zo9A)。

无模型优化方法

在无模型方法中,数据科学家不对训练代码做任何假设,并忽略 HPO 试验之间的相关性。网格搜索和随机搜索是最常用的方法。

在网格搜索中,用户为每个超参数指定了一组有限的值,然后从这些值的笛卡尔积中选择试验超参数。例如,我们可以首先指定学习率的值集(搜索空间)为{0.1, 0.005, 0.001},数据批量大小为{10, 40, 100},然后用这些集合的笛卡尔积(作为网格值)构建网格,例如 (0.1, 10),(0.1, 40),和 (0.1, 100)。构建网格后,我们可以使用网格值开始 HPO 试验。

当超参数数量变大或参数的搜索空间变大时,网格搜索会遇到困难,因为在这种情况下所需的评估数量会呈指数增长。网格搜索的另一个问题是其效率低下。因为网格搜索将每组超参数候选视为相等,所以它会在非最优配置空间浪费大量计算资源,而在最优空间上则没有足够的计算资源。

随机搜索通过在超参数配置空间中随机采样,直到搜索的某个预算用尽为止来工作。例如,我们可以将学习速率的搜索空间设置为[0.001, 0.1],数据批量大小设置为[10, 100],然后将搜索预算设置为 100,这意味着它将运行总共 100 次 HPO 试验。在每次试验中,会在 0.001 和 0.1 之间随机选择一个值作为学习速率,并在 10 和 100 之间随机选择一个值作为数据批量大小。

此方法比网格搜索有两个优点。首先,随机搜索可以评估每个超参数的更多值,这增加了找到最优超参数集的机会。其次,随机搜索具有更简单的并行化要求;因为所有评估工作者可以完全并行运行,它们无需彼此通信,并且失败的工作者不会在搜索空间中留下空缺。但是在网格搜索中,失败的工作者可以跳过分配给 HPO 工作者的试验超参数。

随机搜索的缺点是不确定性;不能保证在有限的计算预算内找到最优的超参数集。理论上,如果我们允许足够的资源,随机搜索可以在搜索中添加足够的随机点,因此它将如预期地找到最优超参数集。在实践中,随机搜索被用作基线。

图 5.4 网格搜索和随机搜索的比较,以最小化具有一个重要参数和一个不重要参数的函数。(来源:Matthias Feurer 和 Frank Hutter 的“超参数优化”的图 1.1。在AutoML: Methods, Systems, Challenges中,由 Frank Hutter,Lars Kotthoff 和 Joaquin Vanschoren 编辑; Springer, 2019. www.automl.org/wp-content/uploads/2019/05/AutoML_Book_Chapter1.pdf

图 5.4 展示了网格搜索和随机搜索之间的比较。网格搜索中的试验超参数候选项(黑色点)是重要参数值(行中)和不重要值点(列中)的笛卡尔积。它们的分布可以看作是搜索空间中的一个网格(白色方形画布)。随机搜索算法从搜索空间中随机获取超参数候选项。当给定足够的搜索预算时,其搜索点更有可能接近最优位置。

基于模型的贝叶斯优化

贝叶斯优化是一种用于全局优化昂贵黑箱函数的最先进的优化框架。它广泛应用于各种问题设置,例如图像分类,语音识别和神经语言建模。

贝叶斯优化方法可以使用不同的采样器,例如高斯过程回归 (见“高斯过程回归的直观教程”,Jie Wang; arxiv.org/abs/2009.10862) 和基于树结构的 Parzen 估计方法 (TPE),来计算搜索空间中的超参数候选。简单来说,贝叶斯优化方法使用统计方法根据过去试验中使用的值及其评估结果计算新的超参数值建议。

注意 为什么叫贝叶斯优化?贝叶斯分析 (www.britannica.com/science/Bayesian-analysis) 是一种广泛使用的统计推断方法,以英国数学家托马斯·贝叶斯 (www.britannica.com/biography/Thomas-Bayes) 命名,它允许您将有关总体参数的先验信息与样本中包含的信息的证据相结合,以指导统计推断过程。基于这种方法,乔纳斯·莫库斯 (Jonas Mockus) 在他在 1970 年代和 1980 年代的全局优化工作中引入了贝叶斯优化 (见“贝叶斯线性回归”,布鲁娜·温德瓦尔德; www.researchgate.net/publication/333917874_Bayesian_Linear_Regression) 这个术语。

贝叶斯优化方法背后的概念是,如果算法能够从过去的试验中学习,那么寻找最佳超参数的过程将更加高效。在实践中,贝叶斯优化方法可以通过较少的评估运行(试验)找到最佳超参数集,并且比其他搜索方法更稳定。图 5.5 显示了随机搜索和贝叶斯方法之间的数据采样差异。

图 5.5 随机搜索 (a) 和贝叶斯方法 (b) 的数据采样器比较,使用 10 次试验

假设最佳超参数值在 (x,y) = (0.5, 1),我们试图使用随机搜索和贝叶斯搜索找到它。在图 5.5 (a) 中,我们看到数据在搜索空间中随机抽样,其中 x := [–1.0, 1.0],y := [1, 5]。在图 5.5 (b) 中,我们看到数据在区域 (x := [0.3, 0.7],y := [1,1.5]) 中密集抽样,最佳值位于该区域。这种比较表明,在给定的搜索空间中,贝叶斯搜索更有可能找到最佳超参数,并且在有限的执行预算下,选择的(抽样的)超参数值在搜索过程中的每次实验后越来越接近最佳值。

还有其他先进的超参数优化算法,例如 Hyperband(mng.bz/Rlwv)、TPE(mng.bz/2a6a)和协方差矩阵适应进化策略(CMA-ES;mng.bz/1M5q)。尽管它们不完全遵循与贝叶斯-高斯过程方法相同的数学理论,但它们共享相同的超参数选择策略:通过考虑历史评估结果来计算下一个建议的值。

多信度优化

多信度方法提高了无模型和贝叶斯优化方法的效率。如今,在大型数据集上调整超参数可能需要几个小时甚至几天。为了加速超参数优化,开发了多信度方法。采用这种方法,我们使用实际损失函数的所谓低信度近似来最小化损失函数。因此,在超参数优化过程中我们可以跳过很多计算。

在机器学习的背景下,损失函数(www.datarobot.com/blog/introduction-to-loss-functions/)是评估训练算法对数据集建模效果的一种方法。如果模型输出(预测)与期望结果相差很远,损失函数应该输出较高的数字;否则,应该输出较低的数字。损失函数是机器学习算法开发的关键组成部分;损失函数的设计直接影响模型准确性。

尽管这种近似方法在优化性能和运行时间之间引入了一种权衡,但在实践中,加速往往超过了近似误差。有关更多详细信息,请参阅 Matthias Feurer 和 Frank Hutter 的“超参数优化”(www.automl.org/wp-content/uploads/2019/05/AutoML_Book_Chapter1.pdf)。

贝叶斯式超参数优化算法为什么有效?

Michael McCourt 的博客文章“高斯过程背后的直觉”(sigopt.com/blog/intuition-behind-gaussian-processes/)对为什么贝叶斯优化算法可以在不检查搜索空间中的每个可能值的情况下找到最佳超参数集提供了很好的解释。在某些情况下,我们观察到的实验是独立的,例如抛硬币 50 次;一个的知识并不意味着对其他的了解。但是,幸运的是,许多情况具有更有用的结构,从中以往的观察结果能够提供对未观察到的结果的见解。

在机器学习的背景下,我们假设历史实验(训练试验)结果与未来实验结果之间存在某种关系。更具体地说,我们相信存在一个数学模型来描述这种关系。虽然使用贝叶斯方法——例如,高斯过程——来建模这种关系是一个非常强的假设,但我们得到了强大的力量来做出可证明的最优预测。一个额外的好处是,我们现在有一种处理模型预测结果不确定性的方法。

注意 如果您有兴趣将贝叶斯优化应用于深度学习项目,Quan Nguyen 的书籍 贝叶斯优化实战(Manning, 2022; www.manning.com/books/bayesian-optimization-in-action)是一个很好的资源。

哪种 HPO 算法效果最好?

没有单一的 HPO 算法最好。不同的优化算法可能适用于不同的调优任务,在不同的约束条件下。其中一些变量可能包括搜索空间的外观(例如,超参数类型、值范围)、试验预算的外观以及目标是什么(最终最优性或随时最优性能)。图 5.6 显示了来自 Optuna (optuna.org/) HPO 框架的 HPO 算法选择指南。

图 5.6 来自 Optuna HPO 框架的 HPO 算法选择备忘单

在图 5.6 中,我们看到一个关于何时使用以下三种 HPO 算法的决策图:高斯过程、TPE 和 CMA-ES。由于 HPO 是一个快速发展的领域,新的高效算法随时可能被发布,因此像这样的算法选择备忘单将很快过时。例如,FLAML (github.com/microsoft/FLAML) 是一个新开发的 Python HPO 库,它在 HPO 过程中检查超参数之间的相关性;它绝对值得一试。因此,请咨询您的数据科学团队以获取最新的 HPO 算法选择指南。

注意 HPO 算法不是 HPO 工程的主要关注点。HPO 算法背后的数学可能会让人望而生畏,但幸运的是,这不是工程师的重点。通常,确定要为某个特定训练任务使用哪种 HPO 算法是数据科学家的工作。作为工程师,我们的角色是构建一个灵活、可扩展的黑盒式 HPO 系统,以便数据科学家可以轻松地使用任意 HPO 算法运行其模型训练代码。

5.2.3 常见的自动 HPO 方法

幸运的是,今天已经存在许多成熟的框架和系统用于进行 HPO。根据使用情况,它们分为两种不同的类别:HPO 库方法和 HPO 服务方法。图 5.7 说明了这两种方法。现在让我们逐一讨论它们。

HPO 库方法

在图 5.7(a)中,库方法,我们看到数据科学家自己管理 HPO 过程,从编码到执行。他们使用 HPO 库(例如 Hyperopt——一个开源的 Python HPO 库)编写整个 HPO 流程,并将其与训练代码一起集成到一个训练应用程序中。接下来,数据科学家在本地计算机或直接访问的服务器上运行此应用程序。应用程序内的 HPO 库将执行我们在图 5.3 中看到的 HPO 工作流。

图 5.7 两种不同的 HPO 方法:库 vs 服务。(a)HPO 库可以在本地计算机或经过预配置的服务器组上运行 HPO 实验(训练);(b)HPO 服务可以以完全远程和自动化的方式运行 HPO 实验。

灵活性和敏捷性是库方法的最大优势;你可以选择任何你喜欢的 HPO 算法/库,将它们集成到你的训练代码中,立即开始 HPO 过程,因为一切(训练加上超参数计算)都发生在你的本地计算机上。一些 HPO 库——例如 Ray Tune(5.4.3 节)——也支持并行分布式执行,但不是完全自动化的。这需要设置一个具有特定软件的分布式计算组,允许跨计算机通信,并且需要在每台服务器上手动启动并行过程。

库方法面临的最大挑战是可扩展性、可重用性和稳定性。HPO 需要大量的计算资源来执行其试验,因此单个服务器通常无法执行 HPO。即使具有分布功能,它仍然无法扩展。想象一下,我们想要使用 20 台服务器进行需要 10,000 次试验的 HPO 任务;我们需要在 20 台服务器上手动设置 HPO 过程,并在每次训练或 HPO 代码更改时重新设置。此外,如果 20 个并行工作中的 1 个失败,整个 HPO 工作组都会停止。为了解决这些问题,引入了 HPO 服务方法。

HPO 服务方法

现在让我们更仔细地看看 HPO 服务方法;我们为清晰起见重复图 5.7,这里呈现为图 5.8。在图 5.8(b)中,服务方法,我们看到 HPO 发生在一个远程计算集群中,由一个服务——HPO 服务管理。数据科学家只向服务提供训练代码和选定的 HPO 算法配置,并启动 HPO 作业。该服务管理计算资源分配和 HPO 工作流程(图 5.3)的执行;它跟踪每个试验的结果(模型性能指标,例如准确性),并在所有试验完成时向数据科学家返回最终的最佳超参数。

图 5.8 两种不同的 HPO 方法:库 vs 服务

该服务方法提供了真正的黑盒体验。数据科学家无需担心管理自己的服务器、设置试验工作者,以及学习如何修改训练代码以适应不同的 HPO 算法。HPO 服务会处理所有这些任务。作为 HPO 服务的用户,我们只需将参数传递给服务,然后服务会自动运行 HPO 并在最后返回最优超参数。该服务还负责自动缩放和失败试验作业的故障恢复。由于这些优点,服务方法现在是深度学习生产环境中的主导 HPO 方法。由于您现在熟悉了 HPO 的概念和方法,让我们在接下来的两节中看看如何设计 HPO 服务以及如何使用 HPO 库。

注意 HPO 不是一次性工作。如果使用不同的数据集进行训练,即使模型架构没有改变,您也需要重新进行 HPO。如果数据集发生变化,最优模型权重集也会发生变化,因此您需要进行新的 HPO 搜索工作。

5.3 设计一个 HPO 服务

现在你已经对 HPO 库方法有了很好的理解,让我们来回顾一下 HPO 服务方法。在这一节中,我们将看看如何设计一个 HPO 服务,以支持对任意模型训练进行自动和黑盒方式的 HPO。

5.3.1 HPO 设计原则

在我们查看具体的设计方案之前,让我们先来了解一下构建 HPO 服务的五个设计原则。

原则 1:训练代码不可知

HPO 服务需要对训练代码和模型训练框架保持不可知。除了支持像 TensorFlow、PyTorch 和 MPI 这样的任意机器学习框架之外,我们希望该服务能够调整任何编程语言编写的训练代码的超参数。

原则 2:在支持不同 HPO 算法方面具有可扩展性和一致性

从第 5.2.2 节的 HPO 算法讨论中,我们知道超参数搜索算法是 HPO 过程的核心。超参数搜索的效率决定了 HPO 的性能。一个好的 HPO 算法可以在少量试验中找到大量超参数和任意搜索空间的最优超参数。

由于 HPO 算法研究是一个活跃的领域,每隔几个月就会发表一个新的有效算法。我们的 HPO 服务需要轻松集成这些新算法,并将它们作为算法选项暴露给客户(数据科学家)。此外,新添加的算法在用户体验方面应该与现有算法保持一致。

原则 3:可扩展性和容错性

除了 HPO 算法之外,HPO 服务的另一个重要责任是管理用于 HPO 的计算资源——具有各种超参数值的模型训练。从 HPO 实验的角度来看,我们希望在实验级别和试验级别进行分布式执行。更具体地说,我们不仅希望以分布式和并行的方式运行试验,还希望能够以分布式方式运行单个训练试验——例如,在一个试验中进行模型训练的分布式训练。从资源利用的角度来看,系统需要支持自动缩放,以使计算集群大小能够根据当前工作负载自动调整,从而不会出现资源的过度或不足利用。

容错性也是 HPO 试验执行管理的另一个重要方面。容错性很重要,因为一些 HPO 算法需要按顺序执行试验。例如,试验 2 必须在试验 1 之后进行,因为算法需要过去的超参数值和结果来推断下一个试验开始前的超参数。在这种情况下,当一个试验意外失败——例如,由于节点重新启动或网络问题——整个 HPO 过程都会失败。系统应自动从之前的故障中恢复。常见的方法是记录每个试验的最新状态,这样我们就可以从上次记录的检查点继续恢复。

原则 4:多租户性

HPO 过程本质上是一组模型训练执行。与模型训练类似,HPO 服务必须为各种用户或组提供资源隔离。这将确保不同的用户活动保持在其边界内。

原则 5:可移植性

如今,“云中立”概念变得非常流行。人们希望在不同的环境中运行他们的模型训练工作——亚马逊网络服务、谷歌云平台和 Azure——因此我们构建的 HPO 服务需要与基础架构解耦。在这里,使用 Kubernetes 运行 HPO 服务是一个不错的选择。

5.3.2 一般 HPO 服务设计

因为 HPO 工作流程(图 5.3)非常标准且变化不大,所以 HPO 服务系统设计(图 5.9)可以应用于大多数 HPO 场景。它由三个主要组件组成:API 接口、HPO 作业管理器和超参数(HP)建议生成器。(它们在图 5.9 中分别标记为 A、B 和 C。)

图 5.9 HPO 服务的一般系统设计

API 接口(组件 A)是用户提交 HPO 作业的入口点。要启动 HPO 实验,用户向接口提交 API 请求(步骤 1);请求提供模型训练代码,如 Docker 镜像;超参数及其搜索空间;以及 HPO 算法。

HP 建议制定者(组件 C)是不同 HPO 算法的包装器/适配器。它为用户运行每个不同的 HPO 算法提供了一个统一的接口,因此用户可以选择算法而不必担心执行细节。要添加新的 HPO 算法,必须在此建议制定者组件中注册它,以成为用户的算法选项。

HPO 作业管理器(组件 B)是 HPO 服务的核心组件;它管理客户请求的 HPO 实验。对于每个 HPO 请求,作业管理器启动一个 HPO 试验循环(步骤 2)。在循环中,它首先调用 HP 建议制定者来获得建议的超参数值集合(步骤 2.a),然后创建一个试验以使用这些超参数值运行模型训练(步骤 2.b 和 2.c)。

对于每个训练试验,HPO 作业管理器都会创建一个试验对象。该对象有两个职责:首先,它收集试验执行的输出,例如训练进度、模型指标、模型准确性和尝试的超参数;其次,它管理训练过程。它处理训练过程的启动、分布式训练设置和失败恢复。

HPO 服务的端到端执行流程

让我们按照图 5.9 显示的端到端用户工作流程来走一遍。为了方便起见,我们重复了图 5.9 并将其显示为图 5.10。

图 5.10 HPO 服务的一般系统设计

首先,用户向 API 接口提交 HPO 请求(步骤 1)。该请求定义了训练代码、超参数及其值搜索空间的列表、训练目标和一个 HPO 算法。然后,HPO 作业管理器为该请求启动 HPO 试验循环(步骤 2)。该循环启动一组试验来确定哪组超参数值最好。最后,当试算预算用尽或一次试验达到训练目标时,试验循环会中断,最优超参数会被返回(步骤 3)。

在试验循环中,作业管理器首先查询 HP 建议制定者以推荐超参数候选项(步骤 2.a)。制定者将运行所选的 HPO 算法来计算一组超参数值,并将其返回给作业管理器(步骤 2.b)。然后,作业管理器创建一个试验对象,以使用建议的超参数值启动模型训练过程(步骤 2.c)。试验对象还将监视训练过程,并继续向试验历史数据库报告训练指标,直到训练完成(步骤 2.d)。当作业管理器注意到当前试验已完成时,它将拉取试验历史记录(试验指标和用于过去试验的超参数值)并将其传递给 HP 建议制定者以获得新的 HP 候选项(步骤 2.e)。

因为 HPO 的使用案例非常标准和通用,并且已经有多个开源的 HPO 项目可以直接使用,我们认为学习如何使用它们比重新构建一个没有附加值的新系统更好。因此,在附录 C 中,我们将介绍一个功能强大且高度可移植的基于 Kubernetes 的 HPO 服务——Kubeflow Katib。

5.4 开源 HPO 库

对于一个小型数据科学家团队来说,HPO 服务可能会显得太过繁重,特别是如果他们所有的模型都是在他们自己管理的几台服务器上训练的话。在这种情况下,在本地机器或托管集群(小规模,1-10 台服务器)上使用 HPO 库来优化模型训练是一个更好的选择。

在这一部分,我们将介绍三个有用的 HPO 开源库:Optuna、Hyperopt 和 Ray Tune。它们都作为 HPO 库运行,而且易于学习和简单使用。因为 Optuna、Hyperopt 和 Ray Tune 都有明确的入门文档和合适的示例,我们将重点放在一般概述和功能介绍上,这样你就可以根据自己的情况决定使用哪一个。

在接下来关于不同的 HPO 库的讨论中,特别是在“如何使用”部分,你会经常看到术语 目标函数。什么是目标函数?图 5.11 展示了这个过程。

对于 HPO 算法(例如贝叶斯搜索)来说,为了生成一个超参数建议,使得下一个试验效果更好,它需要知道上一个 HPO 试验的表现如何。因此,HPO 算法要求我们定义一个函数来评分每个训练试验,并在随后的试验中继续最小化或最大化函数的返回值(分数)。我们将其命名为目标函数。

在图 5.11 中,我们看到一个目标函数接收超参数作为输入,并返回一个浮点值,或者分数。目标函数使用给定的超参数执行模型训练,并在训练完成时评估输出模型。

图 5.11 一个目标函数接收超参数作为输入,并返回一个分数。

5.4.1 Hyperopt

Hyperopt (hyperopt.github.io/hyperopt/#getting-started) 是一个轻量级且易于使用的 Python 库,用于串行和并行 HPO。随机搜索、TPE 和自适应 TPE 是 Hyperopt 中实现的三种 HPO 算法。贝叶斯优化算法(基于高斯过程)和回归树已经设计用于适应,但在书写本书时尚未实现。

如何使用

假设你想知道哪种分类器对你的深度学习案例效果最好。我们可以使用 Hyperopt 在三个步骤中得到答案。

首先,我们创建一个目标函数,基本上是实际训练代码的包装函数,但从args变量中读取超参数值。其次,我们为选定的超参数定义搜索空间。第三,我们选择一个 HPO 算法,该算法从搜索空间中选择超参数值,并将它们传递给目标函数以启动优化过程。列表 5.1 实现了这个场景。

在这个例子中,我们想确定哪个分类器能够产生最佳的模型准确性,因此我们选择在三个候选项中优化classifier_type超参数:naive_bayessvmdtree。你可能还注意到每个分类器都有自己的值搜索空间,比如对于svm分类器,有hp.lognormal('svm_rbf_width', 0, 1)。在fmin函数中(在步骤 3 中),我们将 TPE 指定为 HPO 算法,最大试验数为 10,并将目标函数和搜索空间作为必需参数传入。

列表 5.1 Hyperopt 入门

# Step 1: define an objective function
def objective(args):
  model = train(args)                                   ❶
  return evaluate(model)                                ❶

# Step 2 define search space for hyperparameters 
space = hp.choice('classifier_type', [                  ❷
  {
    'type': 'naive_bayes',
  },
  {
    'type': 'svm',
    'C': hp.lognormal('svm_C', 0, 1),                   ❸
    'kernel': hp.choice('svm_kernel', [                 ❸
      {'ktype': 'linear'},                              ❸
      {'ktype': 'RBF',                                  ❸
       'width': hp.lognormal('svm_rbf_width', 0, 1)},   ❸
    ]),
  },
  {
    'type': 'dtree',
    'criterion': hp.choice('dtree_criterion', 
      ['gini', 'entropy']),
    'max_depth': hp.choice('dtree_max_depth',
      [None, hp.qlognormal('dtree_max_depth_int', 3, 1, 1)]),
    'min_samples_split': hp.qlognormal(
      'dtree_min_samples_split', 2, 1, 1),
  },
  ])

# Step 3 start the hpo process execution 
best = fmin(objective, space, algo=tpe.suggest,
 ➥ max_evals=100)                            ❹

❶ 用传入的超参数训练模型并评估结果

❷ 声明了三个分类器候选项

❸ 为 SVM 分类器的参数定义了搜索空间

❹ fmin 函数在选择的算法上最小化目标函数的空间。

并行化

尽管 Hyperopt 是一个独立的库,但我们可以在一组机器的集群中并行运行它。基本思想是在不同的机器上运行 Hyperopt 工作者,并让它们与一个中央数据库进行通信以协调工作。Hyperopt 也可以使用 Spark 计算来并行运行 HPO。您可以查看以下两篇文章以获取更多详细信息:“On Using Hyperopt: Advanced Machine Learning” by Tanay Agrawal (mng.bz/PxwR) 和 “Scaling Out Search with Apache Spark” (hyperopt.github.io/hyperopt/scaleout/spark/)。

何时使用

Hyperopt 是小型或早期模型训练项目的一个不错的选择。首先,它易于使用。您可以在本地机器上或直接访问的服务器上运行 HPO 的三个步骤。其次,它对修改友好。由于采用了库方法,HPO 代码与训练代码放置在同一个代码项目中。因此,尝试不同的优化方案,比如选择不同的超参数进行调整,非常方便。

5.4.2 Optuna

与 Hyperopt 类似,Optuna 也是一个轻量级的 Python 库,旨在自动化超参数搜索。它支持大空间搜索和在不被看好的试验中进行早期修剪,以及在多个线程或进程上并行执行而无需修改代码。

在我们看来,Optuna 是 Hyperopt 的一个高级版本,其可视化能力要好得多。通过在图表中检查参数之间的相互作用,超参数搜索中的可视化能够为您提供大量见解,因此您可以轻松确定哪些参数比其他参数更有效。Optuna 的可视化效果美观且交互性强。

Optuna 在文档方面也优于 Hyperopt。Optuna 的文档非常出色。除了详细的 API 文档和良好组织的教程外,它还具有维护良好的源代码。如果您查看其 GitHub 项目的问题部分,您将会发现一个非常活跃且不断增长的社区,还有许多出色的功能和 GitHub 拉取请求即将推出。

如何使用

图 5.2 展示了如何快速使用 Optuna 的三个步骤示例:步骤 1,定义目标函数;步骤 2,创建一个表示 HPO 过程的研究对象;步骤 3,启动具有最大试验配额的 HPO 过程。

与 Hyperopt 相比,Optuna 要求大部分 HPO 逻辑在目标函数中定义。通用的代码模式如下。首先,定义搜索空间,并通过trial.suggest_xxx函数生成超参数值。接下来,使用采样的超参数值开始模型训练。然后运行评估方法来计算模型性能并返回目标值。在下面的示例中,评估分数由mean_squared_error计算。您可以在github.com/optuna/optuna-examples找到更多 Optuna 示例。

图 5.2 使用 Optuna 入门

# Step 1: define an objective function
def objective(trial):

  regressor_name = trial.suggest_categorical(                  ❶
    'classifier', ['SVR', 'RandomForest'])                     ❶
  if regressor_name == 'SVR':
    svr_c = trial.suggest_float(                               ❷
      'svr_c', 1e-10, 1e10, log=True)                          ❷
    regressor_obj = sklearn.svm.SVR(C=svr_c)                   ❷
  else:
    rf_max_depth = trial.suggest_int('rf_max_depth', 2, 32)    ❸
    regressor_obj = sklearn.ensemble
      .RandomForestRegressor(max_depth=rf_max_depth)

  X_train, X_val, y_train, y_val = \
    sklearn.model_selection.train_test_split(X, y, random_state=0)

  regressor_obj.fit(X_train, y_train)                          ❹
  y_pred = regressor_obj.predict(X_val)

  error = sklearn.metrics
    .mean_squared_error(y_val, y_pred)                         ❺
  return error                                                 ❺

# Step 2: Set up HPO by creating a new study.
study = optuna.create_study() 

# Step 3: Invoke HPO process 
study.optimize(objective, n_trials=100)  

❶ 设置分类器候选项

❷ 调用 suggest_XXX 方法生成超参数

❸ 在 2 和 32 范围内选择 max_depth

❹ 使用 Optuna 回归器运行模型训练

❺ 将均方误差设置为目标值,并链接到试验对象

并行化

我们可以使用 Optuna 在一台机器或一组机器上运行分布式 HPO。分布式执行设置非常简单,可以分为三个步骤:首先,启动一个关系型数据库服务器,例如 MySQL;其次,创建一个带有存储参数的研究;第三,将研究分享给多个节点和进程。与 Hyperopt 相比,Optuna 的分布式执行设置更简单,可以从单台机器扩展到多台机器而无需修改代码。

何时使用

Optuna 可以被视为 Hyperopt 的继任者;它具有更好的文档、可视化和并行执行。对于任何可以在一个或多个机器上运行的深度学习模型训练项目,您都可以使用 Optuna 来找到最佳的超参数。

Optuna 在支持大型数据科学团队或多个 HPO 项目时会受到限制,因为它需要管理一个中央机器集群来提供计算资源。但是 Optuna 的并行/分布式执行是手动的;人们需要将代码分发到每个服务器并一次在一个服务器上执行它,手动操作。为了以自动和编程方式管理分布式计算作业,我们可以使用 Kubeflow Katib(附录 C)或 Ray Tune。

5.4.3 Ray Tune

Ray (docs.ray.io/en/latest/index.html) 提供了一个简单、通用的构建分布式应用程序的 API。Ray Tune (docs.ray.io/en/latest/tune/index.html) 是建立在 Ray 之上的 Python 库,用于任何规模的 HPO。

Ray Tune 库支持几乎所有机器学习框架,包括 PyTorch、XGBoost、MXNet 和 Keras。它还支持最先进的 HPO 算法,如基于种群的训练(PBT)、BayesOptSearch 和 HyperBand/ASHA。此外,Tune 提供了一种机制,用于集成来自其他 HPO 库的 HPO 算法,如 Hyperopt 集成。

使用 Ray 作为其分布式执行支持,我们可以用几行代码启动一个多节点的 HPO 实验。Ray 将负责代码分发、分布式计算管理和容错。

如何使用

使用 Ray Tune 执行 HPO 任务非常简单。首先,定义一个目标函数。在函数中,从config变量读取超参数值,开始模型训练,并返回评估分数。其次,定义超参数及其值搜索空间。第三,通过将目标函数和搜索空间链接起来,启动 HPO 执行。图 5.3 实现了上述三个步骤。

图 5.3 使用 Ray Tune 入门

# Step 1: define objective_function
def objective_function(config):
  model = ConvNet()                                      ❶
  model.to(device)

  optimizer = optim.SGD(                                 ❷
    model.parameters(), lr=config["lr"],                 ❷
    momentum=config["momentum"])                         ❷
  for i in range(10):
    train(model, optimizer, train_loader)                ❸
    acc = test(model, test_loader)

    tune.report(mean_accuracy=acc)                       ❹

# Step 2: define search space for each hyperparameter
search_space = {
   "lr": tune.sample_from(lambda spec: 
      10**(-10 * np.random.rand())),
   "momentum": tune.uniform(0.1, 0.9)                    ❺
}

# Uncomment this to enable distributed execution
# `ray.init(address="auto")`

# Step 3: start the HPO execution
analysis = tune.run(
   objective_function,
   num_samples=20,
   scheduler=ASHAScheduler(metric="mean_accuracy", mode="max"),
   config=search_space)

# check HPO progress and result
# obtain a trial dataframe from all run trials 
# of this `tune.run` call.
dfs = analysis.trial_dataframes

❶ ConvNet 是一个自定义的神经网络。

❷ 从输入配置中读取超参数值

❸ 开始模型训练

❹ 将评估结果(准确率)发送回 Tune

❺ 从 0.1 到 0.9 均匀地采样一个浮点值作为“动量”

在第 3 步中,你可能注意到一个调度器对象,ASHAScheduler,被传递给train.run函数。ASHA (mng.bz/JlwZ) 是一个用于原则性提前停止的可扩展算法(参见“Massively Parallel Hyperparameter Optimization,” by Liam Li; mng.bz/wPZ5)。在高层次上,ASHA 终止了不太有前途的试验,并为更有前途的试验分配时间和资源。通过适当调整参数num_samples,搜索可以更加高效,并支持更大的搜索空间。

并行化

相比于 Optuna,Ray Tune 的最大优势就是分布式执行。Ray Tune 允许你透明地在多个 GPU 和多个节点上并行执行(请参阅Ray 文档)。Tune 甚至具备无缝的容错和云支持。与 Optuna 和 Hyperopt 不同,我们不需要手动设置分布式环境,并逐台执行工作脚本。Ray Tune 会自动处理这些步骤。图 5.12 展示了 Ray Tune 如何将 HPO Python 代码分发到一组机器上。

图 5.12 Ray Tune 在一组机器上运行分布式 HPO

首先,我们用命令"ray``` up tune-cluster.yaml"建立了一个 Ray 集群;tune-cluster.yaml是一个声明集群计算资源的集群配置。然后,我们运行以下命令将 HPO 代码从本地机器提交到集群的 head 节点:"ray submit tune-cluster.yaml tune_ script.py --start -- --ray-address={server_address}"。接下来,Ray 分配资源,将 HPO 代码复制到服务器,并启动分布式执行。更多详情,请参见“Tune Distributed Experiments” ([mng.bz/71QQ`](http://mng.bz/71QQ))。

除了分布式 HPO 执行,Ray Tune 还支持单次试验的分布式训练,自动检查点管理和 TensorBoard 日志记录。这些功能为 Ray Tune 增添了巨大的价值,因为它们具有高容错性和简单的故障排除能力。

什么时候使用

与其他 HPO 库相比,Ray Tune 是否是进行 HPO 的最佳选择?暂时是的。在撰写本书时,Ray 提供了底层训练框架(如 TensorFlow 和 PyTorch)与最新的 HPO 算法(例如贝叶斯搜索和 TPE),以及提前停止(ASHA)的集成。它允许我们以简单且可靠的方式分布式地运行 HPO 搜索。

对于大多数数据科学团队,不希望拥有 HPO 服务的情况下,Ray Tune 是建议的方法。它使用简单,并且几乎满足每个模型训练项目的 HPO 需求:丰富的文档,最新的 HPO 算法,高效且简单的分布式执行管理。

注意:我们推荐使用 Ray Tune 而不是其他 HPO 库,理由如下:(1) 使用简单;(2) 文档和示例丰富;(3) 其分布式执行是自动和程序化的;(4) Ray Tune 支持单次试验的分布式训练;(5) Ray Tune 具有调度程序功能(例如,ASHAScheduler),可以通过提前终止不太被看好的试验大大降低计算成本。

Ray Tune 的局限性

当我们需要在一个共享的 HPO 系统中支持不同团队和不同的深度学习项目时,Ray Tune 和其他 HPO 库将受到限制。Ray Tune 缺乏计算隔离,这导致了两个大问题。

首先,不同训练代码的包版本可能会导致 Ray workers 之间的冲突。在 Ray Tune 中执行分布式 HPO 时,我们将 HPO 代码提交到 Ray 集群的 head 服务器,然后在集群 workers 中并行运行此代码。这意味着每个 Ray worker 服务器都需要安装每个它需要运行的训练代码的依赖库。想象一下,当您需要在一个 Ray 集群中运行 10 个不同的 HPO 任务时,工作机器需要为这 10 个不同的训练代码安装数百个包,并解决它们的版本冲突时,我们如何管理包安装和潜在的版本冲突。其次,Ray Tune 不强制执行用户隔离。在 Ray Tune 中为不同的数据科学团队建立虚拟边界以限制其计算资源使用是非常困难的。

5.4.4 后续步骤

当您遇到 HPO 库的上述问题时,是时候转向 HPO 服务了。我们强烈建议您在考虑构建自己的 HPO 之前阅读附录 C。它介绍了一个坚实的开源 HPO 服务,名为 Kubeflow Katib,这是一个设计良好的通用 HPO 服务。

摘要

  • 超参数是用于控制学习过程的参数值。这种类型的参数在模型训练中不可学习;因此,我们需要调整它。

  • HPO 是一个发现一组超参数的过程,该组超参数产生了一个最优模型,该模型在给定数据集上最小化了预定义的损失函数。

  • 自动 HPO 是使用计算资源和算法(HPO 算法)来自动找到训练代码的最佳超参数的过程。

  • 自动 HPO 现在是模型训练的标准步骤。

  • 大多数 HPO 算法可以归类为三种类型之一:无模型优化、贝叶斯优化或多态优化。

  • 没有单一最佳的 HPO 算法。不同的优化算法可能适用于不同约束条件下的不同 HPO 任务。

  • HPO 可以在库中运行,也可以在远程服务中运行。库方法简单、灵活,适用于小团队和原型阶段的项目,而服务方法适用于大型组织和生产用例。

  • HPO 服务方法提供了一个完全自动的黑盒 HPO 体验,包括计算资源管理;因此,如果您正在为大型团队构建深度学习系统,我们建议采用服务方法。

  • 构建 HPO 服务的五个设计原则是训练代码不可知、高可扩展性、高可扩展性和可靠性、HPO 执行和资源消耗隔离,以及高可移植性。

  • 为了加速 HPO 实验,我们可以并行训练不同试验的训练执行,引入分布式训练,并及早停止不太有希望的试验。

  • 我们鼓励您采用 Kubeflow Katib 作为您的 HPO 服务,而不是自己构建一个新的服务。

  • 在三个常用的开源 HPO(超参数优化)库中——Optuna、Hyperopt 和 Ray Tune 中,到目前为止,Ray Tune 被证明是最好的。

第六章:模型服务设计

本章涵盖

  • 定义模型服务

  • 常见模型服务挑战和方法

  • 设计不同用户场景下的模型服务系统

模型服务是使用用户输入数据执行模型的过程。在深度学习系统中的所有活动中,模型服务是最接近最终客户的活动。在完成了数据集准备、训练算法开发、超参数调整和测试结果生成模型的所有辛勤工作之后,这些模型由模型服务服务呈现给客户。

以语音翻译为例。在为语音翻译训练了一个序列到序列模型之后,团队准备向世界展示它。为了让人们远程使用这个模型,通常会将模型托管在 Web 服务中,并通过 Web API 公开。然后我们(客户)可以通过 Web API 发送我们的语音音频文件,并获得一个翻译后的语音音频文件。所有模型加载和执行都发生在 Web 服务后端。包括在这个用户工作流程中的一切——服务、模型文件和模型执行——都被称为模型服务

构建模型服务应用程序是另一个特殊的深度学习领域,软件工程师特别适合这个领域。模型服务使用请求延迟、可伸缩性、可用性和可操作性——所有这些都是工程师内外熟知的领域。通过一些深度学习模型服务概念的介绍,有一些分布式计算经验的开发人员可以在构建模型服务元素方面发挥重要作用。

将模型在生产中提供服务可能很具有挑战性,因为模型是由各种框架和算法训练的,因此执行模型的方法和库各不相同。此外,模型服务领域使用的术语令人困惑,有太多不同的术语,如模型预测模型推理,听起来不同但在服务上下文中意思相同。此外,有许多模型服务选项可供选择。一方面,我们有像 TensorFlow Serving、TorchServe 和 NVIDIA Triton 推理服务器等黑盒解决方案。另一方面,我们有像构建自己的预测服务或直接将模型嵌入应用程序中这样的定制方法。这些方法看起来都非常相似且功能强大,因此很难选择其中一个。因此,如果您对这个领域还不熟悉,您可能会很快迷失方向。

我们的目标是帮助你找到自己的方向。我们希望能赋予你设计和构建最适合你情况的模型服务解决方案的能力。为了实现这个目标,我们有很多内容需要介绍,包括模型服务的概念理解、服务设计考虑因素、具体示例和模型部署工作流程。为了避免让你阅读超长的一章内容,我们将这部分内容分成了两章:第六章重点关注概念、定义和设计,第七章将这些概念付诸实践,包括构建一个样本预测服务,介绍开源工具以及部署和监控模型生产。

在本章中,我们首先澄清术语,并为模型服务中使用的元素提供我们自己的定义。我们还描述了我们在模型服务领域面临的主要挑战。然后我们将转向设计方面,解释模型服务的三种常见策略,并设计一个适合不同用例的模型服务系统。

通过阅读本章,你不仅会对模型服务的工作原理有扎实的理解,还会了解到可以应对大多数模型服务用例的常见设计模式。随着概念和术语在你脑海中变得清晰,你应该可以自如地参与任何与模型服务相关的讨论,或者阅读关于这个主题的文章和论文。当然,本章也为你在下一章介绍的实际工作奠定了基础。

6.1 解释模型服务

在模型服务的工程中,术语是一个主要问题。例如,模型模型架构推理图预测推理等术语被人们使用时没有清晰地定义它们,因此它们可以具有相同的含义,也可以根据上下文(模型服务或模型训练)而指代不同的概念。当我们与数据科学家合作构建模型服务解决方案时,模型服务术语的混淆会导致很多交流不畅。在本节中,我们将从工程角度解释模型服务的核心概念,并对常用术语进行解释,以帮助你避免陷入术语陷阱。

6.1.1 什么是机器学习模型?

在学术界对机器学习模型有多种定义,从对数据集学习的精简表达到基于以前未见过的信息识别特定模式或做出决策的数学表达。然而,作为模型服务开发人员,我们可以简单地将模型理解为在训练过程中产生的一组文件的集合。

模型的概念很简单,但很多人误解模型只是静态文件。虽然模型被保存为文件,但它们并不是静态的,它们实质上是可执行的程序。

让我们分解这个语句并确定其含义。一个模型由机器学习算法、模型数据和模型执行器组成。模型执行器是机器学习算法的封装代码;它接收用户输入并运行算法来计算和返回预测结果。机器学习算法是指模型训练中使用的算法,有时也称为模型架构。再以语音翻译为例,如果翻译模型是由序列到序列网络作为其训练算法,则模型中的机器学习算法就是相同的序列到序列网络。模型数据是运行机器学习算法所需的数据,例如神经网络的学习参数(权重和偏差)、嵌入和标签类别等。图 6.1 展示了一个通用的模型结构。

图 6.1 一个模型由机器学习算法、模型执行器和模型数据组成。

注意 在本章中,我们经常简称机器学习算法为模型算法

本节中最重要的要点是,模型训练执行的输出,也就是模型,并不仅仅只是一组静态数据。相反,深度学习模型是可执行程序,包括机器学习算法及其依赖的数据,因此模型可以根据运行时的输入数据进行预测。

注意 模型不仅仅包括权重和偏差。有时数据科学家将神经网络的训练参数(权重和偏差)保存到一个文件中,并命名为“模型文件”。这会让人们误以为模型只是一个只包含权重和偏差的数据文件。权重和偏差是模型的数据,但我们还需要算法和封装代码来运行预测。

6.1.2 模型预测和推断

学术界可能认为模型推断和预测是两个不同的概念。模型推断可以指学习数据是如何生成的、理解其原因和影响,而模型预测则可能指对未来事件的预测。

一个样本模型预测的场景可能包括使用销售记录来训练一个模型,以预测哪些个体可能会对下一次营销活动做出回应。而一个样本模型推断的场景将包括使用销售记录来训练一个模型,从产品价格和客户收入的角度理解销售效果。对于模型推断来说,之前未见过的数据上的预测准确性并不是非常重要,因为主要关注的是学习数据生成过程。模型训练的目的是拟合整个数据集。

从工程的角度来看,模型预测和模型推断意味着相同。虽然模型可以被建立和用于不同的目的,但是在模型服务的上下文中,模型预测和模型推断指的是同样的行为:使用给定的数据点执行模型以获得一组输出值。图 6.2 展示了预测模型和推断模型的模型服务工作流程;正如你所见,它们之间没有区别。

图 6.2 模型预测和模型推断在模型服务工程中是相同的。

为了简化本章中插图中的文本,从图 6.2 开始,我们使用 模型 一词来表示模型数据、模型执行者和机器学习 (模型) 算法。这不仅是为了保持文本简洁,也强调了机器学习模型是可执行程序。

6.1.3 什么是模型服务?

模型服务 意味着简单地使用输入数据执行模型进行预测,这包括获取预期的模型、设置模型的执行环境、使用给定的数据点执行模型进行预测,并返回预测结果。模型服务最常用的方法是在 Web 服务中托管模型,并通过 Web API 公开模型的预测功能。

假设我们构建了一个目标检测模型,用于检测海岸图片中的鲨鱼;我们可以构建一个网络服务来托管这个模型,并公开一个鲨鱼检测的 Web API。然后,世界上任何海滨酒店都可以使用这个 Web API 来检测他们自己海岸图片中的鲨鱼。在传统上,我们称模型服务的网络服务为预测服务。

预测服务中的典型模型预测工作流程有四个步骤:接收用户请求;从工件存储加载模型到内存或 GPU;执行模型的算法;最后返回预测结果。图 6.3 展示了这个工作流程。

图 6.3 预测服务中的典型模型预测工作流程

除了四步预测工作流程之外,图 6.3 还提到了模型服务的三个主要组件:预测服务 (A)、模型工件存储 (B) 和预测 Web API (C)。模型工件存储 (组件 B) 包含模型训练生成的所有模型。Web API (组件 C) 接收预测请求。预测服务 (组件 A) 响应预测请求,从工件存储加载模型,运行模型,并返回预测结果。

虽然预测工作流程的四个步骤通常适用于所有类型的模型,但步骤的实际实现取决于业务需求、模型训练算法和模型训练框架。我们将在第 6.3 节讨论预测服务的设计选项,并在第七章中介绍两个示例预测服务。

模型服务以特殊模式运行机器学习算法。

模型训练和模型服务使用相同的机器学习算法,但是有两种不同的模式:学习模式和评估模式。

在学习模式中,我们以开环的方式运行算法,这意味着在每个训练迭代中,我们首先对神经网络(算法)运行一个输入数据样本来计算预测结果。根据预测结果与预期结果之间的差异,网络的参数(权重和偏差)会被更新以更接近数据集。

在评估模式中,神经网络(算法)在闭环中运行,这意味着网络的参数不会被更新。神经网络仅用于获取预测结果。因此从代码实现的角度来看,模型服务本质上是以评估模式运行机器学习算法(神经网络)。

6.1.4 模型服务的挑战

构建一个成本效益高的网络服务以服务模型比在我们的笔记本电脑上本地运行模型要复杂得多。以下是为网络服务提供模型所面临的六个常见挑战。

模型预测 API 根据模型算法而异。不同的深度学习算法(如循环神经网络和卷积神经网络 [CNN])需要不同的输入数据格式,其输出格式也可能不同。在设计 Web 预测 API 时,设计一个满足每种模型算法输入数据要求的统一 Web API 是非常具有挑战性的。

模型执行环境因训练框架而异。模型可以在不同的框架中进行训练,例如 TensorFlow 和 PyTorch。而每个训练框架都有其特殊的设置和配置来执行其模型。预测服务应该在其后端封装模型执行环境的设置,这样客户就可以专注于使用模型预测 API,而不是该模型所训练的框架。

有太多的模型服务工具、库和系统可供选择。如果我们决定使用现有的开源模型服务方法,那么立即出现的问题就是我们应该选择哪种方法。有 20 多种不同的选择,比如 TorchServe、TensorFlow Serving、NVIDIA Triton Inference Server、Seldon Core 和 KFServing。我们如何知道哪种方法最适合我们的情况?

没有通用的、最具成本效益的模型服务设计;我们需要量身定制适合我们自己用例的模型服务方法。与模型训练和超参数调整服务不同,它们都有一种适用于所有情况的方法——预测服务的设计严重依赖于具体的用户场景。例如,设计一个仅支持一个模型的预测服务,比如花卉识别模型,与设计一个支持 10 种不同类型模型的预测服务,比如 PDF 扫描、文本意图分类和图像分类,是完全不同的。

在保持资源饱和度的同时减少模型预测延迟。从成本效益的角度来看,我们希望我们的计算资源完全饱和于模型预测工作负载。此外,我们希望为客户提供实时的模型预测体验,因此我们不希望由于严格的基础设施预算而导致预测延迟下降。为了实现这一目标,我们需要创新地减少预测工作流的每个步骤的时间成本,比如更快地加载模型或在提供服务之前预热模型。

模型部署和部署后模型监控是我们在第一天就应该考虑的事情。模型部署——将模型从训练推进到生产——对于成功的模型开发至关重要。我们希望快速将模型推进到生产环境,并且我们希望在生产环境中有多个模型版本,这样我们可以快速评估不同的训练算法并选择最佳模型。部署后的模型监控可以帮助检测模型性能退化;这是欺诈检测和贷款批准等模型的关键保护机制。

好消息是,这些六个挑战都是工程问题,所以你能够处理它们!我们将在这里和下一章讨论如何解决它们。

6.1.5 模型服务术语

随着我们继续阅读本章,我们希望提醒你模型服务术语。许多术语在学术界有不同的定义,但在实践中讨论模型服务时是可以互换的。以下定义应该帮助你和你的同事在提到它们时避免混淆。

  • 模型服务模型评分模型推断模型预测在深度学习的上下文中是可以互换的术语。它们都指的是使用给定数据点执行模型。在本书中,我们将使用模型服务

  • 预测服务评分服务推断服务模型服务是可以互换的;它们指的是允许远程执行模型的网络服务。在本书中,我们使用预测服务。

  • 在模型服务的上下文中,预测推断是可以互换的;它们是与运行模型算法相关的入口函数。在本书中,我们使用预测

  • 预测请求评分请求推断请求 是可以互换的;它们指的是执行模型以进行预测的 Web API 请求。在本书中,我们使用 预测请求

  • 机器学习算法训练算法模型算法 是可以互换的,正如我们在第 6.1.3 节中所述;在模型训练和服务中运行的算法是相同的机器学习算法(相同的神经网络),但处于不同的执行模式。

  • 模型部署模型发布 是可以互换的;它们指的是将经过训练的模型(文件)部署/复制到业务运行的生产环境中,以便客户可以从这个新模型中受益。通常,这指的是将模型文件加载到预测服务中。

6.2 常见的模型服务策略

在我们审查第 6.3 节中的具体模型服务用例和预测服务设计之前,让我们先了解三种常见的模型服务策略:直接模型嵌入、模型服务和模型服务器。无论你的具体用例需要做什么,通常可以采用以下三种方法之一来构建你的预测服务。

6.2.1 直接模型嵌入

直接模型嵌入意味着在用户应用程序的进程内加载模型并运行模型预测。例如,一个花卉识别的手机应用可以直接在其本地进程中加载图像分类模型,并从给定的照片中预测植物身份。整个模型加载和服务都发生在本地模型应用程序内(在手机上),而不需要与其他进程或远程服务器进行通信。

大多数用户应用程序,如手机应用程序,都是用 Go、Java 和 C# 等强类型语言编写的,但大多数深度学习建模代码是用 Python 编写的。因此,将模型代码嵌入应用程序代码是很困难的,即使你这样做了,这个过程也可能需要一段时间。为了在非 Python 进程中促进模型预测,PyTorch 和 TensorFlow 等深度学习框架提供了 C++ 库。此外,TensorFlow 还提供了 Java (github.com/tensorflow/java) 和 JavaScript (github.com/tensorflow/tfjs) 库,用于直接从 Java 或 JavaScript 应用程序加载和执行 TensorFlow 模型。

直接嵌入的另一个缺点是资源消耗。如果模型在客户端设备上运行,没有高端设备的用户可能会有不好的体验。运行大型深度学习模型需要大量的计算,这可能导致应用程序变慢。

最后,直接嵌入涉及将模型服务代码与应用程序业务逻辑混合在一起,这对向后兼容性构成了挑战。因此,因为它很少被使用,我们只简要描述它。

6.2.2 模型服务

模型服务 指的是在服务器端运行模型服务。对于每个模型、每个模型的版本或每种类型的模型,我们都为其构建一个专用的 Web 服务。这个 Web 服务通过 HTTP 或 gRPC 接口公开模型预测 API。

模型服务管理模型服务的全部生命周期,包括从模型制品存储库中获取模型文件、加载模型、为客户请求执行模型算法以及卸载模型以回收服务器资源。以文档分类用例为例,为了自动按照内容对图像和 PDF 中的文档进行分类,我们可以训练一个用于光学字符识别(OCR)的 CNN 模型来提取文档图像或 PDF 中的文本。为了在模型服务方法中为这个模型提供服务,我们为这个 CNN 模型专门构建一个 Web 服务,并且 Web API 仅设计用于这个 CNN 模型的预测函数。有时我们为每个主要模型版本更新构建一个专用的 Web 服务。

模型服务的常见模式是将模型执行逻辑构建到 Docker 镜像中,并使用 gRPC 或 HTTP 接口公开模型的预测函数。对于服务设置,我们可以托管多个服务实例,并使用负载均衡器将客户的预测请求分发到这些实例。

模型服务方法的最大优势是简单性。我们可以很容易地将模型的训练容器转换为模型服务容器,因为本质上,模型预测执行涉及运行经过训练的模型神经网络。模型训练代码可以通过添加 HTTP 或 gRPC 接口并设置神经网络为评估模式快速转换为预测 Web 服务。我们将在第 6.3.1 和 6.3.2 节中看到模型服务的设计和用例,并在第七章中看到一个具体的代码示例。

因为模型服务针对模型算法具体化,所以我们需要为不同的模型类型或版本构建单独的服务。如果您有多个不同的模型需要提供服务,这种一模型一服务的方法可能会产生许多服务,并且维护这些服务的工作——如打补丁、部署和监控——可能会很辛苦。如果您面临这种情况,模型服务器方法是正确的选择。

6.2.3 模型服务器

模型服务器方法旨在以黑盒方式处理多种类型的模型。无论模型算法和模型版本如何,模型服务器都可以使用统一的 Web 预测 API 操作这些模型。模型服务器是下一阶段;我们不再需要为新型模型或模型新版本进行代码更改或部署新服务。这从模型服务方法中节省了许多重复的开发和维护工作。

然而,模型服务器方法比模型服务方法更加复杂,实现和管理起来更加困难。在一个服务和一个统一的 API 中处理各种类型模型的模型服务是复杂的。模型算法和模型数据是不同的;它们的预测函数也是不同的。例如,图像分类模型可以用 CNN 网络训练,而文本分类模型可以用长短期记忆(LSTM)网络训练。它们的输入数据不同(文本 vs 图像),它们的算法也不同(CNN vs LSTM)。它们的模型数据也不同;文本分类模型需要嵌入文件来编码输入文本,而 CNN 模型不需要嵌入文件。这些差异给找到一个低维护、低成本和统一的服务方法带来了许多挑战。

虽然构建模型服务器方法很困难,但绝对可行。许多开源模型服务库和服务,如 TensorFlow Serving、TorchServe 和 NVIDIA Triton 推理服务器,提供了模型服务器解决方案。我们只需要构建定制的集成逻辑,将这些工具整合到我们现有系统中以解决业务需求,例如将 TorchServe 集成到我们的模型存储、监控和警报系统中。

从模型部署的角度来看,模型服务器采用黑盒方法。只要我们按照模型服务器的标准保存模型文件,当我们通过其管理 API 将模型上传到模型服务器时,模型预测应该正常工作。模型服务实现和维护的复杂性可以大大降低。我们将在第 6.3.3 节看到模型服务器的设计和用例,并在第七章看到使用 TorchServe 的代码示例。

注意我们是否总是应该考虑模型服务器方法?并不总是。如果我们不考虑服务开发成本和维护成本,模型服务器方法是最强大的,因为它设计用来覆盖所有类型的模型。但如果我们关心模型服务的成本效益——而我们应该关心!——那么理想的方法取决于用例。在下一节中,我们将讨论常见的模型服务用例和应用设计。

6.3 设计预测服务

在软件系统设计中一个常见的错误是试图构建一个无所不能的系统,而不考虑具体的用户场景。过度设计会将我们的注意力从即时的客户需求转移到未来可能有用的功能上。结果,系统要么需要花费不必要的时间来构建,要么难以使用。这对于模型服务尤其如此。

深度学习是一项昂贵的业务,无论是人力资源还是计算资源。我们应该只构建必需品,尽快将模型投入生产,并尽量减少操作成本。为此,我们需要从用户场景开始。

在本节中,我们将介绍三种典型的模型服务场景,从简单到复杂。对于每个用例,我们解释场景并说明一个适合的高级设计。通过按顺序阅读以下三个小节,您将看到当用例变得越来越复杂时,预测服务的设计如何演变。

注意 预测服务设计的目标不是构建适用于各种模型的强大系统,而是以成本效益的方式构建适合环境的系统。

6.3.1 单模型应用

想象一下构建一个可以在两张图片之间交换人脸的移动应用程序。消费者期望应用程序 UI 能够上传照片,选择源图片和目标图片,并执行一个 deepfake 模型(arxiv.org/abs/1909.11573)来交换所选图片之间的人脸。对于只需要与一个模型一起工作的应用程序,服务方法可以是模型服务(6.2.2)或直接模型嵌入(6.2.1)。

模型服务方法

从 6.2.2 节的讨论中可以看出,模型服务方法包括为每个模型构建一个 Web 服务。因此,我们可以使用以下三个组件构建换脸模型应用程序:在手机上运行的前端 UI 应用程序(组件 A);用于处理用户操作的应用后端(组件 B);以及用于托管 deepfake 模型并公开 Web API 以执行每个换脸请求的后端服务,或预测器(组件 C)。

当用户在移动应用程序上上传源图片和目标图片,并点击换脸按钮时,移动后端应用程序将接收请求并调用预测器的 Web API 进行换脸。然后,预测器对用户请求数据(图片)进行预处理,执行模型算法,并对模型输出(图片)进行后处理,然后将其发送到应用后端。最终,移动应用程序将显示源图片和目标图片,并交换它们的人脸。图 6.4 描述了适用于换脸用例的一般设计。

图 6.4 在客户端/服务器设置中的单模型预测器设计

如果我们放大预测器(组件 C),我们会发现模型服务逻辑与我们在图 6.3 中介绍的一般模型预测工作流程相同。预测器(模型服务服务)从模型仓库中加载模型文件并运行模型以响应通过 Web 接口收到的请求。

图 6.4 中的设计通常适用于具有 Web 后端和只有一个模型的任何应用程序。该设计的关键组件是预测器;它是一个 Web 服务,通常作为 Docker 容器运行。我们可以快速实现这个方法,因为预测器容器可以从构建模型的训练容器中轻松转换。将训练容器转换为预测器容器的两个主要工作项是 Web 预测 API 和训练神经网络中的评估模式。我们将在第 7.1 节中介绍一个具体的预测器容器示例。

直接模型嵌入方法

构建单一模型应用的另一种设计方法是将模型执行代码与应用的用户逻辑代码结合起来。没有后端服务器,所有操作都在用户的计算机或手机上本地完成。以换脸应用为例,深度伪造模型文件在应用部署包中,当应用启动时,模型被加载到应用的进程空间中。图 6.5 展示了这个概念。

图 6.5 在直接模型嵌入设计中,模型在应用逻辑的同一进程中执行。

模型服务不一定要在独立的服务中运行。在图 6.5 中,我们可以看到模型服务代码(单一模型框)和数据转换代码可以与用户逻辑代码在同一个应用中运行。现在,很多深度学习框架都提供了在非 Python 应用中运行模型的库。例如,TensorFlow 提供了 Java、C++和 JavaScript 的 SDK 来加载和执行模型。借助 SDK 的帮助,我们可以直接在 Java/C++/JavaScript 应用中训练和执行模型。

注意为什么应该考虑直接模型嵌入?通过使用模型嵌入,我们可以直接将模型服务逻辑与应用逻辑集成并在同一个进程空间中运行它们。这相对于图 6.4 中的预测器服务方法有两个优势。首先,它减少了一次网络跳转;没有对预测器的 Web 请求,模型执行在本地进行。其次,它提高了服务的调试能力,因为我们可以将应用作为一个整体在本地运行。

为什么模型服务方法更受欢迎?

虽然直接模型嵌入方法看起来简单并且可以节省一次网络跳转,但它仍然不是构建模型服务的常见选择。以下是四个原因:

  • 模型算法必须在不同的语言中重新实现。模型的算法和执行代码通常是用 Python 编写的。如果我们选择模型服务方法,将模型服务实现为 Web 服务(图 6.4 中的预测器),我们可以重用大部分训练代码并快速构建它。但是,如果我们选择将模型服务嵌入非 Python 应用程序中,我们必须在应用程序的语言中(如 Java 或 C ++)重新实现模型加载、模型执行和数据处理逻辑。这项工作并不简单,而且没有多少开发人员具备重写训练算法的深度知识。

  • 所有权边界变得模糊。将模型嵌入应用程序时,业务逻辑代码可能会与服务代码混合在一起。当代码库变得复杂时,很难在服务代码(由数据科学家拥有)和其他应用程序代码(由开发人员拥有)之间划定界限。当数据科学家和开发人员来自两个不同的团队,但在同一个代码仓库上工作时,交叉团队的代码审查和部署时间会比平常长得多。

  • 客户设备可能出现性能问题。通常,应用程序在客户的手机、平板电脑或低端笔记本电脑上运行。在这些设备上,从原始用户数据中捕获特征,然后预处理模型输入数据并运行模型预测可能会导致性能问题,如 CPU 使用率飙升、应用程序减速和内存使用量高。

  • 内存泄漏很容易发生。例如,在 Java 中执行 TensorFlow 模型时,算法执行和输入/输出参数对象都是在本地空间中创建的。这些对象不会被 Java GC(垃圾收集)自动回收;我们必须手动释放它们。很容易忽视模型所声明的本地资源的回收,并且由于 Java 堆中不跟踪本地对象的内存分配,它们的内存使用量很难观察和测量。所以内存泄漏可能会发生,并且很难修复。

注意:为了排除本地内存泄漏,Jemalloc (github.com/jemalloc/jemalloc/wiki/Background) 是一个非常方便的工具。您可以查看我的博客文章“在您的 Java 应用程序中修复内存问题” (mng.bz/lJ8o) 获取更多详情。

出于前述原因,我们强烈建议您采用模型服务方法来处理单一模型应用用例。

6.3.2 多租户应用程序

我们将以聊天机器人应用程序作为示例来解释多租户用例。首先,让我们设定一下背景。租户是一家公司或组织(例如学校或零售店),他们使用聊天机器人应用程序与其客户进行沟通。租户使用相同的软件/服务-聊天机器人应用程序,但具有单独的带有其数据隔离的账户。聊天用户是租户的客户,使用聊天机器人与租户进行业务交流。

按设计,聊天机器人应用程序依靠意图分类模型从对话中识别用户的意图,然后将用户请求重定向到租户的正确服务部门。目前,该聊天机器人采用单一模型应用的方法,这意味着它为每个用户和租户使用单一的意图分类模型。

现在,由于租户反馈单一意图分类模型预测准确度低,我们决定让租户使用我们的训练算法,使用他们自己的数据集构建自己的模型。这样,模型可以更好地适应每个租户的业务情况。对于模型服务,我们将让租户使用自己的模型进行意图分类预测请求。当一个聊天机器人用户与聊天机器人应用程序交互时,应用程序将找到租户的特定模型来回答用户的问题。聊天机器人被改为多租户应用程序。

在这个聊天机器人多租户使用案例中,虽然这些模型属于不同的租户并使用不同的数据集进行训练,但它们属于相同类型的模型。因为这些模型使用相同的算法进行训练,它们的模型算法和预测函数都是相同的。我们可以通过添加模型缓存来扩展图 6.4 中的模型服务设计,以支持多租户。通过将模型图和其相关数据缓存在内存中,我们可以在一个服务中执行多租户模型服务。图 6.6 说明了这个概念。

与图 6.4 中的模型服务设计相比,图 6.6 中的设计增加了一个模型缓存(组件 A)和一个模型文件服务器(组件 B)。因为我们希望在一个服务中支持多个模型,所以我们需要一个内存中的模型缓存来托管和执行不同的模型。模型文件服务器存储可以加载到预测服务模型缓存中的模型文件。模型服务器也可以在预测服务实例之间共享。

要构建一个良好的模型缓存,我们需要考虑模型缓存管理和内存资源管理。对于模型缓存,我们需要分配一个唯一的模型 ID 作为缓存键,以识别缓存中的每个模型。例如,我们可以使用模型训练运行 ID 作为模型 ID;好处是,对于缓存中的每个模型,我们都可以追踪到是哪个训练运行生成了它。另一种更灵活的构建模型 ID 的方式是结合模型名称(自定义字符串)和模型版本。无论我们选择哪种模型 ID 样式,ID 必须是唯一的,并且必须在预测请求中提供。

图 6.6 面向多租户应用的模型缓存预测服务

对于内存资源管理,因为每台服务器的内存和 GPU 资源都是有限的,我们无法将所有所需的模型都加载到内存中。因此,我们需要构建模型交换逻辑到模型缓存中。当资源容量达到时——例如,进程即将耗尽内存时——需要从模型缓存中将一些模型驱逐出去,为新的模型预测请求释放一些资源。像 LRU(最近最少使用)算法和模型在不同实例之间的分区可以帮助减少缓存未命中率(请求的模型不在缓存中),并使模型交换更少地造成中断。我们在第 7.1 节中构建的样本意图分类预测服务演示了模型缓存的概念;你可以在那里探索详细信息。

我们可以将模型缓存设计扩展到多个模型类型吗?

我们不建议将模型缓存设计扩展到多个模型类型。各种模型类型的输入/输出数据格式和数据处理逻辑,如图像分类模型和意图分类模型,都非常不同,因此很难在同一个模型缓存中托管和执行不同类型的模型。为此,我们需要为每种模型类型构建单独的 Web 接口以及单独的数据预处理和后处理代码。在这一点上,你会发现为每种模型类型构建单独的预测服务更容易一些——每个服务都有自己的 Web 接口类型和数据处理逻辑,并管理其自己模型类型的模型缓存。例如,我们可以为这两种不同类型的模型分别构建图像分类预测服务和意图分类预测服务。

当你只有少量模型类型时,每种模型类型一个服务的方法效果很好。但如果你有 20 多种模型类型,那么它就无法扩展。构建和维护 Web 服务,比如设置 CI/CD 管道、网络和部署,成本很高。此外,监控服务的工作也不容易;我们需要建立监控和报警机制,以确保服务 24/7 运行。如果我们按照这种设计支持整个公司的 100+模型类型,考虑到入职和维护工作的成本。为了扩展规模并在一个系统中提供多种不同的模型类型,我们需要采取模型服务器方法(第 6.2.3 节),我们将在下一节进一步讨论。

6.3.3 在一个系统中支持多个应用程序

你已经成功地构建了多个模型服务以支持不同的应用程序,比如多租户聊天机器人、换脸、花卉识别和 PDF 文档扫描。现在,你又有两个任务:(1)为使用语音识别模型的新应用程序构建模型服务支持;(2)减少所有应用程序的模型服务成本。

到目前为止,所有的模型服务实现都是采用模型服务方法构建的。从前面第 6.3.1 节和第 6.3.2 节的讨论中,我们知道当我们有越来越多的模型类型时,这种方法无法扩展。当许多产品和应用程序都有模型服务需求时,最好只构建一个集中的预测服务来解决所有的服务需求。我们将这种类型的预测服务称为预测平台。它采用了模型服务器方法(第 6.2.3 节),并在一个地方处理所有类型的模型服务。对于多个应用程序情况来说,这是最具成本效益的方法,因为模型入职和维护成本仅限于一个系统,远远低于每个应用程序一种预测服务方法(第 6.2.2 节)。

要构建这样一个全能的模型服务系统,我们需要考虑很多因素,比如模型文件格式、模型库、模型训练框架、模型缓存、模型版本控制、模型流执行、模型数据处理、模型管理,以及适合所有模型类型的统一预测 API。图 6.7 展示了预测平台的设计和工作流程。

图 6.7 中的预测平台设计比图 6.6 中的模型服务方法复杂得多。这是因为我们需要组合多个组件和服务来支持任意模型。让我们来看看系统的每个组件,然后是模型预测工作流程。

图 6.7 通用的预测服务(平台)设计,适用于任意模型类型

统一的 Web API

为了支持任意模型,我们希望公共预测 API 是通用的。无论调用哪个模型,API 的规范——例如预测请求和响应的有效载荷模式——都应该足够通用,以满足模型的算法需求。这种统一 API 的一个示例是 KFServing 的预测协议(mng.bz/BlB2),该协议旨在为任何模型和各种预测后端标准化预测协议。

Web API 也应该简单易懂,这样我们就能减少客户的接入和维护工作量。预测 API 可以分为三种类型:模型预测请求 API、模型元数据获取 API 和模型部署 API。模型元数据获取 API 和部署 API 非常有用,因为它们对于它们所提供的模型是无所不知的。我们需要这些方法来检查模型元数据,例如模型版本和算法信息,以及检查模型部署状态。

路由组件

通常,每种类型的服务后端只能处理几种类型的模型。为了支持任意模型,我们需要有不同种类的服务后端,例如 TensorFlow Serving 后端用于 TensorFlow 模型,TorchServe 后端用于 PyTorch 模型。当接收到模型预测请求时,系统需要知道哪个后端可以处理它。这是通过路由组件来完成的。

路由组件负责将预测请求路由到正确的后端推理服务器。对于给定的请求,路由组件首先获取模型的元数据;元数据包括模型算法名称和版本、模型版本和训练框架。然后,通过将模型元数据与路由配置进行匹配,确定应该将预测请求路由到哪个推理后端。

图执行组件

图执行组件处理需要执行一系列模型预测的预测类型。例如,为了自动化抵押贷款批准流程,我们必须按照以下三个模型的顺序运行贷款批准预测请求:一个 PDF 扫描模型来解析贷款申请的文本,一个命名实体识别模型来识别关键词,以及一个贷款评分模型来评分贷款申请。为了支持这种需求,我们可以定义一个有向无环图(DAG)来描述模型执行链,并构建一个图执行引擎以一次性执行。

推理服务器

推理(模型)服务器通过管理模型缓存和模型预测执行来执行实际的模型预测工作。它类似于图 6.6 中显示的预测服务,但更加复杂,因为它需要支持任意模型算法。除了预测 API 之外,推理服务器还应该提供模型管理 API,以实现注册新模型和通过编程方式删除模型的功能。

构建推理服务器比构建预测器服务复杂得多;很少有工程师愿意尝试。但幸运的是,有多个黑盒开源方法可以直接使用,例如 TensorFlow Serving、TorchServe 和 NVIDIA Triton Inference Server。在实践中,我们经常重用这些现有的开源推理服务器,并将它们集成到我们自己的路由组件和图执行组件中。我们将在第七章中更多地讨论开源模型服务器工具。

应用场景

在图 6.7 中,我们看到应用程序 A、B 和 C 共享同一模型服务后端。不同应用程序的模型服务发生在同一地方。与图 6.6 中的模型服务设计相比,预测平台更具可扩展性和更具成本效益,因为添加新应用程序 D 几乎没有任何入职成本。

例如,如果我们想要引入新的应用程序 D——一个语音转文本脚本应用程序——我们只需将语音脚本模型上传到模型文件服务器,然后让该应用程序使用预测平台的统一预测 web API。对于支持新应用程序,预测平台端不需要进行任何代码更改。

模型预测工作流程

在解释每个关键组件之后,让我们看一个典型的模型预测工作流程(图 6.7)。首先,我们将我们的模型文件发布到模型文件服务器,并更新路由组件中的配置,使路由组件知道应该将这种类型的模型的预测请求路由到哪个推理服务器。其次,应用程序向预测系统的 web API 发送预测请求,然后路由组件将请求路由到正确的推理服务器。第三,推理服务器将从模型文件服务器加载模型,将请求载荷转换为模型输入,运行模型算法,并以后处理方式返回预测结果。

注意 预测平台设计并不总是最佳的服务方法!理论上,图 6.7 中的设计可以适用于任何模型,但它确实带来了一些额外的成本。它的设置、维护和调试比模型服务方法要复杂得多。这种设计对于第 6.3.1 节和第 6.3.2 节中介绍的情景来说是过度的。因为每种设计都有其优点,我们建议不要坚持一个服务方法,而是根据实际用户场景选择服务方法。

6.3.4 常见的预测服务需求

尽管我们声明设计预测服务应该从具体的用例开始,但不同的情况会导致不同的设计。所有模型服务设计中存在三个共同的要求:

  • 模型部署安全性——无论我们选择什么样的模型部署策略和版本策略,我们都必须有一种方法将模型回滚到先前的状态或版本。

  • 延迟 — 网络请求延迟是许多在线业务成功的关键因素。一旦我们建立了模型服务支持,下一步就是尽力减少平均预测响应时间。

  • 监控和警报 — 模型服务是深度学习系统中最关键的服务;如果它停止运行,业务也会停止。请记住,实际业务是实时运行在模型预测之上的。如果服务停止或服务延迟增加,客户会立即受到影响。在监控和警报方面,预测服务应该是其他深度学习服务中配备最齐全的服务。

在本章中,我们回顾了模型服务的概念、定义和抽象的高级系统设计。我们希望您能清楚地了解什么是模型服务以及在设计模型服务系统时要考虑什么。在下一章中,我们将演示两个样本预测服务,并讨论常用的预测开源工具。这些示例将展示本章中的设计概念如何应用于实际生活中。

摘要

  • 一个模型可以是几个文件;它由三个元素组成:机器学习算法、模型执行器(包装器)和模型数据。

  • 模型预测和模型推断在模型服务环境中具有相同的含义。

  • 直接模型嵌入、模型服务和模型服务器是模型服务策略的三种常见类型。

  • 模型服务方法涉及为每个模型、每个模型版本或每种类型的模型构建一个预测服务。

  • 模型服务器方法包括仅构建一个预测服务,但它可以运行使用不同算法和框架训练的模型,并且可以运行每个模型的不同版本。

  • 在设计模型服务系统时,首先要了解的是使用情况,这样我们就可以决定哪种服务方法最合适。

  • 成本效益是设计模型服务系统的主要目标;成本包括服务部署、维护、监控、基础设施和服务开发。

  • 对于单一模型应用程序,我们建议采用模型服务方法。

  • 对于多租户应用程序,我们建议采用带有内存模型缓存的模型服务方法。

  • 对于支持具有不同类型模型的多个应用程序,模型服务器和预测平台是最合适的方法。它们包括统一的预测 API、路由组件、图执行组件和多个模型服务器后端。

第七章:模型服务实践

本章涵盖

  • 使用模型服务方法构建样本预测器

  • 使用 TorchServe 和模型服务器方法构建样本服务

  • 参观流行的开源模型服务库和系统

  • 解释生产模型发布流程

  • 讨论后期模型监控

在上一章中,我们讨论了模型服务的概念,以及用户场景和设计模式。在本章中,我们将重点放在这些概念在生产环境中的实际实现上。

正如我们所说,当前实施模型服务的挑战之一是我们有太多可能的做法。除了多个黑盒解决方案之外,还有许多定制和从头开始构建全部或部分模型服务的选项。我们认为教您如何选择正确方法的最佳方式是通过具体的例子。

在本章中,我们实现了两个示例服务,演示了两种最常用的模型服务方法:一种使用自建模型服务容器,演示了模型服务方法(第 7.1 节),另一种使用 TorchServe(用于 PyTorch 模型的模型服务器),演示了模型服务器方法(第 7.2 节)。这两种都用于第三章训练的意图分类模型。一旦您完成了示例,我们将提供(在第 7.3 节中)对最受欢迎的开源模型服务工具的介绍,以帮助您了解它们的特性、最佳用法和其他对您决定使用哪种工具的重要因素。在本章的其余部分,我们将重点关注模型服务操作和监控,包括将模型部署到生产环境并监控模型性能。

通过阅读本章,您不仅将对不同的模型服务设计有具体的理解,还将具备选择适合自己情况的正确方法的眼光。更重要的是,本章将全面呈现模型服务领域的视角,不仅仅是构建模型服务,还有运行监控模型服务系统在构建后的过程。

注意:在本章中,术语模型服务模型推断模型预测是可以互换使用的。它们都指的是使用给定数据点执行模型。

7.1 模型服务示例

在本节中,我们将向您展示第一个样本预测服务。该服务采用了模型服务方法(第 6.2.2 节),可用于单模型(第 6.3.1 节)和多租户应用(第 6.3.2 节)。

此示例服务遵循单一模型应用设计(第 6.3.1 节),其中包含前端 API 组件和后端预测器。我们还对预测器进行了一些增强,以支持多个意图分类模型。我们将按照以下步骤对此示例服务进行参观:

  1. 在本地运行示例预测服务

  2. 讨论系统设计

  3. 查看其子组件的实现细节:前端服务和后端预测器

7.1.1 与服务玩耍

列表 7.1 显示了如何在本地机器上运行示例预测服务。以下脚本首先运行后端预测器,然后运行前端服务。

注意设置预测服务有点繁琐;我们需要运行元数据和艺术品存储服务,并准备好模型。为了清晰地演示这个想法,列表 7.1 强调了主要的设置步骤。要使模型服务在您的本地机器上工作,请完成附录 A 中的实验(A.2 节),然后使用代码 ./scripts/lab-004-model-serving.sh {run_id} {document} 发送模型预测请求。

列表 7.1 启动预测服务

# step 1: start backend predictor service
docker build -t orca3/intent-classification-predictor:latest \   ❶
  -f predictor/Dockerfile predictor

docker run --name intent-classification-predictor \              ❷
   --network orca3 --rm -d -p "${ICP_PORT}":51001 \              ❷
   -v "${MODEL_CACHE_DIR}":/models \                             ❷
   orca3/intent-classification-predictor:latest

# step 2: start the prediction service (the web api)
docker build -t orca3/services:latest -f \                       ❸
  services.dockerfile .

docker run --name prediction-service --network orca3 \           ❹
  --rm -d -p "${PS_PORT}":51001 -v "${MODEL_CACHE_DIR}":/tmp/modelCache \
  orca3/services:latest prediction-service.jar

❶ 构建预测器 Docker 镜像

❷ 运行预测器服务容器

❸ 构建预测服务镜像

❹ 运行预测服务容器

一旦服务启动,您就可以向其发送预测请求;服务将加载第三章训练的意图分类模型,对给定文本进行模型预测,并返回预测结果。在以下示例中,将文本字符串“merry christmas”发送到服务,并预测为“joy”类别:

#./scripts/lab-004-model-serving.sh 1 "merry christmas"
grpcurl -plaintext 
  -d "{
    "runId": "1",                        ❶
    "document": "merry christmas"        ❷
 }" 
localhost:"${PS_PORT}" 
prediction.PredictionService/Predict

model_id is 1                            ❸
document is hello world                  ❸
{
  "response": "{\"result\": \"joy\"}"    ❸
}

❶ 将模型 ID 指定给响应

❷ 预测负载

❸ 预测响应,预测类别

7.1.2 服务设计

此示例服务由前端界面组件和后端预测器组成。前端组件有三个功能:托管公共预测 API、从元数据存储下载模型文件到共享磁盘卷,并将预测请求转发给后端预测器。后端预测器是一个自建的预测器容器,用于响应加载意图分类模型并执行这些模型以服务于预测请求。

此预测服务有两个外部依赖项:元数据存储服务和共享磁盘卷。元数据存储保存有关模型的所有信息,例如模型算法名称、模型版本和指向真实模型文件的云存储的模型 URL。共享卷使前端服务和后端预测器之间能够共享模型文件。您可以在图 7.1 中看到模型服务过程的端到端概述。

图 07-01

图 7.1 系统概述和模型服务端到端工作流程

浏览图 7.1 中显示的样本模型服务的系统设计,您可以看到完成预测请求需要六个步骤。让我们逐步浏览图中编号的每一步:

  1. 用户向预测服务(前端组件)发送带有指定模型 ID 和文本字符串(即,document)的预测请求。模型 ID 是训练服务生成的唯一标识符,用于识别其生成的每个模型。

  2. 前端服务通过搜索模型 ID 从元数据存储中获取模型元数据。对于每个成功的模型训练,训练服务将模型文件保存到云存储中,并将模型元数据(模型 ID、模型版本、名称和 URL)保存到元数据存储中;这就是为什么我们可以在元数据存储中找到模型信息的原因。

  3. 如果模型文件尚未下载,前端组件将会将其下载到共享磁盘卷上。

  4. 前端组件将推理请求转发给后端预测器。

  5. 后端预测器通过从共享磁盘卷上的模型文件中读取,将意图分类模型加载到内存中。

  6. 后端预测器执行模型,对给定的文本字符串进行预测,并将预测结果返回给前端组件。

7.1.3 前端服务

现在,让我们重点关注前端服务。前端服务主要由三个组件组成:Web 接口、预测器管理器和预测器后端客户端(CustomGrpcPredictorBackend)。这些组件响应主机公共 gRPC 模型提供 API,并管理后端预测器的连接和通信。图 7.2 显示了前端服务的内部结构以及在接收到预测请求时的内部工作流程。

图 7.2 前端服务设计和模型提供工作流程

接下来我们考虑在图 7.2 中描述的模型提供工作流程中的意图预测场景,并应用刚刚复习过的六个步骤:

  1. 用户向 Web 接口发送包含模型 ID A 的意图预测请求。

  2. Web 接口调用预测器连接管理器来提供此请求。

  3. 预测器连接管理器通过查询元数据存储获取模型元数据,查询的条件为模型 ID 等于 A;返回的模型元数据包含模型算法类型和模型文件 URL。

  4. 基于模型算法类型,预测器管理器选择合适的预测器后端客户端来处理请求。在这种情况下,它选择了 CustomGrpcPredictorBackend,因为我们正在演示用于意图分类的自建模型提供容器。

  5. CustomGrpcPredictorBackend 客户端首先在模型 A 的共享模型文件磁盘中检查模型文件的存在。如果在以前没有下载过模型,则使用模型 URL(从模型元数据中获取)从云存储中下载模型文件到共享文件磁盘。

  6. CustomGrpcPredictorBackend 客户端然后调用在服务配置文件中与该后端客户端预注册的模型预测器。在此示例中,CustomGrpcPredictorBackend 将调用我们自建的预测器,即意图预测器,将在第 7.1.4 节中讨论。

现在我们已经审查了系统设计和工作流程,让我们考虑主要组件的实际代码实现,包括 Web 接口(预测 API)、预测器连接管理器、预测器后端客户端和意图预测器。

前端服务模型服务代码演示

以下代码清单突出了图 7.2 中提到的预测工作流的核心实现。你也可以在 src/main/ java/org/orca3/miniAutoML/prediction/PredictionService.java 找到完整的实现。

7.2 前端服务预测工作流

public void predict(PredictRequest request, .. .. ..) {
  .. .. ..
  String runId = request.getRunId();                        ❶

  if (predictorManager.containsArtifact(runId)) {           ❷
    artifactInfo = predictorManager.getArtifact(runId);
  } else {
    try {
       artifactInfo = msClient.getArtifact(                 ❷
                GetArtifactRequest.newBuilder()
               .setRunId(runId).build());
     } catch (Exception ex) {
       .. .. .. 
    }
  } 

 # Step 4, pick predictor backend client by model algorithm type
  PredictorBackend predictor;
  if (predictorManager.containsPredictor(
        artifactInfo.getAlgorithm())) {

    predictor = predictorManager.getPredictor(              ❸
        artifactInfo.getAlgorithm());
  } else {
   .. .. ..
  }

  # Step 5, use the selected predictor client to download the model files
  predictor.downloadModel(runId, artifactInfo);             ❹

  # Step 6, use the selected predictor client to call
 # its backend predictor for model serving
  String r = predictor.predict(                             ❺
     artifactInfo, request.getDocument());                  ❺
  .. .. ..
}

❶ 获取所需的模型 ID

❷ 从元数据存储中获取模型元数据

❸ 根据模型算法类型选择后端预测器

❹ 下载模型文件

❺ 调用后端预测器运行模型推理

预测 API

前端服务仅提供一个 API — Predict — 用于发出预测请求。该请求有两个参数,runIddocumentrunId 不仅用于在训练服务(第三章)中引用模型训练运行,还可以用作引用模型的模型 ID。document 是客户想要运行预测的文本。

通过使用 Predict API,用户可以指定一个意图模型(带有 runId)来预测给定文本字符串(document)的意图。以下清单显示了 Predict API 的 gRPC 合同(grpc-contract/src/main/proto/prediction_service .proto)。

7.3 预测服务 gRPC 接口

service PredictionService {
 rpc Predict(PredictRequest) returns (PredictResponse);
}

message PredictRequest {
 string runId = 3;
 string document = 4;
}

message PredictResponse {
 string response = 1;
}

预测器连接管理器

前端服务的一个重要作用是路由预测请求。给定一个预测请求,前端服务需要根据请求中所需的模型算法类型找到正确的后端预测器。这个路由是在 PredictorConnectionManager 中完成的。在我们的设计中,模型算法和预测器的映射是预定义的在环境属性中。当服务启动时,PredictorConnectionManager 将读取映射,这样服务就知道为哪种模型算法类型使用哪个后端预测器。

尽管在这个示例中我们只是演示了我们自己构建的意图分类预测器,PredictorConnectionManager 可以支持任何其他类型的后端预测器。让我们看一下以下清单(config/config-docker-docker.properties)来看看模型算法和预测器映射是如何配置的。

7.4 模型算法和预测器映射配置

# the algorithm and predictor mapping can be defined in 
# either app config or docker properties

# enable algorithm types
ps.enabledPredictors=intent-classification

# define algorithm and predictors mapping
# predictor.<algorithm_type>.XXX = predictor[host, port, type]
predictors.intent-classification.host= \      ❶
  Intent-classification-predictor             ❶
predictors.intent-classification.port=51001
predictors.intent-classification.techStack=customGrpc

❶ 将意图分类预测器映射到意图分类算法

现在,让我们回顾代码清单 7.5,看看预测器管理器如何读取算法和预测器映射,并使用该信息初始化预测器后端客户端发送预测请求。完整的实现位于 prediction-service/src/main/java/org/orca3/miniAutoML/prediction/PredictorConnectionManager.java

7.5 预测器管理器加载算法和预测器映射

public class PredictorConnectionManager {
  private final Map<String, List<ManagedChannel>> 
    channels = new HashMap<>();

  private final Map<String, PredictorBackend>                ❶
    clients = new HashMap<>();

  private final Map<String, GetArtifactResponse>             ❷
    artifactCache;

  // create predictor backend objects for 
 // the registered algorithm and predictor
  public void registerPredictor(String algorithm, 
       Properties properties) {

    String host = properties.getProperty(                    ❸
       String.format(“predictors.%s.host”, algorithm));

    int port = Integer.parseInt(properties.getProperty(      ❸
       String.format(“predictors.%s.port”, algorithm)));

    String predictorType = properties.getProperty(           ❸
       String.format(“predictors.%s.techStack”, algorithm));

    ManagedChannel channel = ManagedChannelBuilder
       .forAddress(host, port)
       .usePlaintext().build();

    switch (predictorType) {
      .. ..
      case “customGrpc”:                                     ❹
      default:
        channels.put(algorithm, List.of(channel));
        clients.put(algorithm, new CustomGrpcPredictorBackend(
          channel, modelCachePath, minioClient));
      break;
     }
  }

  .. .. ..
}

❶ 预测器后端映射的算法

❷ 模型元数据缓存; 键字符串为模型 ID。

❸ 从配置中读取算法和预测器映射

❹ 创建预测器后端客户端并将其保存在内存中

在列表 7.5 中,我们可以看到 PredictorConnectionManager 类提供了registerPredictor函数来注册预测器。它首先从属性中读取算法和预测器映射信息,然后创建实际的预测器后端客户端CustomGrpcPredictorBackend与后端意图预测器容器通信。

您还可以注意到 PredictorConnectionManager 类有几个缓存,如模型元数据缓存(artifactCache)和模型后端预测器客户端(clients)。这些缓存可以极大地提高模型服务的效率。例如,模型元数据缓存(artifactCache)可以通过避免调用元数据存储服务来减少呼叫已经下载的模型的服务请求响应时间。

预测器后端客户端

预测器客户端是前端服务用于与不同的预测器后端进行通信的对象。按设计,每种类型的预测器后端都支持其自己的模型类型,并且它有自己的用于通信的客户端,该客户端在PredictorConnectionManager中创建并存储。每个预测器后端客户端都会继承一个名为PredictorBackend的接口,如下列表所示。

列表 7.6 预测器后端接口

public interface PredictorBackend {
   void downloadModel(String runId, 
           GetArtifactResponse artifactResponse);

   String predict(GetArtifactResponse artifact, String document);

   void registerModel(GetArtifactResponse artifact);
}

downloadModepredictregisterModel三个方法都是不言自明的。每个客户端实现这些方法来下载模型并向其注册的后端服务发送预测请求。GetArtifactResponse参数是从元数据存储中获取的模型元数据对象。

在这个(意图预测器)示例中,预测器后端客户端是CustomGrpcPredictorBackend。您可以在prediction-service/src/main/java/org/orca3/miniAutoML/prediction/CustomGrpcPredictorBackend.java中找到详细的实现。下面的代码片段展示了该客户端如何使用 gRPC 协议将预测请求发送到自建的意图预测器容器:

// calling backend predictor for model serving
public String predict(GetArtifactResponse artifact, String document) {
   return stub.predictorPredict(PredictorPredictRequest
       .newBuilder().setDocument(document)    ❶
       .setRunId(artifact.getRunId())         ❷
       .build()).getResponse();
}

❶ 模型的文本输入

❷ 模型 ID

7.1.4 意图分类预测器

我们已经看到了前端服务及其内部路由逻辑,现在让我们来看看这个示例预测服务的最后一部分:后端预测器。为了向您展示一个完整的深度学习用例,我们实现了一个预测器容器来执行第三章训练的意图分类模型。

我们可以将这个自建的意图分类预测器视为一个独立的微服务,可以同时为多个意图模型提供服务。它具有 gRPC web 接口和模型管理器。模型管理器是预测器的核心;它执行多项任务,包括加载模型文件,初始化模型,将模型缓存在内存中,并使用用户输入执行模型。图 7.3 显示了预测器的设计图和预测器内的预测工作流程。

让我们使用图 7.3 中的模型 A 的意图预测请求来考虑工作流程。它按以下步骤运行:

  1. 前端服务中的预测客户端调用预测器的 web gRPC 接口,使用模型 A 运行意图预测。

  2. 请求调用模型管理器。

  3. 模型管理器从共享磁盘卷加载模型 A 的模型文件,初始化模型,并将其放入模型缓存中。模型文件应该已经由前端服务放置在共享磁盘卷上。

  4. 模型管理器使用转换器的帮助执行模型 A,对输入和输出数据进行预处理和后处理。

  5. 返回预测结果。

图 7.3 后端意图预测器设计和预测工作流程

接下来,让我们看看工作流程中提到的组件的实际实现。

预测 API

意图预测器有一个 API — PredictorPredict(见代码列表 7.7)。它接受两个参数,runIddocumentrunId 是模型 ID,document 是一个文本字符串。你可以在 grpc-contract/src/main/proto/ prediction_service.proto 中找到完整的 gRPC 合同。

列表 7.7 意图预测器 gRPC 接口

service Predictor {
 rpc PredictorPredict(PredictorPredictRequest) returns (PredictorPredictResponse);
}

message PredictorPredictRequest {
 string runId = 1;
 string document = 2;
}

message PredictorPredictResponse {
 string response = 1;
}

你可能注意到预测器 API 与前端 API(代码列表 7.2)相同;这是为了简单起见。但在实际应用中,它们通常是不同的,主要是因为它们被设计用于不同的目的。预测器的 predict API 设计有利于模型执行,而前端的 predict API 设计有利于客户和业务的需求。

模型文件

我们在模型训练服务(第三章)中生成的每个意图分类模型都有三个文件。manifest.json 文件包含模型元数据和数据集标签;预测器需要这些信息将模型预测结果从整数转换为有意义的文本字符串。model.pth 是模型的学习参数;预测器将读取这些网络参数以设置模型的神经网络以进行模型服务。vocab.pth 是模型训练中使用的词汇文件,这也是服务所必需的,因为我们需要它将用户输入(字符串)转换为模型输入(十进制数)。让我们来看一下示例意图模型:

├── manifest.json                          ❶
├── model.pth                              ❷
└── vocab.pth                              ❸

// A sample manifest.json file 
{
  "Algorithm": "intent-classification",    ❹
  "Framework": "Pytorch",
  "FrameworkVersion": "1.9.0",
  "ModelName": "intent",
  "CodeVersion": "80bf0da",
  "ModelVersion": "1.0",
  "classes": {                             ❺
    "0": "cancel",
    "1": "ingredients_list",
    "2": "nutrition_info",
    "3": "greeting",
     .. .. ..
}

❶ 模型元数据和数据集标签

❷ 模型权重文件

❸ 词汇文件

❹ 模型元数据

❺ 数据集标签

当保存 PyTorch 模型时,有两种选择:序列化整个模型或仅序列化学习参数。第一种选项序列化整个模型对象,包括其类和目录结构,而第二种选项仅保存模型网络的可学习参数。

根据马修·英卡威奇的文章“PyTorch: Saving and Loading Models”(mng.bz/zm9B),PyTorch 团队建议仅保存模型的学习参数(模型的state_dict)。如果我们保存整个模型,序列化数据将与保存模型时使用的特定类和确切目录结构绑定。模型类本身不会被保存;而是保存包含类的文件。因此,在加载时,当在其他项目中使用或进行重构后,序列化的模型代码可能会以各种方式中断。

为此,我们只保存模型的state_dict(学习参数)作为训练后的模型文件;在这个例子中,它是model.pth文件。我们使用以下代码保存它:torch.save(model.state_dict(), model_local_path)。因此,预测器需要知道模型的神经网络架构(见代码清单 7.8)来加载模型文件,因为模型文件只是state_dict——模型网络的参数。

清单 7.8(predictor/predict.py)显示了我们用来在预测器中加载模型文件model.pth(仅参数)的模型架构。服务中的模型执行代码源自模型训练代码。如果你将以下清单中的模型定义与我们训练代码中的TextClassificationModel类(training-code/text-classification/train.py)进行比较,你会发现它们是相同的。这是因为模型服务本质上是模型训练运行。

清单 7.8 模型的神经网络(架构)

class TextClassificationModel(nn.Module):

  def __init__(self, vocab_size, embed_dim,   ❶
      fc_size, num_class):                    ❶

    super(TextClassificationModel, self).__init__()
    self.embedding = nn.EmbeddingBag(vocab_size, embed_dim, sparse=True)
    self.fc1 = nn.Linear(embed_dim, fc_size)
    self.fc2 = nn.Linear(fc_size, num_class)
    self.init_weights()

  def forward(self, text, offsets):
    embedded = self.embedding(text, offsets)
    return self.fc2(self.fc1(embedded))

❶ 定义模型架构

你可能会想知道训练代码和模型服务代码是否现在合并了。当训练代码发生变化时,似乎预测器中的模型服务代码也需要调整。这只是部分正确;上下文往往会决定模型服务如何受到模型训练算法变化的影响。以下是这种关系的一些微妙之处。

首先,训练代码和服务代码只需在神经网络架构和输入/输出数据模式上同步。其他模型训练变化,比如训练策略、超参数调整、数据集拆分和增强,不会影响服务,因为它们会产生模型权重和偏置文件。其次,在训练时应引入模型版本控制。在实践中,每次模型训练或重新训练都会给输出模型分配一个新的模型版本。所以要解决的问题是如何为模型的不同版本提供服务。

这个示例服务不处理模型版本管理。但是,在第 7.5 节和第八章中,我们将深入讨论模型版本的元数据管理。我们在这里只是描述了大致的想法。

如果你正在使用类似的模型服务方法,并且有一个自定义的预测器后端,你需要准备多个版本的预测器后端,以匹配使用不同神经网络架构训练的模型。在发布模型时,训练代码的版本、服务代码的版本以及模型文件的版本需要作为模型元数据的一部分相关联,并保存在元数据存储中。因此,在提供服务时,预测服务(前端服务)可以搜索元数据存储,以确定应将请求路由到给定模型的哪个预测器版本。

如果你使用模型服务器方法,使用不同版本的模型变得更加容易,因为这种方法打破了服务代码(模型执行代码)和训练代码之间的依赖关系。你可以在第 7.2 节中看到一个具体的例子。

注意:正如我们在第六章(第 6.1.3 节)中提到的,模型训练和服务都利用相同的机器学习算法,但是在不同的执行模式下:学习和评估。然而,我们想再次澄清这个概念。理解训练代码、服务代码和模型文件之间的关系是服务系统设计的基础。

模型管理器

模型管理器是这个意图预测器的关键组件。它托管一个内存模型缓存,加载模型文件,并执行模型。下面的清单(predictor/predict.py)显示了模型管理器的核心代码。

清单 7.9 意图预测器模型管理器

class ModelManager:
  def __init__(self, config, tokenizer, device):
    self.model_dir = config.MODEL_DIR
    self.models = {}                                      ❶

  # load model file and initialize model
  def load_model(self, model_id):
    if model_id in self.models:
      return

    # load model files, including vocabulary, prediction class mapping.
    vacab_path = os.path.join(self.model_dir, model_id, "vocab.pth")
    manifest_path = os.path.join(self.model_dir, model_id, "manifest.json")
    model_path = os.path.join(self.model_dir, model_id, "model.pth")

    vocab = torch.load(vacab_path)
    with open(manifest_path, 'r') as f:
    manifest = json.loads(f.read())
    classes = manifest['classes']

    # initialize model graph and load model weights
    num_class, vocab_size, emsize = len(classes), len(vocab), 64
    model = TextClassificationModel(vocab_size, emsize, 
       self.config.FC_SIZE, num_class).to(self.device)
    model.load_state_dict(torch.load(model_path))
    model.eval()

    self.models[self.model_key(model_id)] = model        ❷
self.models[self.model_vocab_key(model_id)]              ❷
  ➥ = vocab                                             ❷
self.models[self.model_classes(model_id)]                ❷
  ➥ = classes                                           ❷

  # run model to make prediction
  def predict(self, model_id, document):
    # fetch model graph, dependency and 
    # classes from cache by model id 
    model = self.models[self.model_key(model_id)]
    vocab = self.models[self.model_vocab_key(model_id)]
    classes = self.models[self.model_classes(model_id)]

    def text_pipeline(x):
      return vocab(self.tokenizer(x))

    # transform user input data (text string) 
 # to model graph’s input
    processed_text = torch.tensor(text_pipeline(document), dtype=torch.int64)
    offsets = [0, processed_text.size(0)]
    offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)

    val = model(processed_text, offsets)                ❸

    # convert prediction result from an integer to 
 # a text string (class)
    res_index = val.argmax(1).item()
    res = classes[str(res_index)]
    print("label is {}, {}".format(res_index, res))
    return res

❶ 将模型托管在内存中

❷ 将模型图、内存中的依赖项和类缓存

❸ 运行模型以获取预测结果

意图预测器预测请求工作流程

你已经了解了意图预测器的主要组件,现在让我们来看看这个预测器内部的端到端工作流程。首先,我们通过将 PredictorServicer 注册到 gRPC 服务器来公开预测 API,这样前端服务就可以远程与预测器通信。其次,当前端服务调用 PredictorPredict API 时,模型管理器将加载模型到内存中,运行模型,并返回预测结果。清单 7.10 突出了上述工作流程的代码实现。你可以在 predictor/predict.py 中找到完整的实现。

清单 7.10 意图预测器预测工作流程

def serve():
  .. .. ..
  model_manager = ModelManager(config, 
    tokenizer=get_tokenizer('basic_english'), device="cpu")
  server = grpc.server(futures.
    ThreadPoolExecutor(max_workers=10))                         ❶

  prediction_service_pb2_grpc.add_PredictorServicer_to_server(
    PredictorServicer(model_manager), server)                   ❷
  .. .. ..

class PredictorServicer(prediction_service_pb2_grpc.PredictorServicer):
  def __init__(self, model_manager):
    self.model_manager = model_manager

 # Serving logic 
  def PredictorPredict(self, request, context: grpc.ServicerContext):

 # load model 
    self.model_manager.load_model(model_id=request.runId)
    class_name = self.model_manager.                            ❸
      predict(request.runId, request.document)
    return PredictorPredictResponse(response=json.dumps({'res': class_name}))

❶ 启动 gRPC 服务器

❷ 将模型服务逻辑注册到公共 API

❸ 进行预测

7.1.5 模型逐出

示例代码未涵盖模型淘汰——即从预测服务的内存空间中淘汰不经常使用的模型文件。在设计中,对于每个预测请求,预测服务将从元数据存储中查询和下载请求模型,然后从本地磁盘读取和初始化模型到内存中。对于一些模型来说,这些操作是耗时的。

为了减少每个模型预测请求的延迟,我们的设计在模型管理器组件(内存中)中缓存模型图,以避免模型加载已使用的模型。但想象一下,我们可以继续训练新的意图分类模型并对其进行预测。这些新产生的模型将继续加载到模型管理器的模型缓存中。最终,预测器将耗尽内存。

为了解决这些问题,模型管理器需要升级以包含模型淘汰功能。例如,我们可以引入 LRU(最近最少使用)算法来重建模型管理器的模型缓存。借助 LRU 的帮助,我们可以仅保留最近访问的模型在模型缓存中,并在当前加载的模型超过内存阈值时淘汰最少访问的模型。

7.2 TorchServe 模型服务器示例

在本节中,我们将向您展示使用模型服务器方法构建预测服务的示例。更具体地说,我们使用了 TorchServe 后端(一个为 PyTorch 模型构建的模型服务工具)来替换上一节(7.1.4)中讨论的自建预测器。

为了与第 7.1 节中的模型服务方法进行公平比较,我们通过重新使用上一节中展示的前端服务来开发此模型服务器方法示例。更确切地说,我们只添加了另一个预测器后端,仍然使用前端服务、gRPC API 和意图分类模型来演示相同的端到端预测工作流程。

第 7.1.4 节的意图预测器和 TorchServe 预测器(模型服务器方法)之间有一个很大的区别。相同的预测器可以为任何 PyTorch 模型提供服务,而不管其预测算法如何。

7.2.1 玩转服务

因为这个模型服务器示例是在上一个示例服务的基础上开发的,所以我们以相同的方式与预测服务交互。唯一的区别是我们启动了一个 TorchServe 后端(容器),而不是启动一个自建的意图预测器容器。代码清单 7.11 仅显示了启动服务和发送意图预测请求的关键步骤。要在本地运行实验,请完成附录 A(A.2 节)中的实验,并参考 scripts/lab-006-model-serving-torchserve.sh 文件(mng.bz/0yEN)。

列表 7.11 启动预测服务并进行预测调用

# step 1: start torchserve backend
docker run --name intent-classification-torch-predictor\
 --network orca3 --rm -d -p "${ICP_TORCH_PORT}":7070 \
 -p "${ICP_TORCH_MGMT_PORT}":7071 \
 -v "${MODEL_CACHE_DIR}":/models \                     ❶
 -v "$(pwd)/config/torch_server_config.properties": \
     /home/model-server/config.properties \
 pytorch/torchserve:0.5.2-cpu torchserve \             ❷
 --start --model-store /models                         ❸

# step 2: start the prediction service (the web frontend)
docker build -t orca3/services:latest -f services.dockerfile .
docker run --name prediction-service --network orca3 \
  --rm -d -p "${PS_PORT}":51001 \
  -v "${MODEL_CACHE_DIR}":/tmp/modelCache \            ❹
 orca3/services:latest  \
 prediction-service.jar

# step 3: make a prediction request, ask intent for “merry christmas”
grpcurl -plaintext 
  -d "{
    "runId": "${MODEL_ID}",
    "document": "merry christmas"
 }" 
 localhost:"${PS_PORT}" prediction.PredictionService/Predict

❶ 将本地目录挂载到 TorchServe 容器

❷ 启动 TorchServe

❸ 设置 TorchServe 从 /models 目录加载模型

❹ 设置预测服务的本地模型目录以下载模型

7.2.2 服务设计

此示例服务遵循图 7.1 中的相同系统设计;唯一的区别是预测器后端变成了 TorchServe 服务器。请参阅图 7.4 以获取更新后的系统设计。

图 7.4

图 7.4 系统概述和模型服务端到端工作流程

从图 7.4 可以看出,模型服务工作流程与图 7.1 中的模型服务示例保持一致。用户调用预测服务的前端 API 发送模型服务请求;前端服务然后下载模型文件,并将预测请求转发到 TorchServe 后端。

7.2.3 前端服务

在第 7.1.3 节中,我们确认了前端服务可以通过在预测器连接管理器中注册预测器来支持不同的预测器后端。当预测请求到来时,预测器连接管理器将通过检查请求的模型算法类型将请求路由到适当的预测器后端。

遵循之前的设计,为了支持我们的新 TorchServe 后端,我们在前端服务中添加了一个新的预测器客户端(TorchGrpcPredictorBackend)来代表 TorchServe 后端;请参阅图 7.5 以获取更新后的系统设计。

图 7.5

图 7.5 前端服务设计和模型服务工作流程

在图 7.5 中,添加了两个灰色的方框;它们分别是 TorchServe gRPC 预测器后端客户端(TorchGrpcPredictorBackend)和后端 TorchServe 服务器。TorchGrpcPredictorBackend 通过下载模型文件并向 TorchServe 容器发送预测请求进行响应。在这个示例中,TorchServe 后端将由预测器连接管理器选择,因为请求的模型元数据(在元数据存储中)将 TorchServe 定义为其预测器。

7.2.4 TorchServe 后端

TorchServe 是由 PyTorch 团队构建的用于提供 PyTorch 模型服务的工具。TorchServe 作为一个黑盒运行,它提供 HTTP 和 gRPC 接口用于模型预测和内部资源管理。图 7.6 可视化了我们在这个示例中如何使用 TorchServe 的工作流程。

图 7.6

图 7.6 TorchServe 后端的模型服务工作流程:TorchServe 应用程序作为一个黑盒运行。

在我们的示例代码中,我们将 TorchServe 作为一个 Docker 容器运行,这是由 PyTorch 团队提供的,然后将本地文件目录挂载到容器中。这个文件目录作为 TorchServe 进程的模型存储。在图 7.6 中,我们分三步来运行模型预测。首先,我们将 PyTorch 模型文件复制到模型存储目录中。其次,我们调用 TorchServe 管理 API 将模型注册到 TorchServe 进程中。最后,我们调用 TorchServe API 来运行模型预测,对于我们来说,是意图分类模型。

跟第 7.1.4 节中的自构建意图预测器相比,TorchServe 要简单得多。我们甚至不需要编写任何代码就可以使模型服务正常运行,只需使用共享磁盘设置 Docker 容器即可。此外,TorchServe 不仅适用于意图分类算法,它不受任何特定训练算法的限制,只要模型是使用 PyTorch 框架训练的,TorchServe 就可以为其提供服务。

TorchServe 提供了极大的灵活性和便利性,但也有相关要求。TorchServe 要求操作员使用其独有的 API 发送模型服务请求,并要求模型文件以 TorchServe 格式打包。下面的两小节会详细介绍这些要求。

7.2.5 TorchServe API

TorchServe 提供了众多类型的 API,例如健康检查、模型解释、模型服务、工作线程管理和模型注册等。每个 API 都有 HTTP 和 gRPC 两种实现方式。由于 TorchServe 在其官网(pytorch.org/serve/)和 GitHub 仓库(github.com/pytorch/serve)上都对 API 的协议和使用方式进行了详细的解释,你可以在那里找到具体的信息。在本小节中,我们将着重介绍我们在示例服务中使用的模型注册 API 和模型推理 API。

模型注册 API

由于 TorchServe 采用黑箱方式进行模型服务,所以在使用模型之前需要将其注册。具体来说,我们需要先将模型文件放到 TorchServe 的模型存储库中(即本地文件目录),但是 TorchServe 并不会自动加载该模型文件。我们需要向 TorchServe 注册模型文件和该模型的运行方法,以便 TorchServe 知道如何正常运行该模型。

在我们的代码示例中,我们使用了 TorchServe 的 gRPC 模型注册 API 来从预测服务中注册我们的意图模型,示例如下:

public void registerModel(GetArtifactResponse artifact) {
  String modelUrl = String.format(MODEL_FILE_NAME_TEMPLATE,
        artifact.getRunId());

  String torchModelName = String.format(TORCH_MODEL_NAME_TEMPLATE,
            artifact.getName(), artifact.getVersion());
  ManagementResponse r = managementStub.registerModel(     ❶
           RegisterModelRequest.newBuilder()
             .setUrl(modelUrl)
             .setModelName(torchModelName)
             .build());

  # Assign resource (TorchServe worker) for this model
  managementStub.scaleWorker(ScaleWorkerRequest.newBuilder()
             .setModelName(torchModelName)
             .setMinWorker(1)
             .build());
}

❶ 通过提供模型文件和模型名称向 TorchServe 注册模型

TorchServe 模型文件中已经包含有模型的元数据,包括模型版本、模型运行时和模型服务入口。因此,在注册模型时,通常只需要在registerModelAPI 中设置模型文件名。除了模型注册之外,我们还可以使用scaleWorkerAPI 来控制为该模型分配多少计算资源。

模型推理 API

TorchServe 为各种模型提供了统一的模型服务 API,使其使用起来非常简单。如果想要为模型的默认版本运行预测,只需向/predictions/{model_name}发送一个 REST 请求;如果想要为加载的特定版本的模型运行预测,则向/predictions/{model_name}/{version}发送 REST 请求。需要预测的内容以二进制格式输入到预测请求中。例如:

# prediction with single input on model resnet-18
curl http:/ /localhost:8080/predictions/resnet-18 \
 -F "data=@kitten_small.jpg"
# prediction with multiple inputs on model squeezenet1_1
curl http:/ /localhost:8080/predictions/squeezenet1_1 \
 -F 'data=@docs/images/dogs-before.jpg' \
 -F 'data=@docs/images/kitten_small.jpg'

在我们的样本服务中,我们使用 gRPC 接口将预测请求发送到 TorchServe。代码清单 7.12 展示了 TorchGrpcPredictorBackend 客户端将预测请求从前端 API 调用转换为 TorchServe 后端 gRPC 调用。您可以在 prediction-service/src/main/java/org/orca3/miniAutoML/prediction/TorchGrpcPredictorBackend.java 找到 TorchGrpcPredictorBackend 的完整源代码。

清单 7.12 从前端服务调用 TorchServe 预测 API

// call TorchServe gRPC prediction api
public String predict(GetArtifactResponse artifact, String document) {
  return stub.predictions(PredictionsRequest.newBuilder()
           .setModelName(String.format(TORCH_MODEL_NAME_TEMPLATE,
              artifact.getName(), artifact.getVersion()))
           .putAllInput(ImmutableMap.of("data",                         ❶
              ByteString.copyFrom(document, StandardCharsets.UTF_8)))
                .build()).getPrediction()
           .toString(StandardCharsets.UTF_8);
}

❶ 将文本输入转换为二进制格式以调用 TorchServe

7.2.6 TorchServe 模型文件

到目前为止,您已经看到了 TorchServe 模型服务的工作流程和 API。您可能想知道当 TorchServe 对其所服务的模型一无所知时,TorchServe 的模型服务是如何工作的。在第六章中,我们学到要服务一个模型,预测服务需要知道模型算法和模型输入/输出模式。与直觉相反,TorchServe 运行模型服务而不知道模型算法和模型输入/输出数据格式。诀窍在于 TorchServe 模型文件。

TorchServe 要求模型打包到一个特殊的 .mar 文件中。我们可以使用 torch-model-archiver CLI 或 model_archiver Python 库将 PyTorch 模型文件打包成一个 .mar 文件。

要归档 TorchServe 的 .mar 文件,我们需要提供模型名称、模型文件(.pt.pth)和一个处理程序文件。处理程序文件是关键部分;它是一个定义处理自定义 TorchServe 推理逻辑的 Python 代码文件。因为 TorchServe 的模型包(.mar 文件)包含模型算法、模型数据和模型执行代码,而模型执行代码遵循 TorchServe 的预测接口(协议),所以 TorchServe 可以通过使用其通用预测 API 在不知道模型算法的情况下执行任何模型(.mar 文件)。

当 TorchServe 收到预测请求时,它首先会找到承载模型的内部工作进程,然后触发模型的处理程序文件来处理请求。处理程序文件包含四个逻辑部分:模型网络初始化、输入数据预处理、模型推理和预测结果后处理。为了使前面的解释更具体,让我们以我们的意图模型文件为例。

意图分类 .mar 文件

如果我们打开样本服务中意图模型的 .mar 文件,与我们在第 7.1.4 节中看到的模型文件相比,我们会看到额外添加了两个文件——MANIFEST.jsontorchserve_handler.py。以下是意图 .mar 文件的文件夹结构:

# intent.mar content
├── MAR-INF
│   └── MANIFEST.json          ❶
├── manifest.json              ❷
├── model.pth                  ❸
├── torchserve_handler.py      ❹
└── vocab.pth                  ❺

# MANIFEST.json, TorchServe .mar metadata 
{
 "createdOn": "09/11/2021 10:26:59",
 "runtime": "python",
 "model": {
   "modelName": "intent_80bf0da",
   "serializedFile": "model.pth",
   "handler": "torchserve_handler.py",
   "modelVersion": "1.0"
 },
 "archiverVersion": "0.4.2"
}

❶ TorchServe .mar 文件元数据

❷ 包含标签信息

❸ 模型权重文件

❹ 模型架构和模型服务逻辑

❺ 词汇文件,意图算法所需

MANIFEST.json 文件定义了模型的元数据,包括模型版本、模型权重、模型名称和处理程序文件。通过拥有MANIFEST.json文件,TorchServe 知道如何加载和运行预测任意模型,而不知道其实现细节。

TorchServe 处理程序文件

一旦模型在 TorchServe 中注册,TorchServe 将使用模型处理程序文件中的handle(self, data, context)函数作为模型预测的入口点。处理程序文件管理模型服务的整个过程,包括模型初始化、对输入请求的预处理、模型执行和对预测结果的后处理。

代码清单 7.13 强调了在该示例服务中使用的意图分类 .mar 文件的处理程序文件中定义的关键部分。您可以在我们的 Git 代码库中找到此文件,路径为training-code/text-classification/torchserve_handler.py

列表 7.13 意图模型 TorchServe 处理文件

class ModelHandler(BaseHandler):
   """
   A custom model handler implementation for serving 
   intent classification prediction in torch serving server.
   """

   # Model architecture 
   class TextClassificationModel(nn.Module):
       def __init__(self, vocab_size, embed_dim, fc_size, num_class):
           super(ModelHandler.TextClassificationModel, self)
➥ .__init__()
           self.embedding = nn.EmbeddingBag(vocab_size, 
➥ embed_dim, sparse=True)
           self.fc1 = nn.Linear(embed_dim, fc_size)
           self.fc2 = nn.Linear(fc_size, num_class)
           self.init_weights()

       def init_weights(self):
           .. .. ..

       def forward(self, text, offsets):
           embedded = self.embedding(text, offsets)
           return self.fc2(self.fc1(embedded))

   # Load dependent files and initialize model
   def initialize(self, ctx):

       model_dir = properties.get("model_dir")
       model_path = os.path.join(model_dir, "model.pth")
       vacab_path = os.path.join(model_dir, "vocab.pth")
       manifest_path = os.path.join(model_dir, "manifest.json")

       # load vocabulary
       self.vocab = torch.load(vacab_path)

       # load model manifest, including label index map.
       with open(manifest_path, 'r') as f:
           self.manifest = json.loads(f.read())
       classes = self.manifest['classes']

       # intialize model
       self.model = self.TextClassificationModel(
           vocab_size, emsize, self.fcsize, num_class).to("cpu")
       self.model.load_state_dict(torch.load(model_path))
       self.model.eval()
       self.initialized = True

 # Transform raw input into model input data.
   def preprocess(self, data):

       preprocessed_data = data[0].get("data")
       if preprocessed_data is None:
           preprocessed_data = data[0].get("body")

       text_pipeline = lambda x: self.vocab(self.tokenizer(x))

       user_input = " ".join(str(preprocessed_data))
       processed_text = torch.tensor(text_pipeline(user_input), 
            dtype=torch.int64)
       offsets = [0, processed_text.size(0)]
       offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
       return (processed_text, offsets)

   # Run model inference by executing the model with model input
   def inference(self, model_input):
       model_output = self.model.forward(model_input[0], model_input[1])
       return model_output

 # Take output from network and post-process to desired format
   def postprocess(self, inference_output):
       res_index = inference_output.argmax(1).item()
       classes = self.manifest['classes']
       postprocess_output = classes[str(res_index)]
       return [{"predict_res":postprocess_output}]

 # Entry point of model serving, invoke by TorchServe 
 # for prediction request 
   def handle(self, data, context):

       model_input = self.preprocess(data)
       model_output = self.inference(model_input)
       return self.postprocess(model_output)

从清单 7.13 中的handle函数开始,您将清楚地了解处理程序文件是如何执行模型服务的。initialize函数加载所有模型文件(权重、标签和词汇表)并初始化模型。handle函数是模型服务的入口点;它对二进制模型输入进行预处理,运行模型推断,对模型输出进行后处理,并返回结果。

在训练过程中打包 .mar 文件

当我们决定在模型服务中使用 TorchServe 时,最好在训练时生成 .mar 文件。另外,因为 TorchServe 处理程序文件包含模型架构和模型执行逻辑,通常是模型训练代码的一部分。

打包 .mar 文件有两种方法。首先,在模型训练完成后,我们可以运行torch-model-archiver CLI 工具,将模型权重打包成序列化文件,将依赖文件作为额外文件。其次,我们可以使用model_ archiver Python 库,在模型训练代码的最后一步生成 .mar 文件。以下代码片段是我们用于打包意图分类模型的示例:

## Method one: package model by command line cli tool.
torch-model-archiver --model-name intent_classification --version 1.0 \
 --model-file torchserve_model.py --serialized-file \
    workspace/MiniAutoML/{model_id}/model.pth \
 --handler torchserve_handler.py --extra-files \
workspace/MiniAutoML/{model_id}/vocab.pth,
➥ workspace/MiniAutoML/{model_id}/manifest.json

## Method two: package model in training code.
model_archiver.archive(model_name=archive_model_name, 
  handler_file=handler, model_state_file=model_local_path,
  extra_files=extra_files, model_version=config.MODEL_SERVING_VERSION,
  dest_path=config.JOB_ID)

7.2.7 在 Kubernetes 中扩展规模

在我们的示例服务中,为了演示目的,我们运行单个 TorchServe 容器作为预测后端,但这在生产环境中并非如此。扩展 TorchServe 面临的挑战如下:

  • 负载均衡器使 TorchServe 模型注册变得困难。在 TorchServe 中,模型文件需要先注册到 TorchServe 服务器,然后才能使用。但是在生产环境中,TorchServe 实例被放置在网络负载均衡器后,因此我们只能将预测请求发送到负载均衡器,让它将请求路由到随机的 TorchServe 实例。在这种情况下,由于我们无法指定哪个 TorchServe 实例为哪个模型提供服务,注册模型变得困难。负载均衡器向我们隐藏了 TorchServe 实例。

  • 每个 TorchServe 实例都需要有一个用于加载模型的模型存储目录,并且在可以注册之前,模型文件需要放在模型存储目录中。有多个 TorchServe 实例会使模型文件复制变得难以管理,因为我们需要知道每个 TorchServe 实例的 IP 地址或 DNS 来复制模型文件。

  • 我们需要在 TorchServe 实例之间平衡模型。让每个 TorchServe 实例加载每个模型文件是一个糟糕的想法;这将极大浪费计算资源。我们应该将负载均匀分配到不同的 TorchServe 实例上。

要解决这些挑战并扩展 TorchServe 后端,我们可以在 Kubernetes 中引入“边车”模式。图 7.7 描绘了整体概念。

图 7.7 在 Kubernetes 中的 TorchServe pod 中添加代理容器以扩展 TorchServe。

图 7.7 中的建议是在每个 TorchServe pod 中与 TorchServe 容器一起添加代理容器(作为边车)。我们不直接调用 TorchServe API,而是将预测请求发送到代理容器。代理容器中的代理 API 将隐藏 TorchServe 模型管理细节,包括模型下载和模型注册。它将准备 TorchServe 容器以服务任意模型。

添加代理容器后,模型服务工作流程(图 7.7)如下。首先,预测请求落在代理容器上。其次,代理下载模型文件并将其输入到共享磁盘(模型存储库)。第三,代理将模型注册到 TorchServe 容器并将推理请求转换为 TorchServe 格式。第四,TorchServe 容器运行模型服务并将结果返回给代理。最后,代理容器将预测响应返回给用户。

通过添加代理容器,我们无需担心将预测请求发送到未注册该模型的 TorchServe 实例。代理容器(边车)将确保 TorchServe 容器准备好处理任何预测请求,方法是将模型文件复制到模型存储库并注册模型。这也简化了资源管理工作,因为现在我们可以简单地依赖负载均衡器将预测负载(模型)在 TorchServe pod 之间分配。此外,通过在所有 TorchServe pod 之间共享磁盘,我们可以为所有 TorchServe 实例共享模型存储库,从而减少了模型下载时间并节省了网络带宽。

边车模式:运行模型服务器的常用方法。

在第 7.4 节中,我们将介绍其他几种模型服务器方法,例如 TensorFlow serving 和 Triton。尽管这些模型服务器的实现方式不同,但它们的设计思想是相似的。它们都采用黑盒方法,并需要特定的模型格式和一些模型管理来启用模型服务。

图 7.7 中的旁路模式是在 Kubernetes pod 中运行这些不同模型服务器容器的常见解决方案。代理容器封装了模型服务器的所有特殊要求,并且只暴露通用模型服务 API。

7.3 模型服务器 vs. 模型服务

在设计模型服务应用程序时,选择模型服务器方法和模型服务方法之间是我们需要做出的第一个决定。当我们选择不当时,我们的服务应用程序要么难以使用和维护,要么构建所需时间过长。

我们已经在第六章(第 6.2 节和第 6.3 节)中回顾了这两种方法之间的差异,但这是一个非常关键的选择,值得再次审查。现在你已经看到了每种方法的具体示例,这些想法可能更容易理解。

通过在第 7.1 节和第 7.2 节中讨论的两个示例服务,可以清楚地看出,模型服务器方法避免了为特定模型类型构建专用后端预测器的工作量。相反,它可以直接使用,并且可以为任意模型提供服务,而不管模型实现了哪种算法。因此,模型服务器方法似乎应该始终是最佳选择。但这并不是真的;选择模型服务器或模型服务应该取决于用例和业务需求。

对于单一应用场景,在实践中,模型服务方法更容易构建和维护。模型服务后端预测器相当容易构建,因为模型服务代码是训练代码的简化版本。这意味着我们可以轻松地将模型训练容器转换为模型服务容器。一旦构建完成,模型服务方法更容易维护,因为我们完全拥有代码,并且工作流程简单。对于模型服务器方法,无论我们选择开源、预构建的模型服务器还是构建自己的服务器,设置系统的过程都很复杂。要学会并运营和维护系统需要花费大量的精力。

对于模型服务平台场景,系统需要支持多种不同类型的模型时,模型服务器方法无疑是最佳选择。当你为 500 种不同类型的模型构建模型服务系统时,如果选择模型服务器方法,你只需要一个单一类型的预测器后端来支持所有模型。相比之下,使用模型服务方法,你将需要 500 种不同的模型预测器!管理计算资源和进行所有这些预测器的维护工作非常困难。

我们的建议是,当你初学时使用模型服务方法,因为它更简单、更容易。当你需要在你的服务系统中支持超过 5 到 10 种类型的模型或应用时,你可以转向模型服务器方法。

7.4 开源模型服务工具巡回

有很多开源的模型服务工具可以使用。这些选择非常多,但是选择这么多有时会让人感到困惑。为了帮助您更轻松地做出选择,我们将向您介绍一些流行的模型服务工具,包括 TensorFlow Serving、TorchServe、Triton 和 KServe。所有这些工具都可以立即使用,并适用于生产用例。

因为这里描述的每一个工具都有详尽的文档,所以我们将保持讨论在一个通用的层面,只看它们的总体设计、主要特征和适当使用情况。这些信息应该足以作为一个起点,让你自己深入探索。

7.4.1 TensorFlow Serving。

TensorFlow Serving (www.tensorflow.org/tfx/guide/serving) 是一个可自定义的独立网页系统,用于在生产环境中提供 TensorFlow 模型。TensorFlow Serving 采用模型服务器方法;它可以使用相同的服务器架构和 API 为所有类型的 TensorFlow 模型提供服务。

特征。

TensorFlow Serving 提供以下特点:

  • 可以为多个模型或相同模型的多个版本提供服务。

  • 与 TensorFlow 模型具有开箱即用的整合。

  • 自动发现新的模型版本,支持不同的模型文件源。

  • 具有统一的 gRPC 和 HTTP 端点,用于模型推断。

  • 支持批量预测请求和性能调优。

  • 具有可扩展的设计,可以在版本策略和模型加载上进行自定义。

高级架构。

在 TensorFlow Serving 中,一个模型由一个或多个可用服务程序组成。可用服务程序是用于执行计算的基础对象(例如查找或推断);它是 TensorFlow Serving 中的中心抽象。来源是用于查找和提供可用服务程序的插件模块。装载器标准是用于装载和卸载可用服务程序的 API。管理器处理可用服务程序的整个生命周期,包括加载、卸载和提供可用服务程序。

图 7.8 显示了 TensorFlow Serving 的架构和模型服务的生命周期。蓝色 = 最深的灰色;绿色 = 较浅的灰色;黄色 = 最浅的灰色。(来源:TensorFlow;mng.bz/KlNj

图 7.8 说明了向客户提供可提供服务的工作流程。首先,源插件为特定的可用服务程序创建一个加载器,加载器包含加载可用服务程序的元数据。第二,源从文件系统(模型库)中找到一个可用服务程序;它通知 DynamicManager 可用服务程序的版本和加载器。第三,基于预定义的版本策略,DynamicManager 确定是否加载模型。最后,客户端发送一个预测请求给可用服务程序,DynamicManager 返回一个句柄,以便客户端可以执行模型。

TensorFlow Serving 模型文件。

TensorFlow Serving 要求模型以 SavedModel (mng.bz/9197) 格式保存。我们可以使用 tf.saved_model.save(model, save_path) API 来实现这个目的。一个保存的模型是一个包含了序列化签名和运行它们所需的状态的目录,包括变量值和词汇表。例如,一个保存的模型目录有两个子目录,assetsvariables,以及一个文件,saved_model.pb

assets 文件夹包含了 TensorFlow 图使用的文件,比如用于初始化词汇表的文本文件。variables 文件夹包含了训练检查点。saved_model.pb 文件存储了实际的 TensorFlow 程序,或者说模型,以及一组命名的签名,每个签名标识了一个接受张量输入并产生张量输出的函数。

模型服务

因为 TensorFlow 的 SavedModel 文件可以直接加载到 TensorFlow Serving 进程中,所以运行模型服务非常简单。一旦服务进程启动,我们可以将模型文件复制到 TensorFlow Serving 的模型目录中,然后立即发送 gRPC 或 REST 预测请求。让我们来看下面的预测示例:

# 1\. Save model in training code 
MODEL_DIR='tf_model'
version = "1"
export_path = os.path.join(MODEL_DIR, str(version))
model.save(export_path, save_format="tf")

# 2\. Start tensorflow serving service locally as a docker container
docker run -p 8501:8501 
--mount type=bind,source=/workspace/tf_model,target=/models/model_a/ 
-e MODEL_NAME=model_a -t tensorflow/serving
--model_config_file_poll_wait_seconds=60
--model_config_file=/models/model_a/models.config 

# 3\. Send predict request to local tensorflow serving docker container 
# The request url pattern to call a specific version of a model is
   /v1/models/<model name>/versions/<version number> 
json_response = requests.post('http:/ /localhost:8501/
 ➥ v1/models/model_a/versions/1:predict', 
  data=data, headers=headers)

为了将多个模型和同一模型的多个版本加载到服务服务器中,我们可以在模型配置中配置模型的版本,如下所示:

model_config_list {
  config{
    name: 'model_a'
    base_path: '/models/model_a/'
    model_platform: 'tensorflow'
    model_version_policy{
      specific{
        versions:2    ❶
        versions:3    ❷
      }
    }
  }
  config{
    name: 'model_b'
    base_path: '/models/model_b/'
    model_platform: 'tensorflow'
  }
}

❶ 在 /models/model_a/versions/2 找到模型 v2

❷ 在 /models/model_a/versions/3 找到模型 v3

在这个配置中,我们定义了两个模型,model_amodel_b。因为 model_a 有一个 model_version_policy,所以两个版本(v2 和 v3)都被加载并可以提供请求服务。默认情况下,模型的最新版本将被提供服务,所以当检测到 model_b 的新版本时,旧版本将被新版本替换。

回顾

TensorFlow Serving 是用于 TensorFlow 模型的生产级模型服务解决方案;它支持 REST、gRPC、GPU 加速、小批量处理和边缘设备上的模型服务。虽然 TensorFlow Serving 在高级指标、灵活的模型管理和部署策略方面存在不足,但如果你只有 TensorFlow 模型的话,它仍然是一个不错的选择。

TensorFlow Serving 的主要缺点是它是一个供应商锁定的解决方案;它只支持 TensorFlow 模型。如果你正在寻找一个训练框架无关的方法,那么 TensorFlow Serving 将不是你的选择。

7.4.2 TorchServe

TorchServe (pytorch.org/serve/) 是一个性能出色、灵活且易于使用的工具,用于为 PyTorch eager 模式和 torchscripted 模型提供服务(torchscripted 模型是 PyTorch 模型的一种中间表示,可以在高性能环境(如 C++)中运行)。与 TensorFlow Serving 类似,TorchServe 采用模型服务器方法为所有类型的 PyTorch 模型提供服务,并提供统一的 API。不同之处在于 TorchServe 提供了一组管理 API,使模型管理非常方便灵活。例如,我们可以以编程方式注册和注销模型或模型的不同版本。我们还可以为模型和模型的不同版本扩展和缩小服务工作程序。

高层架构

一个 TorchServe 服务器由三个组件组成:前端、后端和模型存储。前端处理 TorchServe 的请求/响应。它还管理模型的生命周期。后端是一组负责在模型上运行实际推断的模型工作程序。模型存储是一个包含所有可加载模型的目录;它可以是云存储文件夹或本地主机文件夹。图 7.9 显示了 TorchServing 实例的高级架构。

图 7.9 TorchServe 架构图(来源:Kuldeep Singh,“使用 TorchServe 将命名实体识别模型部署到生产环境”,Analytics Vidhya,2020)

图 7.9 描绘了两个工作流程:模型推断和模型管理。对于模型推断,首先,用户将预测请求发送到模型的推断端点,例如 /predictions/{model_name}/{version}。然后,推断请求被路由到已加载模型的工作进程之一。接下来,工作进程将从模型存储中读取模型文件,并让模型处理器加载模型,预处理输入数据,并运行模型以获得预测结果。

对于模型管理,用户需要在可以访问模型之前注册模型。这可以通过使用管理 API 来完成。我们还可以为模型调整工作进程计数的规模。我们将在即将到来的示例使用部分中看到一个示例。

功能

TorchServe 提供以下功能:

  • 可以为多个模型或同一模型的多个版本提供服务

  • 将模型推断的 gRPC 和 HTTP 端点统一起来

  • 支持批量预测请求和性能调优

  • 支持将 PyTorch 模型和 Python 函数组合成顺序和并行管道的工作流程

  • 提供管理 API 来注册/注销模型和调整工作进程的规模

  • 处理模型版本控制,用于 A/B 测试和实验

Torch 服务模型文件

纯 PyTorch 模型不能直接加载到 Torch 服务服务器中。TorchServe 要求将其所有模型打包成 .mar 文件。请参阅 7.2.6 节,了解如何创建 .mar 文件的详细示例。

模型服务

下面的代码片段列出了使用 TorchServe 运行模型推理的五个一般步骤。有关具体示例,您可以查看我们的示例意图分类预测器的 README 文档(mng.bz/WA8a):

# 1\. Create model store directory for torch serving
# and copy model files (mar files) to it
mkdir -p /tmp/model_store/torchserving
cp sample_models/intent.mar /tmp/model_store/torchserving                       ❶

# 2\. Run the torch serving container
docker pull pytorch/torchserve:0.4.2-cpu
docker run --rm --shm-size=1g \
       --ulimit memlock=-1 \
       --ulimit stack=67108864 \
       -p8080:8080 \
       -p8081:8081 \
       -p8082:8082 \
       -p7070:7070 \
       -p7071:7071 \
       --mount type=bind,source=/tmp/model_store/torchserving,target=/tmp/models❷
pytorch/torchserve:0.4.2-cpu torchserve --model-store=/tmp/models

# 3\. Register intent model through torchserving management api
curl -X POST  "http:/ /localhost:8081/models?url=                               ❸
➥ intent_1.mar&initial_workers=1&model_name=intent"                            ❸

# 4\. Query intent model in torch serving with default version.
curl --location --request GET 'http:/ /localhost:8080/predictions/intent' \
--header 'Content-Type: text/plain' \
--data-raw 'make a 10 minute timer'

# 5\. Query intent model in torch serving with specified version - 1.0
curl --location --request GET 'http:/ /localhost:8080/predictions/intent/1.0' \
--header 'Content-Type: text/plain' \
--data-raw 'make a 10 minute timer'

❶ 创建本地模型目录并复制意图分类模型

❷ 将本地模型目录绑定为 TorchServe 的模型存储目录

❸ Intent_1.mar 包含模型文件和模型元数据,例如模型版本。

使用管理 API 注册模型之外,我们还可以使用 scale worker API 动态调整任何版本模型的工作节点数量,以更好地服务不同的推理请求负载,如下例所示:

# 1\. Scale up the worker number for the intent model. Default number is 1.
# Set minimum worker count to 3 and maximum worker count to 6 
# for version 1.0 of the intent model 
curl -v -X PUT "http:/ /localhost:8081/models/intent/1.0?min_worker=3&max_worker=6"

# 2\. Use the describe model API to get detail runtime status of 
# default version of the intent model.
curl http:/ /localhost:8081/models/intent

# 3\. Use the describe model API to get detail runtime status of 
# specific version (1.0) of the intent model.
curl http:/ /localhost:8081/models/intent/1.0 

回顾

TorchServe 是用于 PyTorch 模型的生产级模型服务解决方案;它专为高性能推理和生产用例而设计。TorchServe 的管理 API 增加了许多灵活性,用于自定义模型部署策略,并允许我们在每个模型级别管理计算资源。

与 TensorFlow Serving 类似,TorchServe 的主要缺点是它是一种供应商锁定解决方案;它仅支持 PyTorch 模型。因此,如果您正在寻找一种训练框架不可知的方法,TorchServe 将不是您的选择。

7.4.3 Triton 推理服务器

Triton 推理服务器(developer.nvidia.com/nvidia-triton-inference-server)是由 NVIDIA 开发的开源推理服务器。它提供了一个针对 CPU 和 GPU 优化的云和边缘推理解决方案。Triton 支持 HTTP/REST 和 gRPC 协议,允许远程客户端请求服务器管理的任何模型的推理。对于边缘部署,Triton 可作为具有 C API 的共享库提供,允许直接在应用程序中包含 Triton 的全部功能。

与其他服务工具相比,Triton 的训练框架兼容性是其主要优势之一。与仅适用于 TensorFlow 模型的 TensorFlow Serving 和仅适用于 PyTorch 模型的 Torch 服务不同,Triton 服务器可以为从几乎任何框架训练的模型提供服务,包括 TensorFlow、TensorRT、PyTorch、ONNX 和 XGBoost。Triton 服务器可以从本地存储、Google Cloud Platform 或 Amazon Simple Storage Service (Amazon S3) 加载模型文件,并在基于 GPU 或 CPU 的基础设施(云、数据中心或边缘)上运行。

推理性能对于 Triton 也是一项优势。Triton 在 GPU 上并发运行模型,以最大化吞吐量和利用率;支持基于 x86 和 ARM CPU 的推断;并提供动态批处理、模型分析器、模型集成和音频流等功能。这些功能使模型服务内存高效且稳健。

高层架构

图 7.10 显示了 Triton 推理服务器的高级架构。所有推理请求都作为 REST 或 gRPC 请求发送,然后在内部转换为 C API 调用。模型从模型仓库加载,模型仓库是一个基于文件系统的仓库,我们可以将其视为文件夹/目录。

图 7.10 Triton 推理服务器的高级架构(来源:NVIDIA Developer,developer.nvidia.com/nvidia-triton-inference-server

对于每个模型,Triton 准备一个调度程序。调度和批处理算法可以根据模型逐个配置。每个模型的调度程序可选择对推理请求进行批处理,然后将请求传递给与模型类型对应的后端,例如 PyTorch 模型的 PyTorch 后端。 Triton 后端是执行模型的实现。它可以是围绕深度学习框架(如 PyTorch、TensorFlow、TensorRT 或 ONNX Runtime)的包装器。一旦后端使用批处理请求中提供的输入执行推理以产生请求的输出,输出就会返回。

值得注意的是,Triton 支持一个后端 C API,允许通过自定义预处理和后处理操作或甚至一个新的深度学习框架来扩展 Triton 的功能。这就是我们如何扩展 Triton 服务器的方式。您可以查看 triton-inference-server/backend GitHub 仓库(github.com/triton-inference-server/backend)来找到所有 Triton 后端实现。作为一个额外的好处,由 Triton 服务的模型可以通过专用的模型管理 API 进行查询和控制,该 API 可通过 HTTP/REST、gRPC 协议或 C API 使用。

特点

Triton 提供以下功能:

  • 支持所有主要的深度学习和机器学习框架后端。

  • 在单个 GPU 或 CPU 上同时运行来自相同或不同框架的多个模型。在多 GPU 服务器上,Triton 会自动在每个 GPU 上创建每个模型的一个实例,以提高利用率。

  • 优化推理服务以进行实时推理、批量推理以最大化 GPU/CPU 利用率,并使用内置支持音频流输入的流推理。Triton 还支持模型集成,用于需要多个模型执行端到端推理的用例,例如对话型 AI。

  • 处理输入请求的动态批处理,以获得高吞吐量和利用率,并在严格的延迟约束下。

  • 在生产环境中实时更新模型,而无需重新启动推理服务器或中断应用程序。

  • 使用模型分析器自动找到最佳模型配置并最大化性能。

  • 支持大模型的多 GPU、多节点推理。

Triton 模型文件

Triton 中的每个模型都必须包含一个模型配置,提供关于模型的必需和可选信息。通常,它是一个指定为 ModelConfig protobuf 的 config.pbtxt 文件(mng.bz/81Kz)。以下是 PyTorch 模型的简单模型配置(config.pbtxt):

platform: “pytorch_libtorch”    ❶
pytorch_libtorch                ❷
  max_batch_size: 8             ❸
  input [                       ❹
    {
      name: “input0”
      data_type: TYPE_FP32
      dims: [ 16 ]
    },
    {
      name: “input1”
      data_type: TYPE_FP32
      dims: [ 16 ]
    }
  ]
  output [                      ❺
    {
      name: “output0”
      data_type: TYPE_FP32
      dims: [ 16 ]
    }
  ]

❶ 指定此模型的 PyTorch 服务后端

❷ 表示这是一个 PyTorch 后端配置

❸ 定义模型支持的最大批处理大小

❹ 模型输入数据架构

❺ 模型输出数据架构

通常,训练应用程序在训练服务完成训练后会创建此 config.pbtxt 文件,然后将此配置作为模型文件的一部分上传到模型存储库。有关 Triton 模型配置的更多详细信息,请查看 Triton 模型配置文档(mng.bz/Y6mA)。

除了统一的配置文件外,Triton 模型文件格式因训练框架而异。例如,TensorFlow 模型以 SavedModel 格式(mng.bz/El4d)可以直接加载到 Triton 中。但 PyTorch 模型需要由 TorchScript 程序保存。

TorchScript

TorchScript 是一种从 PyTorch 代码创建可序列化和可优化模型的方法。Triton 要求 PyTorch 模型被序列化为 TorchScript 的原因是 TorchScript 可以用作 PyTorch 模型的中间表示。它可以独立于 Python 运行,例如在独立的 C++ 程序中。请参阅以下代码片段,了解如何从 PyTorch 模型创建 TorchScript 模型:

#### Pytorch training code

# 1\. Define an instance of your model.
Model = ...TorchModel()

# 2\. Switch the model to eval model
model.eval()

# 3\. Build an example input of the model’s forward() method.
Example = torch.rand(1, 3, 224, 224)

# 4\. Use torch.jit.trace to generate a torch.jit.ScriptModule via tracing.
Traced_script_module = torch.jit.trace(model, example)
# 5\. Save the TorchScript model
traced_script_module.save(“traced_torch_model.pt”)   

对于其他训练框架的模型格式要求,请查看 triton-inference-server/backend GitHub 存储库(mng.bz/NmOn)。

模型服务

Triton 中的模型服务包括以下三个步骤:首先,将模型文件复制到模型存储库;其次,调用管理 API(POST v2/repository/ models/${MODEL_NAME}/load)注册模型;第三,发送推理请求(POST v2/models/${MODEL_NAME}/versions/${MODEL_VERSION})。有关 Triton 管理 API 的更多信息,请查看 Triton HTTP/REST 和 gRPC 协议文档(mng.bz/DZvR)。有关推理 API,请查看 KServe 社区标准推理协议文档(kserve.github.io/website/0.10/modelserving/data_plane/v2_protocol/)。

回顾

在撰写本书时,我们认为 Triton 是最佳的模型服务方法,原因有三。首先,Triton 不依赖任何训练框架;它提供了一个设计良好且可扩展的后端框架,使得它能够执行几乎任何训练框架构建的模型。其次,Triton 提供了更好的模型服务性能,例如服务吞吐量。Triton 有多种机制来提高其服务性能,例如动态批处理、GPU 优化和模型分析工具。第三,Triton 支持高级的模型服务用例,如模型集成和音频流式传输。

警告 要小心! Triton 可能不是免费的。Triton 采用 BSD 3-Clause "new" 或 "revised" 许可证,可以免费进行修改和商业发布。但是修复故障和错误呢?该项目非常复杂,代码量大,因此会在调试和修复性能问题(如内存泄漏)上带来一定困难。如果你想获取支持,可以考虑购买 NVIDIA 的 AI-Enterprise 许可证,但在本书撰写时,这将需要支付每年每块 GPU 数千美元的费用。所以在注册之前,请确保你了解 Triton 的代码库。

7.4.4 KServe 和其他工具

开源服务工具的列表很长,包括 KServe (www.kubeflow.org/docs/external-add-ons/kserve/)、Seldon Core (www.seldon.io/solutions/open-source-projects/core) 和 BentoML (github.com/bentoml/BentoML)。这些工具各有其独特的优势。它们要么运行轻量且易于使用,像 BentoML 一样,要么使得在 Kubernetes 中轻松快速部署模型,就像 Seldon Core 和 KServe 一样。尽管服务工具的多样性,它们也有很多共同之处:它们都需要以一定的格式打包模型,定义一个模型包装器和配置文件来执行模型,将模型上传到存储库,并通过 gRPC 或 HTTP/REST 端点发送预测请求。通过阅读本章中的 TorchServe、TensorFlow 和 Triton 示例,你应该能够自己探索其他工具。

在结束服务工具讨论之前,我们想特别提到 KServe。KServe 是几家知名高科技公司(包括 Seldon、Google、Bloomberg、NVIDIA、Microsoft 和 IBM)在模型服务方面的合作项目。这个开源项目值得关注,因为它旨在为常见的机器学习服务问题创建一个标准化的解决方案。

KServe 的目标是在 Kubernetes 上提供一种无服务器推断解决方案。它提供了一个抽象的模型服务接口,适用于像 TensorFlow、XGBoost、scikit-learn、PyTorch 和 ONNX 等常见的机器学习框架。

从我们的角度来看,KServe 的主要贡献在于它创建了一个标准的服务接口,适用于所有主要的服务工具。例如,我们之前提到的所有服务工具现在都支持 KServe 模型推理协议。这意味着我们可以仅使用一组推理 API(KServe API)来查询由不同服务工具托管的任何模型,如 Triton、TorchServe 和 TensorFlow。

KServe 的另一个优势是它被设计为在 Kubernetes 上本地提供无服务器解决方案。KServe 使用 Knative 来处理网络路由、模型工作器自动扩展(甚至到零)和模型版本跟踪。通过简单的配置(见下面的示例),您可以将模型部署到您的 Kubernetes 集群,然后使用标准化的 API 来查询它:

apiVersion: serving.kserve.io/v1beta1             ❶
kind: InferenceService
metadata:
 name: “torchserve”
spec:
 predictor:
   pytorch:                                       ❷
     storageUri: gs://kfserving-examples/models   ❸
          ➥ /torchserve/image_classifier         ❸

❶ KServe 的样本模型部署配置

❷ 后端服务器类型

❸ 模型文件位置

在幕后,KServe 使用不同的服务工具来运行推理,如 TensorFlow Serving 和 Triton。KServe 提供了一个隐藏所有细节的简单 Kubernetes CRD 配置的好处。在前面的示例中,InferenceService CRD 配置隐藏了工作,包括预测服务器设置、模型复制、模型版本跟踪和预测请求路由。

在书写本书时,KServe 的新版本(v2)仍处于测试阶段。虽然它还不够成熟,但其在跨平台支持和无服务器模型部署方面的独特优势使其在其他方法中脱颖而出。如果您想要建立一个适用于 Kubernetes 上所有主要训练框架的大型服务平台,那么 KServe 值得您的关注。

7.4.5 将服务工具集成到现有服务系统中

在许多情况下,用新的服务后端替换现有的预测服务是不可行的选择。每个服务工具对于模型存储、模型注册和推理请求格式都有自己的要求。这些要求有时与现有系统的预测接口以及内部模型元数据和文件系统相冲突。为了引入新技术而不影响业务,我们通常采取集成方法,而不是完全替换它。

在这里,我们以 Triton 服务器为例,展示如何将服务工具集成到现有的预测服务中。在这个示例中,我们假设三件事情:首先,现有的预测服务在 Kubernetes 中运行;其次,现有的预测服务的 Web 推理接口不允许更改;第三,有一个模型存储系统,将模型文件存储在云存储中,如 Amazon S3。图 7.11 显示了这个过程。

图 7.11 提议将一系列 Triton 服务器实例集成到现有服务系统中

图 7.11(A)说明了系统概述。一列 Triton 服务器 Kubernetes pod 被添加到现有的预测 API 后面。通过 Kubernetes 负载均衡器,预测请求可以落在任何 Triton pod 上。我们还添加了一个共享卷,所有 Triton pod 都可以访问;这个共享卷充当了所有 Triton 实例的共享 Triton 模型存储库。

图 7.11(B)显示了 Triton 服务器 Kubernetes pod 内部的内容。每个 Triton pod 都有两个 Docker 容器:一个 Triton 服务器容器和一个 sidecar 容器。Triton 服务器容器是我们在第 7.4.3 节中讨论的 Triton 推理服务器。模型预测发生在此容器中,我们可以简单地将此容器视为黑匣子。sidecar 容器充当适配器/代理,以准备 Triton 在将预测请求转发到 Triton 容器之前需要的内容。这个 sidecar 容器从云存储中下载模型到 Triton 的本地模型存储库(共享卷),调用 Triton 注册模型,并将预测请求转换为 Triton API 调用。

使用这种集成方法,所有的更改都发生在预测服务内部。公共预测 API 和外部模型存储系统保持不变,当我们切换到 Triton 后端时,我们的用户不会受到影响。虽然我们使用了一个特定的工具(Triton)和一个特定的基础设施(Kubernetes)来演示这个想法,但只要它们使用 Docker,你就可以将这种模式应用到任何其他系统中。

注意 因为 Triton 服务器支持主要的训练框架,KServe 提供了一个标准化的服务协议,我们可以将它们结合起来生成一个适用于不同框架训练的各种模型的服务系统。

7.5 发布模型

发布模型是将新训练的模型部署到预测服务并向用户公开的行为。在构建生产中的模型服务系统时,自动化模型部署和支持模型评估是我们需要解决的两个主要问题。

首先,当训练服务完成模型构建时,应自动将模型发布到生产环境中的预测服务。其次,新发布的模型及其先前版本应全部在预测服务中可访问,以便我们可以在相同环境中对它们进行评估并进行公平比较。在本节中,我们提出了一个三步模型发布过程来解决这些挑战。

首先,数据科学家(Alex)或训练服务向元数据存储注册最近生成的模型(由模型文件和其元数据组成)—这是一个将在下一章中讨论的云元数据和工件存储系统。其次,Alex 对新注册的模型进行模型评估。他可以通过向预测服务发送具有特定模型版本的预测请求来测试这些模型的性能。预测服务具有从元数据存储加载任何特定版本模型的内置机制。

第三,Alex 将性能最佳的模型版本设置为元数据存储中的发布模型版本。一旦设置完成,所选版本的模型将会公开!客户应用程序将不知不觉地开始使用来自预测服务的新发布版本的模型。图 7.12 说明了这个三步骤的过程。

图 7.12 模型发布流程工作流程:(1)在模型元数据存储中注册模型;(2)加载模型的任意版本以提供预测请求;以及(3)在元数据存储中发布模型

在接下来的三个部分中,我们将逐个探讨三个模型发布步骤(如图 7.12 所示)。在此过程中,我们还将探讨元数据存储的细节以及其与存储和预测服务的交互。让我们开始吧!

7.5.1 注册模型

在大多数深度学习系统中,都有一个存储模型的存储服务。在我们的示例中,这个服务称为元数据存储;它用于管理深度学习系统生成的工件的元数据,如模型。元数据和工件存储服务将在下一章中详细讨论。

要向元数据存储注册模型,通常需要提供模型文件和模型元数据。模型文件可以是模型权重、嵌入和执行模型所需的其他依赖文件。模型元数据可以是描述模型事实的任何数据,例如模型名称、模型 ID、模型版本、训练算法、数据集信息和训练执行指标。图 7.13 说明了元数据如何在内部存储模型元数据和模型文件。

图 7.13 元数据存储的内部存储设计;模型元数据存储为对象文件,并带有前置查找表。

在图 7.13 中,我们可以看到元数据存储有两个部分:模型查找表和模型元数据列表。模型元数据列表只是纯元数据存储;所有模型元数据对象都存储在这个列表中。模型查找表用作快速搜索的索引表。查找表中的每个记录指向元数据列表中的实际元数据对象。

在训练完成后,训练服务可以自动将模型注册到元数据存储中。数据科学家也可以手动注册模型,这通常发生在数据科学家想要部署他们本地构建的模型(而不使用深度学习系统)时。

当元数据存储接收到模型注册请求时,首先,它为此模型创建一个元数据对象。其次,通过添加一个新的搜索记录来更新模型查找表;该记录使我们能够通过使用模型名称和版本来找到该模型元数据对象。除了通过使用模型名称和版本搜索查找表外,元数据存储还允许通过使用模型 ID 进行模型元数据搜索。

实际模型文件存储在工件存储中——云对象存储,例如 Amazon S3。模型在工件存储中的存储位置保存在模型的元数据对象中作为指针。

图 7.13 显示了模型查找表中模型 A 的两个搜索记录:版本 1.0.0 和 1.1.0。每个搜索记录映射到不同的模型元数据对象(分别为 ID = 12345 和 ID = 12346)。有了这种存储结构,我们可以通过使用模型名称和模型版本找到任何模型元数据;例如,我们可以通过搜索“模型 A”和版本“1.1.0”找到模型元数据对象 ID = 12346。

使用模型的规范名称和版本来查找实际的元数据和模型文件是预测服务同时提供不同模型版本的能力的基础。让我们在下一节中看看元数据存储在预测服务中的应用。

- 实时加载模型的任意版本与预测服务

为了在生产环境中决定使用哪个模型版本,我们希望公平地(在相同的环境中)和轻松地(使用相同的 API)评估每个模型版本的模型性能。为此,我们可以调用预测服务以使用不同的模型版本运行预测请求。

在我们的提案中,当预测服务收到预测请求时,它会实时从元数据存储加载模型。数据科学家可以通过在预测请求中定义模型名称和版本来允许预测服务使用任何模型版本来运行预测。图 7.14 说明了该过程。

图 7.14 模型在预测服务中的服务

图 7.14 显示了预测服务实时加载指定的服务请求中的模型。在接收到预测请求时,路由层首先在元数据存储中找到请求的模型,下载模型文件,然后将请求传递给后端预测器。以下是运行时模型加载和服务过程的七个步骤的详细说明:

  1. 用户向预测服务发送预测请求。在请求中,他们可以通过提供模型名称和版本(/predict/{model_name}/{version})或模型 ID(/predict/{model_id})来指定要使用的模型。

  2. 预测服务内部的路由层搜索元数据存储,并找到模型元数据对象。

  3. 路由层然后将模型文件下载到所有预测器都可以访问的共享磁盘上。

  4. 通过检查模型元数据,例如算法类型,路由层将预测请求路由到正确的后端预测器。

  5. 预测器从共享磁盘加载模型。

  6. 预测器处理数据预处理,执行模型,执行后处理,并将结果返回给路由层。

  7. 路由层将预测结果返回给调用者。

7.5.3 通过更新默认模型版本释放模型

在模型评估之后,模型释放的最后一步是让客户在预测服务中使用新验证的模型版本。我们希望模型释放过程在不知不觉中发生,以便客户不知道底层模型版本的更改。

在上一节(7.5.2)的步骤 1 中,用户可以使用/predict/{model_name}/{version}API 请求任何指定模型版本的模型服务。这种能力对于评估同一模型的多个版本至关重要,因此我们可以防止模型性能回归。

但在生产场景中,我们不希望客户跟踪模型版本和模型 ID。相反,我们可以定义几个静态版本字符串作为变量来表示新发布的模型,并让客户在预测请求中使用它们,而不是使用真实的模型版本。

例如,我们可以定义两个特殊的静态模型版本或标签,例如STGPROD,分别表示预生产和生产环境。如果与模型 A 关联的PROD标签的模型版本为1.0.0,则用户可以调用/predict/model_A/PROD,而预测服务将加载模型 A 和版本1.0.0来运行模型服务。当我们将新发布的模型版本升级到1.2.0时——将PROD标签与版本 1.2.0 关联——/predict/model_A/PROD请求将落在模型版本1.2.0上。

有了特殊的静态版本/标签字符串,预测用户不需要记住模型 ID 或版本;他们只需使用/predict/{model_name}/PROD即可发送预测请求以消耗新发布的模型。在幕后,我们(数据科学家或工程师)维护这些特殊字符串与元数据存储的查找表中实际版本之间的映射,因此预测服务知道对于/STG/PROD请求下载哪个模型版本。

在我们的提案中,我们将将特定模型版本映射到静态模型版本的操作命名为模型释放操作。图 7.15 说明了模型释放过程。

图片 7.15

图 7.15 在带有元数据存储的预测服务中模型提供服务

在图 7.15 中,数据科学家首先在元数据存储中将模型 A,版本 1.0.0 注册到模型 A,版本PROD。然后在模型查找表中,(Model A, PROD) 记录更改为指向实际的模型对象记录(ModelA, version: 1.0.0)。因此,当用户在预测服务中调用/predict/ModelA/PROD时,他们实际上是在调用/predict/ModelA/1.0.0

接下来,当预测服务收到一个模型版本等于STGPROD的预测请求时,服务将在元数据存储中搜索查找表,并使用实际的模型版本,即已注册到PROD的版本,来下载模型文件。在图 7.15 中,预测服务将为/ModelA/PROD的请求加载模型ModelA, version: 1.0.0,并为/ModelA/STG的请求加载模型ModelA, version: 1.1.0。

对于未来的模型发布,数据科学家只需在元数据存储的查找表中更新模型记录,将最新的模型版本映射到STGPROD。预测服务将自动加载新的模型版本以响应新的预测请求。所有这些操作都是自动进行的,对用户来说是不可感知的。

注意:所提出的发布工作流程并不是发布模型的唯一方法。模型发布方法高度依赖于公司内部的 DevOps 流程和预测服务设计,因此在这个主题上没有单一的最佳设计。我们希望通过阅读第 7.5 节中的问题分析和提出的解决方案,您可以得出适合您情况的模型发布流程。

7.6 生产后模型监控

与监控其他服务(如数据管理)相比,在机器学习系统中,模型投入生产后工作仍未完成。我们不仅需要监控和维护预测服务本身,还需要关注服务提供的模型性能。模型漂移是指知识领域分布发生变化,不再与训练数据集匹配,导致模型性能下降。这可能发生在预测服务完全正常运行的情况下,因为模型推理是独立于预测服务运行的。

为了应对模型漂移,数据科学家需要使用新数据重新训练模型或使用改进的训练算法重建模型。表面上,这听起来像是一个数据科学项目,但它需要大量的底层工程工作,例如从预测服务收集和分析模型指标以检测模型漂移。在本节中,我们从工程的角度讨论模型监控,并探讨工程师在监控过程中的作用。

7.6.1 指标收集和质量门限

工程师可以为 模型指标收集模型质量门设置 这两个最重要的领域做出贡献。让我们解释一下。

为了运行检测模型漂移的分析,数据科学家需要分析数据,工程师可以找到途径提供必要的数据(指标)。尽管工程师可能必须创建一个单独的数据管道来收集模型性能指标,但在大多数情况下这将是不划算的。通常,模型性能指标可以使用现有的遥测系统(如 Datadog)和日志记录系统(如 Sumo 和 Splunk)进行收集和可视化。因此,请善用您已经拥有的现有日志记录和指标系统,而不是费力地建立一个新的指标系统。

工程师还可以帮助构建模型质量门。工程师可以与数据科学家合作,自动化他们的故障排除步骤,例如检查数据质量和生成模型推理分析报告。通过给定的阈值,这些检查最终将形成一个模型质量门。

7.6.2 需要收集的指标

从理论上讲,我们需要收集至少五种指标来支持模型性能测量。它们是预测跟踪、预测日期、模型版本、观察和观察率、以及日期。让我们逐一来看看它们:

  • 预测跟踪 — 通常,我们通过为每个预测请求分配一个唯一的请求 ID 来跟踪每个预测请求,但这还不够。对于一些复杂的场景,比如 PDF 扫描,我们将不同类型的模型预测组合在一起以产生最终结果。例如,我们首先将 PDF 文档发送到 OCR(光学字符识别)模型以提取文本信息,然后将文本发送到 NLP(自然语言处理)模型以识别目标实体。在这种情况下,除了为父预测请求分配唯一的请求 ID 外,我们还可以为每个子预测请求分配一个 groupRequestID,这样我们就可以在故障排除时将所有相关的预测请求分组起来。

  • 预测日期 — 通常,预测请求在一秒内完成。为了追踪预测的日期,我们可以使用预测开始时间或完成时间,因为它们之间没有太大的区别。但是对于像欺诈检测这样的情况,预测的完成时间戳可能与预测开始时间戳相差很大,因为它可能需要多天的用户活动作为输入。

  • 模型版本 — 为了将模型性能数据映射到确切的模型文件,我们需要知道模型版本。此外,当我们组合多个模型来提供一个预测请求时,需要在日志中跟踪每个模型的版本。

  • 观察 —— 预测结果需要与预测输入一起记录,以供将来比较。此外,我们可以为客户提供反馈或调查 API,以报告模型性能问题。通过使用反馈 API,客户可以报告模型 ID、预期预测结果和当前预测结果。

  • 观察日期和频率 —— 许多时候,观察是手动收集的,观察的频率也需要记录。数据科学家需要日期和频率来决定数据是否能够在统计上代表模型的整体性能。

你能读到这里真是太好了!模型服务是机器学习系统的重要组成部分,因为外部业务应用程序依赖于它。随着模型类型、预测请求数量和推理类型(在线/离线)的增加,许多模型服务框架/系统被发明出来,并且它们变得越来越复杂。如果你遵循第 6 和第七章介绍的服务心理模型,从模型如何加载和执行开始,你可以轻松地浏览这些服务系统,无论代码库有多大或组件数量有多少。

摘要

  • 这一章的模型服务示例由一个前端 API 组件和一个后端模型预测器容器组成。由于预测器是基于第三章的意图模型训练代码构建的,因此它只能为意图分类模型提供服务。

  • 模型服务器示例由与第三章相同的前端 API 和一个不同的后端 —— TorchServe 预测器组成。TorchServe 后端不仅限于意图分类模型;它可以为任意的 PyTorch 模型提供服务。这是模型服务器方法在模型服务方法上的一个巨大优势。

  • 对于实施模型服务器方法,我们建议使用现有工具,例如 Triton 服务器,而不是自己构建。

  • 模型服务方法适用于单个应用场景;它可以快速实施,并且您可以完全控制端到端工作流的代码实现。

  • 模型服务器方法适用于平台场景;当服务系统需要支持五种或更多不同类型的模型时,它可以极大地减少开发和维护工作量。

  • TorchServe、TensorFlow Serving 和 Triton 都是可靠的开源模型服务工具,它们都采用了模型服务器方法。如果适用,我们推荐 Triton,因为它与大多数模型训练框架兼容,并且在 GPU 加速方面具有性能优势。

  • KServe 提供了一个标准的服务接口,适用于所有主要的服务工具,包括 TensorFlow Serving、TorchServe 和 Triton。KServe 可以极大地提高我们的服务系统的兼容性,因为我们可以使用单一集合的 API 来运行具有不同后端的模型服务。

  • 在生产中发布新模型或模型服务系统的新版本不应该是事后才考虑的事情;我们需要在设计阶段妥善考虑这一点。

  • 模型度量收集和模型质量门是工程师需要专注于模型性能监控的两个领域。

第八章:元数据和工件存储

本章内容包括:

  • 在深度学习背景下理解和管理元数据。

  • 设计一个元数据和工件存储来管理元数据

  • 介绍两个开源的元数据管理工具:ML Metadata 和 MLflow。

要生成符合业务需求的高质量模型,数据科学家需要尝试各种数据集、数据处理技术和训练算法。为了构建和发布最佳模型,他们花费了大量时间进行这些实验。

模型训练实验产生了各种工件(数据集和模型文件)和元数据。元数据可能包括模型算法、超参数、训练指标和模型版本等,这些对分析模型性能非常有帮助。为了有用,这些数据必须是持久的和可检索的。

当数据科学家需要调查模型性能问题或比较不同的训练实验时,作为工程师,我们是否有什么可以做的来促进这些努力呢?例如,我们是否可以使模型的再现和实验比较更容易?

答案是肯定的。作为工程师,我们可以构建一个系统,保留数据科学家需要再现和比较模型的实验元数据和工件。如果我们设计这个存储和检索系统得当,并进行适当的元数据管理,数据科学家可以轻松地从一系列实验中选择最佳模型,或者快速找出模型退化的根本原因,而不需要深入了解元数据系统。

在前几章中,我们已经学习了设计服务来生成和提供模型。在这里,我们将注意力转向元数据和工件管理系统,这个系统有助于两个更重要的操作:故障排除和比较实验。

我们将从介绍工件元数据开始这一章,并讨论这些概念在深度学习背景下的含义。然后我们将通过示例和强调设计原则来向您展示如何设计元数据管理系统。最后,我们将讨论两个开源的元数据管理系统:MLMD(ML Metadata)和 MLflow。通过阅读本章,您将清楚地了解如何管理元数据和工件,以便促进实验比较和模型故障排除。

8.1 介绍工件

人们经常假设在深度学习中,工件是模型训练过程产生的模型文件。这在某种程度上是正确的。工件实际上是组成模型训练过程中组件的输入和输出的文件和对象。这是一个关键的区别,如果你想设计一个支持模型再现性的系统,记住这个更广泛的定义是很重要的。

在这个定义下,工件可以包括用于深度学习项目的数据集、模型、代码或任何其他数量的对象。例如,原始输入训练数据、通过标记工具生成的带标签数据集以及数据处理流水线的结果数据都被认为是工件。

此外,为了进行性能比较、可重现性和故障排除,必须将工件与描述其事实和血统的元数据一起保存。在实践中,工件以原始文件的形式存储在文件服务器或云存储服务(例如 Amazon Simple Storage Service 或 Azure Blob Storage)上。我们将工件与其元数据关联在一个独立的存储服务上的元数据存储中。请参见图 8.1,了解此安排通常的外观。

图 8.1 显示了管理工件的常见做法。工件文件保存在文件存储系统中,并将其文件 URL 与其他相关元数据(例如模型训练执行 ID 和模型 ID)一起保存在元数据存储中。这样的设置允许我们 - 或数据科学家 - 在元数据存储中搜索模型,并轻松找到相应模型训练过程的所有输入和输出工件。

图 8.1 工件与其元数据相关联存储在元数据存储中。

8.2 深度学习上下文中的元数据

从一般意义上讲,元数据是结构化的参考数据,提供有关其他数据或对象的信息,例如包装食品上的营养事实标签。然而,在机器学习(ML)和深度学习中,元数据更具体地指的是模型的数据;它是描述模型训练执行(运行)、工作流、模型、数据集和其他工件的数据。

对于任何分布式系统,我们通过日志和度量标准以服务级别跟踪服务元数据。例如,我们可能跟踪 CPU 使用率、活跃用户数量和失败的 Web 请求数量等度量标准。我们使用这些度量标准进行系统/服务监控、故障排除和观察。

在深度学习系统中,除了服务级别指标之外,我们还收集用于模型故障排除、比较和重现的元数据。您可以将深度学习元数据视为系统中用于监视和跟踪每个深度学习活动的一种特殊子集的日志和度量标准。这些活动包括数据解析、模型训练和模型服务。

8.2.1 常见的元数据类别

虽然我们刚刚定义了元数据,但这个术语在某种程度上实际上是相当任意的;没有关于哪些数据应被视为元数据的固定准则。对于深度学习系统的工程师,我们建议将元数据定义分为以下四个类别:模型训练运行、通用工件、模型文件和编排工作流。为了让您对这些类别有一个具体的感觉,让我们来看看每个类别以及每个类别中包含的一些示例元数据。

模型训练运行的元数据

为了重现模型、分析模型性能并促进模型故障排除,我们需要跟踪模型训练运行的所有输入和输出数据和工件。这包括

  • 数据集 ID 和版本—用于模型训练的数据集的唯一标识。

  • 超参数—训练中使用的超参数,例如学习率和 epoch 数。

  • 硬件资源—分配给训练的 CPU、GPU、TPU、内存和磁盘大小以及这些资源的实际消耗。

  • 训练代码版本—用于模型训练的训练代码快照的唯一标识。

  • 训练代码配置—用于重新创建训练代码执行环境的配置,例如 conda.yml、Dockerfile 和 requirement.txt。

  • 训练指标—显示模型训练进展的指标,例如每个训练 epoch 的损失值。

  • 模型评估指标—显示模型性能的指标,例如 F 分数和均方根误差(RMSE)。

通用工件的元数据

工件可以是任意文件,例如数据集、模型和预测结果。为了能够在工件存储中找到工件,我们希望跟踪工件的以下元数据:

  • 文件位置—工件存储位置的路径,例如 Amazon S3 文件路径或内部文件系统路径

  • 文件版本—用于区分不同文件更新的唯一标识

  • 描述—用于描述工件文件内容的额外信息

  • 审计历史—有关谁创建了工件版本、工件何时创建以及如何创建的信息

模型文件的元数据

模型是一种工件,但由于模型是每个深度学习系统的主要产品,我们建议将模型元数据与其他工件分开跟踪。当我们定义模型元数据时,最好考虑两个视角:模型训练和模型服务。

对于模型训练,为了有模型谱系,我们希望保留模型与产生它的模型训练运行之间的映射。模型谱系对于模型比较和重现很重要。例如,当比较两个模型时,通过具有模型训练运行和模型的链接,数据科学家可以轻松确定模型是如何产生的所有细节,包括输入数据集、训练参数和训练指标。模型训练指标对于了解模型性能非常有用。

对于模型服务,我们希望跟踪模型执行数据以用于未来的模型性能分析。这些执行数据,例如模型响应延迟和预测失误率,对于检测模型性能下降非常有用。

除了前述的通用工件元数据之外,以下是一些推荐的模型元数据类别:

  • 资源消耗—用于模型服务的内存、GPU、CPU 和 TPU 消耗

  • 模型训练运行——模型训练运行 ID,用于查找创建模型的代码、数据集、超参数和环境

  • 实验——跟踪在生产环境中的模型实验活动,例如不同模型版本的用户流量分布

  • 生产——模型在生产环境中的使用,例如每秒查询和模型预测统计

  • 模型性能——跟踪模型评估指标以检测漂移,如概念漂移和性能漂移

注:不可避免地,一旦模型上线,它们将开始表现得更差。我们称此行为为模型退化。随着目标组的统计分布随时间变化,模型预测变得不太准确。例如,新的流行口号可能会影响语音识别的准确性。

用于流水线的元数据

当我们想要自动化多步模型训练任务时,需要使用流水线或工作流程。例如,我们可以使用工作流管理工具,如 Airflow、Kubeflow 或 Metaflow,自动化包含多个功能步骤的模型训练过程:数据收集、特征提取、数据集增强、训练和模型部署。我们将在下一章中详细讨论工作流程。

对于流水线元数据,我们通常会跟踪流水线执行历史和流水线输入输出。这些数据可以为将来的故障排除提供审计信息。

注:深度学习项目差异很大。语音识别、自然语言处理和图像生成的模型训练代码截然不同。项目特定的因素有很多,例如数据集的大小/类型、ML 模型的类型和输入资产。除了先前提到的样本元数据外,我们建议您基于项目定义和收集元数据。当您寻找有助于模型再现和故障排除的数据时,元数据列表将自然而然地出现在您面前。

8.2.2 为什么要管理元数据?

由于元数据通常是通过日志或指标的形式进行仪表化或记录的,因此您可能会想知道为什么我们需要单独管理深度学习元数据。我们能否简单地从日志文件中提取深度学习元数据?日志管理系统,如 Splunk(www.splunk.com/)和 Sumo Logic(www.sumologic.com/),非常方便,因为它们允许开发人员搜索和分析分布式系统生成的日志和事件。

为了更好地解释在深度学习系统中需要专门的元数据管理组件的必要性,我们将讲述一个故事。Julia(数据工程师)、Ravi(数据科学家)和 Jianguo(系统开发人员)一起开发一个深度学习系统,为聊天机器人应用程序开发意图分类模型。Ravi 开发意图分类算法,Julia 负责数据收集和解析,而 Jianguo 则开发和维护深度学习系统。

在项目开发和测试阶段,Julia 和 Ravi 共同合作构建了一个实验性的训练流水线来生成意图模型。模型构建完成后,Ravi 将其传递给 Jianguo,以部署实验模型到预测服务并使用真实客户请求进行测试。

当 Ravi 对实验感到满意时,他会将训练算法从实验性流水线推广到自动化生产训练流水线。该流水线在生产环境中运行,并以客户数据作为输入生成意图模型。该流水线还会自动将模型部署到预测服务。图 8.2 描绘了整个故事背景。

图 8.2 没有元数据管理的模型开发

几周后,当 Ravi 发布了最新的意图分类算法后,一个聊天机器人客户——BestFood 公司向 Ravi 报告了模型性能下降的问题。在调查请求中,BestFood 提到他们的机器人在使用新数据集后的意图分类准确率下降了 10%。

为了排除报告的模型性能下降问题,Ravi 需要验证大量信息。他首先需要检查 BestFood 当前在预测服务中使用的模型版本,然后检查当前模型的模型衍生情况,例如在训练流水线中使用的数据集版本和代码版本。之后,Ravi 还可能需要重新生成模型进行本地调试。他需要比较当前模型和之前的模型,以测试数据分布的影响(当前新数据集 vs. 之前的数据集)。

Ravi 是一个自然语言处理(NLP)专家,但他对于运行其训练代码的深度学习系统了解甚少。为了继续他的调查,他不得不向 Jianguo 和 Julia 请求获取相关的模型、数据集和代码信息。因为每个人对模型训练应用和底层深度学习系统/基础设施的知识只是一知半解,对于每一个模型性能故障排除,Ravi、Julia 和 Jianguo 不得不共同努力来掌握完整的背景,这是耗时且低效的。

当然,这个故事过于简化了。在实践中,深度学习项目开发包含了数据、算法、系统/运行时开发和硬件管理。整个项目由不同的团队负责,并且很少有一个人了解所有内容。在企业环境中依赖跨团队合作来排除与模型相关的问题是不现实的。

在图 8.2 中缺少的关键因素是在一个集中的地方搜索和连接深度学习元数据的有效方法,以便 Julia、Ravi 和 Jianguo 能够轻松获取模型元数据。在图 8.3 中,我们增加了缺失的部分——一个元数据和制品存储(中间的灰色方框)来提高调试能力。

图 8.3 使用元数据管理进行模型故障排除

如果您将图 8.3 与图 8.2 进行比较,您会看到一个新组件(元数据和工件存储)被引入到图 8.3 的中间。我们在第 8.2.1 节中描述的所有深度学习元数据,无论它们来自实验流水线还是生产流水线,都会被收集并存储在此元数据存储中。

元数据存储为深度学习系统中的每个数据科学活动提供了元数据的全面视图。模型、流水线/训练运行和工件的元数据不仅被保存,而且在此存储内部相关联,因此人们可以轻松获取相关信息。例如,由于模型文件和模型训练运行在存储中被链接,人们可以轻松确定给定模型的模型谱系。

现在,数据科学家拉维可以使用元数据存储的用户界面列出系统中的所有模型和训练运行。然后,他可以深入研究元数据存储,找到过去训练运行中使用的输入参数、数据集和训练指标,这对评估模型非常有帮助。更重要的是,拉维可以自行快速完整地检索元数据,而无需了解模型训练和服务的基础架构。

8.3 设计一个元数据和工件存储

在本节中,我们将首先讨论构建元数据和工件存储的设计原则,然后介绍一个遵循这些原则的通用设计方案。即使您更喜欢使用开源技术来管理元数据,本节中的讨论仍将使您受益;了解设计需求和解决方案将帮助您选择适合您需求的正确工具。

注意为了简洁起见,我们在本章中将元数据和工件存储元数据存储互换使用。当我们提到元数据存储时,它包括工件管理。

8.3.1 设计原则

元数据和工件存储被设计为便于模型性能故障排除和实验比较。它存储各种元数据并将其聚合到模型和训练运行周围,因此数据科学家可以快速获取任意模型的相关模型谱系和模型训练元数据。一个好的元数据存储应该遵循以下四个原则。

原则 1:显示模型谱系和版本控制

当收到模型名称时,元数据存储应该能够确定该模型的版本和每个模型版本的血统,例如,哪个训练运行生成了该模型,输入参数和数据集是什么。模型版本和血统对于模型故障排除至关重要。当客户报告模型的问题,例如模型性能下降时,我们首先问的问题是:模型是何时生成的?训练数据集是否发生了变化?使用了哪个版本的训练代码,以及我们从哪里可以找到训练指标?我们可以在模型血统数据中找到所有答案。

原则二:确保模型可复现性

元数据存储应跟踪重现模型所需的所有元数据,例如训练管道/运行配置、输入数据集文件和算法代码版本。能够重现模型对于模型实验评估和故障排除至关重要。我们需要一个地方来捕获配置、输入参数和工件,以启动模型训练运行以重现相同的模型。元数据存储是保留此类信息的理想地点。

原则三:方便获取打包的模型

元数据存储应该让数据科学家轻松访问模型文件,而不必理解复杂的后端系统。存储库应该具有手动和程序化两种方法,因为数据科学家需要能够运行手动和自动化的模型性能测试。

例如,通过使用元数据存储,数据科学家可以快速识别当前在生产服务中使用的模型文件,并下载它进行调试。数据科学家还可以编写代码从元数据存储中提取任意版本的模型,以自动比较新旧模型版本。

原则四:可视化模型训练跟踪和比较

良好的可视化可以极大地提高模型故障排除过程的效率。数据科学家依赖于大量的指标来比较和分析模型实验,元数据存储需要配备能够处理所有(或任何类型的)元数据查询的可视化工具。

例如,它需要能够显示一组模型训练运行的模型评估指标的差异和趋势行为。它还需要能够显示最新发布的 10 个模型的模型性能趋势。

8.3.2 一个通用的元数据和工件存储设计建议

为了解决第 8.3.1 节中的设计原则,深度学习元数据存储应该是一个度量存储系统,并且需要存储所有类型的元数据及其之间的关系。此类元数据应围绕模型和训练/实验执行聚合,以便在故障排除和性能分析过程中快速找到所有与模型相关的元数据。因此,内部元数据存储的数据架构是元数据存储设计的关键。

尽管元数据存储是一个数据存储系统,但数据扩展通常不是一个问题,因为深度学习系统的元数据量并不高。由于元数据大小取决于模型训练执行和模型的数量,而我们不希望每天进行超过 1,000 次模型训练运行,一个单独的数据库实例应该足够用于元数据存储系统。

为了用户方便,元数据存储应该提供一个 Web 数据摄入接口和日志记录 SDK,这样深度学习元数据就可以以类似应用程序日志和度量的方式被记录。基于设计原则和对系统需求的分析,我们提出了一个示例元数据存储设计供参考。图 8.4 显示了此组件的概述。

图 8.4 通用元数据和工件存储系统设计

在图 8.4 中,示例元数据存储系统由四个组件组成:一个客户端 SDK、一个 Web 服务器、后端存储和一个 Web UI。深度学习工作流程中的每个组件和步骤都使用客户端 SDK 将元数据发送到元数据存储服务器。元数据存储提供一个 RESTful 接口来进行元数据摄入和查询。Web UI 可视化元数据存储服务器的 RESTful 接口。除了基本的元数据和工件组织和搜索外,它还可以可视化各种模型训练运行的模型性能指标和模型差异。

元数据存储服务器位于此设计的中心位置。它有三层——一个 RESTful Web 接口、一个数据聚合器和存储。数据聚合器组件知道元数据如何组织和相互关联,因此它知道在哪里添加新元数据以及如何为不同类型的元数据搜索查询提供服务。在存储方面,我们建议构建一个抽象的元数据和工件存储层。这个抽象层作为一个适配器,封装了实际的元数据和文件存储逻辑。因此,元数据存储可以在不同类型的存储后端上运行,例如云对象存储、本地文件和本地或远程 SQL 服务器。

元数据存储数据模式

现在让我们来看一下元数据存储的数据模式。无论我们是将元数据保存在 SQL 数据库、noSQL 数据库还是纯文件中,我们都需要定义一个数据模式来描述元数据的结构和序列化方式。图 8.4 展示了我们元数据存储的实体关系图。

在图 8.5 中,模型训练运行(Training_Runs 对象)处于实体关系图的中心位置。这是因为模型性能故障排除总是从生成模型的过程(训练运行或工作流程)开始的,所以我们希望有一个专用的数据对象来跟踪生成模型文件的训练执行。

图 8.5 元数据存储数据模式的实体关系图

模型训练的详细元数据保存在MetricsParameters对象中。Parameters对象存储训练运行的输入参数,例如数据集 ID、数据集版本和训练超参数。Metrics对象存储训练期间产生的训练指标,例如模型 F2 分数。

Experiments对象用于组织和分组模型训练运行。一个实验可以有多个训练运行。例如,我们可以将意图分类模型开发项目定义为一个训练实验,然后将所有意图分类模型训练执行与该实验关联。然后,在 UI 上,我们可以按不同的实验组织训练执行。

Models对象存储模型文件的元数据,例如模型版本、类型和阶段。一个模型可以有多个阶段,例如测试、预生产和生产,所有这些都可以保留。

还要注意的是,图 8.5 中的每个实体都链接(由图中的线表示)到产生它们的特定训练运行,因此它们都将共享一个公共的training_run_id。通过利用这个数据链接,你可以从任何训练运行对象开始,找到其输出模型、训练输入数据和模型训练指标。

早些时候,我们说可以将其简称为元数据存储,但也存储工件。那么这个设计中的工件在哪里呢?我们将工件的 URL 作为训练运行的输出存储在Training_Runs对象中。如果查询模型或训练执行,则会得到工件的 URL。

模型焦点与流水线焦点

设计元数据系统有两种方法:模型焦点和流水线焦点。模型焦点方法将元数据与模型文件相关联,而流水线焦点方法聚合绕流水线/训练运行的元数据,就像我们在图 8.4 中提议的那样。

我们认为模型焦点和流水线焦点对最终用户(数据科学家)同样有用,它们并不是互斥的。我们可以支持两种焦点。

您可以使用流水线焦点方法实现元数据存储的存储层,类似于图 8.5 中的示例,然后在 Web UI 上构建搜索功能,以支持流水线搜索和模型搜索。

8.4 开源解决方案

在本节中,我们将讨论两个广泛使用的元数据管理工具:MLMD 和 MLflow。两个系统都是开源的,可以免费使用。我们首先对每个工具进行概述,然后提供比较,确定哪个工具在什么时候使用。

8.4.1 机器学习元数据

MLMD (github.com/google/ml-metadata)是一个轻量级库,用于记录和检索与 ML 开发人员和数据科学家工作流相关的元数据。MLMD 是 TensorFlow Extended(TFX; www.tensorflow.org/tfx)的一个组成部分,但是它设计成可以独立使用。例如,Kubeflow (www.kubeflow.org/)使用 MLMD 来管理其管道和笔记本服务生成的元数据。有关更多详细信息,请参阅 Kubeflow 元数据文档(mng.bz/Blo1)。你可以将 MLMD 视为一个日志记录库,并在你的 ML 管道的每个步骤中使用它来记录元数据,以便你可以理解和分析工作流/管道的所有相互连接的部分。

系统概览

使用 MLMD 库进行元数据记录可以设置两种不同的后端:SQL 或 gRPC 服务器。请参阅图 8.6 了解概念。

图 8.6 使用 MLMD 记录元数据的两种不同设置:(A)直接向后端数据库报告元数据,(B)向 gRPC 服务器 DB 报告元数据,数据库。

在图 8.6 中,我们看到 ML 管道/工作流的每个步骤都使用 MLMD 库(MLMD 客户端 API)来记录元数据。在后端,MLMD 将元数据保存在关系型数据库中,例如 mySQL 或 PostgreSQL。

你可以选择让每个 MLMD 库直接与 SQL 服务器通信(图 8.5,A),或者使用 MLMD 库中的服务器设置代码设置一个 gRPC 服务器,让客户端库与服务器通信(图 8.5,B)。方法 A 很简单;你不需要托管一个专用的日志服务器,但是推荐使用方法 B,因为它避免了暴露后端数据库。

您可以查看以下两个文档以获取详细的元数据存储配置:“元数据存储后端和存储连接配置” (mng.bz/dJMo) 和 “使用 MLMD 与远程 gRPC 服务器” (mng.bz/rd8J)。

日志记录 API

MLMD 中的元数据存储使用以下数据结构在存储后端记录元数据。一个execution代表工作流程中的一个组件或步骤;一个artifact描述了执行中的输入或输出对象;一个event是描述工件和执行之间关系的记录。一个context是一个逻辑组,用于将工件和执行在同一个工作流中关联在一起。

在这个概念的基础上,让我们来看一些示例元数据记录代码:

# define a dataset metadata
data_artifact = metadata_store_pb2.Artifact()                ❶
data_artifact.uri = 'path/to/data'
data_artifact.properties["day"].int_value = 1
data_artifact.properties["split"].string_value = 'train'
data_artifact.type_id = data_type_id
[data_artifact_id] = store                                   ❷
     .put_artifacts([data_artifact])                         ❷

# define a training run metadata
trainer_run = metadata_store_pb2.Execution()                 ❸
trainer_run.type_id = trainer_type_id
trainer_run.properties["state"].string_value = "RUNNING"
[run_id] = store.put_executions([trainer_run])

# define a model metadata
model_artifact = metadata_store_pb2.Artifact()               ❹
model_artifact.uri = 'path/to/model/file'
model_artifact.properties["version"].int_value = 1
model_artifact.properties["name"].string_value = 'MNIST-v1'
model_artifact.type_id = model_type_id
[model_artifact_id] = store.put_artifacts([model_artifact])

# define an experiment metadata
my_experiment = metadata_store_pb2.Context()                 ❺
my_experiment.type_id = experiment_type_id
# Give the experiment a name
my_experiment.name = "exp1"
my_experiment.properties["note"].string_value = \
   "My first experiment."
[experiment_id] = store.put_contexts([my_experiment])

# declare relationship between model, training run
# and experiment
attribution = metadata_store_pb2.Attribution()
attribution.artifact_id = model_artifact_id
attribution.context_id = experiment_id

association = metadata_store_pb2.Association()
association.execution_id = run_id
association.context_id = experiment_id

# Associate training run and model with the
# same experiment
store.put_attributions_and_associations( \
  [attribution], [association])                             ❻

一个数据集被记录为一个工件。

将元数据保存到存储中

记录模型训练运行为一个执行。

模型被记录为一个工件。

定义了模型训练元数据的逻辑组

保存元数据之间的关系

查看 MLMD 的“快速入门”文档 (mng.bz/VpWy) 以获取详细的代码示例和本地设置说明。

搜索元数据

MLMD 没有提供用于显示其存储的元数据的 UI。因此,要查询元数据,我们需要使用其客户端 API。参见以下代码示例:

artifacts = store.get_artifacts()             ❶

[stored_data_artifact] = store                ❷
   .get_artifacts_by_id([data_artifact_id])

artifacts_with_uri = store                    ❸
   .get_artifacts_by_uri(data_artifact.uri)

artifacts_with_conditions = store 
   .get_artifacts(
      list_options=mlmd.ListOptions(          ❹
        filter_query='uri LIKE "%/data" 
        AND properties.day.int_value > 0'))

❶ 查询所有注册的 artifacts

❷ 通过 ID 查询 artifact

❸ 通过 URI 查询 artifact

❹ 使用过滤器查询 artifact

MLMD 的“快速入门”文档 (mng.bz/VpWy) 提供了许多用于获取 artifacts、执行和上下文元数据的查询示例。如果您感兴趣,请参阅。

学习 MLMD 的数据模型的最佳方法是查看其数据库模式。您可以首先创建一个 SQLite 数据库并配置 MLMD 元数据存储以使用该数据库,然后运行 MLMD 示例代码。最后,在本地 SQLite 数据库中创建所有实体和表。通过查看表模式和内容,您将深入了解 MLMD 中的元数据是如何组织的,因此您可以自己在其上构建一个漂亮的 UI。以下示例代码显示了如何配置 MLMD 元数据存储以使用本地 SQLite 数据库:

connection_config = metadata_store_pb2.ConnectionConfig()
connection_config.sqlite.filename_uri = 
  '{your_workspace}/mlmd-demo/mlmd_run.db'               ❶
connection_config.sqlite.connection_mode = 3             ❷
store = metadata_store.MetadataStore(connection_config)

❶ SQLite 数据库的本地文件路径

❷ 允许读取、写入和创建

8.4.2 MLflow

MLflow (mlflow.org/docs/latest/tracking.html) 是一个开源的 MLOps 平台。它旨在管理 ML 生命周期,包括实验、可重现性、部署和中央模型注册表。

与 MLMD 相比,MLflow 是一个完整的系统,而不是一个库。它由四个主要组件组成:

  • MLflow 跟踪(元数据跟踪服务器) — 用于记录和查询元数据

  • MLflow 项目 — 以可重复使用和可重现的方式打包代码

  • MLflow 模型 — 用于打包可用于不同模型服务工具的 ML 模型

  • MLflow 模型注册表 — 用于通过 UI 管理 MLflow 模型的全生命周期,例如模型谱系、模型版本控制、注释和生产推广

在本节中,我们重点关注跟踪服务器,因为这与元数据管理最相关。

系统概述

MLflow 提供了六种不同的设置方法。例如,MLflow 运行(训练管道)的元数据可以记录到本地文件、SQL 数据库、远程服务器或带有代理存储后端访问的远程服务器。有关这六种不同设置方法的详细信息,您可以查看 MLflow 文档“How Runs and Artifacts Are Recorded” (mlflow.org/docs/latest/tracking.html#id27)。

在本节中,我们关注最常用的设置方法:使用代理存储访问的远程服务器。请参见图 8.7 以获取系统概述图。

图 8.7 设置 MLflow 跟踪服务器以进行元数据摄取和查询

从图 8.7 可以看出,深度学习流水线(工作流)的每个步骤/动作都使用 MLflow 客户端将元数据和文物注入到 MLflow 跟踪服务器中。跟踪服务器将度量、参数和标签等元数据保存在指定的 SQL 数据库中;文物,如模型、图像和文档,保存在配置的对象存储中,如 Amazon S3。

MLflow 提供了两种上传文物的方式:(1)直接从客户端上传和(2)通过跟踪服务器进行代理上传。在图 8.6 中,我们说明了后一种方法:利用跟踪服务器作为涉及文物的任何操作的代理服务器。优点是最终用户可以直接访问后端远程对象存储,而无需提供访问凭据。

MLflow 的另一个好处是它提供了一个漂亮的用户界面;数据科学家可以通过托管在跟踪服务器上的网站来检查和搜索元数据。该用户界面不仅允许用户从流水线执行的角度查看元数据,还可以直接搜索和操作模型。

记录 API

将元数据发送到 MLflow 跟踪服务器非常简单。我们可以通过创建一个活动运行作为上下文管理器,然后调用 log 函数来记录文物或单个键值参数、度量和标签。参见以下示例代码:

import mlflow
remote_server_uri = "..."                 ❶
mlflow.set_tracking_uri(remote_server_uri)

mlflow.set_experiment("/my-experiment")

with mlflow.start_run():
  mlflow.log_param("parameter_a", 1)      ❷
  mlflow.log_metric("metric_b", 2)        ❷
  mlflow.log_artifact("features.txt")     ❷

❶ 定义 MLflow 服务器 URL

❷ 在 Python 上下文管理器中由 MLflow ActiveRun 对象创建的日志元数据

自动记录日志

如果您厌倦了手动指定大量元数据,MLflow 支持自动记录日志。通过在训练代码之前调用 mlflow.autolog() 或特定于库的 autolog 函数,如 mlflow.tensorflow.autolog()mlflow.keras.autolog()mlflow.pytorch.autolog(),MLflow 将自动记录元数据,甚至文物,而无需显式的日志语句。如果您想了解更多关于 MLflow 记录日志的信息,请查看 MLflow 记录函数文档(mng.bz/xd1d)。

搜索元数据

MLflow 跟踪服务器托管的跟踪用户界面允许您可视化、搜索和比较运行,以及下载运行文物或元数据以在其他工具中进行分析。该用户界面包含以下关键功能:基于实验的运行列表和比较,通过参数或度量值搜索运行,可视化运行度量,以及下载运行结果。除了用户界面外,您还可以像以下示例中那样以编程方式实现跟踪用户界面提供的所有操作:

from mlflow.tracking import MlflowClient

client = MlflowClient()                              ❶
.. .. ..

# Fetch the run metadata from the backend store,
# which contains a list of  metadata 
active_run = client.get_run(run_id)
print("run_id: {}; lifecycle_stage: {}"\             ❷
  .format(run_id, active_run.info.lifecycle_stage))

# Retrieve an experiment by 
# experiment_id from the backend store
experiment = client.get_experiment(exp_id)

# Get a specific version of a model
mv = client.get_model_version(model_name, mv_version)

❶ 初始化客户端

❷ 打印出运行的执行阶段

编程式元数据访问不仅在使用分析工具(例如 pandas)查询和比较不同训练运行的模型性能时有帮助,而且对于将模型与模型服务系统集成也很有用,因为它允许你从 MLflow 模型注册表中以编程方式获取模型。有关完整的 MLflowClient API 用法,请查看 MLflow 跟踪 API 文档 (mng.bz/GRzO)。

8.4.3 MLflow vs. MLMD

从前面章节的描述中,我们可以看到 MLMD 更像是一个轻量级库,而 MLflow 是一个 MLOps 平台。两者都可以独立运行,提供元数据摄取和搜索功能,并基于模型训练运行跟踪元数据。但是 MLflow 提供的功能更加丰富。

除了 MLMD 的功能之外,MLflow 还支持自动元数据记录和设计良好的 UI 以可视化实验元数据(包括实验比较)、模型注册表、工件管理、代码可复现性、模型打包等。

如果你需要向系统引入一个完整的、新的元数据和工件存储,MFflow 将是你的首选。它得到了一个活跃的开源社区的支持,并且涵盖了大多数用户对 ML 元数据管理的需求。作为一个额外的奖励,MLflow 在 MLOps 上有着很好的支持,比如 MLflow 项目管理和模型部署。

如果你已经拥有一个工件注册表和一个度量可视化网站,并且想要将元数据功能集成到现有系统中,那么 MLMD 是一个不错的选择。MLMD 轻量级、易于使用,并且简单易学。例如,Kubeflow (www.kubeflow.org/) 深度学习平台将 MLMD 作为元数据跟踪工具集成到其组件中,如 Kubeflow pipeline (www.kubeflow.org/docs/components/pipelines/)。

总结

  • 机器学习元数据可以分为四类:模型训练运行、通用工件、模型工件和流水线。

  • 元数据和工件存储设计用于支持模型性能比较、故障排除和复现。

  • 一个良好的元数据管理系统可以帮助展示模型血统,实现模型可复现性,并促进模型比较。

  • MLMD 是一个轻量级的元数据管理工具,源自 TensorFlow 流水线,但可以独立使用。例如,Kubeflow 使用 MLMD 在其管道组件中管理 ML 元数据。

  • MLMD 适用于将元数据管理集成到现有系统中。

  • MLflow 是一个 MLOps 平台;它旨在管理机器学习生命周期,包括实验、可复现性、部署和中央模型注册。

  • 如果你想要引入一个完全独立的元数据和工件管理系统,那么 MLflow 是适合的选择。

第九章:工作流编排

本章涵盖了

  • 定义工作流和工作流编排

  • 为什么深度学习系统需要支持工作流

  • 设计一个通用工作流编排系统

  • 引入三个开源编排系统:Airflow、Argo Workflows 和 Metaflow

在本章中,我们将讨论深度学习系统的最后但至关重要的部分:工作流编排——一个管理、执行和监控工作流自动化的服务。工作流是一个抽象且广泛的概念;它本质上是一系列操作,这些操作是某个更大任务的一部分。如果你可以设计一个带有一组任务的计划来完成一项工作,这个计划就是一个工作流。例如,我们可以为训练机器学习(ML)模型定义一个顺序工作流。这个工作流可以由以下任务组成:获取原始数据、重建训练数据集、训练模型、评估模型和部署模型。

因为工作流是一个执行计划,它可以手动执行。例如,数据科学家可以手动完成我们刚刚描述的模型训练工作流的任务。例如,要完成“获取原始数据”任务,数据科学家可以制作网络请求并将其发送到数据集管理(DM)服务以获取数据集——所有这些都不需要工程师的帮助。

然而,手动执行工作流并不理想。我们希望自动化工作流的执行。当针对不同目的开发了大量工作流时,我们需要一个专门的系统来处理工作流执行的复杂性。我们称这种系统为工作流编排系统

工作流编排系统被建立来管理工作流的生命周期,包括工作流的创建、执行和故障排除。它不仅提供了使所有预定代码保持运行的脉搏,还为数据科学家提供了一个控制平面,用于管理深度学习系统中的所有自动化。

在本章中,我们将讨论工作流编排系统的设计以及在深度学习领域中使用的最受欢迎的开源编排系统。通过阅读本章,您不仅将对系统要求和设计选项有扎实的理解,还将了解如何选择最适合您自己情况的正确开源编排系统。

9.1 引入工作流编排

在我们深入探讨工作流编排系统设计的细节之前,让我们就工作流编排的基本概念进行快速讨论,特别是关于从深度学习/ML 角度出发的特殊工作流挑战。

注意 由于在深度学习项目和 ML 项目中使用工作流编排的要求几乎相同,因此在本章中我们将深度学习和机器学习这两个词用于交替使用。

9.1.1 什么是工作流?

一般来说,工作流是一系列操作,这些操作是某个较大任务的一部分。工作流可以被视为一种步骤的有向无环图(DAG)。

步骤是描述一个动作的最小可恢复计算单元;这个任务可以是获取数据或触发服务等。一个步骤要么成功,要么失败。在本章中,我们将 任务步骤 这两个词互换使用。

DAG 指定了步骤之间的依赖关系和执行它们的顺序。图 9.1 显示了一个用于训练自然语言处理(NLP)模型的示例工作流。

图 9.1 展示了一个具有多个步骤的示例模型训练工作流的 DAG。椭圆形和菱形都是步骤,但是不同类型。实线箭头表示步骤之间的依赖关系,虚线箭头表示从步骤发送的外部网络请求。

从图 9.1 中的示例 DAG 中,我们看到了一个由许多步骤组成的工作流。每个步骤都依赖于另一个,实线箭头显示了步骤之间的依赖关系。这些箭头和步骤形成了一个没有循环的工作流 DAG。

如果你按照 DAG 中的箭头(从左到右)并完成任务,你可以训练并发布一个 NLP 模型到生产环境。例如,当一个传入的请求触发了工作流时,授权(authorization)步骤将首先被执行,然后数据集构建步骤和嵌入获取步骤将同时被执行。在这两个步骤完成之后,箭头的另一侧的步骤将被执行。

工作流在 IT 行业中随处可见。只要你能够将一个流程定义为单个任务/步骤的 DAG,这个流程就可以被视为工作流。工作流对于深度学习模型的开发至关重要。事实上,在生产环境中,大多数深度学习模型构建活动都被呈现并执行为工作流。

注意:工作流不应该有循环。为了保证工作流在任何情况下都能完成,其执行图必须是一个 DAG,这样可以防止工作流执行陷入死循环。

9.1.2 什么是工作流编排?

一旦我们定义了一个工作流,下一步就是运行工作流。运行工作流意味着根据工作流 DAG 中定义的顺序执行工作流步骤。工作流编排 是我们用来描述工作流的执行和监视的术语。

工作流编排的目标是自动化执行工作流中定义的任务。在实践中,工作流编排的概念通常扩展到整个工作流管理——即以自动化方式创建、调度、执行和监视多个工作流。

深度学习系统为什么需要工作流编排?理想情况下,我们应该能够将整个深度学习项目编码为一个整体。这正是我们在项目的原型阶段所做的,将所有代码放在一个 Jupyter 笔记本中。那么,为什么我们需要将原型代码转换为工作流,并在工作流编排系统中运行它呢?答案有两个方面:自动化和工作共享。为了理解这些原因,让我们看一下图 9.2 中的三个样本训练工作流。

图 9.2 深度学习工作流由许多可重复使用的任务组成。

使用工作流的一个巨大好处是它将大量代码转换为一组可共享和可重用的组件。在图 9.2 中,我们想象三名数据科学家正在进行三个模型训练项目(A、B 和 C)的工作。由于每个项目的训练逻辑不同,数据科学家开发了三个不同的工作流(A、B 和 C)来自动化他们的模型训练流程。尽管每个工作流具有不同的 DAGs,但每个 DAG 中的步骤高度重叠。总共的六个步骤是可共享和可重用的。例如,auth 步骤(步骤 1)是所有三个工作流的第一步。

具有可重复使用步骤可以极大地提高数据科学家的生产率。例如,要从 DM 服务中提取数据(图 9.2 中的第 2 步),数据科学家需要了解 DM Web API 的工作原理。但是如果有人已经将 DM 数据提取方法构建为一个步骤函数,科学家们就可以在他们的工作流中重复使用这个步骤,而不必学习如何与 DM 服务交互。如果每个人都以工作流的形式编写他们的项目,我们将拥有大量可重用的步骤,这将在组织级别节省大量重复的工作!

另一个适应深度学习开发的工作流的原因是它促进了协作。模型开发需要团队合作;一个专门的团队可能负责数据,而另一个团队负责训练算法。通过在工作流中定义复杂的模型构建过程,我们可以将一个大型复杂项目分解为片段(或步骤)并将其分配给不同的团队,同时仍然保持项目有序和组件正确顺序。工作流 DAG 清楚地显示了所有项目参与者可以看到的任务依赖关系。

简而言之,一个好的工作流编排系统鼓励工作共享,促进团队协作,并自动化复杂的开发场景。所有这些优点使工作流编排成为深度学习项目开发的关键组成部分。

9.1.3 深度学习中使用工作流编排的挑战

在前一节中,我们看到工作流系统可以为深度学习项目开发提供许多好处。但有一个注意事项:使用工作流来原型化深度学习算法的想法是很麻烦的。

要了解为什么这样做很麻烦,让我们看一下深度学习开发过程的图表(图 9.3)。这张图表应该为你理解工作流在深度学习背景下提出的挑战奠定基础。

图 9.3

图 9.3 深度学习项目开发的数据科学家视角

在图 9.3 中,我们从数据科学家的角度看到了一个典型的深度学习项目开发过程。该过程可以分为两个阶段:本地孵化阶段和生产阶段。

在本地孵化阶段,数据科学家在本地/开发环境中进行数据探索和模型训练原型。当原型完成并且项目看起来很有前景时,数据科学家开始进行生产上线:将原型代码移到生产系统。

在生产阶段,数据科学家将原型代码转换为工作流程。他们将代码分解为多个步骤,并定义一个工作流 DAG,然后将工作流提交给工作流编排系统。之后,编排系统接管并根据其时间表运行工作流程。

在原型和生产之间存在差距。

如果你问一个在工作流编排系统上工作的工程师他们对图 9.3 中的开发过程的感觉,答案很可能是:还不错!但实际上,这个过程对数据科学家来说是有问题的。

从数据科学家的角度来看,一旦算法在本地测试通过,其原型代码应立即投入生产。但是在图 9.3 中,我们看到原型阶段和生产阶段 不是 顺利连接的。将孵化代码部署到生产并不直接;数据科学家必须额外工作来构建一个工作流程来在生产中运行他们的代码。原型代码和生产工作流之间的差距影响了开发速度,原因有两个:

  • 工作流程的构建和调试并不是直接的 —— 数据科学家在编写模型训练工作流程时,通常会面临巨大的学习曲线。学习工作流 DAG 语法、工作流程库、编码范例和故障排除对于数据科学家来说是一个巨大的负担。工作流程的故障排除是最痛苦的部分。大多数编排系统不支持本地执行,这意味着数据科学家必须在远程编排系统中测试他们的工作流程。这很困难,因为工作流环境和工作流执行日志都是远程的,所以数据科学家在工作流程执行出错时无法轻易找出根本原因。

  • 工作流构建并非一次性事件,而是频繁发生——一种常见的误解是,由于工作流构建只发生一次,所以如果耗时且繁琐也没关系。但事实是,工作流构建是持续不断的,因为深度学习开发是一个迭代过程。正如图 9.3 所示,数据科学家会迭代地进行原型设计和生产实验,因此工作流需要经常更新,以测试从本地到生产环境的新改进。因此,令人不快且耗时的工作流构建会反复发生,这阻碍了开发速度。

平滑地从原型设计过渡到生产环境

尽管存在差异,图 9.3 中的流程是不错的。数据科学家从一个简单的脚本开始在本地进行原型设计,然后继续完善。如果每次迭代后的结果看起来足够令人满意,那么“简单的本地脚本”将被转换为工作流,并在生产环境中在编排系统中运行。

关键的改进是使从原型代码到生产工作流的过渡步骤变得无缝。如果一个编排系统是为深度学习用例设计的,它应该提供工具,帮助数据科学家用最少的工作量从他们的代码构建工作流。例如,Metaflow 是一个开源库,将在 9.3.3 节中讨论,它允许数据科学家通过编写带有 Python 注解的 Python 代码来授权工作流。数据科学家可以直接从他们的原型代码中获得工作流,而不需要进行任何更改。Metaflow 还在本地和云生产环境之间提供了统一的模型执行用户体验。这消除了工作流测试中的摩擦,因为 Metaflow 在本地和生产环境中以相同的方式运行工作流。

深度学习系统应以人为中心

当我们向深度学习系统引入通用工具——如工作流编排时,不要满足于仅仅启用功能。尽量减少系统中人工的时间。总是可以进行定制工作,以帮助我们的用户更加高效。

Metaflow(9.3.3 节)是一个很好的例子,说明当工程师们不满足于仅仅构建一个用于自动化深度学习工作流的编排系统时会发生什么。相反,他们更进一步优化了工作流构建和管理,以解决数据科学家的工作方式。

9.2 设计工作流编排系统

在本节中,我们将分三个步骤设计工作流编排系统。首先,我们使用一个典型的数据科学家用户场景,展示编排系统从用户角度的工作方式。第二,我们学习通用编排系统设计。第三,我们总结构建或评估编排系统的关键设计原则。通过阅读本节,您将了解编排系统的一般工作方式,从而可以自信地评估或操作任何编排系统。

9.2.1 用户场景

尽管各工作流场景的过程有很大的差异,但数据科学家的用户场景通常非常标准。大多数工作流使用可以分为两个阶段:开发阶段和执行阶段。请参见图 9.4,了解数据科学家(Vena)的工作流用户体验。我们将一步一步地跟随图 9.4 中 Vena 的用户场景。

图 9.4:工作流编排系统的通用深度学习用户场景。

开发阶段

在开发阶段,数据科学家将其训练代码转换为工作流。以下是 Vena 的示例:

  1. 数据科学家 Vena 在本地环境中使用 Jupyter notebook 或纯 Python 原型开发其模型训练算法。经过本地测试和评估,Vena 认为是时候将代码部署到生产环境中,进行真正的客户数据在线实验了。

  2. 由于生产环境中的所有内容都是工作流,因此 Vena 需要将其原型代码转换为工作流程。所以,Vena 使用编排系统提供的语法,在 YAML(文本配置)文件中将其工作重建为一个任务的 DAG。例如,数据解析->数据增强->数据集构建->训练->[在线评估、离线评估]->模型发布。

  3. 然后,Vena 为 DAG 中每个步骤设置输入/输出参数和动作。以训练步骤为例,Vena 将步骤动作设置为 RESTful HTTP 请求。此步将向模型训练服务发送一个 RESTful 请求来启动训练作业。该请求的有效载荷和参数来自步骤输入参数。

  4. 一旦定义好工作流,Vena 就在 DAG YAML 文件中设置工作流的执行计划。例如,Vena 可以将工作流安排在每个月的第一天运行,还可以将工作流设置为由外部事件触发。

  5. Vena 运行工作流本地验证,并将工作流提交给编排服务。

为了让您了解工作流在现实中的含义,以下代码显示了 Vena 的伪工作流(在第 9.3 节,我们将讨论实际的工作流系统):

# define workflow DAG
with DAG( 
  description='Vena’s sample training workflow',
  schedule_interval=timedelta(months=1),
  start_date=datetime(2022, 1, 1),
  ) as dag:                                       ❶

 # define execution logic for each step
  data_parse_step = BashOperator( .. .. ..)
  data_augment_step = BashOperator( .. .. ..)     ❷
  dataset_building_step = BashOperator( .. .. ..)
  training_step = BashOperator( .. .. ..)

 # Declares step dependencies
  data_parse_step >> data_augment_step            ❸
  >> dataset_building_step >> training_step       ❸

❶ DAG 定义;定义了工作流的主体,包括步骤和依赖项

❷ 执行数据增强的 bash 命令

❸ 顺序执行流

执行阶段

在执行阶段,编排服务执行模型训练工作流,如 Vena 的示例所示:

  1. 一旦 Vena 的工作流被提交,编排服务就会将工作流 DAG 保存到数据库中。

  2. 编排服务的调度器组件会检测 Vena 的工作流,并将工作流的任务分派给后端工作者。调度器将确保任务按照工作流 DAG 中定义的顺序执行。

  3. Vena 使用编排服务的 Web UI 实时检查工作流的执行进度和结果。

  4. 如果工作流生成了一个好的模型,Vena 可以将其推广到分期和生产环境。如果不是,Vena 就会开始另一个原型的迭代。

判断一个编排系统是否适合深度学习的一个关键指标是将原型代码转换为工作流的难易程度。在图 9.4 中,我们可以看到,每次 Vena 原型化一个新的想法时,她都需要将其训练代码转换为工作流。我们可以想象如果我们减少将深度学习代码转换为工作流的摩擦会节省多少人力时间。

注意 一个工作流应该总是轻量级的。工作流用于自动化一个过程;其目标是将一系列任务分组并连接起来,并按照定义的顺序执行它们。使用工作流的巨大好处是人们可以共享和重复使用这些任务,因此他们可以更快地自动化他们的流程。因此,工作流本身不应进行任何繁重的计算,真正的工作应由工作流的任务完成。

9.2.2 一个通用的编排系统设计

现在让我们转向一个通用的工作流编排系统。为了帮助您了解编排系统的工作原理以及如何研究开源编排系统,我们准备了一个高级系统设计。通过放大详细的实现并仅保留核心组件,这个设计适用于大多数编排系统,包括将在第 9.3 节讨论的开源系统。请参见图 9.5 以获取设计方案。

图 9.5 通用工作流编排服务的设计概览

一个工作流编排系统通常由以下五个组件组成:

  • Web 服务器—Web 服务器提供了一个 Web 用户界面和一组 Web API,供用户创建、检查、触发和调试工作流的行为。

  • 调度器和控制器—调度器和控制器组件有两个功能。首先,调度器监视系统中的每个活动工作流,并在合适的时间安排工作流运行。其次,控制器将工作流任务分派给工作者。尽管调度器和控制器是两个不同的功能单元,但它们通常一起实现,因为它们都与工作流执行相关。

  • 元数据数据库 — 元数据数据库存储工作流的配置、DAG、编辑和执行历史,以及任务的执行状态。

  • 工作组 — 工作组提供计算资源来运行工作流任务。工作器抽象了基础架构,并且对正在运行的任务不可知。例如,我们可能有不同类型的工作器,例如 Kubernetes 工作器和 Amazon Elastic Compute Cloud(EC2)工作器,但它们都可以执行相同的任务,尽管在不同的基础架构上。

  • 对象存储 — 对象存储是所有其他组件的共享文件存储;通常建立在云对象存储之上,例如 Amazon Simple Storage Service(S3)。对象存储的一个用途是任务输出共享。当工作器运行任务时,它从对象存储中读取上一个任务的输出值作为任务输入;工作器还将任务输出保存到对象存储中,供后续任务使用。

对象存储和元数据数据库都可以由编排系统的所有组件访问,包括调度程序、Web 服务器和工作器组件。具有集中式数据存储可以解耦核心组件,因此 Web 服务器、调度程序和工作器可以独立工作。

工作流程是如何执行的?

首先,Vena 为工作流定义了 DAG。在 DAG 内部,Vena 声明了一组任务,并定义了任务执行顺序的控制流。对于每个任务,Vena 要么使用系统的默认运算符,例如 Shell 命令运算符或 Python 运算符,要么构建自己的运算符来执行任务。

第二,Vena 通过 Web UI 或命令行将工作流程(具有依赖代码的 DAG)提交给 Web 服务器。工作流程保存在元数据数据库中。

第三,调度程序定期(每隔几秒或几分钟)扫描元数据数据库并检测新的工作流程;然后在预定时间启动工作流程。为了执行工作流程,调度程序调用控制器组件根据 DAG 中定义的任务顺序将工作流程的任务分派到工作器队列中。

第四,工作人员从共享作业队列中挑选一个任务;它从元数据数据库中读取任务定义,并通过运行任务的运算符执行任务。在执行过程中,工作人员将任务的输出值保存到对象存储中,并将任务的执行状态报告回元数据数据库。

最后但同样重要的是,Vena 使用托管在 Web 服务器组件上的 Web UI 来监视工作流程的执行。因为调度程序/控制器组件和工作器实时向元数据数据库报告状态,所以 Web UI 始终显示最新的工作流程状态。

9.2.3 工作流程编排设计原则

因为我们已经看到了工作流编排系统在内部和外部的工作方式,现在是时候研究使编排系统在深度学习场景中出色的设计原则了。我们希望您可以将这些原则作为指导,来改进您的系统或评估开源方法。

注意 在深度学习系统中,工作流编排系统是最复杂的组件之一,涉及到大量的工程工作。所以,在最初的几个版本中,不必过于担心使您的系统与这些原则完全匹配。

原则 1:重要性

工作流编排本质上是一种作业调度的挑战,所以任何编排系统的底线都是提供一个可靠的工作流执行体验。一个有效的工作流应该总是能够正确、重复地按计划执行。

原则 2:可用性

在深度学习环境中,编排系统的可用性衡量标准是是否优化了数据科学家的工作效率。在一个编排系统中,数据科学家的大部分交互工作都是工作流的创建、测试和监控。因此,一个用户友好的编排系统应该让用户能够轻松地创建、监控和排除故障。

原则 3:可扩展性

为了适应各种深度学习基础设施,人们应该能够轻松定义自己的任务操作符和执行器,而不用担心它们部署在哪里。编排系统应该提供适合您环境的抽象级别,无论是 Amazon EC2 还是 Kubernetes。

原则 4:隔离性

可能发生两种关键的隔离:工作流创建隔离和工作流执行隔离。工作流创建隔离意味着在创建工作流时,人们不能相互干扰。例如,如果 Vena 提交了一个无效的工作流有向无环图(DAG),或者发布了一个在其他工作流中被引用的共享库的新版本,那么现有的工作流不应受到影响。

工作流执行隔离意味着每个工作流在一个独立的环境中运行。工作流之间不应有资源竞争,并且一个工作流的失败不会影响其他工作流的执行。

原则 5:扩展性

一个好的编排系统应该解决以下两个扩展性问题:处理大量同时运行的工作流以及处理大型扩展性工作流。同时运行的工作流扩展性通常指,给定足够的计算资源 —— 例如,向工作组中添加更多的工作节点 —— 编排系统可以满足无限数量的并发工作流执行。此外,系统应始终为每个工作流保持服务级别协议(SLA)。例如,工作流应在其预定时间执行,且不得晚于 2 秒,无论有多少其他工作流正在执行。

对于单一的大型工作流扩展,系统应该鼓励用户不必担心性能,这样他们就可以专注于可读性强、直接明了的代码和简单的操作。当工作流执行达到限制时——例如,训练运算符执行时间过长——编排系统应该提供一些水平并行运算符,例如分布式训练运算符,以解决单个工作流性能问题。

深度学习编排的主要扩展思想是我们应该在系统级别解决性能问题,并避免要求用户考虑可扩展性编写代码。这可能导致可读性下降、调试困难和操作负担增加。

原则 6:人本支持,既适用于原型设计,也适用于生产环境

连接数据科学家本地原型代码到生产工作流的能力是深度学习特有的要求。这是我们用来评估编排系统是否适合深度学习系统的关键指标。

一个为深度学习设计的编排系统将尊重深度学习项目开发是从原型到生产的迭代持续努力的事实。因此,它将不遗余力地帮助数据科学家将他们的本地原型代码转换为生产工作流。

9.3 巡回开源工作流编排系统

在本节中,我们将介绍三种经过实战验证的工作流编排系统:Airflow、Argo Workflows 和 Metaflow。这三个开源系统在 IT 行业得到了广泛应用,并得到了活跃社区的支持。除了一般介绍外,我们还将从深度学习项目开发的角度评估这些工作流系统。

为了进行公正的比较,我们在 Airflow、Argo Workflows 和 Metaflow 中为相同工作流实现伪代码。基本上,如果有新数据,我们首先转换数据并将其保存到数据库的新表中,然后通知数据科学团队。此外,我们希望工作流每天运行。

9.3.1 Airflow

Airflow(airflow.apache.org/docs/apache-airflow/stable/index.html)于 2014 年在 Airbnb 创建,现在是 Apache 基金会的一部分。Airflow 是一个平台,用于以编程方式编写、调度和监视工作流。Airflow 并不是为深度学习用例设计的;它最初是为了编排越来越复杂的 ETL(抽取、转换、加载)管道(或数据管道)而构建的。但由于 Airflow 具有良好的可扩展性、生产质量和 GUI 支持,它被广泛应用于许多其他领域,包括深度学习。截至本书撰写时,Airflow 是最受欢迎的编排系统。

典型用例

在 Airflow 中构建工作流程需要两个步骤。首先,定义工作流 DAG 和任务。其次,在 DAG 中声明任务之间的依赖关系。Airflow DAG 本质上是 Python 代码。看以下清单,了解我们的示例工作流在 Airflow 中是如何实现的。

清单 9.1 一个示例 Airflow 工作流定义

# declare the workflow DAG. 
with DAG(dag_id="data_process_dag",
        schedule_interval="@daily",
        default_args=default_args,
        template_searchpath=[f"{os.environ['AIRFLOW_HOME']}"],
        catchup=False) as dag:

 # define tasks of the workflow, each code section below is a task 

   is_new_data_available = FileSensor(       ❶
       task_id="is_new_data_available",
       fs_conn_id="data_path",
       filepath="data.csv",
       .. .. ..
   )

 # define data transformation task
   transform_data = PythonOperator( 
       task_id="transform_data",
       python_callable=transform_data        ❷
   )

 # define table creation task
   create_table = PostgresOperator(          ❸
       task_id="create_table",
       sql='''CREATE TABLE IF NOT EXISTS invoices (
               .. .. ..
               );''',
       postgres_conn_id='postgres',
       database='customer_data'
   )

   save_into_db = PythonOperator(
       task_id='save_into_db',
       python_callable=store_in_db
   )

   notify_data_science_team = SlackWebhookOperator(
       task_id='notify_data_science_team',
       http_conn_id='slack_conn',
       webhook_token=slack_token,
       message="Data Science Notification \n"
       .. .. ..
   )

# Step two, declare task dependencies in the workflow
  is_new_data_available >> transform_data
  transform_data >> create_table >> save_into_db
  save_into_db >> notify_data_science_team
  save_into_db >> create_report

# The actual data transformation logic, which is referenced
# in the “transform_data” task.
def transform_data(*args, **kwargs):
   .. .. ..

❶ 检查是否有新文件到达

❷ 实际逻辑是在 "transform_data" 函数中实现的。

❸ PostgresOperator 是预定义的 Airflow 运算符,用于与 postgres db 交互。

在代码清单 9.1 中,我们看到示例工作流 DAG 包含多个任务,如 create_tablesave_into_db。在 Airflow 中,任务被实现为运算符。有许多预定义和社区管理的运算符,例如 MySqlOperator、SimpleHttpOperator 和 Docker 运算符。

Airflow 的预定义运算符帮助用户实现任务而无需编码。您还可以使用 PythonOperator 运行自定义的 Python 函数。一旦工作流 DAG 被构建并且所有代码被部署到 Airflow,我们可以使用 UI 或以下 CLI 命令来检查工作流执行状态;以下是一些示例 shell 命令:

airflow dags list                             ❶

airflow tasks list data_process_dag           ❷

airflow tasks list data_process_dag --tree    ❸

❶ 打印所有活动的 DAG

❷ 打印 "data_process_dag" DAG 中任务的列表

❸ 打印 "data_process_dag" DAG 中任务的层次结构

如果您想了解更多关于 Airflow 的信息,您可以查看其架构概述文档和教程 (mng.bz/Blpw)。

关键功能

Airflow 提供以下关键功能:

  • DAGs — Airflow 通过 DAGs 抽象复杂的工作流程,工作流 DAG 是通过 Python 库实现的。

  • 程序化工作流管理 — Airflow 支持动态创建任务,并允许创建复杂的动态工作流。

  • 出色的内置运算符帮助构建自动化 — Airflow 提供了许多预定义的运算符,帮助用户实现任务而无需编码。

  • 可靠的任务依赖性和执行管理 — Airflow 在每个任务中都内置了自动重试策略,并提供了不同类型的传感器来处理运行时依赖关系,例如检测任务完成、工作流运行状态变更和文件存在。

  • 可扩展性 — Airflow 使其传感器、钩子和运算符完全可扩展,这使得它能够从大量社区贡献的运算符中受益。Airflow 还可以通过添加定制运算符轻松集成到不同的系统中。

  • 监控和管理界面 — Airflow 提供了一个强大的用户界面,用户可以快速了解工作流/任务执行状态和历史。用户还可以从界面触发和清除任务或工作流运行。

  • 生产质量 — Airflow 提供了许多有用的工具,用于在生产环境中维护服务,如任务日志搜索、扩展、报警和 restful API。

限制

尽管 Airflow 是一个出色的工作流编排工具,但在深度学习场景中使用时仍然存在一些缺点:

  • 数据科学家入门时的高前期成本 — Airflow 对于实现不受内置运算符支持的任务具有陡峭的学习曲线。此外,没有一种简单的方法进行工作流本地测试。

  • 将深度学习原型代码移至生产环境时的高摩擦力 — 当我们将 Airflow 应用于深度学习时,数据科学家必须将他们的本地模型训练代码转换为 Airflow DAG。这是额外的工作,对于数据科学家来说是一种不愉快的体验,特别是考虑到如果我们直接从模型训练代码构建工作流程 DAG,这是可以避免的。

  • 在 Kubernetes 上操作时的高复杂性 — 在 Kubernetes 上部署和操作 Airflow 并不简单。如果您希望采用一个编排系统在 Kubernetes 上运行,Argo Workflows 是一个更好的选择。

9.3.2 Argo Workflows

Argo Workflows 是一个开源的、容器原生的工作流引擎,用于在 Kubernetes 上编排并行工作流程/任务。Argo Workflows 解决了与 Airflow 相同的问题,但采用了不同的方式;它采用了 Kubernetes 本地方法。

Argo Workflows 和 Airflow 之间最大的区别在于 Argo Workflows 在 Kubernetes 上本地构建。更具体地说,Argo Workflows 中的工作流程和任务以 Kubernetes 自定义资源定义(CRD)对象实现,并且每个任务(步骤)都作为 Kubernetes pod 执行。请参阅图 9.6 以获得高级系统概述。

图 9.6 Argo Workflows 中的工作流程及其步骤是作为 Kubernetes pod 执行的。

在图 9.6 中,Vena(数据科学家)首先将工作流程及其步骤/任务定义为 Kubernetes CRD 对象,通常表示为 YAML 文件。然后她将工作流提交到 Argo Workflows,其控制器在 Kubernetes 集群内创建 CRD 对象。接下来,Kubernetes pod 动态启动以按工作流程顺序运行工作流程步骤/任务。

您还可以注意到,每个步骤的执行完全由容器和 pod 隔离;每个步骤使用文件来表示其输入和输出值。Argo Workflows 会自动将依赖文件挂载到步骤的容器中。

Kubernetes pod 创建的任务隔离是 Argo Workflows 的一个巨大优势。简单性也是人们选择 Argo Workflows 的另一个原因。如果您了解 Kubernetes,Argo 的安装和故障排除都很简单。我们可以使用 Argo Workflows 命令或标准的 Kubernetes CLI 命令来调试系统。

典型的使用案例

为了更好地理解,让我们看一个 Argo Workflows 的例子。在本节中,我们使用 Argo Workflows 来自动化我们在之前 Airflow 部分看到的相同数据处理工作。工作流程包括首先检查新数据,转换数据,将其保存到数据库中的新表中,然后通过 Slack 通知数据科学家团队。请参阅以下代码清单以查看 Argo Workflows 的定义。

代码清单 9.2 Argo Workflows 的示例工作流程,包含一系列步骤

apiVersion: argoproj.io/v1alpha1
kind: Workflow                                         ❶
metadata:
 generateName: data-processing-  
spec:
 entrypoint: argo-steps-workflow-example 
 templates:
   - name: argo-steps-workflow-example
     Steps:                                            ❷
       - - name: check-new-data
           template: data-checker                      ❸
       - - name: transform-data
           template: data-converter
           arguments:
             artifacts:
               - name: data-paths                      ❹
                 from: "{{steps.check-new-data.outputs. 
                          artifacts.new-data-paths}}"  ❺
       - - name: save-into-db
           template: postgres-operator
       - - name: notify-data-science-team
           template: slack-messenger

   - name: data-checker                                ❻
     container:
       image: docker/data-checker:latest
       command: [scan, /datastore/ds/]
     outputs:
       artifacts:
         - name: new-data-paths                        ❼
           path: /tmp/data_paths.txt

   - name: data-converter
     inputs:
       artifacts:
         - name: data_paths                            ❽
           path: /tmp/raw_data/data_paths.txt
     container:
       image: docker/data-checker:latest
       command: [data_converter, /tmp/raw_data/data_paths.txt]

   - name: save-into-db
     .. .. ..
   - name: notify-data-science-team
     .. .. ..

❶ 将 CRD 对象类型声明为工作流程

❷ 声明工作流程的步骤

❸ 步骤主体被定义为另一个模板,类似于函数。

❹ 声明数据路径工件来自由 check-new-data 步骤生成的新数据路径工件

❺ 这就是步骤传递参数的方式。

❻ 实际步骤定义,类似于函数实现

❼ 声明此步骤的输出工件(生成新的数据路径);工件来自/tmp/data_paths.txt,该工件也可以是一个目录。

❽ 解压缩数据 _paths 输入工件,并将其放置在/tmp/raw_data/data_paths.txt

Argo Workflows 中最基本的概念是工作流程和模板。工作流程对象代表工作流程的单个实例;它包含工作流程的定义和执行状态。我们应该将工作流程视为一个“活动”对象。模板可以被认为是函数;它们定义要执行的指令。entrypoint字段定义了主函数是什么,意味着将首先执行的模板。

在代码清单 9.2 中,我们看到了一个四步顺序工作流程:check-new-data -> transform_data -> save-into-db -> notify-data-science-team。每个步骤都可以引用一个模板,并且步骤通过工件(文件)传递参数。例如,check-new-data引用了data-checker模板,该模板定义了用于检查是否有新数据的 Docker 镜像。data-checker模板还声明了步骤输出——新到达的数据文件路径——将被保存到/tmp/data_paths.txt作为其输出值。

接下来,步骤transform_datacheck-new-data的输出绑定到 data-converter 模板的输入。这就是变量在步骤和模板之间移动的方式。一旦您提交了工作流程——例如,argo submit -n argo sample_workflow.yaml——您可以使用 Argo Workflows UI 或以下命令来查看工作流运行的详细信息:

# list all the workflows
argo list -n argo

# get details of a workflow run
argo get -n argo {workflow_name}

除了使用argo命令之外,我们还可以使用 Kubernetes CLI 命令来检查工作流的执行,因为 Argo Workflows 在 Kubernetes 上原生运行;请参考以下示例:

# list all argo customer resource definitions 
kubectl get crd -n argo

# list all workflows
kubectl get workflows -n argo

# check specific workflow
kubectl describe workflow/{workflow_name} -n argo

要了解更多关于 Argo Workflows 的信息,您可以查看 Argo Workflows 用户指南(mng.bz/WAG0)和 Argo Workflows 架构图(argoproj.github.io/argo-workflows/architecture)。

代码 Docker 化:轻松进行生产部署

Argo Workflows 本质上是一个 Kubernetes Pod(Docker 镜像)调度系统。尽管它强迫人们将其代码编写成一系列 Docker 镜像,但它在编排系统内部创建了极大的灵活性和隔离性。因为代码以 Docker 形式存在,所以可以由任何工作节点执行,而不用担心配置工作节点环境。

Argo Workflows 的另一个优点是生产部署成本低。当您在 Docker 中本地测试代码时,Docker 镜像(原型代码)可以直接在 Argo Workflows 中使用。与 Airflow 不同,Argo Workflows 几乎不需要从原型代码转换为生产工作流程的工作量。

关键特性

Argo Workflows 提供以下关键特性:

  • 安装和维护成本低—Argo Workflows 在 Kubernetes 上原生运行,因此您可以只使用 Kubernetes 进程来解决任何问题;无需学习其他工具。此外,它的安装非常简单;只需几个 kubectl 命令,您就可以在 Kubernetes 环境中运行 Argo Workflows。

  • 稳健的工作流程执行—Kubernetes pod 为 Argo Workflows 任务执行提供了良好的隔离。Argo Workflows 还支持 cron 工作流程和任务重试。

  • 模板化和可组合性—Argo Workflows 模板就像函数一样。在构建工作流程时,Argo Workflows 支持组合不同的模板(步骤函数)。这种可组合性鼓励团队之间共享通用工作,从而大大提高了生产率。

  • 完整的 UI 功能—Argo Workflows 提供了一个方便的 UI 来管理工作流程的整个生命周期,例如提交/停止工作流程、列出所有工作流程和查看工作流程定义。

  • 高度灵活和适用—Argo Workflows 定义了 REST API 来管理系统和添加新功能(插件),并且工作流程任务定义为 Docker 镜像。这些特性使得 Argo Workflows 在许多领域,如 ML、ETL、批处理/数据处理和 CI/CD(持续集成和持续交付/持续部署)中被广泛使用。

  • 生产质量—Argo Workflows 设计用于在严肃的生产环境中运行。Kubeflow pipeline 和 Argo CD 是将 Argo Workflows 用于生产环境的绝佳示例。

限制

使用 Argo Workflows 在深度学习系统中的缺点如下:

  • 每个人都将编写和维护 YAML 文件—Argo Workflows 要求工作流程定义为 YAML 文件中的 Kubernetes CRD。一个项目的短小 YAML 文件可以管理,但一旦工作流程数量增加并且工作流程逻辑变得更加复杂,YAML 文件可能变得冗长和混乱。Argo Workflows 提供了模板以保持工作流程定义简单,但除非您习惯使用 Kubernetes YAML 配置,否则这仍然不太直观。

  • 必须是 Kubernetes 专家—如果您是 Kubernetes 专家,您会觉得这是司空见惯的。但是初学者可能需要花费相当多的时间学习 Kubernetes 的概念和实践。

  • 任务执行延迟 —— 在 Argo Workflows 中,对于每个新任务,Argo 将启动一个新的 Kubernetes Pod 来执行它。Pod 的启动可能会为每个单独的任务执行引入秒数或分钟,这限制了 Argo 在支持时间敏感的工作流时的能力。例如,Argoflow 不适用于实时模型预测工作流,该工作流以毫秒级 SLA 运行模型预测请求。

9.3.3 Metaflow

Metaflow 是一个以人为本的 Python 库,专注于 MLOps。它最初是在 Netflix 开发的,并于 2019 年开源。Metaflow 的特点在于它遵循以人为本的设计;它不仅用于自动化工作流程,还旨在减少在深度学习项目开发中花费的人工时间(操作成本)。

在第 9.1.3 节中,我们指出原型代码转换为生产工作流会在 ML 开发中产生很多摩擦。数据科学家必须为每个模型开发迭代构建和测试新版本的工作流。为了弥合原型和生产之间的差距,Metaflow 进行了两项改进:首先,它简化了工作流程的构建;其次,它统一了本地和生产环境之间的工作流程执行体验(参见图 9.7)。

图 9.7 Metaflow 在原型和生产之间提供了统一的开发体验。

在图 9.7 中,我们可以看到 Metaflow 将原型和生产环境都视为一流的执行环境。由于 Metaflow 库提供了一组统一的 API 来抽象实际的基础设施,一个工作流可以在不同的环境中以相同的方式运行。例如,一个工作流可以在本地调度器和生产调度器上运行而无需任何更改。本地调度器在本地执行工作流,而生产调度器集成到其他生产编排系统中,例如 AWS Step Functions 或 Argo Workflows。

Metaflow 允许用户注释 Python 代码 —— 一个 DAG Python 类 —— 来定义工作流程。然后 Metaflow 库会根据 Python 注释自动创建/打包工作流。使用 Metaflow Python 注释,Vena 可以在不更改任何原型代码的情况下构建工作流程。

除了无缝创建和测试工作流之外,Metaflow 还提供其他一些对模型可重复性至关重要的实用功能,如工作流/步骤版本控制和步骤输入/输出保存。要了解更多关于 Metaflow 的信息,您可以查看 Metaflow 的官方网站(docs.metaflow.org/)和一本名为《Effective Data Science Infrastructure》的精彩 Metaflow 书籍,作者是 Ville Tuulos(Manning,2022;www.manning.com/books/effective-data-science-infrastructure)。

典型用例

让我们使用 Metaflow 自动化我们在 9.3.1 和 9.3.2 节中看到的相同的数据处理工作。请参见伪代码清单以下示例。

图 9.3 显示了一个 Metaflow 工作流的示例

# define workflow DAG in a python class
class DataProcessWorkflow(FlowSpec):

  # define "data source" as an input parameter for the workflow 
  data_source = Parameter(
     "datasource_path", help="the data storage location for data process"
     , required=True
  )

  @step
  def start(self):
    # The parameter “self.data_source” are available in all steps.
    self.newdata_path = dataUtil.fetch_new_data(self.data_source)

    self.next(self.transform_data)

  @step
  def transform_data(self):
    self.new_data = dataUtil.convert(self.newdata_path)

    # fan out to two parallel branches after data transfer.
    self.next(self.save_to_db, self.notify_data_science_team)

  @step
  def save_to_db(self):
    dataUtil.store_data(self.new_data)
    self.next(self.join)

  @step
  def notify_data_science_team(self):
    slackUtil.send_notification(messageUtil.build_message(self.new_data))

    self.next(self.join)

  # join the two parallel branches steps: 
  # notify_data_science_team and save_to_db 
  @step
  def join(self, inputs):

    self.next(self.end)

  @step
  def end(self, inputs):
    # end the flow.
    pass

if __name__ == "__main__":
  DataProcessWorkflow()

在代码清单 9.3 中,我们看到 Metaflow 通过使用代码注释的新方法构建工作流。通过在函数上注释 @step 并使用 self.next 函数来连接步骤,我们可以轻松地从我们的原型代码构建一个工作流 DAG(图 9.8)。

图 9.8 显示了从图 9.3 构建的工作流 DAG

这里的一大优势在于,我们不需要在一个单独的系统中定义工作流 DAG 并将代码重新打包到不同的格式(比如 Docker 镜像)中。Metaflow 工作流完全融入我们的代码中。工作流开发和原型代码开发发生在同一地方,并且可以从整个 ML 开发周期的开始到结束一起进行测试。

代码准备就绪后,我们可以在本地验证和运行工作流。参见以下示例命令:

# display workflow DAG
python data_process_workflow.py show

# run the workflow locally
python data_process_workflow.py run

一旦我们完成了本地开发和测试,就该将工作流推送到生产环境了,可以通过以下两个命令来实现:

# push the workflow from local to AWS step functions
python data_process_workflow.py --with retry step-functions create

# push the workflow from local to Argo workflows
python data_process_workflow.py --with retry argo-workflows create

这些命令将我们在代码清单 9.3 中定义的数据处理工作流导出到 AWS Step Functions 和 Argo Workflows。然后,您还可以在 AWS Step Functions UI 或 Argo Workflows UI 中按名称搜索流程,从而查看导出的流程。

注意 Metaflow 在本地和生产环境之间提供了统一的开发体验。由于 Metaflow 提供的统一 API,我们在本地和生产环境中测试代码和工作流时拥有无缝的体验。无论使用哪种后端工作流编排系统,无论是 Metaflow 本地调度器、Argo Workflows 还是 AWS Step Functions,工作流开发的 Metaflow 用户体验都是相同的!

关键功能

Metaflow 提供以下关键功能:

  • 将代码结构化为工作流 — Metaflow 允许用户通过对 Python 代码进行注释来创建工作流,这极大地简化了工作流的构建。

  • 可重复性 — Metaflow 保留了执行每个工作流步骤所需的数据、代码和外部依赖项的不可变快照。Metaflow 还记录了每个工作流执行的元数据。

  • 版本控制 — Metaflow 通过对工作流中的所有代码和数据进行哈希处理来解决 ML 项目的版本控制要求。

  • 稳健的工作流执行 — 元数据通过使用 @conda 装饰器在工作流级别和步骤级别提供了依赖管理机制。它还提供了任务重试。

  • ML 的可用性设计 — Metaflow 将原型设计和生产视为同等重要。它提供了一组统一的 API 来抽象基础设施,因此相同的代码可以在原型环境和生产环境中运行而无需任何更改。

  • 无缝扩展性—Metaflow 集成了 Kubernetes 和 AWS Batch,允许用户轻松定义所需的计算资源,并可以并行执行任意数量的工作流步骤。例如,通过对步骤函数应用像@batch(cpu=1, memory=500)这样的注解,Metaflow 将与 AWS Batch 合作分配所需的资源来计算此步骤。

局限性

在深度学习系统中使用 Metaflow 的缺点如下:

  • 没有条件分支支持—Metaflow 步骤注解不支持条件分支(仅在满足条件时执行步骤)。这不是一个红旗,但是这是一个很好的功能。

  • 没有作业调度程序—Metaflow 本身不带有作业调度程序,因此无法使用 cron 工作流。这并不是一个大问题,因为 Metaflow 可以与支持作业调度的其他编排系统集成,例如 AWS Step Functions 和 Argo Workflows。

  • 与 AWS 紧密耦合—Metaflow 的最重要特性与 AWS 紧密耦合,例如,Amazon S3 和 AWS Batch。幸运的是,Metaflow 是一个开源项目,因此可以将其扩展到非 AWS 替代方案。

9.3.4 何时使用

如果您正在寻找一种用于自动化非 ML 项目工作流执行的编排系统,Airflow 和 Argo Workflows 都是不错的选择。它们拥有出色的社区支持,并且在 IT 行业被广泛使用。如果您的系统在 Kubernetes 上运行,并且您的团队习惯使用 Docker,那么 Argo Workflows 将是一个很好的选择;否则,Airflow 也不会让您失望。

如果您正在寻找一个能够简化 ML 项目开发流程的系统,Metaflow 强烈推荐。Metaflow 不仅是一个编排工具;它是一个 MLOps 工具,旨在节省数据科学家在 ML 开发周期中的时间。由于 Metaflow 抽象了 ML 项目的后端基础设施部分,数据科学家可以专注于模型开发,而无需担心生产转换和部署。

总结

  • 工作流是某个更大任务的操作序列。工作流可以看作是步骤的 DAG。步骤是最小的可恢复计算单元,描述了要执行的操作;步骤要么全部成功,要么全部失败。DAG 指定了步骤之间的依赖关系和执行顺序。

  • 工作流编排意味着根据工作流的有向无环图(DAG)中定义的顺序执行工作流步骤。

  • 采用工作流鼓励工作共享、团队协作和自动化。

  • 在深度学习项目中应用工作流的主要挑战是降低工作流构建成本,简化工作流测试和调试。

  • 构建/评估工作流编排系统的六个推荐设计原则是关键性、可用性、可扩展性、任务隔离性、可扩展性和以人为中心。

  • 在选择非机器学习项目的编排系统时,Airflow 和 Argo Workflows 都是不错的选择。如果项目在 Kubernetes 和 Docker 上运行,Argo Workflows 是更好的选择。

  • 在选择机器学习项目的编排系统时,Metaflow 目前是最佳选择。

第十章:生产路径

本章涵盖

  • 在生产化深度学习模型之前的初步工作和任务

  • 使用深度学习系统生产化深度学习模型

  • 用于生产中实验的模型部署策略

对于书的结尾章节,我们认为回到高层次视角并连接前几章的所有内容是有意义的。我们现在已经详细讨论了深度学习系统中的每个服务。在本章中,我们将讨论这些服务如何共同支持我们在第一章介绍的深度学习产品开发周期。如果你还记得的话,该周期将研究和数据科学的努力一直延伸到生产化,最终产品是客户使用的产品。

作为提醒,图 10.1 取自第一章,展示了产品开发周期。本章我们的重点将放在该过程末尾发生的三个阶段上:深度学习研究、原型制作和生产化。这意味着我们将忽略实验、测试、训练和探索的循环,并关注如何将最终产品从研究阶段转化为最终产品,使其准备好发布到公众。

图 10.1 这个深度学习开发周期是将深度学习从研究转化为成品的典型情景。框 (3)、(4) 和 (5) 是本章的重点。

定义 生产化是生产出一个值得用户消费的产品的过程。生产值得性通常被定义为能够服务于客户请求、承受一定水平的请求负载,并优雅地处理诸如格式不正确的输入和请求过载等不利情况。

正如我们所说,本章重点讨论从研究、原型制作到生产化的生产周期路径。让我们将这三个阶段从图 10.1 所示的典型开发周期中拿出来,以便更详细地查看它们。我们将这些阶段放在下一个图表中,图 10.2,并放大它们,以显示每个阶段内的步骤,以及三个阶段之间的连接方式。不要让这个图表的复杂性吓到你!在本章中,我们将带领你走过每个阶段和每个步骤。

图 10.2 生产样品路径中的三个主要阶段。在生产化之前,研究和原型制作会经历许多迭代。

让我们简要回顾一下这张图表,因为它将提供本章的预览。图 10.2 中的前两个阶段是研究和原型制作。这两项工作都需要从模型训练和实验中进行快速迭代和周转。这些阶段(步骤 1–8)中的主要交互点是笔记本环境。使用笔记本,研究人员和数据科学家会调用数据集管理服务来跟踪训练数据集(在步骤 2 和 6 中),并可能使用训练服务和超参数优化库/服务来进行模型训练和实验(在步骤 4 和 8 中)。我们在 10.1 节中详细介绍了这些阶段,直到训练数据形状和代码变得相当稳定并且可以进行产品化。换句话说,团队已经提出了更多或更少的最终版本,并且准备通过最后的步骤将其发布给公众。

在 10.2 节中,我们将从上一节开始,介绍模型的产品化,直到模型被提供给生产推断请求流量的点。

定义 推断请求 是用户或应用程序针对经过训练的模型生成推断的输入。以视觉识别为例。推断请求可以是一张猫的图片。使用经过训练的视觉识别模型,或者推断,可以生成一个形式为 的标签。

这个部分对应于图 10.2 中的第三个也是最后一个阶段。在产品化中,我们系统中的几乎每个服务都会发挥作用。数据集管理服务管理训练数据;工作流管理服务启动和跟踪训练工作流;训练服务执行和管理模型训练作业;元数据和工件存储包含和跟踪代码工件、训练模型及其元数据;模型服务将经过训练的模型提供给推断请求流量。

从产品化转移到部署。在 10.3 节中,我们将研究一些支持在生产中更新模型到新版本的模型部署策略。这些策略还支持在生产中进行实验。这里的主要重点将放在模型服务上,因为这是所有推断请求都被服务的地方。

通过全程跟踪到产品的完整过程,我们希望您能够看到我们在前几章中讨论的第一原则如何影响使用该系统提供深度学习功能的不同方。您从本章中获得的理解应该有助于您将自己的设计适应不同的情况。我们将使用图像识别产品的开发作为示例,以说明所有操作步骤。

10.1 准备产品化

在本节中,我们将研究深度学习模型从诞生前到准备投入生产的过程。在图 10.3 中,我们突出显示了深度学习研究和原型设计的阶段,这些阶段来自更大的深度学习开发循环(如图 10.1 所示)。我们将从深度学习研究阶段开始,在这个阶段,模型训练算法诞生。并不是每个组织都进行深度学习研究,有些使用现成的训练算法,如果这种情况不适用于您,请随意跳过这一步。

图 10.3 深度学习研究和原型设计阶段通往生产的路径摘录

在深度学习研究之后,我们继续进行原型设计。在此阶段,我们假定算法已经准备好用于训练模型。数据探索和实验模型训练的快速迭代过程构成了这一步骤的核心。这一步的目标是找到适当的训练数据形状,并开发一个稳定的模型训练代码库。

10.1.1 研究

通过研究发明了新的深度学习算法,并通过研究改进了现有算法。因为同行评审研究需要可重复的结果,所以模型训练数据需要公开获取。许多公共数据集,例如 ImageNet,都可以供研究团队使用。

笔记本环境,如 JupyterLab,是研究人员原型设计模型培训最流行的选择,因为它的交互性和灵活性。让我们看一下研究人员在模型训练原型设计期间可能采取的一些示例步骤:

  1. 深度学习研究员 Alice 正在致力于改进视觉识别算法。经过理论探讨后,她准备开始原型设计。

  2. Alice 在 JupyterLab 中创建了一个新的笔记本。

  3. Alice 想要使用 ImageNet 数据集来训练和基准测试她的算法。她可能

    1. 编写代码将数据集下载到她的笔记本中,并在数据集管理服务中存储它以供重复使用(第二章)。

    2. 发现数据集已经存储在数据集管理服务中,并编写代码来直接使用它。

  4. Alice 开始对现有的视觉识别算法进行改进,直到它能够在笔记本中本地生成实验模型。

  5. Alice 尝试更改一些超参数,训练和测试一些实验模型,并比较它们生成的指标。

  6. Alice 可以进一步使用超参数优化技术(第五章)自动运行更多实验,以确认她确实对现有算法进行了改进。

  7. Alice 发布了她的研究结果,并将她的训练代码改进打包成一个库供他人使用。

通过使用版本化数据集进行训练,爱丽丝确保她所有实验模型训练运行的输入训练数据是相同的。她还使用源控制管理系统,如 Git,以跟踪她的代码,以便所有实验模型都可以追溯到她的代码版本。

注意,在这个阶段,模型训练通常在笔记环境托管的计算节点上进行,因此很有必要为这些节点分配足够的资源。如果训练数据存储在网络上,请确保读取速度不会成为模型训练的瓶颈。

10.1.2 原型制作

原型制作是将研究与实际用例联系起来的实践。它是寻找合适的训练数据、算法、超参数和推断支持的正确深度学习特征的实践,以满足产品需求。

在这个阶段,很常见发现笔记环境仍然是数据科学家和工程师的首选,原因是原型制作的快速迭代性质。期望快速交付。让我们走一遍原型制作的一个可能的场景:

  1. 模型开发团队收到产品需求,要改善安全摄像头产品的运动检测。

  2. 基于需求,团队发现爱丽丝的新视觉识别训练算法可能有助于改善运动检测。

  3. 团队创建一个新的笔记本,并开始探索与他们选择的算法集相关的用于模型训练的数据:

    1. 如果团队有已收集的数据与正在解决的问题相匹配,他们可能能够使用现有数据进行模型训练。

    2. 在某些情况下,团队可能需要收集新数据进行训练。

  4. 在大多数情况下,这个阶段应用迁移学习,并且团队会选择一个或多个现有模型作为源模型。

  5. 团队开发带有算法的建模代码,并使用已收集的数据和源模型训练实验模型。

  6. 实验模型经过评估,以查看是否产生令人满意的结果。步骤 3 到 6 会重复进行,直到训练数据形状和代码稳定。

我们称步骤 3 到 6 为探索循环。这个循环对应于图 10.3 中原型制作的放大部分的迭代圈。当原型制作开始时,这个循环会迅速迭代。此阶段的重点是缩小训练数据形状和代码。

一旦训练数据形状和代码稳定,它们将准备好进行进一步的调整和优化。这个阶段的目标是收敛到一个状态,使得模型训练和推断代码可以被打包并部署到生产环境。

10.1.3 关键收获

我们已经走过图 10.1 中我们参考的深度学习开发周期中的研究和原型制作阶段。尽管它们有不同的目的,但我们看到它们在如何处理深度学习系统方面有相当大的重叠:

  • 笔记本环境是研究和预生产原型设计的常见选择,因为它具有高度的交互性和冗长性。

  • 对训练数据的访问应尽可能宽泛和灵活(在合法性和合规性的限制范围内),这有助于加速数据探索过程。

  • 应为模型训练分配足够的计算资源,以确保周转时间短。

  • 至少使用数据集管理服务和源代码控制管理系统来跟踪实验模型的来源。此外,使用元数据存储来包含指标,并将其与训练数据集和代码关联起来,以进行完整的渊源追踪。

10.2 模型生产化

在深度学习模型能够集成到最终产品之前,它们需要经历生产化过程。对于这个术语肯定有很多解释,但基本上是:

  • 模型需要为生产推断请求提供服务,无论是来自最终产品还是最终用户。

  • 模型服务应满足预定义的服务水平协议,例如在 50 毫秒内响应或可用时间达到 99.999%。

  • 与模型相关的生产问题应易于故障排除。

在本节中,我们将看看深度学习模型如何从一个相当动态的环境,比如笔记本,过渡到一个生产环境,在那里它们会受到各种严苛条件的影响。图 10.4 显示了生产化阶段相对于开发周期的其余部分。让我们回顾一下这个阶段的步骤。

图 10.4 生产化阶段的生产路径摘录

10.2.1 代码组件化

如前一节所示,在原型设计期间,常常将训练数据准备、模型训练和推断代码存在于单个笔记本中。为了将它们生产化为一个深度学习系统,我们需要将它们拆分为单独的组件。拆分组件的一种方法,即代码组件化,如图 10.5 所示。

图 10.5 将代码从单个笔记本组件化为可以单独打包的多个部分。第一个分割发生在训练好的模型是输出的地方。可选的第二次分割发生在训练数据是输出的地方。

让我们将图中的流程付诸实施。在代码中划分的第一条分割线是模型是输出的地方。这应该导致两段代码如下所示:

  • 输出模型的模型训练代码

  • 模型推断代码,以模型和推断请求作为输入,产生推断作为输出

可选择地,模型训练代码可以分为以下几部分:

  • 训练数据转换代码,以原始数据作为输入,并输出可被模型训练代码使用的训练数据

  • 模型训练代码,它以训练数据作为输入,并训练出一个模型作为输出

如果你有其他受益于相同类型准备好的数据的模型训练代码,将这些代码分离开是个好主意。如果你的数据准备步骤需要以不同的节奏执行模型训练,分离也是一个好主意。

10.2.2 代码打包

一旦代码组件被清晰地分离,它们就可以被打包部署。为了能够在训练服务(第三章)、模型服务(第六章)和工作流服务(第九章)上运行它们,我们首先需要确保它们遵循这些服务设置的约定。

模型训练代码应修改为从训练服务设置的环境变量指示的位置获取训练数据。其他组件应遵循类似的约定。

模型推理代码应遵循您选择的模型服务策略的约定:

  • 如果你使用直接的模型嵌入,需要与嵌入模型的团队合作,确保你的推理代码可以正常工作。

  • 如果你计划使用模型服务来提供模型,确保你的推理代码提供了一个接口,使得模型服务可以进行通信。

  • 如果你使用模型服务器,只要模型服务器能够正确地提供模型,你可能就不需要模型推理代码。

我们将这些代码组件打包为 Docker 容器,以便它们可以被它们各自的主机服务启动、访问和跟踪。如何做到这一点的示例可以在附录 A 中找到。如果需要特殊的数据转换,我们可以将数据转换代码集成到数据管理服务中。

10.2.3 代码注册

在训练代码和推理代码可以被系统使用之前,它们的包必须被注册并存储到元数据和工件服务中。这提供了训练代码和推理代码之间的必要联系。让我们看看它们是如何相关的(图 10.6)。

图 10.6 在生产深度学习系统中的简单训练和推理执行流程

一旦训练和推理代码被打包为容器(如图中的训练容器和推理容器),它们可以使用一个共同的句柄(例如visual_recognition,就像图 10.6 中所示的示例一样)注册到元数据和工件存储中。这有助于系统服务在接收到提供相同句柄名称的请求时找到并使用正确的代码容器。我们将在接下来的几个部分继续讲解这个图。

10.2.4 训练工作流程设置

我们建议即使您不经常训练模型,也要设置训练工作流程。 主要原因是在生产中提供相同模型训练流程的可重复性。 当除您之外的其他人需要训练模型并且可以使用您设置的流程时,这非常有帮助。 在某些情况下,生产环境是隔离的,并且通过在生产环境中设置的工作流程可能是在那里生成模型的唯一方法。 在图 10.7 中,我们已将先前图表的模型训练部分放大,以便您可以看到细节。

图 10.7 典型的生产模型训练设置。 工作流服务管理训练流程的何时以及如何运行。 训练服务运行模型训练作业。 元数据和工件存储提供训练代码,存储训练过的模型,并将其与元数据关联起来。

参考图 10.7,一旦设置了 visual_recognition 的训练工作流程,就可以触发训练到训练服务。 训练服务使用句柄从元数据和工件存储中查找要执行的训练代码容器。 一旦模型训练完成,它会使用句柄名称将模型保存到元数据和工件存储中。

在这个阶段,通常也会发现使用超参数优化技术来在模型训练期间找到最佳训练超参数。 如果使用了 HPO 服务,则工作流程将与 HPO 服务而不是直接与训练服务进行通信。 如果您需要提醒 HPO 服务如何工作,请参阅第五章。

10.2.5 模型推理

一旦模型在生产环境中训练并注册,下一步是确保它能够处理系统中进入的推理请求,并在一定的速率内产生推理。 我们可以通过将推理请求发送到模型服务来实现这一点。 当模型服务收到请求时,它会在请求中找到句柄名称 visual_recognition,并查询元数据和工件存储以获取匹配的模型推理容器和模型文件。 然后,模型服务可以一起使用这些工件来生成推理响应。 您可以在图 10.8 中看到这个过程,再次强调,这是图 10.6 模型服务部分的放大版本。

图 10.8 典型的生产模型服务设置。 当推理请求到达模型服务时,服务使用元数据和工件存储查找推理容器和模型,以产生推理响应。

如果您使用模型服务器,可能需要在其前面加上一层薄膜,以便它知道从哪里获取模型文件。 一些模型服务器实现支持自定义模型管理器实现,也可以用于针对元数据和工件存储进行查询以加载正确的模型。

10.2.6 产品集成

在从模型服务获得适当的推断响应后,就该将模型服务客户端集成到将使用这些推断的产品中。这是生产化的最后一步,我们在推出给最终客户之前需要确保检查几个问题。因为我们正在改进安全摄像头产品的运动检测,所以我们必须将一个模型服务客户端集成到安全摄像头视频处理后端中,以便从新改进的模型请求推断:

  • 确保推断响应可被使用它的产品消耗。

  • 通过以接近生产流量的速率发送推断请求来进行压力测试推断。

  • 通过使用不规范的推断请求进行测试推断,以确保其不会破坏模型推断代码或模型服务。

这只是一个非常基本的检查项目列表。您的组织可能会定义更多生产准备性标准,您需要在集成之前满足这些标准。除了可以告诉我们模型是否正常提供推断请求的系统指标外,我们还应该设置业务指标,这些指标将告诉我们模型是否有助于实现业务用例。

10.3 模型部署策略

在上一节中,我们通过一个从原型设计到生产的示例路径。这个过程假设模型是首次部署,没有现有版本的模型需要替换。一旦模型在生产中使用,除非有维护窗口允许,否则通常需要使用模型部署策略来确保生产推断请求流量不会中断。事实上,这些模型部署策略也可以作为在生产中进行实验的方式,通过使用前一节中设置的业务指标。我们将看下三种策略:金丝雀、蓝绿和多臂赌博机。

10.3.1 金丝雀部署

金丝雀部署(类似 A/B 测试)是指在保留旧模型为大多数请求提供服务的同时,将新模型部署到生产推断请求的一小部分上。示例如图 10.9 所示。这需要模型服务支持将一小部分推断请求流量分段和路由到新模型。

图 10.9 金丝雀部署示例,显示将一小部分流量重定向到新模型的版本

使用这种策略,可能由于部署新模型而产生的任何不利影响都局限在少部分最终用户之内。通过将所有推断请求流量路由回旧模型,回滚变得相当简单。

这种方法的一个缺点是,您只能了解到模型对一小部分最终用户的性能。将新模型发布以服务于所有推断请求流量可能会产生与仅为流量的一小部分提供服务时所观察到的不同效果。

10.3.2 蓝绿部署

在我们的上下文中,蓝绿部署意味着部署一个新模型,将所有推理请求流量路由到新模型,并保持旧模型在线,直到我们确信新模型的性能符合预期。在实现上,它是三种策略中最简单的,因为根本没有流量分割。服务所需做的一切就是内部指向新模型以服务所有推理请求。图 10.10 描绘了蓝绿部署。

图 10.10 蓝绿部署显示所有流量指向旧(蓝色)或新(绿色)模型的方向

这种策略不仅简单,而且在为所有终端用户提供服务时可以全面了解模型的表现。回滚也很简单。只需将模型服务指向旧模型即可。

这种方法的明显缺点是,如果新模型出现问题,将影响所有终端用户。当您基于新模型开发新产品功能时,这种策略可能是合理的。随着您随时间迭代训练更好的模型,您可能希望摆脱这种策略,因为终端用户会根据稳定的体验建立他们的期望。

10.3.3 多臂赌博机部署

多臂赌博机(MAB)是三种策略中最复杂的部署策略。MAB 指的是一种技术,它持续监控多个模型的性能,并随着时间的推移将越来越多的推理请求流量重定向到胜利模型。这使用了模型服务最复杂的实现,因为它要求服务了解模型性能,这取决于您的模型性能指标如何定义。MAB 部署在图 10.11 中说明。

图 10.11 多臂赌博机部署显示了第 0 天和第 1 天的流量模式。注意,模型 v2.0a 在第 1 天的模型性能方面处于领先地位,因为它接收到了最多的流量。

不过,这种策略确实带来了一个优势,因为它在一定的时间范围内最大化了表现最佳模型的利益,而使用金丝雀部署,如果新模型胜过旧模型,您可能只会获得最小的利益。注意,您应确保模型服务报告流量分割随时间的变化。这有助于与模型的性能相关联。

摘要

  • 深度学习研究团队发明并改进用于训练模型的深度学习算法。

  • 模型开发团队利用现有的算法和可用数据来训练帮助解决深度学习用例的模型。

  • 研究和原型制作都需要与代码开发、数据探索和可视化的高度互动。笔记本环境是这些团队的流行选择。

  • 数据集管理服务可以在研究和原型制作过程中使用,帮助跟踪用于训练实验模型的训练数据。

  • 一旦训练数据和代码足够稳定,投入生产的第一步是打包模型训练代码、模型推理代码和任何源模型。

  • 所有深度学习系统的服务都可以使用这些软件包来训练、跟踪和提供模型。

  • 一旦模型训练工作流程开始运行并获得令人满意的推理响应,就可以开始与最终用户产品的集成。

  • 如果提供推理请求不能中断,则需要一个模型部署策略。

  • 多种模型部署策略可供选择,它们可以兼作在生产中进行实验。

附录 A:一个“你好世界”深度学习系统

本书是关于教授构建适合自己情况的深度学习系统的设计原则。但你可能会想知道一个深度学习系统看起来是什么样子。或者人们在实践中如何、为什么以及何时使用这样一个系统。这些都是现阶段很好的问题。

我们相信学习新思想、技能和方法的最佳方法是通过实践——通过获取一些示例并看看你能做些什么。为了帮助你,我们建立了一个迷你深度学习系统和一个代码实验室供你使用。在这个“你好世界”深度学习系统中玩耍应该有助于你建立理解本书介绍的概念和原则的知识库。为了使这个示例系统易于理解,我们侧重于深度学习系统的关键组件,如数据集管理(DM)和模型训练与服务。整个迷你系统可以通过一个 bash 脚本轻松设置在你的本地系统上,并且它的组件在后面的章节中会详细讨论。

在这个附录中,我们将首先参观我们的示例系统,然后进行实验室练习,让你体验深度学习系统中最常见的用户活动,包括数据集摄入、模型训练和模型服务。虽然我们的示例系统非常简化,但它涵盖了深度学习系统的所有基础知识。通过阅读这个附录,你不仅会获得一个关于基本深度学习系统是如何组织和运作的实践理解,而且还会对本书其余部分讨论的示例服务有一个全面的了解。

A.1 介绍“你好世界”深度学习系统

从用户的角度来理解软件系统的最快方法。因此,在这个介绍部分,我们首先将看看深度学习系统的用户:人物角色及其责任。然后,我们将深入探讨系统设计、主要组件和用户工作流程。

A.1.1 人物角色

为了将复杂性降到最低,我们的示例迷你深度学习系统只有四个人物角色,或角色:数据工程师、数据科学家/研究员、系统开发人员和深度学习应用程序开发人员。我们选择了这四个角色,因为它们是保持深度学习系统运行所需的最少人员。在这个“你好世界”系统中,每个角色的角色定义和工作描述在以下各节中列出。

注意 这里描述的角色责任是过于简化的,因为我们想集中在深度学习系统的最基本工作流程上。关于深度学习系统中涉及的人物角色和职责的更详细定义,请参阅第 1.1 节。

A.1.2 数据工程师

数据工程师负责收集、处理和存储用于深度学习训练的原始数据。在这个小系统中,我们有一个 DM 服务用于存储用于模型训练的数据集。数据工程师将使用此服务上传原始训练数据。在我们的实验中,我们准备了一些意图分类数据集,供您体验此过程。

A.1.3 数据科学家/研究人员。

数据科学家或研究人员开发具有满足业务要求的模型训练算法和模型。它们是深度学习系统中模型训练基础设施的“客户”。

我们的示例系统包含一个训练服务,用于运行模型训练代码。在实验中,我们为你预先构建了一个意图分类训练代码,以体验模型训练执行。

A.1.4 系统开发人员。

系统开发人员构建整个深度学习系统并维护它,以确保所有的机器学习活动都正常运行。他们的活动包括数据集上传、模型训练和模型服务。

A.1.5 深度学习应用开发人员。

深度学习应用开发人员利用深度学习模型构建商业产品,如聊天机器人、自动驾驶软件和人脸识别移动应用程序。这些应用程序是任何深度学习系统的最重要的客户,因为它们为系统产生的模型创造业务影响(收入)。在我们的实验中,你将有机会想象自己是聊天机器人的客户,通过运行脚本向预测服务发送请求并分类你的消息。

A.1.6 样例系统概述。

我们的小型深度学习系统由四个服务和一个存储系统组成:

  • 数据服务 — 用于存储和获取数据集。

  • 模型训练服务 - 用于运行模型训练代码。

  • 元数据存储服务 — 用于存储模型元数据,如模型名称、模型版本和模型算法。

  • 预测服务 — 设计用于执行模型以处理客户的预测请求。

  • MinIO 存储 — 旨在在您的本地计算机上运行,作为与 Amazon S3 类似的对象存储。

几乎所有这些服务在本书中都有自己的章节,因此我们将能够更详细地研究它们。现在,我们只想提供你需要理解后面的用户场景和实验的高级概述。图 A.1 说明了样例系统(四个服务和存储系统)的主要组成部分及其相互依赖关系。

图 A.1 所示是样例深度学习系统的设计概述。

除了四个服务和存储(用矩形框表示),你会注意到这些框之间有很多有向箭头。这些箭头显示了样例系统内部服务的相互依赖关系。以下是这些依赖关系的解释:

  • DM 服务将数据集保存到 MinIO 存储中。

  • 模型训练服务查询 DM 以准备训练数据集并获取训练数据。

  • 模型训练服务从 MinIO 下载训练数据。

  • 模型训练服务将模型文件保存到 MinIO。

  • 模型训练服务将模型元数据保存到元数据存储服务中。

  • 预测服务查询元数据存储以确定使用哪个模型。

  • 预测服务从 MinIO 下载模型文件以提供预测请求。

A.1.7 用户工作流程

现在,我们已经介绍了人物角色和主要服务,让我们来看看用户工作流程。图 A.2 展示了每个角色的用户工作流程。

图 A.2 中的系统实现了 DM、训练、服务和系统维护四种不同的工作流程。

图 A.2 显示了每个角色使用迷你深度学习系统来执行自己的任务,利用图 A.1 中介绍的服务。让我们回顾一下每个工作流程:

  • 场景 A ——数据工程师调用 DM 服务上传原始数据; DM 将数据摄取并保存到 MinIO 存储中的训练数据集格式中。

  • 场景 B ——数据科学家首先编写训练代码,然后提交训练请求给训练服务。训练服务执行训练代码并生成模型。然后,它将模型元数据保存到元数据存储中,并将模型文件保存到 MinIO 存储中。

  • 场景 C ——应用程序开发人员构建应用程序,调用预测服务来使用在场景 B 中训练的模型。

  • 场景 D ——系统开发人员构建和维护此系统。

A.2 实验演示

现在是你开始学习的时候了。在这个实验练习中,你将在本地计算机上参与到 A.1.3 节中提到的用户场景中。为了使这个练习更生动,我们介绍了一些角色,这样你不仅会知道如何使用深度学习系统,还会了解谁负责处理每个不同的工作。虚构的角色包括伊万(一名数据科学家)、风(一名数据工程师)、唐(一名系统开发人员)和约翰娜(一名应用程序开发人员)。

A.2.1 演示步骤

在这个演示场景中,我们将有唐、风、伊万和约翰娜四个人一起合作,训练一个意图分类模型,并使用该模型对任意文本消息进行分类。这个场景模拟了一个典型模型开发工作流程的四个基本步骤:系统设置、数据集构建、模型训练和模型服务。

为了使实验易于运行,我们将所有微服务进行了 Docker 化,并构建了 shell 脚本来自动化实验设置和演示场景。通过按照我们 GitHub 仓库中 README 文件(github.com/orca3/MiniAutoML#lab)中的说明,运行四个 shell 脚本,你可以完成实验。

注意:你可以在 github.com/orca3/MiniAutoML/tree/main/scripts 的 scripts 文件夹中找到实验演示脚本。该文件夹包含了整本书的演示脚本。以 lab- 开头的文件用于此演示,例如 lab-001-start-all.sh(地址:mng.bz/zmv1)可在本地系统上设置。对于未来的更新和成功执行实验脚本,请始终参考 GitHub 仓库中的说明。

第一步是系统设置。运行 scripts/lab-001-start-all.sh (mng.bz/zmv1)。

唐(系统开发者)通过运行 scripts/lab-001-start-all.sh 脚本启动迷你深度学习系统。该脚本将下载演示服务的预构建 Docker 镜像并执行它们。

当脚本执行完毕后,迷你深度学习系统已经启动并运行。你可以使用以下命令列出所有本地正在运行的 Docker 容器,以验证所有服务都在运行:

$ docker ps --format="table {{.Names}}\t{{.Image}}"

提供了用于运行实验的 Docker 容器,见下式。

附录 A.1 确认所有系统组件都在运行

NAMES                                 IMAGE
training-service                      orca3/services:latest
prediction-service                    orca3/services:latest
intent-classification-torch-predictor pytorch/torchserve:0.5.2-cpu
intent-classification-predictor       orca3/intent-classification-predictor
metadata-store                        orca3/services:latest
data-management                       orca3/services:latest
minio                                 minio/minio

唐确认所有微服务都在运行后,系统已准备好供使用。他通知伊凡和冯开始他们的工作。

注意:如果你阅读过 lab-001-start-all.sh 脚本,你会发现系统中大多数服务(除了预测器)—例如数据管理和模型训练—都被打包到一个 Docker 镜像中(orca3/services)。这不是一个推荐的生产用例模式,但由于使用的磁盘空间较少且执行简单,它适用于我们的演示需求。

第二步是构建训练数据集。运行 scripts/lab-002-upload-data.sh (mng.bz/0yqJ)。

冯(数据工程师)首先从互联网上下载原始数据,并进行一些训练的修改(参见 scripts/prepare_data.py,地址:mng.bz/KlKX)。然后,冯将处理后的数据上传到 DM 服务。数据集上传完成后,DM 服务会返回一个唯一的数据集 ID,以供将来参考。

我们已经将冯的工作自动化在 scripts/lab-002-upload-data.sh 脚本中。执行完此脚本后,将创建一个数据集。你可以在终端中看到 DM 服务打印出一个 JSON 对象。此 JSON 对象表示数据集的元数据,请参考以下示例。

附录 A.2 DM 服务中的样本数据集元数据

# DM returns dataset metadata for a newly created dataset
{
 "dataset_id": "1",                                ❶
  "name": "tweet_emotion",                          ❷
  "dataset_type": "TEXT_INTENT",                    ❸
  "last_updated_at": "2022-03-25T01:32:37.493612Z",
  "commits": [                                      ❹
    {                                               ❹
      "dataset_id": "1",                            ❹
      "commit_id": "1",                             ❹
      "created_at": "2022-03-25T01:32:38.919357Z",  ❹
      "commit_message": "Initial commit",           ❹
      "path": "dataset/1/commit/1",                 ❹
      "statistics": {                               ❹
        "numExamples": "2963",                      ❹
        "numLabels": "3"                            ❹
      }                                             ❹
    }                                               ❹
  ]                                                 ❹
}

❶ 数据集标识符

❷ 数据集名称

❸ 数据集类型

❹ 数据集审核历史记录

数据集元数据在第二章中有详细讨论。现在,我们可以忽略元数据 JSON 对象的大部分属性,只关注dataset_id属性。数据集 ID是数据集的唯一标识符;你需要在第 3 步将此 ID 传递给训练服务以进行模型训练。一旦数据集准备好,Feng 就会通知 Ivan 使用dataset_id="1"开始模型训练。

步骤 3 是模型训练。运行scripts/lab-003-first-training.shmng.bz/vnra)。

Ivan(数据科学家)首先构建意图分类训练代码(training-code/text-classification位于github.com/orca3/MiniAutoML/tree/main/training-code/text-classification)并将其打包成 Docker 镜像(mng.bz/WA5g)。接下来,Ivan 向模型训练服务提交训练请求以创建模型训练作业。在训练请求中,他指定了在训练中使用的数据集(数据集 ID)和训练算法(Docker 镜像名称)。

注意 在本实验中,我们使用了硬编码的数据集 ID "1"。要测试其他数据集,请随意将任何其他数据集 ID 设置为训练请求中。

一旦训练服务接收到训练请求,它将启动一个 Docker 容器来运行 Ivan 提供的意图分类训练代码。在我们的演示中,Docker 镜像是orca3/intent-classificationmng.bz/WA5g)。请运行实验脚本(scripts/lab-003-first-training.sh位于mng.bz/916j)来启动模型训练作业,该脚本设置了依赖项和参数。

列表 A.3 向训练服务提交训练作业

# send gRPC request to kick off a model training in training service.
function start_training() {
 grpcurl -plaintext \
   -d "{
   \"metadata\": {
     \"algorithm\":\"intent-classification\",   ❶
     \"dataset_id\":\"1\",                      ❷
     \"name\":\"test1\",  
     \"train_data_version_hash\":$2,            ❸
     \"output_model_name\":\"twitter-model\",   ❹
     \"parameters\": {                          ❹
       \"LR\":\"4\",                            ❹
       \"EPOCHS\":\"15\",                       ❹
       \"BATCH_SIZE\":\"64\",                   ❹
       \"FC_SIZE\":\"128\"                      ❹
     }                                          ❹
   }
 }" \
   localhost:"6003" training.TrainingService/Train
}

❶ 训练 Docker 镜像名称

❷ 要训练的数据集的 ID

❸ 数据集版本

❹ 训练超参数

一旦训练作业开始,训练服务将持续监视训练执行状态并返回作业 ID 以便跟踪。有了作业 ID,Ivan 可以通过查询训练服务的GetTrainingStatusAPI 和元数据存储服务的GetRunStatusAPI 获取最新的训练作业状态和训练元数据。查看示例查询请求如下。

列表 A.4 查询模型训练作业状态和模型元数据

# query training job status from training service
grpcurl -plaintext \
 -d "{\"job_id\": \"1\"}" \                                        ❶
 localhost:"6003" training.TrainingService/GetTrainingStatus

# query model training metrics from metadata store.
grpcurl -plaintext \
 -d "{\"run_id\": \"1\"}" \                                        ❶
 localhost:"6002" metadata_store.MetadataStoreService/GetRunStatus

❶ 模型 ID,也是训练作业 ID

训练服务可以实时返回训练执行状态;查看示例响应如下:

job 1 is currently in "launch" status, check back in 5 seconds
job 1 is currently in "running" status, check back in 5 seconds
job 1 is currently in "succeed" status            ❶

❶ 训练完成

由于训练 Docker 容器在训练执行期间向元数据存储报告实时指标,如训练准确性,因此元数据存储服务可以返回实时训练指标。查看来自元数据存储服务的示例模型训练指标如下:

{
  "run_info": {
    "start_time": "2022-03-25T14:25:44.395619",
    "end_time": "2022-03-25T14:25:48.261595",
    "success": true,                                 ❶
    "message": "test accuracy 0.520",                ❷
    "run_id": "1",                                   ❸
    "run_name": "training job 1",
    "tracing": {
      "dataset_id": "1",                             ❹
      "version_hash": "hashAg==",                    ❹
      "code_version": "231c0d2"
    },
    "epochs": {                                      ❺
      "0-10": {                                      ❺
        "start_time": "2022-03-25T14:25:46.880859",  ❺
        "end_time": "2022-03-25T14:25:47.054872",    ❺
        "run_id": "1",                               ❺
        "epoch_id": "0-10",                          ❺
        "metrics": {                                 ❺
          "accuracy": "0.4925373134328358"           ❺
        }                                            ❺
      },                                             ❺
      .. .. ..
   }
}

❶ 训练状态

❷ 训练容器的最后一条消息

❸ 训练作业 ID,以及模型 ID

❹ 数据集标识符

❺ 每个时期的训练度量

训练完成后,Ivan 通知 Johanna 模型已经准备好使用。在我们的实验室中,他将模型 ID(作业 ID = "1")传递给 Johanna,以便她知道要使用哪个模型。

请注意,代码清单 A.3 和 A.4 中描述的所有 API 请求都在 scripts/lab-003-first-training.sh 中自动执行;您可以一次性执行它们。在第三章和第四章中,我们会详细讨论训练服务的工作原理。

步骤 4 是模型服务。 运行 scripts/lab-004-model-serving.shmng.bz/815K)。

Johanna(应用程序开发人员)正在构建一个聊天机器人,所以她想要使用新训练的意图分类模型来对客户的问题进行分类。当 Ivan 告诉 Johanna 模型已经准备好使用时,Johanna 向预测服务发送一个预测请求来测试新训练的模型。

在预测请求中,Johanna 指定了模型 ID(runId)和文档,其中文档是正在被分类的文本消息。样本预测服务将自动加载预测请求中请求的模型。您可以在以下清单中看到一个样本 gRPC 预测请求。

清单 A.5 一个样本模型预测 gRPC 请求

grpcurl -plaintext \
  -d "{
    \"runId\": \"1\",                                      ❶
    \"document\": \"You can have a certain arrogance, 
      and I think that's fine, but what you should never
      lose is the respect for the others.\"                ❷
  }" \
  localhost:6001 prediction.PredictionService/Predict

❶ 模型 ID,以及训练作业 ID

❷ 请求体(文本)

在终端中执行查询(代码清单 A.5)或 scripts/lab-004-model-serving.sh 后,您将会看到以下来自模型服务的输出:对于给定文本,意图分类模型预测出的类别(标签)。

{
  "response": "{\"result\": \"joy\"}"   ❶
}

❶ 预测的类别是“快乐”。

如果在完成实验过程中遇到任何问题,请查看我们 GitHub 仓库 README 文件的实验部分(github.com/orca3/MiniAutoML#lab)中的最新说明。如果示例系统被修改,我们会尽量保持这些说明的更新。

A.2.2 一个自己完成的练习

现在我们已经向您展示了一个完成的模型开发周期,现在是作业时间。想象一下,在成功发布聊天机器人之后,Johanna 的聊天机器人服务需要支持一个新类别,乐观。这个新需求意味着当前的意图分类模型需要重新训练以识别乐观类型的文本消息。

Feng 和 Ivan 需要共同合作建立一个新的意图分类模型。Feng 需要收集更多带有“乐观”标签的训练数据并将其添加到当前数据集中。虽然 Ivan 不需要更改训练代码,但他确实需要使用更新的数据集触发训练服务中的训练作业来构建一个新模型。

通过按照第 A.2.1 节中的示例查询和脚本,您应该能够完成 Feng 和 Ivan 的任务。如果您想要检查您的结果或者在完成这些任务时需要帮助,您可以在 scripts/lab-005-second-training.sh 文件中找到我们的解决方案。我们鼓励您在检查我们的解决方案之前尝试或者玩弄这个问题。

附录 B:现有解决方案调查

从零开始实施深度学习系统是一项庞大的工作。在某些情况下,特殊需求可能需要额外的努力来从头开始构建深度学习系统。在其他情况下,鉴于有限的资源和时间,使用现有组件,甚至整个系统,并将其定制为符合自己需求可能是有意义的。

本附录的目的是审查几个由不同云供应商和开源社区实施的深度学习系统。这些操作范围从无服务器部署到自定义服务容器部署。通过将它们与我们的参考架构进行比较,并突出它们的相似之处和不同之处,你将了解到哪些操作可以用来设计自己的项目。

如果你想看到我们将要涵盖的每个解决方案的快速摘要比较,请随意跳转到 B.5 节。另外,为了方便起见,在第 1.2.1 节介绍的参考架构在图 B.1 中重新发布。

图 B.1 典型深度学习系统概述,包括支持深度学习开发周期的基本组件。这个参考架构可以作为一个起点,并进一步定制。

B.1 亚马逊 SageMaker

亚马逊 SageMaker 是其人工智能产品系列的总称,可以一起使用形成完整的深度学习系统。在本节中,我们将回顾产品套件,并查看它们与我们的关键组件的比较。正如本节开头所提到的,我们进行这些比较是为了让你了解哪种产品能最好地帮助构建你自己的系统。

B.1.1 数据集管理

亚马逊 SageMaker 没有提供数据集管理组件,该组件提供统一的接口来帮助管理数据准备与深度学习系统不同类型用户的复杂交互。然而,亚马逊提供了一系列数据存储、转换和查询解决方案,可以用来构建数据管理组件。

可以构建一个数据管理组件,用于收集 Amazon S3 的原始数据,这是一款对象存储产品。元数据标记可以由 AWS Glue 数据目录支持,该目录可以由 AWS Glue ETL 用于进一步处理成可用于训练的数据集。阅读第二章后,你应该能够确定如何使用这些亚马逊产品构建自己的数据管理组件。

B.1.2 模型训练

Amazon SageMaker 支持内置算法和提供外部自定义代码进行深度学习模型训练。它还支持用于训练运行的容器。它公开了一个 API,可以通过调用来启动按需训练作业。这与驱动深度学习系统培训组件的计算后端非常相似,本书也对此进行了介绍。要实现训练组件的资源管理部分,您可以使用 Amazon 提供的现有工具,例如为不同的 AWS 身份和访问管理(IAM)用户或角色分配资源限制和策略。如果您的组织需要额外的控制或复杂性,或者已经有身份提供者实现,您可能需要花费更多的时间构建自定义解决方案。阅读第三章和第四章后,您应该能够弄清楚如何使用现有的 Amazon 工具来构建自己的训练组件。

B.1.3 模型服务

在其最基本的形式下,Amazon SageMaker 能够支持将训练后的模型部署为可以通过互联网访问的 Web 服务。为了扩展多个模型的部署而无需将它们部署到单独的端点,SageMaker 提供了一个多模型端点,同时也配备了可配置的模型缓存行为。如果这些工具符合您的需求,它们会很有用。截至本文撰写时,SageMaker 支持多容器端点和串行推理流水线,这与本书中描述的服务架构和 DAG 支持类似。第六章和第七章回顾了模型服务原则,以便您了解现有工具和在遇到现有工具的限制时如何构建自己的工具。

B.1.4 元数据和工件存储

作为以受过训练的模型为中心的组件,云供应商推出了专门的产品是不足为奇的。SageMaker 模型注册表提供了许多元数据和深度学习系统工件存储的关键概念相对应的功能。例如,可以使用模型注册表跟踪模型的训练度量和模型版本等元数据。然而,它并不提供同一组件中的工件存储解决方案。您可以轻松地在模型注册表和其他 Amazon 存储产品之上构建接口,以提供这个组件的工件存储方面。

在工件之间跟踪的另一种重要元数据类型是它们的谱系信息。SageMaker 提供了 ML 谱系跟踪作为一个独立的功能,它会自动跟踪这些信息。

在第八章中,我们将讨论构建元数据和工件存储的关键问题。阅读本章后,您将了解这个组件背后的设计原则以及现有的产品如何帮助您快速构建自己的组件。

B.1.5 工作流编排

  • 在亚马逊 SageMaker 上,您可以使用 Model Building Pipelines 产品来管理工作流程或管道(这是 SageMaker 的术语)。使用此产品,您可以按照预定义的顺序以任意方式执行一组操作,例如数据准备步骤、训练步骤和模型验证步骤,作为一个单元。为了允许多种类型的用户共同解决同一个问题,SageMaker 还提供了一个 Project 产品,帮助组织工作流程、代码版本、血统信息以及每种用户类型的不同访问权限之间的关系。

  • 在第九章中,我们将介绍如何使用工作流管理器启用不同的训练模式。阅读本章后,您将了解到在深度学习系统中设计和实用工作流管理器的原因,以及其在企业环境中的作用。

- B.1.6 实验

  • 亚马逊 SageMaker 提供了一个名为 Experiments 的功能,它使用相关的跟踪信息和指标标记实验运行。事实上,这种跟踪信息也是一种元数据,对于需要评估不同数据输入、训练算法和超参数组合性能的深度学习系统用户来说,这种元数据是很重要的。

- B.2 Google Vertex AI

  • Google Vertex AI 是 Google AI 平台提供的一项功能与其 AutoML 产品相结合的产品,提供了一系列可用作深度学习系统的工具和服务。在本节中,我们将回顾其提供的功能,并将其与本书介绍的关键组件进行比较。

- B.2.1 数据集管理

  • Google Vertex AI 提供了一个简单的 API 来管理数据集,尽管您必须首先将对象数据上传到 Google Cloud Storage,然后通过 Vertex AI API 上传引用 Google Cloud Storage 中对象数据的元数据和注释文件。数据集 API 在提供给开发人员时提供了统一的体验,尽管不同类型的数据集(图像、文本、视频等)之间的 API 类似。然而,该 API 并未提供版本信息和其他血统跟踪信息。在第二章中,我们探讨了核心数据管理原则。阅读本章后,您将能够比较现有解决方案,并针对自己的需求扩展它们或从头开始构建。

- B.2.2 模型训练

Google Vertex AI 支持使用 Docker 容器进行训练。它为那些不需要进一步定制的用户提供预构建的训练容器,并支持为那些需要比预构建版本提供的内容更多的用户提供自定义构建的训练容器。其训练服务公开了一个接口,允许在单个节点或多个节点上启动训练运行以进行分布式训练。在运行分布式训练时,Vertex AI 提供额外的支持,以加速训练过程。在第三章和第四章中,我们探讨了这些功能及其背后的原理。阅读完这些章节后,您将能够确定可以使用哪些现有产品,如何在需要更多功能时扩展它们,以及如何根据具体需求从头开始构建它们。

B.2.3 模型服务

Google Vertex AI 支持向经过训练的模型提供在线推断请求服务,可以使用预构建推断容器或自定义推断容器。训练过的模型与容器分离,并且必须使用计算资源部署,以形成可以提供在线推断请求服务的端点。Vertex AI 支持将一个模型部署到多个端点,并支持将多个模型部署到单个端点。与支持各种模型类型的其他解决方案不同,在 Vertex AI 中,将多个模型部署到单个端点主要用于使用分流流量模式来执行新模型版本的金丝雀发布。在 Vertex AI 中,如果您训练了一个 Vertex AI 视频模型,则无法使其提供在线推断请求服务。

在第六章和第七章中,我们学习了模型服务背后的基本原理。完成这些章节后,您将对模型服务有很好的理解,并能够决定现有解决方案是否满足您的需求。您将能够构建自己的解决方案,并了解如何高效且规模化地运行模型服务器。

B.2.4 元数据和工件存储

Vertex ML Metadata 是谷歌的元数据存储解决方案,可用于深度学习系统中。它使用图来描述诸如数据集、训练运行和训练模型等工件之间的关系。图中的每个节点和边都可以用一系列键值对标记,以描述任何元数据。当正确使用时,这可以为深度学习系统中的所有内容提供全面的血统信息。

工件不直接存储在 Vertex ML Metadata 中。工件存储在 Google Cloud Storage 中。Vertex ML Metadata 使用 URI 引用指向这些工件。

在第八章中,我们将探讨一种类似的方法,即构建元数据和工件存储,通过单一统一的界面可以管理两者。阅读完本章后,您将能够了解如何利用和扩展现有解决方案以满足您的需求。

B.2.5 工作流编排

使用 Google,您可以使用 Vertex Pipelines 来管理和操作您的深度学习工作流程。您可以将数据准备和训练操作表示为管道中的步骤。在 Vertex Pipelines 中,步骤被组织为有向无环图中的节点。管道的每个步骤由一个容器实现。管道的运行实际上是对容器执行的编排。

在第九章中,我们将介绍如何使用工作流管理器来启用不同的训练模式。阅读该章后,您将了解到在深度学习系统中设计工作流管理器的原因和效用以及它在企业环境中的作用。

实验

Google Vertex AI Experiments 提供了统一的用户界面来创建、跟踪和管理实验。Vertex AI SDK 提供了用于模型训练代码的自动记录支持,以记录超参数、指标和数据衍生关系。与 Vertex ML Metadata 结合使用时,您可以获得所有模型训练实验运行的完整概述。

微软 Azure 机器学习

不同于微软经典的 ML Studio 方案,该方案专注于机器学习的图形用户界面方法,Azure 机器学习是一套新的工具和服务,还支持使用代码和已建立的开源框架进行广泛定制。在本节中,我们将比较它们的功能与本书中描述的关键组件。完成本节后,您将对可直接使用的内容、可扩展的内容以及需要从头开始构建以满足您需求的内容有所了解。

数据集管理

在 Azure 机器学习中,数据集是第一类对象,是数据处理和训练任务的输入和输出。数据集被定义为与数据集的原始数据相关联的元数据集合。数据集通过 URI 引用指向底层数据存储的原始数据。一旦数据集被创建,它就变成了不可变的。然而,底层数据并没有同样的保证,您需要自己管理其不可变性。

一旦数据集被定义,数据处理和训练代码可以通过统一的客户端 API 访问它们。数据可以被下载以供本地访问,也可以被挂载为网络存储以供直接访问。阅读第二章后,您将能够识别出这种范例与本书中描述的范例之间的相似之处。您将学习如何直接使用这个现有产品以及如何根据自己的需求进行扩展。

模型训练

Azure Machine Learning 提供预构建的带有 Python 分发的容器,并允许用户定义符合特定要求的自定义基础镜像。在我们撰写本文时,仅支持使用 Python 定义自定义的训练代码。要启动一个训练过程,您需要指定运行时容器和符合特定约定的训练代码的引用。如果您需要其他设置,则需要构建自己的训练服务。第 3 和第四章将向您展示训练服务的关键原则和一个示例,作为自己训练服务的起点。

B.3.3 模型服务

在 Azure Machine Learning v2 上,可以创建端点来提供在线推断请求。端点可以配置为加载某个模型并使用 Python 脚本产生推断结果,或者配置为使用完全自定义的容器镜像(如 TensorFlow Serving)产生推断结果。Azure Machine Learning 还与 NVIDIA Triton Inference Server 集成,当 GPU 用于产生推断结果时,可以提供额外的性能提升。

如果您需要将多个模型部署到一个端点或在边缘设备上管理模型和推断产量,则需要构建自己的服务。在第 6 和第七章中,我们深入讨论了模型服务。完成这些章节后,您将能够构建自己的模型服务器,以便支持现有提供的功能无法支持的其他功能。

B.3.4 元数据和工件存储

在 Azure Machine Learning 中,元数据可以以标签的形式附加到许多对象上,例如模型、训练运行等等。虽然不是一个独立的产品,但模型注册功能支持在注册模型时添加额外的元数据。在注册期间,接口同时接收元数据和模型文件(工件),相比其他需要先将模型注册到其云存储中的解决方案,需要少一步操作。

我们撰写本文时,一个称为注册表的预览功能可用于将与 ML 相关的元数据集中存储到一个位置。但是,如果您想要跟踪不同工件之间的传承关系,可能需要构建自己的解决方案。

阅读第八章后,您将深入了解元数据和工件存储。您将学习其基础知识,并能够快速构建自己的存储。

B.3.5 工作流编排

Azure Machine Learning 提供了一个称为 ML pipelines 的功能,允许您将数据、训练和其他任务定义为步骤。这些步骤可以通过编程方式组合成管道,并可以根据时间表或触发器定期执行,或仅可手动启动。管道定义时,可以编程配置计算资源、执行环境和访问权限。

在第九章中,我们将回顾如何使用工作流管理器启用不同的训练模式。阅读完本章后,你将理解深度学习系统中工作流管理器的设计理念和实用性,以及它在企业环境中的作用。

B.3.6 实验

Azure 机器学习提供了一个用于定义和跟踪实验的功能。在实验的一部分进行模型训练时,可以从训练代码中记录指标,并通过 Web 界面可视化。它还支持任意标记和实验运行之间的父子关系,以进行层次化组织和查找。

B.4 Kubeflow

Kubeflow 是一个开源工具套件,为构建深度学习系统提供了许多有用的组件,而不会被锁定到特定的云供应商。在本节中,我们列出了本书介绍的关键组件,并将它们与 Kubeflow 提供的类似组件进行了比较。

B.4.1 数据集管理

Kubeflow 的愿景是不重复造轮子,因此不会有数据管理组件也不足为奇,因为其他开源解决方案已经存在。在第二章中,我们审查了一些开源数据管理解决方案,并探讨了它们如何进一步扩展以实现该章节描述的关键原则。

B.4.2 模型训练

Kubeflow 是一个基于 Kubernetes 的工具套件,具有复杂的资源调度器支持。不像云供应商提供预构建的模型训练容器,你必须构建自己的容器并管理它们的启动。在第三章和第四章中,我们讨论了训练服务的原则以及它如何帮助抽象出资源分配和调度中的复杂性。我们将介绍一个参考训练服务,你将学会如何根据自己的需求构建一个。

B.4.3 模型服务

截至目前,Kubeflow 提供了一个名为 KServe 的组件,可用于将训练好的模型部署为推断服务,通过网络提供推断请求。它是一个接口,位于现有的服务框架之上,如 TensorFlow Serving、PyTorch TorchServe 和 NVIDIA Triton 推断服务器。使用 KServe 的主要好处在于额外抽象了操作复杂性,如自动缩放、健康检查和自动恢复。由于它是一个开源解决方案,可以在同一个端点上托管一个或多个模型。在第六章和第七章中,我们将介绍模型服务原理,以便你理解设计流行服务接口的原因以及如何自定义它们以适应自己的需求。

B.4.4 元数据和工件存储

从 Kubeflow 版本 1.3 开始,元数据和工件成为 Kubeflow Pipelines 的一个组成部分。Kubeflow Pipelines 由一组管道组件图组成。在每个组件之间,参数和工件可以传递。类似于本书中的描述,工件封装了深度学习系统的任何类型的数据,例如模型本身、训练指标和数据分布指标等。元数据是描述管道组件和工件的任何数据。有了这些构造,你可以推断出输入训练数据集、训练模型、实验结果和服务推理之间的衍生关系。

在第八章中,我们讨论了构建元数据和工件存储的关键问题。阅读完本章后,你将理解该组件背后的设计原则以及现有产品如何帮助你快速构建自己的存储。

B.4.5 工作流编排

此前的章节中也有描述,Kubeflow Pipelines 可以用于帮助管理深度学习数据准备和训练工作流程。元数据和版本控制已集成到管道中,并且可以使用 Kubernetes 的本机用户和访问权限来限制访问。

在第九章中,我们将回顾工作流程管理器如何实现不同的训练模式。阅读完本章后,你将理解深度学习系统中工作流程管理器设计和实用性背后的原理。

B.4.6 实验

Kubeflow Pipelines 提供了实验结构,其中多个训练运行可以组织成一个逻辑组,在其中为每个实验运行提供了额外的可视化工具以显示差异。这非常适用于离线实验。如果需要进行在线实验,则需要自行解决方案。

B.5 旁边对比

我们认为,提供一个按照我们先前涵盖的组件分组的所有解决方案的摘要概述表格将会很方便。我们希望表格 B.1 能够让你更轻松地选择合适的解决方案。

表格 B.1 旁边对比

亚马逊 SageMaker 谷歌 Vertex AI 微软 Azure 机器学习 Kubeflow
比较数据集管理解决方案 AWS 组件,如 S3、Glue 数据目录和 Glue ETL,可用于构建数据集管理组件。 用于管理数据集的 API 已经准备就绪。数据内容上传和元数据标记是分开的操作。 数据集是一级对象,一旦创建就不可变。提供了统一的客户端 API 用于训练作业访问训练数据集。 不提供数据集管理解决方案。其他开源替代方案随时可用。
比较模型训练解决方案 支持内置算法、外部提供的自定义代码和用于训练的自定义容器。提供一个 API 用于按需启动训练作业。 提供可以直接使用的预构建训练容器。支持自定义训练容器。提供支持在多个节点上启动训练容器的 API。 提供具有 Python 的预构建训练容器,可进行定制。训练容器必须符合某种约定。 具有对 Kubernetes 调度能力的本地访问。不提供预构建的训练容器。
比较模型服务解决方案 模型可以部署为 Web 端点。可以将多个模型部署到同一端点以实现更好的利用,但使用 GPU 时存在一些限制。可配置的模型缓存行为。 模型和推断容器是分离的。它们必须一起部署以形成用于服务的 Web 端点。支持自定义推断容器。主要用于金丝雀测试新版本模型的单个端点上使用多个模型。不支持视频模型。 可以部署端点以通过 Web 提供模型服务。端点配置为使用特定模型,使用自定义 Python 脚本生成推断。支持 NVIDIA Triton 推理服务器集成。 KServe 是 Kubeflow 的组件,用于提供模型服务。它在流行的服务框架(如 TensorFlow Serving、PyTorch TorchServe 和 NVIDIA Triton 推理服务器)之上提供了一个无服务器推断抽象。
比较元数据和工件存储解决方案 SageMaker 模型注册表提供了一个中心化的元数据存储解决方案。工件单独存储在亚马逊的对象存储中。 Vertex ML 元数据提供了一个中心化的元数据存储解决方案。元数据以能描述复杂关系的图形形式存储。工件存储在谷歌的对象存储中。 一个称为注册表的预览功能可用于集中管理 ML 元数据。元数据存在于不同对象(训练运行、模型等)的标签中,并且对象可以是工件。可以使用这些对象标签推断出血统信息。 没有中心化的元数据或工件存储库。元数据和工件是 Kubeflow Pipelines 的组成部分。管道中的每个阶段都可以用元数据注释,并且可以跟踪生成的工件。可以从 Pipelines API 检索到的信息中推断出血统信息。
比较工作流编排解决方案 模型构建流水线可用于构建和管理深度学习工作流。 Vertex ML Metadata 提供了一个集中的元数据存储解决方案。元数据存储为可以描述复杂关系的图形。工件存储在谷歌的对象存储中。 一个名为注册表的预览功能可用于集中化 ML 元数据。元数据存在于不同对象的标签中(训练运行、模型等),对象可以是工件。可以使用这些对象标签推断出谱系信息。 没有元数据或工件的中央存储库。元数据和工件是 Kubeflow 流水线的组成部分。管道中的每个阶段都可以用元数据注释,并产生可以被跟踪的工件。可以从可以从流水线 API 中检索的详细信息中推断出谱系信息。
比较实验解决方案 实验功能提供了对训练运行的分组和跟踪。 为 Vertex AI 实验提供跟踪和可视化实验设置和运行结果的功能。 提供了定义和跟踪实验的功能。实验可以关联为父子关系。Web 接口支持可视化。 提供了一个实验构造,用于逻辑分组 Kubeflow 流水线,这些流水线属于同一个实验组。提供了可视化工具,用于突出显示同一实验中每个流水线运行之间的差异。

附录 C:创建使用 Kubeflow Katib 的 HPO 服务

我们将向您介绍一个开源的超参数优化(HPO)服务——Kubeflow Katib,它满足了我们在第五章讨论的几乎所有 HPO 要求。我们强烈建议您在构建自己的 HPO 服务之前考虑采用 Katib。除了向您展示如何使用 Katib 外,我们还将介绍其系统设计和代码库,以使您对这个开源服务感到舒适。

作为 Kubeflow 家族的一员,Katib 是一个云原生、可扩展且可投入生产的超参数优化系统。此外,Katib 不关心机器学习框架或编程语言。此外,Katib 是用 Go 编写的,采用 Kubernetes 本地方法,可以在 Kubernetes 集群中独立运行。除了支持具有早期停止支持的超参数优化外,Katib 还支持神经架构搜索(NAS)。

Katib 有许多优点,包括其支持多租户和分布式训练的能力、其云原生性以及其可扩展性,所有这些都使其与其他系统有所区别。无论您是在云中还是在本地服务器上使用 Kubernetes 管理服务器集群,Katib 都是最佳选择。在本章中,我们将以以下五个步骤介绍 Katib:Katib 概述、如何使用 Katib、Katib 系统设计和代码阅读、加速 HPO 执行以及向 Katib 添加自定义 HPO 算法。

C.1 Katib 概览

Katib 以黑盒方式管理 HPO 实验和计算资源,因此 Katib 用户只需要提供训练代码并定义 HPO 执行计划,然后 Katib 就会处理其余事务。图 C.1 显示了 Katib 的系统概述。

图 C.1 Katib 系统概述。Katib 组件作为 Kubernetes 本地服务运行,并且 Katib 支持三种类型的用户界面:UI、API 和 SDK。

在图 C.1 中,我们看到 Katib 为用户的方便性提供了三种类型的用户界面:一个 Web UI,一个 Python SDK,以及一组 API。用户可以通过网页、Jupyter 笔记本、Kubernetes 命令和 HTTP 请求来运行 HPO。

从用户的角度来看,Katib 是一个远程系统。要运行 HPO,用户需要向 Katib 提交一个实验请求,然后 Katib 为他们执行 HPO 实验。要构建实验请求,用户需要做两件事:首先,将训练代码制作成 Docker 镜像,并将要优化的超参数暴露为外部变量;其次,创建一个实验对象,定义 HPO 实验的规范,如 HPO 算法、试验预算或超参数及其值搜索空间。一旦实验对象在 Katib 中创建完成,Katib 将分配计算资源以启动 HPO 执行。

Katib 在 Kubernetes 集群内部运行。Katib 服务本身并不消耗大量内存或磁盘空间;它启动 Kubernetes pod 来运行模型训练作业(HPO 试验)以测试不同的超参数建议。Katib 可以在不同的命名空间为不同的用户运行训练作业,以创建资源隔离。

C.2 使用 Katib 入门

在本节中,我们将看一下如何操作 Katib。首先,我们在本地安装 Katib,然后解释术语,最后,我们向您展示一个 Katib 的端到端使用案例。

为什么在设计书中谈论 Katib 操作和安装呢?

理想情况下,我们不希望在设计书中包含软件的安装和用户指南,因为这些信息在书出版后可能会过时,并且我们可以在官方网站上找到实时的文档。以下是我们违反规则的两个原因。

首先,因为我们建议您使用 Katib 而不是构建您自己的服务,我们有责任向您展示完整的用户体验,既从 Katib 用户(数据科学家)的角度,又从 Katib 运营者(工程师)的角度。其次,为了理解 Katib 的设计并学习如何阅读其代码库,最好先解释其术语和典型用户工作流程。一旦你理解了 Katib 的工作原理,阅读其代码就会更容易。

C.2.1 第一步:安装

如果您安装了 Kubeflow 系统 (mng.bz/WAp4),那么 Katib 已包含在内。但如果您只对 HPO 感兴趣,您可以单独安装 Katib。Katib 正在积极发展和得到良好维护,所以请查看其官方安装文档 “Getting Started with Katib: Installing Katib” (mng.bz/81YZ),获取最新的安装提示。

C.2.2 第二步:理解 Katib 术语

对于 Katib 用户来说,实验、建议和试验是需要熟悉的三个最重要的实体/概念。定义如下。

实验

一个实验是单一的优化运行;它是一个端到端的 HPO 过程。一个实验配置包含以下主要组件:用于训练代码的 Docker 镜像,一个我们要优化的客观指标(也称为目标值),需要调整的超参数,以及一个值搜索空间和 HPO 算法。

建议

一个建议是一个由 HPO 算法提出的一组超参数值。Katib 创建一个试验作业来评估建议的值集。

试验

试验是实验的一次迭代。一次试验会接受一个建议,执行一个训练过程(一个试验作业)以产生一个模型,并评估模型的性能。

每个实验都运行一个试验循环。实验会持续调度新的试验,直到达到客观目标或者配置的最大试验数。您可以在 Katib 官方文档 “Introduction to Katib” (mng.bz/ElBo) 中看到更多的 Katib 概念解释。

C.2.3 第三步:将训练代码打包为 Docker 镜像

与超参数优化库的方法相比(第 5.4 节),最大的区别在于优化服务方法要求我们将模型训练代码打包成一个 Docker 镜像。这是因为优化服务需要在远程集群中运行优化训练实验,而 Docker 镜像是在远程运行模型训练代码的理想方法。

在准备 Docker 镜像时,有两个需要注意的地方:将超参数定义为训练代码的命令行参数,以及将训练指标报告给 Katib。让我们来看一个例子。

首先,我们在训练代码中将需要优化的超参数定义为命令行参数。因为 Katib 需要为不同的超参数值执行训练代码作为一个 docker 容器,所以训练代码需要从命令行参数中获取超参数值。在下面的代码示例中,我们定义了两个要调整的超参数:lr(学习率)和批量大小。在优化过程中,Katib 将在训练容器启动时传入这些值;请参见以下代码:

def main():
  parser = argparse.ArgumentParser( \
    description="PyTorch MNIST Example")
  parser.add_argument("--batch-size", \              ❶
    type=int, default=64, metavar="N", \
    help="input batch size for training (default: 64)")
  parser.add_argument("--lr", \
    type=float, default=0.01, metavar="LR", \        ❶
    help="learning rate (default: 0.01)") 

❶ 从命令行参数解析超参数值

其次,我们让训练代码报告训练指标,尤其是目标指标,给 Katib,这样它就可以跟踪每个试验执行的进度和结果。Katib 可以从以下三个位置收集指标:stdout(操作系统的标准输出位置),任意文件和 TensorFlow 事件。如果你有特殊的指标收集或存储要求,也可以编写自己的指标收集容器。

最简单的选项是将评估(目标)指标打印到训练代码的标准输出(stdout)中,并使用 Katib 的标准指标收集器收集它们。例如,如果我们将目标指标定义为Validation-accuracy,并希望优化过程找到最小化此值的最优超参数,我们可以将以下日志写入到 stdout 中。Katib 的标准指标收集器将在 stdout 中检测到Validation-accuracy=0.924463,并将解析该值。如下所示是标准输出样本:

2022-01-23T05:19:53Z INFO  Epoch[5] Train-accuracy=0.932769
2022-01-23T05:19:53Z INFO  Epoch[5] Time cost=31.698
2022-01-23T05:19:54Z INFO  Epoch[5] Validation-accuracy=0.924463
2022-01-23T05:19:58Z INFO  Epoch[6] Batch [0-100] Speed: 1749.16 .. 

Katib 默认使用的正则表达式格式来解析日志中的目标指标是([\w|-]+)\s*=\s*([+-]?\d*(\.\d+)?([Ee][+-]?\d+)?)。您可以在实验配置文件的.source.filter.metricsFormat中定义自己的正则表达式格式。更多详细信息,请参阅 Katib 文档“运行实验”的指标收集器部分(mng.bz/NmvN)。

为了帮助你入门,Katib 提供了一系列示例训练代码和示例 Docker 镜像文件,以展示如何打包你的训练代码。这些示例是为不同的训练框架编写的,如 TensorFlow、PyTorch、MXNet 等。你可以在 Katib 的 GitHub 仓库中找到这些示例(mng.bz/DZln)。

C.2.4 第四步:配置一个实验

现在你已经准备好了训练代码,我们可以开始在 Katib 中准备一个 HPO 实验。我们只需要在 Katib 中创建一个实验 CRD(customer resource definition)对象。

通过使用 Kubernetes API 或kubectl命令,我们可以通过指定一个 YAML 配置来创建实验 CRD。以下是一个示例配置。为了便于阅读,我们将示例配置分成了三个部分。我们逐个部分讨论一下。

第一部分:目标

第一部分是定义 HPO 实验的目标,并确定如何衡量每个试验(训练执行)的性能。Katib 使用objectiveMetricadditionalMetric的值作为目标值,以监控建议的超参数与模型的配合情况。如果一个试验的目标值达到了目标,Katib 将将建议的超参数标记为最佳值,并停止进一步的试验。

对于以下配置,目标指标被设置为Validation-accuracy,目标值为0.99

apiVersion: kubeflow.org/v1beta1
kind: Experiment
metadata:
 namespace: kubeflow
 name: bayesian-optimization
spec:
 Objective:
   type: maximize
   goal: 0.99
   objectiveMetricName: Validation-accuracy    ❶
   additionalMetricNames:
     - Train-accuracy

❶ 定义了目标指标

第二部分:HPO 算法和超参数

设置完 HPO 的目标后,我们可以配置 HPO 算法并声明它们的搜索空间以及需要调整的超参数。我们分别来看这些配置。

algorithm config指定了我们希望 Katib 在实验中使用的 HPO 算法。在当前的示例中,我们选择了贝叶斯优化算法(mng.bz/lJw6)。Katib 支持许多最新的 HPO 算法,你可以在 Katib 官方文档的“Running an Experiment”一节的 Search Algorithm in Detail 中看到它们(mng.bz/BlV0)。你还可以将自己的 HPO 算法添加到 Katib 中,我们将在 C.5 节中讨论。

ParallelTrialCountmaxTrialCountmaxFailedTrialCount:根据它们的名称,可以自解释地定义试验如何安排实验。在这个示例中,我们并行运行了三个试验,共进行了 12 个试验。如果有三个试验失败,实验将停止。

parameters config定义了要调整的超参数及其值的搜索空间。Katib 根据你指定的超参数调整算法在搜索空间中选择超参数的值。请参考以下代码:

algorithm:
  algorithmName: bayesianoptimization    ❶
  algorithmSettings:
    - name: "random_state"
      value: "10"
parallelTrialCount: 3
maxTrialCount: 12
maxFailedTrialCount: 3
Parameters:                              ❷
   - name: lr
     parameterType: double
     feasibleSpace:
       min: "0.01"
       max: "0.03"
   - name: num-layers
     parameterType: int
     feasibleSpace:
       min: "2"
       max: "5"
   - name: optimizer
     parameterType: categorical
     feasibleSpace:
       list:
         - sgd
         - adam
         - ftrl

❶ 使用了 Katib 提供的贝叶斯优化算法

❷ 定义了要优化的超参数及其值的搜索空间

最后一节:试验配置

在这个trial template config中,我们定义了要执行的训练代码(Docker 镜像)和要传递给训练代码的超参数。Katib 为几乎每个模型训练框架(如 TensorFlow、PyTorch、MXNet 等)都内置了作业,它负责在 Kubernetes 中执行实际的训练。

例如,如果我们想要在 PyTorch 训练代码的 HPO 试验中运行分布式训练,需要设置一个分布式组,我们可以将试验定义为 PyTorch 作业类型。Katib 将为您运行分布式训练。

在以下示例中,我们将试验定义为默认的作业类型 Kubernetes Job。在实验过程中,Katib 将以 Kubernetes Pod 的形式运行试验作业,无需对训练代码进行任何特殊的自定义配置;请参阅以下代码:

 trialTemplate:
   primaryContainerName: training-container
   trialParameters:                          ❶
     - name: learningRate
       description: Learning rate for the training model
       reference: lr
     - name: numberLayers
       description: Number of training model layers
       reference: num-layers
     - name: optimizer
       description: Training model optimizer (sdg, adam or ftrl)
       reference: optimizer
   trialSpec:                                ❷
     apiVersion: batch/v1
     kind: Job
     spec:
       template:
         spec:
           containers:
             - name: training-container
               image: docker.io/kubeflowkatib/mxnet-mnist:latest
               command:                      ❸
                 - "python3"
                 - "/opt/mxnet-mnist/mnist.py"
                 - "--batch-size=64"
                 - "--lr=${trialParameters.learningRate}"
                 - "--num-layers=${trialParameters.numberLayers}"
                 - "--optimizer=${trialParameters.optimizer}"
           restartPolicy: Never

❶ 为训练代码声明超参数

❷ 配置训练容器

❸ 配置如何执行训练代码

Katib 为其支持的每个 HPO 算法提供了示例实验配置文件;您可以在 Katib GitHub 仓库中找到它们:katib/examples/v1beta1/hp-tuning/ (mng.bz/dJVN)

C.2.5 步骤 5:开始实验

一旦我们定义了实验配置并将其保存在 YAML 文件中,我们可以运行以下命令来启动实验:

% kubectl apply -f bayesian-optimization.yaml
experiment.kubeflow.org/bayesian-optimization created

% kubectl get experiment -n kubeflow
NAME                    TYPE      STATUS   AGE
bayesian-optimization   Created   True     46s

kubectl get experiment -n kubeflow 的返回消息中,我们看到实验 bayesian-optimization 被创建为 Experiment CRD 资源。从现在开始,Katib 将完全拥有 HPO 实验,直到获得结果。

请注意,Katib 完全依赖于 Kubernetes CRD 对象来管理 HPO 实验和试验。它还使用 CRD 对象来存储其 HPO 活动的指标和状态,因此我们说 Katib 是一个 Kubernetes 原生应用程序。

除了上述 kubectl 命令之外,我们还可以使用 Katib SDK、其 Web UI 或发送 HTTP 请求来启动实验。

C.2.6 步骤 6:查询进度和结果

您可以使用以下命令来检查实验的运行状态:

% kubectl describe experiment bayesian-optimization -n kubeflow

kubectl describe 命令将返回有关实验的所有信息,例如其配置、元数据和状态。从进度跟踪的角度来看,我们主要关注状态部分。请参阅以下示例:

Status:
  Completion Time:  2022-01-23T05:27:19Z
  Conditions:                              ❶
    .. .. .. 
    Message:               Experiment is created
    Type:                  Created
    .. .. ..
    Message:               Experiment is running
    Reason:                ExperimentRunning
    Status:                False
    Type:                  Running
    .. .. ..
    Message:               Experiment has succeeded because max trial count has reached
    Reason:                ExperimentMaxTrialsReached
    Status:                True
    Type:                  Succeeded
  Current Optimal Trial:                   ❷
    Best Trial Name:  bayesian-optimization-9h25bvq9
    Observation:
      Metrics:                             ❸
        Latest:  0.979001
        Max:     0.979001
        Min:     0.955713
        Name:    Validation-accuracy
        Latest:  0.992621
        Max:     0.992621
        Min:     0.906333
        Name:    Train-accuracy
    Parameter Assignments:                 ❹
      Name:    lr
      Value:   0.014183662191100063
      Name:    num-layers
      Value:   3
      Name:    optimizer
      Value:   sgd
  Start Time:  2022-01-23T05:13:00Z
  Succeeded Trial List:                    ❺
    .. .. ..
    bayesian-optimization-5s59vfwc
    bayesian-optimization-r8lnnv48
    bayesian-optimization-9h25bvq9
    .. .. ..
  Trials:            12
  Trials Succeeded:  12 

❶ 实验历史

❷ 当前最佳试验的元数据

❸ 当前最佳试验的目标度量

❹ 当前最佳试验中使用的超参数值

❺ 已完成试验列表

以下是对前面示例响应的几点解释:

  • 状态/条件—显示当前和以前的状态。在前面的示例中,我们看到实验经历了三个状态:创建、运行和成功。从消息中,我们知道实验已完成,因为它用完了训练预算——最大试验计数。

  • 当前最佳试验—显示当前的“最佳”试验以及试验使用的超参数值。它还显示目标度量的统计信息。随着实验的进行,这些值将不断更新,直到实验中的所有试验都完成,然后我们将 status.currentOptimalTrial.parameterAssignment(超参数值分配)作为最终结果。

  • 成功的试验列表/失败的试验列表/试验—通过列出实验执行的所有试验来显示实验的进展情况。

C.2.7 步骤 7:故障排除

如果有失败的试验,我们可以运行以下命令来检查失败的试验作业的错误消息。参见以下失败的 HPO 示例:

-> % k describe trial bayesian-optimization-mnist-pytorch-88c9rdjx \
     -n kubeflow
Name:         bayesian-optimization-mnist-pytorch-88c9rdjx
.. .. ..
Kind:         Trial
Spec:
  .. .. ..
  Parameter Assignments:
    Name:                  lr
    Value:                 0.010547476197421666
    Name:                  num-layers
    Value:                 3
    Name:                  optimizer
    Value:                 ftrl

Status:
  Completion Time:  2022-01-23T06:23:50Z
  Conditions:
    .. .. ..
    Message:  Trial has failed. Job message: Job has reached the specified backoff limit
    Reason:   TrialFailed. Job reason: BackoffLimitExceeded     ❶
   .. .. ..

❶ 失败消息

从返回的数据中,我们可以看到试验中使用的超参数值以及相关的错误消息。

除了 describe 命令的错误消息外,我们还可以通过检查训练容器的日志来找到根本原因。如果选择使用 Katib 标准度量收集器,Katib 将在同一 pod 中的训练代码容器中运行 metrics-logger-and-collector 容器。该度量收集器捕获来自训练容器的所有 stdout 日志;您可以使用以下命令检查这些日志:kubectl logs ${trial_pod} -c metrics-logger-and-collector -n kubeflow。参见以下示例命令:

% kubectl logs bayesian-optimization-mkqgq6nm--1-qnbtt -c \
 metrics-logger-and-collector -n kubeflow

logs 命令输出大量有价值的信息,例如训练过程的初始参数,数据集下载结果和模型训练指标。在以下示例日志输出中,我们可以看到 Validation-accuracyTrain-accuracy。Katib 度量收集器将解析这些值,因为它们在实验配置中被定义为目标度量:

Trial Name: bayesian-optimization-mkqgq6nm                                                                ❶
2022-01-23T05:17:17Z INFO  start with arguments Namespace(                                                ❷
add_stn=False, batch_size=64, disp_batches=100,
 dtype='float32', gc_threshold=0.5, gc_type='none', gpus=None, 
image_shape='1, 28, 28', ... warmup_epochs=5,
warmup_strategy='linear', wd=0.0001)
I0123 05:17:20.159784    16 main.go:136] 2022-01-23T05:17:20Z INFO
downloaded http:/ /data.mxnet.io/data/mnist/t10k-labels-idx1-ubyte.gz
➥ into t10k-labels-idx1-ubyte.gz successfully                                                            ❸
.. .. ..
I0123 05:17:26.711552   16 main.go:136] 2022-01-23T05:17:26Z INFO   Epoch[0] Train-accuracy=0.904084      ❹
.. .. ..
I0123 05:17:26.995733   16 main.go:136] 2022-01-23T05:17:26Z INFO   Epoch[0] Validation-accuracy=0.920482 ❺
I0123 05:17:27.576586   16 main.go:136] 2022-01-23T05:17:27Z INFO   Epoch[1] Batch [0-100]  Speed: 20932.09 samples/sec  accuracy=0.926825
I0123 05:17:27.871579   16 main.go:136] 2022-01-23T05:17:27Z INFO   Epoch[1] Batch [100-200]  Speed: 21657.24 samples/sec  accuracy=0.930937

❶ 试验名称

❷ 训练试验的初始参数

❸ 数据集下载

❹ 附加度量值

❺ 目标度量值

C.3 加速 HPO

HPO 是一项耗时且昂贵的操作。Katib 提供了三种方法来加速该过程:并行试验,分布式训练和提前停止。

C.3.1 并行试验

通过在实验配置中指定 parallelTrialCount,您可以并行运行试验。我们应该注意的一件事是,一些 HPO 算法不支持并行试验执行。因为这种类型的算法对试验执行顺序有线性要求,下一个试验需要等到当前试验完成。

C.3.2 分布式试验(训练)作业

为了更快完成试验作业,Katib 允许我们为运行训练代码启用分布式训练。正如我们在 C.2(步骤 4)中所解释的那样,Katib 为不同的训练框架(如 PyTorch、TensorFlow 和 MXNet)在 trialTemplate 中定义了不同的作业类型。

以下是如何为 Katib 实验中的 PyTorch 训练代码启用分布式训练(一个主节点,两个工作节点)的示例:

trialTemplate:
  primaryContainerName: pytorch
  trialParameters:                       ❶
    - name: learningRate
      description: Learning rate for the training model
      reference: lr
   - name: momentum
      description: Momentum for the training model
      reference: momentum
   trialSpec:
      apiVersion: kubeflow.org/v1
      kind: PyTorchJob                   ❷
      spec:
        pytorchReplicaSpecs:
          Master:                        ❸
            replicas: 1                  ❸
            restartPolicy: OnFailure
            template:
              spec:
                containers:
                  - name: pytorch
                    image: docker.io/kubeflowkatib/pytorch-mnist:latest
                    command:
                      - "python3"
                      - "/opt/pytorch-mnist/mnist.py"
                      - "--epochs=1"
                      - "--lr=${trialParameters.learningRate}"
                      - "--momentum=${trialParameters.momentum}"
 Worker:
            replicas: 2                  ❹
            restartPolicy: OnFailure
            template:
              spec:
                containers:
                  - name: pytorch
                    image: docker.io/kubeflowkatib/pytorch-mnist:latest
                    command:
                      - "python3"
                      - "/opt/pytorch-mnist/mnist.py"
                      - "--epochs=1"
                      - "--lr=${trialParameters.learningRate}"
                      - "--momentum=${trialParameters.momentum}"

❶ 将学习率和动量声明为超参数

❷ 将试验作业类型设置为 PyTorch

❸ 配置主训练器

❹ 配置工作训练器

在前面的示例中,与第 C.2 节中的非分布式实验配置相比(步骤 4),唯一的区别是 trialSpec 部分。作业类型现在变为 PyTorchJob,并且具有主训练器和工作训练器的副本数等单独的设置。您可以在以下两个 GitHub 仓库中找到 Katib 训练操作符及其配置示例的详细信息:Kubeflow 训练操作符(github.com/kubeflow/training-operator)和 Katib 操作符配置示例(mng.bz/rdgB)。

C.3.3 早期停止

Katib 提供的另一个有用技巧是早期停止。当试验的目标指标不再改善时,早期停止结束试验。它通过终止不太有希望的试验来节省计算资源并减少执行时间。

在 Katib 中使用早期停止的优点是我们只需要更新实验配置文件,而不需要修改训练代码。只需在 .spec.algorithm 部分中定义 .earlyStopping.algorithmName.earlyStopping.algorithmSettings,您就可以开始使用了。

Katib 当前支持的早期停止算法是中位数停止规则,如果试验的最佳目标值比所有其他已完成试验的目标报告到相同步骤的运行平均值的中位数值更差,则停止试验。请在 Katib 官方文档“使用早期停止”中阅读更多详细信息。

C.4 Katib 系统设计

最后,我们可以谈谈我们最喜欢的话题——系统设计。通过阅读第 C.2 节和第 C.3 节,您应该对 Katib 如何从用户角度解决 HPO 问题有一个清晰的了解。这为理解 Katib 的系统设计打下了良好的基础。

正如我们所见,Katib 不仅解决了 HPO 问题,而且还以生产质量来解决它。通常,这样一个功能强大的系统具有庞大而复杂的代码库,但是 Katib 是一个例外。因为核心 Katib 组件都是按照一个简单的设计模式——Kubernetes 控制器/操作器模式来实现的,如果您理解一个组件,您几乎就理解了整个系统。通过在本节中阅读我们的介绍,阅读 Katib 源代码对您来说将会很简单。

C.4.1 Kubernetes 控制器/操作器模式

我们已经在第 3.4.2 节中讨论了控制器设计模式。但是,为了帮助您记住,我们在此将图 3.10 重新发布为图 C.2。如果图 C.2 看起来不太熟悉,请回顾第 3.4.2 节。

图 C.2 Kubernetes 控制器/操作器模式运行一个无限控制循环,监视特定 Kubernetes 资源的实际状态(右侧)和期望状态(左侧),并尝试将其实际状态移动到期望状态。

C.4.2 Katib 系统设计和工作流程

图 C.2 说明了 Katib 的内部组件及其交互方式。系统有三个核心组件:实验控制器(标记为 A)、建议控制器(标记为 B)和试验控制器(标记为 C)。

实验控制器管理整个 HPO 实验的生命周期,例如为实验安排 HPO 试验并更新其状态。建议控制器运行 HPO 算法,为给定的超参数提供建议值。试验控制器运行给定超参数集的实际模型训练。

从这些核心组件的名称可以知道,它们的实现都遵循 Kubernetes 控制器模式。除了控制器外,Katib 还定义了一组 CRD 对象(spec)来与这三个控制器一起工作。例如,实验规范 是一种 CRD 类型,用于定义 HPO 实验的期望状态,并作为输入请求传递给实验控制器。

如图 C.3 所示,数据科学家 Alex 在与 Katib 交互时可能会遵循典型的工作流程。主要步骤列在以下各节中。

图 C.3 显示了 Katib 系统设计图和用户工作流程

第 1 步:创建实验请求

在第 1 步中,Alex 使用客户端工具(如 Katib SDK、Katib web UI 或 kubectl 命令)创建了一个实验 CRD 对象。这个实验对象包含了所有 HPO 实验的定义,如训练算法、超参数及其搜索空间、HPO 算法和试验预算。

实验控制器(组件 A)定期扫描所有实验 CRD 对象。对于每个实验 CRD 对象,它创建声明的建议 CRD 对象和试验 CRD 对象。简而言之,实验控制器生成实际资源,以实现实验 CRD 中定义的所需状态。此外,它会在实验 CRD 对象中更新实验的运行状态,因此 Alex 可以实时查看试验超参数和实验的执行状态。

一旦在第 1 步中创建了 Alex 的实验对象,Katib 就会为 Alex 的实验部署一个 HPO 算法建议服务(组件 D),以便运行所需的 HPO 算法。在这个建议服务中,实验 CRD 对象中定义的 HPO 搜索算法(库)被加载并通过 gRPC 接口公开,允许建议控制器与其通信并请求建议的超参数。

第 2 步:获取下一个试验的超参数

当实验控制器在第 2 步中发现了 Alex 的实验 CRD 对象时,它会创建一个建议 CRD 对象作为建议控制器(组件 B)的输入请求。在此建议 CRD 对象中指定了超参数及其值,以及搜索算法和建议的数量。

随后,建议控制器调用在第 1 步创建的建议算法服务来计算建议的超参数值。此外,建议控制器在建议 CRD 对象中维护建议的超参数值的历史记录。

第 3 步:创建试验请求

作为第 3 步的一部分,在建议控制器提供一组试验超参数值后,实验控制器(组件 A)创建一个试验 CRD 对象来启动模型训练试验。试验使用建议服务(组件 D)计算出的超参数值集来训练模型。

第 4 步:启动训练作业

在第 4 步中,试验控制器(组件 C)读取新创建的试验 CRD 对象(在第 3 步创建),并创建一个 TrialJob CRD 对象。有几种类型的 TrialJob CRD 对象,包括 Kubernetes 作业、PyTorch 作业、TF 作业和 MXNet 作业。对于每种作业类型,Kubeflow(www.kubeflow.org/docs/components/training/)提供了一个专用的训练运算符来执行它,如 PyTorch 训练运算符或 TensorFlow 训练运算符(组件 E)。

在检测到其类型中新创建的 TrialJob CRD 对象后,训练运算符(组件 E)会根据试验作业中定义的超参数创建 Kubernetes Pod 来执行训练图像。Alex 的 HPO 实验的训练试验将由 PyTorch 训练运算符运行,因为他的训练代码是用 PyTorch 编写的。

第 5 步:返回试验结果

当模型试训练开始时,度量收集器边车(一个位于 Kubernetes 训练 Pod 中的 Docker 容器)在第 5 步收集训练指标,并将其报告给 Katib 指标存储(一个 MySQL 数据库)。使用这些指标,试验控制器(组件 C)将试验执行状态更新到试验 CRD 对象上。当实验控制器注意到试验 CRD 对象的最新更改时,它读取更改并使用最新的试验执行信息更新实验 CRD 对象,以便实验对象具有最新状态。最新状态以这种方式聚合到实验对象中。

HPO 工作流本质上是一个试验循环。为了在 Katib 上处理 Alex 的 HPO 请求,此工作流中的第 2、3、4 和 5 步会重复进行,直到满足退出标准。Alex 可以在 HPO 执行过程中始终检查实验 CRD 对象,以获取 HPO 的及时执行状态,其中包括已完成或失败的试验数量、模型训练指标和当前最佳超参数值。

注意:使用 CRD 对象存储 HPO 执行数据有两个主要优点:简单性和可靠性。首先,可以轻松访问实验的最新状态信息。例如,你可以使用 Kubernetes 命令,如 kubectl describe experiment|trial|suggestion,在几秒钟内获取实验、试验和建议的中间数据和最新状态。其次,CRD 对象有助于提高 HPO 实验的可靠性。当 Katib 服务关闭或训练操作员失败时,我们可以从失败的地方恢复 HPO 执行,因为这些 CRD 对象保留了 HPO 执行历史记录。

C.4.3 Kubeflow 训练操作员集成分布式训练

Katib 的默认训练操作员——Kubernetes 作业操作员——只支持单 pod 模型训练;它为实验中的每个试验启动一个 Kubernetes pod。为了支持分布式训练,Katib 与 Kubeflow 训练操作员合作(www.kubeflow.org/docs/components/training/)。你可以在图 C.4 中看到这是如何运作的。

图 C.4 Katib 创建不同的试验作业以触发训练操作员为不同的训练框架运行分布式训练。

HPO 实验由试验组成。Katib 为每个试验创建一个试验 CRD 对象和一个 TrialJob CRD 对象。试验 CRD 包含 HPO 试验的元数据,例如建议的超参数值、工作人员数量和退出条件。在 TrialJob CRD 中,试验元数据被重新格式化,以便 Kubeflow 训练操作员能够理解它。

PyTorchJobTFJob 是两种最常用的 TrialJobs CRD 类型。它们可以被 TensorFlow 训练操作员和 PyTorch 训练操作员处理,每个操作员都支持分布式训练。当 Alex 在实验 CRD 对象中将工作人员数量设置为三时,Katib 会创建一个 PyTorchJob 试验 CRD 对象,PyTorch 训练器可以在这个实验上进行分布式训练。

这个例子还说明了 Kubernetes 控制器模式是多么的灵活和可扩展。如果它们都被实现为控制器,两个应用程序 Katib 和 KubeFlow 训练操作员可以轻松集成。

注意:我们在第 3.4.3 节讨论了 Kubeflow 训练操作员设计,请如果你想了解更多内容,请重新阅读。

C.4.4 代码阅读

虽然 Katib 有一个庞大的代码库(github.com/kubeflow/katib),但阅读和调试其代码并不太困难。

从哪里开始阅读代码

所有 Katib 核心组件都采用了控制器模式:experiment_controllertrial_controllersuggestion_controller。控制器的工作是确保对于任何给定的对象,Kubernetes 世界的实际状态与对象中的期望状态相匹配。我们称这个过程为reconciling。例如,在experiment_controller中的 reconcile 函数会读取实验对象的集群状态,并根据所读取的状态进行更改(建议、试验)。通过遵循这个思路,我们可以从每个控制器类的 reconcile 函数开始理解其核心逻辑。

你可以在pkg/controller.v1beta1/experiment/experiment_controller.go找到实验控制器,建议控制器在pkg/controller.v1beta1/suggestion/suggestion_controller.go,试验控制器在pkg/controller.v1beta1/trial/trial_controller.go。记得从这些文件的 reconcile 函数开始。

调试

Katib 核心应用(katib-controller)作为控制台应用程序运行。这个控制台应用程序中没有 UI 或者 Web 代码,只有纯逻辑代码,因此它的本地调试设置很简单。要调试 Katib,首先设置好你的本地 Kubernetes 集群,然后通过断点在本地运行 katib-controller,接着你可以通过创建一个测试实验请求,例如kubectl apply -f {test_experiment.yaml}来启动 HPO 过程。reconcile 函数中的断点会被触发,你可以从那里开始调试和探索代码。

要设置一个本地开发环境,请按照 Katib 的开发者指南操作(mng.bz/VpzP)。katib-controller 的入口点在 cmd/katib-controller/v1beta1/main.go。

注意 Katib 是一个生产质量的 HPO 工具。它以高可靠性和稳定性运行。但是要在日常操作中使用它,我们需要阅读其源代码以了解其行为,这样我们就知道在 HPO 执行偏离脚本时该如何引导它。通过遵循图 C.2 中的工作流程并阅读每个控制器的 reconcile 函数,你将在几个小时内对 Katib 有很好的理解。

C.5 添加新算法

从图 C.2 中我们知道 Katib 将不同的 HPO 算法作为独立的建议/算法服务运行。一旦创建了一个实验,Katib 就会为所选的 HPO 算法创建一个建议服务。这个机制使得向 Katib 添加新算法并让新添加的算法与现有算法一致变得很容易。要向 Katib 添加一个新算法,我们需要执行以下三个步骤。

C.5.1 第 1 步:用新算法实现 Katib 建议 API

首先,我们需要实现 Katib 的Suggestion接口。这个接口在 gRPC 中定义,所以你可以用你喜欢的任何语言来实现它。这个接口的详细定义可以在mng.bz/xdzW找到;请看以下代码:

service Suggestion {
  rpc GetSuggestions(GetSuggestionsRequest) 
    returns (GetSuggestionsReply);
  rpc ValidateAlgorithmSettings(ValidateAlgorithmSettingsRequest)
    returns (ValidateAlgorithmSettingsReply);
}

以下代码片段是实现 Suggestion 接口的一个示例。超参数及其值搜索空间在 request 变量中定义。过去的试验及其指标也可以在 request 变量中找到,因此您可以运行您的算法来计算下一个建议,方法是在 GetSuggestions 方法中使用这些输入数据;请参阅以下代码:

class NewAlgorithmService(                               ❶
  api_pb2_grpc.SuggestionServicer,                       ❶
  HealthServicer):                                       ❶
  def ValidateAlgorithmSettings(self, request, context):
    # Optional, it is used to validate 
    # algorithm settings defined by users.
    Pass

  def GetSuggestions(self, request, context):            ❷
    search_space = HyperParameterSearchSpace.convert(request.experiment)

    trials = Trial.convert(request.trials)               ❸

    # Implement the logic to use your algorithm 
    # to generate new assignments 
    # for the given current request number.
    list_of_assignments = your_logic(search_space,       ❹
      trials, request.current_request_number)            ❹

    return api_pb2.GetSuggestionsReply(
      trials=Assignment.generate(list_of_assignments))

❶ 定义一个新的算法服务,并实现 GetSuggestions 接口

❷ Suggestion 函数为每个试验提供超参数。

❸ 获取过去的试验

❹ 实现了实际的 HPO 算法,提供候选值。

C.5.2 步骤 2:将算法代码作为 GRPC 服务 Docker 化

一旦我们实现了 Suggestion 接口,我们需要构建一个 gRPC 服务器来将此 API 暴露给 Katib,并将其 Docker 化,以便 Katib 可以通过发送 gRPC 调用来启动算法服务并获取超参数建议。代码如下所示:

server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
service = YourNewAlgorithmService()
api_pb2_grpc.add_SuggestionServicer_to_server(service, server)
health_pb2_grpc.add_HealthServicer_to_server(service, server)
server.add_insecure_port(DEFAULT_PORT)
print("Listening...")
server.start()

C.5.3 步骤 3:将算法注册到 Katib

最后一步是将新算法注册到 Katib 的起始配置中。在 Katib 服务配置的 suggestion 部分添加一个新条目;以下是一个示例:

suggestion: |-
  {
    "tpe": {
      "image": "docker.io/kubeflowkatib/suggestion-hyperopt"
  },
    "random": {
      "image": "docker.io/kubeflowkatib/suggestion-hyperopt"
  },
+   "<new-algorithm-name>": {
+     "image": "new algorithm image path"
+ }

C.5.4 示例和文档

大部分前文来自 Katib GitHub 仓库(github.com/kubeflow/katib)的 readme 文件,“关于如何在 Katib 中添加新算法的文档”(mng.bz/Alrz)— 这是一份非常详细和写得很好的文档,我们强烈建议您阅读。

因为 Katib 的所有预定义 HPO 算法都遵循相同的 HPO 算法注册模式,您可以将它们用作示例。此示例代码可在 katib/cmd/suggestion(mng.bz/ZojP)找到。

C.6 进一步阅读

恭喜您到达这里!这是需要消化的大量内容,但您已经走了这么远。虽然我们已经涵盖了 Katib 的很大一部分,但由于页面限制,我们仍然没有讨论到一些重要的部分。如果您想进一步进行,我们列出了一些有用的阅读材料供您探索。

C.7 使用它的时机

从这次讨论中我们可以看到,Katib 满足了 HPO 服务的所有设计原则。它对训练框架和训练代码是不可知的;它可以扩展以纳入不同的 HPO 算法和不同的指标收集器;并且由于 Kubernetes 的存在,它是可移植和可扩展的。如果你正在寻找一个生产级别的 HPO 服务,Katib 是最佳选择。

对于 Katib 唯一的注意事项是它的前期成本很高。你需要建立一个 Kubernetes 集群,安装 Katib,并将训练代码 Docker 化才能开始。你需要了解 Kubernetes 命令来排查故障。这需要专门的工程师来操作和维护系统,因为这些都是非常重要的任务。

对于生产场景,这些挑战并不是主要问题,因为通常模型训练系统的设置与 Kubernetes 中的 Katib 相同。只要工程师有操作模型训练系统的经验,就可以轻松管理 Katib。但对于小团队或原型项目来说,如果你更喜欢简单的东西,像 Ray Tune 这样的 HPO 库方法更合适。

posted @ 2024-05-02 22:33  绝不原创的飞龙  阅读(83)  评论(0编辑  收藏  举报