机器学习工程实战-全-

机器学习工程实战(全)

原文:Machine Learning Engineering in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分 机器学习工程的介绍

我相信,像数据科学领域的许多人一样,你一定见过关于项目失败的数据统计。根据我的经验,那些关于项目投入生产的数字(即,供应商承诺,如果你付钱给他们,他们的工具栈将提高你的成功率!)是荒谬地悲观。然而,在项目失败率中引用的夸张数字中,确实存在一些真实元素。

使用机器学习(ML)解决现实世界问题是复杂的。构建一个有用的模型所涉及的工具、算法和活动数量之大,对于许多组织来说都是令人畏惧的。在我作为数据科学家工作的时期,以及随后帮助数十家公司构建有用的机器学习项目的过程中,我从未见过工具或算法成为项目无法为公司提供价值的原因。

在绝大多数情况下,未能持续产生实际效用的项目,其问题根源在于非常早期的阶段。在写下第一行代码之前,在选择和构建服务架构之前,以及在做出可扩展训练决策之前,如果规划、范围界定和实验没有正确进行,项目注定要被取消或陷入无人问津的境地。

从项目定义的早期阶段、专业知识审查以及合理的测试验证水平来看,可以构建一个连贯的项目计划和路线图,将解决问题的想法带入到可以构建有效解决方案的阶段。本书的第一部分,我们将通过蓝图展示如何评估、计划和验证一个计划,以确定最可能低风险的解决方案,通过(或不通过)机器学习来实现。

1 什么是机器学习工程师?

本章涵盖

  • 机器学习工程师的知识和技能范围

  • 应用机器学习项目工作的六个基本方面

  • 机器学习工程师的功能性目的

机器学习(ML)令人兴奋。它很有趣,具有挑战性、创造性,并且富有智力刺激。它还为公司赚钱,自主处理压倒性的大任务,并从那些更愿意做其他事情的人那里移除了单调乏味的工作负担。

机器学习也非常复杂。从数千种算法、数百个开源包,以及需要从数据工程(DE)到高级统计分析与可视化的多种技能集的从业者职业,专业机器学习从业者所需完成的工作确实令人畏惧。再加上需要能够与广泛的跨职能专家、领域专家(SMEs)和业务单元团队合作——在解决问题的性质和机器学习支持的解决方案的输出上进行沟通和协作,增加了这种复杂性。

机器学习工程应用了一套系统来处理这种令人敬畏的复杂性。它使用一系列标准、工具、流程和方法,旨在最大限度地减少在解决商业问题或需求时进行废弃、误导或不相关工作的可能性。本质上,它是创建可以部署到生产中、未来几年内可以维护和更新的基于机器学习的系统的路线图,使企业能够获得机器学习在效率、盈利能力和准确性方面证明可以提供的收益(当正确执行时)。

这本书本质上就是那条路线图。它是一份指南,帮助你导航开发具有生产能力的机器学习解决方案的路径。图 1.1 显示了本书涵盖的机器学习项目工作的主要元素。我们将通过这些经过验证的过程集(主要是从我的职业生涯中犯过的错误中吸取的教训)来提供一个框架,通过应用机器学习来解决商业问题。

01-01

图 1.1 项目工作的机器学习工程路线图

这个项目工作路径并不是要专注于每个阶段应该完成的任务。相反,它是每个阶段内的方法论(即“我们为什么要这样做”的元素),这使得项目工作能够成功。

机器学习工作的最终目标毕竟是为了解决问题。作为数据科学(DS)从业者,解决我们所有人都要面对的业务问题的最有效方式是遵循一个旨在防止返工、混乱和复杂性的流程。通过拥抱机器学习工程的概念并遵循有效项目工作的道路,获得有用建模解决方案的最终目标可以更短,成本更低,成功的可能性也更高,而不是仅仅碰运气,寄希望于最好的结果。

1.1 为什么是机器学习工程?

简而言之,机器学习是困难的。在正确地以可靠频率大规模提供相关预测的意义上,它甚至更难。考虑到该领域存在许多专业领域——如自然语言处理(NLP)、预测、深度学习和传统的基于线性树建模——以及大量为解决特定问题而构建的算法,学习该领域所有知识的一小部分都极具挑战性。理解应用机器学习的理论和实践方面是具有挑战性和耗时的工作。

然而,这些知识在构建模型解决方案与外部世界之间的接口方面没有帮助。它也没有帮助告知确保可维护和可扩展解决方案的开发模式。

数据科学家还应该熟悉其他领域的专业技能。从中级数据工程技能(你总得从某处获取你的数据科学数据,对吧?),软件开发技能,项目管理技能,可视化技能,到演示技能,这个列表越来越长,需要积累的经验量也变得相当令人畏惧。考虑到所有这些,对于创建生产级机器学习解决方案所需的所有技能“只是弄清楚”来说,这并不令人惊讶。

机器学习工程的目标不是迭代上述技能列表,并要求数据科学家(DS)掌握每一项技能。相反,机器学习工程收集了这些技能的某些方面,精心设计以与数据科学家相关,所有这些都有助于增加将机器学习项目投入生产的可能性,并确保它不是一个需要不断维护和干预才能运行的解决方案。

无论如何,机器学习工程师不需要能够为通用算法用例创建应用程序和软件框架。他们也不太可能编写自己的大规模流式处理提取、转换和加载(ETL)管道。他们同样不需要能够使用 JavaScript 创建详细和动态的前端可视化。

机器学习工程师需要掌握“足够”的软件开发技能,以便能够编写模块化代码和实现单元测试。他们不需要了解非阻塞异步消息代理的复杂性。他们需要“足够”的数据工程技能来构建(并为模型调度 ETL)特征数据集,但不需要构建 PB 级流式摄取框架。他们需要“足够”的可视化技能来创建能够清晰传达其研究和模型所做工作的图表,但不需要开发具有复杂用户体验(UX)组件的动态 Web 应用程序。他们还需要“足够”的项目管理经验,知道如何正确定义、范围和控制在解决问题时项目,但不需要通过项目管理专业(PMP)认证。

在机器学习领域,有一个巨大的问题仍然存在。具体来说,为什么——随着许多公司全力投入机器学习,雇佣大量高薪数据科学家,并投入大量财务和时间资源到项目中——这么多努力最终都以失败告终?图 1.2 描绘了我所看到的六个主要项目失败原因的粗略估计(并且根据我的经验,任何特定行业的这些失败率确实令人惊讶)。

01-02

图 1.2:我对为什么机器学习项目失败的原因估计,基于我所参与和指导的数百个项目

在本书的第一部分,我们将讨论如何识别导致许多项目失败、被放弃或远远超出预期时间才达到生产阶段的原因。我们还将讨论解决这些常见失败的方法,并介绍可以显著降低这些因素导致项目受阻的流程。

通常,这些失败发生是因为数据科学团队要么缺乏解决所需规模问题(技术或流程驱动型失败)的经验,要么没有完全理解业务所期望的结果(沟通驱动型失败)。我从未见过这种情况是由于恶意意图造成的。相反,大多数机器学习项目极其具有挑战性、复杂,并且由难以向非专业人士解释的算法软件工具组成——这就是为什么大多数项目在业务部门之间沟通出现破裂的原因。

机器学习项目的复杂性还增加了两个其他关键元素,这些元素(大多数)传统软件开发项目并不具备:项目期望的细节经常不足,以及工具的相对行业不成熟。这两个方面与 20 世纪 90 年代初的软件工程状态并无不同。当时的企业不确定如何最好地利用技术能力的新方面,工具发展严重不足,许多项目未能满足那些委托工程团队为其建设的人的期望。从我的偏见来看,机器学习工作(仅与为数不多的公司合作)在 21 世纪的第二个十年处于与 30 年前软件工程相同的位置。

这本书并不是关于机器学习挑战的末日预言论文;相反,它的目的是展示这些元素如何成为项目的风险。目的是教授那些有助于最小化失败风险的流程和工具。图 1.3 展示了在项目执行过程中可能出现的各种绕行;每一项都会给项目成功的执行带来不同的风险元素。

01-03

图 1.3 导致项目失败的机器学习项目绕行

机器学习工程中使用的框架正是专门用来解决这些主要失败模式的。消除这些失败的可能性是这个方法的核心。这是通过提供更好的决策流程、简化与内部客户的沟通、在实验和开发阶段消除返工、创建易于维护的代码库,并将最佳实践方法应用于任何受数据科学工作严重影响的项目来实现的。正如几十年前的软件工程师从大规模瀑布式实施中提炼出更灵活、更高效的敏捷流程一样,机器学习工程寻求定义一套新的实践和工具,以优化数据科学家完全独特的软件开发领域。

1.2 机器学习工程的核心原则

既然你对机器学习工程有了大致的了解,我们现在可以稍微关注一下构成图 1.2 中那些极其广泛的类别的主要元素。这些主题中的每一个都将在本书后面的章节中进行深入讨论,但就目前而言,我们将通过可能令人痛苦熟悉的情况来全面审视它们,以阐明它们为什么如此重要。

1.2.1 规划

构建一个解决错误问题的机器学习解决方案是最令人沮丧的事情。

到目前为止,项目失败的最大原因之一是未能彻底规划项目,这是项目被取消最令人沮丧的方式之一。想象一下,你是一家公司第一位雇佣的数据科学家。在你第一周的时候,营销部门的负责人来找你,用他们的术语解释他们正在面临的一个严重的业务问题。他们需要找出一种高效的方法通过电子邮件与客户沟通,让他们知道即将到来的促销活动。给你提供的额外细节非常少,高管只是说:“我想看到我们的电子邮件的点击率和打开率上升。”

如果这是唯一提供的信息,并且对营销团队成员的重复查询只是陈述相同的最终目标——提高点击率和打开率,那么可以追求的途径似乎是无尽的。如果完全由你自己决定,你会

  • 专注于内容推荐并为每个用户定制电子邮件吗?

  • 提供一个基于 NLP 系统的预测,为每个用户定制相关的主题行吗?

  • 试图预测每天对客户群最相关的产品列表以进行销售吗?

在如此多的不同复杂性和方法的选择中,且缺乏指导,创建一个符合高管期望的解决方案几乎是不可能的。相反,如果进行适当的规划讨论,深入探讨正确的细节程度,避免 ML 方面的复杂性,真正的期望可能会被揭示。那时你就会知道,唯一的期望是预测每个用户最有可能打开邮件的时间。高管只想知道某人最有可能不在工作、通勤或睡觉的时间,这样公司就可以在一天中向不同的客户群体发送批量的电子邮件。

令人悲哀的现实是,许多 ML 项目都是以这种方式开始的。通常,在项目启动时很少进行沟通,普遍的期望是 DS 团队“只需弄清楚”。然而,如果没有关于需要构建什么、如何运行以及预测的最终目标等方面的适当指导,项目几乎注定会失败。

毕竟,如果为这种情况构建了一个整个内容推荐系统,投入了数月的时间和精力,而实际上只需要基于 IP 地址地理位置的简单分析查询,那么这个项目不仅会被取消,而且很可能会从高层提出许多问题,询问为什么建立这个系统以及为什么其开发成本如此之高。

让我们看看图 1.4 中展示的简化规划讨论。即使在讨论的初期阶段,我们也能看到仅仅几个精心设计的问题和清晰的答案就能提供每个数据科学家在这种情况下都应该寻找的东西(尤其是作为公司第一个解决第一个问题的 DS):快速胜利。

01-04

图 1.4 简化规划讨论图

如您从右侧展示的 DS 内部独白中可以看到,当前的问题根本不在最初假设的列表中。没有关于电子邮件内容、与主题行相关性的讨论,也没有关于电子邮件中条目的讨论。这是一个简单的分析查询,旨在确定客户所在的时区,并分析每个客户在本地时间的历史开启情况。通过花几分钟时间规划和全面理解用例,节省了数周(如果不是数月)的无效努力、时间和金钱。

通过关注将要建设的内容为什么需要建设它,DS 团队和业务部门都能够更有效地引导讨论。避免讨论如何建设的问题,可以使 DS 团队成员专注于问题本身。忽略由谁在何时完成的问题,有助于业务部门保持对项目需求的关注。

在项目这个阶段避免讨论实施细节,不仅对团队专注于问题至关重要。将算法和解决方案设计的神秘细节排除在更大团队的讨论之外,可以保持业务单元成员的参与度。毕竟,他们真正关心的不是混合了多少个鸡蛋,鸡蛋是什么颜色,甚至是什么物种下的蛋;他们只想在完成时吃到蛋糕。在第一部分的剩余部分,我们将详细讨论规划过程、与内部业务客户进行项目期望讨论以及与非技术受众进行关于机器学习工作的通用沟通。

1.2.2 范围和调研

如果在开发过程中半途改变方法,你将不得不面对与业务部门的艰难对话,解释项目延误是由于你没有做好作业。

总的来说,内部客户(业务单元)对项目只有两个问题:

  • 这能解决我的问题吗?

  • 这需要多长时间?

让我们看看另一个可能熟悉的场景,以讨论 ML 项目开发这个阶段可能出现的两种截然不同的错误方式。假设我们有一家公司的两个 DS 团队,每个团队都被置于对立面,以开发解决公司账单系统中日益严重的欺诈行为的解决方案。团队 A 的调研和范围过程如图 1.5 所示。

01-05

图 1.5 初级团队的数据科学家(有良好意图但缺乏经验)对欺诈检测问题的调研和范围

A 团队主要由初级数据科学家组成,他们中的所有人进入职场时都没有在学术界度过一段长时间。在获得项目细节和他们的期望后,他们的行动是立即查阅博客文章。他们在互联网上搜索“检测支付欺诈”和“欺诈算法”,找到了来自咨询公司的数百个结果,几篇来自类似初级数据科学家的极高层次的博客文章,这些初级数据科学家可能从未将模型投入生产,以及一些基本的开源数据示例。

与此相反,B 团队由一群博士级别的学术研究人员组成。他们的研究和范围界定如图 1.6 所示。

01-06

图 1.6 针对学术研究团队针对欺诈检测问题的研究和范围界定

B 团队对研究和想法的严谨态度,首先的行动是深入研究关于欺诈建模主题的已发表论文。经过几天阅读期刊和论文,这些团队成员现在掌握了一大堆理论,涵盖了检测欺诈活动的一些最前沿的研究。

如果我们要求任何一队估算出产生解决方案所需的工作量,我们会得到截然不同的答案。A 团队可能会估计大约需要两周时间来构建其 XGBoost 二分类模型,而 B 团队则会讲述一个截然不同的故事。这些团队成员会估计需要几个月时间来实现、训练和评估他们在一份备受推崇的论文中发现的创新深度学习结构,该论文的研究证明其准确性显著优于任何 Perforce 实现的算法。

在范围界定和研究方面的问题在于,这两个截然不同的方面都可能导致项目失败,原因完全不同。A 团队可能会失败,因为问题的解决方案比博客文章中展示的例子要复杂得多(仅平衡类别问题就是一个过于复杂的话题,难以在博客的简短篇幅中有效记录)。尽管 B 团队的解决方案可能非常准确,但公司作为初始的欺诈检测服务,永远不会分配资源来构建风险解决方案

对于机器学习项目来说,范围界定极具挑战性。即使是经验丰富的机器学习老手,预测一个项目需要多长时间,哪种方法最可能成功,以及需要多少资源,都是一项徒劳且令人沮丧的练习。做出错误断言的风险相当高,但合理地构建范围界定和解决方案研究可以帮助最小化估计失误的可能性。

大多数公司都拥有这种夸张场景中的人的类型混合。有些人是为了进一步推进算法的知识和研究进步,为行业内的未来发现铺平道路的学者。其他人则是“机器学习应用”工程师,他们只想将机器学习作为解决业务问题的工具。在 ML 工作中,重要的是要接受并平衡这些哲学观点的各个方面,在项目的研究和规划阶段达成妥协,并知道这里的中间地带是确保项目真正投入生产的最佳途径。

1.2.3 实验

测试方法是一种金发姑娘活动;如果你测试的选项不够多,你很可能找不到最佳解决方案,而测试太多东西则浪费宝贵的时间。找到中间地带。

在实验阶段,项目失败的最大原因要么是实验时间过长(测试太多东西或花费太多时间微调方法),要么是原型开发不足,糟糕到业务决定转向其他事情。

让我们用一个类似的例子来阐述,说明这两家公司如何在一个试图为检测零售店货架上的产品构建图像分类器的公司中发挥作用。这两个小组采取的实验路径(显示了实验的极端对立面)如图 1.7 和 1.8 所示。

01-07

图 1.7 不经验丰富的数据科学家团队匆忙的实验阶段

团队 A 体现了在项目早期阶段完全不足的研究和实验的例子。一个在解决方案开发的关键阶段草草了事的项目,如图 1.7 所示,面临着结果严重不成熟,以至于与业务无关的风险。这样的项目会削弱业务对 DS 团队的信心,浪费金钱,并无谓地消耗多个团队的宝贵资源。

这些缺乏经验的数据科学团队成员只进行最肤浅的研究,从博客文章中改编了一个基本的演示。虽然他们的基本测试显示出希望,但他们未能彻底研究在他们的数据上应用模型所需的实现细节。通过仅在他们图像库中的成千上万种产品中的两种产品上重新训练预训练模型,他们误导的结果掩盖了他们方法中的问题。

这与另一支队伍的情况正好相反。团队 B 处理这个问题的方法如图 1.8 所示。

01-08

图 1.8 项目实验阶段过度测试的案例

B 团队解决这个问题的方法是在数周内搜索最新的论文、阅读期刊,并理解涉及各种卷积神经网络(CNN)和生成对抗网络(GAN)方法的原理。他们确定了三种广泛的潜在解决方案,每个解决方案都包含需要运行和评估其整个训练图像数据集的多个测试。

在这种情况下,失败并不是因为研究的深度,就像其他团队一样。B 团队的问题在于他们的最小可行产品(MVP),因为他们试图在深度上尝试太多事情。为了解决他们试图解决的问题,调整自定义构建的 CNN 的结构和深度需要数十次(如果不是数百次)迭代才能正确。这项工作应该纳入项目的开发阶段,而不是在评估阶段,在基于早期结果选择了一种方法之后。

虽然不是项目失败的主要原因,但实验阶段的不正确实施可能会阻碍或取消本应很棒的项目。这两个极端例子都不合适,最好的做法是在两者之间采取适度的方法。

1.2.4 开发

没有人会在周六凌晨 4 点,已经调试失败 18 个小时,仍然没有修复错误时认为代码质量很重要。

对于机器学习项目来说,不良的开发实践可能会以多种方式表现出来,这些方式可能会完全摧毁一个项目。尽管通常不像其他一些主要原因那样直接可见,但脆弱且设计不良的代码库以及不良的开发实践会使项目更难工作,更容易在生产中崩溃,并且随着时间的推移,改进起来也更加困难。

例如,让我们看看在建模解决方案开发过程中经常出现的一种相对简单且常见的修改情况:特征工程的变更。在图 1.9 中,我们看到两位数据科学家试图在一个单体代码库中做出一系列修改。在这种开发模式中,整个工作的所有逻辑都通过脚本变量声明和函数写在一个单独的笔记本中。

朱莉在单体代码库中可能需要进行大量的搜索和滚动,找到特征向量定义的每个单独位置,并将她的新字段添加到集合中。她的编码工作必须正确,并且需要在脚本中的正确位置进行。对于任何足够复杂的机器学习代码库来说,这是一项艰巨的工作(因为特征工程和建模的代码行数总和可以达到数千行,如果以脚本方式开发的话),并且容易发生遗漏、打字错误和其他转录错误等令人沮丧的错误。

与此同时,Joe 需要做的编辑要少得多。但他仍然需要搜索长代码库并正确地编辑硬编码的值。

单体方法真正的问题在于他们试图将每个更改合并到单个脚本副本中时。由于他们相互依赖对方的工作,他们都必须更新他们的代码,并选择其中一个副本作为项目的 master,将另一个人的工作更改复制进来。这个过程既长又辛苦,浪费了宝贵的发展时间,并且可能需要大量的调试才能正确完成。

图 1.10 展示了维护 ML 项目代码库的另一种方法。这次,模块化的代码架构将图 1.9 中大型脚本内存在的紧密耦合分离出来。

01-09

图 1.9 编辑用于 ML 项目工作的单体代码库(脚本)

这个模块化代码库是在集成开发环境(IDE)中编写的。虽然两个数据科学家(DS)所做的更改在本质上与图 1.9 中的更改相同(Julie 正在向特征向量添加一些字段并更新这些新字段的编码,而 Joe 正在更新用于特征向量的缩放器),但将这些更改协同工作所花费的努力和时间却大不相同。

01-10

图 1.10 更新模块化 ML 代码库以防止返工和合并冲突

在 Git 中注册了完全模块化的代码库后,他们每个人都可以从主分支检出功能分支,对其功能所属的模块进行小幅度编辑,如果需要的话,编写新的测试,运行他们的测试,并提交拉取请求。一旦他们的工作完成——因为基于配置的代码以及每个模块类中方法的能力,可以通过利用作业配置来对其项目的数据进行操作——每个功能分支都不会影响其他分支,应该按设计正常工作。Julie 和 Joe 可以在单个构建中同时发布他们的更改分支,运行完整的集成测试,并安全地合并到主分支,自信他们的工作是正确的。实际上,他们可以高效地在同一代码库上协作,极大地减少错误发生的可能性,并减少调试代码所花费的时间。

1.2.5 部署

没有围绕部署策略规划项目就像不知道有多少客人会出席的晚宴。你可能会浪费钱或破坏体验。

对于新团队来说,ML 项目工作中最令人困惑和复杂的部分可能是如何构建一个成本效益高的部署策略。如果它能力不足,预测质量无关紧要(因为基础设施无法正确服务预测)。如果它能力过剩,你实际上是在烧钱购买未使用的基础设施和复杂性。

以一家快餐公司的库存优化问题为例。数据科学团队多年来在为区域分组提供库存管理预测方面相当成功,每周进行大量批量预测,以满足预期顾客数量每日需求的预测,并且每周提交大量提取的预测。直到这一点,数据科学团队已经习惯了类似于图 1.11 所示的 ML 架构。

01-11

图 1.11 基本的批量预测提供架构

这种为提供计划好的批量预测相对标准的架构,主要关注向内部分析人员暴露结果,他们提供有关订购材料数量的指导。这种预测提供架构并不特别复杂,是数据科学团队成员所熟悉的范例。由于设计的计划同步性质以及后续重新训练和推理之间的大量时间,其技术堆栈的总体复杂性不必特别高(这很好;请参阅以下侧边栏)。

简单架构的简要说明

在机器学习的世界里,构建架构时始终追求尽可能简单的方案。如果项目需要每周进行一次推理的周期性,使用批量处理(而不是实时流)。如果数据量以兆字节计算,使用数据库和简单的虚拟机(而不是 25 节点的 Apache Spark 集群)。如果训练运行时间以分钟计算,坚持使用 CPU(而不是 GPU)。

仅为了使用而使用复杂的架构、平台和技术将不可避免地导致你后悔,因为它向一个已经复杂的解决方案中引入了不必要的复杂性。随着引入的每个新复杂性,出现故障的可能性增加(通常以极其复杂的方式)。为了解决项目即将到来的商业需求,保持技术、堆栈和架构尽可能简单,始终是推荐的最佳实践,以便向企业提供一致、可靠和有效的解决方案。

随着公司随着时间的推移认识到这些批量方法在预测建模方面的好处,其对数据科学团队(DS team)的信心也在增加。当出现新的商业机会,需要针对每个门店进行近实时库存预测时,公司高管会要求数据科学团队为这一用例提供解决方案。

机器学习团队明白,他们标准的预测服务架构不适用于这个项目。他们需要构建一个 REST 应用程序编程接口(API),以支持预测数据的请求量和预测更新频率。为了适应每个门店库存预测的粒度级别(以及其中涉及的不稳定性),团队知道他们需要在一天中频繁地重新生成预测。有了这些要求,他们寻求公司一些软件工程师的帮助,并构建出解决方案。

直到上线后的第一周,企业才意识到实施云计算的成本比更高效的库存管理系统节省的成本高出整整一个数量级。新的架构,以及解决该问题所需的自回归积分移动平均(ARIMA)模型,如图 1.12 所示。

01-12

图 1.12 满足项目业务需求所需的更加复杂的伪实时服务架构

项目取消和为降低成本而对该实施架构进行完全重新设计的情况并不少见。这是一个在实施机器学习以解决新问题和有趣问题(而且公平地说,我在职业生涯中亲自造成了三次)的公司中反复出现的故事。

如果在项目开始时不专注于部署和服务,那么构建一个低效的解决方案——不符合服务级别协议(SLA)或流量需求,或者过度设计的解决方案——以不可接受的高成本超出技术规范的风险是高的。图 1.13 显示了在提供预测结果及其相关成本极端范围时需要考虑的一些(不是全部,无论如何想象)要素。

01-13

图 1.13 部署成本考虑因素

面对用算法以巧妙的方式解决一个新颖问题的挑战时,考虑成本可能并不特别令人兴奋或重要。虽然数据科学团队可能不会考虑特定项目的总拥有成本,但请放心,高管们会考虑。通过在构建项目的早期阶段尽早评估这些考虑因素,可以进行分析,以确定项目是否值得。

无论如何,在规划的第一周取消一个项目,总比在花费数月构建后关闭生产服务要好。然而,要了解相对昂贵的架构是否值得运行的成本,唯一的方法是衡量和评估其对业务的影响。

1.2.6 评估

如果你无法证明你的项目在生产中的好处,不要期望它在那里停留很长时间。

导致机器学习项目被取消或放弃的最糟糕的原因是预算。通常情况下,如果项目一开始就进入了生产阶段,那么与开发解决方案相关的初始成本已经被公司领导层接受并理解。由于无法看到其对公司的影响,导致项目在生产过程中被取消,这完全是另一回事。如果你无法证明解决方案的价值,你将面临有人告诉你某天为了省钱而关闭它的真实可能性。

想象一家公司,在过去六个月里,他们不懈地努力通过使用预测模型来提高销售额的新举措。DS 团队在整个项目开发过程中遵循最佳实践——确保他们构建的正是业务所要求的,并将开发努力集中在可维护和可扩展的代码上——并将解决方案推向了生产。

在过去的三个月里,该模型的表现一直非常好。每次团队对预测与现实状态进行事后分析时,预测结果都出奇地接近。然后,图 1.14 以一个简单的问题出现在我们面前,这是公司一位关注运行此机器学习解决方案成本的执行董事提出的问题。

01-14

图 1.14 一个几乎完美的机器学习项目因为缺乏 A/B 测试和统计上有效的归因测量而被取消

团队在创建一个伟大的机器学习项目时忘记的一件事是如何将他们的预测与业务的一个方面联系起来,以证明其存在的合理性。他们一直在开发和目前在生产中运行的模型旨在增加收入,但当团队仔细审查使用它的成本时,他们意识到他们还没有想到一个归因分析的方法来证明解决方案的价值。

他们能否简单地将销售额相加,并将其全部归因于模型?不,这甚至完全不正确。他们能否看看销售额与去年相比的情况?这也不正确,因为太多潜在因素正在影响销售额。

他们能做的唯一一件事就是通过进行 A/B 测试和使用合理的统计模型来得出收入提升的计算(包括估计误差),以显示额外销售量有多少是由于他们的模型。然而,船已经开走了,因为解决方案已经对所有客户部署。团队失去了证明模型继续存在的合理性的机会。虽然项目可能不会立即关闭,但如果公司需要减少预算支出,它肯定会在削减名单上。

提前思考和规划这种情况总是一个好主意。无论它是否已经发生在你身上,我可以向你保证,在某个时候,它肯定会发生(我通过两次非常痛苦的教训才学到这个小小的智慧)。如果你有经过验证和具有统计学意义的测试作为弹药,证明模型继续存在的合理性,那么捍卫你的工作会容易得多。第十一章涵盖了构建 A/B 测试系统的方法、归因的统计测试以及相关的评估算法。

1.3 机器学习工程的目标

在最基本的意义上,任何数据科学家的主要目标是通过使用统计、算法和预测建模来解决一个对人类来说过于繁重、单调、易出错或复杂的难题。这并不是要构建最花哨的模型,撰写关于他们解决方案的最令人印象深刻的论文,或者寻找最激动人心的新技术来强迫其进入项目工作。

我们所有人都在这个职业中是为了解决问题。在大量可供数据科学家使用的工具、算法、框架和核心责任中,很容易感到不知所措,并专注于工作的技术方面。如果没有流程指南来处理机器学习项目工作的复杂性,很容易失去解决问题的真正目标。

通过关注第 1.2 节中突出显示的项目工作核心方面,并在本书的其余部分进行更详细的阐述,你可以达到机器学习工作的真正期望状态:看到你的模型在生产环境中运行,并解决一个真正的商业问题。

你可以做到这一点

外面有一个完整的行业旨在说服你,你做不到——你需要雇佣他们为你完成所有这些复杂的工作。他们通过这样做赚了很多钱。

但请相信我,你可以学习这些核心概念,并组建一个遵循方法论来处理机器学习工作的团队,这可以显著提高项目的成功率。这项工作可能一开始很复杂,也很令人困惑,但遵循指南并使用合适的工具来帮助管理复杂性,可以帮助任何团队开发出复杂的机器学习解决方案,而不会需要巨额预算或消耗数据科学团队所有的时间来维持那些实施不当的解决方案的运行。你完全能行。

在深入探讨这些机器学习工程方法论和方法的细节之前,请参阅图 1.15 中的详细大纲。这实际上是一个生产机器学习工作的流程计划,我已看到它为任何团队和任何项目证明是成功的。

01-15

图 1.15 机器学习工程方法论组件图

在本书中,我们将涵盖这些要素,不仅关注每个要素的讨论和实现,还关注它们为什么如此重要。这条道路——关注支持成功机器学习项目的人员、流程和工具——是在我职业生涯中看到的许多失败项目的废墟上铺就的。然而,通过遵循本书概述的实践,你可能会看到更少的这些失败,让你能够构建更多不仅能够进入生产,而且被使用并保持生产的项目。

摘要

  • 机器学习工程师需要了解数据科学、传统软件工程和项目管理方面的知识,以确保应用机器学习项目能够高效开发,专注于解决实际问题,并且易于维护。

  • 在应用机器学习工作的六个主要项目阶段(规划、范围和调研、实验、开发、部署和评估)中始终关注最佳实践,将极大地帮助项目降低放弃的风险。

  • 放下对技术实现细节、工具和方法的创新性的担忧,将有助于将项目工作集中在真正重要的事情上:解决问题。

2 你的数据科学可能需要一些工程

本章涵盖了

  • 阐明数据科学家和机器学习工程师之间的差异

  • 在所有项目工作中注重简单性以降低风险

  • 将敏捷原则应用于机器学习项目工作

  • 阐述 DevOps 和 MLOps 之间的差异和相似之处

在上一章中,我们从项目工作的角度介绍了机器学习工程的组成部分。从项目层面的角度来看,解释这种数据科学工作方法所包含的内容只是故事的一部分。从更高的层面来看,机器学习工程可以被视为一个包含三个核心概念的食谱:

  • 技术(工具、框架、算法)

  • 人员(协作工作,沟通)

  • 流程(软件开发标准,实验严谨性,敏捷方法)

这个职业的简单真理是,专注于每个这些要素的项目工作通常都是成功的,而那些省略一个或多个要素的项目往往失败。这正是工业界机器学习项目高得令人难以置信且经常引用的失败率的原因(我认为这相当自私,并且当来自供应商营销材料时,充满了恐慌)。

本章从高层次上涵盖了这三个成功项目的组件。采用适当的平衡,专注于创建与内部客户共同开发、协作和包容性的可维护解决方案,将大大增加构建持久机器学习解决方案的机会。毕竟,所有数据科学工作的主要重点是解决问题。将工作模式符合一个专注于可维护性和效率的经过验证的方法论,直接转化为以更少的努力解决更多的问题。

2.1 通过增加项目成功的过程来增强复杂职业

在《数据科学、分类及相关方法》(Springer,1996 年),由 C. Hayashi 等人编写的关于术语“数据科学”的最早定义中,三个主要关注点如下:

  • 数据设计——具体来说,围绕如何收集信息和需要以何种结构获取信息以解决特定问题的规划

  • 数据收集——获取数据的行为

  • 数据分析——通过使用统计方法从数据中获取见解以解决问题

现代数据科学的大部分内容主要集中在这三个项目中的最后一个(尽管在许多情况下,数据科学团队被迫开发自己的 ETL),因为前两个通常由现代数据工程团队处理。在这个广泛的术语中,“数据分析”,现代数据科学的一个主要焦点:应用统计技术、数据操作活动以及统计算法(模型)来从数据中获取见解并对其做出预测。

图 2.1 的上半部分(以故意简明扼要和高度概括的方式)展示了现代数据科学家从技术角度的关注点。这些是人们在谈论我们所做的工作时最关注的职业要素:从数据访问到构建复杂的预测模型,利用令人眼花缭乱的算法方法和高级统计学。这并不是对数据科学家在实际项目工作中所做工作的特别准确的评估,而是关注了一些在解决问题中使用的任务和工具。以这种方式思考数据科学几乎和通过列出语言、算法、框架、计算效率以及他们职业的其他技术考虑因素来分类软件工程师的工作一样没有帮助。

02-01

图 2.1 软件工程技能与 DS 融入 ML 工程师角色的融合

我们可以在图 2.1 中看到,DS 的技术关注点(许多从业者专注于这一点)只是下半部分所示更广泛系统的一个方面。正是在这个区域,ML 工程,互补的工具、流程和范式提供了一个指导框架,这个框架在 DS 技术的核心方面得到根本性的支持,以更建设性的方式工作。

作为一种概念,ML 工程是一种范式,它帮助从业者关注项目工作中真正重要的方面:提供真正有效的解决方案。那么,从哪里开始呢?

2.2 简单性的基础

当真正解释数据科学家做什么时,没有比“他们通过将数学创造性地应用于数据来解决问题”更简洁的描述了。尽管这个描述很广泛,但它反映了从记录的信息(数据)中可以开发出的广泛解决方案。

在追求解决商业问题的过程中,关于 DS 在算法、方法或技术方面的期望没有任何规定。事实上,恰恰相反。我们是问题解决者,利用各种技术和方法。

对于数据科学领域的初学者来说,不幸的是,许多数据科学家认为,只有当他们使用最新的“最伟大”的技术时,他们才为公司提供价值。与其关注在权威白皮书或博客文章中大量宣传的新方法周围的最新炒作,经验丰富的数据科学家意识到,真正重要的是解决问题的行为,无论方法如何。尽管新技术和方法令人兴奋,但一个 DS 团队的有效性是通过它提供的解决方案的质量、稳定性和成本来衡量的。

如图 2.2 所示,机器学习工作中最重要的部分之一是在面对任何问题时导航复杂性的路径。通过以这种心态——将最简单的解决方案作为机器学习原则的基石(关注解决业务问题的最简单方案)——来处理每个新的业务请求,可以专注于解决方案本身,而不是特定的方法或新算法。

02-02

图 2.2 构建机器学习问题的最简单解决方案指南

围绕这一原则——追求尽可能简单的实现来解决问题——是构建所有其他机器学习工程方面的基础。这无疑是机器学习工程最重要的一个方面,因为它将影响项目工作的所有其他方面,包括范围界定和实施细节。尽早退出路径可能是决定项目是否失败的最大驱动因素。

“但如果解决方案没有使用 AI,那就不是数据科学工作”

我从未带着使用技术、特定算法、框架或方法的期望进入这条职业道路。我遇到过很多人是这样做的,而且在我所知道的一些人的整个职业生涯中,他们最终对他们在工作中实际上使用到的特定框架或库的少之又少感到惊讶。他们中大多数人对他们花在编写 SQL、对他们的数据进行统计分析以及清理杂乱数据以解决问题所花费的大量时间感到特别惊讶。

我想,我之所以从未有过许多同行在“现实世界”中不经常应用前沿方法时所经历的那种看似令人泄气的经历,是因为我在进入机器学习之前先从事了数据分析工作。在我转向这个领域的早期,我就意识到,解决问题的最简单方法总是最好的方法。

这个原因很简单:我必须维护解决方案。无论是每月、每日还是实时,我的解决方案和代码都是我需要调试、改进、解决不一致性以及坦白地说,只是保持运行的东西。一个解决方案越复杂,诊断失败所需的时间就越长,调试就越困难,更改其内部逻辑以添加功能就越令人沮丧。

追求解决方案简单性的目的(即仍然解决问题的最简单设计和方法)直接转化为在维护已解决的问题上花费的时间更少。这让你有更多的时间去解决问题,为公司带来更多价值,并且通常让你接触到更多的问题。

我多次看到人们对使用令人兴奋的算法的热情表现不佳。其中一个更引人注目的是一个用于图像分辨率提升的生成对抗网络(GAN),一个由 12 名数据科学家组成的团队花费了 10 个月才达到生产就绪和可扩展的状态。在与他们的高层管理人员交谈时,他们说他们正在招聘顾问来构建流失模型、欺诈模型和收入预测模型。他们认为,由于他们内部团队忙于研发项目,他们不得不聘请外部顾问来完成重要的关键建模工作。在与那家公司合作的前 12 周内,整个数据科学团队被解雇,图像项目被放弃。

有时,专注于为公司带来巨大价值的基本事物可以帮助你保住工作(这并不是说预测、流失和欺诈建模很简单,即使它们看起来并不特别有趣)。

2.3 借鉴敏捷软件开发的原则

开发运维(DevOps)将指导原则和成功的工程工作范式引入到软件开发中。随着敏捷宣言的出现,经验丰富的行业专业人士意识到了软件开发方式的不足。我和我的同事们尝试将这些指导原则应用于数据科学领域,如图 2.3 所示。

02-03

图 2.3 将敏捷宣言元素应用于机器学习项目工作

通过对敏捷开发原则的轻微修改,我们为将数据科学应用于商业问题建立了一套规则。我们将涵盖所有这些主题,包括为什么它们很重要,并给出如何在本书中应用它们以解决问题的事例。虽然其中一些与敏捷原则有显著差异,但它们在机器学习项目工作中的适用性为我们和许多人提供了可重复的成功模式。

然而,当应用于机器学习项目工作时,敏捷开发的两个关键点可以显著改善数据科学团队的工作方式:沟通与合作,以及拥抱和期待变化。我们将在下一节探讨这些内容。

2.3.1 沟通与合作

正如本书多次讨论的那样(尤其是在接下来的两章中),成功的机器学习解决方案开发的核心原则集中在人身上。这对于一个深深植根于数学、科学、算法和巧妙编码的职业来说可能看起来非常反直觉。

事实是,对问题解决方案的质量实施永远不会在真空中产生。我所参与或看到他人实施的最成功的项目是那些更关注人和项目及其状态的相关沟通,而不是围绕解决方案开发的技术和正式流程(或文档)。

在传统的敏捷开发中,这一点非常正确,但对于机器学习工作来说,编码解决方案的人员与解决方案的目标用户之间的互动更为关键。这是由于构建解决方案所涉及内容的复杂性。由于绝大多数机器学习工作对一般公众来说相当陌生,需要多年的专注学习和持续学习才能掌握,因此我们需要付出更大的努力,以进行有意义的和有用的讨论。

在确保项目成功且重工作最少的情况下,最大的推动因素是机器学习团队与业务部门之间的协作参与。确保成功的第二大因素是机器学习团队内部的沟通

以孤狼心态(这在大多数人的学术生涯中一直是焦点)来处理项目工作,对于解决困难问题是无效的。图 2.4 说明了这种风险行为(我在职业生涯早期做过,并看到其他人做过几十次)。

02-04

图 2.4 在独立工作于完整的机器学习解决方案中所学到的艰难教训。这种情况很少会有好结果。

这种开发风格的原因可能很多,但最终结果通常相同:要么是大量的重工作,要么是业务部门的大量挫败感。即使数据科学团队没有其他成员(一个“团队”只有一个人),请求同行评审并向其他软件开发人员、架构师或业务部门中为该解决方案构建的部门的专业人士展示解决方案,也可能是有帮助的。

你最不想做的事情(相信我,我做过,结果很糟糕)是收集需求然后直接去键盘解决问题,而不与任何人交谈。满足所有项目需求、正确处理边缘情况以及构建客户期望的产品,这些可能性极小,如果一切顺利,也许你应该考虑买一些彩票,用你所有多余的运气。

一个更全面且与敏捷开发流程相匹配的机器学习开发过程,与通用软件开发中的敏捷开发非常相似。唯一的区别是,对于软件开发来说,可能不需要额外的内部演示级别(通常一个同行评审的功能分支就足够了)。对于机器学习工作来说,展示性能作为影响输入到代码中的数据的函数,演示功能,以及展示输出可视化非常重要。图 2.5 展示了基于敏捷的机器学习工作的一种更可取的方法,该方法高度重视内部和外部合作与沟通。

02-05

图 2.5 机器学习敏捷特征创建过程,侧重于需求收集和反馈

团队成员之间更高的互动水平几乎总是会导致更多想法、观点和对假设事实的挑战,从而产生更高品质的解决方案。如果你选择让你的客户(请求你帮助的业务单元)或你的同事(即使在开发选择中的细节)不参与讨论,那么你构建的可能是他们没有预期或渴望的东西。

2.3.2 接受并预期变化

在实验和项目方向上,以及在项目开发过程中,做好充分准备并预期不可避免的变化发生,这一点至关重要。在我参与过的几乎所有机器学习(ML)项目中,项目开始时设定的目标最终都没有完全实现。这适用于从具体技术、开发语言和算法,到对数据的假设或期望——有时甚至包括最初使用机器学习(ML)来解决问题(例如,一个简单的聚合仪表板,帮助人们更有效地解决问题)。

如果你为不可避免的变化做好准备,你就可以帮助聚焦于所有数据科学(DS)工作中最重要的目标:解决问题。这种期望还可以帮助将注意力从无关紧要的元素(例如,哪种花哨的算法、酷炫的新技术或惊人的强大框架来开发解决方案)上移开。

如果没有预期或允许变化发生,关于项目实施的决定可能会使修改变得极其困难(或不可能),除非对之前完成的所有工作进行全面重写。通过思考项目方向可能如何变化,工作被迫更多地采用模块化格式,将功能松散耦合的片段,从而减少方向性转变对已完成工作其他部分的影响。

敏捷拥抱这种松散耦合设计和在迭代冲刺中构建新功能的概念,即使在面对动态和不断变化的需求时,代码仍然能够正常工作。将这种范式应用于机器学习(ML)工作,可以相对简化突然甚至较晚出现的变更——当然,在合理范围内。(从基于树的算法迁移到深度学习算法不可能在两周内完成。)虽然简化了,但这并不保证简单。事实很简单,预测变化并构建支持快速迭代和修改的项目架构将使开发过程变得更加容易。

2.4 机器学习工程的基础

现在你已经看到了将敏捷原则应用于机器学习(ML)的 DS 工作基石,让我们简要地看一下整个生态系统。这个项目工作系统通过我在行业中构建的许多经历,已经证明是成功的,这些经历旨在解决问题的弹性和有用解决方案。

如本章引言中所述,机器学习操作(MLOps)作为一种范例的想法根植于 DevOps 应用于软件开发中类似原则的应用。图 2.6 显示了 DevOps 的核心功能。

02-06

图 2.6 DevOps 的组件

比较这些核心原则,正如我们在第 2.3 节中与敏捷方法所做的那样,图 2.7 展示了数据科学版本的 DevOps:MLOps。通过合并和整合这些元素,可以完全避免 DS 工作中的最灾难性事件:消除失败、取消或未被采用解决方案。

02-07

图 2.7 将 DevOps 原则应用于机器学习项目工作(MLOps)

在本书中,我们将不仅涵盖为什么这些元素都很重要,还会展示有用的示例和实际应用,您可以跟随这些示例来进一步巩固这些实践在自己的工作中。毕竟,所有这些的目标是让您成功。实现这一目标最好的方式是帮助您通过提供如何处理项目工作的指南,使您的业务成功,该指南将用于使用、提供价值,并且尽可能容易维护,对您和您的数据科学团队同事来说也是如此。

摘要

  • 机器学习工程将数据科学家、数据工程师和软件工程师的核心功能能力整合到一个混合角色中,该角色支持创建专注于通过专业软件开发严格性解决问题的机器学习解决方案。

  • 开发最简单的解决方案有助于降低任何给定项目的开发、计算和运营成本。

  • 将敏捷的基本原则借鉴和应用于机器学习项目工作有助于缩短开发周期,迫使开发架构更容易修改,并强制执行复杂应用程序的可测试性,以减少维护负担。

  • 正如 DevOps 增强软件工程工作一样,MLOps 增强机器学习工程工作。虽然这些范例的许多核心概念是相同的,但管理模型工件和执行新版本持续测试的附加方面引入了细微的复杂性。

3 在建模之前:规划和范围界定一个项目

本章涵盖了

  • 为 ML 项目工作制定有效的规划策略

  • 使用高效的方法来评估机器学习(ML)问题的潜在解决方案

在机器学习项目的世界中,最大的杀手与大多数数据科学家所想象的毫无关系。这些杀手与算法、数据或技术专长无关。它们与您使用哪个平台无关,也与将优化模型的处理引擎无关。项目未能满足业务需求的最大原因在于任何技术方面的步骤之前:项目的规划和范围界定阶段。

在我们接受的教育和培训过程中,直到成为公司数据科学家(DS)的工作,我们被强调要独立解决复杂问题。将自己孤立并专注于展示在理论理解和算法应用方面的可证明技能,使我们形成了这样的预期:我们在工业界的工作将是一项单独的任务。面对一个问题,我们会想出如何去解决它。

作为数据科学家的生活现实与仅凭个人解决问题的学术方法相去甚远。实际上,这个职业远不止算法和积累如何使用它们的知识。这是一个高度协作和同伴驱动的领域;最成功的项目是由集成团队共同努力、在整个过程中进行沟通而构建的。有时这种孤立是由公司文化强加的(出于错误的目的,有意将团队与其他组织隔离开来,以“保护”团队免受随机项目请求的干扰),有时则是自我强加的。

本章涵盖了为什么这种范式转变——使机器学习团队减少对如何(算法、技术和独立工作)的关注,而更多地关注什么(关于正在构建的内容的沟通和协作)——可以使项目成功。这种转变有助于减少实验时间,使团队专注于构建适合公司的解决方案,并规划出包含跨职能团队 SME 知识的分阶段项目工作,以极大地提高项目成功的可能性。

这个包容性旅程的开始,即尽可能多地聚集人员,创建一个能够解决问题的功能性解决方案,是在范围界定阶段。让我们将一个缺乏或缺少范围界定和规划的机器学习团队的工作流程(图 3.1)与包括适当范围界定和规划的工作流程(图 3.2)进行对比。

03-01

图 3.1 缺乏规划、不恰当的范围界定和缺乏实验流程

这些机器学习团队成员通过绝对不是他们自己的错误(除非我们想责怪团队没有对业务单元施加压力以获取更多信息,而这我们不会做),他们尽最大努力构建几个解决方案来解决向他们抛出的模糊需求。如果他们幸运的话,他们最终会得到四个 MVP,以及三个月的努力浪费在三个永远不会进入生产的解决方案上(大量的浪费工作)。如果他们非常不幸,他们会在没有任何解决问题的解决方案上浪费数月的时间。无论如何,都不会有好的结果。

如图 3.2 所示,适当的范围界定和计划可以显著减少构建解决方案所需的时间。这种变化的最大原因是团队需要验证的总方法更少(所有方法都限制在两周内),主要是因为“尽早和经常”的反馈来自内部客户。另一个原因是,在新的功能开发每个阶段,都会进行快速会议和演示,以便 SMEs 进行验收测试。

03-02

图 3.2 一个彻底的范围界定、计划和协作的机器学习 MVP 项目路线图

为了进一步增加效率的显著提升,这种方法在内部客户包容性方面的另一个重大好处是,最终解决方案满足业务期望的概率显著提高。图 3.1 中显示的极端风险已经消失:经过数月的工作后,展示多个解决方案,却发现整个项目需要从头开始重新启动。

计划、范围界定、头脑风暴和组织会议不是项目经理的事情吗?

一些机器学习实践者可能会对在机器学习项目讨论中包含计划、沟通、头脑风暴和其他项目管理相关的元素表示反对。作为回应,我只能提供另一条轶事证据:在我参与的最成功的项目中,机器学习团队负责人不仅与其他涉及的团队负责人和项目经理紧密合作,还与请求解决方案的部门代表合作。

因为负责人参与了解决方案的项目管理方面,团队通常承受的工作相关波动和返工要少得多。团队成员可以采用整体方法专注于开发,以便将最佳解决方案交付到生产环境中。

相比之下,在孤岛中运作的团队通常难以使项目成功。这种挣扎可能是因为未能将讨论保持在抽象术语上,从而将其他人孤立起来,使他们无法为解决方案贡献想法(例如,机器学习团队将讨论集中在实施细节上,或在会议中过于深入地讨论算法)。此外,“我们不是项目经理……我们的工作是构建模型”的态度可能也起到了作用。在没有适当和有效的沟通模式的情况下,跨职能团队工作的最终结果不可避免地会导致范围蔓延、混乱,以及项目内部子团队之间的普遍社会对抗。

以开放的心态(以及非常开放的心态,倾听和观察他人的观点和想法,无论他们的技术专长如何)和慷慨地接纳跨职能团队中的各种观点,你可能会发现,对于手头的问题,一个简单的解决方案可以出现。正如我将会反复强调(因为它值得重复,作为机器学习实践者的生活格言),最简单的方法就是最好的方法。我发现,大多数情况下,这些启示发生在早期规划和范围界定阶段。

在本章(以及下一章)中,我们将讨论帮助这些讨论的方法,一个我用来指导这些阶段的标准,以及我在这个阶段犯了很多错误后学到的教训。

但如果所有的测试都是垃圾怎么办?

我收到了一些关于图 3.2 的相当一致的反馈。几乎每个参与过真实世界机器学习项目的人都会问这个问题:“好吧,本,限制测试范围确实是个好主意。但如果什么都没用怎么办?那怎么办?”

我对每个人都以同样的方式回应:“你还能做什么其他工作?”

这可能看起来是最荒谬的答案,但它打开了围绕项目的更大元问题。如果对最有希望的测试方法的研究都遇到了失败的结果,那么你试图解决的问题可能从开发努力和时间上讲将非常昂贵。如果项目非常重要,业务坚决要求承担额外测试带来的延误,并且团队有足够的带宽来支持这项额外工作,那么就去做吧。开始新一轮的测试。弄清楚它。如果需要,寻求帮助。

然而,如果项目不符合这些要求,那么向业务解释继续工作的巨大风险是至关重要的。这一评估阶段的重要性不仅仅是因为要做出以下裁决:“我们真的能构建这个吗?”或者“我们甚至知道我们能否构建这个吗?”

如果答案不是响亮的“是”,并且没有定量证据来支持这一说法,那么是时候与业务部门进行大量的坦诚交流,进行更多的概念验证工作,并与项目负责人共同讨论项目周围任何未知元素的风险讨论。

3.1 计划:你想要我预测什么?!

在我们深入探讨成功进行机器学习项目规划阶段的方法之前,让我们模拟一下在一个没有建立或证明的流程来启动机器学习工作的公司中,一个典型项目的起源。让我们想象我们是一家电子商务公司,这家公司刚开始尝试现代化其网站。

在看到竞争对手多年来通过在其网站上添加个性化服务来夸大销售增长后,公司的高层管理人员要求公司全力以赴进行推荐。公司的高层管理人员没有人完全清楚这些服务是如何构建的技术细节,但他们都知道首先需要接触的是机器学习专家。业务部门(在这种情况下,销售部门领导、市场营销和产品团队)召开会议,邀请整个机器学习团队,邀请函中除了标题“个性化推荐项目启动”外,几乎没有添加任何额外的色彩。

管理层和您合作过的各个部门都对贵团队构建的小规模机器学习项目(欺诈检测、客户估值估计、销售预测和客户流失概率风险模型)感到满意。从机器学习的角度来看,每个先前项目在复杂程度上各有不同,但基本上都是封闭的——在机器学习团队内部处理,该团队提出了一个可以被各个业务单元消费的解决方案。这些项目都不需要主观质量评估或过多的业务规则来影响结果。这些解决方案的数学纯粹性根本不容争辩或解释;要么是正确的,要么是错误的。

成功的受害者,团队被业务部门提出一个新概念:现代化网站和移动应用程序。高管们已经听说,随着个性化推荐的出现,带来了巨大的销售增长和客户忠诚度,他们希望贵团队为网站和应用程序构建一个集成系统。他们希望每个用户在登录时都能看到一个独特的商品列表。他们希望这些商品对用户来说既相关又有趣,最终,他们希望增加用户购买这些商品的可能性。

在一次简短的会议中,展示了其他网站上的例子后,他们询问系统何时可以准备好。你根据过去阅读的关于这些系统的几篇论文估计大约需要两个月,然后开始工作。团队在接下来的 scrum 会议中制定了一个初步的开发计划,然后每个人都开始努力解决问题。

你和 ML 团队的其余成员都认为管理层正在寻找许多其他网站上显示的行为,即在主屏幕上推荐产品。毕竟,这纯粹是个性化的体现:一个算法预测将对单个用户具有相关性的独特产品集合。你们都同意,这种方法看起来相当直接,团队开始迅速规划如何构建一个数据集,该数据集基于每个网站和移动应用用户的浏览和购买历史,列出每个用户的排名产品键。

稍等一下。规划项目不是与敏捷开发相矛盾吗?

好吧,是的,也不是。引用 Scott Ambler(敏捷基础流程最多产的作家之一)的话:“项目计划很重要,但它不能过于僵化,以至于无法适应技术或环境的变化、利益相关者的优先级以及人们对问题和解决方案的理解。” (www.ambysoft.com/essays/agileManifesto.html)。

在我的职业生涯中,我经常看到这种观点的误解。Ambler 和敏捷宣言的原始创作者指出,项目不应该由一个预先计划且不可更改的元素构建脚本来指导。意图从来不是,也永远不会是根本不规划。这仅仅是为了使创建的计划具有灵活性,以便在需要时进行更改。

如果出现了一种更简单的方法来实现某事,一种在仍然达到相同最终结果的同时减少复杂性的更好方法,那么项目计划应该改变。在机器学习的世界里,这种情况很常见。

也许在项目开始时(在彻底的研究阶段完成之前),跨职能团队确定唯一可能的解决方案是一个高度复杂和复杂的建模方法。然而,在进行了实验之后,团队发现可以开发一个简单的线性方程,以在开发时间和成本的一小部分内以可接受的精度解决问题。尽管最初的计划是使用深度学习来解决问题,但团队可以、应该并且必须转向这个简单得多的方法。计划确实改变了,但如果没有计划,研究和实验阶段就像在夜晚迷失的船只——没有引导,没有方向,在黑暗中混乱地移动。

在机器学习中,规划是好的。但关键是要避免将这些计划固定化。

在接下来的几个冲刺中,你们都专心致志地独立工作。你测试了你在博客文章中看到的各种实现,消费了数百篇关于不同算法和解决隐式推荐问题的理论论文,最终使用交替最小二乘法(ALS)构建了一个 MVP 解决方案,实现了 0.2334 的均方根误差(RMSE),以及基于先前行为的有序评分的粗略实现。

带着自信,你相信有令人惊叹的东西可以向业务团队赞助商展示,你带着测试笔记本、显示总体指标的图表以及你相信会真正打动团队的样本推理数据去参加会议。你首先展示了亲和度的总体评分,将数据以 RMSE 图的形式展示,如图 3.3 所示。

03-03

图 3.3 为亲和度得分与其预测值之间的 RMSE 的相当标准的损失图表

对展示图表的反应最多是冷淡。提出了许多问题,集中在数据的意义、穿过点的线代表什么以及数据是如何生成的。会议没有聚焦于解决方案和下一个阶段你希望工作的内容(提高准确性),而是开始变成了一团混乱和无聊的混合体。为了更好地解释数据,你展示了一个使用非折扣累积增益(NDCG)指标的非折扣累积增益(NDCG)排名有效性快速表格,以展示随机选择的一个用户的预测能力,如图 3.4 所示。

03-04

图 3.4 为单个用户的推荐引擎的 NDCG 计算。在没有上下文的情况下,展示这样的原始分数对数据科学团队没有任何益处。

第一张图表引起了一丝困惑,但表格却带来了完全的混乱。没有人理解展示的内容或看到与项目的相关性。大家心中想的只是,“这真的是几周努力的结果吗?数据科学团队这段时间都做了些什么?”

在数据科学团队解释这两个可视化图表的过程中,一位市场分析师开始查看会议提供的样本数据集中某位团队成员账户的产品推荐列表。图 3.5 展示了结果,以及市场分析师在提出列表中每个推荐的产品目录数据时的想法。

03-05

图 3.5 使用视觉模拟进行 SME 定性验收测试

DS 团队从这次会议中学到的最大教训,实际上并不是验证其模型结果的方式,这种方式能够模拟预测的最终用户会如何反应。尽管这是一个重要的考虑因素,并且在接下来的侧边栏中进行了讨论,但它被一个更重要的认识所超越,那就是模型之所以受到如此糟糕的对待,是因为团队没有正确规划这个项目的细微之处。

不要盲目相信你的指标

在进行特别大规模的机器学习时,过分依赖错误指标和模型验证分数是非常诱人的。它们不仅是衡量大型数据集(我们中的许多人现在经常处理)预测客观质量的唯一真正现实的方法,而且往往是评估特定实现预测质量唯一真实、有效的定量方法。

然而,仅仅依赖这些模型评分指标是不够的。当然要使用它们(适用于手头工作的适当指标),但要用额外的手段来获取预测有效性的主观测量。如图 3.5 所示,对单个用户的预测进行简单可视化,比任何预测排序评分算法或损失估计能揭示更多客观和主观的质量评估。

请记住,这种额外的最终用户模拟样本评估不应该由 DS 团队成员执行,除非他们正在评估他们自己被认为是领域专家的数据的预测质量。对于我们在讨论的使用案例,DS 团队应该与几位营销分析师合作,在进行结果展示给更大团队之前,进行一些非正式的质量保证(QA)验证。

DS 团队并没有从了解业务问题的角度理解房间里其他团队成员的看法,这些团队成员知道所有所谓的“尸体”都埋在数据中,并且拥有关于数据性质和产品的累积数十年的知识。这种失败的负担并不完全落在项目经理、DS 团队负责人或任何单个团队成员身上。相反,这是更广泛团队中每个成员的集体失败,因为他们没有彻底定义项目的范围和细节。他们怎么能做得不同呢?

分析师查看自己账户的预测时,发现了许多对他们来说显而易见的问题。他们看到了由于旧产品 ID 的退役而导致的重复项目数据,同样立刻意识到鞋类部门为每种鞋款的颜色使用单独的产品 ID,这些都是导致演示效果不佳的核心问题。所有发现的问题,导致项目取消的高风险,都是由于项目规划不当造成的。

3.1.1 项目的基本规划

任何机器学习项目的规划通常从高层次开始。业务部门、高管甚至 DS 团队的一员可能会提出一个想法,即利用 DS 团队的专长来解决一个具有挑战性的问题。虽然在这个早期阶段可能只是一个概念,但这在项目生命周期中是一个关键节点。

在我们一直在讨论的场景中,高层次的想法是个性化。对于一个经验丰富的 DS 来说,这可能意味着许多事情。对于业务部门的小专家来说,它可能意味着 DS 团队可能想到的许多相同概念,但也可能不是。从这个想法的早期阶段到甚至基本研究开始之前,这个项目中所有相关人员应该做的第一件事就是开会。这次会议的主题应该集中在一个基本要素上:我们为什么要构建这个

提出这样的问题可能听起来像是一个敌对或对抗性的问题。当听到这个问题时,可能会让一些人感到惊讶。然而,这是最有效和最重要的问题之一,因为它开启了对人们想要构建项目的真正动机的讨论。是为了增加销售额吗?是为了让我们的外部客户更快乐吗?还是为了让人们更长时间地浏览网站?

每个这些细微的答案都可以帮助确定这次会议的目标:定义任何机器学习工作的输出期望。这个答案也满足了模型性能的测量指标标准,以及生产中性能的归因评分(这个评分将用于衡量稍后进行的 A/B 测试)。

在我们的示例场景中,团队未能提出这个重要的为什么问题。图 3.6 显示了业务方和 ML 方期望的分歧,因为两组都没有讨论项目的本质方面,而是各自陷入了他们自己创造的思维孤岛。ML 团队完全专注于如何解决问题,而业务团队则对交付的内容有期望,错误地假设 ML 团队“自然会明白”。

图 3.6 总结了 MVP 的规划过程。在极端模糊的需求、对原型最小功能的期望缺乏充分沟通,以及未能控制实验复杂性的情况下,演示被认为是一个彻底的失败。防止这种结果只能在项目想法讨论的早期会议中实现。扩大这些期望差距区域的重叠是 DS 团队领导和项目经理的责任。在规划会议结束时,理想的状态是所有人的期望一致(没有人专注于实施细节或可能在未来添加的具体范围外功能)。

03-06

图 3.6 由无效的规划讨论驱动的项目期望差距

在继续这个场景的基础上,让我们看看 MVP 演示反馈讨论,看看在早期规划和范围会议期间可能讨论过的问题。图 3.7 显示了问题及其潜在的根本原因。

03-07

图 3.7 MVP 演示的结果。问题和它们随后的讨论本可以在规划阶段进行,以防止显示的五个核心问题。

尽管这个例子故意夸张,但我发现许多机器学习项目(那些主要不是以机器学习为重点的公司)中存在这种混淆的元素,这是可以预料的。机器学习经常旨在解决的问题很复杂,充满了具体且独特的细节,这些细节对每个业务(以及公司内的每个业务部门)都是特定的,并且充满了关于这些细节的细微差别的错误信息。

重要的是要认识到,这些挑战将是任何项目的必然部分。最大限度地减少它们影响的最佳方式是进行一系列彻底的讨论,旨在尽可能多地捕捉关于问题、数据和预期结果的细节。

假设业务知识

假设业务知识是一个具有挑战性的问题,尤其是对于一个刚开始利用机器学习(ML)的公司,或者对于一个之前从未与 ML 团队合作过的公司业务部门来说。在我们的例子中,业务领导层假设 ML 团队了解业务方面,这些方面是领导层认为普遍公认的知识。由于没有明确和直接的要求被提出,这个假设并没有被识别为一个明确的需求。在数据探索过程中,没有来自业务部门的小型专家(SME)参与指导 ML 团队,因此在构建最小可行产品(MVP)的过程中,他们根本无法了解这些信息。

假设业务知识通常是大多数公司的一条危险道路。在许多公司中,机器学习从业者与业务的内部运作隔绝。他们的主要关注点在于提供高级分析、预测建模和自动化工具,几乎没有时间来理解业务如何运作以及为什么运作的细微差别。虽然一些明显的业务方面是众所周知的(例如,“我们在网站上销售产品x”),但期望模型员了解存在一个业务流程,其中某些商品供应商会在网站上被优先推广,而其他供应商则不是,这是不合理的。

获得这些细微差别的一个好方法是让请求为他们构建解决方案的小组(在这种情况下,是产品营销组)的专家解释他们如何决定在网站和应用的每一页上产品的排序。进行这项练习将使房间里的人都能理解可能应用于模型输出的具体规则。

数据质量假设

在演示输出中重复产品列表的责任并不完全在任何一个团队。虽然机器学习团队成员当然可以计划这个问题,但他们并没有精确地意识到其影响范围。即使他们知道了,他们可能也会明智地提到纠正这个问题不会是演示阶段的一部分(因为需要的工作量很大,并且要求原型不要延迟太久)。

这里的主要问题是没有计划。由于没有讨论期望,业务领导对机器学习团队能力的信心就会减弱。原型成功的客观衡量标准将主要被忽视,因为业务成员只关注这样一个事实:对于一些用户的样本数据,前 300 个推荐只显示了 80 种颜色和图案中的 4 种产品。

对于我们的用例,机器学习团队认为他们使用的数据非常干净,正如 DE 团队告诉他们的那样。然而,对于大多数公司来说,现实情况比大多数人想象中的要严峻得多,尤其是在数据质量方面。图 3.8 总结了 IBM 和德勤进行的两项行业研究,表明成千上万的公司在机器学习实施方面挣扎,特别是指出数据清洁度方面的问题。在开始构建模型之前检查数据质量非常重要。

03-08

图 3.8 数据质量问题对公司参与机器学习项目工作的影响。数据质量问题很常见,因此在项目工作的早期阶段应该始终对其进行审查。

拥有“完美”的数据并不重要。即使是图 3.8 中那些成功部署许多机器学习模型到生产中的公司,仍然会定期遇到数据质量问题(据报道为 75%)。这些问题只是生成数据的极其复杂系统、多年(如果不是几十年)的技术债务以及设计“完美”系统(不允许工程师生成问题数据)相关的费用所带来的副产品。处理这些已知问题的正确方式是预测它们,在建模开始之前验证任何项目将涉及的数据,并向最熟悉这些数据的专家询问数据性质的问题。

对于我们的推荐引擎,机器学习团队成员未能不仅就他们所建模的数据的性质提出问题(即,“所有产品是否以相同的方式在我们的系统中注册?”),而且还通过分析验证数据。快速统计报告可能已经清楚地揭示了这个问题,特别是如果鞋类的独特产品数量比任何其他类别高得多。在规划会议中提出的“我们为什么卖这么多鞋?”这个问题,可以立即揭示解决这一问题的必要性,同时也导致了对所有产品类别的深入检查和验证,以确保进入模型的数据是正确的。

功能假设

在这个例子中,业务领导者担心推荐的产品是上周购买的。无论产品类型(消耗品或非消耗品),这里的规划失败在于未能表达这种做法对最终用户来说是多么令人不悦。

机器学习团队确保这一关键元素需要成为最终产品的一部分的回应是有效的。在处理过程的这个阶段,虽然从业务部门的角度来看,看到这样的结果令人沮丧,但几乎是不可避免的。在这个讨论方面的前进路径应该是确定功能添加工作范围,决定是否将其包含在未来的迭代中,然后继续下一个话题。

到今天为止,我还没有参与过在演示过程中没有出现这种情况的机器学习项目。改进想法总是来自这些会议——毕竟,这是召开会议的主要原因之一:使解决方案更好!最糟糕的事情是直接拒绝或盲目接受实施负担。最好的做法是提出改进的代价(时间、金钱和人力资本),让内部客户决定这是否值得。

知识诅咒

在这个讨论点上,机器学习团队立刻变得“全神贯注”。第四章详细介绍了知识诅咒,但就目前而言,要意识到,在沟通时,已经测试过的事物的内部细节总是会落在聋子的耳朵里。假设房间里的人除了伪科学的流行词汇之外,对解决方案的细节了如指掌,这对作为机器学习实践者的你(你无法传达你的观点)和对听众(他们会感到无知和愚蠢,因为你觉得他们会知道这样一个具体的话题)都是一种伤害。

讨论你尝试的众多解决方案失败的最佳方式:尽可能以尽可能抽象的方式说话:“我们尝试了几种方法,其中一种可能会使推荐变得更好,但这将使我们的时间表增加几个月。您想做什么?”

在非专业人士的背景下处理复杂话题总是比深入研究技术细节要好得多。如果你的听众对更技术性的讨论感兴趣,逐渐深入到更深的技术细节,直到问题得到解答。用他们无法合理理解的术语来解释问题绝不是个好主意。

分析瘫痪

没有适当的规划,机器学习团队很可能会对许多方法进行实验,很可能是他们能找到的最先进的方法,以追求提供最佳可能的推荐。如果在规划阶段没有关注解决方案的重要方面,这种仅关注模型纯净度的混乱方法可能会导致一个偏离整个项目目标的解决方案。

总的来说,最精确的模型并不一定是最佳解决方案。大多数时候,一个好的解决方案是能够满足项目需求,并且通常意味着将解决方案保持尽可能简单,以满足这些需求。带着这种想法来处理项目工作将有助于减轻在选择最佳模型时可能出现的犹豫和复杂性。

3.1.2 那次第一次会议

如我们之前讨论的,我们的示例机器学习团队在规划方面采取了有问题的方法。那么,团队是如何陷入无法沟通项目应关注什么的状态的呢?

当机器学习团队的每个人都安静地思考算法、实现细节以及如何获取数据来填充模型时,他们过于专注于这些问题,以至于没有提出应该提出的问题。没有人询问实现方式的具体细节,需要实施的限制类型,或者产品是否应该在排序的集合中以某种方式显示。他们所有人都专注于“如何”,而不是“为什么”和“什么”。

在跨职能会议中关注“如何”进行

虽然在项目的规划和范围定义阶段讨论潜在解决方案可能很有诱惑力,但我强烈建议您抵制这种诱惑。并不是说在您的内部客户面前进行讨论有什么危险。恰恰相反。只是他们并不关心(也不应该关心)。对于一些机器学习从业者(我在对你,年轻的自己)来说,人们不会立即讨论项目中涉及的所有酷炫算法和复杂的特征工程的想法,这在他们看来简直是不可想象的。当然,每个人都必须觉得这些话题和我们一样令人兴奋,对吧?

错误。如果你不相信我,我挑战你和你配偶、伴侣、孩子、朋友、非数据科学同事、发型师(或理发师)、邮递员或狗讨论你的下一个项目。我可以向你保证,唯一感兴趣的可能就是你的狗。

而且这只是在你们在告诉他们的时候吃东西的情况下。特别是如果它是一个芝士汉堡。狗喜欢芝士汉堡。特别是我的狗。

讨论如何做的问题应该在内部进行,稍后,在 DS 团队内部进行。进行头脑风暴会议。彼此辩论(文明地进行)。但是为了你和你的业务单元成员的利益,我建议不要在他们在场时进行。

相反,将项目带到 ML 团队的内部营销团队成员没有清楚地讨论他们的期望。没有恶意意图,他们对开发此解决方案的方法论的无知,加上他们对客户和解决方案应该如何表现的高度了解,创造了一个完美的实施灾难的配方。

这该如何处理才能有所不同?如何安排第一次会议,以确保业务单元团队成员持有的最大数量的隐藏期望(如我们在 3.1.1 节中讨论的)可以以最有效的方式进行公开讨论?这可以简单到从一个简单的问题开始:“你现在如何决定在哪些地方展示哪些产品?”在图 3.9 中,让我们看看提出这个问题可能会揭示什么,以及它如何可以告知应该为 MVP 范围的关键特性要求。

03-09

图 3.9 一个专注于定义特性的范围和规划会议的示例

正如你所看到的,并不是每个想法都是绝妙的。有些超出了预算的范围(时间、金钱或两者兼而有之)。还有些超出了我们的技术能力极限(“看起来不错”的需求)。然而,重要的是要关注的是,已经确定了两个关键的关键特性,以及一个潜在的附加未来特性,可以将其放入项目的待办事项列表中。

尽管这个图表中的对话可能看起来相当夸张,但这几乎是对我参与的一个实际会议的逐字记录。尽管我在一些请求上几次忍俊不禁,但我发现这次会议非常有价值。花几个小时讨论 SME 们看到的所有可能性,使我和我团队获得了一个我们没有考虑过的视角,同时揭示了关于项目的关键需求,这些需求我们如果没有从团队那里听到,永远都不会猜到或假设。

在这些讨论中要确保避免的一件事就是讨论机器学习解决方案。记下笔记,以便你和 DS 团队成员可以稍后讨论。确保你不会将讨论偏离会议的主要目的(了解业务目前是如何解决问题的)是至关重要的。

接近这个主题的最简单方法之一,如以下侧边栏所示,是询问 SMEs 目前是如何解决这个问题。除非项目是一个完全的绿色田野月球射击项目,否则很可能有人以某种方式解决了这个问题。你应该和他们谈谈。这种方法论正是图 3.9 中提问和讨论的依据。

解释你是如何做的,这样我才能帮你自动化这个过程

虽然不是所有的机器学习都是直接替代人类进行的无聊、易出错或重复性工作,但我发现绝大多数都是。这些解决方案中的大多数要么是为了取代这种手工工作,要么至少在人们试图在没有算法帮助的情况下完成的工作上做得更全面。

对于我们一直在讨论的推荐引擎,业务一直在尝试进行个性化;这只是通过尽可能吸引尽可能多的人(或他们自己)在选择产品时进行个性化,以突出特征和展示。这适用于从供应链优化到销售预测的各种机器学习项目。在你即将面临的大多数项目中,可能有人在公司里正在尽最大努力完成同样的事情(尽管没有能够从我们的大脑在可接受的时间内识别的复杂关系中筛选数十亿数据点并得出优化解决方案的算法的好处)。

我一直觉得最好的办法是找到这些人,并问他们:“请教我你现在是如何做的。”真正令人震惊的是,几个小时听一个人解决这个问题的过程可以消除后续的浪费工作和返工。他们对你要建模的任务以及解决方案的整体要求的丰富知识将有助于不仅获得更准确的项目范围评估,而且确保你正在构建正确的东西。

我们将在本章后面更深入地讨论规划和定期创意会议的例子。

3.1.3 规划演示——大量的演示

在向业务展示他们的个性化解决方案时,ML 团队成员犯的另一个重大错误是只展示一次 MVP。也许他们的冲刺节奏是这样的,他们无法在方便的时候生成模型预测的构建,或者他们不想放慢向业务展示真正 MVP 的进度。无论原因如何,团队成员实际上在试图节省时间和精力时浪费了时间和精力。他们显然处于图 3.10 的上半部分。

03-10

图 3.10 反馈导向的演示密集型项目工作与仅关注内部开发的时间线比较。虽然演示需要时间和精力,但它们节省的返工是无价的。

在最佳情况下(频繁展示每个关键功能),演示后每个功能都可能需要进行一些返工。这不仅是可以预见的,而且与在真空环境中开发所需的返工相比,采用这种敏捷方法调整功能所需的时间要少,因为紧密耦合的依赖性更少。

尽管在机器学习团队内部使用了敏捷实践,但对于市场营销团队来说,最小可行产品(MVP)演示是他们两个月工作的两个月内看到的第一个演示。在这两个月的时间里,没有会议来展示实验的当前状态,也没有关于从建模工作中看到结果节奏的计划被传达。

在构建功能时没有频繁的演示,整个团队在项目机器学习方面只是在黑暗中操作。与此同时,机器学习团队错过了来自 SME 成员的宝贵时间节省反馈,这些成员能够停止功能开发并帮助完善解决方案。

对于大多数涉及足够复杂机器学习的项目,存在太多的细节和细微差别,以至于在没有经过审查的情况下自信地构建数十个功能是不可能的。即使机器学习团队展示了预测质量的指标,汇总排名统计,"最终证明"他们所构建的强大和质量,但只有机器学习团队关心这一点。为了有效地完成复杂项目,需要 SME 小组——市场营销团队——根据它可以消费的数据提供反馈。向该团队展示任意或复杂的指标几乎等同于故意模糊不清,这只会阻碍项目并抑制使项目成功的必要关键思想。

通过提前规划演示,在特定的节奏上,机器学习内部的敏捷开发过程可以适应业务专家的需求,以创建更相关和更成功的项目。机器学习团队成员可以采用真正的敏捷方法:在构建过程中测试和展示功能,调整未来的工作,并以高度有效的方式调整元素。他们可以帮助确保项目能够真正见光。

但是我不知道前端开发。我该如何构建一个演示?

我之前用过一句话。

如果你确实知道如何构建可以托管你的基于机器学习的演示的交互式轻量级应用,那真是太棒了。利用这些技能。但请不要在构建这部分上花费太多时间。尽可能保持简单,并将你的精力和时间集中在手头的机器学习问题上。

对于我们这些 99%的机器学习从业者来说,你不需要制作网站、应用程序或微服务来展示内容。如果你能制作幻灯片(注意,我并不是在问你是否愿意——我们都知道我们都讨厌制作幻灯片),那么你可以通过展示一个模拟来展示你的项目将如何工作,即展示最终用户将看到的内容。复制并粘贴图片。制作一个基本的线框图。任何可以近似展示给生成数据用户最终结果的东西都将是足够的。

如果你清楚地传达,UX 团队、前端开发者或应用程序设计师的最终设计将完全不同于你的演示,而你只是在这里展示数据,那么一个简单的幻灯片或 PDF 的平易近人布局就足够了。我可以向你保证,将主键数组或 matplotlib ROC 曲线下的面积转换为以可消化的方式讲述模型性能的故事,在涉及非技术受众的会议中总是效果更好。

3.1.4 通过构建解决方案进行实验:为了虚荣而浪费时间

回顾 ML 团队成员为网站个性化项目构建原型推荐引擎的不幸场景,他们的实验过程令人担忧,但不仅限于业务方面。在没有制定明确的计划来尝试什么以及他们将花费多少时间和精力在他们同意追求的不同解决方案上,大量的时间(和代码)被不必要地浪费了。

在他们的初次会议之后,他们作为一个团队独立行动,开始通过头脑风暴来开始他们的孤立式构思会议,讨论哪些算法可能最适合以隐式方式生成推荐。大约进行了 300 次左右的网络搜索后,他们制定了一个基本的计划,即进行三种主要方法的面对面比较:一个 ALS 模型、一个奇异值分解(SVD)模型和一个深度学习推荐模型。在了解满足项目最低要求所需的功能后,三个不同的团队开始以友好的竞争方式构建他们能构建的内容。

以这种方式进行实验的最大缺陷在于进行此类 bake-offs 所涉及的巨大范围和规模的无谓浪费。通过类似黑客马拉松的方法来处理复杂问题可能对某些人来说很有趣,而且从流程管理的角度来看,对团队领导来说也容易得多(你们都是自己一个人——无论谁赢,我们都跟着走!),但这是一种极其不负责任的方式来开发软件。

这种有缺陷的概念,即在实验中进行解决方案构建,与图 3.11 中展示的更有效(但有些人可能会认为不那么有趣)的原型实验方法形成对比。通过定期的演示,无论是内部对机器学习团队还是对外部跨职能团队,项目的实验阶段可以优化,以便有更多的手(和思想)专注于尽可能快地将项目做得尽可能成功。

03-11

图 3.11 比较了多-MVP 开发和实验淘汰开发(底部)。通过早期淘汰选项,团队可以完成更多的工作(质量更高,时间更短)。

如图 3.11 顶部所示,在没有计划进行原型淘汰的情况下处理模型烘焙问题存在两个主要风险。首先,在上部部分,团队 A 在整合业务指定的第一个关键特性时遇到了困难。

由于在模型工作初始公式化后没有进行评估,因此在构建支持需求的功能上花费了大量时间。在那之后,当转向第二个最关键的功能时,团队成员意识到他们没有足够的时间在演示会议中实现该功能,这实际上保证了所有投入到 SVD 模型中的工作都将付诸东流。

使用其他两种方法的团队,在原型实现方面都人手不足,无法完成第三个关键特性。结果,这三种方法都无法满足关键项目需求。由于项目的多学科性质,这种延误影响了其他工程团队。团队本应采取的做法是遵循图 3.11 底部原型实验部分的路径。

在这种方法中,团队在早期就与业务部门进行了会面,提前沟通说明关键特性不会在这个时间点实现。他们选择决定对每个正在测试的模型类型的原始输出进行决策。在决定专注于单一选项后,整个机器学习团队的资源和时间可以集中用于实现所需的最小特性(并在核心解决方案展示后增加一个检查演示,以确保他们处于正确的轨道上),并更快地达到原型评估阶段。

虽然特性尚未完全构建,但专注于早期和频繁的演示,有助于最大化利用人力资源,并从行业专家那里获得宝贵的反馈。最终,所有机器学习项目都是资源受限的。通过尽可能早地专注于最少且最有潜力的选项,即使是精简的资源集也能创造出成功的复杂机器学习解决方案。

3.2 实验范围:设定期望和边界

我们现在已经完成了推荐引擎的规划。我们有了对业务重要性的细节,我们理解用户在与我们的推荐互动时期望什么,并且我们为项目中的特定日期的演示里程碑制定了一个稳固的计划。现在,对我们大多数机器学习爱好者来说,是时候进行有趣的部分了。现在是规划我们的研究的时候了。

在我们指尖上有关于这个主题几乎无限的资料来源,而我们只有有限的时间去处理它们,我们真的应该制定关于我们将要测试的内容以及我们将如何进行测试的指南。这就是实验界定发挥作用的地方。

到目前为止,团队应该已经与 SME 团队成员进行了适当的发现会议,知道需要构建的关键特性:

  • 我们需要一种方法来去重我们的产品库存。

  • 我们需要将基于产品的规则纳入,以根据每个用户的隐含偏好进行加权。

  • 我们需要根据产品类别、品牌和特定页面类型对推荐进行分组,以便在网站和应用程序中满足不同的结构化元素。

  • 我们需要一个算法,它将生成用户到物品的亲和力,而不会花费大量资金来运行。

在列出 MVP 的绝对关键方面之后,团队可以开始规划解决这些四个关键任务所需的工作量估计。通过设定这些期望并为每个任务设定边界(无论是时间还是实施复杂度级别),机器学习团队可以提供业务寻求的一件事:预期的交付日期以及对可行性的判断

这可能对一些人来说有些矛盾。 “难道实验不是我们确定从机器学习角度如何界定项目范围的地方吗?” 这可能是你现在脑海中正在浮现的想法。在本节中,我们将讨论为什么,如果缺乏边界,解决这个推荐引擎问题的研究和实验可能会轻易地填满整个项目界定时间表。如果我们计划和界定我们的实验,我们将能够专注于找到,可能不是最好的解决方案,但希望是一个足够好的解决方案,以确保我们最终能够从我们的工作中构建出产品。

一旦完成初步规划阶段(这肯定不会只通过一次会议就能完成),并且对项目涉及的内容有一个大致的想法已经形成并记录下来,就不应该再谈论界定或估计实际解决方案实施所需的时间,至少最初不应该。界定非常重要,是设定整个项目团队期望的主要手段之一,但对于机器学习团队来说更是至关重要。然而,在机器学习的世界中(由于大多数解决方案的复杂性,这与其他类型的软件开发非常不同),需要发生两种不同的界定。

对于习惯与其他开发团队互动的人来说,实验范围的概念是完全陌生的,因此,对初始阶段范围估计的任何估计都将被误解。然而,考虑到这一点,没有为实验设定内部目标范围显然是不明智的。

3.2.1 什么是实验范围?

在开始估计项目将花费多长时间之前,你需要研究不仅其他人如何解决类似问题,而且从理论角度的潜在解决方案。就我们一直在讨论的情况而言,在初步项目规划和整体范围(需求收集)阶段,决定了一系列潜在的方法。当项目进入研究和实验阶段时,向更大的团队设定 DS 团队将花费多长时间审查每个想法的预期是绝对关键的

设定预期对 DS 团队有益。尽管为完全不可知的事物(即最佳解决方案)设定任意截止日期可能看似适得其反,但有一个目标截止日期可以帮助集中测试的通常无序过程。在其他情况下可能看起来值得探索的元素被忽略,并标记为“将在 MVP 开发期间研究”,随着即将到来的截止日期的临近。这种方法仅仅有助于集中工作。

这些期望同样有助于业务和参与项目的跨职能团队成员。他们不仅将获得一个更有可能最终成功的项目方向的决定,而且还将保证近期未来的进展。记住,沟通对于成功的机器学习项目工作至关重要,即使是为实验设定交付目标也将有助于继续让每个人参与过程。这只会使最终结果更好。

对于相对简单直接的人工智能应用案例(例如预测、异常检测、聚类和转化预测),分配给测试方法的时长应该相对较短。通常一到两周就足够探索标准人工智能的潜在解决方案;记住,这不是构建最小可行产品(MVP)的时间,而是要获得不同算法和方法有效性的总体印象。

对于更加复杂的应用案例,例如这种情况,可能需要更长的调查期。仅仅研究阶段可能就需要两周时间,再加上两周的“黑客”(粗略地编写测试 API、库和构建粗略的可视化)。

这些阶段唯一的目的是决定一条路径,但要在尽可能短的时间内做出决定。挑战在于平衡做出最佳裁决所需的时间与 MVP 交付的时间表。

没有一个标准的标准来决定这个时期应该有多长,因为它取决于问题、行业、数据、团队的经验以及每个被考虑的选项的相对复杂性。随着时间的推移,团队将获得智慧,这将使实验(黑客)估计更加准确。最重要的要点是要记住,这个阶段以及向业务单位传达所需时间的沟通决不能被忽视。

3.2.2 机器学习团队的实验范围:研究

所有机器学习实践者的内心都渴望实验、探索和学习新事物。在机器学习领域的深度和广度面前,我们可能一生只能学到所做工作的很小一部分,目前正在进行的研究,以及将作为解决复杂问题的新解决方案的工作。我们所有人共有的这种天生的欲望意味着,在研究新问题的解决方案时,设定时间和距离的界限是极其重要的。

在规划会议和项目范围规划的第一阶段之后,现在是时候开始做一些实际工作了。这个初始阶段,实验阶段,在各个项目和实施中可能会有很大的不同,但对于机器学习团队来说,它必须是时间限制的。这对我们中的许多人来说可能会感到非常沮丧。我们有时被迫进入“只管做出来”的情况,而不是从零开始研究一个新颖的解决方案,或者利用最近开发的新技术。为了满足这种时间紧迫的要求,一种很好的方法是限制机器学习团队研究解决方案可能性的时间。

对于本章中我们一直在讨论的推荐引擎项目,机器学习团队的研究路径可能看起来像图 3.12。

03-12

图 3.12 为机器学习团队确定要测试的潜在解决方案的研究规划阶段图。定义这样的结构化计划可以显著减少在迭代想法上花费的时间。

在这个简化的图中,有效的研发限制了团队可用的选项。经过几番初步的互联网搜索、博客阅读和白皮书咨询后,团队可以在一天左右的时间内确定行业和学术界现有解决方案的“大致轮廓”。

一旦确定了常见的做法(并由团队成员个别整理),就可以更深入地研究所有可能性的完整列表。一旦达到这种适用性和复杂性的水平,团队可以开会讨论其发现。

如图 3.12 所示,在呈现发现的过程中,候选的测试方法被筛选出来。在这个裁决阶段结束时,团队应该有一个坚实的计划,包括两到三个值得通过原型开发进行测试的选项。

注意该小组选择的方法的混合。在选择中存在足够的异质性,这将有助于后来基于最小可行产品(MVP)的决策(例如,如果所有三个选项都是深度学习方法的轻微变化,在某些情况下,将很难决定选择哪一个)。

另一个关键行动是缩减大量选项的列表,以帮助防止过度选择(一种几乎因为选项过多而使决策变得令人瘫痪的情况)或小决策的暴政(在连续做出许多看似微不足道的小选择后,可能导致不利的后果)。为了推动项目进展并在项目结束时创建一个可行的产品,最好限制实验的范围。

图 3.12 中的最终决策,基于团队的研究,是专注于三个独立的解决方案(一个具有复杂依赖关系):交替最小二乘法(ALS)、奇异值分解(SVD)和深度学习(DL)解决方案。一旦这些路径被确定,团队就可以开始尝试构建原型。就像研究阶段一样,实验阶段的时间限制允许完成的工作量有限,确保在实验结束时可以产生可衡量的结果。

3.2.3 机器学习团队的实验范围:实验

在制定计划后,机器学习团队负责人可以自由地分配资源到原型解决方案。一开始,明确实验的期望是很重要的。目标是产生最终产品的模拟,以便对正在考虑的解决方案进行无偏见的比较。不需要调整模型,也不需要编写代码,使其能够被考虑用于最终项目的代码库。这里的游戏规则是在两个主要目标之间取得平衡:速度和可比性。

在决定采取哪种方法时,需要考虑许多事情,这些内容在后续的几个章节中进行了详细讨论。但就目前而言,这一阶段的临界估计是关于解决方案的性能以及开发完整解决方案的难度。可以在这一阶段结束时创建总最终代码复杂性的估计,从而通知更大的团队预计的开发时间,以产生项目的代码库。除了与代码复杂度相关的承诺时间外,这还可以帮助了解解决方案的总拥有成本:重新训练模型、生成亲和力推断、托管数据和提供数据每日运行成本。

在通过编写故事和任务来规划将通过普遍接受的最佳方法(敏捷)完成的工作之前,为实验制定一个测试计划可能会有所帮助。这个计划,不包含技术实现细节和故事票据的冗长性质,这些票据将在测试阶段完成,可以用来不仅通知冲刺计划,还可以跟踪机器学习团队将要进行的 bake-off 状态。这个计划可以作为沟通工具与更大的团队共享和利用,有助于展示完成的任务和结果,并可以伴随两个(或更多!)正在追求的竞争性实现进行演示。

图 3.13 展示了推荐引擎实验阶段的分阶段测试计划。

03-13

图 3.13 为推荐引擎项目原型阶段的两个阶段的实验跟踪流程图

这些测试路径清楚地显示了研究阶段的结果。团队 1 的矩阵分解方法显示了一个需要手动生成(不是通过 ETL 作业为此测试阶段)的常见数据源。基于团队成员对这些算法的计算复杂性(以及数据本身的规模)的研究和理解,他们选择了 Apache Spark 来测试解决方案。在这个阶段,两个团队都必须将他们的努力分开来研究两个模型的 API,得出两个非常不同的结论。对于 ALS 实现,SparkML 中的高级 DataFrame API 实现使得代码架构比基于 RDD 的低级实现简单得多。团队可以在这次测试中定义这些复杂性,让更大的团队意识到 SVD 实现将显著更复杂,需要实施、维护、调整和扩展。

所有这些步骤对于团队 1 都有助于定义后续的开发范围。如果更大的团队整体决定 SVD 是他们用例的更好解决方案,他们应该权衡实施复杂性与团队的熟练程度。如果团队不熟悉编写利用 Breeze 的 Scala 实现,项目和团队预算是否有时间让团队成员学习这项技术?如果实验结果的质量显著高于其他正在测试的结果(或是对另一个更好的解决方案的依赖),更大的团队需要意识到交付项目所需的额外时间。

团队 2 的实现方式显著更复杂,需要输入 SVD 模型的推理。为了评估这种类型两种方法的结果,评估复杂性是很重要的。

评估复杂性风险

如果团队 2 的结果显著优于 SVD 本身的结果,团队应该仔细审查这种性质的复杂解决方案。审查的主要原因是在解决方案中增加的复杂性水平。这不仅将增加开发成本(在时间和金钱方面),而且维护这种架构将更加困难。

从增加的复杂性中获得的性能提升应该始终是如此显著的级别,以至于在如此改进面前,团队增加的成本是可以忽略不计的。如果明显的收益对每个人(包括商业)来说并不明显,那么应该进行一次关于基于简历的开发(RDD)和承担如此增加工作的动机的内部讨论。每个人只需要意识到他们将要进入什么,如果他们选择追求这种额外的复杂性,他们可能需要维护几年。

跟踪实验阶段

当讨论实验阶段时,提供给更大团队的另一个有用的可视化是,从机器学习的角度来看,解决方案的大致轮廓的粗略估计。复杂的架构图是不必要的,因为在开发的早期阶段,它将改变很多次,在这个项目阶段创建任何实质性的详细内容纯粹是浪费时间。

然而,一个高级图,如图 3.14 所示,它引用了我们的个性化推荐引擎,可以帮助向更大的团队解释需要构建什么来满足解决方案。这种视觉“工作架构”指南(在实际项目中会有更多细节)也可以帮助机器学习团队跟踪当前和即将到来的工作(作为敏捷看板的一个补充)。

03-14

图 3.14 情景项目的实验阶段高级架构

这些注释可以帮助与更大的团队进行沟通。而不是坐在可能包括十几个或更多人参加的状态会议上,像这样的工作图可以被机器学习团队用来与每个人高效地沟通。可以添加各种解释来回答关于团队在做什么以及为什么在特定时间的工作的问题,以及提供与按时间顺序聚焦的交付状态报告(对于一个如此复杂的项目,对于不参与项目的人来说可能很难阅读)的背景信息。

理解界定研究(实验)阶段的重要性

如果负责个性化项目的机器学习团队成员有无限的时间(以及无限的预算),他们可能有机会找到他们问题的最优解。他们可以筛选数百篇白皮书,阅读关于一种方法相对于另一种方法的益处的论文,甚至花时间寻找一种新颖的方法来解决他们业务视为理想解决方案的特定用例。不受发布日期或降低技术成本的约束,他们可以轻松地花费数月甚至数年,仅用于研究将个性化引入他们的网站和应用的最好方法。

而不是仅仅测试两三种在其他类似行业和用例中已被证明有效的方法,他们可以致力于构建数十种方法的原型,并通过仔细的比较和裁决,选择绝对最佳的方法来创建一个能够为用户提供最佳推荐的引擎。他们甚至可能提出一种新颖的方法,这可能会彻底改变问题空间。如果团队被允许自由测试他们想要的任何内容以构建这个个性化的推荐引擎,那么他们的想法白板可能看起来就像图 3.15 所示。

03-15

图 3.15 提出解决问题的潜在方法

在产生这些想法的头脑风暴会议(这与我与大型的雄心勃勃的数据科学团队进行的许多创意会议有惊人的相似之处)之后,团队应采取的下一步集体行动是开始对这些实现进行估算。在每个替代方案旁边附上注释可以帮助制定一个计划,在合理的实验时间内确定最有可能成功的两种或三种方案。图 3.16 中的评论可以帮助团队决定要测试哪些内容以满足将产品实际投入生产的需求。

03-16

图 3.16 在头脑风暴会议中评估和评级讨论的选项。这是在实验中相互测试两种或三种方法的有效方式。

在团队完成如图 3.16 所示的分配不同方法的风险的练习后,可以决定在测试分配的时间范围内最可能且风险最低的选项。评估和分类各种想法的主要重点是确保尝试可行的实现。为了满足项目的目标(准确性、实用性、成本、性能和业务问题解决的成功标准),追求能够实现所有这些目标的实验至关重要。

一些建议实验的目标是找到最有可能且最简单的方法来解决问题,而不是使用最技术复杂的解决方案。专注于解决问题而不是你打算用它来解决问题的工具,总是会提高成功的几率。

看一下图 3.17。这是两个在实验阶段工作的团队实验计划的基于时间的表示的略微修改过的转置。最需要注意的部分是顶部:时间尺度。

03-17

图 3.17 两个在项目实验阶段工作的团队的历时表示

这个关键因素——时间——是建立实验控制如此重要的一个元素。实验需要时间。构建概念验证(PoC)是一项艰苦的学习新 API、研究编写代码的新方法以支持模型应用,以及整合所有组件以确保至少有一次运行成功的努力。这可能需要惊人的努力(取决于问题)。

如果团队正在努力构建最佳可能的解决方案,那么这个时间尺度将比图 3.17 所示的时间长得多。公司根本不会感兴趣花费如此多的资源在尝试通过两个永远不会见天日的解决方案来实现完美。然而,通过限制总时间支出并接受实施策略的比较将远远不如完美,团队可以做出一个权衡预测质量与所选择方向的总拥有成本的明智决策。

总拥有成本

虽然在实验阶段对这种性质的项目维护成本的估计几乎不可能准确,但这是一个需要考虑的重要方面,并做出有根据的猜测。

在实验过程中,不可避免地会从业务的整体数据架构中缺失一些元素。可能需要创建服务来捕获数据。需要构建服务层。如果组织从未处理过围绕矩阵分解的建模,它可能需要使用之前从未使用过的平台。

如果实际上无法获取数据来满足项目需求怎么办?这是识别阻止性问题的时刻。识别它们,询问是否将提供解决方案来支持实施需求,如果没有,则提醒团队,如果没有投资来创建所需的数据,项目应该停止。

假设没有如此严重的问题,以下是一些在这个阶段当发现差距和关键问题时需要考虑的问题:

  • 我们需要构建哪些额外的 ETL?

  • 我们需要多频繁地重新训练模型和生成推理?

  • 我们将使用什么平台来运行这些模型?

  • 对于我们需要的平台和基础设施,我们是打算使用托管服务还是尝试自己运行?

  • 我们是否有运行和维护此类 ML 服务的专业知识?

  • 我们的服务层计划的成本是多少?

  • 存储的成本是多少,推理数据将存放在哪里以支持这个项目?

你不必在开发开始之前回答这些问题(除了与平台相关的问题),但它们应该始终作为在整个开发过程中重新审视的元素保持。如果你没有足够的预算来运行这些引擎之一,也许应该选择一个不同的项目。

我们为这些元素中的每一个都设定了时间块,还有一个最终原因:快速做出项目决策。这个概念可能对大多数数据科学家来说相当冒犯。毕竟,如果模型没有完全调优,一个人如何能够充分评估实施的成败?如果解决方案的所有组件都没有完成,一个人如何合法地声称其预测能力?

我明白了。真的,我明白了。我曾经在那里,提出过同样的论点。回顾过去,在我职业生涯的早期,当我忽视软件开发者给我的关于为什么长时间在多个前端运行测试是坏事的高见时,我意识到他们试图向我传达什么。

如果你早点做出决定,你花在其他事情上的所有工作都会投入到最终选择的实施中。

——许多优秀的工程师

即使我知道他们告诉我的事情是真实的,但听到这一点仍然有点令人沮丧,意识到我浪费了这么多时间和精力。

关于士气的轶事

时间块并不是为了给团队施加不切实际的压力,而是为了防止团队在货架软件上浪费时间和精力。限制在潜在解决方案上花费的时间也有助于提高团队对那些永远不会实现实施的士气——毕竟,你实际上只能为一个项目构建一个解决方案。

限制人们可以用于解决方案的时间量是有价值的,因为如果他们只工作了一周,那么放弃他们的工作就会痛苦得多。但如果他们已经工作了几个月,当他们被告知他们的解决方案不会被使用时,这会感觉相当令人沮丧。

在测试实施时,最毒害的事情之一是团队内部形成部落。每个团队都花费了大量时间研究其解决方案,并且可能被可能使其不适合作为解决问题途径的因素所蒙蔽。如果实验从原型验证(PoC)阶段发展到真正最小可行产品(MVP)阶段(坦白说,如果给予足够的时间,大多数机器学习团队都会构建一个 MVP 而不是一个 PoC),在决定使用哪个实现时,紧张关系就会出现。脾气会爆发,冰箱里的午餐会消失,站立会议期间会爆发争吵,团队的总体士气会受到影响。拯救你的团队,拯救你自己,并确保人们不要对 PoC 产生依恋。

如果项目完全新颖,时间块也非常关键。虽然拥有成熟机器学习(ML)存在的公司中,月亮射击项目可能并不常见,但一旦出现,限制早期阶段投入的时间就很重要。这些项目风险较大,有很高的可能性最终一无所获,并且可能最终在构建和维护上花费巨额费用。对于这些项目,快速失败和尽早失败总是最好的选择。

当任何人第一次接触一个对他们来说陌生的全新问题时,通常需要做大量的准备工作。研究阶段可能涉及大量的阅读、与同行交谈、搜索研究论文以及在入门指南中测试代码。如果解决问题的唯一可用工具是在特定平台上,使用团队中没有人使用过的语言,或者涉及团队中全新的系统设计(例如,分布式计算),这个问题会成倍增加。

在这种情况下,研究和工作负担的增加使得在研究和实验上设定时间限制变得更加重要。如果团队成员意识到他们需要掌握新技术来解决业务问题,这是可以的。然而,项目的实验阶段应该适应这一需求。如果发生这种情况,关键点是向业务领导沟通这一点,以便他们在项目工作开始之前理解范围的增加。这是一个风险(尽管我们都很聪明,可以快速学习新事物),他们应该以开放和诚实的态度意识到这一点。

唯一的例外是,如果可以采用简单且熟悉的方法,并在实验期间显示出有希望的结果,那么这个时间阻塞规则不适用。如果问题可以用熟悉且简单的方式解决,但新技术可能(也许)使项目变得更好,那么在团队熟悉新的语言或框架的同时,花费数月时间从失败中学习,在我看来,这是不道德的。最好在数据科学团队的时间表中为独立或基于团队的继续教育和个人项目工作腾出时间。在为商业执行项目时,不是学习新技术的时候,除非没有其他选择。

总之,这项工作会有多少工作量?

在实验阶段结束时,应该理解项目机器学习方面的概貌。重要的是要强调,他们应该被理解,但尚未实施

团队应该对需要开发以符合项目规格的功能有一个总体看法,以及需要定义和开发的所有额外 ETL 工作。团队成员应该就进入和离开模型的数据达成一致,如何增强这些数据,以及将用于满足这些需求的工具。

在这个阶段,可以开始识别风险因素。以下是最重要的两个问题:

  • 建造这个需要多长时间?

  • 运行这个需要多少成本?

这些问题应该是实验阶段和开发阶段之间的审查阶段的一部分。有一个粗略的估计可以通知更广泛的团队关于为什么应该选择一个解决方案而不是另一个解决方案的讨论。但机器学习团队应该单独决定使用哪种实现吗?任何团队的假设都存在固有的偏见,因此为了减轻这些因素,创建一个加权矩阵报告可能是有用的,这个报告可以让更大的团队(以及项目负责人)用来选择一个实现。

机器学习中的所有者偏见

我们都喜欢我们所建造的东西,尤其是如果它很聪明。然而,在实验阶段之后,一个项目可能遇到的最有害的事情之一就是仅仅因为自己建造了它,就紧紧抓住一些聪明而独特的东西。

如果团队中的其他人有一些具有相似预测质量但远不那么无聊或标准的解决方案,那么应该接受这个更好的选择。记住,团队中的每个人都将不得不维护这个解决方案,为其做出贡献,随着时间的推移对其进行改进,也许有一天将其升级以适应新的生态系统。如果这个聪明的定制解决方案过于复杂而难以维护,它可能会给团队带来沉重的负担。

这也是为什么我一直觉得请一个同行来起草比较分析是有用的。重要的是要找到一个熟悉评估不同方法成本和效益的人——一个有足够经验经历过维护脆弱方法困难时期的人。我通常找到一个直到目前为止未参与项目的人,以确保他们对决策没有偏见。他们的客观意见,没有偏见,可以帮助确保报告中的数据准确,以便更大的团队能够诚实地评估选项。

当我的复杂解决方案因为其复杂性而被摒弃时,我迅速地转向了其他方向。我总是对“酷”的解决方案被丢弃持开放态度,无论当时我多么想构建它。毕竟,团队、公司和项目比我的自尊心更重要。

图 3.18 展示了一个这样的加权矩阵报告示例(为了简洁而简化),以便让更大的团队积极参与。每个元素的评分由进行无偏见评估不同解决方案相对属性的专家评审员锁定,但权重在会议中可以自由修改。这样的工具帮助团队在考虑每个实现的多种权衡后做出数据驱动的选择。

03-18

图 3.18 评估推荐引擎三个测试实现实验结果、开发复杂性、拥有成本、维护解决方案的能力和比较开发时间的加权决策矩阵

如果这个矩阵是由从未构建过如此复杂系统的 ML 团队成员填充,他们可能会对预测质量给予很高的权重,而对其他方面则很少考虑。一个更有经验的 ML 工程师团队可能会过分强调可维护性和实施复杂性(任何经历过这些的人都不喜欢无休止的史诗级任务和周五凌晨 2 点的呼叫警报)。数据科学总监可能只关心运行成本,而项目负责人可能只对预测质量感兴趣。

需要记住的重要一点是,这是一个平衡行为。随着更多对项目有利益关系的成员聚集在一起辩论和解释他们的观点,可以做出更明智的决定,这有助于确保解决方案的成功和长期运行。

最后,正如俗话所说,天下没有免费的午餐。需要做出妥协,并且这些妥协应该由更大的团队、团队领导和将整体实施这些解决方案的工程师达成一致。

摘要

  • 在项目初期专注于如何最好地解决特定问题,可以带来巨大的成功。收集关键需求,评估方法时不引入技术复杂性或实现细节,并确保与业务的沟通清晰,有助于避免许多需要后期返工的陷阱。

  • 使用敏捷方法中的研究和实验原则,ML 项目可以显著缩短评估方法和确定项目可行性的时间,从而更快地完成。

4 在建模之前:项目和后勤的沟通

本章涵盖

  • 为机器学习项目工作安排规划会议

  • 从跨职能团队征求反馈以确保项目健康

  • 进行研究、实验和原型设计以最小化风险

  • 在项目早期就包含业务规则逻辑

  • 使用沟通策略来吸引非技术团队成员

在我多年的数据科学家工作经历中,我发现 DS 团队在将他们的想法和实施应用于公司时面临的最大挑战,其根源在于沟通效果不佳。这并不是说我们作为职业,在沟通方面做得不好。

更多的原因是,为了在与公司内部客户(业务单元或跨职能团队)打交道时有效,我们需要使用不同于团队内部使用的不同形式的沟通。以下是我看到 DS 团队在与客户讨论项目时遇到的一些最大问题(以及我个人遇到的):

  • 知道在何时提出哪些问题

  • 将沟通策略有针对性地集中在关键细节上,忽略与项目工作无关的细微错误

  • 用通俗易懂的语言讨论项目细节、解决方案和结果

  • 将讨论集中在问题上而不是解决方案的运作上

由于这个领域非常专业化,没有普遍的平民指南可以像其他软件工程领域那样提炼我们的工作。因此,需要额外的努力。从某种意义上说,我们需要学习一种将我们所做的工作翻译成另一种语言的方法,以便与业务进行有意义的对话。

我们还需要作为机器学习从业者,努力提高质量沟通实践。处理那些不可避免地会让人感到沮丧和困惑的复杂主题,需要一定的同理心沟通。

与愤怒或沮丧的人进行艰难的对话

在我目前的工作中,我与很多人进行了很多艰难的对话。有时人们会感到沮丧,因为解决方案没有进展。有时,人们会愤怒,因为解决方案不可解释。在罕见的情况下,人们坚决反对使用机器学习解决方案,因为他们认为这将取代他们的工作。

在每一次这些艰难的对话之后,不可避免地有人后来找我,询问如何在会议中做我刚才做的事情。在过去几年里,我对这个问题感到困惑。对我来说,这个问题几乎没有意义。毕竟,我所做的就是倾听投诉,进行专注于他们关注的开放讨论,并就如何前进达成共识。然而,如今,我认为我知道为什么人们会问这个问题。

作为高度专业领域的专家,数据科学实践者很容易忽视普通人知道或不知道的东西。随着人工智能越来越成为今天时代精神的一部分,这种情况在行业中正在逐渐改变。但这并不意味着你与之交谈的每个人都会理解你的解决方案能做什么和不能做什么。

我对那些问我如何管理困难讨论的人给出的答案很简单:就是多听少说。不要对业务单位说教。倾听他们的担忧,并以他们理解的方式清晰地沟通。最重要的是,要诚实。不要承诺那些超出你执行能力的神奇解决方案或交付日期。他们会欣赏被倾听和进行真诚的讨论。真正倾听他们的不满,具有同理心的心态可以帮助比我所知的任何其他方法更好地降低敌对讨论的级别。

图 4.1 展示了我一直认为效果很好的一个通用对话路径,我们将在本章中应用这个路径。

04-01

图 4.1 在第一次规划会议期间与业务单位讨论的关键问题,以及将告知如何构建、何时构建和成功标准的至关重要答案

通过使用清晰、直接、以结果为导向的沟通风格,可以更紧密地将项目结果与业务对工作的期望相一致。以这个为主要目标的针对性讨论有助于定义“要构建什么,如何构建,何时完成,以及成功标准是什么”。这实际上是为项目的每个后续阶段,包括在生产中切换到开启状态所概述的整个食谱。

4.1 沟通:定义问题

如第三章所述,我们将继续讨论我们的数据科学团队被分配构建的产品推荐系统。我们已经看到了规划项目和为最小可行产品(MVP)设定范围的有效和无效方法的对比,但我们还没有看到团队是如何达到创建一个有效项目计划并合理设定项目范围的这个点的。

如我们在第 3.1 节中讨论的,第一个例子会议围绕高度抽象的目标展开。业务希望其网站实现个性化。DS 团队在那个对话中的第一个错误是没有继续追问。最重要的问题从未被问过:“你为什么想构建一个个性化服务?”

大多数人,尤其是技术人员(可能是在讨论这个初步项目提案和头脑风暴会议的房间里的人中的绝大多数),更喜欢关注项目的“如何”。我该如何构建这个?系统将如何与这些数据进行集成?我需要多久运行一次我的代码来解决需求?

对于我们的推荐引擎项目,如果有人提出这个问题,它将开启关于需要构建什么、预期的功能应该是什么、项目对业务的重要性如何以及业务希望何时开始测试解决方案的开放而坦率的对话。一旦收到这些关键答案,就可以进行所有围绕后勤的细节工作。

在这些启动会议中需要记住的重要事项是,当双方——客户和解决方案的供应商——都得到他们所需的东西时,这些会议是有效的。DS 团队得到其研究、范围和计划细节。业务得到对即将进行的工作的审查日程。业务获得对项目成功至关重要的包容性,这将在项目期间安排的各种演示和创意会议中得到体现(关于这些演示边界的更多内容请参阅 4.1.2 节)。如果没有如图 4.1 所示的有指导和富有成效的对话,会议中的相关人员可能会陷入图 4.2 所示的思维模式。

通过将会议聚焦于共同目标,可以协作地引导图 4.2 中每个角色的个人责任和期望,以定义项目和确保其成功。

04-02

图 4.2 无指导和有指导规划会议的比较

与集体现讨论项目的关键原则的其他主要好处是帮助定义解决问题的最简单可能方案。通过获得业务部门的认可、来自领域专家的反馈以及来自同行软件工程师的输入,最终解决方案可以量身定制以满足确切需求。它还可以在后续阶段适应新的功能,而不会给更大的团队带来挫败感。毕竟,从一开始大家就一起讨论了项目。

机器学习开发的重要原则:始终构建尽可能简单的解决方案来解决问题。记住,你必须维护这个系统并随着时间推移不断改进以满足不断变化的需求。

4.1.1 理解问题

在我们的场景中,规划会议的无指导性质导致 DS 团队成员对要构建什么没有明确的方向。在没有业务对期望的最终状态进行任何实际定义的情况下,他们把精力集中在构建他们能够通过评分算法证明的每个用户的最佳推荐集合上。他们所做的是实际上错过了重点。

核心问题是沟通的根本破裂。没有询问业务部门希望从他们的工作中得到什么,他们错过了对业务部门(以及外部的“真实”客户)意义最大的细节。你总是想避免这种情况。这种沟通和规划的破裂可以以多种方式表现出来,从缓慢、沸腾的被动攻击到项目结束时直接的大喊大叫(通常是单方面的)。

我们这里的问题是沟通失败

在我作为开发者、数据科学家、架构师或顾问参与的众多机器学习项目中,所有未能成功投入生产的项目中,一个始终如一、普遍的主题就是缺乏沟通。这并不是指工程团队中的沟通失败(尽管在我的职业生涯中,我已经见证了许多我不太喜欢的情况)。

最糟糕的沟通破裂发生在数据科学团队和请求解决方案的业务部门之间。无论是漫长的、缓慢的、拖延的沟通熵,还是完全拒绝使用所有各方都能理解的共同对话形式,当客户(内部)没有被开发者听取时,结果总是相同的。

项目最终发布到生产阶段时,缺乏沟通对项目所有参与者来说变得明显,这时问题尤为突出。最终用户在使用预测结果时得出结论,不仅预测模型输出的结果似乎有些不对劲,而且问题基本上是根本性的。

沟通破裂不仅限于生产发布。它们通常在解决方案的开发过程中缓慢发生,或者在用户验收测试期间发生。各方都做出了假设;想法要么没有说出来,要么被忽视,在全体团队会议期间,评论被驳回,要么被认为是不相关的,要么简单地被认为是浪费时间。

几乎没有什么比团队之间由于沟通破裂而导致的项目失败更让人无限沮丧的了,但这种失败完全可以避免。这些失败,导致时间和资源的巨大浪费,可以追溯到项目的早期阶段——在写下第一行代码之前——当问题的范围和定义发生时。这些失败完全可以通过有意识和坚定的计划来预防,确保在项目的每个阶段都保持开放和包容的对话,从最初的构思和头脑风暴会议开始。

你希望它做什么?

对于这个建议的“什么”比“如何”对团队中的每个人来说都更为重要。通过关注项目目标的职能(即“它将做什么?”这个问题),产品团队可以参与讨论。前端开发者也可以做出贡献。整个团队可以审视一个复杂主题,并计划看似无限的边缘情况和商业的细微差别,这些都需要在构建最终项目和最小可行产品(MVP)时考虑。

为构建这个个性化解决方案的团队解决这些复杂主题的最简单方法是通过使用模拟和流程路径模型。这些模型可以帮助确定整个团队对项目的期望,以便随后通知数据科学团队所需了解的细节,以便限制构建解决方案的选项。

你是什么意思,你不在乎我的困境?

是的,我的机器学习同行们,我可以承认:如何是复杂的,涉及项目的大部分工作,并且极具挑战性。然而,如何是我们被付薪去解决的问题。对我们中的某些人来说,这正是我们选择这个职业的原因。关于“如何解决问题”的问题占据了我们在交谈时很多专注于极客的讨论。这是有趣的事情,它很复杂,学习起来也很有趣。

但团队的其他成员并不关心将要使用哪种建模方法;请相信我,即使他们假装感兴趣,他们提出问题的唯一目的就是让他们看起来关心——他们并不真的关心。如果你想进行有意义的、协作的和包容性的会议,请将这些细节排除在小组讨论之外。只有当讨论保持这种欢迎的团队合作氛围时,你才会得到见解、创意想法,以及识别出看似无害但需要处理的细节,以便使项目尽可能成功。

对于正在从事这个项目的团队来说,通过借鉴前端软件开发者的最佳实践来通过这次对话是最好的方式。在切分任何功能分支之前,在将任何 Jira 任务分配给开发者之前,前端开发团队会利用线框图来模拟最终状态。

对于我们的推荐引擎,图 4.3 展示了在网站上应用个性化功能后,用户旅程可能的高层次流程路径的初始样子。绘制这样的简单架构用户导向旅程可以帮助整个团队思考所有这些移动部件将如何工作。这个过程也将讨论向非技术团队成员开放,其方式远比查看他们不熟悉的代码片段、键值集合和以高度复杂表示的准确性指标要简单得多。

04-03

图 4.3 展示了个性化推荐引擎的简化、基本概述,有助于规划个性化项目的需求和功能。这是启动构思会议的核心、最小功能。

注意:即使您不打算生成与网站的用户界面功能(或任何需要与外部服务集成的 ML)交互的预测,在规划阶段明确项目的最终状态流程也是极其有用的。这并不意味着要为业务单元构建完整的架构图进行分享,但一个展示项目各部分如何交互以及最终输出如何被利用的线图可以是一个很好的沟通工具。

像这样的图表有助于与更广泛的团队进行规划讨论。将您的架构图、建模讨论和对推荐系统评分指标适用性的辩论保留在 DS 团队内部讨论中。从用户的角度提出潜在解决方案不仅使整个团队能够讨论重要方面,而且也使非技术团队成员能够对直接影响实验和实际代码生产开发的考虑因素有所见解。

由于该图非常简单,便于看到系统的基本功能,同时隐藏了特别是预计算推荐部分中包含的复杂性,因此讨论可以从房间里每个人都被吸引并能够贡献定义项目初始状态的想法开始。例如,图 4.4 显示了在更广泛的团队中举行初步会议时可能产生的结果,讨论在一个彻底的构思会议中可以构建的内容。

将图 4.4 与图 4.3 进行比较,显示了项目构思的演变。重要的是要考虑,如果没有产品团队和 SMEs 参与讨论,DS 团队可能不会考虑许多提出的思想。将实施细节排除在讨论之外,使每个人都能继续关注最大的问题:“我们为什么要讨论构建这个,它应该如何为最终用户工作?”

什么是用户体验之旅?

从商业对消费者(B2C)行业的项目管理领域借鉴而来,用户体验之旅(或旅程图)是对产品的模拟,探索特定用户将如何消费新功能或系统。它类似于一种地图,从用户最初与您的系统交互(例如图 4.4 中的登录)开始,然后跟随他们在系统元素的用户界面交互。

我发现这些不仅对电子商务和基于应用的实现有用,这些实现通过摄入机器学习来提供功能,而且对于设计面向内部系统的设计也非常有帮助。最终,你希望你的预测被某人或某物使用。很多时候,绘制一个图来展示那个人、系统或下游过程将如何与您产生的数据进行交互,这可以

帮助设计机器学习解决方案,以最好地满足客户的需求。映射过程可以帮助找到需要告知设计的信息,不仅包括服务层,还包括在解决方案开发过程中可能需要考虑作为关键功能的元素。

04-04

图 4.4:在跨职能团队内的包容性构思会议后,对核心最小功能所做的添加

在图 4.4 中,注意标记为希望拥有的四个项目。这是初始规划会议的一个重要且具有挑战性的方面。所有参与的人都希望头脑风暴并努力为问题提供最佳解决方案。DS 团队应欢迎所有这些想法,但在讨论中添加一个警告,即每个添加都与成本相关

应真诚关注项目的关键方面(最小可行产品,MVP)。追求 MVP 确保首先只构建最关键的部分。另一个要求是,在包含任何附加功能之前,它们必须正确运行。辅助项目应标注为辅助项目;想法应记录、可引用、修改,并在项目的实验和开发阶段被引用。曾经可能看似难以逾越的困难,随着代码库的形成可能会变得微不足道,甚至值得将这些功能包括在 MVP 中。

唯一糟糕的想法是那些被忽视的想法。不要忽视想法,但也不允许每个想法都进入核心实验计划。如果想法看起来遥不可及且极其复杂,简单地在项目成形后重新审视它,在项目复杂性被更深入地了解时,可以考虑实施的可能性。

将工程元素排除在构思会议之外

在我的职业生涯中,我曾参与过许多规划会议。它们通常分为三类。图 4.2 和图 4.3 中的示例代表了我所见到的并取得最大成功的规划事件。

其中最无用的(那些导致后续会议、线下讨论和混乱的结果)是那些完全关注项目中的机器学习(ML)方面,或者关注使系统工作所需的工程考虑的。

如果模型是主要关注点,那么小组中的许多人可能会感到完全陌生(他们没有知识或参考框架来参与算法讨论)或者烦恼到他们从对话中退出。在这个时候,这仅仅是一群数据科学家在争论他们是否应该使用 ALS 或深度学习来生成原始推荐分数,以及如何将历史信息融合到预测结果中。在市场营销团队面前讨论这些事情是没有意义的。

如果工程方面是重点,那么图表将不再是用户体验流程路径的图表,而是一个架构图,这将使完全不同的一群人感到疏远。工程和建模讨论都是重要的,但它们可以在更广泛的团队之外进行,并且可以在实验完成后迭代式地开发。

在走过这个用户体验工作流程时,可能会发现团队成员对于这些引擎中某一个的工作方式存在相互冲突的假设。市场营销团队认为,如果用户点击了某个东西,但没有将其添加到购物车中,我们可以推断用户不喜欢这个产品。这些团队成员不希望用户再次在推荐中看到这个产品。

这将如何改变 MVP 的实施细节?架构将不得不改变。

现在比在模型构建之前更容易发现这一点,并且能够在规划阶段为这个特性分配范围化的复杂性;否则,更改必须被猴子补丁到现有的代码库和架构中。定义的功能架构也可能,如图 4.4 所示,开始增加对引擎的整体视图:它将要支持什么,以及什么将不会得到支持。功能架构设计将允许数据科学团队、前端团队和设计团队开始关注他们各自需要研究和实验以证明或反驳将要构建的原型。记住,所有这些讨论都是在在写下一行代码之前发生的。

提出简单的问题“这个应该如何工作?”并避免专注于标准的算法实现,这是一种习惯,比任何技术、平台或算法更能帮助确保机器学习项目成功。这个问题可以说是确保所有参与项目的人都在同一页上讨论的最重要问题之一。我建议在必要的提问过程中提出这个问题,以挖掘出需要调查和实验的核心功能。如果对核心需求存在困惑或缺乏具体理论,那么在早期阶段花几个小时开会规划,尽可能多地梳理所有业务细节,要比浪费几个月的时间和精力去构建不符合项目发起人愿景的东西要好得多。

理想的最终状态是什么样的?

在一开始(尤其是在进行任何实验之前)很难定义理想的实现方式,但对于实验团队来说,了解理想状态的各个方面非常有用。在这些开放式、意识流的讨论中,大多数机器学习实践者的一个倾向是立即根据那些不了解机器学习的人的想法来判断什么是可能的,什么是不可能的。我的建议是简单地倾听。不要立即关闭一个对话线程,认为它超出了范围或不可能,让对话继续进行。

在这个创意构思阶段,你可能会发现一条你原本可能错过的替代路径。你可能会发现一个更简单、不那么独特且更易于维护的机器学习解决方案,而不是你独自想出来的。我多年来参与的最成功的项目都来自于与广泛的专家(以及当我幸运的时候,实际的用户)进行这类创意讨论,这让我能够将我的思维方式转向尽可能接近他们愿景的创造性方式。

讨论理想的最终状态不仅对更出色的机器学习解决方案有益。让请求构建项目的个人参与进来,他们的观点、想法和创造力可以以积极的方式影响项目。这种讨论还有助于建立信任和项目开发中的所有权感,这有助于团结团队。

学习仔细倾听机器学习项目客户的需要是机器学习工程师最重要的技能之一——比掌握任何算法、语言或平台都重要。这将有助于指导你将要尝试什么,将要研究什么,以及如何以不同的方式思考问题,以提出尽可能最佳的解决方案。

在图 4.4 所示的场景中,初步规划会议的结果是一个理想状态的草图。根据我的经验,这很可能不是最终的引擎(那绝对不是一种可能的情况)。但这个图将指导如何将这些功能模块转换为系统。它将帮助确定实验的方向,以及你需要和团队深入研究以最大限度地减少或防止意外范围蔓延的项目领域,如图 4.5 所示。

04-05

图 4.5 恐怖的机器学习项目范围蔓延。在规划初期就要明确这一点是不可容忍的,你也就不必为此担心。

图 4.5 对于任何曾在初创公司工作过的人来说都应该是熟悉的。那些想要做些非凡事情的驱动和有创造力的人所激发的兴奋和想法是具有感染力的,并且经过适当的调整,可以创建一个真正革命性的公司,该公司在其核心使命上做得非常出色。然而,如果没有这种调整和专注,尤其是对于一个机器学习项目来说,解决方案的规模和复杂性可能会迅速失控。

注意:在我的职业生涯中,我从未允许任何项目达到图 4.5 中展示的那种荒谬程度(尽管有几个项目几乎做到了)。然而,在几乎所有我参与过的项目中,都曾出现过这样的评论、想法和问题。我的建议是:感谢提出想法的人,用非技术性的语言温和地解释目前无法实现,然后继续完成项目。

范围蔓延:几乎肯定会导致项目失败的杀手

不当规划(或未涉及为项目构建的团队进行的规划)是项目缓慢、痛苦死亡的最令人沮丧的方式之一。也被称为“千次请求导致的机器学习死亡”,这个概念在开发的后期阶段出现,尤其是在向对项目构建细节不知情的小组展示演示时。如果客户(内部业务单元)没有参与规划讨论,他们不可避免地会有问题,其中许多问题都是关于演示做了什么。

在我看到的几乎所有情况下(或在我早期试图“英雄式”通过项目而不寻求输入时造成的),演示会议的结果将是数十个要求添加额外功能和需求。这是预期的(即使在设计合理和计划得当的项目中也是如此),但如果实施无法轻松包括与不可变业务操作“法则”相关的关键功能,可能需要完全重新实施项目。这留给决策者一个困难的抉择:是否因为 DS 团队(或个人)的决定而推迟项目,或者完全放弃项目以防止初始失败的重演。

在机器学习的世界里,没有什么比在生产环境中发布后立即收到强烈的负面反馈更令人沮丧的了。收到来自高层管理人员的大量邮件,指出你刚刚发布的解决方案建议给狗主人买猫玩具,这简直是可笑,但如果是向儿童推荐成人主题产品,那几乎是最糟糕的情况。更糟糕的是,在项目即将发布之前,在用户验收测试(UAT)期间意识到需要做出无法克服的一系列更改以满足业务的紧急需求,而且从头开始重新启动项目所需的时间比修改现有解决方案的时间要少。

识别范围蔓延很重要,但其程度可以最小化,在某些情况下甚至可以消除。需要达到适当的讨论水平,并在将单个字符输入实验笔记本或 IDE 之前,将项目的关键方面以有时令人痛苦且详尽的递归和痛苦细节包括在内。

谁是这个项目的支持者,我可以与之合作构建这些实验?

我合作过的任何团队中最有价值的成员都是 SME——被分配与我或我的团队合作以检查我们的工作、回答我们所有的愚蠢问题,并提供有创意的想法,帮助项目以我们都没有预见到的方式发展。虽然通常不是技术人员,但 SME 对这个问题的联系和知识非常深入。花点额外的时间将工程和机器学习的世界翻译成外行术语总是值得的,主要是因为它创造了一个包容的环境,使 SME 能够投资于项目的成功,因为他们看到他们的意见和想法正在被考虑和实施。

我必须强调,你绝对不希望担任这个角色的人是实际的高层项目所有者。虽然一开始可能看起来合乎逻辑,认为能够要求经理、总监或副总裁批准想法和实验会更容易,但我可以向你保证,这只会使项目停滞不前。这些人非常忙碌,正在处理他们委托给其他人的数十个其他重要且耗时的工作。期望这个人——他可能或可能不是项目所涉及领域的专家——提供关于细节的广泛和深入讨论(毕竟,所有机器学习解决方案都是关于细节的),这可能会使项目处于风险之中。在第一次启动会议上,确保有一个来自团队的资源,他是领域专家,并且有时间、可用性和权限来处理这个项目和在整个过程中需要做出的关键决策。

我们何时应该开会分享进度?

由于大多数机器学习项目(尤其是那些需要作为推荐引擎与业务多个部分接口的项目)的复杂性质,会议至关重要。然而,并非所有会议都同等重要。

虽然人们想要按照每周规定的固定时间进行节奏会议非常有吸引力,但项目会议应与项目相关的里程碑相一致。这些基于项目的里程碑会议应该

  • 不要替代每日站立会议

  • 不要与各部门的团队会议重叠

  • 总是应该有整个团队在场进行

  • 总要有项目负责人在场,以便对有争议的话题做出最终决定

  • 应专注于展示该点当前的状态解决方案,而不要涉及其他内容

虽然出发点良好但具有毒性的外部想法

讨论发生在这些结构化展示和数据聚焦会议之外非常有吸引力。也许你的团队中不参与项目的人好奇,并希望提供反馈和额外的头脑风暴会议。同样,与更大团队中的小组成员讨论你卡住的问题可能也很方便。

我无法强调得更多,这些团队外的讨论可能会造成多大的干扰。在大型项目中(甚至在实验阶段),团队成员做出的任何决定都应该被视为神圣不可侵犯的。涉及外部声音和“试图帮助”的人会侵蚀共同建立的全员沟通环境。

外部构思通常也会给项目引入一种难以管理的不可控的混乱,这对于所有参与实施的人来说都是困难的。例如,如果 DS 团队在没有通知和讨论的情况下决定改变预测的交付方式(例如,重用带有附加有效载荷数据的 REST 端点),这将影响整个项目。尽管这可能通过不必创建另一个 REST 端点而节省 DS 团队一周的工作量,但对于前端工程师正在进行的任何工作来说,这将是一场灾难。这可能导致前端团队重做数周的工作。

在没有通知和讨论的情况下引入变更,可能会浪费大量时间和资源,这反过来又削弱了团队和整个业务对流程的信心。这是一种让项目成为积压品或在不同业务单元的微观群体中引入隔阂行为的极其有效的方法。

在早期会议中,DS 团队向小组传达这些基于事件的会议需求是至关重要的。你希望让每个人都知道,对其他团队来说可能微不足道的变更,可能与其相关的重做风险,这可能导致 DS 团队额外工作数周或数月。同样,DS 变更可能对其他团队产生重大影响。

04-06

图 4.6 跨职能团队项目时间线图表。注意每个演示和讨论的频率和成员要求(大多数都是针对整个团队)。

为了说明项目的相互关联性以及不同的交付如何影响项目,让我们来看看图 4.6。这张图表显示了在一个相对较大的公司(比如说有超过 1,000 名员工)中,这种解决方案将是什么样子,其中角色和责任在各个小组之间分配。在一个较小的公司(例如,一家初创公司)中,许多这些责任将落在前端或 DS 团队,而不是单独的 DE 团队。

图 4.6 展示了来自机器学习实验的依赖关系如何影响 DE 和前端开发团队的未来工作。增加过多的延迟或需要重做不仅会导致 DS 团队重做其代码,还可能导致整个工程组织浪费数周的工作。这就是为什么规划、频繁展示项目状态以及与相关团队的开放讨论如此关键。

但它什么时候会完成?

诚实总是最好的政策。我见过很多 DS 团队认为在项目规划期间承诺少、交付多是一种明智的做法。这不是一个明智的选择。

许多时候,给予项目一些灵活性以保护项目开发过程中出现的不可预见复杂性的政策被采用。但将这些因素纳入估计的交付日期并不会对团队有任何好处。这是不诚实的,可能会侵蚀业务对团队的信任。更好的做法是向每个人坦诚。让他们知道机器学习项目中有许多未知因素。

这种做法唯一会导致的结果是让内部业务部门客户感到沮丧和愤怒。他们不会喜欢不断地比承诺的时间提前几周得到结果,并且会很快发现你的行为。信任很重要

这枚事实遗漏的另一面与在交付中设定不切实际的期望有关。通过没有告诉业务在项目工作的许多阶段事情可能会出错,并为迭代设计设定激进的交付日期,每个人都将期望在那个日期交付一些有用的东西。未能解释这些是一般目标,可能需要稍作调整,意味着唯一适应不可预见复杂性的方法是通过迫使 DS 团队长时间且艰苦地工作以达到那些目标。

唯一可以保证的是:团队疲惫不堪。如果团队因为努力满足不合理的要求而完全失去动力和疲惫不堪,解决方案永远不会很好。细节会被遗漏,代码中会滋生错误,一旦解决方案投入生产,团队中最优秀的成员将更新他们的简历以寻找更好的工作。

图 4.7 展示了与一般电子商务机器学习项目相关的里程碑的高级甘特图,仅关注主要概念。使用此类图表作为常见的集中沟通工具可以大大提高所有团队的效率,并减少跨学科团队中的一些混乱,尤其是在部门壁垒之间。

04-07

图 4.7 工程和 DS 的高级项目时间线

正如图 4.7 顶部的时间里程碑箭头所示,在关键阶段,整个团队应该聚在一起,确保所有团队成员都理解所开发和发现的内容的影响,以便他们可以集体调整自己的项目工作。我所合作的多数团队都会在这些会议和他们的冲刺计划在同一天举行,这值得注意。

这些断点允许展示演示,探索基本功能,并识别风险。这个常见的沟通点有两个主要目的:

  • 最小化浪费在返工上的时间

  • 确保项目按照既定目标顺利进行

虽然为每个项目创建甘特图并不是绝对必要的,但至少创建一些可以跟踪进度和里程碑的东西是明智的。对于由单个机器学习工程师负责整个项目的独立项目来说,五彩斑斓的图表和多学科的系统开发跟踪显然没有意义。但即使你可能是在键盘上敲击的唯一一个人,弄清楚项目开发中的主要边界在哪里,并安排一些展示和说明,也可以非常有帮助。

你是否有一个经过调整的模型生成的演示测试集,你想要确保它解决了问题?在那个点上设置一个边界,生成数据,以可消费的形式展示,并展示给请求你帮助解决问题的小组。及时获得——正确的反馈——可以为你和你的客户节省大量的挫败感。

4.1.2 设置关键讨论边界

接下来需要问的问题是,“我在我的项目中设置这些边界在哪里?”每个项目在解决问题的所需工作量、参与解决方案的人数以及实施过程中的技术风险方面都是完全独特的。

但一些一般性指南对于设置所需的最小会议数量是有帮助的。在我们计划构建的推荐引擎的范围内,我们需要设定某种形式的日程,表明我们将何时开会,我们将讨论什么,我们可以期待从这些会议中得到什么,最重要的是,如何让所有参与项目的人的积极参与有助于最小化及时交付解决方案的风险。

让我们暂时想象一下,这是公司首次处理涉及机器学习的大型项目。这是这么多开发者、工程师、产品经理和行业专家首次共同工作,他们都没有关于何时开会和讨论项目的想法。你意识到会议确实需要发生,因为你已经在规划阶段确定了这一点。你只是不知道何时开会。

在每个团队内部,成员们对能够交付解决方案的节奏有稳固的理解——假设他们正在使用某种形式的敏捷开发,他们很可能会进行敏捷的 scrum 会议和每日站立会议。但没有人真正清楚其他团队的开发阶段是什么样的。

自然地,这个简单答案对所有相关人员来说都是令人沮丧的:“让我们每周三下午一点开会。”建立一个“定期安排的会议”通常会导致团队没有太多可讨论、演示或审查的内容。没有明确的议程,会议的重要性和有效性可能会受到质疑,导致在需要审查关键事项时人们未能出席。

我发现最好的政策是设定具有可审查具体成果的交付日期会议,制定明确的议程,并期望每位与会者做出贡献。这样,每个人都会意识到会议的重要性,每个人的声音和意见都会被听到,并且尽可能尊重宝贵的时间资源。

关于 DS 团队的无意义会议的注意事项

当有有趣的事情在进行时,每个人都想与 DS 团队交谈。这可能是因为对项目进展的一般兴奋,或者是因为商业领导者只是害怕您会在一个项目上完全爆发监狱暴乱,以囚犯管理疯人院的方式开发(希望这不是真的)。

想要开会讨论项目状态是有可理解的原因的。(好吧,如果您的公司担心您会完全变成牛仔/女孩,您可以通过一些成功交付的项目来及时缓解他们的担忧。)然而,举行许多除了陈述自上次会议以来没有变化的进度报告之外几乎没有其他目的的会议,对团队是有害的。

我强烈建议在项目开始时就传达这一概念:为了达到每个演示和演示的预定交付目标,团队需要被大部分放手去完成其工作。面对面进行的问题、思考和有益的对话是受欢迎的(并且是敏捷的基石)。但是,状态会议、进度报告和重复的繁琐计算没有任何作用,应该立即从团队的负担中消除。

这可能是一次困难的对话,尤其是如果公司因为机器学习的创新性而对其持谨慎态度。但应该提出这个问题,以便清楚地传达为什么按时交付可交付成果会带来伤害,而不是帮助。

更合理、有用和高效地利用每个人的时间,是在需要审查新内容时才召开会议来审查正在进行的解决方案。但是,这些决策点何时出现?我们如何定义这些边界,以便在讨论项目元素的需求与因过于频繁的工作干扰会议而产生的疲惫之间取得平衡?

这取决于项目、团队和公司。我想要表达的观点是,每种情况都是不同的。关于会议频率、会议议程以及应参与人员等期望的讨论,简单来说,必须发生,以帮助控制可能出现的混乱,并防止进度偏离解决问题的方向。

研究阶段后的讨论(更新会议)

为了在我们的场景中举例,让我们假设 DS 团队确定必须构建两个模型以满足规划阶段用户旅程模拟的要求。基于团队成员的研究,他们决定想要将协同过滤和频繁模式增长(FP-growth)市场篮子分析算法与深度学习实现进行对比,以查看哪个提供了更高的准确性和更低的拥有成本,用于重新训练。

DS 负责人指派两组数据科学家和机器学习工程师来工作这些竞争性实现。两组都在相同的合成客户数据集上生成模型结果的模拟,向显示这些推荐的实际网站页面的线框提供模拟产品图像。

这次会议不应关注任何实施细节。相反,它应专注于研究阶段的结果:对那些已阅读、研究和尝试过的几乎无限的选择进行筛选。团队发现了很多很好的想法,以及基于现有数据的更大一群潜在解决方案,这些方案可能不会奏效,并将优秀想法的列表缩减为两个实施方案的较量。不要提出你探索过的所有选项。不要提及那些结果惊人但可能需要两年时间来构建的事情。相反,将讨论浓缩到启动下一阶段所需的核心理念:实验。

向 SMEs 展示这两个选项,仅限于展示每个算法解决方案可以做什么,一个或两个算法无法做到什么,以及 SMEs 何时可以期望看到原型以决定他们更喜欢哪一个。如果预测质量没有明显的差异,那么选择哪个的决定应基于方法的缺点,将技术复杂度或实施细节排除在讨论之外。

在这些密集的会议中,讨论应集中在听众能够理解和与之相关联的语言和参考资料上。你可以在脑海中完成翻译并保留在那里。技术细节应由 DS 团队、架构师和工程管理内部讨论。

在我参与的许多案例中,实验测试阶段可能会测试出十几个想法,但只向业务单位展示两个最可接受的想法进行审查。如果实施过于繁重、成本高昂或复杂,最好提出能够最大程度保证项目成功的选项——即使它们不如其他解决方案那样花哨或令人兴奋。记住:DS 团队必须维护解决方案,而在实验中听起来非常酷的东西可能会变成维护的噩梦。

实验后阶段(SME/UAT 审查)

在实验阶段之后,DS 小组内的子团队为推荐引擎构建了两个原型。在之前的里程碑会议上,讨论了这两个选项,以观众能够理解的方式展示了它们的优缺点。现在,是时候把预测卡片摆到桌面上,展示解决方案的原型看起来是什么样子。

在之前对潜在解决方案的审查中,展示了一些相当粗略的预测。具有不同产品 ID 的重复产品并排摆放,为某些用户生成了无限的产品类型列表(没有人会那么喜欢腰带),并且列出了演示中的关键问题以供考虑。在这些最初的早期预原型中,业务逻辑和功能需求尚未构建,因为这些元素直接依赖于模型平台和技术选择。

完成实验阶段的演示目标应该是展示核心功能的原型。可能需要根据相关性对元素进行排序。特殊考虑可能需要根据价格点、基于最近非会话的历史浏览和某些客户对某些品牌有隐含忠诚度的理论来推荐项目。每个这些达成一致的功能都应该向整个团队展示。然而,完整的实现不应该在这个阶段完成,而只是模拟以展示最终设计系统的样子。

这次会议的结果应该与初始规划会议的结果相似:可以将未被识别为重要的额外功能添加到开发计划中,如果发现任何原始功能是不必要的,它们应该从计划中移除。回顾原始计划,更新的用户体验可能看起来像图 4.8 所示。

04-08

图 4.8 实验结果审查后推荐引擎的最终线框设计

实验阶段结束后,DS 团队可以解释说,早期阶段中那些“想要有”的元素不仅可行,而且可以在不进行大量额外工作的前提下进行整合。图 4.8 展示了这些想法(市场篮子分析、动态过滤和聚合过滤)的整合,同时也保留了一个“想要有”的想法。如果在开发过程中发现整合这个功能是可行的,它就会被保留在这个活生生的规划文档中。

这个阶段会议最重要的部分是确保团队中的每个人(从将处理事件数据传递到服务器进行过滤的前端开发者到产品团队)都了解涉及的元素和移动部件。会议确保团队了解哪些元素需要界定范围,以及需要为冲刺规划创建的一般史诗和故事。对实施进行协作估计至关重要。

开发冲刺审查(面向非技术受众的进度报告)

举行非工程重点的定期会议不仅有助于将信息从开发团队传递到业务部门,还可以作为项目状态的晴雨表,并帮助指示何时可以开始不同系统的集成。尽管如此,这些会议仍然应该是以项目为重点的高层次讨论。

许多跨职能团队在类似项目上的诱惑是将这些更新会议变成一个超级回顾会议或超级冲刺规划会议。虽然这样的讨论可能是有用的(尤其是在各个工程部门之间的集成目的上),但这些话题应该留给工程团队的会议。

全团队进度报告会议应努力展示到那个点的当前状态进度演示。应展示解决方案的模拟,以确保业务团队和领域专家可以对工程师在项目工作中可能忽略的细节提供相关反馈。这些定期会议(无论是每个冲刺还是每两个冲刺一次)可以帮助防止上述可怕的范围蔓延和最后一刻发现一个未被视为必要的关键组件缺失,导致项目交付严重延迟。

MVP 审查(完整的演示与 UAT)

代码 完成 对不同的组织可能意味着不同的事情。一般来说,它被广泛接受为一个状态,即

  • 代码已经经过测试(并且通过了单元/集成测试)。

  • 系统作为一个整体在评估环境中使用生产规模的数据(模型已在生产数据上训练)运行。

  • 所有计划好的、达成一致的功能都已完成,并且按设计运行。

尽管如此,这并不意味着解决方案的主观质量已经达到要求。这个阶段仅仅意味着系统会为这个推荐引擎示例将推荐传递给页面上的正确元素。MVP 审查以及为准备这次会议而进行的关联 UAT 是这个阶段进行主观质量评估的阶段。

这对我们推荐引擎意味着什么?这意味着 SMEs 登录到 UAT 环境并浏览网站。他们根据自己的偏好查看推荐内容,并对所见做出判断。这也意味着模拟了高价值账户,确保 SMEs 通过这些客户的视角看到的推荐与他们对这类用户的了解相一致。

对于许多机器学习实现,指标是一个非常好的工具(并且当然应该被大量利用和记录,用于所有建模)。但确定解决方案是否在质量上解决问题的关键,是使用内部用户和专家的广泛知识,他们在系统部署给最终用户之前就可以使用该系统。

在评估历时数月开发的解决方案的 UAT 反馈的会议中,我曾看到业务团队和 DS 团队之间就某个特定模型的验证指标更高,但定性审查质量却远低于相反情况而爆发争论。这正是为什么这次会议如此关键的原因。它可能会揭露在规划阶段、实验阶段以及开发阶段都未注意到的明显问题。对解决方案结果的最终检查只能使最终结果更好。

关于这次会议和审查期间对质量估计的注意事项,有一点至关重要:几乎每个项目都伴随着大量创造者偏见。在创造某物时,尤其是具有足够挑战性的令人兴奋的系统,创造者可能会因为对它的熟悉和喜爱而忽略和错过重要的缺陷。

父母永远无法看到自己孩子有多么丑陋或愚蠢。无条件地爱你所创造的一切是人的天性。

——每一位理性的父母,都是如此。

如果在这些审查会议结束时,唯一的回应是对解决方案的压倒性正面赞誉,那么团队应该有所担忧。创建一个由共享项目所有权集体感的跨职能团队的一个副作用是,对项目的情感偏见可能会模糊对其有效性的判断。

如果你曾参加过关于解决方案质量的总结会议,却听不到任何问题,那么你和你所在的项目团队最好邀请公司中与此项目无关的其他人参与。他们无偏见且客观的视角可能会带来可操作的改进或修改,这些是团队在近乎家庭般热爱项目的偏见下可能会完全忽略的。

预生产审查(最终演示与 UAT)

最后的预生产审查会议就在“启动时间”之前。最终修改已完成,UAT(用户接受测试)开发完成的测试反馈已处理,系统已经连续几天没有出现故障。

发布计划在接下来的周一(提示:永远不要在周五发布),需要对系统进行最终检查。系统负载测试已完成,通过模拟高峰流量 10 倍的用户量来衡量响应性,日志记录正在工作,并且在对合成用户行为进行模型重新训练后,模型已适应模拟数据。从工程角度来看,所有测试都已通过。

那么我们为什么又要开会呢?

—所有被无数会议累垮的人

在发布前的这次最终会议应该回顾与原始计划的比较,被拒绝的超出范围的功能,以及任何新增内容。这有助于了解发布后应查询的分析数据。用于收集推荐项交互数据的系统已经构建,并创建了一个 A/B 测试数据集,允许分析师检查项目的性能。

这次最终会议应关注数据集将位于何处,工程师如何查询它,以及哪些图表和报告将可供非技术团队成员使用(以及如何访问它们)。这个新引擎在业务部分运行的前几个小时、几天和几周将受到大量的审查。为了保持分析师和 DS 团队的精神健康,确保人们可以自助访问项目的指标和统计数据,这将确保公司中每个人,即使那些没有参与创建解决方案的人,也能做出基于关键数据的决策。

关于耐心的注意事项

对于电子商务公司来说,发布一个像推荐引擎这样对业务有重大影响的 ML 项目是令人恐惧的。业务领导者将想知道今天的数字是昨天的。嘿,他们可能还想知道明天的销售额将如何,就像昨天一样。在这种期待和恐惧的水平上,传达分析结果耐心的重要性是重要的。重要的是提醒人们要呼吸。

许多潜在因素都可能影响项目感知的成功或失败,其中一些可能在设计团队的控制范围内,而另一些则完全超出控制,完全未知。由于这些潜在因素众多,对设计有效性的任何判断都需要推迟,直到收集到足够关于解决方案性能的数据,以便进行统计上有效的裁决。

等待,尤其是对于一个投入了如此多时间和精力的团队来说,看到项目转向生产使用是一个挑战。人们会不断检查状态,以实验室老鼠按杠杆获取奶酪的速度和猛烈程度,追踪广泛的聚合和趋势中的互动结果。

对于 DS 团队来说,为负责此项目的决策者提供统计分析的速成课程是最有益的。对于一个如推荐引擎这样的项目,以相对较高的水平解释诸如方差分析(ANOVA)、复杂系统中的自由度、最近、频率、货币(RFM)群体分析以及置信区间等主题(主要关注分析在短时间内将有多大的信心——好吧,具体来说,就是分析将有多大的不确定性),将有助于这些人做出明智的决策。根据用户数量、你服务的平台数量以及客户到达你网站的频率,可能需要几天或几周的时间才能收集足够的数据,以便对项目对公司的影响做出明智的决策。

同时,努力安抚担忧,抑制对看到销售大幅增长可能与项目直接相关的期望。只有通过仔细和负责任的数据分析,任何人才能知道新功能可能带来的参与度和收入提升。

4.2 不要浪费我们的时间:与跨职能团队会议

在第三章讨论项目的规划和实验阶段时,指出要记住的最重要方面之一(除了机器学习工作本身之外)是那些阶段的沟通。收到的反馈和评估可以是一个无价的工具,以确保最小可行产品(MVP)按时交付,并且尽可能正确,以便整个开发工作可以继续进行。

让我们再次查看图 4.7 中的甘特图,以跟踪每个团队在整个阶段中的工作的高层次进展。然而,出于沟通的目的,我们只关注图 4.9 中显示的上部部分。

04-09

图 4.9 项目中关键会议边界的翻译

根据要构建的项目类型,可能在整个阶段以及发布后几个月的后续会议中会有无数更多的会议(包括审查指标、统计数据和解决方案弹性的估计)。例如,即使开发阶段需要九个月,每周两次的进度报告会议也只是对上一个冲刺中成就进展的重复讨论。我们将在下一节详细分解这些阶段。

4.2.1 实验更新会议:我们在这里知道我们在做什么吗?

实验更新会议是 DS 团队最害怕的会议,而其他所有人都对此会议充满期待。这个会议在 DS 团队进行半成品原型实现和未完成研究的过程中打断他们。元素处于流动状态,几乎达到最大熵。

然而,这个会议可能是项目中第二重要的会议。这是团队成员最后一次优雅地举起白旗表示投降的时候,如果他们发现项目不可行,最终会花费比团队分配的时间和经济资源更多,或者其复杂性如此之大,以至于在接下来的 50 年内无法发明出满足要求的技术。这是一个诚实和反思的时刻。如果情况需要,这是一个放下自我,承认失败的时刻。

在这次讨论中占主导地位的压倒性问题应该是,“我们实际上能否解决这个问题?”在这个阶段,关于项目的任何其他讨论或想法都是完全不相关的。DS 团队的任务是报告其发现的状态(例如,不涉及特定模型的具体细节或他们将为测试采用的其他算法)。这次会议最关键的讨论点应该是以下内容:

  • 原型开发的进展如何?

    • 你是否已经弄清楚你正在测试的任何事物?

    • 哪一个看起来目前最有希望?

    • 你打算停止追求你计划测试的任何东西吗?

    • 我们是否按计划在预定截止日期前完成原型?

  • 你迄今为止发现了哪些风险?

    • DE 团队需要了解的数据存在哪些挑战?

    • 我们是否需要团队不熟悉的新技术、平台或工具?

    • 就目前而言,你是否觉得这是一个我们可以解决的问题?

除了这些直接问题外,目前实际上没有太多其他可以讨论的内容,除了浪费 DS 团队的时间。这些问题都是为了评估这个项目在人员、技术、平台和成本方面的可行性。

举起白旗:在承认失败可以接受的情况下

很少有严肃的 DS 人士愿意承认失败。对于一个刚从博士项目中毕业的人来说,这个项目可能持续数月甚至数年,承认问题无法解决的想法永远不会进入他们的脑海。这对他们来说也是好事,因为这些人正是发明新算法的人!(注意:他们得到了公司的批准来做这件事,而不仅仅是为了新颖而选择以新颖的方式解决问题。)

然而,在为公司开发机器学习(ML)解决方案时,问题不在于“这个问题对我们来说是否可以解决”,而在于我们是否能在足够短的时间内创造出解决方案,以免浪费过多的金钱和资源。急于找到解决方案的愿望可能会模糊即使是最高技能的 ML 实践者的能力评估。

在积累了足够多的通过维护脆弱或不稳定解决方案的斗争经验后,人们会获得一定的自制力。有了这样的认识,解决“所有问题”的欲望可以被抑制,即解决方案可能并不适合这个特定的项目、公司或参与维护该解决方案的团队。毕竟,并非每个项目、团队或公司都需要解决最苛刻和复杂的问题。每个人都有自己的极限。我可以向你保证,即使那面失败的白色旗帜被举起,也还有足够多的 DS 项目等待团队和公司在接下来的几个世纪里去完成。

越早意识到这一点越好。如前所述,随着时间和精力投入的增加,创作者放弃其作品的意愿只会增长。如果你能尽早叫停一个项目(并且希望能够认识到这不值得继续追求的迹象),你将能够转向更有价值的事情,而不是盲目地通过那些最终只会带来挫败感、遗憾以及完全失去对团队——在最坏的情况下,对贵公司机器学习(ML)的信心——的解决方案。

假设本次会议的所有答案都是积极的,工作应正式开始。(希望不会进一步干扰 DS 团队成员的工作,以便他们能够满足下一个截止日期并在下次会议上进行展示。)

4.2.2 SME 审查/原型审查:我们能解决这个问题吗?

早期会议中最重要的莫过于SME 审查,你真的不希望错过这个环节。这是资源承诺发生的时候。这是最终决定这个项目是否会发生,或者是否会被放入待办事项列表中,直到解决一个更简单的问题。

在这次审查会议中,应该提出与之前 SME 团队会议中相同的问题。唯一的修改是,它们应该根据现在对工作范围有更全面的了解来调整,以回答是否具备开发完整解决方案的能力、预算和意愿。

这场讨论的主要焦点通常是模拟的原型。对于我们的推荐引擎,原型可能看起来像是带有叠加的产品图像和标签的网站合成线框。为了这些演示的目的,使用真实数据总是有帮助的。如果你向一组行业专家展示推荐演示,展示他们的数据。展示他们的账户推荐(当然是在他们的同意下!)并评估他们的反应。记录他们给出的每一个正面——更重要的是,每一个负面——印象。

如果它真的很糟糕怎么办?

根据项目、涉及的模型以及处理机器学习任务的总体方法,对原型的主观评价“糟糕”可能是微不足道的(适当调整模型,增加特征集等)或者完全不可能(没有数据来增加额外的特征请求,数据不够细粒度以解决问题,或者提高预测以满足团队需求可能需要大量的魔法,因为目前还没有解决该问题的技术)。

快速提炼出任何已识别问题发生的原因至关重要。如果原因明显且众所周知,可以通过 DS 团队进行修改,那么只需这样回答即可。“别担心,我们会调整预测,这样你就不会看到一双又一双的凉鞋并排出现”是完全可行的。但如果问题性质极其复杂,例如“我真的不想看到波希米亚长裙旁边是朋克风格的鞋子”(希望你在会议期间能快速搜索这些术语的含义),那么回应应该是深思熟虑地向对方表达,或者记录下来进行额外的研究,并设定时间和努力的上限。

在下一个合适的机会,回应可能是这样的:“我们调查了这个问题,因为我们没有数据表明这些鞋子的风格,我们不得不构建一个 CNN 模型,训练它来识别风格,并创建数百万个标签来识别我们产品目录中的这些风格。这可能会花费几年时间来构建。”或者“我们调查了这个问题,因为我们有每个产品的标签,我们可以轻松地按风格类型分组推荐,给你更多关于产品混合的灵活性。”

在原型评审会议之前,确保你知道什么可行什么不可行。如果你遇到一个不确定的请求,请使用机器学习的八句黄金法则:“我不知道,但我去查一下。”

在演示结束时,整个团队应该能够判断项目是否值得继续推进。你希望看到的是共识,即推荐的方法是每个人都(无论他们是否了解其工作原理)都舒适地认为是一个合适的方向,项目即将采取的方向。

一致性并非绝对关键。但如果每个人的担忧都得到了解决,并且进行了无偏见和理性的讨论以缓解他们的恐惧,团队将更加团结。

4.2.3 开发进度审查:这个玩意儿能行吗?

开发进度审查是开发过程中“纠正航向”的机会。团队应该关注里程碑,例如展示正在开发的功能当前状态的里程碑。在实验审查阶段使用的相同线框方法是有用的,同样,使用相同的原型数据,以便整个团队能够看到早期阶段的直接比较。为 SME 提供共同的参考框架有助于他们从他们完全理解的角度评估解决方案的主观质量。

在这些会议的前几场应该是实际开发的审查。虽然细节不应该涉及到软件开发的具体方面、模型调整或实施的详细技术,但应该以抽象术语讨论功能开发的总体进度。

如果在之前的会议中,预测的质量被判定为在某些方面不足,应该展示更新和修复的演示,以确保问题得到了 SME 小组的满意解决。仅仅声称“功能已完成并已检查到 master”是不够的。相反,应该证明这一点。用他们最初识别问题的相同数据向他们展示修复。

随着项目不断推进,这些会议应该变得越来越短,并且更加专注于集成方面。当推荐项目的最终会议到来时,SME 小组应该正在查看一个在 QA 环境中的网站的实际演示。推荐应该按照计划通过导航进行更新,并且应该检查在不同平台上的功能验证。在这些后期阶段,复杂性增加时,将项目的 QA 版本构建推送给 SME 团队成员,以便他们可以在自己的时间内评估解决方案,并在定期安排的节奏会议上向团队提供反馈,这可能是有帮助的。

预想不到的变化:欢迎来到机器学习的世界

说大多数机器学习项目很复杂,这只是一个悲哀的轻描淡写。一些实现,比如推荐引擎,可能是公司中最复杂的代码库之一。抛开建模,这可能相对复杂,预测的相关规则、条件和用法可能复杂到足以几乎保证在最彻底的计划阶段也会遗漏或忽视。

机器学习项目的有时不稳定但通常可互换的本质意味着事情会发生变化。这是可以接受的。将敏捷方法应用于机器学习应该允许变化尽可能小地干扰工作(和代码)。

可能数据不存在或成本太高,无法在构建到那个阶段的基础上解决特定问题。通过一些方法上的改变,解决方案可以实现,但代价是增加解决方案的复杂性或成本。这既是幸运的也是不幸的(取决于需要改变什么),这是机器学习的一部分。

明白事物会变化的重要一点是,当出现阻碍时,应清楚地通知所有需要了解变化的人。这是否影响服务层的 API 合约?那就与前端团队沟通;不要召开全员会议来讨论技术细节。这是否影响过滤性别特定推荐的能力?这对 SMEs 来说是个大问题,并且讨论解决方案可能会让团队中的每一个聪明头脑一起解决问题和探索替代方案。

当问题出现(它们会出现),只需确保你不是在进行“忍者解决”。不要默默地对看似可行的解决方案进行黑客攻击,并且不告诉任何人。你后来可能会创建未预见的问题的机会非常高,解决方案的影响应该由更大的团队进行审查。

4.2.4 MVP 审查:我们要求的功能是否已经构建完成?

到你进行MVP 审查的时候,每个人都应该既兴奋又对项目感到相当疲惫。这是最后阶段;内部工程审查已经完成,系统运行正常,集成测试全部通过,在大规模突发流量下已经测试了延迟,所有参与开发的人都准备好度假了。

我看到团队和公司在这个阶段直接将解决方案发布到生产环境的次数令人震惊。每次发生这种情况,他们都会后悔。在构建并达成一致意见的 MVP 之后,接下来的几个迭代应该专注于代码加固(创建可测试、可监控、可记录且经过仔细审查的生产就绪代码——我们将在本书的第二部分和第三部分中讨论所有这些主题)。

成功的发布涉及在工程 QA 阶段完成后,解决方案进行 UAT 的阶段。这个阶段旨在衡量解决方案的主观质量,而不是可以计算(预测质量的统计指标)或由对项目有情感投入的 SME 团队进行的带有偏见的客观质量衡量。

UAT 阶段是极好的。正是在这个时候,解决方案以来自项目外一组人的反馈的形式首次亮相。这组新鲜、无偏见的眼光可以看到拟议的解决方案本身,而不是构建它所投入的辛勤工作和情感。

虽然项目中的所有其他工作都通过工作/不工作的布尔尺度进行有效衡量,但机器学习方面是一个根据预测的最终消费者的解释而变化的质量滑动尺度。对于像推荐的相关性这样的主观性内容,这个尺度可以非常广泛。为了收集相关数据以创建调整,一种有效的技术是调查(尤其是对于像推荐这样的主观性项目)。基于有效质量的有控制测试和数字排名的反馈可以允许在分析响应时实现标准化,从而对需要添加到引擎或需要修改的设置中的任何额外元素提供一个广泛的估计。

评估和指标收集的关键方面是确保评估解决方案的成员在以任何方式参与创建它,或者他们不了解引擎的内部工作原理。对引擎任何方面的功能有先知可能会污染结果,并且如果任何项目团队成员被包括在评估中,审查数据将立即受到怀疑。

在评估 UAT 结果时,使用适当的统计方法对数据进行标准化非常重要。分数,尤其是那些在大数值范围内的分数,需要在每个用户提供分数的范围内进行标准化,以考虑到大多数人都有(有些人倾向于给出最高或最低分数,其他人则围绕平均值波动,还有一些人在他们的评价分数中过于积极)。一旦标准化,就可以评估和排名每个问题的相对重要性以及它对模型整体预测质量的影响,并确定实施的可行性。如果时间足够,变化是合理的,并且实施风险足够低,不需要额外的完整 UAT 轮次,这些变化可以实施,以在发布时创建最佳可能的解决方案。

如果你从未在 UAT 审查会议中找到任何问题,那么要么是你所在的团队运气好得令人难以置信,要么是评估者完全失去了判断力。这在小型公司中相当常见,几乎每个人都完全了解并支持项目(伴随着不健康的确认偏差)。在这种情况下,引入外部人员来验证解决方案可能是有帮助的(前提是项目不是像欺诈检测模型或其他极端敏感的东西)。

许多在为外部客户构建解决方案方面取得成功的公司通常会进行 alpha 或 beta 测试新功能,正是为了这个目的:从对其产品和平台有投资的客户那里获取高质量反馈。为什么不使用你最热情的最终用户(无论是内部还是外部)来提供反馈呢?毕竟,他们将是使用你正在构建的东西的人。

4.2.5 预生产审查:我们真心希望没有搞砸这件事

项目即将结束。最终功能已根据 UAT(用户验收测试)反馈添加,开发已完成,代码已加固,QA(质量保证)检查全部通过,解决方案已在一周多的压力测试环境中稳定运行,没有任何问题。性能收集指标已设置,分析报告数据集也已创建,准备填充以衡量项目的成功。最后要做的就是将其部署到生产环境。

最好是再开一次会,但不是为了自我庆祝(不过,稍后作为跨职能团队,一定要庆祝一下)。这次最终的预生产审查会议应该结构化为基于项目的回顾和分析功能。会议上的每个人,无论专业领域和贡献水平如何,都应该问同样的问题:“我们是否构建了我们最初设定的东西?”

要回答这个问题,应该将原始计划与最终设计解决方案进行比较。原始设计中每个功能都应该经过检查并验证其在 QA(测试)环境中是否能够实时运行。在切换页面时,项目是否会过滤掉?如果连续添加多个项目到购物车中,是否所有相关产品都会被过滤,或者只是最后一个?如果从购物车中移除项目,产品是否仍然从推荐中移除?如果用户浏览网站并添加了一千个产品到购物车然后又全部移除,会发生什么?

希望到这一点之前,所有这些场景都已经进行了测试,但与整个团队一起进行这项重要练习,以确保功能最终被确认已正确实现,这是一个重要的步骤。在此之后,就无法回头了;一旦发布到生产环境中,它就掌握在客户手中,无论好坏。我们将在后面的章节中讨论如何处理生产中的问题,但就目前而言,想想如果发布了一个根本性的错误,会对项目的声誉造成多大的损害。正是在这个最后的预生产会议中,可以在不可逆转的生产发布之前计划关注点和最后的修复。

4.3 对实验设置限制

我们已经为推荐引擎项目付出了巨大的努力,准备到了这一点。我们已经参加了会议,表达过担忧和风险,制定了设计计划,并根据研究阶段,我们有一套明确的模型要尝试。现在是时候来点爵士乐,发挥创意,看看我们能否做出一些不是完全垃圾的东西。

然而,在我们过于兴奋之前,重要的是要意识到,就像 ML 项目工作的所有其他方面一样,我们应该适度行事,并且我们做的事情背后要有深思熟虑的目的。这一点在实验阶段比项目的任何其他方面都更为重要——主要是因为这是少数几个完全隔离的阶段之一。

如果我们拥有世界上所有的时间和资源,我们会对这个个性化的推荐引擎做些什么呢?我们会研究最新的白皮书并尝试实施一个完全新颖的解决方案吗?(这取决于你的行业和公司。)我们会考虑构建一个广泛的推荐模型集合,以涵盖我们所有的想法吗?(让我们基于客户的生命周期价值分数、倾向性和一般的产品组亲和力,为每个客户群体构建一个协同过滤模型,然后将其与 FP-growth 市场篮子模型合并,以填充某些用户的稀疏预测。)也许我们会构建一个图嵌入到深度学习模型中,以找到产品和用户行为之间的关系,从而可能创造出最复杂和最准确的预测。

所有这些想法都很不错,如果我们的公司整个目的就是向人类推荐物品,那么它们都可能是值得的。然而,在大多数公司最紧缺的资源——时间上,这些想法都是非常昂贵的。

我们需要理解时间是一种有限的资源,业务单位请求解决方案的耐心也是如此。正如我们在 3.2.2 节中讨论的,实验的范围直接与可用资源相关:团队中数据科学家的数量,我们将尝试比较的选项数量,以及,最重要的是,我们完成这项工作的时间。我们知道时间和开发者的限制有限,因此我们需要控制的最终限制是,在 MVP 阶段只能构建这么多内容。

想要完全构建出心中所想并看到它按设计工作是完全有诱惑力的。这对于帮助自身生产力的内部工具或 DS 团队内部的内部项目来说效果很好。但几乎每个机器学习工程师或数据科学家在其职业生涯中将要从事的其他所有工作都有客户方面,无论是内部还是外部。这意味着将有人依赖你的工作来解决一个问题。他们可能对解决方案的需求有细微的理解,这可能与你的假设不一致。

不仅如前所述,将它们纳入项目与目标对齐的过程中是极其重要的,而且在没有获取他们对所建内容有效性的意见的情况下,完全构建一个紧密耦合且复杂的解决方案可能存在潜在危险。解决这一问题,即让专家参与过程的方法,是在将要测试的原型周围设定边界。

4.3.1 设定时间限制

可能最简单的方法是通过在初始原型上花费过多时间和精力来拖延或取消一个项目。这可能出于任何数量的原因,但我发现,其中大多数是由于团队内部沟通不畅,非 DS 团队成员对 ML 过程的工作方式(通过测试进行细化,其中包含适量的试验、错误和重工作)的错误假设,或者经验不足的 DS 团队认为他们需要在任何人看到他们的原型之前有一个“完美”的解决方案。

防止这种混淆和完全浪费时间最好的方法是为围绕想法验证的实验设定时间限制。由于这种限制的本质,它将消除这一阶段所编写的代码量。项目团队的所有成员都应该清楚,在规划阶段表达的大多数想法在验证阶段都不会被实施;相反,为了做出关于采用哪种实现方式的关键决策,项目应该仅测试最基本的部分。

图 4.10 显示了实现实验阶段目标所需的最小化实现量。在此阶段,任何额外的工作都不符合当前的需求:决定一个在大规模和成本上都能良好工作,并且符合客观和主观质量标准的算法。

04-10

图 4.10 为测试想法的团队映射高级实验阶段

相比之下,图 4.11 展示了基于规划会议初始计划的某些潜在核心特征的简化视图。

04-11

图 4.11 通过有效的实验和从更大团队获得反馈,实现开发阶段涉及到的扩展功能的伪架构计划

通过比较图 4.10 和图 4.11,应该很容易想象从第一个计划到第二个计划的过渡中工作范围的扩大。需要构建全新的模型,需要进行大量的动态运行特定聚合和过滤,必须整合自定义权重,并且可能需要生成数十个额外的数据集。这些元素中的任何一个都不能解决实验边界上的核心问题:我们应该选择哪个模型进行开发?

限制做出这个决定的时间将防止(或至少最小化)大多数机器学习从业者想要构建解决方案的自然倾向,无论已经制定出的计划如何。有时强迫完成更少的工作对于减少流失和确保正在工作的正确元素是有好处的。

关于实验代码质量的说明

实验代码应该有点“粗糙”。它应该是脚本化的,注释掉的,丑陋的,几乎不可测试的。它应该是一个脚本,里面充满了图表、图形、打印语句和所有种类的糟糕编码实践。

毕竟,这是一个实验。如果你正在遵循紧张的进度表来做出实验性的决策,你很可能没有时间去创建类、方法、接口、枚举器、工厂构建模式、配置传递等等。你将使用高级 API、声明性脚本和静态数据集。

不要担心实验结束时代码的状态。它应该作为开发努力的参考,在这些努力中,将进行适当的编码(并且绝不应该在最终解决方案上扩展实验代码),团队将构建可维护的软件,使用标准的软件开发实践。

但在这个阶段,并且仅在这个阶段,编写一些看起来相当糟糕的脚本通常是可行的。我们有时都会这样做。

4.3.2 你能将其投入生产吗?你愿意维护它吗?

对于更大的团队来说,实验阶段的主要目的是决定模型实现的预测能力,而在 DS 团队内部,其中一个主要目的是确定这个解决方案是否适合团队。DS 团队的负责人、架构师或高级 DS 人员应该仔细审查这个项目将涉及的内容,提出困难的问题,并给出诚实的答案。以下是一些最重要的问题:

  • 这个解决方案需要多长时间才能构建?

  • 这个代码库将会多么复杂?

  • 根据需要重新训练的日程安排,这个解决方案的培训成本将会是多少?

  • 我的团队是否有维护这个解决方案所需的技能?每个人是否都了解这个算法/语言/平台?

  • 如果训练或推断的数据发生重大变化,我们将多快能够修改这个解决方案?

  • 有没有人报告过使用这种方法/平台/语言/API 取得成功的案例?我们是在重新发明轮子,还是在制造一个方轮?

  • 为了使这个解决方案在满足所有其他功能目标的同时工作,团队需要额外做多少工作?

  • 这个解决方案是否具有可扩展性?当不可避免地需要这个解决方案的 2.0 版本时,我们能否轻松地增强这个解决方案?

  • 这个解决方案是否可测试?

  • 这个解决方案是否可审计?

在我的职业生涯中,无数次我要么是构建这些原型的人,要么是在审查他人原型时提出这些问题的人。尽管机器学习实践者看到结果的第一反应通常是“让我们选择结果最好的一项”,但很多时候,“最好”的那一项最终要么几乎不可能完全实施,要么难以维护。

考虑到维护性和可扩展性,这些关于未来的问题至关重要,无论是关于正在使用的算法、调用算法的 API,还是它运行的平台。花时间正确评估实施的生产特定问题,而不是仅仅评估模型原型的预测能力,这可能是成功解决方案和空谈之间的区别。

4.3.3 机器学习项目中的 TDD vs. RDD vs. PDD vs. CDD

在开发软件时,我们似乎有无穷无尽的方法可供选择。从瀑布模型到敏捷革命(及其无数种风味),每种方法都有其优点和缺点。

我们不会讨论哪种开发方法可能最适合特定项目或团队的具体细节。已经出版了一些绝对出色的书籍,深入探讨了这些主题,我强烈推荐阅读它们以提高机器学习项目的开发流程。Greg Smith 和 Ahmed Sidky 的《在一个不完美的世界中变得敏捷》(Manning,2009 年)以及 Lasse Koskela 的《测试驱动:Java 开发者的 TDD 和验收 TDD》(Manning,2007 年)是值得注意的资源。然而,在这里值得讨论的是四种机器学习开发的一般方法(其中一种是成功的方法,其他则是警示性的故事)。

测试驱动开发或特性驱动开发

纯粹的测试驱动开发(TDD)对于机器学习项目来说极具挑战性(并且最终无法达到传统软件开发所能达到的相同测试覆盖率),这主要是因为模型本身的非确定性。纯粹的特性驱动开发(FDD)方法在项目过程中可能会导致大量的返工。

但大多数成功的机器学习项目方法都融合了这两种开发风格的一些方面。保持工作增量化、适应变化,并专注于模块化代码,这些代码不仅可测试,而且完全专注于满足项目指南所需的功能,这是一种经过验证的方法,有助于按时交付项目,同时创建一个可维护和可扩展的解决方案。

这些敏捷方法需要借鉴和调整,以创建一个有效的开发策略,不仅适用于开发团队,也适用于组织的整体软件开发实践。此外,特定的设计需求可能决定了实施特定项目的不同方法。

为什么我想使用不同的开发哲学?

当讨论机器学习作为一个广泛的话题时,我们面临过度简化的风险,这是一个极其复杂且动态的学科。由于机器学习被用于如此广泛的应用场景(以及拥有如此广泛的一套技能、工具、平台和语言),各种项目之间复杂性的差异程度确实是惊人的。

对于像“我们希望预测客户流失”这样简单的项目,以 TDD 为主的方法可以是一种开发解决方案的成功方式。用于实现客户流失预测模型的模型和推理管道通常相当简单(绝大多数的复杂性在于数据工程部分)。因此,将代码模块化,并以这种方式构建代码库,使得数据采集阶段中的每个组件都可以独立测试,对于高效的实现周期和易于维护的最终产品是有益的。

另一方面,像集成推荐引擎这样的复杂项目可能使用实时预测服务,拥有数百个基于逻辑的重排序功能,使用多个模型的预测,并且有一个大型多学科团队在开发它。这种类型的项目可以从使用 TDD 的可测试性组件中受益,但在整个项目过程中,应使用 FDD 的原则来确保只有最关键的组件在需要时才被开发,以帮助减少功能蔓延。

每个项目都是独特的。从开发角度来看,负责实施的项目负责人或架构师应该根据测试和适应项目需求的通用代码架构来设定工作速度的期望。在最佳实践和这些经过验证的开发标准之间保持适当的平衡,项目可以在最低风险失败点达到所需的功能完整状态,从而使解决方案在生产中稳定且可维护。

祈祷驱动开发

在某个时候,所有的机器学习项目都源于“祈祷驱动开发”(PDD)。在许多刚开始机器学习开发的公司中,项目仍然如此。在有了便于建模的、文档齐全的高级 API 之前,一切都是在痛苦地尝试,希望所拼凑的东西至少能足够好,以至于模型在生产中不会爆炸。虽然这里所说的并不是指这种希望“请让它正常工作”的祈祷。

我所讽刺地指的是通过跟随来自互联网论坛或可能没有比搜索者更多实际经验的人的糟糕建议,疯狂寻找解决特定问题的线索的行为。搜索者可能会找到一个覆盖机器学习技术或应用的博客,这似乎与手头的问题有些相关,但几个月后,他们才会发现他们所希望的神奇解决方案不过是空谈。

祈祷驱动的机器学习开发是将不知道如何解决的问题交给一个以前已经解决过的人的象征性手,所有这一切都是为了消除对技术方法进行适当研究和评估的讨厌任务。走这样一条简单的路很少会有好结果。随着代码库的损坏、浪费的努力(“我做了他们做的事——为什么这个不工作?”)以及在最极端情况下项目的放弃,这是一个正在增长规模和严重性的问题和开发反模式。

我看到的最常见的由这种机器学习“复制文化”方法产生的影响是,接受这种心态的人要么想为每个问题使用单个工具(是的,XGBoost 是一个可靠的算法。不,它并不适用于每个监督学习任务)或者只尝试最新的流行趋势(“我认为我们应该使用 TensorFlow 和 Keras 来预测客户流失”)。

如果你只知道 XGBoost,那么所有东西看起来都像梯度提升问题

当你以这种方式限制自己——不做研究,不学习或测试其他方法,并将实验或开发限制在狭窄的工具集时——解决方案将反映这些限制和自我强加的界限。在许多情况下,将单一工具或新潮流强加给每个问题,会创造次优解,或者更糟糕的是,迫使你写出更多不必要的复杂代码,以将方钉塞入圆孔。

一个很好的方法来检测团队(或你自己)是否在 PDD 的道路上,就是看看项目原型阶段计划了什么。有多少模型正在被测试?有多少框架正在被审查?如果这两个问题的答案都是“一个”,并且团队中没有人在之前解决过那个特定问题,那么你就是在做 PDD。你应该停止。

驱动开发的混乱

也被称为牛仔式开发(或黑客式开发),驱动开发的混乱(CDD)是跳过实验和原型设计阶段的过程。一开始可能看起来更容易,因为早期并没有太多的重构发生。然而,在项目工作中根据需要构建机器学习的方法充满了危险。

在开发解决方案的过程中,随着修改请求和新功能需求的出现,大量的返工,有时甚至是从头开始,使得项目进展缓慢。到了最后(如果真的能走到那一步),DS 团队精神状态的脆弱状态将完全阻止任何未来的改进或对代码的更改,因为实施的混乱性质。

如果我希望你能从这本书中学到一件事,那就是避免这种开发风格。我在机器学习项目工作的早期就犯过这样的错误,也看到它成为我合作过的公司中项目放弃的最大原因之一。如果你不能阅读你的代码,修复你的代码,甚至解释它是如何工作的,那么它可能不会很好地工作。

简历驱动开发

最有害的开发实践——为问题设计一个过度工程化的、炫耀性的实现——是项目在生产后放弃的主要原因之一。这些简历驱动开发(RDD)实现通常关注几个关键特性:

  • 涉及到一个新颖的算法

    • 除非问题的独特性质要求如此

    • 除非多个经验丰富的机器学习专家一致认为没有其他解决方案可用

  • 涉及到一个新的(在机器学习社区中尚未得到验证的)框架来执行项目的任务(具有在解决问题中没有任何目的的功能)。

    • 现在实际上并没有什么借口可以解释这种情况。
  • 正在开发过程中撰写关于解决方案的博客文章,或一系列博客文章(项目完成后写也可以!)。

    • 这应该在团队中引起健康的怀疑。

    • 项目发布到生产环境,经过一个月的稳定验证,并验证了影响指标后,将有时间自我庆祝。

  • 大量的代码都致力于机器学习算法,而不是特征工程或验证。

    • 对于绝大多数机器学习解决方案,特征工程代码与模型代码的比例应该始终大于 4 倍。
  • 状态会议中异常高水平的讨论是关于模型,而不是要解决的问题。

    • 我们在这里是为了解决商业问题,对吧?

这并不是说不需要开发新的算法或极其深入和复杂的解决方案。当然,它们确实可能需要。但只有在所有其他选项都已用尽的情况下,才应该追求这些方案。

对于本章中我们一直在审查的例子,如果有人从没有任何实施措施的位置提出一个前所未有的独特解决方案,应该提出反对意见。这种开发实践及其背后的动机不仅对将不得不支持该解决方案的团队是有毒的,而且会毒害项目的根基,几乎可以保证它将花费更长的时间,成本更高,除了填充开发者的简历外,什么也做不了。

4.4 为商业规则混乱做准备

作为我们本章中一直在构建的推荐引擎的一部分(或者至少,在谈论构建过程时),出现了许多新功能,这些功能被实施并增强了模型的结果。其中一些是为了解决最终结果的具体用例(例如,为了可视化目的,为网站和应用程序的不同部分提供集合聚合),而另一些则是为了满足与供应商的合同义务。

最关键的是保护用户免受冒犯或过滤不适当的内容。我喜欢将这些额外的细微差别称为机器学习的商业规则混乱。这些具体的限制和控制极其重要,但也是项目实施中最具挑战性的方面。

如果没有相应地规划(或者完全未能实施)这些限制,几乎可以保证你的项目在达到预期发布日期之前就会被搁置。如果这些限制在发布前未被捕捉到,它们可能会损害你公司的品牌。

4.4.1 通过规划来拥抱混乱

让我们假设一下,正在为推荐引擎 MVP 工作的数据科学团队没有意识到公司销售敏感产品。这是可以理解的,因为大多数电子商务公司销售大量产品,数据科学团队成员不是产品专家。他们可能是网站用户,但肯定不是所有人都可能对销售的所有产品都了如指掌。由于他们没有意识到项目可能作为推荐的一部分引起冒犯,因此他们未能识别这些项目并将它们从结果集中过滤掉。

忽略这个细节并没有什么问题。根据我的经验,这类细节总是在复杂的机器学习解决方案中浮现出来。唯一规划这些细节的方法是预期这类情况会发生,并以一种方式构建代码库,使其具有“杠杆和旋钮”——可以通过传入的配置应用或修改的功能或方法。这样,实施新的限制不需要完全重写代码或花费数周时间调整代码库。

在开发解决方案的过程中,许多机器学习从业者往往主要考虑模型预测能力的质量。为了追求在验证指标方面最佳数学解决方案,他们投入了无数小时进行实验、调整、验证和重新工作。因此,在花费了如此多的时间和精力构建理想系统之后,发现需要对模型的预测施加额外的限制,这可能会让人感到非常恼火。

这些限制几乎存在于所有以预测性机器学习为核心的系统(无论是最初还是如果解决方案在生产环境中运行足够长时间后)中。在金融系统中,可能存在过滤或调整结果的法律原因。也许,基于防止客户对预测感到冒犯的内容限制可能存在于推荐系统中(相信我,你不想向任何人解释为什么一个未成年人被推荐了成人向产品)。无论出于财务、法律、道德还是纯粹的老式常识原因,不可避免地,大多数机器学习实现中的原始预测将需要进行一些改变。

在开始花费大量时间开发解决方案之前理解潜在的限制绝对是一种最佳实践。提前了解限制可以影响解决方案的整体架构和特征工程,并允许控制机器学习模型学习向量的方式。它可以节省团队无数小时的调整时间,并消除充满不断循环的if/elif/else语句的昂贵且难以阅读的代码库,这些语句用于处理模型输出的事后纠正。

对于我们的推荐引擎项目,可能需要向来自 ALS 模型的原始预测输出添加许多规则。作为一个练习,让我们回顾一下早期开发阶段的工作组件图。图 4.12 显示了计划解决方案中旨在强制执行推荐输出约束的元素。其中一些是绝对必要的——合同需求元素,以及旨在筛选不适合某些用户的产品的过滤器。其他则是项目团队怀疑将对用户参与推荐产生重大影响的想法。

04-12

图 4.12 识别推荐引擎项目的业务上下文需求——即风险检测图

此图显示了位置,但更重要的是,模型的业务限制类型。在规划阶段,在实验之后和全面开发开始之前,识别和分类每个这些特征是值得的。

绝对必要的方面,如图 4.12 所示的“业务规则”,必须在工作范围内进行规划,并作为建模过程的组成部分构建。它们是否以可调整解决方案方面(通过权重、条件逻辑或布尔开关)构建取决于团队的需求,但它们应被视为基本功能,而不是可选或可测试的功能。

规则的其余部分,如图 4.12 所示的“业务假设”,可以以各种方式处理。它们可以被优先考虑为可测试的功能(将构建配置,允许对不同的想法进行 A/B 测试,以微调解决方案)。或者,它们可以被看作是未来工作的一部分,不属于引擎的初始 MVP 发布版本,只是作为可以在以后轻松修改的占位符实现。

4.4.2 人工参与设计

无论哪种方法最适合团队(尤其是正在开发引擎的机器学习开发者),需要记住的重要事实是,这些对模型输出的限制应该在早期识别出来,并且如果需要,应该允许它们可变,以便改变其行为。然而,对于这些需求,你绝对不希望构建的是源代码中的硬编码值,因为这需要修改源代码才能进行测试。

最好以这种方式处理这些项目,使 SMEs 能够修改性能,快速改变系统的行为,而无需长时间发布期关闭系统。你还想确保建立控制措施,限制在没有经过适当的验证程序的情况下修改这些内容的能力。

4.4.3 你的备份计划是什么?

当有新客户时会发生什么?对于一个已经超过一年没有访问过你的网站而返回的客户,推荐会发生什么?对于一个只看过一个产品并在第二天返回网站的客户呢?

为稀疏数据规划并不仅限于推荐引擎,但它确实比其他机器学习应用对它们的影响更大。

所有机器学习项目都应该预期出现数据质量问题,当数据不完整或缺失时,需要制定回退计划。这种安全模式可以像使用注册信息或 IP 地理定位跟踪来从用户登录的地区提取聚合的流行产品(希望他们没有使用虚拟私人网络,或 VPN)一样复杂,或者可以像所有用户的通用流行度排名一样简单。无论选择哪种方法,如果用户没有个性化数据集,重要的是要有一个安全的数据集作为回退。

这个一般概念适用于许多用例,而不仅仅是推荐引擎。如果你正在运行预测,但没有足够的数据来完全填充特征向量,这可能会与推荐引擎的冷启动问题相似。处理这个问题有多种方法,但在规划阶段,重要的是要意识到这将是一个问题,并且应该有一个回退方案,以便向期望返回数据的用户提供一定水平的信息。

4.5 讨论结果

向非专业人士解释机器学习算法的工作原理是一项挑战。使用类比、基于思想实验的例子以及与之相伴的易懂图表,在最好的情况下(当有人出于真正的好奇心而提问时)也是困难的。当这些问题由试图发布项目的跨职能团队成员提出时,这可能会更加具有挑战性和精神负担,因为他们对黑盒要执行的操作有期望。当这些团队成员在发现预测结果或质量有问题,并对主观上较差的结果感到愤怒时,描述所选算法的功能和能力的这一冒险之旅可能会非常紧张。

在任何项目的开发过程中,无论是在规划阶段的早期,在原型演示期间,甚至在开发阶段结束时进行 UAT 评估时,都会不可避免地出现一些问题。以下问题特定于我们的示例推荐引擎,但我可以向你保证,这些问题的替代形式可以应用于任何机器学习项目,从欺诈预测模型到威胁检测视频分类模型:

  • “为什么它认为我会喜欢那个?我永远不会为自己挑选那样东西!”

  • “为什么它会推荐雨伞?那个客户住在沙漠里。它在想什么?!”

  • “为什么它会认为这个客户会喜欢 T 恤?他们只买高级时装。”

对所有这些问题的轻率回答很简单:“它并没有思考。算法只‘知道’我们‘教’了它什么。’(提示:如果你打算使用这句话,不要用;为了在你职位上的进一步任期,在传达这句话时强调那些引用的元素。再想想,即使你很烦,也不应该这样和同事说话,即使你不得不在项目中进行第 491 次解释这个概念。)一个可以接受的回答,用耐心和理解的方式传达,是简单诚实:“我们没有数据来回答这个问题。”在声称这一点之前,最好用尽所有特征工程创造性的可能性,但如果你已经这样做了,这确实是唯一值得给出的答案。

对我来说,通过阐述因果关系概念,但以一种与问题的机器学习方面相关的方式,解释这个问题及其根本原因已经成功了。图 4.13 展示了有助于解释机器学习可以做什么,但更重要的是,它不能做什么的有用可视化。

04-13

图 4.13 机器学习的数据领域——我们不可能拥有所有数据

如图 4.13 所示,会议中的人要求的数据超出了获取的能力。也许那些可以告知某人对于一双袜子主观偏好的数据具有如此个人化的性质,以至于根本无法推断或收集这些信息。也许,为了使模型得出所要求得出的结论,需要收集的数据可能如此复杂、存储成本高昂或难以收集,以至于这超出了公司的预算范围。

当会议中的小企业主问,“为什么这个群体的人没有将这些商品添加到他们的购物车中,如果模型预测这些对他们来说如此相关的话?”时,你绝对无法回答这个问题。不要忽视这个问题,这不可避免地会导致提问者感到烦恼和沮丧,而应该在你解释模型能够“看到”的现实观的同时,提出自己的一些问题。也许用户在为别人购物。也许他们正在寻找一些新事物,这些新事物是从我们无法以数据形式看到的事件中受到启发的。也许他们只是没有心情。

影响现实世界事件行为的潜在因素有着惊人的无限性。即使你收集了关于可观测宇宙的所有可知信息和指标,你仍然无法可靠地预测将要发生什么,它将在哪里发生,以及为什么它会以这种方式发生或不会发生。对于那个行业专家想要知道为什么模型以某种方式表现,以及预期的结果(用户为我们购买商品)没有发生,这是可以理解的;作为人类,我们追求可解释的秩序。

放松。我们,就像我们的模型一样,不可能完美。

世界是一个相当混乱的地方。我们只能希望猜对将要发生的事情,而不是猜错。

以这种方式(我们无法预测我们没有信息来训练的东西)解释局限性是有帮助的,尤其是在项目的初期,可以帮助消除人们对机器学习对门外汉所假设的不切实际能力的误解。在项目背景下进行这些讨论,包括涉及的数据如何与业务相关,可以在项目向前推进到演示和审查的里程碑时,作为消除失望和挫折的强大工具。

清楚地、直白地解释期望,尤其是对项目领导来说,可能是可以创造性解决的风险与项目完全停滞和放弃之间的区别,因为解决方案没有达到业务领导所期望的效果。正如历史上许多明智的人所说,“总是最好少承诺,多交付。”

请像对我这个五岁的孩子一样解释给我听

有时候,当谈论模型、数据、机器学习、算法等等时,可能会感觉就像是在经历《洞穴寓言》。尽管可能感觉像是自己身处阳光下,试图说服每个人日光的样子,但事实远非如此。

我们在前两章中讨论的沟通目标很简单:让对方理解。抵制将自己或你的团队视为“步入光明”的洞穴居民的想法,他们只是返回洞穴,向其他人展示全彩的奇迹图像和“现实世界”。你可能比门外汉更了解机器学习,但采取“开明者”的立场,在向其他团队成员解释概念时采用优越的语气,只会招致嘲笑和愤怒,就像试图将其他人拖向光明的那群人一样。

你总是会在用熟悉术语向听众解释概念,并通过寓言和例子来处理复杂话题,而不是默认使用排他性对话中取得更多成功,这种对话其他人可能无法完全理解,尤其是那些不熟悉你职业内部运作的团队成员。

摘要

  • 将跨职能团队沟通聚焦于目标导向、非技术性、基于解决方案且无专业术语的言辞,将有助于营造一个协作和包容的环境,确保机器学习项目达成其目标。

  • 为向广泛的领域专家和内部客户展示项目功能设定具体里程碑,将显著减少机器学习项目中的返工和意外功能缺陷。

  • 以应用于敏捷开发的严谨性来处理研究、实验和原型制作工作的复杂性,可以缩短到达可行开发选项所需的时间。

  • 在项目早期理解、定义和整合业务规则和期望,将有助于确保机器学习实施适应并围绕这些要求进行设计和调整,而不是在解决方案构建完成后强行加入。

  • 避免讨论实施细节、深奥的机器学习相关主题以及算法内部工作原理的解释,将有助于进行清晰且专注的解决方案性能讨论,从而允许所有团队成员进行富有创造性的讨论。

5 实验行动:规划和研究机器学习项目

本章涵盖

  • 项目研究阶段的细节

  • 对项目进行解决方案实验的过程和方法

我们在前两章中专注于围绕机器学习项目规划、工作范围和团队沟通的过程。本章和接下来的两章将关注与数据科学家相关的机器学习工作的下一个最关键方面:研究、实验、原型设计和 MVP 开发。

一旦从规划会议中彻底捕捉到项目的需求(尽可能实现的部分)并且定义了建模解决方案的目标,创建机器学习解决方案的下一阶段就是开始 实验和研究。如果没有适当的结构,这些过程很容易导致项目取消。

项目可能会因为看似无休止的实验阶段而被取消,在这个阶段中,对于最终确定解决方案的方法没有明确的方向。停滞的项目也可能是由于预测能力差造成的。无论是由于犹豫不决还是无法满足准确度期望,防止因数据和算法问题而停滞和取消的项目,始于实验阶段。

没有具体的规则集可以用来精确估计实验阶段应该持续多长时间,因为每个独特项目都可能产生无数复杂情况。然而,本章中的方法确保了达到有利的 MVP 状态所需时间的减少,以及显著减少了团队在没有这些方法进行实验时可能面临的重复工作量的减少。

本章涵盖机器学习实验的第一阶段,如图 5.1 所示。我们将介绍一种经过验证的方法来设置有效的实验环境,通过创建可重用的可视化函数来评估数据集,并以受控和高效的方式进行研究和建模方法验证,以帮助更早地进入 MVP 阶段,并减少返工。

05-01

图 5.1 机器学习实验过程

我们将了解如何组织并规划适当的研究,在规划阶段设定期望和规则,正确分析本章中我们将要解决的场景,以指导我们的模型选择和实验,最后,进行实验并为当前项目构建有用的工具。所有这些阶段和过程都是为了最大限度地提高开发期更容易的机会,并最大限度地减少不仅从项目开始就产生技术债务的风险,而且还有项目放弃的风险。

在前几章中,我们一直在处理一家电子商务公司推荐引擎的预实验阶段。为了简洁起见,在接下来的几章中,我们将使用一个更简单的例子。虽然这个时间序列建模项目比许多机器学习实现都要简单,但我们所涉及的内容通常普遍适用于所有机器学习工作;当它们不适用时,我在侧边栏讨论中提供额外的注释。就像软件开发中的所有事情一样,一个高质量的项目始于规划。

5.1 规划实验

让我们假设在这个章节中,我们为一家从事花生供应业务的公司工作(具体来说,是那些在世界上大多数主要航空公司发放的单独包装的花生,附带一张方形餐巾、一个设计成当邻座调整到更舒适的位置时会把饮料洒到腿上的凹槽塑料杯,以及一罐经过两次巴氏杀菌的碳酸饮料)。负责花生物流的业务单元要求开发一个项目,以预测这些悲伤的机上零食的需求量,因为航空公司对他们在保质期到期时不断丢弃的大量干烤豆类的大量运输施加了压力。

会议已经召开,需求已经收集,机器学习团队已经内部讨论了该项目。普遍共识是我们面对的是一个简单的需求预测时间序列预测问题。但现在我们知道了我们试图解决的问题是什么?我们还有两周的时间来提出一个粗略的最小可行产品(MVP),以证明我们有一个经过验证的解决方案。最好立即着手。

05-02

图 5.2 机器学习实验规划阶段路线图

我们将要讨论的内容如图 5.2 所示:机器学习实验规划阶段。在这个阶段,我们将阅读很多东西,希望大部分都能保存在我们的脑海中,并且会创建许多浏览器书签。

5.1.1 进行基本研究和规划

一旦团队成员在规划会议后回到他们的办公桌,他们要做的第一件事就是查看可用的数据。由于我们是花生制造商,并且没有与主要航空公司有任何合作,我们无法获得机票销售预测数据。我们当然没有时间构建网络爬虫来尝试查看每个机场的航班容量(也没有人愿意做这件事,因为之前尝试过构建爬虫的人都知道)。不过,我们确实有机场交通管理局免费提供的乘客容量历史数据。

从图 5.2 我们知道,为了了解数据的性质,我们应该做的第一件事是可视化它并运行一些我们可用的统计分析。大多数人会简单地将数据加载到他们的本地计算机环境中,并在笔记本中开始工作。

这是一种灾难性的做法。在您的首选计算机的主要操作系统上运行的默认 Python 环境远非纯净。为了最小化在开发环境中挣扎所浪费的时间(并帮助为后续章节的开发阶段顺利过渡做准备),我们需要为我们的测试创建一个干净的环境。有关如何使用 Docker 和 Anaconda 创建用于本章和所有后续章节代码列表的开发环境的指导,请参阅本书末尾的附录 B。

现在我们已经有一个隔离的环境(笔记本存储位置在容器中映射到本地文件系统位置),我们可以将样本数据放入此位置并创建一个新的笔记本进行实验。

数据集的快速可视化

在选择机器学习方法来解决该问题之前,应该做的第一件事是最简单(但经常被忽视)的数据科学方面:了解您的数据。对于机场预测,让我们看看我们可用的数据。列表 5.1 展示了一种快速可视化需要预测的一个时间序列(JFK 国内乘客)的方法。

注意:要精确地跟随此示例,您可以通过克隆艾伦·图灵研究所维护的存储库来获取此数据集。导航到附录 B 中概述的步骤同步的本地笔记本目录,并运行命令行语句 git clone https://github.com/alan-turing-institute/TCPD.git

列表 5.1 可视化数据

import pandas as pd
import numpy as np
import matplotlib.pylab as plt

ts_file = '/opt/notebooks/TCPD/datasets/jfk_passengers/air-passenger-traffic-per-month-port-authority-of-ny-nj-beginning-1977.csv'
raw_data = pd.read_csv(ts_file)
raw_data = raw_data.copy(deep=False)                                          ❶
raw_data['Month'] = pd.to_datetime(raw_data['Month'], format='%b').dt.month   ❷
raw_data.loc[:, 'Day'] = 1                                                    ❸
raw_data['date'] = pd.to_datetime(raw_data[['Year', 'Month', 'Day']])         ❹

jfk_data = raw_data[raw_data['Airport Code'] == 'JFK']                        ❺
jfk_asc = jfk_data.sort_values('date', ascending=True)                        ❻
jfk_asc.set_index('date', inplace=True)                                       ❼
plt.plot(jfk_asc['Domestic Passengers'])
plt.show()

❶ 对 DataFrame 进行浅拷贝,以便我们可以对其进行可变修改

❷ 将月份列转换为日期对象,以便我们可以从它组装日期。(目前,它是一个月份的三字母缩写字符串。)

❸ 添加一个常数列,以便我们可以组装日期列

❹ 为每个机场组装基于行的索引的日期列

❺ 过滤 DataFrame,以便我们只查看单个机场(在本例中为 JFK)

❻ 按日期对 DataFrame 进行排序,以便正确地按顺序绘制时间序列(以及未来的活动)

❼ 将过滤后的 DataFrame 的索引设置为日期列

在笔记本的读取-评估-打印循环(REPL)中执行列表 5.1 后,我们将得到一个简单的时间序列趋势可视化,显示 1977 年至 2015 年间在美国国内航班的月度乘客数量。matplotlib 窗口如图 5.3 所示。

05-03

图 5.3 原始数据的基本默认可视化

看到这些原始数据展示出来,我们可以开始思考实验阶段的计划。首先,我们提出应该回答的问题,这些问题不仅将指导我们为了理解预测选项而需要进行的调查,还将指导平台决策(这在第 5.2 节中有深入讨论)。以下是我们的数据观察和问题:

  • 潜在因素正在影响趋势。数据看起来不具有平稳性。

  • 数据似乎有一个强烈的季节性成分。

  • 我们需要为成千上万的机场建模。我们需要考虑选择的方法的可扩展性。

  • 对于这个用例,哪些模型是好的?

  • 我们有两周的时间来制定这个问题的处理方向。我们能完成吗?

这个数据可视化阶段的问题和答案都可以帮助为项目创建一个更有效的实验阶段。过早地直接创建模型和测试随机想法可能会产生大量的浪费工作,从而推迟了 MVP 的交付,使其无法按时完成。在研究潜在解决方案之前,始终理解数据集的性质和揭示任何隐藏问题都是有效利用时间的方法,因为这个阶段可以帮助通过早期淘汰选项来减少测试和额外研究。

研究阶段

既然我们已经了解了一些数据的问题——它具有高度的季节性,趋势受到我们完全未知的潜在因素的影响——我们可以开始研究。让我们暂时假设团队中没有一个人做过时间序列预测。在没有团队专家知识的情况下,研究应该从哪里开始?

网络搜索是一个很好的起点,但大多数搜索结果都显示了人们提供的时间序列预测解决方案的博客文章,这些文章涉及大量的挥手和简化了构建完整解决方案所涉及的复杂性。白皮书可能是有信息的,但通常不关注他们所涵盖的算法的应用。最后,不同 API 入门指南中的脚本示例对于了解 API 签名的工作原理非常棒,但它们故意简化,仅作为基本起点的参考,正如其名称所示。

那么,我们应该关注哪些方面来弄清楚如何预测机场未来几个月的旅客需求?简短的答案是书籍。关于时间序列预测有很多优秀的书籍。深入的研究博客也有帮助,但它们应该仅作为解决手头问题的初始方法,而不是直接复制代码的代码库。

注意:G. E. P. Box 和 G. M. Jenkins 所著的奠基性作品《时间序列分析》(Holden-Day,1970 年)被广泛认为是所有现代时间序列预测模型的基础。Box-Jenkins 方法是今天几乎所有预测实现的基础。

在对时间序列预测进行了一些研究之后,我们发现了一些似乎足够常用,值得努力实现粗略脚本方法的选项。我们决定尝试的简短列表如下:

  • 线性回归(OLS、岭回归、Lasso、弹性网络和集成)

  • ARIMA(自回归积分移动平均)

  • 指数平滑(Holt-Winters)

  • VAR(向量自回归)

  • SARIMA(季节性自回归积分移动平均)

在有了要测试的这些列表之后,下一步是找出哪些包有这些算法,并阅读它们的 API 文档。在机器学习世界中,一个很好的生活规则是建立一个健康的库和团队预算,以持续扩展该库。拥有一系列深入的技术书籍可以帮助团队应对新的挑战,并确保机器学习应用的细微复杂性能够用正确的信息来完成。

5.1.2 忘掉博客——阅读 API 文档

当一个团队——通常是一个相当年轻的团队——如此坚信博客文章的真实性,以至于他们围绕该博客的方法(有时甚至是确切代码)构建整个项目时,项目失败几乎是必然的。虽然几乎总是出于好意,但关于机器学习主题的短博客作者由于媒体格式的限制,无法深入覆盖所有必要的信息,这些信息对于现实世界的生产级机器学习解决方案至关重要。

让我们看看博客文章可能为我们的时间序列问题提供什么。如果我们搜索“时间序列预测示例”,我们可能会找到很多结果。毕竟,预测已经存在很长时间了。我们可能会发现的是,代码片段高度脚本化,使用 API 默认值,并省略了许多使练习可重复的更细微的细节。

如果你选择跟随此示例(假设它已经说服了你),你可能会花几个小时查找 API 文档,并对作者看起来简单的东西感到沮丧,结果发现他们为了达到那神奇的 10 分钟阅读时间,省略了所有复杂的细节。以下是从一个虚构的弹性网络回归博客(scikit-learn 示例)中摘取的示例片段,用于演示目的。

列表 5.2 来自 scikit-learn 的弹性网络博客示例

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn import datasets
from sklearn.linear_model import ElasticNet
from sklearn import metrics
boston_data = datasets.load_boston()                                    ❶
boston_df = pd.DataFrame(boston_data.data, columns=boston_data.feature_names)
boston_df['House_Price'] = boston_data.target
x = boston_df.drop('House_Price', axis=1)
y = boston_df['House_Price']
train_x, test_x, train_y, test_y = train_test_split(x, y, test_size=0.3, random_state=42)                                  ❷
lm_elastic = ElasticNet()                                               ❸
lm_elastic.fit(train_x, train_y)                                        ❹
predict_lm_elastic = lm_elastic.predict(test_x)
print("My score is:")
np.round(metrics.mean_squared_error(test_y, predict_lm_elastic)         ❺
>> My score is:
>> 25.0

❶ 使用内置数据集——对于可重复的演示来说是一个稳健的举措

❷ 随机样本分割

❸ 希望默认值是好的……

❹ 我想我们不需要对拟合模型进行引用?

❺ 单个指标?当然,我们可以做得更好……

使用此代码有哪些问题?抛开糟糕的格式化和文本墙,让我们列举一下将此类示例作为进行时间序列回归基础的问题:

  • 这是一个演示。相当糟糕的一个。但它的目的是尽可能简单,展示 API 的大致轮廓。

  • 训练-测试分割使用随机抽样。这不会对预测时间序列产生好结果。(请记住,博客的目的是展示弹性网络回归,而不是时间序列问题。)

  • 模型使用默认的超参数。从博客的简洁性考虑,这是可以理解的,但这并不帮助读者了解他们可能需要更改什么才能将其应用于他们的用例。

  • 这是一种方法链和打印到标准输出的方式,使得对象无法用于进一步处理。

请不要误解我的意思。博客是好的。它们有助于教授新概念,并为可能正在解决的问题提供替代解决方案的灵感。我总是告诉人们不要过度依赖它们的主要原因在于,它们旨在易于消化、简洁和简单。为了实现这三个目标,以及最大简洁性的总体目标,绝对必须省略细节。

关于博客的注意事项

我不想让人觉得我在批评他们。他们很棒。他们提供了对概念和潜在解决方案的精彩介绍,这些内容绝对是无价的。如果你是博客作者,请继续保持出色的表现。这真的很有帮助。如果你是博客读者,请谨慎行事。

网上有关机器学习的真正优秀的博客文章有很多。不幸的是,这些文章被充斥着过于简化的概念证明、损坏的代码或无意中糟糕的编程实践的博客淹没。如果你在开始一个项目时将博客作为基本研究的主要来源,请记住,直接基于博客中的示例代码构建原型可能没问题,但在构建最小可行产品(MVP)时,你将不得不完全重写解决方案。

如果将博客作为主要参考工具,我的最好建议是通过对多个观点进行审查。你是否看到多个人使用特定方法撰写关于类似(但不完全相同)的解决方案?那么,在您的数据上测试这种方法可能是一个安全的赌注。

你是否看到过在多个博客中存在完全相同的代码示例的特定解决方案?这很可能是为了获取广告收入或进行其他恶意行为而进行的复制粘贴工作。你查看的博客越多,你就能越容易嗅出糟糕的代码和糟糕的实现,并判断作者是否真的了解他们在谈论的内容,以及是否值得信赖。

只需记住:你绝对不希望直接基于从博客文章中复制的代码来实现你的实现。博客是为了简洁而编写的,通常只关注覆盖一个狭窄的主题。这种短篇写作不适合生产代码的现实示例,因此它应该始终被视为它实际是的东西:以尽可能短的时间和文字跨度传达一个单一主题的手段。

而不是盲目地相信基于似乎会起作用的项目的博客文章,你需要检查和核实额外的信息来源。这包括学术论文、API 教程、关于该主题的出版书籍,以及,最重要的是,团队方法的测试和验证阶段。由于从博主关于他们最近学到的新东西的信息性帖子中复制的(或本质上复制的)工作导致项目取消,这不仅对业务对 DS 团队的看法有害,而且可能具有潜在的危险性。

不,认真地说,阅读 API 文档

一旦我们有了想要测试的建模方法列表,我们应该前往我们使用的模块的 API 文档。以弹性网络为例,如果我们这样做,我们会发现这个模型的超参数有几个重要的选项需要测试和调整,如下所示。

列表 5.3 scikit-learn 中弹性网络的完整 API 签名

elasticnet_regressor = ElasticNet(
  alpha=0.8,                             ❶
  l1_ratio=0.6,                          ❷
  fit_intercept=False,                   ❸
  normalize=False,                       ❹
  precompute=True,                       ❺
  max_iter=5000,                         ❻
  copy_X=True,                           ❼
  tol=1e-6,                              ❽
  warm_start=False,                      ❾
  positive=True,                         ❿
  random_state=42                        ⓫
  selection=’random’                     ⓬
)

❶ 应用到 l1 和 l2 正则化的惩罚

❷ 弹性网络混合参数(岭回归与 lasso 的比例)

❸ 是否拟合截距(根据数据中心化是否发生,这是一个很重要的问题)

❹ 仅当 fit_intercept 为 False 时使用。通过减去平均值并除以 l2 范数进行归一化。

❺ 要么是布尔值,要么是一个特征形状的数组,作为语法矩阵来加速计算

❻ 允许收敛的最大迭代次数

❼ 是否复制训练集

❽ 每次迭代是否继续尝试收敛的优化容忍度

❾ 是否重用前一次迭代的解决方案来初始化模型拟合

❿ 线性方程中的系数是否将被强制为正值

⓫ 如果选择类型是“随机”,则种子值

⓬ 系数选择的选取类型(循环是默认值,它遍历特征向量,而随机则在每个迭代中为不同的特征使用随机系数选择)

对于许多机器学习算法,默认指定的选项(超参数)偶尔对某些常见的数据结构来说是好的。但始终最好验证这些选项是什么,以及它们被用于什么,并识别出应该调整的选项是构建有效模型的一个基本部分。很多时候,这些选项只是简单地作为占位符指定,API 开发者完全希望最终用户覆盖这些值。

TIP 就像 DS 世界中的任何其他事物一样,不要假设任何事情。假设会导致问题在项目后期工作中困扰你。

根据团队同意测试的模型列表,团队中的每个人都应该去熟悉每个模型 API 的签名选项。这很重要,以便在运行每个快速且粗略的实验结果时,可以同时权衡模型的可维护性和复杂性以及通常作为唯一判断点的准确性指标。

真的,你应该阅读文档

当我看到有人使用特定的 API 时,有时甚至用于生产用例,而从未阅读过围绕该 API 的文档(包括我自己,事后看来),我总是有点惊讶。我惊讶的原因是大多数人会惊讶地看到一名乘务员走进飞机驾驶舱并开始驾驶飞机。他们能保持飞机在空中吗?当然(希望如此)。他们知道飞机的工作原理和飞行动力学吗?可能不知道。让我们希望天空保持晴朗和蓝色。

这并不是说你应该阅读你将要使用的每个模块的每一个开发者 API 文档。这是不可行的,而且有点荒谬。然而,在机器学习的世界里,可用的算法数量似乎无穷无尽(更不用说那些算法背后的代码的内部工作原理极其复杂且冗长),因此阅读至少主要接口级别的 API 文档非常重要。

这意味着要熟悉你正在使用的类,它们的签名,以及你在这些类中使用的方法。没有必要逆向工程包。然而,至少你应该熟悉类的文档字符串描述,知道哪些属性需要传递或覆盖,并理解你将要调用和与之交互的方法的基本功能。

大多数这些算法的实现都有细微差别(尤其是那些由配置决定整个行为的高级元算法)。了解需要调整哪些旋钮,如何调整它们,以及旋转这些旋钮的后果可以帮助在测试期间降低风险。这将节省你大量的时间和挫败感,尤其是当你转向解决方案的完整开发时,知道哪些默认值是占位符,哪些是一般情况下可以保留的值。

我们将在本书的后面部分更详细地讨论这些概念,但到目前为止,你应理解为什么在本章中这个 MVP 模拟的整个 API 中指定了所有设置。

API 文档的一个关键功能是向用户告知可用于控制软件行为(以及相应地,对于机器学习用例,算法的学习模式)的选项。如果不了解如何控制模型的学习行为,我们就有可能构建出无法很好地泛化的模型,因为过度拟合,或者模型过于脆弱,以至于特征输入的基线变异性的一点点变化都会使解决方案对业务完全无用。

当一个模型变得无用,表现不如手动的人为中心解决方案时,它通常会被业务放弃(即使它仍然由机器学习团队在生产环境中运行)。在实验的早期阶段,正确地调整和控制模型的行为至关重要,尽管在这个阶段进行微调的行为并不是必要的。

快速测试和粗略估计

在机器学习项目工作中,可能唯一一次可以忽略对适当超参数调整的广泛评估的时间点就是现在。在快速评估期间,我们并不特别关心我们如何优化模型与数据的拟合程度。相反,我们感兴趣的是测量一组不同算法的总体敏感性,试图评估在后期当我们微调模型并维护它们以应对漂移情况时,特定方法将有多稳定。

前一节涵盖了为什么通过阅读 API 文档(以及可能还有源代码)了解如何调整每个模型很重要。但在快速测试阶段,调整所有这些(参见以下关于过度构建的边栏)根本不可行。在将这九种可能的实现削减为更适合 MVP 实施和全面测试的更易管理的东西的过程中,仅仅使用大多数默认设置并查看结果可能是有帮助的。然而,明确标记实例化块以使用提供的默认条件,或者只是在代码中留下一个 TODO,以确保在准备进入 MVP 阶段对模型进行全面调整时,检查 API 文档,并验证和测试 API 中的可选设置,这也是一个有用的实践。

关于过度构建快速原型测试的注意事项

在对候选解决方案的早期烟雾测试实验中,重点应放在速度上,而不是准确性。记住,你是在为公司工作,预期会有结果,而且可能还有其他项目需要工作。

在前几章中,我提到了过度开发原型的一些危险(它使得决定选择什么用于最小可行产品变得更加困难)。然而,从更大的角度来看,不必要的劳动对业务的损害更为严重。团队每天都在努力证明不同的解决方案,而这些时间本可以用来工作在下一个项目上。

效率、基于共同标准的客观选择,以及转向开发 MVP 始终应该是原型设计的首要关注点。没有其他事情。在 MVP 阶段,将有时间构建更好的准确性、巧妙的功能工程和解决问题的创造性方法。

我们将在下一章中通过测试示例来介绍我们的预测问题。现在,只需知道,在初步的探索工作和评估解决方案阶段,预测不必完美。你将把更多的时间花在精简可能性列表上,以便你有一个或两个候选解决方案,而不是花大量时间微调九个(或更多)方法。

5.1.3 为内部黑客马拉松抽签

在测试周围设定界限至关重要,尤其是在团队规模增长和项目复杂性随着团队经验的成熟而增长时。为了追求效率(以及选择构建 MVP 方向时上述关键的时间方面),如果没有将测试分配给个人或结对编程团队,这可能会对项目的成功造成绝对的损害。

如果每个人都只负责找出最佳解决方案,无疑会重复工作并过度努力在某些解决方案上。通过专注于单一方法,并对其进展进行一致的状态更新,团队可以最小化错过 MVP 交付日期的风险。

现在我们已经为我们的预测模型想出了一系列可能的解决方案,我们该如何测试它们?无论团队中只有一个人还是十几名数据科学家,方法应该是相同的:

  • 为测试预留一定的时间。为这个阶段设定一个结束时间截止日期将传达一种紧迫感,以便可以快速决定解决方案的有效性。

  • 制定一些规则,就像为黑客马拉松做的那样:

    • 每个人都必须使用相同的 dataset。

    • 每个人都必须使用相同的评估指标。

    • 每次评估都需要在相同的时间段内进行预测。

    • 需要提供预测的可视化以及指标。

    • 实验代码需要从头开始可重跑。

  • 确保所选语言得到团队的支持,并且如果业务决定继续推进解决方案,该平台对团队可用。

如果我们以这种方式设置实验,针对这个问题,我们可能会基于这个 dataset 制定以下规则:

  • 测试一周——从 scrum 会议后的星期四开始,演示文稿将在下一个星期四上午提交,以便整个团队进行审查。

  • 要建模的数据是针对 JFK 国内乘客的。

  • 评估指标将如下:

    • 均绝对误差 (MAE)

    • 均方绝对百分比误差 (MAPE)

    • 均方误差 (MSE)

    • 根均方误差 (RMSE)

    • R-squared

  • 评估的预测期将是数据集的最后五年。

  • 实验将在运行 Python 3 的 Jupyter 笔记本中完成,利用内置在 Docker 容器中的标准 Anaconda 构建。

在确立了规则之后,团队(如果团队计数大于 1,则为“你”)可以着手寻找解决方案。在我们深入研究如何以高效的方式完成这项工作之前,我们还有最后一件事要讨论:标准。

5.1.4 平衡竞争环境

为了使我们的实验这九种不同的方法具有意义,我们需要确保我们是在公平竞争。这意味着我们不仅使用相同的 dataset 进行比较,而且还将测试数据与预测值使用完全相同的误差指标进行评估。我们需要防止的核心问题是团队在衡量解决方案的有效性时出现犹豫不决和混乱(这浪费了时间,正如我们之前提到的,如果我们想进入项目的 MVP 阶段,我们根本就没有时间)。

由于我们正在研究一个时间序列问题,我们将评估一个回归问题。我们知道,为了进行真正的比较,我们需要控制数据拆分(我们将在第 5.2 节中的代码示例中探讨这一点),但我们还需要就每个模型将要记录以进行预测拟合度比较的评估指标达成一致。由于我们最终需要构建数千个这样的模型,而原始预测值具有截然不同的数量级(例如,通过 JFK 和 ATL 机场飞行的人数略多于通过博伊西机场的人数),团队成员已同意使用 MAPE 作为比较指标。然而,在明智的决定中,他们还同意捕获尽可能多的适用于时间序列回归问题的回归指标,以便在后续的模型优化过程中,如果他们选择切换到不同的指标,可以进行相应的调整。

因此,我们将同意收集 MAPE、MAE、MSE、RMSE、解释方差和 R 平方的度量标准。这样,我们将能够根据数据和相关项目讨论不同指标的好处。

度量战争及其解决方法

对于不同机器学习解决方案的最佳度量标准存在许多意见。无数小时被浪费在无意义的争论中,争论是否使用均方误差(MSE)或均方根误差(RMSE),F1 分数是否比 ROC 曲线下的面积更合适,以及是否应该对平均绝对误差(MAE)进行归一化,将其转换为平均绝对百分比误差(MAPE)。

确定每个用例的适当指标确实是一个很好的论点。然而,计算误差通常既便宜又快。计算所有适用的指标并记录它们并不会有什么坏处。显然,不要为回归问题记录分类指标(那将是非常不明智的),反之亦然,但为模型提供 MAE、MSE 和 R 平方的计算以确保每种方法的利益可以被利用,这可能会很有帮助。

同样,记录所有这些内容也是非常有价值的,以防在构建解决方案和调整过程中,团队决定使用不同的指标。从一开始就记录每个指标,可以为每个尝试运行的实验提供历史参考,而无需重新运行旧实验来收集额外的指标(这既昂贵又耗时)。

收集所有指标的唯一明显例外是,如果指标评估(在计算上)非常昂贵,那么它提供的利益超过了计算它的成本。例如,在第四章的推荐引擎中,NDCG 的计算涉及一个在大型数据集(隐式评分数据)上的窗口函数,这在相对较大的 Apache Spark 集群上可能需要数小时才能执行。在关系数据库管理系统(RDBMS)中计算这些分数涉及昂贵的笛卡尔积,这可能需要更长的时间。如果指标不是关键的,并且执行时间过长,不划算收集,那么最好不要浪费时间在它上面。

5.2 进行实验准备工作

在一个专注于构建解决商业问题的机器学习解决方案的团队完成规划和研究阶段之后,接下来的阶段,即实验测试的准备阶段,是数据科学社区中最常被忽视的活动之一(这里从个人经验来说)。即使有一个明确的计划,确定谁将测试什么,达成一致的指标系列,对数据集进行评估,以及达成一致的实验深度方法论,如果这个准备阶段被忽视,将会产生更多的低效率,可能导致项目延迟。这个准备阶段专注于对数据集进行深入分析,创建整个团队都可以使用的通用工具,以便提高他们评估实验尝试的速度。

到目前为止,我们已经决定尝试一些模型,为实验阶段设定了基本规则,并选择了我们的语言(主要是 Python,因为 statsmodels 库)和我们的平台(在 Docker 容器上运行的 Jupyter Notebook,这样我们就不必浪费时间在库兼容性问题上了,并且可以快速原型测试并直接查看可视化)。在我们开始发射大量建模测试之前,了解与当前问题相关的数据非常重要。

对于这个预测项目,这意味着对平稳性测试进行彻底分析、分解趋势、识别严重异常值,并构建基本的可视化工具,这些工具将有助于子团队快速进行模型测试。如图 5.4 所示,我们将涵盖这些准备工作的关键阶段,以确保我们的黑客团队将有一个高效的开发过程,并且不会专注于创建九种不同的绘图和评分结果的方式。

05-04

图 5.4 分析阶段,专注于评估数据以指导原型设计工作

这种分析路径高度依赖于正在进行的 ML 项目的类型。对于这种时间序列预测,在构建原型解决方案以评估之前,完成这些项目是一个好主意。每一步都相当适用于任何监督式 ML 问题。然而,对于 NLP 项目,在这个阶段你需要执行一些不同的操作。

展示这些过程及其执行顺序的目的是为了说明在开始模型原型设计工作之前,需要制定一个计划。没有计划,评估阶段肯定会是漫长、艰难、混乱的,并且可能不会有结论。

5.2.1 执行数据分析

在研究可能的解决方案的过程中,很多人似乎发现趋势可视化非常有帮助。这项活动不仅为数据的基本可视化做准备,以便向将成为项目解决方案消费者的更广泛的业务单元团队展示,而且有助于最小化在项目后期可能发现的未预见的数据问题;这些问题可能需要完全重新设计解决方案(并且如果从时间和资源角度来看重新设计成本过高,可能还需要取消项目)。为了降低发现数据严重缺陷过晚的风险,我们将构建一些分析可视化。

根据列表 5.1 中构建的初始原始数据可视化(如图 5.3 所示),我们注意到数据集中存在大量的噪声。趋势中的大量噪声当然有助于可视化总体趋势线,因此让我们首先对 JKF 国内乘客的原始数据趋势应用平滑函数。我们将执行的脚本如下所示,使用了基本的 matplotlib 可视化。

列表 5.4 带有两倍标准差误差的移动平均趋势

rolling_average = jfk_asc['Domestic Passengers'].rolling(12, center=False).mean()                                                 ❶
rolling_std = jfk_asc['Domestic Passengers'].rolling(12, center=False).std()                                                      ❷
plt.plot(jfk_asc['Domestic Passengers'], color='darkblue', label='Monthly Passenger Count')                                       ❸
plt.plot(rolling_average, color='red', label='Rolling Average')                                                                   ❹
plt.plot(rolling_average + (2 * rolling_std), color='green', linestyle='-.', label='Rolling 2 sigma')                             ❺
plt.plot(rolling_average - (2 * rolling_std), color='green', linestyle='-.')
plt.legend(loc='best')
plt.title('JFK Passengers by Month')                                                                                              ❻
plt.show(block=False)                                                                                                             ❼

❶ 基于一年的平滑周期生成滚动平均系列

❷ 在与平滑滚动平均相同的滚动时间周期内生成标准差系列

❸ 使用原始数据(国内乘客)初始化图表,并为图例框创建标签

❹ 将滚动平均系列应用于图表

❺ 通过添加和减去滚动平均值系列中的值,将两倍标准差的滚动标准差系列应用于图表

❻ 为图表添加标题,使得从该图表导出的图像可以立即识别

❼ 在标准输出中显示图表

注意:这里和 5.2 节中展示的代码仅用于快速实验。5.22 节涵盖了编写 MVP 代码的更有效方法。

在我们的 Jupyter 笔记本中运行此代码将生成图 5.5 所示的图表。注意数据在平滑后的总体趋势,并意识到在 2002 年左右发生了一个明确的阶梯函数。此外,还要注意标准差在不同时间段内变化很大。在 2008 年之后,方差变得比历史上更宽。

05-05

图 5.5 从列表 5.4 中得到的基线平滑和 sigma 拟合

趋势是好的,并且对于理解可能出现的潜在问题有一定的帮助,这些问题可能来自于构建不反映趋势变化的训练和验证数据集。(具体来说,我们可以看到如果我们训练到 2000 年并期望模型能够从 2000 年准确预测到 2015 年,可能会发生什么。)

然而,在研究和规划阶段,我们发现时间序列平稳性的提及非常多,以及某些模型类型在预测非平稳趋势时可能会遇到真正的困难。我们应该看看这是怎么回事。

对于此,我们将使用 statsmodels 模块中提供的增强迪基-富勒平稳性测试。此测试将告诉我们是否需要为无法处理非平稳数据的特定模型提供平稳性调整。如果测试返回的值表明时间序列是平稳的,则几乎所有模型都可以使用未经变换的原始数据。然而,如果数据是非平稳的,则需要额外的工作。接下来将展示用于对 JFK 国内乘客系列运行此测试的脚本。

列表 5.5 时间序列平稳性测试

from statsmodel.tsa.stattools import adfuller
dickey_fuller_test = adfuller(jfk_asc['Domestic Passengers'], autolag='AIC')❶
test_items = dickey_fuller_test[:4]                                         ❷
report_items = test_items + (("not " if test_items[1] > 0.05 else "") + "stationary",)                                                         ❸
df_report = pd.Series(report_items, index=['Test Statistic', 'p-value', '# Lags', '# Observations', 'Stationarity Test'])                      ❹
for k, v in dickey_fuller_test[4].items():                                  ❺
    df_report['Critical Value(%s)' % k] = v
print(df_report)

❶ 实例化 adfuller(增强迪基-富勒测试)并设置 autolag 以自动最小化滞后计数的信息准则

❷ 获取测试结果的第一部分

❸ 创建一个布尔值是/否平稳性测试。(在实践中,最好将测试统计量与临界值进行比较,以做出真正的平稳性判断。)

❹ 生成信息的索引序列

❺ 从测试统计量中提取临界值

运行此代码后,我们得到图 5.6 的结果,并打印到标准输出。

05-06

图 5.6 增强迪基-富勒测试的平稳性结果。这就是我们在列表 5.5 中运行代码时将看到的内容。

好吧,那很酷。但这意味着什么呢?

检验统计量(总是负数)是衡量时间序列包含单位根的邻近性的度量。(如果必须对时间序列应用多个单位根——例如,多个差分函数——以使其基本平坦,那么它就越不平稳。)用非数学术语来说,如果检验统计量小于临界值,则该序列将被确定为平稳。在这种情况下,我们的检验统计量值远高于临界值,因此给出了一个接受零假设的 p 值,我们可以相当自信地说,“这不是平稳的”(adfuller测试的零假设是时间序列是非平稳的)。

注意:如果你对测试背后的理论和数学感兴趣,我强烈建议你搜索原始研究论文:“Efficient Tests for an Autoregressive Unit Root” by Graham Elliot et al. (1996) 以及在期刊出版物“Distribution of the Estimators for Autoregressive Time Series with a Unit Root” by D. A. Dickey and W. A. Fuller (1979) 中阐述的基础单位根理论。

其中还有其他有趣的数据——特别是发现的滞后数。我们可以以另一种方式查看这个值,这有助于我们确定在用基于 ARIMA 模型进入建模阶段时应使用的设置。考虑到我们在这里查看的是月度数据,数字 13 似乎有点奇怪。如果我们盲目地只将这个值作为模型中的季节性(周期)成分,我们可能会得到一些相当糟糕的结果。然而,我们可以通过查看图 5.7 中的某些趋势分解来验证这一点。

我们将尝试使用 statsmodels 内置功能有效地分解信号中的趋势、季节性和残差,以帮助告知我们在建模实验中需要使用的某些设置。幸运的是,该软件包的作者不仅构建了分解方法,还提供了一个很好的可视化,我们可以轻松地绘制出来,如下面的列表所示。让我们看看如果我们使用adfuller报告中的滞后计数作为季节性周期会发生什么。

列表 5.6 季节性分解趋势

from statsmodels.tsa.seasonal import seasonal_decompose
decomposed_trends = seasonal_decompose(jfk_asc['Domestic Passengers'], period=13)❶
trend_plot = decomposed_trends.plot()                                            ❷
plt.savefig("decomposed13.svg", format='svg')                                    ❸

❶ 使用 adfuller 滞后值 13 进行季节性分解

❷ 获取用于存储的绘图引用。(它将自动以内联方式显示。)

❸ 保存绘图以供以后参考

图 5.7 显示了当执行列表 5.6 中的代码时该图表的形状。

05-07

图 5.7 趋势分解图,从上到下包括:原始数据、提取的趋势、季节性成分和残差。这似乎不太对。

这不是最引人注目的数据,对吧?残差(底部面板)似乎存在一个信号。残差应该是从数据中提取出一般趋势和季节性之后留下的未解释的噪声。但在这里,似乎还有相当多的实际可重复信号。让我们尝试不同的运行,但指定周期为 12,如图 5.8 所示。

05-08

图 5.8 将周期设置为 12 而不是 13 的趋势分解图。这要好一些。

在图 5.8 中,使用 12 个周期的评估看起来明显比之前的 13 个周期的测试要好。我们的趋势很平滑,季节性看起来很好地匹配了数据中重复模式的周期性,残差(大部分)是随机的。当我们第六章进行测试时,我们会记住这个值。

在此之前完成这项准备工作的重要性在于为我们的测试提供信息。这是以数据知识为依据指导测试,从而能够快速迭代实验。

请记住,在测试阶段,我们将评估九种预测方法。我们越快确定这九种中哪两种最有希望,我们就越快可以忽略其他七种,并且作为一个团队,我们可以更快地向我们的 MVP 截止日期迈进。

我们的数据有多干净?

数据清洁问题是一个重要原因,导致 MVP 的延期时间远超过对企业的承诺。识别不良数据点不仅对建模训练效果至关重要,而且有助于向企业讲述为什么模型的一些输出有时可能不够准确的原因。构建一系列可视化,可以传达潜在因素、数据质量问题和其他可能影响解决方案的不可预见元素,可以在与项目业务单元的讨论中作为强大的工具。

我们将不得不解释的关于本项目预测的最重要的一点是,它将不会,也不能成为一个无误的系统。在我们的数据集中仍有许多未知因素——影响趋势的元素,这些元素要么过于复杂难以追踪,要么建模成本过高,或者几乎无法预测,这些都需要输入到算法中。对于单变量时间序列模型,模型中除了趋势数据本身外,没有其他任何东西。在更复杂的实现中,例如窗口方法和长短期记忆(LSTM)循环神经网络(RNNs)等深度学习模型,尽管我们可以创建包含更多信息的向量,但我们并不总是有能力或时间收集所有可能影响趋势的特征。

为了帮助进行这次对话,我们可以看看一种简单的方法来识别与季节性影响趋势相比差异巨大的异常值。使用序列数据的一个相对简单的方法是在排序数据上使用差分函数。这可以通过以下列表中的示例实现。

列表 5.7 时间序列差分函数和可视化

from datetime import datetime
jfk_asc['Log Domestic Passengers'] = np.log(jfk_asc['Domestic Passengers'])   ❶
jfk_asc['DiffLog Domestic Passengers month'] = jfk_asc['Log Domestic Passengers'].diff(1)                                                    ❷
jfk_asc['DiffLog Domestic Passengers year'] = jfk_asc['Log Domestic Passengers'].diff(12)                                                    ❸
fig, axes = plt.subplots(3, 1, figsize=(16,12))                               ❹
boundary1 = datetime.strptime('2001-07-01', '%Y-%m-%d')                       ❺
boundary2 = datetime.strptime('2001-11-01', '%Y-%m-%d')
axes[0].plot(jfk_asc['Domestic Passengers'], '-', label='Domestic Passengers')❻
axes[0].set(title='JFK Domestic Passengers')
axes[0].axvline(boundary1, 0, 2.5e6, color='r', linestyle='--', label='Sept 11th 2001')                                                      ❼
axes[0].axvline(boundary2, 0, 2.5e6, color='r', linestyle='--')
axes[0].legend(loc='upper left')
axes[1].plot(jfk_asc['DiffLog Domestic Passengers month'], label='Monthly diff of Domestic Passengers')                                      ❽
axes[1].hlines(0, jfk_asc.index[0], jfk_asc.index[-1], 'g')
axes[1].set(title='JFK Domestic Passenger Log Diff = 1')
axes[1].axvline(boundary1, 0, 2.5e6, color='r', linestyle='--', label='Sept 11th 2001')
axes[1].axvline(boundary2, 0, 2.5e6, color='r', linestyle='--')

axes[1].legend(loc='lower left')
axes[2].plot(jfk_asc['DiffLog Domestic Passengers year'], label='Yearly diff of Domestic Passengers')
axes[2].hlines(0, jfk_asc.index[0], jfk_asc.index[-1], 'g')
axes[2].set(title='JFK Domestic Passenger Log Diff = 12')
axes[2].axvline(boundary1, 0, 2.5e6, color='r', linestyle='--', label='Sept 11th 2001')
axes[2].axvline(boundary2, 0, 2.5e6, color='r', linestyle='--')
axes[2].legend(loc='lower left')
plt.savefig("logdiff.svg", format='svg')                                      ❾

❶ 获取原始数据的对数以减少后续步骤的差异幅度

❷ 获取每个位置值相对于指定滞后值的单位差分。在这里,我们正在查看立即前一个值。

❸ 获取前 12 个月值的差分(与去年的差异,因为我们的数据是按月计算的)

❹ 生成绘图结构,以便我们可以创建这三个单独绘图的单一图像

❺ 创建 x 轴参考点,以说明序列数据中的异常期(有助于向询问预测失败原因的业务单元成员进行解释)

❻ 如果要生成用于与业务其他部分共享的图形,始终绘制原始数据。这将让您免于以后制作复杂得令人难以置信的幻灯片。

❼ 绘制我们想要突出显示的静态边界,说明不可预见潜在因素如何影响趋势

❽ 以多种方式显示数据中的突出异常,可以帮助更清楚地传达潜在因素的影响。

❾ 无论平台、可视化技术还是流程,保存我们生成的所有绘图以供以后参考都是一个好习惯。

当我们执行此操作时,我们得到图 5.9 中所示的绘图(以及保存到我们共享笔记本目录中的 SVG 图像)。

05-09

图 5.9 从列表 5.7 的异常值分析演示

我们现在对数据的外观有了一些了解。我们创建了演示图和基本趋势分解,并收集了有关这些趋势外观的数据。代码有点粗糙,读起来像脚本。如果我们不花点时间通过使用实用函数使此代码可重用,我们可能会发现每次有人想要生成这样的可视化时,他们都会在代码库中大量复制粘贴。

5.2.2 从脚本到可重用代码的迁移

返回到及时性的主题,如果我们专注于使用可重用代码,那么对项目方向做出决策的紧迫性可以降低。这不仅使代码库更干净(并且减少了由多个人创建的完全相同的东西的版本),还有助于在 MVP(和开发)阶段标准化项目的元素。减少混淆、加快决策时间、在笔记本和脚本中减少混乱,所有这些都是在努力最大化业务对项目有足够信心以继续开发工作的可能性。

我们在这里进行了大量的脚本编写,用于趋势分析和我们的 JFK 国内乘客数据的可视化。这完全适合快速检查事物,对于实验的早期阶段来说也是完全可以理解的(我们都会这样做,任何说否则的人都是在撒谎)。然而,当团队分头进行建模活动时,每个人都构建自己的可视化、自己的类似测试的实现以及可以相对容易地合并到标准函数中的代码,这将是极其浪费的。我们(应该)最不希望看到的是,散布着多个笔记本,它们包含大量几乎完全相同的代码,只是略有修改。虽然使用神奇的复制和粘贴命令可能看起来很方便,但最终会对生产力和理智造成破坏。更好的做法是创建函数。

我当然不建议在这个阶段为这些实用函数构建一个包级项目。这项工作将在项目的实际开发阶段进行,在漫长的生产发布之路上。

现在,让我们将这些有用的可重复代码片段,用于操作原始数据、可视化趋势和从中提取信息,整理成标准的基本函数集合。这项工作将为我们节省数十个小时,尤其是在将要测试针对其他机场数据的不同实现时。我们绝对不希望做的事情是复制和粘贴一大块脚本以展示可视化和分析,这会让每个人都想知道哪种方法最好,造成大量重复工作,并生成难以维护的代码蔓延。

让我们看看列表 5.1 中的数据集导入脚本,看看一个获取数据并正确格式化的函数可能是什么样子。为了使导入函数有用,我们需要获取包含在此文件中的机场列表,能够应用过滤以获取单个机场,并指定与数据相关的时间序列周期性。以下列表显示了这些函数中的每一个。

列表 5.8 数据导入和格式化函数

AIRPORT_FIELD = 'Airport Code'                                         ❶

def apply_index_freq(data, freq):                                      ❷
    return data.asfreq(freq)

def pull_raw_airport_data(file_location):                              ❸
    raw = pd.read_csv(file_location)
    raw = raw.copy(deep=False)                                         ❹
    raw['Month'] = pd.to_datetime(raw['Month'], format='%b').dt.month  ❺
    raw.loc[:, 'Day'] = 1                                              ❻
    raw['date'] = pd.to_datetime(raw[['Year', 'Month', 'Day']])        ❼
    raw.set_index('date', inplace=True)                                ❽
    raw.index = pd.DatetimeIndex(raw.index.values, freq=raw.index.inferred_freq)                                     ❾
    asc = raw.sort_index()
    return asc

def get_airport_data(airport, file_location):                          ❿
    all_data = pull_raw_airport_data(file_location)
    filtered = all_data[all_data[AIRPORT_FIELD] == airport]
    return filtered

def filter_airport_data(all_data, airport):
    filtered_data = all_data[all_data[AIRPORT_FIELD] == airport]
    return filtered_data

def get_all_airports(file_location):                                   ⓫
    all_data = pull_raw_airport_data(file_location)
    unique_airports = all_data[AIRPORT_FIELD].unique()
    return sorted(unique_airports)

❶ 定义一个静态变量,用于包含机场键的列(为了最小化代码中的字符串替换,如果需要更改的话)

❷ 为 DataFrame 索引设置时间序列频率的函数

❸ 主要数据获取和格式化函数

❹ 设置导入数据的副本,以便可以安全地修改

❺ 从原始数据中的字符串日期值中提取月份

❻ 创建一个日期字段(月份的第一天),以便将编码转换为日期对象

❼ 生成适当的 NumPy 日期时间格式(对于时间序列建模是必需的)

❽ 将 DataFrame 的索引设置为日期列(对于绘图和建模很有用)

❾ 将索引的属性设置为推断出的频率

❿ 确保 DataFrame 已经按日期索引排序,以防止以后数据序列提取时出现问题

⓫ 返回数据中包含的所有机场的列表的实用函数

在这些函数建立之后,它们可以被每个将在实验阶段测试预测项目解决方案的子团队使用。通过更多的工作,这些都可以在开发阶段模块化成一个类,以创建一个标准化的、可测试的生产级最终项目实现(在第 9、10 和 14 章中介绍)。这些的使用可以像下面的列表一样简单。

列表 5.9 通过可重复使用的函数摄取数据

DATA_PATH = '/opt/notebooks/TCPD/datasets/jfk_passengers/air-passenger-traffic-per-month-port-authority-of-ny-nj-beginning-1977.csv'
jfk = get_airport_data('JFK', DATA_PATH)      ❶
jfk = apply_index_freq(jfk, 'MS')             ❷

❶ 使用函数 get_airport_data()获取作为日期索引的 pandas DataFrame 的数据

❷ 将正确的周期性时间应用到 DataFrame 的日期索引上(MS 代表“月起始频率”)

让我们看看我们可以做的另一个修改,专注于在列表 5.7 中创建并在图 5.9 中展示的异常值可视化脚本。我们将看到这个脚本如何被改编成一行使用,这极大地简化了这些图表的生成,而无需使其完全通用(这将需要大量的时间和精力)。尽管这个可视化逻辑的函数表示稍微复杂一些,并且需要更多的代码行,但最终结果将是非常值得的,因为我们可以用一行代码生成图表。

列表 5.10 可重复使用的可视化异常数据的函数

from datetime import datetime
from dateutil.relativedelta import relativedelta

def generate_outlier_plots(data_series, series_name, series_column, event_date, event_name, image_name):                                   ❶
    log_name = 'Log {}'.format(series_column)                               ❷
    month_log_name = 'DiffLog {} month'.format(series_column)
    year_log_name = 'DiffLog {} year'.format(series_column)
    event_marker = datetime.strptime(event_date, '%Y-%m-%d').replace(day=1) ❸
    two_month_delta = relativedelta(months=2)                               ❹
    event_boundary_low = event_marker - two_month_delta
    event_boundary_high = event_marker + two_month_delta
    max_scaling = np.round(data_series[series_column].values.max() * 1.1, 0)❺
    data = data_series.copy(deep=True)                                      ❻
    data[log_name] = np.log(data[series_column])                            ❼
    data[month_log_name] = data[log_name].diff(1)
    data[year_log_name] = data[log_name].diff(12)
    fig, axes = plt.subplots(3, 1, figsize=(16, 12))                        ❽
    axes[0].plot(data[series_column], '-', label=series_column)             ❾
    axes[0].set(title='{} {}'.format(series_name, series_column))
    axes[0].axvline(event_boundary_low, 0, max_scaling, color='r', linestyle='--', label=event_name)
    axes[0].axvline(event_boundary_high, 0, max_scaling, color='r', linestyle='--')
    axes[0].legend(loc='best')
    axes[1].plot(data[month_log_name], label='Monthly diff of {}'.format(series_column))
    axes[1].hlines(0, data.index[0], data.index[-1], 'g')
    axes[1].set(title='{} Monthly diff of {}'.format(series_name, series_column))
    axes[1].axvline(event_boundary_low, 0, max_scaling, color='r', linestyle='--', label=event_name)
    axes[1].axvline(event_boundary_high, 0, max_scaling, color='r', linestyle='--')
    axes[1].legend(loc='best')
    axes[2].plot(data[year_log_name], label='Year diff of {}'.format(series_column))
    axes[2].hlines(0, data.index[0], data.index[-1], 'g')
    axes[2].set(title='{} Yearly diff of {}'.format(series_name, series_column))
    axes[2].axvline(event_boundary_low, 0, max_scaling, color='r', linestyle='--', label=event_name)
    axes[2].axvline(event_boundary_high, 0, max_scaling, color='r', linestyle='--')
    axes[2].legend(loc='best')
    plt.savefig(image_name, format='svg')
    return fig

❶ 函数通常不会有这么多参数。元组打包运算符和**字典打包运算符*允许传递多个参数。在这个例子中,我明确地命名它们以减少混淆。

❷ 使用字符串插值来构建对 DataFrame 中动态创建的字段的静态引用

❸ 将传入的日期转换为与 DataFrame 的日期索引匹配的东西。在这个例子中,转换传入的值是可以的。在一般实践中(尤其是对于库),正确的行动是对于无效的传入配置引发异常(验证值是否存在于索引中可能是一种方法)以确保函数的最终用户不会得到意外的结果。

❹ 创建一个基于 DataFrame 时间序列索引频率的统一缩放的时间差分

❺ 创建一个基于数据范围的垂直线的最大界限

❻ 对我们将要对数据进行的一系列突变创建一个深拷贝(在不同内存地址的对象复制)。这是一个有用的操作,尤其是在机器学习(ML)中,我们可能想要改变这一点,以便后续对这个函数的调用不会修改源数据,使我们能够对调用此数据的集合进行循环或 map/lambda 操作。

❼ 执行与脚本版本中之前执行相同的日志和差异函数,但这些函数是通过插值名称参数化的,因此不需要硬编码

❽ 这些值也可以作为此函数(figsize 值)的参数,以便用户可以在绘图大小方面有灵活性。在这个例子中,我们保留它们为硬编码。

❾ 从这里到下面的所有代码与之前的脚本版本完全相同,唯一的区别是我们正在使用传入参数的动态变量以灵活的方式构建一切。

插值是你的朋友

在机器学习领域,我们做的大部分工作都涉及到传递字符串引用。这可能会有些繁琐。我发现,处理配置中的字符串比手动覆盖代码中不同用途的字符串还要繁琐。

插值 是一个非常强大的工具,一旦你学会了如何正确使用它,就能避免无数因输入错误导致的挫败和失败。然而,尽管它非常出色,但人们正确使用它的方法与“懒惰”的实现方式大相径庭。

你如何以懒惰的方式构建字符串?通过使用连接运算符。

假设我们想从列表 5.10 中构建一个字符串,即 axes[1] 的标题。在连接的懒惰实现中,我们可能会这样做:

axes[1].set(title=series_name + ' Monthly diff of ' + series_column)

虽然技术上正确(它将正确组装字符串),但看起来很糟糕,难以阅读,并且极易出错。如果你忘记在静态定义的字符串中间添加前导和尾随空格怎么办?如果有人后来需要更改该字符串怎么办?如果需要添加标题以提供一打不同的字符串怎么办?在某个时候,代码将开始看起来很业余,难以阅读。

使用 '{}'.format() 语法(如果声明变量和类型格式化也在其中,则加分)可以让你避免烦人的错误,并使你的代码看起来更整洁,这对于维护性来说应该是最终目标。如果你不喜欢格式化语法,你总是可以使用 f-strings,这是一种优化且更简短的将值插入字符串的方法。在这本书中,我坚持使用较旧的格式,以便让熟悉这种格式的人更容易理解代码,但在实践中我使用 f-strings。

执行列表 5.10 中的代码并构建可视化(也可以存储以供以后参考)就像以下代码一样简单。

列表 5.11 使用离群值可视化函数

irrelevant_outlier = generate_outlier_plots(jfk, 'JFK', 'International Passengers', '2003-10-24', 'Concorde Retired', 'irrelevant_outlier.svg')

如果我们执行此操作,我们将得到图 5.10 中所示的可视化。请注意,我们不必指定日期窗口、格式或其他任何样板代码,因为所有这些都是根据函数的配置参数动态生成的。我们甚至可以使用此函数绘制国际乘客计数,而不是像列表 5.7(及其后续可视化)中那样将所有值硬编码到脚本中。

05-10

图 5.10 使用函数生成此图提供了适应性,其可重用性允许快速验证数据异常,节省时间。

为了展示花一点额外时间构建函数,即使是实验验证代码的好处,让我们看看我们可以如何使用来自完全不同的机场,拉瓜迪亚(LGA)的数据生成。如果我们编写了原始的异常值绘图脚本,并希望为 LGA 生成相同的绘图,我们就必须复制 JFK 脚本,经过繁琐的过程,逐个覆盖对 JFK 的引用,将绘图和分析字段从 International Passengers 更改为 Domestic Passengers,并希望我们能够替换所有引用,以防止错误的时间序列或值被绘制。(由于 Python REPL 有对象常性的概念,所有引用都保留在内存中,直到内核 REPL 停止。)接下来将展示具有绘图功能实现的代码。

列表 5.12 用于异常分析实验阶段函数的使用

laguardia = get_airport_data('LGA', DATA_PATH)                       ❶
laguardia = apply_index_freq(laguardia, 'MS')                        ❷
useful_outlier = generate_outlier_plots(laguardia, 'LGA', 'Domestic Passengers', '2001-09-11', 'Domestic Passenger Impact of 9/11', 'lga_sep_11_outlier.svg')                                       ❸

❶ 从拉瓜迪亚获取数据。(这仅用于演示目的。在一个正确开发的解决方案中,我们只会加载一次数据,并在内存中的 DataFrame 上直接应用过滤器。)

❷ 以与 JFK 数据相同的方式设置“月初”的索引频率

❸ 生成可视化并将其保存到磁盘。此函数的参数使得生成这些绘图变得简单,但更重要的是,可重复。

通过这三行简短的代码,我们可以获取一个存储到磁盘的新可视化,并适当地标记了我们发现的指示异常期,而无需重新实现构建数据集和可视化的所有原始代码。图 5.11 显示了生成的可视化。

05-11

图 5.11 列表 5.12 的绘图结果

注意:这里显示的函数仅用于说明目的。后面的章节将介绍构建函数和机器学习方法的正确方式,以便在单个函数或方法中减少操作步骤。目前,重点是简单地说明即使在项目的早期阶段,可重用代码的好处。

应该创建函数的情况

在实验过程中,除了立即解决问题的焦点之外,我们还应该考虑哪些代码元素需要模块化以供重用。并非解决方案的每个方面都需要达到生产就绪状态,尤其是在早期阶段,但开始思考哪些项目方面需要多次引用或执行是有帮助的。

从快速原型设计转向构建函数有时会感觉像是暂时偏离了你的目标。重要的是要意识到,通过花上一两个小时创建一个通用的可重复任务参考,你可能会在之后节省数十个小时。

这些小时主要是由这样一个简单的事实节省下来的:当你努力将脚本解决方案转换为经过良好开发的机器学习代码库时,你不需要审查那么多的实现。而不是在代码中散布数十个可视化和评分函数,你将只剩下一些单用途函数,这些函数需要作为一个单一单元来查看和评估。

在机器学习(ML)领域,我通常会尽早创建函数的一些元素如下:

  • 数据摄取和数据清洗

  • 评分和错误计算

  • 通用数据可视化

  • 验证和预测可视化

  • 模型性能报告(例如,ROC 曲线和混淆矩阵)

许多其他实例在构建机器学习解决方案的早期阶段都适合进行“函数处理”。在构建项目早期阶段时,需要记住的重要一点是,要么立即留出时间创建可重用代码,要么至少将代码标记为易于实施,以便一旦可行就易于识别并采取行动。

我们为什么要讨论函数的好处?难道每个人都知道何时使用它们吗?

预测建模实验的现实情况是,大多数机器学习从业者最终会花费大量精力在特征工程、数据验证和建模上。不断的代码重写和测试让我们所有人都习惯了这样一个事实:我们构建的项目实验代码可能会迅速退化成半实现、注释掉且通常难以阅读的混乱状态。

有时候感觉,在想要测试新事物的时候,直接从笔记本的上方单元格复制一大块代码来快速实现某个功能似乎更容易。结果却导致了一团糟的混乱代码,需要巨大的努力才能将其整理成适合进一步开发的形态。

大多数时候,当我看到(或过去做过)这样的绿色田野实验时,一旦确定了方法,所有的原始测试代码就被简单地废弃了。然而,情况并不一定如此。如果在这一阶段稍加注意,后续的开发阶段可以更加高效。

如果你在一个团队中工作,这些问题只会变得更加复杂。想象一下,如果这个项目是由六个数据科学子团队执行的。到测试阶段结束时,仅数据摄取就有几十种实现,至少有十种方式来绘制数据并运行时间序列数据的统计分析。标准化和使用函数可以帮助减少这种冗余代码。

5.2.3 关于为实验构建可重用代码的最后一则注意事项

在我们继续进行这个项目实验的建模阶段之前,让我们看看另一个函数。这个函数将帮助我们从一个机场的列表中获取一个特定时间序列(乘客数据系列之一)的有用快照,该列表是每个模型的比较位置。

之前我们查看过绘制异常值(第 5.2.2 节)和获取趋势分解图(第 5.2.1 节)。如果我们有两个额外的图表,将非常有价值,帮助我们了解我们应该为将要测试的一些模型类型使用的初始设置。这两个图表是自相关和偏自相关图。

自相关 是一种算法,它将在时间序列和相同序列的滞后值(相同数据系列的先前步骤)之间运行皮尔逊测试,结果在 -1 到 +1 的范围内,表示这些滞后之间的相对相关性。+1 的值表示最大正相关,表明在整个数据系列中指定滞后位置上的值完美同步(如果时间序列中每 10 个值有一个可重复的模式,这将显示为 +1 的最大正相关)。来自自相关测试绘制的图表将显示计算出的每个滞后值,以及从 0 开始向外的蓝色圆锥体,表示置信区间(默认为 95%)。超出这个蓝色圆锥体的点被认为是统计上显著的。自相关测试在滞后测量中包括直接依赖信息以及间接影响。

由于自相关测试的性质对此有影响,单独查看时可能会有些误导。在分析时间序列数据时,除了自相关测试外,还有一个有用的附加图,即部分自相关测试也被使用。这个附加测试以与自相关类似的方式评估每个滞后位置,但它更进一步,通过消除先前滞后值对被测量的独立滞后引入的影响。通过消除这些影响,可以测量该特定值处的直接滞后关系。

这为什么很重要?

我们可以使用这些图表中揭示的值作为我们建模的起点(即设计用于自回归的模型)。我们将在第六章中更深入地探讨这一点。

目前,我们应该确保在任何人开始建模之前,我们有一个标准化的方法一次性生成这些图表,以便所有团队都能快速生成这些可视化,以帮助他们调整。让我们创建一个简单的函数来绘制我们分析将要预测的序列所需的大部分内容。

列表 5.13 模型准备的标准时序可视化和分析

from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
def stationarity_tests(time_df, series_col, time_series_name, period, image_name, lags=12, cf_alpha=0.05, style='seaborn', plot_size=(16, 32)):
    log_col_name = 'Log {}'.format(series_col) 
    diff_log_col_name = 'LogDiff {}'.format(series_col) 
    time_df[log_col_name] = np.log(time_df[series_col])
    time_df[diff_log_col_name] = time_df[log_col_name].diff()                ❶
    decomposed_trend = seasonal_decompose(time_df[series_col], period=period)❷
    df_index_start = time_df.index.values[0]
    df_index_end = time_df.index.values[len(time_df)-1]                      ❸
    with plt.style.context(style=style):                                     ❹
        fig, axes = plt.subplots(7, 1, figsize=plot_size)
        plt.subplots_adjust(hspace=0.3)                                      ❺
        axes[0].plot(time_df[series_col], '-', label='Raw data for 
          {}'.format(time_series_name))                                      ❻
        axes[0].legend(loc='upper left')
        axes[0].set_title('Raw data trend for {}'.format(time_series_name)) 
        axes[0].set_ylabel(series_col)
        axes[0].set_xlabel(time_df.index.name)
        axes[1].plot(time_df[diff_log_col_name], 'g-', label='Log Diff for   ❼
          {}'.format(time_series_name))
        axes[1].hlines(0.0, df_index_start, df_index_end, 'r', label='Series 
          center')
        axes[1].legend(loc='lower left')
        axes[1].set_title('Diff Log Trend for outliers in 
          {}'.format(time_series_name))
        axes[1].set_ylabel(series_col)
        axes[1].set_xlabel(time_df.index.name) 
        fig = plot_acf(time_df[series_col], lags=lags, ax=axes[2])           ❽
        fig = plot_pacf(time_df[series_col], lags=lags, ax=axes[3])          ❾
        axes[2].set_xlabel('lags')
        axes[2].set_ylabel('correlation')
        axes[3].set_xlabel('lags')
        axes[3].set_ylabel('correlation')
        axes[4].plot(decomposed_trend.trend, 'r-', label='Trend data for 
          {}'.format(time_series_name))                                      ❿
        axes[4].legend(loc='upper left')
        axes[4].set_title('Trend component of decomposition for 
          {}'.format(time_series_name))
        axes[4].set_ylabel(series_col)
        axes[4].set_xlabel(time_df.index.name)
        axes[5].plot(decomposed_trend.seasonal, 'r-', label='Seasonal data for
          {}'.format(time_series_name))                                      ⓫
        axes[5].legend(loc='center left', bbox_to_anchor=(0,1))
        axes[5].set_title('Seasonal component of decomposition for 
          {}'.format(time_series_name))
        axes[5].set_ylabel(series_col)
        axes[5].set_xlabel(time_df.index.name)
        axes[6].plot(decomposed_trend.resid, 'r.', label='Residuals data for 
          {}'.format(time_series_name))                                      ⓬
        axes[6].hlines(0.0, df_index_start, df_index_end, 'black', 
          label='Series Center')
        axes[6].legend(loc='center left', bbox_to_anchor=(0,1))
        axes[6].set_title('Residuals component of decomposition for 
          {}'.format(time_series_name))
        axes[6].set_ylabel(series_col)
        axes[6].set_xlabel(time_df.index.name)
        plt.savefig(image_name, format='svg')                                ⓭
        plt.tight_layout()
    return fig                                                               ⓮

❶ 计算异常值图的日志差分数据

❷ 将序列分解为趋势成分、季节性成分和残差作为 NumPy 序列

❸ 提取索引的起始和结束值,以便绘制水平线

❹ matplotlib.pyplot.plot 的包装器,允许设置图形样式和更高效的绘图单元格渲染

❺ 对渲染的图形进行轻微调整,以确保标题和轴标签不重叠

❻ 绘制原始数据图,为其他所有图表提供视觉参考

❼ 异常值数据图(日志差分)

❽ 自相关图,为调整(连同部分自相关)提供洞察,用于自回归模型

❾ 部分自相关图,为调整自回归模型提供洞察

❿ 从序列中提取的趋势图示

⓫ 序列季节性信号的图示

⓬ 系列残差的图示

⓭ 保存图形以供以后参考和演示

⓮ 返回组合图形,以便在需要额外处理的情况下使用

现在我们来看看这段代码会产生什么结果。图 5.12 是执行以下代码的结果。

05-12

图 5.12 模型准备的全趋势可视化套件,应用于纽瓦克国际机场国内旅客数据

列表 5.14 纽瓦克国内旅客趋势可视化

ewr = get_airport_data('EWR', DATA_PATH)                                  ❶
ewr = apply_index_freq(ewr, 'MS')                                         ❷
ewr_plots = stationarity_tests(ewr, 'Domestic Passengers', 'Newark Airport', 12, 'newark_domestic_plots.svg', 48, 0.05)                           ❸

❶ 从原始数据源获取 EWR(纽瓦克国际机场)的数据

❷ 在 DataFrame 的日期索引上应用频率

❸ 为指定的时序(国内旅客)生成纽瓦克的快照图表

现在我们终于准备开始模型评估了。我们有一些标准的可视化,它们被很好地封装在可重用的函数中,我们知道哪些机场将被用于测试,而我们开发的工具将确保每个实验测试都将使用相同的可视化集和数据预处理步骤。我们已经消除了可能开发的大部分样板代码,并减少了开始解决我们试图解决的核心理问题的所需时间:预测。

当我们在下一章开始建模阶段时,我们将构建额外的标准可视化。目前,我们可以保证一件事:团队不会重新发明轮子或过度使用复制粘贴。

摘要

  • 对解决问题可能方法的彻底研究涉及通过数据集统计分析、模型 API 审查、API 文档阅读、快速原型设计和目标比较的时间限制评估。

  • 通过适当的统计评估和可视化来深入了解候选特征数据将有助于早期发现问题。从对项目训练数据的清晰和定义良好的熟悉状态开始,将在项目开发周期后期消除昂贵的返工。

6 实验行动:测试和评估一个项目

本章节涵盖

  • 评估机器学习项目的潜在方法

  • 客观选择项目实施的方法

前一章涵盖了为了最小化与项目实验阶段相关的风险而应采取的所有准备工作。这些工作包括进行研究以提供解决问题的选项,以及构建团队成员在原型设计阶段可以利用的有用功能。我们将继续本章的前一个场景,即机场旅客需求预测的时间序列建模项目,同时关注应用于实验测试的方法,这些方法将有助于降低项目失败的可能性。

我们将花费时间介绍测试方法,因为这个项目开发阶段对于两个主要原因至关重要。首先,在一种极端情况下,如果测试的方法(批判性和客观地评估)不足,所选的方法可能不足以解决实际问题。在另一种极端情况下,测试过多的选项并深入到过大的程度可能导致实验原型设计阶段在业务眼中耗时过长。

通过遵循旨在快速测试想法的方法论,使用统一的评分方法以实现方法之间的可比性,并专注于评估方法的表现而非预测的绝对准确性,可以降低项目放弃的可能性。

图 6.1 比较了在机器学习项目中原型设计的两种极端。中间地带,即适度方法,在我所领导或合作过的团队中显示出最高的成功率。

如此图表所示,两端的极端方法通常会导致截然相反的问题。在左侧,由于业务对 DS 团队交付解决方案能力的信心不足,项目取消的概率极高。除非极端幸运,否则团队随意选择且几乎未经测试的解决方案可能根本不是最优的。他们实施解决方案的方式同样可能糟糕、昂贵且脆弱。

然而,在图表的另一侧,却存在一个完全不同的问题。这里展示的受学术影响的彻底性是值得赞扬的,对于进行原创研究的团队来说效果很好。但对于在工业界工作的 DS 团队来说,彻底评估所有可能解决方案所需的时间将远远超过大多数公司所能容忍的耐心。为每种方法定制特征工程、全面评估流行框架中可用的模型,以及可能的新算法的实施,都是沉没成本。虽然它们作为一系列采取的行动更加科学严谨,但花费时间构建每种方法以正确检验哪种方法最有效,意味着其他项目无法得到工作。正如古老的谚语所说,时间就是金钱,从时间和金钱的角度来看,构建完整的方法来解决问题是昂贵的。

为了以应用为导向的方式探索一种有效的方法,我们将继续使用前一章中关于时间序列建模的场景。在本章中,我们将从图 6.1 的中间地带开始,到达最有可能导致成功 MVP 的候选方法。

在这个阶段,要抵制追求完美的诱惑。

作为数据科学家,我们工作中的自然倾向是构建尽可能最优和数学上正确的解决方案。这是一个重要的驱动力,但应该是整个项目的目标。在早期测试阶段,追求完美实际上可能对项目的成功产生不利影响。

尽管项目的商业赞助者都希望拥有最佳可能的解决方案,但他们对这个解决方案的可见性仅限于你最终决定的方法。他们还专注于开发这个解决方案所需的时间(以及其成本)。他们对你为了找出最佳解决方案所做的一切没有可见性,并且并不真正关心你在发现最佳解决方案的过程中测试了多少东西。

在原型设计和测试方法这个阶段,最好是摒弃你天生的全面探索所有解决问题选项的欲望,而是专注于高效地找到最可能的方法。通过这种方式调整焦点,并将你的范式转变为将交付时间视为项目第二重要的因素,你将确保解决方案有更高的机会沿着生产路径进一步推进。

06-01

图 6.1 机器学习解决方案原型设计工作的滑动尺度

6.1 测试想法

在第五章结束时,我们处于一个准备评估机场乘客预测的不同单变量建模方法的状态。现在,团队准备分成小组;每个小组将专注于实施已发现的各种研究选项,不仅全力以赴以产生尽可能准确的结果,还要理解调整每个模型的细微差别。

在每个人都开始编写实现代码之前,还需要开发一些额外的标准化工具函数,以确保每个人都在评估相同的指标,生成相同的报告,并生成可以轻松显示不同方法优势和劣势的适当可视化。一旦完成这些,团队就可以开始评估和进行研究,每个团队都使用相同的核心功能和评分。图 6.2 概述了在项目模型原型阶段应遵守的典型实用工具、功能标准。

06-02

图 6.2 原型阶段的工作要素及其功能

如第 5.2 节所述,这一行动路径通常专注于监督学习项目工作。对于 CNN 等原型阶段,其工作方式会有很大不同(在构建模型性能的可读性评估方面有更多前期工作,尤其是如果我们谈论的是分类器)。但总的来说,如果遵循这些前期行动和原型不同解决方案的方法,将节省几周令人沮丧的重做和困惑。

6.1.1 在代码中设置指南

在第五章中,我们查看并开发了一套可视化工具和基本的数据摄入和格式化函数,每个团队都可以使用。我们建立这些工具的主要目的是:

  • 标准化——以便每个团队生成相同的图表、图形和指标,以便在不同方法之间进行连贯的比较

  • 沟通——以便我们可以生成可引用的可视化,向业务展示我们的建模工作是如何解决问题的

在项目工作的这个阶段满足这两个需求至关重要。如果没有标准化,我们可能会在 MVP(以及随后的完整解决方案)选择方法上做出错误的决定。此外,我们可能会浪费多个团队的时间,这些团队本应测试他们的方法,却正在构建实际上与可视化本质上做同样事情的实现。如果没有沟通方面,我们可能会留下令人困惑的指标分数值来报告,或者在最坏的情况下,向业务展示原始代码。任何一种方法都会在演示会议上导致灾难。

总是准备好不令人困惑的图表

作为一名初出茅庐的数据科学家(在我们被称为这个名字之前),我学到的最早的教训之一是,公司里的每个人并不都对统计数据有胃口。没有比向为项目提供资金的高管吹嘘你花费数月时间研究出的解决方案的真实性更好的方法了,你可以声称一些晦涩(对他们来说,不是对我们来说)的准确度分数、置信区间或其他数学指标。

作为一种物种,我们渴望在世界上找到秩序和模式。负熵(由莱昂·布里渊提出的一个术语)是一种自然的进化趋势,它被有效地编程进我们的身体中。正因为如此,数据的视觉表示,尤其是当它们被精心设计以简化高度复杂的系统时,总是作为沟通工具更加有效。

我强烈推荐,对于任何数据科学家正在工作的特定解决方案,都应该投入大量的思考和精力来思考并构建最有效且易于理解的视觉表示,以传达所使用的算法(或从头开发)的相应预测能力,以解决目标商业问题。这并不是说业务部门的每个人都将是无知的;相反,重点是视觉表示总是比其他任何手段在传达机器学习解决方案的信息方面更强大。

引用这个想法的原始传达者亨利克·易卜生的话,“一千个词也不如一个行动留下的深刻印象。”换句话说,正如弗雷德·R·巴纳德方便地改编的那样,“一图胜千言。”

在团队开始跳出自己的领域并过于专注于开发分配的解决方案之前,我们可能需要由更大的团队进行一次最终分析,以帮助了解他们的预测在视觉上的表现如何。记住,正如我们在第四章中讨论的那样,在这个实验阶段的结束时,团队需要以一种可以轻松被非机器学习和非技术受众消化的方式展示其发现。

实现这种沟通的最有效方式之一是通过简单的可视化。专注于展示方法输出的结果,并使用清晰简单的注释,不仅可以有利于测试的早期阶段,还可以在解决方案投入生产后用于报告其性能。避免使用没有视觉提示来解释其含义的混淆报告和指标表,将确保与业务进行清晰简洁的沟通。

基线比较可视化

为了有一个更复杂模型的基本参考,查看最简单的实现结果可能是有益的;然后我们可以看看我们提出的方案是否能做得更好。对于时间序列建模而言,这个基线可以采取简单移动平均和指数平滑平均的形式。这两种方法都不适用于项目的预测需求,但它们的输出结果可以用来在验证的保留期内查看我们的更复杂的方法是否会是一个改进。

为了创建团队可以用来查看这些关系的可视化,我们首先必须定义一个指数平滑函数,如以下列表所示。请记住,这一切都是为了标准化每个团队的工作,并建立一个有效的沟通工具,以传达项目的成功给业务。

列表 6.1 用于生成比较预测的指数平滑函数

def exp_smoothing(raw_series, alpha=0.05):            ❶
    output = [raw_series[0]]                          ❷
    for i in range(1, len(raw_series)):               ❸
        output.append(raw_series[i] * alpha + (1-alpha) * output[i-1])
    return output

❶ alpha 是平滑参数,为序列中的前一个值提供阻尼。 (值接近 1.0 具有强烈的阻尼效果,而相反,值接近 0.0 的阻尼效果较小。)

❷ 将序列的起始值添加到初始化正确的索引位置以进行遍历

❸ 对序列进行迭代,将指数平滑公式应用于每个值及其前一个值

为了进行额外的分析目的,需要提供一个补充函数来为这些简单的时间序列建模拟合生成指标和误差估计。以下列表提供了一个计算拟合平均绝对误差的方法,以及计算不确定性区间(yhat值)的方法。

列表 6.2 平均绝对误差和不确定性

from sklearn.metrics import mean_absolute_error
def calculate_mae(raw_series, smoothed_series, window, scale):
    res = {}                                                             ❶
    mae_value = mean_absolute_error(raw_series[window:], 
      smoothed_series[window:])                                          ❷
    res['mae'] = mae_value
    deviation = np.std(raw_series[window:] - smoothed_series[window:])   ❸
    res['stddev'] = deviation
    yhat = mae_value + scale * deviation                                 ❹
    res['yhat_low'] = smoothed_series - yhat                             ❺
    res['yhat_high'] = smoothed_series + yhat
    return res

❶ 实例化一个字典,用于将计算值放入 Currying 的目的地

❷ 使用标准的 sklearn mean_absolute_error 函数获取原始数据和平滑序列之间的 MAE

❸ 计算序列差异的标准差以计算不确定性阈值(yhat)

❹ 计算差分序列的标准基线 yhat 值

❺ 在平滑序列数据周围生成一个低和高 yhat 序列

注意:在这些代码列表中,import语句在函数上方需要的地方显示。这只是为了演示目的。所有的import语句都应该始终位于代码的顶部,无论是编写笔记本、脚本还是在 IDE 中作为模块。

现在我们已经定义了列表 6.1 和 6.2 中的两个函数,我们可以在另一个函数中调用它们,不仅生成可视化,还可以生成移动平均和指数平滑数据的系列。以下代码用于生成每个机场和乘客类型的参考数据和易于引用的可视化。

列表 6.3 生成平滑图

def smoothed_time_plots(time_series, time_series_name, image_name, smoothing_window, exp_alpha=0.05, yhat_scale=1.96, style='seaborn', plot_size=(16, 24)):
    reference_collection = {}                                              ❶
    ts = pd.Series(time_series)
    with plt.style.context(style=style):
        fig, axes = plt.subplots(3, 1, figsize=plot_size)  
        plt.subplots_adjust(hspace=0.3)
        moving_avg = ts.rolling(window=smoothing_window).mean()            ❷
        exp_smoothed = exp_smoothing(ts, exp_alpha)                        ❸
        res = calculate_mae(time_series, moving_avg, smoothing_window, 
          yhat_scale)                                                      ❹
        res_exp = calculate_mae(time_series, exp_smoothed, smoothing_window, 
          yhat_scale)                                                      ❺
        exp_data = pd.Series(exp_smoothed, index=time_series.index)        ❻
        exp_yhat_low_data = pd.Series(res_exp['yhat_low'], 
          index=time_series.index)
        exp_yhat_high_data = pd.Series(res_exp['yhat_high'], 
          index=time_series.index)
        axes[0].plot(ts, '-', label='Trend for {}'.format(time_series_name))
        axes[0].legend(loc='upper left')
        axes[0].set_title('Raw Data trend for {}'.format(time_series_name))
        axes[1].plot(ts, '-', label='Trend for {}'.format(time_series_name))
        axes[1].plot(moving_avg, 'g-', label='Moving Average with window: 
          {}'.format(smoothing_window))
        axes[1].plot(res['yhat_high'], 'r--', label='yhat bounds')
        axes[1].plot(res['yhat_low'], 'r--')
        axes[1].set_title('Moving Average Trend for window: {} with MAE of: 
          {:.1f}'.format(smoothing_window, res['mae']))                    ❼
        axes[1].legend(loc='upper left')
        axes[2].plot(ts, '-', label='Trend for {}'.format(time_series_name))
        axes[2].legend(loc='upper left')
        axes[2].plot(exp_data, 'g-', label='Exponential Smoothing with alpha: 
          {}'.format(exp_alpha))
        axes[2].plot(exp_yhat_high_data, 'r--', label='yhat bounds')
        axes[2].plot(exp_yhat_low_data, 'r--')
        axes[2].set_title('Exponential Smoothing Trend for alpha: {} with MAE 
          of: {:.1f}'.format(exp_alpha, res_exp['mae']))
        axes[2].legend(loc='upper left')
        plt.savefig(image_name, format='svg')
        plt.tight_layout()
        reference_collection['plots'] = fig
        reference_collection['moving_average'] = moving_avg
        reference_collection['exp_smooth'] = exp_smoothed
        return reference_collection

❶ 数据返回值的 Currying 字典

❷ 简单的时间序列移动平均计算

❸ 调用列表 6.1 中定义的函数

❹ 调用列表 6.2 中定义的函数以获取简单移动平均序列

❺ 调用列表 6.2 中定义的函数以获取指数平滑趋势

❻ 将 pandas 索引日期序列应用于非索引的指数平滑序列(以及 yhat 序列值)

❼ 使用字符串插值和数字格式化,使可视化更易读

我们将在下一列表中调用此函数。有了这些数据和预建的可视化,团队可以有一个易于使用且标准化的指南,在整个建模实验过程中进行参考。

列表 6.4 调用参考平滑函数以获取序列数据和可视化

ewr_data = get_airport_data('EWR', DATA_PATH)
ewr_reference = smoothed_time_plots(ewr_data['International Passengers'], 'Newark International', 'newark_dom_smooth_plot.svg', 12, exp_alpha=0.25)

当执行时,此代码将为子团队提供一个快速参考可视化(以及与移动平均和指数加权移动平均平滑算法比较的序列数据),如图 6.3 所示。

06-03

图 6.3 基于列表 6.4 中展示的 smoothed_time_plots()函数使用的参考趋势可视化

在此阶段将此样板可视化代码包装成函数(如列表 6.3 所示,并在列表 6.4 中使用)的目标有两个:

  • 便携性—每个团队都可以将此函数作为可引用的代码片段使用,作为其工作的依赖项,确保每个人都在生成完全相同的可视化。

  • 为生产做准备—作为函数,此代码可以轻松地移植到可视化类中,作为方法使用,不仅适用于本项目,也适用于未来的其他预测项目。

在创建可重用代码上花费少量时间可能在此阶段看起来不值得,尤其是考虑到我们一直关注解决方案原型的及时交付。但请放心,随着项目规模的扩大和复杂性的增加远远超出简单的预测问题,现在为模块化代码所做的相对较小的努力将节省大量的时间。

标准指标

在转向模型实验之前,团队需要实施的最后一件事是对预测预测的标准化测量,以保持对保留验证数据的评估。这项工作是为了消除关于每个实现有效性的任何争议。我们通过标准化有效地简化了每个实现优点的裁决,这不仅会在会议中节省时间,而且为每个的比较提供了一个强大的科学方法。

如果我们让每个团队自行确定其最优评估指标,比较它们将几乎不可能,导致测试的重做和项目进一步的延误。如果我们积累足够的这些可避免的延误,我们将大大增加项目放弃的可能性。

就指标进行争论听起来很愚蠢,对吧?

是的。是的,它确实如此。

我见过有人这样做吗?是的,我见过。

我做过吗?羞愧地说,是的,我希望我能把那些生命中的小时重新利用起来。

我作为接受者是否忍受过这种情况?我当然忍受过。

我见过它是导致项目取消的原因吗?不,那太荒谬了。

需要提到的是,时间是有限的。当构建一个解决方案来解决业务问题时,在业务单元继续做它之前的事情,或者会直接要求取消项目,基本上拒绝再次与团队合作之前,只能允许发生如此多的延迟。

围绕着关于使用哪个指标来评估模型持续争论的可避免和多余的延迟是愚蠢的,尤其是当我们考虑到计算模型评估的所有指标并保留其可参考的分数以供未来任何时候的后验评估是一项如此微不足道的时间投资时。只需收集与你要解决的问题相关的所有指标(值得注意的是,前面提到的例外——如果指标的计算复杂度如此之高,以至于获取它明显很昂贵,确保在编写代码之前捕获它是值得的)。使代码适应这种灵活性符合敏捷原则,允许快速转向,而无需进行大量重构来更改功能。

在 5.1.3 节中,我们讨论了团队将用于评估模型的协议指标:R 平方、均方误差(MSE)、均方根误差(RMSE)、平均绝对误差(MAE)、平均绝对百分比误差(MAPE)和解释方差。为了为每个将专注于实施建模测试的子团队节省大量时间,我们应该构建一些函数,这将使评分和标准化报告结果变得更加容易。

首先,我们需要实际实现 MAPE,因为它在 Python 库中(在撰写本文时)并不是作为评分指标 readily available。这个指标对于评估跨许多不同时间序列的预测的整体质量至关重要,因为它是一个缩放和标准化的值,可以用来比较不同的预测,而无需考虑序列值的幅度。

然而,它不应该作为唯一的测量指标,正如我们在实验规划中之前讨论的那样。为每个正在进行的实验记录多个指标将带来好处,如果我们需要根据不同的指标评估以前的实验。以下列表显示了一个基本的 MAPE 实现。

列表 6.5 简单 MAPE 实现

def mape(y_true, y_pred):
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

现在我们已经定义了这一点,我们可以创建一个简单的序列评分函数,它将计算所有商定的指标,而无需在所有实验代码库中手动实现每个计算。此函数还将允许我们将这些计算嵌入到我们的可视化中,而无需在代码中不断重新定义标准指标计算。我们将使用的标准指标函数将在下面展示。

列表 6.6 为评分预测数据的标准误差计算

from sklearn.metrics import explained_variance_score, mean_absolute_error, mean_squared_error, r2_score                                         ❶
def calculate_errors(y_true, y_pred):                                     ❷
    error_scores = {}                                                     ❸
    mse = mean_squared_error(y_true, y_pred)                              ❹
    error_scores['mae'] = mean_absolute_error(y_true, y_pred)
    error_scores['mape'] = mape(y_true, y_pred)                           ❺
    error_scores['mse'] = mse
    error_scores['rmse'] = sqrt(mse)
    error_scores['explained_var'] = explained_variance_score(y_true, y_pred)
    error_scores['r2'] = r2_score(y_true, y_pred)
    return error_scores

❶ 尽可能多地导入和利用可用的标准评分实现。没有必要重新发明轮子。

❷ 实际序列和预测序列在预测验证时间段的通过情况

❸ 实例化一个字典结构来存储评分以供其他地方使用(注意打印语句的缺失)

❹ 局部变量声明(因为 mse 值将被存储并用于 rmse 指标)

❺ 列表 6.5 中定义的 mape 计算的运算和使用

这个函数明显没有print语句。这是出于两个截然不同的原因的设计。

首先,我们希望使用字典封装的评分指标来构建我们将为团队使用的可视化;因此,我们不希望简单地打印到标准输出。其次,在函数和方法中报告标准输出是一种不良做法,因为这将在你开发解决方案时给你带来更多的工作。

在发布到生产之前挖掘代码以清除print语句(或将它们转换为日志语句)是乏味、易出错且如果遗漏,可能会对生产解决方案的性能产生影响(尤其是在延迟评估的语言中)。此外,在生产中,没有人会阅读标准输出,使得print语句除了是无用执行的代码之外别无其他。

打印语句及其为何对机器学习很糟糕

实际上,print语句对所有软件都不好。唯一的显著例外是用于代码的临时调试。如果你想检查运行时复杂事物的状态,它们可以非常有帮助。除了这个特定用例之外,应该不惜一切代价避免使用。

问题是我到处都看到它们:打印的行数、打印的评分指标、打印的数组和列表长度、打印正在测试的超参数、打印 I/O 操作的源和目标以及打印传递给方法的参数配置。它们都是同样无用的(而且大多数对团队的基础设施预算有积极破坏作用)。

博客文章、Hello World 示例和 API 的基本入门指南广泛使用它们来展示新语言、主题或 API 的即时和令人满意的结果,但一旦您对语法和用法有了基本的了解,这些应该总是从代码中移除。原因很简单:您永远不会再次查看那些print语句,除非是在实验和开发之外。在代码中随意放置它们会在 stdout 中留下令人困惑且难以找到的引用,指示代码将在生产中运行的位置,这通常意味着一旦运行结束,信息就会永远丢失。

对于与机器学习运行相关的信息,更好的做法是将数据持久化到可以轻松查询或视觉参考的位置。这样,您为print语句收集的辛苦收集的信息可以存储起来供以后参考、绘图或用于系统控制自动化流程。

做个好事,如果你在实验期间真的需要打印东西,确保print语句只存在于实验脚本代码中。更好的替代方案是在代码中记录结果,或者,如我们将在下一章中介绍的,使用 MLflow 这样的服务。

对于最终建模前的准备工作,我们需要构建一个快速的可视化和指标报告函数,这将给每个团队提供一个标准且高度可重用的评估每个模型预测性能的方法。以下列表显示了一个简单的示例,我们将在第 6.1.2 节中的模型实验阶段使用它。

列表 6.7:带有误差度量的预测预报绘图

def plot_predictions(y_true, y_pred, time_series_name, value_name, image_name, style='seaborn', plot_size=(16, 12)):                 ❶
    validation_output = {} 
    error_values = calculate_errors(y_true, y_pred)                    ❷
    validation_output['errors'] = error_values                         ❸
    text_str = '\n'.join((
        'mae = {:.3f}'.format(error_values['mae']),
        'mape = {:.3f}'.format(error_values['mape']),
        'mse = {:.3f}'.format(error_values['mse']),
        'rmse = {:.3f}'.format(error_values['rmse']),
        'explained var = {:.3f}'.format(error_values['explained_var']),
        'r squared = {:.3f}'.format(error_values['r2']),
    ))                                                                 ❹
    with plt.style.context(style=style):
        fig, axes = plt.subplots(1, 1, figsize=plot_size)
        axes.plot(y_true, 'b-', label='Test data for 
          {}'.format(time_series_name))
        axes.plot(y_pred, 'r-', label='Forecast data for 
          {}'.format(time_series_name))                                ❺
        axes.legend(loc='upper left')
        axes.set_title('Raw and Predicted data trend for 
          {}'.format(time_series_name))
        axes.set_ylabel(value_name)
        axes.set_xlabel(y_true.index.name)
        props = dict(boxstyle='round', facecolor='oldlace', alpha=0.5) ❻
        axes.text(0.05, 0.9, text_str, transform=axes.transAxes, fontsize=12, 
          verticalalignment='top', bbox=props)                         ❼
        validation_output['plot'] = fig
        plt.savefig(image_name, format='svg')
        plt.tight_layout()
    return validation_output

❶ 将输入设置为索引系列值,而不是具有字段名的 DataFrame 输入,以使函数更通用

❷ 调用第 6.6 节中创建的函数,计算项目中所有商定的误差度量

❸ 将误差度量添加到输出字典中,以便在仅生成可视化之外使用

❹ 生成应用于图上叠加的边界框元素的字符串

❺ 以不同颜色将实际和预测预测数据叠加到同一图上

❻ 创建一个文本框,显示所有误差分数以及绘制的数据

❻ 将文本内容写入文本边界框

现在,在创建这些基本函数以加速我们的实验工作之后,我们终于可以开始测试各种时间序列工作的预测算法的过程了。

6.1.2 运行快速预测测试

快速测试阶段是原型设计中最关键的方面,必须正确处理。正如本章引言中提到的,努力寻求中间地带至关重要——既不是测试各种方法太少,无法确定每个算法的调整灵敏度,也不是花费过多的时间构建每个方法的完整最小可行产品(MVP)解决方案。由于时间是这一阶段最重要的方面,我们需要在做出明智决策的同时保持效率,关于哪种方法最有希望以稳健的方式解决问题。

拥有有用且标准化的效用函数,每个团队都可以在其相应的方法上工作,快速测试以找到最有希望的模式。团队已经同意,用于建模测试的机场是 JFK、EWR 和 LGA(每个团队需要在相同的数据集上测试其模型和调整范式,以便对每种方法进行公平的评价)。

让我们来看看在快速测试期间,团队将如何使用不同的模型方法,将做出哪些关于方法的决策,以及如果他们发现这种方法没有进展,团队如何快速调整。探索阶段不仅将揭示每个算法的细微差别,还将阐明在准备阶段(在第五章中介绍)可能没有意识到的项目方面。重要的是要记住,这是可以预见的,在快速测试阶段,当团队发现这些问题时,他们应该相互之间保持频繁的沟通(以下侧边栏提供了有效管理这些发现的技巧)。

在黑客马拉松期间寻求裁判

我最激动人心的数据科学工作之一发生在项目的快速原型阶段。看到产生的创造力和一群聪明人共同努力解决一个被认为无法解决的问题,这是令人兴奋的。

在黑客马拉松一天(或几天,取决于问题的复杂性)的混乱中,有一个活动调解员非常重要。无论是团队领导、经理、首席数据科学家还是小组中最资深的个人技术贡献者,重要的是要留出一个人来从工作中抽身,作为小组之间的沟通者。

这个人的角色是讨论正在进行的任务,提供建议,并转移在团队之间获得的知识。由于仲裁角色的关键性,这个人不应该积极从事任何解决方案的工作。他们应该花时间在各个小组之间移动,提出简短而尖锐的问题,并在团队遇到困难时提供关于替代策略的建议。

在本节中,我们将看到快速原型设计练习中的发现可以应用于其他团队。有一个中立的第三方技术方来传播这些信息是关键。

无论原型设计阶段是否游戏化,重要的是要记住,毕竟整个团队都在为同一家公司工作。每个人最终都会关注通过 MVP、开发和生产阶段胜出的解决方案的方法。进行激烈和高度竞争的比赛实际上没有什么好处。

等一下……我们怎么创建一个验证数据集?

在模型测试阶段,有一个团队抽到了不吉利的短草,他们采用了一种团队不太理解的预测方法进行研究和测试。团队中的某个人提到了使用向量自回归(VAR)来共同建模多个时间序列(多元内生序列建模),因此这个团队着手研究这个算法是什么以及如何使用它。

他们首先搜索“向量自回归”,结果是一大堆围绕宏观经济计量研究和模型在自然科学应用中的公式化理论分析和数学证明。这很有趣,但如果他们想快速测试这个模型在数据上的应用,就不是很实用了。接下来,他们找到了该模型的 statsmodels API 文档。

团队成员很快意识到,他们还没有考虑过标准化一个共同的功能:分割方法。对于大多数监督机器学习问题,他们一直使用 pandas 的分割方法,通过 DataFrame 切片或利用高级随机分割 API,这些 API 使用随机种子来选择训练集和测试集的数据行。然而,对于预测,他们意识到他们已经有一段时间没有进行时间分割了,需要一种确定性和按时间顺序的分割方法来获取准确的预测验证保留数据。由于数据集有一个从 DataFrame 格式化函数中设置的索引,他们可能可以基于索引位置构建一个相对简单的分割函数。他们想出的方法如下所示。

列表 6.8:训练集和测试集的时间分割(包含验证检查)

from dateutil.parser import parse
def split_correctness(data, train, test):                           ❶
    assert data.size == train.size + test.size, \                   ❷
    "Train count {} and test count {} did not match to source count {}".format(train.size, test.size, data.size)

def generate_splits(data, date):                                    ❸
    parsed_date = parse(date, fuzzy=True)                           ❹
    nearest_date = data[:parsed_date].iloc(0)[-1].name              ❺
    train = data[:nearest_date]                                     ❻
    test = data[nearest_date:][1:]                                  ❼
    split_correctness(data, train, test)                            ❽
    return train, test

❶ 设计的验证断言函数旨在确保通过自定义函数进行的分割不会在训练集和测试集之间丢失任何数据行

❷ 这样的断言是“加固代码”和单元测试的前奏。我们将在后面的章节中更详细地介绍这一点,但在这个简单的例子中,要意识到我们正在构建一个自定义分割函数,以确保它按用户期望的方式运行。

❸ 用于生成训练集和测试集分割以构建模型并验证它的函数

❻ 允许在这里进行创新输入,对吧?“2005 年 6 月 3 日”应该像“2005-06-03”一样解析。如果我们使用 Python,我们不妨利用这种灵活性。我的意思是,谁还需要类型安全呢?

❺ 一个用于查找最近日期的搜索函数。(记住,我们这里的数据是按月度提供的;如果有人输入 2008-04-17 会发生什么?如果他们输入 2008-04-01 会发生什么?我们需要确保无论传入哪个有效数据,行为都是相同的。)

❺ 生成直到最近找到的日期的训练数据

❻ 从训练结束后的下一个索引位置生成测试数据

❽ 验证我们的训练和测试分割没有从原始源 DataFrame 中重复或删除行。(在模糊解析部分我们不需要这样做,因为解析器中的无效日期会抛出异常。)

这个团队的成员,作为他们自己知道的优秀的团队合作和友谊的守护者,立即将这个函数片段发送给其他团队,以便他们可以有一个简单的单行方法来分割他们的数据。他们甚至加入了一个创新的模糊匹配解析器,以防人们想使用不同的日期格式。

为了确保他们正确地编写了代码,他们将对他们的实现进行一些测试。他们想确保如果数据不匹配,实际上会抛出异常。让我们看看他们在图 6.4 中测试了什么。

06-04

图 6.4 这个自定义逻辑验证函数确保列表 6.8 的功能按预期工作。

VAR 模型方法的快速测试

现在我们有了将数据分割成训练和测试的方法,让我们回顾一下之前设置用于测试 VAR 模型的团队。不深入探讨这个模型能做什么,VAR 模型的目标是单次遍历中对多个时间序列进行同时建模。

备注:如果您想了解更多关于这些高级方法的信息,没有比 Helmut Lütkepohl 的《多时间序列分析新引论》(Springer,2006)更好的资源了,他是这个算法的创造者。

团队查看 API 文档页面上的示例,并开始实现一个简单的测试,如下所示。

列表 6.9 对 VAR 模型的一个粗略的初步尝试

from statsmodels.tsa.vector_ar.var_model import VAR                        ❶
jfk = get_airport_data('JFK', DATA_PATH)

jfk = apply_index_freq(jfk, 'MS')
train, test = generate_splits(jfk, '2006-07-08')                           ❷
var_model = VAR(train[['Domestic Passengers', 'International Passengers']])❸
var_model.select_order(12)                                                 ❹
var_fit = var_model.fit()                                                  ❺
lag_order = var_fit.k_ar                                                   ❻
var_pred = var_fit.forecast(test[['Domestic Passengers', 'International Passengers']].values[-lag_order:], test.index.size)        ❼
var_pred_dom = pd.Series(np.asarray(list(zip(*var_pred))[0], dtype=np.float32), index=test.index)                                  ❽
var_pred_intl = pd.Series(np.asarray(list(zip(*var_pred))[1], dtype=np.float32), index=test.index)                                 ❾
var_prediction_score = plot_predictions(test['Domestic Passengers'], 
                                        var_pred_dom, 
                                        "VAR model Domestic Passengers JFK", 
                                        "Domestic Passengers", 
                                        "var_jfk_dom.svg")                 ❿

❶ 这就是我们一直在谈论的向量自回归模型!

❷ 使用我们超级棒的分割函数,可以读取人们想输入的各种日期的废话

❸ 使用时间序列数据向量配置 VAR 模型。我们可以同时建模!酷吗?我想?

❹ VAR 类有一个基于最小化赤池信息准则(AIC)的优化器。这个函数试图对滞后阶数的选择设置一个限制,以优化拟合度。我们是通过阅读这个模块的 API 文档了解到这一点的。优化 AIC 将允许算法测试大量的自回归滞后阶数,并选择表现最好的一个(至少理论上是这样)。

❺ 让我们在模型上调用 fit()函数,看看它会得出什么方程。

❻ 文档建议这样做。它应该从拟合模型中获取 AIC 优化的滞后阶数。

❼ 生成预测。这有点难以理解,因为文档非常模糊,而且显然很少有人使用这个模型。我们摸索了一下,终于搞明白了。在这里,我们正在从两个序列的测试数据集中开始预测,提取出纯序列,并预测出与测试数据集中相同数量的数据点。

❽ 这让我头疼,而且是我写的。由于我们得到了一个预测的向量(国内乘客预测和国际乘客预测的元组),我们需要从这个元组数组中提取值,将它们放入一个列表中,将它们转换为 NumPy 数组,然后从测试数据中生成一个具有正确索引的 pandas 序列,这样我们就可以绘制这个图了。呼。

❾ 我们甚至不用这个来绘图(原因即将揭晓),但实验代码中出现这种令人讨厌的复制粘贴是预料之中的。

❿ 最后,让我们使用在列表 6.7 中创建的预测图代码来看看我们的模型做得怎么样!

前面代码中生成的预测图,比较了在保留验证期间预测数据和实际数据,如图 6.5 所示。

06-05

图 6.5 可能应该阅读 API 文档

PRO TIP 如果我每次在预测(或在算法开发代码中)创造出像图 6.5 中那样的混乱局面,我都会有一便士,我现在就不会工作了。我会在某个地方和我的美丽妻子以及半打狗一起放松,啜饮着冰镇鸡尾酒,听着海浪轻拍着水晶般的海岸的美妙声音。当你生成垃圾时,不要气馁。我们都会这样做。这是我们学习的方式。

好吧,那确实很糟糕。但也没有糟糕到无法想象的地步(例如,它没有预测出乘客数量会超过历史上所有人类生活的数量),但基本上是一个垃圾预测。让我们假设这个团队的宪法勇气和智慧足够高,他们愿意挖掘 API 文档和维基百科文章来找出哪里出了问题。

在这里需要记住的最重要的一点是,糟糕的结果是快速测试阶段预期的一部分。有时你会很幸运,事情会顺利,但大多数时候,事情在第一次尝试时不太可能顺利。在看到类似于图 6.5 的结果之后,最糟糕的事情就是将这种方法归类为不可行并转向其他事情。通过一些调整和改进方法,这个模型可能就是最好的解决方案。如果它在仅使用默认配置对原始数据系列进行第一次尝试后就放弃了,团队就永远不会知道它可能是一个可行的解决方案。

然而,考虑到这种极端情况,另一个极端对项目的成功同样有害。如果团队成员花费数天(或数周)重新调整方法数百次,以从模型中获得最佳结果,他们就不再是在做原型;相反,他们将会构建一个 MVP,并将大量资源投入到这个单一的方法中。在这个阶段的目标是在几小时内得到一个快速的答案,以确定这种方法是否值得冒险影响项目的成功。

让我们准备好去搞砸一些事情吧

在本章中,我们一直在观察从糟糕的结果到相当不错的结果的实验构建过程。这在机器学习中是可以预料的。对于任何使用机器学习工具解决的问题,可能有许多可能的途径可以解决。有些比其他更容易实现。对于那些更难实现的,隐藏的复杂层次在阅读 API 文档、博客甚至书籍时可能不会立即显现。对于大多数我们这些天生有缺陷的人类来说,完美的解决方案可能不会一开始就能找到。事实上,解决一个问题的前十几次尝试可能都会令人尴尬地糟糕。

我在机器学习开发方面的一般指导原则是,对于我带到生产状态的每一个成功的模型,我都已经扔掉了超过一百次的尝试(并且通常最终解决方案的代码行数也有类似的倍数被扔掉,在构建过程中)。

作为一名专业的机器学习工程师,意识到在实验的早期阶段,你可能会遇到一些真正(也许是有趣的)失败是很关键的。有些可能会相当令人沮丧,但当你最终找出问题并得到一个不那么糟糕的预测结果时,大多数都会给你带来难以置信的满足感。简单地接受失败,从中学习,并在写下你的第一次尝试之前,对需要阅读多少 API 文档有一个稳固的感觉,以在盲目解决问题和花费数周时间学习 API 以达到原作者同样详细程度之间取得平衡。

在下一轮测试中,团队发现fit()方法实际上接受参数。他们看到的例子和用作第一次尝试基准的例子没有定义这些参数,所以他们直到阅读 API 文档才知道这些参数。他们发现他们可以设置滞后周期性,以帮助模型理解在构建自回归方程时应该回溯多远,根据文档,这应该有助于构建自回归模型的线性方程。

回顾他们从开始建模之前所做的时序分析任务中记住的(以及记录、保存和存储的)内容,他们知道趋势分解有一个 12 个月的周期(那时趋势线的残差变成了噪声,而不是与季节性周期不匹配的某种循环关系)。他们再次尝试,如下一列表所示。

列表 6.10 在阅读文档后,让我们再给 VAR 试一次。

var_model = VAR(train[['Domestic Passengers', 'International Passengers']])
var_model.select_order(12)
var_fit = var_model.fit(12)                                                 ❶
lag_order = var_fit.k_ar
var_pred = var_fit.forecast(test[['Domestic Passengers', 'International Passengers']].values[-lag_order:], test.index.size)
var_pred_dom = pd.Series(np.asarray(list(zip(*var_pred))[0], dtype=np.float32), index=test.index)
var_pred_intl = pd.Series(np.asarray(list(zip(*var_pred))[1], dtype=np.float32), index=test.index)                                                      ❷
var_prediction_score = plot_predictions(test['Domestic Passengers'], 
                                        var_pred_dom, 
                                        "VAR model Domestic Passengers JFK", 
                                        "Domestic Passengers", 
                                        "var_jfk_dom_lag12.svg")
var_prediction_score_intl = plot_predictions(test['International Passengers'], 
                                        var_pred_intl, 
                                        "VAR model International Passengers JFK", 
                                        "International Passengers", 
                                        "var_jfk_intl_lag12.svg")             ❸

❶ 这是关键。让我们尝试正确设置它,看看我们是否能得到不那么令人尴尬的结果。

❷ 为了彻底,让我们也看看其他时序数据(国际旅客)。

❸ 让我们再绘制国际旅客数据,看看这个模型预测得如何。

在运行这个略微调整的测试后,团队查看结果,如图 6.6 所示。它们确实比之前好,但仍然只是稍微有点偏差。经过最终审查和更多研究后,他们发现 VAR 模型仅设计用于处理平稳时序数据。

06-06

图 6.6 仅因为执行列表 6.10 的结果比之前好了一个数量级,并不意味着它是好的。

到目前为止,这个团队已经完成了其评估。团队成员对这个 API 了解了很多:

  • 从这个 API 获取预测很复杂。

  • 将多个时序数据通过此模型运行似乎对传入的向量有互补作用。这可能会对同一机场的分歧序列造成问题。

  • 如果需要向量具有相似的形状,这将如何处理那些在成为国内枢纽后才开始提供国际航班的机场?

  • 季节性成分的分辨率损失意味着,如果预测运行得太远,预测趋势中的细微细节将会丢失。

  • 该算法似乎对fit()方法的maxlags参数很敏感。如果在生产中使用,这将需要广泛的测试和监控。

  • VAR 模型不是设计用来处理非平稳数据的。从早期的测试中,我们知道这些时序数据不是平稳的,这是基于 5.2.1 节中运行代码列表 6.10 时的 Dickey-Fuller 测试。

现在这个团队已经完成了测试,并且对这一模型家族的限制(即平稳性问题)有了坚实的理解,是时候看看其他几个团队的进展了(不用担心,我们不会逐一查看所有九个模型)。也许他们运气会更好。

重新考虑一下,我们再给它最后一次机会。团队有整天的时间来对这个模型得出结论,而且每个团队内部截止日期之前还有几个小时。

让我们快速解决这个平稳性问题,看看我们是否能将预测做得更好一点。要将时间序列转换为平稳序列,我们需要通过应用自然对数来归一化数据。然后,为了消除与序列相关的非平稳趋势,我们可以使用差分函数来获取序列在时间尺度上移动时的变化率。列表 6.11 是转换为差分尺度、运行模型拟合和将时间序列压缩到适当尺度的完整代码。

列表 6.11 使用 VAR 模型的平稳性调整预测

jfk_stat = get_airport_data('JFK', DATA_PATH)
jfk_stat = apply_index_freq(jfk, 'MS')
jfk_stat['Domestic Diff'] = np.log(jfk_stat['Domestic Passengers']).diff()    ❶
jfk_stat['International Diff'] = np.log(jfk_stat['International Passengers']).diff()                                                  ❷
jfk_stat = jfk_stat.dropna()
train, test = generate_splits(jfk_stat, '2006-07-08')
var_model = VAR(train[['Domestic Diff', 'International Diff']])              ❸
var_model.select_order(6)
var_fit = var_model.fit(12)
lag_order = var_fit.k_ar
var_pred = var_fit.forecast(test[['Domestic Diff', 'International Diff']].values[-lag_order:], test.index.size)
var_pred_dom = pd.Series(np.asarray(list(zip(*var_pred))[0], dtype=np.float32), index=test.index)
var_pred_intl = pd.Series(np.asarray(list(zip(*var_pred))[1], dtype=np.float32), index=test.index)
var_pred_dom_expanded = np.exp(var_pred_dom.cumsum()) * test['Domestic Passengers'][0]                                                       ❹
var_pred_intl_expanded = np.exp(var_pred_intl.cumsum()) * test['International Passengers'][0]
var_prediction_score = plot_predictions(test['Domestic Passengers'], 
                                        var_pred_dom_expanded, 
                                        "VAR model Domestic Passengers JFK Diff", 
                                        "Domestic Diff", 
                                        "var_jfk_dom_lag12_diff.svg")         ❺
var_prediction_score_intl = plot_predictions(test['International Passengers'], 
                                        var_pred_intl_expanded, 
                                        "VAR model International Passengers JFK
                                         Diff", 
                                        "International Diff", 
                                        "var_jfk_intl_lag12_diff.svg")

❶ 对序列的对数取差分函数以创建平稳时间序列(记住,这与我们对异常值分析所做的一样)

❷ 我们还必须对国际乘客的其他向量位置序列数据进行同样的操作。

❸ 在数据的平稳表示上训练模型

❹ 通过使用 diff()的逆函数(累积和)将平稳数据转换回数据的实际尺度。然后通过使用指数将数据的对数尺度转换回线性空间。这个序列被设置为差分,因此我们必须将值乘以起始位置值(这是测试数据集序列开始处的实际值)以获得正确的缩放。

❺ 比较测试序列与扩展预测序列

这么多的复制粘贴是怎么回事?

在本节的所有示例中,我们都看到相同的代码行在每次模型改进的迭代中反复粘贴。将这些代码全部包含在这些片段中,不仅仅是为了展示一个可以执行的完整代码块,而是模拟了许多实验笔记本(或者,如果编写 Python 脚本,这些脚本的副本)在测试过程中最终会呈现的样子,包括对个别想法的迭代,最终一个功能性的脚本将产生可测量的结果。

这是正常的。这是实验中预期会发生的事情。

一个普遍的好做法是确保你的实验和评估代码相对组织良好,易于阅读和跟踪,并且有足够的注释来解释任何特别复杂的内容。无论选择哪种解决方案,保持代码足够整洁将有助于开发下一阶段。边做边清理,删除死代码,并保持显著的结构。

你肯定不希望看到的是,笔记本中充满了无序的单元格、损坏的变量依赖链和大量注释掉的无效代码,处于一种纯粹的混乱状态。试图拼凑一个混乱的实验是一种令人沮丧和无用的练习,它将一个已经复杂的过程(制定生产级代码的封装设计和架构)的难度提升到许多情况下,从头开始重写一切比尝试挽救已经开发的内容更容易。

在项目这个阶段,拥有完全功能和单元格级别的封装并不一定是坏事。只要代码编写整洁且格式正确,这种封装可能比筛选数十个(或数百个!)单元格以找出如何让实验像快速原型阶段那样运行要容易得多。它还使得转换为基于类或基于函数编程的实现变得容易得多。

图 6.7 显示了该团队在迭代模型实现、完全阅读文档并研究模型如何工作(至少在“机器学习应用”层面)之后所达到的最终状态。这个可视化结果是通过运行列表 6.11 中的代码得到的。

06-07

图 6.7 执行列表 6.11 的结果

实验阶段的第一个部分已经完成。团队拥有一个有潜力的模型,更重要的是,他们理解了模型的应用并能够正确调整它。已经记录了可视化结果以展示结果,并在笔记本中编写了干净的示例代码,以便以后参考使用。

在其他团队完成原型设计之前,从事这个特定模型实现的团队成员,可以将他们从工作中获得的智慧传授给其他团队。这种信息共享也将有助于加快所有实验的进度,以便对实际项目工作应采用哪种方法做出决定。

哇,那真是不愉快……

重要的是要注意,采用特定方法获得可接受结果有多么困难。无论是模型需要异常大量的特征工程才能产生除垃圾之外的东西,还是对超参数极端敏感,或者甚至使用一个令人困惑且设计不佳的 API,这个阶段所面临的困难需要由团队记录下来。

正如我们在第 6.2 节中将要回顾的,实施各种解决方案所面临的挑战将对开发具有生产能力的解决方案的复杂性产生重大影响。此外,这些挑战将直接影响团队在解决方案投入生产后维护该解决方案的能力。

在这个阶段思考以下主题并记录笔记是好的,这样在以后评估复杂性时可以参考:

  • 对参数变化的敏感性。

  • 超参数的数量。(这将影响模型的优化。)

  • API 的流畅性。(是否是标准的?能否放入管道中?)

  • 为了得到可接受的结果,必须进行的特征工程工作的数量。

  • 适应训练和测试数据量变化的能力。(当分割边界改变时,预测是否崩溃了?)

ARIMA 的快速测试

让我们暂时假设 ARIMA 团队成员在开始时除了系列数据的训练和测试分割方法以对预测进行评分之外,没有从 VAR 团队那里得到任何提示。他们正在开始模型研究和测试阶段,使用与其他团队用于数据预处理和日期索引格式化的相同功能工具,但除此之外,他们处于绿色地带。

团队意识到他们面临的一个主要障碍是 ARIMA 模型所需的设置,特别是模型实例化时需要分配的p(自回归参数)、d(差分)和q(移动平均)变量。阅读文档后,团队成员意识到大家已经贡献的前期实验工作已经提供了一种找到这些值起始点的方法。通过使用第五章列表 5.14 中构建的平稳性测试可视化函数,我们可以获得自回归(AR)参数的显著性值。

为了获得适当的自相关和偏自相关测量值,我们将在时间序列的对数上执行与 VAR 团队在其最终测试模型中执行相同的差分函数(VAR 团队成员特别友好,并分享了他们的发现),以便我们可以消除尽可能多的噪声。图 6.8 显示了结果趋势图。

06-08

图 6.8 执行 JFK 国内乘客序列的滞后差分平稳性测试

与他们之前的 VAR 团队一样,ARIMA 团队成员尝试了几个不同的参数来获得不是悲惨的结果。我们不会涵盖所有这些迭代(毕竟这不是一本关于时间序列建模的书)。相反,让我们看看他们最终得出的结果。

列表 6.12 ARIMA 实验的最终状态

from statsmodels.tsa.arima.model import ARIMA
jfk_arima = get_airport_data('JFK', DATA_PATH)
jfk_arima = apply_index_freq(jfk_arima, 'MS')
train, test = generate_splits(jfk_arima, '2006-07-08')
arima_model = ARIMA(train['Domestic Passengers'], order=(48,1,1), enforce_stationarity=False, trend='c')                               ❶
arima_model_intl = ARIMA(train['International Passengers'], order=(48,1,1), enforce_stationarity=False, trend='c')
arima_fit = arima_model.fit()
arima_fit_intl = arima_model_intl.fit()
arima_predicted = arima_fit.predict(test.index[0], test.index[-1])
arima_predicted_intl = arima_fit_intl.predict(test.index[0], test.index[-1])
arima_score_dom = plot_predictions(test['Domestic Passengers'],
                                   arima_predicted,
                                   “ARIMA model Domestic Passengers JFK",
                                   "Domestic Passengers",
                                   "arima_jfk_dom_2.svg"
                                   )
arima_score_intl = plot_predictions(test['Domestic Passengers'],
                                    arima_predicted_intl,
                                    "ARIMA model International Passengers JFK",
                                    "International Passengers",
                                    "arima_jfk_intl_2.svg"
                                    )

❶ (p,d,q)的阶数参数。p(周期)值是从自相关和偏自相关分析中作为显著值的计算因子推导出来的。

特别值得注意的是,在序列上没有采取强制平稳性的对数和差分操作。虽然对这些平稳性调整进行了测试,但结果明显比在原始数据上进行的预测要差。(我们不会查看代码,因为它几乎与列表 6.11 中的方法相同。)

图 6.9 显示了他们一些测试的验证图和分数;对数差分的尝试位于左侧(显然较差),用于训练的未修改序列位于右侧。虽然右侧的图表组合在项目解决方案中并不理想,但它确实为更广泛的团队提供了一个关于 ARIMA 模型在预测目的上的细微差别和能力的主意。

06-09

图 6.9 强制平稳性(左侧)和使用原始数据(右侧)进行 ARIMA 模型比较

他们测试的结果表明,在两种方法(原始数据和强制平稳性操作)中都有希望,说明存在更好的调整机会,以使该算法的实现更加完善。凭借这些知识和结果,这个团队可以准备好向更大的团队展示其发现,而无需在此阶段花费更多宝贵的时间来尝试改进结果。

快速测试 Holt-Winters 指数平滑算法

我们将对此部分进行更简短的介绍(抱歉,时间序列建模的粉丝)。对于这个模型评估,团队成员希望将他们的 Holt-Winters 指数平滑模型实现包装在一个函数中,这样他们就不必在整个笔记本单元中重复相同的代码。

为什么这种方法是编写甚至实验代码的首选方式,将在下一章中变得更加明显。现在,我们只需说,这个团队有几位资深的 DS 成员。下一个列表显示了他们最终得出的结果。

列表 6.13 Holt-Winters 指数平滑函数及其用法

from statsmodels.tsa.holtwinters import ExponentialSmoothing
def exp_smoothing(train, test, trend, seasonal, periods, dampening, smooth_slope, damping_slope):
    output = {}
    exp_smoothing_model = ExponentialSmoothing(train,
                                               trend=trend,
                                               seasonal=seasonal,
                                               seasonal_periods=periods,
                                               damped=dampening
                                              )
    exp_fit = exp_smoothing_model.fit(smoothing_level=0.9,
                                      smoothing_seasonal=0.2,
                                      smoothing_slope=smooth_slope,
                                      damping_slope=damping_slope,
                                      use_brute=True,
                                      use_boxcox=False,
                                      use_basinhopping=True,
                                      remove_bias=True
                                     )                                     ❶
    forecast = exp_fit.predict(train.index[-1], test.index[-1])            ❷
    output['model'] = exp_fit
    output['forecast'] = forecast[1:]                                      ❸
    return output
jfk = get_airport_data('JFK', DATA_PATH)
jfk = apply_index_freq(jfk, 'MS')
train, test = generate_splits(jfk, '2006-07-08')
prediction = exp_smoothing(train['Domestic Passengers'], test['Domestic Passengers'], 'add', 'add', 48, True, 0.9, 0.5)
prediction_intl = exp_smoothing(train['International Passengers'], test['International Passengers'], 'add', 'add', 60, True, 0.1, 1.0)     ❹
exp_smooth_pred = plot_predictions(test['Domestic Passengers'], 
                                   prediction['forecast'],
                                   "ExponentialSmoothing Domestic Passengers JFK",
                                   "Domestic Passengers",
                                   "exp_smooth_dom.svg"
                                  )
exp_smooth_pred_intl = plot_predictions(test['International Passengers'], 
                                   prediction_intl['forecast'],
                                   "ExponentialSmoothing International Passengers 
                                    JFK",
                                   "International Passengers",
                                   "exp_smooth_intl.svg"
                                  )

❶ 在开发过程中,如果选择此模型,所有这些设置(以及此拟合方法可用的其他设置)都将参数化,并使用类似 Hyperopt 的工具进行自动优化。

❷ 与其他测试的模型略有不同,此模型要求预测范围至少包含训练数据中的最后一个元素。

❸ 从训练数据序列的最后一个元素中移除所做的预测

❹ 由于该组时间序列的性质,自动回归元素(seasonal_periods)使用更长的周期。在开发过程中,如果选择此模型,这些值将通过网格搜索或更优雅的自动优化算法自动调整。

在开发过程中,这个子团队发现 Holt-Winters 指数平滑的 API 在版本 0.11 和 0.12 之间发生了相当大的变化(0.12.0 是 API 文档网站上的最新文档,因此默认显示)。因此,团队成员花费了大量时间试图弄清楚为什么他们尝试应用的设置总是因为重命名或修改的参数而抛出异常而失败。

最终,他们意识到他们需要检查安装的 statsmodels 版本以获取正确的文档。(有关 Python 版本管理的进一步阅读,请参阅以下侧边栏。)图 6.10 显示了该团队的工作结果,反映了迄今为止任何团队中最有希望的指标。

如何快速找出模块的版本而无需大费周章

我们在这些示例中使用的包管理器 Anaconda 提供了相当多的模块。除了基本的 Python 本身之外,还包括了数百个对机器学习工作非常有用的工具。每个模块都经过精心整理,以确保相应的依赖项都能协同工作。

由于这个原因,一些模块可能不像 API 文档中提到的“稳定版本”那样新(尤其是对于正在积极开发和频繁发布的项目)。因此,文档可能不会反映你正在交互的模块版本。

这不仅仅是一个 Python 问题。任何大型开源生态系统都会遇到这个问题。你会在 Java、Scala、R、TensorFlow、Keras 等语言中遇到这个问题。然而,在 Python 中,我们可以相对容易地从 Python REPL(或笔记本单元格)中获取版本信息。

为了我们的示例,让我们检查 statsmodels 的版本信息。要获取它,你只需找出方法名(通常是伪私有方法)并调用它。你可以通过导入基本包,执行dir(<packagename>),并查看其命名来找到这些方法名可能被调用(通常是__VERSION____version_version等变体的一个)。

对于 statsmodels,方法名是_version。要打印版本信息,我们只需在单元格中输入以下内容,它就会打印到标准输出:

import statsmodels
statsmodels._version.get_versions()

在撰写本文时,statsmodels 的最新稳定版本是 0.12.0,API 发生了我们一直在使用的重大变化。幸运的是,开源软件包的每个版本通常都会在其网页上保留其旧版本的文档。只是确保你在查看文档时选择了正确的版本,以确保你不会浪费时间实现与你要运行的包的安装版本不兼容的东西。

然而,我们在 Anaconda 构建中使用的版本是 0.11.1。我们需要确保我们正在查看该版本的 API 文档,以查看我们为建模导入的每个类别的选项。

06-10

图 6.10 展示了列表 6.13 中的 Holt-Winters 指数平滑测试结果。我们有一个明确的竞争者!

在完成了一天的迷你黑客松后,团队将他们的结果整理成关于算法预测此数据能力的简单且易于消化的报告。然后,团队进行了一段时间的展示和说明。

应用第 6.1 节中定义的准备工作步骤,我们可以高效、可靠和客观地比较不同的方法。标准化意味着团队将有一个真正的基线比较来评判每个方法,而评估的时间限制性质确保没有任何团队在没有知道他们正在构建的方法是否确实是最好的方法之前,花费太多时间构建 MVP 解决方案(浪费时间和计算资源)。

我们已经减少了选择一个较差的实现来解决业务需求的机会,并且做得很快。即使请求解决问题的业务部门对这些内部流程一无所知,但由于这种方法论的方法,公司最终将拥有一个更好的产品,并且能够满足项目的截止日期。

6.2 精简可能性

团队作为一个整体如何决定前进的方向?回想一下,在第三章和第四章中,我们讨论了在实验评估完成后,是时候涉及业务利益相关者了。我们需要获取他们的意见,尽管这些意见可能是主观的,但为了确保他们会感到对这种方法感到舒适,并且被包括在方向选择中,以及他们的专业知识在深度主题领域知识中在决策中占有重要地位。

为了确保对项目测试的潜在实施方法进行彻底的审查,更广泛的团队需要查看每个已测试的方法,并基于以下标准做出判断:

  • 最大化方法的可预测能力

  • 在尽可能解决问题的同时,最大限度地减少解决方案的复杂性

  • 评估和估算开发解决方案的难度,以便为交付日期进行现实范围的规划

  • 估算(再)培训和推理的总拥有成本

  • 评估解决方案的可扩展性

通过在评估阶段关注这些方面的每一个,团队可以显著降低项目风险,集体决定一个 MVP 方法,这将减少大多数机器学习项目失败、最终被废弃或取消的原因。图 6.11 显示了每个这些标准以及它们如何适应机器学习项目工作的整体原型阶段。

06-11

图 6.11 评估阶段元素,以指导构建 MVP 的路径

现在你已经对团队在评估方法时应关注的内容有了稳固的认识,让我们看看这个团队将如何做出决定,选择要实施的方法。

6.2.1 正确评估原型

在这一点上,大多数 ML 团队可能会让自己误入歧途,特别是在只展示特定解决方案带来的准确性的意义上。我们之前在 6.1.1 节(以及列表 6.7)中讨论了创建引人入胜的可视化的重要性,以易于消费的格式向 ML 团队和业务部门展示,但这只是决定采用一种 ML 方法而不是另一种方法的故事的一部分。算法的预测能力当然非常重要,但它只是众多重要考虑因素中的一个。例如,让我们继续讨论这三个实现(以及为了简洁我们没有展示的其他实现),并收集它们的数据,以便可以探索构建任何这些解决方案的完整图景。

团队会面,互相展示代码,回顾使用不同参数进行的各种测试运行,并汇总一个关于相对难度的共识比较。对于某些模型(例如 VAR 模型、弹性网络回归器、lasso 回归器和 RNN),ML 团队决定甚至不将这些结果包含在分析中,因为这些模型在预测中产生了压倒性的糟糕结果。向业务部门展示彻底的失败没有任何实际作用,只会让一个已经智力上负担沉重的讨论变得更长、更繁重。如果需要对达到候选方案所需的工作量进行完全披露,只需简单地说,“我们尝试了 15 种其他方法,但它们真的不适合这些数据”,然后继续前进。

在权衡每种方法的客观优点后,内部 DS 团队得出一个类似于图 6.12 的评估矩阵。虽然相对通用,但这个矩阵中的评估元素可以应用于大多数项目实施。在过去,我使用过更加详细和针对项目旨在解决的问题类型定制的选择标准,但一个通用的标准是一个好的起点。

06-12

图 6.12 实验原型阶段的结果决策矩阵

正如你所见,全面评估一个方法除了其预测能力之外的元素至关重要。毕竟,所选的解决方案将需要开发、监控、修改和(希望)长期维护。未能考虑可维护性因素可能导致团队获得一个极其强大的解决方案,但几乎不可能保持其运行。

在原型制作完成后,深入思考构建这个解决方案将是什么样的,以及整个生命周期所有权将是什么样的,这是值得的。有人会想要改进它吗?他们能够做到吗?如果预测开始变得不准确,这将是一件相对简单的问题吗?我们能解释模型为什么会做出这样的决策吗?我们能承担运行它的成本吗?

如果你对这些建议的解决方案中的任何方面感到不确定,最好是团队内部讨论这些话题,直到达成共识,或者至少,不要将其作为潜在解决方案提交给业务。你绝对不希望项目结束时意识到你建造了一个令人厌恶的东西,你希望它能够无声无息地消失,永远不再回来,不再露出丑陋的脑袋,像一种令人不安的梦魇一样渗透你的清醒和失眠之夜。在这个时候要明智选择,因为一旦你做出承诺,转向另一种方法将会非常昂贵。

6.2.2 确定前进方向

现在已经收集了关于每种方法的相对优势和劣势的数据,并且已经决定了建模方法,真正的乐趣开始了。由于每个人都得出结论,Holt-Winters 指数平滑似乎是构建这些预测的最安全选项,我们可以开始讨论架构和代码。

在编写任何代码之前,团队需要进行另一次规划会议。这是提出困难问题的时候。关于这些问题的最重要的注意事项是,它们应该在承诺开发方向之前得到回答

问题 1:这个需求需要运行多频繁?

“这个需求需要运行多频繁?”可能是最重要的问题,考虑到每个人都选定的模型类型。由于这是一个自回归模型,如果模型不是以高频率(可能每次推理运行)重新训练,预测将不会适应新的事实数据。模型只看单一变量的序列来做出预测,所以尽可能最新的训练可以确保预测能够准确适应变化趋势。

TIP 永远不要问业务或任何前端开发者,“那么,你们多久需要一次预测?”他们通常会给出一些荒谬的短时间。相反,问,“预测何时会变得无关紧要?”然后从那里开始工作。4 小时的服务水平协议(SLA)和 10 毫秒的 SLA 之间的差异是几十万美元的基础设施和大约六个月的工作。

业务将需要为这些预测的“freshness”提供最小和最大服务级别协议(SLA)。给出开发支持这些 SLA 要求的解决方案所需时间的粗略估计,以及该解决方案在生产中运行的成本。

问题 2:目前这些数据在哪里?

由于数据由外部数据源提供,我们需要认真考虑如何为训练数据和预测数据创建一个稳定可靠的 ETL 摄取。数据的 freshness 需要满足问题 1 答案的要求(请求的 SLA)。

我们需要将 DE 团队成员纳入其中,以确保他们在我们考虑将此项目投入生产之前,就已经优先考虑获取此数据源。如果他们无法承诺一个可接受的时间,我们就必须自己编写这个 ETL,并手动将数据填充到源表中,这将增加我们的项目范围、成本和风险。

问题 3:预测将被存储在哪里?

用户是否会向预测发出类似商业智能(BI)风格的查询,以临时方式推动分析可视化?然后我们可以将数据写入我们内部拥有的关系数据库管理系统(RDBMS)源。

这将被数百(或数千)个用户频繁查询吗?数据是否将作为服务提供给 Web 前端?如果是这样,我们必须考虑将预测存储为排序数组在 NoSQL 引擎中或可能是一个内存存储,如 Redis。如果我们打算为前端服务提供服务,我们还需要在数据前面构建一个 REST API,这将增加这个项目几个冲刺的工作范围。

问题 4:我们如何设置我们的代码库?

这将是一个新的项目代码库,还是我们将让这段代码与其他机器学习项目共同存在于一个公共仓库中?我们是否追求使用模块化设计的完全面向对象(OO)方法,还是我们将尝试进行函数式编程(FP)?

我们对于未来的改进部署策略是什么?我们将使用持续集成/持续部署(CI/CD)系统、GitFlow 发布,还是标准的 Git?每个运行的指标将存储在哪里?我们将把参数、自动调整的超参数和可视化日志记录在哪里以供参考?

在这个阶段,立即对有关开发的所有这些问题都有答案并不是绝对必要的,但团队领导和架构师应该仔细考虑项目开发的各个方面,并且应该尽快做出关于这些元素的深思熟虑的决策(我们将在下一章详细讨论)。

问题 5:这个模型将在哪里进行训练?

我们绝对不应该在我们的笔记本电脑上运行这个。真的。别这么做。

由于本项目涉及到的模型数量,我们将在下一章中探讨这方面的选项,并讨论每个选项的优缺点。

问题 6:推理将在哪里运行?

我们绝对不应该在我们的笔记本电脑上运行这个。云服务提供商的基础设施、本地数据中心,或者在云或本地运行的临时无服务器容器,这里真的是唯一的选择。

问题 7:我们如何将预测结果传递给最终用户?

如问题 3 的答案所述,将预测结果传递给最终用户是任何力求真正有用的机器学习项目中最被忽视但最关键的部分。您是否需要在网页上提供预测结果?现在是与一些前端和/或全栈开发者进行交谈的好时机。

是否需要将其作为 BI 报告的一部分?现在应该咨询 DE 和 BI 工程团队。

是否需要存储以供分析师进行即兴的 SQL 查询?如果是这样,您已经有了这个。这很简单。

问题 8:我们现有的代码中有多少可以用于此项目?

如果您已经开发了可以简化您生活的实用程序包,请检查它们。它们是否有现有的技术债务,您可以在进行此项目时修复并改进?如果是的话,那么现在是修复它的时候了。如果您有现有的代码并且认为它没有技术债务,您应该对自己更诚实一些。

如果您还没有建立现有的实用框架,或者您是第一次开始使用机器学习工程实践,请不要担心!我们将在接下来的许多章节中介绍这种工具的外观。

问题 9:我们的开发节奏是什么,我们将如何处理功能?

您是否在与项目经理打交道?现在花点时间解释一下在开发过程中您将要丢弃多少代码。让项目经理知道整个故事和史诗级的内容将成为死代码,永远从地球上消失,不再被看到。向他们解释机器学习项目工作的混乱,以便他们能够度过那些最初的四个悲伤阶段,并在项目开始之前学会接受它。您不需要给他们一个拥抱或任何东西,但请温柔地告诉他们这个消息,因为它将粉碎他们对现实本质的理解。

机器学习特征工作是一种独特的生物。确实,将开发大量代码,但一旦发现某种方法不可行,这些代码将被完全重构(或丢弃!)这是与“纯”软件开发截然不同,在“纯”软件开发中,特定的功能是理性定义的,并且可以相当准确地界定范围。除非你的项目的一部分是设计和发展一个全新的算法(据我所知,这不应该发生,无论你的团队成员多么试图说服你需要这样做),否则无法保证代码库中会实现特定的功能。

因此,纯敏捷方法通常不是开发机器学习代码的有效方式,仅仅是因为可能需要做出的变化(例如,更换模型可能会引起大量、全面的重构,这可能会消耗两个整个冲刺)。为了帮助处理应用于机器学习开发的敏捷的不同性质,相应地组织你的故事、你的 scrum 和你的提交是至关重要的。

6.2.3 那么,接下来是什么?

下一步实际上是构建最小可行产品(MVP)。这是在开发一个可演示的解决方案,该解决方案对模型进行了精确调整,记录了测试结果,并向业务展示问题可以解决。下一步是让工程机器学习工程中发挥作用。

我们将在下一章深入探讨这些主题,继续以这个花生库存优化问题为例,观察它从硬编码的原型及其边缘调整到充满函数、自动调整模型的支持以及每个模型调整评估的完整日志记录到 MLflow 的代码库的初期。我们还将从单线程顺序 Python 的世界过渡到 Apache Spark 分布式系统中的并发建模能力的世界。

摘要

  • 采用时间限制和整体方法测试潜在解决问题的 API,将有助于确保项目快速达到实施方向,彻底评估,并在尽可能短的时间内满足问题的需求。预测能力并不是唯一重要的标准。

  • 审查解决一个问题的候选方法的所有方面,鼓励评估不仅仅是预测能力。从可维护性、实现复杂性到成本,在选择解决方案以解决问题时,应考虑许多因素。

7 实验行动:从原型到 MVP

本章涵盖

  • 超参数调整技术以及自动化方法的益处

  • 提高超参数优化性能的执行选项

在上一章中,我们探讨了针对机场乘客预测的业务问题的潜在解决方案的测试和评估场景。我们最终做出了关于要使用的模型(霍尔特-温特斯指数平滑)的决定,但在快速原型阶段,我们只进行了少量的模型调整。

从实验原型转向 MVP 开发具有挑战性。这需要一种与迄今为止所做工作完全相反的全面认知转变。我们不再思考如何解决问题并获得良好的结果。相反,我们思考的是如何构建一个解决方案,这个解决方案足够好,能够以足够稳健的方式解决问题,使其不会不断崩溃。我们需要将重点转移到监控、自动化调整、可扩展性和成本上。我们正从以科学为重点的工作转向工程领域。

从原型转向最小可行产品(MVP)的首要任务是确保解决方案调整正确。请参阅以下侧边栏,以获取更多关于为何调整模型如此关键以及这些看似可选的建模 API 设置实际上为何重要的详细信息。

超参数很重要——非常重要

在机器学习代码库中最令人沮丧的事情之一是看到一个未调整的模型(一个将由 API 提供的占位符默认值生成的模型)。在构建其余解决方案所涉及的先进特征工程、ETL、可视化和编码努力中,看到一个仅使用默认值的裸模型就像买了一辆高性能跑车,却给它加满了普通汽油。

它会运行吗?当然会。它会表现良好吗?不会。不仅它的表现会不佳,而且一旦将其带入“现实世界”(指模型,使用之前未见过的数据来做出预测),它崩溃的可能性很高。

一些算法会自动处理其方法以到达优化的解决方案,因此不需要覆盖任何超参数。然而,绝大多数算法从单个到数十个参数不等,这些参数不仅影响算法优化器的核心功能(例如,广义线性回归中的family参数将比任何其他超参数更直接地影响该模型的预测性能),还影响优化器执行搜索以找到最小目标函数的方式。其中一些超参数仅适用于算法的特定应用——只有当特征向量中的方差极端或与目标变量相关联特定分布时,这些超参数才是适用的。但对于大多数超参数而言,它们的设置值对算法如何“学习”数据的最优拟合方式的影响至关重要。

下面的图表是线性回归模型中两个关键超参数的简化示例。由于每个特征向量集合和问题通常会有与其他人截然不同的最佳超参数设置,因此无法猜测这些值应该设置在哪里。

注意,这些示例仅用于演示目的。对于不同超参数设置的模型效果不仅高度依赖于所使用的算法类型,还取决于特征向量中包含的数据的性质以及目标变量的属性。这就是为什么每个模型都需要进行调优。

如您所见,与每个机器学习算法相关的看似可选设置实际上在训练过程的执行方式中起着非常重要的作用。如果不更改这些值并优化它们,几乎没有成功基于机器学习的解决方案解决问题的可能性。

07-0-unnumb

超参数对过拟合和欠拟合的影响

7.1 调优:自动化繁琐的工作

在过去的两章中,我们一直专注于一个花生预测问题。在第六章结束时,我们有一个勉强可行的原型,在一个机场进行了验证。调整和调优模型预测性能的过程是手动进行的,并不特别科学,留下了模型预测能力可能实现的范围和手动调优之间的较大差距。

在这种情况下,从良好预测到非常良好预测之间的差异可能是一个很大的产品差额,这是我们希望在机场进行展示的。毕竟,我们的预测失误可能导致数百万美元的损失。仅仅通过尝试大量超参数来手动调优,既无法提高预测准确性,也无法保证交付的及时性。

如果我们想要提出比部落知识猜测更好的方法来调整模型,我们需要考虑我们的选项。图 7.1 显示了 DS 团队用于调整模型的各种方法,从简单(较弱且不易维护)到复杂(自定义框架)的顺序。

07-01

图 7.1 超参数调整方法的比较

顶部部分,手动调整,通常是构建原型的典型方式。在快速测试时,手动测试超参数的值是一种可以理解的方法。如第六章所述,原型的目标是获得解决方案可调整性的近似值。然而,在向生产级解决方案迈进的过程中,需要考虑更多可维护且强大的解决方案。

7.1.1 调整选项

我们知道我们需要调整模型。在第六章中,我们清楚地看到了如果我们不这样做会发生什么:生成的预测结果如此糟糕,以至于从帽子里抽数字会更准确。然而,可以追求多种选项以达到最优的超参数集。

手动调整(有根据的猜测)

我们将在稍后看到,当将 Hyperopt 应用于我们的预测问题时,到达每个需要为这个项目构建的模型的最佳超参数将有多么困难。不仅优化值难以猜测,而且每个预测模型的最佳超参数集与其他模型不同。

使用手动测试方法接近最优参数几乎是不可能的。这个过程效率低下,令人沮丧,尝试这个过程是极大的时间浪费,如图 7.2 所示。

07-02

图 7.2 手动超参数调整的剧痛

小贴士:除非你正在使用具有非常少数量的超参数(一个或两个,最好是布尔型或分类型)的算法,否则不要尝试手动调整。

这种方法的主要问题是跟踪已经测试的内容。即使有系统来记录并确保之前没有尝试过相同的值,维护这个目录所需的工作量巨大,容易出错,且在极端情况下毫无意义。

在快速原型阶段之后,项目工作应尽快放弃这种调整方法。相信我,你有很多更好的事情可以做。

网格搜索

机器学习技术的一个基石,基于网格的超参数暴力搜索方法已经存在很长时间了。为了执行网格搜索,数据科学家将选择一组要测试的值集合,对于每个超参数。然后,网格搜索 API 将通过创建每个指定组的每个值的排列来组装要测试的超参数集合。图 7.3 说明了这是如何工作的,以及为什么它可能不是你愿意用于具有许多超参数的模型的某种方法。

正如你所见,随着超参数数量的增加,需要测试的排列数量会迅速变得难以承受。显然,这种权衡是在运行所有排列所需的时间和搜索优化能力之间。如果你想探索更多超参数响应面,你将不得不运行更多的迭代。这里实际上没有免费的午餐。

07-03

图 7.3 基于暴力搜索的网格搜索方法进行调优

随机搜索

虽然网格搜索存在许多限制,阻碍了其找到一组优化超参数的能力,但从时间和金钱的角度来看,使用它可能会变得过于昂贵。如果我们对彻底测试预测模型中所有连续分布的超参数感兴趣,那么在单核 CPU 上运行时得到答案所需的时间将是以周计,而不是以分钟计。

网格搜索的一个替代方案,尝试同时测试不同超参数的影响(而不是依赖于显式的排列来确定最佳值),是使用每个超参数组的随机抽样。图 7.4 说明了随机搜索;将其与图 7.3 进行比较,以查看方法之间的差异。

07-04

图 7.4 超参数优化的随机搜索过程

正如你所见,要测试的候选者的选择是随机的,并且不是通过所有可能值的排列机制来控制的,而是通过测试的最大迭代次数来控制。这是一把双刃剑:虽然执行时间大大减少,但超参数空间的搜索是有限的。

关于参数搜索的学术性争论

关于为什么随机搜索优于基于网格的搜索,可以提出许多论点,其中许多论点相当有说服力。然而,在在线参考资料、示例和博客文章中展示的大多数例子仍然使用网格搜索作为模型调优的手段。

有一个明显的理由:它速度快。没有任何包开发者或博客作者愿意创建一个对读者来说非常复杂或耗时的示例。尽管如此,这并不意味着这是一种好的做法。

在示例中看到如此多的网格搜索应用,给许多从业者造成了错误的印象,认为它在找到良好参数方面远比其他方法更有效。我们也许也有普遍的熵厌恶,作为人类(我们厌恶随机性,所以随机搜索一定是坏的,对吧?)。我并不完全确定。

然而,我无法强调网格搜索的限制性(如果你想要彻底的话,它也很昂贵)。我并不孤单;参见詹姆斯·伯格斯特拉和约书亚·本吉奥的“随机搜索超参数优化”(2012)www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf。我通常同意他们的结论,即网格搜索作为一种方法本质上是有缺陷的;因为某些超参数对特定训练模型的总体质量影响更大,那些影响更大的超参数与那些影响微不足道的超参数获得相同的覆盖量,这限制了有效的搜索,因为计算时间和更广泛测试的成本。在我看来,随机搜索比网格搜索是一个更好的方法,但它仍然不是最有效或最有效的方法。

伯格斯特拉和本吉奥达成共识:“我们对超参数响应表面的分析表明,随机实验更有效率,因为并非所有超参数对调整同等重要。网格搜索实验在探索不重要的维度上分配了过多的试验,并且在重要的维度上覆盖不足。”在下一节中,我们将讨论他们如何通过创建一个真正出色的新算法来解决这个问题。

基于模型的优化:树结构帕累托估计器(Hyperopt)

在我们的时间序列预测模型中,我们面临着复杂的超参数搜索——总共有 11 个超参数,其中 3 个是连续分布的,1 个是序数——这混淆了有效搜索空间的能力。前面提到的方法要么太耗时(手动、网格搜索),要么成本高昂(网格搜索),或者难以获得足够的拟合特征以验证保留数据(所有这些方法)。

同一个团队提出了随机搜索是比网格搜索更优越的方法论的论文,他们也提出了一种选择优化超参数响应表面的过程:使用基于模型的优化,依赖于高斯过程或帕累托树估计器(TPEs)的贝叶斯技术。他们的研究成果包含在开源软件包 Hyperopt 中。图 7.5 展示了 Hyperopt 工作的基本原理。

07-05

图 7.5 Hyperopt 的树结构帕累托估计器算法的高层次图示

这个系统几乎可以保证在通过任何前面提到的经典调优方法进行工作的经验丰富的 DS(数据科学家)中表现优异。它不仅能够非常出色地探索复杂的超参数空间,而且比其他方法需要的迭代次数要少得多。关于这个主题的进一步阅读,我推荐阅读 James Bergstra 等人于 2011 年撰写的原始白皮书,“超参数优化算法”(mng.bz/W76w),以及阅读该包的 API 文档以获取其有效性的更多证据(hyperopt.github.io/hyperopt/)。

更高级(且复杂)的技术

任何比 Hyperopt 的 TPE 和类似的自动化调优包更高级的技术通常意味着做两件事之一:支付提供自动化-ML(autoML)解决方案的公司,或者自己构建。在构建自定义调谐解决方案的领域,你可能会考虑将遗传算法与贝叶斯先验搜索优化相结合,以在n-维超参数空间中创建具有最高成功概率的搜索候选者,利用遗传算法所知的选择性优化。

从一个构建了这些 autoML 解决方案(github.com/databrickslabs/automl-toolkit)的人的角度来看,除非你正在为数百(或更多)个不同的项目构建自定义框架,并且有明确的需求来开发一个高性能且成本较低的优化工具,专门用于解决公司面临的问题,否则我不建议走这条路。

然而,AutoML 绝对不是大多数经验丰富的 DS 团队的可接受选项。这些解决方案的本质,除了配置驱动的界面外,在很大程度上是自主的,这迫使你放弃对软件中包含的决策逻辑的控制和可见性。你失去了发现为什么某些特征被删除而其他特征被创建的原因,为什么选择了特定的模型,以及为了实现声称的最佳结果,可能对特征向量进行了哪些内部验证的能力。

除了这些解决方案是黑盒之外,重要的是要认识到这些应用的目标受众。这些功能齐全的管道生成工具包最初并不是为经验丰富的 ML(机器学习)开发者设计的或打算使用的。它们是为不幸被称为公民数据科学家的人构建的——这些人是业务领域的专家,他们深知自己的业务需求,但没有经验或知识自己手工制作 ML 解决方案。

建立一个框架来自动化公司面临的一些更(可以说是)无聊和基础建模需求可能看起来很令人兴奋。这确实可以。然而,这些框架并不简单构建。如果你正在走定制化构建的道路,比如一个 autoML 框架,确保你有足够的带宽去做这件事,确保业务理解并批准这个庞大的项目,以及你能够证明在大量时间和资源投入上的回报。在项目进行到中途时,不是添加几个月酷炫工作的好时机。

7.1.2 Hyperopt 入门

回到我们的预测项目工作中,我们可以自信地断言,为每个机场调整模型的最佳方法将是通过使用 Hyperopt 和其 TPE 方法。

注意:Hyperopt 是一个 Anaconda 构建之外的外部包。要使用它,你必须在你的环境中执行 pip 或 conda 安装包。

在我们深入研究将要使用的代码之前,让我们从简化的实现角度来看看这个 API 是如何工作的。首先,Hyperopt 的第一个方面在于目标函数的定义(列表 7.1 展示了寻找最小化的函数的简化实现)。这个目标函数通常是拟合在训练数据上的模型,在测试数据上验证,评分,并返回与验证数据相比预测数据的误差度量。

列表 7.1 Hyperopt 基础:目标函数

import numpy as np
def objective_function(x):                   ❶
  func = np.poly1d([1, -3, -88, 112, -5])    ❷
  return func(x) * 0.01                      ❸

❶ 定义要最小化的目标函数

❷ 我们想要求解的一维四次多项式方程

❸ 最小化优化的损失估计

为什么选择 Hyperopt?

我使用 Hyperopt 进行这次讨论仅仅是因为它被广泛使用。其他工具执行类似甚至更高级的版本,这些版本是设计这个包要做的(优化超参数)。Optuna (optuna.org) 是对构建 Hyperopt 的原始研究工作的一个相当显著的延续。我强烈建议您去了解一下。

这本书的重点不在于技术。它关注的是围绕技术使用的过程。在不久的将来,会出现更好的技术。会有一种更优的方法来寻找优化参数。该领域的发展是持续、不可避免且快速的。我对讨论一种技术比另一种技术更好不感兴趣。很多其他书籍都在做这件事。我感兴趣的是讨论为什么使用某种东西来解决这个问题很重要。请随意选择对你来说感觉正确的“某种东西”。

在我们声明了目标函数之后,使用 Hyperopt 的下一个阶段是定义一个搜索空间。对于这个例子,我们只对优化单个值感兴趣,以便解决 7.1 列表中多项式函数的最小化问题。在下一个列表中,我们定义了这个函数一个 x 变量的搜索空间,实例化 Trials 对象(用于记录优化历史),并使用 Hyperopt API 中的最小化函数运行优化。

列表 7.2 Hyperopt 对简单多项式的优化

optimization_space = hp.uniform('x', -12, 12)     ❶
trials = Trials()                                 ❷
trial_estimator = fmin(fn=objective_function,     ❸
                       space=optimization_space,  ❹
                       algo=tpe.suggest,          ❺
                       trials=trials,             ❻
                       max_evals=1000             ❼
)

❶ 定义搜索空间——在这种情况下,种子在 -12 和 12 之间的均匀采样,以及在初始种子先验返回后的 TPE 算法的有界高斯随机选择

❷ 实例化 Trials 对象以记录优化历史

❸ 如 7.1 列表中定义的目标函数,传递给 Hyperopt 的 fmin 优化函数

❹ 搜索空间,如上所述(-12 到 12,均匀分布)

❺ 要使用的优化算法——在这种情况下,树结构 Parzen 估计器

❻ 将 Trials 对象传递给优化函数以记录运行历史

❷ 进行优化运行的次数。由于 hpopt 是受迭代次数限制的,我们可以通过这种方式控制优化的运行时间。

一旦执行此代码,我们将收到一个进度条(在基于 Jupyter 的笔记本中),它将在优化过程中返回运行历史中发现的最佳损失。在运行结束时,我们将从 trial_estimator 获取 x 的最佳设置,以最小化函数 objective_function 中定义的多项式返回的值。以下列表显示了此简单示例的工作过程。

列表 7.3 Hyperopt 在最小化简单多项式函数中的性能

rng = np.arange(-11.0, 12.0, 0.01)                                    ❶
values = [objective_function(x) for x in rng]                         ❷
with plt.style.context(style='seaborn'):
  fig, ax = plt.subplots(1, 1, figsize=(5.5, 4))
  ax.plot(rng, values)                                                ❸
  ax.set_title('Objective function')
  ax.scatter(x=trial_estimator[‘x’], y=trials.average_best_error(), marker='o', s=100)                                               ❹
  bbox_text = 'Hyperopt calculated minimum value\nx: {}'.format(trial_estimator['x'])
  arrow = dict(facecolor='darkblue', shrink=0.01, connectionstyle='angle3,angleA=90,angleB=45')
  bbox_conf = dict(boxstyle='round,pad=0.5', fc='ivory', ec='grey', lw=0.8)
  conf = dict(xycoords='data', textcoords='axes fraction', arrowprops=arrow, bbox=bbox_conf, ha='left', va='center', fontsize=12)
  ax.annotate(bbox_text, xy=(trial_estimator['x'], trials.average_best_error()), xytest=(0.3, 0.8), **conf)         ❺
  fig.tight_layout()
  plt.savefig('objective_func.svg', format='svg')

❶ 生成一系列 x 值,用于绘制 7.1 列表中定义的函数

❷ 从 rng 集合中检索每个 x 值对应的 y 值

❸ 在 rng 的 x 空间上绘制函数

❹ 绘制 Hyperopt 根据我们的搜索空间找到的优化最小值

❺ 在图表上添加注释以指示最小化值

运行此脚本会导致图 7.6 中的图表。

07-06

图 7.6 使用 Hyperopt 求解简单多项式的最小值

线性模型在参数和它们的损失度量之间经常有“凹陷”和“山谷”。我们使用术语 局部最小值局部最大值 来描述它们。如果参数搜索空间没有得到充分探索,模型的调整可能位于局部而不是全局的最小值或最大值。

7.1.3 使用 Hyperopt 调整复杂预测问题

现在你已经理解了这种自动化模型调整包背后的概念,我们可以将其应用于我们复杂的预测建模问题。正如我们在本章前面讨论的,如果没有一些帮助,调整这个模型将会很复杂。不仅我们有 11 个超参数要探索,而且我们在第六章中手动调整所取得的成果并不特别令人印象深刻。

我们需要一些帮助。让我们让托马斯·贝叶斯伸出援手(或者更确切地说,皮埃尔-西蒙·拉普拉斯)。列表 7.4 显示了我们的 Holt-Winters 指数平滑(HWES)模型针对机场乘客的优化函数。

列表 7.4:Holt-Winters 指数平滑的最小化函数

def hwes_minimization_function(selected_hp_values, train, test, loss_metric):❶
    model = ExponentialSmoothing(train,                                      ❷
                   trend=selected_hp_values['model']['trend'],
                   seasonal=selected_hp_values['model']['seasonal'],
                   seasonal_periods=selected_hp_values['model'][                'seasonal_periods'],
                   damped=selected_hp_values['model']['damped']
                   )
    model_fit = \                                                            ❸
    model.fit(smoothing_level=selected_hp_values['fit']['smoothing_level'],
                smoothing_seasonal=selected_hp_values['fit'][             'smoothing_seasonal'],
                damping_slope=selected_hp_values['fit']['damping_slope'],
                use_brute=selected_hp_values['fit']['use_brute'],
                use_boxcox=selected_hp_values['fit']['use_boxcox'],
                use_basinhopping=selected_hp_values['fit'][             'use_basinhopping'],
                remove_bias=selected_hp_values['fit']['remove_bias']
                )
    forecast = model_fit.predict(train.index[-1], test.index[-1])            ❹
    param_count = extract_param_count_hwes(selected_hp_values)               ❺
    adjusted_forecast = forecast[1:]                                         ❻
    errors = calculate_errors(test, adjusted_forecast, param_count)          ❼
    return {'loss': errors[loss_metric], 'status': STATUS_OK}                ❽

❶ selected_hp_values 是一个多级字典。由于我们有两组独立的超参数部分要应用,并且一些参数名称相似,我们通过“model”和“fit”来区分它们,以减少混淆。

❷ 将 ExponentialSmoothing 类实例化为一个对象,配置了 Hyperopt 将为每个模型迭代测试选择的价值

❸ fit 方法有其自己的超参数集,Hyperopt 将为它将生成和测试的模型池选择这些超参数。

❹ 为这次模型运行生成预测,以执行验证和评分。我们是从训练集的末尾到测试集索引的最后一个值进行预测。

❺ 一个获取参数数量(可在书籍的 GitHub 仓库中查看)的实用函数

❻ 移除预测的第一个条目,因为它与训练集最后一个索引条目重叠

❼ 计算所有错误指标——赤池信息准则(AIC)和贝叶斯信息准则(BIC),新添加的指标需要超参数计数

❽ Hyperopt 的最小化函数的唯一返回值是一个字典,包含用于优化的测试指标和来自 Hyperopt API 的状态报告消息。Trials()对象将持久化所有关于运行和最佳调整模型的数据。

如你所回忆的,在第六章中创建这个算法的原型时,我们硬编码了几个这些值(smoothing_levelsmoothing_seasonaluse_bruteuse_boxcoxuse_basin_hoppingremove_bias),以使原型调整变得更容易。在列表 7.4 中,我们将所有这些值设置为 Hyperopt 的可调整超参数。即使搜索空间如此之大,该算法也会允许我们探索它们对保留空间预测能力的影响。如果我们使用基于排列的(或者更糟糕的是,基于人类短期记忆的)方法,如网格搜索,我们可能不会想包括所有这些,仅仅是因为它们会以阶乘的方式增加运行时间。

现在我们已经完成了模型评分的实现,我们可以继续到下一个关键阶段,即高效调整这些模型:定义超参数的搜索空间。

列表 7.5 Hyperopt 探索空间配置

hpopt_space = {
    'model': {                                                             ❶
          'trend': hp.choice('trend', ['add', 'mul']),                     ❷
          'seasonal': hp.choice('seasonal', ['add', 'mul']),
          'seasonal_periods': hp.quniform('seasonal_periods', 12, 120, 12),❸
          'damped': hp.choice('damped', [True, False])
    },
    'fit': {
          'smoothing_level': hp.uniform('smoothing_level', 0.01, 0.99),    ❹
          'smoothing_seasonal': hp.uniform('smoothing_seasonal', 0.01, 0.99),
          'damping_slope': hp.uniform('damping_slope', 0.01, 0.99),
          'use_brute': hp.choice('use_brute', [True, False]),
          'use_boxcox': hp.choice('use_boxcox', [True, False]),
          'use_basinhopping': hp.choice('use_basinhopping', [True, False]),
          'remove_bias': hp.choice('remove_bias', [True, False])
    }
}

❶ 为了可读性,我们将配置分为类级别超参数(模型)和方法级别超参数(fit),因为其中一些名称是相似的。

❷ hp.choice 用于布尔值和多变量选择(从可能的值列表中选择一个元素)。

❸ hp.quniform 在量化空间中随机选择一个值(在本例中,我们选择 12 的倍数,介于 12 和 120 之间)。

❹ hp.uniform 在连续空间中随机选择(此处,介于 0.01 和 0.99 之间)。

此代码中的设置是截至 statsmodels 版本 0.11.1 可用的 ExponentialSmoothing() 类和 fit() 方法的所有超参数的总和。其中一些超参数可能不会影响我们模型的预测能力。如果我们通过网格搜索进行评估,我们可能会从评估中省略它们。由于 Hyperopt 的算法以更大的权重提供有影响力的参数,因此将它们保留在评估中不会显著增加总运行时间。

自动化调整此时间模型的令人畏惧的任务的下一步是构建一个函数来执行优化,收集调优运行的数据,并生成我们可以用于在后续微调运行中进一步优化搜索空间的图表。列表 7.6 显示了我们的最终执行函数。

注意:请参阅本书的配套仓库 github.com/BenWilson2/ML-Engineering,以查看列表 7.6 中调用所有函数的完整代码。其中包含更详细的讨论,可在可下载和可执行的工作簿中找到。

列表 7.6 Hyperopt 调优执行

def run_tuning(train, test, **params):                                   ❶
    param_count = extract_param_count_hwes(params['tuning_space'])       ❷
    output = {}
    trial_run = Trials()                                                 ❸
    tuning = fmin(partial(params['minimization_function'],               ❹
                          train=train, 
                          test=test,
                          loss_metric=params['loss_metric']
                         ), 
                  params['tuning_space'],                                ❺
                  algo=params['hpopt_algo'],                             ❻
                  max_evals=params['iterations'],                        ❼
                  trials=trial_run
                 )
    best_run = space_eval(params['tuning_space'], tuning)                ❽
    generated_model = params'forecast_algo'     ❾
    extracted_trials = extract_hyperopt_trials(trial_run, params['tuning_space'], params['loss_metric'])                      ❿
    output['best_hp_params'] = best_run
    output['best_model'] = generated_model['model']
    output['hyperopt_trials_data'] = extracted_trials
    output['hyperopt_trials_visualization'] = \ generate_hyperopt_report(extracted_trials, params['loss_metric'], params['hyperopt_title'], params['hyperopt_image_name'])            ⓫
    output['forecast_data'] = generated_model['forecast']
    output['series_prediction'] = build_future_forecast(
                                                generated_model['model'],
                                                params['airport_name'],
                                                params['future_forecast_                                                  periods'],
                                                params['train_split_cutoff_                                                  months'],
                                                params['target_name']
                                                       )                 ⓬
    output['plot_data'] = plot_predictions(test, 
                                           generated_model['forecast'], 
                                           param_count,
                                           params['name'], 
                                           params['target_name'], 
                                           params['image_name'])         ⓭
    return output

❶ 由于执行调优运行和收集优化过程中所有可视化和数据的配置量很大,我们将使用基于命名字典的参数传递(kwargs)。

❷ 要计算 AIC 和 BIC,我们需要优化中使用的超参数总数。我们不必强迫此函数的用户计数,我们可以从传递的 Hyperopt 配置元素 tuning_space 中提取它们。

❸ Trials() 对象记录了每个超参数实验的不同试验,并允许我们查看优化是如何收敛的。

❹ fmin() 是启动 Hyperopt 运行的主方法。我们使用部分函数作为对每个模型静态属性的包装器,这样每个 Hyperopt 迭代的唯一区别就在于变量超参数,而其他属性保持不变。

❺ 列表 7.5 中定义的调优空间

❻ Hyperopt 的优化算法(随机、TPE 或自适应 TPE),可以是自动化的或手动控制的

❼ 测试和搜索以找到最佳配置的模型数量

❽ 从 Trials()对象中提取最佳模型

❾ 重建最佳模型以记录和存储

❿ 从 Trials()对象中提取调整信息以进行绘图

⓫ 绘制试验历史图

⓬ 根据 future_forecast_periods 配置值指定的点数构建未来预测

⓭ 在保留验证期间绘制预测,以显示测试与预测(来自第六章的可视化更新版本)

注意:想了解更多关于部分函数和 Hyperopt 如何工作的信息,请参阅 Python 文档中的docs.python.org/3/library/functools.html#functools.partial以及 Hyperopt 文档和源代码github.com/hyperopt/hyperopt.github.io

注意:列表 7.6 的自定义绘图代码可在本书的配套仓库中找到;请参阅github.com/BenWilson2/ML-Engineering的 Chapter7 笔记本。

执行列表 7.6 中的plot_predictions()调用如图 7.7 所示。从列表 7.6 中调用generate_hyperopt_report()产生如图 7.8 所示的图表。

07-07

图 7.7 对总时间序列最近数据的预测回测(x 轴放大以提高可读性)

通过使用 Hyperopt 在我们的保留数据上获得最佳预测,我们将超参数优化到了一个程度,可以自信地预测未来的状态(前提是没有意外和不可知的潜在因素影响它)。因此,我们通过使用自动调整来解决了 ML 工作中优化阶段的一些关键挑战性元素:

  • 准确性—预测是最优的(对于每个模型,前提是我们选择合理的搜索空间并运行足够的迭代)。

  • 训练的及时性—在这个自动化水平下,我们可以在几分钟内而不是几天(或几周)内获得调优良好的模型。

  • 可维护性—自动化调整使我们不必手动重新训练模型,因为基线随着时间的推移而变化。

  • 开发的及时性—由于我们的代码是准模块化的(在笔记本中使用模块化函数),代码是可重用的、可扩展的,并且可以通过控制循环轻松地用于构建每个机场的所有模型。

07-08

图 7.8 Hyperopt 试验中采样到的超参数结果

注意:我们刚刚使用 Hyperopt 提取的代码示例是书中仓库 Notebooks 部分的一个更大端到端示例的一部分。在这个示例中,你可以看到对数据集中所有机场的自动化调整和优化,以及为支持这种有效的模型调整而构建的所有实用函数。

7.2 为平台和团队选择正确的技术

我们一直在讨论的预测场景,当在虚拟机(VM)容器中执行,并对单个机场进行自动调整优化和预测时,效果相当不错。我们对每个机场都得到了相当好的结果。通过使用 Hyperopt,我们还成功地消除了手动调整每个模型的不可维护的负担。虽然令人印象深刻,但这并没有改变我们不仅仅是在预测单个机场的乘客的事实。我们需要为数千个机场创建预测。

图 7.9 展示了我们迄今为止在墙钟时间方面所做的工作。每个机场模型(在for循环中)和 Hyperopt 的贝叶斯优化器(也是一个串行循环)的同步性质意味着我们正在等待模型一个接一个地构建,每个后续步骤都在等待前一个步骤完成,正如我们在第 7.1.2 节中讨论的那样。

07-09

图 7.9 单线程执行中的串行调整

如此大规模的机器学习问题,如图所示,是许多团队的绊脚石,主要是因为复杂性、时间和成本(这也是为什么此类规模的项目经常被取消的主要原因之一)。对于这些可扩展性问题,存在机器学习项目工作的解决方案;每个解决方案都涉及离开串行执行领域,进入分布式、异步或这两种计算范例的混合世界。

对于大多数 Python 机器学习任务的标准结构化代码方法是以串行方式执行。无论是列表推导式、lambda 函数还是forwhile)循环,机器学习都深深植根于顺序执行。这种方法可能是一个优点,因为它可以减少许多具有高内存需求的算法的内存压力,尤其是那些使用递归的算法,而递归算法很多。但这种方法也可能是一个缺点,因为它执行时间更长,因为每个后续任务都在等待前一个任务完成。

我们将在第 7.4 节简要讨论机器学习中的并发性,并在后面的章节中更深入地讨论(包括安全和不可安全的方法)。现在,鉴于我们项目相对于墙钟时间的可扩展性问题,我们需要考虑一种分布式方法来解决这个问题,以便更快地为每个机场探索搜索空间。正是在这一点上,我们离开了单线程虚拟机方法的世界,进入了 Apache Spark 的分布式计算世界。

7.2.1 为什么使用 Spark?

为什么使用 Spark?一言以蔽之:速度。

对于我们在这里处理的问题,即预测美国每个主要机场每月的乘客期望值,我们不受以分钟或小时为单位的 SLA(服务等级协议)的约束,但我们仍然需要考虑运行预测所需的时间量。这有多个原因,主要

  • 时间—如果我们将这个任务作为一个单体建模事件来构建,任何在运行时间极长的任务中的失败都将需要重启(想象一下任务在完成 99%后失败,连续运行了 11 天)。

  • 稳定性—我们非常关注任务中的对象引用,并确保我们不会创建可能导致任务失败的内存泄漏。

  • 风险—将机器专门用于运行时间极长的任务(即使在云服务提供商那里)可能会带来平台问题,这些问题可能会导致任务失败。

  • 成本—无论你的虚拟机在哪里运行,总有人为它们支付账单。

当我们专注于解决这些高风险因素时,分布式计算提供了对串行循环执行的吸引人的替代方案,这不仅因为成本,而且主要是因为执行速度。如果在任务中出现问题,数据中不可预见的问题,或者虚拟机运行的底层硬件问题,这些显著减少的预测任务执行时间将给我们提供灵活性,以便使用预测值快速重新启动任务。

关于 Spark 的简要说明

Spark 是一个大主题,一个巨大的生态系统,一个基于 Java 虚拟机(JVM)的活跃开源分布式计算平台。因为这不是一本关于 Spark 本身的书籍,所以我不打算深入探讨其内部工作原理。

关于这个主题已经写了几本值得注意的书,如果你倾向于了解更多关于这项技术的信息,我推荐阅读它们:Jules Damji 等人所著的《Learning Spark》(O’Reilly,2020 年),Bill Chambers 和 Matei Zaharia 所著的《Spark: The Definitive Guide》(O’Reilly,2018 年),以及 Jean-Georges Perrin 所著的《Spark in Action》(Manning,2020 年)。

话虽如此,在这本书中,我们将探讨如何有效地利用 Spark 来执行机器学习任务。从现在开始,许多示例都集中在利用平台的力量来执行大规模机器学习(包括训练和推理)。

对于当前章节,所涵盖的信息与 Spark 如何工作这些示例相比相对较高层次;相反,我们完全专注于如何使用它来解决问题。

但 Spark 如何帮助我们解决这个问题呢?我们可以采用两种相对简单直观的范式,如图 7.10 所示。我们可以使用不止这两种,但现在我们将从简单且不太复杂的开始;更高级的方法在第 7.4 节中提到。

07-10

图 7.10 使用 pandas_udf 在 Spark 上扩展超参数调整

第一种方法是利用集群内的工作者来并行评估超参数。在这种范式下,我们的时间序列数据集需要从工作者收集(完全物化)到驱动器。存在限制(在撰写本文时,数据的序列化大小限制为 2 GB),并且对于许多 Spark 上的 ML 用例,这种方法不应使用。对于此类时间序列问题,这种方法将工作得很好。

在第二种方法中,我们将数据留在工作者上。我们使用pandas_udf通过使用我们独立的 Hyperopt Trials()对象来分发每个工作者上每个机场的并发训练,就像我们在第六章中在单核 VM 上运行时做的那样。

现在我们已经从高层架构的角度定义了两种加速超参数调整的范式,让我们看看下一两个小节中的过程执行(以及每个的权衡)。

7.2.2 使用 SparkTrials 从驱动器处理调整

虽然 7.10 图显示了 Spark 集群中处理分布式调整的SparkTrials()操作的物理布局,但 7.11 图显示了更详细的执行。每个需要建模的机场在驱动器上迭代,其优化通过分布式实现来处理,其中每个候选超参数集合都提交给不同的工作者。

07-11

图 7.11:利用 Spark 工作者分布 Hyperopt 测试迭代以进行超参数优化的逻辑架构

这种方法通过最小的修改就能非常有效地工作,与单核方法相比,它只需要在并行级别增加时稍微增加迭代次数。

注意:将迭代次数作为并行级别的因子增加是不推荐的。在实践中,我通常通过简单地调整单核迭代次数 + (并行因子 / 0.2) 来增加迭代次数。这是为了提供一个更大的先前值池来抽取。由于并行运行异步执行,每个启动的边界纪元都不会有同步执行会有的在飞行结果的好处。

这对于 Hyperopt 优化器的本质来说至关重要。作为一个贝叶斯估计器,其能力到达一组经过测试的优化参数的强大之处直接在于其对先前数据的访问。如果同时执行太多的运行,那么它们结果数据的缺乏转化为搜索那些不太可能有效工作的参数的更高频率。没有先前结果,优化就变成了更多随机的搜索,违背了使用贝叶斯优化器的目的。

虽然这种权衡是可以忽略不计的,尤其是与利用 n 个工作者将每个迭代分布开来的相当令人印象深刻的性能相比。要将我们的函数移植到 Spark,只需要对这个第一个范式进行一些修改。

注意:要完全跟随 Apache Spark 分布式超参数优化的可参考和可执行示例,请参阅本书仓库中名为 Chapter8_1 的配套 Spark 笔记本,我们将在下一章中继续使用它。

我们首先需要做的是从 Hyperopt 中导入模块 SparkTrialsSparkTrials 是一个跟踪对象,允许集群的驱动器维护所有尝试过的不同超参数配置的历史记录,这些配置是在远程工作者上执行的(与跟踪在相同 VM 上运行的运行历史的标准 Trials 对象相反)。

完成导入后,我们可以通过使用本机 Spark 读取器(在这个例子中,我们的数据已经存储在 Delta 表中并注册到 Apache Hive 元存储中,使其可以通过标准数据库和表名标识符访问)。一旦我们将数据加载到工作者上,我们就可以将序列数据收集到驱动器,如下所示。

列表 7.7 使用 Spark 将数据收集到驱动器作为 pandas DataFrame

delta_table_nm = 'airport'                                          ❶
delta_database_nm = 'ben_demo'                                      ❷
delta_full_nm = "{}.{}".format(delta_database_nm, delta_table_nm)   ❸
local_data = spark.table(delta_full_nm).toPandas()                  ❹

❶ 定义我们写入机场数据的 Delta 表的名称

❷ 定义 Delta 表注册到的 Hive 数据库名

❸ 将数据库名和表名插入到标准 API 签名中用于数据检索

❹ 使用 Delta 中的工作者读取数据(从 Delta 中直接读取数据到驱动器没有能力),然后将数据收集到驱动节点作为一个 pandas DataFrame

警告:在 Spark 中收集数据时要小心。对于大多数大规模机器学习(训练数据集可能达到数十或数百吉字节),Spark 中的 .toPandas() 调用或任何收集操作都会失败。如果您有一大批可以迭代的重复数据,只需过滤 Spark DataFrame 并使用迭代器(循环)通过 .toPandas() 方法调用收集数据块,以控制每次在驱动器上处理的数据量。

在运行前面的代码后,我们留下数据驻留在驱动器上,准备利用 Spark 集群的分布式特性,以比我们在 7.1 节的 Docker 容器 VM 中处理的可扩展性更高的方式调整模型。以下列表显示了修改列表 7.6 的内容,使我们能够以这种方式运行。

列表 7.8 修改用于在 Spark 上运行 Hyperopt 的调整执行函数

def run_tuning(train, test, **params):
    param_count = extract_param_count_hwes(params['tuning_space'])
    output = {}
    trial_run = SparkTrials(parallelism=params['parallelism'], timeout=params['timeout'])                                           ❶
    with mlflow.start_run(run_name='PARENT_RUN_{}'.format(params['airport_name']), nested=True):                                      ❷
      mlflow.set_tag('airport', params['airport_name'])                    ❸
      tuning = fmin(partial(params['minimization_function'], 
                            train=train, 
                            test=test,
                            loss_metric=params['loss_metric']
                           ), 
                    params['tuning_space'], 
                    algo=params['hpopt_algo'], 
                    max_evals=params['iterations'], 
                    trials=trial_run,
                    show_progressbar=False
                   )                                                      ❹
      best_run = space_eval(params['tuning_space'], tuning)
      generated_model = params'forecast_algo'
      extracted_trials = extract_hyperopt_trials(trial_run, 
        params['tuning_space'], params['loss_metric'])
      output['best_hp_params'] = best_run
      output['best_model'] = generated_model['model']
      output['hyperopt_trials_data'] = extracted_trials
      output['hyperopt_trials_visualization'] = 
        generate_Hyperopt_report(extracted_trials, 
                               params['loss_metric'], 
                               params['hyperopt_title'], 
                               params['hyperopt_image_name'])
      output['forecast_data'] = generated_model['forecast']
      output['series_prediction'] = build_future_forecast(
                                          generated_model['model'],
                                          params['airport_name'],
                                          params['future_forecast_periods'],
                                          params['train_split_cutoff_months'],
                                          params['target_name'])
      output['plot_data'] = plot_predictions(test, 
                                             generated_model['forecast'], 
                                             param_count,
                                             params['name'], 
                                             params['target_name'], 
                                             params['image_name'])
      mlflow.log_artifact(params['image_name'])                            ❺
      mlflow.log_artifact(params['hyperopt_image_name'])                    ❻
    return output

❶ 配置 Hyperopt 使用 SparkTrials()而不是 Trials(),设置在集群工作节点上运行的并发实验数量和全局超时级别(因为我们使用 Futures 提交测试)

❷ 配置 MLflow 记录每个机场父运行中每个超参数测试的结果

❸ 将机场名称记录到 MLflow 中,以便更容易地搜索跟踪服务的输出结果

❹ 最小化函数在添加 MLflow 记录超参数和正在测试的迭代中计算的损失指标方面基本保持不变。

❺ 将最佳模型的预测图记录到父 MLflow 运行中

❻ 记录运行的超参数报告,写入父 MLflow 运行 ID

对代码进行的小修改就足以使其在 Spark 的分布式框架中工作。作为额外的好处(我们将在第 7.3 节中更深入地讨论),我们还可以轻松地将信息记录到 MLflow 中,解决我们创建可维护项目的关键需求之一:测试的来源,以便于参考和比较。

通过将此方法与我们在单核虚拟机中进行的运行进行并列比较,这种方法满足了我们所寻找的时效性目标。我们已经将此预测努力的优化阶段从超过 3.5 小时减少到相对较小的四节点集群上的不到 30 分钟(使用更高的 Hyperopt 迭代计数 600 和并行化参数 8,以尝试实现类似的损失指标性能)。

在下一节中,我们将探讨一种完全不同的方法来解决我们的可扩展性问题,通过并行化每个机场的模型而不是并行化调整过程。

7.2.3 使用 pandas_udf 处理工作节点上的调整

使用上一节的方法,我们能够通过利用 Spark 来分布单个超参数调整阶段,从而显著减少执行时间。然而,我们仍然在每个机场使用顺序循环。随着机场数量的增加,总作业执行时间与机场数量之间的关系仍然会线性增加,无论我们在 Hyperopt 调整框架内进行多少并行操作。当然,这种方法的有效性有一个极限,因为提高 Hyperopt 的并发级别将基本上抵消运行 TPE 的好处,并将我们的优化变成随机搜索。

相反,我们可以并行化实际的模型阶段本身,有效地将此运行时问题转化为水平扩展问题(通过向集群添加更多工作节点来减少所有机场建模的执行时间),而不是垂直扩展问题(迭代受限,只能通过使用更快的硬件来提高运行时间)。图 7.12 阐述了通过在 Spark 上使用 pandas_udf 解决我们的多模型问题的替代架构。

07-12

图 7.12 使用 Spark 控制一组 VM 以异步方式处理每个预测

在这里,我们使用 Spark DataFrame——基于不同 VM 上驻留的弹性分布式数据集(rdd)关系的分布式数据集——来控制我们的主要建模键的分组(在本例中,我们的 Airport_Code 字段)。然后我们将此聚合状态传递给一个 pandas_udf,它将利用 Apache Arrow 将聚合数据序列化为工作器作为 pandas DataFrame。这创建了众多并发 Python VM,它们都在处理各自的机场数据,就像它们是一个单独的 VM 一样。

虽然存在权衡。为了使这种方法工作,我们需要对我们的代码进行一些更改。列表 7.9 显示了这些更改中的第一个:将 MLflow 记录逻辑移动到我们的最小化函数中,向我们的函数参数添加记录参数,并在最小化函数中生成每个迭代的预测图,以便在建模阶段完成后查看。

列表 7.9 修改最小化函数以支持分布式模型方法

def hwes_minimization_function_udf(selected_hp_values, train, test, loss_metric,  airport, experiment_name, param_count, name, target_name, image_name, trial):                                                  ❶
    model_results = exp_smoothing_raw_udf(train, test, selected_hp_values)
    errors = calculate_errors(test, model_results['forecast'], 
      extract_param_count_hwes(selected_hp_values))
    with mlflow.start_run(run_name='{}_{}_{}_{}'.format(airport,          ❷
        experiment_name,str(uuid.uuid4())[:8], len(trial.results))):
      mlflow.set_tag('airport', airport)                                  ❸
      mlflow.set_tag('parent_run', experiment_name)                       ❹
      mlflow.log_param('id', mlflow.active_run().info.run_id)             ❺
      mlflow.log_params(selected_hp_values)                               ❻
      mlflow.log_metrics(errors)                                           ❼
      img = plot_predictions(test, 
                       model_results['forecast'], 
                       param_count,
                       name, 
                       target_name, 
                       image_name)
      mlflow.log_artifact(image_name)                                     ❽
    return {'loss': errors[loss_metric], 'status': STATUS_OK}

❶ 添加参数以支持 MLflow 记录

❷ 初始化每个迭代到其自己的 MLflow 运行,具有唯一名称以防止冲突

❸ 为 MLflow UI 搜索功能添加可搜索的标签

❹ 为特定执行中构建的所有模型集合提供可搜索的标签

❺ 记录 Hyperopt 的迭代次数

❻ 记录特定迭代的超参数

❼ 记录迭代的损失指标

❽ 保存从 plot_predictions 函数生成的图像(PNG 格式),该函数构建测试与预测数据

由于我们将在 Spark 工作器中直接执行伪本地 Hyperopt 运行,我们需要在新的函数中直接创建我们的训练和评估逻辑,该函数将消耗通过 Apache Arrow 传递给工作器作为 pandas DataFrame 处理的分组数据。下一个列表显示了此用户定义函数(udf)的创建。

列表 7.10 创建分布式模型 pandas_udf 以并发构建模型

output_schema = StructType([
  StructField('date', DateType()),
  StructField('Total_Passengers_pred', IntegerType()),
  StructField('Airport', StringType()),
  StructField('is_future', BooleanType())
])                                                                        ❶

@pandas_udf(output_schema, PandasUDFType.GROUPED_MAP)                     ❷
def forecast_airports(airport_df):

  airport = airport_df['Airport_Code'][0]                                 ❸
  hpopt_space = {
    'model': {
          'trend': hp.choice('trend', ['add', 'mul']),
          'seasonal': hp.choice('seasonal', ['add', 'mul']),
          'seasonal_periods': hp.quniform('seasonal_periods', 12, 120, 12),
          'damped': hp.choice('damped', [True, False])
    },
    'fit': {
          'smoothing_level': hp.uniform('smoothing_level', 0.01, 0.99),
          'smoothing_seasonal': hp.uniform('smoothing_seasonal', 0.01, 0.99),
          'damping_slope': hp.uniform('damping_slope', 0.01, 0.99),
          'use_brute': hp.choice('use_brute', [True, False]),
          'use_boxcox': hp.choice('use_boxcox', [True, False]),
          'use_basinhopping': hp.choice('use_basinhopping', [True, False]),
          'remove_bias': hp.choice('remove_bias', [True, False])
    }
  }                                                                       ❹

  run_config = {'minimization_function': hwes_minimization_function_udf,
                  'tuning_space': hpopt_space,
                  'forecast_algo': exp_smoothing_raw,
                  'loss_metric': 'bic',
                  'hpopt_algo': tpe.suggest,
                  'iterations': 200,
                  'experiment_name': RUN_NAME,
                  'name': '{} {}'.format('Total Passengers HPOPT', airport),
                  'target_name': 'Total_Passengers',
                  'image_name': '{}_{}.png'.format('total_passengers_               validation', airport),
                  'airport_name': airport,
                  'future_forecast_periods': 36,
                  'train_split_cutoff_months': 12,
                  'hyperopt_title': '{}_hyperopt Training 
                    Report'.format(airport),
                  'hyperopt_image_name': '{}_{}.png'.format(
                    'total_passengers_hpopt', airport),
                  'verbose': True
            }                                                             ❺

  airport_data = airport_df.copy(deep=True)
  airport_data['date'] = pd.to_datetime(airport_data['date'])
  airport_data.set_index('date', inplace=True)
  airport_data.index = pd.DatetimeIndex(airport_data.index.values, freq=airport_data.index.inferred_freq)
  asc = airport_data.sort_index()
  asc = apply_index_freq(asc, 'MS')                                       ❻

  train, test = generate_splits_by_months(asc, run_config['train_split_cutoff_months'])

  tuning = run_udf_tuning(train['Total_Passengers'], test['Total_Passengers'], **run_config)                              ❼

  return tuning                                                           ❽

❶ 由于 Spark 是强类型语言,我们需要向 udf 提供期望的结构和数据类型,即 pandas 将返回给 Spark DataFrame 的数据类型。这是通过使用定义字段名称及其类型的 StructType 对象来实现的。

❷ 通过应用在函数上方的装饰器定义了 pandas_udf 的类型(在这里我们使用了一个分组映射类型,它接受一个 pandas DataFrame 并返回一个 pandas DataFrame)

❸ 由于我们不能将额外值传递到这个函数中,我们需要从数据本身提取机场名称。

❹ 我们需要在 udf 内部定义我们的搜索空间,因为我们不能将其传递到函数中。

❺ 设置搜索的运行配置(在 udf 内部,因为我们需要在 MLflow 中按机场名称命名运行,而机场名称仅在数据传递给 udf 中的工作节点之后定义)

❻ 将 pandas DataFrame 的机场数据处理放在这里,因为系列数据的索引条件和频率没有在 Spark DataFrame 中定义。

❼ 对“运行调整”功能的唯一修改是移除为基于驱动器的分布式 Hyperopt 优化创建的 MLflow 日志,并仅返回预测数据,而不是包含运行指标和数据的字典。

❽ 返回预测的 pandas DataFrame(这是必需的,以便在所有机场完成异步分布式调整和预测运行后,可以将这些数据“重新组装”成一个汇总的 Spark DataFrame)

通过创建这个pandas_udf,我们可以调用分布式建模(使用 Hyperopt 的单节点Trials()模式)。

列表 7.11 执行基于模型的异步预测运行的全分布式运行

def validate_data_counts_udf(data, split_count):                           ❶
    return (list(data.groupBy(col('Airport_Code')).count()
          .withColumn('check', when(((lit(12) / 0.2) < (col('count') * 0.8)), 
            True)
          .otherwise(False))
          .filter(col('check')).select('Airport_Code').toPandas()[             'Airport_Code']))

RUN_NAME = 'AIRPORT_FORECAST_DEC_2020'                                     ❷
raw_data = spark.table(delta_full_nm)                                      ❸
filtered_data = raw_data.where(col('Airport_Code').isin(validate_data_counts_udf(raw_data, 12))).repartition('Airport_Code')                               ❹
grouped_apply = filtered_data.groupBy('Airport_Code').apply(forecast_airports)        ❺
display(grouped_apply)                                                     ❻

❶ 对单节点代码中使用的机场过滤进行了修改,利用 PySpark 过滤来确定特定机场的系列中是否有足够的数据来构建和验证预测模型

❷ 为特定的预测运行定义一个唯一名称(这设置了跟踪 API 的 MLflow 实验名称)

❸ 将 Delta(机场的历史乘客原始数据)中的数据读取到集群上的工作节点

❹ 过滤掉数据不足的情况,其中某个机场没有足够的数据进行建模

❺ 将 Spark DataFrame 分组并发送聚合数据到工作节点,作为 pandas DataFrame 通过 udf 执行

❻ 强制执行(Spark 是惰性评估的)

当我们运行此代码时,我们可以看到正在生成的机场模型数量与可用于处理我们的优化和预测运行的工作节点数量之间存在相对平坦的关系。虽然在最短的时间内(具有数千个工作节点的 Spark 集群)对超过 7,000 个机场进行建模的现实可能有些荒谬(仅成本就天文数字),但我们使用这种范式有一个可排队解决方案,其横向扩展能力是任何其他解决方案都无法比拟的。

尽管由于成本和资源限制(每个模型需要一个工作者),我们无法获得有效的 O(1)执行时间,但我们可以从一个包含 40 个节点的集群开始,实际上可以并行运行 40 个机场建模、优化和预测执行。这将将所有 7,000 个机场的总运行时间显著减少到 23 小时,而不是通过在虚拟机中通过嵌套循环(> 5,000 小时)运行它们,或者收集数据到 Spark 集群的驱动器上并运行分布式调优(> 800 小时)。

当寻找处理此类大规模项目的选项时,执行架构的可扩展性与其他机器学习组件一样关键。无论在构建解决方案的机器学习方面投入了多少努力、时间和勤奋,如果解决问题需要数千(或数百)小时,项目成功的可能性都很小。在下一章,第 8.2 节中,我们将讨论可以进一步减少已经大幅改善的 23 小时运行时间的替代方法。

7.2.4 使用团队的新范式:平台和技术

从一个新的平台开始,利用新的技术,也许学习一种新的编程语言(或者在你已知的语言中的新范式)对于许多团队来说是一项艰巨的任务。在前面的场景中,从运行在单台机器上的 Jupyter 笔记本迁移到分布式执行引擎 Spark 是一个相对较大的飞跃。

机器学习的世界提供了许多选择——不仅包括算法,还包括编程语言(R、Python、Java、Scala、.NET、专有语言)以及开发代码的地方(用于原型设计的笔记本、用于 MVP 的脚本工具和用于生产解决方案开发的 IDE)。最重要的是,有许多地方可以运行你编写的代码。正如我们之前看到的,导致项目运行时间大幅下降的不是语言,而是我们选择使用的平台。

在探索项目工作的选项时,做足准备工作是绝对关键的。测试不同的算法方法来解决特定问题至关重要,而找到适合该项目需求的地方来运行解决方案可能更为关键。

为了最大化解决方案被业务采纳的机会,应选择合适的平台以最小化执行成本,最大化解决方案的稳定性,并缩短开发周期以满足交付期限。关于在哪里运行机器学习代码的重要观点是,它就像这个职业的任何其他方面一样:花时间学习用于运行你的模型和分析的框架将是值得的,这将提高你未来工作的生产力和效率。正如 7.2.3 节中提到的,如果不了解如何实际使用特定的平台或执行范式,该项目可能需要为每个启动的预测事件花费数百小时的运行时间。

关于学习新事物的建议

在我的数据科学职业生涯早期,我对学习除 Python 之外的语言感到有些畏惧和犹豫。我错误地认为我的选择语言可以“做所有的事情”,并且我没有必要学习任何其他语言,因为当时我所使用的算法都在那里(据我所知),并且我对使用 pandas 和 NumPy 操作数据的细微差别很熟悉。当我不得不构建第一个涉及预测交付服务级别协议(SLA)的极其大规模的机器学习解决方案时,我深感错误,因为该 SLA 的时间太短,无法允许对兆字节级数据进行循环推理处理。

在接触 Hadoop 后的几年里,我精通了 Java 和 Scala,两者都用于构建机器学习用例的定制算法和框架,并扩展了我的并发异步编程知识,使我能够利用尽可能多的计算能力。我的建议?将学习新技术变成一种常规习惯。

数据科学和机器学习工作不是关于单一语言、单一平台或任何固定不变的东西。它是一个可变的发现职业,专注于以最佳方式解决问题。学习新的解决问题方法将只会对你和你工作的公司有益,并且有一天可能会帮助你利用在旅程中获得的知识回馈社区。

摘要

  • 依赖于手动和规定性的方法进行模型调优既耗时又昂贵,而且不太可能产生高质量的结果。利用模型驱动的参数优化是首选。

  • 选择合适的平台和实现方法对于耗时的 CPU 密集型任务可以显著提高机器学习项目的效率并降低开发成本。对于超参数调优等过程,最大化并行和分布式系统方法可以显著缩短开发时间表。

8 实验行动:使用 MLflow 和运行时优化完成最小可行产品(MVP)

本章涵盖

  • 版本控制 ML 代码、模型和实验结果的方法、工具和技巧

  • 模型训练和推理的可扩展解决方案

在前一章中,我们找到了解决作为机器学习从业者面临的最耗时和单调的任务之一的方法:微调模型。通过拥有解决繁琐调优的技术,我们可以大大降低产生不准确到毫无价值的机器学习解决方案的风险。然而,在应用这些技术的过程中,我们默默地欢迎了一个巨大的大象进入我们的项目房间:跟踪。

在过去的几章中,我们每次进行推理时都需要重新训练我们的时间序列模型。对于绝大多数其他监督学习任务,情况并非如此。那些其他建模应用,无论是监督学习还是无监督学习,都将定期进行重新训练事件,在这些事件之间,每个模型将被多次调用进行推理(预测)。

无论我们是否需要每天、每周或每月重新训练(你真的不应该让模型闲置超过那么长时间),我们都会有最终生成评分指标的最终生产模型的版本,以及自动化调优的优化历史。将这一大量建模信息与丰富的统计验证测试、元数据、工件和特定运行数据相结合,这些数据对于历史参考非常有价值,你就拥有了一座需要记录的关键数据山。

在本章中,我们将介绍将我们的调优运行数据记录到 MLflow 的跟踪服务器中,使我们能够对存储关于项目解决方案的重要信息有历史参考。拥有这些数据不仅对于调优和实验有价值;对于监控解决方案的长期健康状况也至关重要。随着时间的推移,可参考的指标和参数搜索历史有助于了解如何可能使解决方案变得更好,同时也提供了洞察,当性能下降到需要重建解决方案的程度时。

注意:本章节提供了一个配套的 Spark 笔记本,其中包含了本章讨论的要点示例。如需进一步详情,请参阅随附的 GitHub 仓库。

8.1 记录:代码、指标和结果

第二章和第三章讨论了关于建模活动的沟通对于企业和数据科学家团队的重要性。不仅能够展示我们的项目解决方案,而且拥有一个可追溯的历史记录以供参考,这对于项目的成功至关重要,甚至比解决该问题的算法更为重要。

对于我们在过去几章中一直在讨论的预测项目,解决方案的机器学习方面并不特别复杂,但问题的规模却是巨大的。要为成千上万的机场建模(这反过来意味着需要调整和跟踪成千上万的模型),处理通信并为项目代码的每次执行提供历史数据的参考是一项艰巨的任务。

当我们在生产环境中运行我们的预测项目后,如果业务单元团队中的成员想要解释为什么某个预测与收集到的数据的最终现实相差如此之远,会发生什么?这是许多依赖机器学习预测来告知业务应采取哪些行动的公司常见的疑问。如果发生黑天鹅事件,业务询问为什么模型预测没有预见它,而你又不得不尝试重新生成模型在某个时间点的预测以全面解释不可预测事件无法被建模的情况,这将是你最不愿意面对的事情。

注意:黑天鹅事件是一种不可预见且许多时候具有灾难性的事件,它改变了获取数据的性质。虽然罕见,但它们可以对模型、企业和整个行业产生灾难性的影响。一些最近的黑天鹅事件包括 2001 年 9 月 11 日的恐怖袭击、2008 年的金融危机和 Covid-19 大流行。由于这些事件的影响范围广泛且完全不可预测,对模型的影响可能是绝对毁灭性的。术语“黑天鹅”是由纳西姆·尼古拉斯·塔勒布(Nassim Nicholas Taleb)在他的书中《黑天鹅:几乎不可能发生的事物的影响》(Random House,2007 年)中提出并普及的。

为了解决机器学习从业者历史上必须处理的这些棘手问题,MLflow 被创建出来。在本节中,我们将探讨 MLflow 的跟踪 API,它为我们提供了一个记录所有调整迭代、每个模型调整运行中的指标以及可以轻松检索和引用的预生成可视化(GUI)的地方。

8.1.1 MLflow 跟踪

让我们看看第七章(7.2 节)中基于 Spark 的两个实现与 MLflow 日志记录相关的情况。在该章节中展示的代码示例中,MLflow 上下文的初始化在两个不同的地方实例化。

在第一种方法中,使用SparkTrials作为状态管理对象(在驱动程序上运行),MLflow 上下文被放置在run_tuning()函数中整个调整运行的外部包装器。这是使用SparkTrials进行运行跟踪的首选方法,以便可以轻松地将父运行的个人子运行关联起来,以便在跟踪服务器的 GUI 中查询,以及涉及过滤谓词的跟踪服务器 REST API 请求。

图 8.1 展示了与 MLflow 跟踪服务器交互时此代码的图形表示。该代码不仅记录了封装运行的父级元数据,还记录了每个超参数评估发生时工作者进行的迭代日志。

08-01

图 8.1 使用分布式超参数优化进行 MLflow 跟踪服务器日志记录。

当查看 MLflow 跟踪服务器 GUI 中的实际代码表现时,我们可以看到这种父子关系的结果,如图 8.2 所示。

08-02

图 8.2 MLflow 跟踪 UI 示例

相反,用于pandas_udf实现的这种方法略有不同。在第七章的列表 7.10 中,Hyperopt 执行的每个单独迭代都需要创建一个新的实验。由于没有父子关系来分组数据,因此需要应用自定义命名和标记,以便在 GUI 中进行搜索,并且对于具有生产能力的代码来说更为重要——REST API。此替代方案(以及此用例数千个模型的更可扩展实现)的日志机制概述如图 8.3 所示。

08-03

图 8.3 MLflow 为 pandas_udf 分布式模型方法提供的日志逻辑执行。

无论选择哪种方法,所有这些讨论的重要方面是,我们已经解决了一个经常导致项目失败的大问题。(每种方法都有其针对不同方法的优点;对于单一模型项目,SparkTrails无疑是更好的选择,而对于我们在此处展示的具有数千个模型的预测场景,pandas_udf方法则远胜一筹。)我们解决了长期困扰机器学习项目工作的历史跟踪和组织难题。能够轻松访问测试结果以及训练和评分时正在生产的模型的状态,是创建成功的机器学习项目的一个基本要素。

8.1.2 请停止打印并记录您的信息

现在我们已经看到了一个可以用来跟踪我们的实验、调整运行和每个预测作业的预生产训练的工具,让我们花点时间讨论使用跟踪服务构建机器学习项目时的另一个最佳实践方面:日志记录。

我在生产 ML 代码中看到的print语句的次数确实令人震惊。大多数情况下,这是由于忘记(或故意留下以供未来调试)的调试脚本行,让开发者知道代码正在执行(并且是否在运行时可以安全地去喝咖啡)。在解决方案开发期间的咖啡休息时间之外,这些print语句永远不会再次被人类眼睛看到。图 8.4 的顶部显示了这些print语句在代码库中的无关性。

图 8.4 比较了在 ML 项目代码中常见的模式,尤其是在前两个领域。虽然顶部部分(在笔记本中打印到 stdout,这些笔记本以某种周期性执行)绝对不推荐,但遗憾的是,这是在行业中看到的最常见的习惯。对于编写 ML 项目打包代码的更复杂的团队(或使用可以编译的语言,如 Java、Scala 或基于 C 的语言),历史上的做法是将运行信息记录到日志守护程序中。虽然这确实为数据记录维护了历史参考,但它也涉及到大量的 ETL 或更常见的是 ELT,以便在出现问题时提取信息。图 8.4 中的最后一个块展示了如何利用 MLflow 解决这些可访问性问题,以及任何 ML 解决方案的历史溯源需求。

08-04

图 8.4 比较了 ML 实验的信息存储范式

为了明确起见,我并不是说永远不要使用printlog语句。在调试特别复杂的代码库时,它们具有非凡的效用,并且在开发解决方案时非常有用。这种效用在你过渡到生产开发时开始减弱。print语句不再被查看,当你忙于其他项目时,解析日志以检索状态信息的需求变得远不如以前那样令人愉快。

如果需要记录一个项目代码执行的关键信息,它应该始终被记录并保存以供将来参考。在像 MLflow 这样的工具解决这个问题之前,许多数据科学团队会将这些关键信息记录到关系型数据库管理系统(RDBMS)中的一个表中,以用于生产目的。更大规模的团队,拥有数十个在生产中的解决方案,可能会利用 NoSQL 解决方案来处理可扩展性。真正自虐的人会编写 ELT 作业来解析系统日志以检索他们关于模型的关键数据。MLflow 通过创建一个连贯的统一框架来简化所有这些情况,该框架用于指标、属性和工件记录,以消除 ML 日志记录中耗时的工作。

如我们在 Spark 早期示例中看到的那样,我们在这些运行之外记录了额外的信息,这些信息通常与调优执行相关联。我们记录了每个机场的指标和参数,以便于历史搜索,以及我们的预测图表。如果我们有额外的数据要记录,我们可以通过 API 简单地添加一个标签,形式为mlflow.set_tag(<key>, <value>)用于运行信息记录,或者,对于更复杂的信息(可视化、数据、模型或高度结构化数据),我们可以使用 API mlflow.log_artifact(<location and name of data on local filesystem>)将其记录为工件。

将特定模型调优和训练事件的所有相关信息保存在一个单独的地方,且在执行运行的系统之外,可以在尝试重新创建模型在训练时可能遇到的确切条件时节省无数令人沮丧的工作时间。能够快速回答有关业务对模型性能的信心的问题可以显著降低项目放弃的可能性,以及节省大量时间来改进表现不佳的模型。

8.1.3 版本控制、分支策略和与他人协作

影响项目及时且有序地交付到 MVP 阶段的最大开发工作之一是团队(或个人)与存储库的交互方式。在我们的示例场景中,一个相对规模较大的 ML 团队正在处理预测模型的各个组件,每个人都能够以结构化和受控的方式为代码库的各个部分做出贡献,这对于消除令人沮丧的重做、损坏的代码和大规模重构至关重要。虽然我们还没有深入研究该代码的生产版本会是什么样子(它不会在笔记本中开发,这是肯定的),但总体设计看起来可能类似于图 8.5 中的模块布局。

08-05

图 8.5 预测项目的初始存储库结构

随着项目的进展,项目不同的团队成员将在任何给定时间贡献代码库中的不同模块。有些人可能在冲刺期间处理与可视化相关的任务和故事。其他人可能在该冲刺期间从事核心建模类的工作,而常见的实用函数将由团队中的几乎每个人添加和改进。

没有使用强大的版本控制系统以及围绕将代码提交到该仓库的基础流程,代码库被显著降级或损坏的可能性很高。虽然机器学习(ML)开发的大部分方面与传统软件开发开发有显著不同,但两个领域完全相同的一个方面是版本控制和分支开发实践。

为了防止不兼容的更改合并到主分支导致的问题,从冲刺中选取的每个故事或任务,DS 需要对其工作的当前主分支的构建进行分支。在这个分支中,应该构建新功能,更新通用功能,并添加新的单元测试,以确保团队相信这些修改不会破坏任何东西。当关闭故事(或任务)的时间到来时,为该故事(或任务)开发代码的 DS 需要确保整个项目的代码通过单元测试(特别是对于他们没有修改的模块和功能)以及完整的运行集成测试,然后才能提交他们的同行评审请求以合并代码到主分支。

图 8.6 显示了处理仓库时机器学习项目工作的标准方法,无论使用的仓库技术或服务如何。每个都有自己的细微差别、功能和命令,我们在这里不会深入探讨;重要的是仓库的使用方式,而不是如何使用特定的一个。

08-06

图 8.6 机器学习团队在功能开发期间的仓库管理流程

通过遵循这样的代码合并范例,可以完全避免大量的挫败感和浪费时间。这将简单地为数据科学(DS)团队成员留下更多时间来解决项目的实际问题,而不是解决合并地狱问题以及修复由不良合并导致的损坏代码。对代码合并候选者的有效测试可以带来更高的项目速度,这可以显著降低项目放弃的可能性,为项目创建一个更可靠、更稳定且无错误的代码库。

8.2 可扩展性和并发性

在我们一直在工作的这个项目中,解决方案最关键和最复杂的方面是可扩展性。当我们谈论可扩展性时,我们实际上是在指成本。虚拟机(VM)运行和执行我们的项目代码的时间越长,我们的账单上的无声计时器就越高。我们可以做的任何事,都是为了最大化硬件随时间变化的资源利用率,这将保持账单在可管理状态,减少企业对解决方案总成本的担忧。

在第七章的后半部分,我们评估了两种将我们的问题扩展以支持建模多个机场的策略。第一种,在集群上并行化超参数评估,与串行方法相比,显著缩短了每个模型的训练时间。第二种,在集群上并行化实际的每个模型的训练,以略不同的方式扩展了解决方案(这更有利于许多模型/合理的训练迭代方法),以更大的方式减少了我们的解决方案的成本足迹。

如第七章所述,这些只是扩展此问题的两种方法,两者都涉及并行实现,将建模过程的各个部分分散到多台机器上。然而,我们可以添加一个额外的处理层来进一步加快这些操作的速度。图 8.7 展示了我们增加机器学习任务吞吐量以减少构建解决方案所需墙钟时间的选项概述。

08-07

图 8.7 执行范例比较

在图 8.7 中向下缩放会带来简单性和性能之间的权衡。对于需要分布式计算可以提供的规模的问题,了解将引入代码库的复杂性水平是很重要的。这些实现的挑战不再局限于解决方案的 DS 部分,而是需要越来越复杂的工程技能来构建。

获得构建利用能够处理分布式计算的系统(例如,Spark、Kubernetes 或 Dask)的大规模机器学习项目的知识和能力,将有助于确保你能够实施需要扩展的解决方案。根据我自己的经验,我花费的时间很好地用于学习如何利用并发性和分布式系统的使用来通过尽可能多地垄断可用硬件资源来加速项目性能和降低项目成本。

为了简洁起见,我们不会在本章中详细介绍图 8.7 最后两个部分的实现示例。然而,我们将在本书的后面部分讨论并发操作的一些示例。

8.2.1 什么是并发性?

在图 8.7 中,你可以看到在底部两个解决方案中列出了并发性这个术语。对于大多数没有软件工程背景的数据科学家来说,这个术语很容易被误解为并行性。毕竟,它实际上是在同时做很多事情。

并发性,按定义,是指同时执行许多任务的行为。它并不暗示任务的排序或同时处理的顺序。它仅仅要求系统和发送给它的代码指令能够同时运行多个任务。

另一方面,并行性通过将任务划分为可以在 CPU 或 GPU 的离散线程和核心上并行执行的子任务来实现。例如,Spark 在执行器上的离散核心分布式系统中并行执行任务。

这两个概念可以在支持它们的系统中结合,一个由多台机器组成,每台机器都有多个可用的核心。这种系统架构在图 8.7 的底部最后部分显示。图 8.8 说明了并行执行、并发执行以及混合并行-并发系统之间的差异。

08-08

图 8.8 执行策略比较

利用这些执行策略来解决适当类型的问题可以显著提高项目的成本。虽然使用最复杂的方法(在分布式系统中进行并行并发处理)来处理每个问题可能看起来很有吸引力,但这并不值得。如果你试图解决的问题可以在单台机器上实现,那么通过采用这种方法来降低基础设施复杂性总是最好的选择。只有在你需要的时候,才建议你走更复杂的路径。这尤其适用于数据、算法或任务规模如此之大,以至于简单的方案不可行的情况。

8.2.2 你可以(和不可以)异步运行的内容

关于提高运行时性能的最后一句话,重要的是要提到,在机器学习中并非每个问题都可以通过并行执行或在分布式系统中解决。许多算法需要维护状态才能正确运行,因此不能分割成子任务在核心池上执行。

我们在前几章中讨论的单变量时间序列的情景确实可以从并行化中受益。我们可以并行化 Hyperopt 调优和模型训练。我们可以在数据本身内实现隔离(每个机场的数据是自包含的,不依赖于任何其他数据)以及调优操作,这意味着我们可以通过适当利用分布式处理和异步并发来显著减少我们工作的总运行时间。

当选择提高建模解决方案性能的机会时,你应该考虑正在执行的任务之间的依赖关系。如果有机会将任务彼此隔离,例如根据可以应用于数据集的过滤器来分离模型评估、训练或推理,那么利用可以为你处理此处理的框架可能是值得的。

然而,在机器学习中,许多任务是无法分布的(或者至少难以分布)。需要访问整个特征训练集的模型不适合分布式训练。其他模型可能具有分布的能力,但由于需求或构建分布式解决方案的技术复杂性,尚未实现。当考虑一个算法或方法是否可以通过分布式处理利用并发或并行性时,最好的办法是阅读流行框架的库文档。如果一个算法尚未在分布式处理框架上实现,那么很可能有很好的理由。要么有更简单的方法可以满足你正在研究的模型(高度可能),要么构建分布式解决方案的开发和运行成本非常高。

摘要

  • 在整个解决方案的生命周期中利用实验跟踪服务,例如 MLflow,可以显著提高项目的可审计性和历史监控。此外,利用版本控制和日志记录将增强生产代码库,能够减少故障排除时间,并在生产中允许对项目健康状况的诊断报告。

  • 学习在可扩展的基础设施中使用和实现解决方案对于许多大规模机器学习项目至关重要。虽然这不适用于所有实现,但理解分布式系统、并发性和使这些范式成为可能的框架对于机器学习工程师来说是至关重要的。

第二部分 准备生产:创建可维护的 ML

现在你已经完成了这本书的第一部分,你对借鉴现代软件开发方法的项目想法验证模式有了感觉。一旦一个想法经过适当的审查并且(粗略的)原型已经构建,构建可维护解决方案的下一步就是专注于正确地构建它。

在第一部分的介绍中,我提到许多机器学习项目失败是因为规划不足和范围不明确。紧随其后的是关闭生产中的项目的失败模式,要么是因为它是一个无法挽救的混乱,要么是因为业务没有意识到它的价值,并且不愿意继续为其运行付费。这些是可以通过应用特定项目开发方法来避免的可以解决的问题。

在第二部分,我们将回顾我在自己的项目工作中学到的经验教训,以及在他人工作中看到的一些经验教训(无论好坏),以及有助于你构建以下内容的 ML 代码开发标准:

  • 运行良好的代码

  • 可测试且易于调试的代码

  • 可以轻松修改的解决方案

  • 可以评估其性能的解决方案(基于它是否很好地解决了问题,并且继续解决它设定的目标问题)

  • 你不会后悔构建的解决方案

在这些指南到位后,你将处于一个更好的位置来将你的解决方案部署到生产环境中,安心地知道你将支持一个你和你团队可以维护的软件部署。

9 机器学习的模块化:编写可测试和可读的代码

本章涵盖了

  • 阐述为什么单体脚本编码模式使机器学习项目更复杂

  • 理解调试非抽象代码的复杂性

  • 将基本抽象应用于机器学习项目

  • 在机器学习代码库中实现可测试的设计

当你被交给一个别人编写的复杂代码库时,几乎没有比这更让人心灵崩溃的情感。在被告知你负责修复、更新和支持它之后,阅读一大堆难以理解的代码是令人沮丧的。当你继承了一个本质上已经损坏的代码库来维护时,唯一更糟糕的情况是你的名字出现在提交历史中。

这并不是说代码不起作用。它可能运行得很好。代码能运行的事实并不是问题。问题是人类无法轻易地弄清楚它是如何(或者,更糟糕的是,为什么)工作的。我相信这个问题在 2008 年由马丁·福勒最生动地描述了:

任何傻瓜都能写出计算机能理解的代码。优秀的程序员写出人类能理解的代码。

大部分机器学习代码不符合良好的软件工程实践。由于我们专注于算法、向量、索引器、模型、损失函数、优化求解器、超参数和性能指标,我们作为实践者的一个职业,通常不会花太多时间遵守严格的编码标准。至少,我们中的大多数是这样的。

我可以自豪地说,我多年来就是这样一个人,编写了一些真正糟糕的代码(大多数时候它在我发布时是能工作的)。我专注于尽可能小的精度改进或是在特征工程任务中变得巧妙,结果我创造了一个真正的弗兰肯斯坦怪物,是难以维护的代码。为了公正地对待这个被误解的复活的生物,我的一些早期项目要恐怖得多。(如果我的同伴拿着火把和长柄叉追赶我,我不会责怪他们。)

本章和下一章致力于我在多年中学习到的编码标准教训。这绝对不是关于软件工程的详尽论述;有书是关于这个话题的。相反,这些是我为了创建更简单、更容易维护的机器学习项目代码库而学到的最重要的方面。我们将按照图 9.1 所示的五个关键领域来介绍这些最佳实践。

本章中的部分内容,如图 9.1 所示,展示了我所做的可怕事情、我在他人代码中看到的可怕元素,以及最重要的是,如何解决这些问题。我们本章的目标是避免复杂的和过度复杂的代码的弗兰肯斯坦怪物。

09-01

图 9.1 比较了机器学习项目工作中的编码实践极端

复杂代码与复杂代码

这个短语复杂与复杂可能看起来是对语法的一个糟糕解释——毕竟这两个词似乎意味着相同的意思。但是,当应用于代码时,每个词都有明显的不同。一个复杂的代码库是对特定封装代码(例如:函数或方法)可以遍历的分支路径的经验性评估。for循环、条件语句、匹配的switch语句以及传入的参数功能状态变化都是增加代码复杂性的元素。由于包含的代码可以执行许多“事情”,因此从这个代码中可能产生的结果数量非常高。复杂的代码库通常需要大量的测试来确保它们在所有可能的情况下都能正确运行。这种复杂性也使得代码库比那些功能分支路径更少的代码库更难以理解。

然而,一个复杂的代码库是这样编写的,使得很难确定代码的功能。这种高度主观的评估“阅读和弄懂有多难”是一个很大程度上取决于阅读代码的人的衡量标准。不管怎样,大多数经验丰富的开发者可以就复杂代码库与简单代码库的区别达成一般共识。

一个高度复杂的代码库可能是在代码反复修补(重构代码以修复延迟的技术债务)之后达到这种状态的。这种代码可能链式连接,使用糟糕的命名约定,以难以阅读的方式重复使用条件逻辑,或者只是自由地使用不常见的缩写符号(我看着你,Scala 通配符,_)。

代码库可以是由以下这些元素混合而成的:

  • 复杂但不复杂——对于机器学习代码库来说是一个具有挑战性但可接受的状态。

  • 不复杂也不复杂——也是可接受的,但在数据科学工作中的应用案例中通常不常见。

  • 不复杂也不复杂——我们将在第 9.1.1 节中看到一个例子。

  • 复杂且复杂——作为机器学习实践者在继承代码库时存在的烦恼。

在开发机器学习项目时,目标是首先尽可能降低代码库的复杂度,同时也要尽可能减少复杂性。这两个概念的衡量标准越低,你正在工作的项目不仅能够进入生产阶段,而且更有可能成为一个可维护和可扩展的解决方案,以满足业务需求。

为了让事情变得简单,我们将在本章中通过一个相对简单的例子来进行分析,这是我们所有人都应该相当熟悉的内容:单变量数据的分布估计。我们将坚持使用这个例子,因为它简单且易于接近。我们将从不同编程问题的角度来审视相同的有效解决方案,讨论在所有考虑因素中,关注可维护性和实用性是多么重要。

9.1 理解单体脚本及其为何不好

在计算机的世界里,“继承”可以意味着几件事情。这个话题首先出现在我们思考通过抽象(面向对象设计中代码的重用,以减少复制的功能并降低复杂性)来构建可扩展代码时。虽然这种类型的继承无疑是好的,但另一种类型的继承可以从好到噩梦般。这是我们承担他人代码库责任时所获得的继承。

让我们想象一下,你加入了一家新公司。经过培训后,你被分配了一个令牌来访问 DS 存储库(repo)。当你第一次穿越这个 repo 时,这种感觉要么令人兴奋,要么令人恐惧,这取决于你之前做过多少次。你将发现什么?你的前任在这个公司都建立了什么?代码的调试、修改和支持将有多容易?它是否充满了技术债务?它的风格是否一致?它是否遵循语言标准?

初看目录结构时,你会感到胃里一阵恶心。有数十个目录,每个目录都有一个项目名称。在这些目录中,每个目录都只有一个文件。你知道在弄清楚这些庞大且混乱的脚本如何工作时,你将面临一个充满挫折的世界。你将负责提供这些脚本的支持,这将是极其具有挑战性的。毕竟,每个出现的问题都将涉及对这些令人困惑和复杂的脚本进行逆向工程,即使是发生最微不足道的错误也是如此。

9.1.1 单体是如何形成的

如果我们要深入研究我们新团队存储库的提交历史,我们可能会发现从原型到实验的无缝过渡。第一个提交很可能是裸骨实验的结果,充满了TODO注释和占位符功能。随着我们通过提交历史前进,脚本开始逐渐成形,最终到达你在主分支中看到的代码的生产版本。

这里的问题并不是使用了脚本。包括我在内的绝大多数专业机器学习工程师,我们在原型设计和实验中都在笔记本(脚本)中工作。笔记本的动态性质和快速尝试新想法的能力使其成为这一工作阶段的理想平台。然而,在将原型作为开发路径接受之后,所有这些原型代码都会被丢弃,以便在最小可行产品(MVP)开发期间创建模块化代码。

从原型到脚本的演变是可以理解的。机器学习开发因其无数的变化、需要快速的结果反馈以及在最小可行产品阶段方法上的重大转变而臭名昭著。即使在早期阶段,代码的结构也可以设计得更容易解耦功能、抽象复杂性,并创建一个更容易测试(且易于调试)的代码库。

单一的生产代码库的形成是通过将原型部署到生产环境中实现的。这从来都不是一个明智的选择。

9.1.2 文本墙

如果说在我的数据科学家职业生涯早期我学到了什么,那就是我真的很讨厌调试。让我感到沮丧的并不是追踪代码中的错误这一行为,而是为了找出我在告诉计算机做什么时出了什么问题而必须经历的过程。

就像许多数据科学从业者在其职业生涯开始时一样,当我开始用软件解决问题时,我会写很多声明性代码。我按照逻辑思考问题的方法来编写解决方案(“我拉取数据,然后进行一些统计测试,然后做出决定,然后操作数据,然后将其放入向量,然后放入模型……”)。这体现为一系列直接依次流动的动作列表。这种编程模型在最终产品中的意义是一个没有分离或隔离动作,更不用说封装的大块代码墙。

在那种方式的代码中寻找任何错误就像是在干草堆里找针一样,是一项纯粹且未受污染的折磨。代码的架构并不利于我找出其中数百个步骤中哪一个导致了问题。

解决“文本墙”(WoT,发音为 What?!)是一项需要耐心且深度和所需努力很少能与之相比的练习。如果你是这种代码展示的原作者,那么这是一项令人烦恼的任务(你除了自己之外没有其他人可以恨,因为你创造了这个怪物),是一项令人沮丧的活动(参见前面的评论),而且是一项耗时的工作,可以轻易避免——只要你知道如何、在哪里以及如何隔离你机器学习代码中的元素。

如果代码是别人编写的,而您不幸成为了代码库的继承人,我向您表示哀悼,并热情地欢迎您加入“俱乐部”。也许在修复代码库之后,值得您花时间指导作者,提供丰富的阅读清单,并帮助他们永远不再编写如此令人愤怒的代码。

为了让我们讨论有一个参考框架,让我们看看这些长篇大论的脚本之一可能是什么样子。虽然本节中的示例相当简单,但目的是想象一个完整的端到端机器学习项目在这个格式下会是什么样子,而不必阅读数百行代码。(我想您不会喜欢在印刷书籍中翻阅数十页的代码。)

关于列表 9.1 中代码的简要说明

我将这个示例放入本书的最后一个目的是想表明我从未编写过这样的代码。我可以向您保证,在我职业生涯的早期,我写过更多令人毛骨悚然的代码。我编写过脚本、函数和整个类,其中包含的方法非常糟糕,难以阅读,以至于在写完它不到两周后,我无法理解我所创造的内容。

当这种情况发生时,会感到非常可怕,因为从所有意义上讲,代码的原始作者应该是地球上唯一能够弄清楚它是如何工作的那个人。当这一点失败时,无论是由于复杂性还是需要修改的大量代码以改进代码库,我常常是从头开始的。

我展示这些示例的目的是为了阐明我是如何艰难地学到这些知识的,为什么它们当时让我的生活非常困难(错过截止日期,当我意识到需要完全重写数百行代码时,让其他人感到愤怒),以及您如何以更简单的方式学习我辛苦赚来的教训。亲爱的读者,请沉浸在我过去的无能和无知所带来的荣耀中,请不要重复我的错误。我保证您最终会感谢我——而且您的未来自我也会感谢您的现在自我。

列表 9.1 展示了一个相对简单的脚本块,该脚本块旨在用于确定传递的连续数据序列最近的标准化分布类型。代码在顶部包含一些正常性检查,然后是标准分布的比较,最后生成一个图表。

注意:本章中的代码示例提供在本书的配套仓库中。然而,我不建议您运行它们。它们执行时间非常长。

列表 9.1 一段长篇大论的脚本

import warnings as warn
import pandas as pd
import numpy as np
import scipy.stats as stat
from scipy.stats import shapiro, normaltest, anderson
import matplotlib.pyplot as plt
from statsmodels.graphics.gofplots import qqplot

data = pd.read_csv('/sf-airbnb-clean.csv')
series = data['price']
shapiro, pval = shapiro(series)                                          ❶
print('Shapiro score: ' + str(shapiro) + ' with pvalue: ' + str(pval))   ❷
dagastino, pval = normaltest(series)                                     ❸
print("D'Agostino score: " + str(dagastino) + " with pvalue: " + str(pval))
anderson_stat, crit, sig = anderson(series)
print("Anderson statistic: " + str(anderson_stat))
anderson_rep = list(zip(list(crit), list(sig)))
for i in anderson_rep:
    print('Significance: ' + str(i[0]) + ' Crit level: ' + str(i[1]))
bins = int(np.ceil(series.index.values.max()))                           ❹
y, x = np.histogram(series, 200, density=True)
x = (x + np.roll(x, -1))[:-1] / 2\.                                       ❺
bl = np.inf                                                              ❻
bf = stat.norm
bp = (0., 1.)
with warn.catch_warnings():
    warn.filterwarnings('ignore')
    fam = stat._continuous_distns._distn_names
    for d in fam:
        h = getattr(stat, d)                                             ❼
        f = h.fit(series)
        pdf = h.pdf(x, loc=f[-2], scale=f[-1], *f[:-2])
        loss = np.sum(np.power(y - pdf, 2.))
        if bl > loss > 0:
            bl = loss
            bf = h
            bp = f
start = bf.ppf(0.001, *bp[:-2], loc=bp[-2], scale=bp[-1])
end = bf.ppf(0.999, *bp[:-2], loc=bp[-2], scale=bp[-1])
xd = np.linspace(start, end, bins)
yd = bf.pdf(xd, loc=bp[-2], scale=bp[-1], *bp[:-2])
hdist = pd.Series(yd, xd)
with warn.catch_warnings():
    warn.filterwarnings('ignore')
    with plt.style.context(style='seaborn'):
        fig = plt.figure(figsize=(16,12))
        ax = series.plot(kind='hist', bins=100, normed=True, alpha=0.5, label='Airbnb SF Price', legend=True)                               ❽
        ymax = ax.get_ylim()
        xmax = ax.get_xlim()
        hdist.plot(lw=3, label='best dist ' + bf.__class__.__name__, legend=True, ax=ax)
        ax.legend(loc='best')
        ax.set_xlim(xmax)
        ax.set_ylim(ymax)
qqplot(series, line='s')

❶ pval?这不是一个标准的命名约定。它应该是 p_value_shapiro 或类似的东西。

❷ 字符串连接难以阅读,可能会在执行时出现问题,并且需要输入更多内容。不要这样做。

❸ 修改变量 pval 使得原始的 shapiro 变量在未来使用中不可访问。这是一个不好的习惯,使得更复杂的代码库几乎不可能被跟踪。

❹ 使用这样的通用变量名,我们必须在代码中搜索以找出它是用来做什么的。

❺ 在这里修改 x 是有意义的,但同样,我们没有指示这是用来做什么的。

❻ bl?那是什么?!缩写并不能帮助读者理解正在发生的事情。

❼ 所有这些单字母变量名在没有逆向工程代码的情况下是无法理解的。这可能会使代码更简洁,但确实很难理解。缺乏注释,这种简写变得难以阅读。

❽ 所有这些硬编码的变量(尤其是这些箱子)意味着如果需要调整,源代码需要被编辑。所有这些都应该在函数中抽象化。

对于您刚才不得不看的内容,我表示最诚挚的歉意。这不仅代码令人困惑、密集和业余,而且它的编写方式使得其风格接近于有意混淆功能。

变量名很糟糕。单字母值?变量名中的极端简写符号?为什么? 这并不会使程序运行得更快。它只是使它更难以理解。可调值被硬编码,需要修改脚本以进行每个测试,这可能会极其容易出错和出现打字错误。没有设置停止点,这使得找出为什么某些事情没有按预期工作变得容易。

9.1.3 单一脚本考虑事项

除了难以阅读之外,9.1 的最大缺陷是它是单一的。虽然它是一个脚本,但 WoT 开发的原则可以应用于类中的函数和方法。这个例子来自一个笔记本,它越来越多地被用作执行 ML 代码的声明性载体,但这个概念在一般意义上是适用的。

在执行封装的范围内包含过多的逻辑会引发问题(因为这是一个在笔记本中运行的脚本,整个代码是一个封装的块)。我邀请您通过以下问题来思考这些问题:

  • 如果你必须在这个代码块中插入新功能,那会是什么样子?

  • 测试你的更改是否正确是否容易?

  • 如果代码抛出了异常怎么办?

  • 你会如何找出代码抛出异常时出了什么问题?

  • 如果数据结构发生了变化?你会如何更新代码以反映这些变化?

在我们回答这些问题之前,让我们看看这段代码实际上做了什么。由于变量名混乱、编码结构密集和引用紧密耦合,我们必须运行它来找出它在做什么。下一个列表显示了 9.1 列表的第一个方面。

列表 9.2 9.1 列表的打印语句的 Stdout 结果

Shapiro score: 0.33195996284484863 with pvalue: 0.0     ❶
D'Agostino score: 14345.798651770001 with pvalue: 0.0   ❷
Anderson statistic: 1022.1779688188954
Significance: 0.576 Crit level: 15.0                    ❸
Significance: 0.656 Crit level: 10.0
Significance: 0.787 Crit level: 5.0
Significance: 0.917 Crit level: 2.5
Significance: 1.091 Crit level: 1.0

❶ 这可能是有一些太多有效数字,以至于没有实际用途。

❷ 这些 pvalue 元素可能会让人感到困惑。如果没有某种解释说明它们代表什么,用户不得不查阅 API 文档来理解它们是什么。

❸ 没有关于这些意义和临界水平的解释,这些数据对于不熟悉安德森-达尔林测试的人来说毫无意义。

这段代码正在对一个单变量序列(在这里是一个DataFrame中的列)进行正常性测试。这些测试对于回归问题的目标变量来说绝对是有价值的。图 9.2 中显示的是由脚本剩余部分生成的第一个图表的结果(除了最后一行之外)。

09-02

图 9.2 列表 9.1 生成的第一个图表

注意:第八章介绍了将日志信息记录到 MLflow 和其他类似工具的强大功能,以及将重要信息打印到 stdout 是多么糟糕的想法。然而,这个例子是个例外。MLflow 作为一个综合性的工具,有助于基于模型的实验、开发和生产监控。在我们的例子中,我们正在进行一次性的验证检查,使用像 MLflow 这样的工具显然是不合适的。如果我们需要看到的信息只与短时间内(例如,决定特定的开发方法时)相关,那么保持这种信息的无限期持久性既令人困惑又毫无意义。

图 9.3 显示了从列表 9.1 生成的最后一个图表。

09-03

图 9.3 列表 9.1 中附加在末尾的图表生成

这个分位数-分位数图是一个同样有用的探索性辅助工具,通过绘制一个序列的分位数值与另一个序列的分位数值来决定正常性(或与不同分布的拟合度)。在这个例子中,数据集的价格序列的分位数被绘制在与标准正态分布的分位数上。

然而,如果没有在代码中的注释或对这张图的指示,这个脚本的最终用户可能会对正在发生的事情感到有些困惑。将评估放入代码中的这种做法很少是好的实践;它们很容易被忽略,用户可能会对为什么它们会出现在代码的这个位置感到困惑。

让我们暂时假设我们不受打印媒介的限制。假设我们不是在分析单个目标变量的简单统计分析示例,而是在查看一个作为单体脚本编写的完整项目,就像列表 9.1 中所示的那样。比如说,大约有 1,500 行。如果代码出错了会发生什么?我们能否在这样格式下清楚地看到并理解代码中发生的一切?我们会在哪里开始调试问题?

但是,封装动作不就是在移动复杂性吗?

好吧,是的,也不是。

如果我们将脚本内的代码的常见功能包装成函数或方法,那么重构对代码复杂性的影响并不大(毕竟,逻辑将按照 CPU 看到的顺序以相同的顺序处理)。然而,重构将显著减少代码的复杂性。它将使我们,作为人类开发者,能够看到更小的代码块,允许我们调试功能,测试隔离(封装)的代码分组是否按我们的意图工作,并显著提高我们未来修改代码的能力。

将脚本转换为功能性(FP)或面向对象(OO)代码可能看起来像是增加了复杂性:代码将包含更多行,将有更多元素需要跟踪,并且对于那些不熟悉 FP 或 OO 概念的人来说,最初阅读代码可能更困难。但一旦团队成员对这些范式的设计实践更加熟练,维护结构化和功能隔离的设计将比维护庞大的 WoT 代码库要容易得多。

9.2 调试文本墙

如果我们在理论上快速前进到新工作,在看到代码库的状态后,我们最终将处于维护它的位置。也许我们被分配到将新功能集成到现有脚本之一。在逆向工程代码并为我们自己的理解添加注释后,我们继续添加新功能。在这个阶段,测试我们代码的唯一方法就是运行整个脚本。

在将脚本更改为适应新功能的过程中,我们不可避免地会遇到一些错误。如果我们处理的是一个执行一系列操作的脚本或笔记本环境,我们如何调试代码中的错误?图 9.4 显示了纠正列表 9.1 中 WoT 问题的故障排除过程。

09-04

图 9.4 二进制故障排除的耗时且考验耐心的过程使单体代码库变得复杂(不一定是复杂的)。

这个过程虽然进行起来很令人沮丧,但如果没有像列表 9.1 中的那样糟糕的变量名和令人困惑的缩写符号,就已经足够复杂了。代码越难以阅读和跟踪,在测试代码时选择二进制边界点进行隔离以及确定哪些变量状态需要报告到 stdout 所需的认知负荷就越大。

这种评估和测试代码部分的过程意味着我们必须实际上更改源代码来进行测试。无论我们是添加print语句、调试注释还是注释掉代码,使用这种范式进行故障测试都需要做大量工作。很可能会出错,而且无法保证通过这种方式操作代码不会引入新的问题。

关于单体代码的注意事项

列表 9.1 可能看起来像是一个夸张的例子,展示了不良的开发实践。你可能会读一遍,嘲笑,并认为没有人会以这种方式编写整个 ML 解决方案。在我进入咨询之前,我可能也会这样想。

根据观察数百家公司 ML 团队开发解决方案的事实,编写单体块代码是非常常见的。通常,这源于一个孤立的 DS 部门,该部门与公司内其他工程团队的成员没有外部联系,并且团队中没有人与软件开发者合作过。这些团队实际上是在将他们的原型 PoC 解决方案(从算法实现的角度来看,确实解决了问题)推向生产。

实际上,我见过一些代码库(由于缺乏更好的术语,它们在生产中“运行”,但经常出现错误和故障),它们的可读性比列表 9.1 中展示的要差得多。这些大多数拥有类似这样 ML 代码的公司最终会做以下两件事之一:

  • 聘请一家昂贵的咨询公司来重构代码并使其准备好生产。在这里,你可能会根据他们解决方案的可维护性、咨询人员的专业技术水平以及聘请一个高质量团队来完成这项工作的总成本而有所不同。

  • 在资源限制(你真的希望你的团队不断修复相同的项目代码以保持其运行吗?)和不断修复的成本超过解决方案带来的好处之前,让代码艰难地继续运行。在此之后,完全放弃项目。

提出这些做法的目的是阐明像这样开发代码的问题,并帮助那些不了解为什么编写这样的代码是一个坏主意的人。

当然,组织代码(无论复杂与否)肯定有更好的方法来减少复杂度。列表 9.3 展示了 9.1 列表中脚本的一个面向对象版本的替代方案。

9.3 设计模块化 ML 代码

在经历了寻找、修复和验证我们对这个庞大脚本更改的痛苦过程之后,我们达到了临界点。我们向团队传达,代码的技术债务过高,在继续任何其他工作之前,我们需要偿还它。接受这一点后,团队同意根据功能拆分脚本,将复杂性抽象成更小的部分,这些部分可以单独理解和测试。

在我们查看代码之前,让我们分析脚本以查看其主要的功能分组。这种基于功能的分析可以帮助我们了解要创建哪些方法以实现功能隔离(有助于我们将来进行故障排除、测试和插入新功能)。图 9.5 展示了脚本中包含的核心功能以及我们如何提取、封装和创建单一用途的代码分组来定义我们的方法。

09-05

图 9.5 列表 9.1 的代码架构重构

这段代码的结构和功能分析帮助我们合理化常见功能的元素。从这个检查中,我们确定了元素,将其隔离并封装,以帮助代码的可读性(帮助我们,人类)和可维护性(故障排除和可扩展性)。注意私有(内部功能,最终用户不需要使用它来从模块中获取值)和公共(面向用户的、基于用户需求从代码中生成特定动作的方法)。隐藏此模块的内部功能将有助于减少用户所承受的认知负荷,同时尽可能减少代码复杂性。

现在我们已经有一个计划,将代码从几乎无法理解的脚本重构为更容易遵循和维护的形式,让我们来看看重构和模块化的最终产品,在下一部分列表中。

列表 9.3 列表 9.1 中脚本的对象化版本


import warnings as warn
import pandas as pd
import numpy as np
import scipy
import scipy.stats as stat
from scipy.stats import shapiro, normaltest, anderson
import matplotlib.pyplot as plt
from statsmodels.graphics.gofplots import qqplot

class DistributionAnalysis(object):                                          ❶

    def __init__(self, series, histogram_bins, **kwargs):                    ❷
        self.series = series
        self.histogram_bins = histogram_bins
        self.series_name = kwargs.get('series_name', 'data')
        self.plot_bins = kwargs.get('plot_bins', 200)
        self.best_plot_size = kwargs.get('best_plot_size', (20, 16))
        self.all_plot_size = kwargs.get('all_plot_size', (24, 30))
        self.MIN_BOUNDARY = 0.001                                            ❸
        self.MAX_BOUNDARY = 0.999
        self.ALPHA = kwargs.get('alpha', 0.05)

    def _get_series_bins(self):                                              ❹
        return int(np.ceil(self.series.index.values.max()))

    @staticmethod                                                            ❺
    def _get_distributions():
        scipy_ver = scipy.__version__                                        ❻
        if (int(scipy_ver[2]) >= 5) and (int(scipy_ver[4:]) > 3):
            names, gen_names = stat.get_distribution_names(stat.pairs, stat.rv_continuous)
        else:
            names = stat._continuous_distns._distn_names
        return names

    @staticmethod                                                            ❼
    def _extract_params(params):
        return {'arguments': params[:-2], 'location': params[-2], 'scale': 
          params[-1]}                                                        ❽
    @staticmethod
    def _generate_boundaries(distribution, parameters, x):                   ❾
        args = parameters['arguments']
        loc = parameters['location']
        scale = parameters['scale']
        return distribution.ppf(x, *args, loc=loc, scale=scale) if args else distribution.ppf(x, loc=loc, scale=scale)                               ❿

    @staticmethod
    def _build_pdf(x, distribution, parameters):                             ⓫
        if parameters['arguments']:
            pdf = distribution.pdf(x, loc=parameters['location'], 
              scale=parameters['scale'], *parameters['arguments'])
        else:
            pdf = distribution.pdf(x, loc=parameters['location'], 
              scale=parameters['scale'])
        return pdf

    def plot_normalcy(self):
        qqplot(self.series, line='s')                                        ⓬

    def check_normalcy(self):                                                ⓭
        def significance_test(value, threshold):                             ⓮
            return "Data set {} normally distributed from".format('is' if value 
              > threshold else 'is not')
        shapiro_stat, shapiro_p_value = shapiro(self.series)
        dagostino_stat, dagostino_p_value = normaltest(self.series)
        anderson_stat, anderson_crit_vals, anderson_significance_levels = 
          anderson(self.series)
        anderson_report = list(zip(list(anderson_crit_vals), 
          list(anderson_significance_levels)))
        shapiro_statement = """Shapiro-Wilk stat: {:.4f}
        Shapiro-Wilk test p-Value: {:.4f}
        {} Shapiro-Wilk Test""".format(
            shapiro_stat, shapiro_p_value, significance_test(shapiro_p_value, 
              self.ALPHA))
        dagostino_statement = """\nD'Agostino stat: {:.4f}
        D'Agostino test p-Value: {:.4f}
        {}  D'Agostino Test""".format(
            dagostino_stat, dagostino_p_value, significance_test(dagostino_p_value, self.ALPHA))
        anderson_statement = '\nAnderson statistic: {:.4f}'.format(anderson_stat)
        for i in anderson_report:
            anderson_statement = anderson_statement + """
            For signifance level {} of Anderson-Darling test: {} the evaluation. 
              Critical value: {}""".format(
                i[1], significance_test(i[0], anderson_stat), i[0])
        return "{}{}{}".format(shapiro_statement, dagostino_statement, 
          anderson_statement)

    def _calculate_fit_loss(self, x, y, dist):                                   ⓯
        with warn.catch_warnings():
            warn.filterwarnings('ignore')
            estimated_distribution = dist.fit(x)
            params = self._extract_params(estimated_distribution)
            pdf = self._build_pdf(x, dist, params)
        return np.sum(np.power(y - pdf, 2.0)), estimated_distribution

    def _generate_probability_distribution(self, distribution, parameters, bins):⓰
        starting_point = self._generate_boundaries(distribution, parameters, 
          self.MIN_BOUNDARY)
        ending_point = self._generate_boundaries(distribution, parameters, 
          self.MAX_BOUNDARY)
        x = np.linspace(starting_point, ending_point, bins)
        y = self._build_pdf(x, distribution, parameters)
        return pd.Series(y, x)

    def find_distribution_fit(self):                                           ⓱
        y_hist, x_hist_raw = np.histogram(self.series, self.histogram_bins, 
          density=True)
        x_hist = (x_hist_raw + np.roll(x_hist_raw, -1))[:-1] / 2.
        full_distribution_results = {}                                         ⓲
        best_loss = np.inf
        best_fit = stat.norm
        best_params = (0., 1.)
        for dist in self._get_distributions():
            histogram = getattr(stat, dist)
            results, parameters = self._calculate_fit_loss(x_hist, y_hist, 
              histogram)
            full_distribution_results[dist] = {'hist': histogram,
                                               'loss': results,
                                               'params': {
                                                   'arguments': parameters[:-2],
                                                   'location': parameters[-2],
                                                   'scale': parameters[-1]
                                               }}
            if best_loss > results > 0:
                best_loss = results
                best_fit = histogram
                best_params = parameters
        return {'best_distribution': best_fit,
                'best_loss': best_loss,
                'best_params': {
                    'arguments': best_params[:-2],
                    'location': best_params[-2],
                    'scale': best_params[-1]
                },
                'all_results': full_distribution_results
                }

    def plot_best_fit(self):                                                 ⓲
        fits = self.find_distribution_fit()
        best_fit_distribution = fits['best_distribution']
        best_fit_parameters = fits['best_params']
        distribution_series = self._generate_probability_distribution(best_fit_distribution,        best_fit_parameters,                                                         self._get_series_bins())
        with plt.style.context(style='seaborn'):
            fig = plt.figure(figsize=self.best_plot_size)
            ax = self.series.plot(kind='hist', bins=self.plot_bins, normed=True,
                                  alpha=0.5, label=self.series_name, 
                                  legend=True)
            distribution_series.plot(lw=3,
➥label=best_fit_distribution.__class__.__name__, legend=True, ax=ax)
            ax.legend(loc='best')
        return fig

    def plot_all_fits(self):                                                 ⓴

        fits = self.find_distribution_fit()
        series_bins = self._get_series_bins()

        with warn.catch_warnings():
            warn.filterwarnings('ignore')
            with plt.style.context(style='seaborn'):
                fig = plt.figure(figsize=self.all_plot_size)
                ax = self.series.plot(kind='hist', 
                                      bins=self.plot_bins, 
                                      normed=True, 
                                      alpha=0.5,
                                      label=self.series_name, 
                                      legend=True)
                y_max = ax.get_ylim()
                x_max = ax.get_xlim()
                for dist in fits['all_results']:
                    hist = fits['all_results'][dist]
                    distribution_data = self._generate_probability_distribution(
                      hist['hist'], hist['params'], series_bins)
                    distribution_data.plot(lw=2, label=dist, alpha=0.6, ax=ax)
                ax.legend(loc='best')
                ax.set_ylim(y_max)
                ax.set_xlim(x_max)
        return fig

❶ 将整个模块封装为一个类,因为其中所有的功能都集中在分布分析上。

❷ 通过传递**kwargs 参数,可以在类初始化方法中定义默认值,并通过键值组合来覆盖这些默认值(脚本中这些都是硬编码的)。

❸ 这些值被保留为静态的,但在需要的情况下可以包装到 kwargs 覆盖中。

❹ 私有实用方法,以保持调用者位置更干净、更容易阅读。

❺ 静态方法实际上是一个封装的函数;没有将初始化参数的引用传递到类中,也没有依赖于任何其他方法。大多数代码库都有很多这样的方法,并且被认为比定义全局函数是一种更好的实践,以防止全局上下文中可变状态的问题。

❻ 由于 SciPy API 发生了变化(Python 开源库在改进过程中经常发生这种情况),版本检查的 switch 语句。SciPy 的新版本已经保护了分布列表的访问,并创建了一个访问方法来检索它们。

❼ 从分布拟合中提取参数的方法,将它们放入字典结构中(这被称为 currying,是对 Haskell Curry 的命名引用,它将复杂的返回类型压缩成一个单一引用,使代码更加简洁)。

❽ 记住,这个确切的引用逻辑在列表 9.1 中复制了多次。提供单个引用以提取此信息可以减少代码中因打字错误引起的错误的机会,这些错误在调试时令人沮丧

❾ 私有方法通过分布的百分位数函数(累积分布函数的逆函数)生成生成标准直方图的起始和结束点

❿ 切换逻辑用于处理一些只需要两个参数(位置和尺度)的分布,而其他分布则需要额外的参数

⓫ 私有方法基于找到的拟合参数构建概率密度函数(pdf)。条件切换是由于分布家族中参数数量的不同

⓬ 一个公共方法,用于生成比较系列与标准正态分布的 Q-Q 图。对于未来的工作,这可以扩展或重构,以允许与 scipy.stats 中的任何分布进行比较绘图

⓭ 标准输出打印函数,用于向三个主要家族报告正常性测试。这里的插值与脚本中的插值略有不同,而基于传入的 alpha 显著性水平的正常性决策的可读性使得最终报告更容易理解,并且更不容易在假设系列时出错

⓮ 一个私有方法。由于此功能仅旨在使代码更容易阅读和更简洁,并且没有外部用途,因此将其作为此方法中的私有方法是首选的方法

⓯ 私有方法用于评估测试数据系列的直方图与 scipy.stats 中的标准直方图的拟合度,使用 SSE(均方误差)

⓰ 私有方法用于生成 pdf(概率密度函数)并将其转换为一系列数据点,以与原始传入的数据系列进行比较

⓱ 寻找与传入系列最接近(以及所有其他)标准分布的主要原始方法。从最终用户的角度来看,暴露模块的结果中的原始数据有时是有价值的(通常标记为开发者 API),这样用户就可以使用这些数据执行其他操作。

⓲ 再次使用柯里化(Currying),这样我们就不需要返回一个复杂的 n 值元组作为返回语句。在 Python 中(以及 Scala 中的 case 类)中的字典比位置编码的返回语句更可取,这使得调试和最终用户的使用体验更加无缝,即使这意味着模块开发者需要更多输入

⓲ 公共方法用于绘制最佳拟合曲线,该曲线与评估过程中传递给类的系列数据进行拟合

⓴ 一个额外的公共方法,用于将所有分布与传入的系列数据绘制出来,以帮助可视化标准分布之间的相似性

这段代码在功能上与脚本相同。它在相同的时间内产生相同的结果,并且会即时(JIT)编译成与列表 9.1 中的脚本相同的字节码(除了用于将所有标准参考分布与系列数据对比的附加方法)。这段代码的主要区别在于其实用性。

虽然这里的代码行数比脚本多得多,但我们现在在处理代码的核心逻辑方面有了隔离。我们可以逐个方法地遍历代码,以追踪可能出现的任何问题,从而极大地帮助进行故障排除。我们现在还有能力对代码进行单元测试。通过预测性和易于理解的数据的模拟,我们可以将这些方法与已知的功能进行验证,作为一种试金石。

以这种方式编写代码的好处在于,我们可以在进行一次初步的、稍微复杂一些的开发行动投资之后,可能节省自己无数令人沮丧的故障排除时间。这使我们能够去做我们应该做的事情:解决商业问题

注意 如果这个解决方案是针对真实代码库的,统计计算将被放入统计模块中的自己的类中,而可视化代码将被放入另一个模块中。列表 9.3 中显示的所有方法都被折叠到一个类中,以便在这本书中更容易阅读。

可读性机器代码与可读性人类代码

关于代码设计和结构的一个重要观点是,它主要是为了人类的利益,而不是执行代码的机器的利益。虽然将操作链在一起以形成密集和复杂的块在执行上可能看起来更有效率,但事实是,对于计算机来说,只要可执行逻辑相同,代码的编写方式(就功能、面向对象和脚本而言)纯粹是为了维护代码的人的利益。

一个高质量的代码库应该像书面文本一样易于阅读。它应该是清晰、简洁的,并且足够容易通过查看变量、函数、方法、类和模块名称以及语言中的标准操作来理解。精通该编程语言的人应该能够像阅读代码库的书面文本描述一样轻松地理解代码的功能。

简写符号、令人困惑的缩写词以及过于密集的控制流程并不能帮助任何人更好地理解代码的工作方式。毕竟,对于执行从您的高级语言代码编译而来的字节码的计算机来说,一个名为 h 的变量在引用内存中的相同对象时与 standard_distribution_histogram 意义相同。而对于评估代码的人类来说,情况并非如此。

存在一种针对编写代码的设计哲学,它适用于机器学习项目工作。被称为测试驱动开发(TDD),它可以帮助以高效的方式构建代码解决方案。在下一节中,我们将探讨 TDD 在机器学习开发中的应用原则。

9.4 使用测试驱动开发进行机器学习

作为对我们为新团队解决一个有问题的脚本所做重构工作的后续工作,我们可能需要讨论如何以不同的方式处理 MVP(最小可行产品)开发。多年来已经发展了许多软件开发哲学和模式,而我使用并看到在机器学习项目工作中特别有效的一个是 TDD。

作为一种原则,TDD(测试驱动开发)非常适合通用软件开发。其核心是通过首先编写测试,然后创建一个功能强大且优雅的代码库来支持测试通过,来关注开发工作。它从“我需要执行操作x,我期望得到结果y,因此我将创建一个断言y的测试,然后构建x的代码,使得y测试通过”的角度来创建最小功能。对于今天大多数软件工程实践,TDD 被认为是敏捷范式下软件开发的基础方法之一。

虽然纯 TDD 作为机器学习用例的开发策略极具挑战性(尤其是如果尝试测试非确定性或半非确定性算法的结果),但当应用于机器学习项目工作时,其基本原理可以显著提高代码的功能性、可读性和稳定性。您的断言可能与传统软件开发者编写的方式不同,但意图和基础保持相同。这全部关于在开发过程中有意和可预测的行为,这些行为可以在开发过程中得到验证,以确保其正确性。

当查看代码列表 9.1 和 9.3 之间的重构时,关于在哪里分割功能的决定更多地是由问题“我如何测试这段代码?”而不是“什么看起来不错?”来指导的。图 9.6 涵盖了我在创建代码列表 9.3 时经历的思维过程。

09-06

图 9.6 列表 9.3 的设计过程,重点关注可测试性和可隔离的代码结构

图 9.6 左侧列最右侧的每个框代表为测试目的而分离出的不同逻辑操作。以这种方式分解组件使我们能够减少需要搜索的地方。我们还通过隔离单个功能来降低代码复杂性,使得一系列复杂的动作变成了一条复杂的动作路径,每个阶段都能够独立于其他阶段进行检查和验证,以确保其功能正确。

注意:在编写这些示例时,我实际上首先编写了列表 9.3,然后后来从该代码中改编了列表 9.1。从一开始就编写可生成单元测试的代码的视角有助于使你的解决方案更容易阅读、修改,当然,测试(或者在这种情况下,将其转换为难以阅读的脚本)。当你开始编写抽象代码时,创建抽象的过程可能看起来很陌生。就像这个职业中的任何其他事情一样,随着时间的推移,你自然会倾向于更有效的方法。如果你觉得你在重构过程中从脚本到抽象的转变感到沮丧,请不要气馁。很快你就会发现自己已经离开了脚本的世界。

为了进一步解释图 9.6 中的思维过程是如何从结构设计转化为创建可测试代码的简洁且可隔离的功能分组,让我们以私有方法_generate_boundaries()为例。以下列表显示了对此私有方法进行简单单元测试的外观。

列表 9.4 对_generate_boundaries()方法的单元测试示例

def test_generate_boundaries():                                           ❶
    expected_low_norm = -2.3263478740408408                               ❷
    expected_high_norm = 2.3263478740408408
    boundary_arguments = {'location': 0, 'scale': 1, 'arguments': ()}
    test_object = DistributionAnalysis(np.arange(0,100), 10)              ❸
    normal_distribution_low = test_object._generate_boundaries(stat.norm, 
                                            boundary_arguments, 
                                            0.01)                         ❹
    normal_distribution_high = test_object._generate_boundaries(stat.norm, 
                                            boundary_arguments, 
                                            0.99)
    assert normal_distribution_low == expected_low_norm, \
      'Normal Dist low boundary: {} does not match expected: {}' \
      .format(normal_distribution_low, expected_low_norm)                 ❺
    assert normal_distribution_high == expected_high_norm, \
      'Normal Dist high boundary: {} does not match expected: {}' \
      .format(normal_distribution_high, expected_high_norm)

if __name__ == '__main__':                                                ❻
    test_generate_boundaries()
    print('tests passed')

❶ 用于测试_generate_boundaries()方法的单元测试定义函数

❷ 我们期望作为结果确保正确功能的静态测试值

❸ 我们类DistributionAnalysis()的对象实例化

❹ 使用下限值为 0.01 调用受保护的_generate_boundaries方法

❺ 断言_generate_boundaries方法的返回值等于我们期望的值

❻ 允许运行模块的所有测试(实际上,这里将调用多个单元测试函数)。如果所有测试都通过(断言不会抛出断言异常),这个脚本将退出,打印测试通过。

在这种方法中,我们测试了几个条件以确保我们的方法按预期工作。重要的是要注意,从这个例子中可以看出,如果这段代码没有从这个模块中其余的动作中隔离出来,那么测试将极其困难(或者不可能)。如果这段代码的部分导致了问题(如果出现了问题)或者另一个与这段代码紧密耦合的动作,我们就没有任何方法来确定罪魁祸首,除非修改代码。然而,通过分离出这个功能,我们可以在这一边界进行测试,并确定它是否表现正确,从而减少我们需要评估的项目数量,如果模块没有按预期工作。

注意:存在许多 Python 单元测试框架,每个框架都有自己的接口和行为(例如,pytest 严重依赖于固定装置注释)。基于 JVM 的语言通常依赖于由 xUnit 设定的标准,这些标准与 Python 中的标准截然不同。这里的重点不是使用特定的风格,而是编写可测试的代码并坚持特定的测试标准。

为了展示这种范式在实践中将为我们做什么,让我们看看当我们将第二个断言语句从相等切换到非相等时会发生什么。当我们运行这个测试套件时,我们得到以下输出,作为AssertionError,详细说明了我们的代码中(以及在哪里)出了什么问题。

列表 9.5 故意制造的单元测试失败

=================================== FAILURES ===================================
___________________________ test_generate_boundaries ___________________________

    def test_generate_boundaries():
        expected_low_norm = -2.3263478740408408
        expected_high_norm = 2.3263478740408408
        boundary_arguments = {'location': 0, 'scale': 1, 'arguments': ()}
        test_object = DistributionAnalysis(np.arange(0, 100), 10)
        normal_distribution_low = test_object._generate_boundaries(stat.norm,
                                                                   boundary_arguments,
                                                                   0.01)
        normal_distribution_high = test_object._generate_boundaries(stat.norm,
                                                                    boundary_arguments,
                                                                    0.99)
        assert normal_distribution_low == expected_low_norm, \
            'Normal Dist low boundary: {} does not match expected: {}' \
                .format(normal_distribution_low, expected_low_norm)
>       assert normal_distribution_high != expected_high_norm, \                    ❶
            'Normal Dist high boundary: {} does not match expected: {}' \
                .format(normal_distribution_high, expected_high_norm)
E       AssertionError: Normal Dist high boundary: 2.3263478740408408 does not match expected: 2.3263478740408408                                           ❷
E       assert 2.3263478740408408 != 2.3263478740408408                             ❸

ch09/UnitTestExample.py:20: AssertionError
=========================== 1 failed in 0.99 seconds ===========================
Process finished with exit code 0

❶ 报告边缘的撇号显示了单元测试中失败的行。

❷ 顶级异常(AssertionError)的返回以及我们在测试中放入的消息,以确保我们可以追踪到出了什么问题

❸ 断言尝试执行的实际评估

设计、编写和运行有效的单元测试对于生产稳定性至关重要,尤其是在考虑未来的代码重构或扩展此实用模块的功能时,因为额外的工作可能会改变此方法或其他向此模块提供数据的方法的功能。然而,我们在将代码合并到主分支(或主分支)之前,确实想知道所做的更改不会给此模块中的其他方法引入问题(同时,由于功能与其他模块代码隔离,这还让我们能够直接了解问题可能出在哪里)。通过拥有这种安全网,即知道事情按原意工作,我们可以自信地维护复杂(并且希望不是复杂的)代码库。

注意:有关 TDD 的更多信息,我强烈建议您查看 Kent Beck 的书籍,《通过示例进行测试驱动开发》(Addison-Wesley Professional,2002 年)。

摘要

  • 单一脚本不仅难以阅读,而且迫使采用低效且易出错的调试技术。

  • 大型、急切评估的脚本在修改行为和引入新功能方面极具挑战性。在这些脚本中排除故障变成了一种令人沮丧的练习。

  • 在 ML 代码库中使用抽象来定义任务逻辑分离,极大地帮助其他团队成员在维护和改进解决方案的过程中提高可读性。

  • 设计项目代码架构以支持对功能进行离散的可测试接口,这在调试、功能增强工作以及长期 ML 项目的持续维护更新方面非常有帮助。

10 编码标准和创建可维护的机器学习代码

本章涵盖

  • 识别机器学习代码中的问题及其纠正方法

  • 在机器学习项目中降低代码复杂性

  • 为了更清晰和易于理解的代码进行柯里化

  • 在机器学习代码库中应用适当的异常处理

  • 理解副作用及其如何导致错误

  • 简化嵌套逻辑以提高理解性

在上一章中,我们讨论了代码基础的大致轮廓。通过利用重构和基本的软件工程最佳实践来分解复杂结构,对于进一步讨论机器学习软件开发更详细方面是非常重要的。如果没有建立基本最佳实践的基础,接下来的代码架构和设计元素就无关紧要了。

在软件开发(包括机器学习)的早期阶段,识别实现中潜在问题的能力实际上是不存在的。这是可以理解的,因为知道什么有效什么无效的智慧直接来自于经验。从事软件开发工作的每个人最终都会学到,仅仅因为你可以做某事,并不意味着你应该在代码中这样做。这些教训通常是通过犯很多错误而获得的。

拥有太多上述错误的项目有被放弃的风险。毕竟,如果没有人能够调试代码,更不用说阅读它,那么充斥着技术债务的解决方案能够在生产环境中长时间运行的可能性很小。

本章的目标是确定我在机器学习代码库中看到的最常见问题,这些问题直接影响到解决方案的稳定性(以及那些需要维护它的人的一般心理健康)。

10.1 机器学习代码中的问题

有时候你看看代码库,就能知道某些地方不对劲。你在格式、集合处理、缺乏适当的递归或死代码数量中看到的问题可以让你对代码库的整体健康状况有一个感觉。如果问题严重,即使是团队中最初级成员也能识别出来。

更隐蔽的问题可能对初级数据科学家来说更难识别,但对团队中的资深成员来说可能很清楚。这些代码中的“问题”(由 Martin Fowler 著名提出)表明了可能出现的潜在严重问题,这些问题可能直接影响到生产稳定性,或者如果出现问题,使得代码几乎无法调试。

表 10.1 列出了我在机器学习代码库中看到的一些更常见的代码异味。虽然列出的这些异味本身并不是灾难性的,但它们通常是“丹麦一切都不好”的第一个迹象。发现这些代码异味通常意味着代码库中可能包含一些可能影响生产稳定性的隐蔽问题。学会识别这些问题,制定计划来解决这些问题的技术债务,并努力学习在机器学习项目中避免这些问题的技术,可以显著减少机器学习团队未来需要进行的重构和修复工作。

表 10.1 机器学习代码库中常见的“非毒性”代码异味

代码异味 示例 为什么它令人厌恶
通配符导入 from scipy import * 它会导入包中的所有顶级函数。这可能会在其他导入的库或项目代码库中引起命名空间冲突。
多重导入 import numpy as np 在代码中使用时,混乱且用途不一。
from numpy import add 使代码难以阅读。
参数过多 def my_method(df, name,source, fitting, metric,score, value, rename,outliers, train, valid) 难以阅读、难以维护且令人困惑。表明代码库中存在更深层次的抽象和封装问题。
复制样板代码 训练、测试和推理中的特征工程代码定义在三个不同的地方 也称为散弹手术——所有地方都需要相同地匹配更改,增加了出错和出现不一致的机会。
默认 km = Kmeans() 默认值通常不是理想的。
超参数 km.fit(train) 在快速原型设计之外看到未调优的模型是危险的。
变量重用 pred = lr.predict(test)pred.to_parquet('/')pred = rf.predict(test)pred.to_parquet('/') 违反了单一职责原则。使代码难以跟踪和调试。可能创建难以修复的状态性错误。添加新功能可能会创建意大利面代码。
文字常量使用 profit = 0.72 * revenue 文字常量是“魔法数字”,当它们散布在代码中时,可能会使更新它们变得噩梦般。这些应该始终定义为命名常量。
解释代码工作原理的行内注释 <一些令人厌恶的复杂链式代码> 如果你需要编写注释来解释代码的工作原理,那么你做错了。任何时候代码变得如此复杂,以至于你需要提醒它是如何工作的,你应该假设没有人能够理解你所写的内容。重构它以减少复杂性。
SQL 没有常用表表达式 (CTEs) <无封装的临时表定义的链式连接> CTEs 有助于提高 SQL 的可读性。拥有数百(或数千)行只有单一依赖链的 SQL 代码意味着任何修改(添加或删除列)都可能需要数小时,并且几乎无法调试。
SQL 墙 <函数无大写,无缩进或换行 SQL> 所有这三者都难以阅读。
恒定类型转换 age = int(age)height = float(height)seniority = int(retirement) - int(age) 类型转换不会改变。转换一次即可。这表明了编程的幼稚性(“它曾经因为不是整数而抛出异常,所以我会确保所有整数都转换为整数。”)这是没有意义的。

本章专注于五种最频繁的“致命”错误。这些问题是导致机器学习代码库根本性损坏的致命问题。看到这些问题可能会意味着每周至少一次的呼叫中心任务。

如果一个项目包含本章中描述的少量问题,这并不保证项目会失败。项目可能难以继续开发,或者维护起来非常不愉快,但这并不意味着它不会运行并实现其预期目的。

然而,如果代码库中充满了多种类型的问题的多个实例,那么在整个值班周都能睡得好的可能性相当渺茫。图 10.1 展示了这些问题严重性与最终项目结果潜在影响之间的关系。

10-01

图 10.1 机器学习代码中最常见的五个问题及其与项目结果的关系

我们将在本章的剩余部分探讨这五个主要的不良做法。我们将关注如何修复它们,并讨论为什么它们会对机器学习项目工作造成如此大的损害。

10.2 命名、结构和代码架构

在值班支持中,有人会经历一些更加疲惫和令人恐慌的情况,比如意识到刚刚崩溃并需要调查的工作是“那个”工作。这段代码如此混乱、复杂和修补,以至于当它崩溃时,通常需要原始作者回来修复它。更糟糕的是,知道那个人两个月前就离开了公司。现在你必须修复他们的代码。

深入研究后,你看到的只是作为变量名的晦涩缩写,函数内部的庞大代码墙,随意添加的数十个无关的方法的类,无用的内联注释,以及数千行被注释掉的代码。这基本上是两种世界最糟糕的结合:既有 意大利面代码(代码中的控制流组织得像意大利面碗中的面条一样)又有 泥球(实际上是一团意大利面的泥潭,有重复的代码、全局引用、死代码,并且没有明显的架构设计以供维护)。

很多的机器学习代码看起来都像这样,不幸的是,诊断和重构可能会非常令人沮丧。让我们看看一些关于命名、结构和架构的不良习惯,以及那些不良实践的更好替代方案。

10.2.1 命名约定和结构

命名变量可能是一项有点棘手的练习。一些思想流派遵循“少即是多”的哲学,认为最简洁(最短)的代码是最好的。其他人,包括我自己,在编写非机器学习代码时,倾向于坚持更详尽的命名约定。如第九章所述,计算机根本不在乎你如何命名事物(只要你不像列表 10.1 中所示,使用保留关键字作为结构变量名)。

让我们看看一些命名问题的密集表示。从懒惰的缩写(简写占位符变量名)到难以理解的密码式名称,以及一个保留的函数名,这个列表有很多问题。

列表 10.1 坏的命名约定

import functools
import operator
import math
gta = tuple([1,2,3,4])                                                ❶
abc = list(range(100))                                                ❷
REF_IND_24G_88A = list(zip(abc, list(range(0, 500, 5))))              ❸
tuple = [math.pow(x[0] - x[1],2) for x in REF_IND_24G_88A]            ❹
rtrn = math.sqrt(functools.reduce(operator.add, tuple) / len(tuple))  ❺
rtrn                                                                  ❻
> 229.20732972573106                                                  ❼
gta                                                                   ❽
> (1, 2, 3, 4)                                                        ❾
another_tuple = tuple([2,3,4]                                         ❿
> TypeErrorTraceback (most recent call last)
<ipython-input-9-e840d888412f> in <module>
----> 1 another_tuple = tuple([2,3,4])
TypeError: 'list' object is not callable                              ⓫

❶ 使用内置语言函数 tuple()定义一个元组,该函数接受一个可迭代对象(在这里,是一个列表)。变量定义并没有说明这个用途是什么。

❷ 生成一个数字列表。变量名“abc”只是懒惰的表现。

❸ 创建一个合并列表,包含其他每个列表。在这个语句中定义列表很难阅读,并增加了代码的复杂性。变量名像缩写汤,对阅读代码的人没有任何帮助。

❹ 计算两个数字列表的平方误差。变量名是危险的,因为它是一个保留的函数名,现在在这个上下文中将被覆盖。

❺ 计算均方根误差(RMSE),但定义的变量只是对保留语言特性(“返回”)的一个简写名称。

❻ 将值报告到标准输出(仅用于演示目的)

❼ 两个序列的均方根误差(RMSE)

❽ 现在调用之前定义的元组 gta,看看它在执行时生成了什么

❾ 在 gta 声明中定义元组时的预期结果

❿ 现在尝试生成另一个元组

⓫ 哎呀!为什么它不起作用?好吧,我们用列表定义覆盖了语言函数 tuple。由于 Python 中几乎所有东西都是可变的、弱类型的和基于对象的,如果我们不小心,甚至可以覆盖语言本身的本质。

这明显是一个夸张的例子,将多个不良实践压缩到一个单独的块中。你很少有机会在“野外”看到这样的东西,但这些问题中的每一个都出现在我见过的代码库中。

在这里提出的所有问题中,保留名称的使用可能是最隐蔽的。这不仅在大型的代码库中难以检测,而且可能会对未来的功能开发造成破坏。我无法强调避免使用不具体的变量名称的重要性,尤其是在像 Python 这样的语言中,因为你可以用看似无害的快捷命名覆盖核心功能。

虽然在编译型语言中这并不是一个问题(毕竟编译器不会允许将受保护的方法重新赋值为你定义的内容),但它可以通过无意中覆盖具有依赖性的方法而引入。虽然 JVM 语言会检测并禁止从超类中混合不正确覆盖的特性,但在开发过程中方法命名不当可能会导致浪费无数小时追踪构建失败的原因。

10.2.2 试图过于聪明

没有奖项,也永远不会有为使用最少的按键来开发软件而设立的奖项。试图通过看到代码可以多么紧凑和简洁来表现聪明,对解释型语言的代码运行效率没有任何帮助。它唯一达到的效果是激怒了不得不阅读代码的其他人。

注意:代码风格和可理解的结构对人类有益。计算机不在乎你的链式操作有多花哨,但其他人会。他们会因为这种聪明而讨厌你。

列表 10.2 展示了尝试创建最密集和高效代码的例子。虽然它在技术上正确,并且会导致计算均方根误差,但它几乎无法阅读。

编写这样的代码对性能没有任何帮助。作者可能会觉得自己通过编写他们认为高效的代码而变得更聪明,但事实并非如此。这样的代码让其他人难以理解发生了什么,修改起来会非常困难,并且限制了调试的能力。

列表 10.2 复杂的单行代码

rmse = math.sqrt(functools.reduce(operator.add, [math.pow(x[0] - x[1], 2) for x in list(zip(list(range(100)), list(range(0,500,5))))]) / 100)       ❶

❶ 边界故意混淆的功能。编写这样的代码对你自己或任何人都没有好处。它很密集,难以阅读,需要大量的脑力才能弄清楚它在做什么(即使它被正确命名)。

这种高效的单行编码风格需要非常关注每个元素,以便拼凑出所有发生的操作。幸运的是,这个例子中正在执行一个简单的逻辑集。我之前在 IDE 中见过单行代码长达数十行,这样的代码对任何人都没有好处。

以下是以更干净、更直接的方式编写此功能块的方法。虽然仍然不是最佳选择,但它达到了更高的可读性。

列表 10.3 正确命名和结构化的版本

first_series_small = list(range(100))
larger_series_by_five = list(range(0, 500, 5))                               ❶
merged_series_by_index = list(zip(first_series_small, larger_series_by_five))❷
merged_squared_errors = [math.pow(x[0] - x[1],2) for x in merged_series_by_index]
merged_rmse = math.sqrt(functools.reduce(operator.add, merged_squared_errors) / len(merged_squared_errors))                                             ❸

❶ 更清晰的变量名称,以纯文本形式解释变量指向的值

❷ 通过在变量名中描述正在发生的事情,代码可以更容易地被扫描。而不是一个令人困惑的名字,这个名字在这个操作的状态下没有任何意义,说明正在做什么会使阅读代码变得容易得多。

❸ 正确命名最终操作,使其基于定义的逻辑成为一个特定的计算值,这使得整个块更容易理解。

然而,编写此代码的正确方式在列表 10.4 中展示。不仅变量名清晰,我们没有重新实现标准包中已经存在的功能。为了使代码尽可能简单和易读,不要试图重新发明轮子。

列表 10.4 应该如何编写

import numpy as np
from sklearn.metrics import mean_squared_error                              ❶

def calculate_rmse_for_generated_sequences(**kwargs):
    first_sequence = np.arange(kwargs['seq_1_start'], kwargs['seq_1_stop'],  ❷
        kwargs['seq_1_step'], float)
    second_sequence = np.arange(kwargs['seq_2_start'], kwargs['seq_2_stop'], 
        kwargs['seq_2_step'], float)
    return mean_squared_error(first_sequence, second_sequence, squared=False)❸

calculate_rmse_for_generated_sequences(**{'seq_1_start': 0, 'seq_1_stop': 100,
                                          'seq_1_step': 1, 'seq_2_start': 0,
                                          'seq_2_stop': 500, 'seq_2_step': 5})
> 229.20732972573106

❶ RMSE 方程由 scikit-learn 贡献团队慷慨提供并维护。他们当然知道自己在做什么,你应该相信他们的模块是正确工作的。

❷ 在函数或方法中硬编码值是一个反模式(除了在 mean_squared_error 函数中,我们通过将标志设置为 False 来强制特定的功能),因此我们允许生成器通过传入的配置计算生成的序列的不同值。

❸ 将均方误差函数(MSE)的平方参数标志设置为 False,你就有了 RMSE

10.2.3 代码架构

代码架构是一个有争议的话题。虽然许多人吹嘘他们有一个理想的方法,但关于什么使得代码库中的逻辑布局好的唯一有效答案是团队可以维护的那个。我已经数不清有多少次在项目完成前,我工作过或看到过某个人的理想仓库结构,它如此过度工程化,以至于团队在项目完成前都难以将其代码合并进去。

为项目定义一个良好意图但过于复杂的仓库结构的不幸结果是,适当的抽象发生了崩溃。随着机器学习项目的发展过程,为了解决解决方案的需求而创建额外的功能,新的功能最终被挤入原本不会放置的地方。到开发周期结束时,代码库变得无法导航,如图 10.2 所示。

10-02

图 10.2 一个经过深思熟虑的仓库设计和代码架构可能会逐渐变得混乱和令人困惑。

在这个例子中,需要向代码中添加一系列三个主要的功能更新。每个贡献者都试图弄清楚他们的功能分支代码应该放在哪里,这是基于项目开始时构建的现有线框图。向向量添加更多功能的第一个改进并不令人困惑。仓库结构已经明确定义了专门用于此的模块。

第二个更改,即对模型家族的修改,涉及替换之前使用的模型。只要原始模型的核心代码,在更改之前就已经存在,完全从代码库中移除,并且死代码被移除而不是仅仅注释掉,这种重构形式是完全可以接受的。然而,作为模型更改的一部分,需要一种验证检查的新功能。这个功能应该放在哪里?

贡献者最终将这个新功能强行加入到功能验证统计类中。这现在在功能相关统计和新目标相关统计之间创建了一个紧密的功能耦合。

虽然这两个操作确实都在进行数据的统计验证,但所执行的算法、验证和操作之间没有任何关系。此外,为了将这个功能集成到现有的类中,签名需要更改以适应两种用例。这是一个明显的代码混乱案例:完全无关的代码和修改被用来“猴子补丁”功能,最终使代码更加脆弱、更加混乱,并且在未来更难修改。由于新功能必须被考虑,对这个类的测试也将变得更加困难。这纯粹是工作量大于其价值。贡献者应该用这个新功能创建一个新模块,其中包含一个(或多个)支持目标统计验证需求的类。

需要做的最后一个更改,即添加 Hyperopt 以自动调整模型,迫使团队成员执行一个高度复杂的重构。他们更新了模型训练模块以支持这一点,这是合理的。然而,搜索空间配置应该被外部化到不同的模块。将无关的功能加载到指标、参数和监控模块中只会创建一个混乱的代码库。这将使同行评审(PR)过程更加复杂,使未来的功能工作更具挑战性,并迫使编写更复杂的单元测试以确保适当的代码覆盖率。

在这里要讲得非常清楚,我并不是在提倡对特定的代码架构进行僵化的遵循,也不是在 MVP 阶段项目结束时坚持任何设计。代码应该始终自然地增长;重构、改进、添加功能、移除功能以及维护代码库的过程应该被所有从事软件开发工作的人所接受。

然而,有一些方法可以使代码库可维护,也有一些方法会使它变得破碎、复杂和混乱。如果你正在更改现有功能或添加一个仅限于当前类或模块封装的新功能,你应该在该模块内编写你的功能。然而,如果更改非常大(可以抽象成其自己的模块的全新功能)或涉及与代码库中分散的许多其他类和模块进行通信,请为自己和你的团队做点好事,只需创建一个新的模块。

10.3 元组解包及可维护的替代方案

假设我们正在处理一个相对复杂的处于生产状态的 ML 代码库。我们已经创建了功能分支,并准备实施改进。我们正在处理的工作单,向核心模块添加统计测试,需要向评分方法添加另一个返回值。

看看现有的方法,我们看到返回的是一个值的元组,目前有三个。在添加额外的逻辑并更新返回元组以包含额外的变量后,我们转向需要新返回值的代码部分。在更新我们的功能分支目标消费此方法的返回结构后,我们在我们的功能分支上运行测试。

所有的东西都崩溃了。即使代码库中的其他地方不需要这个新变量,即使它们没有使用它,仍然需要捕获增加的返回值。幸运的是,有一个通过位置引用解决这种 curry 返回值问题的解决方案:元组解包。

10.3.1 元组解包示例

让我们看看列表 10.5 中的简单数据生成器。在这段代码中,我们使用逻辑映射函数生成一系列数据,可视化它,并返回图表对象和序列(这样我们就可以根据配置的值对其进行统计分析)。

列表 10.5 带元组返回的逻辑映射数据生成器

import matplotlib.pyplot as plt
import numpy as np

def logistic_map(x, recurrence):                                ❶
    return x * recurrence * (1 - x)

def log_map(n, x, r, collection=None):                          ❷
    if collection is None:
        collection = []
    calculated_value = logistic_map(x, r)
    collection.append(calculated_value)
    if n > 0:
        log_map(n-1, calculated_value, r, collection)
    return np.array(collection[:n])

def generate_log_map_and_plot(iterations, recurrence, start):   ❸
    map_series = log_map(iterations, start, recurrence)
    with plt.style.context(style='seaborn'):
        fig = plt.figure(figsize=(16,8))
        ax = fig.add_subplot(111)
        ax.plot(range(iterations), map_series)
        ax.set_xlabel('iterations')
        ax.set_ylabel('logistic map values')
        ax.set_title('Logistic Map with recurrence of: {}'.format(recurrence))
    return (map_series, fig)                                    ❹

log_map_values_chaos, log_map_plot_chaos = generate_log_map_and_plot(1000, 3.869954, 0.5)                                             ❺

❶ 用于递归先前值的逻辑映射函数

❷ 通过对每个先前值应用逻辑映射方程生成序列的尾递归函数

❸ 生成序列的函数以及一个展示特定递归值对序列影响的图表

❹ 元组返回类型。这并不是一个特别严重的复杂性展示,即在函数中传递结果,但它仍然需要了解函数签名才能使用。它还要求对每个函数将被调用的位置进行位置引用(在函数的返回类型和它在代码中使用的每个地方之间创建了一个紧密耦合的结构)。

❺ 使用元组解包返回值调用函数,直接将它们分配给变量

注意:关于这些示例的结果,请参阅本书配套仓库中该章节的 Jupyter 笔记本,仓库地址为github.com/BenWilson2/ML-Engineering

使用generate_log_map_and_plot()函数指定的两个返回值,从使用和维护的角度来看,保持正确的引用并不是一个过于复杂的负担。然而,当返回值的大小和复杂性增加时,使用该函数变得越来越困难。

作为函数复杂返回类型的示例,请参阅列表 10.6。这个简单的单变量序列统计分析生成复杂的输出。虽然利用分组元组使其更容易使用的意图是存在的,但它仍然过于复杂。

列表 10.6 具有噩梦般元组解包的统计分析函数

def analyze_series(series):                                                ❶
    minimum = np.min(series)
    mean = np.average(series)
    maximum = np.max(series)
    q1 = np.quantile(series, 0.25)
    median = np.quantile(series, 0.5)
    q3 = np.quantile(series, 0.75)
    p5, p95 = np.percentile(series, [5, 95])
    std_dev = np.std(series)
    variance = np.var(series)
    return ((minimum, mean, maximum), (std_dev, variance), (p5, q1, median,❷
      q3, p95))                                                            ❷

get_all_of_it = analyze_series(log_map_values_chaos)                       ❸
mean_of_chaos_series = get_all_of_it[0][1]                                 ❹
mean_of_chaos_series
> 0.5935408729262835

((minimum, mean, maximum), (std_dev, variance), (p5, q1, median, q3, p95)) = analyze_series(log_map_values_chaos)                                  ❺

❶ 用于收集数据序列统计信息的函数

❷ 该函数调用方将强制使用位置(或复杂定义的返回)的复杂分组嵌套元组返回类型

❸ 使用一个对象来在单个变量中持有整个返回结构

❹ 使用位置记法和嵌套来从返回结构中返回特定元素。这非常脆弱且难以使用。大多数时候,当使用这种方法时,如果这个函数发生变化,这些值在重构时会被忽略,导致令人困惑的异常或错误的计算。

❺ 扩展元组的替代访问模式。这只是丑陋的代码,难以维护。当底层函数发生变化时,这个紧密耦合的签名将抛出 ValueError 异常,解包计数与预期不符。

以这种方式编写代码存在其他问题,而不仅仅是需要查看源代码才能使用它。当这个函数需要更改时会发生什么?如果我们不仅需要评估序列的 95 百分位数,还需要计算 99 百分位数,我们把这个放在结构中的哪个位置呢?

如果我们更新返回签名,那么我们就必须更新使用此函数的每个地方。这根本不是一种从函数中提取数据以供其他地方使用的可用形式。它还以使整个代码库更加脆弱、更难以维护和调试测试的方式增加了代码的复杂程度。

10.3.2 元组解包的可靠替代方案

列表 10.7 展示了该问题的解决方案,使用了一种类似于用于另一种主导机器学习语言(Scala,通过使用案例类)的结构和方法。在这个列表中,我们使用命名元组来处理返回类型结构,使我们能够使用命名引用来访问结构内部的数据。

这种方法使代码具有前瞻性,因为任何对返回结构的修改都不需要在使用位置定义消费模式。它也更容易实现。使用这些结构就像使用字典(使用类似的底层结构),但由于位置命名实体记号,它们比字典有更多的语法糖感。

列表 10.7 使用命名元组重构序列和绘图生成器

from collections import namedtuple                                         ❶

def generate_log_map_and_plot_named(iterations, recurrence, start):
    map_series = log_map(iterations, start, recurrence)
    MapData = namedtuple('MapData', 'series plot')                         ❷
    with plt.style.context(style='seaborn'):
        fig = plt.figure(figsize=(16,8))
        ax = fig.add_subplot(111)
        ax.plot(range(iterations), map_series)
        ax.set_xlabel('iterations')
        ax.set_ylabel('logistic map values')
        ax.set_title('Logistic Map with recurrence of: {}'.format(recurrence))
    return MapData(map_series, fig)                                        ❸

other_chaos_series = generate_log_map_and_plot_named(1000, 3.7223976, 0.5) ❹
other_chaos_series.series                                                  ❺

> array([0.9305994 , 0.24040791, 0.67975427, 0.81032278, 0.57213166,
       0.91123186, 0.30109864, 0.78333483, 0.63177043, 0.86596575, ...])

❶ 导入标准集合库以访问命名元组

❷ 定义我们将用于在元组返回类型内进行命名访问的命名元组

❸ 创建一个新的命名元组 MapData 实例,并将要从函数中返回的对象放置在定义的命名元组结构中

❹ 返回签名现在是一个单一元素(当使用函数时,代码看起来更干净),但它不再需要位置记号来访问元素。

❺ 通过我们作为命名元组集合定义的一部分定义的命名元素访问返回变量中的单个值。

现在我们有一个简单的示例,展示了如何从列表 10.5 中的序列生成和绘图重构,让我们看看如何使用具有定义结构的命名元组方法帮助我们处理列表 10.6 中的更复杂的返回类型,如以下列表所示。

列表 10.8 使用命名元组重构统计属性函数

def analyze_series_legible(series):
    BasicStats = namedtuple('BasicStats', 'minimum mean maximum')               ❶
    Variation = namedtuple('Variation', 'std_dev variance')
    Quantiles = namedtuple('Quantiles', 'p5 q1 median q3 p95')
    Analysis = namedtuple('Analysis', ['basic_stats', 'variation', 'quantiles'])❷
    minimum = np.min(series)
    mean = np.average(series)
    maximum = np.max(series)
    q1 = np.quantile(series, 0.25)
    median = np.quantile(series, 0.5)
    q3 = np.quantile(series, 0.75)
    p5, p95 = np.percentile(series, [5, 95])
    std_dev = np.std(series)
    variance = np.var(series)
    return Analysis(BasicStats(minimum, mean, maximum), 
                    Variation(std_dev, variance), 
                    Quantiles(p5, q1, median, q3, p95))

bi_cycle = generate_log_map_and_plot_named(100, 3.564407, 0.5)                  ❸
legible_return_bi_cycle = analyze_series_legible(bi_cycle.series)               ❹
legible_return_bi_cycle.variation.std_dev                                       ❺
> 0.21570993929353727

❶ 定义用于分析每个组件的命名元组

❷ 命名元组可以嵌套以将类似的数据返回类型聚集在一起。

❸ 生成序列数据

❹ 调用函数并传入生成函数返回的命名引用序列数据

❺ 提取嵌套命名元组变量的数据

通过使用命名结构,在重构代码时,你为自己和他人减少了工作量,因为你不必更改函数或方法的全部调用实例。此外,代码的阅读性也大大提高。提高代码的可读性可能不会减少代码执行的操作的复杂性,但它可以保证使你的代码远没有那么复杂

许多机器学习 API 利用元组解包。通常,元组限制为不超过三个元素以减少最终用户的困惑。跟踪三个元素似乎并不复杂(大部分情况下)。但是,使用位置引用从函数或方法返回元素变得麻烦,因为代码必须在这些代码被调用的每个地方反映这些位置返回。

元组解包最终会增加阅读和维护代码的人的困惑程度,并提高代码库的整体复杂性。通过转向封装的返回类型(Python 中的命名元组,Scala 中的 case 类),我们可以最小化在功能分支中需要更改的代码行数,并减少对代码的解释困惑。

10.4 对问题视而不见:吞噬异常和其他不良做法

让我们继续我们的场景,即进入一个我们不熟悉的代码库,通过关注运行我们的第一个功能分支的全面测试来集中精力。作为这个分支的一部分,我们必须使用一个为与对象存储数据湖接口而编写的数据加载模块。由于该模块的文档不佳和难以阅读的代码,我们错误地传递了错误的认证令牌。在执行我们的分支时,Stderr 和 stdout 仅打印出一行:Oops.``Couldn't``read``data.

这不仅非常令人烦恼(可爱的错误消息没有用),而且它没有提供任何关于为什么数据无法读取的指导。数据是否存在?我们传递了无效的路径吗?我们是否有权访问这些数据?新功能分支中数据加载类的方法使用是否不规范?

没有加载和解析系统上的日志,我们根本无法知道。我们必须追踪、修改我们的代码,插入调试语句,并花费数小时深入挖掘我们的代码和实用模块代码,以找出发生了什么。我们不知不觉成为了异常吞噬的受害者:这是一种错误的意图,通过不恰当地使用 try/catch 块来“仅仅让它工作”。

10.4.1 使用猎枪精度的 try/catch

在开发机器学习代码时,养成的一个更危险的不良习惯是在异常处理方面。这个软件开发领域通常与大多数数据科学家在尝试解决问题时编写代码的方式不同。

通常,当编写代码时发生错误,问题会得到解决,然后继续解决其他问题。然而,在生产代码领域,代码库中可能发生许多错误。可能传递的数据不规范,数据规模变化到一定程度,计算不再有效,或者可能发生数百万种可能出错的情况之一。

我看到许多人将看似无害的故障周围的 try/catch 快速贴上。然而,如果不完全理解如何实现特定异常的处理,可能会导致使用盲目捕获,这可能会使代码库难以调试。

注意:有关异常处理不当可能导致问题的逐步示例,请参阅本书的配套仓库,并跟随 Jupyter 笔记本 CH09_1.ipynb。

列表 10.9 说明了这个概念。在这个简单的例子中,我们正在取一个整数并除以一个整数列表。我们希望从这个函数中得到一个新的集合,表示基数除以传入集合的每个成员的商。函数下面的结果显示了执行代码的必然结果:一个ZeroDivisionError

列表 10.9 一个简单的集合除法函数,它将抛出异常

import random
numbers = list(range(0, 100))                           ❶
random.shuffle(numbers)                                 ❷
def divide_list(base, collection):                      ❸
    output = []
    for i in collection:                                ❹
        output.append(base / i)                         ❺
    return output
blown_up = divide_list(100, numbers)                    ❻
> ZeroDivisionErrorTraceback (most recent call last)    ❼
<ipython-input-140-3ed60281fb4b> in <module>
----> 1 blown_up = divide_list(100, numbers)
<ipython-input-75-a0ad45358f8f> in divide_list(base, collection)
      2     output = []
      3     for i in collection:
----> 4         output.append(base / i)                 ❽
      5     return output
ZeroDivisionError: division by zero                     ❾

❶ 生成一个介于 0 和 99 之间的数字列表,包括 0 和 99

❷ 在原地打乱以提供生成的整数列表的随机排序

❸ 函数定义——base 的签名是用于除以集合变量内容的数字

❹ 遍历集合中的每个元素

❺ 将基数和列表集合实体的迭代器值在位置 i 的商追加到列表中

❻ 调用函数

❼ 异常抛出的 stdout 结果。堆栈跟踪显示在以下标题下方。

❽ 识别出问题的代码行

❾ 与异常相关的 ZeroDivisionError 异常类名称和消息

我看到许多数据科学家(DSs)用来解决这个问题的“盲目捕获”(也称为“吞噬”所有异常)的解决方案可能看起来像以下列表。为了清楚起见,这绝对不应该这样做。

列表 10.10 不安全的异常处理示例

def divide_list_unsafe(base, collection):
    output = []
    for i in collection:
        try:                            ❶
            output.append(base / i)
        except:                         ❷
            pass                        ❸
    return output

❶ try 块尝试执行封装的操作,但如果抛出异常(引发),它将移动到 except 块。

❷ 包含处理特定异常的代码的 except 块。这种实现(盲目捕获)是危险的、不充分的,并会导致稳定性和故障排除问题(这实际上是在代码中直接写入错误)。

❸ 危险的“pass”(什么都不做)命令对于无状态事务系统(例如 Web 应用程序)可能是有用的,但不应该在 ML 代码中使用。

当我们运行此代码针对我们的列表时,我们将得到一个包含 99 个数字的列表的返回值,减去抛出异常并被忽略的 0 值,因为使用了pass关键字。虽然这看起来像解决了问题并允许执行继续,但这实际上是一个真正糟糕的解决方案。以下列表说明了原因。

列表 10.11 盲目异常处理的例子

broken = divide_list_unsafe('oops', numbers)   ❶
len(broken)                                    ❷
> 0                                            ❸

❶ 传入一个要除以的字符串。这显然不会工作(它将抛出 TypeError)。

❷ 由于我们捕获了所有异常并且只是继续执行(使用 pass 关键字),没有异常被抛出以警告我们某些事情没有正常工作。

❸ 列表为空。这可能会破坏下游的操作。

当我们将非数字传递给此函数时,我们不会得到任何错误。没有抛出任何异常来警告我们返回值是一个空列表。我们可以尝试捕获确切的异常,这样就不会发生类似的情况,使我们能够有效地忽略问题。

捕获所有异常的问题

虽然列表 10.11 中的例子很明显,相当简单,在功能上有些无意义,但这类模式在现实世界中的实例却以真正丑陋的方式出现。

假设你在机器学习项目的绝大多数代码周围编写了一系列盲目的 try/catch 语句。从读取源数据,执行特征工程任务,模型调优,验证和日志记录,每个主要步骤都被包裹在一个 tryexcept,然后 pass 语句中。如果数据在编码步骤中出现问题会发生什么?关于读取源数据的过期认证令牌呢?如果数据被移动,而你正在读取的位置现在是空的怎么办?如果模型未能收敛呢?

我试图说明的是,对于调查为什么工作没有产生任何输出的人来说,这些场景看起来都是相同的。唯一的指示是工作没有完成它应该做的事情。由于所有异常都被吞噬了,根本没有任何迹象表明从哪里开始寻找问题的根源。

正是因为这个原因,盲目捕获异常是如此危险。在任何长期运行的项目代码库中,未来某个时刻都会出现问题。工作可能会因为各种原因失败。如果你阻碍了自己找出问题的能力,你将不得不手动逐行代码或执行某种二分搜索来追踪发生了什么。以这种方式找出问题会浪费精力和时间。

即使编写适当的异常处理可能看起来更费事,但这确实是正确的事情。当代码最终崩溃——相信我,它会的,因为给足够的时间,所有代码库都会崩溃——当你用额外的 30 分钟编写适当的处理代码,让你在几分钟而不是几天内找到问题的根源时,你会感到非常感激。

10.4.2 精确的异常处理

下一个列表展示了正确按类型捕获异常的方法。

列表 10.12 安全地捕获和处理单个异常

def divide_list_safer(base, collection):
    output = []
    for i in collection:
        try:
            output.append(base / i)
        except ZeroDivisionError as e:                                     ❶
            print("Couldn't divide {} by {} due to {}".format(base, i, e)) ❷
    return output

safer = divide_list_safer(100, numbers) 
> Couldn’t divide 100 by 0 due to division by zero                         ❸
len(safer) 
> 99                                                                       ❹

❶ 捕获了我们想要的精确异常(ZeroDivisionError)并获取了对异常对象的引用(e)

❷ 对异常的处理不够理想(我们仍然通过在发生时将其打印到标准输出而有效地忽略它,但至少我们对它做了些处理)。适当的做法是将错误记录到日志服务或 MLflow。

❸ 调用该函数不会抛出可中断的异常,但它确实让我们知道发生了什么。

❹ 它丢失了一个元素(零整数),但处理了输入列表中剩余的 99 个元素。

这虽然引入了一个新问题。我们有了生成的警告信息,但它被打印到了标准输出。在需要历史记录来排查问题的生产系统中,这并不能帮助我们。

我们需要有一个集中的地方来查看这些问题发生时的什么哪里何时的细节。我们还需要确保,至少我们有一个可解析的标准日志格式,这样可以减少搜索日志文件以跟踪问题所花费的时间。

10.4.3 正确处理错误

以下列表显示了此异常处理场景的最终实现,包括自定义异常、日志记录和对除以零错误的控制处理。

列表 10.13 带有适当异常处理和日志记录的最终实现

from importlib import reload
from datetime import datetime
import logging
import inspect

reload(logging)
log_file_name = 'ch9_01logs_{}.log'.format(datetime.now().date().strftime('%Y-%m-%d'))
logging.basicConfig(filename=log_file_name, level=logging.INFO)          ❶

class CalculationError(ValueError):                                      ❷
    def __init__(self, message, pre, post, *args):
        self.message = message
        self.pre = pre
        self.post = post
        super(CalculationError, self).__init__(message, pre, post, *args)

def divide_values_better(base, collection):
    function_nm = inspect.currentframe().f_code.co_name                  ❸
    output = []
    for i in collection:
        try:
            output.append(base / i)
        except ZeroDivisionError as e:                                   ❹
            logging.error(
                "{} -{}- Couldn't divide {} by {} due to {} in {}".format(
                    datetime.now(), type(e), base, i, e, function_nm)
            )
            output.append(0.0)
        except TypeError as e:                                           ❺
            logging.error(
                "{} -{}- Couldn't process the base value '{}' ({}) in {}".format(
                datetime.now(), type(e), base, e, function_nm)
            )                                                            ❻
            raise e                                                      ❼
    input_len = len(collection)                                          ❽
    output_len = len(output)
    if input_len != output_len:
        msg = "The return size of the collection does not match passed in collection size."
        e = CalculationError(msg, input_len, output_len)                 ❾
        logging.error("{} {} Input: {} Output: {} in {}".format(
            datetime.now(), e.message, e.pre, e.post, function_nm
        ))                                                               ❿
        raise e                                                          ⓫
    return output

placeholder = divide_values_better(100, numbers)
len(placeholder)
> 100                                                                    ⓬

❶ 这三条线仅适用于 Jupyter Notebook 功能。在一个.egg 文件中,你只需实例化一个新的日志实例(然而,Jupyter 在初始化会话时会为你启动一个)。

❷ 创建一个自定义异常类,具有从标准 ValueError 异常继承属性的能力,并提供*args 以允许其他开发人员扩展或自定义此异常类

❸ 获取当前函数名称以进行日志记录(防止在多个地方手动输入名称)

❹ 捕获除以零异常,记录它,并提供一个占位符值

❺ 捕获基于传入数据的数学无效操作的 TypeError

❻ 在做任何事情之前记录 TypeError 异常(以便我们有可见性知道它发生了)

❼ 在记录异常后,我们希望手动抛出它,以便函数会提醒与它交互的开发者,他们确实应该向此函数传递数值类型的基变量。

❽ 获取输入列表“collection”的长度和循环后的输出列表长度

❾ 如果列表大小不匹配,创建我们的自定义异常类的对象

❿ 记录我们自定义异常的详细信息

⓫ 抛出自定义异常

⓬ 由于我们在输出列表中将失败的除以零错误替换为 0.0,我们的列表长度匹配(100)。

在这一点上,当我们用有效的集合(包含零或不包含零)运行函数时,我们将得到每个被替换实例的日志报告。当我们用无效的值调用函数时,我们将记录异常并抛出(期望的行为)。最后,当列表不匹配是因为对函数的将来修改(例如捕获新的异常而不替换值或修改逻辑行为)时,进行这些更改的人将以明确的方式被提醒他们的更改引入了错误。

以下列表显示了在原始配置下运行此代码的日志结果,测试作为基参数提供的无效字符串参数,并模拟长度不匹配的情况。

列表 10.14 捕获和处理异常的日志结果

def read_log(log_name):                                                  ❶
    try:
        with open(log_name) as log:
            print(log.read())
    except FileNotFoundError as e:                                       ❷
        print("The log file is empty.")

read_log(log_file_name)
>
ERROR:root:2020-12-28 21:01:21.067276 -<class 'ZeroDivisionError'>- Couldn't divide 100 by 0 due to division by zero in divide_values_better  ❸
ERROR:root:2020-12-28 21:01:21.069412 The return size of the collection does not match passed in collection size. Input: 100 Output: 99 in divide_values_better                                      ❹
ERROR:root:2020-12-28 21:01:24.672938 -<class 'TypeError'>- Couldn't process the base value 'oops' (unsupported operand type(s) for /: 'str' and 'int') in divide_values_better                                 ❺

❶ 非常简单的函数,用于读取日志文件

❷ 我们甚至处理了 open()函数预期的异常,这样如果日志文件尚未生成(因为函数使用中没有出现任何问题),我们不会抛出一个对函数最终用户来说不清楚的讨厌异常。相反,会打印出一个简单的解释,让我们知道日志尚未创建。

❸ 我们期望从传递包含数字 0 的整数集合列表中获得的异常

❹ 在处理零除错误时,从捕获块中移除“替换为 0.0”功能的结果

❺ 将无效值作为函数的基数参数传递时的记录结果(这也会在运行时抛出异常,但已经在日志中记录了异常)

记录即使是看似不重要的错误,在开发过程中也可能是一个非常有价值的工具,用于解决生产中的问题。无论您是想修复麻烦问题的根本原因还是检查代码库的健康状况,如果没有日志和适当的数据写入其中,您可能完全不知道解决方案代码中可能存在的潜在问题。当有疑问时,记录下来。

10.5 使用全局可变对象

在继续探索我们新团队现有的代码库时,我们正在处理要添加的另一个新功能。这个功能添加了完全新的功能。在开发过程中,我们意识到我们分支所需的大部分逻辑已经存在,我们只需要重用几个方法和一个函数。我们没有看到的是,该函数使用了一个全局作用域变量的声明。当我们通过单元测试单独运行我们分支的测试时,一切工作正如预期。然而,整个代码库的集成测试产生了不合理的成果。

经过数小时在代码中搜索,跟踪调试跟踪后,我们发现我们使用的函数的状态实际上从第一次使用后发生了变化,该函数使用的全局变量实际上也发生了变化,这使得我们对它的第二次使用完全错误。我们被突变烧伤了。

10.5.1 可变性如何烧伤你

认识到可变性有多危险可能有点棘手。过度使用可变值、状态转换和数据覆盖可以有多种形式,但最终结果通常是相同的:一系列极其复杂的错误。这些错误可以以不同的方式表现出来:海森堡虫似乎在你试图调查它们时消失了,而曼德尔虫如此复杂和非确定性,以至于它们似乎像分形一样复杂。重构充满变动的代码库是非平凡的,很多时候从头开始重新开始更容易来修复设计缺陷。

可变性和副作用的问题通常在项目的初始 MVP(最小可行产品)之后很久才会显现出来。后来,在开发过程中或生产发布之后,依赖于可变性和副作用的代码库开始出现裂缝。图 10.3 展示了不同语言及其执行环境之间的细微差别,以及为什么可变性问题可能并不那么明显,这取决于你熟悉哪些语言。

10-03

图 10.3 比较 Python 和基于 JVM 的语言的可变性

为了简单起见,让我们假设我们正在尝试跟踪一些字段,以便在用于集成建模问题的单独向量中包含它们。以下列表显示了一个包含函数签名参数中默认值的简单函数,当单次使用时,将提供预期的功能。

列表 10.15 维护元素列表的示例实用函数

def features_to_add_to_vector(features, feature_collection=[]):    ❶
    [feature_collection.append(x) for x in features]               ❷
    return feature_collection                                      ❸

❶ 这是一个简单的函数,用于将一个列表添加到新的元素列表中(这不是创建向量的真实示例,但为了解释的目的,它旨在简单)

❷ 遍历提供的元素列表,并将其添加到新的集合中

❸ 返回新的集合

以下是从该函数的单次使用中得到的输出。这里没有真正的惊喜。

列表 10.16 简单列表函数的使用

trial_1 = features_to_add_to_vector(['a', 'b', 'c'])   ❶
trial_1
> ['a', 'b', 'c']                                      ❷

❶ 通过将它们传递给我们的函数,将三个字符串元素添加到新的集合中

❷ 如预期的那样,我们有一个包含我们传入的元素的列表。

然而,当我们第二次调用它进行额外操作时会发生什么?下一个列表显示了这种额外使用,包括值是什么,以及原始变量声明发生了什么变化。

列表 10.17 通过重复调用我们的函数来修改对象状态

trial_2 = features_to_add_to_vector(['d', 'e', 'f'])   ❶
trial_2
> ['a', 'b', 'c', 'd', 'e', 'f']                       ❷
trial_1
> ['a', 'b', 'c', 'd', 'e', 'f']                       ❸

❶ 再次调用函数,传入新的元素列表。我们应该期望返回值是 ['d', 'e', 'f'],对吧?

❷ 哎呀。返回值仍然包含之前调用时的值。这很奇怪。

❸ 并且它更新了我们第一次调用的变量列表。这似乎是出错了。

这有点出乎意料,对吧?如果我们打算构建一个具有字段 abc 的模型,然后构建另一个具有字段 def 的模型呢?这两个模型都会有所有六个列的输入向量。以这种方式使用可变性覆盖变量不会破坏项目的代码。这两个模型都会执行而不会抛出异常。然而,除非我们非常仔细地验证一切,否则我们可能会忽略我们实际上构建了两个配置相同的模型。

这样的错误对生产力具有致命的影响。花费在调试以找出为什么某些事情没有按预期工作的时间确实可能很大;这些时间本应该花在构建新事物上,而不是找出我们的代码为什么没有按我们的意图工作。

所有这些发生的原因是因为 Python 函数本身也是对象。它们维护状态,因此,该语言不包含变量和操作在它们内部发生是可隔离的概念。在添加到代码库时,必须注意原始实现是以一种方式构建的,这样就不会引入意外的行为(在这个例子中是避免意外的修改)。

当向代码库添加新功能时,首要目标是确保代码能够运行(不会抛出异常)。如果更改未经验证,可能会出现正确性问题,由于无意中使用诸如不安全的修改等捷径,这会导致难以诊断的错误。我们该如何编写这段代码?

10.5.2 封装以防止可变副作用

通过了解 Python 函数维护状态(在这个语言中一切都是可变的),我们本可以预测这种行为。而不是应用默认参数以保持隔离并打破对象修改状态,我们应该用可以与之比较的状态初始化这个函数。

通过执行这种简单的状态验证,我们让解释器知道,为了满足逻辑,需要创建一个新的对象来存储新的值列表。Python 中用于检查集合修改实例状态的正确实现如下所示。

列表 10.18:固定实现实用函数

def features_to_add_to_vector_updated(features, feature_collection=None): ❶
    collection = feature_collection if feature_collection else list()     ❷
    [collection.append(x) for x in features]
    return collection
trial_1a = features_to_add_to_vector_updated(['a', 'b', 'c'])
trial_1a
> ['a', 'b', 'c']                                                         ❸
trial_2a = features_to_add_to_vector_updated(['d', 'e', 'f'])
trial_2a
> ['d', 'e', 'f']                                                         ❹
trial_1a
> ['a', 'b', 'c']                                                         ❺

❶ 将签名更改为将第二个参数默认设置为 None 而不是空列表

❷ 如果没有传递任何内容给 feature_collection 参数,则创建一个新的空列表(这会触发 Python 在此情况下生成一个新对象)

❸ 如预期的那样,我们得到了一个新的列表,其中包含我们传入的元素。

❹ 现在我们得到了一个新的列表,这是重复调用后的结果。这是预期的行为。

❺ 重复使用函数时,原始变量没有改变。

像这样的看似小的问题可能会给实施项目的人(或团队)带来无尽的麻烦。通常,这类问题在早期开发时就会显现出来,在构建模块时没有问题。即使简单的单元测试,在隔离的情况下验证这个功能,看起来也是正确的。

通常在 MVP(最小可行产品)的中期,与可变性相关的问题开始显现出来。随着复杂性的增加,函数和类可能会被多次使用(这在开发中是一个期望的模式),如果没有正确实现,之前看似正常的工作现在会导致难以调试的错误。

小贴士:最好熟悉你的开发语言如何处理对象、原始类型和集合。了解这些语言的核心细微差别将为你提供必要的工具,以指导你的开发过程,这样就不会在整个过程中给你带来更多的工作和挫败感。

关于封装的说明

在本书中,你会看到多次提到我反复强调使用函数而不是声明性代码。你也会注意到提到优先考虑类和方法而不是函数。这一切都是由于使用封装(以及抽象,但这又是另一个故事,将在文本的其他地方讨论)带来的压倒性好处。

封装代码有两个主要好处:

  • 限制最终用户访问内部受保护的功能、状态或数据

  • 在传入的数据包和包含在方法中的逻辑上执行逻辑的强制执行

虽然第一个原因对绝大多数数据科学家来说意义不大(除非你在编写开源项目或实用库,或者向公众提供的 API 做出贡献),但封装的第二个属性可以节省机器学习从业者无数的麻烦。通过这种数据捆绑(即将数据作为参数传递给方法的参数)和在该数据上局部执行逻辑,你可以将行为与其他过程隔离开来:

  • 在方法中声明的变量只在该方法内部引用。

  • 方法对外部世界的唯一外部访问途径就是它的返回值。

  • 执行的操作不能受到除传入它的参数之外任何其他状态的影响。

这些封装属性意味着你可以在任何给定时间确保代码的正确性;例如,如果你有一个方法,它的唯一目的是对商品价格应用销售税抵扣,你可以传入商品成本和税率,并确保无论系统外部的基础状态如何,它始终只会做一件事:对传入的价值应用销售税抵扣并返回调整后的值。这些属性还可以帮助使你的代码更容易测试。

封装还有许多其他好处(尤其是对于机器学习工作),我们将在本书的第三部分中介绍。现在,请记住,通过使用函数和方法正确应用封装数据和方法,可以完全消除可变性问题以及状态管理可能带来的头痛。

10.6 过度嵌套的逻辑

在所有常见的机器学习代码库编码部分中,没有一个比大型条件逻辑树更让那些必须阅读和调试它们的人感到恐惧。大多数逻辑树在早期使用时相对简单:几个if语句,一些elif,然后是一小部分通用的else语句。等到代码在生产环境中运行了几个月后,这些头痛的逻辑巨石可以扩展到数百行(如果不是数千行)。这些业务逻辑规则通常演变成多层混乱、复杂、几乎无法维护的逻辑。

作为例子,让我们看看机器学习领域的一个常见用例:集成。假设我们有两个模型,每个模型为每个客户生成一个概率。让我们从生成表示这两个模型输出的数据集开始。

列表 10.19 生成集成对齐的合成概率数据

import random
def generate_scores(number, seed=42):                            ❶
    def get_random():                                            ❷
        return random.uniform(0.0, 1.0)                          ❸
    random.seed(seed)
    return [(get_random(), get_random()) for x in range(number)] ❹
generated_probabilities = generate_scores(100)
> [(0.6394267984578837, 0.025010755222666936),                   ❺
   (0.27502931836911926, 0.22321073814882275),
   (0.7364712141640124, 0.6766994874229113)...

❶ 封装函数用于生成我们的数据

❷ 一个封装的内部函数,它将引用 random()函数的种子状态

❸ 根据提供给 random 的种子状态生成随机数,使用 0.0 和 1.0 之间的均匀分布

❹ 生成包含我们两个模拟概率的元组,并迭代 number 次以创建一个元组列表

❺ 合成数据

现在我们已经生成了一些数据,让我们假设业务希望根据这些不同的概率进行五级分类,将这些分桶值合并成一个代表分数。

由于 Python(截至 Python 3.9)没有提供 switch(case)语句,创建这个评估合并分数的方法可能看起来像以下这样。

列表 10.20 通过 if、elif 和 else 语句实现合并逻辑

def master_score(prob1, prob2):                         ❶
    if prob1 < 0.2:
        if prob2 < 0.2:
            return (0, (prob1, prob2))                  ❷
        elif prob2 < 0.4:
            return (1, (prob1, prob2))
        elif prob2 < 0.6:
            return (2, (prob1, prob2))
        elif prob2 < 0.8:
            return (3, (prob1, prob2))
        else:
            return (4, (prob1, prob2))
    elif prob1 < 0.4:
        if prob2 < 0.2:
            return (1, (prob1, prob2))
        elif prob2 < 0.4:
            return (2, (prob1, prob2))
        elif prob2 < 0.6:
            return (3, (prob1, prob2))
        elif prob2 < 0.8:
            return (4, (prob1, prob2))
        else:
            return (5, (prob1, prob2))
    elif prob1 < 0.6:
        if prob2 < 0.2:
            return (2, (prob1, prob2))
        elif prob2 < 0.4:
            return (3, (prob1, prob2))
        elif prob2 < 0.6:
            return (4, (prob1, prob2))
        elif prob2 < 0.8:
            return (5, (prob1, prob2))
        else:
            return (6, (prob1, prob2))
    elif prob1 < 0.8:
        if prob2 < 0.2:
            return (3, (prob1, prob2))
        elif prob2 < 0.4:
            return (4, (prob1, prob2))
        elif prob2 < 0.6:
            return (5, (prob1, prob2))
        elif prob2 < 0.8:
            return (6, (prob1, prob2))
        else:
            return (7, (prob1, prob2))
    else:
        if prob2 < 0.2:
            return (4, (prob1, prob2))
        elif prob2 < 0.4:
            return (5, (prob1, prob2))
        elif prob2 < 0.6:
            return (6, (prob1, prob2))
        elif prob2 < 0.8:
            return (7, (prob1, prob2))
        else:
            return (8, (prob1, prob2))

def apply_scores(probabilities):                        ❸
    final_scores = []
    for i in probabilities:
        final_scores.append(master_score(i[0], i[1]))   ❹
    return final_scores
scored_data = apply_scores(generated_probabilities)     ❺
scored_data
> [(3, (0.6394267984578837, 0.025010755222666936)),     ❻
   (2, (0.27502931836911926, 0.22321073814882275)),
   (6, (0.7364712141640124, 0.6766994874229113))...

❶ 处理两个概率的配对组合并通过嵌套条件逻辑解决它们的函数

❷ 嵌套逻辑结构(如果第一个概率小于 0.2,检查第二个概率的条件)

❸ 评估概率值配对元组集合的调用函数

❹ 调用评估函数以将概率解决为单个分数

❺ 调用函数处理分数数据

❻ 根据条件逻辑解决的分数的前三个元素

这个分层逻辑链被写成一系列的ifelifelse语句。它既难以阅读,又会在嵌入额外的现实条件逻辑时难以维护。

如果需要修改这个功能,处理这个工单的人将不得不仔细阅读这堵条件逻辑墙,并确保每个地方都正确更新。对于这个例子来说,由于它的简单性,这并不算过分繁重,但在我所看到的代码库中,业务规则的逻辑很少如此简单直接。相反,通常在条件检查中会有嵌套的andor条件语句,这使这种方法变得极其复杂。

如果将这种方法交给一个传统的软件开发者,他们可能会以完全不同的方式来处理这个问题:利用配置结构将业务逻辑与得分的合并处理隔离开。下面的列表显示了这样的模式。

列表 10.21 基于字典配置处理业务逻辑的方法

threshold_dict = {                                       ❶
    '<0.2': 'low',
    '<0.4': 'low_med',
    '<0.6': 'med',
    '<0.8': 'med_high',
    '<1.0': 'high'
}
match_dict = {                                           ❷
    ('low', 'low'): 0,
    ('low', 'low_med'): 1,
    ('low', 'med'): 2,
    ('low', 'med_high'): 3,
    ('low', 'high'): 4,
    ('low_med', 'low'): 1,
    ('low_med', 'low_med'): 2,
    ('low_med', 'med'): 3,
    ('low_med', 'med_high'): 4,
    ('low_med', 'high'): 5,
    ('med', 'low'): 2,
    ('med', 'low_med'): 3,
    ('med', 'med'): 4,
    ('med', 'med_high'): 5,
    ('med', 'high'): 6,
    ('med_high', 'low'): 3,
    ('med_high', 'low_med'): 4,
    ('med_high', 'med'): 5,
    ('med_high', 'med_high'): 6,
    ('med_high', 'high'): 7,
    ('high', 'low'): 4,
    ('high', 'low_med'): 5,
    ('high', 'med'): 6,
    ('high', 'med_high'): 7,
    ('high', 'high'): 8
}
def adjudicate_individual(value):                        ❸
    if value < 0.2: return threshold_dict['<0.2']
    elif value < 0.4: return threshold_dict['<0.4']
    elif value < 0.6: return threshold_dict['<0.6']
    elif value < 0.8: return threshold_dict['<0.8']
    else: return threshold_dict['<1.0']
def adjudicate_pair(pair):                               ❹
    return match_dict[(adjudicate_individual(pair[0]), adjudicate_individual(pair[1]))]
def evaluate_raw_scores(scores):                         ❺
    return [(adjudicate_pair(x), x) for x in scores]
dev_way = evaluate_raw_scores(generated_probabilities)   ❻
dev_way
> [(3, (0.6394267984578837, 0.025010755222666936)),      ❼
   (2, (0.27502931836911926, 0.22321073814882275)),
   (6, (0.7364712141640124, 0.6766994874229113))...

❶ 用于从处理逻辑中移除映射逻辑的查找字典(在实际代码库中,这些字典会位于后续处理逻辑的不同模块中)

❷ 用于将成对概率的桶化阈值转换为单个得分的解决字典

❸ 函数用于处理单个概率并将其值映射到阈值桶

❹ 函数用于查找和评估成对概率的元组与匹配字典

❺ 函数用于遍历总得分集中的每个元组并应用解决逻辑

❻ 调用主函数以解决概率到得分的转换

❷ 数据的前三个元素

虽然这种方法比列表 10.20 中早期实现的阅读起来容易得多,但它仍然远非理想。让我们假设,在开发项目解决方案的过程中,做出了一个决定,将生成概率得分的模型数量从两个增加到八个。

这将对这两种结构中的哪一种产生影响?接下来的列表说明了为了八个模型解决这两种实现模式,我们需要编写多少行代码。

列表 10.22 一个用于计算我们需要编写多少行代码的函数

import math
def how_many_terrible_lines(levels):                    ❶
    return ((5**levels) * 2) + math.factorial(levels)
how_many_terrible_lines(8)
> 821570                                                ❷

❶ 一个有趣的小函数,用于计算我们需要为 if、elif、else 模式编写多少行代码

❷ 一个非常可怕的数量!这根本不是现实的尝试。

显然,这不是一个选择。即使我们尝试使用这种方法(使用配置字典处理映射的“开发方式”),如果我们尝试将八个概率合并为一个得分,我们将在字典的元组-8 键中创建 32,768 个条件。这只是一个真正荒谬的配置行数。

关于坚持使用糟糕的设计模式的一则笔记

虽然if/elif/else模式的例子可能对一些读者来说有点荒谬,但我发现这是我在野外看到的机器学习代码库中最常见的方法。当我们谈论八个不同的元素时,考虑到配置控制结构可能创建的排列组合数量,字典方法也可能显得有些荒谬。

这个例子并不是夸张。我见过类似的配置文件,其中字典的键数远远超过 10,000 个,以处理这样的逻辑。其中大部分都不是手工输入的(那将是荒谬的),而是机器生成代码和一些复制粘贴到 IDE 的结果。

问题不在于有数万个键;Python 散列表可以轻松处理,无需太多麻烦就能处理 2²⁶个唯一的键标识符计数,在查找函数的性能成为瓶颈之前(67,108,864 条记录)。Python 可以处理它。你的键盘和你的同伴不能。

以这种方式处理业务逻辑或特征工程工作所暴露的真实问题是,它甚至被尝试了。用if/elif/else模式或字典模式来处理这类问题,就像古老的谚语,“当你只有一把锤子时,一切看起来都像钉子。”有更好的方法来解决这个问题,可以将复杂的逻辑模式分解成更小、更易于管理的部分。

如果你发现自己不得不反复复制粘贴大段逻辑,最好的做法是离开键盘,思考如何更高效地解决这个问题,然后回来测试一些可以帮助你不仅使代码库免于变得难以管理,而且在未来更容易修改和调试的理论。

列表 10.23 展示了处理这个问题的更好方法。在这个代码块中,我们将适配数据生成器以支持作为模型返回元组一部分的任意数量的概率,然后将查找函数从字典转换为分数的直接数学表示。从这一点开始,代码将复杂性降低到一个更易于管理的状态,使得通过扩展、映射到新的解析分数以及创建一个未来可以轻松修改的代码库更容易解析业务规则。

列表 10.23 一个轻松扩展的更好解决方案

def generate_scores_updated(number, elements, seed=42):                    ❶
    def get_random():
        return random.uniform(0.0, 1.0)
    random.seed(seed)
    return [tuple(get_random() for y in range(elements)) for x in range(number)]
larger_probabilities = generate_scores_updated(100, 8)                     ❷
larger_probabilities
> [(0.6394267984578837, 0.025010755222666936, 0.27502931836911926,         ❸
     0.22321073814882275, 0.7364712141640124, 0.6766994874229113,  0.8921795677048454, 0.08693883262941615), ...
def updated_adjudication(value):                                           ❹
    if value < 0.2: return 0
    elif value < 0.4: return 1
    elif value < 0.6: return 2
    elif value < 0.8: return 3
    else: return 4
def score_larger(scores):                                                  ❺
    return sum(updated_adjudication(x) for x in scores)
def evaluate_larger_scores(probs):                                         ❻
    return [(score_larger(x), x) for x in probs]
simpler_solution = evaluate_larger_scores(larger_probabilities)
simpler_solution
> [(15, (0.6394267984578837, 0.025010755222666936, 0.27502931836911926,    ❼
     0.22321073814882275, 0.7364712141640124, 0.6766994874229113,   0.8921795677048454, 0.08693883262941615)),
 (10, (0.4219218196852704, 0.029797219438070344, 0.21863797480360336,...

❶ 生成每个元组内任意数量元素的函数

❷ 生成一个元组-8 概率集合以解析为单个分数

❸ 生成第一个元组-8 的示例

❹ 将分数解析函数适配到数学桶划分。将这个值的范围返回到原始 2-元组集合设计的范围,就像创建一个上限或下限函数在值的总和上,除以元组长度的二分之一一样简单。

❺ 对概率元组内每个元素的桶的已解析分数进行求和的函数

❻ 遍历所有元组概率集合的主要函数

❼ 分数解析器前两个元素的样本

我们已经在少量代码行中解决了可扩展性和复杂性问题。我们减少了复杂性(去除字典、映射和链式逻辑),并使代码变得更加简单。在编写代码时追求简单性应该是任何开发者的目标,尤其是那些必须处理 DS 工作广度的人。

学习更多,以及我遇到的最频繁的问题

到目前为止,我收到初级数据科学家最频繁的问题之一是,“我如何提高学习所有这些软件开发知识的能力?”这是一个合理的问题。然而,它通常是一个相当错误的观点。

机器学习的软件开发与纯软件开发非常不同。它是一个聚焦于所有开发者需要了解的元素的微观宇宙,更侧重于创建可维护和稳定的代码,以执行数据科学工作所需的功能。当然,与纯软件开发的基本原理存在共同点。了解良好的软件设计、抽象、封装、理解、继承和多态的基础知识对于成为一名成功的机器学习工程师和开发者至关重要。然而,在这些基础知识之后,相似之处开始出现分歧。

当年轻的数据科学家问我这个问题时,我试图告诉他们,他们不需要同时成为经验丰富的数据科学家和经验丰富的开发者。这对绝大多数人来说都是不可行的(就像同时掌握两个不同的职业一样)。

我给出的建设性回答相当开放。这完全取决于他们想要了解多少超出基础知识和成为全面机器学习工程师所需的具体技能。

软件开发技能不是你“仅仅学习”就能掌握的。你不会通过阅读这本书或任何其他书籍来获得它们。你也不会通过参加昂贵的课程或浏览互联网上的代码库来学习它们。这些技能是通过故意花时间专注于用代码解决问题的新方法,同时参考那些比你更有技能的人过去是如何解决问题的来学习的。它们是通过失败、重写、从错误中学习、测试以及努力编写比上周编写的代码更少出错的代码来学习的。这是一段旅程——在我看来,这是一段值得的旅程。

本章涵盖的问题仅仅是我在看到很多数据科学家在他们的代码中做的事情,这些事情使得他们的代码变得复杂且难以调试。这些主题绝对不是详尽的列表,而是一组例子,帮助你思考为什么你写的某些代码可能对你或其他人来说调试、维护甚至解释都很有挑战性。

他们之所以称编程接口为“语言”,是有原因的。就像学习任何语言一样,为了使你的思想和意图被他人理解,你需要理解和遵守基本的语法规则、语法和结构组件。编程语言的某些细微差别与口语和书面语言相似。有精心制作的语法完美的例子,也有只有少数知情者才能理解的简写“俚语”组合。

就像在面试时不宜用那种方式说话一样,编写像给朋友发带内部玩笑的短信一样的代码也不是一个好主意。然而,如果没有语言的知识和标准,即使是那些不了解这些标准的、有良好意图的开发者,也会编写出像语言初学者一样难以理解的代码,或者更糟糕的是,像使用网络简写和模因习语的人那样,编写出粗糙和业余的代码。

一旦超越了学习那些基础概念(而且第一门语言是最难学的,这一点值得注意),基本能力和艺术性的精通之间就存在着巨大的差距。

我喜欢将语言的精通视为比较不同作者写诗和散文的类比。一开始,在学会基础知识之后,你的代码可能处于儿童书水平。当然,有句子,也有情节,但普利策奖可能不会是你的目标。然而,随着时间的推移,通过练习和改正很多错误,最终你会达到这样一个水平,你编写的机器学习解决方案将具有像大卫·福斯特·华莱士小说那样的精致和细微之处。

提高编码能力的过程需要时间。很多时间。它充满了如此多的错误和挫折,以至于你可能觉得你永远不会在这方面变得很好。然而,就像你学会的其他任何一项技能一样,你最终会发现,在某个时刻,事情会变得更容易。你过去在基本实现中遇到的难题现在会变得如此普遍和容易做到“完美”,以至于你可能不会意识到你所取得的进步。这一切都归结于学习和实践。

摘要

  • 能够识别常见的有问题的实现模式(代码异味)可以帮助创建更易于阅读、更容易调试和扩展的机器学习代码库。

  • 通过简化实现来提高可读性并减少理解代码库功能时的认知负担是值得花费时间的。

  • 使用标准结构进行数据打包可以显著减少扩展代码库所需的重构量,同时减少故障排除的复杂性。

  • 安全地使用 try/catch(异常处理)将创建一个更稳定的代码库。确保只捕获特定的异常将有助于在生产环境中调查问题。

  • 副作用和全局变量的不当使用可能会在代码库中造成潜在的确定性问题。知道何时有效地使用它们,并且永远不要在那些少数必需的时间之外使用它们,可以增强代码的弹性。

  • 即使执行预期行为的逻辑过程可能适合嵌套和复杂的递归行为,但在机器学习代码库中将这种逻辑重构为更易于理解的形式应该是一个优先事项。

11 模型测量及其重要性

本章涵盖

  • 确定模型影响的方法论

  • 归因数据收集的 A/B 测试方法

在第一部分,我们专注于将机器学习项目工作与业务问题对齐。毕竟,这是使解决方案可行的最关键方面。虽然前面的章节侧重于发布前、发布期间以及发布后立即的沟通,但本章侧重于发布后的项目沟通。我们将介绍如何展示、讨论和准确报告机器学习项目的长期健康状况——特别是以业务能够理解的语言和方法。

关于模型性能的讨论很复杂。当业务部门关注业务性能的可衡量属性时,机器学习团队则专注于与目标变量相关联的模型有效性的测量。尽管在这些不同的目标中隐含地定义了语言障碍,但解决方案是存在的。通过将沟通集中在业务指标上,你可以回答业务领导者真正想要了解的问题:“这个解决方案是如何帮助公司的?”确保在内部客户真正关心的业务指标上执行分析,数据科学团队可以避免图 11.1 所描述的情况。

11-01

图 11.1 机器学习项目短视

这个机器学习团队存在短视问题。通过专注于自己的需求以将一个稳固的解决方案投入生产,机器学习团队成员没有准备好回答客户不可避免的疑问。毕竟,展示相关性指标对客户来说毫无意义。也不应该这样。

解决这个问题的方案是存在的。它涉及到核心解决方案开发代码之外的一点点工作,但保持业务参与并了解项目状态是非常值得的。一切始于衡量那些业务属性。

11.1 测量模型归因

我们将转向冰淇淋。具体来说,我们是一群在冰淇淋公司工作的数据科学家。几个月前,销售和营销团队找到我们,希望有一个模型能帮助他们确定何时发送优惠券给客户,以增加客户在收件箱中看到这些优惠券的机会。营销团队的标准做法是每周一早上 8 点发送大量邮件。我们的项目目标是生成一个日期和小时组合,以便以个性化的方式发送电子邮件。

图 11.2 的顶部显示了我们先前状态下的组件和示例。底部显示了模型输出作为图像组件生成器的一部分,针对我们每个成员个性化。

11-02

图 11.2 对于我们的冰淇淋项目,我们有一个基线和一项新的实验来测试。我们如何衡量它的成功?

我们已经构建了这个 MVP(最小可行产品),并根据我们的影子运行(shadow runs)展示了一些有希望的结果。通过跟踪我们的像素数据(嵌入在我们电子邮件优惠券代码中的 1 × 1 像素,显示我们营销广告的打开和点击率),我们发现模型基于我们对优惠券实际打开和使用率的监控,得到了令人震惊的准确结果。

虽然这个消息令人兴奋,但业务部门并不被我们的预测到实际打开时间的分钟误差所说服。他们真正想知道的是:“这能增加销售额吗?”为了开始回答这个问题,我们应该分析图 11.3 中显示的指标。

11-03

图 11.3 销售额:我们模型的真正目标指标

我们如何确定在客户最有可能看到优惠券的时间发送目标优惠券,与客户使用这些优惠券之间存在因果关系?这一切都始于确定要衡量什么,要在谁身上衡量,以及要利用什么工具来确定模型是否有因果影响。

11.1.1 衡量预测性能

在衡量我们模型性能的第一步,我们需要考虑的与任何实验设计(DOE)练习中需要考虑的是相同的。我们在解决方案的生产发布日期之前很久就开始与参与电子邮件营销活动的专家(我们项目的内部客户)交谈。毕竟,这个团队对我们客户及其与我们产品线的互动有着根本的深入理解。

在这些讨论中,我们将希望关注营销团队对我们客户的了解。对客户基础的深入理解将帮助我们确定我们收集的哪些关于他们的数据可以用来限制潜在的效应,以最小化结果中的方差。表 11.1 显示了 SME 小组和 DS 团队提出的假设,以及分析的结果。

表 11.1 假设的不同化因素与我们的数据中的实际先前证据

假设 显著吗? 分层候选者?
热带气候地区的客户购买更多冰淇淋。
居住在农村地区的客户购买更多。 可能*
30 岁以上的客户购买更多。 可能*
打开电子邮件的客户购买更多。
与我们合作历史悠久的客户购买更多。 可能**
有孩子的客户购买更多。 否***
* 可能会引入分析中的巨大偏差。潜在的高风险分层值** 可以与购买金额和购买频率结合。*** 数据不足,可能难以跟踪。

评估我们历史数据中不同客户基础分组的分析过程将有助于隔离行为模式以最小化组内方差。图 11.4 说明了我们将使用我们在分析测试中找到的最优分层方法所要做的事情。

11-04

图 11.4 通过分层分组最小化潜在因素效应以减少组内方差

我们知道我们需要最小化导致行为不平衡的潜在变量效应。我们无法获得确定性地识别我们看到的(多模态)行为的资料,但如果我们控制它,我们当然可以改善我们的归因。但我们如何做到这一点?我们如何最有效地对用户进行分组?

根据我们与行业专家小组的讨论,我们着手分析可以减少我们人口内在变异性的方法。通过听取营销团队的意见,我们发现其经过验证的方法论,即评估客户群体的最优化解决方案。通过结合购买的最近性、历史购买次数以及客户发送给我们的总消费金额,我们可以定义一个标准指标来分类我们的群体(有关 RFM 的细分技术功效,请参阅以下侧边栏)。

RFM:如果你向他们销售东西,这是一个将人类分组的好方法

RFM,即最近性、频率和货币价值的缩写,是一个由 Jan Roelf Bult 和 Tom Wansbeek 提出的直接营销术语。在他们关于“直接邮件最优选择”的文章中,他们提出 RFM 是一种将客户价值化的非常强大的手段。这对估计认为,公司 80%的收入实际上来自其 20%的客户。

虽然极具先见之明,但这种方法的成功已经在许多行业中得到了反复证明(不仅限于面向消费者的公司)。主要概念是在这三个观测变量中的每一个上定义五个基于分位数的客户桶。例如,货币价值高的客户将是花费最高的前 20%,对于M的值为5。频率低(账户生命周期内的总购买次数)的客户,通常只购买一次,将具有F值为1

当结合在一起时,RFM 值会形成一个包含 125 个元素的矩阵,从价值最低的客户(111)到价值最高的客户(555)。在这些原始的 125 个矩阵条目值之上应用业务特定和行业特定的元分组,使公司(以及数据科学团队)能够为假设测试目的拥有无潜在变量分层点。

我曾经对这种将人类行为以如此简单的方式分组的技术有点怀疑——直到我在第三家公司第三次分析它。我现在对这个看似简单但神奇强大的技术深信不疑。

使用我们的 RFM 计算生成客户群体如图 11.5 所示。

11-05

图 11.5 展示了我们客户基础的 RFM 组件的直方图可视化

这个 RFM 示例并不局限于人类(或者,就狗而言)。在我们的客户基础的百分位分析中,我们涵盖了从最有价值(555,有非常近期的购买、频繁的历史购买以及在其一生中的高消费)到最无价值(111,前者的反转)。这使我们能够大致估计影响客户行为的潜在因素的大量数量。这反过来又使我们能够在分析时进行分层,以确保我们向行为相似的人群相对均匀的组别展示测试。这使我们能够通过控制实验来减少方差。

思考练习:如果我们进行人口抽样,并且 90%的 555 名成员被选入对照组,而只有 10%在测试组,那么我们的模型因果验证的结果会是什么?我们可能会得出结论,我们的模型不太好,这将是误导性的。相反,相反的情况会带来哪些危险?

尽管由这三个属性产生的 125 种 RFM 组合很有趣,但它们对于我们分析模型性能与关注的业务指标之间的关系并不特别有帮助。在市场领域的专家共同努力下,我们能够将这些 125 个组别合并为三个主要的元组,以便于我们的分析:高价值、中价值和低价值组。

11-06

图 11.6 展示了我们与市场领域的专家小组共同定义的三个元组在 RFM 计算中的同质性分析

这导致我们的客户基础出现了一般性的分离,如图 11.6 所示。图表显示了每个组件对我们基准收入的贡献、这些差异的统计显著性,以及我们在进行假设检验时可以使用的获胜公式。

注意:有关如何生成这些图表(以及代码)、用于获取这些显著性值的 Python 中的统计包,以及涉及的数据生成器等更多信息,请参阅 GitHub 上本书的配套代码库github.com/BenWilson2/ML-Engineering

为什么我不能仅仅使用我的评分指标来告诉我模型做得怎么样?

让我们暂时不考虑业务可能不熟悉我们用于估计模型拟合优度的预测误差指标的概念。我们无法仅仅使用评分指标来表明模型表现如何的主要原因是我们测量业务影响力时评估的不是同一件事。无论我们的模型在保留验证数据上的表现如何,指标性能并不能保证对任何项目的目标有影响。

看起来我们似乎已经完全解决了基于观察指标性能的问题。然而,仅基于这些指标就宣称整个项目解决方案的胜利是有点过于仓促且极具误导性的。使用相关性分数来估计我们模型的质量的问题在于,收集到的特征并没有涵盖影响结果的所有因素。

在生成特征向量的过程中,我们正在努力优化我们的观察与响应变量的相关性。由于这个事实,我们永远无法确定预测会对我们试图影响的事物产生任何影响。

对于我们的场景,确定预测的影响力的唯一方法是通过 假设检验,测量看到模型输出的人和没有看到的人之间的收入影响。这些样本群体之间的收入差异可以让我们有信心,当我们的模型应用于整个群体时,它具有概率效应。

在我们进行更大范围的讨论之前,为什么这是(相关性 versus 因果性)之前,让我们看看一些常见的监督学习问题中的差异:机器学习指标分数与同一项目的业务指标相比会是什么样子。表 11.2 提供了一些例子。

表 11.2 项目指标 vs. 业务指标示例

项目 机器学习指标 业务指标
欺诈检测 PR 曲线下面积、ROC 曲线下面积、F1 欺诈损失金额、欺诈调查数量
顾客流失预测 PR 曲线下面积、ROC 曲线下面积、F1 购买频率、高流失风险用户的登录事件
销售预测 AIC、BIC、RMSE 等 收入
情感分析 BLEURT、BERTScore 工具用户数量、参与率
冰淇淋优惠券 MAE、MSE、RMSE 收入、优惠券使用率

这种关注点与 DS 团队对业务重要性的看法相一致,而不仅仅是适用损失指标。虽然损失指标对于训练至关重要,但存在一种可能性,即优化的损失指标(尤其是在数据集中优化到虚假相关性的情况下)并不等同于目标业务指标的有利条件。在整个机器学习项目生命周期中利用损失指标和业务指标将大大降低未能满足业务预期的风险。

在向整个企业展示证据结果时,需要记住的最重要的一点是永远不要混淆相关性和因果关系的概念。让人们从你的结果中推断因果关系是一个容易滑倒的斜坡。如果被监控的指标是公司范围内的关键属性,如收入,这会变得更加危险。

A/B 测试可以根据观察到的行为差异提供基于证据的模型影响判断,但这就是你可以做出的声明所能达到的极限。这永远不是确定性。最好的做法是永远不要暗示模型基于相关性的特征或分层分析中使用的分组特征是驱动力的实际原因。我们根本无法拥有完整的画面来做出这样的声明。

关于机器学习指标的一点说明

如果我没有明确指出,我没有对机器学习指标有任何问题。它们非常有用,对于正确构建模型至关重要,并且提供了大量关于我们能够进行的基于相关性的预测的经验质量信息。如果有什么不同的话,我通常在构建解决方案的过程中收集了太多的指标。(我是一个“以防万一”的数学收藏家。)

话虽如此,机器学习指标对业务部门来说毫无用处。它们对内部和外部客户都不相关。

它们并不能保证你将解决你试图解决的问题。根据其设计和目的,它们不过是一个信息工具,用于衡量你如何匹配目标的相对质量,前提是你收集到的关于现实世界的有限数据。

在本章(以及,一般来说,在本书的许多部分)中,我所争论的是,我们的重点始终应该是最终状态。作为机器学习从业者,我们应该关注我们正在构建的东西——不是指我们使用哪种算法,我们使用哪种统计模型,或者我们的特征工程工作多么优雅和巧妙。模型及其所有支持的基础设施和数据流都是为了解决问题而使用的。

任何由数据科学团队承担的项目都具有内在的可衡量质量。如果项目没有,那么它将超出实验阶段的可能性相当有限。任何项目中要解决的问题都有其自己的指标,这些指标通常由请求数据科学团队解决问题的团队定义。

我们是在尝试增加销售额吗?那么就衡量收入、销售单位、客户保留率、重复购买和会话长度。

我们是在尝试增加我们内容的观看量吗?那么就衡量观看百分比、平台上的时间、重复访问和推荐内容的消费。

我们是在尝试检测欺诈吗?那么就衡量成功识别率、损失减少和客户满意度率。

我们是在预测设备故障吗?那么就测量事后设备检查的健康状况、灾难性维修成本水平和设备更换支出。

项目的指令包括一个已经测量并且正在被密切审查的业务方面,这是为了证明值得 DS 团队投入精力去修复。业务对应用机器学习的期望是使事情变得更好。

如果你不是在衡量你是否在使事情变得更好,而是用一些神秘的统计指标来证明你实施预测能力的有效性,那么你是在对自己和你的团队不利。

将项目的术语保持在业务熟悉的指标中——这正是业务领导拿起电话给你打电话,认为你可能成为他们英雄的原因。这种关注将增加他们对团队能力的信心,使团队对项目对业务的影响保持诚实,并帮助每个人清楚地认识到事情不再像以前那样顺利(正如你将在下一节中看到的,这是不可避免的)。

11.1.2 明确相关性与因果关系

向业务部门展示模型结果的一个重要部分是明确区分相关性和因果关系。如果业务领导从你展示给他们的任何内容中推断出因果关系,即使只有一点可能性,最好还是进行这次对话。

相关性简单来说就是观察到的变量之间存在的相互关系或关联。它并不暗示这种关系的存在之外有任何意义。这个概念对于不参与数据分析的普通人来说,本质上是不直观的。在分析中得出“看似合理”的关于数据关系的还原论结论,实际上就是我们的大脑是如何工作的。

例如,我们可以收集冰淇淋车和手套的销售数据,这两者都按年度周和按国家汇总。我们可以计算出两者之间强烈的负相关性(手套销售增加时冰淇淋销售增加,反之亦然)。大多数人会对因果关系的结论感到好笑:“嗯,如果我们想卖更多冰淇淋,我们需要减少我们的手套供应!”

普通人可能会从这样一个愚蠢的例子中立刻说出,“嗯,人们冷的时候买手套,热的时候买冰淇淋。”这是一种试图定义因果关系的尝试。基于观察数据中的这种负相关性,我们绝对不能做出这样的因果推断。我们无法知道实际上是什么影响了购买冰淇淋或手套对个人的影响(每个观察值)。

如果我们在这个分析中引入一个额外的混淆变量(外部温度),我们可能会发现对我们虚假结论的额外证实。然而,这忽略了驱动购买决策的复杂性。例如,参见图 11.7。

11-07

图 11.7 相关性并不表示因果关系。

显然,存在一种关系。随着温度的升高,冰淇淋的销售量也随之增加。所展示的关系相当强。但我们能推断出除了存在这种关系之外的其他任何东西吗?

让我们看看另一个图表。图 11.8 展示了我们可以放入模型中以帮助预测某人是否可能购买我们的冰淇淋的额外观察数据点。

11-08

图 11.8 一个混淆的相关性,当我们思考温度与销售之间的关系。是哪一个在推动销售?是温度还是云层?是混合效应吗?

将云层与销售量对比,我们得到比温度更强的相关性。这告诉我们什么?它只是简单地说明这些观察变量之间存在强烈的(相关性)。我们无法推断出除此之外的其他任何东西。我们绝对不能做出逻辑跳跃,声称高温无云的日子将保证冰淇淋的购买。温度和云层似乎对购买率有显著的影响,但我们不能肯定地说这些是购买或不购买我们冰淇淋选择的原因。

在机器学习模型的领域中,我们处理的是在观察变量的关系中优化成本函数,以基于我们所拥有的数据,对(一个或多个)预测变量进行最佳合理估计。在任何情况下,这都不意味着因果关系。

这个现象有一个简单的解释:我们并非全能。我们根本无法捕捉到所有导致决策的原因。由于我们没有观察到所有原因,我们的模型当然也无法了解它们。如果我们能够捕捉到所有的影响因素,那么作为数据科学家,我们都会失业,因为人们能够直接以完美精确和几乎零不确定性来陈述预期的结果。

让我们设想我们正在试图弄清楚某人是否会购买冰淇淋。图 11.9 显示了可能影响某人购买我们产品的各种影响因素的综合。在这个庞大的原因海洋中,我们只收集了关于这个人的有限数据。对于可能影响购买决策的其他影响因素,我们根本无法收集这些信息。如果我们收集了所有这些信息,模型可能不会具有很强的泛化能力。要从一个具有许多特征的有用模型中提取信息,我们需要数亿行数据才能达到哪怕是近似准确的程度。

11-09

图 11.9 模型无法知晓的事件影响之海。如果我们没有观察到所有这些元素,我们就无法推断因果关系;我们只是没有所有信息

总是最好不尝试将因果关系分配给任何机器学习模型的结果。记住,我们处理的是相关性,以及从相关值中得出结论的最佳努力,以构建预测。我们不是基于这种对众多导致某事发生或不发生的力量的狭隘观点来分配意义或动机(因果关系)。

同样,我们不能仅仅因为 A/B 测试的统计显著结果就直接推断因果关系。我们只能拒绝测试组之间结果的等价性。然而,我们可以通过 A/B 测试来验证我们的预测是否有用。

作为数据科学家,理解这些概念很重要。然而,在向为你的业务构建项目的内部客户沟通时,强化这些概念更为重要。未能传达这些原则在我合作过的团队中造成了大量的困惑和挫败感。

关于因果分析(推断)的说明

某些技术,如设计实验(DOE)和因果建模,可以确定特征与目标之间的因果关系。与仅关注误差项最小化的监督学习不同,通过 DOE 建模发现的因果关系可以自信地确定。

我们可以通过在 DOE 中精心构建有向无环图(DAG)关系,来确定对目标变量的影响程度和方向,这是传统监督学习无法做到的。关于因果建模和 DOE 的进一步阅读,我强烈推荐阅读由 Jonas Peters 等人撰写的《因果推断要素:基础与学习算法》(MIT Press,2017 年)。

11.2 利用 A/B 测试进行归因计算

在上一节中,我们确立了归因测量的重要性。对于我们的冰淇淋优惠券模型,我们定义了一种方法来将我们的客户群分为不同的群体段,以最小化潜在变量影响。我们定义了为什么基于我们试图改进的业务指标(我们的收入)来评估我们实施的成功标准是如此关键。

带着这种理解,我们如何进行影响力的计算?我们如何做出一个在数学上合理且对模型对业务影响这样复杂事物提供不可辩驳评估的判断?

11.2.1 A/B 测试 101

现在我们已经通过简单的基于百分比的 RFM 分段(第 11.1.1 节中分配给客户的三个组)定义了我们的群体,我们准备对客户进行随机分层抽样,以确定他们将获得哪种优惠券体验。

控制组将获得在周一早上 8 点太平洋标准时间(PST)发送到他们收件箱的通用优惠券的预机器学习处理。测试组将获得目标内容和交付时间。

注意:虽然同时发布多个与控制条件显著不同的项目元素在假设检验中可能看起来反直觉(并且它对因果关系造成混淆),但大多数公司(明智地)愿意为了尽快将解决方案推向市场而放弃评估的科学准确性。如果你面临这种所谓的违反统计标准的所谓违规行为,我最好的建议是:保持耐心,保持沉默,并意识到你可以在后续的 A/B 测试中通过改变实施方面的某些方面来进行变异测试,以确定对解决方案不同方面的因果影响。当是时候发布解决方案时,通常首先发布最佳可能的解决方案,然后分析组件更为有价值。

在生产发布后的短时间内,人们通常希望看到数据开始滚动时即展示影响的图表。将创建许多折线图,基于控制组和测试组汇总业务参数结果。在让每个人都疯狂制作花哨的图表之前,需要定义假设检验的几个关键方面,以确保其成功裁决。

我们需要收集多少数据?

在设计假设检验时,过程的一个关键部分是确定评估的适当样本大小。以下列表显示了一种相对直接的方法,根据业务需求确定适当的样本大小。

列表 11.1 最小样本大小确定器

from statsmodels.stats.power import tt_ind_solve_power                    ❶
x_effects = [0.01, 0.05, 0.1, 0.15, 0.2, 0.25, 0.5]                       ❷
sample_sizes = [tt_ind_solve_power(x, None, 0.2, 0.8, 1, 'two-sided') for x in x_effects]                                                ❸
sample_sizes_low_alpha = [tt_ind_solve_power(x, None, 0.01, 0.8, 1, 'two-sided') for x in x_effects]                                     ❹

❶ 有人为 SciPy 中的 power 求解器包装了 statsmodels 的高级 API。

❷ 生成控制与测试之间的“提升”差值列表(指标之间的百分比差异)

❸ 通过将 effect_size 设置为 None 来求解 alpha 0.2 的样本大小

❹ 求解 alpha 为 0.01(99%确信没有一类错误)的样本大小

图 11.10 显示了运行此代码的结果(可视化代码可在本书的配套仓库中找到)。在两种情况下,我们都将功效值保持在 0.8,如果此类用例中二类错误的危险对业务有害,则可以也应该进行调整。

11-10

图 11.10 基于置信度要求的样本大小确定

随着 alpha 值(我们测量的显著性水平)的降低,确定测试组和对照组之间差异所需的记录样本数量增加。在模型投入生产之前,能够传达收集足够数据所需的时间以做出结论性判断是绝对必要的。如果没有设定这些期望,企业将仅仅在猜测何时,以及更令人沮丧的是,是否能够从项目中看到有希望的结果。

上述估计完全基于需要正态分布和同质样本群体大小的统计测试。在接下来的章节中,我们将讨论如何不仅测试参数数据,还要测试非参数数据和样本量不平衡的适当显著性测试。

不应该做什么

图 11.11 说明了许多公司在使用机器学习项目工作影响业务早期如何评估归因。如果没有应用适当的统计分析以及合理的统计过程来处理归因测量,企业可能会感到极大的挫败感。

11-11

图 11.11 忽略假设测试会带来挫败感。

应对这一问题的最佳方法是制定关于如何评估数据和裁决影响所需时间的既定规则,并建立一个监控系统来测试监控参数的统计显著性。

在定义了 RFM 群体、理解了样本量估计,并且有一个自动监控任务来检索我们的归因数据用于测量后,我们准备开始评估我们的项目。我们准备看看所有辛勤的工作是否值得。

11.2.2 评估连续度量

对于我们的冰淇淋优惠券优化,企业关注的其中一个主要指标是收入。在处理货币价值度量时,许多情况下与支出相关的分布通常是高度非正态的。图 11.12 显示了与可变价格商品和无限篮子情况(如电子商务中)相关的无界购买图。

11-12

图 11.12 对客户收入的正常性验证检查

如果你处理的是类似这种图表的分布,你将不会使用标准的参数测试。然而,在我们的用例中,我们有一组固定价格的物品(我们所有的冰淇淋价格都相同),我们发放的优惠券针对的是单个物品。尽管如此,我们已经完成了统计分析的家庭作业,验证我们将拥有相对正态分布的数据。

在进行我们解决方案的实验测试时,我们将定义参数测试,如列表 11.2 所示。我们将将这些应用于标准图表,不仅可以显示特定客户群体随时间变化的销售数据,还可以显示每个测试的等效性测试 p 值。在实际操作中,并非所有这些都会包含在报告中(在这里展示和计算参数和非参数测试只是为了演示目的)。你应该只使用最适合你数据的那一个。

列表 11.2 线形图与统计测试

from statsmodels.stats import anova
from scipy.stats import f_oneway, mannwhitneyu, wilcoxon, ttest_ind
from collections import namedtuple
import matplotlib.pyplot as plt
DATE_FIELD = 'Date'
TARGET_FIELD = 'Sales'
def calculate_basic_stats_df(series):                                       ❶
    StatsData = namedtuple('StatsData', 'name mean median stddev variance sum')
    return StatsData(series.name,
                     np.mean(series),
                     np.median(series),
                     np.std(series),
                     np.var(series),
                     np.sum(series)
                    )

def series_comparison_continuous_df(a, b):                                  ❷
    BatteryData = namedtuple('BatteryData', 'left right anova mann_whitney_u wilcoxon ttest')
    TestData = namedtuple('TestData', 'statistic pvalue')
    anova_test = f_oneway(a, b)
    mann_whitney = mannwhitneyu(a, b)
    wilcoxon_rank = wilcoxon(a, b)
    t_test = ttest_ind(a, b, equal_var=False)
    return BatteryData(a.name, 
                       b.name, 
                       TestData(anova_test.statistic, anova_test.pvalue),
                       TestData(mann_whitney.statistic, mann_whitney.pvalue),
                       TestData(wilcoxon_rank.statistic, wilcoxon_rank.pvalue),
                       TestData(t_test.statistic, t_test.pvalue)
                      )

def plot_comparison_series_df(x, y1, y2, size=(10,10)):
    with plt.style.context(style='seaborn'):
        fig = plt.figure(figsize=size)
        ax = fig.add_subplot(111)
        ax.plot(x, y1, color='darkred', label=y1.name)
        ax.plot(x, y2, color='green', label=y2.name)
        ax.set_title("Comparison of Sales between tests {} and {}".format(y1.name, y2.name))
        ax.set_xlabel(DATE_FIELD)
        ax.set_ylabel(TARGET_FIELD)
        comparison = series_comparison_continuous_df(y1, y2)                ❸
        y1_stats = calculate_basic_stats_df(y1)                             ❹
        y2_stats = calculate_basic_stats_df(y2)
        bbox_stats = "\n".join((
            "Series {}:".format(y1.name),
            "   Mean: {:.2f}".format(y1_stats.mean),
            "   Median: {:.2f}".format(y1_stats.median),
            "   Stddev: {:.2f}".format(y1_stats.stddev),
            "   Variance: {:.2f}".format(y1_stats.variance),
            "   Sum: {:.2f}".format(y1_stats.sum),
            "Series {}:".format(y2.name),
            "   Mean: {:.2f}".format(y2_stats.mean),
            "   Median: {:.2f}".format(y2_stats.median),
            "   Stddev: {:.2f}".format(y2_stats.stddev),
            "   Variance: {:.2f}".format(y2_stats.variance),
            "   Sum: {:.2f}".format(y2_stats.sum)
        ))
        bbox_text = "Anova pvalue: {}\nT-test pvalue: {}\nMannWhitneyU pvalue: 
          {}\nWilcoxon pvalue: {}".format(
          comparison.anova.pvalue,
          comparison.ttest.pvalue,
          comparison.mann_whitney_u.pvalue,
          comparison.wilcoxon.pvalue
          )
        bbox_props = dict(boxstyle='round', facecolor='ivory', alpha=0.8)
        ax.text(0.05, 0.95, bbox_text, transform=ax.transAxes, fontsize=12, 
          verticalalignment='top', bbox=bbox_props)
        ax.text(0.05, 0.8, bbox_stats, transform=ax.transAxes, fontsize=10, 
          verticalalignment='top', bbox=bbox_props)
        ax.legend(loc='lower right')
        plt.tight_layout()

❶ 获取每个序列关键统计信息的简单函数

❷ 调用 SciPy 和 statsmodels 模块进行参数和非参数等效性测试的函数

❸ 调用序列比较函数以获取图表显示的显著性测试值

❹ 调用每个序列的基本统计计算

图 11.13 显示了执行此代码的结果;描绘了高价值客户群体前 150 天的测试结果。

11-13

图 11.13 随时间绘制测试组和对照组,显示数据的非参数性质

这里比较的数据集是非参数的。这是由于销售随时间趋势的变化,导致我们的分布随时间变化。唯一可能允许我们使用如方差分析、T 检验和 Z 检验等比较的条件是,我们的数据具有平稳性(趋势为 0)。

以这种方式展示时间序列显示只是展示测试结果的一部分。正如我们在第一部分所关注的,对于任何机器学习项目来说,与业务清晰沟通的能力至关重要。当归因和测量的主题进入对话时,这一点尤为重要。全面了解不仅涉及单一的数据结果展示,我们将在下一部分进行介绍。

我真的必须这样做吗?!

简而言之,不。

机器学习项目工作具有不同的级别。每个影响业务的临界性级别都对应着实施归因(和漂移)测量的紧迫性级别。让我们看看几个例子:

  • 用于为其他项目生成标记数据的公司内部工具模型——标准的机器学习指标是足够的。

  • 设计用于协助其他部门进行重复性任务的公司内部预测模型——归因建模不适用,定期的临时漂移检测可能是有价值的。

  • 直接影响关键业务运营的公司内部项目(帮助影响主要商业决策)——绝对需要具备漂移检测功能,并且建立归因建模是个好主意。

  • 面向外部客户的模型——归因测量、漂移检测和偏差检测(评估基于收集的数据的性质和类型,具有放大系统性社会问题实际后果的偏见预测)是绝对必要的。

最后一个要素是大多数生产级机器学习所关注的:影响公司盈利能力或效率的关键重要项目。在这个列表中,特别值得注意的是偏差测量,这是在撰写本书时正在进行积极研究的一个主题。我在本书中不深入探讨这个主题,但它是我们工作的一个关键方面。(关于这个主题的书籍已经有很多,我鼓励所有专业机器学习从业者至少阅读其中之一。)

当我们的模型影响人们的生活时,偏差测量变得非常重要:信用卡申请、房屋贷款审批、警察巡逻推荐、城市资金和人类行为风险检测只是基于我们数据集中反映的先前行为的一些严重偏差的机器学习应用的小样本。密切关注结果总是能让你避免后续的困难对话。

11.2.3 使用替代显示和测试

为了配合任何时间相关的假设检验,向业务展示结果的箱线图可能很有用。虽然这些图表以易于接受的方式提炼信息非常有用,但绝大多数非专业人士并不熟悉看到这些图表伴随着帮助指导可解释性的关键重要的统计摘要。

没有统计显著性的参考,很容易对不足(或高方差)的数据做出判断。下一个列表展示了参数数据的 ANOVA 图和进行测试所需的 DataFrame 操作。

列表 11.3 生成针对参数数据的 ANOVA 箱线图报告

from statsmodels.formula.api import ols
from statsmodels.stats import anova
def generate_melted_df(series_collection, dates, date_filtering=DATA_SIZE):❶
    series_df = generate_df(series_collection, dates)
    melted = pd.melt(series_df.reset_index(), id_vars='Date', 
      value_vars=[x.name for x in series_collection])
    melted.columns = [DATE_FIELD, 'Test', 'Sales']
    return melted[melted[DATE_FIELD] > max(melted[DATE_FIELD]) - 
      timedelta(days=date_filtering)]
def run_anova(data, value_name, group_name):                               ❷
    ols_model = ols('{} ~ C({})'.format(value_name, group_name), 
      data=data).fit()
    return anova.anova_lm(ols_model, typ=2)
def plot_anova(melted_data, plot_name, figsize=(16, 16)):
    anova_report = run_anova(melted_data, 'Sales', 'Test')
    with plt.style.context(style='seaborn'):
        fig = plt.figure(figsize=figsize)
        ax0 = fig.add_subplot(111)
        ax0 = sns.boxplot(x='Test', y='Sales', data=melted_data, 
          color='lightsteelblue')
        ax0 = sns.stripplot(x='Test', y='Sales', data=melted_data, 
          color='steelblue', alpha=0.4, jitter=0.2)
        ax1 = fig.add_subplot(211)
        ax1.set_title("Anova Analysis of tests", y=1.25, fontsize=16)
        tbl = ax1.table(cellText=anova_report.values, 
                        colLabels=anova_report.columns, 
                        rowLabels=anova_report.index, 
                        loc='top', 
                        cellLoc='center', 
                        rowLoc='center',
                        bbox=[0.075,1.0,0.875,0.2]
                       )                                                   ❸
        tbl.auto_set_column_width(col=list(range(len(anova_report.columns))))
        ax1.axis('tight')
        ax1.set_axis_off()
        plt.savefig("anova_{}.svg".format(plot_name), format='svg')

❶ 将 DataFrame 标准化(熔化)以支持 statsmodels 中的 ANOVA 计算

❷ 创建 ANOVA 所需的线性模型

❸ 将 ANOVA 结果统计信息叠加到图表上,以便于参考

在一个替代数据集(一个静态且没有季节性影响的数据集)上执行此代码的结果显示在图 11.14 中。有关与我们所使用的数据集相比,此数据集生成的详细差异,请参阅本书的配套仓库。

11-14

图 11.14 静态参数测试示例

使用这些参数测试,我们可以更准确地确定测试中差异的大小。这主要是因为参数学生测试建立的基础(要求样本均值遵循正态分布,均值的标准误差遵循具有 n- 1 个自由度的卡方分布)。在我们的原始问题中,同时测试几个组,可能有点繁重,需要将每个 ANOVA 测试作为配对测试来绘制。当只有三个组被分配到测试组和对照组时,这可能不会太令人痛苦。但是,25 个组进行测试则是另一回事。

进入 Tukey HSD 测试(HSD 代表 honestly significant difference)。这是另一种参数测试,与学生家族测试的主要区别在于,可以一次性进行每个组之间的配对比较。以下列表展示了该测试的实现和相应的可视化报告。

列表 11.4 Tukey HSD 假设检验和图表

from statsmodels.stats.multicomp import pairwise_tukeyhsd
def convert_tukey_to_df(tukey):
    STRUCTURES = [(0, 'str'), (1, 'str'), (2, 'float'),                         ❶
    (3, 'float'), (4, 'float'), (5, 'float'), (6, 'bool')]                      ❶
    fields = tukey.data[0]
    extracts = [extract_data(tukey.data[1:], x[0], x[1]) for x in STRUCTURES]   ❷
    result_df = pd.concat(extracts, axis=1)
    result_df.columns = fields
    return result_df.sort_values(['p-adj', 'meandiff'], ascending=[True, False])❸

def run_tukey(value, group, alpha=0.05):                                        ❹
    paired_test = pairwise_tukeyhsd(value, group, alpha)
    return convert_tukey_to_df(paired_test._results_table)

def plot_tukey(melted_data, name, alpha=0.05, figsize=(14,14)):
    tukey_data = run_tukey(melted_data[TARGET_FIELD], melted_data[TEST_FIELD], 
      alpha)
    with plt.style.context(style='seaborn'):
        fig = plt.figure(figsize=figsize)
        ax_plot = fig.add_subplot(111)
        ax_plot = sns.boxplot(x=TEST_FIELD, y=TARGET_FIELD, data=melted_data, 
          color='lightsteelblue')
        ax_plot = sns.stripplot(x=TEST_FIELD, y=TARGET_FIELD, 
                                data=melted_data, color='steelblue', 
                                alpha=0.4, jitter=0.2)
        ax_table = fig.add_subplot(211)
        ax_table.set_title("TukeyHSD Analysis of tests", y=1.5, fontsize=16)
        tbl = ax_table.table(cellText=tukey_data.values,
                             colLabels=tukey_data.columns,
                             rowLabels=tukey_data.index,
                             loc='top',
                             cellLoc='center',
                             rowLoc='center',
                             bbox=[0.075, 1.0, 0.875, 0.5]
                            )                                                   ❺
        tbl.auto_set_column_width(col=list(range(len(tukey_data.columns))))
        ax_table.axis('tight')
        ax_table.set_axis_off()
        plt.tight_layout()
        plt.savefig('tukey_{}.svg'.format(name), format='svg')

❶ 定义 pairwise_tukeyhsd 返回类型的结构

❷ 从 Tukey HSD 测试的结果中提取数据

❸ 运行配对 Tukey HSD 测试

❹ 按显著性排序并返回成对数据

❺ 在箱线图上方创建一个显示表,显示所有待评估配对组之间的关系

将此代码应用于我们的静态全样本测试组结果,产生了图 11.15 中所示的图表。正如您通过查看中等价值和低价值组所看到的,在看似相似的数据之间进行视觉上的区分极其困难。抛开制作视觉或简单汇总评估的危险性不谈,将统计验证测试作为您向业务展示的任何比较图表的一部分,可以帮助巩固结论。

11-15

图 11.15 Tukey HSD 配对比较测试,显示每个组与其他每个组的关系以及对于等效性的配对比较中是否可以拒绝零假设

这个简单的图表对于业务单元来说足够容易理解。它有助于防止在存在足够数据支持该结论之前,任何人就对项目的成功(或失败)做出判断。在图表中,我们可以看到,对于我们的中等价值队列,测试组和对照组之间没有明显的差异。这有助于确定哪些组可能需要不同的方法(为模型的下一迭代和进一步的测试打开大门)以及哪些应该谨慎处理(高价值组的测试条件似乎运作得非常出色;现在为什么要改变它呢?)。

11.2.4 评估分类指标

到目前为止,我们一直在讨论收入,但这并不是我们冰淇淋消费优化项目的全部故事。虽然高管们关心模型对销售数字的影响,但市场营销团队(我们的内部客户)想知道优惠券的使用率。

很遗憾,我们不能使用与连续数据相同的处理方法来处理名义数据。ANOVA 测试、Tukey HSD 比较或其他类似技术都不再适用。相反,我们需要深入研究分类测试的世界。我们需要开始思考我们在测量事件时,是在考虑“发生”还是“未发生”。

列表 11.5 展示了测量测试组和对照组之间比率的数据的简单模拟,这是测试前 50 天发行的 50,000 张优惠券的例子。为了保持可视化简单,我们将所有队列放入一个单独的组中(但在实践中,您会有不同的图表和针对每个队列的统计测试)。

列表 11.5 分类显著性测试

from scipy.stats import fisher_exact, chi2_contingency
def categorical_significance(test_happen, test_not_happen, control_happen, 
      control_not_happen):
    CategoricalTest = namedtuple('CategoricalTest', 
                                 'fisher_stat fisher_p chisq_stat chisq_p
                                 chisq_df chisq_expected')
    t_happen = np.sum(test_happen)                                          ❶
    t_not_happen = np.sum(test_not_happen)
    c_happen = np.sum(control_happen)
    c_not_happen = np.sum(control_not_happen)
    matrix = np.array([[t_happen, c_happen], [t_not_happen, c_not_happen]])
    fisher_stat, fisher_p = fisher_exact(matrix)                            ❷
    chisq_stat, chisq_p, chisq_df, chisq_expected = chi2_contingency(matrix)❸
    return CategoricalTest(fisher_stat, fisher_p, chisq_stat, chisq_p, 
      chisq_df, chisq_expected)

def plot_coupon_usage(test_happen, test_not_happen, control_happen, 
  control_not_happen, name, figsize=(10,8)):
    cat_test = categorical_significance(test_series, test_unused, 
      control_series, control_unused)                                       ❹
    with plt.style.context(style='seaborn'):
        fig = plt.figure(figsize=figsize)
        ax = fig.add_subplot(111)
        dates = np.arange(DATE_START, 
                            DATE_START + timedelta(days=COUPON_DATES), 
                            timedelta(days=1)).astype(date)
        bar1 = ax.bar(dates, test_series, color='#5499C7', label='Test 
          Coupons Used')
        bar2 = ax.bar(dates, test_unused, bottom=test_series, 
          color='#A9CCE3', label='Test Unused Coupons')                     ❺
        bar3 = ax.bar(dates, control_series, bottom=test_series+test_unused,
          color='#52BE80', label='Control Coupons Used')
        bar4 = ax.bar(dates, control_unused, 
          bottom=test_series+test_unused+control_series, 
          color='#A9DFBF', label='Control Unused Coupons')
        bbox_text = "Fisher's Exact pvalue: {}\nChisq Contingency pvalue: 
          {}\nChisq DF: {}".format(
          cat_test.fisher_p, cat_test.chisq_p, cat_test.chisq_df
          )                                                                 ❻
        bbox_props = dict(boxstyle='round', facecolor='ivory', alpha=1.0)
        ax.set_title("Coupon Usage Comparison", fontsize=16)
        ax.text(0.05, 0.95, bbox_text, transform=ax.transAxes, fontsize=12, 
          verticalalignment='top', bbox=bbox_props)
        ax.set_xlabel('Date')
        ax.set_ylabel('Coupon Usage')
        legend = ax.legend(loc='best', shadow=True, frameon=True)
        legend.get_frame().set_facecolor('ivory')
        plt.tight_layout()
        plt.savefig('coupon_usage_{}.svg'.format(name), format='svg')

❶ 对于测试组和对照组的每个系列数据(事件发生,事件未发生),获取这些事件的和

❷ 对每个组的“发生/未发生”矩阵运行 Fisher 精确测试

❸ 对矩阵运行卡方列联测试

❹ 从分类显著性函数中获取统计测试

❺ 将条形图堆叠在一起,以便轻松查看随时间变化的交互率

❻ 为图表构建统计测试报告框

当我们运行此代码(在为优惠券设置完成我们的发送和 ETL 处理后),我们最终会得到一个看起来像图 11.16 的图表。

11-16

图 11.16 在假设测试分析中测量分类的“发生/未发生”事件

图 11.16 可能不适用于所有机器学习项目。本章前面的连续值测量更为常见。然而,如果您需要评估基于事件的数据并提供关于测试条件是否不同的结论性声明,拥有这种方法作为工具是必不可少的。

归因测量的通用应用

我们已经讨论了冰淇淋优惠券发行的模型归因测量。不是要贬低任何冰淇淋公司(我向你保证,我的狗爱你),但关于稍微“更严肃”的事业呢?

关于监控商业属性的关键是要选择一个有用的度量指标。这些指标的有用性,用更好的词来形容,完全集中在“细节决定成败”的概念上。商业成功的衡量具有细微差别,可以从有用到毫无意义。选择能够体现商业细微差别的可衡量属性至关重要。与负责该业务方面的业务单元 SME 团队进行讨论,以确保您的归因分析的相关性非常有帮助。

细节决定成败

对于外人来说,商业指标似乎是从常识中产生的。如果我们试图从模型中测量收入效应,我们只需查看销售额,对吧?如果我们计算参与度的提升,我们只需查看登录事件。优化飞往国际机场的航线会查看飞机的载客率,对吗?

虽然定义这些规则可能看起来微不足道,但我可以向你保证,事实并非如此。对于每个用例,如何计算哪个指标以及这些指标是如何计算的细节,都已被证明既复杂又高度具体于公司决定如何运营业务的方式。因此,与你的同事讨论任何归因指标非常重要,否则他们将为公司的报告目的计算这些指标。

如果你正在构建一个试图为公司目标收入的解决方案,与财务团队交谈。如果你正在为物流的效率优化工作,与运营部门交谈。如果模型的目标是减少制造部件的缺陷密度,你应该与质量保证和计量部门交谈。

拥有一系列与公司在互动领域定义其成功方式一致的指标,将确保在向业务展示模型影响时几乎不会出现混淆。有了这种一致性,对解决方案的信心得以培养,确保机器学习解决方案成为公司运营的关键关键方面,并有助于增长创新并为数据科学团队解决更多有趣的项目。

我该如何确定要监控哪些业务指标?

简短的回答:询问。

通常,数据科学团队是提出出色的机器学习项目想法的一方——尽管大多数时候,业务单元的赞助商或高管会向团队寻求解决问题的帮助。尽管如此,我可以数得清我参与过的创新问题数量。

我并不是在贬低所做的工作,只是诚实地表示,在构建机器学习解决方案之前,问题就已经存在。区别在于,它是由人类而不是算法和代码来处理的。收入最大化?那是市场营销。欺诈检测?那是欺诈部门。定价优化?需求预测?在你被召唤来帮忙之前,已经有很多人在做这些工作了。

这些人了解他们的技艺。他们对于数据的细微差别、他们负责的领域以及客户或流程的本质的了解将远远超过你。确定哪些业务方面可以用来创建衡量你将要构建的性能的可衡量指标的最佳来源是这些人。

每次我开始一个新的项目——除了最初在真空状态下构建的几个解决方案,它们失败得相当惨烈——我都会认识这些人。我会邀请他们参与讨论、共进午餐、参加会议,并且一般都会倾听他们说的每一句话。我会就他们自己的工作如何衡量成功(如果部门的目标是衡量你的模型的标准,那么每个人都会更容易理解其影响)提出尖锐的问题。我会询问他们用来确定其部门成功与否的查询。我会询问团队的目标和关键结果(OKR)是什么。

他们可能会告诉你,他们不是通过检测到的正确欺诈事件的数量来衡量的,而是更多地通过检测以前未见过的创新欺诈事件来衡量的。他们可能会说,他们专注于捕捉足够的欺诈,但从不希望错误地将欺诈标记在合法客户身上。这些可能会影响你模型的构建,但也可以用作衡量你实施健康状况的指标。

通过进行这种对齐并涉及对主题了解如此深入的人,你将为项目在发布后获得更高的成功率做好准备,但更重要的是,为能够以公司整体将看到的方式衡量生产发布模型的能力做好准备。这将有助于确保你在公司意识到这些问题之前就意识到退化问题(希望如此),从而提高项目的稳定性水平。

现在我们已经讨论了如何衡量归因分析中的 ML 实验,专注于回答业务希望对项目提出的关键问题,现在是时候面对房间里的大象了。我们需要弄清楚如何检测漂移,如何处理它,以及当我们的模型不可避免地恶化时何时采取行动。有了用于测量的统计数据,以及我们内部 DS 关注的损失指标,我们准备直面挑战。现在是时候考虑模型漂移了。

摘要

  • 归因分析使 DS 团队能够清楚地沟通其解决方案如何解决其打算为业务解决的问题。利用适当的统计方法和受控测试可以提供关于解决方案状态的客观声明。

  • 通过使用正确的统计测试来评估 A/B 测试数据,可以就解决方案性能的状态做出声明,提供基于数据的声明性影响。

12 通过监控漂移来保持您的收益

本章涵盖

  • 识别和监控生产解决方案中的漂移

  • 定义对检测到的漂移的响应

在上一章中,我们为衡量机器学习解决方案的有效性奠定了基础。这个坚实的基础使数据科学团队能够用与业务相关的术语向业务沟通项目的性能。为了继续(希望)报告关于解决方案有效性的积极结果,还需要做更多的工作。

如果适当的归因监控和向业务报告是项目的基石和基础,那么熵就是试图不断摧毁项目的冲击风暴。我们称这种性能的混乱变化为“漂移”,它有多种形式。对抗它需要持续的监控和对进入和离开模型的一切保持怀疑和不信任。

在本章中,我们将探讨模型漂移的主要类型、原因和解决方案。对抗漂移将有助于确保您为公司带来的收益继续证明是富有成效的。

12.1 检测漂移

让我们假设我们已经将第十一章中的冰淇淋推荐器部署到生产中。我们在整个开发过程中都使用了良好的工程实践,我们的内部专家测试看起来很有希望。归因测量已设置,A/B 测试已定义,我们准备开始收集结果。我们将传说中的克苏鲁释放到世界上。

直到模型在生产中运行得非常顺利大约六周后,我们才被营销团队通知其在分析客户基础时发现的一些令人担忧的趋势。在国家的某个地区,优惠券的发行和返利率已经增加到如此程度,以至于出现了产品短缺,而在另一个地区,产品购买类型的失衡变得如此严重,以至于产生了大量过剩的废品。可能到了稍微恐慌一下的时候了

我们集体陷入混乱,以临时方式深入研究特征数据,将所有其他项目工作暂停,以便我们应对这场试图调查问题根本原因是否为模型的即时火灾。经过几天对根本原因的探索性分析,几乎没有取得成果后,我们面临来自业务的最后通牒:要么修复模型,要么关闭它。虽然利润归因提升抵消了产品报废的成本,但这并不是一个足够有说服力的故事,无法安抚业务。

我们在启动模型的新一轮训练时,闭上眼睛,希望一切顺利。根据训练期间保留的验证评分指标的结果,似乎问题已经自行解决。至少目前是这样。

这里发生了什么?为什么模型突然开始表现得像这样?为什么业务会受到看似无害的事情如此严重的影响?最重要的是,在我们将此模型投入生产之前,我们应该做些什么不同的事情?

简单的答案是,熵无处不在。特征度量数据,以及影响因果关系的潜在因素,始终在变化。在许多情况下,我们对模型输出采取的行动会导致数据的变化。在训练期间模型未接触到的潜在影响可以引入新的相关性。曾经对目标优化有价值的关联关系可能会恶化或增强到一定程度,以至于模型输出的预测不再解决项目旨在解决的问题。

这些影响在某些用例中可能相当严重且迅速(例如,欺诈检测,因为犯罪分子很聪明,会适应创造性追求以击败你模型检测其活动的能力),而其他影响则是渐进的,如果不进行算法监控则容易忽略。意识到并控制这些不可避免的变动是机器学习项目开发的一部分。我们需要预料到它们,建立系统来发现它们,并知道如何从中恢复。

12.1.1 影响漂移的因素有哪些?

模型漂移可以采取六种主要形式。其中一些很容易检测到,而其他一些则需要大量的研究和分析才能发现。表 12.1 简要概述了这些模型退化机制。

表 12.1 预测漂移类型及纠正措施

漂移类型 测量方法 纠正措施
特征漂移 特征分布验证 在新数据上重新训练模型
后验预测错误计算 重新审视特征工程
标签漂移 预测的后验分析 在新数据上重新训练,调整
概念漂移 归因度量 执行特征工程工作
特征分布验证 重新训练模型
后验预测分析 重新审视解决方案(新算法或方法)
临时分析 评估解决方案的相关性
因果模型(模拟)
预测漂移 后验预测分析 分析对业务的影响
归因度量
现实漂移 你会直接知道。一切都在燃烧。 转向人工干预
重新评估特征
在训练数据中创建硬停止边界
重新训练模型
反馈漂移 改进模型所花费的时间 评估解决方案的有效性
重新训练期间的表现 确定是否需要新的解决方案

这些测量方法相对常见,在概念漂移检测中得到了最详细的描述。这些测量方法中的每一个都应该用于任何推向生产的模型。持续测量的原因很多,但最重要的是以下这些:

  • 模型会漂移。没有静态实施的东西。

  • 单凭归因测量很难识别渐进式退化。以多种方式监控性能可以提醒你注意那些在长时间内显现的问题。

  • 如果没有历史测量,快速退化很难应对。在没有数据定义出了什么问题的情况下修复模型是极其耗时的。

  • 警报可以在问题变成更大问题之前为你赢得宝贵的时间来修复。这有助于项目的使命,并增加企业对数据科学工作的信心。

为了探索这些漂移机制的每一个,我们将为了简便(以及乐趣)在本章中坚持使用冰淇淋场景。

注意:在第 12.2 节中描述的一些用于设置这些效果监控的技术,尤其是基于特征的漂移,对于采用厨房水槽方法的模型来说可能难以扩展。(我见过人们试图实现包含数千个特征的巨大向量,希望提高准确性。)这绝对是在设计预测解决方案时需要考虑的事情。采取简单的方法,只是把大量数据扔进模型,寄希望于最好的结果,最终可能会成为监控这种实施健康状况的噩梦。

特征漂移

让我们暂时想象一下,我们的冰淇淋购买倾向模型使用多个地区的天气预报数据。让我们也假装我们没有在我们的模型上设置监控,并且每月使用被动的模型重新训练。

当我们最初构建模型时,我们对特征进行了彻底的分析。我们确定了相关值(皮尔逊相关系数和卡方值),并发现温度和冰淇淋销量之间存在惊人的强关系。在前几个月,一切进展顺利,根据超过 60%的打开倾向得分,定期发送电子邮件。

突然间,到了六月中旬,归因模型开始急剧下降。打开率和利用率低得令人难以置信。从 20%的收入提升到现在的测试组显示 300%的损失。我们继续这样操作,营销团队尝试不同的营销方法。甚至产品开发团队也开始尝试新的口味,错误地认为顾客厌倦了正在销售的口味。

直到几个月后,当 DS 团队被告知项目可能被取消时,才会进行深入调查。在调查 6 月中旬开始的模型预测时,我们发现倾向性使用优惠券的概率发生了显著的阶梯函数变化。当我们查看特征时,我们发现一些令人担忧的事情,如图 12.1 所示。

尽管这是一个特征漂移的滑稽例子,但它与我职业生涯中见过的许多例子相似。我很少遇到一个数据科学家没有在未被告知的情况下意外地遇到数据流变化的情况,而且我所经历或听说的许多情况与这个例子一样荒谬。

许多时候,这种变化会如此之大,以至于预测结果变得无法使用,在短时间内就会知道发生了重大变化。在某些罕见的情况下,如图 12.1 所示,如果没有自动化监控,这种变化可能会微妙且难以在较长时间内检测到。

12-01

图 12.1 通过数值缩放变化引起的特征漂移

对于这个用例,预测输出将为经历新现代冰河事件的客户做出推荐。模型输出的概率对于大多数用户来说可能非常低。由于从模型发送推荐的后预测触发器设置为 60%的倾向性,因此较低的概率将导致评估测试中的绝大多数客户不再收到电子邮件。由于特征监控正在测量平均值和标准差,简单的启发式控制逻辑就会捕捉到这一点。

另一种特征漂移的形式是特征无知。当我们的推理数据到达一个训练模型,而这个模型超出了模型训练的范围时,这种漂移就会发挥作用。例如,如果我们的模型是在 60°F 到 95°F(南加州)的温度范围内训练的,而由于转换为摄氏度,插补特征漂移到 20,基于树的模型将很好地处理这种情况。它们将把这些新值归入任何捕获最低温度范围(约 60)的决策标准中。

然而,对于线性模型来说,情况并非如此。模型的缺陷毕竟是一个线性模型中的方程。推理特征向量中的温度值将与系数相乘,然后加到或从训练期间确定的特征计算余数中减去。当值远远超出训练估计期间看到的范围时,预测可能会以完全意想不到的方式表现。

标签漂移

标签 漂移是一个相当难以追踪的问题。通常由几个关键(高重要性)特征分布的变化引起,标签的漂移可能会与业务的愿望背道而驰。

让我们假设由于数据收集不足,我们冰激凌倾向模型的一些方面开始受到我们不完全理解的潜在力量的影响。我们可以在相关性中看到似乎在驱动它的因素,因为它普遍降低了我们特征值中的一个方差。然而,我们无法将我们收集的数据特征与我们所看到的效果明确联系起来。我们看到的主要效果如图 12.2 所示。

12-02

图 12.2 标签漂移是预测分布的变化。

在这种分布变化下,我们可能会看到对业务的巨大影响。从机器学习的角度来看,模型在图 12.2 底部场景中的准确性(损失)理论上可能比初始训练时更好。这可能会使发现此类事件变得极其困难;从模型训练的角度来看,它可能看起来要好得多,更理想。然而,从业务角度来看,此类漂移事件可能会证明是灾难性的。

如果营销团队只在使用概率超过 90%的情况下发送定制电子邮件优惠券,会发生什么?这样的限制通常是由于成本(批量发送便宜,而定制解决方案对于服务来说要昂贵得多)。如果营销团队基于这个水平发送的阈值,在分析了模型预测的前几周结果后,它将为这些定制发送选择一个最佳的成本效益比。随着第二张图中标签漂移的发生,这意味着基本上所有测试组客户都会被纳入这个计划。这种成本的大幅增加可能会迅速使营销部门对项目失去兴趣。如果问题严重到足以让团队完全放弃对项目输出的利用。

密切关注模型输出随时间的变化分布,可以揭示潜在问题并确保输出的一致性。当结果发生变化(而且它们会的,请相信我),无论是看似积极的还是消极的,模型内部消费者可能没有准备好的后续影响。

总是最好进行监控。根据你解决的问题类型,如果你不持续监控解决方案输出的预测状态,对业务范围的影响可能会非常严重。标签漂移监控的实施应关注以下方面:

  • 预测的收集和存储

  • 对于分类问题

    • 定义一个用于聚合预测类别值的窗口时间,并存储每个值的计数。

    • 跟踪标签预测随时间变化的比率,并建立比率值的可接受偏差水平。

    • 进行等效性比较,使用如 Fisher 精确测试之类的算法,具有非常低的 alpha 值(< 0.01),比较最近值和模型生成期间计算的验证(测试)指标。

    • (可选)确定最近数据的概率质量函数(pmf),并与模型在训练期间生成的验证预测的pm f 进行比较。可以使用如 Fisher 非中心超几何测试之类的算法进行pmf离散分布的比较。

  • 对于回归问题

    • 通过捕获窗口数据的均值、中位数、标准差和四分位数间距(IQR)来分析最近预测的分布(根据预测的量和波动性,回溯天数和小时数)。

    • 为监测感兴趣的值设置阈值。当发生偏差时,提醒团队进行调查。

    • (可选)确定与连续预测最接近的分布拟合,并通过使用如 Kolmogorov-Smirnov 测试之类的算法比较此概率密度函数(pdf)的相似性。

概念漂移

概念 漂移 是一个具有挑战性的问题,可能会影响模型。用最简单的话说,它是一个引入了一个具有强烈影响模型预测的大隐含(未收集)变量的过程。这些影响通常在广义上表现出来,改变了一个训练模型用于插补的大多数,如果不是所有特征。继续我们的冰淇淋例子,让我们看看图 12.3。

12-03

图 12.3 概念漂移对模型性能、业务影响和归因测量的影响

我们测量并用于基于相关性的训练(天气数据、我们自己的产品数据和事件数据)的这些值,已被用来建立强大的相关性,以预测个人客户在周内购买冰淇淋的倾向。正如我们在第十一章中讨论的,我们无法收集的潜在变量对一个人购买决策的影响比我们收集的数据更强。

当未知因素正面或负面地影响模型输出时,我们可能会在我们的预测或模型归因测量中看到剧烈的变化,这正是这里的情况。追踪根本原因可能是非常明显(全球大流行)或微妙复杂(社交媒体对品牌形象的影响)。我们可以按照以下方式监控我们场景中的此类漂移:

  • 实施指标日志记录,包括

    • 主要模型误差(损失)指标

    • 模型归因标准(项目正在努力改善的业务指标)

  • 收集并生成预测的聚合统计信息(适用于适用的时间窗口):

    • 计数(预测次数、分组队列中的预测次数等)

    • 回归器的均值、标准差、四分位数间距

    • 计数(预测次数、预测的标签数量)和分类器的分桶概率阈值

  • 评估预测和归因测量随时间推移的聚合统计数据趋势。无法解释的漂移可能是模型重新训练或回归到特征工程评估(可能需要额外的特征来捕捉新的潜在因素效应)的依据。

无论原因如何,重要的是要监控这个问题的潜在症状:与被动重新训练相关的模型指标,以及与主动重新训练相关的模型归因数据。监控这些模型有效性的变化可以帮助进行早期干预,解释性分析报告,以及以不会对整个项目造成干扰的方式解决问题。

与其他类型的漂移(即特征和预测漂移)相比,答案可能并不明显。对于这种无法解释的漂移,生产监控的关键方面是它首先被捕捉到。如果对这种可能对模型性能的影响视而不见,那么根据用例,如果未加控制,可能会对业务产生巨大的影响。通过简单的 ETL 创建这些监控统计数据总是值得花费时间。

预测漂移

预测 漂移 与标签漂移高度相关,但有一个细微的区别,使得从这种类型的漂移中恢复需要遵循另一套行动。像标签漂移一样,它极大地影响了预测,但它与外部影响无关,而是直接与模型的一部分特征(尽管有时是以混淆的方式)相关。

让我们想象一下,我们的冰淇淋公司当时在训练我们的模型解决方案时,在美国太平洋西北地区的表现相当糟糕。由于缺乏训练数据,模型并不适合适应与该地区相关的极端少数特征数据。加上这个数据不足的问题,我们还不知道该地区的潜在未来客户是否喜欢我们的产品,因为同样的信息缺乏导致了探索性数据分析(EDA)的信息不足。

在新活动运行的前几个月,通过口碑提高知名度后,结果发现不仅太平洋西北地区的人们(和狗)非常喜欢我们的冰淇淋,而且他们的行为模式与我们的最活跃客户之一相当吻合。因此,我们的模型增加了向该国该地区客户发放优惠券的频率和速率。由于这种需求增加,模型开始向该地区的客户发放如此多的优惠券,以至于我们创造了一个全新的问题:库存问题。

图 12.4 显示了我们的模型无意中帮助创造的业务影响。虽然这本身并不是一个问题(它确实提高了收入!),但业务运营基础的意外驱动因素可能会引入需要解决的问题。

图 12.4 所示的情况确实是一个积极的局面。然而,在这种情况下,模型的影响不会在建模指标中显示出来。实际上,这可能会显示为一个相当等效的损失分数,即使我们在这个新数据上重新训练模型也是如此。归因测量分析是唯一能够检测这种情况并解释客户基础未来潜在变化的方法。

12-04

图 12.4 模型输出的高度有益的商业影响。这可能会引起其他问题,可能需要快速调整(尤其是如果发生相反条件时)。

从一般意义上讲,预测漂移通过特征监控过程处理。这组工具涉及许多以下概念:

  • 与最近值相比,每个特征的先验分布的分布监控,滞后一个适当的时间因子:

    • 计算特征在训练时间点的均值、中位数、标准差、四分位数范围。

    • 计算用于推理的最近特征统计指标。

    • 计算这些值之间的距离或百分比误差。

    • 如果这些指标之间的差异超过一个确定的水平,则向团队发出警报。

  • 分布等效性测量:

    • 将连续特征转换为训练期间的特征的概率密度函数(pdf)。

    • 将名义(分类)特征转换为训练期间的特征的概率质量函数(pmf)。

    • 利用 Wasserstein 度量或 Hellinger 距离等算法计算这些与最近(在训练过程中对模型不可见)的推理数据之间的相似性。

  • 为每个特征的基本统计指标指定统计过程控制(SPC)规则:

    • 基于 Sigma 的阈值水平,通过测量每个连续特征随时间的变化的平滑值(通常通过移动平均或窗口聚合)并在违反选定规则时发出警报。通常使用 Western Electric 规则。

    • 基于特征中分类或名义值的缩放百分比成员资格的 SPC 规则(按时间函数聚合)。

无论您选择哪种方法(或者如果您想选择全部方法),在训练过程中收集关于特征状态的信息的最重要的方面是,它允许进行监控并提前通知特征退化。

为了帮助跟踪这些统计指标,许多人(包括我自己)严重依赖 MLflow 的跟踪服务器。将值作为模型训练事件的一部分进行记录可以帮助确保该模型用于训练的历史记录得到保留,同时避免每次执行漂移验证时都要进行昂贵的(计算和时间的)历史计算。

现实漂移

我是在 2022 年 1 月 20 日写下这句话的。过去一年对冰淇淋行业来说是个艰难的年份。可以说,对人类来说,这也许是个艰难的一年。曾经是我们公司销售美味佳肴(冰淇淋车在社区、社区公园、体育赛事和狗公园附近巡逻)的主要手段,对我们来说并没有那么顺利。我们不得不重新评估我们的分销策略和营销信息,并在由于 Covid-19 的影响而造成的极其艰难的经济环境中努力。

现实漂移是概念漂移的一种特殊情况:虽然它是一种外部(未测量和不可预见的)影响,但这些基础性的转变对模型的有效性可能比一般的概念漂移有更深远和更大规模的影响。不仅仅是流行病会导致现实漂移。毕竟,马蹄铁制造商在 20 世纪的前几十年预测需求时也会遇到类似的问题。

这些事件在根本上是变革性和破坏性的,尤其是当它们是黑天鹅事件时。在最严重的情况下,它们对企业的损害可能如此之大,以至于一个功能失调的模型只是他们担忧的最坏情况;公司持续存在的问题更为紧迫。

对于更为温和的破坏性现实漂移,运行在生产中的机器学习解决方案通常受到相当大的冲击。由于无法识别哪些新特征可以解释业务中的潜在构造性变化,适应解决方案以处理大规模和即时变化成为了一个时间问题。简单地来说,没有足够的时间或资源来修复模型(有时甚至没有收集所需数据的可能性)。

当这类基础性的范式转变事件发生时,受到世界状态变化影响的模型应该面临两种命运之一:

  • 由于表现不佳和/或成本节约措施而放弃

  • 在进行广泛的特征生成和工程后重建模型

你绝对不应该做的事情是安静地忽视问题。预测很可能是无关的,盲目地在原始特征上进行再训练不太可能解决问题,让表现不佳的模型继续运行是代价高昂的。至少,需要对进入模型的特征的性质和状态进行全面评估,以确保其有效性仍然可靠。如果不以这种彻底的验证和验证方式来处理这些事件,模型(以及其他模型)被允许继续长时间产生未经审查的结果的可能性很小。

反馈漂移和收益递减定律

一种较少提及的漂移形式是反馈漂移。想象一下,我们正在为估计工厂制造的零件的缺陷密度开发一个建模解决方案。我们的模型是一个因果模型,我们的生产配方被构建成反映一个反映我们的生产过程的定向无环图。通过运行贝叶斯建模方法来模拟改变参数对最终结果(我们的产量)的不同影响,我们发现我们有一组看似最优的参数可以放入我们的机器中。

最初,模型显示的关系并不能导致最佳结果。随着我们进一步探索特征空间并重新训练我们的模型,模拟结果在测试启动时能更准确地反映预期的结果。在模型运行的前几个月,我们的产量稳定在近 100%。

通过控制我们在建模系统中存在的因果关系,我们已经在模型中有效地创建了一个反馈循环。可调整参数的方差缩小,如果我们为验证目的构建一个监督机器学习模型,它将学不到很多东西。这里根本就没有可以学习的信号(至少不是值得太多)。

这种影响并不在所有情况下都存在,因为因果模型比基于相关性的传统机器学习模型更容易受到影响。但在某些情况下,基于相关性的模型预测结果可能会污染我们新收集的特征,从而扭曲那些与实际发生观察结果收集的特征的效果。流失模型、欺诈模型和推荐引擎都高度易受这些影响(我们通过采取行动来促进积极结果和最小化消极结果,直接操纵客户的行为了)。

这是在许多监督学习问题中的一个风险,可以通过评估预测质量随时间的变化来检测。每次重新训练发生时,与模型相关的指标都应该被记录(MLflow 是一个很好的工具),并定期测量,以查看是否在将新特征数据包含到模型中时发生了退化。如果模型根本无法根据用于最近活动的验证数据将损失指标恢复到可接受的水平,那么你可能处于收益递减的领域。

对此现象的响应是重新审视特征工程工作(添加可以帮助模型学习新数据范式的数据)或重新审视项目。重新审视项目有时意味着最好将其关闭。一些问题可以通过利用机器学习来发现系统(或人)行为中的模式并在一段时间内完全解决,可以通过修改业务运营的方式取代。

12.2 对漂移的响应

我们已经介绍了如何通过在归因度量上使用适当的统计测试来计算模型影响,我们也讨论了导致我们的模型随时间变得不稳定的影响模型熵的类型。如果我们看到我们的冰淇淋优惠券模型在 12.1 节中定义的六种方式中的任何一种恶化,我们将使用什么过程来纠正它?

12.2.1 我们能对此做些什么?

所有这一切都始于监控。对于我们冰淇淋优惠券场景,这涉及到构建 ETL 流程,不仅包括我们的预测(安全存储每个批次的预测以供分析用途),还包括基本统计测量属性,用于设置关于模型健康状况的触发警报。

让我们回顾一下第十一章中作为特征输入到我们模型的外部温度测量。图 12.5 展示了通过在温度特征上设置三个单独的检查,我们可以如何检测底层数据中存在的问题。

此图仅作为视觉辅助。在实际操作中,警报将通过在数据上执行的计算进行配置,如果边界移动的幅度超过预先设定的阈值,则触发警报,这完全基于代码中编写的逻辑。然而,此图中所识别的三个区域是应该嵌入到监控代码中的规则的示例,这些规则可以提醒团队注意模型输入特征中存在的问题。

12-05

图 12.5 在特征周围设置阈值边界以警报大范围变化

第一个识别出的检测(基于平均值的阶跃变化警报)对于检测可能对模型预测能力造成问题的较大、意外的偏差非常有用。这类规则相对容易实现,可以配置阈值,并且是机器学习团队在新的数据到达时立即干预的有效早期预警系统。

第二种检测类型(数据方差值的阶跃变化)通常需要更多的时间来触发。同一值(温度)在不同尺度(摄氏度与华氏度)上的方差本质上是不同的。因此,数据的总方差将显示出明显的差异。然而,为了减少在离散时间段内出现假阳性警告的可能性,与方差监控相关的警报条件通常需要更长时间才能触发。

第三种指示类型,尽管与平均值的移动一致,但却是历史上未曾观察到的显著增加的变异性。当变异性测量中出现大峰值(通常远大于本例中第二个案例所监控的变化)时,有必要调查被测量的数据状态。

至少,为了防止模型有效性的缓慢熵减和如图 12.5 所示的基础颠覆性事件,我们需要测量我们模型的一些方面。特征监控、训练标签漂移测量、模型验证指标和归因指标都是构成有效策略以识别漂移的元素。

表 12.2 展示了我在不同行业中看到并参与过的常见建模类型,以及重新训练事件之前稳定性保持的一般估计,以供参考。

表 12.2 模型稳定性和对漂移的鲁棒性

应用 归因度量指标 重新训练周期性(近似)
持续流失预测 对高概率客户采取行动后的购买事件 每月
客户终身价值(CLV) 继续 CLV 群体成员的百分比 每周
稳定性
交通运输业 收入 每月
需求/定价 购买率
推荐引擎(个性化) 购买率或观看率 每小时或每日
图像内容标注 分类错误百分比 两到六个月
欺诈检测 损失事件计数 双周
损失金额
未检测到的欺诈事件计数
设备故障 维护成本(更换) 每半年或每年
预测(生存能力) 不必要维护的计数
销售预测 预测准确性回溯测试 每日或每周

如您所见,预测的重新训练周期性在各个应用中差异很大。表 12.2 并未反映这些计划之外的情况。即使有系统用于主动重新训练,在模型归因性能下降时触发创建新模型,也不能保证新模型的成功持续。漂移效应可能已经影响了旧模型和新模型(大多数情况下确实如此),以至于仅仅在新的数据上重新训练并不能将模型性能修复到可接受的水平。

对于被动计划的重新训练范式,如果归因测量没有密切监控,问题可能需要更长的时间才能显现。根据表 12.2 中提到的周期性(大致近似),漂移事件后的第一次计划重新训练通常会发现需要手动干预解决的问题。这可能包括重新审视项目的特征工程阶段,包括新特征,这些特征可以帮助模型适应现有特征中的新世界状态,或者是对最初构建项目所使用方法的彻底改革。

通过监控影响模型的因素,从特征指标和模型指标,到归因测量,我们可以识别出预测中存在的问题。然而,一旦我们识别出问题,我们又能做些什么呢?

12.2.2 对漂移的响应

对于之前在图 12.5 中展示的温度漂移示例,修复漂移条件时的响应是微不足道的。我们可以对旧数据进行特征转换,使其与新温度值的缩放一致。识别、隔离和修复明显且易于纠正的问题,嗯,很明显。只需修复并继续前进。

不幸的是,并非每个问题都如此简单。如果我们不能轻易地识别导致模型退化的原因怎么办?我们有四种主要方式来应对漂移:

  • 安排或触发的重新训练,对先前模型的结果进行验证,对新模型进行新验证数据的验证。保留最好的一个。

  • 对于明显的问题(例如,ETL 错误、基数爆炸或特征方差的变化),要么修复或缩放特征,重新训练模型,在新保留数据上验证其性能,然后在新模型上继续运行,就像以前一样。

  • 对于与前面列表项中提到的明显因素无关的预测退化问题,重新审视特征工程,进行探索性数据分析和相关性分析。确定是否需要添加任何新特征或删除现有特征。尝试重新训练并发布一个经过验证的新模型到生产环境中。

  • 如果模型显示出具有统计学意义的负面业务影响,立即停止使用该模型。尝试进行根本原因分析并修复问题(如果可能)。如果模型的好处不再存在,永久关闭它。

列表中的后三个元素相对容易理解。然而,第一个元素在重新训练的机制上具有一定的细微差别。启动重新训练事件有两种主要方式:被动和主动,如图 12.6 所示。

12-06

图 12.6 被动重新训练(计划)和主动重新训练(触发)以应对模型漂移

这两种启动模型重新训练的机制非常不同。在被动重新训练中,我们设置一个计划任务,该任务将使用我们的特征数据的一个滑动窗口来训练一个新模型(这种方法对于预测随时间快速变化的值的动态数据集非常有用)或者从时间开始的所有数据,包括之前的生产模型尚未看到的新数据。然后我们从最新数据中取出一组保留验证集,对当前先前的模型(我们的生产模型)和新的模型进行模型评估,评估它们在相同的保留验证数据上的性能。根据我们的模型指标,获胜者将被选为生产模型。

对于这种被动重新训练方法,我们通常会设置警报,以便在新的模型在多次迭代后没有被选为替换时通知我们。这是为了警告我们,最近的数据可能发生了根本性的变化,这可能表明需要重建特征工程集(这将是一项从被动重新训练周期中移除的活动)。

对于图 12.6 下半部分所示的活动重新训练实现,使用了一种持续监控的自动化解决方案,该解决方案测量与模型业务影响和预测质量(分布、方差、均值等)相关的属性。如果归因度量的监控检测到性能下降,将发生自动重新训练事件。与被动实现一样,会进行新触发的模型事件与最近保留数据对比当前运行模型与相同数据的比较。如果新模型表现更好,它将被选中提升到生产环境中(通常通过 CI/CD 自动化)。与被动方法一样,如果早期一代模型迭代连续失败,将触发 DS 团队的警报以进行调查。

选择被动或主动实现完全适用于在项目上工作的 ML 团队的大小、实现的稳定性、业务用例的性质以及团队的能力。对于 ML 项目来说,选择哪种解决方案无关紧要。唯一重要的是要理解,其中之一必须被选择。

将模型置之不理,假设它将继续像最初训练时那样良好地预测,而不采取任何进一步行动,这是灾难的预兆。那些没有考虑到重新训练、健康检查、监控和归因度量的项目注定会失败,因为它们的不相关性或积极的不良结果会对业务产生负面影响。

摘要

  • 监控漂移的主要类型——特征、标签、概念、预测、现实和反馈——对于确保解决方案的健康至关重要。

  • 通过被动或主动的方式重新训练是有效对抗漂移的方法。当这些尝试失败时,重新审视实现方式是至关重要的,以便引入处理漂移的新功能,以确保解决方案继续发挥作用。

13 机器学习开发傲慢

本章涵盖

  • 将重构应用于过度工程化的实现以提高开发速度

  • 确定要重构的代码

  • 建立以简单性为驱动的发展实践

  • 通过可持续的方式采用新技术

  • 比较实现中的构建、购买和先验艺术

前一章侧重于用于从纯粹预测和解决方案有效性角度衡量项目整体健康的临界组件。当然,旨在通过有效和详细监控其输入和输出以支持长期生存的机器学习项目肯定比那些没有这样做的项目有更高的成功率。然而,这仅仅是故事的一部分。

成功项目中的另一个主要因素与工作的这一人类方面有关。具体来说,我们需要考虑在解决方案的生命周期中支持、诊断项目代码库的问题、改进和维护项目代码库的人类。

注意 当项目发布到生产环境时,这仅仅是其生命周期的开始。机器学习的真正挑战是让某物在长时间内运行良好。

以下是人类元素的表现形式:

  • 代码的构建方式——其他人能否阅读并理解它?

  • 代码的性能——它是确定性的吗?它是否有无意之外的副作用?

  • 代码的复杂性——它是否过度或不足工程化以适应用例?

  • 改进的容易程度——机器学习代码处于不断的重构状态。

在本章中,我们将探讨需要注意的迹象,这些迹象定义了使机器学习代码库难以维护的模式。从炫耀的开发者的花哨代码(show-off developers)到构建帝国的框架创造者,我们将能够识别这些问题,看到替代方案,并理解为什么最有效的机器学习项目代码开发设计模式与其他项目的所有其他方面相同。

小贴士 只构建解决问题所需的复杂度。毕竟,人们必须维护这段代码。

为什么是“傲慢”?这有点侮辱性。

在经过长时间深思熟虑后,我选择“傲慢”这个词作为本章标题的一个组成部分,这是在两个不同时间版本的我之间进行的。一方面是我的当前自我,由于对自己技能的过度自信而感到失败的痛苦,构建了令人困惑的、以自我为中心的解决方案,追求虚荣的虚荣,以及在代码中吹嘘的人,他衡量项目成功的方式是其实施的巧妙性。另一方面是我年轻得多、刚开始在这个领域工作的版本,感觉自己不称职,就像我能想象到的一个人一样,是一个冒牌货。

我在是否使用“傲慢”这个词而不是“自负”上犹豫不决,但觉得那样做是不诚实的,也不适用于我们将在本章中讨论的内容(以及我希望我能与年轻时的自己进行一场长谈的内容)。“自负”更为适用。根据定义,它是指拥有过度的骄傲和自信。请注意,这并不是关于拥有骄傲(当我们在我们职业中解决一个复杂问题时,我们都应该感到骄傲),而是过度地拥有它。

当我们作为数据科学家表现出傲慢的倾向时,我们往往会构建过于复杂的解决方案来解决问题。无论是因为自负、虚荣,还是简单地想要向同伴证明我们的技能足够高(通常是由于冒充者综合症或过去被我们写过的某些垃圾代码所伤害),最终结果都是一样的:遗憾。我们最终构建了难以维护、令人困惑、过于复杂且无法扩展的解决方案,这些解决方案有很大概率会导致项目失败或让我们的同伴感到沮丧,并且害怕有一天我们必须调试代码中的故障。

本章涵盖了我在追求代码简洁性过程中学到的一些危险方法,定义了可持续的机器学习开发模式,希望这些模式能帮助你避免我多年来犯的一些痛苦错误。

13.1 优雅的复杂性 vs. 过度设计

暂时想象一下,我们正在启动一个新的项目。这并不太偏离前两章的内容(剧透:这与狗有关)。我们有一些关于狗的数据。我们知道它们的品种、年龄、体重、最喜欢的食物,以及它们是否通常具有讨人喜欢的性格。此外,我们还拥有标记数据,这些数据衡量了每只狗在走进我们的宠物店连锁店时是否表现出饥饿的迹象。

带着这些数据,我们想要构建一个模型,根据我们犬类消费者的注册数据预测,当他们通过结账线时,我们是否应该给他们提供零食。

注意:是的,我完全清楚这有多愚蠢。不过,这会让我的妻子笑出声,所以这个场景还是保留着。

当我们开始调查数据时,我们意识到我们拥有真正巨大的训练数据量。数以亿计的行数据。然而,我们希望利用所有这些数据来训练模型,因此我们的平台决策为我们留下了简单的选择:Apache Spark。

由于我们在这本书中广泛使用了 Python,让我们利用本章深入探讨另一种广泛用于大规模(就训练行数量而言)机器学习项目的语言:Scala。由于我们将使用 Spark 的 ML 库,为了有效地从我们的列式数据中构建特征向量,我们需要识别任何非连续的数据类型并将它们转换为索引整数值。

在我们进入展示本节主题差异的代码示例之前,让我们讨论一下机器学习编码实践的比例。我喜欢将开发风格(就代码复杂性而言)视为一种微妙的平衡行为,如图 13.1 所示。

13-01

图 13.1 在软件开发实践这两种极端之间取得平衡可以导致更有效和更稳定的工程项目工作。

在这个尺度的右侧,我们有非常轻量级的代码。它高度声明性(几乎像脚本一样),单调(语句被多次复制粘贴,参数略有变化),并且耦合紧密(更改一个元素意味着在代码中搜索并更新所有基于字符串的配置引用)。

这些轻量级代码库往往看起来像是不同公司团队编写的。在许多情况下,确实如此,因为整个函数和代码片段被完整地从流行的开发者问答论坛中提取出来。它们共享的一个附加特性是对高度流行的框架和工具的依赖,这些框架和工具有很好的文档(或者至少足够复杂,以至于在上述开发者论坛上提供了足够的问题和答案,可以自由借用),无论用例是否合适。以下是这种行为的几个关键标识符:

  • 当训练数据集有数千行和数十列时,使用为大规模机器学习设计的框架。(例如,而不是使用 SparkML,坚持使用 pandas,并在广播模式下使用 Spark 进行训练。)

  • 当请求量永远不会超过每分钟几个请求时,在大型服务架构之上构建实时服务。(而不是使用带有 Seldon 的 Kubernetes,请在 Docker 容器中构建一个简单的 Flask 应用。)

  • 当每小时需要处理几百个预测,且服务等级协议(SLA)可以以分钟为单位衡量时,设置一个用于大规模微批预测的流式摄取服务。(而不是使用 Kafka、Spark Structured Streaming 或 Scala 用户定义函数,请使用 Flask 应用。)

  • 使用 GPU 硬件上运行的 LSTM 构建时间序列预测模型,使用 Horovod 多 GPU 群组调度模式,对于可以预测出单数 RMSE 值的多变量时间序列。 (使用 ARIMA 模型,并选择远更便宜的基于 CPU 的虚拟机。)

然而,在刻度的一侧,是它的完全对立面。代码密集、简洁、高度抽象,通常复杂。左侧可以在某些团队和组织中工作,但总体来说,它是多余的、令人困惑的,并且由于理解高级语言特性所需的经验,限制了可以贡献项目的人数。一些机器学习工程师在处理足够大且复杂的机器学习开发轻量级脚本式风格的项目后,可能会在后续项目中追求左侧的重型代码方法。他们在维护脚本式风格和所有存在的广泛耦合方面遇到的挑战可能会导致抽象操作器的爆炸性增长,迅速接近构建通用框架。我可以相当诚实地说我就是那个人,这在图 13.1 的底部可以看到我的旅程。

在图表的中间,坐着一个平衡的方法,它为团队开发风格的长期成功提供了最大的可能性。让我们看看我们的代码在开始使用这两种竞争的极端对立面时可能的样子。

13.1.1 轻量级脚本式风格(命令式)

在我们深入探讨编写原型机器学习模型的简约声明式代码之前,让我们简要地看一下我们的数据是什么样的。表 13.1 显示了数据集的前五行样本。

表 13.1 我们饥饿狗数据集的数据样本

年龄 体重 最喜欢的食物 品种 好男孩或女孩 饥饿
2 3.05 Labneh Pug No True
7 20.44 Fajitas Dalmatian Sometimes False
5 11.3 Spaghetti German Shepherd No True
3 17.9 Hummus Estrela Yes False
8 55.6 Bolognese Husky 是的,当有食物时 True

我们可以清楚地看到,我们的大部分数据都需要编码,包括我们的标签(目标)hungry

让我们看看我们如何通过构建向量和运行简单的DecisionTreeClassifier来处理这些编码,使用 SparkML 的 Pipeline API。这些操作的代码如下所示。(参见“为什么选择 Scala?”侧边栏,了解为什么我选择用 Scala 而不是 Python 来展示这些示例。)

列表 13.1 命令式模型原型

import org.apache.spark.ml.feature.{StringIndexer, 
 VectorAssembler, 
 IndexToString}
import org.apache.spark.ml.classification.DecisionTreeClassifier
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator
import org.apache.spark.ml.Pipeline
val DATA_SOURCE = dataLarger                              ❶
val indexerFood = new StringIndexer()
  .setInputCol("favorite_food")
  .setOutputCol("favorite_food_si")
  .setHandleInvalid("keep")
  .fit(DATA_SOURCE)                                       ❷
val indexerBreed = new StringIndexer()
  .setInputCol("breed")
  .setOutputCol("breed_si")
  .setHandleInvalid("keep")
  .fit(DATA_SOURCE)                                       ❸
val indexerGood = new StringIndexer()
  .setInputCol("good_boy_or_girl")
  .setOutputCol("good_boy_or_girl_si")
  .setHandleInvalid("keep")
  .fit(DATA_SOURCE)
val indexerHungry = new StringIndexer()
  .setInputCol("hungry")
  .setOutputCol("hungry_si")
  .setHandleInvalid("error")
  .fit(DATA_SOURCE)                                       ❹
val Array(train, test) = DATA_SOURCE.randomSplit(
  Array(0.75, 0.25))                                      ❺
val indexerLabelConversion = new IndexToString()
  .setInputCol("prediction")
  .setOutputCol("predictionLabel")
  .setLabels(indexerHungry.labelsArray(0))
val assembler = new VectorAssembler()
  .setInputCols(Array("age", "weight", "favorite_food_si", 
    "breed_si", "good_boy_or_girl_si"))                   ❻
  .setOutputCol("features")
val decisionTreeModel = new DecisionTreeClassifier()
  .setLabelCol("hungry_si")
  .setFeaturesCol("features")
  .setImpurity("gini")
  .setMinInfoGain(1e-4)
  .setMaxDepth(6)
  .setMinInstancesPerNode(5)
  .setMinWeightFractionPerNode(0.05)                      ❼
val pipeline = new Pipeline()
  .setStages(Array(indexerFood, indexerBreed, indexerGood, 
    indexerHungry, assembler, decisionTreeModel, 
    indexerLabelConversion))                              ❽
val model = pipeline.fit(train)                           ❾
val predictions = model.transform(test)                   ❿
val lossMetric = new BinaryClassificationEvaluator()      ⓫
  .setLabelCol("hungry_si")
  .setRawPredictionCol("prediction")
  .setMetricName("areaUnderROC")
  .evaluate(predictions)

❶ dataLarger 是一个包含表 13.1 中样本的完整数据集的 Spark DataFrame。

❷ 索引第一个字符串类型的列(品种)并创建一个基于发生频率的 0th 有序降序排序的新索引

❸ 为下一个分类(字符串)列构建索引器(很高兴只有四个,对吧?)

❹ 为目标(标签)列构建索引器

❺ 创建训练和测试分割

❻ 定义将用于特征向量的字段(列)

❼ 构建决策树分类器模型(为了简洁,超参数硬编码)

❽ 定义操作顺序并包装在管道中(在实验过程中进行了大量修改)

❾ 将管道拟合到训练数据上(执行管道的所有阶段,返回处理步骤以及作为单个操作对象返回的模型)

❿ 针对测试数据进行预测,以进行评分

⓫ 计算评分指标(在这种情况下,为 areaUnderROC)并返回指标值

这段代码看起来相对熟悉。这是我们查看特定建模框架的 API 文档时都会看到的。在这种情况下,是 Spark,但任何特定框架都有类似的例子。它是一种命令式风格,这意味着我们在代码中直接提供执行步骤,保留了我们一步步执行此步骤的方式。虽然这使得代码非常易于阅读(这就是为什么入门指南中的示例使用这种格式),但在实验和 MVP 开发过程中,修改和扩展代码却是一个噩梦。

为什么选择 Scala?

嗯,我们主要使用 Scala 是因为 Spark。Python 是 Spark 的官方语言,Spark 完全支持 Python,但 Spark 的后端(即香肠是如何制成的底层细节)是用 Scala 编写的。Python API 仅仅是 Scala API 的包装器(接口),因此,如果需要将比DataFrame API 更底层的功能接口化,我们必须在 Scala 或 Java 中这样做。

在 Spark 中使用 Python 还是 Scala 的选择通常取决于以下简短的因素列表:

  • 对 Java(或 Scala)的熟悉程度与 Python 相比

  • 需要执行复杂的数据操作,这些操作不能直接通过DataFrame API 的函数模块支持——通过使用用户定义的函数、弹性分布式数据集(RDD)操作或开发自定义评估器和转换器

  • 需要使用自定义分布式算法来解决特定问题(例如,在撰写本书时,XGBoost 仅作为 Scala/Java 库可用)

“但为什么在这本书中使用 Scala?”

这是一个非常好的问题。主要是因为在行业中存在一个庞大的沉默群体,他们是机器学习工程师,他们更倾向于使用 Scala 来处理他们的机器学习任务,尤其是在处理极其庞大的数据集时。(由于使用 Scala 的代码比 Python 等更宽容的语言的入门门槛更高,因此在互联网搜索结果中很难找到关于 Scala 和 Java 使用的问题。)我将 Scala 包括在本章中,是为了展示一种与大多数人熟悉的方法略有不同的机器学习代码开发方法,以期激发好奇心并拓宽你的视野。虽然如果你只习惯于 Python,这种语言可能看起来很陌生,但请让我向你保证,学习它可以是一项有益的尝试,并且可以帮助你作为一个专业机器学习工程师的技术储备得到扩展(它为你提供了一套在 Python 中可能难以解决的工具,这些工具可能非常困难)。

使用 Scala 而不是 Python 在 Spark 中进行机器学习存在许多其他、更底层和工程导向的原因。这些原因与以下主题相关——并发、线程管理和在 JVM 上直接利用堆内存——这些主题是为机器学习空间中的算法开发者保留的。对于 Spark 的最终用户,进行与机器学习相关的工作,Python 无疑是广泛接受的标准。然而,话虽如此,了解另一种语言总是好的,因为在 5%的情况下,你可能别无选择,只能使用 Scala(此外,它是一种优雅且有趣的编程语言!)。

当我开始从事机器学习项目工作时,我从未意识到以这种命令式风格编写代码会有多大的挑战。我的大部分代码看起来就像 13.1 列表中的那样。那么,如果我承认在作为数据科学家职业生涯早期几十个项目中都做过这样的事情,为什么我还要强调这一点呢?

如果在我们的实验和测试过程中,我们发现需要向这个模型添加更多特征怎么办?如果我们进行了广泛的探索性数据分析(EDA),并发现可以包含 47 个额外的特征,这可能会使模型的表现更好怎么办?如果它们都是分类的怎么办?

然后,如果我们按照 13.1 列表中所示的使用命令式设计风格构建代码,它将变成一个难以管理的文本墙。我们将使用浏览器或 IDE 中的查找功能来了解代码中需要更新内容的位置。仅VectorAssembler构造函数本身就会开始成为一个庞大的字符串数组,这将很难维护。

以这种方式编写复杂的代码库是容易出错的、脆弱的,并且会引起头痛。虽然之前提到的理由在项目的实验和开发阶段已经足够糟糕,但想想如果源数据发生变化(源系统中的列被重命名)会发生什么。我们有多少地方需要在代码库中更新?我们能否在值班时及时找到所有这些地方并恢复工作,以免预测服务中断?

我已经经历过那种生活。在我意识到缺乏新的预测并成为问题之前,我修复东西(调整代码库以支持数据上游发生的根本性变化)的成功率,当时还不到 40%。

因此,在经历了这些挫折之后,我致力于在那个摇摆不定的平衡平面上跳舞到完全相反的一侧。我通过拥抱极端抽象和面向对象原则,成为了自己(以及我的团队)的最坏敌人,并真正认为通过产生极其复杂的代码,我正在做正确的事情。

13.1.2 过度设计的混乱

那么,年轻的本建造了什么呢?他建造了以下类似的结构。

列表 13.2 过度复杂的模型原型

case class ModelReturn(
                      pipeline: PipelineModel,
                      metric: Double
                     )                                                  ❶
class BuildDecisionTree(data: DataFrame,                                ❷
                      trainPercent: Double,
                      labelCol: String) {
  final val LABEL_COL = "label"                                         ❸
  final val FEATURES_COL = "features"
  final val PREDICTION_COL = "prediction"
  final val SCORING_METRIC = "areaUnderROC"
  private def constructIndexers(): Array[StringIndexerModel] = {        ❹
    data.schema
      .collect {
        case x if (x.dataType == StringType) & (x.name != labelCol) => x.name
      }
      .map { x =>
        new StringIndexer()
          .setInputCol(x)
          .setOutputCol(s"${x}_si")
          .setHandleInvalid("keep")
          .fit(data)
      }
      .toArray
  }
  private def indexLabel(): StringIndexerModel = {                      ❺
    data.schema.collect {
      case x if (x.name == labelCol) & (x.dataType == StringType) =>
        new StringIndexer()
          .setInputCol(x.name)
          .setOutputCol(LABEL_COL)
          .setHandleInvalid("error")
          .fit(data)
    }.head
  }
  private def labelInversion(                                           ❻
    labelIndexer: StringIndexerModel
  ): IndexToString = {
    new IndexToString()
      .setInputCol(PREDICTION_COL)
      .setOutputCol(s"${LABEL_COL}_${PREDICTION_COL}")
      .setLabels(labelIndexer.labelsArray(0))
  }
  private def buildVector(                                              ❼
    featureIndexers: Array[StringIndexerModel]
  ): VectorAssembler = {
    val featureSchema = data.schema.names.filterNot(_.contains(labelCol))
    val updatedSchema = featureIndexers.map(_.getInputCol)
    val features = featureSchema.filterNot(
      updatedSchema.contains) ++ featureIndexers
      .map(_.getOutputCol)
    new VectorAssembler()
      .setInputCols(features)
      .setOutputCol(FEATURES_COL)
  }
  private def buildDecisionTree(): DecisionTreeClassifier = {           ❽
    new DecisionTreeClassifier()
      .setLabelCol(LABEL_COL)
      .setFeaturesCol(FEATURES_COL)
      .setImpurity("entropy")
      .setMinInfoGain(1e-7)
      .setMaxDepth(6)
      .setMinInstancesPerNode(5)
  }
  private def scorePipeline(testData: DataFrame, 
pipeline: PipelineModel): Double = {
    new BinaryClassificationEvaluator()
      .setLabelCol(LABEL_COL)
      .setRawPredictionCol(PREDICTION_COL)
      .setMetricName(SCORING_METRIC)
      .evaluate(pipeline.transform(testData))
  }
  def buildPipeline(): ModelReturn = {                                  ❾
    val featureIndexers = constructIndexers()
    val labelIndexer = indexLabel()
    val vectorAssembler = buildVector(featureIndexers)
    val Array(train, test) = data.randomSplit(
Array(trainPercent, 1.0-trainPercent))
    val pipeline = new Pipeline()
      .setStages(
        featureIndexers ++ 
        Array(
          labelIndexer,
          vectorAssembler,
          buildDecisionTree(),
          labelInversion(labelIndexer)
        )
      )
      .fit(train)
    ModelReturn(pipeline, scorePipeline(test, pipeline))
  }
}
object BuildDecisionTree {                                              ❿
  def apply(data: DataFrame,
            trainPercent: Double,
            labelCol: String): BuildDecisionTree =
    new BuildDecisionTree(data, trainPercent, labelCol)
}

❶ 为从主方法返回签名(返回管道和评分指标)中提取数据定义的案例类

❷ 包含模型生成代码的类。在项目的早期阶段(在这个复杂度级别),生成它是多余的。在方法内重构依赖关系将比命令式脚本更复杂。

❸ 将使用它们的常量外部化(最终的生产代码将把这些放在它们自己的模块中)

❹ 在 DataFrame 的模式内容上创建映射,并将 StringIndexer 应用于任何不是标签(目标)字段且为字符串类型的字段。

❺ 如果标签(目标)是字符串类型,则生成 String 索引器的函数。请注意,这里没有处理其他值,因此还没有构建完整的泛型实现。

❻ 标签反转器,将标签转换回原始值。在这个实现中,没有检查处理如果目标值不符合索引标准的情况。在这种情况下,此代码将抛出异常。

❼ 通过操作列列表和类型来生成特征向量的动态方法。这不包括除了数值和字符串类型之外的其他类型的数据,这些类型的数据不会被包含到特征向量中。

❽ 这个决策树分类器的超参数是硬编码的。虽然只是一个占位符,但在这个编码风格中,所需的重构将非常广泛。由于这是一个私有方法,主方法签名将需要将这些值作为参数传递,或者类构造函数需要在实例化时传递这些值。这是一个糟糕的设计。

❾ 虽然这是一种基于传入数据的构建管道的相对灵活的设计,但其他人要参与其中可能会很具挑战性,需要密切关注在管道构造函数中插入额外阶段时需要发生的操作顺序。

❿ 类的伴随对象。这当然应该等到项目的最终 API 设计完成后再进行。

这段代码一开始可能看起来并不荒谬。毕竟,如果我们考虑在模型的特征向量中添加更多功能,它将大大减少冗余。实际上,如果我们为模型添加甚至 1,000 个额外的特征,代码将保持不变。这似乎是采用这种方式编写机器学习代码的一个明显优势。

如果我们需要为某些字段而不是其他字段对 StringIndexer 进行不同的行为,会发生什么?假设某些字段可能支持将无效键(训练期间未出现的分类值)附加到通配索引值,而其他字段则不能。在这种情况下,我们不得不大量修改此代码。我们需要抽象方法 constructIndexers() 并使用 case 和 match 语句为不同类型的列生成索引器。然后我们可能需要修改传递给包装方法的签名参数,以包括字段名称和如何处理键存在验证的元组(或案例类定义)。

虽然这种方法在扩展性方面表现良好,但在实验阶段进行时却显得相当繁琐。我们不是专注于验证针对不同模型类型运行的不同实验的性能,而是花费大量时间重构我们的类,添加新方法,抽象复杂性,并可能是在追求一个可能根本无法成功的想法。

以这种方式(高抽象和泛化)进行原型设计工作,在考虑生产力时可能会带来灾难。在项目的早期阶段,最好采用一种更简单的编码风格,以支持快速迭代和修改。向 13.2 列表中展示的风格更适用于项目的最终预发布阶段(代码加固),特别是在最终项目解决方案的组件已知、定义明确且可以识别为代码库中必要组件的情况下。作为一个如何处理这些开发阶段工作的例子,请参阅图 13.2。

13-02

图 13.2 通过分阶段机器学习开发避免重构地狱

由于原型设计的极端可变性(一切都很灵活,元素需要快速更改),我通常坚持使用最小化命令式编程技术。随着项目开发逐步转向生产构建,越来越多的复杂逻辑被抽象为可维护和可重用的部分,分别放在不同的模块中。

在早期过程中构建过度工程化和过于复杂的代码架构,如列表 13.2 所示,将创建封闭的场景,使得为功能增强进行重构变得极其复杂。在项目早期追求过度工程化的开发方法只会浪费时间,让团队感到沮丧,并最终导致一个更加复杂且难以维护的代码库。

不要像我一样做。看起来很花哨的代码,尤其是在开发初期,只会给你带来问题。选择追求最简单和最简约的实现,当你需要扩展时,可以打开扩展的大门,当你编写生产代码时,可以拥有一个结构统一的代码结构,并且更容易调试的代码库,不会充满技术债务(以及数十个永远不会修复的 TODO 语句)。

13.2 非故意混淆:如果你没有编写它,你能读懂它吗?

一种相当独特的形式的机器学习傲慢体现在代码开发实践中。有时是恶意的,许多时候是由自我(以及被尊敬的愿望)驱动的,但大多数情况下是由于缺乏经验和恐惧,这种特定的破坏性活动通过创建难以理解的复杂代码而形成。

对于我们的场景,让我们看看一个常见且有些简化的任务:将数据类型重新转换为支持特征工程任务。在这个比较示例的旅程中,我们将查看一个需要修改其特征(以及目标字段)类型以支持管道启用处理阶段以构建模型的数据库。这个问题在其最简化的实现中,将在下一个列表中展示。

列表 13.3 强制类型转换

def simple(df: DataFrame): DataFrame = {                  ❶
  df.withColumn("age", col("age").cast("double"))         ❷
    .withColumn("weight", col("weight").cast("double"))   ❸
    .withColumn("hungry", col("hungry").cast("string"))   ❹
}

❶ 通过返回 DataFrame 封装传入 DataFrame 的修改

❷ 将年龄列从原始的整数类型转换为 Double 类型(仅用于演示目的)

❸ 确保权重列的类型为 Double

❹ 将目标列从布尔类型转换为字符串类型,以便编码器可以工作

从这个相对简单且强制风格的 DataFrame 中字段转换的实现,我们将查看混淆的示例,并讨论每个可能对如此简单的情况产生的影响。

注意:在下一节中,我们将探讨一些机器学习工程师在编写代码时的一些坏习惯。必须指出的是,列表 13.3 的方法和实现并不是为了贬低。在构建机器学习代码库时(只要代码库没有紧密耦合,如果一列发生变化,则需要数十次编辑),强制方法没有任何问题。只有当解决方案的复杂性使得修改强制代码成为一种负担时,它才成为一个问题。如果项目足够简单,就坚持使用更简单的代码。当你需要修改并添加新功能时,你会感谢自己的简单性。

13.2.1 混淆的多种形式

本节通过一个滑动复杂度量表进行,代码示例变得越来越难以理解,越来越复杂,并且越来越难以维护。我们将分析一些开发者的不良习惯,以帮助您识别这些编码模式,并指出它们是什么——对生产力具有破坏性,并且绝对需要重构以保持可维护性。

如果您发现自己陷入这些兔子洞之一,这些示例可以作为一个提醒,不要遵循这些模式。但在我们来看例子之前,让我们看看我在开发习惯方面看到的角色,如图 13.3 所示。

13-03

图 13.3 机器学习代码开发中的不同角色。远离中心区域在未来很可能给团队带来很多问题。

这些角色并不是为了识别特定的人,而是描述 DS 在成为更好的开发者过程中可能经历的特质。我遇到的大多数人(包括我自己)一开始都是作为黑客编写代码。我们会发现自己卡在一个以前从未遇到过的问题上,然后立刻在网上搜索解决方案,复制别人的代码,如果它有效,就继续前进。(我并不是说在网上或书中寻找信息是坏事;即使是经验最丰富的开发者也经常这样做。)

随着编码经验的加深,有些人可能会倾向于其他三种编码风格之一,或者如果他们得到了适当的指导,可以直接移动到中心区域。有些人有东西要证明——通常只是对自己,因为大多数人只是希望他们的同伴编写出来自善良的开发者的代码。其他人可能认为代码行数最少是一种有效的开发策略,尽管在这个过程中他们牺牲了可读性、可扩展性和可测试性。图 13.4 展示了我遇到(以及个人经历)的这些模式。

13-04

图 13.4 成为更好的开发者的路径

这条曲折的道路导致在达到智慧经验的顶峰之前,实现变得越来越复杂且不必要地复杂。在这次旅程中,我们所能期望的最好的事情是能够识别并学习更好的路径——特别是,对于一个问题(仍然满足任务要求)的最简单解决方案总是解决问题的最佳方式。

我作为开发者的个人成长历史

我在整个职业生涯中的成长路径几乎触及了图 13.4 中展示旅程的每一个方面。这主要是由自负驱动的,但也由于学会了如何在以软件工程为重点的公司之外用代码解决问题,因此在旅途中不断犯错,通过艰难的方式学习东西。我曾是一名黑客(不是像 1995 年电影《黑客》中那样著名、酷炫的那种,电影中有“太酷了以至于不上学”的乔尼·李·米勒,一个短暂的神秘主义者,多年的炫耀者,几个项目上的疯狂科学家(这让我未来的自己感到非常沮丧,因为我不得不修复我无法理解的代码),最终,我一直在努力保持一个中立的善良开发者。

我提到这一点是为了说明,这条旅程正是我所说的那样:一个不断且充满西西弗斯式挣扎的简单设计和连贯代码的追求。这可能是值得忍受的最崇高的斗争之一。追求编写更干净、更简单的代码不仅对你的团队和公司有益,而且可能是你能给予未来自己(他必须修复或改进代码库)的最慷慨的礼物。所有那些在写作时看似好主意,但实际上并非如此的高明技巧、简洁的一行代码、自我满足的复杂设计模式,以及难以置信的复杂实现。

我不得不通过艰难的方式反复学习这一点。我的唯一建议是,从我的例子中学习,并能够识别出你或你合作的他人是否正在走向这些有害的开发模式。点亮灯塔,让人们回归简单,你的项目将更加成功。

在接下来的几节中,我们将查看列表 13.3 的版本,其中我们试图重新构建 Spark DataFrame中的某些列,以便为特征工程转换做准备。这似乎是一个简单的任务,但到本节结束时,希望你能看到有人通过创建不同类型的令人困惑(并且可能非常糟糕)的实现方式,有多么“聪明”。

黑客

黑客心态在很大程度上是源于缺乏经验和对软件开发概念(无论是机器学习还是其他)的完全不知所措。许多处于这种开发模式的人担心在构建解决方案或理解其他团队成员的解决方案是如何构建的时寻求帮助。被称为“冒名者综合症”的令人痛苦的不适感,如果他们没有得到有效的指导和团队的接受,可能会限制这个人的成长潜力。

他们许多项目或对项目的贡献可能感觉完全脱节,在调性上也不和谐。这看起来像是不同的人参与了他们提交的拉取请求中的代码编写。很可能确实如此:Stack Overflow 的匿名贡献者。

图 13.5 总结了我多年前开始编写完整项目代码时的一些想法。我询问过其他初级数据科学家,在他们代码的特别粗糙的同行评审之后,是什么促使他们从 Stack Overflow 复制代码,他们的思考过程也在此处进行了概述。

13-05

图 13.5 黑客思维模式,创建混乱和不稳定的代码库,这是我们所有人开始机器学习的地方。

黑客的代码看起来像是一块拼布。缺乏连贯的结构、不一致的命名约定以及不同的代码质量程度可能会在代码或同行评审提交中反复被标记。如果编写了单元测试,对代码的测试可能会显示出实现中的许多脆弱点。

列表 13.4 展示了黑客类型开发者可能为解决列转换问题提出的解决方案示例。虽然这并不直接表明是一个拼凑的状态,但它确实充满了反模式。

列表 13.4 黑客尝试转换列

def hacker(df: DataFrame, 
  castChanges: List[(String, String)]): DataFrame = {       ❶
  var mutated = df                                          ❷
  castChanges.foreach { x =>                                ❸
    mutated = mutated.withColumn(x._1, 
   mutated(x._1).cast(x._2))                                ❹
  }
  mutated                                                   ❺
}
val hackerRecasting = hacker(dogData, List(("age", "double"), 
  ("weight", "double"), 
  ("hungry", "string")))                                    ❻

❶ 函数参数 castChanges 很奇怪。元组列表代表什么?

❷ 在这种情况下,修改对象不被认为是良好的实践。DataFrame 本质上是不可变的,但将其声明为 var 允许在 foreach 迭代器中支持这种黑客式的链式方法。

❸ 遍历传入的元组列表

❹ 元组的定位表示法令人困惑,极易出错,难以理解,并可能在 API 使用中引发挫败感。(如果数据类型和列名被交换会发生什么?)

❺ 返回修改后的 DataFrame 将仍然保持封装性,但这是一种代码嗅探。

❻ 使用繁琐的元组列表定义作为 castChanges 参数的示例用法

在这段代码中,我们可以看到显示的逻辑与 Python 固有的可变性质相似。这位开发者没有研究如何安全地迭代集合以将链式方法应用于对象,而是在 Scala 中实现了一个强烈的反模式:修改共享状态变量。此外,由于函数的参数castChanges没有关于那些String值的概念(哪一个应该是列名,哪一个被转换成数据类型),使用这个函数的用户将不得不查看源代码来理解哪一个应该放在哪里。

在同事的工作中识别这些代码异味至关重要。无论这些人是否是团队(或职业)的新人,或者有丰富的经验但只是“应付差事”,都应该努力帮助他们。这是一个与团队成员一起工作的完美机会,帮助他们提高技能,在这个过程中,建立一个由工程师组成的更强大团队,他们都在创建更易于维护和更稳定的代码。

神秘主义者

随着我们逐渐在 ML 软件开发中提高技能和接触新概念,下一个合乎逻辑的旅程是学习 FP 技术。与传统的软件开发不同,大量的 DS 编码工作适合于函数式组合。我们摄取数据结构(通常表示为数组集合),对它们进行操作,并以封装的方式返回修改后的数据状态。我们的许多操作都是基于对数据进行算法应用,无论是通过直接计算值还是通过结构转换。在很大程度上,我们的代码库的大部分内容都可以用无状态 FP 风格编写。

在其核心,ML 中的许多任务都是函数式的。将函数式编程技术应用于我们执行的大多数操作确实有很强的理由。神秘 开发者角色并不是一个选择性选择合适位置使用 FP 范式的人。相反,他们投入时间和精力使整个代码库函数化。他们以弱状态的形式将配置单子传递给函数,牺牲了组合,以近乎狂热的热情来遵守 FP 标准。为了说明,图 13.6 展示了我发现 FP 以及它能为代码库带来的所有奇迹时的思维过程。

13-06

图 13.6 FP 纯粹主义者(神秘)的内心世界

当我开始学习 FP 概念,尽力将所有代码转换为这种标准时,我发现它的简洁性令人解放,高效且优雅。我喜欢无状态编码的简单性和纯封装的纯粹性。在我早期的拙劣代码中,突变状态的副作用问题消失了,取而代之的是光滑且风格化的 mapflatmapreducescanfold。我绝对喜欢将容器化和定义泛型类型作为减少我需要编写、维护和调试的代码行数的方式。一切似乎都更加优雅。

在以这种方式重构代码的过程中,我成功地激怒了那些正在查看每个重手重构的其他人。他们正确地指出我增加了代码库的复杂性,以不需要解耦的方式解耦函数,并且通常使代码更难以阅读。要了解这种实现风格对我们列铸造的适用性,请参阅以下列表。

列表 13.5 纯函数式编程方法

def mystic(df: DataFrame,
          castChanges: List[(String, DataType)]
  ): DataFrame = {                               ❶
  castChanges.foldLeft(df) {                     ❷
    case (data, (c, t)) => 
      data.withColumn(c, df(c).cast(t))          ❸
  }
}
val mysticRecasting = mystic(dogData, 
  List(("age", DoubleType), 
  ("weight", DoubleType), 
  ("hungry", StringType)))                       ❹

❶ 函数签名中的 castChanges 参数比黑客的实现更安全。通过要求传递一个 DataType 抽象类,通过此函数引入意外错误的可能性降低了。

❷ 使用 foldLeft(映射 castChanges 集合并对传入的 DataFrame df 应用累加器)可以使 DataFrame 的突变状态比黑客方法更高效。

❸ 使用案例匹配来定义传入参数castChanges的结构,允许消除黑客实现中存在的复杂(且令人烦恼)的位置引用。这段代码要干净得多。

使用该函数与黑客实现相比在打字上节省不多,但你可以看出,为铸造转换类型定义这些类型如何使该函数的使用更加便捷。

正如你所见,这个实现具有明显的函数式特性。从技术角度讲,对于这个用例,这个实现是本节所有示例中最好的。DataFrame对象以安全累加器友好的方式被突变(链式操作在DataFrame上的突变状态被封装在foldLeft中),参数签名利用基类型作为铸造的一部分(最小化使用时的错误),并且使用的匹配签名防止了任何令人困惑的变量命名约定。

我会使其变得更好的一种方法就是利用单子来处理castChanges参数。定义一个可以包含列名到铸造类型的映射的案例类构造函数可以进一步防止误用或任何令人困惑的实现细节,对于希望使用这个小型实用函数的其他人来说。

列表 13.5 中的问题不在于代码;而在于编写这种代码的人的哲学方法,并在代码库的每个地方强制执行这些模式。如果你在代码库的每个地方都检测到这些开发模式,充满了高度复杂和令人困惑的状态累积,将整个堆栈传递到每个函数,你应该与这个人交谈。向他们展示光明。让他们知道,这种对“纯粹性”的追求就像与风车搏斗一样,是一种徒劳的行为。毕竟,他们不是唯一需要维护这个的人。

关于函数式编程的一席话

我知道这可能会让人觉得我在讨厌函数式编程。我不是。你会在这一章以及我贡献的任何代码库中看到,我选择做很多函数式编程的事情。对于它设计要做的,它是一种非常好的编程风格。在某些语言中,例如 Python 和 Scala,它还有性能优势(使用累加器比使用突变更有效)。

然而,我事后责备自己的是纯粹主义的方法。在 ML 开发的许多领域,使用 FP 技术根本就没有意义。例如,试图将 FP 设计模式强行塞入确定性状态控制的超参数调整中,是一场灾难。

我确实鼓励所有机器学习从业者学习函数式编程(FP)的概念,因为它们在许多地方都非常有意义。你需要遍历一个集合并对其应用一个函数吗?不要使用for循环;使用map函数(Python 中的列表推导)。你需要根据大量任务更新对象的状态吗?使用 map-reduce 范式(Scala 中的折叠,Python 中的列表推导)。这些语言特性非常有帮助,通常比替代迭代器(如for循环和while循环)性能更好,并且代码更简洁。

使用函数式编程的唯一缺点是如果你的团队不熟悉它。但这总是可以通过培训来解决。花点时间向你的团队介绍这个话题,你会发现遍历集合将更容易阅读、编写,并且运行成本更低。

炫耀型

炫耀型人格可以有多种形式。它可能是一个拥有长期独立开发软件历史、没有任何机器学习组件的极其高级的贡献者。他们可能会审视一个机器学习项目,并试图构建一个定制的算法实现,而这个算法在其他流行的开源库中已经存在。他们也可能是一个已经从黑客型开发者毕业的人,凭借对实现语言和软件设计模式的更深入理解,选择向团队中的每个人展示他们现在的优秀。

无论这种类型的人为什么要在他们的实现中构建复杂性,它都会以相同的方式影响团队和团队必须维护的项目。如果代码没有被重构,构建它的人最终将拥有它。

如果代码的复杂度是由用例和要解决的问题所证明的必要,那么在代码中存在复杂性绝对没有问题。然而,炫耀型的人仅仅为了过度设计解决方案以向团队中的其他人展示自己的技能,而人为地引入复杂性。我想象一下符合炫耀型人格的人的心理状态可能看起来像图 13.7。

13-07

图 13.7 炫耀型的人的不良习惯和思维模式

当你是这个人的同事时,这些习惯和思维模式会让你感到非常不愉快。他们传达的想法并不坏(除了右下角的那个有毒的想法)。构建者模式、重抽象、隐式类型、反射和精心设计的接口都是好事。然而,它们是在需要时使用的工具。

这个人的思考和编写代码的方式的问题在于,他们将从第一个分支上的初始提交开始实施项目,为一个大型的、完全不必要的项目架构创建骨架占位符。这类机器学习工程师只关注项目的代码复杂性,几乎不考虑项目的实际目的。在这种盲目中,他们通常努力编写非常复杂的代码,对于其他团队成员来说,由于他们为当前问题所做的过度工程,这种代码看起来是故意混淆的。

提示:如果你想让人人都认为你很聪明,就报名参加 Jeopardy 并赢得几轮。如果你在代码中炫耀,你只是在让你的团队处于危险之中。

让我们来看看我们的类型转换场景函数,这次以展示者的开发风格编写。

列表 13.6 展示者的类型转换实现

val numTypes =
    List(IntegerType, FloatType, DoubleType, LongType, DecimalType, ShortType)❶
def showOff(df: DataFrame): DataFrame = {
    df.schema
      .map(
        s =>
          s.dataType match {                                                  ❷
            case x if numTypes.contains(x) => s.name -> "n"                   ❸
            case _                         => s.name -> "s"                   ❹
        }
      )
      .foldLeft(df) {
        case (df, x) =>                                                       ❺
          df.withColumn(x._1, df(x._1).cast(x._2 match {
            case "n" => "double"
            case _   => "string"                                              ❻
          }))
      }
  }
val showOffRecasting = showOff(dogData)                                       ❼

❶ 对于这个特定的实现,定义匹配的数值类型是合适的。如果整数需要以不同的方式处理,会发生什么?为了坚持这种设计模式,所需的重构将会相当大!

❷ 在传入的 DataFrame 的数据类型上,匹配方法并不差。这是关于这段代码唯一值得说的好话。

❸ 将列名映射到转换类型的方式很奇怪。它在下一个语句中被消耗。

❹ 对于所有其他条件,使用通配符捕获。如果传入的 DataFrame 包含一个集合,会发生什么?

❺ 从第一阶段懒传递映射集合(x)。现在需要使用位置表示法来访问这些值。

❻ 再次,通配符匹配。如果是一个 ArrayType 或 ListType 列,这里会出现严重问题。

❷ 至少这个函数的实例化相当简单。

这段代码是可行的。它的行为与前面的三个例子完全一样。只是很难阅读。试图展示技巧和“高级”语言特性,做出了一些相当糟糕的决定。

首先,对模式字段进行初始映射是完全不必要的。创建一个由单字符值到列名的伪枚举组成的 Map 类型列不仅无用,而且令人困惑。从第一阶段生成的集合,然后折叠到累加器操作中的 DataFrame,立即被消耗,迫使创建一个“临时”的 Map 对象集合来应用正确的类型转换。最后,在懒惰地不想完全写出可能发生的所有条件匹配的情况下,在最后部分有一个通配符匹配案例。当有人需要处理不同的数据类型时会发生什么?更新以支持二进制类型、整数或布尔值的步骤是什么?扩展这一点不会特别有趣。

要警惕编写这种代码的人,尤其是如果他们是团队中的资深人员。关于为什么团队中的每个人都能够维护和调试代码的重要性进行一次对话是一个好的方法。他们不太可能有意使代码对其他人变得复杂。如果请求一个更简单的实现,他们很可能会根据这个想法调整他们的开发策略,以适应未来的需求。

疯狂科学家

疯狂科学家开发者是善意的。他们也是那些在软件开发知识道路上取得巨大进步的人。凭借他们的经验、项目数量以及他们所编写的代码的巨大数量,他们开始利用语言中的高级技术(他们通常精通一种以上的语言)来减少需要维护的代码量。

这些人通常基于开发效率来考虑如何解决问题,而不是基于希望因为代码的复杂性而获得认可。他们多年来学到了很多,并且不得不维护(和重构)不够理想的代码,以至于他们选择以使调试和维护更容易的方式进行实现。

当团队的其他成员与他们的技术能力相似时,这些目标是非常崇高的。然而,大多数团队由各种不同开发能力的人组成。编写复杂但高效的代码可能会阻碍团队中更初级人员的效果。为了说明这些思维过程,图 13.8 展示了疯狂科学家的思维片段。

13-08

图 13.8 如果没有对团队其他成员进行适当的指导和辅导,一个资历较深、高级的机器学习工程师可能会编写出非常晦涩和复杂的代码。

注意,疯狂科学家的观点并不是不好的。它们非常相关,被认为是通用的最佳实践。然而,这种心态的问题在于,所有其他与代码一起工作的人并不了解这些标准。

如果代码是按照这些组合规则编写的,并且只是通过在分支上提交一个 PR 而没有让团队的其他成员意识到这些标准为什么如此重要,那么代码的设计和实现对他们来说将是难以理解的。让我们看看我们的铸造示例的延续,看看这位疯狂科学家开发者可能会在列表 13.7 中如何编写这段代码。

列表 13.7 一个稍微复杂的铸造实现

val numTypes = List(FloatType, DoubleType, LongType, DecimalType, ShortType)
def madScientist(df: DataFrame): DataFrame = {
  df.schema.foldLeft(df) {                                             ❶
    case (accum, s) =>                                                 ❷
      accum.withColumn(s.name, accum(s.name).cast(s.dataType match {   ❸
        case x: IntegerType => x 
        case x if numTypes.contains(x) => DoubleType
        case ArrayType(_,_) | MapType(_,_,_) => s.dataType
        case _                         => StringType
      }))                                                              ❹
  }
}

❶ 与前面的例子类似,但我们是直接在 df.schema getter 返回的集合上进行迭代的。

❷ 放弃了前面例子中使用的 df 这样的令人困惑的名称引用。尽管在这里(并且是安全的)将其封装起来,但将其命名为 df 会让阅读变得困惑。

❸ 使用模式返回的命名实体(变量 s)来防止未来出现意外的错误

❹ 通过在转换语句中包装决策逻辑,代码行数更少。直接匹配到模式元数据中的类型将更有利于未来的兼容性。

现在,这段代码没有问题。它简洁,很好地覆盖了所需的使用案例,并且设计得不会在数据集中的列中包含复杂类型(数组映射)时突然爆炸。这里唯一的注意事项是确保你的团队可以维护这种设计模式。如果他们可以接受以这种方式维护和编写代码,这是一个好的解决方案。然而,如果团队的其他成员习惯于命令式编程风格,这种代码设计可能和用另一种语言编写的一样晦涩难懂。

如果团队面临大量强制调用,最好向团队介绍列表 13.7 中展示的编码风格。花时间教授和指导团队其他成员更有效的开发实践可以加速项目工作并减少支持项目所需维护的工作量。然而,对于更资深的人来说,教育其他团队成员了解为什么这些标准很重要是绝对关键的。这并不意味着仅仅抛出一个语言规范的链接(有人将 Python 的 PEP-8 标准链接到 PR 上是我的一个烦恼),也不是仅仅向团队发送包含密集和高效代码的分支。相反,这意味着编写良好的文档,在内部团队文档存储库中提供示例,进行培训课程,并与团队中经验较少的成员进行结对编程。

如果你恰好是这类疯狂科学家类型的人,编写优雅且结构良好的代码,但被团队成员误解且难以理解,你应该首先考虑的是教学。帮助每个人理解为什么这些开发范式是好的,比写严厉的代码审查笔记和拒绝合并请求要有效得多。毕竟,如果你在编写好的代码并将其提交给没有你使用范式经验的团队,它和列表 13.6 中展示的炫耀代码的混乱一样难以理解。

一个更安全的赌注

让我们看看一种更安全、更易读、稍微更标准的解决这个问题的方法。下面是一个更可维护的实现示例。

列表 13.8 对无效类型转换的一个更安全的赌注

object SimpleReCasting {                                   ❶
  private val STRING_CONVERSIONS = List(
BooleanType, CharType, ByteType)                           ❷
  private val NUMERIC_CONVERSIONS = List(
FloatType, DecimalType)                                    ❸
  def castInvalidTypes(df: DataFrame): DataFrame = {
    val schema: StructType = df.schema                     ❹
    schema.foldLeft(df) {
      case (outputDataFrame, columnReference) => {
        outputDataFrame.withColumn(columnReference.name, 
         outputDataFrame(columnReference.name)
          .cast(columnReference.dataType match {
            case x if STRING_CONVERSIONS.contains(x) => 
              StringType                                   ❺
            case x if NUMERIC_CONVERSIONS.contains(x) => 
              DoubleType                                   ❻
            case _ => columnReference.dataType             ❼
          }))
}}}}

❶ 使用对象进行封装,并通过 JVM 进行更有效的垃圾回收

❷ 明确声明我们想要转换为 StringType 的数据类型

❸ 明确声明我们想要转换为 DoubleType 的数据类型

❹ 仅为了减少代码复杂性和使其他阅读者更容易理解,将模式引用分离出来

❺ 如果它们在我们的配置列表中,将我们声明的类型转换为 StringType

❻ 只转换与我们的列表匹配的数字类型到 DoubleType

❼ 不要触碰任何其他东西。就让它保持原样。

注意代码被封装在对象中?这是为了隔离对已定义的Lists的引用。我们不希望在代码库中全局定义这样的变量,因此将它们封装在对象中达到这个目的。

此外,封装使得垃圾收集器更容易移除对不再需要的对象的引用。一旦使用过并且代码中不再引用SimpleRecasting,它将和它内部的所有其他封装对象一起从堆中移除。看似更冗长的命名约定(有助于新读者跟随foldLeft操作中正在执行的操作),使得这段代码比列表 13.7 中的简短代码更易于阅读。

关于这段代码的最后一句话是,操作完全是显式的。这是与所有之前的例子相比,这段代码最大的特点,除了列表 13.3 中原始引用的命令式转换。在这里,就像那个早期的例子一样,我们只改变我们明确命令系统要改变的列类型。我们不是默认将“将所有其他内容转换为String”或其他任何会创建易碎、不可预测行为的操作。

这种思考编码的方法将为你节省很多令人沮丧的小时、天数和月份,用于调试在生产中看似无害但实际上会导致崩溃的代码。我们将在下一章回顾一些将未知状态默认为静态值(或推断值)的方法,这些方法可能会在机器学习工程师的我们身上造成麻烦。现在,只需意识到明确动作绝对是一个好的设计模式。

13.2.2 麻烦的编码习惯回顾

在前一节中,我们关注了几种,让我们说,不友好的编写代码的方式。每种都有其自身的坏处和无数的原因,但最令人讨厌的原因在表 13.2 中。

在编写代码时需要牢记的最重要的一点是,你创建的代码并不仅仅是为了执行它的系统的利益。如果真是这样,那么这个职业可能永远不会从低级代码框架(如汇编语言或对真正受虐狂来说,第一代机器代码)转向编写指令的第二代语言。

表 13.2 开发者实现罪过

罪恶人格 为什么这么糟糕
黑客 易碎的代码是碎片化的,并且经常断裂。
神秘 复杂且密集的代码需要花费很长时间才能逆向工程。不可测试的嵌套代码可能会静默地引入难以诊断的错误。
展示 故意复杂的代码旨在让他人感到无足轻重。难以调试、修复或扩展。噩梦般的代码。
疯狂科学家 实现过于复杂,以至于同行难以理解(因为缺乏教学)。过于僵化,不允许进行轻量级测试或扩展。

语言的发展并非为了提高计算机处理器和内存的计算效率,而是为了编写代码的人类,更重要的是,为了阅读代码以了解其功能的人类。我们编写代码,尽可能使用高级 API,并按照易于阅读和维护的方式构建代码,仅仅是为了我们同行和未来的自己。

避免表 13.2 中列出的习惯,并朝着编写你、你的团队以及你未来打算招聘的具有类似技术才能的人所需的代码方向发展。这样做将有助于使每个人都能够高效地贡献于构建和维护解决方案,并防止对复杂代码库进行低效的重构,以修复由无思考的开发者造成的沉重技术债务。

13.3 过早的泛化、过早的优化以及其他展示你有多聪明的坏方法

假设我们正在与一支相对先进的(从软件开发角度来看)机器学习工程师团队开始一个新的项目。在项目开始时,架构师决定控制代码状态的最佳方式是设计和实现一个用于执行建模和推理任务的框架。团队非常兴奋!团队成员想,终于可以做些有趣的工作了!

在他们的集体兴奋中,他们中没有一个人意识到,除了难以阅读的代码外,最糟糕的傲慢形式之一就是花费时间在不必要的地方。他们正准备构建无用的框架代码库,除了证明他们自己的存在之外,没有任何实际用途。

13.3.1 泛化和框架:在你无法避免之前避免使用它们

团队首先着手制作一个产品需求文档(PRD),概述他们希望他们独特的框架能做什么。基于构建者模式的一般设计被草拟。架构师希望团队执行以下操作:

  1. 确保在整个项目代码中利用自定义默认值(不依赖于 API 默认值)

  2. 强制覆盖建模过程中某些元素以调整超参数

  3. 使用与公司代码标准更一致的命名约定和结构元素来包装开源 API

在进行实验之前,会制定一个功能计划,如图 13.9 所示。

13-09

图 13.9 一个建筑师试图围绕不同的框架构建一个统一的包装器,以支持公司所有的机器学习需求。剧透一下:结果并不好。

这个关键功能的计划过于雄心勃勃。如果继续进行,图右侧显示的现实方面很可能会发生(每次我看到有人尝试这样做时,这些事情总是会发生)。这个项目将充满重做、重构和重新设计,注定会失败。

与其专注于使用现有框架(如 Spark、pandas、scikit-learn、NumPy 和 R)解决问题,团队将不仅支持项目解决方案,还要支持一个定制的框架包装器实现——以及所有随之而来的痛苦。如果你没有几十名软件工程师来支持一个框架,最好仔细考虑计划构建一个框架。

在构建和维护这样一个软件堆栈的巨大工作量之上,还有一个简单的事实,那就是你将尝试支持一个比它所包装的框架更通用的包装器。从事这类工作通常不会有好结果,主要有两个原因:

  • 你现在拥有了一个框架——这意味着更新、兼容性保证,以及需要编写的大量真正庞大的测试(你确实在编写测试,对吧?)。功能保证现在与构建框架所使用的包保持一致。

  • 你现在拥有了一个框架——除非你打算真正地使其通用,开源它,并让一个提交者社区参与其成长,并承诺维护它,否则这项工作是没有意义的。

只有在存在直接需求的情况下,追求通用方法才是真正有意义的。是否需要开发一个关键的新功能,以便使另一个机器学习框架工作得更有效率?也许你可以考虑为那个开源框架做出贡献。是否有必要将不同的低级 API 缝合在一起以解决一个共同的问题?这可能是一个创建框架的好案例。

在开始一个项目时,与我们的建筑师朋友不同,你最后应该考虑的是着手构建一个定制的框架来支持那个特定的项目。这种过早的泛化工作(在时间、分心和挫败感方面)将严重削弱项目的待办工作,将推迟和干扰应该专注于解决问题的生产性工作,并且不可避免地需要在项目演变过程中多次重做。这根本不值得。

我是否应该构建一个通用的框架?

当然!嗯……也许吧。

我会列出一些需要考虑的事项,然后让你自己决定是否真的想追求构建框架(假设这是在指定时间内进行,而不是在项目交付期间):

  • 你的团队有多少人?如果你每周无法至少投入 16 个人时来维护框架、添加功能和解决问题,你应该重新考虑是否值得开始。

  • 你打算开源它吗?你能围绕它建立多大的社区?公司关于维护开源软件的法律规定是什么?你能投入多少时间来支持软件?

  • 它是否解决了新颖的问题,还是你正在构建另一个工具中已经存在的功能?

  • 你能购买一个工具或平台来执行你希望框架执行的任务吗?如果是这样,我保证购买该工具或使用现有的开源解决方案将比投入时间和精力构建自己的框架更便宜。

  • 这个框架将有多少依赖项?对于你引入的每个额外包,你都在为其长期维护增加了一个阶乘级的问题。软件包和依赖项不断变化,许多弃用实际上只是未来威胁,你的框架有一天会在你面前爆炸。

  • 这个计划中的框架带来了哪些额外的价值?如果它不能通过至少是你将花费在构建和维护此框架上的时间的两倍来加速你当前和未来的项目工作,那么它就是浪费时间精力。

框架只是另一个开源框架的包装器吗?我看到人们围绕 pandas 或 Spark 编写自定义包装器的次数真正令人震惊。一切工作都进行得很好,直到下一个重大版本发布,它有根本性的破坏性变化(或者下一个次要的关键功能添加,现在需要为你的自定义 API 实现包装器),迫使你实际上从零开始重写你的框架。

这些只是我向那些告诉我他们打算为机器学习工作构建通用框架的人提出的一些问题。我并不是试图贬低他们的宏伟目标;只是因为我亲身经历过,深知维护此类事物的痛苦。

当你在生产中运行数百个 XGBoost 模型以提供业务预测洞察时,构建是完美的。但是,业务和你都应该理解你将使自己陷入多么巨大的工作量。只有在不构建框架就会愚蠢的情况下,才追求这条道路;为构建、监控和从数百个 XGBoost 模型中进行推断的高级 API 将是构建一个框架的好理由。

13.3.2 过早优化

假设我们为一家不同的公司工作——一家没有上一节中那位架构师的公司,最好是。这家公司,而不是帝国建设的架构师,有一个来自后端工程背景的 DS 团队顾问。在整个职业生涯中,他们专注于可以以毫秒计量的 SLA,以最有效的方式遍历集合的算法,以及大量时间榨取每个可用的 CPU 周期。他们的世界完全专注于代码离散部分的性能。

在第一个项目中,顾问希望通过帮助构建负载测试器来为 DS 团队的工作做出贡献。由于团队再次面临确定狗在进入当地宠物用品店时是否饥饿的问题,顾问指导团队实施解决方案。

基于他们的经验和 Scala 后端系统的知识,团队成员最终专注于高度优化以最小化 JVM 上的内存压力的东西。他们希望避免使用可变缓冲区集合,而是使用显式集合构建(仅使用所需的最小内存量)和固定预定的集合大小。由于先前的经验,他们花了几天时间编写代码,以生成测试建模解决方案吞吐量的数据。

首先,顾问致力于定义将要用于测试的数据结构。列表 13.9 显示了数据结构和用于生成数据的定义静态参数。

备注:列表 13.9 中的 Scala 格式是为了打印目的而压缩的,并不代表正确的 Scala 语法设计。

列表 13.9 数据生成器的配置和常见结构

import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.apache.spark.sql.{DataFrame, SparkSession}
import scala.collection.mutable.ArrayBuffer
import scala.reflect.ClassTag
import scala.util.Random
case class Dogs(age: Int, weight: Double, favorite_food: String,
                breed: String, good_boy_or_girl: String, hungry: Boolean) ❶
case object CoreData {                                                    ❷
  def dogBreeds: Seq[String] = Seq("Husky", "GermanShepherd", "Dalmation", "Pug", "Malamute", "Akita", "BelgianMalinois", "Chinook", "Estrela", "Doberman", "Mastiff")
  def foods: Seq[String] = Seq("Kibble", "Spaghetti", "Labneh", "Steak",
      "Hummus", "Fajitas", "BœufBourgignon", "Bolognese")
  def goodness: Seq[String] = Seq("yes", "no", "sometimes", "yesWhenFoodAvailable")
  def hungry: Seq[Boolean] = Seq(true, false)
  def ageSigma = 3
  def ageMean = 2
  def weightSigma = 12
  def weightMean = 60
}
trait DogUtility {                                                        ❸
  lazy val spark: SparkSession = SparkSession.builder().getOrCreate()     ❹
  def getDoggoDataT: ClassTag: Seq[T] = {
    val rnd = new Random(seed)
    Seq.fill(dogs)(a(rnd.nextInt(a.size)))
  }                                                                       ❺
  def getDistributedIntData(sigma: Double, mean: Double, dogs: Int,
                            seed: Long): Seq[Int] = {
    val rnd = new Random(seed)
    (0 until dogs).map(
      _ => math.ceil(math.abs(rnd.nextGaussian() * sigma + mean)).toInt)
  }                                                                       ❻
  def getDistributedDoubleData(sigma: Double, mean: Double, dogs: Int,
                               seed: Long): Seq[Double] = {
    val rnd = new Random(seed)
    (0 until dogs).map( _ => math.round(math.abs(rnd.nextGaussian() * sigma * 100 + mean)).toDouble / 100)
  }                                                                       ❼
}

❶ 定义用于测试的数据集模式(带类型)

❷ 使用案例对象来存储用于数据生成的静态值(Scala 中的伪枚举)

❸ 使用特质进行多重继承以测试不同的实现并使代码更简洁

❹ 我们将在对象中使用 Spark 会话引用,因此在特质中使其可用是有意义的。

❺ 使用泛型类型随机填充值(字符串或布尔值)到固定大小的序列中

❻ 根据传入的均值和标准差值生成基于整数的随机高斯分布

❷ 根据均值和标准差生成基于 Double 值的随机高斯分布

现在已经开发出辅助代码来控制模拟数据的性质和行为,顾问测试了在特质DogUtility中定义的方法的性能。经过几小时的调整和重构后,性能在数亿个元素上表现良好。

应该不用说,这个实现对于当前的问题来说有点过度。由于这是项目的开始阶段,不仅模型最终结果所需的功能尚未完全定义,而且特征的概率分布还没有进行分析。导师决定现在是时候构建实际的控制执行代码,以生成作为 Spark DataFrame 的数据,如下一列表所示。

列表 13.10 一个过于复杂且优化不当的数据生成器

object PrematureOptimization extends DogUtility {                        ❶
  import spark.implicits._                                               ❷
  case class DogInfo(columnName: String,
                     stringData: Option[Either[Seq[String], 
                       Seq[Boolean]]],                                   ❸
                     sigmaData: Option[Double],                          ❹
                     meanData: Option[Double],
                     valueType: String)                                  ❺
  def dogDataConstruct: Seq[DogInfo] = {                                 ❻
    Seq(DogInfo("age", None, Some(CoreData.ageSigma), 
          Some(CoreData.ageMean), "Int"),
        DogInfo("weight", None, Some(CoreData.weightSigma), 
                  Some(CoreData.weightMean), "Double"),
        DogInfo("food", Some(Left(CoreData.foods)), None, None, "String"),
        DogInfo("breed", Some(Left(CoreData.dogBreeds)), 
          None, None, "String"),
        DogInfo("good", Some(Left(CoreData.goodness)), 
          None, None, "String"),
        DogInfo("hungry", Some(Right(CoreData.hungry)), 
          None, None, "Boolean"))
  }
  def generateOptimizedData(rows: Int, 
seed: Long): DataFrame = {                                               ❼
    val data = dogDataConstruct.map( x => x.columnName -> {
            x.valueType match {
              case "Int" => getDistributedIntData(x.sigmaData.get, 
                             x.meanData.get, rows, seed)
              case "Double" => getDistributedDoubleData(x.sigmaData.get, 
                                x.meanData.get, rows, seed)
              case "String" => getDoggoData(x.stringData.get.left.get, 
                                rows, seed)                              ❽
              case _        => getDoggoData(
x.stringData.get.right.get, 
  rows, 
  seed)
            }
        }                                                                ❾
).toMap                                                                  ❿
    val collection = (0 until rows).toArray                              ⓫
      .map(x => {
        Dogs(
          data("age")(x).asInstanceOf[Int],
          data("weight")(x).asInstanceOf[Double],
          data("food")(x).asInstanceOf[String],
          data("breed")(x).asInstanceOf[String],
          data("good")(x).asInstanceOf[String],
          data("hungry")(x).asInstanceOf[Boolean]
        )
      })
      .toSeq
    collection.toDF()                                                    ⓬
  }
}

❶ 使用之前定义的 DogUtility 特质来访问那里定义的方法和 SparkContext。

❷ 使用 Spark 的隐式转换,能够直接通过序列化将案例类对象的集合转换为 DataFrame 对象(减少了大量糟糕的代码)。

❸ 这是一团糟。Either 类型允许在两种类型之间进行右对齐选择,并且很难正确扩展。这里使用泛型类型会更好。

❹ 选项类型在这里是因为这些值对于数据生成器的一些配置方法调用不是必需的(不需要为从其中随机抽取的字符串集合定义一个 sigma)。

❺ 值类型允许对以下生成器进行优化实现(针对行数,而不是对读者的理解容易程度)。

❻ 构建控制有效载荷,以定义数据生成器将被如何调用(以及顺序)。

❼ 这是一个针对根据方法 dogDataConstruct 中指定的配置调用数据生成器的过于花哨且优化(针对代码长度)的实现(这种实现很脆弱)

❽ 好吧,这对于访问一个值来说很糟糕。两个 .get 操作?你一定是在开玩笑吧。

❾ 以下是性能问题的根本原因。这默认为 Seq 类型,但应该是 IndexedSeq 类型,以便允许对单个值进行 O(1) 访问,而不是当前的 O(n)。

❿ 将每个数据集合包裹在一个 Map 对象中,以便通过名称访问值比使用位置表示法更容易。

⓫ 这段代码的第二个主要问题——映射每个集合的索引位置以构建行。这在复杂度上是 O(kn)。

⓬ 转换为 Spark DataFrame。

在对这个代码进行了一些测试之后,团队成员很快意识到生成的行大小与运行时间之间的关系远非线性。事实上,它比线性更糟糕,计算复杂度更接近于 O(n× log(n))。生成 5,000 行大约需要 0.6 秒,而 500,000 行的重负载测试大约需要 1 分钟 20 秒。对于 5000 万行的完全负载测试,等待 2 小时 54 分钟的想法有点过分。

发生了什么问题?他们把所有的时间都花在优化代码的各个部分上,以至于在独立执行时,每个部分都尽可能地快。当整个代码执行时,却是一团糟。实现方式在所有错误的方向上都非常聪明。

然而,为什么它这么慢呢?问题在于最后一部分,它非常致命。尽管对于这个实现,内存压力很小,但定义在变量 collection 中的行数生成必须在 Map 集合中的每个 Sequence 上执行非索引位置查找。在构建 Dogs() 对象的每次迭代中,都需要遍历 Sequence 到那个位置以检索值。

现在,这个例子有点夸张。毕竟,如果这个后端开发者真的擅长优化,他们可能会利用一个索引集合,并将数据对象从 Sequence 强制转换为 IndexedSeq(这将能够直接到达请求的位置,并以极短的时间返回正确的值)。即使有这个改变,这种实现仍然是在错误的地方“嗅探”。

性能非常糟糕,但这只是故事的一部分。如果需要添加另一种数据类型以与 String 数据相同的方式处理,列表 13.10 中的代码会发生什么?开发者会围绕第一个 Either[] 语句包裹另一个吗?然后它会被包裹在另一个 Option[] 类型中吗?如果需要生成 Spark Vector 类型,这段代码会变成多么混乱的一团?因为这个代码是以这种方式构建的,过度优化到了解决方案预 MVP 版本的早期状态,这段代码要么需要在整个项目中大量修改以保持与 DS 团队特征工程工作的同步,要么在变得笨拙且难以维护时需要从头开始完全重写。这段代码最有可能的路径是它注定要进入 rm -rf 命令的无底深渊。

以下列表显示了一种略微不同的实现方式,它利用了一种更简单的方法。这段代码专注于将运行时间减少一个数量级。

列表 13.11 一个性能更好的数据生成器

object ConfusingButOptimizedDogData extends DogUtility {           ❶
  import spark.implicits._
  private def generateCollections(rows: Int, 
seed: Long): ArrayBuffer[Seq[Any]] = {
    var collections = new ArrayBuffer[Seq[Any]]()                  ❷
    collections += getDistributedIntData(CoreData.ageSigma,
 CoreData.ageMean, rows, seed)                                     ❸
    collections += getDistributedDoubleData(CoreData.weightSigma,
      CoreData.weightMean, rows, seed)
    Seq(CoreData.foods, CoreData.dogBreeds, CoreData.goodness,
      CoreData.hungry)
      .foreach(x => { collections += getDoggoData(
        x, rows, seed)})                                           ❹
    collections
  }
  def buildDogDF(rows: Int, seed: Long): DataFrame = {
    val data = generateCollections(rows, seed)                     ❺
    data.flatMap(_.zipWithIndex)                                   ❻
        .groupBy(_._2).values.map( x =>                            ❼
          Dogs(
            x(0)._1.asInstanceOf[Int],
            x(1)._1.asInstanceOf[Double],
            x(2)._1.asInstanceOf[String],
            x(3)._1.asInstanceOf[String],
            x(4)._1.asInstanceOf[String],
            x(5)._1.asInstanceOf[Boolean])).toSeq.toDF()
      .withColumn("hungry", when(col("hungry"), 
        "true").otherwise("false"))                                ❽
      .withColumn("hungry", when(col("breed") === "Husky",
        "true").otherwise(col("hungry")))                          ❾
      .withColumn("good_boy_or_girl",  when(col("breed") === "Husky",
        "yesWhenFoodAvailable").otherwise(
          col("good_boy_or_girl")))                                ❿
  }
}

❶ 与列表 13.10 中的实现相同

❷ 为了消除集合迭代的一个阶段,我们只需将每个生成的值序列(最终的行数据)追加到一个 Buffer 中。

❸ 将第一列的数据(为年龄生成的随机整数)添加到 Buffer 中

❹ 遍历所有 String 和 Boolean 列的数据集合,并将它们配置的可允许值逐个传递给生成器

❺ 调用上面定义的私有方法来获取用于测试的随机采样数据的 ArrayBuffer

❻ 遍历每一行数据集合,并通过位置表示法直接生成 Dogs 情况类结构

❼ 将数据折叠成元组,这些元组按照正确的生成顺序包含行值

❽ 不如将布尔字段转换为字符串类型,以节省后续的处理步骤

❾ 如果你曾经认识过狗熊,你会知道它们总是很饿。

❿ 狗熊为了食物可以做任何事情。如果没有食物,它什么也不做。

代码重构后表现如何?嗯,现在它是线性扩展的。5000 行数据不到一秒;50000 行数据用了 1 秒;500 万行数据在不到 1 分钟 35 秒内返回。然而,之前实现中测试的 5000 万行目标,返回这个行数大约需要 15 分钟。这比早期实现中的 174 分钟要好得多。

虽然这个场景专注于负载测试数据生成器,对于大多数数据科学从业者来说很晦涩,但关于更多以 ML 为中心的任务的其他方面有很多可以说的。如果有人专注于优化 ML 管道中计算上最不重要的(即)方面,会发生什么?如果有人将所有精力集中在项目上,就像我们在本章第一部分所看到的那样,专注于将列转换为特定类型的性能,会发生什么?

图 13.10 显示了大多数 ML 工作流程训练周期的通用分解。注意每个列出的执行动作的费米能级估计,对于一个通用的 ML 项目。如果你试图优化这个工作,你会在哪里投入努力?你应该首先在哪里寻找问题并解决它们?

13-10

图 13.10 ML 管道内任务墙钟运行时间的通用分解

正如你所见,ML 项目代码的大部分处理时间主要专注于数据摄取操作(加载数据、合并数据、对数据进行聚合计算,以及将序数和分类数据转换为数值表示)和超参数调整。如果你注意到数据摄取绝对主导了你的项目运行时间(假设你所使用的平台可以支持大规模并行摄取,并且数据存储格式对快速读取是最优的,如 Delta、Parquet、Avro 或像 Kafka 这样的流源),那么考虑将数据重新部署到更高效的存储范式,或者研究更有效的数据处理方法。

在日志记录、模型注册和基本数据处理任务上花费的时间极其有限。因此,如果这些问题出现,修复这些问题很可能是通过阅读你所使用的模块的 API 文档,并纠正代码中的错误来相对容易地完成的。

了解这一点,任何优化努力都应该主要集中于减少工作的高时间约束阶段的总运行时间和 CPU 压力,而不是浪费在为解决方案中不重要的部分创建复杂和巧妙的代码上。关键要点是,优化 ML 代码的过程应该关注几个关键的关键方面:

  • 等待整个代码库的端到端功能全部运行正常后再花时间优化代码。 在开发过程中发生的更改数量和频率可能会使对优化代码的重做变得令人沮丧。

  • 识别代码中运行时间最长的部分。 在解决已经相对较快的部分之前,尝试变得巧妙,使这些部分更加高效。

  • 不要重新发明轮子。 如果一个语言结构(或者类似功能在完全不同的语言中,无论如何)可以显著加快你正在尝试做的事情,或者减少内存压力,那么就使用它。实现你自己的链表抽象类或设计一个新的字典集合是极度自负的行为。只需使用现有的,然后继续解决更有价值的问题。

  • 如果运行时间确实非常糟糕,请探索不同的算法。 只因为你非常喜欢梯度提升树,并不意味着它们是解决每个问题的理想方案。也许一个线性模型可以在运行时间的一小部分内相对接近性能。0.1%的准确度值得为了运行模型而增加 50 倍的预算吗?

在我看到的多支参与过早优化和泛化的团队中,DS-DNA 的集体信念是,ML 项目工作的技术方面超越了他们试图解决的问题。他们热爱工具,热爱大型 ML 专注组织推出的令人惊叹的新工作,以及 ML 生态系统中不断取得的快速进步。这些团队对平台、工具包、框架、算法以及 ML 工作的技术方面的关心远超过他们确保他们的方法能够以最有效和最可维护的方式帮助他们的业务。

13.4 你真的想成为那只金丝雀吗?alpha 测试和开源矿井的危险

让我们暂时假设你对 DS 领域非常陌生。实际上,你甚至是在工作的第一周。在办公室里,你环顾你的办公桌。团队中没有任何 DS 成员在职业中工作超过一个月。经理,一位经验丰富的软件工程师,不仅忙于管理 DS 团队,还忙于管理商业智能团队和数据仓库小组,并且忙于面试更多候选人,以使新的 DS 团队更加完整。

作为第一个任务,为团队生成一个低垂的果实建模项目来应对。被告知不能使用你在学校做工作时的笔记本电脑,经理给你们的方向是选择一个用于开发模型的框架。

在对平台和解决方案进行研究和调查的前几天,团队成员之一在博客中发现了正在讨论的一个新框架。它似乎具有前瞻性、功能丰富且易于使用。关于未来几个月计划为它构建的内容的讨论非常强大。有关于支持不仅是在用 C++编写的具有光滑 Python API 作为接口的分布式大规模并行处理(MPP)系统中的 CPU 任务,还包括 GPU 集群以及未来计划支持量子计算接口(量子算子优化所有可能的解的叠加以解决最小二乘问题的讨论)!

如果你曾经阅读过机器学习框架的源代码(即大多数专业人士在解决实际问题时所使用的框架),为其中一个框架做出过贡献,或者甚至围绕其中一些更受欢迎的开源框架的功能构建了一个包装器,你就会意识到这个“新潮且热门”的框架是多么荒谬。如果你是这样的情况,那么你就在图 13.11 的右侧部分(不是苦涩,而是明智的)。

让我们同意,我们所在的团队深深扎根于图 13.11 的中间列。团队成员的天真使他们无法看到他们即将面对的危险,那就是拥抱这个半成品、傲慢的怪物,一个过于雄心勃勃的开发者正在尝试构建。我们尝试了它,我们自愿成为试验品,我们的项目以生命为代价。

13-11

图 13.11 炒作?这是真实的。这也通常意味着炒作的对象实际上真的很糟糕(或者至少不是它所声称的那样)。

在这个新且高度在建的框架中工作的最终结果不可避免:彻底的失败。项目无法启动的失败并不是因为它们使用的 API,也不是因为它们调整解决方案的方式。真正的失败在于开发者的傲慢和围绕新功能和新框架的夸大其词的博客炒作氛围。

尝试新事物绝对没有错。我经常尝试这些新发布的软件包,看看它们是否值得。我在开源数据集上进行测试,在隔离环境中运行它们,以确保不会因为不稳定的依赖而污染我的类路径,并且让它们经历所有测试。我评估它们声称的功能,检查是否容易通过自定义实现来增强其功能,并观察系统如何处理不同的建模任务。内存利用率是否稳定?CPU 使用率是否与广泛使用的类似系统相当(或者,希望更好!)?我通过验证它们的声明来询问所有这些问题以及更多。

我永远不会在早期阶段使用这些软件包之一来构建一个企业依赖的项目。这里有几个原因:

  • API 将会改变很多。 到稳定版 1.0 发布时,整个接口很可能会被完全重构。你将不得不更改你的代码以适应。

  • 事情可能会出错。 可能会有一些事情,但通常在项目的 alpha 发布阶段初期会有很多事情出错。如果你在脆弱的代码上构建了重要的东西,你将不得不面对一个不稳定的代码库。

  • 没有保证项目不会变成积压品。 如果项目周围没有强大的社区,没有数百或数千的贡献者,以及机器学习社区中相当一部分人的支持,代码库很可能会灭绝并被遗弃。你真的不希望你的项目运行在死代码上。

  • 即使是在首次发布的版本中,技术债务也早已存在。 有时候会走捷径,省略某些步骤,并且可能存在一些错误。它可能对演示效果很好,对预包装的示例也毫无瑕疵,但很可能不会很好地适用于你需要实现以解决业务预测建模任务的非常具体的自定义逻辑。至少在其生命周期后期之前不会。

  • 新不一定代表更好。 在决定像框架或平台这样至关重要的东西之前,你绝对必须忽略来自公司、博客作者和广告嘈杂声中的营销炒作。尝试新事物,并对你的选项进行科学的研究。从生产力、可维护性、稳定性和成本的角度选择最合理的解决方案。这个闪亮的新玩具可能具有所有这些特性,但根据我的经验,这几乎从未发生过(尽管有时这些项目最终确实会变成那样,所以请密切关注它们)。

接受他人的傲慢是可能困扰机器学习团队的最具破坏性的任务之一。通过不做适当的测试和研究关于如何以及在哪里运行你的代码的选项,你可能会陷入一个本质上已经破裂的系统,这最终会导致你的团队在仅仅保持系统运行而不是创新到新的项目解决方案上花费更多的时间和金钱。让你的测试阶段成为你的金丝雀,而不是你的机器学习项目。

13.5 技术驱动开发与解决方案驱动开发

让我们从第 13.4 节的新手数据科学成员团队转移到充满经验丰富的机器学习工程师的团队。假设团队中没有一个人的软件开发经验少于 20 年,每个人都对构建不同类型的深度学习模型、梯度提升树、线性模型和单变量预测感到厌倦和疲惫。

他们都渴望构建一些东西来自动化处理他们正在工作的数百个预测模型的枯燥工作。他们最想要的莫过于一个挑战。

当面对他们下一个主要项目时,一个基于关联规则的实现(如果他们使用一个经过验证的方法),他们决定要聪明一点。他们感觉可以编写一个在 Apache Spark 上运行性能更好的 FP-growth 算法版本,并开始推导出一个改进的 FP-tree 方程,这样就可以动态挖掘,从而消除树的核心扫描之一,用于项目收集检索。

虽然他们的初衷是好的,但他们最终花了整整三个月的时间来完善他们的算法,测试它,并证明它几乎与参考 FP-growth 实现的结果相同,但构建和扫描树的时间却少了很多。他们创造了一种新的算法实现,并开始使用它来解决他们同意开发的业务用例。

他们喝了一些啤酒,拍了拍彼此的背,开始撰写他们的博客文章和白皮书,并为一些会议演讲活动做准备。哦,天哪,现在每个人都知道他们有多么聪明了!

他们将解决方案投入生产。一切运行良好,在他们看来,这个算法每天都在通过显著提高运行时间的成本节约来为自己付费。当然,直到底层框架的重大修订发布。在这个新的运行时中,开源框架中对这些树的构建方式进行了重大更改,以及如何构建前因和后果的根本性优化。

团队对调整模型以适应他们用来构建解决方案的开发者级 API 的底层变化感到士气低落。图 13.12 展示了他们的困境以及他们本应该做的事情。

13-12

图 13.12 一个机器学习技术债务的“选择你的冒险”路径

正如你所见,导致项目脱轨的关键决策在于没有使用之前多次被证明有效的现有标准。他们不仅需要构建一个支持业务用例的解决方案,还必须构建一个全新的算法,将其整合到框架的低级设计范式,并完全拥有实现权,以确保他们可以继续支持推动他们独特算法创建的业务用例。

由于他们的算法利用了框架的许多内部结构来加速开发过程,团队现在面临一个新的困境。他们是更新他们的算法以在新框架版本中工作,希望它将继续优于提供的 FP-growth 算法?还是重构整个解决方案以使用标准算法?

这里没有好的答案。他们的自定义框架注定要么成为积压品,要么需要几个季度的转换才能使其工作。

他们尝试的主要问题是构建了一个他们没有准备好支持的定制实现。他们构建解决方案不是为了解决业务问题,而是为了出名。他们想要被注意到并得到赞赏,因为他们的技能。团队没有意识到,虽然构建新算法和推进职业状态并没有什么真正的问题,但构建它的动机应该集中在解决问题的必要性上。

如果团队成员以解决方案驱动的思维方式来处理问题,他们永远不会考虑创建自定义解决方案的可能性。也许他们会联系现有流行开源框架的维护者,并自愿创建一个可以由该框架社区支持的新版本。如果有明确的降低运行时间以满足服务级别协议的需要,并且需要构建一个新算法,那是可以的。如果你遇到这种需求,就去构建它。只是要知道,你需要维护那段代码,直到那个业务用例的需求存在。

我发现自己越来越反感这种 TDD(测试驱动开发)的概念,因为它只是给一个已经压力很大的职业增加了更多的压力。通过追求更容易(并且,可以说,更无聊)的解决方案来解决问题,尤其是如果你已经有一个现有的解决方案来解决几乎相同的问题,你是在让业务处于更好的手中。你将会有更少的维护工作要做,有更多的时间创造性地使用你的才能来解决更有趣的未来问题。

摘要

  • 追求简单实现,不超出项目当前需求,将节省在功能需要改变时大量重构的时间。少即是多。

  • 虽然每个人在软件开发技能的增长阶段都不同,但让团队专注于利用易于理解和阅读的通用设计模式,将确保团队中的每个人都能为代码库做出贡献并维护它。

  • 在机器学习代码库中构建不必要的功能、复杂的接口和巧妙的独特实现,仅仅意味着你将不得不支持和维护更多的代码,这对组织没有任何价值。保持代码库的复杂度仅限于解决问题的关键,始终是一个明智的选择。

  • 在决定将任何此类工具集成到项目中之前,彻底调查任何新技术的能力、实用性和最重要的是需求,以确定它是否对项目有用,这是至关重要的。

  • 在为商业需求工作项目时,要注意只实现解决问题所需的功能。任何超出项目需求的东西都是虚荣的开发,会损害解决方案的可维护性。

第三部分:开发生产级机器学习代码

一旦项目准备就绪,可以部署到生产环境中,在实施部署计划之前,还有一些最终任务需要完成。虽然看到测试通过的实施版本可能让人误以为它已经完整并准备好进入现实世界的部署,但还有一些事项需要考虑,以确保值班人员不会每隔几个小时就被叫醒。

从漂移监控,到代码架构原则(这将有助于进行最终同行评审),预测质量保证,日志记录和服务器基础设施,这些最后的项目是最容易被忽视的。当被忽视时,它们是那些没有适当设计和实施的人最遗憾忘记的元素之一。

在本节中,我们将探讨这些更高级的主题,这些主题可以帮助使生产部署更加容易,并确保您的模型是可解释的、能够重新训练、监控的,并且(相对)易于更新。

14 编写生产代码

本章涵盖

  • 在尝试使用数据构建模型之前,验证特征数据

  • 监控生产中的功能

  • 监控生产模型生命周期的各个方面

  • 以尽可能简单的方式解决问题的项目方法

  • 为机器学习项目定义标准代码架构

  • 避免在机器学习中出现货船崇拜行为

我们在这本书的第二部分中花费了全部时间来探讨构建机器学习软件的技术方面。在这一章中,我们将开始从架构师的角度审视机器学习项目工作。

我们将专注于从高度互联、极度复杂且整体性的视角,探讨如何运用机器学习解决问题的理论和方法。我们将研究生产机器学习的案例研究(所有这些案例,以某种方式,都是基于我所犯的错误或看到他人犯的错误),以深入了解那些不常被讨论的机器学习开发要素。这些是我们作为行业,在更关注解决问题的算法方面,而不是我们应该关注的领域时,通常以艰难的方式学到的教训:

  • 数据——它的生成方式、位置以及它的本质

  • 复杂性——解决方案和代码的复杂性

  • 问题——以最简单的方式解决它

正如我们在前面的章节中讨论的,数据科学工作的目标不仅仅是利用算法。它不是框架、工具包或看似越来越热门或流行的特定模型基础设施。

注意数据科学工作应仅专注于解决问题、使用数据和将科学方法应用于我们的方法,以确保我们基于可用的数据以最佳方式解决问题。

带着这种关注,我们将探讨现实世界中生产开发的一些方面,特别是构建解决方案的独特破坏性方面,这些方面可能对那些算法专注且未经历过足够痛苦的实施失败的实践者来说并不明显。在这个行业中工作足够长的时间,每个人都会以某种方式学到这些教训。你越早从他人的错误中学习,学习过程就越不会像我们中的一些人那样痛苦,因为我们从它变得流行之前就开始做这件事了。

所有关于工具和框架的提及在哪里?

正如我在本书的许多地方提到的,成功的机器学习不是关于一系列工具。也不是关于特定的平台。

使成功项目与那些未能继续解决其存在理由的项目区别开来的,并不是某些巧妙的 API、炒作的宏伟框架或包装解决方案。使项目成功的四个主要要素简单来说就是这些:数据的质量、解决问题的最小复杂度、解决方案的可监控性(以及易于修复),以及最重要的是,解决方案解决问题的能力。正如我的一个同事经常说的,其他所有东西都只是浮华。

在本章和下一章中,我们将重点关注这些基本要素——保持数据清洁、监控数据和模型的健康状况,以及在解决方案开发中注重简洁性。

虽然框架、工具、平台和其他提高生活质量的工具使机器学习解决方案的生产过程变得更加容易(我们将在最后几章中深入探讨这些主题),但它们并不是成功的万能保障。当你需要它们时,它们都在那里(平台除外——你肯定需要选择最适合你团队和公司的平台),并且可以帮助解决一些组织可能会遇到的具体问题,但它们并不是普遍适用的。

成功机器学习的原则无疑是普遍适用的。如果你没有弄清楚这些,那么你使用的工具箱多么花哨都无关紧要。无论你是否拥有最先进的 CI/CD、特征存储、自动机器学习、特征生成工厂、GPU 加速的深度学习,或者任何其他机器学习领域的炒作术语,这些巧妙的工具并不能拯救你的项目,如果你的数据质量差、代码难以维护,而且你也没有确保你的内部客户对解决方案感到满意。

14.1 你是否已经与你的数据相识?

我所说的“相识”并不是在去续杯咖啡的路上对数据简短而礼貌地点头致意,也不是在展会上匆匆忙忙、尴尬地自我介绍 30 秒。相反,你应该与你的数据进行的“相识”更像是长达数小时的私密对话,在一个安静、装饰考究的酒吧里,一边品尝着麦卡伦珍稀桶威士忌,一边分享见解,深入挖掘你们两人所体现的细微差别:真正地了解它

小贴士:在编写任何一行代码,即使是实验性的,确保你拥有以最简单的方式回答问题基本性质所需的数据(一个 if/else 语句)。如果你没有,看看你是否可以获取它。如果你无法获取,就继续做你能解决的问题。

作为仅通过偶然的随意会面使用数据解决问题的危险的一个例子,让我们假设我们都在一家内容提供商公司工作。由于我们小公司的商业模式性质,我们的内容在互联网上通过定时付费墙列出。对于前几篇文章的阅读,不显示广告,内容免费查看,交互体验不受干扰。在阅读了一定数量的文章后,会呈现越来越令人讨厌的一系列弹出窗口和干扰,以诱使读者注册订阅。

系统的先前状态是通过基本启发式方法控制的,该方法通过计算最终用户看到的文章页数来设置。意识到这可能会让在平台上第一次会话期间浏览的人感到厌烦,因此将其调整为查看会话长度以及每篇文章已读行数的估计。随着时间的推移,这个看似简单的规则集变得如此难以驾驭和复杂,以至于网页团队要求我们的数据科学团队构建一个能够预测每个用户级别的干扰类型和频率,以最大化订阅率的系统。

我们花费了几个月的时间,主要使用先前构建来支持启发式方法的工作,让数据工程团队创建与前端团队使用的生成决策数据的数据结构和操作逻辑相对应的镜像 ETL 流程。有了数据湖中的数据,我们继续构建一个高度有效且准确的模型,该模型似乎在我们的所有保留测试中都表现出色。

在发布到生产环境中时,我们意识到了一个问题,如图 14.1 所示。作为构建解决方案的数据科学团队,我们没有做到的是检查我们用于特征的所用数据的条件。

14-01

图 14.1 在此情况下,未能理解数据服务等级协议会导致模型非常糟糕。

我们从湖仓中的对象存储数据中训练了我们的模型。在模型开发过程中处理提取的数据时,我们没有意识到数据提取的机制。我们假设我们使用的特征将直接在数据湖中几乎实时可用。然而,为了降低成本并最小化对生产系统的影响,数据工程团队将其 ETL 从 Redis 开发为一个每 15 分钟触发一次的周期性导出。从我们用于训练的数据中,我们看到了会话的消费数据,这些数据被分成 5 秒的活动块,我们可以利用这些数据轻松地创建滚动聚合统计作为主要特征。从逻辑上讲,我们可以假设数据将通过 5 秒触发器连续加载。

一旦解决方案进入生产,问题不仅仅是根据活动个性化效果。相反,巨大的问题是每个人在看到他们的第一篇文章时都会立即收到“显示所有广告和弹出窗口”的相同预测。由于缺乏相关特征数据,模型完全无效。我们让整个网站在整整一天内陷入混乱,迫使项目完全重新设计,最终不得不丢弃大部分基于无法轻易提供给模型的数据的解决方案。哎呀。

让我们来看看在开始数据科学项目时我思考的三个主要指导原则以及为什么它们很重要。如果这三个原则没有得到遵守,根据我的经验,项目能够持续生产的可能性几乎为零——无论其实现多么巧妙,无论其在解决问题上的成功程度如何,或者组织内部使用它的热情有多高。

14.1.1 确保你有数据

这个例子可能看起来有点愚蠢,但我已经看到这种情况发生了数十次。无法获取模型服务所需正确数据是一个常见问题。

我看到过团队使用手动提取的数据集(一次性提取),用这些数据构建了一个真正出色的解决方案,当准备将项目部署到生产时,他们才在最后一刻意识到构建这个一次性提取的过程需要 DE 团队完全手动操作。使解决方案有效的必要数据被隔离在 DS 和 DE 团队无法访问的生产基础设施中。图 14.2 展示了一个我见证过太多次的相当熟悉的情况。

14-02

图 14.2 在将解决方案部署到生产之前,最好确保你有数据。

如图 14.2 所示,如果没有基础设施将数据转换为可用于预测的格式,整个项目就需要创建一个,以便 DE 团队构建所需的 ETL 以按计划实现数据。根据数据源复杂性的不同,这可能需要一段时间。毕竟,从多个生产关系数据库和内存键值存储中提取的加固生产级 ETL 作业并非易事。这样的延误可能导致(并且已经导致)项目放弃,无论数据科学部分解决方案的预测能力如何。

如果预测需要在在线进行,那么这个复杂 ETL 作业创建的问题变得更加具有挑战性。到那时,问题不再是 DE 团队努力让 ETL 流程运行;相反,工程组织中的不同团队将不得不将数据积累到单一位置,以便生成可以输入到对 ML 服务的 REST API 请求中的属性集合。

整个问题是可以解决的。在 EDA 期间,DS 团队应该评估数据生成的性质,向数据仓库团队提出尖锐的问题:

  • 数据能否压缩到最少的表以降低成本?

  • 如果出现问题,团队优先修复这些来源的优先级是什么?

  • 我能否从训练层和服务层访问这些数据?

  • 查询这些数据用于服务是否满足项目 SLA?

在开始建模工作之前,了解每个问题的答案可以帮助决定是否参与项目工作。如果数据尚未准备好供消费,这些答案可以给 DE 团队时间来优先处理,并在手动提取的最终数据集副本上进行建模的同时,异步地构建这些数据集。

14.1.2 检查数据来源

除了围绕数据可用性的基本问题之外,还有一个极其重要的数据来源问题。具体来说,数据是通过什么机制进入数据仓库或数据湖屋的?了解即将进入项目的潜在数据来源,有助于你理解其稳定性、清洁程度以及将其包含在模型中的风险。

为了说明来源的重要性,让我们假设我们有三个独立的表,我们从中获取数据来解决一个特定的监督学习问题。所有三个表都存在于由云对象存储支持的数据仓库中,并且每个表都是 parquet 格式。从数据最终用户的角度来看,每个表看起来都很相似。每个表中都有一点重叠,因为一些数据似乎是相同基础信息的重复信息,但所有表都可以根据外键相互连接。

图 14.3 展示了查看这三个表中的数据时可见的信息。

通过查看行数和字段名,我们可以清楚地看到我们正在查看电子商务数据。表 A 是我们的主会员表,B 是我们的订单数据,C 是我们的网站流量数据。如果我们到此结束对数据来源的调查,那么在利用这些数据进行建模时,我们可能会遇到一些令人不快的惊喜。

14-03

图 14.3 湖屋表中存在的三个数据表,可供我们的项目使用

在我们开始使用这些数据创建特征集之前,我们需要了解摄取机制。如果不了解数据何时加载以及每个表更新的频率,我们创建的任何用于创建插补向量的连接都可能存在重大的正确性问题。

主要是因为这些数据集是由不同的工程团队生产和编排的,而且也因为生成数据的系统性质,它们之间在最近数据上达成一致的可能性非常低。例如,在最近的站点活动数据中,后续的购买事件数据可能延迟超过一小时。理解这些 SLA 考虑因素对于确保从这些 ETL 过程中生成的特征数据准确至关重要。图 14.4 显示了这些表的扩展视图,其中包含通过质疑拥有将数据填充到表中的作业的 DE 团队获得的额外数据。

14-04

图 14.4 与 DE 团队讨论数据来源、数据如何到达以及关于数据可以和不可以做什么的关键细节所获得的信息补充

从 DE 团队获得这些新细节后,我们可以就数据源做出一些相当关键的决策。然后我们可以将这些信息输入到我们的数据目录解决方案中。这样的例子可能看起来像表 14.1。

表 14.1 我们样本用户跟踪数据的数据目录条目

表名 更新频率 描述/备注
成员表 10 分钟 用更改覆盖现有数据。历史更改仅在原始表中反映。如果需要建模状态更改,请使用 Members_Historic 表。由前端网络团队拥有。
订单表 1 小时+每日核对 从实时订单和发货源系统获取订单数据。要获取最新状态,必须使用版本键值上的窗口函数以获取真正的自然键条目。由后端市场工程团队拥有。
站点活动表 实时+每日核对 插入顺序不保证正确。当用户在移动设备上时,数据可能延迟数小时。成员使用 VPN 可能导致位置数据不准确。嵌套模式元素的更改。由 DE 团队拥有。

基于在特征存储中收集的这些笔记,DS 团队可以更好地理解数据的细微差别。彻底编制源数据系统的性质可以防止可能困扰 ML 解决方案的最坏问题之一:生成高质量预测所需的数据不足。

在生产开发阶段初期花额外的时间来理解数据究竟在何时、何地以及如何到达用于训练和推理的源系统,可以避免许多问题。我们可以了解哪些数据可以用于特定的用例(在这个示例场景中,是用于历史相关性目的的成员属性与其他表的连接)。我们可以根据最终用途定义的特征来识别项目的局限性;在我们的例子中,我们显然不能使用活动数据来处理极低服务等级协议(SLA)的用例。如果项目需要的数据新鲜度比当前 ETL 过程提供的更新频率短,我们可以在发布前探索缩短 ETL 过程以防止灾难性的生产发布问题。

有足够的时间准备,数据工程(DE)团队可以与机器学习(ML)开发工作并行工作,以提供所需的数据格式,确保实施正在作用于足够近期的数据以支持项目需求。

当我们开始考虑合规性问题,这些问题关于数据来源变得更加复杂。以下是一些需要仔细思考的要素:

  • 您想要用于建模的数据是否受到法规的约束,例如欧盟的通用数据保护条例(GDPR)、个人身份信息(PII)或健康保险可携带性和问责制法案(HIPAA)?如果是这样,请遵守这些要求。

  • 关于您正在使用的数据,是否有内部限制关于可见性?

  • 您的数据中是否存在固有的偏见,这可能会从道德上损害您正在构建的模型?(如果您正在处理与人类相关的数据,答案可能是响亮的肯定,您应该仔细考虑正在收集的数据的来源。)

  • 源系统和为这些表提供数据的流程多久会因维护或完全失败而停机?ETL 通常是否稳定?

  • 这些表上的模式多久更改一次?对于数据结构中的嵌套元素(主要适用于基于 Web 的数据集)是否有规则和流程来管理它们是否可以更改?

  • 生成的数据是从自动化流程(应用程序)还是从人工输入中产生的?

  • 是否有数据验证检查正在运行以确保只有干净的数据被允许进入这些表?

  • 数据是否一致?源数据是否持久?在向表中写入数据时是否涉及隔离以消除正确性问题发生的可能性?

当信息来自不同的系统时,我们有一长串关于数据质量需要检查的其他事情。在所有关于数据的事情中,最重要的是在使用任何数据集之前,不要相信任何东西,要验证一切。在浪费时间构建基于你用来训练的数据性质的使用案例不会工作的模型之前,提出问题并获取关于你的数据的信息。

将未知和可能错误的数据投入模型中是创建完全无法使用的垃圾解决方案的可靠方法。相信我,我已经不止一次地学到了这个教训。

14.1.3 寻找真相的来源并与之一致

我还没有在、与或为一家完美无瑕的数据公司工作过。虽然许多组织几乎拥有完美的数据模型、高度稳健的数据工程管道和有效无瑕的摄取架构,但数据本身完美的概念几乎是一个难以达到的目标。

让我们假设我们是一家面向企业的公司,为广泛的行业提供人力资源服务。我们的 DE 团队是世界级的,从公司早期就采用了能够出色处理多年业务变化的数据模型。信息以灵活的关系星型模式呈现,并允许在数据仓库中进行快速分析。

三年前,随着转向云计算的到来以及随之而来的成本效益数据湖(比本地解决方案更便宜)的范式转变,事情开始发生变化。那些所有新的数据分析数据源都必须经过 DE 团队的日子已经过去了。公司中的任何团队都可以创建数据,将其上传到对象存储,将源注册为表,并为其目的使用它。云供应商承诺的数据访问民主化肯定将是我们公司效率和洞察力的一次真正革命!

然而,事情并没有按这种方式发展。 随着湖泊恶化成为沼泽,类似的数据的多个副本开始诞生。图 14.5 显示了数据湖分析层中多个位置的行业类型的单一层次表示。

14-05

图 14.5 在数据湖上启用自助服务后,如果没有统一的事实来源,可能会让每个人的生活更加困难。

如果我们打算使用数据湖中可用的这些产品层次结构来开展我们的机器学习项目,我们应该选择哪一个?在如此多的重叠和不一致的情况下,我们如何确定哪个是最相关的?

简直没有测试所有这些的方法——尤其是,如图 14.5 底部提到的,考虑到同一组在各个提交周期存在多个版本。我们应该怎么做?

我发现最成功的方法是将团队对齐到一个提供单一事实来源的过程,这个来源满足每个团队的需求。这并不意味着每个人都必须遵守相同的定义,即哪些公司群体需要进入哪个聚合桶。相反,这意味着以下内容:

  • 维护每个部门支持其与数据交互需求的定义的单一副本(没有 _V2 或 _V37 的相同数据副本,这只会增加混淆)。

  • 选择正确的缓慢变化维度(SCD)更新类型,以满足每个团队对这种数据的需求和用途。(一些团队可能需要历史参考,而其他团队可能只需要最新的值。)

  • 标准化。如果它是一只鸭子,就称它为鸭子。用独特而可爱的名字,如 aquatic_avian_waterfowl_fun_plumage,对任何人都没有好处。

  • 定期清理。如果数据没有被使用,就将其存档。保持湖泊健康意味着每个人都可以在其中游泳。

  • 清点数据。在知识库中使用实体关系(ER)图,构建或购买数据目录,或维护每个表中每列的详细文档。

虽然所有这些任务看起来像是大量的工作——确实如此——但它们是现代企业运行的基础。拥有可理解的数据不仅有利于机器学习项目,还允许相同(大部分)干净的数据在分析组和数据科学组之间共享。这意味着当讨论业务状况和可以利用这些数据的创新未来工作时,每个人都使用同一种语言。

在数据质量方面,作为机器学习项目的一部分,你绝对不应该尝试自行纠正数据(即使这样做很有诱惑力)。单一事实来源的概念比你可能认为的要重要得多。

14.1.4 不要将数据清理嵌入到你的生产代码中

这将是一个敏感的话题。尤其是对你的数据工程朋友来说。

让我们假设我们正在从事一个旨在估计客户是否应该自动注册一个提供比当前信用卡更高信用额的信用卡优惠的项目。我们已经探索了数据仓库中的数据,并确定了构建原型所需的最小特征数量(一开始保持简单),以及获取数据的三个所需表。

在对数据进行探索和验证时,我们遇到了问题。从重复数据到不一致的产品描述,再到原始金融交易历史数据中的缩放因子问题,我们有很多工作要做。

如果我们利用我们机器学习平台中可用的数据清洗工具来解决这些问题,我们将在代码库中有一个专门用于数据预处理任务的整个模块,以修复数据。运行数据通过预处理阶段,然后是特征工程,最后是模型训练和验证,我们将有一个生成模型的过程,效果相当好。

然而,预测时会发生什么?如果我们坚持这种范式,由于源数据质量极差,我们有三种选择:

  • 复制用于预测任务的填充、去重和正则表达式代码。(由于可维护性考虑,这是一个坏主意。)

  • 创建一个独立的实用预处理模块,可以从训练和推理任务中调用。(这是一个更好的主意,但仍然不是理想的。)

  • 将清洗逻辑集成到一个完整的管道对象中。(这是一个更好的主意,但可能浪费和昂贵。)

假设在我们急于快速完成项目的过程中,我们完全忘记了做这些事情。我们的数据清洗逻辑完全构建在我们的训练代码库中,模型经过验证工作得相当好,我们准备将其部署到生产环境中。

在对生产数据量极小的一部分进行测试时,我们开始意识到,通过监控模型性能,多个客户被反复联系,他们的信用额度被提高了好几次。其他看似有资格的客户正在为没有的卡片和服务申请信用额度增加请求。基本上,我们构建了一个伟大的模型,它正在预测垃圾数据的状态。图 14.6 说明了这个项目所造成的情况。

图 14.6,虽然是一个遗忘和混乱的极端案例,但揭示了当机器学习团队选择修复数据质量问题时所列出的可能解决方案。当你自己修复数据时,你现在负责这个。与其用数据构建解决方案,不如拥有一个解决方案和数据修复任务。

14-06

图 14.6 在机器学习代码中修复数据质量问题可以造成大量的混乱。

尽管这种特定场景在某些组织中(例如,小型初创公司,其中数据科学家可能同时担任数据工程师和数据科学家角色)是不可避免的,但推荐的行动方案仍然是相同的:具体来说,数据清洗代码永远不应该与建模解决方案链接在一起。图 14.7 显示了更好的数据质量问题解决方案。

14-07

图 14.7 修复数据质量问题更好的前进方式:不在机器学习代码中嵌入数据修复任务

在长期保持数据修复任务功能性的更可持续和更受欢迎的方式是在源头修复数据。这有助于解决几个问题:

  • 数据已经清洗以供其他用途。

  • 从模型训练和推理代码中移除了昂贵的去重、问题修正、插值和复杂的连接操作(减少了复杂性)。

  • 数据在训练和推理之间是可靠的(不存在训练和推理之间逻辑不匹配的风险)。

  • 特征监控(漂移检测)得到了极大的简化。

  • 分析和归因测量得到了极大的简化。

保持用于建模的数据的干净状态是稳定和高质量机器学习解决方案的基础。虽然机器学习包包括许多用于纠正数据问题的工具,但最可靠的数据正确性强制方式是在数据存储的源头进行。

14.2 监控你的特征

在生产级机器学习部署中,一个经常被忽视的部分是密切关注进入模型的特征。作为专业的数据科学家,我们真正地花费了大量的时间和精力分析与我们特征相关的每一个属性。很多时候,解决方案被部署到生产环境中,唯一被监控的是模型的输出。这导致性能下降时出现意外的惊喜,使我们处于匆忙诊断变化、为什么可能发生变化以及如何解决问题的境地。

有解决办法的。

假设我们正在附录 A 中的狗粮公司工作。我们已经将模型部署到生产环境中,对预测的狗粮需求进行了监控,产品浪费量大幅减少。我们有一个彻底且自动化的归因分析系统,它跟踪预测性能,显示出比预期更高的项目性能结果。

许多周后,我们的预测开始变得没有意义。它们预测每个配送站点的库存订购量都大大减少。幸运的是,我们有人工智能在循环中验证订单请求,所以并没有全盘皆输。我们在几天的时间里,越来越担心地观察着每种产品类型的订单预测都下降到极低水平。

我们恐慌,重新训练模型,并看到基于我们对先前产品需求的理解,结果变得如此荒谬,以至于我们完全关闭了预测系统。直到一周后我们深入挖掘特征数据,才找到罪魁祸首。图 14.8 显示了模型用于预测的一个关键特征。

14-08

图 14.8 一个关键特征的 ETL 变化对数据科学家团队来说是非常糟糕的一周。

图 14.8 顶部的图表显示了我们某个区域分销中心的销售数据,而底部的图表显示了财务团队要求 DE 团队为公司的“更准确”的报表范式创建的新调整后的销售数据。在重叠期(过渡期)内,这两列销售数据都被填充,但在过渡期结束时,数据停止流入原始列。

那么,我们的模型发生了什么?由于销售数据是模型的一个关键部分,并且因为我们使用基于最近七天的数据的时间窗口的插补方法,缺失数据的插补值开始迅速趋向于零。模型对这一特征赋予了如此大的权重,不仅接收到了它在训练期间没有评估过的数据(毕竟,零销售是一个坏事情,并且在我们没有破产的公司中并不存在),而且这个值如此之低的影响实际上在短时间内将所有产品的需求预测推到了零。

在讨论机器学习中的空值处理(填充 0、在训练集数据值上插补、平滑插补等)的争论之前,我们如何能在问题真正恶化之前捕捉到这个问题?即使我们没有提前警告这个变化,我们如何能在特征值上建立警报,以便当第一个值降到零时,我们知道这个特定的特征有问题?

最简单的解决方案是在训练过程中收集每个特征的基本统计信息(如果你在一个具有大量训练集的分布式系统上,可以近似统计)。这些统计信息可以存储在一个表中,该表使用基本的 SCD 类型 2 方法进行版本控制:为特征数据添加新行,并在每次后续运行中增加版本。然后可以安排一个每日任务,其唯一目的是比较用于预测的最近n小时或天的值与上次训练运行期间特征的值。以下列表展示了这个概念的基本示例,针对我们场景中的数据(图 14.8 顶部的图表)运行。

列表 14.1 一个简单的特征监控脚本

import numpy as np
prior_to_shift = np.append(ORIGINAL_DATA, 
BOUNDARY_DATA)                                              ❶
prior_stats = {}                                            ❷
prior_stats['prior_stddev'] = np.std(prior_to_shift)        ❸
prior_stats['prior_mean'] = np.mean(prior_to_shift)
prior_stats['prior_median'] = np.median(prior_to_shift)
prior_stats['prior_min'] = np.min(prior_to_shift)
prior_stats['prior_max'] = np.max(prior_to_shift)
post_shift = np.append(BOUNDARY_DATA, 
np.full(ORIGINAL_DATA.size, 0))                             ❹
post_stats = {}                                             ❺
post_stats['post_stddev'] = np.std(post_shift)
post_stats['post_mean'] = np.mean(post_shift)
post_stats['post_median'] = np.median(post_shift)
post_stats['post_min'] = np.min(post_shift)
post_stats['post_max'] = np.max(post_shift)
bad_things = "Bad things are afoot in our sales data!"
if post_stats['post_mean'] <= prior_stats['prior_min']: 
    print(bad_things + 
      " Mean is lower than training min!")                  ❻
if post_stats['post_mean'] >= prior_stats['prior_max']: 
    print(bad_things + 
      " Mean is higher than training max!")                 ❼
if ~(prior_stats['prior_stddev'] * 0.5  
  <= post_stats['post_stddev'] <=  2\. 
  * prior_stats['prior_stddev']): 
    print(bad_things + " stddev is way out of bounds!")     ❽
>> prior_stats
{'prior_stddev': 70.23796409350146,
 'prior_mean': 209.71999999999994,
 'prior_median': 196.5,
 'prior_min': 121.9,
 'prior_max': 456.2}
>> post_stats
{'post_stddev': 71.95139902894329,
 'post_mean': 31.813333333333333,
 'post_median': 0.0,
 'post_min': 0.0,
 'post_max': 224.9}
>> Bad things are afoot in our sales data! Mean is lower than training min!

❶ 我们场景中在变化之前的数据(原始的销售数据列)

❷ 一个简单的字典,用于安全存储我们的特征数据统计值

❸ 训练后的特征统计信息(标准差、均值、中位数、最小值和最大值)

❹ 用于与训练统计信息进行比较的后续变化数据

❺ 每次验证运行的一个字典(健康检查作业脚本,用于测量每个特征的这些统计信息)

❸ 基本示例检查特征现在的均值是否低于训练期间的最小值

❷ 对均值是否高于训练值的最大值进行类似检查

❽ 对特征方差是否发生显著变化的广泛检查

这段代码故意简单,目的是提高人们对需要监控相对简单元素以计算的需求的认识。你最终可能开发的特定特征监控工具包的规则可以变得像你的用例所需的那么复杂和功能丰富,或者可以保持相对简单,作为一个轻量级的实用框架来监控模型中使用的任何特征的基本统计数据。

在现实世界的场景中,我们不仅会从所有特征中检索数据,还会查询一个表(或存储这些统计数据的表或服务,例如 MLflow 的跟踪服务器)。显然,警报不会是一个简单的print语句,而是一个通过呼叫中心警报、电子邮件或类似机制的通知,让团队知道前方有一个相当大的问题和破坏性的一天。围绕所有这些需求架构是高度特定于你可能在运行的底层基础设施的,所以我们在这里保持简单,使用print语句和字典。

在撰写本文时,正在开发中的开源软件包旨在为开源社区解决这个问题。我强烈建议你进行一些研究,以确定哪一个适合你的语言、平台和生态系统。然而,为了简单起见,即使基于列表 14.1 中的逻辑构建一个简单的验证脚本也能完成任务。你唯一不想做的事情是在将解决方案部署到生产后完全忽略特征。

这可能看起来像一个愚蠢的例子,但……

我可以想象你可能正在想什么:“这太荒谬了。谁会做这样的事情?这个例子只是一个夸张的讽刺!”

好吧,亲爱的朋友,我可以向你保证,在我职业生涯中,这种确切的事件总共发生了六次。在那第六次之后,我终于吸取了教训(可能是因为一个严重、业务关键性的模型受到了影响)。

正如我之前讨论的,我并不总是使用复杂的实现来检查特征的健康状况。有时它只是一个基于 SQL 的脚本,在一段时间内对存储表中的基本计算进行基本计算,该表包含与上次训练相同的特征集的基本指标。我不花太多时间微调阈值应该是什么,也不构建复杂的逻辑,利用统计过程控制规则或其他类似的东西。很多时候,它就像前面例子中描述的那样简单:数据的平均值、方差和总体形状是什么?它是否与原始数据在同一水平?平均值现在是否高于之前记录的训练最大值?是否低于最小值?方差是否低一个数量级,或者更高?

在这些过于广泛的检查到位后,您可以监控可能导致模型根本性崩溃的大量特征变化。这通常是朝着识别即将发生的故障迈出的良好一步,即使不是识别故障,至少可以确定当您更紧密监控的预测和归因开始崩溃时应该查看的地方。

监控的最终目标是节省时间和减轻在生产中运行的模型根本性损坏造成的损害。问题诊断、修复并返回到生产中的良好状态越快,您的日子(或周、月、年)就会过得越好。

14.3 监控模型生命周期中的其他所有内容

在第十二章中,我们详细讨论了监控特征漂移。这非常重要,但对于生产机器学习解决方案来说,它只是关于适当机器学习监控的整个故事的一部分。

让我们假设我们正在一家公司工作,该公司在生产中有稳定的机器学习项目足迹:14 个主要项目,它们在业务中解决不同的用例。凭借我们 10 个数据科学家和 4 个机器学习工程师的团队,我们发现很难扩展团队以支持额外的工作负载。

对于我们所有人来说,我们一天中的大部分时间都被分配给了仅仅保持灯火通明。在任意一天,某个模型都需要一点关注。无论我们是否忙于处理最终用户向我们报告的预测下降,还是需要进行的常规分析维护以检查特定解决方案的健康状况,我们都没有太多时间去思考接受另一个项目。如果我们分析我们在维护任务上花费的时间,我们可能会看到类似于图 14.9 的情况。

14-09

图 14.9 尽管我们在生产中有许多模型,但我们大部分时间都在试图弄清楚为什么它们会漂移,如何修复它们,讨论为什么我们必须修复它们,以及进行修复工作。这是“保持灯火通明”的数据科学工作。

这种日常生活是令人悲伤的。问题不在于模型本身不好,也不在于包含它们的解决方案不好。事实是,模型会漂移,性能会下降。如果我们不积极使用自动化解决方案监控我们的模型,我们最终会耗尽团队在故障排除和修复问题上的资源,以至于唯一可用的接受新项目工作的选择是要么雇佣更多的人(祝您在长时间内获得预算好运!)或者能够看到以下情况:

  • 发生了什么变化

  • 它是如何变化的

  • 漂移(在特征、模型重新训练和预测中)的可能嫌疑对象是什么

通过了解我们模型生命周期的所有方面,我们可以大大减少故障排除的负担(同时,完全消除手动监控的行为!)图 14.10 说明了模型生命周期中应该实施某种形式的监控以减轻图 14.9 中显示的可怕负担的部分。

14-10

图 14.10 需要监控的机器学习项目部分

在许多这些阶段中的观察可能看起来有点过度。例如,为什么我们应该监控我们的特征工程数据?预测不应该是足够好的吗?

14-11

图 14.11 显著的特征漂移及其对不同类型模型的影响

让我们看看为什么我们的团队应该监控特征及其可能进行的任何修改。图 14.11 比较了在训练期间(左侧)和后来在生产推理期间(右侧)看到的相同特征的分布。

模型在显示的数据范围内看到了特征。后来,特征漂移到模型在训练期间接触到的范围之外。根据使用的模型不同,这可能会以各种方式表现出来,所有这些方式都同样糟糕(只要特征对模型至少有一定的重要性)。

如果团队成员没有监控这种分布的变化,他们会经历什么样的过程?让我们保持简单,假设他们的实现一开始就相当精简,只有 30 个特征。当预测开始出现难以理解的混乱结果时,需要对特征的当前状态和训练期间存在的历史值进行数据分析。将执行许多查询,引用训练事件,绘制图表,计算统计数据,并需要进行彻底的根源分析。

用 Kimberly “Sweet Brown” Wilkins 不朽的话说,“没有人有时间做那件事。”这些事后调查是漫长的。它们是复杂的,单调的,并且令人疲惫。在 14 个生产项目运行,14 个团队支持公司的机器学习需求,且没有对流程进行监控的情况下,这个团队将完全被零价值的工作淹没。在最佳情况下,他们可能每周要进行两到三次调查,每次至少需要一个人一整天的时间来完成,还需要一天来启动新的训练运行并评估测试结果。

然而,通过在管道的每个方面设置监控,团队可以确定发生了什么变化,变化了多少,以及偏差开始的时间。这可以节省整个人的工作量,让团队有更多时间自动化调查为什么他们的模型开始崩溃,让他们有更多时间投入到新的项目中。

这个监控系统不仅仅关注模型中进入的特征。它还意味着要查看以下内容:

  • 生成特征——交互、缩放和基于启发式数据操作

  • 模型(s)——每次训练运行的度量

  • 预测——对于pmfpdf,回归的均值和方差,混淆矩阵和分类的度量

  • 归因——衡量解决方案针对其试图解决的问题有效性的业务指标的稳定性

  • 性能考虑——对于批量,作业运行时间;对于在线,响应 SLA

  • 特征随时间的效果——定期递归特征消除和随后的不必要特征剔除

通过关注监控 ML 支持解决方案的每个组件在整个生命周期,你可以通过消除繁琐的工作来帮助扩大你的团队规模。当人们不只是保持系统运行时,他们可以更多地关注新的和创新的解决方案,这些解决方案可以在一段时间内证明更大的商业价值。响应监控健康检查的另一个重要部分是,在解决问题的同时,尽可能保持解决方案的简单性。

14.4 尽可能保持简单

简单性是 ML 应用中独特的美。许多新进入该领域的人嘲笑它,因为他们最初认为复杂的解决方案很有趣,而最简单的解决方案才是持久的。这不仅仅是因为它们比复杂方案更容易保持运行——主要是因为成本、可靠性和升级的简便性。

让我们假设我们是一个相对较新的、级别较低的团队。每个团队成员都沉浸在 ML 领域的最新技术进步中,在利用这些尖端工具和技术开发解决方案方面能力很强。让我们暂时假设我们的同事认为使用“旧”技术,如贝叶斯方法、线性算法和启发式方法来解决问题的人是拒绝学习未来技术的卢德分子。

团队接到的第一个项目来自运营部门。零售集团的资深副总裁(SVP)在会议上接近团队,请求一个运营部门很难很好地扩展的解决方案。SVP 想知道 DS 团队是否可以使用仅作为解决方案素材的图像,确定图片中的人是否穿着红色衬衫。

DS 团队立即转向他们在最新和最佳解决方案工具箱中经验丰富的部分。图 14.12 说明了展开的事件。

14-12

图 14.12 在尝试更简单的方法之前尝试高级方法时的令人沮丧的结果

在这种场景中会发生什么?最大的问题是团队成员在没有验证更简单的方法的情况下采取的复杂方法。他们选择关注技术而不是解决方案。通过专注于一个高度先进的解决方案来解决问题,而不考虑一个远为简单的方法(在每张图片的中心线上方三分之一处抓取像素块,确定这些像素的色调和饱和度,并将它们分类为红色或非红色),他们在解决问题的过程中浪费了数月时间,并可能花费了大量金钱。

这种场景在许多公司中非常频繁地发生——尤其是那些刚开始接触机器学习的公司。这些公司可能觉得有必要快速推进他们的项目,因为围绕人工智能的炒作声势浩大,他们认为如果不以任何代价让 AI 工作,他们的业务将面临风险。最终,我们的示例团队认识到最简单的解决方案可能是什么,并迅速开发出一个大规模运行且成本最低的解决方案。

追求简单性的想法存在于机器学习发展的两个主要方面:定义你试图解决的问题,并构建最简单的解决方案来解决问题。

14.4.1 问题定义的简单性

在我们之前的场景中,问题定义对业务和机器学习团队来说都很清晰。“请为我们预测红色衬衫”无法简化为比这更基本的工作任务。然而,在进行的讨论中仍然发生了根本性的崩溃。

在定义问题时追求简单性,围绕着要提供给内部(业务单元)客户的两个重要问题的基本属性:

  • 你希望解决方案做什么? 这定义了预测类型。

  • 你将如何使用解决方案? 这定义了决策方面。

如果在早期与业务单元的会议中除了这两个问题之外没有讨论其他任何事情,项目仍然会成功。解决业务问题的核心需求可以比其他任何主题更直接地导致项目成功。业务只是想确定员工是否穿着旧公司品牌的红色衬衫,以便知道发送他们新的品牌蓝色衬衫。通过专注于红色衬衫与蓝色衬衫的问题,可以实现一个远为简单的解决方案。

在接下来的讨论中,我们会了解到照片的性质及其固有的同质性。在这两个基本方面定义之后,团队可以专注于更小的一系列潜在方法,简化解决问题所涉及的范围和工作。然而,如果没有定义并回答这些问题,团队将不得不进行过于宽泛和富有创造性的解决方案探索,这是有风险的。

团队成员一听到图像分类,就立刻转向 CNN 实现,并且连续数月将自己锁定在一个高度复杂的架构中。尽管它最终相当好地解决了问题,但它是以一种极其浪费的方式做到的。(在它们上训练的 GPU 和深度学习模型比可以在智能烤箱上运行的像素色调和饱和度分桶算法要贵得多。)

将特定潜在项目的定义保持得如此简单不仅有助于引导与请求解决方案的业务单元的初步讨论,而且还能为在构建过程中实现尽可能少的复杂性提供一条路径。

14.4.2 实现的简洁性

如果我们继续分析我们的红衫分类场景,我们可以简单地看看团队提出的最终解决方案,以说明他们本应该首先做什么。

我,以及多年来在这个职业中认识的许多人,都多次学到了这个痛苦的教训。为了追求酷炫而构建一些东西,当我们意识到那个酷炫的实现最终维护起来有多么困难时,我们常常非常后悔。我们忍受着脆弱的代码和高度复杂的过程耦合,这些过程在构建时看起来非常有趣,但最终在代码完全崩溃时,调试、调整或重构却变成了一个完全的噩梦。

不想过多地举例说明,我将展示我思考请求我帮助解决的问题的方式。图 14.13 展示了我的思考过程。

这个流程图根本算不上夸张。我几乎总是首先像尝试用基本的聚合、算术和 case/switch 语句解决问题一样思考问题。如果那不行,我就转向贝叶斯方法、线性模型、决策树等等。我最不想一开始就尝试实现的是需要数百小时训练的对抗网络,当它崩溃时,需要花费数天(或数周)来调试模式崩溃以及如何调整我的 Wasserstein 损失来补偿消失的梯度。非常感谢,但只有在用尽所有其他解决问题的方法后,我才会使用这些方法。

在最纯粹的形式下,图 14.13 展示了我的心理核心:我很懒。真的,非常懒。我不想开发自定义库。我不想构建极其复杂的解决方案(好吧,这部分是真的;我喜欢构建它们,我只是不想拥有它们)。

14-13

图 14.13 评估问题机器学习方法的作者思考过程

我只是想以代码能够正常工作的方式来解决问题。我希望能够有效地解决问题,以至于人们忘记我的解决方案正在运行,直到有人因为平台服务中断而惊慌失措,我们才集体回忆起实际上运行着业务关键部分的代码。要达到这种近乎懒惰的最高版本,唯一的方法是以最简单的方式构建东西,设置监控在别人之前提醒你事情不正常,并且拥有一个干净的代码库,使得修复只需要几小时而不是几周。

选择简单的设计来解决问题带来的另一个好处是,开发解决方案的过程(软件工程方面的实际操作部分)变得更加容易。设计变得更容易进行架构设计、开发和协作。这一切都始于为代码库构建有效的线框图。

14.5 机器学习项目的线框设计

在我们第一次真正的生产级机器学习项目(至少对我来说是普遍的,与我在职业生涯中互动过的同行们)之后,我们都学到了一个真正痛苦的教训。这个真正痛苦的教训在解决方案的开发过程中以轻微的形式出现,但只有在支持解决方案数月之后,这个教训才完全显现。这是关于代码架构的教训,以及缺乏它如何产生真正令人难以承受的技术债务,以至于为了对代码库进行哪怕微小的更改,都需要重构(或重写!)大量代码。

由于对单体脚本造成维护和增强解决方案负担的反感,新觉醒的人通常在代码开发过程中,逐步分离代码的主要功能。

让我们看看当我们观察一个新近明智的机器学习实践者团队时,这一切是如何展开的。他们已经支持了第一个主要(并且可以说是混乱的)代码库几个月,并确定了多种他们组织代码的方式,这些方式并不适合维护。

他们决定,在各个冲刺阶段,随着新功能的开发需求,将代码拆分,以便功能分离。图 14.14 展示了他们的过程。

14-14

图 14.14 没有一般项目代码架构,你将面临大量的重构。

他们很快就会意识到,尽管他们的方法值得尝试,但这并不是构建项目的最简单方法。为什么机器学习代码会是这种情况呢?

  • 脚本中存在紧密的依赖关系,尤其是在黑客马拉松的“只管让它工作”脚本中。

  • 实验性原型主要关注算法,而不是数据处理。最终开发的大部分代码库都在数据处理领域。

  • 代码在开发过程中(以及生产发布后)会频繁更改。

团队在其第三次冲刺结束时意识到,随着开发进程的推进,将所有代码重构为独立的模块会带来大量额外的工作和混乱的代码,使得新功能的实现变得困难。以这种方式处理代码架构根本不可持续;即使只有一个人贡献代码,管理代码就已经足够困难,如果多个人在持续重构的代码库上工作,几乎是不可能的。

存在着更好的解决方案,它涉及到为项目设置一个基本的线框图。虽然我在涉及代码时对“模板”这个词有所抵触,但本质上它就是这样,尽管是松散且可变的。

大多数机器学习项目的架构在最基本层面上可以归纳为核心功能组:

  • 数据获取

  • 数据验证

  • 特征工程、特征增强和/或特征存储交互

  • 模型训练和超参数优化

  • 模型验证

  • 记录和监控

  • 模型注册

  • 推理(批量)或服务(在线)

  • 单元和集成测试

  • 后处理预测消费(如果适用,决策引擎)

并非每个项目都保证拥有所有这些组件,而其他项目可能会有额外的要求。(例如,深度学习 CNN 实现可能需要一个数据序列化层用于批量文件处理和图像增强,而自然语言处理项目可能需要一个用于本体词典更新和接口的模块)。关键是功能的不同分离构成了项目的完整功能部分。如果它们被随意地合并到模块中,代码中的责任边界变得模糊(或者在最坏的情况下,所有内容都在一个文件中),那么修改和维护代码将是一项真正的艰巨任务。

图 14.15 展示了一种替代架构,该架构可以在实验原型(类似于黑客马拉松的快速原型,用于证明模型对公司数据的适用性)完成后立即使用。虽然这个架构中的模块一开始可能包含的内容不多,但它们基于团队在整个项目期间预期的需求作为占位符(占位符)。如果需要新的模块,可以创建它们。如果在发布前的最终冲刺中某个占位符从未被填充,则可以将其删除。

14-15

图 14.15 一个通用的机器学习项目代码线框图,以保持代码逻辑上的组织,更容易在内部开发,并更容易维护

这种通用的模板架构强制执行了关注点的封装。它不仅有助于指导冲刺计划,还有助于避免在冲刺结束时代码库中的合并冲突。它从开发初期就保持代码的整洁,使得查找功能更容易,并有助于使单元测试和故障排除变得远更简单。

在组织概要和创建抽象似乎即使是对于简单的项目来说也是过度夸张,但我可以向你保证,从我整个 productive working life 中花费了整整几个月的时间,除了重写和重构基本损坏的代码架构之外,我什么都没做,这绝对不是这样。与将代码库从纯粹的、蒸馏的混乱中翻译成某种逻辑顺序相比,崩溃抽象和删除占位模块要容易得多。

作为不做什么的例子,以及一个设计不良的 ML 代码架构的相互交织的混乱可以有多糟糕,请参见图 14.16。(是的,那是我做的。)

14-16

图 14.16:在我了解代码设计以及抽象、封装和继承是如何工作之前,我的早期艺术作品之一。不要这样做。

这个例子代表了我最早的项目之一(使用案例的细节已被删除,以免某个公司的律师打电话给我),这让我能够传达我从中学到的教训的严重性。我们可以这么说,相当重大

对于一个大型代码库几乎没有逻辑设计,不仅会影响初始开发。这当然是一个重要的原因,为什么提供线框图很重要(尤其是当整个团队都在一个项目上工作,需要确保代码合并不会覆盖彼此的更改)。当不可避免的变化需要被做出时,缺乏逻辑设计会变得更加痛苦。即使你使用巧妙的命名约定为变量和函数命名,在庞大的脚本中搜索也是极其耗时和令人沮丧的。

我学会了永远不要低估良好的代码设计带来的时间节省。当适当框架时,它能够实现以下功能:

  • 步进模块进行调试或演示

  • 编写隔离的子模块单元测试和模块级单元测试,以确保大量代码的功能性

  • 在几秒钟内直接到达代码中的某个位置(而不是在代码库中搜索几分钟或几小时)

  • 从代码库中轻松提取常用功能并将其放入自己的包中,以便其他项目重用

  • 显著降低 ML 代码库中功能重复的可能性,因为它鼓励从开发一开始就进行抽象和封装

即使对于那些更喜欢在笔记本中完全开发的人来说——顺便说一句,这是可以的,尤其是如果你在一个小团队或是一个人的团队中——这种活动的分离可以使你的开发和长期维护项目代码比其他方式简单得多。毕竟,替代方案是我早期职业生涯中做的事情,从实验性脚本开始,不顾一切地添加功能,直到我留下了一个我像村民拿着长柄叉一样高兴看的弗兰肯斯坦怪物。

关于 ML 代码库中频繁重构的注意事项

最近有人问我:“当我的代码库变得过于复杂、混乱或难以维护时,我应该重构多少?”这让我停下来,思考一个合适的答案。作为框架开发者,我想大声疾呼:“早重构,经常重构!”而作为数据科学家,我回想起多年来经历的痛苦的重构经历,那是一种极度令人沮丧的体验。

我最终像任何写过生产级 ML 代码的非承诺型开发者一样,严肃地回答:“只要你觉得舒服,以便让项目再次可维护。”不幸的是,这不是很好的建议。尽管如此,这也有其原因。

在传统软件工程(无论是 FP 还是 OO)中,由于匆忙妥协标准以尽快推出产品而累积的技术债务,可以通过相对直接的方式偿还:你可以重构代码。它可能已经封装和抽象到一定程度,以至于重构不会太具挑战性。在能力和技术水平的限制内,尽情优化代码以提升性能。如果你更喜欢的话,可以从头开始(模块化)重写整个系统。

ML 代码有些不同。从性能、算法复杂度、数据质量、监控和验证的角度来看,你在编写代码时的每一个决定都会对解决方案的有效性以及代码各部分的整体关联性产生深远的影响。与传统软件工程相比,所有这些“哦,我们稍后再解决”这类可以累积的技术债务,其利率要高得多。

重新重构我们的代码并不那么容易。其中一些可以轻松修改(添加功能、删除功能、更改权重应用方式等),但像从基于树的实现转换为广义线性模型、从机器视觉方法转换为 CNN,或从基于 ARIMA 的实现转换为 LSTM 这样的变化,基本上是对我们项目的全面重写。

改变整个解决方案的基本性质(例如,API 返回值可能因不同包的模型输出而改变,需要重写大量代码)风险极高,可能会延迟项目数月。在开源代码中最终弃用和移除功能可能意味着对代码库主要部分的完全重实现,这可能会意味着

转向不同的执行引擎。解决方案中存在的复杂性越大,我们累积的 ML 技术债务就越多,其利率比软件开发者可能为类似决策承担的利率要高得多。

这是我们无法在开发软件时完全敏捷的一个主要原因。我们需要进行一些预先规划和架构研究,在我们的代码库中,需要创建一种模板,以帮助指导我们代码的复杂部分如何相互交互。

14.6 避免货船崇拜机器学习行为

在这本书中,我一直在强调避免机器学习中的炒作趋势。在本节中,我将重点讨论我认为炒作周期中最具破坏性的形式:货船崇拜行为。

让我们假设我们是一家拥有相对较新机器学习足迹的公司。一些关键的临界业务问题已经得到解决,通常使用经过验证且可能不太复杂的统计方法。这些解决方案在生产中运行良好,得到了有效的监控,并且由于进行了彻底的归因确定和测试,企业意识到了这些解决方案的价值。然后有人读到了这篇文章。

这是一篇来自著名且成功的科技公司的博客文章,介绍了它如何解决一个以前无法解决的问题,这个问题也影响到了我们的公司。文章的作者提到了他们公司新开源的解决方案,详细解释了该算法的工作原理,并在文章的大部分内容中解释了实现的技术方面。

这是一篇优秀的文章,它很好地作为招聘工具吸引顶尖技术人才加入他们的公司。我们公司的读者没有意识到,撰写这篇文章的原因是为了招聘,而不是让一家小公司拾起他们的开源工具并在几周内神奇地解决这个问题。

尽管如此,对这一解决方案的需求如此之高,以至于每个人都同意使用这一新的软件解决方案。制定了一个项目计划,进行了实验,仔细阅读并理解了 API 文档,并构建了一个基本的原型。

看起来,在项目的早期阶段进展顺利,但大约一个月后,计划中的裂痕开始显现。团队意识到以下几点:

  • 算法极其复杂,难以调优得当。

  • 发明该算法的公司可能拥有许多内部工具,这些工具有助于使用该算法更加便捷。

  • 代码中许多元素所需的数据格式与他们存储数据的方式不同。

  • 该工具需要昂贵的云基础设施来运行,以及建立许多他们不熟悉的新服务。

  • 收集的数据不足以避免他们看到的一些过拟合问题。

  • 可扩展性(与成本)问题将训练时间限制在几天,从而减缓了开发进度。

在这些裂缝出现之后,团队成员决定尝试一种远不如之前复杂的方法,并不需要花费太多额外的时间。他们发现,尽管他们的解决方案无法与工具创造者展示的所谓精度相匹配,但仍然相当成功。另一个主要好处是,他们的解决方案复杂度更低,运行成本低得多,并且需要的基础设施正是他们用于机器学习(ML)的平台已经支持的。

只有当团队足够幸运,能够在项目时间表早期就放弃他们已经选择的路径时,这种结果才可能实现。我真心希望我没有像我看到的那样多次看到这种替代方案:团队花费数月时间努力使某事工作,花费了大量时间和金钱,最终却一无所获。

货物崇拜?

货物崇拜行为起源于二战后南太平洋岛屿上。这是某些土著居民的一种倾向,在与使用这些孤立岛屿的战争时期军事人员互动后,他们收到了他们以前从未遇到过的货物和服务(医疗、牙科、技术等)。在随后的几年里,由于这些服务人员的返回,一些岛屿上的团体开始模仿这种行为、服装风格,以及夸张技术,他们相信如果他们模仿来访者,他们有一天会回来。岛民们把外来者(以及他们丰富的供应、商品和技术)看作是他们无法理解但对他们是有益的。

尽管这个术语非常具有偏见和过时,但它由于理查德·费曼在描述一些科学家在实验和验证研究过程中表现出的不充分的科学严谨性时使用这个术语而一直延续到今天。术语货物崇拜软件工程,当应用于利用设计原则、直接复制参考中的代码示例,以及盲目遵循成功公司使用的标准而不评估它们是否需要(甚至是否与用例相关)时,被作者 Steve McConnell 普及。

我在这里使用这个术语,是按照 McConnell 的用法,用来描述那些选择抓住每一个由大科技公司开发出来的技术、算法、框架、平台和创新进步的缺乏经验的团队和初级数据科学家(DS)的行为。通常,这种货物崇拜机器学习行为表现为使用为解决高度复杂问题而设计的极其复杂的系统,而对这些工具和过程是否适用于他们自己的问题却毫不关心。他们看到大型科技公司 A 开发了一个用于调整神经网络权重的全新框架,并假设为了成功,他们自己也必须使用这个框架来解决所有的问题解决项目(我看着你,LSTM,用于基本的销售预测!)。

从事这种行为的团队没有意识到这些技术被开发的真实原因(为了解决那些公司的一组特定问题)以及源代码被共享的原因(为了吸引顶尖人才加入他们的公司)。这不是为了让每个人都抓住一个新范式并开始使用这些技术来处理即使是微不足道和日常的 ML 任务。

跟随新技术热潮并假设最新出现的东西是解决所有问题的万能药,这在生产力、成本和时间方面都是灾难性的。这种方法通常会让公司中经验不足的团队努力使技术工作起来。

更重要的是,一些发布这些工具的公司中更有经验的 DSs 和 ML 工程师会毫不犹豫地承认,他们并不使用这些工具做任何除了它们被设计来解决的事情(至少,我所了解并讨论过这个话题的人是这样的;我无法代表所有人)。他们主要关注最简单的问题解决方法,只有在需要时才会转向更高级的方法。

我看到这种载货文化行为多次上演的思维过程在图 14.17 中得到了体现。

14-17

盲目相信新软件包的 README 和博客文章中的承诺可能会浪费大量时间。

在这个例子中,团队错误地认为新软件包将给他们带来与大型科技公司新闻稿中展示的相同成功水平。团队将这家公司的神奇表现等同于该组织门出来的所有东西。

这并不是说这些大公司不成功。事实上,他们通常雇佣了一些世界上最创新和最聪明的软件工程师。问题是,他们并没有发布所有东西,以便其他人可以利用使他们成功的一切。试图复制这些例子并期望获得相同结果的公司几乎总是会失败。这是由于几个关键因素:

  • 他们没有相同的数据。

  • 他们没有相同的基础设施和工具。

  • 他们没有相同数量的高素质工程师来支持这些复杂解决方案。

  • 他们可能并不是在处理完全相同的用例(不同的客户、不同的生态系统或不同的行业)。

  • 他们没有相同的预算(时间和金钱)来使其工作。

  • 他们没有相同的研发预算来花费数月时间以非常先进的方式迭代解决问题。

我在任何方式、形状或形式上都没有说新技术不应该被使用。我经常使用它们——而且大多数时候,我都享受这样做。我的同事也是如此,与我的经验相似,成功程度各异。新技术很棒,尤其是在它解决了以前无法解决的问题时。然而,我警告的是,不要盲目相信这些技术,假设它们将神奇地解决你所有的麻烦,如果你模仿某个大型创新公司的机器学习方式,它就会以同样的方式为你工作。

避免机器学习中的货船崇拜行为的关键可以归结为本书早期部分中涵盖的几个基本步骤。图 14.18 展示了一个视觉指南,它在我评估新可能的技术时始终效果良好。

14-18

图 14.18 评估新宣布的机器学习技术的流程

我在评估机器学习领域宣布的新事物时尽力做到尽职调查。随着进步的快速步伐和该领域似乎永无止境的炒作,根本没有时间评估所有事物。然而,如果某件事看起来很有希望,来自一个建立良好声誉的来源,并且实际上声称解决了我在努力解决的问题(或过去遇到过的问题),那么它就是这次严格评估的候选人。

令人遗憾的是,绝大多数项目(即使是那些由大型成功科技公司倡导的项目)要么从未获得社区的支持,要么试图解决远远超出团队能力(或当前技术能力)的问题,这些问题不值得花费太多时间。当团队没有在他们的需求范围内评估技术时,这变得很危险。即使这项技术非常酷且令人兴奋,也不意味着它就是你的公司应该使用的东西。记住,使用新技术是一项高风险活动。

坚持最简单的方法并不意味着使用“最新潮的技术”。这意味着只有在它使你的解决方案更容易、更易于维护且更容易持续运行的情况下,才使用最新潮的技术。其他所有东西对你或其他人来说,都只是无足轻重的琐事。

摘要

  • 在尝试将任何数据用于模型之前,应彻底审查其来源、特性和属性。在项目早期确认其效用所花费的时间将节省许多令人沮丧的调查。

  • 任何将要用于机器学习解决方案的数据都需要进行全面监控,异常情况应以可预测的方式进行处理。基于训练和推理数据的变化而产生的意外行为可能会轻易使解决方案失效。

  • 监控特征数据是至关重要的,但它只是模型生命周期中应该被关注的几个部分之一。从 ETL 摄入到特征工程、模型训练、模型重新训练、预测和归因,每个阶段都有应该收集、分析和在行为异常时发出警报的指标。

  • 专注于设计和实现的简洁性,机器学习项目将更快地进入生产阶段,更容易维护,并且可能成本远低于使用定制设计,从而让任何数据科学团队有更多时间解决为公司带来价值的额外问题。

  • 通过为机器学习项目代码库使用标准架构,可以在整个开发过程中将重构保持到最小,团队成员可以轻松理解抽象逻辑所在的位置,并且维护工作将比使用每个项目的定制设计要容易得多。

  • 确保你作为技能组合的一部分采用的新技术适用于你的团队、项目和公司,这将有助于使所有机器学习项目工作更加可持续和可靠。评估、研究和怀疑精神都将对你有益。

15 质量和验收测试

本章涵盖

  • 建立用于 ML 的数据源的一致性

  • 使用回退逻辑优雅地处理预测失败

  • 为 ML 预测提供质量保证

  • 实施可解释的解决方案

在前一章中,我们关注了成功 ML 项目工作的广泛和基础技术主题。在此基础上,需要建立一个关键的监控和验证基础设施,以确保任何项目的持续健康和相关性。本章重点介绍这些辅助流程和基础设施工具,这些工具不仅使开发更加高效,而且一旦项目投入生产,维护也更加容易。

在模型开发完成和项目发布之间有四个主要活动:

  • 数据可用性和一致性验证

  • 冷启动(回退或默认)逻辑开发

  • 用户验收测试(主观质量保证)

  • 解决方案的可解释性(可解释人工智能,或 XAI)

为了展示这些元素在项目开发路径中的位置,图 15.1 展示了本章涵盖的建模阶段后的工作。

15-01

图 15.1 ML 项目的生产级资格和测试阶段

这些突出的行动通常被视为许多我接触过的项目的后知后觉或反应性实施。虽然这些不一定适用于每个 ML 解决方案,但评估这些组件非常推荐。

在发布之前完成他们的行动或实现可以有效地防止内部业务单位客户产生很多困惑和挫败感。直接移除这些障碍可以转化为与业务更好的工作关系,并为你减少许多麻烦。

15.1 数据一致性

数据问题可能是模型生产稳定性中最令人沮丧的方面之一。无论是因为数据收集不稳定、项目开发和部署之间的 ETL 更改,还是 ETL 的一般性实施不佳,它们通常会导致项目的生产服务陷入停滞。

在模型生命周期的每个阶段确保数据一致性(并定期验证其质量)对于实现输出的相关性以及解决方案随时间稳定性的重要性不可估量。通过消除训练和推理偏差、利用特征存储以及在组织内公开共享物化特征数据,实现了建模阶段的一致性。

15.1.1 训练和推理偏差

让我们假设我们正在一个团队中工作,该团队通过使用特征批处理提取来确保模型开发的一致性,从而开发解决方案。在整个开发过程中,我们小心翼翼地利用我们知道在服务系统的在线数据存储中可用的数据。由于项目的成功,现状并没有被忽视。业务希望我们提供更多。

经过几周的工作,我们发现从未包含在初始项目开发中的新数据集中添加特征对模型的预测能力产生了重大影响。我们整合了这些新特征,重新训练了模型,并处于图 15.2 所示的境地。

15-02

图 15.2 由于特征更新导致的推理偏差

由于在线特征系统无法访问后来包含在模型修订中的数据,我们遇到了训练和推理偏差问题。正如图 15.2 所述,这个问题以两种主要方式表现出来:

  • 空值被填充。

    • 如果用特征空间的平均值或中值填充,特征向量的方差和潜在信息将减少,这可能导致重新训练期间模型退化。

    • 如果用占位符值填充,结果可能比原始模型更差。

  • 未处理空值。这可能会根据所使用的库抛出异常。这可能会从根本上破坏新模型的部署。所有预测都将使用回退启发式“最后希望”服务。

训练和推理之间的不匹配场景并不仅限于特征数据的缺失或存在。如果数据仓库中的离线数据与在线系统之间创建原始数据的处理逻辑不同,这些问题也可能发生。解决这些问题、诊断它们和修复它们可能非常昂贵且耗时。

作为任何生产 ML 流程的一部分,应进行架构验证和检查离线和在线训练系统的一致性。这些检查可以是手动的(通过计划作业进行的统计验证)或通过使用特征存储完全自动化,以确保一致性。

15.1.2 特征存储简介

从项目开发的角度来看,构建 ML 代码库中耗时较多的一个方面是在特征创建。作为数据科学家,我们在模型中使用的数据上投入了大量的创造性努力,以确保现有的相关性得到最佳利用,以解决问题。历史上,这种计算处理嵌入在项目的代码库中,在一个在线执行链中执行,该链在训练和预测期间都会被调用。

特征工程代码与模型训练和预测代码之间这种紧密耦合的关联可能导致大量的令人沮丧的故障排除,正如我们在之前的场景中看到的。这种紧密耦合还可能导致数据依赖关系变化时的复杂重构,以及如果计算出的特征需要在其他项目中实现时,会重复工作。

然而,通过实施特征存储,这些数据一致性问题是可以通过大量解决。一旦定义了单一的真实来源,注册的特征计算就可以开发一次,作为计划作业的一部分进行更新,并且可供组织中的任何人使用(如果他们有足够的访问权限的话)。

一致性并不是这些工程化系统的唯一目标。同步数据流到在线事务处理(OLTP)存储层(用于实时预测)是特征存储带来的另一个生活质量的提升,它有助于最小化开发、维护和同步生产机器学习 ETL 需求的技术负担。能够支持在线预测的特征存储的基本设计包括以下内容:

  • 一个符合 ACID 存储层:

    • (A) 原子性—保证事务(写入、读取、更新)作为单元操作处理,要么成功(提交),要么失败(回滚),以确保数据一致性。

    • (C) 一致性—对数据存储的事务必须使数据处于有效状态,以防止数据损坏(来自对系统的无效或非法操作)。

    • (I) 隔离—事务是并发进行的,并且总是使存储系统处于有效状态,就像操作是按顺序执行的一样。

    • (D) 持久性—对系统状态的合法执行将始终持续存在,即使在硬件系统故障或断电的情况下,也会写入持久存储层(写入磁盘,而不是易失性内存)。

  • 一个低延迟的服务层,与 ACID 存储层同步(通常是易失性的内存缓存层或内存数据库表示,如 Redis)。

  • 一个非规范化的表示数据模型,适用于持久存储层和内存中的键值存储(通过主键访问以检索相关特征)。

  • 对最终用户来说,是一个不可变的只读访问模式。拥有生成数据的团队是唯一具有写入权限的团队。

如前所述,特征存储的好处不仅限于一致性。可重用性是特征存储的主要特性之一,如图 15.3 所示。

15-03

图 15.3 特征存储的基本概念

正如你所见,实施特征存储带来了许多好处。在整个公司拥有一个标准的特征库意味着每个用例,从报告(BI)到分析和 DS 研究,都在使用与其他人相同的同一套真实数据来源。使用特征存储消除了混淆,提高了效率(特征不需要为每个用例由每个人重新设计),并确保生成特征的成本只发生一次。

15.1.3 过程胜于技术

特征存储实施的成功并不在于实现它的具体技术。其好处在于它使公司能够利用其计算和标准化的特征数据采取行动。

让我们简要考察一个公司需要更新其收入指标定义的理想过程。对于这样一个广泛定义的术语,公司的收入概念可以根据最终用例、关注该数据使用的部门以及应用于这些用例的定义的会计标准水平以多种方式解释。

例如,一个营销团队可能对总收入感兴趣,以衡量广告活动的成功率。DE 团队可能会定义多种收入变体,以满足公司内部不同团队的需求。DS 团队可能会查看数据仓库中任何包含“销售”、“收入”或“成本”等词语的列的窗口聚合,以创建特征数据。BI 团队可能有一套更复杂的定义,这些定义适用于更广泛的分析用例。

如果每个人都对其小组的个人定义负责,那么改变这样一个关键业务指标的定义逻辑可能会对组织产生深远的影响。每个小组在每个查询、代码库、报告和模型中改变其参考的可能性微乎其微。将如此重要的指标的定义碎片化到各个部门本身就存在问题。在每个小组内部创建多个定义特征的版本是导致完全混乱的配方。如果没有建立关键业务指标定义的标准,公司内部的小组在评估彼此的结果和输出时实际上不再处于平等地位。

无论使用哪种技术栈来存储用于消费的数据,围绕关键特征变更管理构建的过程可以保证无缝且具有弹性的数据迁移。图 15.4 展示了这样一个过程。

15-04

图 15.4 设置更新关键特征存储条目数据变更点过程

正如你所见,公司收入报告的新标准来自执行委员会。从这个定义性的点开始,一个成功的特征存储更新过程可以开始。在处理此类公司范围内数据利用的每个小组的利益相关者都到场的情况下,可以开始对拟议的变更进行彻底评估。数据的生产者和消费者集体同意采取行动,以确保新的标准在公司中成为实际标准。会议结束后,每个小组都知道它需要采取哪些行动才能迁移到这个新指标;该指标通过 ETL 同步到公共特征存储中。

变点过程对于确保依赖数据做出明智决策的组织的一致性至关重要。通过采用这些过程,每个人都使用相同的“数据语言”。关于分析、报告和预测的真实性的讨论都可以基于对数据术语的同一共同定义进行标准化。这也极大地提高了依赖于这些数据的依赖性生产(自动化)工作和报告的稳定性。

15.1.4 数据孤岛的危险

数据孤岛看似无害,实则危险。将数据隔离在只有特定小部分人可以访问的封闭、私人位置,会抑制其他团队的生产力,在整个组织中造成大量重复劳动,并且(至少在我观察到的经验中)经常导致一些晦涩难懂的数据定义,这些定义在孤立状态下与公司普遍接受的观点大相径庭。

当一个机器学习团队被授予自己的数据库或整个云对象存储桶以赋予团队自助服务能力时,这似乎是一件非常好的事情。对于数据工程或仓储团队来说,用于加载数据集所需的时间似乎在地质尺度上消失了。团队成员完全掌握自己的领域,能够无拘无束地加载数据、消费数据和生成数据。这确实可能是一件好事,前提是明确且合理定义的过程来管理这项技术。

但无论数据是干净的还是脏的,仅限内部使用的数据存储堆栈都是一种孤岛,其内容被隐藏在世外桃源。这些孤岛可能产生的问题比解决的问题还要多。

为了说明数据孤岛可能带来的不利影响,让我们设想我们工作在一个建造狗公园的公司。我们最新的机器学习项目是一个有点像太空任务的项目,使用反事实模拟(因果建模)来确定在不同拟议的建筑地点,哪些设施对我们客户最有价值。目标是找出如何最大化拟议公园的感知质量和价值,同时最小化我们公司的投资成本。

要构建这样的解决方案,我们必须获取全国所有注册狗公园的数据。我们还需要与这些狗公园所在地相关的人口统计数据。由于公司的数据湖不包含具有这些信息的数据源,我们必须自己获取这些信息。自然地,我们将所有这些信息放在我们自己的环境中,认为这将比等待数据工程团队的工作队列清理完毕后开始工作要快得多。

几个月后,关于公司在某些地区竞标的一些合同开始出现疑问。业务运营团队好奇为什么在许多建筑库存中会订购如此多的定制爪子激活喷泉。随着分析师开始挖掘数据湖中的数据,他们无法理解为什么某些合同的推荐始终建议这些极其昂贵的组件。

在花费数月时间进行分析后,决定从合同投标中移除此功能。没有人能解释为什么它在那里,他们决定继续提供它不值得。他们更愿意提供自动洗狗站(汽车洗车风格)、狗粪机器人(清洁者,而非制造者)、公园范围的冷却风扇和自动抛球装置。因此,订购了大量这些物品,并终止了喷泉采购合同。

几个月后,一个竞争对手开始在合同中提供与我们一直在竞标的完全相同的元素。城市和城镇开始转向竞争对手的投标。当最终被追问原因时,销售团队开始听到相同的答案:狗们真的很喜欢喷泉,尤其是在远离人们住所和市政供水站的地区。这里最终发生的情况如图 15.5 所示。

15-05

图 15.5 在隔离区存储关键数据

由于无法看到 DS 团队构建的基于便利设施反事实模拟模型所收集和使用的这些特征,业务无法拼凑出模型建议的原因。数据被孤立,没有恶意,但它给业务带来了巨大问题,因为关键数据无法访问。

我们不是农民。我们永远不应该使用隔离区。至少不是数据隔离区。如果你对农业感兴趣,那就别让我阻止你。另一方面,我们应该与数据工程和仓储团队紧密合作,确保我们能够将数据写入每个人都可以访问的位置——最好,如我们将在第十七章中讨论的,写入特征存储库。

15.2 备用方案和冷启动

让我们想象一下,我们刚刚为一家披萨公司构建了一个用于优化配送路线的机器学习解决方案。不久前,公司向我们寻求一个更便宜、更快、更灵活的解决方案来优化单个司机的配送路线。之前确定某个司机将配送披萨到哪些地址的方法是通过路径算法,该算法基于 ArcGIS 生成最佳路线。虽然功能强大且全面,但公司希望有一种考虑实际配送数据的时空性质和历史的方法来创建更有效的路线。

团队使用了一种基于 LSTM 的方法,该方法在过去的三年配送数据上进行了训练,创建了一个基于强化学习的对抗网络,该网络根据配送的及时性奖励最佳路径。项目迅速从一项科学项目转变为在几个地区证明其价值的实际应用。它在选择配送序列方面比之前生产系统能够实现的暴力路径搜索要熟练得多。

在审查了测试市场几周的路由数据后,公司对在所有配送路线上启用系统感到放心。一切看起来都很顺利。预测正在被提供服务,司机在交通堵塞中花费的时间减少了,而且比以往任何时候都要高得多地将热披萨准时送达。

大约过了一个星期,投诉才开始如潮水般涌来。农村地区的客户以惊人的高比例抱怨配送时间非常长。在查看投诉后,一个模式开始显现,即每个投诉都与配送链的最后一个停靠点有关。DS 团队很快就意识到发生了什么。由于大部分训练数据集中在城市中心,停靠点的数量和停靠点之间的距离较低,因此模型通常针对优化停靠点数量。当这个配送数量应用于农村环境时,巨大的距离意味着几乎所有最终停靠点的配送客户都会收到室温的披萨。

由于没有对路线长度或估计的总配送时间进行回退控制,该模型正在优化总配送运行量的最小时间,而不管这个总估计时间有多长。该解决方案缺乏备用计划。如果没有回退到使用现有的地理位置服务(农村路线的 ArcGIS 解决方案),如果模型的输出违反了业务规则(不要配送冷披萨),则没有备用方案。

任何生产级机器学习解决方案的关键部分是始终有一个备份计划。无论准备、预想和规划的程度如何,即使是综合性和抗故障能力最强的解决方案,也难免在某些时候出现问题。无论你构建的解决方案是离线(批量)、近实时(微批流式)、在线还是边缘部署,未来近或远某个条件都可能导致模型无法按照预期的方式运行。

表 15.1 展示了解决方案模型可能出现的故障方式及其影响程度,这取决于模型使用的严重程度。

表 15.1 模型不配合的情况

条件 好笑的例子 严重的业务例子
回归预测超出可能自然范围 预测客户今天将花费-8,745 美元。 现在将反应堆控制棒拔到最大高度。
只预测单个类别的分类器 一切都是狗。甚至那只猫也是狗。 在州际高速公路上自动分类停车标志。
未满足应用/网页的 SLA 空白的空 Iframe 元素。 由于欺诈活动锁定账户。
聊天机器人没有内容过滤 开始背诵歌曲歌词。 开始侮辱用户。
失败检测系统无响应 监控面板变成了圣诞展示。 关闭所有发电厂。在东海岸。

虽然这些例子相当荒谬(大多数是——一些基于真实情况),但它们都有一个共同点。没有一个有回退计划。如果系统中的单点故障(模型的预测)没有按预期工作,它们就会允许发生不好的事情。虽然故意含糊其辞,但这里的重点是,所有由模型驱动的解决方案都有某种失败模式,如果没有备份,就会发生。

另一方面,冷启动是模型失败的一种独特形式。与典型回退系统处理的完全非功能场景不同,遭受冷启动问题的模型是需要历史数据来运行,而这种数据尚未生成。从为新用户设计的推荐系统到新市场的价格优化算法,需要基于不存在的数据进行预测的模型解决方案需要特定的回退系统。

15.2.1 过度依赖现有技术

我们可以用表 15.1 中的几乎所有好笑的例子来说明创建回退计划的第一条规则。相反,让我们用一个来自我个人历史的实际例子来说明。

我曾经参与过一个必须处理制造配方项目。这个配方的目标是设置一个旋转速度在一个极其昂贵的设备上,当材料滴到上面时。这个单元的速度需要根据温度和湿度的变化,在一天中定期调整,因为温度和湿度会改变滴到产品上的材料的粘度。保持这台设备运行最佳是我的工作;机器中有许多这样的站点和许多种类的化学品。

就像在我的职业生涯中许多次一样,我厌倦了重复性的工作。我想,一定有办法自动化这些单元的转速,这样我就不必每小时站在控制站调整它们。自认为相当聪明,我连接了一些传感器到微控制器上,编程了可编程逻辑控制器以接收来自我的小控制器的输入,编写了一个简单的程序,它会根据房间内的温度和湿度调整夹具转速,并激活了系统。

我想,前几个小时一切都很顺利。我已经将一个简单的回归公式编程到微控制器中,检查了我的数学计算,甚至在另一件损坏的设备上进行了测试。一切看起来都很稳固。

直到凌晨 3 点左右,我的呼叫器(是的,那是很久以前的事情了)才开始响起。当我 20 分钟后到达工厂时,我意识到我已经在每个旋转夹具系统中造成了超速条件。它们停止了。其余的液体计量系统没有停止。当冷风击打我的后脑勺,我看着敞开的船坞门让 27°F 的夜晚空气进来时,我意识到我的错误。

我没有后备条件。回归线,考虑到环境温度,试图补偿未经测试的数据范围(在该范围内,粘度曲线实际上并不是线性的),并试图让一个通常以约 2,800 RPM 旋转的夹具以 15,000 RPM 的速度旋转。

接下来的四天三夜,我都在清理这台机器内部的清漆。当我完成时,首席工程师把我拉到一边,递给我一个巨大的三环笔记本,并告诉我“在玩任何游戏之前先读一读。”(我在这里进行释义。我不能把他对我说的话写出来。)这本书充满了机器使用的每种化学品的材料科学分析。它有我可以用到的精确的粘度曲线。它有关于沉积的最大转速的信息。

在我到达之前,有人做了很多工作。有人已经确定了材料的安危阈值,以及夹具驱动电机。

我在那一天学到了一个重要的教训。这个教训在图 15.6 中得到了说明。

我一开始就坚定地处于图 15.6 的上半部分。我迅速地意识到,在同事工程师们不小的对抗性强化下,我需要努力达到图 15.6 的下半部分。我学会了思考任何解决方案可能出错的地方,以及当事情出错时,拥有护栏和后备条件是多么重要。

15-06

图 15.6 在工程工作中弄清楚安全措施和后备方案的重要性

在构建机器学习解决方案时,许多时候,数据科学家可能会错误地假设他们正在解决的问题是没有先例的。当然有例外(如登月项目),但我在职业生涯中工作的绝大多数解决方案都有人在公司中扮演着项目旨在自动化的角色。

那个人有完成这项任务的方法、实践和标准。在你到来之前,他们就理解了数据。从所有意义上讲,他们是一个活生生的、有呼吸的版本,就像我的老板生气时扔给我的那个三环笔记本。他们知道卡盘能转多快,如果技术人员想在冬天中间偷偷抽根烟,会发生什么。

这些代表现有技术的个人(或代码)将知道在构建后备系统时需要考虑的条件。他们会知道如果模型预测出垃圾数据,默认条件应该是什么。他们会知道回归预测的可接受范围。他们会知道每天应该有多少猫的照片和狗的照片。他们是你的智者向导,可以帮助你构建更稳健的解决方案。询问他们如何解决问题以及他们最有趣的错误故事是值得的。这只会帮助你确保不要重复同样的错误。

15.2.2 冷启动问题

对于某些类型的机器学习项目,模型预测失败不仅频繁,而且预期。对于需要现有数据历史背景才能正常工作的解决方案,缺乏历史数据会阻止模型进行预测。数据根本无法通过模型。被称为“冷启动问题”,这是任何处理时间相关数据的项目的解决方案设计和架构的关键方面。

例如,让我们假设我们经营一家宠物美容业务。我们的移动洗澡站车队在美国郊区巡游,为狗狗提供各种上门服务。预约和服务选择通过应用程序界面处理。当预订访问时,客户可以从数百个选项中选择,并在访问前一天通过应用程序预先支付服务费用。

为了提高我们客户的满意度(并增加我们的收入),我们在应用程序上使用服务推荐界面。此模型查询客户的历史访问记录,找到可能对他们相关的产品,并指出狗可能喜欢的额外服务。为了使推荐器正常工作,需要在服务选择期间提供历史服务记录。

这对任何人来说都不是很难想象。没有数据处理数据模型的模型并不特别有用。没有历史记录,模型显然没有数据来推断可能推荐捆绑到预约中的额外服务。

向最终用户提供“某种东西”需要冷启动解决方案。对于此用例的一个简单实现是生成全球最常订购服务的集合。如果模型没有足够的数据来提供预测,可以用基于流行度的服务聚合来代替。到那时,应用程序的 IFrame 元素至少会有一些内容(而不是显示空集合),用户界面不会因为看到空框而受损。

可以进行更复杂的实现,将全局流行度排名升级为具有更多细粒度预生成的冷启动数据的排名。至少,可以使用地理区域作为分组聚合来计算服务的流行度,以创建一个伪个性化的故障转移条件。如果为最终用户提供额外的数据,可以进行更复杂的分组分配,参考用户基础中的聚合数据点来设置分组条件,确保提供更精细和粒度化的推荐。图 15.7 展示了启用了冷启动的架构。

15-07

图 15.7 冷启动解决方案的逻辑图

建立基于启发式解决方案,通过与 SMEs 合作利用用例的深度知识,是解决冷启动问题的有效方法。当一个用户没有至少三个预约选择历史记录时开始使用应用程序,模型将完全绕过,并做出简单的基于业务规则的伪预测。这些冷启动解决方案的实施可以采取以下形式:

  • 上个月用户地理位置中最受欢迎的项目

  • 全球今天最受欢迎的项目

  • SME 管理的项目集合

  • 库存高的项目

无论使用何种方法,都要确保有东西可以实施。毕竟,另一种选择是返回没有数据。这对于需要从 API 生成数据以填充界面元素的面向客户的应用程序来说,根本不是一种选择。拥有冷启动替代解决方案的好处是它可以作为一个后备解决方案。通过微调决策逻辑以检查模型预测输出结果的准确性,可以提供冷启动值来替代有问题的数据。

对于这个冷启动默认值服务,可能会诱使人们构建一个复杂的东西,但在这里应该避免复杂性。目标是构建一个特别快(低 SLA)、易于调整和维护,并且足够相关,以至于不会引起最终用户注意某些东西设计不正确。

冷启动解决方案不仅适用于推荐系统。任何时候模型发布预测,尤其是那些具有低 SLA 响应要求的预测,都应该产生至少与当前任务多少有些相关的价值(由业务部门和 SMEs 定义和设计的相关性,而不是由 DS 团队定义)。

对于实时用例,未能生成价值可能会破坏下游系统,这些系统会消费这些数据。对于许多系统,未能有相关性依赖的后备方案可能会导致它们抛出异常、过度重试,或者求助于后端或前端开发者设置的系统保护默认值。这是工程部门和最终用户都不希望看到的情况。

15.3 最终用户测试与内部使用测试

一旦确认端到端功能正常,就发布项目到生产环境是非常诱人的。经过如此多的工作、努力和基于指标的定量质量检查,自然而然地认为解决方案已经准备好进入世界使用。抵制这种冲刺最后一英里的冲动是困难的,尽管这是绝对关键的。

如我们在第一部分中所述,以下是一些主要原因,说明仅基于 DS 团队的内部评估发布项目是多么不明智:

  • DS 团队成员存在偏见。这是他们的“孩子”。没有人愿意承认自己创造了一个丑陋的“孩子”。

  • 定量指标并不总是保证定性特征。

  • 对质量预测最重要的可能是不收集的数据。

这些原因让人联想到相关性不意味着因果关系的概念,以及创造者偏见。虽然模型的验证和定量指标可能表现得非常好,但很少有几个项目能够将所有因果因素都捕捉在特征向量中。

详尽的测试或质量保证(QA)流程可以帮助我们对我们的解决方案进行定性评估。我们可以通过多种方式实现这一点。

让我们设想我们正在一家音乐流媒体服务公司工作。我们有一个通过在排队收听会话后提供高度相关的歌曲选择来增加客户参与度的倡议。

我们不想使用一种协作过滤方法,该方法会找到其他用户听过的类似歌曲,而是想根据人类耳朵如何解释一首歌来找到类似的歌曲。我们使用音频文件的四次变换来获取频率分布,然后将该分布映射到梅尔尺度(音频信号的对数功率谱的线性余弦变换,这接近于人类耳朵对声音的感知)。通过这种数据变换和绘图,我们得到了每首歌曲特性的视觉表示。然后,我们通过使用调整过的三分支 Siamese 网络,以离线方式计算所有歌曲与所有其他歌曲的相似性。从这个系统中出来的特征向量,通过为每首歌曲添加额外的标签特征来增强,用于计算一首歌曲到另一首歌曲的欧几里得距离和余弦距离。我们将所有歌曲之间的关系保存在一个 NoSQL 数据库中,该数据库跟踪了与所有其他歌曲最相似的 1000 首歌曲,用于我们的服务层。

为了说明,图 15.8 基本上是团队输入到 Siamese 网络中的内容,每首歌曲的梅尔可视化。这些距离度量有内部“旋钮”,DS 团队可以使用这些旋钮来调整最终的输出集合。这个特性在测试早期被发现,当时内部 SME 成员表达了希望细化音乐流派中类似音乐的过滤器的愿望,以适应音乐的时代。

15-08

图 15.8 音乐文件转换为用于 Siamese CNN 网络的梅尔频谱图

现在我们已经可以看到这里的情况(以及 CNN 从编码特征中创建的信息类型),我们可以深入探讨如何测试这项服务。图 15.9 展示了测试的概述。

图 15.9 比较了三种可以在我们的服务上进行定性评估的预生产 QA 工作形式。接下来的三个小节涵盖了这些元素——内部偏向性测试、尝鲜(消费我们自己的产品)、详尽的小型专家(SME)审查——以展示整体 SME 评估方法相对于其他方法的优点。

15-09

图 15.9 不同的定性测试策略。顶部的策略作为实践来说相当糟糕。

最终,对机器学习项目进行 QA 的目标是评估基于现实世界数据的预测,这些数据不依赖于解决方案创造者的高度短视视角。目标是尽可能消除对解决方案效用定性评估中的偏见。

15.3.1 偏向性测试

内部测试很容易——好吧,比替代方案更容易。如果模型工作正常,那么这个过程是无痛的。这是我们通常在认证项目结果时所想到的。这个过程通常涉及以下步骤:

  • 在新(对建模过程来说是未知的)数据上生成预测

  • 分析新预测的分布和统计特性

  • 对预测进行随机抽样并对其做出定性判断

  • 将手工制作的样本数据(或适用的个人账户)通过模型运行

列表中的前两个元素适用于模型有效性的资格认证。它们完全无偏见,应该执行。另一方面,后两个则是有害的。其中最后一个更危险。

在我们的音乐播放列表生成系统场景中,假设 DS 团队成员都是古典音乐的粉丝。在整个定性验证过程中,他们一直在检查他们最熟悉的音乐领域——古典音乐的播放列表生成器的相对质量。为了进行这些验证,他们一直在生成他们最喜欢的作品的收听历史,调整实现来微调结果,并在验证过程中迭代。

当他们完全满意解决方案在识别具有高度复杂性的主题和调性相关相似音乐片段方面表现良好时,他们会询问同事的看法。DS 团队(本和朱莉)以及他们的数据仓库工程师朋友康纳的结果如图 15.10 所示。

15-10

图 15.10 模型效能在定性评估中的偏差反馈

结果是,基于偏差的解决方案优化,以满足 DS 团队自己对该音乐类型的偏好和知识。虽然对于古典音乐爱好者的挑剔品味来说,解决方案调得恰到好处,但对于像康纳这样的现代另类摇滚乐迷来说,解决方案则非常糟糕。他的反馈将与团队对自己解决方案质量的判断大相径庭。为了修复实现,本和朱莉可能需要做出很多调整,引入额外的功能来进一步细化康纳对另类摇滚音乐的品味。那么,其他数百种音乐类型又如何呢?

尽管这个例子特别具有挑战性(音乐品味极为多样且高度个性化),但任何 ML 项目都可能存在这种内部团队偏差的问题。任何 DS 团队都将只能有限地了解数据的细微差别。对数据的复杂潜在关系以及每个如何与业务相关的详细了解通常不是 DS 团队所知的。这就是为什么让那些对公司中解决项目用例最了解的人参与 QA 过程是如此关键。

15.3.2 试用

比 Ben 和 Julie 的第一次尝试要更彻底的方法是向公司的人进行调查。而不是将评估限制在团队内部,因为有限的接触类型阻碍了他们从质量上衡量项目有效性的能力,他们可以寻求帮助。他们可以四处询问,看看公司的人是否可能对查看他们自己的账户和受 DS 团队引入的变更影响的用法感兴趣。图 15.11 说明了这种情况是如何工作的。

15-11

图 15.11 通过利用志愿者提供主观反馈作为用户来尝鲜一个项目

在最广泛的意义上,“尝鲜”是指消费你自己的产品结果。这个术语指的是开放正在开发的功能,以便公司中的每个人都可以使用它,找出如何破坏它,提供关于它如何被破坏的反馈,并共同努力打造更好的产品。所有这些都在广泛的视角中发生,借鉴了来自所有部门的许多员工的经验和知识。

然而,如图 15.11 所示,评估仍然包含偏见。使用公司产品的内部用户可能不是典型用户。根据他们的工作职能,他们可能在使用他们的账户来验证产品中的功能,用它进行演示,或者仅仅因为与员工福利相关的它而更多地与产品互动。

除了员工收听历史中可能包含的虚假信息之外,另一种形式的偏见是人们喜欢他们喜欢的东西。他们也不喜欢他们不喜欢的东西。对像音乐偏好这样情绪化的东西的主观反应,由于人类成员的本质,会添加大量的偏见。知道这些预测是基于他们的收听历史,并且是他们自己公司的产品,内部用户评估自己的个人资料时,如果他们发现不喜欢的东西(这与 DS 团队会遇到的构建者偏见形成鲜明对比),通常会比典型用户更加苛刻。

尽管尝鲜当然比在 DS 团队的限制内评估解决方案的质量要好,但这仍然不是理想的,主要是因为这些固有的偏见存在。

15.3.3 主题专家(SME)评估

在保持在公司内部的前提下,你可以进行的最彻底的 QA 测试是利用业务中的主题专家(SMEs)。这是保持业务中的 SMEs 参与项目最重要的原因之一。他们不仅会知道公司中谁对项目的某个方面(在这种情况下,是音乐类型)有最深入的知识和经验,而且他们可以帮助调动这些资源来协助。

对于 SME 评估,我们可以在 QA 阶段之前安排这一阶段,请求那些对我们需要提供关于生成歌单质量公正意见的音乐流派专家资源。通过指定专家,我们可以不仅向他们提供自己的推荐,还可以提供随机抽样用户的推荐。凭借他们对每个流派细微之处的深入了解,他们可以评估他人的推荐,以确定生成的播放列表是否在音调和主题上合理。图 15.12 展示了这一过程。

15-12

图 15.12 项目实施的公正 SME 评估

在实施更为彻底的裁决后,反馈的有用性显著高于任何其他方法。我们可以在最小化偏见的同时,将专家的深入知识融入可迭代的可操作变化中。

虽然这个场景关注的是音乐推荐,但它几乎可以应用于任何机器学习项目。最好记住,在你开始着手解决你正在工作的任何项目之前,一些人类(或许多人类)已经在以某种方式解决这个问题了。他们将比 DS 团队中的任何人都更深入地了解主题的细节。你不妨利用他们的知识和智慧,尽力提供最佳的解决方案。

15.4 模型可解释性

假设我们正在解决一个旨在控制森林火灾的问题。我们工作的组织可以调动设备、人员和资源到大型国家公园系统内的各个位置,以减轻野火失控的可能性。为了使物流效率尽可能高效,我们被要求构建一个可以通过网格坐标识别火灾爆发风险的解决方案。我们拥有多年的数据,每个位置的传感器数据,以及每个网格位置的火灾燃烧历史。

在构建模型并向物流团队提供预测服务之后,关于模型预测的问题开始出现。物流团队成员注意到某些预测与他们对火灾季节的经验性知识不符,他们表达了对使用他们接触到的特征数据应对预测灾难的担忧。

他们开始怀疑这个解决方案。他们提出了问题。他们确信有些奇怪的事情正在发生,并且他们想知道为什么他们的服务和人员被告知要在一个月内覆盖一个网格坐标,而这个坐标,据他们所记,从未发生过火灾。

我们如何应对这种情况?我们如何运行我们的特征向量预测的模拟,并通过我们的模型向他们明确说明模型为什么做出这样的预测?具体来说,我们如何以最少的努力在我们的模型上实施可解释的人工智能(XAI)?

在规划项目时,尤其是对于业务关键用例,一个经常被忽视的方面是考虑模型的可解释性。一些行业和公司是这一规则的例外,这可能是由于法律要求或公司政策,但与我互动的大多数团队来说,可解释性是一个事后考虑的问题。

我理解大多数团队在考虑将 XAI 功能添加到项目中时的犹豫。在 EDA(探索性数据分析)、模型调整和 QA 验证过程中,DS 团队通常对模型的行为有相当的了解。实现 XAI 可能看起来是多余的。

当你需要解释模型如何或为什么做出这样的预测时,你通常已经处于一个紧张的情况,时间已经受限。通过实现 XAI 流程的简单开源包,可以避免这种紧张和混乱的解释解决方案功能。

15.4.1 Shapley 加性解释

对于 Python 而言,较为知名且经过充分验证的 XAI(可解释人工智能)实现之一是 Scott Lundberg 编写和维护的 shap 包。这个实现详细记录在 2017 年 NeurIPS 论文“一种统一的方法来解释模型预测”中,由 Lundberg 和 Su-In Lee 撰写。

算法的核心是博弈论。本质上,当我们考虑要进入训练数据集的特征时,每个特征对模型预测的影响是什么?就像团队运动中的球员一样,如果比赛是模型本身,而参与训练的特征是球员,那么如果一个球员被另一个球员替换,这对比赛有什么影响?一个球员的影响力如何改变比赛的结果是 shap 尝试回答的基本问题。

基础

shap 原理背后的思想是估计训练数据集中每个特征对模型贡献的大小。根据原始论文,计算真实贡献(即精确的 Shapley 值)需要评估数据集中每一行的所有排列,包括和排除源行特征的排列,创建不同的特征分组联盟。

例如,如果我们有三个特征(a、b 和 c;原始特征用 [i] 表示),用数据集中的替换特征 [j](例如,a[j])表示,那么评估特征 b 的测试联盟如下:

(a[i], b[i], c[j]), (a[i], b[j], c[j]), (a[i], b[j], c[i]), (a[j], b[i], c[j]), (a[j], b[j], c[i])

这些特征联盟通过模型运行以获取预测。然后,将得到的预测与原始行的预测(以及差异的绝对值)进行差分。这个过程对每个特征重复进行,当对每个特征的每个差分组应用加权平均时,结果得到一个特征值贡献分数。

毫不奇怪,这不是一个非常可扩展的解决方案。随着特征数量的增加和训练数据集行数的增加,这种方法计算复杂性的快速增加变得难以承受。幸运的是,另一个解决方案的扩展性要远好:近似 Shapley 估计。

近似 Shapley 值估计

为了在大特征集中缩放特征的加性效应,采用了一种略有不同的方法。Python 包shap利用这种近似实现来获取所有行和特征上的合理值,而无需求助于原始论文中的暴力方法。图 15.13 说明了这种近似方法的过程。

15-13

图 15.13 shap 中近似核 Shapley 值实现的实现

与穷举搜索方法相比,这里的主要区别在于测试的数量有限和构建联盟的方法。与原始设计不同,单行特征向量不用于生成基线预测。相反,对行进行随机抽样,并将测试中的特征与其他选定子集的值进行交换。然后,将这些新的合成向量传递给模型,生成预测。对于这些合成预测中的每一个,都计算绝对差异,然后取平均值,给出参考向量在这些联盟中的特征贡献值。应用于这些值平均的加权因子取决于单个合成向量中“修改”(替换)的特征数量。对于特征交换较多的行,与特征交换较少的行相比,这些行的重要性权重更高。

图 15.13 中显示的最后阶段是在整体每个特征的贡献评估中。这些特征重要性估计是通过加权每行的特征贡献边际并按百分比贡献对整个数据集进行缩放来完成的。这两种计算数据伪影都可以通过使用 Python shap包(每行的贡献估计和整个数据集的聚合测量)获得,并且可以帮助解释不仅是一行的预测,还可以提供对训练模型中特征影响的整体视图。

我们可以用这些值做什么

简单地计算 Shapley 值对数据科学团队来说帮助不大。基于这个包的 XAI 解决方案的效用在于这些分析能够回答哪些问题。在计算这些值之后,你将能够回答以下一些问题:

  • “为什么模型预测了这个奇怪的结果?”(单事件解释)

  • “这些额外的特征会产生不同的性能吗?”(特征工程验证)

  • “我们的特征范围如何影响模型预测?”(一般模型功能解释)

shap包不仅可以作为解决方案开发和维护的辅助工具,还可以帮助向业务单元成员和专家提供基于数据的解释。通过将关于解决方案功能的讨论从 DS 团队通常使用的工具(相关性分析、依赖图、方差分析等)转移到数据上,可以开展更有成效的讨论。这个包以及其中的方法,减轻了 ML 团队解释晦涩的技术和工具的负担,转而专注于讨论解决方案的功能,即公司生成的数据。

15.4.2 使用 shap

为了说明我们如何使用这种技术来预测森林火灾的问题,让我们假设我们已经构建了一个模型。

注意:要跟随示例并查看模型构建、使用 Optuna 包(第二部分中提到的 Hyperopt 的现代版本)进行调优以及此示例的完整实现,请参阅本书的配套 GitHub 存储库。代码位于第十五章目录中。

在可用的预构建模型的基础上,让我们利用shap包来确定训练数据中各个特征的影响,以帮助回答业务提出的关于模型为何以特定方式表现的问题。以下列表展示了一系列辅助生成解释图的类(请参考存储库中的import语句和代码的其余部分,这部分代码太长,无法在此打印)。

列表 15.1 shap 接口

class ImageHandling:                                               ❶
    def __init__(self, fig, name):
        self.fig = fig
        self.name = name
    def _resize_plot(self):
        self.fig = plt.gcf()                                       ❷
        self.fig.set_size_inches(12, 12)
    def save_base(self):
        self.fig.savefig(f"{self.name}.png", 
                         format='png', bbox_inches='tight')
        self.fig.savefig(f"{self.name}.svg", 
                         format='svg', bbox_inches='tight')
    def save_plt(self):
        self._resize_plot()
        self.save_base()
    def save_js(self):
        shap.save_html(self.name, self.fig)                        ❸
        return self.fig
class ShapConstructor:                                             ❹
    def __init__(self, base_values, data, values, feature_names, shape):
        self.base_values = base_values 
        self.data = data 
        self.values = values 
        self.feature_names = feature_names 
        self.shape = shape     
class ShapObject:
    def __init__(self, model, data):
        self.model = model
        self.data = data
        self.exp = self.generate_explainer(self.model, self.data)
        shap.initjs()
    @classmethod
    def generate_explainer(self, model, data):                     ❺
        Explain = namedtuple('Explain', 'shap_values explainer max_row')
        explainer = shap.Explainer(model)
        explainer.expected_value = explainer.expected_value[0]
        shap_values = explainer(data)
        max_row = len(shap_values.values)
        return Explain(shap_values, explainer, max_row)
    def build(self, row=0):
        return ShapConstructor(
base_values = self.exp.shap_values[0][0].base_values,
            values = self.exp.shap_values[row].values,
            feature_names = self.data.columns,
            data = self.exp.shap_values[0].data,
            shape = self.exp.shap_values[0].shape)
    def validate_row(self, row):
        assert (row < self.exp.max_row, 
f"The row value: {row} is invalid. " 
f"Data has only {self.exp.max_row} rows.")
    def plot_waterfall(self, row=0):                               ❻
        plt.clf()
        self.validate_row(row)
        fig = shap.waterfall_plot(self.build(row), 
                                  show=False, max_display=15)
        ImageHandling(fig, f"summary_{row}").save_plt()
        return fig
    def plot_summary(self):                                        ❼
        fig = shap.plots.beeswarm(self.exp.shap_values, 
                                  show=False, max_display=15)
        ImageHandling(fig, "summary").save_plt()
    def plot_force_by_row(self, row=0):                            ❽
        plt.clf()
        self.validate_row(row)
        fig = shap.force_plot(self.exp.explainer.expected_value, 
                               self.exp.shap_values.values[row,:], 
                               self.data.iloc[row,:],
                               show=False,
                               matplotlib=True
                              )
        ImageHandling(fig, f"force_plot_{row}").save_base()
    def plot_full_force(self):                                     ❾
        fig = shap.plots.force(self.exp.explainer.expected_value, 
                               self.exp.shap_values.values, 
                               show=False
                              )
        final_fig = ImageHandling(fig, "full_force_plot.htm").save_js()
        return final_fig
    def plot_shap_importances(self):                               ❿
        fig = shap.plots.bar(self.exp.shap_values, 
                             show=False, max_display=15)
        ImageHandling(fig, "shap_importances").save_plt()
    def plot_scatter(self, feature):                               ⓫
        fig = shap.plots.scatter(self.exp.shap_values[:, feature],  
                                 color=self.exp.shap_values, show=False)
        ImageHandling(fig, f"scatter_{feature}").save_plt()

❶ 图像处理类,用于处理图表的调整大小和存储不同格式

❷ 获取当前图表图标的引用以调整大小

❸ 由于此图是用 JavaScript 生成的,我们必须将其保存为 HTML。

❹ 统一来自 shap Explainer 的所需属性,以处理所有图表的要求

❺ 在类实例化期间调用的方法,根据传入的模型和提供的数据生成 shap 值,以评估模型的功能

❻ 生成单行的水波图,以解释每个特征对行目标值的影响(组成分析)

❼ 生成整个传入数据集中每个特征的完整 shap 摘要

❽ 生成单行力的图表,以展示每个特征对其目标值的累积影响

❾ 将整个数据集的合并力图生成单个显示的可视化

❿ 创建每个特征的估计 shap 重要性的调试图

⓫ 生成单个特征与其 shap 值之间的散点图,颜色由向量中剩余位置的最高协方差特征决定

定义了我们的类别后,我们可以开始回答商家关于为什么模型预测了这些值的问题。我们可以摆脱通过展示相关性效应来解释的最好努力尝试的猜想领域,而不是浪费时间(和商家的)去进行耗时且可能令人困惑的演示,我们可以专注于回答他们的问题。

作为额外的奖励,在开发过程中拥有这种基于博弈论的方法可以帮助我们了解哪些特征可以改进,哪些可能被删除。从该算法中获得的信息在整个模型的生命周期中都是无价的。

在我们查看列表 15.1 中这些方法执行时会产生什么结果之前,让我们回顾一下业务高管想知道的内容。为了确保模型输出的预测是合理的,他们想知道以下内容:

  • 如果我们看到以下条件,应该引起我们恐慌吗?

  • 为什么降雨量似乎不影响风险?

为了回答这两个问题,让我们看看可以从 shap 包中生成的两个图表。基于这些图表,我们应该能够看到问题预测的来源。

shap 概要图

为了回答关于降雨的问题,以及提供了解哪些特征驱动预测最多的机会——概要图是为此目的最全面和实用的。因为它结合了所有训练数据的行,它将通过算法执行的替换策略对每个特征的冲击进行逐行估计。这种对整个训练数据集的整体视角可以显示特征在问题范围内的整体影响程度。图 15.14 显示了概要图。

15-14

图 15.14 shap 概要图,显示每行的每个特征的替换和预测增量

拥有这个图表,可以与商家进行大量的讨论。不仅能够共同探讨为什么降雨量值在模型输出中明显没有产生影响(图表显示该特征甚至没有被随机森林模型考虑),还可以了解模型如何解释数据的其他方面。

注意:确保你向商家非常清楚地解释什么是 shap。它与现实无关;它只是简单地表明模型如何解释其预测中向量的特征变化。这是一个关于模型的度量,而不是你试图模拟的现实。

概述图可以开始讨论为什么模型会以这种方式表现,在识别了 SMEs(行业专家)指出的不足之后,可能进行哪些改进,以及如何用大家都能理解的方式与业务讨论模型。一旦解释了这个工具的初始困惑,业务完全理解所显示的值只是模型理解特征的估计(并且它们不是你预测的问题空间现实情况的反映),对话就可以变得更加富有成效。

清楚地说,在展示工具能够生成的任何可视化之前,绝对必要的是解释这些值的确切含义。我们不是在解释世界;我们是在解释基于我们实际收集的数据,模型对相关效应的有限理解。其中并没有更多或更少的内容。

在与业务和降雨问题的一般讨论完成后,我们可以继续回答下一个问题。

水位图

我们可以通过一系列的可视化来回答第二个问题,这可能是企业最关心的问题。当业务领导问他们何时应该恐慌时,他们真正想了解的是模型何时会预测紧急情况。他们想知道他们应该关注哪些特征的属性,以便警告地面人员可能发生不好的事情。

这是对机器学习的一种可嘉的使用,我在我的职业生涯中见过很多次。一旦公司的业务单元走过了不信任的低谷,进入了依赖预测的领域,不可避免的结果就是企业想要了解他们的问题的哪些方面可以监控和控制,以帮助最小化灾难或最大化有利的结果。

为了实现这一发现,我们可以查看我们之前的数据,从历史中选择最坏的情况(或情况),并绘制每个特征对预测结果的影响。这个数据集中历史上最严重的火灾的贡献分析如图 15.15 所示。

15-15

图 15.15 历史上最严重的野火的水位图。每个特征的贡献边际可以告知企业模型与哪些因素相关联,以预测高风险的火灾。

虽然这个图表只是一个单一的数据点,但通过分析目标的历史数据的前* n*行,我们可以得到一个更完整的图景。接下来的列表展示了如何使用围绕shap构建的界面来简单地生成这些图表。

列表 15.2 生成历史上最极端事件的特征贡献图

shap_obj = ShapObject(final_rf_model.model, final_rf_model.X)         ❶
interesting_rows = fire_data.nlargest(5, 'area').reset_index()['index'].values                            ❷
waterfalls = [shap_obj.plot_waterfall(x) for x in interesting_rows]   ❸

❶ 通过传递训练好的模型和用于训练它的训练数据,实例化 shap 包的处理程序以生成 shap 值

❷ 从训练数据中提取五个最严重的区域烧伤事件,以检索它们的行索引值

❸ 为五个最严重的五个事件生成瀑布图(如图 15.15 所示)

拥有这些顶级事件图和每个特征对这些事件的贡献,企业现在可以识别他们希望向分析师和现场观察者解释的行为模式。有了这些知识,人们可以在模型预测之前就做好准备并主动采取行动。

使用shap帮助团队根据模型对数据的推断采取一系列有益的行动,这是该工具最强大的功能之一。它可以帮助以其他方式难以利用的方式利用模型。它有助于从模型中获得比单独的预测更大的深远利益(对商业(或社会和自然界)而言)。

关于 XAI 的个人笔记

解释基于输入特征的监督(和未监督!)模型如何得出结论,这帮助我制定了对问题的更全面解决方案。然而,XAI 还使我能够执行 DS 将参与的最重要任务之一:赢得企业的信任。

建立对数据解释和赋予企业能力的信任和内在信念,使组织能够更全面地向目标迈进,实现真正以数据驱动的决策过程。当基于证据的逻辑被用来引导企业时,效率、收入和员工的总体福祉都会提高。最重要的是,这为使您的业务同行参与理解您帮助他们前进的算法提供了理由。

与此包相关联的还有许多图表和功能,其中大部分在存储库中该章节的配套笔记本中得到了彻底的介绍。我鼓励您阅读它并考虑在您的项目中采用这种方法。

摘要

  • 通过使用基于规则的验证功能存储库,可以实现特征和推断数据的一致性。拥有单一的真实来源可以显著减少对结果解释的混淆,并确保对发送给模型的任何数据进行质量控制检查。

  • 建立因数据不足或数据集损坏导致的预测失败的后备条件,可以确保解决方案的消费者不会看到错误或服务中断。

  • 仅使用预测质量指标不足以确定解决方案的有效性。由领域专家、测试用户和跨职能团队成员对预测结果进行验证,可以为任何 ML 解决方案提供主观质量测量。

  • 利用如shap等技术可以帮助以简单的方式解释模型为何做出特定的决策,以及特定特征值对模型预测的影响。这些工具对于解决方案的生产健康至关重要,尤其是在定期重新训练期间。

16 生产基础设施

本章涵盖

  • 使用模型注册表实现被动再训练

  • 利用特征存储进行模型训练和推理

  • 为机器学习解决方案选择合适的托管架构

在实际用例中利用机器学习解决复杂问题具有挑战性。需要掌握的技能数量巨大,以从公司的数据(通常是杂乱无章、部分完整且充满质量问题)中提取数据,选择合适的算法,调整管道,并验证模型(或模型集合)的预测输出是否满足业务需求,这令人望而生畏。尽管创建了一个可接受性能的模型,但机器学习支持的项目复杂性并未结束。如果做得不正确,架构考虑和实现细节可能会给项目带来重大挑战。

每天似乎都有新的开源技术栈承诺更简单的部署策略或神奇的自动化解决方案,以满足所有需求。随着这些工具和平台的不断涌入,决定如何满足特定项目的需求可能会令人感到畏惧。

初看可用的产品,可能会让人觉得最合理的计划是坚持单一范式(例如,将每个模型作为 REST API 服务部署)。确保每个机器学习项目都遵循共同的架构和实现确实简化了发布部署。然而,事实并非如此。正如在选择算法时,没有“一刀切”的生产基础设施。

本章的目标是介绍可以应用于模型预测架构的通用通用主题和解决方案。在介绍掩盖生产机器学习服务复杂性和细节的基本工具之后,我们将深入研究可以满足不同项目需求的通用架构。

任何托管架构的目标是构建具有最少功能、最简单和最经济的解决方案,同时仍能满足消费模型输出的需求。以服务的一致性和效率(SLA 和预测量考虑)作为生产工作的主要焦点,有几个关键概念和方法需要了解,以使机器学习项目工作的最后一公里尽可能无痛。

16.1 艺术品管理

让我们想象我们仍在第十五章中介绍的森林服务火灾风险部门的火险部门工作。在我们努力有效地派遣人员和设备到公园系统中的高风险区域的过程中,我们找到了一个效果显著的方法。我们的特征已经锁定,并且随着时间的推移保持稳定。我们已经评估了预测的性能,并从模型中看到了真正的价值。

在将特征状态调整得好的整个过程中,我们一直在迭代改进周期,如图 16.1 所示。

16-01

图 16.1 部署模型在生产稳态操作过程中的改进

如此循环所示,我们一直在迭代发布模型的新版本,与基线部署进行测试,收集反馈,并努力改进预测。然而,在某个时候,我们将进入模型维持模式。

我们已经尽最大努力改进进入模型的特征,并发现继续向项目中添加新数据元素的投资回报率(ROI)根本不值得。我们现在处于根据随时间到来的新数据进行模型计划被动重新训练的位置。

当我们达到这个稳态点时,我们最不想做的就是让 DS 团队的一员花一个下午的时间手动重新训练一个模型,手动比较其结果与当前生产部署的模型,并通过临时分析来决定是否更新模型。

哦,拜托。没有人会手动做这件事。

从我作为一个数据科学家(DS)的历史来看,我在解决问题的前六年没有开始使用被动重新训练。这并不是因为缺乏需求,也不是因为缺乏工具。纯粹是因为无知。我不知道问题漂移可能造成多大的问题(我多次通过解决方案变得无关紧要来艰难地了解到这一点,因为我忽视了它)。我也不理解或欣赏归因计算的重要性。

经过多年的反复犯错,我发现了一些通过研究解决我自设的项目工程不足的难题而写下的技术。我接受了最初让我进入 DS 工作的想法:自动化令人讨厌和重复的任务。通过移除手动监控项目健康状况的活动(通过临时漂移跟踪),我发现我解决了困扰我的两个主要问题。

首先,我释放了自己的时间。对预测结果和特征稳定性进行临时分析花费了很多时间。此外,这项工作极其无聊。

第二个大问题是准确性。手动评估模型性能是重复且易出错的。通过手动分析遗漏的细节可能导致部署的模型版本比当前部署的版本更差,引入的问题比略微较差的预测性能要严重得多。

我已经从自动化重新训练中吸取了教训(通常在可能的情况下选择被动重新训练系统而不是更复杂的主动系统)。就像我在职业生涯中学到的其他所有东西一样,我是通过犯错误来学习的。希望你们能避免同样的命运。

使用被动重新训练系统自动测量、裁决以及决定是否用新重新训练的模型替换现有模型是可能的。图 16.2 展示了计划重新训练事件的概念。

16-02

图 16.2 被动重新训练系统的逻辑图

在此安排的重新训练自动化实施后,这个系统的主要关注点是了解生产中正在运行的内容。例如,如果在新版本发布后生产中发现了问题,会发生什么?我们如何从对重新训练事件产生重大影响的漂移概念中恢复过来?我们如何在不重建模型的情况下将模型回滚到上一个版本?我们可以通过使用模型注册表来缓解这些担忧。

16.1.1 MLflow 的模型注册表

在我们目前所处的这种情况下,模型自动进行计划更新,了解生产部署的状态对我们来说非常重要。我们不仅需要了解当前状态,而且如果关于被动重新训练系统过去的表现出现疑问,我们需要有一种方法来调查模型的历史来源。图 16.3 比较了使用和不使用注册表来跟踪来源,以解释历史问题。

16-03

图 16.3 被动重新训练计划中发现的远期历史问题

正如您所看到的,尝试重新创建过去运行的流程充满了危险;我们面临很高的风险,无法重现业务在历史预测中发现的那个问题。由于没有注册表来记录生产中使用的工件,必须手动工作来重新创建模型的原始条件。这在大多数公司中可能非常具有挑战性(如果不是不可能的话),因为用于训练模型的基本数据可能已经发生变化,使得无法重新创建那种状态。

如图 16.3 所示,首选的方法是利用模型注册表服务。例如,MLflow 在其 API 中提供了这一功能,允许我们将每次重新训练运行的详细信息记录到跟踪服务器,如果计划中的重新训练作业在保留数据上表现更好,则处理生产推广,并将旧模型存档以供将来参考。如果我们使用了这个框架,测试曾经在生产中运行过的模型的条件过程将简单到只需从注册条目中召回工件,将其加载到笔记本环境中,并使用shap等工具生成可解释的相关报告。

注册表真的那么重要吗?

好吧,用两个字来说,“视情况而定。”

我记得我第一次真正的大规模、严肃的、认真的机器学习实现,有一种令人毛骨悚然的恐惧。这绝对不是我的第一个解决方案的生产发布,但这是第一个受到严重关注的。它帮助运行了业务的一个重要部分,因此受到了许多人的密切审查。如果我可以补充的话,这是理所当然的。

我的部署(如果可以这么称呼的话)涉及一个类似被动式再训练的系统,该系统存储了前一天调优运行中最后已知的良好超参数,使用这些值作为起点开始自动化调优。在优化了所有新的特征训练数据后,它选择了表现最佳的模型,对新数据进行预测,并用预测结果覆盖了服务表。

直到项目生产运行满三个月后,才出现了关于为什么模型以某种意想不到的方式预测某些客户的第一个严重问题。业务领导无法弄清楚为什么它会这样做,所以他们来找我,让我调查。

由于没有模型的记录(甚至没有保存任何地方),并且意识到训练数据随着时间的推移而持续变化,因为特征在更新,这使得我完全无法解释模型的历史性能。

业务对这种回答不太满意。尽管模型没有被关闭(它可能应该被关闭),但它让我意识到存储和编目模型的重要性,以便能够解释为什么解决方案以这种方式表现,即使这种解释是在它被使用后的几个月。

16.1.2 与模型注册接口

为了了解这段代码如何支持与 MLflow 模型注册服务的集成,让我们将我们的用例调整为支持这种被动式再训练功能。首先,我们需要创建一个裁决系统,该系统检查当前生产模型的性能与计划再训练结果之间的比较。在构建了这种比较之后,我们可以与注册服务接口,用较新的模型(如果它更好)替换当前的生产模型,或者根据与新的模型测试的相同保留数据,保持当前的生产模型。

让我们看看如何与 MLflow 模型注册接口支持自动化的被动式再训练,并保留模型状态随时间变化的来源。列表 16.1 建立了构建每个计划再训练事件的历史状态表所需的第一部分。

注意:要查看所有 import 语句和与这些片段集成的完整示例,请参阅 GitHub 仓库中本书此章节的配套笔记本,网址为 github.com/BenWilson2/ML-Engineering

列表 16.1 注册状态行生成和记录

@dataclass
class Registry:                                             ❶
  model_name: str
  production_version: int
  updated: bool
  training_time: str
class RegistryStructure:                                    ❷
  def __init__(self, data):
    self.data = data
  def generate_row(self):
    spark_df = spark.createDataFrame(pd.DataFrame(
      [vars(self.data)]))                                   ❸
    return (spark_df.withColumn("training_time", 
F.to_timestamp(F.col("training_time")))
            .withColumn("production_version", 
F.col("production_version").cast("long")))
class RegistryLogging:
  def __init__(self, 
               database, 
               table, 
               delta_location, 
               model_name, 
               production_version, 
               updated):
    self.database = database
    self.table = table
    self.delta_location = delta_location
    self.entry_data = Registry(model_name, 
                               production_version, 
                               updated, 
                               self._get_time())           ❹
  @classmethod
  def _get_time(self):
    return datetime.today().strftime('%Y-%m-%d %H:%M:%S')
  def _check_exists(self):                                 ❺
    return spark._jsparkSession.catalog().tableExists(
      self.database, self.table)
  def write_entry(self):                                   ❻
    log_row = RegistryStructure(self.entry_data).generate_row()
    log_row.write.format("delta").mode("append").save(self.delta_location)
    if not self._check_exists():
      spark.sql(f"""CREATE TABLE IF NOT EXISTS 
         {self.database}.{self.table} 
         USING DELTA LOCATION 
         '{self.delta_location}';""")

❶ 用于包装我们将要记录的数据的数据类

❷ 用于将注册数据转换为 Spark DataFrame 以将行写入用于溯源的 delta 表的类

❸ 以简写方式访问数据类的成员,以将其转换为 pandas DataFrame,然后转换为 Spark DataFrame(利用隐式类型推断)

❹ 在类初始化时构建 Spark DataFrame 行

❺ 确定 delta 表是否已经创建的方法

❻ 以追加模式将日志数据写入 Delta,并在 Hive Metastore 中创建表引用(如果尚未存在)

这段代码有助于为模型训练历史的溯源做好准备。由于我们希望按计划自动化重新训练,因此拥有一个引用集中位置中更改历史的跟踪表要容易得多。如果我们有多个此模型的构建,以及其他已注册的项目,我们可以有一个单个快照视图来查看生产被动重新训练的状态,而无需做任何更多的事情,只需编写一个简单的查询。

列表 16.2 展示了查询此表将看起来是什么样子。将多个模型记录到这种事务历史表中,添加 df.filter(F.col("model_name") == "<project title>") 可以快速访问单个模型的日志历史。

列表 16.2 查询注册状态表

from pyspark.sql import functions as F
REGISTRY_TABLE = "mleng_demo.registry_status"
display(spark.table(REGISTRY_TABLE).orderBy(F.col("training_time"))    ❶

❶ 由于我们之前在行输入阶段已注册了该表,我们可以通过 .<table_name> 引用直接引用它。然后我们可以按时间顺序对提交进行排序。

执行此代码将产生图 16.4。除了这个日志之外,MLflow 中的模型注册也提供了一个 GUI。图 16.5 展示了与列表 16.2 中的注册表相匹配的 GUI 屏幕截图。

16-04

图 16.4 查询注册状态事务表

现在我们已经设置了历史跟踪功能,我们可以编写与 MLflow 注册服务器的接口以支持被动重新训练。列表 16.3 展示了利用跟踪服务器条目、查询当前生产元数据的注册服务以及自动状态转换重新训练模型以取代当前生产模型的实现。

16-05

图 16.5 我们实验的 MLflow 模型注册 GUI

列表 16.3 被动重新训练模型注册逻辑

class ModelRegistration:
  def __init__(self, experiment_name, experiment_title, model_name, metric,
               direction):
    self.experiment_name = experiment_name
    self.experiment_title = experiment_title
    self.model_name = model_name
    self.metric = metric
    self.direction = direction
    self.client = MlflowClient()
    self.experiment_id = mlflow.get_experiment_by_name(experiment_name).experiment_id
  def _get_best_run_info(self, key):                                        ❶
    run_data = mlflow.search_runs(
      self.experiment_id, 
      order_by=[f"metrics.{self.metric} {self.direction}"])
    return run_data.head(1)[key].values[0]
  def _get_registered_status(self):
    return self.client.get_registered_model(name=self.experiment_title)
  def _get_current_prod(self):                                              ❷
    return ([x.run_id for x in self._get_registered_status().latest_versions
     if x.current_stage == "Production"][0])
  def _get_prod_version(self):
    return int([x.version for x in 
     self._get_registered_status().latest_versions
             if x.current_stage == "Production"][0])
  def _get_metric(self, run_id):
    return mlflow.get_run(run_id).data.metrics.get(self.metric)
  def _find_best(self):                                                     ❸
    try: 
      current_prod_id = self._get_current_prod()
      prod_metric = self._get_metric(current_prod_id)
    except mlflow.exceptions.RestException:
      current_prod_id = -1
      prod_metric = 1e7
    best_id = self._get_best_run_info('run_id')
    best_metric = self._get_metric(best_id)
    if self.direction == "ASC":
      if prod_metric < best_metric:
        return current_prod_id
      else:
        return best_id
    else:
      if prod_metric > best_metric:
        return current_prod_id
      else:
        return best_id
  def _generate_artifact_path(self, run_id):
    return f"runs:/{run_id}/{self.model_name}"
  def register_best(self, registration_message, logging_location, log_db,
                    log_table):                                             ❹
    best_id = self._find_best()
    try:
      current_prod = self._get_current_prod()
      current_prod_version = self._get_prod_version()
    except mlflow.exceptions.RestException:
      current_prod = -1
      current_prod_version = -1
    updated = current_prod != best_id
    if updated:
      register_new = mlflow.register_model(self._generate_artifact_path(best_id),
                                   self.experiment_title)
      self.client.update_registered_model(name=register_new.name, 
                                          description="Forest Fire 
                                          Prediction for the National Park")
      self.client.update_model_version(name=register_new.name, 
                                       version=register_new.version, 
                                       description=registration_message)
      self.client.transition_model_version_stage(name=register_new.name, 
                                                 version=register_new.version,
                                                 stage="Production")
      if current_prod_version > 0:
        self.client.transition_model_version_stage(
          name=register_new.name, 
          version=current_prod_version,
         stage="Archived")
      RegistryLogging(log_db, 
            log_table, 
            logging_location, 
            self.experiment_title,  
            int(register_new.version), 
            updated).write_entry()
      return "upgraded prod"
    else:
      RegistryLogging(log_db, 
            log_table, 
            logging_location, 
            self.experiment_title, 
            int(current_prod_version), 
            updated).write_entry()
      return "no change"
  def get_model_as_udf(self):                                               ❺
    prod_id = self._get_current_prod()
    artifact_uri = self._generate_artifact_path(prod_id)
    return mlflow.pyfunc.spark_udf(spark, model_uri=artifact_uri)

❶ 从生产部署的历史中提取所有之前的运行数据,并返回针对验证数据的最佳性能的运行 ID

❷ 查询当前在注册表中注册为“生产部署”的模型

❸ 确定当前计划中的被动重新训练运行是否在其保留数据上优于生产的查询方法。它将返回最佳记录运行的 run_id。

❹ 如果新模型表现更好,则利用 MLflow 模型注册表 API 进行注册,如果正在替换,则注销当前生产模型

❺ 使用 Python UDF 在 Spark DataFrame 上获取当前生产模型以进行批量推理

此代码允许我们完全管理此模型实现的被动重新训练(有关完整代码,请参阅本书的配套 GitHub 仓库)。通过利用 MLflow 模型注册表 API,我们可以通过一行代码访问模型工件来满足生产调度预测的需求。

这极大地简化了预测批量调度作业,同时也满足了我们在本节开始讨论的调查需求。有了如此轻松检索模型的能力,我们可以手动将特征数据与该模型进行测试,使用 shap 等工具进行模拟,并快速回答业务问题,而无需努力重新创建可能无法实现的状态。

在使用模型注册表跟踪模型工件的同一路线上,用于训练模型和用模型进行预测的特征也可以为了效率而编目。这一概念通过特征存储来实现。

这很酷,但关于主动重新训练呢?

被动重新训练和主动重新训练之间的主要区别在于触发重新训练的机制。

被动式,由 CRON 调度,是一种“最佳希望”策略,试图通过结合新的训练数据来寻找改进的模型拟合度,以对抗漂移。另一方面,主动式则监控预测状态和特征,以算法方式确定何时触发重新训练。

由于它旨在应对不可预测的性能下降,如果漂移以不可预测的速度发生,则主动系统可能有益——例如,一个模型已经表现良好几周,但在几天内崩溃,重新训练后仅表现良好几天,然后又需要重新训练。为了创建这种响应式反馈循环以触发重新训练事件,需要监控预测质量。需要构建一个系统来生成重新训练信号;该系统消耗预测,合并后来到达的(在某些情况下,几秒后,在其他情况下,几周后)高度可变的地标结果,并在时间上对聚合结果状态设置统计上显著的阈值。

这些系统高度依赖于 ML 解决的问题的本质,因此它们的设计和实现差异很大,以至于即使是通用的示例架构在这里也不相关。

例如,如果你试图确定某个地点在下一个小时内预测天气模型成功性的成功,你可以在一个小时内获得反馈。你可以构建一个系统,将滞后一个小时的实时天气与预测合并,将实际模型准确性输入到过去 48 小时内准确性率的窗口聚合中。如果天气预测的成功率聚合低于定义的 70%阈值,可以自动启动模型的再训练。这个新训练的模型可以通过通过验证两个模型的标准(新)保留验证数据集来与当前生产模型进行比较。然后,可以通过蓝/绿部署策略立即使用新模型,或者通过具有多臂老丨虎丨机算法的动态流量分配来逐渐使用它,该算法根据与当前生产模型的相对性能改进来路由流量。

概括来说,主动再训练是复杂的。我建议人们在发现被动再训练已经不再奏效时才去研究它,而不是仅仅因为它看起来很重要。在自主处理再训练时,有更多的部分、服务和基础设施需要处理。使用主动再训练时,你收到的云服务账单也会反映出复杂性的增加(它很昂贵)。

16.2 特征存储

我们在上一章简要提到了使用特征存储。虽然理解实施特征存储的合理性和好处(即一致性、可重用性和可测试性)很重要,但看到相对较新的技术的应用比讨论理论更为相关。在这里,我们将探讨一个我努力克服的场景,涉及利用特征存储在利用机器学习和高级分析的组织中强制执行一致性的重要性。

让我们设想我们在一个拥有多个数据科学(DS)团队的公司工作。在工程组内,主要的 DS 团队专注于公司范围内的项目。这个团队主要处理涉及关键服务的大型项目,这些服务可以被公司内的任何团队使用,以及面向客户的服务。在各个部门中散布着一些独立的贡献型 DS 员工,他们由各自的部门负责人雇佣并汇报。虽然存在协作,但核心 DS 团队使用的主要数据集并不对独立的 DS 员工开放。

在新年的开始,部门主管雇佣了一位刚从大学项目中毕业的新 DS。这位新员工有良好的意图,有动力,充满热情,立即开始着手进行这位部门主管希望调查的倡议。在分析公司客户特征的过程中,这位新员工遇到了一个包含客户拨打客服中心投诉概率的生产表。出于好奇,这位新的 DS 开始分析预测与他们在部门数据仓库中的数据之间的对比。

无法将任何特征数据与预测结果相匹配,数据科学家(DS)开始着手构建一个新的模型原型,试图改进投诉预测解决方案。经过几周的努力,DS 向他们的部门主管展示了他们的发现。在获得继续进行这个项目的批准后,DS 开始在他们的分析部门工作空间中构建项目。几个月后,DS 在公司全体员工会议上展示了他们的发现。

感到困惑,核心 DS 团队询问为什么这个项目正在进行,并要求进一步了解实施细节。不到一个小时,核心 DS 团队就能解释为什么独立 DS 的解决方案如此有效:他们泄露了标签。图 16.6 展示了核心 DS 团队的解释:构建任何新模型或对从用户收集的数据进行广泛分析所需的数据被核心 DS 团队工程部门周围的隔阂所隔离。

16-06

图 16.6 隔离工程部门,将原始数据和计算特征与其他组织部分隔离开

用于训练的数据,存在于部门的数据仓库中,是由核心 DS 团队的生产解决方案提供的。用于训练核心模型的每个源特征对工程和生产流程之外的人不可访问。

虽然这个场景是极端的,但它确实发生了。核心团队本可以通过提供一个可访问的生成特征数据源,开放访问权限,允许其他团队利用这些高度精选的数据点进行额外项目,从而帮助避免这种情况。通过将他们的数据与适当的标签和文档注册,他们可以节省这位可怜的 DS 大量的努力。

16.2.1 特征存储的用途

解决我们场景中的数据孤岛问题是使用特征存储的最有说服力的理由之一。当在一个组织内处理分布式 DS 能力时,通过减少重复工作、不一致的分析和围绕解决方案真实性的普遍困惑,可以看到标准化和可访问性的好处。

然而,拥有特征存储使组织能够利用其数据进行比仅仅进行质量控制更多的操作。为了说明这些好处,图 16.7 展示了带有和不带有特征存储的模型构建和服务的代码架构。

图 16.7 的上半部分显示了 ML 开发项目的历史现实。紧密耦合的特征工程代码直接与模型调优和训练代码一起开发,以生成比仅用原始数据进行训练更有效的模型。虽然从开发角度来看,这种架构对于生成一个好的模型是有意义的,但它会在开发预测代码库时产生问题(如图 16.7 右上角所示)。

16-07

图 16.7 使用特征存储与不使用特征存储进行机器学习开发的比较

现在需要对原始数据进行的所有操作都需要迁移到这个服务代码中,这为模型向量中的错误和不一致性提供了机会。然而,这种方法的替代方案可以帮助消除数据不一致的可能性:

  • 使用管道(大多数主要机器学习框架都有)。

  • 将特征工程代码抽象成一个包,训练和提供都可以调用。

  • 编写传统的 ETL 来生成特征并将它们存储起来。

每种方法都有其自身的缺点。管道很棒,应该使用,但它们将有用的特征工程逻辑与特定模型的实现纠缠在一起,使其无法在其他地方被利用。简单地没有容易的方法来重用这些特征用于其他项目(更不用说分析师在没有帮助的情况下几乎不可能将特征工程阶段从机器学习管道中分离出来)。

抽象特征工程代码确实有助于代码重用,并解决了需要使用这些特征的项目的一致性问题。但访问这些特征在 DS 团队之外仍然受到限制。另一个缺点是,它又是一个需要维护、测试和频繁更新的代码库。

让我们看看与特征存储交互的例子,使用 Databricks 实现来看到实际的好处。

注意:公司构建的此类特征实现可能会发生变化。API、特征细节和相关功能可能会随着时间的推移而改变,有时变化相当显著。此示例展示了一个特征存储的实现,用于演示目的。

16.2.2 使用特征存储

利用特征存储的第一步是定义一个 DataFrame 表示,用于创建我们希望用于建模和分析的特征的涉及的处理过程。以下列表显示了一组函数,这些函数正在对原始数据集进行操作以生成新特征。

列表 16.4 特征工程逻辑

from dataclasses import dataclass
from typing import List
from pyspark.sql.types import *
from pyspark.sql import functions as F
from pyspark.sql.functions import when
@dataclass
class SchemaTypes:
  string_cols: List[str]
  non_string_cols: List[str]
def get_col_types(df):
  schema = df.schema
  strings = [x.name for x in schema if x.dataType == StringType()]
  non_strings = [x for x in schema.names if x not in strings]
  return SchemaTypes(strings, non_strings)
def clean_messy_strings(df):                                 ❶
  cols = get_col_types(df)
  return df.select(*cols.non_string_cols, *[F.regexp_replace(F.col(x), " ", 
    "").alias(x) for x in cols.string_cols])
def fill_missing(df):                                        ❷
  cols = get_col_types(df)
  return df.select(
*cols.non_string_cols, *[when(F.col(x) == "?", 
"Unknown").otherwise(F.col(x)).alias(x) for x in cols.string_cols])
def convert_label(df, label, true_condition_string):         ❸
  return df.withColumn(label, when(F.col(label) == 
true_condition_string,1).otherwise(0))
def generate_features(df, id_augment):                       ❹
  overtime = df.withColumn("overtime", 
when(F.col("hours_worked_per_week") > 40, 1).otherwise(0))
  net_pos = overtime.withColumn("gains", 
when(F.col("capital_gain") > F.col("capital_loss"), 1).otherwise(0))
  high_edu = net_pos.withColumn("highly_educated", 
when(F.col("education_years") >= 16, 2)
.when(F.col("education_years") > 12, 1).otherwise(0))
        gender = high_edu.withColumn("gender_key", 
when(F.col("gender") == "Female", 1).otherwise(0))
  keys = gender.withColumn("id", 
F.monotonically_increasing_id() + F.lit(id_augment))
  return keys
def data_augmentation(df, 
                      label, 
                      label_true_condition, 
                      id_augment=0):                         ❺
  clean_strings = clean_messy_strings(df)
  missing_filled = fill_missing(clean_strings)
  corrected_label = convert_label(missing_filled, 
                                  label, 
                                  label_true_condition)
  additional_features = generate_features(corrected_label, 
                                           id_augment)
  return additional_features

❶ 对数据集的字符串列进行一般清理,去除前导空白

❷ 将未知占位符转换为更有用的字符串

❸ 将目标从字符串转换为布尔二进制值

❹ 为模型创建新的编码特征

❺ 执行所有特征工程阶段,返回一个 Spark DataFrame

一旦我们执行此代码,我们将得到一个 DataFrame 和创建那些附加列所需的嵌入式逻辑。有了这个,我们可以初始化特征存储客户端并注册表,如下一个列表所示。

列表 16.5 将特征工程注册到特征存储

from databricks import feature_store               ❶
fs = feature_store.FeatureStoreClient()            ❷
FEATURE_TABLE = "ds_database.salary_features"      ❸
FEATURE_KEYS = ["id"]                              ❹
FEATURE_PARTITION = "gender"                       ❺
fs.create_feature_table(
  name=FEATURE_TABLE,
  keys=["id"],
  features_df=data_augmentation(raw_data, 
                                "income", 
                                ">50K"),           ❻
  partition_columns=FEATURE_PARTITION,
  description="Adult Salary Data. Raw Features."   ❼
)

❶ 包含与特征存储接口 API 的库

❷ 初始化特征存储客户端以与特征存储 API 交互

❸ 将此功能表注册到的数据库和表名

❹ 一个主键以影响连接

❺ 设置分区键以使查询在利用该键的操作中表现更好

❻ 指定用于定义特征存储表 DataFrame 的处理历史(来自列表 16.4)

❼ 添加描述以让其他人了解此表的内容

在执行功能表注册后,我们可以通过轻量级的计划 ETL 确保它随着新数据的到来而填充。以下列表显示了这有多么简单。

列表 16.6 特征存储 ETL 更新

new_data = spark.table(“prod_db.salary_raw”)             ❶
processed_new_data = data_augmentation(new_data, 
                                        "income", 
                                        ">50K", 
                                        table_counts)    ❷
fs = feature_store.FeatureStoreClient()
fs.write_table(                                          ❸
  name=FEATURE_TABLE,
  df=processed_new_data,
  mode='merge'
)

❶ 读取需要通过特征生成逻辑处理的新原始数据

❷ 通过特征逻辑处理数据

❸ 以合并模式通过先前注册的功能表写入新特征数据以追加新行

现在我们已经注册了表,其实用性的真正关键是注册一个使用它的模型。为了开始访问特征表中定义的特征,我们需要为每个字段定义查找访问器。下一个列表显示了如何进行我们想要用于我们的收入预测模型的数据获取。

列表 16.7 用于建模的特征获取

from databricks.feature_store import FeatureLookup          ❶
def generate_lookup(table, feature, key):
  return FeatureLookup(
    table_name=table,
    feature_name=feature,
    lookup_key=key
  )
features = ["overtime", "gains", "highly_educated", "age",
            "education_years", "hours_worked_per_week", 
            "gender_key"]                                   ❷
lookups = [generate_lookup(FEATURE_TABLE, x, "id") 
            for x in features]                              ❸

❶ 用于建模目的与特征存储接口的 API

❷ 我们模型将使用的字段名称列表

❸ 每个特征的查找对象

现在我们已经定义了查找引用,我们可以在训练简单模型时使用它们,如列表 16.8 所示。

NOTE 这只是完整代码的简略片段。请参阅书中存储库中的配套代码 github.com/BenWilson2/ML-Engineering 以获取完整示例。

列表 16.8 注册与特征存储集成的模型

import mlflow
from catboost import CatBoostClassifier, metrics as cb_metrics
from sklearn.model_selection import train_test_split
EXPERIMENT_TITLE = "Adult_Catboost"
MODEL_TYPE = "adult_catboost_classifier"
EXPERIMENT_NAME = f"/Users/me/Book/{EXPERIMENT_TITLE}"
mlflow.set_experiment(EXPERIMENT_NAME)
with mlflow.start_run():
  TEST_SIZE = 0.15
  training_df = spark.table(FEATURE_TABLE).select("id", "income")
  training_data = fs.create_training_set(
    df=training_df,
    feature_lookups=lookups,
    label="income",
    exclude_columns=['id', 'final_weight', 'capital_gain', 'capital_loss']) ❶
  train_df = training_data.load_df().toPandas()                             ❷
  X = train_df.drop(['income'], axis=1)
  y = train_df.income
  X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=TEST_SIZE,
                                                      random_state=42,
                                                      stratify=y)
  model = CatBoostClassifier(iterations=10000, learning_rate=0.00001, 
    custom_loss=[cb_metrics.AUC()]).fit(X_train, y_train, 
      eval_set=(X_test, y_test), logging_level="Verbose")
  fs.log_model(model, MODEL_TYPE, flavor=mlflow.catboost,
    training_set=training_data, registered_model_name=MODEL_TYPE)           ❸

❶ 通过使用前一个列表中定义的查找来指定用于训练模型的字段

❷ 将 Spark DataFrame 转换为 pandas DataFrame 以利用 catboost

❸ 将模型注册到特征存储 API,以便特征工程任务将合并到模型工件中

通过此代码,我们定义了一个数据源,作为对特征存储表的链接,一个利用这些特征进行训练的模型,以及将工件依赖链注册到特征存储与 MLflow 的集成。

从一致性和实用性角度来看,特征存储的吸引力最终体现在模型的提供服务上。假设我们想使用此模型进行每日批处理预测。如果我们不使用特征存储方法,我们就必须重新生成特征生成逻辑或调用外部包,对原始数据进行处理,以获取我们的特征。相反,我们只需编写几行代码即可获得批处理预测的输出。

列表 16.9 使用已注册的特征存储模型运行批处理预测

from mlflow.tracking.client import MlflowClient
client = MlflowClient()
experiment_id = mlflow.get_experiment_by_name(EXPERIMENT_NAME).experiment_id ❶
run_id = mlflow.search_runs(experiment_id, 
    order_by=["start_time DESC"]
   ).head(1)["run_id"].values[0]                                             ❷
feature_store_predictions = fs.score_batch(
                            f"runs:/{run_id}/{MODEL_TYPE}", 
                            spark.table(FEATURE_TABLE))                      ❸

❶ 通过特征存储 API 检索注册到 MLflow 的实验

❷ 从实验中获取我们感兴趣的个别运行 ID(在此处,最新的运行)

❸ 在不编写摄入逻辑和执行批处理预测的情况下,将模型应用于定义的特征表

虽然像这样的批处理预测占历史机器学习用例的大比例,但 API 支持注册外部 OLTP 数据库或内存数据库作为接收器。通过将特征存储的发布副本填充到支持低延迟和弹性服务需求的服务中,可以轻松满足所有服务器端(非边缘部署)的建模需求。

16.2.3 评估特征存储

选择特征存储(或自己构建)时需要考虑的要素,就像不同公司对数据存储模式的需求一样多样化。在考虑此类服务的当前和潜在未来增长需求时,应仔细评估特定特征存储的功能,同时牢记这些重要需求:

  • 将特征存储与外部数据服务平台同步,以支持实时服务(OLTP 或内存数据库)

  • 可供其他团队用于分析、建模和 BI 用例的访问权限

  • 通过批处理和流式源轻松将数据摄入到特征存储中

  • 遵守围绕数据(访问控制)的法律限制的安全考虑

  • 将即时(JIT)数据合并到特征存储数据(用户生成数据)中进行预测的能力

  • 数据血缘和依赖关系跟踪,以查看哪些项目正在创建和消耗存储在特征存储中的数据

通过有效的研发和评估,特征存储解决方案可以极大地简化生产服务架构,消除训练和提供服务之间的不一致性错误,并减少其他人跨组织重复工作的可能性。它们是非常有用的框架,我确实看到它们将成为行业内所有未来机器学习努力的组成部分。

好吧,特征存储很酷,但我真的需要它吗?

“我们多年来没有它也能过得很好。”

我通常对新技术的炒作持怀疑态度。我倾向于用高度怀疑的眼光看待任何新事物,尤其是那些声称能解决许多挑战性问题或听起来好得令人难以置信的事物。说实话,机器学习领域的许多公告正是如此:它们忽略了为什么他们声称要解决的问题在过去对其他人来说如此困难的原因。只有当我开始对“新潮技术”进行实地测试时,问题才开始显现。

我还没有在特征存储库上有过这样的体验。恰恰相反。我最初确实对他们持怀疑态度。但测试其功能,看到集中跟踪特征、复用复杂特征工程逻辑的结果以及能够解耦和监控外部计划作业中的特征的好处,让我成为了信徒。能够监控特征的健康状况,无需为额外项目维护单独的计算特征逻辑,以及能够创建可用于 BI 用例的特征是无价的。

这些系统在项目开发期间也很有用。有了特征存储库,你不会修改通过 ETL 创建的生产表。由于特征工程工作的速度和动态性,可以在这些特征表上执行轻量级的 ETL,而不需要与数据湖或数据仓库中生产数据变更相关的大规模变更管理。数据完全处于数据科学团队的监管之下(当然,仍然遵守生产代码质量标准!),与 DE 作业的变更相比,对整个组织的更大规模变更得到了缓解。

你绝对需要一个特征存储库吗?不,你不需要。但拥有一个用于开发、生产部署和数据重用的好处如此之大,以至于不使用一个是不合逻辑的。

16.3 预测服务架构

让我们假设一下,我们的公司正在努力将第一个模型投入生产。在过去四个月里,数据科学团队一直在努力微调酒店房间价格优化器。这个项目的最终目标是生成一个精选的个性化交易列表,比现在现有的通用集合更相关于个别用户。

对于每个用户,团队的计划是每天生成预测,预测可能的访问地点(或用户过去访问过的地点),生成在区域搜索期间要显示的交易列表。团队很早就意识到需要将预测结果适应用户当前会话的浏览活动。

为了解决这种动态需求,团队为每位成员根据过去旅行过的类似地区的可用交易生成过大的预计算列表。该项目备选和冷启动逻辑简单地使用了项目开始前就存在的现有全局启发式方法。图 16.8 显示了团队为提供预测而构思的计划总体架构。

16-08

图 16.8 初始服务架构设计

初始阶段,在构建这个基础设施之后,QA 测试看起来很稳固。基于 NoSQL 的 REST API 的响应 SLA 表现良好,模型输出的批量预测和启发式逻辑在成本上进行了优化,备选逻辑故障转移工作得非常完美。团队准备开始通过 A/B 测试来测试这个解决方案。

不幸的是,测试组的预订率与控制组的预订率没有不同。在分析结果后,团队发现不到 5%的会话使用了预测,迫使剩余的 95%的页面显示使用备选逻辑(这与向控制组展示的数据相同)。哎呀。为了修复这种糟糕的性能,DS 团队决定专注于两个领域:

  • 增加每个用户在每个地理区域内的预测数量

  • 增加每个用户预测的区域数量以覆盖

这个解决方案极大地影响了他们的存储成本。他们本可以如何不同地做?图 16.9 显示了显著不同的架构,该架构可以解决这个问题,而无需在处理和存储上产生如此巨大的成本。

16-09

图 16.9 此用例的更具成本效益的架构

虽然这些变化既不微不足道,也不太可能受到 DS 团队或网站工程团队的热烈欢迎,但它们清楚地说明了为什么服务预测永远不应该是一个项目的后续考虑。为了有效地提供价值,应在项目初期评估服务架构开发的几个考虑因素。接下来的小节将涵盖这些考虑因素以及满足这些场景所需的架构类型。

16.3.1 确定服务需求

在我们的性能场景中,团队最初未能设计出一个完全支持项目需求的服务架构。进行这一选择并非易事,但通过对项目几个关键特征的彻底评估,可以采用适当的服务范式,以实现预测的理想交付方法。

当评估项目需求时,考虑以下正在解决的问题的特征非常重要,以确保服务设计既不过度设计也不过度简化。

这听起来像是一个开发者问题,而不是“我的问题”

可能看起来最好是让软件工程团队来担心如何利用模型工件。毕竟(在大多数情况下),他们在软件开发方面比数据科学团队更擅长,并且接触到了更多适用于机器学习领域的工具和实现技术。

在我的经验中,我从未在将模型“踢过墙”给另一个团队方面取得过太多成功。根据用例,数据操作需求(那些需要特定包或其他对数据科学领域高度专业的算法)、预测后的启发式需求以及工件更新速度可能对开发者来说具有挑战性,难以整合。没有与生产基础设施开发团队的紧密协作,部署一个与现有系统集成服务的尝试可能是一次令人沮丧的练习,并且会产生大量的技术债务。

大多数时候,在与开发团队讨论项目的集成需求后,我们发现了一些巧妙的方法来存储预测,在大量规模上操作数据,并在最低成本下满足项目 SLA 需求的设计上进行协作。如果没有数据科学团队对模型所做之事的反馈,开发团队将无法为优化架构决策做好准备。同样,如果没有开发团队的指导和协作,数据科学团队可能创建的解决方案不符合 SLA 需求,或者成本过高,无法长期运行。

在评估服务架构时,协作至关重要;很多时候,这种协作有助于告知机器学习解决方案输出结构的设计。最好在项目设计阶段早期就涉及模型的“工程消费者”。他们越早参与项目(数据工程师对于批量批量预测解决方案,软件工程师对于实时服务解决方案),他们就能对如何构建解决方案的决策产生越多的积极影响。

SLA

在我们场景的早期,团队的原意是确保他们的预测不会打断最终用户的 APP 体验。他们的设计包括一组预先计算的推荐,存储在一个超低延迟的存储系统中,以消除他们假设运行基于虚拟机的模型服务所涉及的时间负担。

SLA 考虑因素是机器学习架构设计中服务于用户的最重要方面之一。一般来说,构建的解决方案必须考虑服务延迟的预算,并确保在大多数时间里,这个预算不会被延长或违反。无论模型在预测准确度或效能方面表现得多么出色,如果它不能在分配的时间内被使用或消费,那么它就是无价值的。

需要与 SLA 要求平衡的其他考虑因素是实际的货币预算。作为基础设施复杂性的函数,一般规则是,预测可以以更大的请求规模更快地提供服务,解决方案托管和开发的成本就会更高。

成本

图 16.10 显示了预测新鲜度(预测做出后多长时间打算使用或采取行动)与需要做出的预测量之间的关系,这作为成本和复杂性的因素。

16-10

图 16.10 满足 SLA 和预测量需求时的架构影响

图 16.10 的上半部分展示了一个传统的批处理服务范式。对于极大规模的生产推理量,使用 Apache Spark Structured Streaming 在“触发一次”操作中进行的批预测作业可能是最经济的选项。

图 16.10 的下半部分涉及即时使用的 ML 解决方案。当预测打算用于实时界面时,架构开始从受批处理启发的用例发生重大变化。随着预测量的增加,需要 REST API 接口、服务容器的弹性可伸缩性和对这些服务的流量分配。

近期性

近期性,即特征数据生成与预测可以采取行动之间的延迟,是设计项目模型服务范式最重要的方面之一。SLA(服务等级协议)的考虑因素在很大程度上是选择特定服务层架构的 ML 项目的定义特征。然而,与可用于预测的数据的近期性相关的边缘情况可能会修改项目最终采用的最终可扩展和成本效益的设计。

根据具体情况,数据的近期性和项目的最终用例可能会覆盖基于 SLA 的一般设计标准。图 16.11 展示了一系列数据近期性和消费层模式示例,以说明架构如何从图 16.10 中纯粹基于 SLA 的设计转变为。

16-11

图 16.11 数据近期性和常见使用模式对服务架构的影响

这些例子绝对不是详尽的。在为模型预测提供服务时,边缘情况考虑的多样性不亚于用机器学习解决问题的细微方法。目的是通过评估传入数据的特点、确定项目的需求,并寻求尽可能简单的解决方案来满足项目的限制,从而开启关于哪种服务解决方案是合适的讨论。通过考虑项目服务需求的各个方面(数据的新鲜度、服务级别协议需求、预测量以及预测消费模式),可以利用适当的架构来满足使用模式需求,同时遵守一个既不复杂也不昂贵的原则。

但我们为什么不为所有东西都构建实时服务呢?

围绕一种适合所有情况的模式简化机器学习部署可能很有吸引力。对于一些组织来说,以这种方式减少机器学习工程复杂性可能是有意义的(例如,在 Kubernetes 中提供一切服务)。当然,如果每个项目只需要使用某种形式的框架,该框架支持单一的部署策略,这似乎会更容易。

如果您的公司只有一种机器学习用例,这确实是有道理的。如果您的公司所做的只是代表小型公司进行欺诈预测,那么坚持使用 Seldon 和 Kubernetes 来为所有模型提供 REST API 端点可能是有意义的。如果您专注于基于异步但低流量量的模型进行市场定价优化,那么一个运行简单 Flask 服务器的 Docker 容器将非常适合。

尽管大多数公司并没有只关注单一机器学习用例,但许多公司的内部用例将受益于简单的批量预测,这些预测被写入数据库中的表。大多数团队都有需求,可以通过一些用例的简单(且更便宜!)基础设施来解决,这些用例不需要启动一个每秒可以支持数十万请求的虚拟机集群。对于每天最多查询几十次的情况使用如此先进的基础设施是浪费的(在开发时间、维护和金钱上),并且是疏忽的。

对于机器学习解决方案的长期成功来说,选择一个适合消费模式、数据量大小和交付时间保证的架构至关重要。这并不意味着为了以防万一而过度设计,而是选择满足您项目需求的适当解决方案。不多也不少,当然,更不能更多。

当机器学习项目的输出旨在公司内部使用时,架构负担通常远低于其他任何场景。然而,这并不意味着可以走捷径。使用 MLOps 工具、遵循稳健的数据管理流程和编写可维护的代码在这里与其他任何服务范式一样至关重要。内部用例建模工作可以划分为两大类:批量预计算和轻量级临时微服务。

从数据库或数据仓库中提供服务

旨在工作日内使用的预测通常利用批量预测范式。模型应用于工作日开始前到达的新数据,预测写入表格(通常以覆盖模式),公司内部最终用户可以临时使用这些预测。

无论接口方法(BI 工具、SQL、内部 GUI 等)如何,预测都安排在固定时间(每小时、每天、每周等)进行,DS 团队唯一的基础设施负担是确保预测得以生成并到达表格。图 16.12 展示了支持这种实现的示例架构。

16-12

图 16.12 批量服务通用架构

这种架构是机器学习可以得到的最为简化的解决方案。从注册表中检索训练好的模型,从源系统(最好是特征存储表)查询数据,进行预测,执行漂移监控验证,最后将预测数据写入可访问的位置。对于需要大量预测数据的内部用例,从基础设施角度来看,不需要更多要求。

从微服务框架中提供服务

对于那些基于临时需求依赖更及时预测的内部用例,或者允许用户指定特征向量以接收按需预测(例如优化模拟)的用例,预计算不是一种选择。这种范式专注于拥有一个轻量级的服务层来托管模型,提供一个简单的 REST API 接口以接收数据、生成预测并将预测返回给最终用户。

大多数满足这些要求的实现都是通过 BI 工具和内部 GUI 完成的。图 16.13 展示了支持这种架构设置的示例,以支持临时预测。

16-13

图 16.13 轻量级低容量 REST 微服务架构

这种部署风格的简单性对许多用于内部应用场景的模型服务的用例具有吸引力。能够支持每秒数十个请求,轻量级的 Flask 模型部署可以是一个吸引人的替代方案,用于可能的最终用户预测排列的大规模暴力计算。

没错,我们知道团队

对于内部使用项目来说,走捷径可能相当诱人。也许记录被动的再训练历史对于内部项目来说似乎是过度杀鸡用牛刀。可能有人会诱人地将代码库发送给一个设计糟糕、缺乏适当重构(如果用于面向客户的模型则会进行重构)的计划任务。花费额外的时间优化数据存储设计以支持最终用户查询性能可能看起来是浪费时间。

最后,他们都是同事。如果它不能完美工作,他们会理解的,对吧?

没有什么比这更远离真相了。根据我的经验,公司对数据科学团队的集体认知是基于这些内部用例项目。数据科学团队感知的能力、容量和竞争力直接受到这些内部工具在公司内部用户中工作效果的影响。以与客户使用解决方案相同的工程严谨性和纪律性构建这些解决方案至关重要。你的声誉就悬于一线,而你可能没有意识到这一点。

在内部项目中,感知能力变得重要,原因无他,这些内部团队将需要你的团队参与未来的项目。如果这些团队认为数据科学团队为他们提供的解决方案是破损的、不稳定的和有缺陷的,那么他们希望你的团队参与面向客户的项目的可能性几乎为零。

毕竟,你们都是公司内部的同事。确保你的主要客户——业务部门——对你交付稳定和有用解决方案的能力有信心,你会做得很好。

16.3.2 批量外部交付

批量外部交付的考虑因素与内部使用数据库或数据仓库的服务没有实质性区别。这些服务案例之间的唯一实质性差异在于交付时间和预测的监控。

交付一致性

将结果批量交付给外部方的相关要求与其他任何机器学习解决方案相同。无论你是为内部团队构建东西还是生成面向最终用户客户的预测,创建有用预测的目标不会改变。

与其他服务范例相比,向外部组织(通常适用于 B2B 公司)提供大量预测时发生变化的唯一一件事是交付的及时性。虽然显然未能完全交付大量预测的摘要是坏事,但不一致的交付同样有害。然而,有一个简单的解决方案,如图 16.14 的底部部分所示。

图 16.14 显示了对外部用户组进行门控和非门控服务的比较。通过控制计划批量预测作业中存储预测的最终阶段出口,以及将特征生成逻辑与受特征存储管理的 ETL 过程耦合,可以从时间角度保证交付的一致性。虽然从生成预测的团队的 DS 角度来看,这可能不是一个重要的考虑因素,但具有可预测的数据可用性计划可以显著提高服务公司的专业形象。

16-14

图 16.14 未加门控与加门控批量服务的比较

质量保证

在公司数据科学和数据分析团队外部(即公司 DS 和数据分析团队外部)提供大量预测服务时,一个有时被忽视的方面是确保对这些预测进行彻底的质量检查。

一个内部项目可能依赖于对明显的预测失败的简单检查(例如,忽略导致空值的静默失败,或线性模型预测无穷大)。当将数据产品发送到外部时,应采取额外步骤以最大限度地减少预测的最终用户对其提出异议的机会。由于我们人类在寻找模式中的异常方面非常擅长,因此批量交付的预测数据集中的一些少量问题很容易引起数据消费者的关注,从而降低他们对解决方案有效性的信心,甚至导致其不再使用。

在我的经验中,当向数据专家团队外部交付大量预测时,我在发布数据之前进行了一些检查是值得的:

  • 将预测与训练数据进行验证:

    • 分类问题—比较聚合的类别计数

    • 回归问题—比较预测分布

    • 无监督问题—评估组成员计数

  • 检查预测异常值(适用于回归问题)。

  • 基于知识渊博的领域专家的知识(如果适用)建立启发式规则,以确保预测不会超出主题的可能性范围。

  • 验证传入的特征(尤其是可能使用通用捕获所有编码的编码特征,如果编码键之前未见)以确保数据与训练时的模型完全兼容。

通过在批量预测的输出上运行一些额外的验证步骤,可以在最终产品面前避免大量的困惑和信任度降低的可能性。

16.3.3 微批量流式处理

流式预测范式的应用相当有限。无法满足严格的 SLA 要求,这会迫使决策者利用 REST API 服务,以及对于小规模批量预测需求来说过于冗余,流式预测在机器学习服务基础设施中占据一个独特的空间。这个细分市场位置牢牢地集中在项目对相对较高的 SLA(以秒到周的范围衡量)和大型推理数据集大小的需求上。

流式处理对高 SLA 需求的吸引力在于成本和复杂性的降低。而不是构建一个可扩展的基础设施来支持发送到 REST API 服务(或类似微服务,能够执行分页批量预测大数据)的大批量预测,可以配置一个简单的 Apache Spark 结构化流式处理作业,允许从流式源(如 Kafka 或云对象存储队列索引)中提取基于行的数据,并使用序列化模型工件在流上本地运行预测。这有助于显著降低复杂性,可以支持流式批处理状态计算,并防止在不需要进行预测时运行昂贵的硬件。

从大数据量的角度来看,流式处理可以减少在传统批量预测范式下进行大数据集预测所需的底层基础设施规模。通过将数据流经一个比在内存中保留整个数据集所需的机器集群规模更小的集群,基础设施负担大大减轻。

这直接转化为具有相对较高服务级别协议(SLA)的机器学习解决方案的总拥有成本降低。图 16.15 展示了简单结构化流式处理方法,以比传统批量或 REST API 解决方案更低的复杂性和成本提供预测。

16-15

图 16.15 简单结构化流式处理预测管道架构

虽然不能解决机器学习服务的大部分需求,但这种架构在处理极大数据集和当 SLA 不是特别严格时,作为批量预测的吸引人替代品仍有其位置。如果适合这个细分市场,实施这种服务方法还是值得的,仅仅是因为成本的降低。

16.3.4 实时服务器端

实时服务的主要特征是低 SLA。这直接影响了服务预测的基本架构设计。支持此范式的任何系统都需要托管模型工件作为服务,并配有一个接口用于接受传入的数据,一个计算引擎用于执行预测,以及一个方法将预测返回给原始请求者。

实时服务架构实现的细节可以通过对流量级别的分类来定义,分为三个主要分组:低流量、低流量带突发容量和高流量。每个都需要不同的基础设施设计和工具实现,以实现高可用性和最小成本解决方案。

低流量

低流量(低速率请求)的一般架构与 REST 微服务容器架构没有不同。无论使用什么 REST 服务器,使用什么容器服务运行应用程序,还是使用什么虚拟机管理套件,对外部端点的主要补充只是确保 REST 服务运行在管理硬件上。这并不一定意味着需要使用完全管理的云服务,但对于即使是低流量的生产服务,系统也需要保持运行。

运行你构建的容器的这个基础设施不仅应该从机器学习的角度进行监控,还应该从性能考虑进行监控。托管虚拟机上的容器内存利用率、CPU 利用率、网络延迟以及请求失败和重试都应该实时监控,并且要有冗余备份,以便在满足服务请求时出现问题时进行故障转移。

在低流量解决方案(每分钟数到数千个请求)中,流量路由的可扩展性和复杂性不会成为问题,只要项目的 SLA 要求得到满足,因此对于低流量用例,需要更简单的部署和监控架构。

突发流量和高流量

当迁移到支持突发流量的规模时,将弹性集成到服务层是架构中的一个关键补充。由于单个虚拟机只有有限的线程来处理预测,因此对于预测的请求洪水,如果超过了单个虚拟机的执行能力,可能会使该虚拟机过载。无响应、REST 超时和虚拟机不稳定(可能崩溃)可能会使单个虚拟机模型部署不可用。处理突发流量和高容量服务的方法是在弹性负载均衡中结合进程隔离和路由。

负载均衡,正如其名所示,是一种在分片虚拟机群(模型服务应用的重复容器)中路由请求的方法。由于许多容器并行运行,请求负载可以水平扩展以支持真正惊人的请求量。这些服务(每个云都有自己的风味,本质上做的是同样的事情)对部署容器的机器学习团队和最终用户都是透明的。由于有一个请求进入的单一点端和一个构建和部署的单个容器镜像,负载均衡系统将确保负载分布是自动发生的,以防止服务中断和不稳定。

图 16.16 展示了利用云无关服务的一个常见设计模式。通过使用一个简单的 Python REST 框架(Flask),该框架与容器内的模型工件接口,可以实现可扩展的预测,从而支持高流量和突发流量需求。

这个相对简单的架构是一个弹性扩展的基于 REST 的实时服务的基本模板,用于提供预测。图中缺少的是我们在前几章中讨论的其他关键组件(特征监控、重新训练触发器、A/B 测试和模型版本控制),但它具有区分较小规模的实时系统与能够处理大量流量的服务的基本组件。

在核心上,图 16.16 中所示的负载均衡器使得系统可以从单个虚拟机的可用核心限制(在 Flask 前面放置 Gunicorn 将允许虚拟机的所有核心同时处理请求)扩展到水平扩展以处理数百个并发预测(或更多)。然而,这种可扩展性也伴随着一个警告。添加此功能意味着服务解决方案的复杂性和成本都会增加。

16-16

图 16.16 云原生 REST API 模型服务架构

图 16.17 展示了大规模 REST API 解决方案的更详细设计。这种架构可以支持极高的预测流量速率,以及所有需要编排以实现生产部署的流量、服务级别协议(SLA)和数据分析用例。

16-17

图 16.17 大规模 REST API 模型服务自动化的基础设施和服务

这些系统有很多组件。复杂性很容易增长到几十个不同的系统被粘合在一起形成一个应用堆栈,以满足项目用例的需求。因此,向对构建需要这种架构的解决方案感兴趣的业务部门解释支持这些系统的复杂性以及成本至关重要。

通常,由于这种复杂性的程度,这不是一个数据科学团队可以独立维护的设置。DevOps、核心工程、后端开发人员和软件架构师都参与此类服务的架构、部署和维护。云服务账单是考虑总拥有成本的一个因素,但另一个突出的因素是保持此类服务持续运行所需的人力资本投资。

如果您的 SLA 要求和规模如此复杂,那么在项目早期就识别这些需求是明智的,诚实地评估投资,并确保业务理解这项工作的规模。如果他们认为投资是值得的,那么就继续构建。然而,如果设计并构建这些庞然大物对业务领导来说是令人畏惧的,那么最好不要在项目开发后期强迫他们允许这样做,那时已经投入了大量的时间和精力。

16.3.5 集成模型(边缘部署)

边缘 部署 是某些用例中低延迟服务的高级阶段。因为它将模型工件和所有依赖库作为容器镜像的一部分进行部署,它具有超过任何其他方法的可扩展性级别。然而,这种部署范式给应用程序开发者带来了很大的负担:

  • 新模型或重新训练的模型的部署需要与应用程序部署和升级一起安排。

  • 监控预测和生成的特征依赖于互联网连接。

  • 预测的启发式或最后一英里校正不能在服务器端完成。

  • 在服务容器内的模型和基础设施需要更深入和更复杂的集成测试,以确保其正常功能。

  • 设备能力可以限制模型复杂性,迫使采用更简单、更轻量级的建模解决方案。

由于这些原因,边缘部署对于许多用例可能并不很有吸引力。模型变化的速率非常低,模型漂移的影响可以使边缘部署的模型比新构建的模型更快地变得不相关,而且某些最终用户缺乏监控可能导致这种范式在大多数项目中无法应用。对于那些不受边缘部署的负面影响的人,这种服务风格的典型架构如图 16.18 所示。

16-18

图 16.18 边缘部署模型工件容器的简化架构

如您所见,边缘部署与应用程序代码库紧密耦合。由于运行时需要大量打包的库来支持包含模型所做的预测,容器化工件阻止了应用程序开发团队维护与 DS 团队环境相匹配的环境。这可以减轻许多可能困扰基于容器模型边缘部署的问题(即环境依赖管理、语言选择标准化和共享代码库中功能库的同步)。

可以利用边缘部署的项目,尤其是那些专注于图像分类等任务的,可以显著降低基础设施成本。能够符合边缘部署的条件是模型所利用的特征的稳定性状态。如果模型输入数据的函数性质不会特别频繁地变化(例如,在成像用例中),边缘部署可以极大地简化基础设施,并将机器学习解决方案的总拥有成本保持在极低水平。

摘要

  • 模型注册服务将有助于确保已部署和存档模型的有效状态管理,从而实现无需人工干预的有效被动重新训练和主动重新训练解决方案。

  • 特征存储将特征生成逻辑与建模代码分离,从而允许更快地重新训练过程,跨项目复用特征,以及一种远更简单的监控特征漂移的方法。

  • 为了选择一个合适的架构用于服务,我们必须权衡项目的许多特性:采用适当的服务和基础设施水平以支持所需的 SLA、预测量和数据的时效性,以确保预测服务具有成本效益且稳定。

附录 A. 大 O(no)以及如何考虑运行性能

对于机器学习用例,运行时间复杂度与其他任何软件没有区别。不高效和优化不良的代码对机器学习任务中的处理任务的影响与对任何其他工程项目的影响相同。唯一将机器学习任务与传统软件区分开来的实质性差异在于解决问题的算法。这些算法的计算和空间复杂度通常被封装递归迭代的通用 API 所掩盖,这可能会显著增加运行时间。

本附录的目的是专注于理解控制代码(项目中所有不涉及训练模型的代码)的运行特性以及正在训练的机器学习算法本身。

A.1 什么是大 O?

假设我们正在开发一个即将投入生产的项目的项目。结果是令人瞩目的,为该项目构建的业务单元对归因结果感到满意。然而,并非所有人都满意。运行解决方案的成本非常高。

在我们逐步通过代码的过程中,我们发现大部分执行时间都集中在我们的特征工程预处理阶段。代码的某个特定部分似乎比我们最初预期的要花费更长的时间。根据以下列表中的初始测试,我们原本认为这个函数不会造成太大问题。

列表 A.1 嵌套循环名称协调示例

import nltk
import pandas as pd
import numpy as np
client_names = ['Rover', 'ArtooDogTwo', 'Willy', 'Hodor', 
  'MrWiggleBottoms', ‘SallyMcBarksALot', 'HungryGames', 
  'MegaBite', 'HamletAndCheese', 'HoundJamesHound', 
  'Treatzilla', 'SlipperAssassin', 'Chewbarka', 
  'SirShedsALot', 'Spot', 'BillyGoat', 'Thunder', 
  'Doggo', 'TreatHunter']                                      ❶
extracted_names = ['Slipr Assassin', 'Are two dog two', 
  'willy', 'willie', 'hodr', 'hodor', 'treat zilla', 
  'roover', 'megbyte', 'sport', 'spotty', 'billygaot', 
  'billy goat', 'thunder', 'thunda', 'sirshedlot', 
  'chew bark', 'hungry games', 'ham and cheese', 
  'mr wiggle bottom', 'sally barks a lot']                     ❷
def lower_strip(string): return string.lower().replace(" ", "")
def get_closest_match(registered_names, extracted_names):
    scores = {}
    for i in registered_names:                                 ❸
        for j in extracted_names:                              ❹
            scores['{}_{}'.format(i, j)] = nltk.edit_distance(lower_strip(i), 
       lower_strip(j))                                         ❺
    parsed = {}
    for k, v in scores.items():                                ❻
        k1, k2 = k.split('_')
        low_value = parsed.get(k2)
        if low_value is not None and (v < low_value[1]):
            parsed[k2] = (k1, v)
        elif low_value is None:
            parsed[k2] = (k1, v)
    return parsed
get_closest_match(client_names, extracted_names)               ❼
>> {'Slipr Assassin': ('SlipperAssassin', 2), 
    'Are two dog two': ('ArtooDogTwo', 2),
    'willy': ('Willy', 0), 
    'willie': ('Willy', 2), 
    'hodr': ('Hodor', 1),
    'hodor': ('Hodor', 0), 
    'treat zilla': ('Treatzilla', 0), 
    'roover': ('Rover', 1),
    'megbyte': ('MegaBite', 2), 
    'sport': ('Spot', 1), 
    'spotty': ('Spot', 2),
    'billygaot': ('BillyGoat', 2), 
    'billy goat': ('BillyGoat', 0),
    'thunder': ('Thunder', 0), 
    'thunda': ('Thunder', 2), 
    'sirshedlot': ('SirShedsALot', 2),
    'chew bark': ('Chewbarka', 1), 
    'hungry games': ('HungryGames', 0),
    'ham and cheese': ('HamletAndCheese', 3), 
    'mr wiggle bottom': ('MrWiggleBottoms', 1),
    'sally barks a lot': ('SallyMcBarksALot', 2)}              ❽

❶ 我们数据库中狗的注册名称列表(小样本)

❷ 来自我们客户的人类从自由文本字段评分中解析的名称

❸ 遍历我们所有的注册名称

❹ O(n²)嵌套循环,遍历每个解析名称

❺ 计算去除空格并在两个字符串上强制转换为小写后的名称之间的 Levenshtein 距离

❻ 遍历成对距离测量以返回每个解析名称的最可能匹配项。这是 O(n)。

❼ 对注册名称列表和解析名称列表运行算法

❽ 通过 Levenshtein 距离找到的最近匹配结果

在用于验证和开发的较小数据集上,执行时间以毫秒计。然而,当针对我们包含 500 万注册狗和 1000 亿名称参考提取的完整数据集运行时,我们数据中的狗太多,无法运行此算法。(是的,可能会有太多狗的情况,信不信由你。)

原因是此算法的计算复杂度为 O(n²)。对于每个注册名称,我们正在测试其与每个名称提取的距离,如图 A.1 所示。

A-01

图 A.1 我们特征工程的计算复杂度

以下列表展示了减少循环搜索的另一种方法。

列表 A.2 一种稍微好一点的方法(但仍不完美)

JOIN_KEY = 'joinkey'
CLIENT_NM = 'client_names'
EXTRACT_NM = 'extracted_names'
DISTANCE_NM = 'levenshtein'

def dataframe_reconciliation(registered_names, extracted_names, threshold=10):
    C_NAME_RAW = CLIENT_NM + '_raw'
    E_NAME_RAW = EXTRACT_NM + '_raw'
    registered_df = pd.DataFrame(registered_names, columns=[CLIENT_NM])     ❶
    registered_df[JOIN_KEY] = 0                                             ❷
    registered_df[C_NAME_RAW] = registered_df[CLIENT_NM].map(lambda x: lower_strip(x))                                                        ❸
    extracted_df = pd.DataFrame(extracted_names, columns=[EXTRACT_NM])
    extracted_df[JOIN_KEY] = 0                                              ❹
    extracted_df[E_NAME_RAW] = extracted_df[EXTRACT_NM].map(lambda x: lower_strip(x))
    joined_df = registered_df.merge(extracted_df, on=JOIN_KEY, how='outer') ❺
    joined_df[DISTANCE_NM] = joined_df.loc[:, [C_NAME_RAW, E_NAME_RAW]].apply(
        lambda x: nltk.edit_distance(*x), axis=1)                           ❻
    joined_df = joined_df.drop(JOIN_KEY, axis=1)
    filtered = joined_df[joined_df[DISTANCE_NM] < threshold]                ❼
    filtered = filtered.sort_values(DISTANCE_NM).groupby(EXTRACT_NM, as_index=False).first()                                                ❽
    return filtered.drop([C_NAME_RAW, E_NAME_RAW], axis=1)

❶ 从客户端名称列表创建 pandas DataFrame

❷ 生成静态连接键以支持我们将要执行的笛卡尔连接

❸ 清理名称,以便我们的 Levenshtein 计算尽可能准确(在列表 A.1 中定义的函数)

❹ 在右侧表中生成相同的静态连接键以实现笛卡尔连接

❺ 执行笛卡尔连接(空间复杂度为 O(n²))

❻ 通过使用非常有用的 NLTK 包计算 Levenshtein 距离

❽ 从 DataFrame 中移除任何潜在的非匹配项

❽ 返回具有最低 Levenshtein 距离得分的每个潜在匹配键的行

注意:如果您对 NLTK 包及其在 Python 中为自然语言处理所能做的所有奇妙事情感兴趣,我强烈建议您阅读 Steven Bird、Ewan Klein 和 Edward Loper 所著的《Python 自然语言处理》(O’Reilly,2009 年),他们是开源项目的原始作者。

利用这种 DataFrame 方法可以显著加快运行时间。列表 A.2 不是一个完美的解决方案,因为空间复杂度会增加,但以这种方式重构可以显著减少项目的运行时间并降低成本。图 A.2 显示了调用列表 A.2 中定义的函数的结果。

A-02

图 A.2 以空间复杂性的代价降低计算复杂度

关于这个示例的重要事情是要记住可扩展性是相对的。在这里,我们是在计算复杂性和空间复杂性之间进行权衡:我们最初是顺序遍历两个数组,这需要很长时间,但内存占用非常低;然后,使用 pandas 的矩阵结构要快得多,但需要大量的 RAM。在实际操作中,考虑到这里涉及的数据量,最佳解决方案是在循环处理(最好在 Spark DataFrame中)的同时,分块利用笛卡尔连接来找到计算和空间压力之间的良好平衡。

优化性能和成本

大多数代码库重构是为了提高其可测试性和可扩展性。但在机器学习代码库中,一个常见的活动是提高运行时效率。这通常更多地关注模型的训练和再训练,而不是机器学习的预测方面,但涉及的工作中包含着极其复杂的特征工程。很多时候,机器学习项目中代码性能不佳的根本原因在于特征处理和控制逻辑,而不是模型(s)的训练(除非是广泛的超参数调整,这可能会主导总运行时间)。

主要由于这些工作的长时间运行特性,识别和优化运行时性能可以对机器学习解决方案的总拥有成本产生重大影响。然而,为了有效地优化,分析计算复杂度(影响总运行时间)和空间复杂度(影响运行代码所需的机器大小或数量)是至关重要的。

从实用和理论的角度来看,运行时问题的分析是通过评估计算复杂度和空间复杂度来处理的,简称为大 O

A.1.1 复杂度的温和介绍

计算复杂度本质上是对计算机执行算法所需时间的最坏情况估计。另一方面,空间复杂度是指算法可能对系统内存造成的最坏情况负担。

虽然计算复杂度通常影响 CPU,但空间复杂度涉及系统(RAM)中您需要处理的内存,以避免磁盘溢出(将分页到硬盘或固态硬盘)。图 A.3 显示了操作数据点集合时,根据所使用的算法,可以具有不同的空间和计算复杂度。

对数据集合执行的不同操作会影响所需的时间和空间复杂度。如图 A.3 从上到下移动时,不同操作的空间和计算复杂度都会增加。

A-03

图 A.3 对操作数据集合的计算复杂性和空间复杂性的比较

在评估算法的复杂度时,考虑了许多其他复杂度作为标准。图 A.4 显示了这些标准评估在线性尺度上的情况,而图 A.5 则显示它们在对数 y 尺度上的情况,以说明其中一些应该避免到何种程度。

A-04

图 A.4 不同计算复杂度的线性 y 轴尺度,过滤到 150 次迭代

如图 A.4 和 A.5 所示,集合大小与算法类型之间的关系可以显著影响代码的运行时间。理解这些关系(包括空间和计算复杂度)在代码的非机器学习方面,即在模型训练和推理之外,是绝对必要的。

A-05

图 A.5 计算复杂度的对数 y 轴尺度。请注意图表顶部 y 轴的大小。指数和阶乘复杂度确实会带来痛苦。

让我们想象一下,在项目编排代码中实现如此简单的东西,如集合遍历的成本。如果我们试图以暴力方式评估两个数字数组之间的关系(以嵌套方式遍历每个数组),我们将面临 O(n²)的复杂度。如果我们通过优化的连接合并列表,我们可以显著降低复杂度。从 O(n²)这样的复杂度转移到接近 O(n),如图 A.4 和 A.5 所示,在处理大型集合时,可以转化为显著的成本和时间节省。

A.2 通过示例分析复杂度

分析代码以查找性能问题可能会令人畏惧。很多时候,我们如此专注于解决围绕特征工程、模型调整、指标评估和统计评估的所有细节,以至于评估我们如何迭代集合的概念并没有进入我们的脑海。

如果我们审视控制代码,这些代码指导着项目元素的执行,将它们的执行视为复杂性的一个因素,我们就能估计出将发生的相对运行时间效应。有了这些知识,我们可以解耦低效的操作(例如,可以折叠成单个索引遍历的过度嵌套循环语句),并帮助减轻运行我们代码的系统的 CPU 和内存负担。

现在你已经看到了大 O 理论,让我们看看一些使用这些算法的代码示例。能够看到集合中元素数量的差异如何影响操作的时间,对于完全理解这些概念非常重要。

我将以一种不太传统的方式介绍这些主题,以狗为例,然后展示代码示例来展示这些关系。为什么?因为狗很有趣。

A.2.1 O(1):不关心数据大小的“它的大小无关紧要”算法

让我们想象一下,我们身处一个房间。一个非常非常大的房间。房间的中心是一个食物碗的环形。为狗准备的。我们已经在这些碗里装了一些博洛尼亚面食。对于狗来说,这是一天痛苦的等待(整个过程中都在闻味),但我们已经把食物分装进了五个单独的碗里,并准备好了我们的笔记本来记录这个事件的数据。一切结束后(碗比倒面食之前干净),我们有了代表我们的狗小组采取的不同行动的有序列表集合。

当我们希望回答关于我们观察到的事实的问题时,我们正在操作这些列表,但检索与这些事件发生的顺序相关的单个索引值。无论这些列表的大小如何,O(1)类型的问题只是基于位置参考获取数据,因此,所有操作所需的时间相同。让我们看看图 A.6 中的这个场景。

A-06

图 A.6 通过饥饿的狗实现 O(1)搜索

O(1)不关心数据有多大,如图 A.6 所示。这些算法以不遍历集合,而是访问集合中数据的位置的方式进行操作。

为了在计算意义上展示这种关系,列表 A.3 展示了在两个不同大小的数据集上执行 O(1)任务的比较——具有相似的运行时性能。

列表 A.3 O(1)复杂性的演示

import numpy as np
sequential_array = np.arange(-100, 100, 1)                     ❶
%timeit -n 1000 -r 100 sequential_array[-1]                    ❷
>> 269 ns ± 52.1 ns per loop (mean ± std. dev. of 
100 runs, 10000 loops each)                                    ❸
massive_array = np.arange(-1e7, 1e7, 1)                        ❹
%timeit -n 10000 -r 100 massive_array[-1] 
>> 261 ns ± 49.7 ns per loop (mean ± std. dev. of 
100 runs, 10000 loops each)                                    ❺
def quadratic(x):                                              ❻
    return (0.00733 * math.pow(x, 3) -0.001166 * 
math.pow(x, 2) + 0.32 * x - 1.7334)
%timeit -n 10000 -r 100 quadratic(sequential_array[-1])        ❼
>> 5.31 µs ± 259 ns per loop (mean ± std. dev. of 100 runs, 10000 loops each)
%timeit -n 10000 -r 100 quadratic(massive_array[-1])           ❽
>> 1.55 µs ± 63.3 ns per loop (mean ± std. dev. of 100 runs, 10000 loops each)

❶ 生成介于-100 和 100 之间的整数数组

❷ 运行 100,000 次操作的迭代,以最小化每次运行的方差,从而查看访问速度

❸ 每次迭代的平均速度的绝对值高度依赖于代码运行的硬件。尽管如此,对于使用 8 核心笔记本电脑 CPU 的单个核心来说,269 纳秒已经非常快了。

❹ 生成一个比第一个稍大的数组

❺ 261 纳秒。即使有 100,000 倍的数据,执行时间也是相同的。

❻ 二次方程用于说明对单个值进行的数学运算

❼ 在数组中的单个值上执行 5.31 微秒

❽ 在数组中的单个值上执行 1.55 微秒(由于 NumPy 访问较大数组中的索引操作,比之前少)

第一个数组(sequential_array)长度仅为 200 个元素,从其索引的 c-based-struct 类型中检索元素的访问时间非常快。随着我们增加数组的大小(包含 200 万个元素的massive_array),位置检索的运行时间不会改变。这是由于数组的优化存储模式;我们可以通过索引注册表直接在常数 O(1)时间内查找元素的内存地址位置。

机器学习项目的控制代码有许多 O(1)复杂性的例子:

  • 获取排序、排名的聚合数据点的最后一个条目——例如,从按发生时间排序的事件窗口函数中。然而,由于涉及排序,构建窗口聚合的过程通常是 O(n log n)。

  • 取模函数——这表示除以另一个数后的余数,在收集遍历中的模式生成中很有用。(遍历将是 O(n)。)

  • 等价测试——等于、大于、小于等等。

A.2.2 O(n):线性关系算法

如果我们想在特定时间点了解我们的犬类测试对象的状态怎么办?比如说,我们真的想找出他们狼吞虎咽食物的速度。假设我们决定在盛宴开始后的 30 秒收集数据,以查看每只狗的食物碗状态。

我们为每只狗收集的数据将涉及键值对。在 Python 中,我们将收集一个包含狗的名字和它们碗中剩余食物量的字典:

thirty_second_check = {'champ': 0.35, 'colossus': 0.65, 
    'willy': 0.0, 'bowser': 0.75, 'chuckles': 0.9}

这个操作,绕着走并估计碗中的剩余食物量,将其记录在这个(键,值)对中,将是 O(n),如图 A.7 所示。

A-07

图 A.7 O(n)搜索所有狗的消耗率

如您所见,为了测量剩余量,我们需要绕到每只狗那里检查它们的碗的状态。对于我们展示的五只狗,这可能需要几秒钟。但如果我们有 500 只狗呢?那可能需要几分钟的时间来测量。O(n)表示算法(检查吃掉的博洛尼亚肉的数量)与数据大小(狗的数量)之间的线性关系,这是计算复杂度的反映。

从软件的角度来看,同样的关系也成立。列表 A.4 显示了列表 A.3 中定义的quadratic()方法的迭代使用,在该列表中操作每个元素。随着数组大小的增加,运行时间以线性方式增加。

列表 A.4 O(n)复杂度演示

%timeit -n 10 -r 10 [quadratic(x) for x in sequential_array]
>> 1.37 ms ± 508 µs per loop (mean ± std. dev. of 10 runs, 10 loops each)  ❶
%timeit -n 10 -r 10 [quadratic(x) for x in np.arange(-1000, 1000, 1)]
>> 10.3 ms ± 426 µs per loop (mean ± std. dev. of 10 runs, 10 loops each)  ❷
%timeit -n 10 -r 10 [quadratic(x) for x in np.arange(-10000, 10000, 1)]
>> 104 ms ± 1.87 ms per loop (mean ± std. dev. of 10 runs, 10 loops each)  ❸
%timeit -n 10 -r 10 [quadratic(x) for x in np.arange(-100000, 100000, 1)]
>> 1.04 s ± 3.77 ms per loop (mean ± std. dev. of 10 runs, 10 loops each)
%timeit -n 2 -r 3 [quadratic(x) for x in massive_array]
>> 30 s ± 168 ms per loop (mean ± std. dev. of 3 runs, 2 loops each)       ❹

❶ 在小数组(-100,100)上映射并应用每个值比检索单个值花费的时间要长。

❷ 将数组的大小增加一个 10 倍,运行时间也增加一个 10 倍

❸ 再次增加一个 10 倍的因素,运行时间也相应增加。这是 O(n)。

❹ 增加一个 10 倍的因素,运行时间增加一个 30 倍的因素?!这归因于被计算值的尺寸以及 Cython(优化计算使用的底层编译 C*代码)中乘法替代形式的转变。

如结果所示,集合大小与计算复杂度之间的关系在大多数情况下是相对均匀的(见以下说明,了解为什么这里不是完全均匀的)。

当计算复杂度在巨大规模上打破模式时

在列表 A.4 中,最终的集合不遵循前面集合的相同模式。这种行为(在处理大量数据时,假设的预期性能崩溃)在任何系统中都存在,尤其是在分布式系统中。

当一些算法开始处理足够大的数据时,内存重新分配可能成为这些算法性能的限制因素。同样,在以机器学习为重点的语言(Python 或任何在 JVM 上运行的东西)中的垃圾回收操作可能会对运行时性能造成重大干扰,因为系统必须释放内存空间以继续执行您指示它执行的操作。

O(n)是我们 DS 工作世界中的一种生活事实。然而,如果我们正在构建使用我们列表中下一个关系的软件:O(n²),我们应该都停下来重新考虑我们的实现。这是事情可能变得有点疯狂的地方。

A.2.3 O(n²):与集合大小的多项式关系

现在我们喂饱了狗,它们对食物感到满意,它们需要一点锻炼。我们带它们去狗公园,让它们同时进入。

就像任何涉及狗的社会时刻一样,首要任务是正式介绍,通过背后嗅探。图 A.8 展示了我们五只狗之间的招呼组合。

A-08

图 A.8 狗公园的相遇和问候。虽然不是精确的 O(n²),但它有相似的关系。

注意:组合计算在严格意义上是 O(n choose k)的复杂度。为了简单起见,让我们想象通过交互所有可能的排列并过滤来暴力破解解决方案,这将具有 O(n²)的复杂度。

这种基于组合的配对关系遍历不是严格的 O(n²);实际上它是 O(n choose k)。但我们可以应用这个概念,并通过组合运算来展示操作的数量。同样,我们可以通过操作排列来展示运行时间与集合大小之间的关系

表 A.1 显示了在这个狗公园中,根据通过大门进入的狗的数量(组合)以及可能的招呼,将发生的总屁股嗅探交互次数。我们假设狗感到需要正式介绍,其中每只狗都作为发起者(这是我多次目睹我的狗的行为)。

表 A.1 狗的问候次数

狗的数量 招呼的数量(组合) 可能的招呼(排列)
2 1 2
5 10 20
10 90 45
100 4,950 9,900
500 124,750 249,500
1,000 499,500 999,000
2,000 1,999,000 3,998,000

为了说明友好狗的招呼关系在组合和排列中的样子,图 A.9 展示了随着狗数量的增加,复杂性的惊人增长。

A-09

图 A.9 随着我们狗公园中狗数量的增加,嗅探的爆炸性增长。在代码中,复杂度的指数关系和狗在谈论效率时一样糟糕。

对于大多数机器学习算法(通过训练过程构建的模型),这种计算复杂度只是开始。大多数比 O(n²)复杂得多。

列表 A.5 展示了n²复杂度的实现。对于源数组的每个元素,我们将生成一个偏移曲线,该曲线将元素旋转到迭代索引值。接下来的每个部分的可视化将展示代码中的操作,以便更清晰。

列表 A.5 O(n²)复杂度的示例

import seaborn as sns
def quadratic_div(x, y):                                                   ❶
    return quadratic(x) / y
def n_squared_sim(size):                                                   ❷
    max_value = np.ceil(size / 2)
    min_value = max_value * -1
    x_values = np.arange(min_value, max_value + 1, 1)                      ❸
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")                                    ❹
        curve_matrix = [[quadratic_div(x, y) for x in x_values] for 
                         y in x_values]                                    ❺
    curve_df = pd.DataFrame(curve_matrix).T
    curve_df.insert(loc=0, column='X', value=x_values)
    curve_melt = curve_df.melt('X', var_name='iteration', value_name='Y')  ❻
    fig = plt.figure(figsize=(10,10))
    ax = fig.add_subplot(111)
    sns.lineplot(x='X', y='Y', hue='iteration', data=curve_melt, ax=ax)    ❼
    plt.ylim(-100,100)
    for i in [ax.title, ax.xaxis.label, ax.yaxis.label] + ax.get_xticklabels() + ax.get_yticklabels():
        i.set_fontsize(14)
    plt.tight_layout
    plt.savefig('n_squared_{}.svg'.format(size), format='svg')
    plt.close()
    return curve_melt

❶ 通过数组中的一个值修改数组的二次解函数

❷ 生成二次评估系列值的集合函数

❸ 获取数组生成周围的 0 范围(为了对称性,大小加 1)

❹ 捕获与除以零相关的警告(因为我们正在跨越数组中整数的边界)

❺ 通过映射集合两次生成数组数组的 n² 遍历

❻ 将生成的数据矩阵转换为归一化形式,以便进行绘图

❼ 用不同的颜色绘制每条曲线,以说明算法中的复杂度差异

对于列表 A.5 中定义的算法,如果我们用不同的有效集合大小值调用它,我们将在下一个列表中看到计时结果。

列表 A.6 评估 O(n²) 复杂度算法的结果

%timeit -n 2 -r 2 ten_iterations = n_squared_sim(10)
>> 433 ms ± 50.5 ms per loop (mean ± std. dev. of 2 runs, 2 loops each)   ❶
%timeit -n 2 -r 2 one_hundred_iterations = n_squared_sim(100)
>> 3.08 s ± 114 ms per loop (mean ± std. dev. of 2 runs, 2 loops each)    ❷
%timeit -n 2 -r 2 one_thousand_iterations = n_squared_sim(1000)
>> 3min 56s ± 3.11 s per loop (mean ± std. dev. of 2 runs, 2 loops each)  ❸

❶ 仅需 121 次操作,这个执行速度相当快。

❷ 在数组大小增加 10 倍时,10,201 次操作需要显著更长的时间。

❸ 在 1,002,001 次操作时,指数关系变得明显。

从列表 A.5 中的输入数组大小和列表 A.6 中显示的结果可以看出,图 A.10 中可以更清楚地看到它们之间的关系。如果我们继续增加数组生成参数值的规模到 100,000,我们将看到 10,000,200,001 次操作(而我们的第一次迭代大小为 10 生成 121 次操作)。然而,更重要的是,生成如此多的数据数组所带来的内存压力。大小复杂度将迅速成为这里的限制因素,导致在我们对计算所需时间感到厌烦之前,就可能出现内存不足(OOM)异常。

A-10

图 A.10 列表 A.5 中算法对不同集合大小的计算复杂度

为了说明此代码正在做什么,我们可以查看第一次迭代的结果(使用 10 作为函数参数),如图 A.11 所示。

A-11

图 A.11 在大小为 11 的数组上运行的 O(n²) 算法生成数据(执行时间:433 毫秒,约需 26 KB 空间)

图 A.12 显示了通过此算法运行时,从数组大小 201(顶部)到更极端的大小(底部 2,001)的复杂度进展。

A-12

图 A.12 比较数组大小 201(时间:8.58 秒,空间:约 5.82 MB)和 2,001(时间:1,367 秒,空间:约 576.57 MB)。

如您所见(请记住,这些图表是为输入数组的每个索引位置生成的一系列),当通过这样的算法运行时,看似无害的集合大小可以迅速变得非常大。如果代码以这种复杂度级别编写,想象这将对项目的运行时性能产生多大的影响并不困难。

代码中的复杂度问题

在代码恶臭的大背景下,计算复杂性通常是更容易识别的一个。这种复杂性通常表现为嵌套循环。无论是具有内部额外for循环的声明式for循环,还是包含嵌套迭代或映射的while循环,或者是嵌套列表推导式,这些代码结构都显得可能存在危险。

这并不是说嵌套循环和复杂的while语句中的逻辑一定是 O(n²)、O(2n)或 O(n!)的最坏情况,但在评估代码时,这些地方需要花费更多的时间。它们就像是烟雾,需要调查以确保在运行代码时不会突然爆发潜在的火灾。

在代码库中看到这些意味着你应该花额外的时间检查逻辑并运行各种场景。最好的方法是想象如果正在迭代的集合大小加倍会发生什么。如果它增加一个数量级会怎样?

代码是否会扩展?运行时间是否会过长以至于错过 SLA?运行的系统是否会 OOM?思考如何识别、重构和更改代码的逻辑可以帮助预防后续的稳定性问题和成本考虑。

A.3 分析决策树复杂性

让我们假设我们正在构建一个解决方案来解决问题,其核心需求需要一个高度可解释的模型结构作为输出。由于这个要求,我们选择使用决策树回归器来构建预测解决方案。

由于我们是一家致力于客户(狗)及其宠物(人)的公司,我们需要一种方法将我们的模型结果转化为直接且可操作的结果,这些结果可以快速理解和应用。我们不是在寻找黑盒预测;相反,我们希望理解数据中相关性的本质,并了解预测如何受到特征复杂系统的影响。

在特征工程完成并构建了原型之后,我们正在探索超参数空间,以优化 MVP 的自动化调整空间。在启动运行(成千上万的调整实验)后,我们注意到不同超参数的训练结果导致不同的完成时间。事实上,根据测试的超参数,每个测试的运行时间可能比另一个高一个数量级。为什么?

为了探索这个概念,让我们逐步了解决策树回归器(在复杂性的意义上)是如何工作的,并评估更改一些超参数设置如何影响运行时间。图 A.13 展示了算法在训练数据上拟合时发生的情况的高级视图。

A-13

图 A.13 决策树算法的高级解释

这个图表可能对你来说很熟悉。算法的基本结构、功能和行为在博客文章、其他书籍中都有详细的介绍,并且是学习机器学习基础的一个基本概念。对我们讨论感兴趣的是,在训练模型时,哪些因素会影响计算和空间复杂度。

注意:图 A.13 仅作为示例。这个模型过度拟合,可能在对验证分割的测试中表现非常糟糕。在有更现实的数据分割体积和深度限制的情况下,预测将是分割分支成员的平均值。

首先,我们可以看到,在树的根节点上的初始分割需要确定首先选择哪个特征进行分割。这个算法一开始就存在一个可扩展性因素。为了确定分割的位置,我们需要测量每个特征,根据库的实现选择的准则进行分割,并计算这些分割之间的信息增益。

为了计算复杂度的目的,我们将特征的数量称为 k。信息增益的计算还包括基于训练数据集大小的熵的估计。这是传统非机器学习复杂度中的 n。为了增加这种复杂性,我们必须遍历树的每一层。一旦实现了最佳分割路径,我们必须继续在数据子集中的现有特征上迭代,反复进行,直到达到超参数中设定的标准,即填充叶(预测)节点所需的最小元素数量。

遍历这些节点代表了一个计算复杂度为 O(nlog(n)),因为当我们接近叶节点时,分割的大小受到限制。然而,由于我们必须在每个裁决节点上迭代所有特征,我们的最终计算复杂度更接近于 O(k × n× log(n))。

我们可以通过调整超参数直接影响这种最坏情况运行时性能的真实世界行为(记住,O()表示法是最坏情况)。特别值得注意的是,一些超参数可能对计算复杂度和模型有效性有益(例如创建叶节点的最小计数,树的深度限制),而其他则可能呈负相关(例如,在其他使用随机梯度下降(SGD)的算法中的学习率)。

为了说明超参数与模型运行时性能之间的关系,让我们看看修改列表 A.7 中树的最大深度。在这个例子中,我们将使用一个免费可用的开源数据集来展示直接影响模型计算和空间复杂性的超参数值的影响。(对于没有收集关于狗的特征和一般饥饿水平的数据集,我表示歉意。如果有人想创建这个数据集并公开发布,请告诉我。)

注意:在列表 A.7 中,为了展示过深的深度,我通过独热编码分类值来违反基于树的模型规则。以这种方式编码分类值存在高度风险,可能会优先在布尔字段上分割,如果深度不足以利用其他字段,将导致模型显著欠拟合。在编码值时,应始终彻底验证特征集,以确定它们是否会创建一个糟糕的模型(或难以解释的模型)。考虑使用分桶、k 级划分、二进制编码或强制顺序索引来解决你的有序或名义分类问题。

列表 A.7 展示树深度对运行时性能的影响

from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import mean_squared_error
import requests
URL = 'https://raw.githubusercontent.com/databrickslabs
/automl-toolkit/master/src/test/resources/fire_data.csv'
file_reader = pd.read_csv(URL)                              ❶
encoded = pd.get_dummies(file_reader, 
columns=['month', 'day'])                                   ❷
target_encoded = encoded['burnArea'] 
features_encoded = encoded.drop('burnArea', axis=1)
x_encoded, X_encoded, y_encoded, Y_encoded = train_test_split(features_encoded, 
target_encoded, test_size=0.25)                             ❸
shallow_encoded = DecisionTreeRegressor(max_depth=3, 
  min_samples_leaf=3, 
  max_features='auto', 
  random_state=42)
%timeit -n 500 -r 5 shallow_encoded.fit(x_encoded, y_encoded)
>> 3.22 ms ± 73.7 µs per loop (mean ± std. dev. of 5 
runs, 500 loops each)                                       ❹
mid_encoded = DecisionTreeRegressor(max_depth=5, 
  min_samples_leaf=3, max_features='auto', 
  random_state=42)
%timeit -n 500 -r 5 mid_encoded.fit(x_encoded, y_encoded)
>> 3.79 ms ± 72.8 µs per loop (mean ± std. dev. of 5 
runs, 500 loops each)                                       ❺
deep_encoded = DecisionTreeRegressor(max_depth=30, 
  min_samples_leaf=1, 
  max_features='auto', 
  random_state=42)
%timeit -n 500 -r 5 deep_encoded.fit(x_encoded, y_encoded)
>> 5.42 ms ± 143 µs per loop (mean ± std. dev. of 5 
runs, 500 loops each)                                       ❻

❶ 引入一个开源数据集进行测试

❷ 将月份和日期列进行独热编码,以确保我们有足够多的特征来实现本练习所需的深度。(参见本列表之前的说明。)

❸ 获取训练集和测试集数据

❹ 浅深度 3(可能欠拟合)将运行时间降低到最小基线。

❺ 从深度 3 增加到 5,运行时间增加了 17%(一些分支将已终止,限制了额外的时间)。

❻ 将深度移动到 30(根据此数据集实际实现的深度为 21)并将最小叶子大小减少到 1,可以捕捉到最糟糕的运行时复杂度。

正如你在调整超参数的计时结果中所看到的,树深度和运行时间之间存在一种看似微不足道的关系。然而,当我们将其视为百分比变化时,我们就可以开始理解这可能会造成的问题。

为了说明这种基于树的方法的复杂性,图 A.14 显示了在生成树的过程中,在每个候选分割步骤所采取的步骤。

A-14

图 A.14 决策树的计算复杂性

不仅需要完成多个任务来决定在哪里分割以及分割什么,而且还需要完成图 A.14 右侧显示的整个任务块,以针对每个在候选节点满足先前分割条件的子数据集的特征进行操作。当树深度为 30、40 或 50 时,我们可以想象这棵树会很快变得相当大。运行时间也会相应增加。

当数据集不是像这个玩具示例中的 517 行时会发生什么?当我们训练 5000 万行数据时会发生什么?抛开模型性能影响(泛化能力)的模型运行到过深树的含义,当我们考虑单个超参数导致的 68%的运行时间增加时,如果我们不仔细控制模型的超参数,训练时间的差异可能会非常显著(并且代价高昂)。

现在你已经看到了超参数调整的计算成本,在下一节中,我们将探讨不同模型家族的计算和空间复杂度。

A.4 机器学习的一般算法复杂度

尽管我们不会讨论任何其他机器学习算法的实现细节(正如我之前提到的,有专门的书籍介绍这个主题),但我们可以看看另一个更进一步的例子。假设我们正在处理一个非常大的数据集。它包含 1000 万行训练数据,100 万行测试数据,以及 15 个特征元素。

对于如此大的数据集,我们显然会使用分布式机器学习与 SparkML 包。在测试向量中的 15 个特征之后,我们决定开始提高性能,以尝试获得更好的错误度量性能。由于我们在这个项目中使用的是广义线性模型,我们对所有特征进行共线性检查,并适当地缩放特征。

对于这项工作,我们将团队分成两组。第 1 组一次添加一个经过验证的特征,并在每次迭代中检查预测性能的改进或下降。虽然这个过程很慢,但第 1 组能够一次淘汰或添加一个潜在候选者,并且从一次运行到下一次运行的运行时间相对可预测。

与此同时,第 2 组的成员添加了他们认为会使模型更好的 100 个潜在特征。他们执行训练运行并等待。他们去吃午饭,进行愉快的交谈,然后回到办公室。六小时后,Spark 集群仍在运行,所有执行器都达到了>90%的 CPU 使用率。它还继续运行了一整夜。

这里的主要问题是计算复杂度的增加。尽管模型的n值完全没有改变(训练数据的大小仍然是完全相同的),运行时间变长的原因仅仅是特征大小的增加。对于大数据集来说,这会由于优化器的工作方式而成为一个问题。

虽然传统的线性求解器(例如普通最小二乘法)可以依赖于通过涉及矩阵逆的闭式解来求解最佳拟合,但在需要分布的大型数据集中,这不是一个选项。其他求解器必须用于在分布式系统中进行优化。由于我们正在使用分布式系统,我们正在考虑 SGD。作为一个迭代过程,SGD 将通过沿着调整历史的局部梯度进行步骤来执行优化。

要获得 SGD 工作原理的简化理解,请参阅图 A.15。这个 3D 图表示求解器沿着一系列梯度进行搜索,以找到生成线性方程特定系数集的全局最小误差。

A-15

图 A.15 优化过程中搜索最小化的 SGD 过程的视觉表示(带有艺术自由度)

注意随机梯度下降将沿着固定距离的调整进行,试图达到测试数据(误差最小化)的最佳拟合。它将在下降变得平坦到斜率为 0,并且在阈值内后续迭代没有改进或达到最大迭代次数时停止。

注意正在发生的迭代搜索。这一系列尝试将方程的最佳拟合与目标变量相匹配,包括调整特征向量每个元素的所有系数。自然地,随着向量大小的增加,系数评估的数量也会增加。这个过程需要为每个正在进行的迭代遍历发生。

然而,这个情况中出现了一些问题。SGD 及其类似迭代方法(如遗传算法)没有简单的解决方案来确定计算复杂性。

这(对于其他类似的迭代求解器,如有限内存 Broyden-Fletcher-Goldfarb-Shanno,或 L-BFGS 也是正确的)的原因是,在局部和全局意义上,优化最小值的性质高度依赖于特征数据的组成(分布和推断的结构类型)、目标性质以及特征空间的复杂性。

这些算法都有最大迭代次数的设置,以实现最佳努力的全局最小化状态优化,但无法保证优化会在达到迭代器最大计数之前发生。相反,确定训练将花费多长时间时可能出现的挑战之一是优化的复杂性。如果 SGD(或其他迭代优化器)可以在相对较少的迭代次数内达到(希望是全局的)最小值,则训练将在达到最大迭代计数之前很久就终止。

由于这些考虑,表 A.2 是对常见传统机器学习算法理论最坏情况计算复杂度的一个粗略估计。

表 A.2 不同模型家族的计算复杂度估计

模型家族 训练复杂度 预测复杂度
决策树 O(knlog(n)) O(k)
随机森林 O(knlog(n)m) O(km)
梯度提升树 O(knm) O(km)
线性模型(OLS) O(k2n) O(k)
线性模型(非 OLS) O(k²n+ k³) O(k)
支持向量机 O(kn²+ n³) O(km)
K-最近邻算法 O(kmn)* O(kn)
K-均值算法 O(mni)** O(m)
交替最小二乘法 O(mni)** O(ni)
n = 训练集行数k = 向量中的特征数m = 集成成员数i = 收敛所需的迭代次数* m 在此情况下是指考虑边界定义时邻居数量的限制。** m 在此处指的是考虑的 k-质心数量。

所有这些复杂性的最常见方面都涉及单独的因素:用于训练的向量数量(DataFrame 中的行数)和向量中的特征数。这两种数量的增加都会直接影响到运行时的性能。许多机器学习算法在计算时间和输入特征向量大小之间有指数关系。在忽略不同优化方法复杂性的情况下,给定算法的求解器可能会因为特征集大小的增加而受到不利影响。虽然每个算法家族都与特征大小和训练样本大小有自己独特的细微关系,但在项目开发初期,了解特征计数的一般影响是一个重要的概念。

如前节所述,决策树的深度可以影响运行时的性能,因为它需要搜索更多的分割,因此需要更多的时间。几乎所有模型都有参数,这些参数可以给应用实践者提供灵活性,这将直接影响到模型的预测能力(通常是以运行时间和内存压力为代价)。

通常,熟悉机器学习模型的计算和空间复杂度是个好主意。了解选择一种模型而不是另一种模型对业务的影响(假设它们能够以类似的方式解决问题)可以在生产完成后在成本上产生数量级的差异。我个人已经多次决定使用预测能力略差的方法,因为它可以以比替代方案执行成本低得多的时间运行。

记住,最终,我们在这里是为了解决业务问题。以 50 倍的成本为代价,在预测精度上提高 1%,在解决你最初想要解决的问题的同时,为业务创造了一个新的问题。

附录 B. 设置开发环境

在处理新项目时,从一张白纸开始有许多原因。以下列表展示了与 ML 项目工作更相关的几个原因:

  • 在干净的环境中管理依赖关系更为容易。

  • 临时文件、日志和文件的隔离更为简单。

  • 脚本化环境创建使得迁移到生产环境更为简单。

  • 在依赖冲突较少的情况下,库的安装更为简单。

虽然存在许多创建可隔离环境以开发新项目的方法,但本附录提供了使用 Docker 以及 Conda 的包管理工具套件的指导,正如本书的配套仓库所做的那样。

B.1 清洁实验环境的案例

数据科学家在长时间在本地计算机上构建原型后面临的一个主要挑战是,旧项目无法在后续项目所需的更新环境中运行。随着库的发展,数据科学家升级库版本,并添加新的包,这些包依赖于其他包的新依赖项;在这个真正庞大的生态系统中,库的依赖关系发生了变化,这个生态系统由大量的相互连接的 API 网络组成。

维护库之间兼容性的这个极其复杂且令人沮丧的概念被称为依赖地狱,这是一个当之无愧的称号。图 B.1 展示了依赖冲突的典型场景。

B-01

图 B.1 常见 Python 开发环境中的依赖地狱。包管理冲突可能会浪费大量时间。

正如你所见,在单个本地环境中解决库冲突的选项相当严峻。一方面,你可能被迫重构代码库和训练运行时环境,随着公司项目数量的增长,这两项行动都是不可行的。另一方面,数据科学团队成员每次想要开始一个新项目时,都必须浪费无数小时修改他们安装的包(回滚或升级)。这显然不是一个可扩展的解决方案。

无论你运行的是哪种操作系统,如果你已经在你的机器上安装了 Python,你将会有一些深层次的依赖关系,这些依赖关系也存在于已安装的包中。一些实验和测试可能需要安装库,这可能会破坏之前开发的项目或命令行上可用的实用应用程序。更不用说,每个团队成员的电脑可能都有这些关键包的不同版本,如果这些团队成员运行彼此的代码,就会导致可重复性问题。

B.2 解决依赖地狱的容器

这种令人沮丧且浪费时间的工作,即始终确保所有东西都能协同工作,有多种解决方案。其中最受欢迎的是由 Anaconda 公司慷慨地作为开源发行版(在新的 BSD 许可证下)提供给机器学习社区的预包装构建。这些经过测试和验证的软件包集合保证能够很好地协同工作,让您免于自行确保这些软件包的行为。创建一个全新的、纯净的 Python 环境的 Anaconda 构建,主要有以下三种主要方式:

  • Conda 环境管理器——命令行工具,可以从不会干扰系统 Python 安装的镜像中在本地创建隔离的 Python 环境

  • Anaconda Navigator——图形用户界面,允许在本地机器上使用隔离的 Conda 环境一键设置许多流行的开发工具

  • Docker 容器部署 Conda 环境,用于在虚拟机(VM)中使用——可移植的容器定义,将创建一个隔离的 Python 环境,该环境包含 Conda 包构建,可以在本地虚拟机或基于云的虚拟机中运行

图 B.2 展示了前两种方法,这些方法适用于 Python 中的机器学习实验,并使用纯开源(免费)解决方案来隔离运行时环境。顶部部分可以通过命令行界面(CLI)或通过 Anaconda Navigator 图形用户界面(GUI)完成。

B-02

图 B.2 Conda 环境管理对比容器服务环境管理。两者都是简化实验、开发和生产的良好选择。

这些方法解决了不同项目需求中存在版本冲突的问题,大大节省了时间和精力,否则您将不得不进行令人沮丧的工作来管理所有机器学习软件包。有关 Docker 是什么以及为什么它很重要的进一步解释,请参阅以下侧边栏。

什么是 Docker?

Docker 是一种容器化服务。它是一个平台,允许操作系统级别的虚拟化(想想:计算机中的计算机),可以配置为使用运行在其上的机器的资源,并且可以从主机机器上的其他应用程序和操作系统实体中实现完全的过程级隔离。

这允许您打包您的软件、运行软件所需的库以及配置文件,以便在不同的环境中运行。您甚至可以打开端口,就像容器是其自己的计算机一样进行通信。

机器学习的容器化使你能够处理依赖地狱问题:每个项目都可以拥有自己的库集合,这些库保证与你的代码以可重复和一致的方式协同工作。容器化还使你能够在任何环境中运行容器——本地服务器、基于云的服务器或任何能够运行容器的虚拟机环境。这为机器学习项目工作引入了可移植性,这在实验阶段越来越普遍,对于生产规模的机器学习来说也绝对至关重要。

B.3 为实验创建基于容器的原始环境

在本节中,我们将通过使用 Docker 定义并初始化一个基本的隔离运行时环境。我相当偏爱 Anaconda,因为它不需要付费服务就可以进行实验,所以我们将使用其预配置的 Docker 容器之一,该容器已经为 Python 3 和大多数核心机器学习库搭建了启动环境(至少对于本书中我们需要的功能来说是这样)。

为了确保我们的系统上有这个镜像,我们将通过命令行运行docker pull continuumio/anaconda3。这个命令将从 Docker Hub 获取预构建的 Docker 容器,Docker Hub 是一个包含免费和受限镜像的 Docker 镜像仓库。该容器包括 Linux 操作系统、Anaconda Python 的最新版本,以及为大多数数据科学工作任务提供完全可操作的开发环境所需的所有配置,用户几乎不需要进行任何额外操作。

注意:尤其是在实验阶段,始终建议有一个隔离的环境,我们可以像孩子们说的那样,“尽情地疯狂”使用各种包、这些包的版本以及我们可能不想污染计算机一般环境的配置。没有比意识到你一直在工作的其他项目现在因为更新到 NumPy 的新版本而抛出成百上千个异常更痛苦的了。

为了获得一个基本的机器学习环境(可运行的虚拟机镜像),用于执行项目的测试和研究的初期阶段,我们可以在安装 Docker 之后运行以下命令。

列表 B.1 创建基本机器学习环境的 Docker run 命令

docker run -i --name=airlineForecastExperiments           ❶
-v Users/benwilson/Book/notebooks:/opt/notebooks          ❷
-t -p 8888:8888                                           ❸
continuumio/anaconda3                                     ❹
/bin/bash  -c "/opt/conda/bin/conda install jupyter 
-y --quiet && mkdir -p  /opt/notebooks && 
/opt/conda/bin/jupyter notebook --notebook-dir=/opt/notebooks 
--ip='*' --port=8888 --no-browser --allow-root" 

❶ 你可以随意命名容器。如果你省略这个配置,Docker 会为你选择一个有趣的名字,让你很难记住容器中有什么。

❷ 本地文件系统的绝对路径(你希望没有 benwilson 的 root 用户目录)——需要更改。

❸ 这是我们使用“docker pull continuumio/anaconda3”命令从 Docker Hub 拉取的镜像。

❹ Bash 命令,允许我们安装 Jupyter 并将其配置为使用端口转发,这样我们就可以打开本地浏览器窗口并与容器的环境交互

对这个脚本进行一些轻微的修改——特别是覆盖挂载位置(冒号之前带有-v选项的第一个部分)——然后将其粘贴到命令行中,容器就会启动并运行。在收集完软件包并构建了镜像之后,命令行会给你一个提示(一个本地主机引用http://127.0.0.7:8888/?token=...),你可以将其粘贴到你的网页浏览器中,以便打开 Jupyter,这样你就可以开始在笔记本中编写代码了。

注意:如果你在云上托管了一个开发环境,这使得其他人可以非常容易地为你创建这样一个原始环境,只需象征性的费用,那么你可以忽略这一点。这是给所有在笔记本电脑上挣扎学习机器学习的数学姐妹和兄弟们。

posted @ 2025-11-21 09:08  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报