大规模-MLOps-工程(全)

大规模 MLOps 工程(全)

原文:zh.annas-archive.org/md5/5ca914896ff49b8bc0c3f25ca845e22b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

前言

从这本书的一位审稿人那里得到的一条有用的反馈是,它成为了他们攀登陡峭的 MLOps 学习曲线的“秘籍”。我希望本书的内容能帮助您成为更加了解机器学习工程和数据科学的从业者,同时也是您项目、团队和组织更具生产力的贡献者。

2021 年,主要技术公司公开表示努力“民主化”人工智能(AI),通过使深度学习等技术更容易接触到更广泛的科学家和工程师。遗憾的是,企业采取的民主化方法过分关注核心技术,而不足够关注将 AI 系统交付给最终用户的实践。因此,机器学习(ML)工程师和数据科学家准备充足,能够创建实验性的 AI 原型,但在成功将这些原型交付到生产环境中则做得不够。这一点从各种各样的问题中显而易见:从 AI 项目的失败率不可接受到关于成功交付给最终用户的 AI 系统的道德争议。我相信,要取得成功,民主化 AI 的努力必须超越对核心、启用技术(如 Keras、PyTorch 和 TensorFlow)的狭隘关注。MLOps 成为了一个统一的术语,用于将实验性的 ML 代码有效地运行到生产环境中。无服务器 ML 是主导的云原生软件开发模型,用于 ML 和 MLOps,抽象出基础架构,提高了从业者的生产力。

我还鼓励您使用附带本书的 Jupyter 笔记本。笔记本代码中使用的 DC 出租车费项目旨在为您提供成长为从业者所需的练习。祝阅读愉快,编码愉快!

致谢

我永远感激我的女儿 Sophia。你是我永恒的幸福和灵感之源。我的妻子 Alla,在我写第一本书时对我有着无限的耐心。你总是在那里支持我,为我加油打气。对我的父亲 Mikhael,没有你,我不会成为现在的我。

我也要感谢 Manning 公司的工作人员,使这本书得以出版:我的开发编辑 Marina Michaels;我的技术开发编辑 Frances Buontempo;我的技术校对 Karsten Strøbaek;我的项目编辑 Deirdre Hiam;我的副本编辑 Michele Mitchell;以及我的校对员 Keri Hales。

特别感谢技术同行审阅者:Conor Redmond,Daniela Zapata,Dianshuang Wu,Dimitris Papadopoulos,Dinesh Ghanta,Dr. Irfan Ullah,Girish Ahankari,Jeff Hajewski,Jesús A. Juárez-Guerrero,Trichy Venkataraman Krishnamurthy,Lucian-Paul Torje,Manish Jain,Mario Solomou,Mathijs Affourtit,Michael Jensen,Michael Wright,Pethuru Raj Chelliah,Philip Kirkbride,Rahul Jain,Richard Vaughan,Sayak Paul,Sergio Govoni,Srinivas Aluvala,Tiklu Ganguly 和 Todd Cook。您的建议帮助我们制作出更好的书籍。

关于本书

感谢您购买《规模化 MLOps 工程》。

谁应该阅读本书

要从本书中获得最大的价值,你需要具备使用 Python 和 SQL 进行数据分析的现有技能,并且具有一些机器学习经验。我期望,如果你正在阅读本书,你对作为机器学习工程师的专业知识感兴趣,并且计划将基于机器学习的原型部署到生产环境中。

这本书适用于信息技术专业人士或学术界人士,他们在机器学习方面有一定的接触,并且正在开发或有兴趣在生产中推出机器学习系统。附录 A 中提供了本书的机器学习先决条件复习。请记住,如果您对机器学习全新,您可能会发现同时学习机器学习和基于云的机器学习基础设施可能会让人不知所措。

如果你是软件工程师或数据工程师,并计划开始一个机器学习项目,这本书可以帮助你更深入地了解机器学习项目的生命周期。你会发现,尽管机器学习的实践依赖于传统信息技术(即计算、存储和网络),但在实践中与传统信息技术有所不同。前者比你作为软件工程师或数据专业人员所经历的实验性更强,更加迭代,你应该为结果可能较少事先了解而做好准备。在处理数据时,机器学习实践更像是科学过程,包括对数据形成假设,测试替代模型以回答假设问题,并排名和选择表现最佳的模型来部署到你的机器学习平台上。

如果你是一名机器学习工程师或从业者,或者是一名数据科学家,请记住,本书不是要让你成为更好的研究者。本书不旨在教育你关于机器学习科学前沿的知识。本书也不会试图重新教授你机器学习的基础知识,尽管你可能会发现附录 A 中针对信息技术专业人员的材料是一个有用的参考。相反,你应该期望使用本书成为你机器学习团队中更有价值的合作者。本书将帮助你更好地利用你已经掌握的关于数据科学和机器学习的知识,以便你能够为你的项目或组织提供可即用的贡献。例如,你将学会如何实现关于提高机器学习模型准确性的见解,并将其转化为生产就绪的能力。

本书的组织结构:路线图

本书分为三个部分。在第一部分,我概述了将机器学习系统投入生产所需的条件,描述了实验性机器学习代码与生产机器学习系统之间的工程差距,并解释了无服务器机器学习如何帮助弥合这一差距。到第一部分结束时,我将教你如何使用公共云(Amazon Web Services)的无服务器特性来开始一个真实的机器学习用例,为该用例准备一个工作机器学习数据集,并确保你已经准备好将机器学习应用于该用例。

  • 第一章全面介绍了机器学习系统工程领域以及将系统投入生产所需的条件。

  • 第二章向你介绍了华盛顿特区出租车行程数据集,并教你如何在亚马逊 Web Services(AWS)公共云中开始使用该数据集进行机器学习。

  • 第三章运用 AWS Athena 交互式查询服务,深入挖掘数据集,发现数据质量问题,然后通过严格和原则性的数据质量保证流程加以解决。

  • 第四章演示了如何使用统计量来总结数据集样本并量化它们与整个数据集的相似性。该章还涵盖了如何选择测试、训练和验证数据集的正确大小,并使用云中的分布式处理来准备用于机器学习的数据集样本。

在第二部分,我将教你如何使用 PyTorch 深度学习框架为结构化数据集开发模型,解释如何在云中分发和扩展机器学习模型训练,并展示如何部署经过训练的机器学习模型以满足用户需求的扩展。在这个过程中,你将学会评估和评估替代机器学习模型实现的性能,并选择适合用例的正确模型。

  • 第五章介绍了 PyTorch 的基础知识,介绍了核心张量应用程序编程接口(API),并帮助您熟练使用该 API。

  • 第六章专注于 PyTorch 的深度学习方面,包括自动微分支持、替代梯度下降算法和支持工具。

  • 第七章解释了如何通过教授图形处理单元(GPU)特性来扩展您的 PyTorch 程序,并如何利用这些特性加速您的深度学习代码。

  • 第八章介绍了分布式 PyTorch 训练的数据并行方法,并深入介绍了传统参数服务器方法和基于环形的分布式训练方法(例如,Horovod)之间的区别。

在第三部分中,我向您介绍了经过考验的机器学习实践技术,并介绍了特征工程、超参数调整和机器学习流水线组装。通过本书的结尾,您将建立一个机器学习平台,该平台摄取原始数据,为机器学习准备数据,应用特征工程,并训练高性能、超参数调整的机器学习模型。

  • 第九章探讨了围绕特征选择和特征工程的用例,使用案例研究来建立对可以选择或设计为 DC 出租车数据集的特征的直觉。

  • 第十章教您如何通过采用一个名为 PyTorch Lightning 的框架来消除 DC 出租车 PyTorch 模型实现中的样板工程代码。此外,该章节还介绍了训练、验证和测试增强的深度学习模型所需的步骤。

  • 第十一章将您的深度学习模型与一个名为 Optuna 的开源超参数优化框架集成,帮助您基于备选超参数值训练多个模型,然后根据它们的损失和指标性能对训练好的模型进行排序。

  • 第十二章将您的深度学习模型实现打包成一个 Docker 容器,以便通过整个机器学习流水线的各个阶段运行它,从开发数据集一直到准备用于生产部署的训练好的模型。

关于代码

您可以从我的 Github 仓库访问本书的代码:github.com/osipov/smlbook。该仓库中的代码以 Jupyter 笔记本的形式打包,并设计用于在基于 Linux 的 Jupyter 笔记本环境中使用。这意味着在执行代码时您有多种选择。例如,如果您拥有自己的本地 Jupyter 环境,那么使用 Jupyter 本机客户端(JupyterApp: github.com/jupyterlab/jupyterlab_app) 或 Conda 分发(jupyter.org/install)就很棒!如果您不使用本地 Jupyter 分发,您可以使用 Google Colab 或 Binder 等云服务从笔记本中运行代码。我的 Github 仓库 README.md 文件包括徽章和超链接,以帮助您在 Google Colab 中启动特定章节的笔记本。

我强烈建议您使用本地 Jupyter 安装,而不是云服务,特别是如果您担心您的 AWS 账户凭证的安全性。代码的一些步骤将要求您使用您的 AWS 凭证来执行诸如创建存储桶、启动 AWS Glue 提取转换加载(ETL)作业等任务。第十二章的代码必须在安装了 Docker 的节点上执行,因此我建议您计划在具有足够容量安装 Docker 的笔记本电脑或台式机上使用本地 Jupyter 安装。有关 Docker 安装要求的更多信息,请参阅附录 B。

liveBook 讨论论坛

购买 大规模工程中的 MLOps 包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以全局附加评论到书籍或特定章节或段落。为自己做笔记,提出和回答技术问题,并从作者和其他用户那里获得帮助都是轻而易举的。

要访问论坛,请转到livebook.manning.com/#!/book/mlops-engineering-at-scale/discussion。一定要加入论坛并打个招呼!你还可以了解更多关于 Manning 论坛和行为规则的信息,请访问livebook.manning.com/#!/discussion

Manning 对我们的读者的承诺是提供一个场所,个人读者和读者与作者之间可以进行有意义的对话。这并不意味着作者有任何具体的参与承诺,他对论坛的贡献仍然是自愿的(且未付酬)。我们建议您尝试向作者提出一些具有挑战性的问题,以免他失去兴趣!只要图书仍在印刷中,论坛和以前的讨论存档将可以从出版商的网站访问。

关于作者

250Osipov Carl Osipov 自 2001 年以来一直在信息技术行业工作,专注于大数据分析和多核分布式系统中的机器学习项目,比如面向服务的体系结构和云计算平台。在 IBM 期间,卡尔帮助 IBM 软件集团塑造了其围绕使用 Docker 和其他基于容器的技术进行无服务器云计算的策略,使用了 IBM Cloud 和 Amazon Web Services。在 Google,卡尔向世界顶尖的机器学习专家学习,并帮助管理公司努力通过 Google Cloud 和 TensorFlow 实现人工智能的大众化。卡尔是《专业,贸易和学术期刊》上的 20 多篇文章的作者;美国专利与商标局的六项专利的发明人;以及 IBM 获得的三项企业技术奖的获得者。

关于封面插图

MLOps 规模工程封面上的图画标题为“Femme du Thibet”,即一位来自西藏的妇女。这幅插图取自雅克·格拉塞·德·圣索维尔(1757-1810)的《不同国家的服装》系列,该系列于 1797 年在法国出版。每幅插图都是精细绘制和手工上色的。格拉塞·德·圣索维尔的收藏丰富多样,生动地提醒了我们 200 年前世界各地城镇和地区在文化上的巨大差异。人们相互隔离,说着不同的方言和语言。在街上或在乡间地区,可以通过服装轻松地辨别他们居住的地方,以及他们的行业或社会地位。

自那时起,我们的着装方式已经发生了变化,当时地区的多样性已经消失。现在很难区分不同大陆的居民,更不用说不同的城镇,地区或国家。也许我们已经以文化多样性换取了更加多样化的个人生活——当然也换取了更加多样化和快节奏的技术生活。

在难以辨别一本电脑书与另一本之际,Manning 通过以格拉塞·德·圣索维尔的图片为基础的书籍封面,庆祝计算机商业的独创性和主动性,并将两个世纪前地区生活丰富多样性的丰盛多样性重新带回生活。

第一部分:掌握数据集

构建有效的机器学习系统取决于对项目数据集的彻底了解。如果您有过构建机器学习模型的经验,您可能会想跳过这一步骤。毕竟,机器学习算法不应该能够自动从数据中学习模式吗?然而,正如您将在本书中观察到的那样,成功在实际中应用的机器学习系统依赖于一个了解项目数据集的实践者,并以现代算法无法实现的方式应用对数据的人类洞察。

第一章:介绍无服务器机器学习

本章内容包括

  • 什么是无服务器机器学习,以及为什么你应该关注

  • 机器学习代码与机器学习平台之间的区别

  • 如何本书教授关于无服务器机器学习的知识

  • 本书的目标读者

  • 你可以从本书中学到什么

一个大峡谷般的鸿沟将实验性机器学习代码和生产机器学习系统分隔开来。跨越“峡谷”的风景如画:当一个机器学习系统在生产中成功运行时,它似乎能够预见未来。我第一次开始在一个机器学习驱动的自动完成搜索栏中输入查询,看到系统预测我的词语时,我就被吸引住了。我一定试了几十种不同的查询来测试系统的工作效果。那么,穿越“峡谷”需要什么?

开始起步惊人地容易。在拥有正确的数据和不到一小时的编码时间的情况下,就可以编写实验性机器学习代码,并重现我使用预测我的词语的搜索栏时所体验到的非凡经历。在与信息技术专业人士的交流中,我发现许多人已经开始尝试机器学习。有关如何入门机器学习基础的在线课程,例如 Coursera 和 Andrew Ng 的课程,提供了丰富的信息。越来越多的公司在招聘信息技术工作时期望具有机器学习的入门经验。

尽管进行机器学习实验相对容易,但基于实验结果构建产品、服务或特性已被证明是困难的。一些公司甚至开始使用独角兽一词来描述那些具备启动生产机器学习系统所需技能的难以找到的从业者。具有成功启动经验的从业者通常具有涵盖机器学习、软件工程和许多信息技术专业的技能。

本书适合那些有兴趣从实验性机器学习代码走向生产机器学习系统的人。在本书中,我将教你如何组装机器学习平台的组件,并将它们用作生产机器学习系统的基础。在这个过程中,你将学到:

  • 如何使用和整合公共云服务,包括来自亚马逊网络服务(AWS)的服务,用于机器学习,包括数据摄取、存储和处理

  • 如何评估和实现结构化数据的机器学习数据质量标准

  • 如何工程合成特征以提高机器学习效果

  • 如何可重复地将结构化数据抽样为实验子集,以进行探索和分析

  • 如何在 Jupyter 笔记本环境中使用 PyTorch 和 Python 实现机器学习模型

  • 如何实现数据处理和机器学习流水线,以实现高吞吐量和低延迟

  • 如何训练和部署依赖于数据处理流水线的机器学习模型

  • 一旦机器学习系统投入生产,如何监视和管理其生命周期

为什么你要投入时间学习这些技能?它们不会让你成为著名的机器学习研究员,也不会帮助你发现下一个开创性的机器学习算法。然而,如果你从本书中学习,你可以为尽早、更高效地交付你的机器学习成果做好准备,并成长为你的机器学习项目、团队或组织更有价值的贡献者。

1.1 什么是机器学习平台?

如果你从未听说过信息技术行业中使用的“剃牦牛”的说法,² 这里是一个假设性的例子,说明它在机器学习实践者的日常生活中可能会如何出现:

我公司希望我们的机器学习系统一个月内上线……但我们训练机器学习模型花费的时间太长了……所以我应该通过启用图形处理单元(GPU)来加快速度……但我们的 GPU 设备驱动程序与我们的机器学习框架不兼容……所以我需要升级到最新版的 Linux 设备驱动程序以实现兼容性……这意味着我需要使用新版本的 Linux 发行版。

还有许多类似的情况,你需要“剃牦牛”来加速机器学习。在将基于机器学习的系统投入生产并使其保持运行的当代实践与剃牦牛的故事有太多共同之处。与专注于使产品取得骄人成功所需的特性不同,太多的工程时间花在了似乎不相关的活动上,比如重新安装 Linux 设备驱动程序或在网络上搜索正确的集群设置来配置数据处理中间件。

为什么会这样?即使你的项目拥有机器学习博士的专业知识,你仍然需要许多信息技术服务和资源的支持来启动该系统。“机器学习系统中隐藏的技术债务”是一篇 2015 年发表的同行评审文章,基于来自谷歌数十位机器学习实践者的见解,建议成熟的机器学习系统“最多是机器学习代码的 5%”(mng.bz/01jl)。

本书使用术语“机器学习平台”来描述在整个系统中扮演支持但关键角色的那 95%。拥有正确的机器学习平台可以成就或毁掉你的产品。

如果你仔细观察图 1.1,你应该能够描述一些从机器学习平台中所需要的功能。显然,平台需要摄入和存储数据,处理数据(其中包括将机器学习和其他计算应用于数据),并向平台的用户提供由机器学习发现的洞见。不够明显的观察是,平台应该能够处理多个并发的机器学习项目,并使多个用户相互隔离地运行这些项目。否则,仅替换机器学习代码就意味着需要重做系统的 95%。

01-01

图 1.1 尽管机器学习代码是使你的机器学习系统脱颖而出的原因,但根据谷歌的 Sculley 等人在“机器学习系统中的隐含技术债务”中所描述的经验,它仅占系统代码的 5%。无服务器机器学习帮助你使用基于云的基础设施组装其余的 95%。

1.2 设计机器学习平台时的挑战

这个平台应该能够存储和处理多少数据?AcademicTorrents.com是一个致力于帮助机器学习实践者获取适用于机器学习的公共数据集的网站。该网站列出了超过 50TB 的数据集,其中最大的数据集大小在 1 到 5TB 之间。Kaggle 是一个流行的举办数据科学竞赛的网站,包括最大为 3TB 的数据集。你可能会忽略最大的数据集,并将关注点集中在千兆字节级别的常见数据集上。然而,你应该记住,在机器学习中取得成功通常是基于更大的数据集依赖的。Peter Norvig 等人在《数据的不合理有效性》中(mng.bz/5Zz4)提出了利用更大的数据集的机器学习系统“简单的模型和大量的数据胜过基于较少数据的更为精细的模型”的观点。

一个预计在存储和处理上达到TB 到 PB 级别的机器学习平台,必须构建成使用多台相互连接的服务器组成的分布式计算系统,在集群中每个服务器处理数据集的一部分。否则,一个GB 到 TB 级别的数据集在典型硬件配置的单台服务器上处理时会导致内存溢出问题。将服务器集群作为机器学习平台的一部分还可以解决单个服务器的输入/输出带宽限制。大多数服务器每秒只能为 CPU 提供几 GB 的数据。这意味着机器学习平台执行的大多数数据处理可以通过将数据集拆分成由集群中的服务器并行处理的块(有时称为 shards)来加速。所描述的用于机器学习平台的分布式系统设计通常被称为 scaling out

图 1.1 的重要部分是平台中使用的基础设施的服务部分。这是将机器学习代码产生的数据洞察力暴露给平台用户的部分。如果你曾经让你的电子邮件提供商将你的电子邮件分类为垃圾邮件或非垃圾邮件,或者如果你曾经使用过你最喜爱的电子商务网站的产品推荐功能,那么你就已经作为用户与机器学习平台的服务基础设施部分进行了交互。一个主要的电子邮件提供商或电子商务提供商的服务基础设施需要能够每秒为全球数百万用户做出决策,数百万次。当然,并不是每个机器学习平台都需要以这种规模运作。然而,如果你计划基于机器学习提供产品,你需要记住,基于数字产品和服务可以在数月内达到数亿用户的可能性。例如,Niantic 推出的机器学习驱动的视频游戏 Pokemon Go,在不到两个月的时间内就吸引了五亿用户。

在规模上启动和运营机器学习平台成本很高吗?就在 2000 年代,运行一个可扩展的机器学习平台需要显著的前期投资,包括服务器、存储、网络以及构建平台所需的软件和专业知识。我在 2009 年为一家客户开发的第一个机器学习平台花费超过 10 万美元,采用的是基于本地硬件和开源的 Apache Hadoop(和 Mahout)中间件。除了前期成本之外,机器学习平台的运营成本也可能很高,原因是资源浪费:大多数机器学习代码没有充分利用平台的能力。如您所知,机器学习的训练阶段对计算、存储和网络的利用率要求很高。然而,训练是间歇性的,在生产环境中,机器学习系统相对较少进行训练,平均利用率较低。用于服务的基础设施利用率因机器学习系统的特定用例而异,并根据一天中的时间、季节性、营销活动等因素而波动。

1.3 机器学习平台的公共云

好消息是,公共云计算基础设施可以帮助您创建一个机器学习平台,并解决前一节中描述的挑战。特别是,本书介绍的方法将利用像亚马逊网络服务(Amazon Web Services)、微软 Azure 或谷歌云这样的公共云,为您的机器学习平台提供以下功能:

  1. 安全隔离,使您平台的多个用户能够并行工作,处理不同的机器学习项目和代码

  2. 当您的项目需要时,能够获取数据存储、计算和网络等信息技术,并在需要的时间内持续使用

  3. 按消费计量,以便仅为您使用的资源结算机器学习项目的费用

本书将教您如何使用公共云基础设施创建一个机器学习平台,以亚马逊网络服务为主要示例。具体而言,我将教您:

  • 如何使用公共云服务以高效低成本地存储数据集,无论数据集是由几千字节还是几百万个字节组成

  • 如何优化您的机器学习平台计算基础设施的利用率和成本,以便使用所需的服务器

  • 如何弹性地扩展您的服务基础设施,以降低机器学习平台的运营成本

1.4 什么是无服务器(Serverless)机器学习?

无服务器机器学习是一种机器学习代码的软件开发模型,旨在在托管在云计算基础设施中的机器学习平台上运行,并采用按使用量计量和计费的模式。

如果一个机器学习系统在基于服务器的云计算基础设施上运行,为什么这本书要讲述无服务器机器学习?在公共云中使用服务器的想法显然与无服务器(serverless)的前提相矛盾。无服务器机器学习?那怎么可能呢?

在你反对在定义中使用无服务器一词之前,请记住,与云计算平台一起工作的信息技术专业人员已经采用了无服务器作为一个名称,以描述一种使用云计算的方法,包括计算、存储和网络,以及其他云资源和服务,以帮助他们更有效地利用时间,提高生产率并优化成本。无服务器并不意味着没有服务器;它意味着在使用无服务器方法时,开发人员可以忽略云提供商中服务器的存在,专注于编写代码。

在本书中,无服务器(serverless)描述了一种构建机器学习系统的方法,使机器学习实践者尽可能多地花费时间编写机器学习代码,尽可能少地花费时间管理和维护计算、存储、网络和操作系统;中间件;或者任何其他承载和运行机器学习平台所需的底层信息技术的部分。无服务器机器学习也实现了云计算中的成本优化的一个关键理念:消费性计费。这意味着使用无服务器机器学习时,你只需要为你使用的资源和服务付费。

机器学习,无论是在学术界还是在信息技术行业中使用,涵盖了广泛的算法和系统,包括那些在古老的围棋游戏中击败顶级人类玩家的算法,赢得了电视节目危险边缘中的比赛,并生成了世界名人和领导人的深度伪造图像。本书专注于机器学习的特定子领域,即使用结构化数据(行和列的表)的监督学习。如果你担心这个子领域太窄,注意到在谷歌,也可以说是机器学习领域的领导者,超过 80%的生产机器学习系统都是使用监督学习从结构化数据集构建和使用的,而且这些系统在不同成熟阶段都有使用。

1.5 为什么选择无服务器机器学习?

在无服务器机器学习之前,涉及将机器学习代码投入生产的开发人员必须要么与运维组织的团队成员合作,要么自己担任运维角色(这在行业中被称为 DevOps)。开发角色的职责包括编写机器学习代码,例如执行推断的代码,例如从房地产物业记录中估算房屋销售价格的代码。一旦代码准备就绪,开发人员将其打包,通常作为机器学习框架(例如第二部分中的 PyTorch)的一部分,或与外部代码库一起,以便在服务器上作为应用程序(或微服务)执行,如图 1.2 所示。

01-02

图 1.2 在无服务器平台之前,大多数基于云的机器学习平台都依赖于基础设施即服务(IaaS)或平台即服务(PaaS)服务模型,如图所示。在 IaaS 的情况下,由于运维的作用,基础结构是基于服务器的,而在 PaaS 的情况下,基础结构是基于应用程序的。一旦它们开始运行,运维也需要负责管理基础架构的生命周期。

运维角色涉及实例化运行代码所需的基础设施,同时确保基础架构具有适当的容量(内存、存储、带宽)。该角色还负责配置服务器基础架构,包括操作系统、中间件、更新、安全补丁和其他先决条件。接下来,运维人员启动一个或多个应用程序实例来执行开发人员的代码。在代码启动和运行后,操作将管理代码执行,确保请求得到高可用性(即可靠)和低延迟性(即响应迅速)。 运维还被要求通过优化基础设施利用率来帮助降低成本。这意味着不断监视 CPU、存储、网络带宽和服务延迟水平,以改变基础架构容量(例如取消服务器)并实现目标利用率目标。

IaaS 等云计算服务模型用虚拟服务器代替物理服务器,从而使运维更加高效:与物理服务器相比,虚拟服务器的配置和销毁需要更少的时间和精力。云中的运维进一步自动化,具有自动扩展等功能,根据 CPU、内存和其他服务器级指标的准实时测量来自动配置和取消配置虚拟服务器。PaaS 是一种更抽象的云服务模型,通过为代码执行运行时预配置虚拟服务器,以及预安装中间件和操作系统,进一步减少了操作负担。

虽然像 IaaS 和 PaaS 这样的云计算服务模型在机器学习平台的服务基础设施部分工作良好,但在其他方面却表现不佳。在进行训练前的探索性数据分析时,机器学习工程师可能需要对数据执行数十个不同的查询才能找到正确的查询。在 IaaS 和 PaaS 模型中,这意味着数据分析查询的基础设施需要被预配(有时是由运维团队完成)甚至在第一个查询被执行之前就要进行预配。更糟糕的是,预配基础设施的使用完全取决于用户的心情。在极端情况下,如果机器学习工程师每天只运行一个数据分析查询,并且需要一个小时才能执行,那么数据分析基础设施可能在一天的其他 23 个小时内处于空闲状态,同时仍然产生成本。

1.5.1 Serverless 与 IaaS 和 PaaS 的比较

相比之下,图 1.3 中所示的 Serverless 方法有助于进一步优化机器学习平台的利用率和成本。 Serverless 平台消除了执行传统操作任务的需要。使用 Serverless 机器学习,机器学习平台接管了整个机器学习代码的生命周期,对其进行实例化和管理。这是通过平台为不同的编程语言和函数提供专用运行时来实现的。例如,存在一个服务运行时来执行 Python 代码以运行机器学习模型训练,另一个运行时则用于执行结构化数据查询的 SQL 代码,等等。

01-03

图 1.3 Serverless 平台消除了操作管理代码基础设施的需要。基于云的平台负责在运行时中实例化代码以服务请求,并管理基础设施以确保高可用性、低延迟和其他性能特征。

使用 Serverless 与 IaaS 或 PaaS 模型相比,最重要的影响是成本。在 IaaS 和 PaaS 模型中,公共云供应商根据预配容量计费。相比之下,在 Serverless 模型中,可以根据代码是否实际在平台上执行来优化机器学习平台的成本。

无服务器和机器学习存在于两种信息技术的交汇处。一方面,机器学习为新产品、新功能,甚至是基于以前在市场上不存在的能力重新发明的行业开启了潜力。另一方面,无服务器模型在生产率和定制性之间取得平衡,使开发人员能够专注于构建不同 iating 能力,同时重用云计算平台的现有组件。无服务器方法不仅仅是重用黑盒组件。它是关于快速组装特定项目的机器学习平台,可以通过代码进行定制,从而实现新产品和服务的开发。

1.5.2 无服务器机器学习生命周期

当机器学习为基础的系统能够以规模操作时,它们变得更有价值,能够对数据做出频繁且重复的决策,同时支持大量用户。要了解机器学习在这种规模上的运行情况,想象一下您的电子邮件提供商每秒为数百万封电子邮件分类为垃圾邮件或非垃圾邮件,并为全球数百万并发用户提供服务。或者考虑来自主要电子商务网站的产品推荐(“如果您购买了这个,您可能也会喜欢那个”)。

机器学习为基础的系统在规模越大时变得越有价值,就像任何软件项目一样,它们在小规模时仍应高效运行,并且如果成功,应能够扩展以支持增长。然而,大多数软件项目并不会一夜成名,也不会发展到达数十亿用户。尽管从成本的角度来看这可能听起来很昂贵,但在本书中,无服务器机器学习中的无服务器部分是关于确保你的项目可以从公共云计算的最初承诺中受益:只为你使用的部分付费,不多不少。

1.6 本书适合谁?

本书中描述的无服务器机器学习方法针对的是对构建和实施可能需要扩展到潜在的大量用户和大量请求和数据量,但也需要在必要时缩小以保持成本效益的机器学习系统感兴趣的团队和个人。即使您决定不在项目中使用机器学习算法,您仍然可以使用本书了解无服务器和云计算如何帮助您管理、处理和分析数据。

1.6.1 本书的收获

如果您计划将机器学习系统投入生产,那么在某个时候您必须决定是购买还是构建支持 95%的支持,换句话说,机器学习平台的组件。例如,“机器学习系统中的隐藏技术债务”中的示例包括服务基础设施、数据收集、验证、存储、监视等。

如果您计划构建大多数或全部机器学习平台,您可以将本书视为一系列设计用例或来自示例机器学习项目的启发性示例。本书演示了平台功能如何在来自各种公共云供应商的云计算平台中实现,包括 AWS、Google Cloud 和 Microsoft Azure。本书还将教您有关机器学习平台所需功能的知识,包括对象存储、数据仓库、交互式查询等。在可能的情况下,本书将突出显示您可以在平台构建中使用的开源项目。虽然本书不会为您提供构建机器学习平台的逐步说明,但您可以将其用作案例研究和指导,以了解您应该构建的架构组件。

如果您计划获取大多数机器学习平台的功能,本书将为您提供说明,并引导您完成构建一个示例机器学习项目并将其投入生产使用的过程。本书还将为您介绍机器学习平台的实施步骤,包括项目所需的源代码。在可能的情况下,本书的方法依赖于便携式开源技术,如 Docker(有关 Docker 的更多信息请参见附录 B)和 PyTorch(有关 PyTorch 的更多信息请参见第二部分),这将简化将项目迁移到其他云提供商(如 Google Cloud 和 Microsoft Azure)的过程。

1.7 本书的教学方法是什么?

机器学习领域存在于计算机科学和统计学的交叉点上。因此,介绍机器学习应用的替代途径并不奇怪。许多信息技术专业人士从 Andrew Ng 的著名 Coursera 课程开始他们的机器学习学习之旅(www.coursera.org/learn/machine-learning)。具有统计学或学术背景的人通常会将 James 等人的《统计学习导论》(Springer,2013)引用为他们的第一本机器学习教材。

本书采用软件工程方法来对待机器学习。这意味着在本书中,机器学习 是指构建具有自动从数据中推断答案的能力的软件系统的实践,以增强并经常替代重复的数据驱动决策中人类的需求。对软件工程的关注也意味着对机器学习算法、技术和统计基础的细节将比其他提及的来源更少。相反,本书将重点介绍如何描述具有以机器学习为核心的生产就绪系统的工程化方法。

1.8 如果这本书不适合你?

根据你到目前为止阅读的一切,你可能会产生一个错误的印象,即无服务器机器学习适用于机器学习的每一个应用。那么,何时使用无服务器机器学习才是合理的呢?我将首先承认,它并不适用于每种情况。如果你正在开展一个实验性的、独特的项目,一个在范围、大小或持续时间上有限的项目,或者如果你的整个工作数据集小到可以放入虚拟服务器内存中,你应该重新考虑使用无服务器机器学习。你可能更适合使用一个专用的单虚拟服务器(单节点)和一个 Anaconda 安装中的 Jupyter 笔记本,Google Colaboratory 或类似的 Jupyter 笔记本托管环境。

无服务器方法确实有助于优化与在公共云上运行机器学习项目相关的成本;但是,这并不意味着从本书中重新实现项目是免费的。要从本书中获得最大收益,你将希望使用你的 AWS 账户来复制本书即将描述的示例。为此,你将需要花费约 45 美元来按照本书中描述的步骤重新创建项目。但是,为了从本书中受益,你并不需要固守于 AWS。在可能的情况下,本书将提及其他供应商(如 Google Cloud 和 Microsoft Azure)的替代功能。好消息是,本书的整个项目都可以在三家主要公共云供应商提供的免费信用额度内完成。或者,如果你选择不在公共云中实现本书中的代码示例或项目,你也可以依靠描述来获得关于如何在规模上启动机器学习系统的概念性理解。

请记住,如果你没有准备在投入生产后维护你的系统,就不应该使用本书中的方法。现实情况是,无服务器方法与公共云平台(如 AWS)的功能集成在一起,而这些功能,特别是它们的 API 和端点,会随着时间的推移而发生变化。虽然公共云供应商有一种提供给你一些端点稳定性的方法(例如,托管的淘汰计划),但你应该准备好供应商推出新功能和变更,这反过来意味着你应该准备好花费时间和精力来维护你的功能。如果你需要最小化和控制可维护性的范围,那么无服务器方法不适合你。

隐私问题可能会引发更多原因来避免在项目中使用基于公共云的基础设施。虽然大多数公共云提供商提供复杂的基于加密密钥的数据安全机制,并具有帮助满足数据隐私需求的功能,但在公共云中,您可以确保数据隐私的程度很高,但并不一定能完全保证您的数据和流程将安全。话虽如此,本书并不教您如何确保您在云中的数据的 100%安全性,如何提供身份验证和授权,也不会处理本书中描述的机器学习系统的其他类型的安全问题。尽管在可能的情况下,我会提供可以帮助您解决安全问题的参考资料,但本书的范围不包括教您数据和隐私安全方面的知识。

从可移植性的角度来看,本书描述的方法试图在理想的代码可移植性和尽量减少部署机器学习项目所需工作量之间取得平衡。如果可移植性是您首要考虑的因素,那么最好尝试另一种方法。例如,您可以依赖于复杂的基础设施管理堆栈,如 Kubernetes 或 Terraform,用于基础设施部署和运行时管理。如果您决心使用与本书中使用的堆栈不兼容的专有框架或技术,则也不应使用无服务器机器学习方法。本书将尽可能使用非专有、可移植和开源工具。

1.9 结论

这本书能为读者解决哪些问题,读者可以从中获得什么价值?当代的机器学习实践消耗了机器学习从业者太多的生产力。本书教导读者通过一个样本机器学习项目高效工作。与其冒险在机器学习平台的各种选择中徘徊,冒着错误或失败的风险,本书将读者直接传送到经验丰富的机器学习从业者已经走过的路上。与其自己重新发现机器学习的实践,您可以使用本书来利用适用于绝大多数机器学习项目需求的能力。

本书适用于已经具有一定机器学习经验的人,因为它不会从零开始教授机器学习。该书侧重于对机器学习的实际、务实理解,并为您提供足够的知识来理解和完成示例项目。到书末,您将完成您的机器学习项目,将其部署到公共云上的机器学习平台,将您的系统作为一个高可用的 Web 服务提供给互联网上的任何人,并为确保系统长期成功的下一步做好准备。

摘要

  • 成功的机器学习系统由约 5% 的机器学习代码组成。其余的是机器学习平台。

  • 公共云计算基础设施为机器学习平台提供了具有成本效益的可伸缩性。

  • 无服务器机器学习是一种针对在云计算基础设施中托管的机器学习平台上运行的机器学习代码进行软件开发的模型。

  • 无服务器机器学习可以帮助您通过快速组装机器学习系统来开发新产品和服务。

  • 本书将帮助您从实验性机器学习代码导航到在公共云中运行的生产机器学习系统的路径。

^(1.)如果您需要或希望对机器学习基础知识进行复习,附录 A 中有关于该主题的部分。

^(2.)据说这个短语起源于 1990 年代的麻省理工学院人工智能实验室(参见 mng.bz/m1Pn)。

第二章:开始使用数据集

本章涵盖了

  • 引入一个机器学习用例

  • 从对象存储开始使用无服务器机器学习

  • 使用爬虫自动发现结构化数据模式

  • 迁移到基于列的数据存储以实现更高效的分析。

  • 尝试 PySpark 提取-转换-加载(ETL)作业

在上一章中,你学习了关于无服务器机器学习平台的知识以及它们为何能帮助你构建成功的机器学习系统的一些原因。在本章中,你将开始使用一个实用的、真实世界的无服务器机器学习平台用例。接下来,你被要求下载华盛顿特区几年的出租车乘车记录数据集,以构建一个适用于该用例的机器学习模型。当你熟悉数据集并了解如何使用它来构建机器学习模型的步骤时,你将了解到无服务器机器学习平台的关键技术,包括对象存储、数据爬虫、元数据目录和分布式数据处理(提取-转换-加载)服务。通过本章的结论,你还将看到使用代码和 shell 命令示例,演示这些技术如何与亚马逊网络服务(AWS)一起使用,以便你可以在自己的 AWS 账户中应用所学的知识。

2.1 引入华盛顿特区出租车乘车数据集

本节深入探讨了华盛顿特区出租车行业的业务领域和业务规则的细节。你可能会想跳过这些细节;毕竟,它们可能与你计划在机器学习项目中使用的数据集无关。然而,我鼓励你将本节视为一个案例研究,说明你在计划应用机器学习的任何业务领域中应该提出的问题种类。当你在本节中探索业务用例时,你可以更多地了解到 DC 出租车行程数据背后的因素,并更好地为构建机器学习模型做准备。

2.1.1 业务用例是什么?

想象一下,你是一名机器学习工程师,为一家年轻有为的初创公司工作,计划推出一款自动驾驶的无人驾驶汽车,以接管乘车共享行业,并超越 Waymo、Uber 和 Lyft 等公司。你的业务领导决定,你的服务首先将在华盛顿特区市场推出。由于你的初创公司希望提供与普通出租车竞争的价格,所以你被要求编写一些代码来估算乘客从一个位置到另一个位置乘坐普通出租车的费用,范围包括华盛顿特区及其附近的弗吉尼亚州和马里兰州。

2.1.2 业务规则是什么?

华盛顿特区出租车费用的计算业务规则可从 dc.gov 网站获取。¹ 规则如下:

  • 前 1/8 英里的费用为 $3.50。

  • 每增加 1/8 英里的费用为 0.27 美元,每英里累计费用为 2.16 美元。

  • 特殊的按时计费为每小时 25 美元,并按 1 分钟递增。

基于持续时间的收费适用于出租车在交通拥堵中的情况,因此车费金额会随着时间的推移而持续增加。dc.gov 网站还列出了其他特殊费用(例如,下雪紧急情况的费用),但让我们暂时忽略它们。

2.1.3 业务服务的模式是什么?

对于出租车车费估算服务接口的更具体规范,软件工程师可以定义输入和输出值的数据类型,如表 2.1 所示。接口期望输入由接送位置(每个位置都是一对纬度和经度坐标)以及预计行程开始时间的时间戳组成。服务的输出仅是估计出租车费用的金额。表 2.1 中提供的示例值适用于短途半英里出租车行程,大约花费 6.12 美元。由于第 1/8 英里的固定费用为 3.50 美元,剩余的 3/8 距离的费用为 0.81 美元(0.27 美元*3),剩余的 1.81 美元可能是由于出租车在星期一的午间高峰期在华盛顿特区市中心繁忙地区花费的时间。

表 2.1 出租车车费估算服务接口的架构和示例值

输入
名称
接送位置纬度
接送位置经度
接送位置经度
送达位置经度
预计行程开始时间
输出
名称
估计车费(美元)

表 2.1 中行程的纬度和经度坐标对应于华盛顿特区 1814 N St. NW 的接送地址和 1100 New Hampshire Ave. NW 的送达地址。请注意,该服务不执行任何地理编码;换句话说,该服务期望接送位置和送达位置是纬度和经度坐标,而不是类似于 1100 New Hampshire Ave. NW 的人类可读地址。当然,您的服务的用户不需要键入坐标的纬度和经度值。相反,用户可以在您的移动应用程序中的地图上直观地放置接送位置的图钉。然后,可以直接使用图钉的纬度和经度与该服务一起使用。或者,还可以使用来自 Google 地图和类似服务的地理编码功能,但它们不在本书的范围之内。

2.1.4 实施业务服务的选项有哪些?

表 2.1 中示例的行程仅是华盛顿特区可能的许多出租车路线中的一种。对于出租车费用估算服务的目的,出租车行程可以跨越任何上车和下车位置,只要两者都在菱形边界内,该边界包括华盛顿特区的整个范围以及马里兰州和弗吉尼亚州的附近地区。交互式地图(osm.org/go/ZZcaT9)上的区域包括本书中华盛顿特区出租车行程的所有可能的上车和下车位置。你创业公司的移动应用的用户可以在区域边界内放置上车和下车标记,以获取行程的估价。

在着手实现用于估算出租车费用的机器学习项目之前,请考虑一下传统的软件工程方法来构建费用估算服务。一个软件工程师(假设他们对机器学习不熟悉)可能会首先开发代码来使用业务规则来计算费用,并将代码与来自服务(如 Google Maps 或 Bing Maps)的路线规划应用程序接口(API)集成。这两个 API 都可以计算从一个位置到另一个位置的最短驾驶路线,并估算路线的距离和持续时间。出租车司机实际走的路线和行程持续时间可能会因交通、道路关闭、天气和其他因素而有所不同,但这种方法可以合理估计距离。接下来,API 返回的距离可以与业务规则结合起来计算出预估的出租车费用。

构建出租车费用估算服务的传统软件工程方法有几个优点。该服务很容易实现,即使对于初级软件工程师也是如此。全球范围内有大量具备交付实施技能的工程师。一旦实施,该服务应该会产生准确的估算结果,除非出租车乘车受到异常交通、天气或紧急事件的影响。

然而,对于创业公司来说,依赖路线规划服务可能会很昂贵。像 Google Maps 这样的服务每次 API 请求都要收费,以执行路线规划和计算距离,并根据交通选择路线。这些服务的成本可能会迅速累积起来。此外,还要考虑到您服务的用户的额外成本,他们将估算行程的价格而实际上并未乘坐车辆。虽然一家更大的公司可以探索通过在开源数据软件上进行构建或从供应商购买许可证来开发本地、内部部署的路线规划服务的选项,但对于创业公司来说,这样做的成本是禁止的。

本书不是依赖于传统软件工程来构建出租车费用估算服务,而是介绍了使用 AWS 的无服务器能力实现的机器学习方法。

2.1.5 什么数据资产可用于业务服务?

华盛顿特区首席技术官办公室维护着一个网站,该网站托管了在华盛顿特区范围内发生的出租车行程的数据。⁴ 在本书中,您将使用这个从 2015 年到 2019 年的出租车行程的历史数据集来构建机器学习模型,以估计在华盛顿特区乘坐出租车的成本。机器学习方法的关键优势在于,它不依赖于昂贵的外部服务进行路线规划和距离计算。该模型将从出租车行程数据中学习,以估计基于在华盛顿特区不同位置进行的出租车行程的费用。

本书的后续部分,您还将部署模型到 AWS 作为一个具有互联网可访问 API 的 Web 服务,用于出租车费用预测。该服务将处理包含接送地点的地理坐标的 HTTP(超文本传输协议)请求,并返回估计的出租车费用。该服务的 API 还将考虑行程的开始时间,以便模型能够正确调整预测的费用。例如,相同接送地点的多次行程的费用将根据一天中的时间(高峰时段与深夜)、一周中的日期(工作日与周末)甚至一年中的日期(假期与工作日)而变化。

您还会发现,机器学习方法可以通过最小的更改来适应服务扩展到支持其他地理区域。您不需要为您的创业公司想要推出的每个城市硬编码城市特定的业务规则,而只需将其他城市的出租车行程数据扩展到数据集中即可。

2.1.6 下载和解压数据集

从 opendata.dc.gov 下载并解压文件开始处理数据集。⁵ 下载文件后,您应该能够确认您拥有 2015 年到 2019 年的数据,每年都有一个单独的 zip 文件。请注意,2019 年的数据集仅限于一月至六月的数据。解压文件后,整个数据集应该占据不到 12 GiB 的磁盘空间。

注意:解压文件后,数据集的内容会被放置到单独的子目录中。在继续之前,请将数据集中的所有文件移动到一个单独的目录中。不用担心覆盖 README_DC_Taxicab_trip.txt 文件;对于数据集的每一年,该文件都有一个相同的副本。

本书中的指令假设你在 Linux 或 MacOS 中使用 bash(或类似的)shell 作为你的 shell 环境。当你下载并解压缩文件后,你可以使用 shell 的 du 命令确认数据集大约占据你磁盘的 12 GiB 空间。

du -cha --block-size=1MB

结果输出以以下内容开头。

列表 2.1 显示从 2015 年到 2019 年的 DC 出租车行程数据集的解压缩文件

8.0K    ./README_DC_Taxicab_trip.txt
176K    ./taxi_2015_01.txt
 60M    ./taxi_2015_02.txt
151M    ./taxi_2015_03.txt
190M    ./taxi_2015_04.txt
302M    ./taxi_2015_05.txt
317M    ./taxi_2015_06.txt

...

11986    total

为简洁起见,列表 2.1 中 du 命令的输出省略了数据集中的大部分文件,并用省略号代替。完整的列表可在 Github Gist(mng.bz/nrov)上找到。

在 zip 文件中,数据被打包为一组文本文件(具有“.txt”扩展名),每行使用竖线(|)字符将列分隔开。机器学习从业者通常将这种文件称为管道分隔逗号分隔值(CSV)文件。尽管这个行业术语令人困惑,我将继续使用 CSV 这个缩写词来表达这个数据格式,直到本书结束。

DC 出租车数据集的 CSV 文件包含标题行,即每个文件的第一行为每一列的字符串标签,例如 MILEAGE、FAREAMOUNT 等。文件中的其余行是出租车行程记录,每行一次行程。每个 zip 文件还包含一个相同的 README_DC_Taxicab_trip.txt 文件副本,该文件提供了有关数据资产的一些附加文档。文档的关键部分将在本章后面介绍。

2.2 从数据集开始使用对象存储

本节将介绍本书中机器学习项目的第一个无服务器能力。你将在对传统文件系统的了解基础上,开始学习关于无服务器对象存储的知识。接下来,你将使用 AWS 的命令行界面为 DC 出租车数据集创建一个无服务器对象存储位置,并开始将 CSV 文件复制到该位置。你将熟悉如何使用公共云对象存储来处理你的机器学习数据集,并将 DC 出租车数据集转移到对象存储中进行进一步处理。

本节和本书的其余部分将使用来自 AWS 的简单存储服务(S3)的示例来解释无服务器对象存储如何帮助你进行机器学习项目。但是,你应该知道其他公共云供应商,如 Google Cloud 和 Microsoft Azure,也提供类似的功能。

2.2.1 理解对象存储和文件系统的区别

文件系统和对象存储之间有许多相似之处,因此,如果你首先专注于它们的区别(见表 2.2),你会发现更容易理解对象存储。请记住,文件系统设计用于在命名位置存储可变或可更改的数据。这意味着使用文件系统,你可以打开文件,导航到文件中的任何行或字节位置,更改所需数量的字节,然后将更改保存回文件系统。由于文件系统中的文件是可变的,所以在进行更改后,原始数据就不存在了,并且被你的更改替换在存储介质上(例如固态驱动器)。

表 2.2 虽然文件系统和对象存储服务都具有类似的功能,如文件夹层次结构和支持复制、删除和移动等常见操作,但在这个表中突出显示了一些重要的区别。

文件系统/文件 无服务器对象存储/对象
可变的 不可变的
缺乏全局唯一名称 可以使用 URL 在全球范围内标识
跨多个存储设备的数据冗余 跨多个可用性区域(数据中心)和多个存储设备的数据冗余

相反,对象存储中的对象是不可变的。一旦在对象存储中创建了对象,它就会存储在创建对象时放入对象中的确切数据。你可以使用你的更改创建对象的新版本,但是就对象存储服务而言,新版本是一个额外的对象,占用额外的存储空间。当然,你也可以删除整个对象,释放存储空间。

与文件不同,像 AWS S3 这样的无服务器对象存储服务中的对象设计为可以使用 HTTP 协议在互联网上访问。默认情况下,对象的公共互联网访问是禁用的。但是,无服务器对象存储中的任何对象都可以通过公共 URL 提供。为了支持此功能,对象存储服务将对象组织到(在 Azure 中称为容器)中,这些桶是具有全局唯一标识符的命名位置。对象存储中的每个对象都必须存在于桶中,直接或在一些层次结构的类似文件夹的名称结构下。例如,如果是 S3 桶的全局唯一标识符名称,那么名为 dataset 的对象可以直接通过 S3 桶的 URL 访问,使用 https://.us-east-2.amazonaws.com/dataset,或者在桶内名为“2015”的文件夹下使用 https://.us-east-2.amazonaws.com/2015/dataset。

示例中的对象 URL 中的“us-east-2”部分是传统文件系统和对象存储之间另一个差异的原因。与依赖于同一物理服务器内的多个存储设备进行数据冗余的文件系统不同,AWS 等对象存储提供商会在多个存储设备和被称为可用区的多个物理数据中心之间复制数据。在一个大都市区域内相互连接的高带宽和低延迟网络上的冗余可用区集群称为区域。对象 URL 的“us-east-2”部分指定了存储对象的区域的 AWS 特定代码名称。

为什么您应该为 DC 出租车乘车数据集和出租车费用估算服务使用无服务器对象存储?对于您的机器学习项目,使用无服务器对象存储,您不必担心存储空间不足的问题。像 S3 这样的服务可以帮助您从 GB 到 PB 的数据集进行扩展。正如您从第一章对无服务器的定义中所记得的那样,使用无服务器对象存储可以确保您不需要管理任何存储基础设施,并且您将根据存储桶中保存的数据量收费。此外,由于对象存储可以为存储的对象提供基于 HTTP 的接口,因此访问和集成您存储的数据所需的工作量更少。

使用 Amazon Web Services 进行身份验证

本章中剩余的示例依赖于 AWS 服务。如果您计划从示例中运行代码,则应安装 AWS 软件开发工具包 (SDK),并了解您的 AWS 帐户的访问和密钥。SDK 安装的详细信息可在 AWS 文档 (docs.aws.amazon.com/cli) 中找到。

如果您没有可用的 AWS 访问和密钥,可以通过转到 AWS 管理控制台 (console.aws.amazon.com/),点击右上角下拉菜单中的用户名,然后选择“我的安全凭证”来生成新的一对。要创建新的密钥对,请点击“创建访问密钥”按钮。

本书的说明假定您已经配置了带有 AWS 环境变量的 shell 使用

export AWS_ACCESS_KEY_ID=█████████████████████
export AWS_SECRET_ACCESS_KEY=█████████████████

在运行任何依赖 AWS SDK 的命令之前。

注意 在本书中,所有的清单都使用一系列 █ 字符替换了敏感的账户特定信息。请务必使用您的账户特定值来替换 AWS 访问和密钥。

要验证您已指定有效值的环境变量 AWS_ ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY,您可以运行

aws sts get-caller-identity

在与 AWS 成功验证的情况下,应返回您的用户 ID、账户和 Arn 值:

{
    "UserId": "█████████████████████",
    "Account": "████████████",
    "Arn": "arn:aws:iam::████████████:user/█████████"
}

创建无服务器对象存储存储桶

本节将指导您完成创建 S3 存储桶并将 DC 出租车数据文件作为对象上传到存储桶的步骤(图 2.1)。本节中的步骤使用 AWS 的命令行界面(CLI)完成,但如果您愿意,您也可以使用 AWS 管理控制台的图形用户界面完成相同的步骤(console.aws.amazon.com)。本书专注于基于 CLI 的方法,因为它可以轻松解释、测试和重用作为脚本自动化的一部分的步骤。

02-01

图 2.1 要将 DC 出租车数据集传输到 AWS 的对象存储中,您将使用 aws s3api create-bucket 命令创建一个 S3 存储桶,指定区域和存储桶的全局唯一标识符。接下来,您将使用 aws s3 sync 将数据集文件上传到存储桶中名为“csv”的文件夹中。

选择存储桶的区域(以及作为结果的位置)对于访问存储在存储桶中的数据的低延迟非常重要。未来,您应该记住从与您放置 S3 存储桶相同的区域运行任何处理数据的代码。本节假设您将使用 us-east-2 区域存储数据集。

要导出 AWS_DEFAULT_REGION 变量的设置,该变量将用于指定存储桶的默认区域,请运行

export AWS_DEFAULT_REGION=us-east-2
echo $AWS_DEFAULT_REGION

这应该返回您选择的存储桶区域的值。

由于存储桶名称应该是全局唯一的,因此在代码清单中发布固定且相同的存储桶名称毫无意义。相反,清单 2.2 使用 $RANDOM 环境变量,该变量始终返回一个伪随机值。然后,使用 MD5 散列函数对值进行哈希处理,以得到由一系列数字和字符组成的唯一标识符。然后将 BUCKET_ID 变量设置为哈希字符串的前 32 个字符的值,如 cut 命令的输出所示。

清单 2.2 使用伪随机生成器生成存储桶 ID 的可能唯一值

export BUCKET_ID=$(echo $RANDOM | md5sum 
➥ | cut -c -32)                           ❶

echo $BUCKET_ID

❶ 使用 Linux 伪随机数生成器的 MD5 哈希的前 32 个字符。

注意:如果您在 Mac OSX 或 BSD 上运行清单 2.2 中的命令,则可能需要使用 md5 而不是 md5sum。

此时,您应该已经导出了环境变量,指定了存储桶的全局唯一标识符(在 BUCKET_ID 中)和区域(在 AWS_DEFAULT_REGION 中)。

在创建存储桶之前,运行

aws sts get-caller-identity

以确保您的 shell 配置为有效的环境变量值,这些值是用于与 AWS 进行身份验证所需的 AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY。

请注意,以下命令创建存储桶时使用的是 aws s3api 而不是您可能期望的 aws s3。这是为了与传统的、细粒度的 AWS CLI 功能兼容,这些功能在引入 aws s3 命令之前就已经提供。

注意:如果想要使用 us-east-1(北弗吉尼亚地区)而不是 us-east-2,您需要在 aws s3api create-bucket 命令中删除 LocationConstraint 参数。

创建存储桶。

aws s3api create-bucket --bucket dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION \
--create-bucket-configuration LocationConstraint=$AWS_DEFAULT_REGION

并确认使用您的 BUCKET_ID 和 AWS_DEFAULT_REGION 环境变量替换 █ 字符后,命令返回类似于以下 JavaScript 对象符号 (JSON) 响应:

{
"Location": "http:/dc-taxi-████████████████████████████████-█████████.s3
➥ .amazonaws.com/"
}

虽然 aws s3api create-bucket 命令的响应返回存储桶的 HTTP URL,但通常您将使用以 s3:// 前缀开头的 AWS 特定命名方案来引用存储桶。如果您迷失了名称,可以使用以下代码重新创建它:

echo s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION.

您还可以使用 AWS CLI 的 list-buckets 命令打印出 AWS 帐户中存在的所有存储桶。然而,打印的名称不会使用 s3:// 前缀:

aws s3api list-buckets

list-buckets 命令可以为您提供第二次确认,以确保存储桶已经成功创建。一旦您知道存储桶已经创建成功,将当前工作目录更改为包含列表 2.1 中数据集文件的目录。

接下来,使用 aws s3 sync 命令将数据集文件复制到存储桶中。该命令递归地传输新文件和修改后的文件到或从 S3 存储桶中的位置。在运行时,命令依赖于多个线程来加快传输速度。

使用下面的代码将 CSV 文件从本地工作目录传输到存储桶中的 csv 文件夹:

aws s3 sync . s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/csv/

数据传输所需的时间取决于您可用的带宽。在大多数情况下,您应该预计需要超过 10 分钟,因此这是休息并在传输完成后继续的好时机。

sync 命令完成后,您可以使用 aws s3 ls 命令确认数据集文件存储在存储桶的 csv 文件夹下。与类 Unix 的操作系统一样,S3 中的 ls 命令列出文件夹的内容。试着运行以下命令:

aws s3 ls --recursive --summarize \
--human-readable s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/csv/

注意你已经将 11.2 GiB 的 CSV 文件传输到了对象存储存储桶中。这个传输到存储桶的数据量应该与列表 2.1 的数据集内容大小匹配。

文件上传到对象存储后,可以下载和处理它们。然而,这些数据还不能与非结构化的二进制大对象 (BLOB) 区分开来。为了在 CSV 文件中编目数据结构,您将需要遍历文件并发现数据模式。

2.3 发现数据集的模式

在这一点上,您已在对象存储桶中创建了一个 csv 文件夹,并已将由 11.2 GiB 的 CSV 文件组成的 DC 出租车数据集传输到该文件夹中。在开始对文件进行分析之前,重要的是要识别和了解数据集的架构。虽然可以手动发现数据集的架构,例如,通过搜索 opendata.dc.gov 网站获取架构规范或直接探索 CSV 文件的内容,但自动化方法可以简化和加速架构发现的过程。在本节中,您将了解一个数据爬虫服务,该服务可以帮助您自动发现数据集的架构,以便您更好地跟上数据的架构变化。您还将爬取 DC 出租车数据集的 CSV 文件,并将数据集的架构持久化存储在数据目录中。

2.3.1 介绍 AWS Glue

Glue 是一个包含不同 AWS 服务工具包的总称,您可以使用这些工具包为数据集准备分析。在本书中,您将了解 Glue 数据目录、Glue 提取-转换-加载(数据处理)作业以及用于分布式数据处理的 Glue 库。

Glue 数据目录是一个设计用于存储有关数据资产、数据架构和数据来源的元数据存储库。数据目录由一个或多个数据库组成,这些数据库存在于一起组织一组表。由于 Glue 数据库和表设计用于存储元数据,因此您的项目数据必须存在于 Glue 之外的存储中。例如,Glue 表可以存储存储在对象存储中、关系(例如 MySQL 或 PostgreSQL)或 NoSQL 数据库中的数据的架构。

除了数据架构之外,Glue 表还保留了有关从数据推断架构的时间以及有关数据的一些基本统计信息,例如用于存储数据的对象存储中使用的对象数量、数据中的行数以及对象存储中一行占用的平均空间量。

虽然可以手动在 Glue 数据库中创建表,但在本节中,您将了解如何使用 Glue 爬虫自动创建表。如果您熟悉在网络搜索引擎的上下文中 爬虫 一词,请记住 Glue 爬虫是不同的。它们设计用于处理和分析结构化数据格式,而不是网页。Glue 爬虫是一个过程,它

  1. 建立与结构化数据存储位置的连接(例如,与对象存储桶的连接)

  2. 确定数据使用的格式(例如,CSV)

  3. 分析数据以推断数据架构,包括各种列数据类型,例如整数、浮点数和字符串

爬虫可以被定期调度以定期重新爬取数据,因此,如果您的数据架构随时间变化,下次运行爬虫时,爬虫将能够检测到该变化并更新表中的架构。

要创建一个爬虫,您需要提供一个爬虫配置,该配置指定一个或多个目标,换句话说,指定应该由爬虫处理的存储位置的唯一标识符。此外,AWS 中的爬虫必须假定一个安全角色,以访问爬虫配置目标中的数据。像 AWS 这样的云提供商要求您在应用程序、服务或过程(例如 AWS Glue 爬虫)代表您访问云资源时创建安全身份,称为角色

2.3.2 授权爬虫访问您的对象

在为 DC 出租车数据创建爬虫之前,您应完成列表 2.3 中的步骤,以创建一个名为 AWSGlueServiceRole-dc-taxi 的角色。aws iam create-role 命令(列表 2.3 ❶)创建了一个角色,该角色具有允许 Glue 服务(aws.amazon.com/glue)假定 AWSGlueServiceRole-dc-taxi 安全角色的策略文档。简而言之,策略文档指定 Glue 爬虫应使用 AWSGlueServiceRole-dc-taxi 角色。

列表 2.3 允许 AWS Glue 爬虫访问对象存储桶中的文件

aws iam create-role \
  --path "/service-role/" \
  --role-name AWSGlueServiceRole-dc-taxi            ❶
  --assume-role-policy-document '{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "glue.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}'

aws iam attach-role-policy \                      ❷
  --role-name AWSGlueServiceRole-dc-taxi \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole

aws iam put-role-policy \
  --role-name AWSGlueServiceRole-dc-taxi \        ❸
  --policy-name GlueBucketPolicy \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:*"
            ],
            "Resource": [
                "arn:aws:s3:::dc-taxi-'$BUCKET_ID'-'$AWS_DEFAULT_REGION'/*"
            ]
        }
    ]
}'

❶ 创建名为 AWSGlueServiceRole-dc-taxi 的安全角色。

❷ 将 AWS Glue 策略附加到 AWSGlueServiceRole-dc-taxi 角色。

❸ 为 AWSGlueServiceRole-dc-taxi 分配一个策略文件,以启用对数据集 S3 存储桶的爬取。

aws iam attach-role-policy 命令(列表 2.3 ❷)将 AWS Glue 定义的现有服务角色(AWSGlueServiceRole)附加到 AWSGlueServiceRole-dc-taxi 角色。附加角色确保 AWSGlueServiceRole-dc-taxi 角色可以访问 Glue 数据库和表,并执行与 AWS 资源的其他必需操作。AWSGlueServiceRole 规范的详细信息可从 AWS 文档(mng.bz/XrmY)中获取。

aws iam put-role-policy 命令(列表 2.3 ❸)指定 AWSGlueServiceRole-dc-taxi 角色被允许访问您在本章早些时候创建并填充 DC 出租车 CSV 文件的对象存储桶的内容。

2.3.3 使用爬虫发现数据模式

在这一部分,您将在 Glue 中创建一个数据库和爬虫,配置爬虫以处理 DC 出租车数据,并运行爬虫以填充数据库,其中包含数据架构的表。您可以选择使用 AWS 的浏览器界面来完成这些步骤⁸。然而,本章中的列表 2.4 和即将出现的列表将解释基于 CLI 的命令,以创建一个 Glue 数据库和一个爬虫,并启动爬虫来发现 DC 出租车数据模式。

在列表 2.4 ❶中,aws glue create-database 命令创建了名为 dc_taxi_db 的 Glue 元数据数据库,该数据库将用于存储 DC 出租车数据集的模式以及基于该模式的表。

列表 2.4 创建数据库并确认数据库存在

aws glue create-database --database-input '{    ❶
  "Name": "dc_taxi_db"
}'

aws glue get-database --name 'dc_taxi_db'       ❷

❶ 创建名为dc_taxi_db的数据库。

❷ 确认名为dc_taxi_db的数据库已创建。

由于这是一个过程,Glue 爬虫会循环通过一系列状态。成功创建爬虫后,它会从 READY 状态开始存在。启动后,爬虫转移到 RUNNING 状态。在 RUNNING 状态下,爬虫会建立与爬虫配置中指定的存储位置的连接。根据爬虫配置,爬虫会识别在处理过程中包含或排除的存储位置,并使用这些位置的数据推断数据架构。RUNNING 状态通常是爬虫完成的时间最长的状态,因为爬虫在此状态下进行大部分工作。接下来,爬虫转移到 STOPPING 状态,以使用在过程中发现的架构和其他元数据填充 Glue 数据目录表。假设进程成功完成,爬虫将返回到 READY 状态。

列表 2.5 创建和启动 Glue 爬虫

aws glue create-crawler \
  --name dc-taxi-csv-crawler \                         ❶
  --database-name dc_taxi_db \                         ❷
  --table-prefix dc_taxi_ \                            ❸
  --role $( aws iam get-role \
          --role-name AWSGlueServiceRole-dc-taxi \     ❹
          --query 'Role.Arn' \
          --output text ) \
   --targets '{
  "S3Targets": [                                       ❺
    {
      "Path": "s3://dc-taxi-'$BUCKET_ID'-'$AWS_DEFAULT_REGION'/csv/",
      "Exclusions": ["README*"]                        ❻
    }]
}'

aws glue start-crawler --name dc-taxi-csv-crawler      ❼

❶ 将dc-taxi-csv-crawler用作爬虫名称。

❷ 将爬虫输出存储在dc_taxi_db中。

❸ 爬虫创建的表名应以dc_taxi_前缀开头。

❹ 使用AWSGlueServiceRole-dc-taxi角色进行爬虫操作。

❺ 配置爬虫以爬取数据集存储桶的csv文件夹。

❻ 排除爬虫中的README_DC_Taxicab_trip文档文件。

❼ 启动爬虫。

在列表 2.5 中,使用dc-taxi-csv-crawler❶创建爬虫,并配置将爬取过程中发现的元数据存储在dc_taxi_db数据库中。还配置了爬虫在数据库中创建的任何表的表前缀为dc_taxi_

注意,列表 2.5 中指定的命令❹比本章遇到的其他 shell 命令更复杂。在 bash 中,用 $( ) 字符括起来的命令首先被评估,然后评估的输出用于原始命令中。因此,在 $( ) 中嵌套的aws iam get-role命令用于查找您在列表 2.3 中创建的角色的亚马逊资源名称(Arn)。

在列表 2.5 中,配置爬虫以爬取您上传 DC 出租车数据文件的对象存储桶中的csv文件夹,并注意忽略以❺README前缀❻的对象。

最后,根据第❼条指示使用aws glue start-crawler命令启动dc-taxi-csv-crawler

对于 DC 出租车数据集,爬取过程应该在一分钟左右完成。您可以使用浏览器中的 AWS 管理控制台监视爬虫的状态,或者运行以下命令来打印爬虫的状态:

aws glue get-crawler --name dc-taxi-csv-crawler --query 'Crawler.State' \
--output text

当爬虫正在运行时,状态应为运行中。一旦爬虫完成,它应该变为就绪状态。

注意:要每两秒打印一次爬虫的最新状态,您可以在aws glue get-crawler命令之前输入“watch”。

一旦爬虫返回到 READY 状态,您可以使用以下方法确定爬取是否成功

aws glue get-crawler --name dc-taxi-csv-crawler --query 'Crawler.LastCrawl'

由--query 'Crawler.LastCrawl'参数请求的最后一次爬取详情包括一个状态消息,指示爬虫的最后一次运行是成功还是失败。

假设爬虫成功完成,您可以使用以下方法列出爬虫发现的模式的列名和列数据类型

aws glue get-table --database-name dc_taxi_db --name dc_taxi_csv.

请注意,表名“dc_taxi_csv”是由爬虫根据清单 2.5 中爬虫表前缀的组合和爬取存储桶中 csv 文件夹的名称自动分配的,如清单 2.5 中所示。

请记住,您也可以使用浏览器查看 aws glue get-table 命令打印的模式,方法是导航到 AWS 中的 Glue 服务,选择左侧边栏中的“数据目录 > 数据库 > 表”,然后在右侧单击“dc_taxi_csv”表。

此时,您的项目已经超越了将 DC 出租车数据视为 BLOB 集合的阶段,并为数据的结构创建了更详细的规范,列举了数据列及其数据类型。然而,到目前为止您一直使用的 CSV 数据格式并不适合进行高效和可扩展的分析。在本章的即将到来的部分,您将学习如何修改数据格式以减少分析数据查询的延迟。

迁移到列式存储以进行更高效的分析

在本书的下一章中,您将了解到一种交互式查询服务,该服务可以帮助您使用 Glue 爬虫发现的表和数据模式查询 DC 出租车数据集。然而,正如本节所解释的,针对行式数据存储格式(如 CSV)的分析查询在处理大型数据集时效率低下。虽然您可以立即深入分析 DC 数据集,但本节将首先向您介绍使用列式(列式)数据存储格式(如 Apache Parquet)而不是 CSV 进行分析的好处。在解释了列式格式的优缺点之后,本节的其余部分将涵盖 AWS 的另一个用于使用 PySpark(Apache Spark)进行分布式数据处理的无服务器功能。通过本节的结论,您将学习到一个典型的 PySpark 作业示例,该示例可以帮助您将 CSV 文件重新编码为 Parquet 格式,以便在即将到来的章节中更快、更高效地分析数据集。

引入列式数据格式用于分析

DC 出租车数据集使用的 CSV 数据格式是行定向格式的一个例子。对于 CSV 文件,文件中的每一行存储来自结构化数据集的单个数据行。行定向数据格式(如图 2.2 左侧所示)通常由传统关系型数据库使用,用于存储数据记录序列。行定向格式非常适合关系型数据库的事务工作负载。事务工作负载是对数据的单个行进行操作,并且通常一次仅操作一行。例如,考虑一个存储出租车行程记录的事务性数据库。如果乘客在行程的一半决定更改目的地,事务性数据库可以轻松处理识别关于行程的数据行、更新目的地的纬度和经度坐标,然后将更改保存回数据库。

02-02

图 2.2 行定向存储(左侧)由 CSV 文件和传统关系型数据库使用,设计用于事务处理,使得一次可以更改一行数据。列定向存储(右侧)由 Apache Parquet 和许多现代数据仓库使用,最适合于对不可变数据集进行分析查询。

分析工作负载与事务性工作负载有显著不同。在对数据集执行分析查询时,通常处理数据集中的所有行,例如识别一天中特定小时内的出租车行程或排除费用超过$20 的行程的行。分析查询通常包括聚合函数,这些函数在匹配行上处理一组值,并基于该组计算单个值。聚合函数的示例包括求和、平均值(算术平均值)、最小值和最大值。

要对行定向存储格式中的数据执行分析查询,处理节点需要一次获取并操作一块数据行。例如,考虑一个计算在上午 11:00 至下午 1:00 之间开始的出租车行程的平均持续时间的查询。为了筛选出具有匹配行程时间的行,需要将行块从存储传输到处理节点,尽管块中的大多数数据都与查询无关,例如上车和下车坐标、下车时间等。

除了数据在存储和节点之间转移的不必要长时间之外,行定向格式还浪费了处理器中宝贵的高速缓存内存。由于大多数每行的数据对于执行查询是无用的,因此需要经常不必要地将缓存内容驱逐出去,以替换为另一个数据块。

相比之下,列式数据格式(图 2.2 的右侧)将数据存储在列中而不是行中。大多数现代数据仓库系统使用列式存储,此格式也被像 Apache Parquet[¹⁰] 这样的开源项目采用,以提高分析工作负载的效率。

考虑如何在列式格式中执行分析查询以找到中午行程的平均出租车行程持续时间。要筛选匹配的行程时间,只需将行程开始时间列的数据传输到处理节点。一旦找到具有匹配开始时间的行程,只需获取行程持续时间列的相应条目以计算平均值。

在这两个步骤中,需要传输到处理节点及其缓存的数据量都会有显著的节省。此外,列式格式支持各种编码和压缩方案,将文本数据转换为二进制,以进一步减少数据占用的存储空间[¹¹]。

切记,列式格式并非设计用于事务工作负载。例如 Parquet 等格式所使用的压缩和编码方案相比于简单的文件附加或行特定更改(在 CSV 文件或传统数据库中可能出现的情况)会增加写入操作的延迟。如果你计划采用 Parquet 或其他列式格式,你需要记住这些格式最适用于不可变数据集。例如,DC 出租车行程数据的记录不太可能会更改,这使得 Parquet 成为更高效的存储和更低延迟分析查询的优秀选择。

2.4.2 迁移至基于列的数据格式

正如您在本章早些时候学到的,AWS Glue 包括创建和运行数据处理作业的能力,包括将数据提取转换加载(ETL)到目标存储进行分析的作业。在本节中,您将创建和使用 Glue 中的 ETL 作业将原始的、面向行的、基于 CSV 的 DC 出租车数据集转换为列式 Parquet 格式,并将结果 Parquet 对象加载到您 S3 存储桶中的位置。

可以使用 Python 编程语言实现 Glue 数据处理作业。由于 Glue 是无服务器的,作为一个机器学习从业者,你只需要实现作业并将作业代码提交到 Glue 服务。该服务将负责验证您的代码,确保其可执行,为您的作业提供分布式基础设施,使用基础设施完成作业,并在作业完成后拆除基础设施。

一个将 CSV 数据集转换为 Parquet 格式并将转换后的数据存储为 S3 对象的基于 Python 的作业示例在清单 2.6 中展示。

代码清单中,从文件开头到❶的代码是所需对象和函数的标准库导入。从❶到❷之间的代码是用于实例化 Glue 作业的样板头部,相当于根据作业实例传递的运行时参数对作业进行初始化。

代码中的关键步骤带有❸和❹的注释。❸处使用的 createOrReplaceTempView 方法修改了 Spark 会话的状态,声明了一个临时(非物化)视图,名称为 dc_taxi_csv,可以使用 SQL 语句进行查询。

位于❹的方法执行针对 dc_taxi_csv 视图的 SQL 查询,以便作业可以处理数据集中 CSV 文件的内容并输出一些列,同时将列的内容转换为 DOUBLE 和 STRING 数据类型。

位于❺的作业提交操作仅指示作业将转换的输出持久化到存储中。

清单 2.6 将清单中的代码保存到名为“dctaxi_csv_to_parquet.py”的文件中

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job                                             ❶

args = getResolvedOptions(sys.argv, ['JOB_NAME',
                                     'BUCKET_SRC_PATH',
                                     'BUCKET_DST_PATH',
                                     'DST_VIEW_NAME'])

BUCKET_SRC_PATH = args['BUCKET_SRC_PATH']
BUCKET_DST_PATH = args['BUCKET_DST_PATH']
DST_VIEW_NAME = args['DST_VIEW_NAME']

sc = SparkContext()
glueContext = GlueContext(sc)
logger = glueContext.get_logger()
spark = glueContext.spark_session

job = Job(glueContext)
job.init(args['JOB_NAME'], args)                                        ❷

df = ( spark.read.format("csv")
        .option("header", True)
        .option("inferSchema", True)
        .option("delimiter", "|")
        .load("{}".format(BUCKET_SRC_PATH)) )                           ❸

df.createOrReplaceTempView("{}".format(DST_VIEW_NAME))

query_df = spark.sql("""

 SELECT
    origindatetime_tr,

    CAST(fareamount AS DOUBLE) AS fareamount_double,
    CAST(fareamount AS STRING) AS fareamount_string,

    origin_block_latitude,
    CAST(origin_block_latitude AS STRING) AS origin_block_latitude_string,

    origin_block_longitude,
    CAST(origin_block_longitude AS STRING) AS origin_block_longitude_string,
    destination_block_latitude,
    CAST(destination_block_latitude AS STRING)
      AS destination_block_latitude_string,

    destination_block_longitude,
    CAST(destination_block_longitude AS STRING)
      AS destination_block_longitude_string,

    CAST(mileage AS DOUBLE) AS mileage_double,
    CAST(mileage AS STRING) AS mileage_string

 FROM dc_taxi_csv

""".replace('\n', ''))                                                  ❹

query_df.write.parquet("{}".format(BUCKET_DST_PATH), mode="overwrite")  ❺

job.commit()

❶ 导入 AWS Glue 作业以便稍后管理作业的生命周期。

❷ 检索传递给作业的 JOB_NAME 参数。

❸ 将位于 BUCKET_SRC_PATH 的 CSV 文件读取到 Spark DataFrame 中。

❹ 消除 Spark SQL 兼容性的 Python 多行字符串中的新行。

❺ 使用 Parquet 格式保存到由 BUCKET_DST_PATH 指定的对象存储位置。

请注意,您需要将清单 2.6 的内容保存到名为 dctaxi_csv_ to_parquet.py 的文件中。如清单 2.7 所示,您需要将作业源代码文件上传到 S3 存储桶中的位置,以确保 Glue 服务可以访问它以启动作业。

清单 2.7 将代码上传到项目的存储桶中的 glue/dctaxi_csv_to_parquet.py

aws s3 cp dctaxi_csv_to_parquet.py \
s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/glue/    ❶

aws s3 ls \                                          ❷
s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/glue/dctaxi_csv_to_parquet.py

❶ 将 PySpark 作业文件复制到 S3 存储桶的 Glue 文件夹中。

❷ 确认文件已按预期上传。

您应该期望类似于以下内容的输出,第一列中的时间戳可能不同:

upload: ./dctaxi_csv_to_parquet.py to 
➥ s3://dc-taxi-████████████████████████████████-█████████/glue/
➥ dctaxi_csv_to_parquet.py
2020-04-20 14:58:22       1736 dctaxi_csv_to_parquet.py

上传作业文件后,应按清单 2.8 中所示创建并启动作业。

清单 2.8 创建并启动 dc-taxi-csv-to-parquet-job Glue 作业

aws glue create-job \
  --name dc-taxi-csv-to-parquet-job \
  --role `aws iam get-role \
  --role-name AWSGlueServiceRole-dc-taxi \
  --query 'Role.Arn' \
  --output text` \
  --default-arguments \
   '{"--TempDir":"s3://dc-taxi-'$BUCKET_ID'-'$AWS_DEFAULT_REGION'/glue/"}' \
  --command '{
    "ScriptLocation": "s3://dc-taxi-'$BUCKET_ID'-'$AWS_DEFAULT_REGION'
➥       /glue/dctaxi_csv_to_parquet.py",
    "Name": "glueetl",
    "PythonVersion": "3"
}'

aws glue start-job-run \
  --job-name dc-taxi-csv-to-parquet-job \
  --arguments='--BUCKET_SRC_PATH="'$(
      echo s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/csv/
    )'",
  --BUCKET_DST_PATH="'$(
      echo s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/parquet/
    )'",
  --DST_VIEW_NAME="dc_taxi_csv"'

要监视作业的执行,可以直接使用以下命令,或者在其前面加上 watch 命令:

aws glue get-job-runs --job-name dc-taxi-csv-to-parquet-job \
--query 'JobRuns[0].JobRunState'

作业成功后,您可以使用以下命令列出存储桶中 parquet 文件夹的内容

aws s3 ls --recursive --summarize --human-readable \
s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/parquet/

...
Total Objects: 99
   Total Size: 940.7 MiB

并确认由于转换为 Parquet 格式而引起的压缩将数据大小从以行为导向格式存储的 11.2 GiB CSV 数据减少到 940.7 MiB。

然后,您可以在 Glue 数据目录中创建一个新表,并让该表描述以 Apache Parquet 格式存储的新创建数据。使用清单 2.5 中的方法,做一些更改,包括以下内容:

  1. 将爬虫重命名为 dc-taxi-parquet-crawler❶,❸,❹

  2. 将存储桶位置更改为使用 parquet 文件夹❷

  3. 删除排除选项,因为 Parquet 格式的数据不包括 README 文件❷

aws glue create-crawler \                      ❶
--name dc-taxi-parquet-crawler \
--database-name dc_taxi_db \
--table-prefix dc_taxi_ \
--role `aws iam get-role --role-name AWSGlueServiceRole-dc-taxi 
➥   --query 'Role.Arn' --output text` --targets '{
  "S3Targets": [                               ❷
    {
      "Path": "s3://dc-taxi-'$BUCKET_ID'-'$AWS_DEFAULT_REGION'/parquet/"
    }]
}'

aws glue start-crawler \                       ❸
--name dc-taxi-parquet-crawler

aws glue get-crawler --name dc-taxi-parquet-crawler --query 'Crawler.State'\
 --output text                                 ❹

❶ 创建 dc-taxi-parquet-crawler 爬虫实例。

❷ 爬取包含转换后数据集的 S3 存储桶的 parquet 子文件夹。

❸ 启动爬虫。

❹ 获取当前爬虫状态。

你可以确认从 CSV 到 Parquet 的数据转换是否生成了新的 Glue 表。如果你执行

aws glue get-table --database-name dc_taxi_db --name dc_taxi_parquet

然后,输出应该类似于当你针对 dc_taxi_csv 表运行 aws glue get-table 命令时的结果,唯一的区别是 Parameters.classification 键的值发生了变化。该值应该从 csv 变为 parquet。

总结

  • 使用机器学习方法建立出租车费估算服务可以帮助你降低运营成本,并避免硬编码特定城市的商业规则。

  • 你将使用一个公开的华盛顿特区出租车行程数据集来学习如何使用无服务器机器学习构建出租车费估算 API。

  • 无服务器对象存储服务(如 S3)可以帮助你将管理文件系统上的文件数据的知识应用于将大型数据集(从千兆字节到拍字节的数据)作为对象存储在对象存储中。

  • AWS Glue 数据爬虫可以帮助你发现数据的模式,无论你的数据在文件系统、对象存储还是关系数据库中。

  • AWS Glue 提取-转换-加载(ETL)作业服务可以帮助你在不同存储位置之间移动数据,并在过程中对数据进行转换,为分析做好准备。

  • 列式数据格式可以提高分析查询的数据处理效率。

^(1.)2018 年 1 月 archive.org 的出租车费快照:mng.bz/6m0G

^(2.) 模式数据类型使用 ANSI SQL 格式来说明。

^(3.) 时间戳以月/日/年 小时:分钟的格式存储为字符串。

^(4.) 2015 年至 2019 年期间华盛顿特区出租车行程目录:mng.bz/o8nN

^(5.)2015 年至 2019 年期间华盛顿特区出租车行程目录:mng.bz/o8nN

^(6.) 采用独立磁盘冗余阵列来确保服务器硬件中的数据冗余: mng.bz/v4ax

^(7.)Amazon 资源名称(ARN)是 AWS 专用的、全球唯一的资源标识符,包括 AWS 中的用户 ID 和账户。你可以从 mng.bz/4KGB 了解更多关于 ARN 的信息。

^(8.)AWS Glue 用户界面可从 console.aws.amazon.com/glue 访问。

^(9.)AWS Glue 用户界面可从 console.aws.amazon.com/glue 访问。

^(10.)Apache Parquet 是一种开放源代码的列式数据存储格式,由 Twitter 和 Cloudera 合作开发,由 Apache 软件基金会项目维护。你可以从 github.com/apache/parquet-mr 了解更多关于该格式的信息。

Apache Parquet 列式存储格式使用的编码和压缩方案示例可从mng.bz/yJpJ获取。

第三章:探索和准备数据集

本章内容包括

  • 使用 AWS Athena 进行互动查询入门

  • 在手动指定数据模式和发现数据模式之间进行选择

  • 使用 VACUUM 规范原则处理数据质量

  • 通过互动查询分析 DC 出租车数据质量

  • 在 PySpark 中实现数据质量处理

在上一章中,将 DC 出租车数据集导入 AWS,并将其存储在项目的 S3 对象存储桶中。您创建、配置并运行了一个 AWS Glue 数据目录爬虫,分析了数据集并发现了其数据模式。您还学习了基于列的数据存储格式(例如 Apache Parquet)及其在分析工作负载中相对于基于行的格式的优势。在章节的结尾,您使用在 AWS Glue 上运行的 PySpark 作业将 DC 出租车数据集的原始基于行的逗号分隔值(CSV)格式转换为 Parquet,并将其存储在 S3 存储桶中。

在本章中,您将学习关于 Athena 的内容,这是 AWS 的另一个无服务器功能,将使用标准查询语言(SQL)对 DC 出租车出行数据集进行分析将证明其价值。您将使用 Athena 开始探索性数据分析(EDA),并识别数据集中存在的一些数据质量问题。接下来,您将了解 VACUUM,这是一个关于数据清理和数据质量的一组规范原则的缩写词,用于有效的机器学习。遵循 VACUUM 原则,您将探索 DC 出租车数据集中存在的数据质量问题,并学习使用 Athena 来重复和可靠地对整个 DC 出租车数据集的子集进行抽样分析。最后,您将实现一个 PySpark 作业,创建一个干净且可以进行分析的数据集版本。

此外,您将学习有关表格数据集数据质量的基础知识,并在有效的机器学习项目中进行实践,这是一个重要的方面。在处理数据质量时,您将了解机器学习数据质量背后的原则,以及如何在机器学习平台上使用 SQL 和 PySpark 应用它们。

3.1 进行互动查询的入门

本节首先概述了数据查询的用例,与第二章中用于将 CSV 转换为 Parquet 的数据处理作业相对应。然后,当您介绍 AWS 的交互式查询服务 Athena 时,您将了解使用模式读取方法进行结构化数据查询的优缺点,并准备尝试使用示例出租车数据集,并将替代方案应用于该数据集。到本节结束时,您将准备好使用 AWS Athena 的基于浏览器的界面,并探索 DC 出租车车费数据集中的数据质量问题。在本节实现中,您将开始掌握对 DC 出租车车费数据集的探索性数据分析所需的技能,并开始练习在改进数据质量时有用的查询类型。

3.1.1 选择交互式查询的正确用例

本节澄清了 I/O 密集型和计算密集型工作负载之间的区别,以及如何从 AWS Glue、AWS Athena、Google Cloud DataProc 或 Google Cloud BigQuery 等技术中选择这两类工作负载。

要对何时使用交互式查询服务有直觉,首先要介绍数据处理中高吞吐量与低延迟的区别是很有价值的。请记住,既可以使用面向行的格式(如 CSV),也可以使用面向列的格式(如 Parquet)来存储结构化数据集,这些数据集被组织成行和列的表。本书使用术语记录来描述来自结构化数据集的单个数据行。将数据集描述为记录而不是行有助于避免混淆,即数据是存储在行向或列向格式中。换句话说,记录独立于底层数据存储格式。

在第二章中,您使用托管在 AWS Glue 上的 PySpark 作业执行了一个高吞吐量的工作负载,以将数据记录从 CSV 迁移到 Parquet。高吞吐量工作负载的一个特点是输入和输出记录之间的 一对多(有时是 一对任意)关系:用作工作负载输入的单个记录可能产生零个、一个或多个输出记录。例如,一个以 SELECT * 开头的简单 SQL 语句会为数据存储中的每个输入记录返回一个输出记录,带有 WHERE 子句的 SELECT 可以过滤一部分记录,而涉及 SELF JOIN 的更复杂的 SQL 语句可以将从表中返回的记录总数平方。在实践中,一对多关系意味着输出记录的数量与输入记录的数量具有相同的数量级,并且与输入记录的数量没有实质性的不同。这样的工作负载也可以描述为输入/输出密集型,因为执行工作负载的底层 IT 基础设施花费的时间用于读取和写入存储,而不是计算。

在第二章开始执行 PySpark 作业时,您可能已经注意到 CSV 到 Parquet 的重新编码工作量需要大约几分钟才能完成。工作量的高延迟(这里的延迟描述了 Glue 作业从开始到结束的持续时间)是由于为每个 CSV 输入记录写入 Parquet 输出记录引起的。工作量的高吞吐量描述了以输入和输出记录的数量之和为总量来处理的记录的总数量。由于处理输入和输出记录所花费的时间占此类工作负载总持续时间的相当大比例,因此它们也被描述为输入/输出(I/O)密集型。

与专为高吞吐量工作负载设计的 AWS Glue 相比,AWS 和 Google 的交互式查询服务(如 AWS Athena 和 BigQuery)旨在处理低延迟的多对一(或多对少)工作负载,其中许多输入记录(考虑表中的所有记录的大多数)被聚合到少数(或通常仅一个)输出记录中。多对一工作负载的示例包括使用 COUNT、SUM 或 AVG 等函数以及与 SQL GROUP BY 子句一起使用的其他聚合函数的 SQL 语句。通过使用 SQL 操作识别列的唯一值集合,多对少工作负载在 SELECT DISTINCT 时很常见。多对一和多对少工作负载也可以描述为计算密集型,因为底层 IT 基础设施花费更多时间执行计算(例如,计算算术平均值)而不是输入/输出操作(例如,读取或写入数据)。

3.1.2 介绍 AWS Athena

本节概述了 AWS Athena 交互式查询服务,并描述了 Athena 如何应用基于读取的模式来进行数据处理和分析。

Athena 是 AWS 的无服务器查询服务,主要用于使用 ANSI SQL 和 SQL 扩展对结构化和半结构化数据进行交互式分析。交互式分析意味着 Athena 被设计用于执行计算密集型 SQL 工作负载,并在几秒内返回结果。这也意味着,虽然可以使用 Athena 提取、转换和加载(ETL)数据,但你应该计划使用 PySpark 而不是 Athena 编写 ETL 代码,以支持高吞吐量而不是低延迟的数据处理。如果你曾经使用过关系数据库(如 MySQL 或 PostgreSQL)的交互式查询界面,你就知道 Athena 提供了类似的功能。尽管 Athena 面向通过基于浏览器的界面进行交互式分析的最终用户,但也支持基于 API 的访问。作为查询服务,Athena 在以下重要方面与传统的关系数据库和数据仓库有所不同:

  • Athena 依赖 AWS 服务进行数据存储,并不存储查询的源数据或元数据。例如,Athena 可以查询来自 S3 的数据集,以及来自 MySQL、DynamoDB 或其他提供 Athena 数据源连接器的数据源。当数据作为查询结果生成时,Athena 将数据存储到预配置的 S3 存储桶中。

  • Athena 软件基于 Facebook 工程师部分开发的开源 PrestoDB 分布式查询引擎。该实现已经被证明可以扩展到 Facebook 内部的工作负载,涉及对数百 PB 数据的查询。

  • Athena 不使用传统关系型数据仓库的写入时模式。这意味着 Athena 可以根据互斥的模式定义解释相同的数据;例如,Athena 可以将相同数据值的列查询为字符串或数字。这种方法通常被描述为读取时模式

在第二章中,你学习了如何使用网络爬虫从数据中发现数据模式,并学习了如何根据发现的模式在数据目录中创建数据库和表。Athena 要求在服务查询由表描述的数据之前,必须在数据目录中定义表。如图 3.1 上的虚线所示,Athena 可用于为存储在数据存储服务中的数据定义模式,例如 S3。或者,Athena 可以查询根据爬虫发现的元数据定义的表。

03-01

图 3.1 Athena 查询服务既可以定义模式,也可以使用由 Glue 爬虫定义的模式来分析数据,使用相同数据集的替代模式定义。替代且互斥的模式可以帮助您为特定用例应用正确的模式。

依赖 Athena 为数据目录中的表定义模式既有优势也有劣势。由于许多存储在数据仓库中并用于机器学习的真实世界数据集是宽的(包含许多列),因此使用 Athena 定义表模式意味着需要为模式中的每一列显式指定 SQL 数据类型所需的工作量。虽然看起来工作量是有限的,但请记住,模式定义需要在基础数据集发生变化时进行维护和更新。然而,如果你需要能够使用包含互斥数据类型的数据模式查询相同的数据,那么使用 Athena 定义模式就是正确的选择。

相比之下,如果你使用基于爬虫的模式定义方法,你不需要显式指定数据类型,因为它们会被爬虫自动发现。爬虫还可以定期运行,根据数据的变化更新模式定义。使用爬虫的缺点在于,当你需要使用与自动发现的模式不同的替代数据模式来查询数据时,它就显得不那么相关了。在基于爬虫的方法中,这意味着要么使用 Athena 定义替代模式,要么实施一个 PySpark 作业,将替代模式应用于数据集。请记住,在第二章结束时你实施的 PySpark 作业重新编码了 STRING 数据类型(例如,对于车费金额)为 DOUBLE。

准备示例数据集

在本节中,你将开始使用一个小型 CSV 数据集,以更好地了解依赖 Athena 为其定义模式的优缺点。在数据集中,行包含表示出租车行程费用以及行程上车和下车位置的纬度和经度坐标的值。

要开始使用 Athena 查询此小型数据集,您需要首先将相应的 CSV 文件上传到您 S3 存储桶的一个文件夹中,该文件夹只包含五行数据。

在您本地文件系统上创建一个名为 trips_sample.csv 的 CSV 文件,并通过执行以下 bash 命令预览它:

wget -q https://gist.githubusercontent.com/osipov/ 
➥ 1fc0265f8f829d9d9eee8393657423a9/ 
➥ raw/9957c1f09cdfa64f8b8d89cfec532a0e150d5178/trips_sample.csv

ls -ltr trips_sample.csv

cat trips_sample.csv

假设 bash 命令成功执行,则 cat 的输出应该产生类似于表 3.1 的输出。

表 3.1 本数据集中数据值的类型解释²取决于你选择的模式。

车费金额 起点 终点
纬度 经度 纬度
8.11 38.900769 −77.033644
5.95 38.912609 −77.030788
7.57 38.900773 −77.03655
11.61 38.892101 −77.044208
4.87 38.899615 −76.980387

接下来,将文件内容复制到您 S3 对象存储桶中的 samples 文件夹中,并通过运行以下命令确认它已成功复制:

aws s3 cp trips_sample.csv s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION
➥ /samples/trips_sample.csv

aws s3 ls s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/samples/
➥ trips_sample.csv

如果您正确上传了示例文件,则aws s3 ls命令的输出应该报告其大小为 378 字节。

3.1.4 使用浏览器从 Athena 进行交互式查询

本节介绍了 AWS Athena 的基于浏览器的图形用户界面(GUI)。虽然可以使用 Athena GUI 执行本章中使用的查询,但使用基于命令行界面(CLI)的 Athena API 访问可以更直观地演示数据分析自动化和可重现性,而不是使用浏览器。因此,虽然本节涵盖了如何使用基于浏览器的界面,但后续章节将专注于脚本化基于 CLI 的查询。

要从浏览器访问 Athena 界面,请使用 AWS 控制台顶部菜单中的 AWS 服务下拉菜单导航到 Athena 服务。您应该能够点击到类似于图 3.2 所示的屏幕。

03-02

图 3.2 显示了 Athena 基于浏览器的界面的屏幕截图,说明了您需要了解的交互式查询的关键组件。

请注意,在 Athena 界面屏幕上,您需要确保您正在访问的 Athena 与您的 $AWS_DEFAULT_REGION 环境变量的值匹配的地区,也就是您上传 CSV 文件的地区。与其他 AWS 服务一样,您可以使用 AWS 控制台顶部菜单中右上角的下拉菜单更改地区。

图 3.2 中高亮显示的选择项 1 指定了您在第二章中创建的数据目录数据库。确保您已选择 dc_taxi_db 作为数据库。选择数据库后,请确认在高亮显示的选择项 2 中,您可以看到您在 dc_taxi_db 数据库中使用爬虫创建的表。表应命名为 dc_taxi_csv 和 dc_taxi_parquet。

Athena 的 SQL 查询使用图 3.2 中高亮显示的分栏 SQL 编辑器指定。如果这是您第一次使用 Athena,请在运行查询之前为服务指定查询结果位置。默认情况下,Athena 执行的每个查询的输出都保存到 S3 中的查询结果位置。执行以下 bash shell 命令并将输出复制到剪贴板:

echo s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/athena/

从 shell 命令的输出中注意到,Athena 将查询位置结果存储到您的存储桶中的 athena 文件夹中。

在您运行第一个查询之前,您应该配置 S3 查询结果位置,首先点击图 3.2 中屏幕截图上部显示的“设置查询结果位置”超链接,然后将您刚刚复制到剪贴板的值粘贴到对话框中的结果位置文本字段中,最后点击保存。

3.1.5 使用示例数据集进行交互式查询

本节将解释如何使用 trips_sample.csv 文件中的少量记录在 Athena 中应用基于读取的模式。在后续章节中,您将能够将相同的技术应用于更大的数据集。

由于接下来的 Athena 示例依赖于使用脚本化的基于 CLI 的 Athena API 访问,请从配置 Athena 开始,将 Athena 文件夹配置为您 S3 存储桶中用于存储 Athena 查询结果的位置。 这意味着您应该从您的 shell 执行以下操作:

aws athena create-work-group --name dc_taxi_athena_workgroup \
--configuration "ResultConfiguration={OutputLocation=
➥ s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/athena},
➥ EnforceWorkGroupConfiguration=false,PublishCloudWatchMetricsEnabled=false"

创建完 dc_taxi_athena_workgroup 后,您可以通过 CLI 开始使用 Athena。

由于 Athena 与 Glue 数据目录集成,因此可以在数据读取时(即查询数据时)应用数据目录表的数据库和模式定义,而不是在数据写入时。 然而,为了说明 Athena 的模式读取功能,而不是使用爬虫来发现五个样本行程的表模式,您将首先使用手动定义的模式填充数据目录。 您将创建的第一个表将所有的数据值都视为 STRING 数据类型,如列表 3.1 中所示。 后来,您将创建一个将相同值视为 DOUBLE 的第二个表。

列表 3.1 使用 STRING 类型为五个 DC 行程数据集定义模式

CREATE EXTERNAL TABLE IF NOT EXISTS dc_taxi_db.dc_taxi_csv_sample_strings(
        fareamount STRING,
        origin_block_latitude STRING,
        origin_block_longitude STRING,
        destination_block_latitude STRING,
        destination_block_longitude STRING
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION 's3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/samples/'
TBLPROPERTIES ('skip.header.line.count'='1');

使用列表 3.1 中的 SQL 语句定义 dc_taxi_db.dc_taxi_csv_sample_strings 表的模式,请从您的 bash shell 执行以下命令序列。

列表 3.2 使用 AWS CLI 对 AWS Athena 进行基于 Shell 的查询

SQL="                                                     ❶
CREATE EXTERNAL TABLE IF NOT EXISTS dc_taxi_db.dc_taxi_csv_sample_strings(
        fareamount STRING,
        origin_block_latitude STRING,
        origin_block_longitude STRING,
        destination_block_latitude STRING,
        destination_block_longitude STRING
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION 's3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/samples/'
TBLPROPERTIES ('skip.header.line.count'='1');"

ATHENA_QUERY_ID=$(aws athena start-query-execution \
--work-group dc_taxi_athena_workgroup \
--query 'QueryExecutionId' \
--output text \
--query-string "$SQL")                                   ❷

echo $SQL

echo $ATHENA_QUERY_ID
until aws athena get-query-execution \                   ❸
  --query 'QueryExecution.Status.State' \
  --output text \
  --query-execution-id $ATHENA_QUERY_ID | grep -v "RUNNING";
do
  printf '.'
  sleep 1;
done

❶ 将基于字符串的模式定义保存到 SQL shell 变量中。

❷ 根据 SQL 变量的内容启动 Athena 查询。

❸ 反复检查并报告 Athena 查询是否完成。

到目前为止,根据您使用 SQL 查询关系数据库的经验,您可能会尝试使用以 SELECT * 开头的 SQL 语句来查询 dc_taxi_csv_sample_strings 表。 然而,在处理列式数据存储时,尽可能避免使用 SELECT * 是更好的选择。 正如您在第二章中学到的,列式存储在多个文件以及文件的不同部分中维护数据的各个列。 通过仅指定您查询所需的列的名称,您可以将像 Athena 这样的列感知查询引擎指向仅处理您需要的数据部分,从而减少处理的数据总量。 对于 Athena 以及其他公共云供应商的无服务器查询服务,处理的数据量越少,成本就越低。 由于 Athena 是无服务器的,因此您按照 Athena 查询处理的数据量来计费。

此外,列表 3.2 中的脚本相当冗长。 为了保持本章中查询示例的简洁性,请继续下载 utils.sh 脚本:

wget -q https://raw.githubusercontent.com/osipov/smlbook/master/utils.sh
ls -l utils.sh

下载完成后,脚本将占用文件系统的 4,776 字节。 这个脚本在接下来的示例中使用 source utils.sh 命令加载,并通过向 athena_query_to_table 函数传递 Athena 的 SQL 查询来调用。

当 Athena 使用您刚刚创建的 dc_taxi_csv_sample_ strings 表的方案查询数据时,数据被处理为将纬度和经度坐标解释为字符串数据类型。将坐标值视为字符串类型可在将坐标对传递给网页脚本以在浏览器中显示交互式映射上的锥标时,非常有用。请注意,以下查询不涉及任何数据类型转换,因为数据是由 Athena 从源 CSV 数据作为 STRING 读取的。因此,可以直接在数据值上使用 ANSI SQL ||(双竖杠)操作来执行连接操作。

列出 3.3 使用 STRING 数据类型为坐标简化基于浏览器的用例

source utils.sh
SQL="
SELECT

origin_block_latitude || ' , ' || origin_block_longitude
    AS origin,

destination_block_latitude || '  , ' || destination_block_longitude
    AS destination

FROM
    dc_taxi_db.dc_taxi_csv_sample_strings
"
athena_query_to_table "$SQL" \
"ResultSet.Rows[*].[Data[0].VarCharValue,Data[1].VarCharValue]"

这导致一个类似于以下内容的输出,其中每行都包含字符串数据类型,将纬度和经度值连接在一起:

origin destination
38.900769,–77.033644 38.912239,–77.036514
38.912609,–77.030788 38.906445,–77.023978
38.900773,–77.03655 38.896131,–77.024975
38.892101000000004,–77.044208 38.905969,–77.06564399999999
38.899615000000004,–76.980387 38.900638,–76.97023

或者,Athena 可以使用不同的架构,把相同的坐标值作为浮点数据类型来处理,以计算数据集中最大和最小车费之间的差异。从 shell 中执行下面的 Athena 操作,以创建 dc_taxi_csv_sample_double 表,其中 trips_sample.csv 文件中的每个值都被解释为 SQL DOUBLE:

%%bash
source utils.sh ; athena_query "
CREATE EXTERNAL TABLE IF NOT EXISTS dc_taxi_db.dc_taxi_csv_sample_double(
        fareamount DOUBLE,
        origin_block_latitude DOUBLE,
        origin_block_longitude DOUBLE,
        destination_block_latitude DOUBLE,
        destination_block_longitude DOUBLE
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
LOCATION 's3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/samples/'
TBLPROPERTIES ('skip.header.line.count'='1');
"

dc_taxi_csv_sample_double 表可以成为查询的数据源之后,您可以尝试处理源数据文件中的值作为双精度浮点数,例如,通过尝试查找五行数据集中的最大和最小车费之间的差异:

source utils.sh ; athena_query_to_pandas "
SELECT ROUND(MAX(fareamount) - MIN(fareamount), 2)
FROM dc_taxi_db.dc_taxi_csv_sample_double
"

列出中的 athena_query_to_pandas 函数将 Athena 查询的输出保存到文件系统上的临时/tmp/awscli.json 文件中。首先,按照下面的列表所示定义 Python 实用程序函数。

列出 3.4 报告 Athena 结果为 pandas DataFrame

import pandas as pd
def awscli_to_df():
  json_df = pd.read_json('/tmp/awscli.json')
  df = pd.DataFrame(json_df[0].tolist(), index = json_df.index, \
    columns = json_df[0].tolist()[0]).drop(0, axis = 0)

  return df

然后,您可以方便地将 tmp/awscli.json 文件的内容预览为 pandas DataFrame,以便调用 awscli_to_df() 输出以下结果:

_col0
6.74

输出显示,数据集中的出租车费用的最大值和最小值之间存在 $6.74 的差异。此外,由于最后一个查询未使用 AS 关键字为结果中唯一列分配名称,因此 Athena 使用了自动生成的列名称 _col0。

3.1.6 查询 DC 出租车数据集

本节介绍如何使用 AWS Athena 查询 DC 出租车数据集,以便在即将到来的部分中,您可以准备分析 DC 出租车数据的质量。

正如你在第二章中回忆起的那样,DC 出租车数据的 Parquet 格式版本被存储为 dc_taxi_db 数据库中的 dc_taxi_parquet 表。让我们尝试使用 Athena CLI 查询这个表的 10 行数据:

source utils.sh ; athena_query_to_pandas "
SELECT fareamount_double,
         origindatetime_tr,
         origin_block_latitude_double,
         origin_block_longitude_double,
         destination_block_latitude_double,
         destination_block_longitude_double
FROM dc_taxi_db.dc_taxi_parquet
LIMIT 10
"

不要忘记使用 awscli_to_df()函数使用 pandas 输出结果。

由于 Athena 执行的数据处理是并行和分布式的,所以 dc_taxi_parquet 表中的行顺序在每次执行最后一个查询时都会不同。因此,你将看到的查询结果中的 10 行与我的不同。然而,即使只有 10 行的结果,你也应该能够找到包含缺失值的行。缺失值将出现在一个或多个列中的空单元格或 None 值中。

例如,你可能会发现你的输出缺少起点的数值,但目的地坐标没有缺失。在某些情况下,结果中除了车费和行程起点的日期/时间值之外,其他值都会缺失。导入的 DC 出租车行程数据集存在数据质量问题。

虽然在第二章将 DC 出租车数据转换为 Parquet 格式有助于优化查询和分析性能,但你尚未对数据集进行任何质量检查。简而言之,你不知道可用的数据是否可信。解决这些质量问题意味着什么?应该或不应该修复哪些问题?你应该花多少精力清理数据?清理后的数据集在数据分析和机器学习方面何时达到足够好的质量?

3.2 开始进行数据质量测试

本章的这一部分与其他部分的写作方式不同。虽然大部分章节关注于技术知识和详细指导,以特定步骤的形式与无服务器机器学习技术一起使用,但本部分是规范化的而不是指导性的。换句话说,你首先应该了解机器学习中的数据质量应该是怎样的,然后学习将数据质量应用于机器学习数据集的步骤。我希望通过这一部分教会你应该在任何机器学习项目中使用的数据质量标准,无论数据集如何,因此本节主要涉及概念而不是代码。

可以说,数据清理是机器学习中重要但并非最令人兴奋的话题,为了将数据质量原则更加具体、容易记住,以及希望更有趣,这一部分主要依赖于实际案例和数据清理示例,你可以应用到下一个机器学习项目中。如果你愿意直接进入清理 DC 出租车数据的实际步骤,可以直接跳到 3.3 节。

3.2.1 从“垃圾进垃圾出”到数据质量

本小节说明了解决数据质量问题的理由,并描述了本章后面部分回答的数据质量问题。

“垃圾进,垃圾出”是信息技术行业中众所周知的陈词滥调。在本书的背景下,它意味着如果输入到您的机器学习系统中的是垃圾,那么机器学习算法将会在垃圾上进行训练,机器学习的输出也将是垃圾。这个陈词强调了对机器学习项目的数据质量的重要性,但它并没有证明垃圾进,垃圾出对于现实世界的数据分析和机器学习是至关重要或相关的。

2010 年,当全球经济仍在从几年前的金融危机中恢复时,两位哈佛经济学家卡门·M·莱因哈特和肯尼斯·S·罗戈夫发表了一篇研究论文,解构了可以帮助国家重新实现经济增长的政策。在这篇论文中,经济学家们认为,债务超过其国内生产总值(GDP)90% 的国家将面临经济衰退。部分基于这些经济学家的分析,一些欧盟(EU)国家采取了严厉的紧缩政策,削减工资并裁减了数千个工作岗位。结果证明,用于分析的数据是错误的。

政客们基于莱因哈特-罗戈夫(Reinhart-Rogoff)的结果制定政策,成为经典的垃圾进垃圾出问题的受害者。莱因哈特-罗戈夫惨败只是许多情况之一,其中低质量数据的分析导致数十亿美元的负面后果。即使在 COVID-19 疫情加速数字转型之前,备受尊敬的哈佛商业评论杂志也发表了一个引人注目的说法,即美国经济因糟糕数据而产生的总成本应该以数万亿美元来衡量。⁴

数据质量问题很重要,但作为一名机器学习从业者,你可能不会立即意识到自己正在使用低质量的数据集。你如何知道你的数据是垃圾还是足够质量以进行机器学习?

3.2.2 在开始处理数据质量之前

本小节帮助您了解在解决其中任何一个结构化(表格)数据集的数据质量问题之前应该回答的问题。

在开始处理数据质量之前,你需要的不仅仅是一个结构化数据集。你需要知道关于 DC 出租车数据的那些问题的答案:

  • 数据集是否可以查询为一个或多个行列表? 换句话说,你是否正在查询使用结构化数据集格式存储的数据?回想一下,在第二章中,你了解了用于结构化数据的行向(例如 CSV)和列向(例如 Apache Parquet)存储格式的定义。由于 VACUUM 是用于结构化数据集的一套数据质量原则,因此它不适用于用于自然语言文本、图像、音频和视频的非结构化格式。

  • 你需要基于哪些列回答哪些问题? 本书中基于 DC 出租车数据集的机器学习示例围绕着一个问题构建:“在你知道 DC 出租车行程的开始时间以及行程的上车和下车地点的纬度和经度坐标时,车费金额列的值是多少?” 知道你希望对数据提出的问题也有助于你了解数据集中的基本数据,换句话说,是用于训练机器学习系统以回答问题的数据范围。除了基本数据外,你的数据集还可能包含参考数据,这些数据对于确保你的基本数据的质量(特别是准确性)是有用的,但不需要以相同严格的程度进行清理。例如,DC 出租车数据集中里程表列中的值并不是回答问题所必需的,但作为参考来与车费金额列的值进行比较,并确保车费金额值具有正确的数据质量程度是有用的。

  • 基本数据的模式是什么? 在你开始清理数据集之前,你需要在目录中创建一个数据模式,其中包含确保数据值使用适当的数据类型和约束进行指定的更改。数据模式指定了数据集的每一列的数据类型。然而,虽然数据类型规范对于模式来帮助确保数据质量是必要的,但它们并不足够。对于每种数据类型,你还应该能够指定它是否是可为空的。在这里,数据类型的可为空性等同于 DDL(数据定义语言)的可为空性,并指示值是否允许缺失。你还应该指定任何进一步限制可能值范围的约束:对于字符串类型,这些可以包括正则表达式,而对于整数类型,这些可以使用区间范围来指定。关于有效数据的部分使用实际示例说明了约束。

在上一章中,你使用了一个爬虫和一个数据目录来发现并存储 DC 出租车数据集的发现数据模式。目录中的模式类似于描述数据类型(如整数、浮点数、时间戳等)的 DDL 模式(SQL 标准的一部分)。请记住,发现的模式可能是正确的模式,也可能不是正确的模式。

那么什么是正确的模式呢?更准确地说,模式由适用于数据集的值的数据类型组成,这意味着什么?就像 DDL 模式一样,选择适当的数据类型是一种权衡考虑。一方面,模式应该使用足够通用的数据类型,以便保留数据值而不丢失信息。另一方面,数据类型应该支持数据值的预期操作(无需类型转换),同时高效利用存储空间。例如,DC 出租车数据集中的纬度和经度坐标应该在模式中指定为浮点数值(DOUBLE 数据类型),而不是 Unicode 字符串,以便坐标值可以用于距离计算。

3.2.3 数据质量的规范性原则

本节介绍了结构化数据质量的有效、准确、一致、统一和完整模型(VACUUM)背后的原则,以及作为案例研究的教训。这些原则是规范性的,意味着它们定义了数据质量应该是什么样子,而不是规定数据质量处理的具体步骤或代码实现。这些原则的价值在于通过充分且严谨地定义,为符合 VACUUM 标准的数据提供足够“干净”且能够用于机器学习的准备。

将 VACUUM 原则视为数据质量的一份指南、标准或度量的清单,作为机器学习项目的一部分进行探索。要记住,医生和飞行员(以及许多其他专业人士)使用清单,但拥有清单并不会使您成为飞行员或医生。如果您计划在数据质量方面开发专业技能,您需要培养数据清理的技能。一旦您具备了正确的经验,清单可以帮助您复习并确保不会错过重要的数据质量方面。

有效

2020 年 1 月 31 日,英国脱离了欧盟。那么,一个欧盟数据仓库是否应该将字符串值“United Kingdom”作为列名中的有效值存储起来?

您可以争辩说,从 2020 年 2 月 1 日开始,“United Kingdom”不应再是提到欧盟成员国的任何列中的有效数据值。然而,这种方法是适得其反的:排除“United Kingdom”作为有效值集合的一部分意味着与该列相关的任何历史数据(换句话说,任何日期早于 2020 年 2 月 1 日的记录)都与无效的值相关联。如果数据集中的某个值在其存在的任何时间点都是有效的,那么它应该保持有效。

注意 该定义没有指明是否多个列的多个有效值的组合是有效的;这个问题将在准确性部分的即将到来的章节中解决。

更准确地说,列中的数据值如果满足以下条件,则为有效

  • 与模式指定的列数据类型匹配。对于数据值而言,要有效必须与模式指定的数据类型匹配。模式中基于 SQL 的数据类型定义可能包括以下内容:

    • INTEGER(例如,存储电梯楼层编号的列)

    • DOUBLE(例如,点击网站上的订阅按钮的用户百分比)

    • TIMESTAMP(例如,网站上下订单的时间)

    • BOOLEAN(例如,出租车行程是否在机场结束)

    • STRING(例如,在调查的评论框中留下的评论文本)

  • 匹配一个或多个以下约束

    • 可空性 —此约束适用于任何数据类型,并指定数据列中的值是否允许具有 NULL 值。例如,在驾驶执照数据库中存储出生日期的 TIMESTAMP 数据值必须是非可空的(即,不应允许具有 NULL 值),而客户配置文件网页上的用户 Twitter 用户名可以指定为可空,以处理用户名未知或未指定的情况。可空数据类型还可以包括 INTEGER(例如,乘客对出租车行程的评分,评分范围为 1—5,NULL 值表示没有评分)和其他数据类型。

    • 枚举 —此约束适用于任何数据类型,并指定了数据类型的验证集、字典或有效值的枚举。对于 STRING 值,枚举可能包括美国州名或纽约市区域的主要机场名称,如 LGA、JFK、EWR。模式的枚举约束可以为电话号码数据集中的国家代码列指定 INTEGER 数据类型,并使用有效国家电话代码的枚举。请从本节开头的示例中回忆,枚举必须包括对该列曾经有效的所有值。因此,在 2020 年 2 月 1 日之前的任何数据集中,存储 EU 国家名称的数据列中,英国是一个有效值,而不管英国于 2020 年 1 月 31 日离开欧盟的事实如何。

    • 范围 —此约束是数据类型特定的,可以是以下类型之一:

      • 间隔约束 用于数字或日期/时间数据类型。作为有效整数数据值的示例,考虑一个用于摩天大楼中单个电梯的活动日志的数据集。数据集中的一个数据列存储电梯停靠的楼层数。由于这个假想摩天大楼中并非所有楼层都可由电梯到达,并且由于迷信原因编号系统跳过了第 13 层,因此可能值的约束包括从—3 到—1 的间隔表示停车场,以及从 1 到 12 和 14 到 42。这个间隔的典型表示法是 [[—3, —1] 或 (0, 12] 或 [14,42]],其中方括号表示值包含在间隔中,而圆括号表示间隔不包括与括号相邻的值。在这种情况下,“或”关键字表示集合并操作(换句话说,逻辑或)。

      • 在使用 DOUBLE 和其他浮点数据类型时采用类似的方法。例如,可以使用区间范围约束指定概率值为 0.0 到 1.0,[0.0, 1.0]。

      • 时间戳数据类型常见的间隔用于描述日期/时间范围,例如工作日、周末或假日(例如,日期:[2020-01-01 00:00:00, 2021-01-01 00:00:00])。

      • 正则表达式约束 用于字符串数据类型的情况下,用于指定有效值的范围。例如,在存储社交媒体影响者 Twitter 账号的数据库中,正则表达式可以指定任何匹配 /^@[a-zA-Z0-9_]{1,15}$/ 的值是有效的。请注意,正则表达式也适用于许多看起来是数值的数据列;例如,IP 地址主要由数字组成,但通常存储为字符串。

    • 规则 ——此约束适用于任何数据类型,并指定计算条件以确定值是否有效。例如,如果你曾经在网站上使用“保存我的付款以供以后使用”的功能,以允许符合 PCI-DSS 标准的⁵供应商存储你的信用卡号,你应该知道信用卡号的规则约束是基于 Luhn 算法的⁶,该算法计算出奇偶校验位以确保信用卡号有效。

到目前为止,你已经看到了指定条件和举例说明数据集中单个值有效或无效的含义。然而,很容易举出一个由完全有效值组成但存在明显数据质量问题的记录示例。以下是来自假设数据集的一个虚构记录,其中列出了带有大陆和国家信息的位置:

大陆 国家 纬度 经度
南美洲 美国 38.91 –77.03

所有值,包括南美洲、美国以及纬度/经度坐标,都对应着各自列的有效值。回想一下来自 VACUUM 的有效原则侧重于数据质量问题和单个值内的验证检查。要解决此示例中的数据质量问题,您需要了解准确性原则。

准确

当您了解有效数据时,您看到了有关欧盟成员国的记录数据集的示例。作为示例的一部分,您看到英国是欧盟国家列的有效值。假设您正在处理一个包含两列的数据记录:第一列是入会日期/时间,第二列是国家名称:

记录日期 成员国
2020-01-31 英国
2020-02-01 英国

虽然示例中的所有值都是有效的,但是如果不使用外部(数据记录之外的)参考数据源,就不可能断言第二行是垃圾。参考数据应该能够处理整个记录中的值,并指示记录是否(或在何种程度上)不准确。

更准确地说,如果记录中的所有数据值都是有效的,并且记录中的值的组合与参考数据源一致,那么数据记录就是准确的。举个例子,考虑一个大学校友数据库,其中包括校友入学日期和毕业日期。检查数据库的准确性需要参考外部的真实数据源,例如招生数据库和成绩单数据库。在财务记录中,准确性问题可能是由信用卡号和 PIN 码之间的不匹配引起的。有时准确性问题是由于错误地连接多个表,例如,一条数据记录声称电影 泰坦尼克号 是由盖·里奇在 1920 年制作的。

对于诸如域名等值的准确性保证是一项特别困难的任务,因为参考数据源、域名注册和 DNS 数据库随着时间的推移而发生变化。例如,如果您尝试创建一个电子邮件邮寄列表,并使用正则表达式检查电子邮件的域名部分,那么列表中的数据可能是有效的,但从某种意义上来说并不准确,因为其中一些电子邮件没有映射到有效的域名。您可以尝试向邮寄列表中的地址发送电子邮件,以确认域名和电子邮件是否解析到准确的地址。甚至在发送电子邮件之前,您可能会尝试执行 DNS 查询来验证域名的准确性。

在英国退出欧盟的例子中,改善数据集中数据的质量意味着参考数据源必须存在于欧盟成员国开始和结束日期的时间戳的主记录中。然而,对于许多组织来说,参考数据源的挑战并不是它们太少,而是它们太多。下一节关于一致性将用更多的例子说明这个问题。

一致性

2005 年 1 月,印第安纳州波特郡约有 3 万名居民的小镇瓦尔帕莱索的一位小房屋业主收到通知,他房屋的年度税收评估价值被设定为 4 亿美元。这份通知还包括要求交纳 800 万美元税款的要求,对这座普通房屋的所有者来说,这是一个惊喜,因为就在前一年,税款金额仅为 1500 美元。尽管数据准确性的问题很快得到解决,但故事并没有就此结束。

瓦尔帕莱索的数据系统没有遵循数据质量一致性原则,因此原始的数据准确性问题传播到了该镇的预算系统中。这个小镇的预算假设了 800 万美元的税款,因此该镇不得不从学校、图书馆和其他预算资助单位中收回 310 万美元。那一年,波特郡有很多不满的学生和家长,因为学校不得不填补 20 万美元的预算缺口。

一致性问题在不同的数据孤岛(数据库、数据存储或数据系统)中使用不同和冲突的验证和准确性实现时会出现:虽然每个单独的孤岛可以根据孤岛特定的定义集合有效和准确,但实现一致性意味着在将跨越不同技术和组织边界的系统中的数据集成之前,确保有效和准确的数据的一个共同标准。

例如,英国在 2020 年 1 月 31 日晚上 11:30 是否是欧盟成员国?如果你对数据质量不够谨慎,答案可能取决于你的数据集。在英国的数据集中,你可以期待一条有效和准确的记录,显示英国在 2020 年 1 月 31 日晚上 11:30 不是欧盟成员国。然而,在欧盟的数据集中,相同的日期、时间和国家名称值的组合是一个欧盟成员国的准确记录。正如你可能已经猜到的那样,不一致是由于在不同的数据集中存储日期和时间值的假设不同。这个例子中的英国数据集使用格林尼治平均时区,而欧盟数据集使用中欧时间。

即使在单个数据集或数据隔离中连接表时,确保验证和准确性规则的一致性也很重要。典型的问题出现在使用电话号码和电子邮件作为用户的唯一标识符时:由于电话号码和电子邮件可能更换所有者,基于这些信息连接表可能会导致问题。另一个例子可能包括存储其他标识符的不同方法,比如电话号码。有些可能用国家代码唯一标识,而其他可能不是。这可能非常简单,比如在不同系统中使用不同的主键来标识同一个人,可能创建一个新的主键来连接,或者可能更微妙。例如,有些系统可能使用 5+4 的邮政编码,其他系统可能为每个个体使用一个五位数的邮政编码。

统一

火星气候轨道飞行器是一项耗资 1.25 亿美元的火星无人空间探测器,由 NASA 于 1998 年发射到火星。不到 12 个月后,在进行轨道变化机动时,它滑离了火星的大气层,从此销声匿迹。原因很简单:轨道飞行器的设计者集成了两个独立开发的系统,一个使用美国习惯(英制)度量单位,另一个基于国际单位制(公制)单位。因此,非统一的表被连结在一起(连接记录),数据集中出现非统一的记录。由于轨道飞行器使用的数据测量值在多个数据记录中不统一,NASA 浪费了 1.25 亿美元的预算。

一致性和统一性的区别微妙但重要。正如火星轨道飞行器的例子所示,确保跨数据隔离的验证和准确性规则的一致性是不足以解决数据质量问题的。统一原则规定数据集中的每一列,所有记录都应该使用相同(统一)的测量系统记录的数据。不再使用 NASA 的例子,考虑一个更贴近生活的场景,即创建用来分析用户对不同视频流媒体服务的满意度的数据集。

假设某些流媒体服务在每次观看后,提示用户对内容满意度进行 0—4 星的评分。其他服务可能使用 0 来表示 1—4 星的范围内没有回应。尽管两者的有效值规则相同,为了确保数据质量,仅仅指定客户满意度应该是一个[0, 4]的 DOUBLE 值,并一致应用于视频流媒体服务的数据隔离中是不够的。例如,如果每个服务的平均满意度分数按照每日记录并连接以准备聚合平均分数,则结果在数据集中的行之间不是统一的。特别是,使用 0 值表示没有回应的服务将在分析中受到处罚。

数据集的统一问题往往在数据集的生命周期中出现。考虑一个强制执行商店货架编码系统的杂货连锁店,在这个系统中,所有的商店都有标号为 1-8 的货架,每个货架对应一个产品类别,比如 1 代表奶制品,2 代表肉类,3 代表冷冻食品,以此类推。一旦有一个商店违反了货架编码系统,比如把奶制品编码为 2 而不是 1,整个杂货连锁店的统一性就被破坏了。

统一

在 1912 年的一本书中,有影响力的逻辑学家和哲学家贝特兰·罗素用一个故事来阐述归纳推理的问题,这是机器学习背后的一个基本原则。对罗素的话进行改写,以下是这个寓言的概括:

在 12 月 1 日,一个火鸡在美国出生了。它不是普通的火鸡。有人说它是有史以来最聪明的火鸡。这只天才火鸡很快就摸清了夜空的规律和太阳在投射阴影中的作用,并意识到每天早上 7 点它都会被喂食。它推理出食物对健康至关重要,因此开始思考是否值得策划一次逃亡,冒着饥饿和死亡的风险,还是继续作为一个受到良好喂养的囚徒。天才火鸡重新发明了统计学,收集数据,并逐渐增加信心,无论太阳、月亮、星星、温度、降水和其他因素的位置如何,它每天早上 7 点都会被喂食。可悲的是,在感恩节的早晨,食物没有来,天才火鸡的头落在了砧板上。

这个故事(主要是生动而不是悲惨)是为了帮助你记住,无论你创建多么复杂的机器学习模型,它们只不过是罗素火鸡的数字化版本。它们的成功完全取决于它们利用的可用数据的能力。相比之下,作为一个机器学习从业者,你可以通过好奇心、因果推理和演绎推理让你的机器学习项目更加成功:通过发现对项目使用案例来说是新颖且相关的事实和数据集,将发现的信息与手头的数据集合并,并扩大用于训练模型的相关训练数据的范围。你还可以通过发现和解决训练数据集中潜在的非明显系统性偏差的可能来源,将项目运行环境的文化价值观与数据集的内容统一和调整,以最大限度地减少机器学习项目成功的风险。

虽然你可以依靠机器学习模型进行有效的归纳推理,但是你有责任来执行统一原则,也就是说,你的数据集:

  • 是否有一个单一的位置存放与你的项目的机器学习用例相关的数据

  • 将您的用例使用的标准与用于机器学习模型训练的数据内容对齐,以实现无偏的数据驱动决策制定。

  • 取决于用于机器学习模型训练的数据和用于已经训练的机器学习模型一起使用的数据的共同数据质量过程。

统一的原则是 VACUUM 的一部分,提醒您数据质量是一项旅程,而不是目的地。

3.3 将 VACUUM 应用于 DC 出租车数据

现在您已经了解了 VACUUM 数据质量原则,是时候将这些原则应用于 DC 出租车数据集了。在本节中,您将从单个数据表开始,并专注于如何实现数据质量查询,以确保数据集是有效,准确和统一的。

3.3.1 强制执行模式以确保有效值

本节介绍了您可以对 DC 出租车数据集执行的 SQL 语句,以检查无效值并将其排除在进一步分析之外。

表 3.2 中显示的模式与您在第二章中首次遇到的版本相匹配。模式使用 SQL 类型指定出租车费用估计服务接口所需的数据类型。在本书的后续章节中,当您开始从 DC 出租车数据中训练机器学习模型时,训练数据集中的 NULL 值可能会造成问题(考虑要求机器学习模型为 NULL 取车位置估算出租车费用!)。因此,该模式旨在确保不允许任何数据值为空。

表 3.2 出租车费用估计服务界面的模式和示例值

输入
名称
取车位置纬度
取车位置经度
下车位置纬度
下车位置经度
旅行的预期开始时间
输出
名称
预估费用(美元)

让我们通过使用以下查询语句从您的 shell 中找出 origindatetime_tr 列中 NULL 值的时间戳数,假设您执行了清单 3.4 中的 Python awscli_to_df()函数,并使用 pandas 输出查询结果:

source utils.sh ; athena_query_to_pandas "
SELECT
    (SELECT COUNT(*) FROM dc_taxi_db.dc_taxi_parquet) AS total,
    COUNT(*) AS null_origindate_time_total
FROM
    dc_taxi_db.dc_taxi_parquet
WHERE
    origindatetime_tr IS NULL
"

这将导致以下结果:

总数 null_origindate_time_total
67435888 14262196

为了简洁起见,即将到来的代码片段将不再提醒您运行 source utils.sh;athena_query_to_pandas 或 awscli_to_df()。

请记住,SQL COUNT(*)函数⁹返回 NULL 和非 NULL 值的计数。但是,由于 SQL 查询的 WHERE 子句将输出限制为 origindatetime_tr 为 NULL 的行,因此 SQL 查询的输出报告了在整个数据集的 67435888 行中,有 14262196 行为空。

除了确保 origindatetime_tr 值为非 NULL 外,还必须确认值符合有效时间戳值的正则表达式定义。在实践中,这意味着应该可以解析 origindatetime_tr 列的非 NULL 值为时间戳的相关元素,包括年、月、日、小时和分钟。

幸运的是,您不必实现正则表达式解析规则来处理日期/时间。以下 SQL 查询计算数据集中行数与非 NULL 的 origindatetime_tr 值之间的差异,并且可以使用 SQL DATE_PARSE 函数正确解析,该函数使用 DC 出租车数据集中的 %m/%d/%Y %H:%i 格式:

SELECT
    (SELECT COUNT(*) FROM dc_taxi_db.dc_taxi_parquet)
    - COUNT(DATE_PARSE(origindatetime_tr, '%m/%d/%Y %H:%i'))
    AS origindatetime_not_parsed
FROM
    dc_taxi_db.dc_taxi_parquet
WHERE
    origindatetime_tr IS NOT NULL;

这导致以下结果:

origindatetime_not_parsed
14262196

由于语句返回的差值也等于 14,262,196,这意味着时间戳的所有非 NULL 值都可以解析。此外,请注意,SQL 语句使用 SQL 子查询来计算数据集中的总行数,包括 NULL 和非 NULL 值,因为子查询不包括 WHERE 子句。外部 SQL 查询的结尾处的 WHERE 子句仅适用于计算 COUNT 函数,该函数计算 DATE_PARSE 函数可以正确解析的值的数量。

让我们继续将验证规则应用于起始点和目的地位置。由于在使用情况中,起始点和目的地位置的纬度和经度坐标是非空的,请看下面展示的验证规则对坐标值的影响。

列表 3.5 位置坐标的缺失频率

SELECT
    ROUND(100.0 * COUNT(*) / (SELECT COUNT(*)
                        FROM dc_taxi_db.dc_taxi_parquet), 2)

        AS percentage_null,

    (SELECT COUNT(*)
     FROM dc_taxi_db.dc_taxi_parquet
     WHERE origin_block_longitude_double IS NULL
     OR origin_block_latitude_double IS NULL)

        AS either_null,

    (SELECT COUNT(*)
     FROM dc_taxi_db.dc_taxi_parquet
     WHERE origin_block_longitude_double IS NULL
     AND origin_block_latitude_double IS NULL)

        AS both_null

FROM
    dc_taxi_db.dc_taxi_parquet
WHERE
    origin_block_longitude_double IS NULL
    OR origin_block_latitude_double IS NULL

这导致以下结果:

percentage_null either_null both_null
14.04 9469667 9469667

根据查询结果,在数据集中,原始块纬度和原始块经度成对缺失(即,如果其中一个为 NULL,则另一个也为 NULL)的行数为 9,469,667,大约占数据集的 14.04%。

对目的地坐标的类似分析使用以下 SQL 语句:

SELECT
    ROUND(100.0 * COUNT(*) / (SELECT COUNT(*)
                        FROM dc_taxi_db.dc_taxi_parquet), 2)

        AS percentage_null,

    (SELECT COUNT(*)
     FROM dc_taxi_db.dc_taxi_parquet
     WHERE destination_block_longitude_double IS NULL
     OR destination_block_latitude_double IS NULL)

        AS either_null,

    (SELECT COUNT(*)
     FROM dc_taxi_db.dc_taxi_parquet
     WHERE destination_block_longitude_double IS NULL
     AND destination_block_latitude_double IS NULL)

        AS both_null

FROM
    dc_taxi_db.dc_taxi_parquet
WHERE
    destination_block_longitude_double IS NULL
    OR destination_block_latitude_double IS NULL

这导致

percentage_null either_null both_null
19.39 13074278 13074278

这表明有 13,074,278 行的目的地坐标具有 NULL 值,大约占整个数据集的 19.39%。

起始点和目的地坐标的 NULL 值的比例显然非常显著。在缺少值的潜在最坏情况下,您可能会发现 42.4%(即 24.59% + 17.81%)的行的起点或目的地坐标缺失。然而,在数据集中,大部分缺失值是重叠的,这意味着如果起点或目的地任一坐标为 NULL,则另一个坐标也为 NULL。您可以使用以下方法找到缺失坐标的计数和比例:

SELECT
    COUNT(*)
      AS total,

    ROUND(100.0 * COUNT(*) / (SELECT COUNT(*)
                              FROM dc_taxi_db.dc_taxi_parquet), 2)
      AS percent

FROM
    dc_taxi_db.dc_taxi_parquet

WHERE
    origin_block_latitude_double IS NULL
    OR origin_block_longitude_double IS NULL
    OR destination_block_latitude_double IS NULL
    OR destination_block_longitude_double IS NULL

这导致

total percent
16578716 24.58

这显示出数据集中 24.58%,或者 16,578,716 行,没有有效的起点和终点坐标。由于乘车和下车位置是出租车费用估算服务规范的必需部分,让我们将数据质量工作集中在剩下的 75.42%具有可用乘车和下车坐标的行上。

3.3.2 清理无效的费用金额

本节将通过 SQL 语句对 fare_amount 列进行分析,并对列值强制执行验证规则。

填充 dc_taxi_parquet 表的 PySpark 作业对原始数据集执行了一些验证处理。如果您查询 Athena 以获取表的模式,请注意项目所需的值同时存在字符串和双精度类型。同时存在两种类型意味着,在某些情况下,值无法转换为所需的 DOUBLE 类型(例如,无法解析值为双精度数值),原始值将被保留并可用于数据故障排除。

根据第二章中描述的模式规范,每个出租车行程记录必须在车费金额、行程开始时间戳和起点和终点纬度/经度坐标中具有非 NULL 值。让我们从调查 fareamount_double 列包含 NULL 值的实例开始,这是根据模式不允许的。由于 fareamount_string 列是从 STRING 到 DOUBLE 解析失败的车费金额值的信息来源,您可以使用以下 SQL 语句了解更多有关问题值的信息。

列表 3.6fareamount_string 列的解析失败的值

SELECT
    fareamount_string,
    COUNT(fareamount_string) AS rows,
    ROUND(100.0 * COUNT(fareamount_string) /
          ( SELECT COUNT(*)
            FROM dc_taxi_db.dc_taxi_parquet), 2)
      AS percent
FROM
    dc_taxi_db.dc_taxi_parquet
WHERE
    fareamount_double IS NULL
    AND fareamount_string IS NOT NULL
GROUP BY
    fareamount_string;

得到以下结果:

fareamount_string 行数 百分比
NULL 334,964 0.5

列表 3.6 中的 SQL 语句过滤了 fareamount_string 值集合的一组值,仅关注 PySpark 无法解析车费金额的情况,或者更准确地说,fareamount_double(包含解析算法输出的列)的值为 NULL 而 fareamount_string(包含解析算法输入的列)的值不为 NULL 的行。

根据查询的输出,有 334,964 个条目的解析失败。所有这些对应的情况是 fareamount_string 等于'NULL'字符串值的情况。这是一个好消息,因为只有大约 0.5%的数据集受到这个问题的影响,而且没有额外的工作要做:'NULL'值不能转换为 DOUBLE。如果列表 3.6 的输出发现了一些 DOUBLE 值没有解析成功,因为它们包含额外的字符,比如'$7.86',那么就需要实现额外的代码来正确解析这样的值为 DOUBLE。

为了继续搜索无效的 fareamount 值,值得探索 fareamount_double 列的一些摘要统计信息。以下 SQL 查询将摘要统计计算移到一个单独的子查询中,使用两个 WITH 子句。请注意,数据特定的查询被打包为一个名为 src 的子查询,并且 stats 子查询引用来自 src 的结果。

列表 3.7 解耦统计查询与数据查询的可重用模式

WITH
src AS (SELECT
            fareamount_double AS val
        FROM
            dc_taxi_db.dc_taxi_parquet),

stats AS
    (SELECT
        MIN(val) AS min,
        APPROX_PERCENTILE(val, 0.25) AS q1,
        APPROX_PERCENTILE(val ,0.5) AS q2,
        APPROX_PERCENTILE(val, 0.75) AS q3,
        AVG(val) AS mean,
        STDDEV(val) AS std,
        MAX(val) AS max
    FROM
        src)

SELECT
    DISTINCT min, q1, q2, q3, max

FROM
    dc_taxi_db.dc_taxi_parquet, stats

以下是结果:

分钟 四分位数 1 四分位数 2 四分位数 3 最大值
–2064.71 7.03 9.73 13.78 745557.28

根据列表 3.7 中查询输出报告的数据集中的最小值,应清楚地看出,数据集受到了一类无效值的影响:出租车费用不应为负值或低于 3.25 美元。回想一下第二章中对 DC 出租车业务规则的审查,DC 的出租车乘车费用的最低收费为 3.25 美元。让我们找出数据集受影响的百分比:

WITH
src AS (SELECT
            COUNT(*) AS total
        FROM
            dc_taxi_db.dc_taxi_parquet
        WHERE
            fareamount_double IS NOT NULL)

SELECT
    ROUND(100.0 * COUNT(fareamount_double) / MIN(total), 2) AS percent
FROM
    dc_taxi_db.dc_taxi_parquet, src
WHERE
    fareamount_double < 3.25
    AND fareamount_double IS NOT NULL

以下是结果:

百分比
0.49

输出表明,只有 0.49%的行受到了负值或低于最小阈值的车费值的影响,因此它们可以被分析时轻松忽略。从验证的角度来看,这意味着验证规则的实施应该修改为使用大于或等于 3.25 的值。

3.3.3 提高准确性

在本节中,让我们通过将它们与行程里程值的参考数据源进行比较,来更仔细地查看 NULL 值的准确性。正如您在上一节中学到的,DC 出租车数据集中的 NULL 值仅占 0.5%。在 mileage_double 列中使用参考数据可以帮助您更好地了解行程里程导致 NULL 车费金额的情况。

列表 3.8 里程 _double 值的摘要统计

SELECT
    fareamount_string,
    ROUND( MIN(mileage_double), 2) AS min,
    ROUND( APPROX_PERCENTILE(mileage_double, 0.25), 2) AS q1,
    ROUND( APPROX_PERCENTILE(mileage_double ,0.5), 2) AS q2,
    ROUND( APPROX_PERCENTILE(mileage_double, 0.75), 2) AS q3,
    ROUND( MAX(mileage_double), 2) AS max
FROM
    dc_taxi_db.dc_taxi_parquet
WHERE
    fareamount_string LIKE 'NULL'
GROUP BY
    fareamount_string

以下是结果:

fareamount_string 最小值 四分位数 1 四分位数 2 四分位数 3 最大值
NULL 0.0 0.0 1.49 4.79 2591.82

列表 3.8 中的 SQL 语句仅报告了里程列的摘要统计信息(包括最小值、最大值和四分位值),仅适用于 fareamount_string 解析失败的情况,或者更具体地说,它等于'NULL'的情况。查询的输出表明,超过四分之一的情况(下四分位数,从最小值到第 25 百分位数的范围)对应于 0 英里的行程。至少四分之一的里程值(位于中间和上四分位数之间,包括 50 和 75 百分位数的范围)似乎在 DC 出租车的合理里程范围内。

此时,你可以考虑进行几项数据增强实验,试图通过从里程列计算车费的估算值来恢复丢失的 fareamount_double 数据值。这些实验可以使用估算值替换丢失的车费金额。例如,你可以将里程处于中四分位数范围内的缺失车费金额值替换为相同范围内已知车费金额的算术平均值(平均值)。也可以使用更复杂的估算器,包括机器学习模型。

然而,由于列表 3.8 中的输出表明,它将帮助解决数据集约 0.12%(= 0.25 * 0.49%)的问题,因此这些实验不太可能对车费估算模型的整体性能产生显著影响。

根据列表 3.7 中查询的输出,车费金额的最大值似乎是一个垃圾数据点。然而,从数据模式的角度来看,它是有效的,因为 745,557.28 小于 SQL DOUBLE 数据类型的最大值。

解决车费金额值上限问题需要应用准确性规则。请回忆,验证检查应该在没有外部数据源的参考情况下进行。

在华盛顿特区出租车数据集的情况下,最大车费金额未明确规定为一项业务规则。然而,通过一些常识推理和华盛顿特区出租车数据集之外的参考数据,你可以得出一些合理的最大车费金额上限:

  • 估算 1。最大车费金额取决于出租车司机每个工作班次行驶的里程。一个快速的互联网搜索告诉我们,一名华盛顿特区出租车司机每 24 小时至少需要休息 8 小时。因此,假设性地,司机可能连续驾驶最长 16 小时。根据华盛顿特区、马里兰州和弗吉尼亚州的网站,这些地区的最高速度限制为 70 英里/小时。即使在司机以最高限速连续驾驶 16 小时的荒谬情况下,这段时间内的最大行驶里程也仅为 1,120 英里。

  • 显然,一趟里程为 1,120 英里的出租车行程,估计车费为$2,422.45(1,120 英里 * $2.16/英里 + $3.25 基本车费),是一个不可能实现的上限,不会转化为准确的华盛顿特区出租车车费金额。然而,与其将此估算结果丢弃,正确的做法是加以考虑,并通过与更多估算结果的汇总来完善它。

  • 估算 2。与其专注于行驶距离,你也可以根据时间来估算最大车费金额。考虑到华盛顿特区出租车车费规定,一辆出租车每小时可以收取$35。由于出租车司机被允许工作的最长时间为 16 小时,你可以计算出另一个与距离无关的、车费金额的上限估算值为$560 = 16 小时 * $35/小时。

  • 估算 3。出租车费用的上限也可以基于数据集中两个最远角落之间的行程距离。第二章所述的 DC 出租车数据集边界大致是个以市中心为中心的正方形。使用以下查询可以找出正方形上左下角和右上角点的位置:

  • SELECT
      MIN(lat) AS lower_left_latitude,
      MIN(lon) AS lower_left_longitude,
      MAX(lat) AS upper_right_latitude,
      MAX(lon) AS upper_right_longitude
    
     FROM (
      SELECT
        MIN(origin_block_latitude_double) AS lat,
        MIN(origin_block_longitude_double) AS lon
      FROM "dc_taxi_db"."dc_taxi_parquet"
    
      UNION
    
      SELECT
        MIN(destination_block_latitude_double) AS lat,
        MIN(destination_block_longitude_double) AS lon
      FROM "dc_taxi_db"."dc_taxi_parquet"
    
      UNION
    
      SELECT
        MAX(origin_block_latitude_double) AS lat,
        MAX(origin_block_longitude_double) AS lon
      FROM "dc_taxi_db"."dc_taxi_parquet"
    
      UNION
    
      SELECT
        MAX(destination_block_latitude_double) AS lat,
        MAX(destination_block_longitude_double) AS lon
      FROM "dc_taxi_db"."dc_taxi_parquet"
    
    )
    
  • 得出以下结果:

    lower_left_latitude lower_left_longitude upper_right_latitude upper_right_longitude
    38.81138 –77.113633 38.994909 –76.910012

    将查询报告的纬度和经度坐标插入 OpenStreetMap (mng.bz/zEOZ) 中,可得到 21.13 英里总行程,或者 $48.89 (21.13 X $2.16/英里 + $3.25) 的估算费用。

  • 估算 4。对于另一种估算技术,根据统计学中的中心极限定理(CL​T),随机抽样¹⁰所得到的车费值的算术平均数的总和(因此也是平均数)符合高斯(钟形)分布。根据 SQL 语句,你可以从数据中生成一千个的出租车里程的算术平均数样本(以后可以计算它们的平均数)。

列表 3.9 2015 年至 2019 年 DC 出租车行程数据集的解压文件。

WITH dc_taxi AS
(SELECT *,
    origindatetime_tr
    || fareamount_string
    || origin_block_latitude_string
    || origin_block_longitude_string
    || destination_block_latitude_string
    || destination_block_longitude_string
    || mileage_string AS objectid

    FROM "dc_taxi_db"."dc_taxi_parquet"

    WHERE fareamount_double >= 3.25
            AND fareamount_double IS NOT NULL
            AND mileage_double > 0 )

SELECT AVG(mileage_double) AS average_mileage
FROM dc_taxi
WHERE objectid IS NOT NULL
GROUP BY 
➥ MOD( ABS( from_big_endian_64( xxhash64( to_utf8( objectid ) ) ) ), 1000)

注意 GROUP BY 版本语句的 GROUP BY 部分中的复杂逻辑。数据集中的 objectid 列包含每个数据行的唯一标识符,用顺序排列的整数值表示。你可以使用 GROUP BY MOD(CAST(objectid AS INTEGER), 1000) 子句来替代列表 3.9 版本。然而,如果 objectid 值是基于数据集出租车行程的原始顺序排序,则每个结果样本都包含数据集中相距 1,000 行的里程值。这种有序的间隔结构抽样可能会在计算中引入意外偏差。例如,如果华盛顿特区每小时大约有 1,000 辆出租车,而开往纽约市的火车车站每小时在整点留下的出租车可能会占据一些样本。其他定期间隔样本可能包含太多日终出租车行程。

基于偏置问题而进行正常间隔抽样的随机抽样(基于计算中使用的伪随机值)可以解决。然而,在用如下 GROUP BY 子句将值分组时,使用伪随机数生成器有几个不足之处:

GROUP BY MOD(ABS(RAND()), 1000)

首先,由于随机数生成器不能保证确定性行为,所以无法准确地复制抽样结果。不能指定一个随机数种子,以在 SQL 语句的多个执行之间保证相同的伪随机值序列。

其次,即使您尝试为数据集中的每一行预先计算伪随机标识符,并将行与标识符一起保存到一个单独的表中以供将来重复使用,该表很快也会变得过时。例如,如果 DC 出租车数据集扩展到包括 2020 年的出租车行程,随后对数据进行的 Glue 爬行器索引将使源数据表失效,并迫使重新创建新的伪随机标识符。

相比之下,清单 3.9 中使用的方法以及在此处显示的方法具有数据集的伪随机洗牌的优点,消除了不必要的偏见,并且在数据集添加时无论查询如何都会产生相同的结果,只要可以唯一标识数据的每一行:

GROUP BY MOD(ABS(from_big_endian_64(xxhash64(to_utf8(objectid)))), 1000)

在 SQL 语句中,对 objectid 应用函数起到唯一标识符的作用。xxhash64 哈希函数和 from_big_endian_64 的组合实际上产生了一个伪随机但确定性的值。

作为对清单 3.9 中生成的车费金额样本的平均值近似于高斯分布的可视确认,图 3.3 中的以下直方图是基于清单中对伪随机数种子值的任意选择而绘制的。

03-03

图 3.3 显示清单 3.9 中的随机抽样依赖于 CLT 进行估算。

回顾一下,在平均里程列使用 1,000 个随机样本的原始意图是计算样本的平均值。由于在正态分布中大约有 99.99% 的值在距离平均值四个标准差之内,以下 SQL 语句产生了另一个上限估计值的统计估计值,从而得到了车费金额的另一个上限估计值:

WITH dc_taxi AS
(SELECT *,
    origindatetime_tr
    || fareamount_string
    || origin_block_latitude_string
    || origin_block_longitude_string
    || destination_block_latitude_string
    || destination_block_longitude_string
    || mileage_string AS objectid

    FROM "dc_taxi_db"."dc_taxi_parquet"

    WHERE fareamount_double >= 3.25
            AND fareamount_double IS NOT NULL
            AND mileage_double > 0 ),

dc_taxi_samples AS (
    SELECT AVG(mileage_double) AS average_mileage
    FROM dc_taxi
    WHERE objectid IS NOT NULL
    GROUP BY 
➥     MOD( ABS( from_big_endian_64( xxhash64( to_utf8( objectid ) ) ) ), 1000)
)
SELECT AVG(average_mileage) + 4 * STDDEV(average_mileage)
FROM dc_taxi_samples

此次执行产生了约 12.138 英里,或大约$29.47(12.01 * $2.16/英里 + $3.25)作为另一个上限车费估算。当然,本节中解释的统计方法的优点在于,它可以直接与 fareamount_double 列一起使用,如下 SQL 语句所示:

WITH dc_taxi AS
(SELECT *,
    origindatetime_tr
    || fareamount_string
    || origin_block_latitude_string
    || origin_block_longitude_string
    || destination_block_latitude_string
    || destination_block_longitude_string
    || mileage_string AS objectid

    FROM "dc_taxi_db"."dc_taxi_parquet"

    WHERE fareamount_double >= 3.25
            AND fareamount_double IS NOT NULL
            AND mileage_double > 0 ),

dc_taxi_samples AS (
    SELECT AVG(fareamount_double) AS average_fareamount
    FROM dc_taxi
    WHERE objectid IS NOT NULL
    GROUP BY 
➥     MOD( ABS( from_big_endian_64( xxhash64( to_utf8( objectid ) ) ) ), 1000)
)
SELECT AVG(average_fareamount) + 4 * STDDEV(average_fareamount)
FROM dc_taxi_samples

这产生了一个 $15.96 的上限。

尽管您可以继续探索替代的估算方法,但这是一个评估迄今为止车费平均上限的好时机。

使用 Python 中的简单平均实现

means = [15.96, 29.19, 48.89, 560, 2,422.45]
sum(means) / len(means)

179.748

表明出租车车费的估计上限为 $179.75

尽管确实可以继续思考更好的上限估计方法,但让我们估计在使用 $179.75 的上限后还剩下多少数据:

SELECT
    100.0 * COUNT(fareamount_double) /
      (SELECT COUNT(*)
      FROM dc_taxi_db.dc_taxi_parquet
      WHERE fareamount_double IS NOT NULL) AS percent
FROM
    dc_taxi_db.dc_taxi_parquet
WHERE (fareamount_double < 3.25 OR fareamount_double > 179.75)
        AND fareamount_double IS NOT NULL;

这导致以下结果:

百分比
0.48841

请注意,根据边界,只有约 0.49% 的数据被排除在外。

然而,使用新的边界重新运行 fareamount_double 列的摘要统计信息会产生更有意义的摘要统计信息:

WITH src AS (SELECT fareamount_double AS val
             FROM dc_taxi_db.dc_taxi_parquet
             WHERE fareamount_double IS NOT NULL
             AND fareamount_double >= 3.25
             AND fareamount_double <= 180.0),
stats AS
    (SELECT
     ROUND(MIN(val), 2) AS min,
     ROUND(APPROX_PERCENTILE(val, 0.25), 2) AS q1,
     ROUND(APPROX_PERCENTILE(val, 0.5), 2) AS q2,
     ROUND(APPROX_PERCENTILE(val, 0.75), 2) AS q3,
     ROUND(AVG(val), 2) AS mean,
     ROUND(STDDEV(val), 2) AS std,
     ROUND(MAX(val), 2) AS max
    FROM src)
SELECT min, q1, q2, q3, max, mean, std
FROM stats;

这导致以下结果:

最小值 四分位数 1 四分位数 2 四分位数 3 最大值 平均值 标准差
3.25 7.03 9.73 13.78 179.83 11.84 8.65

现在,已经完成了对 fareamount 列的准确性检查,您应该准备好使用接送坐标重复进行准确性练习了。虽然可能根据值本身确定纬度和经度坐标是否有效,但是您需要一个参考数据源来决定一个值是否准确。用于在第二章生成 DC 出租车地图的 OpenStreetMap 服务也可以用于确认数据集中起始点和目的地坐标的准确性。

使用 SQL 语句和 OpenStreetMap (mng.bz/01ez) 来检查原始纬度和经度列的最小和最大坐标,确认结果对(38.81138, —77.113633)和(38.994217, —76.910012)在 DC 边界内:

SELECT
    MIN(origin_block_latitude_double) AS olat_min,
    MIN(origin_block_longitude_double) AS olon_min,
    MAX(origin_block_latitude_double) AS olat_max,
    MAX(origin_block_longitude_double) AS olon_max,
    MIN(destination_block_latitude_double) AS dlat_min,
    MIN(destination_block_longitude_double) AS dlon_min,
    MAX(destination_block_latitude_double) AS dlat_max,
    MAX(destination_block_longitude_double) AS dlon_max,
FROM
    dc_taxi_db.dc_taxi_parquet

这将输出以下内容:

olat_min olon_min olat_max olon_max dlat_min dlon_min dlat_max dlon_max
38.81138 –77.113633 38.994909 –76.910012 38.81138 –77.113633 38.994217 –76.910012

3.4 在 PySpark 作业中实现 VACUUM

在这一节中,您将运用在 DC 出租车数据集中学到的数据质量知识,并将您的发现应用于实现一个 PySpark 作业。该作业的目的是使用 AWS Glue 提供的分布式 Apache Spark 服务器群集执行 dc_taxi_parquet 表的高吞吐量数据清理,该表在第二章中填充。该作业应该实现为一个名为 dctaxi_parquet_vacuum.py 的单个 Python 文件;然而,在这一节中,该文件被拆分成了几个单独的代码片段,这些片段将在接下来的段落中逐一解释。数据集的清理副本将由该作业保存到您 S3 存储桶中的 parquet/vacuum 子文件夹中。

该 PySpark 作业的代码片段的初始部分在列表 3.10 中。请注意,直到❶处的代码行与第二章中的 PySpark 作业中的代码是相同的。这应该不会让人感到惊讶,因为代码的这部分涉及到 PySpark 作业中的先决条件库的导入和常用变量的分配。带有❶注释的代码行是与第二章 PySpark 作业不同的第一个代码行。请注意,该行正在读取您在第二章末尾创建的 Parquet 格式数据集,并在本章中一直在使用 Athena 进行查询。

在 dctaxi_parquet_vacuum.py 中的列表 3.10 中的 PySpark DataFrame 读取代码

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job

args = getResolvedOptions(sys.argv, ['JOB_NAME',
                                     'BUCKET_SRC_PATH',
                                     'BUCKET_DST_PATH',
                                     ])
BUCKET_SRC_PATH = args['BUCKET_SRC_PATH']
BUCKET_DST_PATH = args['BUCKET_DST_PATH']

sc = SparkContext()
glueContext = GlueContext(sc)
logger = glueContext.get_logger()
spark = glueContext.spark_session

job = Job(glueContext)
job.init(args['JOB_NAME'], args)

df = ( spark
        .read
        .parquet(f"{BUCKET_SRC_PATH}") )    ❶

❶ 将源 Parquet 数据集读入 Spark DataFrame。

为了选择要清理的数据子集,从列表 3.11 中带有❶的行开始调用 Spark DataFramecreateOrReplaceTempView 方法。该方法创建一个名为 dc_taxi_parquet 的临时视图,作为 SparkSession 的一部分,可以通过 spark 变量访问。该视图使 Spark 能够查询在❶处创建的 DataFrame,使用从❷行开始的 SQL 查询,引用 dc_taxi_parquet 视图❸。

从 ❹ 开始的 WHERE 子句的内容不应该令人惊讶。对于 NULL 值的检查和 fareamount_double 列的范围边界检查恰好是在第 3.3 节中定义的条件。

在 ❺ 处调用 replace 方法,将多行字符串中的任何换行符实例替换为空字符。需要使用 replace 方法确保用于指定 PySpark 作业中 SQL 查询的多行字符串与 Spark 使用的 SQL 查询解析器兼容。

列表 3.11 PySpark 数据清理实现保存到 dc_taxi_vacuum.py

df.createOrReplaceTempView("dc_taxi_parquet")    ❶

query_df = spark.sql("""                         ❷
SELECT
    origindatetime_tr,
    fareamount_double,
    origin_block_latitude,
    origin_block_longitude,
    destination_block_latitude,
    destination_block_longitude

FROM
    dc_taxi_parquet                              ❸

WHERE                                            ❹
    origindatetime_tr IS NOT NULL
    AND fareamount_double IS NOT NULL
    AND fareamount_double >= 3.25
    AND fareamount_double <= 180.0
    AND origin_block_latitude IS NOT NULL
    AND origin_block_longitude IS NOT NULL
    AND destination_block_latitude IS NOT NULL
    AND destination_block_longitude IS NOT NULL

""".replace('\n', ''))                          ❺

❶ 将源数据集在 df 中别名为 dc_taxi_parquet,以供 Spark SQL API 使用。

❷ 创建一个基于此片段中 SQL 查询填充的 DataFrame 查询 _df。

❸ 查询 dc_taxi_parquet 以输出干净的值以进行进一步分析。

❹ 根据第 3.3 节中的 VACUUM 分析过滤记录。

❺ 消除 Python 多行字符串中的换行符,以确保与 Spark SQL API 的兼容性。

由于数据集中原始的 STRING 格式列 origindatetime_tr 需要格式化为机器学习的数值,列表 3.12 中的 PySpark DataFrame API 代码首先将该列转换为 SQL TIMESTAMP ❶,消除由于从 STRING 转换为 TIMESTAMP 失败而产生的任何 NULL 值。然后,衍生的列进一步分解为数字、INTEGER 列,包括出租车行程的年、月、星期几(dow)和小时。转换后的最后一步移除了临时的 origindatetime_ts 列,删除了任何缺失数据的记录,并消除了重复记录。

列表 3.12 PySpark 数据清理实现保存到 dc_taxi_vacuum.py

#parse to check for valid value of the original timestamp
from pyspark.sql.functions import col, to_timestamp, \
    dayofweek, year, month, hour
from pyspark.sql.types import IntegerType

#convert the source timestamp into numeric data needed for machine learning
query_df = (query_df
  .withColumn("origindatetime_ts", \                      ❶
    to_timestamp("origindatetime_tr", "dd/MM/yyyy HH:mm"))
  .where(col("origindatetime_ts").isNotNull())
  .drop("origindatetime_tr")
  .withColumn( 'year_integer',                            ❷
    year('origindatetime_ts').cast(IntegerType()) )
  .withColumn( 'month_integer',
    month('origindatetime_ts').cast(IntegerType()) )
  .withColumn( 'dow_integer',
    dayofweek('origindatetime_ts').cast(IntegerType()) )
  .withColumn( 'hour_integer',
    hour('origindatetime_ts').cast(IntegerType()) )
  .drop('origindatetime_ts') )

#drop missing data and duplicates
query_df = ( query_df                                     ❸
            .dropna()
            .drop_duplicates() )

❶ 使用 dd/MM/yyyy HH:mm 模式解析行程 origindatetime_tr 时间戳。

❷ 根据行程的年、月、星期几和小时构建数字列。

❸ 消除任何具有缺失或重复数据的记录。

PySpark 作业的结束部分,如列表 3.13 所示,将结果的 PySpark DataFrame 持久化为 Parquet 格式的数据集,保存在由 BUCKET_DST_PATH 参数指定的 AWS S3 位置。请注意,该列表声明了一个 save_stats_metadata 函数,该函数使用 PySpark describe 函数计算清理后数据集的摘要统计信息,并将统计信息保存为位于 AWS S3 位置下名为 .meta/stats 的 S3 子文件夹中的单个 CSV 文件。

列表 3.13 PySpark 数据清理实现保存到 dc_taxi_vacuum.py

(query_df
 .write
 .parquet(f"{BUCKET_DST_PATH}", mode="overwrite"))      ❶
def save_stats_metadata(df, dest, header = 'true', mode = 'overwrite'):
  return (df.describe()
    .coalesce(1)
    .write
    .option("header", header)
    .csv( dest, mode = mode ) )

save_stats_metadata(query_df,
    f"{BUCKET_DST_PATH}/.meta/stats")                   ❷

job.commit()

❶ 将清理后的数据集持久化到 Parquet 格式的 BUCKET_DST_PATH。

❷ 将关于清理后数据集的元数据保存为单独的 CSV 文件。

为方便起见,显示了整个 PySpark 作业的描述。在执行此作业之前,请确保将代码列表的内容保存到名为 dc_taxi_vacuum.py 的文件中。

列表 3.14 PySpark 数据清理代码保存到 dc_taxi_vacuum.py

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job

args = getResolvedOptions(sys.argv, ['JOB_NAME',
                                     'BUCKET_SRC_PATH',
                                     'BUCKET_DST_PATH',
                                     ])

BUCKET_SRC_PATH = args['BUCKET_SRC_PATH']
BUCKET_DST_PATH = args['BUCKET_DST_PATH']

sc = SparkContext()
glueContext = GlueContext(sc)
logger = glueContext.get_logger()
spark = glueContext.spark_session

job = Job(glueContext)
job.init(args['JOB_NAME'], args)

df = ( spark
        .read
        .parquet(f"{BUCKET_SRC_PATH}") )
df.createOrReplaceTempView("dc_taxi_parquet")
query_df = spark.sql("""
SELECT
    fareamount_double,
    origindatetime_tr,
    origin_block_latitude_double,
    origin_block_longitude_double,
    destination_block_latitude_double,
    destination_block_longitude_double
FROM
  dc_taxi_parquet
WHERE
    origindatetime_tr IS NOT NULL
    AND fareamount_double IS NOT NULL
    AND fareamount_double >= 3.25
    AND fareamount_double <= 180.0
    AND origin_block_latitude_double IS NOT NULL
    AND origin_block_longitude_double IS NOT NULL
    AND destination_block_latitude_double IS NOT NULL
    AND destination_block_longitude_double IS NOT NULL
""".replace('\n', ''))

#parse to check for valid value of the original timestamp
from pyspark.sql.functions import col, to_timestamp, \
    dayofweek, year, month, hour
from pyspark.sql.types import IntegerType

#convert the source timestamp into numeric data needed for machine learning
query_df = (query_df
  .withColumn("origindatetime_ts",
    to_timestamp("origindatetime_tr", "dd/MM/yyyy HH:mm"))
  .where(col("origindatetime_ts").isNotNull())
  .drop("origindatetime_tr")
  .withColumn( 'year_integer',
    year('origindatetime_ts').cast(IntegerType()) )
  .withColumn( 'month_integer',
    month('origindatetime_ts').cast(IntegerType()) )
  .withColumn( 'dow_integer',
    dayofweek('origindatetime_ts').cast(IntegerType()) )
  .withColumn( 'hour_integer',
    hour('origindatetime_ts').cast(IntegerType()) )
  .drop('origindatetime_ts') )

#drop missing data and duplicates
query_df = ( query_df
            .dropna()
            .drop_duplicates() )

(query_df
 .write
 .parquet(f"{BUCKET_DST_PATH}", mode="overwrite"))

def save_stats_metadata(df, dest, header = 'true', mode = 'overwrite'):
  return (df.describe()
    .coalesce(1)
    .write
    .option("header", header)
    .csv(dest, mode = mode))

save_stats_metadata(query_df, f"{BUCKET_DST_PATH}/.meta/stats")

job.commit()

在第 3.3 节首次介绍的 utils.sh 脚本文件中包含了简化在 AWS Glue 中从 bash shell 执行 PySpark 作业的 bash 函数。请注意,在列表 3.15 中,列表 3.14 中的 PySpark 作业通过文件名 dctaxi_ parquet_vacuum.py 引用,并用于启动名为 dc-taxi-parquet-vacuum-job 的 AWS Glue 作业。该作业使用您在本章前面分析过的 Parquet 格式的 DC 出租车数据集,将数据的清理版本填充到 AWS S3 存储桶的 parquet/vacuum 子文件夹中。清理后的版本也以 Parquet 格式持久化存储。

列表 3.15 使用 bash 启动 dctaxi_parquet_vacuum.py 中的 PySpark 作业

%%bash
wget -q https://raw.githubusercontent.com/osipov/smlbook/master/utils.sh
source utils.sh

PYSPARK_SRC_NAME=dctaxi_parquet_vacuum.py \
PYSPARK_JOB_NAME=dc-taxi-parquet-vacuum-job \
BUCKET_SRC_PATH=s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/parquet \
BUCKET_DST_PATH=s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/parquet/vacuum \
run_job

假设列表 3.15 中的 PySpark 作业成功完成,您应该会观察到类似以下的输出:

{
    "JobName": "dc-taxi-parquet-vacuum-job"
}
{
    "Name": "dc-taxi-parquet-vacuum-job"
}
{
    "JobRunId": 
➥     "jr_8a157e870bb6915eef3b8c0c280d1d8596613f6ad79dd27e3699115b7a3eb55d"
}
Waiting for the job to finish..................SUCCEEDED

总结

  • 交互式查询服务如 AWS Athena 可以帮助探索结构化数据集,其大小从几千兆字节到几百兆字节不等。

  • 按需架构的方法使得交互式查询服务可以将多个不同的数据模式应用于同一个数据集。

  • 与垃圾输入、垃圾输出方法相比,VACUUM 原则可以帮助您的机器学习项目发展成熟的数据质量实践。

  • 交互式查询服务,比如基于 PrestoDB 的 AWS Athena 和基于 Apache Spark 的 AWS Glue,可以用来在公共云中实现数据集的 VACUUM 原则。

^(1.)尽管一项 2016 年的调查(mng.bz/Mvr2)声称数据科学家 60%的时间用于解决数据质量问题,但最近的一项更大规模的调查将估计值降低到了 15%(mng.bz/ g1KR)。有关这些常被引用的统计数据的更多见解,请查看 mng.bz/ePzJ

^(2.)五个 DC 出租车行程样本数据集的 CSV 文件可从 mng.bz/OQrP 获取。

^(3.)示例包括 Google BigQuery:cloud.google.com/bigquery

^(4.)2016 年,一篇有影响力的 哈佛商业评论 文章引用了另一项研究,“糟糕的数据每年为美国造成 3 万亿美元的损失”:mng.bz/Yw47

^(5.)《支付卡行业数据安全标准》具体规定了存储持卡人数据的要求:www.pcicomplianceguide.org/faq/#1

^(6.)Luhn 算法以 IBM 科学家汉斯·彼得·卢恩命名:spectrum.ieee.org/hans-peter-luhn-and-the-birth-of-the-hashing-algorithm

^(7.)切斯特顿论坛,印第安纳州的一家日报,发表了有关瓦尔帕莱索惨败的文章: mng.bz/GOAN

^(8.)时间戳以月/日/年 时:分 的格式存储为字符串。

^(9.)关于在本章中阅读到的关于 SELECT 的警告不适用于 COUNT(),因为在 SQL 中,这两者是基本不同的操作:前者返回每一行的所有列的值,而后者仅返回行数。

^(10.)样本量预计至少包含几十个记录,并且记录应该是独立的,并带有替换抽样。

第四章:更多的探索性数据分析和数据准备

本章涵盖内容

  • 分析华盛顿特区出租车数据集的摘要统计信息

  • 评估用于机器学习的替代数据集大小

  • 使用统计量选择合适的机器学习数据集大小

  • 在 PySpark 作业中实现数据集抽样

在上一章中,您开始分析了华盛顿特区出租车费用数据集。在将数据集转换为适合分析的 Apache Parquet 格式后,您检查了数据架构,并使用 Athena 交互式查询服务来探索数据。数据探索的初步步骤揭示了许多数据质量问题,促使您建立严谨的方法来解决机器学习项目中的垃圾进、垃圾出问题。接下来,您了解了用于数据质量的 VACUUM 原则,并通过几个案例研究说明了这些原则的现实相关性。最后,您对华盛顿特区出租车数据集应用了 VACUUM 进行了“清洁”,准备了一个足够质量的数据集,以便从中进行机器学习的抽样。

本章继续使用经过 VACUUM 处理的数据集进行更深入的数据探索。在本章中,您将分析数据集的摘要统计信息(算术平均值、标准差等等),以便更明智地确定用于机器学习的训练、验证和测试数据集的大小。您将比较常见的数据集大小选择方法(例如,使用 70/15/15% 的划分)和根据数据集统计信息选择合适大小的方法。您将了解如何使用统计量,如均值标准误、Z 分数和 P 值,来帮助评估替代数据集大小,并学习如何使用 PySpark 实现基于数据的实验来选择合适的大小。

4.1 数据采样入门

本节向您介绍了一种更严谨、基于数据驱动且可重复使用的方法,用于选择适合您数据集的正确训练、验证和测试数据集分割大小。利用华盛顿特区出租车数据的示例,您将探索选择正确数据集大小所需的关键统计量,然后使用一个可以重复使用于其他数据集的数据集大小选择方法来实现一个 PySpark 作业。

我经常听到初级机器学习从业者提出的一个最常见的问题是关于训练、验证和测试数据集的数据集大小的选择。这应该不足为奇,因为在线课程、博客和机器学习教程经常使用像 70/15/15% 这样的数字,意味着项目数据集的 70% 应该分配给训练,15% 分配给验证,15% 分配给留出测试数据。一些课程主张使用 80/10/10% 的分割或 98/1/1% 的“大数据”数据集。著名的 Netflix Prize 使用了大约 97.3/1.35/1.35% 的分割来处理大约 1 亿条记录的数据集,但体积不到 1 GB,它应该被视为“大数据”吗?

4.1.1 探索清理后数据集的汇总统计

在本节中,您将将清理后的数据集元数据加载为 pandas DataFrame 并探索数据集的汇总统计(包括计数、算术平均数、标准差等)。

在第三章结束时,除了清理后的数据集之外,dctaxi_parquet_vacuum.py PySpark 作业使用 save_stats_metadata 函数保存了一些带有数据集统计描述的元数据信息,包括每列值的总行数、均值、标准差、最小值和最大值。要将此信息读入名为 df 的 pandas DataFrame 中,请执行以下代码:

!pip install fsspec s3fs        ❶

import s3fs
import pandas as pd

df = pd.read_csv(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-
➥ {os.environ['AWS_DEFAULT_REGION']}/parquet/
➥ vacuum/.meta/stats/*")      ❷

print(df.info())

❶ 安装 pandas 读取 S3 所需的 Python 包。

❷ 将元数据读入 pandas DataFrame。

该代码在您的环境中安装了 s3fs 库,以使用 pandas read_csv API 访问来自 S3 的数据。代码的其余部分列出了 S3 存储桶的 parquet/vacuum/.meta/stats/* 子文件夹中的对象,并从该文件夹中的 CSV 文件读取内容到 pandas DataFrame 中。

数据帧的 info 方法的输出报告存储的数据的模式以及数据消耗的内存量。

清单 4.1 dctaxi_parquet_vacuum.py 元数据的 df.info() 输出

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 5 entries, 0 to 4
Data columns (total 10 columns):
 #   Column                              Non-Null Count  Dtype
---  ------                              --------------  -----
 0   summary                             5 non-null      object
 1   fareamount_double                   5 non-null      float64
 2   origin_block_latitude_double        5 non-null      float64
 3   origin_block_longitude_double       5 non-null      float64
 4   destination_block_latitude_double   5 non-null      float64
 5   destination_block_longitude_double  5 non-null      float64
 6   year_integer                        5 non-null      float64
 7   month_integer                       5 non-null      float64
 8   dow_integer                         5 non-null      float64
 9   hour_integer                        5 non-null      float64
dtypes: float64(9), object(1)
memory usage: 528.0+ bytes
None

请注意,清单 4.1 中的模式与第二章中的 SQL 查询使用的模式保持一致,只有一些小的变化:数据帧使用 float64 而不是 DOUBLE,并且使用 object 代替 STRING。此外,DC 出租车数据集中没有 summary 列。summary 列是通过第三章的 dctaxi_parquet_vacuum.py PySpark 作业的 describe 方法创建的,并用于存储每行在元数据表中的统计函数的名称,如平均值和计数。

要开始,您可以使用 summary 列索引数据帧并查看结果

summary_df = df.set_index('summary')
summary_df

这将产生

04_table_4-1color

让我们将数据集的大小(即每列的值的数量)保存到一个单独的变量 ds_size 中,稍后将使用它:

ds_size = summary_df.loc['count'].astype(int).max()
print(ds_size)

执行后,这将打印 14262196。

用于获取数据集大小的代码依赖于 max 方法,在数据集的所有列中找到最大值。对于经过清理的 DC 出租车数据集,所有列都返回相同的计数,因为它们都不包含 NULL、None 或 NaN 值。尽管对于 DC 出租车数据集来说 max 是不必要的,但继续使用该函数来正确计算存储数据所需的最大行数是一个好的做法。

由于接下来的章节将重点涉及从数据中采样,所以创建两个单独的序列来收集数据集的均值(mu)

mu = summary_df.loc['mean']
print(mu)

这将输出

fareamount_double                        9.74
origin_block_latitude_double            38.90
origin_block_longitude_double          -77.03
destination_block_latitude_double       38.91
destination_block_longitude_double     -77.03
year_integer                         2,016.62
month_integer                            6.57
dow_integer                              3.99
hour_integer                            14.00
Name: mean, dtype: float64

和标准差(sigma)统计数据

sigma = summary_df.loc['stddev']
print(sigma)

打印如下所示:

fareamount_double                     4.539085
origin_block_latitude_double          0.014978
origin_block_longitude_double         0.019229
destination_block_latitude_double     0.017263
destination_block_longitude_double    0.022372
year_integer                          1.280343
month_integer                         3.454275
dow_integer                           2.005323
hour_integer                          6.145545
Name: stddev, dtype: float64

4.1.2 为测试数据集选择合适的样本大小

在本节中,您将探索使用机器学习“经验法则”选择数据集大小的有效性,并决定 DC 出租车数据集的合适大小。尽管本节以 DC 出租车数据集为例,但您将学习一种在使用实际数据集时选择正确大小的方法。

现在您已经了解了清理数据集中数值列的平均值,您准备好回答将数据集中多少记录分配给机器学习模型训练,以及有多少记录保留给测试和验证数据集的问题了。在准备训练、验证和测试数据集时,许多机器学习从业者依赖于经验法则或启发式方法来确定各个数据集的大小。有些人主张使用 80/10/10%的训练、验证和测试划分,而其他人则声称当数据集很大时,划分应为 98/1/1%,而不指定“大”是什么意思。

在处理分配给训练、验证和测试数据集的记录数量时,回顾它们的基本原理是有价值的。选择训练数据集和测试数据集的合适百分比之间存在困难的原因是它们本质上是相互对立的。一方面,用于机器学习模型训练的数据集的百分比应尽可能大。另一方面,用于测试的数据集的百分比应足够大,以便训练后的机器学习模型在测试数据集上的性能是对该模型在未知样本中的预期表现的有意义的估计。

测试和验证数据集

本书中描述的测试数据集将不会用于检查模型是否过拟合。虽然一些机器学习文献使用测试数据集来确保模型的泛化,但本书将使用一个单独的验证数据集来实现此目的。本书使用的方法如下图所示。

004-UN01

本章中将项目的清理数据集分为开发集和测试集。接下来的章节将涵盖将开发数据集进一步分为训练集和验证数据集。

您可以使用统计学的一些基本结果来帮助您选择大小。思路是确保测试数据集足够大,以便在统计上与整个数据集相似。

首先,考虑用于测试数据的数据集的上限和下限。对于上限,在训练时使用 70%,您可以分配 15%进行验证和测试。在下限方面,您可以考虑将仅 1%用于测试和验证。为更好地说明下限的概念,让我们考虑分配 0.5%的数据进行测试的更极端情况。

您可以使用以下内容获取各种百分比(分数)的记录数:

fractions = [.3, .15, .1, .01, .005]
print([ds_size * fraction for fraction in fractions])

它的返回值为

[4278658.8, 2139329.4, 1426219.6, 142621.96, 71310.98]

在处理样本大小时,将其转化为 2 的幂次方会很有帮助。这是有帮助的原因有几个。当从样本计算统计学(例如,样本均值的标准误差)时,您会发现需要指数级地改变数据集的大小才能实现统计量的线性变化。此外,在统计公式中,取样本大小的平方根很常见,而从 2 的幂次方开始会简化计算。

要找出数据集分数的二次幂估计值,可以使用以下代码:

from math import log, floor
ranges = [floor(log(ds_size * fraction, 2)) for fraction in fractions]
print(ranges)

请注意,该代码以 30%到 0.5%的近似数据集分数的实际记录数的基 2 对数为基础。由于对数值可以是非整数值,因此 floor 函数返回以 2 的幂次方存储近似数据集分数的数据集大小。

因此,代码的输出为

[22, 21, 20, 17, 16]

对应于从 2²² = 4,194,304 到 2¹⁶ = 65,536 的范围。

尽管此范围内的数据集可以轻松适应现代笔记本电脑的内存,但让我们尝试进行实验,以确定可以对数据集进行抽样并仍然用于报告机器学习模型准确性能指标的最小数据集。实验的有价值之处不在于发现,而在于说明寻找正确样本大小的过程。该过程有价值,因为即使在更大的数据集中也可以重复。

在这个实验中,让我们继续使用上部范围作为最大样本大小,2²² = 4,194,304,但从范围较小的 2¹⁵ = 32,768 开始:

sample_size_upper, sample_size_lower = max(ranges) + 1, min(ranges) - 1
print(sample_size_upper, sample_size_lower)

代码返回的最大和最小值如下:

(23, 15)

给定范围,您可以通过运行以下内容来计算其近似数据集分数的程度:

sizes = [2 ** i for i in range(sample_size_lower, sample_size_upper)]
original_sizes = sizes
fracs = [ size / ds_size for size in sizes]
print(*[(idx, sample_size_lower + idx, frac, size) \
  for idx, (frac, size) in enumerate(zip(fracs, sizes))], sep='\n')

它的结果为

(0, 15, 0.0022975423980991427, 32768)
(1, 16, 0.004595084796198285, 65536)
(2, 17, 0.00919016959239657, 131072)
(3, 18, 0.01838033918479314, 262144)
(4, 19, 0.03676067836958628, 524288)
(5, 20, 0.07352135673917257, 1048576)
(6, 21, 0.14704271347834513, 2097152)
(7, 22, 0.29408542695669027, 4194304)

它显示 2¹⁵的测试数据集大小仅覆盖约 0.23%的数据集,而测试数据大小为 2²²则覆盖约 29.4%。

4.1.3 探索替代样本大小的统计信息

本节描述了如何使用均值的标准误差统计量以及收益递减(边际)来生成候选大小(以记录数表示)的测试数据集。在下面的清单中,清单 4.2 中的 sem_over_range 函数计算了一个 pandas DataFrame,该 DataFrame 指定了数据集中每一列和每个样本大小从 sample_size_lower 到 sample_size_upper 的标准误差(SEM)。在本例中,范围对应于从 32,768 到 4,194,304 的值。

对每个候选样本大小的每列进行 SEM(标准误差)。

import numpy as np
def sem_over_range(lower, upper, mu, sigma):    ❶
  sizes_series = pd.Series([2 ** i \            ❷
    for i in range(lower, upper + 1)])
  est_sem_df = \                                ❸
    pd.DataFrame( np.outer( (1 / np.sqrt(sizes_series)), sigma.values ),
                        columns = sigma.index,
                        index = sizes_series.values)
  return est_sem_df

sem_df = sem_over_range(sample_size_lower, sample_size_upper, mu, sigma)
sem_df

❶ sem_over_range 函数使用样本范围以及数据集的 mu 和 sigma。

❷ 将样本范围转换为 pandas Series。

❸ 通过计算每个样本大小和列σ的平均值标准误差来创建一个 pandas DataFrame。

清单 4.2 中的 sem_over_range 函数计算了一个 pandas DataFrame,该 DataFrame 指定了数据集中每一列和每个样本大小从 sample_size_lower 到 sample_size_upper 的标准误差(SEM)。在本例中,范围对应于从 32,768 到 4,194,304 的值。

请记住,对于数据集中的任何一列,给定其总体标准差(σ)和列中的记录数(观测值)(n),SEM 定义为004-UN01_EQ01

由于清单 4.2 中的 sem_df DataFrame 返回的原始 SEM 值不易解释,因此绘制图形以说明随着样本大小增长 SEM 的总体变化趋势是有价值的。您可以使用 matplotlib 库显示此趋势,绘制 sem_df 数据框中各列的平均 SEM 值,如下所示

import matplotlib.pyplot as plt
%matplotlib inline

plt.figure(figsize = (12, 9))
plt.plot(sem_df.index, sem_df.mean(axis = 1))
plt.xticks(sem_df.index,
           labels = list(map(lambda i: f"2^{i}",
                              np.log2(sem_df.index.values).astype(int))),
           rotation = 90);

这导致图 4.1。

04-01

图 4.1 样本大小呈指数增长是昂贵的:更大的样本需要指数级的内存、磁盘空间和计算量,同时在标准误差减小方面产生的改进较少。

图 4.1 中的绘图使用二的幂次方作为水平轴上的注释,描述数据框中的样本大小。请注意,该图捕获了样本大小增加时的收益递减趋势。尽管样本大小呈指数增长,但平均 SEM 的斜率(给定样本大小的 SEM 的瞬时变化率)随着每倍增长而趋于平缓。

由于将尽可能多的数据分配给训练数据集是有价值的,您可以利用收益递减启发式方法发现测试数据集的下限大小。思路是找到一个样本大小,以便如果它更大,那么 SEM 的改善将产生收益递减。

要确定样本大小加倍的边际收益点(也称为 边际 ),您可以从每次样本大小增加时 SEM 的总减少开始。这是使用以下代码片段中的 sem_df.cumsum() 计算的。然后,为了获得每个样本大小的单个聚合度量,mean(axis = 1) 计算数据集中列之间 SEM 总减少的平均值:

agg_change = sem_df.cumsum().mean(axis = 1)
agg_change

生成

32768     0.01
65536     0.02
131072    0.02
262144    0.03
524288    0.03
1048576   0.03
2097152   0.03
4194304   0.03
8388608   0.04
dtype: float64

agg_change pandas 系列的值在图 4.2 中被绘制出来。请注意,箭头突出显示的样本大小对应于 220 的样本大小,也是由于增加样本大小而导致 SEM 减少开始产生边际收益的点。

04-02

图 4.2 边际样本大小对应于边际收益点之前的最大样本大小。

此时,边际可以使用以下边际函数在 Python 中计算:

import numpy as np

def marginal(x):
  coor = np.vstack([x.index.values,
            x.values]).transpose()          ❶

  return pd.Series(index = x.index,         ❷
    data = np.cross(coor[-1] - coor[0], coor[-1] - coor) \
             / np.linalg.norm(coor[-1] - coor[0])).idxmin()

SAMPLE_SIZE = marginal(agg_change).astype(int)
SAMPLE_SIZE, SAMPLE_SIZE / ds_size

❶ 创建一个 NumPy 数组,其中数据点在 x 轴上的样本大小,SEM 值在 y 轴上。

❷ 计算数据点到连接最大和最小样本大小数据点的虚拟线的距离。

在这里,通过查看样本大小的数据点与 SEM 累积减少之间的关系,绘制连接最小和最大样本大小的虚线(图 4.2 中的虚线)并识别与虚拟线右角最远距离的数据点来计算边际。

当应用于 DC 出租车数据集时,边际函数计算如下内容:

(1048576, 0.07352135673917257)

在这里,通过边际收益启发法选择的边际测试样本大小对应于 1,048,576 条记录,或者大约是数据集的 7%。

如果可能的话,使用任意 1,048,576 条记录的样本作为测试数据集将有助于最大化可用于机器学习模型训练的数据量。然而,SEM 测量旨在确定样本大小的 下限 ,并不表示这种大小的任意数据集都适合用作测试数据集。

您可以使用 1,048,576 条记录的 p 值来建立对样本的置信度,从而回答统计假设检验的基本问题:样本来自总体的确信度是多少?

4.1.4 使用 PySpark 作业对测试集进行抽样

在本节中,您将通过使用 PySpark 作业随机采样 1,048,576 条记录(在上一节中确定的大小)来创建测试数据集进行实验。一旦采样了测试集,剩余的记录将持久保存到一个单独的 DC 出租车开发数据集中。开发和测试数据集还被分析以计算 p 值以及其他摘要统计信息。

由于整个 PySpark 作业的实现大约有 90 行代码,在本节中,作业被介绍为一系列代码片段。作业的前文部分,在列表 4.3 中显示的,类似于第 2 和第三章中的 PySpark 作业。与早期章节一样,作业的这一部分导入相关库并解析作业参数。

在 dctaxi_dev_test.py 中的第 4.3 节代码中读取的 PySpark DataFrame。

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job

args = getResolvedOptions(sys.argv, ['JOB_NAME',
                                     'BUCKET_SRC_PATH',
                                     'BUCKET_DST_PATH',
                                     'SAMPLE_SIZE',
                                     'SAMPLE_COUNT',
                                     'SEED'
                                     ])

sc = SparkContext()
glueContext = GlueContext(sc)
logger = glueContext.get_logger()
spark = glueContext.spark_session

job = Job(glueContext)
job.init(args['JOB_NAME'], args)

BUCKET_SRC_PATH = args['BUCKET_SRC_PATH']
df = ( spark.read.format("parquet")
        .load( f"{BUCKET_SRC_PATH}" ))      ❶

❶ 根据 BUCKET_SRC_PATH 参数构建一个 pandas DataFrame df。

与从清理后的 DC 出租车数据集中抽样有关的实现始于列表 4.4,其中计算整个数据集大小的样本分数,并将其保存到变量 sample_frac 中。为了在 PySpark 中计算清理后数据集的摘要统计信息,实现依赖于 Kaen 库的 PySpark 实用函数 spark_df_to_stats_pandas_df,该函数从名为 df 的 PySpark DataFrame 实例返回 pandas DataFrame。然后,pandas summary_df 提供了对清理后数据集中每列的平均值(mu)和标准差(sigma)的标准 pandas DataFrame API 访问。

在 dctaxi_dev_test.py 中的第 4.4 节代码中读取的 PySpark DataFrame。

SAMPLE_SIZE = float( args['SAMPLE_SIZE'] )   
dataset_size = float( df.count() )
sample_frac = SAMPLE_SIZE / dataset_size            ❶

from kaen.spark import spark_df_to_stats_pandas_df, \
                      pandas_df_to_spark_df, \
                      spark_df_to_shards_df         ❷

summary_df = spark_df_to_stats_pandas_df(df)        ❸
mu = summary_df.loc['mean']                         ❹
sigma = summary_df.loc['stddev']                    ❺

❶ 根据 Spark 的 randomSplit 方法所需的样本大小,以分数的形式表示。

❷ 从 kaen 包中导入 Spark 和 pandas 实用工具。

❸ 创建包含 Spark DataFrame 统计信息的 pandas DataFrame。

❹ 将数据集的平均值保存为 mu。

❺ 将数据集的标准差保存为 sigma。

汇总统计信息以及 sample_frac 值在列表 4.5 中用于执行随机抽样。PySpark 的 randomSplit 方法将经过清理的 DC 出租车数据集分割为 test_df,其中包含最多 SAMPLE_SIZE 行,并且总计来自 df 数据帧的 sample_frac 的整个数据集。

在 dctaxi_dev_test.py 中的第 4.5 节代码中读取的 PySpark DataFrame。

SEED = int(args['SEED'])                                ❶
SAMPLE_COUNT = int(args['SAMPLE_COUNT'])                ❷
BUCKET_DST_PATH = args['BUCKET_DST_PATH']

for idx in range(SAMPLE_COUNT):
  dev_df, test_df = ( df                                ❸
                      .cache()
                      .randomSplit([1.0 - sample_frac,
                                      sample_frac],     ❹
                                    seed = SEED) )

  test_df = test_df.limit( int(SAMPLE_SIZE) )           ❺

  test_stats_df = \                                     ❻
    spark_df_to_stats_pandas_df(test_df, summary_df,
                                  pvalues = True, zscores = True)

  pvalues_series = test_stats_df.loc['pvalues']
  if pvalues_series.min() < 0.05:
    SEED = SEED + idx                                   ❼
  else:
    break

❶ 使用 SEED 初始化伪随机数生成器。

❷ 通过使用最多 SAMPLE_COUNT 个样本,解决了选择不佳(p 值 < 0.05)的 SEED 值的问题。

❸ 将测试数据集抽样到 Spark 的 test_df DataFrame 中,其余抽样到 dev_df。

❹ 使用 df 中记录的 sample_frac 分数作为测试数据集。

❺ 确保 test_df 最多仅包含 SAMPLE_SIZE 条记录。

❻ 创建一个包含 test_df 摘要统计信息的 pandas test_stats_df DataFrame。

❼ 在出现不良样本(p 值 < 0.05)的情况下再次抽样,最多抽样 SAMPLE_COUNT 次。

列表 4.6 中显示的作业实现部分负责将开发(dev_df)和测试(test_df)数据集保存到 S3。对于每个数据集,Spark 将记录保存为 CSV 格式,带有标头信息,保存到 BUCKET_DST_PATH 中。此外,对于开发和测试,该实现还将其他元数据(稍后在本节中显示)保存到 BUCKET_DST_PATH 的子文件夹中:.meta/stats 和 .meta/shards。

stats 子文件夹存储一个包含摘要统计信息的 CSV 文件,包括计数、均值、p 值等。 shards 子文件夹被存储以便在训练期间处理数据集,并存储关于用于将数据集保存在 S3 中的 CSV 部分文件数和每个部分文件中的记录数的元数据。

列表 4.6 dctaxi_dev_test.py 中的 PySpark DataFrame 读取代码

for df, desc in [(dev_df, "dev"), (test_df, "test")]:
    ( df
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}") )

    stats_pandas_df = \
    spark_df_to_stats_pandas_df(df,
                                summary_df,
                                pvalues = True,
                                zscores = True)
    ( pandas_df_to_spark_df(spark,  stats_pandas_df)
    .coalesce(1)
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/stats") )

    ( spark_df_to_shards_df(spark, df)
    .coalesce(1)
    .write
    .option('header', True)
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/shards") )

job.commit()

为了方便起见,下面展示了 PySpark 作业的完整实现,它应该被保存在一个名为 dctaxi_dev_test.py 的文件中。

列表 4.7 PySpark dctaxi_dev_test.py 作业以抽样开发和测试数据集

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job

args = getResolvedOptions(sys.argv, ['JOB_NAME',
                                     'BUCKET_SRC_PATH',
                                     'BUCKET_DST_PATH',
                                     'SAMPLE_SIZE',
                                     'SAMPLE_COUNT',
                                     'SEED'
                                     ])

sc = SparkContext()
glueContext = GlueContext(sc)
logger = glueContext.get_logger()
spark = glueContext.spark_session

job = Job(glueContext)
job.init(args['JOB_NAME'], args)

BUCKET_SRC_PATH = args['BUCKET_SRC_PATH']
df = ( spark.read.format("parquet")
        .load( f"{BUCKET_SRC_PATH}" ))

SAMPLE_SIZE = float( args['SAMPLE_SIZE'] )
dataset_size = float( df.count() )
sample_frac = SAMPLE_SIZE / dataset_size

from kaen.spark import spark_df_to_stats_pandas_df, \
                      pandas_df_to_spark_df, \
                      spark_df_to_shards_df

summary_df = spark_df_to_stats_pandas_df(df)
mu = summary_df.loc['mean']
sigma = summary_df.loc['stddev']

SEED = int(args['SEED'])
SAMPLE_COUNT = int(args['SAMPLE_COUNT'])
BUCKET_DST_PATH = args['BUCKET_DST_PATH']

for idx in range(SAMPLE_COUNT):
  dev_df, test_df = ( df
                      .cache()
                      .randomSplit( [1.0 - sample_frac, sample_frac],
                                    seed = SEED) )
  test_df = test_df.limit( int(SAMPLE_SIZE) )

  test_stats_df = \
    spark_df_to_stats_pandas_df(test_df, summary_df,
                                  pvalues = True, zscores = True)

  pvalues_series = test_stats_df.loc['pvalues']
  if pvalues_series.min() < 0.05:
    SEED = SEED + idx
  else:
    break

for df, desc in [(dev_df, "dev"), (test_df, "test")]:
    ( df
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}") )

    stats_pandas_df = \
    spark_df_to_stats_pandas_df(df,
                                summary_df,
                                pvalues = True,
                                zscores = True)

    ( pandas_df_to_spark_df(spark,  stats_pandas_df)
    .coalesce(1)
    .write
    .option('header', 'true')
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/stats") )

    ( spark_df_to_shards_df(spark, df)
    .coalesce(1)
    .write
    .option('header', True)
    .mode('overwrite')
    .csv(f"{BUCKET_DST_PATH}/{desc}/.meta/shards") )

job.commit()

在 dctaxi_dev_test.py 文件中执行 PySpark 作业之前,你需要配置几个环境变量。应使用相应 Python 变量的值设置 SAMPLE_SIZE 和 SAMPLE_COUNT 操作系统环境变量:

os.environ['SAMPLE_SIZE'] = str(SAMPLE_SIZE)
os.environ['SAMPLE_COUNT'] = str(1)

与上一章节类似,PySpark 作业使用 utils.sh 脚本中的便捷函数执行。首先,在你的 bash shell 中使用以下命令下载该脚本到你的本地环境:

wget -q --no-cache https://raw.githubusercontent.com/
➥ osipov/smlbook/master/utils.sh

一旦 utils.sh 脚本被下载,你可以使用它来启动和监视 dctaxi_dev_test.py 文件中实现的 PySpark 作业。在你的 shell 环境中运行以下命令来启动该作业:

source utils.sh

PYSPARK_SRC_NAME=dctaxi_dev_test.py \
PYSPARK_JOB_NAME=dc-taxi-dev-test-job \
ADDITIONAL_PYTHON_MODULES="kaen[spark]" \
BUCKET_SRC_PATH=s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/parquet/vacuum \
BUCKET_DST_PATH=s3://dc-taxi-$BUCKET_ID-$AWS_DEFAULT_REGION/csv \
SAMPLE_SIZE=$SAMPLE_SIZE \
SAMPLE_COUNT=$SAMPLE_COUNT \
SEED=30 \
run_job

注意,该作业将要读取第三章保存在 parquet/vacuum 子文件夹中的 Parquet 文件,并将开发和测试数据集保存在你的 S3 存储桶的 csv/dev 和 csv/test 子文件夹下。该作业在 AWS Glue 上应该需要大约八分钟才能完成。假设它成功完成,它应该会产生以下类似的输出:

Attempting to run a job using:
  PYSPARK_SRC_NAME=dctaxi_dev_test.py
  PYSPARK_JOB_NAME=dc-taxi-dev-test-job
  AWS_DEFAULT_REGION=us-west-2
  BUCKET_ID=c6e91f06095c3d7c61bcc0af33d68382
  BUCKET_SRC_PATH=s3://dc-taxi-c6e91f06095c3d7c61bcc0af33d68382-
➥   us-west-2/parquet/vacuum
  BUCKET_DST_PATH=s3://dc-taxi-c6e91f06095c3d7c61bcc0af33d68382-
➥   us-west-2/csv
  SAMPLE_SIZE=1048576
  SAMPLE_COUNT=1
  BINS=
  SEED=30
upload: ./dctaxi_dev_test.py to s3://dc-taxi-
➥   c6e91f06095c3d7c61bcc0af33d68382-us-west-2/glue/dctaxi_dev_test.py
2021-08-15 17:19:37       2456 dctaxi_dev_test.py
{
    "JobName": "dc-taxi-dev-test-job"
}
{
    "Name": "dc-taxi-dev-test-job"
}
{
    "JobRunId": [CA
    "jr_05e395544e86b1534c824fa1559ac395683f3e7db35d1bb5d591590d237954f2"
}
Waiting for the job to finish......................................SUCCEEDED

由于 PySpark 作业保留了关于数据集的元数据,你可以使用 pandas 预览元数据的内容。为了预览测试集的统计摘要,请执行以下 Python 代码:

pd.options.display.float_format = '{:,.2f}'.format

test_stats_df = pd.read_csv(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-
➥ {os.environ['AWS_DEFAULT_REGION']}/csv/test/.meta/stats/*.csv")

test_stats_df = test_stats_df.set_index('summary')
test_stats_df

假设 PySpark 作业执行正确,对于测试数据集的 test_stats_df 的打印输出应该类似于以下内容:

04_table_4-2color

关于开发数据集的 CSV 部分文件(shards)的元数据应该已保存到你的 S3 存储桶的 csv/dev/.meta/shards 子文件夹中。如果你使用以下代码预览此元数据中的 pandas DataFrame

import pandas as pd
dev_shards_df = pd.read_csv(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-
➥ {os.environ['AWS_DEFAULT_REGION']}/csv/dev/.meta/shards/*")

dev_shards_df.sort_values(by = 'id')

输出应该包含一个三列表,其中 id 列存储来自 S3 中 csv/dev 子文件夹的 CSV 部分文件的 ID,而 count 列中的相应条目指定了部分文件中的行数。数据框的内容应该类似于以下内容:

id count
39 0 165669
3 1 165436
56 2 165754
53 3 165530
63 4 165365

...

72 75 164569
59 76 164729
2 77 164315
11 78 164397
22 79 164406

摘要

  • 使用固定百分比的启发式方法来选择保留的测试数据集的大小可能会浪费宝贵的机器学习模型训练数据。

  • 测量数据集大小增加导致递减的结果,有助于选择测试和验证数据集大小的下限。

  • 确保测试数据集具有足够的 z 分数和 p 值,可以防止选择机器学习时数据集大小过小。

  • 无服务器 PySpark 作业可用于评估替代测试数据集,并报告它们的统计摘要。

第二部分:用于无服务器机器学习的 PyTorch

在开始使用 PyTorch 之前,我花了几年时间使用 TensorFlow 的 1 和 2 版本。自从我转向 PyTorch 以来,我作为一个机器学习从业者变得更加高效,并且我发现学习和使用 PyTorch 的经历令人愉快。我想与本书的读者分享这种经历。在这个过程中,我旨在帮助你掌握 PyTorch 的核心元素,指导你了解框架中可用的抽象级别,并准备好从单独使用 PyTorch 转变为使用在 PyTorch 中实现并集成到更广泛的机器学习流水线中的机器学习模型。

  • 在第五章,我涵盖了 PyTorch 的基础知识,介绍了核心张量应用程序接口(API),并帮助你掌握使用该 API 的流畅度,以降低后续章节的学习曲线。

  • 在第六章,你将专注于学习 PyTorch 的深度学习方面,包括自动微分的支持、替代梯度下降算法和支持的实用工具。

  • 在第七章,你将通过学习图形处理单元(GPU)的特性以及如何利用 GPU 加速你的机器学习代码,来扩展你的 PyTorch 程序。

  • 在第八章,你将学习有关分布式 PyTorch 训练的数据并行方法,并深入探讨传统的基于参数服务器的方法与基于环形的分布式训练(例如 Horovod)之间的区别。

第五章:PyTorch 介绍:张量基础

本章涵盖

  • 介绍 PyTorch 和 PyTorch 张量

  • 使用 PyTorch 张量创建方法

  • 理解张量操作和广播

  • 探索在 CPU 上的 PyTorch 张量性能

在上一章中,你从 DC 出租车数据集的清理版本开始,并应用了数据驱动的抽样过程,以确定要分配给一个保留的测试数据子集的数据集的正确部分。你还分析了抽样实验的结果,然后启动了一个 PySpark 作业来生成三个不同的数据子集:训练、验证和测试。

这一章将暂时偏离 DC 出租车数据集,为你准备好使用 PyTorch 编写可扩展的机器学习代码。别担心;第七章会回到 DC 出租车数据集,以基准测试基线 PyTorch 机器学习模型。在本章中,你将专注于学习 PyTorch,这是深度学习和许多其他类型的机器学习算法的顶级框架之一。我曾在需要在机器学习平台上进行分布式训练的机器学习项目中使用过 TensorFlow 2.0、Keras 和 PyTorch,并发现 PyTorch 是最好的选择。PyTorch 可从特斯拉的关键生产机器学习用例¹扩展到 OpenAI 的最新研究²。

由于你需要在开始将它们应用于 DC 出租车数据集的机器学习之前对核心 PyTorch 概念有实际的理解,所以本章重点是为你提供对核心 PyTorch 数据结构:张量的深入知识。大多数软件工程师和机器学习实践者在数学、编程或数据结构课程中都没有使用张量,所以如果这是新的,你不应感到惊讶。

在第 5.1 节中,我介绍了 PyTorch 张量的全面定义。暂时记住,如果你曾经在编程语言中使用数组的数组(即,包含其他数组的数组)实现过矩阵,那么你已经在理解张量的路上走得很远了。作为一个工作定义,你可以将张量视为一种通用数据结构,可以存储和操作变量、数组、矩阵及其组合。在本书中,你遇到的最复杂的张量实际上是矩阵的数组,或者如果你更喜欢更递归的描述,是数组的数组的数组。

5.1 开始使用张量

本节在机器学习用例的背景下定义了张量,解释了张量的属性,包括张量的维度和形状,并最终向你介绍了使用 PyTorch 创建张量的基础知识,而不是使用本地 Python 数据类型。通过本节的结论,你应该准备好研究 PyTorch 张量相对于本地 Python 数据类型在机器学习用例中的优势了。

张量一词在数学、物理或计算机科学中使用时有微妙不同的定义。虽然从数学中了解张量的几何解释或从物理学中了解张量的应力力学解释可以丰富您对张量抽象方面的理解,但本书使用一个更狭窄的定义,更符合将张量应用于机器学习的从业者。本书中,该术语描述了一种数据结构(即数据容器),用于基本数据类型,如整数、浮点数和布尔值。

由于张量与数组密切相关,因此值得花一点时间回顾数组或 Python 列表的关键属性。数组只是数据值的有序集合。在大多数编程语言中,数组索引可以取值于一组有限的整数,基于数组中元素数量减一的范围。³例如,在 Python 中,这个范围是

range(len(a_list))

其中 a_list 是 Python 列表实例的名称。因此,不同的数组具有不同的有效索引值。相反,所有由基本数据类型组成的数组,无论长度如何,其张量维度都等于一。

维度在这里定义为访问数据结构中值所需的索引总数(而不是索引的值)。这个定义很方便,因为它帮助使用一个数字描述不同的数据结构。例如,矩阵的维度为二,因为需要两个索引,行索引和列索引,才能在矩阵中定位数据值。例如,使用 Python,一个简单的矩阵实现可以使用列表的列表:

mtx = [[3 * j + i for i in range(3)] for j in range(3)]
print(mtx[1][2])

其中 mtx 的求值结果为

[[0, 1, 2],
 [3, 4, 5],
 [6, 7, 8]]

和 mtx[1][2]的值打印为 5。由于矩阵的维度为二,因此必须指定两个索引值——行索引为 1,列索引为 2——才能访问矩阵中 5 的值。

维度还指定了实现数据结构所需的数组嵌套的度量。例如,维度为 2 的 mtx 需要一个数组的数组,而维度为 3 的矩阵数组需要一个数组的数组的数组。如果考虑一个维度为 0 的数据结构,换句话说,需要零个索引来访问值的数据结构,很快就会意识到这个数据结构只是一个常规变量。对于维度(也称为张量秩)为 0、1、2 和 3 的张量的可视化,请参阅图 5.1。

05-01

图 5.1 张量秩(维度)等于访问张量中数据值所需的索引数量。与较低秩的张量不同,机器学习中没有公认的命名。

张量是一种能够存储任意数量维度数组的数据结构,或者更简洁地说,张量是一个 n 维数组。根据此定义,一个平面 Python 列表或任何扁平化的数组都是一维张量,有时也被描述为秩 1 张量。Python 变量是零维张量,通常被描述为标量,或者更少见地被描述为秩 0 张量。一个二维张量通常被称为矩阵。对于更高维的例子,请考虑用于表示灰度图像的矩阵,其中像素值为 0 为黑色,255 为白色,中间的数字为逐渐增加亮度的灰色颜色。然后,三维张量是一种有序灰度图像集合的便捷数据结构,因此三个指数中的第一个指定图像,其余两个指定图像中像素的行和列位置。三维张量也适用于彩色图像(但不适用于彩色图像集合),因此第一个指数指定颜色为红色、绿色、蓝色或不透明度(alpha)通道,而其余指数指定相应图像中的像素位置。继续这个例子,四维张量可以用于顺序的彩色图像集合。

有了这些基础知识,您就可以准备在 PyTorch 中创建您的第一个张量了。

列表 5.1 使用 PyTorch 实现的秩 0 张量

import torch as pt        ❶
alpha = pt.tensor(42)     ❷
alpha

❶ 导入 PyTorch 库并将其别名为 pt。

❷ 创建一个值为 42 的秩 0 张量(标量)。

一旦执行了这段代码,它会输出

tensor(42)

在导入 PyTorch 库并将其别名为 pt ❶ 之后,代码的下一行 ❷ 简单地创建一个标量(秩 0 张量)并将其赋值给一个名为 alpha 的变量。在 64 位 Python 运行时执行时,从列表 5.1 中的值 42 被表示为 64 位整数,alpha 张量将使用 PyTorch 的 torch.LongTensor 类进行实例化。

对于任何 PyTorch 张量,您可以使用 type 方法来发现用于实例化张量的特定张量类:

alpha.type()

这会输出

torch.LongTensor

torch.LongTensor,以及其他用于各种基本 Python 数据类型的张量类,都是 torch.Tensor 类的子类。⁴ torch.Tensor 的子类包括对不同处理器架构(设备)的支持;例如,torch.LongTensor 是具有 CPU 特定张量实现的类,而 torch.cuda.LongTensor 是具有 GPU 特定张量实现的类。有关 PyTorch 对 GPU 的支持,将在第七章中详细描述。

在您的机器学习代码中,您应主要依赖于张量的 dtype 属性,而不是 type 方法,因为 dtype 以与设备无关的方式返回张量的类型,确保您的代码可以轻松地在不同设备之间移植。对于 alpha 的 dtype,

alpha.dtype

输出数据类型的与设备无关的描述⁵

torch.int64

要访问张量存储的值,您可以使用 item 方法

alpha.item()

在这种情况下显示 42。

要确认 alpha 张量是一个标量,您可以访问张量的 shape 属性,

alpha.shape

打印出 torch.Size([])。

PyTorch 库使用 torch.Size 类来指定张量的大小(也称为形状)的详细信息。在这里,大小由一个空的、长度为零的列表组成,因为 alpha 标量的秩为 0。一般来说,torch.Size 列表的长度等于张量的维度。例如,

len(alpha.shape)

输出 0。张量的形状指定了沿张量维度存储的元素数量。例如,从 Python 列表创建的一个一维 PyTorch 张量的前五个斐波那契数,

arr = pt.tensor([1, 1, 2, 3, 5])
arr.shape

产生了 torch.Size([5]),这证实了 arr 张量的第一个和唯一维度中有五个元素。

如果您从 Python 列表的列表中创建一个 PyTorch 矩阵(秩为 2 的张量),

mtx = pt.tensor([ [  2,   4,  16,  32,  64],
                  [  3,   9,  27,  81, 243]] )

然后 mtx.shape 返回 torch.Size([2, 5]) 的大小,因为 mtx 矩阵有两行和五列。

标准的 Python 索引和 item 方法继续按预期工作:要检索 mtx 张量左上角的值,您使用 mtx[0][0].item(),它返回 2。

在 PyTorch 中处理秩为 2 或更高的张量时,您需要了解一个重要的默认限制:尾部维度中的元素数量;换句话说,第二个及更高的维度必须保持一致,例如,如果您尝试创建一个第二个(列)维度有四个元素的矩阵,而其他列有五个元素。

用支持变量的 PyTorch 张量的 5.2 列表

pt.tensor([  [  2,   4,  16,  32,  64],
             [  3,   9,  27,  81, 243],
             [  4,  16,  64, 256]        ])

PyTorch 报告了一个错误:

ValueError: expected sequence of length 5 at dim 1 (got 4)

由于 PyTorch 使用零为基础的索引来表示维度,因此第二个维度的索引为 1,如 ValueError 所报告的那样。尽管默认的 PyTorch 张量实现不支持“不规则”张量,但 NestedTensor 包旨在为这类张量提供支持。⁶

5.2 开始使用 PyTorch 张量创建操作

之前,您已经看到您可以从一个值(例如,一个 Python 整数)创建一个 PyTorch 标量张量,并从一组值(例如,从一个 Python 列表)创建一个数组张量;但是,还有其他工厂方法可以帮助您创建张量。在本节中,您将练习使用 PyTorch API 中的工厂方法创建 PyTorch 张量。当创建用于机器学习代码中常见的数学操作的张量时,以及当张量基于非数据集值时,这些方法非常有用。

当使用工厂方法实例化张量时,除非 PyTorch 可以从方法的参数中推断出所需张量的形状(如本节稍后解释的那样),否则将显式指定所需张量的形状。例如,要使用 zeros 工厂方法创建一个两行三列的零矩阵,请使用

pt.zeros( [2, 3] )

产生

tensor([[0., 0., 0.],
        [0., 0., 0.]])

给定张量的一个实例,您可以通过使用张量的 shape 属性来确认张量具有所需的形状,

pt.zeros( [2, 3] ).shape

返回一个 torch.Size 实例,表示形状,本例中与您传递给 zeros 方法的内容匹配:

torch.Size([2, 3])

PyTorch 张量工厂方法允许您通过将一个或多个整数传递给方法来指定张量形状。例如,要创建一个包含 10 个 1 的数组,您可以使用 ones 方法,

pt.ones(10)

返回长度为 10 的数组,

tensor([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])

而 pt.ones(2, 10)返回一个 2 行 10 列的矩阵:

tensor([[1., 1., 1., 1., 1., 1., 1., 1., 1., 1.],
        [1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]])

当使用工厂方法时,您可以为张量中的值指定数据类型。虽然 ones 等方法默认返回浮点数张量,但您可以使用 dtype 属性覆盖默认数据类型。例如,要创建一个整数 1 的数组,您可以调用

pt.ones(10, dtype=pt.int64)

返回

tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1])

其他 PyTorch 支持的数据类型包括 16 位和 32 位整数、16 位、32 位和 64 位浮点数、字节(无符号 8 位整数)和布尔值。⁷

5.3 创建 PyTorch 伪随机和间隔值张量

本节向您介绍了用于创建填充有从常用概率分布中抽样的数据值的张量的 PyTorch API,包括标准正态、正态(高斯)和均匀分布。本节还描述了如何创建由间隔(等间距)值组成的张量。学习本节中描述的 API 将帮助您生成用于测试和故障排除机器学习算法的合成数据集。

深度学习和许多机器学习算法依赖于生成伪随机数的能力。在使用 PyTorch 随机抽样工厂方法之前,您需要调用 manual_seed 方法来设置用于抽样伪随机数的种子值。如果您使用与本书中使用的相同的种子值调用 manual_seed,您将能够重现本节中描述的结果。否则,您的结果看起来会不同。以下代码片段假定您使用的种子值为 42:

pt.manual_seed(42)

设置种子后,如果您使用的是 PyTorch v1.9.0,您应该期望获得与以下示例中相同的伪随机数。randn 方法从标准正态分布中抽样,因此您可以期望这些值的均值为 0,标准差为 1。要创建一个 3×3 的张量以抽样值,调用

pt.randn(3,3)

输出

tensor([[ 0.3367,  0.1288,  0.2345],
        [ 0.2303, -1.1229, -0.1863],
        [ 2.2082, -0.6380,  0.4617]])

要从均值和标准差不同于 1 和 0 的正态分布中抽样值,您可以使用 normal 方法,例如,指定均值为 100,标准差为 10,以及 3 行 3 列的秩 2 张量:

pt.normal(100, 10, [3, 3])

导致

tensor([[102.6735, 105.3490, 108.0936],
        [111.1029,  83.1020,  90.1104],
        [109.5797, 113.2214, 108.1719]])

对于从均匀分布中抽样的伪随机值的张量,您可以使用 randint 方法,例如,从 0(包括)到 10(不包括)均匀抽样,并返回一个 3×3 矩阵:

pt.randint(0, 10, [3, 3])

产生

tensor([[9, 6, 2],
        [0, 6, 2],
        [7, 9, 7]])

randint 和 normal 方法是本书中最常用的方法。PyTorch 提供了一个全面的伪随机张量生成器库,⁸但本书不涵盖所有内容。

如 5.5 节更详细地解释的那样,在创建 Python 整数值列表时会涉及显著的内存开销。相反,您可以使用 arange 方法在指定范围内创建具有间隔(等间距)值的 PyTorch 张量。PyTorch arange 的行为类似于 Python 中的 range 运算符,因此

pt.arange(10)

返回

tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

正如您在使用 Python 时所期望的那样,在 PyTorch 中调用 arange 可以带有附加参数用于范围的起始、结束和步长(有时称为步进),因此要创建一个从 1 到 11 的奇数张量,可以使用

pt.arange(1, 13, 2)

输出

tensor([ 1,  3,  5,  7,  9, 11])

就像 Python range 一样,生成的序列不包括结束序列参数值(第二个参数),而步长被指定为该方法的第三个参数。

而不是必须计算 arange 方法的步长值,使用 linspace 方法并指定结果张量中应存在的元素数量可能更加方便。例如,要创建一个包含值在从 0 开始到 10 结束并包括值 10 的 5 个元素的张量,可以使用 linspace 方法,

pt.linspace(0, 10, 5)

导致

tensor([ 0.0000,  2.5000,  5.0000,  7.5000, 10.0000])

作为实现的一部分,linspace 方法计算适当的步长大小,以便生成的张量中的所有元素之间距离相等。此外,默认情况下,linspace 创建浮点值的张量。

现在您已经熟悉了创建张量的函数,可以继续执行常见张量操作,例如加法、乘法、指数运算等。

5.4 PyTorch 张量操作和广播

本节向您介绍了 PyTorch 张量执行常见数学运算的功能,并澄清了对不同形状的张量应用操作的规则。完成本节后,您将能够在机器学习代码中将 PyTorch 张量作为数学表达式的一部分使用。

由于 PyTorch 重载了标准 Python 数学运算符,包括 +、-、*、/ 和 **,因此使用张量操作非常容易。例如,

pt.arange(10) + 1

等同于调用 pt.arange(10).add(1),两者都输出

tensor([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10])

当添加 PyTorch 张量和兼容的基本 Python 数据类型(浮点数、整数或布尔值)时,PyTorch 会自动将后者转换为 PyTorch 标量张量(这称为类型强制转换)。因此,这些操作

pt.arange(10) + 1

pt.arange(10) + pt.tensor(1)

是等价的。

PyTorch API 的默认实现对张量执行不可变操作。因此,在开发 PyTorch 机器学习代码时,您必须记住加法操作以及其他由 PyTorch 重载的标准 Python 数学运算符都会返回一个新的张量实例。您可以轻松通过运行以下命令进行确认

a = pt.arange(10)
id(a), id(a + 1)

PyTorch 还提供了一组可就地(in-place)操作的操作符,这些操作符可更改张量的值。这意味着 PyTorch 将直接在张量设备的内存中替换张量的值,而不是为张量分配新的 PyObject 实例。例如,使用 add_ 方法

a = pt.arange(10)
id(a), id(a.add_(1))

返回一个带有两个相同对象标识符的元组。

注意 在 PyTorch API 设计中,所有的就地(in place)更改张量的操作都使用 _ 后缀,例如 mul_ 表示就地乘法,pow_ 表示就地幂运算,abs_ 表示就地绝对值函数等等。⁹

在处理机器学习代码时,您肯定会发现自己不得不在非标量张量上执行常见的数学运算。例如,如果给定两个张量 a 和 b,则 PyTorch 找到 a + b 的值是什么意思?

列出 5.3 张量按元素加和,因为它们具有相同的形状

a = pt.arange(10)
b = pt.ones(10)

正如您所期望的那样,因为 a 的值是

tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

b 的值是

tensor([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])

他们的和等于

tensor([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.])

因此,a + b 相当于将张量 a 的每个元素递增 1。这个操作可以被描述为张量 a 和 b 的逐元素相加,因为对于张量 a 的每个元素,PyTorch 找到仅一个对应的张量 b 的元素索引值,并将它们相加产生输出。

如果尝试添加

a = pt.ones([2, 5])
b = pt.ones(5)

在 tensor a 的逻辑中,元素按位相加没有立即意义,这意味着应将张量 a 的哪些元素按 1 递增?应将张量 a 的第一行,第二行还是两行都增加 1?

要理解此示例中加法的原理以及在操作中张量的形状不同时的其他情况,您需要熟悉broadcasting(¹⁰),PyTorch 在幕后执行它来生成 a + b 的下面的结果:

tensor([[2., 2., 2., 2., 2.],
        [2., 2., 2., 2., 2.]])

当操作中使用的张量的形状不相同时,PyTorch 尝试执行张量广播。在两个大小不同的张量上执行操作时,一些维度可能会被重用或广播以完成操作。如果给定两个张量 a 和 b,并且要在两个张量之间执行广播运算,可以通过调用 can_broadcast 来检查能否执行此操作。

列出 5.4,当 can_broadcast 返回 true 时,可以 broadcast

def can_broadcast(a, b):
  return all( [x == y or x == 1 or y == 1 \
    for x, y in zip( a.shape[::-1], b.shape[::-1] ) ])

这个广播规则取决于张量的尾部维度,或者以相反顺序对齐的张量的维度。即使是将标量添加到张量的简单示例也涉及广播:当 a = pt.ones(5) 且 b = pt.tensor(42) 时,它们的形状分别为 torch.Size([5]) 和 torch.Size([])。因此,标量必须像图 5.2 中所示一样广播五次到张量 a。

05-02

图 5.2 将标量 b 广播到秩为 1 的张量 a

广播不需要在内存中复制或复制张量数据;相反,从操作中使用的张量的值直接计算产生广播结果的张量的内容。有效地使用和理解广播可以帮助您减少张量所需的内存量,并提高张量操作的性能。

为了用更复杂的示例说明广播,其中 a = pt.ones([2, 5]) 且 b = pt.ones(5),请注意广播重复使用张量 b 的值(图 5.3 的右侧),以便在生成 a + b 张量时对齐结果的尾部维度,同时保留来自张量 a 的前导维度。

05-03

图 5.3 将秩为 1 的张量 b 广播到秩为 2 的张量 a

根据您目前所见的广播示例,您可能会错误地认为广播只发生在一个方向上:从操作中的一个张量到另一个张量。这是错误的。请注意,根据列表 5.4 中的规则,参与操作的两个张量都可以相互广播数据。例如,在图 5.4 中,张量 a 被广播到张量 b 三次(基于第一维),然后张量 b 的内容沿着相反的方向(沿第二维)广播,以产生尺寸为 torch.Size([3, 2, 5]) 的结果张量 a+b。

05-04

图 5.4 广播是双向的,其中 b 的第一维广播到 a,a 的第二维广播到 b。

5.5 PyTorch 张量 vs. 原生 Python 列表

在本节中,您将深入了解原生 Python 数据结构与 PyTorch 张量在内存使用方面的比较,并了解为什么 PyTorch 张量可以帮助您更有效地利用内存来处理机器学习用例。

大多数现代笔记本电脑使用的中央处理单元(CPU)的运行频率为 2 到 3 GHz。为了保持计算简单,让我们忽略一些现代处理器执行流水线功能的高级指令,并将 2 GHz 处理器频率解释为处理器大约需要半个纳秒(ns)来执行一条单个指令,例如执行加法运算并存储结果的指令。虽然处理器可以在少于 1 ns 的时间内执行一条指令,但处理器必须等待超过 100 倍的时间,从 50 到 100 ns 不等,才能从主计算机内存(动态随机访问存储器)中获取一段数据。当然,处理器使用的一些数据存储在缓存中,可以在单个数字纳秒内访问,但低延迟缓存的大小有限,通常以单个数字 MiB 为单位进行度量。[¹¹]

假设你正在编写一个计算机程序,需要对数据张量执行一些计算,比如处理一个包含 4,096 个整数的数组,并将每个整数加 1。为了使这样的程序获得高性能,可以使用较低级别的编程语言,如 C 或 C++,在计算机内存中为输入数据数组分配一个单一的块。例如,在 C 编程语言中,一个包含 4,096 个整数值的数组,每个整数值为 64 位,可以存储为某个连续内存范围内的 64 位值序列,例如从地址 0x8000f 到地址 0x9000f。[¹²]

假设所有 4,096 个整数值都在连续的内存范围内,那么这些值可以作为一个单一块从主存储器传输到处理器缓存中,有助于减少对值的加法计算的总延迟。如图 5.5 的左侧所示,C 整数仅占用足够的内存以存储整数值,以便可以将一系列 C 整数存储为可寻址的内存位置序列。请注意,数字 4,096 是有意选择的:因为 4,096 * 8(每个 64 位整数的字节数)= 32,768 字节是 2020 年 x86 处理器的常见 L1 缓存大小。这意味着每次需要刷新缓存并用另外 4,096 个整数重新填充缓存时,都会产生大约 100 ns 的延迟惩罚,这些整数需要从计算机内存中获取。

05-05

图 5.5 C 整数的值(左侧)直接存储为可寻址的内存位置。Python 整数(右侧)存储为对通用 PyObject_HEAD 结构的地址引用,该结构指定了数据类型(整数)和数据值。

这种高性能方法不适用于本地 Python 整数或列表。在 Python 中,所有数据类型都以 Python 对象(PyObjects)的形式存储在计算机内存中。这意味着对于任何数据值,Python 分配内存来存储值以及称为 PyObject_HEAD 的元数据描述符(图 5.5 右侧),该描述符跟踪数据类型(例如,数据位描述整数还是浮点数)和支持元数据,包括一个引用计数器,用于跟踪数据值是否正在使用。对于浮点数和其他原始数据值,PyObject 元数据的开销可能会使存储数据值所需的内存量增加两倍以上。

从性能角度来看,情况更糟糕的是,Python 列表(例如,如图 5.6 左侧所示的 list PyObject)通过引用存储值到它们的 PyObject 内存地址(图 5.6 右侧)并且很少将所有值存储在连续的内存块中。由于 Python 列表存储的每个 PyObject 可能分散在计算机内存中的许多位置,所以在最坏的情况下,每个 Python 列表中的值可能会有 100 纳秒的潜在延迟惩罚,因为需要为每个值刷新并重新填充缓存。

05-06

图 5.6 中的整数在 Python 列表(PyListObject)中通过引用(内存地址)作为 PyListObjects 中的项目进行访问,需要额外的内存访问以查找每个整数的 PyObject。根据内存碎片化程度,单个整数的 PyObjects 可以分散在内存中,导致频繁的缓存未命中。

PyTorch 张量(以及其他库,如 NumPy)使用基于低级别 C 代码实现高性能数据结构,以克服较高级别的 Python 本地数据结构的低效率。具体而言,PyTorch 使用基于 C 的 ATen 张量库¹⁴,确保 PyTorch 张量使用友好的缓存、连续的内存块(在 ATen 中称为 blobs)存储底层数据,并提供了从 Python 到 C++ 的绑定,以支持通过 PyTorch Python API 访问数据。

为了说明性能差异,请看下面的代码片段,使用 Python 的 timeit 库来测量处理长度从 2 到大约 268 百万(2²⁸)的整数值列表的性能,并将列表中的每个值递增 1,

import timeit
sizes = [2 ** i for i in range(1, 28)]

pylist = [ timeit.timeit(lambda: [i + 1 for i in list(range(size))],
                          number = 10) for size in sizes ]

使用类似的方法来测量递增张量数组中值所需的时间:

pytorch = [ timeit.timeit(lambda: pt.tensor(list(range(size))) + 1,
                          number = 10) for size in sizes ]

如图 5.7 所示,可以通过将大小作为 x 轴、比率作为 y 轴的图形比较 PyTorch 张量与本地 Python 列表的性能,其中比率定义为:

ratio = [pylist[i] / pytorch[i] for i in range(len(pylist))]

05-07

图 5.7 Python 到 PyTorch 性能比的比例显示了增量操作基准测试的一致更快的 PyTorch 性能,从具有 10,000 个元素及更高数量的列表开始。

摘要

  • PyTorch 是一个面向深度学习的框架,支持基于高性能张量的机器学习算法。

  • PyTorch 张量将标量、数组(列表)、矩阵和更高维数组泛化为单个高性能数据结构。

  • 使用多个张量的操作,包括张量加法和乘法,依赖于广播以对齐张量形状。

  • PyTorch 中基于 C/C++ 的张量比 Python 本地数据结构更节省内存,并且能够实现更高的计算性能。

由特斯拉 AI 主管 Andrej Karpathy 提出的 PyTorch 机器学习用例:www.youtube.com/watch?v=oBklltKXtDE

OpenAI 因创建基于 PyTorch 的最先进自然语言处理 GPT 模型而闻名:openai.com/blog/openai-pytorch/

当然,在 Python 中可以使用切片符号,但这与本说明无关。

有关 PyTorch 中所有子类的完整列表,请参阅 torch.Tensor 文档: pytorch.org/docs/stable/tensors.html#torch-tensor

PyTorch 支持的 dtype 值的全面列表可在mng.bz/YwyB上找到。

NestedTensor 类作为 PyTorch 包在这里提供:github.com/pytorch/nestedtensor

关于 PyTorch 张量数据类型的详细信息,请参阅pytorch.org/docs/stable/ tensors.html

有关 PyTorch 随机抽样工厂方法的详细文档,请访问mng.bz/GOqv

关于原地张量操作的详细参考,请访问pytorch.org/docs/stable/tensors.html 并搜索“in-place”。

广播是各种计算库和软件包(如 NumPy、Octave 等)中常用的技术。有关 PyTorch 广播的更多信息,请访问pytorch.org/docs/stable/ notes/broadcasting.html

了解每位计算机程序员都应该知道的延迟数字的全面解析,请访问gist.github.com/jboner/2841832

当然,现代计算机程序的实际内存地址不太可能具有像 0x8000f 或 0x9000f 的值;这些值仅用于说明目的。

^(13.)Python 使用此引用计数器来确定数据值是否不再使用,以便可以安全地释放用于数据的内存,并释放给其他数据使用。

^(14.)有关 ATen 文档,请访问pytorch.org/cppdocs/#aten

第六章:PyTorch 核心:Autograd、优化器和实用工具

本章涵盖内容如下

  • 理解自动微分

  • 使用 PyTorch 张量进行自动微分

  • 开始使用 PyTorch SGD 和 Adam 优化器

  • 使用 PyTorch 实现带有梯度下降的线性回归

  • 使用数据集批次进行梯度下降

  • PyTorch 数据集和 DataLoader 工具类用于批量处理

在第五章中,您学习了张量(tensor),这是 PyTorch 的核心数据结构,用于表示 n 维数组。该章节展示了 PyTorch 张量相对于原生 Python 数据结构的数组的显著性能优势,并介绍了创建张量以及在一个或多个张量上执行常见操作的 PyTorch API。

本章将介绍 PyTorch 张量的另一个关键特性:支持使用 自动微分(autodiff)进行梯度计算。自动微分被描述为自 1970 年以来科学计算中的一项重大进展,它出人意料地简单,由赫尔辛基大学的硕士研究生 Seppo Linnainmaa 发明。本章的第一部分通过展示如何使用基本的 Python 实现标量张量的核心算法来向您介绍自动微分的基础知识。

本章的其余部分将解释如何使用 PyTorch 张量 API 的自动微分功能来计算机器学习模型的梯度,以一个基于一个小的合成数据集的线性回归问题应用梯度下降的简单示例。在这个过程中,您将学习 PyTorch 自动微分的 API,并学会如何使用它们来实现机器学习中使用梯度下降的标准步骤序列。本章最后展示了使用各种梯度下降优化器的 torch.optim 包,并向您展示如何在您的机器学习代码中利用这些优化器。

6.1 理解自动微分的基础知识

本节介绍了自动微分的概念,并通过使用纯粹的 Python 编程语言构造,在没有使用 PyTorch 的情况下,通过一个简单的例子来教授其基础知识。在这个过程中,您将深入理解 PyTorch 自动微分功能,并开发出使您能够在项目中解决 PyTorch 自动微分问题的知识。在本节中,您将看到自动微分虽然出奇地简单,但它是一个支持复杂应用微积分链式法则的算法。在后续章节中,您将应用所学知识,并使用 PyTorch 张量的自动微分功能。

PyTorch 张量的自动微分功能是该框架成为深度学习和许多依赖于梯度下降以及相关优化技术的机器学习算法流行的核心原因之一。虽然可以将自动微分视为一个黑盒子来使用,而不完全理解它的工作方式,但如果您希望开发用于在生产场景中排除自动微分问题的技巧,了解这个关键的 PyTorch 功能至少是有价值的。

PyTorch 实现了一种名为反向模式积累自动微分的自动微分方法,这是一种高效的方法,用于计算常用于机器学习的损失函数(在附录 A 中定义)的梯度,包括均方误差和交叉熵。更准确地说,PyTorch 自动微分具有 O(n)的计算复杂度,其中 n 是函数中操作(如加法或乘法操作)的总数,只要函数的输入变量多于输出变量。

如果您已经熟悉反向模式积累自动微分,可以跳转到第 6.2 节,其中解释如何使用 PyTorch 自动微分 API 进行机器学习。否则,本节将帮助您更深入地了解 PyTorch 自动微分 API 设计及其用途。

如果您刚开始学习自动微分,需要知道它与其他流行的微分技术(如数值微分或符号微分)是不同的。数值微分通常在本科计算机科学课程中教授,基于对![006-01_EQ01]的近似。与数值微分不同,自动微分在数值上是稳定的,这意味着它在不同函数值的极端值时提供准确的梯度值,并且对实数的浮点数近似所引入的小误差的累积是有决策力的。

与符号微分不同,自动微分不尝试派生一个差分函数的符号表达式。因此,自动微分通常需要更少的计算和内存。然而,符号微分推导了一个可应用于任意输入值的差异函数,不像自动微分,它一次为函数的特定输入变量的值进行差异。

理解自动微分的一个好方法是自己实现一个玩具示例。在本节中,您将为一个微不足道的张量实现自动微分,一个纯量,添加支持计算使用加法和乘法的函数的梯度,然后探索如何使用您的实现来区分常见函数。

要开始,定义一个标量 Python 类,存储标量的值(val)和其梯度(grad):²

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0

为了更好地跟踪标量实例的内容并支持更好的实例值输出打印,让我们也添加一个 repr 方法,返回实例的字符串表示形式:

def __repr__(self):
  return f"Value: {self.val}, Gradient: {self.grad}"

有了这个实现,您可以实例化一个标量类的对象,例如使用 Scalar(3.14)。

列表 6.1 grad 属性用于存储标量张量的梯度

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0.0
  def __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"

print(Scalar(3.14))

一旦执行,这个操作应该返回输出

Value: 3.14, Gradient: 0

这与 repr 方法返回的字符串相对应。

接下来,让我们通过重写相应的 Python 方法来实现对标量实例的加法和乘法。在反向模式自动微分中,这被称为前向传播 过程,它仅仅计算标量运算的值:

def __add__(self, other):
    out = Scalar(self.val + other.val)
    return out

  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    return out

此时,您可以对标量实例执行基本的算术运算,

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0

  def __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"

  def __add__(self, other):
    out = Scalar(self.val + other.val)
    return out

  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    return out

Scalar(3) + Scalar(4) * Scalar(5)

正确计算为

Value: 23, Gradient: 0

并证实该实现遵守算术优先规则。

在这一点上,整个实现只需大约十二行代码,应该很容易理解。您已经完成了一半以上的工作,因为这个实现正确计算了自动微分的前向传播。

为了支持计算和累积梯度的反向传播,您需要对实现做一些小的更改。首先,标量类需要用默认设置为一个空操作的反向函数进行初始化❶。

列表 6.2 反向传播支持的后向方法占位符

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0
    self.backward = lambda: None      ❶
  def __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"
  def __add__(self, other):
    out = Scalar(self.val + other.val)
    return out
  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    return out

❶ 使用 lambda: None 作为默认实现。

令人惊讶的是,这个实现足以开始计算琐碎线性函数的梯度。例如,要找出线性函数y = x在 x = 2.0 处的梯度006-01_EQ02,您可以从评估开始

x = Scalar(2.0)
y = x

它将 x 变量初始化为一个 Scalar(2.0),并声明函数y = x。此外,由于这是一个非常简单的案例,计算 y 的前向传播只是一个空操作,什么也不做。

接下来,在使用反向函数之前,您需要执行两个先决步骤:首先,将变量的梯度清零(我将很快解释为什么),其次,指定输出 y 的梯度。由于 x 是函数中的一个单独变量,清零其梯度就相当于运行

x.grad = 0.0

如果您觉得设置 x.grad = 0.0 这个步骤是不必要的,因为 grad 已经在 init 方法中设置为零,那么请记住,这个例子是针对一个琐碎函数的,当您稍后将实现扩展到更复杂的函数时,设置梯度为零的必要性会变得更加明显。

第二步是指定输出 y 的梯度值,即关于自身的006-01_EQ02。幸运的是,如果您曾经将一个数字除以自身,那么这个值就很容易找出:y.grad 就是 1.0。

因此,要在这个简单线性函数上执行反向累积自动微分,你只需要执行以下操作:

x = Scalar(2.0)
y = x

x.grad = 0.0
y.grad = 1.0
y.backward()

然后使用

print(x.grad)

计算出 006-01_EQ02 的值,其结果正确显示为 1.0。

如果你一直关注 y = x 的定义,你完全有权利提出反对,认为这整个计算过程只是将梯度从 y.grad = 1.0 语句中取出并打印出来。如果这是你的思维线路,那么你是绝对正确的。就像前面介绍 006-01_EQ03 的例子一样,当计算 006-01_EQ02 的梯度时,对于函数 y = x,y 相对于 x 的变化与 x 相对于 y 的变化的比率就是 1.0。然而,这个简单的例子展示了一系列自动微分操作的重要顺序,即使对于复杂函数也是如此:

  • 指定变量的值

  • 指定变量的输出(前向传播)

  • 确保变量的梯度设置为零

  • 调用 backward() 来计算梯度(后向传播)

如果你对微分的推理感到舒适,那么你就可以进一步计算更复杂函数的梯度了。使用自动微分,梯度的计算发生在实现数学运算的函数内部。让我们从比较容易的加法开始:

def __add__(self, other):
  out = Scalar(self.val + other.val)

  def backward():    
    self.grad += out.grad   
    other.grad += out.grad    
    self.backward()              
    other.backward()        
  out.backward = backward   

  return out

注意,梯度的直接计算、累积和递归计算发生在分配给加法操作所产生的 Scalar 对象的 backward 函数的主体中。

要理解 self.grad += out.grad 和类似的 other.val += out.grad 指令背后的逻辑,你可以应用微积分的基本规则或者进行一些有关变化的直观推理。微积分中的相关事实告诉我们,对于一个函数 y = x + c,其中 c 是某个常数,那么 006-01_EQ02 = 1.0。这与之前计算 y = x 的梯度的例子几乎完全相同:尽管给 x 添加了一个常数,但是 y 相对于 x 的变化与 x 相对于 y 的变化的比率仍然是 1.0。对于代码来说,这意味着 self.grad 对 out.grad 贡献的变化量与 out.grad 的值是一样的。

那么对于代码计算没有常数的函数的梯度的情况呢,换句话说y = x + z,其中 x 和 z 都是变量?在实现方面,当计算 self.grad 时,为什么 out.grad 应被视为常数呢?答案归结于梯度或关于一个变量的偏导数的定义。找到 self.grad 的梯度相当于回答问题“假设所有变量,除了 self.grad,都保持不变,那么 y 对 self.grad 的变化率是多少?”因此,在计算梯度 self.grad 时,其他变量可以视为常量值。当计算 other.grad 的梯度时,这种推理也适用,唯一的区别是 self.grad 被视为常量。

还要注意,在 add 方法的梯度计算的一部分中,both self.grad 和 other.grad 都使用+=运算符累积梯度。理解 autodiff 中的这部分是理解为什么在运行 backward 方法之前需要将梯度清零至关重要。简单地说,如果你多次调用 backward 方法,则梯度中的值将继续累积,导致不良结果。

最后但并非最不重要的是,调用 backward 方法递归触发的 self.backward()和 other.backward()代码行确保 autodiff 的实现也处理了函数组合,例如 f(g(x))。请回忆,在基本情况下,backward 方法只是一个无操作的 lambda:None 函数,它确保递归调用始终终止。

要尝试带有后向传递支持的 add 实现,让我们通过将 y 重新定义为 x 值的和来查看更复杂的示例:

x = Scalar(2.0)
y = x + x

从微积分中,你可能会记得y = x + x = 2 * x的导数只是 2。

使用你的 Scalar 实现确认一下。同样,你需要确保 x 的梯度被清零,初始化 006-01_EQ03 = 1,然后执行后向函数:

x.grad = 0.0
y.grad = 1.0
y.backward()

此时,如果你打印出来

x.grad

它返回正确的值

2.0

要更好地理解为什么006-01_EQ02评估为 2.0,回想一下在 add 方法中实现的向后函数的定义。由于 y 定义为 y = x + x,self.grad 和 other.grad 都引用 backward 方法中 x 变量的同一实例。因此,对 x 的更改相当于对 y 或梯度的更改两倍,因此梯度006-01_EQ02为 2。

接下来,让我们扩展 Scalar 类的实现,以支持在乘法 Scalars 时计算梯度。在 mul 函数中,实现只需要六行额外代码:

def __mul__(self, other):
  out = Scalar(self.val * other.val)

  def backward():           
    self.grad += out.grad * other.val   
    other.grad += out.grad * self.val
    self.backward()               
    other.backward()         
  out.backward = backward    

  return out

乘法的梯度推导逻辑比加法更复杂,因此值得更详细地审查。与加法一样,假设你试图根据 self.grad 来推导梯度,这意味着在计算时,other.val 可以被视为常数 c。当计算 y = c * x 关于 x 的梯度时,梯度就是 c,这意味着对于 x 的每一个变化,y 都会变化 c。当计算 self.grad 的梯度时,c 就是 other.val 的值。类似地,你可以翻转对 other.grad 的梯度计算,并将 self 标量视为常数。这意味着 other.grad 是 self.val 与 out.grad 的乘积。

有了这个改变,标量类的整个实现只需以下 23 行代码:

class Scalar:
  def __init__(self, val):
    self.val = val
    self.grad = 0
    self.backward = lambda: None
  def __repr__(self):
    return f"Value: {self.val}, Gradient: {self.grad}"
  def __add__(self, other):
    out = Scalar(self.val + other.val)
    def backward():
      self.grad += out.grad
      other.grad += out.grad
      self.backward(), other.backward()
    out.backward = backward
    return out
  def __mul__(self, other):
    out = Scalar(self.val * other.val)
    def backward():
      self.grad += out.grad * other.val
      other.grad += out.grad * self.val
      self.backward(), other.backward()
    out.backward = backward
    return out

为了更加确信实现正确,你可以尝试运行以下测试案例:

x = Scalar(3.0)
y = x * x

重复早期步骤将梯度归零,并将输出梯度值指定为 1.0:

x.grad = 0.0
y.grad = 1.0
y.backward()

使用微积分规则,可以很容易地通过解析方法找出预期结果:给定 y = x²,则 006-01_EQ02 = 2 * x。因此对于 x = 3.0,你的标量实现应返回梯度值为 6.0。

你可以通过打印输出来确认

x.grad

返回结果为

6.0.

标量实现也可扩展到更复杂的函数。以y = x³ + 4**x* + 1 为例,其中梯度 006-01_EQ02 = 3 * x² + 4,所以 006-01_EQ02x = 3 时等于 31,你可以通过指定如下代码来使用标量类实现这个函数 y:

x = Scalar(3.0)
y = (x * x * x) + (Scalar(4.0) * x) + Scalar(1.0)

然后运行

x.grad = 0.0
y.grad = 1.0
y.backward()

x.grad

确认实现正确返回值为 31.0。

对于标量(Scalar)的自动微分实现与 PyTorch 张量提供的功能相比较简单。然而,它可以让你更深入地了解 PyTorch 在计算梯度时提供的功能,并阐明为什么以及何时需要使用看似神奇的zero_gradbackward函数。

使用 PyTorch 自动微分进行线性回归

本节在介绍了第 6.1 节中自动微分算法的基础上,引入了 PyTorch 中的自动微分支持。作为一个激励性的例子,本节将带领你通过使用 PyTorch 自动微分和基本的梯度下降法解决单变量线性回归问题的过程。在这个过程中,你将学习如何使用 PyTorch 自动微分 API,实践可微模型的前向和反向传播,并为在接下来的章节深入应用 PyTorch 做好准备。

为了说明 PyTorch 中的自动微分特性,让我们结合梯度下降算法解决经典的线性回归问题。为了建立问题,让我们生成一些样本数据,

X = pt.linspace(-5, 5, 100)

所以变量 X 包含 100 个值,均匀分布在范围从-5 到 5。在这个例子中,假设 y = 2x + ε,其中 ε 是从标准正态分布中抽样的随机数(ε ~ N (0,1)),以便 y 可以用 PyTorch 实现为:

y = 2.0 * X + pt.randn(len(X))

向 y 函数添加 randn 噪声的目的是说明算法正确估计线的斜率的能力,换句话说,仅使用训练数据张量 y、X 即可恢复值为 2.0。

此时,如果您将 X 的值沿水平轴和 y 的值沿垂直轴绘制成图表,您应该期望看到一个类似于图 6.1 的图。当然,如果您使用了不同的随机种子,您的具体值可能会有所不同。

06-01

图 6.1 用于解释 PyTorch 张量 API 基础知识的示例回归问题

下一步,为了为梯度下降算法建立可微分模型,您需要指定模型参数以及参数值的初始猜测。对于这种简化的线性回归情况,没有偏差,您需要指定的唯一模型参数是线的斜率。为了初始化参数,您可以使用 randn 函数从标准正态分布中抽样:

w = pt.randn(1, requires_grad = True)

到目前为止,在本章中您还没有看到 requires_grad 参数在此处被 randn 张量工厂方法用于实例化 w 值。在第 6.1 节中,我介绍了自动微分算法的内部工作原理,您看到计算梯度需要为张量中的每个数据值额外的内存和计算开销。例如,对于每个 Scalar 实例,自动微分需要一个额外的 grad 属性以及一个递归反向函数的定义。

对于机器学习模型,支持自动微分可能会使张量所需的内存量增加一倍以上。因此,当使用工厂方法实例化张量时,PyTorch 默认禁用张量自动微分,需要您使用 requires_grad 参数显式启用支持。但是,如果一个张量是从启用 requires_grad 的张量派生的,那么派生张量(称为非叶张量)的 requires_grad 将自动设置为 True。

一旦模型参数 w 初始化完成,算法的前向传递就准备好了。在这种情况下,前向传递是简单地使用 w 参数猜测(预测)y 值。前向传递是作为一个返回预测的均方误差值的 forward 方法实现的:

def forward(X):
  y_pred = X * w
  mse_loss = pt.mean((y_pred - y) ** 2)
  return mse_loss

仔细查看前向方法的实现,去计算 PyTorch 在计算均方误差公式过程中实例化的所有张量的数量。第一个张量 y_pred 包含基于给定 w 值的 y 的预测值。第二个张量是 y_pred - y,包含了预测值的个体误差,而第三个张量包含了平方误差(y_pred - y) ** 2。最后,使用 mean 函数计算第四个张量,返回一个标量,其值为预测值的均方误差。

前向方法中实例化的四个张量均无需手动指定 requires_grad = True,因为 PyTorch 会自动推断:为了支持对 w 张量的梯度计算,还需要启用来自 w 的非叶张量的 requires_grad。一般来说,对于任意的 PyTorch 张量,你可以检查其 requires_grad 属性的值,以确定它是否可以用于梯度计算。例如,在前向方法的代码块中,y_pred.requires_grad 返回 True。

在本章中,你还没有使用过 PyTorch 张量聚合函数,比如 mean。在前向方法中,mean 函数简单地计算张量值的算术平均值(即平方误差的平均值),并将聚合结果返回为标量。在接下来的章节中,你将学习更多关于 mean 和其他 PyTorch 张量聚合函数的内容。

有了前向传播代码,就可以完成使用 PyTorch 自动微分来实现梯度下降的工作。回想一下,代码中的 y 和 X 的值基于方程y = 2x + ε。以下代码执行 25 次梯度下降迭代来估算 2.0 的值,该值用作方程中的斜率。

列表 6.3 使用梯度下降的 PyTorch 自动微分进行线性回归

LEARNING_RATE = 0.03

for _ in range(25):
  mse_loss = forward(X)
  w.grad = None                ❶
  mse_loss.backward()
  w.data -= LEARNING_RATE * w.grad

print("MSE ", mse_loss.data, " W ", w.data)

❶ 清空 w 的梯度(置零)。

你应该期望代码打印出接近以下数字的结果:

MSE  tensor(0.7207)  W  tensor([1.9876])

在梯度下降的实现中,将学习率任意设为 0.03,并将梯度下降迭代次数设置为 25。在接下来的章节中,你将学习更多关于超参数调整和更严格的方法来选择学习率、梯度下降迭代次数以及其他机器学习超参数的值。

正如你从第 6.1 节已经了解的那样,在使用自动微分时,重要的是在使用 backward 函数累积更新的梯度值之前将梯度归零。请注意,在 PyTorch 张量的情况下,grad 属性是通过将其设置为 None ❶ 而不是 0 的值来归零的。一旦 mse_loss 张量由 forward 方法返回,就会通过调用 backward 函数来更新梯度。梯度下降步骤等同于使用学习率和更新后梯度的负乘积来更新 w 参数数据 w.data -= LEARNING_RATE * w.grad。

请注意,由于 y 值的噪声,不应期望梯度下降或线性回归的解析解能恢复用于生成数据的精确值 2.0。为了确认这一点,你可以使用 PyTorch 张量 API 根据公式 (X^T X)(-1)*XTy* 计算解析的最小二乘解,

pt.pow(X.T @ X, -1) * (X.T @ y)

应该返回大约 tensor(1.9876) 的值。

6.3 迁移到 PyTorch 优化器进行梯度下降

本节介绍了 PyTorch 的 torch.optim 包和 Optimizer 类,包括 Adam 和 SGD(随机梯度下降),你可以在基于 PyTorch 的机器学习模型中重新使用它们,以改进模型参数的训练方式。

除了 torch.autograd 的自动微分框架外,PyTorch 还包括了 torch.optim 包,其中包含了一系列优化器,这些优化器是根据损失函数的梯度实现的替代优化启发式算法,用于更新机器学习模型参数。优化器算法的实现细节超出了本书的范围,但你应该知道,PyTorch 开发团队一直在努力维护 PyTorch torch.optim 包文档中与相应算法实现相关的研究论文的链接。³

优化器类被设计成易于交换,确保你可以在不必更改机器学习模型训练代码的情况下尝试替代优化器。回想一下,在列表 6.3 中,你实现了一个简单的线性回归版本,使用了自己简单的规则来根据梯度和学习率更新模型参数值:

w.data -= LEARNING_RATE * w.grad

不要自己硬编码这个规则,你可以通过重新实现以下代码,来重用 torch.optim.SGD 优化器中的等价更新规则。

列表 6.4 使用 torch.optim 优化器进行线性回归

import torch as pt
pt.manual_seed(0)

X = pt.linspace(-5, 5, 100)
y = 2 * X + pt.randn(len(X))

w = pt.randn(1, requires_grad = True)

def forward(X):
  y_pred = X * w
  return y_pred

def loss(y_pred, y):
  mse_loss = pt.mean((y_pred - y) ** 2)
  return mse_loss

LEARNING_RATE = 0.03
optimizer = pt.optim.SGD([w], lr = LEARNING_RATE)   ❶

EPOCHS = 25                                         ❷
for _ in range(EPOCHS):
  y_pred = forward(X)
  mse = loss(y_pred, y)
  mse.backward()

  optimizer.step()                                  ❸
  optimizer.zero_grad()                             ❹

print(w.item())

❶ 使用模型参数的可迭代对象 [w] 来实例化 SGD 优化器。

❷ 假设进行 25 个周期的梯度下降。

❸ 使用 backward 计算的梯度执行梯度更新步骤。

❹ 将模型参数的梯度归零,以备下一次迭代。

这应该输出模型对线性斜率 w 的估计值大约为 2.0812765834924307。使用 PyTorch 优化器所需的更改已经标注了❶、❸和❹。请注意,当实例化优化器❶时,您正在提供一个 Python 可迭代对象(在本例中是 Python 列表),用于模型参数。梯度下降计算出梯度后(即 backward()方法返回后),对优化器的 step()方法❸的调用基于梯度更新模型参数。优化器的 zero_grad()方法❹的调用清除(清空)梯度,以准备下一次 for 循环迭代中 backward()方法的调用。

如果您有训练机器学习模型的先验经验,可能已经遇到过 Adam 优化器。使用 PyTorch 优化器库,将 SGD 优化器❶替换为 Adam 很容易:

optimizer = pt.optim.Adam([w], lr = LEARNING_RATE)

通常,要使用 torch.optim 包中的任何 PyTorch 优化器,您需要首先使用构造函数实例化一个,

torch.optim.Optimizer(params, defaults)

其中 params 是模型参数的可迭代对象,defaults 是特定于优化器的命名参数的默认值。⁴

SGD 和 Adam 优化器都可以使用除模型参数和学习率之外的其他配置设置来实例化。然而,这些设置将在第十一章中的超参数调整中详细介绍。在那之前,示例将使用 SGD 优化器,因为它更容易理解和解释。

当你逐渐进入使用梯度下降进行更复杂的训练场景时,清晰而全面的术语是很有用的。从列表 6.4 中可以看到,通过梯度下降训练机器学习模型包括多个迭代,每个迭代包括以下操作:

  • 前向传递,其中使用特征值和模型参数返回预测输出,例如来自列表 6.4 的 y_pred = forward(X)。

  • 损失计算,其中使用预测输出和实际输出来确定损失函数的值,例如来自列表 6.4 的 mse = loss(y_pred, y)。

  • 反向传递,其中反向模式自动微分算法基于损失函数的计算计算模型参数的梯度,例如来自列表 6.4 的 mse.backward()。

  • 参数权重更新,其中使用从反向传递中计算得到的梯度值更新模型参数,如果您使用的是 torch.optim 包中的优化器,应该是 optimizer.step()。

  • 清除梯度,其中将模型参数 PyTorch 张量中的梯度值设置为 None,以防止自动微分在多次梯度下降迭代中累积梯度值;如果您使用的是 torch.optim 包中的优化器,则应使用 optimizer.zero_grad()进行此操作。

在行业中,术语迭代梯度下降的步骤通常互换使用。令人困惑的是,单词“步骤”有时也用来描述作为梯度下降的一部分执行的具体操作,例如向后步骤向前步骤。由于 PyTorch 使用步骤来特指根据损失函数的梯度来更新模型参数的操作,本书将坚持使用 PyTorch 的术语。请记住,一些 PyTorch 的框架,如 PyTorch Lightning,使用步骤来表示迭代

在过渡到使用批次进行梯度下降的模型训练之前,还有必要明确术语周期的定义。在机器学习中,一个周期描述了使用每个数据集示例恰好一次来训练(更新)机器学习模型参数所需的一个或多个梯度下降迭代。例如,列表 6.4❷指定了应该进行 25 个周期的梯度下降。使用词“周期”也对应于迭代,简单地原因是对于梯度下降的每次迭代,都会使用数据集中的所有示例来计算梯度和更新模型的权重。然而,正如您将在接下来关于批次的部分中了解到的,执行一个周期的训练可能需要多次梯度下降迭代。

6.4 开始使用数据集批次进行梯度下降

本节将向您介绍数据集批次(batches),以便您可以为使用 PyTorch 进行梯度下降做准备。梯度下降中数据集批次的概念看起来很简单。批次只是从数据集中随机抽样的一组非空示例(或在数学术语中,一个多重集)。尽管如此,使用批次的梯度下降是微妙而复杂的:数学家们甚至专门研究了这个领域,称为随机优化。训练数据集批次不仅仅是训练数据集的一个样本:在梯度下降的单个迭代中,训练数据集批次中的所有数据示例都用于更新模型的梯度。

虽然您不需要拥有数学优化的博士学位来使用 PyTorch 中的批量梯度下降,但具备与批次和梯度下降相关的精确术语能够更好地理解该主题的复杂性。

批处理大小 是一个正整数(大于零),指定了批处理中的示例数。许多机器学习研究论文和在线课程使用 mini-batch 梯度下降 这个短语来描述批处理大小大于一的梯度下降。然而,在 PyTorch 中,SGD(torch.optim.SGD)优化器以及 torch.optim 包中的其他优化器可以使用从 1 到整个数据集示例数的任何批处理大小。要记住这一点,因为在机器学习文献中,随机梯度下降 这个短语通常用来描述批处理大小恰好为一的梯度下降。

批处理大小的选择与您机器学习计算节点中的内存有关,也与梯度下降算法的机器学习性能有关。这意味着批处理大小的上限应考虑您机器学习平台节点的可用内存量。关于批处理大小的确切值的选择在第十一章的超参数调整中有更详细的介绍,但首先您应该知道您的机器学习平台节点内存中可以容纳的最大批处理大小。

对 PyTorch 中批处理的应用存在很多混淆,根源在于没有意识到批处理应该被视为数据集大小的一部分,即数据集中的示例数。将批处理解释为分数就是简单的 06-01_EQ06,因此可以将批处理大小的选择归类为生成完整或不完整批次,其中 完整批次 的批处理大小是数据集大小的整数因子,或者更准确地说

06-01_EQ07

对于某个表示数据集中批次数的正整数 batch_count。

6.5 使用 PyTorch Dataset 和 DataLoader 的数据集批处理

这一部分教你如何开始使用 PyTorch 中的数据集批处理,并使用 PyTorch 实用程序类来帮助你管理和加载数据集作为批处理。

PyTorch 框架提供了一组数据实用程序类,组织在 torch.utils.data 包中,并实现了支持使用梯度下降的数据批处理。该包中的类,包括 DataLoader、Dataset、IterableDataset 和 TensorDataset,旨在共同简化可扩展的机器学习模型训练过程的开发,包括数据集不适合单个节点内存的情况,以及数据集由分布式计算集群中的多个节点使用的情况。虽然这些类提供了可扩展的实现,但这并不意味着它们仅对大型数据集或大型计算集群有用。正如您将在本节中看到的,这些类可以很好地工作(除了可忽略的开销)与小型、内存中的数据集。

Dataset 是一个高度可重用的类,并且可以支持基于映射样式和可迭代样式的数据集子类的各种机器学习用例。映射样式数据集是 PyTorch 框架中的原始数据集类,最适合在内存中,可以通过索引进行寻址的数据集。例如,如果您要通过将 PyTorch 的数据集子类化为 MapStyleDataset 来实现自己的映射样式数据集,那么您必须实现所需的 getitemlen 方法。

列表 6.5 设计为子类的 PyTorch 映射样式数据集

import torch.utils.data.Dataset

class MapStyleDataset(Dataset):
    def __getitem__(self, index):    ❶
      ...
    def __len__(self):               ❷
      ...

❶ 映射样式接口方法,用于从数据集中检索特定项。. . .

❷ . . . 并返回整个数据集中项目的总数。

请注意,作为接口的一部分,映射样式数据集做出了两个假设:预计可以通过索引值❶访问数据集中的每个示例(项),并且随时可以了解和获取数据集的大小❷。

在大多数情况下,如果您使用的是内存数据集,您可以避免实现自己的映射样式数据集子类,而是重用 TensorDataset 类。TensorDataset 也是 torch.utils.data 包的一部分,并通过包装张量或 NumPy n 维数组来实现所需的数据集方法。例如,要为张量 X 和 y 中的示例数据值创建映射样式的训练数据集,可以直接将数据张量传递给 TensorDataset❶。

列表 6.6 简化批处理 PyTorch 张量的 TensorDataset

import torch as pt
from torch.utils.data import TensorDataset
pt.manual_seed(0)

X = pt.linspace(-5, 5, 100)
y = 2 * X + pt.randn(len(X))

train_ds = TensorDataset(y, X)   ❶

这使您可以使用 Python [0]语法从数据集中获取索引为 0 的示例,使用 getitem 方法,

print(train_ds[0])

输出为

(tensor(-11.1258), tensor(-5.))

并使用 train_ds 数据集上的 len 方法验证数据集的长度为 100,

assert
 len(train_ds) == 100

其中断言中的布尔表达式的结果为 True。

在使用数据集的实例时,实现梯度下降迭代的 PyTorch 代码不应直接访问数据集,而是使用 DataLoader 的实例作为与数据交互的接口。您将在使用 PyTorch 和 GPU 的即将到来的部分中了解更多有关使用 DataLoader 的理由。

DataLoader 本质上是对数据集的包装器,因此通过包装前面描述的 train_ds 实例,可以创建 train_dl 使用

from torch.utils.data import DataLoader
train_dl = DataLoader(train_ds)

注意,默认情况下,当 DataLoader 与映射样式的数据集一起使用时,DataLoader 实例返回的每个批次的大小为 1,这意味着以下表达式的结果为 True:

len(next(iter(train_dl))) == 1

可以通过在实例化 DataLoader 时指定 batch_size 命名参数来轻松修改此默认行为,以便该表达式

train_dl = DataLoader(train_ds, batch_size = 25)
len(next(iter(train_dl)))

评估结果为 25。

批量大小的两个值,1 和 25,都会生成完整的批次。尽管所有完整的批次的批量大小相同,但不完整的批次包含的示例数少于批量大小。具体来说,根据批量大小和数据集,不完整的批次可以包含至少

dataset_size mod batch_size

示例,或者在 Python 中,dataset_size % batch_size。

例如,当使用 batch_size 为 33 时,在 for 循环的第四次迭代过程中,以下代码生成一个批次大小为 1 的不完整批次:

train_dl = DataLoader(train_ds, batch_size = 33)
for idx, (y_batch, X_batch) in enumerate(train_dl):
  print(idx, len(X_batch))

这将打印以下内容:

0 33
1 33
2 33
3 1

处理不完整的批次没有普遍接受的技术。虽然有可能尝试防止不完整批次的问题,但在实践中可能没有太多价值:因为批次是在处理足够大卷的数据时使用的,以致于数据集太大而无法放入内存中,因此如果不完整的批次对机器学习模型的整体性能没有负面和可衡量的影响,那么可以选择忽略或删除它们。例如,DataLoader 类提供了一个 drop_last 选项,这样您就可以忽略较小的、不完整的批次:

train_dl = DataLoader(train_ds, batch_size = 33, drop_last=True)
for idx, (y_batch, X_batch) in enumerate(train_dl):
  print(idx, len(X_batch))

这将输出以下内容:

0 33
1 33
2 33

尽管在指定批大小时应谨慎使用不完整批次的 drop_last 选项,特别是在使用占数据集的大比例的批次大小时。例如,假设批次大小不慎设置为06-01_EQ09。由于此批次大小的选择产生两个批次,当使用 drop_last=True 选项时,两个中的单个不完整批次,批次大小为06-01_EQ10,被丢弃,导致将近一半的数据集浪费!

可以通过训练多个周期并将数据集与自身连接起来,使用 batch_size 窗口作为滚动窗口在数据集上滑动,从而防止不完整的批次。采用这种技术时,训练周期的数量应该基于批次大小和数据集大小的最小公倍数(lcm):

06-01_EQ11

为了说明这种方法,假设仅为例子而工作的批次大小为 12,数据集大小为 33,则

import math

def lcm(a, b):
    return a * b / math.gcd(a, b)

lcm(12, 33) / 33

输出 4.0,这表示训练数据集需要与自身连接四次,以实现所需的四个训练周期,以避免不完整的批次。

从数据集中选择批次是如何进行的?由于批次旨在统计代表数据集,批次中的示例应尽可能相互独立。这意味着确保批次中的出租车票价示例是从整个数据集中随机采样(无替换)的。在实践中,实现对数据集进行随机洗牌最直接的方法是使用 PySpark DataFrame API 的 shuffle()方法。

由于批次需要统计代表数据集,您可能会倾向于重新使用基于第四章中发现的测试数据集大小的批次大小。尽管测试数据集大小指标作为批次大小的 下限,但重新使用其值并不是正确的决定,因为测试数据集大小是尽可能小而又统计代表开发数据集的。第十一章详细描述了如何使用在本章介绍的下限和上限值来选择正确的批量大小的原则性超参数调整方法。

6.6 用于批量梯度下降的 Dataset 和 DataLoader 类

本节说明了如何使用最小化示例应用 Dataset 和 DataLoader 类来教授这些概念,这些概念也适用于使用数据集批次的更复杂和更现实的机器学习问题。要使用 Dataset 和 DataLoader 执行使用批量的梯度下降的线性回归,您需要修改第 6.3 节的解决方案。

列表 6.7 使用批量数据集的基本线性回归

import torch as pt
from torch.utils.data import TensorDataset, DataLoader
pt.manual_seed(0)

X = pt.linspace(-5, 5, 100)
y = 2 * X + pt.randn(len(X))

train_ds = TensorDataset(y, X)                  ❶
train_dl = DataLoader(train_ds, batch_size=1)   ❷

w = pt.empty(1, requires_grad = True)

def forward(X):
  y_pred =  X * w
  return y_pred

def loss(y_pred, y):
  mse_loss = pt.mean((y_pred - y) ** 2)
  return mse_loss

LEARNING_RATE = 0.003
optimizer = pt.optim.SGD([w], lr = LEARNING_RATE)

EPOCHS = 25
for _ in range(EPOCHS):
  for y_batch, X_batch in train_dl:             ❸
    y_pred = forward(X_batch)                   ❹
    mse = loss(y_pred, y_batch)                 ❺
    mse.backward()

    optimizer.step()
    optimizer.zero_grad()

print(w.item())

❶ 使用 TensorDataset 提供 y 和 X 的数据集接口。

❷ 使用批量大小为 1(默认)为数据集创建一个 DataLoader。

❸ 在迭代 DataLoader 时解压数据批次中的每个元组。

❹ 对特征批次执行前向步骤以生成预测。

❺ 使用批次标签和预测计算损失。

这应该输出估计的 w 大约为 2.0812765834924307。一旦原始张量 y 和 X 被打包成映射样式的 TensorDataset 数据集 ❶,则产生的 train_ds 实例进一步包装使用 DataLoader 以生成 train_dl。

要使用梯度下降的批次,对于每个周期,代码使用在 train_dl 中由 for 循环返回的单个批次执行 100 次梯度下降迭代 ❸。由于原始数据集包含 100 个示例,并且 DataLoader 的默认批量大小等于 1。由于批量大小为 1 会产生完整的批次(回想一下批次的定义为数据集的一部分),

06-01_EQ12

或者,如果您使用批次大小为 25 的批次

train_dl = DataLoader(train_ds, batch_size=25),

那么

06-01_EQ13

列表 6.7 中的更改 ❹ 和 ❺ 很简单:代码不再使用原始数据集,而是使用 train_dl 实例返回的批次。

如果您将批量大小修改为生成不完整批次的值,例如通过指定批量大小为 51,

train_dl = DataLoader(train_ds, batch_size=51)

内部 for 循环的第二次迭代将生成具有 49 个示例的批次,因为 DataLoader 默认允许不完整的批次。在这种特定情况下,这不是一个问题,因为具有 torch.Size([]) 形状的模型参数可以与形状为 torch.Size([49]) 的不完整批次进行广播。然而,通常情况下,您必须注意将模型参数的形状与批次的形状对齐。在第七章中,您将从对齐模型参数与 DC 出租车数据集批次形状的示例中学习。

总结

  • 自动微分是简化复杂链式规则应用的基本算法。

  • 可以使用 python 原生数据结构来演示如何为张量实现自动微分的基础知识。

  • PyTorch 张量提供了全面的支持,用于对张量梯度进行自动微分。

  • torch.optim 中的优化器是一系列用于使用梯度下降优化机器学习模型中参数的算法。

  • PyTorch 张量的自动微分和优化器 API 在使用 PyTorch 进行机器学习时是核心内容。

  • Dataset 和 DataLoader 接口类简化了在 PyTorch 代码中使用批处理的过程。

  • TensorDataset 提供了一个可以直接使用的内存中数据集的实现。

^(1.)自动微分和反向传播的故事在这里有详细描述:people.idsia.ch/~juergen/who-invented-backpropagation.html

^(2.)在有关自动微分的复杂数学论文中,标量类被称为双重数,grad 被称为伴随数。

^(3.)例如,SGD 的文档可以从mng.bz/zEpB获取,并包含指向相关研究论文和 SGD 实现详细信息的链接。

^(4.)基础的 Optimizer 类和它的构造函数在这里有文档说明:pytorch.org/docs/stable/optim.html#torch.optim.Optimizer

^(5.)更精确地说,通过使用无替换的随机抽样,或者等效地对数据集中的示例顺序进行随机洗牌。

^(6.)根据批处理大小和数据集大小的最小公倍数增加纪元数,可以产生足够的训练纪元,以避免不完整的批次。也可以训练任意 lcm(batch_size, data set_size) 的倍数,以避免不完整批次。

第七章:无服务器机器学习的扩展性。

本章节涵盖以下几个方面

  • 使用 IterableDataset 与 AWS 和其他云平台

  • 理解 PyTorch 编程中的 GPU

  • 使用 GPU 核对梯度下降进行扩展

  • 利用线性回归对 DC 出租车数据集进行基准测试。

在第 5 和 6 章节中,您已经学会了如何在小规模上使用 PyTorch,实例化由几百个数据值组成的张量并仅在几个参数中训练机器学习模型。第六章中使用的规模意味着,要训练机器学习模型,您可以对整个模型参数集、参数梯度和整个训练数据集进行梯度下降,假定它们可以轻松适应单个节点的内存,并因此随时可供梯度下降算法使用。

本章节介绍扩大机器学习系统所需的概念。建立在您对梯度下降的现有知识(有关刷新,请参见附录 A)的基础上,学习如何对数据集批次执行梯度下降。接下来,使用批次来帮助您扩展到不适合单个节点的机器学习平台内存的数据集。您还将学习如何在单个节点上进行扩展,换句话说,利用多个处理器(如 CPU 和 GPU)。本章节的概念还在第八章节中重用,以解释扩展出机器学习系统,换句话说,确保您的机器学习系统可以利用由多个互连处理节点组成的分布式计算集群的计算能力。

7.1 如果我的机器学习模型只需要单个节点,该怎么办?

本章节教授如何确定 PyTorch 机器学习系统的扩展能力与您的机器学习项目是否有关。

如果(a)您使用的数据集可以适应您机器学习平台的单个节点内存,(b)即使启动系统后您的数据集仍可适应内存,(c)您认为 CPU 或 GPU 上的机器学习代码的性能已足够,则可以放弃本章和下一章中描述的扩展技巧。

注意作为一般经验法则,如果您的机器学习模型和数据集保证适合内存,请将其保留在内存中。

针对内存数据集的机器学习算法在计算效率和机器学习效果方面提供了最佳性能。这意味着当使用内存数据上的机器学习时,你的机器学习模型训练和推断将需要更少的时间,并且你将能够更快地达到最佳的损失和度量性能。此外,对于适合内存的数据集,你可以使用单节点的机器学习框架(如 scikit-learn)在进入生产之前开发和测试各种机器学习模型。如果你的计划是避免实现用于扩展机器学习的代码,你可以考虑跳转到第九章继续进行特征选择和工程。

在你匆忙前进之前,你应该记住,“The Bitter Lesson”是由杰出计算机科学家和人工智能研究员 Rich Sutton 在 2019 年发表的一篇有影响力的论文(www.incompleteideas.net/IncIdeas/BitterLesson.html),该论文认为利用大规模计算能力的机器学习系统一直以来效果最佳,且差距较大。Sutton 的论文描述了在围棋游戏、语音识别和计算机视觉等人工智能研究的不同领域取得突破性成果的例子。如果你正在构建托管在云平台上的机器学习系统,你的系统可以利用的信息技术能力规模受到财务预算而不是技术限制的限制。因此,在你的机器学习系统设计中,你需要对你的系统预计在云中使用的信息技术资源的规模做出决策。如果你正在构建需要在市场上表现优越或在学术领域提供最新成果的机器学习系统,那么你应该从“苦涩的教训”中汲取教训,并为扩展和缩放设计系统。

如果你打算认真对待“苦涩的教训”,并扩展你的机器学习系统,你需要付出一些努力,深入了解数据集批处理、使用 GPU 和使用 PyTorch 进行分布式处理。尽管有许多流行和有效的机器学习算法,包括梯度提升决策树,但梯度下降和深度学习的优势在于它们能够利用由 Rich Sutton 描述的规模的计算、存储和网络的优势。

7.2 使用 IterableDataset 和 ObjectStorageDataset

本节介绍了 IterableDataset 类的应用,它可以帮助你支持 out-of-memory 和流式数据集的梯度下降。你还将了解到 ObjectStorageDataset,它可以帮助你使用位于 AWS 对象存储或其他主要云提供商的类似服务中的数据集。

在 out-of-memory 数据集中,数据集中的一个示例可能存储在磁盘上或者你机器学习平台的任意节点的内存中。map-style 数据集(在 getitem 方法中使用的基于索引的内存假设)不适用于这种 out-of-memory 模型。此外,map-style 数据集(如第六章所述)假设数据集必须使用 len 方法,因此对于概念上无界的流式数据集不适用。

新的 torch.utils.data.IterableDataset,在 PyTorch 1.2 版本中引入,消除了定义 getitemlen 方法的要求,而只需定义一个 iter 方法,可以与 Python 内置的迭代器 API 一起使用。

7.1 IterableDataset 子类声明草图

import torch.utils.data.IterableDataset

class MyIterableDataset(Dataset):
    def __init__(self, ...):
      ...
    def __iter__(self):
      ...

例如,要从数据集中检索单个批次的示例并将其分配给批次变量,可以使用 Python 的 next 和 iter 函数:

batch = next(iter(MyIterableDataset(...)))

虽然批量中的示例数量未作为 IterableDataset 类的一部分指定,但 IterableDataset 类的各个实现和实例负责管理批量大小。例如,在本书的其余部分中使用的 ObjectStorageDataset 类中,批量大小被指定为类的 init 方法的参数之一。

就像 TensorDataset(在第六章中描述)为基于张量的、内存中的数据集提供了对 map-style 接口的支持一样,ObjectStorageDataset 为基于张量的、out-of-memory 数据集提供了对 iterable-style 接口的支持。当你安装 PyTorch 时,默认情况下不会提供 ObjectStorageDataset,因此你需要从 Kaen 框架中单独安装它

pip install kaen[osds]

安装后,可以使用以下命令在运行时导入该类

from kaen.torch import ObjectStorageDataset as osds

ObjectStorageDataset 类为以 CSV 格式存储的数据集提供了标准的 PyTorch 接口,无论它们是位于公共云对象存储中还是位于本地文件系统中。对于类的 iter 方法的每次调用,结果是来自基于 CSV 的数据集的数值的 PyTorch 张量。在第四章的 dctaxi_dev _test.py PySpark 作业创建的 DC 出租车开发数据集的情况下,这意味着 ObjectStorageDataset 返回的张量必须分离为执行梯度下降迭代所需的标签(y)和特征(X)。例如,这可以使用 Python 切片表示法来完成 ❷。

7.2 划分批量张量

batch = next(iter(osds(...)))

def batchToXy(batch):
  batch = batch.squeeze_()             ❶
  return batch[:, 1:], batch[:, 0]     ❷

X_batch, y_batch = batchToXy(batch)

❶ 消除张量的前导(批量)维度。

❷ 将批次切片为第一个(y_batch)和剩余列(X_batch)。

第一列中的所有行都分配给标签张量 y_batch,剩余列的所有行都分配给特征张量 X_batch。

要实例化 ObjectStorageDataset,您必须指定一个 URL 样式路径(类似于 Unix 通配符字符串),该路径指向您的 CSV 格式数据集的位置。例如,如果已经为包含开发数据集的 S3 存储桶配置了 BUCKET_ID 和 AWS_DEFAULT_REGION 环境变量,您可以使用以下命令实例化该类

import os
BUCKET_ID = os.environ['BUCKET_ID']
AWS_DEFAULT_REGION = os.environ['AWS_DEFAULT_REGION']
BATCH_SIZE = 1_048_576 # = 2 ** 20

train_ds = \
  osds(f"s3://dc-taxi-{BUCKET_ID}-{AWS_DEFAULT_REGION}/csv/dev/part*.csv",
        partitions_glob = f"s3://dc-taxi-{BUCKET_ID}-
➥           {AWS_DEFAULT_REGION}/csv/dev/.meta/shards/*.csv",
        storage_options = {'anon': False},
        batch_size = BATCH_SIZE)

其中 train_ds 被分配了 ObjectStorageDataset 的一个实例。由于 ObjectStorageDataset 支持通配符字符 (),用于创建 train_ds 实例的 Python f-string 指定数据集应包括 dc-taxi-\({BUCKET_ID}-\){AWS_DEFAULT_REGION} S3 存储桶中匹配 /csv/dev/part.csv glob 的所有对象。

ObjectStorageDataset 的 partitions_glob 参数指向有关匹配 /csv/dev/part*.csv glob 的 CSV 部分文件的元数据文件。请回想 dctaxi_dev_test.py PySpark 作业将 Spark 分区(也称为部分文件)元数据保存到开发和测试数据集的 .meta/shards 子文件夹中。对于数据集的开发部分,您可以通过将其加载到内存中作为 pandas DataFrame 来预览此元数据,

import pandas as pd
partitions_df = pd.read_csv(f"s3://dc-taxi-{BUCKET_ID}-
➥ {AWS_DEFAULT_REGION}/csv/dev/.meta/shards/*.csv")
print(partitions_df[:5])

这应该会产生类似以下的输出:

id   count
77  164315
10  165314
31  165168
 1  165436
65  164777

其中 id 列表示 .meta/shards 子文件夹中的一个部分文件的 ID,count 列表示部分文件中行数(记录数)的计数。

ObjectStorageDataset 被设计为在最短可能的时间内实例化,以启动梯度下降的迭代。实际上,这意味着 ObjectStorageDataset 仅缓存内存和磁盘上需要从数据集返回第一批示例的数据集对象,如图 7.1 所示。

07-01

图 7.1 ObjectStorageDataset 使用对象存储的多级缓存访问

在图 7.1 的示例中,ObjectStorageDataset 使用一个虚构的 src S3 存储桶实例化,该存储桶包含以完整 URL 样式路径 s3://src/data/part.csv 作为 CSV 格式对象(图 7.1 的右侧)。数据集的分区(即名称匹配 part.csv 的 CSV 格式对象)位于 src 存储桶的 data 文件夹下。在图 7.1 中,part.csv 对象显示为 S3 存储桶中的带编号的正方形。为了说明,假定 S3 存储桶中的每个 part.csv 对象都包含 1,000 个示例,这些示例以 CSV 格式的每行一行表示。

在使用 batch_size 为 2,048 实例化 ObjectStorageDataset 后(计算节点 Python 运行时的左侧),ObjectStorageDataset 的实现会触发从 S3 存储桶到计算节点的文件系统缓存的三个数据集分区的网络传输。由于 S3 中的每个对象有 1,000 行,需要将 3 个对象(总共 3,000 行)从 S3 传输到计算节点的文件系统,以使 ObjectStorageDataset 实例生成一个 2,048 行的批次。在图中,文件系统缓存的位置显示为 /tmp 目录;但是,Linux 操作系统特定的位置可能会因操作系统默认值而异。文件系统缓存是为了在多次重复训练机器学习模型的过程中最小化网络上的总数据传输。

注意,分区的大小(行数)完全独立于用于实例化 ObjectStorageDataset 的 batch_size,这意味着 batch_size 可以变化,而分区的大小保持不变。在本书中使用的 DC 出租车项目中,分区的数量和大小在保存清理后的数据集到对象存储中的 PySpark 作业中指定。一般来说,数据集分区的数量和大小取决于机器学习项目的具体情况,尽管如果您使用的是常见的 100 Mbps 网络接口,最好选择 100—200 MB 范围内的分区大小以实现有效的网络传输。

注意:除非 URL 样式路径以 file:// 协议处理程序开头,或者数据集源自节点的文件系统,否则数据集分区将被复制到文件系统缓存中。

当单个批次的训练示例适合内存时,在分区被缓存到文件系统后,缓存了分区 1、2 和第 3 个分区的前 48 行(在图 7.1 中以虚线显示)的内存中,这些构成了一个大小为 2,048 的碎片,作为 PyTorch 张量被缓存在内存中。ObjectStorageDataset 的每次对 iter 方法的调用都会清空内存缓存,触发网络传输将下一个碎片所需的额外数据集分区从存储桶传输到文件系统缓存目录,并将下一个 2,048 个示例加载到内存中作为 PyTorch 张量。

本节描述的 ObjectStorageDataset 的所有功能都适用于驻留在主要云提供商的无服务器对象存储服务中的数据集。¹尽管本书中的示例侧重于使用 AWS 和 S3,但您可以通过修改类的 URL 样式 glob 命名参数中指定的协议,轻松地将 ObjectStorageDataset 类的实例重新定向到不同的云提供商(或本地文件系统):

  • 例如使用 gcs:// 来表示 Google Cloud Storage,

  • osds(f"gcs://dc-taxi-${BUCKET_ID}-${AWS_DEFAULT_REGION}/test/part*.csv")
    
  • 例如使用 abfs:// 来表示 Azure Blob Storage 或 Azure Datalake Gen2,

  • osds(f"abfs://dc-taxi-${BUCKET_ID}-${AWS_DEFAULT_REGION}/test/part*.csv")
    
  • 对于位于本地文件系统上的文件,使用 file://,例如使用

  • osds("file://home/user/test/part*.csv")
    

使用内存中的数据集进行梯度下降

在本节中,你将扩展第六章中解释的基本线性回归示例,以计算 DC 出租车训练和测试数据集的弱基准性能指标。

到目前为止,在本章中,你已经学会了使用基于批次的 PyTorch 梯度下降,使用 Dataset 和 DataLoader 类的实例以及只有一个模型参数的基本线性回归模型。由于你准备用于训练的 DC 出租车数据集包含八个特征(接送地点的纬度和经度坐标,以及行程的年份,月份,星期几和小时),要执行线性回归,你需要将机器学习模型扩展到至少八个参数,每个特征一个。

注意,到目前为止,你看到的线性回归示例都没有使用偏置参数。这是有意为之的,以简化示例代码:由于先前的线性回归示例依赖于具有零均值的数据集,因此不需要偏置参数。然而,DC 出租车数据集的列在下一个示例中不具有零均值。因此,你将添加一个额外的张量标量来表示线性回归的偏置参数。

以前,你使用 torch.randn 方法通过从正态分布中采样来初始化模型参数,但由于你正在过渡到更复杂的模型,你可以利用 PyTorch 提供的更好的模型参数初始化方案。

Kaiming 初始化是由何等人在 2015 年的一篇名为《深入研究整流器:在 ImageNet 分类中超越人类水平的性能》的重要研究论文中流行化的。Kaiming 初始化通过考虑需要初始化的模型参数的数量来设置初始模型参数值。要使用 Kaiming 初始化,你只需将对 torch.empty 的调用包装在 torch.nn.init.kaiming_uniform_ 方法中。

列表 7.3 使用 Kaiming 初始化进行模型参数初始化

pt.set_default_dtype(pt.float64)                                        ❶

FEATURE_COUNT = 8

w = pt.nn.init.kaiming_uniform_(pt.empty(FEATURE_COUNT,
                                            1, requires_grad=True))     ❷

b = pt.nn.init.kaiming_uniform_(pt.empty(1,
                                            1, requires_grad = True))   ❸

使用 torch.float64 作为新创建张量的 dtype。

这里,模型参数(也称为线性回归中的系数)被分配给变量 w,模型偏置(截距)被分配给 b。

注意:正如第五章中更详细地解释的那样,kaiming_uniform_ 方法是 PyTorch 中的一个例子,它是一个就地方法,方法名后面有一个下划线。由于本章中对 Kaiming 初始化的示例使用了就地方法,因此由 empty 方法返回的张量值将被初始化替换。

在第 7.2 节中,你已经看到默认情况下,ObjectStorageDataset 将 float64 作为批量张量的 dtype 返回。如第五章所述,PyTorch 要求张量在执行诸如矩阵乘法之类的操作之前具有相同的 dtype。列表 7.3 中使用的 set_default_dtype 方法❶确保 w 和 b 张量使用 float64 数据类型创建,以匹配 ObjectStorageDataset 返回的 dtype。

要利用修改后的模型参数,你需要改变梯度下降迭代的前进步骤的细节。此时,由于 DC 出租车数据集的特征张量 X 的形状为 torch.Size([DATASET_SIZE, FEATURE_COUNT]),模型参数张量 w 的形状为 torch.Size([FEATURE_COUNT, 1]),它们的乘积必须具有 torch.Size([DATASET_SIZE, 1])的形状。然而,如列表 7.2 中所述,从 ObjectStorageDataset 切片创建的 y_batch 张量的形状为 torch.Size([DATASET_SIZE])。因此,在计算损失指标期间,在 y_batch 和 y_est 张量进行减法计算之前,你应该使用 PyTorch 的 squeeze 方法更新 y_est 张量,以确保它们的形状都是 torch.Size([DATASET_SIZE]):

def forward(X):
  y_est = X @ w + b
  return y_est.squeeze_()

经过这些变化,DC 出租车数据集的基线线性回归实现已经准备就绪。

列表 7.4 使用线性回归的弱基线

import os
import time
import torch as pt

from torch.utils.data import TensorDataset, DataLoader
from kaen.torch import ObjectStorageDataset as osds

pt.manual_seed(0);
pt.set_default_dtype(pt.float64)   

BUCKET_ID = os.environ['BUCKET_ID']
AWS_DEFAULT_REGION = os.environ['AWS_DEFAULT_REGION']

BATCH_SIZE = 2 ** 20 #evaluates to 1_048_576
train_ds = osds(f"s3://dc-taxi-{BUCKET_ID}-
➥              {AWS_DEFAULT_REGION}/csv/dev/part*.csv",
                storage_options = {'anon': False},
                batch_size = BATCH_SIZE)

train_dl = DataLoader(train_ds, batch_size=None)

FEATURE_COUNT = 8

w = pt.nn.init.kaiming_uniform_(pt.empty(FEATURE_COUNT,
                                            1, requires_grad=True))
b = pt.nn.init.kaiming_uniform_(pt.empty(1,
                                            1, requires_grad = True))

def batchToXy(batch):
  batch = batch.squeeze_()
  return batch[:, 1:], batch[:, 0]

def forward(X):
  y_est = X @ w + b
  return y_est.squeeze_()

LEARNING_RATE = 0.03
optimizer = pt.optim.SGD([w, b], lr = LEARNING_RATE)

GRADIENT_NORM = None                                                       ❶

ITERATION_COUNT = 5

for iter_idx, batch in zip(range(ITERATION_COUNT), train_dl):
  start_ts = time.perf_counter()

  X, y = batchToXy(batch)

  y_est = forward(X)
  mse = pt.nn.functional.mse_loss(y_est, y)
  mse.backward()

  pt.nn.utils.clip_grad_norm_([w, b],                                      ❷
                                GRADIENT_NORM) if GRADIENT_NORM else None  ❸

  optimizer.step()
  optimizer.zero_grad()

  sec_iter = time.perf_counter() - start_ts

  print(f"Iteration: {iter_idx:03d}, Seconds/Iteration: {sec_iter:.3f} 
➥   MSE: {mse.data.item():.2f}")

❶ 假设 GRADIENT_NORM 未初始化,将其设置为 None。

❷ 如果梯度高于 GRADIENT_NORM,则剪裁梯度;否则不进行任何操作。

列表 7.4 中的 ITERATION_COUNT 故意设置为 5,因为一旦执行列表中的代码,你将会看到类似以下的输出:

WARNING:root:defaulting to batch_size of 1048576
WARNING:root:stats_glob is not specified at initialization, defaulting to
stats_glob=s3://dc-taxi-c6e91f06095c3d7c61bcc0af33d68382-us-west-2/csv/dev/.meta/stats/part-00000-e4fcf448-1123-4bf4-b2bc-9768d30c6dd6-c000.csv
Iteration: 000, Seconds/Iteration: 0.020 MSE: 1590566.22
Iteration: 001, Seconds/Iteration: 0.024 MSE: 95402822161212448.00
Iteration: 002, Seconds/Iteration: 0.021 MSE: 
➥ 5722549747136962931644694528.00
Iteration: 003, Seconds/Iteration: 0.023 MSE: 
➥ 343256645163430856187799115795093520384.00
Iteration: 004, Seconds/Iteration: 0.021 MSE: 
➥ 20589650711877918152593680659301796448689601904640.00

请注意,与第六章中的线性回归示例不同,此输出中的损失函数未收敛。如果在看本书之前从未见过梯度下降产生这种行为,那么恭喜你!你刚刚观察到了你的第一个梯度爆炸!

如果你对这个结果感到意外,回想一下第六章中使用的合成 X 和 y 数据张量:它们的值的平均值为零,相对较小。相比之下,DC 出租车数据集中的数据由原始的位置和出租车费用值组成,具有较大的值和非零均值。你将在本书的后续部分学习有关如何正确准备数据集并防止梯度爆炸(和消失)的技术。现在,你应该知道可以使用内置的 PyTorch torch.nn.utils.clip_grad_norm_ 方法轻松解决梯度爆炸的问题。

列表 7.4 中的前两个注释 ❶ 和 ❸ 展示了如何在梯度下降迭代中包含梯度裁剪。当您在执行列表中的代码时观察到梯度爆炸时,GRADIENT_NORM 被设置为 None ❶,这将关闭梯度裁剪。要启用梯度裁剪,GRADIENT_NORM 的值应设置为正的小数值。该值被用作模型张量中最大梯度值的上限。换句话说,梯度裁剪相当于在传递给 clip_grad_norm 方法的张量的每个梯度值上运行 Python 的 min(gradient, GRADIENT_NORM) 函数。因此,在 backward 步骤(设置可能爆炸的梯度值)之后但优化器步骤(使用梯度值来更新模型参数)之前,使用 clip_grad_norm 方法非常重要。

为了获取训练数据集上均方误差的基准度量,将 GRADIENT_NORM 修改为 0.5,将 ITERATION_COUNT 修改为 50。GRADIENT_NORM 和 ITERATION_COUNT 的值成反比:将梯度裁剪到较小的值意味着梯度下降需要更多的迭代次数来调整模型参数的值。虽然了解梯度裁剪对于排查机器学习模型问题是有用的,但更好的方法是在首次出现梯度爆炸的风险最小化。

假设您在列表 7.4 中使用了默认的种子设置为 0,并使用 GRADIENT_NORM=0.5 和 ITERATION_COUNT=50 重新执行了代码,那么训练应该会返回梯度下降的最后 10 次迭代的以下结果:

Iteration: 040, Seconds/Iteration: 0.027 MSE: 2450.01
Iteration: 041, Seconds/Iteration: 0.026 MSE: 416.45
Iteration: 042, Seconds/Iteration: 0.026 MSE: 218.96
Iteration: 043, Seconds/Iteration: 0.026 MSE: 416.74
Iteration: 044, Seconds/Iteration: 0.026 MSE: 214.22
Iteration: 045, Seconds/Iteration: 0.027 MSE: 407.82
Iteration: 046, Seconds/Iteration: 0.029 MSE: 216.30
Iteration: 047, Seconds/Iteration: 0.026 MSE: 415.99
Iteration: 048, Seconds/Iteration: 0.026 MSE: 223.59
Iteration: 049, Seconds/Iteration: 0.026 MSE: 421.73

在最后 10 次迭代中,均方误差损失函数的值没有改善,并且 200-400 的范围明显是一个较弱的基准线。然而,启用了梯度裁剪后,梯度不再爆炸。

7.4 使用 GPU 加速 PyTorch 张量操作

本节介绍了 PyTorch 张量 API 提供的图形处理单元 (GPU) 支持,以及 GPU 如何帮助提高机器学习算法的性能,实现更高吞吐量的计算。了解 PyTorch 中的 GPU 支持将为下一节做准备,在下一节中,您将修改基准线性回归实现来使用 GPU 而不是 CPU 来处理 DC 出租车数据集。

Alex Krizhevsky 在 2012 年 ImageNet 竞赛中的获胜作品是帮助重新燃起对深度学习兴趣的最具有代表性的成功故事之一。 尽管卷积神经网络(Krizhevsky 使用的机器学习模型)自 1990 年代以来就广为人知,但它之所以在排行榜中获得最高排名,很大程度上要归功于“非常高效的 GPU 实现。” 从 2012 年以来,GPU 和其他专用处理器已被用于高效训练最先进的机器学习模型,特别是对于包含大量非结构化数据集的领域,例如包含图像,视频,音频或大量自然语言文档的领域。

PyTorch 张量可以在不更改机器学习代码实现的情况下利用 GPU 的更高吞吐量计算。 但是,如果您计划在机器学习项目中使用 GPU,则应清楚了解可以使用 CPU 获得的张量性能,并且还要了解使用 PyTorch 张量的 GPU 的入门障碍。

PyTorch 依赖于基于 Compute Unified Device Architecture (CUDA) 的 API 与 GPU 进行交互。 CUDA 是由 nVidia(一个主要 GPU 制造商)于 2007 年引入的,此后成为为像 PyTorch 这样的应用程序和框架提供 GPU API 的软件库的事实上的标准。 CUDA 使 PyTorch 能够在 GPU 上对张量操作进行并行处理,而不管 GPU 是由 nVidia,Intel 还是其他支持 CUDA 标准的处理器制造商构建的。

PyTorch 可以使用 CPU 执行有限程度的并行处理,因为现代处理器通常具有 2 至 16 个核心。 要查找 PyTorch 可用的 CPU 核心数量,可以执行以下 Python 代码:

import os
os.cpu_count()

在我的情况下,它返回 4。 在处理器术语中,每个 CPU 核心都可以充当独立的算术逻辑单元 (ALU),执行 PyTorch 张量操作所需的底层算术计算。 然而,如图 7.2 所示,在标准 CPU 上的 ALU 数量与 GPU 上的 ALU 数量相比相形见绌。

07-02

图 7.2 虽然具有更大的缓存,但 CPU(左)的并行处理吞吐量受 ALU 核心数的限制。 GPU(右)中较小的缓存和控制单元在更多的 ALU 核心之间共享,并具有比 CPU 更高的总并行处理吞吐量。

PyTorch CUDA APIs 可以为您提供有关 GPU 设备上的 ALU 数量的信息。 在使用 GPU 之前,习惯上需要在 PyTorch 中初始化设备变量。

列表 7.5 检查 GPU 和 CUDA 设备驱动程序是否可用

device = pt.device("cuda" if pt.cuda.is_available() else "cpu")

如果您的计算机上有一个 CUDA 设备可用,设备变量的值为 "cuda"。要找出您可用的 ALU 数量,您需要首先使用 get_device_capability 方法找出您的 CUDA 计算能力配置文件:

[pt.cuda.get_device_properties(i) for i in range(pt.cuda.device_count())]

在我的情况下报告

[_CudaDeviceProperties(name='Tesla P100-PCIE-16GB',
  major=6, minor=0, total_memory=16280MB, multi_processor_count=56)]

get_device_capability 返回的值不是实际的 ALU 计数,而是通用的设备配置。要找出配置文件的实际 ALU 数量,您需要查阅相应的 nVidia CUDA 网站条目:docs.nvidia.com/cuda/cuda-c-programming-guide/index.html。例如,在 6,0 配置文件的情况下,特定 URL 是 docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#compute-capability-6-x,其中列出了 64 个 ALU,与图 7.2 右侧的数字相匹配。

在第 7.3 节中,您了解了如何使用 set_default_dtype 方法指定 PyTorch 代码中创建的张量的默认 dtype。对于每种受支持的 PyTorch dtype(例如,torch.float64),PyTorch 库都提供了两种替代实现:一种是基于 CPU 的,另一种是基于 GPU 的张量。⁶ PyTorch 默认使用基于 CPU 的张量,除非您使用 set_default_tensor_type 方法将 CUDA-based 实现指定为默认值。例如,

pt.set_default_dtype(pt.float64)

tensor = pt.empty(1)
print(tensor.dtype, tensor.device)

输出

torch.float64 cpu

张量实例的设备属性报告 PyTorch 默认为基于 CPU 的实现。但是,您可以配置 PyTorch 默认为基于 GPU 的实现(清单 7.6 ❶)。

清单 7.6 使用 GPU 张量作为默认张量类型

pt.set_default_tensor_type(pt.cuda.FloatTensor)      ❶
pt.set_default_dtype(pt.float64)

tensor = pt.empty(1)
print(tensor.dtype, tensor.device)

❶ 使用 torch.cuda.FloatTensor 作为默认张量类型。

此代码生成

torch.float64 cuda:0

显示张量默认到第一个 CUDA GPU 设备,如 cuda 前缀和基于 0 的索引后缀所示。

注意 在本章中,您将学习如何扩展 PyTorch 代码以使用每个计算节点的单个 GPU。第八章和以后的章节解释如何扩展到多个 GPU 设备和网络中的多个计算节点。

set_default_tensor_type 方法是全局设置,因此可能会意外影响整个 PyTorch 代码库。即使您将 set_default_tensor_type 指定为使用 FloatTensor 张量的基于 GPU 的实现,您代码中创建的所有张量也会转换为使用 GPU。例如,

pt.set_default_tensor_type(pt.cuda.FloatTensor)
pt.set_default_dtype(pt.float64)

tensor = pt.empty(1, dtype=int)
print(tensor.dtype, tensor.device)

打印出

torch.int64 cuda:0

显示配置为 int 的张量实例也默认为基于 GPU 的实现。

虽然注意全局 set_default_tensor_type 设置很重要,但在使用 GPU 进行张量操作时更好的做法是直接在所需设备上创建张量。假设你按照清单 7.5 中的说明初始化设备变量,你可以通过设置设备命名参数在特定设备上创建张量:

device = pt.device("cuda" if pt.cuda.is_available() else "cpu")

tensor = pt.empty(1, dtype=int, device=device)
print(tensor.dtype, tensor.device)

这导致输出

torch.int64 cuda:0

如果 PyTorch 可用 CUDA 设备(即 cuda.is_available 为 True)。 当 CUDA 设备不可用或未配置时,⁷ 相同的代码输出

torch.int64 cpu

在您的 PyTorch 代码处理来自外部库(如 NumPy)的张量的情况下,将现有张量移动到不同设备可能很有用,方法是使用 to 方法。 例如,如果您的设备变量初始化为 cuda,则可以使用以下方法在 GPU 上创建包含 100 个随机元素的数组张量

a = pt.randn(100).to(device)

请注意,张量操作使用的所有张量必须驻留在同一设备上,以使操作成功。 这意味着

b = pt.randn(100).to(device)
a + b

正确返回张量 a 和 b 的和,而

c = pt.randn(100)
a + c

失败,出现 RuntimeError: 预期设备 cuda:0,但得到设备 cpu。

尽管与 CPU 相比,GPU 为机器学习提供了显著的性能改进,但将张量的内容从主计算机内存移动到 GPU 内存中涉及延迟开销。 要量化 GPU 的性能优势,您可以从以下基准函数开始:

import timeit
MAX_SIZE = 28
def benchmark_cpu_gpu(n, sizes):
  for device in ["cpu", "cuda"]:
    for size in sizes:
      a = pt.randn(size).to(device)
      b = pt.randn(size).to(device)
      yield timeit.timeit(lambda: a + b, number = n)

sizes = [2 ** i for i in range(MAX_SIZE)]
measurements = list(benchmark_cpu_gpu(1, sizes))
cpu = measurements[:MAX_SIZE]
gpu = measurements[MAX_SIZE:]
ratios = [cpu[i] / gpu[i] for i in range(len(cpu))]

其中,在执行基准方法之后,ratio 变量包含 GPU 与 CPU 的性能比(越高越好)。 例如,在我的测量中,GPU 对 CPU 的加速超过 1000 倍(图 7.3),特别是对于较大的张量大小。

07-03

图 7.3 对于更大的张量大小(横轴),GPU 可比 CPU(纵轴)快高达 150 倍。

但是,对于较小的张量大小,即包含 4,096 个浮点值或更少的张量(图 7.4),CPU 的性能要么与 GPU 相当,要么更快。

07-04

图 7.4 对于少于 4K 个值的张量(横轴),将数据传输到 GPU 内存的开销可能导致 GPU 性能等于 CPU 性能的 50%。

7.5 扩展以使用 GPU 核心

在本节中,您将修改列表 7.4 中的基准线性回归实现,以利用计算节点的多个 GPU 核心。 正如您从第 7.4 节中学到的那样,要使您的机器学习代码适应 GPU 的优势,您需要确保通过调用 torch.cuda.is_available() 方法正确配置 PyTorch 的 CUDA 设备和设备驱动程序(列表 7.7 ❶),其中可用设备被分配给设备变量。

列表 7.7 使用线性回归的弱基准

iimport os
import torch as pt
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds

pt.manual_seed(0);
pt.set_default_dtype(pt.float64)

device = pt.device("cuda" \                                                
                      if pt.cuda.is_available() else "cpu")                ❶

BATCH_SIZE = 1_048_576 # = 2 ** 20

train_ds = osds(f"s3://dc-taxi-{os.environ['BUCKET_ID']}-
➥                 {os.environ['AWS_DEFAULT_REGION']}/csv/dev/part*.csv",
    storage_options = {'anon': False},
    batch_size = BATCH_SIZE)

train_dl = DataLoader(train_ds,
                      pin_memory = True)                                   ❷

FEATURE_COUNT = 8
w = \                                                                      ❷'

  pt.nn.init.kaiming_uniform_(pt.empty(FEATURE_COUNT, 1,
                                        requires_grad=True, device=device))❸
b = \                                                                      
  pt.nn.init.kaiming_uniform_(pt.empty(1, 1,
                                      requires_grad = True, device=device))❹

def batchToXy(batch):
  batch = batch.squeeze_().to(device)                                      ❽
  return batch[:, 1:], batch[:, 0]

def forward(X):
  y_pred = X @ w + b
  return y_pred.squeeze_()

def loss(y_est, y):
  mse_loss = pt.mean((y_est - y) ** 2)
  return mse_loss

LEARNING_RATE = 0.03
optimizer = pt.optim.SGD([w, b], lr = LEARNING_RATE)

GRADIENT_NORM = 0.5

ITERATION_COUNT = 50

for iter_idx, batch in zip(range(ITERATION_COUNT), train_dl):              ❺
  start_ts = time.perf_counter()

  X, y = batchToXy(batch)

  y_est = forward(X)
  mse = loss(y_est, y)
  mse.backward()

  pt.nn.utils.clip_grad_norm_([w, b],
                              GRADIENT_NORM) if GRADIENT_NORM else None

  optimizer.step()
  optimizer.zero_grad()

  sec_iter = time.perf_counter() - start_ts

  print(f"Iteration: {iter_idx:03d}, Seconds/Iteration: {sec_iter:.3f} 
➥   MSE: {mse.data.item():.2f}")

❶ 当设备可用时,使用 GPU。

❷ 自定义 DataLoader 以将数据固定在内存中以加速传输。

❷ 初始化模型参数...

❸ 当可用时,将模型偏差设置为使用 GPU 设备,否则使用 CPU。

❽ 当 GPU 设备可用时,将批数据传输到 GPU 设备,否则不执行操作。

剩余的更改在清单 7.7❸—❾中突出显示。请注意,DataLoader 的实例化已更改以利用 pin_memory 参数❸。此参数通过“固定住”操作系统的虚拟内存页面,以防止页面从物理内存交换到存储器,反之亦然,从而帮助加速大张量从 CPU 内存到 GPU 的传输。其余的更改❹—❾只是为了指定正确的设备与 PyTorch 张量一起使用:如果 GPU 可用于 PyTorch 运行时,则为 cuda,否则为 cpu。

运行代码清单 7.7 应该可以通过 MSE 损失来展示弱基线:

Iteration: 040, Seconds/Iteration: 0.009 MSE: 865.98
Iteration: 041, Seconds/Iteration: 0.009 MSE: 36.48
Iteration: 042, Seconds/Iteration: 0.009 MSE: 857.78
Iteration: 043, Seconds/Iteration: 0.009 MSE: 39.33
Iteration: 044, Seconds/Iteration: 0.009 MSE: 868.70
Iteration: 045, Seconds/Iteration: 0.009 MSE: 37.57
Iteration: 046, Seconds/Iteration: 0.009 MSE: 870.87
Iteration: 047, Seconds/Iteration: 0.009 MSE: 36.42
Iteration: 048, Seconds/Iteration: 0.009 MSE: 852.75
Iteration: 049, Seconds/Iteration: 0.009 MSE: 36.37

总结

  • 在较小的机器学习问题中使用内存中的方法更快且更有效,而使用内存不足的技术可以扩展到更大的数据集和更大的信息技术资源池:计算、存储、网络。

  • 使用数据集批处理结合梯度下降,使得您的 PyTorch 代码能够利用单个节点中的计算资源并扩展到利用计算集群中的多个节点。

  • PyTorch 的 IterableDataset 简化了在 PyTorch 代码中对于内存不足和流式数据集的批处理的使用,而 ObjectStorageDataset 实用程序类提供了已经准备好的用于内存不足数据集的实现。

  • PyTorch 对于 GPU 的支持是由 CUDA 设备驱动程序实现的,它使得 PyTorch 开发人员可以轻松地扩展现有的 PyTorch 代码,以利用 GPU 的更高吞吐量和更多的并行计算能力。

  • 在 PyTorch 中实现基本的线性回归可以为 DC 出租车数据集上预期的训练均方误差提供一个弱基线。

^(1.)ObjectStorageDataset 支持的存储选项的完整列表可在此处找到: filesystem-spec.readthedocs.io/en/latest/

^(2.)论文摘要以及 PDF 版本的链接可以从 arXiv 获取:arxiv.org/ abs/1502.01852

^(3.)比赛网站以及比赛结果的链接可在此处找到:mng.bz/Koqj

^(4.)如 Alex Krizhevsky 的论文所述:www.cs.toronto.edu/~hinton/absps/imagenet.pdf

^(5.)谷歌开发了一种用于加速张量操作的张量处理单元(TPU)。

^(6.)关于 PyTorch 数据类型(dtypes)以及相应的 CPU 和 GPU 张量实现的详细文档,请参考mng.bz/9an7

^(7.)设备参数对于所有 PyTorch 张量的创建操作都可用,并在此处记录: mng.bz/jj8r

第八章:使用分布式训练进行扩展

本章内容包括:

  • 理解分布式数据并行梯度下降

  • 在梯度下降中使用梯度累积以处理内存不足的数据集

  • 对比参数服务器和基于环结构的分布式梯度下降方法

  • 理解基于环结构的梯度下降的 reduce-scatter 和 all-gather 阶段

  • 使用 Python 实现基于环结构的分布式梯度下降的单节点版本

在第七章中,您了解了如何将机器学习实现扩展到单个计算节点上,以充分利用可用的计算资源。例如,您可以看到如何利用 GPU 设备中更强大的处理器。然而,当您在生产中启动一个机器学习系统时,训练示例的数量增长速度和训练数据集的规模可能会超过即使是最强大的服务器和工作站的计算能力。尽管借助现代公共云基础设施的升级,例如通过升级到更强大的处理器,增加内存或 GPU 设备,可以获得很大的扩展性,但您应该有一个更好的长远计划。

分布式数据并行(DDP)训练是一种依靠扩展而不是升级的机器学习模型训练方法。随着训练数据集的增大,通过将模型训练涉及的计算工作负载划分并在网络计算服务器(节点)集群上进行,可以进行扩展。这里的“节点”是连接成集群的网络上的虚拟或物理服务器。与采用更高性能(也往往更昂贵)的计算节点来执行机器学习模型训练(即扩展方法)不同,通过扩展,您可以将一组较弱甚至是普通的计算节点组成一个网络,并通过在节点之间分布和并行执行工作的方式,可能更早地完成训练。事实上,将训练数据集扩展到更大规模意味着向集群中添加更多的节点。

DDP 模型训练不仅仅是通过向集群中添加节点来进行扩展。“数据并行”方面的 DDP 描述了在集群中,每个节点仅使用训练数据集的独立且互斥的划分(也称为“分片”)来计算梯度。通常,每个分片中的训练示例数量都被选定为确保每个节点的内存可以容纳该分片。虽然在 DDP 方法中,集群中的每个训练节点在梯度下降的每次迭代中都使用数据集的不同分片,但在迭代的范围内,所有节点必须使用相同的模型副本进行训练以计算模型参数梯度。因此,在节点根据训练数据集(或一批训练示例)计算梯度后,节点必须同步到更新后的模型参数版本。

在本章中,您将了解分布式梯度下降的替代方法以及 DDP 梯度下降实现如何帮助您有效地跨任意数量的节点扩展训练,同时使用具有有限计算、内存、存储和带宽资源的实用节点。

8.1 如果训练数据集不适合内存怎么办?

本节及其子节提供了逐步介绍梯度累积以及梯度累积在梯度下降中的作用,以支持超出内存训练数据集的功能。

8.1.1 说明梯度累积

本节演示了使用 PyTorch 的 autograd 库进行梯度累积。虽然本节中的示例是基于使用 autograd 与一个简单函数的情况,但后面的部分将梯度累积应用于更现实的示例。

当使用梯度下降与反向模式累积自动微分时,在执行梯度下降的优化步骤后,有必要清除张量的梯度值。在 PyTorch 中,可以通过将张量的梯度设置为 None 或使用 torch.optim.Optimizer 的辅助方法 zero_grad 来实现此操作。除非将梯度清零(清除),否则由损失函数产生的张量的 backward 方法的调用可能会导致模型的张量中梯度值的累积。以下列表显示了这种行为。

列表 8.1 说明梯度累积对于反向调用的重复调用的插图

import torch as pt
x = pt.tensor(3., requires_grad=True)     ❶
y = x ** 2
for _ in range(5):
  y.backward(retain_graph=True)           ❷
  print(x.grad)

❶ 使用 requires_grad=True 来启用对 y 相对于 x 的微分。

❷ 设置 retain_graph=True 来防止 PyTorch 释放内存。

这会输出

tensor(6.)
tensor(12.)
tensor(18.)
tensor(24.)
tensor(30.)

根据对 008-01_EQ01y = x² 的五次重复调用,输出为 3 时为 6。由于累积的结果,x.grad 的输出在 for 循环的 5 次迭代中跳过 6。尽管梯度累积可能看起来像是自动微分的一个不方便的副作用,但在将梯度下降扩展到超出内存数据集和分布式集群时,它可以发挥有用的作用。

8.1.2 准备一个示例模型和数据集

本节描述了如何准备一个示例模型和一个训练数据集,以说明梯度累积在扩展到超出内存数据集时的作用。在下一节中,您将学习如何在梯度下降中使用模型和数据集。

假设您正在处理一个包含 1,000 个结构化记录的训练数据集,并且执行您的梯度下降算法的计算节点只能一次容纳 250 个示例。当然,现代计算环境可以扩展到更大的数据集;然而,选择这些数字将证明对实例有用。让我们首先看一个适合内存的虚构数据集的梯度累积,然后再直接进入现实世界的超出内存数据集的复杂性。

列表 8.2 准备一个样本多元线性回归数据集

pt.manual_seed(42)                                     ❶
FEATURES = 4                                           ❷
TRAINING_DATASET_SIZE = 1000                           ❸

X_train = pt.distributions.multivariate_normal.
➥   MultivariateNormal(                               ❹
  pt.arange(FEATURES, dtype=pt.float32),               ❺
  pt.eye(FEATURES)).sample((TRAINING_DATASET_SIZE,))   ❻

y_train = X_train @ (pt.arange(FEATURES,
                      dtype=pt.float32) + 1)           ❼

❶ 设置伪随机数种子以实现可重现性。

❷ 创建用于多元线性回归问题的数据集。

❸ 在训练示例数据集中使用 1,000 条记录(行)。

❹ 使用 multivariate_normal 生成合成训练数据集。

❺ 使用不同的均值来作为独立变量。

❻ 指定独立变量应该不相关。

❼ 将 X_train 中的特征与系数相乘。

此列表创建了一个训练数据集,其中有四个特征(自变量)和一个因变量(标签),基于每个特征的四个系数 1、2、3、4。例如,假设在生成 X_train 值时使用了种子值 42,则 y_train[0] 的值是从 X_train[0,:] 计算的:

print(X_train[0, :] @ pt.tensor([1, 2, 3, 4], dtype = pt.float32))

应输出

tensor(19.1816)

您还可以通过打印来确认训练数据集张量 X_train 和 y_train 的预期形状

print(X_train.shape, y_train.shape)

应基于 TRAINING_DATASET_SIZE 和 FEATURES 的值输出如下:

(torch.Size([1000, 4]), torch.Size([1000]))

有了训练数据集张量的准备,您可以准备一个线性回归模型和支持方法,使用梯度下降来训练模型。模型 w 是用从标准正态分布中抽取的随机值初始化的。此外,由于模型参数张量 w 被创建为 requires_grad=True,因此张量的初始梯度值设置为 None。

列表 8.3 定义模型 w 和梯度下降的实用方法

pt.manual_seed(42)
w = pt.randn(FEATURES, requires_grad = True)     ❶
def forward(w, X):                               ❷
  return X @ w

def mse(y_est, y):
  err = y_est - y                                ❸
  return (err ** 2).mean()                       ❹

❶ 创建多元线性回归问题的模型。

❷ 基于模型 w 实现梯度下降的前向步骤。

❸ 计算目标(y)的误差(残差)。

❹ 返回均方误差的值。

尽管您可以使用更复杂的技术来初始化 w,但在这种情况下,多元线性回归问题足够简单,不需要增加复杂性。

8.1.3 理解使用超出内存的数据片段的梯度下降

在本节中,使用第 8.1.2 节准备的模型和数据集,使用梯度下降利用 autodiff 的梯度累积特性来扩展到超出内存的数据集。

通过依赖梯度累积,梯度下降可以使用图 8.1 中所示的方法基于整个训练数据集(即梯度下降的一个时期)来计算梯度。注意不要将图 8.1 中显示的分片与 mini-batch 梯度下降中使用的批次混淆;区别在下面的段落中进行了澄清。

08-01

图 8.1 梯度累积重新使用分片内存以实现对超出内存的数据集的扩展。

图 8.1 的左侧显示了使用 [0:250][0] 表示训练数据集中的前 250 个示例(记录)的第一个分片,[0:250][1] 表示第二个分片,即记录从 250 到 500,依此类推。在这里,使用 Python 切片表示法(例如,[0:250])来指定训练数据集中的哪些 1,000 个示例包含在一个分片中。

请注意,在图 8.1 中,每个分片都使用相同的模型 w 进行处理(在梯度下降的前向和后向步骤中),或者更准确地说,使用相同的 w 模型参数值。虽然图 8.1 中梯度积累的四个顺序步骤中模型参数值是相同的,但由于每个分片包含训练示例的不同集合,因此为每个分片计算的梯度也是不同的,并且是特定于分片的。在图中,使用下标表示分片及其相应的梯度之间的关系,以便分片 [0:250][0] 产生梯度g[0],依此类推。

一旦每个分片的训练样本计算出梯度(见清单 8.4),则不会使用分片梯度来更新模型参数。相反,梯度被保留在模型张量中累积。因此,在第二个训练示例分片通过前向方法处理,然后后向方法计算相应的梯度g[1]之后,模型张量 w.grad 包含梯度g[0]+g[1]的总和(累积)。

请注意,使用分片进行计算与小批量梯度下降中的批量计算不同,其中来自每个批次的梯度用于更新模型参数,然后清除。将批次与分片区分开很有用,因为两者都可以与梯度下降一起使用;例如,分片可以是批次的分区,在数据批次不适合节点内存的情况下。分片还可以由多个批次组成,以便通过处理存储在节点内存中的多个批次来加速梯度下降。虽然可以将分片与小批量梯度下降一起使用,但本节重点介绍使用分片与普通梯度下降的更基本示例,其中根据整个训练示例集计算梯度。

仅在处理完整个训练数据集后,一次处理一个分片,图 8.1 中的算法才执行基于累积梯度g[0]+g[1]+g[2]+g[3]的梯度下降的优化步骤。

清单 8.4 使用 IN_MEMORY_SHARD_SIZE 示例的梯度下降

EPOCHS = 500
LEARNING_RATE = 0.01
IN_MEMORY_SHARD_SIZE = 250

for epoch in range(EPOCHS):
  for i in range(0, \
  TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE):   ❶

    start_idx = i * IN_MEMORY_SHARD_SIZE
    end_idx = start_idx + IN_MEMORY_SHARD_SIZE
    y_shard = y_train[start_idx : end_idx]
    X_shard = X_train[start_idx : end_idx]          ❷

    y_est = forward(w, X_shard)                     ❸
    loss = \                                        ❹
      (IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE) * mse(y_est, y_shard)

    loss.backward()                                 ❺

  #notice that the following is
  #in scope of the outer for loop
  w.data -= LEARNING_RATE * w.grad                  ❻
  w.grad = None                                     ❼

❶ 每个周期执行 TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE 次迭代。

❷ 将训练示例分配给 y_shard 和 X_shard。

❸ 执行梯度下降的前向步骤。

❹ 计算调整后的分片大小训练损失。

❺ 执行反向传播和梯度累积

❻ 执行梯度下降优化步骤。

❼ 清除模型张量的梯度。

代码执行后,打印语句

print(w)

应该输出

tensor([1.0000, 2.0000, 3.0000, 4.0000], requires_grad=True)

证实梯度下降正确地恢复了列表 8.2 中使用的系数[1.0000,2.0000,3.0000,4.0000],以创建由 y_train 和 X_train 组成的训练数据集。

在列表 8.4 中计算损失时使用的分数(IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE)微妙但重要。回想一下,该列表旨在计算整个训练示例或更准确地说是 TRAINING_DATASET_SIZE 示例的梯度。mse 方法的默认实现,计算模型估计值 y_est 的均方误差,假定在计算期间有 IN_MEMORY_SHARD_SIZE 个示例。换句话说,在列表中内部 for 循环的每次迭代中,通过计算 mse 来计算08-01_EQ02,或者在 PyTorch 中等效地使用

(1 / IN_MEMORY_SHARD_SIZE) * ((y_est - y_shard) ** 2).sum()

返回每个 IN_MEMORY_DATASET_SIZE 示例的均方误差。列表 8.4 中在计算损失时使用的(IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE)分数将均方误差重新缩放为 TRAINING_DATASET_SIZE 示例。

通过这个以方程表示的乘法,注意到重新缩放相当于 IN_MEMORY_DATASET_SIZE,这在08-01_EQ02的分子和分母中取消了。

08-01_EQ03

当内部 for 循环完成时,w.grad 包含训练示例梯度的总和,因此代码 w.data -= LEARNING_RATE * w.grad 计算了整个 epoch 的片段的优化步骤。换句话说,在列表 8.4 中的梯度下降实现中,梯度优化步骤是针对每个训练示例的 epoch 执行一次。这证实了列表 8.4 中的实现不是小批量梯度下降。

虽然图 8.1 中的方法使得可以在使用任意片段大小的内存外数据集上进行扩展,但它遭受着一个显著的算法复杂性问题:内部 for 循环是顺序的,这会将梯度下降实现的大零性能从O(EPOCHS)变为O(EPOCHS * SHARDS)。

将列表 8.4 中的内部 for 循环分布到一组并行工作节点上,可以将实现恢复到原始O(EPOCHS)最坏情况的性能。但是如何高效地实现呢?

8.2 参数服务器方法的梯度累积

本节介绍了基于参数服务器的分布式梯度下降的实现,并解释了梯度累积在实现中的作用。本节澄清了参数服务器方法的局限性,并激发了更高效的基于环的实现。

像 TensorFlow 1.x 这样的传统机器学习框架普及了基于参数服务器的方法,以在集群的多个节点之间分布梯度下降。 图 8.2 中描绘的参数服务器方法易于理解和实现。

08-02

图 8.2 梯度下降在工作节点和参数服务器之间进行分布以支持扩展

在图中,每个工作节点(使用虚线表示)根据训练数据集的单个分片执行梯度下降的前向和后向步骤(例如清单 8.4 中的内部循环中的步骤),以计算损失函数的分片特定梯度。请注意,在图 8.2 中,梯度具有与用于计算梯度的分片的下标对应的下标,就像图 8.1 中一样。

一旦工作节点计算出其梯度,它就将梯度发送到参数服务器(或参数服务器集群)进行处理。参数服务器(图 8.2 的右侧)等待累积来自工作节点的梯度,并使用累积的梯度执行梯度下降的优化步骤,计算下一次梯度下降的模型参数。然后,基于新计算的模型参数(在图 8.2 中表示为 w'),将下一个版本的模型发送到工作节点,取代以前的模型参数(在图 8.2 中表示为 w),确保每个节点使用相同和更新的模型的下一个梯度下降迭代。

图 8.2 中的分布式梯度下降的参数服务器实现是一种分布式数据并行(在本章的介绍中定义)方法。在分布式数据并行方法中,训练数据集被划分为独立且互不重复的子集,以便训练数据集分片和工作节点之间存在一对一的关系。接下来,每个工作节点使用一个分片和一个相同的模型参数副本计算梯度。

与替代的分布式数据并行方法(在本章的其余部分中讲解)不同,分布式梯度下降的参数服务器实现存在重大的可伸缩性问题:工作节点和参数服务器之间的网络连通性是通信瓶颈。具体而言,在实现的两个通信阶段中都存在通信带宽受限的问题:在从工作节点到参数服务器的梯度的多到一(或多到少)通信期间,以及在从参数服务器(多个参数服务器)到工作节点的更新模型参数的一到多(或少到多)通信期间。

8.3 引入逻辑环形梯度下降

本节介绍了在逻辑环网络中通信的节点的基本概念。本节不是为了提供实际节点并使其通过网络通信,而是使用在单节点环境中运行的简单 Python 程序来解释网络概念。一旦你对概念有了牢固的掌握,你将把它们应用到更复杂的、分布式的、多节点环境中。

与依赖于集中式参数服务器集群(第 8.2 节中所示方法)相反,基于逻辑环的分布式数据并行算法(例如 Horovod;github.com/horovod/horovod)避免了一对多和多对一通信的瓶颈,并且仅依赖于在环中与两个逻辑邻居通信的节点:前趋节点和后继节点。

图 8.3(左侧)的图示显示了四个节点,每个节点使用虚线表示,并表示为节点n[0]到n[3],这些节点组织在一个逻辑环中。请注意,在公共云环境中的当代虚拟网络中,节点不必物理连接到彼此形成环:标准以太网网络足够。但是,在图中显示的逻辑环网络中,每个节点都受到限制,仅与其前趋节点和后继节点通信。正如您将在第 8.4 节中了解到的那样,这有助于限制分布式梯度下降每次迭代所需的网络带宽。

08-03

图 8.3 逻辑网络环(左)使用示例值解释

对于具有标识符n[i]的节点,后继节点的标识符被定义为n[(i+1) %] NODES,其中 NODES 是逻辑环中节点的总数。模运算确保通信模式形成一个环,其中具有最高标识符(始终为n[NODES-1])的节点与标识符为 0 的节点进行通信,反之亦然。在环形网络中,如本章所述,每个节点只向后继节点发送数据。

使用类似的逻辑,基于环形网络的前趋节点的标识符被定义为n[(i-1) %] NODES,以便节点 0 可以与节点 1 和具有最高标识符值(NODES - 1)的节点进行通信。本章使用的环网络中的每个节点只从前趋节点接收数据。

就像第 8.2 节中解释的基于参数服务器的方法一样,图 8.3 中的节点处理训练数据集的独立碎片,以便g0 表示由节点n[0]计算的具有索引 0 的碎片的梯度值。继续使用第 8.2 节的示例,如果[0:250]0 是四个碎片中的第一个碎片,那么g0 表示由节点n[0]计算的第一个碎片的梯度值,使用模型参数值 w。因此,就像基于参数服务器的方法一样,基于环的方法也是数据并行分布式的。

在基于环的分布式数据并行实现中,不存在专用的参数服务器。相反,在集群中的每个节点完成梯度下降迭代的前向和后向步骤后,节点在逻辑环网络中通信,以便所有碎片的梯度在环中的每个节点上累积。

需要在节点之间通信什么样的信息,以确保模型参数值和累积梯度值完全同步和相同?在逻辑环中,由于每个节点只能向后继节点发送数据,因此节点只能从前驱节点的一系列梯度发送/接收操作中接收累积梯度。例如,为了让节点n[0]从节点n[1]到n[3](图 8.4 的最右侧)累积梯度,需要三次迭代的发送/接收操作。这三次迭代从图 8.4 的左侧到右侧按顺序显示。正如您将在本章中观察到的那样,在由 NODES 个节点组成的多节点集群中,需要(NODES - 1)次发送/接收通信迭代。

08-04

图 8.4 在一个由四个节点组成的环中将梯度(求和)减少到节点 0,这是一个分布梯度的全局规约算法,用于在节点之间分发梯度。

列表 8.5 中的源代码提供了图 8.4 描述的逻辑的 Python 伪代码实现。在实现中,使用了 NODES 变量,该变量是使用训练数据集中训练示例的数量(常量 TRAINING_DATASET_SIZE 的值)与多节点集群中一个节点的内存中适合的训练示例的数量(IN_MEMORY_SHARD_SIZE 的值)之间的关系定义的。使用地板除法运算符//以确保 NODES 常量的值被设置为整数值,因为它稍后将用作 Python 范围操作的参数。

列表 8.5 Python 伪代码,以说明梯度减少到节点 0

NODES = \
  TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE       ❶

GRADIENTS = [5., 3., 2., 1.]                          ❷

node_to_gradients = \
  dict(zip(range(NODES), GRADIENTS))                  ❸

for iter in range(NODES - 1):                         ❹
  node = (iter + 1) % NODES                           ❺
  grad = node_to_gradients[node]                      ❻
  next_node = (node + 1) % NODES                      ❼

  # simulate "sending" of the gradient value
  # over the network to the next node in the ring
  node_to_gradients[next_node] += grad                ❽

❶ 计算训练数据集所需的节点数量。

❷ 为演示分配任意的 GRADIENT 值,每个节点一个。

❸ 创建一个字典来跟踪节点计算的梯度。

❹ 执行 NODES - 1 次通信迭代。

❺ 从节点 iter+1 开始,以便在 NODES-1 后...

❻……迭代,节点 0 累积梯度。

❼下一个节点的标识符结束了环。

❽在节点对梯度进行累积。

一旦代码执行完毕,打印 node_to_gradients 字典的值。

print(node_to_gradients)

输出结果:

{0: 11.0, 1: 3.0, 2: 5.0, 3: 6.0}

其中键 0 对应于预期梯度,计算的 n[0],值为 11,基于累积梯度 5+3+2+1。此外,请注意,由于图 8.4 不包括对n[0]以外的任何节点的梯度累积,因此 n[1]到n[3]的梯度保持不变。即将介绍的部分将解释如何确保在环中的所有节点上累积相同的梯度。

在三(节点-1)次迭代的第一次(在图 8.4 中以基于零的索引显示为迭代 0)中,节点n[1]发送并且节点n[2]接收节点n[1]在开始迭代 0 之前计算的梯度值 g1。由于在环中的通信目的是为了到达累积梯度,一旦接收 g1 梯度值,n[2]节点可以直接累积(加到)梯度值,以确保重用内存来存储累积梯度值:g1+g2。例如,如果每个节点上的每个梯度张量都是 400 MB,那么在环中的节点之间传输 400 MB 的数据,并且每个节点消耗 400 MB 的内存来存储累积梯度值。到迭代 0 结束时,节点n[2]累积了添加(即使用求和操作减少的)梯度。

因此,在第二次迭代(在图 8.4 中标记为迭代 1)期间,累积的梯度值从节点n[2]发送到节点n[3],导致在第二次迭代结束时在节点 n[3]上累积的梯度值g1+g2+g3。

在这个示例中的最后一次迭代(在图 8.4 中标记为迭代 2)完成了对节点n[0]上的梯度的累积,将在这次迭代中计算的节点n[0]上的梯度* g0 加到从n* [3]收到的累积梯度上。由此得到的梯度,包括g0+g1+g2+g3,足以让n[0]计算出下一个优化步骤的模型参数值,这个步骤是由集群中的每个节点执行的梯度下降过程。

虽然图 8.4 和列表 8.5 中示例的三次迭代实现了梯度的累积(reduce 步骤)到单个节点,但要使分布式数据并行梯度下降工作,环中的每个节点必须访问整个累积梯度:g0 + g1 + g2 + g3。除非每个节点都可以访问累积梯度,否则节点无法执行使用累积梯度更改模型参数值的梯度下降步骤。即将介绍的各节将基于列表 8.5 中的 reduce 步骤来解释整个分布式梯度下降算法的 reduce-all 阶段。

8.4 理解基于环形的分布式梯度下降

虽然第 8.3 节描述的天真的基于环的 reduce 操作可以消除对参数服务器的需求,并确保梯度值在环形多节点集群中的各个计算节点上被减少(累积),但它存在一些缺点。随着训练数据集的增长(这是可以预料的),集群中的节点数量必须增长以跟上。这也意味着集群需要的总带宽必须随着节点数量的增加而增长,因为每个节点在每次迭代期间都必须将整个梯度发送给环中的下一个节点。在本节中,您将了解基于环形分布式数据并行算法(例如,著名的 Horovod 算法)如何在规模化情况下帮助有效利用带宽,其中训练节点的数量和训练示例的数量都增加。

Horovod 算法可以支持训练数据集的增长(以及集群中节点的数量),同时保持带宽需求恒定或甚至降低带宽要求。为了支持这一点,Horovod 依赖于两个分离且独立的环形通信阶段:(1)reduce-scatter 和(2)all-gather。在两个阶段中,Horovod 不是在节点之间发送/接收整个梯度数据,而是只通信梯度的一个单一段落,其中默认情况下段落的大小是梯度大小乘以 08-04_EQ04,其中NODES是环集群中的工作节点数。因此,增加工作节点的数量以与训练数据集大小成比例地减少节点间通信的带宽需求。

那么梯度的 是什么?你可以把每个段视为梯度的逻辑分区,如图 8.5 所示。在图中,节点 n[0] 计算的梯度 g[0],基于训练数据集分片 [0:250][0](其中 [0:250] 是 Python 切片表示法),依次被分成 NODES 段,以便默认情况下,每个段都存在大致相等数量的梯度值。继续之前的例子,梯度占据了 400 MB 的数据量(例如模型参数的 32 位浮点梯度值的每 100,000,000 个字节为 4 字节),每个段是 100 MB 的互斥逻辑分区的相同模型张量。请注意,在这种情况下,由于分片是由节点 n[0] 计算的,因此每个四个段中的 i 都使用 s[i](n[0]) 进行注释。

08-05

图 8.5 Horovod 用于节点间通信的梯度段

还要注意,虽然在图 8.5 的框架的水平轴上不能累积(相加)段,但是可以沿垂直轴累积段。此外,图 8.5 段框架下方显示的段 s[i] 对应于每个节点计算的相应段的累积。例如,s[0] 等于 s[0](n[0]) + s[1](n[1]) + s[2](n[2]) + s[3](n[3])。因此,图 8.5 框架下方显示的 s[0]s[1]s[2]s[3] 段等同于将累积梯度 g[0] + g[1] + g[2] + g[3] 逻辑分割为段所需以执行梯度下降的优化步骤。

就像在列表 8.5 中介绍的基于环形减少步骤一样,本章的其余部分使用 Python 伪代码来解释 Horovod 算法。请回忆,对于一个分布式数据并行算法(如 Horovod)要正确工作,环形集群中的每个节点必须初始化具有模型参数的相同副本。在列表 8.6 中,Python 张量列表 W 被用来表示相同的模型。请注意,W 中的每个张量都是使用来自 w_src 的值初始化的,w_src 是从标准正态分布中抽样的伪随机值张量。

列表 8.6 W 存储模型张量的相同副本

pt.manual_seed(42)
w_src = pt.randn((4,))
W = [pt.tensor(w_src.detach().numpy(),
                requires_grad=True) for _ in range(NODES)]

为了重复使用列表 8.4 中的训练数据集张量 X_train 和 y_train,Horovod 算法的以下解释创建了一个 PyTorch DataLoader,它将训练数据集分成每个 IN_MEMORY_SHARD_SIZE 记录的分片。不要被列表 8.7 中 DataLoader 的 batch_size 参数所迷惑;虽然该参数用于分割源 TensorDataset,但是单个分片不会作为批量用于更新模型的参数。

列表 8.7 使用 PyTorch DataLoader 进行分片的梯度下降步骤

from torch.utils.data import TensorDataset, DataLoader
train_dl = DataLoader(TensorDataset(y_train, X_train), \
                      batch_size = IN_MEMORY_SHARD_SIZE,
                      shuffle = False)

for node, (y_shard, X_shard) in zip(range(NODES), train_dl):
  y_est = forward(W[node], X_shard)
  loss = \
    (IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE) * mse(y_shard, y_est)
  loss.backward()

代码执行完毕后,

[W[node].grad for node in range(NODES)]

应该输出

[tensor([ -0.1776, -10.4762, -19.9037, -31.2003]),
 tensor([  0.0823, -10.3284, -20.6617, -30.2549]),
 tensor([ -0.1322, -10.9773, -20.4698, -30.2835]),
 tensor([  0.1597, -10.4902, -19.8841, -29.5041])]

代表环形集群中每个节点的模型梯度的张量。

请注意,在使用列表 8.7 中 for 循环中的代码对每个节点执行梯度下降的前向和后向步骤之后,Horovod 算法必须执行两个基于环形网络的阶段,以便将累积梯度通信到环中的每个节点。第一个阶段称为 reduce-scatter,在第 8.5 节中进行了解释,第二个阶段称为 all-gather,在第 8.6 节中进行了解释。

8.5 阶段 1:Reduce-scatter

本节介绍了 Horovod 的 reduce-scatter 阶段,假设环形集群中的每个节点都使用模型参数的相同副本进行初始化。本节继续使用列表 8.7 中的示例,其中模型参数的相同副本存储在 W[node] 中,并且每个节点完成了梯度下降的前向和后向步骤,导致的梯度值保存在 W[node].grad 中。通过本节的结束,您将了解 Horovod 的 reduce-scatter 阶段如何确保环中的每个节点最终获得累积梯度 g[0] + g[1] + g[2] + g[3] 的不同段。

Horovod 的第一个阶段称为 reduce-scatter,在每个节点都完成基于数据集的节点特定分片的梯度计算后开始。如前一节所述,每个节点逻辑上将计算的梯度分成 NODES 个段。此阶段的第一次迭代(共三次迭代)显示在图 8.6 中,其中图的顶部显示,在阶段开始时,每个节点 n[i] 存储着特定于分片的段,s0 到 s[NODES-1](n[i])。

08-06

图 8.6 第一次 reduce-scatter 阶段的迭代启动了跨节点的梯度段传输。

由于每次迭代 reduce-scatter 仅将一个段的数据发送到后续节点,因此在第一次迭代(如图 8.6 底部箭头所示)中,节点 n[i] 将段 s[(i - 1)] % NODES(n[i]) 转发给后续节点。在第一次迭代结束时(图 8.6 底部),每个节点 n[i] 累积了一个段 s(i - t - 1) % NODES % NODES]) + s(i - t - 1) % NODES,其中 t=1 代表第一次迭代。

在后续的迭代中,每个节点将上一次迭代中累积的段发送给后继节点。例如,在第二次迭代(如图 8.7 所示)中,节点n[1]发送段s[3](n[0] + n[1]),节点n[2]发送段s[0](n[1] + n[2]),一般来说,对于第 t 次迭代,节点n[i]发送累积的段s[(i - t)] % NODES(n[i])。由于在具有四个节点的示例中,只需要三次迭代来减少散布段,因此图 8.7 的底部显示,到第二次迭代结束时,每个节点上只缺少每个段的一部分:由s(i + 1) % NODES 指定的段。

08-07

图 8.7 第二次减少散布迭代传播累积梯度。

这个缺失的部分在示例的第三个和最后一个迭代中填补,即在迭代结束时(图 8.8 的底部),每个节点n[i]都累积了整个段s[i]。例如,注意在图 8.8 中,n[0]以s[0]结束了本阶段的最后一次迭代,节点n[1]以s[1]结束,依此类推。

08-08

图 8.8 第三次减少散布迭代完成四节点环的梯度累积。

列表 8.8 减少散布阶段的 Python 伪代码

for iter in range(NODES - 1):
  for node in range(NODES):
    seg = (node - iter - 1) % NODES     ❶
    grad = W[node].grad[seg]            ❷

    next_node = (node + 1) % NODES
    W[next_node].grad[seg] += grad      ❸

❶ 第一个段被累积到第一个节点。

❷ 检索与节点和段 seg 对应的梯度值。

❸ 在环中的下一个节点上累积梯度段的值。

在列表 8.8 中的代码执行完毕后,可以使用以下方式输出结果梯度

print([f"{W[node].grad}" for node in range(NODES)])

应该打印出

['tensor([ -0.0679, -31.9437, -39.7879, -31.2003])',
 'tensor([  0.0823, -42.2722, -60.4496, -61.4552])',
 'tensor([-4.9943e-02, -1.0977e+01, -8.0919e+01, -9.1739e+01])',
 'tensor([ 1.0978e-01, -2.1468e+01, -1.9884e+01, -1.2124e+02])'].

请注意,如预期的那样,梯度值分散在节点之间,以便n[0]存储累积梯度的段s[0],n[1]存储段s[1],依此类推。一般来说,减少散布后的梯度累积段可以使用以下方式打印出来

print([f"{W[node].grad[node]}" for node in range(NODES)]),

它在每个节点上输出累积段的值:

['-0.06785149872303009', '-42.27215576171875', '-80.91938018798828', '-121.24281311035156']

图 8.9 的插图总结了当减少散布环由四个节点组成时,列表 8.8 中的代码的情况。

08-09a

图 8.9a 减少散布的迭代

08-09b

图 8.9b 减少散布的迭代

8.6 阶段 2:全聚合

本节介绍 Horovod 算法的第二个和最后一个阶段:全聚合。在本节中,您可以观察到来自减少散布阶段的累积梯度的散布段如何被收集或发送到环中,以便在阶段结束时,每个节点都存储整个累积梯度g[0] + g[1] + g[2] + g[3]。这意味着在本阶段结束时,逻辑环中的每个节点都可以执行梯度下降的优化步骤,并计算出模型参数的下一个迭代,以进行进一步的训练。

鉴于减少-分散阶段执行了有选择性地累积(减少)梯度段值的细微步骤,全收集的实现,即第二个和最后一个阶段,更容易理解。使用与减少-全部算法介绍的方法,此阶段仅涉及从一个节点发送累积的段到下一个节点。与 Horovod 算法的减少-分散阶段一样,全收集阶段在集群的逻辑环网络中进行节点到节点通信,共需要 NODES - 1 次迭代。

图 8.10 中四个节点的三次迭代分别表示为图的左上、右上和右下象限。图的左下角显示了节点在完成 Horovod 算法的所有步骤后集群中的最终状态。请注意,每个节点上的梯度段(表示为 s[0] 到 s[3])存储了从训练数据集的相应分片计算出的整个累积梯度(表示为 g[0] + g[1] + g[2] + g[3])。

08-10a

图 8.10a 全收集的迭代

08-10b

图 8.10b 全收集的迭代

图的左上象限指出,在第一个迭代的开始,示例中四个节点的状态是 n[0] 存储了累积梯度的段 s[0],等等。在该阶段的第一次迭代(左上象限)中,每个节点仅向环中的后继节点发送它存储的累积段,覆盖并替换后继节点中存储的任何先前的段值。

列表 8.9 全收集阶段的 Python 伪代码

for iter in range(NODES - 1):
  for node in range(NODES):
    seg = (node - iter) % NODES       ❶
    grad = W[node].grad[seg]

    next_node = (node + 1) % NODES
    W[next_node].grad[seg] = grad     ❷

❶ 从第一次迭代的第一个节点开始。

❷ 在环中的下一个节点上存储段的梯度值。

在第二次迭代开始时(图 8.10 的右上象限),每个节点恰好存储两个完整的累积梯度段。在这次迭代和剩余的迭代中,每个节点将在上一次迭代中接收的段(例如,在第二次迭代中,n[0] 接收了 s[3])发送到环中的后继节点。最后一次迭代(右下象限)完成了将梯度的剩余段传输到集群中的节点。在此阶段结束时(左下象限),环集群中的每个节点上都可以获得累积梯度 g[0] + g[1] + g[2] + g[3]。

此时,在每个节点上打印模型的梯度,

print([f"{W[node].grad}" for node in range(NODES)]),

为环中的每个节点输出了四个相同的梯度值:

['tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])',
 'tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])',
 'tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])',
 'tensor([-6.7851e-02, -4.2272e+01, -8.0919e+01, -1.2124e+02])']

列表 8.10 Horovod 基于环的分布式梯度下降算法

import torch as pt
from torch.utils.data import TensorDataset, DataLoader

IN_MEMORY_SHARD_SIZE = 250
TRAINING_DATASET_SIZE = 1000
NODES = TRAINING_DATASET_SIZE // IN_MEMORY_SHARD_SIZE

FEATURES = 4
pt.manual_seed(42)
w_src = pt.randn((FEATURES,))
W = [pt.tensor(w_src.detach().numpy(),
                requires_grad=True) for _ in range(NODES)]

def forward(w, X):
  return X @ w

def mse(y_est, y):
  err = y_est - y
  return (err ** 2).mean()

X_train = pt.distributions.multivariate_normal.MultivariateNormal(
    pt.arange(FEATURES, dtype=pt.float32),
    pt.eye(FEATURES)).sample((TRAINING_DATASET_SIZE,))
y_train = X_train @ (pt.arange(FEATURES, dtype=pt.float32) + 1)
train_dl = DataLoader(TensorDataset(y_train, X_train), \
                      batch_size = IN_MEMORY_SHARD_SIZE,
                      shuffle = False)

EPOCHS = 1000
LEARNING_RATE = 0.01
for epoch in range(EPOCHS):

  #compute per shard gradients on each node
  for node, (y_shard, X_shard) in zip(range(NODES), train_dl):
    y_est = forward(W[node], X_shard)
    loss = \
      (IN_MEMORY_SHARD_SIZE / TRAINING_DATASET_SIZE) * mse(y_shard, y_est)
    loss.backward()

  #horovod phase 1: reduce-scatter
  for iter in range(NODES - 1):
    for node in range(NODES):
      seg = (node - iter - 1) % NODES
      grad = W[node].grad[seg]

      next_node = (node + 1) % NODES
      W[next_node].grad[seg] += grad

  #horovod phase 2: all-gather
  for iter in range(NODES - 1):
    for node in range(NODES):
      seg = (node - iter) % NODES
      grad = W[node].grad[seg]

      next_node = (node + 1) % NODES
      W[next_node].grad[seg] = grad

  #perform a step of gradient descent
  for node in range(NODES):
    W[node].data -= LEARNING_RATE * W[node].grad
    W[node].grad = None

print([f"{W[node].data}" for node in range(NODES)])

这应该输出恢复的多变量线性回归系数:

['tensor([1.0000, 2.0000, 3.0000, 4.0000])',
 'tensor([1.0000, 2.0000, 3.0000, 4.0000])',
 'tensor([1.0000, 2.0000, 3.0000, 4.0000])',
 'tensor([1.0000, 2.0000, 3.0000, 4.0000])']

概要

  • 分布式数据并行训练是一种分布式梯度下降的方法,其中规模扩展的集群中的每个节点都使用训练模型的相同副本,但是使用训练数据集的专用分片。

  • 反向模式累积自动微分的梯度累积特性使得梯度下降可以缩小到有限内存节点,或者扩展到超出内存的数据集。

  • 基于遗留参数服务器的分布式数据并行梯度下降方法需要昂贵的广播式网络操作,并且在带宽限制下不易扩展。

  • Horovod 是一个可伸缩且带宽高效的算法,用于基于两个阶段的基于环形网络操作的分布式数据并行梯度下降:reduce-scatter 和 all-gather。

^(1.)自动微分的这个特性在第五章中有详细介绍。

^(2.)例如,许多深度学习模型都是使用 Kaiming 初始化进行初始化的:mng.bz/5K47

第三部分:无服务器机器学习流水线

一个机器学习系统不仅仅是一个模型和一个数据集。在这一部分,你将逐步学习整个机器学习流水线的工程步骤,从自动化特征工程到超参数优化和实验管理的步骤。

  • 在第九章中,你将探索围绕特征选择和特征工程的用例,通过案例研究学习,了解可以为 DC 出租车数据集创建的特征类型。

  • 在第十章中,你将采用一个名为 PyTorch Lightning 的 PyTorch 框架,以减少在你的实现中的样板工程代码的数量。此外,你将确保你可以训练、验证和测试基于 PyTorch Lightning 的机器学习模型。

  • 在第十一章中,你将把你的机器学习模型与 Optuna 超参数框架集成,基于 Optuna 建议的超参数值训练替代模型,并根据它们的损失和指标表现对模型进行排名。

  • 在第十二章中,你将把你的机器学习模型实现打包成一个 Docker 容器,以便通过整个机器学习流水线的各个阶段运行容器,从开发数据集开始,一直到准备好进行生产部署的训练模型。

第九章:特征选择

本章涵盖

  • 了解特征选择和特征工程原则

  • 将特征选择原则应用于案例研究

  • 基于案例分析磨练特征选择技能

到目前为止,您一直在使用 DC 出租车数据集的原始(原始)数据值作为机器学习模型的特征。 特征 是机器学习模型在训练和推断阶段期间使用的输入值或一组值(请参阅附录 A)。 特征工程 是选择、设计和实施使用原始数据值的合成(虚构)特征的过程,可以显着提高模型的机器学习性能。 特征工程的一些例子是对原始数据值进行简单的、公式化的转换,例如将任意数字值重新调整到-1 到 1 的范围。 特征选择(也称为特征设计)是特征工程的初始阶段,是工作的更有创意的部分,涉及指定捕获有关数据集的人类知识或直觉的特征,例如选择衡量出租车行程数据集中每次乘车的上车和下车位置之间距离的特征。

无差别地向项目数据集中添加大量特征可能是一个代价高昂的错误(参见“维度诅咒”问题)。 特征“过度设计”可能导致过拟合以及机器学习模型性能的整体下降。 这引出了一个问题:有哪些指导原则可以帮助选择正确的特征,以避免可怕的特征过度设计? 本章将使用案例研究来介绍这些原则,并说明如何在实践中应用它们。

本章涵盖了三个案例研究,涉及金融、广告和移动游戏行业。 对于每个案例研究,您将得到一个行业的机器学习项目描述,以及您预期为该项目训练的机器学习模型的高级规范。 然后,您将得到该项目的候选特征描述。 最后,每个案例研究的讨论部分描述了如何应用五个指导原则,以帮助您决定是否应选择候选特征。

特征选择的指导原则

本节介绍了五个指导原则,以帮助你为你的机器学习项目选择正确的特征。虽然我没有看到这些原则被作为行业标准编纂,但它们总结了我在选择数据科学、机器学习和深度学习项目特征方面超过十年的经验。本节的其余部分将更详细地解释这些指导原则,以便在第 9.2 节中,你可以将它们应用于案例研究和候选特征的具体示例。根据这些原则,一个候选特征应该是:

  • 与标签相关

  • 在推断时间之前记录

  • 得到丰富的例子支持

  • 表达为具有有意义刻度的数字

  • 基于项目的专业见解

9.1.1 与标签相关

本节教你在评估特征(或潜在特征)与标签之间关系时应考虑哪些因素,以便你可以为你的特征工程工作选择和优先考虑特征。在你决定在机器学习模型训练实验中包含一个潜在特征之前,确保你能够表达一个理由(一种证明),解释为什么这个特征与标签值相关联。表达理由可以帮助你说服自己(在理想情况下,一个公正的观察者),这个特征实际上与你尝试使用机器学习解决的问题相关。当然,有些理由比其他理由更好:“为什么不呢?”并不是一个强有力的特征理由。一般来说,弱理由会导致潜在特征的数量过多,并且与标签的相关性强弱不一。

请注意,对于一个特征有一个理由也很重要,因为候选特征与标签之间的关系可能会根据你的问题而改变。在实践中,改变你用数据集回答的问题可以改变候选特征与标签之间是否存在关系。例如,当估算跨越 DC 的出租车费用时,接送地点之间的距离特征值与出租车费用估算相关。然而,如果你决定不再估算出租车费用,而是决定使用距离特征来估算在 DC 特定时间的特定位置的出租车接送数量,那么这个特征就失去了与标签的相关性。虽然似乎显而易见,一个为不同项目选择的特征在更改标签时可能变得无用,但在实践中,训练数据集和特征存储库被重新使用于机器学习项目,导致意外重复使用无关和潜在有害(如本章的其余部分所示)的特征。

当本节中使用的“相关”一词有不同的含义时,其意义也不尽相同,例如,“统计相关”就是其中一种相关性。正如你所知,统计相关性意味着因果关系可能对特征和标记之间的关系起到作用,也可能不起作用。

考虑一个众所周知的例子:根据标准的皮尔逊统计相关度量,儿童的阅读能力与他们的鞋尺码相关。换句话说,根据经典统计学的说法,如果你想要估算一个孩子的鞋码,你可以使用孩子最近一次阅读测试的分数。当然,在鞋尺码和阅读能力变量之间不存在因果关系,因相关性存在于年龄这一潜在(混淆)变量之间。

鞋尺码估算的例子如图 9.1 所示。注意,孩子年龄与鞋尺码之间的因果关系,以及孩子年龄和阅读成绩之间的因果关系也转化为这些变量对之间的统计相关性。因此,虽然候选特征与标记之间的统计相关性可以作为特征的基线理由,但更强的证明可以基于特征与标记之间的因果关系。

09-01

图 9.1 特征可以基于孩子的阅读测试成绩或年龄变量,但基于年龄的特征与鞋尺码之间有更强的相关性,因为年龄与鞋尺码之间存在反事实因果关系。

是否可以区分相关性和因果关系?这取决于你对因果关系的定义。大多数现代机器学习和数据科学从业者和学者在业界和学术界都使用一种称为“反事实因果”的因果关系。¹ 你可以通过回答一个假设性问题来决定候选原因和效应变量之间是否存在反事实因果关系:在其他所有条件相同的情况下,如果一个全能的演员只改变原因,那么结果是否必然改变?注意,这种因果关系在阅读测试成绩和鞋尺码之间的关系中不存在:如果有人干预并改变某个年龄段儿童的平均测试成绩,他们的鞋尺码不会改变。相比之下,用一群年龄更小的孩子代替一群年龄更大的孩子,会导致平均阅读测试分数(假设测试没有经过年龄调整)增加,平均鞋尺码也变大。

当阐述候选特征和标记之间的相关关系时,有助于确定并优先考虑与标记存在反事实因果关系的特征,而不是表明与标记之间存在相关性或不明确关系的特征。

9.1.2 推理之前的记录

本节教你如何避免特征选择中的一个常见陷阱:使用在训练时已知但在部署到生产环境后难以获取或难以获取的特征。

您正在考虑的特征值必须在推理时可用,一旦您的机器学习模型处理超出用于创建模型的训练和测试数据集的数据时。这是特征选择的一个重要但经常被忽视的方面,特别是因为训练数据集是回顾性的,通常不包含有关数据值可用顺序的信息。例如,描述公司销售交易历史的训练数据集通常不会捕捉到在关闭销售交易的金额之前,客户的姓名和联系信息是公司可用的这一事实。

使这个问题微妙的是,回顾性地查看过去事件的数据,正如在训练数据集中记录的,事件的顺序可能并不是立即显而易见的,您可能会在推理时无意中使用未来的信息。例如,假设您正在尝试估计新生儿的体重,其中一个候选特征是怀孕的持续时间。

注意:早产儿的体重低于平均水平,而在怀孕 42 周后出生的婴儿平均体重较重,因此,以周数表示的怀孕持续时间似乎是一个有用的选择特征。

在训练数据集中找到怀孕期(以周为单位)的持续时间不足为奇,因为这是一个历史性的衡量标准,在出生时记录是有意义的。但是,如果您正在创建一个模型,以估计怀孕期中途的常规医生探访时新生儿的预期体重,那么出生时的怀孕周数就是未知的。

对新生儿体重估计的示例只是所谓数据泄漏问题的一个实例。数据泄漏的其他示例包括在训练、验证和测试数据集之间重复观察。通常,当关于标签值的信息无意中包含或“泄漏”到训练数据集中时,就会发生数据泄漏。数据泄漏的症状包括在训练数据集上膨胀的机器学习模型性能,甚至在部署到生产环境后完全失败。例如,当使用来自著名的泰坦尼克号数据集的特征时,就会出现这个问题,如图 9.2 所示。

09-02

在泰坦尼克号数据集的特征子集中,年龄和性别在乘客的生存结果之前是已知的。根据乘客的生存或死亡情况记录了船和尸体特征的值。因此,船和尸体特征会“泄漏”有关生存标签的数据。

推理过去事件的预测是违反直觉的;然而,泰坦尼克号数据集恰恰邀请您这样做。您可以训练一个机器学习模型来预测泰坦尼克号在 1912 年 4 月横渡大西洋时的乘客的二元生存结果(是否幸存)。虽然整个数据集包含 13 个特征和一个标签,但图 9.2 仅显示了与此示例相关的 4 个特征以及标签(生存)。数据集中的生存标签使用值 1 表示乘客幸存,而值 0 表示乘客遇难。年龄特征是数值型的,表示乘客登船时的年龄,而性别特征则限定为男性和女性值,分别使用 1 和 0 表示。船和尸体特征都是分类的,并编码为字符串。船特征存储乘客被救的船的标识符(例如“11”)。尸体特征存储类似“328”的值,指定了遇难乘客的标识符。

为了避免使用“预测”一词带来的违反直觉的含义,在考虑候选特征训练机器学习模型时,将每位乘客的生存结果推断出来是有帮助的。从时间顺序的角度来看,推断基于年龄和性别特征的生存是合理的,因为这两个特征在每位乘客的生存结果之前都存在。图 9.2 使用从年龄和性别特征指向生存标签的箭头来展示这一点。因此,机器学习模型可以训练使用年龄和性别特征,因为这两个特征应该在推断时可用。

相比之下,一旦泰坦尼克号上的乘客幸存或遇难,该乘客就会在救生船上幸存或者以尸体标签结束。图 9.2 使用从生存标签指向尸体和船特征的箭头来展示这一点。尸体和船特征值都不应该用于训练机器学习模型推断泰坦尼克号乘客的生存结果。当然,我们不希望发生类似的事件,因此这样的模型永远不会用于推断除泰坦尼克号乘客之外的任何乘客的生存结果。这个例子的目标是介绍数据集中特征的重要性以及特征之间的时间顺序关系。

在其他情况下,由于法律或伦理原因,在推断时可能无法获得特征值。例如,对于一个总部位于欧盟(EU)的公司,记录求职者的出生日期是完全合法的。如果你在欧盟使用这种类型的人力资源信息来估计求职者接受工作邀约的可能性,求职者的年龄是一个合理的特征选择。然而,一旦你试图在美国应用同样的特征,获取求职者的出生日期可能违法,因为在美国,基于年龄做招聘决定是非法的。此外,泄露有关出生日期的特征,如高中或大学毕业年份,也可能引起企业的法律担忧。因此,如果你试图将在欧盟建立的机器学习系统调整到美国,你应该重新评估培训数据集中的特征是否被你的组织允许使用。正如你可以想象的那样,人力资源数据集可能充满了信息(例如种族或健康记录),但作为机器学习模型的特征则是非法或不道德的。

9.1.3 丰富的示例支持

本节探讨了在选择具有太多缺失值或具有太少不同值的特征时可能出现的问题。

简单地通过添加 NaN 或 NULL 值的列将一个特征引入到训练数据集中是非常简单的。虽然这样的特征显然是无用的,但这个例子提醒我们,在增加训练数据集中的特征数量时,更多并不意味着更好。另一个极端情况涉及添加组合起来作为标签的唯一标识符的特征。作为特征工程的最坏情况示例,考虑一个分类问题的训练数据集,其中分类标签有 2^N 个不同的值。由于 N 个二进制特征可以编码 2^N 种可能性,每个标签值可能都可以通过 N 个特征列的二进制值来唯一标识。

当然,在实践中很少会发生特征过度工程的极端情况,但它们可以作为方便的参考点,用于比较你自己的特征工程工作。例如,如果你正在选择一个特征,你是否期望在培训数据集中有显著比例(大于 5%)的特征值不是 NaN?如果你选择了多个特征,这些特征值在一起是否会导致在培训数据集中的标签值具有唯一标识符,而在测试数据集中没有?

要回答这些问题,你需要确保在考虑候选特征时,也要保持对特征缺失值的统计以及每个标签值的特征值集合的交叉乘积计数。

9.1.4 用具有有意义的比例表达的数字

本节将教授一个方便的经验法则,用于检查是否将一个特征正确地表达为可用于机器学习算法的数字。

正如你在第一章中了解的那样,本书关注的是从结构化数据集进行有监督机器学习。对于特征工程来说,这意味着如果你计划使用用于训练机器学习模型的原始数据包含视频、图像、音频或自然语言文本等非结构化内容,则在开始进行本书中描述的特征工程步骤之前,必须将相应的非结构化数据值转换为数字表示。这些转换的具体技术,例如自然语言文本的单词嵌入或用于摄影数据的图像分类,超出了本书的范围。

对于特征工程来说,拥有一个由数值组成的项目数据集是前提条件,因为有监督的机器学习模型是对数字(根据附录 A 的定义,可以是连续的或分类的)特征值进行算术操作以估计标签值的序列。但是,即使你的原始数据值是数字(即使用数字表达的),这也不意味着用于训练机器学习模型的相应特征具有有意义的数值比例和大小。如果你不相信,考虑一下字母 A 和 B 在 ASCII 编码标准中分别表示为数字 65 和 66。编码的总和 131(65+66)对应于字符 â(带有抑扬符的 a 字母),这不是一个有意义的结果。

如果你能够熟练应用附录 A 中图表 A.9 中的正式定义到这个例子中,你应该会认识到 ASCII 编码了一个描述 ASCII 字符有限词典的分类变量。一般而言,你可以通过对这些值进行基本的算术操作来检查数值是否可以被视为连续变量,以确认是否能够获得有意义的结果。

9.1.5 基于项目的专业见解

本节描述了特征设计、选择和工程最重要的指导原则——它可以对你的机器学习模型的性能产生最积极的影响。

在与具有各自行业深度领域专业知识的主题专家(SMEs)一起开展机器学习项目时,我发现鼓励 SMEs 提出有用特征的最有效方式是问他们:“您会与团队分享哪些信息以帮助他们更好地估计标签值?” 这种谈话方式激励 SMEs 将注意力从机器学习系统设计的复杂性转移到以自然、以人为本的术语思考问题。接下来,我会问:“如何从项目数据集中提取这些信息?”

总之,对这些问题的回答提供了从项目数据集中生成候选特征的途径。例如,假设您正在解决的问题是对世界各地城市拍摄的汽车照片进行分类,以估计照片是否包含该城市的出租车。虽然可以进行复杂的工程演练,提取徽章号码、车牌号码或其他独特的出租车标识符,并将此信息与城市出租车的市政数据集相结合,但有一个更简单的前进路径。了解该城市的人类 SMEs 可以立即通过颜色辨认出出租车,例如在纽约市为黄色或在伦敦为黑色。这个经验法则是 SME 可以轻松与从未去过该城市的人分享,并帮助他们在街上的众多汽车中识别出一辆出租车。

成功的特征选择不仅仅是算法数据处理;它是一种基于对世界的人类常识和对问题领域的洞察的创造性过程,用于机器学习项目。请记住,本节中的案例研究方法可以介绍您了解特征选择的构思和设计基础。但是,这并不能取代将机器学习系统投入生产的真实世界经验。

9.2 特征选择案例研究

本节介绍了在金融、广告和移动游戏行业跨越三个不同案例研究中应用监督机器学习的应用。在每个案例研究中,您将了解一个特定行业的机器学习项目,以便了解如何将特征选择原则应用于其他项目。

  • 金融:信用卡欺诈分类。您正在与一家金融行业公司合作,该公司向客户发行信用卡,并监视客户信用卡交易以寻找欺诈迹象。您监督的机器学习分类模型的目的是估计给定交易是否欺诈或非欺诈。为了使特征工程练习简单化,请假设您正在使用一个平衡的数据集(在生产中并非如此)来处理欺诈与非欺诈示例,因此您的分类器的准确度是一个有意义的机器学习模型性能指标。

  • 广告:在线横幅广告点击估计。你正在与一家广告行业公司合作,为其客户管理在线广告横幅,并在观看者点击横幅时收费。由于该公司有一款为客户设计横幅广告的工具(例如横幅广告设计师示例,请查看github.com/osipov/banner-designer),它拥有横幅广告设计和广告活动期间广告收到的点击数的数据集。你监督的机器学习回归模型的目的是根据横幅设计估计广告应该获得的总点击数。模型的特征应该根据广告内容和设计设置进行选择。

  • 移动游戏:流失预测。你正在与一家快速增长的移动游戏初创公司合作,帮助他们通过对他们的畅销射击游戏 Clash Legends 进行升级来提高客户满意度。你监督的机器学习回归模型的目的是估计下一周有望从其移动设备上卸载游戏(即流失)的总客户数(游戏玩家)。

9.3 使用指导原则进行特征选择

在这一部分中,针对第 9.2 节的每个案例研究,都提出了几个建议的特征,并讨论了是否应该选择该应用程序。请注意,对于每个案例研究,使用领域知识和一些常识有助于决定是否选择该特征。

9.3.1 与标签相关

这一部分教授了评估候选特征所需的概念,即它们与标签的关系有多强,以便您可以优先选择更有效的特征。

案例研究: 信用卡欺诈分类

特征: 在交易中使用信用卡购买同一供应商的次数

讨论: 一笔欺诈性信用卡交易可能涉及未经授权的购买。例如,欺诈者可能使用被盗的信用卡从欺诈者拥有的虚假在线商店购买商品,或者从没有视频监控或保留不良记录的实体供应商购买商品。相反,如果卡在交易中多次用于从供应商购买且没有欺诈报告的情况,则关联性可能较低。对于这个特征,关联应该是明显的,并且应该选择该特征用于模型。

特征: 在交易期间信用卡插入信用卡阅读器的毫秒数

讨论:这是一个技术性的信息示例,机器学习从业者通常可以获得这些信息,但却不能转化为模型的重要特征。注意,使用信用卡进行实体交易时,有时卡片会很快取出,而有时卡片会停留在读卡器中较长时间。只要交易成功完成,就没有这个特征与某个交易是否欺诈之间的关联。尽管你可能会认为,一个欺诈者很可能窃取了一批卡,并在进行欺诈交易时迅速轮换使用它们,但请记住,模型的目的并不是对一批交易进行分类,而是必须对任何单一的任意交易进行分类。大多数交易都是非欺诈性的,并涉及各种各样的信用卡读卡器和许多用户,他们可能会将卡片在读卡器中停留任意长的时间。在这种情况下,并没有明显的关联选择这个特征。

特征:交易中供应商的业务类别

讨论:众所周知,在美国,盗取的信用卡通常会在加油站进行小额购买,以确认卡片的正常使用。尽管在特征选择时可能不知道其他关联性,但机器学习的应用可能会揭示供应商特定业务类别与欺诈交易之间的关联。

特征:信用卡的过期日期

讨论:如果这个候选特征仅仅捕捉了卡的过期月份,而卡片的过期月份大致上有相同的几率,那么就没有理由相信这个特征和欺诈之间存在关联。此外,仅仅捕捉过期月份和年份并不意味着伪造交易发生在一个过期的卡上:当卡到期时,是不会发生交易的,所以不需要将其归类为欺诈或非欺诈。换句话说,没有理由相信欺诈者能够以某种方式盯上在十二月过期的卡而不是一月过期的卡。然而,在美国,从邮箱里盗取信用卡存在潜在的欺诈交易的可能性。这意味着你可以选择一个更复杂的特征,来捕捉交易日期和卡的到期日之间的差异,以便在发生欺诈交易靠近卡发放日期的时候进行检测,例如卡到期前两到五年。

案例研究:在线横幅广告点击预估

特征:横幅广告中商品的价格

讨论: 这是可以从广告中选择的最明显的特征之一。商品的价格很可能是决定广告查看者是否对广告感兴趣并点击横幅的最重要因素。鉴于这种关联的强度,这应该是你回归模型的优先特征。

特征: 横幅广告中文本的字体类别

讨论: 在这种情况下,该特征基于广告中使用的字体的命名类别,例如 Times New Roman 或 Comic Sans。要描述这个候选特征与标签之间的关联,请记住设计元素(如字体)会引发观众的情绪反应。由于某些字体可能更具吸引力,因此捕捉广告中使用的字体类型的分类特征可以模拟字体引发的参与感与广告点击总数之间的微妙关联。当然,这个特征应该与广告的其他设计元素一起使用,包括内容。例如,选择 Comic Sans 来广告小丑服装可能会比将该字体用于财富管理广告更容易产生更多点击。

特征: 横幅广告上显示的剩余库存商品数量

讨论: 你一定曾在某个在线零售网站购物时看到过类似“仅剩三件”的消息,位于你想购买的商品旁边。那么,选择一个指示横幅广告上显示的剩余商品数量的特征是否合适呢?由于你一定在真实的横幅广告上看到过这样的消息,你可能会倾向于选择这个特征,但是这个特征与标签之间的关联是什么呢?在这种情况下,关联与广告查看者在看到广告商品的有限库存后可能产生的更强烈的紧迫感有关。紧迫感可能会转化为更高的点击率,这正是你试图捕捉的。

特征: 库存系统报告的广告商品数量

讨论: 尽管你可能会认为商品的低库存水平是商品受欢迎程度的代理,表明这是一种备受追捧的商品,但你应该从许多不同横幅广告跨多个广告系列的角度思考这个特征。例如,玩具汽车的库存量与实际汽车的库存量以及纸张的库存量不同。此外,广告查看者对于广告商品的实际库存数量一无所知,因此点击广告的决定与实际库存商品数量之间没有关联。

案例研究:流失预测

特征: 客户的邮政编码

讨论: 玩家的地理位置与他们在下一个季度流失的潜力可能有各种各样的联系。一些原因是技术性的:也许游戏服务器基础设施使用的网络基础设施会使美国东南部的邮政编码产生更高的延迟(因此增加了不好的体验),而与加州湾区的客户相比。其他原因可能来自人口统计学:与游戏的“甜点”相比,一些邮编有较老或较年轻的人口。

特征: 每月花费在服务上的美元金额

讨论: 由于移动游戏通常包括每月循环订阅价格以及各种选项(例如:花钱购买强化道具,玩家角色装饰和其他选项来在其他玩家中脱颖而出),因此玩家在游戏中花费的金额是其投入程度的代表,也是其投资程度的衡量标准。因此,花费的金额与下一个季度流失的可能性之间存在复杂的关系。例如,不在游戏上花钱可能意味着玩家失去了兴趣,更有可能卸载游戏。另一方面,如果玩家过度投入,花费过多的巨额款项(例如,处于前 0.1%的百分位数),他们也很可能因游戏过度消磨时间而精疲力尽,从而卸载他们的设备上的游戏。

特征: 距下一次游戏公司纳税日期的天数

讨论: 美国的公司必须向美国税务局提交季度和年度纳税申报表。令人惊讶的是,许多公司注意到了纳税日期和客户流失的变化之间的相关性。尽管公司内部分析师可能会注意到并报告此相关性,但您应该对此协会持怀疑态度:玩家在卸载日期时是否甚至知道这些日期?如果存在相关性,更有可能是在月底,当很多人回顾他们的月度支出并决定削减不必要的物品时。由于美国税务局的纳税申报日期与许多人重新评估他们的支出的时间重合,因此这可能表现为美国税务局时间表和客户流失之间的虚假相关性。

特征: 订阅游戏的周数

讨论: 游戏的许多卸载发生在玩家第一次安装游戏后不久,然后决定放弃它。

9.3.2 推理时间之前记录

本节介绍了数据泄漏的概念,它可能会微妙地破坏您的机器学习模型的性能,并说明了有效的特征选择如何帮助您避免这些问题。

案例研究:信用卡欺诈分类

特征: 在该店之前使用过该卡

讨论: 此信息应在推理时可用,因为通过实体卡阅读器进行的实体卡交易将被分类不同,并为每笔交易记录适当的信息。

特征: 交易中售出的商店商品是新进货的商品

讨论: 如果你进行过信用卡购买,你会知道,在交易级别,购买的详细信息对于发卡公司是不可用的。例如,如果你在杂货店购买并购买了一种新口味的可口可乐,信用卡公司无法将此信息与商店的其他购买区分开来;所有商品只是被合并成一笔费用,所以这对你的机器学习模型来说不是一个好的特征。

特征: 交易中销售的商品类别

讨论: 这可能在推理时可用,也可能不可用。例如,如果交易是汽油购买,那么类别是明显的。在其他情况下,当购买是在诸如 Target 或 Walmart 之类的大型零售商处进行时,存在成千上万种不同的商品类别,这些信息在推理时是不可用的。

特征: 使用信用卡在实体(而不是在线)位置消费

讨论: 此信息应在推理时可用,因为通过实体卡阅读器进行的实体卡交易将被分类不同,并为每笔交易记录适当的信息。

案例研究:在线横幅广告点击量估算

特征: 使用折扣码购买的商品总数

讨论: 尽管此信息应在运行活动的公司的数据仓库中在活动结束后可用,但此信息在推理时不可用。

特征: 在过去 30 天内使用折扣码购买的商品数量

讨论: 如果运营广告的公司维护有关折扣码使用次数的交易数据,那么可以维护一个滑动窗口,记录过去 30 天的数据,并为任何给定的交易计算这个值。此外,通过预测横幅广告上折扣码的点击次数,可以使用这个每日值更好地估计总横幅广告点击次数。

特征: 查看了有关该商品的横幅广告的顾客数量

讨论: 对于在线横幅广告,信息应该来自数据仓库或数据分析源。

特征: 制造商广告库存中的商品数量

讨论: 故意选择这个候选特征,引发一个发人深省的关于潜在特征和广告参与度的讨论。请记住,训练数据集是基于过去广告活动的数据和实际的横幅广告点击次数。虽然您可能拥有关于广告活动中使用的项目的历史库存数据(在广告被查看时),但是否应该将此数据用于机器学习模型的特征?尽管您可能会认为低库存水平表示一个受欢迎或需求量大的项目,但您应该从许多不同横幅广告的广告活动的角度考虑该特征。您无法建立合理的关联来使用此特征。此外,在运行时(执行推断或产生估计时)获得此特征的值可能是一个技术挑战,这可能超过了通过尝试此关联获得的价值。

案例研究:流失预测

特征: 游戏中总共花费的分钟数

讨论: 请注意,在描述此特征时使用词语 可能会导致混淆。对于卸载游戏的人来说,总数是指安装游戏的整个时间段内花费的分钟数。相比之下,对于没有卸载游戏的人来说,总数描述的是他们的数据被记录在训练数据集中的时点之前的分钟数。一旦清楚了这个双重解释的可能性,也就清楚了不应使用此特征,因为在推断时不可能确定从未取消订阅或卸载游戏的玩家的游戏总分钟数。

特征: 过去 28 天内玩游戏的总分钟数

讨论: 通过微小的变化,限制对过去 28 天内游戏时间的测量,可以使用前一特征的关键思想。无论玩家是否在下个月流失,都可以测量他们过去 28 天的游戏活动,并将其用作训练和推断的特征。

特征: 客户提供的卸载原因

讨论: 当玩家卸载游戏时,他们可以指定反馈,说明他们为什么决定卸载。显然,这些信息仅适用于已卸载游戏的玩家,并且只有在他们卸载游戏后才可用。因此,在推断时不可用,不应用于训练机器学习模型。

特征: 流失前三个月客户满意度得分

讨论: 如果客户满意度是随机收集的,涵盖了已卸载游戏的玩家以及继续玩游戏的玩家,那么这是一个有用的特征,可以用于推断。

9.3.3 丰富的例子支持

本节提供了候选特征的示例,这些特征可能或可能不足以训练机器学习模型,以指导您在实际示例中使用此原则。

案例研究:信用卡欺诈分类

特征: 持卡人地址和商家地址之间的距离

讨论: 拥有有关持卡人地址和交易发生地点之间距离的信息在欺诈分类时可能很有用。但是,要使用此特征,从业者需要评估在实践中是否有此特征的示例。除非客户在交易时已积极地进行了地理编码以测量距离,否则不应期望有足够的此特征示例。

特征: 持卡人地址和商家地址的邮政编码是否相同

讨论: 请注意,与试图使用地理编码估算持卡人和商家位置之间距离的特征不同,可以通过检查商家和持卡人邮政编码是否匹配的特征来支持客户的历史交易数据。由于每笔金融交易都应具有此特征,因此应该有大量关于此特征的示例。

特征: 在商家处使用过卡片

讨论: 金融公司会为给定卡片维护一份交易历史记录,因此可以检查以前的交易,以确定该卡片是否在特定商家处使用过。缺少与商家的交易表明该卡片未在商家处使用过,因此对于数据集中的每笔交易,可以为此特征分配真值或假值。

特征: 购买物品的类别

讨论: 尽管对于一些商家(如加油站),可能可以唯一地识别交易中购买的物品的类别,但许多公司维护专有的物品库存代码。一般而言,并不保证交易包含有关所购买物品的信息。您不应期望对于一般用途的欺诈与非欺诈分类器而言,有足够数量的购买物品类别示例。但是,您可能可以为特定子类商家(如加油站)创建更专业的分类器。

案例研究:在线横幅广告点击量估算

特征: 广告活动开始的年份的日期

讨论: 只要管理横幅广告活动的公司维护活动的开始和结束日期,此日期应该对于每个训练示例都是可用的。

特征: 查看横幅广告的人已经购买了广告中的物品

  • 讨论: 大多数情况下,横幅广告中显示的商品购买次数与横幅广告浏览次数相比少于 0.1%。你不应该期望有大量例子表明广告观看者是否购买了该商品。

  • 特征: 广告中提供的折扣百分比

  • 讨论: 广告中提供的大多数折扣百分比都是基于一小部分众所周知的值,如 10%、20%、25%、50%、75%或 90%。你不应该期望在横幅广告上看到折扣 27.54%。只要跨营销活动提供的不同折扣金额的数量是训练数据集的一个小部分,你应该有足够的示例来支持这个特征。

案例研究:流失预测

  • 特征: 玩家账单地址位置的纬度和经度

  • 讨论: 由于客户账单地址位置的纬度和经度对于每个客户的账单位置是唯一的,使用这些值来估计玩家是否要卸载游戏是错误的。除非谨慎使用,特定的坐标值可能会产生过度拟合训练数据集的模型。不应使用账单地址位置的原始纬度和经度值来预测客户流失。

  • 特征: 玩家以前卸载游戏的次数

  • 讨论: 如果玩家过去卸载了游戏,他们很可能会再次卸载。请注意,游戏卸载记录的缺失可能意味着玩家从未卸载过游戏,只要游戏平台将卸载事件准确报告给游戏公司即可。因此,应该能够为每个训练示例选择具有值的此功能。

  • 特征: 玩家打算在下个月游戏上花费的美元金额

  • 讨论: 尽管知道一个给定玩家在下个月的预算中有多少钱用于支付手机游戏可以极大地帮助估计他们是否会流失,但公司很有可能对任何或任何重要部分的玩家都没有这些信息。

9.3.4 具有意义大小的数值

这一部分比较了备选特征表示法,以帮助你选择连续和分类特征之间的区别。

案例研究:信用卡欺诈分类

  • 特征: 交易中供应商的邮政编码

  • 讨论: 尽管交易中的邮政编码是一个整数,但应明确它不是连续值,不能直接在机器学习模型中使用。如果你还不相信,你可以应用算术值的经验法则:将纽约市的邮政编码 10001 加到华盛顿特区的邮政编码 20002,得到 30003 的邮政编码,这是乔治亚州 Norcross 的邮政编码-显然是一个无意义的结果。邮政编码值应被编码并被视为一个分类变量。

特征: 交易中信用卡被用于从同一供应商购买的次数

讨论: 虽然可以将此计数视为连续变量并获得有意义的结果,但请注意,知道卡片在供应商处使用的实际次数并不特别有意义。

特征: 信用卡的到期日期

讨论: 信用卡到期的月份和年份作为连续变量并不实用。你可以将这些信息重新编码为距离到期的天数,但是这些信息的原始数值并没有实际意义。

案例研究:在线横幅广告点击量估计

特征: 折扣的百分比值,例如 10%、25% 或 50% off

讨论: 表面上看,这似乎是一个简单的数值特征值。但是,负值呢?可能获得负 100%的折扣吗?这应该被分类为重新列出特征值的分类特征。

特征: 横幅广告的尺寸

讨论: 表面上看,像 400 像素乘以 400 像素或 100 像素乘以 400 像素这样的数量似乎是传统的数值特征。然而,由于模型试图从这些值中外推和内插,你可能会发现自己处理意外的结果。

特征: 横幅广告所使用的字体

讨论: 添加或乘以字体值会产生无意义的结果。这不是一个连续而是一个分类特征。

特征: 横幅广告字体的颜色

讨论: 虽然可以将颜色表示为数字的组合,例如使用红-绿-蓝值,但在这种情况下,特征是关于预测广告点击的,因此更适合使用分类表示,因为颜色在整个广告中是统一的,落入人类可读的类别,例如蓝色、黑色或绿色。

特征: 通过优惠券折扣的项目类别标识符

讨论: 类别编号,如 1 代表乳制品,3 代表罐头食品等,作为连续值并不具有实际意义,应该重新编码为分类变量。

案例研究:流失预测

特征: 客户的平均游戏时间与用户群体的平均游戏时间之比

讨论: 这个特征应该被编码为连续值。它有一个有意义的零值(即当客户的平均值与整个用户群的平均值相同时),并且有一个有意义的负值到正值的范围。

特征: 订阅游戏的周数

讨论: 这个特征应该被编码为连续值,因为它可以被细分为更精细的部分。

特征: 客户使用的移动操作系统(例如 iOS、Android 或其他)

讨论: 由于移动游戏应用程序支持的操作系统是有限的,所以这个特征应该被编码为分类变量。

9.3.5 带来专家见解解决问题

本节将通过金融、广告和游戏领域的专家见解的示例,帮助您磨练这些特性,以实现更有效的机器学习。

如本章所述,虽然在技术上选择各种特性是可行的,但并不是每个潜在的特性都对机器学习系统的成功有所贡献,有些特性可能对系统产生更多伤害而不是益处。成功的特性是那些专家知识和常识增强原始数据集并简化机器学习算法任务的特性,使特性与标签值之间的关系更直接。

案例研究:信用卡欺诈分类

特性: 过去一个月与嫌疑供应商的交易

讨论: 根据专家的说法,许多欺诈交易是由于供应商受到威胁,员工窃取信用卡信息后使用被窃信息实施欺诈行为。人工专家通常使用基于图形的分析来识别涉嫌教唆欺诈的供应商,如图 9.3 所示。图 9.3 显示,三张用于报告欺诈交易的信用卡都在同一嫌疑供应商处使用,以粗体显示在左侧。

09-03

图 9.3 合法的信用卡交易与嫌疑供应商(左侧以粗体显示)导致被报告为欺诈的交易(右侧)。

基于专家见解,您可以为每个供应商使用一个数字特性,表示过去一个月报告欺诈的信用卡总数。

案例研究:在线横幅广告点击量估计

特性: 广告主题与 Twitter 热门话题的相关度评分

讨论: 正如你所预期的,在线横幅广告的点击量不仅取决于广告本身的内容,还取决于其与市场的相关性。例如,关于手电筒的广告会在飓风登陆前夜产生更多的点击量。同样,在温网决赛周末,关于私人网球课程的广告会产生更多的点击量。基于这一观察结果,您可以设计一个数字特性,捕捉在线横幅广告中与广告活动期间 Twitter 上热门话题的主题之间的相似性。

案例研究:流失预测

特性: 社交网络上玩游戏的连接人数。

讨论: 视频游戏设计人员会告诉您,同行压力是玩家是否继续玩游戏的最强预测因素之一。这就是为什么很多手机游戏尝试连接到您的 Facebook 和其他社交媒体帐户的原因:如果游戏开发商知道您的“朋友”在玩游戏,他们就会知道您也有可能玩。

9.4 选择 DC 出租车数据集的特征

在这一部分,您将学习有关 DC 出租车票价数据集的专家见解,以及如何将这些见解应用于选择一组候选特征,以供您的机器学习模型使用。

从第四章中可以回顾到,DC 出租车数据集的特征集相当稀疏:推断时仅可用于原始数据包括行程开始的日期和时间戳以及出租车行程的乘车和下车位置的纬度和经度坐标。您可以在出租车票价估算问题中带来什么见解以选择正确的特征?

对于 DC 出租车数据集,一个关键的专家见解涉及用于指定行程位置坐标的 GPS。GPS 系统可以精确到距离实际位置 1 米(略超过 3 英尺)。然而,从出租车票价估算的角度来看,GPS 精度过高:正如第二章所述,出租车行程的业务规则使用 1/8 英里的粒度进行定价。这相当于大约 200 米(约 660 英尺)的精度。定价精度比 GPS 坐标估计的精度粗略多达两个数量级。因此,该数据集的特征可以包括对出租车乘车和下车坐标的更粗略的表示。

DC 出租车数据集中的出租车乘车地点的边界是使用纬度和经度坐标的最小值和最大值指定的。这些最小值和最大值如何用于特征?图 9.4 中的插图解释了关于乘车和下车坐标的粗略表示的概念。

09-04

图 9.4 中的原始最小和最大纬度和经度坐标(左)可用于基于坐标的分箱生成数值特征(右)。

为了清晰起见,DC 出租车数据集的实际纬度和经度坐标的最小值和最大值被替换为一组更方便的数字。尽管这些虚构的坐标数字与华盛顿特区非常接近,但它们被选中是为了更容易地解释。因此,图 9.4 中的左侧和右侧都假设 DC 出租车数据集中所有行程的坐标范围从纬度(南北)38.30 到 38.90,经度(东西)-76.48 到-76.12。

除了使用原始纬度和经度值来训练机器学习模型之外,用于乘车和下车坐标的粗粒度表示的特征可以使用分箱(也称为离散化量化)GPS 坐标值的概念,如图 9.4 右侧所示,假设纬度和经度坐标均被“分箱”为三个箱子,分别对应于三个相等大小的区间:

  • (38.30, 38.50),(38.50, 38.70),以及 (38.70, 38.90),对应纬度

  • (–76.48, –76.36) (–76.36, –76.24),以及 (–76.24, –76.12),对应经度

图 9.4 右侧的图表使用整数 0、1 和 2 索引了三个箱子中的每一个。因此,九个粗粒度位置中的每一个可以使用一对整数表示。例如,从一个上车位置(38.31,-76.47)到一个下车位置(38.61,-76.14)的出租车行程可以分别用位置(0,0)和(2,1)表示。请记住,选择九个位置是任意的。由于不清楚位置边界应该有多粗,因此最终应该根据相应特征如何帮助模型预测出租车票价来衡量坐标的粗细选择。

在接下来的章节中,你将学习如何应用特征工程技术来实现本节描述的粗粒度上车和下车位置特征。请注意,尽管基于专家对粗粒度位置表示的见解的特征不能保证改进的出租车票价估计,但这些特征可以在机器学习模型开发的迭代过程中使用,并且可以根据其对机器学习模型性能指标的影响进行评估。

概要

  • 有效的特征工程可以决定机器学习系统是平庸还是成功。

  • 尽管特征选择更多的是一门艺术而不是一门科学,但机器学习实践者可以通过练习指导原则到现实世界的例子,学习识别机器学习系统所需的正确特征的技能。

  • 本章的案例研究帮助机器学习实践者学习如何将特征选择原则一贯地应用于来自不同行业的案例研究,包括金融、广告和移动游戏。

  • 成功的特征工程通过精心选择和设计的特征,将常识知识和专家见解融入到机器学习问题中的原始训练数据中。

^(1.)反事实因果关系与因果关系的潜在结果定义密切相关。Judea Pearl 等人的书籍,《统计因果推断:入门》(Wiley,2016),是一个很好的资源,对因果关系有更正式的处理。

^(2.)泰坦尼克号数据集,以及文档,可从www.openml.org/d/40945获取。

第十章:采用 PyTorch Lightning

本章涵盖了

  • 使用 PyTorch Lightning 减少样板代码

  • 为 DC 出租车模型添加训练、验证和测试支持

  • 使用 pandas 分析 DC 出租车模型的训练和验证

到目前为止,您已经编写了与训练和测试您的机器学习模型相关的实现。然而,您编写的大部分代码与您的机器学习模型架构无关,可以适用于广泛范围的不同模型。基于这一观察结果,本章介绍了 PyTorch Lightning,这是一个可以帮助您减少机器学习系统中样板工程代码量的框架,并因此帮助您专注于发展您的模型设计和实现的框架。

了解 PyTorch Lightning

本节介绍了 PyTorch Lightning 框架,用于您的 PyTorch DC 出租车车费估算模型,并教您如何启用 PyTorch Lightning 训练、验证和测试特性的步骤。

目前为止,您已经为您的机器学习模型实现了大部分的 Python 和 PyTorch 样板代码。这意味着你的实现中只有少部分是模型特定的,比如

  • 将特征值打包为张量

  • 配置神经网络层

  • 计算损失的张量

  • 模型指标报告

剩下的大部分代码,比如迭代培训批次、验证批次和培训纪元的代码,主要是样板代码,这意味着它可以在模型特定的代码发生各种变化时不加修改地重复使用。

随着您的机器学习系统变得更加复杂,系统实现中的样板代码也会变得更加复杂。例如,成熟的机器学习系统需要周期性保存(检查点)模型的权重值到存储介质,以便实现可复制性。拥有模型检查点还可以使机器学习训练流程从预训练模型中恢复。其他例子包括与超参数优化服务、指标跟踪和控制机器学习流程的其他实验管理工具集成的代码。这不应该令人惊讶:回想一下第一章中提到的,一个生产级机器学习系统的组件中有超过 90% 是辅助于核心机器学习代码的。

PyTorch Lightning 框架 (www.pytorchlightning.ai) 的目标是通过帮助开发者专注于开发核心机器学习代码而不被样板代码分散注意力来提高 PyTorch 开发者的生产力。就 DC 出租车模型而言,采用 PyTorch Lightning 是直接的。在开始之前,您需要确保已经在您的 shell 环境中运行以下内容安装了 PyTorch Lightning 的 pip 包:

pip install pytorch_lightning

PyTorch Lightning 是一个全面的框架,具有可观的特性集,用于机器学习模型开发。本书不旨在取代现有的 PyTorch Lightning 教程或文档;相反,接下来的章节专注于您可以为 DC 出租车模型采用的框架特性。

10.1.1 将 PyTorch 模型训练转换为 PyTorch Lightning

本节教你关于 PyTorch Lightning 的 init、training_step 和 configure_optimizers 方法,然后演示如何为 DC 出租车模型实现这些方法以及如何使用小型的示例训练数据集训练基于 PyTorch Lightning 的 DC 出租车模型。

假设 PyTorch Lightning 包在您的环境中正确安装,您可以实现一个最小的、可训练的 DC 出租车模型。

图 10.1:带有支持的基本 PyTorch Lightning DC 出租车模型

import torch as pt
import pytorch_lightning as pl                                               ❶

pt.set_default_dtype(pt.float64)                                             ❷

class DcTaxiModel(pl.LightningModule):                                       ❸
  def __init__(self, **kwargs):                                              ❹
    super().__init__()                                                       ❺
    self.save_hyperparameters()                                              ❻
    pt.manual_seed(int(self.hparams.seed))                                   ❼
    self.layers = pt.nn.Linear(int(self.hparams.num_features), 1)            ❽

  def batchToXy(batch):                                                      ❾
    batch = batch.squeeze_()
    X, y = batch[:, 1:], batch[:, 0]
    return X, y

  def forward(X):                                                            ❿
    y_est = self.model(X)
    return y_est.squeeze_()

  def training_step(self, batch, batch_idx):
    X, y = self.batchToXy(batch)
    y_est = self.forward(X)
    loss = pt.nn.functional.mse_loss(y_est, y)
    for k,v in {
        "train_mse": loss.item(),
        "train_rmse": loss.sqrt().item(),
    }.items():
      self.log(k, v, on_step=True, on_epoch=True, prog_bar=True, logger=True)⓫

    return loss                                                              ⓬

  def configure_optimizers(self):                                            ⓭
    optimizers = {'Adam': pt.optim.AdamW,
                    'SGD': pt.optim.SGD}
    optimizer = optimizers[self.hparams.optimizer]

    return optimizer(self.layers.parameters(),                               ⓮
                        lr = float(self.hparams.lr))

model = DcTaxiModel(**{                                                      ⓯
    "seed": "1686523060",
    "num_features": "8",
    "optimizer": "Adam",
    "lr": "0.03",
    "max_batches": "100",
    "batch_size": "64",
})

❶ 导入 PyTorch Lightning 库,并将其别名为 pl。

❷ 使用 torch.float64 作为模型参数的 dtype。

❸ PyTorch Lightning 模型必须扩展自 LightningModule。

❹ 使用 **kwargs 传递超参数给模型。

❺ LightningModule 子类必须首先调用父类的 init

❻ 将 **kwargs 中的超参数保存到 self.hparams。

❼ 根据超参数设置设置伪随机数生成器。

❽ 为此示例使用简单的线性回归模型。

❾ 重用第七章的函数进行 batchToXy 的 . . .

❿ . . . 以及前向实现。

⓫ 使用 PyTorch Lightning 内置的日志记录来记录 MSE 和 RMSE 测量值。

⓬ training_step 方法必须返回损失张量。

⓭ LightningModule 子类必须有一个 configure_optimizers 方法。

⓮ 返回由超参数指定的配置好的优化器实例。

⓯ 实例化一个 PyTorch Lightning 版本的 DcTaxiModel,命名为 model。

注意,DcTaxiModel 类继承自基础的 pl.LightningModule 类,在 init 方法中通过 super().init() 方法调用需要单独初始化。类的其余 init 方法在此处被简化以进行说明,并突出显示以下关键概念:将模型的超参数存储在 self.hparams 中以及在 self.layers 实例中实例化模型参数。

training_step 方法是 PyTorch Lightning 实现的工作核心,执行模型层的前向步骤,计算损失并返回损失值。请注意,它依赖于 batchToXy 方法(在第七章介绍),该方法负责将一批训练样例转换为适合模型训练的格式。

转换就是使用 squeeze_ 方法消除任何形状为 1 的维度。例如,形状为 [1, 128, 5, 1] 的张量在 squeeze_ 应用后被重新调整为 [128,5]。在 squeeze_ 中使用下划线进行了小幅度的性能优化。回想一下第五章所讲的,squeeze_ 中的下划线表示 PyTorch 方法将就地执行操作,对张量进行突变,而不是返回新的张量实例。

DcTaxiModel 的实现假定张量中的第一列是标签,其余列是特征。因此,在 batchToXy 代码的结尾部分,只需将标签简单地别名为 y,将特征别名为 X 并返回结果。

在 training_step 方法中调用 self.log 报告模型计算出的训练 MSE 和 RMSE 值。正如第五章所解释的那样,在 PyTorch 张量 API 中,标量张量的 item 方法返回常规的 Python 值而不是张量。因此,使用 self.log 记录的值是 Python 数值而不是 PyTorch 张量。PyTorch Lightning 的 self.log 方法是一个可扩展的日志框架的通用 API,稍后在本章中有更详细的介绍。

示例中的 configure_optimizers 方法使用优化器字典,以便使模型能够根据优化器超参数的值在不同的优化算法(Adam 和 SGD)之间切换。尽管这种模型训练的实现尚未使用超参数优化,但在 configure_optimizers 中展示的基于字典查找的方法确保了当后续开发启用超参数优化时,模型代码无需改变。

在 PyTorch Lightning 中,使用一个 Trainer 实例来训练模型。

代码清单 10.2:使用 PyTorch Lightining Trainer 训练子类

from pytorch_lightning.loggers import CSVLogger
csvLog = \
  CSVLogger(save_dir = "logs",                         ❶
            name = "dctaxi",
            version = f"seed_{model.hparams.seed}")    ❷

trainer = \
  pl.Trainer(gpus = pt.cuda.device_count() \           ❸
                      if pt.cuda.is_available() else 0,
    max_epochs = 1,                                    ❹
    limit_train_batches = \                            ❺
      int( model.hparams.max_batches ) \
            if 'max_batches' in model.hparams else 1,
    log_every_n_steps = 1,                             ❻
    logger = [csvLog])                                 ❼

❶ 使用 CSVLogger 可以用 pandas 进行分析。

❷ seed 超参数被用于唯一标识模型日志。

❸ 在有多个 GPU 可用时使用多个 GPU 进行训练。

❹ 在此将其设置为 1,因为训练持续时间由 max_batches 控制。

❺ 使用 max_batches 来设置训练迭代的次数。

❻ 确保每次调用 self.log 的记录都被保存在日志中。

❼ 基于 csvLog 设置将发送到 self.log 的值保存下来。

超参数值可以应用于机器学习流水线,而不仅仅是模型:例如,max_batches 超参数控制模型训练的持续时间。正如你将在本章的其余部分中看到的,超参数值可以在机器学习流水线的各个阶段使用。代码示例中的 max_epochs 设置旨在确保训练流水线可以支持 Iterable 和 Map PyTorch 数据集。回想一下第七章,IterableDataset 实例具有可变数量的训练数据集示例;因此,对这一类别的训练受到限制,限制训练批次的数量。这个数字是使用 Trainer 的 limit_train_batches 参数指定的。

在列表 10.2 中,progress_bar_refresh_rate 和 weight_summary 设置是 Trainer 的合理默认设置,以最小化训练过程中报告的日志信息量。如果你喜欢对训练模型参数进行报告,你可以将 weights_summary 更改为 "full",以报告所有权重,或者更改为 "top",以仅报告模型中顶部(连接到模型主干的权重)的层的权重。同样,progress_bar_refresh_rate 可以更改为表示多久重新绘制一次显示训练完成进度的进度条的整数值(以训练步骤的数量为单位)。

要提供训练样本给模型,你可以使用第七章介绍的 ObjectStorageDataset。在执行下一个示例中的代码片段之前,请确保已安装 Kaen 框架使用

pip install kaen[osds]

接下来,要仅执行模型的训练,你可以在 pl.Trainer 的实例上调用 fit 方法,传入一个包含训练样本的 PyTorch DataLoader:

from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds

train_ds = osds('https://raw.githubusercontent.com/osipov/
➥                   smlbook/master/train.csv',
                batch_size = int(model.hparams.batch_size) )

train_dl = DataLoader(train_ds,
                      pin_memory = True)
trainer.fit(model,
            train_dataloaders = train_dl)

trainer.fit(model, train_dl)

在例子中,使用来自 mng.bz/nr9a 的 DC 出租车数据集的样本简单说明了如何使用 PyTorch Lightning。在下一章中,你将看到如何通过简单地更改传递给 osds 的 URL 字符串来扩展到更大的数据集。

由于在训练过程中损失和指标值被记录到一个 CSV 文件中,一旦训练结束,你可以将这些值加载到一个 pandas DataFrame 中,并使用以下方式绘制结果,

import pandas as pd
metrics_df = pd.read_csv(f'logs/dctaxi/seed_{model.hparams.seed}/
➥                             metrics.csv')
ax = metrics_df.plot('step', 'train_rmse_step')

这应该输出一个类似于图 10.1 的图形。

10-01

图 10.1 在一个小样本上,微不足道的线性回归模型按预期收敛。

根据图 10.1,来自列表 10.1 的简单线性回归模型收敛到一致的损失值。要检查收敛的最后 25 步的损失值的详细信息,你可以再次利用 pandas DataFrame API,

ax = metrics_df.iloc[-25:].plot('step', 'train_rmse_step')
ax.plot(metrics_df.iloc[-25:]['step'],
        pt.full([25], metrics_df[-25:]['train_rmse_step'].mean())),

这绘制了图 10.2。

10-02

图 10.2 训练的最后 25 步收敛到大约 4.0 的 RMSE。

你可以确认在训练的最后 25 步左右,模型以大约 4.0 的平均 RMSE 收敛。即使对于微不足道的线性回归模型,这也不应该令人惊讶,因为本示例使用了一个小训练样本。

此时,引入一个 build 函数很有用,可以调用它来实例化、训练,以及稍后验证和测试模型。为了方便起见,以下是此版本模型的完整实现,其中包含了训练步骤的封装。

列表 10.3 基本的 PyTorch Lightning DC 出租车模型

import torch as pt
import pytorch_lightning as pl
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds

pt.set_default_dtype(pt.float64)

class DcTaxiModel(pl.LightningModule):
    def __init__(self, **kwargs):
        super().__init__()
        self.save_hyperparameters()

        pt.manual_seed(int(self.hparams.seed))

        self.layers = pt.nn.Linear(int(self.hparams.num_features), 1)

    def batchToXy(self, batch):
      batch = batch.squeeze_()
      X, y = batch[:, 1:], batch[:, 0]
      return X, y

    def forward(self, X):
      y_est = self.layers(X)
      return y_est.squeeze_()

    def training_step(self, batch, batch_idx):

        X, y = self.batchToXy(batch) #unpack batch into features and label

        y_est = self.forward(X)

        loss = pt.nn.functional.mse_loss(y_est, y)

        for k,v in {
          "train_mse": loss.item(),
          "train_rmse": loss.sqrt().item(),
        }.items():
          self.log(k, v, on_step=True,
                          on_epoch=True, prog_bar=True, logger=True)

        return loss

    def configure_optimizers(self):
        optimizers = {'Adam': pt.optim.AdamW,
                      'SGD': pt.optim.SGD}
        optimizer = optimizers[self.hparams.optimizer]

        return optimizer(self.layers.parameters(),
                            lr = float(self.hparams.lr))

def build(model):
  csvLog = CSVLogger(save_dir = "logs",
                    name = "dctaxi",
                    version = f"seed_{model.hparams.seed}"
                    )

  trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                              if pt.cuda.is_available() else 0,
    max_epochs = 1,
    limit_train_batches = int( model.hparams.max_batches ) \
                          if 'max_batches' in model.hparams else 1,
    progress_bar_refresh_rate = 20,
    weights_summary = None,
    log_every_n_steps = 1,
    logger = csvLog)

  train_ds = osds('https://raw.githubusercontent.com/osipov/smlbook/
➥                   master/train.csv',
                  batch_size = int(model.hparams.batch_size) )

  train_dl = DataLoader(train_ds,
                        pin_memory = True)

  trainer.fit(model,
              train_dataloaders = train_dl)

  return model, trainer

model = build(DcTaxiModel(**{
        "seed": "1686523060",
        "num_features": "8",
        "optimizer": "Adam",
        "lr": "0.03",
        "max_batches": "100",
        "batch_size": "100",
}))

10.1.2 启用已训练模型的测试和报告

本节描述了 PyTorch Lightning 模型的 test_step 方法以及如何使用该方法测试和报告已训练模型的指标。

一旦模型训练完成,Trainer 实例也可以用于报告模型在测试数据集上的损失和度量。但是,为了支持 PyTorch Lightning 中的测试,必须将 LightningModule 子类扩展为实现 test_step 方法。以下代码片段描述了 DcTaxiModel 的相应实现:

def test_step(self, batch, batch_idx):
    X, y = self.batchToXy(batch)

    with pt.no_grad():                                 ❶
        loss = pt.nn.functional.mse_loss(self.forward(X), y)

    for k,v in {
        "test_mse": loss.item(),                       ❷
        "test_rmse": loss.sqrt().item(),               ❸
    }.items():
        self.log(k, v, on_step=True, on_epoch=True,
                        prog_bar=True, logger=True)

❶ 在测试期间忽略梯度图以获得更好的性能。

❷ 使用 test_mse 而不是 train_mse . . .

❸ . . . 并在记录测试测量时使用 test_rmse 而不是 train_rmse。

PyTorch Lightning 的 test_step 不需要任何返回值;相反,代码应报告使用训练模型计算的指标。回顾第六章中的自动微分讨论,保持梯度的反向图会带来额外的性能开销。由于在模型测试(或验证)期间不需要模型梯度,因此在 pt.no_grad() 的上下文中调用 forward 和 mse_loss 方法,该上下文禁用了用于损失梯度计算的跟踪。

除了与重命名的记录损失和度量测量相关的轻微更改(例如,test_rmse 对比 train_rmse),test_step 记录的实现与 training_step 函数完全相同。

为了向 Trainer 实例引入配置更改以进行测试并创建测试数据的 DataLoader,需要修改 build 函数 ❶—❹:

def build(model, train_glob, test_glob):                            ❶
  csvLog = CSVLogger(save_dir = "logs",
                    name = "dctaxi",
                     version = f"seed_{model.hparams.seed}")

  trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                              if pt.cuda.is_available() else 0,
    max_epochs = 1,
    limit_train_batches = int( model.hparams.max_batches ) \
                          if 'max_batches' in model.hparams else 1,
    limit_test_batches = 1,                                         ❷
    log_every_n_steps = 1,
    logger = csvLog)

  train_ds = osds(train_glob,                                       ❸
                  batch_size = int(model.hparams.batch_size) )

  train_dl = DataLoader(train_ds,                                   ❹
                        pin_memory = True)

  trainer.fit(model,
              train_dataloaders = train_dl)

  test_ds = osds(test_glob, 
                  batch_size = int(model.hparams.batch_size) )

  test_dl = DataLoader(test_ds, 
                        pin_memory = True)

  trainer.test(model,                                               ❺
              test_dataloaders=test_dl)

  return model, trainer

❶ 通过 URL 通配符实例化 DataLoader 以用于训练和测试数据。

❷ 仅使用测试数据集一次来报告损失和度量。

❸ 使用 test_glob 实例化 train_ds . . .

❹ . . . 并创建 train_dl 实例。

❺ 使用 Trainer.test 方法测试并报告模型性能。

在使用更新后的模型和构建实现进行训练和测试之后

model = build(DcTaxiModel(**{
        "seed": "1686523060",
        "num_features": "8",
        "optimizer": "Adam",
        "lr": "0.03",
        "max_batches": "100",
        "batch_size": "100",}),
  train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',
  test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv')

你应该获得类似以下的测试结果:

-----------------------------------------------------------------------------
DATALOADER:0 TEST RESULTS
{'test_mse': 9.402312278747559,
 'test_mse_epoch': 9.402312278747559,
 'test_rmse': 3.066318988800049,
 'test_rmse_epoch': 3.066318988800049}
-----------------------------------------------------------------------------

10.1.3 启用模型训练期间的验证

本节说明了如何在 LightningModule 子类中使用 validation_step 方法来启用对 PyTorch 模型的验证支持。

当您修改实现以支持训练期间的重复验证步骤时,使用 PyTorch Lightning 的优势变得更加明显。例如,要将模型验证添加到 DcTaxiModel 实现中,只需引入 validation_step 方法:

def validation_step(self, batch, batch_idx):
    X, y = self.batchToXy(batch)

    with pt.no_grad():
        loss = pt.nn.functional.mse_loss(self.forward(X), y)

    for k,v in {
    "val_mse": loss.item(),
    "val_rmse": loss.sqrt().item(),
    }.items():
    self.log(k, v, on_step=True, on_epoch=True, prog_bar=True, logger=True)

    return loss

下面的代码描述了配置训练器实例以在固定大小的数据集上执行验证(而不是 k 折交叉验证)所需的剩余更改:

trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                            if pt.cuda.is_available() else 0,
    max_epochs = 1,
    limit_train_batches = int( model.hparams.max_batches ) \
                          if 'max_batches' in model.hparams else 1,
    limit_val_batches = 1,                         ❶
    num_sanity_val_steps = 1,                      ❷
    val_check_interval = min(20,                   ❸
                            int( model.hparams.max_batches ) ),
    limit_test_batches = 1,
    log_every_n_steps = 1,
    logger = csvLog,
    progress_bar_refresh_rate = 20,
    weights_summary = None,)

❶ 仅验证 1 批验证 DataLoader 数据。

❷ 在训练之前进行验证以确保验证数据集可用。

❸ 在每 20 次梯度下降的训练迭代(步骤)之后进行验证。

limit_val_batches 的作用类似于 limit_train_batches,指定用于验证的验证数据集中的批次数量。Trainer 中的 num_sanity_val_steps 参数控制了 PyTorch Lightning 的一个特性,该特性使用验证数据集来确保模型以及验证 DataLoader 被正确实例化并准备好进行训练。在本示例中,将 num_sanity_val_steps 的值设置为 1 执行单个验证步骤并报告相应的指标。val_check_interval 参数指定,在每 20 次训练迭代之后,PyTorch Lightning 应使用 limit_val_batches 参数指定的批次数进行验证。使用 val_check_interval 的 min 函数确保如果 max_batches 的超参数设置为小于 20,则在训练结束时执行验证。

列表 10.4 PyTorch Lightning DC 出租车线性回归模型

import torch as pt
import pytorch_lightning as pl
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds

pt.set_default_dtype(pt.float64)

class DcTaxiModel(pl.LightningModule):
    def __init__(self, **kwargs):
        super().__init__()
        self.save_hyperparameters()

        pt.manual_seed(int(self.hparams.seed))

        self.layers = pt.nn.Linear(int(self.hparams.num_features), 1)

    def batchToXy(self, batch):
      batch = batch.squeeze_()
      X, y = batch[:, 1:], batch[:, 0]
      return X, y

    def forward(self, X):
      y_est = self.layers(X)
      return y_est.squeeze_()

    def training_step(self, batch, batch_idx):

        X, y = self.batchToXy(batch)

        y_est = self.forward(X)

        loss = pt.nn.functional.mse_loss(y_est, y)

        for k,v in {
          "train_mse": loss.item(),
          "train_rmse": loss.sqrt().item(),
        }.items():
          self.log(k, v, on_step=True, on_epoch=True,
                          prog_bar=True, logger=True)

        return loss

    def validation_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)
      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
        "val_mse": loss.item(),
        "val_rmse": loss.sqrt().item(),
      }.items():
        self.log(k, v, on_step=True, on_epoch=True,
                        prog_bar=True, logger=True)

      return loss

    def test_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
          "test_mse": loss.item(),
          "test_rmse": loss.sqrt().item(),
      }.items():
          self.log(k, v, on_step=True, on_epoch=True,
                          prog_bar=True, logger=True)

    def configure_optimizers(self):
        optimizers = {'Adam': pt.optim.AdamW,
                      'SGD': pt.optim.SGD}
        optimizer = optimizers[self.hparams.optimizer]

        return optimizer(self.layers.parameters(),
                            lr = float(self.hparams.lr))

def build(model, train_glob, val_glob, test_glob):
  csvLog = CSVLogger(save_dir = "logs",
                    name = "dctaxi",
                     version = f"seed_{model.hparams.seed}")

  trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                              if pt.cuda.is_available() else 0,
    max_epochs = 1,
    limit_train_batches = int( model.hparams.max_batches ) \
                          if 'max_batches' in model.hparams else 1,
    limit_val_batches = 1,
    num_sanity_val_steps = 1,
    val_check_interval = min(20, int( model.hparams.max_batches ) ),
    limit_test_batches = 1,
    log_every_n_steps = 1,
    logger = csvLog,
    progress_bar_refresh_rate = 20,
    weights_summary = None,)

  train_dl = \
    DataLoader(osds(train_glob,
                    batch_size = int(model.hparams.batch_size) ),
               pin_memory = True)

  val_dl = \
    DataLoader(osds(val_glob,
                    batch_size = int(model.hparams.batch_size) ),
               pin_memory = True)

  trainer.fit(model,
              train_dataloaders = train_dl,
              val_dataloaders = val_dl)

  test_dl = \
    DataLoader(osds(test_glob,
                    batch_size = int(model.hparams.batch_size) ),
               pin_memory = True)

  trainer.test(model,
               dataloaders=test_dl)

  return model, trainer

您可以通过运行来训练、验证和测试整个模型

model, trainer = build(DcTaxiModel(**{
        "seed": "1686523060",
        "num_features": "8",
        "optimizer": "Adam",
        "lr": "0.03",
        "max_batches": "100",
        "batch_size": "100",}),
  train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',
  val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/valid.csv',
  test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv').

如果将来自 logs/dctaxi/version_1686523060 文件夹的生成日志加载为 pandas DataFrame,并使用以下代码绘制结果

import pandas as pd
metrics_df = \
  pd.read_csv(f'logs/dctaxi/seed_{model.hparams.seed}/metrics.csv')

ax = (metrics_df[['step', 'train_rmse_step']][20:]
      .dropna()
      .plot('step', 'train_rmse_step'))

ax = (metrics_df[['step', 'val_rmse_step']][20:]
    .fillna(method='ffill')['val_rmse_step']
    .plot(ax = ax))

您应该观察到类似于图 10.3 的图形。由于 val_check_interval 参数设置为 20,因此数据帧中 val_rmse_step 列的大多数值都缺失。fillna(method='ffill') 调用向前填充缺失值,例如,根据步骤 80 的验证 RMSE 设置步骤 81、82 等的缺失值。

10-03

图 10.3 尽管测试性能合理,但验证 RMSE 信号表明过拟合。

如图 10.3 所示,对验证数据集的平淡性能表明模型可能在训练数据集上过拟合。在将代码投入生产之前,应重构模型实现以使其更具一般性,并且不那么依赖于训练数据集的记忆。这意味着为了前进,您需要具有更全面的实验和超参数优化支持的模型开发方法。

总结

  • 采用 PyTorch Lightning 框架可以帮助您重构机器学习实现,减少附带的样板代码比例,并专注于模型特定开发。

  • 在基于 PyTorch Lightning 的机器学习模型实现中,您可以逐步添加对模型训练、验证和测试的支持,以及可插拔的特性,例如用于分析的日志框架。

  • PyTorch Lightning 中的 CSVLogger 将模型训练、验证和测试结果保存到 CSV 文件中,您可以使用 pandas 进行分析。

第十一章:超参数优化

本章涵盖内容

  • 通过超参数优化理解机器学习

  • 引入超参数优化到 DC taxi 模型

  • 可视化超参数优化实验结果

在第十章,您将 PyTorch Lightning 框架与您的 DC taxi 模型集成,提取出模板化工程代码,并为其提供超参数优化支持的途径。在本章中,您将采用一种超参数优化框架 Optuna,以进一步超越试错法来选择您的机器学习超参数值。您将训练一系列基于 Optuna 的 Tree-Structured Parzen Estimator(TPE)选择的超参数值的 DC taxi 模型实例,该模型适配了您机器学习系统中的超参数的高斯混合模型(GMM)。使用各种 Optuna 可视化图比较这些模型实例的性能。

11.1 使用 Optuna 进行超参数优化

本小节介绍了 Optuna 用于超参数优化(HPO)以及如何为 DC taxi 车费估算模型添加 HPO 支持。

Optuna 是适用于 PyTorch 的众多开源 HPO 框架之一。与其他 HPO 框架一样,Optuna 包括了一系列无梯度的优化算法,从随机搜索、贝叶斯优化到 TPE 不等。Optuna 使用trial的概念来描述计算损失函数值的过程的实例,例如基于一组超参数值计算 DcTaxiModel 模型的测试损失的实验实例。

在 Optuna 中,一个试验必须生成一个你希望最小化(或最大化)的损失函数的值。计算损失值的过程的实现通常在一个目标函数中进行捕捉。请注意,为了仅解释与理解 Optuna API 相关的部分,这个实现是有意不完整的 ❸。

代码清单 11.1 DC taxi HPO 的起始点与目标函数

def objective(trial):                          ❶
  hparams = {
    "seed": trial.suggest_int('seed',
                0,
                pt.iinfo(pt.int32).max - 1),   ❷
    "num_features": "8",
    ...                                        ❸
  }
  model, trainer = build(DcTaxiModel(**hparams),
    train_glob = "https://raw.githubusercontent.com/osipov/smlbook/
➥     master/train.csv",
    val_glob = "https://raw.githubusercontent.com/osipov/smlbook/
➥     master/valid.csv")

  return (trainer
          .callback_metrics['train_val_rmse']
          .item())                             ❹

❶ 目标函数是 Optuna 试验的标准接口。

❷ suggest_int 返回 Optuna 选择的整数,以优化目标函数。

❸ hparams 的实现在本章后面完成。

❹ Optuna 根据每次实验的结果建议调整超参数,以减小 train_val_rmse 的值。

注意,在 hparams 字典中,有一个从 Optuna 请求的整数超参数值 seed。suggest_int 方法是 Optuna trial API 中的几种方法之一,用于获取超参数的值。(试验接口中的其他可用方法在此处描述:mng.bz/v4B7。)在本例中,suggest_int('seed', 0, pt.iinfo(pt.int32).max - 1)方法调用指定 Optuna 应该为伪随机数生成器推荐从 0 到 32 位整数最大正值之前的值。

请记住,DcTaxiModel 的实现取决于其他超参数值,包括 optimizer、bins、lr(学习率)、max_batches 等。为了在 DcTaxiModel 的实现中支持这些超参数,需要扩展 hparams 字典以符合其他超参数值的 Optuna 规范。由于这些超参数的取样策略比 suggest_int 更复杂,下一节将解释一些基本概念。

11.1.1 理解 loguniform 超参数

本节提出了在训练过程中使用对数均匀超参数的合理性,并提出了应将哪些超参数设置为 DC 出租车模型的对数均匀形式。

对于许多连续的超参数,比如学习率,使用 Optuna 试验 API 的 suggest_loguniform 方法非常方便,该方法通过超参数的上限和下限值来调用。由于在 Optuna 试验 API 中,有几个连续超参数值的选项,因此有必要澄清为什么学习率应该使用 Optuna suggest_loguniform 而不是 suggest_uniform。一般来说,loguniform 更适合于搜索上限比下限大一个数量级以上的范围。这种合理性与十进制数系统的工作原理有关,下面的例子进行了解释:

x = pt.linspace(1, 1_000, 300)
#prints the 1s, 10s, 100s
print(pt.count_nonzero(x[(x > 0) & (x < 10) ]).item(),
    pt.count_nonzero(x[(x > 10) & (x < 100) ]).item(),
    pt.count_nonzero(x[(x > 100) & (x < 1_000) ]).item())

输出结果为

3 27 269

由于 x 包含了从 0 到 1,000(三个数量级)的 300 个浮点值,因此 print 语句输出了在每个数量级范围内出现的值的计数(即,从 0 到 10,从 10 到 100,从 100 到 1,000)。在这个例子中,在 0 到 10 的范围内的 x 值的数量与在 100 到 1,000 范围内的值的数量之间大约相差 100 倍,或者更确切地说是 3 与 269。

一般来说,从一个线性范围中以均匀概率取样,可以预期从较大范围中获得的样本平均要多 10N倍。log 函数的应用消除了由于十进制数系统导致的这种无意的偏差,下面的例子证明了这一点:

y = pt.logspace(pt.log10(pt.tensor(1)),
                pt.log10(pt.tensor(1_000)),  300)

#prints the 1s, 10s, 100s
print(pt.count_nonzero(y[(y > 0) & (y < 10) ]).item(),
    pt.count_nonzero(y[(y > 10) & (y < 100) ]).item(),
    pt.count_nonzero(y[(y > 100) & (y < 1_000) ]).item())

输出结果为

100 100 99

由于使用了以对数 10 为底的比例尺,示例中展示了约 300 个样本在整个从 1 到 1,000 的范围内大致相等地分布。

在对离散整数值进行对数尺度范围上的超参数值取样的概念,适用于连续值一样。例如,max_batches 超参数可以使用 Optuna Trial API 函数调用 suggest_int('max_batches', 40, 4000, log = True)进行初始化,因为调用中的下限和上限值跨越了一个数量级以上的范围。

11.1.2 使用分类和对数均匀超参数

本节基于你学到的关于对数均匀超参数的知识,解释了如何通过 Optuna 从对数均匀尺度中采样的超参数来初始化优化器学习率。

优化器和相应的学习率是你可能想要包含在 HPO 试验中的其他超参数之一。由于优化器学习率最好用连续值表示,跨越几个数量级的范围,因此应该在试验中使用 suggest_loguniform 方法进行优化。这可以如下实现,对于候选学习率在范围 [0.001, 0.1) 中的值:

hparams = {
...
"lr": trial.suggest_loguniform('lr', 0.001, 0.1),    ❶
...
}

❶ 使用来自对数均匀范围 [0.001, 0.1) 的优化器学习率。

由于 DcTaxiModel 的 configure_optimizers 方法已经包含了对随机梯度下降和 Adam 的支持,因此你可以让 Optuna 在 objective 方法的实现中建议这些值的选择(SGD 或 Adam)。这需要在试验对象的 suggest_categorical 方法中使用,如下所示:

hparams = {
...
"optimizer": \
  trial.suggest_categorical('optimizer',
                            ['Adam', 'SGD']),    ❶
...
}

❶ 在每个 HPO 试验中,使用 Adam 和 SGD 作为优化器选项。

试验 API 的参数是在程序运行时计算的,这意味着你可以使用标准的 Python 特性来更加表达超参数值的规范。例如,batch_size 超参数可以使用一个整数列表来指定,其中整数是动态生成的,解析为从 2¹⁶ 到 2²¹ 的二次幂,换句话说,值为 [65536, 131072, 262144, 524288, 1048576, 2097152]

hparams = {
  ...
  "batch_size": \
    trial.suggest_categorical('batch_size',
                  [2 ** i for i in range(16, 22)]),    ❶
  ...
}

❶ 在使用 suggest_categorical 方法之前,预先计算一个 Python 列表的超参数值。

展示了一个更有趣的 suggest_categorical 应用,实现了 num_hidden_neurons 超参数的规范化。

列表 11.2 Optuna 试验以发现神经网络架构

hparams = {
  ...
  "num_hidden_neurons": \                                            ❶
    [trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons",
                              [7, 11, 13, 19, 23]) for layer in \
                              range(trial.suggest_categorical('num_layers',
                                                       [11, 13, 17, 19]))],
  ...
}

❶ 将网络架构指定为一个超参数(例如,[5, 11, 7])。

DcTaxiModel 可以使用隐藏层中神经元数量的字符串表示来构建其模型层。例如,一个字符串表示 [3, 5, 7, 8] 可以表示四个隐藏层,其中第一层有三个神经元,第二层有五个,依此类推。

这种类型的规范可以通过一系列的 suggest_categorical 调用来实现为 Optuna 超参数。首先,Optuna 为隐藏层的总数(num_layers)分配一个值,该值基于列表中的可能隐藏层数量 [11, 13, 17, 19],然后,根据 Optuna 为隐藏层数量(num_layers)选择的值,通过 for 运算符多次调用下一个 suggest_categorical 调用,每个隐藏层调用一次。每个调用都会更改对 layer 变量的赋值,并实例化一个新的超参数,例如架构中的第一层的 num_hidden_layer_0_neurons,第二层的 num_hidden_layer_1_neurons,依此类推,具体取决于超参数层数(num_layers)的值。为每个超参数(描述每个隐藏层的神经元数量)分配的值来自不同的 suggest_categorical 列表,指定为 [7, 11, 13, 19, 23]。最终,num_hidden_neurons 解析为一个 Python 列表,其中包含 Optuna 提出的隐藏层和神经元的配置。

将这些超参数与目标函数的整个实现结合起来,结果如下:

def objective(trial):
  hparams = {
    "seed": trial.suggest_int('seed', 0, pt.iinfo(pt.int32).max - 1),
    "num_features": "8",
    "optimizer": trial.suggest_categorical('optimizer', ['Adam', 'SGD']),
    "lr": trial.suggest_loguniform('lr', 0.009, 0.07),
    "num_hidden_neurons": \
      str([trial
➥           .suggest_categorical(f"num_hidden_layer_{layer}_neurons",
            [7, 11]) for layer in \
              range(trial.suggest_categorical('num_layers', [2, 3]))]),
    "batch_size": trial.suggest_int('batch_size', 30, 50, log = True),
    "max_batches": trial.suggest_int('max_batches', 30, 50, log = True)
    "batch_norm_linear_layers": \
      str(trial.suggest_int('batch_norm_linear_layers', 0, 1)),
  }
  model, trainer = build(DcTaxiModel(**hparams),
    train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥     master/train.csv',
    val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥     master/valid.csv')

  return trainer.callback_metrics['train_val_rmse'].item()

11.2 神经网络层配置作为超参数

本节介绍如何扩展 DC 出租车模型以支持深度学习模型,每层隐藏层和每层神经元数都是任意的。该部分还描述了如何使用超参数值来指定此配置。

不是随意选择神经网络参数的配置(例如,层数或每层的神经元数),而是将配置视为要优化的超参数是有价值的。尽管 PyTorch 和 PyTorch Lightning 都没有直接的技术来优化神经网络配置,但是您可以轻松实现一个实用方法来将网络的隐藏层的字符串表示转换为与字符串镜像的 nn.Module 实例集合。例如,字符串 [3, 5, 8] 可以表示具有三个隐藏层的神经网络,第一层有三个神经元,第二层有五个神经元,第三层有八个神经元。以下代码片段中显示的 build_hidden_layers 实用方法实现了从字符串到 torch.nn.Linear 实例的转换,以及任意激活函数:

def build_hidden_layers(self, num_hidden_neurons, activation):
  linear_layers = \                                            ❶
    [ pt.nn.Linear(num_hidden_neurons[i],
      num_hidden_neurons[i+1]) for i in range(len(num_hidden_neurons) - 1) ]

  classes = \                                                  ❷
    [activation.__class__] * len(num_hidden_neurons)

  activation_instances = \                                     ❸
    list(map(lambda x: x(), classes))

  hidden_layer_activation_tuples = \                           ❹
    list(zip(linear_layers, activation_instances))

  hidden_layers = \                                            ❺
    [i for sublist in hidden_layer_activation_tuples for i in sublist]

  return hidden_layers

❶ 创建一个线性(前馈)层的 Python 列表...

❷ . . . 并创建一个匹配长度的激活类列表。

❸ 将激活类转换为激活实例。

❹ 将线性层与激活函数实例进行压缩。

❺ 将结果作为平面 Python 列表返回。

在 DcTaxiModel 的实现中,添加了 build_hidden_layers 实用程序方法,现在您可以修改 init 方法以使用 build_hidden_layers 如下:

import json
import torch as pt
import pytorch_lightning as pl
class DcTaxiModel(pl.LightningModule):
  def __init__(self, hparams = None):
    super().__init__()
    self.hparams = hparams

    pt.manual_seed(self.hparams['seed'])

    num_hidden_neurons = \                           ❶
      json.loads(self.hparams.num_hidden_neurons)

    self.layers = \                                  ❷
      pt.nn.Sequential(
        pt.nn.Linear(int(self.hparams.num_features), num_hidden_neurons[0]),
        pt.nn.ReLU(),
        *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
        pt.nn.Linear(num_hidden_neurons[-1], 1)
    )

model = build(DcTaxiModel(**{
        "seed": "1686523060",
        "num_features": "8",
        "num_hidden_neurons": "[3, 5, 8]",           ❸
        "optimizer": "Adam",
        "lr": "0.03",
        "max_batches": "100",
        "batch_size": "100",}),
  train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',
  val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥               master/valid.csv',
  test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥               master/train.csv')

❶ 创建一个隐藏层神经元的列表(例如[3, 5, 8])。

❷ 对模型使用前馈隐藏层的序列。

❸ 使用字符串格式指定隐藏层。

在示例中,使用 json.loads 方法将隐藏层字符串表示(例如[3, 5, 8])转换为 Python 整数值列表。此外,虽然 self.layers 参考神经网络模型仍然具有四个输入特征和一个输出值,但隐藏值是通过将 build_hidden_layers 方法中的 torch.nn.Linear 实例的列表展开为作为 torch.nn.Sequential 初始化程序传递的单个对象来指定的。

11.3 使用批量归一化超参数进行实验

批量归一化是一种广泛使用的技术,可以增加梯度下降收敛率。²尽管批量归一化被广泛使用,但收集证明它可以帮助创建更有效的 DC 出租车模型的数据仍然很有用。

由于批量归一化特性应根据布尔型 HPO 标志启用或禁用,因此引入一种自动重连 DC 出租车模型的图层以利用此特性的方法是很有用的。以下 batch_norm_linear 方法在模型中的每个 torch.nn.Linear 层之前自动插入 PyTorch torch.nn.BatchNorm1d 类实例。以下实现还正确配置每个 BatchNorm1d 实例,使其具有正确数量的输入,与相应的 Linear 层的输入数相匹配,应遵循 BatchNorm1d:

def batch_norm_linear(self, layers):
  idx_linear = \                                                   ❶
    list(filter(lambda x: type(x) is int,
      [idx if issubclass(layer.__class__, pt.nn.Linear) else None \
        for idx, layer in enumerate(layers)]))

  idx_linear.append(sys.maxsize)                                   ❷

  layer_lists = \                                                  ❸
    [list(iter(layers[s:e])) \
      for s, e in zip(idx_linear[:-1], idx_linear[1:])]

  batch_norm_layers = \                                            ❹
    [pt.nn.BatchNorm1d(layer[0].in_features) for layer in layer_lists]

  batch_normed_layer_lists = \                                     ❺
    [ [bn, *layers] for bn, layers in \
      list(zip(batch_norm_layers, layer_lists)) ]

  result = \                                                       ❻
    pt.nn.Sequential(*[layer for nested_layer in \
      batch_normed_layer_lists for layer in nested_layer ])

  return result

❶ 创建一个列表,其中包含模型中线性类的位置索引。

❷ 使用最大整数值作为列表的最后一个元素,表示无限值。

❸ 创建带有 s 作为每个 Linear 的索引和 e 作为每个 Linear 之前的索引的子列表。

❹ 使用与对应的 Linear 输入匹配的输入实例化 BatchNorm1d。

❺ 在对应的 Linear 之前插入 BatchNorm1d 实例。

❻ 将所有的 BatchNorm1d 和 Linear 层序列打包成 Sequential。

一旦将 batch_norm_linear 方法添加到 DcTaxiModel 类中,就应该修改该类的 init 方法(列表 11.3 ❶),以根据 batch_norm_linear_layers 超参数的值应用批量归一化。

列表 11.3 使用可选批量归一化

from distutils.util import strtobool

def __init__(self, **kwargs):
  super().__init__()
  self.save_hyperparameters()

  self.step = 0
  self.start_ts = time.perf_counter()
  self.train_val_rmse = pt.tensor(0.)

  pt.manual_seed(int(self.hparams.seed))
  #create a list of hidden layer neurons, e.g. [3, 5, 8]
  num_hidden_neurons = json.loads(self.hparams.num_hidden_neurons)

  self.layers = pt.nn.Sequential(
      pt.nn.Linear(int(self.hparams.num_features), num_hidden_neurons[0]),
      pt.nn.ReLU(),
      *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
      pt.nn.Linear(num_hidden_neurons[-1], 1)
  )

  if 'batch_norm_linear_layers' in self.hparams \          ❶
    and strtobool(self.hparams.batch_norm_linear_layers):
    self.layers = self.batch_norm_linear(self.layers)

❶ 如果 batch_norm_linear_layers 为 True,则对 Linear 层进行批量归一化。

使用批量归一化后,DcTaxiModel 和匹配的构建方法已准备好用于 HPO。

列表 11.4 DcTaxiModel 实现支持 HPO

import sys
import json
import time
import torch as pt
import pytorch_lightning as pl
from distutils.util import strtobool
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds
pt.set_default_dtype(pt.float64)

class DcTaxiModel(pl.LightningModule):
    def __init__(self, **kwargs):
      super().__init__()
      self.save_hyperparameters()

      self.step = 0
      self.start_ts = time.perf_counter()
      self.train_val_rmse = pt.tensor(0.)

      pt.manual_seed(int(self.hparams.seed))
      #create a list of hidden layer neurons, e.g. [3, 5, 8]
      num_hidden_neurons = json.loads(self.hparams.num_hidden_neurons)

      self.layers = \
        pt.nn.Sequential(
          pt.nn.Linear(int(self.hparams.num_features),
                              num_hidden_neurons[0]),
          pt.nn.ReLU(),
          *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
          pt.nn.Linear(num_hidden_neurons[-1], 1)
        )

      if 'batch_norm_linear_layers' in self.hparams \
        and strtobool(self.hparams.batch_norm_linear_layers):
        self.layers = self.batch_norm_linear(self.layers)

    def build_hidden_layers(self, num_hidden_neurons, activation):
      linear_layers = [ pt.nn.Linear(num_hidden_neurons[i],
          num_hidden_neurons[i+1]) for i in \
            range(len(num_hidden_neurons) - 1) ]

      classes = [activation.__class__] * len(num_hidden_neurons)

      activation_instances = list(map(lambda x: x(), classes))

      hidden_layer_activation_tuples = \
        list(zip(linear_layers, activation_instances))

      hidden_layers = \
        [i for sublist in hidden_layer_activation_tuples for i in sublist]

      return hidden_layers

    def batch_norm_linear(self, layers):
      idx_linear = \
        list(filter(lambda x: type(x) is int,
        [idx if issubclass(layer.__class__, pt.nn.Linear) else None \
          for idx, layer in enumerate(layers)]))

      idx_linear.append(sys.maxsize)
      layer_lists = \
        [list(iter(layers[s:e])) \
          for s, e in zip(idx_linear[:-1], idx_linear[1:])]
      batch_norm_layers = \
        [pt.nn.BatchNorm1d(layer[0].in_features) for layer in layer_lists]
      batch_normed_layer_lists = \
        [ [bn, *layers] for bn, layers in \
          list(zip(batch_norm_layers, layer_lists)) ]

      return \
        pt.nn.Sequential(*[layer \
          for nested_layer in batch_normed_layer_lists \
          for layer in nested_layer ])

    def batchToXy(self, batch):
      batch = batch.squeeze_()
      X, y = batch[:, 1:], batch[:, 0]
      return X, y

    def forward(self, X):
      y_est = self.layers(X)
      return y_est.squeeze_()

    def training_step(self, batch, batch_idx):
        self.step += 1

        X, y = self.batchToXy(batch) #unpack batch into features and label

        y_est = self.forward(X)

        loss = pt.nn.functional.mse_loss(y_est, y)

        for k,v in {

          "train_mse": loss.item(),
          "train_rmse": loss.sqrt().item(),
          "train_steps_per_sec": \
            self.step / (time.perf_counter() - self.start_ts),

        }.items():
          self.log(k, v, on_step=True, on_epoch=True,
                          prog_bar=True, logger=True)

        self.train_val_rmse = loss.sqrt().item()

        return loss

    def validation_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
        "val_mse": loss.item(),
        "val_rmse": loss.sqrt().item(),
        "train_val_rmse": self.train_val_rmse + loss.sqrt().item(),
      }.items():
        self.log(k, v, on_step=True, on_epoch=True,
                        prog_bar=True, logger=True)
      return loss

    def test_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
          "test_mse": loss.item(),
          "test_rmse": loss.sqrt().item(),
      }.items():
          self.log(k, v, on_step=True, on_epoch=True,
                          prog_bar=True, logger=True)

    def configure_optimizers(self):
        optimizers = {'Adam': pt.optim.AdamW,
                      'SGD': pt.optim.SGD}
        optimizer = optimizers[self.hparams.optimizer]

        return optimizer(self.layers.parameters(),
                            lr = float(self.hparams.lr))

def build(model, train_glob, val_glob, test_glob = None):
  csvLog = CSVLogger(save_dir = "logs",
                    name = "dctaxi",
                    version = f"seed_{model.hparams.seed}")

  trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                                if pt.cuda.is_available() else 0,
    max_epochs = 1,
    limit_train_batches = int( model.hparams.max_batches ) \
                                if 'max_batches' in model.hparams else 1,
    limit_val_batches = 1,
    num_sanity_val_steps = 1,
    val_check_interval = min(20, int( model.hparams.max_batches ) ),
    limit_test_batches = 1,
    log_every_n_steps = 1,
    logger = csvLog,
    gradient_clip_val=0.5,
    progress_bar_refresh_rate = 0,
    weights_summary = None,)

  train_dl = \
    DataLoader(osds(train_glob,
                    batch_size = int(model.hparams.batch_size) ),
               pin_memory = True)

  val_dl = \
    DataLoader(osds(val_glob,
                    batch_size = int(model.hparams.batch_size) ),
               pin_memory = True)
  trainer.fit(model,
              train_dataloaders = train_dl,
              val_dataloaders = val_dl)

  if test_glob is not None:
    test_dl = \
      DataLoader(osds(test_glob,
                      batch_size = int(model.hparams.batch_size) ),
                pin_memory = True)

    trainer.test(model,
                dataloaders=test_dl)

  return model, trainer

model, trainer = build(DcTaxiModel(**{
        "seed": "1686523060",
        "num_features": "8",
        "num_hidden_neurons": "[3, 5, 8]",
        "batch_norm_linear_layers": "1",
        "optimizer": "Adam",
        "lr": "0.03",
        "max_batches": "100",
        "batch_size": "100",}),

  train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',
  val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/valid.csv',
  test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv')

11.3.1 使用 Optuna 研究进行超参数优化

本节介绍了 Optuna 研究的概念,描述了它与 Optuna 试验的关系,并帮助您使用研究实例来运行和分析 HPO 实现中的一组试验。

在 Optuna 中,目标函数负责执行单个试验,包括从 Optuna 中检索超参数值、训练模型,然后根据验证损失评估训练模型的步骤。因此,每个试验仅向 Optuna HPO 算法返回单个评估指标,以便决定下一组建议的超参数值用于下一个试验。典型的 HPO 过程涉及几十甚至几百次试验;因此,有能力组织试验、比较其结果并分析试验中涉及的超参数是很重要的。在 Optuna 中,研究扮演着相关试验的容器角色,并提供有关试验结果和相关超参数的表格数据以及可视化数据。

如图 11.5 所示,通过一个目标函数,一个研究由优化方向(例如,最小化或最大化函数)和采样器来定义,采样器基于 Optuna 支持的许多 HPO 算法和框架之一实例化。用于初始化研究的种子(图 11.5 ❷)与用于初始化 PyTorch 和 NumPy 的种子值不同。在使用 HPO 时,此种子可用于创建下游随机数生成器的种子值,包括 Python 自己的随机数生成器。尽管 HPO 种子值从优化 DcTaxiModel 机器学习性能的角度来看毫无用处,但它确实具有确保 HPO 试验的可重现性的重要目的。

列表 11.4 中显示了 DcTaxiModel 的整个 HPO 实现。

列表 11.5 用于执行 HPO 的 Optuna 研究

def objective(trial):
  hparams = {
    "seed": trial.suggest_int('seed', 0, pt.iinfo(pt.int32).max - 1),

    "num_features": "8",

    "batch_norm_linear_layers": \
      str(trial.suggest_int('batch_norm_linear_layers', 0, 1)),

    "optimizer": trial.suggest_categorical('optimizer', ['Adam', 'SGD']),

    "lr": trial.suggest_loguniform('lr', 0.009, 0.07),

    "num_hidden_neurons": \
      str([trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons",
            [7, 11]) for layer in \
              range(trial.suggest_categorical('num_layers', [2, 3]))]),

    "batch_size": trial.suggest_int('batch_size', 30, 50, log = True),

    "max_batches": trial.suggest_int('max_batches', 30, 50, log = True)
  }
  model, trainer = build(DcTaxiModel(**hparams),
    train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                   master/train.csv',
    val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                   master/valid.csv')

  return trainer.callback_metrics['train_val_rmse'].item()

import optuna
from optuna.samplers import TPESampler                       ❶
study = \
  optuna.create_study(direction = 'minimize',                ❷
                      sampler = TPESampler( seed = 42 ),)

study.optimize(objective, n_trials = 100)                    ❸

❶ 配置研究以最小化 DcTaxiModel 的 MSE 损失。

❷ 使用 TPE 算法进行 HPO。

❸ 使用 100 次试验开始 HPO。

在执行列表 11.5 中的代码后,study.optimize 方法完成了 100 次 HPO 试验。使用以下方式可获得各个试验的详细信息

study_df = study.trials_dataframe().sort_values(by='value',
                                                ascending = True)
study_df[:5][['number', 'value', 'params_seed']],

应返回一个类似以下值的 pandas 数据帧:

number value params_seed
96 2.390541 1372300804
56 7.403345 1017301131
71 9.006614 939699871
74 9.139935 973536326
94 9.817746 1075268021

其中,数字列指定由 Optuna 建议的试验的索引,值是由目标方法中的 trainer.callback_ metrics['train_val_rmse'].item() 返回的损失函数的相应值,params_seed 是用于初始化 DcTaxiModel 的模型参数(权重)的种子值。

11.3.2 在 Optuna 中可视化 HPO 研究

本节介绍了本章中使用三种不同 Optuna 可视化执行的 HPO 研究,并比较了这些可视化图在 HPO 方面的相关性。

完成的研究实例也可以使用 Optuna 可视化包进行可视化。虽然全面概述 Optuna 中各种可视化的范围超出了本书的范围,但我发现自己在一系列机器学习模型中一直重复使用三个可视化图。这些可视化图将在本节的其余部分按重要性下降的顺序进行解释。

超参数重要性图揭示了关于超参数对目标函数相对影响的惊人信息。在列表中包含种子超参数特别有用,以评估某些超参数是否比仅用于模型初始化的随机变量具有更多或更少的重要性。比随机种子更重要的超参数值得进一步研究,而比随机变量不重要的超参数应该降低优先级。

要创建重要性图,您可以使用

optuna.visualization.plot_param_importances(study)

这应该会生成类似于图 11.1 的条形图。

11-01

图 11.1 重要性图有助于指导后续 HPO 迭代。

一旦您确定了要更详细探讨的一组超参数,下一步就是在平行坐标图上绘制它们。您可以使用以下命令实例化此图。

optuna.visualization.plot_parallel_coordinate(study,
    params=["lr", "batch_size", "num_hidden_layer_0_neurons"])

这会绘制学习率(lr)、批量大小和 num_hidden_layer_0_neurons 超参数之间的关系。请注意,在图 11.2 中,线条代表各个试验配置,颜色较深的线条对应于目标函数值较低的试验。因此,通过某个超参数的一系列较深线条穿过一定区间,表明该超参数区间值得更仔细检查,可能需要进行另一次 HPO 迭代。

11-02

图 11.2 平行坐标图有助于确定超参数值的影响区间。

到目前为止描述的图中,轮廓图在生成有关研究结果的见解方面排在最后。由于轮廓图仅限于可视化超参数值对的图形,您会发现自己生成多个轮廓图,通常基于从重要性图或平行坐标图中选择的超参数。例如,要绘制批量大小、学习率和目标函数之间的关系,您可以运行

optuna.visualization.plot_contour(study, params=["batch_size", "lr"])

这应该生成类似于图 11.3 的图形。

11-03

图 11.3 轮廓图有助于分析与损失函数相关的超参数对。

摘要

  • Optuna 是一个超参数优化框架,具有与本机 Python 集成,可支持机器学习模型实验中的复杂超参数配置。

  • 当使用 HPO 用于跨量级的超参数范围时,采用对数均匀采样是有用的,以确保样本在范围内均匀分布而不是倾斜分布。

  • 在执行 HPO 试验之后,Optuna 的可视化功能有助于分析试验结果和相关的超参数。

^(1.)无梯度算法不需要计算损失(或任何目标)函数的梯度来优化函数参数。换句话说,无梯度的超参数优化可以在目标函数没有可计算梯度的情况下进行优化。

^(2.)最初广泛引用的论文《Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift》介绍了批归一化,并可从arxiv.org/abs/1502.03167获取。

第十二章:机器学习管道

本章包括

  • 了解具有实验管理和超参数优化的机器学习管道

  • 为了减少样板代码,为 DC 出租车模型实现 Docker 容器

  • 部署机器学习管道以训练模型

到目前为止,你已经学习了机器学习的各个独立阶段或步骤。一次只专注于机器学习的一个步骤有助于集中精力处理更可管理的工作范围。然而,要部署一个生产机器学习系统,有必要将这些步骤集成到一个单一的管道中:一个步骤的输出流入到管道后续步骤的输入中。此外,管道应该足够灵活,以便启用超参数优化(HPO)过程来管理并对管道各阶段执行的具体任务进行实验。

在本章中,您将了解到用于集成机器学习管道、部署到 AWS 并使用实验管理和超参数优化训练 DC 出租车车费估算机器学习模型的概念和工具。

12.1 描述机器学习管道

本节介绍了解释本章描述的机器学习管道实现所需的核心概念。

为了澄清本章描述的机器学习管道的范围,有助于从整个管道的输入和输出描述开始。在输入方面,管道期望的是从探索性数据分析(EDA)和数据质量(数据清理)过程产生的数据集。机器学习管道的输出是一个或多个训练好的机器学习模型,这意味着管道的范围不包括将模型部署到生产环境的步骤。由于管道的输入和输出要求人机交互(EDA 和数据质量)或可重复自动化(模型部署),它们都不在 HPO 的范围内。

要了解机器学习管道所需的期望特性,请参考图 12.1。

12-01

图 12.1 一个统一的机器学习管道可以在每个阶段进行超参数优化。

在图中,数据准备、特征工程和机器学习模型训练阶段由 HPO 管理。使用由 HPO 管理的阶段可能会导致关于是否

  • 在数据准备阶段,具有缺失数值特征的训练示例将从训练数据集中删除或更新为将缺失值替换为特征的预期(均值)值

  • 在特征工程阶段,数值位置特征(如纬度或经度坐标)将通过分箱转换为具有 64 或 128 个不同值的分类特征

  • 在机器学习训练阶段,模型使用随机梯度下降(SGD)或 Adam 优化器进行训练

尽管实施图 12.1 中的管道可能看起来很复杂,但通过使用一系列 PyTorch 和配套框架,您将能够在本节结束时部署它。本节中管道的实施依赖于以下技术:

  • MLFlow — 用于开源实验管理

  • Optuna — 用于超参数优化

  • Docker — 用于管道组件打包和可重复执行

  • PyTorch Lightning — 用于 PyTorch 机器学习模型训练和验证

  • Kaen — 用于跨 AWS 和其他公共云提供商的管道供应和管理

在继续之前,总结一下将更详细地描述管道的 HPO 方面的关键概念是有帮助的。图 12.2 中的图表澄清了管道、HPO 和相关概念之间的关系。像 MLFlow 这样的实验管理平台(其他示例包括 Weights & Biases、Comet.ML 和 Neptune.AI)存储和管理实验实例,以便每个实例对应于不同的机器学习管道。例如,实验管理平台可以为实现训练 DC 出租车票价估算模型的机器学习管道存储一个实验实例,并为用于在线聊天机器人的自然语言处理模型训练的机器学习管道存储一个不同的实验实例。实验实例彼此隔离,但由单个实验管理平台管理。

12-02

图 12.2 实验管理器根据 HPO 设置控制管道执行(作业)实例。

每个实验实例使用 父运行 作为一个或多个机器学习管道执行(子运行)的集合。父运行配置为应用于多个管道执行的设置,例如 HPO 引擎(如 Optuna)使用的伪随机数种子的值。父运行还指定应执行以完成父运行的子运行(机器学习管道执行)的总数。由于每个机器学习管道执行还对应于一组唯一的超参数键/值对的组合,父运行指定的子运行数还指定了 HPO 引擎应生成的 HPO 试验(值集)的总数,以完成父运行。

机器学习管道代码与实验管理、超参数优化和机器学习模型训练的服务一起部署为 Docker 容器,在云提供商的虚拟专用云(VPC)网络中相互连接。该部署如图 12.3 所示。

12-03

图 12.3 具有 HPO 的机器学习管道部署为包含至少一个管理节点和一个工作节点以及可选管理节点和工作节点的一组 Docker 容器。

如图所示,为了部署具有 HPO 的机器学习管道,至少需要两个 Docker 容器在虚拟专用云网络上连接,部署中至少有一个管理器和至少一个工作节点。管理节点托管具有

  • 实验管理服务(例如 MLFlow)

  • HPO 引擎(例如 Optuna)作为与实验管理集成的服务运行(例如 Kaen 的 BaseOptunaService)

  • 实验管理用户界面

  • 工作节点管理服务,用于在工作节点上安排和编排机器学习管道子运行

工作节点托管具有机器学习模型(例如 PyTorch 代码)的 Docker 容器,以及描述如何根据超参数(例如 PyTorch Lightning 代码)训练、验证和测试机器学习模型的代码。

请注意,管理节点和工作节点的生命周期与节点上 Docker 容器执行的生命周期不同。这意味着相同的节点可以托管多个容器实例的执行和多个机器学习管道运行,而无需进行配置或取消配置。此外,管理节点上的容器是长时间运行的,例如为了在多个机器学习管道执行期间提供实验服务用户界面和超参数优化引擎服务,而工作节点上的容器仅在机器学习管道执行期间保持运行。

尽管本节描述的部署配置可能看起来复杂,但节点的提供、机器学习中间件(实验管理、超参数优化等)以及机器学习管道在工作节点之间的执行的编排完全由 Kaen 框架和相关的 Docker 容器处理。您将在本章后面学习有关该框架以及如何在现有的 Kaen 容器上构建您的机器学习管道的更多信息。

12.2 使用 Kaen 启用 PyTorch 分布式训练支持

本节说明了如何使用 PyTorch DistributedDataParallel 类添加 PyTorch 分布式训练支持。通过本节的结束,DC 出租车费用模型的 train 方法将被扩展以与云环境中的分布式训练框架 Kaen 集成。

与本书前几章的代码和 Jupyter 笔记本说明不同,本章剩余部分的代码要求您的环境已安装 Docker 和 Kaen。有关安装 Docker 和开始使用的更多信息,请参阅附录 B。要将 Kaen 安装到已存在 Docker 安装的环境中,请执行

pip install kaen[cli,docker]

这将下载并安装 kaen 命令行界面(CLI)到您的 shell 环境中。例如,如果 Kaen 安装正确,您可以使用以下命令获取有关 Kaen 命令的帮助:

kaen --help

这应该会产生类似以下的输出:

Usage: kaen [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  dojo     Manage a dojo training environment.
  hpo      Manage hyperparameter optimization.
  init     Initialize a training dojo in a specified infrastructure...
  job      Manage jobs in a specific dojo training environment.
  jupyter  Work with a Jupyter notebook environment.

要执行本书其余部分的说明,请启动 Kaen Jupyter 环境,使用以下命令:

kaen jupyter

从您的 shell 环境中执行以下命令,这应该会在您本地的 Docker 主机上启动一个专门的 Jupyter 笔记本环境作为一个新的 Docker 容器。kaen jupyter 命令还应该将您的默认浏览器导航到 Jupyter 主页,并在 shell 中输出类似以下的文本:

Started Jupyter. Attempting to navigate to Jupyter in your browser using
➥ http://127.0.0.1:8888/?token=...

它指定了您可以在浏览器中使用的 URL,以打开新启动的 Jupyter 实例。

在 Jupyter 环境中,创建并打开一个新的笔记本。例如,您可以将笔记本命名为 ch12.ipynb。作为笔记本中的第一步,您应该执行 shell 命令

!mkdir -p src

在此环境中为您的代码创建一个 src 目录。请记住,在 Jupyter 中的 Python 代码单元格中使用感叹号!时,其后的命令将在底层的 bash shell 中执行。因此,运行该代码的结果是在文件系统中创建一个 src 目录。

接下来,使用 %%writefile 魔术将 DC 出租车模型的最新版本(如第十一章所述)保存到 src 目录中的 model_v1.py 文件中。

列表 12.1 将实现保存到 model_v1.py

%%writefile src/model_v1.py
import sys
import json
import time
import torch as pt
import pytorch_lightning as pl
from distutils.util import strtobool

pt.set_default_dtype(pt.float64)
class DcTaxiModel(pl.LightningModule):
    def __init__(self, **kwargs):
      super().__init__()
      self.save_hyperparameters()
      pt.manual_seed(int(self.hparams.seed))

      self.step = 0
      self.start_ts = time.perf_counter()
      self.train_val_rmse = pt.tensor(0.)

      #create a list of hidden layer neurons, e.g. [3, 5, 8]
      num_hidden_neurons = json.loads(self.hparams.num_hidden_neurons)

      self.layers = \
        pt.nn.Sequential(
          pt.nn.Linear(int(self.hparams.num_features),
                        num_hidden_neurons[0]),
          pt.nn.ReLU(),
          *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
          pt.nn.Linear(num_hidden_neurons[-1], 1)
      )

      if 'batch_norm_linear_layers' in self.hparams \
        and strtobool(self.hparams.batch_norm_linear_layers):
        self.layers = self.batch_norm_linear(self.layers)

    def build_hidden_layers(self, num_hidden_neurons, activation):
      linear_layers = [ pt.nn.Linear(num_hidden_neurons[i],
          num_hidden_neurons[i+1]) \
            for i in range(len(num_hidden_neurons) - 1) ]

      classes = [activation.__class__] * len(num_hidden_neurons)

      activation_instances = list(map(lambda x: x(), classes))

      hidden_layer_activation_tuples = \
        list(zip(linear_layers, activation_instances))

      hidden_layers = [i for sublist in \
        hidden_layer_activation_tuples for i in sublist]

      return hidden_layers

    def batch_norm_linear(self, layers):
      idx_linear = \
        list(filter(lambda x: type(x) is int,
            [idx if issubclass(layer.__class__, pt.nn.Linear) else None \
              for idx, layer in enumerate(layers)]))
      idx_linear.append(sys.maxsize)
      layer_lists = [list(iter(layers[s:e])) \
        for s, e in zip(idx_linear[:-1], idx_linear[1:])]
      batch_norm_layers = [pt.nn.BatchNorm1d(layer[0].in_features) \
        for layer in layer_lists]
      batch_normed_layer_lists = [ [bn, *layers] \
        for bn, layers in list(zip(batch_norm_layers, layer_lists)) ]
      return pt.nn.Sequential(*[layer \
        for nested_layer in batch_normed_layer_lists \
        for layer in nested_layer ])

    def batchToXy(self, batch):
      batch = batch.squeeze_()
      X, y = batch[:, 1:], batch[:, 0]
      return X, y

    def forward(self, X):
      y_est = self.layers(X)
      return y_est.squeeze_()

    def log(self, k, v, **kwargs):
        super().log(k, v,
                on_step = kwargs['on_step'],
                on_epoch = kwargs['on_epoch'],
                prog_bar = kwargs['prog_bar'],
                logger = kwargs['logger'],)

    def training_step(self, batch, batch_idx):
        self.step += 1

        X, y = self.batchToXy(batch) #unpack batch into features and label

        y_est = self.forward(X)

        loss = pt.nn.functional.mse_loss(y_est, y)

        for k,v in {
          "train_step": self.step,
          "train_mse": loss.item(),
          "train_rmse": loss.sqrt().item(),
          "train_steps_per_sec": \
            self.step / (time.perf_counter() - self.start_ts),

        }.items():
          self.log(k, v, step = self.step, on_step=True, on_epoch=True,
                                            prog_bar=True, logger=True)

        self.train_val_rmse = loss.sqrt()

        return loss

    def validation_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
        "val_mse": loss.item(),
        "val_rmse": loss.sqrt().item(),
        "train_val_rmse": (self.train_val_rmse + loss.sqrt()).item(),
      }.items():
        self.log(k, v, step = self.step, on_step=True, on_epoch=True,
                                          prog_bar=True, logger=True)

      return loss

    def test_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
          "test_mse": loss.item(),
          "test_rmse": loss.sqrt().item(),
      }.items():
        self.log(k, v, step = self.step, on_step=True, on_epoch=True,
                                          prog_bar=True, logger=True)

    def configure_optimizers(self):
        optimizers = {'Adam': pt.optim.AdamW,
                      'SGD': pt.optim.SGD}
        optimizer = optimizers[self.hparams.optimizer]

        return optimizer(self.layers.parameters(),
                            lr = float(self.hparams.lr))

由于列表 12.1 中的代码将 DC 出租车模型的版本 1 保存到名为 model_v1.py 的文件中,因此构建和测试该模型版本的过程的入口点(在 src 目录的 trainer.py 文件中)从加载 model_v1 包中的 DC 出租车模型实例开始:

%%writefile src/trainer.py
from model_v1 import DcTaxiModel

import os
import time
import kaen
import torch as pt
import numpy as np
import pytorch_lightning as pl
import torch.distributed as dist
from torch.utils.data import DataLoader
from torch.nn.parallel import DistributedDataParallel

from kaen.torch import ObjectStorageDataset as osds

def train(model, train_glob, val_glob, test_glob = None):
    #set the pseudorandom number generator seed
    seed = int(model.hparams['seed']) \                          ❶
                if 'seed' in model.hparams \
                else int( datetime.now().microsecond )

    np.random.seed(seed)
    pt.manual_seed(seed)

    kaen.torch.init_process_group(model.layers)                  ❷

    trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                            if pt.cuda.is_available() else 0,
        max_epochs = 1,
        limit_train_batches = int( model.hparams.max_batches ) \
                                 if 'max_batches' in model.hparams else 1,
        limit_val_batches = 1,
        num_sanity_val_steps = 1,
        val_check_interval = min(20, int( model.hparams.max_batches ) ),
        limit_test_batches = 1,
        log_every_n_steps = 1,
        gradient_clip_val=0.5,
        progress_bar_refresh_rate = 0,
        weights_summary = None,)

    train_dl = \
    DataLoader(osds(train_glob,
                    worker = kaen.torch.get_worker_rank(),
                    replicas = kaen.torch.get_num_replicas(),
                    shard_size = \                              ❸
                      int(model.hparams.batch_size),
                    batch_size = \                              ❹
                      int(model.hparams.batch_size),
                    storage_options = {'anon': False},
                   ),
               pin_memory = True)

    val_dl = \
    DataLoader(osds(val_glob,
                    batch_size = int(model.hparams.batch_size),
                    storage_options = {'anon': False},
                   ),
               pin_memory = True)

    trainer.fit(model,
              train_dataloaders = train_dl,
              val_dataloaders = val_dl)
    if test_glob is not None:
        test_dl = \
          DataLoader(osds(test_glob,
                          batch_size = int(model.hparams.batch_size),
                          storage_options = {'anon': False},
                         ),
                    pin_memory = True)

        trainer.test(model,
                    dataloaders=test_dl)

    return model, trainer

if __name__ == "__main__":
    model, trainer = train(DcTaxiModel(**{
            "seed": "1686523060",
            "num_features": "8",
            "num_hidden_neurons": "[3, 5, 8]",
            "batch_norm_linear_layers": "1",
            "optimizer": "Adam",
            "lr": "0.03",
            "max_batches": "1",
            "batch_size": str(2 ** 18),}),

      train_glob = \
        os.environ['KAEN_OSDS_TRAIN_GLOB'] \
          if 'KAEN_OSDS_TRAIN_GLOB' in os.environ \
          else 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',

      val_glob = \
        os.environ['KAEN_OSDS_VAL_GLOB'] \
          if 'KAEN_OSDS_VAL_GLOB' in os.environ \
          else 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/valid.csv',

      test_glob = \
        os.environ['KAEN_OSDS_TEST_GLOB'] \
          if 'KAEN_OSDS_TEST_GLOB' in os.environ \
          else 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/valid.csv')

    print(trainer.callback_metrics)

❶ 使用超参数或当前时间戳初始化伪随机数种子。

❷ 自动更新 DC 出租车模型,以利用多个训练节点(如果有的话)。

❸ 正如第八章中所述,在分布式集群中,shard_size 往往不同于 . . .

❹ . . . 用于计算梯度的 batch_size。

在此时,您可以通过从您的 shell 环境中运行以下命令对 trainer.py 进行单元测试。

列表 12.2 运行一个简单的测试来确认实现是否按预期工作。

%%bash
python3 src/trainer.py

这应该会使用 DC 出租车数据的小样本训练、测试并报告您模型的度量标准。

12.2.1 理解 PyTorch 分布式训练设置

本节说明了执行分布式训练时 PyTorch 模型所期望的环境变量和相关设置的配置。

PyTorch 模型的分布式训练方法出奇的简单。尽管原生的 PyTorch 不提供与 AWS、Azure 或 GCP 等云提供商的集成,但列表 12.3 中的代码说明了如何使用 Kaen 框架(kaen.ai)在云提供商中桥接 PyTorch 和 PyTorch Lightning,实现分布式训练。

kaen.torch.init_process_ group 方法使用的 PyTorch 特定实现,使得 DC 出租车模型的分布式训练成为可能,如模型 PyTorch Lightning 模块所指定的,其中 PyTorch torch.nn.Sequential 层存储在 model.layers 属性中。

清单 12.3 Kaen 框架配置 PyTorch 模型

#pytorch distributed training requires MASTER_ADDR and MASTER_PORT to be set
os.environ['MASTER_ADDR'] = \
  os.environ['KAEN_JOB_MANAGER_IP'] \                            ❶
  if 'KAEN_JOB_MANAGER_IP' in os.environ else "127.0.0.1"

MASTER_ADDR = os.environ['MASTER_ADDR']
os.environ['MASTER_PORT'] = \                                    ❷
  os.environ['MASTER_PORT'] if 'MASTER_PORT' in os.environ else "12355"
MASTER_PORT = os.environ['MASTER_PORT']

BACKEND = os.environ['KAEN_BACKEND'] \                           ❸
                    if 'KAEN_BACKEND' in os.environ else "gloo"
RANK = int(os.environ['KAEN_RANK'])                              ❹
WORLD_SIZE = int(os.environ['KAEN_WORLD_SIZE'])                  ❺

if not dist.is_initialized():
    dist.init_process_group(init_method = "env://",              ❻
                            backend = BACKEND,
                            rank = RANK,
                            world_size = WORLD_SIZE)
    model.layers = \                                              ❼
      DistributedDataParallel(model.layers, device_ids=[])

❶ 将 PyTorch 的 MASTER_ADDR 设置为本地主机地址,除非 Kaen 另有规定。

❷ 将 PyTorch 的 MASTER_PORT 设置为 12355,除非 MASTER_PORT 变量中另有规定。

❸ 除非 KAEN_BACKEND 另有规定,否则使用基于 CPU 的 gloo 后端。

❹ 初始化分布式数据并行训练的等级 . . .

❺ . . . 并根据 KAEN_RANK 和 KAEN_WORLD_SIZE 变量设置训练节点的计数。

❻ 确保分布式训练进程组已准备好进行训练。

❼ 使用 DistributedDataParallel 为模型启用分布式训练。

当使用 DistributedDataParallel 实现训练 PyTorch 模型时,在训练开始之前必须满足几个先决条件。首先,必须为网络上的模型训练管理节点配置分布式训练库的 MASTER_ADDR 和 MASTER_PORT 环境变量。即使在单节点场景中使用 DistributedDataParallel,也必须指定这些值。在单节点场景中,MASTER_ADDR 和 MASTER_ PORT 的值分别初始化为 127.0.0.1 和 12355。当分布式训练集群由多个节点组成时,MASTER_ADDR 必须对应于集群中的管理节点的 IP 地址(根据第十一章中的描述,即节点等级为 0 的节点)。

Kaen 框架可以使用 PyTorch 训练的管理节点的运行时 IP 地址初始化您的模型训练环境。因此,在示例中,如果 Kaen 框架设置了后者的环境变量,则将 MASTER_ADDR 初始化为 KAEN_ JOB_MANAGER_IP 的值,否则将其初始化为 127.0.0.1(用于单节点训练)。在示例中,默认情况下将 MASTER_PORT 初始化为 12355,除非在启动训练运行时之前预先设置了不同的值。

注意,init_process_group 方法的 init_method 参数被硬编码为 env://,以确保分布式训练初始化根据前述的 MASTER_ADDR 和 MASTER_PORT 环境变量的值发生。虽然可以使用文件或键/值存储进行初始化,但在本示例中演示了基于环境的方法,因为它是 Kaen 框架本地支持的。

除了初始化方法之外,请注意 init_process_group 是使用 BACKEND、WORKER 和 REPLICAS 设置的值调用的。BACKEND 设置对应于 PyTorch 支持的几个分布式通信后端库之一的名称。 (这些库支持的特性的详细信息在此处可用:pytorch.org/docs/stable/distributed.html。)gloo 用于启用基于 CPU 的分布式训练,而 nccl 则用于基于 GPU 的分布式训练。由于基于 CPU 的分布式训练更容易、更便宜,并且通常更快地在云提供商(如 AWS)中预配,因此本章首先关注基于 CPU 的训练,然后再介绍如何引入支持基于 GPU 的训练所需的更改。

初始化分布式训练所需的 RANK 和 WORLD_SIZE 值也由 Kaen 框架提供。WORLD_SIZE 值对应于用于分布式训练的节点的整数计数的自然计数(即,从一开始),而 RANK 值对应于在 PyTorch 模型中执行 Python 运行时训练的节点的从零开始的整数索引。请注意,RANK 和 WORLD_SIZE 都是根据 Kaen 框架的环境变量设置进行初始化的。例如,如果您实例化一个仅包含单个训练节点的 Kaen 训练环境,则 KAEN_WORLD_SIZE 设置为 1,而单个训练节点的 RANK 值设置为 0。相比之下,对于由 16 个节点组成的分布式 Kaen 训练环境,KAEN_WORLD_SIZE 初始化为 16,并且每个训练节点分配了范围为 [0, 15] 的 RANK 值,换句话说,包括起始(0)索引和结束(15)索引。

最后,请注意,仅在检查 is_initialized 状态之后才初始化 DistributedDataParallel 训练。初始化涉及使用此部分前述的 backend、rank 和 world_size 设置执行 init_process_group。一旦初始化完成(换句话说,init_process_group 返回),就会将 DistributedDataParallel 实例包装在基于 PyTorch nn.Module 的模型实例周围,并将其分配给示例中的 model.nn。此时,模型已准备好通过分布式集群进行训练。

12.3 在本地 Kaen 容器中对模型训练进行单元测试

这一部分描述了如何在本地的 Kaen 容器中对模型实现进行单元测试,然后再将代码部署到像 AWS 这样的云环境中。

尽管代码实现支持分布式训练,但无需在云提供商中预留(和支付)分布式训练环境即可进行测试。您将通过下载为面向 AWS 的 PyTorch 模型提供的 Kaen 提供的基础容器镜像来开始单元测试。

确保您可以使用 DockerHub 进行身份验证,您可以下载基础容器镜像。一旦您在 Kaen Jupyter 环境中执行以下代码片段,您将被提示输入您的 DockerHub 用户名,然后将其存储在 DOCKER_HUB_USER Python 变量中:

DOCKER_HUB_USER = input()
DOCKER_HUB_USER

接下来,在提示时输入您的用户名的 DockerHub 密码。请注意,验证完成后,密码将从 DOCKER_HUB_PASSWORD 变量中清除出来:

import getpass
DOCKER_HUB_PASSWORD = getpass.getpass()

!echo "{DOCKER_HUB_PASSWORD}" | \
docker login --username {DOCKER_HUB_USER} --password-stdin

DOCKER_HUB_PASSWORD = None

如果您指定了有效的 DockerHub 凭据,您应该看到一个显示“登录成功”的输出消息。

基本的 PyTorch Docker 镜像相当庞大,大约为 1.9 GB。基于 Kaen 的 PyTorch 镜像 (kaenai/pytorch-mlflow-aws-base:latest),添加了支持 AWS 和 MLFlow 的二进制文件,大约大小为 2 GB,所以请做好准备,以下下载将根据您的互联网连接速度需要几分钟。

要执行下载,请运行

!docker pull kaenai/pytorch-mlflow-aws-base:latest

下载完成后,您可以使用以下 Dockerfile 将您的源代码打包到一个从 kaenai/pytorch-mlflow-aws-base:latest 派生的镜像中。请注意,该文件只是将 Python 源代码复制到镜像文件系统的 /workspace 目录中:

%%writefile Dockerfile
FROM kaenai/pytorch-mlflow-aws-base:latest
COPY *.py /workspace/

由于本章早些时候描述的源代码文件 model_v1.py 和 trainer.py 被保存到了一个 src 目录中,请注意以下命令构建您的 Docker 镜像时将 src/ 目录作为 Docker 镜像构建过程的根目录。为了确保您构建的镜像可以上传到 DockerHub,镜像使用 {DOCKER_HUB_USER} 作为前缀进行标记:

!docker build -t {DOCKER_HUB_USER}/dctaxi:latest -f Dockerfile src/

在 docker build 命令完成后,您可以使用以下命令运行您新创建的 Docker 容器

!docker run -it {DOCKER_HUB_USER}/dctaxi:latest \
"python /workspace/trainer.py"

这应该产生一个与列表 12.2 输出相同的输出。为什么要费心创建 Docker 镜像呢?回想一下,拥有 Docker 镜像将简化在诸如 AWS 等云服务提供商环境中部署和训练模型的过程。如何将镜像从您的本地环境共享到云服务提供商环境?通常,Docker 镜像是使用 DockerHub 等 Docker Registry 实例进行共享的。

要推送(上传)您新构建的镜像到 DockerHub,执行

!docker push {DOCKER_HUB_USER}/dctaxi:latest

由于 docker push 操作只需要推送源代码(Python 文件)的内容到 DockerHub,所以应该在几秒钟内完成。您的 dctaxi 镜像的其余部分是从基础 kaenai/pytorch-mlflow-aws-base:latest 镜像挂载的。

12.4 使用 Optuna 进行超参数优化

本节介绍了用于 HPO 的 Optuna 和如何使用 Kaen 框架为 DC 出租车车费估算模型添加 HPO 支持。

到目前为止,您已经使用静态的超参数值对模型训练进行了单元测试。回想一下第十一章,您可以使用 Optuna 对您的代码执行超参数优化(HPO)。

Optuna 是 Kaen 支持的几种 HPO 框架之一。要在分布式训练中纳入对 HPO 的支持,您需要使用一个将 Optuna 作为服务将其公开给您的代码的基于 Kaen 的 Docker 映像,并实现一个可子类化的 Python 类命名为 BaseOptunaService。回想一下,Optuna 中的超参数是使用试验 API 指定的。Kaen 中的 BaseOptunaService 提供了对 BaseOptunaService 子类中的 Optuna 试验实例的访问。例如:

import optuna
import numpy as np
from kaen.hpo.optuna import BaseOptunaService
class DcTaxiHpoService(BaseOptunaService):
  def hparams(self):
    trial = self._trial         ❶

    #define hyperparameter
    return {
        "seed": \               ❷
          trial.suggest_int('seed', 0, np.iinfo(np.int32).max)
    }

❶ _trial 属性引用 Optuna 试验实例。

❷ 试验实例支持 Optuna 试验 API 方法,例如 suggest_int。

注意,在 hparams 方法返回的字典实例中,有一个超参数向 Optuna 请求。suggest_int 方法是 Optuna 试验 API 中可用的几种方法之一,用于获取超参数的值。 (试验接口中可用的其他方法在这里描述:optuna.readthedocs.io/en/stable/reference/generated/optuna.trial.Trial.html#。) 在本例中,suggest_int('seed', 0, np.iinfo(np.int32).max) 方法指定 Optuna 应推荐从 0 到包括正 32 位整数的最大值的伪随机数种子生成器的值。

回想一下,DcTaxiModel 的训练还依赖于额外的超参数值,包括 optimizer、bins、lr(学习率)、num_hidden_neurons、batch_size 和 max_batches。本书第十一章介绍了使用 Optuna 试验 API 实现这些超参数。要在 DcTaxiHpoService 类的实现中启用对这些超参数的支持,您需要扩展 hparams 方法返回的字典,使用 Optuna 对应超参数值的规范来尝试:

def hparams(self):
  trial = self._trial

  return {
    "seed": \
        trial.suggest_int('seed', 0, np.iinfo(np.int32).max - 1),

    "optimizer": \
        trial.suggest_categorical('optimizer', ['Adam']),

    "lr": \
        trial.suggest_loguniform('lr', 0.001, 0.1),

    "num_hidden_neurons": \
        [trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons", \
            [7, 11, 13, 19, 23]) for layer in \
            range(trial.suggest_categorical('num_layers',
                                            [11, 13, 17, 19]))],

    "batch_size": \
        trial.suggest_categorical('batch_size',
                                  [2 ** i for i in range(16, 22)]),

    "max_batches": \
        trial.suggest_int('max_batches', 40, 400, log = True)
  }

除了试验,Optuna 还使用了一个“研究”(相当于 MLFlow 父运行),它是试验的集合。在 Kaen 框架中,Optuna 研究用于生成关于试验摘要统计信息的报告,以及生成形式为已完成试验的自定义可视化报告。

要持久化试验摘要统计信息,您可以使用 Optuna 研究 API 的 trials_dataframe 方法,该方法返回一个 pandas DataFrame,描述了已完成试验以及关联超参数值的摘要统计信息。请注意,在下面的示例中,数据帧基于实验名称持久化为 html 文件:

def on_experiment_end(self, experiment, parent_run):
    study = self._study
    try:
      for key, fig in {
        "plot_param_importances": \
            optuna.visualization.plot_param_importances(study),

        "plot_parallel_coordinate_all": \
            optuna.visualization.plot_parallel_coordinate(study, \
                params=["max_batches",
                        "lr",
                        "num_hidden_layer_0_neurons",
                        "num_hidden_layer_1_neurons",
                        "num_hidden_layer_2_neurons"]),

        "plot_parallel_coordinate_l0_l1_l2": \
            optuna.visualization.plot_parallel_coordinate(study, \
                params=["num_hidden_layer_0_neurons",
                        "num_hidden_layer_1_neurons",
                        "num_hidden_layer_2_neurons"]),

        "plot_contour_max_batches_lr": \
            optuna.visualization.plot_contour(study, \
                params=["max_batches", "lr"]),
      }.items():
        fig.write_image(key + ".png")
        self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                            local_path = key + ".png")

    except:
      print(f"Failed to correctly persist experiment 
➥             visualization artifacts")
      import traceback
      traceback.print_exc()

    #log the dataframe with the study summary
    study.trials_dataframe().describe().to_html(experiment.name + ".html")
    self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                        local_path = experiment.name + ".html")

    #log the best hyperparameters in the parent run
    self.mlflow_client.log_metric(parent_run.info.run_id,
                                  "loss", study.best_value)
    for k, v in study.best_params.items():
      self.mlflow_client.log_param(parent_run.info.run_id, k, v)

在示例中,对 Optuna API 的调用是在 on_experiment_end 方法的上下文中执行的,该方法在实验结束后由 BaseOptunaService 基类调用。在将包含实验摘要统计信息的 html 文件持久化后,方法的剩余部分生成并持久化使用 Optuna 可视化包 (mng.bz/4Kxw) 的研究的可视化效果。请注意,对于每个可视化效果,相应的图像都会持久化到一个 png 文件中。

代码中的 mlflow_client 充当对 MLFlow 客户端 API (mng.bz/QqjG) 的通用引用,使得可以从 MLFlow 中读取和写入数据,并监视实验的进展。parent_run 变量是对“父”运行的引用,或者说是具有 Optuna HPO 服务建议的特定超参数值配置的一系列试验或执行。

本章描述的整个 HPO 实现如下代码片段所示。请注意,该片段将实现源代码保存为 src 文件夹中的 hpo.py 文件:

%%writefile src/hpo.py
import optuna
import numpy as np
from kaen.hpo.optuna import BaseOptunaService

class DcTaxiHpoService(BaseOptunaService):
  def hparams(self):
    trial = self._trial

    #define hyperparameters
    return {
      "seed": trial.suggest_int('seed', 0, np.iinfo(np.int32).max - 1),
      "optimizer": trial.suggest_categorical('optimizer', ['Adam']),
      "lr": trial.suggest_loguniform('lr', 0.001, 0.1),
      "num_hidden_neurons": \
        [trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons",
          [7, 11, 13, 19, 23]) for layer in \
            range(trial.suggest_categorical('num_layers',
                                            [11, 13, 17, 19]))],

      "batch_size": \
        trial.suggest_categorical('batch_size', \
                                  [2 ** i for i in range(16, 22)]),

      "max_batches": trial.suggest_int('max_batches', 40, 400, log = True)
    }

  def on_experiment_end(self, experiment, parent_run):
    study = self._study
    try:
      for key, fig in {
        "plot_param_importances": \
          optuna.visualization.plot_param_importances(study),
        "plot_parallel_coordinate_all": \
          optuna.visualization.plot_parallel_coordinate(study,
            params=["max_batches",
                    "lr",
                    "num_hidden_layer_0_neurons",
                    "num_hidden_layer_1_neurons",
                    "num_hidden_layer_2_neurons"]),
        "plot_parallel_coordinate_l0_l1_l2": \
          optuna.visualization.plot_parallel_coordinate(study,
            params=["num_hidden_layer_0_neurons",
            "num_hidden_layer_1_neurons",
            "num_hidden_layer_2_neurons"]),

        "plot_contour_max_batches_lr": \
          optuna.visualization.plot_contour(study,
            params=["max_batches", "lr"]),
      }.items():
        fig.write_image(key + ".png")
        self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                            local_path = key + ".png")

    except:
      print(f"Failed to correctly persist experiment 
➥             visualization artifacts")
      import traceback
      traceback.print_exc()

    #log the dataframe with the study summary
    study.trials_dataframe().describe().to_html(experiment.name + ".html")
    self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                        local_path = experiment.name + ".html")

    #log the best hyperparameters in the parent run
    self.mlflow_client.log_metric(parent_run.info.run_id,
                                    "loss", study.best_value)
    for k, v in study.best_params.items():
      self.mlflow_client.log_param(parent_run.info.run_id, k, v)

有了源代码,你就可以将其打包成一个 Docker 容器。首先拉取一个用于 Optuna 和 MLFlow 的基本 Kaen 容器:

!docker pull kaenai/optuna-mlflow-hpo-base:latest

一旦完成,使用以下命令为派生图像创建一个 Dockerfile:

%%writefile Dockerfile
FROM kaenai/optuna-mlflow-hpo-base:latest
ENV KAEN_HPO_SERVICE_PREFIX=hpo \
    KAEN_HPO_SERVICE_NAME=DcTaxiHpoService

COPY hpo.py /workspace/.

请注意,你的 DcTaxiHpoService 实现的软件包前缀对应于文件名 hpo.py,分别由 KAEN_HPO_SERVICE_NAME 和 KAEN_HPO_SERVICE_PREFIX 环境变量指定。保存 Dockerfile 后,通过以下命令构建图像:

!docker build -t {DOCKER_HUB_USER}/dctaxi-hpo:latest -f Dockerfile src/

并将其推送到 DockerHub:

!docker push {DOCKER_HUB_USER}/dctaxi-hpo:latest.

12.4.1 启用 MLFlow 支持

本节描述如何在你的 DcTaxiModel 和 MLFlow 框架之间添加集成,以管理和跟踪 HPO 实验。

尽管基本 kaenai/pytorch-mlflow-aws-base:latest 图像包含对 MLFlow 的支持,但 trainer.py 中的训练实现没有利用 MLFlow 实验管理和跟踪功能。由于 MLFlow 使用实验的概念来组织一系列 HPO 试验和运行,Kaen 提供了一个 BaseMLFlowClient 类,可以用来为 DcTaxiModel 实现一个由 MLFlow 管理的实验。BaseMLFlowClient 的子类负责使用 BaseMLFlowClient 从 MLFlow 和 Optuna 获取的超参数值实例化未训练的 PyTorch 模型实例。

首先,在你的 Kaen Jupyter 环境中运行以下命令保存名为 DcTaxiExperiment 的 BaseMLFlowClient 子类的一个实例:

%%writefile src/experiment.py
import os
from model_v1 import DcTaxiModel
from trainer import train
from kaen.hpo.client import BaseMLFlowClient

class DcTaxiExperiment(BaseMLFlowClient):

    def on_run_start(self, run_idx, run):
        print(f"{run}({run.info.status}): starting...")

        #create a set of default hyperparameters
        default_hparams = {"seed": "1686523060",
                        "num_features": "8",
                        "num_hidden_neurons": "[3, 5, 8]",
                        "batch_norm_linear_layers": "1",
                        "optimizer": "Adam",
                        "lr": "0.03",
                        "max_batches": "1",
                        "batch_size": str(2 ** 18),}

        #fetch the MLFlow hyperparameters if available
        hparams = run.data.params if run is not None \
                    and run.data is not None else \
                    default_hparams

        #override the defaults with the MLFlow hyperparameters
        hparams = {**default_hparams, **hparams}

        untrained_model = DcTaxiModel(**hparams)
        def log(self, k, v, **kwargs):
            if self.mlflow_client and 0 == int(os.environ['KAEN_RANK']):
                if 'step' in kwargs and kwargs['step'] is not None:
                    self.mlflow_client.log_metric(run.info.run_id,
                      k, v, step = kwargs['step'])
                else:
                    self.mlflow_client.log_metric(run.info.run_id,
                       k, v)

        import types
        untrained_model.log = types.MethodType(log, self)

        model, trainer = \
          train(untrained_model,
                train_glob = os.environ['KAEN_OSDS_TRAIN_GLOB'],
                val_glob = os.environ['KAEN_OSDS_VAL_GLOB'],
                test_glob = os.environ['KAEN_OSDS_TEST_GLOB'])

        print(trainer.callback_metrics)

这将代码保存到 src/experiment.py 文件中。

有了实验支持,你就可以使用以下命令构建更新的 dctaxi 图像:

%%writefile Dockerfile
FROM kaenai/pytorch-mlflow-aws-base:latest
COPY * /workspace/
ENV KAEN_HPO_CLIENT_PREFIX=experiment \
    KAEN_HPO_CLIENT_NAME=DcTaxiExperiment

它指定了一个新的入口点进入镜像,使用 experiment.py 中的 experiment.DcTaxiExperiment 来更改 KAEN_HPO_CLIENT_PREFIX 和 KAEN_HPO_CLIENT_NAME 环境变量的默认值。

与以前一样,使用以下命令构建你的 dctaxi 镜像。

!docker build -t {DOCKER_HUB_USER}/dctaxi:latest -f Dockerfile src/

并使用以下命令将其推送到 DockerHub。

!docker push {DOCKER_HUB_USER}/dctaxi:latest.

12.4.2 在本地 Kaen 提供程序中为 DcTaxiModel 使用 HPO

此时,你已经准备好构建一个能够建议超参数优化试验并管理试验运行的 Docker 容器。在容器中,超参数值由 Optuna 建议,并且基于这些值的试验由 MLFlow 管理。

在配置更昂贵的云提供程序之前,最好先通过配置本地 Kaen 提供程序来开始,以便你可以对 HPO 和模型训练代码进行单元测试。你可以通过执行以下命令创建一个 Kaen 训练 道场

!kaen dojo init --provider local

它应该返回新创建的 Kaen 道场的字母数字标识符。

你可以使用以下命令列出工作空间中可用的 Kaen 道场。

!kaen dojo ls

它应该打印出你刚刚创建的道场的 ID。

你将希望将道场的标识符保存为 Python 变量以供将来使用,你可以使用以下 Jupyter 语法将 bash 脚本赋值给 Python 变量。

[MOST_RECENT_DOJO] = !kaen dojo ls | head -n 1
MOST_RECENT_DOJO

在 Kaen 道场用于训练之前,它应该被激活。通过运行以下命令激活由 MOST_RECENT_DOJO 变量中的标识符指定的道场。

!kaen dojo activate {MOST_RECENT_DOJO}

由于 Jupyter 的 ! shell 快捷方式提供了对 Python 变量的访问,因此在前面的代码片段中,{MOST_RECENT_DOJO} 语法将替换为相应 Python 变量的值。你可以通过检查来确认道场是否激活。

!kaen dojo inspect {MOST_RECENT_DOJO}

它应该包含一个输出行,其中包含 KAEN_DOJO_STATUS=active。

在你能够在道场中启动训练作业之前,你需要创建一个指定了道场和用于训练的 Kaen 镜像的作业。

要创建一个训练 DcTaxiModel 的作业,请执行以下命令。

!kaen job create --dojo {MOST_RECENT_DOJO} \
--image {DOCKER_HUB_USER}/dctaxi:latest

它将尝试从 DockerHub 拉取指定的镜像,如果成功,则会返回作业的字母数字标识符。

与道场一样,你可以使用以下命令将作业标识保存到 Python 变量中。

[MOST_RECENT_JOB] = !kaen job ls | head -n 1
MOST_RECENT_JOB

它应该打印出你创建的作业的标识符。

Kaen 中的每个作业都配置了专用的网络设置,你可以通过运行以下命令来检查。

!kaen job inspect {MOST_RECENT_JOB}

由于你还没有为这个作业启用 HPO,所以检查的作业设置中不包含用于提供 MLFlow 实验管理和 Optuna 超参数值的 HPO 镜像的信息。你可以通过执行以下命令来配置作业进行一次 HPO 运行。

!kaen hpo enable \
--image {DOCKER_HUB_USER}/dctaxi-hpo:latest \
--num-runs 1 \
--service-prefix hpo \
--service-name DcTaxiHpoService \
--port 5001 5001 \
{MOST_RECENT_JOB}

它会覆盖你的 dctaxi-hpo 镜像的默认设置,以指定使用 hpo.DcTaxiHpoService 类来启动 HPO 服务。执行的语句还使用 --port 设置配置了 MLFlow UI 端口 5001。

假设 hpo enable 命令成功完成,你可以再次检查作业以观察与 HPO 相关的设置:

!kaen job inspect {MOST_RECENT_JOB}

请注意,此时输出中包括 KAEN_HPO_MANAGER_IP,用于内部 Docker 网络的 IP 地址(由 KAEN_JOB_SUBNET 指定),该网络处理容器实例之间的通信。

此时,HPO 服务应该已经启动并运行,因此您应该能够通过将浏览器导航到 http://127.0.0.1:5001 访问 MLFlow 用户界面,该界面应该显示类似于图 12.4 的屏幕。请注意,在您探索 HPO 实验的详细信息之前,您需要在 MLFlow 界面的左侧边栏中打开以 job 前缀开头的 MLFlow 实验。

12-04

图 12.4 屏幕截图显示了 MLFlow 基于浏览器的界面,展示了实验实例的父运行和唯一的子运行

由于此时您刚刚启动了 HPO 服务,因此您的实验只包括一个父运行和一个子运行。主运行与 MLFlow 实验具有一对一的关系,并包含定义应由机器学习管道执行实例使用的特定超参数配置的各个子运行。如果您在 MLFlow 用户界面中导航到子运行,则应看到类似于图 12.5 截图的屏幕。

12-05

图 12.5 MLFlow 屏幕截图显示了 Optuna HPO 建议的设置,用于子运行

要使用您 AWS 存储桶中的可用数据在本地提供程序中开始训练模型,您需要配置环境变量与您的 AWS 凭据。在以下代码片段中,将 Python None 替换为您的匹配 AWS 凭据的值,用于 AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY 和 AWS_DEFAULT_REGION。同时,对于您的 BUCKET_ID 值,进行相同的替换,并执行代码以在您的 Kaen Jupyter 环境中配置相应的环境变量:

import os
os.environ['MOST_RECENT_JOB'] = MOST_RECENT_JOB

os.environ['BUCKET_ID'] = None
os.environ['AWS_ACCESS_KEY_ID'] = None
os.environ['AWS_SECRET_ACCESS_KEY'] = None
os.environ['AWS_DEFAULT_REGION'] = None

我建议您从您的 bash shell 中执行以下一系列 echo 命令,以确保所有环境变量都配置如预期:

%%bash
echo $BUCKET_ID
echo $AWS_ACCESS_KEY_ID
echo $AWS_SECRET_ACCESS_KEY
echo $AWS_DEFAULT_REGION
echo $MOST_RECENT_JOB

现在,您可以通过运行 kaen job start 来开始训练您的模型。为了简单起见,首先使用单个训练工作者进行训练(由 --replicas 1 指定)。请注意,命令中的 KAEN_OSDS 环境变量指向您在 AWS 存储桶中的数据 CSV 文件:

!kaen job start \
--replicas 1 \
-e KAEN_HPO_JOB_RUNS 1 \
-e AWS_DEFAULT_REGION $AWS_DEFAULT_REGION \
-e AWS_ACCESS_KEY_ID $AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY $AWS_SECRET_ACCESS_KEY \
-e KAEN_OSDS_TRAIN_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/dev/part*.csv" \
-e KAEN_OSDS_VAL_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
-e KAEN_OSDS_TEST_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
$MOST_RECENT_JOB

当训练作业正在运行时,您应该能够在 MLFlow 用户界面中导航到子运行的详细信息,假设您的训练过程至少运行了 25 个训练步骤,则 train_rmse 指标的结果图表应该类似于图 12.6 中的图表。

12-06

图 12.6 MLFlow 屏幕截图显示了 train_rmse 指标的图表

12.4.3 使用 Kaen AWS 提供程序进行训练

本节说明了如何使用 Kaen 框架在 AWS 虚拟私有云环境中训练容器,而不是在本地提供者中,这样你就可以利用 AWS 中提供的弹性、水平扩展。

要在 AWS 中创建一个 Kaen 道场,你需要在运行 kaen init 时使用 --provider aws 设置。默认情况下,在使用 AWS 提供者时,Kaen 会在 AWS 中将 t3.micro 实例作为 worker 和 manager 节点。尽管 t3.micro 实例是适用于简单演示的低成本默认值,但对于 DcTaxiModel,我建议按照以下方式提供 t3.large 实例:

!kaen dojo init --provider aws \
--worker-instance-type t3.xlarge --manager-instance-type t3.xlarge

在成功创建后,应该报告道场 ID。

要配置 MOST_RECENT_DOJO Python 变量,你应该执行以下操作:

[MOST_RECENT_DOJO] = !kaen dojo ls | head -n 1
MOST_RECENT_DOJO

然后使用以下命令激活道场:

!kaen dojo activate {MOST_RECENT_DOJO}

注意,如果你提供了性能不足的 AWS 节点实例(例如 t3.micro),激活过程可能需要一些时间。一旦激活正确完成,你应该能够使用以下命令检查道场:

!kaen dojo inspect {MOST_RECENT_DOJO}

输出应包含以 KAEN_DOJO_STATUS=active 开头的一行以及激活完成的时间戳。

与本地提供者一样,要在 AWS 中执行训练,你应该首先创建一个作业:

!kaen job create --dojo {MOST_RECENT_DOJO} \
--image {DOCKER_HUB_USER}/dctaxi:latest

与本地提供者不同,在 AWS 提供者中运行 kaen job create 可能需要一些时间。这是因为你推送到 DockerHub 的 dctaxi 镜像需要下载到 AWS 道场中的 AWS 节点。作业创建完成后,你应该使用以下命令将作业 ID 保存到 MOST_RECENT_JOB Python 变量中:

[MOST_RECENT_JOB] = !kaen job ls | head -n 1
os.environ['MOST_RECENT_JOB'] = MOST_RECENT_JOB
MOST_RECENT_JOB

这也将 MOST_RECENT_JOB 环境变量设置为与相应 Python 变量匹配的值。

接下来,使用以下命令为作业启用 HPO:

!kaen hpo enable \
--num-runs 1 \
--image {DOCKER_HUB_USER}/dctaxi-hpo:latest \
--service-prefix hpo \
--service-name DcTaxiHpoService \
--port 5001 5001 \
{MOST_RECENT_JOB}

一旦 kaen hpo enable 操作完成,你可以通过构造笔记本中的 URL 打开 MLFlow 用户界面:

!echo "http://$(kaen dojo inspect {MOST_RECENT_DOJO} \
| grep KAEN_DOJO_MANAGER_IP | cut -d '=' -f 2):5001"

并在浏览器中导航到 URL。由于 MLFlow UI 可能需要几秒钟才能变为可用(取决于 AWS 管理节点实例的性能),你可能需要刷新浏览器才能访问该界面。

要开始训练,kaen job start 命令与之前使用的相同:

!kaen job start \
--replicas 1 \
-e AWS_DEFAULT_REGION $AWS_DEFAULT_REGION \
-e AWS_ACCESS_KEY_ID $AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY $AWS_SECRET_ACCESS_KEY \
-e KAEN_OSDS_TRAIN_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/dev/part*.csv" \
-e KAEN_OSDS_VAL_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
-e KAEN_OSDS_TEST_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
$MOST_RECENT_JOB

与本地提供者的情况类似,你可以在浏览器中导航到 MLFlow UI 并在模型训练时监视指标。

完成后,不要忘记使用以下命令移除 AWS 训练道场:

!kaen dojo rm {MOST_RECENT_DOJO}.

概要

  • 实验管理和超参数优化是机器学习流水线的组成部分。

  • Docker 容器便于将机器学习代码打包、部署和集成到机器学习流水线服务中。

  • 训练机器学习模型意味着执行大量作为机器学习流水线运行实例的实验。

附录 A:机器学习简介

在和机器学习领域的新手讨论时,我发现很多人已经掌握了基础知识,但是他们觉得机器学习领域的信息量和数学深度让人望而生畏。我还记得当我刚开始学习机器学习的时候,我有过类似的经历:感觉需要学的东西太多了。这个附录是为那些可能已经通过一些教程或几个在线课程试图理解机器学习的人准备的。在这个附录中,我将基础的机器学习概念整理成一个完整的框架,并解释它们如何组合在一起,以便你对基础有足够的回顾,可以尝试本书中的项目。在可能的情况下,我将直观地介绍机器学习概念,并尽量少使用数学符号。我的目标不是要替代机器学习的全面课程或深度博客文章;相反,我想向你展示机器学习中最重要、最显著的部分,以供实际应用。

机器学习初学者往往会从深入研究机器学习算法开始他们的学习之旅,这是错误的。机器学习算法可以用于解决问题,但首先应理解适合机器学习的问题。作为机器学习从业者(例如机器学习工程师或数据科学家),你需要了解客户的业务问题,并决定它是否可以重组为一个机器学习问题。因此,A.1 节到 A.3 节介绍机器学习的基础知识,并涵盖结构化数据集的最常见机器学习用例。从第 A.4 节开始并通过本附录的结论,我将向你介绍可以用于解决机器学习问题的机器学习算法以及有关如何应用算法的细节。

A.1 为什么要学机器学习?

如果你正在阅读这本书,那么你至少愿意考虑把机器学习作为研究课题甚至解决问题的方法。但是,机器学习是你学习或使用的正确技术吗?什么情况下应用机器学习才有意义?即使你对机器学习很感兴趣,你可能会发现,进入门槛(是相当高的)让人望而生畏,决定不付出真正理解机器学习所需的深度努力来应用这项技术。过去,很多技术曾经在市场上声称要“改变一切”,但最终没有兑现承诺。机器学习是否注定成为头条新闻几年后就消失在人们的记忆里?还是说有些东西与众不同?

表面上看,机器学习对于当代计算机软件和硬件的用户可能看起来非常普通。机器学习依赖于编写代码的人类,并且代码又依赖于信息技术资源,如计算、存储、网络以及输入和输出接口。然而,要了解机器学习为计算机领域带来的变革之巨大,回顾计算机经历了类似规模的转型的时刻是很有用的。

阅读关于 1940 年代进行数学计算的“计算机”的机器学习书籍可能会让你感到惊讶。这是因为如果你不知道在 1950 年代电子和数字计算机的发明和广泛采用之前,术语计算机被用来描述执行数学计算的人类,通常是与其他“计算机”组合工作。图 A.1 显示了 1949 年的一个计算机团队的照片。

A-01

图 A.1 1949 年夏季,人类计算机在德雷登飞行研究中心设施的办公室里工作。(此照片属于公共领域;更多信息请访问 mng.bz/XrBv。)

在其核心,计算是关于使用数据回答问题的程序(算法)。在 1950—60 年代数字计算机广泛部署之前,计算机(人类)在回答计算问题中扮演了关键角色。在这项工作中,人类计算机通常依靠从纸和笔到早期计算器或基于打孔卡的制表机等外部计算工具。在这种计算范式中,计算指令,描述如何计算的程序,仍然存储在人类计算机的脑海中(图 A.2 的左侧)。

A-02

图 A.2 人类计算机依靠设备来存储计算中使用的数据。这些设备范围从纸和笔到电机计算器,甚至是基于打孔卡的制表机。然而,这些设备都没有内部存储器,用于存储和执行计算指令,换句话说,程序代码(图的左侧)。相比之下,冯·诺伊曼计算机架构,它将计算机设备内存用于数据和计算机指令,创造了一种革命性的计算机编程实践:将用于计算的指令(程序)传输到计算设备的存储器中以供存储和执行(图的右侧)。

现代计算机通过改变人类与计算设备的关系角色而转变了这种计算范式。人类程序员不再将计算指令与计算机程序存储在人类思维中,而是将程序以代码或可机读的指令形式输入到计算设备的内存中(图 A.2 右侧)。

冯·诺依曼结构以与工业革命带来的变革相媲美的规模改变了全球经济。几乎地球上每一台当代计算设备,从袖珍式的移动电话到驱动云计算数据中心的大型服务器,都使用冯·诺依曼结构。人工智能领域的出现是一旦计算转移到了从前的范式,即计算指令存储在人类计算机的生物思维中,才变得可能的。

冯·诺依曼计算范式还为人工智能领域带来了显著突破;例如,IBM 基于该范式构建的 DeepBlue 是第一个击败人类国际象棋冠军加里·卡斯帕罗夫的国际象棋程序。尽管如此,人类程序员编写的硬编码程序对人工智能的许多子领域(包括计算机视觉、语音识别、自然语言理解等)来说过于简单了。人类编程人员编写的用于执行诸如数字图像中对象分类或语音识别等任务的代码最终过于不准确且脆弱,无法广泛采用。

当代机器学习正在以与上世纪五十年代计算机革命相当的基本程度改变程序员与现代计算设备的关系。机器学习从业者不再编写计算机程序,而是使用定制的数据集训练机器学习系统(使用机器学习算法),以生成机器学习模型(见图 A.3)。由于机器学习模型只是计算机代码,机器学习算法可以赋予机器学习从业者产生能够计算超出人类编写程序能力的问题答案的代码的能力。例如,在 2010 年代,机器学习模型被用于以超人类的表现分类图像,如此有效地识别人类语音,以至于许多家庭安装了语音识别数字助理(如亚马逊的 Alexa 和谷歌的 Home),并击败了李世石,古老棋类游戏围棋的人类冠军。

A-03

图 A.3 机器学习依赖于机器学习从业者,后者使用机器学习算法根据定制数据集来“训练”机器学习模型。虽然训练好的机器学习模型只是由机器学习算法创建的代码,但该模型可以回答人类无法手动编程的复杂问题。例如,机器学习模型可以比由人类程序员开发的手工编码更好地对数字图像中的对象进行分类,或者识别数字音频中的人类语音。

本附录将机器学习介绍为计算机科学的一个子领域,专注于使用计算机从数据中学习。尽管这个定义是准确的,但它并没有完全传达机器学习对计算机领域转型的重要性和持久影响。在另一端,关于机器学习的营销口号,如“新的电力”或人工通用智能的实现者,夸大了和模糊了这个领域。显然,机器学习正在改变从 1950 年代到 2010 年代基本保持不变的计算架构的部分。机器学习将如何改变计算尚不清楚。我希望您能像我一样对这种变革的不确定性和您可以在其中扮演的潜在角色感到兴奋!

A.2 乍一看的机器学习

本节介绍了机器学习算法对传统计算机科学带来的变革,用易于理解的例子说明了机器学习,并描述了如何使用 Python 编程语言和 pandas、Numpy 和 scikit-learn 库来实现机器学习示例。通过本节的介绍,您应该能够解释基本的机器学习概念,并将这些概念与简单的机器学习示例结合使用。

在机器学习出现之前,传统计算机科学算法¹主要关注于根据已知数据计算答案。机器学习通过使用数据计算答案来回答基于可能但未知的内容的问题,从而扩展了计算机科学领域。

为了说明机器学习对计算机科学带来的变革的实质,假设您正在处理以下易于理解的数据集,描述了从 1971 年到 1982 年制造的福特野马及其燃油效率²,以每加仑英里数(mpg)的燃油消耗。使用 Python 编程语言和用于结构化数据的 pandas 库,您可以通过在计算机内存中将其实例化为 pandas 数据结构来准备该数据集以进行分析,称之为 DataFrame。

清单 A.1 在内存中创建一个准备好进行分析的 pandas DataFrame 数据集

import pandas as pd                                   ❶
import numpy as np                                    ❷
df = \                                                ❸
       pd.DataFrame([{"mpg": 18, "model year": 1971, "weight": 3139},
                    {"mpg": 13, "model year": 1975, "weight": 3169},
                    {"mpg": 25.5, "model year": 1977, "weight": 2755},
                    {"mpg": 23.6, "model year": 1980, "weight": 2905},
                    {"mpg": 27, "model year": 1982, "weight": 2790}])
print(df.to_string(index=False))                      ❹

❶ 导入 pandas 库并将其别名为 pd。

❷ 导入 NumPy 库并将其别名设为 np。

❸ 用于存储和管理结构化数据的 pandas DataFrame 可以通过使用包含每行数据的 Python 字典列表构建,其中每行使用字典实例指定,数据框列名为键,行内容为字典值。

❹ 为了避免列印每行的默认零基索引,使用 df.to_string(index=False) 代替 print(df)。

这样会产生一个输出,显示在图 A.4 的左侧作为表格。

A-04

图 A.4 中福特野马燃油效率数据集(左)和对应的散点图(右),基于同一数据集。(这个公开的数据集是从加州大学尔湾分校机器学习库获取的:archive.ics.uci.edu/ml/datasets/Auto+MPG.)

毫不奇怪,众所周知,计算机科学中的算法和数据结构(例如哈希表)可以用于回答关于数据集中已知内容的问题,比如一辆重 2,905 磅的福特野马的每加仑油耗效率。使用 pandas 可以回答这个问题

df[ df['weight'] == 2905 ]['mpg'].values

这会输出一个 NumPy⁴ 数组,其中包含了 mpg 列的单个元素,以及列表 A.1 中数据集的第四行的值:

array([23.6])

pandas DataFrame

本附录中使用 pandas 来演示对机器学习数据集的常见操作。虽然 pandas DataFrame 是一个易学易用的数据结构,但它无法处理不适合单个节点内存(网络上的计算机)的数据集。此外,pandas 并不是为那些可以在主要云计算提供商的云计算环境中使用分布式计算集群进行数据分析设计的。本附录将继续使用 pandas DataFrames 来介绍机器学习;然而,本书的其余部分重点关注可以处理比 pandas 更大数据集的 SQL 表和 PySpark DataFrames 。许多关于 pandas DataFrames 的概念直接适用于 PySpark 和 SQL。例如,第 A.3 节关于结构化数据集和监督机器学习的描述适用于不管是使用 pandas、PySpark 还是 SQL 管理的数据集。

那么一辆重 3,000 磅的福特野马的油耗如何?在机器学习出现之前,传统计算机科学算法无法回答这个问题。

然而,作为一个人类,你可以观察数据集(图 A.4 的右侧)并注意到数据集描述的相关福特野马之间的一个模式(一个重复的规律):随着车辆重量的增加,它们的燃油效率下降。如果要估计一辆重 3,000 磅的福特野马的燃油效率(数据集并未给出),你可以将这个模式的心理模型应用到估计答案,大约为每加仑 20 英里。

给定正确的机器学习算法,计算机可以学习数据集的软件模型(称为机器学习模型,将在第 A.3 节中更准确地定义),使得学习到的(也称为训练的)模型可以输出估计值,就像你推断出来的估计 3,000 磅福特野马燃油效率的心理模型一样。

scikit-learn,一种流行的机器学习库,⁵ 包括各种可供使用的机器学习算法,包括几种可以构建与数据集中观察到的模式相符的机器学习模型的算法。根据仅从重量列中的值构建模型的步骤显示如下。⁶

A.2 列出了福特野马数据集的简单模型

from sklearn.linear_model import LinearRegression       ❶
model = LinearRegression()                              ❷
model = \                                               ❸
       model.fit(df['weight'].values.reshape(len(df), 1), df['mpg'].values)

❶ 从 scikit-learn 库导入线性回归实现。

❷ 创建线性回归机器学习模型的实例。

❸ 使用福特野马数据集中的重量列作为模型输入和 mpg 值作为模型输出来训练(拟合)线性回归模型。reshape 函数将 df['weight'].values 返回的 NumPy 数组重新塑形为由单列组成的矩阵。由于 scikit-learn 要求模型输入为结构化矩阵,因此在这里需要重新塑形。

线性回归算法被广泛认为是机器学习中的基本算法之一,⁷ 训练(即“拟合”)机器学习模型实例基于传递给 LinearRegression 类的数据集。一旦通过 fit 方法训练,模型实例就可以回答诸如“一辆 3,000 磅的福特野马的预计燃油效率是多少?”这样的问题。

A.3 使用训练过的线性回归模型来估计 mpg

model.predict( np.array(3_000).reshape(1, 1) )[0]     ❶

❶ 将包含单个值 3,000(代表一辆 3,000 磅的福特野马)的数组重新塑形为一个具有一行一列的矩阵,并使用预测方法要求模型估计输出值。由于预测方法的输出以 NumPy 数组的形式返回,因此使用 [0] 索引从估计值的数组中检索第一个元素。

这输出

20.03370792

这代表着大约 20.03 英里每加仑(MPG)的估计值。机器学习模型还可以为车辆重量的其他值产生估计值。请注意,随后的代码对于已经熟悉 Python 但对机器学习新手来说更加简单直观,因此更容易理解。⁸

A.4 列出了对重量为 2,500、3,000 和 3,500 磅的车辆进行 MPG 估计的情况

ds = \                                            ❶
  np.array([[ model.predict(np.array(weight).reshape(1, 1))[0], weight] \
       for weight in [2_500, 3_000, 3_500] ])

df = \                                            ❷
       pd.DataFrame(data=ds, columns=['mpg_est', 'weight'])

print(df.to_string(index=False))                  ❸

❶ Python for 表达式遍历来自列表 [2_500, 3_000, 3_500] 的重量值。对于列表中的每个重量,表达式返回一个由两列组成的矩阵的行:左列是模型预测的 mpg 值,右列是重量本身的值。生成的矩阵存储在变量 ds 中。

❷ 使用 ds 矩阵实例化 pandas DataFrame,并以 mpg_est 和 weight 作为左右列的列名进行注释。

❸ 为了避免打印每一行的默认从零开始的索引,使用 df.to_string(index=False) 替代 print(df)

这将输出如图 A.5 左侧显示的作为 pandas DataFrame 的结果。图 A.5 右侧显示了由 LinearRegression 从原始数据集学习的模型具有虚线,可用于估计任意重量值的 mpg 值。

A-05

图 A.5 一个估计的 Ford Mustang 虚构燃油效率 mpg 的表格,其中给定的重量值由重量列(左侧)给出,并由连接表中数据点的线性模型(右侧)绘制

在本节中,你通过一个示例学到了机器学习问题、数据集和机器学习算法是如何事先准备好的。这意味着你不必完全理解问题的细微之处,如何为问题准备数据集,或者如何选择合适的机器学习算法来解决问题。在附录的其余部分,你将更深入地探索与机器学习工作的这些方面,并准备将你的理解应用到本书中的机器学习项目中去。

A.3 结构化数据集的机器学习

在前一节中,你已经了解了使用描述 Ford Mustang 燃油效率的示例数据集应用机器学习的实例。本节将教授应用机器学习到任意结构化数据集所需的概念。

对于本附录的目的,结构化数据集存储了关于

  • 相关对象,例如不同品牌和型号的汽车、标准普尔 500 指数中的上市公司、不同亚种的鸢尾花,或

  • 重复事件,例如赢得或失去的销售机会、按时或延迟的餐食送达或网站上的按钮点击

作为表格中的记录(通常是行),其中每个记录由至少两列但通常是三列或更多列的数字值组成。请注意,图 A.6 展示了一个具有 N 个观测值(以行表示的记录)和 M 列的结构化数据集,以及本节后面解释的相关术语。

A-06

图 A.6 机器学习用的结构化数据集的概念表示。数据集被组织成 N 行的观察,每一行都有一个观察的标签列,以及其他 M-1 列(剩下的)特征列。按照惯例,第一列通常是观察标签。在有关事件的观察中(例如标记销售机会是否成功),标签有时被称为事件的“结果”。在使用数据集进行机器学习时,标签通常被描述为“实际值”、“目标”或“真值”。在机器学习中,变量 y 和 X 通常用来表示标签和特征。

用于结构化数据集的监督式机器学习算法(例如第 A.2 节中使用的线性回归算法)训练(输出)一个机器学习模型,该模型可以使用记录中的其他列的值(特征)来估计记录中某列(标签)的值。

在监督式机器学习中,“标签”是机器学习模型在训练过程中用作估计结果的数值。按照惯例,机器学习从业者通常使用结构化机器学习数据集的第一列来存储标签的值(通常使用变量 y 进行指示),并使用其余列(使用变量 X 进行指示)存储特征。特征是监督式机器学习模型用来估计标签值的数值。当使用由标签和特征组成的记录集来训练机器学习模型时,这些记录被描述为“训练数据集”。

“标签”这个术语在机器学习中具有歧义。

不幸的是,在机器学习社区中,对于“标签”一词的使用并不一致,这给机器学习的初学者带来了很多困惑。尽管在结构化数据集中,当观察值描述相关对象(例如福特野马)时,该词频繁使用,但当数据集中的观察值描述事件(例如销售机会)时,与“标签”同义的词为“结果”。具有统计学背景的机器学习从业者通常将标签描述为“因变量”,将特征描述为“自变量”。其他人将“目标值”或“实际值”与“标签”同义使用。本书旨在简化术语,并尽可能使用“标签”这个词。

机器学习领域远比监督式机器学习更广泛,包括无监督机器学习(标签不被使用或不可用)、强化学习(算法寻求最大化奖励)、生成对抗网络(神经网络竞争生成和分类数据)等等。然而,即使在谷歌这样一个在机器学习应用上领先的公司,超过 80%的投入生产的机器学习模型都是基于使用结构化数据的监督式机器学习算法。因此,本书完全专注于这个重要的机器学习领域。¹¹

监督式机器学习的数学定义

虽然本书不要求这样做,但是这里有一个更正式的监督式机器学习定义:如果y[i]是要从索引 i 的记录中估计的值,则监督式机器学习模型可以被描述为一个函数 F,它基于记录中除y[i]之外的列(即其他列)的值X[i]输出估计值F(X[i])。监督式机器学习模型的训练过程描述了基于训练数据集y, X构建函数 F 的过程。训练算法通常使用y, X, F(X)进行迭代,其中基础F(X)是从 F 的某个随机初始化生成的。

为了说明监督式机器学习,回想一下在 A.2 节中您学到的描述福特野马燃油效率的结构化数据集。训练数据集仅包含两列:一个标签列,其中包含 mpg 值,和一个带有车辆重量值的单个特征。该数据集也作为示例在图 A.7 中显示。基于该数据集的相应监督式机器学习模型可以根据车重列的值估计 1971 年到 1982 年款福特野马的平均燃油效率(mpg 列)。

A-07

图 A.7 是一份关于福特野马燃油效率的样本数据集,以每加仑英里数为单位。

大多数机器学习算法都是一个迭代过程的训练过程(如图 A.8 所示),从使用机器学习算法生成第一个迭代的机器学习模型开始。一些算法使用训练数据集创建模型的第一个迭代,但这不是必需的:大多数基于神经网络的深度学习模型是根据简单的随机方案进行初始化的。

一旦模型的第一次迭代完成,它就会根据训练数据集中的特征输出第一次迭代的估计值(预测)。 接下来,机器学习算法通过比较估计值与训练数据集中的标签的接近程度来评估估计的质量。 用于评估机器学习模型质量(即性能)的可量化措施称为损失(也称为成本目标函数),在第 A.4 节中有更详细的介绍。 对于流程的下一次迭代,算法使用损失以及训练数据集来生成下一个模型的迭代。 在图 A.8 中显示的过程的单次迭代后,非迭代机器学习算法可以输出一个机器学习模型。

A-08

图 A.8 机器学习算法产生的初始机器学习模型用于输出估计的标签值(y_est),这是基于记录的特征值的记录的估计。 然后通过更改模型(特别是模型参数)来迭代改进机器学习模型以改善模型性能得分(损失),该性能得分基于估计值和标签值的比较。

迭代机器学习算法在如何决定停止训练过程上有所不同; 一些具有内置标准用于停止迭代,而其他一些则要求机器学习从业者提供显式的停止标准,或者提供用于决定何时停止的时间表或函数。 在涵盖不同机器学习算法的停止标准时,如何处理这些停止标准的其他细节始于第 A.4 节。

到目前为止,本附录直观地使用了短语数字值,但没有提供适用于机器学习的数字值的清晰定义。 如第 A.1 节所述,机器学习算法需要准备定制的数据集,并且在使用任意数据值时可能会失败。 因此,机器学习从业者必须清楚地了解结构化数据集中存在的数字值。 在图 A.9 中详细说明了基于值对数字变量进行分类的详细分类法(源自统计学中的类似分类法)。

A-09

图 A.9 数字值的类别适用于受监督机器学习,这是根据斯坦利·史密斯·史蒂文斯(mng.bz/0w4z)对统计变量进行分类的著名框架改编而来。 数字值可以被分类为互斥的连续值和分类值子集。 连续值可以进一步分类为区间或比率,而分类值可以是名义或有序的。

  • 本附录和本书的其余部分侧重于具有连续和分类变量的机器学习,特别是使用间隔、比率和名义数据。本书在可能的情况下,为处理有序值提供提示和技巧;然而,本书中的项目不涵盖这两种值的任何具体用例。作为机器学习从业者,你需要准备并将混乱的现实世界数据集转换为机器学习能够正确使用的数值。第一部分的大部分内容都致力于磨练你在这个领域的技能。

  • 在这一节中,你了解了训练,即机器学习算法执行的一系列步骤,以生成机器学习模型。在训练过程中,算法使用结构化数据集的一个子集(称为训练数据集)来生成模型,然后可以使用该模型输出估计值(也称为预测),给定记录的特征值。

- A.4 结构化数据集的回归

  • 在本节中,你将了解两种常用的监督式机器学习问题类别:回归和分类。本节介绍了损失的定义(也称为成本或目标函数),这是对机器学习模型在标签和特征数据集上的性能的定量和技术性度量。通过本节的结论,你将熟悉与这些问题相关的术语,并回顾回归应用。

  • 结构化数据集的回归是一个监督式机器学习问题,其中标签是一个连续的(如图 A.9 中定义的)变量。例如,在第 A.2 节中估计福特野马的燃油效率时,你处理了一个回归问题的实例,因为 mpg 是一个连续(更确切地说是一个间隔)值。在第 A.5 节中,你将了解更多关于结构化数据集的分类,这是一个监督式机器学习问题,其中标签是一个分类变量。对这些机器学习问题的全面理解对于机器学习从业者至关重要,因为正如在第 A.3 节中解释的那样,回归和分类占据了像谷歌这样的顶级信息技术公司生产机器学习模型的 80%以上。

根据福特野马数据集的回归问题示例和相关损失计算,图 A.10 中显示了模型损失的平方误差。回想一下,在第 A.2 节中,您扮演了机器学习算法的角色,并推断了一个用于估计每加仑英里数(mpg)值的心理模型。假设您在本节中再次扮演相同的角色,但在这里,您通过取重量值并将其乘以 0.005 来估计 mpg 值。由于训练过程是迭代的,0.005 的值只是一个初始(也许是幸运的)但合理的猜测。更好的猜测方法将很快介绍。与此同时,基于此计算的估计值显示在图 A.10 的 Estimate 列中。

A-10

图 A.10 在回归问题中,许多机器学习实践者首先应用均方误差损失函数来建立基线,然后再转向更复杂的机器学习方法并尝试更复杂的损失函数。

从图 A.8 中解释的过程中回想一下,下一步是评估损失,这是机器学习模型产生的估计质量的可量化度量。损失函数的选择取决于机器学习问题,更精确地说是问题中标签和估计值的数字类型。

在回归问题中,最常用的损失函数之一是 均方误差(MSE),定义为个别平方误差(也称为 残差差值)值的算术平均值,如图 A.10 的 Squared Error 列所示。

提供生成图 A.10 中各列值的 Python 代码。

清单 A.5 计算模型损失的平方误差

df = pd.DataFrame([{"y": 18,  "X": 3139},           ❶
                   {"y": 13, "X": 3169},
                   {"y": 25.5, "X": 2755},
                   {"y": 23.6, "X": 2905},
                   {"y": 27, "X": 2790}])

W = np.array([0.007])[:, None]                      ❷

df['y_est'] = df[['X']] @ W                         ❸

df['error'] = df['y'] - df['y_est']                 ❹
df['squared_error'] = df['error'] ** 2              ❺

df[['squared_error', 'error', 'y_est', 'y', 'X']]   ❻

❶ 将福特野马数据集实例化为 pandas DataFrame。为简洁起见,并遵循已接受的做法,本示例使用 y 表示标签,X 表示特征。有关更详细的说明,请参阅清单 A.1。

❷ 变量名 W 通常用于表示机器学习模型参数的值。注意,NumPy 的切片表示法 [:, None] 等效于使用 reshape(1,1) 将 W 重塑为下一步中所需的矩阵。

❸ 表达式 df[['X']] 使用的双方括号符号返回特征值矩阵,并使用 @ 操作进行矩阵乘积,其中包含生成结果的单列的模型参数值的矩阵,该列包含权重乘以 0.005。在这里使用矩阵乘法,因为它可以轻松扩展到许多特征和模型参数值,而无需更改实现。

❹ 误差只是标签与估计值之间的差异。

❺ 使用 Python 中的 ** 指数表示法来计算平方误差。

❻ 列名列表被指定,以确保输出的列顺序与图 A.10 中显示的顺序相对应。

假设均方误差的值存储在名为 squared_error 的 pandas DataFrame 列中,则只需使用简单地计算即可得到 MSE 的相应值

df['squared_error'].mean()

在图 A.10 中的值的情况下,会输出一个近似等于的数字

80.71

正如您所期望的,由于 W 的值是随机选择的,均方误差的值远非零。在图 A.11 中,您可以探索对 W 的各种随机选择的结果(子图(a)对应于使用 0.005 作为 W),以及随机值 W 和相应均方误差之间的关系。

A-11

图 A.11 子图 a—d 说明了选择替代的、随机选择的 W 值对均方误差的影响。请注意,在所有情况下,W 的值对应于通过特征和标签值对的点确定的直线的斜率。与图 A.5 中使用的线性回归模型不同,该图中的线经过原点,因此无法捕捉燃油效率中更低重量对应的模式。

由于基于 W 的基于线性的模型非常简单,所以不需要随机猜测 W 的值,可以依靠解决估计最小化数据集均方误差的问题的分析解。这个分析解被称为普通最小二乘法(OLS)公式(XTX)(-1)X^Ty,可以使用 Python 代码来实现。

列表 A.6 线性回归的普通最小二乘法解

X = df.X.values                           ❶
y = df.y.values                           ❷

W = \
  np.linalg.inv( np.array(X.T @ X,        ❸
                     dtype = np.float,
                     ndmin = 2) )
                @ np.array( X.T @ y,      ❹
                     dtype = np.float,
                     ndmin = 2)
W

❶ 将 X 分配为特征值的 NumPy 数组。

❷ 将 y 分配为标签值的 NumPy 数组。

❸ 计算 OLS 公式中的 X.T @ X 表达式,将其转换为 NumPy 矩阵(使用 ndmin=2),并使用 np.linalg.inv 倒置所得矩阵。

❹ 用 OLS 公式中的 X.T @ y 乘以倒置矩阵。

这返回一个 1 × 1 的矩阵:

array([[0.00713444]])

您可以通过使用列表 A.5 中的示例代码确认,当使用基于 OLS 公式的最优 W 值时,会产生 40.88 的 MSE。这意味着什么?请注意,与图 A.5 中显示的 LinearRegression 模型不同,仅基于 W 的模型不够复杂,无法捕捉数据中的基本模式:较大的权值会导致较低的燃油效率。当然,仅通过对图 A.11 中的子图进行目测检查,原因是显而易见的:基于单个 W 参数的线必须经过原点(即 y 截距为零);因此,不可能使用它来模拟 mpg 和 weight 数据列中大于零值之间的倒数关系。

然而,当处理更复杂的数据集时,数据维度过多以至于难以进行可视化,可视化对于判断模型是否足够灵活以捕捉到数据集中期望的模式没有帮助。除了依赖可视化,你可以进行额外的测试来评估模型的灵活性。在回归问题中,你可以将你的模型与平均标签值估计的均方误差进行比较,使用来自训练数据集的平均标签值。例如,使用训练数据集的 DataFrame,可以通过如下方式进行评估:

np.mean((df['y'] - df['y'].mean()) ** 2)

应该得到一个大约如下的输出

27.03

最优模型的均方误差为 40.88,而使用平均值的简单均方误差为 27.03,这表明该模型不够复杂(参数太少)以捕捉数据中期望的模式。

A.5 结构化数据集的分类

本节将向您介绍并演示许多机器学习算法用于训练分类模型的交叉熵损失函数。在理解了交叉熵的基础上,本节将引导您完成实现标签的独热编码及如何使用编码后的标签计算交叉熵损失值的步骤。最后,本节将教您使用 NumPy、pandas 和 scikit-learn 的最佳实践,以便您可以训练和评估一个基准的 LogisticRegression 分类模型。

从第 A.4 节可以回顾到,对于结构化数据集的分类是一个机器学习问题,其目标是从特征中估计出一个分类标签的值。例如,图 A.12 中展示的福特野马数据集可以用来训练一个分类模型(也被称为分类器),来估计车型年份的十年代,可以选择 1970 年代和 1980 年代,利用 mpg(燃油效率)和重量两个特征。

A-12

图 A.12 使用名为 1970s 和 1980s 的列对车型年份标签进行独热编码,用于表示福特野马车的十年代(左)。编码的独热性指的是在用于编码的列中,每一行都只有一个 1 值;其余值为 0。数据集的散点图(右)以“x”和“•”标记分别表示 1970 年代和 1980 年代的车辆。对于网格上的任何位置(不限于标记所示的位置),一个经过训练的分类模型必须能够判断车辆是在 1970 年代还是 1980 年代生产的。

虽然均方误差损失函数可用于一些分类问题,但许多基线分类器使用交叉熵损失。用于优化交叉熵损失的机器学习算法包括 logistic 回归(这是一种用于分类的机器学习算法,不应与回归机器学习问题混淆)和神经网络等。流行的决策树和随机森林算法使用的一个密切相关的损失函数称为基尼不纯度。本节首先解释使用交叉熵损失进行分类,以便为您理解更高级的分类机器学习算法所使用的交叉熵的变体做准备。

与均方损失不同,均方误差损失期望回归模型的输出为单个数值,而交叉熵损失期望分类模型为分类标签的每个可能值输出一个概率。继续使用用于估计福特野马车型年代的工作数据集,图 A.13 展示了基于数据集的假设分类模型的四个信息示例的输出。

在图 A.13 左上方显示的示例 1 中,分类模型将 1970 年代的估计概率为 0.6。由于概率必须加起来等于 1,因此 1980 年代的估计概率为 0.4。在这个例子中,相应的损失值(显示在示例标题中)约为 0.51,这个值显著大于零,因为分类模型虽然估计了正确的值(1970 年代),但对估计缺乏信心。从图 A.13 右上方的示例 2 中可以看出,当模型完全不确定正确的值,缺乏对 1970 年代或 1980 年代的估计信心或偏好(由于两者的概率均为 0.5)时,损失进一步增加,达到约 0.6931。简而言之,当分类模型输出正确标签估计的高概率(实际上是高置信度)时,交叉熵损失函数向零减少,否则增加。

如果分类模型在估计标签值时不正确,损失函数会进一步增加,即使在完全不确定的情况下也是如此,就像图 A.13 左下方的示例 3 中所报告的损失数字一样。在这个例子中,正确的标签值是 1980 年代,而模型对 1970 年代的估计比对 1980 年代的稍微有点信心,分别为 0.6 和 0.4。请注意,损失值进一步增加,从示例(3)中的 0.9163 增加到图 A.13 右下方的示例 4 中的 4.6052,其中分类模型对错误估计非常有信心。

A-13

图 A.13(1)模型对正确值的信心略高。 (2)模型完全不确定选择哪个值。 (3)模型对错误值的信心略高。 (4)模型对错误值的信心很高。

由于分类模型的输出由标签值的概率(概率分布)组成,所以在训练或测试机器学习模型之前,工作数据集中的原始标签必须被编码(转换)成相同的格式。编码的结果显示在图 A.12 左侧的 1970 年代和 1980 年代列中。这种转换过程被称为one-hot 编码,指的是在编码标签的列中,整行中只有一个值被设置为一(1)。

交叉熵损失函数可以用 NumPy 操作来定义。 xe_loss 函数定义实现了对给定分类模型输出 y_est 和相应的 one-hot 编码的标签值 y 数组的交叉熵损失的计算。请注意,使用此实现时,需要注意不要混淆标签和模型输出数组参数,因为 np.log 函数分别对值 0 和 1 输出 -Inf 和 0.0。

列表 A.7 xe_loss 计算并返回交叉熵损失

def xe_loss(y, y_est):
  return -np.sum( y * np.log( y_est ) )

print( xe_loss ( np.array([1., 0.]),
                     np.array([.6, .4]) ) )       ❶
print( xe_loss ( np.array([1., 0.]),
                     np.array([.5, .5]) ) )       ❷
print( xe_loss ( np.array([0., 1.]),
                     np.array([.6, .4]) ) )       ❸
print( xe_loss ( np.array([0., 1.]),
                     np.array([.99, .01]) ))      ❹

❶ 根据图 A.13 的例 3,计算损失值为 0.9163。

❷ 根据图 A.13 的例 2,计算损失值为 0.6931。

❸ 根据图 A.13 的例 3,计算损失值为 0.9163。

❹ 根据图 A.13 的例 4,计算损失值为 4.6052。

运行列表 A.7 中的代码,输出对应于图 A.13 中示例 1—4 的以下交叉熵损失值:

0.5108256237659907
0.6931471805599453
0.916290731874155
4.605170185988091

交叉熵损失的数学定义。

以下是交叉熵损失的数学描述:给定单个训练示例 yX 的标签和特征,函数定义为 A-13_EQ01,其中 K 是分类标签变量的值的数量,y[k] 是 one-hot 编码标签 y 中特定标签值的概率, A-13_EQ02 是分类模型产生的估计 A-13_EQ03 的特定标签值的概率估计。

此部分中到目前为止使用的示例依赖于已经为您进行独热编码的标签值。在实践中,在训练分类模型之前,您必须实现标签编码。虽然可以使用一套全面的 scikit-learn 类来对模型年份列标签进行独热编码,¹⁵ ,但由于在附录中数据集是作为一个 pandas DataFrame 实例化的,所以更容易使用通用的 pandas get_dummies 方法进行标签编码。这个方法的奇怪的 get_dummies 命名来自于虚拟变量,这是一个用于描述二进制指示变量的术语,它们要么是 1 表示存在,要么是 0 表示不存在一个值。

给定数据集的标签和特征作为 pandas DataFrame,将 get_dummies 方法直接应用于模型年份标签。

清单 A.8 使用 get_dummies 对分类标签进行编码

import pandas as pd

df = \                                                               ❶
  pd.DataFrame([{"model year": 1971, "mpg": 18,  "weight": 3139},
                    {"model year": 1975, "mpg": 13, "weight": 3169},
                    {"model year": 1977, "mpg": 25.5,  "weight": 2755},
                    { "model year": 1980, "mpg": 23.6, "weight": 2905},
                    {"model year": 1982, "mpg": 27,  "weight": 2790}])

enc_df = \                                                           ❷
       pd.get_dummies(df['model year'], prefix='le', sparse=False)
print(enc_df.to_string(index=False))                                 ❸

❶ 将数据集实例化为 pandas DataFrame。由于此实例化使用模型年份作为标签,因此它放置在前导列中。

❷ 使用带有 pandas Series 的 get_dummies 可以识别系列中的唯一值集,并为集合中的每个值创建一个新列。prefix 参数确保每个新列都使用指定的前缀命名。将 sparse 设置为 True 可以导致结果 DataFrame 的内存利用率降低,但不保证。具有较大数量的不同值和对应更多列的标签在独热编码格式下受益于由 sparse set 为 True 启用的稀疏数组表示。

❸ 不打印以零为基础的索引的 enc_df DataFrame。

这产生了

le_1971  le_1975  le_1977  le_1980  le_1982
       1        0        0        0        0
       0        1        0        0        0
       0        0        1        0        0
       0        0        0        1        0
       0        0        0        0        1

这不是所需的编码。尽管您可以轻松地实现将车辆的确切年份从列名转换为模型十年的代码,分箱是一种替代方法和更灵活的方法,用于执行此用例的标签编码。使用 pandas cut 方法,您可以将标签值“分箱”为一个范围:

pd.cut(df['model year'], bins=[1969, 1979, 1989])

它输出一个区间范围的 pandas.Series:

0    (1969, 1979]
1    (1969, 1979]
2    (1969, 1979]
3    (1979, 1989]
4    (1979, 1989]
Name: model year, dtype: category
Categories (2, interval[int64]): [(1969, 1979] < (1979, 1989]]

注意到前三辆车是在 1970 年代正确放置的(1969 年被排除在外,如括号所示),而其余的车辆则放置在 1980 年代。

结合标签分箱和 get_dummies 进行独热编码,

enc_df = pd.get_dummies(pd.cut(df['model year'], bins=[1969, 1979, 1989]),
               prefix='le', sparse=False)
print(enc_df.to_string(index = False))

输出了图 A.12 所示的所需编码:

le_(1969, 1979]  le_(1979, 1989]
               1                0
               1                0
               1                0
               0                1
               0                1

在使用编码值评估交叉熵损失函数之前,将标签编码的列与原始数据集合并,用编码值替换原始标签值很方便:

enc_df = pd.get_dummies(pd.cut(df['model year'], bins=[1969, 1979, 1989]),
               prefix='le', sparse=False)
          .join(df[df.columns[1:]])

print(enc_df.to_string(index = False))

它的结果是

le_(1969, 1979]  le_(1979, 1989]   mpg  weight
               1                0  18.0    3139
               1                0  13.0    3169
               1                0  25.5    2755
               0                1  23.6    2905
               0                1  27.0    2790

此时,数据集已准备好分割为用于训练的标签和特征,并转换为 NumPy 数组。从标签值开始,

y_train = df[ df.columns [df.columns.str.startswith('le_') == True] ].values
print(y_train)

输出

array([[1, 0],
       [1, 0],
       [1, 0],
       [0, 1],
       [0, 1]], dtype=uint8)

要将特征值放置到 NumPy X_train 数组中,使用

X_train = df [['mpg', 'weight']].values
print(X_train)

打印出

array([[  18\. , 3139\. ],
       [  13\. , 3169\. ],
       [  25.5, 2755\. ],
       [  23.6, 2905\. ],
       [  27\. , 2790\. ]])

此时,您已经准备好训练一个 LogisticRegression 分类器模型,

from sklearn.linear_model import LogisticRegression
model = LogisticRegression(solver='liblinear')
model.fit(X_train, y_train.argmax(axis = 1))

并计算交叉熵损失,

def cross_entropy_loss(y, y_est):
  xe = -np.sum(y * np.log (y_est))
  return xe

cross_entropy_loss(y_train, model.predict_proba(X_train))

输出为

2.314862688295351

A.6 训练监督机器学习模型

当训练机器学习模型时,几乎永远不会将整个结构化数据集用作训练数据集。相反,大多数机器学习从业者遵循的模式是将初始数据集划分为两个相互排斥的子集:开发(dev)数据集和测试(也称为保留)数据集。

A-14

图 A.14 一旦机器学习项目数据集被分割以提取保留的测试数据集,通常会直接开始探索性数据分析和机器学习模型训练,换句话说,使用开发数据集作为训练数据集(左侧)。一个更成熟的机器学习训练工作流程可以帮助及早检测过拟合并优化超参数,包括将开发数据集进一步分割为训练和验证数据集,以及使用交叉验证与开发数据集。

许多机器学习领域的新手不熟悉开发数据集的概念。虽然可以直接将其用作训练数据集(许多在线课程和教程使用这种简化方法),但为了生产训练机器学习需要更健壮的方法。这两种方法之间的区别在图 A.14 中有所说明。如图 A.14 右侧所示,开发数据集进一步分为训练和验证数据集。与以前一样,训练数据集的目的是训练机器学习模型;但验证(或评估)数据集的目的是估计训练好的机器学习模型在保留(测试)数据集上的预期性能。例如,在福特野马燃油效率数据集中,可以随机选择一个记录放入测试数据集,四个记录放入开发数据集。接下来,再次从开发数据集中随机选择一个记录放入验证数据集,其余三个记录放入训练数据集以训练机器学习模型。

由于验证数据集的目的是估计训练机器学习模型在测试数据集上的表现,只在验证数据集中拥有一条记录是一个问题。然而,尽可能多地利用开发数据集进行训练也很有价值。解决这个困境的一个方法是使用一种称为K-fold 交叉验证的技术,如图 A.15 所示。使用 K 折交叉验证的关键思想是通过 K 次重复使用开发数据集训练 K 个不同的机器学习模型,每次将开发数据集划分为 K 个折叠,其中 K-1 折叠被用作训练数据集,剩下的第 K 个折叠被用作验证数据集。图 A.15 中的示例使用三个分区,即三折交叉验证。当数据集中的观测值不能被 K 折折叠时,具有最小观测值数量的分区被指定为验证数据集。否则,所有分区的观测值数量都相同。

接下来,使用 K-1 训练数据集分区分别训练 K 个不同的机器学习模型,并使用剩余的第 K 个验证分区进行验证。因此,在图 A.15 的示例中,使用每个三个不同分区中的两个训练折叠来训练三个单独的机器学习模型。

A-15

图 A.15 K 折交叉验证技术包括训练 K 个不同的机器学习模型并报告基于每个 K 个独立模型获得的训练和验证损失(和度量)的平均值。请注意,K 个模型中的每一个都是使用开发数据集的不同验证分区进行验证的,使用开发数据集的剩余部分进行训练。

开发数据集可以按原样用作训练数据集,换句话说,作为训练数据集。然而,对于生产机器学习模型来说,这很少发生。

相反,开发数据集被进一步划分为训练数据集和验证数据集。第四章详细解释和阐述了这个过程,但是对于本附录的目的,您可以预计验证数据集用于估计机器学习模型在未使用的(测试)数据集上的性能。

^(1.)Donald E. Knuth 在他的经典著作 计算机编程艺术(Addison-Wesley Professional,2011 年)中提供了传统计算机科学算法的全面回顾。

^(2.)这些值基于公开可用的加利福尼亚大学尔湾机器学习库archive.ics.uci.edu/ml/datasets/Auto+MPG 中的数据集

^(3.)也称为面板或表格数据,结构化数据集是基于行和列组织的值

NumPy 是一个用于高性能数值计算的 Python 库。pandas 封装了 NumPy 库并将其用于高性能数据分析。

scikit-learn (scikit-learn.org)被设计用于机器学习与内存数据集,就像这里用于说明机器学习的一个简单示例一样。使用云计算的大内存数据集进行机器学习需要其他框架。

尽管它通常与现代统计学联系在一起,但线性回归源自高斯和拉格朗日在 19 世纪早期关于行星运动预测的工作。

以及许多其他科学领域,包括统计学,计量经济学,天文学等等。

可以以更简洁的方式实现这段代码,但需要解释更多的 NumPy 和 pandas 概念,包括多维数组。如果你对这个主题和张量的更一般的主题感兴趣,请参阅第五章。

为了数学上的精确性,观察结果被期望是统计独立和同分布的,尽管大多数真实世界的数据集存在这一定义的灰色地带。

因此,当你听到标签被描述为“目标值”时,不应感到惊讶。

如果你有兴趣扩展你的机器学习知识超出监督学习,并愿意阅读更多数学密集型的书籍,请查看 Stuart Russell 和 Peter Norvig(Pearson,2020)的《人工智能:一种现代方法》;Christopher Bishop(Springer,2006)的《模式识别与机器学习》;Ian Goodfellow,Yoshua Bengio 和 Aaron Courville(麻省理工学院出版社,2016)的《深度学习》;以及 Trevor Hastie,Robert Tibshirani 和 Jerome Friedman(Springer,2016)的《统计学习的要素》。

如果标签是二进制的,并且编码为—1 或 1,那么可以使用均方误差来进行分类问题的评估。

均匀概率分布的这种状态也被称为最大熵

你可能已经注意到,这几乎是 e 常数的值,因为这里的交叉熵计算使用的是自然对数。

scikit-learn 提供了一套全面的类,包括 LabelEncoder 和 LabelBinarizer,旨在帮助进行标签编码,以及 OneHotEncoder 和 OrdinalEncoder 用于特征编码;这些类最适合于不使用 pandas 存储和管理数据集的开发场景。例如,如果你的整个数据集都存储为 NumPy 数组,那么这些 scikit-learn 类是一个不错的选择。然而,如果你的数据集是一个 pandas DataFrame 或一个 pandas Series,对于标签和特征编码,直接应用 pandas 自己的 get_dummies 方法会更简单。

^(16.)这里的“相互排斥”意味着在分区之前会移除重复记录,并在去重之后,任何给定的记录都存在于其中一个子集中。

附录 B:使用 Docker 入门

如果您曾经使用过公共云来启用您的应用程序进行自动缩放,即您可以轻松地添加或删除应用程序集群的计算节点功能,那么您已经使用了虚拟服务实例。您甚至可能使用过类似 ssh 的程序登录到您的实例,然后通过这个 ssh 会话远程管理它们。乍一看,Docker 容器实例似乎与虚拟服务器没有什么不同。如果您通过 ssh 登录到 Docker 容器中,与通过 AWS EC2 等公共云服务托管的虚拟服务器的会话相比,您可能甚至感觉不出差别。但是,虽然与 Docker 有关的传统公共云服务虚拟服务器存在相似之处,但 Docker 提供的重要功能是需要知道的。

一个了解 Docker 的可接近方式是将其看作是轻量级(与虚拟服务器相比)的虚拟化。这包括以下维度:

  • 存储方面,Docker 镜像快照占用的磁盘空间比传统的虚拟服务器/机器镜像更少。

  • 内存方面,由于 Docker 容器实例消耗的内存比客户机实例(虚拟服务器)少。

  • 启动速度方面,Docker 容器比其虚拟服务器等同物启动得更快。

  • 性能方面,由于运行在 Docker 容器中的程序与运行在虚拟客户机实例中的程序相比,几乎没有任何 CPU 开销。

然而,Docker 容器和虚拟服务器之间的差异在核心硬件/软件级别上更加根本。传统的虚拟化技术(例如 VMWare、Xen)将主机计算机硬件进行虚拟化,或者创建基于软件的代理来实现底层硬件组件的虚拟化,包括中央处理单元、存储、网络设备等,通过在硬盘和内存中实例化带有操作系统副本和设备驱动程序及其他支持软件的客户机环境。相比之下,Docker 容器虚拟化操作系统,以便每个客户机容器实例共享相同的操作系统,但在操作上却像单独拥有对整个操作系统的隔离访问权限一样。

B.1 使用 Docker 入门

如果您的环境中没有安装 Docker,则可以通过访问labs.play-with-docker.com/,获取一个带有 Docker 的实验室环境。

Docker 是一个多义词,它描述了各种 Docker 技术组件(例如 Docker 引擎、Docker Swarm 等)、Docker 公司本身,以及在 hub.docker.com 维护的 Docker 容器镜像注册表。安装 Docker 引擎时,您的环境中没有任何 Docker 镜像安装。

假设您已经正确配置了 Docker 引擎和 Docker 主机软件,那么您可以通过在 Shell 环境中运行以下命令的变体来使用 Docker,即经典的 hello-world 程序:

docker run hello-world

假设您尚未下载(pull)hello-world Docker 镜像,这应该输出以下内容:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
0e03bdcc26d7: Pull complete
Digest: sha256:7f0a9f93b4aa3022c3a4c147a449bf11e094
➥ 1a1fd0bf4a8e6c9408b2600777c5
Status: Downloaded newer image for hello-world:latest

Hello from Docker!

重要的是要理解 run 是一个组合的 Docker 命令,在幕后执行多个操作。以下将解释 run 执行的命令,以帮助您理解它的作用。

由于您已经完成了使用 Docker 的基本 hello-world 风格示例,让我们尝试使用流行的 nginx web 服务器来进行一个稍微复杂一些的示例。要从 hub.docker.com(也称为 Docker Hub)下载 Docker 镜像到本地 Docker 主机,您可以执行如下的 pull 命令:

docker pull nginx

这应该输出以下内容:

docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
bf5952930446: Pull complete
cb9a6de05e5a: Pull complete
9513ea0afb93: Pull complete
b49ea07d2e93: Pull complete
a5e4a503d449: Pull complete
Digest: sha256:b0ad43f7ee5edbc0effbc14645ae7055e21b
➥ c1973aee5150745632a24a752661
Status: Downloaded newer image for nginx:latest
docker.io/library/nginx:latest

注意 由于 nginx 镜像可能在此书创建之时已经发生了变化,因此您在消息中看到的哈希码可能与示例中的不对应,但是本附录中的概念都是适用的,不管示例中的哈希码具体值是什么。

pull 命令生成的消息表明 Docker 默认使用了 nginx 镜像的标签 latest。由于也可以指定完全限定的域名从而让 Docker 拉取镜像,Docker 默认也使用 Docker Hub FQN docker.io/library 作为唯一标识 nginx 镜像的前缀。

注意 pull 命令返回的消息中提到的各种哈希码,例如

bf5952930446: Pull complete.

在 pull 命令执行时,您观察到的每个 Pull complete 消息前面的哈希码值(以及您运行 pull 命令时观察到的下载进度消息)都是 Docker 容器镜像所使用的联合文件系统中的一个层的唯一标识符或指纹。相比之下,跟随 Digest: sha256: 消息的哈希码是整个 nginx Docker 镜像的唯一指纹。

一旦镜像位于您的 Docker 主机服务器上,您就可以使用它来创建 Docker 容器的实例。该容器是前面描述的轻量级虚拟机,或者是运行在与 Docker 主机服务器操作系统的其余部分近乎隔离的虚拟客户操作系统环境。

要创建容器,您可以执行以下命令

docker create  nginx

这应该返回类似以下的唯一容器 ID:

cf33323ab079979200429323c2a6043935399653b4bc7a5c86
➥ 553220451cfdb1

您可以在命令中使用完整且冗长的容器 ID,也可以使用 Docker 允许您指定容器 ID 的前几个字符,只要在您的 Docker 主机环境中是唯一的即可。要确认容器是否已在您的环境中创建,您可以使用 docker ls -a | grep <CONTAINER_ID> 命令,其中 docker ls -a 列出您环境中的所有容器,并且管道过滤器 grep 命令筛选出您需要的容器。例如,由于我创建的容器的 ID 以 cf33 开头,我可以执行

docker ps -a | grep cf33

在我的情况下,输出如下:

cf33323ab079        nginx                 
➥ "/docker-entrypoint...." 
➥ 5 minutes ago       
➥     Created                                       ecstatic_gagarin

注意,Docker 自动为容器创建了一个易于记忆和在命令行中指定的可读的 Docker 容器 ID,名为 ecstatic_gagarin,与哈希码相比更容易记忆和指定。此外,由于容器刚刚从镜像创建而从未启动,因此容器的状态为已创建。要启动容器,您可以执行

docker start -p 8080:80 CONTAINER_ID

用您的容器 ID 值或前缀替换 CONTAINER_ID。输出只是回显容器 ID,但您可以通过重新运行确认容器已更改状态

docker ps -a | grep CONTAINER_ID

这应该会报告容器的正常运行时间,类似于以下内容:

cf33323ab079        nginx                          
➥ "/docker-entrypoint...."   11 minutes ago      
➥ Up 2 minutes              80/tcp              
➥ ecstatic_gagarin

尽管您可能期望您应该能够访问 NGINX Web 服务器,因为您启动了一个 nginx 容器,但这是不正确的。简单地启动容器不包括将在客户容器环境中打开的端口映射(暴露)到主机环境的步骤。要解决此问题,您可以使用

docker stop CONTAINER_ID

这应该会回显您的 CONTAINER_ID 值。

接下来,使用端口 80(Web 服务器 HTTP 端口)作为主机 Docker 环境的端口 8080 重新运行容器。可以通过以下方式执行:

docker run -p 8080:80 nginx

这将调用一个新的 Docker 容器的新实例,并在终端中返回 NGINX 服务的日志消息。此时,如果您打开 Web 浏览器并导航到您的 Docker 主机服务器 IP 地址的端口 8080,例如通过导航到 127.0.0.1:8080,您应该会看到带有消息的 HTML 页面:

Welcome to nginx!

此时,Docker 创建的容器实例的行为与您执行 docker start 时观察到的不同。在这种情况下,如果您在终端会话中按下 Ctrl-C,容器实例将终止,您可以通过重新运行 docker ps 轻松确认。这次,docker ps 不应显示任何正在运行的容器实例,因为您刚刚通过按下 Ctrl-C 关闭了它。

为了防止 Docker 容器实例接管您的终端会话,您可以通过指定 -d 参数在分离模式下重新运行它:

docker run -d -p 8080:80 nginx

这应该会返回您刚刚启动的实例的容器 ID。

当然,拥有一个只显示“欢迎使用 nginx!”消息的 Web 服务器并不有趣。要更改用于提供欢迎网页的 HTML 文件的内容需要做什么?

您可以先确认包含欢迎消息的 index.html 文件的位置。exec 命令允许您使用主机 shell 的 docker CLI 在运行的客户容器实例中执行任意 Linux 命令。例如,要输出您的 nginx 实例中 /usr/share/nginx/html/index.html 文件的内容,请运行

docker exec CONTAINER_ID /bin/bash -c 
➥ 'head /usr/share/nginx/html/index.html'

如果您为您的 nginx 容器实例使用正确的 CONTAINER_ID 值,则应输出

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>

请注意,在 exec 命令中,您指定要使用 /bin/bash 执行 Bash shell,并使用 -c 标志和 head /usr/share/nginx/html/index.html 作为实际命令的命令行参数指定 shell 命令。请记住,head 命令可用于输出文件的前五行。

类似地,您可以通过更改客户容器实例中的内容轻松修改 index.html 文件的内容。如果执行

docker exec CONTAINER_ID /bin/bash 
➥ -c 'echo "Hello from my Docker tutorial" > 
➥ /usr/share/nginx/html/index.html'

刷新浏览器中的 localhost:8080 页面,你应该收到“Hello from my Docker tutorial”消息。

重要的是,您意识到 index.html 文件的更改发生在容器实例中,而不是在用于启动实例的容器镜像中。如果您对用于启动容器实例的容器镜像所做的更改感到不确定,您可以使用 diff 命令找出详细信息:

docker diff CONTAINER_ID

这将根据对 index.html 文件的更改和 NGINX Web 服务器更改(C)或添加(A)所输出以下内容:

C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
C /usr/share/nginx/html/index.html
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
C /run
A /run/nginx.pid

在第 B.2 节,您将了解如何创建自己的自定义 Docker 镜像,以便可以持久保存所需的更改并在许多 Docker 容器实例之间重复使用它们。

当您经常在 Docker 主机环境中启动和停止多个容器实例时,将它们作为一批管理是很方便的。您可以使用以下命令列出所有容器实例 ID

docker ps -aq

这应该返回类似以下的列表:

c32eaafa76c1
078c98061959
...
a74e24994390
6da8b3d1f0e1

省略号表示您可能有任意数量的容器 ID 由命令返回。要停止环境中的所有容器实例,您可以使用 xargs 命令

docker ps -aq | xargs docker stop

这会停止所有容器实例。接下来,您可以重复使用 docker rm 结合 xargs 来移除任何剩余的容器实例:

docker ps -aq | xargs docker rm

在停止并删除 docker 容器实例后,如果重新运行

docker ps -aq

你应该看到一个空的响应,这意味着你的 Docker 主机环境中没有任何容器实例。

B.2 构建自定义镜像

创建自己的 Docker 镜像并与世界分享是非常简单的。它始于一个 Dockerfile,这是一个声明性规范,用于如何获取现有(基础)容器镜像并使用您自己的更改扩展它(考虑在其上添加层)。

您应该通过创建和导航至一个空目录 tmp 开始构建自己的 Docker 镜像的过程:

mkdir tmp

准备一个空目录是个好习惯,因为 Docker 在构建过程中会复制目录的内容(称为上下文目录),所以如果您意外地从包含大量不相关内容的目录启动构建过程,您将不得不等待 Docker 不必要地复制这些不相关的内容,而不是立即返回结果镜像。

由于每个 Docker 镜像都以基础镜像开始,因此 Dockerfile 必须在构建过程中使用 FROM 语句指定要使用的基础镜像的标识符。此示例继续使用 NGINX Web 服务器:

echo "FROM nginx:latest" > Dockerfile

在这里,echo 命令不会产生输出,而是在当前目录中创建一个新的 Dockerfile,其中包含一个包含 FROM 语句的单行,指定 nginx:latest 作为基础镜像。现在,您已准备好使用以下构建命令构建您的第一个自定义 NGINX 镜像:

docker build -t just-nginx:latest -f Dockerfile tmp/

应该输出

docker build -t just-nginx:latest -f Dockerfile tmp/
Sending build context to Docker daemon  1.583kB
Step 1/1 : FROM nginx:latest
 ---> 4bb46517cac3
Successfully built 4bb46517cac3
Successfully tagged just-nginx:latest

此时,您可以确认您在 Docker 主机环境中有一个新的 Docker 镜像

docker image ls | grep nginx

这会产生一个输出,可能会让您对奇怪的创建日期时间戳感到惊讶。在我的情况下,对于镜像 ID 4bb46517cac3,时间戳报告了 3 周前的创建日期

just-nginx      latest                        
➥ 4bb46517cac3        3 weeks ago         133MB

请记住,Docker 依赖于基于哈希代码的指纹来对图像层和整个容器镜像进行识别。由于您的 Dockerfile 没有对图像进行任何更改,所以哈希代码保持不变,尽管元数据值(just-nginx)发生了变化。

那么有关实际更改基础 Docker 镜像的示例呢?您可以首先创建自己的自定义 index.html 文件,您希望在访问 NGINX Web 服务器时看到其呈现。请注意,使用以下命令将该文件创建在 tmp 子目录中

echo 
➥ '<html><body>Welcome to my custom nginx message!
➥ </body></html>' > tmp/index.html

准备好 index.html 文件后,您可以使用命令修改 Dockerfile,在构建过程中将文件复制到镜像中,

echo 'COPY index.html 
➥ /usr/share/nginx/html/index.html' >> Dockerfile

因此整个 Dockerfile 应该包括以下内容:

FROM nginx:latest
COPY index.html /usr/share/nginx/html/index.html

此时,您已经准备好使用自定义欢迎消息构建另一个镜像。运行

docker build -t custom-nginx:latest -f Dockerfile tmp/

应该输出

Sending build context to Docker daemon  2.607kB
Step 1/2 : FROM nginx:latest
 ---> 4bb46517cac3
Step 2/2 : COPY index.html 
➥ /usr/share/nginx/html/index.html
 ---> c0a21724aa7a
Successfully built c0a21724aa7a
Successfully tagged custom-nginx:latest

其中哈希代码可能与您的不匹配。

请注意,Docker COPY 命令完成成功,因为您将 tmp 用作构建上下文目录,并且 index.html 存在于 tmp 中。通常,在构建过程中想要复制到 Docker 镜像中的任何文件都必须位于构建上下文目录中。

现在,您已经准备好启动新构建的镜像,

docker run -d -p 8080:80 custom-nginx:latest

并确认如果您访问 localhost:8080,NGINX 会响应

Welcome to my custom nginx message!

B.3 共享您的自定义镜像给世界

在您可以将 Docker 镜像上传到 Docker 镜像注册表之前,您必须在 hub.docker.com 上创建您的个人帐户。假设您已经创建了您的帐户并且拥有 Docker Hub 的用户名和密码,您可以使用这些凭据从命令行登录:

docker login

成功登录后,您应该观察到类似以下的输出:

docker login
Login with your Docker ID to push and pull images 
➥ from Docker Hub. If you don't have a Docker ID, 
➥ head over to https://hub.docker.com to create one.
Username: YOUR_USER_NAME
Password: YOUR_PASSWORD
Login Succeeded

要使 Docker 镜像准备好上传,必须以您的 Docker 用户名作为前缀对其进行标记。要将此前缀分配给您的 custom-nginx 镜像,可以使用 tag 命令

docker tag custom-nginx:latest 
➥ YOUR_USER_NAME/custom-nginx:latest

将 YOUR_USER_NAME 替换为您的 Docker Hub 用户名。要上传(推送)您的镜像到 Docker Hub,可以执行

docker push YOUR_USER_NAME/custom-nginx:latest

这应该会产生类似以下的输出:

The push refers to repository 
➥ [docker.io/YOUR_USER_NAME/custom-nginx]
088b6bf061ef: Pushed
550333325e31: Pushed
22ea89b1a816: Pushed
a4d893caa5c9: Pushed
0338db614b95: Pushed
d0f104dc0a1f: Pushed
latest: digest: sha256:9d12a3fc5cbb0a20e9be7afca476
➥ a0603a38fcee6ccfedf698300c6023c4b444 size: 1569

这表明你可以重新登录到你的 Docker 注册表仪表板,网址为 hub.docker.com,并确认custom-nginx:latest镜像已经在你的注册表中可用。

posted @ 2024-05-02 22:33  绝不原创的飞龙  阅读(35)  评论(0编辑  收藏  举报