Python-和-PySpark-数据分析-全-
Python 和 PySpark 数据分析(全)
原文:Data Analysis with Python and PySpark
译者:飞龙
前置内容
前言
虽然计算机变得越来越强大,处理大量数据集的能力也越来越强,但我们消费数据的需求增长得更快。因此,我们开发了新的工具来跨多台机器扩展大数据作业。这并非免费,早期的工具因为需要用户管理不仅数据程序,还要管理机器集群的健康和性能而变得复杂。我记得我试图扩展自己的程序,结果得到的建议是“只采样你的数据集,然后继续你的日子。”
PySpark 改变了游戏规则。从流行的 Python 编程语言开始,它提供了一个清晰易读的 API 来操作非常大的数据集。尽管如此,当你在驾驶员座位上时,你编写的代码就像你在处理一台单机一样。PySpark 位于强大、表达和灵活的交汇点。通过强大的多维数据模型,你可以构建你的数据程序,并有一个清晰的路径来实现可扩展性,无论数据大小如何。
当我在作为构建信用风险模型的数据科学家工作时,我爱上了 PySpark。在我们即将将模型迁移到新的大数据环境之际,我们需要制定一个计划,在“保持灯火通明”的同时智能地转换我们的数据产品。作为自封的 Python 专家,我被指派去帮助团队熟悉 PySpark 并加速过渡。随着我有机会与众多客户在不同用例上合作,这种爱以指数级增长。共同的主题?大数据和大数据问题,所有这些问题都可以通过强大的数据模型解决。有一个注意事项:大多数用于学习 Spark 的材料都集中在 Scala 和 Java 上,Python 开发者不得不将代码转译到他们喜欢的编程语言。我开始写这本书是为了推广 PySpark 作为数据分析的优秀工具。幸运的是,Spark 项目真的将 Python 提升为一等公民。现在,比以往任何时候,你都有一个强大的工具来扩展你的数据程序。
一旦大数据被驯服,真的感觉非常强大。
致谢
虽然我的名字在封面上,但这本书是一个巨大的团队努力的结果,我想花时间感谢那些在过程中帮助我的人。
首先我要感谢我的家人。写一本书是一项繁重的工作,随之而来的是大量的抱怨。西蒙、凯瑟琳、维罗妮克、让,衷心感谢你们的支持。我非常爱你们。
瑞吉娜,从某种意义上说,你是我的第一位 PySpark 学生。通过你的领导,你实际上改变了我职业上的所有事情。我将永远珍惜我们一起工作的时光,并且我觉得很幸运我们的道路在那时交汇。
我要感谢 Renata Pompas,她允许我使用在她的监督下制作的调色板来装饰我书中的图表。我色盲,找到一组既让我满意又保持一致的安全颜色在书的发展过程中非常有帮助。如果你觉得这些图表看起来不错,请感谢她(以及优秀的 Manning 图形设计师)。如果它们看起来不好,那就怪我吧。
感谢 EPAM 团队,特别感谢 Zac、James、Nasim、Vahid、Dmitrii、Yuriy、Val、Robert、Aliaksandra、Ihor、Pooyan、Artem、Volha、Ekaterina、Sergey、Sergei、Siarhei、Kseniya、Artemii、Anatoly、Yuliya、Nadzeya、Artsiom、Denis、Yevhen、Sofiia、Roman、Mykola、Lisa、Gaurav、Megan 以及更多。从我宣布我要写一本书到写下这些话的时候,我感受到了支持和鼓励。感谢 Laivly 团队,Jeff、Rod、Craig、Jordan、Abu、Brendan、Daniel、Guy 和 Reid,感谢你们给我继续冒险的机会。我向你们保证,未来是光明的。
热烈感谢那些相信我的“使用 PySpark,你会感激你这么做”的口号的人。这里的人太多,无法一一列举,但我想向 Mark Derry、Uma Gopinath、Tom Everett、Dhrun Lauwers、Milena Kumurdjieva、Shahid Amlani、Sam Diab、Chris Wagner、JV Eng、Chris Purtill、Naveen Pothayath、Vish Tipirneni 和 Patrick Kurkiewicz 表示敬意。
在写书的过程中,我有幸与一些优秀的播客制作人一起深入研究 PySpark:Brian 在 Test and Code (testandcode.com/),Lior 和 Michael 在WHAT the Data?! (podcast.whatthedatapodcast.com/),以及 Ben 在Profitable Python (anchor.fm/profitablepythonfm)。我非常谦卑和感激你们邀请我加入你们的交流。感谢 Alexey Grigorev 让我加入你在 Slack 上的“每周一书”俱乐部——你们建立了多么棒的社区!
我要感谢在书的发展过程中提供评论的读者,以及提供出色反馈的审稿人:Alex Lucas、David Cronkite、Dianshuang Wu、Gary Bake、Geoff Clark、Gustavo Patino、Igor Vieira、Javier Collado Cabeza、Jeremy Loscheider、Josh Cohen、Kay Engelhardt、Kim Falk、Michael Kareev、Mike Jensen、Patrick A. Mol、Paul Fornia、Peter Hampton、Philippe Van Bergen、Rambabu Posa、Raushan Jha、Sergio Govoni、Sriram Macharla、Stephen Oates 和 Werner Nindl。
最后,也是最重要的一点,我要感谢 Manning 的梦幻团队,他们参与了将这本书变为现实。有许多人让这次经历变得难以置信:Marjan Bace、Michael Stephens、Rebecca Rinehart、Bert Bates、Candace Gillhoolley、Radmila Ercegovac、Aleks Dragosavljević、Matko Hrvatin、Christopher Kaufmann、Ana Romac、Branko Latinčić、Lucas Weber、Stjepan Jureković、Goran Ore、Keri Hales、Michele Mitchell、Melody Dolab 以及 Manning 制作团队的其余成员。
说到 Manning,我想感谢两本书的作者:来自 《wxPython in Action》 的诺埃尔·拉平(Noel Rappin)和罗宾·邓恩(Robin Dunn)(Manning,2016;www.manning.com/books/wxpython-in-action),以及来自 《Clojure 的乐趣》 的迈克尔·福格斯(Michael Fogus)和克里斯·豪泽(Chris Houser)(Manning,2014;www.manning.com/books/the-joy-of-clojure-second-edition)。这些书在我的脑海中触发了某种东西,让我一头扎进了编程(然后是数据科学)。从某种意义上说,它们是这本书产生的最初火花(这里有点糟糕的打趣)。
最后,我想强调的是,Manning 团队帮助我在日常工作中保持责任感,使这本书成为我引以为傲的作品。亚瑟·祖巴列夫,我简直不敢相信我们住在同一个城市,却没能见面!感谢你提供的宝贵反馈和回答我提出的许多问题。亚历克斯·奥特,我认为我无法期望更好的技术顾问。Databricks 非常幸运有你。最后,但同样重要的是,我要感谢玛丽娜·迈克尔斯,从我产生写这本书的想法的那一刻起,她就一直支持我。写一本书比我想象的要难得多,但你让整个过程变得愉快、有教育意义且相关。衷心感谢你。
关于这本书
《使用 Python 和 PySpark 进行数据分析》 教你如何使用 PySpark 来进行自己的大数据分析程序。本书在教授 PySpark 的如何以及为什么方面采取了实用主义立场。你将学习如何有效地摄取、处理和大规模工作数据,以及如何推理自己的数据转换代码。阅读完这本书后,你应该能够舒适地使用 PySpark 来编写自己的数据程序和分析。
应该阅读这本书的人
本书围绕越来越复杂的用例构建,从简单的数据转换到机器学习管道。我们涵盖了整个周期,从数据摄取到结果消费,增加了关于数据源摄取和转换可能性的更多元素。
本书主要面向那些希望将 Python 代码扩展到更大数据集的数据分析师、科学家和工程师。理想情况下,你应该已经通过工作或学习编程编写了一些数据程序。如果你已经熟悉 Python 编程语言和生态系统,那么你会从这本书中获得更多收获。
Spark(以及 PySpark,自然地)借鉴了面向对象和函数式编程的很多内容。我认为,仅仅为了高效地使用大数据,就期望完全掌握这两种编程范式是不合理的。如果你理解 Python 类、装饰器和高阶函数,你将能够愉快地使用书中一些更高级的结构来使 PySpark 为你所用。如果你对这些概念感到陌生,我在全书的适当位置和附录中介绍了它们。
本书是如何组织的:一个路线图
本书分为三个部分。第一部分“熟悉”,介绍了 PySpark 及其计算模型。它还涵盖了构建和提交一个简单的数据程序,重点关注你肯定会在你创建的每个 PySpark 程序中使用的核心操作,例如在数据框中选择、过滤、连接和分组数据。
第二部分“精通”通过引入层次化数据,深入探讨了数据转换,这是 PySpark 可扩展数据程序的关键元素。我们还通过审慎地引入 SQL 代码、探索弹性分布式数据集/用户定义函数、在 PySpark 中高效使用 pandas 以及窗口函数,使我们的程序更具表现力、灵活性和性能。我们还探讨了 Spark 的报表功能和资源管理,以确定潜在的性能问题。
最后,第三部分“自信”,建立在第一部分和第二部分的基础上,涵盖了如何在 PySpark 中构建机器学习程序。我们使用我们的数据转换工具包在构建和评估机器学习管道之前创建和选择特征。我们通过创建我们自己的机器学习管道组件来结束这一部分,确保我们的 ML 程序具有最大限度的可用性和可读性。
第一部分和第二部分在章节中以及章节末尾都有练习。章节末尾的练习不需要你编写代码;你应该能够用你所学到的知识回答问题。
本书是以从头到尾阅读的想法来编写的,根据需要使用附录。如果你想要直接深入研究一个主题,我仍然建议在深入研究特定章节之前先覆盖第一部分。以下是一些硬依赖和软依赖,以帮助你更有效地导航本书:
-
第三章是第二章的直接延续。
-
第五章是第四章的直接延续。
-
第九章使用了第八章中教授的一些概念,但高级读者可以单独阅读。
-
第十二章、第十三章和第十四章最好依次阅读。
关于代码
本书最适合与 Spark 版本 3.1 或 3.2 一起使用:Spark 在 3.0 版本中引入了许多新功能,现在大多数商业产品都默认使用这个版本。在适当的情况下,我提供了与 Spark 版本 2.3/2.4 兼容的说明。我不建议使用 Spark 2.2 或更低版本。我还建议使用 Python 版本 3.6 及以上(本书使用 Python 3.8.8)。安装说明可在附录 A 中找到。
您可以在 github.com/jonesberg/DataAnalysisWithPythonAndPySpark 找到本书的配套代码库,其中包含数据和代码。在适当的情况下,它还包含全书开发过程中编写的程序的可运行版本,以及一些可选练习。此外,您还可以从本书的 liveBook(在线)版本中获取可执行的代码片段 livebook.manning.com/book/data-analysis-with-python-and-pyspark.
本书包含许多源代码示例,既有编号列表,也有与普通文本并列的代码。在这两种情况下,源代码都使用 fixed-width font like this 格式化,以将其与普通文本区分开来。有时代码也会加粗,以突出显示与章节中先前步骤相比已更改的代码,例如当新功能添加到现有代码行时。
在许多情况下,原始源代码已经被重新格式化;我们添加了换行符并重新调整了缩进,以适应书籍中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中移除。许多列表旁边都有代码注释,并突出显示重要概念。
liveBook 讨论论坛
购买 使用 Python 和 PySpark 进行数据分析 包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或特定章节或段落中附加评论。为自己做笔记、提问和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/data-analysis-with-python-and-pyspark/discussion。您还可以在 livebook.manning.com/#!/discussion 了解更多关于 Manning 论坛和行为准则的信息。
曼宁对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和以前讨论的存档将可通过出版商的网站访问。
关于作者
![]() |
乔纳森·里尤斯每天都会全面使用 PySpark。他还教授数据科学家、工程师和具有数据洞察力的商业分析师进行大规模数据分析。乔纳森在保险行业担任各种分析职位十年后,作为机器学习和数据分析专家进入咨询行业。他目前是 Laivly 公司的机器学习总监,该公司为友好的人类配备智能自动化和机器学习,以在地球上创造最佳的客户体验。 |
|---|
关于封面插图
《使用 Python 和 PySpark 进行数据分析》封面上的图像是“Russien”,即俄罗斯人,取自雅克·格拉塞·德·圣索沃尔于 1788 年出版的书籍。每一幅插图都是手工精细绘制和着色的。
在那些日子里,人们通过他们的着装很容易就能识别出他们住在哪里,他们的职业或生活地位是什么。曼宁通过基于几个世纪前丰富的地方文化多样性的书封面来庆祝当今计算机业务的创新精神和主动性,这些文化多样性通过像这一系列这样的图片被重新带回生活。
1 简介
本章涵盖
-
什么是 PySpark
-
为什么 PySpark 是数据分析的有用工具
-
Spark 平台的多功能性及其局限性
-
PySpark 处理数据的方式
几乎每家新闻机构都认为,数据无处不在,数据就是一切。它是新的石油,新的电力,新的黄金,钚,甚至是培根!我们称之为强大、无形、珍贵、危险。然而,数据本身并不足够:重要的是你如何使用它。毕竟,对于计算机来说,任何数据都是零和一的集合,作为用户,我们有责任理解它如何转化为有用的东西。
就像石油、电力、黄金、钚和培根(尤其是培根!)一样,我们对数据的渴望在增长。事实上,增长得如此之快,以至于计算机跟不上了。数据在规模和复杂性上都在增长,而消费级硬件的增长却有所停滞。对于大多数笔记本电脑来说,RAM 徘徊在 8 到 16GB 左右,而 SSD 在几 TB 之后变得过于昂贵。对于日益增长的数据分析师来说,解决方案是不是要三倍抵押他们的生活,才能负担得起顶级的硬件来处理大数据问题?
这里介绍了 Apache Spark(我在本书中会称之为 Spark)及其配套的 PySpark。它们借鉴了超级计算机操作手册中的几页——强大的计算单元在网络中的机器上交织在一起——并将它们带给大众。再加上一套强大的数据结构,可以应对您愿意投入的任何工作,您就拥有了一个会成长(有意为之)的工具。
本书的一个目标是为您提供使用 PySpark 分析数据的工具,无论您需要回答一个快速的数据驱动问题还是构建一个机器学习模型。它涵盖了足够的理论知识,让您感到舒适,同时给您足够的机会进行实践。大多数章节都包含一些练习来巩固您刚刚学到的知识。这些练习都在附录 A 中解决并解释。
1.1 什么是 PySpark?
名称中有什么含义?实际上,很多。仅仅通过将 PySpark 分成两部分,你就可以推断出这将与 Spark 和 Python 相关。而且你是对的!
在其核心,PySpark 可以概括为 Spark 的 Python API。虽然这是一个准确的定义,但如果不知道 Python 和 Spark 的含义,它并没有太多意义。不过,让我们通过首先回答“什么是 Spark?”来分解这个总结定义。有了这个基础,我们接下来将探讨为什么 Spark 与 Python 及其令人难以置信的分析(和机器学习)库结合使用时变得特别强大。
1.1.1 从一开始:什么是 Spark?
根据 Apache Spark™软件的作者,我将在本书中称之为 Spark,它是一个“用于大规模数据处理的一体化分析引擎”(见spark.apache.org/)。这是一个非常准确,但略显枯燥的定义。作为一个心理图像,我们可以将 Spark 比作一个分析工厂。原材料——在这里是数据——进入,而数据、洞察、可视化、模型,等等,都会产出。
就像工厂通过扩大其占地面积来增加产能一样,Spark 可以通过横向扩展(跨多个较小的机器)而不是纵向扩展(向单一机器添加更多资源,如 CPU、RAM 和磁盘空间)来处理越来越多的数据。与世界上大多数事物不同,RAM 的价格随着购买量的增加而增加(例如,一根 128GB 的内存条比两根 64GB 的内存条价格要高)。这意味着,你不需要购买数千美元的 RAM 来容纳你的数据集,而是会依赖多台计算机,将工作分配给它们。在一个两台普通计算机的成本低于一台大型计算机的世界里,横向扩展比纵向扩展更经济,这可以让你省下更多的钱。
云端成本和 RAM
在云端,价格往往更为重要。例如,截至 2022 年 1 月,一个 16 核/128GB RAM 的机器可能比一个 8 核/64GB RAM 的机器贵一倍。随着数据量的增长,Spark 可以通过扩展特定任务的工人和执行器的数量来帮助控制成本。例如,如果你有一个在适度数据集(几 TB)上的数据转换任务,你可以限制自己使用更少的机器——比如说五台——当你想要进行机器学习时,可以扩展到 60 台。一些供应商,如 Databricks(见附录 B),提供自动扩展,这意味着他们会根据集群的压力在任务期间增加或减少机器的数量。自动扩展/成本控制的实现完全取决于供应商。(查看第十一章,了解构成 Spark 集群的资源以及它们的作用。)
一台计算机有时可能会崩溃或表现出不可预测的行为。如果你有 100 台而不是一台,那么至少有一台出现故障的概率现在要高得多。¹ 因此,Spark 需要管理、扩展和照看很多环节,以便你能专注于你真正想要的事情,那就是与数据工作。
这实际上是 Spark 的关键特性之一:它是一个好工具,因为你可以用它做很多事情,但更重要的是,它让你不需要做的事情很多。Spark 提供了一个强大的 API(应用程序编程接口,为你提供的一组函数、类和变量,用于与数据交互),这使得你看起来像是在与一个统一的数据源一起工作,同时在后台努力优化你的程序以使用所有可用的功能。你不需要成为分布式计算神秘艺术的专家;你只需要熟悉你将用来构建程序的语言。
1.1.2 PySpark = Spark + Python
PySpark 为 Spark 计算模型中的 Python 提供了一个入口点。Spark 本身是用 Scala 编写的。² 作者在提供连贯的接口的同时,适当地保留了每种语言的独特性,做得非常出色。因此,对于 Scala/Spark 程序员来说,阅读你的 PySpark 程序将会非常容易,同样对于还没有深入水中的 Python 程序员也是如此。
Python 是一种动态的通用语言,可在许多平台上运行,适用于各种任务。它的多功能性和表现力使其非常适合 PySpark。这种语言在各种领域都非常受欢迎,目前它是数据分析和科学领域的主要力量。其语法易于学习和阅读,可用的库数量意味着你通常会找到一个(或更多!)非常适合你问题的库。
PySpark 不仅提供了对核心 Spark API 的访问,还提供了一套专门的功能,用于扩展常规 Python 代码以及 pandas 转换。在 Python 的数据分析生态系统中,pandas 是内存受限数据框的事实上的库(整个数据框需要驻留在单个机器的内存中)。现在不是 PySpark 或 pandas 的问题,而是 PySpark 和 pandas。第八章和第九章专门介绍了将 Python、pandas 和 PySpark 结合在一个快乐程序中的方法。对于那些真正致力于 pandas 语法(或者如果你有一个大的 pandas 程序想要扩展到 PySpark),Koalas(现在称为pyspark.pandas,自 3.2.0 版本起成为 Spark 的一部分;koalas.readthedocs.io/)在 PySpark 之上提供了一个类似 pandas 的瓷器。如果你正在用 Python 开始一个新的 Spark 程序,我建议使用本书彻底介绍的 PySpark 语法,将 Koalas 保留在你想要从 pandas 过渡到 PySpark 时使用。你的程序将运行得更快,在我看来,可读性也会更好。
1.1.3 为什么选择 PySpark?
与数据一起工作的库和框架并不缺乏。为什么人们应该花时间专门学习 PySpark 呢?
PySpark 对现代数据工作负载有很多优势。它位于快速、表达和灵活的交汇点。本节涵盖了 PySpark 的许多优势,为什么其价值主张不仅仅局限于“Spark,加上 Python”,以及何时应该选择其他工具。
PySpark 速度快
如果你在一个搜索引擎中搜索“大数据”,有很大可能性 Hadoop 会在前几个结果中出现。这有一个很好的原因:Hadoop 普及了 Google 在 2004 年开创的著名MapReduce框架,并启发了大规模数据处理的方式(我们在第八章中提到 MapReduce,当时我们讨论了 PySpark 的低级数据结构,弹性分布式数据集)。
Spark 是在几年后创建的,建立在 Hadoop 令人难以置信的遗产之上。凭借积极的查询优化器、合理的 RAM 使用(减少磁盘 I/O;见第十一章)以及我们将在下一章中提到的其他改进,Spark 可以比普通的 Hadoop 快 100 倍。由于这两个框架之间的集成,你可以轻松地将你的 Hadoop 工作流程切换到 Spark,并获得一些性能提升,而无需更改你的硬件。³
PySpark 具有表达性
除了 Python 是最受欢迎和易于学习的语言之一之外,PySpark 的 API 从头开始设计,易于理解。PySpark 从 SQL 中借用并扩展了数据操作词汇。它以流畅的方式进行:对数据框的每次操作都会返回一个“新”数据框,因此你可以连续执行操作。尽管我们目前只是处于学习 PySpark 的早期阶段,但列表 1.1 展示了 PySpark 的可读性和精心设计的特性。即使没有先前的知识,词汇选择和语法的连贯性也使得它读起来像散文。我们读取 CSV 文件,创建一个包含基于旧列的值的条件的新列,过滤(使用where),按列值分组,为每个组生成计数,最后将结果写回 CSV 文件。所有这些方法都在本书的第一部分中进行了介绍,但我们已经可以推断出这段代码的作用。
列表 1.1 显示 PySpark 表达性的简单 ETL 管道
(
spark.read.csv("./data/list_of_numbers/sample.csv", header=True)
.withColumn(
"new_column", F.when(F.col("old_column") > 10, 10).otherwise(0)
)
.where("old_column > 8")
.groupby("new_column")
.count()
.write.csv("updated_frequencies.csv", mode="overwrite")
)
在底层,Spark 对这些操作进行了优化,以确保我们不会在每次方法调用后得到一个中间数据框。正因为如此,我们可以用非常简洁和自我描述的方式编写我们的数据转换代码,依靠 Spark 来优化最终结果——这是程序员最舒适的体验。
你将在本书中看到许多(更复杂!)的示例。当我编写这些示例时,我很高兴代码最终看起来与我最初的(笔和纸)推理非常接近。在理解了框架的基本原理之后,我坚信你也会处于同样的情况。
PySpark 功能丰富
PySpark 的一个关键优势是其多功能性:您学习一个工具,并在各种环境中使用它。这种多功能性有两个组成部分。首先,是框架的可用性。其次,是围绕 Spark 的多元化生态系统。
PySpark 无处不在。所有三大云服务提供商(亚马逊网络服务 [AWS]、谷歌云平台 [GCP]、微软 Azure)都将其服务中包含一个管理的 Spark 集群,这意味着您只需点击几个按钮就可以获得一个完全配置的集群。您还可以轻松地在您的计算机上安装 Spark,以便在更强大的集群上扩展之前确定您的程序。附录 B 涵盖了如何启动本地 Spark 以及简要介绍了当前主要的云服务。
PySpark 是开源的。与其他分析软件不同,您不需要绑定到单个公司。如果您对源代码感兴趣,可以检查它,如果您有新功能的想法或发现了错误,甚至可以贡献。它还提供了一个低门槛的采用率:下载、学习、获利!
最后,Spark 的生态系统不仅限于 PySpark。还有 Scala、Java 和 R 的 API,以及一个最先进的 SQL 层。这使得在 Spark 中编写多语言程序变得容易。一个 Java 软件工程师可以使用 Java 在 Spark 中处理数据转换管道,而数据科学家可以使用 PySpark 构建模型。
PySpark 的不足之处
如果 PySpark 是每个数据问题的答案,那将是极好的。不幸的是,有一些注意事项。它们都不是决定性的,但在选择您下一个项目的工具时应该考虑。
如果您处理的是(非常)小的数据集的快速处理,PySpark 不是正确的选择。在多台机器上执行程序需要在节点之间进行一些协调,这会带来一些开销。如果您只使用单个节点,您正在付出代价,但没有使用其好处。例如,PySpark shell 将花费几秒钟来启动;这通常足以处理适合您 RAM 的数据。然而,随着新的 PySpark 版本的发布,这种小数据集的性能差距越来越小。
与 Java 和 Scala API 相比,PySpark 也有一些小缺点。由于 Spark 是 Scala 程序的核心,纯 Python 代码必须转换为 JVM(Java 虚拟机,Java 和 Scala 代码的运行时)指令。由于 DataFrame API 在 PySpark 中可用,语言之间的差异已经显著缩小:数据帧操作映射到高度高效的 Spark 操作,无论您的程序是用 Scala、Java 还是 Python 编写的,这些操作都以相同的速度运行。当您使用弹性分布式数据集(RDD)数据结构或定义您的 Python 用户自定义函数时,您仍然会见证较慢的操作。这并不意味着我们将避免使用它们:我在第八章中涵盖了这两个主题。
最后,虽然编写 PySpark 可能感觉直接,但管理集群可能有点复杂。Spark 是一个相当复杂的软件;尽管在过去几年中代码库成熟度显著提高,但我们还没有达到可以像管理单个节点那样轻松管理 100 台机器集群的程度。Spark 的配置和性能调优将在第十一章中介绍,云选项使得这比以往任何时候都更容易(参见附录 B)。对于更复杂的问题,做像我一样的事情:与您的运维团队交朋友。
本节不仅提供了 PySpark 的“为什么”,还提供了一些“为什么不”,因为了解何时何地使用 PySpark 对于获得良好的开发体验和高效的处理性能至关重要。在下一节中,我们将更深入地探讨 Spark 如何处理数据,以及如何使分布式数据处理看起来就像您在控制一个单一工厂。
1.2 您自己的工厂:PySpark 的工作原理
在本节中,我们将介绍 Spark 如何处理程序。虽然我们之前声称该系统隐藏了复杂性,但展示其工作原理和基础可能有点奇怪。然而,了解 Spark 的设置、数据管理方式和查询优化方式是非常重要的。有了这些知识,您将能够与系统进行推理,改进您的代码,并快速找出它没有按您期望的方式执行的情况。
如果我们保持工厂的类比,我们可以想象 Spark 所坐的计算机集群就是大楼。如果我们看图 1.1,我们可以看到解释数据工厂的两种不同方式。在左边,我们看到它从外表看起来是什么样的:一个统一的单元,项目进来,结果出来。这通常是它在你面前呈现的样子。在底层,它看起来更像是右边的东西:你有一些工作台,一些工人被分配到那里。这些工作台就像我们 Spark 集群中的计算机:它们有固定数量的工作台。一些现代的 Spark 实现,如 Databricks(见附录 B),允许在运行时自动扩展机器的数量。一些需要更多的规划,特别是如果你在本地运行并拥有自己的硬件。在 Spark 的文献中,这些工人被称为执行器:它们在机器/节点上执行实际的工作。

图 1.1 一个完全相关的数据工厂,从外到内。百分之九十的时间我们关心整个工厂,但了解它的布局有助于我们反思代码性能。
其中一个小工人看起来比其他工人更精神。那顶高帽子确实让他从人群中脱颖而出。在我们的数据工厂中,他是工作场所的管理者。在 Spark 术语中,我们称这个为主节点。⁴ 这个主节点坐在一个工作台/机器上,但它也可以坐在一个不同的机器上(甚至你的电脑上!)这取决于集群管理器和部署模式。主节点的角色对于你程序的效率执行至关重要,所以第 1.2.2 节专门讨论了这一点。
小贴士:在云中,你可以有一个高可用性集群,这意味着你的主节点将在多个机器上复制。
1.2.1 使用集群管理器进行一些物理规划
在接收到任务后,在 Spark 世界中被称为驱动程序,工厂开始运行。这并不意味着我们直接进入处理。在那之前,集群需要规划分配给你的程序的容量。负责这个任务的实体或程序恰当地被称为集群管理器。在我们的工厂中,这个集群管理器将查看有可用空间的工作台,并确保尽可能多的空间,然后开始雇佣工人来填补这些空间。在 Spark 中,它将查看有可用计算资源的机器,并在它们上启动所需数量的执行器之前确保所需资源。
注意 Spark 提供了一个自己的集群管理器,称为 Standalone,但在与 Hadoop 或其他大数据平台协同工作时也能很好地与其他集群管理器协同工作。如果你在野外读到关于 YARN、Mesos 或 Kubernetes 的内容,要知道它们(就 Spark 而言)被用作集群管理器。
关于容量(机器和执行器)的任何指示都编码在表示与我们的 Spark 集群连接的 SparkContext 中。如果我们的指示没有提到任何特定的容量,集群管理器将分配由我们的 Spark 安装规定的默认容量。
例如,让我们尝试以下操作。使用与列表 1.1 中相同的 sample.csv 文件(可在本书的存储库中找到),让我们计算程序的简化版本:返回 old_column 的值的算术平均值。假设我们的 Spark 实例有四个执行器,每个执行器在其自己的工作节点上工作。数据处理将大致在四个执行器之间分配:每个执行器将处理数据框的一小部分。
列表 1.2 sample.csv 文件的内容
less data/list_of_numbers/sample.csv
old_column
1
4
4
5
7
7
7
10
14
1
4
8
图 1.2 描述了 PySpark 处理我们小型数据框中 old_column 平均值的一种方式。我选择平均值,因为它不像求和或计数那样可以轻易分配,在求和或计数的情况下,你需要从每个工作节点求中间值的和。在计算平均值的情况下,每个工作节点独立计算值的总和及其计数,然后在将结果(不是所有数据!)移动到单个工作节点之前,该节点将处理聚合到一个数字,即平均值。
对于这样的简单例子,映射 PySpark 的思维过程是一个简单而有趣的练习。我们数据的大小和程序的复杂性将会增长,并且会变得更加复杂,我们将无法轻易地将我们的代码映射到 Spark 实例执行的精确物理步骤。第十一章介绍了 Spark 用来让我们了解工作执行情况以及工厂健康状况的机制。

图 1.2 以 PySpark 风格计算我们小型数据框的平均值:每个工作节点处理不同的数据。根据需要,数据会被移动/洗牌以完成指令。
本节通过一个简单的例子——计算数字数据框的平均值——将 Spark 执行的物理步骤蓝图映射给我们正确的答案。在下一节中,我们将介绍 Spark 最好的、也是最受误解的功能之一:惰性。在大数据分析的情况下,辛勤的工作会得到回报,但聪明的工作更好!
一些语言约定:Data frame 与 DataFrame
由于本书将更多地讨论数据框,我更喜欢使用非首字母大写的命名法(即,“data frame”)。我发现这比使用大写字母甚至没有空格的“dataframe”更易读。
当直接引用 PySpark 对象时,我将使用 DataFrame 但字体为固定宽度。这将有助于区分“data frame”这个概念和 DataFrame 这个对象。
1.2.2 通过惰性领导者使工厂变得高效
本节介绍了 Spark 最基本的功能之一:其懒加载能力。在我教授 PySpark 和解决数据科学家程序的问题时,我会说,懒加载是 Spark 中最容易引起混淆的概念。这真的很遗憾,因为懒加载(部分)是 Spark 实现其惊人处理速度的原因。通过从高层次理解 Spark 如何实现懒加载,你将能够解释很多其行为,并更好地调整性能。
就像在大型工厂中一样,你不会去每个员工那里给他们一个任务清单。不,在这里,主节点/管理者负责工人。驱动程序是动作发生的地方。将驱动程序想象成一个楼层领导:你给他们你的步骤清单,让他们处理。在 Spark 中,驱动程序/楼层领导将你的指令(用 Python 代码仔细编写)转换为 Spark 步骤,然后在工作节点上处理它们。驱动程序还管理哪个工作节点/表拥有哪些数据切片,并确保在处理过程中不丢失任何数据。执行器/工厂工人位于工作节点/表之上,并执行实际的数据工作。
作为总结:
-
大师就像工厂主,根据需要分配资源以完成工作。
-
驱动程序负责完成给定的工作。它根据需要从主节点请求资源。
-
工作节点是一组计算/内存资源,就像我们工厂中的工作台一样。
-
执行器位于工作之上,执行由驱动程序发送的工作,就像工作台上的员工一样。
我们将在第十一章中回顾实践中的术语。
以列表 1.1 为例,逐条分解每个指令,PySpark 不会开始执行工作,直到write指令。如果你使用常规 Python 或 pandas 数据框(它们不是懒加载的,我们称之为急切评估),每个指令都会在读取时逐个执行。
你的楼层领导/驱动程序拥有优秀管理者所具备的所有品质:它聪明、谨慎、懒散。等等?你读对了吗。在编程环境中——甚至可以说在现实世界中——懒散可能是一件非常好的事情。你提供给 Spark 的每个指令都可以分为两类:转换和操作。操作是许多编程语言会考虑的 I/O。最典型的操作如下:
-
在屏幕上打印信息
-
将数据写入硬盘或云存储桶
-
计算记录数
在 Spark 中,我们通常通过数据框上的show()、write()和count()方法看到这些指令。

图 1.3 将数据框指令分解为一系列转换和一个操作。Spark 将执行的操作由零个或多个转换和一个操作组成。
转换几乎涵盖了所有其他内容。以下是一些转换的例子:
-
向表中添加列
-
根据某些键进行聚合操作
-
计算汇总统计量
-
训练一个机器学习模型
你可能会问,为什么要有这种区分?当思考对数据进行计算时,作为开发者的你,你只关心计算导致的行为。你将始终与行为的结果进行交互,因为这是你可以看到的东西。Spark 凭借其懒计算模型,会将这一点推向极致,直到有行为触发计算链之前,它都会避免执行数据工作。在此之前,驱动程序会存储你的指令。在处理大规模数据时,这种方式处理计算有许多好处。
注意:正如我们在第五章中看到的,count() 当作为聚合函数应用时(在这里它计算每个组的记录数)是一个转换,但当它应用于数据框时(在这里它计算数据框中的记录数)则是一个行为。
首先,将指令存储在内存中比存储中间数据结果占用更少的空间。如果你在数据集上执行许多操作,并且每一步都物化数据,那么你会更快地耗尽存储空间,尽管你不需要中间结果。我们都可以同意,减少浪费是更好的。
第二,通过拥有要执行的任务的完整列表,驱动程序可以更有效地优化执行器之间的工作。它可以使用运行时可用信息,例如数据特定部分所在的节点。它还可以重新排序、消除无用的转换、合并多个操作,并在必要时更有效地重写程序的一部分。

图 1.4:急切与懒计算:存储(和即时计算)转换通过减少对中间数据框的需求来节省内存。它还使得在某个节点失败时更容易重新创建数据框。
第三,如果在处理过程中某个节点失败——计算机会出故障!——Spark 将能够重新创建缺失的数据块,因为它已经缓存了指令。它将读取相关的数据块并处理它,直到你所在的位置,而无需你做任何事情。有了这个,你可以专注于代码的数据处理方面,将灾难恢复部分委托给 Spark。有关计算和内存资源以及如何监控失败的信息,请参阅第十一章。
最后,在交互式开发过程中,你不必提交一大块命令并等待计算发生。相反,你可以迭代地构建你的转换链,一次一个,当你准备好启动计算时,你可以添加一个行为,让 Spark 施展其魔法。
惰性计算是 Spark 操作模型的基本方面,也是其速度如此之快的原因之一。大多数编程语言,包括 Python、R 和 Java,都是即时求值的。这意味着它们在接收到指令后立即处理指令。在 PySpark 中,你可以使用一个即时语言——Python——和一个惰性框架——Spark。这可能会显得有些陌生和令人生畏,但无需担心。最好的学习方式是通过实践,本书在相关情况下提供了惰性的具体示例。你将很快成为一个懒惰的专家!
需要记住的一个方面是,Spark 不会保留动作(或中间数据帧)的结果以供后续计算使用。如果你两次提交相同的程序,PySpark 会两次处理数据。我们使用缓存来改变这种行为,并优化代码中的某些热点(最明显的是在训练机器学习模型时),第十一章将向你介绍如何以及何时缓存(剧透:没有你想象的那么频繁)。
注意:读取数据,虽然属于 I/O 操作,但在 Spark 中被视为转换。在大多数情况下,读取数据不会对用户执行任何可见的工作。因此,你只有在需要对其执行某些操作(写入、读取、推断模式)时才会读取数据(更多信息请参阅第六章)。
没有能干的员工,经理又有什么用?一旦接收到任务及其动作,驱动程序开始将数据分配给 Spark 所说的执行器。执行器是运行计算并存储应用程序数据的进程。这些执行器位于所谓的工作节点上,即实际的计算机。在我们的工厂类比中,执行器是执行工作的员工,而工作节点是许多员工/执行器可以工作的工坊。
我们的工厂之旅到此结束。让我们总结一下典型的 PySpark 程序:
-
我们首先用 Python 代码编码我们的指令,形成一个驱动程序。
-
当我们提交程序(或启动 PySpark shell)时,集群管理器为我们分配资源。这些资源在程序运行期间将保持不变(除了自动扩展)。
-
驱动程序将你的代码摄入并转换为 Spark 指令。这些指令要么是转换,要么是动作。
-
当驱动程序遇到动作时,它会优化整个计算链,并在执行器之间分配工作。执行器是执行实际数据工作的进程,它们位于标记为工作节点的机器上。
就这样!正如我们所见,整个过程相当简单,但很明显,Spark 隐藏了由高效分布式处理产生的许多复杂性。对于开发者来说,这意味着代码更短、更清晰,并且开发周期更快。
1.3 你将在本书中学到什么?
本书将使用 PySpark 解决数据分析师、工程师或科学家在日常工作中可能会遇到的各种任务。因此
-
从(和到)各种来源和格式读取和写入数据
-
使用 PySpark 的数据操作功能处理杂乱的数据
-
发现新的数据集并执行数据探索性分析
-
构建数据管道,以自动化的方式转换、总结并从数据中获得洞察
-
故障排除常见的 PySpark 错误以及如何从中恢复以及如何从一开始就避免它们
在介绍完这些基础知识之后,我们还将处理一些不那么常见但有趣且展示 PySpark 强大和多功能性的绝佳方式的不同任务:
-
我们将构建机器学习模型,从简单的实验到健壮的机器学习管道。
-
我们将处理多种数据格式,从文本到表格到 JSON。
-
我们将无缝融合 Python、pandas 和 PySpark 代码,利用各自的优点,最重要的是将 pandas 代码扩展到新的领域。
我们试图满足许多潜在读者的需求,但重点是那些对 Spark 和/或 PySpark 接触很少或没有接触的人。经验更丰富的从业者可能会在需要解释复杂概念时找到有用的类比,也许还能学到一些新东西!
1.4 我需要什么来开始?
本书专注于 Spark 3.2 版本,这是最新的版本。DataFrame 首次出现在 Spark 1.3 版本中,因此一些代码可以在比这更旧的 Spark 版本上运行。为了避免任何麻烦,我建议您使用 Spark 3.0 或更高版本;如果不可能,请尝试使用您可用的最新版本。
我们假设您具备一些基本的 Python 知识;一些有用的概念在附录 C 中进行了概述。如果您想要更深入地了解 Python,我推荐 Naomi Ceder 所著的《The Quick Python Book》(Manning, 2018;www.manning.com/books/the-quick-python-book-third-edition),或者 Reuven M. Lerner 所著的《Python Workout》(Manning, 2020;www.manning.com/books/python-workout)。
要开始,您只需要一个可工作的 Spark 安装。它可以是您的电脑上的,也可以是云服务提供商上的(参见附录 B)。本书中的大多数示例都可以使用 Spark 的本地安装来完成,但有些可能需要更强的计算能力,并将被标记出来。
在您通过示例进行编写、阅读和编辑脚本以及构建程序的过程中,代码编辑器也将非常有用。拥有一个 Python 感知的编辑器,如 PyCharm、VS Code,甚至是 Emacs/Vim,会很方便,但并非必需。所有示例都可以与 Jupyter 一起使用;请查看附录 B 以设置您的笔记本环境。
本书中的代码示例可在 GitHub 上找到(mng.bz/6ZOR),因此 Git 将是一个有用的软件工具。如果您不了解 Git 或没有它,GitHub 提供了一个下载所有本书代码的 zip 文件的方法。请确保定期检查更新!
最后,我建议您有一种模拟的方式来草拟您的代码和模式。我是一个强迫性的笔记记录者和涂鸦者,即使我的绘画非常基础和粗糙,我发现通过绘画来处理新的软件可以帮助澄清我的思路。这意味着需要重写的代码更少,程序员也更快乐!不需要任何花哨的东西:一些废纸和铅笔就能产生奇迹。
摘要
-
PySpark 是 Spark 的 Python API,是一个用于大规模数据分析的分布式框架。它将 Python 编程语言的丰富性和动态性带给 Spark。
-
Spark 很快:它的速度归功于对可用 RAM 的明智使用以及一个积极和懒加载的查询优化器。
-
您可以使用 Spark 在 Python、Scala、Java、R 等多种语言中。您还可以使用 SQL 进行数据处理。
-
Spark 使用一个驱动程序来处理指令并协调工作。执行器从主节点接收指令并执行工作。
-
PySpark 中的所有指令要么是转换,要么是动作。因为 Spark 是懒加载的,只有动作才会触发指令链的计算。
¹ 这可以是一个有趣的概率练习来计算,但我将尽量将数学简化到最小。
² Spark 背后的公司 Databricks 有一个名为 Photon 的项目,这是用 C++ 重写的 Spark 执行引擎。
³ 如同往常,标准的免责声明适用:并不是每个 Hadoop 作业在 Spark 中都会变快。效果因人而异。在做出大的架构变更之前,请始终测试您的作业。
⁴ 术语 master 正在逐步淘汰。替代方案尚未确定,但您可以在以下链接中关注讨论:issues.apache.org/jira/browse/SPARK-32333。
第一部分. 熟悉起来:PySpark 的初步步骤
当与新技术一起工作时,最好的方法是直接上手,在过程中建立我们的直觉。这一部分简要介绍了 PySpark,然后介绍了两个不同的用例。
第一章介绍了驱动 Spark 的技术和计算模型。
然后,在第二章和第三章中,我们构建了一个简单的端到端程序,并学习如何以可读和直观的方式组织 PySpark 代码。我们从文本数据的摄取开始,到处理,再到结果的展示,最后以非交互式的方式提交程序。
第四章和第五章探讨了与表格数据一起工作,这是最常用的数据类型。我们基于前几章(已经!)的基础,来操纵结构化数据以满足我们的需求。在第一部分结束时,你应该能够自信地从头到尾编写自己的简单程序!
2 在 PySpark 中的第一个数据程序
本章涵盖
-
启动和使用
pysparkshell 进行交互式开发 -
将数据读取和摄取到数据框中
-
使用
DataFrame结构探索数据 -
使用
select()方法选择列 -
使用
explode()将单层嵌套数据重塑为不同的记录 -
将简单函数应用于您的列以修改它们包含的数据
-
使用
where()方法过滤列
任何复杂的数据驱动应用程序,无论多么复杂,都可以归结为我们认为的三个元步骤,这些步骤在程序中很容易区分:
-
我们首先加载或读取我们希望处理的数据。
-
我们通过一些简单的指令或一个非常复杂的机器学习模型来转换数据。
-
然后,我们将结果数据导出(或存储),无论是放入文件中还是将我们的发现总结成可视化。
接下来的两章将通过创建简单的 ETL(提取、转换和加载,这是一种更商业化的说法,即摄取、转换和导出)来介绍 PySpark 的基本工作流程。您将在本书构建的每个程序中找到这三个简单的步骤,从简单的摘要到最复杂的 ML 模型。我们将大部分时间花在 pyspark shell 中,逐个步骤交互式地构建我们的程序。就像正常的 Python 开发一样,使用 shell 或 REPL(我将交替使用这两个术语)提供快速反馈和快速进展。一旦我们对结果感到满意,我们将包装我们的程序,以便我们可以以批量模式提交它。
注意,REPL 代表读取、评估、打印和循环。在 Python 的情况下,它代表我们输入命令并读取结果的交互式提示符。
数据操作是任何数据驱动程序最基本且最重要的方面,PySpark 非常注重这一点。它是我们希望执行的任何报告、机器学习或数据科学练习的基础。本节为您提供工具,不仅可以使用 PySpark 在规模上操作数据,还可以从数据转换的角度思考。显然,我们无法涵盖 PySpark 提供的每个函数,但我提供了对我们使用的函数的良好解释。我还介绍了如何使用 shell 作为友好提醒,以防您忘记某些功能的工作方式。
由于这是您在 PySpark 中的第一个端到端程序,我们将从一个简单的问题开始解决:英语中最常用的单词是什么?由于收集英语语言中所有产生的材料将是一项庞大的工作,我们从一个非常小的样本开始:简·奥斯汀的《傲慢与偏见》。我们首先让程序与这个小的样本一起工作,然后将其扩展以处理更大的文本语料库。我在构建新程序时使用这个原则——从本地数据样本开始,以正确地获取结构和概念——当在云环境中工作时,这意味着探索时成本更低。一旦我对程序的流程有信心,我就会在完整的数据集上全速运行所有节点。
由于这是我们第一个程序,我需要介绍许多新概念,因此本章将专注于程序的数据操作部分。第三章将涵盖最终的计算,以及封装我们的程序并对其进行扩展。
小贴士:本书的代码库包含了示例和练习中使用的代码和数据。您可以在 mng.bz/6ZOR 上在线获取。
2.1 设置 PySpark shell
Python 提供了一个交互式开发环境(REPL)。由于 PySpark 是一个 Python 库,它也使用相同的环境。它通过在您提交指令时立即提供反馈来加速您的开发过程,而不是强迫您编译整个程序并作为一个大型的单体块提交。我甚至可以说,在 PySpark 中使用 REPL 更加有用,因为每个操作都可能需要相当长的时间。程序在中间崩溃总是令人沮丧的,但当你已经运行了一个数据密集型作业几个小时后,这会更糟。
对于本章(以及本书的其余部分),我假设您可以访问一个工作状态的 Spark 安装,无论是本地还是云上。如果您想自己进行安装,附录 B 包含了针对 Linux、macOS 和 Windows 的逐步安装说明。如果您无法在自己的计算机上安装它,或者您更愿意不安装,同样的附录还提供了一些基于云的选项。
一切设置完成后,确保一切运行的最简单方法是通过在终端中输入 pyspark 来启动 PySpark shell。您应该会看到一个 Spark 标志的 ASCII 艺术版本以及一些有用的信息。列表 2.1 展示了我本地机器上发生的情况。在第 2.1.1 节中,您将找到一个运行 pyspark 作为命令的更不神奇的选择,这将帮助您将 PySpark 集成到现有的 Python REPL 中。
列表 2.1 在本地机器上启动 pyspark
$ pyspark
Python 3.8.8 | packaged by conda-forge | (default, Feb 20 2021, 15:50:57)
[Clang 11.0.1 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
21/08/23 07:28:16 WARN Utils: Your hostname, gyarados-2.local resolves to a loopback address:
127.0.0.1; using 192.168.2.101 instead (on interface en0)
21/08/23 07:28:16 WARN Utils: Set SPARK_LOCAL_IP if you need to bind to another address
21/08/23 07:28:17 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform...
using builtin-java classes where applicable ❶
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel). ❷
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/__ / .__/\_,_/_/ /_/\_\ version 3.2.0 ❸
/_/
Using Python version 3.8.8 (default, Feb 20 2021 15:50:57) ❹
Spark context Web UI available at http:/ /192.168.2.101:4040 ❺
Spark context available as 'sc' (master = local[*], app id = local-1629718098205). ❻
SparkSession available as 'spark'. ❻
+In [1]: ❼
❶ 当在本地使用 PySpark 时,您通常不会有一个预先配置好的完整 Hadoop 集群。出于学习目的,这是完全可以接受的。
❷ Spark 正在指示它将为您提供多少细节。我们将在第 2.1.2 节中看到如何配置这一点。
❸ 我们使用 Spark 版本 3.2.0。
❹ PySpark 正在使用你路径上的 Python。这将显示主节点上的 Python 版本。由于我们是在本地工作,这是安装在我机器上的 Python。
❺ Spark UI 可在该地址访问(请参阅第十一章了解如何高效使用它)。
❻ pyspark shell 通过变量 spark 和 sc 为您提供了一个入口点。更多内容将在 2.1.1 节中介绍。
❼ REPL 现在已准备好接受您的输入!
没有 IPython?没问题!
我强烈建议你在使用 PySpark 的交互模式下使用 IPython。IPython 是 Python shell 的一个更好的前端,具有许多有用的功能,例如更友好的复制粘贴和语法高亮。附录 B 中的安装说明包括配置 PySpark 以使用 IPython shell。
如果你没有使用 IPython REPL,你将看到类似以下内容:
Using Python version 3.9.4 (default, Apr 5 2021 01:47:16)
Spark context Web UI available at http:/ /192.168.0.12:4040
Spark context available as 'sc' (master = local[*], app id = local-1619348090080).
SparkSession available as 'spark'.
>>>
附录 B 还提供了如何使用 Jupyter 笔记本界面来使用 PySpark 的说明,如果您更喜欢这种用户体验。在云中——例如,当使用 Databricks 时——您通常会默认提供使用笔记本的选项。
pyspark程序提供了快速便捷的访问 Python REPL 的方法,其中 PySpark 已预先配置:在列表 2.1 的最后两行中,我们看到变量spark和sc已预先配置。当使用我最喜欢的代码编辑器时,我通常更喜欢从一个常规的 python/IPython shell 开始,并从该 shell 添加 Spark 实例,就像附录 B 中那样。在下一节中,我们将通过定义和实例化它们来探索spark和sc作为 PySpark 程序的入口点。
2.1.1 SparkSession 入口点
本节介绍了SparkSession对象及其在程序中作为 PySpark 功能入口点的角色。了解它是如何创建和使用的,可以消除一些设置 PySpark 时的神秘感。我还解释了如何在现有的 REPL 中连接 PySpark,从而简化了与 Python IDE 和工具的集成。
如果你已经启动了pyspark shell,使用exit()(或 Ctrl-D)可以让你回到常规终端。启动一个python(或者更好的是ipython)shell,并在其中输入列表 2.2 中的代码;我们手动创建spark对象。这非常明确地表明 PySpark 被用作 Python 库,而不是作为单独的工具。当你从 Python REPL 开始时,很容易将 Python 库与 PySpark 混合和融合。第八章和第九章专注于在 PySpark 的数据框中集成 Python 和 pandas 代码。
PySpark 通过SparkSession.builder对象使用构建器模式。对于那些熟悉面向对象编程的人来说,构建器模式提供了一系列方法来创建一个高度可配置的对象,而无需多个构造函数。在本章中,我们只关注最理想的情况,但随着我们研究集群配置和向我们的作业添加依赖项,SparkSession构建器模式将在第二部分和第三部分中变得越来越有用。
在列表 2.2 中,我们开始使用构建器模式,然后链式一个配置参数来定义应用程序名称。这不是必需的,但在监控你的作业时(见第十一章),拥有一个独特且经过深思熟虑的作业名称将有助于了解各个作业。我们通过.getOrCreate()方法完成构建器模式,以具体化和实例化我们的 SparkSession。
列表 2.2:从头开始创建SparkSession入口点
from pyspark.sql import SparkSession ❶
spark = (SparkSession
.builder ❷
.appName("Analyzing the vocabulary of Pride and Prejudice.") ❸
.getOrCreate())
❶ SparkSession 的入口点位于 pyspark.sql 包中,提供了数据转换的功能。
❷ PySpark 提供了一个构建器模式抽象,用于构建 SparkSession,其中我们链式调用方法来配置入口点。
❸ 提供一个相关的 appName 有助于识别在 Spark 集群上运行的程序(见第十一章)。
注意:通过使用getOrCreate()方法,你的程序将在交互式和批处理模式下工作,避免在已存在一个SparkSession的情况下创建新的SparkSession。注意,如果会话已存在,你将无法更改某些配置设置(大多数与 JVM 选项相关)。如果你需要更改SparkSession的配置,请杀死所有进程并从头开始,以避免任何混淆。
在第一章中,我们简要介绍了名为SparkContext的 Spark 入口点,它是你的 Python REPL 和 Spark 集群之间的联络员。SparkSession是它的超集。它封装了SparkContext,并为与 Spark SQL API 交互提供了功能,这包括我们在大多数程序中使用的 DataFrame 结构。为了证明我们的观点,看看从我们的SparkSession对象获取SparkContext有多容易——只需从spark调用sparkContext属性:
$ spark.sparkContext
# <SparkContext master=local[*] appName=Analyzing the vocabulary of [...]>
SparkSession对象是 PySpark API 中较新的添加,它从版本 2.0 开始出现。这是由于 API 以使 DataFrame(作为主要数据结构)比低级别的 RDD 有更多空间的方式发展。在此之前,你必须使用另一个对象(称为SQLContext)来使用 DataFrame。将所有内容放在一个伞下要容易得多。
本书将主要关注 DataFrame 作为我们的主要数据结构。我将在第八章讨论 RDD,当时我们将讨论低级 PySpark 编程以及如何在程序中嵌入我们的 Python 函数。在下一节中,我将解释我们如何通过日志级别使用 Spark 来提供更多(或更少!)关于其基础的信息。
阅读旧的 PySpark 代码
虽然这本书展示了现代 PySpark 编程,但我们并不生活在真空中。在网上,你可能会遇到使用旧版SparkContext/sqlContext组合的旧 PySpark 代码。你也会看到sc变量映射到SparkContext入口点。根据我们对SparkSession和SparkContext的了解,我们可以通过以下变量赋值来推理旧 PySpark 代码:
sc = spark.sparkContext
sqlContext = spark
您将在 API 文档中看到SQLContext的痕迹,以保持向后兼容性。我建议避免使用它,因为新的SparkSession方法更干净、更简单,并且更具未来性。
如果您从命令行运行pyspark,所有这些都会为您定义好,如列表 2.1 所示。
2.1.2 配置 Spark 的“话多”程度:日志级别
本节介绍了日志级别,可能是 PySpark 程序中最被忽视(且令人烦恼)的元素。监控您的 PySpark 作业是开发健壮程序的重要部分。PySpark 提供了许多日志级别,从什么也不显示到对集群上发生的所有事情的完整描述。pyspark shell 默认为WARN,当我们学习时可能会有些话太多。更重要的是,非交互式 PySpark 程序(您将大部分时间用于运行脚本)默认为过度分享的INFO级别。幸运的是,我们可以通过使用下一列表中的代码来更改会话的设置。
列表 2.3 决定您希望 PySpark 有多“话多”
spark.sparkContext.setLogLevel("KEYWORD")
表 2.1 列出了您可以传递给setLogLevel(作为字符串)的可用关键词。每个后续关键词都包含所有前面的关键词,唯一的例外是OFF,它不会显示任何内容。
表 2.1 日志级别关键词
| 关键词 | 含义 |
|---|---|
OFF |
完全不记录日志(不推荐)。 |
FATAL |
只显示致命错误。致命错误将使您的 Spark 集群崩溃。 |
ERROR |
将显示FATAL以及其他可恢复的错误。 |
WARN |
添加警告(而且有很多)。 |
INFO |
将提供运行时信息,例如分区和数据恢复(见第一章)。 |
DEBUG |
将提供关于您作业的调试信息。 |
TRACE |
将跟踪您的作业(更详细的调试日志)。可能非常有信息量,但非常令人烦恼。 |
ALL |
PySpark 能输出的所有内容,它都会输出。与OFF一样有用。 |
注意:当使用pyspark shell 时,当您输入命令时,任何比WARN更话多的内容都可能显示出来,这使得向 shell 中输入命令变得非常困难。您可以根据自己的喜好调整日志级别,但除非对当前任务有价值,否则我们不会显示任何输出。将日志级别设置为ALL是让未锁电脑的同事感到非常烦恼的一个非常好的方法。这不是我告诉您的。
您现在已经启动了 REPL 并准备好接收输入。现在就足够进行这些日常维护工作了。让我们开始规划我们的程序并开始编码!
2.2 映射我们的程序
本节映射了我们简单程序的大纲。在数据分析之前花时间设计可以带来回报,因为我们可以在知道将要发生什么的情况下构建代码。这将最终加快我们的编码速度,并提高代码的可靠性和模块化。把它想象成烹饪时阅读食谱:您永远不希望在揉面团时意识到您少了一杯面粉!
在本章的引言中,我们介绍了我们的问题陈述:“在英语中,最常用的单词是什么?”在我们甚至能在 REPL 中编写代码之前,我们必须首先确定程序需要执行的主要步骤:
-
读取—读取输入数据(我们假设是一个纯文本文件)。
-
标记—将每个单词标记化。
-
清理—删除任何标点符号和/或非单词标记。将每个单词转换为小写。
-
计数—计算文本中每个单词的出现频率。
-
答案—返回前 10(或 20、50、100)个。
从视觉上看,我们程序的简化流程将类似于图 2.1。

图 2.1 我们程序的简化流程,展示了五个步骤
我们的目标相当宏伟:英语语言在历史上产生了难以估量的书面材料。由于我们正在学习,我们将从一个相对较小的来源开始,让程序运行起来,然后扩展它以适应更大的文本库。为此,我选择了简·奥斯汀的《傲慢与偏见》,因为它已经是纯文本格式并且可以免费获取。在下一节中,我们将摄取和探索我们的数据,以开始构建我们的程序。
数据分析和帕累托原理
帕累托原理,也称为 80/20 法则,通常总结为“20%的努力将产生 80%的结果。”在数据分析中,我们可以将这 20%视为分析、可视化或机器学习模型,任何为接收者提供实质性价值的东西。
剩余的部分是我所说的无形工作:摄取数据、清理数据、理解其含义并将其塑造成可用的形式。如果你看看你的简单步骤,步骤 1 到 3 可以被认为是无形工作:我们正在摄取数据并使其为计数过程做好准备。步骤 4 和 5 是可见的,它们在回答我们的问题(有人可能会争论只有步骤 5 在进行可见工作,但让我们不要过于纠结于此)。步骤 1 到 3 之所以存在,是因为数据需要处理才能为我们的问题所用。这些步骤不是问题的核心,但我们不能没有它们。
当你在构建项目时,这部分将是耗时最长的部分,你可能会倾向于(或者被迫!)在这方面节省时间。始终记住,你摄取和处理的这些数据是程序的原始材料,而给它垃圾将产生,嗯,垃圾。
2.3 吞吐和探索:为数据转换做准备
本节涵盖了每个 PySpark 程序都会遇到的三种操作,无论您的程序性质如何:将数据摄取到结构中,打印结构(或模式)以查看数据是如何组织的,最后展示数据样本以供审查。这些操作对于任何数据分析都是基本的,无论是文本(本章和第三章),表格(大多数章节,尤其是第四章和第五章),甚至是二进制或分层数据(第六章);通用的蓝图和方法将在您的 PySpark 之旅的每个地方都适用。
2.3.1 使用 spark.read 将数据读取到数据框中
我们程序的第一步是将数据以我们可以进行工作的结构中摄取。本节介绍了 PySpark 提供的基本数据读取功能以及它是如何针对纯文本进行优化的。
在摄取任何数据之前,我们需要选择它将去往何处。PySpark 提供了两种主要结构来存储在执行操作时的数据:
-
RDD
-
数据框
RDD 在很长一段时间内是唯一的结构。它看起来像是一个分布式的对象(或行)集合。我将它可视化为一个你可以下命令的袋子。你通过在袋子中的项目上使用常规 Python 函数将命令传递给 RDD。
数据框是 RDD 的更严格版本。从概念上讲,您可以将其视为一个表格,其中每个单元格可以包含一个值。数据框大量使用列的概念,您在列上操作,而不是在 RDD 中那样在记录上操作。图 2.2 提供了这两个结构的视觉总结。数据框现在是主导的数据结构,我们将几乎在本书中独家使用它;第八章涵盖了 RDD(一个更通用和灵活的结构,数据框从中继承),用于需要逐记录灵活性的情况。

图 2.2 RDD 与数据框的比较。在 RDD 中,我们将每条记录视为一个独立的实体。使用数据框时,我们主要与列进行交互,对它们执行函数。如果需要,我们仍然可以通过 RDD 访问数据框的行。
如果您以前使用过 SQL,您会发现数据框实现借鉴了 SQL 的很多灵感。数据组织和操作模块的名称甚至被命名为pyspark.sql!此外,第七章介绍了如何在同一程序中混合 PySpark 和 SQL 代码。
使用DataFrameReader对象将数据读取到数据框中,我们可以通过spark.read访问该对象。列表 2.4 中的代码显示了该对象及其公开的方法。我们识别了几种文件格式:CSV 代表逗号分隔值(我们将在第四章早期使用它),JSON 代表 JavaScript 对象表示法(一种流行的数据交换格式),而文本则是,嗯,就是纯文本。
列表 2.4 DataFrameReader对象
In [3]: spark.read
Out[3]: <pyspark.sql.readwriter.DataFrameReader at 0x115be1b00>
In [4]: dir(spark.read)
Out[4]: [<some content removed>, _spark', 'csv', 'format', 'jdbc', 'json',
'load', 'option', 'options', 'orc', 'parquet', 'schema', 'table', 'text']
PySpark 读取您的数据
PySpark 可以适应您处理数据的不同方式。在底层,spark.read.csv()将映射到spark.read.format('csv').load(),您可能会在野外遇到这种形式。我通常更喜欢使用直接的csv方法,因为它提供了一个方便的提醒,说明了读者可以接受的不同参数。
orc和parquet也是特别适合大数据处理的数据格式。ORC(代表“优化行列”)和 Parquet 是竞争性的数据格式,基本上服务于相同的目的。两者都是开源的,现在都是 Apache 项目的一部分,就像 Spark 一样。
PySpark 在读取和写入文件时默认使用 Parquet 格式,我们将在整本书中使用这种格式来存储我们的结果。我将在第六章提供关于使用 Parquet 或 ORC 作为数据格式的使用、优势和权衡的更详细讨论。
让我们读取列表 2.5 中的数据文件。我假设您在这个书的仓库根目录启动了 PySpark。根据您的具体情况,您可能需要更改文件所在的位置。代码全部可在 GitHub 上找到,该书的配套仓库(mng.bz/6ZOR)。
列表 2.5 “以记录时间读取”我们的简·奥斯汀小说
book = spark.read.text("./data/gutenberg_books/1342-0.txt")
book
# DataFrame[value: string]
我们得到了一个数据框,正如预期的那样!如果您将名为book的数据框输入到 shell 中,您会发现 PySpark 不会将任何数据输出到屏幕上。相反,它打印出模式,即列名及其类型。在 PySpark 的世界里,每一列都有一个类型:它代表了 Spark 的引擎如何表示值。通过将类型附加到每一列,您可以立即知道可以对数据进行哪些操作。有了这些信息,您就不会无意中尝试将整数添加到字符串中:PySpark 不会让您将 1 加到“blue”上。在这里,我们有一个名为value的列,由一个string组成。我们数据框的快速图形表示将类似于图 2.3:每一行文本(由换行符分隔)是一个记录。除了作为数据框内容的提醒外,类型对于 Spark 快速且准确地处理数据至关重要。我们将在第六章对此进行深入探讨。

图 2.3 展示了我们book数据框的高级逻辑模式,其中包含一个value字符串列。我们可以看到列名、其类型以及数据的一个小片段。
当处理数据框时,我们最常担心的是逻辑模式,即数据作为单个节点上的组织形式。我们使用模式来理解数据及其类型(整数、字符串、日期等),对于给定的数据框。当我们在 REPL 中输入变量时,Spark 会显示逻辑模式:列和类型。在实践中,你的数据框将分布在多个节点上,每个节点都有记录的一部分。在执行数据转换和分析时,使用逻辑模式更方便。第十一章通过查询计划更深入地探讨了逻辑与物理世界,这让我们了解到 Spark 是如何从高级指令到优化机器指令的。
当处理较大的数据框(想想有成百上千列)时,你可能希望更清晰地显示模式。PySpark 提供了printSchema()来以树形显示模式。我可能比其他任何方法都更常用这个方法,因为它直接提供了关于数据框结构的信息。由于printSchema()直接打印到 REPL 而没有其他选项,如果你想过滤模式,可以使用数据框的dtypes属性,它给你一个(column_name, column_type)元组的列表。你还可以使用schema属性以编程方式(作为一个数据结构)访问模式(更多信息请参见第六章)。
列表 2.6 打印我们的数据框的模式
book.printSchema()
# root ❶
# |-- value: string (nullable = true) ❷
print(book.dtypes)
# [('value', 'string')] ❸
❶ 每个数据框树都以一个根节点开始,列都附加到这个根节点上。
❷ 我们有一个包含字符串的列值,这些字符串可以是 null(或 Python 中的 None)。
❸ 同样的信息存储在数据框的 dtypes 属性下的元组列表中。
在本节中,我们将我们的文本数据导入到数据框中。这个数据框推断出了一种简单的列式结构,我们可以通过 REPL 中的变量名、printSchema()方法或dtypes属性来探索它。在下一节中,我们将超越结构,窥视数据内部。
通过使用 shell 加速学习
使用 shell 不仅适用于 PySpark,而且利用其功能通常可以节省在文档中大量搜索的时间。当我记不起要应用的确切方法时,我非常喜欢在对象上使用dir(),就像我在 2.4 列表中做的那样。
PySpark 的源代码非常详细地进行了文档说明。如果你不确定函数、类或方法的正确用法,可以打印__doc__属性,或者对于使用 IPython 的用户,可以使用尾随问号(如果你想获取更多详细信息,可以加两个问号)。
列表 2.7 在 REPL 中直接使用 PySpark 的文档
# you can use `print(spark.__doc__)` if you don't have iPython.
In [292]: spark?
Type: SparkSession
String form: <pyspark.sql.session.SparkSession object at 0x11231eb80>
File: ~/miniforge3/envs/pyspark/lib/python3.8/site-packages/pyspark/sql/session.py
Docstring:
The entry point to programming Spark with the Dataset and DataFrame API.
A SparkSession can be used create :class:`DataFrame`, register :class:`DataFrame` as
tables, execute SQL over tables, cache tables, and read parquet files.
To create a SparkSession, use the following builder pattern:
.. autoattribute:: builder
:annotation:
[... more content, examples]
2.3.2 从结构到内容:使用 show()探索我们的数据框
使用 REPL 进行交互式开发的一个关键优势是您可以在执行过程中查看您的作品。现在我们的数据已加载到数据框中,我们可以开始查看 PySpark 如何结构化我们的文本。本节涵盖了查看数据框中数据的最重要的方法,即 show()。
在 2.3.1 节中,我们看到了在 shell 中输入数据框的默认行为是提供对象的模式或列信息。虽然非常有用,但有时我们想窥视一下数据。
进入 show() 方法,它将显示一些数据行——不多也不少。与 printSchema() 一起,此方法将成为您在执行数据探索和验证时的最佳朋友。默认情况下,它将显示 20 行并截断长值。列表 2.8 中的代码显示了将此方法的默认行为应用于我们的 book 数据框。对于文本数据,长度限制是有限的(有意为之)。幸运的是,show() 提供了一些选项来显示您所需的内容。
列表 2.8 使用 .show() 方法显示少量数据
book.show()
# +--------------------+
# | value| ❶
# +--------------------+
# |The Project Guten...|
# | |
# |This eBook is for...|
# |almost no restric...|
# |re-use it under t...|
# |with this eBook o...|
# | |
# | |
# |Title: Pride and ...|
# | |
# | [... more records] |
# |Character set enc...|
# | |
# +--------------------+
# only showing top 20 rows
❶ Spark 以类似 ASCII 艺术的表格形式显示数据框中的数据,限制每个单元格的长度为 20 个字符。如果内容超出限制,将在末尾添加省略号。
show() 方法有三个可选参数:
-
n可以设置为任何正整数,并将显示该数量的行。 -
truncate,如果设置为 true,将截断列以仅显示 20 个字符。设置为False,将显示整个长度,或任何正整数以截断到特定数量的字符。 -
vertical接受布尔值,当设置为True时,将每个记录显示为一个小表格。如果您需要详细检查记录,这是一个非常有用的选项。
下一个列表中的代码展示了 book 数据框的一个更有用的视图,仅显示 10 条记录,但将它们截断到 50 个字符。现在我们可以看到更多的文本了!
列表 2.9 使用 show() 方法显示更短的长度,更宽的宽度
book.show(10, truncate=50)
# +--------------------------------------------------+
# | value|
# +--------------------------------------------------+
# |The Project Gutenberg EBook of Pride and Prejud...|
# | |
# |This eBook is for the use of anyone anywhere at...|
# |almost no restrictions whatsoever. You may cop...|
# |re-use it under the terms of the Project Gutenb...|
# | with this eBook or online at www.gutenberg.org|
# | |
# | |
# | Title: Pride and Prejudice|
# | |
# +--------------------------------------------------+
# only showing top 10 rows
show() 和 printSchema() 一起为您提供了数据框结构和内容的完整概述。毫不奇怪,当在 REPL 中构建数据分析时,您将最频繁地使用这些方法。
我们现在可以开始真正的工作了:对数据框进行转换以实现我们的目标。让我们花些时间回顾一下本章开头概述的五步:
-
[完成]读取—读取输入数据(我们假设是纯文本文件)。
-
Token—对每个单词进行标记。
-
Clean—移除任何标点符号和/或非单词标记。将每个单词转换为小写。
-
计数—计算文本中每个单词的频率。
-
回答—返回前 10(或 20、50、100)条。
在下一节中,我们开始执行一些简单的列转换,以标记和清理数据。我们的数据框将直接在我们的眼前发生变化!
可选主题:非惰性 Spark?
如果你来自其他数据框实现,例如 pandas 或 R data.frame,你可能会发现当调用变量时看到数据框的结构而不是数据摘要很奇怪。show() 方法可能对你来说是个麻烦。
如果我们退一步思考 PySpark 的用例,这很有道理。show() 是一个动作,因为它执行了在屏幕上打印数据的可见工作。作为熟练的 PySpark 程序员,我们希望避免意外触发计算链,因此 Spark 开发者将 show() 明确化。在构建复杂的转换链时,触发其执行比在准备好时输入 show() 方法要麻烦得多。这种转换与动作的区别也使得 Spark 优化器有更多机会生成更高效的程序(参见第十一章)。
话虽如此,在学习过程中,有时你希望你的数据框在每次转换后都能被评估(我们称之为 急切评估)。从 Spark 2.4.0 版本开始,你可以配置 SparkSession 对象以支持屏幕打印。我们将在第三章中更详细地介绍如何创建 SparkSession 对象,但如果你想在 shell 中使用急切评估,你可以在你的 shell 中粘贴以下代码:
from pyspark.sql import SparkSession
spark = (SparkSession.builder
.config("spark.sql.repl.eagerEval.enabled", "True")
.getOrCreate())
书中的所有示例都假设数据框是惰性评估的,但如果你在演示 Spark,这个选项可能很有用。根据需要使用它,但请记住,Spark 的许多性能都归功于其惰性评估。你将留下一些额外的马力!
2.4 简单列转换:从句子到单词列表的转换
当我们将选定的文本导入数据框时,PySpark 为每一行文本创建了一个记录,并提供了类型为 String 的 value 列。为了对每个单词进行分词,我们需要将每个字符串拆分成一个不同的单词列表。本节将介绍使用 select() 的简单转换。我们将把我们的文本行拆分成单词,以便我们能够计数。
因为 PySpark 的代码可以相当直观,所以我一开始就一次性提供代码,然后我们会一步一步地分解每个步骤。你可以在下一部分中看到它的全部风采。
列表 2.10 将我们的文本行拆分成数组或单词
from pyspark.sql.functions import split
lines = book.select(split(book.value, " ").alias("line"))
lines.show(5)
# +--------------------+
# | line|
# +--------------------+
# |[The, Project, Gu...|
# | []|
# |[This, eBook, is,...|
# |[almost, no, rest...|
# |[re-use, it, unde...|
# +--------------------+
# only showing top 5 rows
在一行代码中(我不计算导入或 show(),因为 show() 只用于显示结果),我们已经做了很多事情。本节的剩余部分将介绍基本的列操作,并解释我们如何将分词步骤作为一个单行代码来实现。具体来说,我们将学习以下内容:
-
select()方法及其标准用法,即选择数据 -
alias()方法用于重命名转换后的列 -
从
pyspark.sql.functions导入列函数并使用它们
尽管我们的示例看起来非常具体(从字符串转换为单词列表),但使用 PySpark 转换函数的蓝图非常一致:当转换数据框时,您会非常频繁地看到并使用这种模式。
2.4.1 使用 select() 选择特定列
本节将介绍 select() 函数最基本的功能,即从您的数据框中选择一个或多个列。这是一个概念上非常简单的方法,但为在您的数据上执行许多附加操作提供了基础。
在 PySpark 的世界中,数据框由 Column 对象组成,您对它们执行转换。最基本的转换是恒等转换,即您返回所提供的内容。如果您以前使用过 SQL,您可能会认为这听起来像是一个 SELECT 语句,您是对的!您还获得了一个免费通行证:方法名也方便地命名为 select()。
我们将快速通过一个示例:选择 book 数据框的唯一列。由于我们已经知道了预期的输出,我们可以专注于 select() 方法的技巧。下面的列表提供了执行这个非常有用的任务的代码。
列表 2.11:最简单的选择语句
book.select(book.value)
PySpark 为其数据框中的每一列提供了一个点表示法,它指向该列。只要列名不包含任何特殊字符,这就是选择列的最简单方法:PySpark 会接受 $!@# 作为列名,但您将无法使用点表示法来表示此列。
PySpark 提供了多种选择列的方法。我在下面的列表中展示了四种最常见的方法。
列表 2.12:从 book 数据框中选择 value 列
from pyspark.sql.functions import col
book.select(book.value)
book.select(book["value"])
book.select(col("value"))
book.select("value")
选择列的第一种方法是我们在几段之前熟悉的信任的点表示法。第二种方法使用方括号而不是点来命名列。它解决了 $!@# 问题,因为您将列名作为字符串传递。
第三种方法使用来自 pyspark.sql.functions 模块的 col 函数。这里的主要区别是您没有指定该列来自 book 数据框。这在本书的第二部分处理更复杂的数据管道时将非常有用。我会尽可能多地使用 col 对象,因为我认为它的使用更符合习惯,这将为我们准备更复杂的使用案例,例如执行列转换(参见第四章和第五章)。
最后,第四种方法仅使用列名作为字符串。PySpark 足够智能,可以推断出我们在这里指的是列。对于简单的选择语句(以及我稍后将要介绍的其他方法),直接使用列名可能是一个可行的选项。但话虽如此,它不如其他选项灵活,一旦您的代码需要列转换,例如在 2.4.2 节中,您就必须使用另一种选项。
现在我们已经选择了列,让我们开始使用 PySpark。接下来是拆分文本行。
2.4.2 转换列:将字符串拆分为单词列表
我们刚刚看到了在 PySpark 中选择列的一个非常简单的方法。在本节中,我们在此基础上构建,通过选择列的转换来选择。这提供了一种强大且灵活的方式来表达我们的转换,正如你将看到的,这种模式在处理数据时将被频繁使用。
PySpark 在 pyspark.sql.functions 模块中提供了一个 split() 函数,用于将较长的字符串拆分为较短的字符串列表。此函数最常用的用例是将句子拆分为单词。split() 函数接受两个或三个参数:
-
包含
字符串的列对象 -
一个 Java 正则表达式分隔符,用于拆分字符串
-
一个可选的整数,表示我们应用分隔符的次数(此处未使用)
由于我们想要拆分单词,我们不会使我们的正则表达式过于复杂,并使用空格字符进行拆分。下一个列表显示了我们的代码结果。
列表 2.13 将我们的文本行拆分为单词列表
from pyspark.sql.functions import col, split
lines = book.select(split(col("value"), " "))
lines
# DataFrame[split(value, , -1): array<string>]
lines.printSchema()
# root
# |-- split(value, , -1): array (nullable = true)
# | |-- element: string (containsNull = true)
lines.show(5)
# +--------------------+
# | split(value, , -1)|
# +--------------------+
# |[The, Project, Gu...|
# | []|
# |[This, eBook, is,...|
# |[almost, no, rest...|
# |[re-use, it, unde...|
# +--------------------+
# only showing top 5 rows
split 函数将我们的 字符串 列转换成了包含一个或多个 字符串 元素的 数组 列。这正是我们所期待的:甚至在查看数据之前,看到结构按计划运行是一种很好的方法来验证我们的代码。
观察我们打印的五行,我们可以看到我们的值现在由逗号分隔,并用方括号括起来,这是 PySpark 视觉表示数组的方式。第二条记录为空,所以我们只看到 [],一个空数组。
PySpark 的内置数据操作函数非常实用,你应该花点时间浏览 API 文档 (spark.apache.org/docs/latest/api/python/),看看核心功能提供了什么。如果你找不到你想要的,第六章介绍了如何创建在 Column 对象上运行的函数,并深入探讨了 PySpark 的复杂数据类型,如数组。内置的 PySpark 函数与纯 Spark(在 Java 和 Scala 中)一样高效,因为它们直接映射到 JVM 函数。(有关更多信息,请参阅以下侧边栏。)
高级主题:PySpark 的架构和 JVM 遗产
如果你像我一样,你可能对查看 PySpark 如何构建其核心 pyspark .sql.functions 函数感兴趣。如果你查看 split() 函数的源代码(来自 API 文档;见 mng.bz/oa4D),你可能会感到失望:
def split(str, pattern, limit=-1):
""" [... elided ] """
sc = SparkContext._active_spark_context
return Column(sc._jvm.functions.split(_to_java_column(str), pattern, limit))
它实际上指的是 sc._jvm.functions 对象的 split 函数。这与数据框的构建方式有关。PySpark 使用一个转换层来调用 JVM 函数来实现其核心功能。这使得 PySpark 更快,因为你不必总是将你的 Python 代码转换成 JVM 代码;这已经为你完成了。它还使得将 PySpark 移植到另一个平台变得更容易:如果你可以直接调用 JVM 函数,你就不必重新实现一切。
这是在巨人的肩膀上站立的一个权衡。这也解释了为什么 PySpark 在其内置函数中使用 JVM 基础的正则表达式而不是 Python 的正则表达式。第三部分将大大扩展这个主题,但在此期间,探索 PySpark 的源代码!
现在我们将文本行分解成单词后,出现了一点小麻烦:Spark 给我们的列起了一个非常不直观的名字(split(value,, ,, -1))。下一节将介绍我们如何将转换后的列重命名为我们喜欢的名字,以便我们可以显式地控制列的命名模式。
2.4.3 重命名列:alias 和 withColumnRenamed
当对列进行转换时,PySpark 会给结果列提供一个默认名称。在我们的例子中,我们在使用空格作为分隔符拆分值列后,得到了 split(value,, ,, -1) 这个名字。虽然准确,但并不适合程序员。本节提供了一个蓝图,使用 alias() 和 withColumnRenamed() 来重命名新创建的和现有的列。
隐含的假设是你将使用 alias() 方法自己重命名结果列。它的使用并不复杂:当应用于列时,它接受一个参数并返回应用了该参数的列,并带有新名称。下一个列表提供了一个简单的演示。
列表 2.14 重命名前后的数据框
book.select(split(col("value"), " ")).printSchema()
# root
# |-- split(value, , -1): array (nullable = true) ❶
# | |-- element: string (containsNull = true)
book.select(split(col("value"), " ").alias("line")).printSchema()
# root
# |-- line: array (nullable = true) ❷
# | |-- element: string (containsNull = true)
❶ 我们的新列名为 split(value, , -1),这并不太美观。
❷ 我们将列重命名为 name。好多了!
alias() 在对列进行操作后提供了一个干净且明确的方式来命名你的列。另一方面,它并不是唯一的重命名选项。另一种同样有效的方法是在数据框上使用 .withColumnRenamed() 方法。它接受两个参数:列的当前名称和想要的名称。由于我们已经在列上使用 split 进行了操作,因此链式 alias 比使用其他方法更有意义。列表 2.15 展示了两种不同的方法。
在编写代码时,选择这两种选项相当容易:
-
当你使用指定要出现的列的方法时,如
select()方法,使用alias()。 -
如果你只想重命名列而不更改数据框的其余部分,请使用
.withColumnRenamed。请注意,如果该列不存在,PySpark 将将此方法视为无操作,不会执行任何操作。
列表 2.15 两种重命名列的方法
# This looks a lot cleaner
lines = book.select(split(book.value, " ").alias("line"))
# This is messier, and you have to remember the name PySpark assigns automatically
lines = book.select(split(book.value, " "))
lines = lines.withColumnRenamed("split(value, , -1)", "line")
本节介绍了一套新的 PySpark 基础知识:我们学习了如何选择不仅包括普通列还包括列转换。我们还学习了如何显式命名结果列,避免 PySpark 预测但令人不快的命名约定。现在我们可以继续进行剩余的操作。如果我们看看我们的五个步骤,我们已经完成了第二步的一半。我们有一个单词列表,但我们需要每个标记或单词成为它自己的记录:
-
[完成]读取—读取输入数据(我们假设是一个纯文本文件)。
-
[进行中]标记—对每个单词进行标记。
-
清理—删除任何标点符号和/或不是单词的标记。将每个单词转换为小写。
-
计数—计算文本中每个单词的出现频率。
-
答案—返回前 10(或 20、50、100)个。
2.4.4 重新塑形你的数据:将列表分解为行
当处理数据时,数据准备中的一个关键要素是确保它“符合模式”;这意味着要确保包含数据的结构是逻辑上合理且适合当前工作的。目前,我们数据框中的每一行都包含多个单词到一个字符串数组中。最好每个单词对应一个记录。
进入 explode() 函数。当应用于包含容器类型数据结构(如数组)的列时,它将每个元素转换为它自己的行。这比用文字解释更容易用视觉方式说明,图 2.4 解释了这个过程。

图 2.4 将 array[String] 数据框分解为 String 数据框。每个数组的每个元素都成为其自己的记录。
代码结构与 split() 相同,你可以在下一个列表中看到结果。我们现在有一个数据框,最多每行只有一个单词。我们几乎完成了!
列表 2.16 将数组列分解为元素行
from pyspark.sql.functions import explode, col
words = lines.select(explode(col("line")).alias("word"))
words.show(15)
# +----------+
# | word|
# +----------+
# | The|
# | Project|
# | Gutenberg|
# | EBook|
# | of|
# | Pride|
# | and|
# |Prejudice,|
# | by|
# | Jane|
# | Austen|
# | |
# | This|
# | eBook|
# | is|
# +----------+
# only showing top 15 rows
在继续我们的数据处理之旅之前,我们可以退一步看看数据的一个样本。仅通过查看返回的 15 行,我们就可以看到“偏见”有一个逗号,以及“奥斯汀”和“这”之间的单元格包含空字符串。这为我们提供了在开始分析单词频率之前需要执行的下一步的良好蓝图。
回顾我们的五个步骤,我们现在可以得出第二步,我们的单词已经被分词。让我们来处理第三步,我们将清理我们的单词以简化计数:
-
[完成]读取—读取输入数据(我们假设是一个纯文本文件)。
-
[完成]标记—对每个单词进行标记。
-
清理—删除任何标点符号和/或不是单词的标记。将每个单词转换为小写。
-
计数—计算文本中每个单词的出现频率。
-
答案—返回前 10(或 20、50、100)个。
2.4.5 处理单词:更改大小写和删除标点符号
到目前为止,使用split和explode,我们的模式如下:在pyspark.sql.functions中找到相关函数,应用它,然后获利!本节将使用相同的成功公式来规范化我们的单词的大小写并删除标点符号,因此我将专注于函数的行为而不是如何应用它们。本节负责将大小写转换为小写(使用lower函数)并通过使用正则表达式删除标点符号。
让我们直接进入正题。列表 2.17 包含了将数据框中所有单词转换为小写的源代码。代码看起来非常熟悉:我们选择由lower转换的列,这是一个降低传递给参数的列内数据的字面的 PySpark 函数。然后我们将结果列别名为word_lower以避免 PySpark 的默认命名约定。
列表 2.17 降低数据框中单词的大小写
from pyspark.sql.functions import lower
words_lower = words.select(lower(col("word")).alias("word_lower"))
words_lower.show()
# +-----------+
# | word_lower|
# +-----------+
# | the|
# | project|
# | gutenberg|
# | ebook|
# | of|
# | pride|
# | and|
# | prejudice,|
# | by|
# | jane|
# | austen|
# | |
# | this|
# | ebook|
# | is|
# | for|
# | the|
# | use|
# | of|
# | anyone|
# +-----------+
# only showing top 20 rows
接下来,我们想要清除我们的单词中的任何标点符号和其他非有用字符;在这种情况下,我们将仅使用正则表达式保留字母(参见本节末尾的正则表达式参考[或regex])。这可能有点棘手:我们不会在这里即兴创作一个完整的 NLP(自然语言处理)库,而是依赖 PySpark 在其数据处理工具箱中提供的功能。本着保持这个练习简单化的精神,我们将保留第一个连续的字母组作为单词,并删除其余部分。这将有效地删除标点符号、引号和其他符号,但会牺牲对更奇特单词结构的鲁棒性。下一个列表显示了所有代码的精彩之处。
列表 2.18 使用regexp_extract保留看起来像单词的内容
from pyspark.sql.functions import regexp_extract
words_clean = words_lower.select(
regexp_extract(col("word_lower"), "[a-z]+", 0).alias("word") ❶
)
words_clean.show()
# +---------+
# | word|
# +---------+
# | the|
# | project|
# |gutenberg|
# | ebook|
# | of|
# | pride|
# | and|
# |prejudice|
# | by|
# | jane|
# | austen|
# | |
# | this|
# | ebook|
# | is|
# | for|
# | the|
# | use|
# | of|
# | anyone|
# +---------+
# only showing top 20 rows
❶ 我们只匹配多个小写字母(介于 a 和 z 之间)。加号(+)将匹配一个或多个出现。
到目前为止,我们的单词数据框看起来相当规范,除了austen和this之间的空单元格。在下一节中,我们将介绍通过删除任何空记录的过滤操作。
我们其余人的正则表达式
PySpark 在我们迄今为止使用的两个函数中使用了正则表达式:regexp_extract和split。你不必成为正则表达式专家就能使用 PySpark(我当然不是)。在整个书中,每当我使用一个非平凡的正则表达式时,我都会提供一个简单的英文定义,以便你能够跟上。
如果你感兴趣自己构建,RegExr([regexr.com/](https://regexr.com/))网站非常有用,以及 Steven Levithan 和 Jan Goyvaerts 的《正则表达式烹饪书》(O’Reilly,2012 年)。
练习 2.1
给定以下exo_2_1_df数据框,solution_2_1_df数据框将包含多少条记录?(注意:无需编写代码来解决这个问题。)
exo_2_1_df.show()
# +-------------------+
# | numbers|
# +-------------------+
# | [1, 2, 3, 4, 5]|
# |[5, 6, 7, 8, 9, 10]|
# +-------------------+
solution_2_1_df = exo_2_1_df.select(explode(col("numbers")))
2.5 过滤行
一个重要的数据处理操作是根据某个谓词过滤记录。在我们的例子中,空白单元格不应被视为单词!本节介绍了如何从数据帧中过滤记录。在 select() 记录之后,过滤可能是对您的数据执行的最频繁且最简单的操作;PySpark 提供了一个简单的流程来完成此操作。
从概念上讲,我们应该能够为每条记录提供一个测试。如果它返回 true,我们保留该记录。如果是 false?您就被淘汰了!PySpark 提供了不止一个,而是两个相同的方法来执行此任务。您可以使用 .filter() 或其别名 .where()。这种重复是为了方便来自其他数据处理引擎或库的用户过渡;有些人使用一个,有些人使用另一个。PySpark 提供了这两个,所以不可能有争议!我更喜欢 filter(),因为 w 映射到更多的数据帧方法(第四章中的 withColumn() 或第三章中的 withColumnRenamed())。如果我们查看下一个列表,我们可以看到列可以使用常用的 Python 比较运算符与值进行比较。在这种情况下,我们使用“不等于”,或 !=。
列表 2.19 使用 where 或 filter 过滤数据帧中的行
words_nonull = words_clean.filter(col("word") != "")
words_nonull.show()
# +---------+
# | word|
# +---------+
# | the|
# | project|
# |gutenberg|
# | ebook|
# | of|
# | pride|
# | and|
# |prejudice|
# | by|
# | jane|
# | austen|
# | this| ❶
# | ebook|
# | is|
# | for|
# | the|
# | use|
# | of|
# | anyone|
# | anywhere|
# +---------+
# only showing top 20 rows
❶ 空白单元格已消失!
提示:如果您想在 filter() 方法中否定整个表达式,PySpark 提供了 ~ 操作符。理论上,我们可以使用 filter(~(col("word") == ""))。查看本章末尾的练习,以了解它们在实际应用中的使用。您还可以使用 SQL 风格的表达式;请参阅第七章了解替代语法。
我们本可以在程序中更早地进行过滤。这是一个需要考虑的权衡:如果我们过滤得太早,我们的过滤条件将因为没有任何原因而变得滑稽复杂。由于 PySpark 会在触发操作之前缓存所有转换,因此我们可以专注于代码的可读性,并让 Spark 优化我们的意图,就像我们在第一章中看到的那样。我们将在第三章中看到如何将 PySpark 代码转换成几乎像一系列书面指令一样阅读,并利用延迟评估。
现在似乎是休息和反思我们所取得的成果的好时机。如果我们看看我们的五个步骤,我们已经完成了 60%。我们的清理步骤处理了非字母字符并过滤了空记录。我们准备好对分析结果进行计数和显示:
-
[完成]读取—读取输入数据(我们假设是一个纯文本文件)。
-
[完成]标记—将每个单词标记化。
-
[完成]清理—删除任何标点符号和/或不是单词的标记。将每个单词转换为小写。
-
计数—计算文本中每个单词的出现频率。
-
答案—返回前 10(或 20、50、100)条。
在 PySpark 操作方面,我们在数据处理空间中覆盖了大量的内容。您现在不仅可以选择列,还可以选择列的转换,并在事后按需重命名它们。我们学习了如何将嵌套结构,如数组,分解成单个记录。最后,我们学习了如何使用简单条件过滤记录。
我们现在可以休息一下了。下一章将涵盖我们程序的结束。我们还将探讨将我们的代码合并到一个单独的文件中,从交互式解释器(REPL)模式转向批处理模式。我们将探索简化并提高程序可读性的选项,然后通过将其扩展到更大的文本语料库来完成。
摘要
-
几乎所有的 PySpark 程序都将围绕三个主要步骤展开:读取、转换和导出数据。
-
PySpark 通过
pysparkshell 提供了一个交互式解释器(REPL,读取、评估、打印、循环),您可以在其中与数据进行交互式实验。 -
PySpark 数据帧是一组列的集合。您使用链式转换在结构上操作。PySpark 将优化转换,仅在您提交操作(如
show())时执行工作。这是 PySpark 性能的支柱之一。 -
PySpark 的列操作函数集合位于
pyspark.sql.functions。 -
您可以通过
select()方法选择列或转换后的列。 -
您可以使用
where()或filter()方法以及提供返回True或False的测试来过滤列;只有返回True的记录将被保留。 -
PySpark 可以有嵌套值的列,如元素数组。为了将元素提取到不同的记录中,您需要使用
explode()方法。
额外练习
对于所有练习,假设以下:
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
练习 2.2
给定以下数据帧,以编程方式计算不是字符串的列数(答案 = 只有一列不是字符串)。
createDataFrame() 允许您从各种来源创建数据帧,例如 pandas 数据帧或(在这种情况下)列表的列表。
exo2_2_df = spark.createDataFrame(
[["test", "more test", 10_000_000_000]], ["one", "two", "three"]
)
exo2_2_df.printSchema()
# root
# |-- one: string (nullable = true)
# |-- two: string (nullable = true)
# |-- three: long (nullable = true)
练习 2.3
重写以下代码片段,删除 withColumnRenamed 方法。哪个版本更清晰、更容易阅读?
from pyspark.sql.functions import col, length
# The `length` function returns the number of characters in a string column.
exo2_3_df = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(length(col("value")))
.withColumnRenamed("length(value)", "number_of_char")
)
练习 2.4
假设一个数据帧 exo2_4_df。以下代码块会产生错误。问题是什么,如何解决它?
from pyspark.sql.functions import col, greatest
exo2_4_df = spark.createDataFrame(
[["key", 10_000, 20_000]], ["key", "value1", "value2"]
)
exo2_4_df.printSchema()
# root
# |-- key: string (containsNull = true)
# |-- value1: long (containsNull = true)
# |-- value2: long (containsNull = true)
# `greatest` will return the greatest value of the list of column names,
# skipping null value
# The following statement will return an error
from pyspark.sql.utils import AnalysisException
try:
exo2_4_mod = exo2_4_df.select(
greatest(col("value1"), col("value2")).alias("maximum_value")
).select("key", "max_value")
except AnalysisException as err:
print(err)
练习 2.5
让我们看一下下一列表中的 words_nonull 数据帧。您可以使用存储库中的代码(code/Ch02/end_of_chapter.py)在您的 REPL 中获取加载数据帧。
列表 2.20 练习的 words_nonull
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, split, explode, lower, regexp_extract
spark = SparkSession.builder.getOrCreate()
book = spark.read.text("./data/gutenberg_books/1342-0.txt")
lines = book.select(split(book.value, " ").alias("line"))
words = lines.select(explode(col("line")).alias("word"))
words_lower = words.select(lower(col("word")).alias("word_lower"))
words_clean = words_lower.select(
regexp_extract(col("word_lower"), "[a-z]*", 0).alias("word")
)
words_nonull = words_clean.where(col("word") != "")
a) 删除所有单词 is 的出现。
b)(挑战)使用 length 函数,仅保留具有三个以上字符的单词。
练习 2.6
where子句接受一个布尔表达式,该表达式涉及一个或多个列以过滤数据框。除了常用的布尔运算符(>、<、==、<=、>=、!=)之外,PySpark 在pyspark.sql.functions模块中提供了其他函数,这些函数返回布尔列。
一个很好的例子是isin()方法(应用于Column对象,如col(...).isin(...)),该方法接受一个值列表作为参数,并且只返回列中的值等于列表中成员的记录。
假设你想要使用单个where()方法从words_nonull数据框中移除单词is、not、the和if,编写相应的代码。
练习 2.7
你的一个朋友向你展示了以下代码。他们不知道为什么它不起作用。你能诊断try块中的问题,解释为什么是错误,并提供一个修复方案?
from pyspark.sql.functions import col, split
try:
book = spark.read.text("./data/gutenberg_books/1342-0.txt")
book = book.printSchema()
lines = book.select(split(book.value, " ").alias("line"))
words = lines.select(explode(col("line")).alias("word"))
except AnalysisException as err:
print(err)
3 提交和扩展你的第一个 PySpark 程序
本章涵盖
-
使用
groupby和简单的聚合函数总结数据 -
对结果进行排序以便显示
-
从数据框中写入数据
-
使用
spark-submit以批处理模式启动你的程序 -
使用方法链简化 PySpark 写入
-
将程序扩展到多个文件
第二章处理了我们单词频率程序的所有数据准备工作。我们 读取 输入数据,分词 每个单词,并 清理 记录以仅保留小写单词。如果我们拿出我们的大纲,我们只需要完成步骤 4 和 5:
-
[完成]*读取:读取输入数据(我们假设是一个纯文本文件)。
-
[完成]分词:对每个单词进行分词。
-
[完成]*清理:移除任何标点符号和/或非单词标记。将每个单词转换为小写。
-
计数:计算文本中每个单词的频率。
-
答案:返回前 10(或 20、50、100)个。
在解决最后两个步骤之后,我们看看将代码打包到一个文件中以便提交给 Spark,而无需启动 REPL。我们还看一下我们的完整程序,并探讨通过删除中间变量来简化它。我们以扩展程序以适应更多数据源结束。
3.1 记录分组:计算单词频率
如果你使用与第二章结尾相同形状的数据框(你可以在书的代码仓库中的单个文件 code/Ch02/end_of_ chapter.py 中找到代码),只需做一点额外的工作。对于包含每条记录一个单词的数据框,我们只需计算单词出现次数并选择最频繁的单词。本节将展示如何使用 GroupedData 对象来计数记录并执行聚合函数——在这里,是计数项——在每个组上。
直观地,我们通过创建 组 来计数每个单词的数量:每个单词一个组。一旦这些组形成,我们就可以对每个组执行 聚合函数。在这个特定的情况下,我们计算每个组的记录数,这将给我们数据框中每个单词的出现次数。在底层,PySpark 使用 GroupedData 对象表示分组数据框;将其视为一个过渡对象,等待聚合函数将其转换为转换后的数据框。

列表 3.1 groups 对象的示意图。每个小框代表一条记录。
计算记录出现次数的最简单方法是使用 groupby() 方法,将我们希望分组的列作为参数传递。列表 3.1 中的 groupby() 方法返回一个 GroupedData 并等待进一步指令。一旦我们应用 count() 方法,我们就会得到一个包含分组列 word 以及包含每个单词出现次数的 count 列的数据框。
列表 3.1 使用 groupby() 和 count() 计算单词频率
groups = words_nonull.groupby(col("word"))
print(groups)
# <pyspark.sql.group.GroupedData at 0x10ed23da0>
results = words_nonull.groupby(col("word")).count()
print(results)
# DataFrame[word: string, count: bigint]
results.show()
# +-------------+-----+
# | word|count|
# +-------------+-----+
# | online| 4|
# | some| 203|
# | still| 72|
# | few| 72|
# | hope| 122|
# [...]
# | doubts| 2|
# | destitute| 1|
# | solemnity| 5|
# |gratification| 1|
# | connected| 14|
# +-------------+-----+
# only showing top 20 rows
查看列表 3.1 中的results数据帧,我们发现结果没有特定的顺序。事实上,如果你和我有完全相同的单词顺序,我会非常惊讶!这与 PySpark 管理数据的方式有关。在第一章中,我们了解到 PySpark 将数据分布到多个节点。当执行分组函数,如groupby()时,每个工作节点在其分配的数据上执行工作。groupby()和count()是转换操作,因此 PySpark 会懒惰地将它们排队,直到我们请求一个动作。当我们向结果数据帧传递show方法时,它触发了我们在图 3.2 中看到的计算链。

列表 3.2:在words_nonull数据帧上执行分布式分组。工作以分布式方式执行,直到我们需要通过show()方法组装结果以进行连贯显示。
提示:如果你需要根据多个列的值创建组,可以将多个列作为参数传递给groupby()。我们将在第五章中看到这个操作的实例。
由于 Spark 是懒惰的,它不关心记录的顺序,除非我们明确要求它。由于我们希望在显示中看到最常出现的单词,让我们在我们的数据帧中添加一些顺序,同时完成我们程序的最后一步:返回最常出现的单词频率。
练习 3.1
从本节中看到的word_nonull开始,以下哪个表达式会返回每个字母计数的单词数量(例如,有X个单字母单词,Y个双字母单词等)?
假设pyspark.sql.functions.col,和pyspark.sql.functions.length已被导入。
a) words_nonull.select(length(col("word"))).groupby("length").count()
b) words_nonull.select(length(col("word")).alias("length")).groupby("length").count()
c) words_nonull.groupby("length").select("length").count()
d) 这些选项中没有一个会工作。
3.2 使用 orderBy 在屏幕上排序结果
在 3.1 中,我们解释了为什么 PySpark 在执行转换时不一定维护记录的顺序。如果我们查看我们的五步蓝图,最后一步是返回不同N值的N条记录。我们已经知道如何显示特定数量的记录,所以本节重点介绍在显示之前对数据帧中的记录进行排序:
-
[完成]读取:读取输入数据(我们假设是一个纯文本文件)。
-
[完成]标记:对每个单词进行标记化。
-
[完成]清理:移除任何标点符号和/或非单词标记。将每个单词转换为小写。
-
[完成]计数:计算文本中每个单词出现的频率。
-
答案:返回前 10 个(或 20 个、50 个、100 个)。
就像我们使用groupby()按一列或多列的值对数据帧进行分组一样,我们使用orderBy()按一列或多列的值对数据帧进行排序。PySpark 提供了两种不同的语法来排序记录:
-
我们可以将列名作为参数提供,还可以有一个可选的
ascending参数。默认情况下,我们按升序对数据框进行排序;通过将ascending设置为 false,我们可以反转顺序,首先得到最大的值。 -
或者,我们可以通过
col函数直接使用Column对象。当我们想要反转排序时,我们使用列上的desc()方法。
PySpark 按列顺序对数据框进行排序,一次一列。如果您传递多个列(见第五章),PySpark 将使用第一列的值来排序数据框,然后是第二列(然后是第三列,等等),当有相同值时。由于我们只有一个列——由于groupby()而没有重复——因此,在下一个列表中应用orderBy()很简单,无论我们选择哪种语法。
列表 3.2 显示简·奥斯汀的《傲慢与偏见》中的前 10 个单词
results.orderBy("count", ascending=False).show(10)
results.orderBy(col("count").desc()).show(10)
# +----+-----+
# |word|count|
# +----+-----+
# | the| 4480|
# | to| 4218|
# | of| 3711|
# | and| 3504|
# | her| 2199|
# | a| 1982|
# | in| 1909|
# | was| 1838|
# | i| 1749|
# | she| 1668|
# +----+-----+
# only showing top 10 rows
列表非常不出所料:尽管我们无法质疑奥斯汀的词汇量,但她无法避免这样一个事实,即英语语言需要代词和其他常用词。在自然语言处理中,这些词被称为停用词,可以被移除。我们解决了原始查询,可以安心休息了。如果您想获取前 20 名、前 50 名,甚至前 1000 名,只需更改参数为show()即可。
PySpark 的方法命名约定动物园
如果您注重细节,您可能会注意到我们使用了groupby(小写),但orderBy(小驼峰式,每个单词的首字母大写,但第一个单词除外)。这似乎是一个奇怪的设计选择。
groupby()是groupBy()的别名,就像where()是filter()的别名一样。我想 PySpark 的开发者发现,通过接受这两种情况,可以避免很多打字错误。orderBy()没有这样的奢侈,原因超出了我的理解,因此我们需要注意这一点。
这部分不一致的部分是由于 Spark 的遗产。Scala 更喜欢驼峰式命名方法。另一方面,我们在第二章中看到了regexp_extract,它使用 Python 首选的蛇形命名法(单词由下划线分隔)。这里没有魔法秘诀:您必须注意 PySpark 中正在使用的不同大小写约定。
在屏幕上显示结果对于快速评估来说很棒,但大多数时候您希望它们有一定的持久性。将结果保存到文件中会更好,这样我们就可以在不每次都进行计算的情况下重用它们。下一节将介绍将数据框写入文件。
练习 3.2
为什么以下代码块中的顺序没有被保留?
(
results.orderBy("count", ascending=False)
.groupby(length(col("word")))
.count()
.show(5)
)
# +------------+-----+
# |length(word)|count|
# +------------+-----+
# | 12| 199|
# | 1| 10|
# | 13| 113|
# | 6| 908|
# | 16| 4|
# +------------+-----+
# only showing top 5 rows
3.3 从数据框写入数据
在屏幕上拥有数据对于交互式开发来说很棒,但您通常会想要导出结果。为此,我们将结果写入逗号分隔值(CSV)文件。我选择这种格式,因为它是一种人类可读的格式,这意味着我们可以审查我们操作的结果。
就像我们在 Spark 中使用 read() 和 SparkReader 来读取数据一样,我们使用 write() 和 SparkWriter 对象将我们的数据框写回到磁盘。在列表 3.3 中,我将 SparkWriter 特化以将文本导出为 CSV 文件,输出命名为 simple_count.csv。如果我们查看结果,我们可以看到 PySpark 没有创建一个 results.csv 文件。相反,它创建了一个同名目录,并在目录中放置了 201 个文件(200 个 CSV 文件 + 1 个 _SUCCESS 文件)。
列表 3.3 在多个 CSV 文件中写入我们的结果,每个分区一个
results.write.csv("./data/simple_count.csv")
# The ls command is run using a shell, not a Python prompt.
# If you use IPython, you can use the bang pattern (! ls -1).
# Use this to get the same results without leaving the IPython console.
$ ls -1 ./data/simple_count.csv ❶
_SUCCESS ❷
part-00000-615b75e4-ebf5-44a0-b337-405fccd11d0c-c000.csv
[...]
part-00199-615b75e4-ebf5-44a0-b337-405fccd11d0c-c000.csv ❸
❶ 结果被写入一个名为 simple_count.csv 的目录中。
❷ _SUCCESS 文件意味着操作成功。
❸ 我们有 part-00000 到 part-00199,这意味着我们的结果分布在 200 个文件中。
好的,朋友们!这是我们需要关注 PySpark 分布式特性的第一个时刻。就像 PySpark 会将转换工作分布到多个工作节点上一样,它也会在写入数据时做同样的事情。虽然对于我们的简单程序来说可能看起来有些麻烦,但在分布式环境中工作时会非常有用。当你有一个由大量节点组成的大集群时,拥有许多较小的文件使得逻辑上分布读取和写入数据变得容易,这使得它比拥有一个单一的巨大文件要快得多。
默认情况下,PySpark 会为每个分区提供一个文件。这意味着在我机器上运行的我们的程序最终产生了 200 个分区。这并不利于可移植性。为了减少分区的数量,我们应用 coalesce() 方法并指定所需的分区数。下一个列表显示了在使用 coalesce(1) 在写入磁盘之前对数据框进行操作时的差异。我们仍然得到一个目录,但里面只有一个 CSV 文件。任务完成!
列表 3.4 在单个分区下写入我们的结果
results.coalesce(1).write.csv("./data/simple_count_single_partition.csv")
$ ls -1 ./data/simple_count_single_partition.csv/
_SUCCESS
part-00000-f8c4c13e-a4ee-4900-ac76-de3d56e5f091-c000.csv
注意:你可能已经意识到我们在写入文件之前并没有对文件进行排序。由于我们这里的数据相当小,我们可以按频率递减的顺序写入单词。如果你有一个大的数据集,这个操作将会相当昂贵。此外,由于读取是一个可能分布的操作,有什么保证它会以相同的方式被读取?除非你通过在显示步骤之前显式地使用 orderBy() 来请求,否则永远不要假设你的数据框会保持记录相同的顺序。
到目前为止,我们的工作流程相当交互式。我们在向终端显示结果之前先写入一行或两行文本。随着我们对操作数据框结构的信心越来越强,这些显示将变得越来越少。
现在我们已经交互式地执行了所有必要的步骤,让我们看看将我们的程序放入一个单独的文件和重构的机会。
3.4 将所有内容整合起来:计数
交互式开发非常适合我们代码的快速迭代。在开发程序时,通过快速向 shell 输入代码来实验和验证我们的想法是非常好的。当实验结束后,将我们的程序整合成一段连贯的代码是很好的。本节将本章和第二章中我们编写的所有代码整合成一段可运行的代码。
REPL 允许您使用键盘上的方向箭头回溯历史,就像常规的 Python REPL 一样。为了使事情变得更容易一些,我在下一个列表中提供了逐步的程序。本节致力于简化我们的代码,使其更加简洁和易读。
列表 3.5 我们的第一段 PySpark 程序,被称为“计数简·奥斯汀”
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
col,
explode,
lower,
regexp_extract,
split,
)
spark = SparkSession.builder.appName(
"Analyzing the vocabulary of Pride and Prejudice."
).getOrCreate()
book = spark.read.text("./data/gutenberg_books/1342-0.txt")
lines = book.select(split(book.value, " ").alias("line"))
words = lines.select(explode(col("line")).alias("word"))
words_lower = words.select(lower(col("word")).alias("word"))
words_clean = words_lower.select(
regexp_extract(col("word"), "[a-z']*", 0).alias("word")
)
words_nonull = words_clean.where(col("word") != "")
results = words_nonull.groupby(col("word")).count()
results.orderBy("count", ascending=False).show(10)
results.coalesce(1).write.csv("./simple_count_single_partition.csv")
如果您将整个程序粘贴到 pyspark shell 中,程序将完美运行。将所有内容放在同一个文件中,我们可以使我们的代码更加友好,并使其更容易为未来的您所理解。首先,我们采用与 PySpark 一起工作时常用的导入约定。然后,我们重新排列代码,使其更易于阅读,正如第一章中所示。
3.4.1 使用 PySpark 的导入约定简化依赖
本节涵盖了使用 PySpark 模块时的通用约定。我们回顾了最相关的导入——转换函数——以及如何通过限定导入来帮助我们了解内容来自哪里。
这个程序使用了来自 pyspark.sql.functions 模块中的五个不同的函数。我们可能需要将其替换为有条件的导入,这是 Python 通过为模块分配一个关键字来导入模块的方式。虽然没有硬性规则,但普遍的智慧是使用 F 来指代 PySpark 的函数。下一个列表显示了前后变化。
列表 3.6 简化我们的 PySpark 函数导入
# Before
from pyspark.sql.functions import col, explode, lower, regexp_extract, split
# After
import pyspark.sql.functions as F
由于 col、explode、lower、regexp_extract 和 split 都在 pyspark.sql.functions 中,我们可以导入整个模块。由于新的导入语句导入了 pyspark.sql.functions 模块的全部内容,我们将关键字(或关键字母)赋值为 F。PySpark 社区似乎已经隐式地决定使用 F 来指代 pyspark.sql.functions,我鼓励您也这样做。这将使您的程序保持一致性,并且由于模块中的许多函数与 pandas 或 Python 内置函数共享名称,您将避免名称冲突。程序中的每个函数应用都将以 F 为前缀,就像常规的 Python-qualified 导入一样。
警告:开始导入时使用 from pyspark.sql .functions import * 可能非常诱人!不要陷入这个陷阱!这将使读者难以知道哪些函数来自 PySpark,哪些来自常规 Python。在第八章中,我们将使用用户定义函数(UDFs),这种分离将变得更加重要。这是一条好的编码卫生规则!
在随后的章节中,特别是第六章,我将介绍其他需要合格导入的功能。你选择逐个导入函数还是导入整个合格模块取决于你的用例;我通常更重视一致性而不是简洁性,并倾向于使用合格导入进行数据转换 API。
我们简化了程序的前置部分;现在让我们通过使用 PySpark 的我最喜欢的特性之一——它的链式能力——来简化我们的程序流程。
3.4.2 通过方法链简化我们的程序
如果我们看看我们应用在我们数据框上的转换方法(select()、where()、groupBy() 和 count()),它们都有一些共同点:它们都接受一个结构作为参数——在 count() 的情况下是 GroupedData 数据框——并返回一个结构。所有转换都可以看作是管道,它们消耗一个结构并返回一个修改后的结构。本节将探讨方法链以及它是如何通过消除中间变量来使程序更简洁、更易于阅读的。
我们程序中使用了大量的中间变量:每次我们执行转换时,我们都将结果赋值给一个新的变量。当使用 shell 时这很有用,因为我们保持转换的状态,并且可以在每一步结束时查看我们的工作。另一方面,一旦我们的程序运行起来,这种变量的倍增就不再那么有用,并且可能会使我们的程序在视觉上变得杂乱。
在 PySpark 中,每个转换都返回一个对象,这就是为什么我们需要将结果赋值给一个变量的原因。这意味着 PySpark 不执行就地修改。例如,以下代码块在程序中单独使用时不会做任何事情,因为我们没有将结果赋值给变量。另一方面,在 REPL 中,你会得到打印出来的返回值作为输出,所以这可以算作工作:
results.orderBy("word").count()
我们可以通过将一个方法的结果链接到下一个方法来避免中间变量。由于每个转换都返回一个数据框(或者当我们执行 groupby() 方法时是 GroupedData),我们可以直接附加下一个方法,而不需要将结果赋值给一个变量。这意味着我们可以避免除了一个变量赋值之外的所有赋值。下一列表中的代码展示了前后变化。注意,我们还添加了 F 前缀到我们的函数中,以尊重我们在 3.4.1 节中概述的导入约定。
列表 3.7 通过链式转换方法移除中间变量
# Before
book = spark.read.text("./data/gutenberg_books/1342-0.txt")
lines = book.select(split(book.value, " ").alias("line"))
words = lines.select(explode(col("line")).alias("word"))
words_lower = words.select(lower(col("word")).alias("word"))
words_clean = words_lower.select(
regexp_extract(col("word"), "[a-z']*", 0).alias("word")
)
words_nonull = words_clean.where(col("word") != "")
results = words_nonull.groupby("word").count()
# After
import pyspark.sql.functions as F
results = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']*", 0).alias("word"))
.where(F.col("word") != "")
.groupby("word")
.count()
)
这就像是白天和黑夜:修改后的代码更加简洁易读,我们能够轻松地跟随步骤列表。从视觉上,我们也可以在图 3.3 中看到这种差异。

列表 3.3 方法链消除了对中间变量的需求。
我并不是说中间变量是邪恶的,应该避免使用。但它们可能会阻碍你的代码可读性,所以你必须确保它们有存在的意义。许多初出茅庐的 PySpark 开发者养成了总是在同一个变量上写代码的习惯。虽然这本身并不危险,但它使得代码冗余,更难以推理。如果你发现自己正在做类似下一列表的前两行的事情,请链式调用你的方法。你将得到相同的结果,并且代码看起来更美观。
列表 3.8 链式操作以覆盖相同变量
df = spark.read.text("./data/gutenberg_books/1342-0.txt") ❶
df = df.select(F.split(F.col("value"), " ").alias("line")) ❶
df = (
spark.read.text("./data/gutenberg_books/1342-0.txt") ❷
.select(F.split(F.col("value"), " ").alias("line")) ❷
)
❶ 不要这样做 . . .
❷ . . . 你可以这样做——没有变量重复!
通过使用 Python 的括号使你的生活更轻松
如果你查看列表 3.7 中的“之后”代码,你会注意到我以一个开括号(spark = ( [...])开始等号右边。这是一个我在需要链式调用 Python 中的方法时使用的技巧。如果你不将你的结果包裹在一对括号中,你需要在每一行的末尾添加一个\字符,这会给你的程序增加视觉噪音。PySpark 代码在链式调用方法时尤其容易发生行中断:
results = spark\
.read.text('./data/ch02/1342-0.txt')\
...
作为一种懒惰的替代方案,我非常喜欢使用 Black 作为 Python 代码格式化工具(black.readthedocs.io/)。它消除了在代码逻辑布局和一致性方面所需的大量猜测工作。由于我们读代码比写代码多,可读性很重要。
由于我们在results上执行了两个操作(在屏幕上显示前 10 个单词并将数据帧写入 CSV 文件),我们必须使用一个变量。如果你只需要对你的数据帧执行一个操作,你可以通过不使用任何变量名来发挥你内心的代码高尔夫选手¹的精神。大多数时候,我更喜欢将我的转换组合在一起,并将操作在视觉上分开,就像我们现在所做的那样。
我们的项目现在看起来更加精致了。最后一步将是添加 PySpark 的管道,以便为批量模式做准备。
3.5 使用 spark-submit 以批量模式启动你的程序
如果我们使用pyspark程序启动 PySpark,启动器会为我们创建SparkSession。在第二章中,我们从基本的 Python REPL 开始,因此我们创建了我们的入口点并将其命名为spark。本节将我们的程序以批量模式提交。这相当于运行一个 Python 脚本;如果你只需要结果而不需要 REPL,这将是一个好方法。
与交互式 REPL 不同,在 REPL 中,语言的选择会触发程序的运行,就像列表 3.10 中所示,我们看到 Spark 提供了一个名为spark-submit的单个程序,用于提交 Spark(Scala、Java、SQL)、PySpark(Python)和 SparkR(R)程序。我们程序的完整代码可以在本书的仓库中的code/Ch02/word_count_submit.py下找到。
列表 3.9 提交我们的作业到批量模式
$ spark-submit ./code/Ch03/word_count_submit.py
# [...]
# +----+-----+
# |word|count|
# +----+-----+
# | the| 4480|
# | to| 4218|
# | of| 3711|
# | and| 3504|
# | her| 2199|
# | a| 1982|
# | in| 1909|
# | was| 1838|
# | i| 1749|
# | she| 1668|
# +----+-----+
# only showing top 10 rows
# [...]
小贴士:如果你收到大量的INFO消息,别忘了你对此有控制权:在定义spark之后立即使用spark.sparkContext.setLogLevel("WARN")。如果你的本地配置默认为INFO,你仍然会收到一系列消息,直到它捕获到这一行,但不会遮挡你的结果。
一旦完成这一步,我们就完成了!我们的程序成功地将书籍摄入,将其转换为清洗后的单词频率列表,然后以两种方式导出:屏幕上的前 10 名列表和 CSV 文件。
如果我们审视我们的过程,我们一次交互式地应用一个转换,每次转换后都使用show()来显示过程。当你处理一个新的数据文件时,这通常会是你的操作模式。一旦你对一段代码有信心,你可以移除中间变量。PySpark 默认为你提供了一个高效的环境,可以交互式地探索大型数据集,并提供了一种表达性和简洁的词汇来操作数据。它还很容易从交互式开发过渡到批量部署——你只需定义你的SparkSession(如果你还没有定义的话),然后就可以开始了。
3.6 本章没有发生的事情
第二章和第三章内容相当密集。我们学习了如何读取文本数据,将其处理以回答任何问题,在屏幕上显示结果,并将它们写入 CSV 文件。另一方面,我们故意省略了许多元素。让我们快速看一下在本章中我们没有做什么。
除了将数据帧合并到一个文件中以便写入外,我们对数据的分布没有做太多处理。我们在第一章中看到,PySpark 会将数据分布到多个工作节点上,但我们的代码并没有过多关注这一点。不必不断思考分区、数据局部性和容错性,使得我们的数据发现过程变得更快。
我们没有花太多时间配置 PySpark。除了为我们的应用程序提供一个名称外,我们在SparkSession中没有输入任何额外的配置。这并不是说我们永远不会触及这一点,但我们可以从一个基本的配置开始,并在过程中进行调整。后续章节将定制SparkSession以优化资源(第十一章)或创建连接到外部数据存储库的连接器(第九章)。
最后,我们没有过分关注与处理相关的操作顺序规划,而是专注于可读性和逻辑。我们确保以逻辑上尽可能清晰的方式描述我们的转换,并让 Spark 优化这些步骤为高效的处理步骤。我们可能重新排列一些步骤并得到相同的结果,但我们的程序可读性好,易于推理,并且运行正确。
这与我在第一章中做出的声明相呼应:PySpark 不仅在它提供的内容上令人瞩目,而且在它能够抽象的内容上也是如此。你通常可以将你的代码编写为一系列转换,这些转换大多数情况下都能带你到达目的地。对于那些想要更精细的性能或更多控制数据物理布局的情况,在第三部分中我们将看到 PySpark 不会阻碍你。因为 Spark 持续发展,仍然有一些情况下你需要对你的程序如何转换为集群上的物理执行更加小心。为此,第十一章涵盖了 Spark UI,它显示了正在你的数据上执行的工作以及你如何影响处理。
3.7 扩展我们的词频程序
那个例子并不是大数据。我首先得承认这一点。
教授大数据处理有一个二难困境。虽然我想展示 PySpark 与大规模数据集一起工作的能力,但我不想让你购买一个集群或产生巨大的云费用。使用较小的数据集展示会更简单,因为我们知道我们可以使用相同的代码进行扩展。
让我们以我们的词数示例为例:我们如何将其扩展到更大的文本语料库?让我们从 Project Gutenberg 下载更多文件并将它们放在同一个目录中:
$ ls -1 data/gutenberg_books
11-0.txt
1342-0.txt
1661-0.txt
2701-0.txt
30254-0.txt
84-0.txt
虽然这不足以宣称“我们在做大数据”,但它足以解释一般概念。如果你想扩展,你可以使用附录 B 在云上配置一个强大的集群,下载更多书籍或其他文本文件,并以几美元的价格运行相同的程序。
我们以非常微妙的方式修改了我们的 word_count_submit.py。在 .read.text() 的地方,我们将路径更改为包含目录中的所有文件。接下来的列表显示了前后变化:我们只将 1342-0.txt 改为 *.txt,这被称为 glob 模式。* 表示 Spark 选择目录中的所有 .txt 文件。
列表 3.10 使用 glob 模式扩展我们的词数程序
# Before
results = spark.read.text('./data/gutenberg_books/1342-0.txt') ❶
# After
results = spark.read.text('./data/gutenberg_books/*.txt') ❷
❶ 这里我们传递了一个参数的单个文件 . . .
❷ . . . 并且在这里,星号(或球体)选择目录内的所有文本文件。
注意:如果你想让 PySpark 读取目录内的所有文件,你也可以只传递目录的名称。
在以下列表中提供了在目录中运行程序的所有文件的结果。
列表 3.11 扩展我们的程序到多个文件的结果
$ spark-submit ./code/Ch02/word_count_submit.py
+----+-----+
|word|count|
+----+-----+
| the|38895|
| and|23919|
| of|21199|
| to|20526|
| a|14464|
| i|13973|
| in|12777|
|that| 9623|
| it| 9099|
| was| 8920|
+----+-----+
only showing top 10 rows
使用这个方法,你可以自信地说,你可以使用 PySpark 扩展一个简单的数据分析程序。你可以使用我们在这里概述的通用公式,并修改一些参数和方法以适应你的用例。第四章和第五章将进一步深入探讨一些有趣且常见的数据转换,基于我们在这里学到的内容。
摘要
-
你可以使用
groupby方法对记录进行分组,将你想要分组的列名作为参数传递。这将返回一个GroupedData对象,该对象等待聚合方法返回对组进行计算的结果,例如记录的count()。 -
PySpark 中操作列的功能集合位于
pyspark.sql.functions。虽然这不是官方规定,但被广泛尊重的做法是在你的程序中使用F关键字来限定这个导入。 -
当将数据帧写入文件时,PySpark 将创建一个目录,并为每个分区创建一个文件。如果你想写入单个文件,请使用
coalesce(1)方法。 -
为了使你的程序通过
spark-submit以批处理模式运行,你需要创建一个SparkSession。PySpark 在pyspark.sql模块中提供了一个构建器模式。 -
如果你的程序需要在同一目录下的多个文件中进行扩展,你可以使用 glob 模式一次性选择多个文件。PySpark 将它们收集到一个单独的数据帧中。
补充练习
对于这些练习,你需要我们在本章中工作的word_count_submit.py程序。你可以从本书的代码仓库(Code/Ch03/word_ count_submit.py)中获取它。
练习 3.3
-
通过修改
word_count_submit.py程序,返回简·奥斯汀的《傲慢与偏见》中不同单词的数量。(提示:results包含每个唯一单词的一条记录。) -
(挑战)将你的程序封装在一个函数中,该函数接受一个文件名作为参数。它应该返回不同单词的数量。
练习 3.4
使用word_count_submit.py,修改脚本以返回简·奥斯汀的《傲慢与偏见》中只出现一次的五个单词的样本。
练习 3.5
-
使用
substring函数(如有需要,请参考 PySpark 的 API 或pysparkshell),返回最常见的五个首字母(只保留每个单词的首字母)。 -
计算以辅音或元音开头的单词数量。(提示:
isin()函数可能很有用。)
练习 3.6
假设你想要获取GroupedData对象的count()和sum()。为什么这段代码不起作用?映射每个方法的输入和输出。
my_data_frame.groupby("my_column").count().sum()
第四章将涵盖多个聚合函数的应用。
¹ 使用尽可能少的字符编写程序。
4 使用 pyspark.sql 分析表格数据
本章涵盖
-
将分隔符数据读取到 PySpark 数据框中
-
理解 PySpark 如何在数据框中表示表格数据
-
摄入和探索表格或关系数据
-
在数据框中选择、操作、重命名和删除列
-
概述数据框以快速探索
本章 2 和 3 章中的第一个例子是处理非结构化文本数据。每一行文本都被映射到数据框中的一个记录,通过一系列转换,我们从(一个或多个)文本文件中计算单词频率。本章深入数据转换,这次使用结构化数据。数据有多种形状和形式:我们开始于 关系型(或 表格型,¹ 或行和列)数据,这是 SQL 和 Excel 流行的一种最常见格式。本章和下一章遵循与我们的第一个数据分析相同的蓝图。我们使用公共加拿大电视节目时间表数据来识别和测量总节目中的广告比例。
更具体地说,我首先对表格数据及其如何通过数据框提供必要的抽象来表示数据表进行入门介绍。然后,我将 SparkReader 对象再次专门化,这次是为了分隔符数据而不是非结构化文本。然后,我涵盖了 Column 对象上最常用的操作,在二维设置中处理数据。最后,我通过 summary() 和 describe() 方法介绍了 PySpark 的轻量级 EDA(探索性数据分析)功能。到本章结束时,你将能够探索和更改数据框的列结构。更有趣的是,这些知识将适用于任何数据格式(例如,第六章中的层次数据)。
就像每个 PySpark 程序一样,我们首先初始化我们的 SparkSession 对象,如下所示。我还主动导入 pyspark.sql.functions 作为合格的 F,因为我们看到在第三章中,这有助于提高可读性并避免函数名称冲突的潜在问题。
列表 4.1 创建我们的 SparkSession 对象以开始使用 PySpark
from pyspark.sql import SparkSession
import pyspark.sql.functions as F
spark = SparkSession.builder.getOrCreate()
4.1 什么是表格数据?
当我们将数据表示为二维表格时,我们称之为表格数据。你有单元格,每个单元格包含一个单一(或 简单)值,组织成行和列。一个很好的例子是你的购物清单:你可能有一列是你想要购买的物品,一列是数量,一列是预期的价格。图 4.1 提供了一个小型购物清单的例子。我们有提到的三个列,以及四行,每行代表购物清单中的一个条目。

图 4.1 我将购物清单表示为表格数据。每一行代表一个项目,每一列代表一个属性。
我们可以用来类比表格数据的最佳类比是电子表格格式:界面为你提供了一个大量行和列,你可以在其中输入和执行数据计算。可以将 SQL 数据库视为由行和列组成的表格。表格数据是一种极其常见的数据格式,因为它如此流行且易于推理,因此它成为 PySpark 数据操作 API 的完美入门。
PySpark 的数据框结构非常自然地映射到表格数据。在第二章中,我解释了 PySpark 可以通过 select() 和 groupby() 等方法在整个数据框结构上操作,或者操作 Column 对象(例如,使用 split() 这样的函数)。数据框是列主序的,因此它的 API 专注于操纵列以转换数据。正因为如此,我们可以通过考虑要执行的操作以及哪些列会受到它们的影响来简化我们对数据转换的推理。
注意:在第一章中简要介绍的弹性分布式数据集是一个列主序结构的良好例子。你不需要考虑列,而是考虑具有属性的项目(行),在这些属性上应用函数。这是思考数据的一种替代方式,第八章包含了更多关于在哪里/何时它可能有用的信息。
4.1.1 PySpark 如何表示表格数据?
在第二章和第三章中,我们的数据框始终只包含一个列,直到我们最后计算每个单词的出现次数。换句话说,我们处理了非结构化数据(一段文本),进行了一些转换,并创建了一个包含我们所需信息的两列表格。表格数据在某种程度上是这种方法的扩展,其中我们有多于一个列可以操作。
让我们以我的非常健康的购物清单为例,并将其加载到 PySpark 中。为了简化问题,我们将我们的购物清单编码为一个列表的列表。PySpark 有多种导入表格数据的方式,但最流行的是列表的列表和 pandas 数据框。在第九章中,我简要介绍了如何使用 pandas。仅为了导入四个记录(我们购物清单上的四个项目)而导入一个库可能有点过度,所以我将其保留在列表的列表中。
列表 4.2 从我们的购物清单创建数据框
my_grocery_list = [
["Banana", 2, 1.74],
["Apple", 4, 2.04],
["Carrot", 1, 1.09],
["Cake", 1, 10.99],
] ❶
df_grocery_list = spark.createDataFrame(
my_grocery_list, ["Item", "Quantity", "Price"]
)
df_grocery_list.printSchema()
# root
# |-- Item: string (nullable = true) ❷
# |-- Quantity: long (nullable = true) ❷
# |-- Price: double (nullable = true) ❷
❶ 我的购物清单编码在一个列表的列表中。
❷ PySpark 自动从 Python 对每个值的了解中推断出每个字段的类型。
我们可以使用spark的.createDataFrame函数轻松地从程序中的数据创建一个数据框,如列表 4.2 所示。我们的第一个参数是数据本身。你可以提供一个项目列表(这里是一个列表的列表)、一个 pandas 数据框或一个弹性分布式数据集,这些内容我在第八章中进行了介绍。第二个参数是数据框的模式。第六章更深入地介绍了自动和手动模式定义。在此期间,传递一个列名列表将使 PySpark 感到高兴,同时它会推断出我们列的类型(分别为string、long和double)。从视觉上看,数据框将类似于图 4.2,尽管更加简化。主节点了解数据框的结构,但实际数据是在工作节点上表示的。每一列映射到我们集群上某个位置存储的数据,该数据由 PySpark 管理。我们在抽象结构上操作,让主节点有效地分配工作。

图 4.2 我们的数据框的每一列都映射到我们的工作节点上的某个位置。
PySpark 很乐意使用我们的列定义来表示我们的表格数据。这意味着我们迄今为止学到的所有函数都适用于我们的表格数据。通过为多种数据表示提供一个灵活的结构——到目前为止我们已经涵盖了文本和表格——PySpark 使得从一个领域转移到另一个领域变得容易。它消除了学习另一组函数和全新的数据抽象的需求。
本节介绍了简单二维/表格型数据框的外观和感觉。在下一节中,我们将处理一个更重要的数据框。是时候开始编码了!
4.2 使用 PySpark 分析和处理表格数据
我的购物清单很有趣,但分析工作的潜力相当有限。我们将接触到更大的数据集,对其进行探索,并提出一些我们可能感兴趣的基本问题。这个过程被称为探索性数据分析(或 EDA),通常是数据分析师和科学家在面临新数据时采取的第一步。我们的目标是熟悉数据发现函数和方法,以及进行一些基本的数据组装。熟悉这些步骤将消除在数据面前无法看到其转换的尴尬。本节向您展示了一个蓝图,您可以在面对新的数据框时重复使用,直到您能够以每秒处理数百万条记录的速度进行视觉处理。
图形探索性数据分析
你在野外看到的许多 EDA 工作都包含图表和/或表格。这意味着 PySpark 有选项做同样的事情吗?
我们在第二章中看到了如何打印数据框,以便我们可以一目了然地查看内容。这同样适用于总结信息和在屏幕上显示。如果你想以易于处理的形式(例如,将其纳入报告)导出表格,可以使用 spark.write.csv,确保将数据框合并为单个文件。(参见第三章中对 coalesce() 的复习。)由于表格摘要的性质,它不会很大,所以你不会面临内存不足的风险。
PySpark 不提供任何图表功能,也不与其他图表库(如 Matplotlib、seaborn、Altair 或 plot.ly)交互,这很有道理:PySpark 将你的数据分布到多台计算机上。分布图表创建没有太多意义。通常的解决方案将是使用 PySpark 转换你的数据,使用 toPandas() 方法将你的 PySpark 数据框转换为 pandas 数据框,然后使用你喜欢的图表库。在使用图表时,我会提供我用来生成它们的代码。
当使用 toPandas() 时,请记住,你将失去在多台机器上工作的优势,因为数据将累积在驱动器上。将此操作保留用于聚合或可管理的数据集。虽然这是一个粗略的公式,我通常取行数乘以列数;如果这个数字超过 100,000(对于 16 GB 的驱动器),我会进一步尝试减少它。这个简单的技巧帮助我了解我正在处理的数据的大小,以及根据我的驱动器大小可能实现的内容。
你不希望总是将数据在 pandas 和 PySpark 数据框之间移动。将 toPandas() 保留用于离散操作,或者一次性将数据移动到 pandas 数据框中。来回移动会产生大量不必要的劳动,用于分配和收集数据,却毫无意义。如果你需要在 Spark 数据框上使用 pandas 功能,请查看第九章中的 pandas UDFs。
对于这个练习,我们将使用来自加拿大政府的公开数据,更具体地说,是加拿大广播和电视电信委员会(CRTC)。每个广播机构都要求提供向加拿大公众展示的节目和商业广告的完整日志。这为我们提供了许多潜在的问题要回答,但我们将选择一个问题:哪些频道拥有最大和最小的商业广告比例?
您可以从加拿大开放数据门户下载该文件(mng.bz/y4YJ);选择BroadcastLogs_2018_Q3_M8文件。文件大小为 994 MB,下载可能太大,取决于您的计算机。本书的存储库在data/broadcast_logs目录下包含数据的样本,您可以使用它替换原始文件。您还需要下载以.doc 格式存在的数据字典,以及参考表的压缩文件,将它们解压缩到data/ broadcast_logs目录下的ReferenceTables目录中。再次强调,示例假设数据下载在data/broadcast_logs下,并且 PySpark 是从存储库的根目录启动的。
在进入下一节之前,请确保您有以下内容。除了大的BroadcastLogs文件外,其余都在存储库中:
-
data/BroadcastLogs_2018_Q3_M8.CSV(可以从网站下载或使用存储库中的样本) -
data/broadcast_logs/ReferenceTables -
data/broadcast_logs/data_dictionary.doc
4.3 在 PySpark 中读取和评估分隔数据
现在我们已经使用一个小型的合成表格数据集进行了测试,我们准备深入实际数据。就像在第三章中一样,我们的第一步是在进行探索和转换之前读取数据。这次,我们读取的数据比一些无组织的文本要复杂一些。因此,我更详细地介绍了SparkReader的使用。由于二维表是最常见的组织格式之一,了解如何摄取表格或关系数据将很快变得自然而然。
小贴士:关系数据通常存储在 SQL 数据库中。Spark 可以轻松地从 SQL(或类似 SQL)数据存储中读取:请参阅第九章的示例,其中我读取了 Google BigQuery。
在本节中,我首先介绍SparkReader在分隔数据或以分隔字符分隔的数据(以创建第二个维度)中的应用,通过将其应用于 CRTC 数据表之一。然后,我回顾了最常见的读取器选项,以便您可以轻松地读取其他类型的分隔文件。
4.3.1 针对 CSV 文件的 SparkReader 的第一次尝试
分隔数据是一种非常常见、流行且棘手的数据共享方式。在本节中,我介绍了如何读取 CRTC 表,这些表使用一组相当常见的约定来处理 CSV 文件。
CSV 文件格式源于一个简单的想法:我们使用文本,以二维记录(行和列)的形式分隔,这些记录由两种类型的分隔符分隔。这些分隔符是字符,但在 CSV 文件的应用中它们具有特殊的作用:
-
第一个是行分隔符。行分隔符将文件分割成逻辑记录。分隔符之间只有一个记录。
-
第二种是字段分隔符。每个记录由相同数量的字段组成,字段分隔符指示一个字段开始和结束的位置。

图 4.3 我们数据的一个样本,突出显示了字段分隔符(|)和行分隔符(\n)
换行符(\n,当明确表示时)是事实上的记录分隔符。它自然地将文件分解为视觉行,其中一条记录从行的开头开始,并在行尾结束。逗号字符(,)是最常见的字段分隔符。
CSV 文件易于生成,遵循一套松散的规则即可被认为是可用的。因此,PySpark 在读取 CSV 文件时提供了 25 个可选参数。将其与读取文本数据的两个参数进行比较。在列表 4.3 中,我使用了三个配置参数:通过 sep 的记录分隔符、通过 header 的存在(列名)行,以及我最终要求 Spark 通过 inferSchema 推断数据类型(更多内容请见第 4.3.2 节)。这足以将我们的数据解析为数据框。
列表 4.3 读取我们的广播信息
import os
DIRECTORY = "./data/broadcast_logs"
logs = spark.read.csv(
os.path.join(DIRECTORY, "BroadcastLogs_2018_Q3_M8.CSV"), ❶
sep="|", ❷
header=True, ❸
inferSchema=True, ❹
timestampFormat="yyyy-MM-dd", ❺
)
❶ 我们首先指定数据所在文件的路径。
❷ 我们的文件使用竖线作为分隔符/分隔符,因此我们将 | 作为参数传递给 sep。
❸ header 接受一个布尔值。当为 true 时,文件的第一行被解析为列名。
❹ inferSchema 也接受一个布尔值。当为 true 时,它将预解析数据以推断列的类型。
❺ timestampFormat 用于通知解析器时间戳字段的格式(年、月、日、小时、分钟、秒、微秒)(见第 4.4.3 节)。
虽然我们能够读取用于分析的数据的 CSV 数据,但这只是 SparkReader 使用的一个狭义示例。下一节将扩展到读取 CSV 数据时最重要的参数,并提供对列表 4.3 中使用的代码的更详细解释。
4.3.2 自定义 SparkReader 对象以读取 CSV 数据文件
本节重点介绍如何专门化 SparkReader 对象以读取分隔数据以及最流行的配置参数,以适应 CSV 数据的各种变体。
列表 4.4 spark.read.csv 函数,其中每个参数都明确列出
logs = spark.read.csv(
path=os.path.join(DIRECTORY, "BroadcastLogs_2018_Q3_M8.CSV"),
sep="|",
header=True,
inferSchema=True,
timestampFormat="yyyy-MM-dd",
)
读取分隔数据可能是一个风险业务。由于格式非常灵活且可由人类编辑,CSV 读取器需要提供许多选项来覆盖可能出现的许多用例。还存在文件格式不正确的风险,在这种情况下,您需要将其视为文本,并谨慎地手动推断字段。我将遵循快乐路径,并介绍最流行的场景:单个文件,正确分隔。
你想要读取的文件的路径作为唯一必填参数
就像读取文本一样,唯一真正必填的参数是 path,它包含文件或文件的路径。正如我们在第二章中看到的,您可以使用 glob 模式读取给定目录内的多个文件,只要它们具有相同的结构。您也可以显式传递文件路径列表,如果您只想读取特定文件。
使用 sep 参数传递显式字段分隔符
当摄取和生成 CSV 文件时,你将遇到的最常见的变体是选择正确的分隔符。逗号是最受欢迎的,但它的问题在于它是一个在文本中很常见的字符,这意味着你需要一种方法来区分哪些逗号是文本的一部分,哪些是分隔符。我们的文件使用的是竖线字符,这是一个合适的选择:它在键盘上容易到达,但在文本中不常见。
注意:在法语中,我们使用逗号来分隔整数部分和小数部分之间的数字(例如,1.02 → 1,02)。这在 CSV 文件中相当糟糕,因此大多数法语 CSV 文件将使用分号(;)作为字段分隔符。这是你需要在使用 CSV 数据时保持警惕的另一个例子。
当读取 CSV 数据时,PySpark 将默认使用逗号字符作为字段分隔符。你可以设置可选参数 sep(分隔符)为你想要用作字段分隔符的单个字符。
引用文本以避免将字符误认为是分隔符
当处理使用逗号作为分隔符的 CSV 文件时,通常的做法是 引用 文本字段,以确保文本中的任何逗号都不会被误认为是字段分隔符。CSV 读取器对象提供了一个可选的 quote 参数,默认值为双引号字符(")。由于我没有向 quote 传递显式值,我们保留了默认值。这样,我们可以有一个值为 "Three | Trois" 的字段,而如果没有引号字符,我们会将其视为两个字段。如果我们不想使用任何字符作为引号,我们需要显式地将空字符串传递给 quote。
使用第一行作为列名
header 可选参数接受一个布尔标志。如果设置为 true,它将使用你的文件的第一行(或如果你正在摄取多个文件,则为这些文件)并将其用于设置列名。
如果你希望显式地命名列,也可以传递一个显式模式(见第六章)或 DDL 字符串(见第七章)作为 schema 可选参数。如果你不填写任何这些,你的数据框将使用 _c* 作为列名,其中 * 被替换为递增的整数(_c0,_c1,……)。
在读取数据时推断列类型
PySpark 具有模式发现能力。你可以通过将 inferSchema 设置为 True(默认情况下是关闭的)来开启它。这个可选参数会强制 PySpark 对摄取的数据进行两次遍历:一次用于设置每列的类型,一次用于摄取数据。这使得摄取过程变得相当长,但有助于我们避免手动编写模式(我在第六章中深入到这个细节)。让机器来做这项工作吧!
小贴士:如果你有大量数据,推断模式可能会非常昂贵。在第六章中,我介绍了如何处理(和提取)模式信息;如果你多次读取数据源,保留推断出的模式信息是个好主意!你也可以取一个小型的代表性数据集来推断模式,然后读取大型数据集。
我们很幸运,加拿大政府是数据的好管家,为我们提供了干净、格式正确的文件。在野外,格式不正确的 CSV 文件很多,当你尝试摄入其中的一些文件时,你会遇到错误。此外,如果你的数据很大,你通常不会有时间检查每一行来修复错误。第六章介绍了一些减轻痛苦的策略,并展示了包含模式共享数据的方法。
我们的数据帧模式,在下一条列表中显示,与我们所下载的文档是一致的。列名被正确显示,类型也合理。这足以开始一些探索。
列表 4.5 我们的 logs 数据帧的模式
logs.printSchema()
# root
# |-- BroadcastLogID: integer (nullable = true)
# |-- LogServiceID: integer (nullable = true)
# |-- LogDate: timestamp (nullable = true)
# |-- SequenceNO: integer (nullable = true)
# |-- AudienceTargetAgeID: integer (nullable = true)
# |-- AudienceTargetEthnicID: integer (nullable = true)
# |-- CategoryID: integer (nullable = true)
# |-- ClosedCaptionID: integer (nullable = true)
# |-- CountryOfOriginID: integer (nullable = true)
# |-- DubDramaCreditID: integer (nullable = true)
# |-- EthnicProgramID: integer (nullable = true)
# |-- ProductionSourceID: integer (nullable = true)
# |-- ProgramClassID: integer (nullable = true)
# |-- FilmClassificationID: integer (nullable = true)
# |-- ExhibitionID: integer (nullable = true)
# |-- Duration: string (nullable = true)
# |-- EndTime: string (nullable = true)
# |-- LogEntryDate: timestamp (nullable = true)
# |-- ProductionNO: string (nullable = true)
# |-- ProgramTitle: string (nullable = true)
# |-- StartTime: string (nullable = true)
# |-- Subtitle: string (nullable = true)
# |-- NetworkAffiliationID: integer (nullable = true)
# |-- SpecialAttentionID: integer (nullable = true)
# |-- BroadcastOriginPointID: integer (nullable = true)
# |-- CompositionID: integer (nullable = true)
# |-- Producer1: string (nullable = true)
# |-- Producer2: string (nullable = true)
# |-- Language1: integer (nullable = true)
# |-- Language2: integer (nullable = true)
练习 4.1
让我们看一下以下文件,称为 sample.csv,它包含三个列:
Item,Quantity,Price
$Banana, organic$,1,0.99
Pear,7,1.24
$Cake, chocolate$,1,14.50
完成以下代码以成功摄入文件。
sample = spark.read.csv([...],
sep=[...],
header=[...],
quote=[...],
inferSchema=[...]
)
(Note: If you want to test your code, sample.csv is available in the book’s repository under data/sample.csv/sample.csv).
4.3.3 探索我们的数据宇宙的形状
当处理表格数据时,尤其是如果数据来自 SQL 数据仓库,你经常会发现数据集被分割在多个表中。在我们的例子中,我们的日志表包含大量以 ID 结尾的字段;这些 ID 列在其它表中,我们必须将它们链接起来以获取这些 ID 的说明。本节简要介绍了星型模式,为什么它们如此频繁地出现,以及我们如何可视地表示它们以便于工作。
我们的数据宇宙(我们正在处理的一组表)遵循关系数据库中一个非常常见的模式:一个中心表包含许多 ID(或 键)和一些辅助表,这些辅助表在每个键和其值之间包含说明。这被称为 星型模式,因为它看起来像一颗星星。由于 规范化,星型模式在关系数据库世界中很常见,这是一种避免数据重复并提高数据完整性的过程。数据规范化在图 4.4 中展示,我们的中心表 logs 包含映射到辅助表(称为 链接表)的 ID。在 CD_Category 链接表中,它包含许多字段(例如,Category_CD 和 English_description),当使用 Category_ID 键链接表时,这些字段对 logs 表是可用的。

图 4.4 logs 表的 ID 列映射到其他表,如 CD_category 表,它链接了 Category_ID 字段。
在 Spark 的宇宙中,我们通常更喜欢使用单个表而不是链接多个表来获取数据。我们称这些为非规范化表,或者通俗地说,胖表。我们首先评估logs表中直接可用的数据,然后再扩充我们的表,这个话题我在第五章中进行了介绍。通过查看logs表、其内容和数据文档,我们避免了链接包含对我们分析没有实际价值的数据的表。
适合工作的正确结构
规范化、非规范化——这到底是什么?这不是一本关于数据分析的书吗?
虽然这本书不是关于数据建模的,但至少需要了解一些数据可能的结构方式,这样我们才能与之工作。当与关系信息(例如,我们的广播表)一起工作时,规范化数据有许多优点。除了更容易维护外,数据规范化还能降低数据中出现异常或不合逻辑记录的概率。另一方面,大规模数据系统有时会采用非规范化表来避免昂贵的连接操作。
在处理分析时,包含所有数据的单个表是最好的。然而,手动连接/合并数据可能会很繁琐,尤其是在处理数十个甚至数百个链接表时(有关连接的更多信息,请参阅第五章)。幸运的是,数据仓库的结构变化并不频繁。如果你有一天面临一个复杂的星型模式,那就与数据库管理员交朋友吧。他们非常有可能提供给你将表非规范化的信息,通常是通过 SQL,第七章将展示如何以最小的努力将代码适配到 PySpark 中。
4.4 数据操作的基础:选择、删除、重命名、排序、诊断
当你第一次接触数据时,探索和总结数据是一种常见的做法。这就像与你的数据第一次约会:你想要一个良好的概述,而不是纠结于细节。(在魁北克法语中,我们说 s’enfarger dans les fleurs du tapis 来指代那些过于纠结于细节的人。翻译过来就是“绊倒在地毯的花上。”)本节将更详细地展示在数据框上最常见的操作。我展示了如何选择、删除、重命名、重新排序和创建列,以便你可以自定义数据框的显示方式。我还涵盖了数据框的汇总,这样你可以快速诊断结构内的数据。无需任何花朵。
4.4.1 知道我们想要什么:选择列
到目前为止,我们已经了解到,将我们的数据框变量输入到 shell 中会打印出数据框的结构,而不是数据,除非你使用的是急切评估的 Spark(在第二章中提到)。我们还可以使用show()命令来显示一些记录以供探索。我不会显示结果,但如果你尝试,你会看到表格式的输出是混乱的,因为我们一次显示了太多的列。本节重新介绍了select()方法,这次它指导 PySpark 选择你想要保留在数据框中的列。我还介绍了在使用 PySpark 方法和函数时如何引用列。
在最简单的情况下,select()可以接受一个或多个列对象——或者表示列名的字符串——并返回一个只包含所列列的数据框。这样,我们可以保持我们的探索整洁,一次检查几个列。一个例子将在下一个列表中显示。
列表 4.6:选择数据框前三个列的五行
logs.select("BroadcastLogID", "LogServiceID", "LogDate").show(5, False)
# +--------------+------------+-------------------+
# |BroadcastLogID|LogServiceID|LogDate |
# +--------------+------------+-------------------+
# |1196192316 |3157 |2018-08-01 00:00:00|
# |1196192317 |3157 |2018-08-01 00:00:00|
# |1196192318 |3157 |2018-08-01 00:00:00|
# |1196192319 |3157 |2018-08-01 00:00:00|
# |1196192320 |3157 |2018-08-01 00:00:00|
# +--------------+------------+-------------------+
# only showing top 5 rows
在第二章中,你了解到.show(5, False)显示五行而不截断它们的表示,这样我们就可以显示全部内容。.select()语句是魔法发生的地方。在文档中,select()接受一个参数,*cols;星号表示该方法将接受任意数量的参数。如果我们传递多个列名,select()将简单地将这些参数聚集成一个名为cols的元组。
由于这个原因,我们可以使用相同的解构技巧来选择列。从 PySpark 的角度来看,列表 4.7 中的四个语句被解释为相同。注意,在列表前加上星号移除了容器,使得每个元素都成为函数的参数。如果你觉得这有点令人困惑,不要担心!附录 C 为你提供了一个关于集合解包的良好概述。
列表 4.7:在 PySpark 中选择列的四种方式,在结果方面都等效
# Using the string to column conversion
logs.select("BroadCastLogID", "LogServiceID", "LogDate")
logs.select(*["BroadCastLogID", "LogServiceID", "LogDate"])
# Passing the column object explicitly
logs.select(
F.col("BroadCastLogID"), F.col("LogServiceID"), F.col("LogDate")
)
logs.select(
*[F.col("BroadCastLogID"), F.col("LogServiceID"), F.col("LogDate")]
)
当明确选择几个列时,你不需要将它们包装成一个列表。如果你已经在处理一个列的列表,你可以使用*前缀来解包它们。这种参数解包模式值得记住,因为许多其他以列作为输入的数据框方法都使用相同的方法。
在追求聪明(或懒惰)的精神下,让我们扩展我们的选择代码,以便以每组三列的方式查看每一列。这将给我们一种内容的感觉。数据框通过columns属性跟踪其列;logs.columns是一个包含logs数据框所有列名的 Python 列表。在下一个列表中,我将列切片成三列一组,以便以小批量而不是一次性显示它们。
列表 4.8:以三列一组查看数据框
import numpy as np
column_split = np.array_split()
np.array(logs.columns), len(logs.columns) // 3 ❶
)
print(column_split)
# [array(['BroadcastLogID', 'LogServiceID', 'LogDate'], dtype='<U22'),
# [...]
# array(['Producer2', 'Language1', 'Language2'], dtype='<U22')]'
for x in column_split:
logs.select(*x).show(5, False)
# +--------------+------------+-------------------+
# |BroadcastLogID|LogServiceID|LogDate |
# +--------------+------------+-------------------+
# |1196192316 |3157 |2018-08-01 00:00:00|
# |1196192317 |3157 |2018-08-01 00:00:00|
# |1196192318 |3157 |2018-08-01 00:00:00|
# |1196192319 |3157 |2018-08-01 00:00:00|
# |1196192320 |3157 |2018-08-01 00:00:00|
# +--------------+------------+-------------------+
# only showing top 5 rows
# ... and more tables of three columns
❶array_split()函数来自 numpy 包,在本列表的开头导入为 np。
让我们逐行分析。我们首先将 logs.columns 列表分割成大约三组的近似组。为此,我们依赖于来自 numpy 包的一个函数 array_split()。该函数接受一个数组和所需子数组的数量 N,并返回一个包含 N 个子数组的列表。我们通过 np.array 函数将列的列表 logs.columns 包装成一个数组,并将其作为第一个参数传递。对于子数组的数量,我们用整数除法 // 将列数除以三。
小贴士 完全诚实地说,调用 np.array 可以避免,因为 np.array_split() 可以在列表上工作,尽管速度较慢。我仍然在使用它,因为如果你使用静态类型检查器,如 mypy,你会得到一个类型错误。第八章介绍了 PySpark 程序中的类型检查基础知识。
列表 4.8 的最后一部分遍历子数组的列表,使用 select();选择每个子数组中存在的列,并使用 show() 在屏幕上显示它们。
小贴士 如果你使用 Databricks 笔记本,你可以使用 display(logs) 以吸引人的表格格式显示 logs 数据框。
这个例子展示了将 Python 代码与 PySpark 混合是多么容易。除了提供大量函数外,DataFrame API 还将诸如列名等信息暴露在方便的 Python 结构中。当这样做有意义时,我不会避免使用库中的功能,但就像在列表 4.8 中一样,我会尽力解释它是做什么的以及为什么我们要使用它。第八章和第九章将更深入地探讨如何在 PySpark 中结合纯 Python 代码。
4.4.2 保留所需内容:删除列
选择列的另一面是选择不选择什么。我们可以通过 select() 完成整个旅程,仔细构建我们想要保留的列的列表。幸运的是,PySpark 也提供了一个更短的旅程:简单地删除你不需要的。
让我们本着整理的精神,从当前数据框中删除两列。希望这能给我们带来快乐:
-
BroadCastLogID是表的主键,在回答我们的问题时不会对我们有帮助。 -
SequenceNo是一个序列号,也不会有用。
当我们开始查看链接表时,还会有更多内容被删除。下一列表中的代码就是这样做的。
列表 4.9 使用 drop() 方法删除列
logs = logs.drop("BroadcastLogID", "SequenceNO")
# Testing if we effectively got rid of the columns
print("BroadcastLogID" in logs.columns) # => False
print("SequenceNo" in logs.columns) # => False
就像 select() 一样,drop() 也是一个 *cols 并返回一个数据框,这次排除了作为参数传递的列。就像 PySpark 中的每个其他方法一样,drop() 返回一个新的数据框,因此我们通过分配代码的结果来覆盖我们的 logs 变量。
警告 与 select() 不同,选择一个不存在的列将返回一个运行时错误,删除一个不存在的列是一个无操作。PySpark 将简单地忽略它找不到的列。注意你的列名拼写!
根据你想要保留多少列,select() 可能是一个更整洁的方式来只保留你想要的内容。我们可以将 drop() 和 select() 视为同一枚硬币的两面:一个删除你指定的内容;另一个保留你指定的内容。我们可以用 select() 方法重现列表 4.9,下一个列表就是这样做的。
列表 4.10 删除列,选择样式
logs = logs.select(
*[x for x in logs.columns if x not in ["BroadcastLogID", "SequenceNO"]]
)
高级主题:不幸的不一致性
理论上,你也可以不解包列表来 select() 列。这段代码将按预期工作:
logs = logs.select(
[x for x in logs.columns if x not in ["BroadcastLogID", "SequenceNO"]]
)
对于 drop() 而言并非如此,你需要显式解包:
logs.drop(logs.columns[:])
# TypeError: col should be a string or a Column
logs.drop(*logs.columns[:])
# DataFrame[]
我宁愿显式解包,以避免记住何时是强制性的,何时是可选性的认知负担。
你现在已经知道了在数据框上执行的最基本操作。你可以选择和删除列,并且利用第二章和第三章中介绍的 select() 的灵活性,你可以对现有列应用函数以转换它们。下一节将介绍如何在不依赖 select() 的情况下创建新列,从而简化你的代码并提高其弹性。
注意:在第六章中,我们将学习如何限制直接从模式定义中读取的数据。这是一种避免最初就删除列的有吸引力的方法。
练习 4.2
代码的打印结果是什么?
sample_frame.columns # => ['item', 'price', 'quantity', 'UPC']
print(sample_frame.drop('item', 'UPC', 'prices').columns)
a) ['item' 'UPC']
b) ['item', 'upc']
c) ['price', 'quantity']
d) ['price', 'quantity', 'UPC']
e) 抛出错误
4.4.3 创建不存在的内容:使用 withColumn() 创建新列
创建新列是一项如此基本的操作,以至于依赖 select() 似乎有些牵强。这也给代码的可读性带来了很大压力;例如,使用 drop() 可以清楚地表明我们正在删除列。如果能有一种信号表明我们正在创建新列那就太好了。PySpark 将这个函数命名为 withColumn()。
在疯狂创建列之前,让我们用一个简单的例子开始,逐步构建我们需要的内容,然后将数据移动到 withColumn()。让我们以 Duration 列为例,该列包含每个展示节目的长度。
列表 4.11 选择并显示 Duration 列
logs.select(F.col("Duration")).show(5)
# +----------------+
# | Duration|
# +----------------+
# |02:00:00.0000000|
# |00:00:30.0000000|
# |00:00:15.0000000|
# |00:00:15.0000000|
# |00:00:15.0000000|
# +----------------+
# only showing top 5 rows
print(logs.select(F.col("Duration")).dtypes) ❶
# [('Duration', 'string')]
❶ 数据框的 dtypes 属性包含列名及其类型,封装在一个元组中。
PySpark 没有日期或持续时间的时间类型的默认类型,因此它将列保留为字符串类型。我们通过 dtypes 属性验证了确切的类型,该属性返回数据框列的名称和类型。字符串是一个安全且合理的选项,但这对我们的目的来说并不特别有用。多亏了我们的窥视,我们可以看到字符串的格式类似于 HH:MM:SS.mmmmmm,其中
-
HH是以小时为单位的时间长度。 -
MM是以分钟为单位的时间长度。 -
SS是以秒为单位的时间长度。 -
mmmmmmm是以微秒为单位的时间长度。
注意:为了匹配任意的日期/时间戳模式,请参考 Spark 文档中关于日期时间模式的说明,mng.bz/M2X2。
我忽略了微秒的持续时间,因为我认为这不会有多大影响。pyspark.sql.functions 模块(我们将其别名为 F)包含 substr() 函数,该函数可以从字符串列中提取子字符串。在列表 4.12 中,我使用它从 Duration 列中提取小时、分钟和秒。substr() 方法接受两个参数。第一个参数给出子字符串开始的位位置,第一个字符是 1,而不是 Python 中的 0。第二个参数给出我们想要提取的子字符串的长度,以字符数表示。函数应用返回一个字符串 Column,我通过 cast() 方法将其转换为 Integer。最后,我为每个列提供一个别名,这样我们就可以轻松地分辨出它们。
列表 4.12 从 Duration 列提取小时、分钟和秒
logs.select(
F.col("Duration"), ❶
F.col("Duration").substr(1, 2).cast("int").alias("dur_hours"), ❷
F.col("Duration").substr(4, 2).cast("int").alias("dur_minutes"), ❸
F.col("Duration").substr(7, 2).cast("int").alias("dur_seconds"), ❹
).distinct().show( ❺
5
)
# +----------------+---------+-----------+-----------+
# | Duration|dur_hours|dur_minutes|dur_seconds|
# +----------------+---------+-----------+-----------+
# |00:10:06.0000000| 0| 10| 6|
# |00:10:37.0000000| 0| 10| 37|
# |00:04:52.0000000| 0| 4| 52|
# |00:26:41.0000000| 0| 26| 41|
# |00:08:18.0000000| 0| 8| 18|
# +----------------+---------+-----------+-----------+
# only showing top 5 rows
❶ 为了保持清晰,原始列。
❷ 前两个字符是小时。
❸ 第四个和第五个字符是分钟。
❹ 第七个和第八个字符是秒。
❺ 为了避免看到重复的行,我在结果中添加了 distinct()。
在 show() 之前,我使用了 distinct() 方法,这会去重数据框。这将在第五章中进一步解释。我添加 distinct() 以避免在显示时看到重复的行,这些行不会提供任何额外的信息。
注意:我们可以通过 UDF(见第八章和第九章)依赖 datetime 和 timedelta Python 构造。根据 UDF 的类型(简单与矢量化),性能可能会较慢或与使用此方法相当。虽然 UDF 有其专门的章节,但我尽量使用 PySpark API 的尽可能多的功能,在需要超出可用功能的功能时使用 UDF。
我认为我们做得很好!让我们将这些值合并到一个字段中:程序的秒数长度。PySpark 可以使用与 Python 相同的运算符对列对象进行算术运算,所以这将非常简单!在下一个列表中,我们像处理简单的数值一样对整数列进行加法和乘法运算。
列表 4.13 从 Duration 列创建秒字段的时间长度
logs.select(
F.col("Duration"),
(
F.col("Duration").substr(1, 2).cast("int") * 60 * 60
+ F.col("Duration").substr(4, 2).cast("int") * 60
+ F.col("Duration").substr(7, 2).cast("int")
).alias("Duration_seconds"),
).distinct().show(5)
# +----------------+----------------+
# | Duration|Duration_seconds|
# +----------------+----------------+
# |00:10:30.0000000| 630|
# |00:25:52.0000000| 1552|
# |00:28:08.0000000| 1688|
# |06:00:00.0000000| 21600|
# |00:32:08.0000000| 1928|
# +----------------+----------------+
# only showing top 5 rows
我们保留了相同的定义,移除了别名,并直接在列上执行算术运算。一分钟有 60 秒,一小时有 60 * 60 秒。PySpark 遵守运算符优先级,所以我们不需要用括号来混淆我们的方程。总的来说,我们的代码相当容易理解,我们准备将我们的列添加到数据框中。
如果我们想在数据框的末尾添加一个列怎么办?而不是在所有列 加上 我们的新列上使用 select(),让我们使用 withColumn()。应用于数据框,它将返回一个带有新列附加的数据框。下一个列表将我们的字段添加到我们的 logs 数据框中。我还包括 printSchema() 方法的示例,以便您可以看到末尾添加的列。
列表 4.14 使用 withColumn() 创建新列
logs = logs.withColumn(
"Duration_seconds",
(
F.col("Duration").substr(1, 2).cast("int") * 60 * 60
+ F.col("Duration").substr(4, 2).cast("int") * 60
+ F.col("Duration").substr(7, 2).cast("int")
),
)
logs.printSchema()
# root
# |-- LogServiceID: integer (nullable = true)
# |-- LogDate: timestamp (nullable = true)
# |-- AudienceTargetAgeID: integer (nullable = true)
# |-- AudienceTargetEthnicID: integer (nullable = true)
# [... more columns]
# |-- Language2: integer (nullable = true)
# |-- Duration_seconds: integer (nullable = true) ❶
❶ 我们的数据框末尾添加了 Duration_seconds 列。
警告 如果你使用 withColumn() 创建一个列,并给它一个在数据框中已经存在的名字,PySpark 会愉快地覆盖这个列。这通常非常有用,可以保持列的数量可控,但请确保你确实想要达到这种效果!
我们可以使用相同的表达式使用 select() 和 withColumn() 来创建列。两种方法都有其用途。当你明确地处理几个列时,select() 将非常有用。当你需要创建几个新列而不改变数据框的其余部分时,我更喜欢 withColumn()。面对选择时,你会很快对哪个更容易有直观的认识。
警告 使用 withColumns() 创建许多(100+)新列将使 Spark 变得非常缓慢。如果你需要一次性创建很多列,请使用 select() 方法。虽然它会产生相同的工作量,但它对查询计划器的负担较小。

图 4.5 select() 与 withColumn() 的视觉对比。withColumn() 在不需要显式指定的情况下保留了所有现有列。
4.4.4 整理我们的数据框:重命名和重新排序列
本节介绍了如何使列的顺序和名称更加友好。这可能会显得有些空洞,但当你花费几个小时在特别棘手的数据上敲代码时,你会很高兴拥有这个工具箱。
可以使用 select() 和 alias() 来重命名列,当然。我们在第三章中简要介绍了 PySpark 提供的更简单的方法。进入 withColumnRenamed()!在以下列表中,我使用 withColumnRenamed() 来移除我新创建的 duration_seconds 列中的大写字母。
列表 4.15 通过 withColumnRenamed() 方式重命名一个列
logs = logs.withColumnRenamed("Duration_seconds", "duration_seconds")
logs.printSchema()
# root
# |-- LogServiceID: integer (nullable = true)
# |-- LogDate: timestamp (nullable = true)
# |-- AudienceTargetAgeID: integer (nullable = true)
# |-- AudienceTargetEthnicID: integer (nullable = true)
# [...]
# |-- Language2: integer (nullable = true)
# |-- duration_seconds: integer (nullable = true)
我非常喜欢使用没有大写字母的列名。我是一个懒惰的打字员,一直按着 Shift 键会增加工作量!我可以用 withColumnRenamed() 结合一个遍历所有列的 for 循环来重命名我的数据框中的列。PySpark 开发者考虑到了这一点,并提供了一种更好的方法,可以在一次操作中重命名数据框中的所有列。这依赖于一个名为 toDF() 的方法,它返回一个新的数据框,其中包含新的列。就像 drop() 一样,toDF() 也接受一个 *cols 参数,就像 select() 和 drop() 一样,如果列名在列表中,我们需要解包它们。下一列表中的代码展示了如何使用该方法在一行中重命名所有列到小写。
列表 4.16 使用 toDF() 方法批量转换为小写
logs.toDF(*[x.lower() for x in logs.columns]).printSchema()
# root
# |-- logserviceid: integer (nullable = true)
# |-- logdate: timestamp (nullable = true)
# |-- audiencetargetageid: integer (nullable = true)
# |-- audiencetargetethnicid: integer (nullable = true)
# |-- categoryid: integer (nullable = true)
# [...]
# |-- language2: integer (nullable = true)
# |-- duration_seconds: integer (nullable = true)
如果你仔细查看代码,你会看到我没有分配结果数据框。我想展示这个功能,但由于我们有一些辅助表,它们的列名与我们的匹配,我想避免在每个表中将每个列都转换为小写的麻烦。
我们的最后一步是重新排序列。由于重新排序列等同于以不同的顺序选择列,因此 select() 是完成这项工作的完美方法。例如,如果我们想按字母顺序排序列,我们可以使用列表中的数据框列上的 sorted 函数,就像在下一个列表中一样。
列表 4.17 使用 select() 按字母顺序选择我们的列
logs.select(sorted(logs.columns)).printSchema()
# root
# |-- AudienceTargetAgeID: integer (nullable = true)
# |-- AudienceTargetEthnicID: integer (nullable = true)
# |-- BroadcastOriginPointID: integer (nullable = true)
# |-- CategoryID: integer (nullable = true)
# |-- ClosedCaptionID: integer (nullable = true)
# |-- CompositionID: integer (nullable = true)
# [...]
# |-- Subtitle: string (nullable = true)
# |-- duration_seconds: integer (nullable = true) ❶
❶ 记住,在大多数编程语言中,大写字母在小写字母之前。
在本节中,我们覆盖了很多内容:通过选择、删除、创建、重命名和重新排序列,我们获得了关于 PySpark 如何管理和提供数据框结构可见性的直觉。在下一节中,我将介绍一种快速探索数据框数据的方法。
4.4.5 使用 describe() 和 summary() 诊断数据框
当处理数值数据时,查看一长串值并不很有用。我们通常更关心一些关键信息,这可能包括计数、平均值、标准差、最小值和最大值。在本节中,我将介绍如何使用 PySpark 的 describe() 和 summary() 方法快速探索数值列。
当应用于没有参数的数据框时,describe() 将显示所有数值和字符串列的摘要统计信息(计数、平均值、标准差、最小值和最大值)。为了避免屏幕溢出,我通过遍历列列表逐个显示列描述,并在下一个列表中显示 describe() 的输出。请注意,describe() 将(懒加载地)计算数据框但不会显示它,就像任何转换一样,因此我们必须 show() 结果。
列表 4.18 一次性描述所有内容
for i in logs.columns:
logs.describe(i).show()
# +-------+------------------+ ❶
# |summary| LogServiceID|
# +-------+------------------+
# | count| 7169318|
# | mean|3453.8804215407936|
# | stddev|200.44137201584468|
# | min| 3157|
# | max| 3925|
# +-------+------------------+
#
# [...]
#
# +-------+ ❷
# |summary|
# +-------+
# | count|
# | mean|
# | stddev|
# | min|
# | max|
# +-------+
# [... many more little tables]
❶ 数值列将以描述表的形式显示信息,如下所示。
❷ 如果列的类型不兼容,PySpark 只会显示标题列。
这会比一次性完成所有操作花费更多时间,但输出将更加友好。由于我们无法计算字符串的平均值或标准差,因此您将看到这些列的 null 值。此外,某些列可能不会显示(您将看到只有标题列的时间表),因为 describe() 只适用于数值和字符串列。对于简短的行来说,您仍然可以得到很多信息!
describe() 是一个很棒的方法,但如果您想要更多呢?summary() 来拯救!
describe() 函数将 *cols 作为参数(一个或多个列,与 select() 或 drop() 相同),而 summary() 函数将 *statistics 作为参数。这意味着在传递 summary() 方法之前,您需要选择您想要查看的列。另一方面,我们可以自定义我们想要查看的统计信息。默认情况下,summary() 会显示 describe() 显示的所有内容,并添加约 25-50% 和 75% 的分位数。下面的列表显示了如何用 summary() 替换 describe() 以及这样做的结果。
列表 4.19 一次性总结所有内容
for i in logs.columns:
logs.select(i).summary().show() ❶
# +-------+------------------+
# |summary| LogServiceID|
# +-------+------------------+
# | count| 7169318|
# | mean|3453.8804215407936|
# | stddev|200.44137201584468|
# | min| 3157|
# | 25%| 3291|
# | 50%| 3384|
# | 75%| 3628|
# | max| 3925|
# +-------+------------------+
#
# [... many more slightly larger tables]
for i in logs.columns:
logs.select(i).summary("min", "10%", "90%", "max").show() ❷
# +-------+------------+
# |summary|LogServiceID|
# +-------+------------+
# | min| 3157|
# | 10%| 3237|
# | 90%| 3710|
# | max| 3925|
# +-------+------------+
#
# [...]
❶ 默认情况下,我们有计数、平均值、标准差、最小值、25%、50%、75%、最大值作为统计量。
❷ 我们也可以按照相同的命名约定传递自己的参数。
如果您只想限制自己查看这些指标的一部分,summary() 将接受代表统计量的多个字符串参数。您可以直接输入 count、mean、stddev、min 或 max。对于近似百分位数,您需要以 XX% 格式提供它们,例如 25%。
这两种方法都只能在非 null 值上工作。对于汇总统计,这是预期的行为,但“计数”条目也将只计算每个列的非 null 值。这是一种查看哪些列大部分为空的好方法!
警告 describe() 和 summary() 是两个非常有用的方法,但它们不是用于开发期间快速查看数据的。PySpark 开发者不保证输出在各个版本之间看起来相同,因此如果您需要程序中的一个输出,请使用 pyspark.sql.functions 中的相应函数。它们都在那里。
本章介绍了表格数据集的摄取和发现,这是最受欢迎的数据表示格式之一。我们基于第二章和第三章中介绍的 PySpark 数据操作基础知识,通过处理列添加了一个新层次。下一章将是本章的直接延续,我们将探讨数据框结构的更多高级方面。
摘要
-
PySpark 使用
SparkReader对象直接读取数据框中的任何类型的数据。专门的CSVSparkReader用于摄取 CSV 文件。就像读取文本一样,唯一的强制参数是源位置。 -
CSV 格式非常灵活,因此 PySpark 提供了许多可选参数来处理这种灵活性。其中最重要的参数是字段分隔符、记录分隔符和引号字符,它们都有合理的默认值。
-
通过将
inferSchema可选参数设置为True,PySpark 可以推断 CSV 文件的模式。PySpark 通过读取数据两次来完成此操作:一次用于为每个列设置适当的类型,另一次用于以推断的格式摄取数据。 -
表格数据在数据框中以一系列列的形式表示,每个列都有一个名称和类型。由于数据框是一个列主数据结构,行的概念不太相关。
-
您可以使用 Python 代码高效地探索数据,将列列表作为任何 Python 列来暴露感兴趣的数据框的元素。
-
数据框上最常用的操作是选择、删除和创建列。在 PySpark 中,分别使用
select()、drop()和withColumn()方法。 -
select可以通过传递一个重新排序的列列表来用于列重排。 -
您可以使用
withColumnRenamed()方法逐个重命名列,或者使用toDF()方法一次性重命名所有列。 -
您可以使用
describe()或summary()方法来显示列的摘要。describe()方法具有一组固定的度量指标,而summary()方法将接受函数作为参数并将它们应用于所有列。
额外练习
练习 4.3
重新读取 logs_raw 数据框中的数据(数据文件位于 ./data/broadcast_logsBroadcastLogs_2018_Q3_M8.CSV),这次不传递任何可选参数。打印数据的前五行以及模式。logs 和 logs_raw 在数据和模式方面有哪些不同?
练习 4.4
创建一个新的数据框 logs_clean,其中只包含不以 ID 结尾的列。
¹ 如果我们非常挑剔,表格数据和关系数据并不完全相同。在第五章中,当将多个数据框一起使用时,这些差异将很重要。当处理单个表时,我们可以将这两个概念合并在一起。
5 数据帧体操:连接和分组
本章涵盖
-
将两个数据帧连接在一起
-
选择适合你用例的正确类型的连接
-
对数据进行分组和理解
GroupedData转换对象 -
使用聚合方法打破
GroupedData -
在你的数据帧中填充
null值
在第四章中,我们探讨了如何通过选择、删除、创建、重命名、重新排序和创建列的摘要来转换数据帧。这些操作构成了在 PySpark 中处理数据帧的基础。在本章中,我将完成对你在数据帧上最常执行的操作的回顾:连接或 连接 数据帧,以及分组数据(并在 GroupedData 对象上执行操作)。我们通过将我们的探索性程序封装成一个可以提交的单个脚本来完成本章,就像我们在第三章中所做的那样。本章学到的技能完成了你在日常工作中转换数据时将使用的所有基本操作集。
我们使用与第四章中留下的相同的 logs 数据帧。在实际步骤中,本章的代码通过包含在链接表中的相关信息丰富我们的表格,然后使用可以被认为是第四章中展示的 describe() 方法的进阶版本对其进行总结。如果你想要以最小的麻烦赶上进度,我在 code/Ch04-05 目录提供了一个 checkpoint.py 脚本。
5.1 从多到一:连接数据
当处理数据时,我们通常一次只处理一个结构。到目前为止,我们已经探索了多种方法来切割、切块和修改数据帧以满足我们的最大愿望。当我们需要连接两个来源时会发生什么?本节将介绍连接以及我们如何在使用星型模式设置或另一组值完全匹配的表中应用它们。
在处理相关表时,连接数据帧是一个常见的操作。如果你使用过其他数据处理库,你可能见过相同的操作被称为 合并 或 链接。因为执行连接有多种方式,所以下一节设定了一个共同词汇表,以避免混淆并在坚实的基础上建立理解。
5.1.1 连接世界中的“什么是什么”
本节涵盖了连接的核心蓝图。我介绍了 join() 方法的通用语法,以及不同的参数。有了这些,你将能够识别并知道如何构建一个基本的连接,并准备好执行更具体的连接操作。
在最基本层面上,join 操作是一种将一个数据帧中的数据根据一组规则链接到另一个数据帧的方法。为了介绍连接的移动部件,我在列表 5.1 中提供了一个要连接到我们的 logs 数据帧的第二个表。我使用与 logs 表相同的 SparkReader.csv 参数化来读取我们的新 log_identifier 表。一旦表被摄取,我就过滤数据帧,只保留主要通道,如数据文档中所述。有了这个,我们应该可以开始了。
列表 5.1 探索我们的第一个链接表:log_identifier
DIRECTORY = "./data/broadcast_logs"
log_identifier = spark.read.csv(
os.path.join(DIRECTORY, "ReferenceTables/LogIdentifier.csv"),
sep="|",
header=True,
inferSchema=True,
)
log_identifier.printSchema()
# root
# |-- LogIdentifierID: string (nullable = true) ❶
# |-- LogServiceID: integer (nullable = true) ❷
# |-- PrimaryFG: integer (nullable = true) ❸
log_identifier = log_identifier.where(F.col("PrimaryFG") == 1)
print(log_identifier.count())
# 758
log_identifier.show(5)
# +---------------+------------+---------+
# |LogIdentifierID|LogServiceID|PrimaryFG|
# +---------------+------------+---------+
# | 13ST| 3157| 1|
# | 2000SM| 3466| 1|
# | 70SM| 3883| 1|
# | 80SM| 3590| 1|
# | 90SM| 3470| 1|
# +---------------+------------+---------+
# only showing top 5 rows
❶ 这是通道标识符。
❷ 这是通道键(它映射到我们的中心表)。
❸ 这是一个布尔标志:通道是主要通道(1)还是(0)?我们只想保留 1。
我们有两个数据帧,logs 和 log_identifier,每个数据帧都包含一组列。我们准备好开始连接操作了!
连接操作有三个主要成分:
-
两个表,分别称为 left 和 right 表
-
一个或多个 predicates,它们是一系列条件,决定了两个表之间的记录如何连接
-
一种指示在谓词成功和失败时如何执行连接的方法
通过这三个成分,您可以使用列表 5.2 中的蓝图,通过填写相关关键字来在 PySpark 中构建两个数据帧之间的连接,以实现所需的行为。PySpark 中的每个连接操作都将遵循相同的蓝图。接下来的几节将分别介绍每个关键字,并说明它们如何影响最终结果。
列表 5.2 PySpark 中连接操作的裸骨配方
[LEFT].join(
[RIGHT],
on=[PREDICATES]
how=[METHOD]
)
5.1.2 知道我们的左和右
一次在两个表上执行连接操作。在本节中,我们将介绍列表 5.2 中的 [LEFT] 和 [RIGHT] 块。了解哪个表被称为左表以及哪个被称为右表,在讨论连接类型时很有帮助,因此我们从这个有用的词汇表开始。
由于数据操作词汇中的 SQL 遗产,两个表被命名为 left 和 right 表。在 PySpark 中,一个记住左右表的好方法是说左表位于 join() 方法的左侧,而右表位于右侧(括号内)。知道哪个是哪个在选择连接方法时非常有用。不出所料,存在左连接和右连接类型(见 5.1.4 节)。
我们的表现在已经被标识,因此我们可以更新我们的连接蓝图,如下一列表所示。我们现在需要将注意力转向下一个参数,即谓词。
列表 5.3 PySpark 中裸骨连接,左表和右表已填写
logs.join( ❶
log_identifier, ❷
on=[PREDICATES]
how=[METHOD]
)
❶ logs 是左表 . . .
❷ . . . 以及 log_identifier 是右表。
5.1.3 成功连接的规则:谓词
本节涵盖了连接蓝图中的 [PREDICATES] 块,这是确定左表中的哪些记录将与右表匹配的基石。连接操作中的大多数谓词都很简单,但根据您想要的逻辑,它们可以显著增加复杂性。我首先介绍最简单和最常见的使用案例,然后再过渡到更复杂的谓词。
PySpark 连接的谓词是左右数据帧列之间的规则。连接是按记录进行的,其中左数据帧上的每个记录(通过谓词)与右数据帧上的每个记录进行比较。如果谓词返回 True,则连接匹配;如果返回 False,则不匹配。我们可以将其视为双向的 where 子句(见第二章):您将一个表中的值与另一个表中的值匹配,谓词块(布尔值)的结果确定是否匹配。
最好的说明谓词的方法是创建一个简单的示例并探索结果。对于我们的两个数据帧,我们将构建谓词 logs["LogServiceID"] == log_identifier["LogServiceID"]。用普通英语来说,这相当于“当它们的 LogServiceID 列的值相等时,匹配 logs 数据帧中的记录与 log_identifier 数据帧中的记录”。
我已经从两个数据帧中取了一个小样本,并在图 5.1 中说明了应用谓词的结果。有两个重要点需要强调:
-
如果左表中的一条记录与右表中的多条记录(或反之亦然)解析谓词,则该记录将在连接表中重复。
-
如果左表或右表中的一条记录与另一表中的任何记录都无法解析谓词,则它将不会出现在结果表中,除非连接方法(见第 5.1.4 节)指定了失败谓词的协议。

图 5.1 使用 LogServiceID 在两个表中的简单连接谓词解析以及谓词中的等式测试。我只显示了结果表中的四个成功案例。我们的谓词应用于我们两个表的一个样本:左表中的 3590 解析谓词两次,而左表中的 3417 和右表中的 3883 没有匹配。
在我们的例子中,左表中的 3590 记录等于右表中的两条对应记录,我们在结果集中看到这个数字有两个解决的谓词。另一方面,左表中的 3417 记录与右表中没有任何匹配,因此它不在结果集中。右表中的 3883 记录发生相同的情况。
您在谓词中不仅限于一个测试。您可以通过使用布尔运算符(如 |(或)或 &(与))将它们分开来使用多个条件。您还可以使用不同于等于的测试。以下有两个示例及其普通英语翻译:
-
(logs["LogServiceID"]==log_identifier["LogServiceID"])&(logs["left_col"]<log_identifier["right_col"])—这只会匹配两边的LogServiceID相同,并且logs表中的left_col的值小于log_identifier表中的right_col的值的记录。 -
(logs["LogServiceID"]==log_identifier["LogServiceID"])|(logs["left_col"]>log_identifier["right_col"])—这只会匹配两边的LogServiceID相同的记录,或者logs表中的left_col的值大于log_identifier表中的right_col的值。
你可以使操作尽可能复杂。我建议将每个条件用括号括起来,以避免担心运算符优先级并便于阅读。
在将我们的谓词添加到正在进行的连接之前,我想指出 PySpark 提供了一些谓词快捷方式来减少代码的复杂性。如果你有多个and谓词(例如(left["col1"] == right["colA"]) & (left["col2"] > right["colB"]) & (left["col3"] != right["colC"])),你可以将它们放入一个列表中,例如[left["col1"] == right["colA"], left["col2"] > right["colB"], left["col3"] != right["colC"]]。这使得你的意图更加明确,并避免了在长链条件中计数括号。
最后,如果你正在进行“等值连接”,即测试同名列之间的相等性,你可以简单地指定列名作为字符串或字符串列表作为谓词。在我们的例子中,这意味着我们的谓词只能是"LogServiceID"。这就是我在以下列表中放入的内容。
列表 5.4 PySpark 中的连接,包含左右表和谓词
logs.join(
log_identifier,
on="LogServiceID"
how=[METHOD]
)
连接方法影响你如何构建谓词,因此第 5.1.5 节在完成逐个成分的方法后,重新审视整个连接操作。最后一个参数是how,它完成了我们的连接操作。
5.1.4 如何做:连接方法
成功连接的最后一种成分是how参数,它将指示连接方法。大多数解释连接的书籍都会展示维恩图来表示每种连接如何着色不同的区域,但我发现这仅仅是一个提醒,而不是一个教学工具。我将使用与图 5.1 中相同的表格来回顾每种类型的连接,并给出操作的结果。
连接方法归结为这两个问题:
-
当谓词的返回值为
True时会发生什么? -
当谓词的返回值为
False时会发生什么?
根据这些问题的答案对连接方法进行分类是记住它们的一个简单方法。
提示 PySpark 的连接本质上与 SQL 相同。如果你已经熟悉它们,可以自由跳过这一节。
内连接
内连接(how="inner")是最常见的连接。如果你没有明确传递连接方法,PySpark 将默认使用内连接。如果谓词为真,则返回记录;如果为假,则丢弃它。我认为内连接是考虑连接的自然方式,因为它们非常简单易懂。
如果我们查看我们的表,我们有一个与图 5.1 非常相似的表。左表上LogServiceID等于3590的记录将被重复,因为它与右表中的两个记录匹配。结果如图 5.2 所示。


左和右外连接
左(how="left"或how="left_outer")和右(how="right"或how="right_outer"),如图 5.4 所示,与内连接类似,它们在成功谓词的情况下生成一个记录。区别在于当谓词为假时会发生什么:
-
一个左连接(也称为左外连接)将在连接表中添加来自左表的不匹配记录,并用
null填充来自右表的列。 -
一个右连接(也称为右外连接)将在连接表中添加右表中不匹配的记录,并用
null填充来自左表的列。
在实践中,这意味着你的连接表保证包含所有为连接提供数据的表的记录(左或右)。直观上,图 5.3 显示了这一点。尽管3417不满足谓词,但它仍然存在于左连接表中。同样,3883和右表也是如此。就像内连接一样,如果谓词成功多次,记录将被重复。

图 5.3 左连接和右连接的表。结果表中包含方向表的所有记录。
当你不确定链接表是否包含所有键时,左连接和右连接非常有用。你可以填充null值(参见列表 5.16)或处理它们,同时知道你没有删除任何记录。
全外连接
全外连接(how="outer",how="full"或how="full_outer")连接只是左连接和右连接的融合。它将添加来自左表和右表的不匹配记录,并用null填充。它服务于与左连接和右连接类似的目的,但由于你通常只有一个(并且只有一个)锚表,你希望在其中保留所有记录,所以它不太受欢迎。

图 5.4 左连接和右连接的表。我们可以看到两个表中的所有记录。
左半连接和左反连接
左半连接和左反连接不太常见,但仍然非常有用。
左半连接 (how="left_semi") 与内连接相同,但保留左表中的列。它也不会在左表中重复记录,如果它们与右表中的多个记录满足谓词。其主要目的是根据依赖于另一个表的谓词从表中过滤记录。
左外反连接 (how="left_anti") 是内连接的相反。它将仅保留来自左表的记录,这些记录与右表中的任何记录都不匹配。如果左表中的记录与右表中的记录匹配,它将从连接操作中删除。
我们的设计图连接现在已最终确定:我们选择内连接,因为我们只想保留 LogServiceID 在我们的 log_identifier 表中有额外信息的记录。由于我们的连接已完成,我将结果分配给一个新变量:logs_and_channels。
列表 5.5 PySpark 中的我们的连接,所有参数都已填写
logs_and_channels = logs.join(
log_identifier,
on="LogServiceID",
how="inner" ❶
)
❶ 我本可以直接省略 how 参数,因为内连接是默认的。
在本节中,我们回顾了不同的连接方法及其用法。下一节将涵盖连接时列和数据帧名称的无害但重要方面。它将提供一个解决方案,以解决左表和右表都具有相同名称列的常见问题。
交叉连接:核选项
交叉连接 (how="cross") 是核选项。它为每个记录对返回一个记录,无论谓词返回的值如何。在我们的数据帧示例中,我们的 logs 表包含四个记录,而 logs_identifier 表包含五个记录,因此交叉连接将包含 4 × 5 = 20 个记录。结果如图 5.5 所示。

图 5.5 交叉连接的视觉示例。左边的每个记录都与右边的每个记录相匹配。
交叉连接很少是您想要的操作,但它们在您想要包含每个可能组合的表时很有用。
PySpark 还提供了一个显式的 crossJoin() 方法,该方法将右侧数据帧作为参数。
分布式环境中的连接科学
在分布式环境中连接数据时,“我们不在乎数据在哪里”不再适用。为了能够处理记录之间的比较,数据需要位于同一台机器上。如果不是这样,PySpark 将在称为 shuffle 的操作中移动数据。正如你所想象的那样,在网络上移动大量数据非常慢,我们应该尽可能避免这种情况。
这就是 PySpark 的抽象模型显示出一些弱点的一个实例。由于连接是处理多个数据源的重要组成部分,我在这里介绍了语法,这样我们就可以开始操作了。第十一章将更详细地讨论 shuffle。
5.1.5 连接世界的命名约定
本节介绍了 PySpark 如何管理列和数据帧的名称。虽然这适用于连接世界之外的情况,但在尝试将多个数据帧组合成一个时,名称冲突是最痛苦的。我们介绍了如何防止名称冲突以及如果你继承了一个已经混乱的数据帧应该如何处理它。
默认情况下,PySpark 不允许两个列有相同的名称。如果你使用withColumn()创建一个具有现有列名称的列,PySpark 将覆盖(或阴影)该列。当连接数据帧时,情况要复杂一些,如下面的列表所示。
列表 5.6 生成两个看似同名列的连接
logs_and_channels_verbose = logs.join(
log_identifier, logs["LogServiceID"] == log_identifier["LogServiceID"]
)
logs_and_channels_verbose.printSchema()
# root
# |-- LogServiceID: integer (nullable = true) ❶
# |-- LogDate: timestamp (nullable = true)
# |-- AudienceTargetAgeID: integer (nullable = true)
# |-- AudienceTargetEthnicID: integer (nullable = true)
# [...]
# |-- duration_seconds: integer (nullable = true)
# |-- LogIdentifierID: string (nullable = true)
# |-- LogServiceID: integer (nullable = true) ❷
# |-- PrimaryFG: integer (nullable = true)
try:
logs_and_channels_verbose.select("LogServiceID")
except AnalysisException as err:
print(err)
# "Reference 'LogServiceID' is ambiguous, could be: LogServiceID, LogServiceID.;"❸
❶ 这是一个 LogServiceID 列...
❷ ...这是另一个。
❸ PySpark 不知道我们指的是哪个列:是 LogServiceID 还是 LogServiceID?
PySpark 愉快地将两个数据帧连接起来,但当我们尝试处理有歧义的列时却失败了。这在处理遵循相同列命名约定的数据时是一个常见情况。为了解决这个问题,在本节中,我将展示三种方法,从最简单到最通用。
首先,在进行等值连接时,我更喜欢使用简化语法,因为它会处理移除谓词列的第二个实例。这仅在使用相等比较时有效,因为从谓词的两个列中的数据是相同的,这防止了信息丢失。我将在下一个列表中展示使用简化等值连接时的代码和模式。
列表 5.7 使用等值连接的简化语法
logs_and_channels = logs.join(log_identifier, "LogServiceID")
logs_and_channels.printSchema()
# root
# |-- LogServiceID: integer (nullable = true)
# |-- LogDate: timestamp (nullable = true)
# |-- AudienceTargetAgeID: integer (nullable = true)
# |-- AudienceTargetEthnicID: integer (nullable = true)
# |-- CategoryID: integer (nullable = true)
# [...]
# |-- Language2: integer (nullable = true)
# |-- duration_seconds: integer (nullable = true)
# |-- LogIdentifierID: string (nullable = true) ❶
# |-- PrimaryFG: integer (nullable = true) ❶
❶ 这里没有 LogServiceID:PySpark 只保留了第一个引用的列。
第二种方法依赖于 PySpark 连接的数据帧记住列的来源。正因为如此,我们可以使用与之前相同的命名法(即,log_identifier["LogServiceID"])来引用LogServiceID列。然后我们可以重命名这个列或删除它,从而解决我们的问题。我在下面的列表中使用这种方法。
列表 5.8 使用列的原始名称进行无歧义选择
logs_and_channels_verbose = logs.join(
log_identifier, logs["LogServiceID"] == log_identifier["LogServiceID"]
)
logs_and_channels.drop(log_identifier["LogServiceID"]).select(
"LogServiceID") ❶
# DataFrame[LogServiceID: int]
❶ 通过删除两个重复列中的一个,我们就可以无问题地使用另一个列的名称。
最后一种方法如果你直接使用Column对象时很方便。PySpark 不会解析原始名称,当你依赖F.col()来处理列时。为了以最通用的方式解决这个问题,我们需要在执行连接时对表进行alias(),如下面的列表所示。
列表 5.9 别名我们的表以解决来源问题
logs_and_channels_verbose = logs.alias("left").join( ❶
log_identifier.alias("right"), ❷
logs["LogServiceID"] == log_identifier["LogServiceID"],
)
logs_and_channels_verbose.drop(F.col("right.LogServiceID")).select(
"LogServiceID"
) ❸
# DataFrame[LogServiceID: int]
❶ 我们的日志表被别名化为 left。
❷ 我们的 log_identifier 被别名化为 right。
❸ F.col()将作为列名称的前缀解析左右。
这三种方法都是有效的。第一种方法仅在等值连接的情况下有效,但其他两种方法基本上可以互换。PySpark 给你提供了很多控制数据帧结构和命名的选项,但要求你必须明确。
本节包含了关于连接的大量信息,这是处理相关数据框时一个非常重要的工具。尽管可能性是无限的,但语法简单易懂:left.join(right) 决定第一个参数。on 决定是否匹配。how 指示如何在匹配成功和失败时操作。
现在第一个连接已经完成,我们将链接两个额外的表以继续我们的数据发现和处理。CategoryID 表包含有关程序类型的信息,而 ProgramClassID 表包含允许我们定位广告的数据。
这次,我们正在执行 left 连接,因为我们并不完全确定链接表中的键的存在。在列表 5.10 中,我们一次性遵循了我们为 log_identifier 表所做的相同过程:
-
我们使用
SparkReader.csv和与其他表相同的配置读取表。 -
我们保留相关列。
-
我们使用 PySpark 的方法链将数据连接到我们的
logs_and_channels表。
列表 5.10 使用两个左连接链接类别和程序类别表
DIRECTORY = "./data/broadcast_logs"
cd_category = spark.read.csv(
os.path.join(DIRECTORY, "ReferenceTables/CD_Category.csv"),
sep="|",
header=True,
inferSchema=True,
).select(
"CategoryID",
"CategoryCD",
F.col("EnglishDescription").alias("Category_Description"), ❶
)
cd_program_class = spark.read.csv(
os.path.join(DIRECTORY, "ReferenceTables/CD_ProgramClass.csv"),
sep="|",
header=True,
inferSchema=True,
).select(
"ProgramClassID",
"ProgramClassCD",
F.col("EnglishDescription").alias("ProgramClass_Description"), ❷
)
full_log = logs_and_channels.join(cd_category, "CategoryID", how="left").join(
cd_program_class, "ProgramClassID", how="left"
)
❶ 我们正在将 EnglishDescription 列别名为记住它映射的内容。
❷ 我们在这里也进行了别称,但针对的是程序类别。
在我们的表得到很好的增强后,让我们进入最后一步:使用分组来总结表。
练习 5.1
假设有两个表,left 和 right,每个表都包含一个名为 my_column 的列。这段代码的结果是什么?
one = left.join(right, how="left_semi", on="my_column")
two = left.join(right, how="left_anti", on="my_column")
one.union(two)
练习 5.2
假设有两个数据框,red 和 blue。如果你想在 red.join(blue, ...) 中使用适当的连接来连接 red 和 blue 并保留满足谓词的所有记录,应该使用哪种连接?
a) 左
b) 右
c) 内部
d) Theta
e) 交叉
练习 5.3
假设有两个数据框,red 和 blue。如果你想在 red.join(blue, ...) 中使用适当的连接来连接 red 和 blue 并保留满足谓词的所有记录,应该使用哪种连接?
a) 左
b) 右
c) 内部
d) Theta
e) 交叉
5.2 通过 groupby 和 GroupedData 总结数据
在显示数据时,尤其是大量数据时,你通常会首先使用统计方法来总结数据。第四章展示了如何使用 summary() 和 display() 计算整个数据框的均值、最小值、最大值等。那么,通过根据列内容总结来稍微扩展我们的数据框怎么样?
本节介绍了如何通过 groupby() 方法将数据框总结到更细粒度的维度(而不是整个数据框)。我们已经在 3 中对文本数据框进行了分组;本节将更深入地探讨分组的细节。在这里,我介绍了 GroupedData 对象及其用法。在实践中,我们将使用 groupby() 来回答我们最初的问题:哪些频道拥有最多和最少的广告比例?为了回答这个问题,我们必须对每个频道进行操作,以两种方式对 duration_seconds 进行求和:
-
一个用于获取程序为商业时的秒数
-
一个用于获取总编程秒数
在我们开始求和之前,我们的计划是确定什么是商业,什么不是。文档没有提供正式的指导如何做到这一点,所以我们将探索数据并得出结论。让我们开始分组!
5.2.1 一个简单的 groupby 蓝图
在第三章中,我们执行了一个非常简单的 groupby() 来计算每个单词出现的次数。这是一个基于(唯一的)列中的单词进行分组和计数记录的简单示例。在本节中,我们扩展了这个简单示例,通过在多个列上分组。我还引入了一个比我们之前使用的 count() 更通用的表示法,以便我们可以计算多个汇总函数。
由于你已经熟悉了 groupby() 的基本语法,本节首先展示一个完整的代码块,用于计算程序类总时长(以秒为单位)。在下一个列表中,我们执行分组,计算聚合函数,并以降序展示结果。
列表 5.11 显示最流行的程序类型
(full_log
.groupby("ProgramClassCD", "ProgramClass_Description")
.agg(F.sum("duration_seconds").alias("duration_total"))
.orderBy("duration_total", ascending=False).show(100, False)
)
# +--------------+--------------------------------------+--------------+
# |ProgramClassCD|ProgramClass_Description |duration_total|
# +--------------+--------------------------------------+--------------+
# |PGR |PROGRAM |652802250 |
# |COM |COMMERCIAL MESSAGE |106810189 |
# |PFS |PROGRAM FIRST SEGMENT |38817891 |
# |SEG |SEGMENT OF A PROGRAM |34891264 |
# |PRC |PROMOTION OF UPCOMING CANADIAN PROGRAM|27017583 |
# |PGI |PROGRAM INFOMERCIAL |23196392 |
# |PRO |PROMOTION OF NON-CANADIAN PROGRAM |10213461 |
# |OFF |SCHEDULED OFF AIR TIME PERIOD |4537071 |
# [... more rows]
# |COR |CORNERSTONE |null |
# +--------------+--------------------------------------+--------------+
这个小程序有几个新的部分,让我们逐一回顾。

图 5.6 原始数据帧,聚焦于我们要分组的列
我们的分组路由从图 5.6 所示的数据帧上的 groupby() 方法开始。一个“按分组”的数据帧不再是数据帧;相反,它变成了一个 GroupedData 对象,并在列表 5.12 中完整展示。这个对象是一个过渡对象:你实际上无法检查它(没有 .show() 方法),它正在等待进一步的指令以再次变得可展示。图示的话,它看起来就像图 5.7 的右侧。你有键(或键,如果你 groupby() 多个列),其余的列被分组在某个“单元格”中,等待汇总函数,以便它们可以再次提升为真正的列。

图 5.7 分组后的 GroupedData 对象
为懒惰的人聚合
agg() 也接受一个字典,形式为 {column_name: aggregation_function},其中两者都是字符串。正因为如此,我们可以像这样重写列表 5.11:
full_log.groupby("ProgramClassCD", "ProgramClass_Description").agg(
{"duration_seconds": "sum"}
).withColumnRenamed("sum(duration_seconds)", "duration_total").orderBy(
"duration_total", ascending=False
).show(
100, False
)
这使得快速原型设计变得非常容易(你可以像使用列对象一样,使用 "*" 来引用所有列)。我个人不喜欢这种方法,因为在创建时你无法为列创建别名。我包括它是因为你将在阅读其他人的代码时看到它。
列表 5.12 GroupedData 对象表示
full_log.groupby()
# <pyspark.sql.group.GroupedData at 0x119baa4e0>
在第三章中,我们通过使用count()方法将GroupedData重新引入数据框,该方法返回每个组的计数。还有一些其他方法,例如min()、max()、mean()或sum()。我们本可以直接使用sum()方法,但我们就无法选择给结果列起别名,并且会固定使用sum(duration_seconds)作为名称。相反,我们使用了奇怪命名的agg()。
agg()方法(用于聚合或聚合)将从一个我们都熟悉并喜爱的pyspark.sql.functions模块中选择一个或多个聚合函数,并将它们应用于GroupedData对象的每个组。在图 5.8 中,我从左边的GroupedData对象开始。使用适当的聚合函数调用agg()从组单元格中提取列,提取值并执行函数,得到答案。与在groupby对象上使用sum()函数相比,agg()以几个按键的代价换取两个主要优势:
-
agg()可以接受任意数量的聚合函数,与直接使用摘要方法不同。你无法在GroupedData对象上链式调用多个函数:第一个会将其转换为数据框,第二个会失败。 -
你可以给结果列起别名,从而控制它们的名称并提高你代码的健壮性。

图 5.8 应用agg()方法(聚合函数:F.sum()在Duration_seconds上)产生的一个数据框
在对GroupedData对象应用聚合函数之后,我们再次得到一个数据框。然后我们可以使用orderBy()方法按duration_total(我们新创建的列)的降序对数据进行排序。最后,我们显示 100 行,这比数据框包含的行数多,因此显示了所有内容。
让我们选择我们的商业广告。表 5.1 显示了我的选择。
表 5.1 我们将考虑作为商业广告的节目类型
| ProgramClassCD | 程序类别描述 | duration_total |
|---|---|---|
| COM | 商业信息 | 106810189 |
| PRC | 推广即将到来的加拿大节目 | 27017583 |
| PGI | 节目信息广告 | 23196392 |
| PRO | 推广非加拿大节目 | 10213461 |
| LOC | 本地广告 | 483042 |
| SPO | 赞助信息 | 45257 |
| MER | 营销 | 40695 |
| SOL | 请求信息 | 7808 |
现在我们已经完成了识别我们的商业代码的艰难工作,我们可以开始计数。下一节将介绍我们如何使用自定义列定义来灵活使用聚合。
agg()不是市场上唯一的参与者
你还可以使用groupby(),结合apply()(Spark 2.3+)和applyInPandas()(Spark 3.0+)方法,在名为“split-apply-combine”的创新模式中使用。我们将在第九章中探讨这个强大的工具。其他较少使用(但仍然有用)的方法也可用。查看GroupedData对象方法的 API 文档:mng.bz/aDoJ。
5.2.2 列就是列:使用 agg() 和自定义列定义
当在 PySpark 中对列进行分组和聚合时,我们手头上有 Column 对象的全部功能。这意味着我们可以根据自定义列进行分组和聚合!在本节中,我们将首先定义 duration_ commercial,它只取商业节目的时长,并在我们的 agg() 语句中使用它来无缝地计算总时长和商业时长。
如果我们将表 5.1 的内容编码成 PySpark 定义,它将给出下一个列表。
列表 5.13 计算表中每个节目的商业时间
F.when(
F.trim(F.col("ProgramClassCD")).isin(
["COM", "PRC", "PGI", "PRO", "PSA", "MAG", "LOC", "SPO", "MER", "SOL"]
),
F.col("duration_seconds"),
).otherwise(0)
我认为描述这次代码的最佳方式是将它直接翻译成普通的英语。
当列 ProgramClass 的字段去除字段开头和结尾的空格在我们的商业代码列表中时,则取列 duration_seconds 中字段的值。否则,使用零作为值*。
F.when() 函数的蓝图如下。如果我们有多个条件,我们可以链式调用多个 when(),如果我们可以接受在所有测试都不为正时出现 null 值,则可以省略 otherwise():
(
F.when([BOOLEAN TEST], [RESULT IF TRUE])
.when([ANOTHER BOOLEAN TEST], [RESULT IF TRUE])
.otherwise([DEFAULT RESULT, WILL DEFAULT TO null IF OMITTED])
)
我们现在有一个可以使用的列。虽然我们可以在分组之前使用 withColumn() 创建这个列,但让我们更进一步,直接在 agg() 子句中使用我们的定义。下面的列表就是这样做的,同时,它也给出了我们的答案!
列表 5.14 将我们的新列用于 agg() 来计算最终答案
answer = (
full_log.groupby("LogIdentifierID")
.agg(
F.sum( ❶
F.when( ❶
F.trim(F.col("ProgramClassCD")).isin( ❶
["COM", "PRC", "PGI", "PRO", "LOC", "SPO", "MER", "SOL"]❶
), ❶
F.col("duration_seconds"), ❶
).otherwise(0) ❶
).alias("duration_commercial"), ❶
F.sum("duration_seconds").alias("duration_total"),
)
.withColumn(
"commercial_ratio", F.col(
"duration_commercial") / F.col("duration_total")
)
)
answer.orderBy("commercial_ratio", ascending=False).show(1000, False)
# +---------------+-------------------+--------------+---------------------+
# |LogIdentifierID|duration_commercial|duration_total|commercial_ratio |
# +---------------+-------------------+--------------+---------------------+
# |HPITV |403 |403 |1.0 |
# |TLNSP |234455 |234455 |1.0 |
# |MSET |101670 |101670 |1.0 |
# |TELENO |545255 |545255 |1.0 |
# |CIMT |19935 |19935 |1.0 |
# |TANG |271468 |271468 |1.0 |
# |INVST |623057 |633659 |0.9832686034602207 |
# [...]
# |OTN3 |0 |2678400 |0.0 |
# |PENT |0 |2678400 |0.0 |
# |ATN14 |0 |2678400 |0.0 |
# |ATN11 |0 |2678400 |0.0 |
# |ZOOM |0 |2678400 |0.0 |
# |EURO |0 |null |null |
# |NINOS |0 |null |null |
# +---------------+-------------------+--------------+---------------------+
❶ 列就是列:我们的 F.when() 函数返回一个可以用于 F.sum() 的列对象。
等一下——一些频道的商业比率为 1.0;是否有些频道只播放广告?如果我们看总时长,我们可以看到一些频道播出的内容不多。由于一天有 86,400 秒(24 × 60 × 60),我们看到 HPITV 在我们的数据框中只有 403 秒的节目。我现在不太关心这一点,但我们始终有选择 filter() 我们的方式出去并删除播出很少的频道(见第二章)。尽管如此,我们达成了目标:我们确定了广告最多的频道。我们以处理那些 null 值的最后任务结束这一章。
5.3 处理空值:删除和填充
null 值表示值的缺失。我认为这是一个很好的矛盾:没有值的值?抛开哲学不谈,我们的结果集中有一些 null,我希望它们消失。本节介绍了在数据框中处理 null 值的两种最简单的方法:你可以删除包含它们的记录,或者用值填充 null。在本节中,我们探讨了这两种选项,看看哪一种最适合我们的分析。
5.3.1 热火朝天般地删除:使用 dropna() 删除包含空值的记录
我们的第一种选择是简单地忽略具有null值的记录。在本节中,我将介绍使用dropna()方法根据null值的存在来删除记录的不同方式。
dropna()函数非常容易使用。这个数据帧方法接受三个参数:
-
how可以取any或all的值。如果选择any,PySpark 将删除至少有一个字段是null的记录。在all的情况下,只有所有字段都是null的记录将被删除。默认情况下,PySpark 将采用any模式。 -
thresh接受一个整数值。如果设置(默认值为None),PySpark 将忽略how参数,并且只删除小于thresh个非null值的记录。 -
subset将接受一个可选的列列表,dropna()将使用它来做出决定。
在我们的情况下,我们只想保留具有commercial_ratio且非null的记录。我们只需将我们的列传递给subset参数,就像下一个列表中那样。
列表 5.15 仅删除具有null commercial_ratio值的记录
answer_no_null = answer.dropna(subset=["commercial_ratio"])
answer_no_null.orderBy(
"commercial_ratio", ascending=False).show(1000, False)
# +---------------+-------------------+--------------+---------------------+
# |LogIdentifierID|duration_commercial|duration_total|commercial_ratio |
# +---------------+-------------------+--------------+---------------------+
# |HPITV |403 |403 |1.0 |
# |TLNSP |234455 |234455 |1.0 |
# |MSET |101670 |101670 |1.0 |
# |TELENO |545255 |545255 |1.0 |
# |CIMT |19935 |19935 |1.0 |
# |TANG |271468 |271468 |1.0 |
# |INVST |623057 |633659 |0.9832686034602207 |
# [...]
# |OTN3 |0 |2678400 |0.0 |
# |PENT |0 |2678400 |0.0 |
# |ATN14 |0 |2678400 |0.0 |
# |ATN11 |0 |2678400 |0.0 |
# |ZOOM |0 |2678400 |0.0 |
# +---------------+-------------------+--------------+---------------------+
print(answer_no_null.count()) # 322
这个选项是合法的,但它会从我们的数据帧中删除一些记录。如果我们想保留所有内容怎么办?下一节将介绍如何用其他东西替换null值。
5.3.2 使用fillna()随心所欲地填充值
dropna()的阴对应于提供默认值给null值。本节介绍了fillna()方法来替换null值。
fillna()甚至比dropna()更简单。这个数据帧方法接受两个参数:
-
value是一个 Python 整型、浮点型、字符串或布尔型。PySpark 只会填充兼容的列;例如,如果我们使用fillna("zero"),由于commercial_ratio是一个双精度浮点数,它将不会被填充。 -
与我们在
dropna()中遇到的相同的subset参数。我们可以限制填充的范围,仅限于我们想要的列。
具体来说,我们任何数值列中的null值意味着该值应该是零,所以下一个列表将使用零填充null值。
列表 5.16 使用fillna()方法用零填充我们的数值记录
answer_no_null = answer.fillna(0)
answer_no_null.orderBy(
"commercial_ratio", ascending=False).show(1000, False)
# +---------------+-------------------+--------------+---------------------+
# |LogIdentifierID|duration_commercial|duration_total|commercial_ratio |
# +---------------+-------------------+--------------+---------------------+
# |HPITV |403 |403 |1.0 |
# |TLNSP |234455 |234455 |1.0 |
# |MSET |101670 |101670 |1.0 |
# |TELENO |545255 |545255 |1.0 |
# |CIMT |19935 |19935 |1.0 |
# |TANG |271468 |271468 |1.0 |
# |INVST |623057 |633659 |0.9832686034602207 |
# [...]
# |OTN3 |0 |2678400 |0.0 |
# |PENT |0 |2678400 |0.0 |
# |ATN14 |0 |2678400 |0.0 |
# |ATN11 |0 |2678400 |0.0 |
# |ZOOM |0 |2678400 |0.0 |
# +---------------+-------------------+--------------+---------------------+
print(answer_no_null.count()) # 324 ❶
❶ 我们有两个额外的记录,其中 5.15 被删除了。
字典的返回值
你也可以将一个字典传递给fillna()方法,其中列名作为键,值作为字典值。如果我们使用这种方法进行填充,代码将如下所示:
Filling our numerical records with zero using the fillna() method and a dict
answer_no_null = answer.fillna(
{"duration_commercial": 0, "duration_total": 0, "commercial_ratio": 0}
)
就像agg()一样,我更喜欢避免使用字典方法,因为我觉得它不太易读。在这种情况下,你可以链式调用多个fillna()以获得更好的可读性并达到相同的结果。
我们的程序现在没有null值,我们有一个完整的频道列表及其相关的商业节目比率。我认为现在是时候全面总结我们的程序,并总结我们在本章中涵盖的内容。
5.4 我们的问题是什么?我们的端到端程序
在本章的开头,我们为自己设定了一个锚定问题,以开始探索数据并揭示一些见解。在整个章节中,我们收集了一个包含识别商业节目所需的相关信息的连贯数据集,并根据它们的节目中有多少是商业的来对频道进行排名。在列表 5.17 中,我将本章中引入的所有相关代码块组合成一个可以spark-submit的单个程序。代码也可在本书的仓库中找到,位于code/Ch05/commercials.py目录下。章节末尾的练习也使用了此代码。
不计算数据摄取、注释或 docstring,我们的代码是一百行左右的代码。我们可以玩代码高尔夫(尝试尽可能减少字符数),但我认为我们在简洁性和易读性之间找到了一个很好的平衡。再次强调,我们没有过多关注 PySpark 的分布式特性。相反,我们非常具体地看待我们的问题,并通过 PySpark 强大的数据框抽象和丰富的函数生态系统将其转化为代码。
本章是本书第一部分的最后一章。你现在已经熟悉了 PySpark 生态系统以及如何使用其主要数据结构——数据框,来摄取和处理两种非常常见的数据源,文本和表格。你了解可以应用于数据框和列的多种方法和函数,并且可以将它们应用于自己的数据问题。你还可以利用 PySpark docstrings 提供的文档,直接从 PySpark shell 中访问。
您可以从本书的数据操作部分获得更多内容。因此,我建议花时间回顾 PySpark 在线 API,并熟练掌握其结构的导航。现在您已经对数据模型和如何构建简单的数据操作程序有了坚实的理解,向您的 PySpark 工具箱中添加新功能将变得容易。
书的第二部分在很大程度上建立在您已经学到的知识之上:
-
我们深入 PySpark 的数据模型,寻找改进代码的机会。我们还将探讨 PySpark 的列类型,它们如何与 Python 的类型桥接,以及如何使用它们来提高代码的可靠性。
-
通过摄取层次数据,我们超越了二维数据框,使用复杂的数据类型,如数组、映射和结构。
-
我们探讨 PySpark 如何使 SQL 现代化,SQL 是一种用于表格数据操作的有影响力的语言,以及如何在单个程序中结合 SQL 和 Python。
-
我们探讨将纯 Python 代码提升到在 Spark 分布式环境中运行。我们正式引入一个较低级别的结构,即弹性分布式数据集(RDD)及其行主模型。我们还探讨了 UDFs 和 pandas UDFs 作为增强数据框功能的一种方式。
列表 5.17 我们完整的程序,按商业节目比例降序排列频道
import os
import pyspark.sql.functions as F
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName(
"Getting the Canadian TV channels with the highest/lowest proportion of commercials."
).getOrCreate()
spark.sparkContext.setLogLevel("WARN")
# Reading all the relevant data sources
DIRECTORY = "./data/broadcast_logs"
logs = spark.read.csv(
os.path.join(DIRECTORY, "BroadcastLogs_2018_Q3_M8.CSV"),
sep="|",
header=True,
inferSchema=True,
)
log_identifier = spark.read.csv(
os.path.join(DIRECTORY, "ReferenceTables/LogIdentifier.csv"),
sep="|",
header=True,
inferSchema=True,
)
cd_category = spark.read.csv(
os.path.join(DIRECTORY, "ReferenceTables/CD_Category.csv"),
sep="|",
header=True,
inferSchema=True,
).select(
"CategoryID",
"CategoryCD",
F.col("EnglishDescription").alias("Category_Description"),
)
cd_program_class = spark.read.csv(
"./data/broadcast_logs/ReferenceTables/CD_ProgramClass.csv",
sep="|",
header=True,
inferSchema=True,
).select(
"ProgramClassID",
"ProgramClassCD",
F.col("EnglishDescription").alias("ProgramClass_Description"),
)
# Data processing
logs = logs.drop("BroadcastLogID", "SequenceNO")
logs = logs.withColumn(
"duration_seconds",
(
F.col("Duration").substr(1, 2).cast("int") * 60 * 60
+ F.col("Duration").substr(4, 2).cast("int") * 60
+ F.col("Duration").substr(7, 2).cast("int")
),
)
log_identifier = log_identifier.where(F.col("PrimaryFG") == 1)
logs_and_channels = logs.join(log_identifier, "LogServiceID")
full_log = logs_and_channels.join(cd_category, "CategoryID", how="left").join(
cd_program_class, "ProgramClassID", how="left"
)
answer = (
full_log.groupby("LogIdentifierID")
.agg(
F.sum(
F.when(
F.trim(F.col("ProgramClassCD")).isin(
["COM", "PRC", "PGI", "PRO", "LOC", "SPO", "MER", "SOL"]
),
F.col("duration_seconds"),
).otherwise(0)
).alias("duration_commercial"),
F.sum("duration_seconds").alias("duration_total"),
)
.withColumn(
"commercial_ratio", F.col("duration_commercial") / F.col("duration_total")
)
.fillna(0)
)
answer.orderBy("commercial_ratio", ascending=False).show(1000, False)
摘要
-
PySpark 实现了七种连接功能,使用常见的“是什么?”,“基于什么?”和“如何?”问题:交叉,内部,左,右,全,左半和左反。选择适当的连接方法取决于如何处理解决谓词的记录以及未解决谓词的记录。
-
PySpark 在连接数据帧时保留 lineage 信息。利用这些信息,我们可以避免列命名冲突。
-
你可以使用数据帧上的
groupby()方法对相似值进行分组。该方法接受多个列对象或表示列的字符串,并返回一个GroupedData对象。 -
GroupedData对象是过渡结构。它们包含两种类型的列:键列,这是你“按此分组”的列,以及组单元格,它是一个包含所有其他列的容器。返回数据帧的最常见方式是通过agg()函数或通过直接聚合方法(如count()或min())对列中的值进行汇总。 -
你可以使用
dropna()删除包含null值的记录,或者使用fillna()方法用另一个值替换它们。
额外练习
练习 5.4
编写 PySpark 代码,在不使用左反连接的情况下返回以下代码块的结果:
left.join(right, how="left_anti", on="my_column").select("my_column").distinct()
练习 5.5
使用 data/broadcast_logs/Call_Signs.csv(注意:这里的分隔符是逗号,而不是管道!)中的数据,将 Undertaking_Name 添加到我们的最终表中,以显示频道的可读描述。
练习 5.6
加拿大政府要求你进行分析,但他们希望对 PRC 进行不同的加权。他们希望每个 PRC 第二被考虑为 0.75 个商业秒。修改程序以考虑这一变化。
练习 5.7
在 commercials.py 返回的数据帧上,根据它们的 commercial_ratio 返回每个桶中的频道数。(提示:查看 round 的文档了解如何四舍五入值。)
| commercial_ratio | number_of_channels |
|---|---|
| 1.0 | |
| 0.9 | |
| 0.8 | |
| ... | |
| 0.1 | |
| 0.0 |
第二部分. 精通:将你的想法转化为代码
在掌握两种不同类型的程序之后,是时候拓展我们的视野了。第二部分是关于多样化你的工具集,这样就没有任何数据集会对你保密。
第六章打破了行和列的模具,走向多维。通过 JSON 数据,我们构建了包含数据框的数据框。这个工具将 Spark 数据框的通用性推进到了全新的境界。
第七章介绍了 PySpark 和 SQL。它们一起解锁了代码的新层次的表达性和简洁性,让你能够以创纪录的速度扩展 SQL 工作流程,并提供一种新的方式来推理你的分析。
第八章和第九章涵盖了使用 PySpark 代码全面使用 Python。从弹性分布式数据集,一种灵活可扩展的数据结构,到使用 Python 和 pandas 的两种 UDF 风味,你将充满信心地提升你的能力。
第十章通过引入窗口函数为你提供数据的新视角。窗口函数是那些使有序数据更容易处理的事情之一,你会 wonder how anyone can do without them。
最后,第十一章从所有的编码中暂时抽身,来反思 Spark 的执行模型。你将通过 Spark UI 深入了解,更好地理解你的指令是如何被引擎处理的。
在第二部分的结尾,你应该能够从数据到洞察力绘制一条清晰的路径,拥有一个完整的工具箱来按照你的意愿弯曲你的数据。
6 多维数据帧:使用 PySpark 处理 JSON 数据
本章涵盖了
-
在 JSON 文档和 Python 数据结构之间建立联系
-
在数据帧中摄取 JSON 数据
-
通过复杂列类型在数据帧中表示层次化数据
-
使用文档/层次化数据模型减少重复和对外部表的依赖
-
从复杂数据类型创建和提取数据
到目前为止,我们已经使用 PySpark 的数据帧来处理文本数据(第二章和第三章)和表格数据(第四章和第五章)。这两种数据格式相当不同,但它们无缝地融入了数据帧结构。我相信我们准备好将抽象进一步推进,通过在数据帧中表示层次化信息。想象一下:列中的列,这是终极灵活性。
本章介绍了如何使用 PySpark 数据帧来摄取和处理层次化 JSON 数据。JSON(JavaScript 对象表示法)数据迅速成为客户端(如您的浏览器)和服务器之间交换信息的占主导地位的数据格式。在大数据背景下,与表格序列化格式(如 CSV)相比,JSON 允许您存储比纯标量值更丰富的数据类型。我首先介绍了 JSON 格式以及我们如何将其与 Python 数据结构进行比较。我介绍了数据帧中可用的三种容器结构:数组、映射和结构,以及它们如何用于表示更丰富的数据布局。我涵盖了如何使用它们来表示多维数据以及结构如何表示层次化信息。最后,我将这些信息封装到一个模式中,这是一个非常有用的结构,用于记录数据帧中的内容。
6.1 读取 JSON 数据:为模式灾难做准备
在 PySpark 中,每个数据处理作业都以数据摄取开始;JSON 数据也不例外。本节解释了 JSON 是什么,如何使用 PySpark 的专用 JSON 读取器,以及 JSON 文件如何在数据帧中表示。在此之后,您将能够对您的 JSON 数据进行推理并将其映射到 PySpark 数据类型。
对于本章,我们使用 TV Maze 中关于电视剧《硅谷》的信息的 JSON 导出。我在本书的存储库中上传了数据(在 ./data/shows 下),但您也可以直接从 TV Maze API(在线可用:mng.bz/g4oR)下载它。下一个列表展示了简化版的 JSON 文档;主要部分已编号,我将逐一介绍它们。
列表 6.1 JSON 对象的简化示例
{ ❶
"id": 143, ❷
"name": "Silicon Valley",
"type": "Scripted",
"language": "English",
"genres": [ ❸
"Comedy"
],
"network": { ❹
"id": 8,
"name": "HBO",
"country": {
"name": "United States",
"code": "US",
"timezone": "America/New_York"
}
},
"_embedded": {
"episodes": [ ❺
{
"id": 10897,
"name": "Minimum Viable Product",
"season": 1,
"number": 1,
},
{
"id": 10898,
"name": "The Cap Table",
"season": 1,
"number": 2,
}
]
}
}
❶ 在顶层,JSON 对象看起来像 Python 字典。两者都使用括号来界定对象边界。
❷ JSON 数据被编码成键值对,就像在字典中一样。JSON 键必须是字符串。
❸ JSON 数组可以包含多个值(这里我们有一个单个字符串)。
❹ 对象也可以是值;您可以通过这种方式在对象内部嵌套对象。
❺ 我们的内容是包含在数组中的对象。
当我看到 JSON 数据格式时,我的第一个想法是它看起来很像 Python 字典。我仍然认为这是一种在心理上映射 JSON 文档的有效方式,下一节将解释我们如何利用我们的 Python 知识快速内化 JSON。
6.1.1 从小做起:将 JSON 数据视为有限的 Python 字典
在本节中,我们简要介绍 JSON 格式以及我们如何使用 Python 数据结构构建数据的心智模型。在此之后,我们通过在 Python 中解析一个小 JSON 消息来验证我们的直觉。就像 CSV 一样,将您的原始数据转换为 PySpark 结构有助于了解如何映射您的数据转换;您本能地知道字段映射的位置,并且可以更快地开始编码。
JSON 数据是一种长期存在的数据交换格式,因其可读性和相对较小的尺寸而变得非常流行。JSON 代表 JavaScript Object Notation,考虑到每个 JSON 文件都可以被视为一个 JavaScript 对象,这个名字非常合适。官方 JSON 网站 (json.org) 包含对 JSON 数据格式的更正式介绍。由于我们专注于 Python 编程语言,我将通过 Python 数据结构系列来探讨 JSON 规范。
观察列表 6.1 和图 6.1,我们注意到我们的文档以一个开括号 { 开始。每个有效的 JSON 文档都是一个 对象;¹ JavaScript 使用括号作为对象分隔符。在 Python 中,就 JSON 而言,对象的直接等价物是字典。就像字典一样,JSON 对象有键和值。JSON 文档中的顶层对象被称为 根对象 或元素。

图 6.1 一个简单的 JSON 对象,说明了其主要组件:根对象、键和值。对象使用括号分隔符,数组/列表使用方括号分隔符。JSON 使用引号表示字符串值,但不表示数值。
JSON 对象——或 Python 字典——都有键和值。根据 JSON 规范,JSON 对象的键必须是一个字符串。Python 字典没有这个限制,但我们没有问题地进行了适配。
最后,JSON 对象的值可以表示几种数据类型:
-
字符串(使用双引号字符
"作为引号字符)。 -
数字(JavaScript 不区分整数和浮点数)。
-
布尔值 (
true或false,与 Python 中的大小写不同)。 -
null,类似于 Python 的None。 -
数组,由方括号
[分隔。它们类似于 Python 列表。 -
对象,由花括号
{分隔。
如果你切换 JSON 和 Python 术语(数组到列表,对象到字典),在 Python 中处理 JSON 将变得轻而易举。为了完成我们的类比,我在下一个列表中读取:使用 Python 标准库中可用的 json 模块读取简单的 JSON 对象。
列表 6.2 将简单的 JSON 文档读取为 Python 字典
import json ❶
sample_json = """{
"id": 143,
"name": "Silicon Valley",
"type": "Scripted",
"language": "English",
"genres": [
"Comedy"
],
"network": {
"id": 8,
"name": "HBO",
"country": {
"name": "United States",
"code": "US",
"timezone": "America/New_York"
}
}
}"""
document = json.loads(sample_json)
print(document) ❷
# {'id': 143,
# 'name': 'Silicon Valley',
# 'type': 'Scripted',
# 'language': 'English',
# 'genres': ['Comedy'],
# 'network': {'id': 8,
# 'name': 'HBO',
# 'country': {'name': 'United States',
# 'code': 'US',
# 'timezone': 'America/New_York'}}}
type(document)
# dict ❸
❶ 我导入了 Python 标准库中可用的 json 模块。
❷ 我们加载的文档看起来像 Python 字典,具有字符串键。Python 识别出 143 是一个整数,并将其解析为这样的数字。
❸ 我们加载的文档类型为 dict。
在本节中,我介绍了如何将 JSON 对象视为一个有限的 Python 字典。键始终是字符串,值可以是数值、布尔值、字符串或 null 值。你还可以有元素数组或对象作为值,这使数据可以嵌套和分层组织。现在我们了解了它在 Python 中的工作方式,接下来的几节将展示如何使用 PySpark 读取 JSON 数据,并介绍我们迄今为止遇到的最复杂的数据框模式。很快,你将征服模式末日!
6.1.2 扩展范围:在 PySpark 中读取 JSON 数据
本节介绍了使用专门的 JSON SparkReader 对象读取 JSON 数据。我们讨论了读者最常见和最有用的参数。有了这些信息,你将能够将 JSON 文件读取到数据框中。
在本节中,我们将使用本章开头介绍的数据。我们一次性读取 JSON 文档,使用专门的 SparkReader 对象。结果如下所示。
列表 6.3 使用 JSON 专门的 SparkReader 导入 JSON 文档
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
shows = spark.read.json("./data/shows/shows-silicon-valley.json") ❶
shows.count()
# 1 ❷
❶ 通过在 spark.read 上调用 json 方法,可以访问专门的 SparkReader 对象,就像 CSV 或文本数据一样。
❷ 我导入的文档只包含一条记录。
在回顾代码时,有两个元素浮现在脑海中。首先,我们没有使用任何可选参数。与 CSV 数据不同,JSON 数据不需要担心记录分隔符或推断数据类型(JSON 强制使用字符串分隔符,因此值 03843 是一个数字,而 "03843" 是一个字符串),这大大减少了需要调整读取过程的需求。有许多选项可以放松 JSON 规范(例如,允许字符串使用单引号,注释或不带引号的键)。如果你的 JSON 文档符合规范,并且你没有特殊需求来处理 JSON 提供的数据类型之外的一些值,那么默认的读取器将工作得很好。当数据不够完美时,有选项可以调整读取器以满足你的需求,随时准备提供帮助。我将在需要时介绍方法选项,但如果你迫不及待,可以阅读 DataFrameReader 对象的 json 方法的文档字符串。
我们数据摄入的第二个奇怪之处在于我们只有一个记录。如果我们花点时间反思这一点,这是有道理的:TVMaze 在我们的查询结果中提供了一个文档。在 PySpark 世界中,读取 JSON 遵循以下规则:一个 JSON 文档,一行,一个记录。这意味着如果你想在同一个文档中有多条 JSON 记录,你需要每行一个文档,文档内没有换行符。如果你对 JSON Lines 文档格式(jsonlines.org/)感兴趣,它有一个更正式的定义。通过打开我们在列表 6.3 中读取的 JSON 文档(一个常规文本编辑器就可以做到),你会发现文件中只有一行。
如果你想要跨多个文件摄入多个文档,你需要将multiLine(注意大写的 L!)参数设置为 true。这将改变 JSON 读取规则为以下形式:一个 JSON 文档,一个文件,一个记录。有了这个,你可以使用 glob 模式(使用*来指代多个文件),如第三章所示,或者将包含与读取器相同模式的 JSON 文件的目录作为参数传递。我在data/shows目录中提供了两个额外的示例(绝命毒师和黄金女孩,以覆盖广泛的范围)。在下一个列表中,我一次性读取了三个 JSON 文档,并展示了确实有三个记录。
列表 6.4 使用multiLine选项读取多个 JSON 文档
three_shows = spark.read.json("./data/shows/shows-*.json", multiLine=True)
three_shows.count()
# 3
assert three_shows.count() == 3
本节介绍了如何在 PySpark 中导入简单的 JSON 文档以及我们如何调整专门的 JSON 读取器以适应常见用例。在下一节中,我们将重点关注复杂数据类型如何帮助我们导航数据框架中的层次数据。
6.2 使用复杂数据类型打破第二维度
本节将 JSON 数据模型应用于 PySpark 数据框架的上下文中。我深入探讨了 PySpark 的复杂数据类型:数组和映射。我将 PySpark 的列式模型转换为层次数据模型。本节结束时,你将了解如何在 PySpark 数据框架中表示、访问和处理容器类型。这将在处理层次或面向对象的数据时非常有用,例如我们正在处理的shows数据。
PySpark 在数据框架中使用复杂类型的能力使其具有非凡的灵活性。虽然你仍然有表格抽象来工作,但你的单元格被超级充电,因为它们可以包含多个值。这就像从 2D 到 3D,甚至更高级!
在 Python 的意义上,复杂 类型并不复杂:Python 使用复杂数据来表示图像、地图、视频文件等,而 Spark 使用这个术语来指代 包含其他类型的数据类型。因此,我也使用术语 容器 或 复合 类型作为复杂类型的同义词。我发现它们更不容易产生歧义;容器类型列包含其他类型的值。在 Python 中,主要的复杂类型是列表、元组和字典。在 PySpark 中,我们有数组、映射和结构体。有了这些,你将能够表达无限多的数据布局。
不遗漏任何类型:如果你想深入了解标量数据类型
在第一章到第三章中,我们主要处理 标量 数据,它包含单个值。这些类型无缝映射到 Python 类型;例如,PySpark 的 string 类型列映射到 Python 字符串。因为 Spark 借用了 Java/Scala 类型约定,所以有一些特殊性,我会在遇到时介绍。
我想我已经说得够多了:看吧,下一列表揭示了我们的数据框模式!
列表 6.5 嵌套结构,具有更深层次的缩进
shows.printSchema()
# root ❶
# |-- _embedded: struct (nullable = true) ❷
# | |-- episodes: array (nullable = true)
# | | |-- element: struct (containsNull = true)
# | | | |-- _links: struct (nullable = true)
# | | | | |-- self: struct (nullable = true)
# | | | | | |-- href: string (nullable = true)
# | | | |-- airdate: string (nullable = true)
# | | | |-- airstamp: string (nullable = true)
# | | | |-- airtime: string (nullable = true)
# | | | |-- id: long (nullable = true)
# | | | |-- image: struct (nullable = true)
# | | | | |-- medium: string (nullable = true)
# | | | | |-- original: string (nullable = true)
# | | | |-- name: string (nullable = true)
# | | | |-- number: long (nullable = true)
# | | | |-- runtime: long (nullable = true)
# | | | |-- season: long (nullable = true)
# | | | |-- summary: string (nullable = true)
# | | | |-- url: string (nullable = true)
# |-- _links: struct (nullable = true)
# | |-- previousepisode: struct (nullable = true)
# | | |-- href: string (nullable = true)
# | |-- self: struct (nullable = true)
# | | |-- href: string (nullable = true)
# |-- externals: struct (nullable = true)
# | |-- imdb: string (nullable = true)
# | |-- thetvdb: long (nullable = true)
# | |-- tvrage: long (nullable = true)
# |-- genres: array (nullable = true)
# | |-- element: string (containsNull = true)
# |-- id: long (nullable = true)
# [and more columns...]
❶ 就像 JSON 文档一样,我们数据框模式的最顶层元素被称为根。
❷ 复杂列在数据框模式中引入了新的嵌套层级。
我不得不截断模式,以便我们可以关注这里的重要点:模式中的 层次结构。PySpark 解析了每个顶层键——从根对象中来的键——并将它们解析为列(参见下一列表中的顶层列)。当一个列具有标量值时,类型是根据我们在 6.1.1 节中看到的 JSON 规范推断的。
列表 6.6 打印 shows 数据框的列
print(shows.columns)
# ['_embedded', '_links', 'externals', 'genres', 'id', 'image',
# 'language', 'name', 'network', 'officialSite', 'premiered',
# 'rating', 'runtime', 'schedule', 'status', 'summary', 'type',
# 'updated', 'url', 'webChannel', 'weight']
在本节中,我简要介绍了我们摄取的 JSON 文档的模式。下一节将介绍 Spark 提供的两个复杂列类型,首先是数组,然后是映射。
6.2.1 当你有多个值时:数组
在本节中,我介绍了 PySpark 中最简单的容器类型:数组。我解释了数组最常用于何处,以及创建、操作和从数组列中提取数据的主要方法。
在 6.1.1 节中,我将 JSON 数组松散地等同于 Python 列表。在 PySpark 世界中,情况也是如此,但有一个重要的区别:PySpark 数组是 相同类型值 的容器。这种精确性对 PySpark 如何处理 JSON 文档以及更一般的嵌套结构有重要影响,所以我将更详细地解释这一点。
在列表 6.5 中,genres 数组指向一个 element 项目,其类型为 string(我复制了相关部分)。像数据框中的任何其他类型一样,我们需要为任何复杂类型提供完整的类型描述,包括数组。由于数组可以包含的内容灵活性降低,我们更好地掌握了列中的数据,并可以避免难以追踪的错误。我们将使用 Array[element] 符号来引用数组列(例如,Array[string] 表示包含字符串数组的列):
|-- genres: array (nullable = true)
| |-- element: string (containsNull = true)
警告:如果你尝试使用具有多个类型的数组类型列进行读取,PySpark 不会引发错误。相反,它将简单地默认为最低公共分母,通常是字符串。这样,你不会丢失任何数据,但如果你期望的代码是另一种类型的数组,你会在以后得到一个惊喜。
为了稍微操作一下数组,我选择 shows 数据框的一个子集,这样就不会在这个庞大的模式中失去焦点。在下一个列表中,我选择了 name 和 genres 列,并显示了记录。不幸的是,硅谷 是一个单类型节目,所以我们的数组对我来说有点过于基础了。让我们让它变得更有趣一些。
列表 6.7 选择 name 和 genres 列
array_subset = shows.select("name", "genres")
array_subset.show(1, False)
# +--------------+--------+
# |name |genres |
# +--------------+--------+
# |Silicon Valley|[Comedy]|
# +--------------+--------+
从概念上讲,我们的 genres 列可以被视为包含每个记录中元素的列表。在第二章中,我们有过类似的情景,将我们的行拆分成单词。从视觉上看,它看起来像图 6.2:我们的 Comedy 值位于列表类型的结构中,在列内。

图 6.2 array_subset 数据框的视觉表示。genres 列的类型为 Array[string],这意味着它包含任何数量的字符串值,在一个列表类型的容器中。
要获取数组内的值,我们需要提取它们。PySpark 提供了一种非常 Pythonic 的方式来处理数组,就像它们是列表一样。在列表 6.8 中,我展示了访问我数组中(唯一)元素的主要方法。数组在检索元素时是零索引的,就像 Python 列表一样。与 Python 列表不同,传递一个超出列表内容的索引会返回 null。
列表 6.8 从数组中提取元素
import pyspark.sql.functions as F
array_subset = array_subset.select(
"name",
array_subset.genres[0].alias("dot_and_index"), ❶
F.col("genres")[0].alias("col_and_index"),
array_subset.genres.getItem(0).alias("dot_and_method"), ❷
F.col("genres").getItem(0).alias("col_and_method"),
)
array_subset.show()
# +--------------+-------------+-------------+--------------+--------------+
# | name|dot_and_index|col_and_index|dot_and_method|col_and_method|
# +--------------+-------------+-------------+--------------+--------------+
# |Silicon Valley| Comedy| Comedy| Comedy| Comedy|
# +--------------+-------------+-------------+--------------+--------------+
❶ 使用点符号和通常的方括号,其中包含索引。
❷ 我们可以使用 Column 对象上的 getItem() 方法,而不是方括号语法中的索引。
警告:尽管方括号方法看起来非常 Pythonic,但你不能将其用作切片工具。PySpark 只接受一个整数作为索引,所以 array_subset.genres[0:10] 会失败,并返回一个包含神秘错误信息的 AnalysisException。与第一章呼应,PySpark 是 Spark(Java/Scala)的包装层。这为跨语言提供了一致的 API,但代价是主机语言中并不总是感觉集成;在这里,PySpark 由于不允许切片数组而未能实现 Pythonic。
PySpark 的数组函数——位于 pyspark.sql.functions 模块中——几乎都以前缀 array_ 开头(一些,如列表 6.9 中的 size(),可以应用于多种复杂类型,因此没有前缀)。因此,在 API 文档中一次性查看它们非常容易(见 mng.bz/5Kj1)。接下来,我们使用函数创建一个更强大的数组,并对其进行一些探索。在列表 6.9 中,我执行以下任务:
-
我创建了三个字面量列(使用
lit()创建标量列,然后make_array())来创建一个可能的流派数组。PySpark 不接受 Python 列作为lit()的参数,因此我们必须通过创建单个标量列然后再将它们组合成一个数组来走一条长路。第八章涵盖了可以返回数组列的 UDF。 -
我随后使用函数
array_repeat()创建一列,重复我们在列表 6.8 中提取的Comedy字符串五次。最后,我计算了两列的大小,去重了两个数组,并将它们交集,得到了列表 6.7 中的原始[Comedy]数组。
列表 6.9 在数组列上执行多个操作
array_subset_repeated = array_subset.select(
"name",
F.lit("Comedy").alias("one"),
F.lit("Horror").alias("two"),
F.lit("Drama").alias("three"),
F.col("dot_and_index"),
).select(
"name",
F.array("one", "two", "three").alias("Some_Genres"), ❶
F.array_repeat("dot_and_index", 5).alias("Repeated_Genres"), ❷
)
array_subset_repeated.show(1, False)
# +--------------+-----------------------+----------------------------------------+
# |name |Some_Genres |Repeated_Genres |
# +--------------+-----------------------+----------------------------------------+
# |Silicon Valley|[Comedy, Horror, Drama]|[Comedy, Comedy, Comedy, Comedy, Comedy]|
# +--------------+-----------------------+----------------------------------------+
array_subset_repeated.select(
"name", F.size("Some_Genres"), F.size("Repeated_Genres") ❸
).show()
# +--------------+-----------------+---------------------+
# | name|size(Some_Genres)|size(Repeated_Genres)|
# +--------------+-----------------+---------------------+
# |Silicon Valley| 3| 5|
# +--------------+-----------------+---------------------+
array_subset_repeated.select(
"name",
F.array_distinct("Some_Genres"), ❹
F.array_distinct("Repeated_Genres"), ❹
).show(1, False)
# +--------------+---------------------------+-------------------------------+
# |name |array_distinct(Some_Genres)|array_distinct(Repeated_Genres)|
# +--------------+---------------------------+-------------------------------+
# |Silicon Valley|[Comedy, Horror, Drama] |[Comedy] |
# +--------------+---------------------------+-------------------------------+
array_subset_repeated = array_subset_repeated.select(
"name",
F.array_intersect("Some_Genres", "Repeated_Genres").alias( ❺
"Genres"
),
)
array_subset_repeated.show()
# +--------------+--------+
# | name| Genres|
# +--------------+--------+
# |Silicon Valley|[Comedy]|
# +--------------+--------+
❶ 使用 array() 函数从三个列创建一个数组
❷ 使用 array_repeat() 在数组内重复值五次
❸ 使用 size() 函数计算两个数组中的元素数量
❹ 使用 array_distinct() 方法将重复项从两个数组中移除。由于 Some_Genres 没有任何重复项,数组内的值没有变化。
❺ 通过使用 array_intersect() 交集两个数组,只有两个数组共有的值是 Comedy。
当你想知道数组中一个值的位时,你可以使用 array_position()。这个函数接受两个参数:
-
一个用于执行搜索的数组列
-
在数组中搜索的值
它返回数组列中 值 的基数位置(第一个值是 1,第二个值是 2,等等)。如果值不存在,函数返回 0。我在列表 6.10 中说明了这一点。getItem() 的零基索引(对于 getItem())和 array_position() 的基于一/基数索引(对于 array_position() 返回值)之间的这种不一致可能会令人困惑:我通过调用 getItem() 或 array_position() 函数的返回值中的方括号 index 与 position 来记住这个差异,就像在 PySpark API 中一样。
列表 6.10 使用 array_position() 搜索 Genres 字符串
array_subset_repeated.select(
"Genres", F.array_position("Genres", "Comedy")
).show()
# +--------+------------------------------+
# | Genres|array_position(Genres, Comedy)|
# +--------+------------------------------+
# |[Comedy]| 1|
# +--------+------------------------------+
在本节中,我们使用 shows 数据帧查看数组。我们看到了 PySpark 数组包含相同类型的元素,数组列可以访问一些容器函数(例如,size())以及一些以 array_ 为前缀的数组特定函数。下一节将介绍一个同样有用但使用频率较低的复杂类型,即映射。
6.2.2 映射类型:列中的键和值
本节介绍了映射列类型及其成功应用的地方。映射作为列类型并不常见;读取 JSON 文档不会产生类型为 map 的列,但它们对于表示简单的键值对仍然很有用。
在概念上,映射与 Python 的 类型化 字典非常接近:你拥有键和值,就像在字典中一样,但与数组一样,键需要是同一类型,值也需要是同一类型(键的类型可以不同于值的类型)。值可以是 null,但键不能,就像 Python 字典一样。
创建映射的最简单方法之一是从两个数组类型的列。我们将通过收集有关 name、language、type 和 url 列的一些信息到一个数组,并使用 map_from_arrays() 函数来实现,就像在下一条列表中一样。
列表 6.11 从两个数组创建映射
columns = ["name", "language", "type"]
shows_map = shows.select(
*[F.lit(column) for column in columns],
F.array(*columns).alias("values"),
)
shows_map = shows_map.select(F.array(*columns).alias("keys"), "values")
shows_map.show(1)
# +--------------------+--------------------+
# | keys| values|
# +--------------------+--------------------+
# |[name, language, ...|[Silicon Valley, ...|
# +--------------------+--------------------+
shows_map = shows_map.select(
F.map_from_arrays("keys", "values").alias("mapped")
)
shows_map.printSchema()
# root
# |-- mapped: map (nullable = false)
# | |-- key: string
# | |-- value: string (valueContainsNull = true)
shows_map.show(1, False)
# +---------------------------------------------------------------+
# |mapped |
# +---------------------------------------------------------------+
# |[name -> Silicon Valley, language -> English, type -> Scripted]|
# +---------------------------------------------------------------+
shows_map.select(
F.col("mapped.name"), ❶
F.col("mapped")["name"], ❷
shows_map.mapped["name"], ❸
).show()
# +--------------+--------------+--------------+
# | name | mapped[name]| mapped[name]|
# +--------------+--------------+--------------+
# |Silicon Valley|Silicon Valley|Silicon Valley|
# +--------------+--------------+--------------+
❶ 我们可以使用 col() 函数内的点符号来访问与键对应的值。
❷ 我们也可以在括号内传递键值,就像在 Python 字典中一样。
❸ 就像数组一样,我们可以使用点符号来获取列,然后使用括号来选择正确的键。
就像数组一样,PySpark 在 pyspark.sql.functions 模块下提供了一些函数来处理映射。其中大多数都以 map 为前缀或后缀,例如 map_values()(它从映射值创建一个数组列)或 create_map()(它从作为参数传递的列创建映射,交替使用键和值)。本节末尾和本章末尾的练习提供了更多关于 map 列类型的实践。
如果映射映射到 Python 字典,为什么我们的 JSON 文档没有映射?因为映射的键和值需要分别是同一类型,而 JSON 对象并不强制这样做——我们需要一个更灵活的容器来容纳对象。将顶层名称/值对作为列,就像 PySpark 在列表 6.3 中的 shows 数据帧所做的那样,也更有用。下一节将介绍 struct,它是我们所知的数据帧的骨架。
数组和映射中的空元素
当定义数组或映射时,你还可以传递一个可选参数(对于数组是 containsNull,对于映射是 valueContainsNull),这将指示 PySpark 是否可以接受 null 元素。这与列级别的 nullable 标志不同:在这里,我们可以提到是否 任何元素(或值) 可以是 null。
我在处理数据帧时不会使用不可为空的/无 null 元素的列,但如果你的数据模型需要它,这个选项是可用的。
练习 6.1
假设以下 JSON 文档:
"""{"name": "Sample name",
"keywords": ["PySpark", "Python", "Data"]}"""
使用 spark.read.json 读取后,模式是什么?
练习 6.2
假设以下 JSON 文档:
"""{"name": "Sample name",
"keywords": ["PySpark", 3.2, "Data"]}"""
What is the schema once read by spark.read.json?
6.3 结构:列内的嵌套列
本节介绍 struct 作为列类型,以及它是数据框的基础。我们探讨如何从 struct 的角度来推理我们的数据框,以及如何导航嵌套 struct 的数据框。
struct类似于 JSON 对象,因为在每个对的关键字或名称都是一个字符串,并且每条记录可以是不同类型。如果我们从我们的数据框的列中取一个小子集,就像列表 6.12 中那样,我们看到schedule列包含两个字段:
-
days,一个字符串数组 -
time,一个字符串
列表 6.12 带有字符串数组和字符串的schedule列
shows.select("schedule").printSchema()
# root
# |-- schedule: struct (nullable = true) ❶
# | |-- days: array (nullable = true)
# | | |-- element: string (containsNull = true)
# | |-- time: string (nullable = true)
❶ 调度列是一个 struct。当我们查看从列中产生的嵌套时,我们注意到 struct 包含两个命名字段:days(一个 Array[string])和 time,一个字符串。
Struct 与数组和 map 非常不同,因为它在事先就知道字段的数量和名称。在我们的例子中,schedule struct 列是固定的:我们知道我们数据框的每条记录都将包含那个schedule struct(或者如果我们想严谨一点,是一个null值),并且在该 struct 中,将有一个字符串数组days和一个字符串time。数组和 map 强制执行值的类型,但不是它们的数量或名称。只要为每个字段命名并提供类型,struct 就允许有更多类型的灵活性。
从概念上讲,我发现最容易思考 struct 列类型的方法是想象你的列记录中有一个小数据框。使用列表 6.12 中的例子,我们可以可视化schedule是一个包含两个列(days和time)的数据框,被困在列中。我在图 6.3 中说明了嵌套列的类比。

图 6.3 shows.select("schedule") 数据框。该列是一个包含两个命名字段的 struct:days和time。
Struct 可以嵌套在彼此之中。例如,在列表 6.5(或列表 6.13)中,我们数据框的第一个字段_embedded是一个包含数组字段episodes的 struct。该数组包含包含 struct _links的 struct,其中包含一个包含字符串字段href的 struct。我们在这里面临一个相当复杂的嵌套结构!如果这仍然有点难以想象,请不要担心;下一节将通过导航我们的数据框来解释 struct 的嵌套娃娃排列。
6.3.1 将 struct 视为嵌套列进行导航
本节介绍如何从数据框内部的嵌套 struct 中提取值。PySpark 在处理嵌套列时提供与处理常规列相同的便利性。我涵盖了点号和方括号表示法,并解释了 PySpark 在使用其他复杂结构时如何处理嵌套。我们通过清理无用的嵌套来处理_embedded列。
在动手操作键盘之前,我们将 _embedded 列的结构草拟为一个树形结构,以了解我们正在处理的内容。在下面的列表中,我提供了 printSchema() 命令的输出,我在图 6.4 中绘制了这个输出。
列 6.13 _embedded 列的模式
shows.select(F.col("_embedded")).printSchema()
# root
# |-- _embedded: struct (nullable = true) ❶
# | |-- episodes: array (nullable = true) ❷
# | | |-- element: struct (containsNull = true)
# | | | |-- _links: struct (nullable = true) ❸
# | | | | |-- self: struct (nullable = true)
# | | | | | |-- href: string (nullable = true)
# | | | |-- airdate: string (nullable = true)
# | | | |-- id: long (nullable = true)
# | | | |-- image: struct (nullable = true)
# | | | | |-- medium: string (nullable = true)
# | | | | |-- original: string (nullable = true)
# | | | |-- name: string (nullable = true)
# | | | |-- number: long (nullable = true)
# | | | |-- runtime: long (nullable = true)
# | | | |-- season: long (nullable = true)
# | | | |-- summary: string (nullable = true)
# | | | |-- url: string (nullable = true)
❶ _embedded 包含一个字段:episodes。
❷ episodes 是一个 Array[Struct]。是的,这是可能的。
❸ 每个 episodes 都是数组中的一个记录,包含结构体中所有的命名字段。_links 是一个 Struct[Struct[string]] 字段。PySpark 可以无问题地表示多级嵌套。
首先,我们在图 6.4 中看到 _embedded 是一个无用的结构体,因为它只包含一个字段。在列表 6.14 中,我创建了一个新的顶级列 episodes,它直接引用 _embedded 结构体中的 episodes 字段。为此,我使用了 col 函数和 _embedded.episodes。这与“结构体作为迷你数据帧”的思维模型一致:你可以使用与数据帧相同的符号来引用结构体字段。

图 6.4 我们数据帧 _embedded 字段的模式
列 6.14 将结构体内的字段提升为列
shows_clean = shows.withColumn(
"episodes", F.col("_embedded.episodes")
).drop("_embedded")
shows_clean.printSchema()
# root
# |-- _links: struct (nullable = true)
# | |-- previousepisode: struct (nullable = true)
# | | |-- href: string (nullable = true)
# | |-- self: struct (nullable = true)
# | | |-- href: string (nullable = true)
# |-- externals: struct (nullable = true)
# | |-- imdb: string (nullable = true)
# [...]
# |-- episodes: array (nullable = true) ❶
# | |-- element: struct (containsNull = true)
# | | |-- _links: struct (nullable = true)
# | | | |-- self: struct (nullable = true)
# | | | | |-- href: string (nullable = true)
# | | |-- airdate: string (nullable = true)
# | | |-- airstamp: string (nullable = true)
# | | |-- airtime: string (nullable = true)
# | | |-- id: long (nullable = true)
# | | |-- image: struct (nullable = true)
# | | | |-- medium: string (nullable = true)
# | | | |-- original: string (nullable = true)
# [... rest of schema]
❶ 我们丢失了 _embedded 列,并将结构体(episodes)的字段提升为顶级列。
最后,我们来看如何遍历嵌套在数组中的结构体。在第 6.2.1 节中,我解释了我们可以使用列引用后的括号中的索引来引用数组中的单个元素。那么,如何提取所有 episodes 数组中嵌套的 episodes 的名称呢?
结果表明,PySpark 允许你在数组内部进行遍历,并将结构体的子集以数组形式返回。这最好通过一个例子来说明:在下一个列表中,我从 shows_clean 数据帧中提取了 episodes.name 字段。由于 episodes 是结构体数组,而 name 是字符串字段之一,因此 episodes.name 是一个字符串数组。
列 6.15 在 Array[Struct] 中选择一个字段以创建列
episodes_name = shows_clean.select(F.col("episodes.name")) ❶
episodes_name.printSchema()
# root
# |-- name: array (nullable = true)
# | |-- element: string (containsNull = true)
episodes_name.select(F.explode("name").alias("name")).show(3, False) ❷
# +-------------------------+
# |name |
# +-------------------------+
# |Minimum Viable Product |
# |The Cap Table |
# |Articles of Incorporation|
# +-------------------------+
❶ episodes.name 指的是 episodes 数组元素的名称字段。
❷ 由于 episodes 数组中有多个记录,episodes.name 提取数组中每个记录的名称字段,并将其打包成一个名称数组。我使用展开操作(第二章和第 6.5 节)来清晰地显示这些名称。
本节通过使用与从数据帧中提取列相同的符号,遍历了结构体层次结构。现在我们可以从我们的 JSON 文档中提取任何字段,并确切地知道期望得到什么。下一节将利用我们对复杂数据类型的了解,并在构建模式时使用这些知识。我还简要提到了使用分层模式和复杂数据类型的优缺点。
6.4 构建和使用数据帧模式
在本节中,我将介绍如何使用 PySpark 数据框定义和使用模式。我们以编程方式构建 JSON 对象的模式,并回顾 PySpark 提供的内置类型。能够使用 Python 结构(序列化为 JSON)意味着我们可以像处理任何其他数据结构一样处理我们的模式;我们可以重用我们的数据操作工具包来操作数据框的元数据。通过这样做,我们还解决了 inferSchema 的潜在减速问题,因为我们不需要 Spark 两次读取数据(一次推断模式,一次执行读取)。
在 6.3 节中,我解释了我们可以将结构列视为嵌套在该列中的微型数据框。反之亦然:您可以将数据框视为具有单个结构实体的结构,其中列是“根”结构的顶级字段。在任何 printSchema() 的输出中(为了方便,我在下一列表中重现了列表 6.5 的相关部分),所有顶级字段都与 root 相连。
列表 6.16 shows 数据框模式的示例
shows.printSchema()
# root ❶
# |-- _links: struct (nullable = true)
# | |-- previousepisode: struct (nullable = true)
# | | |-- href: string (nullable = true)
# | |-- self: struct (nullable = true)
# | | |-- href: string (nullable = true)
# |-- externals: struct (nullable = true)
# | |-- imdb: string (nullable = true)
# [... rest of schema]
❶ 所有顶级字段(或列)都是根隐式结构的子结构。
您可以使用两种语法来创建模式。在下一节中,我们将回顾显式、程序化的方法。PySpark 还接受 DDL 风格的模式,这在第七章中讨论,其中我们讨论了 PySpark 和 SQL。
6.4.1 使用 Spark 类型作为模式的基本块
在本节中,我将从零开始介绍模式定义中的列类型。我构建了我们 shows 数据框的模式,并包括一些 PySpark 模式构建能力的编程技巧。我介绍了 PySpark 数据类型以及如何在结构中组装它们来构建您的数据框模式。将数据与模式解耦意味着您可以控制数据在数据框中的表示方式,并提高您数据转换程序的鲁棒性。
我们用于构建模式的 数据类型 位于 pyspark.sql.types 模块中。当处理数据框时,这些数据类型被频繁导入,就像 pyspark.sql.functions 一样,通常使用带限定前缀的 T 进行导入:
import pyspark.sql.types as T
小贴士:与使用大写字母F的函数一样,当导入类型模块时,常见的约定是使用大写字母T。我强烈建议这样做。
在pyspark.sql.types中,有两种主要类型的对象。首先,你有types对象,它代表了一定类型的列。所有这些对象都遵循ValueType()驼峰式语法:例如,长列将由一个LongType()对象表示。大多数标量类型不接收任何参数(除了用于小数点前后有精确数量精度的DecimalType(precision, scale),它用于小数)。复杂类型,如数组和映射,直接在构造函数中接收它们的值类型。例如,字符串数组将是ArrayType(StringType()),而将字符串映射到长整型的映射将是MapType(StringType(), LongType())。
第二,你有字段对象;换句话说,是StructField()。PySpark 提供了一个可以包含任意数量命名字段的StructType();在程序上,这相当于一个接收StructField()列表的StructType()。就这么简单!
StructField()包含两个强制参数以及两个可选参数:
-
字段的
name,以字符串形式传递 -
字段的
dataType,以类型对象形式传递 -
(可选)一个
nullable标志,它确定字段是否可以是null(默认为True)。 -
(可选)一个
metadata字典,它包含任意信息,我们将使用它作为与 ML 管道(在第十三章中)一起工作的列元数据。
提示:如果您提供了一个简化的模式——这意味着您只定义了字段的一个子集——PySpark 将只读取定义的字段。在您只需要从非常宽的数据框中读取子集的列/字段的情况下,您可以节省大量时间!
将所有这些放在一起,shows数据框的summary字符串字段将被编码在一个StructField中,如下所示:
T.StructField("summary", T.StringType())
在列表 6.17 中,我完成了shows数据框的_embedded模式。虽然非常冗长,但我们获得了对数据框结构的深入了解。由于数据框模式是常规的 Python 类,我们可以将它们分配给变量,并从底部向上构建我们的模式。我通常将包含三个或更多字段的struct拆分成自己的变量,这样我的代码就不会像是一个带有括号的整个struct块。
列表 6.17 _embedded字段的模式
import pyspark.sql.types as T
episode_links_schema = T.StructType(
[
T.StructField(
"self", T.StructType([T.StructField("href", T.StringType())]) ❶
)
]
)
episode_image_schema = T.StructType(
[
T.StructField("medium", T.StringType()), ❷
T.StructField("original", T.StringType()), ❷
]
)
episode_schema = T.StructType(
[
T.StructField("_links", episode_links_schema), ❸
T.StructField("airdate", T.DateType()),
T.StructField("airstamp", T.TimestampType()),
T.StructField("airtime", T.StringType()),
T.StructField("id", T.StringType()),
T.StructField("image", episode_image_schema), ❸
T.StructField("name", T.StringType()),
T.StructField("number", T.LongType()),
T.StructField("runtime", T.LongType()),
T.StructField("season", T.LongType()),
T.StructField("summary", T.StringType()),
T.StructField("url", T.StringType()),
]
)
embedded_schema = T.StructType(
[
T.StructField(
"_embedded",
T.StructType(
[
T.StructField(
"episodes", T.ArrayType(episode_schema) ❹
)
]
),
)
]
)
❶ _links字段包含一个包含单个字符串字段的 self struct:href。
❷ 图片字段是一个包含两个字符串字段的struct:medium 和 original。
❸ 由于类型是 Python 对象,我们可以将它们传递给变量并使用它们。使用 episodes_links_schema 和 episode_image_schema 可以使我们的剧集模式看起来更加整洁。
❹ 很明显,我们的_embedded列包含一个名为 episodes 的单个字段,它包含一个剧集数组。使用好的变量名有助于记录我们的意图,而无需依赖注释。
本节介绍了如何自下而上构建模式:您可以使用pyspark.sql.types模块中的类型和字段,并为每一列创建一个字段。当您有一个结构化列时,您以相同的方式处理:创建一个StructType()并分配结构化字段。遵循这些简单的规则,您应该能够构建您需要的任何模式。下一节将利用我们的模式以严格的方式读取 JSON。
6.4.2:在现有严格模式中读取 JSON 文档
本节介绍了如何在强制执行精确模式的情况下读取 JSON 文档。当您希望提高数据管道的鲁棒性时,这非常有用;在程序后期出现错误时,知道您在摄入时缺少几个列比知道您缺少列要好。我回顾了一些方便的实践,当您期望数据符合某种模式时,以及您如何依赖 PySpark 在混乱的 JSON 文档世界中保持清醒。作为额外的好处,当使用模式读取数据时,您可以期待更好的性能,因为inferSchema需要预先读取数据以推断模式。
如果您逐字段分析了 6.17 的列表,您可能会意识到我将airdate定义为日期,将airstamp定义为时间戳。在 6.1.2 节中,我列出了 JSON 文档中可用的类型;其中缺少的是日期和时间戳。幸运的是,PySpark 在这方面支持您:我们可以利用 JSON 读取器的某些选项来读取某些字符串作为日期和时间戳。为此,您需要为您的文档提供一个完整的模式;幸运的是,我们已经有了一个现成的模式。在 6.18 列表中,我再次读取我的 JSON 文档,但这次我提供了一个显式的模式。注意airdate和airstamp类型的变化。我还提供了一个新的参数mode,当设置为FAILFAST时,如果遇到与提供的模式不匹配的格式错误的记录,则会报错。
因为我们只传递了部分模式(embedded_schema),PySpark 将只读取定义的列。在这种情况下,我们只覆盖了_embedded结构化,所以我们只读取数据框的这一部分。这是一种方便的方法,可以在删除未使用的列之前避免读取所有内容。
由于我们的 JSON 文档中的日期和时间戳符合 ISO-8601 标准(日期为yyyy-MM-dd,时间戳为yyyy-MM-ddTHH:mm:ss.SSSXXX),我们不需要自定义 JSON DataFrameReader来自动解析我们的值。如果您遇到非标准日期或时间戳格式,您需要将正确的格式传递给dateFormat或timestampFormat。格式语法可在官方 Spark 文档网站上找到(mng.bz/6ZgD)。
警告:如果您正在使用 Spark 2 的任何版本,dateFormat和timestampFormat遵循的格式是不同的。如果情况如此,请查找java.text.SimpleDateFormat。
列表 6.18:使用显式部分模式读取 JSON 文档
shows_with_schema = spark.read.json(
"./data/shows/shows-silicon-valley.json",
schema=embedded_schema, ❶
mode="FAILFAST", ❷
)
❶ 我们将我们的模式传递给模式参数。由于我们的模式是 JSON 文档的一个子集,我们只读取定义的字段。
❷ 通过选择 FAILFAST 模式,如果我们的模式不兼容,我们的 DataFrameReader 将会崩溃。
成功的读取是有希望的,但既然我想验证我的新日期和时间戳字段,我就深入挖掘,爆炸,并在以下列表中显示字段。
列表 6.19 验证airdate和airstamp字段读取
for column in ["airdate", "airstamp"]:
shows.select(f"_embedded.episodes.{column}").select(
F.explode(column)
).show(5)
# +----------+
# | col|
# +----------+
# |2014-04-06|
# |2014-04-13|
# |2014-04-20|
# |2014-04-27|
# |2014-05-04|
# +----------+
# only showing top 5 rows
# +-------------------+
# | col|
# +-------------------+
# |2014-04-06 22:00:00|
# |2014-04-13 22:00:00|
# |2014-04-20 22:00:00|
# |2014-04-27 22:00:00|
# |2014-05-04 22:00:00|
# +-------------------+
# only showing top 5 rows
这里看起来一切正常。如果模式不匹配会发生什么?即使在FAILFAST模式下,如果模式允许null值,PySpark 也会允许文档中缺少字段。在列表 6.20 中,我污染了我的模式,将两个StringType()改为LongType()。我没有包括整个堆栈跟踪,但结果是直接命中要害的Py4JJavaError:我们的字符串值不是bigint(或long)。不过,你不会知道是哪一个:堆栈跟踪只给出了它尝试解析的内容和预期的内容。
注意Py4J([www.py4j.org/](https://www.py4j.org/))是一个库,它使 Python 程序能够访问 JVM 中的 Java 对象。在 PySpark 的情况下,它帮助弥合了 Pythonic 外观和基于 JVM 的 Spark 之间的差距。在第二章中,我们看到——虽然没有命名——Py4J在行动,因为大多数pyspark.sql.functions调用一个_jvm函数。这使得核心 Spark 函数在 PySpark 中的速度与在 Spark 中一样快,但有时会带来一些奇怪的错误。
列表 6.20 观察具有不兼容模式的 JSON 文档摄入
from py4j.protocol import Py4JJavaError ❶
episode_schema_BAD = T.StructType(
[
T.StructField("_links", episode_links_schema),
T.StructField("airdate", T.DateType()),
T.StructField("airstamp", T.TimestampType()),
T.StructField("airtime", T.StringType()),
T.StructField("id", T.StringType()),
T.StructField("image", episode_image_schema),
T.StructField("name", T.StringType()),
T.StructField("number", T.LongType()),
T.StructField("runtime", T.LongType()),
T.StructField("season", T.LongType()),
T.StructField("summary", T.LongType()), ❷
T.StructField("url", T.LongType()), ❷
]
)
embedded_schema2 = T.StructType(
[
T.StructField(
"_embedded",
T.StructType(
[
T.StructField(
"episodes", T.ArrayType(episode_schema_BAD)
)
]
),
)
]
)
shows_with_schema_wrong = spark.read.json(
"./data/shows/shows-silicon-valley.json",
schema=embedded_schema2,
mode="FAILFAST",
)
try:
shows_with_schema_wrong.show()
except Py4JJavaError:
pass
# Huge Spark ERROR stacktrace, relevant bit:
#
# Caused by: java.lang.RuntimeException: Failed to parse a value for data type
# bigint (current token: VALUE_STRING). ❸
❶ 我导入相关的错误(Py4JJavaError)以便能够捕获和分析它。
❷ 我在我的模式中将两个字段从字符串改为长整型。
❸ PySpark 将给出两个字段的类型,但不会告诉你哪个字段有问题。我想是时候进行一些法医分析了。
这个部分虽然简短,但仍然非常有用。我们看到了如何使用模式信息在数据提供者和数据处理者(我们)之间创建一个严格的合同。在实践中,这种严格的模式断言在数据不是你期望的那样时提供了更好的错误消息,并允许你避免一些错误(或错误的结果)。
FAILFAST:你什么时候想陷入麻烦?
在手动设置详细模式时使用FAILFAST似乎有点偏执。不幸的是,数据很混乱,人们可能会粗心大意,当你依赖数据来做决策时,垃圾输入,垃圾输出。
在我的职业生涯中,我在读取数据时遇到了很多数据完整性问题,以至于我现在坚信你需要尽早诊断这些问题。FAILFAST模式就是一个例子:默认情况下,PySpark 会将格式错误的记录设置为null(PERMISSIVE方法)。在探索时,我认为这是完全合理的。但在我被一个业务利益相关者在最后一刻打电话给我,说“结果很奇怪”之后,我经历了很多不眠之夜,因此我尽可能地减少数据戏剧性。
6.4.3 完整循环:在 JSON 中指定你的架构
本节介绍了一种不同的架构定义方法。与 6.4 节中看到的冗长构造函数不同,我解释了如何使用 JSON 定义你的架构。我们使用 JSON 来定义数据和其架构,实现了完整循环!
StructType 对象有一个方便的 fromJson() 方法(注意这里使用的 camelCase,其中第一个单词的首字母不使用大写,其余则使用大写),它将读取一个 JSON 格式的架构。只要我们知道如何提供适当的 JSON 架构,我们就应该可以顺利地进行。
要了解典型 PySpark 数据框的布局和内容,我们使用我们的 shows_with_schema 数据框和 schema 属性。与 printSchema() 不同,后者将我们的架构打印到标准输出,schema 返回一个以 StructType 为术语的架构的内部表示。幸运的是,StructType 提供了两种方法将内容导出为类似 JSON 的格式:
-
json()将输出一个包含 JSON 格式架构的字符串。 -
jsonValue()将返回架构作为字典。
在列表 6.21 中,我使用标准库中的 pprint 模块,将 shows_with_schema 数据框的架构的一部分进行了美化打印。结果是相当合理的——每个元素都是一个包含四个字段的 JSON 对象:
-
name,一个表示字段名称的字符串 -
type,一个字符串(用于标量值)包含数据类型(例如,"string"或"long")或一个对象(用于复杂数值),表示字段的类型 -
nullable,一个布尔值,表示字段是否可以包含null值 -
包含字段元数据的
metadata对象
列表 6.21 美化打印架构
import pprint ❶
pprint.pprint(
shows_with_schema.select(
F.explode("_embedded.episodes").alias("episode")
)
.select("episode.airtime")
.schema.jsonValue()
)
# {'fields': [{'metadata': {},
# 'name': 'airtime',
# 'nullable': True,
# 'type': 'string'}],
# 'type': 'struct'}
❶ pprint 将 Python 数据结构美化打印到壳中。它使得阅读嵌套字典变得容易得多。
这些是我们传递给 StructField 的相同参数,如 6.4.1 节所示。数组、映射和结构体具有稍微复杂一些的类型表示,以匹配它们稍微复杂一些的数据表示。与其长篇累牍地列举它们,记住你可以通过创建一个虚拟对象并在其上调用 jsonValue() 来直接从你的 REPL 中获得复习。我在以下列表中这样做。
列表 6.22 美化打印虚拟复杂数据类型
pprint.pprint(
T.StructField("array_example", T.ArrayType(T.StringType())).jsonValue()
)
# {'metadata': {},
# 'name': 'array_example',
# 'nullable': True,
# 'type': {'containsNull': True, 'elementType': 'string', 'type': 'array'}}❶
pprint.pprint(
T.StructField(
"map_example", T.MapType(T.StringType(), T.LongType())
).jsonValue()
)
# {'metadata': {},
# 'name': 'map_example',
# 'nullable': True,
# 'type': {'keyType': 'string',
# 'type': 'map',
# 'valueContainsNull': True,
# 'valueType': 'long'}} ❷
pprint.pprint(
T.StructType(
[
T.StructField(
"map_example", T.MapType(T.StringType(), T.LongType())
),
T.StructField("array_example", T.ArrayType(T.StringType())),
]
).jsonValue()
)
# {'fields': [{'metadata': {}, ❸
# 'name': 'map_example',
# 'nullable': True,
# 'type': {'keyType': 'string',
# 'type': 'map',
# 'valueContainsNull': True,
# 'valueType': 'long'}},
# {'metadata': {},
# 'name': 'array_example',
# 'nullable': True,
# 'type': {'containsNull': True,
# 'elementType': 'string',
# 'type': 'array'}}],
# 'type': 'struct'}
❶ 数组类型包含三个元素:containsNull、elementType 和 type(总是数组)。
❷ 映射包含与数组类似元素,但使用 keyType 和 valueType 代替 elementType 和 valueContainsNull(null 键没有意义)。
❸ 结构体包含与构造函数相同的元素:我们有一个结构体类型和一个包含 JSON 对象数组的字段元素。每个 StructField 包含与 6.3 节中看到的构造函数相同的四个字段。
最后,我们可以通过确保我们的 JSON 模式与当前正在使用的模式一致来闭合循环。为此,我们将 shows_with_schema 的模式导出为 JSON 字符串,将其加载为 JSON 对象,然后使用 StructType.fromJson() 方法重新创建模式。正如我们可以在下一个列表中看到的那样,这两个模式是等效的。
列表 6.23 验证 JSON 模式等于数据框模式
other_shows_schema = T.StructType.fromJson(
json.loads(shows_with_schema.schema.json())
)
print(other_shows_schema == shows_with_schema.schema) # True
虽然这看起来像是一个简单的客厅把戏,但能够将你的数据框模式序列化为通用格式,对你的大数据一致性和可预测性之旅大有裨益。你可以对模式进行版本控制,并与他人分享你的期望。此外,由于 JSON 与 Python 字典有很高的亲和力,你可以使用常规的 Python 代码在任意模式定义语言之间进行转换。(第七章包含有关 DDL 的信息,DDL 是描述数据模式的一种方式,SQL 数据库用于定义模式)。PySpark 允许你以一等公民的身份定义和访问你的数据布局。
本节介绍了 PySpark 在数据框内组织数据以及如何通过模式将此信息传达给你。你学习了如何通过编程创建模式,以及如何导入和导出 JSON 格式的模式。下一节将解释为什么在分析大型数据集时,复杂的数据结构是有意义的。
练习 6.3
这个模式有什么问题?
schema = T.StructType([T.StringType(), T.LongType(), T.LongType()])
6.5 将一切整合:使用复杂数据类型减少重复数据
本节采用层次数据模型,并展示了在大数据环境下的优势。我们探讨了它如何帮助减少数据重复,而不依赖于辅助数据框,以及我们如何扩展和收缩复杂类型。
当查看一个新的表格(或数据框)时,我总是问自己,每条记录包含什么内容?另一种处理这个问题的方式是完成以下句子:每条记录包含一个 ____________。
小贴士:数据库人员有时称这为 主键。主键在数据库设计中具有特定的含义。在我的日常生活中,我使用术语 曝光记录:每条记录代表一个单独的曝光点,这意味着记录之间没有重叠。这避免了特定领域的语言(零售:客户或交易;保险:被保险人或保单年度;银行:客户或日终余额)。这不是一个官方术语,但我发现它非常方便,因为它可以在各个领域之间迁移。
在 shows 数据框的情况下,每条记录包含一个 show。当查看字段时,我们可以这样说:“每个 show 有一个(插入字段名称)。”例如,每个 show 有一个 ID、一个名称、一个 URL 等等。关于剧集呢?一个 show 一定有不止一个剧集。到现在为止,我非常确信你已经看到了层次数据模型和复杂的 Spark 列类型是如何优雅地解决这个问题,但让我们回顾一下传统的“行和列”模型对此有何看法。
在二维世界中,如果我们想要有一个包含节目和剧集的表,我们会进行两种情况之一。

图 6.5 通过两个表之间的链接/关系可以表达层次关系。在这里,我们的节目通过show_id键与其剧集相链接。
首先,我们可以有一个shows表与一个episodes表相链接,使用类似于第四章和第五章中遇到的星型模式。从视觉上看,图 6.5 解释了我们将如何使用两个表来分离shows和episodes的层次关系。在这种情况下,我们的数据是规范化的,而且我们没有重复,但获取所有我们想要的信息意味着根据键连接表。
第二,我们可以有一个包含标量记录的连接表(没有嵌套结构)。在我们的情况下,这使我们的暴露单位难以理解。如果我们看看我们需要“标量化”的地图和数组类型,我们有节目、剧集、类型和天数。一个“每个剧集-节目-类型-播出日”的暴露单位表几乎没有意义。在图 6.6 中,我展示了一个只有这四个记录的表作为示例。我们看到show_id和genre的数据重复,这并没有提供额外的信息。此外,有一个连接表意味着记录之间的关系丢失。genre字段是节目的类型还是剧集的类型?

图 6.6 展示了我们的shows层次模型的连接表示。我们见证了数据重复和关系信息的丢失。
自从本书开始,我们所有的数据处理都试图收敛到只有一个表。如果我们想避免数据重复,保留关系信息,并且有一个单一的表,那么我们可以——并且应该!——使用数据帧的复杂列类型。在我们的shows数据帧中
-
每个记录代表一个节目。
-
一档节目包含多个剧集(结构体数组的列)。
-
每个剧集有许多字段(数组内的结构体列)。
-
每个节目可以有多个类型(字符串数组的列)。
-
每个节目都有一个时间表(结构体列)。
-
每个属于节目的时间表可以有多个日期(数组),但只有一个时间(字符串)。
从视觉上看,它看起来像图 6.7。很明显,剧集、类型和时间表属于节目,但我们可以在不重复任何数据的情况下有多个剧集。

图 6.7 展示了shows数据帧的一个示例,展示了层次结构(或面向对象)模型。
一个高效、层次化的数据模型是一件美丽的事物,但有时我们需要走出象牙塔,处理数据。下一节将展示如何根据您的喜好扩展和收缩数组列,以在每个阶段获得您的“金发女郎”数据帧。
6.5.1 达到“恰到好处”的数据帧:分解和收集
本节介绍了如何使用 explode 和 collect 操作从层次结构转换为表格结构,并返回原结构。我们涵盖了将数组或映射拆分为离散记录的方法,以及如何将这些记录重新组合到原始结构中。
在第二章中,我们已经看到了如何使用 explode() 函数将值数组拆分为离散记录。现在,我们将通过将其泛化到映射中重新审视爆炸操作,查看数据帧具有多个列时的行为,并查看 PySpark 提供的不同选项。
在列表 6.24 中,我选取了一小部分列,将 _embedded.episodes 爆炸一次,生成一个包含每个剧集一条记录的数据帧。这与我们在第二章中看到的用例相同,但列更多。PySpark 会复制那些未被爆炸的列中的值。
列表 6.24 将 _embedded.episodes 爆炸成 53 个不同的记录
episodes = shows.select(
"id", F.explode("_embedded.episodes").alias("episodes")
) ❶
episodes.show(5, truncate=70)
# +---+----------------------------------------------------------------------+
# | id| episodes|
# +---+----------------------------------------------------------------------+
# |143|{{{http:/ /api.tvmaze.com/episodes/10897}}, 2014-04-06, 2014-04-07T0...|
# |143|{{{http:/ /api.tvmaze.com/episodes/10898}}, 2014-04-13, 2014-04-14T0...|
# |143|{{{http:/ /api.tvmaze.com/episodes/10899}}, 2014-04-20, 2014-04-21T0...|
# |143|{{{http:/ /api.tvmaze.com/episodes/10900}}, 2014-04-27, 2014-04-28T0...|
# |143|{{{http:/ /api.tvmaze.com/episodes/10901}}, 2014-05-04, 2014-05-05T0...|
# +---+----------------------------------------------------------------------+
# only showing top 5 rows
episodes.count() # 53
❶ 我们爆炸一个数组列,为数组中的每个元素创建一个记录。
爆炸也可以应用于映射:键和值将在两个不同的字段中爆炸。为了完整性,我将介绍第二种爆炸类型:posexplode()。其中“pos”代表位置:它爆炸列并返回一个包含位置的 long 类型的额外列。在列表 6.25 中,我从数组中的两个字段创建了一个简单的映射,然后对每个记录进行 posexplode()。由于映射列有一个键和一个值字段,对映射列进行 posexplode() 将生成三个列;在别名结果时,我们需要向 alias() 传递三个参数。
列表 6.25 使用 posexplode() 爆炸映射
episode_name_id = shows.select(
F.map_from_arrays( ❶
F.col("_embedded.episodes.id"), F.col("_embedded.episodes.name")
).alias("name_id")
)
episode_name_id = episode_name_id.select(
F.posexplode("name_id").alias("position", "id", "name") ❷
)
episode_name_id.show(5)
# +--------+-----+--------------------+
# |position| id| name|
# +--------+-----+--------------------+
# | 0|10897|Minimum Viable Pr...|
# | 1|10898| The Cap Table|
# | 2|10899|Articles of Incor...|
# | 3|10900| Fiduciary Duties|
# | 4|10901| Signaling Risk|
# +--------+-----+--------------------+
# only showing top 5 rows
❶ 我们从两个数组中构建一个映射:第一个是键;第二个是值。
❷ 通过位置爆炸,我们创建了三个列:位置、键和映射中每个元素的值。
explode() 和 posexplode() 都会跳过数组或映射中的任何 null 值。如果您想将 null 作为记录,可以使用 explode_outer() 或 posexplode_outer(),方法相同。
现在我们已经爆炸了数据帧,我们将通过将记录收集到一个复杂列中来做相反的操作。为此,PySpark 提供了两个聚合函数:collect_list() 和 collect_set()。这两个函数的工作方式相同:它们将列作为参数,并返回一个数组列作为结果。collect_list() 每个列记录返回一个数组元素,而 collect_set() 将每个 distinct 列记录返回为一个数组元素,就像 Python 集合一样。
列表 6.26 将我们的结果收集回数组
collected = episodes.groupby("id").agg(
F.collect_list("episodes").alias("episodes")
)
collected.count() # 1
collected.printSchema()
# |-- id: long (nullable = true)
# |-- episodes: array (nullable = true)
# | |-- element: struct (containsNull = false)
# | | |-- _links: struct (nullable = true)
# | | | |-- self: struct (nullable = true)
# | | | | |-- href: string (nullable = true)
# | | |-- airdate: string (nullable = true)
# | | |-- airstamp: timestamp (nullable = true)
# | | |-- airtime: string (nullable = true)
# | | |-- id: long (nullable = true)
# | | |-- image: struct (nullable = true)
# | | | |-- medium: string (nullable = true)
# | | | |-- original: string (nullable = true)
# | | |-- name: string (nullable = true)
# | | |-- number: long (nullable = true)
# | | |-- runtime: long (nullable = true)
# | | |-- season: long (nullable = true)
# | | |-- summary: string (nullable = true)
# | | |-- url: string (nullable = true)
默认情况下,不支持收集爆炸后的映射,但您可以通过将多个 collect_list() 函数作为参数传递给 agg() 函数来轻松实现。然后,您可以使用 map_from_arrays()。请参阅列表 6.25 和 6.26 以了解构建块。
本节介绍了从容器列到独立记录以及反向转换的过程。有了这个,我们可以从层次结构到非规范化列,然后再返回,而不依赖于辅助表。在章节的最后部分,我解释了如何通过创建我们层次数据模型的最后一块缺失部分来创建自己的结构体。
6.5.2 构建自己的层次结构:结构体作为函数
本节通过展示如何在数据框中创建结构体来结束本章。有了这个工具箱中的最后一个工具,数据框的结构将对你来说不再有任何秘密。
要创建结构体,我们使用来自 pyspark.sql.functions 模块的 struct() 函数。这个函数接受多个列作为参数(就像 select() 一样)并返回一个包含作为参数传递的列的字段的结构体列。简单得就像做饼一样!
在下一个列表中,我创建了一个新的结构体 info,其中包含 shows 数据框中的几个列。
列表 6.27 使用 struct 函数创建 struct 列
struct_ex = shows.select(
F.struct( ❶
F.col("status"), F.col("weight"), F.lit(True).alias("has_watched")
).alias("info")
)
struct_ex.show(1, False)
# +-----------------+
# |info |
# +-----------------+
# |{Ended, 96, true}| ❷
# +-----------------+
struct_ex.printSchema()
# root
# |-- info: struct (nullable = false) ❸
# | |-- status: string (nullable = true)
# | |-- weight: long (nullable = true)
# | |-- has_watched: boolean (nullable = false)
❶ 结构体函数可以接受一个或多个列对象(或列名)。我传递了一个字面量列来表示我已经观看了该节目。
❷ info 列是一个结构体,包含我们指定的三个字段。
❸ info 列是一个结构体,包含我们指定的三个字段。
提示:就像顶层数据框一样,您可以使用星号隐式列标识符 column.* 来解包(或选择)结构体中的所有列。
本章通过使用与大多数二维数据表示形式截然不同的数据模型——层次文档数据模型——介绍了数据框的强大功能和灵活性。我们使用与文本和表格数据相同的同一个数据框和函数集来摄取、处理、导航和塑造 JSON 文档。这扩展了数据框的功能,使其超越了单纯的行和列,并为关系模型提供了一种替代方案,在关系模型中,我们通过复制数据来表示记录的某个字段具有多个值的情况。
摘要
-
PySpark 为在数据框中摄取 JSON 文档提供了一个专门的
DataFrameReader。默认参数将读取格式良好的 JSONLines 文档,而将multiLine=True设置为读取一系列 JSON 文档,每个文档位于自己的文件中。 -
可以将 JSON 数据视为 Python 字典。通过数组(Python 列表)和对象(Python 字典)允许嵌套(或层次)元素。
-
在 PySpark 中,层次数据模型通过复杂列类型来表示。数组表示相同类型的元素列表,映射表示多个键和值(类似于 Python 字典),而结构体表示 JSON 中的对象。
-
PySpark 提供了一个程序性 API,用于在 JSON 表示形式之上构建数据框模式。具有显式模式可以降低数据在数据操纵阶段出现不兼容类型的风险,从而导致进一步的分析错误。
-
可以通过数据帧 API 中的操作(如爆炸、集合和解包)创建和分解复杂类型。
额外练习
练习 6.4
考虑到你也在数据帧中用它来访问层次实体,为什么在列名中使用点或方括号是一个坏主意?
练习 6.5
虽然不太常见,但你也可以从字典创建一个数据帧。由于字典与 JSON 文档非常接近,为以下字典构建摄入模式。(这里 JSON 或 PySpark 模式都是有效的。)
dict_schema = ???
spark.createDataFrame([{"one": 1, "two": [1,2,3]}], schema=dict_schema)
练习 6.6
使用 three_shows 计算每个剧集的第一集和最后一集之间的时间。哪个剧集的任期最长?
练习 6.7
从 shows 数据帧中提取每个剧集的播出日期和名称,并将它们放入两个数组列中。
练习 6.8
给定以下数据帧,创建一个新的数据帧,其中包含一个从 one 到 square 的单个映射:
exo6_8 = spark.createDataFrame([[1, 2], [2, 4], [3, 9]], ["one", "square"])
¹ 根据“JavaScript 对象表示法(JSON)数据交换格式”(datatracker.ietf.org/doc/html/rfc8259)你还可以有一个只包含值(例如,数字、字符串、布尔值或 null)的有效 JSON 文本。对于我们来说,这些 JSON 文本没有用,因为你可以直接解析值。
7 双语 PySpark:混合 Python 和 SQL 代码
本章涵盖了
-
将 PySpark 的指令集与 SQL 词汇进行类比
-
将数据帧注册为临时视图或表,以便使用 Spark SQL 进行查询
-
使用目录来创建、引用和删除用于 SQL 查询的已注册表
-
将常见的 Python 到 SQL 的数据操作指令翻译,反之亦然
-
在某些 PySpark 方法中使用 SQL 风格的子句
对于“Python 与 SQL,我应该学习哪一个?”这个问题,我的答案是“两者都要学”。
当涉及到操作表格数据时,SQL 是当之无愧的王者。几十年来,它一直是关系型数据库的工作语言,即使今天,学习如何驾驭它仍然是一项值得的练习。Spark 直面 SQL 的力量。你可以在 Spark 或 PySpark 程序中无缝地混合 SQL 代码,这使得迁移那些旧的 SQL ETL 作业变得前所未有的容易。
本章致力于使用 SQL 与 PySpark 结合,以及在其之上使用。我介绍了如何从一个语言转换到另一个语言。我还介绍了如何在数据帧方法中使用类似 SQL 的语法来加快你的代码,以及你可能面临的某些权衡。最后,我们将 Python 和 SQL 代码结合起来,以获得两者的最佳效果。
如果你已经对 SQL 有显著的接触,那么这一章对你来说将会轻松自如。你可以自由地浏览 SQL 特定部分(7.4),但不要跳过 Python 和 SQL 互操作性部分(7.5 及之后),因为我将涵盖一些 PySpark 的特殊性。对于那些刚开始接触 SQL 的人来说,这可能会是一个令人耳目一新的时刻,你将掌握另一个工具。如果你想深入了解 SQL,Ben Brumm 的《SQL in Motion》(Manning,2017)是一个很好的视频资源。如果你更喜欢书籍,Joe Celko 的《SQL for Smarties》(Morgan Kauffman,2014)是一本非常详尽的参考书。
下面是我在本章示例中使用的导入:
from pyspark.sql import SparkSession
from pyspark.sql.utils import AnalysisException ❶
import pyspark.sql.functions as F
import pyspark.sql.types as T
spark = SparkSession.builder.getOrCreate()
❶ 我们将处理一些 AnalysisException 异常,因此我在一开始就导入了它。
Spark SQL 与 ANSI SQL 与 HiveQL 的比较
Spark 支持 ANSI SQL(目前实验性地支持;参见mng.bz/oapr)以及绝大多数 HiveQL^a 作为 SQL 方言。Spark SQL 还内置了一些 Spark 特定的函数,以确保跨语言的功能一致性。
简而言之,Hive 是一个可以在多种数据存储选项上使用的类似 SQL 的接口。它之所以非常受欢迎,是因为它提供了查询 HDFS(Hadoop 分布式文件系统)中文件的能力,就像它们是一个表一样。当你的环境中安装了 Hive 时,Spark 可以与之集成。Spark SQL 还提供了额外的语法来处理更大的数据集,这是本章讨论的主题。
由于材料数量庞大且历史悠久,以及它的语法与基本和中级查询相似,我通常建议首先学习 ANSI SQL,然后在学习过程中学习 HiveQL。这样,你的知识就可以转移到其他基于 SQL 的产品上。由于 Hive 不是 Spark 的组件,因此我不会在本书中涵盖 Hive 特定的功能,而是将重点放在与 Spark 一起使用的 SQL 上。
^a 你可以在 Spark 网站上查看支持(和不支持)的功能:mng.bz/nYMg。
7.1 借助我们所知:pyspark.sql 与普通 SQL
在本节中,我们将 Spark 的功能和方法名称与 SQL 关键字进行类比。由于两者共享一个基础词汇,因此阅读 Spark 和 SQL 代码并理解它们的行为变得容易。更具体地说,我们将两种语言中的简单指令分解开来,以识别它们的相似之处和不同之处。
PySpark 的 SQL 遗产不仅仅在表面:模块的名称——pyspark.sql——就是一个明显的提示。PySpark 开发者认识到 SQL 编程语言在数据处理方面的遗产,并使用相同的关键字来命名他们的方法。让我们快速看一下 SQL 和纯 PySpark 中的示例,并观察所使用关键字之间的相似性。在列表 7.1 中,我加载了一个包含元素周期表信息的 CSV 文件,并查询数据集以找到每个周期中具有液态状态的条目数量。代码以 PySpark 和 SQL 的形式呈现,并且在不多的上下文中,我们可以看到相似之处。我在图 7.1 中对比了这两个版本。

图 7.1 PySpark 和 SQL 共享相同的关键字,但操作顺序不同。PySpark 看起来像一系列有序的操作(取这个表,执行这些转换,然后最终显示结果),而 SQL 则采用更描述性的方法(在执行这些转换后,显示该表的结果)。
与 SQL 语言相比,PySpark 的数据操作 API 在两个方面有所不同:
-
PySpark 将以你正在处理的数据帧的名称开头。SQL 使用
from关键字来引用表(或目标)。 -
PySpark 将转换和操作作为数据帧上的方法链,而 SQL 将它们分为两组:操作组和条件组。前者在
from子句之前,操作列。后者在from子句之后,对结果表的结构进行分组、过滤和排序。
提示 SQL 对大小写不敏感,因此你可以使用小写或大写。
列表 7.1 通过周期读取和计算液态元素
elements = spark.read.csv(
"./data/elements/Periodic_Table_Of_Elements.csv",
header=True,
inferSchema=True,
)
elements.where(F.col("phase") == "liq").groupby("period").count().show()
-- In SQL: We assume that the data is in a table called `elements`
SELECT
period,
count(*)
FROM elements
WHERE phase = 'liq'
GROUP BY period;
两者都会返回相同的结果:第四周期(溴)有一个元素,第六周期(汞)有一个元素。
你更喜欢 PySpark 或 SQL 的操作顺序将取决于你如何心理构建查询以及你对相应语言的熟悉程度。幸运的是,PySpark 使你很容易从一种语言切换到另一种语言,甚至可以同时使用这两种语言。
7.2 准备数据框以供 SQL 查询
由于我们可以将 PySpark 数据框视为类固醇上的表,因此考虑使用用于查询表的语言来查询它们并不牵强。Spark 提供了一个完整的 SQL API,其文档方式与 PySpark API 相同(mng.bz/vozJ)。Spark SQL API 还定义了在 pyspark.sql API 中使用的函数,例如 substr() 或 size()。
注意:Spark 的 SQL API 仅涵盖 Spark 的数据操作子集。例如,你将无法使用 SQL 进行机器学习(见第十三章)。
7.2.1 将数据框提升为 Spark 表
在本节中,我将介绍使用 SQL 获取 Spark 数据框的简单步骤。PySpark 在其自己的命名空间和 Spark SQL 的命名空间之间保持边界;因此,我们必须明确提升它们。
首先,让我们看看如果我们什么都不做会发生什么。列表 7.2 中的代码展示了示例。
列表 7.2 尝试(并失败)以 SQL 风格查询数据框
try:
spark.sql(
"select period, count(*) from elements "
"where phase='liq' group by period"
).show(5)
except AnalysisException as e:
print(e)
# 'Table or view not found: elements; line 1 pos 29'
在这里,PySpark 并没有在 Python 变量 elements(它指向数据框)和可以由 Spark SQL 查询的潜在表 elements 之间建立链接。为了允许数据框通过 SQL 进行查询,我们需要对其进行 注册。
当我们将数据框分配给变量时,Python 指向数据框。Spark SQL 无法看到 Python 分配的变量。
当你想创建一个用于 Spark SQL 查询的表/视图时,请使用 createOrReplaceTempView() 方法。此方法接受一个字符串参数,即你想要使用的表名称。这种转换将查看由 Python 变量引用的数据框,并将创建一个指向相同数据框的 Spark SQL 引用。
注意:尽管你可以将表命名为与正在使用的变量相同的名称,但你并不被强迫这样做。
一旦我们注册了指向与我们的同名 Python 变量相同的数据框的 elements 表,我们就可以没有任何问题地查询我们的表。让我们重新运行列表 7.2 中的相同代码块,看看它是否成功。
注意:在本章中,我相当宽松地使用术语 表 和 视图。在 SQL 中,它们是不同的概念:表在内存和磁盘上物化,而视图是即时计算的。Spark 的临时视图在概念上更接近视图而不是表。Spark SQL 也有表,但我们将不会使用它们,而是将我们的数据读取和物化到数据框中。
列表 7.3 尝试(并成功)以 SQL 风格查询数据框
elements.createOrReplaceTempView("elements") ❶
spark.sql(
"select period, count(*) from elements where phase='liq' group by period"
).show(5)
# +------+--------+
# |period|count(1)|
# +------+--------+
# | 6| 1|
# | 4| 1|
# +------+--------+ ❷
❶ 我们使用元素数据框上的 createOrReplaceTempView() 方法注册我们的表。
❷ 当 Spark 能够解析 SQL 视图名称时,相同的查询才会生效。
现在我们已经注册了一个视图。在管理少量视图的情况下,将其名称保持在内存中相当容易。如果你有数十个视图或需要删除一些,怎么办?进入目录,这是 Spark 管理其 SQL 命名空间的方式。
高级话题:Spark SQL 视图和持久化
PySpark 有四种创建临时视图的方法,乍一看它们看起来相当相似:
-
createGlobalTempView() -
createOrReplaceGlobalTempView() -
createOrReplaceTempView() -
createTempView()
我们可以看到,存在一个两行两列的可能性矩阵:
-
我是否想要替换现有的视图(
OrReplace)? -
我是否想要创建一个全局视图(
Global)?
第一个问题相对容易回答:如果你使用createTempView与另一个表已经使用的名称,该方法将失败。另一方面,如果你使用createOrReplaceTempView(),Spark 将用新表替换旧表。在 SQL 中,这相当于使用CREATE VIEW与CREATE OR REPLACE VIEW。我个人总是使用后者,因为它模仿了 Python 处理事情的方式:在重新赋值变量时,你会遵守。
那么Global呢?本地视图和全局视图之间的区别在于它们在内存中持续的时间。本地表与你的SparkSession相关联,而全局表与 Spark 应用程序相关联。目前这些差异并不显著,因为我们没有使用需要共享数据的多个SparkSessions。在 Spark 进行数据分析的上下文中,你不会同时处理多个SparkSessions,所以我通常不使用Global方法。
7.2.2 使用 Spark 目录
Spark 目录是一个允许与 Spark SQL 表和视图一起工作的对象。它的大多数方法都与管理这些表的元数据有关,例如它们的名称和缓存级别(我将在第十一章中详细说明)。在本节中,我们将查看最基本的功能集,为更高级的内容打下基础,例如表缓存(第十一章)和 UDF(第八章)。
我们可以使用目录来列出我们已注册的表/视图,并在完成时删除它们。下一列表中的代码提供了执行这些任务的简单方法。由于它们主要模仿 PySpark 的数据帧功能,我认为示例是最好的说明。
列表 7.4 使用目录显示我们的注册视图然后删除它
spark.catalog ❶
# <pyspark.sql.catalog.Catalog at 0x117ef0c18>
spark.catalog.listTables() ❷
# [Table(name='elements', database=None, description=None,
# tableType='TEMPORARY', isTemporary=True)]
spark.catalog.dropTempView("elements") ❸
spark.catalog.listTables() ❹
# []
❶ 目录是通过我们的 SparkSession 的目录属性访问的。
❷ listTables方法给我们一个包含我们所需信息的 Table 对象的列表。
❸ 要删除视图,我们使用dropTempView()方法,并将视图名称作为参数传递。
❹ 我们现在没有表可以查询。
现在我们已经了解了如何在 PySpark 中管理 Spark SQL 视图,我们可以开始探讨使用这两种语言来操作数据。
7.3 SQL 和 PySpark
Python(通过 PySpark)与 SQL 之间的集成考虑得非常周到,可以提高我们编写代码的速度。本节重点介绍仅使用 SQL 来操作数据,Python 在目录和指令中扮演协调角色。我将从纯 SQL 和 PySpark 的角度回顾最常见的操作,以说明基本操作是如何编写的。
在本章的剩余部分,我们将使用 Backblaze 提供的一个公共数据集,该数据集提供了硬盘驱动器和统计数据。Backblaze 是一家提供云存储和备份的公司。自 2013 年以来,他们提供了数据中心中驱动器的数据,并且随着时间的推移,他们转向了关注故障和诊断。他们的(清洁)数据量在千兆字节范围内,虽然目前还不是很大,但绝对适合 Spark,因为它将超过您家庭电脑上的内存。如果您需要更大的数据集,Backblaze 网站上还有更多历史数据可供使用。还提供了一个方便的 shell 脚本,可以一次性下载所有内容。对于在本地上工作的用户,如果担心内存不足,可以使用 Q3 2019。两种工作流程的语法略有不同。至少有 16GB RAM 的计算机应该能够处理所有文件。Backblaze 主要提供 SQL 语句形式的文档,这对于我们正在学习的内容来说非常合适。
要获取文件,您可以从网站下载它们(mng.bz/4jZa)或者使用代码仓库中可用的backblaze_download_data.py,这需要安装wget包。数据需要放在./data/目录下的backblaze文件夹中。
列表 7.5 从 Backblaze 下载数据
$ pip install wget
$ python code/Ch07/download_backblaze_data.py full
# [some data download progress bars]
$ ls data/backblaze ❶
__MACOSX/ data_Q2_2019.zip data_Q4_2019/
data_Q1_2019.zip data_Q3_2019/ data_Q4_2019.zip
data_Q2_2019/ data_Q3_2019.zip drive_stats_2019_Q1/
❶ Windows 用户,使用"dir data\backblaze"。
在尝试读取文件之前,请确保将文件解压缩到该目录中。与许多其他编解码器(例如,Gzip、Bzip2、Snappy 和 LZO)不同,PySpark 在读取时不会自动解压缩 zip 文件,因此我们需要提前进行解压缩。如果您使用的是命令行(您可能需要在 Linux 上安装此工具),可以使用unzip命令。在 Windows 上,我通常使用 Windows 资源管理器手动解压缩。
读取和准备数据的代码相当简单。我们分别读取每个数据源,然后确保每个数据帧与其同伴具有相同的列。在我们的案例中,第四季度的数据比其他数据多两个列,因此我们添加缺失的列。在连接四个数据帧时,我们使用选择方法以确保它们的列顺序相同。然后,我们将包含 SMART 测量的所有列转换为长整型,因为它们被记录为整数值。最后,我们将我们的数据帧注册为视图,这样我们就可以在它上面使用 SQL 语句。
列表 7.6 将 Backblaze 数据读取到数据帧中并注册视图
DATA_DIRECTORY = "./data/backblaze/"
q1 = spark.read.csv(
DATA_DIRECTORY + "drive_stats_2019_Q1", header=True, inferSchema=True
)
q2 = spark.read.csv(
DATA_DIRECTORY + "data_Q2_2019", header=True, inferSchema=True
)
q3 = spark.read.csv(
DATA_DIRECTORY + "data_Q3_2019", header=True, inferSchema=True
)
q4 = spark.read.csv(
DATA_DIRECTORY + "data_Q4_2019", header=True, inferSchema=True
)
# Q4 has two more fields than the rest
q4_fields_extra = set(q4.columns) - set(q1.columns)
for i in q4_fields_extra:
q1 = q1.withColumn(i, F.lit(None).cast(T.StringType()))
q2 = q2.withColumn(i, F.lit(None).cast(T.StringType()))
q3 = q3.withColumn(i, F.lit(None).cast(T.StringType()))
# if you are only using the minimal set of data, use this version
backblaze_2019 = q3
# if you are using the full set of data, use this version
backblaze_2019 = (
q1.select(q4.columns)
.union(q2.select(q4.columns))
.union(q3.select(q4.columns))
.union(q4)
)
# Setting the layout for each column according to the schema
backblaze_2019 = backblaze_2019.select(
[
F.col(x).cast(T.LongType()) if x.startswith("smart") else F.col(x)
for x in backblaze_2019.columns
]
)
backblaze_2019.createOrReplaceTempView("backblaze_stats_2019")
7.4 在数据帧方法中使用类似 SQL 的语法
本节的目标是对列子集进行快速探索性数据分析。我们将重现 Backblaze 计算出的故障率,并确定 2019 年故障最多和最少的型号。
7.4.1 获取你想要的行和列:select 和 where
select和where用于缩小你想要保留在数据框中的列(select)和行(where)。在列表 7.7 中,我使用select和where来展示一些在某个时刻失败的硬盘序列号(failure = 1)。select()和where()都是在第二章中引入的,并且自那时起一直在使用;我想再次强调 SQL 和 Python 语法的差异。
要在 PySpark 程序中使用 SQL,请使用SparkSession对象的sql方法。这个方法接受一个包含 SQL 语句的字符串。就这么简单!
列表 7.7 在 PySpark 和 SQL 中比较select和where
spark.sql(
"select serial_number from backblaze_stats_2019 where failure = 1"
).show(
5
) ❶
backblaze_2019.where("failure = 1").select(F.col("serial_number")).show(5)
# +-------------+
# |serial_number|
# +-------------+
# | 57GGPD9NT|
# | ZJV02GJM|
# | ZJV03Y00|
# | ZDEB33GK|
# | Z302T6CW|
# +-------------+
# only showing top 5 rows
❶ 由于 SQL 语句返回一个数据框,我们仍然需要使用show()来查看结果。
让我们回顾一下使用列表 7.7 中的示例,Python 和 SQL 代码之间的差异。PySpark 让你思考如何链式操作。在我们的例子中,我们首先过滤数据框,然后选择感兴趣的列。SQL 提供了一个不同的结构:
-
你将想要选择的列放在语句的开头。这被称为SQL 操作:
selectserial_number。 -
你添加一个或多个表到查询中,称为目标:
frombackblaze_stats_2019。 -
你可以添加条件,例如过滤:
wherefailure=1。
本章中我们将讨论的每个操作都将被分类为操作、目标或条件,这样你可以知道它在语句中的位置。
作为最后的注意事项,SQL 没有使用withColumns()创建列或使用withColumnRenamed重命名列的概念。所有操作都必须通过SELECT来完成。在下一节中,我将介绍如何将记录分组在一起;同时,我也将机会用来介绍别名。
提示:如果你有一个想要提取为数据框的表,你可以将SELECT语句的结果赋值给一个变量。例如,你可以这样做:failures = spark.sql("select serial_number . . ."),然后结果数据框将被分配给变量failures。
7.4.2 将相似记录分组在一起:group by 和 order by
在第五章中详细介绍了 PySpark 的groupby()和orderby()语法。在本节中,我通过将 Python 语法与 SQL 语法进行比较,介绍了 SQL 语法——通过查看数据中包含的硬盘的容量(以千兆字节为单位),按型号分组。为此,我们使用一点算术和pow()函数(在pyspark.sql.functions中可用),它将第一个参数提升为第二个参数的幂。我们可以看到 SQL 和 PySpark 词汇之间的相似性,但再次强调,转换的顺序是不同的。
列表 7.8 PySpark 和 SQL 中的分组和排序
spark.sql(
"""SELECT
model,
min(capacity_bytes / pow(1024, 3)) min_GB,
max(capacity_bytes/ pow(1024, 3)) max_GB
FROM backblaze_stats_2019
GROUP BY 1
ORDER BY 3 DESC"""
).show(5)
backblaze_2019.groupby(F.col("model")).agg(
F.min(F.col("capacity_bytes") / F.pow(F.lit(1024), 3)).alias("min_GB"),
F.max(F.col("capacity_bytes") / F.pow(F.lit(1024), 3)).alias("max_GB"),
).orderBy(F.col("max_GB"), ascending=False).show(5)
# +--------------------+--------------------+-------+
# | model| min_GB| max_GB|
# +--------------------+--------------------+-------+
# | ST16000NM001G| 14902.0|14902.0|
# | TOSHIBA MG07ACA14TA|-9.31322574615478...|13039.0|
# |HGST HUH721212ALE600| 11176.0|11176.0|
# | ST12000NM0007|-9.31322574615478...|11176.0|
# | ST12000NM0008| 11176.0|11176.0|
# +--------------------+--------------------+-------+
# only showing top 5 rows
在 PySpark 中,我们再次查看操作的逻辑顺序。我们按 capacity_GB 列进行 groupby,这是一个计算列。就像在 PySpark 中一样,可以使用常规语法在 SQL 中执行算术运算。此外,Spark SQL 也实现了 pow() 函数。如果您需要查看可以使用的函数,Spark SQL API 文档包含必要的信息(mng.bz/vozJ)。
要给列起别名,我们只需在列描述符后添加名称,前面加一个空格。在我们的例子中,min(capacity_bytes / pow(1024, 3)) 被别名为 min_GB——一个更友好的名称!有些人可能更喜欢使用关键字 as,这样行就变成了 min(capacity_bytes / pow(1024, 3)) as min_GB;在 Spark SQL 中,这完全是个人喜好问题。
分组和排序是 SQL 中的条件,因此它们位于语句的末尾。它们都遵循与 PySpark 相同的约定:
-
我们通过逗号分隔列来提供
group by。 -
对于
order by,我们向子句提供列名,如果想要按降序排序(默认为升序ASC),则可选地提供DESC参数。
值得注意的是,我们按 1 进行分组,按 3 DESC 排序。这是一种通过位置而不是名称来引用 SQL 操作中列的简写方式。在这种情况下,它使我们免于在条件块中编写 group by capacity_bytes / pow(1024,3) 或 order by max(capacity_bytes / pow(1024,3)) DESC。我们可以在 group by 和 order by 子句中使用数字别名。虽然它们很方便,但过度使用它们会使你的代码更加脆弱,且在更改查询时难以维护。
观察查询结果,有一些驱动器报告了多个容量。此外,我们还有一些报告负容量的驱动器,这真的很奇怪。让我们关注一下这种情况的普遍性。
7.4.3 使用 having 在分组后进行过滤
由于 SQL 中操作评估的顺序,where 总是在 group by 之前应用。如果我们想过滤在 group by 操作之后创建的列的值,会发生什么?我们使用一个新的关键字:having!
例如,假设对于每个模型,报告的最大容量是正确的。下一条列表中的代码展示了我们如何在两种语言中实现这一点。
列表 7.9 在 SQL 中使用 having 并在 PySpark 中依赖 where
spark.sql(
"""SELECT
model,
min(capacity_bytes / pow(1024, 3)) min_GB,
max(capacity_bytes/ pow(1024, 3)) max_GB
FROM backblaze_stats_2019
GROUP BY 1
HAVING min_GB != max_GB
ORDER BY 3 DESC"""
).show(5)
backblaze_2019.groupby(F.col("model")).agg(
F.min(F.col("capacity_bytes") / F.pow(F.lit(1024), 3)).alias("min_GB"),
F.max(F.col("capacity_bytes") / F.pow(F.lit(1024), 3)).alias("max_GB"),
).where(F.col("min_GB") != F.col("max_GB")).orderBy(
F.col("max_GB"), ascending=False
).show(
5
)
# +--------------------+--------------------+-------+
# | model| min_GB| max_GB|
# +--------------------+--------------------+-------+
# | TOSHIBA MG07ACA14TA|-9.31322574615478...|13039.0|
# | ST12000NM0007|-9.31322574615478...|11176.0|
# |HGST HUH721212ALN604|-9.31322574615478...|11176.0|
# | ST10000NM0086|-9.31322574615478...| 9314.0|
# |HGST HUH721010ALE600|-9.31322574615478...| 9314.0|
# +--------------------+--------------------+-------+
# only showing top 5 rows
having 是 SQL 独有的语法:它可以被视为只能应用于聚合字段(如 count(*) 或 min(date))的 where 子句。由于它在功能上与 where 相等,having 位于 group by 子句之后的条件块中。在 PySpark 中,我们没有 having 作为方法。由于每个方法都返回一个新的数据框,我们不需要不同的关键字,可以直接使用我们创建的列的 where。
注意:我们暂时忽略那些容量报告的不一致性。它们将在练习中再次出现。
到目前为止,我们已经涵盖了最重要的 SQL 操作:使用 select 选择列。接下来,让我们以 SQL 的方式实现我们的工作。
7.4.4 使用 CREATE 关键字创建新表/视图
现在我们已经查询了数据,并在 SQL 中掌握了它的使用方法,我们可能想要检查我们的工作并保存一些数据,这样我们就不必下次从头开始处理所有内容。为此,我们可以创建一个表或一个视图,然后我们可以直接查询它。
在 SQL 中创建表或视图非常简单:在我们的查询前加上 CREATE TABLE/VIEW。在这里,创建一个表或视图将产生不同的影响。如果您连接了 Hive 元数据存储,创建表将物化数据,而视图将只保留查询。用烘焙的比喻来说,CREATE TABLE 将存储蛋糕,而 CREATE VIEW 将引用原料(原始数据)和食谱(查询)。
为了演示这一点,我将重现 drive_days 和 failures,它们分别计算模型运行的天数和已发生的驱动器故障数。列表 7.10 中的代码显示了如何实现:在您的 select 查询前加上 CREATE [TABLE/VIEW]。
在 PySpark 中,我们不需要依赖额外的语法。一个新创建的数据框必须分配给一个变量,然后我们就可以继续了。
列表 7.10 在 Spark SQL 和 PySpark 中创建视图
backblaze_2019.createOrReplaceTempView("drive_stats")
spark.sql(
"""
CREATE OR REPLACE TEMP VIEW drive_days AS
SELECT model, count(*) AS drive_days
FROM drive_stats
GROUP BY model"""
)
spark.sql(
"""CREATE OR REPLACE TEMP VIEW failures AS
SELECT model, count(*) AS failures
FROM drive_stats
WHERE failure = 1
GROUP BY model"""
)
drive_days = backblaze_2019.groupby(F.col("model")).agg(
F.count(F.col("*")).alias("drive_days")
)
failures = (
backblaze_2019.where(F.col("failure") == 1)
.groupby(F.col("model"))
.agg(F.count(F.col("*")).alias("failures"))
)
从 SQL 中的数据创建表
您也可以从硬盘或 HDFS 上的数据创建一个表。为此,您可以使用修改后的 SQL 查询。由于我们正在读取 CSV 文件,我们在路径前加上 csv.:
spark.sql("create table q1 as select * from csv.`./data/backblaze/drive_stats_2019_Q1`")
我更倾向于依赖 PySpark 语法来从我的数据源读取和设置模式,然后使用 SQL,但这个选项是可用的。
7.4.5 使用 UNION 和 JOIN 向我们的表中添加数据
到目前为止,我们已经看到了如何一次查询一个表。在实践中,你经常会得到多个相互关联的表。我们已经通过每个季度有一个历史表来见证这个问题,这些表需要堆叠在一起(或联合),以及我们的 drive_days 和 failures 表,每个表都描绘了故事的一个维度,直到它们合并(或连接)。
连接和联合是我们将看到的唯一修改 SQL 语句目标部分的子句。在 SQL 中,查询一次只对一个目标进行操作。我们在本章开头已经看到了如何使用 PySpark 联合表。在 SQL 中,我们遵循相同的蓝图:SELECT columns FROM table1 UNION ALL SELECT columns FROM table2。
PySpark 的 union() 与 SQL UNION
在 SQL 中,UNION会移除重复的记录。PySpark 的union()不会,这就是为什么它与 SQL 的UNION ALL等价。如果你想要删除重复项,这在分布式环境中是一个昂贵的操作,请在union()之后使用distinct()函数。这是 PySpark 词汇不遵循 SQL 的罕见情况之一,但这是出于一个很好的原因。大多数时候,你都会想要UNION ALL的行为。
在尝试进行合并之前,确保你的数据框具有相同的列、相同的类型以及相同的顺序总是一个好主意。在 PySpark 解决方案中,我们利用了可以提取列列表的事实,以相同的方式select数据框。Spark SQL 没有简单的方法来做同样的事情,所以你可能需要输入所有列。当你只有少数几个列时,这没问题,但我们这里讨论的是数百个列。
一种绕过这个问题的简单方法就是利用 Spark SQL 语句是一个字符串的事实。我们可以将我们的列列表转换成一个类似 SQL 的字符串,然后完成它。这正是我在列表 7.11 中所做的。这不是一个纯 Spark SQL 解决方案,但它比让你逐个输入所有列要友好得多。
警告:在处理用户输入时,不要允许直接插入纯字符串!这是导致 SQL 注入的最佳方式,用户可以构建一个字符串,对你的数据造成破坏。有关 SQL 注入及其为何如此危险的信息,请参阅 Open Web Application Security Project 关于此主题的文章(mng.bz/XWdG)。
列表 7.11 在 Spark SQL 和 PySpark 中联合表
columns_backblaze = ", ".join(q4.columns) ❶
q1.createOrReplaceTempView("Q1") ❷
q2.createOrReplaceTempView("Q2")
q3.createOrReplaceTempView("Q3")
q4.createOrReplaceTempView("Q4")
spark.sql(
"""
CREATE OR REPLACE TEMP VIEW backblaze_2019 AS
SELECT {col} FROM Q1 UNION ALL
SELECT {col} FROM Q2 UNION ALL
SELECT {col} FROM Q3 UNION ALL
SELECT {col} FROM Q4
""".format(
col=columns_backblaze
)
)
backblaze_2019 = ( ❸
q1.select(q4.columns)
.union(q2.select(q4.columns))
.union(q3.select(q4.columns))
.union(q4)
)
❶ 我们使用join()方法在一个分隔符字符串上创建一个包含列表中所有元素的字符串,元素之间用逗号分隔。
❷ 我们将我们的季度数据框提升为 Spark SQL 视图,以便我们可以在查询中使用它们。
❸ 这是从列表 7.6 中摘取的。
在 SQL 中,连接同样简单。我们在语句的目标部分添加一个[DIRECTION] JOIN table [ON] [LEFT COLUMN] [COMPARISON OPERATOR] [RIGHT COLUMN]。方向与 PySpark 中的how参数相同。on子句是一系列列之间的比较。在列表 7.12 的例子中,我们连接了model列的值在drive_days和failures表中都相等(=)的记录。有多个条件?使用括号和逻辑运算符(AND,OR),就像在 Python 中工作一样(更多信息,请参阅第五章)。
列表 7.12 在 Spark SQL 和 PySpark 中连接表
spark.sql(
"""select
drive_days.model,
drive_days,
failures
from drive_days
left join failures
on
drive_days.model = failures.model"""
).show(5)
drive_days.join(failures, on="model", how="left").show(5)
7.4.6 通过子查询和公用表表达式更好地组织你的 SQL 代码
我们将单独查看的最后一些 SQL 语法是子查询和公用表表达式。许多 SQL 参考文献直到很晚才讨论它们,这是很遗憾的,因为它们(a)易于理解,并且(b)在保持代码整洁方面非常有帮助。简而言之,它们允许你在查询本地创建表。在 Python 中,这类似于使用 with 语句或使用函数块来限制查询的作用域。我将展示函数方法,因为它更为常见。¹
对于我们的示例,我们将使用 drive_days 和 failures 表定义,并将它们捆绑成一个查询,该查询将衡量 2019 年故障率最高的模型。列表 7.13 中的代码显示了我们可以如何使用子查询来完成这项工作。子查询简单地用一个独立的 SQL 查询替换了一个表名。在示例中,我们可以看到表名已被形成该表的 SELECT 查询所取代。我们可以通过在语句的末尾添加名称来给子查询中引用的表起别名,在括号关闭之后。
列表 7.13 使用子查询查找故障率最高的驾驶模型
spark.sql(
"""
SELECT
failures.model,
failures / drive_days failure_rate
FROM (
SELECT
model,
count(*) AS drive_days
FROM drive_stats
GROUP BY model) drive_days
INNER JOIN (
SELECT
model,
count(*) AS failures
FROM drive_stats
WHERE failure = 1
GROUP BY model) failures
ON
drive_days.model = failures.model
ORDER BY 2 desc
"""
).show(5)
子查询很酷,但可能难以阅读和调试,因为你在主查询中增加了复杂性。这就是公用表表达式(CTE)特别有用之处。CTE 是一个表定义,就像在子查询的情况下一样。这里的区别在于你将它们放在主语句的顶部(在主 SELECT 之前),并以前缀词 WITH 开头。在下一个列表中,我使用了与子查询相同的语句,但使用了两个 CTE。这些也可以被视为临时 CREATE 语句,在查询结束时被删除,就像 Python 中的 with 关键字一样。
列表 7.14 使用公用表表达式查找最高故障率
spark.sql(
"""
WITH drive_days as ( ❶
SELECT ❶
model, ❶
count(*) AS drive_days ❶
FROM drive_stats ❶
GROUP BY model), ❶
failures as ( ❶
SELECT ❶
model, ❶
count(*) AS failures ❶
FROM drive_stats ❶
WHERE failure = 1 ❶
GROUP BY model) ❶
SELECT
failures.model,
failures / drive_days failure_rate
FROM drive_days
INNER JOIN failures
ON
drive_days.model = failures.model
ORDER BY 2 desc
"""
).show(5)
❶ 我们可以在主查询中参考 drive_days 和 failures。
在 Python 中,我发现最好的替代方案是将语句包装在一个函数中。在函数的作用域内创建的任何中间变量,一旦函数返回,就不会被保留。我使用 PySpark 的查询版本将在下一个列表中。
列表 7.15 使用 Python 范围规则查找最高故障率
def failure_rate(drive_stats):
drive_days = drive_stats.groupby(F.col("model")).agg( ❶
F.count(F.col("*")).alias("drive_days")
)
failures = (
drive_stats.where(F.col("failure") == 1)
.groupby(F.col("model"))
.agg(F.count(F.col("*")).alias("failures"))
)
answer = ( ❷
drive_days.join(failures, on="model", how="inner")
.withColumn("failure_rate", F.col("failures") / F.col("drive_days"))
.orderBy(F.col("failure_rate").desc())
)
return answer
failure_rate(backblaze_2019).show(5)
print("drive_days" in dir()) ❸
❶ 我们在函数体内部创建中间数据框,以避免有一个庞大的查询。
❷ 我们的答案数据框使用了两个中间数据框。
❸ 当函数返回确认我们的中间框架被整洁地限制在函数范围内时,我们正在测试是否有一个变量 drive_days 在范围内。
在本节中,我们使用 PySpark/Python 数据转换 API 和 Spark SQL 对数据进行转换。PySpark 在不太多仪式的情况下将优先权给了 SQL。如果你经常与 DBA 和 SQL 开发人员打交道,这会非常方便,因为你可以使用他们首选的语言进行协作,同时知道 Python 就在附近。大家都赢了!
7.4.7 PySpark 与 SQL 语法快速总结
PySpark 从 SQL 世界借用了很多词汇。我认为这是一个非常聪明的想法:有代际的程序员知道 SQL,采用相同的关键字使得沟通变得容易。我们在操作顺序上看到很多差异:PySpark 会自然地鼓励你思考操作应该执行的顺序。SQL 遵循一个更严格的框架,要求你记住你的操作属于操作、目标还是条件子句。
我发现 PySpark 处理数据操作的方式更直观,但在方便的时候,我会依靠我作为数据分析师多年的 SQL 经验。在编写 SQL 时,我通常先写出目标,然后逐步构建。不是所有东西都需要从头到尾!
到目前为止,我尽量使两种语言保持独立。现在,我们将打破这种障碍,释放 Python + SQL 的力量。这将简化我们编写某些转换的方式,并使我们的代码更容易编写,且更加简洁。
练习 7.1
以 elements 数据帧为例,以下哪个 PySpark 代码与以下 SQL 语句等价?
select count(*) from elements where Radioactive is not null;
a) element.groupby("Radioactive").count().show()
b) elements.where(F.col("Radioactive").isNotNull()).groupby().count().show()
c) elements.groupby("Radioactive").where(F.col("Radioactive").isNotNull()).show()
d) elements.where(F.col("Radioactive").isNotNull()).count()
e) 以上查询均不适用
7.5 简化我们的代码:融合 SQL 和 Python
PySpark 在处理方法和函数参数时相当灵活:在使用 groupby() 时,你可以传递一个列名(作为字符串),而不是列对象(F.col())(参见第四章)。此外,还有一些方法我们可以使用,将一点 SQL 语法塞入我们的 PySpark 代码中。你会发现能使用这种方法的地方不多,但它非常实用且做得很好,你最终会经常使用它。
本节将基于我们迄今为止编写的代码。我们将编写一个函数,对于给定的容量,将根据我们的故障率返回最可靠的三个驱动器。我们将利用我们已编写的代码并简化它。
7.5.1 使用 Python 提高鲁棒性和简化数据读取阶段
我们首先简化了读取数据的代码。程序的数据摄取部分显示在列表 7.16 中。与我们的原始数据摄取相比,有一些变化。
首先,我将所有目录放入一个列表中,这样我就可以使用列表推导来读取它们。这消除了一些重复的代码,并且如果我要删除或添加文件(如果你只使用 Q3 2019,你可以从列表中删除其他条目)的话,它也会很容易工作。
其次,由于我们不需要 SMART(自我监控、分析和报告技术,大多数硬盘驱动器中包含的监控系统;更多信息请见mng.bz/jydV)的测量,我选择列的交集而不是尝试用null值填充缺失的列。为了创建适用于任何数量数据源的通用交集,我使用了reduce,它将匿名函数应用于所有列集,从而得到所有数据帧之间的公共列。(对于那些不熟悉reduce的人,我发现 Python 文档非常明确且易于理解:mng.bz/y4YG。)我还添加了一个关于公共列集的断言,因为我想要确保它包含我用于分析所需的列。断言是在某些条件未满足时短路分析的好方法。在这种情况下,如果缺少一个列,我宁愿程序在AssertionError中提前失败,也不愿在之后出现巨大的堆栈跟踪。
最后,我使用第二个reduce来合并所有不同的数据帧成为一个统一的数据帧。这与我创建公共变量时使用的原则相同。这使得代码更加简洁,如果我想添加更多来源或删除一些,它将无需任何修改即可工作。
列表 7.16 我们程序的数据摄入部分
from functools import reduce
import pyspark.sql.functions as F
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
DATA_DIRECTORY = "./data/backblaze/"
DATA_FILES = [
"drive_stats_2019_Q1",
"data_Q2_2019",
"data_Q3_2019",
"data_Q4_2019",
]
data = [
spark.read.csv(DATA_DIRECTORY + file, header=True, inferSchema=True)
for file in DATA_FILES
]
common_columns = list(
reduce(lambda x, y: x.intersection(y), [set(df.columns) for df in data])
)
assert set(["model", "capacity_bytes", "date", "failure"]).issubset(
set(common_columns)
)
full_data = reduce(
lambda x, y: x.select(common_columns).union(y.select(common_columns)), data
)
7.5.2 在 PySpark 中使用 SQL 风格的表达式
现在,我们的数据已经被读取并且处于稳定状态,我们可以处理它,使其能够轻松回答我们的问题。三种方法接受 SQL 类型的语句:selectExpr()、expr()和where()/filter()。在本节中,我们适当地使用 SQL 风格的表达式来展示何时融合这两种语言是有意义的。在本节的末尾,我们有代码
-
仅选择查询中使用的有用列
-
获取我们的驱动器容量(以千兆字节为单位)
-
计算出
drive_days和failures数据帧 -
将两个数据帧合并成一个汇总的数据帧并计算故障率
代码在以下列表中可用。
列表 7.17 处理我们的数据以便为查询函数做好准备
full_data = full_data.selectExpr(
"model", "capacity_bytes / pow(1024, 3) capacity_GB", "date", "failure"
)
drive_days = full_data.groupby("model", "capacity_GB").agg(
F.count("*").alias("drive_days")
)
failures = (
full_data.where("failure = 1")
.groupby("model", "capacity_GB")
.agg(F.count("*").alias("failures"))
)
summarized_data = (
drive_days.join(failures, on=["model", "capacity_GB"], how="left")
.fillna(0.0, ["failures"])
.selectExpr("model", "capacity_GB", "failures / drive_days failure_rate")
.cache()
)
selectExpr()与select()方法类似,只是它将处理 SQL 风格的运算。我非常喜爱这个方法,因为它在用函数和算术操作处理列时减少了语法。在我们的案例中,PySpark 的替代方案(在下一条列表中显示)稍微有点冗长且难以编写和阅读,特别是我们不得不创建一个字面量1024列来应用pow()函数。
列表 7.18 将selectExpr()替换为常规的select()
full_data = full_data.select(
F.col("model"),
(F.col("capacity_bytes") / F.pow(F.lit(1024), 3)).alias("capacity_GB"),
F.col("date"),
F.col("failure")
)
第二种方法简单地称为 expr()。它将 SQL 风格的表达式包装成一个列。这有点像通用的 selectExpr(),当你想要修改一个列时,你可以用它来代替 F.col()(或列名)。如果我们从列表 7.17 中的 failures 表开始,我们可以使用 expr(或 表达式)作为 agg() 参数。这种替代语法在下一个列表中展示。我喜欢在 agg() 参数中这样做,因为它可以节省很多 alias()。
列表 7.19 在我们的 failures 数据框代码中使用 SQL 表达式
failures = (
full_data.where("failure = 1")
.groupby("model", "capacity_GB")
.agg(F.expr("count(*) failures"))
)
第三种方法,也是我最喜欢的,是 where()/filter() 方法。我发现 SQL 中过滤的语法比常规 PySpark 更简洁;能够无需任何仪式地将 SQL 语法作为 filter() 方法的参数使用,真是一个福音。在我们的最终程序中,我能够使用 full_data.where("failure = 1") 而不是像我们之前做的那样将列名包裹在 F.col() 中。
我在查询函数中重用了这个便利性,该函数在列表 7.20 中展示。这次,我结合了字符串插值和 between。这并不节省很多按键,但它很容易理解,而且当你使用 data.capacity_GB.between(capacity_min, capacity_max)(如果你更喜欢使用列函数,你也可以使用这种语法:F.col("capacity_GB") .between(capacity_min, capacity_max))时,你不会得到那么多的行噪声。在这个阶段,这很大程度上是一个个人风格的问题,以及你对每种方法有多熟悉。
列表 7.20 most_reliable_drive_for_capacity() 函数
def most_reliable_drive_for_capacity(data, capacity_GB=2048, precision=0.25, top_n=3):
"""Returns the top 3 drives for a given approximate capacity.
Given a capacity in GB and a precision as a decimal number, we keep the N
drives where:
- the capacity is between (capacity * 1/(1+precision)), capacity * (1+precision)
- the failure rate is the lowest
"""
capacity_min = capacity_GB / (1 + precision)
capacity_max = capacity_GB * (1 + precision)
answer = (
data.where(f"capacity_GB between {capacity_min} and {capacity_max}")❶
.orderBy("failure_rate", "capacity_GB", ascending=[True, False])
.limit(top_n) ❷
)
return answer
most_reliable_drive_for_capacity(summarized_data, capacity_GB=11176.0).show()
# +--------------------+-----------+--------------------+
# | model|capacity_GB| failure_rate|
# +--------------------+-----------+--------------------+
# |HGST HUH721010ALE600| 9314.0| 0.0|
# |HGST HUH721212ALN604| 11176.0|1.088844437497695E-5|
# |HGST HUH721212ALE600| 11176.0|1.528677999266234...|
# +--------------------+-----------+--------------------+
❶ 我在 where() 方法中使用了 SQL 风格的表达式,而不需要使用任何其他特殊语法或方法。
❷ 由于我们想要返回前 N 个结果,而不仅仅是显示它们,所以我使用 limit() 而不是 show()。
7.6 结论
你不需要学习或使用 SQL 就能有效使用 PySpark。话虽如此,由于数据操作 API 与 SQL 共享了如此多的词汇和功能,如果你对语法和查询结构有基本的了解,你将会有更多富有成效的时间使用 PySpark。
我的家庭说英语和法语,有时你并不总是知道一种语言从哪里开始,在哪里结束。我倾向于用两种语言思考,有时在单个句子中混合它们。同样,我发现一些问题用 Python 更容易解决,而另一些则更适合 SQL 的领域。你也会找到自己的平衡点,这就是为什么有这个选项是件好事。就像口语语言一样,目标是以尽可能清晰的方式表达你的思想和意图,同时考虑到你的听众。
概述
-
Spark 提供了一个用于数据操作的数据 API。此 API 支持 ANSI SQL。
-
Spark(以及通过扩展的 PySpark)从 SQL 操作表的方式中借用了许多词汇和预期的功能。这一点在数据操作模块被称为
pyspark.sql时尤为明显。 -
PySpark 的数据帧在可以使用 Spark SQL 查询之前需要注册为视图或表。你可以给它们一个不同于你注册的数据帧的名字。
-
PySpark 自己的数据帧操作方法和函数大部分借鉴了 SQL 功能。一些例外,如
union(),在 API 中有文档说明。 -
可以通过
spark.sql函数将 Spark SQL 查询插入到 PySpark 程序中,其中spark是正在运行的SparkSession。 -
Spark SQL 表引用保存在
Catalog中,它包含所有 Spark SQL 可访问表的元数据。 -
PySpark 接受
where()、expr()和selectExpr()中的 SQL 风格子句,这可以简化复杂过滤和选择的语法。 -
当使用 Spark SQL 查询与用户提供的输入时,请注意清理输入以避免潜在的 SQL 注入攻击。
额外练习
练习 7.2
如果我们查看下面的代码,我们可以进一步简化它,并避免直接创建两个表。你能写一个summarized_data,而不需要使用除了full_data之外的任何表,也不需要连接吗?(加分:尝试使用纯 PySpark,然后纯 Spark SQL,最后是两者的组合。)
full_data = full_data.selectExpr(
"model", "capacity_bytes / pow(1024, 3) capacity_GB", "date", "failure"
)
drive_days = full_data.groupby("model", "capacity_GB").agg(
F.count("*").alias("drive_days")
)
failures = (
full_data.where("failure = 1")
.groupby("model", "capacity_GB")
.agg(F.count("*").alias("failures"))
)
summarized_data = (
drive_days.join(failures, on=["model", "capacity_GB"], how="left")
.fillna(0.0, ["failures"])
.selectExpr("model", "capacity_GB", "failures / drive_days failure_rate")
.cache()
)
练习 7.3
本章的分析存在缺陷,因为未考虑驱动器的年龄。与其按故障率对模型进行排序,不如按平均故障年龄排序(假设如果它们仍然存活,每个驱动器都会在报告的最大日期上失败)。(提示:记住,你需要首先计算每个驱动器的年龄。)
练习 7.4
Backblaze 在每个月初记录的总容量(TB)是多少?
练习 7.5
注意:在第十章中,我们看到了一个使用窗口函数解决这个问题的更优雅的方法。在此期间,这个练习可以通过谨慎使用group by和连接来解决。
如果你查看数据,你会发现一些驱动模型可能会报告错误的容量。在数据准备阶段,重新调整full_data数据帧,以便使用每个驱动器最常见的容量。
¹ with语句通常与需要在结束时清理的资源一起使用。它在这里并不适用,但我认为这个比较值得一提。
8 使用 Python 扩展 PySpark:RDD 和 UDFs
本章涵盖
-
使用 RDD 作为低级、灵活的数据容器
-
使用高阶函数在 RDD 中操作数据
-
如何将常规 Python 函数提升为 UDFs 以分布式方式运行
-
如何在本地数据上应用 UDFs 以简化调试
到目前为止,我们与 PySpark 的旅程证明它是一个强大且多用途的数据处理工具。到目前为止,我们已经探索了许多现成的函数和方法来操作数据框中的数据。回想一下第一章,PySpark 的数据框操作功能将我们的 Python 代码应用于优化的查询计划。这使得我们的数据作业高效、一致且可预测,就像在框内上色一样。如果我们需要偏离脚本并根据我们自己的规则操作数据怎么办?
在本章中,我介绍了 PySpark 提供的两种用于分发 Python 代码的机制。换句话说,我们离开了 pyspark.sql 提供的函数和方法集;相反,我们使用 PySpark 作为方便的分发引擎,在纯 Python 中构建我们自己的转换集。为此,我们从 弹性分布式数据集(或 RDD)开始;RDD 类似于数据框,但分布的是无序对象而不是记录和列。这种以对象为先的方法与数据框的更刚性模式相比提供了更多的灵活性。其次,我介绍了 UDFs,这是一种将常规 Python 函数提升为可用于数据框的简单方法。
RDD,过时了吗?
随着数据框的出现,它拥有更好的性能和针对常见数据操作(select、filter、groupby、join)的简化 API,RDD 在流行度方面落后了。在现代 PySpark 程序中还有 RDD 的空间吗?
虽然随着 Spark 版本的发布,数据框变得越来越灵活,但 RDD 在灵活性方面仍然占据主导地位。RDD 特别在两个用例中表现出色:
-
当你有一个可以序列化的无序 Python 对象集合(这是 Python 调用对象序列化的方式;参见
mng.bz/M2X7) -
当你有无序的
key、value对,就像 Python 字典中一样
本章涵盖了这两个用例。默认情况下,数据框应该是你的首选结构,但要知道,如果你觉得它限制性太强,RDD 总是为你准备的。
8.1 PySpark,自由风格:RDD
本节涵盖了 RDD。更具体地说,我介绍了如何推理数据结构以及操作其中数据的 API。

图 8.1 collection_rdd。容器中的每个对象都是独立的——没有列,没有结构,没有模式。
与数据框不同,我们的数据操作工具套件大多围绕列展开,而 RDD 围绕对象:我认为 RDD 是一个没有顺序或相互关系的元素集合。每个元素都是独立的。实验 RDD 的最简单方法是从 Python 列表创建一个 RDD。在列表 8.1 中,我创建了一个包含多种类型对象的列表,然后通过parallelize方法将其提升为 RDD。结果 RDD 如图 8.1 所示。RDD 的创建过程将列表上的对象序列化(或快照),然后在工作节点之间分配它们。
列表 8.1 将 Python 列表提升为 RDD
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
collection = [1, "two", 3.0, ("four", 4), {"five": 5}] ❶
sc = spark.sparkContext ❷
collection_rdd = sc.parallelize(collection) ❸
print(collection_rdd)
# ParallelCollectionRDD[0] at parallelize at PythonRDD.scala:195 ❹
❶ 我的集合是一个包含整数、字符串、浮点数、元组和字典的列表。
❷ RDD 函数和方法位于 SparkContext 对象下,可以通过我们的 SparkSession 的属性访问。为了方便,我将其别名为 sc。
❸ 使用 SparkContext 的 parallelize 方法将列表提升为 RDD。
❹ 我们集合 _rdd 对象实际上是一个 RDD。PySpark 在打印对象时返回集合的类型。
与数据框相比,RDD 在它接受的类型方面更加自由式(请原谅 90 年代的引用)。如果我们试图将整数、字符串、浮点数、元组和字典存储在单个列中,数据框将(并失败)找不到一个共同的基础来适应这些不同类型的数据。
在本节中,我们检查了 RDD 的一般吸引力,并见证了它在将多种类型的数据摄入单一容器抽象中的灵活性。下一节将介绍在 RDD 中操作数据;巨大的灵活性伴随着巨大的责任!
8.1.1 以 RDD 方式操作数据:map(), filter(), 和 reduce()
本节解释了使用 RDD 进行数据操作的基本构建块。我讨论了高阶函数的概念,并使用它们来转换数据。我以对 MapReduce 的快速概述结束,MapReduce 是大规模数据处理的一个基本概念,并将其置于 Spark 和 RDD 的上下文中。
使用 RDD 操作数据就像作为一名将军下达军队的命令:你的士兵/师/集群现场完全服从你的指挥,但如果你下达了不完整或错误的命令,你会在你的部队/RDD 中造成混乱。此外,每个师都有自己的特定类型命令可以执行,而你没有一个提醒来区分这些命令(与数据框模式不同)。听起来像是一项有趣的工作。
RDD 提供了许多方法(你可以在pyspark.RDD对象的 API 文档中找到),但我们专注于三个特定的方法:map()、filter()和reduce()。这三个方法共同捕捉了使用 RDD 进行数据操作的精髓;了解这三个方法的工作原理将为你理解其他方法提供必要的基石。
map()、filter() 和 reduce() 都接受一个函数(我们将称之为 f)作为它们的唯一参数,并返回一个具有所需修改的 RDD 的副本。我们将接受其他函数作为参数的函数称为 高阶函数。第一次遇到它们时可能有点难以理解;不要担心,在看到它们实际应用后,你将非常舒适地在 PySpark(以及如果你查看附录 C,Python)中使用它们。
将一个函数应用到每个对象:map
我们从最基本和最常见操作开始:将 Python 函数应用到 RDD 的每个元素。为此,PySpark 提供了 map()。这直接反映了 Python 中 map() 函数的功能。
最好的说明 map() 的方式是通过一个例子。在列表 8.2 中,我应用了一个将 1 添加到其参数的函数,在 RDD 的每个对象中。通过 map() 应用函数后,我 collect() RDD 的每个元素到一个 Python 列表中以打印它们——或者看起来是这样;我们得到一个与其伴随的堆栈跟踪的错误。发生了什么?
列表 8.2 将简单函数 add_one() 映射到 RDD 的每个元素
from py4j.protocol import Py4JJavaError
def add_one(value):
return value + 1 ❶
collection_rdd = collection_rdd.map(add_one) ❷
try:
print(collection_rdd.collect()) ❸
except Py4JJavaError:
pass
# Stack trace galore! The important bit, you'll get one of the following:
# TypeError: can only concatenate str (not "int") to str
# TypeError: unsupported operand type(s) for +: 'dict' and 'int'
# TypeError: can only concatenate tuple (not "int") to tuple
❶ 一个看似无害的函数,add_one() 将 1 添加到作为参数传递的值。
❷ 我通过 map() 方法将我的函数应用到 RDD 中的每个元素。
❸ collect() 方法在主节点上将 RDD 转换为 Python 列表。
要理解为什么我们的代码失败,我们将分解映射过程,如图 8.2 所示。我通过将 add_one() 函数作为参数传递给 map() 方法,将其应用到 RDD 中的每个元素。add_one() 是一个常规的 Python 函数,应用于常规的 Python 对象。由于我们有不兼容的类型(例如,在 Python 中 "two" + 1 不是一个合法的操作),我们的三个元素是 TypeError。当我 collect() RDD 来查看值时,它在我的 REPL 中爆炸成堆栈跟踪。

图 8.2 通过 map() 应用 add_one() 函数到 RDD 的每个元素。如果函数无法应用,则在动作时间将引发错误。
注意:RDD 是一个惰性集合。如果你在函数应用中有一个错误,它将不会在你执行动作(例如,collect())之前可见,就像数据框一样。
幸运的是,由于我们使用 Python,我们可以使用 try/except 块来防止错误。我在下一个列表中提供了一个改进的 safer_add_one() 函数,如果函数遇到类型错误,则返回原始元素。
列表 8.3 将 safer_add_one() 函数映射到 RDD 中的每个元素
collection_rdd = sc.parallelize(collection) ❶
def safer_add_one(value):
try:
return value + 1
except TypeError:
return value ❷
collection_rdd = collection_rdd.map(safer_add_one)
print(collection_rdd.collect())
# [2, 'two', 4.0, ('four', 4), {'five': 5}] ❸
❶ 我从头开始重新创建我的 RDD,以移除 thunk 中的错误操作(参见第一章中对计算 thunk 的描述)。
❷ 如果函数遇到 TypeError,则返回原始值不变。
❸ RDD 的相关元素已增加 1。
总结来说,你使用 map() 将函数应用于 RDD 的每个元素。由于 RDD 的灵活性,PySpark 不会给你关于 RDD 内容的任何保障。作为开发者,你需要确保你的函数无论输入如何都足够健壮。在下一节中,我们将过滤 RDD 的一些元素。
仅保留你想要的元素使用 filter
就像我在介绍数据框时首先介绍的方法之一是 where() 或 filter() 一样,对于 RDD 我们也有同样的操作。filter() 用于保留满足谓词的元素。RDD 版本的 filter() 与数据框版本略有不同:它接受一个函数 f,该函数应用于每个对象(或元素),并仅保留返回真实值的那些元素。在列表 8.4 中,我使用 lambda 函数过滤我的 RDD,仅保留整数和浮点元素。isinstance() 函数在第一个参数的类型存在于第二个参数中时返回 True;在我们的情况下,它将测试每个元素是否为 float 或 int。
列表 8.4 使用 lambda 函数过滤我们的 RDD
collection_rdd = collection_rdd.filter(
lambda elem: isinstance(elem, (float, int))
)
print(collection_rdd.collect())
# [2, 4.0]
像专业人士一样使用 lambda 函数
Lambda 函数是当你需要一个仅用于一次的简单函数时减少样板代码的好方法。本质上,lambda 函数允许你创建一个没有名称的函数。在下面的图中,我展示了使用 def 关键字定义的 命名 函数与使用 lambda 关键字定义的 lambda(或 匿名)函数之间的对应关系。两个语句都返回一个函数对象,然后可以用来使用:命名函数可以通过其名称(is_a_number)引用,而 lambda 函数通常定义在需要使用它的地方。在列表 8.4 中的 filter() 应用中,我们可以用 is_a_number 函数替换我们的 lambda,但直接使用 lambda 函数作为 filter() 的参数可以节省一些按键。

使用 lambda 关键字将简单函数转换为 lambda 函数
Lambda 函数与 RDD 中的高阶函数(如 map() 和 filter())结合使用非常有用。通常,应用于元素的函数只会使用一次,所以没有必要将它们分配给变量。如果你不习惯使用 lambda 函数,不用担心;仍然可以创建一个小函数并将其应用于你的 RDD。
就像 map() 一样,传递给 filter() 的函数应用于 RDD 中的每个元素。不过,这一次,我们不是在新的 RDD 中返回结果,而是在函数的结果为真值时保留原始值。如果结果为假值,则丢弃该元素。我在图 8.3 中展示了 filter() 操作的分解。

图 8.3 过滤我们的 RDD 以保留 int 和 float。我们的谓词函数逐元素应用,并且仅保留导致真值谓词的值。
Python 中的真/假值
当与数据框一起工作时,filter()(数据框方法)的参数需要显式返回布尔值:要么是 True,要么是 False。在本章中,使用 RDD 时,我们遵循 Python 的布尔测试约定;因此,在讨论 RDD 中的过滤时,我避免使用绝对的 True/False,因为 Python 有自己的方式来确定值是否是“真值”(它将被 Python 考虑为 True)。作为提醒,False、0(任何 Python 数值类型中的数字零)、空序列和集合(列表、元组、字典、集合、范围)都是假值。有关 Python 在非布尔类型上如何推断布尔值的更多解释,请参阅 Python 文档 (mng.bz/aDoz)。
现在我们可以映射并过滤我们的 RDD 中的元素。我们如何将它们聚合到汇总值中?如果你认为 RDD 再次提供了一个非常通用的方法来汇总它包含的元素,你是正确的!准备好进行归约了吗?
两个元素进来,一个元素出来:reduce()
本节介绍了 RDD 的最后一个重要操作,该操作允许使用数据框对数据进行汇总(类似于 groupby()/agg())。reduce(),正如其名称所暗示的,用于减少 RDD 中的元素。通过“减少”,我的意思是取两个元素并应用一个函数,该函数将只返回一个元素。PySpark 将函数应用于前两个元素,然后再次将其应用于结果和第三个元素,依此类推,直到没有元素为止。我发现当视觉解释时,这个概念更容易理解,所以图 8.4 展示了使用 reduce() 求和 RDD 中元素值的过程。下面的列表展示了如何在数据框上使用 reduce() 方法。

图 8.4 通过求和 RDD 元素的值来减少我们的 RDD
列表 8.5 通过 reduce() 应用 add() 函数
from operator import add ❶
collection_rdd = sc.parallelize([4, 7, 9, 1, 3])
print(collection_rdd.reduce(add)) # 24
❶ operator 模块包含函数版本或常用运算符,例如 + (add()),因此我们不需要传递 lambda a, b: a + b。
map(), filter(), 和 reduce() 初看起来像是简单的概念:它们接受一个函数并将其应用于集合内的所有元素。结果根据选择的方法不同而有所不同,reduce() 需要一个返回单个值的两个参数的函数。在 2004 年,Google 通过发布其 MapReduce 框架 (research.google/pubs/pub62/),利用了这个朴素的概念,在大型数据处理领域引发了一场革命。在这里,关于命名无可争议:这个名字是 map() 和 reduce() 的组合。这个框架直接启发了大数据框架,如 Hadoop 和 Spark。尽管现代抽象,如数据框,并不像原始的 MapReduce 那么接近,但思想仍然存在,对构建块的高层次理解将使理解一些高级设计选择变得更容易。
分布式世界中的 reduce()
由于 PySpark 的分布式特性,RDD 的数据可以分布在多个分区上。reduce() 函数将在每个分区上独立应用,然后每个中间值将被发送到主节点进行最终减少。因此,你需要为 reduce() 提供一个交换律和结合律的函数。
一个 交换律 函数是一个其中参数应用的顺序并不重要的函数。例如,add() 是交换律的,因为 a + b = b + a。另一方面,subtract() 不是:a - b != b - a。
一个 结合律 函数是一个其中值的分组方式并不重要的函数。add() 是结合律的,因为 (a + b) + c = a + (b + c)。subtract() 不是:(a - b) - c != a - (b - c)。
add(), multiply(), min(), 和 max() 既是结合律的也是交换律的。
这就结束了我们对 PySpark RDD API 的快速浏览。我们介绍了 RDD 如何通过 map(), filter(), 和 reduce() 等高阶函数对其元素应用转换。这些高阶函数将作为参数传递的函数应用于每个元素,使 RDD 成为 元素主要(或 行主要)。如果你对 RDD 的其他应用感兴趣,我建议查看 PySpark 在线 API 文档。RDD 中的大多数方法在数据框中都有直接对应的方法,或者直接从 map(), filter(), 和 reduce() 的使用中发展而来。接下来的几节将建立在直接应用 Python 函数的概念之上,但这次是在数据框上。乐趣从这里开始!
练习 8.1
PySpark RDD API 提供了一个 count() 方法,该方法返回 RDD 中的元素数量作为一个整数。使用 map(), filter(), 和/或 reduce() 重新生成此方法的行为。
练习 8.2
以下代码块的返回值是什么?
a_rdd = sc.parallelize([0, 1, None, [], 0.0])
a_rdd.filter(lambda x: x).collect()
a) [1]
b) [0, 1]
c) [0, 1, 0.0]
d) []
e) [1, []]
可选主题:回到起点,数据框是一个 RDD!
要展示 RDD 的最终灵活性,看看这个:你可以通过数据帧的 rdd 属性访问数据帧内的隐式 RDD。
列表 8.6 使用 rdd 属性在数据帧内部发现 RDD
df = spark.createDataFrame([[1], [2], [3]], schema=["column"])
print(df.rdd)
# MapPartitionsRDD[22] at javaToPython at NativeMethodAccessorImpl.java:0
print(df.rdd.collect())
# [Row(column=1), Row(column=2), Row(column=3)]
从 PySpark 的角度来看,数据帧也是一个 RDD[Row](来自 pyspark.sql.Row),其中每一行可以被视为一个字典;键是列名,值是记录中包含的值。要执行相反的操作,你可以将 RDD 传递给 spark.createDataFrame 并可选地指定模式。记住,当你从数据帧移动到 RDD 时,你放弃了数据帧的模式安全性!
根据你希望执行的操作,在数据帧和 RDD 之间来回移动可能会很有吸引力。请记住,这将会带来性能成本(因为你要将数据从列主序转换为行主序),但也会使你的代码更难理解。你还需要确保在将你的 RDD 放入数据帧之前,所有的 Row 都遵循相同的模式。通常,如果你偶尔需要 RDD 的强大功能,但又希望享受数据帧的列属性,UDF 是一个不错的选择。幸运的是,这正是下一节的主题。
8.2 使用 Python 通过 UDFs 扩展 PySpark
在 8.1 节中,我们体验了 RDD 方法在数据处理方面的灵活性。本节将同样的一个问题——我们如何在我们数据上运行 Python 代码?——应用到数据帧上。更具体地说,我们关注 map() 转换:对于每个输入的记录,输出一个记录。Map 类型的转换到目前为止是最常见且最容易实现的。
与 RDD 不同,数据帧由列强制执行的结构。为了解决这个限制,PySpark 提供了通过 pyspark.sql.functions.udf() 函数创建 UDF 的可能性。输入的是一个常规的 Python 函数,输出的是一个提升到可以在 PySpark 列上工作的函数。
为了说明这一点,我们将模拟一个 PySpark 中不存在的数据类型:Fraction。分数由分子和分母组成。在 PySpark 中,我们将它表示为两个整数的数组。在下一个列表中,我创建了一个包含两个列的数据帧,这两个列分别代表分子和分母。我通过 array() 函数将这两个列融合到一个数组列中。
列表 8.7 创建包含单个数组列的数据帧
import pyspark.sql.functions as F
import pyspark.sql.types as T
fractions = [[x, y] for x in range(100) for y in range(1, 100)] ❶
frac_df = spark.createDataFrame(fractions, ["numerator", "denominator"])
frac_df = frac_df.select(
F.array(F.col("numerator"), F.col("denominator")).alias( ❷
"fraction"
),
)
frac_df.show(5, False)
# +--------+
# |fraction|
# +--------+
# |[0, 1] |
# |[0, 2] |
# |[0, 3] |
# |[0, 4] |
# |[0, 5] |
# +--------+
# only showing top 5 rows
❶ 我将分母的范围从 1 开始,因为分母为 0 的分数是未定义的。
❷ array() 函数接受两个或更多相同类型的列,并创建一个包含作为参数传递的列的数组的单列。
为了支持我们新的临时分数类型,我们创建了一些提供基本功能的功能。这对于 Python UDF 是一个完美的任务,我借此机会介绍 PySpark 使其创建的两种方式。
8.2.1 一切从纯 Python 开始:使用类型化 Python 函数
本节介绍创建一个与 PySpark 数据帧无缝工作的 Python 函数。虽然 Python 和 Spark 通常可以无缝协作,但创建和使用 UDF 需要一些预防措施。我介绍如何使用 Python 类型提示来确保你的代码将与 PySpark 类型无缝工作。在本节的末尾,我们将有一个用于化简分数的函数和一个将分数转换为浮点数的函数。
当创建一个注定要成为 Python UDF(用户定义函数)的函数时,我的蓝图如下:
-
创建并记录函数。
-
确保输入和输出类型兼容。
-
测试函数。
对于本节,我提供了一些断言以确保函数按预期行为。
每个 UDF 背后都有一个 Python 函数,因此我们的两个函数在列表 8.8 中。我在这个代码块中介绍了 Python 类型注解;本节的其余部分涵盖了它们在这个上下文中的应用以及为什么它们与 Python UDF 结合时是一个强大的工具。
列表 8.8 创建我们的三个 Python 函数
from fractions import Fraction ❶
from typing import Tuple, Optional ❷
Frac = Tuple[int, int] ❸
def py_reduce_fraction(frac: Frac) -> Optional[Frac]: ❹
"""Reduce a fraction represented as a 2-tuple of integers."""
num, denom = frac
if denom:
answer = Fraction(num, denom)
return answer.numerator, answer.denominator
return None
assert py_reduce_fraction((3, 6)) == (1, 2) ❺
assert py_reduce_fraction((1, 0)) is None
def py_fraction_to_float(frac: Frac) -> Optional[float]:
"""Transforms a fraction represented as a 2-tuple of integers into a float."""
num, denom = frac
if denom:
return num / denom
return None
assert py_fraction_to_float((2, 8)) == 0.25
assert py_fraction_to_float((10, 0)) is None
❶ 我们依赖fractions模块中的Fraction数据类型来避免重新发明轮子。
❷ 一些特定的类型需要导入才能使用:标准库包含标量值的类型,但像 Option 和 Tuple 这样的容器需要显式导入。
❸ 我们创建了一个类型同义词:Frac。这相当于告诉 Python/mypy,“当你看到 Frac 时,假设它是一个 Tuple[int, int]”(一个包含两个整数的元组)。这使得类型注解更容易阅读。
❹ 我们的函数接受一个 Frac 作为参数,并返回一个Optional[Frac],这相当于“一个 Frac 或 None。”
❺ 我创建了一些断言来验证我的代码并确保我得到预期的行为。
两个函数都很相似,所以我将以py_reduce_fraction为例,逐行分析。
我的函数定义有几个新元素。frac参数有一个: Frac,在冒号之前有一个-> Optional[Frac]。这些添加是类型注解,是确保函数接受和返回我们期望内容的惊人工具。Python 是一种动态语言;这意味着对象的类型是在运行时知道的。当与 PySpark 的数据帧一起工作时,其中每个列只有一个类型,我们需要确保我们的 UDF 将返回一致的类型。我们可以使用类型提示来确保这一点。
Python 的类型检查可以通过多个库启用;mypy就是其中之一。你可以通过pip install mypy来安装它。一旦安装,你就可以使用mypy MY_FILE.py在你的文件上运行mypy。附录 C 包含了对typing模块和mypy的深入介绍,以及它们如何应用于(以及为什么应该应用于)UDF 之外。我会在相关的地方添加类型注解,因为它们可以作为有用的文档,同时使代码更加健壮。(我的函数期望什么?它返回什么?)
在我的函数定义中,我宣布 frac 函数参数的类型为 Frac,它等同于 Tuple[int, int] 或包含两个整数的两个元素元组。如果我与他人分享我的代码,这种类型注解会向他人发出关于我的函数输入类型的信号。此外,如果我将不兼容的参数传递给我的函数,mypy 将会抱怨。如果我尝试执行 py_reduce_fraction("one half"),mypy 将告诉我以下内容:
error: Argument 1 to "py_reduce_fraction" has incompatible type "str"; expected "Tuple[int, int]"
我已经可以看到类型错误正在消失。
第二个类型注解位于函数参数之后,并以前缀箭头开头,是关于函数的返回类型。我们识别 Frac,但这次,我将它包装在 Optional 类型中。
在 8.1 节中,当创建要在 RDD 上分布的函数时,我需要确保它们不会触发错误并返回 None。我在这里应用了相同的概念。我检查 denom 是否是一个真实值:如果它等于零,我返回 None。这是一个如此常见的用例,以至于 Python 提供了 Optional[...] 类型,这意味着括号内的类型或 None。PySpark 会接受 None 值作为 null。
类型注解:额外的按键,更少的错误
类型注解开箱即用就非常有用,但与 Python UDF 一起使用时尤其巧妙。由于 PySpark 的执行模型是懒加载的,你通常会在动作执行时得到错误堆栈跟踪。UDF 堆栈跟踪并不比 PySpark 中的任何其他堆栈跟踪更难阅读——这并不是说很多——但绝大多数错误都是由于不良的输入或返回值引起的。类型注解不是万能的银弹,但它是避免和诊断类型错误的一个很好的工具。
函数的其余部分相对简单:我将分子和分母摄入一个 Fraction 对象中,该对象会化简分数。然后我从 Fraction 中提取分子和分母,并将它们作为两个整数的元组返回,正如我在返回类型注解中所承诺的那样。
我们有两个具有明确定义输入和输出类型的函数。在下一节中,我将展示如何将常规 Python 函数提升为 UDF 并将其应用于你的数据框。
8.2.2 使用 udf() 从 Python 函数到 UDFs
一旦你创建了你的 Python 函数,PySpark 提供了一种简单的机制来将其提升为 UDF。本节涵盖了 udf() 函数及其如何直接用于创建 UDF,以及使用装饰器简化 UDF 的创建。
PySpark 在 pyspark.sql.functions 模块中提供了一个 udf() 函数,用于将 Python 函数提升为其 UDF 等效物。该函数接受两个参数:
-
你想要提升的函数
-
生成的 UDF 的返回类型
在表 8.1 中,我总结了 Python 和 PySpark 之间的类型等效性。如果你提供了返回类型,它必须与你的 UDF 的返回值兼容。
表 8.1 PySpark 中类型总结。在 Python 等效 列旁边有一个星号表示 Python 类型更精确或可以包含更大的值,因此在使用返回的值时需要小心。
| 类型构造函数 | 字符串表示 | Python 等价类型 |
|---|---|---|
NullType() |
null |
None |
StringType() |
string |
Python 的常规字符串 |
BinaryType() |
binary |
bytearray |
BooleanType() |
boolean |
bool |
DateType() |
date |
datetime.date(来自 datetime 库) |
TimestampType() |
timestamp |
datetime.datetime(来自 datetime 库) |
DecimalType(p,s) |
decimal |
decimal.Decimal(来自 decimal 库)* |
DoubleType() |
double |
float |
FloatType() |
float |
float* |
ByteType() |
byte 或 tinyint |
int* |
IntegerType() |
int |
int* |
LongType() |
long 或 bigint |
int* |
ShortType() |
short 或 smallint |
int* |
ArrayType(T) |
N/A | list, tuple, 或 Numpy 数组(来自 numpy 库) |
MapType(K, V) |
N/A | dict |
StructType([...]) |
N/A | list 或 tuple |
在列表 8.9 中,我通过 udf() 函数将 py_reduce_fraction() 函数提升为 UDF。就像我处理 Python 等效函数一样,我为 UDF 提供了一个返回类型(这次是一个 Long 的 Array,因为 Array 是元组的伴随类型,而 Long 是 Python 整数的类型)。一旦创建了 UDF,我们就可以像对任何其他 PySpark 函数一样在列上应用它。我选择创建一个新列来展示前后变化;在示例中,分数被正确约简。
列表 8.9 使用 udf() 函数显式创建 UDF
SparkFrac = T.ArrayType(T.LongType()) ❶
reduce_fraction = F.udf(py_reduce_fraction, SparkFrac) ❷
frac_df = frac_df.withColumn(
"reduced_fraction", reduce_fraction(F.col("fraction")) ❸
)
frac_df.show(5, False)
# +--------+----------------+
# |fraction|reduced_fraction|
# +--------+----------------+
# |[0, 1] |[0, 1] |
# |[0, 2] |[0, 1] |
# |[0, 3] |[0, 1] |
# |[0, 4] |[0, 1] |
# |[0, 5] |[0, 1] |
# +--------+----------------+
# only showing top 5 rows
❶ 我将 PySpark 的长类型数组别名为 SparkFrac 变量。
❷ 我使用 udf() 函数提升我的 Python 函数,并将我的 SparkFrac 类型别名作为返回类型。
❸ UDF 可以像任何其他 PySpark 列函数一样使用。
您还可以创建自己的 Python 函数,并使用 udf 函数作为装饰器将其提升为 UDF。装饰器 是通过函数定义上方 @ 符号应用于其他函数的函数(我们称该函数为 装饰)。这允许改变函数的行为——在这里,我们从一个常规的 Python 函数定义中创建一个 UDF——而无需编写大量样板代码(更多信息请参阅附录 C)。在列表 8.10 中,我通过在函数定义前加上 @F.udf([return_type]) 直接将 py_fraction_to_float()(现在简称为 fraction_to_float())定义为 UDF。在两种情况下,您都可以通过调用属性 frac 从 UDF 访问底层函数。
列表 8.10 使用 udf() 装饰器直接创建 UDF
@F.udf(T.DoubleType()) ❶
def fraction_to_float(frac: Frac) -> Optional[float]:
"""Transforms a fraction represented as a 2-tuple of integers into a float."""
num, denom = frac
if denom:
return num / denom
return None
frac_df = frac_df.withColumn(
"fraction_float", fraction_to_float(F.col("reduced_fraction"))
)
frac_df.select("reduced_fraction", "fraction_float").distinct().show(
5, False
)
# +----------------+-------------------+
# |reduced_fraction|fraction_float |
# +----------------+-------------------+
# |[3, 50] |0.06 |
# |[3, 67] |0.04477611940298507|
# |[7, 76] |0.09210526315789473|
# |[9, 23] |0.391304347826087 |
# |[9, 25] |0.36 |
# +----------------+-------------------+
# only showing top 5 rows
assert fraction_to_float.func((1, 2)) == 0.5 ❷
❶ 装饰器执行与 udf() 函数相同的功能,但返回一个带有其下定义的函数名称的 UDF。
❷ 为了执行我的断言,我使用 UDF 的 func 属性,该属性返回一个准备就绪的可以调用的函数。
在本章中,我们以可能的最紧密方式将 Python 和 Spark 结合起来。使用 RDD,你对容器内的数据拥有完全的控制权,但你也有责任创建函数并使用 map()、filter() 和 reduce() 等高阶函数来处理容器内的数据对象。对于数据框,UDF 是你的最佳工具:你可以将 Python 函数转换为 UDF,它将使用列作为输入和输出,但会牺牲一些性能以将 Spark 和 Python 之间的数据结合在一起。
下一章将进一步提升这一概念:我们将深入研究 PySpark 和 pandas 之间的交互,通过特殊类型的 UDF。PySpark 和 pandas 是一个吸引人的组合,并且能够通过 Spark 框架扩展 Pandas,这将提升这两个库。乐趣与力量在等待!
摘要
-
弹性的分布式数据集(resilient distributed dataset)与数据框的记录和列方法相比,提供了更好的灵活性。
-
在分布式 Spark 环境中运行 Python 代码的最底层和最灵活的方式是使用 RDD。使用 RDD,你的数据没有结构上的限制,需要在程序中管理类型信息,并防御性地编写代码以应对潜在的异常。
-
RDD 上的数据处理 API 严重受到 MapReduce 框架的启发。你在 RDD 的对象上使用高阶函数,如
map()、filter()和reduce()。 -
数据框最基本的 Python 代码提升功能,称为(PySpark)UDF,模拟了 RDD 的“map”部分。你将其用作标量函数,以
Column对象作为参数,并返回一个单一的Column。
额外练习
练习 8.3
使用以下定义,创建一个 temp_to_temp(value, from, to) 函数,它接受 from 度的数值 value 并将其转换为 to 度。
-
C=(F-32)*5/9(摄氏度) -
K=C+273.15(开尔文) -
R=F+459.67(兰金)
练习 8.4
修正以下 UDF,使其不会产生错误。
@F.udf(T.IntegerType())
def naive_udf(t: str) -> str:
return answer * 3.14159
练习 8.5
创建一个 UDF(用户定义函数),用于将两个分数相加,并通过在 test_frac 数据框中将 reduced_fraction 加到自身来测试它。
练习 8.6
由于 LongType(),如果分子或分母超过 pow(2, 63)-1 或低于 -pow(2, 63),则 py_reduce_fraction(见前一个练习)将无法工作。修改 py_reduce_fraction,使其在这种情况下返回 None。
附加题:这会改变提供的类型注解吗?为什么?
9 大数据只是很多小数据:使用 pandas UDFs
本章涵盖了
-
使用 pandas Series UDFs 相比 Python UDFs 加速列转换
-
使用 Series UDF 的迭代器解决一些 UDF 的冷启动问题
-
在 split-apply-combine 编程模式中控制批量组成
-
有信心地决定使用最佳的 pandas UDF
本章以不同的方式探讨了 PySpark 的分布式特性。如果我们花几秒钟思考一下,我们会将数据读入一个数据框,Spark 将数据分布在节点上的分区中。如果我们能直接操作这些分区,就像它们是单节点数据框一样呢?更有趣的是,如果我们能通过一个我们知道的工具来控制这些单节点分区是如何创建和使用的,那会怎么样?pandas 呢?
当进行大规模数据分析时,PySpark 与 pandas(口语中也称为 pandas UDF)的互操作性是一个巨大的卖点。pandas 是内存中 Python 数据操作库的领导者,而 PySpark 是分布式操作的主流。结合两者可以解锁额外的可能性。在本章中,我们首先扩展了一些基本的 pandas 数据操作功能。然后我们探讨了 GroupedData 上的操作以及 PySpark 和 Pandas 如何实现数据分析中常见的 split-apply-combine 模式。最后,我们完成了 pandas 和 PySpark 之间的最终交互:将 PySpark 数据框视为一个小的 pandas DataFrames 集合。
本章显然很好地使用了 pandas (pandas.pydata.org) 库。广泛的 pandas 知识是很好的,但并不是必需的。本章将涵盖在基本 pandas UDF 中使用的必要 pandas 技能。如果你想提高你的 pandas 技能,成为 pandas UDF 大师,我强烈推荐 Boris Paskhaver(Manning,2021)的《Pandas in Action》一书。
两个版本的传说
PySpark 3.0 完全改变了我们与 pandas UDF API 的交互方式,并添加了许多功能性和性能改进。因此,我考虑到 PySpark 3.0 来构建这一章。对于那些使用 PySpark 2.3 或 2.4 的人来说,我在适用的情况下添加了一些侧边栏,其中包含了方便的适当语法。
pandas UDF 是在 PySpark 2.3 中引入的。如果你使用的是 Spark 2.2 或更早的版本,那么你就不幸了!
对于本章中的示例,你需要三个之前未使用的库:pandas、scikit-learn 和 PyArrow。如果你已经安装了 Anaconda(见附录 B),你可以使用 conda 安装这些库;否则,你可以使用 pip。¹
如果你使用的是 Spark 2.3 或 2.4,你还需要在 Spark 根目录的 conf/spark-env.sh 文件中设置一个标志,以处理 Arrow 序列化格式的变化。Spark 根目录是我们安装 Spark 时设置的 SPARK_HOME 环境变量所指向的目录,如附录 B 所示。在 conf/ 目录中,你应该找到一个 spark-env.sh.template 文件。创建一个副本,命名为 spark-env.sh,并在文件中添加以下行:
ARROW_PRE_0_15_IPC_FORMAT=1
这将告诉 PyArrow 使用与 Spark 2.0 版本兼容的序列化格式,而不是仅与 Spark 3.0 兼容的新格式。有关更多信息,请参阅 Spark JIRA 工单 (issues.apache.org/jira/browse/SPARK-29367)。你也可以使用 PyArrow 版本 0.14 来避免这个问题:
# Conda installation
conda install pandas scikit-learn pyarrow
# Pip installation
pip install pandas scikit-learn pyarrow
9.1 使用 pandas 进行列转换:使用系列 UDF
在本节中,我们将介绍 pandas UDF 的最简单家族:系列 UDF。这个家族与常规 PySpark 数据转换函数一样,以列优先为焦点。本节中所有我们的 UDF 都将接受一个 Column 对象(或多个对象)作为输入,并返回一个 Column 对象作为输出。在实践中,它们是 UDF 最常见的类型,适用于你想要将已经在 pandas 中实现的功能(或与 pandas 兼容的库)提升到 PySpark 分布式世界的情况。
PySpark 提供了三种类型的系列 UDF。以下是它们的总结;我们将在本节的其余部分进一步探讨:
-
系列到系列 是最简单的。它接受
Columns对象作为输入,将它们转换为 pandasSeries对象(因此得名),并返回一个Series对象,该对象被提升回 PySparkColumn对象。 -
系列到系列迭代器 的不同之处在于,
Column对象被分批处理,然后作为迭代器对象提供。它接受单个Column对象作为输入,并返回单个Column。它提供了性能改进,尤其是在 UDF 需要在处理数据之前初始化一个昂贵的状态时(例如,在 scikit-learn 中创建的本地 ML 模型)。 -
多个系列到系列迭代器 是前面系列 UDF 的组合,可以接受多个
Columns作为输入,就像系列到系列 UDF 一样,同时保留了系列到系列迭代器的迭代模式。
注意:还有一个属于分组聚合 UDF 家族的 系列到标量 UDF。有关更多信息,请参阅第 9.2.1 节。尽管它看起来与前面提到的三个类似,但它服务于不同的目的。
在我们开始探索系列 UDF 之前,让我们获取一个数据集来实验。下一节将介绍如何将 PySpark 连接到 Google BigQuery 的数据集,以有效地从数据仓库中读取数据。
9.1.1 连接 Spark 到 Google 的 BigQuery
本节提供了将 PySpark 连接到 Google 的 BigQuery 的说明,我们将使用美国国家海洋和大气管理局(NOAA)的每日全球表面综合数据集(GSOD)。同样,这也为将 PySpark 连接到其他数据仓库,如 SQL 或 NoSQL 数据库提供了蓝图。Spark 有一个不断增长的连接器列表,用于流行的数据存储和处理解决方案——我们无法合理地涵盖所有这些!——但它通常会遵循与我们使用 BigQuery 相同的步骤:
-
按照供应商的文档安装和配置连接器(如果需要)。
-
修改
SparkReader对象以适应新的数据源类型。 -
读取数据,并在需要时进行身份验证。
您不需要使用 BigQuery
我明白仅为了访问一些数据就创建一个 GCP 帐户可能会有些烦人。我建议您尝试一下——这相当典型地展示了如何将 Spark 与外部数据源连接——但如果您只想熟悉本章内容,请跳过本节的其余部分,并使用存储库中可用的(Parquet)数据:
gsod = (
reduce(
lambda x, y: x.unionByName(y, allowMissingColumns=True),
[
spark.read.parquet(f"./data/gsod_noaa/gsod{year}.parquet")
for year in range(2010, 2021)
],
)
.dropna(subset=["year", "mo", "da", "temp"])
.where(F.col("temp") != 9999.9)
.drop("date")
)
安装和配置连接器
Google BigQuery 是一种无服务器数据仓库引擎,它使用 SQL 语法快速处理数据。Google 为实验提供了一系列公共数据集。在本节中,我们安装和配置 Google Spark BigQuery 连接器,以便直接访问通过 BigQuery 提供的数据。
首先,您需要一个 GCP 帐户。一旦您的帐户创建完成,您需要创建一个服务帐户和一个服务帐户密钥,以便告诉 BigQuery 以编程方式向您提供访问公共数据的权限。为此,选择 服务 帐户(在 IAM & Admin 下)并点击 + 创建 服务 帐户。为您的服务帐户提供一个有意义的名称。在服务帐户权限菜单中,选择 BigQuery → BigQuery admin并点击继续。在最后一步,点击+ CREATE KEY` 并选择 JSON。下载密钥并将其存储在安全的地方(见图 9.1)。

图 9.1 我创建的 BigQuery-DataAnalysisPySpark 服务帐户下的密钥
警告 将此密钥视为任何其他密码。如果恶意人员窃取了您的密钥,请返回“服务帐户”菜单,删除此密钥,并创建一个新的密钥。
在创建帐户并下载密钥后,您现在可以获取连接器。连接器托管在 GitHub 上 (mng.bz/aDZz)。因为它处于积极开发中,安装和使用的说明可能会随时间变化。我鼓励您阅读其 README 文件。现在不需要下载连接器:我们将在下一步让 Spark 负责此事。
通过连接器在 PySpark 和 BigQuery 之间建立连接
现在我们使用连接器和上一节中创建的密钥在 BigQuery 和我们的 PySpark 环境之间建立连接。这将闭合循环,并直接将我们的 PySpark shell 连接到 BigQuery,有效地将其用作外部数据存储。
如果你通过常规 Python shell 使用 PySpark 并使其运行(如附录 B 所示),你需要重新启动你的 Python shell。在这种情况下,仅使用 spark.stop() 并启动一个新的 SparkSession 是不起作用的,因为我们正在向我们的 Spark 安装添加一个新的依赖项。使用新的 Python REPL,我们在列表 9.1 中添加了一个 config 标志:spark.jars.packages。这指示 Spark 获取并安装外部依赖项,在我们的例子中,是 com.google.cloud.spark:spark-bigquery 连接器。由于它是一个 Java/Scala 依赖项,我们需要匹配正确的 Spark 和 Scala 版本(撰写本文时为 Spark 3.2,使用 Scala 2.12);请参阅连接器的 README 以获取最新信息。
当 SparkSession 被实例化时打印的日志消息是自解释的:Spark 从 Maven Central(Java 和 JVM 语言包的中央存储库,类似于当你使用 pip 时 Python 的 PyPI)获取连接器,安装它,并为你的 Spark 实例配置它。无需手动下载!
列表 9.1 在 Python shell 中使用 BigQuery 连接器初始化 PySpark
from pyspark.sql import SparkSession
spark = SparkSession.builder.config(
"spark.jars.packages",
"com.google.cloud.spark:spark-bigquery-with-dependencies_2.12:0.19.1", ❶
).getOrCreate()
# [...]
# com.google.cloud.spark#spark-bigquery-with-dependencies_2.12 added as a dependency
# :: resolving dependencies :: org.apache.spark#spark-submit-parent-77d4bbf3-1fa4-4d43-b5f7-59944801d46c;1.0
# confs: [default]
# found com.google.cloud.spark#spark-bigquery-with-dependencies_2.12;0.19.1 in central
# downloading https://repo1.maven.org/maven2/com/google/cloud/spark/spark-bigquery-with-dependencies_2.12/
0.19.1/spark-bigquery-with-dependencies_2.12-0.19.1.jar ...
# [SUCCESSFUL ] com.google.cloud.spark#spark-bigquery-with-dependencies_2.12;0.19.1!
spark-bigquery-with-dependencies_2.12.jar (888ms)
# :: resolution report :: resolve 633ms :: artifacts dl 889ms
# :: modules in use:
# com.google.cloud.spark#spark-bigquery-with-dependencies_2.12;0.19.1 from central in [default]
# ---------------------------------------------------------------------
# | | modules || artifacts |
# | conf | number| search|dwnlded|evicted|| number|dwnlded|
# ---------------------------------------------------------------------
# | default | 1 | 1 | 1 | 0 || 1 | 1 |
# ---------------------------------------------------------------------
# :: retrieving :: org.apache.spark#spark-submit-parent-77d4bbf3-1fa4-4d43-b5f7-59944801d46c
# confs: [default]
# 1 artifacts copied, 0 already retrieved (33158kB/23ms)
❶ 我在我的电脑上选择了推荐的 Spark/Scala 版本(3.2/2.12)的软件包版本。请检查连接器的存储库以获取最新版本。
现在,PySpark 已连接到 BigQuery,我们只需使用 GCP 的认证密钥读取数据即可。
直接调用 PySpark 时使用连接器
如果你在一个无法动态下载依赖的环境(例如,在公司的防火墙后面),你可以手动下载 jar 文件,并在实例化 SparkSession 时将其位置添加到 spark.jars 配置标志。当使用 pyspark 或 spark-submit 作为启动 REPL 或 PySpark 作业的方式时,另一种方法是使用 --jars 配置标志:
pyspark --jars spark-bigquery-latest.jar
spark-submit --jars spark-bigquery-latest.jar my_job_file.py
如果你正在云中使用 PySpark,请参阅你的提供商文档。每个云提供商都有不同的管理 Spark 依赖项和库的方式。
如果你通过 Python/IPython shell 使用 PySpark,你可以在创建 SparkSession 时直接从 Maven(Java/Scala 的 PyPI 等价物)加载库。
使用我们的密钥从 BigQuery 读取数据
我们现在处于创建 pandas UDF 之前的最后阶段:在我们的环境配置完成后,我们只需读取数据。在本节中,我们组装了位于 BigQuery 中的 10 年天气数据,总计超过 4000 万条记录。
从 BigQuery 读取数据非常简单。在列表 9.2 中,我们使用了由我们嵌入到 PySpark shell 中的连接器库提供的 bigquery 专用 SparkReader,它提供了两种选项:
-
指向我们要导入的表的
table参数。其格式为project.dataset.table;bigquery-public-data是一个对所有用户都可用的项目。 -
credentialsFile是第 9.1.1 节中下载的 JSON 密钥。您需要根据文件的位置调整路径和文件名。
列表 9.2 读取 2010 年到 2020 年的stations和gsod表
from functools import reduce
import pyspark.sql.functions as F
def read_df_from_bq(year): ❶
return (
spark.read.format("bigquery").option( ❷
"table", f"bigquery-public-data.noaa_gsod.gsod{year}" ❸
)
# .option("credentialsFile", "bq-key.json") ❹
.load()
)
gsod = (
reduce(
lambda x, y: x.unionByName(y, allowMissingColumns=True),
[read_df_from_bq(year) for year in range(2010, 2021)], ❺
)
.dropna(subset=["year", "mo", "da", "temp"])
.where(F.col("temp") != 9999.9)
.drop("date")
)
❶ 由于所有表都是用相同的方式读取的,我将我的读取例程抽象成一个可重用的函数,返回结果数据帧。
❷ 我通过 format()方法使用 bigquery 专用读取器。
❸ 站点表在 BigQuery 中可用,位于 bigquery-public-data.noaa_gsod.gsodXXXX 下,其中 XXXX 是四位数的年份。
❹ 我将我的 JSON 服务账户密钥传递给 credentialsFile 选项,以告诉 Google 我有权使用 BigQuery 服务。
❺ 我在我的数据帧列表解析上创建了一个 lambda 函数来合并它们所有。
而不是使用循环结构,这里我提出了一个在 PySpark 中使用reduce(在第七章中也使用过)合并多个表的实用模式。如果我们将其分解为离散步骤,将更容易理解归约操作。
我从一个年份范围开始(在我的例子中,从 2010 年到 2020 年,包括 2010 年但不包括 2020 年)。为此,我使用了range()函数。
我通过列表解析将我的辅助函数read_df_from_bq()应用于每个年份,从而得到一个数据帧列表。我不必担心内存消耗,因为列表中只包含数据帧的引用(DataFrame[...])。
作为归约函数,我使用了一个 lambda 函数(我们在第八章创建单次使用函数时使用过),它通过列名(使用unionByName)合并两个数据帧。reduce将取列表中的第一个数据帧,并将其与第二个数据帧合并。然后,它将取上一次合并的结果,并再次与下一个数据帧合并。最终结果是包含 2010 年到 2020 年(包括)每条记录的单个数据帧。
我们可以迭代地这样做,使用一个 for 循环。在下一个列表中,我将展示如何在不使用reduce()的情况下完成相同的目标。由于高阶函数通常产生更干净的代码,因此我更喜欢在合理的情况下使用它们而不是循环结构。
列表 9.3 通过循环读取 2010 年到 2020 年的gsod数据
gsod_alt = read_df_from_bq(2010) ❶
for year in range(2011, 2020):
gsod_alt = gsod_alt.unionByName(
read_df_from_bq(year), allowMissingColumns=True
)
gsod_alt = gsod_alt.drop("date")
❶ 当使用循环方法合并表时,需要一个显式的起始种子。我使用 2010 年的表。
由于gsod2020有一个额外的date列,而前几年没有,因此unionByName会使用null填充值,因为我们传递了True给allowMissingColumns属性。相反,日期通过数据集中的三个整数列表示:year(年),mo(月)和da(日)。
提示:如果你使用的是本地 Spark,加载 2010-2019 年的数据将使本章的示例运行得相当慢。我在本地实例上只使用 2018 年,这样我就不必等待太长时间来执行代码。相反,如果你使用的是更强大的配置,你可以添加年份到范围。gsod表可以追溯到 1929 年。
在本节中,我们从仓库中读取了大量数据,并组装了一个代表过去 10 年全球天气信息的单一数据框。在接下来的几节中,我将介绍 Series UDF 的三个成员,首先是最常见的:Series 到 Series UDF。
9.1.2 Series 到 Series UDF:列函数,但使用 pandas
在本节中,我们将介绍 Pandas UDF 中最常见的一种类型:Series 到 Series UDF,也称为标量 UDF。Series 到 Series UDFs 类似于pyspark.sql模型中的大多数函数。就大部分而言,它们的工作方式与第八章中看到的 Python UDFs 相同,但有一个关键区别:Python UDFs 一次处理一条记录,并且你通过常规 Python 代码表达你的逻辑。标量 UDFs 一次处理一个 Series,并且你通过 pandas 代码表达你的逻辑。这种区别很微妙,而且更易于直观解释。
在 Python UDF 中,当你将列对象传递给你的 UDF 时,PySpark 将每个值拆包,执行计算,然后在一个Column对象中为每条记录返回一个值。在标量 UDF 中,如图 9.2 所示,PySpark 将通过一个名为 PyArrow 的库(我们在本章开头安装了它)将每个分区列序列化为一个 pandas Series对象(mng.bz/g41l)。然后你直接在 Series 对象上执行操作,从你的 UDF 返回相同维度的 Series。从最终用户的角度来看,它们在功能上是相同的。因为 pandas 针对快速数据处理进行了优化,所以当你可以选择时,使用 Series 到 Series UDF 而不是常规 Python UDF 会更优,因为它会快得多。

图 9.2 比较 Python UDF 和 pandas 标量 UDF。前者将列拆分为单个记录,而后者将它们拆分为 Series。
现在我们已经掌握了 Series 到 Series UDFs 的“如何工作”原理,让我们自己创建一个。我选择创建一个简单的函数,它可以将华氏度转换为摄氏度。在加拿大,我们根据用途使用这两个尺度:°F 用于烹饪,°C 用于体温或室外温度。作为一个真正的加拿大人,我会在 350°F 的温度下烹饪晚餐,但我知道 10°C 是穿毛衣的天气。该函数在列表 9.4 中展示。构建块非常相似,但我们能找出两个主要区别:
-
我使用
pandas_udf()而不是udf(),它同样来自pyspark.sql.functions模块。可选的(但推荐),我们可以将 UDF 的返回类型作为参数传递给pandas_udf()装饰器。 -
我们的功能签名也有所不同:而不是使用标量值(如
int或str),UDF 接受pd.Series并返回一个pd.Series。
函数内的代码可以像常规 Python UDF 一样使用。我正在(滥用)你可以用 pandas Series 进行算术运算的事实。
列表 9.4 创建将华氏度转换为摄氏度的 pandas 标量 UDF
import pandas as pd
import pyspark.sql.types as T
@F.pandas_udf(T.DoubleType()) ❶
def f_to_c(degrees: pd.Series) -> pd.Series: ❷
"""Transforms Farhenheit to Celsius."""
return (degrees - 32) * 5 / 9
❶ 对于标量 UDF,最大的变化发生在使用的装饰器上。我也可以直接使用 pandas_udf 函数。
❷ 系列到系列 UDF 的签名是一个函数,它接受一个或多个 pandas.Series。
提示:如果你使用的是 Spark 2 版本,你需要在这里为装饰器添加另一个参数,因为只有 Spark 3.0 及以上版本才识别 pandas UDF 的函数签名。列表 9.4 中的代码将读取 @F.pandas_udf (T.DoubleType(), PandasUDFType.SCALAR)。请参阅官方 PySpark 文档:mng.bz/5KZz。
在列表 9.5 中,我们将新创建的系列到系列 UDF 应用到 gsod 数据框的 temp 列,该列包含每个站点-天组合的温度(华氏度)。就像常规的 Python UDFs 一样,系列到系列(以及所有标量 UDF)就像任何数据操作函数一样使用。在这里,我使用 withColumn() 创建了一个新列 temp_c,并将 f_to_c 温度应用到 temp 列上。
列表 9.5 使用系列到系列 UDF 像其他任何列操作函数一样
gsod = gsod.withColumn("temp_c", f_to_c(F.col("temp")))
gsod.select("temp", "temp_c").distinct().show(5)
# +-----+-------------------+
# | temp| temp_c|
# +-----+-------------------+
# | 37.2| 2.8888888888888906|
# | 85.9| 29.944444444444443|
# | 53.5| 11.944444444444445|
# | 71.6| 21.999999999999996|
# |-27.6|-33.111111111111114|
# +-----+-------------------+
# only showing top 5 rows
系列到系列 UDFs,就像常规的 Python UDFs 一样,当您想要应用到数据框中的记录级转换(或映射)在 PySpark 的标准函数(pyspark.sql.functions)中不可用时,非常方便。将华氏度到摄氏度的转换器作为 Spark 核心的一个部分来创建可能会有些复杂,因此使用 Python 或 pandas 系列到系列 UDF 是以最小的麻烦扩展核心功能的一种方式。接下来,我们将看看如何更有效地控制拆分并使用 PySpark 中的拆分-应用-组合模式。
在 pandas UDF 中处理复杂类型
PySpark 拥有比 pandas 更丰富的数据类型系统,它将字符串和复杂类型组合成一个通用的 object 类型。由于你在执行 UDF 期间从 PySpark 跳转到 pandas,你完全负责相应地对齐类型。这就是 pandas_udf 装饰器的返回类型属性派上用场的地方,因为它可以帮助早期诊断错误。
如果你想接受或返回复杂类型,比如结构数组的数组?pandas 会接受一个系列中的项目列表作为值,这些项目将被提升为 ArrayType 列。对于 StructType 列,你需要将相关的 pd.Series 替换为 pd.DataFrame。在第六章中,我们看到了结构列就像迷你数据框,这里的等价性继续存在!
9.1.3 标量 UDF + 冷启动 = 系列到系列 UDF 的迭代器
提示:这仅在 PySpark 3.0+ 中可用。
这一节结合了其他两种类型的标量 UDF:序列到序列迭代器 UDF 和多个序列到序列迭代器。 (试着快速说五遍!)由于它们在应用上与序列到序列 UDF 非常相似,我将重点关注赋予它们力量的迭代器部分。当你需要执行昂贵的冷启动操作时,序列迭代器 UDF 非常有用。冷启动意味着在处理步骤开始时需要执行一次的操作,在处理数据之前。反序列化本地 ML 模型(使用 scikit-learn 或其他 Python 建模库拟合)是一个例子:我们需要为整个数据帧解包并读取模型一次,然后它可以用来处理所有记录。在这里,我将重用我们的f_to_c函数,但会添加一个冷启动来演示用法。
列表 9.6 中的我们的 UDF 与 9.1.2 节中的序列到序列 UDF 相似。应该注意以下几点:
-
签名从
(pd.Series)->pd.Series变为(Iterator[pd.Series])->Iterator[pd.Series]。这对于使用序列迭代器 UDF 是重要的。 -
当使用序列到序列 UDF 时,我们假设 PySpark 会一次给我们一个批次。在这里,由于我们正在处理序列迭代器,我们明确地逐个迭代每个批次。PySpark 会为我们分配工作。
-
而不是使用
return值,我们yield,这样我们的函数就返回一个迭代器。
列表 9.6 使用序列到序列迭代器到序列迭代器 UDF
from time import sleep
from typing import Iterator
@F.pandas_udf(T.DoubleType())
def f_to_c2(degrees: Iterator[pd.Series]) -> Iterator[pd.Series]: ❶
"""Transforms Farhenheit to Celsius."""
sleep(5) ❷
for batch in degrees: ❸
yield (batch - 32) * 5 / 9 ❸
gsod.select(
"temp", f_to_c2(F.col("temp")).alias("temp_c")
).distinct().show(5)
# +-----+-------------------+
# | temp| temp_c|
# +-----+-------------------+
# | 37.2| 2.8888888888888906|
# | 85.9| 29.944444444444443|
# | 53.5| 11.944444444444445|
# | 71.6| 21.999999999999996|
# |-27.6|-33.111111111111114|
# +-----+-------------------+
# only showing top 5 rows
❶ 现在的签名是 (Iterator[pd.Series]) -> Iterator[pd.Series]。注意添加了迭代器关键字(来自 typing 模块)。
❷ 我们使用 sleep()模拟了五秒的冷启动。冷启动将在每个工作节点上发生一次,而不是每个批次。
❸ 由于我们在这里使用迭代器,我们遍历每个批次,使用 yield(而不是 return)。
我们已经涵盖了从序列迭代器到序列迭代器的案例。那么,多个序列到序列迭代器的情况又是怎样的呢?这个特殊情况是将多个列包裹在一个单独的迭代器中。在这个例子中,我将year、mo和da列(代表年份、月份和日期)组合成一个单独的列。这个例子比使用单个序列迭代器时需要更多的数据转换;我在图 9.3 中展示了数据转换的过程。

图 9.3 将三个值序列转换为单个日期列的转换。我们使用for循环遍历每个批次,使用多重赋值从元组中获取单个列,并将它们打包成一个字典,该字典可以输入到数据帧中,在那里我们可以应用我们的to_datetime()函数。
我们日期组装 UDF 的工作方式是这样的:
-
year_mo_da是一个包含year、mo和da列中所有值批次的元组序列的迭代器。 -
要访问每个批次,我们使用对迭代器的
for循环,这与 Series UDF 的 Iterator(第 9.1.3 节)中的原理相同。 -
要从元组中提取每个单独的序列,我们使用多重赋值。在这种情况下,
year将映射到元组的第一个 Series,mo映射到第二个,da映射到第三个。 -
由于
pd.to_datetime需要一个包含year、month和day列的数据框,我们通过字典创建数据框,给键赋予相关的列名。pd.to_datetime返回一个 Series。 -
最后,我们
yield答案以构建 Series 的 Iterator,履行我们的合同。
列表 9.7 使用多个 Series UDF 组装来自三个列的日期
from typing import Tuple
@F.pandas_udf(T.DateType())
def create_date(
year_mo_da: Iterator[Tuple[pd.Series, pd.Series, pd.Series]]
) -> Iterator[pd.Series]:
"""Merges three cols (representing Y-M-D of a date) into a Date col."""
for year, mo, da in year_mo_da:
yield pd.to_datetime(
pd.DataFrame(dict(year=year, month=mo, day=da))
)
gsod.select(
"year", "mo", "da",
create_date(F.col("year"), F.col("mo"), F.col("da")).alias("date"),
).distinct().show(5)
这就结束了我们对如何使用标量 UDF 的概述。标量 UDF 在进行列级转换时非常有用,就像 pyspark.sql.functions 中的函数一样。当使用任何标量用户定义函数时,你需要记住 PySpark 不会保证应用时的顺序或批次的组成。如果你遵循我们在处理 PySpark 列函数时使用的相同的“输入列,输出列”咒语,你会做得很好。
提示:默认情况下,Spark 会尝试每个批次包含 10,000 条记录。在创建 SparkSession 对象时,你可以使用 spark.sql.execution.arrow.maxRecordsPerBatch 配置自定义每个批次的最大大小;对于大多数工作来说,10,000 条记录是一个很好的平衡点。如果你正在使用内存受限的执行器,你可能想减少这个数值。
如果你需要担心基于一个或多个列的批次组成,你将在下一节中学习如何在 GroupedData 对象(见第五章)上应用 UDF 以获得对记录的更精细的控制。我们不仅会创建聚合函数(例如,sum()),还会在控制批次组成的同时应用函数。
练习 9.1
在以下代码块中 WHICH_TYPE 和 WHICH_SIGNATURE 的值是什么?
exo9_1 = pd.Series(["red", "blue", "blue", "yellow"])
def color_to_num(colors: WHICH_SIGNATURE) -> WHICH_SIGNATURE:
return colors.apply(
lambda x: {"red": 1, "blue": 2, "yellow": 3}.get(x)
)
color_to_num(exo9_1)
# 0 1
# 1 2
# 2 2
# 3 3
color_to_num_udf = F.pandas_udf(color_to_num, WHICH_TYPE)
9.2 对分组数据的 UDF:聚合和应用
这一节涵盖了当你需要担心批次组成时的情况。这在两种情况下很有用。为了完整起见,我提供了 Spark 3 版本中常用的常见名称:
-
分组聚合 UDFs:你需要执行聚合函数,如
count()或sum(),就像我们在第五章中看到的那样。 -
分组映射 UDFs:你的数据框可以根据某些列的值分割成批次;然后你将函数应用于每个批次,就像它是一个 pandas
DataFrame一样,然后再将每个批次合并回 Spark 数据框。例如,我们可以将gsod数据批次化按站点月份,并对结果数据框进行操作。
无论是分组聚合还是分组映射 UDFs,都是 PySpark 对 split-apply-combine 模式的回答。在核心上,split-apply-combine 只是一系列在数据分析中经常使用的三个步骤:
-
分割 你的数据集成逻辑批次(使用
groupby())。 -
应用一个函数到每个批次独立地。
-
合并这些批次到一个统一的数据集中。
完全诚实地说,直到有一天有人指着我的代码说,“你这里做了一些很好的分而治之-应用-合并工作。”我才知道这个模式的名字。你可能也是本能地使用它。在 PySpark 的世界里,我更把它看作是一个分割和加工的动作,如图 9.4 所示。

图 9.4 展示了分而治之-应用-合并的视觉表示。我们将数据帧批量/分组,并在合并回(Spark)数据帧之前,使用 pandas 对每个分组进行处理。
在这里,我们将涵盖每种类型的 UDF,并说明每种如何与分而治之-应用-合并模式相关。虽然分组映射和分组聚合 UDF 属于同一类,并且都在GroupedData对象上工作(在第五章回顾groupby()方法时看到),但它们的语法和用法相当不同。
警告:权力越大,责任越大:在分组你的数据帧时,确保每个批次/分组是“pandas-size”(即可以舒适地加载到内存中)。如果有一个或多个批次太大,你会得到内存不足异常。
9.2.1 分组聚合 UDF
注意:仅从 Spark 2.4 版本开始可用。Spark 2.4 提供了functionType为PandasUDFType.GROUPED_AGG(来自pyspark.sql.functions;参见mng.bz/6Zmy)。
本节涵盖了分组聚合 UDF,也称为序列到标量 UDF。有了这个名字,我们就可以想象它和第 9.1.2 节中看到的序列到序列 UDF 有一些相似之处。与序列到序列不同,分组聚合 UDF 将接收到的序列简化为单个值。在分而治之-应用-合并模式中,应用阶段根据我们分组的列值将批次折叠成单个记录。
PySpark 通过我们在第五章中学到的groupby().agg()模式提供了分组聚合功能。一个分组聚合 UDF(用户定义函数)简单来说就是我们将作为参数传递给agg()的自定义聚合函数。在本节的示例中,我想做一些比重现常见的聚合函数(计数、最小值、最大值、平均值)更复杂的事情。在列表 9.8 中,我使用 scikit-learn 的LinearRegression对象计算了给定时期的温度线性斜率。你不需要了解 scikit-learn 或机器学习就能跟随理解;我正在使用基本功能,并解释每个步骤。
注意:这不是一个机器学习练习:我只是使用 scikit-learn 的管道来创建一个特征。Spark 中的机器学习将在本书的第三部分介绍。不要将此代码视为健壮的模型训练!
要在 scikit-learn 中训练一个模型,我们首先初始化模型对象。在这种情况下,我使用 LinearRegression() 而不带任何其他参数。然后我 fit 模型,提供 X,我的特征矩阵,和 y,我的预测向量。在这种情况下,由于我只有一个特征,我需要“重塑”我的 X 矩阵,否则 scikit-learn 会抱怨形状不匹配。这根本不会改变矩阵的值。
提示:fit() 是训练 ML 模型的常用方法。事实上,Spark ML 库在训练分布式 ML 模型时也使用相同的方法。更多信息请见第十三章。
在 fit 方法的末尾,我们的 LinearRegression 对象已经训练了一个模型,在线性回归的情况下,它将系数保存在 coef_ 向量中。由于我真正关心的是系数,我简单地提取并返回它。
列表 9.8 创建一个分组聚合 UDF
from sklearn.linear_model import LinearRegression ❶
@F.pandas_udf(T.DoubleType())
def rate_of_change_temperature(day: pd.Series, temp: pd.Series) -> float:
"""Returns the slope of the daily temperature for a given period of time."""
return (
LinearRegression() ❷
.fit(X=day.astype(int).values.reshape(-1, 1), y=temp) ❸
.coef_[0] ❹
)
❶ 我从 sklearn.linear_model 导入了线性回归对象。
❷ 我初始化了 LinearRegression 对象。
❸ fit 方法使用 day 序列作为特征,temp 序列作为预测来训练模型。
❹ 由于我只有一个特征,我选择 coef_ 属性的第一个值作为我的斜率。
将分组聚合 UDF 应用到我们的数据框上很容易。在列表 9.9 中,我按站点代码、名称和国家,以及年份和月份进行了 groupby()。我将新创建的分组聚合函数作为 agg() 方法的参数传递,并将我的 Column 对象作为参数传递给 UDF。
列表 9.9 使用 agg() 应用我们的分组聚合 UDF
result = gsod.groupby("stn", "year", "mo").agg(
rate_of_change_temperature(gsod["da"], gsod["temp"]).alias( ❶
"rt_chg_temp"
)
)
result.show(5, False)
# +------+----+---+---------------------+
# |stn |year|mo |rt_chg_temp |
# +------+----+---+---------------------+
# |010250|2018|12 |-0.01014397905759162 |
# |011120|2018|11 |-0.01704736746691528 |
# |011150|2018|10 |-0.013510329829648423|
# |011510|2018|03 |0.020159116598556657 |
# |011800|2018|06 |0.012645501680677372 |
# +------+----+---+---------------------+
# only showing top 5 rows
❶ 应用分组聚合 UDF 与使用 Spark 聚合函数相同:你将其作为 GroupedData 对象的 agg() 方法的参数添加。
在本节中,我们使用 Series 到标量 UDF 创建了一个自定义聚合函数,也称为分组聚合 UDF。遵循我们的拆分-应用-组合模式,成功的分组聚合 UDF 使用依赖于 groupby() 方法,并使用 Series 到标量 UDF 作为 agg() 方法的参数之一或多个。像它的名字一样,应用阶段的返回值是一个单一值,因此每个批次变成一个单独的记录,在分组数据框中合并。在下一节中,我们将探讨聚合模式的一个替代方案,其中应用阶段的返回值是一个数据框。
9.2.2 分组映射 UDF
注意:仅从 Spark 2.3+ 开始可用。Spark 2.3/2.4 提供了 functionType 为 PandasUDFType.GROUPED_MAP,并使用 apply() 方法(来自 pyspark.sql.functions;见 mng.bz/oa8M)和 @pandas_udf() 装饰器。
分组数据上的第二种 UDF 是分组映射 UDF。与返回一个标量值的分组聚合 UDF 不同,分组映射 UDF 在每个批次上映射并返回一个(pandas)数据框,该数据框随后被合并回一个单独的(Spark)数据框。正因为这种灵活性,PySpark 提供了不同的使用模式(并且 Spark 2 和 Spark 3 之间的语法变化很大;请参阅本节顶部的注释)。
在查看 PySpark 的管道之前,我们关注等式的 pandas 部分。标量 UDF 依赖于 pandas Series,而分组映射 UDF 使用 pandas DataFrame。图 9.4 第 1 步中的每个逻辑批次都变成一个准备就绪的 pandas DataFrame。我们的函数必须返回一个完整的 DataFrame,这意味着我们需要返回所有想要显示的列,包括我们分组所针对的列。
列表 9.10 中的 scale_temperature 函数看起来非常像 pandas 函数——不需要 pandas_udf() 装饰器(当使用 Spark 3 时)。当作为分组映射 UDF 应用时,pandas 函数不需要任何特殊定义。返回值数据框包含六个列:stn、year、mo、da、temp 和 temp_norm。除了 temp_norm 之外的所有列都假定为输入数据框中存在。我们创建 temp_norm 列,该列包含使用每个批次/ pandas DataFrame 的最大和最小温度缩放的温度。由于我在 UDF 中有一个除法操作,如果我的批次中的最小温度等于最大温度,我将提供一个合理的值 0.5。默认情况下,pandas 将在除以零时给出无限值;PySpark 将将其解释为 null。
列表 9.10 用于缩放温度值的分组映射 UDF
def scale_temperature(temp_by_day: pd.DataFrame) -> pd.DataFrame:
"""Returns a simple normalization of the temperature for a site.
If the temperature is constant for the whole window, defaults to 0.5."""
temp = temp_by_day.temp
answer = temp_by_day[["stn", "year", "mo", "da", "temp"]]
if temp.min() == temp.max():
return answer.assign(temp_norm=0.5)
return answer.assign(
temp_norm=(temp - temp.min()) / (temp.max() - temp.min())
)
现在应用步骤已完成,剩下的就是小菜一碟。就像分组聚合 UDF 一样,我们使用 groupby() 将数据框拆分为可管理的批次,然后将我们的函数传递给 applyInPandas() 方法。该方法将一个函数作为第一个参数,将 schema 作为第二个参数。我在这里使用的是第七章中看到的简化 DDL 语法;如果您更习惯于第六章中看到的 StructType 语法,它也可以在这里互换使用。
提示 Spark 2 使用 pandas_udf() 装饰器并将返回模式作为参数传递,因此如果您使用这个版本,您会在这里使用 apply() 方法。
在下一个列表中,我们使用三个列:stn、year 和 mo 对数据框进行分组。每个批次将代表一个站点-月份的观测值。我的 UDF 返回值中有六个列;applyInPandas() 后的数据框也有相同的六个列。
列表 9.11 PySpark 中的拆分-应用-组合
gsod_map = gsod.groupby("stn", "year", "mo").applyInPandas(
scale_temperature,
schema=(
"stn string, year string, mo string, "
"da string, temp double, temp_norm double"
),
)
gsod_map.show(5, False)
# +------+----+---+---+----+-------------------+
# |stn |year|mo |da |temp|temp_norm |
# +------+----+---+---+----+-------------------+
# |010250|2018|12 |08 |21.8|0.06282722513089001|
# |010250|2018|12 |27 |28.3|0.40314136125654443|
# |010250|2018|12 |31 |29.1|0.4450261780104712 |
# |010250|2018|12 |19 |27.6|0.36649214659685864|
# |010250|2018|12 |04 |36.6|0.8376963350785339 |
# +------+----+---+---+----+-------------------+
分组映射 UDF 是高度灵活的结构:只要尊重你提供给 applyInPandas() 的模式,Spark 就不会要求你保持相同(或任何)数量的记录。这几乎是我们将 Spark 数据帧视为预先确定的集合(通过 groupby())的 pandas 数据帧的方式。如果你不关心块组成,但需要“pandas DataFrame 输入,pandas DataFrame 输出”的灵活性,请查看 PySpark DataFrame 对象的 mapInPandas() 方法:它重用了第 9.1.3 节中看到的迭代器模式,但将其应用于整个数据帧而不是一系列系列。
由于这种灵活性,分组映射 UDF 通常是我看到开发者最难正确实现的部分。这时你的笔和纸就派上用场了:绘制你的输入和输出,花时间确保你的数据帧结构保持一致。
本章的下一节和最后一节总结了如何在众多 pandas UDF 中做出选择。通过利用几个问题,你将知道每次应该使用哪个。
9.3 何时使用什么
这一小节是关于如何正确选择 pandas UDF。因为每个 UDF 都针对特定的用例,我认为最好的方法是利用每个 UDF 的特性,并就我们的用例提出几个问题。这样,就不再犹豫了!
在图 9.5 中,我提供了一个小的决策树,当你犹豫使用哪个 pandas UDF 时可以遵循。以下是总结:
-
如果你需要控制批次的制作方式,你需要使用分组数据 UDF。如果返回值是标量、分组聚合或其他,请使用分组映射并返回一个转换后的(完整)数据帧。
-
如果你只想使用批次,你有更多选择。最灵活的是
mapInPandas(),其中 pandas DataFrame 迭代器进入并输出一个转换后的数据帧。这在你想在整个数据帧上分布 pandas/本地数据转换时非常有用,例如使用本地 ML 模型的推理。如果你处理数据帧的大部分列,请使用它;如果你只需要几个列,请使用 Series 到 Series UDF。 -
如果你有一个冷启动过程,使用 Series/multiple Series UDF 的迭代器,具体取决于你 UDF 中需要的列数。
-
最后,如果你只需要使用 pandas 转换一些列,那么 Series 到 Series UDF 是最佳选择。

图 9.5 选择 UDF 的决策总结
pandas UDF 对于扩展 PySpark,添加 pyspark.sql 模块中未包含的转换非常有用。我发现它们也相当容易理解,但很难正确实现。我在本章结束时提供了一些关于测试和调试你的 pandas UDF 的提示。
pandas UDF(以及任何 UDF)最重要的方面是它需要在非分布式版本的数据上工作。对于常规 UDF,这意味着传递 任何你期望的值的类型 的参数应该得到一个答案。例如,如果你将一个值数组除以另一个值数组,你需要处理除以零的情况。对于任何 pandas UDF 也是如此:你需要对接受的输入宽容,对提供的输出严格。
要测试你的 pandas UDF,我最喜欢的策略是使用 UDF 的 func() 方法来获取数据样本。这样,我可以在 REPL 中进行尝试,直到得到正确的结果,然后将其提升到脚本中。在下一个列表中,我将展示 rate_of_change_temperature() UDF 的一个本地应用示例。
列表 9.12 将一个站点和一个月份的数据移动到本地 pandas DataFrame
gsod_local = gsod.where(
"year = '2018' and mo = '08' and stn = '710920'"
).toPandas()
print(
rate_of_change_temperature.func( ❶
gsod_local["da"], gsod_local["temp_norm"]
)
)
# -0.007830974115511494
❶ 使用 func() 访问 UDF 的底层 pandas 函数
当将你的数据框样本带入 pandas DataFrame 进行分组映射或分组聚合 UDF 时,你需要确保你得到的是完整的一批数据以重现结果。在我们的特定情况下,由于我们按 "station","year","month" 进行分组,我带来了一个站点和一个月份(确切地说是一个特定的年/月)的数据。由于数据分组是在 PySpark 的级别上发生的(通过 groupby()),你需要以相同的方式思考你的样本数据的过滤器。
pandas UDF 可能是 PySpark 为数据操作提供的最强大的功能。本章涵盖了最有用和最常见类型。随着 Python 编程语言在 Spark 生态系统中的普及,我坚信新的优化 UDF 类型将会出现。pandas,PySpark——你不再需要做出选择。根据工作内容和数据使用合适的工具,并知道你可以利用强大的 pandas UDF 来扩展你的代码,当这有意义时。
摘要
-
pandas UDF 允许你将适用于 pandas DataFrame 的代码扩展到 Spark DataFrame 结构。PyArrow 确保了两者之间的高效序列化。
-
根据我们需要对批次的控制级别,我们可以将 pandas UDF 分为两大类。Series 和 Series 的迭代器(以及 DataFrame 的迭代器/
mapInPandas)将有效地批量处理,用户对批组成没有控制权。 -
如果你需要控制每个批次的内容,你可以使用分组数据 UDF 并采用 split-apply-combine 编程模式。PySpark 提供了对
GroupedData对象每个批次中的值的访问,无论是作为 Series(分组聚合 UDF)还是作为数据框(分组映射 UDF)。
额外练习
练习 9.2
使用以下定义,创建一个 temp_to_temp(value, from_temp, to_temp) 函数,它接受 from_temp 度数中的数值 value 并将其转换为 to 度。这次使用 pandas UDF(我们在第八章中做了同样的练习)。
-
C=(F-32)*5/9(摄氏度) -
K=C+273.15(开尔文) -
R=F+459.67(兰金)
练习 9.3
将以下代码块修改为使用摄氏度而不是华氏度。如果将用户定义函数(UDF)应用于相同的数据框,结果会有何不同?
def scale_temperature(temp_by_day: pd.DataFrame) -> pd.DataFrame:
"""Returns a simple normalization of the temperature for a site.
If the temperature is constant for the whole window, defaults to 0.5."""
temp = temp_by_day.temp
answer = temp_by_day[["stn", "year", "mo", "da", "temp"]]
if temp.min() == temp.max():
return answer.assign(temp_norm=0.5)
return answer.assign(
temp_norm=(temp - temp.min()) / (temp.max() - temp.min())
)
练习 9.4
完成以下代码块的架构,使用之前练习中的 scale_temperature_C。如果我们像这样应用我们的分组映射 UDF,会发生什么?
gsod_exo = gsod.groupby("year", "mo").applyInPandas(scale_temperature, schema=???)
练习 9.5
将以下代码块修改为返回线性回归的截距以及斜率在一个 ArrayType 中。(提示:截距在拟合模型的 intercept_ 属性中。)
from sklearn.linear_model import LinearRegression
@F.pandas_udf(T.DoubleType())
def rate_of_change_temperature(day: pd.Series, temp: pd.Series) -> float:
"""Returns the slope of the daily temperature for a given period of time."""
return (
LinearRegression()
.fit(X=day.astype("int").values.reshape(-1, 1), y=temp)
.coef_[0]
)
¹ 在 Windows 上,有时你可能会遇到 pip 轮子的问题。如果是这种情况,请参阅 PyArrow 文档页面进行安装:arrow.apache.org/docs/python/install.html
10 以不同的视角看待你的数据:窗口函数
本章涵盖:
-
窗口函数和它们所允许的数据转换类型
-
使用不同的窗口函数类别总结、排名和分析数据
-
为你的函数构建静态、增长和无界的窗口
-
将 UDF 应用到窗口作为自定义窗口函数
在进行数据分析或特征工程(这是我机器学习中最喜欢的部分;见第十三章)时,没有什么能让我比窗口函数更高兴。乍一看,它们看起来像是第九章中引入的拆分-应用-组合模式的一个稀释版本。然后你拉开窗帘——哇——在简短而富有表现力的代码体中进行强大的操作。
不了解窗口函数的人很可能会糟糕地重新实现其功能。这是我辅导数据分析师、科学家和工程师时的经验。如果你发现自己难以
-
排名记录
-
根据一组条件识别顶部/底部记录
-
从表中的先前观察中获取一个值(例如,使用第九章中的温度数据框并询问“昨天的温度是多少?”)
-
构建趋势特征(即总结过去观察的特征,如上周观察的平均值)
你会发现窗口函数将提高你的生产力并简化你的代码。
窗口函数填补了分组聚合(groupBy().agg())和分组映射 UDF(groupBy().apply())转换之间的空白,这两种转换在第九章中都有介绍。两者都依赖于分区来根据谓词拆分数据帧。分组聚合转换将为每个分组生成一条记录,而分组映射 UDF 允许结果数据帧具有任何形状;窗口函数始终保持数据帧的维度不变。窗口函数有一个秘密武器,即我们在分区中定义的窗口框架:它决定了哪些记录包含在函数的应用中。
窗口函数主要用于创建新列,因此它们利用了一些熟悉的方法,例如 select() 和 withColumn()。因为我们已经熟悉了添加列的语法,所以我以不同的方式处理本章。首先,我们看看如何通过依赖我们已知的概念来模拟一个简单的窗口函数,例如 groupby 和 join 方法。然后我们熟悉窗口函数的两个组成部分:窗口规范和函数本身。然后我应用并剖析三种主要的窗口函数类型(总结、排名和分析)。一旦你装备了窗口函数应用的这些构建块,我们就通过引入有序和有界窗口以及窗口框架来打开窗口规范。最后,我们回到起点,引入 UDF 作为窗口函数。
本章大量借鉴了第五章和第九章的内容。同样,每个部分都大量借鉴了前一个部分的内容。窗口函数本身并不复杂,但有很多新的术语,而且函数的行为可能一开始并不直观。如果你感到困惑,请确保你仔细地研究例子。因为最好的学习方式是通过实践,尝试本章中的练习和结尾处的练习。一如既往,答案可在附录 A 中找到。
10.1 增长和使用简单的窗口函数
当学习一个新概念时,我发现当我能够从基本原理出发,利用我所知道的知识来构建它时,我会更容易。这正是我在学习窗口函数时发生的情况:我开始通过使用一串 SQL 指令来重现它们的行为。完成之后,很明显窗口函数为什么如此有用。当我发现它们被编织进 PySpark 中,并且还附带一个漂亮的 Python API 时,我感到无比的喜悦。
在本节中,我们遵循相同的路径:我首先通过使用之前章节中的技术来重现一个简单的窗口函数。然后,我介绍了窗口函数的语法以及它们如何简化你的数据转换逻辑。我希望你能像我第一次对窗口函数“恍然大悟”时那样兴奋。
对于本节,我们重用了第八章中的温度数据集;该数据集包含一系列气象站的天气观测数据,按日汇总。窗口函数在处理类似时间序列的数据(例如,温度的每日观测)时特别出色,因为你可以按日、月或年切片数据,并获取有用的统计数据。如果你想使用 BigQuery 中的数据,请使用第九章中的代码,保留你想要的(或尽可能少的)年份数据。对于那些更喜欢本地优先的方法,本书的存储库包含三年的数据,以 Parquet 格式存储(参见列表 10.1)。
列表 10.1 读取必要的数据:GSOD NOAA 天气数据
gsod = spark.read.parquet("./data/window/gsod.parquet")
现在我们已经具备了数据,让我们开始提问吧!接下来的几节将展示窗口函数背后的思考过程,然后再深入到术语和语法。
10.1.1 通过长方法确定每年最冷的一天
在本节中,我们通过之前章节中学到的功能来模拟一个简单的窗口函数——最明显的是在第五章中使用join()方法。目的是提供一个对窗口函数直观的感觉,并消除围绕它们的一些神秘感。为了说明这一点,我们从一个简单的问题开始,询问我们的数据框:何时和何地记录了每年最低的温度?换句话说,我们想要一个包含三个记录的数据框,每个记录对应一年,显示该年的气象站、日期(年、月、日)以及当年记录的最低温度。
让我们映射一下思维过程。首先,我们将获取一个包含每年最低温度的数据帧。这将给我们两个列(year和temp)及其值。在列表 10.2 中,我们创建了coldest_temp数据帧,它使用我们的历史数据并按year列分组,然后通过agg()应用min()聚合函数提取最小temp。如果语法有点模糊,请前往第五章复习分组数据。
列表 10.2 使用groupBy()计算每年最低温度
coldest_temp = gsod.groupby("year").agg(F.min("temp").alias("temp"))
coldest_temp.orderBy("temp").show()
# +----+------+
# |year| temp|
# +----+------+
# |2017|-114.7|
# |2018|-113.5|
# |2019|-114.7|
# +----+------+
人们,地球很冷!
这提供了年份和温度,这大约是原始要求的 40%。为了获取其他三个列(mo、da、stn),我们可以使用原始表上的左半半连接,使用coldest_temp的结果来解决连接。在列表 10.3 中,我们使用year和temp列上的左半等值连接将gsod与coldest_temp连接起来(有关左半连接和等值连接的更多信息,请参阅第五章)!因为coldest_temp只包含每年最冷的温度,所以左半连接只保留与该年份-温度对对应的gsod记录;这相当于只保留每年温度最低的记录。
列表 10.3 使用左半连接计算每年最冷的站点/日期
coldest_when = gsod.join(
coldest_temp, how="left_semi", on=["year", "temp"]
).select("stn", "year", "mo", "da", "temp")
coldest_when.orderBy("year", "mo", "da").show()
# +------+----+---+---+------+
# | stn|year| mo| da| temp|
# +------+----+---+---+------+
# |896250|2017| 06| 20|-114.7|
# |896060|2018| 08| 27|-113.5|
# |895770|2019| 06| 15|-114.7|
# +------+----+---+---+------+
在列表 10.2 和列表 10.3 中,我们正在执行gsod表与来自gsod表中的某些内容的连接。这是一种自连接,即当你将一个表与自身连接时,通常被认为是一种数据操作的反模式。虽然从技术上讲并没有错误,但它可能很慢,并且使代码看起来比实际需要更复杂。它看起来也有些奇怪。当你想要将包含在两个或多个表中的数据链接起来时,连接表是有意义的。将一个表与自身连接感觉是多余的,正如我们在图 10.1 中看到的那样:数据已经在(一个)表中了!

图 10.1 当一个表与自身连接时发生自连接。你可以用窗口函数替换大多数自连接。
幸运的是,窗口函数可以更快地给出相同的结果,并且代码更简洁。在下一节中,我们将使用窗口函数重现相同的数据转换,并简化并加快我们的数据转换代码。
10.1.2 创建和使用简单的窗口函数以获取最冷的日子
本节通过替换前节中的自连接示例来介绍窗口函数。我介绍了Window对象,并将其参数化以根据列值拆分数据帧。然后,我们使用传统的选择方法在数据帧上应用窗口函数。
在本章的开头,我将窗口函数与我在介绍 pandas group map UDF(第八章)时提到的分而治之、合并模式进行了类比。为了与来自 SQL 的 Window 函数术语保持一致(见第七章),我在分而治之、合并模式的三个阶段使用了不同的词汇:
-
我们不是进行拆分,而是将数据框进行分区。
-
我们不是应用,而是在窗口上选择值。
-
在窗口函数中,
combine/union操作是隐式的(即,不是显式编码的)。
注意为什么这里使用不同的词汇?窗口函数是来自 SQL 世界的一个概念,而分而治之、合并的模式则来自数据分析领域。不同的领域,不同的词汇!
窗口函数在根据列上的值拆分的数据集上应用。每个拆分,称为分区,将窗口函数应用于其记录,就像它们是独立的数据框一样。然后结果被合并回单个数据框。在列表 10.4 中,我创建了一个窗口,根据 year 列的值进行分区。Window 类是一个构建器类,就像 SparkSession.builder 一样:我们通过在 Window 类标识符后附加方法来链式参数化。结果是包含参数化信息的 WindowSpec 对象。
列表 10.4 通过使用 Window 构建器类创建 WindowSpec 对象
from pyspark.sql.window import Window ❶
each_year = Window.partitionBy("year") ❷
print(each_year)
# <pyspark.sql.window.WindowSpec object at 0x7f978fc8e6a0>
❶ 我们从 pyspark.sql.window 导入 Window。由于我们只会使用这个对象进行窗口函数,因此没有必要导入整个模块。
❷ 要根据一列或多列的值进行分区,我们将列名(或列对象)传递给 partitionBy() 方法。
WindowSpec 对象不过是一个最终窗口函数的蓝图。在我们的案例中,在列表 10.4 中,我们创建了一个名为 each_year 的窗口规范,指示窗口应用根据 year 列的值拆分数据框。真正的魔法发生在你将窗口函数应用于数据框的时候。对于我们的第一个窗口函数应用,我打印了整个代码,重复了第 10.1.1 节中提到的自连接方法,然后逐行分析。看看窗口应用(列表 10.6)和左外连接之间的区别。
列表 10.5 使用左外连接计算每年最冷的站点/天
coldest_when = gsod.join(
coldest_temp, how="left_semi", on=["year", "temp"]
).select("stn", "year", "mo", "da", "temp")
coldest_when.orderBy("year", "mo", "da").show()
# +------+----+---+---+------+
# | stn|year| mo| da| temp|
# +------+----+---+---+------+
# |896250|2017| 06| 20|-114.7|
# |896060|2018| 08| 27|-113.5|
# |895770|2019| 06| 15|-114.7|
# +------+----+---+---+------+
列表 10.6 使用窗口函数选择每年最低温度
(gsod
.withColumn("min_temp", F.min("temp").over(each_year)) ❶
.where("temp = min_temp")
.select("year", "mo", "da", "stn", "temp")
.orderBy("year", "mo", "da")
.show())
# +----+---+---+------+------+
# |year| mo| da| stn| temp|
# +----+---+---+------+------+
# |2017| 06| 20|896250|-114.7|
# |2018| 08| 27|896060|-113.5|
# |2019| 06| 15|895770|-114.7|
# +----+---+---+------+------+
❶ 我们在定义的窗口内选择最低温度(对于每年)。
是时候对代码进行解包了。通过withColumn()方法,我们定义了一个名为min_temp的列,该列收集temp列的最小值。现在,我们不再选择整个数据框的最小温度,而是使用over()方法在定义的窗口规范上应用min()。对于每个窗口分区,Spark 计算最小值,然后将该值广播到每个记录。与聚合函数或 UDF 相比,这是一个重要的区别:在窗口函数的情况下,数据框中的记录数不会改变。虽然min()是一个聚合函数,但由于它是通过over()方法应用的,所以窗口中的每个记录都会附加最小值。对于pyspark.sql.functions中的任何其他聚合函数也是如此,例如sum()、avg()、min()、max()和count()。
窗口函数仅仅是列上的方法(几乎)
由于窗口函数是通过Column对象上的方法应用的,因此你还可以在select()中应用它们。你还可以在同一个select()中应用多个窗口(或不同的窗口)。Spark 不允许你在groupby()或where()方法中使用窗口,否则会抛出AnalysisException。如果你想根据窗口函数的结果进行分组或过滤,请在使用所需操作之前使用select()或withColumn()“物化”该列。
例如,列表 10.6 可以重写,将窗口定义放入select中。因为窗口是按列逐列应用的,所以你可以在一个选择语句中有多处窗口应用。
列表 10.7 在select()方法中使用窗口函数
gsod.select(
"year",
"mo",
"da",
"stn",
"temp",
F.min("temp").over(each_year).alias("min_temp"),
).where(
"temp = min_temp"
).drop( ❶
"min_temp"
).orderBy(
"year", "mo", "da"
).show()
❶ 我们删除min_temp,因为它在where子句中已经完成了它的作用,不再需要(它将始终等于结果数据框中的temp)。
检查章节末尾的练习,以实验多个窗口应用。
在底层,PySpark 在应用于列时实现窗口规范。我在这里定义了一个相当简单的窗口规范:根据year列的值对数据框进行分区。就像 split-apply-combine 模式一样,我们根据year列的值对数据框进行分区。
小贴士 你可以对多个列使用partitionBy()!只需将更多列名添加到partitionBy()方法中。
对于每个窗口分区(参见本节末尾的“但是数据框已经有了分区!”侧边栏),我们在广播每个记录的结果之前计算聚合函数(这里为min())。用简单的话来说,我们计算每年的最低温度,并将其作为该年每个记录的列附加。我创造性地将新列命名为min_temp。
接下来,我们需要只保留那些温度实际上是该年最低的记录。为此,我们只需简单地使用filter()(或where())来保留那些temp等于min_temp的记录。因为窗口函数应用给每条记录提供了一个对应于该年最低温度的min_temp字段,所以我们又回到了常规的数据操作技巧。
好了,朋友们!我们有了我们非常第一个窗口函数。这是一个故意简单的例子,用来教授窗口规范、窗口函数和窗口分区概念。在下一节中,我将比较两种方法的应用和速度,并解释为什么窗口函数更容易、更友好、更快。
但数据框已经有了分区!
再次强调,我们遇到了一个词汇问题。从本书开始,分区一词指的是每个执行节点上数据的物理分割。现在我们也在使用窗口函数的分区来表示数据的逻辑分割,这些分割可能等于也可能不等于 Spark 的物理分割。
不幸的是,网上的大部分文献都不会告诉你它们指的是哪个分区。但一旦你内化了 Spark 和窗口函数的概念,就会很容易知道哪个是哪个。对于本章,当谈到由窗口规范应用产生的逻辑分区时,我将使用窗口分区。

图 10.2 我们根据year列将gsod数据框进行分区,并计算每个分区的最低温度。属于每个分区的每条记录都会附加最低温度。结果数据框包含相同数量的记录,但新增了一个名为min_temp的列,该列包含该年的最低温度。
10.1.3 比较两种方法
在本节中,我从代码可读性的角度比较了自连接和窗口函数方法。我们还简要讨论了窗口与连接的性能影响。在进行数据转换和分析时,代码清晰度和性能是工作代码体最重要的两个考虑因素;由于我们有两种执行相同工作的方法,因此从清晰度和性能的角度进行比较是有意义的。
与自连接方法相比,使用窗口函数可以使你的意图更加清晰。使用名为each_year的窗口,代码片段F.min("temp") .over(each_year)几乎就像一个英语句子。自连接方法可以完成相同的工作,但代价是代码稍微有点晦涩:我为什么要将这个表与自身连接?
在性能方面,窗口函数避免了可能代价高昂的自连接。当处理大型数据集时,数据框在执行函数之前只需将数据分割成窗口分区。考虑到 Spark 的操作模型是在多个节点上拆分大型数据集,这很有意义。
找出哪种方法最快将取决于数据的大小、可用的内存(参见第十一章,了解 Spark 中内存使用的概述),以及连接/窗口操作有多复杂。我倾向于压倒性地偏好窗口函数,因为它们更清晰,而且能更清楚地表达我的意图。正如我在编码时反复对自己说,先让它工作,再让它清晰,最后让它快速!
最后,这也是下一节的内容,窗口函数比仅仅在给定窗口上计算聚合度量更灵活。接下来,我将介绍排名和分析函数,它们为你的数据提供一个新的窗口(明白了吗?)。汇总和连接方法将很快变得不足。
练习 10.1
使用gsod数据框,哪个窗口规范一旦应用,就能生成每天的气温最高的站点?
a) Window.partitionBy("da")
b) Window.partitionBy("stn", "da")
c) Window.partitionBy("year", "mo", "da")
d) Window.partitionBy("stn", "year", "mo", "da")
e) 以上皆非
10.2 超越汇总:使用排名和分析函数
在本节中,我将介绍可以在窗口上应用的其他两种函数族。这两个函数族都为谦逊的窗口提供了额外的功能。这些函数族共同允许执行比聚合函数(如count()、sum()或min())更广泛的操作:
-
排名族,它提供了关于排名(第一、第二,一直到最后)、n-tiles 和非常有用的行号的信息。
-
分析族,尽管其名称暗示了与汇总或排名相关的行为,但实际上涵盖了各种与这些行为无关的行为。
两个族都提供了其他 SQL 式功能难以获得的信息窗口(如果你真的喜欢无用的编码谜题,尝试仅使用基本 SQL 功能来折磨 SQL 语言以重现窗口函数的行为,这被留作练习)。因为它们为窗口添加了新功能,我还将介绍如何在数据框中排序值(当你想要对记录进行排名时非常有用)。
对于本节,我使用了一个更小的数据帧——保留 10 条记录和 stn、year、mo、da、temp 和 count_temp 列,这样我们就可以在 show() 时看到它的全部。我发现这极大地帮助了理解正在发生的事情。这个新的数据帧称为 gsod_light,可在书籍的仓库中找到(以 Parquet 格式提供,这是一种针对快速检索列数据优化的数据格式;参见 databricks.com/glossary/what-is-parquet)。所有示例也可以使用原始的 gsod 数据帧运行,或者如果您有一个更强大的集群,甚至可以运行更多年份。
列表 10.8 从书籍的代码仓库中读取 gsod_light
gsod_light = spark.read.parquet("./data/window/gsod_light.parquet")
gsod_light.show()
# +------+----+---+---+----+----------+
# | stn|year| mo| da|temp|count_temp|
# +------+----+---+---+----+----------+
# |994979|2017| 12| 11|21.3| 21|
# |998012|2017| 03| 02|31.4| 24|
# |719200|2017| 10| 09|60.5| 11|
# |917350|2018| 04| 21|82.6| 9|
# |076470|2018| 06| 07|65.0| 24|
# |996470|2018| 03| 12|55.6| 12|
# |041680|2019| 02| 19|16.1| 15|
# |949110|2019| 11| 23|54.9| 14|
# |998252|2019| 04| 18|44.7| 11|
# |998166|2019| 03| 20|34.8| 12|
# +------+----+---+---+----+----------+
现在我们有一个小但易于推理的数据帧,让我们来探索排名函数。
10.2.1 排名函数:快速,谁是第一?
本节涵盖了排名函数:使用 rank() 进行非连续排名,使用 dense_rank() 进行连续排名,使用 percent_rank() 进行百分位排名,使用 ntile() 进行分块,最后使用 row_number() 获取裸行号。排名函数用于获取每个窗口分区的顶部(或底部)记录,或者更普遍地,根据某些列的值进行排序。例如,如果您想获取每个站点/月份的前三个最热的日子,排名函数会让这变成一件轻而易举的事情。由于排名函数的行为相当相似,因此最好一次性介绍。别担心,我保证它不会读起来像技术手册。
排名函数在一生中只有一个目的:根据字段的值对记录进行排名。正因为如此,我们需要对窗口内的值进行排序。这就是窗口的 orderBy() 方法。在列表 10.9 中,我创建了一个新的窗口,temp_per_month_asc,它根据 mo 列对数据帧进行分区,并按 count_temp 列对分区中的每条记录进行排序。就像对数据帧进行排序一样,orderBy() 将按升序排序值。
小贴士:在命名我的窗口时,我喜欢给它们起名字,这样在阅读代码时读起来会更好。在这种情况下,我可以阅读代码并知道我的列将覆盖每个月,按记录的温度计数进行排序。不需要添加 _window 后缀。
列表 10.9 按月分区的有序窗口
temp_per_month_asc = Window.partitionBy("mo").orderBy("count_temp") ❶❷
❶ 我们按 mo 列的值对窗口分区进行排序。
❷ 在每个窗口内,记录将根据 count_temp 列的值进行排序。
金、银、铜:使用 rank() 进行简单排名
本节介绍最简单、最直观的排名形式,使用 rank() 函数。使用 rank(),每条记录根据一个(或多个)列中的值获得一个位置。相同的值具有相同的排名——就像奥运会的获奖者一样,相同的分数/时间会产生相同的排名(除非你作弊!)。
rank() 不需要参数,因为它根据窗口规范的 orderBy() 方法进行排名;按照一列排序但按另一列排名是没有意义的。
列表 10.10 根据 count_temp 列的值进行 rank()
gsod_light.withColumn(
"rank_tpm", F.rank().over(temp_per_month_asc) ❶
).show()
# +------+----+---+---+----+----------+--------+
# | stn|year| mo| da|temp|count_temp|rank_tpm|
# +------+----+---+---+----+----------+--------+
# |949110|2019| 11| 23|54.9| 14| 1| ❷
# |996470|2018| 03| 12|55.6| 12| 1| ❸
# |998166|2019| 03| 20|34.8| 12| 1| ❸
# |998012|2017| 03| 02|31.4| 24| 3| ❹
# |041680|2019| 02| 19|16.1| 15| 1|
# |076470|2018| 06| 07|65.0| 24| 1|
# |719200|2017| 10| 09|60.5| 11| 1|
# |994979|2017| 12| 11|21.3| 21| 1|
# |917350|2018| 04| 21|82.6| 9| 1|
# |998252|2019| 04| 18|44.7| 11| 2|
# +------+----+---+---+----+----------+--------+
❶ rank() 函数不需要列名;所有内容都已定义为窗口规范的一部分。
❷ 当窗口中只有一个记录时,我们得到排名为 1。
❸ 当窗口中有一个或多个记录的 orderBy() 列(此处为 count_temp = 12)值相同时,rank() 会给这两个记录相同的排名。
❹ 现在,因为我们窗口中有两个排名为 1 的记录,第三个记录的排名将是 3。
函数 rank() 根据排序值或窗口规范中我们调用的 orderBy() 方法提供的列(s)的值,为每个记录提供非连续排名。在列表 10.10 中,对于每个窗口,count_temp 越低,排名越低。当两个记录具有相同的排序值时,它们的排名相同。我们说排名是非连续的,因为当你有多个记录平局时,下一个记录将偏移平局的记录数。例如,对于 mo = 03,我们有 count_temp = 12 的两个记录:两者都是排名 1。下一个记录(count_temp = 24)的位置是 3 而不是 2,因为有两个记录平局第一位置。
排名时无平局:使用 dense_rank()
如果我们想要一个更密集的排名,为记录分配连续的排名呢?请使用 dense_rank()。与 rank() 的原理相同,平局记录共享相同的排名,但排名之间不会有任何间隔:1,2,3,以此类推。当你想要窗口中的第二个(或第三个,或任何序数位置)值,而不是记录本身时,这很有用。
列表 10.11 使用 dense_rank() 避免排名中的间隔
gsod_light.withColumn(
"rank_tpm", F.dense_rank().over(temp_per_month_asc) ❶
).show()
# +------+----+---+---+----+----------+--------+
# | stn|year| mo| da|temp|count_temp|rank_tpm|
# +------+----+---+---+----+----------+--------+
# |949110|2019| 11| 23|54.9| 14| 1|
# |996470|2018| 03| 12|55.6| 12| 1| ❷
# |998166|2019| 03| 20|34.8| 12| 1| ❷
# |998012|2017| 03| 02|31.4| 24| 2| ❸
# |041680|2019| 02| 19|16.1| 15| 1|
# |076470|2018| 06| 07|65.0| 24| 1|
# |719200|2017| 10| 09|60.5| 11| 1|
# |994979|2017| 12| 11|21.3| 21| 1|
# |917350|2018| 04| 21|82.6| 9| 1|
# |998252|2019| 04| 18|44.7| 11| 2|
# +------+----+---+---+----+----------+--------+
❶ 使用 dense_rank() 而不是 rank()。
❷ 当密集排名出现平局时,两个记录具有相同的排名。
❸ 与 rank() 不同,密集排名是连续的,不管前一个排名有多少平局。
剩下的三个排名函数 percent_rank()、ntile() 和 row_number() 虽然较为特殊,但仍然很有用。我发现它们用视觉方式解释得更好。
排名?评分?percent_rank() 给你两者!
排名通常被视为序数操作:第一,第二,第三,等等。如果你想要更接近范围的概念,也许是一个百分比,可以反映记录相对于同一窗口分区中其他记录的位置,请使用 percent_rank()。
对于每个窗口,percent_rank() 将根据排序值计算百分比排名(介于零和一之间)。对于数学倾向的人,公式如下。

列表 10.12 计算每年每个记录的温度的百分比排名
temp_each_year = each_year.orderBy("temp") ❶
gsod_light.withColumn(
"rank_tpm", F.percent_rank().over(temp_each_year)
).show()
# +------+----+---+---+----+----------+------------------+
# | stn|year| mo| da|temp|count_temp| rank_tpm|
# +------+----+---+---+----+----------+------------------+
# |041680|2019| 02| 19|16.1| 15| 0.0|
# |998166|2019| 03| 20|34.8| 12|0.3333333333333333|
# |998252|2019| 04| 18|44.7| 11|0.6666666666666666| ❷
# |949110|2019| 11| 23|54.9| 14| 1.0|
# |994979|2017| 12| 11|21.3| 21| 0.0|
# |998012|2017| 03| 02|31.4| 24| 0.5|
# |719200|2017| 10| 09|60.5| 11| 1.0|
# |996470|2018| 03| 12|55.6| 12| 0.0|
# |076470|2018| 06| 07|65.0| 24| 0.5|
# |917350|2018| 04| 21|82.6| 9| 1.0|
# +------+----+---+---+----+----------+------------------+
❶ 你可以通过在它上面链式调用额外的方法来从另一个窗口规范创建一个窗口规范。在这里,我创建了一个按年排序的版本,它根据年份对记录进行分区。
❷ 例如,这条记录在 2019 年有两个记录的值小于 44.7,在窗口中总共有四个记录:2 ÷(4 - 1) = 0.666。
使用 ntile() 根据排名创建桶
本节介绍了一个实用的函数,它允许你根据数据的排名创建任意数量的桶(称为 瓦片)。你可能听说过四分位数(4 个瓦片)、五分位数(5 个)、十分位数(10 个),甚至百分位数(100 个)。ntile() 函数用于计算给定参数 n 的 n-瓦片。下一列表中的代码在图 10.3 中进行了视觉描述。

图 10.3 gsod_light 中三个窗口分区的两瓦片。如果我们把每个窗口看作一个矩形,每个值在该矩形内占据相同的空间。有两个瓦片,低于 50% 标记的值(包括重叠的)在第一个瓦片中,而完全超过的值在第二个瓦片中。
列表 10.13 计算窗口中的两瓦片值
gsod_light.withColumn("rank_tpm", F.ntile(2).over(temp_each_year)).show()
# +------+----+---+---+----+----------+--------+
# | stn|year| mo| da|temp|count_temp|rank_tpm|
# +------+----+---+---+----+----------+--------+
# |041680|2019| 02| 19|16.1| 15| 1|
# |998166|2019| 03| 20|34.8| 12| 1|
# |998252|2019| 04| 18|44.7| 11| 2|
# |949110|2019| 11| 23|54.9| 14| 2|
# |994979|2017| 12| 11|21.3| 21| 1|
# |998012|2017| 03| 02|31.4| 24| 1|
# |719200|2017| 10| 09|60.5| 11| 2|
# |996470|2018| 03| 12|55.6| 12| 1|
# |076470|2018| 06| 07|65.0| 24| 1|
# |917350|2018| 04| 21|82.6| 9| 2|
# +------+----+---+---+----+----------+--------+
使用 row_number() 的普通行号
本节介绍了 row_number(),它正是这样做的:给定一个有序窗口,它将为每个记录提供一个递增的排名(1,2,3,……),无论是否存在平局(平局记录的行号是不确定的,所以如果你需要可重复的结果,请确保对每个窗口进行排序,以便没有平局)。这与对每个窗口进行索引是相同的。
列表 10.14 使用 row_number() 对每个窗口分区内的记录进行编号
gsod_light.withColumn(
"rank_tpm", F.row_number().over(temp_each_year)
).show()
# +------+----+---+---+----+----------+--------+
# | stn|year| mo| da|temp|count_temp|rank_tpm|
# +------+----+---+---+----+----------+--------+
# |041680|2019| 02| 19|16.1| 15| 1| ❶
# |998166|2019| 03| 20|34.8| 12| 2| ❶
# |998252|2019| 04| 18|44.7| 11| 3| ❶
# |949110|2019| 11| 23|54.9| 14| 4| ❶
# |994979|2017| 12| 11|21.3| 21| 1|
# |998012|2017| 03| 02|31.4| 24| 2|
# |719200|2017| 10| 09|60.5| 11| 3|
# |996470|2018| 03| 12|55.6| 12| 1|
# |076470|2018| 06| 07|65.0| 24| 2|
# |917350|2018| 04| 21|82.6| 9| 3|
# +------+----+---+---+----+----------+--------+
❶ row_number() 将为你的窗口中的每条记录提供严格递增的排名。
输家优先:使用 orderBy() 对 WindowSpec 进行排序
最后,如果我们想反转窗口的顺序怎么办?与数据框上的 orderBy() 方法不同,窗口上的 orderBy() 方法没有 ascending 参数可以使用。我们需要直接在 Column 对象上使用 desc() 方法。这是一个小麻烦,但很容易解决。
列表 10.15 创建一个按降序排序的列的窗口
temp_per_month_desc = Window.partitionBy("mo").orderBy(
F.col("count_temp").desc() ❶
)
gsod_light.withColumn(
"row_number", F.row_number().over(temp_per_month_desc)
).show()
# +------+----+---+---+----+----------+----------+
# | stn|year| mo| da|temp|count_temp|row_number|
# +------+----+---+---+----+----------+----------+
# |949110|2019| 11| 23|54.9| 14| 1|
# |998012|2017| 03| 02|31.4| 24| 1|
# |996470|2018| 03| 12|55.6| 12| 2|
# |998166|2019| 03| 20|34.8| 12| 3|
# |041680|2019| 02| 19|16.1| 15| 1|
# |076470|2018| 06| 07|65.0| 24| 1|
# |719200|2017| 10| 09|60.5| 11| 1|
# |994979|2017| 12| 11|21.3| 21| 1|
# |998252|2019| 04| 18|44.7| 11| 1|
# |917350|2018| 04| 21|82.6| 9| 2|
# +------+----+---+---+----+----------+----------+
❶ 默认情况下,列将按升序值排序。传递 desc() 方法将反转该列的顺序。
本节介绍了 PySpark 在其窗口函数 API 中提供的不同类型的排名。在非连续/奥运、连续/密集、百分比、瓦片和严格/行号中,当涉及到排名记录时,你有很多选项可供选择。在下一节中,我将介绍分析函数,它包含窗口函数中最酷的功能之一:回顾和前瞻的能力。
10.2.2 分析函数:回顾与前瞻
本节介绍了一组非常有用的函数,它使你能够查看手头记录周围的记录。能够查看前一个或后一个记录,在构建时间序列特征时解锁了许多功能。例如,当对时间序列数据进行建模时,最重要的特征之一就是过去的观察结果。分析窗口函数无疑是做到这一点最简单的方法。
在使用 lag() 和 lead() 之前或之后访问记录
在分析函数家族中,最重要的两个函数是 lag(col, n=1, default=None) 和 lead(col, n=1, default=None),它们分别会给出你正在查看的记录之前和之后的第 n 条记录的 col 列的值。如果通过 lag/lead 偏移的记录超出了窗口的边界,Spark 将默认使用 default。为了避免 null 值,请向可选参数 default 传递一个值。在下一个列表中,我创建了两个列,一个滞后一个记录,另一个滞后两个记录。如果我们超出窗口,我们会得到 null 值,因为我没有提供 default 参数。
列表 10.16 使用 lag() 获取前两个观察的温度
gsod_light.withColumn(
"previous_temp", F.lag("temp").over(temp_each_year)
).withColumn(
"previous_temp_2", F.lag("temp", 2).over(temp_each_year)
).show()
# +------+----+---+---+----+----------+-------------+---------------+
# | stn|year| mo| da|temp|count_temp|previous_temp|previous_temp_2|
# +------+----+---+---+----+----------+-------------+---------------+
# |041680|2019| 02| 19|16.1| 15| null| null|
# |998166|2019| 03| 20|34.8| 12| 16.1| null| ❶
# |998252|2019| 04| 18|44.7| 11| 34.8| 16.1| ❶
# |949110|2019| 11| 23|54.9| 14| 44.7| 34.8|
# |994979|2017| 12| 11|21.3| 21| null| null|
# |998012|2017| 03| 02|31.4| 24| 21.3| null|
# |719200|2017| 10| 09|60.5| 11| 31.4| 21.3|
# |996470|2018| 03| 12|55.6| 12| null| null|
# |076470|2018| 06| 07|65.0| 24| 55.6| null|
# |917350|2018| 04| 21|82.6| 9| 65.0| 55.6|
# +------+----+---+---+----+----------+-------------+---------------+
❶ 第二条记录的前一个观察结果是第三条记录的两次前一个观察结果,依此类推。
使用 cume_dist() 计算记录的累积分布
我们将要介绍的最后一个分析函数是 cume_dist(),它与 percent_rank() 类似。cume_dist(),正如其名称所示,提供的是累积分布(在统计意义上的术语),而不是排名(在 percent_rank() 中表现突出)。
就像 percent_rank() 一样,我发现通过公式解释它更容易:

在实践中,我在进行某些变量的累积分布的 EDA(探索性数据分析)时使用它。
列表 10.17 在窗口上使用 percent_rank() 和 cume_dist()
gsod_light.withColumn(
"percent_rank", F.percent_rank().over(temp_each_year)
).withColumn("cume_dist", F.cume_dist().over(temp_each_year)).show()
# +------+----+---+---+----+----------+----------------+----------------+
# | stn|year| mo| da|temp|count_temp| percent_rank| cume_dist|
# +------+----+---+---+----+----------+----------------+----------------+
# |041680|2019| 02| 19|16.1| 15| 0.0| 0.25|
# |998166|2019| 03| 20|34.8| 12|0.33333333333333| 0.5|
# |998252|2019| 04| 18|44.7| 11|0.66666666666666| 0.75|
# |949110|2019| 11| 23|54.9| 14| 1.0| 1.0|
# |994979|2017| 12| 11|21.3| 21| 0.0|0.33333333333333|
# |998012|2017| 03| 02|31.4| 24| 0.5|0.66666666666666|
# |719200|2017| 10| 09|60.5| 11| 1.0| 1.0|
# |996470|2018| 03| 12|55.6| 12| 0.0|0.33333333333333|
# |076470|2018| 06| 07|65.0| 24| 0.5|0.66666666666666|
# |917350|2018| 04| 21|82.6| 9| 1.0| 1.0|
# +------+----+---+---+----+----------+----------------+----------------+
cume_dist() 是一个分析函数,而不是排名函数,因为它不提供排名。相反,它提供了数据框中记录的累积密度函数 F(x)(对于那些对统计感兴趣的人来说)。
本节介绍了窗口函数的丰富多样性。虽然它读起来有点像自助餐,但窗口函数不过是作用在窗口上的函数,就像我们在第四章和第五章中看到的函数一次作用于整个数据框一样。一旦你在野外看到它们的应用,就很容易识别它们的好用例,并伸手去拿你的新工具。在下一节中,我将介绍窗口框架,这是一个强大的工具,用于改变在窗口函数的计算中使用的记录。
练习 10.2
如果你有一个所有有序值都相同的窗口,应用 ntile() 到窗口的结果是什么?
10.3 拉伸这些窗口!使用行和范围边界
本节不仅超越了每个记录的统一窗口定义。我介绍了如何根据行和范围构建静态、增长和无界窗口。能够微调窗口的边界通过灵活运用静态窗口分区概念,增强了代码的能力。在本节的结尾,你将能够完全掌握在 PySpark 中使用窗口。
我从这个看似无害的操作开始本节:对两个相同分区窗口的平均值进行计算。唯一的区别是第一个窗口没有排序,而第二个窗口有排序。当然,窗口的顺序对平均值的计算应该没有影响,对吧?
查看以下列表——相同的窗口函数,几乎相同的窗口(除了排序),不同的结果。
列表 10.18:对窗口进行排序和计算平均值
not_ordered = Window.partitionBy("year")
ordered = not_ordered.orderBy("temp")
gsod_light.withColumn(
"avg_NO", F.avg("temp").over(not_ordered)
).withColumn("avg_O", F.avg("temp").over(ordered)).show()
# +------+----+---+---+----+----------+----------------+------------------+
# | stn|year| mo| da|temp|count_temp| avg_NO| avg_O|
# +------+----+---+---+----+----------+----------------+------------------+
# |041680|2019| 02| 19|16.1| 15| 37.625| 16.1|
# |998166|2019| 03| 20|34.8| 12| 37.625| 25.45|
# |998252|2019| 04| 18|44.7| 11| 37.625|31.866666666666664|
# |949110|2019| 11| 23|54.9| 14| 37.625| 37.625|
# |994979|2017| 12| 11|21.3| 21|37.7333333333334| 21.3|
# |998012|2017| 03| 02|31.4| 24|37.7333333333334| 26.35|
# |719200|2017| 10| 09|60.5| 11|37.7333333333334|37.733333333333334|
# |996470|2018| 03| 12|55.6| 12| 67.733333333333| 55.6|
# |076470|2018| 06| 07|65.0| 24| 67.733333333333| 60.3|
# |917350|2018| 04| 21|82.6| 9| 67.733333333333| 67.73333333333333|
# +------+----+---+---+----+----------+----------------+------------------+
↑ ❶ ↑ ❷
❶ 所有都很好:每个窗口的平均值是一致的,结果是合理的。
❷ 有些奇怪的事情发生了。看起来每个窗口都是按记录逐个增长的,所以平均值每次都会变化。
这很有趣。当窗口的顺序出错时,计算会受到影响。官方 Spark API 文档告诉我们,当未定义排序时,默认使用无界窗口框架(rowFrame, unboundedPreceding, unboundedFollowing)。当定义了排序时,默认使用增长窗口框架(rangeFrame, unboundedPreceding, currentRow)。
解密这种新、神秘行为的关键是理解我们可以构建的窗口框架类型以及它们的使用方式。我首先介绍不同的框架大小(静态、增长、无界)以及如何推理它们,然后再添加第二个维度,即框架类型(范围与行)。在本节的结尾,对之前代码的解释将变得完全合理,你将能够根据具体情况灵活运用窗口技能。
10.3.1 计数,窗口风格:静态、增长、无界
本节涵盖了窗口的边界,我们称之为窗口框架。我将传统的包含所有窗口(窗口等于整个分区)的做法打破,引入基于记录的边界。这将在使用窗口函数时提供令人难以置信的新灵活性,因为它控制了记录在窗口中的可见范围。你将能够创建只查看过去并避免在处理时间序列时特征泄露的窗口函数。这仅仅是灵活窗口框架的许多用例之一!
小贴士:特征泄露发生在构建预测模型时使用未来信息。一个例子就是使用明天的降雨量来预测即将到来的周的总降雨量。有关特征和特征泄露的更多信息,请参阅第十二章和第十三章。
在开始之前,让我们先想象一下窗口的视觉概念:当一个函数作用于它时,窗口规范会根据一个或多个列值将数据帧分区,然后(可能)对它们进行排序。Spark 还提供了 rowsBetween() 和 rangeBetween() 方法来创建窗口框架边界。在本节中,我专注于行边界,因为它们更接近我们预期的结果。第 10.3.2 节解释了范围与行之间的区别。

图 10.4 窗口内的不同可能的边界。有些是数值的;有些有保留关键字。当我们查看记录的前面时,我们向前计数(直到 Window.unboundedFollowing),当我们查看记录的后面时,我们向后计数(直到 Window.unboundedPreceding)。
当使用无界/无序窗口时,我们不在乎哪个记录是哪个。当我们使用排名或分析函数时,情况就改变了。例如,对于一个排名、滞后或领先,Spark 会将正在处理的记录称为 Window 的 .currentRow。 (我保留了类的名称。使用 currentRow 关键字使得你正在使用窗口函数显而易见。)之前的记录取值为 -1,以此类推,直到第一个记录,命名为 Window.unboundedPreceding。当前行之后的记录取值为 1,以此类推,直到最后一条记录,命名为 Window.unboundedFollowing。
警告 不要 使用数值来表示窗口中的第一条或最后一条记录。这会使你的代码更难推理,而且你永远不知道窗口何时会超过那个大小。内部,Spark 会将 Window.unboundedPreceding 和 Window.unboundedFollowing 转换为适当的数值,所以你不需要这样做。
让我们回到列表 10.18;我们可以在我们的窗口规范中“添加”边界。在列表 10.19 中,我明确添加了 Spark 在没有提供任何边界时假设的边界。这意味着无论我们是否定义边界(列表 10.19),not_ordered和ordered都会提供相同的结果。如果我想非常准确,ordered 窗口规范是由范围而不是行界定的,但对我们这个数据帧来说,效果是一样的。我现在会为了易于理解而牺牲准确性,但如果你将其应用于 gsod 数据帧,结果会有所不同(见第 10.3.2 节)。
列表 10.19 使用显式窗口边界重写窗口规范
not_ordered = Window.partitionBy("year").rowsBetween(
Window.unboundedPreceding, Window.unboundedFollowing ❶
)
ordered = not_ordered.orderBy("temp").rangeBetween(
Window.unboundedPreceding, Window.currentRow ❷
)
❶ 这个窗口是无界的:从第一条记录到最后一条记录,每条记录都在窗口内。
❷ 这个窗口正在向左扩展:直到当前行值的所有记录都包含在窗口内。
因为在计算avg_NO时使用的窗口是无界的,意味着它跨越了窗口中的第一条记录到最后一条记录,所以平均数在整个窗口内是一致的。而在计算avg_O时,窗口在左侧是增长的,意味着右侧的记录被限制在currentRow上,而左侧的记录被设置为窗口的第一个值。当你从一个记录移动到下一个记录时,平均数会覆盖越来越多的值。窗口的最后一条记录的平均数包含了所有值(因为currentRow是窗口的最后一条记录)。一个静态的窗口框架不过是一个窗口,其中两个记录相对于当前行都是有限制的;例如,对于包含当前行、紧邻的前一条记录和紧邻的后一条记录的窗口,使用rowsBetween(-1, 1)。
警告:如果你的窗口规范没有排序,使用边界是一个非确定性的操作。Spark 无法保证你的窗口将包含与我们在选择边界之前在窗口内未排序相同的值。这也适用于你在之前的操作中对数据帧进行排序的情况。如果你使用边界,请提供一个明确的排序子句。
在实践中,很容易知道你需要哪种类型的窗口。排名和分析函数依赖于有序窗口,因为它们的适用性中顺序很重要。聚合函数不关心值的排序,所以你不应该使用有序窗口规范,除非你想要部分聚合。
本节介绍了不同类型的窗口边界,并部分解释了在有序窗口规范中使用时增长平均数的行为。在下一节中,我将介绍最后一个核心窗口概念:范围与行。
10.3.2 你所是 vs. 你所在:范围 vs. 行
本节介绍了行窗口与范围窗口之间微妙但极其重要的区别。这个概念解锁了构建关注有序列内容而不是其位置的窗口的选项。当处理日期和时间时,与主要度量标准不同的时间间隔时,使用范围很有用。例如,gsod数据帧收集每日温度信息。如果我们想将这个温度与上个月的平均值进行比较会发生什么?月份有 28 天、29 天、30 天或 31 天。这就是范围变得有用的地方。
首先,我在列表 10.20 中对gsod_light数据框进行了一些微小的转换。我使用F.lit(2019)作为列值将 2019 年的所有日期转换为整数,以便在按年分割时只有一个窗口;这将给我们更多的数据来使用范围。我还创建了一个包含观测日期的dt列,在将其转换为dt_num列的整数值之前。PySpark 中的范围窗口仅在数值列上工作;unix_timestamp()将日期转换为自 1970-01-01 00:00:00 UTC(UNIX 纪元)以来的秒数。这为我们提供了一个可用的类似日期的数字,我们可以用它来创建范围窗口。
列表 10.20 创建日期列以应用范围窗口
gsod_light_p = (
gsod_light.withColumn("year", F.lit(2019))
.withColumn(
"dt",
F.to_date(
F.concat_ws("-", F.col("year"), F.col("mo"), F.col("da"))
),
)
.withColumn("dt_num", F.unix_timestamp("dt"))
)
gsod_light_p.show()
#
# +------+----+---+---+----+----------+----------+----------+
# | stn|year| mo| da|temp|count_temp| dt| dt_num|
# +------+----+---+---+----+----------+----------+----------+
# |041680|2019| 02| 19|16.1| 15|2019-02-19|1550552400|
# |998012|2019| 03| 02|31.4| 24|2019-03-02|1551502800|
# |996470|2019| 03| 12|55.6| 12|2019-03-12|1552363200|
# |998166|2019| 03| 20|34.8| 12|2019-03-20|1553054400|
# |998252|2019| 04| 18|44.7| 11|2019-04-18|1555560000|
# |917350|2019| 04| 21|82.6| 9|2019-04-21|1555819200|
# |076470|2019| 06| 07|65.0| 24|2019-06-07|1559880000|
# |719200|2019| 10| 09|60.5| 11|2019-10-09|1570593600|
# |949110|2019| 11| 23|54.9| 14|2019-11-23|1574485200|
# |994979|2019| 12| 11|21.3| 21|2019-12-11|1576040400|
# +------+----+---+---+----+----------+----------+----------+
#
↑ ❶ ↑ ❷
❶ 新列的类型是 DateType(),它可以被(按窗口方式)视为一个数字。
❷ 当使用 PySpark 时,窗口必须覆盖数值。使用 unix_timestamp()是将日期/时间戳转换为数字的最简单方法。
对于一个简单的范围窗口,让我们计算给定日期一个月前和一个月后的温度平均值。因为我们的数值日期是以秒为单位的,所以我会保持简单,并说 1 个月=30 天=720 小时=43,200 分钟=2,592,000 秒。¹ 从视觉上看,记录的窗口将类似于图 10.5:对于每条记录,Spark 计算左和右(或下和上)窗口边界,并使用这些边界来确定记录是否在窗口内。

图 10.5 显示范围在(-2_592_000, 2_592_000)(或±30 天,以秒为单位)的窗口
在下一个列表中,我们创建了一个 60 天(30 天前,30 天后)的范围窗口,按year分区;我们的窗口框架按dt_num排序,因此我们可以使用rangeBetween来处理秒数。
列表 10.21 计算一个 60 天滑动窗口的平均温度
ONE_MONTH_ISH = 30 * 60 * 60 * 24 # or 2_592_000 seconds
one_month_ish_before_and_after = (
Window.partitionBy("year")
.orderBy("dt_num")
.rangeBetween(-ONE_MONTH_ISH, ONE_MONTH_ISH) ❶
)
gsod_light_p.withColumn(
"avg_count", F.avg("count_temp").over(one_month_ish_before_and_after)
).show()
# +------+----+---+---+----+----------+----------+----------+-------------+
# | stn|year| mo| da|temp|count_temp| dt| dt_num| avg_count|
# +------+----+---+---+----+----------+----------+----------+-------------+
# |041680|2019| 02| 19|16.1| 15|2019-02-19|1550552400| 15.75|
# |998012|2019| 03| 02|31.4| 24|2019-03-02|1551502800| 15.75|
# |996470|2019| 03| 12|55.6| 12|2019-03-12|1552363200| 15.75|
# |998166|2019| 03| 20|34.8| 12|2019-03-20|1553054400| 14.8|
# |998252|2019| 04| 18|44.7| 11|2019-04-18|1555560000|10.6666666666|
# |917350|2019| 04| 21|82.6| 9|2019-04-21|1555819200| 10.0|
# |076470|2019| 06| 07|65.0| 24|2019-06-07|1559880000| 24.0|
# |719200|2019| 10| 09|60.5| 11|2019-10-09|1570593600| 11.0|
# |949110|2019| 11| 23|54.9| 14|2019-11-23|1574485200| 17.5|
# |994979|2019| 12| 11|21.3| 21|2019-12-11|1576040400| 17.5|
# +------+----+---+---+----+----------+----------+----------+-------------+
❶ 范围变为(当前行值 – ONE_MONTH_ISH,当前行值 + ONE_MONTH_ISH)。
对于窗口中的每条记录,Spark 根据当前行的值(来自字段dt_num)计算范围边界,并确定它将聚合的实际窗口。这使得计算滑动或增长的时间/日期窗口变得容易:当处理行范围时,你只需说“X条记录之前和之后。”当使用Window.currentRow/unboundedFollowing/unboundedPreceding与范围窗口一起使用时,Spark 将使用记录的值作为范围边界。如果你对于给定时间有多个观测值,基于行的窗口框架将不会工作。使用范围并查看实际值可以使你的窗口尊重你应用它的上下文。

图 10.6 Spark 中可用的窗口类型矩阵
这一节解释了基于行和基于范围的窗口之间的区别,以及何时最好应用其中一个而不是另一个。这完成了窗口函数的“标准”部分。带着这一章的知识,你应该能够舒适地将窗口函数作为数据分析或特征工程操作的一部分来应用。在结束这一章之前,我增加了一个可选的额外部分,介绍了我们如何可以在窗口上应用 UDF。这样,你将能够打破常规,创建自己的窗口函数。
练习 10.3
如果你有一个包含 1,000,001 行的数据框架,其中有序列ord由F.lit(10)定义,以下窗口函数的结果是什么?
-
F.count("ord").over(Window.partitionBy().orderBy("ord").rowsBetween(-2, 2)) -
F.count("ord").over(Window.partitionBy().orderBy("ord").rangeBetween(-2, 2))
10.4 完美循环:在窗口中使用 UDF
这一节介绍了我认为 PySpark 能做的最 PySpark 的事情:在窗口中使用 UDF。它使用了两个非常 Sparky 的东西:UDF 和我们在第九章学到的拆分-应用-组合范式。这同样也是 Python 特有的,因为它依赖于 pandas UDF。PySpark 到底了!在窗口定义中使用 pandas UDF 在需要 pandas UDF 的灵活性时很有用。例如,当你需要 PySpark 中可用的窗口函数集中未定义的功能时,只需定义 UDF 来实现该功能!
这一节并不长,因为我们是在现有知识的基础上构建的。要全面复习 pandas UDF,请参阅第九章。应用 pandas UDF 的配方非常简单:
-
我们需要使用一个Series to Scalar UDF(或一个分组聚合 UDF)。PySpark 将 UDF 应用于每个窗口(每条记录一次),并将(标量)值作为结果。
-
仅支持 Spark 2.4 及以上版本的无界窗口框架上的 UDF。
-
仅支持 Spark 3.0 及以上版本的有界窗口框架上的 UDF。
剩下的呢?一切照旧。在列表 10.22 中,我使用 Spark 3 类型的提示符号创建了一个median UDF。如果你使用 Spark 2.4,将装饰器改为@F.pandas_udf ("double", PandasUDFType.GROUPED_AGG)并移除类型提示。这个简单的median函数计算 pandas Series的中位数。然后我将它两次应用于gsod_light数据框架。这里没有什么特别之处可以看;它只是正常工作。
警告 不要修改你作为输入传递的Series。这样做会在你的代码中引入难以发现的错误,我们在第九章中看到了 UDF 错误的恶劣性。
列表 10.22 在窗口区间使用 pandas UDF
import pandas as pd
# Spark 2.4, use the following
# @F.pandas_udf("double", PandasUDFType.GROUPED_AGG)
@F.pandas_udf("double")
def median(vals: pd.Series) -> float:
return vals.median()
gsod_light.withColumn(
"median_temp", median("temp").over(Window.partitionBy("year")) ❶
).withColumn(
"median_temp_g",
median("temp").over(
Window.partitionBy("year").orderBy("mo", "da") ❷
), ❷
).show()
#
# +------+----+---+---+----+----------+-----------+-------------+
# | stn|year| mo| da|temp|count_temp|median_temp|median_temp_g|
# +------+----+---+---+----+----------+-----------+-------------+
# |041680|2019| 02| 19|16.1| 15| 39.75| 16.1|
# |998166|2019| 03| 20|34.8| 12| 39.75| 25.45|
# |998252|2019| 04| 18|44.7| 11| 39.75| 34.8|
# |949110|2019| 11| 23|54.9| 14| 39.75| 39.75|
# |998012|2017| 03| 02|31.4| 24| 31.4| 31.4|
# |719200|2017| 10| 09|60.5| 11| 31.4| 45.95|
# |994979|2017| 12| 11|21.3| 21| 31.4| 31.4|
# |996470|2018| 03| 12|55.6| 12| 65.0| 55.6|
# |917350|2018| 04| 21|82.6| 9| 65.0| 69.1|
# |076470|2018| 06| 07|65.0| 24| 65.0| 65.0|
# +------+----+---+---+----+----------+-----------+-------------+
#
↑ ❸ ↑ ❹
❶ 用户定义函数(UDF)被应用于一个无界/无序的窗口框架中。
❷ 现在相同的用户定义函数(UDF)被应用于一个有界/有序的窗口框架中。
❸ 由于窗口是无界的,窗口内的每个记录都具有相同的中位数。
❹ 由于窗口是有界到右边的,随着我们向窗口中添加更多记录,中位数会发生变化。
10.5 在窗口中寻找:成功窗口函数的主要步骤
这就结束了关于窗口函数的章节。我鼓励你扩展你的数据处理、分析和特征工程工具集,以包含基于窗口函数的转换。如果你在执行某种转换时遇到难题,请始终记住使用窗口函数的基本参数:
-
我想要执行哪种操作?总结、排名,还是向前/向后查看。
-
我应该如何构建我的窗口?它应该是有界还是无界?我是否需要每个记录都有相同的窗口值(无界),或者答案应该取决于记录在窗口中的位置(有界)?在界定窗口框架时,你通常还希望对其进行排序。
-
对于有界窗口,你希望窗口框架根据记录的位置(基于行)还是记录的值(基于范围)来设置?
-
最后,请记住,窗口函数并不会使你的数据框变得特殊。在你应用函数之后,你可以进行过滤、按组分组,甚至应用另一个完全不同的窗口。
作为临别礼物,窗口函数似乎成了数据分析师和科学家面试中的宠儿。在 PySpark 中应用窗口函数将变得习以为常!
摘要
-
窗口函数是在数据框的一部分上应用的功能,这部分称为窗口框架。它们可以执行聚合、排名或分析操作。窗口函数将返回具有相同记录数量的数据框,与它的兄弟
groupby-aggregate操作和 group map UDF 不同。 -
窗口框架是通过窗口规范定义的。窗口规范规定了数据框是如何分割的(
partitionBy()),如何排序(orderBy()),以及如何分割(rowsBetween()/rangeBetween())。 -
默认情况下,无序窗口框架将是无界的,这意味着窗口框架将等于每个记录的窗口分区。有序窗口框架将向左扩展,这意味着每个记录都将有一个从窗口分区中的第一条记录到当前记录的窗口框架。
-
窗口可以通过行进行界定,这意味着窗口框架中包含的记录与作为参数传递的行边界相关联(范围边界添加到当前行的行号),或者通过范围进行界定,这意味着窗口框架中包含的记录取决于当前行的值(范围边界添加到值)。
额外练习
练习 10.4
使用以下代码,首先确定每年最热的天气,然后计算平均温度。当有超过两个发生时会发生什么?
each_year = Window.partitionBy("year")
(gsod
.withColumn("min_temp", F.min("temp").over(each_year))
.where("temp = min_temp")
.select("year", "mo", "da", "stn", "temp")
.orderBy("year", "mo", "da")
.show())
练习 10.5
你将如何创建一个完整的排名,这意味着temp_per_month_asc中的每个记录都有一个唯一的排名,使用gsod_light数据框?对于具有相同orderBy()值的记录,排名的顺序并不重要。
temp_per_month_asc = Window.partitionBy("mo").orderBy("count_temp")
gsod_light = spark.read.parquet("./data/window/gsod_light.parquet")
gsod_light.withColumn(
"rank_tpm", F.rank().over(temp_per_month_asc) ❶
).show()
# +------+----+---+---+----+----------+--------+
# | stn|year| mo| da|temp|count_temp|rank_tpm|
# +------+----+---+---+----+----------+--------+
# |949110|2019| 11| 23|54.9| 14| 1|
# |996470|2018| 03| 12|55.6| 12| 1| ❶
# |998166|2019| 03| 20|34.8| 12| 1| ❶
# |998012|2017| 03| 02|31.4| 24| 3|
# |041680|2019| 02| 19|16.1| 15| 1|
# |076470|2018| 06| 07|65.0| 24| 1|
# |719200|2017| 10| 09|60.5| 11| 1|
# |994979|2017| 12| 11|21.3| 21| 1|
# |917350|2018| 04| 21|82.6| 9| 1|
# |998252|2019| 04| 18|44.7| 11| 2|
# +------+----+---+---+----+----------+--------+
❶ 这些记录应该是 1 和 2。
练习 10.6
使用 gsod 数据框(而不是 gsod_light),创建一个新列,如果给定站点的温度是该站点在七天时间窗口(前后)内的最高温度,则为 True,否则为 False。
练习 10.7
你会如何创建一个像以下代码一样的窗口,但考虑到月份有不同的天数?例如,三月有 31 天,但四月有 30 天,所以你不能在固定天数上做窗口指定。
(提示:我的解决方案不使用 dt_num。)
ONE_MONTH_ISH = 30 * 60 * 60 * 24 # or 2_592_000 seconds
one_month_ish_before_and_after = (
Window.partitionBy("year")
.orderBy("dt_num")
.rangeBetween(-ONE_MONTH_ISH, ONE_MONTH_ISH)
)
gsod_light_p = (
gsod_light.withColumn("year", F.lit(2019))
.withColumn(
"dt",
F.to_date(
F.concat_ws("-", F.col("year"), F.col("mo"), F.col("da"))
),
)
.withColumn("dt_num", F.unix_timestamp("dt"))
)
gsod_light_p.withColumn(
"avg_count", F.avg("count_temp").over(one_month_ish_before_and_after)
).show()
# +------+----+---+---+----+----------+----------+----------+-------------+
# | stn|year| mo| da|temp|count_temp| dt| dt_num| avg_count|
# +------+----+---+---+----+----------+----------+----------+-------------+
# |041680|2019| 02| 19|16.1| 15|2019-02-19|1550552400| 15.75|
# |998012|2019| 03| 02|31.4| 24|2019-03-02|1551502800| 15.75|
# |996470|2019| 03| 12|55.6| 12|2019-03-12|1552363200| 15.75|
# |998166|2019| 03| 20|34.8| 12|2019-03-20|1553054400| 14.8|
# |998252|2019| 04| 18|44.7| 11|2019-04-18|1555560000|10.6666666666|
# |917350|2019| 04| 21|82.6| 9|2019-04-21|1555819200| 10.0|
# |076470|2019| 06| 07|65.0| 24|2019-06-07|1559880000| 24.0|
# |719200|2019| 10| 09|60.5| 11|2019-10-09|1570593600| 11.0|
# |949110|2019| 11| 23|54.9| 14|2019-11-23|1574485200| 17.5|
# |994979|2019| 12| 11|21.3| 21|2019-12-11|1576040400| 17.5|
# +------+----+---+---+----+----------+----------+----------+-------------+
¹ 如果我们要对“一个月是一个月”非常严格,请检查练习。
11 更快的 PySpark:理解 Spark 的查询规划
本章涵盖
-
Spark 如何使用 CPU、RAM 和硬盘资源
-
更好地使用内存资源以加快(或避免减慢)计算
-
使用 Spark UI 来查看有关你的 Spark 安装的有用信息
-
Spark 如何将作业拆分为阶段以及如何分析和监控这些阶段
-
将转换分类为窄操作和宽操作,以及如何对它们进行推理
-
适度使用缓存并避免因不当缓存而导致的性能下降
想象以下场景:你编写了一个易于阅读、经过深思熟虑的 PySpark 程序。当你将你的程序提交到你的 Spark 集群时,它开始运行。你等待。
我们如何窥视内部并查看程序的进度?调试哪个步骤花费了很长时间?本章是关于理解我们如何访问有关我们的 Spark 实例的信息,例如其配置和布局(CPU、内存等)。我们还从原始 Python 代码到优化的 Spark 指令跟踪程序的执行。这些知识将消除你程序中的许多神秘;你将处于一个位置,可以知道 PySpark 作业的每个阶段发生了什么。如果你的程序运行时间过长,本章将向你展示在哪里(以及如何)查找相关信息。
11.1 打开 sesame:导航 Spark UI 以了解环境
本节介绍了 Spark 如何使用分配的计算和内存资源,以及我们如何配置分配给 Spark 的资源数量。有了这个,你将能够根据任务的复杂程度配置你的作业使用更多或更少的资源。
从本地 Spark 升级
到目前为止,我已保持数据集大小可控,以避免需要利用分布式(付费)的 Spark 实例。在学习新技术(或者如果你正在阅读这本书并运行代码示例),即使是一个小型的云集群——这也意味着你正在为大部分时间处于闲置状态的东西付费。在学习、实验或开发小型概念验证时,不要犹豫,使用本地 Spark,在分布式环境中进行测试。这样你就可以避免对云成本感到焦虑,同时确保你的代码健康扩展。
另一方面,本章的一些材料依赖于 Spark 在多台机器上运行。因此,如果你在本地运行 Spark,你的结果将会有所不同。
对于本章,我们回到第二章和第三章中的单词出现次数计数示例。为了避免一些疯狂的翻页,代码在列表 11.1 中重现。我们的程序遵循一套相当简单的步骤:
-
我们创建一个
SparkSession对象来访问 PySpark 的数据帧功能以及连接到我们的 Spark 实例。 -
我们创建一个包含所选目录内所有文本文件(逐行)的数据帧,并计算每个单词的出现次数。
-
我们展示了最常出现的 10 个单词。
列表 11.1 我们的端到端程序统计单词出现次数
from pyspark.sql import SparkSession
import pyspark.sql.functions as F
spark = SparkSession.builder.appName( ❶
"Counting word occurences from a book, one more time." ❶
).getOrCreate() ❶
results = ( ❷
spark.read.text("./data/gutenberg_books/*.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']+", 0).alias("word"))
.where(F.col("word") != "")
.groupby(F.col("word"))
.count()
)
results.orderBy(F.col("count").desc()).show(10) ❸
❶ 就像任何现代 PySpark 程序一样,我们的程序从创建 SparkSession 并连接到我们的 Spark 实例开始。
❷ 结果映射到从数据源加一系列转换得到的数据帧。
❸ 通过显示结果,我们触发了转换链,并显示了出现频率最高的前 10 个单词。
工作从 show() 方法开始:由于它是一个动作,它触发了从 results 变量开始的转换链。我没有打印我们程序的结果,因为(a)我们知道它工作得很好,并且(b)我们专注于 Spark 实际在底层做了什么。
当启动 Spark,无论是本地还是集群上,程序都会为我们分配计算和内存资源。这些资源通过一个名为 Spark UI 的网页门户显示。为了使 UI 可用,我们需要创建并实例化一个 SparkSession,我们在 PySpark 程序的开始就做了这件事。当本地工作时,请访问 localhost:4040 以检查 Spark UI 首页。如果 4040 端口当前正在使用中,Spark 将输出一个 WARN Utils: Service 'SparkUI' could not bind on port 4040. Attempting port ABCD 消息。将冒号后面的 4040 替换为列出的端口号。如果你在一个管理的 Spark 集群、云或本地环境中工作,请参阅你的提供商文档以访问 SparkUI。
Spark UI 的首页(也称为顶部菜单中的作业标签)包含大量信息,我们可以将其分为几个部分:
-
顶部的菜单提供了访问 Spark UI 主要部分的入口,我们将在本章中对其进行探讨。
-
时间线提供了影响你的
SparkSession的活动的视觉概述;在我们的例子中,我们看到集群正在分配资源(一个执行器驱动程序,因为我们本地工作)并执行我们的程序。 -
在我们的例子中,由
show()动作(在 Spark UI 中表示为showString)触发的作业列在页面底部。如果作业正在处理,它将显示为 进行中。

图 11.1 Spark UI 的首页(作业)。我们看到时间线为空,因为唯一发生的事件是 PySpark shell 的启动。
下一节将概述 Spark UI 的不同标签页。在深入了解我们的程序是如何执行之前,我们先了解 Spark 提供的有关其自身配置及其内存和资源使用的信息。
11.1.1 检查配置:环境标签页
本节涵盖了 Spark UI 的环境标签页。此标签页包含我们的 Spark 实例所在环境的配置,因此这些信息对于解决库问题、在遇到奇怪的行为(或错误!)时提供配置信息或了解 Spark 实例的具体行为非常有用。

图 11.2 环境标签页包含有关 Spark 所在的硬件、操作系统和库/软件版本的信息。
环境标签页包含有关集群上机器配置的所有信息。它涵盖了关于已安装的 JVM 和 Scala 版本的信息(记住,Spark 是一个 Scala 程序),以及 Spark 为本次会话使用的选项。如果您想了解每个字段(包括未列出的字段)的完整描述,Spark 配置页面 (spark.apache.org/docs/latest/configuration.html) 包含了一个相当易读的描述。
注意:还有其他部分(Hadoop 属性和类路径条目)在您需要定位特定的 Hadoop 或库问题时很有用,或者在提交错误报告时,但我们为了我们的目的可以跳过它们,而且不会有任何不快。
许多条目都是不言自明的,但在调试一个按计划无法正常工作的 PySpark 作业时,它们仍然很重要。除了几个标识符,如应用程序 ID 和名称外,Spark 还会在 UI 中列出我们为作业提供的任何配置选项和可选库。例如,在第九章中,BigQuery Spark 连接器将列在 spark.jars 和 spark.repl.local.jars 下。
值得在这里提及的唯一 Python 特定选项是 spark.submit.pyFiles。由于我们从 REPL 运行 PySpark,实际上并没有向 Spark 提交任何文件。当使用 spark-submit 与 Python 文件或模块一起使用时,您的文件(名)将列在那里。
注意:集群上安装的 Python 库在 Spark UI 中没有列出。当本地工作时,为我们的 Spark 实例安装一个新的库就像在我们的本地 Python 中安装它一样简单。当在托管集群上工作时,Spark 提供了一些策略来避免手动操作,这很大程度上取决于提供商。(更多信息请见 mng.bz/nYrK)
这一节更多地是关于了解“什么”而不是对每个配置标志进行非常长(且无聊)的描述。通过记住环境标签页中可用的信息,您可以在面对您认为与 OS-、JVM-或(Java/Scala)库相关的问题时迅速定位。如果我们重用第九章的例子,如果您遇到 BigQuery provider not found 错误,您的第一个反应应该是检查环境标签页,看看 jar 是否被列为依赖项。在提交错误报告时,它也会提供大量信息:您现在可以轻松提供详细信息。
下一节涵盖了我们在运行 PySpark 程序时最关心的资源:内存、CPU 和硬盘。更具体地说,我们回顾了 Spark 如何分配资源给执行器以及如何更改默认值。
11.1.2 集体大于部分之和:执行器标签页和资源管理
在本节中,我回顾了 Executors 选项卡,其中包含有关我们 Spark 实例可用的计算和内存资源的信息。在 Spark 作业过程中参考此选项卡允许你监控 Spark 安装的健康状况以及所有节点上的资源使用情况。
点击 Executors 后,我们会看到我们集群中所有节点的摘要和详细视图。由于我是在本地工作,我只看到一个节点,它扮演着驱动器的角色。
当考虑集群的处理能力时,我们会想到 CPU 和 RAM。在图 11.3 中,我的集群由 12 个 CPU 核心组成(local[*]—见 11.1.1 节—可以访问本地 CPU 的所有核心)和 434.4 MiB(梅比字节,或 2²⁰ [1,048,576] 字节,不要与基于 10 的兆字节混淆,后者为 10⁶ 或 1,000,000)。默认情况下,Spark 将为驱动器进程分配 1 GiB(吉字节)的内存。(参见本节末尾的侧边栏,了解如何从 1GiB 转换到 434.4MiB 的公式。)

图 11.3 我的本地 Spark UI Executors 选项卡。我有 434.4 MiB 的存储内存和 12 个 CPU 核心可用。
Spark 主要使用 RAM 的三个目的,如图 11.4 所示:
-
部分 RAM 被保留用于 Spark 内部处理,例如用户数据结构、内部元数据和在大记录处理时防止潜在的内存不足错误。
-
RAM 的第二部分用于操作(操作内存)。这是在数据转换期间使用的 RAM。
-
RAM 的最后一部分用于数据的存储(存储内存)。与从磁盘读取和写入数据相比,RAM 访问要快得多,因此 Spark 会尽可能多地将数据放在内存中。如果操作内存需求超过了可用资源,Spark 将会将一些数据从 RAM 溢出到磁盘。

图 11.4 简化的布局,或 Spark 默认使用的资源。Spark 尽可能地使用 RAM,当 RAM 不足时(通过溢出)会求助于磁盘。
Spark 提供了一些配置标志来更改可用的内存和 CPU 核心数。我们有两组相同的参数来定义驱动器和执行器将能够访问的资源。
在创建 SparkSession 时,你可以设置 master() 方法以连接到特定的集群管理器¹(在集群模式下)并在本地工作时指定从你的计算机分配的资源/核心数。在列表 11.2 中,我决定将核心数从 12 个减少到 8 个,通过在 SparkSession 构造器对象中传递 master("local[8]") 实现。在本地工作时,我们的(单个)机器将托管驱动器并在数据上执行工作。在 Spark 集群的情况下,你将有一个协调工作的驱动器节点、一个集群管理器和一系列托管执行器的工人节点(参见第一章以刷新知识)。
注意 关于 GPU 呢?截至 Spark 3.0,GPU 使用已经大大简化,但 GPU 在 Spark 实例中仍然不是常见的配置。GPU,就像 CPU 一样,会在处理部分的图中。有关更多信息,请查看 Nvidia 网站上的 RAPIDS+Spark 部分 (nvidia.github.io/spark-rapids/Getting-Started/)。大多数,如果不是所有,云提供商都提供将 GPU 节点配置到你的 Spark/Databricks 集群的选项。
内存分配是通过配置标志完成的;在本地工作时最重要的标志是 spark.driver.memory。此标志将大小作为属性,并通过 SparkSession 构造对象的 config() 方法设置。不同的缩写列在表 11.1 中:Spark 不接受小数,因此你需要传递整数值。
表 11.1 Spark 将接受的用于大小的不同值类型。你可以将 1 改为另一个整数值。
| 缩写 | 定义 |
|---|---|
1b |
1 字节 |
1k 或 1kb |
1 kibibyte = 1,024 字节 |
1m 或 1mb |
1 mebibyte = 1,024 kibibytes |
1g 或 1gb |
1 gibibyte = 1,024 mebibytes |
1t 或 1tb |
1 tebibyte = 1,024 gibibytes |
1p 或 1pb |
1 pebibyte = 1,024 tebibytes |
警告 Spark 使用 2 的幂次数(kibibyte 中有 1,024 字节,而 kilobytes 中只有 1,000 字节),而 RAM 内存通常以 10 的幂次数单位显示。
在列表 11.2 中,我将这两个选项组合成一个新的 SparkSession 创建。如果你已经启动了 Spark,请确保将其关闭(退出启动 PySpark 的 shell)并重新启动,以确保 Spark 识别新的配置。在本地机器上工作,除非你有充分的理由这样做,否则将内存分配限制为总 RAM 的 50%,以考虑同时运行的其它程序/任务。对于集群模式的 Spark,文档建议不要超过可用 RAM 的 75%。
列表 11.2 重新启动 PySpark 以更改可用的核心/内存数量
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("Launching PySpark with custom options")
.master("local[8]") ❶
.config("spark.driver.memory", "16g") ❷
).getOrCreate()
# [... Run the program here ...]
❶ local[8] 表示我们只为 master 使用八个核心。
❷ 驱动程序将使用 16 g 而不是默认的 1 g。
如果你正在使用 pyspark 命令(例如,在 SSH 连接到托管云 Spark 实例的主节点时)或使用 spark-submit,你需要将配置作为命令行参数或配置文件中的参数传递(有关更多详细信息,请参阅附录 B)。在我们的例子中,Spark UI 在 java.sun.command 字段中显示配置(请参阅 11.1.1 节),我展示了我们新的 SparkSession 的结果。

图 11.5 在环境标签页中,sun.java.command 具有与我们在 --conf 启动语法中传递的相同的配置标志。
数学时间!如何从 1 GiB 转换到 434.4 MiB
如本章前面所述,Spark 默认为驱动程序程序分配 1 GiB 的内存。434.4 MiB 是怎么回事?我们是如何从 1 GiB 的分配内存转换为 434.4 MiB 的可用内存的?
在图 11.4 中,我解释了 Spark 将节点上的内存分为三个部分:预留、操作和存储内存。434.4 MiB 代表操作和存储内存。一些配置标志负责精确的内存分割:
-
spark.{driver|executor}.memory确定了 Spark 驱动程序或执行程序可用的总内存封套,我将称之为M(默认为1g)。您可以为驱动程序和执行程序设置不同的内存需求,但通常我看到这两个值是相同的。 -
spark.memory.fraction,我将称之为F,设置 Spark 可用内存的比例(操作加存储;默认为0.6)。 -
spark.memory.storageFraction,我将称之为S,是 Spark 可用内存的比例(M × F),主要用于存储(默认为0.5)。
在提供 1 GiB RAM 的情况下,Spark 将首先预留 300 MiB。其余部分将根据 spark.memory.fraction 值在预留和分配(操作加存储)之间分割:(1 GiB - 300 MiB) * 0.6 = 434.4 MiB。这是 Spark UI 中显示的值。内部,Spark 将使用 spark.memory.storageFraction 比率管理操作和存储内存。在我们的案例中,由于比率是 0.5,内存将在操作和存储之间平均分割。

在实践中,存储可能会超出其分配的空间:spark.memory.storageFraction 定义了 Spark 将保护数据不被溢出到磁盘的区域(例如,在内存密集型计算期间),但如果我们的数据比存储内存大,Spark 将从操作内存部分借用。
对于您的大多数程序,不建议玩弄这些值。虽然使用过多内存来存储数据可能看起来有些反直觉,但请记住,从 RAM 中读取数据比 Spark 需要依赖硬盘时要快得多。
在本节中,我们探讨了配置标志和 Spark UI 的相关部分,以审查和设置 CPU 和内存资源。在下一节中,我将运行一些小任务,探索 Spark UI 提供的运行时信息,并解释我们如何利用这些信息做出更好的配置和编码决策。
11.1.3 查看你所做的工作:通过 Spark UI 诊断已完成的工作
本节涵盖了审查作业性能时最重要的指标。我介绍了作业和阶段的概念,Spark 如何为每个阶段报告性能指标,以及我们如何解释 Spark UI 提供的信息以优化我们的作业。
注意:一些云管理的 Spark 作业(如 Google Dataproc)不提供对 Spark UI 的访问;相反,你有一个 Spark 历史服务器。外观相同,但它在运行中的作业完成之前不可用。在 PySpark shell 的情况下,这意味着你必须在看到结果之前退出会话。
Spark 将我们提交的代码组织成作业。作业简单来说是一系列转换(select()、groupBy()、where() 等)加上一个最终的操作(count()、write()、show())。在第一章中,我解释了 Spark 只有在提交操作后才会开始工作:每个操作都会触发一个作业。例如,导致列表 11.3 中的results数据框的代码不包含任何操作。在 REPL 中提交此代码后,Spark UI 不会显示任何作业(或任何工作迹象)。
列表 11.3 对我们的文本文件应用的一系列转换
from pyspark.sql import SparkSession
import pyspark.sql.functions as F
spark = (
SparkSession.builder.appName(
"Counting word occurences from a book, one more time."
)
.master("local[4]")
.config("spark.driver.memory", "8g")
.getOrCreate()
)
results = (
spark.read.text("./data/gutenberg_books/*.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']+", 0).alias("word"))
.where(F.col("word") != "")
.groupby(F.col("word"))
.count()
)
results.orderBy(F.col("count").desc()).show(10)
只有当我们提交操作时,例如列表 11.3 末尾的show()方法,我们才能看到工作正在执行(一个非常快的进度条,随后是 REPL 窗口中的结果)。在 Spark UI 上,我们看到一个作业(因为有一个操作),如图 11.6 所示。

图 11.6 Spark UI 的“作业”标签页上的“完成作业”表,其中包含我们的一个完成作业。我们的单词计数,通过一个操作,被列为一个作业。
每个作业在内部被分解成阶段,这些阶段是正在数据上执行的工作单元。一个阶段包含什么取决于查询优化器如何决定分割工作。第 11.1.4 节更详细地介绍了阶段的构建方式以及我们如何影响它们。我们的简单程序有三个步骤:
-
第 0 阶段从目录中所有(六个)文本文件中读取数据,并执行所有转换(拆分、展开、小写、提取正则表达式、过滤)。然后它按组别进行分组并独立计算每个分区的单词频率。
-
Spark 然后将数据在每个节点之间进行交换(或洗牌)以准备下一阶段。由于数据分组后非常小(我们只需要 10 条记录来显示),所有数据都通过单个分区返回到一个节点。
-
最后,在第 1 阶段,我们计算 10 个选定记录的总单词数,并以表格形式显示记录。
这种按两个阶段进行分组/计数的做法之所以有效,是因为记录数是交换律和结合律的。第八章涵盖了交换律和结合律以及为什么它们对 Spark 很重要。
在“完成阶段”表(我们现在已经移动到 Spark UI 的“阶段”标签页)中,Spark 提供了与内存消耗相关的四个主要指标:
-
输入是从源读取的数据量。我们的程序读取了 4.1 MiB 的数据。这似乎是一个不可避免的成本:我们需要读取数据才能执行工作。如果你控制输入数据的格式和组织,你可以实现显著的性能提升。
-
输出 是输入的对立面:它代表我们的程序作为动作的结果输出的数据。由于我们打印到终端,所以在阶段 1 的末尾没有值。
-
Shuffle read 和 shuffle write 是
shuffling(或交换;参见图 11.7)操作的一部分。Shuffling 重新排列 Spark 工作节点上的内存,为下一阶段做准备。在我们的例子中,我们需要在阶段 0 的末尾写入 965.6 KiB 以准备阶段 1。在阶段 1 中,我们只读取了 4.8 KiB,因为我们只请求了 10 条记录。由于 Spark 对整个作业进行了懒惰优化,它从一开始就知道我们只需要 10 个单词的计数;在交换时间(阶段 0 和 1 之间),驱动程序只为每个文件保留了相关的 5 个单词,通过减少 99.5% 的所需数据(从 965.6 到 4.8 KiB)来回答我们的动作。当处理大量文件并使用show()(默认 20 条记录)显示内容时,这会导致显著的速度提升!

图 11.7 作业 0 的两个阶段,以及每个阶段的摘要统计信息。数据摄入的大小列在 input 中,数据输出为 output,我们看到中间数据移动为 shuffle。我们可以使用这些值来推断我们正在处理的数据集有多大。
这都是非常相关的信息。我们获得了关于作业内存消耗的见解(转换加动作)。我们看到了如何测量每个任务的时间、垃圾收集所花费的时间以及摄入的数据量以及这些数据如何在每个阶段之间传输。下一节将关注实际编码在计划中的数据处理操作。我们正接近 Spark 的秘密配方!
练习 11.1
如果我们在单词计数程序中添加 10 个更多文件,而不对代码进行任何修改,这会改变(a)作业的数量或(b)阶段的数量吗?为什么?
11.1.4 通过 Spark 查询计划映射操作:SQL 选项卡
本节介绍了 Spark 从代码到实际数据处理所经历的不同计划。我们以本章开头使用的单词计数示例为例,将其分解为阶段,然后将这些阶段分解为步骤。这是理解您作业在代码以下层面发生情况的关键工具,也是您在觉得代码运行缓慢或未按预期执行时应该做的第一件事。
前往 Spark UI 的 SQL 选项卡,并点击作业的描述。您应该看到一个表示不同阶段的漫长框链。

图 11.8 Spark 对我们的数据框进行编码和优化的转换链。我们的代码指令在 results 数据框中用阶段表示,当您将鼠标悬停在每个框上时,会显示并描述这些阶段。
如果您将鼠标悬停在“Scan Text”、“Project”或“Generate”其中一个框上(如图 11.9 所示),会出现一个包含该步骤发生情况的黑色框。在第一个框,称为“Scan text”的情况下,我们看到 Spark 对我们传递给 spark.read.text() 的所有文件执行了 FileScan text 操作。

图 11.9 当鼠标悬停在“Scan Text”、“Project”或“Generate”其中一个框上时,会出现一个黑色覆盖层,其中包含该步骤进行的转换的文本表示(称为计划)。由于空间限制,我们大多数时候只能看到计划的开始和结束部分。
我们知道 Spark 将我们的(Python,尽管该过程适用于每种主机语言)代码摄入并转换为 Spark 指令(参见第一章)。这些指令被编码到查询计划中,然后发送到执行器进行处理。正因为如此,当使用数据框 API 时,PySpark 的性能与 Spark Scala API 非常相似。
警告:如果您还记得第八章,您会记得在处理 RDD 时,翻译类比不起作用。在这种情况下,PySpark 将序列化数据并应用 Python 代码,类似于我们应用 Python UDF 时的情况。
我们如何访问这个查询计划?很高兴您问了!Spark 并不提供一个单一的查询计划,而是按顺序创建了四种不同类型的计划。我们在图 11.10 中按逻辑顺序看到它们。

图 11.10 Spark 使用多层方法优化作业:未解析的逻辑计划、逻辑计划、优化逻辑计划和物理计划。选定的(物理)计划是应用于数据的一个。
要在不悬停在多个框上时看到四个(完整)计划的实际操作,我们有两个主要选项:
-
在 Spark UI 中,在我们的作业的 SQL 选项卡底部,我们可以点击“
Details”,在那里将以文本形式显示计划。 -
我们也可以通过数据框的
explain()方法在 REPL 中打印它们。在这种情况下,我们不会有计划的最终操作,因为操作通常返回一个 Pythonic 值(数字、字符串或None),它们都没有explain值。
这就完成了 Spark UI 中 SQL 选项卡的高级概述。下一节将分解 Spark UI 提供的计划和 explain() 方法,以及我们如何解释它们。
11.1.5 Spark 的核心:解析、分析、优化和物理计划
本节涵盖了 Spark 在执行作业时进入的四个计划。理解这些计划中的关键概念和词汇提供了大量关于作业结构的信息,并让我们对执行器处理数据时跨集群的数据旅程有一个想法。
注意:Spark 文档在计划的命名法上并不一致。因为我喜欢使用单个形容词,所以我依赖于 Spark UI 的词汇表,对于前三个计划省略了“logical”。我将使用 parsed、analyzed、optimized 和 physical 来表示图 11.10 中的四个阶段。
在深入到每个计划的细节之前,了解为什么 Spark 需要对一个作业进行整个规划过程是很重要的。在多台机器上管理和处理大量数据源带来了一系列挑战。除了通过合理使用 RAM、CPU 和 HDD 资源来优化每个节点以实现高速处理(参见图 11.4)之外,Spark 还需要解决跨节点管理数据的复杂性(更多细节请参见 11.2.1 节)。
在 Spark UI 中,通过 explain() 数据框方法显示的计划是一个执行在数据中的步骤树。我们从一个最嵌套的行开始,无论其类型如何,从最内层到最外层读取计划:对于大多数作业,这意味着从底部到顶部读取。在列表 11.4 中,解析计划看起来非常像是将我们的 Python 代码翻译成 Spark 操作。我们识别出计划中的大多数操作(explode、regexp_extract、filter)。在计划中,数据分组被称为 Aggregate,选择数据被称为 Project(即,“我在投射”,而不是“我的项目已经逾期”)。
提示:默认情况下,explain() 只会打印物理计划。如果您想看到所有内容,请使用 explain(extended=True)。explain() 方法的文档解释了其他格式化和统计选项。
列表 11.4 我们作业的解析逻辑计划
== Parsed Logical Plan ==
GlobalLimit 6
+- LocalLimit 6 ❶
+- Project [cast(word#9 as string) AS word#27, cast(count#13L as string)❷
AS count#28] ❷
+- Aggregate [word#9], [word#9, count(1) AS count#13L] ❸
+- Filter NOT (word#9 = ) ❹
+- Project [regexp_extract(word#7, [a-z']+, 0) AS word#9] ❺
+- Project [lower(word#5) AS word#7] ❻
+- Project [word#5] ❼
+- Generate explode(line#2), false, [word#5] ❼
+- Project [split(value#0, , -1) AS line#2] ❽
+- Relation[value#0] text ❾
❶ results.show(5, False)
❷ .count()
❸ .groupby(F.col("word"))
❹ .where(F.col("word") != "")
❺ .select(F.regexp_extract(F.col("word"), "[a-z']+", 0).alias("word"))
❻ .select(F.lower(F.col("word")).alias("word"))
❼ select(F.explode(F.col("line")).alias("word")).
❽ .select(F.split(F.col("value"), " ").alias("line"))
❾ results = spark.read.text("./data/gutenberg_books/1342-0.txt")
注意:当 Spark 与数据框一起工作时,需要唯一的列名,这就是为什么我们在计划中看到 #X(其中 X 是一个数字)的原因。例如,lower(word#5) AS word#7 仍然指的是 word 列,但 Spark 在井号后面分配了一个递增的数字。
从解析的逻辑计划到分析的逻辑计划在操作上变化不大。另一方面,Spark 现在知道了我们的结果数据框的架构:word: string, count: string。
列表 11.5 我们单词计数作业的分析计划
== Analyzed Logical Plan ==
word: string, count: string ❶
GlobalLimit 6
+- LocalLimit 6
+- Project [cast(word#9 as string) AS word#27, cast(count#13L as string) AS count#28]
+- Aggregate [word#9], [word#9, count(1) AS count#13L]
+- Filter NOT (word#9 = )
+- Project [regexp_extract(word#7, [a-z']+, 0) AS word#9]
+- Project [lower(word#5) AS word#7]
+- Project [word#5]
+- Generate explode(line#2), false, [word#5]
+- Project [split(value#0, , -1) AS line#2]
+- Relation[value#0] text
❶ 结果数据框有两个列:单词(一个字符串列)和计数(也是一个字符串,因为我们正在将结果显示到终端)。
分析计划随后通过基于 Spark 执行操作的方式的多个启发式规则和规则进行优化。在下一个列表中,我们识别出与之前两个计划(解析和分析)相同的操作,但我们不再有一对一的映射。让我们更详细地看看差异。
列表 11.6 我们单词计数作业的优化计划
== Optimized Logical Plan ==
GlobalLimit 6
+- LocalLimit 6
+- Aggregate [word#9], [word#9, cast(count(1) as string) AS count#28]
+- Project [regexp_extract(lower(word#5), [a-z']+, 0) AS word#9]
+- Filter NOT (regexp_extract(lower(word#5), [a-z']+, 0) = )
+- Generate explode(line#2), [0], false, [word#5]
+- Project [split(value#0, , -1) AS line#2]
+- Relation[value#0] text
首先,explode() 操作没有投影(参见分析计划中的 Project [word#5])。这里没有什么令人惊讶的:这个列仅在计算链中使用,不需要显式地选择/投影。Spark 也不会保留将 count 转换为字符串的投影步骤;转换发生在聚合过程中。
其次,regexp_extract() 和 lower() 操作被合并为单个步骤。因为这两个操作都是窄操作,它们独立地对每条记录进行操作(参见列表 11.7),Spark 可以在单次数据遍历中执行这两个转换。
最后,Spark 重复了 (regexp_extract(lower(word#5), [a-z']+, 0) = )步骤:它在Filter步骤中执行,然后在Project步骤中再次执行。因此,分析计划中的Filter和Project` 步骤被颠倒了。一开始这可能会看起来有些反直觉:由于数据在内存中,Spark 认为提前执行过滤操作(即使这意味着只是浪费一些 CPU 周期)可以获得更好的性能。
最后,优化后的计划被转换为执行器将执行的实际步骤:这被称为 物理计划(在 Spark 实际在数据上执行这项工作的意义上,而不是你看到你的集群在做跳跃动作)。物理计划与其他计划非常不同。
列表 11.7 我们单词计数作业的物理计划(实际处理步骤)
== Physical Plan ==
CollectLimit 6
+- *(3) HashAggregate(keys=[word#9], functions=[count(1)], output=[word#9, count#28])
+- Exchange hashpartitioning(word#9, 200), true, [id=#78]
+- *(2) HashAggregate(keys=[word#9], functions=[partial_count(1)],
output=[word#9, count#17L])
+- *(2) Project [regexp_extract(lower(word#5), [a-z']+, 0) AS word#9]
+- *(2) Filter NOT (regexp_extract(lower(word#5), [a-z']+, 0) = )
+- Generate explode(line#2), false, [word#5]
+- *(1) Project [split(value#0, , -1) AS line#2]
+- FileScan text [value#0] Batched: false, DataFilters: [],
Format: Text,
Location: InMemoryFileIndex[file:[...]/data/gutenberg_books/1342-0.txt],
PartitionFilters: [], PushedFilters: [],
ReadSchema: struct<value:string>
Spark 用实际的文件读取(FileScan text)交换了逻辑关系。Spark 并不真正关心前三个逻辑计划的实际数据;它只关心获取列名和类型,Spark 将协调数据的读取。如果我们有多个文件(就像我们的情况一样),Spark 将在执行器之间分割文件,以便每个执行器读取所需的内容。真 neat!
我们还有一些以星号开头的数据——*(1) 到 *(3)——这对应于 Spark UI SQL 模式中看到的 WholeStageCodegen。WholeStageCodegen 是一个每个操作都在同一数据遍历中发生的阶段。在我们的例子中,我们有三个:
-
分割值
-
过滤掉空单词,提取单词,并预先聚合单词计数(就像我们在 11.1.3 节中看到的那样)
-
将数据聚合到最终的数据框中
将 Python 指令转换为 Spark 物理计划并不总是那么清晰,但对于复杂的 PySpark 程序,我遵循相同的蓝图,查看解析的计划,并跟踪转换直到物理计划。当你需要诊断底层发生的事情时,这些信息与 Spark 实例概述相结合,证明是无价的。如果你是侦探,Spark UI 及其多个标签就像同时拥有犯罪武器和忏悔信。
本节涵盖了 Spark UI 的概述。这个门户提供了关于你的 Spark 实例配置、可用资源和正在进行的或已完成的不同作业的无价信息。在下一节中,我将介绍一些有助于理解 Spark 处理性能的重要概念,以及一些阻碍你的数据作业的陷阱和错误朋友。
练习 11.2
如果你使用 results.explain(extended=True) 在 REPL 中,并查看分析的计划,模式将读取 word: string, count: bigint,并且没有 GlobalLimit/LocalLimit 6。为什么两个计划(explain() 与 Spark UI)不同?
11.2 考虑性能:操作和内存
在本节中,我介绍了使用 PySpark 进行分布式数据处理的一些基本概念。更具体地说,我提供了一个基础,教你如何思考你的程序以简化逻辑并加快处理速度。无论你使用数据帧 API 还是依赖于更底层的 RDD 操作(见第八章),你都将获得描述程序逻辑的有用词汇以及调试看似缓慢程序的建议。
对于本节,我使用了我们在前几章中已经遇到的一些数据集。我介绍了在设计、编码和性能分析数据管道时两个重要的基本概念。
首先,我介绍了 窄操作 与 宽操作 的概念。对数据帧(或 RDD)执行的每个转换都可以归类为这两种之一。平衡窄操作和宽操作是使你的数据管道运行更快的一个棘手但重要的方面。
其次,我讨论了 缓存 作为性能策略,以及何时是正确使用它的时机。缓存数据帧会改变 Spark 对数据转换的思考方式和优化代码的方式;通过理解其优势和权衡,你将知道何时使用它是合适的。
11.2.1 窄操作与宽操作
在本节中,我介绍了窄转换和宽转换的概念。我展示了它们如何在 Spark UI 中显示,以及思考转换顺序对你的程序性能可能很重要。
在第一章中,我解释了 Spark 会惰性地将转换思考成计划,直到触发一个动作。一旦动作被提交,查询优化器将审查计划的步骤,并以最有效的方式重新组织它们。我们在列表 11.6 的优化计划中看到了这一点,其中我们不仅将regexp_extract()和lower()操作合并为单一步骤,而且还重复了该步骤(一次用于过滤,一次用于实际转换)。
Spark 知道它可以这样做,因为regexp_extract()和lower()都是窄转换。简单来说,窄转换是对记录的转换,它对实际数据位置不敏感。换句话说,如果一个转换独立应用于每条记录,则认为它是窄转换。我们之前的两个例子显然是窄转换:提取正则表达式或更改列的大小写可以在逐条记录的基础上完成;记录的顺序和它们在集群上的物理位置并不重要。

图 11.11 窄转换将应用,而无需记录在节点之间移动。Spark 可以在每个节点上并行化操作。
在分布式环境中工作时,窄转换非常方便:它们不需要任何记录交换(或洗牌)。正因为如此,Spark 通常会将许多连续的窄转换组合成单一步骤。由于数据位于 RAM(或硬盘上),通过在每条记录上执行多个操作(仅读取一次数据)通常比多次读取相同数据并每次执行一个操作具有更好的性能。PySpark(2.0 版本及以上)还可以利用专门的 CPU(自 Spark 3.0 以来,GPU)指令来加速数据转换。²
窄转换的一个重要注意事项是,它们不能做任何事情。例如,根据某些记录的值对数据框进行分组、获取列的最大值以及根据谓词将两个数据框连接起来,都需要数据在逻辑上组织好才能成功执行操作。这三个先前的例子被称为宽转换。与它们的窄转换对应物不同,宽转换需要在多个节点之间以某种方式排列数据。为此,Spark 使用交换步骤来移动数据以完成操作。
在单词计数示例中,group by/count 转换被分成了两个阶段,由一个交换操作隔开。在交换前的阶段,Spark 按节点逐节点地分组数据,因此我们在这个阶段结束时,每个分区都被分组了(见图 11.12)。然后 Spark 在节点间交换数据——在我们的例子中,由于分区分组大大减少了数据量,所以所有数据都流向了单个 CPU 核心——然后完成了分组。Spark 甚至足够聪明,意识到我们只需要五条记录,因此在shuffle read操作期间只读取所需的记录(见第 11.1.3 节)。真聪明!

图 11.12 由于 Spark 需要在节点间交换数据,宽转换可以在两个阶段发生。Spark 将这些必要的交换操作称为洗牌。
由于我们需要将数据交换/发送到网络,宽操作会带来窄操作中不存在的性能成本。使数据转换程序快速的部分是理解窄操作和宽操作之间的平衡,以及我们如何在程序中利用两者的特性。
Spark 的查询优化器在重新组织操作以最大化每个窄阶段方面变得越来越聪明。在列表 11.8 中,我在单词计数示例中添加了三个转换:
-
我只保留超过八个字母的单词。
-
我按单词长度分组。
-
我计算频率的总和。
列表 11.8 一个更复杂的单词计数示例,说明了窄操作与宽操作
results = (
spark.read.text("./data/gutenberg_books/*.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']+", 0).alias("word"))
.where(F.col("word") != "")
.groupby(F.col("word"))
.count()
.where(F.length(F.col("word")) > 8)
.groupby(F.length(F.col("word")))
.sum("count")
)
results.show(5, False)
# Output not shown for brievty.
results.explain("formatted")
# == Physical Plan ==
# * HashAggregate (12)
# +- Exchange (11)
# +- * HashAggregate (10)
# +- * HashAggregate (9)
# +- Exchange (8)
# +- * HashAggregate (7)
# +- * Project (6)
# +- * Filter (5)
# +- Generate (4)
# +- * Project (3)
# +- * Filter (2)
# +- Scan text (1)
# (1) Scan text
# Output [1]: [value#16766]
# Batched: false
# Location: InMemoryFileIndex [file:/.../data/gutenberg_books/11-0.txt, ... 5 entries]
# ReadSchema: struct<value:string>
# (2) Filter [codegen id : 1]
# Input [1]: [value#16766]
# Condition : ((size(split(value#16766, , -1), true) > 0) AND isnotnull(split(value#16766, , -1)))
# [...]
# (11) Exchange
# Input [2]: [length(word#16775)#16806, sum#16799L]
# Arguments: hashpartitioning(length(word#16775)#16806, 200), ENSURE_REQUIREMENTS, [id=#2416]
# (12) HashAggregate [codegen id : 4]
# Input [2]: [length(word#16775)#16806, sum#16799L]
# Keys [1]: [length(word#16775)#16806]
# Functions [1]: [sum(count#16779L)]
# Aggregate Attributes [1]: [sum(count#16779L)#16786L]
# Results [2]: [length(word#16775)#16806 AS length(word)#16787, sum(count#16779L)#16786L AS sum(count)#16788L]
这是一个编写不佳程序的糟糕例子:我不需要先按单词分组,然后再按单词频率分组。如果我们查看物理计划,有两件事会跳出来。
首先,PySpark 足够聪明,可以将.where(F.length(Fcol("word")) > 8)与之前确定的两个窄转换结合起来。其次,PySpark 还不够聪明,无法理解第一个groupby()是不必要的。我们在这里有一些改进的潜力。在列表 11.9 中,我修改了最后几条指令,因此我的程序以更少的指令完成了相同的工作。通过移除(无用的)中间groupby(),我将步骤数量恢复到三个(检查 codegen IDs),因此减少了 PySpark 需要执行的工作量。
列表 11.9 重新组织我们的扩展单词计数程序以避免重复计数
results_bis = (
spark.read.text("./data/gutenberg_books/*.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']+", 0).alias("word"))
.where(F.col("word") != "")
.where(F.length(F.col("word")) > 8)
.groupby(F.length(F.col("word")))
.count()
)
results_bis.show(5, False)
# Output not shown for brievty.
results_bis.explain("formatted")
# == Physical Plan ==
# * HashAggregate (9)
# +- Exchange (8)
# +- * HashAggregate (7)
# +- * Project (6)
# +- * Filter (5)
# +- Generate (4)
# +- * Project (3)
# +- * Filter (2)
# +- Scan text (1)
# (1) Scan text
# Output [1]: [value#16935]
# Batched: false
# Location: InMemoryFileIndex [file:/Users/jonathan/Library/Mobile Documents/com
~apple~CloudDocs/PySparkInAction/data/gutenberg_books/11-0.txt, ... 5 entries]
# ReadSchema: struct<value:string>
# (2) Filter [codegen id : 1]
# Input [1]: [value#16935]
# Condition : ((size(split(value#16935, , -1), true) > 0) AND isnotnull(split(value#16935, , -1)))
# [...]
# (5) Filter [codegen id : 2]
# Input [1]: [word#16940]
# Condition : ((isnotnull(word#16940) AND NOT (regexp_extract(lower(word#16940), [a-z']+, 0) = )) AND (length(regexp_extract(lower(word#16940), [a-z']+, 0)) > 8))
# [...]
# (9) HashAggregate [codegen id : 3]
# Input [2]: [length(word#16944)#16965, count#16960L]
# Keys [1]: [length(word#16944)#16965]
# Functions [1]: [count(1)]
# Aggregate Attributes [1]: [count(1)#16947L]
# Results [2]: [length(word#16944)#16965 AS length(word)#16949, count(1)#16947L AS count#16948L]
不进行真正的基准测试,快速调用timeit(可在 iPython shell/Jupyter 笔记本上使用)显示,简化后的程序大约快了 54%。这并不奇怪——通过物理计划(也在 Spark UI 中可用),我们知道简化版本做了更少的工作,代码更集中:
# Your results will vary.
%timeit results.show(5, False)
920 ms ± 46.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit results_bit.show(5, False)
427 ms ± 4.14 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
虽然这个例子可能有些牵强,但数据管道往往具有这种“只增不减”的代码模式,你在转换链的末尾添加更多要求、更多工作和更多代码。通过使用通过计划(逻辑和物理)提供的信息,你可以分析代码实际执行的物理步骤,更好地理解性能应用。在阅读复杂的数据管道时,这并不总是容易弄清楚;多个观点有助于理解。
本节介绍了窄转换和宽转换的概念。我们看到了如何区分这两者以及在使用它们时的含义。最后,我们以实际的方式学习了 Spark 在优化查询计划时如何重新组织和组合窄操作。在下一节中,我将介绍 PySpark 最被误解的功能:缓存。
练习 11.3
对于以下操作,确定它们是窄操作还是宽操作,以及原因。
a) df.select(...)
b) df.where(...)
c) df.join(...)
d) df.groupby(...)
e) df.select(F.max(...).over(...))
11.2.2 缓存数据帧:强大但往往致命(对性能而言)
本节介绍了数据帧的缓存。我介绍了它是什么,如何在 Spark 中工作,最重要的是,为什么你应该非常小心地使用它。学习如何以及何时缓存数据对于使你的程序更快至关重要,但同时也不要使它们比所需的更慢。
我在图 11.4 中展示了 Spark 将内存分为三个区域:预留、操作和存储。默认情况下,每个 PySpark 作业(转换加操作)都是相互独立的。例如,如果我们对单词计数示例的results数据帧进行五次show()操作,Spark 将五次从源读取数据并转换数据帧。虽然这看起来像是一种非常低效的工作方式,但请记住,数据管道通常“流动”数据从一个转换到下一个转换(因此有管道类比);保持中间状态是无用且浪费的。
缓存改变了这一点。缓存的数据帧将被序列化到存储内存中,这意味着检索它将会很快。权衡的是,你会在你的集群上占用 RAM 空间。在非常大的数据帧的情况下,这意味着一些数据可能会溢出到磁盘(导致检索速度变慢),而且如果你使用的是内存密集型处理,你的集群可能会运行得更慢。

图 11.13 在我们的单词计数程序中缓存数据帧。在这种情况下,第二次操作没有计算从spark.read操作开始的转换链,而是利用了缓存的df_int数据帧。
要缓存一个数据帧,你调用它的cache()方法。因为它是一个转换,cache()不会立即执行,而是等待调用一个动作。一旦你提交一个动作,Spark 将计算整个数据帧并将其缓存到内存中,如果需要的话,也会使用磁盘空间。
在执行器选项卡中,你还可以检查正在使用的内存存储量。未缓存的数据帧会占用一点空间(因为每个执行器都会保留即时重新计算数据帧的指令),但远不如缓存数据帧占用空间多。
持久化:缓存,但具有更多控制
默认情况下,数据帧将使用MEMORY_AND_DISK策略进行缓存,这意味着存储 RAM 将优先使用,如果内存不足,则会回退到磁盘。RDD 将使用MEMORY_ONLY策略,这意味着它根本不会使用磁盘进行存储。如果我们没有足够的存储 RAM,Spark 将从头开始重新计算 RDD(抵消缓存的效果)。
如果你想要更多控制数据缓存的方式,你可以使用persist()方法,将级别(作为字符串)作为参数传递。除了MEMORY_ONLY和MEMORY_AND_DISK之外,你也可以选择DISK_ONLY,这意味着它将直接跳过 RAM 而直接写入磁盘。你还可以添加一个_2后缀(例如,MEMORY_ONLY_2),这将使用相同的启发式方法,但将每个分区复制到两个节点上。
如果你能负担得起 RAM,我建议尽可能多地使用它。RAM 的访问速度比磁盘快得多。实际的决定将取决于你的 Spark 实例配置。

图 11.14 results数据帧,成功缓存。由于大小,所有内容都适合在 RAM 中。
缓存看起来是一个非常有用的功能:它提供了一种保险政策,这样你就不必从头开始重新计算数据帧。在实践中,这种情况很少发生:
-
缓存需要计算和内存资源,这些资源对于一般处理是不可用的。
-
计算一个数据帧有时可能比从缓存中检索它更快。
-
在非交互式程序中,你很少需要多次重用数据帧:如果你不多次重用确切的数据帧,缓存就没有价值。
换句话说,无脑缓存通常会阻碍你的程序性能。既然我已经做了关于如何激进的缓存会对你有害的公共服务公告,那么有哪些情况下你想缓存呢?我见证过两种常见的使用场景,其中缓存是有用的。
首先,当你正在实验一个数据帧,该数据帧(a)适合内存,并且(b)需要多次引用整个缓存数据帧时,缓存是有用的。在交互式开发的情况下(你使用 REPL 快速迭代相同的数据帧),缓存将提供明显的速度提升,因为你不必每次都从源读取。
其次,当你在 Spark 上训练 ML 模型时,缓存非常有用。ML 模型拟合将多次使用训练数据集,从头开始重新计算则非常不便。在第十三章中,你会注意到我在训练之前随意缓存了我的数据框。
作为一条经验法则,在缓存之前,问问自己:我是否需要这整个数据集超过几次?在大多数非交互式数据处理程序中,答案将是否定的,缓存可能会弊大于利。如果答案是肯定的,尝试不同的缓存级别(RAM、磁盘或两者结合)以查看哪一个最适合你的程序/Spark 实例。
本节介绍了缓存的黑暗艺术以及为什么少即是多。在本章中,我介绍了 Spark UI,并揭示了 Spark 在运行时(以及事后)提供的信息,以帮助您更好地决定程序的性能。在构建数据管道时拥有这些信息将提供宝贵的反馈,有助于您做出更好的性能决策。
在结束本章之前,我想强调的是,从一开始就过分关注性能并不会对你有任何好处。Spark 提供了许多开箱即用的优化——你可以在分析逻辑和物理计划时看到这些优化——并将提供良好的性能。在编写 PySpark 程序时,首先让它工作,然后让它整洁,最后让它快速,使用 Spark UI 来帮助你一路前行。数据管道,无论是用于 ETL 还是 ML,如果易于推理,将获得很多好处。如果需要花费一天时间来解析程序的功能,那么节省几分钟的时间是不值得的!
摘要
-
Spark 使用 RAM(或内存)来存储数据(存储内存),以及用于处理数据(操作内存)。在快速处理 Spark 作业时,提供足够的内存至关重要,并且可以在
SparkSession初始化中进行配置。 -
Spark UI 提供了有关集群配置的有用信息。这包括内存、CPU、库和操作系统信息。
-
Spark 作业由一系列转换和一个动作组成。当处理时,作业进度可以在 Spark UI 的作业标签页中查看。
-
作业被分割成阶段,这些是集群上工作的逻辑单元。阶段通过交换操作分割,这是数据在工作节点之间移动的时候。我们可以通过 Spark UI 的 SQL 标签页以及通过
explain()方法查看结果数据框来查看阶段和步骤。 -
一个阶段由一系列优化为单位的窄操作组成。如果必要的数据不是节点本地的,宽操作可能需要洗牌/交换。
-
缓存将数据从源移动到存储内存(如果可用内存不足,可以选择溢出到磁盘)。缓存会干扰 Spark 的优化能力,在类似管道的程序中通常不需要。当多次重用数据帧时,例如在机器学习训练期间,它是合适的。
¹ 如果你正在使用临时集群(即,为特定工作启动集群并在之后销毁它),通常你不需要担心集群管理器,因为它们已经为你设置好了。
² 一个例子是通过使用 SIMD(单指令多数据)指令和循环展开。如果你想了解更多信息,请查看 Spark Tungsten 项目的发布说明。
第三部分。建立信心:使用 PySpark 进行机器学习
第一部分和第二部分都是关于数据转换的,但在第三部分,我们将通过解决可扩展的机器学习来超越这一点。虽然这部分本身并不是对机器学习的完整处理,但它将为你编写稳健且可重复的机器学习程序提供基础。
第十二章通过构建特征为机器学习奠定了基础,这些特征是用于训练过程的信息片段。特征工程本身类似于有目的的数据转换。准备好使用在第一部分和第二部分中学到的技能!
第十三章介绍了机器学习管道,这是 Spark 以稳健和可重复的方式封装机器学习工作流的方法。现在,比以往任何时候都更重要的是,良好的代码结构决定了机器学习程序的成功或失败,所以这个工具将帮助你保持理智,在你构建模型时。
最后,第十四章通过创建我们自己的组件扩展了机器学习管道的抽象。有了这个,你的机器学习工作流将无限灵活,同时不会牺牲稳健性和可预测性。
在第三部分的结尾,你将准备好扩展你的机器学习程序。引入大数据——是时候获得一些深刻的见解了!
12 设置场景:为机器学习准备特征
本章涵盖
-
投资于坚实的数据操作基础如何使数据准备变得轻松
-
使用 PySpark 解决大数据质量问题
-
为你的 ML 模型创建自定义特征
-
为你的模型选择引人注目的特征
-
在特征工程过程中使用转换器和估计器
我对机器学习感到兴奋,但原因与大多数人不同。我喜欢深入研究新的数据集,并尝试解决问题。每个数据集都有其独特的问题和特性,将其“ML ready”化是一种极大的满足感。构建模型赋予了数据转换目的;你摄入、清理、分析并折磨数据,都是为了一个更高的目的:解决现实生活中的问题。本章重点介绍机器学习中最重要的一步,即针对你的用例探索、理解、准备和赋予数据目的。更具体地说,我们专注于通过清理数据、创建新的特征(这些字段将在第十三章中用于训练模型)以及根据它们看起来有多有希望来选择一组精选的特征。在本章结束时,我们将拥有一个干净的数据集,其中包含易于理解的特征,这将准备好用于机器学习。
这不是一门机器学习的大师课
本章和下一章假设你对机器学习有一定的了解。我会边走边解释概念,但无法涵盖完整的建模过程,因为这将是一本单独的书。此外,我们无法在单章内合理地学习如何锤炼我们对数据和建模的直觉。请将本节视为获取模型的一种方法,并鼓励你尝试这个数据集(以及其他数据集!)来建立你自己的直觉。
如果你对学习机器学习感兴趣,我强烈推荐 Gareth James、Daniela Witten、Trevor Hastie 和 Robert Tibshirani 合著的《统计学习基础》(Springer,2021 年出版,可在www.statlearning.com免费在线获取)。这本书使用 R 语言,但其中的概念超越了语言界限。对于更实用的(基于 Python 的)介绍,我喜欢 Henrik Brink、Joseph W. Richards 和 Mark Fetherolf 合著的《现实世界机器学习》(Manning,2016 年)。
12.1 阅读和准备我们的机器学习数据集
本节涵盖了机器学习数据集的摄取和探索。更具体地说,我们将回顾数据框的内容,查看不一致性,并为特征工程准备数据。
对于我们的机器学习模型,我选择了一个包含 20,057 个菜名且包含 680 列的数据集,这些列描述了配料清单、营养成分和菜品的类别。我们的目标是预测这道菜是否是甜点。这是一个简单、基本上没有歧义的问题——你只需通过阅读名称就可以判断一道菜是否是甜点——这使得它非常适合简单的机器学习模型。
在其核心,数据清理、探索和特征准备都是目的驱动的数据转换。因为数据是 CSV 文件,我们重用了第四章和第五章的内容。我们将使用模式信息来确定每列包含的数据类型(第六章),甚至使用 UDF(第八章和第九章)进行一些特殊的列转换。到目前为止学到的所有技能都将得到良好的应用!
数据集可在 Kaggle 上在线获取,Kaggle 是一个面向机器学习爱好者的在线社区,举办建模竞赛以及有趣的数据集(www.kaggle.com/hugodarwood/epirecipes)。我还将数据包含在本书的配套仓库中(在 data/recipes/epi_r.csv 下)。我们通过设置 SparkSession 开始我们的程序(列表 12.1):我们为我的驱动器分配了 8 吉字节(gibibytes)的 RAM(有关 Spark 如何分配内存的更多信息,请参阅第十一章)。列表 12.2 中的代码读取数据框(使用第四章中看到的 CSV 专用 SparkReader 对象)并打印数据框的维度:20,057 行和 680 列。我们将跟踪这些维度,以便在清理数据框时查看受影响的记录或过滤掉的记录数量。
列表 12.1 为我们的机器学习程序启动 SparkSession
from pyspark.sql import SparkSession
import pyspark.sql.functions as F
import pyspark.sql.types as T
spark = (
SparkSession.builder.appName("Recipes ML model - Are you a dessert?")
.config("spark.driver.memory", "8g")
.getOrCreate()
)
列表 12.2 读取我们的数据集并打印维度和模式
food = spark.read.csv(
"./data/recipes/epi_r.csv", inferSchema=True, header=True
)
print(food.count(), len(food.columns)) ❶
# 20057 680 ❶
food.printSchema()
# root
# |-- title: string (nullable = true)
# |-- rating: string (nullable = true)
# |-- calories: string (nullable = true)
# |-- protein: double (nullable = true)
# |-- fat: double (nullable = true)
# |-- sodium: double (nullable = true)
# |-- #cakeweek: double (nullable = true)
# |-- #wasteless: double (nullable = true) ❷
# |-- 22-minute meals: double (nullable = true) ❸
# |-- 3-ingredient recipes: double (nullable = true)
# |-- 30 days of groceries: double (nullable = true)
# ...
# |-- créme de cacao: double (nullable = true)
# |-- crêpe: double (nullable = true)
# |-- crme de cacao: double (nullable = true) ❹
# ... and many more columns
❶ 我们的数据集开始时有 20,057 行和 680 列。
❷ 一些列包含不希望出现的字符,例如 # . . .
❸ . . . 或者一个空格 . . .
❹ 或者一些无效字符!
在我们开始查看数据之前,让我们通过标准化列名来简化我们的工作。下一节将展示一个一次性重命名整个数据框的技巧。
12.1.1 使用 toDF() 标准化列名
在本节中,我们处理所有列名,使它们具有统一的外观,并便于后续使用。我们将删除任何不是字母或数字的内容,将空格和其他分隔符标准化为下划线(_)字符,并将和号(&)替换为其英文等价词 and。虽然这不是强制性的,但这将帮助我们编写更清晰的程序,并通过减少拼写错误和错误来提高列名的一致性。
仅通过查看第 12.1 节中的模式,我们就可以看到一些不太理想的列名。虽然 #cakeweek 较容易输入,但如果没有法语键盘,crêpe 可能会稍微困难一些,更不用说 cr??me de cacao 了!在处理数据时,我喜欢我的列都是小写,单词之间用下划线分隔。
要做到这一点,列表 12.3 中的简单 Python 函数 sanitize_column_name() 会接受一个“脏”列名并将其转换为干净的名称。然后,该函数一次性应用于我的数据框中的所有列,使用第四章中介绍的 toDF() 方法。当 toDF() 用于重命名数据框的列时,它接受 N 个字符串作为参数,其中 N 是我们数据框中的列数。由于我们可以通过 food.columns 访问数据框的列,快速列表推导式就可以处理所有的重命名。我还使用 star 操作符将列表解包到不同的属性中(更多详情请见附录 C)。保持一致的列命名方案将使后续的代码编写、阅读和维护更加容易:我将列名视为常规程序中的变量。
列表 12.3 一次性清理我的列
def sanitize_column_name(name):
"""Drops unwanted characters from the column name.
We replace spaces, dashes and slashes with underscore,
and only keep alphanumeric characters."""
answer = name
for i, j in ((" ", "_"), ("-", "_"), ("/", "_"), ("&", "and")): ❶
answer = answer.replace(i, j)
return "".join(
[
char
for char in answer
if char.isalpha() or char.isdigit() or char == "_" ❷
]
)
food = food.toDF(*[sanitize_column_name(name) for name in food.columns])
❶ 我遍历想要移除的字符,并用更一致的东西替换它们。
❷ 我们只保留字母、数字和下划线。
清理完这些后,我们现在可以开始探索数据了。在本节中,我们处理并清洗了数据列的名称,使数据框更易于使用。在下一节中,我们将对列进行分类,作为不同类型的特征,评估数据质量,并填补空白。
12.1.2 探索我们的数据并获得我们的第一个特征列
本节涉及深入挖掘我们的数据并编码我们的第一个机器学习特征。我介绍了主要类型的机器学习特征以及如何轻松跟踪我们输入模型训练的特征。当我们迭代探索数据和创建机器学习特征时,记录我们认为有潜力的特征是我们保持组织并使代码保持整洁的最佳方式。在这个阶段,将你的代码视为实验笔记的集合:它们越整洁,回顾你的工作以及将结果投入生产就越容易!
探索机器学习中的数据与在执行转换时探索数据类似,因为我们通过操纵数据来揭示一些不一致性、模式或缺口。正因为如此,前几章的所有材料都适用于这里。多么方便!另一方面,机器学习有一些独特之处,这影响了我们对数据和数据准备的推理和准备方式。在列表 12.4 中,我为我们的food数据框中的每一列打印了一个汇总表。这需要一些时间,但为我们提供了每个列中数据的良好总结。与单节点数据处理不同,PySpark 不能必然假设一个列可以放入内存中,因此我们无法使用图表和广泛的数据分析工具。
列表 12.4 创建所有列的汇总表
# for x in food.columns:
# food.select(x).summary().show()
# many tables looking like this one.
# +-------+--------------------+
# |summary| clove|
# +-------+--------------------+
# | count| 20052| ❶
# | mean|0.009624975064831438|
# | stddev| 0.09763611178399834|
# | min| 0.0| ❷
# | 25%| 0.0| ❷
# | 50%| 0.0| ❷
# | 75%| 0.0| ❷
# | max| 1.0| ❷
# +-------+--------------------+
#
❶ 肉豆蔻列包含 20,052 个非空值(因此有五个记录为空)。
❷ 由于四分位数分布仅为 0.0 和 1.0,我们的列很可能是二元的(0,1)。
小贴士:在 PySpark 中处理数据时,最好的建议之一是认识到你的数据足够小,可以收集到一个单独的节点中。对于 pandas DataFrames,如果你的数据大小是 pandas 的,你可以使用出色的pandas-profiling库来自动化大部分的数据分析(github.com/pandas-profiling/pandas-profiling)。记住:当你使用 PySpark 时,你的 Python 知识并没有消失!
在我们的汇总数据中,我们正在查看数值列。在机器学习中,我们将数值特征分为两类:分类或连续。分类特征是指你的列取离散数值,例如一年的月份(1 到 12)。连续特征是指列可以有无限的可能性,例如物品的价格。我们可以将分类家族细分为三种主要类型:
-
二元(或二分),当你只有两个选择(0/1,真/假)
-
有序,当类别有某种顺序时(例如,比赛中的位置)并且这个顺序很重要
-
名义,当类别没有特定的顺序时(例如,物品的颜色)
将你的变量识别为分类(带有适当的子类型)或连续变量,这将对数据准备以及未来你的机器学习模型(使用图 12.1 中的决策树来帮助你)的性能产生直接影响。正确的识别取决于上下文(这一列代表什么?)以及你想要如何编码其含义。随着你开发更多的机器学习程序,你将培养出更强的直觉。如果你第一次没有做对,不要担心;你总是可以回来调整你的特征类型。在第十三章中,我们介绍了机器学习管道,它为特征准备提供了一个很好的抽象,使得随着时间的推移轻松地进化你的机器学习代码。

图 12.1 决策树中数值特征的类型。回答问题以确定你关注的特征!
观察我们的汇总数据,似乎我们有很多可能为二元的列。在clove列的情况下,最小值和三个四分位数都是零。为了验证这一点,我们将整个数据框分组并收集一组不同的值。如果我们给定列中只有两个值,那么它就是二元的!在列表 12.5 中,我们创建了一个临时数据框is_binary来识别二元列。我们将结果收集到一个 pandas DataFrame 中——因为结果数据框只有一行——并使用 pandas(PySpark 没有简单的反转方法)的unstack()方法来反转结果。大多数列都是二元的。我个人对cakeweek和wasteless并不确信。是时候进行调查了!
列表 12.5 从我们的数据框中识别二元列
import pandas as pd
pd.set_option("display.max_rows", 1000) ❶
is_binary = food.agg(
*[
(F.size(F.collect_set(x)) == 2).alias(x) ❷
for x in food.columns
]
).toPandas()
is_binary.unstack() ❸
# title 0 False
# rating 0 False
# calories 0 False
# protein 0 False
# fat 0 False
# sodium 0 False
# cakeweek 0 False
# wasteless 0 False
# 22_minute_meals 0 True
# 3_ingredient_recipes 0 True
# ... the rest are all = True
❶ pandas 会一次显示几行。设置此选项将打印最多 1,000 行,这在探索数据时将非常有帮助。
❷ collect_set()将创建一个包含不同值的数组集合,size()返回数组的长度。两个不同的值意味着它可能是一个二元值。
❸ unstack反转 pandas DataFrame,使宽数据框在终端中更容易分析。
本节介绍了我们在机器学习中遇到的主要数值特征类型,并识别了我们数据框中的二元特征。在下一节中,我们将对剩余的列进行一些分析,并建立我们的基本特征集。
12.1.3 解决数据错误并构建我们的第一个特征集
在本节中,我们调查了一些看似不连贯的特征,并根据我们的发现清理了数据集。我们还确定了我们的第一个特征集,以及每个特征类型。本节是法医数据探索的一个例子,这是数据分析师和科学家工作中极其重要的一部分。在这种情况下,一些列与其他相关(二元)列相比不一致。我们探索了可疑列的内容,解决了差距,并继续我们的探索。结果?一个更一致、更健壮的特征集,这将导致更好的机器学习模型。
在 12.1.2 节的结尾,我们得出结论,我们数据集中的大多数特征列都是二元的。此外,还有两列可疑:cakeweek和wasteless。在下一个列表中,我显示了这两列可以取的离散值,然后显示了其中一列包含非二元值的记录。
列表 12.6 识别我们两个可疑列的离散值
food.agg(*[F.collect_set(x) for x in ("cakeweek", "wasteless")]).show(
1, False
)
# +-------------------------------+----------------------+
# |collect_set(cakeweek) |collect_set(wasteless)|
# +-------------------------------+----------------------+
# |[0.0, 1.0, 1188.0, 24.0, 880.0]|[0.0, 1.0, 1439.0] |
# +-------------------------------+----------------------+
food.where("cakeweek > 1.0 or wasteless > 1.0").select(
"title", "rating", "wasteless", "cakeweek", food.columns[-1] ❶
).show()
# +--------------------+--------------------+---------+--------+------+
# | title| rating|wasteless|cakeweek|turkey|
# +--------------------+--------------------+---------+--------+------+
# |"Beet Ravioli wit...| Aged Balsamic Vi...| 0.0| 880.0| 0.0|
# |"Seafood ""Catapl...| Vermouth| 1439.0| 24.0| 0.0|
# |"""Pot Roast"" of...| Aunt Gloria-Style "| 0.0| 1188.0| 0.0|
# +--------------------+--------------------+---------+--------+------+
❶ 我打印了前几条记录和最后几条记录,以查看潜在的数据对齐问题。
对于三条记录,我们的数据集似乎有一堆引号和一些逗号,这使 PySpark 的强大解析器感到困惑。在我们的情况下,由于受影响的记录数量很少,我没有麻烦重新对齐数据,而是直接删除了它们。我也保留了null值。
如果大量记录存在不匹配的情况,即 CSV 记录边界与预期的结果不一致,你可以尝试将记录作为文本读取,并使用 UDF 手动提取相关信息,然后再将结果保存到更好的数据格式,如 Parquet(参见第六章和第十章)。不幸的是,对于有问题的 CSV 数据,没有银弹。
列表 12.7 仅保留cakeweek和wasteless的合法值
food = food.where(
(
F.col("cakeweek").isin([0.0, 1.0]) ❶
| F.col("cakeweek").isNull() ❶
)
& (
F.col("wasteless").isin([0.0, 1.0]) ❶
| F.col("wasteless").isNull() ❶
)
)
print(food.count(), len(food.columns))
# 20054 680 ❷
❶ 这读起来如下:“'如果 cakeweek 和 wasteless 都是 0.0、1.0 或 null。””
❷ 如预期,我们失去了三条记录。
在列表 12.7 中,我通过打印我的数据框的维度来检查我的过滤。这并不是一个完美的知道我的代码是否无错误的方法,但它有助于验证我是否只移除了三个有问题的记录。如果数据有问题,这些信息可以帮助确定代码中数据出错的地方。例如,如果在过滤数据框后,我失去了 10,000 条记录,而预期只失去三条,这将告诉我我可能在过滤时过于严厉。
现在我们已经确定了两个隐藏的二进制特征列,我们可以确定我们的特征集和目标变量。目标(或标签)是包含我们想要预测的值的列。在我们的案例中,该列恰当地命名为dessert。在列表 12.8 中,我创建了包含我关心的四个主要列集的全大写变量:
-
标识符,这些是包含每个记录独特信息的列(s)
-
目标,这些是包含我们希望预测的值的列(s)(通常是单个)
-
连续列,包含连续特征
-
二进制列,包含二进制特征
数据集似乎不包含分类变量。
列表 12.8 创建四个顶级变量
IDENTIFIERS = ["title"]
CONTINUOUS_COLUMNS = [
"rating",
"calories",
"protein",
"fat",
"sodium",
]
TARGET_COLUMN = ["dessert"] ❶
BINARY_COLUMNS = [
x
for x in food.columns
if x not in CONTINUOUS_COLUMNS
and x not in TARGET_COLUMN
and x not in IDENTIFIERS
]
❶ 尽管我只有一个目标,但我发现将其放入列表中以便与其他变量保持一致很方便。
我喜欢通过变量而不是从我的数据框中删除它们来跟踪我的特征。当数据准备好进行模型训练时,这减少了猜测工作——哪些列是特征?——并且当你在下次阅读代码时,它充当轻量级的文档。这很简单,但在这里很好地满足了我们的目的。
在本节中,我们迅速清理了我们的数据。在实践中,当构建机器学习模型时,这一阶段将占用你超过一半的时间。幸运的是,数据清理是原则性的数据操作,因此你可以利用你迄今为止构建的 PySpark 工具包中的所有功能。我们还确定了我们的特征及其类型,并将它们分组到列表中,这使得在下一节中引用它们变得更加容易。在下一节中,我们将处理移除无用记录和填充二进制特征的null值。
12.1.4 移除无用记录和填充二进制特征
本节涵盖了删除无用记录,那些对我们机器学习模型没有任何信息的记录。在我们的案例中,这意味着移除两种类型的记录:
-
所有特征都是
null的那些 -
目标是
null的那些
此外,我们将进行插补,这意味着我们将为我们的二元特征提供一个 默认值。由于每个都是 0/1,其中零是 False,一是一 True,我们将 null 等同于 False 并用零作为默认值。考虑到我们模型的环境,这是一个合理的假设。这些操作对每个机器学习模型都是常见的。我们总是面临一个想要确保每条记录都会为我们的机器学习模型提供某种信息的点。我喜欢在早期阶段进行这项操作,因为完全 null 记录的过滤需要在任何插补之前发生。一旦你在某些列中填充了 null 值,你就不会知道哪条记录是完全 null 的。
记录一些实验笔记!
在本节和下一节中,我们进行了大量的反复操作。我尽力相对排序我们程序的数据清理部分。说实话,在为这一章构建原始脚本时,我在分析并检查数据的过程中重新排列了许多部分。你自己的数据清理尝试肯定会产生一个非常不同的程序。
机器学习的数据准备部分是艺术,部分是科学。直觉和经验发挥作用;你最终会识别出一些数据模式,并创建一个个人策略库来处理它们。正因为如此,记录你的步骤对于将来(以及你的同事!)更容易地重新提取代码至关重要。我在清理数据时会在旁边放一个笔记本,并确保以易于分享的格式收集我的“实验室”笔记。
列表 12.9 中的代码使用了在第五章中提到的 dropna() 方法,涉及两个子集。第一个是除了“食谱”名称之外的所有列(存储在 IDENTIFIERS 变量中)。第二个是 TARGET_COLUMN。在这个过程中我们失去了五条记录。由于丢失的记录数量很少,我不会去麻烦进行 人工标记,即根据我的最佳判断手动输入每条记录的值。标记始终是一项劳动密集型操作,但有时,例如,当你的目标是零散的或者你数据非常少时,¹ 你无法避免它。Robert (Munro) Monarch 在 Human-in-the-Loop Machine Learning(Manning, 2021)这本书中为这个主题奉献了一整本书。
列表 12.9 移除只包含 null 值的记录
food = food.dropna(
how="all",
subset=[x for x in food.columns if x not in IDENTIFIERS], ❶
)
food = food.dropna(subset=TARGET_COLUMN) ❶
print(food.count(), len(food.columns))
# 20049 680 ❷
❶ 我可以使用特征组变量,而不必记住哪一列是哪一列。
❷ 在这个过程中我们失去了五条记录(20,054 - 5 = 20,049)。
作为第二步,我为所有的二元列插补一个默认值。作为一个经验法则,1 表示 True,0 表示 False。在我们的二元变量(成分的存在或根据某个标签对菜肴的分类)的背景下,值的缺失可以被认为是概念上更接近于 False 而不是 True,因此我们将每个二元特征列的默认值设为 0.0。
列表 12.10 将每个二元特征列的默认值设为 0.0
food = food.fillna(0.0, subset=BINARY_COLUMNS)
print(food.where(F.col(BINARY_COLUMNS[0]).isNull()).count()) # => 0
本节介绍了使用 dropna() 过滤无用记录,以及根据标量值对二元特征进行插补。在下一节中,我们将通过探索分布和检查值域来查看连续列。
12.1.5 处理极端值:清理连续列
本节介绍了在特征准备背景下对连续值的分析。更具体地说,我们回顾了数值列的分布,以考虑极端或不切实际的价值。许多机器学习模型处理极端值的能力不佳(参见第十三章,当我们讨论特征归一化时)。就像我们对二元列所做的那样,花时间评估我们的数值列的拟合度将带来回报,因为我们不会向机器学习模型提供错误的信息。
警告 在本节中,我们使用我们对数据的了解来处理极端值。我们不会建立一个无论情况如何都要应用的通用蓝图。粗心大意的数据转换可能会在你的数据中引入异常,所以请确保花时间理解手头的问题。
在我们开始探索连续特征的分布之前,我们需要确保它们被正确地类型化(参见第六章以复习类型)。回顾我们的方案列表 12.2,由于某些数据错位,PySpark 推断 rating 和 calories 列的类型为字符串,而它们应该是数值类型。在列表 12.11 中,一个简单的 UDF 接受一个字符串列,如果值是浮点数(或 null——PySpark 将允许 Double 列中的 null 值)则返回 True,否则返回 False。我这样做更多的是作为一种探索,而不是作为一个真正的清理步骤;因为这两个列中的任何字符串值都意味着数据错位,我将删除记录而不是尝试修复它。
UDF 看起来相当复杂,但如果我们慢慢来,它是非常简单的。如果值是 null,我立即返回 True。如果我有非 null 值,我尝试将值转换为 Python float。如果失败,则返回 False!
列表 12.11 rating 和 calories 列中的非数值值
from typing import Optional
@F.udf(T.BooleanType())
def is_a_number(value: Optional[str]) -> bool:
if not value:
return True
try:
_ = float(value) ❶
except ValueError:
return False
return True
food.where(~is_a_number(F.col("rating"))).select(
*CONTINUOUS_COLUMNS
).show()
# +---------+------------+-------+----+------+
# | rating| calories|protein| fat|sodium|
# +---------+------------+-------+----+------+
# | Cucumber| and Lemon "| 3.75|null| null| ❷
# +---------+------------+-------+----+------+
❶ 下划线表示“执行工作,但我不在乎结果。”
❷ 我们还有一个最后的异常记录!
我们只剩下一个顽固的记录(该死的未对齐 CSV 文件!)在下一个列表中移除之前,我将其作为双重值。现在我们的连续特征列都是数值的。
列表 12.12 将rating和calories列转换为 double
for column in ["rating", "calories"]:
food = food.where(is_a_number(F.col(column)))
food = food.withColumn(column, F.col(column).cast(T.DoubleType()))
print(food.count(), len(food.columns))
# 20048 680 ❶
❶ 一条记录丢失!
现在我们想查看实际值,以去除任何会导致平均数计算中断的荒谬值。我们重复显示在初始数据探索过程中快速展示的总结表(列表 12.4),在列表 12.13 中。我们立刻看到有些菜式已经过分夸张了!
就像二进制特征一样,我们需要用我们的判断来决定最佳行动方案以解决这个数据质量问题。我可以再次过滤记录,但这次,我将值限制在第 99 百分位数,避免极端(可能错误)的值。
列表 12.13 查看我们的连续特征列中的值
food.select(*CONTINUOUS_COLUMNS).summary(
"mean",
"stddev",
"min",
"1%",
"5%",
"50%",
"95%",
"99%",
"max",
).show()
# +-------+------------------+------------------+------------------+
# |summary| rating| calories| protein|
# +-------+------------------+------------------+------------------+
# | mean| 3.714460295291301|6324.0634571930705|100.17385283565179|
# | stddev|1.3409187660508959|359079.83696340164|3840.6809971287403|
# | min| 0.0| 0.0| 0.0|
# | 1%| 0.0| 18.0| 0.0|
# | 5%| 0.0| 62.0| 0.0|
# | 50%| 4.375| 331.0| 8.0|
# | 95%| 5.0| 1318.0| 75.0|
# | 99%| 5.0| 3203.0| 173.0|
# | max| 5.0| 3.0111218E7| 236489.0|
# +-------+------------------+------------------+------------------+
# +-------+-----------------+-----------------+
# |summary| fat| sodium|
# +-------+-----------------+-----------------+
# | mean|346.9398083953107|6226.927244193346|
# | stddev|20458.04034412409|333349.5680370268|
# | min| 0.0| 0.0|
# | 1%| 0.0| 1.0|
# | 5%| 0.0| 5.0|
# | 50%| 17.0| 294.0|
# | 95%| 85.0| 2050.0|
# | 99%| 207.0| 5661.0|
# | max| 1722763.0| 2.767511E7|
# +-------+-----------------+-----------------+
在下一个列表中,我硬编码了每个列的最大可接受值,然后我将这些最大值迭代地应用到我的食物数据框中。
列表 12.14 插补四个连续列的平均值
maximum = {
"calories": 3203.0, ❶
"protein": 173.0, ❶
"fat": 207.0, ❶
"sodium": 5661.0, ❶
}
for k, v in maximum.items():
food = food.withColumn(
k,
F.when(F.isnull(F.col(k)), F.col(k)).otherwise( ❷
F.least(F.col(k), F.lit(v))
),
)
❶ 我在这里硬编码值以确保我的分析在运行之间是一致的。如果数据发生变化,我可能想在自动化插补之前重新检查第 99 百分位数是否仍然是一个好的衡量标准,但就目前而言,我对这些确切值感到满意。
❷ 因为我想保留空值,所以我通过 when 子句保留它们。最小函数只会应用于非空记录。
没有一种万无一失的方法来处理异常值或首先识别它们;5,661 毫克的钠含量仍然触目惊心,但考虑到野外一些荒谬的食谱,这更加现实。在本章中,我不会再回到这个问题,但这将是一个在完成整个周期后留下一些线索以调整我的方法的例子。
注意:关于这里的null插补,就像我们在二进制特征上所做的那样?PySpark 提供了一个方便的机制,通过Imputer估计器。我们将在 12.3.1 节中看到这个有用的主题。
在本节中,我们在数据集上全局插补了null记录。我们还使用一个小 UDF 清理了分类特征列。在下一节中,我们将回到二进制列以移除低发生率的特征。
12.1.6 清理罕见的二进制发生列
在本节中,我移除了数据集中不足以作为可靠预测因子的列。只有少数零或一的二进制特征在将食谱分类为甜点时没有帮助:如果每个食谱(或没有食谱)都有一个特征为真,那么这个特征就不能区分,这意味着我们的模型对此没有用处。
稀有发生的特征在构建模型时是一个烦恼,因为机器可能会捕捉到偶然存在的信号。例如,如果你在抛一个公平的硬币,并把它作为预测下一次翻转的模型的特征,你可能会得到一个预测 100%正面的假模型。它将完美工作,直到你得到反面。同样,你希望每个进入你模型的特征都有足够的代表性。
对于这个模型,我选择 10 作为我的阈值。我不希望我的模型中有小于 10 个0.0或1.0的二进制特征。在列 12.15 中,我计算了每个二进制列的总和;这将给我 1.0 的数量,因为 1 的总和等于它们的计数。如果一个列中 1 的计数/总和低于 10 或高于记录数减去 10,我就收集列名以移除它。
列 12.15 移除发生得太少或太多的二进制特征
inst_sum_of_binary_columns = [
F.sum(F.col(x)).alias(x) for x in BINARY_COLUMNS
]
sum_of_binary_columns = (
food.select(*inst_sum_of_binary_columns).head().asDict() ❶
)
num_rows = food.count()
too_rare_features = [
k
for k, v in sum_of_binary_columns.items()
if v < 10 or v > (num_rows - 10)
]
len(too_rare_features) # => 167
print(too_rare_features)
# ['cakeweek', 'wasteless', '30_days_of_groceries',
# [...]
# 'yuca', 'cookbooks', 'leftovers']
BINARY_COLUMNS = list(set(BINARY_COLUMNS) - set(too_rare_features)) ❷
❶ 由于一行就像一个 Python 字典,我可以将行带回驱动程序并本地处理。
❷ 而不是从数据帧中删除列,我只是从我的 BINARY_COLUMNS 列表中移除它们。
我们移除了 167 个要么太稀少要么太频繁的特征。虽然这个数字看起来很高,但其中一些特征对于只有几千个菜谱的数据集来说非常精确。当你创建自己的模型时,你肯定会想尝试不同的值,看看是否有些参数仍然太稀少,无法提供可靠的预测。
在本节中,我们移除了稀有的二进制特征,通过减少 167 个元素来缩小我们的特征空间。在下一节中,我们将探讨创建可能提高我们模型预测能力的自定义特征。我们还通过结合那些已经存在的特征来生成和细化新特征。
12.2 特征创建和细化
本节涵盖了模型构建的两个重要步骤:特征创建(也称为特征工程)和细化。特征创建和细化是数据科学家表达他们的判断力和创造力的地方。我们编码意义和识别数据中模式的能力意味着我们的模型可以更容易地捕捉到信号。我们可能会花很多时间精心制作越来越复杂的特征。由于我的目标是使用 PySpark 提供一个端到端模型,我们来看看以下内容:
-
使用我们的连续特征列创建几个自定义特征
-
测量原始和生成连续特征之间的相关性
这些绝对不是我们能够采取的唯一方法,但步骤提供了一个很好的概述,说明了在 PySpark 中可以做什么。
12.2.1 创建自定义特征
在本节中,我们探讨从我们手头的数据中创建新特征。这样做可以提高我们模型的可解释性和预测能力。我展示了一个将几个连续特征放置在我们二进制特征相同尺度上的特征准备示例。
在 PySpark 中创建自定义特征,本质上不过是创建一个新列,只需稍微多加思考和旁边的一些笔记。手动创建特征是数据科学家的一项秘密武器:你可以将业务知识嵌入到高度定制的特征中,从而提高模型准确性和可解释性。例如,我们将选取表示食谱中蛋白质和脂肪含量的protein和fat列(分别以克为单位)。利用这两个列中的信息,我创建了两个特征,分别表示分配给每种宏量营养素的卡路里百分比。
列表 12.16 创建计算蛋白质和脂肪可归因卡路里的新特征
food = food.withColumn(
"protein_ratio", F.col("protein") * 4 / F.col("calories") ❶
).withColumn(
"fat_ratio", F.col("fat") * 9 / F.col("calories")
) ❶
food = food.fillna(0.0, subset=["protein_ratio", "fat_ratio"])
CONTINUOUS_COLUMNS += ["protein_ratio", "fat_ratio"] ❷
❶ 每克蛋白质有 4 千卡,每克脂肪有 9 千卡。
❷ 我将这两个列添加到我的连续特征集中。
通过创建这两个列,我将新知识整合到我的数据中。如果不添加每克脂肪和蛋白质的能量,数据集中没有任何内容能提供这一点。模型可以独立地绘制脂肪/蛋白质的实际数量与总卡路里计数之间的关系,但我们通过允许模型直接访问蛋白质/脂肪(以及碳水化合物;参见本节末尾的侧边栏)的比例,使这种关系更加明显。
在我们进行建模之前,我们希望消除连续变量之间的相关性,并将所有特征组合成一个单一、干净的实体。本节内容非常简短,但在构建模型时请牢记这个教训:通过创建自定义特征,你可以将新知识嵌入到数据集中。在 PySpark 中,创建新特征只需创建包含所需信息的列;这意味着你可以创建简单或高度复杂的特征。
为什么不也对碳水化合物做同样的事情?避免多重共线性
在不深入探讨食物转换过程的情况下,我没有计算碳水化合物比率作为自定义特征。除了碳水化合物的总量没有提供(以及碳水化合物的吸收稍微复杂一些)之外,当我们使用某些类型的模型时,我们必须考虑变量的线性依赖性(或多重共线性)。
当你有一个列可以被表示为其他列的线性组合时,就会发生变量之间的线性依赖。你可能想用以下公式来近似碳水化合物来源的卡路里比率
总卡路里 = 4 *(碳水化合物克数)+ 4 *(蛋白质克数)+ 9 *(脂肪克数)
或者使用基于比率的办法:
1 = (碳水化合物卡路里百分比)+(蛋白质卡路里百分比)+(脂肪卡路里百分比)
在这两种情况下,我们引入了线性依赖性:我们(以及机器)可以使用其他列的值来计算列的值。当使用具有线性组件的模型,如线性回归和逻辑回归时,这会导致模型准确性的问题(要么欠拟合,要么过拟合)。
即使你在变量选择上很注意,也可能出现多重共线性。更多信息,我建议参考《统计学习引论》(Springer,2021),第 3.3.3 节。
12.2.2 移除高度相关的特征
在本节中,我们查看我们的连续变量集,并查看它们之间的相关性,以提高我们的模型准确性和可解释性。我解释了 PySpark 如何构建相关性矩阵以及Vector和DenseMatrix对象,以及我们如何从这些对象中提取数据以进行决策。
线性模型中的相关性并不总是坏事;事实上,你希望你的特征与目标相关(这提供了预测能力)。另一方面,我们想要避免特征之间的相关性,主要有两个原因:
-
如果两个特征高度相关,这意味着它们提供了几乎相同的信息。在机器学习的背景下,这可能会使拟合算法困惑,并导致模型或数值不稳定性。
-
你的模型越复杂,维护起来就越复杂。高度相关的特征很少能提供改进的准确性,反而会使模型复杂化。简单才是更好的选择。
对于计算变量之间的相关性,PySpark 提供了Correlation对象。Correlation有一个单一的方法,corr,它计算Vector中特征之间的相关性。向量类似于 PySpark 数组,但具有针对 ML 工作优化的特殊表示(有关更详细的介绍,请参阅第十三章)。在列表 12.17 中,我使用VectorAssembler变换器对food数据帧进行操作,创建了一个包含所有连续特征的Vector的新列,continuous_features。
变换器是一个预配置的对象,正如其名称所示,它转换数据帧。独立来看,它可能看起来是多余的复杂性,但将其应用于管道中时则大放异彩。我在第十三章中更详细地介绍了变换器。
列表 12.17 将特征列组装成一个单独的Vector列
from pyspark.ml.feature import VectorAssembler
continuous_features = VectorAssembler(
inputCols=CONTINUOUS_COLUMNS, outputCol="continuous_features"
)
vector_food = food.select(CONTINUOUS_COLUMNS)
for x in CONTINUOUS_COLUMNS:
vector_food = vector_food.where(~F.isnull(F.col(x))) ❶
vector_variable = continuous_features.transform(vector_food)
vector_variable.select("continuous_features").show(3, False)
# +---------------------------------------------------------------------+
# |continuous_features |
# +---------------------------------------------------------------------+
# |[2.5,426.0,30.0,7.0,559.0,0.28169014084507044,0.14788732394366197] |
# |[4.375,403.0,18.0,23.0,1439.0,0.17866004962779156,0.5136476426799007]|
# |[3.75,165.0,6.0,7.0,165.0,0.14545454545454545,0.38181818181818183] |
# +---------------------------------------------------------------------+
# only showing top 3 rows
vector_variable.select("continuous_features").printSchema()
# root
# |-- continuous_features: vector (nullable = true)
❶ 向量列不能有 null 值(在计算相关性时它们也没有意义),因此我们通过一系列的 where()函数将其移除(参见第四章)。
注意:如果将分类和/或二元特征混合在一起,相关性可能不会很好地工作。选择适当的依赖度量取决于你的模型、你的可解释性需求以及你的数据。例如,对于非连续数据,可以参考 Jaccard 距离度量。
在列表 12.18 中,我对我连续特征向量应用了Correlation.corr()函数,并将相关性矩阵导出为易于解释的 pandas DataFrame。PySpark 以DenseMatrix列类型返回相关性矩阵,类似于二维向量。为了以易于阅读的格式提取值,我们必须进行一些方法上的调整:
-
我们使用
head()函数提取一个记录作为Row列表。 -
Row类似于有序字典,因此我们可以使用列表切片来访问包含我们的相关性矩阵的第一个(也是唯一一个)字段。 -
可以通过在矩阵上使用
toArray()方法将DenseMatrix转换为与 pandas 兼容的数组。 -
我们可以直接从我们的 Numpy 数组创建一个 pandas DataFrame。将我们的列名作为索引输入(在这种情况下,它们将扮演“行名”的角色),这使得我们的相关性矩阵非常易于阅读。
列表 12.18 在 PySpark 中创建相关性矩阵
from pyspark.ml.stat import Correlation
correlation = Correlation.corr(
vector_variable, "continuous_features" ❶
)
correlation.printSchema()
# root
# |-- pearson(binary_features): matrix (nullable = false) ❷
correlation_array = correlation.head()[0].toArray() ❸
correlation_pd = pd.DataFrame(
correlation_array, ❹
index=CONTINUOUS_COLUMNS, ❹
columns=CONTINUOUS_COLUMNS, ❹
)
print(correlation_pd.iloc[:, :4])
# rating calories protein fat ❺
# rating 1.000000 -0.019631 -0.020484 -0.027028 ❺
# calories -0.019631 1.000000 0.958442 0.978012 ❺
# protein -0.020484 0.958442 1.000000 0.947768 ❺
# fat -0.027028 0.978012 0.947768 1.000000 ❺
# sodium -0.032499 0.938167 0.936153 0.914338 ❺
# protein_ratio -0.026485 0.029879 0.121392 0.086444 ❺
# fat_ratio -0.010696 -0.007470 0.000260 0.029411 ❺
print(correlation_pd.iloc[:, 4:])
# sodium protein_ratio fat_ratio
# rating -0.032499 -0.026485 -0.010696
# calories 0.938167 0.029879 -0.007470
# protein 0.936153 0.121392 0.000260
# fat 0.914338 0.086444 0.029411
# sodium 1.000000 0.049268 -0.005783
# protein_ratio 0.049268 1.000000 0.111694
# fat_ratio -0.005783 0.111694 1.000000
❶ corr 方法接受一个数据框和一个 Vector 列引用作为参数,并生成一个包含相关性矩阵的单行单列数据框。
❷ 结果的 DenseMatrix(在方案中显示为矩阵)本身并不容易访问。
❸ 由于数据框足够小,可以本地传输,我们使用 head() 提取第一条记录,并通过索引切片提取第一条列,然后通过 toArray() 将矩阵导出为 NumPy 数组。
❹ 解释相关性矩阵的最简单方法是通过创建一个 pandas DataFrame。我们可以将列名作为索引和列传递,以便于解释。
❺ 相关性矩阵给出了向量中每个字段之间的相关性。对角线始终为 1.0,因为每个变量与其自身完全相关。
当处理总结度量,如假设检验的相关性时,PySpark 通常会将值的提取委托给简单的 NumPy 或 pandas 转换。而不是记住每个场景的不同方法组合,我使用 REPL 和内联文档:
-
查看你的数据框的方案和使用的函数/方法的文档:
matrix或vector?它们都是伪装成 NumPy 数组的。 -
由于你的数据框总是会适应内存,你可以使用
head()、take()以及Row对象上可用的方法来提取所需的记录和结构。 -
最后,将你的数据包裹在一个 pandas DataFrame、列表或你选择的结构中。
再次强调,我们的 CONTINUOUS_COLUMNS 变量避免了大量的输入和潜在的错误,这有助于在操作数据框时跟踪我们的特征。
我们的相关性计算的最后一步是评估我们想要保留哪些变量以及我们想要删除哪些变量。没有绝对的阈值来保留或删除相关性变量(也没有保留变量的协议)。从列表 12.18 的相关性矩阵中,我们看到钠、卡路里、蛋白质和脂肪之间存在高度相关性。令人惊讶的是,我们看到我们的自定义特征与它们的创建贡献列之间的相关性很小。在我的实验笔记中,我会收集以下行动项目:
-
探索卡路里计数与宏量营养素(和钠)比率的关联。那里是否有模式,或者卡路里计数(或份量的大小)只是到处都是?
-
卡路里/蛋白质/脂肪/钠含量与食谱的“甜度”有关吗?我想象不出一个甜点会非常咸。
-
运行包含所有特征的模型,然后移除卡路里和蛋白质,这对性能有何影响?
相关性分析提出的问题比回答的问题多。这是一件好事:数据质量是一个复杂的问题。通过跟踪需要更详细探索的元素,我们可以快速进入建模阶段,并有一个(粗略的)基准来锚定我们的下一个建模周期。我们重构了我们的 Python 程序,机器学习模型也不例外!
在本节中,我介绍了 PySpark 如何计算变量之间的相关性,并以矩阵形式提供结果。我们看到了Vector和Matrix对象,以及如何从它们中提取值。最后,我们评估了连续变量之间的相关性,并决定是否将它们包含在我们的第一个模型中。在下一节中,我们将探讨使用转换器和估计器准备机器学习特征。
12.3 使用转换器和估计器进行特征准备
本节概述了在特征准备方面的转换器和估计器的概念。我们将转换器和估计器用作机器学习建模中常见操作的抽象。我们探讨了两个相关的转换器和估计器示例:
-
空值填充,即我们提供一个值来替换列中的
null出现(例如,平均值) -
特征缩放,即我们规范化列的值,使它们处于更合理的尺度上(例如,在零和一之间)
转换器和估计器本身就很强大,但在第十三章中介绍的 ML 管道的上下文中尤其相关。
在 12.2.2 节中,我们通过VectorAssembler对象看到了转换器的一个示例。思考转换器的最佳方式是将它的行为转换为函数。在图 12.2 中,我将VectorAssembler与执行相同工作的函数assemble_vector()进行比较,该函数创建一个以outputCol参数命名的Vector,其中包含传递给inputCols的所有列的值。在这里,不要关注实际的工作,而更多地关注应用机制。

图 12.2 比较对数据框应用函数与转换器。转换器对象将参数化(在实例化时)与应用数据(通过transform()方法)分离。
函数应用于数据框,并带有参数化,然后返回一个转换后的数据框。
转换器对象有一个两阶段的过程。首先,在实例化转换器时,我们提供其应用所需的参数,但不是它将应用到的数据框。这呼应了我们在第一章中看到的数据和指令的分离。然后,我们使用实例化转换器的transform()方法对数据框进行操作,以获取转换后的数据框。
小贴士:我们可以使用transform()方法和一个启用转换的功能来重现这个两阶段过程。有关该主题的介绍,请参阅附录 C。
这种指令和数据分离对于创建可序列化的 ML 管道至关重要,这导致 ML 实验更容易进行,模型可移植性更高。在第十三章中,当我们构建模型时,将更详细地介绍这个主题。
估计器就像是一个转换器工厂。接下来的两个部分通过使用Imputer和MinMaxScaler介绍估计器。
12.3.1 使用Imputer估计值估算连续特征
在本节中,我们介绍Imputer估计器和估计器的概念。估计器是 Spark 用于任何数据相关转换的主要抽象,包括 ML 模型,因此在任何使用 PySpark 的 ML 代码中都很普遍。
在核心上,估计器是一个生成转换器的对象。我们通过向其构造函数提供相关参数来实例化估计器对象,就像实例化转换器一样。要应用估计器,我们调用fit()方法,它返回一个Model对象,在所有目的上,它与转换器相同。估计器允许自动创建依赖于数据的转换器。作为一个完美的例子,Imputer及其伴随模型ImputerModel在图 12.3 中有所展示。

图 12.3 Imputer及其伴随的转换器/模型ImputerModel。当对一个实例化的估计器调用fit()时,会创建一个完全参数化的转换器(称为模型)。
例如,我们希望当记录为null时,我们的Imputer将平均值估算到calories、protein、fat和sodium列中的每一条记录。我们将在下一个列表中参数化Imputer对象,提供图 12.3 中显示的相关参数(missingValue和relativeError使用默认参数即可)。
列表 12.19 实例化并应用Imputer以创建ImputerModel
from pyspark.ml.feature import Imputer
OLD_COLS = ["calories", "protein", "fat", "sodium"]
NEW_COLS = ["calories_i", "protein_i", "fat_i", "sodium_i"]
imputer = Imputer(
strategy="mean", ❶
inputCols=OLD_COLS, ❷
outputCols=NEW_COLS, ❸
)
imputer_model = imputer.fit(food) ❹
CONTINUOUS_COLUMNS = (
list(set(CONTINUOUS_COLUMNS) - set(OLD_COLS)) + NEW_COLS ❺
)
❶ 我们使用每列的平均值来填充空值。
❷ 由于我们有四个列需要估算,我们将它们的名称传递给 inputCols。
❸ 我给每个变量添加一个 _i 后缀,以便给我一个关于它们是如何创建的视觉提示。
❹ ImputerModel对象是在使用食物数据框调用 fit()方法后创建的。
❺ 我调整CONTINUOUS_COLUMNS变量以考虑新的列名。
警告:如果一个转换器或估计器有inputCol/outputCol和inputCols/outputCols作为参数,这意味着它们可以应用于一个或多个列。您只能选择这些选项中的一个。如有疑问,请查看对象的签名和文档。
我们像使用任何转换器一样应用结果ImputerModel,通过使用transform()方法。在下一个列表中,我们看到calories被正确地估算为大约 475.52 卡路里。真不错!
列表 12.20 像使用转换器一样使用ImputerModel对象
food_imputed = imputer_model.transform(food)
food_imputed.where("calories is null").select("calories", "calories_i").show(
5, False
)
# +--------+-----------------+
# |calories|calories_i |
# +--------+-----------------+
# |null |475.5222194325885| ❶
# |null |475.5222194325885| ❶
# |null |475.5222194325885| ❶
# |null |475.5222194325885| ❶
# |null |475.5222194325885| ❶
# +--------+-----------------+
# only showing top 5 rows
❶ 我们的 Imputer 按预期工作:空卡路里意味着 475.52 卡路里 _i。
估计器和转换器将在第十三章中回归,因为它们是 ML 管道的构建块。在本节中,我们通过Imputer回顾了估计器及其与转换器的关系。在下一节中,我们将通过MinMaxScaler看到另一个例子。
模型或模型:这取决于你看它的角度
尽管我们在第十三章中更详细地讨论了这个主题,但模型(如机器学习模型)和Model(拟合估计器的输出)之间存在词语冲突。
在 Spark 的世界里,估计器是一个数据相关的构造,在调用fit()方法时,会产生一个Model对象。这意味着 PySpark 中可用的任何 ML 模型(小写“m”)都是一个估计器,我们通过在数据上fit()来获取训练好的Model对象。
反之,尽管我们的ImputerModel是一个Model对象(大写“M”),但它实际上不是一个 ML 模型。对于 PySpark,我们使用fit()方法和一些数据生成一个Model对象,所以从 Spark 的角度来看,这个定义是成立的。
12.3.2 使用 MinMaxScaler 估计器缩放我们的特征
本节介绍了使用MinMaxScaler转换器进行变量缩放。缩放变量意味着对变量执行数学变换,使它们都在相同的数值尺度上。当使用线性模型时,缩放特征意味着你的模型系数(每个特征的权重)是可比较的。这极大地提高了模型的解释性,在评估模型性能时是一个有用的资产。对于某些类型的模型,如神经网络,缩放特征也有助于提高模型性能。
例如,如果你有一个变量包含介于 0.0 和 1.0 之间的数字,另一个变量包含介于 1,000.0 和 2,500.0 之间的数字,如果它们都有相同的模型系数(比如说 0.48),你可能会想认为它们同样重要。实际上,第二个变量要重要得多,因为 0.48 乘以几千比 0.48 乘以零到一之间的任何数字都要高得多。
为了选择正确的缩放算法,我们需要整体考虑我们的变量。由于我们有很多二元变量,让每个变量都在零到一之间是很方便的。我们的protein_ratio和fat_ratio也是介于零到一之间的比率!PySpark 为此用例提供了MinMaxScaler:对于输入列中的每个值,它创建一个介于0.0和1.0之间的归一化输出(MinMaxScaler的更多选项在第十三章中介绍)。

图 12.4 MinMaxScaler及其伴随模型MinMaxScalerModel——与Imputer估计器相同的操作模型
在列表 12.21 中,我们创建了一个 MinMaxScaler 估算器,并将向量列输入和输出列作为字符串提供。这遵循了与 Imputer/ ImputerModel 相同的步骤。
列表 12.21 缩放未缩放的连续变量
from pyspark.ml.feature import MinMaxScaler
CONTINUOUS_NB = [x for x in CONTINUOUS_COLUMNS if "ratio" not in x]
continuous_assembler = VectorAssembler(
inputCols=CONTINUOUS_NB, outputCol="continuous"
)
food_features = continuous_assembler.transform(food_imputed)
continuous_scaler = MinMaxScaler(
inputCol="continuous",
outputCol="continuous_scaled",
)
food_features = continuous_scaler.fit(food_features).transform(
food_features
)
food_features.select("continuous_scaled").show(3, False)
# +------------------------------------------------------...+
# |continuous_scaled ...|
# +------------------------------------------------------...+
# |[0.5,0.13300031220730565,0.17341040462427745,0.0338164...|
# |[0.875,0.12581954417733376,0.10404624277456646,0.11111...|
# |[0.75,0.051514205432407124,0.03468208092485549,0.03381...|
# +------------------------------------------------------...+
# only showing top 3 rows
小贴士:对于你自己的模型,检查 pyspark.ml.feature 模块中的其他缩放器。StandardScaler 通过减去平均值然后除以标准差来规范化你的变量,也是数据科学家中的宠儿。
我们 continuous_scaled 向量中的所有变量现在都在零和一之间。我们的连续变量准备好了;我们的二进制变量也准备好了。我认为我们现在可以组装我们的数据集以进行机器学习了!在本节中,我们回顾了 MinMaxScaler 估算器以及我们如何缩放变量以使它们具有相同的幅度。
这就结束了数据准备章节。从原始数据集开始,我们驯化了列名;探索了数据;设定了目标;将列编码为二进制和连续特征;创建了定制特征;并选择、插补和缩放了一些特征。工作完成了吗?远远没有!
在下一章中,我们终于要进入建模阶段了。同时,我们将通过 ML 管道抽象重新审视我们的程序,这将提供必要的灵活性,将科学融入数据科学,并实验多个场景。我们已经搭建了舞台,现在让我们开始建模吧!
摘要
-
创建机器学习模型的一个重要部分是数据操作。为此,我们可以利用在
pyspark.sql中学到的所有知识。 -
创建机器学习模型的第一步是评估数据质量并解决潜在的数据问题。将 PySpark 中的大数据集转换为 pandas 或纯 Python 中的小摘要可以加快数据发现和评估的速度。
-
特征创建和选择可以通过使用 PySpark 数据操作 API 手动完成,或者通过利用一些
pyspark.ml特定的构造函数,例如相关矩阵。 -
通过 PySpark 的转换器和估算器提供了多种常见的特征工程模式,如特征插补和缩放。
¹ 如果是这样,你可能不会使用 PySpark。
13 使用机器学习管道进行鲁棒的机器学习
本章涵盖
-
使用转换器和估计器将数据转换为机器学习特征
-
通过机器学习管道将特征组装成向量
-
训练一个简单的机器学习模型
-
使用相关的性能指标评估模型
-
使用交叉验证优化模型
-
通过特征权重解释模型的决策过程
在上一章中,我们为机器学习奠定了基础:从原始数据集开始,我们驯化了数据,并根据我们对数据的探索和分析创建了特征。回顾第十二章中的数据转换步骤,我们执行了以下工作,最终得到名为food_features的数据框:
-
读取包含菜名和多个列作为特征候选的 CSV 文件
-
清理了列名(将大小写转换为小写,并修正了标点符号、空格和非打印字符)
-
移除了不合理和不相关的记录
-
将二进制列的
null值填充为0.0 -
将
calories、protein、fat和sodium的数值限制在 99 百分位数 -
创建了比率特征(宏量营养素的热量与菜肴热量的比率)
-
补充了连续特征的
mean值 -
将连续特征缩放到
0.0到1.0之间
提示:如果您想跟上第十二章的代码,我在书的仓库中包含了生成food_features的代码,位于./code/ Ch12/end_of_chapter.py。
在本章中,我们继续我们的鲁棒机器学习训练之旅。我们更深入地探讨了转换器和估计器,这次是在机器学习管道的背景下。有了这个新工具,我们首先训练和评估我们的初始模型。然后,我们学习如何在运行时定制机器学习管道,使用交叉验证,这是一种流行的机器学习优化技术,来优化我们的模型参数。最后,我们简要讨论了通过从我们的机器学习管道中提取模型系数(分配给每个参数的权重)来解释模型的可解释性。
机器学习管道是 PySpark 实现机器学习功能的方式。它们提供了更好的代码组织和灵活性,但需要前期一点准备。本章首先解释了什么是机器学习管道,使用我们在第十二章创建的甜点预测数据集。我们回顾了关于转换器、估计器和机器学习管道的足够理论,以便我们开始。
13.1 转换器和估计器:Spark 中机器学习的基本构建块
本节涵盖了机器学习管道的两个主要组件:转换器和估计器。我们再次从可重用和可参数化的构建块的角度审视转换器和估计器。从 36000 英尺的高度来看,机器学习管道是一个转换器和估计器的有序列表。本节从高级概述深入到更深入的理解。然而,了解如何创建这些构建块以及如何修改它们以使用机器学习管道达到最佳效率是至关重要的。
转换器和估算器是 ML 建模中非常有用的类。当我们训练一个 ML 模型时,我们会得到一个拟合的模型,这就像是一个我们没有明确编写的程序。这个新的数据驱动程序只有一个单一的目的:接受一个格式正确的数据集,并通过添加预测列来对其进行转换。在本节中,我们看到转换器和估算器不仅为 ML 建模提供了一个有用的抽象,而且通过序列化和反序列化提供了可移植性。这意味着你可以训练和保存你的 ML 模型,并在另一个环境中部署它。
为了说明转换器和估算器的参数化方式,我们将使用在第十二章中定义和使用的转换器和估算器:
-
continuous_assembler—一个VectorAssembler转换器,它接受五个列并创建一个用于模型训练的向量列 -
consinuous_scaler—一个MinMaxScaler估算器,它将向量列中包含的值进行缩放,为向量中的每个元素返回介于0和1之间的值
为了方便起见,我在下面的列表中包含了相关的代码。我们首先介绍转换器,然后在此基础上介绍估算器。
列表 13.1 VectorAssembler和MinMaxScaler示例
CONTINUOUS_NB = ["rating", "calories_i", "protein_i", "fat_i", "sodium_i"]
continuous_assembler = VectorAssembler(
inputCols=CONTINUOUS_NB, outputCol="continuous"
)
continuous_scaler = MinMaxScaler(
inputCol="continuous",
outputCol="continuous_scaled",
)
13.1.1 数据进来,数据出去:转换器
本节正式介绍了转换器作为 ML 管道的第一个构建块。我们介绍了通用的转换器蓝图以及如何访问和修改其参数化。当我们想要运行带有 ML 代码的实验或优化 ML 模型时(参见第 13.3.3 节),这个关于转换器的额外上下文起着至关重要的作用。
在我们介绍的VectorAssembler转换器示例中(如图 13.1 所示),我们向构造函数提供了两个参数:inputCols和outputCol。这些参数提供了创建一个完全功能的VectorAssembler转换器所需的功能。这个转换器的唯一目的——通过其transform()方法——是获取inputCols(组装值)中的值,并返回一个名为outputCol的单列,其中包含所有组装值的向量。

图 13.1 continuous_assembler转换器及其 Params。该转换器使用transform()方法对作为输入传递的数据帧应用预定义的转换。
转换器的参数化称为Params(大写的 P)。当实例化一个转换器类时,就像使用任何 Python 类一样,我们传递我们想要的参数作为参数,确保明确指定每个关键字。一旦转换器被实例化,PySpark 就为我们提供了一套方法来提取和修改 Params。接下来的两个部分将介绍在转换器实例化之后如何检索和修改 Params。
查看一下VectorAssembler的签名:关键字参数
如果你查看 VectorAssembler 的签名(以及 pyspark.ml 模块中的几乎所有转换器和估计器的签名),你会在参数列表的开头看到一个星号:
class pyspark.ml.feature.VectorAssembler(*, inputCols=None, outputCol=None, handleInvalid='error')
在 Python 中,每个星号(*)之后的参数被称为关键字参数,这意味着我们需要提及该关键字。例如,我们无法这样做 VectorAssembler("input_column", "output_column")。更多信息,请参阅 PEP(Python 增强提案)3102,链接为 mng.bz/4jKV。
作为一项有趣的附加功能,Python 还支持使用斜杠(/)字符的位置参数。请参阅 PEP 570 (mng.bz/QWqj)。
查看内部结构:获取和解释 Params
回顾图 13.1,VectorAssembler 的实例化接受三个参数:inputCols、outputCol 和 handleInvalid。我们也暗示了转换器(以及估计器,在此同时)类实例的配置依赖于 Params,这驱动了转换器的行为。在本节中,我们探讨 Params,突出它们与常规类属性相似之处和不同之处,并解释为什么这些差异很重要。你可能认为,“嗯,我知道如何从 Python 类中获取属性,转换器也是 Python 类。”虽然这是正确的,但转换器(和估计器)遵循更类似 Java/Scala 的设计,我建议不要跳过这一节。它很短,但很有用,这将帮助你避免在处理 ML 管道时遇到麻烦。
首先,让我们做任何 Python 开发者都会做的事情,直接访问转换器的一个属性。在列表 13.2 中,我们看到访问 continuous_assembler 的 outputCol 属性并不会得到 continuous,就像我们传递给构造函数时那样。相反,我们得到一个名为 Param 的对象的引用(类 pyspark.ml.param.Param),它封装了我们转换器每个属性。
列表 13.2 通过访问转换器的参数以获取 Param
print(continuous_assembler.outputCol)
# VectorAssembler_e18a6589d2d5__outputCol ❶
❶ 我们得到的不是传递给 outputCol 的连续值,而是一个名为 Param 的对象。
要直接访问特定参数的值,我们使用获取器方法,这很简单,只需在单词 get 后面加上我们参数的 CamelCase 名称。在下一个列表中显示的 outputCol 的情况下,获取器方法被称为 getOutputCol()(注意大写的 O)。
列表 13.3 通过 getOutputCol() 访问 outputCol Param 的值
print(continuous_assembler.getOutputCol()) # => continuous
到目前为止,Params 似乎只是添加了一些无用的样板代码。explainParam() 改变了这一点。此方法提供了关于 Param 以及其值的文档。这最好通过一个例子来说明,我们可以在列表 13.4 中看到解释 outputCol Param 的输出。
小贴士 如果你想一次性看到所有参数,你也可以使用复数形式,explainParams()。此方法不接受任何参数,并将返回包含所有参数的换行分隔字符串。
字符串输出包含以下内容:
-
参数的名称:
outputCol -
参数的简短描述:
outputcolumnname -
参数的
default值:VectorAssembler_e18a6589d2d5__output,如果我们没有明确传递值时使用 -
参数的
current值:continuous
列表 13.4 使用 explainParam 解释 outputCol 参数
print(continuous_assembler.explainParam("outputCol"))
# outputCol: output column name. ❶
# (default: VectorAssembler_e18a6589d2d5__output, current: continuous) ❷
❶ 这是输出列参数的名称和简短描述。
❷ 我们为输出列定义了一个值。
在本节中,我们讨论了我们的转换器参数的相关信息。本节中的想法也适用于估计器(参见 13.1.2 节)。在下一节中,我们将停止查看参数,并开始更改它们。转换器将不再对我们有任何秘密!
那么,普通的 getParam() 方法呢?
转换器(和估计器)提供了简单的 getParam() 方法。它简单地返回参数,就像本节开头访问 outputCol 一样。我相信这是为了使 PySpark 转换器能够与其 Java/Scala 等效的 API 保持一致。
使用获取器和设置器设置实例化转换器的参数
在本节中,我们修改了转换器的参数。就这么简单!这主要在两种情况下很有用:
-
你正在 REPL 中构建你的转换器,并想尝试不同的参数化。
-
你正在优化你的机器学习管道参数,就像我们在 13.3.3 节中所做的那样。
小贴士 就像上一节关于获取参数的内容一样,设置参数对于估计器来说也是一样的。
我们如何更改转换器的参数?对于每个获取器,都有一个 设置器,它只是将单词 set 放在参数名称之后,使用驼峰命名法。与获取器不同,设置器将新值作为其唯一参数。在列表 13.5 中,我们使用相关的设置器方法将 outputCol 参数更改为 more_continuous。此操作返回转换后的转换器,但也会就地进行修改,这意味着你不需要将设置器的结果赋值给变量(有关更多信息以及如何避免潜在陷阱,请参阅本节末尾的侧边栏)。
列表 13.5 将 outputCol 参数设置为 more_continuous
continuous_assembler.setOutputCol("more_continuous") ❶
print(continuous_assembler.getOutputCol()) # => more_continuous
❶ 当 setOutputCol() 方法返回一个新的转换器对象时,它也会就地进行修改,所以我们不需要将结果赋值给变量。
如果你需要一次性更改多个参数(例如,在实验不同场景时,你希望一次性更改输入和输出列),你可以使用 setParams() 方法。setParams() 方法与构造函数有相同的签名:你只需按关键字传递新值,如下一列表所示。
列表 13.6 使用 setParams() 一次性更改多个参数
continuous_assembler.setParams(
inputCols=["one", "two", "three"], handleInvalid="skip"
)
print(continuous_assembler.explainParams())
# handleInvalid: How to handle invalid data (NULL and NaN values). [...]
# (default: error, current: skip)
# inputCols: input column names. (current: ['one', 'two', 'three'])
# outputCol: output column name.
# (default: VectorAssembler_e18a6589d2d5__output, current: continuous) ❶
❶ 未传递给 setParams 的 Params 保持其先前值(在列表 13.5 中设置)。
最后,如果你想将 Param 返回到其默认值,你可以使用clear()方法。这次,你需要传递Param对象。例如,在列表 13.7 中,我们通过使用clear()重置handleInvalid Param。我们通过属性槽传递实际的 Param 作为参数,该槽在节的开头可以看到,continuous_assembler.handleInvalid。如果你有一个具有inputCol/outputCol和inputCols/outputCols作为可能 Params 的转换器,这将非常有用。PySpark 一次只允许一个集合处于活动状态,因此如果你想在单列和多列之间切换,你需要clear()那些未使用的列。
列表 13.7 使用clear()清除handleInvalid Param 的当前值
continuous_assembler.clear(continuous_assembler.handleInvalid)
print(continuous_assembler.getHandleInvalid()) # => error ❶
❶ handleInvalid 返回到其原始值,错误。
这就是全部了,朋友们!在本节中,我们更详细地了解了转换器的如何和为什么,以及如何获取、设置和清除其 Params。在下一节中,我们将这些有用的知识应用于快速通过 ML 管道的第二个构建块,即估计器。
转换器和估计器是通过引用传递的:copy()方法
到目前为止,我们已经习惯了流畅的 API(见第一章),其中每个数据帧转换都会生成一个新的数据帧。这使方法链式化成为可能,这使得我们的数据转换代码非常易于阅读。
当使用转换器(和估计器)时,请记住它们是通过引用传递的,并且 setter 会就地修改对象。如果你将转换器分配给新变量名,然后对这两个变量中的任何一个使用 setter,它将修改两个引用的 Param:
new_continuous_assembler = continuous_assembler
new_continuous_assembler.setOutputCol("new_output")
print(new_continuous_assembler.getOutputCol()) # => new_output
print(continuous_assembler.getOutputCol()) # => new_output ❶
❶ continuous_assembler 和 new_continuous_assembler 的 outputCol 都已被 setter 修改。
解决这个问题的方法是copy()转换器,然后将副本分配给新变量:
copy_continuous_assembler = continuous_assembler.copy()
copy_continuous_assembler.setOutputCol("copy_output")
print(copy_continuous_assembler.getOutputCol()) # => copy_output
print(continuous_assembler.getOutputCol()) # => new_output ❶
❶ 在复制时,对 copy_continuous_assembler 的 Params 的修改不会影响 continuous_assembler。
13.1.2 数据进入,转换器输出:估计器
本节介绍了估计器,ML 管道的第二部分。就像转换器一样,了解如何操作和配置估计器是创建高效 ML 管道的宝贵步骤。转换器将输入数据帧转换为输出数据帧,估计器在输入数据帧上拟合并返回输出转换器。在本节中,我们看到转换器和估计器之间的关系意味着它们与第 13.1.1 节中解释的相同方式进行参数化。我们专注于通过fit()方法(与转换器的transform()方法相比)使用估计器,这对于最终用户来说确实是唯一的显著区别。
其中,转换器使用transform()方法,应用于数据帧,以返回一个转换后的数据帧,估计器使用fit()方法,应用于数据帧,以返回一个完全参数化的转换器,称为Model。这种区别使得估计器可以根据输入数据配置转换器。
例如,图 13.2 中的MinMaxScaler估计器有四个参数,其中两个依赖于默认值:
-
min和max,它们是我们缩放列将采取的最小值和最大值。我们分别将它们保持在默认值0.0和1.0。 -
inputCols和outputCols分别是输入列和输出列。它们遵循与转换器相同的约定。

图 13.2 MinMaxScaler估计器及其参数。转换器使用fit()方法创建并参数化一个Model(转换器的子类型),使用作为参数传递的数据帧。
为了在min和max之间缩放值,我们需要从输入列中提取最小值(我称之为E_min),以及最大值(E_max)。E_min转换为0.0,E_max转换为1.0,介于两者之间的任何值都取min和max之间的值,使用以下公式(参见本节末尾的练习,以一个角落[或边缘]情况为例,当E_max和E_min相同时):

由于转换依赖于数据中的实际值,我们无法使用普通的转换器,它期望在应用transform()方法之前“知道”一切(通过其参数化)。在MinMaxScaler的情况下,我们可以将E_min和E_max转换为简单的操作(min()和max()来自pyspark.sql.functions):
-
E_min=min(inputCol) -
E_max=max(inputCol)
一旦这些值被计算(在fit()方法期间),PySpark 就会创建、参数化并返回一个转换器/模型。
这种fit()/transform()方法适用于比MinMaxScaler复杂得多的估计器。以案例为证:ML 模型实际上在 Spark 中作为估计器实现。在下一节中,我们将我们的转换器和估计器集合组装成一个统一的 ML 管道,其中包括机器学习(终于!)。
练习 13.1
当E_min == E_max时,MinMaxScaler 会发生什么?
13.2 构建一个(完整的)机器学习管道
现在我们对转换器和估计器的知识已经很强,并且可以创建、修改和操作它们,我们准备应对成功 ML 管道的最后一个元素,即Pipeline本身。管道建立在转换器和估计器的基础上,使训练、评估和优化 ML 模型变得更加清晰和明确。在本节中,我们使用用于我们的甜点预测特征准备程序的估计器构建一个 ML 管道,并在其中添加建模步骤。
ML 管道是估计器。本课结束。
...
严肃地说,ML 管道是通过Pipeline类实现的,这是一个估计器的特殊版本。Pipeline估计器只有一个参数,称为stages,它接受一个转换器和估计器的列表。为了说明管道,我们为什么不创建一个呢?从code/Ch12/end_of_chapter.py中的food数据帧开始——这是我们在应用Imputer、VectorAssembler和MinMaxScaler之前的数据集——我们在下一列表中创建一个food_pipeline,其中包含我们之前的估计器和转换器作为阶段。
列表 13.8 food_pipeline管道,包含三个阶段
from pyspark.ml import Pipeline
import pyspark.ml.feature as MF
imputer = MF.Imputer( ❶
strategy="mean",
inputCols=["calories", "protein", "fat", "sodium"],
outputCols=["calories_i", "protein_i", "fat_i", "sodium_i"],
)
continuous_assembler = MF.VectorAssembler( ❶
inputCols=["rating", "calories_i", "protein_i", "fat_i", "sodium_i"],
outputCol="continuous",
)
continuous_scaler = MF.MinMaxScaler( ❶
inputCol="continuous",
outputCol="continuous_scaled",
)
food_pipeline = Pipeline( ❷
stages=[imputer, continuous_assembler, continuous_scaler]
)
❶ 本章用于特征准备的两个估计器和转换器在此处重复列出,以方便使用。
❷ food_pipeline管道包含三个阶段,编码在stages参数中。
在实际应用中,由于管道是一个估计器,它有一个fit()方法,该方法生成一个PipelineModel。在底层,管道按顺序应用每个阶段,根据阶段是转换器(transform())还是估计器(fit())调用适当的方法。通过将我们所有的单个阶段包装到一个管道中,我们只需要调用一个方法,即fit(),知道 PySpark 会做正确的事情以生成一个PipelineModel(见图 13.3)。尽管 PySpark 将拟合管道的结果称为管道模型,但我们没有作为阶段的机器学习模型(这将在 13.2.2 节中介绍)。然而,有了管道,我们的代码变得更加模块化,更容易维护。我们可以添加、删除和更改阶段,依靠管道定义来了解将发生什么工作。我经常创建多个转换器和估计器的迭代版本,尝试它们,直到我对结果满意(这在我们讨论 13.3.3 节中的模型优化时也会非常有用)。我可以定义几十个转换器和估计器,并保留一些旧的定义以防万一;只要我的管道清晰,我对我的数据处理方式就很有信心。

图 13.3 我们的food_pipeline展示。当使用数据帧调用fit()时,该数据帧作为参数传递给第一个阶段。每个估计器阶段都会被评估(转换器被原样传递),生成的转换器/模型形成PipelineModel的阶段。
当使用管道时,请记住数据帧将遍历每个阶段。例如,continuous_scaler阶段将输出经过continuous_scaler转换的数据帧。对于估计器阶段,数据帧保持不变,因为fit()不转换数据帧,而是返回一个Model。
本节介绍了Pipeline对象作为一个具有特殊目的的估计器:运行其他转换器和估计器。在下一节中,我们在准备建模之前完成数据的组装。
13.2.1 使用向量列类型组装最终数据集
本节探讨了向量列类型以及在模型准备过程中使用的 VectorAssembler。PySpark 要求所有输入到机器学习估计器的数据,以及一些其他估计器(如 MinMaxScaler),都应在一个单向量列中。我们回顾了模型中输入的变量以及如何使用 VectorAssembler 无缝地组装它们。最后,我介绍了 PySpark 在组装列时填充的 ML 元数据,这样我们就可以轻松记住每个变量的位置。
我们已经知道如何将数据组装成向量:使用 VectorAssembler。这里没有新内容;我们可以创建一个 VectorAssembler 阶段来组装我们想要提供给 ML 训练的所有列。在列表 13.9 中,我们组装了所有的 BINARY_COLUMNS,在第十二章中创建的 _ratio 列,以及来自我们管道的 continuous_ scaled 向量列。当 PySpark 在另一个向量中组装向量列时,它会做正确的事情:而不是得到嵌套的向量,组装步骤会将所有内容展平成一个单一、可使用的向量。
列表 13.9 创建向量组装器并更新 food_pipeline 的阶段
preml_assembler = MF.VectorAssembler(
inputCols=BINARY_COLUMNS ❶
+ ["continuous_scaled"]
+ ["protein_ratio", "fat_ratio"],
outputCol="features",
)
food_pipeline.setStages(
[imputer, continuous_assembler, continuous_scaler, preml_assembler]
)
food_pipeline_model = food_pipeline.fit(food) ❶
food_features = food_pipeline_model.transform(food) ❷
❶ food_pipeline_model 变成了一个 PipelineModel . . .
❷ . . . 然后可以将其转换()为一个数据框。
我们的数据框已经准备好进行机器学习了!我们有许多记录,每条记录都包含
-
一个 目标(或 标签)列,
dessert,包含一个二进制输入(如果食谱是甜点则为1.0,否则为 0.0) -
一个名为
features的 特征 向量,包含我们想要用其训练机器学习模型的所有信息
让我们看看我们的工作成果,好吗?当我们查看数据框中的相关列,如列表 13.10 所示,我们会发现 features 列看起来与我们之前见过的任何内容都大不相同。我们提供了 513 个不同的特征(见 features 列值开头的 513),其中包含大量的零。这被称为 稀疏 特征集。当存储向量时,PySpark 有两种选择来表示向量:
-
一种 密集 表示,其中 PySpark 中的
Vector简单地是一个 NumPy(Python 的高性能多维数组库)单维数组对象 -
一种 稀疏 表示,其中 PySpark 中的
Vector是一个与 Python 中的 SciPy(Python 中的科学计算库)scipy.sparse矩阵兼容的优化稀疏向量。
列表 13.10 使用稀疏向量表示显示 features 列
food_features.select("title", "dessert", "features").show(5, truncate=30)
# +------------------------------+-------+------------------------------+
# | title|dessert| features|
# +------------------------------+-------+------------------------------+
# | Swiss Honey-Walnut Tart | 1.0|(513,[30,47,69,154,214,251,...| ❶
# |Mascarpone Cheesecake with ...| 1.0|(513,[30,47,117,154,181,188...|
# | Beef and Barley Soup | 0.0|(513,[7,30,44,118,126,140,1...|
# | Daiquiri | 0.0|(513,[49,210,214,408,424,50...|
# |Roast Beef and Watercress W...| 0.0|(513,[12,131,161,173,244,25...|
# +------------------------------+-------+------------------------------+
# only showing top 5 rows
❶ 由于我们的向量中有 513 个元素,其中大多数是零,PySpark 使用稀疏向量表示来节省一些空间。
在实践中,你不需要决定一个 Vector 是稀疏的还是密集的:PySpark 将根据需要在这两者之间转换。我提出这个差异是因为当你在数据框中 show() 时,它们看起来不同。我们在第十二章查看相关矩阵时已经看到了密集向量表示(就像一个数组)。为了说明具有不到 513 个元素的稀疏向量,我使用了相同的样本向量两次,使用了两种不同的表示法。一个稀疏向量是一个包含
-
向量的长度
-
元素非零的位置数组
-
非零值数组
Dense: [0.0, 1.0, 4.0, 0.0]
Sparse: (4, [1,2], [1.0, 4.0])
提示:如果你需要,pyspark.sql.linalg.Vectors 有用于从头创建向量的函数和方法。
现在一切都变成了向量,我们如何记住它们在哪里?在第六章中,我简要地提到 PySpark 允许将元数据字典附加到列上,并且当使用 PySpark 的机器学习功能时,会使用这个元数据。现在是时候了!让我们看看那个元数据。
列表 13.11 让 PySpark 展开元数据
print(food_features.schema["features"])
# StructField(features,VectorUDT,true)
print(food_features.schema["features"].metadata) ❶
# {
# "ml_attr": {
# "attrs": {
# "numeric": [
# {"idx": 0, "name": "sausage"},
# {"idx": 1, "name": "washington_dc"},
# {"idx": 2, "name": "poppy"},
# [...]
# {"idx": 510, "name": "continuous_scaled_4"}, ❷
# {"idx": 511, "name": "protein_ratio"},
# {"idx": 512, "name": "fat_ratio"},
# ]
# },
# "num_attrs": 513,
# }
# }
❶ 组装向量的列模式将跟踪其组成的特征,这些特征在元数据属性下。
❷ 对于缩放变量,由于它们来自 VectorAssembler,PySpark 给它们一个通用名称,但你可以根据需要从原始向量列(此处为 continuous_assembled)跟踪它们的名称。
本节涵盖了将最终特征向量组装起来,这是在将数据发送进行训练之前的最后一个阶段。我们更详细地回顾并探讨了 Vector 数据结构,解释了它的密集和稀疏表示。最后,我们使用了 VectorAssembler 转换器来组合所有我们的特征,包括那些已经在 Vector 中的特征,并展示了包含在 features 向量中的元数据。准备好建模了吗?
13.2.2 使用逻辑回归分类器训练 ML 模型
本节涵盖了 PySpark 中的机器学习。好吧,我可能有点夸大其词了。本节涵盖了将 ML 模型阶段添加到管道中。在 PySpark 中,训练 ML 模型不过是向我们的 ML 管道中添加一个阶段。尽管如此,我们不会无端地做事,所以我们花时间来审查我们的算法选择。虽然很容易在 PySpark 中使用每个可用的算法并尝试它们,但每个算法都有使其更适合某些类型问题的特性。通常,你试图解决的商业问题会提供关于你的模型应具有哪些特性的提示。你想要一个易于解释的模型,一个对异常值(超出正常预期范围的观测数据点)具有鲁棒性的模型,还是你追求纯粹的模型精度?在本节中,我们选择了我们的原始要求——这个食谱是甜点还是不是?——并选择了一个首先集成到我们的 ML 管道中的模型类型。
由于我们的目标是二进制的(0.0或1.0),我们限制自己使用分类算法。正如其名所示,分类算法是为了预测一系列有限的结果而设计的。如果你的目标有相对较少的不同值,并且它们之间没有特定的顺序,那么你面临的是一个分类问题。另一方面,如果我们想要预测食谱中的卡路里数量,¹我们将使用回归算法,它可以预测任何数值的目标。
尽管名为逻辑回归,但该算法实际上是一种属于广义线性模型族的分类算法。这个模型族被广泛理解,并且非常强大,而且比其他模型(如决策树和神经网络)更容易解释。尽管逻辑回归很简单,但在分类设置中无处不在。最著名的例子是信用评分,至今仍由逻辑回归模型驱动。虽然我们在这里的具体用例中并不真正需要可解释性,但查看我们模型的最大驱动因素(第 13.4 节)可以让我们了解预测中最有影响力的输入。
注意:逻辑回归并非没有缺陷。线性模型比其他模型族更缺乏灵活性。它们还要求数据进行缩放,这意味着每个特征的范围应该一致。在我们的案例中,所有特征要么是二进制,要么在0.0和1.0之间缩放,所以这不是问题。关于以机器学习为中心的模型选择方法,请参阅第十二章开头的侧边栏,其中有一些优秀的参考文献。
在将我们的逻辑回归集成到管道之前,我们需要创建估计器。这个估计器被称为LogisticRegression,来自pyspark.ml的.classification模块。LogisticRegression的 API 文档页面(在 iPython/Jupyter 中通过LogisticRegression?或在线mng.bz/XWr6)列出了我们可以设置的 21 个参数。为了开始,我们尽可能多地使用默认设置,专注于使管道工作。当我们讨论第 13.3.3 节中的超参数优化时,我们会重新访问一些这些参数。我们设置的唯一三个参数如下:
-
featuresCol:包含我们的特征向量的列 -
labelCol:包含我们标签(或目标)的列 -
predictionCol:将包含我们模型预测的列
列表 13.12 向我们的管道添加LogisticRegression估计器
from pyspark.ml.classification import LogisticRegression
lr = LogisticRegression(
featuresCol="features", labelCol="dessert", predictionCol="prediction"
)
food_pipeline.setStages(
[
imputer,
continuous_assembler,
continuous_scaler,
preml_assembler,
lr,
]
)
接下来,我们将fit()我们的管道,如列表 13.13 所示。在这样做之前,我们需要使用randomSplit()将我们的数据集分成两部分:一部分用于训练,我们将它喂给我们的管道;另一部分用于测试,这是我们用来评估模型拟合度的。这使我们能够对我们的模型在之前未见过的数据上泛化的能力有信心。将训练集视为学习材料,将测试集视为考试:如果你将考试作为学习材料的一部分,分数将会很高,但这并不能准确反映学生的表现。
最后,在拟合我们的管道之前,我们cache()训练数据框。正如你从第十一章所记得的,我们这样做是因为机器学习会重复使用数据框,所以如果您的集群有足够的内存,内存中的缓存可以提供速度上的提升。
警告:尽管 PySpark 将使用相同的种子,这应该可以保证在运行之间保持一致性,但在某些情况下,PySpark 可能会打破这种一致性。如果你想 100%确定你的分割,请分割数据框,将每个数据框写入磁盘,然后从磁盘位置读取它们。
列表 13.13 分割我们的数据框以进行训练和测试
train, test = food.randomSplit([0.7, 0.3], 13) ❶
train.cache()
food_pipeline_model = food_pipeline.fit(train) ❷
results = food_pipeline_model.transform(test) ❷
❶ randomSplit()接受一个分区列表,每个分区包含数据集的一部分。第二个属性是随机数生成器的种子。
❷ 这次,我们在训练集上fit(),在测试集上transform(),而不是使用相同的数据框进行这两个操作。
现在我们已经训练了一个模型,并在测试集上做出了预测,我们准备好进入我们模型的最后阶段:评估和剖析我们的模型。
简而言之,逻辑回归
如果数学不是你的强项,你可以自由地跳过这个侧边栏。
如果我们先理解线性回归模型,那么思考逻辑回归会更容易。在学校,你可能已经学过关于简单一元回归的展示。在这种情况下,y是因变量/目标,x是因变量/特征,而m是x的系数,而b是截距(或者系数的值是零):
y = m * x + b
线性回归将这个简单公式应用到多个特征上。换句话说,x和m变成了值的向量。如果我们使用基于索引的表示法,它看起来会是这样(一些统计学教科书可能会使用不同但等效的表示法):
y = b + (m0 * x0) + (m1 * x1) + (m2 * x2) + ... + (mn * xn)
在这里,我们有n个特征和系数。这个线性回归公式被称为线性组件,通常写作Xβ(x 代表观测值,β[beta]代表系数向量)。线性回归的预测可以跨越任何数值,从负无穷大到正无穷大——公式没有界限。
我们如何从这个模型中得到一个分类模型呢?看吧,这就是逻辑回归!
逻辑回归的名称来源于对数转换。对数转换将我们的线性组件Xβ转换为一个介于零到一之间的函数。该公式是对数函数的展开形式。注意线性组件的位置。它看起来像是一个任意选择的功能,但背后有许多理论,并且这种形式非常方便:
y = 1 / (1 + exp(-Xβ))
逻辑函数的y对于任何Xβ的值将返回一个介于零到一之间的数字。为了将其转换为二元特征,我们应用一个简单的阈值:如果y``>= 0.5,则返回1,否则返回0。如果您想使模型更敏感或更不敏感,您可以更改此阈值。如果您对逻辑回归的原始y结果感兴趣,请查看您的预测数据集中的rawPrediction列:您将得到一个包含[Xβ, -Xβ]的向量。probability列将包含rawPrediction中定义的y,这是根据对数公式对两个值:
results.select("prediction", "rawPrediction", "probability").show(3, False)
# +----------+----------------------+--------------------+
# |prediction|rawPrediction |probability |
# +----------+----------------------+--------------------+
# |0.0 |[11.98907,-11.9890722]|[0.9999937,6.2116-6]|
# |0.0 |[32.94732,-32.947325] |[0.99999,4.88498-15]|
# |1.0 |[-1.32753,1.32753254] |[0.209567,0.7904] |
# +----------+----------------------+--------------------+
13.3 评估和优化我们的模型
模型构建已完成。我们如何知道我们做得有多好?在本节中,我们执行数据科学的关键步骤:审查我们的模型结果并调整其实现。这两个步骤对于确保我们使用提供的输入产生最佳模型至关重要,最大限度地提高模型有用的可能性。
PySpark 提供了一个清晰的 API,与 ML 管道抽象(多么幸运!)相匹配。我们首先通过一个定制的评估器对象来审查我们的第一个朴素模型性能,该对象为我们提供了二元分类器的相关指标。然后,我们通过交叉验证过程调整一些旋钮(称为超参数)来尝试提高模型准确性。到本节结束时,您将有一个可重复使用的评估和优化模型的蓝图。
13.3.1 评估模型准确性:混淆矩阵和评估器对象
本节涵盖了评估二元分类算法时常用的两个指标。核心目标是准确预测我们的标签(甜点或不是?)列。有多种方式来切割和剖析我们的模型结果;例如,预测肝慕斯为“甜点”比预测提拉米苏为“非甜点”更糟糕吗?选择和优化适当的指标对于产生有影响力的模型至关重要。我们关注两种不同的方式来审查我们的模型结果:
-
混淆矩阵,它提供了一个 2×2 的预测与标签矩阵,使我们能够轻松获取诸如精确度(我们在识别甜点方面做得有多好?)和召回率(我们在识别非甜点方面做得有多好?)等指标。
-
受试者工作特征曲线(或 ROC 曲线),它显示了随着我们改变其预测阈值时模型诊断能力的变化(关于这一点稍后讨论)
混淆矩阵:一种简单的方法来审查分类结果
在本节中,我们创建了一个混淆矩阵,用于比较我们的结果与真实标签,以评估我们的模型准确预测甜点的能力。混淆矩阵是呈现分类结果的一种非常简单的格式,因此在交流结果时非常受欢迎。另一方面,它们不提供任何关于模型实际性能的指导,这就是为什么我们通常将它们与一系列指标结合使用。
想象我们的甜点分类模型的混淆矩阵最简单的方法是制作一个 2 × 2 的表格,其中行是标签(真实)结果,列是预测值,就像图 13.4 一样。这也给我们提供了四个用于创建性能指标的有用度量:
-
真阴性 (TN)—标签和预测都是零。我们准确地识别了一个非甜点。
-
真阳性 (TP)—标签和预测都是一。我们准确地识别了一个甜点。
-
假阳性 (FP)—我们预测甜点(1)当菜肴实际上不是甜点(0)。这也被称为第一类错误。
-
假阴性 (FN)—我们预测非甜点(0)当菜肴实际上是甜点(1)。这也被称为第二类错误。

图 13.4 混淆矩阵中精密度和召回率的视觉表示。一个模型的精确度衡量模型预测(预测 = 1)中有多少是合法的(标签 = 1)。一个模型的召回率衡量有多少真实阳性(标签 = 1)被模型捕获(预测 = 1)。
从这四个度量中,我们可以制定出多种指标来评估我们的模型。最流行的是精确度和召回率,如图 13.4 所示。在深入了解性能指标之前,让我们创建我们的混淆矩阵。PySpark 只通过传统的 pyspark.mllib 模块提供混淆矩阵,该模块现在处于维护模式。说实话,对于这样简单的操作,我宁愿手动完成,就像列表 13.14 一样。为此,我们按标签(dessert)分组,然后在预测列上使用 pivot(),使用 count() 作为单元格的值。pivot() 从作为参数传递的列中的每个值创建一个列。
列表 13.14 使用 pivot() 为我们的模型创建混淆矩阵
results.groupby("dessert").pivot("prediction").count().show()
# +-------+----+----+
# |dessert| 0.0| 1.0| ❶❷
# +-------+----+----+ ❷
# | 0.0|4950| 77| ❷
# | 1.0| 104|1005| ❷
# +-------+----+----+ ❷
#
❶ 预测值是列 ...
❷ ... 标签值(甜点)是行——我们手工制作的混淆矩阵。
注意:混淆矩阵显示我们的数据集中非甜点比甜点多得多。在分类世界中,这被称为类别不平衡数据集,通常对模型训练来说并不理想。在这种情况下,类别不平衡是可管理的:对于该主题的彻底回顾,请参阅 Bartosz Krawczyk 的 从不平衡数据中学习:开放挑战和未来方向(2016 年,mng.bz/W7Yd)。
既然我们已经掌握了混淆矩阵,那么让我们来处理精确率和召回率。在 Spark 3.1 之前,你需要(再次)依赖传统的基于 RDD 的 MLlib (pyspark .mllib.evaluation.MulticlassMetrics) 来获取精确率和召回率。在 Spark 3.1 中,我们现在可以访问一个新的 LogisticRegressionSummary 对象,它避免了访问 RDD 世界。由于这是一个新添加的功能,我提供了两种方法的代码,重点关注未来证明的数据帧方法。
对于数据帧方法(Spark 3.1+),我们首先需要从管道模型中提取我们的拟合模型。为此,我们可以使用 pipeline_ food_model 的 stages 属性并访问最后一个项目。从该模型,在列表 13.15 中称为 lr_model,我们在 results 数据集上调用 evaluate()。evaluate() 将错误地输出任何存在的预测列,所以我只是给它提供了相关的列(dessert,features)。这是避免手动计算指标的小小代价。请注意,PySpark 也不知道我们考虑哪个标签是正标签和负标签。因此,精确率和召回率可以通过 precisionByLabel 和 recallByLabel 访问,这两个都返回按顺序排列的每个标签的精确率/召回率列表。
列表 13.15 使用 Spark 3.1+ 计算精确率和召回率
lr_model = food_pipeline_model.stages[-1] ❶
metrics = lr_model.evaluate(results.select("title", "dessert", "features"))
# LogisticRegressionTrainingSummary
print(f"Model precision: {metrics.precisionByLabel[1]}") ❷
print(f"Model recall: {metrics.recallByLabel[1]}") ❷
# Model precision: 0.9288354898336414 ❸
# Model recall: 0.9062218214607755 ❸
❶ 管道模型的最后阶段是拟合的 ML 模型。
❷ 由于 PySpark 没有关于哪个标签是正标签的概念,它将计算两个标签的精确率和召回率并将它们放入一个列表中。我们选择第二个(甜点 == 1.0)。
❸ 第一次尝试就达到了超过 90% 的精确率和召回率:打开香槟(香槟是甜点吗)!
对于使用传统基于 RDD 的 MLLib 的用户,过程相当类似,但我们首先需要将我们的数据移动到一个包含 (prediction, label) 对的 RDD 中。然后,我们需要将 RDD 传递给 pyspark.ml.evaluation.MulticlassMetrics 并提取相关指标。这次,precision() 和 recall() 是 方法,因此我们需要将正标签 (1.0) 作为参数传递。
列表 13.16 通过 RDD-based API 计算精确率和召回率
from pyspark.mllib.evaluation import MulticlassMetrics
predictionAndLabel = results.select("prediction", "dessert").rdd
metrics_rdd = MulticlassMetrics(predictionAndLabel)
print(f"Model precision: {metrics_rdd.precision(1.0)}")
print(f"Model recall: {metrics_rdd.recall(1.0)}")
# Model precision: 0.9288354898336414
# Model recall: 0.9062218214607755
在本节中,我们介绍了评估我们的二分类模型的有用指标。下一节将介绍评估二分类器时另一个有用的视角:ROC 曲线。
13.3.2 真阳性 vs. 假阳性:ROC 曲线
本节介绍了在评估二分类模型时使用的另一个常用指标。接收者操作特征曲线,通常称为 ROC(发音为“rock”)曲线,提供了模型性能的视觉提示。我们还讨论了利用 ROC 曲线的主要指标:ROC 曲线下面的面积。这种展示性能模型的不同方式为我们提供了如何通过调整决策边界来优化模型、提高判别能力的线索。当我们想要针对我们的用例优化模型时,这将非常有用(参见 13.3.3 节)。
逻辑回归(这也适用于大多数分类模型)预测一个介于 0 和 1 之间的值(有关更多信息,请参阅“逻辑回归概述”侧边栏),PySpark 存储一个名为 probability 的列。默认情况下,任何等于或超过 0.5 的概率将产生预测 1.0,而任何低于 0.5 的概率将产生 0.0。实际上,我们可以通过更改 LogisticRegression 对象的 threshold 参数来更改该阈值。
由于这个阈值在区分分类模型的判别能力方面是一个如此重要的概念,PySpark 将原始预测(对于那些数学爱好者,请参阅“逻辑回归概述”侧边栏中看到的线性组件 xβ)存储在一个 rawPrediction 列中。通过使用这个原始预测并更改阈值,而无需重新训练模型,我们可以根据不同的灵敏度感知模型的性能。由于 ROC 曲线在视觉上解释得更好,我跳过了一些步骤,并在图 13.5 中展示了结果,以及一些相关元素。生成此图表的代码将在本节稍后提供。
简而言之,ROC 曲线将假阳性率(FPRs)映射到真阳性率(TPRs)。一个完全准确的模型将有一个 100% 的 TPR 和 0% 的 FPR,这意味着每个预测都是准确的。因此,我们希望图 13.5 中显示的曲线尽可能多地触及右上角。一个不太精确的模型将接近虚线,这与我们认为的 随机 模型(FPR = TPR)相呼应。

图 13.5 我们模型的 ROC 曲线。我们希望实线尽可能多地触及右上角。
好的,我们看到了我们的 ROC 曲线。我们如何创建一个?
ROC 曲线是通过 BinaryClassificationEvaluator 对象获得的。在列表 13.17 中,我们实例化该对象,明确要求 areaUnderROC 指标,这将产生 ROC 曲线。我们的评估器以原始预测作为输入。评估器生成一个单一度量,即 ROC 曲线下的面积,一个介于零和一之间的数字(越高越好)。我们做得很好,但想知道这个数字的含义会更好。
提示:我们可以用于该指标的另一个选项是 areaUnderPR,这将给出精确率-召回率曲线下的面积。这在类别非常不平衡或处理罕见事件时很有用。
列表 13.17 创建和评估一个 BinaryClassificationEvaluator 对象
from pyspark.ml.evaluation import BinaryClassificationEvaluator
evaluator = BinaryClassificationEvaluator(
labelCol="dessert", ❶
rawPredictionCol="rawPrediction", ❶
metricName="areaUnderROC",
)
accuracy = evaluator.evaluate(results)
print(f"Area under ROC = {accuracy} ")
# Area under ROC = 0.9927442079816466
❶ 我们传递我们的标签(或目标)以及由我们的模型生成的原始预测列。
如本节前面所见,一个完美的模型将触及右上角。为了在数值上了解我们离这个目标有多近,我们使用 ROC 曲线下的面积:它是位于我们 ROC 曲线下的图表面积的比例。AUC(曲线下的面积)得分为 0.9929 意味着图表的 99.29% 面积位于 ROC 曲线下。
列表 13.18 使用matplotlib显示 ROC 曲线
import matplotlib.pyplot as plt
plt.figure(figsize=(5, 5))
plt.plot([0, 1], [0, 1], "r--")
plt.plot(
lr_model.summary.roc.select("FPR").collect(),
lr_model.summary.roc.select("TPR").collect(),
)
plt.xlabel("False positive rate")
plt.ylabel("True positive rate")
plt.show()
对于第一次运行,我们的模型表现惊人。这并不意味着我们的工作已经完成:初始模型拟合是拥有一个生产就绪模型的第一个步骤。我们的代码看起来有点手工艺,可能需要一些鲁棒性。我们还需要确保模型随着时间的推移保持准确,这就是为什么拥有一个自动化的度量管道很重要的原因。
最后一节探讨了优化模型训练的一些方面以提高性能。
13.3.3 使用交叉验证优化超参数
本节介绍了我们如何优化LogisticRegression提供的某些参数。通过微调模型训练的一些方面(Spark 构建拟合模型的方式),我们有望提高模型的准确度。为此,我们使用了一种称为交叉验证的技术。交叉验证将数据集重新采样为训练集和测试集,以评估模型对新数据的泛化能力。
在 13.2.2 节中,我们看到了我们将数据集分成两部分:一部分用于训练,另一部分用于测试。使用交叉验证,我们进一步将训练集细分一次,以尝试找到适合我们的LogisticRegression估计器的最佳参数集。在机器学习的术语中,这些参数被称为超参数,因为它们是我们用来训练模型(模型内部将包含用于预测的参数)的参数。我们将超参数优化称为选择给定情况/数据集的最佳超参数的过程。
在深入探讨交叉验证的工作原理之前,让我们选择一个要优化的超参数。为了使示例简单且易于计算,我们只构建了一个包含单个超参数的简单网格:elasticNetParam。这个超参数(称为α)可以取0.0到1.0之间的任何值。起初,我仅保留了两个值,即0.0(默认值)和1.0。
提示:想了解更多关于α的信息,请参阅 Gareth James、Daniela Witten、Trevor Hastie 和 Rob Tibshirani 合著的《统计学习引论》(Springer,2013 年)。Trevor Hastie 共同发明了弹性网络的概念,这正是α所涉及的内容!
为了构建我们希望评估模型的超参数集,我们使用ParamGridBuilder,它有助于创建参数映射,如列表 13.19 所示。为此,我们从一个构建器类开始(就像我们在第二章中使用SparkSession.builder一样)。这个构建器类可以接受一系列addGrid()方法,这些方法接受两个参数:
-
我们想要修改的阶段参数。在这种情况下,我们的
LogisticRegression估计器被分配给变量lr,因此lr.elasticNetParam是我们要讨论的参数。 -
我们希望分配给超参数的值,以列表的形式传递。
完成后,我们调用 build(),返回一个 Param Maps 列表。列表中的每个元素都是一个字典(在 Scala 中称为 Map,因此得名),它将传递给我们的管道以进行拟合的 Params。通常,我们想要设置模型估计器的超参数,但没有任何东西阻止我们从另一个阶段更改 Params,例如 preml_assembler,如果我们想删除特征(见练习)。只是确保如果你修改 inputCol/outputCol,要保持一致性,以避免丢失列错误。
列表 13.19 使用 ParamGridBuilder 构建一组超参数
from pyspark.ml.tuning import ParamGridBuilder
grid_search = (
ParamGridBuilder() ❶
.addGrid(lr.elasticNetParam, [0.0, 1.0]) ❷
.build() ❸
)
print(grid_search)
# [
# {Param(parent='LogisticRegression_14302c005814',
# name='elasticNetParam',
# doc='...'): 0.0}, ❹
# {Param(parent='LogisticRegression_14302c005814',
# name='elasticNetParam',
# doc='...'): 1.0} ❹
# ]
❶ ParamGridBuilder() 是一个构建类 . . .
❷ . . . 我们可以附加 addGrid() 方法,设置 α 为 0.0 和 1.0 . . .
❸ . . . 直到我们调用 build() 方法来最终确定网格!
❹ 我已编辑输出,仅保留相关元素。
网格,完成。现在进行交叉验证。PySpark 通过 CrossValidator 类提供开箱即用的 K 折交叉验证。简而言之,交叉验证通过将数据集分割成一系列非重叠、随机划分的集合(称为 折),这些集合被用作独立的训练和验证数据集。在图 13.6 中,我演示了一个 k = 3 折的例子。对于网格中的每个元素,PySpark 将执行三次训练-验证分割,在包含 2/3 数据的训练部分上拟合模型,然后评估在验证集(包含剩余的 1/3)上选择的性能指标。

图 13.6 三折交叉验证。对于每个 Param 映射,我们执行三次训练/验证周期,每次使用不同的三分之一作为验证集。然后,选定的指标 areaUnderROC 被组合(平均),这成为最终模型加上 Param 映射的性能指标。
代码上,CrossValidator 对象将所有内容组合在一个单一抽象之下。要构建交叉验证器,我们需要三个元素,所有这些我们之前都已经遇到过:
-
一个
estimator,它包含我们希望评估的模型(在这里:food_pipeline) -
一个
estimatorParamMaps集合,这是我们之前在章节中创建的 Param 映射列表 -
一个
evaluator,它携带我们希望优化的指标(在这里,我们重用了列表 13.17 中创建的指标)
在列表 13.20 中,我们还提供了一些参数:numFolds = 3,将我们的折数 k 设置为 3,一个种子(13)以保持运行之间随机数生成器的一致性,以及 collectSubModels=True 以保留每个正在训练的模型的版本(以比较结果)。生成的交叉验证器遵循与估计器相同的约定,因此我们应用 fit() 方法来启动交叉验证过程。
列表 13.20 创建和使用 CrossValidator 对象
from pyspark.ml.tuning import CrossValidator
cv = CrossValidator(
estimator=food_pipeline,
estimatorParamMaps=grid_search,
evaluator=evaluator,
numFolds=3,
seed=13,
collectSubModels=True,
)
cv_model = cv.fit(train) ❶
print(cv_model.avgMetrics)
# [0.9899971586317382, 0.9899992947698821] ❷
pipeline_food_model = cv_model.bestModel
❶ 我们在 CrossValidator 上使用 fit() 方法,就像对任何管道一样。
❷ elasticNetParam == 1.0 以微弱的优势获胜。
要从训练好的模型中提取areaUnderROC(这是我们评估者跟踪的指标),我们使用avgMetrics属性。在这里,我们实际上是在吹毛求疵:两个模型在性能上基本上无法区分,elasticNetParam == 1.0的模型以微弱的优势获胜。最后,我们提取bestModel以便将其用作管道模型。
在本节中,我们做了很多工作。多亏了 PySpark 友好且一致的 API,我们能够以两种方式评估我们的模型(精确度与召回率,以及 ROC 曲线下的面积),然后通过交叉验证优化管道的超参数。知道使用哪个模型,选择哪个指标,以及遵循哪个过程可能需要很多书籍(它已经有了!),但我发现使用 PySpark 进行数据科学非常令人愉快。语法不会成为障碍真是太好了!
在本章的下一节和最后一节中,我们触及了模型的可解释性。我们探讨了模型特征的系数,并基于我们的发现讨论了一些改进。
13.4 从我们的模型中获取最大驱动因素:提取系数
本节涵盖了提取我们的模型特征及其系数。我们使用这些系数来了解模型最重要的特征,并为第二次迭代规划一些改进。
在 13.2.1 节中,我解释了通过VectorAssembly对象构建的向量中的特征保留了列的元数据字典中的特征名称,并展示了如何访问它们。我们可以通过顶层StructField(参见第六章以深入了解模式和StructField)来访问模式。然后我们只需按正确的顺序将变量与系数匹配。在列表 13.21 中,我展示了features列的元数据并提取了相关字段。然后我通过lrModel的coefficient.values属性提取系数。请注意,PySpark 将模型的截距保留在intercept槽中:因为我喜欢一次性展示所有内容,所以我倾向于将截距作为表中的另一个“特征”添加。
小贴士:如果您有非数值特征,PySpark 将使用binary键而不是numeric键来存储特征的元数据。在我们的情况下,因为我们知道特征是二进制的,我们没有将它们视为特殊处理,PySpark 将它们合并到数值元数据键中。
列表 13.21 从features向量中提取特征名称
import pandas as pd
feature_names = ["(Intercept)"] + [ ❶
x["name"]
for x in (
food_features
.schema["features"]
.metadata["ml_attr"]["attrs"]["numeric"]
)
]
feature_coefficients = [lr_model.intercept] + list( ❷
lr_model.coefficients.values
)
coefficients = pd.DataFrame(
feature_coefficients, index=feature_names, columns=["coef"]
)
coefficients["abs_coef"] = coefficients["coef"].abs() ❸
print(coefficients.sort_values(["abs_coef"]))
# coef abs_coef
# kirsch 0.004305 0.004305
# jam_or_jelly -0.006601 0.006601
# lemon -0.010902 0.010902
# food_processor -0.018454 0.018454
# phyllo_puff_pastry_dough -0.020231 0.020231
# ... ... ...
# cauliflower -13.928099 13.928099
# rye -13.987067 13.987067
# plantain -15.551487 15.551487
# quick_and_healthy -15.908631 15.908631
# horseradish -17.172171 17.172171
# [514 rows x 2 columns]
❶ 我从截距槽中手动添加了截距。
❷ 我从截距槽中手动添加了截距。
❸ 由于在逻辑回归中负值和正值同等重要,我创建了一个绝对值列以便于排序。
在第十二章中,我解释了具有相同尺度(此处为零到一)的特征有助于系数的可解释性。每个系数都会乘以一个介于0和1之间的值,因此它们都是一致的。通过按绝对值排序,我们可以看到哪些系数对我们的模型影响最大。
接近零的系数,如kirsch、lemon和food_processor,意味着这个特征对我们的模型不是很有预测性。另一方面,非常高的或低的系数,如cauliflower、horseradish和quick_and_healthy,意味着这个特征具有高度预测性。在使用线性模型时,正系数意味着该特征将预测为1.0(菜肴是甜点)。我们的结果并不令人惊讶;查看非常负的特征,似乎意味着存在芥末或“快速健康”的食谱意味着“不是甜点!”
并非意外,数据科学很大程度上依赖于一个人处理、提取信息和在数据上应用正确抽象的能力。当你需要了解特定模型的原因时,统计知识变得非常有用。PySpark 为越来越多的模型提供了功能,但每个模型都将使用类似的估计器/转换器设置。现在你了解了不同组件的工作原理,应用不同的模型将变得轻而易举!
机器学习管道的可移植性:读取和写入
序列化和反序列化机器学习管道就像写入和读取数据帧一样简单。Spark 的PipelineModel有一个write方法,它的工作方式与数据帧的write方法类似(除了format,因为管道格式是预定义的)。设置overwrite()选项允许你覆盖任何现有模型:
pipeline_food_model.write().overwrite().save("am_I_a_dessert_the_model")
结果是一个名为am_I_a_dessert_the_model的目录。要读取它,我们需要导入PipelineModel类并调用load()方法:
from pyspark.ml.pipeline import PipelineModel
loaded_model = PipelineModel.load("am_I_a_dessert_the_model")
摘要
-
转换器是对象,通过
transform()方法,根据一组驱动其行为的 Params 修改数据帧。当我们想要确定性地转换数据帧时,我们会使用转换器阶段。 -
估计器是对象,通过
fit()方法,接受一个数据帧并返回一个完全参数化的转换器,称为模型。当我们想要使用数据相关的转换器转换数据帧时,我们会使用估计器阶段。 -
机器学习管道类似于估计器,因为它们使用
fit()方法生成管道模型。它们有一个单一的 Param,stages,它携带一个要应用于数据帧的转换器和估计器的有序列表。 -
在训练模型之前,每个特征都需要使用
VectorAssembler转换器组装成一个向量。这提供了一个包含所有特征的单一优化(稀疏或密集)列,用于机器学习。 -
PySpark 通过一系列评估器对象提供用于模型评估的有用指标。您可以根据预测类型选择合适的评估器(二分类 =
BinaryClassificationEvaluator)。 -
使用参数映射网格、评估器和估计器,我们可以执行模型超参数优化,尝试不同的场景,并尝试提高模型精度。
-
交叉验证是一种在拟合/测试模型之前将数据帧重新采样到不同分区的技术。我们使用交叉验证来测试模型在看到不同数据时是否表现一致。
¹ 这是一个非常简单的模型,因为卡路里直接与碳水化合物、蛋白质和脂肪相关联。请参阅第十二章以获取公式。
² 文档称它们为训练集和测试集,但我更喜欢明确区分,将“测试集”这个名称保留为我们运行数据通过管道之前的分割。
14 构建自定义 ML 转换器和评估器
本章节涵盖
-
使用 Params 进行参数化创建自己的转换器
-
使用伴随模型方法创建自己的评估器
-
在 ML 管道中集成自定义转换器和评估器
在本章节中,我们将介绍如何创建和使用自定义的转换器和评估器。虽然 PySpark 提供的转换器和评估器生态系统涵盖了大量的常用用例,并且每个版本都会带来新的用例,但有时你可能需要另辟蹊径,创建自己的。另一种选择是将你的管道一分为二,并在其中插入一个数据转换函数。这基本上抵消了我们已在第十二章和第十三章中介绍过的 ML 管道的所有优势(可移植性、自文档化)。
由于转换器和评估器非常相似,我们首先深入探讨转换器及其基本构建块——参数。然后,我们继续创建评估器,重点关注转换器之间的差异。最后,我们总结如何在 ML 管道中集成自定义转换器和评估器,并注意序列化。
在深入本章内容之前,我强烈建议您阅读第十二章和第十三章,并完成示例和练习。如果您知道它是如何被使用的,那么构建一个健壮且有用的转换器/评估器会容易得多。我认为自定义转换器和评估器是一个最好谨慎使用的工具;始终利用预定义的 PySpark 组件。如果您需要另辟蹊径,本章将为您指引方向。
14.1 创建自己的转换器
本节介绍了如何创建和使用自定义转换器。我们实现了一个ScalarNAFiller转换器,该转换器在Imputer中使用时,用标量值填充列中的null值,而不是使用mean或median。因此,我们的第十三章中的甜点管道将有一个ScalarNAFiller阶段,我们可以在运行不同场景时使用,例如在优化超参数时,而无需更改代码本身。这提高了我们 ML 实验的灵活性和鲁棒性。
创建自定义转换器并不难,但有很多组成部分和一套需要遵循的约定,以确保它与 PySpark 提供的其他转换器保持一致。本节中的蓝图遵循以下计划:
-
设计我们的转换器:参数、输入和输出。
-
创建 Params,根据需要继承一些预配置的。
-
创建必要的获取器和设置器以获取。
-
创建初始化函数以实例化我们的转换器。
-
创建转换函数。

图 14.1 我们的定制ScalarNAFiller蓝图,步骤 1
注意:由于我们将分阶段实现类,因此某些代码块将无法直接在 REPL 中运行。请参考每个部分的末尾,以获取包含新元素的转换器的更新定义。
PySpark 的 Transformer 类 (pyspark.ml.Transformer; mng.bz/y4Jq) 提供了许多我们在第十三章中使用的方法,例如 explainParams() 和 copy(),以及一些对我们实现自己的转换器非常有用的其他方法。通过继承 Transformer,我们免费继承了所有这些功能,就像我们在下面的列表中所做的那样。这为我们提供了一个起点!
列表 14.1 ScalarNAFiller 转换器的壳
from pyspark.ml import Transformer
class ScalarNAFiller(Transformer): ❶
pass
❶ ScalarNAFiller 类是 Transformer 类的子类,继承了其通用方法。
在我们开始编写转换器的其余代码之前,让我们概述其参数化和功能。下一节将回顾如何使用参数和转换函数设计一个优秀的转换器。
14.1.1 设计转换器:从参数和转换的角度思考
本节解释了转换器、其参数和转换函数之间的关系。通过使用这些组成部分设计转换器,我们可以确保我们的转换器是正确的、健壮的,并且与 API 管道中的其他部分保持一致。
在第十二章和第十三章中,我们看到了转换器(以及由此扩展的估计器)是通过一组参数进行配置的。transform() 函数始终以数据框作为输入,并返回一个转换后的数据框。我们希望保持设计的一致性,以避免使用时出现问题。
在设计自定义转换器时,我总是先实现一个函数来重现我的转换器的行为。对于 ScalarNAFiller,我们利用 fillna() 函数。我还创建了一个样本数据框来测试我的函数的行为。
列表 14.2 创建一个重现转换器期望行为的函数
import pyspark.sql.functions as F
from pyspark.sql import Column, DataFrame
test_df = spark.createDataFrame(
[[1, 2, 4, 1], [3, 6, 5, 4], [9, 4, None, 9], [11, 17, None, 3]],
["one", "two", "three", "four"],
)
def scalarNAFillerFunction(
df: DataFrame, inputCol: Column, outputCol: str, filler: float = 0.0
):
return df.withColumn(outputCol, inputCol).fillna(
filler, subset=outputCol
)
scalarNAFillerFunction(test_df, F.col("three"), "five", -99.0).show()
# +---+---+-----+----+----+
# |one|two|three|four|five|
# +---+---+-----+----+----+
# | 1| 2| 4| 1| 4|
# | 3| 6| 5| 4| 5|
# | 9| 4| null| 9| -99| ❶
# | 11| 17| null| 3| -99| ❶
# +---+---+-----+----+----+
❶ 第三列中的 null 已被替换为我们的填充值 -99。
通过我们对转换函数的设计(这在第 14.1.5 节中将证明是有用的),我们立即看到在 ScalarNAFiller 中我们需要三个参数:
-
inputCol和outputCol用于输入和输出列,遵循我们迄今为止遇到的其他转换器和估计器的相同行为。 -
filler包含一个浮点数,用于在transform()方法中将null替换为该值。
数据框(列表 14.2 中的 df)将被传递给 transform() 方法作为参数。如果我们想将其映射到第十三章中介绍的转换器蓝图,它将看起来像图 14.2。

图 14.2 ScalarNAFiller 的蓝图。需要三个参数(inputCol、outputCol、filler)来配置其行为。transform() 方法提供了数据框,就像其他转换器一样。
我认为我们现在可以开始对ScalarNAFiller类进行编码了。在本节中,我们通过概述 Params 设计了我们的转换器,并创建了一个复制transform()函数预期行为的函数。在下一节中,我们将创建转换器操作所需的 Params。
14.1.2 创建转换器的 Params
在本节中,我们为ScalarNAFiller转换器创建了三个 Params(inputCol、outputCol、filler)。我们学习了如何从头开始定义一个与其它 Params 良好协作的 Param。我们还利用了 PySpark 为常见 Params 提供的预定义 Param 类。Params 驱动转换器和估计器的行为,并在运行管道(例如,第十三章中的交叉验证,我们提供了 Param 映射来测试不同的 ML 超参数)时允许轻松定制。因此,我们以允许这种定制和自文档化的方式创建它们非常重要。
首先,我们从创建一个自定义 Param 开始,我们的填充值filler。为了创建一个自定义 Param,PySpark 提供了一个具有四个属性的Param类:
-
一个
parent,它在转换器实例化后携带转换器的值。 -
一个
name,这是我们的 Param 的名称。按照惯例,我们将其设置为与我们的 Param 相同的名称。 -
一个
doc,这是我们的 Param 的文档。这允许我们在转换器被使用时嵌入我们的 Param 的文档。 -
一个
typeConverter,它控制 Param 的类型。这提供了一种标准化的方式将输入值转换为正确的类型。如果,例如,你期望一个浮点数,但转换器的用户提供了一个字符串,它还会给出相关的错误信息。
在 14.3 列表中,我们创建了一个完全配置的filler。我们创建的每个自定义 Param 都需要以Params._dummy()作为父类;这确保了当你在使用或更改它们时,例如在交叉验证(第十三章)期间,PySpark 能够复制和更改转换器的 Params。名称和文档是自解释的,所以让我们花更多的时间在typeConverter上。
类型转换器是我们指导 Param 期望值的类型的方式。想想看,它们就像 Python 中的值注解,但具有尝试转换值的选项。在filler的情况下,我们想要一个浮点数,所以我们使用TypeConverters.toFloat。还有许多其他选项可供选择。(检查 API 参考以找到适合您用例的正确选项:mng.bz/M2vn.)
列表 14.3 使用Param类创建filler Param
from pyspark.ml.param import Param, Params, TypeConverters
filler = Param(
Params._dummy(), ❶
"filler", ❷
"Value we want to replace our null values with.", ❸
typeConverter=TypeConverters.toFloat, ❹
)
filler
# Param(parent='undefined', name='filler',
# doc='Value we want to replace our null values with.')
❶ 为了与其他 Params 保持一致,父类被设置为 Params._dummy()。
❷ 我们的 Param 名称被设置为变量(filler)的字符串值。
❸ 这是我们的 Param 的文档。
❹ 我们期望我们的 Param 参数是一个类似于浮点数的值。
对于我们的转换器有三个参数,我们预计还需要重复这个过程两次,这虽然很繁琐,但却是可行的。幸运的是,PySpark 提供了一些加速手段,可以在不编写自定义参数定义的情况下包含常用参数。由于每个转换器都需要输入和输出列,即 inputCol 和 outputCol,它们属于这一类别。
常用参数定义在名为 Mixin 的特殊类中,位于 pyspark.ml.param.shared 模块下。截至编写本文时,该模块没有公开文档,因此您必须求助于阅读源代码以查看可用的 Mixins (mng.bz/aDZB)。截至 Spark 3.2.0,已定义了 34 个。inputCol 和 outputCol 参数的类分别是 HasInputCol 和 HasOutputCol。这个类本身并没有什么神奇之处:它定义了参数(见下一列表中 HasInputCol 的完整代码)并提供了一个初始化和一个获取函数,这些内容我们在 14.1.3 节中进行了介绍。
列表 14.4 HasInputCol 类看起来非常熟悉
class HasInputCols(Params):
"""Mixin for param inputCols: input column names."""
inputCols = Param( ❶
Params._dummy(),
"inputCols", "input column names.",
typeConverter=TypeConverters.toListString,
)
def __init__(self):
super(HasInputCols, self).__init__()
def getInputCols(self):
"""Gets the value of inputCols or its default value. """
return self.getOrDefault(self.inputCols)
❶ 参数定义遵循与自定义参数中看到的一套相同的约定。
要使用这些加速的参数定义,我们只需在我们的转换器类定义中对其进行子类化。我们的更新后的类定义现在已定义了所有三个参数:其中两个通过 Mixin(inputCol、outputCol),一个自定义(filler)。
列表 14.5 定义了三个参数的 ScalarNAFiller 转换器
from pyspark.ml.param.shared import HasInputCol, HasOutputCol
class ScalarNAFiller(Transformer, HasInputCol, HasOutputCol): ❶
filler = Param( ❷
Params._dummy(),
"filler",
"Value we want to replace our null values with.",
typeConverter=TypeConverters.toFloat,
)
pass
❶ inputCol 和 outputCol 通过 Mixin 类,即 HasInputCol 和 HasOutputCol 定义。
❷ 如定义,填充器有一个自定义参数。
随着 ScalarNAFiller 的参数定义完成,我们现在可以更接近地使用它了。按照本节开头概述的计划,下一步逻辑步骤——也是下一节的主题——是创建不同的获取器和设置器。
提示:如果您需要超过一个输入/输出列,请参阅 14.3.1 节,其中我们将 ScalarNAFiller 扩展到可以处理多个列。
14.1.3 获取器和设置器:成为 PySpark 的好公民
本节介绍了如何为自定义转换器创建获取器和设置器。如第十三章所示,当我们想要获取或更改参数的值时,获取器和设置器非常有用。它们提供了一个一致的接口来与转换器或估计器的参数化进行交互。
根据我们迄今为止使用的每个 PySpark 转换器的设计,创建设置器的最简单方法是:我们首先创建一个通用方法 setParams(),它允许我们更改作为关键字参数传递的多个参数(在第十三章的 continuous_assembler 转换器中可以看到)。然后,为任何其他参数创建设置器只需调用 setParams() 并传递相关的关键字参数即可。
setParams()方法一开始很难正确实现;它需要接受我们的转换器拥有的任何 Params,然后只更新我们作为参数传递的那些。幸运的是,我们可以利用 PySpark 开发者为其他转换器和估计器使用的方案。在列表 14.6 中,我提供了针对ScalarNAFiller调整的setParams()代码。如果你查看 PySpark 提供的任何转换器或估计器的源代码,你会看到相同的代码体,但函数的参数不同。
keyword_only装饰器提供了属性_input_kwargs,它是一个包含传递给函数的参数的字典。例如,如果我们调用setParams(inputCol="input", filler=0.0),则_input_kwargs将等于{"inputCol": "input", "filler": 0.0}。这个属性允许我们只捕获我们显式传递给setParams()的参数,即使我们显式地传递None。
Transformer类¹有一个_set()方法,当传入一个符合_input_kwargs接受的格式字典时,会更新相关的 Params。方便!
列表 14.6 ScalarNAFiller的setParams()方法
from pyspark import keyword_only ❶
@keyword_only
def setParams(self, *, inputCol=None, outputCol=None, filler=None): ❷
kwargs = self._input_kwargs
return self._set(**kwargs) ❸
❶ keyword_only装饰器提供了包含传递给setParams()的参数字典的_input_kwargs属性。
❷ 我们的setParams()签名只包含 ScalarNAFiller 拥有的 Params。
❸ 我们最终使用由超类提供的_set()方法来更新从_input_kwargs字典中获取的每个 Params。
当你第一次遇到setParams()方法时,它可能看起来像魔法。为什么不直接使用**kwargs和_set()呢?我认为当setParams()只包含我们的转换器拥有的 Params 时,它的签名更清晰。此外,如果我们输入了拼写错误(我经常错误地输入inputcol——没有大写字母——而不是inputCol),它将在函数调用时被捕获,而不是在我们调用_set()时很久之后。我认为这种权衡是值得的。
提示:如果你创建了一个自定义转换器并且忘记了如何创建setParams(),可以查看 PySpark 源代码中的任何转换器:它们都以相同的方式实现这个方法!
在setParams()清理完毕后,是时候创建单独的设置器了。这很简单:只需使用适当的参数调用setParams()!在列表 14.4 中,我们看到了虽然提供了inputCol的获取器,但设置器没有提供,因为这会意味着创建一个通用的setParams(),我们最终会覆盖它。不用担心,这只需要几行样板代码。
列表 14.7 ScalarNAFiller的单独设置器
def setFiller(self, new_filler):
return self.setParams(filler=new_filler) ❶
def setInputCol(self, new_inputCol):
return self.setParams(inputCol=new_inputCol) ❶
def setOutputCol(self, new_outputCol):
return self.setParams(outputCol=new_outputCol) ❶
❶ 所有三个setX()方法都使用setParams()蓝图。
设置器已完成!现在轮到获取器了。与设置器不同,Mixin 的获取器已经提供,所以我们只需要创建getFiller()。我们也不必创建通用的getParams(),因为Transformer类提供了explainParam和explainParams。
列表 14.4 中的 Mixin 定义通过提供获取器语法的蓝图,有点破坏了惊喜。我们利用超类提供的 getOrDefault() 方法在下一列表中返回相关值给调用者。
列表 14.8 ScalarNAFiller 的 getFiller() 方法
def getFiller(self):
return self.getOrDefault(self.filler)
如果我们将代码放在一起,我们的转换器看起来就像列表 14.9 中的代码。我们有自己的 Param 定义(省略以避免列表混乱),以及一个通用设置器(setParam()),三个单独的设置器(setInputCol()、setOutputCol()、setFiller()),以及一个显式的获取器(getFiller();getInputCol() 和 getOutputCol() 由 Mixin 类提供)。
列表 14.9 定义了 ScalarNAFiller 及其获取器和设置器
class ScalarNAFiller(Transformer, HasInputCol, HasOutputCol):
filler = [...] # elided for terseness
@keyword_only
def setParams(self, inputCol=None, outputCol=None, filler=None):
kwargs = self._input_kwargs
return self._set(**kwargs)
def setFiller(self, new_filler):
return self.setParams(filler=new_filler)
def getFiller(self):
return self.getOrDefault(self.filler)
def setInputCol(self, new_inputCol):
return self.setParams(inputCol=new_inputCol)
def setOutputCol(self, new_outputCol):
return self.setParams(outputCol=new_outputCol)
在本节中,我们介绍了为我们的自定义转换器创建获取器和设置器,利用 PySpark 提供的一些模板和 Mixin 类来减少样板代码。下一节将介绍初始化函数,这将使我们能够实例化并因此使用我们的转换器。
14.1.4 创建自定义转换器的初始化函数
本节涵盖了我们的转换器的初始化代码。如果我们想在 Python 中创建一个类的实例,初始化方法是最简单和最常见的方法。我们介绍了如何与 Param 映射交互以及如何使用 PySpark 辅助函数创建与其他 PySpark 转换器一致的 API。
在核心上,初始化转换器意味着 nothing more than 初始化转换器的超类并相应地设置 Param 映射。就像 setParams() 一样,__init__() 为每个转换器和估计器定义,因此我们可以从 PySpark 提供的示例中汲取灵感。
列表 14.10 中所示的 SparkNAFiller 的 __init__() 方法执行以下任务:
-
通过
super()函数实例化ScalarNAFiller继承的每个超类。 -
在我们创建的自定义 Param 上调用
setDefault()。由于keyword_only装饰器,我们需要setDefault()来设置fillerParam 的默认值。inputCol和outputCol分别由HasInputCol和HasOutputCol中的__init__()方法处理(参见列表 14.4)。 -
提取
_input_kwargs并调用setParams()来设置传递给__init__()方法的 Params,以便将 Params 设置为传递给类构造函数的值。
列表 14.10 ScalarNAFiller 的初始化器
class ScalarNAFiller(Transformer, HasInputCol, HasOutputCol):
@keyword_only
def __init__(self, inputCol=None, outputCol=None, filler=None):
super().__init__() ❶
self._setDefault(filler=None) ❷
kwargs = self._input_kwargs
self.setParams(**kwargs) ❸
@keyword_only
def setParams(self, *, inputCol=None, outputCol=None, filler=None):
kwargs = self._input_kwargs
return self._set(**kwargs)
# Rest of the methods
❶ 这将调用 Transformer 的 init() 方法,然后是 HasInputCol,然后是 HasOutputCol。
❷ 我们为 Param 填充器设置了默认值,因为 keyword_only 会拦截常规默认参数捕获。
❸ 这里,我们可以调用 _set(),但其他 PySpark 转换器使用 setParams()。两者都行得通。
我们自定义转换器的所有样板代码都已经完成了!就像对于获取器和设置器一样,从现有的 PySpark 转换器中汲取灵感确保了我们的代码是一致的并且易于推断。下一节将涵盖转换函数;然后我们的转换器将完全可用!
14.1.5 创建我们的转换函数
本节涵盖了我们的转换函数的创建。这个函数无疑是我们的转换器中最重要的一部分,因为它执行了实际的数据帧转换工作。我解释了如何使用 Params 值创建一个健壮的转换函数,以及如何处理不合适的输入。
在第十二章和第十三章中,我解释了转换器如何通过transform()方法修改数据。另一方面,Transformer类期望程序员提供一个_transform()方法(注意尾随的下划线)。区别是微妙的:PySpark 为transform()提供了一个默认实现,允许在转换时传递一个可选的参数params,以防我们想在转换时传递一个 Param 映射(类似于我们在第十三章中遇到的ParamGridBuilder和CrossValidator中的 Param 映射)。transform()最终会调用_transform(),它接受一个参数dataset并执行实际的数据转换。
由于我们已经有了一个工作函数(在 14.2 列表中创建的scalarNAFillerFunction),实现_transform()方法就轻而易举了!该方法在 14.11 列表中展示,其中有一些值得注意的细节。
首先,如果我们想验证任何 Params(例如,确保inputCol已设置),我们将在_transform()时通过使用由超类提供的isSet()方法来完成,如果没有明确设置,则抛出异常。如果我们提前这样做,我们可能会在编写/加载自定义转换器时遇到问题,就像我们在 14.3.2 节中所做的那样。
然后,我们使用个体获取器为转换器的三个 Params 设置了一些明确的变量。output_column和na_filler分别代表outputCol和fillerParams。对于代表inputColParams 值(一个字符串)的input_column,我们使用括号符号将其提升为dataset上的列对象;这使得它与我们的原型函数保持一致,并简化了方法中的return子句。由于fillerParams 预期是一个双精度浮点数,我明确地将input_column转换为double以确保fillna()方法能够工作。由于outputCol和filler有默认值,我们只需要检查用户是否设置了inputCol,如果没有设置,则抛出异常。
列表 14.11 ScalarNAFiller转换器的_transform()方法
def _transform(self, dataset):
if not self.isSet("inputCol"):
raise ValueError( ❶
"No input column set for the ScalarNAFiller transformer."
)
input_column = dataset[self.getInputCol()]
output_column = self.getOutputCol()
na_filler = self.getFiller()
return dataset.withColumn(
output_column, input_column.cast("double")
).fillna(na_filler, output_column)
❶ 如果用户没有设置 inputCol,我们将引发一个 ValueError。
_transform() 方法完成后,我们有一个完全功能的转换器!整个代码在下一列表中显示。下一节将演示我们的转换器按预期工作,因此我们可以对自己完成的工作表示祝贺。
列表 14.12 ScalarNAFiller 的源代码
class ScalarNAFiller(Transformer, HasInputCol, HasOutputCol):
filler = Param( ❶
Params._dummy(),
"filler",
"Value we want to replace our null values with.",
typeConverter=TypeConverters.toFloat,
)
@keyword_only
def __init__(self, inputCol=None, outputCol=None, filler=None): ❷
super().__init__()
self._setDefault(filler=None)
kwargs = self._input_kwargs
self.setParams(**kwargs)
@keyword_only
def setParams(self, inputCol=None, outputCol=None, filler=None): ❸
kwargs = self._input_kwargs
return self._set(**kwargs)
def setFiller(self, new_filler): ❹
return self.setParams(filler=new_filler)
def setInputCol(self, new_inputCol): ❹
return self.setParams(inputCol=new_inputCol)
def setOutputCol(self, new_outputCol): ❹
return self.setParams(outputCol=new_outputCol)
def getFiller(self): ❺
return self.getOrDefault(self.filler)
def _transform(self, dataset): ❻
if not self.isSet("inputCol"):
raise ValueError(
"No input column set for the "
"ScalarNAFiller transformer."
)
input_column = dataset[self.getInputCol()]
output_column = self.getOutputCol()
na_filler = self.getFiller()
return dataset.withColumn(
output_column, input_column.cast("double")
).fillna(na_filler, output_column)
❶ 自定义参数定义(第 14.1.2 节)
❷ 初始化方法(第 14.1.4 节)
❸ 一般的 setParams() 方法(第 14.1.3 节)
❹ 单个 setter(第 14.1.3 节)
❺ 单个 getter(仅针对自定义参数,第 14.1.3 节)
❻ 转换方法(第 14.1.5 节)
14.1.6 使用我们的转换器
现在我们已经有一个自定义转换器在手中,是时候使用它了!在本节中,我们确保 ScalarNAFiller 转换器按预期工作。为此,我们将实例化它,设置其参数,并使用转换方法。我认为我无需说服你,一旦代码编写完成,你需要亲自尝试一下。
我们已经在第十二章和第十三章中看到了转换器的实例化和使用方法,因此我们可以直接进入。
列表 14.13 实例化和测试 ScalarNAFiller 转换器
test_ScalarNAFiller = ScalarNAFiller(
inputCol="three", outputCol="five", filler=-99
)
test_ScalarNAFiller.transform(test_df).show()
# +---+---+-----+----+-----+
# |one|two|three|four| five|
# +---+---+-----+----+-----+
# | 1| 2| 4| 1| 4.0|
# | 3| 6| 5| 4| 5.0|
# | 9| 4| null| 9|-99.0|
# | 11| 17| null| 3|-99.0|
# +---+---+-----+----+-----+
由于我们继承了 HasInputCol 和 HasOutputCol,为了简洁起见,我跳过了更改 inputCol 或 outputCol 的测试,而是专注于 filler。在列表 14.14 和图 14.3 中,我展示了两种更改参数的方法,它们应该产生相同的行为:
-
使用显式的
setFiller(),它内部调用setParams() -
将参数映射传递给
transform()方法,该映射覆盖默认参数映射

图 14.3 通过显式设置 filler 参数,我们永久地修改了转换器。我们还可以在 transform() 方法中临时设置新的参数,以测试不同的场景而不修改原始转换器。
在实践中,这两种场景都产生相同的结果;区别在于操作后转换器的样子。当显式使用 setFiller() 时,我们在位置上修改了 test_ScalarNAFiller,在执行转换之前将 filler 设置为 17。在 transform() 方法中,使用参数映射,我们临时覆盖了 filler 参数,而没有更改 test_ScalarNAFiller 的位置。
列表 14.14 测试对 filler 参数的更改
test_ScalarNAFiller.setFiller(17).transform(test_df).show() ❶
test_ScalarNAFiller.transform(
test_df, params={test_ScalarNAFiller.filler: 17} ❷
).show()
# +---+---+-----+----+----+
# |one|two|three|four|five|
# +---+---+-----+----+----+
# | 1| 2| 4| 1| 4.0|
# | 3| 6| 5| 4| 5.0|
# | 9| 4| null| 9|17.0|
# | 11| 17| null| 3|17.0|
# +---+---+-----+----+----+
❶ 我们在位置上修改了 test_ScalarNAFiller。
❷ 我们临时覆盖了填充参数,而没有更改 test_ScalarNAFiller 的位置。
转换器已完成!我们不仅学习了如何从头开始创建自定义转换器,而且为下一节打下了基础,下一节将介绍如何创建自定义估计器。
14.2 创建自己的估计器
在机器学习管道中,转换器和估计器是相辅相成的。在本节中,我们基于创建自定义转换器的知识(参数、获取器/设置器、初始化器和转换函数)来构建自定义估计器。当你的需求超出 PySpark 提供的估计器集时,自定义估计器非常有用,但你仍然希望将所有步骤保持在机器学习管道内。就像在第十三章中一样,我们通过更多地关注它们与自定义转换器的不同之处来关注自定义估计器。
在本节中,我们创建了一个ExtremeValueCapper估计器。这个估计器与我们为准备甜点分类模型(第十二章)数据时对卡路里、蛋白质和脂肪进行的上限操作类似,但ExtremeValueCapper不是使用第 99 百分位数,而是将超出列平均值的值以及标准差的倍数上限。例如,如果我们的列中值的平均值为 10,标准差为 2,倍数为 3,我们将对低于 4(或 10 - 2 × 3)的值进行下限处理,并对高于 16(或 10 + 2 × 3)的值进行上限处理。由于平均数和标准差的计算依赖于输入列,我们需要一个估计器而不是转换器。
我们本节的活动计划与转换器非常相似:
-
概述估计器的设计,考虑到结果模型:输入、输出、
fit()和transform()。 -
创建一个作为
Model(这是一个特殊的Transformer)子类的伴随模型类。 -
将估计器创建为
Estimator子类。
让我们从设计开始。
14.2.1 设计我们的估计器:从模型到参数
本节涵盖了我们在开始编码之前估计器的设计。就像自定义转换器的设计(第 14.1.1 节)一样,我介绍了如何从期望的输出到输入设计你的估计器,确保设计是逻辑上合理和可靠的。
要使估计器成为一个创建转换器的机器,我们需要fit()方法返回一个完全参数化的转换器。因此,在设计估计器时,从构建返回的Model开始设计估计器是有意义的,我称之为“伴随模型”,它决定了估计器应该如何配置,而不是反过来。
在我们的ExtremeValueCapper的情况下,生成的转换器类似于边界守护者:给定一个底值和一个上限值
-
我们列中低于底值的任何值都将被底值替换
-
我们列中高于上限的任何值都将被上限值替换
在图 14.4 中,我绘制了我的转换器流程,称为ExtremeValueCapperModel,展示了相关的参数,名为inputCol、outputCol、cap和floor。我还突出了两个类别的参数:
-
隐式参数,这些参数是从数据本身推断出来的
-
明确的 参数,它们不依赖于数据,并且需要通过估计器的构建显式提供。

图 14.4 ExtremeValueCapperModel 的高级设计,显示了显式和隐式参数
对于 ExtremeValueCapperModel,cap 和 floor 是隐式参数,因为它们是使用输入列的平均值和标准差计算的。inputCol 和 outputCol 参数是显式的。
在模型设计完成之后,我们可以回溯到估计器设计本身。我们的估计器需要拥有伴随模型所需的所有显式参数,以便将其传递出去。对于我们的模型隐式参数,它们需要从输入列和估计器的参数中计算得出。在我们的例子中,我们只需要一个额外的参数,我将其命名为 boundary,用于计算 ExtremeValueCapperModel 的 cap 和 floor。设计在图 14.5 中显示,测试函数(就像我们在 14.1.1 节中为转换器所做的那样)在列表 14.15 中。这次,我创建了两个函数:一个用于估计器的 fit() 方法的功能,另一个用于伴随模型的 transform() 方法的功能。

图 14.5 ExtremeValueCapper 估计器的设计,包括其参数和生成的伴随模型
test_ExtremeValueCapper_transform() 函数接受所有四个参数,inputCol、outputCol、cap 和 floor(以及数据框 df),并返回一个包含额外列的、已对正确值进行地板和上限的 DataFrame。test_ExtremeValueCapper_fit() 函数接受 inputCol、outputCol 和 boundary 作为参数(以及数据框 df),并使用输入列的平均值 (avg) 和标准差 (stddev) 计算出 cap 和 floor。该函数返回应用于相同数据框的 test_ExtremeValueCapper_transform(),其中计算了所有参数,包括隐式和显式参数。
提示:如果我们想让 fit() 返回一个预先参数化并准备好应用于新数据框的 test_ExtremeValueCapper_transform() 函数,我们可以使用与启用转换功能的函数相同的机制。这个非常有用的功能需要一些额外的 Python 技巧,并在附录 C 中介绍,标题为“转换启用函数:返回函数的函数”。
列表 14.15 ExtremeValueCapper 伴随模型的蓝图函数
def test_ExtremeValueCapperModel_transform(
df: DataFrame,
inputCol: Column,
outputCol: str,
cap: float,
floor: float,
):
return df.withColumn(
outputCol,
F.when(inputCol > cap, cap) ❶
.when(inputCol < floor, floor) ❶
.otherwise(inputCol), ❶
)
def test_ExtremeValueCapper_fit(
df: DataFrame, inputCol: Column, outputCol: str, boundary: float
):
avg, stddev = df.agg(
F.mean(inputCol), F.stddev(inputCol)
).head() ❷
cap = avg + boundary * stddev ❸
floor = avg - boundary * stddev ❸
return test_ExtremeValueCapperModel_transform( ❹
df, inputCol, outputCol, cap, floor ❹
)
❶ 使用 when() 使得我们的代码更加冗长,但比使用 F.min(F.max(inputCol, floor), cap) 更加明确。
❷ head() 返回聚合数据框的第一个(也是唯一一个)记录作为一个 Row 对象,我们可以使用解构将其绑定到每个字段。
❸ 我们使用平均值和标准差计算 cap 和 floor,这些值依赖于数据,以及边界,这是估计器参数之一。
❹ 我们返回伴随模型的运用。
在我们的估计器和伴随模型的设计都准备好之后,我们现在可以开始编码了。在下一节中,我们将实现伴随模型类,使用一个技巧来分离 Params 和实现代码。
14.2.2 实现伴随模型:创建我们自己的 Mixin
在本节中,我们实现了伴随模型ExtremeValueCapperModel,它类似于一个 Transformer。因为这个过程与 14.1.1 节中ScalarNAFiller的实现是相同的,我们引入了一个额外的技巧,通过将 Params 与实现分离并创建我们自己的 Param Mixin 来使我们的代码更加模块化。在创建估计器和其伴随模型时,通常会将估计器的 Params 传播到伴随模型,即使它们没有被使用。在我们的例子中,这意味着boundary将被添加到ExtremeValueCapperModel的 Params 中。为了使我们的代码更清晰和简洁,我们可以为ExtremeValueCapper和ExtremeValueCapperModel实现一个 Mixin(在 Python 中是一个常规类),以便它们可以继承。
注意:我们不会在伴随模型上实现setBoundary()方法,因为我们不希望在计算了上限和下限值之后改变这个 Param。
创建一个 Mixin 与创建半个 Transformer 非常相似:
-
从我们希望添加 Params 的任何 Mixin 继承(例如,
HasInputCol,HasOutputCol)。 -
创建自定义 Param 及其 getter。
-
创建
__init__()函数。

图 14.6:我们的_ExtremeValueCapperParams Mixin 的设计,包括从两个 Mixin 继承
这里的唯一细微差别在于__init__()方法的签名。因为这个 Mixin 不会被直接调用——调用将在我们调用继承自我们的 Mixin 的类的super()时发生——我们需要接受任何下游 Transformer、模型或估计器的参数。在 Python 中,我们通过将*args传递给初始化器来简单地做到这一点。因为每个调用这个 Mixin 作为超类的 Transformer 可能有不同的参数(而我们并不使用它们),所以我们把它们捕获在*args下,并使用相同的参数调用super()。最后,我们为我们的自定义 Param boundary调用_setDefault()。
列表 14.16:_ExtremeValueCapperParams Mixin 实现
class _ExtremeValueCapperParams(HasInputCol, HasOutputCol):
boundary = Param(
Params._dummy(),
"boundary",
"Multiple of standard deviation for the cap and floor. Default = 0.0.",
TypeConverters.toFloat,
)
def __init__(self, *args):
super().__init__(*args) ❶
self._setDefault(boundary=0.0) ❷
def getBoundary(self): ❸
return self.getOrDefault(self.boundary)
❶ 通过将它们全部捕获在*args 下,确保适当的超类调用层次结构。
❷ 就像初始化一个 Transformer 一样,我们使用 setDefault()来设置边界 Param 的默认值。
❸ Mixin 通常作为类定义的一部分提供 getter。我们重用与任何 getter 相同的管道。
提示:Mixin 的__init()__方法的语法是相同的(除了_setDefault(),它将为每个 Mixin(包括由 PySpark 提供的)的 Param(s)取值)。您可以参考现有 Mixin 的源代码作为提醒。
现在我们可以实现完整的模型了,它继承自 Model(而不是 transformer,因为我们希望添加这样的知识,即这是一个模型)以及 _ExtremeValueCapperParams。为了使代码更加简洁,我已经省略了获取器和设置器。本章中每个 transformer 和估计器的完整代码都可以在本书的配套仓库中找到。
列表 14.17 ExtremeValueCapperModel 的源代码
from pyspark.ml import Model
class ExtremeValueCapperModel(Model, _ExtremeValueCapperParams): ❶
cap = Param(
Params._dummy(),
"cap",
"Upper bound of the values `inputCol` can take."
"Values will be capped to this value.",
TypeConverters.toFloat,
)
floor = Param(
Params._dummy(),
"floor",
"Lower bound of the values `inputCol` can take."
"Values will be floored to this value.",
TypeConverters.toFloat,
)
@keyword_only
def __init__(
self, inputCol=None, outputCol=None, cap=None, floor=None
):
super().__init__()
kwargs = self._input_kwargs
self.setParams(**kwargs)
def _transform(self, dataset):
if not self.isSet("inputCol"):
raise ValueError(
"No input column set for the "
"ExtremeValueCapperModel transformer."
)
input_column = dataset[self.getInputCol()]
output_column = self.getOutputCol()
cap_value = self.getOrDefault("cap")
floor_value = self.getOrDefault("floor")
return dataset.withColumn(
output_column,
F.when(input_column > cap_value, cap_value).when(input_column < floor_value, floor_value).otherwise(input_column),
)
❶ 我们从 Model 超类以及我们的 Mixin(包括 HasInputCol 和 HasOutputCol)继承,因此我们在这里不再列出它们。
虽然我们的模型不是为了直接使用——它应该是 fit() 方法的输出——但没有任何东西阻止我们的用户或我们自己导入 ExtremeValueCapperModel 并直接使用它,将直接值传递给 cap 和 floor 而不是计算它们。正因为如此,我像任何独立的 transformer 一样编写我的伴随模型,在 transform() 方法中检查适当的 Params。
在本节中,我们创建了伴随模型 ExtremeValueCapperModel 以及 _ExtremeValueCapperParams Mixin。现在我们准备好处理 ExtremeValueCapper 估计器的创建。
14.2.3 创建 ExtremeValueCapper 估计器
本节涵盖了 ExtremeValueCapper 的创建。就像创建 transformer/伴随模型类一样,估计器大量借鉴了我们迄今为止遇到的一系列约定。唯一的区别在于 fit() 方法的返回值:我们返回一个完全参数化的模型,而不是转换后的数据框。而且,就像 transformers 一样,自定义估计器允许实现 PySpark 直接未提供的功能。这使得我们的机器学习管道更加简洁和健壮。
我们已经与大量的原始材料一起工作。我们在 Mixin 中定义了 Params,并且有一个现成的伴随模型(第 14.2.2 节)。我们只需提供 __init__() 方法、设置器和 fit() 方法。由于前两者与 transformer 完成方式相同,我们的重点将放在 fit() 方法上。
对于 fit() 方法,如图 14.7 所示,我们已经在列表 14.15 中有一个可以大量借鉴的示例函数。在列表 14.18 中,fit() 方法使用估计器的 Params 重新实现了示例函数的功能。返回值是一个完全参数化的 ExtremeValueCapperModel。请注意,就像 _transform() 一样,PySpark 要求我们创建 _fit() 方法,提供一个包装的 fit(),这允许在调用时覆盖 Params。

图 14.7 ExtremeValueCapper 估计器的 fit 方法。对于伴随模型,我们根据输入数据生成 cap 和 floor Params,其中 inputCol 和 outputCol 由估计器实例化直接传递。
列表 14.18 ExtremeValueCapper 的 _fit() 方法
from pyspark.ml import Estimator
class ExtremeValueCapper(Estimator, _ExtremeValueCapperParams): ❶
# [... __init__(), setters definition]
def _fit(self, dataset):
input_column = self.getInputCol() ❷
output_column = self.getOutputCol() ❷
boundary = self.getBoundary() ❷
avg, stddev = dataset.agg( ❸
F.mean(input_column), F.stddev(input_column)
).head()
cap_value = avg + boundary * stddev
floor_value = avg - boundary * stddev
return ExtremeValueCapperModel( ❹
inputCol=input_column, ❹
outputCol=output_column, ❹
cap=cap_value, ❹
floor=floor_value, ❹
)
❶ 我们从 Estimator 类和 _ExtremeValueCapperParams 混合类继承,以减少样板代码。
❷ 我们从 Params 设置相关变量。
❸ 我们从作为参数传递的数据框中计算平均值(avg)和标准差(stddev)。
❹ 我们将完全参数化的 ExtremeValueCapperModel 作为方法输出。
就像 ExtremeValueCapperModel 一样,ExtremeValueCapper 的完整源代码可在本书的配套仓库中的 code/Ch14/custom_feature.py 下找到。
在本节中,我们实现了 ExtremeValueCapper 估计器,完成了我们自定义估计器之旅的闭环。在下一节中,我们将我们的估计器在一个样本数据集上进行测试,以进行合理性检查。
14.2.4 尝试我们的自定义估计器
本节将 ExtremeValueCapper 估计器应用于一个样本数据框。就像自定义转换器一样,在使用它之前确保我们的自定义估计器按预期工作至关重要。
在下一个列表中,我们使用本章开头定义的 test_df 数据框来尝试 ExtremeValueCapper。
列表 14.19 在样本数据框上尝试 ExtremeValueCapper
test_EVC = ExtremeValueCapper(
inputCol="one", outputCol="five", boundary=1.0
)
test_EVC.fit(test_df).transform(test_df).show() ❶
# +---+---+-----+----+------------------+
# |one|two|three|four| five|
# +---+---+-----+----+------------------+
# | 1| 2| 4| 1|1.2390477143047667| ❷
# | 3| 6| 5| 4| 3.0|
# | 9| 4| null| 9| 9.0|
# | 11| 17| null| 3|10.760952285695232| ❷
# +---+---+-----+----+------------------+
❶ fit() 返回一个参数化的 ExtremeValueCapperModel,然后调用其 transform() 方法。结果是转换后的数据框。
❷ 1 小于下限,而 11 大于上限。这两种情况都按预期工作。
在这个非常简短的章节中,我们确保了我们的 ExtremeValueCapper 正如预期那样工作。随着两个新的流程成员候选者的开发,下一节将介绍它们如何包含在我们的原始甜点预测模型中。
14.3 在机器学习流程中使用我们的转换器和估计器
如果我们打算不使用它们,创建自定义转换器和估计器有什么意义?在本节中,我们将 ScalarNAFiller 和 ExtremeValueCapper 应用于甜点分类建模流程。这个自定义转换器和估计器将帮助使我们的机器学习流程更便携,并移除我们在运行流程之前需要执行的一些预处理工作(填充 null 和数值上限)。
当编写机器学习程序时,我们可以选择是否将操作集成到流程中(通过自定义转换器/估计器)或将其作为数据转换保留。我喜欢流程的可测试性和可移植性,并倾向于“更多流程而不是更少”的观点。在构建机器学习模型时,我们经常想要对值进行上限/下限处理,或者为 null 值填充一个标量值;使用我们的自定义转换器/估计器,无需重写该转换代码。
如果我们直接使用 ScalarNAFiller,我们不得不为每个我们希望填充的二进制列应用一个转换器。这可不是我想要的!我们从这个部分开始,扩展 ScalarNAFiller 以接受多个列。
14.3.1 处理多个 inputCols
注意 从本节开始,我将使用“转换器”一词以简洁起见。这些概念同样适用于转换器、估计器和伴随模型。
在本节中,我们解决在构建自定义转换器时处理多个输入和输出列的常见问题。我们介绍了 HasInputCols 和 HasOutputCols 混合模式和如何处理可以接受一个或多个列作为输入或输出的转换器。接受多个列作为输入或输出的转换器比每个列使用一个转换器产生的重复性更少。此外,第十二章中首次遇到的 VectorAssembler,根据定义,需要多个输入列(inputCols)和一个单独的 Vector 输出列(outputCol)。在本节结束时,你将能够创建适用于单列和多列的健壮转换器。
就像 HasInputCol 和 HasOutputCol 一样,PySpark 提供了 HasInputCols 和 HasOutputCols 混合模式,我们可以使用。在列表 14.20 中,我们获得了带有额外混合模式继承的新 ScalarNAFiller 类定义。由于我们希望 ScalarNAFiller 能够以单列作为输入/输出或以多列作为输入/输出工作,因此我们继承自单数和复数混合模式。
列表 14.20 向 ScalarNAFiller 添加 HasInputCols 和 HasOutputCols
from pyspark.ml.param.shared import HasInputCols, HasOutputCols
class ScalarNAFiller(
Transformer,
HasInputCol,
HasOutputCol,
HasInputCols, ❶
HasOutputCols, ❶
):
pass
❶ 就像它们的单数对应物一样,接受多个列只是继承适当的混合模式。
注意 我们需要创建适当的设置器,并更新 setParams() 和 __init__ 的参数。完整的 ScalarNAFiller 代码可在本书的配套仓库中找到,位于 code/Ch14/custom_feature.py。
处理 inputCol/inputCols/outputCol/outputCols 意味着我们必须确保在正确的时间使用正确的 Params。这也意味着我们必须验证
-
正确的 Params 已定义
-
我们可以明确地确定应该使用哪些。
在 ScalarNAFiller 的情况下,我们希望将转换器应用于单个列(inputCol/outputCol)或多个列(inputCols/outputCols)。据此,我们可以推导出我们想要防御的三个用例:
-
如果
inputCol和inputCols都已设置,则应引发错误,因为我们不知道转换器应该应用于单个列还是多个列。 -
相反,如果两者都没有设置,我们也应该引发错误。
-
最后,如果设置了
inputCols,则outputCols应该设置为相同长度的列表(输入 N 列,输出 N 列)。
注意 outputCol 已设置为默认值,因此我们不需要测试 isSet()。
我们将这些三个测试案例包装在转换器定义中的 checkParams() 方法内。
列表 14.21 检查 Params 的有效性
def checkParams(self):
if self.isSet("inputCol") and (self.isSet("inputCols")): ❶
raise ValueError(
"Only one of `inputCol` or `inputCols`" "must be set."
)
if not (self.isSet("inputCol") or self.isSet("inputCols")): ❷
raise ValueError("One of `inputCol` or `inputCols` must be set.")
if self.isSet("inputCols"):
if len(self.getInputCols()) != len(self.getOutputCols()): ❸
raise ValueError(
"The length of `inputCols` does not match"
" the length of `outputCols`"
)
❶ 测试 1:可以设置 inputCol 或 inputCols,但不能同时设置。
❷ 测试 2:至少必须设置一个(inputCol 或 inputCols)。
❸ 测试 3:如果设置了 inputCols,则 outputCols 必须是长度相同的列表。
更新后的ScalarNAFiller的第三个方面是它自己的_transform()方法。在列表 14.22 中,新的方法有几个新的组成部分。
首先,我们使用列表 14.21 中定义的方法checkParams()进行检查。我喜欢将所有检查放在一个单独的方法下,这样_transform()方法就可以更专注于实际的转换工作。
其次,由于inputCols/outputCols是字符串列表,而inputCol/outputCol是字符串,并且我们的转换例程需要适应这两种情况,我们将单个 Param(如果使用)包裹在一个单元素列表中,以便稍后迭代。这样,我们可以使用 for 循环遍历input_columns/output_columns,而不用担心我们是在单数还是复数情况下。
最后,在转换例程本身中,我们首先测试input_columns是否与output_columns相同:当这种情况发生时,我们不需要使用withColumn()创建新列,因为它们已经在数据帧中存在。我们将使用na_filler处理output_columns列表中的所有列。
列表 14.22 修改后的_transform()方法
def _transform(self, dataset):
self.checkParams() ❶
input_columns = ( ❷
[self.getInputCol()]
if self.isSet("inputCol")
else self.getInputCols()
)
output_columns = ( ❷
[self.getOutputCol()]
if self.isSet("outputCol")
else self.getOutputCols()
)
answer = dataset
if input_columns != output_columns: ❸
for in_col, out_col in zip(input_columns, output_columns):
answer = answer.withColumn(out_col, F.col(in_col))
na_filler = self.getFiller()
return dataset.fillna(na_filler, output_columns)
❶ 我们在执行任何工作之前首先检查 Params 的有效性。
❷ 由于复数 Params 在列表中,我们通过将单个 Param 包裹在一个(单元素)列表中来保持相同的行为,这样我们就可以迭代它。
❸ 为了节省一些操作,当 input_columns 等于 output_columns 时,我们覆盖现有的列;没有必要创建新的列。
将单列 transformer 转换为多列 transformer 相当直接;我们仍然需要确保我们适当地设计 Params 的使用方式,以便它们能够正确工作,或者在出现错误时提供有信息的错误消息。在下一节中,我们将我们的自定义 transformer 在我们的甜点预测管道中发挥良好作用。
14.3.2 实际应用:将自定义组件插入到 ML 管道中
在本章的最后部分,我们探讨如何将自定义的 transformer/estimator 应用到第十三章中介绍过的我们的甜点机器学习管道中。此外,我们还将研究如何序列化和反序列化包含自定义 transformer 和 estimator 的机器学习管道,确保其与现成组件相同的可移植性。由于我们重用了第十三章中遇到的同一种管道,这一部分的内容节奏更快;实际上,这也展示了我们的自定义 transformer 和 estimator 与 PySpark ML API 的一致性。
要实例化自定义 transformer 和 estimator,我们只需使用相关参数调用类构造函数。就像使用任何 PySpark 现成组件一样,我们的自定义组件接受完全关键字属性,就像在下一个列表中所示。
列表 14.23 实例化用于我们的管道的自定义 transformer 和 estimator
scalar_na_filler = ScalarNAFiller(
inputCols=BINARY_COLUMNS, outputCols=BINARY_COLUMNS, filler=0.0 ❶
)
extreme_value_capper_cal = ExtremeValueCapper(
inputCol="calories", outputCol="calories", boundary=2.0
)
extreme_value_capper_pro = ExtremeValueCapper(
inputCol="protein", outputCol="protein", boundary=2.0
)
extreme_value_capper_fat = ExtremeValueCapper(
inputCol="fat", outputCol="fat", boundary=2.0
)
extreme_value_capper_sod = ExtremeValueCapper(
inputCol="sodium", outputCol="sodium", boundary=2.0
)
❶ inputCols、outputCols 和 filler 作为显式的关键字参数传递。
现在,我们可以在列表 14.24 中定义一个 food_pipeline,它包含我们的新组件作为阶段。由于我们的自定义转换器和评估器,我们的新管道包含了一些新的阶段,但其余部分与我们在第十三章中使用的是相同的。
列表 14.24 新的改进版 food_pipeline
from pyspark.ml.pipeline import Pipeline
food_pipeline = Pipeline(
stages=[
scalar_na_filler, ❶
extreme_value_capper_cal, ❶
extreme_value_capper_pro, ❶
extreme_value_capper_fat, ❶
extreme_value_capper_sod, ❶
imputer,
continuous_assembler,
continuous_scaler,
preml_assembler,
lr,
]
)
❶ 新的处理阶段像任何其他阶段一样列出。
毫不奇怪,更新后的 food_pipeline 使用相同的方法(fit()/ transform())工作。在列表 14.25 中,我们遵循与运行我们之前版本的管道相同的逻辑步骤:
-
将数据集拆分为
train和test分区。 -
在
train数据框上拟合管道。 -
在
test数据框上对观测值进行分类。 -
评估 AUC(曲线下面积)并打印结果。
列表 14.25 在我们的训练数据集上转换 food_pipeline
from pyspark.ml.evaluation import BinaryClassificationEvaluator
train, test = food.randomSplit([0.7, 0.3], 13)
food_pipeline_model = food_pipeline.fit(train)
results = food_pipeline_model.transform(test)
evaluator = BinaryClassificationEvaluator(
labelCol="dessert",
rawPredictionCol="rawPrediction",
metricName="areaUnderROC",
)
accuracy = evaluator.evaluate(results)
print(f"Area under ROC = {accuracy} ")
# Area under ROC = 0.9929619675735302
在我们的管道从开始到结束都运行正常的情况下,让我们看看序列化和反序列化。在下一个列表中,我们的管道没有保存,抛出了一个带有有用信息的 ValueError:一个阶段(ScalarNAFiller)不是 MLWritable。所以,快关上!
列表 14.26 尝试将我们的模型序列化到磁盘
food_pipeline_model.save("code/food_pipeline.model")
# ValueError: ('Pipeline write will fail on this pipeline because
# stage %s of type %s is not MLWritable',
# 'ScalarNAFiller_7fe16120b179', <class '__main__.ScalarNAFiller'>)
幸运的是,我们只需通过从 Mixin 继承即可添加序列化转换器(或评估器)的功能。在这个特定的情况下,我们希望我们的自定义组件从 pyspark.ml.util 模块中的 DefaultParamsReadable 和 DefaultParamsWritable 继承。在列表 14.27 中,我们将这些 Mixin 添加到 ScalarNAFiller 和 _ExtremeValueCapperParams,这样 ExtremeValueCapper 评估器和它的伴随模型就都从它们继承了。这样做可以处理序列化转换器或评估器的元数据,以便另一个 Spark 实例可以读取它们,然后应用来自管道定义或拟合的参数化。
列表 14.27 添加用于写入/读取转换器的两个 Mixin
from pyspark.ml.util import DefaultParamsReadable, DefaultParamsWritable
class ScalarNAFiller(
Transformer,
HasInputCol,
HasOutputCol,
HasInputCols,
HasOutputCols,
DefaultParamsReadable,
DefaultParamsWritable,
):
# ... rest of the class here
class _ExtremeValueCapperParams(
HasInputCol, HasOutputCol, DefaultParamsWritable, DefaultParamsReadable
):
# ... rest of the class here
这就处理了序列化。那么,如何读取包含自定义组件的管道呢?当 PySpark 读取一个序列化的管道时,将执行以下步骤:
-
创建一个具有默认参数化的管道外壳。
-
对于每个组件,应用序列化配置中的参数化。
在许多情况下,序列化环境与反序列化环境并不相同。例如,我们通常在一个强大的 Spark 集群上训练一个机器学习管道,并序列化已拟合的管道。然后,我们可以根据需要,在不同的(更弱或更昂贵)设置上进行预测。在这些场景中,你需要为反序列化的 Spark 环境提供一个指示,告诉它在哪里可以找到实现转换器和评估器的类。PySpark 包含的类将无需显式导入即可找到,但任何自定义的类都需要显式导入。
列表 14.28 从磁盘读取序列化的管道
from pyspark.ml.pipeline import PipelineModel
from .custom_feature import ScalarNAFiller, ExtremeValueCapperModel
food_pipeline_model.save("code/food_pipeline.model")
food_pipeline_model = PipelineModel.read().load("code/food_pipeline.model")
在本节中,我们回顾了使用自定义转换器和估计器的实际步骤。通过采取一些预防措施,例如确保继承适当的 Mixins 和导入必要的自定义类,我们可以确保我们的 Pipeline 是可移植的,因此可以在多个 Spark 环境中使用。
摘要
-
在转换器和估计器后面,PySpark 有 Param/Params 的概念,这是自我文档化的属性,它决定了转换器或估计器的行为方式。
-
当创建自定义转换器/估计器时,我们首先创建它们的 Param,然后在使用
transform()-/fit()-样实例属性时使用它们。PySpark 在pyspark.ml.param.shared模块中提供了标准 Param,用于频繁使用的情况。 -
对于常用 Param 或功能,例如写入和读取,PySpark 提供了 Mixins,这些是包含特定方法的类,用于简化转换器和估计器的样板代码。
-
当反序列化包含自定义阶段的 Pipeline 时,你需要确保程序命名空间内导入了底层类。
结论:有数据,我就开心!
这就结束了我们对 PySpark 数据分析生态系统的概述。我希望通过我们提出的不同用例和问题,你能够对 Spark 数据模型和操作引擎有所欣赏。在本书的开头,我把每个数据工作总结为类似于一个摄取、转换和导出的过程。在整个本书中,我们做了以下几件事:
-
从文本(第二章)到 CSV(第四章)到 JSON(第六章)再到 parquet(第十章)摄取了各种数据源
-
使用类似 SQL 的数据操作框架(第四章和第五章)转换数据,甚至求助于实际的 SQL 代码(第七章)。我们还使用了 Python 和 pandas 代码(第八章和第九章)来结合 Python 的强大功能和 Spark 的可扩展性。
-
学习了 Spark 数据类型、模式以及如何使用数据框构建多维数据模型(第六章)。
-
将数据框模型颠倒过来,深入到底层的 RDD,从而完全控制分布式数据模型。我们通过 RDD 与数据框之间的对比,理解了复杂性、性能和灵活性之间的权衡(第八章)。
-
通过 Spark UI 分析了 Spark 处理数据和管理计算及内存资源的方式(第十一章)。
-
为机器学习准备数据(第十二章),构建可重复的 ML 实验 Pipeline(第十三章),并创建自定义组件以实现更灵活和强大的 Pipeline(本章)。
虽然 PySpark 是一个不断发展的目标,但我希望本书中的信息将使您今天(和明天)使用 PySpark 变得更加容易、更高效、更有趣。随着数据增长速度超过我们的硬件,我相信分布式处理有更多的价值可以提供。
感谢您给我机会陪伴您走过这段旅程。我期待着了解您使用 Python 和 PySpark 从数据中获得的见解。
¹ 理论上,它由 Params 类提供,Transformer 和 Estimator 都继承自该类。
附录 A. 练习解答
本附录包含书中提出的练习的解决方案。如果你还没有解决它们,我鼓励你去做。阅读 API 文档和在其他章节中搜索是公平的游戏,但仅仅阅读答案不会有任何好处!
除非指定,否则每个代码块都假设以下内容:
from pyspark.sql import SparkSession
import pyspark.sql.functions as F
import pyspark.sql.types as T
spark = SparkSession.builder.getOrCreate()
第二章
练习 2.1
十一条记录。explode() 为每个展开列的每个数组元素生成一个记录。numbers 列包含两个数组,一个有五个元素,一个有六个:5 + 6 = 11。
from pyspark.sql.functions import col, explode
exo_2_1_df = spark.createDataFrame(
[
[[1, 2, 3, 4, 5]],
[[5, 6, 7, 8, 9, 10]]
],
["numbers"]
)
solution_2_1_df = exo_2_1_df.select(explode(col("numbers")))
print(f"solution_2_1_df contains {solution_2_1_df.count()} records.")
# => solution_2_1_df contains 11 records.
solution_2_1_df.show()
# +---+
# |col|
# +---+
# | 1|
# | 2|
# | 3|
# | 4|
# | 5|
# | 5|
# | 6|
# | 7|
# | 8|
# | 9|
# | 10|
# +---+
练习 2.2
使用列表推导(参见附录 C),我们可以遍历数据框中的每个 dtypes。因为 dtypes 是一个元组列表,我们可以解构为 x, y,其中 x 映射到列名,y 映射到类型。我们只需要保留 y != "string" 的那些。
print(len([x for x, y in exo2_2_df.dtypes if y != "string"])) # => 1
练习 2.3
我们可以直接在结果列上使用 alias(),而不是通过函数应用创建列然后重命名它。
exo2_3_df = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(length(col("value")).alias("number_of_char"))
)
练习 2.4
在第一个 select() 语句之后,数据框只剩下一列:maximum_value。然后我们尝试选择 key 和 max_value,这失败了。
练习 2.5
a)
我们只需使用 filter() 或 where() 过滤我们的列,以保留不等于 "is" 的 word。
words_without_is = words_nonull.where(col("word") != "is")
b)
from pyspark.sql.functions import length
words_more_than_3_char = words_nonull.where(length(col("word")) > 3)
练习 2.6
记住 PySpark 中的否定符号是 ~。
words_no_is_not_the_if = (
words_nonull.where(~col("word").isin(
["no", "is", "the", "if"])))
练习 2.7
通过将 book.printSchema() 赋值给我们的 book 变量,我们失去了数据框:printSchema() 返回 None,我们将其赋值给 book。NoneType 没有提供 select() 方法。
第三章
练习 3.1
答案:b
(a) 缺少 "length" 别名,因此 groupby() 子句将不起作用。(c) 按一个不存在的列进行分组。
from pyspark.sql.functions import col, length
words_nonull.select(length(col("word")).alias("length")).groupby(
"length"
).count().show(5)
# +------+-----+
# |length|count|
# +------+-----+
# | 12| 815|
# | 1| 3750|
# | 13| 399|
# | 6| 9121|
# | 16| 5|
# +------+-----+
# only showing top 5 rows
练习 3.2
PySpark 在操作过程中不一定保留顺序。在这种情况下,我们做以下操作:
-
按列
count排序。 -
按每个(唯一)单词的长度分组。
-
再次使用
count,生成一个新的count列(与 1 中的不同)。
在 1 中按列 count 排序的 count 列不再存在。
练习 3.3
1)
results = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']*", 0).alias("word"))
.where(F.col("word") != "")
.groupby(F.col("word"))
.count()
.count() ❶
)
print(results) # => 6595
❶ 通过 groupby()/count(),数据框每个单词只有一条记录。再次计数将给出记录数,或不同单词的数量。
或者,我们可以移除 groupby()/count() 并用 distinct() 替换它,这将只保留不同的记录。
results = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']*", 0).alias("word"))
.where(F.col("word") != "")
.distinct() ❶
.count()
)
print(results) # => 6595
❶ distinct() 去除了对 groupby()/count() 的需求。
2)
def num_of_distinct_words(file):
return (
spark.read.text(file)
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(
F.regexp_extract(F.col("word"), "[a-z']*", 0).alias("word")
)
.where(F.col("word") != "")
.distinct()
.count()
)
print(num_of_distinct_words("./data/gutenberg_books/1342-0.txt")) # => 6595
练习 3.4
在 groupby()/count() 之后,我们可以像使用任何其他列一样使用 count 列。在这种情况下,我们过滤 count 值,只保留 1。
results = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']*", 0).alias("word"))
.where(F.col("word") != "")
.groupby(F.col("word"))
.count()
.where(F.col("count") == 1) ❶
)
results.show(5)
# +------------+-----+
# | word|count|
# +------------+-----+
# | imitation| 1|
# | solaced| 1|
# |premeditated| 1|
# | elevate| 1|
# | destitute| 1|
# +------------+-----+
# only showing top 5 rows
❶ 我们只保留计数值为 1 的记录。
练习 3.5
假设 results 可用(来自 words_count_submit.py):
results = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']*", 0).alias("word"))
.where(F.col("word") != "")
.groupby(F.col("word"))
.count()
)
results.withColumn(
"first_letter", F.substring(F.col("word"), 1, 1)
).groupby(F.col("first_letter")).sum().orderBy(
"sum(count)", ascending=False
).show(
5
)
# +------------+----------+
# |first_letter|sum(count)|
# +------------+----------+
# | t| 16101|
# | a| 13684|
# | h| 10419|
# | w| 9091|
# | s| 8791|
# +------------+----------+
# only showing top 5 rows
results.withColumn(
"first_letter_vowel",
F.substring(F.col("word"), 1, 1).isin(["a", "e", "i", "o", "u"]),
).groupby(F.col("first_letter_vowel")).sum().show()
# +------------------+----------+
# |first_letter_vowel|sum(count)|
# +------------------+----------+
# | true| 33522|
# | false| 88653|
# +------------------+----------+
练习 3.6
在使用 groupby()/count() 之后,我们得到一个数据框。DataFrame 对象没有 sum() 方法(有关 GroupedData 对象的更广泛介绍,请参阅第五章)。
第四章
练习 4.1
sample = spark.read.csv("sample.csv",
sep=",",
header=True",
quote="$",
inferSchema=True)
解释:
-
sample.csv是我们想要摄取的文件名。 -
记录分隔符是逗号。由于我们被要求提供一个值,我明确传递逗号字符
,,知道它是默认的。 -
该文件有一个标题行,所以我输入
header=True。 -
引用字符是美元符号
$,所以我将其作为参数传递给quote。 -
最后,由于推断模式很有用,我传递
True给inferSchema。
练习 4.2
c
解释:item 和 UPC 作为列匹配,而 prices 不匹配。PySpark 将忽略传递给 drop() 的不存在列。
练习 4.3
DIRECTORY = "./data/broadcast_logs"
logs_raw = spark.read.csv(os.path.join(
DIRECTORY, "BroadcastLogs_2018_Q3_M8.CSV"),)
logs_raw.printSchema()
# root
# |-- _c0: string (nullable = true)
logs_raw.show(5, truncate=50)
# +--------------------------------------------------+
# | _c0|
# +--------------------------------------------------+
# |BroadcastLogID|LogServiceID|LogDate|SequenceNO|...|
# |1196192316|3157|2018-08-01|1|4||13|3|3|||10|19|...|
# |1196192317|3157|2018-08-01|2||||1|||||20|||00:0...|
# |1196192318|3157|2018-08-01|3||||1|||||3|||00:00...|
# |1196192319|3157|2018-08-01|4||||1|||||3|||00:00...|
# +--------------------------------------------------+
# only showing top 5 rows
两个主要区别:
-
PySpark 将所有内容放入单个字符串列中,因为它在记录中一致地没有遇到默认分隔符 (
,). -
它将记录命名为
_c0,这是当没有关于列名称信息时的默认约定。
练习 4.4
logs_clean = logs.select(*[x for x in logs.columns if not x.endswith("ID")])
logs_clean.printSchema()
# root
# |-- LogDate: timestamp (nullable = true)
# |-- SequenceNO: integer (nullable = true)
# |-- Duration: string (nullable = true)
# |-- EndTime: string (nullable = true)
# |-- LogEntryDate: timestamp (nullable = true)
# |-- ProductionNO: string (nullable = true)
# |-- ProgramTitle: string (nullable = true)
# |-- StartTime: string (nullable = true)
# |-- Subtitle: string (nullable = true)
# |-- Producer1: string (nullable = true)
# |-- Producer2: string (nullable = true)
# |-- Language1: integer (nullable = true)
# |-- Language2: integer (nullable = true)
解释:我在数据帧的列上使用列表推导技巧,使用过滤子句 if not x.endswith("ID") 来保留不以“ID”结尾的列。
第五章
练习 5.1
这是一个一与二之间的左连接。
解释:left_semi 连接只保留左边的记录,其中 my_column 的值也在右边的 my_column 列中。left_anti 连接是相反的:它将保留不存在的记录。将这些结果与原始数据帧 left 联合。
练习 5.2
c: inner
练习 5.3
b: right
练习 5.4
left.join(right, how="left",
on=left["my_column"] == right["my_column"]).where(
right["my_column"].isnull()
).select(left["my_column"]).
解释:在进行内部连接时,左数据帧的所有记录都保留在连接的数据帧中。如果谓词失败,则右表中的列值对于受影响的记录都设置为 null。我们只需过滤以保留不匹配的记录,然后选择 left["my_"] 列。
练习 5.5
首先,我们需要读取 Call_Signs.csv 文件。由于分隔符是逗号,我们可以保留读取器的默认参数化,除了 header=True。然后我们看到两个表共享 LogIdentifierID,我们可以通过它进行等值连接。
import pyspark.sql.functions as F
call_signs = spark.read.csv(
"data/broadcast_logs/Call_Signs.csv", header=True
).drop("UndertakingNo")
answer.printSchema()
# root
# |-- LogIdentifierID: string (nullable = true) ❶
# |-- duration_commercial: long (nullable = true)
# |-- duration_total: long (nullable = true)
# |-- commercial_ratio: double (nullable = false)
call_signs.printSchema()
# root
# |-- LogIdentifierID: string (nullable = true) ❶
# |-- Undertaking_Name: string (nullable = true)
exo5_5_df = answer.join(call_signs, on="LogIdentifierID")
exo5_5_df.show(10)
# +---------------+-------------------+--------------+--------------------+--------------------+
# |LogIdentifierID|duration_commercial|duration_total| commercial_ratio| Undertaking_Name|
# +---------------+-------------------+--------------+--------------------+--------------------+
# | CJCO| 538455| 3281593| 0.16408341924181336|Rogers Media Inc....|
# | BRAVO| 701000| 3383060| 0.2072088582525879| Bravo!|
# | CFTF| 665| 45780| 0.01452599388379205|Télévision MBS in...|
# | CKCS| 314774| 3005153| 0.10474475010091|Crossroads Televi...|
# | CJNT| 796196| 3470359| 0.22942756066447303|Rogers Media Inc....|
# | CKES| 303945| 2994495| 0.1015012548025627|Crossroads Televi...|
# | CHBX| 919866| 3316728| 0.27734140393785683|Bell Media Inc., ...|
# | CASA| 696398| 3374798| 0.20635249872733125|Casa - (formerly ...|
# | BOOK| 607620| 3292170| 0.18456519560047022|Book Television (...|
# | MOVIEP| 107888| 2678400|0.040280764635603344|STARZ (formerly T...|
# +---------------+-------------------+--------------+--------------------+--------------------+
# only showing top 10 rows
❶ 我们可以在这些列上执行等值连接。
练习 5.6
我们可以重用相同的管道来生成我们的最终答案,稍微改变 when() 子句以从“纯” (1.0) 广告中移除 "PRC"。然后我们再链式添加一个 when() 来处理 "PRC" 的不同处理方式。
PRC_vs_Commercial = ( ❶
F.when(
F.trim(F.col("ProgramClassCD")).isin(
["COM", "PGI", "PRO", "LOC", "SPO", "MER", "SOL"]
),
F.col("duration_seconds"),
)
.when( ❷
F.trim(F.col("ProgramClassCD")) == "PRC",
F.col("duration_seconds") * 0.75,
)
.otherwise(0)
)
exo5_6_df = (
full_log.groupby("LogIdentifierID")
.agg(
F.sum(PRC_vs_Commercial).alias("duration_commercial"),
F.sum("duration_seconds").alias("duration_total"),
)
.withColumn(
"commercial_ratio",
F.col("duration_commercial") / F.col("duration_total"),
)
)
exo5_6_df.orderBy("commercial_ratio", ascending=False).show(5, False)
❶ 我们将 when() 子句分开到单独的变量中以提高清晰度(可选)。
❷ 这是 "PRC" 的第二个 when() 子句。
练习 5.7
我们可以直接在 groupby() 子句中创建我们的 round() 谓词,并确保为我们的新列 alias。
exo5_7_df = (
answer
.groupby(F.round(F.col("commercial_ratio"), 1).alias("commercial_ratio"))
.agg(F.count("*").alias("number_of_channels"))
)
exo5_7_df.orderBy("commercial_ratio", ascending=False).show()
# +----------------+------------------+
# |commercial_ratio|number_of_channels|
# +----------------+------------------+
# | 1.0| 24|
# | 0.9| 4|
# | 0.8| 1|
# | 0.7| 1|
# | 0.5| 1|
# | 0.4| 5|
# | 0.3| 45|
# | 0.2| 141|
# | 0.1| 64|
# | 0.0| 38|
# +----------------+------------------+
第六章
练习 6.1
对于这个解决方案,我创建了一个 JSON 文档的字典副本,然后使用json.dump函数将其导出。因为spark.read.json只能读取文件,所以我们使用一个巧妙的方法,创建一个可以由我们的spark.read.json读取的 RDD(有关更多信息,请参阅第八章mng.bz/g41E)。
import json
import pprint
exo6_1_json = {
"name": "Sample name",
"keywords": ["PySpark", "Python", "Data"],
}
exo6_1_json = json.dumps(exo6_1_json)
pprint.pprint(exo6_1_json)
# '{"name": "Sample name", "keywords": ["PySpark", "Python", "Data"]}'
sol6_1 = spark.read.json(spark.sparkContext.parallelize([exo6_1_json]))
sol6_1.printSchema()
# root
# |-- keywords: array (nullable = true)
# | |-- element: string (containsNull = true)
# |-- name: string (nullable = true)
练习 6.2
尽管我们在列表/数组中的keywords有多个,但 PySpark 会默认选择最低公倍数并创建一个strings数组。答案与练习 6.1 相同。
import json
import pprint
exo6_2_json = {
"name": "Sample name",
"keywords": ["PySpark", 3.2, "Data"],
}
exo6_2_json = json.dumps(exo6_2_json)
pprint.pprint(exo6_2_json)
# '{"name": "Sample name", "keywords": ["PySpark", 3.2, "Data"]}'
sol6_2 = spark.read.json(spark.sparkContext.parallelize([exo6_2_json]))
sol6_2.printSchema()
# root
# |-- keywords: array (nullable = true)
# | |-- element: string (containsNull = true)
# |-- name: string (nullable = true)
sol6_2.show()
# +--------------------+-----------+
# | keywords| name|
# +--------------------+-----------+
# |[PySpark, 3.2, Data]|Sample name|
# +--------------------+-----------+
练习 6.3
StructType()将接受一个StructField()列表,而不是直接接受类型。我们需要将T.StringType()、T.LongType()和T.LongType()包装在一个StructField()中,并给它们一个合适的名称。
练习 6.4
为了说明问题,让我们在一个已经包含info结构体,其中包含status字段的DataFrame中创建一个名为info.status的列。通过创建info.status,该列变得不可访问,因为 Spark 默认从info结构体列中选取status。
struct_ex = shows.select(
F.struct(
F.col("status"), F.col("weight"), F.lit(True).alias("has_watched")
).alias("info")
)
struct_ex.printSchema()
# root
# |-- info: struct (nullable = false)
# | |-- status: string (nullable = true)
# | |-- weight: long (nullable = true)
# | |-- has_watched: boolean (nullable = false)
struct_ex.show()
# +-----------------+
# | info|
# +-----------------+
# |{Ended, 96, true}|
# +-----------------+
struct_ex.select("info.status").show()
# +------+
# |status|
# +------+
# | Ended|
# +------+
struct_ex.withColumn("info.status", F.lit("Wrong")).show()
# +-----------------+-----------+
# | info|info.status|
# +-----------------+-----------+
# |{Ended, 96, true}| Wrong|
# +-----------------+-----------+
struct_ex.withColumn("info.status", F.lit("Wrong")).select(
"info.status"
).show()
# +------+
# |status|
# +------+
# | Ended| ❶
# +------+
❶ 结束,非错误
练习 6.5
import pyspark.sql.types as T
sol6_5 = T.StructType(
[
T.StructField("one", T.LongType()),
T.StructField("two", T.ArrayType(T.LongType())),
]
)
练习 6.6
根据你的 Spark 版本,间隔可能显示不同。当选择类型为array的struct类型的列的一个元素时,你将得到一个不需要explode的元素数组。然后我们可以使用array_min()和array_max()来计算第一个和最后一个airdate。
sol6_6 = three_shows.select(
"name",
F.array_min("_embedded.episodes.airdate").cast("date").alias("first"),
F.array_max("_embedded.episodes.airdate").cast("date").alias("last"),
).select("name", (F.col("last") - F.col("first")).alias("tenure"))
sol6_6.show(truncate=50)
练习 6.7
sol6_7 = shows.select(
"_embedded.episodes.name", "_embedded.episodes.airdate"
)
sol6_7.show()
# +--------------------+--------------------+
# | name| airdate|
# +--------------------+--------------------+
# |[Minimum Viable P...|[2014-04-06, 2014...|
# +--------------------+--------------------+
练习 6.8
sol6_8 = (
exo6_8.groupby()
.agg(
F.collect_list("one").alias("one"),
F.collect_list("square").alias("square"),
)
.select(F.map_from_arrays("one", "square"))
)
# sol6_8.show(truncate=50)
# +----------------------------+
# |map_from_arrays(one, square)|
# +----------------------------+
# | {1 -> 2, 2 -> 4, 3 -> 9}|
# +----------------------------+
第七章
练习 7.1
b
注意,d 也可以工作,但它返回一个整数值,而不是像示例中那样的数据帧。
elements.where(F.col("Radioactive").isNotNull()).groupby().count().show()
# +-----+
# |count|
# +-----+
# | 37|
# +-----+
练习 7.2
观察到failures表,我们可以看到我们count了failure等于1的记录。当使用布尔值(True/False)作为整数(1/0)工作时,一个有用的技巧是我们可以将过滤和计数子句组合成一个sum操作(所有记录等于 1 的计数与所有记录的总和相同)。使用sum操作也消除了使用fillna对左连接值的需求,因为我们没有过滤任何记录。正因为如此,代码得到了极大的简化。
sol7_2 = (
full_data.groupby("model", "capacity_GB").agg(
F.sum("failure").alias("failures"),
F.count("*").alias("drive_days"),
)
).selectExpr("model", "capacity_GB", "failures / drive_days failure_rate")
sol7_2.show(10)
# +--------------------+--------------------+--------------------+
# | model| capacity_GB| failure_rate|
# +--------------------+--------------------+--------------------+
# | ST12000NM0117| 11176.0|0.006934812760055479|
# | WDC WD5000LPCX| 465.7617416381836|1.013736124486796...|
# | ST6000DX000|-9.31322574615478...| 0.0|
# | ST6000DM004| 5589.02986907959| 0.0|
# | WDC WD2500AAJS| 232.88591766357422| 0.0|
# | ST4000DM005| 3726.023277282715| 0.0|
# |HGST HMS5C4040BLE641| 3726.023277282715| 0.0|
# | ST500LM012 HN| 465.7617416381836|2.290804285402249...|
# | ST12000NM0008| 11176.0|3.112598241381993...|
# |HGST HUH721010ALE600|-9.31322574615478...| 0.0|
# +--------------------+--------------------+--------------------+
# only showing top 10 rows
练习 7.3
这个问题需要更多的思考。当我们查看每个驱动器模型的可靠性时,我们可以使用驱动天数作为单位,并计算失败与驱动天数之间的比较。现在我们需要计算每个驱动器的年龄。我们可以将这个函数分解成几个组件:
-
为每个驱动器创建一个死亡日期。
-
计算每个驱动器的年龄。
-
按车型分组;获取平均年龄。
-
返回平均年龄的驱动器数量。
我提供了从原始数据帧data中的代码。
common_columns = list(
reduce(
lambda x, y: x.intersection(y), [set(df.columns) for df in data]
)
)
full_data = (
reduce(
lambda x, y: x.select(common_columns).union(
y.select(common_columns)
),
data,
)
.selectExpr(
"serial_number",
"model",
"capacity_bytes / pow(1024, 3) capacity_GB",
"date",
"failure",
)
.groupby("serial_number", "model", "capacity_GB")
.agg(
F.datediff(
F.max("date").cast("date"), F.min("date").cast("date")
).alias("age")
)
)
sol7_3 = full_data.groupby("model", "capacity_GB").agg(
F.avg("age").alias("avg_age")
)
sol7_3.orderBy("avg_age", ascending=False).show(10)
# +--------------------+-----------------+------------------+
# | model| capacity_GB| avg_age|
# +--------------------+-----------------+------------------+
# | ST1000LM024 HN|931.5133895874023| 364.0|
# |HGST HMS5C4040BLE641|3726.023277282715| 364.0|
# | ST8000DM002|7452.036460876465| 361.1777375201288|
# |Seagate BarraCuda...|465.7617416381836| 360.8888888888889|
# | ST10000NM0086| 9314.0| 357.7377450980392|
# | ST8000NM0055|7452.036460876465| 357.033857892227|
# | WDC WD5000BPKT|465.7617416381836| 355.3636363636364|
# |HGST HUS726040ALE610|3726.023277282715| 354.0689655172414|
# | WDC WD5000LPCX|465.7617416381836|352.42857142857144|
# |HGST HUH728080ALE600|7452.036460876465| 349.7186311787072|
# +--------------------+-----------------+------------------+
# only showing top 10 rows
练习 7.4
在 SQL 中,你可以使用extract(day from COLUMN)来从一个日期中获取天。这相当于dayofmonth()函数。
common_columns = list(
reduce(
lambda x, y: x.intersection(y), [set(df.columns) for df in data]
)
)
sol7_4 = (
reduce(
lambda x, y: x.select(common_columns).union(
y.select(common_columns)
),
data,
)
.selectExpr(
"cast(date as date) as date",
"capacity_bytes / pow(1024, 4) as capacity_TB",
)
.where("extract(day from date) = 1")
.groupby("date")
.sum("capacity_TB")
)
sol7_4.orderBy("date").show(10)
# +----------+-----------------+
# | date| sum(capacity_TB)|
# +----------+-----------------+
# |2019-01-01|732044.6322980449|
# |2019-02-01|745229.8319376707|
# |2019-03-01|760761.8200763315|
# |2019-04-01|784048.2895324379|
# |2019-05-01| 781405.457732901|
# |2019-06-01|834218.0686636567|
# |2019-07-01|833865.5910149883|
# |2019-08-01|846133.1006234661|
# |2019-09-01|858464.0372464955|
# |2019-10-01|884306.1266535893|
# +----------+-----------------+
# only showing top 10 rows
练习 7.5
为了解决这个问题,我们需要提取字节中最常见的容量,然后只保留每个容量(如果有多个,则保留两个)的最高记录。我们通过计算给定驱动模型的全部容量并只保留出现次数最多的一个来实现这一点(参见most_common_capacity和capacity_count)。随后,我们将最常见的容量与我们的原始数据合并。
common_columns = list(
reduce(
lambda x, y: x.intersection(y), [set(df.columns) for df in data]
)
)
data7_5 = reduce(
lambda x, y: x.select(common_columns).union(y.select(common_columns)),
data,
)
capacity_count = data7_5.groupby("model", "capacity_bytes").agg(
F.count("*").alias("capacity_occurence")
)
most_common_capacity = capacity_count.groupby("model").agg(
F.max("capacity_occurence").alias("most_common_capacity_occurence")
)
sol7_5 = most_common_capacity.join(
capacity_count,
(capacity_count["model"] == most_common_capacity["model"])
& (
capacity_count["capacity_occurence"]
== most_common_capacity["most_common_capacity_occurence"]
),
).select(most_common_capacity["model"], "capacity_bytes")
sol7_5.show(5)
# +--------------------+--------------+
# | model|capacity_bytes|
# +--------------------+--------------+
# | WDC WD5000LPVX| 500107862016|
# | ST12000NM0117|12000138625024|
# | TOSHIBA MD04ABA500V| 5000981078016|
# |HGST HUS726040ALE610| 4000787030016|
# |HGST HUH721212ALE600|12000138625024|
# +--------------------+--------------+
# only showing top 5 rows
full_data = data7_5.drop("capacity_bytes").join(sol7_5, "model")
第八章
练习 8.1
让我们创建一个简单的 RDD 来解决这个练习。
exo_rdd = spark.sparkContext.parallelize(list(range(100)))
from operator import add
sol8_1 = exo_rdd.map(lambda _: 1).reduce(add)
print(sol8_1) # => 100
说明:
我首先将每个元素映射到值 1,无论输入如何。lambda 函数中的_不绑定元素,因为我们不处理元素;我们只关心它是否存在。在map操作之后,我们有一个只包含值1的 RDD。我们可以使用reduce(sum)来获取所有1的总和,这给出了 RDD 中元素的数量。
练习 8.2
a
当谓词(作为参数传递的函数)返回一个假值时,过滤器将丢弃任何值。在 Python 中,0、None和空集合是假值。由于谓词返回值不变,0、None、[]和0.0是假值并被过滤掉,只留下[1]作为答案。
练习 8.3
由于 C 和 K 相同(减去一个常数),F 和 R 相同(减去另一个常数),我们可以减少我们函数的决策树。如果我们向from_temp和/或to_temp传递一个不是F、C、K或R的字符串值,我们返回None。
from typing import Optional
@F.udf(T.DoubleType())
def temp_to_temp(
value: float, from_temp: str, to_temp: str
) -> Optional[float]:
acceptable_values = ["F", "C", "R", "K"]
if (
to_temp not in acceptable_values
or from_temp not in acceptable_values
):
return None
def f_to_c(value):
return (value - 32.0) * 5.0 / 9.0
def c_to_f(value):
return value * 9.0 / 5.0 + 32.0
K_OVER_C = 273.15
R_OVER_F = 459.67
# We can reduce our decision tree by only converting from C and F
if from_temp == "K":
value -= K_OVER_C
from_temp = "C"
if from_temp == "R":
value -= R_OVER_F
from_temp = "F"
if from_temp == "C":
if to_temp == "C":
return value
if to_temp == "F":
return c_to_f(value)
if to_temp == "K":
return value + K_OVER_C
if to_temp == "R":
return c_to_f(value) + R_OVER_F
else: # from_temp == "F":
if to_temp == "C":
return f_to_c(value)
if to_temp == "F":
return value
if to_temp == "K":
return f_to_c(value) + K_OVER_C
if to_temp == "R":
return value + R_OVER_F
sol8_3 = gsod.select(
"stn",
"year",
"mo",
"da",
"temp",
temp_to_temp("temp", F.lit("F"), F.lit("K")),
)
sol8_3.show(5)
# +------+----+---+---+----+------------------------+
# | stn|year| mo| da|temp|temp_to_temp(temp, F, K)|
# +------+----+---+---+----+------------------------+
# |359250|2010| 03| 16|38.4| 276.7055555555555|
# |725745|2010| 08| 16|64.4| 291.15|
# |386130|2010| 01| 24|42.4| 278.92777777777775|
# |386130|2010| 03| 21|34.0| 274.26111111111106|
# |386130|2010| 09| 18|54.1| 285.42777777777775|
# +------+----+---+---+----+------------------------+
# only showing top 5 rows
练习 8.4
有三件事需要修复:
-
变量使用:我们始终使用
value而不是t和answer。 -
由于我们乘以
3.14159,我们的函数需要被注解为float→float而不是str→str。 -
我们将 UDF 的返回类型更改为
DoubleType()。
@F.udf(T.DoubleType())
def naive_udf(value: float) -> float:
return value * 3.14159
练习 8.5
@F.udf(SparkFrac)
def add_fractions(left: Frac, right: Frac) -> Optional[Frac]:
left_num, left_denom = left
right_num, right_denom = right
if left_denom and right_denom: # avoid division by zero
answer = Fraction(left_num, left_denom) + Fraction(right_num, right_denom)
return answer.numerator, answer.denominator
return None
test_frac.withColumn("sum_frac", add_fractions("reduced_fraction", "reduced_fraction")).show(5)
# +--------+----------------+--------+
# |fraction|reduced_fraction|sum_frac|
# +--------+----------------+--------+
# | [0, 1]| [0, 1]| [0, 1]|
# | [0, 2]| [0, 1]| [0, 1]|
# | [0, 3]| [0, 1]| [0, 1]|
# | [0, 4]| [0, 1]| [0, 1]|
# | [0, 5]| [0, 1]| [0, 1]|
# +--------+----------------+--------+
# only showing top 5 rows
练习 8.6
def py_reduce_fraction(frac: Frac) -> Optional[Frac]:
"""Reduce a fraction represented as a 2-tuple of integers."""
MAX_LONG = pow(2, 63) - 1
MIN_LONG = -pow(2, 63)
num, denom = frac
if not denom:
return None
left, right = Fraction(num, denom).as_integer_ratio()
if left > MAX_LONG or right > MAX_LONG or left < MIN_LONG or right < MIN_LONG:
return None
return left, right
我们不需要更改返回类型从Optional[Frac]:更新后的py_reduce_fraction的返回值仍然是Frac或None。
第九章
练习 9.1
WHICH_TYPE = T.IntegerType()
WHICH_SIGNATURE = pd.Series
练习 9.2
与第八章中相同的练习相比,我们需要返回一个pd.Series而不是标量值。这里的null值(如果我们传递一个不可接受的单位)是一个None的 Series。
def temp_to_temp(
value: pd.Series, from_temp: str, to_temp: str
) -> pd.Series:
acceptable_values = ["F", "C", "R", "K"]
if (
to_temp not in acceptable_values
or from_temp not in acceptable_values
):
return value.apply(lambda _: None)
def f_to_c(value):
return (value - 32.0) * 5.0 / 9.0
def c_to_f(value):
return value * 9.0 / 5.0 + 32.0
K_OVER_C = 273.15
R_OVER_F = 459.67
# We can reduce our decision tree by only converting from C and F
if from_temp == "K":
value -= K_OVER_C
from_temp = "C"
if from_temp == "R":
value -= R_OVER_F
from_temp = "F"
if from_temp == "C":
if to_temp == "C":
return value
if to_temp == "F":
return c_to_f(value)
if to_temp == "K":
return value + K_OVER_C
if to_temp == "R":
return c_to_f(value) + R_OVER_F
else: # from_temp == "F":
if to_temp == "C":
return f_to_c(value)
if to_temp == "F":
return value
if to_temp == "K":
return f_to_c(value) + K_OVER_C
if to_temp == "R":
return value + R_OVER_F
练习 9.3
输出相同。归一化过程不会根据温度的单位而改变。
def scale_temperature_C(temp_by_day: pd.DataFrame) -> pd.DataFrame:
"""Returns a simple normalization of the temperature for a site, in Celsius.
If the temperature is constant for the whole window, defaults to 0.5."""
def f_to_c(temp):
return (temp - 32.0) * 5.0 / 9.0
temp = f_to_c(temp_by_day.temp)
answer = temp_by_day[["stn", "year", "mo", "da", "temp"]]
if temp.min() == temp.max():
return answer.assign(temp_norm=0.5)
return answer.assign(
temp_norm=(temp - temp.min()) / (temp.max() - temp.min())
)
练习 9.4
由于我们定义函数的方式,我们的数据帧返回一个六列的数据帧,而我们期望只有四列。错误行是answer = temp_by_day[["stn", "year", "mo", "da", "temp"]],其中我们硬编码了列。
sol9_4 = gsod.groupby("year", "mo").applyInPandas(
scale_temperature_C,
schema=(
"year string, mo string, "
"temp double, temp_norm double"
),
)
try:
sol9_4.show(5, False)
except RuntimeError as err:
print(err)
# RuntimeError: Number of columns of the returned pandas.DataFrame doesn't match
# specified schema. Expected: 4 Actual: 6
练习 9.5
from sklearn.linear_model import LinearRegression
from typing import Sequence
@F.pandas_udf(T.ArrayType(T.DoubleType()))
def rate_of_change_temperature_ic(
day: pd.Series, temp: pd.Series
) -> Sequence[float]:
"""Returns the intercept and slope of the daily temperature for a given period of time."""
model = LinearRegression().fit(
X=day.astype(int).values.reshape(-1, 1), y=temp
)
return model.intercept_, model.coef_[0]
gsod.groupby("stn", "year", "mo").agg(
rate_of_change_temperature_ic("da", "temp").alias("sol9_5")
).show(5, truncate=50)
# +------+----+---+------------------------------------------+
# | stn|year| mo| sol9_5|
# +------+----+---+------------------------------------------+
# |008268|2010| 07| [135.79999999999973, -2.1999999999999877]|
# |008401|2011| 11| [67.51655172413793, -0.30429365962180205]|
# |008411|2014| 02| [82.69682539682537, -0.02662835249042155]|
# |008411|2015| 12| [84.03264367816091, -0.0476974416017797]|
# |008415|2016| 01|[82.10193548387099, -0.013225806451612926]|
# +------+----+---+------------------------------------------+
# only showing top 5 rows
第十章
练习 10.1
c
sol10_1 = Window.partitionBy("year", "mo", "da")
res10_1 = (
gsod.select(
"stn",
"year",
"mo",
"da",
"temp",
F.max("temp").over(sol10_1).alias("max_this_day"),
)
.where(F.col("temp") == F.col("max_this_day"))
.drop("temp")
)
res10_1.show(5)
# +------+----+---+---+------------+
# | stn|year| mo| da|max_this_day|
# +------+----+---+---+------------+
# |406370|2017| 08| 11| 108.3|
# |672614|2017| 12| 10| 93.8|
# |944500|2018| 01| 04| 99.2|
# |954920|2018| 01| 12| 98.9|
# |647530|2018| 10| 01| 100.4|
# +------+----+---+---+------------+
# only showing top 5 rows
练习 10.2
让我们创建一个包含 1,000 条记录(250 个不同的index值和value列),所有值都等于 2 的数据帧。
exo10_2 = spark.createDataFrame(
[[x // 4, 2] for x in range(1001)], ["index", "value"]
)
exo10_2.show()
# +-----+-----+
# |index|value|
# +-----+-----+
# | 0| 2|
# | 0| 2|
# | 0| 2|
# | 0| 2|
# | 1| 2|
# | 1| 2|
# | 1| 2|
# | 1| 2|
# | 2| 2|
# | 2| 2|
# | 2| 2|
# | 2| 2|
# | 3| 2|
# | 3| 2|
# | 3| 2|
# | 3| 2|
# | 4| 2|
# | 4| 2|
# | 4| 2|
# | 4| 2|
# +-----+-----+
# only showing top 20 rows
sol10_2 = Window.partitionBy("index").orderBy("value")
exo10_2.withColumn("10_2", F.ntile(3).over(sol10_2)).show(10)
# +-----+-----+----+
# |index|value|10_2|
# +-----+-----+----+
# | 26| 2| 1|
# | 26| 2| 1|
# | 26| 2| 2|
# | 26| 2| 3|
# | 29| 2| 1|
# | 29| 2| 1|
# | 29| 2| 2|
# | 29| 2| 3|
# | 65| 2| 1|
# | 65| 2| 1|
# +-----+-----+----+
# only showing top 10 rows
结果可能看起来反直觉,但根据我们的定义(如图 A.1 所示),我们可以看到 PySpark 通过尝试将每个分区窗口分成三个(尽可能均匀)的桶来正确地做了这件事。

图 A.1 使用所有相同values的三分区。我们遵循相同的行为:跨记录分割。
练习 10.3
rowsBetween()窗口分区包含五个记录。因为数据的前两个记录没有两个前置记录,所以我们看到第一个和第二个记录分别是3和4。
rangeBetween()窗口分区使用10值(始终相同)来计算窗口框架边界。结果是到处都是 1,000,001。
exo10_3 = spark.createDataFrame([[10] for x in range(1_000_001)], ["ord"])
exo10_3.select(
"ord",
F.count("ord")
.over(Window.partitionBy().orderBy("ord").rowsBetween(-2, 2))
.alias("row"),
F.count("ord")
.over(Window.partitionBy().orderBy("ord").rangeBetween(-2, 2))
.alias("range"),
).show(10)
# +---+---+-------+
# |ord|row| range|
# +---+---+-------+
# | 10| 3|1000001|
# | 10| 4|1000001|
# | 10| 5|1000001|
# | 10| 5|1000001|
# | 10| 5|1000001|
# | 10| 5|1000001|
# | 10| 5|1000001|
# | 10| 5|1000001|
# | 10| 5|1000001|
# | 10| 5|1000001|
# +---+---+-------+
# only showing top 10 rows
练习 10.4
我们有多个最高温度的记录:PySpark 将显示所有这些记录。
(
gsod.withColumn("max_temp", F.max("temp").over(each_year))
.where("temp = max_temp")
.select("year", "mo", "da", "stn", "temp")
.withColumn("avg_temp", F.avg("temp").over(each_year))
.orderBy("year", "stn")
.show()
)
# +----+---+---+------+-----+--------+
# |year| mo| da| stn| temp|avg_temp|
# +----+---+---+------+-----+--------+
# |2017| 07| 06|403770|110.0| 110.0|
# |2017| 07| 24|999999|110.0| 110.0|
# |2018| 06| 06|405860|110.0| 110.0|
# |2018| 07| 12|407036|110.0| 110.0|
# |2018| 07| 26|723805|110.0| 110.0|
# |2018| 07| 16|999999|110.0| 110.0|
# |2019| 07| 07|405870|110.0| 110.0|
# |2019| 07| 15|606030|110.0| 110.0|
# |2019| 08| 02|606450|110.0| 110.0|
# |2019| 07| 14|999999|110.0| 110.0|
# +----+---+---+------+-----+--------+
练习 10.5
虽然我们可以用多种方式解决这个问题,但在我看来最简单的方法是创建一个记录号(它将始终递增)来打破这种联系。
temp_per_month_asc = Window.partitionBy("mo").orderBy("count_temp")
temp_per_month_rnk = Window.partitionBy("mo").orderBy(
"count_temp", "row_tpm"
)
gsod_light.withColumn(
"row_tpm", F.row_number().over(temp_per_month_asc)
).withColumn("rank_tpm", F.rank().over(temp_per_month_rnk)).show()
# +------+----+---+---+----+----------+-------+--------+
# | stn|year| mo| da|temp|count_temp|row_tpm|rank_tpm|
# +------+----+---+---+----+----------+-------+--------+
# |949110|2019| 11| 23|54.9| 14| 1| 1|
# |996470|2018| 03| 12|55.6| 12| 1| 1| ❶
# |998166|2019| 03| 20|34.8| 12| 2| 2| ❶
# |998012|2017| 03| 02|31.4| 24| 3| 3|
# |041680|2019| 02| 19|16.1| 15| 1| 1|
# |076470|2018| 06| 07|65.0| 24| 1| 1|
# |719200|2017| 10| 09|60.5| 11| 1| 1|
# |994979|2017| 12| 11|21.3| 21| 1| 1|
# |917350|2018| 04| 21|82.6| 9| 1| 1|
# |998252|2019| 04| 18|44.7| 11| 2| 2|
# +------+----+---+---+----+----------+-------+--------+
❶ 这些记录是 1 和 2。
练习 10.6
我们可以将日期转换为unix_timestamp(自 UNIX 纪元以来的秒数;见mng.bz/enPv)然后使用一个 7 天窗口(或 7 天×24 小时×60 分钟×60 秒)。
seven_days = (
Window.partitionBy("stn")
.orderBy("dtu")
.rangeBetween(-7 * 60 * 60 * 24, 7 * 60 * 60 * 24)
)
sol10_6 = (
gsod.select(
"stn",
(F.to_date(F.concat_ws("-", "year", "mo", "da"))).alias("dt"),
"temp",
)
.withColumn("dtu", F.unix_timestamp("dt").alias("dtu"))
.withColumn("max_temp", F.max("temp").over(seven_days))
.where("temp = max_temp")
.show(10)
)
# +------+----------+----+----------+--------+
# | stn| dt|temp| dtu|max_temp|
# +------+----------+----+----------+--------+
# |010875|2017-01-08|46.2|1483851600| 46.2|
# |010875|2017-01-19|48.0|1484802000| 48.0|
# |010875|2017-02-03|45.3|1486098000| 45.3|
# |010875|2017-02-20|45.7|1487566800| 45.7|
# |010875|2017-03-14|45.7|1489464000| 45.7|
# |010875|2017-04-01|46.8|1491019200| 46.8|
# |010875|2017-04-20|46.1|1492660800| 46.1|
# |010875|2017-05-02|50.5|1493697600| 50.5|
# |010875|2017-05-27|51.4|1495857600| 51.4|
# |010875|2017-06-06|53.6|1496721600| 53.6|
# +------+----------+----+----------+--------+
# only showing top 10 rows
练习 10.7
假设一年中总是有 12 个月,我们可以创建一个伪索引,num_mo,通过year * 12 + mo。有了这个,我们可以使用± 1 个月的精确范围。
one_month_before_and_after = (
Window.partitionBy("year").orderBy("num_mo").rangeBetween(-1, 1)
)
gsod_light_p.drop("dt", "dt_num").withColumn(
"num_mo", F.col("year").cast("int") * 12 + F.col("mo").cast("int")
).withColumn(
"avg_count", F.avg("count_temp").over(one_month_before_and_after)
).show()
# +------+----+---+---+----+----------+------+------------------+
# | stn|year| mo| da|temp|count_temp|num_mo| avg_count|
# +------+----+---+---+----+----------+------+------------------+
# |041680|2019| 02| 19|16.1| 15| 24230| 15.75|
# |998012|2019| 03| 02|31.4| 24| 24231|13.833333333333334|
# |996470|2019| 03| 12|55.6| 12| 24231|13.833333333333334|
# |998166|2019| 03| 20|34.8| 12| 24231|13.833333333333334|
# |917350|2019| 04| 21|82.6| 9| 24232| 13.6|
# |998252|2019| 04| 18|44.7| 11| 24232| 13.6|
# |076470|2019| 06| 07|65.0| 24| 24234| 24.0|
# |719200|2019| 10| 09|60.5| 11| 24238| 12.5|
# |949110|2019| 11| 23|54.9| 14| 24239|15.333333333333334|
# |994979|2019| 12| 11|21.3| 21| 24240| 17.5|
# +------+----+---+---+----+----------+------+------------------+
第十一章
练习 11.1
不,我们仍然只有一个任务(由showString()触发的程序)和两个阶段。
练习 11.2
第一个计划没有附加任何动作(show()),因此我们没有最后一个动作。
练习 11.3
a 和 b 是对单个记录的操作;它们是窄操作。其他操作需要洗牌数据以协调相关记录,因此是宽操作。c、d 和 e 需要在某一点上位于同一节点上的匹配键。
第十三章
练习 13.1
当E_max == E_min时,每个值都变为0.5 * (max + min)
附录 B. 安装 PySpark
本附录涵盖了在您的计算机上安装独立 Spark 和 PySpark 的过程,无论它是运行 Windows、macOS 还是 Linux。如果您想轻松利用 PySpark 的分布式特性,我还简要介绍了云服务。
拥有一个本地的 PySpark 集群意味着您将能够使用较小的数据集进行语法实验。在您准备好扩展程序之前,您不必购买多台计算机或花费云上托管 PySpark 的费用。一旦您准备好处理更大的数据集,您就可以轻松地将程序转移到 Spark 的云实例上以获得额外的动力。
B.1 在本地机器上安装 PySpark
本节涵盖了在您的计算机上安装 Spark 和 Python 的过程。Spark 是一个复杂的软件包,尽管安装过程很简单,但大多数指南都过于复杂。我们将通过安装最基本的部分来启动,并在此基础上构建。我们的目标是以下内容:
-
安装 Java(Spark 是用 Scala 编写的,它运行在 Java 虚拟机,或 JVM 上)。
-
安装 Spark。
-
安装 Python 3 和 IPython。
-
使用 IPython 启动 PySpark shell。
-
(可选)安装 Jupyter 并与 PySpark 一起使用。
在下一节中,我们将介绍 Windows、macOS 和 Linux 操作系统的安装说明。
注意:当在本地使用 Spark 时,您可能会收到一条21/10/26 17:49:14 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable的消息。您无需担心,这仅仅意味着您的系统上没有找到 Hadoop(它仅在*nix 平台上可用)。由于我们是在本地工作,这并不重要。
B.2 Windows
当在 Windows 上工作时,您可以选择直接在 Windows 上安装 Spark,或者使用 WSL(Windows Subsystem for Linux)。如果您想使用 WSL,请按照aka.ms/wslinstall上的说明操作,然后按照 GNU/Linux 的说明进行操作。如果您想在纯 Windows 上安装,请按照本节的其余部分进行操作。
B.2.1 安装 Java
在 Windows 上安装 Java 的最简单方法是访问adoptopenjdk.net,并按照下载和安装说明下载 Java 8 或 11。
警告:由于 Java 11 与某些第三方库不兼容,我建议继续使用 Java 8。Spark 3.0+也支持使用 Java 11+,但一些第三方库可能落后。
B.2.2 安装 7-zip
Spark 可以在 Spark 的网站上以 GZIP 存档文件(.tgz)的形式获得。默认情况下,Windows 不提供提取这些文件的原生方式。最流行的选项是 7-zip(www.7-zip.org/)。只需访问网站,下载程序,并按照安装说明进行操作。
B.2.3 下载并安装 Apache Spark
访问 Apache 网站 (spark.apache.org/) 并下载最新的 Spark 版本。接受默认选项,但图 B.1 显示了我导航到下载页面时看到的内容。如果你想要验证下载(页面上的第 4 步),请确保下载签名和校验和。

图 B.1 下载 Spark 的选项
下载完文件后,使用 7-zip 解压文件。我建议将目录放在 C:\Users\[YOUR_USER_NAME]\spark 下。
接下来,我们需要下载一个 winutils.exe 以防止一些神秘的 Hadoop 错误。前往 github.com/cdarlint/winutils 仓库,并在 hadoop-X.Y.Z/bin 目录下下载 winutils.exe 文件,其中 X.Y 与图 B.1 中选择的 Spark 版本所使用的 Hadoop 版本相匹配。请保留仓库的 README.md 文件。将 winutils.exe 放在你的 Spark 安装目录的 bin 目录中(C:\Users\[YOUR_USER-NAME\spark])。
接下来,我们需要设置两个环境变量,以提供我们的 shell 关于 Spark 所在位置的知识。将环境变量视为操作系统级别的变量,任何程序都可以使用;例如,PATH 指示了可执行文件的位置。在这里,我们设置 SPARK_HOME(Spark 可执行文件所在的主要目录),并将 SPARK_HOME 的值附加到 PATH 环境变量中。为此,打开开始菜单并搜索“编辑系统环境变量”。点击环境变量按钮(见图 B.2),然后添加它们。你还需要将 SPARK_HOME 设置为你的 Spark 安装目录(C:\Users\[YOUR-USER-NAME]\spark)。最后,将 %SPARK_HOME%\bin 目录添加到你的 PATH 环境变量中。

图 B.2 在 Windows 上设置 Hadoop 的环境变量
注意:对于 PATH 变量,你肯定已经有一些值在里面(类似于一个列表)。为了避免移除其他可能被其他程序使用的有用变量,双击 PATH 变量,并追加 %SPARK_HOME%\bin。
B.2.4 配置 Spark 以无缝与 Python 一起工作
如果你正在使用 Spark 3.0+ 与 Java 11+,你需要输入一些额外的配置才能无缝地与 Python 一起工作。为此,我们需要在 $SPARK_HOME/conf 目录下创建一个 spark-defaults.conf 文件。当到达这个目录时,应该已经有一个 spark-defaults.conf.template 文件在那里,还有一些其他文件。复制 spark-defaults.conf.template,并将其命名为 spark-defaults.conf。在这个文件中,包括以下内容:
spark.driver.extraJavaOptions="-Dio.netty.tryReflectionSetAccessible=true"
spark.executor.extraJavaOptions="-Dio.netty.tryReflectionSetAccessible=true"
这将防止当你尝试在 Spark 和 Python 之间传递数据时出现的讨厌的 java.lang.UnsupportedOperationException: sun.misc .Unsafe or java.nio.DirectByteBuffer.(long, int) not available 错误(从第八章开始)。
B.2.5 安装 Python
获取 Python 3 的最简单方法是使用 Anaconda 发行版。访问www.anaconda.com/distribution,并按照安装说明进行操作,确保您正在获取适用于您操作系统的 Python 3.0 及以上版本的 64 位图形安装程序。
一旦安装了 Anaconda,我们就可以通过在开始菜单中选择 Anaconda PowerShell Prompt 来激活 Python 3 环境。如果您想为 PySpark 创建一个专门的虚拟环境,请使用以下命令:
$ conda create -n pyspark python=3.8 pandas ipython pyspark=3.2.0
警告 Python 3.8+仅支持 Spark 3.0+。如果您使用 Spark 2.4.X 或更早版本,请确保在创建环境时指定 Python 3.7。
然后,要选择您新创建的环境,只需在 Anaconda 提示符中输入conda activate pyspark。
B.2.6 启动 IPython REPL 并启动 PySpark
如果您已配置了SPARK_HOME和PATH变量,您的 Python REPL 将能够访问 PySpark 的本地实例。按照下一个代码块中的说明启动 IPython:
小贴士 如果您不熟悉命令行或 PowerShell,我推荐 Don Jones 和 Jeffery D. Hicks 的《一个月午餐学 Windows PowerShell》(Manning, 2016)。
conda activate pyspark
ipython
然后,在 REPL 中,您可以导入 PySpark 并启动它:
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
注意 Spark 通过您 Spark 安装的bin目录提供了一个pyspark.cmd辅助命令。我在本地工作时更喜欢通过常规 Python REPL 访问 PySpark,因为这使我更容易安装库并确切知道我正在使用哪个 Python。它还与您最喜欢的编辑器很好地集成。
B.2.7(可选)安装并运行 Jupyter 以使用 Jupyter 笔记本
由于我们已经将 PySpark 配置为可以从常规 Python 进程导入,因此我们不需要进行任何其他配置即可使用它与笔记本一起使用。在您的 Anaconda PowerShell 窗口中,使用以下命令安装 Jupyter:
conda install -c conda-forge notebook
您现在可以使用以下命令运行 Jupyter 笔记本服务器。在这样做之前,请使用cd命令将目录移动到您的源代码所在的目录:
cd [WORKING DIRECTORY]
jupyter notebook
启动一个 Python 内核,并以您使用 IPython 相同的方式开始。
注意某些替代安装说明将为 Python 程序和 PySpark 程序创建一个单独的环境,您可能会看到多个内核选项。使用这组说明,使用Python 3内核。
B.3 macOS
在 macOS 上,最简单的方法——无疑是使用 Homebrew 的apache-spark软件包。它会处理所有依赖项(我仍然建议为了简单起见使用 Anaconda 来管理 Python 环境)。
B.3.1 安装 Homebrew
Homebrew 是 OS.X 的软件包管理器。它提供了一个简单的命令行界面来安装许多流行的软件包并保持它们更新。虽然您可以在 Windows OS 上找到的下载和安装步骤进行少量修改,但 Homebrew 会将安装过程简化为几个命令。
要安装 Homebrew,请访问brew.sh,并按照安装说明操作。你将通过brew命令与 Homebrew 交互。
苹果 M1:使用 Rosetta 还是不使用 Rosetta
如果你使用的是配备新苹果 M1 芯片的 Mac,你有选择使用 Rosetta(x64 指令的模拟器)运行。本节中的说明将适用。
如果你想要使用针对苹果 M1 优化的 JVM,我使用的是 Azul Zulu VM,你可以通过 Homebrew 下载它(github.com/mdogan/homebrew-zulu)。书中所有的代码都适用(比在同等配置的英特尔 Mac 上运行得更快,我敢这么说),除了 Spark BigQuery Connector,它在 ARM 平台上无法运行(见mng.bz/p298)。
B.3.2 安装 Java 和 Spark
在终端中输入以下命令:
$ brew install apache-spark
你可以指定你想要的版本;我建议不传递任何参数来获取最新版本。
如果 Homebrew 在安装 Spark 到你的机器时没有设置$SPARK_HOME(通过重启终端并输入echo $SPARK_HOME来测试),你需要在你的~/.zshrc中添加以下内容:
export SPARK_HOME="/usr/local/Cellar/apache-spark/X.Y.Z/libexec"
确保你输入的是正确的版本号,而不是X.Y.Z。
警告:Homebrew 将在安装新版本时立即更新 Spark。当你安装新包时,请注意apache-spark的“rogue”升级,并根据需要更改SPARK_HOME版本号。在编写这本书的时候,这发生在我身上好几次!
B.3.3 配置 Spark 以无缝与 Python 协同工作
如果你使用 Spark 3.0+与 Java 11+,你需要输入一些额外的配置才能无缝地与 Python 协同工作。为此,我们需要在$SPARK_HOME/conf目录下创建一个 spark-defaults.conf 文件。当你到达这个目录时,应该已经有一个 spark-defaults.conf.template 文件和一些其他文件。复制 spark-defaults.conf.template,并将其命名为 spark-defaults.conf。在这个文件中,包括以下内容:
spark.driver.extraJavaOptions="-Dio.netty.tryReflectionSetAccessible=true"
spark.executor.extraJavaOptions="-Dio.netty.tryReflectionSetAccessible=true"
这将防止在尝试在 Spark 和 Python 之间传递数据时出现的讨厌的java.lang.UnsupportedOperationException: sun.misc.Unsafe 或 java.nio.DirectByteBuffer.(long, int) not available 错误(从第八章开始)。
B.3.4 安装 Anaconda/Python
获取 Python 3 的最简单方法是使用 Anaconda 发行版。访问www.anaconda.com/distribution,按照安装说明操作,确保你获得的是适用于你的操作系统的 64 位图形安装程序,Python 3.0 及以上版本:
$ conda create -n pyspark python=3.8 pandas ipython pyspark=3.2.0
如果你第一次使用 Anaconda,请按照说明注册你的 shell。
警告:Python 3.8+仅支持使用 Spark 3.0。如果你使用 Spark 2.4.X 或更早版本,确保在创建环境时指定 Python 3.7。
然后,为了选择你新创建的环境,只需在终端中输入conda activate pyspark。
B.3.5 启动 IPython REPL 并开始 PySpark
Homebrew 应该已经设置了 SPARK_HOME 和 PATH 环境变量,因此您的 Python shell(也称为 REPL,或 read eval print loop)将能够访问 PySpark 的本地实例。您只需输入以下命令即可:
conda activate pyspark
ipython
然后,在 REPL 中,您可以导入 PySpark 并开始使用:
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
B.3.6(可选)安装并运行 Jupyter 以使用 Jupyter 笔记本
由于我们已经将 PySpark 配置为可以从常规 Python 进程中检测到,因此我们无需进行任何进一步配置即可在笔记本中使用它。在您的 Anaconda PowerShell 窗口中,使用以下命令安装 Jupyter:
conda install -c conda-forge notebook
您现在可以使用以下命令运行 Jupyter 笔记本服务器。在这样做之前,请使用 cd 命令移动到您的源代码所在的目录:
cd [WORKING DIRECTORY]
jupyter notebook
启动 Python 内核,并以与使用 IPython 相同的方式开始。
注意:某些替代安装说明将为 Python 程序和 PySpark 程序创建一个单独的环境,您可能会看到多个内核选项。使用本套件说明,请使用 Python 3 内核。
B.4 GNU/Linux 和 WSL
B.4.1 安装 Java
警告:由于 Java 11 与某些第三方库不兼容,我建议继续使用 Java 8。Spark 3.0 及以上版本可以使用 Java 11 及以上版本,但某些库可能落后。
大多数 GNU/Linux 发行版都提供软件包管理器。OpenJDK 版本 11 可通过软件仓库获得:
sudo apt-get install openjdk-8-jre
B.4.2 安装 Spark
访问 Apache 网站,下载最新的 Spark 版本。您通常不需要更改默认选项,但图 B.1 显示了我访问下载页面时看到的选项。如果您想验证下载(页面上的第 4 步),请确保下载签名和校验和。
提示:在 WSL(有时是 Linux)上,您没有图形用户界面可用。下载 Spark 最简单的方法是访问网站,遵循指示,复制最近镜像的链接,并使用 wget 命令传递:
wget [YOUR_PASTED_DOWNLOAD_URL]
如果您想了解更多关于在 Linux(和 Os.X)上熟练使用命令行的方法,一本好的免费参考资料是 William Shotts 的 The Linux Command Line (linuxcommand.org/)。它也以纸质或电子书的形式提供(No Starch Press,2019)。
下载文件后,请解压它。如果您正在使用命令行,以下命令将解决问题。请确保将 spark-[...].gz 替换为您刚刚下载的文件名:
tar xvzf spark-[...].gz
这将解压存档内容到目录中。现在您可以按需重命名和移动目录。我通常将其放在 /home/[MY-USER-NAME]/bin/spark-X.Y.Z/(如果名称不相同,则重命名),说明将使用该目录。
警告:请确保将 X.Y.Z 替换为适当的 Spark 版本。
设置以下环境变量:
echo 'export SPARK_HOME="$HOME/bin/spark-X.Y.Z"' >> ~/.bashrc
echo 'export PATH="$SPARK_HOME/bin/spark-X.Y.Z/bin:$PATH"' >> ~/.bashrc
B.4.3 配置 Spark 以无缝与 Python 一起工作
如果你正在使用 Spark 3.0 及以上版本与 Java 11 及以上版本,你需要输入一些额外的配置才能无缝地与 Python 一起工作。为此,我们需要在 $``SPARK_HOME/conf 目录下创建一个 spark-defaults.conf 文件。当你到达这个目录时,应该已经有一个 spark-defaults.conf.template 文件在那里,还有一些其他文件。复制 spark-defaults.conf.template,并将其命名为 spark-defaults.conf。在这个文件中,包括以下内容:
spark.driver.extraJavaOptions="-Dio.netty.tryReflectionSetAccessible=true"
spark.executor.extraJavaOptions="-Dio.netty.tryReflectionSetAccessible=true"
这将防止在尝试在 Spark 和 Python 之间传递数据时出现的讨厌的 java.lang.UnsupportedOperationException: sun.misc .Unsafe or java.nio.DirectByteBuffer.(long, int) not available 错误(从第八章开始)。
B.4.4 安装 Python 3、IPython 和 PySpark 包
Python 3 已经提供;你只需要安装 IPython。在终端中输入以下命令:
sudo apt-get install ipython3
提示:你还可以在 GNU/Linux 上使用 Anaconda。遵循 macOS 部分的说明。
然后,使用 pip 安装 PySpark。这将允许你在 Python REPL 中导入 PySpark:
pip3 install pyspark==X.Y.Z
B.4.5 使用 IPython 启动 PySpark
启动一个 IPython shell:
ipython3
然后,在 REPL 中,你可以导入 PySpark 并开始使用:
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
B.4.6(可选)安装和运行 Jupyter 以使用 Jupyter notebook
由于我们已经将 PySpark 配置为可以从常规 Python 进程中检测到,因此我们不需要进行任何进一步的配置就可以在笔记本中使用它。在你的终端中,输入以下内容来安装 Jupyter:
pip3 install notebook
你现在可以使用以下命令运行一个 Jupyter notebook 服务器。在这样做之前,使用 cd 命令移动到你的源代码所在的目录:
cd [WORKING DIRECTORY]
jupyter notebook
启动一个 Python 内核,并像使用 IPython 一样开始。
注意:某些替代安装说明将为 Python 程序和 PySpark 程序创建一个单独的环境,你可能会看到多个内核选项。使用这些说明,使用 Python 3 内核。
B.5 云中的 PySpark
我们用对在云中使用 PySpark 的主要选项的快速回顾来结束这个附录。有许多选项——太多以至于无法一一回顾——但我决定只限制在三个主要的云服务提供商(AWS、Azure、GCP)上。为了完整性,我还添加了一个关于 Databricks 的部分,因为它们是 Spark 的团队,并为所有三个主要云提供了出色的托管 Spark 选项。
云服务提供的内容非常具有动态性。在撰写这本书的过程中,每个提供商都调整了他们的 API,有时甚至是非常重大的调整。因此,我提供了直接链接到相关文章和知识库,这些是我用来在每个提供商上运行 Spark 的。对于大多数提供商,文档演变迅速,但概念保持不变。在核心上,它们都提供了 Spark 访问;差异在于它们提供的用于创建、管理和分析集群的用户界面。我建议,一旦你选择了你偏好的选项,就阅读一下文档,以了解特定提供商的一些独特之处。
注意许多云提供商提供了一些带有 Spark 的小型虚拟机供你测试。如果你无法在本地机器上安装 Spark(由于工作限制或其他原因),它们非常有用。在创建你的集群时,检查“单节点”选项。
与云 Spark 一起工作时的(小)差异
当与 Spark 集群一起工作时,尤其是在云上,我强烈建议你在创建集群时安装你希望使用的库(pandas、scikit-learn 等)。在运行中的集群上管理依赖项最多是令人烦恼的,而大多数情况下,你最好是销毁整个集群并创建一个新的。
每个云提供商都会提供如何创建启动动作以安装库的说明。如果你最终需要重复执行这项操作,请考虑自动化机会,例如 Ansible、Puppet、Terraform 等。在处理个人项目时,我通常创建一个简单的 shell 脚本。大多数云提供商都提供了一个 CLI 接口,以编程方式与其 API 交互。
B.6 AWS
亚马逊提供了两个带有 Spark 的产品:EMR(弹性 MapReduce)和 Glue。虽然它们相当不同,并且满足不同的需求,但我发现 Spark 在 EMR 上通常更新得更及时,如果你在熟悉的环境中运行零星的作业,价格也更优惠。
EMR 提供了一个完整的 Hadoop 环境,其中包括 Spark 在内的众多开源工具。文档可通过aws.amazon.com/emr/resources/获取。
Glue 被宣传为一种无服务器 ETL 服务,其中包括 Spark 作为工具的一部分。Glue 通过一些 AWS 特定的概念扩展了 Spark,例如DynamicFrame和GlueContext,这些功能非常强大,但只能在 Glue 本身中使用。文档可通过aws.amazon.com/glue/resources/获取。
B.7 Azure
Azure 通过 HDInsight 服务提供托管 Spark 服务。该产品的文档可通过docs.microsoft.com/en-us/azure/hdinsight/获取。微软确实对 Hadoop 集群上提供的不同产品进行了细分,所以请确保遵循 Spark 的说明。使用 Azure 时,我通常更喜欢使用 GUI:mng.bz/OGQR上的说明非常容易理解,并且对于探索大规模数据处理,Azure 会根据你构建的集群提供每小时定价。
Azure 还通过其 Linux 数据科学虚拟机提供单节点 Spark(文档可通过mng.bz/YgwB获取)。如果你不想麻烦设置环境,这是一个成本较低的选项。
B.8 GCP
Google 通过 Google Dataproc 提供托管 Spark 服务。文档可通过cloud.google.com/dataproc/docs获取。我在这本书的大部分“扩展”示例中使用了 GCP Dataproc,因为我发现命令行工具很容易学习,而且文档工作得很好。
当使用 Google Dataproc 学习 Spark 时,最简单的方法是使用 Google 提供的单节点集群选项。单节点集群的文档有点难找;它可在mng.bz/GGOv找到。
B.9 Databricks
Databricks 由 Apache Spark 的创建者于 2013 年创立。从那时起,他们围绕 Spark 建立了一个完整的生态系统,涵盖了数据仓库(Delta Lake)、MLOps 解决方案(MLFlow)甚至安全数据交换功能(Delta Share)。
提示:如果你只想在 Databricks 上以最少的麻烦开始,请查看 Databricks 社区版community.cloud.databricks.com/login.html。这为你提供了一个小集群以开始使用,无需预先安装。本节涵盖了使用完整的(付费)Databricks 实例,当你需要更多功能时。
Databricks 将其 Spark 发行版围绕Databricks Runtime构建,这是一个与特定 Spark 版本绑定的库集合(Python、Java、Scala、R)。它们的运行时提供几种不同的版本:
-
Databricks 运行时是标准选项,它为在 Databricks 上运行 Spark 提供了一个完整的生态系统。
docs.databricks.com/runtime/dbr.html。 -
Databricks 运行时为机器学习提供了一套精选的流行 ML 库(如 TensorFlow、PyTorch、Keras 和 XGBoost),这些库建立在标准选项之上。这个运行时确保你有一套协同工作的 ML 库。
docs.databricks.com/runtime/mlruntime.html。 -
Photon 是 C++ 中 Spark 查询引擎的新实现,它更快,但功能尚不完整。由于其性能提升(
docs.databricks.com/runtime/photon.html),它已经变得很有吸引力。
Databricks 根据每 DBU(Databricks 单位)对服务进行定价,这与“一个小时的标准化计算节点”类似。集群越强大(无论是通过增加节点还是通过使它们更强大),您消耗的 DBU 就越多,成本也就越高。您还需要考虑底层云资源(虚拟机、存储、网络等)的价格。这可能会使定价变得相当不透明;我通常使用定价估算器(Databricks 和云提供商的)来了解每小时的成本。
注意:请查看每个云提供商的页面以获取每 DBU 的价格。它们在不同提供商之间并不一致。
在本附录的其余部分,我将介绍在 Databricks 中设置、使用和销毁工作区的主要步骤。我使用 Google Cloud Platform,但这些一般步骤也适用于 Azure 和 AWS。您不会找到一个完整的 Databricks 管理指南,但这将为您提供一个运行和扩展本书示例的工作环境。
警告:一旦创建工作区,使用 Databricks 就会产生费用。使用强大的集群将花费很多钱。一旦完成,请务必关闭您的集群和工作区!
首先,要开始使用 Databricks,我们需要启用服务并创建一个工作区。为此,在搜索栏中搜索 Databricks 并激活试用。仔细阅读条款和条件以及使用该服务所需的权限。完成后,点击“在提供者上管理”按钮并使用您的 GCP 账户登录。您将到达一个类似于图 B.3 的屏幕,其中有一个空列表和一个创建工作区按钮。

图 B.3 Databricks 工作区的登录页面(此处使用 GCP)
要开始使用 Databricks,我们需要创建一个工作区,它作为集群、笔记本、管道等的总称。组织通常使用工作区作为逻辑分离(按团队、项目、环境等)。在我们的情况下,我们只需要一个;在图 B.4 中,我们看到创建新工作区的简单表单。如果您没有 Google Cloud 项目 ID,请转到 GCP 控制台主页并检查右上角的框:我的 ID 是 focus-archway-214221。

图 B.4 创建一个将存储我们的数据、笔记本和集群的工作区
工作区创建后,Databricks 将提供一个包含到达工作台的 URL 的页面;检查图 B.5 的右侧部分以查看以 gcp.databricks.com 结尾的唯一 URL。在此页面的右上角,请注意配置下拉菜单。一旦完成,我们将使用它来销毁工作区。

图 B.5 我们创建的新工作空间已准备好投入使用。点击右侧的唯一 URL 以访问工作台。
工作台是我们开始使用 Databricks 的地方。如果你在企业环境中工作,你可能有你的工作空间已经配置好了。你通过图 B.6 显示的屏幕开始使用 Databricks。在这个简单的例子中,我们限制自己使用 Databricks 以 Spark 为中心的功能:笔记本/代码、集群和数据。如本节开头所述,Databricks 包含一个完整的生态系统,用于 ML 实验、数据管理、数据共享、数据探索/商业智能和版本控制/库管理。随着你对一般工作流程的熟悉,请查阅那些附加组件的文档。

图 B.6 我们工作空间工作台的主页。从该主页,我们可以创建、访问和管理集群,以及运行作业和笔记本。
是时候启动一个集群了。点击“新建集群”(或侧边栏上的“集群”菜单)并填写集群配置的说明。如图 B.5 所示的菜单相当直观。如果你正在处理小型数据集,我建议选择单节点集群模式选项,这将模拟你在本地机器上的设置(驱动程序和工人在同一台机器上)。如果你想要实验一个更大的集群,请设置最小/最大工作员到适当的值。Databricks 将从最小值开始,根据需要自动扩展,直到达到最大值。
默认情况下,GCP 有相当严格的用量配额。当我开始使用 Databricks 时,我不得不申请增加两个额外的配额,以便我可以启动一个集群。我要求将SSD_TOTAL_GB设置为10000(10,000 GB 的 SSD 可用)以及相关区域的CPUS(us-east4;见图 B.2)设置为100(100 个 CPU)。如果你在创建集群时遇到集群被销毁的问题,请检查日志;很可能是你超出了配额。
对于大多数用例,默认配置(如图 B.7 所示,n1-highmem-4,具有 26 GB 的 RAM 和 4 个核心)已经足够。如果需要,例如,在进行大量连接操作时,你可以将机器升级为更强大的配置。对于 GCP,我发现高内存机器在性能成本方面提供了最佳平衡。记住,DBU 成本是除了GCP 将向您收取的虚拟机成本之外的。

图 B.7 创建一个包含一到两个工作节点的小型集群,每个节点包含 26 GB 的 RAM 和 4 个核心。每个节点成本为 0.87 DBU。
当集群正在创建时(这将需要几分钟),让我们上传数据以运行我们的程序。我选择了 Gutenberg 书籍,但任何数据都遵循相同的流程。在工作台首页点击“创建表”,选择“上传文件”,然后拖放你想要上传的文件。请注意 DBFS 目标目录(此处为 /FileStore/tables/gutenberg_books),我们在 PySpark 读取数据时需要引用它。

图 B.8 在 DBFS(Databricks 文件系统)中上传数据(此处为第二章和第三章的 Gutenberg 书籍)
一旦集群运行正常且数据在 DBFS 中,我们就可以创建一个笔记本开始编码。在工作台首页点击“创建笔记本”,并选择你的笔记本将要附加到的集群名称(如图 B.9 所示)。

图 B.9 在 SmallCluster 上创建笔记本以运行我们的分析
创建后,你会看到一个类似于图 B.10 的窗口。Databricks 笔记本看起来像 Jupyter 笔记本,具有不同的样式和一些附加功能。每个单元格可以包含 Python 或 SQL 代码,以及将被渲染的 markdown 文本。在执行单元格时,Databricks 将在执行过程中提供进度条,并给出每个单元格花费的时间信息。

图 B.10 我们的操作笔记本,已准备就绪!Databricks 笔记本看起来像 Jupyter 笔记本,增加了一些 Spark 特定的功能。
在 Databricks 中工作时有两点值得提及:
-
如果你将数据上传到 DBFS,你可以通过
dbfs:/[DATA-LOCATION]访问它。在图 B.8 中,我直接从图 B.6 中设置的数据位置获取值。与引用 URL(例如www.manning.com)不同,这里只有一个正斜杠。 -
Databricks 提供了一个方便的
display()函数,它替代了show()方法。display()默认以丰富的表格格式显示 1,000 行,你可以滚动浏览。在图 B.8 的第三单元格底部,你还可以看到创建图表或以多种格式下载数据的按钮。你还可以使用display()来显示使用流行库的视觉化(更多信息请见mng.bz/zQEB)。
提示:如果你想对显示的内容有更多控制,可以使用 displayHTML() 函数显示 HTML 代码。更多信息请见 mng.bz/0w1N。
一旦完成分析,你应该通过在工作台的集群页面中点击停止按钮来关闭你的集群。如果你不打算长时间(超过几个小时)使用 Spark/Databricks,并且使用的是个人订阅,我建议销毁工作区,因为 Databricks 会启动几个虚拟机来管理它。如果你想要将云支出降至零美元,也可以进入 GCP 的存储标签页并删除 Databricks 为托管数据和集群元数据创建的存储桶。
Databricks 提供了一种吸引人的方式在云端与 PySpark 交互。每个供应商在云中管理 Spark 的方法都不同,从接近硬件(GCP Dataproc、AWS EMR)到自动驾驶(AWS Glue)。从用户的角度来看,差异主要在于你预计需要配置多少环境(以及它的成本)。就像 Databricks 一样,一些额外的工具包被提供出来以简化代码和数据管理,或者提供优化代码以加快关键操作。幸运的是,Spark 是这些环境中的共同分母。你在本书中学到的知识应该适用于你喜欢的任何 Spark 版本。
附录 C. 一些有用的 Python 概念
Python 是一种既迷人又复杂的语言。虽然有很多初学者指南可以学习语言的基础,但关于 Python 的一些新或更复杂的功能讨论较少。本附录是非详尽的关于中级到高级 Python 概念的汇编,这些概念在处理 PySpark 时将非常有用。
C.1 列表解析
列表解析是 Python 的构造之一,一旦你理解了它,你就会想知道在没有使用它们的情况下是如何进行编码的。本质上,它们只是对列表的迭代。它们的强大之处在于它们的简洁性和可读性。我们从第四章开始使用它们,当时我们向 select() 和 drop() 等方法提供多个列,通常是为了选择/删除列的子集。
当与列表、元组和字典一起工作时,你经常会想对列表中的每个元素执行一个操作。为此,你可以使用 for 循环,就像列表 C.1 的前半部分那样,我在其中创建了一个要删除的数据框列的列表。这是完全有效的,尽管有点长,有五行代码。
我们还可以使用列表解析来替换列表创建和迭代。这样,我们可以避免无用的变量分配,就像列表 C.1 的后半部分那样。焦点也完全集中在 drop() 方法上,与循环方法相比,我们更关注创建列的子集。
列表 C.1 将函数应用于数据框的每一列
# Without a list comprehension
to_delete = []
for col in df.columns:
if "ID" in col:
to_delete.append(col)
df = f.drop(*to_delete)
# With a list comprehension
df = df.drop(*[col for col in df.columns if "ID" in col])
列表解析在考虑 PySpark 可以使用 Column 对象存储除主代码之外的计算时特别有用。例如,在列表 C.2 中,使用第九章末的 gsod 数据,我们可以计算 temp 和 temp_norm 的最大值,而无需键入所有内容。我们还使用了星号操作符进行参数解包。(有关此内容的更多详细信息,请参阅 C.2 节。)
列表 C.2 计算 temp 和 temp_norm 的最大值
maxes = [F.max(x) for x in ["temp", "temp_norm"]]
gsod_map.groupby("stn").agg(*maxes).show(5) ❶
# +------+---------+--------------+
# | stn|max(temp)|max(temp_norm)|
# +------+---------+--------------+
# |296450| 77.7| 1.0|
# |633320| 81.4| 1.0|
# |720375| 79.2| 1.0|
# |725165| 83.5| 1.0|
# |868770| 94.6| 1.0|
# +------+---------+--------------+
# only showing top 5 rows
❶ 星号前缀操作符解包列表(请参阅 C.2 节)。
从视觉上看,在图 C.1 中,我们可以设想新列表是如何从之前作为输入传递的列表(通过列表解析中的关键字 in)构建出来的。结果是新的列表,其中每个元素都来自输入列表,并通过开始处的函数进行处理。

图 C.1 使用简单的列表解析计算多个列的最大值
列表解析可以更加复杂。这里有一个包含两个输入列表和 if 子句以过滤结果的假设示例。
列表 C.3 更复杂的列表解析
print([x + y for x in [0, 1, 2] for y in [0, 1, 2] if x != y]) ❶
# => [1, 2, 1, 3, 2, 3]
❶ 包含多个列表进行迭代会产生输入列表的笛卡尔积。在这里,我们在过滤之前有九个元素。

图 C.2 一个更有雄心的列表推导。输入列表中元素的所有组合都在输出列表中,除非它们被 if 子句过滤掉。
C.2 打包和解包参数 (*args 和 **kwargs)
许多 Python 和 PySpark 函数和方法可以处理可变数量的参数。以 PySpark 为例,我们可以使用相同的 select() 方法选择一个、两个、三个等列:select() 方法。在底层,Python 使用参数打包和解包来允许这种灵活性。本节介绍了在 PySpark 操作上下文中的参数打包和解包。了解何时以及如何利用这些技术对于使你的代码更加健壮和简单大有裨益。
实际上,PySpark 中一些最常用的数据框方法,如 select()、groupby()、drop()、summary() 和 describe()(有关这些方法的更多内容,请参阅第四章和第五章),可以与任意数量的参数一起使用。通过查看文档,我们可以看到参数前面有一个 *,就像 drop() 一样。这就是我们如何识别一个方法/函数可以处理多个参数的方法:
drop(*cols)
如果你从未遇到过这种语法,它可能会让你感到有些困惑。另一方面,它在 PySpark 的上下文中非常有用,因此值得记住。
以 drop() 为例,假设我们有一个像列表 C.4 中的简单四列数据框。列名为 feat1、pred1、pred2 和 feat2;想象一下 feat 列是特征,而 pred 列是从机器学习模型得到的预测。在实践中,我们可能有一个任意数量的列需要删除,而不仅仅是两个机器学习模型。
列表 C.4 一个具有四列的简单数据框
sample = spark.createDataFrame(
[[1, 2, 3, 4], [2, 2, 3, 4], [3, 2, 3, 4]],
["feat1", "pred1", "pred2", "feat2"],
)
sample.show()
# +-----+-----+-----+-----+
# |feat1|pred1|pred2|feat2|
# +-----+-----+-----+-----+
# | 1| 2| 3| 4|
# | 2| 2| 3| 4|
# | 3| 2| 3| 4|
# +-----+-----+-----+-----+
最好的方法是什么来删除所有带有 pred 前缀的列?一个解决方案是直接将列名传递给 drop,例如 sample.drop("pred1", "pred2")。只要我们有两个以这种方式命名的列,这就会起作用。如果我们有 pred3 和 pred74 呢?
对于给定的数据框 sample,我们在第五章中看到,我们可以使用列表推导(有关此主题的更多信息,请参阅 C.1 节)通过 sample.columns 来处理列列表。有了这个,我们可以轻松地获取以 pred 开头的列。
列表 C.5 过滤 sample 数据框的列
to_delete = [c for c in sample.columns if str.startswith(c, "pred")]
print(to_delete) # => ['pred1', 'pred2']
如果我们尝试执行 sample.drop(to_delete) 操作,我们会得到一个 TypeError: col should be a string or a Column 消息。drop() 接受多个参数,每个参数要么是一个字符串,要么是一个 Column,而我们有一个字符串列表。输入 *args,也称为参数打包和解包。
前缀运算符 * 在两个方向上操作:当在函数中使用它时,它将参数解包,使其看起来像单独传递的。在函数定义中使用时,它将函数调用的所有参数打包成一个元组。
C.2.1 参数解包
让我们从解包开始,因为这是我们目前面临的情况。我们有一个字符串列表,我们需要从每个元素中提取出来,作为参数传递给drop()。在drop()中将to_delete前加上星号,就像在下一个列表中一样,就能做到这一点。
列表 C.6 使用参数解包操作符一次性删除多个列
sample.drop(*to_delete).printSchema()
# root
# |-- feat1: long (nullable = true)
# |-- feat2: long (nullable = true) ❶
❶ pred1 和 pred2 不再存在!
我喜欢将参数解包想象成一个语法性转换,其中星号“吞噬”元组或列表的容器,留下元素裸露。这最好通过视觉来理解。在图 C.3 中,我们看到双重性:将星号作为前缀添加到*to_delete中,相当于将列表的每个元素作为独立的参数传递。

图 C.3 在列表/元组参数前加上一个状态“解包”会将每个元素解包为一个独立的参数。
C.2.2 参数打包
现在已经介绍了解包,那么打包呢?为此,让我们创建一个简单的drop()实现。在第四章中,我们看到了drop与select()选择我们不希望删除的列是等价的。drop()需要接受可变数量的参数。下一个列表显示了简单实现。
列表 C.7 实现简单的drop()方法等价
def my_drop(df, *cols): ❶
return df.select(*[x for x in df.columns if x not in cols])
❶ 我们将每个参数(除了第一个,称为 df)打包到一个名为 cols 的元组中。
函数定义好后,我们可以看到 Python 如何将"pred1"和"pred2"参数打包到一个名为cols的元组中,以便在函数内部使用。同样,我们可以使用相同的*前缀来解包参数列表,如图 C.4 的右侧所示。

图 C.4 在函数定义中使用*args。第一个参数之后的每个参数都会被合并成一个名为cols的元组。
C.2.3 关键字参数的打包和解包
Python 还接受通过**前缀操作符打包和解包关键字参数。如果你看到一个函数的签名中有**kwargs,如图 C.5 所示,这意味着它将命名参数打包到一个名为kwargs的字典中(你不必将其命名为kwargs,就像你不必将经典的不/解包命名为args一样)。PySpark 并不常用它,而是将其保留用于方法的可选命名参数。DataFrame.orderBy()是最好的例子,其中ascending被捕获为一个关键字参数。

图 C.5 关键字参数的打包和解包:参数的名称是字典键,参数本身是值。
参数打包和解包使 Python 函数更加灵活:我们不必为选择一列、两列或三列实现select1()、select2()、select3()。它还使语法更容易记住,因为我们不必将我们希望选择的列打包成一个列表,只是为了使函数满意。它还有助于使 Python 静态类型工具更满意,这恰好是下一节的主题。
C.3 Python 的类型和 mypy/pyright
Python 是一种强类型但动态类型语言。在我学习编程的时候,我记得许多经验丰富的开发者像念经一样重复这句话。我也记得当我问“那是什么意思?”时,很多人露出困惑的表情。类型工具的引入——本节的主题——给 Python 的类型故事增添了一层神秘感。本节从对强类型和动态类型的定义性描述开始,然后回顾 Python 以及更具体地说 PySpark 如何使用类型来简化并增强数据处理和分析代码的健壮性。
在编程语言的环境中,强类型意味着每个变量都有一个类型。例如,在 Python 中,语句 a = "the letter a" 将字符串 the letter a 分配给变量 a。这意味着我们对 a 执行的任何操作都需要有一个适用于字符串的实现。一些语言,如 Python,在类型方面更加灵活,只要定义了行为,它们就会允许某些函数应用于许多类型。让我们以我们的字符串示例为例:在列表 C.8 中,我们看到,虽然我们不能将 1 加到 a 上,但我们可以在 a 上“添加”一个字符串来创建一个更长的字符串。在 Python 中,每个变量都有一个类型,并且在执行操作时这个类型很重要:+ 操作符不能与 int 和 str 一起使用,但可以与两个 int 或两个 str 一起使用。
列表 C.8 数字与字符串之间的加法(连接)
>>> a = "the letter a"
>>> a + 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: cannot concatenate 'str' and 'int' objects
>>> a + " but not the letter b"
'the letter a but not the letter b'
弱类型语言在传递 a + 1 时可能会执行某些操作,而不是抛出类型错误。弱类型和强类型语言形成的是一个梯度,而不是两个阵营;弱类型和强类型之间的明确界限仍然有待商榷。在我们的特定情况下,记住 Python 为每个变量都有一个类型就足够了。变量的类型在执行操作时很重要,对不兼容类型执行操作,例如将 1 加到 a 字符串上,将产生类型错误。
Python 还是一种动态类型语言,这意味着类型解析/错误是在运行时或程序运行时发现的。这与 静态 类型形成对比,静态类型意味着类型是在编译期间推断(或已知)的。像 Haskell、OCaml 以及甚至 Java 这样的语言是静态类型语言的优秀例子:当 Python 在执行类型不兼容的操作时会产生运行时错误,而静态类型语言会直接拒绝运行程序或甚至编译源代码。严格的或动态类型哪种更好是一个个人偏好的问题。有些人认为严格的类型可以确保在编码时更好的纪律性,并消除类型错误。也有人认为动态类型有助于在不被类型验证的额外仪式所分散注意力的前提下完成任务。像编程中的许多事情一样,这是一个被广泛讨论的话题,大多数讨论这个话题的人都没有充分尝试过两种方法。
Python 3.5 通过在语言中引入类型提示而略微改变了游戏规则。虽然这并不意味着 Python 现在是一个静态类型语言,但包含可选的类型检查意味着我们可以获得一些静态检查的好处,而无需与它们可能强加的非常严格的框架抗争。
要开始类型检查,你需要获取一个类型检查器。最简单的方法是使用 mypy (mypy-lang.org/);我在本节的示例中使用了它。你也可以检查 pytype(来自 Google)、Pyright/Pylance(来自 Microsoft,与 VS Code 一起捆绑)、以及 Pyre(来自 Facebook)作为替代方案。PyCharm 也自带了一个类型检查工具。请参考你的编辑器/类型检查器文档以获取安装说明。
让我们创建一个简单的例子,其中类型不匹配。在下面的代码中,我们有一个明显的类型错误,我们(再次!)将一个整数值添加到一个字符串中(我永远不会学到这一点)。
列表 C.9 type_error.py:故意创建类型错误
def add_one(value: float): ❶
return value + 1
add_one("twenty") ❷
❶ 通过将值注解为浮点数,我们表明我们可以传递一个整数或浮点数(每个整数都是浮点数,但不是每个浮点数都是整数)。
❷ "twenty" 不是一个浮点数。这是一个类型错误。
如果你已经配置了你的编辑器在编写时进行类型检查,那么在你输入列表 C.9 的最后一行后,你应该几乎立即得到一个错误。如果没有,请使用命令行工具 mypy 来检查你的文件,如下面的列表所示。
列表 C.10 使用 mypy 命令行工具来识别类型错误
$ mypy type_error.py
type_error.py:8: error: Argument 1 to "add_one"
has incompatible type "str"; expected "float"
Found 1 error in 1 file (checked 1 source file)
这是一个非常简单的例子,但类型提示在你设计自己的函数时非常有用。它们不仅作为对函数潜在用户期望(和返回)的参数类型的指示,还可以帮助强制执行一些期望的行为并解释 TypeError。在 PySpark 中,它们用于分发 pandas UDF 的类型(见第九章),而无需添加任何特殊的类型注解。例如,我在列表 C.11 中重现了 f_to_c 函数:函数的签名是 (degrees: pd.Series) -> pd.Series,这意味着它接受一个必须是 pandas Series 的单一参数,并返回一个 pandas Series。PySpark 会自动获取这个类型信息,知道它是一个 Series 到 Series 的 UDF。在引入 pandas UDF 的类型提示之前,你需要向装饰器添加第二个参数(见第 C.5 节)以帮助分发。
列表 C.11 f_to_c 函数中的类型提示
import pandas as pd
import pyspark.sql.types as T
import pyspark.sql.functions as F
@F.pandas_udf(T.DoubleType())
def f_to_c(degrees: pd.Series) -> pd.Series: ❶
"""Transforms Farhenheit to Celsius."""
return (degrees - 32) * 5 / 9
❶ f_to_c 函数接收一个 Series 并返回一个 Series。我们知道这一点,因为它有注解。
我们在本节结束时提供了一些有用的类型构造函数,这些函数可以在构建自己的函数时使用。(更多信息,请参阅 PEP484—类型提示 [www.python.org/dev/peps/pep-0484/]。)当使用 Python 3.8 时(Python 3.9 中语义略有变化,并且它们可以在不进行显式导入的情况下使用;参见 PEP585—标准集合中的类型提示 [www.python.org/dev/peps/pep-0585/]),所有五个构造函数,Iterator、Union、Optional、Tuple和Callable,都是从typing模块导入的:
from typing import Iterator, Union, Optional, Callable
我们在第九章中遇到了来自 Series(单个和多个)的Iterator。Iterator类型提示意味着你正在处理一个可以迭代的集合,例如列表、字典、元组,甚至是文件的内容。它暗示这个变量将被迭代,可能通过使用for循环。
Union类型提示意味着变量可以是联合中的任何类型。例如,许多 PySpark 函数的签名是Union[Column, str],这意味着它们接受Column对象(在pyspark.sql中看到)或字符串作为参数。Optional[...]等同于Union[..., None],其中我们在省略号中放置一个类型。
Tuple在多个 Series UDF 的Iterator中使用。由于在 Python 中元组是不可变的(你无法就地更改它们),我们可以通过注解强制执行严格类型。三个 pandas Series 的元组将是Tuple[pd.Series, pd.Series, pd.Series]。
Callable将在下一节中介绍,我们将讨论 Python 闭包和transform()方法。它指的是函数的类型(一个接受参数以返回另一个对象的对象)。例如,列表 C.11 中add_one函数的类型是Callable[[float], float]:第一个位置是输入参数,第二个是返回值。
在结束本节之前,我鼓励您将类型提示作为一个工具来使用:不多也不少。因为类型检查是 Python 的一个较新特性,存在一些粗糙的边缘,并且不同类型检查器之间的覆盖范围不均。很容易沉迷于寻找完美的类型签名,这会从做有用的工作中窃取宝贵的时间。
C.4 Python 闭包和 PySpark 的 transform()方法
如果我要用几个词总结本节,我会说,你可以在 Python 中创建返回函数的函数。这在使用高阶函数(如第八章中看到的map()和reduce())时非常有用,但它也解锁了一个非常有用但可选的代码模式,当在 PySpark 中转换数据时。
在第一章中,我介绍了方法链作为组织数据转换代码的首选方式。我们在第三章提交作为作业的代码,在下述列表中重现,很好地说明了这个概念:我们看到一列点,每个点都是一个对通过前一次应用返回的数据帧调用的方法。
列表 C.12 单词计数提交程序,及其一系列方法链
results = (
spark.read.text("./data/gutenberg_books/1342-0.txt")
.select(F.split(F.col("value"), " ").alias("line"))
.select(F.explode(F.col("line")).alias("word"))
.select(F.lower(F.col("word")).alias("word"))
.select(F.regexp_extract(F.col("word"), "[a-z']*", 0).alias("word"))
.where(F.col("word") != "")
.groupby(F.col("word"))
.count() ❶
)
^
❶ 代码的对齐突出了方法链。
如果你需要超越 select()、where()、groupby()、count() 或通过数据帧 API 可用的任何其他方法呢?
进入 transform() 方法。transform() 方法接受一个参数:一个接受单个参数的函数。它返回将函数应用于数据帧的结果。作为一个例子,假设我们想要计算给定列的模数。让我们创建一个函数,它接受一个数据帧作为参数,并返回一个新列的模数。我们的函数接受四个参数:
-
数据帧本身
-
旧列的名称
-
新列的名称
-
模数值
列表 C.13 接受四个参数的 modulo_of 函数
def modulo_of(df, old_column, new_column, modulo_value):
return df.withColumn(new_column, F.col(old_column) % modulo_value)
如果我们想要将此函数应用于数据帧,我们需要像常规函数一样应用此函数,例如 modulo_of(df, "old", "new", 2)。这打破了方法链,在函数应用和方法应用之间使代码变得杂乱。为了使用 transform() 方法与 modulo_of(),我们需要将其变成一个接受数据帧作为单个参数的函数。
在列表 C.14 中,我们重写我们的 modulo_of 函数以满足此协议。modulo_of() 的返回值是一个函数/可调用对象,接受一个数据帧作为参数并返回一个数据帧。为了有一个返回函数,我们创建了一个 _inner_ func(),它接受一个数据帧作为参数并返回一个转换后的数据帧。_inner_func() 可以访问传递给 modulo_of() 的参数,即 new_name、old_col 和 modulo_value。
列表 C.14 重写 modulo_of 函数
from typing import Callable
from pyspark.sql import DataFrame
def modulo_of(
new_name: str, old_col: str, modulo_value: int
) -> Callable[[DataFrame], DataFrame]: ❶
"""Return the value from the column mod `modulo_value`
Transform-enabled function."""
def _inner_func(df: DataFrame) -> DataFrame: ❷
# Function knows about new_name and old_col and modulo_value
return df.withColumn(new_name, F.col(old_col) % modulo_value)
return _inner_func ❸
❶ modulo_of() 从数据帧返回一个函数到数据帧。
❷ _inner_func() 可以访问传递给 modulo_of() 的参数,即 new_name、old_col 和 modulo_value。
❸ 我们像任何其他对象一样返回函数(它就是)。
它是如何工作的?
-
在 Python 中,函数可以返回函数,因为它们就像任何其他对象一样。
-
在 Python 中,函数内部创建的函数可以访问其定义的环境(定义的变量)。在
_inner_func()的情况下,我们创建的辅助函数DataFrame→DataFrame可以访问new_name、old_col和modulo_value。这即使在结束包围函数块之后也会工作。这被称为 函数闭包,产生的函数被称为 闭包。 -
结果类似于部分评估一个函数,其中我们在第一次“应用”中设置所有参数,然后在第二个数据帧中设置数据帧。
现在我们可以简单地使用transform()与我们的新创建的“允许转换”的函数。
列表 C.15 将modulo_of()函数应用于样本数据帧
df = spark.createDataFrame(
[[1, 2, 4, 1], [3, 6, 5, 0], [9, 4, None, 1], [11, 17, None, 1]],
["one", "two", "three", "four"],
)
(
df.transform(modulo_of("three_mod2", "three", 2))
.transform(modulo_of("one_mod10", "one", 10))
.show()
)
# +---+---+-----+----+----------+---------+
# |one|two|three|four|three_mod2|one_mod10|
# +---+---+-----+----+----------+---------+
# | 1| 2| 4| 1| 0| 1|
# | 3| 6| 5| 0| 1| 3|
# | 9| 4| null| 1| null| 9|
# | 11| 17| null| 1| null| 1|
# +---+---+-----+----+----------+---------+
为了闭合循环,如果我们想像函数一样使用我们新的modulo_of()函数,而不使用transform(),那会怎样?我们只需要应用两次:第一次应用将返回一个只接受数据帧作为唯一参数的函数。第二次应用将返回转换后的数据帧:
modulo_of("three_mod2", "three", 2)(df)
允许转换的函数并非编写高性能和可维护程序所必需。另一方面,它们使我们能够通过转换方法嵌入任意逻辑,这保持了方法链代码组织模式。这产生了更干净、更易读的代码。
C.5 Python 装饰器:包装函数以改变其行为
至少在我们遇到的情况下,装饰器是一种允许在不更改代码主体的情况下修改函数的构造。它们看起来很复杂,因为它们的语法相当独特。装饰器依赖于 Python 将函数视为对象的能力:你可以将它们作为参数传递,并从函数中返回它们(参见 C.4 节)。简单来说,装饰器是将一个函数包装在作为参数传递的函数周围的一种简化语法。
装饰器可以做很多事情。正因为如此,最好关注它们在 PySpark 中的使用。在 PySpark 中,我们使用 Python 装饰器将函数转换成 UDF(常规或矢量化/熊猫)。作为一个例子,让我们回顾一下在第九章中创建的f_to_c() UDF。如果我们回想一下pandas_udf装饰器的工作原理,当应用于一个函数,这里f_to_c(),该函数不再应用于 pandas Series。装饰器将其转换为一个可以应用于 Spark Column的 UDF。
列表 C.16 f_to_c UDF
@F.pandas_udf(T.DoubleType()) ❶
def f_to_c(degrees: pd.Series) -> pd.Series:
"""Transforms Farhenheit to Celsius."""
return (degrees - 32) * 5 / 9
❶ 应用装饰器后,f_to_c 函数不再是 pandas Series 上的简单函数,而是一个用于 Spark 数据帧的矢量化 UDF。
在底层,创建 UDF 需要一些 JVM(Java 虚拟机,因为 Spark 是用 Scala 编写的,而 PySpark 利用 Java API)技巧。我们可以使用pandas_udf()装饰器的伪代码来更好地理解装饰器是如何工作的,以及如何在需要时创建一个装饰器。
装饰器是函数——为了完整性,我们可以有装饰器类,但它们在 PySpark 的用户界面 API 中并不使用——它们至少需要一个函数f作为参数。通常,装饰器在返回(其返回值)之前会在函数f周围执行额外的操作。
列表 C.17 pandas_udf装饰器函数的伪代码
def pandas_udf(f, returnType, functionType):
Step 1: verify the returnType to ensure its validity (either
`pyspark.sql.types.*` or a string representing a PySpark data type).
Step 2: assess the UDF type (functionType) based on the signature (Spark 3)
or the PandasUDFType (Spark 2.3+))
Step 3: Create the UDF object wrapping the function `f` passed as an
argument (in PySpark, this is done through the
`pyspark.sql.udf._create_udf()` function)
Return: the newly formed UDF from step 3.
让我们创建一个装饰器 record_counter,它将计算并打印在转换我们的数据帧之前和之后的记录数。record_counter 只接受一个参数,即我们想要装饰的函数,并返回一个包装器,该包装器计算记录数,应用函数,计算函数结果的记录数,并返回函数的结果。
列表 C.18 一个简单的装饰器函数
def record_counter(f):
def _wrapper(value): ❶
print("Before: {} records".format(value.count())) ❷
applied_f = f(value) ❸
print("After: {} records".format(applied_f.count()))
return applied_f ❹
return _wrapper ❺
❶ 我们在装饰器函数内部创建一个函数,就像在启用转换的函数中做的那样。这个函数将是应用装饰器后返回的函数。
❷ 在实际应用传递给函数的参数之前,我们打印记录数。
❸ 我们应用函数并将返回值保存到包装函数的末尾。
❹ 我们返回函数的结果。忘记这一点意味着使用 record_counter 装饰的函数将返回空值。
❺ 我们将包装器作为装饰器函数的结果返回。
要将装饰器应用于函数,我们在函数定义前加上 @record_counter。Python 将装饰器之后的一行中的函数分配给 record_counter 的第一个参数。当应用没有额外参数(除了函数)的装饰器时,我们不需要在装饰器名称的末尾添加括号 ()。
列表 C.19 将 record_counter 应用到函数中
@record_counter ❶
def modulo_data_frame(df):
return (
df.transform(modulo_of("three_mod2", "three", 2))
.transform(modulo_of("one_mod10", "one", 10))
.show()
)
❶ 我们将装饰器放在函数定义的顶部。由于我们的装饰器不接收额外的参数,我们不需要在末尾添加括号。
由于装饰后的函数是一个函数,我们可以像使用任何其他函数一样使用它。在 pandas UDF 的情况下,装饰器实际上改变了对象的性质,所以它被以不同的方式使用,但仍然具有函数风味。
列表 C.20 将我们的装饰函数像任何其他函数一样使用
modulo_data_frame(df)
# Before: 4 records ❶
# +---+---+-----+----+----------+---------+
# |one|two|three|four|three_mod2|one_mod10|
# +---+---+-----+----+----------+---------+
# | 1| 2| 4| 1| 0| 1|
# | 3| 6| 5| 0| 1| 3|
# | 9| 4| null| 1| null| 9|
# | 11| 17| null| 1| null| 1|
# +---+---+-----+----+----------+---------+
# After: 4 records ❶
❶ 我们在传递给函数的参数的 show() 方法的上方看到计数前后的情况。
由于装饰器函数是一个函数,我们也可以不使用 @ 模式来使用它。为此,我们使用 record_counter() 作为常规函数,并将结果赋给一个变量。我个人觉得装饰器模式非常吸引人且简洁,因为它避免了有两个变量:一个用于原始函数(modulo_data_frame2)和一个用于装饰后的函数(modulo_data_frame_d2)。
列表 C.21 通过使用常规函数应用避免装饰器模式
def modulo_data_frame2(df):
return (
df.transform(modulo_of("three_mod2", "three", 2))
.transform(modulo_of("one_mod10", "one", 10))
.show()
)
modulo_data_frame_d2 = record_counter(modulo_data_frame2)
最后,当与 UDF 一起工作时,您仍然可以通过 func 属性访问原始函数(在 Python 或 pandas 对象上工作的函数)。这在单元测试已经用户定义的函数时很有用。它还可以确保 pandas 和 PySpark 之间的一致行为。
列表 C.22 通过 func 属性从 UDF 访问原始函数
print(f_to_c.func(pd.Series([1,2,3])))
# 0 -17.222222
# 1 -16.666667
# 2 -16.111111
# dtype: float64
装饰器在 PySpark 中非常有用,用于表示一个函数是用户自定义的(以及表示其类型)。因为装饰器是常规的 Python 语言结构,所以我们不仅限于仅将它们用于 UDFs:无论何时你想向一组函数添加新功能(我们展示了日志记录),装饰器都是一个非常可读的选项。



浙公网安备 33010602011771号