Spark-数据算法-全-
Spark 数据算法(全)
原文:
zh.annas-archive.org/md5/8676e264cabfb75df4cf278b6a914b80译者:飞龙
前言
十年前我启动 Apache Spark 项目时,我的主要目标之一是让广泛范围的用户更容易实现并行算法。新的大规模数据算法对计算的各个领域都产生了深远影响,我希望帮助开发人员实现这样的算法,并在不必从头开始构建分布式系统的情况下推理其性能。
因此,我对马哈茂德·帕尔西安博士关于 Spark 数据算法的新书感到非常兴奋。帕尔西安博士在大规模数据并行算法方面拥有广泛的研究和实践经验,包括作为 Illumina 大数据团队负责人开发生物信息学新算法。在这本书中,他通过其 Python API,PySpark,介绍了 Spark,并展示了如何使用 Spark 的分布式计算原语高效地实现各种有用的算法。他还解释了底层 Spark 引擎的工作原理,以及如何通过控制数据分区等技术来优化您的算法。无论是想要以可扩展的方式实现现有算法的读者,还是正在使用 Spark 开发新的定制算法的读者,这本书都将是一个很好的资源。
我也非常激动,因为 Parsian 博士在他讨论的所有算法中都包含了实际工作的代码示例,尽可能使用真实世界的问题。这些将为希望实现类似计算的读者提供一个很好的起点。无论您是打算直接使用这些算法还是利用 Spark 构建自己的定制算法,我希望您享受这本书作为开源引擎、其内部工作以及对计算产生广泛影响的现代并行算法的介绍。
Matei Zaharia
斯坦福大学计算机科学助理教授
Databricks 首席技术专家
Apache Spark 的创始人
序言
Spark 已经成为大规模数据分析的事实标准。自其成立九年以来,我一直在使用和教授 Spark,并且在数据提取、转换、加载(ETL)过程、分布式算法开发以及大规模数据分析方面见证了巨大的改进。我最初使用 Java 开发 Spark,但我发现尽管代码非常稳定,但需要编写冗长的代码行,这可能导致代码难以阅读。因此,为了这本书,我决定使用 PySpark(Spark 的 Python API),因为用 Python 表达 Spark 的强大更为简单:代码简短、易读且易于维护。PySpark 功能强大但使用简单,您可以通过一组简单的转换和操作表达任何 ETL 或分布式算法。
我为什么写这本书
这是一本关于使用 PySpark 进行数据分析的入门书籍。本书提供了一系列指南和示例,旨在帮助软件和数据工程师以最简单的方式解决数据问题。如您所知,解决任何数据问题的方法有很多种:PySpark 可以让我们为复杂问题编写简单的代码。这是我在本书中尝试表达的座右铭:保持简单,使用参数,使您的解决方案可以被其他开发人员重用。我旨在教读者如何思考数据、理解其来源和最终预期形式,以及如何使用基本的数据转换模式解决各种数据问题。
这本书是为谁准备的
要有效使用本书,最好了解 Python 编程语言的基础知识,例如如何使用条件语句(if-then-else)、遍历列表以及定义和调用函数。然而,如果您的背景是其他编程语言(如 Java 或 Scala),并且不了解 Python,也可以使用本书,因为我已经提供了关于 Spark 和 PySpark 的合理介绍。
本书主要面向希望使用 Spark 引擎和 PySpark 分析大量数据并开发分布式算法的人群。我提供了简单的示例,展示如何在 PySpark 中执行 ETL 操作和编写分布式算法。代码示例编写方式简单,可以轻松地复制粘贴以完成工作。
GitHub 提供的示例代码 是开始您自己数据项目的绝佳资源。
本书的组织方式
本书包含 12 章,分为三个部分:
第一部分,“基础”
前四章介绍了 Spark 和 PySpark 的基础知识,并介绍了数据转换,如映射器、过滤器和减少器。它们包含许多实际示例,可以帮助您开始自己的 PySpark 项目。本书的前四章引入了简单的 PySpark 数据转换(如map()、flatMap()、filter()和reduceByKey()),这些转换可以解决大约 95%的所有数据问题。以下是这里的详细内容:
-
第一章,“介绍 Spark 和 PySpark”,提供了数据算法的高级概述,并介绍了使用 Spark 和 PySpark 解决数据分析问题的方法。
-
第二章,“动作中的转换”,展示了如何使用 Spark 转换(映射器、过滤器和减少器)来解决真实的数据问题。
-
第三章,“映射器转换”,介绍了最常用的映射器转换:
map()、filter()、flatMap()和mapPartitions()。 -
第四章,“Spark 中的减少”,重点介绍了减少转换(如
reduceByKey()、groupByKey()和combineByKey()),它们在按键分组数据中起着非常重要的作用。提供了许多简单但实用的示例,确保您能有效地使用这些减少。
第二部分,“处理数据”
接下来的四章涵盖了数据分区、图算法、从/向多种不同数据源读取/写入数据以及排名算法:
-
第五章,“数据分区”,介绍了在特定数据列上物理分区数据的函数。这种分区将使您的 SQL 查询(例如在 Amazon Athena 或 Google BigQuery 中)能够分析数据的一个切片,而不是整个数据集,从而提高查询性能。
-
第六章,“图算法”,介绍了 Spark 中一个最重要的外部包,GraphFrames,它可以用于分析 Spark 分布式环境中的大型图形。
-
第七章,“与外部数据源交互”,展示了如何从各种数据源读取数据并将其写入。
-
第八章,“排名算法”,介绍了两种重要的排名算法,PageRank(用于搜索引擎)和 rank product(用于基因分析)。
第三部分,“数据设计模式”
最后四章介绍了实际数据设计模式,以实例形式呈现:
-
第九章,“经典数据设计模式”,介绍了一些基本数据设计模式或可重复使用的解决方案,通常用于解决各种数据问题。示例包括 Input-Map-Output 和 Input-Filter-Output。
-
第十章,“实用数据设计模式”,介绍了常见和实用的数据设计模式,用于组合、汇总、过滤和组织数据等任务。这些模式以实用示例的形式展示。
-
第十一章,“连接设计模式”,介绍了用于连接两个或多个数据集的简单模式;讨论了一些提高连接算法效率的性能标准。
-
第十二章,“PySpark 中的特征工程”,介绍了开发机器学习算法中最常用的特征工程技术。
附加章节
由于我不希望使本书变得过于臃肿,我在书的 GitHub 仓库 中包含了有关 TF-IDF、相关性和 k-mer 等主题的额外材料。
本书使用的约定
本书使用以下排版约定:
斜体
指示新术语、URL、电子邮件地址、文件名和文件扩展名。
等宽字体
用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
等宽粗体
显示应由用户逐字输入的命令或其他文本。
等宽斜体
显示应由用户提供值或由上下文确定值的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般注释。
警告
此元素表示警告或注意事项。
使用代码示例
补充材料(例如代码示例、练习等)可在 https://github.com/mahmoudparsian/data-algorithms-with-spark 上下载。
如果您在使用代码示例时遇到技术问题或困难,请发送电子邮件至 mahmoud.parsian@yahoo.com。
本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了大部分代码,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序无需许可。销售或分发 O’Reilly 书籍中的示例代码则需要许可。引用本书回答问题并引用示例代码无需许可。将本书中大量示例代码整合到产品文档中则需要许可。
我们感谢您的支持,但通常不要求归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Data Algorithms with Spark by Mahmoud Parsian (O’Reilly). Copyright 2022 Mahmoud Parsian, 978-1-492-08238-5.”
如果您认为使用代码示例超出了合理使用范围或以上授权,请随时通过permissions@oreilly.com与我们联系。
奥莱利在线学习
注意
40 多年来,奥莱利传媒提供技术和商业培训、知识和洞见,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章、会议以及我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自奥莱利和其他 200 多个出版商的大量文本和视频。有关更多信息,请访问http://oreilly.com。
如何联系我们
请将关于本书的评论和问题发送至出版商:
-
奥莱利传媒股份有限公司
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为本书创建了一个网页,列出勘误、示例和任何额外信息。您可以访问此页面:https://oreil.ly/data-algorithms-with-spark。
发送电子邮件至bookquestions@oreilly.com以发表评论或提出关于本书的技术问题。
有关我们的图书、课程、会议和新闻的更多信息,请访问我们的网站:http://www.oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上关注我们:http://youtube.com/oreillymedia
致谢
这本书的构思是由 O'Reilly Media 的高级采编编辑 Jess Haberman 提出的。我非常感激她的联系 —— 非常感谢你,Jess!我感激 O'Reilly Media 的内容开发编辑 Melissa Potter,她从项目开始就与我密切合作,并帮助我大大改善了这本书。非常感谢你,Melissa!非常感谢编辑 Rachel Head,她在整本书的编辑工作中做得非常出色;如果你能读懂并理解这本书,那都是因为 Rachel。我要衷心感谢 Christopher Faucher(出版编辑),他做得非常好,确保了截稿时间并且一切都井然有序。推动一本书的出版绝非易事,但 Christopher 做得非常出色。
感谢技术审稿人 Parviz Deyhim 和 Benjamin Muskalla 非常仔细地审阅我的书,并就随之而来的评论、修正和建议表示感谢。我还要特别感谢我的博士导师和亲密的朋友,Ramachandran Krishnaswamy 博士,我从他身上学到了很多;我将永远珍惜与他的友谊。
为了增加 GitHub 上所有章节的 PySpark 解决方案,Deepak Kumar 和 Biman Mandal 提供了 Scala 解决方案,这对读者来说是一个很好的资源。非常感谢你们,Deepak 和 Biman。最后但同样重要的是,我要向 Apache Spark 的创始人 Matei Zaharia 博士表示巨大的感谢和敬意,他为我的书撰写了前言;我为他的友善言辞感到荣幸和自豪。
第一部分:基础
第一章:Spark 和 PySpark 简介
Spark 是一个强大的大数据分析引擎,旨在实现速度、易用性和大数据应用的可扩展性。它是被许多每天处理大数据的公司广泛采用的验证技术。尽管 Spark 的“原生”语言是 Scala(大部分 Spark 是用 Scala 开发的),它也提供了 Java、Python 和 R 的高级 API。
在本书中,我们将使用 PySpark,它是一个将 Spark 编程模型暴露给 Python 的 API。由于 Python 是最易于访问的编程语言,加上 Spark 强大而表达力强的 API,PySpark 的简洁性使其成为我们的最佳选择。PySpark 是 Spark 在 Python 编程语言中的接口,提供以下两个重要功能:
-
允许我们使用 Python API 编写 Spark 应用程序。
-
它提供了 PySpark shell,用于在分布式环境中交互式分析数据。
本章的目的是介绍 PySpark 作为 Spark 生态系统的主要组件,并展示它可以有效用于大数据任务,如 ETL 操作、索引数十亿个文档、摄入数百万基因组、机器学习、图数据分析、DNA 数据分析等。我将首先回顾 Spark 和 PySpark 的架构,并提供示例展示 PySpark 的表达能力。我将概述 Spark 的核心功能(转换和动作)和概念,以便您可以立即开始使用 Spark 和 PySpark。Spark 的主要数据抽象包括弹性分布式数据集(RDDs)、DataFrame 和 Dataset。正如您将看到的,您可以在任何 RDDs 和 DataFrame 的组合中表示您的数据(存储为 Hadoop 文件、Amazon S3 对象、Linux 文件、集合数据结构、关系数据库表等)。
一旦您的数据被表示为 Spark 数据抽象,您可以对其进行转换,并创建新的数据抽象,直到数据处于您所需的最终形式。Spark 的转换操作(如 map() 和 reduceByKey())可用于将数据从一种形式转换为另一种形式,直到您获得所需的结果。稍后我将简要解释这些数据抽象,但首先,让我们深入探讨一下为什么 Spark 是进行数据分析的最佳选择。
为什么选择 Spark 进行数据分析
Spark 是一个强大的分析引擎,可用于大规模数据处理。使用 Spark 的最重要原因包括:
-
Spark 简单、强大且快速。
-
Spark 是自由开源的。
-
Spark 可在各处运行(Hadoop、Mesos、Kubernetes、独立模式或云中)。
-
Spark 可读取/写入来自/到任何数据源的数据(Amazon S3、Hadoop HDFS、关系数据库等)。
-
Spark 可以与几乎任何数据应用集成。
-
Spark 可以读取/写入行格式(例如 Avro)和列格式(例如 Parquet 和 ORC)的数据。
-
Spark 提供了丰富而简单的 API,用于各种 ETL 过程。
在过去五年中,Spark 已经发展到我认为可以用来解决任何大数据问题的程度。这一观点得到了支持,因为所有的大数据公司,如 Facebook、Illumina、IBM 和 Google,每天都在生产系统中使用 Spark。
Spark 是处理大规模数据和解决 MapReduce 问题及其他问题的最佳选择之一,因为它通过强大的 API 和速度处理大数据,释放数据的力量。使用 MapReduce/Hadoop 解决大数据问题很复杂,即使是解决原始问题,你也必须编写大量的低级代码 — 这就是 Spark 的强大和简单之处。Apache Spark比 Apache Hadoop快得多,因为它使用内存缓存和优化执行来提供快速性能,并支持通用批处理、流式分析、机器学习、图算法和 SQL 查询。
对于 PySpark,Spark 有两种基本的数据抽象:RDD 和 DataFrame。我将教你如何读取数据并将其表示为 RDD(相同类型元素的集合)或 DataFrame(带有命名列的行表),这使你可以在分布式数据集合上施加结构,实现更高级别的抽象。一旦数据被表示为 RDD 或 DataFrame,你可以对其应用转换函数(如映射器、过滤器和减少器),将数据转换为所需的形式。我将呈现许多用于 ETL 过程、分析和数据密集型计算的 Spark 转换。
一些简单的 RDD 转换在图 1-1 中表示。

图 1-1. 简单的 RDD 转换
本图显示了以下转换:
-
首先,我们使用
SparkSession的实例读取我们的输入数据(表示为文本文件,sample.txt — 这里只显示了输入数据的前两行/记录)。SparkSession是编程 Spark 的入口点。SparkSession实例表示为spark对象。读取输入会创建一个新的 RDD,类型为RDD[String]:每个输入记录被转换为String类型的 RDD 元素(如果您的输入路径有N条记录,则 RDD 元素的数量为N)。这是通过以下代码实现的:# Create an instance of SparkSession spark = SparkSession.builder.getOrCreate() # Create an RDD[String], which represents all input # records; each record becomes an RDD element records = spark.sparkContext.textFile("sample.txt") -
接下来,我们将所有字符转换为小写字母。这通过
map()转换来实现,它是一种 1 对 1 的转换:# Convert each element of the RDD to lowercase # x denotes a single element of the RDD # records: source RDD[String] # records_lowercase: target RDD[String] records_lowercase = records.map(lambda x: x.lower()) -
然后,我们使用
flatMap()转换,这是一种 1 对多的转换,将每个元素(表示单个记录)转换为目标元素序列(每个单词表示一个元素)。flatMap()转换通过首先将函数(此处为split(","))应用于源 RDD 的所有元素,然后展平结果,返回一个新的 RDD:# Split each record into a list of words # records_lowercase: source RDD[String] # words: target RDD[String] words = records_lowercase.flatMap(lambda x: x.split(",")) -
最后,我们删除长度小于或等于 2 的单词元素。以下
filter()转换将删除不需要的单词,仅保留长度大于 2 的单词:# Keep words with a length greater than 2 # x denotes a word # words: source RDD[String] # filtered: target RDD[String] filtered = words.filter(lambda x: len(x) > 2)
正如你所观察到的,Spark 的转换是高级别的、强大的和简单的。Spark 天生具有分布式和并行的特性:你的输入数据被分区,并且可以在集群环境中并行地被转换(如 mappers、filters 和 reducers)。简而言之,要在 PySpark 中解决数据分析问题,你需要读取数据并将其表示为 RDD 或 DataFrame(取决于数据格式的特性),然后编写一系列转换来将数据转换为所需的输出。Spark 会自动分区你的 DataFrame 和 RDD,并将这些分区分发到不同的集群节点上。分区是 Spark 中并行性的基本单位。并行性使开发者能够在集群中的数百台计算机服务器上并行且独立地执行任务。在 Spark 中,分区是存储在集群节点上的数据的逻辑分割(块)。DataFrame 和 RDD 是分区的集合。Spark 为 RDD 和 DataFrame 提供了默认的数据分区器,但你也可以使用自定义编程来覆盖该分区。
接下来,让我们深入了解一下 Spark 的生态系统和架构。
Spark 生态系统
Spark 的生态系统显示在图 1-2 中。它有三个主要组成部分:
环境
Spark 可以在任何地方运行,并且与其他环境集成良好。
应用
Spark 与各种大数据平台和应用程序集成良好。
数据源
Spark 可以从许多数据源读取和写入数据。

图 1-2。Spark 生态系统(来源:Databricks)
Spark 广泛的生态系统使得 PySpark 成为 ETL、数据分析等多种任务的强大工具。使用 PySpark,你可以从许多不同的数据源(如 Linux 文件系统、Amazon S3、Hadoop 分布式文件系统、关系表、MongoDB、Elasticsearch、Parquet 文件等)读取数据,并将其表示为 Spark 数据抽象,如 RDD 或 DataFrame。一旦你的数据处于这种形式,你可以使用一系列简单而强大的 Spark 转换将数据转换为所需的形状和格式。例如,你可以使用filter()转换来删除不需要的记录,使用groupByKey()按你需要的键对数据进行分组,最后使用mapValues()转换来对分组数据进行最终聚合(如找到数字的平均值、中位数和标准偏差)。所有这些转换都可以通过简单但强大的 PySpark API 实现。
Spark 架构
当你有少量数据时,可以在合理的时间内用单台计算机进行分析。当你有大量数据时,使用单台计算机进行数据分析、处理和存储可能会非常慢,甚至不可能。这就是为什么我们要使用 Spark。
Spark 包括核心库和一组内置库(SQL、GraphX、Streaming、MLlib),如图 1-3 所示。可以通过其 DataSource API 与多个数据源进行交互,如 Hadoop、HBase、Amazon S3、Elasticsearch 和 MySQL 等。

图 1-3. Spark 库
此图展示了 Spark 的真正威力:你可以使用多种不同的语言编写你的 Spark 应用程序,然后使用丰富的库来解决各种大数据问题。同时,你可以从各种数据源读取/写入数据。
关键术语
要理解 Spark 的架构,你需要了解一些关键术语:
SparkSession
定义在 pyspark.sql 包中的 SparkSession 类是使用 Dataset 和 DataFrame API 编程 Spark 的入口点。要在 Spark 集群上执行任何有用的操作,首先需要创建该类的实例,该实例提供了对 SparkContext 的访问。
注意
PySpark 提供了一个全面的 API(由包、模块、类和方法组成),用于访问 Spark API。需要注意的是,本书讨论的所有 Spark API、包、模块、类和方法都是特定于 PySpark 的。例如,当提到 SparkContext 类时,指的是 pyspark.SparkContext Python 类,定义在 pyspark 包中;提到 SparkSession 类时,指的是 pyspark.sql.SparkSession Python 类,定义在 pyspark.sql 模块中。
SparkContext
定义在 pyspark 包中的 SparkContext 类是 Spark 功能的主要入口点。SparkContext 保存与 Spark 集群管理器的连接,并可用于在集群中创建 RDD 和广播变量。创建 SparkSession 实例后,SparkContext 作为 SparkSession.sparkContext 属性在会话中可用。
驱动程序
所有 Spark 应用程序(包括 PySpark shell 和独立的 Python 程序)都作为独立的一组进程运行。这些进程由驱动程序中的 SparkContext 协调。要将独立的 Python 程序提交给 Spark,你需要使用 PySpark API(或 Java 或 Scala)编写驱动程序。该程序负责运行应用程序的 main() 函数并创建 SparkContext,还可以用于创建 RDD 和 DataFrame。
Worker
在 Spark 集群环境中,存在两种类型的节点:一个(或两个,用于高可用性)主节点和一组工作节点。工作节点是可以在集群中运行程序的任何节点。如果为应用程序启动了一个进程,则此应用程序在工作节点获取执行器,这些执行器负责执行 Spark 任务。
集群管理器
“主”节点称为集群管理器。此节点的主要功能是管理集群环境和 Spark 将利用其执行任务的服务器。集群管理器为每个应用程序分配资源。Spark 支持五种类型的集群管理器,具体取决于其运行位置:
-
独立模式(Spark 内置的集群环境)
-
Mesos(一个分布式系统内核)
注意
虽然在许多软件环境中,主/工作术语的使用已过时并正在被淘汰,但它仍然是 Apache Spark 功能的一部分,这也是为什么我在本书中使用这些术语的原因。
Spark 架构概述
Spark 架构的高级视图显示在图 1-4 中。简而言之,一个 Spark 集群由一个主节点(“集群管理器”)组成,负责管理 Spark 应用程序,以及一组“工作”(执行器)节点,负责执行 Spark 应用程序提交的任务(您希望在 Spark 集群上运行的应用程序)。

图 1-4. Spark 架构
根据 Spark 运行的环境不同,管理此服务器集群的集群管理器可能是 Spark 的独立集群管理器、Kubernetes、Hadoop YARN 或 Mesos。当 Spark 集群运行时,您可以向集群管理器提交 Spark 应用程序,后者将为您的应用程序分配资源,以便您完成数据分析。
您的集群可能拥有一个、几十个、上百个,甚至上千个工作节点,具体取决于您业务和项目需求。您可以在独立服务器上(如 MacBook、Linux 或 Windows PC)上运行 Spark,但通常情况下,生产环境中 Spark 会在一组 Linux 服务器的集群上运行。要运行 Spark 程序,您需要访问一个 Spark 集群,并拥有一个驱动程序,该程序声明了对 RDD 数据的转换和操作,并将这些请求提交给集群管理器。在本书中,所有的驱动程序都将使用 PySpark 编写。
当您启动 PySpark shell(通过执行*<spark-installed-dir>*/bin/pyspark)时,您将自动获得定义的两个变量/对象:
spark
一个SparkSession的实例,非常适合创建 DataFrames
sc
一个SparkContext的实例,非常适合创建 RDDs
如果您编写一个独立的 PySpark 应用程序(使用 PySpark API 的 Python 驱动程序),那么您必须显式地自行创建SparkSession的实例。SparkSession可用于:
-
创建 DataFrames
-
将 DataFrames 注册为表
-
在表上执行 SQL 并缓存表
-
读取/写入文本、CSV、JSON、Parquet 和其他文件格式
-
读取/写入关系型数据库表
PySpark 将SparkSession定义为:
pyspark.sql.SparkSession (Python class, in pyspark.sql module)
class pyspark.sql.SparkSession(sparkContext,jsparkSession=None)
SparkSession: the entry point to programming Spark with the RDD
and DataFrame API.
要在 Python 中创建SparkSession,请使用此处显示的构建器模式:
# import required Spark class
from pyspark.sql import SparkSession 
# create an instance of SparkSession as spark
spark = SparkSession.builder \ 
.master("local") \
.appName("my-application-name") \
.config("spark.some.config.option", "some-value") \ 
.getOrCreate() 
# to debug the SparkSession
print(spark.version) 
# create a reference to SparkContext as sc
# SparkContext is used to create new RDDs
sc = spark.sparkContext 
# to debug the SparkContext
print(sc)
从pyspark.sql模块导入SparkSession类。
提供用于构建SparkSession实例的 Builder API 的访问。
设置一个config选项。使用此方法设置的选项会自动传播到SparkConf和SparkSession自身的配置中。创建SparkSession对象时,可以定义任意数量的config(*<key>*, *<value>*)选项。
获取现有的SparkSession或者如果没有,则根据此处设置的选项创建一个新的。
仅用于调试目的。
SparkContext可以从SparkSession的实例中引用。
PySpark 将SparkContext定义为:
class pyspark.SparkContext(master=None, appName=None, ...)
SparkContext: the main entry point for Spark functionality.
A SparkContext represents the connection to a Spark cluster,
and can be used to create RDD (the main data abstraction for
Spark) and broadcast variables (such as collections and data
structures) on that cluster.
SparkContext是 Spark 功能的主要入口点。Shell(例如 PySpark shell)或 PySpark 驱动程序不能创建多个SparkContext实例。SparkContext表示与 Spark 集群的连接,并可用于在该集群上创建新的 RDD 和广播变量(共享数据结构和集合—一种只读全局变量)。图 1-5 显示了如何使用SparkContext从输入文本文件(标记为records_rdd)创建新的 RDD,然后使用flatMap()转换将其转换为另一个 RDD(标记为words_rdd)。正如您所见,RDD.flatMap(*f*)通过首先将函数(f)应用于源 RDD 的所有元素,然后展平结果,返回一个新的 RDD。

图 1-5. 由SparkContext创建 RDDs
要创建SparkSession和SparkContext对象,请使用以下模式:
# create an instance of SparkSession
spark_session = SparkSession.builder.getOrCreate()
# use the SparkSession to access the SparkContext
spark_context = spark_session.sparkContext
如果您只会处理 RDDs,可以按以下方式创建SparkContext的实例:
from pyspark import SparkContext
spark_context = SparkContext("local", "myapp");
现在您已经了解了 Spark 的基础知识,让我们深入了解一下 PySpark。
PySpark 的强大之处
PySpark 是 Apache Spark 的 Python API,旨在支持 Spark 与 Python 编程语言之间的协作。大多数数据科学家已经了解 Python,PySpark 使他们能够使用 Spark 编写简短、简洁的分布式计算代码变得更加容易。简而言之,它是一个全能生态系统,可以通过支持 RDDs、DataFrames、GraphFrames、MLlib、SQL 等复杂数据要求来处理数据。
我将向您展示 PySpark 的惊人功能,以一个简单的例子。假设我们有大量记录,这些记录包含用户访问 URL 的数据(由搜索引擎从许多 Web 服务器收集),格式如下:
<url_address><,><frequency>
下面是这些记录的几个示例:
http://mapreduce4hackers.com,19779
http://mapreduce4hackers.com,31230
http://mapreduce4hackers.com,15708
...
https://www.illumina.com,87000
https://www.illumina.com,58086
...
假设我们想要查找每个键(即url_address)的访问次数的平均值、中位数和标准偏差。另一个要求是,我们希望丢弃长度小于 5 的记录(因为这些可能是格式错误的 URL)。可以轻松地在 PySpark 中表达一个优雅的解决方案,正如图 1-6 所示。

图 1-6. 计算平均值、中位数和标准偏差的简单工作流程
首先,让我们创建一些基本的 Python 函数,这些函数将帮助我们解决简单的问题。第一个函数create_pair()接受形式为<url_address><,><frequency>的单个记录,并返回一个(键,值)对(这将使我们能够稍后在键字段上执行GROUP BY),其中键是url_address,值是关联的frequency:
# Create a pair of (url_address, frequency)
# where url_address is a key and frequency is a value
# record denotes a single element of RDD[String]
# record: <url_address><,><frequency>
def create_pair(record): 
tokens = record.split(',') 
url_address = tokens[0]
frequency = tokens[1]
return (url_address, frequency) 
#end-def
接受形式为<url_address><,><frequency>的记录。
使用url_address作为键(tokens[0])和frequency作为值(tokens[1])对输入记录进行标记化。
返回一对(url_address,frequency)。
下一个函数compute_stats()接受频率列表(作为数字)并计算三个值,平均值、中位数和标准偏差:
# Compute average, median, and standard
# deviation for a given set of numbers
import statistics 
# frequencies = [number1, number2, ...]
def compute_stats(frequencies): 
average = statistics.mean(frequencies) 
median = statistics.median(frequencies) 
standard_deviation = statistics.stdev(frequencies) 
return (average, median, standard_deviation) 
#end-def
该模块提供了用于计算数值数据的数学统计函数。
接受频率列表。
计算频率的平均值。
计算频率的中位数。
计算频率的标准偏差。
返回三元组作为结果。
接下来,我将在几行代码中展示 PySpark 的强大功能,使用 Spark 转换和我们的自定义 Python 函数:
# input_path = "s3://<bucket>/key"
input_path = "/tmp/myinput.txt"
results = spark 
.sparkContext 
.textFile(input_path) 
.filter(lambda record: len(record) > 5) 
.map(create_pair) 
.groupByKey() 
.mapValues(compute_stats) 
spark 表示 SparkSession 的一个实例,这是编程 Spark 的入口点。
sparkContext(SparkSession 的一个属性)是 Spark 功能的主要入口点。
将数据作为分布式 String 记录集合读取(创建 RDD[String])。
删除长度小于或等于 5 的记录(保留长度大于 5 的记录)。
从输入记录中创建 (url_address, frequency) 对。
按键分组数据——每个键(一个 url_address)将与一个频率列表关联。
将 compute_stats() 函数应用于频率列表。
结果将是一组形如 (key, value) 的键值对:
(url_address, (average, median, standard_deviation))
其中 url-address 是键,(average, median, standard_deviation) 是值。
注意
Spark 最重要的特点是通过数据分区最大化函数和操作的并发性。考虑一个例子:
如果您的输入数据有 6000 亿行,并且您使用了 10 个节点的集群,则您的输入数据将被分成 N (> 1) 个块,这些块将独立并行处理。如果 *N*=20,000(块或分区的数量),那么每个块大约会有 3 千万条记录/元素(600,000,000,000 / 20,000 = 30,000,000)。如果您有一个大型集群,那么这 20,000 个块可能会一次性处理完毕。如果您有一个较小的集群,可能只有每隔 100 个块可以独立并行处理。这个过程会一直持续,直到所有 20,000 个块都处理完毕。
PySpark 架构
PySpark 是建立在 Spark 的 Java API 之上的。数据在 Python 中处理,并在 Java 虚拟机(JVM)中进行缓存/洗牌(我将在 第二章 中讨论洗牌的概念)。PySpark 架构的高层视图如 图 1-7 所示。

图 1-7. PySpark 架构
PySpark 的数据流程如 图 1-8 所示。

图 1-8. PySpark 数据流
在 Python 驱动程序(即 Python 中的 Spark 应用程序)中,SparkContext 使用 Py4J 来启动 JVM,创建 JavaSparkContext。Py4J 仅用于驱动程序,用于 Python 和 Java SparkContext 对象之间的本地通信;大数据传输通过其他机制进行。Python 中的 RDD 转换映射到 Java 中的 PythonRDD 对象上。在远程工作节点上,PythonRDD 对象启动 Python 子进程,并通过管道与其通信,发送用户的代码和要处理的数据。
注意
Py4J 允许运行在 Python 解释器中的 Python 程序动态访问 JVM 中的 Java 对象。方法调用就像 Java 对象驻留在 Python 解释器中一样,并且可以通过标准 Python 集合方法访问 Java 集合。Py4J 还使 Java 程序能够回调 Python 对象。
Spark 数据抽象
在 Python 编程语言中操作数据时,您使用整数、字符串、列表和字典。在 Spark 中操作和分析数据时,您必须将其表示为 Spark 数据集。Spark 支持三种类型的数据集抽象:
-
RDD(弹性分布式数据集):
-
低级 API
-
用
RDD[T]表示(每个元素类型为T)
-
-
数据框(类似于关系表):
-
高级 API
-
由
表(column_name_1, column_name_2, ...)表示
-
-
数据集(类似于关系表):
- 高级 API(在 PySpark 中不可用)
数据集数据抽象用于强类型语言,如 Java,并不支持 PySpark。RDD 和 DataFrame 将在后续章节详细讨论,但我会在这里简要介绍。
RDD 示例
实质上,RDD 将数据表示为元素的集合。它是类型 T 的不可变分布式元素集合,表示为 RDD[T]。
表 1-1 展示了三种简单类型的 RDD 示例:
RDD[整数]
每个元素都是一个 整数。
RDD[字符串]
每个元素都是一个 字符串。
RDD[(字符串, 整数)]
每个元素都是一个 (字符串, 整数) 对。
表 1-1. 简单的 RDDs
| RDD[整数] | RDD[字符串] | RDD[(字符串, 整数)] |
|---|---|---|
2 |
"abc" |
('A', 4) |
-730 |
"fox is red" |
('B', 7) |
320 |
"Python is cool" |
('ZZ', 9) |
| … | … | … |
表 1-2 是复杂 RDD 的示例。每个元素都是一个 (键, 值) 对,其中键是一个 字符串,值是一个三元组 (整数, 整数, 浮点数)。
表 1-2. 复杂的 RDD
RDD[(字符串, (整数, 整数, 浮点数))] |
|---|
("cat", (20, 40, 1.8)) |
("cat", (30, 10, 3.9)) |
("lion king", (27, 32, 4.5)) |
("python is fun", (2, 3, 0.6)) |
| … |
Spark RDD 操作
Spark RDD 是只读的、不可变的和分布式的。一旦创建,它们就无法更改:不能向 RDD 添加记录、删除记录或更新记录。但是可以对其进行转换。RDD 支持两种类型的操作:转换操作(将源 RDD(们)转换为一个或多个新 RDD)和动作操作(将源 RDD(们)转换为非 RDD 对象,如字典或数组)。RDD、转换和动作之间的关系如图 1-9 所示。

图 1-9. RDD、转换和动作
我们将在接下来的章节中详细讨论 Spark 的转换,并通过工作示例帮助您理解它们,但我将在此简要介绍。
转换
A transformation in Spark is a function that takes an existing RDD (the source RDD), applies a transformation to it, and creates a new RDD (the target RDD). Examples include: map(), flatMap(), groupByKey(), reduceByKey(), and filter().
Informally, we can express a transformation as:
transformation: source_RDD[V] --> target_RDD[T] 
将类型为 V 的 source_RDD 转换为类型为 T 的 target_RDD。
RDD 在执行动作操作之前不会被评估:这意味着转换操作是延迟评估的。如果在转换过程中出现 RDD 失败,转换的数据血统会重建 RDD。
大多数 Spark 转换都会创建一个单独的 RDD,但也可能会创建多个目标 RDD。目标 RDD 可能比源 RDD 更小、更大或者大小相同。
下面的例子展示了一系列的转换:
tuples = [('A', 7), ('A', 8), ('A', -4),
('B', 3), ('B', 9), ('B', -1),
('C', 1), ('C', 5)]
rdd = spark.sparkContext.parallelize(tuples)
# drop negative values
positives = rdd.filter(lambda x: x[1] > 0)
positives.collect()
[('A', 7), ('A', 8), ('B', 3), ('B', 9), ('C', 1), ('C', 5)]
# find sum and average per key using groupByKey()
sum_and_avg = positives.groupByKey()
.mapValues(lambda v: (sum(v), float(sum(v))/len(v)))
# find sum and average per key using reduceByKey()
# 1\. create (sum, count) per key
sum_count = positives.mapValues(lambda v: (v, 1))
# 2\. aggregate (sum, count) per key
sum_count_agg = sum_count.reduceByKey(lambda x, y:
(x[0]+y[0], x[1]+y[1]))
# 3\. finalize sum and average per key
sum_and_avg = sum_count_agg.mapValues(
lambda v: (v[0], float(v[0])/v[1]))
提示
groupByKey() 转换会将 RDD 中每个键的值分组为一个单一的序列,类似于 SQL 的 GROUP BY 语句。当每个键的值数量达到数千或数百万时,这种转换可能会导致内存不足(OOM)错误,因为数据会通过 Spark 服务器的网络发送,并在 reducer/workers 上收集。
但是,使用 reduceByKey() 转换时,数据在每个分区中进行合并,因此每个键在每个分区中只有一个输出需要发送到 Spark 服务器的网络上。这使得它比 groupByKey() 更具可扩展性。reduceByKey() 使用关联和可交换的 reduce 函数将每个键的值合并。它将所有值(每个键)合并为具有相同数据类型的另一个值(这是一个限制,可以通过使用 combineByKey() 转换来克服)。总体而言,reduceByKey() 比 groupByKey() 更具可伸缩性。我们将在第四章更详细地讨论这些问题。
动作
Spark actions are RDD operations or functions that produce non-RDD values. Informally, we can express an action as:
action: RDD => non-RDD value
操作可能触发 RDD 的评估(你会记得,RDD 是惰性评估的)。然而,操作的输出是一个具体的值:一个保存的文件,如整数值,元素数量,值列表,字典等。
下面是一些操作示例:
reduce()
应用函数以生成单个值,例如为给定的RDD[Integer]添加值
collect()
将RDD[T]转换为类型为T的列表
count()
查找给定 RDD 中元素的数量
saveAsTextFile()
将 RDD 元素保存到磁盘
saveAsMap()
将RDD[(K, V)]元素保存到磁盘作为dict[K, V]
DataFrame 示例
与 RDD 类似,Spark 中的 DataFrame 是一个不可变的分布式数据集合。但不同于 RDD 的是,数据被组织成了命名列,类似于关系数据库中的表。这旨在使大数据集的处理更加简单。DataFrames 允许程序员对分布式数据集合施加结构,提供了更高级别的抽象。它们还比 RDD 更容易处理 CSV 和 JSON 文件。
下面的 DataFrame 示例有三列:
DataFrame[name, age, salary]
name: String, age: Integer, salary: Integer
+-----+----+---------+
| name| age| salary|
+-----+----+---------+
| bob| 33| 45000|
| jeff| 44| 78000|
| mary| 40| 67000|
| ...| ...| ...|
+-----+----+---------+
DataFrame 可以从许多不同的源创建,如 Hive 表、结构化数据文件(SDF)、外部数据库或现有的 RDD。DataFrames API 专为现代大数据和数据科学应用程序设计,灵感来自 R 中的 DataFrames 和 Python 中的 pandas。正如我们将在后面的章节中看到的,我们可以对 DataFrames 执行 SQL 查询。
Spark SQL 提供了一套强大的 DataFrame 操作,包括:
-
聚合函数(最小值、最大值、总和、平均值等)
-
集合函数
-
数学函数
-
排序函数
-
字符串函数
-
用户定义的函数(UDFs)
例如,你可以轻松地读取 CSV 文件,并从中创建一个 DataFrame:
# define input path
virus_input_path = "s3://mybucket/projects/cases/case.csv"
# read CSV file and create a DataFrame
cases_dataframe = spark.read.load(virus_input_path,format="csv",
sep=",", inferSchema="true", header="true")
# show the first 3 rows of created DataFrame
cases_dataframe.show(3)
+-------+-------+-----------+--------------+---------+
|case_id|country| city|infection_case|confirmed|
+-------+-------+-----------+--------------+---------+
| C0001| USA| New York| contact| 175|
+-------+-------+-----------+--------------+---------+
| C0008| USA| New Jersey| unknown| 25|
+-------+-------+-----------+--------------+---------+
| C0009| USA| Cupertino| contact| 100|
+-------+-------+-----------+--------------+---------+
要按病例数量降序排序结果,我们可以使用sort()函数:
# We can do this using the F.desc function:
from pyspark.sql import functions as F
cases_dataframe.sort(F.desc("confirmed")).show()
+-------+-------+-----------+--------------+---------+
|case_id|country| city|infection_case|confirmed|
+-------+-------+-----------+--------------+---------+
| C0001| USA| New York| contact| 175|
+-------+-------+-----------+--------------+---------+
| C0009| USA| Cupertino| contact| 100|
+-------+-------+-----------+--------------+---------+
| C0008| USA| New Jersey| unknown| 25|
+-------+-------+-----------+--------------+---------+
我们也可以轻松地过滤行:
cases_dataframe.filter((cases_dataframe.confirmed > 100) &
(cases_dataframe.country == 'USA')).show()
+-------+-------+-----------+--------------+---------+
|case_id|country| city|infection_case|confirmed|
+-------+-------+-----------+--------------+---------+
| C0001| USA| New York| contact| 175|
+-------+-------+-----------+--------------+---------+
...
为了让你更好地理解 Spark 的 DataFrames 的强大,让我们通过一个例子来详细说明。我们将创建一个 DataFrame,并查找每个部门员工工作小时数的平均值和总和:
# Import required libraries
from pyspark.sql import SparkSession
from pyspark.sql.functions import avg, sum
# Create a DataFrame using SparkSession
spark = SparkSession.builder.appName("demo").getOrCreate()
dept_emps = [("Sales", "Barb", 40), ("Sales", "Dan", 20),
("IT", "Alex", 22), ("IT", "Jane", 24),
("HR", "Alex", 20), ("HR", "Mary", 30)]
df = spark.createDataFrame(dept_emps, ["dept", "name", "hours"])
# Group the same depts together, aggregate their hours, and compute an average
averages = df.groupBy("dept")
.agg(avg("hours").alias('average'),
sum("hours").alias('total'))
# Show the results of the final execution
averages.show()
+-----+--------+------+
| dept| average| total|
+-----+--------+------+
|Sales| 30.0| 60.0|
| IT| 23.0| 46.0|
| HR| 25.0| 50.0|
+-----+--------+------+
正如你所见,Spark 的 DataFrames 足以使用简单但强大的函数操作数十亿行数据。
使用 PySpark Shell
你可以使用 PySpark 的两种主要方式:
-
使用 PySpark shell(用于测试和交互式编程)。
-
在独立应用程序中使用 PySpark。在这种情况下,你会使用 PySpark API 编写一个 Python 驱动程序(比如my_pyspark_program.py),然后使用
spark-submit命令运行它:export SUBMIT=$SPARK_HOME/bin/spark-submit $SUBMIT [options] my_pyspark_program.py *<parameters>*这里
<parameters>是你的 PySpark 程序(my_pyspark_program.py)消耗的参数列表。
注意
有关使用spark-submit命令的详细信息,请参阅 Spark 文档中的“提交应用程序”。
在本节中,我们将专注于 Python 用户的 Spark 交互式 shell,这是一个强大的工具,可以用于交互式分析数据并立即查看结果(Spark 还提供 Scala shell)。PySpark shell 可以在单机安装和集群安装的 Spark 上运行。您可以使用以下命令启动 shell,其中SPARK_HOME表示您系统上 Spark 的安装目录:
export SPARK_HOME=*<spark-installation-directory>*
$SPARK_HOME/bin/pyspark
例如:
export SPARK_HOME="/home/spark" 
$SPARK_HOME/bin/pyspark 
Python 3.7.2
Welcome to Spark version 3.1.2
Using Python version 3.7.2
SparkSession available as *spark*.
SparkContext available as *sc*
>>>
定义 Spark 安装目录。
调用 PySpark shell。
当您启动 shell 时,PySpark 会显示一些有用的信息,包括正在使用的 Python 和 Spark 版本(请注意,此处的输出已经被缩短)。>>>符号用作 PySpark shell 提示符。此提示符表示您现在可以编写 Python 或 PySpark 命令并查看结果。
为了让您熟悉 PySpark shell,接下来的几节将引导您完成一些基本使用示例。
启动 PySpark Shell。
要进入 PySpark shell,我们执行以下命令:pyspark。
$SPARK_HOME/bin/pyspark 
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/__ / .__/\_,_/_/ /_/\_\ version 3.1.2
/_/
SparkSession available as 'spark'.
SparkContext available as 'sc'.
>>> sc.version 
'3.1.2'
>>> spark.version 
'3.1.2'
执行pyspark将创建一个新的 shell。这里的输出已经被缩短。
确认SparkContext已创建为sc。
确认SparkSession已创建为spark。
一旦进入 PySpark shell,将创建SparkSession实例作为spark变量,并创建SparkContext实例作为sc变量。如您在本章早些时候学到的,SparkSession是使用 Dataset 和 DataFrame API 编程 Spark 的入口点;SparkSession可用于创建 DataFrame,将 DataFrame 注册为表,对表执行 SQL,缓存表,并读取 CSV、JSON 和 Parquet 文件。如果要在独立应用程序中使用 PySpark,则必须使用生成器模式显式创建SparkSession,如"Spark architecture in a nutshell"所示。SparkContext是 Spark 功能的主要入口点;它可用于从文本文件和 Python 集合创建 RDD。接下来我们会详细讨论这一点。
从集合创建 RDD。
Spark 使我们能够从文件和集合(如数组和列表等数据结构)创建新的 RDD。在这里,我们使用SparkContext.parallelize()从集合(表示为data)创建一个新的 RDD:
>>> data = ![1
("fox", 6), ("dog", 5), ("fox", 3), ("dog", 8),
("cat", 1), ("cat", 2), ("cat", 3), ("cat", 4)
]
>>># use SparkContext (sc) as given by the PySpark shell
>>># create an RDD as rdd
>>> rdd = sc.parallelize(data) 
>>> rdd.collect() 
[
('fox', 6), ('dog', 5), ('fox', 3), ('dog', 8),
('cat', 1), ('cat', 2), ('cat', 3), ('cat', 4)
]
>>> rdd.count() 
8
定义您的 Python 集合。
从 Python 集合创建新的 RDD。
显示新 RDD 的内容。
计算 RDD 中元素的数量。
聚合和合并键的值
reduceByKey()转换用于使用可结合和可交换的减少函数合并和聚合值。在此示例中,x和y指的是同一键的值:
>>> sum_per_key = rdd.reduceByKey(lambda x, y : x+y) 
>>> sum_per_key.collect() 
[
('fox', 9),
('dog', 13),
('cat', 10)
]
合并和聚合相同键的值。
收集 RDD 的元素。
此转换的源 RDD 必须由(键,值)对组成。reduceByKey()使用可结合和可交换的减少函数为每个键合并值。这也会在每个 mapper 上本地执行合并,然后将结果发送到 reducer,类似于 MapReduce 中的“combiner”。输出将使用numPartitions分区或未指定numPartitions时的默认并行级别进行分区。默认分区器是HashPartitioner。
如果T是(键,值)对值的类型,则可以将reduceByKey()的func()定义为:
# source_rdd : RDD[(K, T)]
# target_rdd : RDD[(K, T)]
target_rdd = source_rdd.reduceByKey(lambda x, y: func(x, y))
# OR you may write it by passing the function name
# target_rdd = source_rdd.reduceByKey(func)
# where
# func(T, T) -> T
# Then you may define `func()` in Python as:
# x: type of T
# y: type of T
def func(x, y):
result = *`<``aggregation` `of` `x` `and` `y``:` `return` `a` `result` `of` `type` `T``>`*
return result
#end-def
这意味着:
-
函数
func()有两个相同类型的输入参数T。 -
func()的返回类型必须与输入类型T相同(如果使用combineByKey()转换,则可以避免此限制)。 -
Reducer
func()必须是可结合的。非正式地说,集合T上的二元操作f()称为可结合的,如果它满足结合律,即分组数字的顺序不改变操作的结果。结合律
f(f(x, y), z) = f(x, f(y, z))注意,加法(+)和乘法(*)遵循结合律,但减法(-)或除法(/)不遵循。
-
Reducer
func()必须是可交换的:非正式地说,对于所有值x和y,函数f()满足f(x, y) = f(y, x)。也就是说,数字的顺序变化不应影响操作的结果。交换律
f(x, y) = f(y, x)加法和乘法也遵循交换律,但减法或除法不遵循。例如:
5 + 3 = 3 + 5,但是 5 - 3 ≠ 3 - 5
因此,在reduceByKey()转换中不得使用减法或除法操作。
过滤 RDD 的元素
接下来,我们将使用filter()转换返回一个新的 RDD,其中仅包含满足谓词的元素:
>>> sum_filtered = sum_per_key.filter(lambda x : x[1] > 9) 
>>> sum_filtered.collect() 
[
('cat', 10),
('dog', 13)
]
如果值大于 9,则保留(键,值)对。
收集 RDD 的元素。
分组相似键
我们可以使用groupByKey()转换将 RDD 中每个键的值分组为单个序列:
>>> grouped = rdd.groupByKey() 
>>> grouped.collect() 
('fox', <ResultIterable object at 0x10f45c790>), ![3
('dog', <ResultIterable object at 0x10f45c810>),
('cat', <ResultIterable object at 0x10f45cd90>)
]
>>>
>>># list(v) converts v as a ResultIterable into a list
>>> grouped.map(lambda (k,v) : (k, list(v))).collect() 
[
('fox', [6, 3]),
('dog', [5, 8]),
('cat', [1, 2, 3, 4])
]
将相同键的元素分组成元素序列。
查看结果。
ResultIterable 的全名是 pyspark.resultiterable.ResultIterable。
先应用 map() 然后是 collect(),它返回一个包含结果 RDD 中所有元素的列表。list() 函数将 ResultIterable 转换为对象列表。
此转换的源 RDD 必须由(键,值)对组成。groupByKey() 将 RDD 中每个键的值分组为单个序列,并使用 numPartitions 分区进行哈希分区生成结果 RDD,或者如果未指定 numPartitions,则使用默认并行度。请注意,如果您正在分组(使用 groupByKey() 转换)以执行诸如求和或平均值之类的聚合操作,使用 reduceByKey() 或 aggregateByKey() 将提供更好的性能。
聚合相似键的值
要对每个键的值进行聚合和求和,我们可以使用 mapValues() 转换和 sum() 函数:
>>> aggregated = grouped.mapValues(lambda values : sum(values)) 
>>> aggregated.collect() 
[
('fox', 9),
('dog', 13),
('cat', 10)
]
values 是每个键的值序列。我们通过映射函数将每个(键,值)对 RDD 中的所有值相加(使用 sum(values)),而不改变键。
为了调试,我们返回一个包含此 RDD 中所有元素的列表。
我们有几种选择来聚合和汇总值:reduceByKey() 和 groupByKey(),仅举几例。一般而言,reduceByKey() 转换比 groupByKey() 转换更有效率。有关更多详细信息,请参阅第四章。
正如您将在接下来的章节中看到的,Spark 还有许多其他强大的转换功能,可以将一个 RDD 转换为新的 RDD。正如前面提到的,RDD 是只读的、不可变的,并且是分布式的。RDD 转换返回一个指向新 RDD 的指针,并允许您在 RDD 之间创建依赖关系。依赖链中的每个 RDD(或依赖链的字符串)都有一个用于计算其数据的函数和指向其父 RDD 的指针(依赖)。
PySpark 的数据分析工具
Jupyter 是一个测试和原型化程序的好工具。PySpark 也可以从 Jupyter 笔记本中使用;它非常适合探索性数据分析。
Zeppelin 是一个基于 Web 的笔记本,支持使用 SQL、Python、Scala 等进行数据驱动的交互式数据分析和协作文档。
使用数据框架的 ETL 示例
在数据分析和计算中,ETL 是从一个或多个源复制数据到一个目标系统的一般过程,该系统以与源数据不同的方式或在不同的上下文中表示数据。在这里我将展示 Spark 如何实现 ETL 并使其变得简单。
对于这个 ETL 示例,我将使用 JSON 格式的 2010 年人口普查数据(census_2010.json):
$ wc -l census_2010.json
101 census_2010.json
$ head -5 census_2010.json
{"females": 1994141, "males": 2085528, "age": 0, "year": 2010}
{"females": 1997991, "males": 2087350, "age": 1, "year": 2010}
{"females": 2000746, "males": 2088549, "age": 2, "year": 2010}
{"females": 2002756, "males": 2089465, "age": 3, "year": 2010}
{"females": 2004366, "males": 2090436, "age": 4, "year": 2010}
注意
这些数据是从美国人口调查局数据中提取的,该数据在编写本书时仅提供男性和女性的二元选项。我们努力尽可能包容,并希望在未来,诸如此类的国家数据集将提供更多包容性选项。
让我们定义我们的 ETL 过程:
提取
首先,我们从给定的 JSON 文档创建一个 DataFrame。
转换
然后,我们过滤数据并保留老年人(age > 54)的记录。接下来,我们添加一个新列,total,即男性和女性的总数。
加载
最后,我们将修订后的 DataFrame 写入 MySQL 数据库并验证加载过程。
让我们更深入地了解这个过程。
提取
要进行正确的提取,我们首先需要创建一个SparkSession类的实例:
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.master("local") \
.appName("ETL") \
.getOrCreate()
接下来,我们读取 JSON 并创建一个 DataFrame:
>>> input_path = "census_2010.json"
>>> census_df = spark.read.json(input_path)
>>> census_df.count()
101
>>> census_df.show(200)
+---+-------+-------+----+
|age|females| males|year|
+---+-------+-------+----+
| 0|1994141|2085528|2010|
| 1|1997991|2087350|2010|
| 2|2000746|2088549|2010|
...
| 54|2221350|2121536|2010|
| 55|2167706|2059204|2010|
| 56|2106460|1989505|2010|
...
| 98| 35778| 8321|2010|
| 99| 25673| 4612|2010|
+---+-------+-------+----+
only showing top 100 rows
转换
转换可能涉及许多过程,其目的是根据您的要求清理、格式化或对数据执行计算。例如,您可以删除缺失或重复的数据,连接列以创建新列,或过滤某些行或列。一旦通过提取过程创建了 DataFrame,我们就可以执行许多有用的转换,比如仅选择老年人:
>>> seniors = census_df[census_df['age'] > 54]
>>> seniors.count()
46
>>> seniors.show(200)
+---+-------+-------+----+
|age|females| males|year|
+---+-------+-------+----+
| 55|2167706|2059204|2010|
| 56|2106460|1989505|2010|
| 57|2048896|1924113|2010|
...
| 98| 35778| 8321|2010|
| 99| 25673| 4612|2010|
|100| 51007| 9506|2010|
+---+-------+-------+----+
接下来,我们创建一个名为total的新聚合列,它将男性和女性的数量相加:
>>> from pyspark.sql.functions import lit
>>> seniors_final = seniors.withColumn('total',
lit(seniors.males + seniors.females))
>>> seniors_final.show(200)
+---+-------+-------+----+-------+
|age|females| males|year| total|
+---+-------+-------+----+-------+
| 55|2167706|2059204|2010|4226910|
| 56|2106460|1989505|2010|4095965|
| 57|2048896|1924113|2010|3973009|
...
| 98| 35778| 8321|2010| 44099|
| 99| 25673| 4612|2010| 30285|
|100| 51007| 9506|2010| 60513|
+---+-------+-------+----+-------+
加载
加载过程涉及保存或写入转换步骤的最终输出。在这里,我们将seniors_final DataFrame 写入一个 MySQL 表中:
seniors_final\
.write\
.format("jdbc")\
.option("driver", "com.mysql.jdbc.Driver")\
.mode("overwrite")\
.option("url", "jdbc:mysql://localhost/testdb")\
.option("dbtable", "seniors")\
.option("user", "root")\
.option("password", "root_password")\
.save()
加载的最后一步是验证加载过程:
$ `mysql` `-uroot` `-p`
Enter password: *`<``password>`*
Your MySQL connection id is 9
Server version: 5.7.30 MySQL Community Server (GPL)
mysql> use testdb;
Database changed
mysql> select * from seniors;
+------+---------+---------+------+---------+
| age | females | males | year | total |
+------+---------+---------+------+---------+
| 55 | 2167706 | 2059204 | 2010 | 4226910 |
| 56 | 2106460 | 1989505 | 2010 | 4095965 |
| 57 | 2048896 | 1924113 | 2010 | 3973009 |
...
| 98 | 35778 | 8321 | 2010 | 44099 |
| 99 | 25673 | 4612 | 2010 | 30285 |
| 100 | 51007 | 9506 | 2010 | 60513 |
+------+---------+---------+------+---------+
46 rows in set (0.00 sec)
摘要
让我们回顾一下本章的一些关键点:
-
Spark 是一个快速而强大的统一分析引擎(比传统的 Hadoop MapReduce 快高达一百倍),由于其内存操作,并且它提供了健壮、分布式、容错的数据抽象(称为 RDDs 和 DataFrames)。Spark 通过 MLlib(机器学习库)和 GraphX(图库)包集成到机器学习和图分析领域。
-
您可以在四种编程语言(Java、Scala、R 和 Python)中使用 Spark 的转换和操作。PySpark(Spark 的 Python API)可用于解决大数据问题,有效地将您的数据转换为所需的结果和格式。
-
大数据可以使用 Spark 的数据抽象(RDDs、DataFrames 和 Datasets——所有这些都是分布式数据集)来表示。
-
您可以从 PySpark shell 运行 PySpark(使用命令行的
pyspark命令进行交互式 Spark 编程)。使用 PySpark shell,您可以创建和操作 RDDs 和 DataFrames。 -
您可以使用
spark-submit命令将独立的 PySpark 应用程序提交到 Spark 集群;使用 PySpark 开发的自包含应用程序可部署到生产环境。 -
Spark 提供了许多转换和操作,用于解决大数据问题,它们的性能有所不同(例如,
reduceByKey()和groupByKey()以及combineByKey()和groupByKey())。
下一章将深入介绍一些重要的 Spark 转换。
第二章:转换实战
在本章中,我们将探讨最重要的 Spark 转换(映射器和减少器),在数据总结设计模式的背景下,并分析如何选择特定的转换来解决目标问题。
正如您将看到的,对于给定的问题(我们将在这里使用 DNA 碱基计数问题),可以使用不同的 Spark 转换来实现多种可能的 PySpark 解决方案,但这些转换的效率因其实现和洗牌过程(键的分组发生时)而异。DNA 碱基计数问题与经典的单词计数问题非常相似(在一组文件/文档中找到唯一单词的频率),其区别在于在 DNA 碱基计数中,您会找到 DNA 字母(A,T,C,G)的频率。
我选择这个问题是因为在解决它时,我们将了解数据总结,将大量信息(这里是 DNA 数据字符串/序列)压缩成更小的一组有用信息(DNA 字母的频率)。
本章提供了三种完整的 PySpark 端到端解决方案,使用不同的映射器和减少器来解决 DNA 碱基计数问题。我们将讨论它们之间的性能差异,并探讨数据总结设计模式。
DNA 碱基计数示例
本章节中我们的示例目的是计算一组 DNA 字符串/序列中的 DNA 碱基数。不用担心,您不需要成为 DNA、生物学或基因组学的专家来理解这个例子。我会涵盖基础知识,这应该足以让您理解。
人类 DNA 由大约 30 亿个碱基组成,超过 99%的碱基在所有人中都是相同的。要理解 DNA 碱基计数,我们首先需要理解 DNA 字符串。DNA 字符串由字母 {A, C, G, T} 组成,其符号代表腺嘌呤(A)、胞嘧啶(C)、鸟嘌呤(G)和胸腺嘧啶(T)的碱基。我们的 DNA 由一组 DNA 字符串组成。我们想要回答的问题是在一组 DNA 字符串中每个碱基字母出现的次数。例如,如果我们有 DNA 字符串 "AAATGGCATTA" 并询问在这个字符串中碱基 A 出现的次数,答案是 5;如果我们询问在这个字符串中碱基 T 出现的次数,答案是 3。因此,我们要计算每个碱基字母的出现次数,忽略大小写。由于 DNA 机器可能产生大写和小写字母,我们将把它们全部转换为小写。
对于这个问题,我将提供三种不同的解决方案,使用不同组合的强大和高效的 Spark 转换。尽管所有解决方案都生成相同的结果,但由于使用的转换不同,它们的性能将有所不同。
图 2-1 说明了使用 Spark 解决 DNA 碱基计数问题的过程。对于每个解决方案,我们将使用 PySpark API 编写一个驱动程序(一系列 Spark 转换和操作),并将程序提交到一个 Spark 集群。所有的解决方案都将读取输入(FASTA 文件格式,稍后定义)并生成一个字典,其中键是 DNA 字母,值是相关的频率。
这三种解决方案将展示我们在选择解决此问题的 Spark 转换时(以及你尝试解决的任何数据问题时)有多种选择,并且不同转换的性能也会有所不同。关于三种 PySpark 解决方案的摘要在表 2-1 中提供。

图 2-1. 解决 DNA 碱基计数问题
表 2-1. DNA 碱基计数问题的解决方案
| 解决方案 1 | 解决方案 2 | 解决方案 3 | |
|---|---|---|---|
| 程序 | dna_bc_ver_1.py | dna_bc_ver_2.py | dna_bc_ver_3.py |
| 设计模式 | 基本 MapReduce | In-mapper combiner | Mapping partitions |
| 转换 | textFile() |
textFile() |
textFile() |
flatMap() |
flatMap() |
mapPartitions() |
|
reduceByKey() |
reduceByKey() |
reduceByKey() |
如表 2-2 所示,这三个程序在我的机器上表现非常不同(一台配备 16 GB RAM、2.3 GHz 英特尔处理器和 500 GB 硬盘的 MacBook)。注意,我对所有解决方案使用了$SPARK_HOME/bin/spark-submit命令的默认参数;对任何解决方案都没有进行优化。
表 2-2. 三种解决方案的性能
| 输入数据(以字节为单位) | 版本 1 | 版本 2 | 版本 3 |
|---|---|---|---|
| 253,935,557 | 72 秒 | 27 秒 | 18 秒 |
| 1,095,573,358 | 258 秒 | 79 秒 | 57 秒 |
这个基本性能表告诉你什么?当你编写 PySpark 应用程序时,你有很多选择。没有硬性规则适用于使用哪些转换或操作;这取决于你的数据和程序的具体情况。一般来说,当你编写 PySpark 应用程序时,你可以从各种转换和操作的排列中选择,它们会产生相同的结果。然而,并不是所有这些排列都会导致相同的性能:避免常见的陷阱并选择正确的组合可以在应用程序的性能上产生天壤之别。
例如,对于一个大型的(key, value)对集合,通常使用reduceByKey()或combineByKey()比使用groupByKey()和mapValues()的组合更高效,因为它们减少了洗牌时间。如果你的 RDD(由变量rdd表示)是一个RDD[(String, Integer)](每个元素都是一个(key-as-String, value-as-Integer)对),那么这样做:
# rdd: RDD[(String, Integer)]
rdd.groupByKey().mapValues(lambda values : sum(values))
将产生与此相同的结果:
# rdd: RDD[(String, Integer)]
rdd.reduceByKey(lambda x,y: x+y)
然而,groupByKey() 操作将整个数据集传输到集群网络上(导致性能损失很大),而 reduceByKey() 操作将在每个分区中计算每个键的本地总和,并在洗牌后将这些本地总和组合成较大的总和。因此,在大多数情况下,reduceByKey() 将比 groupByKey() 和 mapValues() 的组合传输更少的数据到集群网络上,这意味着 reduceByKey() 在性能上将表现更好。
现在,让我们更详细地讨论一下我们的 DNA 碱基计数问题。
DNA 碱基计数问题
本示例的目标是找出给定一组 DNA 序列中字母 A、T、C、G 和 N(字母 N 表示除了 A、T、C 或 G 之外的任何字母——即一个错误)的频率(或百分比)。正如我之前提到的,{'A', 'T', 'C', 'G'} 代表与 DNA 相关的四个含氮碱基。
DNA 序列可能非常庞大——例如,人类基因组由三十亿个 DNA 碱基对组成,而二倍体基因组(存在于体细胞中)则具有两倍的 DNA 含量——并且可以包含大小写字母。为了保持一致性,我们将所有字母转换为小写。我们示例中 DNA 碱基计数的目标是为每个 DNA 碱基生成频率。表 2-3 展示了示例序列 "ACGGGTACGAAT" 的结果。注意,我正在使用键 z 来找出处理的 DNA 序列的总数。
表 2-3. DNA 碱基计数示例
| Base | Count |
|---|---|
a |
4 |
t |
2 |
c |
2 |
g |
4 |
n |
0 |
z |
1(DNA 序列的总数) |
FASTA 格式
DNA 序列可以用许多不同的格式表示,包括FASTA和 FASTQ。这些是流行的基于文本的格式,其中输入是作为文本文件提供的。我们的解决方案仅处理 FASTA 格式,因为读取 FASTA 文件要容易得多。FASTA 和 FASTQ 格式都存储序列数据和序列元数据。通过对呈现的解决方案进行一些小修改,您可以将其用于 FASTQ 格式的输入;有一个 FASTQ 解决方案在GitHub上提供。
FASTA 格式的序列文件可以包含许多 DNA 序列。每个序列都以单行描述开始,后跟一个或多个序列数据行。根据 FASTA 格式规范,描述行必须以大于号(>)开头的第一列开始。请注意,描述行可用于计算序列的数量,而不包含任何 DNA 序列数据。
样本数据
我们将使用书籍的GitHub 存储库中的sample.fasta文件作为我们 PySpark 程序的测试案例。这个小的 FASTA 文件包含四个样本 DNA 序列(请记住,字符的大小写是无关紧要的):
$ cat sample.fasta
>seq1
cGTAaccaataaaaaaacaagcttaacctaattc
>seq2
agcttagTTTGGatctggccgggg
>seq3
gcggatttactcCCCCCAAAAANNaggggagagcccagataaatggagtctgtgcgtccaca
gaattcgcacca
AATAAAACCTCACCCAT
agagcccagaatttactcCCC
>seq4
gcggatttactcaggggagagcccagGGataaatggagtctgtgcgtccaca
gaattcgcacca
要测试本章提供的 DNA 碱基计数程序与更大文件,请从 加州大学圣塔克鲁兹分校网站 下载 FASTA 数据。
接下来,我们将介绍三种不同的 PySpark 解决方案,用于 DNA 碱基计数问题,使用不同的 Spark 转换。请记住,尽管所有解决方案的结果相同(它们产生相同的结果),但由于数据的性质和使用的转换方式不同,每个解决方案的性能也会有所不同。
DNA 碱基计数解决方案 1
我将首先介绍的版本是 DNA 碱基计数问题的非常基本的解决方案。高级工作流程显示在 图 2-2 中。

图 2-2. DNA 碱基计数解决方案
它由三个简单步骤组成:
-
读取 FASTA 输入数据并创建一个
RDD[String],其中每个 RDD 元素都是一个 FASTA 记录(可以是注释行或实际的 DNA 序列)。 -
定义一个映射函数:对于 FASTA 记录中的每个 DNA 字母,发出一对
(dna_letter, 1),其中dna_letter在{A, T, C, G}中,1是频率(类似于单词计数解决方案)。 -
汇总所有 DNA 字母的频率(这是一个归约步骤)。对于每个唯一的
dna_letter,分组并添加所有频率。
要测试这个解决方案,我将使用前面提到的 sample.fasta 文件。
第 1 步:从输入创建一个 RDD[String]
使用 SparkContext.textFile() 函数创建一个输入格式为 FASTA 文本的 RDD[String]。textFile() 可以用于从 HDFS、Amazon S3、本地文件系统(所有 Spark 节点都可用)或任何支持 Hadoop 文件系统 URI 的文件中读取文本文件,并将其作为 RDD[String] 返回。如果 spark 是 SparkSession 类的实例,则要创建一个 FASTA 记录的 RDD(如 records_rdd 所示),我们至少有两个选项。我们可以使用 SparkSession:
>>># spark: instance of SparkSession
>>> input_path = "./code/chap02/sample.fasta" 
>>> records_rdd = spark.read
.text(input_path)
.rdd.map(lambda r: r[0]) 
定义输入路径。
使用 DataFrameReader 接口(通过 spark.read 访问)创建一个 DataFrame,然后将其转换为 RDD[String]。
DataFrameReader 和 DataFrameWriter
DataFrameReader 类是一个接口,用于从外部数据源(如文本、CSV 和 JSON 文件、Parquet 和 ORC 文件、Hive 表或符合 Java 数据库连接(JDBC)的数据库表)读取数据到 DataFrame 中。其 DataFrameWriter 类是一个接口,用于将 DataFrame 写入外部数据源。
或者我们可以使用 SparkContext:
>>> input_path = "./code/chap02/sample.fasta" 
>>># Let 'spark' be an instance of SparkSession
>>> sc = spark.sparkContext 
>>> records_rdd = sc.textFile(input_path) 
定义输入路径。
创建 SparkContext 的一个实例(作为 sc)。
使用 SparkContext 读取输入并创建 RDD[String]。
第二个选项更可取,因为它简单且高效。第一个选项也可以使用,但效率较低,因为它首先创建一个 DataFrame,然后将其转换为 RDD,最后执行另一个映射器转换。
接下来,我们将检查创建的 RDD 的内容。每个 RDD 元素(作为String)由u'*<element>*'表示:
>>> records_rdd.collect()
[
u'>seq1',
u'cGTAaccaataaaaaaacaagcttaacctaattc',
u'>seq2',
u'agcttagTTTGGatctggccgggg',
u'>seq3',
u'gcggatttactcCCCCCAAAAANNaggggagagcccagataaatggagtctgtgcgtccaca',
u'gaattcgcacca',
u'AATAAAACCTCACCCAT',
u'agagcccagaatttactcCCC',
u'>seq4',
u'gcggatttactcaggggagagcccagGGataaatggagtctgtgcgtccaca',
u'gaattcgcacca'
]
提示
此处使用RDD.collect()方法获取内容作为String对象列表并显示它。如第一章所述,对于大型 RDD,不应使用collect(),这可能导致 OOM 错误,并带来性能损失。要仅查看 RDD 的前N个元素,可以使用RDD.take(*N*)。
步骤 2:定义映射函数
要将 RDD 元素映射为一组(dna_letter, 1)对,我们需要定义一个 Python 函数,该函数将传递给flatMap()转换。flatMap()是一种一对多的转换方式;它通过首先对源 RDD 的所有元素应用函数,然后展平结果来返回一个新的 RDD。例如,如果我们传递给flatMap()转换的 Python 函数返回一个列表,如[V[1], V[2], V[3]],那么这将被展平为三个目标 RDD 元素,V[1]、V[2]和V[3]。非正式地说,我们可以将其写成:
-
创建一个可迭代列表:
single_RDD_element() -> [V1, V2, V3] -
将列表展平为多个元素(此处为三个目标元素):
[V1, V2, V3] -> V1, V2, V3
对于此解决方案,我们将定义一个名为process_FASTA_record()的函数,该函数接受一个 RDD 元素(FASTA 文件的单个记录作为String)并返回一个(dna_letter, 1)对的列表。例如,给定输入记录"AATTG",它将发出以下(key, value)对(请记住,我们将所有 DNA 字母转换为小写):
(a, 1)
(a, 1)
(t, 1)
(t, 1)
(g, 1)
如果输入是描述记录(不包含序列数据并以>seq开头),则我们发出(z, 1)。这将使我们能够找到序列的数量。如果输入是 DNA 序列,我们首先按字符令牌化它,然后对每个 DNA 字母(由dna_letter表示)发出(dna_letter, 1)。最后,我们返回这些对的列表。函数定义如下。请注意,我包含了一些用于调试目的的print语句,但在生产环境中,这些应该删除,因为它们会导致性能损失:
# Parameter: fasta_record: String (a single FASTA record)
#
# Output: a list of (key, value) pairs, where key
# is a dna_letter and value is a frequency
#
def process_FASTA_record(fasta_record):
key_value_list = [] 
if (fasta_record.startswith(">")):
# z counts the number of FASTA sequences
key_value_list.append((z, 1)) 
else:
chars = fasta_record.lower()
for c in chars:
key_value_list.append((c, 1)) 
print(key_value_list) 
return key_value_list 
#end-def
创建一个空列表,我们将向其添加(key, value)对(这是此函数的输出)。
将(z, 1)添加到列表中。
将(c, 1)添加到列表中,其中c是一个 DNA 字母。
仅供调试目的。
返回一个(key, value)对的列表,这将由flatMap()转换展平。
现在,我们将使用此函数将flatMap()转换应用于刚刚创建的records_rdd(RDD[String])。
>>># rec refers to an element of records_rdd
>>># Lambda is a notation that defines input and output
>>># input: "rec" as a records_rdd element 
>>># output: result of process_FASTA_record(rec)
>>> pairs_rdd = records_rdd.flatMap(lambda rec: process_FASTA_record(rec)) 
源 RDD(records_rdd)是一个RDD[String]。
我们使用 lambda 表达式,其中rec表示records_rdd的单个元素。目标 RDD(pairs_rdd)是一个RDD[(String, Integer)]。
或者,我们可以按以下方式编写(不使用 lambda 表达式):
>>> pairs_rdd = records_rdd.flatMap(process_FASTA_record)
例如,如果records_rdd的元素包含 DNA 序列"gaattcg",那么它将被展开为以下的(键,值)对:
(g, 1)
(a, 1)
(a, 1)
(t, 1)
(t, 1)
(c, 1)
(g, 1)
如果records_rdd的元素包含>seq,那么它将被展开为以下的(键,值)对(请记住我们使用键z来找到给定输入的 DNA 序列的总数):
(z, 1)
步骤 3:查找 DNA 字母的频率
pairs_rdd现在包含一组(键,值)对,其中键是 DNA 字母,值是其频率(1)。接下来,我们将reduceByKey()转换应用于pairs_rdd以找到所有 DNA 字母的聚合频率。
reduceByKey()转换使用可结合和可交换的减少函数合并每个唯一键的值。因此,我们现在可以看到,我们只是为给定键获取了一个累积值,并将其与该键的下一个值相加。换句话说,如果键K在 RDD 中有五对,(K, 2),(K, 3),(K, 6),(K, 7)和(K, 8),那么reduceByKey()转换将这五对转换为一对,(K, 26)(因为 2 + 3 + 6 + 7 + 8 = 26)。如果这五对存储在两个分区上,则每个分区将并行和独立地处理:
Partition-1: {
(K, 2),
(K, 3)
}
(K, 2), (K, 3) => (K, 2+3) = (K, 5)
Result of Partition-1: (K, 5)
Partition-2: {
(K, 6),
(K, 7),
(K, 8)
}
(K, 6), (K, 7) => (K, 6+7) = (K, 13)
(K, 8), (K, 13) => (K, 8+13) = (K, 21)
Result of Partition-2: (K, 21)
然后将合并分区:
Merge Partitions:
=> Partition-1, Partition-2
=> (K,5), (K, 21)
=> (K, 5+21) = (K, 26)
Final result: (K, 26)
要生成最终结果,我们使用reduceByKey()转换:
# x and y refer to the frequencies of the same key
# source: pairs_rdd: RDD[(String, Integer)]
# target: frequencies_rdd: RDD[(String, Integer)]
frequencies_rdd = pairs_rdd.reduceByKey(lambda x, y: x+y)
请注意,reduceByKey()的源和目标数据类型是相同的。也就是说,如果源 RDD 是RDD[(K, V)],那么目标 RDD 也将是RDD[(K, V)]。Spark 的combineByKey()转换对reduceByKey()所强加的值的数据类型限制并不适用。
您可以通过使用RDD.collect()函数将最终 RDD 的元素作为一组对获取最终输出的几种方法:
frequencies_rdd.collect()
[
(u'a', 73),
(u'c', 61),
(u't', 45),
(u'g', 53),
(u'n', 2),
(u'z', 4)
]
或者,您可以使用RDD.collectAsMap()操作将结果作为哈希映射返回:
>>> frequencies_rdd.collectAsMap()
{
u'a': 73,
u'c': 61,
u't': 45,
u'g': 53,
u'n': 2,
u'z': 4
}
您还可以使用其他 Spark 转换来聚合 DNA 字母的频率。例如,您可以通过 DNA 字母对其频率进行分组(使用groupByKey())然后将所有频率相加。但是,这种解决方案比使用reduceByKey()转换效率低:
grouped_rdd = pairs_rdd.groupByKey() 
frequencies_rdd = grouped_rdd.mapValues(lambda values : sum(values)) 
frequencies_rdd.collect()
grouped_rdd是一个RDD[(String, [Integer])],其中键是一个String,值是一个整数列表/可迭代对象(作为频率)。
frequencies_rdd 是一个 RDD[(String, Integer)]。
例如,如果 pairs_rdd 包含四对 ('z', 1),那么 grouped_rdd 将有一个单一的对 ('z', [1, 1, 1, 1])。即,它会对相同的键进行值的分组。虽然 reduceByKey() 和 groupByKey() 这两种转换都能产生正确的答案,但在大型 FASTA 数据集上,reduceByKey() 的效果要好得多。这是因为 Spark 知道可以在每个分区上在数据洗牌之前将具有相同键(DNA 字母)的输出进行组合。Spark 专家建议我们尽可能避免使用 groupByKey(),而是在可能的情况下使用 reduceByKey() 和 combineByKey(),因为它们比 groupByKey() 更适合扩展。
如果你想将创建的 RDD 保存到磁盘,可以使用 RDD.saveAsTextFile(*path*),其中 path 是你的输出目录名称。
解决方案 1 的优缺点
让我们看看这个解决方案的一些优缺点:
优点
-
提供的解决方案可行且简单。它使用最少的代码来完成任务,使用了 Spark 的
map()和reduceByKey()转换。 -
使用
reduceByKey()来减少所有 (key, value) 对,不存在可扩展性问题。此转换将自动在所有工作节点上执行combine()优化(局部聚合)。
缺点
-
这个解决方案会产生大量的 (key, value) 对(每个输入字母一个)。这可能会导致内存问题。如果因为产生了太多 (key, value) 对而出现错误,请尝试调整 RDD 的
StorageLevel。默认情况下,Spark 使用MEMORY_ONLY,但你可以为这个 RDD 设置StorageLevel为MEMORY_AND_DISK。 -
性能并不理想,因为发出大量的 (key, value) 对会对网络造成高负载并延长洗牌时间。当扩展此解决方案时,网络将成为瓶颈。
接下来,我将为 DNA 基数计数问题提出第二个解决方案。
DNA 基数计数 解决方案 2
解决方案 2 是解决方案 1 的改进版。在解决方案 1 中,我们对输入的 DNA 序列中的每个 DNA 字母发出了 (dna_letter, 1) 对。FASTA 序列可能非常长,每个 DNA 字母可能有多个 (dna_letter, 1) 对。因此,在这个版本中,我们将执行一种内部映射器组合优化(在第十章中详细讨论的设计模式),以减少映射器发出的中间 (key, value) 对的数量。我们将把 (dna_letter, 1) 对聚合到一个哈希映射(存储在哈希表中的无序 (key, value) 对集合,其中键是唯一的),然后将哈希映射扁平化为一个列表,并最终聚合频率。例如,给定 FASTA 序列记录 "aaatttcggggaa",表 2-4 的第 2 列中的值将被发出,而不是第 1 列中的值(就像解决方案 1 中一样)。
表 2-4. 序列 "aaatttcggggaa" 的发出的 (key, value) 对
| 解决方案 1 | 解决方案 2 |
|---|---|
(a, 1) |
(a, 5) |
(a, 1) |
(t, 3) |
(a, 1) |
(c, 1) |
(t, 1) |
(g, 4) |
(t, 1) |
|
(t, 1) |
|
(c, 1) |
|
(g, 1) |
|
(g, 1) |
|
(g, 1) |
|
(g, 1) |
|
(a, 1) |
|
(a, 1) |
此解决方案的优点在于它将发出较少的(key, value)对,从而减少集群网络流量,提高程序的整体性能。
解决方案 2 可以总结如下:
-
读取 FASTA 输入数据并创建一个
RDD[String],其中每个 RDD 元素都是一个 FASTA 记录。此步骤与解决方案 1 中的步骤相同。 -
对于每个 FASTA 记录,创建一个
HashMap[Key, Value](字典或哈希表),其中key是一个 DNA 字母,value是该字母的聚合频率。然后,展开哈希映射(使用 Spark 的flatMap())为(key, value)对的列表。此步骤与解决方案 1 不同,并且使我们能够发出较少的(key, value)对。 -
对于每个 DNA 字母,聚合并求出所有频率的总和。这是一个归约步骤,与解决方案 1 中的步骤相同。
工作流程在图 2-3 中以图像方式呈现。

图 2-3. DNA 碱基计数解决方案 2
让我们深入了解每个步骤的详细信息。
第 1 步:从输入创建 RDD[String]。
SparkContext.textFile()函数用于创建基于 FASTA 文本格式的输入的 RDD。让spark成为一个SparkSession对象:
>>># spark: an instance of SparkSession
>>> input_path = "./code/chap02/sample.fasta"
>>> records_rdd = spark.sparkContext.textFile(input_path) 
records_rdd是一个RDD[String]。
第 2 步:定义一个映射函数
接下来,我们将每个 RDD 元素(代表单个 FASTA 记录的字符串)映射为(key, value)对的列表,其中 key 是唯一的 DNA 字母,value 是整个记录的聚合频率。
我们定义了一个 Python 函数,该函数传递给flatMap()转换,以返回一个新的 RDD,首先将函数应用于该 RDD 的所有元素,然后展开结果。
为了处理 RDD 元素,我们将定义一个 Python 函数,process_FASTA_as_hashmap,它接受一个 RDD 元素作为String并返回(dna_letter, frequency)的列表。请注意,我在这里包含了一些用于调试和教学目的的print语句,这些语句应在生产环境中删除:
# Parameter: fasta_record: String, a single FASTA record
# output: a list of (dna_letter, frequency)
#
def process_FASTA_as_hashmap(fasta_record):
if (fasta_record.startswith(">")): 
return [("z", 1)]
hashmap = defaultdict(int) 
chars = fasta_record.lower()
for c in chars: 
hashmap[c] += 1
#end-for
print("hashmap=", hashmap)
key_value_list = [(k, v) for k, v in hashmap.iteritems()] 
print("key_value_list=", key_value_list)
return key_value_list 
#end-def
>表示 DNA 序列中的注释行。
创建一个dict[String, Integer]。
聚合 DNA 字母。
将字典展平为(dna_letter, frequency)对的列表。
返回展平的(dna_letter, frequency)对列表。
现在,我们将使用这个 Python 函数,对之前创建的records_rdd(一个RDD[String])应用flatMap()转换:
>>># source: records_rdd (RDD[String])
>>># target: pairs_rdd (RDD[(String, Integer)])
>>> pairs_rdd = records_rdd.flatMap(lambda rec: process_FASTA_as_hashmap(rec))
或者,我们可以这样写,而不使用 lambda 表达式:
>>># source: records_rdd (as RDD[String])
>>># target: pairs_rdd (as RDD[(String, Integer)])
>>> pairs_rdd = records_rdd.flatMap(process_FASTA_as_hashmap)
例如,如果records_rdd元素包含'gggggaaattccccg',则它将被展开为以下的(键,值)对:
(g, 6)
(a, 3)
(t, 2)
(c, 4)
为了使我们能够计算 DNA 序列的总数,任何以">seq"开头的records_rdd元素将被展开为以下的(键,值)对:
(z, 1)
步骤 3:查找 DNA 字母的频率
现在,pairs_rdd包含了(键,值)对,其中键是dna_letter,值是该字母的频率。接下来,我们对pairs_rdd应用reduceByKey()转换,以找到所有 DNA 字母的聚合频率。请记住,'n'是用来表示除了a、t、c或g之外的任何字母的键:
# x and y refer to the frequencies of the same key
frequencies_rdd = pairs_rdd.reduceByKey(lambda x, y: x+y) 
frequencies_rdd.collect() 
[
(u'a', 73),
(u'c', 61),
(u't', 45),
(u'g', 53),
(u'n', 2),
(u'z', 4)
]
pairs_rdd是一个RDD[(String, Integer)]。
frequencies_rdd是一个RDD[(String, Integer)]。
或者,我们可以使用collectAsMap()操作将结果返回为一个哈希映射:
>>> frequencies_rdd.collectAsMap()
{
u'a': 73,
u'c': 61,
u't': 45,
u'g': 53,
u'n': 2,
u'z': 4
}
解决方案 2 的优缺点
让我们来分析这种解决方案的优缺点:
优点
-
提供的解决方案有效且简单,半高效。它通过发射的(键,值)对数量大大减少——每个 DNA 序列最多只有六个,因为我们为每个输入记录创建一个字典,然后将其展开为(键,值)对列表,其中键是 DNA 字母,值是该字母的聚合频率。
-
由于发出的(键,值)对数量减少,网络流量需求较低。
-
由于我们使用
reduceByKey()来减少所有(键,值)对,因此不存在可扩展性问题。
缺点
-
性能不佳,因为我们仍然会发出每个 DNA 字符串最多六个(键,值)对。
-
对于大型数据集或资源有限的情况,这种解决方案可能仍然会因为每个 DNA 序列创建一个字典而占用过多内存。
DNA 碱基计数解决方案 3
这个最终解决方案改进了版本 1 和 2,并且是一个没有任何可扩展性问题的最佳解决方案。在这里,我们将使用一种名为mapPartitions()的强大且高效的 Spark 转换来解决 DNA 碱基计数问题。在我介绍解决方案本身之前,让我们更仔细地看看这个转换。
mapPartitions() 转换
如果源 RDD 是RDD[T],目标 RDD 是RDD[U],则mapPartitions()转换定义如下:
pyspark.RDD.mapPartitions(f, preservesPartitioning=False)
mapPartitions() is a method in the pyspark.RDD class.
Description:
Return a new RDD (called target RDD) by applying a
function f() to each partition of the source RDD.
Input to f() is an iterator (of type T), which
represents a single partition of the source RDD.
Function f() returns an object of type U.
f: Iterator<T> --> U 
mapPartitions : RDD[T]--f()--> RDD[U] 
函数f()接受一个指向单个分区的指针(作为iterator类型的T)并返回一个类型为U的对象;T和U可以是任何数据类型,它们不必相同。
将RDD[T]转换为RDD[U]。
要理解 mapPartitions() 转换的语义,首先必须理解 Spark 中分区和分区的概念。简单地说,使用 Spark 的术语,输入数据(在本例中为 FASTA 格式的 DNA 序列)表示为 RDD。Spark 自动分区 RDD,并将分区分布在节点上。例如,假设我们有 60 亿条记录,并且 Spark 的分区器将输入数据分为 3,000 个块/分区。每个分区将大约有 2 百万条记录,并且将由单个 mapPartitions() 转换处理。因此,用于 mapPartitions() 转换中的函数 f() 将接受一个迭代器(作为参数),以处理一个分区。
在第三种解决方案中,我们将为每个分区创建一个字典,而不是每个 FASTA 记录创建一个字典,以聚合 DNA 字母及其关联的频率。这比解决方案 1 和 2 要好得多,因为在集群中创建 3,000 个哈希表几乎不会使用任何内存,与为每个输入记录创建字典相比。由于集群中所有分区的并行和独立处理,此解决方案具有高度的可扩展性和速度。
用于解决 DNA 碱基计数问题的 mapPartitions() 转换语义在 图 2-4 中进行了说明。

图 2-4. mapPartitions() 转换
让我们来看看 图 2-4:
-
源 RDD 表示所有输入作为
RDD[String],因为 FASTA 文件的每条记录都是String对象。 -
整个输入被分割成
N个块或分区(其中N可以是100,200,1000,…,根据数据大小和集群资源的情况),每个分区可能包含数千或数百万条 DNA 序列(每个 DNA 序列都是String类型的记录)。源 RDD 的分区类似于 Linux 的split命令,该命令将文件分割成片段。 -
每个分区都会被发送到一个
mapPartitions()的映射器/工作者/执行器中,以便由您提供的func()处理。您的func()接受一个分区(作为String类型的迭代器),并返回最多六对(键,值)对,其中键是 DNA 字母,值是该分区中该字母的总频率。请注意,分区是并行和独立处理的。 -
一旦所有分区的处理完成,结果将合并到目标 RDD 中,该 RDD 是一个
RDD[(String, Integer)],其中键是 DNA 字母,值是该 DNA 字母的频率。
详细的 mapPartitions() 转换语义用于解决 DNA 碱基计数问题,如 图 2-5 所示。

图 2-5. 使用 mapPartitions() 解决 DNA 碱基计数问题
正如本图所示,我们的输入(FASTA 格式数据)已经被分割成N个块/分区,每个块可以由一个独立的 mapper/worker/executor 并行处理。例如,如果我们的输入总共有 50 亿条记录,N = 50,000,则每个分区将包含约 100,000 个 FASTA 记录(50 亿 = 50,000 × 100,000)。因此,每个func()将处理(通过迭代)约 100,000 个 FASTA 记录。每个分区最多会生成六个(键,值)对,其中键将是{"a", "t", "c", "g", "n", "z"}(四个字母,"n"作为非 DNA 字母的键,"z"作为处理过的 DNA 字符串/序列的数量的键)。
因为mapPartitions(func)变换在 RDD 的每个分区(块)上分别运行,所以func()必须是iterator类型:
source: RDD[T] 
# Parameter p: iterator<T> 
def func(p): 
u = *`<``create` `object` `of` `type` `U` `by` `iterating` `all`
`elements` `of` `a` `single` `partition` `denoted` `by` `p``>`*
return u 
#end-def
target = source.mapPartitions(func) 
target: RDD[U] 
源 RDD 的每个元素的类型为T。
参数p是一个iterator<T>,表示一个单独的分区。
每次迭代将返回一个类型为T的对象。
定义一个func(),接受一个单独的分区作为iterator<T>(一个类型为T的迭代器,用于遍历源RDD[T]的单个分区),并返回一个类型为U的对象。
应用变换。
结果是一个RDD[U],其中每个分区已经被转换(使用func())为单个类型为U的对象。
假设我们有一个源RDD[T]。因此,对于我们的示例,T表示String类型(DNA 序列记录),而U表示哈希表(Python 中的字典)作为HashMap[String, Integer],其中键是 DNA 字母(作为String对象),值是关联的频率(作为Integer)。
我们可以在 Python 中定义func()(作为通用模板),如下所示:
# Parameter: iterator, which represents a single partition
#
# Note that iterator is a parameter from the mapPartitions()
# transformation, through which we can iterate through all
# the elements in a single partition.
#
# source is an RDD[T]
# target is an RDD[U]
def func(iterator): 
# 1\. Make sure that iterator is not empty. If it is empty,
# then handle it properly; you cannot ignore empty partitions.
# 2\. Initialize your desired data structures
# (such as dictionaries and lists).
# 3\. Iterate through all records in a given partition.
for record in iterator: 
# 3.1 Process the record
# 3.2 Update your data structures
#end-for
# 4\. If required, post-process your data structures (DS).
result_for_single_partition = post_process(DS) 
# 5\. Return result_for_single_partition.
#end-def
iterator是指向单个分区的指针,可用于遍历分区的元素。
record的类型为T。
result_for_single_partition的类型为U。
摘要设计模式
Spark 的mapPartitions()变换可用于实现摘要设计模式,当处理大数据并希望获取汇总视图以获取不仅限于查看局部记录的洞察时,这是非常有用的。此设计模式涉及将相似的数据组合在一起,然后执行操作,如计算统计量、构建索引或简单计数。
当应该使用mapPartitions()转换时呢?当你想从每个分区中提取一些简化或最小数量的信息时,这个转换非常有用,每个分区都是一个大数据集。例如,如果你想找出输入中所有数字的最小值和最大值,使用map()会相当低效,因为你会生成大量的中间(键,值)对,但实际上你只想找到两个数字。如果你想要找出输入中的前 10 个(或后 10 个)值,那么mapPartitions()就非常有用了:它可以高效地实现这个目标,首先找到每个分区的前(或后)10 个值,然后再找到所有分区的前(或后)10 个值。这样一来,你就避免了生成过多的中间(键,值)对。
对于计数 DNA 碱基来说,mapPartitions()转换是一个理想的解决方案,即使分区数目非常高(高达数千个),也能很好地扩展。假设你将输入分成 100,000 个块(这是一个非常高的分区数目——通常情况下分区数目不会这么高)。聚合这 100,000 个字典(哈希映射)的结果是一个简单的任务,可以在几秒钟内完成,不会出现 OOM 错误或可扩展性问题。
在展示使用这种强大转换完成的完整 DNA 碱基计数解决方案之前,我将提及关于使用mapPartitions()的另一个技巧。假设你将要访问数据库来进行一些数据转换,因此需要连接到数据库。正如你所知,创建连接对象是昂贵的,可能需要一两秒钟的时间来创建这个对象。如果你为每个源 RDD 元素创建一个连接对象,那么你的解决方案将无法扩展:你很快就会用完连接和资源。每当需要执行重量级初始化(比如创建数据库连接对象)时,最好是为许多 RDD 元素而不是每个 RDD 元素执行一次。如果这种初始化无法序列化(以便 Spark 可以将其传输到工作节点上),例如从外部库创建对象的情况,那么应该使用mapPartitions()而不是map()。mapPartitions()转换允许一次在工作任务/分区中初始化而不是每个 RDD 数据元素一次。
这种按分区/工作器初始化的概念通过以下示例来展示:
# source_rdd: RDD[T]
# target_rdd: RDD[U]
target_rdd = source_rdd.mapPartitions(func)
def func(partition): 
# create a heavyweight connection object
connection = *`<``create` `a` `db` `connection` `per` `partition``>`* 
data_structures = *`<``create` `and` `initialize` `your` `data` `structure``>`* 
# iterate all partition elements
for rdd_element in partition: 
# Use connection and rdd_element to
# make a query to your database
# Update your data_structures
#end-for
connection.close() # close db connection here 
u = *`<``prepare` `object` `of` `type` `U` `from` `data_structures``>`* 
return u 
#end-def
partition参数是一个iterator<T>,表示source_rdd的一个分区;func()返回一个类型为U的对象。
创建一个单一的connection对象,供给给定分区中的所有元素使用。
data_structures可以是列表、字典或任何你想要的数据结构。
rdd_element是类型为T的单个元素。
关闭connection对象(释放分配的资源)。
从创建的data_structures创建类型为U的对象。
每个分区返回一个类型为U的单个对象。
现在您已经了解了摘要设计模式的基础(由 Spark 的mapPartitions()实现),让我们深入使用它来解决我们的 DNA 碱基计数问题的具体细节。
解决方案 3 的高级工作流程在图 2-6 中展示。我们将再次使用sample.fasta文件来测试这个解决方案。

图 2-6. DNA 碱基计数解决方案 3
在这里有几个重要点需要记住:
-
在本图中,每个分区仅显示四条记录(两个 FASTA 序列),但实际上,每个分区可能包含数千或数百万条记录。如果您的总输入为
N条记录,并且有P个分区,则每个分区将包含约(*N*/*P*)条记录。 -
如果您的 Spark 集群有足够的资源,那么每个分区可以并行和独立地处理。
-
作为一般规则,如果您有大量数据,但只需从该数据中提取少量信息,则
mapPartitions()很可能是一个很好的选择,并且会优于map()和flatMap()转换。
说了这么多,让我们来看看解决方案 3 的主要步骤。
第 1 步:从输入创建一个 RDD[String]
SparkContext.textFile()函数用于创建以 FASTA 文本格式输入的 RDD。此步骤与先前解决方案的第 1 步相同:
input_path = ".../code/chap02/sample.fasta"
>>> records = spark.sparkContext.textFile(input_path) 
将记录创建为RDD[String]。
第 2 步:定义处理分区的函数
让您的 RDD 成为RDD[T](在我们的例子中,T是String)。Spark 将我们的输入数据分割成分区(其中每个分区是类型为T的元素集合——在我们的例子中,T是String),然后在分区上独立并并行执行计算。这称为分而治之模型。使用mapPartitions()转换,源 RDD 被分割为N个分区(分区的数量由 Spark 集群中可用的资源大小和数量决定),每个分区被传递给一个函数(这可以是用户定义的函数)。您可以使用coalesce()来控制分区的数量:
RDD.coalesce(numOfPartitions, shuffle=False)
将源 RDD 分区为numOfPartitions个分区。例如,在这里我们创建了一个 RDD 并将其分区为三个分区:
>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> numOfPartitions = 3
>>> rdd = sc.parallelize(numbers, numOfPartitions) 
>>> rdd.collect()
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> rdd.getNumPartitions() 
3
创建一个 RDD 并将分区数设置为 3。
检查 RDD 的分区数。
接下来,我将在 Python 中定义一个scan()函数来迭代给定的迭代器——您可以使用此函数调试小的 RDD 并检查分区:
>>> def scan(iterator): 
... print(list(iterator))
>>>#end-def
>>> rdd.foreachPartition(scan) 
1 2 3
===
7 8 9 10
===
4 5 6
===
迭代分区的元素。
将scan()函数应用于给定分区。从输出中,我们可以看到这里有三个分区。
警告
不要在生产环境中使用scan();这仅用于教学目的。
现在让我们看看如果在 Python 中定义一个adder()函数来对每个分区中的值进行加法操作的结果:
>>> def adder(iterator):
... yield sum(iterator) 
...
>>> rdd.mapPartitions(adder).collect()
[6, 34, 15]
yield是一个关键字,类似于return,但函数将返回一个可以迭代的生成器。
对于 DNA 碱基计数问题,为了处理(即处理 RDD 分区中的所有元素),我们将定义一个名为process_FASTA_partition()的函数,它接受一个分区(表示为iterator)。然后,我们在给定分区上进行迭代以处理所有给定分区中的元素。这将生成一个字典,我们将其映射为(dna_letter, frequency)对的列表:
#-------------------------------------
# Parameter: iterator
# We get an iterator that represents a single
# partition of the source RDD, through which we can
# iterate to process all the elements in the partition.
#
# This function creates a hash map (dictionary) of DNA
# letters and then flattens it into (key, value) pairs.
#--------------------------------------
from collections import defaultdict
def process_FASTA_partition(iterator): 
hashmap = defaultdict(int) 
for fasta_record in iterator:
if (fasta_record.startswith(">")): 
hashmap["z"] += 1
else: 
chars = fasta_record.lower()
for c in chars:
hashmap[c] += 1 
#end-for
print("hashmap=", hashmap)
key_value_list = [(k, v) for k, v in hashmap.iteritems()] 
print("key_value_list=", key_value_list)
return key_value_list 
输入参数iterator是单个分区的句柄/指针。
创建一个[String, Integer]的哈希表。
处理输入数据的注释。
处理 DNA 序列。
填充哈希表。
将哈希表展平为(dna_letter, frequency)对的列表。
返回(dna_letter, frequency)对的列表。
在定义process_FASTA_partition()函数时,我们使用了defaultdict(int),它的工作原理与普通字典完全相同(作为关联数组),但初始化时使用了一个函数(“默认工厂”),该函数不带参数并提供不存在键的默认值。在我们的情况下,defaultdict用于计数 DNA 碱基,其默认工厂是int(即整数数据类型),默认值为零。对于列表中的每个字符,相应键(DNA 碱基)的值将增加一。我们无需确保 DNA 碱基已经是一个键;如果不是,它将使用默认值零。
第三步:将自定义函数应用于每个分区
在这一步中,我们将process_FASTA_partition()函数应用于每个分区。我已经格式化了输出,并添加了一些注释以显示每个分区的输出(我们有两个分区):
>>> records_rdd.getNumPartitions()
2
>>> pairs_rdd = records_rdd.mapPartitions(process_FASTA_partition)
>>># output for partition 1
hashmap= defaultdict(<type 'int'>,
{
'a': 38, 'c': 28, 'g': 28,
'n': 2, 't': 24, 'z': 3
})
key_value_list= [
('a', 38), ('c', 28), ('g', 28),
('n', 2), ('t', 24), ('z', 3)]
>>># output for partition 2
hashmap= defaultdict(<type 'int'>,
{
'a': 35, 'c': 33,
't': 21, 'g': 25, 'z': 1,
})
key_value_list= [
('a', 35), ('c', 33),
('t', 21), ('g', 25), ('z', 1),
]
请注意,对于此解决方案,每个分区最多返回六个(key, value)对:
('a', count-of-a)
('t', count-of-t)
('c', count-of-c)
('g', count-of-g)
('n', count-of-non-atcg)
('z', count-of-DNA-sequences)
对于我们的示例数据,所有分区的最终集合将是:
>>> pairs_rdd.collect()
[
('a', 38), ('c', 28), ('t', 24), ('z', 3),
('g', 28), ('n', 2), ('a', 35), ('c', 33),
('t', 21), ('g', 25), ('z', 1)
]
最后,我们聚合并汇总(由mapPartitions()生成的)所有分区的输出:
>>> frequencies_rdd = pairs_rdd.reduceByKey(lambda a, b: a+b)
>>> frequencies_rdd.collect()
[
('a', 73),
('c', 61),
('g', 53),
('t', 45),
('n', 2),
('z', 4),
]
解决方案 3 的优缺点
让我们来分析一下解决方案 3 的优缺点:
优点
-
这是 DNA 碱基计数问题的最佳解决方案。提供的解决方案有效且简单高效。它通过每个分区创建字典(而不是每条记录)并将其展平为(键,值)对列表,从而改进了解决方案 1 和 2,减少了发出的(键,值)对数量。
-
由于我们使用
mapPartitions()处理每个分区和reduceByKey()来减少所有分区发出的(键,值)对,因此不存在可伸缩性问题。 -
我们最多会创建
*N*个字典,其中*N*是所有输入数据的分区总数(可能达到数百或数千个)。这不会对可伸缩性构成威胁,也不会使用过多内存。
缺点
- 此解决方案需要自定义代码。
摘要
总结一下:
-
解决大数据问题通常有多种方法,使用各种操作和转换。尽管它们都能达到相同的结果,但它们的性能可能不同。在选择解决特定数据问题的转换时,请确保使用“真实”大数据进行测试,而不是玩具数据。
-
对于大量的(键,值)对,总体上,由于不同的洗牌算法,
reduceByKey()转换比groupByKey()表现更好。 -
当您拥有大数据并希望提取、聚合或派生少量信息(例如,查找最小值和最大值或前 10 个值,或计数 DNA 碱基问题中的值)时,
mapPartitions()转换通常是一个不错的选择。 -
减少(键,值)对数量可提高数据解决方案的性能。这减少了 Spark 应用程序排序和洗牌阶段所需的时间。
接下来,我们将深入探讨映射器转换。
第三章:映射器转换
本章将通过简单的工作示例介绍最常见的 Spark 映射器转换。如果不清楚转换的含义,很难正确有效地解决任何数据问题。我们将在 RDD 数据抽象的背景下检查映射器转换。映射器是一个用于处理源 RDD 的所有元素并生成目标 RDD 的函数。例如,映射器可以将String记录转换为元组(key, value)对或其他您希望的输出形式。非正式地说,映射器将源RDD[V]转换为目标RDD[T],其中V和T分别是源和目标 RDD 的数据类型。您也可以通过将 DataFrame(行和列的表)转换为 RDD,然后使用 Spark 的映射器转换,或者直接应用 DataFrame 函数(使用select()和 UDF)来对所有行应用映射器转换。
数据抽象和映射器
Spark 有许多转换和操作,但本章专注于解释在构建 Spark 应用程序中最常用的那些。Spark 简单而强大的映射器转换使我们能够以简单的方式执行 ETL 操作。
正如我之前提到的,RDD 是 Spark 中的一个重要数据抽象,适用于非结构化和半结构化数据:它是一个不可变的、分区的元素集合,可以并行操作。RDD 是比 Spark 的另一个主要数据抽象 DataFrame 更低级的 API(参见图 3-1)。在 RDD 中,每个元素可能具有数据类型T,用RDD[T]表示。

图 3-1 Spark 的数据抽象
在每个数据解决方案中,我们使用映射器转换将一种形式的数据转换为另一种期望的数据形式(例如,将记录(作为String)转换为(key, value)形式)。Spark 提供了五种重要的映射器转换,在 RDD 转换中被广泛使用,这些转换在表格 3-1 中进行了总结。
表格 3-1 映射器转换
| 转换 | 关系类型 | 描述 |
|---|---|---|
map(f) |
1-to-1 | 通过将函数(f())应用于该 RDD 的每个元素,返回一个新的 RDD。源 RDD 和目标 RDD 将具有相同数量的元素(将源RDD[V]的每个元素转换为结果目标RDD[T]的一个元素)。 |
mapValues(f) |
1-to-1 | 将键值对 RDD 中的每个值通过map(f)函数进行处理,但不改变键;同时保留原始 RDD 的分区。源 RDD 和目标 RDD 将具有相同数量的元素(将源RDD[K, V]的每个元素转换为结果目标RDD[K, T]的一个元素)。 |
flatMap(f) |
一对多 | 通过首先将函数(f())应用于此 RDD 的所有元素,然后展开结果,返回一个新的 RDD。源 RDD 和目标 RDD 的元素数量可能不相同(将源RDD[V]的每个元素转换为目标RDD[T]的零个或多个元素)。 |
flatMapValues(f) |
一对多 | 将(键,值)对 RDD 中的每个值通过flatMap(f)函数处理而不更改键;这也保留了原始 RDD 的分区。源 RDD 和目标 RDD 的元素数量可能不相同。 |
mapPartitions(f) |
多对一 | 通过将函数(f())应用于源 RDD 的每个分区来返回一个新的 RDD。源 RDD 和目标 RDD 的元素数量可能不相同(将源RDD[V]的每个分区(可能由数百、数千或数百万个元素组成)转换为结果目标RDD[T]的一个元素)。 |
我们将在本章稍后通过实际示例深入探讨每个内容的使用,但首先让我们进一步讨论转换的实际含义。
转换是什么?
转换被定义为“形式或外观上的彻底或显著变化”。这与 Spark 转换的语义完全匹配,它将数据从一种形式转换为另一种形式。例如,map()转换可以将电影信息记录(作为String对象的<user_id><,><username><,><movie_name><,><movie_id><,><rating><,><timestamp><,><director><,>…)转换为三元组(user_id, movie_id, rating)。另一个转换的例子可能是将染色体“chr7:890766:T”转换为(chr7, 890766, T, 47)的元组,其中 47(作为派生的分区号)是 890766 除以 101 的余数。
正如我们在第一章中学到的,Spark 支持对 RDD 的两种操作:转换和行动。作为提醒:
-
大多数 RDD 转换接受单个源 RDD 并创建单个目标 RDD。
-
一些 Spark 转换会创建多个目标 RDD。
-
Actions 创建非 RDD 元素(如整数、字符串、列表、元组、字典和文件)。
至少有三种方法可以创建全新的 RDD:
-
RDD 可以从数据文件创建。您可以使用
SparkContext.textFile()或SparkSession.spark.read.text()从 Amazon S3、HDFS、Linux 文件系统和许多其他数据源读取数据文件,如在第一章中讨论的。 -
RDD 可以从集合(例如列表数据结构,例如数字列表、字符串列表或对列表)创建,使用
SparkContext.parallelize()。 -
给定一个源 RDD,您可以应用转换(例如
filter()或map())来创建一个新的 RDD。
Spark 提供了许多有用的转换,这是本章的主题。图 3-2 说明了这些选项。

图 3-2 不同创建 RDD 的选项
简言之,textFile() 和 parallelize() 操作可以表述为:
parallelize : collection --> RDD[T]
# where T is the type of collection elements
textFile : file(s) --> RDD[String]
# reading text files always creates an RDD[String]
在源 RDD 上的转换(具有元素类型 U,例如 map() 或 filter())会创建一个新的 RDD(目标 RDD 元素类型为 V):
transformation : RDD[U] --> RDD[V] where
U: data type of source RDD elements
V: data type of target RDD elements
在源 RDD 上执行的行动(例如 collectAsMap() 或 count())会创建一个具体的结果(非 RDD),例如整数、字符串、列表、文件或字典:
acton : RDD[U] --> non-RDD
一些基本的 Spark 操作(转换和行动)如 图 3-3 所示。

图 3-3. Spark 转换和行动
让我们看看 图 3-3 中发生了什么。通过以下转换创建了四个 RDDs:rdd0、rdd1、rdd2 和 rdd3:
转换 1
SparkSession.sparkContext.textFile() 从文本文件读取输入并创建第一个 RDD rdd0:
input_path = "sample_5_records.txt"
rdd0 = spark.sparkContext.textFile(input_path)
rdd0 被标记为 RDD[String],表示 rdd0 的每个元素是一个 String 对象。
转换 2
rdd1(一个 RDD[(String, Integer)])通过 rdd0.map() 转换创建,将 rdd0 的每个元素映射为 (key, value) 对:
def create_pair(record):
tokens = record.split(",")
return (tokens[0], int(tokens[1]))
#end-def
rdd1 = rdd0.map(create_pair)
转换 3
rdd2(一个 RDD[(String, Integer)])通过 rdd1.map() 创建,其中 mapper 将 (key, value) 对的值部分加倍:
rdd2 = rdd1.map(lambda x: (x[0], x[1]+x[1]))
-- OR --
rdd2 = rdd1.mapValues(lambda v: v+v)
转换 4
rdd3(一个 RDD[(String, Integer)])通过 rdd1.reduceByKey() 创建,其中 reducer 对相同键的值进行求和:
rdd3 = rdd1.reduceByKey(lambda x, y: x+y)
然后,使用以下行动创建了三个额外的非 RDD 输出:
行动 1
调用 rdd2.count() 来计算 rdd2 中元素的数量(结果为整数):
rdd2_count = rdd2.count()
行动 2
调用 rdd3.count() 来计算 rdd3 中元素的数量(结果为整数):
rdd3_count = rdd3.count()
行动 3
调用 rdd3.saveAsText() 将 rdd3 的内容持久化到文件系统中(结果为文本文件):
rdd3.saveAsText("/tmp/rdd3_output")
让我们看另一个例子,图示于 图 3-4。您可以将此转换和操作序列视为有向无环图(DAG),其中节点或顶点表示 RDDs,边表示要应用于 RDDs 的操作。稍后您会看到,Spark 使用 DAG 优化操作。

图 3-4. Spark 操作
让我们看看 图 3-4 中发生了什么:
-
RDD1 和 RDD3 是从文本文件创建的。
-
RDD2 由
map()转换创建:# source RDD: RDD1[U] # target RDD: RDD2[V] RDD2 = RDD1.map(func1) -
RDD4 通过
flatMap()转换创建:# source RDD: RDD3[C] # target RDD: RDD4[D] RDD4 = RDD3.flatMap(func2) -
RDD5 通过连接两个 RDDs(RDD2 和 RDD4)创建。此转换的结果是一个包含所有具有匹配键的元素对的 RDD:
# source RDDs: RDD2, RDD4 # target RDD: RDD5 # join RDD2 and RDD4 on a common key RDD5 = RDD2.join(RDD4) -
最后,RDD5 的元素被保存为哈希映射:
# source RDD: RDD5 # action: saveAsMap() # target: hashmap : dictionary hashmap = RDD5.saveAsMap()
在执行 saveAsMap() 行动之前,不会评估或执行任何转换:这称为延迟评估。
懒加载转换
让我们深入了解一下 Spark 的惰性转换。当运行 Spark 应用程序(无论是 Python、Java 还是 Scala)时,Spark 创建一个 DAG 和该图。由于 Spark 的转换是惰性评估的,直到触发某个动作(如 collect() 或 count())才会开始执行 DAG。这意味着 Spark 引擎可以在查看整个 DAG 之后做出优化决策,而不是仅仅查看单个转换和动作。例如,可以编写一个 Spark 程序,创建 10 个 RDD,其中有 3 个从不被使用(这些被称为 不可达 RDD)。Spark 引擎不需要计算这三个 RDD,通过避免这样做可以减少总执行时间。
正如前面提到的,在 Apache Spark 中,DAG 是一组顶点和边,其中顶点表示 RDD,边表示要在 RDD 上应用的操作(转换或动作)。在 Spark DAG 中,每条边从序列中较早的 RDD 指向较晚的 RDD。在调用动作(如 saveAsMap()、count()、collect() 或 collectAsMap())时,创建的 DAG 被提交给 Spark 的 DAG Scheduler,后者进一步将图分割为任务的阶段,如 图 3-5 所示。

图 3-5. Spark 的 DAG
提示
每个 SparkContext 启动一个 Web UI(默认端口为 4040,具有多个 SparkContext 绑定到连续端口),显示有关应用程序的有用信息,包括 DAG 的可视化。您可以通过访问 http://
在 Spark 中的惰性评估有多个优点。它增加了事务的可管理性,并使 Spark 引擎能够执行各种优化。这降低了复杂性,节省了计算资源,并提高了速度。减少 RDD 操作的执行时间可以提高性能,而血统图(DAG)通过记录在 RDD 上执行的操作来帮助 Spark 实现容错。
现在您对转换有了更多了解,我们将更详细地介绍 Spark 中最常见的映射器转换。我们将从 map() 转换开始,这是任何 Spark 应用程序中最常用的转换。
map() 转换
map() 转换是 Spark 和 MapReduce 范式中最常见的转换。该转换可以应用于 RDD 和 Dataframe。
RDD 映射器
RDD.map() 的目标是通过将函数 f() 应用于源 RDD[V] 的每个元素,将其转换为目标 RDD[T] 的映射元素。这个函数可以是预定义的,也可以是自定义的。map() 转换定义如下:
pyspark.RDD.map (Python method)
map(f, preservesPartitioning=False)
f: V --> T 
map: RDD[V] --> RDD[T] 
函数 f() 接受 V 类型的元素并返回 T 类型的元素。
使用函数 f(),map() 转换将 RDD[V] 转换为 RDD[T]。
这是一个一对一的转换:如果您的源 RDD 有 N 个元素,则结果/目标 RDD 也将有完全相同的 N 个元素。请记住,map() 转换不是一个顺序函数。您的源 RDD 被分区成 P 个分区,然后独立并发地处理这些分区。例如,如果您的源 RDD 有 400 亿个元素,并且 P = 20,000,那么每个分区大约会有 200 万个元素(400 亿 = 20,000 x 2 百万)。如果可用的映射器数量为 80(这个数字取决于集群中的可用资源),则可以同时独立并发地映射 80 个分区。
map() 转换的函数 f() 可以定义为:
# v : a data type of V
# return an object of type T
def convert_V_to_T(v):
t = *`<``convert` `v` `to` `an` `object` `of` `data` `type` `T``>`*
return t
#end-def
# source RDD: source_rdd : RDD[V]
# target RDD: target_rdd : RDD[T]
target_rdd = source_rdd.map(convert_V_to_T)
或者您可以使用 lambda 表达式创建目标 RDD (rdd_v),如下所示:
target_rdd = source_rdd.map(lambda v : convert_V_to_T(v))
Figure 3-6 说明了 map() 转换的语义。

图 3-6. map() 转换
以下示例展示了如何在 PySpark shell 中使用 map() 转换。该示例将源 RDD[Integer] 映射到目标 RDD[Integer]:它将包含数字列表的 RDD 转换为新的 RDD,其中每个正数元素的值增加了 5,而所有其他元素都被更改为 0。
首先,让我们将我们的映射函数定义为 mapper_func():
>>># define a simple mapper function
>>> def mapper_func(x):
... if (x > 0):
... return x+5
... else:
... return 0
>>>#end-def
接下来,我们将应用 map() 转换,并看看它是如何工作的:
>>># spark : SparkSession
>>> data = [1, -1, -2, 3, 4]
>>> rdd = spark.sparkContext.parallelize(data) 
>>> rdd.collect()
[1, -1, -2, 3, 4]
>>># use lambda expression
>>> rdd2 = rdd.map(lambda x : mapper_func(x)) 
>>> rdd2.collect()
[6, 0, 0, 8, 9]
>>># use a function instead
>>> rdd3 = rdd.map(mapper_func) 
>>> rdd3.collect()
[6, 0, 0, 8, 9]
>>>
>>> rdd4 = rdd.map(lambda x : (x, mapper_func(x))) 
>>> rdd4.collect()
[(1, 6), (-1, 0), (-2, 0), (3, 8), (4, 9)]
>>> rdd4.count()
5
rdd 是一个 RDD[Integer]。
rdd2 是一个 RDD[Integer]。
rdd3 是一个 RDD[Integer]。
rdd4 是一个 RDD[(Integer, Integer)]。
这里是另一个例子,将 RDD[(String, Integer)] 映射到 RDD[(String, Integer, String)]。该例子将形式为 (key, value) 对的元素转换为 (key, value, value+100) 三元组:
>>> pairs = [('a', 2), ('b', -1), ('d', -2), ('e', 3)]
>>> rdd = spark.sparkContext.parallelize(pairs) 
>>> rdd.collect()
[('a', 2), ('b', -1), ('d', -2), ('e', 3)]
>>> rdd2 = rdd.map(lambda (k, v) : (k, v, v+100)) 
>>> rdd2.collect()
[
('a', 2, 102),
('b', -1, 99),
('d', -2, 98),
('e', 3, 103)
]
rdd 是一个 RDD[(String, Integer)]。
rdd2 是一个 RDD[(String, Integer, Integer)]。
从 String 对象创建 (key, value) 对也很简单:
>>> def create_key_value(string):
>>> tokens = string.split(",")
>>> return (tokens[0], (tokens[1], tokens[2]))
>>>
>>> strings = ['a,10,11', 'b,8,19', 'c,20,21', 'c,2,8']
>>> rdd = spark.sparkContext.parallelize(strings) 
>>> rdd.collect()
['a,10,11', 'b,8,9', 'c,20,21', 'c,2,8']
>>> pairs = rdd.map(create_key_value) 
>>> rdd2.collect()
[
('a', (10, 11)),
('b', (8, 19)),
('c', (20, 21)),
('c', (2, 8))
]
rdd 是一个 RDD[String]。
rdd2 是一个 RDD[(String, (Integer, Integer))]。
接下来,我将讨论自定义映射函数。
自定义映射函数
在使用 Spark 的转换时,您可以使用自定义的 Python 函数来解析记录,执行计算,最终创建您想要的输出。
假设我们有一个样本数据集,每条记录的格式如下:
<id><,><name><,><age><,><number-of-friends>
我们的数据看起来像这样:
$ cat /tmp/users.txt
1,Alex,30,124
2,Bert,32,234
3,Curt,28,312
4,Don,32,180
5,Mary,30,100
6,Jane,28,212
7,Joe,28,128
8,Al,40,600
对于每个年龄类别,我们想要得到朋友平均数。我们可以编写自己的自定义映射器函数:
# record=<id><,><name><,><age><,><number-of-friends>
# parse record and return a pair as (age, number_of_friends)
def parse_record(record):
# split record into a list at comma positions
tokens = record.split(",")
# extract and typecast relevant fields
age = int(tokens[2])
number_of_friends = int(tokens[3])
return (age, number_of_friends)
#end-def
然后读取我们的数据并使用自定义函数:
users_path = '/tmp/users.txt'
users = spark.sparkContext.textFile(users_path) 
pairs = users.map(parse_record) 
users是一个RDD[String]。
pairs是一个RDD[(Integer, Integer)],其中每条记录都会通过parse_record()处理。
对于我们的样本数据,pairs将是:
(30, 124), (32, 234), (28, 312), (32, 180),
(30, 100), (28, 212), (28, 128), (40, 600)
要获得每个年龄类别的平均数,我们首先获取每个年龄的总和和条目数:
totals_by_age = pairs \ 
.mapValues(lambda x: (x, 1)) \ 
.reduceByKey(lambda x, y: (x[0] + y[0], x[1] + y[1]))  
pairs是一个RDD[(Integer, Integer)]。
将number_of_friends字段转换为(number_of_friends, 1)对。
在年龄上执行减少以找到每个年龄的(sum_of_friends, frequecy_count)。
totals_by_age是RDD[(Integer, (Integer, Integer))]
对于我们的数据,totals_by_age将是:
(30, (124+100, 1+1)) --> (30, (224, 2))
(32, (234+180, 1+1)) --> (32, (414, 2))
(28, (312+212+128, 1+1+1)) --> (28, (652, 3))
(40, (600, 1)) --> (40, (600, 1))
现在,要计算每个年龄段的平均朋友数,我们需要进行另一个转换,将总和除以频率计数以获得平均值:
# x = (sum_of_friends, frequency_count)
# x[0] = sum_of_friends
# x[1] = frequency_count
averages_by_age = totals_by_age.mapValues(lambda x: float(x[0]) / float(x[1]))
averages_by_age.collect()
对于我们的数据,averages_by_age(一个RDD[(Integer, Integer)])将是:
(30, (224 / 2)) = (30, 112)
(32, (414 / 2)) = (32, 207)
(28, (652 / 3)) = (28, 217)
(40, (600 / 1)) = (40, 600)
DataFrame 映射器
Spark 的 DataFrame 没有map()函数,但我们可以通过多种方式实现map()的等效操作:可以通过DataFrame.withColumn()添加新列,并使用DataFrame.drop()删除现有列。新列的值可以根据现有行值或其他要求计算得出。
映射到单个 DataFrame 列
考虑以下 DataFrame:
tuples3 = [ ('alex', 440, 'PHD'), ('jane', 420, 'PHD'),
('bob', 280, 'MS'), ('betty', 200, 'MS'),
('ted', 180, 'BS'), ('mary', 100, 'BS') ]
df = spark.createDataFrame(tuples3, ["name", "amount", "education"])
>>> df.show()
+-----+------+---------+
| name|amount|education|
+-----+------+---------+
| alex| 440| PHD|
| jane| 420| PHD|
| bob| 280| MS|
|betty| 200| MS|
| ted| 180| BS|
| mary| 100| BS|
+-----+------+---------+
假设我们想要对“amount”列计算 10%的奖金并创建一个新的“bonus”列。有多种方法可以完成这个映射任务。
要保留所有列,请执行以下操作:
df2 = df.rdd\
.map(lambda x: (x["name"], x["amount"],
x["education"], int(x["amount"])/10))
.toDF(["name", "amount", "education", "bonus"])
>>> df2 = df.rdd.map(lambda x: (x["name"], x["amount"],
x["education"], x["amount"]/10))
.toDF(["name", "amount", "education", "bonus"])
>>> df2.show()
+-----+------+---------+-----+
| name|amount|education|bonus|
+-----+------+---------+-----+
| alex| 440| PHD| 44.0|
| jane| 420| PHD| 42.0|
| bob| 280| MS| 28.0|
|betty| 200| MS| 20.0|
| ted| 180| BS| 18.0|
| mary| 100| BS| 10.0|
+-----+------+---------+-----+
您必须将行映射到包含所有现有列的元组,然后添加新列。
如果您有太多列要列举,也可以将一个元组添加到现有行。
>>> df3 = df.rdd.map(lambda x: x + \
(str(x["amount"]/10),)).toDF(df.columns + ["bonus"])
>>> df3.show()
+-----+------+---------+-----+
| name|amount|education|bonus|
+-----+------+---------+-----+
| alex| 440| PHD| 44.0|
| jane| 420| PHD| 42.0|
| bob| 280| MS| 28.0|
|betty| 200| MS| 20.0|
| ted| 180| BS| 18.0|
| mary| 100| BS| 10.0|
+-----+------+---------+-----+
还有另一种方法可以使用DataFrame.withColumn()添加bonus列:
>>> df4 = df.withColumn("bonus", F.lit(df.amount/10))
>>> df4.show()
+-----+------+---------+-----+
| name|amount|education|bonus|
+-----+------+---------+-----+
| alex| 440| PHD| 44.0|
| jane| 420| PHD| 42.0|
| bob| 280| MS| 28.0|
|betty| 200| MS| 20.0|
| ted| 180| BS| 18.0|
| mary| 100| BS| 10.0|
+-----+------+---------+-----+
映射到多个 DataFrame 列
现在,假设您要添加一个依赖于两个列“amount”和“education”的bonus列:bonus列计算如下:
bonus = amount * 30% if education = PHD
bonus = amount * 20% if education = MS
bonus = amount * 10% all other cases
最简单的方法是使用用户定义的函数(UDF):定义一个 Python 函数,然后将其注册为 UDF:
def compute_bonus(amount, education):
if education == "PHD": return int(amount * 0.30)
if education == "MS": return int(amount * 0.20)
return int(amount * 0.10)
#end-def
现在,将您的 Python 函数注册为 UDF:
>>> from org.apache.spark.sql.functions import udf
>>> compute_bonus_udf = udf(lambda amount, education:
compute_bonus(amount, education), IntegerType())
一旦您的 UDF 准备就绪,那么您就可以应用它:
>>> df5 = df.withColumn("bonus",
compute_bonus_udf(df.amount, df.education))
>>> df5.show()
+-----+------+---------+-----+
| name|amount|education|bonus|
+-----+------+---------+-----+
| alex| 440| PHD| 132|
| jane| 420| PHD| 126|
| bob| 280| MS| 56|
|betty| 200| MS| 40|
| ted| 180| BS| 18|
| mary| 100| BS| 10|
+-----+------+---------+-----+
接下来,我们将看一下flatMap()转换。
flatMap()转换
flatMap()转换通过将函数应用于源 RDD 的每个元素,然后展平结果来返回新的 RDD。这是一种一对多的转换:源 RDD 的每个元素可以映射为目标 RDD 的 0、1、2 或多个元素。换句话说,flatMap()将长度为N的源RDD[U]转换为长度为M的目标RDD[V](M和N可以不同)。使用flatMap()时,您需要确保源 RDD 的元素是可迭代的(例如,一个包含项目列表的列表)。
例如,如果源 RDD 的元素是[10, 20, 30](包含三个数字的可迭代列表),那么它将被映射为目标 RDD 的三个元素(10,20和30);如果源 RDD 的元素是[](空列表,可迭代),那么它将被丢弃,并且根本不会映射到目标 RDD。如果源 RDD 的任何元素不可迭代,则会引发异常。
注意,map()将长度为N的 RDD 转换为另一个长度为N的 RDD(长度相同),而flatMap()将长度为N的 RDD 转换为一组N个可迭代集合,然后展平这些集合为单个结果 RDD。因此,源 RDD 和目标 RDD 可能具有不同的大小。
flatMap()转换定义如下:
pyspark.RDD.flatMap (Python method)
flatMap(f, preservesPartitioning=False)
U: iterable collection of V
source RDD: RDD[U]
target RDD: RDD[V]
f: U --> [V] 
flatMap: RDD[U] --> RDD[V]
函数f()接受类型为U的元素,并将其转换为类型为V的元素列表(此列表可以包含 0、1、2 或多个元素),然后展平。注意,空列表将被丢弃。函数f()必须创建一个可迭代对象。
图 3-7 展示了flatMap()转换的示例。

图 3-7。flatMap()转换
在图 3-7 中,源 RDD 的每个元素(一个String)被分词为一个Strings列表,然后展平为一个String对象。例如,第一个元素"[red fox jumped]"被转换为Strings列表["red", "fox", "jumped"],然后该列表被展平为三个String对象"red","fox"和"jumped"。因此,第一个源元素被映射为三个目标元素。
以下示例展示了如何使用flatMap()转换:
>>> numbers = [1, 2, 3, 4, 5]
>>> rdd = spark.sparkContext.parallelize(numbers)
>>> rdd.collect()
[1, 2, 3, 4, 5]
>>> rdd2 = rdd.flatMap(lambda x: range(1, x))
>>> rdd2.collect()
[1, 1, 2, 1, 2, 3, 1, 2, 3, 4]
>>> rdd3 = rdd.flatMap(lambda x: [(x, x+1), (x+1, x)])
>>> rdd3.collect()
[
(1, 2), (2, 1),
(2, 3), (3, 2),
(3, 4), (4, 3),
(4, 5), (5, 4),
(5, 6), (6, 5)
]
>>> rdd3.count()
10
让我们来看看如何创建rdd2:
Element 1: --maps--> range(1, 1) --flattens--> []
--> dropped since empty
Element 2: --maps--> range(1, 2) --flattens--> [1]
--> maps into one element as 1
Element 3: --maps--> range(1, 3) --flattens--> [1, 2]
--> maps into two elements as 1, 2
Element 4: --maps--> range(1, 4) --flattens--> [1, 2, 3]
--> maps into three elements as 1, 2, 3
Element 5: --maps--> range(1, 5) --flattens--> [1, 2, 3, 4]
--> maps into four elements as 1, 2, 3, 4
您也可以使用函数,而不是 lambda 表达式:
>>> numbers = [1, 2, 3, 4, 5]
>>> rdd = spark.sparkContext.parallelize(numbers) 
>>> rdd.collect()
[1, 2, 3, 4, 5]
>>> def create_list(x):
... return [(x, x+1), (x, x+2)]
>>>#end-def
...
>>> rdd4 = rdd.flatMap(create_list) 
>>> rdd4.collect()
[
(1, 2), (1, 3),
(2, 3), (2, 4),
(3, 4), (3, 5),
(4, 5), (4, 6),
(5, 6), (5, 7)
]
>>> rdd4.count()
10
rdd是一个包含五个元素的RDD[Integer]。
rdd4是一个包含十个元素的RDD[(Integer, Integer)]。
flatMap()函数示例说明了如何在目标 RDD 中,针对源 RDD 中的每个元素返回零个或多个元素:
>>> words = ["a", "red", "of", "fox", "jumped"]
>>> rdd = spark.sparkContext.parallelize(words)
>>> rdd.count() 
5
>>> rdd.collect()
['a', 'red', 'of', 'fox', 'jumped']
>>> def my_flatmap_func(x):
... if len(x) < 3:
... return [] 
... else:
... return [x, x, x] 
...
>>> flattened = rdd.flatMap(my_flatmap_func)
>>> flattened.count() 
9
>>> flattened.collect()
['red', 'red', 'red', 'fox', 'fox', 'fox', 'jumped', 'jumped', 'jumped']
rdd是一个包含五个元素的RDD[String]。
空列表将被丢弃。
这将映射到三个目标元素。
flattened 是一个具有九个元素的 RDD[String]。
下面的例子清楚地展示了 map() 和 flatMap() 之间的区别。如您从输出中看到的那样,flatMap() 将其输出展平,而 map() 转换是一对一映射,不会展平其输出:
def to_list(x): return [x, x+x, x*x]
# rdd1: RDD[Integer] (element type is Integer)
rdd1 = spark.sparkContext.parallelize([3,4,5]) 
.map(to_list) 
rdd1.collect()
# output: notice non-flattened list
[[3, 6, 9], [4, 8, 16], [5, 10, 25]]
rdd1.count()
3
# rdd2 : RDD[[Integer]] (element type is [Integer])
rdd2 = spark.sparkContext.parallelize([3,4,5]) 
.flatMap(to_list) 
rdd2.collect()
# output: notice flattened list
[3, 6, 9, 4, 8, 16, 5, 10, 25]
rdd2.count()
9
创建一个 RDD[Integer]。
rdd1 的每个元素都是整数列表(作为 RDD[[Integer]])。
创建一个 RDD[[Integer]]。
rdd2 的每个元素都是整数(作为 RDD[Integer])。
图 3-8 中呈现了 flatMap() 转换的视觉表示。

图 3-8. 一个 flatMap() 转换
让我们逐步分析这里发生的情况。我们将从检查输入文件 2recs.txt 的内容开始:
$ cat 2recs.txt
Fox, ran 2 fast!!!
Fox, jumped; of fence!!!
下面是步骤:
-
首先,我们创建一个仅包含两条记录/元素的
RDD[String]:rdd = spark.sparkCintext.textFile("2recs.txt") rdd.collect() [ "Fox, ran 2 fast!!!", "Fox, jumped; of fence!!!" ] -
接下来,我们对此 RDD 的所有元素应用
map()转换,删除所有标点符号,将多个空格缩减为单个空格,并将所有字母转换为小写。这是通过一个简单的 Python 函数实现的:import string, re def no_punctuation(record_str): exclude = set(string.punctuation) t = ''.join(ch for ch in record_str if ch not in exclude) trimmed = re.sub('\s+',' ', t) return trimmed #end-def rdd_cleaned = rdd.map(no_punctuation) rdd_cleaned.collect() [ "fox ran 2 fast", "fox jumped of fence" ] -
然后我们对
rdd_cleaned应用flatMap()转换,首先对此 RDD 的元素进行标记化,然后展开它:flattened = rdd_cleaned.flatMap(lambda v: v.split(" ")) flattened.collect() ['fox', 'ran', '2', 'fast', 'fox', 'jumped', 'of', 'fence'] -
最后,
filter()转换将丢弃flattenedRDD 的元素,仅保留长度大于 2 的元素。final_rdd = flattened.filter(lambda w: len(w) > 2) final_rdd.collect() ['fox', 'ran', 'fast', 'fox', 'jumped', 'fence']
在 图 3-8 中用 X 标出的筛选出的元素。
map() 与 flatMap() 对比
您现在已经看到了一些 map() 和 flatMap() 转换的例子,但重要的是要理解它们之间的区别。总结一下:
map()
这是一个一对一的转换。它通过将给定函数应用于 RDD 的每个元素来返回一个新的 RDD。map() 中的函数只返回一个项。
flatMap()
这是一个一对多的转换。它还通过对源 RDD 的每个元素应用函数来返回一个新的 RDD,但函数可以针对每个源元素返回 0、1、2 或多个元素,并且输出被展平。
图 3-9 中阐明了 map() 和 flatMap() 的区别。

图 3-9. map() 和 flatMap() 的区别
应用 flatMap() 到 DataFrame
RDD.flatMap()是一对多的转换:它接收源 RDD 的一个元素并将其转换为多个(0、1、2、3 或更多)目标元素。PySpark 的 DataFrame 没有flatMap()转换,但是 DataFrame 具有函数pyspark.sql.functions.explode(col),用于展平列。explode(column)为给定的column(表示为列表或字典)中的每个元素返回一行,并使用默认列名col来表示数组中的元素,以及用键和值来表示字典中的元素,除非另有指定。
下面是一个完整的示例,展示了如何使用explode()函数作为RDD.flatMap()转换的等价物。
让我们首先创建一个 DataFrame,其中一个列是一个列表(将由explode()函数展开)。
some_data = [
('alex', ['Java','Scala', 'Python']),
('jane', ['Cobol','Snobol']),
('bob', ['C++',]),
('ted', []),
('max', [])
]
>>> df = spark.createDataFrame(
data=some_data, schema = ['name', 'known_languages'])
>>> df.show(truncate=False)
+----+----------------------+
|name| known_languages |
+----+----------------------+
|alex| [Java, Scala, Python]|
|jane| [Cobol, Snobol] |
|bob | [C++] |
|ted | [] |
|max | [] |
+----+----------------------+
接下来,我们将展开known_languages列:
>>> exploded = df.select(df.name,
explode(df.known_languages).alias('language'))
>>> exploded.show(truncate=False)
+----+--------+
|name|language|
+----+--------+
|alex| Java |
|alex| Scala |
|alex| Python|
|jane| Cobol |
|jane| Snobol|
|bob | C++ |
+----+--------+
如您所见,当展开列时,如果列是空列表,则从展开结果中删除该列(tex和max被删除,因为它们关联的是空列表)。
接下来,我们将看一下如何对给定的 DataFrame 展开多列。请注意,每个select子句只允许一个生成器:这意味着您不能同时展开两列(但可以逐个迭代展开它们)。以下示例显示了如何展开两列:
>>> some_data = [
... ('alex', ['Java','Scala', 'Python'], ['MS', 'PHD']),
... ('jane', ['Cobol','Snobol'], ['BS', 'MS']),
... ('bob', ['C++'], ['BS', 'MS', 'PHD']),
... ('ted', [], ['BS', 'MS']),
... ('max', ['FORTRAN'], []),
... ('dan', [], [])
... ]
>>>
>>> df = spark.createDataFrame(data=some_data,
schema = ['name', 'languages', 'education'])
>>> df.show(truncate=False)
+----+---------------------+-------------+
|name|languages |education |
+----+---------------------+-------------+
|alex|[Java, Scala, Python]|[MS, PHD] |
|jane|[Cobol, Snobol] |[BS, MS] |
|bob |[C++] |[BS, MS, PHD]|
|ted |[] |[BS, MS] |
|max |[FORTRAN] |[] |
|dan |[] |[] |
+----+---------------------+-------------+
接下来我们展开languages列,它是一个数组:
>>> exploded_1 = df.select(df.name,
explode(df.languages).alias('language'), df.education)
>>> exploded_1.show(truncate=False)
+----+--------+-------------+
|name|language|education |
+----+--------+-------------+
|alex|Java |[MS, PHD] |
|alex|Scala |[MS, PHD] |
|alex|Python |[MS, PHD] |
|jane|Cobol |[BS, MS] |
|jane|Snobol |[BS, MS] |
|bob |C++ |[BS, MS, PHD]|
|max |FORTRAN |[] |
+----+--------+-------------+
注意,由于爆炸列值为空列表,名称ted和dan被丢弃。
接下来,我们展开education列:
>>> exploded_2 = exploded_1.select(exploded_1.name, exploded_1.language,
explode(exploded_1.education).alias('degree'))
>>> exploded_2.show(truncate=False)
+----+--------+------+
|name|language|degree|
+----+--------+------+
|alex|Java | MS|
|alex|Java | PHD|
|alex|Scala | MS|
|alex|Scala | PHD|
|alex|Python | MS|
|alex|Python | PHD|
|jane|Cobol | BS|
|jane|Cobol | MS|
|jane|Snobol | BS|
|jane|Snobol | MS|
|bob |C++ | BS|
|bob |C++ | MS|
|bob |C++ | PHD|
+----+--------+------+
注意,由于爆炸列值为空列表,名称max被丢弃。
下面我们将讨论一种仅适用于其元素为(key, value)对的 RDD 的转换。
mapValues()转换
mapValues()转换仅适用于键值对 RDD(RDD[(K, V)],其中K是键,V是值)。它仅对值(V)操作,保持键不变,与map()转换不同,后者操作整个 RDD 元素。
从非正式的角度来看,给定源 RDD RDD[(K, V)] 和函数 f: V -> T,我们可以说 rdd.mapValues(f) 等价于以下 map():
# source rdd: RDD[(K, V)]
# target result: RDD[(K, T)]
result = rdd.map( lambda (k, v): (k, f(v)) )
mapValues()转换定义如下:
pyspark.RDD.mapValues (Python method)
mapValues(f)
f: V --> U 
mapValues: RDD[(K, V)] --> RDD[(K, f(V))]
函数f()可以将数据类型V转换为任何所需的数据类型T。V和T可以相同也可以不同。
mapValues()转换将键值对 RDD 中的每个值通过map()函数传递,而不改变键;这也保留了原始 RDD 的分区(更改是在原地完成的,分区的结构和数量不变)。
下面是mapValues()转换的示例:
>>> pairs = [
("A", []), ("Z", [40]),
("C", [10, 20, 30]), ("D", [60, 70])
]
>>> rdd = spark.sparkContext.parallelize(pairs) 
>>> rdd.collect()
[('A', []), ('Z', [40]), ('C', [10, 20, 30]), ('D', [60, 70])]
>>>
>>> def f(x):
>>> if len(x) == 0: return 0
>>> else: return len(x)+1
>>>
>>> rdd2 = rdd.mapValues(f) 
>>> rdd2.collect()
[('A', 0), ('Z', 2), ('C', 4), ('D', 3)]
rdd是一个RDD[(String, [Integer])]。
rdd2是一个RDD[(String, Integer)]。
mapValues() 是一个一对一的转换,如图 3-10 所示。

图 3-10. mapValues() 转换
flatMapValues() 转换
flatMapValues() 转换是 flatMap() 和 mapValues() 的组合。它类似于 mapValues(),但 flatMapValues() 在 RDD[(K, V)](即 (键, 值) 对的 RDD)的值上运行 flatMap() 函数,而不是 map() 函数。它在不改变键的情况下保留了原始 RDD 的分区。以下是一个例子:
>>> rdd = spark.sparkContext.parallelize([
('S', []), 
('Z', [7]),
('A', [1, 2, 3]),
('B',[4, 5])
]) 
>>># function is applied to entire
>>># value, and then result is flattened
>>> rdd2 = rdd.flatMapValues(lambda v: [i*3 for i in v]) 
>>> rdd2.collect()
[('Z', 21),
('A', 3), ('A', 6), ('A', 9),
('B', 12), ('B', 15)]
此元素将被丢弃,因为其值为空。
rdd 是一个 RDD[(String, [Integer])]。
rdd2 是一个 RDD[(String, Integer)];注意,由于键 S 的值是空列表,所以该键被丢弃了。
这里是另一个例子:
>>> rdd = spark.sparkContext.parallelize([
("A", ["x", "y", "z"]),
("B", ["p", "r"]),
("C", ["q"]),
("D", [])
]) 
>>> def f(x): return x
>>> rdd2 = rdd.flatMapValues(f) 
>>> rdd2.collect()
[
('A', 'x'), ('A', 'y'), ('A', 'z'),
('B', 'p'), ('B', 'r'),
('C', 'q')
]
rdd 是一个 RDD[(String, [String])]。
rdd2 是一个 RDD[(String, String)]。
同样地,如果某个键的值为空([]),那么不会生成输出值(该键也会被丢弃)。因此,对于 D 键,不会生成任何元素。
接下来我们将看看 mapPartitions() 转换,这在我看来是 Spark 中最重要的映射器转换之一。
mapPartitions() 转换
mapPartitions() 是一个强大的分布式映射器转换,它一次处理一个分区(而不是一个元素)。它实现了汇总设计模式,将源 RDD 的每个分区汇总为目标 RDD 的单个元素。该转换的目标是一次处理一个分区(虽然许多分区可以独立并发地处理),遍历分区的所有元素,并将结果汇总到紧凑的数据结构中,例如字典、元素列表、元组或元组列表。
mapPartitions() 转换的签名如下:
mapPartitions(f, preservesPartitioning=False)
# Returns a new RDD by applying a function, f(), to each partition
# of this RDD. If source RDD has N partitions, then your function
# will be called N times, independently and concurrently.
假设您的源 RDD 有N个分区。mapPartitions() 转换将源 RDD 的单个分区映射为所需的数据类型 T(例如,可以是单个值、元组、列表或字典)。因此,目标 RDD 将是长度为N的 RDD[T]。当您想要将由源 RDD 元素集合组成的每个分区减少(或聚合)为类型 T 的紧凑数据结构时,这是一个理想的转换:它将单个分区映射到目标 RDD 的单个元素中。
在图 3-11 中提供了一个高级概述。

图 3-11. mapPartition() 转换
为了帮助您理解mapPartitions()转换的逻辑,我将呈现一个简单具体的例子。假设您有一个包含 100,000,000,000 个元素的源RDD[Integer],并且您的 RDD 被分成了 10,000 个块或分区。因此,每个分区将有大约 10,000,000 个元素。如果您有足够的集群资源可以并行运行 10,000 个映射器,那么每个映射器将接收一个分区。由于您将一次处理一个分区,您有机会过滤元素并将每个分区汇总到单个期望的数据结构中(如元组、列表或字典)。
假设您想要找到数字源 RDD 的(minimum, maximum, count)。每个映射器将为每个分区找到本地的(minimum, maximum, count),最终您可以找到所有分区的最终(minimum, maximum, count)。这里,目标数据类型是三元组:
T = (int, int, int) = (minimum, maximum, count)
mapPartitions()是一种理想的转换,当您想将每个分区映射为少量紧凑或减少的信息时。您可以过滤源 RDD 中不需要的元素,然后在您选择的数据结构中总结剩余的元素。
让我们来详细了解mapPartitions()转换的主要流程:
-
首先,定义一个接受源 RDD 的单个分区(
RDD[Integer])并返回数据类型T的函数,其中:T = (int, int, int) = (minimum, maximum, count)让
N是您源 RDD 的分区数。给定一个分区p(其中p在{1, 2, …, *N*}中),mapPartitions()将计算每个分区p的(minimum[p], maximum[p], count[p]):def find_min_max_count(single_partition): # find (minimum, maximum, count) by iterating single_partition return [(minimum, maximum, count)] #end-def -
接下来,应用
mapPartitions()转换:# source RDD: source_rdd = RDD[Integer] # target RDD: min_max_count_rdd = RDD(int, int, int) min_max_count_rdd = source_rdd.mapPartitions(find_min_max_count) min_max_count_list = min_max_count_rdd.collect() print(min_max_count_list) [ (min1, max1, count1), (min2, max2, count2), ... (minN, maxN, countN) ] -
最后,我们需要收集
min_max_count_rdd的内容,并找到最终的(minimum, maximum, count):# minimum = min(min1, min2, ..., minN) minimum = min(min_max_count_list)[0] # maximum = max(max1, max2, ..., maxN) maximum = max(min_max_count_list)[1] # count = (count1+count2+...+countN) count = sum(min_max_count_list)[2]
我们可以定义我们的函数如下。请注意,通过使用布尔标志first_time,我们避免对数字值范围做出任何假设:
def find_min_max_count(single_partition_iterator):
first_time = True
for n in single_partition_iterator:
if (first_time):
minimum = n;
maximum = n;
count = 1
first_time = True
else:
maximum = max(n, maximum)
minimum = min(n, minimum)
count += 1
#end-for
return [(minimum, maximum, count)]
#end-def
接下来,让我们创建一个RDD[Integer],然后应用mapPartitions()转换:
integers = [1, 2, 3, 1, 2, 3, 70, 4, 3, 2, 1]
# spark : SparkSession
source_rdd = spark.sparkContext.parallelize(integers)
# source RDD: source_rdd = RDD[Integer]
# target RDD: min_max_count_rdd = RDD(int, int, int)
min_max_count_rdd = source_rdd.mapPartitions(find_min_max_count)
min_max_count_list = min_max_count_rdd.collect() 
# compute the final values:
minimum = min(min_max_count_list)[0]
maximum = max(min_max_count_list)[1]
count = sum(min_max_count_list)[2]
这里的collect()是可扩展的,因为分区数将在千级而不是百万级。
总之,如果您有大量数据要减少到更少的信息量(汇总任务),则mapPartitions()转换是一个可能的选择。例如,它非常适用于查找数据集中的最小值和最大值或前 10 个值。mapPartitions()转换:
-
实现了汇总设计模式,将源 RDD 中所有元素组合成目标 RDD 的单个紧凑元素(如字典、元组或对象或元组的列表)。
-
可以用作
map()和foreach()的替代方案,但是每个分区只调用一次,而不是每个元素调用一次。 -
使程序员可以在每个分区而不是每个元素的基础上进行初始化。
接下来,我将讨论一个非常重要的话题:在使用mapPartitions()转换时如何处理和处理空分区。
处理空分区
在我们先前的解决方案中,我们使用了mapPartitions(func)转换,它将输入数据分成许多分区,然后并行地对每个分区应用程序员提供的函数func()。但如果其中一个或多个分区为空怎么办?在这种情况下,将没有数据(该分区中没有元素)进行迭代。我们需要编写我们自己的自定义函数func()(分区处理程序),以便正确和优雅地处理空分区。我们不能只是忽略它们。
空分区可能由于各种原因而发生。如果 Spark 分区器在分区数据时出现异常(例如,在网络传输中途由于损坏的记录导致),则某些分区可能为空。另一个原因可能是分区器没有足够的数据放入某个分区中。无论这些分区为何存在,我们都需要主动处理它们。
为了说明空分区的概念,我首先定义一个函数debug_partition()来显示每个分区的内容:
def debug_partition(iterator):
#print("type(iterator)=", type(iterator))
print("elements = ", list(elements))
#end-def
警告
请记住,在生产环境中,显示或调试分区内容可能会很昂贵,并且应尽量避免。我仅包含了用于教学和调试目的的print语句。
现在让我们创建一个 RDD,并以一种方式对其进行分区,以强制创建空分区。我们通过将分区数设置为大于 RDD 元素数来实现这一点:
>>> sc
<SparkContext master=local[*] appName=PySparkShell>
>>> numbers = [1, 2, 3, 4, 5]
>>> rdd = sc.parallelize(numbers, 7) 
>>> rdd.collect()
[1, 2, 3, 4, 5]
>>> rdd.getNumPartitions()
7
强制创建空分区。
我们可以使用debug_partition()函数检查每个分区:
>>> rdd.foreachPartition(debug_partition)
elements = [4]
elements = [3]
elements = [2]
elements = [] 
elements = [] 
elements = [5]
elements = [1]
一个空分区
从这个测试程序中,我们可以观察到以下内容:
-
一个分区可能为空(没有 RDD 元素)。您的自定义函数必须主动和优雅地处理空分区,即必须返回一个合适的值。不能只是忽略空分区。
-
iterator数据类型(代表单个分区,并作为参数传递给mapPartitions())是itertools.chain。itertools.chain是一个迭代器,它从第一个可迭代对象返回元素,直到耗尽,然后继续到下一个可迭代对象,直到所有可迭代对象都耗尽。它用于将连续的序列视为单个序列。
现在的问题是,我们如何处理 PySpark 中的空分区?以下模式可用于处理空分区。基本思想是使用 Python 的try-except组合,其中try块允许您测试一段代码的错误,并且except块让您处理错误:
# This is the template function
# to handle a single partition.
#
# source RDD: RDD[T]
#
# parameter: iterator
def func(iterator): 
print("type(iterator)=", type(iterator))
# ('type(iterator)=', <type 'itertools.chain'>)
try:
first_element = next(iterator) 
# if you are here it means that
# the partition is NOT empty;
# iterate/process the partition
# and return a proper result
except StopIteration: 
# if you are here it means that this
# partition is empty; now, you need
# to handle it and return a proper result
#end-def
iterator表示类型为T的单个分区元素。
尝试获取给定分区的第一个元素(作为类型为T的first_element)。如果失败(抛出异常),控制将转移到except(发生异常)块。
当给定分区为空时,您不能简单地忽略空分区,必须处理错误并返回适当的值。
处理空分区。
通常情况下,对于空分区,您应该返回一些特殊值,这些值可以通过filter()转换轻松过滤掉。例如,在 DNA 碱基计数问题中,您可以返回一个null值(而不是实际的字典),然后在mapPartitions()转换完成后过滤掉null值。
当寻找(min, max, count)时,为了处理空分区,我们将重写分区处理函数如下:
def find_min_max_count_revised(single_partition_iterator):
try:
first_element = next(single_partition_iterator)
# if you are here it means that
# the partition is NOT empty;
# process the partition and return a proper result
minimum = first_element;
maximum = first_element;
count = 1
for n in single_partition_iterator:
maximum = max(n, maximum)
minimum = min(n, minimum)
count += 1
#end-for
return [(minimum, maximum, count)]
except StopIteration:
# if you are here it means that this
# partition is empty; now, you need
# to handle it gracefully and return
# a proper result
# return a value that we can filter out later 
return [None]
#end-def
我们返回[None]以便我们可以将其过滤掉。
下面的代码展示了如何过滤掉空分区:
integers = [1, 2, 3, 1, 2, 3, 70, 4, 3, 2, 1]
# spark: SparkSession
source_rdd = spark.sparkContext.parallelize(integers, 4)
# source RDD: source_rdd = RDD[Integer]
# target RDD: min_max_count_rdd = RDD(int, int, int)
min_max_count_rdd = source_rdd.mapPartitions(find_min_max_count_revised)
# filter out fake values returned from empty partitions
min_max_count_rdd_filtered = min_max_count_rdd.filter(lambda x: x is not None) 
# compute the final triplet (minimum, maximum, count)
final_triplet = min_max_count_rdd_filtered.reduce(
lambda x, y: (min(x[0], y[0]), max(x[1], y[1]), x[2]+y[2]))
print(final_triplet)
(1, 70, 11)
放弃空分区的结果。
优缺点。
Spark 的mapPartitions()是一种效率高、好处多的转换,总结如下:
低处理开销。
Mapper 函数仅对 RDD 分区应用一次,而不是对 RDD 元素应用一次,这限制了函数调用次数,使其等于分区数,而不是元素数。请注意,对于某些转换操作(如map()和flatMap()),为所有分区的每个元素调用函数的开销可能很大。
高效的本地聚合。
由于mapPartitions()在分区级别上工作,它使用户有机会在该级别执行过滤和聚合。这种本地聚合极大地减少了被洗牌的数据量。通过mapPartitions(),我们将一个分区减少为一个小而完整的数据结构。减少排序和洗牌操作的数量,提高了减少操作的效率和可靠性。
避免显式过滤步骤。
此转换使我们能够在迭代分区(可能包含数千或数百万个元素)期间插入filter()步骤,有效地将map()/flatMap()操作与filter()操作结合起来。当您迭代分区元素时,可以丢弃不需要的元素,然后将剩余元素映射和聚合成所需的数据类型(例如列表、元组、字典或自定义数据类型)。甚至可以同时应用多个过滤器。这样可以提高效率,避免设置和管理多个数据转换步骤的开销。
避免重复的繁重初始化。
使用mapPartitions(),您可以使用广播变量(在所有集群节点之间共享)来初始化聚合分区元素所需的数据结构。如果需要进行大量的初始化操作,那么代价并不高,因为初始化的次数限于分区的数量。在使用map()和flatMap()等窄转换时,由于重复的初始化和反初始化,这些数据结构的创建可能非常低效。而使用mapPartitions(),初始化仅在函数开始时执行一次,对给定分区中的所有数据记录生效。一个重初始化的例子可能是初始化数据库(关系型或 HBase)连接以读取/更新/插入记录。
使用mapPartitions()转换也存在一些潜在的缺点:
-
由于我们将函数应用于整个分区,调试可能比其他映射器转换更困难。
-
适当的数据分区对于
mapPartitions()非常重要。你希望最大化集群对这种转换的利用,分区的数量应该大于可用的映射器/执行器数量,这样就不会有任何空闲的映射器/执行器。
数据框架和 mapPartitions()转换
给定一个 DataFrame,你可以使用 SQL 转换轻松总结你的数据:
# step-1: create your desired DataFrame
df = <a-dataframe-with-some-columns>
# step-2: register your Dataframe as a table
df.registerTempTable("my_table")
# step-3: apply summarization by a SQL transformation
df2 = spark.sql("select min(col1), max(col1), ... from my_table")
Spark 的 DataFrame 并不直接支持 mapPartitions(),但很容易将 mapPartitions()的等效操作应用于 DataFrame。以下示例查找一组物品的最低价格:
>>> tuples3 = [
('clothing', 'shirt', 20), ('clothing', 'tshirt', 10), ('clothing', 'pants', 30),
('fruit', 'banana', 3), ('fruit', 'apple', 4), ('fruit', 'orange', 5),
('veggie', 'carrot', 7), ('veggie', 'tomato', 8), ('veggie', 'potato', 9)]
>>>
>>> df = spark.createDataFrame(tuples3, ["group_id", "item", "price"])
>>> df.show(truncate=False)
+--------+------+-----+
|group_id|item |price|
+--------+------+-----+
|clothing|shirt |20 |
|clothing|tshirt|10 |
|clothing|pants |30 |
|fruit |banana|3 |
|fruit |apple |4 |
|fruit |orange|5 |
|veggie |carrot|7 |
|veggie |tomato|8 |
|veggie |potato|9 |
+--------+------+-----+
# Find minimum price for all items
>>> df.agg({'price': 'min'}).show()
+----------+
|min(price)|
+----------+
| 3|
+----------+
# Find minimum price for each group of items
>>> df.groupby('group_id').agg({'price': 'min'}).show()
+--------+----------+
|group_id|min(price)|
+--------+----------+
|clothing| 10|
| fruit| 3|
| veggie| 7|
+--------+----------+
您可以对 DataFrame 应用多个聚合函数:
>>> import pyspark.sql.functions as F
>>> df.groupby('group_id')
.agg(F.min("price").alias("minimum"), F.max("price").alias("maximum"))
.show()
+--------+-------+-------+
|group_id|minimum|maximum|
+--------+-------+-------+
|clothing| 10| 30|
| fruit| 3| 5|
| veggie| 7| 9|
+--------+-------+-------+
PySpark 的 DataFrame 数据抽象并不直接支持mapPartitions()转换,但如果你希望使用它,你可以将你的 DataFrame 转换为 RDD(通过应用DataFrame.rdd),然后对 RDD 应用mapPartitions()转换:
# SparkSession available as 'spark'.
>>> tuples3 = [ ('alex', 440, 'PHD'), ('jane', 420, 'PHD'),
... ('bob', 280, 'MS'), ('betty', 200, 'MS')]
>>>
>>> df = spark.createDataFrame(tuples3, ["name", "amount", "education"])
>>> df.show()
+-----+------+---------+
| name|amount|education|
+-----+------+---------+
| alex| 440| PHD|
| jane| 420| PHD|
| bob| 280| MS|
|betty| 200| MS|
+-----+------+---------+
>>> df
DataFrame[name: string, amount: bigint, education: string]
>>>
>>> my_rdd = df.rdd
>>> my_rdd.collect()
[Row(name='alex', amount=440, education='PHD'),
Row(name='jane', amount=420, education='PHD'),
Row(name='bob', amount=280, education='MS'),
Row(name='betty', amount=200, education='MS')]
现在我们可以将mapPartitions()应用于my_rdd:
def my_custom_function(partition): 
... initialize your data structures
for single_row in partition:
...
#end-for
return <summary-of-single-partition>
#end-def
result = my_rdd.mapPartitions(my_custom_function)
请注意,在迭代分区时,每个元素(single_row)将是一个 Row 对象。
总结
总结一下:
-
Spark 提供了许多简单而强大的转换(如
map()、flatMap()、filter()和mapPartitions()),可以用来将一种形式的数据转换为另一种形式。Spark 转换使我们能够以简单的方式执行 ETL 操作。 -
如果你的数据需要将一个元素(如一个
String)映射到另一个元素(如元组、键值对或列表),你可以使用map()或flatMap()转换。 -
当你想将大量数据总结为少量有意义的信息时(总结设计模式),
mapPartitions()是一个不错的选择。 -
mapPartitions()转换允许你在每个分区中进行一次重量级初始化(例如,设置数据库连接),而不是针对每个 RDD 元素都初始化一次。在处理大型数据集上的重量级初始化时,这有助于提高数据分析的性能。 -
一些 Spark 转换在性能上有差异,因此你需要根据数据和性能需求选择合适的转换方式。例如,对于数据汇总,
mapPartitions()通常比map()的性能和扩展性更好。
接下来的章节将专注于 Spark 中的减少操作。
第四章:Spark 中的 Reductions
本章重点介绍了 Spark 中 RDD 的 Reduction 转换。特别是,我们将使用(键,值)对的 RDD,这是 Spark 中许多操作所需的常见数据抽象。可能需要进行一些初始的 ETL 操作来将数据转换为(键,值)形式,但是使用 Pair RDDs,你可以对一组值执行任何所需的聚合。
Spark 支持几种强大的 Reduction 转换和操作。最重要的 Reduction 转换包括:
-
reduceByKey() -
combineByKey() -
groupByKey() -
aggregateByKey()
所有的 *ByKey() 转换接受一个源 RDD[(K, V)] 并创建一个目标 RDD[(K, C)](对于某些转换,如 reduceByKey(),V 和 C 是相同的)。这些转换的功能是通过查找给定键的所有值(所有唯一键)来减少所有值,例如:
-
所有值的平均数
-
所有值的总和和计数
-
所有值的模式和中位数
-
所有值的标准偏差
选择 Reduction 转换
与 Mapper 转换类似,选择适合任务的正确工具非常重要。对于某些减少操作(如找到中位数),减少器需要同时访问所有值。对于其他操作,如找到所有值的总和或计数,这并不需要。如果你想要找到每个键的值的中位数,那么 groupByKey() 将是一个不错的选择,但是如果一个键有大量的值(可能会导致内存溢出问题),这种转换就不太适合。另一方面,如果你想要找到所有值的总和或计数,那么 reduceByKey() 可能是一个不错的选择:它使用可结合和交换的减少函数合并每个键的值。
本章将通过简单的 PySpark 示例向你展示如何使用最重要的 Spark Reduction 转换。我们将重点讨论在 Spark 应用中最常用的转换。我还将讨论减少的一般概念,以及作为高效减少算法设计原则的幺半群。我们将从学习如何创建 Pair RDDs 开始,这是 Spark Reduction 转换所必需的。
创建 Pair RDDs
给定一组键及其关联的值,减少转换使用算法(值的总和、值的中位数等)减少每个键的值。因此,本章介绍的减少转换适用于(键,值)对,这意味着 RDD 元素必须符合此格式。在 Spark 中有几种创建 Pair RDDs 的方法。例如,你还可以在集合(如元组列表和字典)上使用 parallelize(),如下所示:
>>> key_value = [('A', 2), ('A', 4), ('B', 5), ('B', 7)]
>>> pair_rdd = spark.sparkContext.parallelize(key_value)
>>> pair_rdd.collect() 
[('A', 2), ('A', 4), ('B', 5), ('B', 7)]
>>> pair_rdd.count()
4
>>> hashmap = pair_rdd.collectAsMap()
>>> hashmap
{'A': 4, 'B': 7}
pair_rdd 有两个键,{'A', 'B'}。
接下来,假设你有与天气相关的数据,你想要创建 (city_id, temperature) 的对。你可以使用 map() 转换来完成这个任务。假设你的输入具有以下格式:
<city_id><,><latitude><,><longitude><,><temperature>
首先,定义一个函数来创建所需的(键,值)对:
def create_key_value(rec):
tokens = rec.split(",")
city_id = tokens[0]
temperature = tokens[3]
return (city_id, temperature) 
键是 city_id,值是 temperature。
然后使用 map() 创建你的键值对 RDD:
input_path = *`<``your``-``temperature``-``data``-``path``>`*
rdd = spark.sparkContext.textFile(input_path)
pair_rdd = rdd.map(create_key_value)
# or you can write this using a lambda expression as:
# pair_rdd = rdd.map(lambda rec: create_key_value(rec))
有许多其他创建(键,值)对 RDD 的方法:reduceByKey() 例如,接受一个源 RDD[(K, V)] 并生成一个目标 RDD[(K, V)],combineByKey() 则接受一个源 RDD[(K, V)] 并生成一个目标 RDD[(K, C)]。
缩减转换
通常,缩减转换会将数据大小从大批量值(如数字列表)减小到较小值。缩减的示例包括:
-
找到所有值的和与平均值
-
找到所有值的平均值、众数和中位数
-
计算所有值的平均值和标准差
-
找到所有值的
(最小值、最大值、计数) -
找到所有值的前 10 个
简而言之,缩减转换大致对应于函数式编程中的 fold 操作(也称为 reduce、accumulate 或 aggregate)。该转换可以应用于所有数据元素(例如找到所有元素的总和)或每个键的所有元素(例如找到每个键的所有元素的总和)。
对于单个分区的一组数字 {47, 11, 42, 13} 进行的简单加法缩减在 图 4-1 中有所说明。

图 4-1. 单个分区中的加法缩减
图 4-2 展示了对两个分区元素求和的缩减操作。Partition-1 和 Partition-2 的最终缩减值分别为 21 和 18。每个分区执行本地缩减,最终来自两个分区的结果被缩减。

图 4-2. 两个分区上的加法缩减
Reducer 是函数式编程中的核心概念,用于将一组对象(如数字、字符串或列表)转换为单个值(如数字的总和或字符串对象的连接)。Spark 和 MapReduce 范式使用该概念将一组值聚合为每个键的单个值。考虑以下(键,值)对,其中键是一个 String,值是一个 Integer 列表:
(key1, [1, 2, 3])
(key2, [40, 50, 60, 70, 80])
(key3, [8])
最简单的 reducer 将是一个针对每个键的数值集合的加法函数。应用该函数后,结果将是:
(key1, 6)
(key2, 300)
(key3, 8)
或者你可以将每个(键,值)缩减为(键,对),其中对是 (值的总和,值的数量):
(key1, (6, 3))
(key2, (300, 5))
(key3, (8, 1))
Reducer 被设计为并行和独立操作,这意味着没有 reducer 之间的同步。Spark 集群的资源越多,缩减操作就可以越快。在最坏的情况下,如果只有一个 reducer,那么缩减将作为队列操作进行。一般来说,集群将提供许多 reducer(取决于资源可用性)用于缩减转换。
在 MapReduce 和分布式算法中,减少是解决问题所需的操作。在 MapReduce 编程范式中,程序员定义了一个 mapper 和一个 reducer,具有以下map()和reduce()签名(注意[]表示可迭代):
map()
(K[1], V[1]) → [(K[2], V[2])]
reduce()
(K[2], [V[2]]) → [(K[3], V[3])]
map()函数将一个(键[1],值[1])对映射到一组(键[2],值[2])对。完成所有映射操作后,自动执行排序和洗牌(此功能由 MapReduce 范式提供,不由程序员实现)。MapReduce 的排序和洗牌阶段与 Spark 的groupByKey()转换非常相似。
reduce()函数将一个(键[2],[值[2]])对减少为一组(键[3],值[3])对。约定用于表示对象的列表(或可迭代对象的列表)。因此,我们可以说,减少转换将值列表减少为具体结果(例如值的总和、值的平均值或所需的数据结构)。
Spark 的减少操作
Spark 提供了一组丰富且易于使用的减少转换。正如本章开头所述,我们的重点将放在对成对 RDD 的减少上。因此,我们将假设每个 RDD 都有一组键,并且对于每个键(如 K),我们有一组值:
{ (K, V1), (K, V2), ..., (K, Vn) }
表格 4-1 列出了 Spark 中可用的减少转换。
表格 4-1. Spark 的减少转换
| 变换 | 描述 |
|---|---|
aggregateByKey() |
使用给定的组合函数和中性“零值”聚合每个键的值 |
combineByKey() |
使用自定义的聚合函数集合组合每个键的元素的通用函数 |
countByKey() |
计算每个键的元素数量,并将结果作为字典返回给主节点 |
foldByKey() |
使用关联函数和中性“零值”合并每个键的值 |
groupByKey() |
将 RDD 中每个键的值分组为单个序列 |
reduceByKey() |
使用关联和交换的减少函数合并每个键的值 |
sampleByKey() |
返回通过键变量采样率指定的不同键的 RDD 子集 |
sortByKey() |
按键对 RDD 进行排序,使得每个分区包含按升序排列的元素范围 |
这些转换函数都作用于由 RDD 表示的(键,值)对。在本章中,我们将仅关注在给定的唯一键集上的数据减少。例如,给定键 K 的以下(键,值)对:
{ (K, V1), (K, V2), ..., (K, Vn) }
我们假设 K 有一个长度为 n (> 0) 的值列表:
[ V1, V2, ..., Vn ]
为了简化问题,减少的目标是生成以下的配对(或一组配对):
(K, R)
其中:
f(V1, V2, ..., Vn) -> R
函数f()被称为减少器或减少函数。Spark 的减少转换将此函数应用于值列表以找到减少的值R。请注意,Spark 不对要减少的值([V[1], V[2], ..., V[n]])施加任何排序。
本章将包括解决方案的实际示例,演示了 Spark 最常见的减少转换的使用:reduceByKey()、groupByKey()、aggregateByKey()和combineByKey()。为了帮助你入门,让我们看一个非常简单的groupByKey()转换的例子。如图 4-3 中的例子所示,它的工作方式类似于 SQL 的GROUP BY语句。在这个例子中,我们有四个键, {A, B, C, P},它们的相关值被分组为整数列表。源 RDD 是一个RDD[(String, Integer)],其中每个元素是一个(String, Integer)对。目标 RDD 是一个RDD[(String, [Integer])],其中每个元素是一个(String, [Integer])对;值是一个整数可迭代列表。

图 4-3. groupByKey()转换
注意
默认情况下,Spark 的减少操作不会对减少的值进行排序。例如,在图 4-3 中,键B的减少值可以是[4, 8]或[8, 4]。如果需要,可以在最终减少之前对值进行排序。如果您的减少算法需要排序,必须显式排序值。
现在你已经对减少器的工作原理有了一般的了解,让我们继续看一个实际的例子,展示如何使用不同的 Spark 减少转换来解决一个数据问题。
简单的热身示例
假设我们有一对列表(K, V),其中K(键)是一个String,V(值)是一个Integer:
[
('alex', 2), ('alex', 4), ('alex', 8),
('jane', 3), ('jane', 7),
('rafa', 1), ('rafa', 3), ('rafa', 5), ('rafa', 6),
('clint', 9)
]
在这个例子中,我们有四个唯一的键:
{ 'alex', 'jane', 'rafa', 'clint' }
假设我们想要按键(sum)合并值。这种减少的结果将是:
[
('alex', 14),
('jane', 10),
('rafa', 15),
('clint', 9)
]
其中:
key: alex => 14 = 2+4+8
key: jane => 10 = 3+7
key: rafa => 15 = 1+3+5+6
key: clint => 9 (single value, no operation is done)
有许多方法可以添加这些数字以获得所需的结果。我们如何得到这些减少的(键,值)对?在这个例子中,我们可以使用任何常见的 Spark 转换。按键聚合或组合值是减少的一种类型——在经典的 MapReduce 范式中,这被称为按键减少(或简称为减少)函数。MapReduce 框架对每个唯一键调用应用程序(用户定义的)减少函数一次。该函数迭代与该键关联的值,并产生零个或多个输出作为(键,值)对,解决了将每个唯一键的元素组合为单个值的问题。(请注意,在某些应用程序中,结果可能不止一个值。)
这里我介绍了使用 Spark 转换的四种不同解决方案。对于所有解决方案,我们将使用以下 Python data和key_value_pairs RDD:
>>> data = 
[
('alex', 2), ('alex', 4), ('alex', 8),
('jane', 3), ('jane', 7),
('rafa', 1), ('rafa', 3), ('rafa', 5), ('rafa', 6),
('clint', 9)
]
>>> key_value_pairs = spark.SparkContext.parallelize(data) 
>>> key_value_pairs.collect()
[
('alex', 2), ('alex', 4), ('alex', 8),
('jane', 3), ('jane', 7),
('rafa', 1), ('rafa', 3), ('rafa', 5), ('rafa', 6),
('clint', 9)
]
data 是一个 Python 集合 —— 一个 (key, value) 对的列表。
key_value_pairs 是一个 RDD[(String, Integer)]。
使用 reduceByKey() 解决问题
对于给定键的值求和非常直接:添加前两个值,然后添加下一个,依此类推。Spark 的 reduceByKey() 转换使用可结合且可交换的 reduce 函数合并每个键的值。在所有集群节点上在合并每个分区的值之前使用组合器(优化的小型 reducer)。
对于 reduceByKey() 转换,源 RDD 是一个 RDD[(K, V)],目标 RDD 是一个 RDD[(K, V)]。请注意,RDD 值 (V) 的源和目标数据类型相同。这是 reduceByKey() 的一个限制,可以通过使用 combineByKey() 或 aggregateByKey() 避免。
我们可以使用 lambda 表达式(匿名函数)应用 reduceByKey() 转换:
# a is (an accumulated) value for key=K
# b is a value for key=K
sum_per_key = key_value_pairs.reduceByKey(lambda a, b: a+b)
sum_per_key.collect()
[('jane', 10), ('rafa', 15), ('alex', 14), ('clint', 9)]
或者,我们可以使用定义的函数,例如 add:
from operator import add
sum_per_key = key_value_pairs.reduceByKey(add)
sum_per_key.collect()
[('jane', 10), ('rafa', 15), ('alex', 14), ('clint', 9)]
通过 reduceByKey() 按键添加值是一种优化的解决方案,因为聚合发生在最终聚合所有分区之前的分区级别。
使用 groupByKey() 解决问题
我们也可以通过使用 groupByKey() 转换来解决这个问题,但这种解决方案性能不佳,因为涉及将大量数据移动到 reducer 节点(在本章后面讨论 shuffle 步骤时,您将了解更多原因)。
对于 reduceByKey() 转换,源 RDD 是一个 RDD[(K, V)],目标 RDD 是一个 RDD[(K, [V])]。请注意,源和目标数据类型不同:源 RDD 的值数据类型是 V,而目标 RDD 的值数据类型是 [V](一个 V 的可迭代列表)。
下面的例子演示了如何使用带有 lambda 表达式的 groupByKey() 来按键汇总值:
sum_per_key = key_value_pairs
.grouByKey() 
.mapValues(lambda values: sum(values)) 
sum_per_key.collect()
[('jane', 10), ('rafa', 15), ('alex', 14), ('clint', 9)]
按键分组值(类似于 SQL 的 GROUP BY)。现在每个键将有一组 Integer 值;例如,三对 {('alex', 2), ('alex', 4), ('alex', 8)} 将被减少为单个对 ('alex', [2, 4, 8])。
使用 Python 的 sum() 函数添加每个键的值。
使用 aggregateByKey() 解决问题
在最简单的情况下,aggregateByKey() 转换被定义为:
aggregateByKey(zero_value, seq_func, comb_func)
source RDD: RDD[(K, V)]
target RDD: RDD(K, C))
它将源 RDD 中每个键的值聚合到目标 RDD 中,使用给定的合并函数和中立的“零值”(用于每个分区的初始值)。这个函数可以返回一个不同的结果类型 (C),而不是源 RDD 中值的类型 (V),尽管在此示例中两者都是 Integer 数据类型。因此,我们需要一个操作来在单个分区内合并值(将类型为 V 的值合并为类型为 C 的值),以及一个操作来在分区之间合并值(从多个分区中合并类型为 C 的值)。为了避免不必要的内存分配,这两个函数都允许修改并返回它们的第一个参数,而不是创建新的 C。
以下示例演示了 aggregateByKey() 转换的使用:
# zero_value -> C
# seq_func: (C, V) -> C
# comb_func: (C, C) -> C
>>> sum_per_key = key_value_pairs.aggregateByKey(
... 0, 
... (lambda C1, C2: C1+C2) 
... )
>>> sum_per_key.collect()
[('jane', 10), ('rafa', 15), ('alex', 14), ('clint', 9)]
应用于每个分区的 zero_value 是 0。
seq_func 在单个分区上使用。
comb_func 用于合并分区的值。
使用 combineByKey() 解决问题
combineByKey() 转换是 Spark 中最通用和强大的减少转换。在其最简单的形式下,它定义为:
combineByKey(create_combiner, merge_value, merge_combiners)
source RDD: RDD[(K, V)]
target RDD: RDD[(K, C))
类似于 aggregateByKey(),combineByKey() 转换将源 RDD[(K, V)] 转换为目标 RDD[(K, C)]。再次强调,V 和 C 可以是不同的数据类型(这是 combineByKey() 的强大之处之一——例如,V 可以是 String 或 Integer,而 C 可以是列表、元组或字典),但在此示例中,两者都是 Integer 数据类型。
combineByKey() 接口允许我们自定义减少和合并行为以及数据类型。因此,为了使用此转换,我们必须提供三个函数:
create_combiner
此函数将单个 V 转换为 C(例如,创建一个单元素列表)。它在单个分区内用于初始化 C。
merge_value
此函数将 V 合并到 C 中(例如,将其添加到列表的末尾)。这在单个分区内用于将值聚合到 C 中。
merge_combiners
此函数将两个 C 合并为一个 C(例如,合并列表)。这在合并来自两个分区的值时使用。
我们使用 combineByKey() 的解决方案如下:
>>> sum_per_key = key_value_pairs.combineByKey(
... (lambda v: v), 
... (lambda C,v: C+v), 
... (lambda C1,C2: C1+C2) 
... )
>>> sum_per_key.collect()
[('jane', 10), ('rafa', 15), ('alex', 14), ('clint', 9)]
create_combiner 在每个分区中创建初始值。
merge_value 合并分区中的值。
merge_combiners 将来自不同分区的值合并到最终结果中。
为了更好地理解 combineByKey() 转换的功能,让我们看另一个例子。假设我们想找到每个键的值的平均值。为了解决这个问题,我们可以创建一个组合数据类型 (C),如 (sum, count),它将保存值的总和及其相关的计数:
# C = combined type as (sum, count)
>>> sum_count_per_key = key_value_pairs.combineByKey(
... (lambda v: (v, 1)),
... (lambda C,v: (C[0]+v, C[1]+1),
... (lambda C1,C2: (C1[0]+C2[0], C1[1]+C2[1]))
... )
>>> mean_per_key = sum_count_per_key.mapValues(lambda C: C[0]/C[1])
给定名为 {P1, P2, P3} 的三个分区,图 4-4 显示如何创建一个组合器(数据类型 C),如何将一个值合并到组合器中,最后如何合并两个组合器。

图 4-4. combineByKey() 转换示例
接下来,我将讨论单子群的概念,这将帮助您理解在减少转换中组合器的功能。
什么是单子群?
单子群是编写高效的 MapReduce 算法的有用设计原则。¹ 如果您不理解单子群,您可能会编写不产生语义正确结果的减少器算法。如果您的减少器是单子群,那么您可以确信它在分布式环境中会产生正确的输出。
由于 Spark 的减少是基于分区的执行(即,您的减少函数是分布式的而不是顺序函数),为了获得正确的输出,您需要确保您的减少函数在语义上是正确的。我们稍后将看一些使用单子群的示例,但首先让我们检查底层的数学概念。
在代数中,单子群是一种具有单一关联二元操作和单位元素(也称为零元素)的代数结构。
对于我们的目的,我们可以非正式地定义单子群为 M = (T, f, Zero),其中:
-
T是一个数据类型。 -
f()是一个二元操作:f: (T, T) -> T。 -
Zero是T类型的一个实例。
注意
Zero 是类型 T 的身份(中性)元素;这不一定是数字零。
如果 a、b、c 和 Zero 是类型 T 的,对于三元组 (T, f, Zero) 来说,必须满足以下属性:
-
二元操作
f: (T, T) -> T -
中性元素
for all a in T: f(Zero, a) = a f(a, Zero) = a -
结合性
for all a, b, c in T: f(f(a, b), c) = f(a, f(b, c))
并非每个二元操作都是单子群。例如,整数集合上的 mean() 函数不是一个关联函数,因此不是一个单子群,如下面的证明所示:
mean(10, mean(30, 50)) != mean(mean(10, 30), 50)
where
mean(10, mean(30, 50))
= mean (10, 40)
= 25
mean(mean(10, 30), 50)
= mean (20, 50)
= 35
25 != 35
这是什么意思?给定一个 RDD[(String, Integer)],我们可能会试图编写以下转换以找到每个键的平均值:
# rdd: RDD[(String, Integer)]
# WRONG REDUCTION to find average by key
avg_by_key = rdd.reduceByKey(lambda x, y: (x+y)/2)
但这不会产生正确的结果,因为平均值的平均值不是平均值—换句话说,这里使用的平均/平均函数不是幺半群。假设这个rdd有三个元素:{("A", 1), ("A", 2), ("A", 3)};{("A", 1), ("A", 2)}在分区 1 中,{("A", 3)}在分区 2 中。使用上述解决方案将导致分区 1 的聚合值为("A", 1.5),分区 2 的聚合值为("A", 3.0)。然后,将这两个分区的结果结合起来,得到最终平均值为(1.5 + 3.0) / 2 = 2.25,这不是正确的结果(三个值的平均值为 2.0)。如果您的缩小器是幺半群,它保证能够正确运行并产生正确的结果。
幺半群和非幺半群示例
为了帮助您理解和识别幺半群,让我们看一些幺半群和非幺半群的例子。以下是幺半群的示例:
-
具有加法的整数:
((a + b ) + c) = (a + (b + c)) 0 + n = n n + 0 = n The zero element for addition is the number 0. -
具有乘法的整数:
((a * b) * c) = (a * (b * c)) 1 * n = n n * 1 = n The zero element for multiplication is the number 1. -
具有连接的字符串:
(a + (b + c)) = ((a + b) + c) "" + s = s s + "" = s The zero element for concatenation is an empty string of size 0. -
具有连接的列表:
List(a, b) + List(c, d) = List(a,b,c,d) -
具有它们的联合集:
Set(1,2,3) + Set(2,4,5) = Set(1,2,3,2,4,5) = Set(1,2,3,4,5) S + {} = S {} + S = S The zero element is an empty set {}.
这里还有一些非幺半群示例:
-
具有均值函数的整数:
mean(mean(a,b),c) != mean(a, mean(b,c)) -
具有减法的整数:
((a - b) -c) != (a - (b - c)) -
具有除法的整数:
((a / b) / c) != (a / (b / c)) -
具有模函数的整数:
mode(mode(a, b), c) != mode(a, mode(b, c)) -
具有中位数函数的整数:
median(median(a, b), c) != median(a, median(b, c))
在某些情况下,可以将非幺半群转换为幺半群。例如,通过对我们的数据结构进行简单更改,我们可以找到一组数字的正确平均值。但是,没有算法可以自动将非幺半群结构转换为幺半群。
在 Spark 中编写分布式算法与在单个服务器上编写顺序算法大不相同,因为算法在分区数据上并行运行。因此,在编写缩小器时,您需要确保您的缩小函数是一个幺半群。现在您理解了这个重要概念,让我们继续看一些实际的例子。
电影问题
第一个例子的目标是提出一个基本问题,然后通过 PySpark 的不同 Spark 减少转换提供解决方案。对于所有减少转换,我都精心选择了数据类型,使它们形成一个幺半群。
电影问题可以陈述如下:给定一组用户、电影和评分(在 1 到 5 的范围内),我们想找出用户对所有电影的平均评分。因此,如果用户userID=100评价了四部电影:
(100, "Lion King", 4.0)
(100, "Crash", 3.0)
(100, "Dead Man Walking", 3.5)
(100, "The Godfather", 4.5)
我们想生成以下输出:
(100, 3.75)
其中:
3.75 = mean(4.0, 3.0, 3.5, 4.5)
= (4.0 + 3.0 + 3.5 + 4.5) / 4
= 15.0 / 4
对于这个例子,请注意,对一组评分进行reduceByKey()转换不会总是产生正确的输出,因为平均(或均值)不是一组浮点数/整数的代数幺半群。换句话说,如前面的部分所讨论的,平均值的平均值不等于所有输入数字的平均值。这里有一个简单的证明。假设我们想找出六个值(数字 1-6)的平均值,存储在单个分区中。我们可以使用mean()函数来完成:
mean(1, 2, 3, 4, 5, 6)
= (1 + 2 + 3 + 4 + 5 + 6) / 6
= 21 / 6
= 3.5 [correct result]
现在,让我们把mean()函数作为一个分布式函数。假设值存储在三个分区中:
Partition-1: (1, 2, 3)
Partition-2: (4, 5)
Partition-3: (6)
首先,我们计算每个分区的平均值:
mean(1, 2, 3, 4, 5, 6)
= mean (
mean(Partition-1),
mean(Partition-2),
mean(Partition-3)
)
mean(Partition-1)
= mean(1, 2, 3)
= mean( mean(1,2), 3)
= mean( (1+2)/2, 3)
= mean(1.5, 3)
= (1.5+3)/2
= 2.25
mean(Partition-2)
= mean(4,5)
= (4+5)/2
= 4.5
mean(Partition-3)
= mean(6)
= 6
然后我们找到这些值的平均值。一旦所有分区都被处理,因此,我们得到:
mean(1, 2, 3, 4, 5, 6)
= mean (
mean(Partition-1),
mean(Partition-2),
mean(Partition-3)
)
= mean(2.25, 4.5, 6)
= mean(mean(2.25, 4.5), 6)
= mean((2.25 + 4.5)/2, 6)
= mean(3.375, 6)
= (3.375 + 6)/2
= 9.375 / 2
= 4.6875 [incorrect result]
为了避免这个问题,我们可以使用一个支持结合性和交换性的幺半群数据结构,例如一对(sum, count),其中sum是到目前为止(每个分区)所有数字的总和,count是到目前为止我们看到的评分数量。如果我们定义我们的mean()函数如下:
mean(pair(sum, count)) = sum / count
我们得到:
mean(1,2,3,4,5,6)
= mean(mean(1,2,3), mean(4,5), mean(6))
= mean(pair(1+2+3, 1+1+1), pair(4+5, 1+1), pair(6,1))
= mean(pair(6, 3), pair(9, 2), pair(6,1))
= mean(mean(pair(6, 3), pair(9, 2)), pair(6,1))
= mean(pair(6+9, 3+2), pair(6,1))
= mean(pair(15, 5), pair(6,1))
= mean(pair(15+6, 5+1))
= mean(pair(21, 6))
= 21 / 6 = 3.5 [correct result]
正如这个例子所显示的,通过使用幺半群,我们可以实现结合性。因此,当您的函数f()是可交换和可结合的时候,您可以应用reduceByKey()转换:
# a = (sum1, count1)
# b = (sum2, count2)
# f(a, b) = a + b
# = (sum1+sum2, count1+count2)
#
reduceByKey(lambda a, b: f(a, b))
例如,加法(+)操作是可交换和可结合的,但均值/平均函数不满足这些属性。
注意
正如我们在第一章中看到的,一个可交换的函数确保聚合的 RDD 元素顺序无关紧要:
f(A, B) = f(B, A)
一个结合函数确保在聚合过程中分组元素的顺序不影响最终结果:
f(f(A, B), C) = f(A, f(B, C))
输入要分析的数据集
我们将为这个问题使用的样本数据集是来自MovieLens的数据集。为简单起见,我假设您已经下载并解压缩了文件到/tmp/movielens/目录中。请注意,不需要将文件放置在建议的位置;您可以将文件放置在您喜欢的目录中,并相应地更新输入路径。
提示
完整的 MovieLens 数据集(ml-latest.zip)为 265 MB。如果您想要使用一个更小的数据集来运行、测试和调试这里列出的程序,您可以下载小型 MovieLens 数据集,这是一个 1 MB 的文件,包含了由 600 名用户对 9,000 部电影进行的 100,000 次评分和 3,600 次标签应用。
所有的评分都包含在文件ratings.csv中。该文件中的每一行在标题行之后表示一个用户对一部电影的一次评分,格式如下:
<userId><,><movieId><,><rating><,><timestamp>
在这个文件中:
-
这些行首先按
userId排序,然后对于每个用户,按movieId排序。 -
评分是在 5 星级的基础上进行的,增量为半星(从 0.5 星到 5.0 星)。
-
时间戳表示自 1970 年 1 月 1 日协调世界时(UTC)午夜以来的秒数(此字段在我们的分析中被忽略)。
解压下载的文件后,您应该有以下文件:
$ ls -l /tmp/movielens/
8,305 README.txt
725,770 links.csv
1,729,811 movies.csv
620,204,630 ratings.csv
21,094,823 tags.csv
首先,检查记录数(根据您下载文件的时间,您看到的记录数可能会有所不同):
$ wc -l /tmp/movielens/ratings.csv
22,884,378 /tmp/movielens/ratings.csv
接下来,看一下前几条记录:
$ head -6 /tmp/movielens/ratings.csv
userId,movieId,rating,timestamp
1,169,2.5,1204927694
1,2471,3.0,1204927438
1,48516,5.0,1204927435
2,2571,3.5,1436165433
2,109487,4.0,1436165496
由于我们使用 RDDs,我们不需要与数据相关联的元数据。因此,我们可以从ratings.csv文件中删除第一行(标题行):
$ tail -n +2 ratings.csv > ratings.csv.no.header
$ wc -l ratings.csv ratings.csv.no.header
22,884,378 ratings.csv
22,884,377 ratings.csv.no.header
现在我们已经获取了样本数据,我们可以解决这个问题的几个方案。第一个解决方案将使用aggregateByKey(),但在此之前,我将介绍此转换背后的逻辑。
aggregateByKey()转换
Spark 的aggregateByKey()转换会初始化每个键在每个分区上的零值,这是一个初始组合数据类型(C);这是一个中性值,通常为(0, 0),如果组合数据类型是(sum, count)。这个零值与分区中的第一个值合并以创建一个新的C,然后与第二个值合并。这个过程继续,直到我们合并了该键的所有值。最后,如果同一个键存在于多个分区中,则这些值将组合在一起以生成最终的C。
图 4-5 和 4-6 展示了aggregateByKey()如何使用不同的零值工作。零值是按键、每个分区应用的。这意味着如果一个键 X 在 N 个分区中,零值将应用 N 次(这 N 个分区的每个都将为键 X 初始化为零值)。因此,选择此值非常重要。
图 4-5 演示了aggregateByKey()如何与zero-value=(0, 0)一起工作。

图 4-5. aggregateByKey()与zero-value=(0, 0)
通常,您会使用(0, 0),但图 4-6 展示了如何使用零值为(10, 20)的相同转换工作。

图 4-6. aggregateByKey()与zero-value=(10, 20)
第一个解决方案使用aggregateByKey():
要找到每个用户的平均评分,第一步是将每条记录映射为形式为(键,值)对:
(userID-as-key, rating-as-value)
使用reduceByKey()转换来按键累加值的最简单方法,但是我们不能用reduceByKey()来计算每个用户的平均评分,因为如我们所见,平均函数对评分集(作为浮点数)不是一个幺半群。为了使其成为幺半群操作,我们使用一对数据结构(一个包含两个元素的元组),来保存一对值,(sum, count),其中sum是评分的累计总和,count是我们迄今已添加(累加)的评分数,我们使用aggregateByKey()转换。
让我们证明,对一组数字使用加法运算符的配对结构(sum, count)是一个幺半群。
如果我们使用(0.0, 0)作为我们的零元素,它是中性的:
f(A, Zero) = A
f(Zero, A) = A
A = (sum, count)
f(A, Zero)
= (sum+0.0, count+0)
= (sum, count)
= A
f(Zero, A)
= (0.0+sum, 0+count)
= (sum, count)
= A
此操作是可交换的(即,聚合的 RDD 元素顺序不影响结果):
f(A, B) = f(B, A)
A = (sum1, count1)
B = (sum2, count2)
f(A, B)
= (sum1+sum2, count1+count2)
= (sum2+sum1, count2+count1)
= f(B, A)
它还是可结合的(聚合元素的顺序不影响最终结果)。
f(f(A, B), C) = f(A, f(B, C))
A = (sum1, count1)
B = (sum2, count2)
C = (sum3, count3)
f(f(A, B), C)
= f((sum1+sum2, count1+count2), (sum3, count3))
= (sum1+sum2+sum3, count1+count2+count3)
= (sum1+(sum2+sum3), count1+(count2+count3))
= f(A, f(B, C))
为了简化事务,我们将定义一个非常基本的 Python 函数,create_pair(),它接受电影评分数据的记录并返回(userID, rating)对:
# Define a function that accepts a CSV record
# and returns a pair of (userID, rating)
# Parameters: rating_record (as CSV String)
# rating_record = "userID,movieID,rating,timestamp"
def create_pair(rating_record):
tokens = rating_record.split(",")
userID = tokens[0]
rating = float(tokens[2])
return (userID, rating)
#end-def
接下来,我们测试该函数:
key_value_1 = create_pair("3,2394,4.0,920586920")
print key_value_1
('3', 4.0)
key_value_2 = create_pair("1,169,2.5,1204927694")
print key_value_2
('1', 2.5)
这是一个使用aggregateByKey()和我们的create_pair()函数的 PySpark 解决方案。组合类型(C)用于表示aggregateByKey()操作的值是一对(sum-of-ratings, count-of-ratings)。
# spark: an instance of SparkSession
ratings_path = "/tmp/movielens/ratings.csv.no.header"
rdd = spark.sparkContext.textFile(ratings_path)
# load user-defined Python function
ratings = rdd.map(lambda rec : create_pair(rec)) 
ratings.count()
#
# C = (C[0], C[1]) = (sum-of-ratings, count-of-ratings)
# zero_value -> C = (0.0, 0)
# seq_func: (C, V) -> C
# comb_func: (C, C) -> C
sum_count = ratings.aggregateByKey( 
(0.0, 0), 
(lambda C, V: (C[0]+V, C[1]+1)), 
(lambda C1, C2: (C1[0]+C2[0], C1[1]+C2[1])) 
)
源 RDD ratings是一个RDD[(String, Float)],其中键是userID,值是rating。
目标 RDD sum_count 是一个RDD[(String, (Float, Integer))],其中键是userID,值是一对(sum-of-ratings, count-of-ratings)。
C在每个分区中被初始化为此值。
这用于在单个分区内合并值。
这用于合并来自不同分区的结果。
让我们分解一下这里发生了什么。首先,我们使用aggregateByKey()函数并创建一个结果集“模板”,其中包含初始值。我们将数据起始为(0.0, 0),因此评分总和初始值为0.0,记录计数初始值为0。对于每一行数据,我们将执行一些加法操作。C是新的模板,因此C[0]是指我们的“总和”元素(sum-of-ratings),而C[1]是“计数”元素(count-of-ratings)。最后,我们根据我们制作的模板,将不同分区的C1值添加到C2值中。
sum_count RDD 中的数据将看起来像下面这样:
sum_count
= [(userID, (sum-of-ratings, count-of-ratings)), ...]
= RDD[(String, (Float, Integer))]
[
(100, (40.0, 10)),
(200, (51.0, 13)),
(300, (340.0, 90)),
...
]
这告诉我们用户100评价了 10 部电影,他们所有评分的总和为 40.0;用户200评价了 13 部电影,他们所有评分的总和为 51.0。
现在,为了获得每个用户的实际平均评分,我们需要使用mapValues()转换,并将第一个条目(sum-of-ratings)除以第二个条目(count-of-ratings):
# x = (sum-of-ratings, count-of-ratings)
# x[0] = sum-of-ratings
# x[1] = count-of-ratings
# avg = sum-of-ratings / count-of-ratings
average_rating = sum_count.mapValues(lambda x: (x[0]/x[1])) 
average_rating是一个RDD[(String, Float)],其中键是userID,值是average-rating。
此 RDD 的内容如下,给出了我们正在寻找的结果:
average_rating
[
(100, 4.00),
(200, 3.92),
(300, 3.77),
...
]
第二种使用aggregateByKey()解决方案
这里,我将展示另一种使用aggregateByKey()转换的解决方案。请注意,为了节省空间,我已经修剪了 PySpark shell 生成的输出。
第一步是读取数据并创建(键,值)对,其中键是userID,值是rating:
# ./bin/pyspark
SparkSession available as 'spark'.
>>># create_pair() returns a pair (userID, rating)
>>># rating_record = "userID,movieID,rating,timestamp"
>>> def create_pair(rating_record):
... tokens = rating_record.split(",")
... return (tokens[0], float(tokens[2]))
...
>>> key_value_test = create_pair("3,2394,4.0,920586920")
>>> print key_value_test
('3', 4.0)
>>> ratings_path = "/tmp/movielens/ratings.csv.no.header"
>>> rdd = spark.sparkContext.textFile(ratings_path)
>>> rdd.count()
22884377
>>> ratings = rdd.map(lambda rec : create_pair(rec))
>>> ratings.count()
22884377
>>> ratings.take(3)
[(u'1', 2.5), (u'1', 3.0), (u'1', 5.0)]
一旦我们创建了(键,值)对,我们可以对评分进行汇总应用aggregateByKey()转换。每个分区的初始值(0.0, 0)用于此操作,其中0.0是评分的总和,0是评分的数量:
>>># C is a combined data structure, (sum, count)
>>> sum_count = ratings.aggregateByKey( 
... (0.0, 0), 
... (lambda C, V: (C[0]+V, C[1]+1)), 
... (lambda C1, C2: (C1[0]+C2[0], C1[1]+C2[1]))) 
>>> sum_count.count()
247753
>>> sum_count.take(3)
[
(u'145757', (148.0, 50)),
(u'244330', (36.0, 17)),
(u'180162', (1882.0, 489))
]
目标 RDD 是一个 RDD[(String, (Float, Integer))]。
C 在每个分区中初始化为 (0.0, 0)。
此 Lambda 表达式将单个 V 的值添加到 C 中(在单个分区中使用)。
此 Lambda 表达式将跨分区的值组合起来(将两个 C 相加以创建一个单独的 C)。
我们可以使用 Python 函数替代 Lambda 表达式。为此,我们需要编写以下函数:
# C = (sum, count)
# V is a single value of type Float
def seq_func(C, V):
return (C[0]+V, C[1]+1)
#end-def
# C1 = (sum1, count1)
# C2 = (sum2, count2)
def comb_func(C1, C2):
return (C1[0]+C2[0], C1[1]+C2[1])
#end-def
现在,我们可以使用定义的函数计算 sum_count:
sum_count = ratings.aggregateByKey(
(0.0, 0),
seq_func,
comb_func
)
前一步创建了以下类型的 RDD 元素:
(userID, (sum-of-ratings, number-of-ratings))
接下来,我们进行最终计算,找到每个用户的平均评分:
>>># x refers to a pair of (sum-of-ratings, number-of-ratings)
>>># where
>>># x[0] denotes sum-of-ratings
>>># x[1] denotes number-of-ratings
>>>
>>> average_rating = sum_count.mapValues(lambda x:(x[0]/x[1]))
>>> average_rating.count()
247753
>>> average_rating.take(3)
[
(u'145757', 2.96),
(u'244330', 2.1176470588235294),
(u'180162', 3.8486707566462166)
]
接下来,我将使用 groupByKey() 解决电影问题。
使用 groupByKey() 的完整 PySpark 解决方案
对于给定的 (K, V) 对集合,groupByKey() 具有以下签名:
groupByKey(numPartitions=None, partitionFunc=<function portable_hash>)
groupByKey : RDD[(K, V)] --> RDD[(K, [V])]
如果源 RDD 是 RDD[(K, V)],groupByKey() 转换会将 RDD 中每个键(K)的值分组为一个 V 的列表/可迭代对象。然后,它会使用现有的分区器/并行级别对生成的 RDD 进行哈希分区。每个组内元素的顺序不保证,甚至每次评估结果的 RDD 时顺序可能都不同。
提示
您可以自定义分区数 (numPartitions) 和分区函数 (partitionFunc)。
在这里,我使用 groupByKey() 转换提供了一个完整的解决方案。
第一步是读取数据并创建 (key, value) 对,其中键是 userID,值是 rating:
>>># spark: SparkSession
>>> def create_pair(rating_record):
... tokens = rating_record.split(",")
... return (tokens[0], float(tokens[2]))
...
>>> key_value_test = create_pair("3,2394,4.0,920586920")
>>> print key_value_test
('3', 4.0)
>>> ratings_path = "/tmp/movielens/ratings.csv.no.header"
>>> rdd = spark.sparkContext.textFile(ratings_path)
>>> rdd.count()
22884377
>>> ratings = rdd.map(lambda rec : create_pair(rec)) 
>>> ratings.count()
22884377
>>> ratings.take(3)
[
(u'1', 2.5),
(u'1', 3.0),
(u'1', 5.0)
]
ratings 是一个 RDD[(String, Float)]
一旦我们创建了 (key, value) 对,我们就可以应用 groupByKey() 转换来将所有用户的评分分组起来。此步骤创建了 (userID, [R[1], ..., R[n]]) 对,其中 R[1],…,R[n] 是唯一 userID 的所有评分。
正如您所注意到的,groupByKey() 转换的工作方式与 SQL 的 GROUP BY 完全相同。它将相同键的值分组为一个值的可迭代对象:
>>> ratings_grouped = ratings.groupByKey() 
>>> ratings_grouped.count()
247753
>>> ratings_grouped.take(3)
(u'145757', <ResultIterable object at 0x111e42e50>), ![2
(u'244330', <ResultIterable object at 0x111e42dd0>),
(u'180162', <ResultIterable object at 0x111e42e10>)
]
>>> ratings_grouped.mapValues(lambda x: list(x)).take(3) 
[
(u'145757', [2.0, 3.5, ..., 3.5, 1.0]),
(u'244330', [3.5, 1.5, ..., 4.0, 2.0]),
(u'180162', [5.0, 4.0, ..., 4.0, 5.0])
]
ratings_grouped 是一个 RDD[(String, [Float])],其中键是 userID,值是一个 rating 列表。
ResultIterable 的完整名称是 pyspark.resultiterable.ResultIterable。
为了调试,将 ResultIterable 对象转换为 Integer 列表。
要找到每个用户的平均评分,我们需要将每个 userID 的所有评分求和,然后计算平均值:
>>># x refers to all ratings for a user as [R1, ..., Rn]
>>># x: ResultIterable object
>>> average_rating = ratings_grouped.mapValues(lambda x: sum(x)/len(x)) 
>>> average_rating.count()
247753
>>> average_rating.take(3)
[
(u'145757', 2.96),
(u'244330', 2.12),
(u'180162', 3.85)
]
average_rating 是一个 RDD[(String, Float)],其中键是 userID,值是 average-rating。
使用 reduceByKey() 的完整 PySpark 解决方案
在其最简单的形式中,reduceByKey()具有以下签名(源和目标数据类型V必须相同):
reduceByKey(func, numPartitions=None, partitionFunc)
reduceByKey: RDD[(K, V)] --> RDD[(K, V)]
reduceByKey()转换使用关联和交换的减少函数合并每个键的值。这也会在每个 mapper 上本地执行合并,然后将结果发送给 reducer,类似于 MapReduce 中的 combiner。输出将使用numPartitions分区进行分区,或者如果未指定numPartitions,则使用默认的并行级别。默认的分区器是HashPartitioner。
由于我们想要找到每个用户评分的平均值,并且我们知道均值不是均值(均值函数不是幺半群),我们需要添加每个用户的所有评分并跟踪他们评分的电影数量。然后,(总评分, 评分数量)是加法函数上的幺半群,但最后我们需要执行一次mapValues()转换来通过评分数量除以总评分找到实际的平均评分。这里给出使用reduceByKey()的完整解决方案。请注意,reduceByKey()比groupByKey()转换更高效和可扩展,因为合并和组合在发送数据进行最终缩减之前在本地完成。
第一步:读取数据并创建对
第一步是读取数据并创建(key, value)对,其中 key 是userID,value 是(rating, 1)对。为了使用reduceByKey()找到平均值,我们需要找到(总评分, 评分数量)。我们首先读取输入数据并创建一个RDD[String]:
>>># spark: SparkSession
>>> ratings_path = "/tmp/movielens/ratings.csv.no.header"
>>># rdd: RDD[String]
>>> rdd = spark.sparkContext.textFile(ratings_path)
>>> rdd.take(3)
[
u'1,169,2.5,1204927694',
u'1,2471,3.0,1204927438',
u'1,48516,5.0,1204927435'
]
然后我们将RDD[String]转换为RDD[(String, (Float, Integer))]:
>>> def create_combined_pair(rating_record):
... tokens = rating_record.split(",")
... userID = tokens[0]
... rating = float(tokens[2])
... return (userID, (rating, 1))
...
>>># ratings: RDD[(String, (Float, Integer))]
>>> ratings = rdd.map(lambda rec : create_combined_pair(rec)) 
>>> ratings.count()
22884377
>>> ratings.take(3)
[
(u'1', (2.5, 1)),
(u'1', (3.0, 1)),
(u'1', (5.0, 1))
]
创建成对的 RDD。
第二步:使用 reduceByKey()汇总评分
一旦我们创建了(userID, (rating, 1))对,我们可以应用reduceByKey()转换来总结给定用户的所有评分和评分数量。这一步的输出将是(userID, (总评分, 评分数量))的元组:
>>># x refers to (rating1, frequency1)
>>># y refers to (rating2, frequency2)
>>># x = (x[0] = rating1, x[1] = frequency1)
>>># y = (y[0] = rating2, y[1] = frequency2)
>>># x + y = (rating1+rating2, frequency1+frequency2)
>>># ratings is the source RDD 
>>> sum_and_count = ratings.reduceByKey(lambda x, y: (x[0]+y[0],x[1]+y[1])) 
>>> sum_and_count.count()
247753
>>> sum_and_count.take(3)
[
(u'145757', (148.0, 50)),
(u'244330', (36.0, 17)),
(u'180162', (1882.0, 489))
]
源 RDD(ratings)是RDD[(String, (Float, Integer))]。
目标 RDD(sum_and_count)是RDD[(String, (Float, Integer))]。注意源和目标的数据类型相同。
第三步:找到平均评分
通过评分数量除以总评分找到每个用户的平均评分:
>>># x refers to (sum_of_ratings, number_of_ratings)
>>># x = (x[0] = sum_of_ratings, x[1] = number_of_ratings)
>>># avg = sum_of_ratings / number_of_ratings = x[0] / x[1]
>>> avgRating = sum_and_count.mapValues(lambda x : x[0] / x[1])
>>> avgRating.take(3)
[
(u'145757', 2.96),
(u'244330', 2.1176470588235294),
(u'180162', 3.8486707566462166)
]
使用combineByKey()的完整 PySpark 解决方案
combineByKey()是reduceByKey()的更一般和扩展版本,其中结果类型可以与聚合的值的类型不同。这是reduceByKey()的一个限制;这意味着,鉴于以下情况:
# let rdd represent (key, value) pairs
# where value is of type T
rdd2 = rdd.reduceByKey(lambda x, y: func(x,y))
func(x,y)必须创建类型为T的值。
combineByKey() 转换是一种优化,它在将聚合分区值发送到指定的 reducer 之前,为给定的键聚合值。此聚合在每个分区中执行,然后将所有分区的值合并为一个单一的值。因此,就像 reduceByKey() 一样,每个分区最多为每个键输出一个值以发送到网络,这加速了 shuffle 步骤。然而,与 reduceByKey() 不同的是,组合(结果)值的类型不必与原始值的类型匹配。
- 对于给定的
(K, V)对集合,combineByKey()具有以下签名(此转换有许多不同版本;这是最简单的形式):
combineByKey(create_combiner, merge_value, merge_combiners)
combineByKey : RDD[(K, V)] --> RDD[(K, C)]
V and C can be different data types.
-
这是一个通用函数,使用自定义的聚合函数组合每个键的元素。它将
RDD[(K, V)]转换为类型为RDD[(K, C)]的结果,其中C是一个组合类型。它可以是简单的数据类型,如Integer或String,也可以是复合数据结构,如 (key, value) 对,三元组(x, y, z)或其他任何你想要的。这种灵活性使得combineByKey()成为一个非常强大的 reducer。 -
正如本章前面讨论的,给定一个源 RDD
RDD[(K, V)],我们必须提供三个基本函数:
create_combiner: (V) -> C
merge_value: (C, V) -> C
merge_combiners: (C, C) -> C
-
为避免内存分配,
merge_value和merge_combiners都允许修改并返回它们的第一个参数,而不是创建新的C(这避免了创建新对象,如果数据量很大,这可能是昂贵的)。 -
此外,用户可以通过提供额外的参数来控制输出 RDD 的分区、用于 shuffle 的序列化程序,以及是否执行 map-side 聚合(即如果 mapper 可以生成具有相同键的多个项目)。因此,
combineByKey()转换提供了相当多的灵活性,但比一些其他缩减转换更复杂。 -
让我们看看如何使用
combineByKey()来解决电影问题。
- 步骤 1:读取数据并创建对
- 就像以前的解决方案一样,第一步是读取数据并创建 (key, value) 对,其中 key 是
userID,value 是rating:
>>># spark: SparkSession
>>># create and return a pair of (userID, rating)
>>> def create_pair(rating_record):
... tokens = rating_record.split(",")
... return (tokens[0], float(tokens[2]))
...
>>> key_value_test = create_pair("3,2394,4.0,920586920")
>>> print key_value_test
('3', 4.0)
>>> ratings_path = "/tmp/movielens/ratings.csv.no.header"
>>> rdd = spark.sparkContext.textFile(ratings_path) 
>>> rdd.count()
22884377
>>> ratings = rdd.map(lambda rec : create_pair(rec)) 
>>> ratings.count()
22884377
>>> ratings.take(3)
[
(u'1', 2.5),
(u'1', 3.0),
(u'1', 5.0)
]
-
-
rdd是一个RDD[String]。 -
-
ratings是一个RDD[(String, Float)]。
- 使用 combineByKey() 对评级进行求和
- 一旦我们创建了
(userID, rating)对,我们可以应用combineByKey()转换来总结每个用户的所有评级和评级数。这一步的输出将是(userID, (sum_of_ratings, number_of_ratings))对:
>>># v is a rating from (userID, rating)
>>># C represents (sum_of_ratings, number_of_ratings)
>>># C[0] denotes sum_of_ratings
>>># C[1] denotes number_of_ratings
>>># ratings: source RDD 
>>> sum_count = ratings.combineByKey( 
(lambda v: (v, 1)), 
(lambda C,v: (C[0]+v, C[1]+1)), 
(lambda C1,C2: (C1[0]+C2[0], C1[1]+C2[1])) 
)
>>> sum_count.count()
247753
>>> sum_count.take(3)
[
(u'145757', (148.0, 50)),
(u'244330', (36.0, 17)),
(u'180162', (1882.0, 489))
]
-
-
源 RDD 是一个
RDD[(String, Float)]。 -
-
目标 RDD 是一个
RDD[(String, (Float, Integer))]。 -
-
这将一个
V(单个值)转换为(V, 1)。
将V(评分)合并为C(总和,计数)。
这将两个C合并为一个C。
步骤 3:找到平均评分
将sum_of_ratings除以number_of_ratings以找到每个用户的平均评分:
>>># x = (sum_of_ratings, number_of_ratings)
>>># x[0] = sum_of_ratings
>>># x[1] = number_of_ratings
>>># avg = sum_of_ratings / number_of_ratings
>>> average_rating = sum_count.mapValues(lambda x:(x[0] / x[1]))
>>> average_rating.take(3)
[
(u'145757', 2.96),
(u'244330', 2.1176470588235294),
(u'180162', 3.8486707566462166)
]
接下来,我们将详细讨论 Spark 缩减转换中的洗牌步骤。
缩减操作中的洗牌步骤
一旦所有的映射器完成了(键,值)对的发射,MapReduce 的魔法发生了:排序和洗牌步骤。该步骤通过键分组(排序)映射阶段的输出,并将结果发送到减少器。从效率和可伸缩性的角度来看,不同的转换操作有所不同。
现在应该很熟悉按键排序的概念了,所以我将专注于洗牌过程。简言之,洗牌是重新分配数据到分区的过程。它可能会导致数据移动到不同的 JVM 进程,甚至通过网络(在分布式服务器的执行器之间)。
我将通过一个例子解释洗牌的概念。假设您有一个 100 节点的 Spark 集群。每个节点都包含有关 URL 访问频率的记录,并且您想计算每个 URL 的总频率。正如您现在所知,您可以通过读取数据并创建(键,值)对来实现,其中键是URL,值是频率,然后对每个 URL 的频率求和。但是如果数据分布在集群中,如何将存储在不同服务器上的相同键的值求和?唯一的方法是将同一键的所有值获取到同一台服务器上,然后可以轻松地对其进行求和。这个过程称为洗牌。
有许多转换操作(例如reduceByKey()和join())需要在集群中对数据进行洗牌,但这可能是一项昂贵的操作。对于groupByKey()的数据洗牌与reduceByKey()的数据洗牌不同,这种差异影响每种转换的性能。因此,正确选择和使用缩减转换非常重要。
考虑以下 PySpark 解决简单词频统计问题的方案:
# spark: SparkSession
# We use 5 partitions for textFile(), flatMap(), and map()
# We use 3 partitions for the reduceByKey() reduction
rdd = spark.sparkContext.textFile("input.txt", 5)\
.flatMap(lambda line: line.split(" "))\
.map(lambda word: (word, 1))\
.reduceByKey(lambda a, b: a + b, 3)\ 
.collect()
3是分区的数量。
由于我们指定了reduceByKey()转换创建三个分区,因此生成的 RDD 将被分成三个块,如图 Figure 4-7 所示。RDD 操作被编译成 RDD 对象的有向无环图(DAG),每个 RDD 都维护指向其依赖父级的指针。正如本图所示,在洗牌边界处,DAG 被分割成阶段(Stage 1,Stage 2 等),按顺序执行。

图 4-7. Spark 的洗牌概念
由于洗牌涉及在执行器和服务器之间复制数据,这是一个复杂且昂贵的操作。让我们更详细地看看它如何适用于两个 Spark 的归约转换,groupByKey()和reduceByKey()。这将帮助说明选择适当的归约的重要性。
groupByKey()的洗牌步骤
groupByKey()的洗牌步骤非常直接。它不会合并每个键的值;相反,洗牌直接发生。这意味着大量数据会发送到每个分区,因为初始数据值没有减少。每个键的值合并发生在洗牌步骤之后。使用groupByKey()时,大量数据需要存储在最终的工作节点(减少器)上,这意味着如果每个键有大量数据,则可能会遇到 OOM 错误。图 4-8 说明了这个过程。请注意,在groupByKey()之后,您需要调用mapValues()来生成最终期望的输出。

图 4-8. groupByKey()的洗牌步骤
因为groupByKey()不会合并或组合值,所以它是一种昂贵的操作,需要在网络上移动大量数据。
reduceByKey()的洗牌步骤
使用reduceByKey()时,每个分区中的数据被组合,以便每个分区中每个键最多只有一个值。然后发生洗牌,将这些数据发送到减少器,如图 4-9 所示。请注意,使用reduceByKey()时,您不需要调用mapValues()来生成最终期望的输出。一般来说,它相当于使用groupByKey()和mapValues(),但由于减少了通过网络发送的数据量,这是一种更高效和更有效的解决方案。

图 4-9. reduceByKey()的洗牌步骤
摘要
本章介绍了 Spark 的归约转换,并提供了多个解决实际数据问题的解决方案,其中最常用的是reduceByKey()、aggregateByKey()、combineByKey()和groupByKey()。正如您所见,解决相同数据问题的方法有很多种,但它们的性能并不相同。
表 4-2 总结了这四个归约转换执行的转换类型(请注意,V和C可以是不同的数据类型)。
表 4-2. Spark 归约的比较
| 归约 | 源 RDD | 目标 RDD |
|---|---|---|
reduceByKey() |
RDD[(K, V)] |
RDD[(K, V)] |
groupByKey() |
RDD[(K, V)] |
RDD[(K, [V])] |
aggregateByKey() |
RDD[(K, V)] |
RDD[(K, C)] |
combineByKey() |
RDD[(K, V)] |
RDD[(K, C)] |
我们了解到一些减少转换(例如 reduceByKey() 和 combineByKey())比 groupByKey() 更可取,因为 groupByKey() 的洗牌步骤更昂贵。在可能的情况下,您应该使用 reduceByKey() 而不是 groupByKey(),或者在您需要组合元素但返回类型与输入值类型不同时,使用 combineByKey()。总体而言,对于大量数据,reduceByKey() 和 combineByKey() 的性能和扩展性会更好。
aggregateByKey() 转换更适合需要涉及计算的按键聚合,例如求和、平均值、方差等。这里的重要考虑是,为了减少发送到其他工作节点和驱动程序的数据量,地图端合并所花费的额外计算可以起到作用。
在下一章中,我们将继续讨论数据分区。
¹ 更多细节,请参阅由 Jimmy Lin 撰写的“Monoidify! Monoids as a Design Principle for Efficient MapReduce Algorithms”。
第二部分:处理数据
第五章:数据分区
分区被定义为“划分的行为;通过创建分隔或保持分开的边界而进行的分离”。数据分区在像 Spark、Amazon Athena 和 Google BigQuery 这样的工具中用于改善查询执行性能。为了扩展大数据解决方案,数据被分割成可以单独管理、访问和并行执行的分区。
正如本书前几章所讨论的,Spark 将数据分割成称为分区的较小块,然后并行处理这些分区(可以同时处理多个分区),使用工作节点上的执行器。例如,如果您的输入有 1000 亿条记录,那么 Spark 可能将其分成 10,000 个分区,每个分区大约有 1,000 万个元素:
-
总记录数:100,000,000,000
-
分区数量:10,000
-
每个分区的元素数量:10,000,000
-
最大可能的并行性:10,000
注意
默认情况下,Spark 使用 HashPartitioner 实现基于哈希的分区,该分区使用 Java 的 Object.hashCode() 函数。
数据分区可以改善可管理性和可扩展性,减少争用,并优化性能。假设您有全球所有国家(7 大洲和 195 个国家)的城市的每小时温度数据,并且目标是查询和分析给定大洲、国家或一组国家的数据。如果您不按相应的方式对数据进行分区,对于每个查询,您将不得不加载、读取并应用您的映射器和减速器到整个数据集,以获得您所需要的结果。这不是非常高效的,因为对于大多数查询,您实际上只需要数据的子集。一个更快的方法是只加载您需要的数据。
Spark 中的数据分区主要是为了并行处理任务而进行的,但在 Amazon Athena 和 Google BigQuery 等查询工具中,其目的是允许您分析数据的一部分而不是整个数据集。PySpark 使按列名物理分区 DataFrame 变得非常容易,以便这些工具可以高效执行查询。
分区介绍
通过对数据进行分区,您可以限制每个查询扫描的数据量,从而提高性能并降低成本。例如,Amazon Athena 利用 Spark 和 Hive 进行分区,让您可以按任意键对数据进行分区(BigQuery 提供了相同的功能)。因此,对于我们之前的天气数据示例,您只需选择并使用特定的文件夹进行查询,而不是对所有国家的整个数据集进行使用。
如果您的数据以表格形式表示,比如 Spark DataFrame,分区是根据特定列的值将该表格划分为相关部分的一种方式。分区可以基于一个或多个列(这些列称为分区键)。分区列中的值用于确定每行应存储在哪个分区中。使用分区可以轻松地在数据片段上执行查询,而不是加载整个数据集进行分析。例如,基因组数据记录包括总共 25 条染色体,这些染色体标记为 {chr1, chr2, ..., chr22, chrX, chrY, chrMT}。由于在大多数基因组分析中,不会混合使用染色体数据,因此按染色体 ID 分区数据是有意义的。这可以通过仅加载所需染色体的数据来减少分析时间。
Spark 中的分区
假设您正在使用像 HDFS 或 Amazon S3 这样的分布式存储系统,其中您的数据分布在许多集群节点之间。您的 Spark 分区如何工作?当物理数据在物理集群中的分区中分布时,Spark 将每个分区视为内存中(如果内存不足则在磁盘上)的高级逻辑数据抽象(RDD 或 DataFrame),如 图 5-1 所示。Spark 集群将优化分区访问,并读取网络中最接近它的分区,观察数据的局部性。

图 5-1. Spark 分区的逻辑模型
在 Spark 中,分区数据的主要目的是通过使集群节点上的执行器同时执行多个任务来实现最大并行性。Spark 执行器在 Spark 应用程序启动时与 Spark 集群管理器协调启动。它们是负责在给定 Spark 作业/应用程序中运行单个任务的工作节点进程。将数据分割为分区允许执行器并行和独立地处理这些分区,每个执行器分配其自己的数据分区来处理(参见 图 5-2)。不需要同步。

图 5-2. Spark 分区的实际应用
要理解分区如何使我们在 Spark 中实现最大性能和吞吐量,可以想象我们有一个包含 100 亿元素的 RDD,有 10,000 个分区(每个分区大约有 100 万个元素),我们希望在这个 RDD 上执行map()转换。进一步想象我们有一个由 51 个节点组成的集群(1 个主节点和 50 个工作节点),其中主节点充当集群管理器且没有执行器,每个工作节点可以同时执行 5 个 mapper 函数。这意味着任何时候会有 5 × 50 = 250 个 mapper 并行和独立地执行,直到所有 10,000 个分区被用完。每个 mapper 完成时,集群管理器将分配一个新的 mapper。因此,平均每个工作节点将处理 10,000 / 250 = 40 个分区。这种情况保证了所有工作节点都得到利用,这应该是分区以实现最大优化时的目标。在这种情况下,如果分区数为 100(而不是 10,000),则每个分区将大约有 1 亿个元素,并且只有 100 / 5 = 20 个工作节点会被利用。其余的 30 个工作节点可能处于空闲状态(低效利用表明资源浪费)。
图 5-3 展示了 Spark 执行器如何处理分区。

图 5-3. Spark 中数据分区的示例
在这个图中,输入数据被分成了 16 个块。假设有两个执行器,Executor-1 和 Executor-2,每个执行器最多可以同时处理三个分区,因此需要三次迭代来处理(例如通过映射转换)所有分区。
Spark 中分区的另一个原因是数据集通常非常大,无法存储在单个节点上。正如前面的例子所示,分区的方式很重要,因为它决定了在执行任何作业时如何利用集群的硬件资源。最优的分区应通过数据转换的最大并行性来最大化硬件资源的利用。
以下因素影响数据分区选择:
可用资源
任务可以运行的核心数
外部数据源
本地集合的大小,使用的输入文件系统(如 HDFS,S3 等)。
用于派生 RDD 和 DataFrame 的转换。
当 RDD/DataFrame 从另一个 RDD/DataFrame 派生时影响分区使用的规则。
让我们看看分区在 Spark 计算环境中是如何工作的。当 Spark 将数据文件读入 RDD(或 DataFrame)时,它会自动将该 RDD 分成多个较小的块,而不管 RDD 的大小如何。然后,在我们对 RDD 应用转换(如 map()、reduceByKey() 等)时,该转换将应用于每个分区。Spark 为每个分区生成一个任务,在执行器的 JVM 中运行(每个工作节点一次只能处理一个任务)。每个阶段包含与 RDD 分区数相同的任务,并将在所有分区上并行执行请求的转换。这个过程由 图 5-4 所示。

图 5-4. 在 Spark 中操作分区数据
注意
Spark 中的分区不跨多台机器。这意味着每个分区被发送到单个工作机器,并且同一分区中的元组保证位于同一台机器上。
就像适当的分区可以提高数据分析的性能一样,不恰当的分区可能会损害数据分析的性能。例如,假设您有一个拥有 501 个节点(1 个主节点和 500 个工作节点)的 Spark 集群。对于一个包含 100 亿个元素的 RDD,适当的分区数量应该超过 500(比如说,1,000),以确保所有集群节点同时被利用。如果您只有 100 个分区,并且每个工作节点最多只能接受 2 个任务,那么大多数工作节点(大约 400 个)将处于空闲和无用状态。充分利用工作节点,您的查询将运行得更快。
接下来,我们将更深入地探讨 Spark 中的分区方式。
管理分区
Spark 同时具有默认和自定义的分区器。这意味着当您创建一个 RDD 时,您可以让 Spark 设置分区数,也可以显式地设置它。在默认情况下,分区数取决于数据源、集群大小和可用资源。大多数情况下,默认分区将工作得很好,但如果您是经验丰富的 Spark 程序员,您可能更喜欢使用 RDD.repartition、RDD.coalesce() 或 DataFrame.coalesce() 函数显式设置分区数。
Spark 提供了几个函数来管理分区。您可以使用 RDD.repartition(numPartitions) 返回一个具有确切 numPartitions 分区数的新 RDD。该函数可以增加或减少 RDD 中的并行级别,如下例所示:
>>> rdd = sc.parallelize([1,2,3,4,5,6,7,8,9,10], 3)
>>> rdd.getNumPartitions()
3
>>> sorted(rdd.glom().collect()) 
[[1, 2, 3], [4, 5, 6], [7, 8, 9, 10]]
>>> len(rdd.repartition(2).glom().collect())
2
>>> len(rdd.repartition(5).glom().collect())
5
RDD.glom() 返回一个由将每个分区中的所有元素合并成列表而创建的 RDD。
内部,RDD.repartition()函数使用洗牌来重新分发数据。如果您减少 RDD 中的分区数,请考虑使用RDD.coalesce(),它可以避免执行洗牌。RDD.coalesce(numPartitions, shuffle=False) 返回一个新的 RDD,将其减少到numPartitions个分区(默认情况下避免洗牌,您不需要提供第二个参数)。以下示例演示了这个概念:
>>> nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> sc.parallelize(nums, 3).glom().collect()
[[1, 2, 3], [4, 5, 6], [7, 8, 9, 10]]
>>> sc.parallelize(nums, 3).coalesce(2).glom().collect()
[[1, 2, 3], [4, 5, 6, 7, 8, 9, 10]]
默认分区
当程序员未显式设置分区数时,默认情况下 RDD 或 DataFrame 的分区。在这种情况下,分区的数量取决于集群中可用的数据和资源。
默认分区数
对于生产环境,大多数情况下默认的分区器效果良好。它确保所有集群节点都被利用,没有集群节点/执行器处于空闲状态。
当您创建 RDD 或 DataFrame 时,可以选择设置分区数。例如,当从 Python 集合创建 RDD 时,可以使用以下 API 设置分区数(其中numSlices表示要创建的分区或切片数):
SparkContext.parallelize(collection, numSlices=None)
类似地,当您使用textfile()从文件系统(如 HDFS 或 S3)中读取文本文件并将其作为RDD[String]返回时,可以设置minPartitions参数:
SparkContext.textFile(name, minPartitions=None, use_unicode=True)
在这两种情况下,如果未设置可选参数,则 Spark 将其设置为默认的分区数(基于数据大小和集群中可用的资源)。在这里,我将演示从集合创建 RDD 而不设置分区数。首先,我将介绍一个简单的调试器函数来显示每个分区的元素:
>>> def debug(iterator):
... print("elements=", list(elements))
然后我可以创建一个 RDD,并使用它来显示分区的内容:
>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
>>> rdd = sc.parallelize(numbers)
>>> num_partitions = rdd.getNumPartitions()
>>> num_partitions
8
>>> rdd.foreachPartition(debug)
elements= [1]
elements= [11, 12]
elements= [4]
elements= [2, 3]
elements= [10]
elements= [8, 9]
elements= [7]
elements= [5, 6]
警告
注意,此函数仅用于测试和教学目的,并且不应在生产环境中使用,因为每个分区可能包含数百万个元素。
显式分区
如前所述,程序员在创建 RDD 时还可以显式设置分区数。
设置分区数
在生产环境中显式设置分区数之前,您需要了解您的数据和集群。确保没有集群节点/执行器处于空闲状态。
在这里,我从相同的集合创建一个 RDD,但在创建时指定了分区数:
>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
>>> rdd = sc.parallelize(numbers, 3) 
>>> rdd.getNumPartitions()
3
分区数量为 3。
接下来,让我们调试创建的 RDD 并查看分区的内容:
>>> rdd.foreachPartition(debug)
elements= [5, 6, 7, 8]
elements= [1, 2, 3, 4]
elements= [9, 10, 11, 12]
然后我们可以在这个 RDD 上应用mapPartitions()转换:
>>> def adder(iterator):
... yield sum(iterator)
...
>>> rdd.mapPartitions(adder).collect()
[10, 26, 42]
SQL 查询的物理分区
在本节中,我们关注的是数据的物理分区,而不是 RDD 和 DataFrame 的分区。物理分区是一种技术,用于提高类似 Hive、Amazon Athena 和 Google BigQuery 等查询工具对数据执行查询的性能。Athena 和 BigQuery 是使用 SQL 查询数据的无服务器服务。通过适当的字段级物理数据分区,可以使我们能够读取、扫描和查询数据集的一个或多个片段,而不是读取和分析整个数据集,极大地提高了查询性能。Spark 也允许我们在磁盘上实现物理数据分区,您将在下一节中看到。
注意
将数据按特定字段(在 SQL 的WHERE子句中使用的字段)分区在使用 Athena 或 BigQuery 查询数据时起着关键作用。通过限制扫描的数据量,显著加快查询执行速度并降低成本,因为成本是基于扫描的数据量计算的。
考虑我们早期关于世界各地城市温度数据的例子。通过查看数据,您可以看到每个大洲都有一组国家,每个国家都有一组城市。如果您要按大洲、国家和城市查询此数据,那么按这三个字段(continent, country, city)分区数据就非常有意义。简单的分区解决方案是为每个大洲创建一个文件夹,然后按国家分区每个大洲,最后按城市分区每个国家。然后,而不是扫描
SELECT <some-fields-from-my_table>
FROM my_table
WHERE continent = 'north_america'
AND country = 'usa'
AND city = 'Cupertino'
只会扩展这个:
<root-dir>/continent=north_america/country=usa/city=Cupertino
正如这个例子所示,分区可以使我们扫描数据的一个非常有限的部分,而不是整个数据集。例如,如果您有一个涉及美国的查询,您只需要扫描一个文件夹,而不是扫描所有 195 个文件夹。在大数据分析中,按目录分区数据非常有效,因为我们没有像关系表那样的索引机制。事实上,您可以将分区视为非常简单的索引机制。分区允许您限制每个查询扫描的数据量,从而提高性能并降低成本。
让我们看另一个例子。给定一个世界温度数据集,您可以在 Amazon Athena 中按以下方式创建这个分区表:
CREATE EXTERNAL TABLE world_temperature(
day_month_year DATE,
temperature DOUBLE
)
PARTITIONED BY (
continent STRING, 
country STRING, 
city STRING 
)
STORED AS PARQUET
LOCATION *`s3``:``/``/``<``bucket``-``name``>``/``dev``/``world_temperature``/`*
tblproperties ("parquet.compress"="SNAPPY");
首先按continent分区。
然后按country分区。
最后,按city分区。
如果您然后查询此表并在WHERE子句中指定一个分区,Amazon Athena 将只从该分区扫描数据。

图 5-5. 查询分区数据
请注意,如果您打算按 year、month 和 day 查询此数据,可以将同一数据分区到另一种形式,其中分区字段为 year、month 和 day。在这种情况下,您的模式将变成以下形式:
CREATE EXTERNAL TABLE world_temperature_by_date(
day_month_year DATE,
continent STRING,
country STRING,
city STRING,
temperature DOUBLE
)
PARTITIONED BY (
year INTEGER, 
month INTEGER, 
day, INTEGER 
)
STORED AS PARQUET
LOCATION *`s3``:``/``/``<``bucket``-``name``>``/``dev``/``world_temperature_by_date``/`*
tblproperties ("parquet.compress"="SNAPPY");
首先按 year 进行分区。
然后按 month 进行分区。
最后,按 day 进行分区。
使用这种新模式,您可以像下面这样发出 SQL 查询:
SELECT <some-fields>
FROM world_temperature_by_date
WHERE year = 2020
AND month = 8
AND day = 16
正如这个例子所说明的,为了有效地对数据进行分区,您需要理解将针对表执行的查询(即将数据表达为表)。
举个例子,假设您有客户数据,每条记录格式如下:
<customer_id><,><date><,><transaction_id><,><item><,><transaction_value>
<date>=DAY/MONTH/YEAR>
另外,假设您的目标是按年分析数据,或者按年和月的组合分析数据。分区数据是一个好主意,因为它允许您通过选择特定文件夹(按年或按年和月)来限制扫描的数据量。Figure 5-6 展示了可能的效果。

Figure 5-6. 按年/月/日分区查询数据
现在,让我们深入了解如何在 Spark 中对数据进行分区。
Spark 中数据的物理分区
Spark 提供了一个简单的 DataFrame API 用于数据的物理分区。让 df 表示我们示例数据的 DataFrame,其记录形式如下:
<customer_id><,><date><,><transaction_id><,><amount>
<date> = <day></><month></><year>
我们可以使用 DataFrameWriter.partitionBy() 方法对数据进行物理分区,可以是文本格式(基于行)或 Parquet 等二进制格式(基于列)。以下各小节展示了具体方法。
按文本格式分区
下面的代码片段显示了如何将数据(表示为 DataFrame)按年和月分区到文本格式中。首先,我们创建一个具有四列的 DataFrame:
# df: a DataFrame with four columns:
# <customer_id>
# <date> (as DAY/MONTH/YEAR)
# <transaction_id>
# <amount>
df = spark.read.option("inferSchema", "true")\
.csv(input_path)\
.toDF('customer_id', 'date', 'transaction_id', 'amount')
接下来,我们添加两个新列(year 和 month):
df2 = df.withColumn("year", get_year(df.date))\ 
.withColumn("month", get_month(df.date)) 
添加一个 year 列。
添加一个 month 列。
最后,我们按 year 和 month 进行分区,然后写入并保存我们的 DataFrame:
df2.write 
.partitionBy("year", "month")\ 
.text(output_path) 
获取一个 DataFrameWriter 对象。
按所需列对数据进行分区。
将每个分区保存为文本文件。
数据按年和月分区的完整解决方案可在该书的 GitHub 存储库中找到,文件名为 partition_data_as_text_by_year_month.py。还提供了包含详细输出的示例运行日志,文件名为 partition_data_as_text_by_year_month.log。
按 Parquet 格式分区
将数据分区为Parquet 格式具有几个优点:与文本数据相比,数据聚合可以更快完成,因为 Parquet 以列格式存储数据,并且 Parquet 还存储元数据。该过程相同,只是不再使用DataFrameWriter类的text()函数,而是使用parquet()函数:
# partition data
df2.write.partitionBy('year', 'month')\
.parquet(output_path)
如果需要,您可以按其他列格式分区数据,例如ORC或CarbonData。如果您只想为每个分区创建单个分区文件,您可以在分区之前重新分区数据。Spark 的repartition(numPartitions, *cols)函数返回一个新的 DataFrame,其分区由给定的分区表达式进行哈希分区。例如,这将为每个('year', 'month')分区创建一个单独的输出文件:
# partition data
df2.repartition('year', 'month')\
.write.partitionBy('year', 'month')\
.parquet(output_path)
我们可以通过检查输出路径来查看数据的物理分区:
$ ls -lR /tmp/output
-rw-r--r-- ... 0 Feb 11 21:04 _SUCCESS
drwxr-xr-x ... 192 Feb 11 21:04 year=2018
drwxr-xr-x ... 160 Feb 11 21:04 year=2019
/tmp/output/year=2018:
drwxr-xr-x ... 128 Feb 11 21:04 month=10
drwxr-xr-x ... 128 Feb 11 21:04 month=12
drwxr-xr-x ... 128 Feb 11 21:04 month=3
drwxr-xr-x ... 128 Feb 11 21:04 month=9
/tmp/output/year=2018/month=10:
-rw-r--r-- ... 1239 Feb 11 21:04 part-00000...snappy.parquet
/tmp/output/year=2018/month=12:
total 8
-rw-r--r-- ... 1372 Feb 11 21:04 part-00000...snappy.parquet
...
如何查询分区数据
为了优化查询性能,您应该在 SQL WHERE子句中包含物理分区列。例如,如果您按("year", "month", "day")分区数据,则以下查询将被优化:
-- Query data for year = 2012
SELECT <some-columns>
FROM <table-name>
WHERE year = 2012
-- Query data for year = 2012 and month = 7
SELECT <some-columns>
FROM <table-name>
WHERE year = 2012
AND month = 7
WHERE子句将指导查询引擎分析数据的片段,而不是整个数据集,如果查询非分区列,则会这样做。让我们看一个使用 Amazon Athena 的示例。
Amazon Athena 示例
要使用 SQL 在 Athena 中访问和查询您的数据,您需要执行以下简单步骤:
-
考虑您将发出的查询类型,然后相应地分区数据。例如,如果您正在处理基因组数据,并且您的 SQL 查询看起来像这样:
SELECT * FROM genome_table WHERE chromosome = 'chr7' AND ....然后,您应该按
chromosome列分区数据。将数据加载到 DataFrame 中(其中包括chromosome列),然后按染色体分区并以 Parquet 格式保存到 S3 中:# create a DataFrame df = <dataframe-includes-chromosome-column> # define your output location s3_output_path = 's3://genomics_bucket01:/samples/' # partition data by chromosome column # and save it as Parquet format df.repartition("chromosome")\ .write.mode("append")\ .partitionBy("chromosome")\ .parquet(s3_output_path) -
接下来,定义您的架构,指定与前面步骤中定义的相同的 S3 位置:
CREATE EXTERNAL TABLE `genome_table`( `sample_barcode` string, `allelecount` int, ... ) PARTITIONED BY ( `chromosome` ) STORED AS PARQUET LOCATION 's3://genomics_bucket01:/samples/' tblproperties ("parquet.compress"="SNAPPY");请注意,
chromosome列是在PARTITIONED BY部分中定义的数据字段。 -
现在您的架构准备就绪,您可以执行/运行它(这将创建 Amazon Athena 使用的元数据)。
-
加载您的分区:
MSCK REPAIR TABLE genome_table; -
一旦您的分区准备就绪,您可以开始执行像这样的 SQL 查询:
SELECT sum(allelecount) FROM genome_table WHERE chromosome = 'chr7';
由于您已按chromosome列分区数据,因此仅会读取/扫描一个名为chromosome=chr7的目录,用于此 SQL 查询。
总结
Spark 中的分区是将数据(表示为 RDD 或 DataFrame)分割为多个分区的过程,您可以并行执行转换,从而更快地完成数据分析任务。您还可以将分区数据写入文件系统中的多个子目录,以便下游系统更快地读取。回顾一下:
-
物理数据分区涉及按数据字段/列将数据(表达为 RDD 或 DataFrame)分割成较小的片段(块),以便以更精细的级别管理和访问数据。
-
数据分区使我们能够减少存储大量数据的成本,同时加快大数据集的处理速度。
-
当使用像 Amazon Athena 和 Google BigQuery 这样的无服务器服务时,需要按字段/列对数据进行分区,主要用于 SQL 查询的
WHERE子句。了解将要执行的查询类型并相应地分区数据非常重要。 -
简言之,数据分区给我们带来以下优势:
-
它提高了查询性能和可管理性。对于给定的查询,你只需分析基于查询子句的相关数据片段。
-
它减少了查询数据的成本,这是基于扫描的数据量。
-
它简化了常见的 ETL 任务,因为你可以基于分区浏览和查看数据。
-
它使得临时查询更加简单和快速,因为你可以分析数据的片段而不是整个数据集。
-
它使我们能够模拟关系数据库表的部分索引。
-
接下来,我们将看一下图算法。
第六章:图算法
到目前为止,我们主要关注的是记录数据,通常存储在平面文件或关系数据库中,并且可以表示为矩阵(一组带有命名列的行)。现在我们将注意力转向基于图的数据,它描述了两个或多个数据点之间的关系。一个常见的例子是社交网络数据:例如,如果“Alex”是“Jane”的“朋友”,而“Jane”是“Bob”的“朋友”,这些关系形成了一个图。航空公司/航班数据是图数据的另一个常见例子;我们将在本章中探索这两个(及其他)例子。
数据结构是计算机中有效组织和存储数据的特定方式。除了线性数据结构(如我们在前几章中主要使用的数组、列表、元组等)外,还包括非线性结构,如树、哈希映射和图。
本章介绍了 GraphFrames,这是一个强大的 Spark 外部包,提供 API 用于表示有向和无向图,查询和分析图,并在图上运行算法。我们将首先探讨图及其用途,然后看看如何在 PySpark 中使用 GraphFrames API 构建和查询图。我们将深入了解 GraphFrames 支持的一些算法,例如查找三角形和模式查找,然后讨论一些实际的现实应用。
图的介绍
图是非线性数据结构,用于直观地展示数据中的关系。非正式地说,图是一对 (V, E),其中:
-
V是一组称为顶点的节点。 -
E是一组称为边的顶点对集合。 -
V(顶点)和E(边)是位置,并存储元素。
一般情况下,每个节点通过唯一标识符和一组关联属性来标识。边由两个节点标识符(源节点和目标节点)和一组关联属性标识。路径表示两个顶点之间的一系列边。例如,在航空网络中:
-
顶点代表机场,并存储三字母机场代码及其他重要信息(城市、州等)。
-
边代表两个机场之间的航班路线,并存储路线的里程数。
边可以是有向或无向的,如图 6-1 所示。有向边由有序顶点对(u, v)组成,其中第一个顶点(u)是源,第二个顶点(v)是目的地。无向边由无序顶点对(u, v)组成。

图 6-1. 有向和无向边
类似地,图可以是有向的(由有向边组成)或无向的(由无向边组成)。图 6-2 展示了一个有向图的例子。它表示了一小组机场作为顶点(通过机场代码标识,如 SJC、LAX 等),并显示了始发机场和飞行目的地之间的关系。

图 6-2. 有向图示例
图 6-3 展示了一个具有六个节点的无向图,标记为{A, B, C, D, E, F},节点之间用边连接。在这个例子中,节点可能表示城市,边可能表示城市之间的距离。在无向图中,所有的边都是双向的。

图 6-3. 无向图示例
提示
要将有向图转换为无向图,您需要为每条有向边添加一条额外的边。也就是说,如果有一条有向边 (u, v),那么您需要添加一条边 (v, u)。
某些类型的数据特别适合使用图来表示。例如,在网络分析中,数据通常被建模为一个图或一组图。图和矩阵通常用于表示和分析社会参与者(用户、朋友、追随者)和对象(如产品、故事、基因等)之间联系模式的信息。我们将在本章后面看一些实际的例子,展示图如何用于解决数据问题,但首先让我们深入了解 GraphFrames API。
GraphFrames API
Spark 提供了两种不同且强大的 API 来实现图算法,如 PageRank、最短路径、连通组件和三角形计数:GraphX 和 GraphFrames。GraphX 是基于 RDD 的 Spark 核心组件,而 GraphFrames(一个开源的外部库)则基于 DataFrames。
我们将集中讨论 GraphFrames,因为在撰写本文时,GraphX(一种面向快速分布式计算优化的通用图处理库)仅支持 Scala 和 Java 的 API,而不支持 Python。GraphFrames 在这三种语言中提供了高级 API,因此我们可以在 PySpark 中使用它,并在底层选择性地使用部分 GraphX 函数。
除了 GraphX 的功能外,GraphFrames 提供了利用 Spark DataFrames 的扩展功能。它利用 DataFrames 提供的可扩展性和高性能,并为图处理提供了统一的 API。GraphFrames 为我们提供了运行图查询和算法的强大工具;除此之外,它简化了交互式图查询,并支持图案发现,也称为图模式匹配。
表 6-1 总结了这两个库之间的关键差异。
表 6-1. GraphFrames 与 GraphX 比较
| 特性 | GraphFrames | GraphX |
|---|---|---|
| 基于 | DataFrames | RDDs |
| 支持的语言 | Scala, Java, Python | Scala, Java |
| 使用案例 | 算法和查询 | 算法 |
| 顶点/边属性 | 任意数量的 DataFrame 列 | 任意顶点 (VD) 或边 (ED) 类型 |
| 返回类型 | GraphFrame 或 DataFrame |
Graph<VD,ED> 或 RDD |
| 支持模式查找 | 是 | 不支持直接 |
GraphFrames 库中的主要类是 graphframes.GraphFrame,它使用 GraphFrames API 构建图。GraphFrame 类定义如下:
class GraphFrame {
def vertices: DataFrame 
def edges: DataFrame 
def find(pattern: String): DataFrame 
def degrees(): DataFrame 
def pageRank(): GraphFrame 
def connectedComponents(): GraphFrame 
...
}
vertices 是一个 DataFrame。
edges 是一个 DataFrame。
在图中搜索结构模式(motif finding)。
返回图中每个顶点的度作为 DataFrame。
在图上运行 PageRank 算法。
计算图的连通分量。
如何使用 GraphFrames
让我们深入使用 GraphFrames API 来构建一些图。由于 GraphFrames 是一个外部包(不是 Spark API 的主要组件),在 PySpark shell 中使用它需要显式地使其可用。第一步是下载并安装它。GraphFrames 是加州大学伯克利分校(UC Berkeley)、麻省理工学院(MIT)和 Databricks 的合作成果。你可以在 Spark Packages 找到最新的 GraphFrames 包分发版,并且文档可以在 GitHub 上获取。
你可以在 PySpark shell 中使用 --packages 参数自动下载 GraphFrames 包及其所有依赖项。这里,我指定了一个特定版本的包(0.8.2-spark3.2-s_2.12)。要使用不同的版本,只需更改 --packages 参数的最后一部分。从操作系统命令提示符下,你可以使用以下命令导入该库(注意这里的输出已经被修整):
export SPARK_HOME=/Users/mparsian/spark-3.2.0
export GF="graphframes:graphframes:0.8.2-spark3.2-s_2.12"
$SPARK_HOME/bin/pyspark --packages $GF
...
graphframes#graphframes added as a dependency
found graphframes#graphframes;0.8.2-spark3.2-s_2.12 in spark-packages
...
Spark context available as 'sc'
SparkSession available as 'spark'.
>>> from graphframes import GraphFrame
如果导入成功,你就可以开始使用 GraphFrames API 了。以下示例展示了如何创建一个 GraphFrame,对其进行查询,并在图上运行 PageRank 算法。我们将在 第八章 中详细介绍 PageRank;现在你只需要知道它是用于对网页搜索结果进行排名的算法。
在 GraphFrames API 中,图表示为 GraphFrame(v, e) 的实例,其中 v 表示顶点(作为 DataFrame),e 表示边(作为 DataFrame)。考虑 图 6-4 中的简单图。

图 6-4. 一个简单的图
在接下来的步骤中,我们将使用 GraphFrames API 构建这个图,并对其应用一些简单的图查询和算法。
-
创建一个带有唯一 ID 列
id的顶点 DataFrame。GraphFrames API 需要id列;它唯一标识要构建的图中的所有顶点。您还可以根据节点属性包括其他列。在这里,我们创建vertices作为一个有三列的 DataFrame(DataFrame["id", "name", "age"]):>>># spark is an instance of SparkSession >>> vertices = [("a", "Alice", 34), \ ("b", "Bob", 36), \ ("c", "Charlie", 30)]  >>> column_names = ["id", "name", "age"] >>> v = spark.createDataFrame(vertices, column_names)  >>> v.show() +---+-------+---+ | id| name|age| +---+-------+---+ | a| Alice| 34| | b| Bob| 36| | c|Charlie| 30| +---+-------+---+代表顶点的 Python 集合。
v表示顶点作为一个 DataFrame。 -
使用
src和dst列创建边 DataFrame。除了这些必需的列外,它们代表源和目标顶点 ID,您还可以根据需要包括其他属性。我们希望存储图中节点之间关系类型的信息,因此我们将包括一个relationship列。在这里,我们创建edges作为一个有三列的 DataFrame(DataFrame["src", "dst", "relationship"]):>>> edges = [("a", "b", "friend"), \ ("b", "c", "follow"), \ ("c", "b", "follow")]  >>> column_names = ["src", "dst", "relationship"] >>> e = sqlContext.createDataFrame(edges, column_names)  >>> e.show() +---+---+------------+ |src|dst|relationship| +---+---+------------+ | a| b| friend| | b| c| follow| | c| b| follow| +---+---+------------+代表边的 Python 集合。
e表示边作为一个 DataFrame。 -
下一步是创建我们的图。使用 GraphFrames API,图被构建为一个
GraphFrame实例,其中包括顶点(作为v)和边(作为e)的一对:>>> from graphframes import GraphFrame  >>> graph = GraphFrame(v, e)  >>> graph  GraphFrame(v:[id: string, name: string ... 1 more field], e:[src: string, dst: string ... 1 more field])导入所需的
GraphFrame类。使用
v(顶点)和e(边)构建一个GraphFrame的实例来构建图。检查构建的图。
-
一旦图被构建,我们可以开始发出查询和应用算法。例如,我们可以发出以下查询来获取图中每个顶点的“入度”(即终止于该顶点的边的数量):
>>> graph.inDegrees.show() +---+--------+ | id|inDegree| +---+--------+ | c| 1| | b| 2| +---+--------+结果是一个有两列的 DataFrame:
id(顶点的 ID)和inDegree,存储顶点的入度作为整数。注意,没有入边的顶点不会在结果中返回。 -
接下来,让我们计算图中“follow”连接的数量:
>>> graph.edges.filter("relationship = 'follow'").count() 2 -
最后,我们可以在图上运行 PageRank 算法并展示结果:
>>> pageranks = graph.pageRank(resetProbability=0.01, maxIter=20)  >>> pageranks.vertices.select("id", "pagerank").show()  +---+------------------+ | id| pagerank| +---+------------------+ | b|1.0905890109440908| | a| 0.01| | c|1.8994109890559092| +---+------------------+对给定的图运行 PageRank 算法 20 次迭代。
展示给定图中每个节点的 PageRank 值。
GraphFrames 函数和属性
如前面的例子所示,GraphFrames 函数(也称为图操作或 GraphOps)使您可以访问有关图的许多细节。除了各种图算法实现(我们将在下一节中更详细地讨论)外,API 还公开了一些属性,使您能够轻松获取有关图的顶点、边和度数(degrees、inDegrees和outDegrees)的信息。
例如,如果graph是GraphFrame的一个实例,您可以按以下方式将顶点和边作为 DataFrame 获取:
vertices_as_dataframes = graph.vertices 
edges_as_dataframes = graph.edges 
Graphframe.vertices属性将图的顶点返回为一个 DataFrame。
Graphframe.edges属性将图的边返回为一个 DataFrame。
API 文档提供了所有可用属性和函数的完整列表,但请注意,并非所有这些函数都适用于 DataFrames。如果您了解如何操作 DataFrames,则还可以对这些函数的输出应用sort()、groupBy()和filter()操作,以获取更多信息,就像我们在示例图中统计“关注”连接的数量一样(您将在后续章节中更多了解如何处理 DataFrames)。
GraphFrames 算法
GraphFrames API 提供了一组算法,用于在图中查找特定模式或子图(也称为“motif”),这通常是一项昂贵的操作。由于 Spark 使用 MapReduce 和分布式算法,它可以相对快速地运行这些操作,但它们仍然是耗时的过程。除了查找模式(使用find())之外,支持的算法还包括:
-
查找模式
-
广度优先搜索(BFS)
-
连通分量
-
强连通分量
-
标签传播
-
PageRank
-
最短路径
-
三角形计数
让我们更详细地研究其中的一些算法。
查找三角形
本节提供了使用 GraphFrames API 查找、计数和列出给定图形或一组图形中所有三角形的高效解决方案。在我们查看示例之前,我们需要定义三元组和三角形。设 T = (a, b, c) 是由 G 标识的图中的三个不同节点集合。如果这些节点中的两个是连接的 ({(a, b), (a, c)}),那么 T 是一个三元组;如果所有三个节点都连接 ({(a, b), (a, c), (b, c)}),则它是一个三角形。
在图分析中,有三个重要的度量指标:
-
全局聚类系数
-
传递率,定义为 T = 3 × m / n,其中 m 是图中的三角形数量,n 是顶点的连接三元组数量
-
局部聚类系数
三角形计数(计算图中每个节点的三角形数量)是社交网络分析中的常见任务,用于检测和衡量社区的凝聚力。它也经常用于计算网络指标,如聚类系数。这项任务需要高效的算法,因为在某些情况下,图可能包含数亿个节点(例如社交网络中的用户)和边(这些用户之间的关系)。
使用 MapReduce 进行三角形计数
我的书Data Algorithms(O'Reilly)的第十六章提供了两种 MapReduce 解决方案,用于查找、计数和列出给定图形或一组图形中的所有三角形。解决方案以 Java、MapReduce 和 Spark 提供。
GraphFrames 包提供了一个方便的方法,GraphFrame.triangleCount(),用于计算通过每个顶点的三角形数量。让我们通过一个示例来展示如何从节点和边构建图,然后找到通过每个节点的三角形数量。
步骤 1:构建图
首先,我们将定义顶点:
>>># SparkSession available as 'spark'.
>>># Display the vertex and edge DataFrames
>>> vertices = [('a', 'Alice',34), \
('b', 'Bob', 36), \
('c', 'Charlie',30), \
('d', 'David',29), \
('e', 'Esther',32), \
('f', 'Fanny',36), \
('g', 'Gabby',60)]
接下来,我们定义节点之间的边:
>>> edges = [('a', 'b', 'friend'),
('b', 'c', 'follow'), \
('c', 'b', 'follow'), \
('f', 'c', 'follow'), \
('e', 'f', 'follow'), \
('e', 'd', 'friend'), \
('d', 'a', 'friend'), \
('a', 'e', 'friend')]
一旦我们有了顶点和边,我们可以构建一个图:
>>> v = spark.createDataFrame(vertices, ["id", "name", "age"]) 
>>> e = spark.createDataFrame(edges, ["src", "dst", "relationship"]) 
>>> from graphframes import GraphFrame
>>> graph = GraphFrame(v, e) 
顶点 DataFrame 需要id列。
边 DataFrame 需要src和dst列。
图被构建为一个GraphFrame对象。
现在让我们检查图及其顶点和边:
>>> graph
GraphFrame(v:[id: string, name: string ... 1 more field],
e:[src: string, dst: string ... 1 more field])
>>> graph.vertices.show()
+---+-------+---+
| id| name|age|
+---+-------+---+
| a| Alice| 34|
| b| Bob| 36|
| c|Charlie| 30|
| d| David| 29|
| e| Esther| 32|
| f| Fanny| 36|
| g| Gabby| 60|
+---+-------+---+
>>> graph.edges.show()
+---+---+------------+
|src|dst|relationship|
+---+---+------------+
| a| b| friend|
| b| c| follow|
| c| b| follow|
| f| c| follow|
| e| f| follow|
| e| d| friend|
| d| a| friend|
| a| e| friend|
+---+---+------------+
步骤 2:计算三角形
接下来,我们将使用GraphFrame.triangleCount()方法计算该图中通过每个顶点的三角形数量:
>>> results = g.triangleCount()
>>> results.show()
+-----+---+-------+---+
|count| id| name|age|
+-----+---+-------+---+
| 0| g| Gabby| 60|
| 0| f| Fanny| 36|
| 1| e| Esther| 32|
| 1| d| David| 29|
| 0| c|Charlie| 30|
| 0| b| Bob| 36|
| 1| a| Alice| 34|
+-----+---+-------+---+
为了仅显示顶点 ID 和通过每个顶点的三角形数量,我们可以编写:
>>> results.select("id", "count").show()
+---+-----+
| id|count|
+---+-----+
| g| 0|
| f| 0|
| e| 1|
| d| 1|
| c| 0|
| b| 0|
| a| 1|
+---+-----+
结果表明我们的图中有三个三角形。然而,这些实际上是同一个三角形,具有不同的根:
Triangle rooted by e: e -> d -> a -> e
Triangle rooted by d: d -> a -> e -> d
Triangle rooted by a: a -> e -> d -> a
在下一节中,我将向您展示如何使用 GraphFrames API 的模式查找算法来消除重复的三角形。
查找模式
图中的模式是顶点之间的交互模式,例如三角形和其他子图。例如,由于 Twitter 数据不是双向的(如果 Alex 关注 Bob,并不意味着 Bob 会关注 Alex),我们可以使用模式查找来查找所有双向用户关系。模式查找使我们能够执行查询,以发现图中各种结构模式,而 GraphFrames API 为此提供了强大的支持。
GraphFrames 使用一种简单的领域特定语言(DSL)来表达结构查询。例如,以下查询:
graph.find("(a)-[e1]->(b); (b)-[e2]->(a)")
将在图中搜索由边双向连接的顶点对{a, b}。它将返回一个 DataFrame,其中包含图中所有这种结构,列分别为模式中每个命名元素(顶点或边)。在本例中,返回的列将是"a, b, e1, e2"(其中e1表示从a到b的边,e2表示从b到a的边)。
在 GraphFrames 框架中,表达结构模式的 DSL 定义如下:
-
模式的基本单位是边。一条边连接一个节点到另一个节点;例如,
"(a)-[e]->(b)"表示从顶点a到顶点b的边e。请注意,顶点由圆括号表示((a)和(b)),而边由方括号表示([e])。 -
模式被表达为边的并集。边模式可以用分号连接(
;)。例如,模式"(a)-[e1]->(b); (b)-[e2]->(c)"指定了两条边(e1和e2),从a到b和从b到c。 -
在模式中,名称可以分配给顶点和边缘。例如,
"(a)-[e]->(b)"有三个命名元素:顶点{a, b}和边缘e。这些名称有两个作用:-
名称可以识别边缘之间的共同元素。例如,
"(a)-[e1]->(b); (b)-[e2]->(c)"指定了相同的顶点b是边缘e1的目的地和边缘e2的源。 -
这些名称被用作结果 DataFrame 中的列名。例如,如果模式包含命名的顶点
a,则结果 DataFrame 将包含一个名为a的列,其StructType子字段等效于GraphFrame.vertices的模式(列)。类似地,模式中的边缘e将在结果 DataFrame 中产生一个名为e的列,其子字段等效于GraphFrame.edges的模式(列)。
-
-
当不需要时,可以省略模式中顶点或边缘的名称。例如,模式
"(a)-[]->(b)"表示顶点a到b之间的边缘,但不为该边缘分配名称。在结果 DataFrame 中将没有匿名边缘的列。类似地,模式"(a)-[e]->()"表示顶点a的出边缘,但不命名目标顶点,而"()-[e]->(b)"表示顶点b的入边缘,但不命名源顶点。 -
可以通过使用感叹号(
!)来否定边缘,以表示图中不应存在的边缘。例如,模式(a)-[]->(b); !(b)-[]->(a)找到从a到b的边缘,但从b到a没有边缘(a跟随b,但b不跟随a)。
使用模式进行三角形计数
在 GraphFrames API 中的模式查找算法使我们能够通过定义模式轻松地在图中找到结构模式(如三角形)。例如,如果"{a, b, c}"表示图中的三个节点,则我们可以将三角形的模式定义为:
a -> b -> c -> a
这个定义包括三个顶点(a、b和c),使得:
a is connected to b (as an edge a -> b)
b is connected to c (as an edge b -> c)
c is connected to a (as an edge c -> a)
您还可以使用模式构建涉及边缘和顶点的更复杂的关系。
(a)-[e]->(b)
为了帮助你理解模式查找的概念,让我们看另一个例子。给定一个GraphFrame对象g,我们将通过几个试验来找到识别三角形的最佳方法。假设我们的图是无向的:如果我们有一个边缘[a -> b],那么我们将有另一个边缘[b -> a]。
试验 1
我们的第一种方法是将三角形视为"a -> b -> c -> a":
>>> triangles = g.find("(a)-[e1]->(b);
(b)-[e2]->(c);
(c)-[e3]->(a)")
>>> triangles.show()
+-----+------+-----+------+-----+------+
| a| e1| b| e2| c| e3|
+-----+------+-----+------+-----+------+
|[1,1]|[1,2,]|[2,2]|[2,4,]|[4,4]|[4,1,]|
|[2,2]|[2,1,]|[1,1]|[1,4,]|[4,4]|[4,2,]|
|[1,1]|[1,4,]|[4,4]|[4,2,]|[2,2]|[2,1,]|
|[4,4]|[4,1,]|[1,1]|[1,2,]|[2,2]|[2,4,]|
|[2,2]|[2,4,]|[4,4]|[4,3,]|[3,3]|[3,2,]|
|[2,2]|[2,4,]|[4,4]|[4,1,]|[1,1]|[1,2,]|
|[4,4]|[4,2,]|[2,2]|[2,3,]|[3,3]|[3,4,]|
|[4,4]|[4,2,]|[2,2]|[2,1,]|[1,1]|[1,4,]|
|[2,2]|[2,3,]|[3,3]|[3,4,]|[4,4]|[4,2,]|
|[3,3]|[3,2,]|[2,2]|[2,4,]|[4,4]|[4,3,]|
|[3,3]|[3,4,]|[4,4]|[4,2,]|[2,2]|[2,3,]|
|[4,4]|[4,3,]|[3,3]|[3,2,]|[2,2]|[2,4,]|
|[5,5]|[5,6,]|[6,6]|[6,7,]|[7,7]|[7,5,]|
|[6,6]|[6,5,]|[5,5]|[5,7,]|[7,7]|[7,6,]|
|[5,5]|[5,7,]|[7,7]|[7,6,]|[6,6]|[6,5,]|
|[7,7]|[7,5,]|[5,5]|[5,6,]|[6,6]|[6,7,]|
|[6,6]|[6,7,]|[7,7]|[7,5,]|[5,5]|[5,6,]|
|[7,7]|[7,6,]|[6,6]|[6,5,]|[5,5]|[5,7,]|
+-----+------+-----+------+-----+------+
这个试验找到了三角形,但由于我们的图是无向的,存在重复输出的问题。
试验 2
让我们再试一次,这次添加一个过滤器来去除重复的三角形。这个过滤器确保了e1.src和e1.dst不相同:
>>> triangles = g.find("(a)-[e1]->(b);
(b)-[e2]->(c);
(c)-[e3]->(a)")
.filter("e1.src < e1.dst")
>>> triangles.show()
+-----+------+-----+------+-----+------+
| a| e1| b| e2| c| e3|
+-----+------+-----+------+-----+------+
|[1,1]|[1,2,]|[2,2]|[2,4,]|[4,4]|[4,1,]|
|[1,1]|[1,4,]|[4,4]|[4,2,]|[2,2]|[2,1,]|
|[2,2]|[2,4,]|[4,4]|[4,3,]|[3,3]|[3,2,]|
|[2,2]|[2,4,]|[4,4]|[4,1,]|[1,1]|[1,2,]|
|[2,2]|[2,3,]|[3,3]|[3,4,]|[4,4]|[4,2,]|
|[3,3]|[3,4,]|[4,4]|[4,2,]|[2,2]|[2,3,]|
|[5,5]|[5,6,]|[6,6]|[6,7,]|[7,7]|[7,5,]|
|[5,5]|[5,7,]|[7,7]|[7,6,]|[6,6]|[6,5,]|
|[6,6]|[6,7,]|[7,7]|[7,5,]|[5,5]|[5,6,]|
+-----+------+-----+------+-----+------+
这个方法更好,但我们的结果中仍然有一些重复。
试验 3
在我们的最终试验中,我们将添加另一个过滤器,可以确保我们唯一地识别所有的三角形而没有重复:
>>> triangles = g.find("(a)-[e1]->(b);
(b)-[e2]->(c);
(c)-[e3]->(a)") 
.filter("e1.src < e1.dst") 
.filter("e2.src < e2.dst") 
>>> triangles.show()
+-----+------+-----+------+-----+------+
| a| e1| b| e2| c| e3|
+-----+------+-----+------+-----+------+
|[1,1]|[1,2,]|[2,2]|[2,4,]|[4,4]|[4,1,]|
|[2,2]|[2,3,]|[3,3]|[3,4,]|[4,4]|[4,2,]|
|[5,5]|[5,6,]|[6,6]|[6,7,]|[7,7]|[7,5,]|
+-----+------+-----+------+-----+------+
找到三角形{a -> b -> c -> a}。
确保 e1.src 和 e1.dst 不相同。
确保 e2.src 和 e2.dst 不相同。
使用模式查找唯一的三角形
在本节中,我将向您展示如何从一组顶点和边构建 GraphFrame,然后在图中找到唯一的三角形。
输入
构建图所需的组件(使用 GraphFrames)是顶点和边。假设我们的顶点和边在两个文件中定义:
-
sample_graph_vertices.txt
-
sample_graph_edges.txt
让我们检查这些输入文件:
$ head -4 sample_graph_vertices.txt
vertex_id
0
1
2
$ head -4 sample_graph_edges.txt
edge_weight,from_id,to_id
0,5,15
1,18,8
2,6,1
为了符合 GraphFrames API,我们将执行以下清理和过滤任务:
-
将
vertex_id重命名为id。 -
删除
edge_weight列。 -
将
from_id重命名为src。 -
将
to_id重命名为dst。
输出
预期的输出将是构建图中唯一的三角形。请注意,给定三个顶点 {a, b, c} 的三角形可以以以下六种方式的任何一种表示:
a -> b -> c -> a
a -> c -> b -> a
b -> a -> c -> b
b -> c -> a -> b
c -> a -> b -> c
c -> b -> a -> c
目标是仅输出其中一种表示方式。
算法
完整的 PySpark 解决方案被称为 unique_triangles_finder.py。利用 GraphFrames 模式发现算法和 DataFrames,解决方案非常简单:
-
为顶点创建一个 DataFrame:
vertices_df。 -
为边创建一个 DataFrame:
edges_df。 -
作为
GraphFrame构建图。 -
应用一个三角形模式。
-
过滤掉重复的三角形。
构建 vertices_df 很简单。在构建 edges_df 时,为了确保我们的图是无向的,如果有一个连接从 src 顶点到 dst 顶点,那么我们会添加一个额外的边从 dst 到 src。这样我们就能找到所有的三角形。
我们将从找到所有三角形开始,包括潜在的重复:
>>> graph = GraphFrame(vertices_df, edges_df)
>>># find all triangles, which might have duplicates
>>> motifs = graph.find("(a)-[]->(b);
(b)-[]->(c);
(c)-[]->(a)")
>>> print("motifs.count()=", motifs.count())
42
接下来,我们将使用 DataFrame 强大的过滤机制来移除重复的三角形,仅保留 {a, b, c} 中 a > b > c 的一种表示:
>>> unique_triangles = motifs[(motifs.a > motifs.b) &
(motifs.b > motifs.c)] 
>>> unique_triangles.count()
7
>>> unique_triangles.show(truncate=False)
+----+----+----+
|a |b |c |
+----+----+----+
|[42]|[32]|[30]|
|[5] |[31]|[15]|
|[8] |[22]|[18]|
|[8] |[22]|[17]|
|[7] |[39]|[28]|
|[52]|[51]|[50]|
|[73]|[72]|[71]|
+----+----+----+
移除重复的三角形。
注意,motifs.count() 返回了 42(因为三角形可以有六种不同的表示方式,如前所示),而 unique_triangles.count() 返回了 7(6 × 7 = 42)。
其他模式查找示例
GraphFrames 和 DataFrames 的组合是解决与图相关问题及其他问题的强大工具。我展示了如何使用模式来查找三角形,但还有许多其他应用。我们将在这里看几个例子。
查找双向顶点
使用模式,您可以构建涉及图的边和顶点更复杂的关系。以下示例找到具有两个方向上的边的顶点对。结果是一个 DataFrame,其中列名是模式键。假设 graph 是一个 GraphFrame 的实例。然后,找到双向顶点可以表示为:
# search for pairs of vertices with edges
# in both directions between them
bidirectional = graph.find("(a)-[e1]->(b);
(b)-[e2]->(a)") 
bidirectional 将具有列 a、e1、b 和 e2。
由于结果是一个 DataFrame,更复杂的查询可以建立在这种主题之上。例如,我们可以找到所有逆向关系,其中一个人年龄超过 30 岁,如下所示:
older_than_30 = bidirectional.filter("b.age > 30 or a.age > 30")
查找子图
子图是图的顶点和边是另一个图的子集。您可以通过过滤顶点和边的子集来构建子图。例如,我们可以构建一个仅包含关注者比被关注用户年轻的关系的子图:
# graph is an instance of GraphFrame
paths = graph.find("(a)-[e]->(b)")\
.filter("e.relationship = 'follow'")\
.filter("a.age < b.age")
# The `paths` variable contains the vertex
# information, which we can extract:
selected_edges = paths.select("e.src", "e.dst", "e.relationship")
# Construct the subgraph
sample_subgraph = GraphFrame(g.vertices, selected_edges)
朋友推荐
GraphFrames motif 查找算法使得在社交网络中另一个常见任务变得简单,即进行朋友推荐。例如,为了推荐用户可能喜欢关注的人,我们可能搜索用户三元组(A, B, C),其中"A关注B"且"B关注C",但"A不关注C"。这可以表示为:
# g is an instance of GraphFrame
# Motif: "A -> B", "B -> C", but not "A -> C"
results = g.find("(A)-[]->(B);
(B)-[]->(C);
!(A)-[]->(C)")
# Filter out loops (with DataFrame operation)
results_filtered = results.filter("A.id != C.id")
# Select recommendations for A to follow C
recommendations = results_filtered.select("A", "C")
产品推荐
最后一个例子,我们将看一下产品推荐。考虑一个情况,一个购买了产品p的客户还购买了另外两个产品,a和b。这种关系显示在图 6-6 中。

图 6-6. 购买产品之间的关系
有两条单独的边,从产品p到a和b。因此,这个 motif 可以表示为:
graph = GraphFrame(vertices, edges)
motifs = graph.find("(p)-[e1]->(a);
(p)-[e2]->(b)")
.filter("(a != b)")
我们还可以对 motif 查找的结果应用过滤器。例如,在这里我们指定顶点p的值为1200(表示具有该id的产品):
motifs.filter("p.id == 1200").show()
以下示例显示如何查找两个产品之间的强关系(即经常一起购买的产品)。在这个例子中,我们指定从p到a和a到b的边,以及从b到a的另一条边。这种模式通常表示当客户购买产品p时,他们可能还会购买a,然后继续购买b。这可能表明正在购买的项目的一些优先级(参见图 6-7)。

图 6-7. 产品关系
找到具有这种类型关系的产品的主题可以表示为:
graph = GraphFrame(vertices, edges)
strong_motifs = graph.find("(p)-[]->(a);
(a)-[]->(b);
(b)-[]->(a)")
strong_motifs.show()
请记住,在 motif 定义中,符号[e]表示标记为e的边,而[]表示没有名称的边。
接下来,我们将深入探讨一些实际世界中使用 GraphFrames 的例子。
实际应用
本节的目的是介绍使用 GraphFrames API 的 motif 查找功能的一些实际应用。
基因分析
让我们通过一个示例来演示如何使用 GraphFrames 和 motifs 进行基因分析。基因是从父代传递给后代并据以确定后代某些特征的遗传单位。基因关系已经分析过与标记过渡图相关的唐氏综合征的基因互动数据(顶点表示基因,边表示基因之间的关系的有向图)。例如,三个顶点(XAB2、ERCC8 和 POLR2A,表示三个基因)和两条边(表示它们之间的互动)可以用以下原始数据表示:
XAB2,ERCC8,Reconstituted Complex
XAB2,POLR2A,Affinity Capture-Western
一个重要的分析是找到特定顶点之间的 motif,这有助于检测类似于唐氏综合征或阿尔茨海默病的情况。例如,刺猬信号通路(HSP),在图 6-8 中说明,以及基因调控网络显示在图 6-9 中。

图 6-8。刺猬信号通路关系

图 6-9。与阿尔茨海默病相关的基因调控网络
这些模式和关系可以通过 GraphFrames API 的 motif 查找功能轻松检测到。我们还可以使用 PageRank 算法找到最重要的基因,或者通过运行标签传播算法多次迭代来找到基因社群。
让我们逐步构建图表。输入的格式如下:
<source-gene><,><destination-gene><,><type-of-relationship>
这里有一些输入记录的示例:
BRCA1,BRCA1,Biochemical Activity
SET,TREX1,Co-purification
SET,TREX1,Reconstituted Complex
PLAGL1,EP300,Reconstituted Complex
由于我们只有边的输入,我们将从边中推导顶点。
为基因查找 motif
之前,我展示了两种结构模式。为了表达 HSP 作为一个主题,我们会写:
hsp = graph.find(
"(shh)-[e1]->(ptch); " +
"(ptch)-[e2]->(gli); " +
"(gli)-[e3]->(wnt1)")
.filter("shh.id = 'SHH'")
.filter("ptch.id = 'PTCH'")
.filter("gli.id = 'GLI'")
.filter("wnt1.id = 'WNT1'")
这非常强大且直接:搜索彼此连接的三个节点,并进一步限制它们到特定节点。
社会推荐
推荐系统在当今应用非常广泛,如社交网络(例如 Twitter 和 Facebook)和购物网站(例如 Amazon)。在本节中,基于 Hamed Firooz 的博客文章“使用 Graphframes 进行社交推荐”,我将向您展示如何使用 Spark 的 GraphFrames 包构建一个简单的社交推荐系统。
假设我们有两种对象:用户和表格,它们包含用户之间发送的消息。这些对象将作为图中的顶点表示,并且它们之间的关系将作为边表示。用户可以互相关注,这是单向连接(不像 Facebook 上的“朋友”关系是双向的)。表格包含两种类型的数据:公共和私有。用户可以选择“关注”表格,这样他们就可以访问公共消息,或者成为表的“成员”,这样他们可以访问所有消息,还可以向表的成员和关注者发送消息。
我们将基于图 6-10 的示例图进行分析,该图显示了六个用户和三个表的数据。

图 6-10. 示例社交图(来源:“使用 Graphframes 进行社交推荐”)
鉴于这个图,假设我们希望推荐 B 关注 A,如果满足以下四个条件:
-
A 和 B 没有连接。A 不关注 B,B 也不关注 A。
-
A 和 B 至少有四个共同的节点。这意味着它们各自连接至少四个节点。
-
至少有两个那四个节点是表格。
-
A 是这两个表格的成员。
我们可以通过 GraphFrames 模式查找算法来表达这一点,如下所示(从“模式查找”中回忆,!字符表示否定;即,该边不应存在于图中):
请记住,GraphFrame 模式查找使用领域特定语言(DSL)来表示结构模式和查询。例如,以下模式使用find()函数查找三角形:
graph.find("(a)-[e1]->(b);
(b)-[e2]->(c);
(c)-[e3]->(a)")
将搜索三角形作为“a、b 和 c”顶点对。
{ (a, b), (b, c), (c, a) }
它将返回图中所有这样的结构的 DataFrame,每个命名元素(顶点或边)在模式中都有对应的列。在这种情况下,返回的列将是"a, b, c, e1, e2, e3"。在模式查找中表达否定时,使用感叹号(“!”)字符;边可以被否定,以指示图中不应存在该边。例如,以下模式:
"(a)-[]->(b); !(b)-[]->(a)"
查找从“a 到 b”的边,其中没有从“b 到 a”的边。
我们的社交推荐可以通过 GraphFrames 的“模式查找”实现:
one_hub_connection = graph.find(
"(a)-[ac1]->(c1); (b)-[bc1]->(c1); " +
"(a)-[ac2]->(c2); (b)-[bc2]->(c2); " +
"(a)-[ac3]->(c3); (b)-[bc3]->(c3); " +
"(a)-[ac4]->(c4); (b)-[bc4]->(c4); " +
"!(a)-[]->(b); !(b)-[]->(a)") 
.filter("c1.type = 'table'") 
.filter("c2.type = 'table'")
.filter("a.id != b.id") 
.filter("c1.id != c2.id") 
.filter("c2.id != c3.id")
.filter("c3.id != c4.id")
recommendations = one_hub_connection
.select("a", "b")
.distinct()
recommendations.show()
recommendations.printSchema()
确保a和b没有连接。
确保至少有两个那四个节点,a和b都连接的节点类型为'table'。
确保a不同于b。
确保四个节点不相同。
输出将是:
+--------------+--------------+
| a| b|
+--------------+--------------+
|[3,Med,person]|[1,Bob,person]|
|[1,Bob,person]|[3,Med,person]|
+--------------+--------------+
root
|-- a: struct (nullable = false)
| |-- id: string (nullable = false)
| |-- name: string (nullable = false)
| |-- type: string (nullable = false)
|-- b: struct (nullable = false)
| |-- id: string (nullable = false)
| |-- name: string (nullable = false)
| |-- type: string (nullable = false)
这是在 GraphFrames 中使用 Motif 查找的一个很好的例子。我们有兴趣找到两个节点 {a, b},它们都连接到另外四个节点 {c1, c2, c3, c4}。这可以表示为:
(a)-[ac1]->(c1);
(b)-[bc1]->(c1);
(a)-[ac2]->(c2);
(b)-[bc2]->(c2);
(a)-[ac3]->(c3);
(b)-[bc3]->(c3);
(a)-[ac4]->(c4);
(b)-[bc4]->(c4);
Motif 表达了以下规则:
-
a和b之间没有连接。这可以表示为:!(a)-[]->(b); !(b)-[]->(a) -
这四个节点中至少有两个是表。这可以使用两个过滤器来表示:
filter("c1.type = 'table'") filter("c2.type = 'table'") -
a和b不是同一个用户。这可以表示为:filter("a.id != b.id") -
a和b连接到四个唯一的节点。这可以表示为:.filter("c1.id != c2.id") .filter("c2.id != c3.id") .filter("c3.id != c4.id")
最后,由于在给定的 Motif 下遍历图的方式有很多种,我们希望确保消除重复的条目。我们可以这样做:
recommendation = one_hub_connection
.select("a", "b")
.distinct()
Facebook 圈子
在本节中,我们将使用 Motif 查找来分析 Facebook 关系。
输入
对于输入,我们将使用来自 Stanford Network Analysis Project (SNAP) 的数据,这些数据包括来自 Facebook 的“圈子”(或“朋友列表”)。该数据是通过使用 Facebook 应用程序对调查参与者进行匿名化收集的。数据集包括节点特征(配置文件)、圈子和自我网络。
让我们看看下载的数据:
$ `wc` `-l` `stanford_fb_edges.csv` `stanford_fb_vertices.csv`
88,235 stanford_fb_edges.csv
4,039 stanford_fb_vertices.csv
这告诉我们有 4,039 个顶点和 88,235 条边。接下来,我们将检查每个文件的前几行。正如您所见,这些文件有我们在创建 DataFrames 时可以用作列名的标头(我已经按照 GraphFrames 的指南重命名了列):
$ `head` `-5` `stanford_fb_edges.csv`
src,dst
0,1
0,2
0,3
0,4
$ `head` `-5` `stanford_fb_vertices.csv`
id,birthday,hometown_id,work_employer_id,education_school_id,education_year_id
1098,None,None,None,None,None
1142,None,None,None,None,None
1304,None,None,None,None,None
1593,None,None,None,None,None
构建图
由于我们有顶点和边作为带有标头的 CSV 文件,我们的第一步是为这些文件构建 DataFrames。我们将从 vertices DataFrame 开始:
>>> vertices_path = 'file:///tmp/stanford_fb_vertices.csv'
>>> vertices = spark 
.read 
.format("csv") 
.option("header", "true") 
.option("inferSchema", "true") 
.load(vertices_path) 
>>> vertices.count()
4039
>>> vertices.printSchema()
root
|-- id: integer (nullable = true)
|-- birthday: string (nullable = true)
|-- hometown_id: string (nullable = true)
|-- work_employer_id: string (nullable = true)
|-- education_school_id: string (nullable = true)
|-- education_year_id: string (nullable = true)
>>> vertices.show(3, truncate=False)
+----+--------+--------+-----------+----------+---------+
|id |birthday|hometown|work_ |education_|education|
| | |_id |employer_id|school_id |_year_id |
+----+--------+--------+-----------+----------+---------+
|1098|None |None |None |None |None |
|1142|None |None |None |None |None |
|1917|None |None |None |None |72 |
+----+--------+--------+-----------+----------+---------+
spark 是 SparkSession 的一个实例。
返回一个 DataFrameReader 以读取输入文件。
指定要读取的文件类型。
指示输入的 CSV 文件有一个标头。
从输入文件推断 DataFrame 的模式;该选项需要对数据进行额外的一次传递,默认为 false。
提供 CSV 文件的路径。
然后构建并检查我们的 edges DataFrame:
>>> edges_path = 'file:///tmp/stanford_fb_edges.csv'
>>> edges = spark 
.read 
.format("csv") 
.option("header","true") 
.option("inferSchema", "true") 
.load(edges_path) 
>>> edges.count()
88234
>>> edges.printSchema()
root
|-- src: integer (nullable = true)
|-- dst: integer (nullable = true)
>>> edges.show(4, truncate=False)
+---+---+
|src|dst|
+---+-- +
|0 |1 |
|0 |2 |
|0 |3 |
|0 |4 |
+---+---+
spark 是 SparkSession 的一个实例。
返回一个 DataFrameReader 以读取输入文件。
指定要读取的文件类型。
指示输入的 CSV 文件有一个标头。
从输入文件推断 DataFrame 的模式。
提供 CSV 文件的路径。
一旦我们有了两个 DataFrames,我们可以创建 GraphFrame 对象:
>>> from graphframes import GraphFrame
>>> graph = GraphFrame(vertices, edges)
>>> graph
GraphFrame(v:[id: int, birthday: string ... 4 more fields],
e:[src: int, dst: int])
>>> graph.triplets.show(3, truncate=False)
+---------------------------------+-------+----------------------------------+
|src |edge |dst |
+---------------------------------+-------+----------------------------------+
|[0, None, None, None, None, None]|[0, 1] |[1, None, None, None, None, None] |
|[0, None, None, None, None, None]|[0, 2] |[2, None, None, None, None, None] |
|[0, None, None, None, None, None]|[0, 3] |[3, 7, None, None, None, None] |
+---------------------------------+-------+----------------------------------+
Motif 查找
现在我们建立了图表,可以进行一些分析。首先,我们将找到所有具有相同生日的连接顶点:
same_birthday = graph.find("(a)-[]->(b)")
.filter("a.birthday = b.birthday")
print "count: %d" % same_birthday.count()
selected = same_birthday.select("a.id", "b.id", "b.birthday")
接下来,我们将统计通过每个顶点的三角形数:
>>> triangle_counts = graph.triangleCount()
>>> triangle_counts.show(5, truncate=False)
+-----+---+--------+-----------+----------------+----------+---------+
|count|id |birthday|hometown_id|work_employer_id|education |education|
| | | | | |_school_id|_year_id |
+-----+---+--------+-----------+----------------+----------+---------+
|80 |148|None |None |None |None |None |
|361 |463|None |None |None |None |None |
|312 |471|None |None |None |52 |None |
|399 |496|None |None |None |52 |None |
|38 |833|None |None |None |None |None |
+-----+---+--------+-----------+----------------+----------+---------+
下面的图查询找到了“朋友的朋友”,他们彼此之间没有连接,但是他们从同一所学校的同一年毕业:
>>> from pyspark.sql.functions import col
>>> friends_of_friends = graph.find("(a)-[]->(b);
(b)-[]->(c);
!(a)-[]->(c)") \
.filter("a.education_school_id = c.education_school_id")
.filter("a.education_year_id = c.education_year_id")
>>> filtered = friends_of_friends
.filter("a.id != c.id") \
.select(col("a.id").alias("source"), "a.education_school_id", \
"a.education_year_id", col("c.id").alias("target"), \
"c.education_school_id", "c.education_year_id")
>>> filtered.show(5)
+------+----------+---------+------+----------+---------+
|source|education |education|target|education |education|
|source|_school_id|_year_id | |_school_id|_year_id |
+------+----------+---------+------+----------+---------+
| 3| None| None| 246| None| None|
| 3| None| None| 79| None| None|
| 3| None| None| 290| None| None|
| 5| None| None| 302| None| None|
| 9| None| None| 265| None| None|
+------+----------+---------+------+----------+---------+
最后,我们在图上运行 PageRank 算法:
>>> page_rank =
graph.pageRank(resetProbability=0.15, tol=0.01)
.vertices
.sort('pagerank', ascending=False)
>>> page_rank.select("id", "pagerank")
.show(5, truncate=False)
+----+------------------+
|id |pagerank |
+----+------------------+
|1911|37.59716511250488 |
|3434|37.555460465662755|
|2655|36.34549422981058 |
|1902|35.816887526732344|
|1888|27.459048061380063|
+----+------------------+
连接组件
给定数百万个 DNA 样本和样本间基因组关系数据,如何找出连接的家族?给定社交网络(如 Facebook 或 Twitter),如何识别连接的社区?为了解决这些问题,可以使用连接组件算法。
此算法的目标是识别独立的、不相连的子图。在我介绍算法本身之前,让我们定义连接组件的概念。让 G 是一个图,定义为顶点集 V 和边集 E,其中每条边都是一对顶点:
G = (V, E)
从 V 中的 x 到 V 中的 y 的路径可以通过顶点序列描述:
x = u0, u1, u2, ..., un = y
其中我们从 u[i] 到 u[{i+1}] 每个 0 <= i <= n-1 有一条边。请注意,顶点可以重复,允许路径交叉或折叠到自身。现在,我们可以定义一个连接组件。我们说一个图 G 是连通的,如果每对顶点之间存在路径。因此,我们可以说图的一个连接组件是一个子图,在这个子图中,任意两个顶点通过路径连接,并且它与超图中的其他顶点不连接。最小的连接组件可以是一个单一的顶点,它不连接到任何其他顶点。例如,图 6-11 的图有三个连接组件。

图 6-11. 连接组件示例
在许多图应用程序的核心是找到并识别连接的组件。例如,考虑在一组 DNA 样本中识别家庭群集的问题。我们可以用一个顶点表示每个 DNA 样本,并在被视为“连接”的每对样本之间添加一条边(父子、兄弟姐妹、二度亲戚、三度亲戚等)。这个图的连接组件对应于不同的家族群体。
给定一个图,如何识别它的连接组件?算法涉及广度优先或深度优先搜索,从某个顶点 v 开始,逐节点向外遍历,直到找到包含 v 的整个连接组件。要找出图的所有连接组件,我们循环遍历其顶点,每当循环到达一个先前未包含在先前找到的连接组件中的顶点时,就开始新的搜索。
Spark 中的连接组件
连通组件算法将图的每个连通组件标记为其最低编号顶点的 ID。要使用此算法,我们首先像往常一样构建一个GraphFrame的图实例,然后调用connectedComponents()方法来计算图的连通组件。此方法具有以下签名:
connectedComponents(
algorithm='graphframes',
checkpointInterval=2,
broadcastThreshold=1000000
)
Parameters:
algorithm – connected components algorithm
to use (default: "graphframes"); supported
algorithms are "graphframes" and "graphx".
checkpointInterval – checkpoint interval in
terms of number of iterations (default: 2)
broadcastThreshold – broadcast threshold
in propagating component assignments
(default: 1000000)
Returns:
DataFrame with new vertices column "component"
它的使用看起来像以下内容:
vertices = *`<``DataFrame``-``representing``-``vertices``>`*
edges = *`<``DataFrame``-``representing``-``edges``>`*
graph = GraphFrame(vertices, edges)
connected_components = graph.connectedComponents()
connected_components.explain(extended=True)
connected_components
.groupBy('component')
.count()
.orderBy('count', ascending=False)
.show()
connected_components.select("id", "component")
.orderBy("component")
.show()
分析航班数据
本节的目的是展示如何构建和执行图查询。此处提供的示例灵感来源于 Carol McDonald 的博客文章“使用 Apache Spark GraphFrames 和 MapR 数据库分析航班延误”,该文章提供了一个使用 GraphFrames 在 Scala 中进行航班数据分析的解决方案。这里提供的解决方案是 PySpark 中的等效解决方案。
输入
顶点(机场)和边(航班)的数据以 JSON 格式提供。我们将读取这些数据文件,并为顶点和边创建两个 DataFrame。然后我们将使用这些数据创建一个图,表示为GraphFrame的实例。
顶点
顶点的数据以文件airports.json提供。让我们查看该文件的前两条记录:
{"id":"ORD","City":"Chicago","State":"IL","Country":"USA"}
{"id":"LGA","City":"New York","State":"NY","Country":"USA"}
边
边(航班数据)的数据以 JSON 文件flightdata2018.json提供。第一条记录如下:
{
"id":"ATL_BOS_2018-01-01_DL_104",
"fldate":"2018-01-01",
"month":1,
"dofW":1,
"carrier":"DL",
"src":"ATL",
"dst":"BOS",
"crsdephour":9,
"crsdeptime":850,
"depdelay":0.0,
"crsarrtime":1116,
"arrdelay":0.0,
"crselapsedtime":146.0,
"dist":946.0
}
构建图
要作为GraphFrame的图实例构建图,我们必须创建两个 DataFrame。我们将从文件airports.json开始构建顶点 DataFrame:
>>> airports_path = '/book/code/chap06/airports.json'
>>> vertices = spark.read.json(airports_path)
>>> vertices.show(3)
+-------------+-------+-----+---+
| City|Country|State| id|
+-------------+-------+-----+---+
| Chicago| USA| IL|ORD|
| New York| USA| NY|LGA|
| Boston| USA| MA|BOS|
+-------------+-------+-----+---+
>>> vertices.count()
13
然后我们将从flightdata2018.json构建边的 DataFrame:
>>> flights_path = '/book/code/chap06/flightdata2018.json'
>>> edges = spark.read.json(flights_path)
>>> edges.select("src", "dst", "dist", "depdelay")
.show(3)
+---+---+-----+--------+
|src|dst| dist|depdelay|
+---+---+-----+--------+
|ATL|BOS|946.0| 0.0|
|ATL|BOS|946.0| 8.0|
|ATL|BOS|946.0| 9.0|
+---+---+-----+--------+
>>> edges.count()
282628
现在我们可以使用这两个 DataFrame 构建我们的图:
>>> from graphframes import GraphFrame
>>> graph = GraphFrame(vertices, edges)
>>> graph
GraphFrame(
v:[id: string, City: string ... 2 more fields],
e:[src: string, dst: string ... 12 more fields]
)
>>> graph.vertices.count()
13
>>> graph.edges.count()
282628
航班分析
现在我们已经创建了一个图,我们可以在其上执行查询。例如,我们现在可以查询GraphFrame以回答以下问题:
-
有多少个机场?
>>> num_of_airports = graph.vertices.count() >>> num_of_airports 13 -
有多少个航班?
>>> num_of_flights = graph.edges.count() >>> num_of_flights 282628 -
哪些航班路线距离最长?
>>> from pyspark.sql.functions import col >>> graph.edges .groupBy("src", "dst") .max("dist") .sort(col("max(dist)").desc()) .show(4) +---+---+---------+ |src|dst|max(dist)| +---+---+---------+ |MIA|SEA| 2724.0| |SEA|MIA| 2724.0| |BOS|SFO| 2704.0| |SFO|BOS| 2704.0| +---+---+---------+ -
哪些航班路线有最高的平均延误?
>>> graph.edges .groupBy("src", "dst") .avg("depdelay") .sort(col("avg(depdelay)").desc()) .show(5) +---+---+------------------+ |src|dst| avg(depdelay)| +---+---+------------------+ |ATL|EWR|25.520159946684437| |DEN|EWR|25.232164449818622| |MIA|SFO|24.785953177257525| |MIA|EWR|22.464104423495286| |IAH|EWR| 22.38344914718888| +---+---+------------------+ -
哪些航班起飞时间有最高的平均延误?
>>> graph.edges .groupBy("crsdephour") .avg("depdelay") .sort(col("avg(depdelay)").desc()) .show(5) +----------+------------------+ |crsdephour| avg(depdelay)| +----------+------------------+ | 19|22.915831356645498| | 20|22.187089292616932| | 18|22.183962000558815| | 17|20.553385253108907| | 21| 19.89884280327656| +----------+------------------+ -
针对超过 1,500 英里距离的航班,哪些航班的延误时间最长?请注意,这里的输出已被修剪以适应页面。
>>> graph.edges .filter("dist > 1500") .orderBy(col("depdelay").desc()) .show(3) +-------+--------+------+----+---+----------+----------+-----+---+ |carrier|depdelay| dist|dofW|dst| fldate| id|month|src| +-------+--------+------+----+---+----------+----------+-----+---+ | AA| 1345.0|1562.0| 4|DFW|2018-06-28|BOS_DFW...| 6|BOS| | AA| 1283.0|2342.0| 1|MIA|2018-07-09|LAX_MIA...| 7|LAX| | AA| 1242.0|2611.0| 3|LAX|2018-03-28|BOS_LAX...| 3|BOS| +-------+--------+------+----+---+----------+----------+-----+---+ -
延误航班从亚特兰大(ATL)出发的平均延误时间是多少?
>>> graph.edges .filter("src = 'ATL' and depdelay > 1") .groupBy("src", "dst") .avg("depdelay") .sort(col("avg(depdelay)").desc()) .show(3) +---+---+------------------+ |src|dst| avg(depdelay)| +---+---+------------------+ |ATL|EWR| 58.1085801063022| |ATL|ORD| 46.42393736017897| |ATL|DFW|39.454460966542754| +---+---+------------------+查看执行计划
要查看 DataFrame 执行的 Spark 逻辑和物理计划,您可以使用
DataFrame.explain():explain(extended=False) Description: Prints the (logical and physical) plans to the console for debugging purpose. For extended plan view, set extended=True例如,对于前面的查询,您可以如下查看执行计划:
graph.edges .filter("src = 'ATL' and depdelay > 1") .groupBy("src", "dst") .avg("depdelay") .sort(col("avg(depdelay)").desc()) .explain()请注意,在应用昂贵的转换之前,您应始终过滤您的 RDD/DataFrame。这将从 RDD/DataFrame 中删除非必需的元素/行,从而减少传递给未来转换的数据量,从而提高性能。
-
从亚特兰大出发的延误航班的最差起飞时间是什么?
>>> graph.edges .filter("src = 'ATL' and depdelay > 1") .groupBy("crsdephour") .avg("depdelay") .sort(col("avg(depdelay)").desc()) .show(4) +----------+------------------+ |crsdephour| avg(depdelay)| +----------+------------------+ | 23|52.833333333333336| | 18| 51.57142857142857| | 19| 48.93338815789474| | 17|48.383354350567465| +----------+------------------+ -
数据集中哪四条航班路线最频繁?
>>> flight_route_count = graph.edges .groupBy("src", "dst") .count() .orderBy(col("count").desc()) .show(4) +---+---+-----+ |src|dst|count| +---+---+-----+ |LGA|ORD| 4442| |ORD|LGA| 4426| |LAX|SFO| 4406| |SFO|LAX| 4354| +---+---+-----+提示
要找到这些信息,我们获取所有可能航班路线的航班计数,并按降序排序。稍后,我们将使用生成的 DataFrame 来查找没有直接连接的航班路线。
-
哪些机场有最多的进出航班?
>>> graph.degrees .orderBy(col("degree").desc()) .show(3) +---+------+ | id|degree| +---+------+ |ORD| 64386| |ATL| 60382| |LAX| 53733| +---+------+为了回答这个问题,我们使用 GraphFrames 的
degrees操作,该操作返回图中每个顶点的所有边(入边和出边)的计数。 -
根据 PageRank 算法,哪些机场最重要?
# Run PageRank until convergence to tolerance "tol" >>> ranks = graph.pageRank(resetProbability=0.15, tol=0.01) >>> ranks GraphFrame( v:[id: string, City: string ... 3 more fields], e:[src: string, dst: string ... 13 more fields] ) >>> ranks.vertices.orderBy(col("pagerank").desc()).show(3) +-------------+-------+-----+---+------------------+ | City|Country|State| id| pagerank| +-------------+-------+-----+---+------------------+ | Chicago| USA| IL|ORD|1.4151923966632058| | Atlanta| USA| GA|ATL|1.3342533126163776| | Los Angeles| USA| CA|LAX| 1.197905124144182| +-------------+-------+-----+---+------------------+GraphFrames API 提供的 PageRank 算法基于 Google 的 PageRank。它是一种通过确定哪些顶点与其他顶点(即具有最多边的顶点)连接最多来衡量图中每个顶点重要性的迭代算法。这个输出表明,芝加哥市的机场是所有检查的机场中最重要的(因为它具有最高的 PageRank 分数)。
您也可以对 PageRank 算法进行固定次数的迭代,而不是使用收敛容差级别,如下所示:
# Run PageRank for a fixed number of iterations results = graph.pageRank(resetProbability=0.15, maxIter=10)关于使用 PageRank 算法的更多细节,请参考第八章。
接下来,我们将考虑一个稍微复杂的查询:哪些航班路线没有直接连接?为了回答这个问题,我们将使用 GraphFrames API 的模式查找算法。首先,我们将从之前创建的flight_route_count DataFrame 创建一个子图,这给我们一个包含所有可能航班路线的子图。然后,我们将使用find()来搜索从a到b和从b到c的航班,其中没有从a到c的航班。最后,我们将使用 DataFrame 的过滤器来删除重复项。这个示例展示了如何将图查询与filter()等 DataFrame 操作轻松结合。
使用我们的flight_route_count DataFrame,我们可以使用以下模式来搜索从a到b和从b到c的航班,其中没有从a到c的直达航班:
>>> sub_graph = GraphFrame(graph.vertices, flight_route_count)
>>> sub_graph
GraphFrame(
v:[id: string, City: string ... 2 more fields],
e:[src: string, dst: string ... 1 more field]
)
>>> results = sub_graph.find(
"(a)-[]->(b);
(b)-[]->(c);
!(a)-[]->(c)"
)
.filter("c.id != a.id")
这产生了以下结果:
>>> results.show(5)
+--------------------+--------------------+--------------------+
| a| b| c|
+--------------------+--------------------+--------------------+
|[New York, USA, N...|[Denver, USA, CO,...|[San Francisco, U...|
|[Los Angeles, USA...|[Miami, USA, FL, ...|[New York, USA, N...|
|[New York, USA, N...|[Denver, USA, CO,...|[Newark, USA, NJ,...|
|[New York, USA, N...|[Miami, USA, FL, ...|[Newark, USA, NJ,...|
|[Newark, USA, NJ,...|[Atlanta, USA, GA...|[New York, USA, N...|
+--------------------+--------------------+--------------------+
假设我们想要找出哪些城市没有直达航班到某个特定机场。为了回答这个问题,我们可以使用最短路径算法计算从每个顶点(机场)到一个或多个“地标”顶点(机场)的最短路径。在这里,我们搜索从每个机场到 LGA 的最短路径。结果(距离大于 1)显示,从 IAH、CLT、LAX、DEN 或 DFW 到 LGA 没有直达航班:
>>> results = graph.shortestPaths(landmarks=["LGA"])
>>> results.show(5)
+-------------+-------+-----+---+----------+
| City|Country|State| id| distances|
+-------------+-------+-----+---+----------+
| Houston| USA| TX|IAH|[LGA -> 1]|
| Charlotte| USA| NC|CLT|[LGA -> 1]|
| Los Angeles| USA| CA|LAX|[LGA -> 2]|
| Denver| USA| CO|DEN|[LGA -> 1]|
| Dallas| USA| TX|DFW|[LGA -> 1]|
+-------------+-------+-----+---+----------+
现在假设我们想要找出两个特定机场之间是否有直达航班。广度优先搜索(BFS)算法找到从起始顶点到结束顶点的最短路径。起始和结束顶点被指定为 DataFrame 表达式,maxPathLength设置路径长度的限制。在这里,我们看到 LAX 和 LGA 之间没有直达航班,尽管有连接航班(请注意,输出已截断以适应页面):
# bfs() signature:
# bfs(fromExpr, toExpr, edgeFilter=None, maxPathLength=10)
# Returns a DataFrame with one row for each shortest
# path between matching vertices.
>>> paths = graph.bfs("id = 'LAX'", "id = 'LGA'", maxPathLength=1)
>>> paths.show()
+----+-------+-----+---+
|City|Country|State| id|
+----+-------+-----+---+
+----+-------+-----+---+
>>> paths = graph.bfs("id = 'LAX'", "id = 'LGA'", maxPathLength=2)
>>> paths.show(4)
+--------+-----------------+--------+-----------------+---------+
| from| e0| v1| e1| to|
+--------+-----------------+--------+-----------------+---------+
|[Los Ang|[0.0, UA, 1333, 8|[Houston|[0.0, UA, 1655, 1|[New York|
|[Los Ang|[0.0, UA, 1333, 8|[Houston|[22.0, UA, 2233, |[New York|
|[Los Ang|[0.0, UA, 1333, 8|[Houston|[6.0, UA, 1912, 1|[New York|
|[Los Ang|[0.0, UA, 1333, 8|[Houston|[0.0, UA, 2321, 1|[New York|
+--------+-----------------+--------+-----------------+---------+
我们还可以使用图案发现来识别两个机场之间的联程航班。在这里,我们将使用一个图案查询来搜索a到c通过b的模式,然后在结果上应用一个 DataFrame 过滤器,以a = LAX和c = LGA。结果显示了一些从 LAX 到 LGA 经由 IAH 联程的航班:
>>> graph.find("(a)-[ab]->(b);
(b)-[bc]->(c)"
).filter("a.id = 'LAX'")
.filter("c.id = 'LGA'")
.limit(4)
.select("a", "b", "c")
.show()
+--------------------+--------------------+--------------------+
| a| b| c|
+--------------------+--------------------+--------------------+
|[Los Angeles, USA...|[Houston, USA, TX...|[New York, USA, N...|
|[Los Angeles, USA...|[Houston, USA, TX...|[New York, USA, N...|
|[Los Angeles, USA...|[Houston, USA, TX...|[New York, USA, N...|
|[Los Angeles, USA...|[Houston, USA, TX...|[New York, USA, N...|
+--------------------+--------------------+--------------------+
通过将图案发现与 DataFrame 操作结合,我们甚至可以进一步缩小这些结果,例如排除到达时间早于初始出发航班时间的航班,并/或者识别特定航空公司的航班。
概要
总结如下:
-
Spark 提供两个图库,GraphX(基于 RDD)和 GraphFrames(基于 DataFrames)。GraphX 是 Spark 的内部图 API,用于图和图并行计算,仅支持 Java 和 Scala。GraphFrames 是 Spark 的外部包,为 Python、Scala 和 Java 提供高级 API。它在 PySpark 中可用,而 GraphX 则不可用。
-
GraphFrames 提供:
-
Python、Java 和 Scala API
-
通过使用“图案发现”进行表达性图查询
-
来自 Spark SQL 的查询计划优化器
-
图算法
-
接下来,我们将介绍 Spark 如何与外部数据源交互。
第七章:与外部数据源交互
在 Spark 中,为了运行任何算法,您需要从数据源读取输入数据,然后将您的算法应用为一组 PySpark 转换和操作(表达为 DAG),最后将所需的输出写入目标数据源。因此,为了编写性能良好的算法,了解从外部数据源读取和写入至关重要。
在前几章中,我们已经探讨了如何与 Spark 中的内置数据源(RDD 和 DataFrame)进行交互。在本章中,我们将专注于 Spark 如何与外部数据源进行接口交互。
如 图 7-1 所示,Spark 可以从广泛的外部存储系统(如 Linux 文件系统、Amazon S3、HDFS、Hive 表以及关系数据库(如 Oracle、MySQL 或 PostgreSQL))中读取数据,通过其数据源接口。本章将向您展示如何读取数据,并将其转换为 RDD 或 DataFrame 以进行进一步处理。我还将向您展示如何将 Spark 的数据写回到文件、Amazon S3 和 JDBC 兼容的数据库中。

图 7-1. Spark 外部数据源
关系数据库
让我们从关系数据库开始。关系数据库是一组数据项,以一组形式描述的表格组织起来(使用 SQL CREATE TABLE 语句创建),可以在不需要重新组织表格本身的情况下,以许多不同的方式访问或重新组装数据。开源关系数据库(如 MySQL 和 PostgreSQL)目前是存储社交媒体网络记录、财务记录、医疗记录、个人信息和制造数据的主要选择。还有许多著名且广泛使用的许可专有关系数据库,如 MS SQL Server 和 Oracle。
一个关系数据库表格非正式地具有一组行和命名列,如 图 7-2 所示。表格中的每一行可以有其自己的唯一键(称为主键)。表格中的行可以通过添加指向其他表格行的唯一键的列(这些列称为外键)来链接到其他表格的行。

图 7-2. 关系数据库表格示例
PySpark 提供了两个类,用于从关系数据库读取数据和将数据写入关系数据库,以及其他外部数据源。这两个类定义如下:
class pyspark.sql.DataFrameReader(spark)
这是从外部存储系统(文件系统、键值存储等)读取数据到 DataFrame 的接口。使用 spark.read() 来访问此功能。
class pyspark.sql.DataFrameWriter(df)
这是将 DataFrame 写入外部存储系统的接口。使用 DataFrame.write() 来访问此功能。
从数据库中读取数据
PySpark 使我们能够从关系数据库表中读取数据,并创建一个新的 DataFrame。您可以使用pyspark.sql.DataFrameReader.load() Python 方法从任何符合 JDBC 的数据库中读取表。load()方法定义如下:
load(path=None, format=None, schema=None, **options)
t :class`DataFrame`.
Parameters:
path – optional string or a list of string
for file-system backed data sources.
format – optional string for format of the data
source. Default to 'parquet'.
schema – optional pyspark.sql.types.StructType for
the input schema or a DDL-formatted string
(for example `col-1 INT, col-2 DOUBLE`).
options – all other string options
要从符合 JDBC 的数据库表中读取数据,需要指定format("jdbc")。然后可以将表属性和连接参数(例如 JDBC URL 和数据库凭据)作为options(*<key>*, *<value>*)对传入。
要读取并写入符合 JDBC 的关系数据库中的数据,您需要访问数据库服务器并具有足够的权限。
步骤 1. 创建数据库表
在此步骤中,我们将连接到 MySQL 数据库服务器,并创建一个名为dept的表,包含七行数据。我们执行mysql客户端程序来进入 MySQL 客户端 shell(例如,如果你在 MacBook 上安装了 MySQL 数据库,则 MySQL 客户端将位于/usr/local/bin/mysql):
$ mysql -uroot -p 
Enter password: *<your-root-password>* 
Welcome to the MySQL monitor. Commands end with ; or \g.
Server version: 5.7.18 MySQL Community Server (GPL)
mysql> show databases; 
+--------------------+ | Database | 
+--------------------+ | information_schema |
| mysql | 
| performance_schema |
+--------------------+ 3 rows in set (0.00 sec)
调用 MySQL shell 客户端。
输入root用户的有效密码。
列出 MySQL 数据库服务器中可用的数据库。
这三个数据库是由 MySQL 数据库服务器创建的。
mysql数据库管理用户、组和权限。
接下来,我们将创建并选择一个数据库:
mysql> create database metadb; 
mysql> use metadb; 
Database changed
mysql>
mysql> show tables; 
Empty set (0.00 sec)
创建一个名为metadb的新数据库。
将metadb设置为当前默认数据库。
显示metadb数据库中的表(因为它是一个新数据库,所以其中将没有任何表)。
然后我们将在metadb数据库内创建一个名为dept的新表:
mysql> create table dept ( 
-> dept_number int,
-> dept_name varchar(128),
-> dept_location varchar(128),
-> manager varchar(128)
-> );
mysql> show tables; 
+------------------+ | Tables_in_metadb |
+------------------+ | dept |
+------------------+
mysql> desc dept; 
+---------------+--------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra |
+---------------+--------------+------+-----+---------+-------+ | dept_number | int(11) | YES | | NULL | |
| dept_name | varchar(128) | YES | | NULL | |
| dept_location | varchar(128) | YES | | NULL | |
| manager | varchar(128) | YES | | NULL | |
+---------------+--------------+------+-----+---------+-------+
这是dept表的表定义,它包含四列。
列出metadb数据库中的表。
描述dept表的模式。
最后,我们使用INSERT语句将以下七行数据插入到dept表中:
mysql> INSERT INTO dept
-> (dept_number, dept_name, dept_location, manager)
-> VALUES
-> (10, 'ACCOUNTING', 'NEW YORK, NY', 'alex'),
-> (20, 'RESEARCH', 'DALLAS, TX', 'alex'),
-> (30, 'SALES', 'CHICAGO, IL', 'jane'),
-> (40, 'OPERATIONS', 'BOSTON, MA', 'jane'),
-> (50, 'MARKETING', 'Sunnyvale, CA', 'jane'),
-> (60, 'SOFTWARE', 'Stanford, CA', 'jane'),
-> (70, 'HARDWARE', 'BOSTON, MA', 'sophia');
我们可以检查dept表的内容,确保它包含这七行数据:
mysql> select * from dept;
+-------------+------------+---------------+---------+
| dept_number | dept_name | dept_location | manager |
+-------------+------------+---------------+---------+
| 10 | ACCOUNTING | NEW YORK, NY | alex |
| 20 | RESEARCH | DALLAS, TX | alex |
| 30 | SALES | CHICAGO, IL | jane |
| 40 | OPERATIONS | BOSTON, MA | jane |
| 50 | MARKETING | Sunnyvale, CA | jane |
| 60 | SOFTWARE | Stanford, CA | jane |
| 70 | HARDWARE | BOSTON, MA | sophia |
+-------------+------------+---------------+---------+
7 rows in set (0.00 sec)
目前,我们确信数据库服务器上存在一个名为metadb的数据库,其中包含一个具有七条记录的dept表。
步骤 2:将数据库表读入 DataFrame 中
一旦您有一个 JDBC 兼容的表(如 dept),那么您可以使用 pyspark.sql.DataFrameReader 类的方法(option() 和 load() 的结合)来读取表的内容并创建一个新的 DataFrame。要执行此读取操作,您需要一个 JAR 文件,这是一个 MySQL JDBC 驱动程序(您可以从 MySQL 网站 下载此 JAR 文件)。您可以将包含 MySQL 驱动程序类的 JAR 文件放在任何您喜欢的位置;我会把它放在:
.../code/jars/mysql-connector-java-5.1.42.jar
注意
MySQL 提供标准的数据库驱动程序连接(参见 Connector/J 了解详情),用于与符合行业标准 ODBC 和 JDBC 的应用程序和工具一起使用 MySQL。任何支持 ODBC 或 JDBC 的系统都可以使用 MySQL。
接下来,我们通过将 JAR 文件传递给 $SPARK_HOME/bin/pyspark 程序来进入 PySpark shell:
export JAR=/book/code/jars/mysql-connector-java-5.1.42.jar 
$SPARK_HOME/bin/pyspark --jars $JAR 
SparkSession available as `'spark'`.
>>> spark 
<pyspark.sql.session.SparkSession object at 0x10a5f2a50>
>>>
这是 MySQL 的驱动程序类 JAR。
启动 PySpark shell,加载 MySQL 驱动程序类 JAR。
确保 SparkSession 可用。
现在我们可以使用 SparkSession 读取一个关系表并创建一个新的 DataFrame:
dataframe_mysql = spark \ 
.read \ 
.format("jdbc") \ 
.option("url", "jdbc:mysql://localhost/metadb") \ 
.option("driver", "com.mysql.jdbc.Driver") \ 
.option("dbtable", "dept") \ 
.option("user", "root") \ 
.option("password", "mp22_pass") \ 
.load() 
spark 是 SparkSession 的一个实例。
返回一个可以用来读取数据作为 DataFrame 的 DataFrameReader
表明您正在读取 JDBC 兼容数据
数据库 URL
JDBC 驱动程序(从 JAR 文件加载)
数据库表名
数据库用户名
数据库密码
从 JDBC 数据源加载数据并将其作为 DataFrame 返回
让我们来看看新创建的 DataFrame:
>>> dataframe_mysql.count() 
7
>>> dataframe_mysql.show() 
+-----------+----------+-------------+-------+
|dept_number| dept_name|dept_location|manager|
+-----------+----------+-------------+-------+
| 10|ACCOUNTING| NEW YORK, NY| alex|
| 20| RESEARCH| DALLAS, TX| alex|
| 30| SALES| CHICAGO, IL| jane|
| 40|OPERATIONS| BOSTON, MA| jane|
| 50| MARKETING|Sunnyvale, CA| jane|
| 60| SOFTWARE| Stanford, CA| jane|
| 70| HARDWARE| BOSTON, MA| sophia|
+-----------+----------+-------------+-------+
计算 DataFrame 中行数。
打印前 20 行到控制台。
我们还可以查看其模式:
>>> dataframe_mysql.printSchema 
<bound method DataFrame.printSchema of
DataFrame[
dept_number: int,
dept_name: string,
dept_location: string,
manager: string
]
以树状格式打印模式。
步骤 3:查询 DataFrame
PySpark 提供了许多访问 DataFrame 的方式。除了各种类似 SQL 的方法(如 select(*<columns>*)、groupBy(*<columns>*)、min()、max() 等),它还允许您通过首先将其注册为“表”,然后针对该注册表执行完全成熟的 SQL 查询来执行对 DataFrame 的查询。我们将很快讨论 DataFrame 表注册。首先,我们将使用 DataFrame 方法执行一些类似 SQL 的查询。
在这里,我们选择所有行的两列,dept_number 和 manager:
>>> dataframe_mysql.select("dept_number", "manager") 
.show() 
+-----------+-------+
|dept_number|manager|
+-----------+-------+
| 10| alex|
| 20| alex|
| 30| jane|
| 40| jane|
| 50| jane|
| 60| jane|
| 70| sophia|
+-----------+-------+
从 DataFrame 中选择 dept_number 和 manager 列。
显示选择结果。
接下来,我们按 manager 列对所有行进行分组,然后找到最小的 dept_number:
>>> dataframe_mysql.select("dept_number", "manager")
.groupBy("manager")
.min("dept_number")
.collect()
[
Row(manager=u'jane', min(dept_number)=30),
Row(manager=u'sophia', min(dept_number)=70),
Row(manager=u'alex', min(dept_number)=10)
]
在这里,我们按 manager 列对所有行进行分组,然后找到分组数据的频率:
>>> dataframe_mysql.select("dept_number", "manager")
.groupBy("manager")
.count()
.show()
+--------+-------+
|manager | count |
+--------+-------+
| jane | 4 |
| sophia | 1 |
| alex | 2 |
+--------+-------+
在这里我们做同样的事情,但额外按 manager 列排序输出:
>>> dataframe_mysql.select("dept_number", "manager")
.groupBy("manager")
.count()
.orderBy("manager")
.show()
+--------+-------+
|manager | count |
+--------+-------+
| alex | 2 |
| jane | 4 |
| sophia | 1 |
+--------+-------+
要对 DataFrame 执行完全成熟的 SQL 查询,首先必须将 DataFrame 注册为表:
DataFrame.registerTempTable(*<your-desired-table-name>*)
您随后可以对其执行常规的 SQL 查询,就像它是一个关系数据库表一样:
>>> dataframe_mysql.registerTempTable("mydept") 
>>> spark.sql("select * from mydept where dept_number > 30") 
.show() 
+-----------+----------+-------------+-------+
|dept_number| dept_name|dept_location|manager|
+-----------+----------+-------------+-------+
| 40|OPERATIONS| BOSTON, MA| jane|
| 50| MARKETING|Sunnyvale, CA| jane|
| 60| SOFTWARE| Stanford, CA| jane|
| 70| HARDWARE| BOSTON, MA| sophia|
+-----------+----------+-------------+-------+
使用给定的名称将此 DataFrame 注册为临时表。
现在您可以针对您注册的表执行 SQL 查询了。
这会将前 20 行打印到控制台。
此查询使用 dept_location 列的“like”模式匹配:
>>> spark.sql("select * from mydept where dept_location like '%CA'")
.show()
+-----------+----------+-------------+-------+
|dept_number| dept_name|dept_location|manager|
+-----------+----------+-------------+-------+
| 50| MARKETING|Sunnyvale, CA| jane|
| 60| SOFTWARE| Stanford, CA| jane|
+-----------+----------+-------------+-------+
>>>
在这里,我们使用 GROUP BY:
>>> spark.sql("select manager, count(*) as count from mydept group by manager")
.show()
+-------+-----+
|manager|count|
+-------+-----+
| jane| 4|
| sophia| 1|
| alex| 2|
+-------+-----+
将 DataFrame 写入数据库
我们可以使用 DataFrameWriter.save() 方法将 Spark DataFrame 写入或保存到外部数据源,比如关系数据库表。让我们通过一个示例来详细说明。
首先,我们将创建一个三元组列表 (<name>, <age>, <salary>) 作为本地 Python 集合:
>>> triplets = [ ("alex", 60, 18000),
... ("adel", 40, 45000),
... ("adel", 50, 77000),
... ("jane", 40, 52000),
... ("jane", 60, 81000),
... ("alex", 50, 62000),
... ("mary", 50, 92000),
... ("mary", 60, 63000),
... ("mary", 40, 55000),
... ("mary", 40, 55000)
... ]
然后,我们将使用 SparkSession.createDataFrame() 方法将其转换为 Spark DataFrame:
>>> tripletsDF = spark.createDataFrame( 
... triplets, 
... ['name', 'age', 'salary'] 
... )
>>> tripletsDF.show() 
+----+---+------+
|name|age|salary|
+----+---+------+
|alex| 60| 18000|
|adel| 40| 45000|
|adel| 50| 77000|
|jane| 40| 52000|
|jane| 60| 81000|
|alex| 50| 62000|
|mary| 50| 92000|
|mary| 60| 63000|
|mary| 40| 55000|
|mary| 40| 55000|
+----+---+------+
创建一个新的 DataFrame。
将三元组转换为 DataFrame。
对创建的 DataFrame 强制执行模式。
显示新创建的 DataFrame 的内容。
现在,我们可以将 DataFrame 转换为名为 triplets 的关系表:
tripletsDF
.write 
.format("jdbc") 
.option("driver", "com.mysql.jdbc.Driver") 
.mode("overwrite") 
.option("url", "jdbc:mysql://localhost/metadb") 
.option("dbtable", "triplets") 
.option("user", "root") 
.option("password", "mp22_pass") 
.save() 
返回一个可以用于向外部设备写入的 DataFrameWriter
表明您正在向兼容 JDBC 的数据库写入
JDBC 驱动程序(从 JAR 文件加载)
如果表已经存在,则覆盖该表
数据库 URL
目标数据库表名称
数据库用户名
数据库密码
将 DataFrame 数据保存为数据库表
当将 DataFrame 的内容写入外部设备时,可以选择所需的模式。Spark JDBC 写入器支持以下模式:
append
将 DataFrame 的内容附加到任何现有数据。
overwrite
覆盖任何现有数据。
ignore
如果数据已经存在,则静默地忽略此操作。
error(默认情况)
如果数据已经存在,则抛出异常。
在这里,我们验证 triplets 表已在 MySQL 数据库服务器上的 'metadb' 数据库中创建:
$ mysql -uroot -p 
Enter password: *<password>* 
Welcome to the MySQL Server version: 5.7.18
mysql> use metadb; 
Database changed
mysql> desc triplets; 
+--------+------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra |
+--------+------------+------+-----+---------+-------+ | name | text | YES | | NULL | |
| age | bigint(20) | YES | | NULL | |
| salary | bigint(20) | YES | | NULL | |
+--------+------------+------+-----+---------+-------+
mysql> select * from triplets; 
+------+------+--------+ | name | age | salary |
+------+------+--------+ | jane | 40 | 52000 |
| adel | 50 | 77000 |
| jane | 60 | 81000 |
| alex | 50 | 62000 |
| mary | 40 | 55000 |
| mary | 40 | 55000 |
| adel | 40 | 45000 |
| mary | 60 | 63000 |
| alex | 60 | 18000 |
| mary | 50 | 92000 |
+------+------+--------+ 10 rows in set (0.00 sec)
启动 MySQL 客户端 shell。
为 root 用户输入密码。
选择所需的数据库。
确保 triplets 表已创建。
显示 triplets 表的内容。
接下来,我们从 MySQL 关系数据库中重新读取 triplets 表,以确保该表可读:
>>> tripletsDF_mysql =
spark 
.read 
.format("jdbc") 
.option("url", "jdbc:mysql://localhost/metadb") 
.option("driver", "com.mysql.jdbc.Driver") 
.option("dbtable", "triplets") 
.option("user", "root") 
.option("password", "mp22_pass") 
.load() 
>>> tripletsDF_mysql.show() 
+----+---+------+
|name|age|salary|
+----+---+------+
|jane| 40| 52000|
|adel| 50| 77000|
|jane| 60| 81000|
|alex| 50| 62000|
|mary| 40| 55000|
|mary| 40| 55000|
|adel| 40| 45000|
|mary| 60| 63000|
|alex| 60| 18000|
|mary| 50| 92000|
+----+---+------+
SparkSession 的一个实例
返回一个 DataFrameReader,可用于将数据读取为 DataFrame
表示正在读取符合 JDBC 的数据
数据库 URL
JDBC 驱动程序(从 JAR 文件加载)
要读取的数据库表
数据库用户名
数据库密码
从 JDBC 数据源加载数据并将其返回为 DataFrame
显示新创建 DataFrame 的内容
最后,我们将在新创建的 DataFrame 上执行一些 SQL 查询。
下面的查询找到 salary 列的最小值和最大值:
>>> tripletsDF_mysql.registerTempTable("mytriplets") 
>>> spark.sql("select min(salary), max(salary) from mytriplets") 
.show() 
+-----------+-----------+
|min(salary)|max(salary)|
+-----------+-----------+
| 18000| 92000|
+-----------+-----------+
使用名为 mytriplets 的临时表名注册此 DataFrame。
执行 SQL 语句并创建新的 DataFrame。
显示 SQL 语句的结果。
在这里,我们通过使用 SQL 的 GROUP BY 对 age 列进行聚合:
>>> spark.sql("select age, count(*) from mytriplets group by age").show()
+---+--------+
|age|count(1)|
+---+--------+
| 50| 3|
| 60| 3|
| 40| 4|
+---+--------+
接下来,我们对前面 SQL 查询的结果进行排序:
>>> spark.sql("select age, count(*) from mytriplets group by age order by age")
.show()
+---+--------+
|age|count(1)|
+---+--------+
| 40| 4|
| 50| 3|
| 60| 3|
+---+--------+
读取文本文件
Spark 允许我们读取文本文件并从中创建 DataFrame。考虑以下文本文件:
$ cat people.txt
Alex,30,Tennis
Betty,40,Swimming
Dave,20,Walking
Jeff,77,Baseball
让我们首先创建一个 RDD[Row](其中每个元素是一个 Row 对象):
>>> from pyspark.sql import Row
>>> def create_row(rec):
p = rec.split(",")
return Row(name=p[0], age=int(p[1]), hobby=p[2])
>>> #end-def
>>> input_path = "people.txt"
>>> # Load a text file and convert each line to a Row
>>> records = spark.sparkContext.textFile(input_path) 
>>> records.collect()
[
u'Alex,30,Tennis',
u'Betty,40,Swimming',
u'Dave,20,Walking',
u'Jeff,77,Baseball'
]
>>> people = records.map(create_row) 
>>> people.collect()
[
Row(age=30, hobby=u'Tennis', name=u'Alex'),
Row(age=40, hobby=u'Swimming', name=u'Betty'),
Row(age=20, hobby=u'Walking', name=u'Dave'),
Row(age=77, hobby=u'Baseball', name=u'Jeff')
]
records 是一个 RDD[String]。
people 是一个 RDD[Row]。
现在我们有了 people 作为 RDD[Row],可以轻松地创建一个 DataFrame:
>>> people_df = spark.createDataFrame(people) 
>>> people_df.show()
+---+--------+-----+
|age| hobby| name|
+---+--------+-----+
| 30| Tennis| Alex|
| 40|Swimming|Betty|
| 20| Walking| Dave|
| 77|Baseball| Jeff|
+---+--------+-----+
>>> people_df.printSchema() 
root
|-- age: long (nullable = true)
|-- hobby: string (nullable = true)
|-- name: string (nullable = true)
people_df 是一个 DataFrame[Row]。
显示创建的 DataFrame 的模式。
接下来,我们将使用 SQL 查询来操作创建的 DataFrame:
>>> people_df.registerTempTable("people_table") 
>>> spark.sql("select * from people_table").show() 
+---+--------+-----+
|age| hobby| name|
+---+--------+-----+
| 30| Tennis| Alex|
| 40|Swimming|Betty|
| 20| Walking| Dave|
| 77|Baseball| Jeff|
+---+--------+-----+
>>> spark.sql("select * from people_table where age > 35").show() 
+---+--------+-----+
|age| hobby| name|
+---+--------+-----+
| 40|Swimming|Betty|
| 77|Baseball| Jeff|
+---+--------+-----+
将 people_df DataFrame 注册为临时表,表名为 people_table。
spark.sql(*sql-query*) 创建一个新的 DataFrame。
spark.sql(*sql-query*) 创建一个新的 DataFrame。
我们可以使用 DataFrame.write() 将 DataFrame 保存为文本文件。
读取和写入 CSV 文件
逗号分隔值文件是一种文本文件,允许以表格结构的格式保存数据。以下是一个带有标题行(包含列名的元数据,用逗号分隔)的简单 CSV 文件示例,名为 cats.with.header.csv:
$ cat cats.with.header.csv
#name,age,gender,weight 
cuttie,2,female,6 
mono,3,male,9 
fuzzy,1,female,4 
头记录以 # 开头,描述列
第一条记录
第二条记录
第三和最后一条记录
以下是一个不带标题的简单 CSV 文件示例,名为 cats.no.header.csv:
$ cat cats.no.header.csv
cuttie,2,female,6
mono,3,male,9
fuzzy,1,female,4
在接下来的部分,我们将使用这两个文件演示 Spark 如何读取 CSV 文件。
读取 CSV 文件
Spark 提供了许多方法将 CSV 文件加载到 DataFrame 中。这里我会展示其中一些方法。
在这个例子中,使用 PySpark shell,我们读取了一个带有标题的 CSV 文件,并将其加载为 DataFrame:
# spark : pyspark.sql.session.SparkSession object
input_path = '/pyspark_book/code/chap08/cats.with.header.csv'
cats = spark 
.read 
.format("csv") 
.option("header", "true") 
.option("inferSchema", "true") 
.load(input_path) 
使用 SparkSession 创建一个名为 cats 的新 DataFrame。
返回一个可以用于将数据读取为 DataFrame 的DataFrameReader。
指定输入数据源格式为 CSV。
指示输入的 CSV 文件包含标题(注意标题不是实际数据的一部分)。
从输入文件推断 DataFrame 的模式。
提供 CSV 文件的路径。
现在我们可以显示新创建的 DataFrame 的内容和推断出的模式:
>>> cats.show() 
+------+---+------+------+
| name|age|gender|weight|
+------+---+------+------+
|cuttie| 2|female| 6|
| mono| 3| male| 9|
| fuzzy| 1|female| 4|
+------+---+------+------+
>>> cats.printSchema 
<bound method DataFrame.printSchema of DataFrame
[
name: string,
age: int,
gender: string,
weight: int
]>
>>> cats.count() 
3
显示 DataFrame 的内容。
显示 DataFrame 的模式。
显示 DataFrame 的大小。
现在我将向您展示如何读取一个没有标题的 CSV 文件,并从中创建一个新的 DataFrame:
input_path = '/pyspark_book/code/chap08/cats.no.header.csv'
cats2 = spark 
.read 
.format("csv") 
.option("header","false") 
.option("inferSchema", "true") 
.load(input_path) 
使用SparkSession创建一个名为cats的新 DataFrame。
返回一个可以用于将数据读取为 DataFrame 的DataFrameReader。
指定输入数据源格式为 CSV。
指示输入的 CSV 文件没有标题。
从输入文件推断 DataFrame 的模式。
提供 CSV 文件的路径。
让我们检查新创建的 DataFrame 及其推断出的模式的内容:
>>> cats2.show()
+------+---+------+---+
| _c0|_c1| _c2|_c3| 
+------+---+------+---+
|cuttie| 2|female| 6|
| mono| 3| male| 9|
| fuzzy| 1|female| 4|
+------+---+------+---+
默认列名
接下来,我们将定义一个包含四列的模式:
>>> from pyspark.sql.types import StructType
>>> from pyspark.sql.types import StructField
>>> from pyspark.sql.types import StringType
>>> from pyspark.sql.types import IntegerType
>>>
>>> catsSchema = StructType([
... StructField("name", StringType(), True),
... StructField("age", IntegerType(), True),
... StructField("gender", StringType(), True),
... StructField("weight", IntegerType(), True)
... ])
如果我们使用定义的模式读取相同的 CSV 文件并从中创建一个 DataFrame,会发生以下情况:
>>> input_path = '/book/code/chap07/cats.no.header.csv'
>>> cats3 = spark
.read
.format("csv")
.option("header","false")
.option("inferSchema", "true")
.load(input_path, schema = catsSchema)
>>> cats3.show()
+------+---+------+------+
| name|age|gender|weight| 
+------+---+------+------+
|cuttie| 2|female| 6|
| mono| 3| male| 9|
| fuzzy| 1|female| 4|
+------+---+------+------+
>>> cats.count()
3
使用显式列名
我们可以将这个预定义的模式应用于任何没有标题的 CSV 文件:
>>> cats4 = spark
.read 
.csv("file:///tmp/cats.no.header.csv", 
schema = catsSchema, 
header = "false") 
>>> cats4.show()
+------+---+------+------+
| name|age|gender|weight| 
+------+---+------+------+
|cuttie| 2|female| 6|
| mono| 3| male| 9|
| fuzzy| 1|female| 4|
+------+---+------+------+
>>> cats4.printSchema
<bound method DataFrame.printSchema of DataFrame
[
name: string,
age: int,
gender: string,
weight: int
]>
>>> cats4.count()
3
返回一个可以用于将数据读取为 DataFrame 的DataFrameReader。
读取一个 CSV 文件。
使用给定的模式来读取 CSV 文件。
指示 CSV 文件没有标题。
使用显式列名。
写入 CSV 文件
有几种方法可以在 Spark 中从 DataFrames 创建 CSV 文件。最简单的选项是使用DataFrameWriter类的.csv()方法,通过DataFrame.write()访问。该方法定义如下(请注意,这只是可用参数的一个小子集):
csv(path, mode=None, compression=None, sep=None, ...)
Saves the content of the DataFrame in CSV format
at the specified path.
Parameters:
path – the path in any Hadoop supported file system.
mode – specifies the behavior of the save operation when data already exists.
"append": Append contents of this DataFrame to existing data.
"overwrite": Overwrite existing data.
"ignore": Silently ignore this operation if data already exists.
"error": Throw an exception if data already exists.
compression – compression codec to use when saving to file.
sep – sets a single character as a separator for each field and value.
If None is set, it uses the default value.
让我们使用这种方法将我们的cats4 DataFrame 保存为 CSV 文件:
>>> cats4.show()
+------+---+------+------+
| name|age|gender|weight|
+------+---+------+------+
|cuttie| 2|female| 6|
| mono| 3| male| 9|
| fuzzy| 1|female| 4|
+------+---+------+------+
>>> cats4.write.csv("file:///tmp/cats4", sep = ';')
然后检查保存的文件:
$ ls -l /tmp/cats4
total 8
-rw-r--r-- ... 0 Apr 12 16:46 _SUCCESS
-rw-r--r-- ... 49 Apr 12 16:46 part-00000-...-c000.csv
$ cat /tmp/cats4/part*
cuttie;2;female;6
mono;3;male;9
fuzzy;1;female;4
注意,在ls(列出)命令的输出中,我们看到两种类型的文件:
-
大小为零的SUCCESS文件,表示
write操作成功。 -
一个或多个以part-开头的文件,这些文件代表单个分区的输出。
还要注意保存的文件中没有标题数据。
让我们再试一次,这次指定我们想要一个标题行:
>>> cats4.write.csv("file:///tmp/cats48",
sep = ';',
header = 'true')
$ ls -l /tmp/cats48
total 8
-rw-r--r-- ... 0 Apr 12 16:49 _SUCCESS
-rw-r--r-- ... 72 Apr 12 16:49 part-00000-...-c000.csv
$ cat /tmp/cats48/part*
name;age;gender;weight 
cuttie;2;female;6
mono;3;male;9
fuzzy;1;female;4
我们 DataFrame 的标题
读取和写入 JSON 文件
JavaScript 对象表示法是一种轻量级的基于文本的数据交换格式,易于人类阅读和编写。JSON 对象由一组(键,值)对组成,用花括号括起来,像这样:
{
"first_name" : "John", 
"last_name" : "Smith",
"age" : 23,
"gender" : "Male",
"cars": [ "Ford", "BMW", "Fiat" ] 
}
一个简单的(键,值)对
一个数组值
读取 JSON 文件
JSON 数据可以使用DataFrameReader.json()方法读取,该方法可以接受一组参数,如路径和模式。考虑以下 JSON 文件:
$ cat $SPARK_HOME/examples/src/main/resources/employees.json
{"name":"Michael", "salary":3000}
{"name":"Andy", "salary":4500}
{"name":"Justin", "salary":3500}
{"name":"Berta", "salary":4000}
我们可以按以下方式读取此文件并将其转换为 DataFrame:
>>> data_path = 'examples/src/main/resources/employees.json'
>>> df = spark.read.json(data_path)
>>> df.show()
+-------+------+
| name|salary|
+-------+------+
|Michael| 3000|
| Andy| 4500|
| Justin| 3500|
| Berta| 4000|
+-------+------+
>>> df.printSchema
<bound method DataFrame.printSchema of DataFrame
[
name: string,
salary: bigint
]>
>>> df.count()
4
您还可以使用load()方法,并将其传递给一个或多个 JSON 文件:
>>> data_path = 'examples/src/main/resources/employees.json'
>>> df2 = spark.read.format('json')
.load([data_path, data_path]) 
>>> df2.show()
+-------+------+
| name|salary|
+-------+------+
|Michael| 3000|
| Andy| 4500|
| Justin| 3500|
| Berta| 4000|
|Michael| 3000| 
| Andy| 4500|
| Justin| 3500|
| Berta| 4000|
+-------+------+
>>> df2.printSchema
<bound method DataFrame.printSchema of DataFrame
[
name: string,
salary: bigint
]>
>>> df2.count()
8
注意,data_path加载了两次。
结果 DataFrame 中因此包含文件内容两次。您还可以使用此方法从多个输入文件创建 DataFrame。
写 JSON 文件
要将 DataFrame 写入json对象,我们可以使用DataFrameWriter.json()方法。该方法接受一组参数,并将 DataFrame 的内容保存为 JSON 格式:
json(
path,
mode=None,
compression=None,
dateFormat=None,
timestampFormat=None
)
Parameters:
path – the path in any Hadoop supported file system
mode – specifies the behavior of the save operation when data already exists.
Possible values are: "append", "overwrite", "ignore", "error"
compression – compression codec to use when saving to file.
dateFormat – sets the string that indicates a date format.
timestampFormat – sets the string that indicates a timestamp format.
让我们首先创建一个 DataFrame:
>>> data = [("name", "alex"), ("gender", "male"), ("state", "CA")]
>>> df = spark.createDataFrame(data, ['key', 'value'])
>>> df.show()
+------+-----+
| key|value|
+------+-----+
| name| alex|
|gender| male|
| state| CA|
+------+-----+
接下来,将其作为 JSON 写入输出路径:
>>> df.write.json('/tmp/data')
$ ls -l /tmp/data
total 24
-rw-r--r-- ... 0 Apr 2 01:15 _SUCCESS
-rw-r--r-- ... 0 Apr 2 01:15 part-00000-...-c000.json
-rw-r--r-- ... 0 Apr 2 01:15 part-00001-...-c000.json
...
-rw-r--r-- ... 29 Apr 2 01:15 part-00007-...-c000.json
注意,我们有八个以part-开头的文件名:这意味着我们的 DataFrame 由八个分区表示。
让我们来看看这些文件:
$ cat /tmp/data/part*
{"key":"name","value":"alex"}
{"key":"gender","value":"male"}
{"key":"state","value":"CA"}
如果要创建单个文件输出,则可以在将其写出之前将 DataFrame 放入单个分区:
>>> df.repartition(1).write.json('/tmp/data') 
repartition(numPartitions)返回一个由给定分区表达式分区的新 DataFrame;有关详细信息,请参见第五章。
从 Amazon S3 读取和写入
Amazon Simple Storage Service(S3)是由 Amazon Web Services(AWS)提供的一项服务,通过 Web 服务接口提供对象存储。S3 对象被视为 Web 对象,即通过 Internet 协议使用 URL 标识符访问。每个 S3 对象都有一个唯一的 URL,格式如下:
http://s3.*<region>*.amazonaws.com/*<bucket>*/*<key>*
例如:
http://s3.us-east-1.amazonaws.com/project-dev/dna/sample123.vcf
其中:project-dev是存储桶名称,dna/sample123.vcf是一个键。
S3 对象也可以通过以下 URI 模式访问:
s3n
使用 S3 本地文件系统,这是用于在 S3 上读写常规文件的本地文件系统。
s3a
使用 S3A 文件系统,这是原生文件系统的继任者。设计为s3n的替代品,此文件系统绑定支持更大的文件并承诺更高的性能。
s3
使用 S3 块文件系统,这是一个基于块的文件系统,由 S3 支持。文件以块的形式存储,就像在 HDFS 中一样。
s3和s3n/s3a的区别在于,s3是 Amazon S3 上的块级覆盖层,而s3n/s3a不是(它们是基于对象的)。s3n和s3a的区别在于,s3n支持最大 5GB 的对象大小,而s3a支持最大 5TB 的对象大小并且具有更好的性能(这两个特性是因为它使用了多部分上传)。
例如,使用s3 URI 模式,我们可以访问sample72.vcf文件如下:
s3://project-dev/dna/sample72.vcf
一般来说,要访问 AWS 的任何服务,您必须经过身份验证。有很多方法可以做到这一点。一种方法是从命令行导出您的访问密钥和秘密密钥:
export AWS_ACCESS_KEY_ID="AKIAI74O5KPLUQGVOJWQ"
export AWS_SECRET_ACCESS_KEY="LmuKE7afdasdfxK2vj1nfA0Bp"
另一个选项是使用SparkContext对象来设置您的凭据:
# spark: SparkSession
sc = spark.sparkContext
# set access key
sc._jsc.hadoopConfiguration()
.set("fs.s3.awsAccessKeyId", "AKIAI74O5KPLUQGVOJWQ")
# set secret key
sc._jsc.hadoopConfiguration()
.set("fs.s3.awsSecretAccessKey", "LmuKE7afdasdfxK2vj1nfA0Bp")
从 Amazon S3 读取
您需要使用s3、s3n或s3a(用于更大的 S3 对象)URI 模式来从 S3 中读取对象。
如果spark是SparkSession的实例,则可以使用以下内容加载文本文件(Amazon S3 对象),并返回一个带有名为value的单个字符串列的 DataFrame(变量名为df):
s3_object_path = "s3n://bucket-name/object-path"
df = spark.read.text(s3_object_path)
以下示例展示了如何读取 S3 对象。首先我们使用boto3库(boto3是 AWS 的 Python SDK,允许 Python 开发者编写利用 Amazon 服务如 S3 和 EC2 的软件)来验证对象是否存在,然后使用 PySpark 来读取它。
在以下代码中,我们检查是否存在s3://caselog-dev/tmp/csv_file_10_rows.csv:
>>> import boto3
>>> s3 = boto3.resource('s3')
>>> bucket = 'caselog-dev'
>>> key = 'tmp/csv_file_10_rows.csv'
>>> obj = s3.Object(bucket, key)
>>> obj
s3.Object(bucket_name='caselog-dev', key='tmp/csv_file_10_rows.csv')
>>> obj.get()['Body'].read().decode('utf-8')
u'0,a,0.0\n1,b,1.1\n2,c,2.2\n3,d,\n4,,4.4\n,f,5.5\n,,\n7,h,7.7\n8,i,8.8\n9,j,9.9'
然后,我们加载该对象并创建一个新的DataFrame[String]:
>>> s3_object_path = "s3n://caselog-dev/tmp/csv_file_10_rows.csv" 
>>> df = spark.read.text(s3_object_path) 
>>> df.show() 
+-------+
| value|
+-------+
|0,a,0.0|
|1,b,1.1|
|2,c,2.2|
| 3,d,|
| 4,,4.4|
| ,f,5.5|
| ,,|
|7,h,7.7|
|8,i,8.8|
|9,j,9.9|
+-------+
>>> df.printSchema 
<bound method DataFrame.printSchema of DataFrame[value: string]>
定义您的 S3 对象路径。
使用SparkSession(作为spark)加载 S3 对象并创建一个 DataFrame。
展示新创建的 DataFrame 的内容。
显示新创建的 DataFrame 的模式。
写入到 Amazon S3
一旦您创建了 DataFrame:
>>># spark: SparkSession
>>> pairs_data = [("alex", 4), ("alex", 8),
("rafa", 3), ("rafa", 6)]
>>> df = spark.createDataFrame(pairs_data, ['name', 'number'])
您可以检查其内容及其关联的架构:
>>> df.show()
+----+------+
|name|number|
+----+------+
|alex| 4|
|alex| 8|
|rafa| 3|
|rafa| 6|
+----+------+
>>> df.printSchema
<bound method DataFrame.printSchema of DataFrame
[
name: string,
number: bigint
]>
接下来,将数据保存到 Amazon S3 文件系统:
>>> df
.write
.format("csv")
.mode("overwrite")
.save("s3n://caselog-dev/output/pairs")
您将看到以下文件被创建:
https://s3.amazonaws.com/caselog-dev/output/pairs/_SUCCESS
https://s3.amazonaws.com/caselog-dev/output/pairs/part-00000-...-c000.csv
https://s3.amazonaws.com/caselog-dev/output/pairs/part-00001-...-c000.csv
现在让我们将其读取回来:
>>># Read S3 object as text
>>> s3_object_path = "s3n://caselog-dev/output/pairs"
>>> df = spark.read.text(s3_object_path)
>>> df.show()
+------+
| value|
+------+
|alex,4|
|alex,8|
|rafa,3|
|rafa,6|
+------+
>>> df.printSchema
<bound method DataFrame.printSchema of DataFrame[value: string]>
我们也可以将 S3 对象读取为 CSV 格式:
>>> df2 = spark.read.format("csv").load(s3_object_path)
>>> df2.show()
+----+---+
| _c0|_c1| 
+----+---+
|alex| 4|
|alex| 8|
|rafa| 3|
|rafa| 6|
+----+---+
默认列名
读取和写入 Hadoop 文件
Hadoop 是一个开源的 MapReduce 编程框架,支持在分布式计算环境中处理和存储极大数据集。它设计用于从单个服务器扩展到数千台机器。由 Apache Software Foundation(ASF)赞助的 Hadoop 项目包括以下模块:
Hadoop 公共模块
支持其他 Hadoop 模块的常见实用工具。
Hadoop 分布式文件系统(HDFS)
一个分布式文件系统,提供高吞吐量访问应用程序数据。HDFS 允许在使用 MapReduce 编程模型的计算机集群上进行大数据集的分布式处理。
Hadoop YARN
用于作业调度和集群资源管理的框架。
Hadoop MapReduce
基于 YARN 的系统,用于大数据集的并行处理。
在本节中,我将展示如何从 HDFS 中读取文件并创建 RDD 和 DataFrame,以及如何将 RDD 和 DataFrame 写入 HDFS。要跟随示例,您需要访问一个 Hadoop 集群。
读取 Hadoop 文本文件
为了演示从 HDFS 中读取文件的完整过程,首先我们将在 HDFS 中创建一个文本文件,然后使用 PySpark 将其读取为 DataFrame 和 RDD。
让 name_age_salary.csv 是 Linux 文件系统中的一个文本文件(可以使用任何文本编辑器创建此文件——注意 $ 是 Linux 操作系统提示符):
$ export input_path = "/book/code/chap07/name_age_salary.csv"
$ cat $input_path
alex,60,18000
adel,40,45000
adel,50,77000
jane,40,52000
jane,60,81000
alex,50,62000
mary,50,92000
mary,60,63000
mary,40,55000
mary,40,55000
使用 $HADOOP_HOME/bin/hdfs 命令在 HDFS 中创建一个 /test 目录:
$ hdfs dfs -mkdir /test
然后我将 name_age_salary.csv 复制到 hdfs:///test/ 目录:
$ hdfs dfs -put $input_path /test/
$ hdfs dfs -ls /test/
-rw-r--r-- 1 ... 140 ... /test/name_age_salary.csv
并且我检查文件的内容:
$ hdfs dfs -cat /test/name_age_salary.csv
alex,60,18000
adel,40,45000
adel,50,77000
jane,40,52000
jane,60,81000
alex,50,62000
mary,50,92000
mary,60,63000
mary,40,55000
mary,40,55000
现在我们已经在 HDFS 中创建了一个文件,我们将读取它,并从其内容创建一个 DataFrame 和一个 RDD。
首先,我们读取 HDFS 文件,并创建一个带有默认列名 (_c0, _c1, _c2) 的 DataFrame。HDFS URI 的一般格式如下:
hdfs://*<server>*:*<port>*/*<directories>*/*<filename>*
其中 <server> 是 NameNode 的主机名,<port> 是 NameNode 的端口号。
在本例中,我使用了安装在我的 MacBook 上的 Hadoop 实例;NameNode 是 localhost,端口号是 9000:
>>> uri = 'hdfs://localhost:9000/test/name_age_salary.csv'
>>> df = spark.read.csv(uri)
>>> df.show()
+----+---+-----+
| _c0|_c1| _c2| 
+----+---+-----+
|alex| 60|18000|
|adel| 40|45000|
|adel| 50|77000|
|jane| 40|52000|
|jane| 60|81000|
|alex| 50|62000|
|mary| 50|92000|
|mary| 60|63000|
|mary| 40|55000|
|mary| 40|55000|
+----+---+-----+
默认列名
让我们检查新创建的 DataFrame 的架构:
>>> df.printSchema
<bound method DataFrame.printSchema of DataFrame
[
_c0: string,
_c1: string,
_c2: string
]>
如果您想在 DataFrame 上强制使用自己的显式模式(列名和数据类型),则可以这样做:
>>> from pyspark.sql.types import StructType
>>> from pyspark.sql.types import StructField
>>> from pyspark.sql.types import StringType
>>> from pyspark.sql.types import IntegerType
>>>
>>> empSchema = StructType( ![1
StructField("name", StringType(), True),
StructField("age", IntegerType(), True),
StructField("salary", StringType(), True)
])
>>>
>>> uri = 'hdfs://localhost:9000/test/name_age_salary.csv'
>>> df2 = spark.read.csv(uri, schema = empSchema) 
>>> df2.show()
+----+---+------+
|name|age|salary| 
+----+---+------+
|alex| 60| 18000|
|adel| 40| 45000|
|adel| 50| 77000|
|jane| 40| 52000|
|jane| 60| 81000|
|alex| 50| 62000|
|mary| 50| 92000|
|mary| 60| 63000|
|mary| 40| 55000|
|mary| 40| 55000|
+----+---+------+
>>> df2.printSchema
<bound method DataFrame.printSchema of DataFrame
[
name: string,
age: int,
salary: string
]>
显式模式定义
强制使用显式模式
显式列名
你还可以从 HDFS 文件中读取,并创建一个RDD[String]:
>>> rdd = spark.sparkContext.textFile(uri)
>>> rdd.collect()
[
u'alex,60,18000',
u'adel,40,45000',
u'adel,50,77000',
u'jane,40,52000',
u'jane,60,81000',
u'alex,50,62000',
u'mary,50,92000',
u'mary,60,63000',
u'mary,40,55000',
u'mary,40,55000'
]
写入 Hadoop 文本文件
PySpark 的 API 允许我们将 RDD 和 DataFrame 保存为 HDFS 文件。首先让我们看看如何将 RDD 保存到 HDFS 文件中:
>>> pairs = [('alex', 2), ('alex', 3),
('jane', 5), ('jane', 6)]
>>> rdd = spark.sparkContext.parallelize(pairs)
>>> rdd.collect()
[('alex', 2), ('alex', 3), ('jane', 5), ('jane', 6)]
>>> rdd.count()
4
>>> rdd.saveAsTextFile("hdfs://localhost:9000/test/pairs")
RDD.saveAsTextFile(path)方法将数据集的元素写入文本文件(或一组文本文件)到指定的目录中,可以是本地文件系统、HDFS 或其他任何 Hadoop 支持的文件系统。Spark 会调用每个元素的toString()方法将其转换为文件中的文本行。
接下来,让我们查看在 HDFS 中创建了什么(这里的输出格式已经适合页面):
$ hdfs dfs -ls hdfs://localhost:9000/test/pairs
Found 9 items
-rw-r--r-- ... 0 ... hdfs://localhost:9000/test/pairs/_SUCCESS
-rw-r--r-- ... 0 ... hdfs://localhost:9000/test/pairs/part-00000
-rw-r--r-- ... 12 ... hdfs://localhost:9000/test/pairs/part-00001
...
-rw-r--r-- ... 12 ... hdfs://localhost:9000/test/pairs/part-00007
$ hdfs dfs -cat hdfs://localhost:9000/test/pairs/part*
(*alex*, 2)
(*alex*, 3)
(*jane*, 5)
(*jane*, 6)
我们得到了八个part-文件的原因是因为源 RDD 有八个分区:
>>> rdd.getNumPartitions()
8
如果你想创建一个单一的part-文件,那么你应该创建一个单一的 RDD 分区:
>>> rdd_single = spark.sparkContext.parallelize(pairs, 1)
>>> rdd_single.collect()
[('alex', 2), ('alex', 3), ('jane', 5), ('jane', 6)]
>>> rdd_single.getNumPartitions()
1
>>> rdd_single.saveAsTextFile("hdfs://localhost:9000/test/pairs_single")
让我们查看在 HDFS 行创建了什么:
$ hdfs dfs -ls hdfs://localhost:9000/test/pairs_single
Found 2 items
-rw-r--r-- 0 hdfs://localhost:9000/test/pairs_single/_SUCCESS
-rw-r--r-- 48 hdfs://localhost:9000/test/pairs_single/part-00000
$ hdfs dfs -cat hdfs://localhost:9000/test/pairs_single/part-00000
(*alex*, 2)
(*alex*, 3)
(*jane*, 5)
(*jane*, 6)
我们可以通过使用DataFrameWriter将 DataFrame 保存到 HDFS 中:
>>> pairs = [('alex', 2), ('alex', 3),
('jane', 5), ('jane', 6)]
>>>
>>> pairsDF = spark.createDataFrame(pairs)
>>> pairsDF.show()
+----+---+
| _1| _2|
+----+---+
|alex| 2|
|alex| 3|
|jane| 5|
|jane| 6|
+----+---+
>>> pairsDF.write.csv("hdfs://localhost:9000/test/pairs_df")
下面是在 HDFS 中创建的内容:
$ hdfs dfs -ls hdfs://localhost:9000/test/pairs_df
Found 9 items
-rw-... 0 hdfs://localhost:9000/test/pairs_df/_SUCCESS
-rw-... 0 hdfs://localhost:9000/test/pairs_df/part-00000-...-c000.csv
...
-rw-... 7 hdfs://localhost:9000/test/pairs_df/part-00007-...-c000.csv
$ hdfs dfs -cat hdfs://localhost:9000/test/pairs_df/part*
alex,2
alex,3
jane,5
jane,6
您可以将 DataFrame 以不同的数据格式保存到 HDFS 中。例如,要以 Parquet 格式保存 DataFrame,可以使用以下模板:
# df is an existing DataFrame object.
# format options are 'csv', 'parquet', 'json'
df.write.save(
'/target/path/',
format='parquet',
mode='append'
)
读取和写入 HDFS SequenceFiles
Hadoop 支持持久化任何文件类型,包括在 HDFS 中的SequenceFile。SequenceFile 是由二进制(键,值)对组成的平面文件。Hadoop 将SequenceFile类定义为org.apache.hadoop.io.SequenceFile。SequenceFile 提供了用于写入、读取和排序的SequenceFile.Writer、SequenceFile.Reader和SequenceFile.Sorter类。SequenceFile 是 Hadoop 的标准二进制序列化格式。它存储Writable(键,值)对的记录,并支持分割和压缩。由于它们比文本文件更高效,因此SequenceFile 在 MapReduce 管道中常用于中间数据存储。
读取 HDFS SequenceFiles
Spark 支持使用SparkContext.sequenceFile()方法读取SequenceFile。例如,在 Python 中读取具有Text键和DoubleWritable值的SequenceFile,我们将执行以下操作:
# spark: an instance of SparkSession
rdd = spark.sparkContext.sequenceFile(path)
请注意,与 Java 或 Scala 不同,我们不会将(键,值)对的数据类型传递给 Spark API;Spark 会自动将 Hadoop 的Text转换为String,将DoubleWritable转换为Double。
写入 HDFS SequenceFiles
PySpark 的RDD.saveAsSequenceFile()方法允许用户将(键,值)对的 RDD 保存为SequenceFile。例如,我们可以从 Python 集合创建一个 RDD,并将其保存为SequenceFile,如下所示:
# spark: an instance of SparkSession
pairs = [('key1', 10.0), ('key2', 20.0),
('key3', 30.0), ('key4', 40.0)]
rdd = spark.sparkContext.parallelize(pairs)
rdd.saveAsSequenceFile('/tmp/sequencefile/')
然后我们可以读取新创建的SequenceFile,并将其转换为(键,值)对的 RDD:
# spark: an instance of SparkSession
rdd2 = spark.sparkContext.sequenceFile('/tmp/sequencefile/')
rdd2.collect()
[(u'key1', 10.0),
(u'key2', 20.0),
(u'key3', 30.0),
(u'key4', 40.0)
]
读写 Parquet 文件
Parquet 是许多数据处理系统支持的列式数据格式。它是自描述的(包含元数据)、与语言无关的,并且非常适合快速分析。
Spark SQL 在读取和写入 Parquet 文件时提供支持,同时自动保留原始数据的模式。在写入 Parquet 文件时,所有列都会自动转换为可为空,以确保兼容性。
图 7-3 描述了一个逻辑表及其相关的行和列布局。

图 7-3. 带有行布局和列布局的逻辑表
写入 Parquet 文件
在本节中,我将向您展示如何使用 PySpark API 将 JSON 文件读取为 DataFrame,然后将其保存为 Parquet 文件。假设我们有以下 JSON 文件:
$ cat examples/src/main/resources/employees.json
{"name":"Michael", "salary":3000}
{"name":"Andy", "salary":4500}
{"name":"Justin", "salary":3500}
{"name":"Berta", "salary":4000}
使用 DataFrameReader,我们将 JSON 文件读取为 DataFrame 对象,命名为 peopleDF:
>>> input_path = "examples/src/main/resources/employees.json"
>>> peopleDF = spark.read.json(input_path)
>>> peopleDF.show()
+-------+------+
| name|salary|
+-------+------+
|Michael| 3000|
| Andy| 4500|
| Justin| 3500|
| Berta| 4000|
+-------+------+
>>> peopleDF.printSchema()
root
|-- name: string (nullable = true)
|-- salary: long (nullable = true)
然后我们可以将其保存为 Parquet 文件,保留模式信息:
>>> peopleDF.write.parquet("file:///tmp/people.parquet")
您可以检查目录内容以查看生成的 Parquet 文件:
$ ls -l /tmp/people.parquet/
-rw-r--r-- ... 0 Apr 30 15:06 _SUCCESS
-rw-r--r-- ... 634 Apr 30 15:06 part-00000-...-c000.snappy.parquet
为了测试和调试目的,您可以从 Python 集合创建 Parquet 文件:
>>> tuples = [("alex", "Math", 97),
("jane", "Econ", 82),
("jane", "Math", 99)]
>>> column_names = ["name", "subject", "grade"]
>>> df = spark.createDataFrame(tuples, column_names) 
>>> df.show()
+----+-------+-----+
|name|subject|grade|
+----+-------+-----+
|alex| Math| 97|
|jane| Econ| 82|
|jane| Math| 99|
+----+-------+-----+
>>> df.write.parquet("file:///tmp/parquet") 
将您的 Python 集合转换为 DataFrame。
将您的 DataFrame 保存为一组 Parquet 文件。
同样,您可以检查目录以查看创建的 Parquet 文件:
$ ls -1 /tmp/parquet
_SUCCESS
part-00000-...-c000.snappy.parquet
part-00002-...-c000.snappy.parquet
part-00005-...-c000.snappy.parquet
part-00007-...-c000.snappy.parquet
读取 Parquet 文件
在这一节中,使用 PySpark,我们将读取刚刚创建的 Parquet 文件。注意,Parquet 文件是自描述的,因此模式得以保留。加载 Parquet 文件的结果是一个 DataFrame:
>>> input_path = "file:///tmp/people.parquet"
>>> parquetFile = spark.read.parquet(input_path)
>>> parquetFile.show()
+-------+------+
| name|salary|
+-------+------+
|Michael| 3000|
| Andy| 4500|
| Justin| 3500|
| Berta| 4000|
+-------+------+
>>> parquetFile.printSchema()
root
|-- name: string (nullable = true)
|-- salary: long (nullable = true)
Parquet 文件也可以用来创建临时视图,然后在 SQL 语句中使用:
>>> parquetFile.createOrReplaceTempView("parquet_table") 
>>> query = "SELECT name, salary FROM parquet_table WHERE salary > 3800"
>>> filtered = spark.sql(query)
>>> filtered.show()
+-----+------+
| name|salary|
+-----+------+
| Andy| 4500|
|Berta| 4000|
+-----+------+
parquet_table 充当关系表。
Parquet 支持集合数据类型,包括数组类型。以下示例读取使用数组的 Parquet 文件:
>>> parquet_file = "examples/src/main/resources/users.parquet"
>>> usersDF = spark.read.parquet(parquet_file)
>>> users.show()
+------+--------------+----------------+
| name|favorite_color|favorite_numbers|
+------+--------------+----------------+
|Alyssa| null| [3, 9, 15, 20]|
| Ben| red| []|
+------+--------------+----------------+
>>> usersDF.printSchema()
root
|-- name: string (nullable = true)
|-- favorite_color: string (nullable = true)
|-- favorite_numbers: array (nullable = true)
| |-- element: integer (containsNull = true)
读取和写入 Avro 文件
Apache Avro 是一种语言中立的数据序列化系统。它将数据定义存储为 JSON 格式,易于读取和解释,而数据本身则以紧凑高效的二进制格式存储。Avro 文件包含标记,可用于将大型数据集分割成适合 MapReduce 处理的子集。Avro 是一种非常快速的序列化格式。
读取 Avro 文件
使用 PySpark,我们可以读取 Avro 文件并按如下方式创建一个关联的 DataFrame:
$ pyspark --packages org.apache.spark:spark-avro_2.11:2.4.0 
SparkSession available as *spark*.
>>> path = "/book/code/chap08/twitter.avro"
>>> df = spark.read.format("avro").load(path) 
>>> df.show(truncate=False)
+----------+-----------------------------------+----------+
|username |tweet |timestamp |
+----------+-----------------------------------+----------+
|miguno |Rock: Nerf paper, scissors is fine.|1366150681|
|BlizzardCS|Works as intended. Terran is IMBA.|1366154481|
+----------+-----------------------------------+----------+
要读取/写入 Avro 文件,您必须导入所需的 Avro 库;spark-avro 模块是外部的,不包含在 spark-submit 或 pyspark 的默认设置中。
读取 Avro 文件并创建一个 DataFrame。
写入 Avro 文件
从 DataFrame 创建 Avro 文件同样简单。这里,我们将使用上一节创建的 DataFrame,将其保存为 Avro 文件,然后再将其读回作为 DataFrame:
$ pyspark --packages org.apache.spark:spark-avro_2.11:2.4.0 
SparkSession available as *spark*.
>>># df : DataFrame (created in previous section)
>>> output_path = "/tmp/avro/mytweets.avro"
>>> df.select("username", "tweet")
.write.format("avro")
.save(output_path) 
>>> df2 = spark.read.format("avro").load(outputPath) 
>>> df2.show(truncate=False)
+----------+-----------------------------------+
|username |tweet |
+----------+-----------------------------------+
|miguno |Rock: Nerf paper, scissors is fine.|
|BlizzardCS|Works as intended. Terran is IMBA.|
+----------+-----------------------------------+
导入所需的 Avro 库。
创建一个 Avro 文件。
从新的 Avro 文件创建一个 DataFrame。
从 MS SQL Server 读取和写入
微软 SQL Server 是微软的关系型数据库管理系统,设计和构建用于将信息以记录形式存储在关系表中。
写入 MS SQL Server
以下示例展示了如何将 DataFrame (df) 写入新的 SQL Server 表中:
# define database URL
server_name = "jdbc:sqlserver://{SERVER_ADDRESS}"
database_name = "my_database_name"
url = server_name + ";" + "databaseName=" + database_name + ";"
# define table name and username/password
table_name = "my_table_name"
username = "my_username"
password = "my_password"
try:
df.write \
.format("com.microsoft.sqlserver.jdbc.spark") \ 
.mode("overwrite") \ 
.option("url", url) \
.option("dbtable", table_name) \
.option("user", username) \
.option("password", password) \
.save()
except ValueError as error :
print("Connector write failed", error)
包含此类的 JAR 文件必须在你的 CLASSPATH 中。
默认情况下,overwrite 模式会先删除数据库中已存在的表。
要将 DataFrame 的行追加到现有表中,只需用 mode("append") 替换 mode("overwrite")。
注意,Spark 的 MS SQL 连接器在执行数据库的批量插入时,默认使用 READ_COMMITTED 隔离级别。如果希望覆盖隔离级别,请使用此 mssqlIsolationLevel 选项,如下所示:
.option("mssqlIsolationLevel", "READ_UNCOMMITTED")
从 MS SQL Server 读取
要从现有的 SQL Server 表中读取,可以使用以下代码片段作为模板:
jdbc_df = spark.read \
.format("com.microsoft.sqlserver.jdbc.spark") \
.option("url", url) \
.option("dbtable", table_name) \
.option("user", username) \
.option("password", password)\
.load()
读取图像文件
Spark 2.4.0.+ 能够读取二进制数据,在许多机器学习应用中非常有用(如人脸识别和逻辑回归)。Spark 可以从目录中加载图像文件到 DataFrame,通过 Java 库中的 ImageIO 将压缩图像(.jpg, .png 等)转换为原始图像表示。加载的 DataFrame 有一个 StructType 列 "image",包含存储为图像模式的图像数据。
从图像创建一个 DataFrame。
假设我们在一个目录中有以下图像:
$ ls -l chap07/images
-rw-r--r--@ ... 27295 Feb 3 10:55 cat1.jpg
-rw-r--r--@ ... 35914 Feb 3 10:55 cat2.jpg
-rw-r--r--@ ... 26354 Feb 3 10:55 cat3.jpg
-rw-r--r--@ ... 30432 Feb 3 10:55 cat4.jpg
-rw-r--r--@ ... 6641 Feb 3 10:53 duck1.jpg
-rw-r--r--@ ... 11621 Feb 3 10:54 duck2.jpg
-rw-r--r--@ ... 13 Feb 3 10:55 not-image.txt 
不是图像
我们可以将所有图像加载到 DataFrame 中,并忽略任何不是图像的文件,如下所示:
>>> images_path = '/book/code/chap07/images'
>>> df = spark.read
.format("image") 
.option("dropInvalid", "true") 
.load(images_path) 
>>> df.count()
6
format 必须是 "image"。
删除/忽略非图像文件。
加载图像并创建 DataFrame。
让我们检查 DataFrame 的模式:
>>> df.printSchema()
root
|-- image: struct (nullable = true)
| |-- origin: string (nullable = true) 
| |-- height: integer (nullable = true) 
| |-- width: integer (nullable = true) 
| |-- nChannels: integer (nullable = true) 
| |-- mode: integer (nullable = true) 
| |-- data: binary (nullable = true) 
图像的文件路径
图像的高度
图像的宽度
图像通道数
兼容 OpenCV 的类型
图像字节
现在,让我们来看看创建的图像 DataFrame 中的一些列:
>>> df.select("image.origin", "image.width", "image.height")
.show(truncate=False)
+--------------------------+-----+------+
|origin |width|height|
+--------------------------+-----+------+
|file:///book/.../cat2.jpg |300 |311 |
|file:///book/.../cat4.jpg |199 |313 |
|file:///book/.../cat1.jpg |300 |200 |
|file:///book/.../cat3.jpg |300 |296 |
|file:///book/.../duck2.jpg|275 |183 |
|file:///book/.../duck1.jpg|227 |222 |
+--------------------------+-----+------+
摘要
总结一下:
-
读写数据是数据算法的一个重要部分。根据项目和数据需求,应谨慎选择数据源。
-
Spark DataSource API 提供了一个可插拔的机制,用于通过 Spark SQL 访问结构化数据。数据源不仅可以是简单的管道,用于转换数据并将其拉入 Spark。
-
Spark SQL 支持从现有的关系型数据库表、Apache Hive 表、列式存储格式如 Parquet 和 ORC,以及行式存储格式如 Avro 中读取数据。Spark 提供了一个简单的 API 用于与所有 JDBC 兼容的关系型数据库、Amazon S3、HDFS 等集成。您还可以轻松地从文本、CSV 和 JSON 文件等外部数据源中读取和保存数据。
第八章:排名算法
本章介绍以下两种排名算法,并在 PySpark 中提供它们的相关实现:
排名产品
此算法在所有项目(如基因)中查找排名。它最初是为检测复制的微阵列实验中不同表达基因而开发的,但目前已被广泛接受,并且现在更广泛地用于包括机器学习在内的多个领域。Spark 不提供排名产品的 API,因此我将提供一个定制的解决方案。
PageRank
PageRank 是一种用于衡量给定图中节点重要性的迭代算法。这个算法被搜索引擎(如 Google)广泛使用,以找出每个网页(文档)相对于所有网页(一组文档)的重要性。简而言之,给定一组网页,PageRank 算法计算每个页面的质量排名。Spark API 为 PageRank 算法提供了多种解决方案。我将介绍其中一种,使用 GraphFrames API,以及两种定制解决方案。
排名产品
排名产品是生物信息学领域常用的算法,也称为计算生物学。最初开发为检测复制的微阵列实验中不同表达基因的生物学动机测试。除了表达谱分析外,它还可以用于其他应用领域中的排名列表组合,例如统计元分析和一般特征选择。在生物信息学和机器学习中,排名产品已经成为一种简单直观但功能强大的排名方法。
该算法不使用任何统计数据(如均值或方差),而是根据它们在多个比较中的排名对项目(如基因)进行评分。如果你只有非常少的复制品(在基因分析的背景下)或者想要分析两项研究结果的一致性,这一点尤为有用。
排名产品算法基于这样的假设:在零假设下,假设所有项目的顺序是随机的,找到特定项目在列表前r个中的概率(p)是:
将这些概率相乘得到排名产品的定义:
其中r[i]是第i个列表中项目的排名,n[i]是第i个列表中项目的总数。RP值越小,观察到项目在列表顶部的位置不是偶然的概率越小。排名产品等同于计算几何平均排名;用总和替换乘积导致了稍微对异常数据更敏感的统计量(平均排名),并且更加看重各个列表中排名的一致性。
注意
这是一个大数据问题吗?考虑 100 个研究,每个研究有 1,000,000 个测定,每个测定有 60,000 条记录。这转换为 100 × 1,000,000 × 60,000 = 6,000,000,000,000 条记录,这绝对是大数据。
计算排名产品
给定 n 个基因和 k 个重复试验,令 e[g,i] 为倍数变化,r[g,i] 为基因 g 在第 i 个重复试验中的排名。
通过几何平均计算排名产品(RP):
或者
正式化排名产品
为了帮助您理解排名产品算法,我将提供一个具体的例子。让 {A[1], …, A[k]} 是(键,值)对数据集,其中每个数据集的键是唯一的。例如,键可以是一个物品,一个用户或一个基因,而值可以是出售的物品数量,该用户的朋友数量或基因值,如倍数变化或测试表达。根据(通常基于数据集的排序值)分配排名,并且根据所有数据集中键 i 的排名 r[i] 计算 {A[1], …, A[k]} 的排名产品。
让我们通过一个非常简单的例子来使用三个数据集 A[1], A[2], A[3]。假设数据集 A[1] 由以下(键,值)对组成:
A1 = { (K1, 30), (K2, 60), (K3, 10), (K4, 80) }
如果我们基于键的降序排序值分配排名,我们得到:
Rank(A1) = { (K1, 3), (K2, 2), (K3, 4), (K4, 1) }
因为 80 > 60 > 30 > 10。注意,1 是最高的排名(分配给最大的值)。然后我们为数据集 A[2] 做同样的操作,该数据集的内容如下:
A2 = { (K1, 90), (K2, 70), (K3, 40), (K4, 50) }
这给了我们:
Rank(A2) = { (K1, 1), (K2, 2), (K3, 4), (K4, 3) }
因为 90 > 70 > 50 > 40。最后,数据集 A[3] 如下所示:
A3 = { (K1, 4), (K2, 8) }
在这种情况下,分配排名会给我们带来:
Rank(A3) = { (K1, 2), (K2, 1) }
因为 8 > 4。然后 {A[1], A[2], A[3]} 的排名产品表示为:
排名 产品 示例
现在让我们通过一个实际例子来演示使用排名产品:
-
让
S = {S[1], S[2], …, S[k]}是一个包含k个研究的集合,其中k > 0,每个研究代表一个微阵列实验。 -
让
S[i] (i=1, 2, …, k)是一个研究,其中有一个由{A[i1], A[i2], …}标识的任意数量的检测。 -
让每个检测(可以表示为文本文件)是一个任意数量记录的集合,格式如下:
<gene_id><,><gene_value_as_double_data_type> -
让
gene_id在{g[1], g[2], …, g[n]}中(我们有n个基因)。
要找到所有研究的排名积,首先我们找到每个基因每个研究的值的均值,然后对每个研究的基因按值排序并分配排名。例如,假设我们的第一项研究有三个检测,其值显示在表 8-1 中。
表 8-1. 研究 1 的基因值
| 检测 1 | 检测 2 | 检测 3 |
|---|---|---|
g1,1.0 |
g1,2.0 |
g1,12.0 |
g2,3.0 |
g2,5.0 |
null |
g3,4.0 |
null |
g3,2.0 |
g4,1.0 |
g4,3.0 |
g4,15.0 |
第一步是找到每个基因的平均值(每个研究)。这给了我们:
g1, 5.0
g2, 4.0
g3, 2.0
g4, 8.0
按值排序将产生以下结果:
g4, 8.0
g1, 5.0
g2, 4.0
g3, 2.0
接下来,我们为该研究中的每个基因分配一个排名,基于排序后的值。在这种情况下,结果将如下所示(最后一列是排名):
g4, 8.0, 1
g1, 5.0, 2
g2, 4.0, 3
g3, 2.0, 4
我们重复这个过程,为所有研究找到每个基因的排名积(RP)。如果:
...
然后,基因 g[j] 的排名积可以表示为:
或:
现在,让我们深入研究一个使用 PySpark 的解决方案。
PySpark 解决方案
如前所述,Spark 并没有提供排名积算法的 API,因此我开发了自己的解决方案。
注意
一部分展示了使用 Java API for Spark 解决排名积问题的网络直播,可以在O’Reilly 网站上找到,相关的 Java Spark 代码在GitHub上找到。
此处介绍的 PySpark 解决方案将接受K个输入路径(继续使用前面的示例,每个路径代表一个研究,可以有任意数量的测定文件)。在高层次上,以下是我们将用来查找在这些研究中出现的每个基因的排名产品的步骤:
-
找到每项研究中每个基因的平均值(在某些情况下,您可能更喜欢应用其他函数来找到中位数)。我们将使用 COPA 分数作为我们的值。¹
-
根据每项研究中的值对基因进行排序,然后分配排名值(排名值将是
{1, 2, …, *N*},其中1分配给最高值,N分配给最低值)。 -
最后,计算所有研究的每个基因的排名产品。这可以通过按键分组所有排名来完成。
要实现最终步骤,我们可以使用RDD.groupByKey()或RDD.combineByKey()。这两种解决方案都在GitHub上可用,标记为rank_product_using_groupbykey.py和rank_product_using_combinebykey.py。
请注意,使用combineByKey()的 PySpark 解决方案比使用groupByKey()解决方案更有效。如第四章中讨论的,这是因为combineByKey()中间值在发送给最终减少之前由本地工作人员减少(或合并),而使用groupByKey()则没有本地减少;所有值都发送到一个位置进行进一步处理。我只会在这里详细介绍使用combineByKey()的解决方案。
输入数据格式
每个测定(可以表示为文本文件)是以下格式中任意数量记录的集合:
<gene_id><,><gene_value_as_double_data_type>
其中gene_id是一个关键字,具有类型为Double的相关值。
为了演示目的,其中K=3(研究数量),我将使用以下示例输入:
$ cat /tmp/rankproduct/input/rp1.txt
K_1,30.0
K_2,60.0
K_3,10.0
K_4,80.0
$ cat /tmp/rankproduct/input/rp2.txt
K_1,90.0
K_2,70.0
K_3,40.0
K_4,50.0
$ cat /tmp/rankproduct/input/rp3.txt
K_1,4.0
K_2,8.0
输出数据格式
我们将以以下格式生成输出:
<gene_id><,><R><,><N>
这里的<R>是所有输入数据集中的排名产品,<N>是参与计算排名产品的值的数量。
使用combineByKey()计算排名产品解决方案
完整的解决方案在程序rank_product_using_combinebykey.py中展示。它需要以下输入/输出参数:
# define input/output parameters:
# sys.argv[1] = output path
# sys.argv[2] = number of studies (K)
# sys.argv[3] = input path for study 1
# sys.argv[4] = input path for study 2
# ...
# sys.argv[K+2] = input path for study K
要实现使用combineByKey()转换的 PySpark 排名产品问题的解决方案,我使用了以下驱动程序,该程序调用了几个 Python 函数:
# Create an instance of SparkSession
spark = SparkSession.builder.getOrCreate()
# Handle input parameters
output_path = sys.argv[1]
# K = number of studies to process
K = int(sys.argv[2])
# Define studies_input_path
studies_input_path = [sys.argv[i+3] for i in range(K)]
# Step 1: Compute the mean per gene per study
means = [compute_mean(studies_input_path[i]) for i in range(K)]
# Step 2: Compute the rank of each gene per study
ranks = [assign_rank(means[i]) for i in range(K)]
# Step 3: Calculate the rank product for each gene
# rank_products: RDD[(gene_id, (ranked_product, N))]
rank_products = compute_rank_products(ranks)
# Step 4: Save the result
rank_products.saveAsTextFile(output_path)
让我们更详细地看看三个主要步骤。
第 1 步:计算每项研究中每个基因的平均值
要找到数据集的排名产品,我们首先需要找到每个基因在每项研究中的平均值。这可以通过compute_mean()函数完成。使用combineByKey()转换来计算键(gene_id)的值的平均值,我们可以创建一个组合数据类型(Double, Integer),表示(值的总和,值的数量)。最后,为了找到平均值,我们将值的总和除以值的数量:
# Compute mean per gene for a single study = set of assays
# @param input_Path set of assay paths separated by ","
# @RETURN RDD[(String, Double)]
def compute_mean(input_path):
# genes as string records: RDD[String]
raw_genes = spark.sparkContext.textFile(input_path)
# create RDD[(String, Double)]=RDD[(gene_id, test_expression)]
genes = raw_genes.map(create_pair)
# create RDD[(gene_id, (sum, count))]
genes_combined = genes.combineByKey(
lambda v: (v, 1), # createCombiner
lambda C, v: (C[0]+v, C[1]+1), # addAndCount
lambda C, D: (C[0]+D[0], C[1]+D[1]) # mergeCombiners
)
# now compute the mean per gene
genes_mean = genes_combined.mapValues(lambda p: float(p[0])/float(p[1]))
return genes_mean
#end-def
第 2 步:计算每项研究中每个基因的排名
要计算每个gene_id的排名,我们执行以下三个子步骤: . 根据 COPA 分数的绝对值排序值。为了按 COPA 分数排序,我们将键与值交换,然后按键排序。 . 为每个基因分配从 1(具有最高 COPA 分数的基因)到n(具有最低 COPA 分数的基因)的排名。 . 使用Math.power(R[1] * R[2] * … * R[n], 1/n)计算每个gene_id的排名。
该整个步骤由assign_rank()函数完成。通过使用RDD.zipWithIndex()为排名分配排名,该函数将此 RDD 与其元素索引进行压缩(这些索引将是排名)。Spark 索引从 0 开始,因此在计算排名乘积时我们加 1:
# @param rdd : RDD[(String, Double)]: (gene_id, mean)
# @returns: RDD[(String, Long)]: (gene_id, rank)
def assign_rank(rdd):
# Swap key and value (will be used for sorting by key)
# Convert value to abs(value)
swapped_rdd = rdd.map(lambda v: (abs(v[1]), v[0]))
# Sort COPA scores in descending order. We need 1 partition so
# that we can zip numbers into this RDD with zipWithIndex().
# If we do not use 1 partition, then indexes will be meaningless.
# sorted_rdd : RDD[(Double,String)]
sorted_rdd = swapped_rdd.sortByKey(False, 1)
# Use zipWithIndex(). Zip values will be 0, 1, 2, ...
# but for ranking we need 1, 2, 3, .... Therefore,
# we will add 1 when calculating the rank product.
# indexed: RDD[((Double,String), Long)]
indexed = sorted_rdd.zipWithIndex()
# add 1 to index to start with 1 rather than 0
# ranked: RDD[(String, Long)]
ranked = indexed.map(lambda v: (v[0][1], v[1]+1))
return ranked
#end-def
第 3 步:计算每个基因的排名乘积
最后,我们调用compute_rank_products()来计算每个基因的排名乘积,该方法将所有排名组合成一个 RDD,然后使用combineByKey()转换来计算每个基因的排名乘积:
# return RDD[(String, (Double, Integer))] = (gene_id, (ranked_product, N))
# where N is the number of elements for computing the rank product
# @param ranks: array of RDD[(String, Long)]
def compute_rank_products(ranks):
# combine all ranks into one
union_rdd = spark.sparkContext.union(ranks)
# next, find unique keys with their associated COPA scores
# we need 3 basic function to be able to use combinebyKey()
# combined_by_gene: RDD[(String, (Double, Integer))]
combined_by_gene = union_rdd.combineByKey(
lambda v: (v, 1), # createCombiner as C
lambda C, v: (C[0]*v, C[1]+1), # multiplyAndCount
lambda C, D: (C[0]*D[0], C[1]+D[1]) # mergeCombiners
)
# next calculate rank products and the number of elements
rank_products = combined_by_gene.mapValues(
lambda v : (pow(float(v[0]), float(v[1])), v[1])
)
return rank_products
#end-def
让我们通过使用combineByKey()进行一个示例运行:
INPUT1=/tmp/rankproduct/input/rp1.txt
INPUT2=/tmp/rankproduct/input/rp2.txt
INPUT3=/tmp/rankproduct/input/rp3.txt
OUTPUT=/tmp/rankproduct/output
PROG=rank_product_using_combinebykey.py
./bin/spark-submit $PROG $OUTPUT 3 $INPUT1 $INPUT2 $INPUT3
output_path=/tmp/rankproduct/output
K=3
studies_input_path ['/tmp/rankproduct/input/rp1.txt',
'/tmp/rankproduct/input/rp2.txt',
'/tmp/rankproduct/input/rp3.txt']
input_path /tmp/rankproduct/input/rp1.txt
raw_genes ['K_1,30.0', 'K_2,60.0', 'K_3,10.0', 'K_4,80.0']
genes [('K_1', 30.0), ('K_2', 60.0), ('K_3', 10.0), ('K_4', 80.0)]
genes_combined [('K_2', (60.0, 1)), ('K_3', (10.0, 1)),
('K_1', (30.0, 1)), ('K_4', (80.0, 1))]
input_path /tmp/rankproduct/input/rp2.txt
raw_genes ['K_1,90.0', 'K_2,70.0', 'K_3,40.0', 'K_4,50.0']
genes [('K_1', 90.0), ('K_2', 70.0), ('K_3', 40.0), ('K_4', 50.0)]
genes_combined [('K_2', (70.0, 1)), ('K_3', (40.0, 1)),
('K_1', (90.0, 1)), ('K_4', (50.0, 1))]
input_path /tmp/rankproduct/input/rp3.txt
raw_genes ['K_1,4.0', 'K_2,8.0']
genes [('K_1', 4.0), ('K_2', 8.0)]
genes_combined [('K_2', (8.0, 1)), ('K_1', (4.0, 1))]
sorted_rdd [(80.0, 'K_4'), (60.0, 'K_2'), (30.0, 'K_1'), (10.0, 'K_3')]
indexed [((80.0, 'K_4'), 0), ((60.0, 'K_2'), 1),
((30.0, 'K_1'), 2), ((10.0, 'K_3'), 3)]
ranked [('K_4', 1), ('K_2', 2), ('K_1', 3), ('K_3', 4)]
sorted_rdd [(90.0, 'K_1'), (70.0, 'K_2'), (50.0, 'K_4'), (40.0, 'K_3')]
indexed [((90.0, 'K_1'), 0), ((70.0, 'K_2'), 1),
((50.0, 'K_4'), 2), ((40.0, 'K_3'), 3)]
ranked [('K_1', 1), ('K_2', 2), ('K_4', 3), ('K_3', 4)]
sorted_rdd [(8.0, 'K_2'), (4.0, 'K_1')]
indexed [((8.0, 'K_2'), 0), ((4.0, 'K_1'), 1)]
ranked [('K_2', 1), ('K_1', 2)]
这是每个键的最终输出:
$ cat /rankproduct/output/part*
(K_2,(1.5874010519681994, 3))
(K_1,(1.8171205928321397, 3))
(K_4,(1.7320508075688772, 2))
(K_3,(4.0, 2))
使用groupByKey()进行排名乘积解决方案
GitHub 上提供了一个使用groupByKey()转换而不是combineByKey()的排名乘积解决方案,命名为rank_product_using_groupbykey.py。总体来说,由于这两种转换实现了 shuffle 步骤的方式不同,combineByKey()解决方案更具可扩展性:combineByKey()尽可能使用组合器,但groupByKey()将所有值传输到一个位置,然后应用所需的算法。
PageRank
在本节中,我们将注意力转向另一个排名算法:PageRank。该算法使 Google 脱颖而出,成为其他搜索引擎的重要组成部分,至今仍不可或缺,因为它允许它们确定用户可能希望查看的页面,从而确定页面与其他页面的相关性或重要性。PageRank 算法的扩展也用于打击垃圾邮件。
注意
有关 PageRank 如何在底层工作的详细信息(或者至少是以前的工作方式),请参阅 Ian Rogers 的文章“理解 Google Page Rank”。
PageRank 算法衡量图中每个节点的重要性(例如互联网上的网页),假设从节点u到节点v的边表示u对v重要性的认可。其主要前提是,如果其他重要节点指向某节点,则该节点重要。例如,如果一个 Twitter 用户被许多其他用户关注,特别是那些拥有大量粉丝的用户,则该用户的排名将很高。任何希望提高其网站搜索引擎排名的网页设计师都应该花时间充分理解 PageRank 的工作原理。(请注意,PageRank 纯粹是一种链接分析算法,与页面的语言、内容或大小无关。)
图 8-1 说明了使用简单图表示一组链接文档的 PageRank 概念。

图 8-1. PageRank 示例
请注意,页面 C 的 PageRank 比页面 E 高,即使 C 的链接较少;这是因为指向 C 的一个链接来自非常重要的页面 B(因此具有高价值)。没有阻尼的话,所有的网络冲浪者最终都会进入页面 B 或 C(因为它们具有最高的 PageRank 分数),而所有其他页面的 PageRank 分数接近零。
PageRank 算法是一种迭代且收敛的算法。它从第一步开始,将一些初始 PageRank(作为Double数据类型)分配给所有页面,然后迭代应用该算法,直到到达称为收敛点的稳定状态。这是一个页面已分配了 PageRank,并且算法的后续迭代将在分布中产生很少或没有进一步的变化的点(您可以为此指定阈值)。
让我们看看算法是如何定义的。在这里,我们假设页面 A 被页面{T[1],…,T[n]}所指向(引用)。然后,页面A的 PageRank(PR)定义如下:
其中d是可以设置在 0 和 1 之间的阻尼因子(通常值为 0.85),PR(T[i])是链接到页面 A 的页面T[i]的 PageRank,L(T[i])是页面T[i]上的出站链接数。注意,PageRanks 形成对网页的概率分布,因此所有网页的 PageRanks 之和将为 1。
注
为什么添加阻尼因子?PageRank 基于随机冲浪者模型。本质上,阻尼因子是一个衰减因子。它表示用户停止点击链接并请求另一个随机页面的机会(例如,直接键入新的 URL 而不是跟随当前页面上的链接)。阻尼因子为 0.85 表示我们假设典型用户不会在页面上跟随任何链接的概率约为 15%,而是会导航到一个新的随机 URL。
给定具有传入和传出链接的网页图,PageRank 算法可以告诉我们每个节点的重要性或相关性。每个页面的 PageRank 取决于指向它的页面的 PageRank。简而言之,PageRank 是所有其他网页关于一个页面重要性的“投票”。
PageRank 的迭代计算
给定一组N个网页,PageRank 可以通过迭代或代数方法计算。迭代方法可以定义如下:
-
在(
t = 0)时,假定一个初始概率分布通常是: -
在每个时间步骤,如上所述的计算结果如下:
据信 Google(以及其他搜索引擎)在每次爬网并重建其搜索索引时会重新计算 PageRank 分数。随着收藏中文档数量的增加,对所有文档的 PageRank 的初始近似精度会降低。因此,PageRank 算法通过入站链接的数量和质量对网站进行排名。入站链接的质量定义为提供链接的站点的 PageRank 函数。
注意
请注意,这只是对原始 PageRank 算法的极其简化的描述。Google(以及其他使用类似 PageRank 算法的公司)考虑许多其他因素,如关键词密度、流量、域名年龄等,以计算网页的 PageRank。
接下来,我将向您展示如何计算 PageRank。假设我们有以下简单的图:
$ cat simple_graph.txt
A B
B A
A D
D A
设定d = 0.85,我们可以写成:
PR(A) = (1-d) + d (PR(B)/L(B) + PR(D)/L(D))
PR(B) = (1-d) + d (PR(A)/L(A))
PR(D) = (1-d) + d (PR(A)/L(A))
where:
L(A) = 2
L(B) = 1
L(D) = 1
要逐步计算这些PR(),我们需要初始化PR(A)、PR(B)、PR(D)。我们将它们全部初始化为1.0,然后迭代计算PR(A)、PR(B)、PR(D),直到这些值不再改变(即收敛)。结果显示在表 8-2 中。
表 8-2. 初始值为 1.00 的 PageRank 迭代
| 迭代次数 | PR(A) | PR(B) | PR(D) |
|---|---|---|---|
| 0 | 1.0000 | 1.0000 | 1.0000 |
| 1 | 1.8500 | 0.9362 | 0.9362 |
| … | … | … | … |
| 99 | 1.4595 | 0.7703 | 0.7703 |
| 100 | 1.4595 | 0.7703 | 0.7703 |
请注意,无论您使用什么作为初始值,PageRank 算法都会收敛,并且您将获得期望的结果。表 8-3 显示了使用初始值 40.00 的结果。
表 8-3. 初始值为 40.00 的 PageRank 迭代
| 迭代次数 | PR(A) | PR(B) | PR(D) |
|---|---|---|---|
| 0 | 40.0000 | 40.0000 | 40.0000 |
| 1 | 68.1500 | 29.1138 | 29.1137 |
| … | … | … | … |
| 99 | 1.4595 | 0.7703 | 0.7703 |
| 100 | 1.4595 | 0.7703 | 0.7703 |
在这两种情况下,100 次迭代后的结果是相同的:
PR(A) = 1.4595
PR(B) = 0.7703
PR(D) = 0.7703
正如您在第六章中看到的那样,Spark 提供了通过 GraphX 和 GraphFrames 库实现 PageRank 算法的 API。为了帮助您理解算法的工作原理,我将介绍几种在 PySpark 中实现的自定义 PageRank 解决方案。
使用 RDDs 在 PySpark 中进行自定义 PageRank
我将从提供一个简单的自定义解决方案开始,使用 PySpark。完整的程序和示例输入数据可在本书的 GitHub 存储库中找到,文件为pagerank.py和pagerank_data.txt。
该解决方案使用 Spark RDDs 实现 PageRank 算法。它不使用 GraphX 或 GraphFrames,但稍后我会展示一个 GraphFrames 的例子。
输入数据格式
假设我们的输入具有以下语法:
<source-URL-ID><,><neighbor-URL-ID>
输出数据格式
PageRank 算法的目标是生成以下形式的输出:
<URL-ID> <page-rank-value>
如果算法运行 15 次迭代,结果将如下所示:
$ spark-submit pagerank.py pagerank_data.txt 15
1 has rank: 0.86013842528.
3 has rank: 0.33174213968.
2 has rank: 0.33174213968.
5 has rank: 0.473769824736.
4 has rank: 0.33174213968.
PySpark 解决方案
我们的自定义解决方案涉及以下步骤:
-
读取输入路径和迭代次数:
input_path = sys.argv[1] num_of_iterations = int(sys.argv[2]) -
创建
SparkSession的实例:spark = SparkSession.builder.getOrCreate() -
从输入路径创建一个
RDD[String]:records = spark.sparkContext.textFile(input_path) -
从输入文件加载所有 URL 并初始化它们的相邻节点:
def create_pair(record_of_urls): # record_of_urls = "<source-URL><,><neighbor-URL>" tokens = record_of_urls.split(",") source_URL = tokens[0] neighbor_URL = tokens[1] return (source_URL, neighbor_URL) #end-def links = records.map(lambda rec: create_pair(rec))  .distinct()  .groupByKey()  .cache() 创建一对
(source_URL,neighbor_URL)。确保没有重复的对。
查找所有相邻的 URL。
缓存结果,因为在迭代中会多次使用它。
-
将 URL 相邻节点转换为 1.0 的排名:
ranks = links.map(lambda url_neighbors: (url_neighbors[0], 1.0)) ranks是一个RDD[(String, Float)]。 -
计算并迭代更新 URL 排名。要执行此步骤,我们需要两个基本函数:
def recalculate_rank(rank): new_rank = (rank * 0.85) + 0.15 return new_rank #end-def def compute_contributions(urls_rank): # calculates URL contributions # to the ranks of other URLs urls = urls_rank[1][0] rank = urls_rank[1][1] num_urls = len(urls) for url in urls: yield (url, rank / num_urls) #end-def现在,让我们执行迭代:
for iteration in range(num_of_iterations): # calculates URL contributions # to the ranks of other URLs contributions = links .join(ranks)  .flatMap(compute_contributions) # recalculates URL ranks based # on neighbor contributions ranks = contributions.reduceByKey(lambda x,y : x+y) .mapValues(recalculate_rank) #end-for请注意,
links.join(ranks)将创建形如[(URL_ID,(ResultIterable,<rank-as-float>)),…]的元素。 -
收集所有 URL 的 PageRank 值并将其转储到控制台:
for (link, rank) in ranks.collect(): print("%s has rank: %s." % (link, rank))
示例输出
为 20 次迭代提供了示例输出。你可以观察 PageRank 算法在更高的迭代中的收敛(迭代次数为 16 到 20):
iteration/node 1 2 3 4 5
0 1.00 1.00 1.00 1.00 null
1 2.27 0.36 0.36 0.36 0.79
2 0.92 0.63 0.63 0.63 0.79
...
19 0.86 0.33 0.33 0.33 0.47
20 0.85 0.33 0.33 0.33 0.47
使用 PySpark 在邻接矩阵中自定义 PageRank
本节介绍了另一种使用邻接矩阵作为输入的 PageRank 算法的自定义解决方案。邻接矩阵是用于表示有限图的矩阵。矩阵的元素指示图中是否相邻的节点。例如,如果节点A链接到其他三个节点(比如B、C和D),那么它的邻接矩阵行如下所示:
A B C D
假设我们有如下图所示的图图 8-2。

图 8-2。具有五个节点的简单有向图
此图的邻接矩阵如下(每行中的第一个项目是源节点,其他项目是目标节点):
A B C D
B C E
C A D E
D E
E B
对图或矩阵进行视觉检查建议E是一个重要节点,因为它被许多其他节点引用。因此,我们预计节点E的 PageRank 值将高于其他节点。
此解决方案再次使用 Spark RDD 来实现 PageRank 算法,并不使用 GraphX 或 GraphFrames。完整的程序和示例输入数据可在本书的 GitHub 存储库中的文件pagerank_2.py和pagerank_data_2.txt中找到。
输入数据格式
我们假设我们的输入具有以下语法,其中S是一个空格:
<source-node><S><target-node-1><S><target-node-2><S>...
where S is a single space
输出数据格式
PageRank 算法的目标是生成如下形式的输出:
<node> <page-rank-value>
PySpark 解决方案
我们的第二个 PySpark 解决方案包括三个主要步骤:
-
映射:对于每个节点
i,计算要分配给每个出链的值(排名 / 邻居节点数),并传播到相邻节点。 -
减少:对于每个节点
i,计算即将到来的投票/值的总和并更新排名(R[i])。 -
迭代:重复直到值收敛(稳定或在定义的边界内)。
这里呈现了完整的 PySpark 解决方案。首先,我们导入所需的库并读取输入参数:
from __future__ import print_function
import sys
from pyspark.sql import SparkSession
# Define your input path
input_path = sys.argv[1]
print("input_path: ", input_path)
# input_path: pagerank_data_2.txt
# Define number of iterations
ITERATIONS = int(sys.argv[2])
print("ITERATIONS: ", ITERATIONS)
# ITERATIONS: 40
接下来,我们读取矩阵并创建 (K, V) 对,其中 K 是源节点,V 是目标节点的列表:
# Create an instance of SparkSession
spark = SparkSession.builder.getOrCreate()
# Read adjacency list and create RDD[String]
matrix = spark.sparkContext.textFile(input_path)
print("matrix=", matrix.collect())
# matrix= ['A B C D', 'B C E', 'C A D E', 'D E', 'E B']
# x = "A B C"
# returns (A, [B, C])
def create_pair(x):
tokens = x.split(" ")
# tokens[0]: source node
# tokens[1:]: target nodes (links from the source node)
return (tokens[0], tokens[1:])
#end-def
# create links from source node to target nodes
links = matrix.map(create_pair)
print("links=", links.collect())
# links= [('A', ['B', 'C', 'D']),
# ('B', ['C', 'E']),
# ('C', ['A', 'D', 'E']),
# ('D', ['E']),
# ('E', ['B'])]
我们计数节点并初始化每个节点的排名为 1.0:
# Find node count
N = links.count()
print("node count N=", N)
# node count N=5
# Create and initialize the ranks
ranks = links.map(lambda node: (node[0], 1.0/N))
print("ranks=", ranks.collect())
# ranks= [('A', 0.2), ('B', 0.2), ('C', 0.2), ('D', 0.2), ('E', 0.2)]
然后,我们实现了 PageRank 算法的三个步骤:
for i in range(ITERATIONS):
# Join graph info with rank info, propagate rank scores
# to all neighbors (rank/(number of neighbors),
# and add up ranks from all incoming edges
ranks = links.join(ranks)\
.flatMap(lambda x : [(i, float(x[1][1])/len(x[1][0])) for i in x[1][0]])\
.reduceByKey(lambda x,y: x+y)
print(ranks.sortByKey().collect())
这里给出了部分输出:
[('A', 0.0667), ('B', 0.2667), ('C', 0.1667), ('D', 0.1334), ('E', 0.3667)]
[('A', 0.0556), ('B', 0.3889), ('C', 0.1556), ('D', 0.0778), ('E', 0.3223)]
...
[('A', 0.0638), ('B', 0.3404), ('C', 0.1915), ('D', 0.0851), ('E', 0.3191)]
[('A', 0.0638), ('B', 0.3404), ('C', 0.1915), ('D', 0.0851), ('E', 0.3191)]
PageRank 算法表明节点 E 是最重要的节点,因为其页面排名(0.3191)最高。我们还观察到,由于页面排名形成概率分布,所有节点的值的总和为 1:
PR(A) + PR(B) + PR(C) + PR(D) + PR(E) =
0.0638 + 0.3404 + 0.1915 + 0.0851 + 0.3191 =
0.9999
结束本节,我将快速通过一个使用 GraphFrames 的示例进行说明。
使用 GraphFrames 的 PageRank
下面的 PySpark 示例展示了如何使用 GraphFrames 包找到图的 PageRank,该包在 第六章 中介绍。有至少两种计算给定图的页面排名的方法:
容差
指定在收敛时允许的容差(请注意,较小的容差会导致 PageRank 结果的更高精度):
# build graph as a GraphFrame instance
graph = GraphFrame(vertices, edges)
#
# NOTE: You cannot specify maxIter()
# and tol() at the same time.
# damping factor = 1 - 0.15 = 0.85
# tol = the tolerance allowed at convergence
# (smaller => more accurate)
# pagerank is computed as a GraphFrame:
pagerank = graph
.pageRank()
.resetProbability(0.15)
.tol(0.0001)
.run()
最大迭代次数
指定算法可运行的最大迭代次数(迭代次数越多,精度越高):
# build graph as a GraphFrame instance
graph = GraphFrame(vertices, edges)
# NOTE: You cannot specify maxIter()
# and tol() at the same time.
# damping factor = 1 - 0.15 = 0.85
# maxIter = the max. number of iterations
# (higher => more accurate)
# pagerank is computed as a GraphFrame:
pagerank = graph
.pageRank()
.resetProbability(0.15)
.maxIter(30)
.run()
摘要
总结:
-
我们涵盖了两种排名算法,排名乘积(主要用于基因分析)和 PageRank(主要用于搜索引擎算法)。
-
Spark 不提供排名乘积的 API,但我提供了一个自定义的 PySpark 解决方案。
-
Spark 能够处理记录级算法以及图算法——除了使用 GraphFrames API 外,还提出了两种自定义的 PySpark 实现 PageRank 的方法。
在接下来的章节中,我们将关注一些实际的基础数据设计模式。
¹ 癌症异常基因剖析(COPA)是一种在基因分析中使用的异常检测方法。基因被分组成互斥的基因对,并根据它们在肿瘤样本中的异常值数量进行排名。
第三部分:数据设计模式
第九章:经典数据设计模式
本章讨论了在绝大多数大数据解决方案中使用的一些最基本和经典的数据设计模式。尽管这些是简单的设计模式,但它们在解决许多常见的数据问题中很有用,我在本书的示例中使用了许多这些模式。在本章中,我将介绍以下设计模式的 PySpark 实现:
-
输入-映射-输出
-
输入-过滤-输出
-
输入-映射-减少-输出
-
Input-Multiple-Maps-Reduce-Output
-
输入-映射-合并器-减少-输出
-
输入-映射分区-减少-输出
-
输入-反向索引-模式-输出
不过,在我们开始之前,我想解释一下“设计模式”的含义。在计算机科学和软件工程中,针对常见问题,设计模式是对该问题的可重用解决方案。它是解决问题的模板或最佳实践,而不是可以直接转换为代码的成品设计。本章介绍的模式将使您能够处理各种数据分析任务。
注意
本章讨论的数据设计模式是基本模式。您可以根据自己的需求创建自己的模式。有关更多示例,请参见 Jeffrey Dean 和 Sanjay Ghemawat 的《MapReduce: 简化大规模集群上的数据处理》(https://oreil.ly/jS7MV)。
输入-映射-输出
输入-映射-输出是数据分析的最简单设计模式:如图 9-1 所示,您从一组文件中读取输入,然后对每条记录应用一系列函数,最后生成所需的输出。映射器可以根据其输入创建任何内容,没有限制:它可以创建一组新记录或(键,值)对。

图 9-1. 输入-映射-输出设计模式
没有减少,但有时会使用映射阶段来清理和重新格式化数据。这是一种非常常见的设计模式,用于改变输入数据的格式并生成输出数据,其他映射器和减少器可以使用。
RDD 解决方案
有时映射阶段用于在生成供减少器使用的(键,值)对之前清理和重新格式化数据。
考虑这样的情况,输入记录具有可以包含诸如以下值的性别字段:
-
女性表示:
"0", "f", "F", "Female", "female" -
男性表示:
"1", "m", "M", "Male", "male"
如果要将性别字段标准化为{"female", "male", "unknown"},假设每条记录的格式如下:
<user_id><,><gender><,><address>
下面的函数可以促进map()转换,并将每条输入记录创建为(user_id, normalized_gender, address)的三元组:
# rec: an input record
def normalize_gender(rec):
tokens = rec.split(",")
user_id = tokens[0]
gender = tokens[1].lower()
if gender in ('0', 'f', 'female'):
normalized_gender = "female"
elif gender in ('1', 'm', 'male'):
normalized_gender = "male"
else:
normalized_gender = "unknown"
return (user_id, normalized_gender, tokens[2])
#end-def
给定源rdd作为RDD[String],则您的映射器转换将如下所示:
# source rdd : RDD[String]
# target rdd_mapped : RDD[(String, String, String)]
# RDD.map() is a 1-to-1 transformation
rdd_mapped = rdd.map(normalize_gender)
另一个场景可能是分析形式为<user_id><,><movie_id><,><rating>的电影评分记录,你的目标是为每条记录创建一个(<movie_id>, (<user_id>, <rating>)的键值对。 进一步假设所有评分将转换为整数。 你可以使用以下的映射器函数:
# rec: <user_id><,><movie_id><,><rating>
def create_pair(rec):
tokens = rec.split(",")
user_id = tokens[0]
movie_id = tokens[1]
rating = int(tokens[2])
return (movie_id, (user_id, rating))
#end-def
如果你想将单个输入记录/元素映射到多个目标元素,并适当地丢弃(过滤掉)记录/元素,该怎么办? Spark 提供了flatMap()转换来实现这一点;它在单个元素上工作(类似于map())并生成多个目标元素。 因此,如果你的输入是RDD[V],你想将每个V映射为一组类型为T的元素,你可以像下面这样使用flatMap():
# source_rdd: RDD[V]
# target_rdd: RDD[T]
target_rdd = source_rdd.flatMap(custom_map_function)
# v an element of source_rdd
def custom_map_function(v):
# t iterable<T>
t = *`<``use``-``v``-``to``-``create``-``an``-``iterable``-``of``-``T``-``data``-``type``-``elements``>`*
return t
#end-def
例如,如果对于输入记录v,你创建了t = [t1, t2, t3],那么v将映射到target_rdd的三个元素t1、t2和t3。 如果t=[]——一个空列表——那么target_rdd将不会创建任何元素:v会被过滤掉。
正如这个例子所示,如果你想同时映射和过滤,即映射一些记录并过滤其他记录,你也可以使用单个flatMap()转换来实现这一点。 例如,假设你有以下格式的记录:
<word1><,><word2><;><word1><,><word2><;>...<word1><,><word2>
你的目标是只保留由逗号分隔的两个单词组成的记录(即二元组),你想要丢弃(过滤掉)所有其他记录。
考虑这个源 RDD:
records = ['w1,w2;w3,w4', 'w9', 'w5,w6;w7,w8;w10,w11']
rdd = spark.sparkContext.parallelize(records)
现在,rdd有三个元素。 你想保留'w1,w2'、'w3,w4'、'w5,w6'、'w7,w8'和'w10,w11',但是丢弃'w9'(因为这不是一个二元组)。 以下的 PySpark 片段展示了如何实现这一点:
# map and filter
def map_and_filter(rec):
if ";" in rec:
bigrams = rec.split(";")
result = []
for bigram in bigrams:
words = bigram.split(",")
if len(words) == 2: result.append(bigram)
return result
else:
# no semicolon in rec
words = rec.split(",")
if len(words) == 2: return [rec]
else: return []
#end-def
# map and filter with flatMap()
mapped_and_filtered = rdd.flatMap(map_and_filter)
mapped_and_filtered.collect()
['w1,w2', 'w3,w4', 'w5,w6', 'w7,w8', 'w10,w11']
正如这个例子所示,你可以将要保留的记录映射为多个目标元素,并同时使用单个flatMap()转换过滤掉你不想保留的记录。
DataFrame 解决方案
Spark 有一个RDD.map()函数,但是 DataFrame 没有这个map()函数。 Spark 的 DataFrame 没有显式的map()函数,但我们可以通过多种方法实现等价的map():我们可以通过DataFrame.withColumn()添加新列,并通过DataFrame.drop()删除现有列。
考虑一个 DataFrame:
tuples3 = [ ('alex', 800, 8), ('jane', 420, 4),
('bob', 380, 5), ('betty', 700, 10),
('ted', 480, 10), ('mary', 500, 0) ]
>>> column_names = ["name", "weekly_pay", "overtime_hours"]
>>> df = spark.createDataFrame(tuples3, column_names)
>>> df.show(truncate=False)
+-----+----------+--------------+
|name |weekly_pay|overtime_hours|
+-----+----------+--------------+
|alex |800 |8 |
|jane |420 |4 |
|bob |380 |5 |
|betty|700 |10 |
|ted |480 |10 |
|mary |500 |0 |
+-----+----------+--------------+
假设我们想通过将overtime_hours加到weekly_pay来计算总周薪。 因此,我们希望基于overtime_hours和weekly_pay的值创建一个新列total weekly pay。 假设加班费率为每小时$20。
def compute_total_pay(weekly_pay, overtime_hours):
return (weekly_pay + (overtime_hours * 20))
#end-def
要保留所有列,请执行以下操作:
>>> df2 = df.rdd.map(lambda x: (x["name"], x["weekly_pay"], x["overtime_hours"],
compute_total_pay(x["weekly_pay"], x["overtime_hours"])))
.toDF(["name", "weekly_pay", "overtime_hours", "total_pay"])
>>> df2.show(truncate=False)
+-----+----------+--------------+---------+
|name |weekly_pay|overtime_hours|total_pay|
+-----+----------+--------------+---------+
|alex |800 |8 |960 |
|jane |420 |4 |500 |
|bob |380 |5 |480 |
|betty|700 |10 |900 |
|ted |480 |10 |680 |
|mary |500 |0 |500 |
+-----+----------+--------------+---------+
实质上,你必须将行映射到一个包含所有现有列并添加新列的元组。
如果你的列太多而无法枚举,你也可以只向现有行添加一个元组。
>>> df3 = df.rdd.map(lambda x: x + (str(compute_total_pay(x["weekly_pay"],
x["overtime_hours"])),)).toDF(df.columns + ["total_pay"])
>>> df3.show(truncate=False)
+-----+----------+--------------+---------+
|name |weekly_pay|overtime_hours|total_pay|
+-----+----------+--------------+---------+
|alex |800 |8 |960 |
|jane |420 |4 |500 |
|bob |380 |5 |480 |
|betty|700 |10 |900 |
|ted |480 |10 |680 |
|mary |500 |0 |500 |
+-----+----------+--------------+---------+
你也可以使用DataFrame.withColumn()添加一个total_pay列:
>>> import pyspark.sql.functions as F
>>> df4 = df.withColumn("total_pay",
F.lit(compute_total_pay(df.weekly_pay, df.overtime_hours)))
>>> df4.show(truncate=False)
+-----+----------+--------------+---------+
|name |weekly_pay|overtime_hours|total_pay|
+-----+----------+--------------+---------+
|alex |800 |8 |960 |
|jane |420 |4 |500 |
|bob |380 |5 |480 |
|betty|700 |10 |900 |
|ted |480 |10 |680 |
|mary |500 |0 |500 |
+-----+----------+--------------+---------+
Flat Mapper 功能
Spark 的 DataFrame 没有flatMap()转换(将一个元素展平为多个目标元素),而是提供了explode()函数,该函数返回给定列(表示为列表或字典)中每个元素的新行,并对数组中的元素使用默认列名col,对字典中的元素使用键和值,除非另有指定。
下面是一个完整的示例,展示如何使用explode()函数作为等效于RDD.flatMap()转换的方法。
让我们首先创建一个包含两列列表的 DataFrame。
接下来,我们来看如何针对给定的 DataFrame 展开多列。请注意,每个select子句只允许一个生成器:这意味着您不能同时展开两列(但可以逐个迭代地展开它们)。以下示例展示了如何展开两列:
>>> some_data = [
... ('alex', ['Java','Scala', 'Python'], ['MS', 'PHD']),
... ('jane', ['Cobol','Snobol'], ['BS', 'MS']),
... ('bob', ['C++'], ['BS', 'MS', 'PHD']),
... ('ted', [], ['BS', 'MS']),
... ('max', ['FORTRAN'], []),
... ('dan', [], [])
... ]
>>>
>>> df = spark.createDataFrame(data=some_data,
schema = ['name', 'languages', 'education'])
>>> df.show(truncate=False)
+----+---------------------+-------------+
|name|languages |education |
+----+---------------------+-------------+
|alex|[Java, Scala, Python]|[MS, PHD] |
|jane|[Cobol, Snobol] |[BS, MS] |
|bob |[C++] |[BS, MS, PHD]|
|ted |[] |[BS, MS] |
|max |[FORTRAN] |[] |
|dan |[] |[] |
+----+---------------------+-------------+
接下来我们来看一下languages列,这是一个数组:
>>> exploded_1 = df.select(df.name,
explode(df.languages).alias('language'), df.education)
>>> exploded_1.show(truncate=False)
+----+--------+-------------+
|name|language|education |
+----+--------+-------------+
|alex|Java |[MS, PHD] |
|alex|Scala |[MS, PHD] |
|alex|Python |[MS, PHD] |
|jane|Cobol |[BS, MS] |
|jane|Snobol |[BS, MS] |
|bob |C++ |[BS, MS, PHD]|
|max |FORTRAN |[] |
+----+--------+-------------+
如您所见,当展开一列时,如果某列是空列表,则该列会从展开结果中删除(tex 和 max 因具有关联的空列表而被删除)。请注意,ted 和 dan 被删除,因为展开的列值为空列表。
接下来,我们来看一下education列:
>>> exploded_2 = exploded_1.select(exploded_1.name, exploded_1.language,
explode(exploded_1.education).alias('degree'))
>>> exploded_2.show(truncate=False)
+----+--------+------+
|name|language|degree|
+----+--------+------+
|alex|Java | MS|
|alex|Java | PHD|
|alex|Scala | MS|
|alex|Scala | PHD|
|alex|Python | MS|
|alex|Python | PHD|
|jane|Cobol | BS|
|jane|Cobol | MS|
|jane|Snobol | BS|
|jane|Snobol | MS|
|bob |C++ | BS|
|bob |C++ | MS|
|bob |C++ | PHD|
+----+--------+------+
请注意,由于展开的列值为空列表,名称为max的列被删除。
输入-过滤-输出
图 9-2 中所示的输入-过滤-输出数据设计模式是一种简单的模式,它允许您保留满足数据要求的记录,同时移除不需要的记录。您可以从一组文件中读取输入,然后对每条记录应用一个或多个过滤函数,保留满足布尔谓词的记录并丢弃其他记录。

图 9-2. 输入-过滤-输出设计模式
这是一个在数据集大且您希望选择其中一部分数据进行关注和可能进行后续分析的情况下非常有用的设计模式。
一个简单的场景是读取由 URL 组成的输入记录,保留有效的 URL 并丢弃无效的 URL。这种设计模式可以通过 RDD 和 DataFrame 实现。
这里是一些示例记录:
http://cnn.com 
htp://mysite.com 
http://www.oreilly.com 
https:/www.oreilly.com 
有效 URL
无效 URL
有效 URL
无效 URL
RDD 解决方案
这种设计模式可以通过RDD.filter()函数轻松实现:
data = ['http://cnn.com', 'htp://mysite.com',
'http://www.oreilly.com', 'https:/www.oreilly.com' ]
urls = spark.sparkContext.parallelize(data)
# return True if a given URL is valid, otherwise return False
def is_valid_URL(url_as_str):
if url_as_str is None: return False
lowercased = url_as_str.lower()
if (lowercased.startswith('http://') or
lowercased.startswith('https://')):
return True
else:
return False
#end-def
# return a new RDD containing only the
# elements that satisfy a predicate
valid_urls = urls.filter(is_valid_URL)
valid_urls.collect()
[ 'http://cnn.com', 'http://www.oreilly.com' ]
DataFrame 解决方案
或者,您可以使用DataFrame.filter()函数来保留所需的记录并丢弃不需要的记录:
>>> data = [('http://cnn.com',), ('htp://mysite.com',),
('http://www.oreilly.com',), ('https:/www.oreilly.com',)]
# create a single-column DataFrame
>>> df = spark.createDataFrame(data, ['url'])
>>> df.show(truncate=False)
+----------------------+
|url |
+----------------------+
|http://cnn.com |
|htp://mysite.com |
|http://www.oreilly.com|
|https:/www.oreilly.com|
+----------------------+
# filter out undesired records
>>> df.filter(df.url.startswith('http://') |
df.url.startswith('https://'))
.show(truncate=False)
+----------------------+
|url |
+----------------------+
|http://cnn.com |
|http://www.oreilly.com|
+----------------------+
DataFrame 过滤
Spark 的 filter() 函数用于根据给定条件过滤 RDD/DataFrame 中的元素/行。对于 DataFrame,如果您来自 SQL 背景,也可以使用 where() 子句代替 filter() 函数。这两个函数(filter() 和 where())的操作完全相同。filter() 和 where() 的目标是保留所需的元素/行。
考虑一个 DataFrame 如下:
tuples3 = [ ('alex', 800, 8), ('jane', 420, 4),
('bob', 380, 5), ('betty', 700, 10),
('ted', 480, 10), ('mary', 500, 0) ]
>>> column_names = ["name", "weekly_pay", "overtime_hours"]
>>> df = spark.createDataFrame(tuples3, column_names)
>>> df.show(truncate=False)
+-----+----------+--------------+
|name |weekly_pay|overtime_hours|
+-----+----------+--------------+
|alex |800 |8 |
|jane |420 |4 |
|bob |380 |5 |
|betty|700 |10 |
|ted |480 |10 |
|mary |500 |0 |
+-----+----------+--------------+
假设我们想保留 weekly_pay 大于 490 的行:
让我们首先使用 filter():
>>> df.filter(df.weekly_pay > 490).show(truncate=False)
+-----+----------+--------------+
|name |weekly_pay|overtime_hours|
+-----+----------+--------------+
|alex |800 |8 |
|betty|700 |10 |
|mary |500 |0 |
+-----+----------+--------------+
我们可以通过 where 子句实现相同的功能:
>>> df.where(df.weekly_pay > 490).show(truncate=False)
+-----+----------+--------------+
|name |weekly_pay|overtime_hours|
+-----+----------+--------------+
|alex |800 |8 |
|betty|700 |10 |
|mary |500 |0 |
+-----+----------+--------------+
filter() 可以用于单个和多个条件:
>>> df.filter(df.weekly_pay > 400).show(truncate=False)
+-----+----------+--------------+
|name |weekly_pay|overtime_hours|
+-----+----------+--------------+
|alex |800 |8 |
|jane |420 |4 |
|betty|700 |10 |
|ted |480 |10 |
|mary |500 |0 |
+-----+----------+--------------+
>>> df.filter((df.weekly_pay > 400) &
(df.overtime_hours > 5)).show(truncate=False)
+-----+----------+--------------+
|name |weekly_pay|overtime_hours|
+-----+----------+--------------+
|alex |800 |8 |
|betty|700 |10 |
|ted |480 |10 |
+-----+----------+--------------+
输入-映射-Reduce-输出
输入-映射-Reduce-输出设计模式,如图 9-3 所示,是聚合操作(例如按键找到值的总和或平均值)中最常见的设计模式。
RDD 解决方案
Spark 提供了以下强大的解决方案来实现这种设计模式,可以使用多种不同的组合来解决数据问题:
-
映射阶段:
map()、flatMap()、mapPartitions()、filter() -
Reduce 阶段:
reduceByKey()、groupByKey()、aggregateByKey()、combineByKey()

图 9-3. 输入-映射-Reduce-输出设计模式
这是最简单的 MapReduce 设计模式:读取数据,执行映射转换(通常创建(键,值)对),对相同键的所有值进行聚合(求和、平均值等),然后保存输出。
假设您有格式为 <name><,><age><,><salary> 的记录,并且希望计算每个年龄组的平均工资,其中年龄组定义为 0-15、16-20、21-25、…、96-100。首先,需要读取输入并创建一个 RDD/DataFrame。然后,mapper 将逐个处理记录并创建(键,值)对,其中键是一个年龄组,值是一个工资。例如,如果我们的记录是 alex,22,45000,那么 mapper 将创建配对 ('21-25', 45000),因为年龄 22 属于年龄组 '21-25'。Mapper 函数可以表达为:
# rec: <name><,><age><,><salary>
def create_key_value_pair(rec):
tokens = rec.split(",")
age = int(tokens[1])
salary = tokens[2]
if age < 16: return ('0-15', salary)
if age < 21: return ('16-20', salary)
...
if age < 91: return ('85-90', salary)
if age < 96: return ('91-95', salary)
return ('96-100', salary)
#end-def
然后,reducer 将按年龄组(0-15、16-20 等)分组键,聚合每组中的值,并找到每组的平均工资。
假设您有以下输入:
alex,22,45000
bob,43,50000,
john,23,65000
jane,41,48000
joe,44,66000
Mapper 将生成以下(键,值)对:
('21-25', 45000)
('41-45', 54000)
('21-25', 67000)
('41-45', 68000)
('41-45', 70000)
然后 reducer 将为每个键分组值:
('21-25', [45000, 67000])
('41-45', [54000, 68000, 70000])
使用 Spark 的 groupByKey() 转换可以轻松实现按键分组。使用 groupByKey(),我们可以将 reducer 写成:
# rdd: RDD[(age-group, salary)]
grouped_by_age_group = rdd.groupByKey()
最后,我们可以计算每个年龄组的平均值:
('21-25', 56000)
('41-45', 64000)
这可以通过另一个简单的 mapper 实现:
# grouped_by_age_group: RDD[(age-group, [salary-1, salary-2, ...])]
age_group_average = grouped_by_age_group.mapValues(lambda v: sum(v)/len(v))
如果要使用组合器(Spark 在 reduceByKey() 中自动使用组合器),mapper 将生成以下(键,值)对,其中值为 (sum, count):
('21-25', (45000, 1))
('41-45', (54000, 1))
('21-25', (67000, 1))
('41-45', (68000, 1))
('41-45', (70000, 1))
创建(sum, count)作为值的原因是为了保证 Reducer 函数是可结合的和可交换的。如果您的 Reducer 函数不遵循这两个代数规则,那么 Spark 的reduceByKey()在输入数据分布在多个分区时将无法产生正确的语义。
给定一个RDD[(key, (sum, count))],使用 Spark 的reduceByKey() — 注意,这个 Reducer 基于分区的基础并且也使用组合器,我们可以将 Reducer 编写为:
# rdd: RDD[(key, (sum, count))]
reduced_by_age_group = rdd.reduceByKey(
lambda x, y: (x[0]+y[0], x[1]+y[1]))
Reducer 将根据它们关联的键对值进行分组:
('21-25', (112000, 2))
('41-45', (192000, 3))
然后,可以通过另一个简单的映射器计算每个年龄组的平均值:
('21-25', 56000)
('41-45', 64000)
也可以使用 Spark 的map()和combineByKey()转换来实现这种设计模式的组合。映射阶段与之前介绍的完全相同。使用create_key_value_pair()函数,它将创建以下(key, value)对:
('21-25', 45000)
('41-45', 54000)
('21-25', 67000)
('41-45', 68000)
('41-45', 70000)
假设这些(key, value)对由age_group_rdd表示。然后我们可以使用一对combineByKey()和mapValues()转换来进行减少:
# C denotes (sum-of-salaries, count-of-salaries)
combined = age_group_rdd.combineByKey(
lambda v : (v, 1), 
lambda C, v: (C[0]+v, C[1]+1), 
lambda C1,C2: (C1[0]+C2[0], C1[1]+C2[1]) 
)
# C denotes (sum-of-salaries, count-of-salaries)
avg_per_age_group = combined.mapValues(
lambda C : C[0]/C[1]
)
创建C为(工资总和, 工资数量)。
将工资合并到C中。
将两个C(来自不同分区)合并为一个单一的C。
提示
注意,reduceByKey()是combineByKey()的一个特例。对于reduceByKey(),源 RDD 和目标 RDD 必须是RDD[(K, V])的形式,而对于combineByKey(),源 RDD 可以是RDD[(K, V)],目标 RDD 可以是RDD[(K, C)],其中V和C可以是不同的数据类型。例如,V可以是Integer,而C可以是(Integer, Integer)。在 Spark 中,combineByKey()转换是对(key, value)数据集最通用和强大的 Reducer。
DataFrame 解决方案
PySpark 的 DataFrame 为减少转换提供了全面的功能。您可以使用Dataframe.groupby(*cols),它使用指定的列对 DataFrame 进行分组,以便对它们进行聚合。另一个选项是将您的 DataFrame 注册为表(行和命名列)然后利用 SQL 的功能GROUP BY和聚合所需的列。
以下示例显示了如何使用groupBy()函数。
首先,让我们创建一个 DataFrame:
>>> tuples4 = [("Illumina", "Alex", "San Diego", 100000),
... ("Illumina", "Bob", "San Diego", 220000),
... ("Illumina", "Jane", "Foster City", 190000),
... ("Illumina", "Ted", "Foster City", 230000),
... ("Google", "Rafa", "Menlo Park", 250000),
... ("Google", "Roger", "Menlo Park", 160000),
... ("Google", "Mona", "Menlo Park", 120000),
... ("IBM", "Joe", "San Jose", 160000),
... ("IBM", "Alex", "San Jose", 170000),
... ("IBM", "George", "San Jose", 180000),
... ("IBM", "Barb", "San Jose", 190000)]
>>> df = spark.createDataFrame(tuples4,
["company", "employee", "city", "salary"])
>>> df.show(truncate=False)
+--------+--------+-----------+------+
|company |employee|city |salary|
+--------+--------+-----------+------+
|Illumina|Alex |San Diego |100000|
|Illumina|Bob |San Diego |220000|
|Illumina|Jane |Foster City|190000|
|Illumina|Ted |Foster City|230000|
|Google |Rafa |Menlo Park |250000|
|Google |Roger |Menlo Park |160000|
|Google |Mona |Menlo Park |120000|
|IBM |Joe |San Jose |160000|
|IBM |Alex |San Jose |170000|
|IBM |George |San Jose |180000|
|IBM |Barb |San Jose |190000|
+--------+--------+-----------+------+
接下来,我们应用分组和聚合函数:
- 描述您的 DataFrame:
>>> df.describe().show()
+-------+--------+--------+-----------+-----------------+
|summary| company|employee| city| salary|
+-------+--------+--------+-----------+-----------------+
| count| 11| 11| 11| 11|
| mean| null| null| null|179090.9090909091|
| stddev| null| null| null|44822.88376589473|
| min| Google| Alex|Foster City| 100000|
| max|Illumina| Ted| San Jose| 250000|
+-------+--------+--------+-----------+-----------------+
- 在 DataFrame 上使用
groupBy():
>>> df.groupBy('company').max().show()
+--------+-----------+
| company|max(salary)|
+--------+-----------+
|Illumina| 230000|
| Google| 250000|
| IBM| 190000|
+--------+-----------+
>>> df.groupBy('Company').sum().show()
+--------+-----------+
| Company|sum(salary)|
+--------+-----------+
|Illumina| 740000|
| Google| 530000|
| IBM| 700000|
+--------+-----------+
>>> df.groupBy("company").agg({'salary':'sum'}).show()
+--------+-----------+
| company|sum(salary)|
+--------+-----------+
|Illumina| 740000|
| Google| 530000|
| IBM| 700000|
+--------+-----------+
>>> import pyspark.sql.functions as F
>>> df.groupby('company')
.agg(F.min("salary").alias("minimum_salary"),
F.max("salary").alias("maximum_salary")).show()
+--------+--------------+--------------+
| company|minimum_salary|maximum_salary|
+--------+--------------+--------------+
|Illumina| 100000| 230000|
| Google| 120000| 250000|
| IBM| 160000| 190000|
+--------+--------------+--------------+
输入-多个映射-减少-输出
输入-多个映射-减少-输出设计模式涉及多个映射、连接和减少。这种设计模式也被称为减少端连接,因为 Reducer 负责执行连接操作。为了帮助您理解这种设计模式,让我举个例子。假设我们有以下两个输入,一个是电影表,一个是评分表:
| 电影 ID | 电影名称 |
|---|---|
| 100 | 狮子王 |
| 200 | 星球大战 |
| 300 | 风中的小提琴手 |
| … | … |
| 电影-ID | 评分 |
| --- | --- |
| 100 | 4 |
| 100 | 5 |
| 200 | 4 |
| 200 | 2 |
| … | … |
最终目标是生成以下输出,即平均评分表。这是电影和评分表的连接,但在完成连接操作后,我们仍然需要执行另一次减少以找到每个电影-ID 的评分平均值:
| 电影-ID | 电影名称 | 平均评分 |
|---|---|---|
| 100 | 狮子王 | 4.5 |
| 200 | 星球大战 | 3.0 |
| … | … | … |
此数据设计模式由 图 9-4 描述。

图 9-4. 输入-映射-减少-输出(减少侧连接)设计模式
让我们逐步走过这一过程:
-
Mapper 读取输入数据,根据共同列或连接键进行合并。我们读取
Input-1,然后将map1()应用为映射器,并创建(<Common-Key>, <Rest-of-Attributes>)对。将此应用于电影表将创建(电影-ID, 电影名称)对,其中电影-ID是键,电影名称是值。 -
接下来,我们读取
Input-2,然后将map2()应用为映射器,并创建(<Common-Key>, <Rest-of-Attributes>)对。将此应用于评分表将创建(电影-ID, (电影名称, 评分))对,其中电影-ID是键,(电影名称, 评分)是值。 -
现在我们在
map1()和map2()的输出之间执行join()操作。因此,目标是在共同键电影-ID上将(电影-ID, 电影名称)对与(电影-ID, (电影名称, 评分))对进行连接。此连接的结果是(电影-ID, (评分, 电影名称))对。 -
下一步是使用
电影-ID作为键减少和聚合join()操作的输出:我们需要每个电影-ID的所有评分以找到评分的平均值。 -
最后,我们有一个简单的映射器 (
map3()) 计算评分的平均值并生成最终输出。
对于这种设计模式,我将提供两种 PySpark 解决方案:一种使用 RDD,另一种使用数据框架。
RDD 解决方案
首先,我将展示使用 RDD 的简单 PySpark 解决方案。第一步是准备输入。我们将创建两个 RDD 来表示我们的两个输入。为此,我将定义两个简单的分词函数:
def create_movie_pair(rec):
tokens = rec.split(",")
return (tokens[0], tokens[1])
#end-def
def create_rating_pair(rec):
tokens = rec.split(",")
# we drop User_ID here (not needed)
return (tokens[0], int(tokens[1]))
#end-def
接下来,我们在映射器转换中使用这些函数:
# spark: SparkSession
movies_by_name = ["100,Lion King", "200,Star Wars",
"300,Fiddler on the Roof", "400,X-Files"]
movies = spark.sparkContext.parallelize(movies_by_name)
movies.collect()
['100,Lion King', '200,Star Wars',
'300,Fiddler on the Roof', '400,X-Files']
movies_rdd = movies.map(create_movie_pair)
movies_rdd.collect()
[('100', 'Lion King'), ('200', 'Star Wars'),
('300', 'Fiddler on the Roof'), ('400', 'X-Files')]
ratings_by_users = ["100,4,USER-1234", "100,5,USER-3467",
"200,4,USER-1234", "200,2,USER-1234"]
ratings = spark.sparkContext.parallelize(ratings_by_users)
ratings.collect()
['100,4,USER-1234', '100,5,USER-3467',
'200,4,USER-1234', '200,2,USER-1234']
ratings_rdd = ratings.map(create_rating_pair)
ratings_rdd.collect()
[('100', 4), ('100', 5), ('200', 4), ('200', 2)]
到目前为止,我们已经创建了两个 RDD:
-
movies_rdd:RDD[(电影-ID, 电影名称)] -
ratings_rdd:RDD[(电影-ID, 评分)]
现在,我们将使用这两个 RDD 来在共同键 电影-ID 上执行连接操作:
joined = ratings_kv.join(movies_kv)
joined.collect()
[ ('200', (4, 'Star Wars')),
('200', (2, 'Star Wars')),
('100', (4, 'Lion King')),
('100', (5, 'Lion King'))]
grouped_by_movieid = joined.groupByKey()
.mapValues(lambda v: list(v))
grouped_by_movieid.collect()
[ ('200', [(4, 'Star Wars'), (2, 'Star Wars')]),
('100', [(4, 'Lion King'), (5, 'Lion King')])]
最后一步是使用简单的映射器准备最终输出,其中包括每个 电影-ID 的平均评分:
def find_avg_rating(values):
total = 0
for v in values:
total += v[0]
movie_name = v[1]
return (movie_name, float(total)/len(values))
#end-def
grouped_by_movieid.mapValues(
lambda values: find_avg_rating(values)).collect()
[
('200', ('Star Wars', 3.0)),
('100', ('Lion King', 4.5))
]
数据框架解决方案
使用数据框架的解决方案非常简单:我们为每个输入创建一个数据框架,然后在共同键 电影-ID 上进行连接。
首先让我们创建数据框架:
movies_by_name = [('100', 'Lion King'), ('200', 'Star Wars'),
('300', 'Fiddler on the Roof'), ('400', 'X-Files')]
movies_df = spark.createDataFrame(movies_by_name,
["movie_id", "movie_name"])
movies_df.show()
+--------+-------------------+
|movie_id| movie_name|
+--------+-------------------+
| 100| Lion King|
| 200| Star Wars|
| 300|Fiddler on the Roof|
| 400| X-Files|
+--------+-------------------+
ratings_by_user = [('100', 4, 'USER-1234'),
('100', 5, 'USER-3467'),
('200', 4, 'USER-1234'),
('200', 2, 'USER-1234')]
ratings_df = spark.createDataFrame(ratings_by_user,
["movie_id", "rating", "user_id"]).drop("user_id")
ratings_df.show()
+--------+------+
|movie_id|rating|
+--------+------+
| 100| 4|
| 100| 5|
| 200| 4|
| 200| 2|
+--------+------+
然后我们只需要执行连接操作。这在 DataFrames 中很容易实现:
joined = ratings_df.join(movies_df, "movie_id")
joined.show()
+--------+------+----------+
|movie_id|rating|movie_name|
+--------+------+----------+
| 200| 4| Star Wars|
| 200| 2| Star Wars|
| 100| 4| Lion King|
| 100| 5| Lion King|
+--------+------+----------+
output = joined.groupBy("movie_id", "movie_name").avg()
output.show()
+--------+----------+-----------+
|movie_id|movie_name|avg(rating)|
+--------+----------+-----------+
| 200| Star Wars| 3.0|
| 100| Lion King| 4.5|
+--------+----------+-----------+
Input-Map-Combiner-Reduce-Output
输入-映射-组合器-减少-输出设计模式与输入-映射-减少-输出非常相似。主要区别在于还使用了组合器,以加速转换过程。在 MapReduce 范式中(在 Apache Hadoop 中实现),组合器——也称为半减少器——是一个可选函数,通过接受每个工作节点上每个分区的映射器函数的输出,按键聚合结果,最后将输出的 (键, 值) 对传递给减少函数。在 Spark 中,组合器会自动在每个工作节点和分区上执行,你不必编写任何特殊的组合器函数。这样的转换的一个例子是 reduceByKey() 转换,它使用一个可结合和交换的减少函数合并每个键的值。
组合器的主要功能是总结和聚合映射器的输出记录,形成 (键, 值) 对,每个分区具有相同的键。这个设计模式的目的是确保组合器可以被使用,并且你的数据算法不会产生错误的结果。例如,目标是按键汇总值,我们有以下 (键, 值) 对:
(K1, 30), (K1, 40),
(K2, 5), (K2, 6), (K2, 7)
在同一个分区中,组合器的任务是将这些汇总为 (K1, 70), (K2, 18)。
这个数据设计模式由 图 9-5 描述。

图 9-5. 输入-映射-组合器-减少-输出设计模式
假设我们有代表城市及其相关温度的输入,并且我们的目标是找到每个城市的平均温度。source_rdd 的格式为 RDD[(String, Double)],其中键是城市名称,值是关联的温度。为了找到每个城市的平均温度,您可能会尝试编写:
# WARNING: THIS WILL NOT WORK
# let t1, t2 denote temperatures for the same city
avg_per_city = source_rdd.reduceByKey(
lambda t1, t2: (t1+t2)/2
)
但这不是正确的转换,因此它不会计算每个城市的平均值。问题在于,正如我们所知,平均函数不是可结合的:
AVG(1, 2, 3, 4, 5) != AVG(AVG(1, 2), AVG(3, 4, 5))
换句话说,平均数的平均数不是一个平均数。为什么?下面的例子可以说明。假设我们有这样的数据,在两个分区上:
Partition-1:
(Paris, 20)
(Paris, 30)
Partition-2:
(Paris, 40)
(Paris, 50)
(Paris, 60)
我们的转换将创建:
Partition-1:
(Paris, (20+30)/2) = (Paris, 25)
Partition-2:
(Paris, (40+50)/2) = (Paris, 45)
(Paris, (45+60)/2) = (Paris, 52.5)
最后,将两个分区的结果合并将产生:
(Paris, (25+52.5)/2)) = (Paris, 38.75)
38.75 是 (20, 30, 40, 50, 60) 的平均数吗?当然不是!正确的平均数是 (20 + 30 + 40 + 50 + 60) / 5 = 200 / 5 = 40。
因为平均函数不是可结合的,我们的减少函数不正确——但稍作修改,我们可以使映射器的输出具有交换性和结合性。这将给出每个唯一城市的正确平均值。
假设我们在一个 RDD 中有以下数据:
sample_cities = [('Paris', 20), ('Paris', 30),
('Paris', 40), ('Paris', 50), ('Paris', 60),
('Cupertino', 40), ('Cupertino', 60)]
cities_rdd = spark.sparkContext.parallelize(sample_cities)
现在,我们将从 cities_rdd 创建一个新的 RDD,以确保其值符合交换性和结合性的法则:
cities_sum_count = cities_rdd.mapValues(lambda v: (v, 1))
cities_sum_count是一个RDD[(city, (sum-of-temp, count-of-temp))。由于我们知道加法在(sum, count)元组上是可交换和可结合的操作,我们可以将我们的减少写成:
cities_reduced = cities_sum_count.reduceByKey(
lambda x, y: (x[0]+y[0], x[1]+y[1])
)
然后我们需要一个最终的映射器来找到每个城市的平均温度:
avg_per_city = cities_reduced.mapValues(
lambda v: v[0]/v[1]
)
此设计模式的另一种解决方案是使用 Spark 的combineByKey()转换。如果cities_rdd是我们的源 RDD,则可以按如下方式找到每个城市的平均温度:
avg_per_city = cities_rdd.combineByKey(
lambda v: (v, 1), 
lambda C, v: (C[0]+v, C[1]+1) 
lambda C1, C2: (C1[0]+C2[0], C1[1]+C2[1]) 
).mapValues(lambda v: v[0]/v[1])
创建C为(sum, count)。
合并每个分区的值。
将两个分区合并(将两个C合并成一个)。
提示
为了使您的组合器正常工作并在语义上正确,您的映射器输出的中间值必须是单子,并遵循交换性和结合性的代数法则。要了解更多关于这种设计模式的信息,请参见第四章和 Jimmy Lin 的论文“Monoidify! Monoids as a Design Principle for Efficient MapReduce Algorithms”。
输入-MapPartitions-Reduce-Output
输入-MapPartitions-Reduce-Output 是一个非常重要的数据设计模式,其中您将一个函数应用于每个分区——每个分区可能有数千或数百万个元素——而不是每个元素。我们在第二章和第三章讨论了这种设计模式,但由于其重要性,我想在这里更详细地介绍它。想象一下,您有数十亿条记录,您希望将所有这些记录汇总为如列表、数组、元组或字典之类的紧凑数据结构。您可以使用输入-MapPartitions-Reduce-Output 设计模式,如图 9-6 所示。

图 9-6. 输入-MapPartitions-Reduce-Output 设计模式
通常情况可以总结如下:
输入
数十亿条记录。
处理
使用mapPartitions()作为总结设计模式。
将输入分割为N个分区,然后使用自定义函数独立和并发地分析/处理每个分区,并产生一个紧凑的数据结构(CDS),如数组、列表或字典。我们可以将这些输出标记为 CDS-1、CDS-2、…、CDS-N。
Reducer
最终的减少器在生成的值 CDS-1、CDS-2、…、CDS-N上工作。这一步的输出是一个单一的紧凑数据结构,如数组、列表或字典。
Spark 的mapPartitions()是一个专门的map(),每个分区只调用一次。整个分区的内容作为顺序值流通过输入参数(Iterator[V],其中V是源 RDD 元素的数据类型)可用。自定义函数必须返回一个Iterator[T],其中T是目标 RDD 元素的数据类型。
要理解这种设计模式,必须理解map()和mapPartitions()之间的区别。map()方法通过应用函数将源 RDD 的每个元素转换为目标 RDD 的单个元素。另一方面,mapPartitions()方法将源 RDD 的每个分区(包含成千上万个元素)转换为结果 RDD 的多个元素(可能为零)。
假设我们有数十亿条记录如下形式:
<name><,><gender><,><salary>
我们的目标是根据员工的性别汇总薪水。我们希望从所有输入记录中找到以下三个(key, value)元组:
("male", (total-number-of-males, sum-of-salaries-for-males))
("female", (total-number-of-females, sum-of-salaries-for-females))
("unknown", (total-number-of-unknowns, sum-of-salaries-for-unknowns))
正如我们从预期输出中可以观察到的,只有三个键:"male","female"和"unknown"。
这里有几个输入记录的示例,我将用它们来说明这种设计模式的行为:
alex,male,22000
david,male,45000
jane,female,38000
mary,female,39000
max,male,55000
nancy,female,67000
ted,x,45000
sam,x,32000
rafa,male,100000
一个简单的解决方案是生成(key, value)对,其中 key 是性别,value 是薪水,然后使用groupByKey()转换来聚合结果。然而,这种解决方案效率不高,并且可能存在以下潜在问题,我们可以通过使用 Spark 的mapPartitions()转换来避免:
-
它将创建数十亿个(key, value)对,这将使集群网络变得混乱。
-
由于只有三个键,如果使用
groupByKey()转换,每个键将有数十亿个值,这可能导致 OOM 错误。 -
由于只有三个键,集群可能无法有效利用。
输入-MapPartitions-Reduce-Output 设计模式挺身而出,提供了一个高效的解决方案。首先,我们将输入分区为N个分区,每个分区包含数千或数百万条记录。根据输入大小决定N的值,例如(*N*=200, 400, 1000, 20000, …)。接下来的步骤是应用mapPartitions()转换:我们映射每个分区,并创建一个非常小的字典,其中包含三个键:"male","female"和"unknown"。最终的规约将聚合这些N个字典。
让我们将示例输入分区为两个分区:
Partition-1:
alex,male,22000
david,male,45000
jane,female,38000
mary,female,39000
max,male,55000
Partition-2:
nancy,female,67000
ted,x,45000
sam,x,32000
rafa,male,100000
这种设计模式的主要思想是对输入进行分区,然后独立并发地处理这些分区。例如,如果*N*=1000并且您有N个映射器,那么所有映射器都可以并发执行。通过应用基本的映射使用mapPartitions(),我们将为每个分区生成以下字典:
Partition-1:
{
"male": (122000, 3),
"female": (77000, 2)
}
Partition-2:
{
"male": (100000, 1),
"female": (67000, 1),
"unknown": (77000, 2)
}
接下来,我们将应用最终的规约,将所有分区的输出聚合到一个单一的字典中:
final output:
{
"male": (222000, 4),
"female": (144000, 3),
"unknown": (77000, 2)
}
当使用 Input-MapPartitions-Reduce-Output 设计模式来总结数据时,由于我们每个分区创建一个简单的小数据结构(例如字典),所以没有可扩展性问题。即使我们设置*N*=100,000,这个解决方案也是高效的,因为处理 100,000 个小字典不会导致任何 OOM 问题。
提示
使用mapPartitions()转换的最重要原因是性能。通过在单个服务器节点上具有所有需要进行计算的数据(作为单个分区),我们减少了洗牌的开销(即需要序列化和网络流量)。
使用 Spark 的mapPartitions()转换来实现输入-MapPartitions-Reduce-Output 设计模式的另一个优点是,它允许您每个分区执行重量级初始化(而不是每个元素一次)。以下示例说明了这一点。mapPartitions()提供了可以一次性对工作任务/线程/分区进行初始化,而不是对每个 RDD 数据元素进行一次性初始化:
# source_rdd: RDD[V]
# source_rdd.count() in billions
# target_rdd: RDD[T]
# target_rdd.count() in thousands
# apply transformation
target_rdd = source_rdd.mapPartitions(custom_func)
def custom_func(partition):
database_connection = *`<``heavyweight``-``operation``-``initialization``>`*
target_data_structure = *`<``initialize``>`*
for element in partition
target_data_structure = update(element,
target_data_structure,
database_connection)
#end-for
close(database_connection)
return target_data_structure
#def
反向索引
在计算机科学中,反向索引是一个数据库索引,它存储了从内容(例如单词或数字)到其在表格、文档或一组文档中的位置的映射。例如,考虑以下输入:
doc1: ant, dog
doc2: ant, frog
doc3: dog
doc4: ant
反向索引的目标是创建此索引:
frog: [doc2]
ant: [doc1, doc2, doc4]
dog: [doc1, doc3]
现在,如果您要搜索“dog”,您会知道它在[doc1, doc3]中。反向索引设计模式从数据集生成索引,以实现更快的搜索。这种类型的索引是文档检索系统中最流行的数据结构,并且在搜索引擎中大规模使用。
反向索引设计模式有优点和缺点。优点包括它使我们能够执行快速的全文搜索(虽然增加了将文档添加到数据库时的处理成本),并且易于开发。使用 PySpark,我们可以通过一系列map()、flatMap()和减少转换来实现这种设计模式。
然而,更新、删除和插入操作也会带来大量的存储开销和高维护成本。
问题陈述
假设我们有一个数据集,其中包含许多文件的莎士比亚作品。我们希望生成一个包含所有单词、每个单词出现的文件以及出现次数的索引。
输入
用于创建反向索引的示例输入文档可以从GitHub下载。这些文档由一系列 35 个文本文件组成:
0ws0110.txt
0ws0210.txt
...
0ws4210.txt
输出
输出将是从输入阶段读取的所有文档创建的反向索引。此输出将具有以下格式:
(*word*, [(*filename1*, *frequency1*), (*filename2*, *frequency2*), ...])
表示word在filename1(频率为frequency1)、filename2(频率为frequency2)等中。
PySpark 解决方案
我们对这种设计模式的 PySpark 实现包括以下步骤:
-
读取输入文件,过滤掉所有停用词(
a、of、the等),如果需要还可以应用词干提取算法(例如,将reading转换为read等)。此步骤创建了(path, text)对。 -
创建具有计数为 1 的元组。也就是说,预期的输出将是
((word, document), 1)。 -
分组所有
(word, document)对并求和计数(需要归约)。 -
将每个
((word, document), frequency)的元组转换为(word, (document, count)),这样我们就可以按document计数word。 -
输出
(document, count)对的序列到逗号分隔的字符串中。 -
保存反向索引。
假设我们有以下三个文档作为输入:
$ ls -l /tmp/documents/
file1.txt
file2.txt
file3.txt
$ cat /tmp/documents/file1.txt
fox jumped
fox jumped high
fox jumped and jumped
$ cat /tmp/documents/file2.txt
fox jumped
fox jumped high
bear ate fox
bear ate honey
$ cat /tmp/documents/file3.txt
fox jumped
bear ate honey
第 1 步是读取输入文件并创建(path, text)对,其中path是输入文件的完整名称,text是文件的内容。例如,如果path表示文件/tmp/documents/file1.txt,那么text就是文件file1.txt的内容。Spark 的wholeTextFiles(*path*)函数从文件系统 URI 中读取文本文件目录。每个文件作为单个记录读取,并返回成(key, value)对,其中 key 是文件路径,value 是文件内容:
docs_path = '/tmp/documents/'
rdd = spark.sparkContext.wholeTextFiles(docs_path)
rdd.collect()
[('file:/tmp/documents/file2.txt',
'fox jumped\nfox jumped high\nbear ate fox \nbear ate honey\n'),
('file:/tmp/documents/file3.txt',
'fox jumped\nbear ate honey\n'),
('file:/tmp/documents/file1.txt',
'fox jumped\nfox jumped high\nfox jumped and jumped\n')]
第 2 步是将每个text映射到一组((word, document), 1)对。我们从换行符开始分割文本:
def get_document_name(path):
tokens = path.split("/")
return tokens[-1]
#end-def
rdd2 = rdd.map(lambda x : (get_filename(x[0]), x[1]))
rdd2.collect()
[('file2.txt',
'fox jumped\nfox jumped high\nbear ate fox \nbear ate honey\n'),
('file3.txt',
'fox jumped\nbear ate honey\n'),
('file1.txt',
'fox jumped\nfox jumped high\nfox jumped and jumped\n')]
rdd3 = rdd2.map(lambda x: (x[0], x[1].splitlines()))
rdd3.collect()
[('file2.txt',
['fox jumped', 'fox jumped high', 'bear ate fox ', 'bear ate honey']),
('file3.txt',
['fox jumped', 'bear ate honey']),
('file1.txt',
['fox jumped', 'fox jumped high', 'fox jumped and jumped'])]
接下来,我们创建(word, document)对,并将它们映射到((word, document), 1)的元组中,这表示word属于document,频率为 1:
def create_pairs(tuple2):
document = tuple2[0]
records = tuple2[1]
pairs = []
for rec in records:
for word in rec.split(" "):
pairs.append((word, document))
return pairs
#end-def
rdd4 = rdd3.flatMap(create_pairs)
rdd4.collect()
[('fox', 'file2.txt'), ('jumped', 'file2.txt'),
('fox', 'file2.txt'), ('jumped', 'file2.txt'), ... ]
rdd5 = rdd4.map(lambda x: (x, 1))
rdd5.collect()
[(('fox', 'file2.txt'), 1), (('jumped', 'file2.txt'), 1),
(('fox', 'file2.txt'), 1), (('jumped', 'file2.txt'), 1), ...]
第 3 步是执行简单的归约操作,将所有((word, document), 1)对分组并求和计数:
frequencies = rdd5.reduceByKey(lambda x, y: x+y)
frequencies.collect()
[(('fox', 'file2.txt'), 3), (('jumped', 'file2.txt'), 2),
(('ate', 'file2.txt'), 2), (('bear', 'file3.txt'), 1), ...]
在第 4 步中,我们执行一个非常简单的map()转换,将path移动到元组的值部分:
((word, path), frequency) => (word, (path, frequency))
我们这样做如下:
mapped = frequencies.map(lambda v: (v[0][0], (v[0][1], v[1])))
>>> mapped.collect()
[('fox', ('file2.txt', 3)), ('jumped', ('file2.txt', 2)),
('ate', ('file2.txt', 2)), ('bear', ('file3.txt', 1)), ...]
接下来,在第 5 步中,我们将(document, count)对的序列输出到逗号分隔的字符串中:
inverted_index = mapped.groupByKey()
inverted_index.mapValues(lambda values: list(values)).collect()
[('fox', [('file2.txt', 3), ('file1.txt', 3), ('file3.txt', 1)]),
('bear', [('file3.txt', 1), ('file2.txt', 2)]),
('honey', [('file3.txt', 1), ('file2.txt', 1)]), ...]
要实现此步骤,我使用了groupByKey()转换。您也可以使用其他归约转换,如reduceByKey()或combineByKey()来完成相同的任务。例如,您可以使用combineByKey()转换来实现此步骤如下:
# convert a tuple into a list
def to_list(a):
return [a]
# append a tuple to a list
def append(a, b):
a.append(b)
return a
# merge two lists from partitions
def extend(a, b):
a.extend(b)
return a
inverted_index = rdd6.combineByKey(to_list, append, extend)
最后,在第 6 步中,保存您创建的反向索引:
inverted_index.saveAsTextFile("/tmp/output/")
概要
本章介绍了一些最常见和基础的数据分析设计模式,并通过简单的示例演示了使用 PySpark 的实现。在发明新的自定义数据转换之前,您应该研究现有的 PySpark API,并尽可能使用它们(因为这些 API 经过严格测试,您可以放心使用它们)。使用 Spark 转换的组合将使您能够解决几乎任何数据问题。
下一章介绍了一些适用于生产环境的实用数据设计模式。
第十章:实用数据设计模式
本章的目标是介绍一些在解决常见数据问题中有用的实用数据设计模式。我们将专注于实际的设计模式,这些设计模式用于大数据解决方案,并且已在生产环境中部署。
与前一章一样,我将提供简单的示例来说明每个设计模式的使用,并向您展示如何使用 Spark 的转换来实现它们。我还将更多地讨论单子的概念,帮助您更好地理解归约转换。
最佳的设计模式书籍是由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(被称为“四人帮”)编写的经典计算机科学书籍 设计模式:可复用面向对象软件的基础。与“四人帮”书中类似的数据设计模式不同,我将专注于实际的、非正式的生产环境中使用的数据设计模式。
本章中我们将要讨论的数据设计模式可以帮助我们编写可部署在 Spark 集群上的可扩展解决方案。然而,要注意的是,在采用和使用设计模式时,并没有银弹。每个模式都应该使用真实数据在类似于生产环境的环境中进行性能和可扩展性测试。
注意
关于软件工程中设计模式的一般介绍,请参阅前面提到的 设计模式:可复用面向对象软件的基础,作者是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley 出版)。要了解更多关于 MapReduce 中设计模式的信息,请参阅 MapReduce 设计模式,作者是 Donald Miner 和 Adam Shook,以及我的书籍 数据算法(均由 O'Reilly 出版)。
我将在本章中介绍的设计模式包括:
-
内部映射器组合
-
前 10 名
-
最大最小
-
复合模式/单子
-
分箱
-
排序
我们将从一个有用的总结设计模式开始,使用一个内部映射器组合器。
在内部映射器组合中
在 MapReduce 范式中,组合器(也称为半减少器)是在每个工作节点上本地运行的过程,用于在数据发送到减少器之前对数据进行聚合。在像 Hadoop 这样的框架中,这通常被视为一个可选的本地优化。In-mapper combiner 执行进一步的优化,通过在收到每个(键,值)对时在内存中执行聚合,而不是将它们全部写入本地磁盘,然后按键聚合值。 (Spark 在内存中执行所有处理,因此默认情况下是这样操作的。)In-mapper 组合设计模式的目的是使 Mapper 能够尽可能高效地组合和总结其输出,以便在排序、洗牌和减少器(如 reduceByKey() 或 groupByKey())处理时发出更少的中间(键,值)对。例如,对于经典的单词计数问题,给定如下输入记录:
"fox jumped and fox jumped again fox"
如果不使用 In-mapper 组合设计模式,我们将生成以下(键,值)对发送到减少器:
(fox, 1)
(jumped, 1)
(and, 1)
(fox, 1)
(jumped, 1)
(again, 1)
(fox, 1)
问题在于,对于非常大的数据集,这种方法会生成大量的 (单词, 1) 对,这样做效率低下,并且会使集群网络非常忙碌。使用 In-mapper 组合设计模式,我们在数据发送到网络之前通过键进行聚合,汇总和减少 Mapper 的输出,在执行洗牌之前完成这一过程。例如,在这种情况下有三个 (fox, 1) 实例和两个 (jumped, 1) 实例,这些(键,值)对将组合成以下输出:
(fox, 3)
(jumped, 2)
(and, 1)
(again, 1)
尽管在这个简单的示例中减少不显著,但如果我们有一个包含大量重复单词的大型数据集,这种设计模式可以通过生成更少的中间(键,值)对显著提升性能。
为了进一步演示这种设计模式背后的概念,接下来的部分将介绍三种解决方案来计算一组文档中每个字符出现频率的问题。简单来说,我们想要找出每个唯一字符在给定语料库中出现的次数。我们将讨论以下解决方案:
-
基本的 MapReduce 算法
-
每条记录的 In-mapper 组合
-
分区内的 In-mapper 组合
基本的 MapReduce 算法
为了计算一组文档中的字符数,我们将每个输入记录拆分为一组单词,将每个单词拆分为字符数组,然后生成(key, value)对,其中 key 是字符数组中的单个字符,value 为 1(表示一个字符的频率计数)。这是一种基本的 MapReduce 设计模式,不使用任何自定义数据类型,而 reducer 简单地求和每个单一唯一字符的频率。这种解决方案的问题在于,对于大数据集,它会生成大量的(key, value)对,这可能会导致网络负载过大,从而影响整体解决方案的性能。生成的大量(key, value)对还可能减慢排序和洗牌阶段(对相同 key 的值进行分组)。这种方法的不同阶段如图 10-1 所示。

图 10-1. 字符计数:基本的 MapReduce 算法
给定一个RDD[String],下面提供了该算法的 PySpark 实现。
首先我们定义一个简单的函数,接受单个String记录,并返回(key, value)对列表,其中 key 是字符,value 是 1(该字符的频率):
def mapper(rec):
words = rec.lower().split() 
pairs = [] 
for word in words: 
for c in word: 
pairs.append((c, 1)) 
#end-for
#end-for
return pairs 
#end-def
将记录标记为单词数组。
创建一个空列表作为pairs。
迭代每个单词。
遍历单个单词。
将每个字符(c)作为(c, 1)添加到pairs中。
返回给定记录中所有字符的(c, 1)列表。
这个mapper()函数可以简化为:
def mapper(rec):
words = rec.lower().split()
pairs = [(c, 1) for word in words for c in word]
return pairs
#end-def
接下来,我们使用mapper()函数来计算唯一字符的频率:
# spark: an instance of SparkSession
rdd = spark.sparkContext.textFile("/dir/input") 
pairs = rdd.flatMap(mapper) 
frequencies = pairs.reduceByKey(lambda a, b: a+b) 
从输入数据创建一个RDD[String]。
将每条记录映射为一组字符,并将其展开为新的RDD[Character, 1]。
查找每个唯一字符的频率。
接下来,我们将介绍一个更高效的实现,它使用内映射器组合设计模式。
记录级别的内映射器组合
本节介绍了每条记录的 In-Mapper Combining 设计模式,也称为本地聚合每条记录解决方案。它类似于基本的 Spark MapReduce 算法,唯一的区别是对于给定的输入记录,我们在发射(key, value)对之前聚合每个字符的频率。换句话说,在这个解决方案中,我们发射(key, value)对,其中 key 是给定输入记录中的唯一字符,value 是该记录中该字符的累计频率。然后我们使用reduceByKey()来聚合每个唯一字符的所有频率。该解决方案利用reduce()函数的结合性和交换性,在发送到网络之前将值组合起来。例如,对于以下的输入记录:
foxy fox jumped over fence
我们将发射以下的(key, value)对:
(o, 3) (m, 1) (v, 1)
(x, 2) (p, 1) (r, 1)
(y, 1) (e, 4) (n, 1)
(j, 1) (d, 1) (c, 1)
给定一个RDD[String],下面提供了一个 PySpark 解决方案。
首先,我们定义一个简单的函数,接受一个单个的String记录(输入RDD[String]的一个元素),并返回一个(key, value)对列表,其中 key 是一个唯一的字符,value 是该字符的累计频率:
import collections 
def local_aggregator(record):
hashmap = collections.defaultdict(int) 
words = record.lower().split() 
for word in words: 
for c in word: 
hashmap[c] += 1 
#end-for
#end-for
print("hashmap=", hashmap)
pairs = [(k, v) for k, v in hashmap.iteritems()] 
print("pairs=", pairs)
return pairs 
#end-def
collections模块提供高性能的容器数据类型。
创建一个空的dict[String, Integer]。defaultdict是一个dict的子类,它调用一个工厂函数来提供缺失的值。
将输入记录分词为一个单词数组。
遍历单词。
遍历每个单词。
聚合字符。
将字典扁平化为(character, frequency)列表。
返回扁平化的(character, frequency)列表。
接下来,我们使用local_aggregator()函数来计算唯一字符的频率:
input_path = '/tmp/your_input_data.txt'
rdd = spark.sparkContext.textFile(input_path)
pairs = rdd.flatMap(local_aggregator)
frequencies = pairs.reduceByKey(lambda a, b: a+b)
这个解决方案将发射较少的(key, value)对,这比之前的解决方案更好。这意味着网络负载较小,排序和洗牌的执行速度比基本算法更快。然而,这个实现仍然存在潜在的问题:虽然它会在不太多的映射器情况下扩展,因为我们在每个映射器中实例化和使用一个字典,如果映射器数量很多,我们可能会遇到内存溢出错误。
接下来,我将展示这一设计模式的另一个版本,避免了这个问题并且更加高效。
分区内的 In-Mapper Combining
此最终解决方案会聚合输入数据每个分区(而不是每个记录)中每个字符的频率,其中每个分区可能包含数千或数百万条输入记录。为此,我们再次构建一个dict[Character, Integer]的哈希表,但这次是针对给定输入分区的字符而不是输入记录。映射器然后会发出由哈希表条目组成的(键,值)对,其中键是dict.Entry.getKey(),值是dict.Entry.getValue()。这是一种非常紧凑的数据表示,因为dict[Character, Integer]的每个条目相当于N个基本的(键,值)对,其中N等于dict.Entry.getValue()。
因此,在这个解决方案中,我们使用每个输入分区一个哈希表来跟踪该分区所有记录中所有字符的频率。在映射器完成处理分区后(使用 PySpark 的 mapPartitions() 转换),我们发出频率表(我们构建的哈希表)中的所有(键,值)对。然后减少器将汇总所有分区的频率,并找出字符的最终计数。这个解决方案比前两种更有效,因为它发出的(键,值)对更少,从而减少了网络负载和排序和洗牌阶段的工作量。它也比前两种解决方案更好地扩展,因为使用每个输入分区一个哈希表消除了内存溢出问题的风险。即使我们将输入分区划分成成千上万个分区,这个解决方案也可以很好地扩展。
例如,对于以下输入分区(而不是单个记录):
foxy fox jumped over fence
foxy fox jumped
foxy fox
我们将会发出以下(键,值)对:
(f, 7) (u, 2) (v, 1) (j, 2) (y, 3)
(o, 7) (m, 1) (r, 1) (d, 2) (e, 5)
(x, 6) (p, 2) (n, 1) (c, 1)
注
在使用这种设计模式时需要考虑的一个因素是,你需要小心哈希表的大小,确保它不会成为瓶颈。对于字符计数问题,每个映射器(每个输入分区)的哈希表大小将非常小(因为我们只有有限数量的唯一字符),所以不会有性能瓶颈的危险。
给定一个RDD[String],PySpark 解决方案如下所示。
首先我们定义一个简单的函数,接受一个由许多输入记录组成的单个输入分区,并返回一个(键,值)对列表,其中键是字符,值是该字符的聚合频率:
def inmapper_combiner(partition_iterator): 
hashmap = defaultdict(int) 
for record in partition_iterator: 
words = record.lower().split() 
for word in words: 
for c in word: 
hashmap[c] += 1 
#end-for
#end-for
#end-for
print("hashmap=", hashmap)
#
pairs = [(k, v) for k, v in hashmap.iteritems()] 
print("pairs=", pairs)
return pairs 
#end-def
partition_iterator 表示由一组记录组成的单个输入分区。
创建一个空的dict[String, Integer]。
从一个分区获取单个记录。
将记录标记为单词数组。
遍历单词。
遍历每个单词。
聚合字符。
将字典扁平化为(character, frequency)列表。
返回扁平化的(character, frequency)列表。
接下来,我们使用inmapper_combiner()函数来计算唯一字符的频率:
rdd = spark.sparkContext.textFile("/.../input") 
pairs = rdd.mapPartitions(inmapper_combiner) 
frequencies = pairs.reduceByKey(lambda a, b: a+b)
从输入文件(s)创建一个RDD[String]。
mapPartitions()转换通过将函数应用于该 RDD 的每个输入分区(而不是单个输入记录)来返回一个新的 RDD。
这种解决方案将比先前的解决方案发出远少于的(key, value)对。它非常高效,因为我们在每个输入分区中实例化和使用一个单一的字典(而不是每个输入记录)。这极大地减少了需要在 mapper 和 reducer 之间传输的数据量,减轻了网络负载并加快了排序和洗牌阶段的速度。内映射合并算法充分利用组合器作为优化器,这种解决方案极好地扩展了。即使有大量的 mapper,也不会导致 OOM 错误。然而,应该注意的是,如果唯一键的数量增长到关联数组无法适应内存的大小,内存分页将显著影响性能。如果出现这种情况,您将不得不恢复到基本的 MapReduce 方法。
在实现按分区设计模式的内映射合并时,我们使用 Spark 强大的mapPartitions()转换将每个输入分区转换为一个单一的dict[Character, Integer],然后将这些聚合成一个最终的单一的dict[Character, Integer]。对于字符计数及其他需要从大数据集中提取少量信息的应用,该算法比其他方法更高效快速。在字符计数问题中,关联数组(每个 mapper 分区)的大小受限于唯一字符的数量,因此在使用这种设计模式时不会遇到可扩展性瓶颈。
总结一下,在按分区设计模式的内映射合并方案中,在效率和可扩展性方面提供了几个重要优势:
-
大大减少了发出的(key, value)对的数量
-
需要较少的(key, value)对的排序和洗牌
-
充分利用组合器作为优化器
-
很好地扩展了
但也需要注意一些缺点:
-
更难实现(需要处理每个分区的自定义函数)
-
底层对象(每个 mapper 分区)更加重量级
-
基础限制是底层对象的大小(对于字符计数问题,每个映射器分区使用一个关联数组)
接下来,我们将看一下其他几个常见的用例,您希望从大型数据集中提取少量信息,并查看最佳方法是什么。
前 10
创建一个前 10 列表在许多数据密集型操作中是一个常见的任务。例如,我们可能会问以下问题:
-
在过去的一天/一周/一个月内访问的前 10 个 URL 是哪些?
-
在过去的一天/一周/一个月内,亚马逊上购买的前 10 件物品是什么?
-
在过去的一天/一周/一个月内,Google 上前 10 个搜索查询是什么?
-
昨天 Facebook 上最受欢迎的前 10 个物品是什么?
-
有史以来最受欢迎的前 10 个卡通是什么?
用于回答这些问题的简单设计模式在图 10-2 中说明。

图 10-2。前 10 设计模式
例如,假设我们有一个包含两列url和frequency的表。使用 SQL 查询查找最受欢迎的前 10 个访问量最高的 URL 很简单:
SELECT url, frequency
FROM url_table
ORDER BY frequency DESC
LIMIT 10;
在 Spark 中查找前N(其中N > 0)条记录也很容易。给定一个RDD[(String, Integer)],其中键是表示 URL 的字符串,值是访问该 URL 的频率,我们可以使用RDD.takeOrdered(*N*)来找到前N列表。RDD.takeOrdered()的一般格式是:
takeOrdered(N, key=None)
Description:
Get the N elements from an RDD ordered in ascending
order or as specified by the optional key function.
假设N是大于 0 的整数,我们有多种选项可以有效地找到一个顶级-N 列表,使用RDD.takeOrdered():
# Sort by keys (ascending):
RDD.takeOrdered(N, key = lambda x: x[0])
# Sort by keys (descending):
RDD.takeOrdered(N, key = lambda x: -x[0])
# Sort by values (ascending):
RDD.takeOrdered(N, key = lambda x: x[1])
# Sort by values (descending):
RDD.takeOrdered(N, key = lambda x: -x[1])
举例来说,假设takeOrdered()在大型数据集中表现不佳。我们还有哪些其他选择?
给定一个大量的(键,值)对集合,其中键是一个String,值是一个Integer,我们希望找到一个独立的、可重用的解决方案来解决找到前N个键(其中N > 0)的问题——即一种设计模式,使我们能够生成可重用的代码来回答像前面提到的那些问题,当处理大数据时。这种问题对于由(键,值)对组成的数据很常见。这本质上是一个过滤任务:您过滤掉不需要的数据,只保留前N个项目。前 10 函数也是可交换和可结合的函数,因此使用分区器、合并器和减少器将始终产生正确的结果。
也就是说,给定一个顶级-10 函数T和一组值(例如频率){a, b, c}对于相同的键,然后我们可以写:
-
可交换
T(a, b) = T(b, a) -
可结合
T(a, T(b, c)) = T(T(a, b), c)
提示
有关前 10 列表设计模式的详细信息,请参考 Donald Miner 和 Adam Shook 的《MapReduce 设计模式》(https://www.oreilly.com/library/view/mapreduce-design-patterns/9781449341954/)。
本节提供了一个完整的 PySpark 解决方案,用于 top-10 设计模式。给定一个 RDD[(String, Integer)],目标是找到该 RDD 的前 10 名列表。在我们的解决方案中,我们假设所有键都是唯一的。如果键不唯一,则可以在找到前 10 名之前使用 reduceByKey() 转换使它们唯一。
我们的解决方案将推广问题并能够找到一个 top-N 列表(对于 N > 0)。例如,我们将能够找到前 10 名猫、前 50 名最访问的网站或前 100 个搜索查询。
正式化的 Top-N
让我们开始正式化这个问题。让 N 是大于 0 的整数。让 L 是一个 (T, Integer) 对的列表,其中 T 可以是任何类型(如 String、Integer 等),L.size() = s,且 s > N。L 的元素是:
其中 K[i] 的数据类型为 T,V[i] 是 Integer 类型(这是 K[i] 的频率)。让 {sort(L)} 是一个排序列表,排序是通过使用频率作为键来完成的。这给了我们:
其中 (A[j], B[j]) 在 L 中。然后,列表 L 的 top-N 定义为:
对于我们的 top-N 解决方案,我们将使用 Python 的 SortedDict,一个有序的可变映射。这种类型的设计很简单:SortedDict 继承自 dict 来存储项目,并维护一个键的排序列表。排序字典键必须是可散列的和可比较的。键的散列和总序在存储在排序字典中时不能更改。
要实现 top-N,我们需要一个哈希表数据结构,其键可以有总序,例如SortedDict(在我们的情况下,键表示频率)。该字典按其键的自然顺序排序。我们可以使用 sortedcontainer.SortedDict() 构建这个结构。我们将继续向 SortedDict 添加 (frequency, url) 对,但保持其大小为 N。当大小为 N+1 时,我们将使用 SortedDict.popitem(0) 弹出最小频率。
这里展示了这个工作的一个例子:
>>> from sortedcontainers import SortedDict
>>> sd = SortedDict({10: 'a', 2: 'm', 3: 'z', 5: 'b', 6: 't', 100: 'd', 20: 's'})
>>> sd
SortedDict({2: 'm', 3: 'z', 5: 'b', 6: 't', 10: 'a', 20: 's', 100: 'd'})
>>> sd.popitem(0)
(2, 'm')
>>> sd
SortedDict({3: 'z', 5: 'b', 6: 't', 10: 'a', 20: 's', 100: 'd'})
>>> sd[50] = 'g'
>>> sd
SortedDict({3: 'z', 5: 'b', 6: 't', 10: 'a', 20: 's', 50: 'g', 100: 'd'})
>>> sd.popitem(0)
(3, 'z')
>>> sd
SortedDict({5: 'b', 6: 't', 10: 'a', 20: 's', 50: 'g', 100: 'd'})
>>> sd[9] = 'h'
>>> sd
SortedDict({5: 'b', 6: 't', 9: 'h', 10: 'a', 20: 's', 50: 'g', 100: 'd'})
>>> sd.popitem(0)
(5, 'b')
>>> sd
SortedDict({6: 't', 9: 'h', 10: 'a', 20: 's', 50: 'g', 100: 'd'})
>>>
>>> len(sd)
6
接下来,我将介绍一个使用 PySpark 的 top-10 解决方案。
PySpark 解决方案
PySpark 解决方案非常直接。我们使用 mapPartitions() 转换来找到每个分区的本地 top N(其中 N > 0),并将这些传递给单个 reducer。Reducer 然后从所有从 mappers 传递的本地 top N 列表中找到最终的 top-N 列表。通常,在大多数 MapReduce 算法中,如果一个 reducer 在一个服务器上接收到所有数据,可能会导致性能瓶颈。然而,在这种情况下,我们的单个 reducer 不会引起性能问题。为什么不会呢?假设我们有 5000 个分区。每个 mapper 只会生成 10 个(键,值)对,这意味着我们的单个 reducer 只会得到 50000 条记录——这样的数据量不太可能在 Spark 集群中引起性能瓶颈。
关于 PySpark 解决方案中关于顶级设计模式的高级概述在图 10-3 中提供。

图 10-3. PySpark 中的顶级设计模式实现
输入被分割成较小的块,并且每个块被发送到一个 mapper。每个 mapper 发出一个本地的前 10 名列表以供一个单一的 reducer 消费。在这里,我们使用单个 reducer 键,以便来自所有 mapper 的输出将被单个 reducer 消耗。
让spark成为SparkSession的一个实例。这就是通过使用mapPartitions()转换和一个名为top10_design_pattern()的自定义 Python 函数解决前 10 名问题的方式:
pairs = [('url-1', 10), ('url-2', 8), ('url-3', 4), ...]
rdd = spark.sparkContext.parallelize(pairs)
# mapPartitions(f, preservesPartitioning=False)
# Return a new RDD by applying a function
# to each partition of this RDD.
top10 = rdd.mapPartitions(top10_design_pattern)
要完成实现,这里我将介绍top10_design_pattern()函数,该函数查找每个分区(包含一组(key, value)对)的前 10 名:
from sortedcontainers import SortedDict
def top10_design_pattern(partition_iterator): 
sd = SortedDict() 
for url, frequency in partition_iterator: 
sd[frequency] = url 
if (len(sd) > 10):
sd.popitem(0) 
#end-for
print("local sd=", sd)
pairs = [(k, v) for k, v in sd.items()] 
print("top 10 pairs=", pairs)
return pairs 
#end-def
partition_iterator是单个分区的迭代器;它迭代一组(URL, frequency)对。
创建一个空的(Integer, String)对的SortedDict。
迭代一组(URL, frequency)对。
将(frequency, URL)对放入SortedDict中。
限制排序字典的大小为 10(删除最低频率的条目)。
将SortedDict(这是一个本地前 10 名列表)转换为(k, v)对的列表。
返回单个分区的本地前 10 名列表。
每个 mapper 接受元素的分区,其中每个元素都是(URL, frequency)对。分区的数量通常由数据大小和集群中可用资源(节点、核心、内存等)决定,或者可以由程序员明确设置。在 mapper 完成创建其本地的SortedDict[Integer, String]作为单个字典(例如SortedDict)的本地前 10 名列表后,该函数返回该列表。请注意,我们每个分区使用单个字典(如SortedDict),而不是源 RDD 的每个元素。正如“在 Mapper 中的每个分区组合”所描述的那样,通过减少网络负载以及在排序和洗牌阶段要做的工作,这显著提高了操作的效率。
书的 GitHub 存储库中提供了使用mapPartitions()的完整解决方案。
寻找底部的 10 个
在前一节中,我向您展示了如何找到前 10 名列表。要找到底部的 10 个列表,我们只需要替换此行代码:
# find top 10
if (len(sd) > 10): 
sd.popitem(0) 
如果SortedDict的大小大于 10…
…然后从字典中删除频率最低的 URL。
with this:
# find bottom 10
if (len(sd) > 10): 
sd.popitem(-1) 
如果SortedDict的大小大于 10…
…然后从字典中删除频率最高的 URL。
接下来让我们讨论如何对输入进行分区。RDD 的分区是艺术和科学的结合体。对于你的集群,什么是正确的分区数?没有计算这个的魔法公式;这取决于集群节点的数量,每个服务器的核心数以及可用的 RAM 数量。这里涉及一些试错,但一个好的经验法则是从以下开始:
2 * num_executors * cores_per_executor
当你创建一个 RDD 时,如果没有显式设置分区数,Spark 集群管理器将根据可用资源将其设置为默认值。你也可以像这样自己设置分区数:
input_path = "/data/my_input_path"
desired_num_of_partitions = 16
rdd = spark.sparkContext.textFile(input_path, desired_num_of_partitions)
这将创建一个带有16 partitions的RDD[String]。
对于现有的 RDD,你可以使用coalesce()函数重新设置新的分区数:
# rdd: RDD[T]
desired_number_of_partitions = 40
rdd2 = rdd.coalesce(desired_number_of_partitions)
新创建的rdd2(另一个RDD[T])将有 40 个分区。coalesce()函数定义如下:
pyspark.RDD.coalesce:
coalesce(numPartitions, shuffle=False)
Description:
Return a new RDD that is reduced into numPartitions partitions.
与repartition()不同,它可以用于增加或减少分区数,但涉及跨集群的洗牌,coalesce()只能用于减少分区数,在大多数情况下不需要洗牌。
接下来,我将介绍 MinMax 设计模式,用于从大量数字数据集中提取少量信息。
MinMax
MinMax 是一种数值汇总设计模式。给定一组数十亿的数字,目标是找到所有数字的最小值、最大值和计数。这种模式可以用于数据为数值类型并且可以按特定字段分组的场景。为了帮助你理解 MinMax 设计模式的概念,我将介绍三种性能差异显著的解决方案。
解决方案 1:经典 MapReduce
最简单的方法是发射以下(键,值)对:
("min", number)
("max", number)
("count", 1)
然后,排序和洗牌将按三个键min、max和count分组所有值,最后我们可以使用一个减少器来迭代所有数字并找到全局的min、max和count。这种方法的问题在于,我们必须在网络上移动可能数百万的(键,值)对,然后创建三个巨大的Iterable<T>数据结构(其中T是数值类型,如Long或Double)。这种解决方案可能会遇到严重的性能问题,并且不会扩展。此外,在减少阶段,由于只有三个唯一键,它不会有效地利用所有的集群资源。
解决方案 2:排序
下一个解决方案是对所有数字进行排序,然后找到数据集的顶部(max)、底部(min)和count。如果性能可接受,则这是一个有效的解决方案;然而,对于大型数据集,排序时间可能会不可接受的长。换句话说,此解决方案也不会扩展。
解决方案 3:Spark 的 mapPartitions()
最终的解决方案,从性能和可扩展性的角度来看最高效,将数据分割为*N*个块(分区),然后使用 Spark 的mapPartitions()转换从每个分区发出三个(键,值)对:
("min", minimum-number-in-partition)
("max", maximum-number-in-partition)
("count", count-of-numbers-in-partition)
最后,我们从所有分区中找到全局的min、max和count。这个解决方案的可扩展性非常好。无论你有多少分区,这都会起作用,并且不会创建 OOM 错误。例如,假设您的数据集中有 5000 亿个数字(假设每条记录一个或多个数字),并且将其分成 100,000 个块。在最坏的情况下(每条记录一个数字),每个分区将有 500 万条记录。这些分区中的每一个将发出上述三对。然后,您只需要找到 100,000 x 3 对 = 300,000 个数字的min、max和count。这是一个微不足道的任务,不会造成任何可扩展性问题。
这个解决方案的高层次视图在图 10-4 中说明。

图 10-4. MinMax 设计模式的 PySpark 实现
假设我们的输入记录具有以下格式:
<number><,><number><,><number>...
这里是一些示例记录:
10,345,24567,2,100,345,9000,765
2,34567,23,13,45678,900
...
下面是解决 MinMax 问题的 PySpark 解决方案(包含完整程序和示例输入的 GitHub 链接,在文件minmax_use_mappartitions.py中可用):
input_path = *`<``your``-``input``-``path``>`*
rdd = spark.sparkContext.textFile(input_path) 
min_max_count = rdd.mapPartitions(minmax) 
min_max_count_list = min_max_count.collect() 
final_min_max_count = find_min_max_count(min_max_count_list) 
从给定的输入返回一个新的 RDD。
应用minmax()函数从每个分区返回一个 RDD,其中包含(min, max, count)。
作为列表从所有分区收集(min, max, count)。
通过调用函数find_min_max_count()找到(final_min, final_max, final_count)。
我定义了minmax()函数如下:
def minmax(iterator): 
first_time = True
for record in iterator: 
numbers = [int(n) for n in record.split(",")] 
if (first_time): 
# initialize count, min, max to the 1st record values
local_min = min(numbers)
local_max = max(numbers)
local_count = len(numbers)
first_time = False
else: 
# update count, min, and max
local_count += len(numbers)
local_max = max(max(numbers), local_max)
local_min = min(min(numbers), local_min)
#end-for
return [(local_min, local_max, local_count)] 
#end-def
iterator的类型为itertools.chain。
迭代iterator(record保存一个记录)。
对输入进行标记化,并构建一个数字数组。
如果这是第一条记录,找到min、max和count。
如果这不是第一条记录,更新local_min、local_max和local_count。
最后,从每个分区返回一个三元组。
如果某些分区为空(即不包含数据),有许多原因可能导致这种情况发生,因此优雅地处理空分区非常重要(详见第三章)。
接下来我将向您展示如何处理这个问题。Python 中的错误处理通过捕获try块中的异常并在except块中处理来完成。在 Python 中,如果在try块中遇到错误,则会停止代码执行,并将控制转移到except块。让我们看看如何在我们的 MinMax 解决方案中实现这一点:
def minmax(iterator): 
print("type(iterator)=", type(iterator)) 
# ('type(iterator)=', <type 'itertools.chain'>)
try:
first_record = next(iterator) 
except StopIteration: 
return [ None ] # We will filter out None values by filter()
# initialize count, min, max to the 1st record values
numbers = [int(n) for n in first_record.split(",")] 
local_min = min(numbers)
local_max = max(numbers)
local_count = len(numbers)
for record in iterator: 
numbers = [int(n) for n in record.split(",")]
# update min, max, count
local_count += len(numbers)
local_max = max(max(numbers), local_max)
local_min = min(min(numbers), local_min)
# end-for
return [(local_min, local_max, local_count)] 
iterator的类型为itertools.chain。
打印iterator的类型(仅用于调试)。
尝试从iterator获取第一个记录。如果成功,则将first_record初始化为分区的第一个记录。
如果您在这里,这意味着分区为空;返回一个空值。
设置min、max和count为第一个记录的值。
迭代iterator以获取记录 2、3 等(record保存单个记录)。
最后,从每个分区返回一个三元组。
我们应该如何测试空分区的处理?程序minmax_force_empty_partitions.py(在书的 GitHub 存储库中可用)强制创建空分区并优雅地处理它们。您可以通过将分区数设置为高于输入记录数来强制创建空分区。例如,如果您的输入有*N*条记录,则将分区数设置为*N*+3将导致分区器创建多达三个空分区。
复合模式和单子
本节探讨了复合模式和单子的概念,这些概念在第四章中介绍,并深入探讨了如何在 Spark 和 PySpark 的上下文中使用它们。
复合模式是一种结构设计模式(也称为分区设计模式),当一组对象可以像该组中的单个对象一样对待时,可以使用它。您可以使用它创建层次结构和对象组,从而形成具有叶子(对象)和复合物(子组)的树状结构。这在 UML 表示法中以图 10-5 说明。

图 10-5. 复合设计模式的 UML 图
有了这种设计模式,一旦你把对象组合成这种类似树结构的形式,你就可以像操作单一对象一样操作这个结构。其关键特点是能够递归地运行方法覆盖整个树结构并汇总结果。这种模式可以通过 PySpark 的 reducer 实现。
复合模式的一个简单应用示例是对一组数字进行加法(在一组键上),如 图 10-6 所示。这里,数字是叶子节点,而复合是加法运算符。

图 10-6. 复合模式示例:加法
接下来,我将在复合模式的背景下讨论幺半群的概念。
幺半群
在 第四章 中,我们讨论了在减少转换中使用幺半群。在这里,我们将在复合模式的背景下看看幺半群的应用,这在大数据中常用于组合(例如通过加法和连接运算符)和聚合数据点集合。从模式的定义来看,很明显幺半群与复合模式有着共同点。
作为一个复习,让我们从 维基百科 查看一下幺半群的定义:
在抽象代数中,数学的一个分支中,幺半群是带有可结合的二元操作和单位元素的集合。幺半群是带单位元的半群。这样的代数结构出现在数学的几个分支中。例如,从一个集合到它自身的函数形成一个关于函数合成的幺半群。更一般地说,在范畴论中,对象到自身的态射形成一个幺半群,反过来,一个幺半群可以看作是具有单一对象的范畴。在计算机科学和计算机编程中,从给定字符集构建的字符串集合是一个自由幺半群。
MapReduce 编程模型是计算机科学中幺半群的一个应用。正如我们所见,它由三个函数组成:map()、combine() 和 reduce()。这些函数与 Spark 中的 map() 和 flatMap() 函数以及减少转换非常相似(combine() 是可选操作)。给定一个数据集,map() 将任意数据映射到特定幺半群的元素,combine() 在本地级别(集群中的工作节点)聚合/折叠数据,而 reduce() 则聚合/折叠这些元素,最终产生一个元素。
因此,在编程语言语义上,幺半群只是一个具有一个抽象值和一个抽象方法的接口。幺半群的抽象方法是附加操作(它可以是整数的加法运算符或字符串对象的连接运算符)。幺半群的抽象值是身份值,定义为可以附加到任何值而始终产生原始值未修改的值。例如,集合数据结构的身份值是空集合,因为将集合附加到空集合通常会产生相同的未修改集合。对于添加一组整数,身份值是零,对于连接字符串,它是空字符串(长度为零的字符串)。
接下来,我们将简要回顾 MapReduce 的组合器和抽象代数的幺半群,并看看它们之间的关系。正如你将看到的那样,当你的 MapReduce 操作(例如在 Spark 中的map()和reduceByKey()转换)不是幺半群时,要有效地使用组合器是非常困难的(如果不是不可能的话)。
在 MapReduce 范式中,Mapper 没有约束,但 Reducer 需要是(迭代应用的)可结合操作。组合器(作为可选的插件组件)是一个“本地减少器”进程,仅在一个服务器生成的数据上操作。成功使用组合器可以通过减少在给定单个服务器上由 Mapper 生成的中间数据量来减少网络流量(这就是为什么称为本地)。组合器可以用作 MapReduce 的优化,通过减少从 Mapper 到 Reducer 发送的(键,值)对的数量来减少网络流量。通常,组合器具有与 Reducer 相同的接口。组合器必须具有以下特征:
-
接收给定服务器上所有由 Mapper 实例发出的数据作为输入(这称为本地聚合)
-
将其输出发送给 Reducers
-
无副作用(组合器可能运行不确定次数)
-
具有相同的输入和输出键和值类型
-
在映射阶段后在内存中运行
我们可以定义组合器的框架如下:
# key: as KeyType
# values: as Iterable<ValueType>
def combine(key, values):
...
# use key and values to create new_key and new_value
new_key = *`<``a``-``value``-``of``-``KeyType``>`*
new_value = *`<``a``-``value``-``of``-``ValueType``>`*
...
return (new_key, new_value);
...
#end-def
此模板说明了组合器生成的(键,值)对必须与 Mapper 发出的(键,值)对的类型相同。例如,如果 Mapper 输出 (T[1], T[2]) 对(其中键是类型为 T[1],值是类型为 T[2]),那么组合器也必须发出 (T[1], T[2]) 对。
Hadoop MapReduce 框架没有显式的combine()函数,但可以通过在Map和Reduce类之间添加Combiner类来减少传输到 Reducer 的数据量。组合器通过Job.setCombinerClass()方法指定。
组合器的目标应该是通过 Mapper 发出的中间值“单体化”,正如我们在第四章中看到的那样,这是设计高效 MapReduce 算法的指导原则。
一些编程语言,如 Haskell,直接支持幺半群。在 Haskell 中,幺半群是“具有一条规则的类型,该规则说明如何将该类型的两个元素组合以生成该类型的另一个元素”。我们将在接下来的章节中看一些例子,但首先让我们快速回顾一下幺半群。
幺半群是一个三元组 (S, f, e),其中:
-
S是一个集合(称为幺半群的底层集合)。 -
f是幺半群的二元操作称为映射 (f : S x S → S)。 -
e是幺半群的单位操作(e∈S)。
带有二元操作 + 的幺半群(请注意,这里的 + 表示二元操作,而不是数学上的加法运算)满足以下三条公理(请注意 f(a,b) = a + b):
闭合性
对于集合 S 中的所有 a、b,操作 (a + b) 的结果也在 S 中。
结合性
对于集合 S 中的所有 a、b 和 c,以下方程成立:
((a + b) + c) = (a + (b + c))
单位元素
在集合 S 中存在一个元素 e,使得对于集合 S 中的所有元素 a,以下两个方程成立:
e + a = a
a + e = a
在数学符号中,我们可以这样写:
闭合性
∀ a,b ∈ S: a + b ∈ S
结合性
for all a,b,c in S: ((a + b) + c) = (a + (b + c))
单位元素
{
exists e in S:
for all a in S:
e + a = a
a + e = a
}
幺半群操作可能(但不一定)具有其他属性,例如:
幂等性
for all a in S: a + a = a
可交换性
for all a, b in S: a + b = b + a
要形成一个幺半群,首先我们需要一个类型 S,该类型可以定义值集合,如整数:{0, -1, +1, -2, +2, ...}。第二个组成部分是一个二元函数:
- + : S x S → S
然后,我们需要确保对于集合 S 中的任意两个值 x 和 y:
- x + y : S
例如,如果类型 S 是整数集合,则二元操作可以是加法(+)、乘法(*)或除法(/)。最后,作为第三个和最重要的成分,我们需要二元操作遵循指定的一组法则。如果是这样,我们说 (S, +, e) 是一个幺半群,其中 e 在 S 中是单位元素(例如加法为 0,乘法为 1)。
提示
请注意,实数集的二元除法操作(/)不是幺半群:
((12 / 4) / 2) != (12 / (4 / 2))
((12 / 4) / 2) = (3 / 2) = 1.5
(12 / (4 / 2)) = (12 / 2) = 6.0
简言之,幺半群捕捉将任意多个东西组合成单个东西的概念,同时还有一个称为空物或值的身份元素或值的概念。一个简单的例子是自然数的加法 {1, 2, 3, ...}。加法函数 + 允许我们将任意多个自然数组合成单个自然数,即和。身份值是数字 0。另一个例子是字符串连接,其中连接运算符允许我们将任意多个字符串组合成单个字符串;在这种情况下,身份值是空字符串。
幺半群和非幺半群的例子
Spark 在reduceByKey()转换中使用合并器,因此要有效使用这个转换,你必须确保缩减函数是一个幺半群——即,幺半群是一个集合(用S表示),它在一个结合的二元操作(f)下是封闭的,并且在S中有一个单位元I,使得对于所有x在S中,f(I, x) = x 和 f(x, I) = x。为了帮助您理解幺半群的概念,我在这里提供一些幺半群和非幺半群的例子。
整数集上的最大值
集合S = {0, 1, 2, ...}是MAX(最大)操作下的交换幺半群,其单位元是0:
MAX(a, MAX(b, c)) = MAX(MAX(a, b), c)}
MAX(a, 0) = a
MAX(0, a) = a
MAX(a, b) in S
整数集上的减法
整数集上的减法(-)不定义一个幺半群;此操作不是结合的:
(1 - 2) -3 = -4
1 - (2 - 3) = 2
整数集上的加法
整数集上的加法(+)定义了一个幺半群;此操作是交换的和结合的,单位元是0:
(1 + 2) + 3 = 6
1 + (2 + 3) = 6
n + 0 = n
0 + n = n
我们可以将此形式化如下,其中e(+)定义了一个单位元:
S = {0, -1, +1, -2, +2, -3, +3, ...}
e(+) = 0
f(a, b) = f(b, a) = a + b
整数的并集和交集
整数集的并集或交集形成一个幺半群。二元函数是并集/交集,单位元是空集{}。
整数集上的乘法
自然数集合N = {0, 1, 2, 3, ...}在乘法下形成一个交换幺半群(单位元为 1)。
整数集上的平均值
另一方面,自然数集N = {0, 1, 2, 3, ...}在MEAN(平均)函数下不构成幺半群。以下例子表明,对于值集的任意子集的平均值的平均值与完整值集的平均值不同:
MEAN(1, 2, 3, 4, 5)
-- NOT EQUAL --
MEAN( MEAN(1,2,3), MEAN(4,5) )
MEAN(1, 2, 3, 4, 5) = (1+2+3+4+5)/5
= 15/5
= 3
MEAN( MEAN(1,2,3), MEAN(4,5) ) = MEAN(2, 4.5)
= (2 + 4.5)/2
= 3.25
因此,如果你想要计算RDD[(key, integer)]的值的平均数,你不能使用以下转换(由于分区而可能导致不正确的值):
# rdd: RDD[(key, integer)]
average_per_key = rdd.reduceByKey(lambda x, y: (x+y)/2)
找到每个键的平均值的正确方法是使该函数成为一个幺半群:
# rdd: RDD[(key, integer)]
# create value as (sum, count) pair: this makes a monoid
rdd2 = rdd.mapValues(lambda n: (n, 1))
# find (sum, count) per key
sum_count = rdd2.reduceByKey(lambda x, y: (x[0]+y[0], x[1]+y[1]))
# now, given (sum, count) per key, find the average per key
average_per_key = sum_count.mapValues(lambda x: x[0]/x[1])
整数集上的中位数
自然数集合同样不在MEDIAN函数下构成幺半群:
MEDIAN(1, 2, 3, 5, 6, 7, 8, 9)
-- NOT EQUAL --
MEDIAN( MEDIAN(1,2,3), MEDIAN(5,6,7,8,9) )
MEDIAN(1, 2, 3, 5, 6, 7, 8, 9)
= (5 + 6) / 2
= 11 / 2
= 5.5
MEDIAN( MEDIAN(1,2,3), MEDIAN(5,6,7,8,9) )
= MEDIAN(2, 7) =
= (2 + 7) / 2
= 9 / 2
= 4.5
列表的串联
列表的串联(+)与空列表([])是一个幺半群。对于任何列表L,我们可以写:
L + [] = L
[] + L = L
此外,请注意,串联函数是可结合的。给定两个列表,例如[1,2,3]和[7,8],我们可以使用+将它们连接在一起以得到[1,2,3,7,8]。然而,除了与空列表(或字符串、集合等)的串联外,它不是交换的:[1,2,3]+[7,8]=[1,2,3,7,8] 而 [7,8]+[1,2,3]=[7,8,1,2,3]。
矩阵示例
设N = {1, 2, 3, ...},且m, n ∈ N。则带整数条目的m × n矩阵的集合,写作Z^(m×n),满足使其在加法下成为幺半群的属性:
-
闭包由定义保证。
-
其元素的结合性质由其元素的结合性质保证。
-
加法恒元是
0,即零矩阵。
这些示例应该帮助您理解减少函数作为幺半群的含义。Spark 的reduceByKey()是一个有效的转换,使用关联和交换的 reduce 函数合并每个 key 的值。我们必须确保其 reduce 函数是一个幺半群,否则可能得不到正确的减少结果。
非幺半群 MapReduce 示例
给定大量的(key, value)对,其中 key 为字符串,value 为整数,对于这个非幺半群示例,我们的目标是按 key 找到所有值的平均值。假设我们在名为mytable的表中有以下数据,有key和value列:
SELECT key, value FROM mytable
key value
--- -----
key1 10
key1 20
key1 30
key2 40
key2 60
key3 20
key3 30
在 SQL 中,可以通过以下方式实现:
SELECT key, AVG(value) as avg FROM mytable GROUP BY key
key avg
--- ---
key1 20
key2 50
key3 25
这是一个 MapReduce 算法的初始版本,其中 mapper 未生成 mean/average 函数的幺半群输出:
Mapper 函数
# key: a string object
# value: a long associated with key
map(key, value) {
emit(key, value);
}
Reducer 函数
# key: a string object
# values: a list of long data type numbers
reduce(key, values) {
sum = 0
count = 0
for (i : list) {
sum += i
count += 1
}
average = sum / count
emit(key, average)
}
这个第一次尝试的 MapReduce 算法存在几个问题:
-
该算法效率不高;在排序和洗牌阶段需要完成大量工作。
-
我们不能将 reducer 作为 combiner 使用,因为我们知道一组值的任意子集的平均值并不等于所有值的平均值。
我们可以做哪些改变以使我们的 Reducer 可以作为 Combiner 使用,以减少网络负载并加快排序和洗牌阶段的速度?我们需要改变 mapper 的输出,使其成为一个幺半群。这将确保我们的 combiners 和 reducers 能够正确运行。
让我们看看我们如何做到这一点。
幺半群 MapReduce 示例
在本节中,我将修改 mapper 以生成(key, value)对,其中 key 是字符串,value 是一对(sum, count)。(sum, count)数据结构是一个幺半群,单位元素是(0, 0)。证明在这里给出:
Monoid type is (N, N) where N = {set of integers}
Identity element is (0, 0):
(sum, count) + (0, 0) = (sum, count)
(0, 0) + (sum, count) = (sum, count)
Let a = (sum1, count1), b = (sum2, count2), c = (sum3, count3)
Then associativity holds:
(a + (b + c)) = ((a + b) + c)
+ is the binary function:
a + b = (sum1+sum2, count1+count2) in (N, N)
现在,让我们为我们的幺半群数据类型编写一个 mapper:
# key: a string object
# value: a long data type associated with key
# emits (key, (sum, count))
map(key, value) {
emit (key, (value, 1))
}
如您所见,key 与以前相同,但 value 是一对(sum, count)。现在,mapper 的输出是一个幺半群,其中单位元素是(0, 0)。可以执行逐元素求和操作如下:
element1 = (key, (sum1, count1))
element2 = (key, (sum2, count2))
==> values for the same key are reduced as:
element1 + element2
= (sum1, count1) + (sum2, count2)
= (sum1+sum2, count1+count2)
因为 mapper 输出了幺半群,现在 mean 函数将被正确计算。假设单个 key 的值为{1, 2, 3, 4, 5},而{1, 2, 3}进入分区 1,{4, 5}进入分区 2:
MEAN(1, 2, 3, 4, 5)
= MEAN( MEAN(1,2,3), MEAN(4,5) )}
= (1+2+3+4+5) / 5
= 15 / 5
= 3
Partition 1:
MEAN(1,2,3) = MEAN(6, 3)
Partition 2:
MEAN(4,5) = MEAN(9, 2)
Merging partitions:
MEAN( MEAN(1,2,3), MEAN(4,5) )
= MEAN( MEAN(6, 3), MEAN(9, 2))
= MEAN(15, 5)
= 15 / 5
= 3
修改后的算法如下,对于给定的一对(sum, count),pair.1表示sum,pair.2表示count。这是我们的 combiner:
# key: a string object
# values: a list of pairs as [(s1, c1), (s2, c2), ...]
combine(key, values) {
sum = 0
count = 0
for (pair : values) {
sum += pair.1
count += pair.2
}
emit (key, (sum, count))
}
这是我们的 reducer:
# key: a string object
# values: a list of pairs as [(s1, c1), (s2, c2), ...]
reduce(key, values) {
sum = 0
count = 0
for (pair : values) {
sum += pair.1
count += pair.2
}
average = sum / count
emit (key, average)
}
由于我们的 mapper 生成了幺半群数据类型,我们知道我们的 combiner 将正确执行,并且我们的 reducer 将产生正确的结果。
Monoidal Mean 的 PySpark 实现
本节的目标是提供一个解决方案,使我们能够在使用组合器来聚合值以找到分区间平均值时使用组合器。为了计算相同键的所有值的平均值,我们可以使用 Spark 的groupByKey()转换来对值进行分组,然后找到总和并除以数目(每个键)。然而,这并不是一个最优解决方案,因为正如我们在前面章节中看到的,对于大数据集使用groupByKey()可能会导致 OOM 错误。
对于这里呈现的解决方案,对于给定的(key, number)对,我们将发出(key, (number, 1))的元组,其中键的关联值表示(sum, count)对:
(key, value1) = (key, (sum1, count1))
(key, value2) = (key, (sum2, count2))
早些时候,我展示了使用(sum, count)作为值将使我们能够使用组合器和减少器正确计算平均值。我们将使用非常高效的reduceByKey()转换而不是groupByKey()。这是归约函数的工作方式:
value1 + value2 =
(sum1, count1) + (sum2, count2) =
(sum1+sum2, count1+count2)
一旦归约完成,我们将使用额外的映射器通过将总和除以计数来找到平均值。
输入记录的格式将是:
<key-as-string><,><value-as-integer>
例如:
key1,100
key2,46
key1,300
在高层次上,PySpark 解决方案由以下四个步骤组成:
-
读取输入并创建第一个 RDD 作为
RDD[String]。 -
应用
map()来创建RDD[key, (number, 1)]。 -
使用
reduceByKey()执行归约操作,将创建一个RDD[key, (sum, count)]。 -
应用
mapValue()来创建最终的 RDD 作为RDD[key, (sum / count)]。
完整的 PySpark 程序(average_monoid_driver.py)可以在 GitHub 上找到。
首先,我们需要两个简单的 Python 函数来帮助我们使用 Spark 转换。第一个函数create_pair()接受一个String对象作为"key,number"并返回一个(key, (number, 1))对:
# record as String of "key,number"
def create_pair(record):
tokens = record.split(",")
key = tokens[0]
number = int(tokens[1])
return (key, (number, 1))
# end-def
第二个函数add_pairs()接受两个对(sum1, count1)和(sum2, count2),并返回它们的和(sum1+sum2, count1+count2):
# a = (sum1, count1)
# b = (sum2, count2)
def add_pairs(a, b):
# sum = sum1+sum2
sum = a[0] + b[0]
# count = count1+count2
count = a[1] + b[1]
return (sum, count)
# end-def
这是完整的 PySpark 解决方案:
from __future__ import print_function 
import sys 
from pyspark.sql import SparkSession 
if len(sys.argv) != 2: 
print("Usage: average_monoid_driver.py <file>", file=sys.stderr)
exit(-1)
spark = SparkSession.builder.getOrCreate() 
# sys.argv[0] is the name of the script
# sys.argv[1] is the first parameter
input_path = sys.argv[1] 
print("input_path: {}".format(input_path))
# read input and create an RDD[String]
records = spark.sparkContext.textFile(input_path) 
# create a pair of (key, (number, 1)) for "key,number"
key_number_one = records.map(create_pair) 
# aggregate the (sum, count) of each unique key
sum_count = key_number_one.reduceByKey(add_pairs) 
# create the final RDD as an RDD[key, average]
averages = sum_count.mapValues(lambda (sum, count): sum / count) 
print("averages.take(5): ", averages.take(5))
# done!
spark.stop()
导入print()函数。
导入系统特定的参数和函数。
从pyspark.sql模块导入SparkSession。
确保在命令行中有两个参数。
使用构建器模式创建SparkSession的实例。
定义输入路径(可以是文件或包含任意数量文件的目录)。
读取输入并创建第一个 RDD 作为RDD[String],其中每个对象的格式为"key,number"。
创建key_number_one RDD 作为RDD[key, (number, 1)]。
将 (sum1, count1) 和 (sum2, count2) 聚合,并创建 (sum1+sum2, count1+count2) 作为值。
应用 mapValues() 转换以找到每个键的最终平均值。
函数子与单子
您现在已经看到了几个单子及其在 MapReduce 框架中的使用示例,但我们甚至可以将高阶函数(如函数子)应用于单子。函数子是一个既是函数又是对象的对象。
首先,我将通过一个简单的例子介绍如何在单子上使用函数子。让 MONOID = (t, e, f) 是一个单子,其中 T 是一个类型(值的集合),e 是单位元素,f 是二进制加法函数 +:
MONOID = {
type T
val e : T
val plus : T x T -> T
}
然后我们可以如下定义函数子 Prod:
functor Prod (M : MONOID) (N : MONOID) = {
type t = M.T * N.T
val e = (M.e, N.e)
fun plus((x1,y1), (x2,y2)) = (M.plus(x1,x2), N.plus(y1,y2))
}
我们还可以定义其他函数子,例如 Square,如下所示:
functor Square (M : MONOID) : MONOID = Prod M M
我们还可以定义两个单子之间的函数子。设 (M[1], f[1], e[1]) 和 (M[2], f[2], e[2]) 是单子。一个函数子:
- F : (M[1], f[1], e[1]) → (M[2], f[2], e[2])
由对象映射指定(单子是具有单个对象的范畴)和箭头映射 F : M[1] → M[2]。并且以下条件将成立:
-
∀a,b ∈ M[1], F(f1) = f2, F(b))
-
F(e[1]) = e[2]
两个单子之间的函数子只是一个单子同态(在保持单子操作并映射第一个单子的单位元素到第二个单子的单位元素之间的映射)。例如,对于 String 数据类型,一个函数 Length(),它计算一个单词中字母的数量,是一个单子同态:
-
Length("") = 0(空字符串的长度为 0)。 -
如果
Length(x) = m和Length(y) = n,则字符串的连接x + y有m + n个字母。例如:Length("String" + "ology") = Length("Stringology") = 11 = 6 + 5 = Length("String") + Length("ology")
再次强调,使映射器创建单子可以确保减少器能够有效且正确地利用组合器。
对单子的使用结论
正如我们所观察到的,在 MapReduce 范式中(这是 Hadoop、Spark、Tez 等框架的基础),如果您的映射器生成单子,则可以利用组合器进行优化和效率目的。使用组合器减少了网络流量并加速了 MapReduce 的排序和洗牌阶段,因为要处理的数据更少。您还看到了如何将 MapReduce 算法单子化的一些示例。一般而言,当您要应用的函数是可交换和可结合的(单子的性质)时,可以使用组合器。例如,经典的单词计数函数在整数集合上是一个具有 + 操作的单子(这里可以使用组合器)。然而,平均函数(不是可结合的)在整数集合上不形成单子。要在这种情况下有效地使用组合器,我们需要确保映射器的输出是单子的。接下来,我们将关注一些重要的数据组织模式:分桶和排序。
分桶
Binning 是一种将多个或多少连续的数值分组成较少数量的“bin”或“桶”的方法。例如,如果你有关于一群人口普查数据,你可能希望将他们的年龄映射到较少数量的年龄区间,比如0-5,6-10,11-15,…,96-100+。Binning 的一个重要优势是它缩小了搜索特定值所需的数据范围。例如,如果你知道有人是 14 岁,你只需要在标记为11-15的 bin 中搜索他们。换句话说,Binning 可以通过检查数据的一个切片而不是整个数据集来帮助我们更快地进行查询。Binning 设计模式将记录移动到类别(bin),而不考虑记录的初始顺序。
Figure 10-7 展示了另一个例子。在基因组数据中,染色体标记为{chr1, chr2, ..., chr22, chrX, chrY, chrMT}。一个人类有 30 亿对染色体,其中chr1大约有 2.5 亿个位置,chr7有 1.6 亿个位置,依此类推。如果你想找到一个变体键为10:100221486:100221486:G,你将不得不搜索数十亿条记录,这是非常低效的。Binning 可以帮助加快这一过程:如果我们按染色体对数据进行分组,要找到这个变体键,我们只需要查看标记为chr10的 bin,而不是搜索所有数据。

图 10-7. 按染色体进行 Binning
要在 PySpark 中实现 Binning 算法,首先我们读取输入并创建一个带有正确列的 DataFrame。然后,我们创建一个额外的列,称为chr_id,它将表示染色体的一个 bin。chr_id 列将具有集合{chr1, chr2, ..., chr22, chrX, chrY, chrMT}中的值。
可以在多个层次上实现 Binning —— 例如,首先按染色体,然后按起始位置的模数—— 如 Figure 10-8 所示。

图 10-8. 按染色体进行 Binning
这将非常有帮助,因为我们可能每条染色体有数百万个变体;再加一层 Binning 可以通过允许我们检查更薄的数据切片进一步减少查询时间。在我展示如何按起始位置实现 Binning 之前,让我们先看一下变体结构:
<chromosome><:><start_position><:><stop_position><:><allele>
一个简单的 Binning 算法是将start_position划分为 101 个 bin(取决于数据量,你可能会选择一个不同的数量,但应为质数)。因此,我们的 bin 值将是{0, 1, 2, ..., 100}。然后,我们将创建另一个新列称为modulo,其值将定义为:
modulo = start_position % 101
例如,对于变体10:100221486:100221486:G,module值将是95(100221486 % 101 = 95)。
继续以基因组数据示例为例,假设我们有以下数据(请注意,我在这里只包括了几列,以保持示例简单)。首先,我们从这些数据创建一个 DataFrame:
variants = [('S-100', 'Prostate-1', '5:163697197:163697197:T', 2),
('S-200', 'Prostate-1', '5:3420488:3420488:C', 1),
('S-100', 'Genome-1000', '3:107988242:107988242:T', 1),
('S-200', 'Genome-1000', '3:54969706:54969706:T', 3)]
columns = ['SAMPLE_ID', 'STUDY_ID', 'VARIANT_KEY', 'ZYGOSITY' ]
df = spark.createDataFrame(variants, columns)
df.show(truncate=False)
+---------+-----------+-----------------------+--------+
|SAMPLE_ID|STUDY_ID |VARIANT_KEY |ZYGOSITY|
+---------+-----------+-----------------------+--------+
|S-100 |Prostate-1 |5:163697197:163697197:T|2 |
|S-200 |Prostate-1 |5:3420488:3420488:C |1 |
|S-100 |Genome-1000|3:107988242:107988242:T|1 |
|S-200 |Genome-1000|3:54969706:54969706:T |3 |
+---------+-----------+-----------------------+--------+
接下来,我们为chr_id创建一个分箱函数,该函数从给定的variant_key中提取:
def extract_chr(variant_key):
tokens = variant_key.split(":")
return "chr" + tokens[0]
#end-def
要使用extract_chr()函数,首先我们必须创建一个 UDF:
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType
extract_chr_udf = udf(extract_chr, StringType())
binned_by_chr = df.select("SAMPLE_ID", "STUDY_ID", "VARIANT_KEY",
"ZYGOSITY", extract_chr_udf("VARIANT_KEY").alias("CHR_ID"))
binned_by_chr.show(truncate=False)
+---------+-----------+-----------------------+--------+------+
|SAMPLE_ID|STUDY_ID |VARIANT_KEY |ZYGOSITY|CHR_ID|
+---------+-----------+-----------------------+--------+------+
|S-100 |Prostate-1 |5:163697197:163697197:T|2 |chr5 |
|S-200 |Prostate-1 |5:3420488:3420488:C |1 |chr5 |
|S-100 |Genome-1000|3:107988242:107988242:T|1 |chr3 |
|S-200 |Genome-1000|3:54969706:54969706:T |3 |chr3 |
+---------+-----------+-----------------------+--------+------+
要创建第二级分箱,我们需要另一个 Python 函数来找到start_position % 101:
# 101 is the number of bins per chromosome
def create_modulo(variant_key):
tokens = variant_key.split(":")
start_position = int(tokens[1])
return start_position % 101
#end-def
然后,我们定义另一个 UDF 来使用此函数创建modulo列:
from pyspark.sql.functions import udf
from pyspark.sql.types import IntegerType
create_modulo_udf = udf(create_modulo, IntegerType())
binned_by_chr_and_position = df.select("SAMPLE_ID", "STUDY_ID", "VARIANT_KEY",
"ZYGOSITY", extract_chr_udf("VARIANT_KEY").alias("CHR_ID"),
create_modulo_udf("VARIANT_KEY").alias("modulo"))
binned_by_chr_and_position.show(truncate=False)
+---------+-----------+-----------------------+--------+------+------+
|SAMPLE_ID|STUDY_ID |VARIANT_KEY |ZYGOSITY|CHR_ID|modulo|
+---------+-----------+-----------------------+--------+------+------+
|S-100 |Prostate-1 |5:163697197:163697197:T|2 |chr5 |33 |
|S-200 |Prostate-1 |5:3420488:3420488:C |1 |chr5 |22 |
|S-100 |Genome-1000|3:107988242:107988242:T|1 |chr3 |52 |
|S-200 |Genome-1000|3:54969706:54969706:T |3 |chr3 |52 |
+---------+-----------+-----------------------+--------+------+------+
我们可以将 DataFrame 保存为 Parquet 格式,不进行分箱,如下所示:
binned_by_chr_and_position.write.mode("append")\
.parquet(*/tmp/genome1/*)
$ ls -l /tmp/genome1/
-rw-r--r-- ... 0 Jan 18 14:34 _SUCCESS
...
-rw-r--r-- ... 1382 Jan 18 14:34 part-00007-....snappy.parquet
或者,为了保存带有分箱信息的数据,我们可以使用partitionBy()函数:
binned_by_chr_and_position.write.mode("append")\
.partitionBy("CHR_ID", "modulo")\
.parquet(*/tmp/genome2/*)
$ ls -R /tmp/genome2/
CHR_ID=chr3 CHR_ID=chr5 _SUCCESS
/tmp/genome2//CHR_ID=chr3:
modulo=52
/tmp/genome2//CHR_ID=chr3/modulo=52:
part-00005-....snappy.parquet
part-00007-....snappy.parquet
/tmp/genome2//CHR_ID=chr5:
modulo=22 modulo=33
/tmp/genome2//CHR_ID=chr5/modulo=22:
part-00003-....snappy.parquet
/tmp/genome2//CHR_ID=chr5/modulo=33:
part-00001-....snappy.parquet
排序
数据记录的排序是许多编程语言中的常见任务,例如 Python 和 Java。排序是指任何按照系统化排列记录的过程,并且可以涉及排序(按某些标准排列顺序排列记录)或分类(具有相似属性的项目分组)。在排序中,可以按照正常的低到高(升序)或正常的高到低(降序)顺序进行排序。有许多著名的排序算法——如快速排序、冒泡排序和堆排序——具有不同的时间复杂度。
PySpark 提供了几个用于排序 RDD 和 DataFrame 的函数,以下是其中几个:
pyspark.RDD.repartitionAndSortWithinPartitions()
pyspark.RDD.sortBy()
pyspark.RDD.sortByKey()
pyspark.sql.DataFrame.sort()
pyspark.sql.DataFrame.sortWithinPartitions()
pyspark.sql.DataFrameWriter.sortBy()
使用这些排序函数非常简单。
总结
MapReduce 设计模式是数据分析中常见的模式。这些设计模式使我们能够以高效的方式解决类似的数据问题。
数据设计模式可以分为几个不同的类别,例如:
摘要模式
通过汇总和分组数据来获得顶层视图。例如,包括内映射组合(用于解决词频统计问题)和 MinMax。
过滤模式
使用谓词查看数据子集。一个示例是前 10 位模式。
数据组织模式
重新组织数据以便与其他系统配合使用,或者使 MapReduce 分析更容易。例如,包括分箱和排序算法。
连接模式
分析不同的数据集以发现有趣的关系。
元模式
将多个模式拼接在一起以解决多阶段问题,或在同一作业中执行多个分析。
输入和输出模式
自定义使用持久存储(如 HDFS 或 S3)加载或存储数据的方式。
在下一章中,我们将研究执行连接的设计模式,这是两个大数据集之间的重要转换。
第十一章:连接设计模式
在本章中,我们将探讨连接数据集的实用设计模式。与前几章一样,我将专注于在实际环境中有用的模式。PySpark 支持 RDD 和 DataFrames 的基本连接操作(pyspark.RDD.join()和pyspark.sql.DataFrame.join()),这将适用于大多数用例。但是,在某些情况下,这种连接可能会很昂贵,因此我还将展示一些特殊的连接算法,这些算法可能会很有用。
本章介绍了连接两个数据集的基本概念,并提供了一些有用和实用的连接设计模式示例。我将展示如何在 MapReduce 范式中实现连接操作以及如何使用 Spark 的转换来执行连接。您将看到如何使用 RDD 和 DataFrames 执行映射端连接,以及如何使用布隆过滤器执行高效连接。
连接操作介绍
在关系数据库世界中,连接两个具有共同键的表(也称为“关系”)——即一个或多个列中的属性或一组属性,这些属性允许唯一标识表中每个记录(元组或行)——是一个频繁的操作。
考虑以下两个表,T1 和 T2:
T1 = {(k1, v1)}
T2 = {(k2, v2)}
其中:
-
k1 是 T1 的键,v1 是关联的属性。
-
k2 是 T2 的键,v2 是关联的属性。
简单内连接会通过合并两个或多个表中具有匹配键的行来创建新表,定义如下:
T1.join(T2) = {(k, (v1, v2))}
T2.join(T1) = {(k, (v2, v1))}
其中:
-
k = k1 = k2。
-
(k, v1) 存在于 T1 中。
-
(k, v2) 存在于 T2 中。
为了说明其工作原理,让我们创建两个表,填充一些示例数据,然后进行连接。首先我们将创建我们的表,T1 和 T2:
>>> d1 = [('a', 10), ('a', 11), ('a', 12), ('b', 100), ('b', 200), ('c', 80)]
>>> T1 = spark.createDataFrame(d1, ['id', 'v1'])
>>> T1.show()
+---+---+
| id| v1|
+---+---+
| a| 10|
| a| 11|
| a| 12|
| b|100|
| b|200|
| c| 80|
+---+---+
>>> d2 = [('a', 40), ('a', 50), ('b', 300), ('b', 400), ('d', 90)]
>>> T2 = spark.createDataFrame(d2, ['id', 'v2'])
>>> T2.show()
+---+---+
| id| v2|
+---+---+
| a| 40|
| a| 50|
| b|300|
| b|400|
| d| 90|
+---+---+
然后我们将使用内连接将它们连接起来(Spark 中的默认连接类型)。请注意,由于在另一张表中找不到匹配行,具有id为c(来自T1)和d(来自T2)的行被丢弃:
>>> joined = T1.join(T2, (T1.id == T2.id))
>>> joined.show(100, truncate=False)
+---+---+---+---+
|id |v1 |id |v2 |
+---+---+---+---+
|a |10 |a |50 |
|a |10 |a |40 |
|a |11 |a |50 |
|a |11 |a |40 |
|a |12 |a |50 |
|a |12 |a |40 |
|b |100|b |400|
|b |100|b |300|
|b |200|b |400|
|b |200|b |300|
+---+---+---+---+
可以对具有共同键的两个表执行许多类型的连接操作,但在实践中,三种连接类型最为常见:
INNER JOIN(T1, T2)
在两个表 T1 和 T2 中结合记录,只要这些记录在两个表中具有匹配值的键。
LEFT JOIN(T1, T2)
返回左表(T1)的所有记录以及右表(T2)中匹配的记录。如果某个特定记录没有匹配项,则右表相应列中将会有NULL值。
RIGHT JOIN(T1, T2)
返回连接右侧表(T2)的所有行,并且左侧表(T1)中有匹配行的行。对于左侧没有匹配行的行,结果集将包含空值。
所有这些连接类型都受到 PySpark 的支持,以及一些其他不常用的类型。有关 PySpark 支持的不同连接类型的介绍,请参阅 Spark by {Examples} 网站上的教程“PySpark Join Types”。
连接两个表可能是一个昂贵的操作,因为它可能需要找到笛卡尔积(对于两个集合 A 和 B,所有有序对 (x, y) 其中 x 在 A 中且 y 在 B 中)。在刚刚展示的示例中,这不会成为问题,但考虑一个大数据示例:如果表 T1 有三十亿行,表 T2 有一百万行,那么这两个表的笛卡尔积将有三千兆(3 后跟 15 个零)个数据点。在本章中,我介绍了一些基本的设计模式,可以帮助简化连接操作,以降低这种成本。通常情况下,在选择和使用连接设计模式时,并没有银弹:务必使用真实数据测试您提出的解决方案的性能和可扩展性。
在 MapReduce 中进行连接
这一节是为了教学目的而呈现的,展示了在分布式计算环境中如何实现 join() 函数。假设我们有两个关系,R(k, b) 和 S(k, c),其中 k 是一个公共键,b 和 c 分别表示 R 和 S 的属性。我们如何找到 R 和 S 的连接?连接操作的目标是找到在它们的键 k 上一致的元组。R 和 S 的自然连接的 MapReduce 实现可以如下实现。首先,在映射阶段:
-
对于在
R中的元组 对于元组(k, b)在R中,以(k, ("R", b))的形式发出一个 (键, 值) 对。 -
对于元组
(k, c)在S中,以(k, ("S", c))的形式发出一个 (键, 值) 对。
然后,在减少阶段:
- 如果一个 reducer 键
k有值列表[("R", v),("S", w)],那么以(k, (v, w))的形式发出一个 (键, 值) 对。请注意,join(R, S)将产生(k, (v, w)),而join(S, R)将产生(k, (w, v))而join(S, R)将产生(k, (w, v))`。
因此,如果一个 reducer 键 k 有值列表 [("R", v1), ("R", v2), ("S", w1), ("S", w2)],那么我们将发出四个 (键, 值) 对:
(k, (v1, w1))
(k, (v1, w2))
(k, (v2, w1))
(k, (v2, w2))
因此,要在两个关系 R 和 S 之间执行自然连接,我们需要两个映射函数和一个 reducer 函数。
映射阶段
映射阶段有两个步骤:
-
映射关系
R:# key: relation R # value: (k, b) tuple in R map(key, value) { emit(k, ("R", b)) } -
映射关系
S:
# key: relation S
# value: (k, c) tuple in S
map(key, value) {
emit(k, ("S", c))
}
映射器的输出(作为排序和洗牌阶段的输入)将是:
(k1, "R", r1)
(k1, "R", r2)
...
(k1, "S", s1)
(k1, "S", s2)
...
(k2, "R", r3)
(k2, "R", r4)
...
(k2, "S", s3)
(k2, "S", s4)
...
减少器阶段
在编写一个 reducer 函数之前,我们需要理解 MapReduce 的神奇之处,这发生在排序和洗牌阶段。这类似于 SQL 的 GROUP BY 函数;一旦所有的映射器完成,它们的输出会被排序、洗牌,并作为输入发送给 reducer(s)。
在我们的示例中,排序和洗牌阶段的输出将是:
(k1, [("R", r1), ("R", r2), ..., ("S", s1), ("S", s2), ...]
(k2, [("R", r3), ("R", r4), ..., ("S", s3), ("S", s4), ...]
...
接下来是 reducer 函数。对于每个键 k,我们构建两个列表:list_R(将保存来自关系 R 的值/属性)和 list_S(将保存来自关系 S 的值/属性)。然后我们确定 list_R 和 list_S 的笛卡尔积,以找到连接元组(伪代码):
# key: a unique key
# values: [(relation, attrs)] where relation in {"R", "S"}
# and attrs are the relation attributes
reduce(key, values) {
list_R = []
list_S = []
for (tuple in values) {
relation = tuple[0]
attributes = tuple[1]
if (relation == "R") {
list_R.append(attributes)
}
else {
list_S.append(attributes)
}
}
if (len(list_R) == 0) OR (len(list_S) == 0) {
# no common key
return
}
# len(list_R) > 0 AND len(list_S) > 0
# perform Cartesian product of list_R and list_S
for (r in list_R) {
for (s in list_S) {
emit(key, (r, s))
}
}
}
在 PySpark 中的实现
本节展示了如何在 PySpark 中实现两个数据集的自然连接(带有一些共同的键),而不使用join()函数。我提出这个解决方案是为了展示 Spark 的强大之处,以及如何在需要时执行自定义连接。
假设我们有以下数据集,T1和T2:
d1 = [('a', 10), ('a', 11), ('a', 12), ('b', 100), ('b', 200), ('c', 80)]
d2 = [('a', 40), ('a', 50), ('b', 300), ('b', 400), ('d', 90)]
T1 = spark.sparkContext.parallelize(d1)
T2 = spark.sparkContext.parallelize(d2)
首先,我们将这些 RDDs 映射到包含关系名称的形式:
t1_mapped = T1.map(lambda x: (x[0], ("T1", x[1])))
t2_mapped = T2.map(lambda x: (x[0], ("T2", x[1])))
接下来,为了对 mapper 生成的(键,值)对执行缩减操作,我们将这两个数据集组合成单个数据集:
combined = t1_mapped.union(t2_mapped)
然后我们在一个单一的组合数据集上执行groupByKey()转换:
grouped = combined.groupByKey()
最后,我们找到每个grouped条目的值的笛卡尔积:
# entry[0]: key
# entry[1]: values as:
# [("T1", t11), ("T1", t12), ..., ("T2", t21), ("T2", t22), ...]
import itertools
def cartesian_product(entry):
T1 = []
T2 = []
key = entry[0]
values = entry[1]
for tuple in values:
relation = tuple[0]
attributes = tuple[1]
if (relation == "T1"): T1.append(attributes)
else: T2.append(attributes)
#end-for
if (len(T1) == 0) or (len(T2) == 0):
# no common key
return []
# len(T1) > 0 AND len(T2) > 0
joined_elements = []
# perform Cartesian product of T1 and T2
for element in itertools.product(T1, T2):
joined_elements.append((key, element))
#end-for
return joined_elements
#end-def
joined = grouped.flatMap(cartesian_product)
使用 RDD 进行 Map-Side Join
如我们所见,连接是一种可能昂贵的操作,用于基于它们之间的共同键组合来自两个(或更多)数据集的记录。在关系数据库中,索引可以帮助减少连接操作的成本;然而,像 Hadoop 和 Spark 这样的大数据引擎不支持数据索引。那么,我们可以做些什么来最小化两个分布式数据集之间连接的成本?在这里,我将介绍一种设计模式,它可以完全消除 MapReduce 范式中的洗牌和排序阶段:map-side join。
Map-side join 是一个过程,其中两个数据集由 mapper 而不是实际的连接函数(由 mapper 和 reducer 的组合执行)连接。除了减少洗牌和减少阶段中的排序和合并成本外,这还可以加快任务的执行速度,提高性能。
要帮助你理解这是如何运作的,我们从一个 SQL 示例开始。假设我们在 MySQL 数据库中有两个表,EMP和DEPT,我们想要对它们进行连接操作。这两个表的定义如下:
mysql> use testdb;
Database changed
mysql> select * from emp;
+--------+----------+---------+
| emp_id | emp_name | dept_id |
+--------+----------+---------+
| 1000 | alex | 10 |
| 2000 | ted | 10 |
| 3000 | mat | 20 |
| 4000 | max | 20 |
| 5000 | joe | 10 |
+--------+----------+---------+
5 rows in set (0.00 sec)
mysql> select * from dept;
+---------+------------+---------------+
| dept_id | dept_name | dept_location |
+---------+------------+---------------+
| 10 | ACCOUNTING | NEW YORK, NY |
| 20 | RESEARCH | DALLAS, TX |
| 30 | SALES | CHICAGO, IL |
| 40 | OPERATIONS | BOSTON, MA |
| 50 | MARKETING | Sunnyvale, CA |
| 60 | SOFTWARE | Stanford, CA |
+---------+------------+---------------+
6 rows in set (0.00 sec)
然后,我们使用INNER JOIN在dept_id键上连接两个表:
mysql> select e.emp_id, e.emp_name, e.dept_id, d.dept_name, d.dept_location
from emp e, dept d
where e.dept_id = d.dept_id;
+--------+----------+---------+------------+---------------+
| emp_id | emp_name | dept_id | dept_name | dept_location |
+--------+----------+---------+------------+---------------+
| 1000 | alex | 10 | ACCOUNTING | NEW YORK, NY |
| 2000 | ted | 10 | ACCOUNTING | NEW YORK, NY |
| 5000 | joe | 10 | ACCOUNTING | NEW YORK, NY |
| 3000 | mat | 20 | RESEARCH | DALLAS, TX |
| 4000 | max | 20 | RESEARCH | DALLAS, TX |
+--------+----------+---------+------------+---------------+
5 rows in set (0.00 sec)
Map-side join 类似于 SQL 中的内连接,但任务仅由 mapper 执行(请注意,内连接和 map-side join 的结果必须相同)。
一般来说,在大数据集上进行连接是昂贵的,但很少希望将一个大表A的全部内容与另一个大表B的全部内容连接起来。给定两个表A和B,当表A(称为事实表)很大而表B(维度表)是小到中等规模时,map-side join 将是最合适的。为了执行这种类型的连接,我们首先从B创建一个哈希表,并将其广播到所有节点。接下来,我们通过 mapper 迭代表A的所有元素,然后通过广播的哈希表访问表B中的相关信息。
为了演示,我们将从我们的EMP和DEPT表创建两个 RDDs。首先,我们将EMP创建为RDD[(dept_id, (emp_id, emp_name))]:
EMP = spark.sparkContext.parallelize(
[
(10, (1000, 'alex')),
(10, (2000, 'ted')),
(20, (3000, 'mat')),
(20, (4000, 'max')),
(10, (5000, 'joe'))
])
接下来,我们将DEPT创建为RDD[(dept_id, (dept_name, dept_location))]:
DEPT= spark.sparkContext.parallelize(
[ (10, ('ACCOUNTING', 'NEW YORK, NY')),
(20, ('RESEARCH', 'DALLAS, TX')),
(30, ('SALES', 'CHICAGO, IL')),
(40, ('OPERATIONS', 'BOSTON, MA')),
(50, ('MARKETING', 'Sunnyvale, CA')),
(60, ('SOFTWARE', 'Stanford, CA'))
])
EMP和DEPT具有共同的键dept_id,所以我们可以如下连接这两个 RDDs:
>>> sorted(EMP.join(DEPT).collect())
[
(10, ((1000, 'alex'), ('ACCOUNTING', 'NEW YORK, NY'))),
(10, ((2000, 'ted'), ('ACCOUNTING', 'NEW YORK, NY'))),
(10, ((5000, 'joe'), ('ACCOUNTING', 'NEW YORK, NY'))),
(20, ((3000, 'mat'), ('RESEARCH', 'DALLAS, TX'))),
(20, ((4000, 'max'), ('RESEARCH', 'DALLAS, TX')))
]
地图端连接如何优化此任务?假设EMP是一个大数据集,而DEPT是一个相对较小的数据集。使用地图端连接在dept_id上将EMP与DEPT连接时,我们将从小表创建广播变量(使用自定义函数to_hash_table()):
# build a dictionary of (key, value),
# where key = dept_id
# value = (dept_name , dept_location)
def to_hash_table(dept_as_list):
hast_table = {}
for d in dept_as_list:
dept_id = d[0]
dept_name_location = d[1]
hash_table[dept_id] = dept_name_location
return hash_table
#end-def
dept_hash_table = to_hash_table(DEPT.collect())
或者,您可以使用 Spark 操作collectAsMap()构建哈希表,该操作将此 RDD(DEPT)中的(键,值)对作为字典返回到主节点:
dept_hash_table = DEPT.collectAsMap()
现在,使用pyspark.SparkContext.broadcast(),我们可以将只读变量dept_hash_table广播到 Spark 集群,使其在各种转换(包括 mapper 和 reducer)中可用:
sc = spark.sparkContext
hash_table_broadcasted = sc.broadcast(dept_hash_table)
为了执行地图端连接,在 mapper 中我们可以通过以下方式访问此变量:
dept_hash_table = hash_table_broadcasted.value
使用如下定义的map_side_join()函数:
# e as an element of EMP RDD
def map_side_join(e):
dept_id = e[0]
# get hash_table from broadcasted object
hash_table = hash_table_broadcasted.value
dept_name_location = hash_table[dept_id]
return (e, dept_name_location)
#end-def
然后,我们可以使用map()转换执行连接:
joined = EMP.map(map_side_join)
这使我们能够不洗牌维度表(即DEPT),并获得相当良好的连接性能。
通过地图端连接,我们只需使用map()函数迭代EMP表的每一行,并从广播的哈希表中检索维度值(如dept_name和dept_location)。map()函数将并行执行每个分区,每个分区将拥有自己的哈希表副本。
总结一下,地图端连接方法具有以下重要优势:
-
通过将较小的 RDD/表作为广播变量,从而避免洗牌,减少连接操作的成本,最小化需要在洗牌和减少阶段进行排序和合并的数据量。
-
通过避免大量网络 I/O 来提高连接操作的性能。其主要缺点是,地图端连接设计模式仅在希望执行连接操作的 RDD/表之一足够小,可以放入内存时才适合使用。如果两个表都很大,则不适合选择此方法。
使用 DataFrame 进行地图端连接
正如我在前面的部分中讨论的那样,当其中一个表(事实表)很大而另一个(维度表)足够小以广播时,地图端连接是有意义的。
在以下示例中(受到 Dmitry Tolpeko 文章“Spark 中的地图端连接”的启发),我将展示如何使用 DataFrame 与广播变量实现地图端连接。假设我们有表 11-1 所示的事实表,以及表 11-2 和表 11-3 所示的两个维度表。
表 11-1. 航班(事实表)
| from | to | airline | flight_number | departure |
|---|---|---|---|---|
| DTW | ORD | SW | 225 | 17:10 |
| DTW | JFK | SW | 355 | 8:20 |
| SEA | JFK | DL | 418 | 7:00 |
| SFO | LAX | AA | 1250 | 7:05 |
| SFO | JFK | VX | 12 | 7:05 |
| JFK | LAX | DL | 424 | 7:10 |
| LAX | SEA | DL | 5737 | 7:10 |
表 11-2. 机场(维度表)
| code | name | city | state |
|---|---|---|---|
| DTW | 底特律机场 | 底特律 | 密歇根州 |
| ORD | 芝加哥奥黑尔 | 芝加哥 | 伊利诺伊州 |
| JFK | 约翰·肯尼迪机场 | 纽约 | 纽约州 |
| LAX | 洛杉矶机场 | 洛杉矶 | 加利福尼亚州 |
| SEA | 西雅图-塔科马机场 | 西雅图 | 华盛顿州 |
| SFO | 旧金山机场 | 旧金山 | 加利福尼亚州 |
Table 11-3. Airlines(维度表)
| 代码 | 航空公司名称 |
|---|---|
| SW | 西南航空 |
| AA | 美国航空 |
| DL | 三角洲航空 |
| VX | 维珍美国航空 |
我们的目标是扩展Flights表,用实际的航空公司名称替换航空公司代码,并用实际的机场名称替换机场代码。这一操作需要将事实表Flights与两个维度表(Airports和Airlines)进行连接。由于维度表足够小以适应内存,我们可以将其广播到所有工作节点的所有映射器上。Table 11-4 显示了所需的连接输出。
Table 11-4. 连接后的表
| 出发城市 | 到达城市 | 航空公司 | 航班号 | 出发时间 |
|---|---|---|---|---|
| 底特律 | 芝加哥 | 西南航空 | 225 | 17:10 |
| 底特律 | 纽约 | 西南航空 | 355 | 8:20 |
| 西雅图 | 纽约 | 三角洲航空 | 418 | 7:00 |
| 旧金山 | 洛杉矶 | 美国航空 | 1250 | 7:05 |
| 旧金山 | 纽约 | 维珍美国航空 | 12 | 7:05 |
| 纽约 | 洛杉矶 | 三角洲航空 | 424 | 7:10 |
| 洛杉矶 | 西雅图 | 三角洲航空 | 5737 | 7:10 |
要实现这个结果,我们需要执行以下步骤:
-
为
Airports创建广播变量。首先,我们从Airports表创建一个 RDD,并将其保存为dict[(key, value)],其中 key 是机场代码,value 是机场名称。 -
为
Airlines创建广播变量。接下来,我们从Airlines表创建一个 RDD,并将其保存为dict[(key, value)],其中 key 是航空公司代码,value 是航空公司名称。 -
从
Flights表创建一个 DataFrame,以与步骤 1 和 2 中创建的缓存广播变量进行连接。 -
映射
FlightsDataFrame 的每条记录,并通过在步骤 1 和 2 中创建的缓存字典中查找值进行简单连接。
接下来,我将讨论另一种设计模式,使用布隆过滤器进行连接,可用于高效地连接两个表。
- 创建机场缓存的步骤
这一步将Airports表(作为字典)创建为广播变量,并缓存在所有工作节点上:
>>> airports_data = [
... ("DTW", "Detroit Airport", "Detroit", "MI"),
... ("ORD", "Chicago O'Hare", "Chicago", "IL"),
... ("JFK", "John F. Kennedy Int. Airport", "New York", "NY"),
... ("LAX", "Los Angeles Int. Airport", "Los Angeles", "CA"),
... ("SEA", "Seattle-Tacoma Int. Airport", "Seattle", "WA"),
... ("SFO", "San Francisco Int. Airport", "San Francisco", "CA")
... ]
>>>
>>> airports_rdd = spark.sparkContext.parallelize(airports_data)\
... .map(lambda tuple4: (tuple4[0], (tuple4[1],tuple4[2],tuple4[3])))
>>> airports_dict = airports_rdd.collectAsMap()
>>>
>>> airports_cache = spark.sparkContext.broadcast(airports_dict)
>>> airports_cache.value
{'DTW': ('Detroit Airport', 'Detroit', 'MI'),
'ORD': ("Chicago O'Hare", 'Chicago', 'IL'),
'JFK': ('John F. Kennedy Int. Airport', 'New York', 'NY'),
'LAX': ('Los Angeles Int. Airport', 'Los Angeles', 'CA'),
'SEA': ('Seattle-Tacoma Int. Airport', 'Seattle', 'WA'),
'SFO': ('San Francisco Int. Airport', 'San Francisco', 'CA')}
Step 2: 为航空公司创建缓存
这一步将Airlines表创建为广播变量,并缓存在所有工作节点上:
>>> airlines_data = [
... ("SW", "Southwest Airlines"),
... ("AA", "American Airlines"),
... ("DL", "Delta Airlines"),
... ("VX", "Virgin America")
... ]
>>> airlines_rdd = spark.sparkContext.parallelize(airlines_data)\
... .map(lambda tuple2: (tuple2[0], tuple2[1]))
>>> airlines_dict = airlines_rdd.collectAsMap()
>>> airlines_cache = spark.sparkContext.broadcast(airlines_dict)
>>> airlines_cache
>>> airlines_cache.value
{'SW': 'Southwest Airlines',
'AA': 'American Airlines',
'DL': 'Delta Airlines',
'VX': 'Virgin America'}
Step 3: 创建事实表
这一步将Flights表创建为 DataFrame,并将其用作事实表,与步骤 1 和 2 中创建的缓存字典进行连接:
>>> flights_data = [
... ("DTW", "ORD", "SW", "225", "17:10"),
... ("DTW", "JFK", "SW", "355", "8:20"),
... ("SEA", "JFK", "DL", "418", "7:00"),
... ("SFO", "LAX", "AA", "1250", "7:05"),
... ("SFO", "JFK", "VX", "12", "7:05"),
... ("JFK", "LAX", "DL", "424", "7:10"),
... ("LAX", "SEA", "DL", "5737", "7:10")
... ]
>>> flight_columns = ["from", "to", "airline", "flight_number", "departure"]
>>> flights = spark.createDataFrame(flights_data, flight_columns)
>>> flights.show(truncate=False)
+----+---+-------+-------------+---------+
|from|to |airline|flight_number|departure|
+----+---+-------+-------------+---------+
|DTW |ORD|SW |225 |17:10 |
|DTW |JFK|SW |355 |8:20 |
|SEA |JFK|DL |418 |7:00 |
|SFO |LAX|AA |1250 |7:05 |
|SFO |JFK|VX |12 |7:05 |
|JFK |LAX|DL |424 |7:10 |
|LAX |SEA|DL |5737 |7:10 |
+----+---+-------+-------------+---------+
Step 4: 应用映射端连接
最后,我们迭代事实表并执行映射端连接:
>>> from pyspark.sql.functions import udf
>>> from pyspark.sql.types import StringType
>>>
>>> def get_airport(code):
... return airports_cache.value[code][1]
...
>>> def get_airline(code):
... return airlines_cache.value[code]
>>> airport_udf = udf(get_airport, StringType())
...
>>> airport_udf = udf(get_airport, StringType())
>>>
>>> flights.select(
airport_udf("from").alias("from_city"), 
airport_udf("to").alias("to_city"), 
airline_udf("airline").alias("airline_name"), 
"flight_number", "departure").show(truncate=False)
+-------------+-----------+------------------+-------------+---------+
|from_city |to_city |airline_name |flight_number|departure|
+-------------+-----------+------------------+-------------+---------+
|Detroit |Chicago |Southwest Airlines|225 |17:10 |
|Detroit |New York |Southwest Airlines|355 |8:20 |
|Seattle |New York |Delta Airlines |418 |7:00 |
|San Francisco|Los Angeles|American Airlines |1250 |7:05 |
|San Francisco|New York |Virgin America |12 |7:05 |
|New York |Los Angeles|Delta Airlines |424 |7:10 |
|Los Angeles |Seattle |Delta Airlines |5737 |7:10 |
+-------------+-----------+------------------+-------------+---------+
机场的映射端连接
机场的地图端连接
航空公司的地图端连接
使用布隆过滤器进行高效连接
给定两个 RDD,一个较大的RDD[(K, V)]和一个较小的RDD[(K, W)],Spark 允许我们在键K上执行连接操作。在使用 Spark 时,连接两个 RDD 是一种常见操作。在某些情况下,连接被用作过滤的一种形式:例如,如果您想对RDD[(K, V)]中的记录子集执行操作,这些记录由另一个RDD[(K, W)]中的实体表示,您可以使用内连接来实现这种效果。然而,您可能更喜欢避免连接操作引入的洗牌,特别是如果您想要用于过滤的RDD[(K, W)]显著小于您将对其进行进一步计算的主要RDD[(K, V)]。
您可以使用广播连接(使用作为布隆过滤器的集合)来执行过滤,但这需要将希望按其收集的较小 RDD 整体收集到驱动程序内存中,即使它相对较小(几千或几百万条记录),这仍可能导致一些不希望的内存压力。如果要避免连接操作引入的洗牌,则可以使用布隆过滤器。这将连接RDD[(K, V)]与从较小的RDD[(K, W)]构建的布隆过滤器之间的问题简化为一个简单的map()转换,其中我们检查键K是否存在于布隆过滤器中。
布隆过滤器介绍
布隆过滤器是一种空间高效的概率数据结构,可用于测试元素是否为集合的成员。它可能对不实际为集合成员的元素返回 true(即可能存在误报),但对于确实在集合中的元素,它不会返回 false;查询将返回“可能在集合中”或“绝对不在集合中”。可以向集合添加元素,但不能删除。随着向集合添加的元素越来越多,误报的概率就越大。
简而言之,我们可以总结布隆过滤器的属性如下:
-
给定一个大集合
S = {x[1], x[2], …, x[n]},布隆过滤器是一种概率、快速且空间高效的缓存生成器。它不会存储集合中的项本身,并且使用的空间比理论上正确存储数据所需的空间少;这是其潜在不准确性的根源。 -
它基本上近似于集合成员操作,并尝试回答“项目
x是否存在于集合S中?”的问题。 -
它允许误报。这意味着对于某些不在集合中的
x,布隆过滤器可能会指示x在集合中。 -
它不允许误报。这意味着如果
x在集合中,布隆过滤器永远不会指示x不在集合中。
为了更清晰地表达,让我们看一个简单的关系或表之间的连接示例。假设我们想要在共同键 K 上连接 R=RDD(K, V) 和 S=RDD(K, W)。进一步假设以下内容为真:
count(R) = 1000,000,000 (larger dataset)
count(S) = 10,000,000 (smaller dataset)
要进行基本连接,我们需要检查 10 万亿(10¹²)条记录,这是一个庞大且耗时的过程。减少连接操作所需的时间和复杂性的一种方法是在关系 S 上使用布隆过滤器(较小的数据集),然后在关系 R 上使用构建好的布隆过滤器数据结构。这可以消除 R 中不必要的记录(可能将其大小减少到 20,000,000 条记录),使连接更快速和高效。
现在,让我们半正式地定义布隆过滤器数据结构。我们如何构建一个?假阳性错误的概率是多少,以及我们如何降低其概率?这就是布隆过滤器的工作原理。给定集合 S = {x[1], x[2], …, x[n]}:
-
让
B成为一个m位数组(m> 1),初始化为 0。B的元素是B[0],B[1],B[2], …,B[m-1]。存储数组B所需的内存量仅为存储整个集合S所需内存量的一小部分。假阳性的概率与位向量(数组B)的大小成反比。 -
让
{H[1], H[2], …, H[k]}成为一组k个哈希函数。如果Hi = a,则设置B[a] = 1。您可以使用 SHA1、MD5 和 Murmer 作为哈希函数。例如:-
Hi = MD5(x+i) -
Hi = MD5(x || i)
-
-
要检查
x是否S,检查Hi的B。所有k值必须为1。 -
可能会出现假阳性,其中所有
k值均为1,但x不在S中。假阳性的概率为: -
什么是最优的哈希函数数量?对于给定的 m(选择用于布隆过滤器的位数)和 n(数据集的大小),使假阳性概率最小的 k 值(哈希函数的数量)是(ln 代表“自然对数”):
-
因此,特定位被翻转为 1 的概率是:
接下来,让我们看一个布隆过滤器的例子。
一个简单的布隆过滤器示例
这个示例展示了如何在大小为 10 的布隆过滤器(m = 10)上插入元素并执行查询,使用三个哈希函数 H = {H[1], H[2], H[3]},其中 H(x) 表示这三个哈希函数的结果。我们从一个初始化为 0 的长度为 10 位的数组 B 开始:
Array B:
initialized:
index 0 1 2 3 4 5 6 7 8 9
value 0 0 0 0 0 0 0 0 0 0
insert element a, H(a) = (2, 5, 6)
index 0 1 2 3 4 5 6 7 8 9
value 0 0 1 0 0 1 1 0 0 0
insert element b, H(b) = (1, 5, 8)
index 0 1 2 3 4 5 6 7 8 9
value 0 1 1 0 0 1 1 0 1 0
query element c
H(c) = (5, 8, 9) => c is not a member (since B[9]=0)
query element d
H(d) = (2, 5, 8) => d is a member (False Positive)
query element e
H(e) = (1, 2, 6) => e is a member (False Positive)
query element f
H(f) = (2, 5, 6) => f is a member (Positive)
Python 中的布隆过滤器
下面的代码段展示了如何在 Python 中创建和使用布隆过滤器(您可以自己编写布隆过滤器库,但通常情况下,如果已经存在库,则应该使用它):
# instantiate BloomFilter with custom settings
>>> from bloom_filter import BloomFilter
>>> bloom = BloomFilter(max_elements=100000, error_rate=0.01)
# Test whether the Bloom-filter has seen a key
>>> "test-key" in bloom
False
# Mark the key as seen
>>> bloom.add("test-key")
# Now check again
>>> "test-key" in bloom
True
在 PySpark 中使用布隆过滤器
布隆过滤器是一种小型、紧凑且快速的用于集合成员测试的数据结构。它可以用于促进两个 RDD/关系/表的连接,如 R(K, V) 和 S(K, W),其中一个关系具有大量记录,而另一个关系具有较少的记录(例如,R 可能有 10 亿条记录,而 S 可能有 1000 万条记录)。
在键字段 K 上执行传统的 R 和 S 的连接操作将花费很长时间且效率低下。我们可以通过将关系 S(K, W) 构建为布隆过滤器,并使用构建的数据结构(使用 Spark 的广播机制)来测试 R(K, V) 中的值是否属于其中来加快速度。请注意,为了减少 PySpark 作业的 I/O 成本,我们在映射任务中使用布隆过滤器来进行减少端连接优化。如何实现呢?以下步骤展示了如何在映射器中使用布隆过滤器(表示 S 的数据结构),来替代 R 和 S 之间的连接操作:
-
构建布隆过滤器,使用两个关系/表中较小的一个。初始化布隆过滤器(创建
BloomFilter的实例),然后使用BloomFilter.add()来构建数据结构。我们将构建好的布隆过滤器称为the_bloom_filter。 -
广播构建好的布隆过滤器。使用
SparkContext.broadcast()将the_bloom_filter广播到所有工作节点,这样它就可以在所有 Spark 转换(包括映射器)中使用:# to broadcast it to all worker nodes for read-only purposes sc = spark.sparkContext broadcasted_bloom_filter = sc.broadcast(the_bloom_filter) -
在映射器中使用广播对象。现在,我们可以使用布隆过滤器来消除
R中不需要的元素:# e is an element of R(k, b) def bloom_filter_function(e): # get a copy of the Bloom filter the_bloom_filter = broadcasted_bloom_filter.value() # use the_bloom_filter for element e key = e[0] if key in the_bloom_filter: return True else: return False #end-def
我们使用 bloom_filter_function() 对 R=RDD[(K, V)] 进行处理,只保留键在 S=RDD[(K, W)] 中的元素:
# R=RDD[(K, V)]
# joined = RDD[(K, V)] where K is in S=RDD[(K, W)]
joined = R.filter(bloom_filter_function)
总结
本章介绍了在优化连接操作成本至关重要的情况下可以使用的一些设计模式。我向您展示了如何在 MapReduce 范式中实现连接,并呈现了映射端连接,它将连接操作简化为一个简单的映射器和一个对构建字典的查找操作(避免了实际的 join() 函数)。这种设计模式完全消除了将任何数据洗牌到减少阶段的需要。然后我向您展示了使用布隆过滤器作为过滤操作的更有效替代方法。正如您所见,通过使用布隆过滤器,可以避免连接操作所导致的洗牌操作。接下来,我们将通过查看特征工程的设计模式来结束本书。
第十二章:特征工程在 PySpark 中
本章涵盖了在构建机器学习模型时处理数据特征(任何可测量的属性,从汽车价格到基因值、血红蛋白计数或教育水平)的设计模式(也称为特征工程)。在构建有效的机器学习模型时,提取、转换和选择特征是必不可少的过程。特征工程是机器学习中最重要的主题之一,因为模型在预测未来时成功与否主要取决于您选择的特征。
Spark 提供了广泛的机器学习 API,涵盖了许多著名的算法,包括线性回归、逻辑回归和决策树。本章的目标是介绍 PySpark 中的基础工具和技术,您可以使用这些工具构建各种机器学习流水线。本章介绍了 Spark 强大的机器学习工具和实用程序,并提供了使用 PySpark API 的示例。在这里学到的技能对于有志成为数据科学家或数据工程师的人来说非常有用。我的目标不是让您熟悉著名的机器学习算法,如线性回归、主成分分析或支持向量机,因为这些内容在许多书籍中已经涵盖,而是为您提供一些工具(如归一化、标准化、字符串索引等),您可以用这些工具来清洗数据并为各种机器学习算法构建模型。
无论您使用哪种算法,特征工程都是重要的。机器学习使我们能够在数据中找到模式——我们通过构建模型找到这些模式,然后使用构建的模型对新数据点(即查询数据)进行预测。为了正确预测,我们必须正确构建数据集并转换数据。本章涵盖了这两个关键步骤。
我们将讨论的主题包括:
-
添加新的派生特征
-
创建和应用用户定义函数(UDF)
-
创建流水线
-
数据二值化
-
数据插补
-
Tokenization
-
标准化
-
归一化
-
String indexing
-
Vector assembly
-
分桶
-
对数变换
-
独热编码
-
TF-IDF
-
特征哈希
-
应用 SQL 转换
不过,首先让我们更深入地研究特征工程。
特征工程介绍
在他出色的博客文章中,Jason Brownlee 将特征工程定义为“将原始数据转换为更好地代表预测模型底层问题的特征的过程,从而在未见数据上提高模型准确性”。在本章中,我的目标是介绍 PySpark 中通用的特征工程技术,您可以使用这些技术来构建更好的预测模型。
假设您的数据表示为行和列的矩阵。在机器学习中,列被称为特征(如年龄、性别、教育、心率或血压),每行代表数据集的一个实例(即记录)。数据中的特征将直接影响您构建和使用的预测模型以及您可以实现的结果。数据科学家大约一半的时间都花在数据准备上,特征工程是其中重要的一部分。
特征工程在构建机器学习模型中的位置是什么?何时将这些技术应用于您的数据?让我们来看看构建和使用机器学习模型的关键步骤:
-
收集机器学习数据的要求并定义问题。
-
选择数据(收集和整合数据,然后将其反规范化为数据集)。
-
预处理数据(格式化、清理和采样数据,以便您可以处理它)。
-
转换数据(执行特征工程)。
-
对数据建模(将数据拆分为训练集和测试集,使用训练数据创建模型,然后使用测试数据评估和调整模型)。
-
使用构建的模型对查询数据进行预测。
特征工程发生在从数据构建模型之前。在选择和清理数据之后(例如,确保空值被替换为适当的值),通过执行特征工程来转换数据:这可能涉及将字符串转换为数值数据、对数据进行分桶、对数据进行标准化或归一化等操作。
本章涵盖的整体过程的部分如图 Figure 12-1 所示。

图 12-1. 特征工程
Spark API 提供了多种用于处理特征的算法,大致分为以下几类:
-
提取(从“原始”数据中提取特征的算法)
-
转换(用于缩放、转换或修改特征的算法)
-
选择(从更大的特征集中选择子集的算法)
-
局部敏感哈希(LSH);用于对相似项进行分组的算法)
数据转换和特征工程有许多原因,可以是强制性的,也可以是可选的:
强制转换
这些转换是为了解决问题(如构建机器学习模型)而必要的,以保证数据的兼容性。例如:
-
将非数值特征转换为数值特征。例如,如果一个特征具有非数值值,则无法进行平均、求和和中位数计算;同样,我们无法对字符串执行矩阵乘法,而必须首先将其转换为某种数值表示。
-
将输入调整为固定大小。一些线性模型和前馈神经网络具有固定数量的输入节点,因此你的输入数据必须始终具有相同的大小。例如,图像模型需要将其数据集中的图像重塑为固定大小。
可选的转换
可选的数据转换可能有助于机器学习模型表现更好。这些转换可能包括:
-
在应用其他数据转换之前将文本转换为小写。
-
分词和去除非必要的词,比如“of”、“a”、“and”、“the”和“so”
-
对数值特征进行归一化
我们将在接下来的章节中检查这两种类型。让我们深入探讨我们的第一个主题,添加一个新特征。
添加新特征
有时你想要添加一个新的衍生特征(因为你的机器学习算法需要这个衍生特征)到你的数据集中,为了向你的数据集添加一个新列或特征,你可以使用DataFrame.withColumn()函数。这个概念在下面进行了演示:
# SparkSession available as 'spark'
>>> column_names = ["emp_id", "salary"]
>>> records = [(100, 120000), (200, 170000), (300, 150000)]
>>> df = spark.createDataFrame(records, column_names)
>>> df.show()
+------+------+
|emp_id|salary|
+------+------+
| 100|120000|
| 200|170000|
| 300|150000|
+------+------+
你可以使用 Spark 的DataFrame.withColumn()来添加一个新列/特征:
>>> df2 = df.withColumn("bonus", df.salary * 0.05)
>>> df2.show()
+------+------+------+
|emp_id|salary| bonus|
+------+------+------+
| 100|120000|6000.0|
| 200|170000|8500.0|
| 300|150000|7500.0|
+------+------+------+
应用 UDFs
如果 PySpark 没有提供你需要的函数,你可以定义自己的 Python 函数,并使用spark.udf.register()将它们注册为 Spark SQL 的 DSL 中的用户定义函数(UDFs)。然后,你可以在你的数据转换中应用这些函数。
要使你的 Python 函数与 Spark 的 DataFrame 兼容,你需要通过将它们传递给pyspark.sql.functions.udf()函数来将它们转换为 PySpark UDFs。或者,你可以使用注解一步创建你的 UDF,如下所示。将udf@作为你的 Python 函数的“装饰器”,并将其返回类型作为参数指定:
from pyspark.sql.functions import udf
>>> @udf("integer") 
... def tripled(num):
... return 3*int(num)
...
>>> df2 = df.withColumn('tripled_col', tripled(df.salary))
>>> df2.show()
+------+------+-----------+
|emp_id|salary|tripled_col| 
+------+------+-----------+
| 100|120000| 360000|
| 200|170000| 510000|
| 300|150000| 450000|
+------+------+-----------+
函数tripled()是一个 UDF,其返回类型是integer。
tripled_col是一个衍生特征。
请注意,如果你的特征表示为 RDD(其中每个 RDD 元素表示特征的一个实例),你可以使用RDD.map()函数向你的特征集添加一个新特征。
创建流水线
在机器学习算法中,你可以将几个阶段连接在一起并按顺序运行它们。考虑三个阶段,称为{Stage-1, Stage-2, Stage-3},其中Stage-1的输出被用作Stage-2的输入,Stage-2的输出被用作Stage-3的输入。这三个阶段形成一个简单的流水线。假设我们必须按照显示在 Table 12-1 中的顺序转换数据。
表 12-1. 流水线阶段
| Stage | 描述 |
|---|---|
Stage-1 |
对dept列进行标签编码或字符串索引(创建dept_index列)。 |
Stage-2 |
对education列进行标签编码或字符串索引(创建education_index列)。 |
Stage-3 |
对索引列education_index进行独热编码(创建education_OHE列)。 |
Spark 提供了一个 pipeline API,定义为pyspark.ml.Pipeline(*, stages=None),它作为一个 estimator(在数据集上拟合模型的学习算法的抽象)。根据 Spark 的文档:
一个
Pipeline由一系列阶段组成,每个阶段都是一个Estimator或Transformer。当调用Pipeline.fit()时,阶段将按顺序执行。如果阶段是一个Estimator,则会在输入数据集上调用其Estimator.fit()方法来拟合模型。然后这个模型,即一个 transformer,将被用来转换数据集作为下一个阶段的输入。如果阶段是一个Transformer,则会调用其Transformer.transform()方法来生成下一个阶段的数据集。从Pipeline中拟合的模型是一个PipelineModel,它包含了拟合的模型和 transformers,对应于 pipeline 的各个阶段。如果阶段是一个空列表,则 pipeline 充当一个 identity transformer。
为了说明 pipeline 的概念,首先我们将创建一个包含三列用作输入数据的示例 DataFrame,如下所示,然后我们将使用pyspark.ml.Pipeline()创建一个简单的 pipeline:
# spark: an instance of SparkSession
# create a DataFrame
df = spark.createDataFrame([
(1, 'CS', 'MS'),
(2, 'MATH', 'PHD'),
(3, 'MATH', 'MS'),
(4, 'CS', 'MS'),
(5, 'CS', 'PHD'),
(6, 'ECON', 'BS'),
(7, 'ECON', 'BS'),
], ['id', 'dept', 'education'])
我们可以使用df.show()来查看我们的样本数据:
>>> df.show()
+---+----+---------+
| id|dept|education|
+---+----+---------+
| 1| CS| MS|
| 2|MATH| PHD|
| 3|MATH| MS|
| 4| CS| MS|
| 5| CS| PHD|
| 6|ECON| BS|
| 7|ECON| BS|
+---+----+---------+
现在我们已经创建了 DataFrame,假设我们想通过三个定义的阶段 {stage_1, stage_2, stage_3} 转换数据。在每个阶段中,我们将传递输入和输出列名称,并通过将定义的阶段作为列表传递给Pipeline对象来设置 pipeline。
Spark 的 pipeline 模型然后按顺序执行特定步骤,并给出最终的期望结果。图 12-2 显示了我们将定义的 pipeline。

图 12-2. 一个包含三个阶段的示例 pipeline
这三个阶段的实现如下:
from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer
from pyspark.ml.feature import OneHotEncoder
# Stage 1: transform the `dept` column to numeric
stage_1 = StringIndexer(inputCol= 'dept', outputCol= 'dept_index')
#
# Stage 2: transform the `education` column to numeric
stage_2 = StringIndexer(inputCol= 'education', outputCol= 'education_index')
#
# Stage 3: one-hot encode the numeric column `education_index`
stage_3 = OneHotEncoder(inputCols=['education_index'],
outputCols=['education_OHE'])
接下来,我们将定义包含这三个阶段的 pipeline:
# set up the pipeline: glue the stages together
pipeline = Pipeline(stages=[stage_1, stage_2, stage_3])
# fit the pipeline model and transform the data as defined
pipeline_model = pipeline.fit(df)
# view the transformed data
final_df = pipeline_model.transform(df)
final_df.show(truncate=False)
+---+----+---------+----------+---------------+-------------+
|id |dept|education|dept_index|education_index|education_OHE|
+---+----+---------+----------+---------------+-------------+
|1 |CS |MS |0.0 |0.0 |(2,[0],[1.0])|
|2 |MATH|PHD |2.0 |2.0 |(2,[],[]) |
|3 |MATH|MS |2.0 |0.0 |(2,[0],[1.0])|
|4 |CS |MS |0.0 |0.0 |(2,[0],[1.0])|
|5 |CS |PHD |0.0 |2.0 |(2,[],[]) |
|6 |ECON|BS |1.0 |1.0 |(2,[1],[1.0])|
|7 |ECON|BS |1.0 |1.0 |(2,[1],[1.0])|
+---+----+---------+----------+---------------+-------------+
数据二值化
二值化数据意味着根据某个阈值将特征值设置为 0 或 1。大于阈值的值映射为 1,小于或等于阈值的值映射为 0。使用默认阈值 0,只有正值映射为 1。因此,二值化是将数值特征阈值化为二进制 {0, 1} 特征的过程。
Spark 的Binarizer接受参数inputCol和outputCol,以及二值化的threshold。大于阈值的特征值被二值化为1.0;小于或等于阈值的特征值被二值化为0.0。
首先,让我们创建一个只有单个特征的 DataFrame:
from pyspark.ml.feature import Binarizer
raw_df = spark.createDataFrame([
(1, 0.1),
(2, 0.2),
(3, 0.5),
(4, 0.8),
(5, 0.9),
(6, 1.1)
], ["id", "feature"])
接下来,我们将创建一个Binarizer,设置threshold=0.5,因此任何小于或等于0.5的值将映射为0.0,任何大于0.5的值将映射为1.0:
>>> from pyspark.ml.feature import Binarizer
>>> binarizer = Binarizer(threshold=0.5, inputCol="feature",
outputCol="binarized_feature")
最后,我们将定义的Binarizer应用于特征列:
binarized_df = binarizer.transform(raw_df)
>>> print("Binarizer output with Threshold = %f" % binarizer.getThreshold())
Binarizer output with Threshold = 0.500000
>>> binarized_df = binarizer.transform(raw_df)
>>> binarized_df.show(truncate=False)
+---+-------+-----------------+
|id |feature|binarized_feature|
+---+-------+-----------------+
|1 |0.1 |0.0 |
|2 |0.2 |0.0 |
|3 |0.5 |0.0 |
|4 |0.8 |1.0 |
|5 |0.9 |1.0 |
|6 |1.1 |1.0 |
+---+-------+-----------------+
补全
Spark 的 Imputer 是用于填充缺失值的填充转换器。现实世界的数据集通常包含缺失值,通常编码为 null、空白、NaN或其他占位符。有许多方法可以处理这些值,包括以下方法:
-
如果存在任何缺失的特征,则删除实例(这可能不是一个好主意,因为会丢失其他特征的重要信息)。
-
对于缺失的特征,找到该特征的平均值并填充该值。
-
对缺失值进行填充(即从数据的已知部分推断出缺失值)。这通常是最佳策略。
Spark 的 Imputer 具有以下签名:
class pyspark.ml.feature.Imputer(*, strategy='mean', missingValue=nan,
inputCols=None, outputCols=None,
inputCol=None, outputCol=None,
relativeError=0.001)
它使用所在列的均值或中位数来填补缺失值。输入列应为数值型;目前 Imputer 不支持分类特征,可能会为分类特征创建不正确的值。
注意,在过滤掉缺失值后计算均值/中位数/模式值。输入列中的所有 null 值都视为缺失值,因此也会被填充。对于计算中位数,使用 pyspark.sql.DataFrame.approxQuantile() 并设定相对误差为 0.001。
您可以指示 imputer 通过 .setMissingValue(*custom_value*) 来填充除 NaN 以外的自定义值。例如,.setMissingValue(0) 告诉它填充所有 0 的出现次数(再次强调,输入列中的 null 值将被视为缺失并进行填充)。
下面的例子展示了如何使用 imputer。假设我们有一个包含三列 id、col1 和 col2 的 DataFrame:
>>> df = spark.createDataFrame([
... (1, 12.0, 5.0),
... (2, 7.0, 10.0),
... (3, 10.0, 12.0),
... (4, 5.0, float("nan")),
... (5, 6.0, None),
... (6, float("nan"), float("nan")),
... (7, None, None)
... ], ["id", "col1", "col2"])
>>> df.show(truncate=False)
+---+----+----+
|id |col1|col2|
+---+----+----+
|1 |12.0|5.0 |
|2 |7.0 |10.0|
|3 |10.0|12.0|
|4 |5.0 |NaN |
|5 |6.0 |null|
|6 |NaN |NaN |
|7 |null|null|
+---+----+----+
接下来,让我们创建一个 imputer 并将其应用于我们创建的数据:
>>> from pyspark.ml.feature import Imputer
>>> imputer = Imputer(inputCols=["col1", "col2"],
outputCols=["col1_out", "col2_out"])
>>> model = imputer.fit(df)
>>> transformed = model.transform(df)
>>> transformed.show(truncate=False)
+---+----+----+--------+--------+
|id |col1|col2|col1_out|col2_out|
+---+----+----+--------+--------+
|1 |12.0|5.0 |12.0 |5.0 |
|2 |7.0 |10.0|7.0 |10.0 |
|3 |10.0|12.0|10.0 |12.0 |
|4 |5.0 |NaN |5.0 |9.0 |
|5 |6.0 |null|6.0 |9.0 |
|6 |NaN |NaN |8.0 |9.0 |
|7 |null|null|8.0 |9.0 |
+---+----+----+--------+--------+
我们是如何得到用于缺失值的这些数字(col1 的 8.0 和 col2 的 9.0)?很简单;因为默认策略是“均值”,我们只需计算每列的平均值并将其用于缺失值:
col1: (12.0+7.0+10.0+5.0+6.0) / 5 = 40 / 5 = 8.0
col2: (5.0+10.0+12.0) / 3 = 27.0 / 3 = 9.0
根据您的数据需求,您可能希望使用不同的策略来填充缺失值。您可以指示 imputer 使用可用特征值的中位数代替缺失值,如下所示:
>>> imputer.setStrategy("median")
>>> model = imputer.fit(df)
>>> transformed = model.transform(df)
>>> transformed.show(truncate=False)
+---+----+----+--------+--------+
|id |col1|col2|col1_out|col2_out|
+---+----+----+--------+--------+
|1 |12.0|5.0 |12.0 |5.0 |
|2 |7.0 |10.0|7.0 |10.0 |
|3 |10.0|12.0|10.0 |12.0 |
|4 |5.0 |NaN |5.0 |10.0 |
|5 |6.0 |null|6.0 |10.0 |
|6 |NaN |NaN |7.0 |10.0 |
|7 |null|null|7.0 |10.0 |
+---+----+----+--------+--------+
要获取这些值(col1 的 7.0 和 col2 的 10.0),我们只需计算每列的中位数值:
median(col1) =
median(12.0, 7.0, 10.0, 5.0, 6.0) =
median(5.0, 6.0, 7.0, 10.0, 12.0) =
7.0
median(col2) =
median(5.0, 10.0, 12.0) =
10.0
分词
分词算法用于将短语、句子、段落或整个文本文档分割成较小的单元,如单词、二元组或术语。这些较小的单元称为 tokens。例如,词法分析器(编译器编写中使用的算法)通过移除任何空格或注释,将编程代码分割成一系列 tokens。因此,您可以将分词更普遍地理解为将字符串拆分为任何有意义的 tokens 的过程。
在 Spark 中,您可以使用 Tokenizer 和 RegexTokenizer(通过正则表达式定义自定义分词策略)来对字符串进行分词。
分词器
Spark 的Tokenizer是一个将输入字符串转换为小写并按空格分割的分词器。为了展示它的工作原理,让我们创建一些示例数据:
>>> docs = [(1, "a Fox jumped over FOX"),
(2, "RED of fox jumped")]
>>> df = spark.createDataFrame(docs, ["id", "text"])
>>> df.show(truncate=False)
+---+---------------------+
|id |text |
+---+---------------------+
|1 |a Fox jumped over FOX|
|2 |RED of fox jumped |
+---+---------------------+
然后应用Tokenizer:
>>> tokenizer = Tokenizer(inputCol="text", outputCol="tokens")
>>> tokenized = tokenizer.transform(df)
>>> tokenized.select("text", "tokens")
.withColumn("tokens_length", countTokens(col("tokens")))
.show(truncate=False)
+---------------------+---------------------------+-------------+
|text |tokens |tokens_length|
+---------------------+---------------------------+-------------+
|a Fox jumped over FOX|[a, fox, jumped, over, fox]|5 |
|RED of fox jumped |[red, of, fox, jumped] |4 |
+---------------------+---------------------------+-------------+
正则表达式分词器(RegexTokenizer)
Spark 的RegexTokenizer是基于正则表达式的分词器,它通过使用提供的正则表达式模式拆分文本(默认情况)或反复匹配正则表达式(如果可选的gaps参数,默认值为True,为False)来提取令牌。下面是一个示例:
>>> regexTokenizer = RegexTokenizer(inputCol="text", outputCol="tokens",
pattern="\\W", minTokenLength=3)
>>> regex_tokenized = regexTokenizer.transform(df)
>>> regex_tokenized.select("text", "tokens")
.withColumn("tokens_length", countTokens(col("tokens")))
.show(truncate=False)
+---------------------+------------------------+-------------+
|text |tokens |tokens_length|
+---------------------+------------------------+-------------+
|a Fox jumped over FOX|[fox, jumped, over, fox]|4 |
|RED of fox jumped |[red, fox, jumped] |3 |
+---------------------+------------------------+-------------+
管道中的分词
我们还可以作为管道的一部分执行分词。这里,我们创建一个包含两列的 DataFrame:
>>> docs = [(1, "a Fox jumped, over, the fence?"),
(2, "a RED, of fox?")]
>>> df = spark.createDataFrame(docs, ["id", "text"])
>>> df.show(truncate=False)
+---+------------------------------+
|id |text |
+---+------------------------------+
|1 |a Fox jumped, over, the fence?|
|2 |a RED, of fox? |
+---+------------------------------+
接下来,我们对这个 DataFrame 应用RegexTokenizer()函数:
>>> tk = RegexTokenizer(pattern=r'(?:\p{Punct}|\s)+', inputCol="text",
outputCol='text2')
>>> sw = StopWordsRemover(inputCol='text2', outputCol='text3')
>>> pipeline = Pipeline(stages=[tk, sw])
>>> df4 = pipeline.fit(df).transform(df)
>>> df4.show(truncate=False)
+---+----------------+-----------------+--------------+
|id | text |text2 |text3 |
+---+----------------+-----------------+--------------+
|1 |a Fox jumped, |[a, fox, jumped, |[fox, jumped, |
| |over, the fence?|over, the, fence]| fence] |
|2 |a RED, of fox? |[a, red, of, fox]|[red, fox] |
+---+----------------+-----------------+--------------+
标准化
在构建模型之前,最流行的数值数据缩放技术之一是标准化。标准化数据集涉及重新缩放值的分布,使观察值的均值(作为特征)为0.00,标准差为1.00。
许多机器学习算法在数值输入变量(特征)缩放到标准范围时表现更好。例如,使用输入加权和的线性回归算法和使用距离度量的 k 最近邻算法需要标准化值,否则构建的模型可能会欠拟合或过拟合训练数据,并且性能较差。
一个值的标准化公式如下:
y = (x – mean) / standard_deviation
均值的计 均值的计算公式为:
mean = sum(x) / count(x)
标准差的计算公式为:
standard_deviation = sqrt(sum( (x – mean)² ) / count(x))
例如,若X = (1, 3, 6, 10),则均值/平均值计算为:
mean = (1+2+6+10)/4 = 20/4 = 5.0
标准差的计算公式为:
standard_deviation
= sqrt ( ((1-5)² + (3-5)² + (6-5)² + (10-5)²)) / 4)
= sqrt ((16+4+1+25)/4)
= sqrt(46/4)
= sqrt(11.5) = 3.39116
所以,新标准化的值将是:
y = (y1, y2, y3, y4) = (-1.1795, -0.5897, 0.2948, 1.4744)
其中:
y1 = (1 – 5.0) / 3.39116
y2 = (3 - 5.0) / 3.39116
y3 = (6 - 5.0) / 3.39116
y4 = (10 - 5.0) / 3.39116
如你所见,标准化值(y)的均值为0.00,标准差为1.00。
让我们来看看如何在 PySpark 中执行标准化。假设我们试图将 DataFrame 中的一列进行标准化(均值 = 0.00,标准差 = 1.00)。首先,我们将创建一个示例 DataFrame,然后我将向你展示两种标准化age列的方法:
features = [('alex', 1), ('bob', 3), ('ali', 6), ('dave', 10)]
columns = ("name", "age")
samples = spark.createDataFrame(features, columns)
>>> samples.show()
+----+---+
|name|age|
+----+---+
|alex| 1|
| bob| 3|
| ali| 6|
|dave| 10|
+----+---+
方法 1 是使用 DataFrame 函数:
>>> from pyspark.sql.functions import stddev, mean, col
>>> (samples.select(mean("age").alias("mean_age"),
... stddev("age").alias("stddev_age"))
... .crossJoin(samples)
... .withColumn("age_scaled",
(col("age") - col("mean_age")) / col("stddev_age")))
... .show(truncate=False)
+--------+------------------+----+---+-------------------+
|mean_age|stddev_age |name|age|age_scaled |
+--------+------------------+----+---+-------------------+
|5.0 |3.9157800414902435|alex|1 |-1.0215078369104984|
|5.0 |3.9157800414902435|bob |3 |-0.5107539184552492|
|5.0 |3.9157800414902435|ali |6 |0.2553769592276246 |
|5.0 |3.9157800414902435|dave|10 |1.276884796138123 |
+--------+------------------+----+---+-------------------+
或者,我们也可以这样写:
>>> mean_age, sttdev_age = samples.select(mean("age"), stddev("age"))
.first()
>>> samples.withColumn("age_scaled",
(col("age") - mean_age) / sttdev_age).show(truncate=False)
+----+---+-------------------+
|name|age|age_scaled |
+----+---+-------------------+
|alex|1 |-1.0215078369104984|
|bob |3 |-0.5107539184552492|
|ali |6 |0.2553769592276246 |
|dave|10 |1.276884796138123 |
+----+---+-------------------+
方法 2 是使用 PySpark 的ml包中的函数。这里,我们使用pyspark.ml.feature.VectorAssembler()将age列转换为向量,然后使用 Spark 的StandardScaler对值进行标准化:
>>> from pyspark.ml.feature import VectorAssembler
>>> from pyspark.ml.feature import StandardScaler
>>> vecAssembler = VectorAssembler(inputCols=['age'], outputCol="age_vector")
>>> samples2 = vecAssembler.transform(samples)
>>> samples2.show()
+----+---+----------+
|name|age|age_vector|
+----+---+----------+
|alex| 1| [1.0]|
| bob| 3| [3.0]|
| ali| 6| [6.0]|
|dave| 10| [10.0]|
+----+---+----------+
>>> scaler = StandardScaler(inputCol="age_vector", outputCol="age_scaled",
... withStd=True, withMean=True)
>>> scalerModel = scaler.fit(samples2)
>>> scaledData = scalerModel.transform(samples2)
>>> scaledData.show(truncate=False)
+----+---+----------+---------------------+
|name|age|age_vector|age_scaled |
+----+---+----------+---------------------+
|alex|1 |[1.0] |[-1.0215078369104984]|
|bob |3 |[3.0] |[-0.5107539184552492]|
|ali |6 |[6.0] |[0.2553769592276246] |
|dave|10 |[10.0] |[1.276884796138123] |
+----+---+----------+---------------------+
与我们接下来要讨论的归一化不同,标准化在数据服从高斯分布的情况下非常有帮助。它也没有边界范围,因此如果数据中存在离群值,标准化不会受到影响。
归一化
归一化是机器学习中常用的数据准备技术之一。归一化的目标是将数据集中数值列的值更改为一个公共的尺度,而不失真值的差异或丢失信息。归一化将每个数值输入变量分别缩放到范围[0,1]内,这是浮点值的范围,具有最高的精度。换句话说,特征值被移动和重新缩放,以便它们最终在0.00到1.00之间。这种技术也称为min-max scaling,Spark 提供了一个名为MinMaxScaler的转换器用于此目的。
这是归一化的公式:
注意X[max]和X[min]是给定特征X[i]的最大值和最小值,分别。
为了说明归一化过程,让我们创建一个具有三个特征的 DataFrame:
>>> df = spark.createDataFrame([ (100, 77560, 45),
(200, 41560, 23),
(300, 30285, 20),
(400, 10345, 6),
(500, 88000, 50)
], ["user_id", "revenue","num_of_days"])
>>> print("Before Scaling :")
>>> df.show(5)
+-------+-------+-----------+
|user_id|revenue|num_of_days|
+-------+-------+-----------+
| 100| 77560| 45|
| 200| 41560| 23|
| 300| 30285| 20|
| 400| 10345| 6|
| 500| 88000| 50|
+-------+-------+-----------+
接下来,我们将MinMaxScaler应用于我们的特征:
from pyspark.ml.feature import MinMaxScaler
from pyspark.ml.feature import VectorAssembler
from pyspark.ml import Pipeline
from pyspark.sql.functions import udf
from pyspark.sql.types import DoubleType
# UDF for converting column type from vector to double type
unlist = udf(lambda x: round(float(list(x)[0]),3), DoubleType())
# Iterating over columns to be scaled
for i in ["revenue","num_of_days"]:
# VectorAssembler transformation - Converting column to vector type
assembler = VectorAssembler(inputCols=[i],outputCol=i+"_Vect")
# MinMaxScaler transformation
scaler = MinMaxScaler(inputCol=i+"_Vect", outputCol=i+"_Scaled")
# Pipeline of VectorAssembler and MinMaxScaler
pipeline = Pipeline(stages=[assembler, scaler])
# Fitting pipeline on DataFrame
df = pipeline.fit(df).transform(df)
.withColumn(i+"_Scaled", unlist(i+"_Scaled")).drop(i+"_Vect")
After scaling, we can create and execute the following pipelines:
for i in ["revenue","num_of_days"]:
assembler = VectorAssembler(inputCols=[i], outputCol=i+"_Vect")
scaler = MinMaxScaler(inputCol=i+"_Vect", outputCol=i+"_Scaled")
pipeline = Pipeline(stages=[assembler, scaler])
df = pipeline.fit(df)
.transform(df)
.withColumn(i+"_Scaled", unlist(i+"_Scaled"))
.drop(i+"_Vect")
并查看缩放后的值:
>>> df.show(5)
+-------+-------+-----------+--------------+------------------+
|user_id|revenue|num_of_days|revenue_Scaled|num_of_days_Scaled|
+-------+-------+-----------+--------------+------------------+
| 100| 77560| 45| 0.866| 0.886|
| 200| 41560| 23| 0.402| 0.386|
| 300| 30285| 20| 0.257| 0.318|
| 400| 10345| 6| 0.0| 0.0|
| 500| 88000| 50| 1.0| 1.0|
+-------+-------+-----------+--------------+------------------+
当你知道你的数据不遵循高斯分布时,归一化是一个好的技术。这在不假设数据分布的算法中非常有用,例如线性回归、k-最近邻和神经网络。在接下来的几节中,我们将通过几个更多的例子来演示。
在管道中使用 MinMaxScaler 来缩放列
与标记化一样,我们可以在管道中应用归一化。首先,让我们定义一组特征:
>>> from pyspark.ml.feature import MinMaxScaler
>>> from pyspark.ml import Pipeline
>>> from pyspark.ml.feature import VectorAssembler
>>> triplets = [(0, 1, 100), (1, 2, 200), (2, 5, 1000)]
>>> df = spark.createDataFrame(triplets, ['x', 'y', 'z'])
>>> df.show()
+---+---+----+
| x| y| z|
+---+---+----+
| 0| 1| 100|
| 1| 2| 200|
| 2| 5|1000|
+---+---+----+
现在我们可以按照以下方式在管道中应用MinMaxScaler来归一化特征(列)x的值:
>>> assembler = VectorAssembler(inputCols=["x"], outputCol="x_vector")
>>> scaler = MinMaxScaler(inputCol="x_vector", outputCol="x_scaled")
>>> pipeline = Pipeline(stages=[assembler, scaler])
>>> scalerModel = pipeline.fit(df)
>>> scaledData = scalerModel.transform(df)
>>> scaledData.show(truncate=False)
+---+---+----+--------+--------+
|x |y |z |x_vector|x_scaled|
+---+---+----+--------+--------+
|0 |1 |100 |[0.0] |[0.0] |
|1 |2 |200 |[1.0] |[0.5] |
|2 |5 |1000|[2.0] |[1.0] |
+---+---+----+--------+--------+
在多列上使用 MinMaxScaler
我们还可以在多列上应用缩放器(如MinMaxScaler):
>>> triplets = [(0, 1, 100), (1, 2, 200), (2, 5, 1000)]
>>> df = spark.createDataFrame(triplets, ['x', 'y', 'z'])
>>> df.show()
+---+---+----+
| x| y| z|
+---+---+----+
| 0| 1| 100|
| 1| 2| 200|
| 2| 5|1000|
+---+---+----+
>>> from pyspark.ml import Pipeline
>>> from pyspark.ml.feature import MinMaxScaler
>>> columns_to_scale = ["x", "y", "z"]
>>> assemblers = [VectorAssembler(inputCols=[col],
outputCol=col + "_vector") for col in columns_to_scale]
>>> scalers = [MinMaxScaler(inputCol=col + "_vector",
outputCol=col + "_scaled") for col in columns_to_scale]
>>> pipeline = Pipeline(stages=assemblers + scalers)
>>> scalerModel = pipeline.fit(df)
>>> scaledData = scalerModel.transform(df)
>>> scaledData.show(truncate=False)
+---+---+----+--------+--------+--------+--------+--------+--------------------+
|x |y |z |x_vector|y_vector|z_vector|x_scaled|y_scaled|z_scaled |
+---+---+----+--------+--------+--------+--------+--------+--------------------+
|0 |1 |100 |[0.0] |[1.0] |[100.0] |[0.0] |[0.0] |[0.0] |
|1 |2 |200 |[1.0] |[2.0] |[200.0] |[0.5] |[0.25] |[0.1111111111111111]|
|2 |5 |1000|[2.0] |[5.0] |[1000.0]|[1.0] |[1.0] |[1.0] |
+---+---+----+--------+--------+--------+--------+--------+--------------------+
您可以进行一些后处理来恢复原始列名:
from pyspark.sql import functions as f
names = {x + "_scaled": x for x in columns_to_scale}
scaledData = scaledData.select([f.col(c).alias(names[c]) for c in names.keys()])
输出将是:
>>> scaledData.show()
+------+-----+--------------------+
| y| x| z|
+------+-----+--------------------+
| [0.0]|[0.0]| [0.0]|
|[0.25]|[0.5]|[0.1111111111111111]|
| [1.0]|[1.0]| [1.0]|
+------+-----+--------------------+
使用 Normalizer 进行归一化
Spark 的Normalizer将一个Vector行数据集转换为单位范数(即长度为 1)的Vector。它接受来自用户的参数p,表示 p-范数。例如,您可以设置p=1来使用曼哈顿范数(或曼哈顿距离),或者p=2来使用欧几里得范数:
L1: z = || x ||1 = sum(|xi|) for i=1, ..., n
L2: z = || x ||2 = sqrt(sum(xi²)) for i=1,..., n
from pyspark.ml.feature import Normalizer
# Create an object of the class Normalizer
ManhattanDistance=Normalizer().setP(1)
.setInputCol("features").setOutputCol("Manhattan Distance")
EuclideanDistance=Normalizer().setP(2)
.setInputCol("features").setOutputCol("Euclidean Distance")
# Transform
ManhattanDistance.transform(scaleDF).show()
+---+--------------+--------------------+
| id| features| Manhattan Distance|
+---+--------------+--------------------+
| 0|[1.0,0.1,-1.0]|[0.47619047619047...|
| 1| [2.0,1.1,1.0]|[0.48780487804878...|
| 0|[1.0,0.1,-1.0]|[0.47619047619047...|
| 1| [2.0,1.1,1.0]|[0.48780487804878...|
| 1|[3.0,10.1,3.0]|[0.18633540372670...|
+---+--------------+--------------------+
EuclideanDistance.transform(scaleDF).show()
+---+--------------+--------------------+
| id| features| Euclidean Distance|
+---+--------------+--------------------+
| 0|[1.0,0.1,-1.0]|[0.70534561585859...|
| 1| [2.0,1.1,1.0]|[0.80257235390512...|
| 0|[1.0,0.1,-1.0]|[0.70534561585859...|
| 1| [2.0,1.1,1.0]|[0.80257235390512...|
| 1|[3.0,10.1,3.0]|[0.27384986857909...|
+---+--------------+--------------------+
字符串索引
大多数机器学习算法要求将分类特征(例如字符串)转换为数值特征。字符串索引是将字符串转换为数值的过程。
Spark 的StringIndexer是一个标签索引器,它将标签的字符串列映射到标签索引的列。如果输入列是数值型的,我们将其转换为字符串并索引字符串值。索引的范围为[0, numLabels)。默认情况下,它们按照标签频率降序排序,因此最频繁的标签得到索引0。排序行为由设置stringOrderType选项来控制。
将 StringIndexer 应用于单列
假设我们有以下 PySpark DataFrame:
+-------+--------------+----+----+
|address| date|name|food|
+-------+--------------+----+----+
|1111111|20151122045510| Yin|gre |
|1111111|20151122045501| Yin|gre |
|1111111|20151122045500| Yln|gra |
|1111112|20151122065832| Yun|ddd |
|1111113|20160101003221| Yan|fdf |
|1111111|20160703045231| Yin|gre |
|1111114|20150419134543| Yin|fdf |
|1111115|20151123174302| Yen|ddd |
|2111115| 20123192| Yen|gre |
+-------+--------------+----+----+
如果我们想要将其转换为适用于pyspark.ml,我们可以使用 Spark 的StringIndexer将name列转换为数值列,如下所示:
>>> indexer = StringIndexer(inputCol="name", outputCol="name_index").fit(df)
>>> df_ind = indexer.transform(df)
>>> df_ind.show()
+-------+--------------+----+----------+----+
|address| date|name|name_index|food|
+-------+--------------+----+----------+----+
|1111111|20151122045510| Yin| 0.0|gre |
|1111111|20151122045501| Yin| 0.0|gre |
|1111111|20151122045500| Yln| 2.0|gra |
|1111112|20151122065832| Yun| 4.0|ddd |
|1111113|20160101003221| Yan| 3.0|fdf |
|1111111|20160703045231| Yin| 0.0|gre |
|1111114|20150419134543| Yin| 0.0|fdf |
|1111115|20151123174302| Yen| 1.0|ddd |
|2111115| 20123192| Yen| 1.0|gre |
+-------+--------------+----+----------+----+
将 StringIndexer 应用于多个列
如果我们想要一次将StringIndexer应用于多个列,简单的方法是将多个StringIndex组合在list()函数中,并使用Pipeline来执行它们所有:
from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer
indexers = [ StringIndexer(inputCol=column, outputCol=column+"_index").fit(df)
for column in list(set(df.columns)-set(['date'])) ]
pipeline = Pipeline(stages=indexers)
df_indexed = pipeline.fit(df).transform(df)
df_indexed.show()
+-------+--------------+----+----+----------+----------+-------------+
|address| date|food|name|food_index|name_index|address_index|
+-------+--------------+----+----+----------+----------+-------------+
|1111111|20151122045510| gre| Yin| 0.0| 0.0| 0.0|
|1111111|20151122045501| gra| Yin| 2.0| 0.0| 0.0|
|1111111|20151122045500| gre| Yln| 0.0| 2.0| 0.0|
|1111112|20151122065832| gre| Yun| 0.0| 4.0| 3.0|
|1111113|20160101003221| gre| Yan| 0.0| 3.0| 1.0|
|1111111|20160703045231| gre| Yin| 0.0| 0.0| 0.0|
|1111114|20150419134543| gre| Yin| 0.0| 0.0| 5.0|
|1111115|20151123174302| ddd| Yen| 1.0| 1.0| 2.0|
|2111115| 20123192| ddd| Yen| 1.0| 1.0| 4.0|
+-------+--------------+----+----+----------+----------+-------------+
接下来,我将更深入地探讨VectorAssembler,介绍在“标准化”中引入的内容。
向量组装
VectorAssembler的主要功能是将一组特征连接成一个单独的向量,可以传递给估算器或机器学习算法。换句话说,它是一个特征转换器,将多个列合并为一个单独的向量列。假设我们有以下 DataFrame:
>>> df.show()
+----+----+----+
|col1|col2|col3|
+----+----+----+
| 7.0| 8.0| 9.0|
| 1.1| 1.2| 1.3|
| 4.0| 5.0| 6.0|
| 2| 3| 4|
| 5.0| NaN|null|
+----+----+----+
我们可以将VectorAssembler应用于这三个特征(col1、col2和col3),并将它们合并为一个名为features的向量列,如下所示:
from pyspark.ml.feature import VectorAssembler
input_columns = ["col1", "col2", "col3"]
assembler = VectorAssembler(inputCols=input_columns, outputCol="features")
# use the transform() method to transform the dataset into a vector
transformed = assembler.transform(df)
transformed.show()
+----+----+----+-------------+
|col1|col2|col3| features|
+----+----+----+-------------+
| 7.0| 8.0| 9.0|[7.0,8.0,9.0]|
| 1.1| 1.2| 1.3|[1.1,1.2,1.3]|
| 4.0| 5.0| 6.0|[4.0,5.0,6.0]|
| 2| 3| 4|[2.0,3.0,4.0]|
| 5.0| NaN|null|[5.0,NaN,NaN]|
+----+----+----+-------------+
如果您想要跳过具有NaN或null值的行,您可以使用VectorAssembler.setParams(handleInvalid="skip")来实现:
>>> assembler2 = VectorAssembler(inputCols=input_columns, outputCol="features")
.setParams(handleInvalid="skip")
>>> assembler2.transform(df).show()
+----+----+----+-------------+
|col1|col2|col3| features|
+----+----+----+-------------+
| 7.0| 8.0| 9.0|[7.0,8.0,9.0]|
| 1.1| 1.2| 1.3|[1.1,1.2,1.3]|
| 4.0| 5.0| 6.0|[4.0,5.0,6.0]|
| 2| 3| 4|[2.0,3.0,4.0]|
+----+----+----+-------------+
分桶
数据分桶,也称为离散分桶或分箱,是一种用于减少小观察误差影响的数据预处理技术。通过这种技术,原始数据值落入给定小间隔(桶)中的数据被替换为该间隔的代表值,通常是中心值。例如,如果您有汽车价格数据,其中值广泛分布,您可能更喜欢使用分桶而不是实际的个别汽车价格。
Spark 的 Bucketizer 将连续特征列转换为特征桶列,其中桶由用户指定。
考虑这个例子:纬度和住房价值之间没有线性关系,但您可能会怀疑单独的纬度和住房价值之间存在关系。为了探索这一点,您可以将纬度进行分桶处理,创建如下的桶:
Bin-1: 32 < latitude <= 33
Bin-2: 33 < latitude <= 34
...
分桶技术可以应用于分类和数值数据。表 12-2 展示了一个数值分桶示例,表 12-3 展示了一个分类分桶示例。
表 12-2. 数值分桶示例
| 值 | 分箱 |
|---|---|
| 0-10 | 非常低 |
| 11-30 | 低 |
| 31-70 | 中等 |
| 71-90 | 高 |
| 91-100 | 非常高 |
表 12-3. 分类分桶示例
| 值 | 分箱 |
|---|---|
| 印度 | 亚洲 |
| 中国 | 亚洲 |
| 日本 | 亚洲 |
| 西班牙 | 欧洲 |
| 意大利 | 欧洲 |
| 智利 | 南美洲 |
| 巴西 | 南美洲 |
分桶也用于基因组数据:我们将人类基因组染色体(1、2、3、...、22、X、Y、MT)进行分桶处理。例如,染色体 1 有 2.5 亿个位置,我们可以将其分成 101 个桶,如下所示:
for id in (1, 2, 3, ..., 22, X, Y, MT):
chr_position = (chromosome-<id> position)
# chr_position range is from 1 to 250,000,000
bucket = chr_position % 101
# where
# 0 =< bucket <= 100
Bucketizer
桶装是将连续变量转换为分类变量的最直接方法。为了说明,让我们看一个例子。在 PySpark 中,通过Bucketizer类可以轻松实现分桶任务。第一步是定义桶边界;然后,我们创建Bucketizer类的对象,并应用transform()方法到我们的 DataFrame 上。
首先,让我们为演示目的创建一个示例 DataFrame:
>>> data = [('A', -99.99), ('B', -0.5), ('C', -0.3),
... ('D', 0.0), ('E', 0.7), ('F', 99.99)]
>>>
>>> dataframe = spark.createDataFrame(data, ["id", "features"])
>>> dataframe.show()
+---+--------+
| id|features|
+---+--------+
| A| -99.99|
| B| -0.5|
| C| -0.3|
| D| 0.0|
| E| 0.7|
| F| 99.99|
+---+--------+
接下来,我们定义我们的桶边界,并应用Bucketizer来创建桶:
>>> bucket_borders=[-float("inf"), -0.5, 0.0, 0.5, float("inf")]
>>> from pyspark.ml.feature import Bucketizer
>>> bucketer = Bucketizer().setSplits(bucket_borders)
.setInputCol("features").setOutputCol("bucket")
>>> bucketer.transform(dataframe).show()
+---+--------+------+
| id|features|bucket|
+---+--------+------+
| A| -99.99| 0.0|
| B| -0.5| 1.0|
| C| -0.3| 1.0|
| D| 0.0| 2.0|
| E| 0.7| 3.0|
| F| 99.99| 3.0|
+---+--------+------+
分位数分箱器
Spark 的QuantileDiscretizer接受一个带有连续特征的列,并输出一个带有分箱分类特征的列。分箱数由numBuckets参数设置,桶分割基于数据确定。如果输入数据中的唯一值太少,无法创建足够的分位数(即数据集的段),则使用的桶数量可能小于指定值。
您可以像这样同时使用Bucketizer和QuantileDiscretizer:
>>> from pyspark.ml.feature import Bucketizer
>>> from pyspark.ml.feature import QuantileDiscretizer
>>> data = [(0, 18.0), (1, 19.0), (2, 8.0), (3, 5.0), (4, 2.2)]
>>> df = spark.createDataFrame(data, ["id", "hour"])
>>> print(df.show())
+---+----+
| id|hour|
+---+----+
| 0|18.0|
| 1|19.0|
| 2| 8.0|
| 3| 5.0|
| 4| 2.2|
+---+----+
>>> qds = QuantileDiscretizer(numBuckets=5, inputCol="hour",
outputCol="buckets", relativeError=0.01, handleInvalid="error")
>>> bucketizer = qds.fit(df)
>>> bucketizer.setHandleInvalid("skip").transform(df).show()
+---+----+-------+
| id|hour|buckets|
+---+----+-------+
| 0|18.0| 3.0|
| 1|19.0| 3.0|
| 2| 8.0| 2.0|
| 3| 5.0| 2.0|
| 4| 2.2| 1.0|
+---+----+-------+
对数变换
简而言之,对数(通常表示为log)变换压缩了大数的范围并扩展了小数的范围。在数学中,对数是幂运算的反函数,定义为(其中b称为基数):
在特征工程中,对数变换是最常用的数学变换之一。它帮助我们通过将异常值强制拉近到均值附近来处理偏斜数据,使数据分布更接近正态(例如,数值 4,000 的自然对数或以 e 为底的对数是 8.2940496401)。这种归一化减少了异常值的影响,有助于使机器学习模型更加健壮。
对数只对除了 1 之外的正数定义(0、1 和负数不能可靠地作为幂函数的底数)。处理负数和零值的常见技术是在应用对数变换之前向数据添加一个常数(例如,log(x+1))。
Spark 提供了以任意基数定义的对数函数,定义如下:
pyspark.sql.functions.log(arg1, arg2=None)
Description: Returns the first argument-based logarithm
of the second argument. If there is only one argument,
then this takes the natural logarithm of the argument.
其用法在以下示例中说明。首先,我们创建一个 DataFrame:
>>> data = [('gene1', 1.2), ('gene2', 3.4), ('gene1', 3.5), ('gene2', 12.6)]
>>> df = spark.createDataFrame(data, ["gene", "value"])
>>> df.show()
+-----+-----+
| gene|value|
+-----+-----+
|gene1| 1.2|
|gene2| 3.4|
|gene1| 3.5|
|gene2| 12.6|
+-----+-----+
然后,我们在标记为value的特征上应用对数变换:
>>> from pyspark.sql.functions import log
>>> df.withColumn("base-10", log(10.0, df.value))
.withColumn("base-e", log(df.value)).show()
+-----+-----+------------------+------------------+
| gene|value| base-10| base-e|
+-----+-----+------------------+------------------+
|gene1| 1.2|0.0791812460476248|0.1823215567939546|
|gene2| 3.4| 0.531478917042255|1.2237754316221157|
|gene1| 3.5|0.5440680443502756| 1.252762968495368|
|gene2| 12.6|1.1003705451175627| 2.533696813957432|
+-----+-----+------------------+------------------+
独热编码
机器学习模型要求所有的输入特征和输出预测都是数值型的。这意味着,如果您的数据包含分类特征(例如教育程度{学士,MBA,硕士,医学博士,博士}),您必须在构建和评估模型之前对其进行数值编码。
图 12-3 说明了一种称为独热编码的概念,这是一种编码方案,其中每个分类值都转换为一个二进制向量。

图 12-3. 独热编码示例
一种独热编码器将标签索引映射到二进制向量表示形式,其中最多有一个 1 值表示来自所有可能特征值集合的特定特征值的存在。当您需要使用分类特征但算法期望连续特征时,此方法非常有用。要理解这种编码方法,考虑一个名为 safety_level 的特征,它具有五个分类值(在 表 12-4 中表示)。第一列显示特征值,其余列显示这些值的独热编码二进制向量表示。
表 12-4. 将分类值表示为二进制向量
| safety_level (文本) | 非常低 | 低 | 中等 | 高 | 非常高 |
|---|---|---|---|---|---|
非常低 |
1 | 0 | 0 | 0 | 0 |
低 |
0 | 1 | 0 | 0 | 0 |
中等 |
0 | 0 | 1 | 0 | 0 |
高 |
0 | 0 | 0 | 1 | 0 |
非常高 |
0 | 0 | 0 | 0 | 1 |
对于字符串类型的输入数据,通常首先使用 StringIndexer 对分类特征进行编码。然后,Spark 的 OneHotEncoder 将字符串索引标签编码为稀疏向量。让我们通过一个示例来看看这是如何工作的。首先,我们将创建一个包含两个分类特征的 DataFrame:
>>> from pyspark.sql.types import *
>>>
>>> schema = StructType().add("id","integer")\
... .add("safety_level","string")\
... .add("engine_type","string")
>>> schema
StructType(List(StructField(id,IntegerType,true),
StructField(safety_level,StringType,true),
StructField(engine_type,StringType,true)))
>>> data = [
... (1,'Very-Low','v4'),
... (2,'Very-Low','v6'),
... (3,'Low','v6'),
... (4,'Low','v6'),
... (5,'Medium','v4'),
... (6,'High','v6'),
... (7,'High','v6'),
... (8,'Very-High','v4'),
... (9,'Very-High','v6')
... ]
>>>
>>> df = spark.createDataFrame(data, schema=schema)
>>> df.show(truncate=False)
+---+------------+-----------+
|id |safety_level|engine_type|
+---+------------+-----------+
|1 |Very-Low |v4 |
|2 |Very-Low |v6 |
|3 |Low |v6 |
|4 |Low |v6 |
|5 |Medium |v4 |
|6 |High |v6 |
|7 |High |v6 |
|8 |Very-High |v4 |
|9 |Very-High |v6 |
+---+------------+-----------+
接下来,我们将对 safety_level 和 engine_type 特征应用 OneHotEncoder 转换。在 Spark 中,我们不能直接对字符串列应用 OneHotEncoder,我们需要先将它们转换为数值,这可以通过 Spark 的 StringIndexer 来完成。
首先,我们对 safety_level 特征应用 StringIndexer:
>>> from pyspark.ml.feature import StringIndexer
>>> safety_level_indexer = StringIndexer(inputCol="safety_level",
outputCol="safety_level_index")
>>> df1 = safety_level_indexer.fit(df).transform(df)
>>> df1.show()
+---+------------+-----------+------------------+
| id|safety_level|engine_type|safety_level_index|
+---+------------+-----------+------------------+
| 1| Very-Low| v4| 3.0|
| 2| Very-Low| v6| 3.0|
| 3| Low| v6| 1.0|
| 4| Low| v6| 1.0|
| 5| Medium| v4| 4.0|
| 6| High| v6| 0.0|
| 7| High| v6| 0.0|
| 8| Very-High| v4| 2.0|
| 9| Very-High| v6| 2.0|
+---+------------+-----------+------------------+
接下来,我们对 engine_type 特征应用 StringIndexer:
>>> engine_type_indexer = StringIndexer(inputCol="engine_type",
outputCol="engine_type_index")
>>> df2 = engine_type_indexer.fit(df).transform(df)
>>> df2.show()
+---+------------+-----------+-----------------+
| id|safety_level|engine_type|engine_type_index|
+---+------------+-----------+-----------------+
| 1| Very-Low| v4| 1.0|
| 2| Very-Low| v6| 0.0|
| 3| Low| v6| 0.0|
| 4| Low| v6| 0.0|
| 5| Medium| v4| 1.0|
| 6| High| v6| 0.0|
| 7| High| v6| 0.0|
| 8| Very-High| v4| 1.0|
| 9| Very-High| v6| 0.0|
+---+------------+-----------+-----------------+
现在我们可以将 OneHotEncoder 应用到 safety_level_index 和 engine_type_index 列:
>>> from pyspark.ml.feature import OneHotEncoder
>>> onehotencoder_safety_level = OneHotEncoder(inputCol="safety_level_index",
outputCol="safety_level_vector")
>>> df11 = onehotencoder_safety_level.fit(df1).transform(df1)
>>> df11.show(truncate=False)
+---+------------+-----------+------------------+-------------------+
|id |safety_level|engine_type|safety_level_index|safety_level_vector|
+---+------------+-----------+------------------+-------------------+
|1 |Very-Low |v4 |3.0 |(4,[3],[1.0]) |
|2 |Very-Low |v6 |3.0 |(4,[3],[1.0]) |
|3 |Low |v6 |1.0 |(4,[1],[1.0]) |
|4 |Low |v6 |1.0 |(4,[1],[1.0]) |
|5 |Medium |v4 |4.0 |(4,[],[]) |
|6 |High |v6 |0.0 |(4,[0],[1.0]) |
|7 |High |v6 |0.0 |(4,[0],[1.0]) |
|8 |Very-High |v4 |2.0 |(4,[2],[1.0]) |
|9 |Very-High |v6 |2.0 |(4,[2],[1.0]) |
+---+------------+-----------+------------------+-------------------+
>>> onehotencoder_engine_type = OneHotEncoder(inputCol="engine_type_index",
outputCol="engine_type_vector")
>>> df12 = onehotencoder_engine_type.fit(df2).transform(df2)
>>> df12.show(truncate=False)
+---+------------+-----------+-----------------+------------------+
|id |safety_level|engine_type|engine_type_index|engine_type_vector|
+---+------------+-----------+-----------------+------------------+
|1 |Very-Low |v4 |1.0 |(1,[],[]) |
|2 |Very-Low |v6 |0.0 |(1,[0],[1.0]) |
|3 |Low |v6 |0.0 |(1,[0],[1.0]) |
|4 |Low |v6 |0.0 |(1,[0],[1.0]) |
|5 |Medium |v4 |1.0 |(1,[],[]) |
|6 |High |v6 |0.0 |(1,[0],[1.0]) |
|7 |High |v6 |0.0 |(1,[0],[1.0]) |
|8 |Very-High |v4 |1.0 |(1,[],[]) |
|9 |Very-High |v6 |0.0 |(1,[0],[1.0]) |
+---+------------+-----------+-----------------+------------------+
我们还可以同时对多列应用此编码:
>>> indexers = [StringIndexer(inputCol=column, outputCol=column+"_index")
.fit(df) for column in list(set(df.columns)-set(['id'])) ]
>>> from pyspark.ml import Pipeline
>>> pipeline = Pipeline(stages=indexers)
>>> df_indexed = pipeline.fit(df).transform(df)
>>> df_indexed.show()
+---+------------+-----------+------------------+-----------------+
| id|safety_level|engine_type|safety_level_index|engine_type_index|
+---+------------+-----------+------------------+-----------------+
| 1| Very-Low| v4| 3.0| 1.0|
| 2| Very-Low| v6| 3.0| 0.0|
| 3| Low| v6| 1.0| 0.0|
| 4| Low| v6| 1.0| 0.0|
| 5| Medium| v4| 4.0| 1.0|
| 6| High| v6| 0.0| 0.0|
| 7| High| v6| 0.0| 0.0|
| 8| Very-High| v4| 2.0| 1.0|
| 9| Very-High| v6| 2.0| 0.0|
+---+------------+-----------+------------------+-----------------+
>>> encoder = OneHotEncoder(
... inputCols=[indexer.getOutputCol() for indexer in indexers],
... outputCols=[
... "{0}_encoded".format(indexer.getOutputCol()) for indexer in indexers]
... )
>>>
>>> from pyspark.ml.feature import VectorAssembler
>>> assembler = VectorAssembler(
... inputCols=encoder.getOutputCols(),
... outputCol="features"
... )
>>>
>>> pipeline = Pipeline(stages=indexers + [encoder, assembler])
>>>
>>> pipeline.fit(df).transform(df).show()
+---+------------+-----------+------------------+-----------------+
| id|safety_level|engine_type|safety_level_index|engine_type_index|
+---+------------+-----------+------------------+-----------------+
| 1| Very-Low| v4| 3.0| 1.0|
| 2| Very-Low| v6| 3.0| 0.0|
| 3| Low| v6| 1.0| 0.0|
| 4| Low| v6| 1.0| 0.0|
| 5| Medium| v4| 4.0| 1.0|
| 6| High| v6| 0.0| 0.0|
| 7| High| v6| 0.0| 0.0|
| 8| Very-High| v4| 2.0| 1.0|
| 9| Very-High| v6| 2.0| 0.0|
+---+------------+-----------+------------------+-----------------+
+---+--------------+-------------------------+-------------------+
| id| safety_level_|engine_type_index_encoded| features|
| | index_encoded| | |
+---+--------------+-------------------------+-------------------+
| 1| (4,[3],[1.0])| (1,[],[])| (5,[3],[1.0])|
| 2| (4,[3],[1.0])| (1,[0],[1.0])|(5,[3,4],[1.0,1.0])|
| 3| (4,[1],[1.0])| (1,[0],[1.0])|(5,[1,4],[1.0,1.0])|
| 4| (4,[1],[1.0])| (1,[0],[1.0])|(5,[1,4],[1.0,1.0])|
| 5| (4,[],[])| (1,[],[])| (5,[],[])|
| 6| (4,[0],[1.0])| (1,[0],[1.0])|(5,[0,4],[1.0,1.0])|
| 7| (4,[0],[1.0])| (1,[0],[1.0])|(5,[0,4],[1.0,1.0])|
| 8| (4,[2],[1.0])| (1,[],[])| (5,[2],[1.0])|
| 9| (4,[2],[1.0])| (1,[0],[1.0])|(5,[2,4],[1.0,1.0])|
+---+--------------+-------------------------+-------------------+
还有另一种数据转换方式:我们可以使用管道来简化这个过程。首先,我们创建所需的阶段:
>>> safety_level_indexer = StringIndexer(inputCol="safety_level",
outputCol="safety_level_index")
>>> engine_type_indexer = StringIndexer(inputCol="engine_type",
outputCol="engine_type_index")
>>> onehotencoder_safety_level = OneHotEncoder(
inputCol="safety_level_index",
outputCol="safety_level_vector")
>>> onehotencoder_engine_type = OneHotEncoder(
inputCol="engine_type_index",
outputCol="engine_type_vector")
然后我们创建一个管道,并将所有定义的阶段传递给它:
>>> pipeline = Pipeline(stages=[safety_level_indexer,
... engine_type_indexer,
... onehotencoder_safety_level,
... onehotencoder_engine_type
... ])
>>>
>>> df_transformed = pipeline.fit(df).transform(df)
>>> df_transformed.show(truncate=False)
+---+---------+------+------+------+-------------+------------------+
|id | safety|engine|safety|engine| safety_level| engine_type |
| | _level| _type|_level| _type| _vector| _vector |
| | | |_index|_index| | |
+---+---------+------+------+------+-------------+------------------+
|1 |Very-Low |v4 |3.0 |1.0 |(4,[3],[1.0])| (1,[],[]) |
|2 |Very-Low |v6 |3.0 |0.0 |(4,[3],[1.0])| (1,[0],[1.0]) |
|3 |Low |v6 |1.0 |0.0 |(4,[1],[1.0])| (1,[0],[1.0]) |
|4 |Low |v6 |1.0 |0.0 |(4,[1],[1.0])| (1,[0],[1.0]) |
|5 |Medium |v4 |4.0 |1.0 |(4,[],[]) | (1,[],[]) |
|6 |High |v6 |0.0 |0.0 |(4,[0],[1.0])| (1,[0],[1.0]) |
|7 |High |v6 |0.0 |0.0 |(4,[0],[1.0])| (1,[0],[1.0]) |
|8 |Very-High|v4 |2.0 |1.0 |(4,[2],[1.0])| (1,[],[]) |
|9 |Very-High|v6 |2.0 |0.0 |(4,[2],[1.0])| (1,[0],[1.0]) |
+---+---------+------+------+------+-------------+------------------+
TF-IDF
词频-逆文档频率(TF-IDF)是一种基于词在文档中出现的次数及其出现在整个语料库中文档数量的原创性度量。换句话说,它是文本挖掘中用于反映术语对文档在语料库中的重要性的特征向量化方法。TF-IDF 技术通常用于文档分析、搜索引擎、推荐系统和其他自然语言处理(NLP)应用中。
词项频率 TF(t,d) 是术语 t 在文档 d 中出现的次数,而文档频率 DF(t, D) 是包含术语 t 的文档数量。如果一个术语在整个语料库中经常出现,意味着它并不提供有关特定文档的特殊信息,通常这类词汇(如“of”、“the”和“as”)可能会在文本分析中被排除。在深入探讨 TF-IDF 转换之前,让我们先定义以下方程中使用的术语(Table 12-5)。
Table 12-5. TF-IDF 符号
| 标记 | 描述 |
|---|---|
t |
术语 |
d |
文档 |
D |
语料库(有限文档集合) |
|D| |
语料库中的文档数量 |
TF(t, d) |
术语频率:术语 t 在文档 d 中出现的次数 |
DF(t, D) |
文档频率:包含术语 t 的文档数量 |
IDF(t, D) |
逆文档频率:一个术语提供信息量的数值度量 |
逆文档频率(IDF)定义如下:
假设 N 是语料库中的文档数量。由于使用了对数,如果一个词在所有文档中出现,其 IDF 值就会变为 0:
注意,为了避免在语料库中未出现的术语分母为零,我们应用了平滑项 (+1)。TF-IDF 测量值简单地是 TF 和 IDF 的乘积:
其中:
-
t表示术语(术语) -
d表示一个文档 -
D表示语料库 -
TF(t,d)表示术语t在文档d中出现的次数
我们可以表达 TF 如下:
在我展示 Spark 如何实现 TF-IDF 之前,让我们通过一个简单的例子来了解,其中包含两个文档(语料库大小为 2,D = {doc1, doc2})。我们首先计算术语频率和文档频率:
documents = spark.createDataFrame([
("doc1", "Ada Ada Spark Spark Spark"),
("doc2", "Ada SQL")],["id", "document"])
TF(Ada, doc1) = 2
TF(Spark, doc1) = 3
TF(Ada, doc2) = 1
TF(SQL, doc2) = 1
DF(Ada, D) = 2
DF(Spark, D) = 1
DF(SQL, D) = 1
然后我们计算 IDF 和 TF-IDF(请注意,所有计算的对数基数为 e):
IDF(Ada, D) = log ( (|D|+1) / (DF(t,D)+1) )
= log ( (2+1) / (DF(Ada, D)+1) )
= log ( 3 / (2+1)) = log(1)
= 0.00
IDF(Spark, D) = log ( (|D|+1) / (DF(t,D)+1) )
= log ( (2+1) / (DF(Spark, D)+1) )
= log ( 3 / (1+1) )
= log (1.5)
= 0.40546510811
TF-IDF(Ada, doc1, D) = TF(Ada, doc1) x IDF(Ada, D)
= 2 x 0.0
= 0.0
TF-IDF(Spark, doc1, D) = TF(Spark, doc1) x IDF(Spark, D)
= 3 x 0.40546510811
= 1.21639532433
在 Spark 中,HashingTF 和 CountVectorizer 是用于生成词项频率向量的两种算法。下面的示例展示了如何执行所需的转换。首先,我们创建我们的样本 DataFrame:
>>> from pyspark.ml.feature import HashingTF, IDF, Tokenizer
>>>
>>> sentences = spark.createDataFrame([
... (0.0, "we heard about Spark and Java"),
... (0.0, "Does Java use case classes"),
... (1.0, "fox jumped over fence"),
... (1.0, "red fox jumped over")
... ], ["label", "text"])
>>>
>>> sentences.show(truncate=False)
+-----+-----------------------------+
|label|text |
+-----+-----------------------------+
|0.0 |we heard about Spark and Java|
|0.0 |Does Java use case classes |
|1.0 |fox jumped over fence |
|1.0 |red fox jumped over |
+-----+-----------------------------+
>>> tokenizer = Tokenizer(inputCol="text", outputCol="words")
>>> words_data = tokenizer.transform(sentences)
>>> words_data.show(truncate=False)
+-----+-----------------------------+------------------------------------+
|label|text |words |
+-----+-----------------------------+------------------------------------+
|0.0 |we heard about Spark and Java|[we, heard, about, spark, and, java]|
|0.0 |Does Java use case classes |[does, java, use, case, classes] |
|1.0 |fox jumped over fence |[fox, jumped, over, fence] |
|1.0 |red fox jumped over |[red, fox, jumped, over] |
+-----+-----------------------------+------------------------------------+
接下来,我们创建原始特征:
>>> hashingTF = HashingTF(inputCol="words", outputCol="raw_features",
numFeatures=16)
>>> featurized_data = hashingTF.transform(words_data)
>>> featurized_data.select("label", "raw_features").show(truncate=False)
+-----+-----------------------------------------------+
|label|raw_features |
+-----+-----------------------------------------------+
|0.0 |(16,[1,4,6,11,12,15],[1.0,1.0,1.0,1.0,1.0,1.0])|
|0.0 |(16,[2,6,11,13,15],[1.0,1.0,1.0,1.0,1.0]) |
|1.0 |(16,[0,1,6,8],[1.0,1.0,1.0,1.0]) |
|1.0 |(16,[1,4,6,8],[1.0,1.0,1.0,1.0]) |
+-----+-----------------------------------------------+
然后我们应用 IDF() 转换:
>>> idf = IDF(inputCol="raw_features", outputCol="features")
>>> idf_model = idf.fit(featurized_data)
>>> rescaled_data = idf_model.transform(featurized_data)
>>> rescaled_data.select("label", "features").show(truncate=False)
+-----+--------------------------------------------------------------+
|label|features |
+-----+--------------------------------------------------------------+
|0.0 |(16,[1,4,6,11,12,15],[0.22314355131420976,0.5108256237659907, |
| |0.0,0.5108256237659907,0.9162907318741551,0.5108256237659907])|
|0.0 |(16,[2,6,11,13,15],[0.9162907318741551,0.0,0.5108256237659907,|
| | 0.9162907318741551,0.5108256237659907]) |
|1.0 |(16,[0,1,6,8],[0.9162907318741551,0.22314355131420976, |
| |0.0,0.5108256237659907]) |
|1.0 |(16,[1,4,6,8],[0.22314355131420976,0.5108256237659907, |
| |0.0,0.5108256237659907]) |
+-----+--------------------------------------------------------------+
使用CountVectorizer展示了如何进行 TF-IDF 计算,它从文档集合中提取词汇,并生成CountVectorizerModel。在本例中,DataFrame 的每一行代表一个文档:
>>> df = spark.createDataFrame(
... [(0, ["a", "b", "c"]), (1, ["a", "b", "b", "c", "a"])],
... ["label", "raw"]
... )
>>> df.show()
+-----+---------------+
|label| raw|
+-----+---------------+
| 0| [a, b, c]|
| 1|[a, b, b, c, a]|
+-----+---------------+
>>> from pyspark.ml.feature import CountVectorizer
>>> cv = CountVectorizer().setInputCol("raw").setOutputCol("features")
>>> model = cv.fit(df)
>>> transformed = model.transform(df)
>>> transformed.show(truncate=False)
+-----+---------------+--------------------------+
|label|raw |features |
+-----+---------------+--------------------------+
|0 |[a, b, c] | (3,[0,1,2],[1.0,1.0,1.0])|
|1 |[a, b, b, c, a]| (3,[0,1,2],[2.0,2.0,1.0])|
+-----+---------------+--------------------------+
在features列中,以第二行为例:
-
3是向量的长度。 -
[0, 1, 2]是向量的索引(index(a)=0, index(b)=1, index(c)=2)。 -
[2.0, 2.0, 1.0]是向量的值。
HashingTF()将文档转换为固定大小的向量:
>>> hashing_TF = HashingTF(inputCol="raw", outputCol="features", numFeatures=128)
>>> result = hashing_TF.transform(df)
>>> result.show(truncate=False)
+-----+---------------+-------------------------------+
|label|raw |features |
+-----+---------------+-------------------------------+
|0 |[a, b, c] |(128,[40,99,117],[1.0,1.0,1.0])|
|1 |[a, b, b, c, a]|(128,[40,99,117],[1.0,2.0,2.0])|
+-----+---------------+-------------------------------+
注意,通过CountVectorizer生成的向量大小取决于训练语料库和文档,而通过HashingTF生成的向量具有固定大小(我们设置为 128)。这意味着当使用CountVectorizer时,每个原始特征映射到一个索引,但HashingTF可能会遇到哈希冲突,其中两个或更多术语映射到同一个索引。为了避免这种情况,我们可以增加目标特征维度。
FeatureHasher
特征哈希将一组分类或数值特征投影到指定维度的特征向量中(通常远小于原始特征空间的维度)。使用哈希技巧将特征映射到特征向量中的索引。
Spark 的FeatureHasher可以处理多列数据,这些数据可以是数值型或分类型特征。对于数值型特征,使用列名的哈希来映射特征值到特征向量中的索引。对于分类和布尔型特征,使用字符串"column_name=value"的哈希,其指示值为1.0。以下是一个例子:
>>> from pyspark.ml.feature import FeatureHasher
>>> df = spark.createDataFrame([
... (2.1, True, "1", "fox"),
... (2.1, False, "2", "gray"),
... (3.3, False, "2", "red"),
... (4.4, True, "4", "fox")
... ], ["number", "boolean", "string_number", "string"])
>>> input_columns = ["number", "boolean", "string_number", "string"]
>>> featurized = hasher.transform(df)
>>> featurized.show(truncate=False)
+------+-------+-------------+------+---------------------------------------+
|number|boolean|string_number|string|features |
+------+-------+-------------+------+---------------------------------------+
|2.1 |true |1 |fox |(256,[22,40,71,156],[1.0,1.0,2.1,1.0]) |
|2.1 |false |2 |gray |(256,[71,91,109,130],[2.1,1.0,1.0,1.0])|
|3.3 |false |2 |red |(256,[71,91,130,205],[3.3,1.0,1.0,1.0])|
|4.4 |true |4 |fox |(256,[40,71,84,156],[1.0,4.4,1.0,1.0]) |
+------+-------+-------------+------+---------------------------------------+
SQLTransformer
Spark 的SQLTransformer实现了由 SQL 语句定义的转换操作。与将 DataFrame 注册为表然后查询表不同,你可以直接将 SQL 转换应用于作为 DataFrame 表示的数据。目前,SQLTransformer的功能有限,可以应用于单个 DataFrame 作为__THIS__,它表示输入数据集的基础表。
SQLTransformer支持类似以下的语句:
SELECT salary, salary * 0.06 AS bonus
FROM __THIS__
WHERE salary > 10000
SELECT dept, location, SUM(salary) AS sum_of_salary
FROM __THIS__
GROUP BY dept, location
以下示例展示了如何使用SQLTransformer:
>>> from pyspark.ml.feature import SQLTransformer
>>> df = spark.createDataFrame([
... (10, "d1", 27000),
... (20, "d1", 29000),
... (40, "d2", 31000),
... (50, "d2", 39000)], ["id", "dept", "salary"])
>>>
>>> df.show()
+---+----+------+
| id|dept|salary|
+---+----+------+
| 10| d1| 27000|
| 20| d1| 29000|
| 40| d2| 31000|
| 50| d2| 39000|
+---+----+------+
query = "SELECT dept, SUM(salary) AS sum_of_salary FROM __THIS__ GROUP BY dept"
sqlTrans = SQLTransformer(statement=query)
sqlTrans.transform(df).show()
+----+-------------+
|dept|sum_of_salary|
+----+-------------+
| d2| 70000|
| d1| 56000|
+----+-------------+
Summary
机器学习算法的目标是利用输入数据创建可用模型,帮助我们回答问题。输入数据包括结构化列形式的特征(如教育水平、汽车价格、血糖水平等)。在大多数情况下,算法要求具有特定特性的特征才能正常工作,这就需要特征工程。Spark 的机器学习库 MLlib(包含在 PySpark 中)提供了一组高级 API,使特征工程成为可能。正确的特征工程有助于构建语义正确的机器学习模型。
以下是提供有关特征工程和本书其他涵盖主题的可访问资源列表:
-
“开始特征工程”,一篇由 Pravar Jain 撰写的博客文章。
-
“数据操作:特征”,作者是冯文强。
-
“表征:特征工程”,来自 Google 的TensorFlow API 机器学习速成课程。
-
“想要构建机器学习流水线?使用 PySpark 的快速介绍”,一篇由 Lakshay Arora 撰写的博客文章。
-
TF-IDF,词频-逆文档频率,Ethen Liu 的文档。
我们的 Spark 数据算法之旅到此结束!希望你感觉准备好解决任何大小的数据问题了。记住我的座右铭:保持简单,并使用参数,以便其他开发者可以重复使用你的解决方案。


浙公网安备 33010602011771号