PySpark-学习指南-全-

PySpark 学习指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

据估计,到 2013 年,全世界产生了大约 4.4 泽字节的数据;也就是说,4.4 十亿 太字节!到 2020 年,我们(人类)预计将产生十倍于此的数据。随着数据以每秒 literally 的速度增长,以及人们对从中获取意义的日益增长的需求,2004 年,谷歌员工杰弗里·迪恩和桑杰·格马瓦特发表了开创性的论文 MapReduce:在大型集群上简化数据处理。从那时起,利用这一概念的技术开始迅速增长,Apache Hadoop 最初是最受欢迎的。它最终创建了一个包括 Pig、Hive 和 Mahout 等抽象层的 Hadoop 生态系统——所有这些都利用了简单的 map 和 reduce 概念。

然而,尽管 MapReduce 每天能够处理 PB 级的数据,但它仍然是一个相当受限的编程框架。此外,大多数任务都需要读写磁盘。看到这些缺点,2009 年,Matei Zaharia 开始在他的博士期间研究 Spark。Spark 最初于 2012 年发布。尽管 Spark 基于相同的 MapReduce 概念,但它处理数据和组织任务的高级方式使其比 Hadoop(对于内存计算)快 100 倍。

在这本书中,我们将使用 Python 引导您了解 Apache Spark 的最新版本。我们将向您展示如何读取结构化和非结构化数据,如何使用 PySpark 中的一些基本数据类型,构建机器学习模型,操作图,读取流数据,并在云中部署您的模型。每一章都将解决不同的问题,到本书结束时,我们希望您能够足够了解以解决我们没有空间在此处涵盖的其他问题。

本书涵盖的内容

第一章,理解 Spark,介绍了 Spark 世界,概述了技术和作业组织概念。

第二章,弹性分布式数据集,涵盖了 RDD,这是 PySpark 中可用的基本、无模式的数据库结构。

第三章,DataFrame,提供了关于一种数据结构的详细概述,这种数据结构在效率方面连接了 Scala 和 Python 之间的差距。

第四章,为建模准备数据,指导读者在 Spark 环境中清理和转换数据的过程。

第五章,介绍 MLlib,介绍了在 RDD 上工作的机器学习库,并回顾了最有用的机器学习模型。

第六章,介绍 ML 包,涵盖了当前主流的机器学习库,并概述了目前所有可用的模型。

第七章, GraphFrames,将引导您了解一种新的结构,使使用图解决问题变得简单。

第八章, TensorFrames,介绍了 Spark 与 TensorFlow 深度学习世界之间的桥梁。

第九章, 使用 Blaze 的多语言持久性,描述了 Blaze 如何与 Spark 配合使用,以便更容易地从各种来源抽象数据。

第十章, 结构化流,提供了 PySpark 中可用的流工具概述。

第十一章, 打包 Spark 应用程序,将引导您了解将代码模块化并通过命令行界面提交到 Spark 以执行步骤。

更多信息,我们提供了以下两个附加章节:

安装 Spark: www.packtpub.com/sites/default/files/downloads/InstallingSpark.pdf

免费 Spark 云服务: www.packtpub.com/sites/default/files/downloads/FreeSparkCloudOffering.pdf

您需要为本书准备什么

为了阅读本书,您需要一个个人电脑(可以是 Windows 机器、Mac 或 Linux)。要运行 Apache Spark,您将需要 Java 7+以及安装并配置好的 Python 2.6+或 3.4+环境;我们使用的是 Python 3.5 版本的 Anaconda 发行版,可以从www.continuum.io/downloads下载。

我们在本书中随机使用的 Python 模块都是 Anaconda 预安装的。我们还使用了 GraphFrames 和 TensorFrames,这些模块可以在启动 Spark 实例时动态加载:要加载这些模块,您只需要一个互联网连接。如果这些模块中的一些目前没有安装到您的机器上,也没有关系——我们将引导您完成安装过程。

本书面向对象

本书面向所有希望学习大数据中增长最快的技术的读者:Apache Spark。我们希望即使是数据科学领域的资深从业者也能发现一些示例令人耳目一新,一些高级主题引人入胜。

规范

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:

代码块设置如下:

data = sc.parallelize(
    [('Amber', 22), ('Alfred', 23), ('Skye',4), ('Albert', 12), 
     ('Amber', 9)])

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

rdd1 = sc.parallelize([('a', 1), ('b', 4), ('c',10)])
rdd2 = sc.parallelize([('a', 4), ('a', 1), ('b', '6'), ('d', 15)])
rdd3 = rdd1.leftOuterJoin(rdd2)

任何命令行输入或输出都按以下方式编写:

java -version

新术语重要词汇将以粗体显示。你会在屏幕上看到这些词汇,例如在菜单或对话框中,文本将显示为:“点击下一步按钮将你带到下一屏幕。”

注意

警告或重要注意事项将以如下框显示。

小贴士

小贴士和技巧看起来像这样。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。

要发送一般反馈,请简单地将电子邮件发送到<feedback@packtpub.com>,并在邮件主题中提及书籍标题。

如果你在某个领域有专业知识,并且对撰写或参与书籍感兴趣,请参阅我们的作者指南:www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲拥有者,我们有一些东西可以帮助你从购买中获得最大收益。

下载示例代码

你可以从www.packtpub.com的账户下载你购买的所有 Packt 书籍的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

所有代码也都在 GitHub 上提供:github.com/drabastomek/learningPySpark

你可以通过以下步骤下载代码文件:

  1. 登录或使用电子邮件地址和密码注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择你想要下载代码文件的书籍。

  6. 从下拉菜单中选择你购买此书籍的地方。

  7. 点击代码下载

文件下载完成后,请确保使用最新版本解压缩或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书代码包也托管在 GitHub 上:github.com/PacktPublishing/Learning-PySpark。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,可在github.com/PacktPublishing/找到。查看它们吧!

下载本书的彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助你更好地理解输出的变化。你可以从www.packtpub.com/sites/default/files/downloads/LearningPySpark_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过从www.packtpub.com/support选择您的标题来查看任何现有的勘误。

侵权

互联网上版权材料的侵权是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

如果您在本书的任何方面遇到问题,请通过 <copyright@packtpub.com> 联系我们,并提供疑似侵权材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面提供的帮助。

询问

如果您在本书的任何方面遇到问题,请通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章:理解 Spark

Apache Spark 是一个强大的开源处理引擎,最初由 Matei Zaharia 在加州大学伯克利分校攻读博士学位时作为其博士论文的一部分开发。Spark 的第一个版本于 2012 年发布。从那时起,在 2013 年,Zaharia 共同创立了 Databricks,并成为其 CTO;他还在斯坦福大学担任教授,此前毕业于麻省理工学院。同时,Spark 代码库捐赠给了 Apache 软件基金会,并成为其旗舰项目。

Apache Spark 是一个快速、易于使用的框架,允许您解决各种复杂的数据问题,无论是半结构化、结构化、流式处理,还是机器学习/数据科学。它已经成为大数据领域最大的开源社区之一,拥有来自 250 多个组织的 1000 多名贡献者,以及全球 570 多个地点的 30 万多名 Spark Meetup 社区成员。

在本章中,我们将提供了解 Apache Spark 的基础。我们将解释 Spark 作业和 API 背后的概念,介绍 Spark 2.0 架构,并探讨 Spark 2.0 的功能。

涵盖的主题包括:

  • 什么是 Apache Spark?

  • Spark 作业和 API

  • 弹性分布式数据集(RDDs)、DataFrames 和 Dataset 综述

  • 催化剂优化器和钨项目综述

  • Spark 2.0 架构综述

什么是 Apache Spark?

Apache Spark 是一个开源的强大分布式查询和处理引擎。它提供了 MapReduce 的灵活性和可扩展性,但速度显著更高:当数据存储在内存中时,速度可高达 Apache Hadoop 的 100 倍,当访问磁盘时,速度可高达 10 倍。

Apache Spark 允许用户轻松地读取、转换和聚合数据,以及训练和部署复杂的统计模型。Spark API 在 Java、Scala、Python、R 和 SQL 中均可访问。Apache Spark 可用于构建应用程序或将其打包成库以在集群上部署,或通过笔记本(例如,Jupyter、Spark-Notebook、Databricks 笔记本和 Apache Zeppelin)进行交互式地快速分析。

Apache Spark 向数据分析师、数据科学家或研究人员暴露了大量的库,这些人员曾使用过 Python 的pandas或 R 的data.framesdata.tables。需要注意的是,尽管 Spark DataFrames 对pandasdata.frames / data.tables用户来说可能很熟悉,但它们之间仍有一些差异,所以请调整您的期望。具有更多 SQL 背景的用户也可以使用该语言来塑造他们的数据。此外,Apache Spark 还提供了几个已实现和调优的算法、统计模型和框架:MLlib 和 ML 用于机器学习,GraphX 和 GraphFrames 用于图处理,以及 Spark Streaming(DStreams 和 Structured)。Spark 允许用户在同一个应用程序中无缝地组合这些库。

Apache Spark 可以轻松地在笔记本电脑上本地运行,也可以轻松地以独立模式、在 YARN 或 Apache Mesos 上部署 - 要么在本地集群中,要么在云中。它可以读取和写入多种数据源,包括但不限于 HDFS、Apache Cassandra、Apache HBase 和 S3:

什么是 Apache Spark?

来源:Apache Spark 是大数据的智能手机 bit.ly/1QsgaNj

注意

如需更多信息,请参阅:Apache Spark 是大数据的智能手机 bit.ly/1QsgaNj

Spark 作业和 API

在本节中,我们将简要概述 Apache Spark 作业和 API。这为 Spark 2.0 架构的后续章节提供了必要的知识基础。

执行过程

任何 Spark 应用都会在 master 节点上启动一个单独的驱动进程(可能包含多个作业),然后根据以下图中所示,将执行进程(包含多个任务)分布到多个 worker 节点上:

执行过程

驱动进程根据为给定作业生成的图确定要发送到执行节点的任务进程的数量和组成。请注意,任何工作节点都可以执行来自多个不同作业的任务。

Spark 作业与一系列对象依赖关系相关联,这些依赖关系组织在一个直接无环图(DAG)中,如下例所示,该图由 Spark UI 生成。基于此,Spark 可以优化这些任务的调度(例如,确定所需任务和工人的数量)和执行:

执行过程

注意

关于 DAG 调度器的更多信息,请参阅 bit.ly/29WTiK8

弹性分布式数据集

Apache Spark 是围绕一组称为弹性分布式数据集(RDD,简称)的不可变 Java 虚拟机(JVM)对象构建的。由于我们使用 Python,因此重要的是要注意 Python 数据存储在这些 JVM 对象中。更多关于 RDD 和 DataFrame 的内容将在后续章节中讨论。这些对象允许任何作业快速执行计算。RDD 是在它们上计算、缓存和存储在内存中的:与 Apache Hadoop 等其他传统分布式框架相比,这种方案的计算速度要快得多。

同时,RDDs 提供了一些粗粒度的转换(如 map(...), reduce(...), 和 filter(...),我们将在第二章 Resilient Distributed Datasets 中更详细地介绍),保持了 Hadoop 平台的灵活性和可扩展性,以执行各种计算。RDDs 并行应用和记录转换到数据中,从而提高了速度和容错性。通过注册转换,RDDs 提供数据血缘——以图形形式表示的中间步骤的祖先树。这实际上保护了 RDDs 防止数据丢失——如果 RDD 的一个分区丢失,它仍然有足够的信息来重新创建该分区,而不是仅仅依赖于复制。

注意

如果你想了解更多关于数据血缘的信息,请查看这个链接 ibm.co/2ao9B1t

RDDs 有两套并行操作:转换(返回指向新 RDD 的指针)和动作(在执行计算后返回值到驱动程序);我们将在后面的章节中更详细地介绍这些内容。

注意

关于最新的转换和动作列表,请参阅 Spark 编程指南 spark.apache.org/docs/latest/programming-guide.html#rdd-operations

RDD 转换操作在某种程度上是 懒加载 的,它们不会立即计算结果。转换只有在执行动作并且需要将结果返回给驱动程序时才会进行计算。这种延迟执行导致查询更加精细:针对性能优化的查询。这种优化从 Apache Spark 的 DAGScheduler 开始——一个以 阶段 为导向的调度器,如前一张截图所示使用 阶段 进行转换。通过将 RDD 的 转换动作 分开,DAGScheduler 可以在查询中执行优化,包括能够避免 shuffle,即数据(最耗资源的任务)。

关于 DAGScheduler 和优化(特别是关于窄或宽依赖)的更多信息,一个很好的参考资料是 High Performance Spark 中的 Narrow vs. Wide Transformations 部分,位于 第五章,有效的转换 (smile.amazon.com/High-Performance-Spark-Practices-Optimizing/dp/1491943203)。

DataFrames

DataFrames,像 RDDs 一样,是在集群节点间分布的数据的不可变集合。然而,与 RDDs 不同,在 DataFrames 中,数据被组织成命名的列。

注意

如果你熟悉 Python 的 pandas 或 R 的 data.frames,这是一个类似的概念。

DataFrame 被设计得使大数据集的处理更加容易。它们允许开发者形式化数据的结构,允许更高层次的抽象;从这个意义上讲,DataFrame 类似于关系数据库世界中的表。DataFrame 提供了一个特定领域的语言 API 来操作分布式数据,并使 Spark 对更广泛的受众,而不仅仅是专业数据工程师,变得可访问。

DataFrame 的一大优点是 Spark 引擎最初构建一个逻辑执行计划,并根据成本优化器确定的物理计划执行生成的代码。与在 Python 中与 Java 或 Scala 相比可能显著较慢的 RDD 不同,DataFrame 的引入使得所有语言都实现了性能上的对等。

Datasets

Spark 1.6 中引入的 Spark Datasets 的目标是提供一个 API,使用户能够轻松地表达对域对象的转换,同时提供强大 Spark SQL 执行引擎的性能和好处。不幸的是,在撰写本书时,Datasets 只在 Scala 或 Java 中可用。当它们在 PySpark 中可用时,我们将在未来的版本中介绍它们。

Catalyst Optimizer

Spark SQL 是 Apache Spark 中技术含量最高的组件之一,因为它既支持 SQL 查询,也支持 DataFrame API。Spark SQL 的核心是 Catalyst 优化器。该优化器基于函数式编程结构,并设计有两个目的:简化新优化技术和功能添加到 Spark SQL 中,并允许外部开发者扩展优化器(例如,添加特定数据源规则,支持新数据类型等):

Catalyst Optimizer

注意

更多信息,请参阅 Spark SQL 的 Catalyst 优化器深入解析 (bit.ly/271I7Dk) 和 Apache Spark DataFrame:结构化数据的简单快速分析 (bit.ly/29QbcOV)

项目 Tungsten

Tungsten 是 Apache Spark 执行引擎的一个总称项目代号。该项目专注于改进 Spark 算法,以便它们更有效地使用内存和 CPU,将现代硬件的性能推向极限。

该项目的努力主要集中在以下方面:

  • 显式管理内存,以消除 JVM 对象模型和垃圾回收的开销

  • 设计算法和数据结构以利用内存层次结构

  • 在运行时生成代码,以便应用程序可以利用现代编译器并针对 CPU 进行优化

  • 消除虚函数调度,以减少多个 CPU 调用

  • 利用低级编程(例如,将即时数据加载到 CPU 寄存器中)来加速内存访问,并优化 Spark 引擎以高效地编译和执行简单的循环

注意

更多信息,请参阅

Project Tungsten:将 Apache Spark 带到裸金属更近一步 (databricks.com/blog/2015/04/28/project-tungsten-bringing-spark-closer-to-bare-metal.html)

深入探讨 Project Tungsten:将 Spark 带到裸金属更近一步 [SSE 2015 视频 和 幻灯片] (spark-summit.org/2015/events/deep-dive-into-project-tungsten-bringing-spark-closer-to-bare-metal/) 以及

Apache Spark 作为编译器:在笔记本电脑上每秒连接十亿行数据 (databricks.com/blog/2016/05/23/apache-spark-as-a-compiler-joining-a-billion-rows-per-second-on-a-laptop.html)

Spark 2.0 架构

Apache Spark 2.0 的引入是 Apache Spark 项目基于过去两年平台开发关键经验教训的最新主要版本:

Spark 2.0 架构

来源:Apache Spark 2.0:更快、更简单、更智能 bit.ly/2ap7qd5

Apache Spark 2.0 版本的三个主要主题围绕着性能提升(通过 Tungsten 第二阶段),引入结构化流,以及统一 Datasets 和 DataFrames。我们将描述 Datasets,因为它们是 Spark 2.0 的一部分,尽管它们目前仅在 Scala 和 Java 中可用。

注意

参考以下由关键 Spark 委员会成员提供的演示,以获取有关 Apache Spark 2.0 的更多信息:

Reynold Xin 的 Apache Spark 2.0:更快、更简单、更智能 网络研讨会 bit.ly/2ap7qd5

Michael Armbrust 的 Structuring Spark:DataFrames、Datasets 和 Streaming bit.ly/2ap7qd5

Tathagata Das 的深入探讨 Spark Streaming bit.ly/2aHt1w0

Joseph Bradley 的 Apache Spark MLlib 2.0 预览:数据科学和生产 bit.ly/2aHrOVN

统一 Datasets 和 DataFrames

在上一节中,我们指出(在撰写本书时)Datasets 只在 Scala 或 Java 中可用。然而,我们提供以下背景信息以更好地理解 Spark 2.0 的方向。

Datasets 于 2015 年作为 Apache Spark 1.6 版本的一部分引入。Datasets 的目标是提供一个类型安全的编程接口。这允许开发者使用半结构化数据(如 JSON 或键值对)进行编译时类型安全(即,生产应用程序在运行之前可以检查错误)。Python 不实现 Dataset API 的一部分原因是因为 Python 不是一个类型安全语言。

同样重要的是,Dataset API 包含高级领域特定语言操作,如 sum()avg()join()group()。这一特性意味着您具有传统 Spark RDDs 的灵活性,但代码也更容易表达、阅读和编写。类似于 DataFrames,Dataset 可以利用 Spark 的催化剂优化器,通过向查询计划器公开表达式和数据字段以及利用 Tungsten 的快速内存编码。

Spark API 的历史在以下图中表示,显示了从 RDD 到 DataFrame 到 Dataset 的演变:

统一数据集和数据框

来源:网络研讨会 Apache Spark 1.5:DataFrame 和 RDD 之间的区别是什么?bit.ly/29JPJSA

DataFrame 和 Dataset API 的统一可能会对向后兼容性造成破坏性变化。这是 Apache Spark 2.0 成为重大版本(而不是 1.x 小版本,这将最小化任何破坏性变化)的主要原因之一。正如您可以从以下图中看到的那样,DataFrame 和 Dataset 都属于 Apache Spark 2.0 作为一部分引入的新 Dataset API:

统一数据集和数据框

来源:三个 Apache Spark API 的故事:RDD、DataFrames 和 Datasets bit.ly/2accSNA

如前所述,Dataset API 提供了一个类型安全的面向对象编程接口。Dataset 可以通过向查询计划器公开表达式和数据字段以及利用 Project Tungsten 的快速内存编码来利用催化剂优化器。但是,随着 DataFrame 和 Dataset 现在作为 Apache Spark 2.0 的一部分统一,DataFrame 现在是 Dataset Untyped API 的别名。更具体地说:

DataFrame = Dataset[Row]

介绍 SparkSession

在过去,您可能会使用 SparkConfSparkContextSQLContextHiveContext 分别执行各种 Spark 查询以进行配置、Spark 上下文、SQL 上下文和 Hive 上下文。SparkSession 实质上是这些上下文的组合,包括 StreamingContext

例如,您不再需要编写:

df = sqlContext.read \
    .format('json').load('py/test/sql/people.json')

现在您可以编写:

df = spark.read.format('json').load('py/test/sql/people.json')

或者:

df = spark.read.json('py/test/sql/people.json')

SparkSession 现在是读取数据、处理元数据、配置会话和管理集群资源的入口点。

Tungsten 第二阶段

当项目开始时,对计算机硬件景观的基本观察是,尽管在 RAM 内存、磁盘和(在一定程度上)网络接口中 性能/价格比 有所提高,但 CPU 的 性能/价格比 进步并不相同。尽管硬件制造商可以在每个插槽中放入更多的核心(即通过并行化提高性能),但实际核心速度并没有显著提高。

Project Tungsten 于 2015 年推出,旨在对 Spark 引擎进行重大改进,重点是提高性能。这些改进的第一阶段主要集中在以下方面:

  • 内存管理和二进制处理:利用应用语义显式管理内存并消除 JVM 对象模型和垃圾回收的开销。

  • 缓存感知计算:算法和数据结构来利用内存层次结构。

  • 代码生成:利用代码生成来利用现代编译器和 CPU。

以下图表是更新后的 Catalyst 引擎,表示包含 Dataset。如图所示(图表右侧,成本模型右侧),代码生成用于针对选定的物理计划生成底层的 RDD:

钨磷 2 阶段

来源:结构化 Spark:DataFrame、Dataset 和流 bit.ly/2cJ508x

作为钨磷 2 阶段的一部分,现在正在推动全阶段代码生成。也就是说,Spark 引擎现在将在编译时为整个 Spark 阶段生成字节码,而不是只为特定的作业或任务生成。

  • 无虚拟函数调度:这减少了在调度数十亿次时可能对性能产生深远影响的多次 CPU 调用。

  • 内存中的中间数据与 CPU 寄存器:钨磷 2 阶段将中间数据放入 CPU 寄存器。这是从 CPU 寄存器而不是从内存中获取数据周期数减少了一个数量级。

  • 循环展开和 SIMD:优化 Apache Spark 的执行引擎,以利用现代编译器和 CPU 高效编译和执行简单for循环的能力(与复杂的函数调用图相反)。

要深入了解 Project Tungsten,请参阅:

结构化流

如 Reynold Xin 在 2016 年 Spark Summit East 上所说:

“执行流式分析最简单的方法就是无需对进行推理。”

这是构建结构化流的基础。虽然流式处理功能强大,但其中一个关键问题是流式处理可能难以构建和维护。尽管像 Uber、Netflix 和 Pinterest 这样的公司已经在生产中运行 Spark Streaming 应用程序,但他们也有专门的团队来确保系统高度可用。

注意

关于 Spark Streaming 的高级概述,请参阅 Spark Streaming:它是什麼以及谁在使用它?bit.ly/1Qb10f6

如前所述,在操作 Spark Streaming(以及任何流式系统)时可能会出现许多问题,包括但不限于迟到的事件、部分输出到最终数据源、失败时的状态恢复以及/或分布式读写:

结构化流

来源:深入解析结构化流 bit.ly/2aHt1w0

因此,为了简化 Spark Streaming,现在有一个单一的 API 解决了 Apache Spark 2.0 发布中的批量和流式处理。更简洁地说,高级流式处理 API 现在建立在 Apache Spark SQL 引擎之上。它运行与使用 Datasets/DataFrames 相同的查询,为您提供所有性能和优化优势,以及诸如事件时间、窗口、会话、源和汇等好处。

持续应用程序

总的来说,Apache Spark 2.0 不仅统一了 DataFrame 和 Dataset,还统一了流式处理、交互式和批量查询。这开启了一系列全新的用例,包括将数据聚合到流中,然后使用传统的 JDBC/ODBC 进行服务,在运行时更改查询,以及/或在不同延迟用例中构建和应用 ML 模型:

持续应用程序

来源:Apache Spark 关键术语解释 databricks.com/blog/2016/06/22/apache-spark-key-terms-explained.html

现在,您可以一起构建端到端的持续应用程序,在其中您可以向批量处理和实时数据发出相同的查询,执行 ETL,生成报告,更新或跟踪流中的特定数据。

注意

关于持续应用程序的更多信息,请参阅 Matei Zaharia 的博客文章持续应用程序:Apache Spark 2.0 中流式处理的演变 - 端到端实时应用程序的基础 bit.ly/2aJaSOr

摘要

在本章中,我们回顾了什么是 Apache Spark,并提供了 Spark 作业和 API 的入门介绍。我们还提供了关于弹性分布式数据集(RDDs)、DataFrames 和 Dataset 的入门介绍;我们将在后续章节中进一步探讨 RDDs 和 DataFrames。我们还讨论了由于 Spark SQL 引擎的 Catalyst 优化器和 Project Tungsten,DataFrames 如何在 Apache Spark 中提供更快的查询性能。最后,我们还提供了 Spark 2.0 架构的高级概述,包括 Tungsten Phase 2、Structured Streaming 以及统一 DataFrames 和 Dataset。

在下一章中,我们将介绍 Spark 中的一个基本数据结构:弹性分布式数据集,或 RDDs。我们将向您展示如何使用转换器和操作来创建和修改这些无模式的数据库结构,以便您的 PySpark 之旅可以开始。

在此之前,然而,请检查链接www.tomdrabas.com/site/book中的 Bonus Chapter 1,其中概述了如何在您的机器上本地安装 Spark 的说明(除非您已经安装了它)。以下是直接链接到手册:www.packtpub.com/sites/default/files/downloads/InstallingSpark.pdf

第二章。弹性分布式数据集

弹性分布式数据集(RDDs)是一组不可变的 JVM 对象的分布式集合,允许您非常快速地进行计算,它们是 Apache Spark 的 骨干

正如其名所示,数据集是分布式的;它根据某些键分割成块,并分布到执行节点。这样做可以非常快速地运行针对此类数据集的计算。此外,如第一章中已提到的,“理解 Spark”,RDD 会跟踪(记录)对每个块应用的所有转换,以加快计算并提供回退机制,以防出现错误且该部分数据丢失;在这种情况下,RDD 可以重新计算数据。这种数据血缘关系是防止数据丢失的另一道防线,是数据复制的补充。

本章涵盖了以下主题:

  • RDD 的内部工作原理

  • 创建 RDD

  • 全局与局部作用域

  • 转换

  • 操作

RDD 的内部工作原理

RDDs 以并行方式运行。这是在 Spark 中工作的最大优势:每个转换都是并行执行的,这极大地提高了速度。

对数据集的转换是惰性的。这意味着只有当对数据集调用操作时,任何转换才会执行。这有助于 Spark 优化执行。例如,考虑以下分析师通常为了熟悉数据集而执行的非常常见的步骤:

  1. 计算某个列中 distinct 值的出现次数。

  2. 选择以 A 开头的记录。

  3. 将结果打印到屏幕上。

虽然前面提到的步骤听起来很简单,但如果只对以字母 A 开头的项感兴趣,就没有必要对所有其他项的 distinct 值进行计数。因此,而不是按照前面几点所述的执行流程,Spark 只能计数以 A 开头的项,然后将结果打印到屏幕上。

让我们用代码来分解这个例子。首先,我们使用 .map(lambda v: (v, 1)) 方法让 Spark 映射 A 的值,然后选择以 'A' 开头的记录(使用 .filter(lambda val: val.startswith('A')) 方法)。如果我们调用 .reduceByKey(operator.add) 方法,它将减少数据集并 添加(在这个例子中,是计数)每个键的出现次数。所有这些步骤 转换 数据集。

其次,我们调用 .collect() 方法来执行步骤。这一步是对我们数据集的 操作 - 它最终计算数据集的 distinct 元素。实际上,操作可能会颠倒转换的顺序,先过滤数据然后再映射,从而将较小的数据集传递给归约器。

注意

如果您目前还不理解前面的命令,请不要担心 - 我们将在本章后面详细解释它们。

创建 RDD

在 PySpark 中创建 RDD 有两种方式:你可以 .parallelize(...) 一个集合(list 或某些元素的 array):

data = sc.parallelize(
    [('Amber', 22), ('Alfred', 23), ('Skye',4), ('Albert', 12), 
     ('Amber', 9)])

或者你可以引用位于本地或外部位置的文件(或文件):

data_from_file = sc.\    
    textFile(
        '/Users/drabast/Documents/PySpark_Data/VS14MORT.txt.gz',
        4)

注意

我们从(于 2016 年 7 月 31 日访问)ftp://ftp.cdc.gov/pub/Health_Statistics/NCHS/Datasets/DVS/mortality/mort2014us.zip 下载了死亡率数据集 VS14MORT.txt 文件;记录模式在本文档中解释 www.cdc.gov/nchs/data/dvs/Record_Layout_2014.pdf。我们故意选择这个数据集:记录的编码将帮助我们解释如何在本章后面使用 UDFs 来转换你的数据。为了你的方便,我们还在这里托管了文件:tomdrabas.com/data/VS14MORT.txt.gz

sc.textFile(..., n) 的最后一个参数指定了数据集被分割成的分区数。

提示

一个经验法则是将你的数据集分成两到四个分区,每个分区在你的集群中。

Spark 可以从多种文件系统中读取:本地文件系统,如 NTFS、FAT 或 Mac OS 扩展(HFS+),或分布式文件系统,如 HDFS、S3、Cassandra 等。

提示

注意你的数据集是从哪里读取或保存的:路径不能包含特殊字符 []。注意,这也适用于存储在 Amazon S3 或 Microsoft Azure Data Storage 上的路径。

支持多种数据格式:文本、parquet、JSON、Hive 表以及来自关系数据库的数据可以使用 JDBC 驱动程序读取。请注意,Spark 可以自动处理压缩数据集(如我们前面的例子中的 Gzipped 数据集)。

根据数据的读取方式,持有数据的对象将略有不同。当我们 .paralellize(...) 一个集合时,从文件读取的数据将表示为 MapPartitionsRDD 而不是 ParallelCollectionRDD

模式

RDDs 是无模式的(与我们在下一章中将要讨论的 DataFrames 不同)。因此,当使用 RDDs 时,Spark 在并行化数据集(如下代码片段所示)时是完全可以接受的:

data_heterogenous = sc.parallelize([
    ('Ferrari', 'fast'),
    {'Porsche': 100000},
    ['Spain','visited', 4504]
]).collect()

因此,我们可以混合几乎所有东西:一个 tuple、一个 dict 或一个 list,Spark 不会抱怨。

一旦你 .collect() 收集数据集(即运行一个操作将其返回到驱动程序),你就可以像在 Python 中正常那样访问对象中的数据:

data_heterogenous[1]['Porsche']

它将产生以下输出:

100000

.collect() 方法将 RDD 的所有元素返回到驱动程序,其中它被序列化为一个列表。

注意

我们将在本章后面更详细地讨论使用 .collect() 的注意事项。

从文件读取

当你从文本文件中读取时,文件中的每一行形成一个 RDD 的元素。

data_from_file.take(1) 命令将产生以下(有些难以阅读)输出:

从文件读取

为了使其更易于阅读,让我们创建一个元素列表,这样每行都表示为一个值列表。

Lambda 表达式

在这个例子中,我们将从 data_from_file 的神秘记录中提取有用的信息。

注意

请参阅我们 GitHub 仓库中这本书的详细信息,关于此方法的细节。在这里,由于空间限制,我们只展示完整方法的简略版,特别是我们创建正则表达式模式的部分。代码可以在以下位置找到:github.com/drabastomek/learningPySpark/tree/master/Chapter03/LearningPySpark_Chapter03.ipynb

首先,让我们在以下代码的帮助下定义该方法,该代码将解析不可读的行,使其变得可使用:

def extractInformation(row):
    import re
    import numpy as np
    selected_indices = [
         2,4,5,6,7,9,10,11,12,13,14,15,16,17,18,
         ...
         77,78,79,81,82,83,84,85,87,89
    ]
    record_split = re\
        .compile(
            r'([\s]{19})([0-9]{1})([\s]{40})
            ...
            ([\s]{33})([0-9\s]{3})([0-9\s]{1})([0-9\s]{1})')
    try:
        rs = np.array(record_split.split(row))[selected_indices]
    except:
        rs = np.array(['-99'] * len(selected_indices))
    return rs

小贴士

在这里需要提醒的是,定义纯 Python 方法可能会减慢你的应用程序,因为 Spark 需要不断地在 Python 解释器和 JVM 之间切换。只要可能,你应该使用内置的 Spark 函数。

接下来,我们导入必要的模块:re 模块,因为我们将会使用正则表达式来解析记录,以及 NumPy 以便于一次性选择多个元素。

最后,我们创建一个 Regex 对象来提取所需的信息,并通过它解析行。

注意

我们不会深入描述正则表达式。关于这个主题的良善汇编可以在以下位置找到:www.packtpub.com/application-development/mastering-python-regular-expressions

一旦解析了记录,我们尝试将列表转换为 NumPy 数组并返回它;如果失败,我们返回一个包含默认值 -99 的列表,这样我们知道这个记录没有正确解析。

小贴士

我们可以通过使用 .flatMap(...) 隐式过滤掉格式不正确的记录,并返回一个空列表 [] 而不是 -99 值。有关详细信息,请参阅:stackoverflow.com/questions/34090624/remove-elements-from-spark-rdd

现在,我们将使用 extractInformation(...) 方法来分割和转换我们的数据集。请注意,我们只传递方法签名到 .map(...):该方法将每次在每个分区中 hand over 一个 RDD 元素到 extractInformation(...) 方法:

data_from_file_conv = data_from_file.map(extractInformation)

运行 data_from_file_conv.take(1) 将产生以下结果(简略):

Lambda 表达式

全局与局部作用域

作为潜在的 PySpark 用户,你需要习惯 Spark 的固有并行性。即使你精通 Python,在 PySpark 中执行脚本也需要稍微转变一下思维方式。

Spark 可以以两种模式运行:本地和集群。当您在本地运行 Spark 时,您的代码可能与您目前习惯的 Python 运行方式不同:更改可能更多的是语法上的,但有一个额外的变化,即数据和代码可以在不同的工作进程之间复制。

然而,如果您不小心,将相同的代码部署到集群中可能会让您感到困惑。这需要理解 Spark 如何在集群上执行作业。

在集群模式下,当提交作业以执行时,作业被发送到驱动程序节点(或主节点)。驱动程序节点为作业创建一个 DAG(见第一章,理解 Spark),并决定哪些执行器(或工作节点)将运行特定任务。

然后,驱动程序指示工作进程执行其任务,并在完成后将结果返回给驱动程序。然而,在发生之前,驱动程序会为每个任务准备闭包:一组变量和方法,这些变量和方法在驱动程序上存在,以便工作进程可以在 RDD 上执行其任务。

这组变量和方法在执行器上下文中本质上是静态的,也就是说,每个执行器都会从驱动程序获取变量和方法的一个副本。如果在运行任务时,执行器更改这些变量或覆盖方法,它将这样做而不影响其他执行器的副本或驱动程序的变量和方法。这可能会导致一些意外的行为和运行时错误,有时很难追踪。

注意

查看 PySpark 文档中的这个讨论以获取更实际的示例:spark.apache.org/docs/latest/programming-guide.html#local-vs-cluster-modes

转换

转换塑造了您的数据集。这包括映射、过滤、连接和转码数据集中的值。在本节中,我们将展示 RDD 上可用的某些转换。

注意

由于空间限制,我们在此仅包含最常用的转换和操作。对于完整的方法集,我们建议您查看 PySpark 关于 RDD 的文档spark.apache.org/docs/latest/api/python/pyspark.html#pyspark.RDD

由于 RDD 是无模式的,在本节中我们假设您知道生成的数据集的模式。如果您无法记住解析的数据集中信息的位置,我们建议您参考 GitHub 上extractInformation(...)方法的定义,代码在第三章

.map(...)转换

可以说,您将最常使用.map(...)转换。该方法应用于 RDD 的每个元素:在data_from_file_conv数据集的情况下,您可以将其视为对每行的转换。

在这个例子中,我们将创建一个新的数据集,将死亡年份转换为数值:

data_2014 = data_from_file_conv.map(lambda row: int(row[16]))

运行data_2014.take(10)将产生以下结果:

The .map(...) transformation

小贴士

如果你不太熟悉lambda表达式,请参阅此资源:pythonconquerstheuniverse.wordpress.com/2011/08/29/lambda_tutorial/

你当然可以引入更多的列,但你需要将它们打包成一个tupledictlist。让我们也包含行中的第 17 个元素,以便我们可以确认我们的.map(...)按预期工作:

data_2014_2 = data_from_file_conv.map(
    lambda row: (row[16], int(row[16]):)
data_2014_2.take(5)

上述代码将产生以下结果:

The .map(...) transformation

The .filter(...) transformation

另一个最常使用的转换方法是.filter(...)方法,它允许你从数据集中选择符合特定标准的元素。作为一个例子,从data_from_file_conv数据集中,让我们计算有多少人在 2014 年发生了事故死亡:

data_filtered = data_from_file_conv.filter(
    lambda row: row[16] == '2014' and row[21] == '0')
data_filtered.count()

小贴士

注意,前面的命令可能需要一段时间,具体取决于你的电脑有多快。对我们来说,它花了点超过两分钟的时间才返回结果。

The .flatMap(...) transformation

.flatMap(...)方法与.map(...)类似,但它返回一个扁平化的结果而不是列表。如果我们执行以下代码:

data_2014_flat = data_from_file_conv.flatMap(lambda row: (row[16], int(row[16]) + 1))
data_2014_flat.take(10)

它将产生以下输出:

The .flatMap(...) transformation

你可以将这个结果与之前生成data_2014_2的命令的结果进行比较。注意,正如之前提到的,.flatMap(...)方法可以在你需要解析输入时用来过滤掉一些格式不正确的记录。在底层,.flatMap(...)方法将每一行视为一个列表,然后简单地添加所有记录;通过传递一个空列表,格式不正确的记录将被丢弃。

The .distinct(...) transformation

此方法返回指定列中的唯一值列表。如果你想要了解你的数据集或验证它,这个方法非常有用。让我们检查gender列是否只包含男性和女性;这将验证我们是否正确解析了数据集。让我们运行以下代码:

distinct_gender = data_from_file_conv.map(
    lambda row: row[5]).distinct()
distinct_gender.collect()

此代码将产生以下结果:

The .distinct(...) transformation

首先,我们只提取包含性别的列。接下来,我们使用.distinct()方法来选择列表中的唯一值。最后,我们使用.collect()方法来在屏幕上打印这些值。

小贴士

注意,这是一个昂贵的操作,应该谨慎使用,并且仅在必要时使用,因为它会在数据周围进行洗牌。

The .sample(...) transformation

.sample(...) 方法从数据集中返回一个随机样本。第一个参数指定采样是否带替换,第二个参数定义要返回的数据的分数,第三个是伪随机数生成器的种子:

fraction = 0.1
data_sample = data_from_file_conv.sample(False, fraction, 666)

在这个例子中,我们从原始数据集中选择了 10% 的随机样本。为了确认这一点,让我们打印数据集的大小:

print('Original dataset: {0}, sample: {1}'\
.format(data_from_file_conv.count(), data_sample.count()))

前面的命令产生了以下输出:

.sample(...) 转换

我们使用 .count() 操作来计算相应 RDD 中的所有记录数。

.leftOuterJoin(...) 转换

.leftOuterJoin(...),就像在 SQL 世界中一样,基于两个数据集中找到的值将两个 RDD 连接起来,并返回来自左 RDD 的记录,在两个 RDD 匹配的地方附加来自右 RDD 的记录:

rdd1 = sc.parallelize([('a', 1), ('b', 4), ('c',10)])
rdd2 = sc.parallelize([('a', 4), ('a', 1), ('b', '6'), ('d', 15)])
rdd3 = rdd1.leftOuterJoin(rdd2)

rdd3 上运行 .collect(...) 将产生以下结果:

.leftOuterJoin(...) 转换

提示

这是一种成本较高的方法,应该谨慎使用,并且仅在必要时使用,因为它会在数据周围进行洗牌,从而影响性能。

你在这里看到的是来自 RDD rdd1 的所有元素及其来自 RDD rdd2 的对应值。正如你所见,值 'a'rdd3 中出现了两次,并且 'a' 在 RDD rdd2 中也出现了两次。rdd1 中的值 b 只出现一次,并与来自 rdd2 的值 '6' 相连接。有两件事缺失rdd1 中的值 'c'rdd2 中没有对应的键,因此在返回的元组中的值显示为 None,并且,由于我们执行的是左外连接,rdd2 中的值 'd' 如预期那样消失了。

如果我们使用 .join(...) 方法,我们只会得到 'a''b' 的值,因为这两个值在这两个 RDD 之间相交。运行以下代码:

rdd4 = rdd1.join(rdd2)
rdd4.collect()

它将产生以下输出:

.leftOuterJoin(...) 转换

另一个有用的方法是 .intersection(...),它返回在两个 RDD 中相等的记录。执行以下代码:

rdd5 = rdd1.intersection(rdd2)
rdd5.collect()

输出如下:

.leftOuterJoin(...) 转换

.repartition(...) 转换

重新分区数据集会改变数据集被分割成的分区数量。这个功能应该谨慎使用,并且仅在真正必要时使用,因为它会在数据周围进行洗牌,这在实际上会导致性能的显著下降:

rdd1 = rdd1.repartition(4)
len(rdd1.glom().collect())

前面的代码打印出 4 作为新的分区数量。

.collect() 相比,.glom() 方法产生一个列表,其中每个元素是另一个列表,包含在指定分区中存在的数据集的所有元素;返回的主要列表具有与分区数量相同的元素数量。

操作

与转换不同,动作在数据集上执行计划的任务;一旦你完成了数据的转换,你就可以执行转换。这可能不包含任何转换(例如,.take(n) 将只从 RDD 返回 n 条记录,即使你没有对它进行任何转换)或执行整个转换链。

.take(...) 方法

这可能是最有用(并且使用最频繁,例如 .map(...) 方法)。该方法比 .collect(...) 更受欢迎,因为它只从单个数据分区返回 n 个顶部行,而 .collect(...) 则返回整个 RDD。这在处理大型数据集时尤为重要:

data_first = data_from_file_conv.take(1)

如果你想要一些随机的记录,可以使用 .takeSample(...) 代替,它接受三个参数:第一个参数指定采样是否带替换,第二个参数指定要返回的记录数,第三个参数是伪随机数生成器的种子:

data_take_sampled = data_from_file_conv.takeSample(False, 1, 667)

.collect(...) 方法

此方法将 RDD 的所有元素返回给驱动器。正如我们刚刚已经对此提出了警告,我们在这里不再重复。

.reduce(...) 方法

.reduce(...) 方法使用指定的方法对 RDD 的元素进行归约。

你可以使用它来对 RDD 的元素进行求和:

rdd1.map(lambda row: row[1]).reduce(lambda x, y: x + y)

这将产生15的总和。

我们首先使用 .map(...) 转换创建 rdd1 所有值的列表,然后使用 .reduce(...) 方法处理结果。reduce(...) 方法在每个分区上运行求和函数(这里表示为 lambda),并将求和结果返回给驱动节点,在那里进行最终聚合。

注意

这里需要提醒一句。作为 reducer 传递的函数需要是结合律,也就是说,当元素的顺序改变时,结果不会改变,并且交换律,也就是说,改变操作数的顺序也不会改变结果。

结合律的例子是 (5 + 2) + 3 = 5 + (2 + 3),交换律的例子是 5 + 2 + 3 = 3 + 2 + 5。因此,你需要小心传递给 reducer 的函数。

如果你忽略了前面的规则,你可能会遇到麻烦(假设你的代码能正常运行的话)。例如,假设我们有一个以下 RDD(只有一个分区!):

data_reduce = sc.parallelize([1, 2, .5, .1, 5, .2], 1)

如果我们以我们想要将当前结果除以下一个结果的方式来减少数据,我们期望得到10的值:

works = data_reduce.reduce(lambda x, y: x / y)

然而,如果你将数据分区成三个分区,结果将会是错误的:

data_reduce = sc.parallelize([1, 2, .5, .1, 5, .2], 3)
data_reduce.reduce(lambda x, y: x / y)

它将产生 0.004

.reduceByKey(...) 方法的工作方式与 .reduce(...) 方法类似,但它是在键键基础上进行归约:

data_key = sc.parallelize(
    [('a', 4),('b', 3),('c', 2),('a', 8),('d', 2),('b', 1),
     ('d', 3)],4)
data_key.reduceByKey(lambda x, y: x + y).collect()

上述代码会产生以下结果:

.reduce(...) 方法

.count(...) 方法

.count(...) 方法计算 RDD 中元素的数量。使用以下代码:

data_reduce.count()

此代码将产生 6,这是 data_reduce RDD 中元素的确切数量。

.count(...) 方法产生的结果与以下方法相同,但它不需要将整个数据集移动到驱动程序:

len(data_reduce.collect()) # WRONG -- DON'T DO THIS!

如果您的数据集是键值形式,您可以使用 .countByKey() 方法来获取不同键的计数。运行以下代码:

data_key.countByKey().items()

此代码将产生以下输出:

.count(...) 方法

.saveAsTextFile(...) 方法

如其名所示,.saveAsTextFile(...) 方法将 RDD 保存为文本文件:每个分区保存到一个单独的文件中:

data_key.saveAsTextFile(
'/Users/drabast/Documents/PySpark_Data/data_key.txt')

要读取它,你需要将其解析回字符串,因为所有行都被视为字符串:

def parseInput(row):
    import re    
    pattern = re.compile(r'\(\'([a-z])\', ([0-9])\)')
    row_split = pattern.split(row)
    return (row_split[1], int(row_split[2]))

data_key_reread = sc \
    .textFile(
        '/Users/drabast/Documents/PySpark_Data/data_key.txt') \
    .map(parseInput)
data_key_reread.collect()

读取的键列表与我们最初的有匹配:

.saveAsTextFile(...) 方法

.foreach(...) 方法

这是一个将相同的函数以迭代方式应用于 RDD 中每个元素的方法;与 .map(..) 相比,.foreach(...) 方法以一对一的方式对每条记录应用定义的函数。当您想将数据保存到 PySpark 本地不支持的数据库时,它非常有用。

在这里,我们将使用它来打印(到 CLI - 而不是 Jupyter Notebook)存储在 data_key RDD 中的所有记录:

def f(x): 
    print(x)

data_key.foreach(f)

如果你现在导航到 CLI,你应该看到所有记录被打印出来。注意,每次的顺序很可能是不同的。

摘要

RDD 是 Spark 的骨架;这些无模式的数据库结构是我们将在 Spark 中处理的最基本的数据结构。

在本章中,我们介绍了通过 .parallelize(...) 方法以及从文本文件中读取数据来创建 RDD 的方法。还展示了处理非结构化数据的一些方法。

Spark 中的转换是惰性的 - 只有在调用操作时才会应用。在本章中,我们讨论并介绍了最常用的转换和操作;PySpark 文档包含更多内容spark.apache.org/docs/latest/api/python/pyspark.html#pyspark.RDD

Scala 和 Python RDD 之间的一个主要区别是速度:Python RDD 可能比它们的 Scala 对应物慢得多。

在下一章中,我们将向您介绍一种数据结构,它使 PySpark 应用程序的性能与 Scala 编写的应用程序相当 - 数据帧。

第三章:数据帧

DataFrame 是一个不可变的分布式数据集合,它组织成命名的列,类似于关系数据库中的表。作为 Apache Spark 1.0 中的实验性功能 SchemaRDD 的一部分引入,它们在 Apache Spark 1.3 发布中更名为 DataFrames。对于熟悉 Python Pandas DataFrame 或 R DataFrame 的读者,Spark DataFrame 是一个类似的概念,它允许用户轻松地处理结构化数据(例如,数据表);也有一些差异,所以请调整您的期望。

通过对分布式数据集合施加结构,这允许 Spark 用户在 Spark SQL 或使用表达式方法(而不是 lambda 函数)中查询结构化数据。在本章中,我们将包括使用这两种方法的代码示例。通过结构化您的数据,这允许 Apache Spark 引擎——特别是 Catalyst 优化器——显著提高 Spark 查询的性能。在 Spark 早期 API(即 RDDs)中,由于 Java JVM 和 Py4J 之间的通信开销,执行 Python 中的查询可能会显著变慢。

注意

如果您熟悉在 Spark 早期版本(即 Spark 1.x)中与 DataFrame 一起工作,您会注意到在 Spark 2.0 中,我们使用 SparkSession 而不是 SQLContext。各种 Spark 上下文:HiveContextSQLContextStreamingContextSparkContext 已合并到 SparkSession 中。这样,您将只作为读取数据、处理元数据、配置和集群资源管理的入口点与这个会话一起工作。

更多信息,请参阅 如何在 Apache Spark 2.0 中使用 SparkSession(bit.ly/2br0Fr1)。

在本章中,您将了解以下内容:

  • Python 到 RDD 通信

  • 快速回顾 Spark 的 Catalyst 优化器

  • 使用 DataFrames 加速 PySpark

  • 创建 DataFrame

  • 简单的 DataFrame 查询

  • 与 RDD 交互

  • 使用 DataFrame API 进行查询

  • 使用 Spark SQL 进行查询

  • 使用 DataFrame 进行准点航班性能分析

Python 到 RDD 通信

每当使用 RDDs 执行 PySpark 程序时,执行作业可能会有很大的开销。如以下图所示,在 PySpark 驱动程序中,Spark Context 使用 Py4j 通过 JavaSparkContext 启动 JVM。任何 RDD 转换最初都映射到 Java 中的 PythonRDD 对象。

一旦这些任务被推送到 Spark Worker(s),PythonRDD 对象将使用管道启动 Python subprocesses,以发送 代码和数据 到 Python 中进行处理:

Python 到 RDD 通信

虽然这种方法允许 PySpark 将数据处理分布到多个工作者的多个 Python 子进程中,但如您所见,Python 和 JVM 之间存在大量的上下文切换和通信开销。

注意

关于 PySpark 性能的优秀资源是 Holden Karau 的改进 PySpark 性能:Spark 性能超越 JVMbit.ly/2bx89bn

Catalyst 优化器刷新

如第一章中所述,理解 Spark,Spark SQL 引擎之所以如此快速,其中一个主要原因是Catalyst 优化器。对于有数据库背景的读者来说,这个图看起来与关系数据库管理系统(RDBMS)的逻辑/物理规划器和基于成本的优化模型/基于成本的优化类似:

Catalyst 优化器刷新

这的重要性在于,与立即处理查询相反,Spark 引擎的 Catalyst 优化器编译并优化一个逻辑计划,并有一个成本优化器来确定生成的最有效物理计划。

注意

如前几章所述,虽然 Spark SQL 引擎既有基于规则的优化也有基于成本的优化,包括(但不限于)谓词下推和列剪枝。针对 Apache Spark 2.2 版本,jira 项目[SPARK-16026]基于成本的优化器框架issues.apache.org/jira/browse/SPARK-16026是一个涵盖广播连接选择之外基于成本的优化器框架的通用票据。更多信息,请参阅bit.ly/2li1t4T上的Spark 基于成本优化设计规范

作为Project Tungsten的一部分,通过生成字节码(代码生成或codegen)而不是解释每一行数据来进一步提高性能。更多关于 Tungsten 的详细信息,请参阅第一章中理解 Spark章节的Project Tungsten部分。

如前所述,优化器基于函数式编程结构,并设计有两个目的:简化向 Spark SQL 添加新的优化技术和功能,并允许外部开发者扩展优化器(例如,添加数据源特定的规则、支持新的数据类型等)。

注意

更多信息,请参阅 Michael Armbrust 的优秀演示文稿,结构化 Spark:SQL DataFrames、Datasets 和 Streamingbit.ly/2cJ508x

要进一步了解Catalyst 优化器,请参阅bit.ly/2bDVB1T上的深入 Spark SQL 的 Catalyst 优化器

此外,有关Project Tungsten的更多信息,请参阅bit.ly/2bQIlKY上的Project Tungsten:将 Apache Spark 带到裸金属更近一步,以及bit.ly/2bDWtnc上的Apache Spark 作为编译器:在笔记本电脑上每秒处理十亿行数据

使用 DataFrames 加速 PySpark

DataFrame 和Catalyst Optimizer(以及Project Tungsten)的重要性在于与未优化的 RDD 查询相比,PySpark 查询性能的提升。如图所示,在引入 DataFrame 之前,Python 查询速度通常比使用 RDD 的相同 Scala 查询慢两倍。通常,这种查询性能的下降是由于 Python 和 JVM 之间的通信开销:

使用 DataFrame 加速 PySpark

来源:在 Apache-spark 中介绍 DataFrame 用于大规模数据科学,请参阅bit.ly/2blDBI1

使用 DataFrame,不仅 Python 性能有了显著提升,现在 Python、Scala、SQL 和 R 之间的性能也实现了对等。

提示

重要的是要注意,虽然 DataFrame 使得 PySpark 通常运行得更快,但也有一些例外。最突出的是 Python UDF 的使用,这会导致 Python 和 JVM 之间的往返通信。注意,这将是最坏的情况,如果计算是在 RDD 上完成的,情况将相似。

即使 Catalyst Optimizer 的代码库是用 Scala 编写的,Python 也可以利用 Spark 的性能优化。基本上,它是一个大约 2,000 行代码的 Python 包装器,允许 PySpark DataFrame 查询显著加快。

总的来说,Python DataFrame(以及 SQL、Scala DataFrame 和 R DataFrame)都能够利用 Catalyst Optimizer(如下面的更新图所示):

使用 DataFrame 加速 PySpark

注意

有关更多信息,请参阅博客文章在 Apache Spark 中介绍 DataFrame 用于大规模数据科学,请参阅bit.ly/2blDBI1,以及 Reynold Xin 在 Spark Summit 2015 上的演讲,从 DataFrame 到 Tungsten:一瞥 Spark 的未来,请参阅bit.ly/2bQN92T

创建 DataFrame

通常,你将通过使用 SparkSession(或在 PySpark shell 中调用spark)导入数据来创建 DataFrame。

提示

在 Spark 1.x 版本中,你通常必须使用sqlContext

在未来的章节中,我们将讨论如何将数据导入你的本地文件系统、Hadoop 分布式文件系统HDFS)或其他云存储系统(例如,S3 或 WASB)。对于本章,我们将专注于在 Spark 中直接生成自己的 DataFrame 数据或利用 Databricks 社区版中已有的数据源。

注意

关于如何注册 Databricks 社区版的说明,请参阅附录章节,免费 Spark 云服务提供

首先,我们不会访问文件系统,而是通过生成数据来创建一个 DataFrame。在这种情况下,我们首先创建stringJSONRDD RDD,然后将其转换为 DataFrame。此代码片段创建了一个包含游泳者(他们的 ID、姓名、年龄和眼睛颜色)的 JSON 格式的 RDD。

生成我们自己的 JSON 数据

下面,我们将首先生成 stringJSONRDD RDD:

stringJSONRDD = sc.parallelize(("""
  { "id": "123",
"name": "Katie",
"age": 19,
"eyeColor": "brown"
  }""",
"""{
"id": "234",
"name": "Michael",
"age": 22,
"eyeColor": "green"
  }""", 
"""{
"id": "345",
"name": "Simone",
"age": 23,
"eyeColor": "blue"
  }""")
)

现在我们已经创建了 RDD,我们将使用 SparkSession 的 read.json 方法(即 spark.read.json(...))将其转换为 DataFrame。我们还将使用 .createOrReplaceTempView 方法创建一个临时表。

注意

在 Spark 1.x 中,此方法为 .registerTempTable,它作为 Spark 2.x 的一部分已被弃用。

创建 DataFrame

下面是创建 DataFrame 的代码:

swimmersJSON = spark.read.json(stringJSONRDD)

创建临时表

下面是创建临时表的代码:

swimmersJSON.createOrReplaceTempView("swimmersJSON")

如前几章所述,许多 RDD 操作是转换,它们只有在执行动作操作时才会执行。例如,在前面的代码片段中,sc.parallelize 是一个转换,它在将 RDD 转换为 DataFrame 时执行,即使用 spark.read.json。注意,在这个代码片段笔记本的截图(靠近左下角)中,Spark 作业直到包含 spark.read.json 操作的第二个单元格才会执行。

小贴士

这些截图来自 Databricks Community Edition,但所有代码示例和 Spark UI 截图都可以在任何 Apache Spark 2.x 版本中执行/查看。

为了进一步强调这一点,在以下图例的右侧面板中,我们展示了执行 DAG 图。

注意

更好地理解 Spark UI DAG 可视化的一个绝佳资源是博客文章《通过可视化理解您的 Apache Spark 应用程序》(bit.ly/2cSemkv)。

在以下截图,你可以看到 Spark 作业的 parallelize 操作来自于生成 RDD stringJSONRDD 的第一个单元格,而 mapmapPartitions 操作是创建 DataFrame 所需的操作:

创建临时表

Spark UI 中 spark.read.json(stringJSONRDD) 作业的 DAG 可视化。

在以下截图,你可以看到 parallelize 操作的 阶段 来自于生成 RDD stringJSONRDD 的第一个单元格,而 mapmapPartitions 操作是创建 DataFrame 所需的操作:

创建临时表

Spark UI 中 spark.read.json(stringJSONRDD) 作业的 DAG 可视化阶段。

重要的是要注意,parallelizemapmapPartitions 都是 RDD 转换。包裹在 DataFrame 操作(在这种情况下为 spark.read.json)中的不仅仅是 RDD 转换,还包括将 RDD 转换为 DataFrame 的 动作。这是一个重要的说明,因为尽管你正在执行 DataFrame 操作,但在调试操作时,你需要记住你将需要在 Spark UI 中理解 RDD 操作

注意,创建临时表是 DataFrame 转换,并且不会在执行 DataFrame 动作之前执行(例如,在下一节中要执行的 SQL 查询中)。

注意

DataFrame 转换和操作与 RDD 转换和操作类似,因为存在一组惰性操作(转换)。但是,与 RDD 相比,DataFrame 操作的惰性程度较低,这主要是由于 Catalyst 优化器。有关更多信息,请参阅 Holden Karau 和 Rachel Warren 的书籍 High Performance Sparkhighperformancespark.com/

简单的 DataFrame 查询

现在您已经创建了 swimmersJSON DataFrame,我们将能够运行 DataFrame API,以及针对它的 SQL 查询。让我们从一个简单的查询开始,显示 DataFrame 中的所有行。

DataFrame API 查询

要使用 DataFrame API 执行此操作,您可以使用 show(<n>) 方法,该方法将前 n 行打印到控制台:

小贴士

运行 .show() 方法将默认显示前 10 行。

# DataFrame API
swimmersJSON.show()

这将产生以下输出:

DataFrame API 查询

SQL 查询

如果您更喜欢编写 SQL 语句,可以编写以下查询:

spark.sql("select * from swimmersJSON").collect()

这将产生以下输出:

SQL 查询

我们正在使用 .collect() 方法,它返回所有记录作为 Row 对象的列表。请注意,您可以使用 collect()show() 方法对 DataFrame 和 SQL 查询进行操作。只需确保如果您使用 .collect(),这仅适用于小型 DataFrame,因为它将返回 DataFrame 中的所有行并将它们从执行器移动到驱动器。您还可以使用 take(<n>)show(<n>),这允许您通过指定 <n> 来限制返回的行数:

小贴士

注意,如果您使用 Databricks,可以使用 %sql 命令并在笔记本单元中直接运行 SQL 语句,如上所述。

SQL 查询

与 RDD 互操作

将现有的 RDD 转换为 DataFrame(或 Datasets[T])有两种不同的方法:使用反射推断模式,或程序化指定模式。前者允许您编写更简洁的代码(当您的 Spark 应用程序已经知道模式时),而后者允许您在运行时仅当列及其数据类型被揭示时构建 DataFrame。请注意,反射是指 模式反射,而不是 Python 反射

使用反射推断模式

在构建 DataFrame 和运行查询的过程中,我们跳过了这样一个事实,即此 DataFrame 的模式是自动定义的。最初,通过将键/值对列表作为 **kwargs 传递给行类来构建行对象。然后,Spark SQL 将此行对象 RDD 转换为 DataFrame,其中键是列,数据类型通过采样数据推断。

小贴士

**kwargs 构造函数允许你在运行时传递一个可变数量的参数给一个方法。

返回到代码,在最初创建 swimmersJSON DataFrame,没有指定模式的情况下,你会注意到通过使用 printSchema() 方法来定义模式:

# Print the schema
swimmersJSON.printSchema()

这将给出以下输出:

使用反射推断模式

但如果我们想指定模式,因为在这个例子中我们知道 id 实际上是一个 long 而不是一个 string 呢?

编程指定模式

在这个例子中,让我们通过引入 Spark SQL 数据类型(pyspark.sql.types)来编程指定模式,并生成一些 .csv 数据:

# Import types
from pyspark.sql.types import *

# Generate comma delimited data
stringCSVRDD = sc.parallelize([
(123, 'Katie', 19, 'brown'), 
(234, 'Michael', 22, 'green'), 
(345, 'Simone', 23, 'blue')
])

首先,我们将按照下面的 [schema] 变量将模式编码为字符串。然后我们将使用 StructTypeStructField 定义模式:

# Specify schema
schema = StructType([
StructField("id", LongType(), True),    
StructField("name", StringType(), True),
StructField("age", LongType(), True),
StructField("eyeColor", StringType(), True)
])

注意,StructField 类可以从以下几个方面进行分解:

  • name: 此字段的名称

  • dataType: 该字段的类型

  • nullable: 指示此字段的值是否可以为 null

最后,我们将我们将创建的 schema(模式)应用到 stringCSVRDD RDD(即生成的 .csv 数据)上,并创建一个临时视图,这样我们就可以使用 SQL 来查询它:

# Apply the schema to the RDD and Create DataFrame
swimmers = spark.createDataFrame(stringCSVRDD, schema)

# Creates a temporary view using the DataFrame
swimmers.createOrReplaceTempView("swimmers")

通过这个例子,我们对模式有了更细粒度的控制,可以指定 id 是一个 long(与前文中的字符串相反):

swimmers.printSchema()

这将给出以下输出:

编程指定模式

小贴士

在许多情况下,模式可以被推断(如前文所述)并且你不需要指定模式,就像前一个例子中那样。

使用 DataFrame API 查询

如前文所述,你可以从使用 collect()show()take() 开始来查看 DataFrame 中的数据(后两个选项包括限制返回行数的选项)。

行数

要获取 DataFrame 中的行数,你可以使用 count() 方法:

swimmers.count()

这将给出以下输出:

Out[13]: 3

运行过滤语句

要运行一个过滤语句,你可以使用 filter 子句;在下面的代码片段中,我们使用 select 子句来指定要返回的列:

# Get the id, age where age = 22
swimmers.select("id", "age").filter("age = 22").show()

# Another way to write the above query is below
swimmers.select(swimmers.id, swimmers.age).filter(swimmers.age == 22).show()

这个查询的输出是选择 idage 列,其中 age = 22

运行过滤语句

如果我们只想获取那些眼睛颜色以字母 b 开头的游泳者的名字,我们可以使用类似 SQL 的语法,即 like,如下面的代码所示:

# Get the name, eyeColor where eyeColor like 'b%'
swimmers.select("name", "eyeColor").filter("eyeColor like 'b%'").show()

输出如下:

运行过滤语句

使用 SQL 查询

让我们运行相同的查询,但这次我们将使用针对同一 DataFrame 的 SQL 查询。回想一下,这个 DataFrame 是可访问的,因为我们为 swimmers 执行了 .createOrReplaceTempView 方法。

行数

以下代码片段用于使用 SQL 获取 DataFrame 中的行数:

spark.sql("select count(1) from swimmers").show()

输出如下:

行数

使用 where 子句运行过滤语句

要使用 SQL 运行过滤语句,您可以使用where子句,如下面的代码片段所示:

# Get the id, age where age = 22 in SQL
spark.sql("select id, age from swimmers where age = 22").show()

此查询的输出是仅选择age等于22idage列:

使用 where 子句运行过滤语句

与 DataFrame API 查询类似,如果我们只想获取具有以字母b开眼的游泳者的名字,我们也可以使用like语法:

spark.sql(
"select name, eyeColor from swimmers where eyeColor like 'b%'").show()

输出如下:

使用 where 子句运行过滤语句

提示

更多信息,请参阅Spark SQL、DataFrames 和 Datasets 指南

注意

在使用 Spark SQL 和 DataFrames 时,一个重要的注意事项是,虽然处理 CSV、JSON 和多种数据格式都很方便,但 Spark SQL 分析查询最常用的存储格式是Parquet文件格式。它是一种列式格式,被许多其他数据处理系统支持,并且 Spark SQL 支持读取和写入 Parquet 文件,自动保留原始数据的模式。更多信息,请参阅最新的Spark SQL 编程指南 > Parquet 文件,链接为:spark.apache.org/docs/latest/sql-programming-guide.html#parquet-files。此外,还有许多与 Parquet 相关的性能优化,包括但不限于Parquet 的自动分区发现和模式迁移Apache Spark 如何使用 Parquet 元数据快速计数

DataFrame 场景 – 准时飞行性能

为了展示您可以使用 DataFrames 执行的查询类型,让我们看看准时飞行性能的使用案例。我们将分析航空公司准时性能和航班延误原因:准时数据(bit.ly/2ccJPPM),并将其与从Open Flights 机场、航空公司和航线数据(bit.ly/2ccK5hw)获得的机场数据集合并,以更好地理解与航班延误相关的变量。

提示

在本节中,我们将使用 Databricks Community Edition(Databricks 产品的免费提供),您可以在databricks.com/try-databricks获取。我们将使用 Databricks 内部的可视化和预加载数据集,以便您更容易专注于编写代码和分析结果。

如果您希望在自己的环境中运行此操作,您可以在本书的 GitHub 仓库中找到可用的数据集,网址为 github.com/drabastomek/learningPySpark

准备源数据集

我们将首先通过指定文件路径位置并使用 SparkSession 导入来处理源机场和飞行性能数据集:

# Set File Paths
flightPerfFilePath = 
"/databricks-datasets/flights/departuredelays.csv"
airportsFilePath = 
"/databricks-datasets/flights/airport-codes-na.txt"

# Obtain Airports dataset
airports = spark.read.csv(airportsFilePath, header='true', inferSchema='true', sep='\t')
airports.createOrReplaceTempView("airports")

# Obtain Departure Delays dataset
flightPerf = spark.read.csv(flightPerfFilePath, header='true')
flightPerf.createOrReplaceTempView("FlightPerformance")

# Cache the Departure Delays dataset 
flightPerf.cache()

注意,我们使用 CSV 读取器(com.databricks.spark.csv)导入数据,它适用于任何指定的分隔符(注意,机场数据是制表符分隔的,而飞行性能数据是逗号分隔的)。最后,我们缓存飞行数据集,以便后续查询更快。

连接飞行性能和机场

使用 DataFrame/SQL 的更常见任务之一是将两个不同的数据集连接起来;这通常是一项性能要求较高的操作。使用 DataFrame,这些连接的性能优化默认情况下已经包括在内:

# Query Sum of Flight Delays by City and Origin Code 
# (for Washington State)
spark.sql("""
select a.City, 
f.origin, 
sum(f.delay) as Delays 
from FlightPerformance f 
join airports a 
on a.IATA = f.origin
where a.State = 'WA'
group by a.City, f.origin
order by sum(f.delay) desc"""
).show()

在我们的场景中,我们正在查询华盛顿州的按城市和起始代码的总延误。这需要通过国际航空运输协会IATA)代码将飞行性能数据与机场数据连接起来。查询的输出如下:

连接飞行性能和机场

使用笔记本(如 Databricks、iPython、Jupyter 和 Apache Zeppelin),您可以更轻松地执行和可视化您的查询。在以下示例中,我们将使用 Databricks 笔记本。在我们的 Python 笔记本中,我们可以使用 %sql 函数在该笔记本单元格中执行 SQL 语句:

%sql
-- Query Sum of Flight Delays by City and Origin Code (for Washington State)
select a.City, f.origin, sum(f.delay) as Delays
  from FlightPerformance f
    join airports a
      on a.IATA = f.origin
 where a.State = 'WA'
 group by a.City, f.origin
 order by sum(f.delay) desc

这与之前的查询相同,但由于格式化,更容易阅读。在我们的 Databricks 笔记本示例中,我们可以快速将此数据可视化成条形图:

连接飞行性能和机场

可视化我们的飞行性能数据

让我们继续可视化我们的数据,但按美国大陆的所有州进行细分:

%sql
-- Query Sum of Flight Delays by State (for the US)
select a.State, sum(f.delay) as Delays
  from FlightPerformance f
    join airports a
      on a.IATA = f.origin
 where a.Country = 'USA'
 group by a.State

输出的条形图如下:

可视化我们的飞行性能数据

但是,将此数据作为地图查看会更酷;点击图表左下角的条形图图标,您可以选择许多不同的原生导航,包括地图:

可视化我们的飞行性能数据

DataFrame 的一个关键好处是信息结构类似于表格。因此,无论您是使用笔记本还是您喜欢的 BI 工具,您都将能够快速可视化您的数据。

小贴士

您可以在 bit.ly/2bkUGnT 找到 pyspark.sql.DataFrame 方法的完整列表。

您可以在 bit.ly/2bTAzLT 找到 pyspark.sql.functions 的完整列表。

Spark Dataset API

在关于 Spark DataFrames 的讨论之后,让我们快速回顾一下 Spark Dataset API。Apache Spark 1.6 中引入的 Spark Datasets 的目标是提供一个 API,使用户能够轻松地表达对域对象的转换,同时提供强大 Spark SQL 执行引擎的性能和优势。作为 Spark 2.0 版本发布的一部分(如以下图所示),DataFrame API 被合并到 Dataset API 中,从而统一了所有库的数据处理能力。由于这种统一,开发者现在需要学习或记住的概念更少,并且可以使用一个单一的高级和 类型安全 API —— 被称为 Dataset:

Spark Dataset API

从概念上讲,Spark DataFrame 是一个 Dataset[Row] 集合的 别名,其中 Row 是一个通用的 未类型化 JVM 对象。相比之下,Dataset 是一个由您在 Scala 或 Java 中定义的 case 类决定的 强类型化 JVM 对象集合。这一点尤其重要,因为这意味着 Dataset API 由于缺乏类型增强的好处,不支持 PySpark。注意,对于 Dataset API 中 PySpark 中不可用的部分,可以通过转换为 RDD 或使用 UDFs 来访问。有关更多信息,请参阅 jira [SPARK-13233]:Python Dataset 在 bit.ly/2dbfoFT

摘要

使用 Spark DataFrames,Python 开发者可以利用一个更简单的抽象层,这个层也可能会显著更快。Python 在 Spark 中最初较慢的一个主要原因是 Python 子进程和 JVM 之间的通信层。对于 Python DataFrame 用户,我们提供了一个围绕 Scala DataFrames 的 Python 包装器,它避免了 Python 子进程/JVM 通信开销。Spark DataFrames 通过 Catalyst 优化器和 Project Tungsten 实现了许多性能提升,这些我们在本章中已进行了回顾。在本章中,我们还回顾了如何使用 Spark DataFrames,并使用 DataFrames 实现了一个准时航班性能场景。

在本章中,我们通过生成数据或利用现有数据集创建了 DataFrame 并与之工作。

在下一章中,我们将讨论如何转换和理解您自己的数据。

第四章:准备数据以进行建模

所有数据都是脏的,无论数据的来源可能让您相信什么:可能是您的同事、一个监控您环境的遥测系统、您从网络上下载的数据集,或者其他来源。直到您已经测试并证明自己数据处于干净状态(我们将在下一节中解释干净状态的含义),您都不应该信任它或将其用于建模。

您的数据可能会受到重复项、缺失观测值和异常值、不存在的地址、错误的电话号码和区号、不准确的地标坐标、错误的日期、不正确的标签、大小写混合、尾部空格以及许多其他更微妙的问题的影响。无论您是数据科学家还是数据工程师,您的任务是清理它,以便您可以构建统计或机器学习模型。

如果您的数据集中没有发现上述任何问题,则您的数据集被认为是技术上干净的。然而,为了建模目的清理数据集,您还需要检查您特征的分布,并确认它们符合预定义的标准。

作为数据科学家,您可以预期将花费 80-90%的时间对数据进行“按摩”并熟悉所有特征。本章将引导您通过这个过程,利用 Spark 的能力。

在本章中,您将学习以下内容:

  • 识别和处理重复项、缺失观测值和异常值

  • 计算描述性统计和相关性

  • 使用 matplotlib 和 Bokeh 可视化您的数据

检查重复项、缺失观测值和异常值

在您完全测试数据并证明它值得您的时间之前,您都不应该信任它或使用它。在本节中,我们将向您展示如何处理重复项、缺失观测值和异常值。

重复项

重复项是在您的数据集中作为独立行出现的观测值,但在仔细检查后看起来是相同的。也就是说,如果您将它们并排查看,这两行(或更多)中的所有特征将具有完全相同的值。

另一方面,如果您的数据具有某种形式的 ID 来区分记录(或将其与某些用户关联,例如),那么最初可能看起来是重复项的,可能不是;有时系统会失败并产生错误的 ID。在这种情况下,您需要检查相同的 ID 是否是真正的重复项,或者您需要提出一个新的 ID 系统。

考虑以下示例:

df = spark.createDataFrame([
        (1, 144.5, 5.9, 33, 'M'),
        (2, 167.2, 5.4, 45, 'M'),
        (3, 124.1, 5.2, 23, 'F'),
        (4, 144.5, 5.9, 33, 'M'),
        (5, 133.2, 5.7, 54, 'F'),
        (3, 124.1, 5.2, 23, 'F'),
        (5, 129.2, 5.3, 42, 'M'),
    ], ['id', 'weight', 'height', 'age', 'gender'])

如您所见,我们这里有几个问题:

  • 我们有两行 ID 等于3,它们完全相同

  • ID 为14的行是相同的——唯一不同的是它们的 ID,因此我们可以安全地假设它们是同一个人

  • 我们有两行 ID 等于5,但看起来这似乎是一个录音问题,因为它们似乎不是同一个人

这是一个非常简单的数据集,只有七行。当你有数百万个观察值时,你该怎么办?我通常做的第一件事是检查是否有任何重复:我将完整数据集的计数与运行 .distinct() 方法后得到的计数进行比较:

print('Count of rows: {0}'.format(df.count()))
print('Count of distinct rows: {0}'.format(df.distinct().count()))

这是我们的 DataFrame 返回的内容:

Duplicates

如果这两个数字不同,那么你就知道你有了,我喜欢称之为,纯重复:彼此完全相同的行。我们可以通过使用 .dropDuplicates(...) 方法来删除这些行:

df = df.dropDuplicates()

您的数据集将如下所示(一旦运行 df.show()):

Duplicates

我们删除了具有 ID 3 的一行。现在让我们检查数据中是否存在任何与 ID 无关的重复项。我们可以快速重复之前所做的操作,但仅使用除 ID 列之外的其他列:

print('Count of ids: {0}'.format(df.count()))
print('Count of distinct ids: {0}'.format(
    df.select([
        c for c in df.columns if c != 'id'
    ]).distinct().count())
)

我们应该看到一行额外的重复项:

Duplicates

我们仍然可以使用 .dropDuplicates(...), 但会添加一个 subset 参数,该参数指定除了 id 列之外的其他列:

df = df.dropDuplicates(subset=[
    c for c in df.columns if c != 'id'
])

subset 参数指示 .dropDuplicates(...) 方法仅使用通过 subset 参数指定的列来查找重复行;在上面的例子中,我们将删除具有相同 weightheightagegender 但不是 id 的重复记录。运行 df.show(),我们得到以下更干净的数据集,因为我们删除了 id = 1 的行,因为它与 id = 4 的记录完全相同:

Duplicates

现在我们知道没有完整的行重复,或者只有 ID 不同的相同行,让我们检查是否有任何重复的 ID。为了在一步中计算总数和不同 ID 的数量,我们可以使用 .agg(...) 方法:

import pyspark.sql.functions as fn

df.agg(
    fn.count('id').alias('count'),
    fn.countDistinct('id').alias('distinct')
).show()

这是前面代码的输出:

Duplicates

在上一个例子中,我们首先从 pyspark.sql 模块导入所有函数。

提示

这使我们能够访问各种函数,太多以至于无法在此列出。然而,我们强烈建议您研究 PySpark 的文档,网址为 spark.apache.org/docs/2.0.0/api/python/pyspark.sql.html#module-pyspark.sql.functions

接下来,我们使用 .count(...).countDistinct(...) 分别计算 DataFrame 中的行数和不同 ids 的数量。.alias(...) 方法允许我们为返回的列指定一个友好的名称。

如您所见,我们总共有五行,但只有四个不同的 ID。由于我们已经删除了所有重复项,我们可以安全地假设这可能是 ID 数据中的一个偶然错误,因此我们将为每一行分配一个唯一的 ID:

df.withColumn('new_id', fn.monotonically_increasing_id()).show()

前面的代码片段生成了以下输出:

Duplicates

.monotonicallymonotonically_increasing_id()方法为每条记录分配一个唯一且递增的 ID。根据文档,只要你的数据被放入少于大约 10 亿个分区,每个分区少于 80 亿条记录,ID 就可以保证是唯一的。

注意

一个警告:在 Spark 的早期版本中,.monotonicallymonotonically_increasing_id()方法在多次评估同一个 DataFrame 时可能不会返回相同的 ID。然而,这已经在 Spark 2.0 中得到了修复。

缺失的观测值

你经常会遇到包含空白的数据集。缺失值可能由多种原因造成:系统故障、人为错误、数据模式变更,仅举几例。

如果你的数据可以承受,处理缺失值的最简单方法是在发现任何缺失值时删除整个观测值。你必须小心不要删除太多:根据缺失值在你数据集中的分布,这可能会严重影响数据集的可用性。如果删除行后,我最终得到一个非常小的数据集,或者发现数据量减少了 50%以上,我开始检查我的数据,看看哪些特征有最多的空缺,也许可以完全排除它们;如果一个特征的大部分值都是缺失的(除非缺失值有特定的含义),从建模的角度来看,它几乎是毫无用处的。

处理具有缺失值的观测值的另一种方法是,用某些值代替那些Nones。根据你的数据类型,你有几个选项可以选择:

  • 如果你的数据是离散布尔值,你可以通过添加第三个类别——缺失来将其转换为分类变量

  • 如果你的数据已经是分类的,你可以简单地扩展级别数量,并添加缺失类别

  • 如果你正在处理有序或数值数据,你可以用均值、中位数或其他预定义的值(例如,第一或第三四分位数,取决于你数据的分布形状)来插补。

考虑一个与我们之前展示的类似的例子:

df_miss = spark.createDataFrame([
        (1, 143.5, 5.6, 28,   'M',  100000),
        (2, 167.2, 5.4, 45,   'M',  None),
        (3, None , 5.2, None, None, None),
        (4, 144.5, 5.9, 33,   'M',  None),
        (5, 133.2, 5.7, 54,   'F',  None),
        (6, 124.1, 5.2, None, 'F',  None),
        (7, 129.2, 5.3, 42,   'M',  76000),
    ], ['id', 'weight', 'height', 'age', 'gender', 'income'])

在我们的例子中,我们处理了多个缺失值类别。

分析,我们可以看到以下:

  • ID 为3的行只有一个有用的信息——身高

  • ID 为6的行只有一个缺失值——年龄

分析,我们可以看到以下:

  • 收入列,由于它是一个非常私人的信息,大部分值都是缺失的

  • 体重性别列各有只有一个缺失值

  • 年龄列有两个缺失值

要找到每行的缺失观测值数量,我们可以使用以下代码片段:

df_miss.rdd.map(
    lambda row: (row['id'], sum([c == None for c in row]))
).collect()

它生成了以下输出:

缺失的观测值

它告诉我们,例如,ID 为3的行有四个缺失观测值,正如我们之前观察到的。

让我们看看哪些值是缺失的,这样当我们计算列中的缺失观测值时,我们可以决定是否删除整个观测值或对某些观测值进行插补:

df_miss.where('id == 3').show()

我们得到以下结果:

缺失观测值

现在我们来检查每列中缺失观测值的百分比是多少:

df_miss.agg(*[
    (1 - (fn.count(c) / fn.count('*'))).alias(c + '_missing')
    for c in df_miss.columns
]).show()

这生成了以下输出:

缺失观测值

注意

.count(...)方法的*参数(代替列名)指示该方法计算所有行。另一方面,列表声明前的*指示.agg(...)方法将列表视为一组单独的参数传递给函数。

因此,我们在weightgender列中有 14%的缺失观测值,在height列中有两倍于此,在income列中有近 72%的缺失观测值。现在我们知道该做什么了。

首先,我们将删除'income'特征,因为其中大部分值是缺失的。

df_miss_no_income = df_miss.select([
    c for c in df_miss.columns if c != 'income'
])

我们现在看到,我们不需要删除 ID 为3的行,因为在'weight''age'列中的观测值覆盖足够(在我们的简化示例中)来计算平均值并将其插补到缺失值的位置。

然而,如果你决定删除观测值,你可以使用.dropna(...)方法,如下所示。在这里,我们还将使用thresh参数,它允许我们指定每行缺失观测值的阈值,以确定该行是否应该被删除。这对于你拥有具有数十或数百个特征的 dataset 来说很有用,你只想删除那些超过一定缺失值阈值的行:

df_miss_no_income.dropna(thresh=3).show()

上述代码产生以下输出:

缺失观测值

另一方面,如果你想插补观测值,你可以使用.fillna(...)方法。此方法接受单个整数(长整型也接受),浮点数或字符串;然后整个 dataset 中的所有缺失值都将用该值填充。你也可以传递一个形式为{'<colName>': <value_to_impute>}的字典。这有一个相同的限制,即,作为<value_to_impute>,你只能传递整数、浮点数或字符串。

如果你想要插补平均值、中位数或其他计算值,你需要首先计算该值,创建一个包含这些值的字典,然后将它传递给.fillna(...)方法。

这是我们的做法:

means = df_miss_no_income.agg(
    *[fn.mean(c).alias(c) 
        for c in df_miss_no_income.columns if c != 'gender']
).toPandas().to_dict('records')[0]

means['gender'] = 'missing'

df_miss_no_income.fillna(means).show()

上述代码将产生以下输出:

缺失观测值

我们省略了性别列,因为显然无法对分类变量计算平均值。

我们在这里使用双重转换。首先将.agg(...)方法的输出(一个 PySpark DataFrame)转换为 pandas DataFrame,然后再将其转换为字典。

提示

注意,调用.toPandas()可能会有问题,因为这个方法基本上与 RDD 中的.collect()方法以相同的方式工作。它会从工作者那里收集所有信息,并将其带到驱动器上。除非你有成千上万的特征,否则这不太可能成为前一个数据集的问题。

pandas 的.to_dict(...)方法的records参数指示它创建以下字典:

缺失观测值

由于我们无法计算分类变量的平均值(或任何其他数值指标),我们在gender特征的字典中添加了missing类别。注意,尽管年龄列的平均值是 40.40,但在插补时,df_miss_no_income.age列的类型仍然被保留——它仍然是一个整数。

异常值

异常值是那些与你的样本中其余部分分布显著偏离的观测值。显著性的定义各不相同,但最一般的形式,你可以接受如果没有异常值,所有值都大致在 Q1−1.5IQR 和 Q3+1.5IQR 范围内,其中 IQR 是四分位距;IQR 定义为上四分位数和下四分位数的差,即 75 百分位数(Q3)和 25 百分位数(Q1)。

让我们再次考虑一个简单的例子:

df_outliers = spark.createDataFrame([
        (1, 143.5, 5.3, 28),
        (2, 154.2, 5.5, 45),
        (3, 342.3, 5.1, 99),
        (4, 144.5, 5.5, 33),
        (5, 133.2, 5.4, 54),
        (6, 124.1, 5.1, 21),
        (7, 129.2, 5.3, 42),
    ], ['id', 'weight', 'height', 'age'])

现在我们可以使用我们之前概述的定义来标记异常值。

首先,我们计算每个特征的上下限。我们将使用.approxQuantile(...)方法。指定的第一个参数是列名,第二个参数可以是01之间的数字(其中0.5表示计算中位数)或列表(如我们的情况),第三个参数指定每个指标的容错水平(如果设置为0,它将为指标计算一个精确值,但这可能非常昂贵):

cols = ['weight', 'height', 'age']
bounds = {}

for col in cols:
    quantiles = df_outliers.approxQuantile(
        col, [0.25, 0.75], 0.05
    )

    IQR = quantiles[1] - quantiles[0]

    bounds[col] = [
        quantiles[0] - 1.5 * IQR, 
        quantiles[1] + 1.5 * IQR
]

bounds字典包含每个特征的上下限:

异常值

让我们现在使用它来标记我们的异常值:

outliers = df_outliers.select(*['id'] + [
    (
        (df_outliers[c] < bounds[c][0]) | 
        (df_outliers[c] > bounds[c][1])
    ).alias(c + '_o') for c in cols
])
outliers.show()

之前的代码产生以下输出:

异常值

我们在weight特征和age特征中各有两个异常值。到现在你应该知道如何提取这些值,但这里有一个列出与整体分布显著不同的值的代码片段:

df_outliers = df_outliers.join(outliers, on='id')
df_outliers.filter('weight_o').select('id', 'weight').show()
df_outliers.filter('age_o').select('id', 'age').show()

之前的代码将给出以下输出:

异常值

有了本节中描述的方法,你可以快速清理甚至最大的数据集。

熟悉你的数据

尽管我们强烈反对这种行为,但你可以在不了解数据的情况下构建模型;这可能会花费你更长的时间,并且生成的模型的质量可能不如最佳,但这是可行的。

注意

在本节中,我们将使用我们从 packages.revolutionanalytics.com/datasets/ccFraud.csv 下载的数据集。我们没有更改数据集本身,但它被 GZipped 并上传到 tomdrabas.com/data/LearningPySpark/ccFraud.csv.gz。请首先下载文件,并将其保存在包含你本章笔记本的同一文件夹中。

数据集的头部看起来如下所示:

熟悉你的数据

因此,任何严肃的数据科学家或数据模型师在开始任何建模之前都会熟悉数据集。作为第一步,我们通常从一些描述性统计开始,以了解我们正在处理的内容。

描述性统计

描述性统计,在最简单的意义上,会告诉你关于你的数据集的基本信息:你的数据集中有多少非缺失观测值,列的均值和标准差,以及最小值和最大值。

然而,首先的事情是——让我们加载数据并将其转换为 Spark DataFrame:

import pyspark.sql.types as typ

首先,我们加载我们需要的唯一模块。pyspark.sql.types 暴露了我们可以使用的数据类型,例如 IntegerType()FloatType()

注意

要查看可用类型的完整列表,请检查spark.apache.org/docs/latest/api/python/pyspark.sql.html#module-pyspark.sql.types

接下来,我们使用 .filter(...) 方法读取数据并删除标题行。这之后,我们将行按每个逗号分割(因为这是一个 .csv 文件),并将每个元素转换为整数:

fraud = sc.textFile('ccFraud.csv.gz')
header = fraud.first()

fraud = fraud \
    .filter(lambda row: row != header) \
    .map(lambda row: [int(elem) for elem in row.split(',')])

接下来,我们为我们的 DataFrame 创建模式:

fields = [
    *[
        typ.StructField(h[1:-1], typ.IntegerType(), True)
        for h in header.split(',')
    ]
]
schema = typ.StructType(fields)

最后,我们创建我们的 DataFrame:

fraud_df = spark.createDataFrame(fraud, schema)

在创建我们的 fraud_df DataFrame 之后,我们可以计算数据集的基本描述性统计。然而,你需要记住,尽管我们的所有特征在本质上都表现为数值型,但其中一些是分类的(例如,genderstate)。

这是我们的 DataFrame 的模式:

fraud_df.printSchema()

表示如下所示:

描述性统计

此外,从计算 custId 列的均值和标准差中不会获得任何信息,所以我们不会进行这项操作。

为了更好地理解分类列,我们将使用 .groupby(...) 方法计算其值的频率。在这个例子中,我们将计算 gender 列的频率:

fraud_df.groupby('gender').count().show()

上述代码将产生以下输出:

描述性统计

如你所见,我们正在处理一个相当不平衡的数据集。你可能会期望看到性别分布是相等的。

注意

这超出了本章的范围,但如果我们在构建统计模型,就需要注意这些类型的偏差。您可以在www.va.gov/VETDATA/docs/SurveysAndStudies/SAMPLE_WEIGHT.pdf了解更多信息。

对于真正的数值特征,我们可以使用.describe()方法:

numerical = ['balance', 'numTrans', 'numIntlTrans']
desc = fraud_df.describe(numerical)
desc.show()

.show()方法将产生以下输出:

描述性统计

即使从这些相对较少的数字中,我们也可以得出很多结论:

  • 所有特征都是正偏的。最大值是平均值的数倍。

  • 变异系数(平均值与标准差的比率)非常高(接近或大于1),表明观察值的分布范围很广。

以下是检查偏度(我们只为'balance'特征做此操作)的方法:

fraud_df.agg({'balance': 'skewness'}).show()

上述代码产生以下输出:

描述性统计

聚合函数列表(名称相当直观)包括:avg()count()countDistinct()first()kurtosis()max()mean()min()skewness()stddev()stddev_pop()stddev_samp()sum()sumDistinct()var_pop()var_samp()variance()

相关系数

另一个非常有用的衡量特征之间相互关系的方法是相关系数。通常,您的模型只会包括与您的目标高度相关的特征。然而,检查特征之间的相关性几乎同样重要;包括彼此高度相关(即,共线性)的特征可能会导致模型的行为不可预测,或者可能不必要地使模型复杂化。

注意

我在我的另一本书中更多地讨论了多重共线性,《实用数据分析食谱,Packt 出版社》 (www.packtpub.com/big-data-and-business-intelligence/practical-data-analysis-cookbook),在第五章介绍 MLlib 中,标题为识别和解决多重共线性的部分。

一旦您的数据以 DataFrame 形式存在,在 PySpark 中计算相关系数非常简单。唯一的困难是.corr(...)方法目前只支持皮尔逊相关系数,并且它只能计算成对的相关性,如下所示:

fraud_df.corr('balance', 'numTrans')

为了创建相关系数矩阵,您可以使用以下脚本:

n_numerical = len(numerical)

corr = []

for i in range(0, n_numerical):
    temp = [None] * i

    for j in range(i, n_numerical):
        temp.append(fraud_df.corr(numerical[i], numerical[j]))
    corr.append(temp)

上述代码将产生以下输出:

相关系数

如您所见,信用卡欺诈数据集中数值特征之间的相关系数几乎不存在。因此,所有这些特征都可以用于我们的模型,如果它们在解释我们的目标时在统计上是有意义的。

检查完相关系数后,我们现在可以继续对数据进行视觉检查。

可视化

虽然存在多个可视化包,但在这个部分,我们将专门使用 matplotlib 和 Bokeh,以提供最适合你需求的工具。

这两个包都预装在 Anaconda 中。首先,让我们加载模块并设置它们:

%matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('ggplot')

import bokeh.charts as chrt
from bokeh.io import output_notebook

output_notebook()

%matplotlib inlineoutput_notebook() 命令将使 matplotlib 或 Bokeh 生成的每个图表都出现在笔记本中,而不是作为单独的窗口。

直方图

直方图无疑是直观评估特征分布的最简单方法。在 PySpark(或 Jupyter notebook)中,你可以通过以下三种方式生成直方图:

  • 在工作节点上聚合数据,并将直方图中每个箱子的箱子和计数列表返回给驱动程序

  • 将所有数据点返回给驱动程序,并允许绘图库的方法为你完成工作

  • 样本你的数据,然后将它们返回给驱动程序进行绘图。

如果你的数据集的行数以亿计,那么第二种方法可能不可行。因此,你需要首先聚合数据:

hists = fraud_df.select('balance').rdd.flatMap(
    lambda row: row
).histogram(20)

要绘制直方图,你可以简单地调用 matplotlib,如下面的代码所示:

data = {
    'bins': hists[0][:-1],
    'freq': hists[1]
}
plt.bar(data['bins'], data['freq'], width=2000)
plt.title('Histogram of \'balance\'')

这将生成以下图表:

直方图

以类似的方式,可以使用 Bokeh 创建直方图:

b_hist = chrt.Bar(
    data, 
    values='freq', label='bins', 
    title='Histogram of \'balance\'')
chrt.show(b_hist)

由于 Bokeh 在后台使用 D3.js,生成的图表是交互式的:

直方图

如果你的数据足够小,可以放在驱动程序上(尽管我们会争论通常使用前一种方法会更快),你可以将数据带进来,并使用 .hist(...)(来自 matplotlib)或 .Histogram(...)(来自 Bokeh)方法:

data_driver = {
    'obs': fraud_df.select('balance').rdd.flatMap(
        lambda row: row
    ).collect()
}
plt.hist(data_driver['obs'], bins=20)
plt.title('Histogram of \'balance\' using .hist()')
b_hist_driver = chrt.Histogram(
    data_driver, values='obs', 
    title='Histogram of \'balance\' using .Histogram()', 
    bins=20
)
chrt.show(b_hist_driver)

这将为 matplotlib 生成以下图表:

直方图

对于 Bokeh,将生成以下图表:

直方图

特征之间的交互

散点图允许我们同时可视化最多三个变量之间的交互(尽管在本节中我们将只展示二维交互)。

提示

你很少需要回退到三维可视化,除非你处理的是一些时间数据,并且你想观察随时间的变化。即使在这种情况下,我们宁愿将时间数据进行离散化,并展示一系列二维图表,因为解读三维图表相对复杂,并且(大多数时候)令人困惑。

由于 PySpark 在服务器端不提供任何可视化模块,并且同时尝试绘制数十亿个观察值将非常不切实际,因此在本节中,我们将对数据集进行 0.02%(大约 2,000 个观察值)的抽样。

提示

除非你选择了分层抽样,否则你应该创建至少三个到五个样本,在预定义的抽样比例下,以便检查你的样本是否在一定程度上代表了你的数据集——也就是说,样本之间的差异不大。

在这个例子中,我们将以 0.02% 的比例对欺诈数据集进行抽样,给定 'gender' 作为分层:

data_sample = fraud_df.sampleBy(
    'gender', {1: 0.0002, 2: 0.0002}
).select(numerical)

要一次性放入多个二维图表,你可以使用以下代码:

data_multi = dict([
    (elem, data_sample.select(elem).rdd \
        .flatMap(lambda row: row).collect()) 
    for elem in numerical
])
sctr = chrt.Scatter(data_multi, x='balance', y='numTrans')
chrt.show(sctr)

上一段代码将生成以下图表:

特征之间的交互

如你所见,有许多欺诈交易余额为 0,但许多交易——即新卡和交易的大幅增加。然而,除了在$1,000 间隔发生的一些带状之外,没有特定的模式可以展示。

摘要

在本章中,我们探讨了如何通过识别和解决数据集中缺失值、重复值和异常值来清洁和准备数据集以进行建模。我们还探讨了如何使用 PySpark 工具(尽管这绝对不是如何分析数据集的完整手册)来更熟悉你的数据。最后,我们展示了如何绘制数据图表。

在接下来的两章中,我们将使用这些(以及更多)技术来构建机器学习模型。

第五章。介绍 MLlib

在上一章中,我们学习了如何为建模准备数据。在本章中,我们将实际使用这些知识来构建一个使用 PySpark 的 MLlib 包的分类模型。

MLlib 代表机器学习库。尽管 MLlib 目前处于维护模式,即它不再积极开发(并且很可能会被弃用),但我们仍然有必要介绍该库的一些功能。此外,MLlib 是目前唯一支持训练流模型(streaming)的库。

注意

从 Spark 2.0 开始,ML 是主要的机器学习库,它操作的是 DataFrame 而不是 RDD,这与 MLlib 的情况不同。

MLlib的文档可以在这里找到:spark.apache.org/docs/latest/api/python/pyspark.mllib.html

在本章中,您将学习以下内容:

  • 使用 MLlib 准备建模数据

  • 执行统计测试

  • 使用逻辑回归预测婴儿的生存机会

  • 选择最可预测的特征并训练随机森林模型

包含概述

在高层次上,MLlib 暴露了三个核心机器学习功能:

  • 数据准备:特征提取、转换、选择、分类特征的哈希以及一些自然语言处理方法

  • 机器学习算法:实现了某些流行的和高级的回归、分类和聚类算法

  • 实用工具:描述性统计、卡方检验、线性代数(稀疏和密集矩阵和向量)以及模型评估方法

如您所见,可用的功能调色板允许您执行几乎所有基本的数据科学任务。

在本章中,我们将构建两个分类模型:线性回归和随机森林。我们将使用我们从www.cdc.gov/nchs/data_access/vitalstatsonline.htm下载的美国 2014 年和 2015 年出生数据的一部分;从总共 300 个变量中我们选择了 85 个特征来构建我们的模型。此外,从近 799 万条记录中,我们选择了 45,429 条平衡样本:22,080 条记录报告婴儿死亡,23,349 条记录婴儿存活。

小贴士

本章我们将使用的数据集可以从www.tomdrabas.com/data/LearningPySpark/births_train.csv.gz下载。

加载数据和转换数据

尽管 MLlib 的设计重点是 RDD 和 DStreams,为了便于转换数据,我们将读取数据并将其转换为 DataFrame。

注意

DStreams 是 Spark Streaming 的基本数据抽象(见bit.ly/2jIDT2A

就像在前一章中一样,我们首先指定数据集的模式。

注意

注意,在这里(为了简洁),我们只展示了少量特征。你应该始终检查我们 GitHub 账户上这本书的最新代码版本:github.com/drabastomek/learningPySpark

下面是代码:

import pyspark.sql.types as typ
labels = [
    ('INFANT_ALIVE_AT_REPORT', typ.StringType()),
    ('BIRTH_YEAR', typ.IntegerType()),
    ('BIRTH_MONTH', typ.IntegerType()),
    ('BIRTH_PLACE', typ.StringType()),
    ('MOTHER_AGE_YEARS', typ.IntegerType()),
    ('MOTHER_RACE_6CODE', typ.StringType()),
    ('MOTHER_EDUCATION', typ.StringType()),
    ('FATHER_COMBINED_AGE', typ.IntegerType()),
    ('FATHER_EDUCATION', typ.StringType()),
    ('MONTH_PRECARE_RECODE', typ.StringType()),
    ...
    ('INFANT_BREASTFED', typ.StringType())
]
schema = typ.StructType([
        typ.StructField(e[0], e[1], False) for e in labels
    ])

接下来,我们加载数据。.read.csv(...)方法可以读取未压缩的或(如我们的情况)GZipped 逗号分隔值。header参数设置为True表示第一行包含标题,我们使用schema来指定正确的数据类型:

births = spark.read.csv('births_train.csv.gz', 
                        header=True, 
                        schema=schema)

我们的数据集中有很多字符串类型的特点。这些大多是类别变量,我们需要以某种方式将它们转换为数值形式。

提示

你可以在这里查看原始文件的模式规范:ftp://ftp.cdc.gov/pub/Health_Statistics/NCHS/Dataset_Documentation/DVS/natality/UserGuide2015.pdf。

我们首先将指定我们的重编码字典:

recode_dictionary = {
    'YNU': {
        'Y': 1,
        'N': 0,
        'U': 0
    }
}

本章的目标是预测'INFANT_ALIVE_AT_REPORT'1还是0。因此,我们将删除所有与婴儿相关的特征,并尝试仅基于与母亲、父亲和出生地相关的特征来预测婴儿的生存机会:

selected_features = [
    'INFANT_ALIVE_AT_REPORT', 
    'BIRTH_PLACE', 
    'MOTHER_AGE_YEARS', 
    'FATHER_COMBINED_AGE', 
    'CIG_BEFORE', 
    'CIG_1_TRI', 
    'CIG_2_TRI', 
    'CIG_3_TRI', 
    'MOTHER_HEIGHT_IN', 
    'MOTHER_PRE_WEIGHT', 
    'MOTHER_DELIVERY_WEIGHT', 
    'MOTHER_WEIGHT_GAIN', 
    'DIABETES_PRE', 
    'DIABETES_GEST', 
    'HYP_TENS_PRE', 
    'HYP_TENS_GEST', 
    'PREV_BIRTH_PRETERM'
]
births_trimmed = births.select(selected_features)

在我们的数据集中,有很多具有是/否/未知值的特征;我们只将“是”编码为1;其他所有内容都将设置为0

关于母亲吸烟数量编码的小问题:0 表示母亲在怀孕前或怀孕期间没有吸烟,1-97 表示实际吸烟的数量,98 表示 98 或更多,而 99 表示未知;我们将假设未知为 0,并相应地进行重编码。

因此,接下来我们将指定我们的重编码方法:

import pyspark.sql.functions as func
def recode(col, key):
    return recode_dictionary[key][col] 
def correct_cig(feat):
    return func \
        .when(func.col(feat) != 99, func.col(feat))\
        .otherwise(0)
rec_integer = func.udf(recode, typ.IntegerType())

recode方法从recode_dictionary(给定key)中查找正确的键并返回修正后的值。correct_cig方法检查特征feat的值是否不等于 99,并且(对于这种情况)返回特征的值;如果值等于 99,则返回 0,否则返回。

我们不能直接在DataFrame上使用recode函数;它需要转换为 Spark 可以理解的 UDF。rec_integer就是这样一种函数:通过传递我们指定的重编码函数并指定返回值数据类型,我们可以使用它来编码我们的是/否/未知特征。

那么,让我们开始吧。首先,我们将纠正与吸烟数量相关的特征:

births_transformed = births_trimmed \
    .withColumn('CIG_BEFORE', correct_cig('CIG_BEFORE'))\
    .withColumn('CIG_1_TRI', correct_cig('CIG_1_TRI'))\
    .withColumn('CIG_2_TRI', correct_cig('CIG_2_TRI'))\
    .withColumn('CIG_3_TRI', correct_cig('CIG_3_TRI'))

.withColumn(...)方法将其第一个参数作为列名,第二个参数作为转换。在前面的例子中,我们没有创建新列,而是重用了相同的列。

现在我们将专注于纠正 Yes/No/Unknown 特征。首先,我们将通过以下片段来确定这些特征:

cols = [(col.name, col.dataType) for col in births_trimmed.schema]
YNU_cols = []
for i, s in enumerate(cols):
    if s[1] == typ.StringType():
        dis = births.select(s[0]) \
            .distinct() \
            .rdd \
            .map(lambda row: row[0]) \
            .collect() 
        if 'Y' in dis:
            YNU_cols.append(s[0])

首先,我们创建了一个包含列名和对应数据类型的元组列表(cols)。接下来,我们遍历所有这些,计算所有字符串列的唯一值;如果返回列表中包含 'Y',我们将列名追加到 YNU_cols 列表中。

DataFrames 可以在选择特征的同时批量转换特征。为了说明这个概念,考虑以下示例:

births.select([
        'INFANT_NICU_ADMISSION', 
        rec_integer(
            'INFANT_NICU_ADMISSION', func.lit('YNU')
        ) \
        .alias('INFANT_NICU_ADMISSION_RECODE')]
     ).take(5)

这是我们的返回结果:

加载和转换数据

我们选择 'INFANT_NICU_ADMISSION' 列,并将特征的名称传递给 rec_integer 方法。我们还把新转换的列别名为 'INFANT_NICU_ADMISSION_RECODE'。这样我们也将确认我们的 UDF 是按预期工作的。

因此,为了一次性转换所有的 YNU_cols,我们将创建一个这样的转换列表,如下所示:

exprs_YNU = [
    rec_integer(x, func.lit('YNU')).alias(x) 
    if x in YNU_cols 
    else x 
    for x in births_transformed.columns
]
births_transformed = births_transformed.select(exprs_YNU)

让我们检查我们是否正确理解了:

births_transformed.select(YNU_cols[-5:]).show(5)

这是我们的结果:

加载和转换数据

看起来一切如我们所愿地工作,所以让我们更好地了解我们的数据。

了解你的数据

为了以有信息的方式构建统计模型,需要对数据集有深入了解。不知道数据,你仍然可以构建一个成功的模型,但这将是一个更加艰巨的任务,或者需要更多的技术资源来测试所有可能的特征组合。因此,在花费了所需 80% 的时间清理数据之后,我们接下来花费 15% 的时间来了解它!

描述性统计

我通常从描述性统计开始。尽管 DataFrames 提供了 .describe() 方法,但由于我们正在使用 MLlib,我们将使用 .colStats(...) 方法。

备注

一个警告:.colStats(...) 是基于样本计算描述性统计的。对于现实世界的数据集,这实际上不应该很重要,但如果你的数据集观察值少于 100,你可能会得到一些奇怪的结果。

该方法接受一个用于计算描述性统计的 RDD 数据,并返回一个包含以下描述性统计的 MultivariateStatisticalSummary 对象:

  • count(): 这包含行数

  • max(): 这包含列中的最大值

  • mean(): 这包含列中值的平均值

  • min(): 这包含列中的最小值

  • normL1(): 这包含列中值的 L1-Norm 值

  • normL2(): 这包含列中值的 L2-Norm 值

  • numNonzeros(): 这包含列中非零值的数量

  • variance(): 这包含列中值的方差

备注

你可以在这里了解更多关于 L1-和 L2-范数的知识 bit.ly/2jJJPJ0

我们建议检查 Spark 的文档以了解更多信息。以下是一个计算数值特征描述性统计的代码片段:

import pyspark.mllib.stat as st
import numpy as np
numeric_cols = ['MOTHER_AGE_YEARS','FATHER_COMBINED_AGE',
                'CIG_BEFORE','CIG_1_TRI','CIG_2_TRI','CIG_3_TRI',
                'MOTHER_HEIGHT_IN','MOTHER_PRE_WEIGHT',
                'MOTHER_DELIVERY_WEIGHT','MOTHER_WEIGHT_GAIN'
               ]
numeric_rdd = births_transformed\
                       .select(numeric_cols)\
                       .rdd \
                       .map(lambda row: [e for e in row])
mllib_stats = st.Statistics.colStats(numeric_rdd)
for col, m, v in zip(numeric_cols, 
                     mllib_stats.mean(), 
                     mllib_stats.variance()):
    print('{0}: \t{1:.2f} \t {2:.2f}'.format(col, m, np.sqrt(v)))

前面的代码产生了以下结果:

描述性统计

如您所见,与父亲相比,母亲更年轻:母亲的平均年龄为 28 岁,而父亲的平均年龄超过 44 岁。一个很好的迹象(至少对于一些婴儿来说)是许多母亲在怀孕期间戒烟;尽管如此,仍然有一些人继续吸烟,这令人震惊。

对于分类变量,我们将计算其值的频率:

categorical_cols = [e for e in births_transformed.columns 
                    if e not in numeric_cols]
categorical_rdd = births_transformed\
                       .select(categorical_cols)\
                       .rdd \
                       .map(lambda row: [e for e in row])
for i, col in enumerate(categorical_cols):
    agg = categorical_rdd \
        .groupBy(lambda row: row[i]) \
        .map(lambda row: (row[0], len(row[1])))
    print(col, sorted(agg.collect(), 
                      key=lambda el: el[1], 
                      reverse=True))

这是结果看起来像什么:

描述性统计

大多数分娩发生在医院(BIRTH_PLACE 等于 1)。大约有 550 次分娩发生在家里:一些是故意发生的('BIRTH_PLACE' 等于 3),一些则不是('BIRTH_PLACE' 等于 4)。

相关系数

相关系数有助于识别共线性数值特征并适当处理它们。让我们检查特征之间的相关性:

corrs = st.Statistics.corr(numeric_rdd)
for i, el in enumerate(corrs > 0.5):
    correlated = [
        (numeric_cols[j], corrs[i][j]) 
        for j, e in enumerate(el) 
        if e == 1.0 and j != i]
    if len(correlated) > 0:
        for e in correlated:
            print('{0}-to-{1}: {2:.2f}' \
                  .format(numeric_cols[i], e[0], e[1]))

前面的代码将计算相关矩阵,并且只打印出那些相关系数大于 0.5 的特征:corrs > 0.5 这一部分负责这个。

这是我们的结果:

相关性

如您所见,'CIG_...' 特征高度相关,因此我们可以删除大部分。由于我们希望尽快预测婴儿的存活机会,我们将只保留 'CIG_1_TRI'。此外,正如预期的那样,权重特征也高度相关,我们将只保留 'MOTHER_PRE_WEIGHT'

features_to_keep = [
    'INFANT_ALIVE_AT_REPORT', 
    'BIRTH_PLACE', 
    'MOTHER_AGE_YEARS', 
    'FATHER_COMBINED_AGE', 
    'CIG_1_TRI', 
    'MOTHER_HEIGHT_IN', 
    'MOTHER_PRE_WEIGHT', 
    'DIABETES_PRE', 
    'DIABETES_GEST', 
    'HYP_TENS_PRE', 
    'HYP_TENS_GEST', 
    'PREV_BIRTH_PRETERM'
]
births_transformed = births_transformed.select([e for e in features_to_keep])

统计测试

我们不能对分类特征计算相关性。然而,我们可以运行卡方测试来确定是否存在显著差异。

这是您可以使用 MLlib.chiSqTest(...) 方法来完成的方法:

import pyspark.mllib.linalg as ln
for cat in categorical_cols[1:]:
    agg = births_transformed \
        .groupby('INFANT_ALIVE_AT_REPORT') \
        .pivot(cat) \
        .count()    
    agg_rdd = agg \
        .rdd \
        .map(lambda row: (row[1:])) \
        .flatMap(lambda row: 
                 [0 if e == None else e for e in row]) \
        .collect()
    row_length = len(agg.collect()[0]) - 1
    agg = ln.Matrices.dense(row_length, 2, agg_rdd)

    test = st.Statistics.chiSqTest(agg)
    print(cat, round(test.pValue, 4))

我们遍历所有分类变量,并通过 'INFANT_ALIVE_AT_REPORT' 特征进行转置以获取计数。接下来,我们将它们转换成一个 RDD,然后我们可以使用 pyspark.mllib.linalg 模块将它们转换成一个矩阵。.Matrices.dense(...) 方法的第一个参数指定了矩阵中的行数;在我们的情况下,它是分类特征的唯一值的长度。

第二个参数指定了列数:我们有两个,因为我们的 'INFANT_ALIVE_AT_REPORT' 目标变量只有两个值。

最后一个参数是要转换成矩阵的值列表。

这是一个更清楚地展示这个的例子:

print(ln.Matrices.dense(3,2, [1,2,3,4,5,6]))

前面的代码产生了以下矩阵:

统计测试

一旦我们将计数以矩阵形式表示,我们就可以使用 .chiSqTest(...) 来计算我们的测试。

这是返回的结果:

统计测试

我们的测试表明,所有特征都应该有显著差异,并有助于我们预测婴儿存活的概率。

创建最终数据集

因此,现在是时候创建我们的最终数据集了,我们将使用它来构建我们的模型。我们将我们的 DataFrame 转换为LabeledPoints的 RDD。

LabeledPoint是 MLlib 结构,用于训练机器学习模型。它由两个属性组成:labelfeatures

label是我们的目标变量,features可以是 NumPy arraylistpyspark.mllib.linalg.SparseVectorpyspark.mllib.linalg.DenseVectorscipy.sparse列矩阵。

创建 LabeledPoints 的 RDD

在我们构建最终数据集之前,我们首先需要解决一个最后的障碍:我们的'BIRTH_PLACE'特征仍然是一个字符串。虽然其他任何分类变量都可以直接使用(因为它们现在是虚拟变量),但我们将使用哈希技巧来编码'BIRTH_PLACE'特征:

import pyspark.mllib.feature as ft
import pyspark.mllib.regression as reg
hashing = ft.HashingTF(7)
births_hashed = births_transformed \
    .rdd \
    .map(lambda row: [
            list(hashing.transform(row[1]).toArray()) 
                if col == 'BIRTH_PLACE' 
                else row[i] 
            for i, col 
            in enumerate(features_to_keep)]) \
    .map(lambda row: [[e] if type(e) == int else e 
                      for e in row]) \
    .map(lambda row: [item for sublist in row 
                      for item in sublist]) \
    .map(lambda row: reg.LabeledPoint(
            row[0], 
            ln.Vectors.dense(row[1:]))
        )

首先,我们创建哈希模型。我们的特征有七个级别,所以我们使用与哈希技巧相同数量的特征。接下来,我们实际上使用模型将我们的'BIRTH_PLACE'特征转换为SparseVector;如果您的数据集有很多列但只有少数几列具有非零值,则这种数据结构是首选的。然后我们将所有特征组合在一起,最后创建一个LabeledPoint

划分为训练集和测试集

在我们进入建模阶段之前,我们需要将我们的数据集分为两个集合:一个用于训练,另一个用于测试。幸运的是,RDD 有一个方便的方法来做这件事:.randomSplit(...)。该方法接受一个比例列表,用于随机划分数据集。

下面是如何操作的:

births_train, births_test = births_hashed.randomSplit([0.6, 0.4])

就这样!不需要做更多的事情了。

预测婴儿存活

最后,我们可以转向预测婴儿的存活机会。在本节中,我们将构建两个模型:一个线性分类器——逻辑回归,以及一个非线性模型——随机森林。对于前者,我们将使用我们所有的特征,而对于后者,我们将使用ChiSqSelector(...)方法选择前四个特征。

MLlib 中的逻辑回归

逻辑回归在构建任何分类模型方面某种程度上是一个基准。MLlib 曾经提供使用随机梯度下降SGD)算法估计的逻辑回归模型。在 Spark 2.0 中,这个模型已被弃用,转而使用LogisticRegressionWithLBFGS模型。

LogisticRegressionWithLBFGS模型使用有限记忆 Broyden-Fletcher-Goldfarb-ShannoBFGS)优化算法。它是一种拟牛顿方法,近似 BFGS 算法。

注意

对于那些数学能力强且对此感兴趣的人,我们建议阅读这篇博客文章,它是对优化算法的精彩概述:aria42.com/blog/2014/12/understanding-lbfgs

首先,我们在我们的数据上训练模型:

from pyspark.mllib.classification \
    import LogisticRegressionWithLBFGS
LR_Model = LogisticRegressionWithLBFGS \
    .train(births_train, iterations=10)

训练模型非常简单:我们只需要调用.train(...)方法。所需的参数是带有LabeledPoints的 RDD;我们还指定了iterations的数量,这样它不会运行得太久。

使用births_train数据集训练模型后,让我们使用该模型来预测测试集的类别:

LR_results = (
        births_test.map(lambda row: row.label) \
        .zip(LR_Model \
             .predict(births_test\
                      .map(lambda row: row.features)))
    ).map(lambda row: (row[0], row[1] * 1.0))

前面的代码片段创建了一个 RDD,其中每个元素都是一个元组,第一个元素是实际标签,第二个元素是模型的预测。

MLlib 为分类和回归提供了评估指标。让我们检查一下我们的模型表现如何:

import pyspark.mllib.evaluation as ev
LR_evaluation = ev.BinaryClassificationMetrics(LR_results)
print('Area under PR: {0:.2f}' \
      .format(LR_evaluation.areaUnderPR))
print('Area under ROC: {0:.2f}' \
      .format(LR_evaluation.areaUnderROC))
LR_evaluation.unpersist()

下面是我们的结果:

MLlib 中的逻辑回归

模型的表现相当不错!精确-召回曲线下的 85%区域表示拟合良好。在这种情况下,我们可能会得到稍微更多的预测死亡(真实和假阳性)。在这种情况下,这实际上是一件好事,因为它可以让医生对预期母亲和婴儿进行特殊护理。

接收者操作特征(ROC)曲线下的面积可以理解为模型将随机选择的正实例排名高于随机选择的负实例的概率。63%的值可以被认为是可接受的。

注意

更多关于这些指标的信息,我们建议感兴趣的读者参考stats.stackexchange.com/questions/7207/roc-vs-precision-and-recall-curvesgim.unmc.edu/dxtests/roc3.htm

仅选择最可预测的特征

任何使用更少特征准确预测类别的模型都应该优先于更复杂的模型。MLlib 允许我们使用卡方选择器选择最可预测的特征。

下面是如何做到这一点的步骤:

selector = ft.ChiSqSelector(4).fit(births_train)
topFeatures_train = (
        births_train.map(lambda row: row.label) \
        .zip(selector \
             .transform(births_train \
                        .map(lambda row: row.features)))
    ).map(lambda row: reg.LabeledPoint(row[0], row[1]))
topFeatures_test = (
        births_test.map(lambda row: row.label) \
        .zip(selector \
             .transform(births_test \
                        .map(lambda row: row.features)))
    ).map(lambda row: reg.LabeledPoint(row[0], row[1]))

我们要求选择器从数据集中返回四个最可预测的特征,并使用births_train数据集来训练选择器。然后我们使用模型从我们的训练和测试数据集中提取仅这些特征。

.ChiSqSelector(...)方法只能用于数值特征;分类变量在使用选择器之前需要被哈希化或转换为虚拟变量。

MLlib 中的随机森林

我们现在准备好构建随机森林模型。

以下代码展示了如何实现:

from pyspark.mllib.tree import RandomForest
RF_model = RandomForest \
    .trainClassifier(data=topFeatures_train, 
                     numClasses=2, 
                     categoricalFeaturesInfo={}, 
                     numTrees=6,  
                     featureSubsetStrategy='all',
                     seed=666)

.trainClassifier(...) 方法的第一个参数指定了训练数据集。numClasses 参数表示我们的目标变量有多少个类别。作为第三个参数,您可以传递一个字典,其中键是我们 RDD 中分类特征的索引,而键的值表示分类特征有多少个级别。numTrees 指定了森林中的树的数量。下一个参数告诉模型使用我们数据集中的所有特征,而不是只保留最具描述性的特征,而最后一个参数指定了模型随机部分的种子。

让我们看看我们的模型表现如何:

RF_results = (
        topFeatures_test.map(lambda row: row.label) \
        .zip(RF_model \
             .predict(topFeatures_test \
                      .map(lambda row: row.features)))
    )
RF_evaluation = ev.BinaryClassificationMetrics(RF_results)
print('Area under PR: {0:.2f}' \
      .format(RF_evaluation.areaUnderPR))
print('Area under ROC: {0:.2f}' \
      .format(RF_evaluation.areaUnderROC))
model_evaluation.unpersist()

这里是结果:

MLlib 中的随机森林

如您所见,具有较少特征的随机森林模型甚至比逻辑回归模型表现更好。让我们看看逻辑回归在特征数量减少的情况下会如何表现:

LR_Model_2 = LogisticRegressionWithLBFGS \
    .train(topFeatures_train, iterations=10)
LR_results_2 = (
        topFeatures_test.map(lambda row: row.label) \
        .zip(LR_Model_2 \
             .predict(topFeatures_test \
                      .map(lambda row: row.features)))
    ).map(lambda row: (row[0], row[1] * 1.0))
LR_evaluation_2 = ev.BinaryClassificationMetrics(LR_results_2)
print('Area under PR: {0:.2f}' \
      .format(LR_evaluation_2.areaUnderPR))
print('Area under ROC: {0:.2f}' \
      .format(LR_evaluation_2.areaUnderROC))
LR_evaluation_2.unpersist()

结果可能会让您感到惊讶:

MLlib 中的随机森林

如您所见,这两个模型可以简化,同时仍然达到相同的准确度水平。话虽如此,您应该始终选择具有较少变量的模型。

摘要

在本章中,我们探讨了 PySpark 的MLlib包的功能。尽管该包目前处于维护模式,并且没有积极开发,但了解如何使用它仍然是有益的。此外,目前它是唯一可用于在流数据时训练模型的包。我们使用MLlib清理、转换并熟悉婴儿死亡数据集。利用这些知识,我们成功构建了两个模型,旨在根据母亲、父亲和出生地信息预测婴儿存活的机会。

在下一章中,我们将重新审视相同的问题,但使用目前 Spark 推荐的机器学习新包。

第六章:介绍 ML 包

在上一章中,我们使用了 Spark 的 MLlib 包,该包严格在 RDD 上操作。在本章中,我们将转向 Spark 的 ML 部分,该部分严格在 DataFrame 上操作。此外,根据 Spark 文档,Spark 的主要机器学习 API 现在是包含在spark.ml包中的基于 DataFrame 的模型集。

那么,让我们开始吧!

注意

在本章中,我们将重用上一章中我们使用的数据集的一部分。数据可以从www.tomdrabas.com/data/LearningPySpark/births_transformed.csv.gz下载。

在本章中,你将学习以下内容:

  • 准备转换器、估计器和管道

  • 使用 ML 包中的模型预测婴儿生存的机会

  • 评估模型的性能

  • 执行参数超调

  • 使用包中可用的其他机器学习模型

包的概述

在最高级别,该包公开了三个主要抽象类:一个Transformer,一个Estimator和一个Pipeline。我们将很快通过一些简短的示例来解释每个类。我们将在本章的最后部分提供一些模型的具体示例。

转换器

Transformer类,正如其名称所暗示的,通过(通常)向 DataFrame 中添加新列来转换你的数据。

在高级别,当从Transformer抽象类派生时,每个新的Transformer都需要实现一个.transform(...)方法。这个方法,作为一个首要且通常是唯一必需的参数,需要传递一个要转换的 DataFrame。当然,在 ML 包中,这会方法各异:其他常用参数是inputColoutputCol;然而,这些参数通常默认为一些预定义的值,例如,对于inputCol参数,默认值可能是'features'

spark.ml.feature提供了许多Transformers,我们将在下面简要描述它们(在我们本章后面使用它们之前):

  • Binarizer:给定一个阈值,该方法将连续变量转换为二进制变量。

  • Bucketizer:类似于Binarizer,该方法接受一系列阈值(splits参数)并将连续变量转换为多项式变量。

  • ChiSqSelector: 对于分类目标变量(例如分类模型),此功能允许你选择一个预定义数量的特征(由numTopFeatures参数参数化),这些特征最好地解释了目标中的方差。选择是通过名称暗示的方法完成的,即使用卡方检验。这是两步方法之一:首先,你需要.fit(...)你的数据(以便方法可以计算卡方检验)。调用.fit(...)方法(你传递 DataFrame 作为参数)返回一个ChiSqSelectorModel对象,然后你可以使用该对象通过.transform(...)方法转换你的 DataFrame。

    注意

    更多关于卡方检验的信息可以在这里找到:ccnmtl.columbia.edu/projects/qmss/the_chisquare_test/about_the_chisquare_test.html

  • CountVectorizer: 这对于标记化文本(例如[['Learning', 'PySpark', 'with', 'us'],['us', 'us', 'us']])非常有用。它是两步方法之一:首先,你需要使用.fit(...)(即从你的数据集中学习模式),然后你才能使用由.fit(...)方法返回的CountVectorizerModel进行.transform(...)。此转换器的输出,对于前面提供的标记化文本,将类似于以下内容:[(4, [0, 1, 2, 3], [1.0, 1.0, 1.0, 1.0]),(4, [3], [3.0])]

  • DCT: 离散余弦变换(Discrete Cosine Transform)将一个实数值向量转换为一个长度相同的向量,但其中的余弦函数以不同的频率振荡。这种变换对于从你的数据或数据压缩中提取一些基本频率非常有用。

  • ElementwiseProduct: 这是一个返回元素为传递给方法的向量与作为scalingVec参数传递的向量乘积的向量的方法。例如,如果你有一个[10.0, 3.0, 15.0]向量,并且你的scalingVec[0.99, 3.30, 0.66],那么你将得到的向量将如下所示:[9.9, 9.9, 9.9]

  • HashingTF: 这是一个哈希技巧转换器,它接受一个标记化文本列表并返回一个向量(具有预定义的长度)和计数。从 PySpark 的文档中:

    "由于使用了简单的模运算将哈希函数转换为列索引,因此建议将 numFeatures 参数设置为 2 的幂;否则,特征将不会均匀地映射到列上。"

  • IDF: 此方法计算文档列表的逆文档频率。请注意,文档需要已经表示为向量(例如,使用HashingTFCountVectorizer)。

  • IndexToString: 这是StringIndexer方法的补充。它使用StringIndexerModel对象的编码将字符串索引反转回原始值。顺便提一下,请注意,这有时可能不起作用,你需要从StringIndexer指定值。

  • MaxAbsScaler: 将数据重新缩放到[-1.0, 1.0]范围内(因此,它不会移动数据的中心)。

  • MinMaxScaler: 与MaxAbsScaler类似,但不同之处在于它将数据缩放到[0.0, 1.0]范围内。

  • NGram: 此方法接受一个标记化文本的列表,并返回n-gram:后续单词的成对、三元组或n-mores。例如,如果您有一个['good', 'morning', 'Robin', 'Williams']向量,您将得到以下输出:['good morning', 'morning Robin', 'Robin Williams']

  • Normalizer: 此方法使用 p-norm 值(默认为 L2)将数据缩放到单位范数。

  • OneHotEncoder: 此方法将分类列编码为二进制向量列。

  • PCA: 使用主成分分析进行数据降维。

  • PolynomialExpansion: 对向量执行多项式展开。例如,如果您有一个符号写为[x, y, z]的向量,该方法将生成以下展开:[x, x*x, y, x*y, y*y, z, x*z, y*z, z*z]

  • QuantileDiscretizer: 与Bucketizer方法类似,但您不是传递splits参数,而是传递numBuckets。然后,方法通过计算数据上的近似分位数来决定应该是什么分割。

  • RegexTokenizer: 这是一个使用正则表达式的字符串分词器。

  • RFormula: 对于那些热衷于使用 R 的用户,您可以通过传递一个公式,例如vec ~ alpha * 3 + beta(假设您的DataFramealphabeta列),它将根据表达式生成vec列。

  • SQLTransformer: 与之前类似,但您可以使用 SQL 语法而不是 R-like 公式。

    提示

    FROM语句应选择__THIS__,表示您正在访问 DataFrame。例如:SELECT alpha * 3 + beta AS vec FROM __THIS__

  • StandardScaler: 将列标准化,使其具有 0 均值和标准差等于 1。

  • StopWordsRemover: 从标记化文本中移除停用词(如'the''a')。

  • StringIndexer: 给定一个列中所有单词的列表,这将生成一个索引向量。

  • Tokenizer: 这是一个默认的分词器,它将字符串转换为小写,然后根据空格分割。

  • VectorAssembler: 这是一个非常有用的转换器,它将多个数值(包括向量)列合并成一个具有向量表示的单列。例如,如果您在 DataFrame 中有三个列:

    df = spark.createDataFrame(
        [(12, 10, 3), (1, 4, 2)], 
        ['a', 'b', 'c']) 
    

    调用以下内容的输出:

    ft.VectorAssembler(inputCols=['a', 'b', 'c'], 
            outputCol='features')\
        .transform(df) \
        .select('features')\
        .collect() 
    

    它看起来如下:

    [Row(features=DenseVector([12.0, 10.0, 3.0])), 
     Row(features=DenseVector([1.0, 4.0, 2.0]))]
    
  • VectorIndexer: 这是一个将分类列索引到索引向量的方法。它以列-by-列的方式工作,从列中选择不同的值,排序并返回映射中的值的索引,而不是原始值。

  • VectorSlicer: 在特征向量上工作,无论是密集的还是稀疏的:给定一个索引列表,它从特征向量中提取值。

  • Word2Vec: 这种方法将一个句子(字符串)作为输入,并将其转换成 {string, vector} 格式的映射,这种表示在自然语言处理中非常有用。

    注意

    注意,ML 包中有很多方法旁边都有一个 E 字母;这意味着该方法目前处于测试版(或实验性)状态,有时可能会失败或产生错误的结果。请小心。

估计器

估计器可以被视为需要估计以进行预测或对观测值进行分类的统计模型。

如果从抽象的 Estimator 类派生,新的模型必须实现 .fit(...) 方法,该方法根据 DataFrame 中的数据和一些默认或用户指定的参数来拟合模型。

PySpark 中有很多估计器可用,我们现在将简要描述 Spark 2.0 中可用的模型。

分类

机器学习包为数据科学家提供了七种分类模型可供选择。这些模型从最简单的(例如逻辑回归)到更复杂的模型不等。我们将在下一节中简要介绍每种模型:

  • LogisticRegression: 分类领域的基准模型。逻辑回归使用 logit 函数来计算观测值属于特定类别的概率。在撰写本文时,PySpark ML 仅支持二元分类问题。

  • DecisionTreeClassifier: 一个构建决策树以预测观测值类别的分类器。指定 maxDepth 参数限制树的生长深度,minInstancePerNode 确定树节点中所需的最小观测值数量以进一步分割,maxBins 参数指定连续变量将被分割成的最大箱数,而 impurity 指定用于衡量和计算分割信息增益的指标。

  • GBTClassifier: 一个用于分类的 梯度提升树 模型。该模型属于集成模型家族:这些模型将多个弱预测模型组合成一个强模型。目前,GBTClassifier 模型支持二元标签以及连续和分类特征。

  • RandomForestClassifier: 该模型生成多个决策树(因此得名——森林),并使用这些决策树的 mode 输出来对观测值进行分类。RandomForestClassifier 支持二元和多项式标签。

  • NaiveBayes: 基于贝叶斯定理,该模型使用条件概率理论对观测值进行分类。PySpark ML 中的 NaiveBayes 模型支持二元和多项式标签。

  • 多层感知器分类器:一种模仿人类大脑性质的分类器。深深植根于人工神经网络理论,该模型是一个黑盒,即模型的内部参数不易解释。该模型至少由三个全连接的(在创建模型对象时需要指定的参数)组成的人工神经元:输入层(需要等于数据集中的特征数量),若干隐藏层(至少一个),以及一个输出层,其神经元数量等于标签中的类别数量。输入层和隐藏层中的所有神经元都有 sigmoid 激活函数,而输出层神经元的激活函数是 softmax。

  • 一对抗:将多类分类减少为二类分类。例如,在多项式标签的情况下,模型可以训练多个二元逻辑回归模型。例如,如果label == 2,则模型将构建一个逻辑回归,其中将label == 2转换为1(所有剩余的标签值将设置为0),然后训练一个二元模型。然后对所有模型进行评分,概率最高的模型获胜。

回归

PySpark ML 包中提供了七个回归任务模型。与分类类似,这些模型从一些基本的(如强制性的线性回归)到更复杂的模型:

  • 加速失效时间回归:拟合加速失效时间回归模型。这是一个参数模型,假设某个特征的一个边缘效应会加速或减慢预期寿命(或过程失效)。它非常适合具有明确阶段的流程。

  • 决策树回归器:与分类模型类似,但有一个明显的区别,即标签是连续的而不是二元的(或多项式的)。

  • 梯度提升回归器:与决策树回归器类似,区别在于标签的数据类型。

  • 广义线性回归:具有不同核函数(链接函数)的线性模型族。与假设误差项正态性的线性回归相比,GLM 允许标签具有不同的误差项分布:PySpark ML 包中的广义线性回归模型支持高斯二项式伽马泊松误差分布族,以及众多不同的链接函数。

  • 等调回归:一种回归类型,将自由形式的非递减线拟合到您的数据。对于具有有序和递增观测值的数据集来说很有用。

  • 线性回归:回归模型中最简单的一种,它假设特征与连续标签之间存在线性关系,以及误差项的正态性。

  • 随机森林回归器:类似于决策树回归器梯度提升回归器随机森林回归器拟合的是连续标签而不是离散标签。

聚类

聚类是一组无监督模型,用于在数据中找到潜在的规律。PySpark ML 包目前提供了四个最流行的模型:

  • BisectingKMeans: k 均值聚类方法和层次聚类的组合。算法开始时将所有观测值放在一个簇中,然后迭代地将数据分割成k个簇。

    注意

    查阅此网站以获取有关伪算法的更多信息:minethedata.blogspot.com/2012/08/bisecting-k-means.html

  • KMeans: 这是著名的 k 均值算法,它将数据分离成k个簇,迭代地寻找使每个观测值与其所属簇的质心之间的平方距离之和最小的质心。

  • GaussianMixture: 此方法使用具有未知参数的k个高斯分布来剖析数据集。通过最大化对数似然函数,使用期望最大化算法找到高斯参数。

    小贴士

    注意,对于具有许多特征的集合,由于维度诅咒和高斯分布的数值问题,此模型可能表现不佳。

  • LDA: 该模型用于自然语言处理应用中的主题建模。

PySpark ML 中还有一个推荐模型可用,但我们将在此处不对其进行描述。

Pipeline

PySpark ML 中的Pipeline是一个端到端转换-估计过程(具有不同的阶段)的概念,它接收一些原始数据(以 DataFrame 形式),执行必要的数据整理(转换),并最终估计一个统计模型(估计器)。

小贴士

Pipeline可以是纯粹的转换型,即仅由Transformer组成。

可以将Pipeline视为多个离散阶段的链。当在Pipeline对象上执行.fit(...)方法时,所有阶段都按照在stages参数中指定的顺序执行;stages参数是一个TransformerEstimator对象的列表。Pipeline对象的.fit(...)方法执行Transformer.transform(...)方法和Estimator.fit(...)方法。

通常,前一个阶段的输出成为下一个阶段的输入:当从TransformerEstimator抽象类派生时,需要实现.getOutputCol()方法,该方法返回创建对象时指定的outputCol参数的值。

使用 ML 预测婴儿生存的机会

在本节中,我们将使用上一章的数据集的一部分来展示 PySpark ML 的思想。

注意

如果你在阅读上一章时还没有下载数据,可以在此处访问:www.tomdrabas.com/data/LearningPySpark/births_transformed.csv.gz

在本节中,我们将再次尝试预测婴儿生存的机会。

加载数据

首先,我们使用以下代码加载数据:

import pyspark.sql.types as typ
labels = [
    ('INFANT_ALIVE_AT_REPORT', typ.IntegerType()),
    ('BIRTH_PLACE', typ.StringType()),
    ('MOTHER_AGE_YEARS', typ.IntegerType()),
    ('FATHER_COMBINED_AGE', typ.IntegerType()),
    ('CIG_BEFORE', typ.IntegerType()),
    ('CIG_1_TRI', typ.IntegerType()),
    ('CIG_2_TRI', typ.IntegerType()),
    ('CIG_3_TRI', typ.IntegerType()),
    ('MOTHER_HEIGHT_IN', typ.IntegerType()),
    ('MOTHER_PRE_WEIGHT', typ.IntegerType()),
    ('MOTHER_DELIVERY_WEIGHT', typ.IntegerType()),
    ('MOTHER_WEIGHT_GAIN', typ.IntegerType()),
    ('DIABETES_PRE', typ.IntegerType()),
    ('DIABETES_GEST', typ.IntegerType()),
    ('HYP_TENS_PRE', typ.IntegerType()),
    ('HYP_TENS_GEST', typ.IntegerType()),
    ('PREV_BIRTH_PRETERM', typ.IntegerType())
]
schema = typ.StructType([
    typ.StructField(e[0], e[1], False) for e in labels
])
births = spark.read.csv('births_transformed.csv.gz', 
                        header=True, 
                        schema=schema)

我们指定 DataFrame 的模式;我们的数据集现在只有 17 列。

创建转换器

在我们可以使用数据集估计模型之前,我们需要进行一些转换。由于统计模型只能操作数值数据,我们将不得不对BIRTH_PLACE变量进行编码。

在我们进行任何操作之前,由于我们将在本章后面使用许多不同的特征转换,让我们导入它们:

import pyspark.ml.feature as ft

为了对BIRTH_PLACE列进行编码,我们将使用OneHotEncoder方法。然而,该方法不能接受StringType列;它只能处理数值类型,因此我们首先将列转换为IntegerType

births = births \
    .withColumn('BIRTH_PLACE_INT', births['BIRTH_PLACE'] \
    .cast(typ.IntegerType()))

完成这些后,我们现在可以创建我们的第一个Transformer

encoder = ft.OneHotEncoder(
    inputCol='BIRTH_PLACE_INT', 
    outputCol='BIRTH_PLACE_VEC')

现在让我们创建一个包含所有特征的单一列。我们将使用VectorAssembler方法:

featuresCreator = ft.VectorAssembler(
    inputCols=[
        col[0] 
        for col 
        in labels[2:]] + \
    [encoder.getOutputCol()], 
    outputCol='features'
)

传递给VectorAssembler对象的inputCols参数是一个列表,其中包含所有要组合在一起形成outputCol(即'features')的列。请注意,我们使用编码器对象的输出(通过调用.getOutputCol()方法),因此我们不必记住在编码器对象中更改输出列名称时更改此参数的值。

现在是时候创建我们的第一个估计器了。

创建估计器

在这个例子中,我们将(再次)使用逻辑回归模型。然而,在本章的后面,我们将展示一些来自 PySpark ML 模型.classification集合的更复杂模型,因此我们加载整个部分:

import pyspark.ml.classification as cl

一旦加载,让我们使用以下代码创建模型:

logistic = cl.LogisticRegression(
    maxIter=10, 
    regParam=0.01, 
    labelCol='INFANT_ALIVE_AT_REPORT')

如果我们的目标列名为'label',我们就不必指定labelCol参数。此外,如果我们的featuresCreator的输出不是名为'features',我们就必须通过(最方便的)在featuresCreator对象上调用getOutputCol()方法来指定featuresCol

创建 pipeline

现在剩下的只是创建一个Pipeline并拟合模型。首先,让我们从 ML 包中加载Pipeline

from pyspark.ml import Pipeline

创建一个Pipeline非常简单。以下是我们的 pipeline 在概念上的样子:

创建 pipeline

将这个结构转换为Pipeline是一件轻而易举的事情:

pipeline = Pipeline(stages=[
        encoder, 
        featuresCreator, 
        logistic
    ])

就这样!我们的pipeline现在已经创建好了,我们可以(终于!)估计模型了。

模型拟合

在拟合模型之前,我们需要将我们的数据集分成训练集和测试集。方便的是,DataFrame API 有.randomSplit(...)方法:

births_train, births_test = births \
    .randomSplit([0.7, 0.3], seed=666)

第一个参数是一个列表,其中包含应该分别进入births_trainbirths_test子集的数据集比例。seed参数为随机化器提供一个种子。

注意

只要列表的元素之和为 1,你就可以将数据集分成超过两个子集,并将输出解包成尽可能多的子集。

例如,我们可以将出生数据集分成三个子集,如下所示:

train, test, val = births.\
    randomSplit([0.7, 0.2, 0.1], seed=666)

前面的代码会将出生数据集的 70%随机放入train对象中,20%会进入test,而val DataFrame 将保留剩余的 10%。

现在是时候最终运行我们的流水线和估计我们的模型了:

model = pipeline.fit(births_train)
test_model = model.transform(births_test)

流水线对象的.fit(...)方法将我们的训练数据集作为输入。在内部,births_train数据集首先传递给encoder对象。在encoder阶段创建的 DataFrame 随后传递给featuresCreator,它创建'features'列。最后,这一阶段的输出传递给logistic对象,它估计最终模型。

.fit(...)方法返回PipelineModel对象(前面代码片段中的model对象),然后可以用于预测;我们通过调用.transform(...)方法并传递之前创建的测试数据集来实现这一点。以下命令中的test_model看起来是这样的:

test_model.take(1)

它生成了以下输出:

拟合模型

如你所见,我们得到了来自TransfomersEstimators的所有列。逻辑回归模型输出几个列:rawPrediction是特征和β系数的线性组合的值,probability是每个类计算的概率,最后是prediction,即我们的最终类别分配。

评估模型的性能

显然,我们现在想测试我们的模型表现如何。PySpark 在包的.evaluation部分暴露了多个用于分类和回归的评估方法:

import pyspark.ml.evaluation as ev

我们将使用BinaryClassficationEvaluator来测试我们的模型表现如何:

evaluator = ev.BinaryClassificationEvaluator(
    rawPredictionCol='probability', 
    labelCol='INFANT_ALIVE_AT_REPORT')

rawPredictionCol可以是估计器生成的rawPrediction列或probability

让我们看看我们的模型表现如何:

print(evaluator.evaluate(test_model, 
    {evaluator.metricName: 'areaUnderROC'}))
print(evaluator.evaluate(test_model, 
   {evaluator.metricName: 'areaUnderPR'}))

前面的代码生成了以下结果:

评估模型的性能

ROC 曲线下 74%的面积和 PR 曲线下 71%的面积显示了一个定义良好的模型,但并没有什么异常之处;如果我们有其他特征,我们可以进一步提高这个值,但这不是本章(甚至整本书)的目的。

保存模型

PySpark 允许你保存Pipeline定义以供以后使用。它不仅保存了流水线结构,还保存了所有TransformersEstimators的定义:

pipelinePath = './infant_oneHotEncoder_Logistic_Pipeline'
pipeline.write().overwrite().save(pipelinePath)

因此,你可以稍后加载它并直接使用它来.fit(...)和预测:

loadedPipeline = Pipeline.load(pipelinePath)
loadedPipeline \
    .fit(births_train)\
    .transform(births_test)\
    .take(1)

前面的代码生成了相同的结果(正如预期):

保存模型

然而,如果你想要保存估计的模型,你也可以这样做;你不需要保存Pipeline,而是需要保存PipelineModel

小贴士

注意,不仅PipelineModel可以被保存:几乎所有通过在EstimatorTransformer上调用.fit(...)方法返回的模型都可以被保存并重新加载以供重用。

要保存你的模型,请参考以下示例:

from pyspark.ml import PipelineModel

modelPath = './infant_oneHotEncoder_Logistic_PipelineModel'
model.write().overwrite().save(modelPath)

loadedPipelineModel = PipelineModel.load(modelPath)
test_reloadedModel = loadedPipelineModel.transform(births_test)

前面的脚本使用了PipelineModel类的类方法.load(...)来重新加载估计的模型。你可以将test_reloadedModel.take(1)的结果与之前展示的test_model.take(1)的输出进行比较。

参数超调

很少情况下,我们的第一个模型就是我们能做的最好的。仅仅通过查看我们的指标并接受模型因为它通过了我们预想的性能阈值,这几乎不是寻找最佳模型的科学方法。

参数超调的概念是找到模型的最佳参数:例如,正确估计逻辑回归模型所需的最大迭代次数或决策树的最大深度。

在本节中,我们将探讨两个概念,这些概念可以帮助我们找到模型的最佳参数:网格搜索和训练-验证拆分。

网格搜索

网格搜索是一个穷举算法,它遍历定义的参数值列表,估计单独的模型,并根据某些评估指标选择最佳模型。

应该指出的是:如果你定义了太多的参数想要优化,或者这些参数的值太多,选择最佳模型可能需要很长时间,因为随着参数和参数值的增加,估计的模型数量会迅速增长。

例如,如果你想要微调两个参数,并且每个参数有两个值,那么你需要拟合四个模型。增加一个额外的参数并赋予两个值,将需要估计八个模型,而将我们的两个参数增加一个额外的值(使每个参数变为三个值),将需要估计九个模型。正如你所见,如果不小心,这会迅速变得难以控制。请查看以下图表以直观地检查这一点:

网格搜索

在这个警示故事之后,让我们开始微调我们的参数空间。首先,我们加载包的.tuning部分:

import pyspark.ml.tuning as tune

接下来,让我们指定我们的模型和想要遍历的参数列表:

logistic = cl.LogisticRegression(
    labelCol='INFANT_ALIVE_AT_REPORT')
grid = tune.ParamGridBuilder() \
    .addGrid(logistic.maxIter,  
             [2, 10, 50]) \
    .addGrid(logistic.regParam, 
             [0.01, 0.05, 0.3]) \
    .build()

首先,我们指定我们想要优化参数的模型。接下来,我们决定我们将优化哪些参数,以及测试这些参数的哪些值。我们使用.tuning子包中的ParamGridBuilder()对象,并使用.addGrid(...)方法向网格中添加参数:第一个参数是我们想要优化的模型的参数对象(在我们的例子中,这些是logistic.maxIterlogistic.regParam),第二个参数是我们想要遍历的值的列表。在.ParamGridBuilder上调用.build()方法将构建网格。

接下来,我们需要一种比较模型的方法:

evaluator = ev.BinaryClassificationEvaluator(
    rawPredictionCol='probability', 
    labelCol='INFANT_ALIVE_AT_REPORT')

因此,我们再次使用 BinaryClassificationEvaluator。现在是时候创建一个逻辑,为我们执行验证工作:

cv = tune.CrossValidator(
    estimator=logistic, 
    estimatorParamMaps=grid, 
    evaluator=evaluator
)

CrossValidator 需要估计器、估计器参数映射和评估器来完成其工作。模型遍历值网格,估计模型,并使用评估器比较它们的性能。

我们不能直接使用数据(因为 births_trainbirths_test 仍然有未编码的 BIRTHS_PLACE 列),因此我们创建了一个纯转换的 Pipeline

pipeline = Pipeline(stages=[encoder ,featuresCreator])
data_transformer = pipeline.fit(births_train)

完成这些后,我们就可以找到我们模型的最佳参数组合:

cvModel = cv.fit(data_transformer.transform(births_train))

cvModel 将返回最佳估计模型。现在我们可以使用它来查看它是否比我们之前的模型表现更好:

data_train = data_transformer \
    .transform(births_test)
results = cvModel.transform(data_train)
print(evaluator.evaluate(results, 
     {evaluator.metricName: 'areaUnderROC'}))
print(evaluator.evaluate(results, 
     {evaluator.metricName: 'areaUnderPR'}))

上一段代码将产生以下结果:

网格搜索

如您所见,我们得到了一个稍微好一点的结果。最佳模型有哪些参数?答案是有点复杂,但以下是您可以提取它的方法:

results = [
    (
        [
            {key.name: paramValue} 
            for key, paramValue 
            in zip(
                params.keys(), 
                params.values())
        ], metric
    ) 
    for params, metric 
    in zip(
        cvModel.getEstimatorParamMaps(), 
        cvModel.avgMetrics
    )
]
sorted(results, 
       key=lambda el: el[1], 
       reverse=True)[0]

上一段代码产生以下输出:

网格搜索

训练-验证分割

TrainValidationSplit 模型,为了选择最佳模型,将输入数据集(训练数据集)随机分割成两个子集:较小的训练集和验证集。分割只进行一次。

在这个例子中,我们还将使用 ChiSqSelector 来选择前五个特征,从而限制我们模型的复杂性:

selector = ft.ChiSqSelector(
    numTopFeatures=5, 
    featuresCol=featuresCreator.getOutputCol(), 
    outputCol='selectedFeatures',
    labelCol='INFANT_ALIVE_AT_REPORT'
)

numTopFeatures 指定了要返回的特征数量。我们将选择器放在 featuresCreator 之后,因此我们在 featuresCreator 上调用 .getOutputCol()

我们之前已经介绍了如何创建 LogisticRegressionPipeline,因此我们在这里不再解释它们的创建方法:

logistic = cl.LogisticRegression(
    labelCol='INFANT_ALIVE_AT_REPORT',
    featuresCol='selectedFeatures'
)
pipeline = Pipeline(stages=[encoder, featuresCreator, selector])
data_transformer = pipeline.fit(births_train)

TrainValidationSplit 对象的创建方式与 CrossValidator 模型相同:

tvs = tune.TrainValidationSplit(
    estimator=logistic, 
    estimatorParamMaps=grid, 
    evaluator=evaluator
)

如前所述,我们将数据拟合到模型中,并计算结果:

tvsModel = tvs.fit(
    data_transformer \
        .transform(births_train)
)
data_train = data_transformer \
    .transform(births_test)
results = tvsModel.transform(data_train)
print(evaluator.evaluate(results, 
     {evaluator.metricName: 'areaUnderROC'}))
print(evaluator.evaluate(results, 
     {evaluator.metricName: 'areaUnderPR'}))

上一段代码输出了以下内容:

训练-验证分割

好吧,具有较少特征的模型肯定比完整模型表现差,但差距并不大。最终,这是一个在更复杂的模型和不太复杂的模型之间的性能权衡。

PySpark ML 的其他功能实战

在本章的开头,我们描述了 PySpark ML 库的大部分功能。在本节中,我们将提供一些使用 TransformersEstimators 的示例。

特征提取

我们已经使用了 PySpark 子模块中相当多的模型。在本节中,我们将向您展示如何使用我们认为最有用的模型。

NLP 相关的特征提取器

如前所述,NGram 模型接受一个分词文本的列表,并生成单词对(或 n-gram)。

在这个例子中,我们将从 PySpark 的文档中摘取一段内容,展示在传递给NGram模型之前如何清理文本。以下是我们的数据集的样子(为了简洁而省略):

小贴士

要查看以下代码片段的完整视图,请从我们的 GitHub 仓库下载代码:github.com/drabastomek/learningPySpark

我们从Pipelines中 DataFrame 使用的描述中复制了这四个段落:spark.apache.org/docs/latest/ml-pipeline.html#dataframe

text_data = spark.createDataFrame([
    ['''Machine learning can be applied to a wide variety 
        of data types, such as vectors, text, images, and 
        structured data. This API adopts the DataFrame from 
        Spark SQL in order to support a variety of data
        types.'''],
    (...)
    ['''Columns in a DataFrame are named. The code examples 
        below use names such as "text," "features," and 
        "label."''']
], ['input'])

我们单列 DataFrame 中的每一行只是一堆文本。首先,我们需要对这段文本进行分词。为此,我们将使用RegexTokenizer而不是仅仅使用Tokenizer,因为我们可以指定我们想要文本在何处被分割的模式:

tokenizer = ft.RegexTokenizer(
    inputCol='input', 
    outputCol='input_arr', 
    pattern='\s+|[,.\"]')

此处的模式在任意数量的空格处分割文本,同时也移除了逗号、句号、反斜杠和引号。tokenizer输出中的一个单行看起来类似于这样:

NLP - 相关特征提取器

如您所见,RegexTokenizer不仅将句子分割成单词,而且还对文本进行了规范化,使得每个单词都是小写。

然而,我们的文本中仍然有很多垃圾信息:例如beato等单词在分析文本时通常不会提供任何有用的信息。因此,我们将使用StopWordsRemover(...)移除这些所谓的停用词

stopwords = ft.StopWordsRemover(
    inputCol=tokenizer.getOutputCol(), 
    outputCol='input_stop')

该方法的输出如下所示:

NLP - 相关特征提取器

现在我们只有有用的单词。所以,让我们构建我们的NGram模型和Pipeline

ngram = ft.NGram(n=2, 
    inputCol=stopwords.getOutputCol(), 
    outputCol="nGrams")
pipeline = Pipeline(stages=[tokenizer, stopwords, ngram])

现在我们有了pipeline,我们将按照之前非常相似的方式继续:

data_ngram = pipeline \
    .fit(text_data) \
    .transform(text_data)
data_ngram.select('nGrams').take(1)

上述代码产生以下输出:

NLP - 相关特征提取器

就这样。我们已经得到了我们的 n-gram,现在我们可以将它们用于进一步的 NLP 处理。

离散化连续变量

我们经常处理一个高度非线性且很难只用一个系数来拟合模型中的连续特征。

在这种情况下,可能很难只用一个系数来解释这种特征与目标之间的关系。有时,将值分组到离散的桶中是有用的。

首先,让我们使用以下代码创建一些假数据:

import numpy as np
x = np.arange(0, 100)
x = x / 100.0 * np.pi * 4
y = x * np.sin(x / 1.764) + 20.1234

现在,我们可以使用以下代码创建一个 DataFrame:

schema = typ.StructType([
    typ.StructField('continuous_var', 
                    typ.DoubleType(), 
                    False
   )
])
data = spark.createDataFrame(
    [[float(e), ] for e in y], 
    schema=schema)

离散化连续变量

接下来,我们将使用QuantileDiscretizer模型将我们的连续变量分割成五个桶(numBuckets参数):

discretizer = ft.QuantileDiscretizer(
    numBuckets=5, 
    inputCol='continuous_var', 
    outputCol='discretized')

让我们看看我们得到了什么:

data_discretized = discretizer.fit(data).transform(data)

我们的功能现在看起来如下:

离散化连续变量

我们现在可以将这个变量视为分类变量,并使用OneHotEncoder对其进行编码以供将来使用。

标准化连续变量

标准化连续变量不仅有助于更好地理解特征之间的关系(因为解释系数变得更容易),而且还有助于计算效率,并防止遇到一些数值陷阱。这是使用 PySpark ML 如何做到这一点。

首先,我们需要创建我们连续变量的向量表示(因为它只是一个单一的浮点数):

vectorizer = ft.VectorAssembler(
    inputCols=['continuous_var'], 
    outputCol= 'continuous_vec')

接下来,我们构建我们的normalizerpipeline。通过将withMeanwithStd设置为True,该方法将移除均值并将方差缩放到单位长度:

normalizer = ft.StandardScaler(
    inputCol=vectorizer.getOutputCol(), 
    outputCol='normalized', 
    withMean=True,
    withStd=True
)
pipeline = Pipeline(stages=[vectorizer, normalizer])
data_standardized = pipeline.fit(data).transform(data)

这是转换后的数据看起来会是什么样子:

标准化连续变量

如你所见,数据现在围绕 0 振荡,具有单位方差(绿色线)。

分类

到目前为止,我们只使用了 PySpark ML 中的LogisticRegression模型。在本节中,我们将使用RandomForestClassfier再次对婴儿生存的机会进行建模。

在我们能够做到这一点之前,我们需要将label特征转换为DoubleType

import pyspark.sql.functions as func
births = births.withColumn(
    'INFANT_ALIVE_AT_REPORT', 
    func.col('INFANT_ALIVE_AT_REPORT').cast(typ.DoubleType())
)
births_train, births_test = births \
    .randomSplit([0.7, 0.3], seed=666)

现在我们已经将标签转换为双精度,我们准备好构建我们的模型。我们以类似的方式前进,区别在于我们将重用本章早些时候的encoderfeatureCreatornumTrees参数指定我们的随机森林中应该有多少决策树,而maxDepth参数限制了树的深度:

classifier = cl.RandomForestClassifier(
    numTrees=5, 
    maxDepth=5, 
    labelCol='INFANT_ALIVE_AT_REPORT')
pipeline = Pipeline(
    stages=[
        encoder,
        featuresCreator, 
        classifier])
model = pipeline.fit(births_train)
test = model.transform(births_test)

现在我们来看看RandomForestClassifier模型与LogisticRegression相比的表现:

evaluator = ev.BinaryClassificationEvaluator(
    labelCol='INFANT_ALIVE_AT_REPORT')
print(evaluator.evaluate(test, 
    {evaluator.metricName: "areaUnderROC"}))
print(evaluator.evaluate(test, 
    {evaluator.metricName: "areaUnderPR"}))

我们得到以下结果:

分类

好吧,正如你所见,结果比逻辑回归模型好大约 3 个百分点。让我们测试一下只有一个树的模型表现如何:

classifier = cl.DecisionTreeClassifier(
    maxDepth=5, 
    labelCol='INFANT_ALIVE_AT_REPORT')
pipeline = Pipeline(stages=[
    encoder,
    featuresCreator, 
    classifier])
model = pipeline.fit(births_train)
test = model.transform(births_test)
evaluator = ev.BinaryClassificationEvaluator(
    labelCol='INFANT_ALIVE_AT_REPORT')
print(evaluator.evaluate(test, 
    {evaluator.metricName: "areaUnderROC"}))
print(evaluator.evaluate(test, 
    {evaluator.metricName: "areaUnderPR"}))

上述代码给出了以下结果:

分类

表现相当不错!实际上,它在精确度-召回率关系方面表现得比随机森林模型更好,只是在 ROC 曲线下的面积上略差一些。我们可能已经找到了一个赢家!

聚类

聚类是机器学习的一个重要部分:在现实世界中,我们往往没有目标特征的奢侈,因此我们需要回到无监督学习范式,试图在数据中揭示模式。

在出生数据集中寻找聚类

在这个例子中,我们将使用k-means模型来寻找出生数据中的相似性:

import pyspark.ml.clustering as clus
kmeans = clus.KMeans(k = 5, 
    featuresCol='features')
pipeline = Pipeline(stages=[
        assembler,
        featuresCreator, 
        kmeans]
)
model = pipeline.fit(births_train)

在估计了模型之后,让我们看看我们是否能在聚类之间找到一些差异:

test = model.transform(births_test)
test \
    .groupBy('prediction') \
    .agg({
        '*': 'count', 
        'MOTHER_HEIGHT_IN': 'avg'
    }).collect()

上述代码产生了以下输出:

在出生数据集中寻找聚类

好吧,MOTHER_HEIGHT_IN在聚类 2 中显著不同。通过查看结果(这里我们不会做,很明显的原因)可能会发现更多差异,并帮助我们更好地理解数据。

主题挖掘

聚类模型不仅限于数值数据。在自然语言处理(NLP)领域,如主题提取等问题依赖于聚类来检测具有相似主题的文档。我们将通过一个这样的例子来讲解。

首先,让我们创建我们的数据集。数据是从互联网上随机选择的段落中形成的:其中三个涉及自然和国家公园的主题,剩下的三个涉及技术。

小贴士

由于显而易见的原因,代码片段再次被简化。请参考 GitHub 上的源文件以获取完整表示。

text_data = spark.createDataFrame([
    ['''To make a computer do anything, you have to write a 
    computer program. To write a computer program, you have 
    to tell the computer, step by step, exactly what you want 
    it to do. The computer then "executes" the program, 
    following each step mechanically, to accomplish the end 
    goal. When you are telling the computer what to do, you 
    also get to choose how it's going to do it. That's where 
    computer algorithms come in. The algorithm is the basic 
    technique used to get the job done. Let's follow an 
    example to help get an understanding of the algorithm 
    concept.'''],
    (...),
    ['''Australia has over 500 national parks. Over 28 
    million hectares of land is designated as national 
    parkland, accounting for almost four per cent of 
    Australia's land areas. In addition, a further six per 
    cent of Australia is protected and includes state 
    forests, nature parks and conservation reserves.National 
    parks are usually large areas of land that are protected 
    because they have unspoilt landscapes and a diverse 
    number of native plants and animals. This means that 
    commercial activities such as farming are prohibited and 
    human activity is strictly monitored.''']
], ['documents'])

首先,我们再次使用RegexTokenizerStopWordsRemover模型:

tokenizer = ft.RegexTokenizer(
    inputCol='documents', 
    outputCol='input_arr', 
    pattern='\s+|[,.\"]')
stopwords = ft.StopWordsRemover(
    inputCol=tokenizer.getOutputCol(), 
    outputCol='input_stop')

在我们的管道中接下来的是CountVectorizer:这是一个计算文档中单词并返回计数向量的模型。向量的长度等于所有文档中不同单词的总数,这在以下代码片段中可以看到:

stringIndexer = ft.CountVectorizer(
    inputCol=stopwords.getOutputCol(), 
    outputCol="input_indexed")
tokenized = stopwords \
    .transform(
        tokenizer\
            .transform(text_data)
    )

stringIndexer \
    .fit(tokenized)\
    .transform(tokenized)\
    .select('input_indexed')\
    .take(2)

上述代码将产生以下输出:

主题挖掘

如您所见,文本中有 262 个不同的单词,现在每个文档都由每个单词出现的计数来表示。

现在是时候开始预测主题了。为此,我们将使用LDA模型——潜在狄利克雷分配模型:

clustering = clus.LDA(k=2, 
    optimizer='online', 
    featuresCol=stringIndexer.getOutputCol())

k参数指定了我们期望看到多少个主题,optimizer参数可以是'online''em'(后者代表期望最大化算法)。

将这些难题组合起来,到目前为止,是我们的管道中最长的:

pipeline = ml.Pipeline(stages=[
        tokenizer, 
        stopwords,
        stringIndexer, 
        clustering]
)

我们是否正确地揭示了主题?好吧,让我们看看:

topics = pipeline \
    .fit(text_data) \
    .transform(text_data)
topics.select('topicDistribution').collect()

我们得到以下结果:

主题挖掘

看起来我们的方法正确地发现了所有主题!但是,不要习惯看到这样的好结果:遗憾的是,现实世界的数据很少是这样的。

回归

我们在介绍机器学习库的章节中,如果不能构建一个回归模型,就无法完成。

在本节中,我们将尝试预测MOTHER_WEIGHT_GAIN,给定这里描述的一些特征;这些特征包含在以下列出的特征中:

features = ['MOTHER_AGE_YEARS','MOTHER_HEIGHT_IN',
            'MOTHER_PRE_WEIGHT','DIABETES_PRE',
            'DIABETES_GEST','HYP_TENS_PRE', 
            'HYP_TENS_GEST', 'PREV_BIRTH_PRETERM',
            'CIG_BEFORE','CIG_1_TRI', 'CIG_2_TRI', 
            'CIG_3_TRI'
           ]

首先,由于所有特征都是数值的,我们将它们收集在一起,并使用ChiSqSelector来选择仅前六个最重要的特征:

featuresCreator = ft.VectorAssembler(
    inputCols=[col for col in features[1:]], 
    outputCol='features'
)
selector = ft.ChiSqSelector(
    numTopFeatures=6, 
    outputCol="selectedFeatures", 
    labelCol='MOTHER_WEIGHT_GAIN'
)

为了预测体重增加,我们将使用梯度提升树回归器:

import pyspark.ml.regression as reg
regressor = reg.GBTRegressor(
    maxIter=15, 
    maxDepth=3,
    labelCol='MOTHER_WEIGHT_GAIN')

最后,再次,我们将所有这些组合到一个Pipeline中:

pipeline = Pipeline(stages=[
        featuresCreator, 
        selector,
        regressor])
weightGain = pipeline.fit(births_train)

在创建了weightGain模型之后,让我们看看它在测试数据上的表现如何:

evaluator = ev.RegressionEvaluator(
    predictionCol="prediction", 
    labelCol='MOTHER_WEIGHT_GAIN')
print(evaluator.evaluate(
     weightGain.transform(births_test), 
    {evaluator.metricName: 'r2'}))

我们得到以下输出:

回归

很遗憾,模型的表现并不比掷硬币好。看起来,如果没有与MOTHER_WEIGHT_GAIN标签更好相关联的附加独立特征,我们将无法充分解释其变异性。

摘要

在本章中,我们详细介绍了如何使用 PySpark ML:PySpark 的官方主要机器学习库。我们解释了 TransformerEstimator 是什么,并展示了它们在 ML 库中引入的另一个概念:Pipeline 中的作用。随后,我们还介绍了如何使用一些方法来微调模型的超参数。最后,我们给出了一些如何使用库中的一些特征提取器和模型的示例。

在下一章中,我们将深入探讨图论和 GraphFrames,这些工具有助于更好地以图的形式表示机器学习问题。

第七章. GraphFrames

图是解决数据问题的有趣方式,因为图结构是许多数据问题类别的更直观的方法。

在本章中,你将了解:

  • 为什么使用图?

  • 理解经典图问题:航班数据集

  • 理解图顶点和边

  • 简单查询

  • 使用基元发现

  • 使用广度优先搜索

  • 使用 PageRank

  • 使用 D3 可视化航班

无论是在社交网络中还是在餐厅推荐中,在图结构(顶点、边和属性)的背景下理解这些数据问题更容易:

GraphFrames

例如,在社交网络的背景下,顶点是人,而是他们之间的连接。在餐厅推荐的背景下,顶点(例如)包括位置、菜系类型和餐厅,而边是他们之间的连接(例如,这三家餐厅都在温哥华,不列颠哥伦比亚省,但只有两家提供拉面)。

虽然这两个图看起来是断开的,但实际上,你可以根据社交圈中朋友的评论创建一个社交网络 + 餐厅推荐图,如下面图所示:

GraphFrames

例如,如果伊莎贝拉想在温哥华找一家好的拉面餐厅,通过查看她朋友的评论,她很可能会选择金太郎拉面,因为萨曼莎朱丽叶特都对该餐厅给出了好评:

GraphFrames

另一个经典的图问题是航班数据的分析:机场由顶点表示,而机场之间的航班由表示。此外,与这些航班相关的属性众多,包括但不限于出发延误、飞机类型和航空公司:

GraphFrames

在本章中,我们将使用 GraphFrames 快速轻松地分析以图结构组织的数据的航班性能数据。因为我们使用图结构,所以我们可以轻松地提出许多在表格结构中不那么直观的问题,例如找到结构基元、使用 PageRank 对机场进行排名以及城市之间的最短路径。GraphFrames 利用 DataFrame API 的分布和表达式能力,既简化了查询,又利用了 Apache Spark SQL 引擎的性能优化。

此外,使用 GraphFrames,图分析在 Python、Scala 和 Java 中都是可用的。同样重要的是,你可以利用现有的 Apache Spark 技能来解决图问题(除了机器学习、流和 SQL),而不是学习新框架进行范式转换。

介绍 GraphFrames

GraphFrames 利用 Apache Spark DataFrame 的强大功能来支持通用图处理。具体来说,顶点和边由 DataFrame 表示,允许我们为每个顶点和边存储任意数据。虽然 GraphFrames 与 Spark 的 GraphX 库类似,但有一些关键区别,包括:

  • GraphFrames 利用 DataFrame API 的性能优化和简洁性。

  • 通过使用 DataFrame API,GraphFrames 现在拥有 Python、Java 和 Scala API。GraphX 仅通过 Scala 访问;现在所有算法都可在 Python 和 Java 中访问。

  • 注意,在撰写本文时,存在一个错误阻止 GraphFrames 与 Python3.x 一起工作,因此我们将使用 Python2.x。

在撰写本文时,GraphFrames 版本为 0.3,作为 Spark 包在spark-packages.orgspark-packages.org/package/graphframes/graphframes处可用。

小贴士

有关 GraphFrames 的更多信息,请参阅databricks.com/blog/2016/03/03/introducing-graphframes.html上的GraphFra mes介绍。

安装 GraphFrames

如果您从 Spark CLI(例如,spark-shell, pyspark, spark-sql, spark-submit)运行作业,您可以使用–-packages命令,该命令将为您提取、编译和执行必要的代码,以便使用 GraphFrames 包。

例如,要使用与 Spark 2.0 和 Scala 2.11 兼容的最新 GraphFrames 包(版本 0.3)和 spark-shell,命令如下:

> $SPARK_HOME/bin/spark-shell --packages graphframes:graphframes:0.3.0-spark2.0-s_2.11

如果您使用的是笔记本服务,您可能需要先安装该包。例如,以下部分显示了在免费 Databricks 社区版(databricks.com/try-databricks)中安装 GraphFrames 库的步骤。

创建库

在 Databricks 中,您可以使用 Scala/Java JAR、Python Egg 或 Maven 坐标(包括 Spark 包)创建库。

要开始,请转到databricks中的工作区,右键单击您想要创建库的文件夹(在本例中为flights),点击创建,然后点击

创建库

创建库对话框中,在下拉菜单中选择,如以下图所示Maven 坐标

创建库

小贴士

Maven 是一种用于构建和管理基于 Java 的项目(如 GraphFrames 项目)的工具。Maven 坐标唯一标识这些项目(或依赖项或插件),以便您可以在 Maven 仓库中快速找到项目;例如,mvnrepository.com/artifact/graphframes/graphframes

从这里,您可以点击搜索 Spark 包和 Maven Central按钮,搜索 GraphFrames 包。确保您匹配 Spark(例如,Spark 2.0)和 Scala(例如,Scala 2.11)的 GraphFrames 版本与您的 Spark 集群相匹配。

如果您已经知道 Maven 坐标,您也可以输入 GraphFrames Spark 包的 Maven 坐标。对于 Spark 2.0 和 Scala 2.11,输入以下坐标:

graphframes:graphframes:0.3.0-spark2.0-s_2.11

输入后,点击创建库,如以下截图所示:

创建库

注意,这是 GraphFrames Spark 包的一次性安装任务(作为库的一部分)。一旦安装,您默认可以自动将包附加到您创建的任何 Databricks 集群:

创建库

准备您的航班数据集

对于这个航班示例场景,我们将使用两组数据:

  • 航空公司准点率与航班延误原因:[bit.ly/2ccJPPM] 此数据集包含由美国航空公司报告的预定和实际起飞和到达时间,以及延误原因。数据由航空公司信息办公室,运输统计局BTS)收集。

  • Open Flights:机场和航空公司数据:[openflights.org/data.html] 此数据集包含美国机场数据列表,包括 IATA 代码、机场名称和机场位置。

我们将创建两个 DataFrame – airportsdepartureDelays – 分别构成我们的 GraphFrame 的顶点。我们将使用 Python 创建这个航班示例应用程序。

由于我们在这个示例中使用 Databricks 笔记本,我们可以使用/databricks-datasets/位置,其中包含许多样本数据集。您也可以从以下链接下载数据:

在此示例中,我们创建了两个变量,分别表示机场和出发延误数据的文件路径。然后我们将加载这些数据集并创建相应的 Spark DataFrame;注意对于这两个文件,我们可以轻松推断其模式:

# Set File Paths
tripdelaysFilePath = "/databricks-datasets/flights/departuredelays.csv"
airportsnaFilePath = "/databricks-datasets/flights/airport-codes-na.txt"

# Obtain airports dataset
# Note, this dataset is tab-delimited with a header
airportsna = spark.read.csv(airportsnaFilePath, header='true', inferSchema='true', sep='\t')
airportsna.createOrReplaceTempView("airports_na")

# Obtain departure Delays data
# Note, this dataset is comma-delimited with a header
departureDelays = spark.read.csv(tripdelaysFilePath, header='true')
departureDelays.createOrReplaceTempView("departureDelays")
departureDelays.cache()

一旦我们加载了departureDelays DataFrame,我们也会将其缓存,这样我们就可以以高效的方式对数据进行一些额外的过滤:

# Available IATA codes from the departuredelays sample dataset
tripIATA = spark.sql("select distinct iata from (select distinct origin as iata from departureDelays union all select distinct destination as iata from departureDelays) a")
tripIATA.createOrReplaceTempView("tripIATA")

前面的查询使我们能够构建一个具有不同出发城市 IATA 代码的独立列表(例如,西雅图 = 'SEA',旧金山 = 'SFO',纽约 JFK = 'JFK'等)。接下来,我们只包括在departureDelays DataFrame 中发生旅行的机场:

# Only include airports with atleast one trip from the 
# `departureDelays` dataset
airports = spark.sql("select f.IATA, f.City, f.State, f.Country from airports_na f join tripIATA t on t.IATA = f.IATA")
airports.createOrReplaceTempView("airports")
airports.cache()

通过构建独特的出发机场代码列表,我们可以构建机场 DataFrame,只包含存在于 departureDelays 数据集中的机场代码。以下代码片段生成一个新的 DataFrame (departureDelays_geo),它包含包括航班日期、延误、距离和机场信息(出发地、目的地)在内的关键属性:

# Build `departureDelays_geo` DataFrame
# Obtain key attributes such as Date of flight, delays, distance, 
# and airport information (Origin, Destination)  
departureDelays_geo = spark.sql("select cast(f.date as int) as tripid, cast(concat(concat(concat(concat(concat(concat('2014-', concat(concat(substr(cast(f.date as string), 1, 2), '-')), substr(cast(f.date as string), 3, 2)), ''), substr(cast(f.date as string), 5, 2)), ':'), substr(cast(f.date as string), 7, 2)), ':00') as timestamp) as `localdate`, cast(f.delay as int), cast(f.distance as int), f.origin as src, f.destination as dst, o.city as city_src, d.city as city_dst, o.state as state_src, d.state as state_dst from departuredelays f join airports o on o.iata = f.origin join airports d on d.iata = f.destination") 

# Create Temporary View and cache
departureDelays_geo.createOrReplaceTempView("departureDelays_geo")
departureDelays_geo.cache()

要快速查看这些数据,您可以运行此处所示的方法 show

# Review the top 10 rows of the `departureDelays_geo` DataFrame
departureDelays_geo.show(10)

准备您的航班数据集

构建图

现在我们已经导入了数据,让我们构建我们的图。为此,我们将构建顶点和边的结构。在撰写本文时,GraphFrames 需要顶点和边具有特定的命名约定:

  • 代表 顶点 的列需要命名为 id。在我们的案例中,飞行数据的顶点是机场。因此,我们需要在 airports DataFrame 中将 IATA 机场代码重命名为 id

  • 代表 的列需要具有源 (src) 和目标 (dst)。在我们的飞行数据中,边是航班,因此 srcdst 是来自 departureDelays_geo DataFrame 的出发地和目的地列。

为了简化我们的图边,我们将创建一个名为 tripEdges 的 DataFrame,它包含 departureDelays_Geo DataFrame 中的一部分列。此外,我们还创建了一个名为 tripVertices 的 DataFrame,它只是将 IATA 列重命名为 id 以匹配 GraphFrame 命名约定:

# Note, ensure you have already installed 
# the GraphFrames spark-package
from pyspark.sql.functions import *
from graphframes import *

# Create Vertices (airports) and Edges (flights)
tripVertices = airports.withColumnRenamed("IATA", "id").distinct()
tripEdges = departureDelays_geo.select("tripid", "delay", "src", "dst", "city_dst", "state_dst")

# Cache Vertices and Edges
tripEdges.cache()
tripVertices.cache()

在 Databricks 中,您可以使用 display 命令查询数据。例如,要查看 tripEdges DataFrame,命令如下:

display(tripEdges)

输出如下:

构建图

现在我们有了这两个 DataFrame,我们可以使用 GraphFrame 命令创建一个 GraphFrame:

tripGraph = GraphFrame(tripVertices, tripEdges)

执行简单查询

让我们从一组简单的图查询开始,以了解飞行性能和出发延误。

确定机场和航班数量

例如,要确定机场和航班的数量,您可以运行以下命令:

print "Airports: %d" % tripGraph.vertices.count()
print "Trips: %d" % tripGraph.edges.count()

如您从结果中看到的,有 279 个机场,有 136 万次航班:

确定机场和航班数量

确定该数据集中的最长延迟

要确定数据集中最长延误的航班,您可以运行以下查询,结果为 1,642 分钟(即超过 27 小时!):

tripGraph.edges.groupBy().max("delay")

# Output
+----------+
|max(delay)| 
+----------+ 
|      1642| 
+----------+

确定延误航班与准点/提前航班数量

要确定延误航班与准点(或提前)航班的数量,您可以运行以下查询:

print "On-time / Early Flights: %d" % tripGraph.edges.filter("delay <= 0").count()
print "Delayed Flights: %d" % tripGraph.edges.filter("delay > 0").count()

结果显示,几乎 43% 的航班延误了!

确定延误航班与准点/提前航班数量

从西雅图出发的航班中,哪些最有可能出现重大延误?

在这些数据中进一步挖掘,让我们找出从西雅图出发的航班中,最有可能有显著延误的前五大目的地。这可以通过以下查询实现:

tripGraph.edges\
  .filter("src = 'SEA' and delay > 0")\
  .groupBy("src", "dst")\
  .avg("delay")\
  .sort(desc("avg(delay)"))\
  .show(5)

如您在以下结果中可以看到:费城(PHL)、科罗拉多斯普林斯(COS)、弗雷斯诺(FAT)、长滩(LGB)和华盛顿特区(IAD)是从西雅图出发的航班延误最严重的五大城市:

哪些从西雅图出发的航班最有可能有显著的延误?

哪些州从西雅图出发往往会有显著的延误?

让我们找出哪些州从西雅图出发的累积延误最长(个别延误超过 100 分钟)。这次我们将使用display命令来查看数据:

# States with the longest cumulative delays (with individual
# delays > 100 minutes) (origin: Seattle)
display(tripGraph.edges.filter("src = 'SEA' and delay > 100"))

哪些州从西雅图出发往往会有显著的延误?

使用 Databricks 的display命令,我们也可以快速将数据从表格视图转换为地图视图。如图所示,从西雅图出发(在此数据集中)累积延误最多的州是加利福尼亚州:

哪些州从西雅图出发往往会有显著的延误?

理解顶点度数

在图论背景下,顶点周围的度数是围绕顶点的边的数量。在我们的航班示例中,度数就是到达顶点(即机场)的总边数(即航班)。因此,如果我们从我们的图中获取前 20 个顶点度数(按降序排列),那么我们就是在请求前 20 个最繁忙的机场(进出航班最多)的查询。这可以通过以下查询快速确定:

display(tripGraph.degrees.sort(desc("degree")).limit(20))

因为我们使用了display命令,所以我们可以快速查看此数据的条形图:

理解顶点度数

深入更多细节,以下是前 20 个inDegrees(即进入航班):

display(tripGraph.inDegrees.sort(desc("inDegree")).limit(20))

理解顶点度数

虽然这里有前 20 个outDegrees(即出发航班):

display(tripGraph.outDegrees.sort(desc("outDegree")).limit(20))

理解顶点度数

有趣的是,尽管前 10 个机场(亚特兰大/ATL 到夏洛特/CLT)在进出航班中的排名相同,但下一个 10 个机场的排名发生了变化(例如,西雅图/SEA 在进出航班中分别排名第 17 和第 18)。

确定顶级中转机场

对于机场理解顶点度数的扩展是确定顶级中转机场。许多机场被用作中转点而不是最终目的地。计算这个的一个简单方法是计算inDegrees(飞往机场的航班数量)和outDegrees(离开机场的航班数量)的比率。接近1的值可能表示许多中转,而小于1的值表示许多出发航班,大于1的值表示许多到达航班。

注意,这是一个简单的计算,不考虑航班的时刻或调度,只是数据集中的整体汇总数:

# Calculate the inDeg (flights into the airport) and 
# outDeg (flights leaving the airport)
inDeg = tripGraph.inDegrees
outDeg = tripGraph.outDegrees

# Calculate the degreeRatio (inDeg/outDeg)
degreeRatio = inDeg.join(outDeg, inDeg.id == outDeg.id) \
  .drop(outDeg.id) \
  .selectExpr("id", "double(inDegree)/double(outDegree) as degreeRatio") \
  .cache()

# Join back to the 'airports' DataFrame 
# (instead of registering temp table as above)
transferAirports = degreeRatio.join(airports, degreeRatio.id == airports.IATA) \
  .selectExpr("id", "city", "degreeRatio") \
  .filter("degreeRatio between 0.9 and 1.1")

# List out the top 10 transfer city airports
display(transferAirports.orderBy("degreeRatio").limit(10))

此查询的输出是顶级 10 个中转城市机场(即枢纽机场)的条形图:

确定顶级中转机场

这是有意义的,因为这些机场是主要的国家航空公司枢纽(例如,达美航空使用明尼阿波利斯和盐湖城作为其枢纽,边疆航空使用丹佛,美国航空使用达拉斯和凤凰城,联合航空使用休斯顿、芝加哥和旧金山,夏威夷航空使用卡胡鲁伊和檀香山作为其枢纽)。

理解 motifs

为了更容易理解城市机场之间以及彼此之间的复杂关系,我们可以使用 motifs 来找到由航班连接的机场(例如,顶点)的模式。结果是包含按 motif 键命名的列的 DataFrame。请注意,motif 寻找是 GraphFrames 作为其一部分支持的新图算法之一。

例如,让我们确定由于 旧金山国际机场SFO)造成的延误:

# Generate motifs
motifs = tripGraphPrime.find("(a)-[ab]->(b); (b)-[bc]->(c)")\
  .filter("(b.id = 'SFO') and (ab.delay > 500 or bc.delay > 500) and bc.tripid > ab.tripid and bc.tripid < ab.tripid + 10000")

# Display motifs
display(motifs)

将前面的查询分解,(x) 表示顶点(即机场),而 [xy] 表示边(即机场之间的航班)。因此,要确定由于旧金山国际机场(SFO)造成的延误,请使用以下查询:

  • 顶点 (b) 表示中间机场(即 SFO)

  • 顶点 (a) 表示原始机场(在数据集中)

  • 顶点 (c) 表示目的地机场(在数据集中)

  • [ab] 表示 (a)(即原始)和 (b)(即 SFO)之间的航班

  • [bc] 表示 (b)(即 SFO)和 (c)(即目的地)之间的航班

filter 语句中,我们放入了一些基本的约束(请注意,这是航班路径的过于简化的表示):

  • b.id = 'SFO' 表示中间顶点 (b) 仅限于 SFO 机场

  • (ab.delay > 500 or bc.delay > 500) 表示我们仅限于延误超过 500 分钟的航班

  • (bc.tripid > ab.tripid and bc.tripid < ab.tripid + 10000) 表示 (ab) 航班必须在 (bc) 航班之前,并且在同一天内。tripid 是从日期时间派生出来的,因此解释了为什么可以以这种方式简化

此查询的输出在以下图中注明:

理解 motifs

以下是从该查询中提取的简化子集,其中列是相应的 motif 键:

a ab b bc c
休斯顿 (IAH) IAH -> SFO (-4)``[1011126] 旧金山 (SFO) SFO -> 纽约肯尼迪机场 (JFK) (536)``[1021507] 纽约 (JFK)
图森 (TUS) TUS -> SFO (-5)``[1011126] 旧金山 (SFO) SFO -> 纽约肯尼迪机场 (JFK) (536)``[1021507] 纽约 (JFK)

参考 TUS > SFO > JFK 航班,你会发现,虽然图森到旧金山的航班提前了 5 分钟起飞,但旧金山到纽约 JFK 的航班延误了 536 分钟。

通过使用模式发现,你可以轻松地在图中搜索结构模式;通过使用 GraphFrames,你正在利用 DataFrames 的强大功能和速度来分发和执行你的查询。

使用 PageRank 确定机场排名

因为 GraphFrames 建立在 GraphX 之上,所以我们可以立即利用几个算法。PageRank 是由 Google 搜索引擎普及并由拉里·佩奇创造的。引用维基百科:

"PageRank 通过计算指向一个页面的链接数量和质量,以确定一个粗略的估计,即该网站的重要性。其基本假设是,更重要的网站更有可能从其他网站获得更多链接。"

虽然前面的例子提到了网页,但这个概念同样适用于任何图结构,无论它是从网页、自行车站还是机场创建的。然而,通过 GraphFrames 的接口调用方法非常简单。GraphFrames.PageRank将返回 PageRank 结果,作为附加到vertices DataFrame 的新列,以简化我们的下游分析。

由于数据集中包含了众多机场的航班和连接,我们可以使用 PageRank 算法让 Spark 迭代遍历图,以计算每个机场重要性的粗略估计:

# Determining Airport ranking of importance using 'pageRank'
ranks = tripGraph.pageRank(resetProbability=0.15, maxIter=5)

# Display the pageRank output
display(ranks.vertices.orderBy(ranks.vertices.pagerank.desc()).limit(20))

注意,resetProbability = 0.15表示重置到随机顶点的概率(这是默认值),而maxIter = 5是一个固定的迭代次数。

小贴士

更多关于 PageRank 参数的信息,请参考维基百科 > Page Rank en.wikipedia.org/wiki/PageRank

PageRank 的结果如下所示:

使用 PageRank 确定机场排名

在机场排名方面,PageRank算法确定 ATL(哈茨菲尔德-杰克逊亚特兰大国际机场)是美国最重要的机场。这一观察结果是合理的,因为 ATL 不仅是美国最繁忙的机场(bit.ly/2eTGHs4),也是 2000-2015 年世界上最繁忙的机场(bit.ly/2eTGDsy)。

确定最受欢迎的非直飞航班

在我们的tripGraph GraphFrame 的基础上进行扩展,以下查询将允许我们找到美国最受欢迎的非直飞航班(对于这个数据集):

# Determine the most popular non-stop flights
import pyspark.sql.functions as func
topTrips = tripGraph \
  .edges \
  .groupBy("src", "dst") \
  .agg(func.count("delay").alias("trips"))

# Show the top 20 most popular flights (single city hops)
display(topTrips.orderBy(topTrips.trips.desc()).limit(20))

注意,虽然我们使用了delay列,但我们实际上只是在计算行程数量。以下是输出结果:

确定最受欢迎的非直飞航班

从这个查询中可以看出,最频繁的非直飞航班是洛杉矶(LAX)和旧金山(SFO)之间的航班。这些航班如此频繁的事实表明它们在航空市场中的重要性。正如 2016 年 4 月 4 日的《纽约时报》文章中提到的,阿拉斯加航空视维珍美国航空为西海岸的关键 (nyti.ms/2ea1uZR),在这些两个机场获得航班时刻表是阿拉斯加航空公司收购维珍航空的原因之一。图表不仅有趣,而且可能包含潜在的商业洞察力!

使用广度优先搜索

广度优先搜索BFS)是 GraphFrames 的一部分新算法,它可以从一组顶点找到另一组顶点的最短路径。在本节中,我们将使用 BFS 遍历我们的tripGraph,快速找到所需的顶点(即机场)和边(即航班)。让我们尝试根据数据集找到城市之间最短连接数。请注意,这些示例不考虑时间或距离,只是城市之间的跳数。例如,要找到西雅图和旧金山之间的直飞航班数量,您可以运行以下查询:

# Obtain list of direct flights between SEA and SFO
filteredPaths = tripGraph.bfs(
  fromExpr = "id = 'SEA'",
  toExpr = "id = 'SFO'",
  maxPathLength = 1)

# display list of direct flights
display(filteredPaths)

fromExprtoExpr是表示起点和目的地机场的表达式(即 SEA 和 SFO)。maxPathLength = 1表示我们只想在两个顶点之间有一个边,即西雅图和旧金山之间的直飞航班。正如以下结果所示,西雅图和旧金山之间有大量的直飞航班:

使用广度优先搜索

但如果我们想确定旧金山和布法罗之间的直飞航班数量呢?运行以下查询将显示没有结果,也就是说,两个城市之间没有直飞航班:

# Obtain list of direct flights between SFO and BUF
filteredPaths = tripGraph.bfs(
  fromExpr = "id = 'SFO'",
  toExpr = "id = 'BUF'",
  maxPathLength = 1)

# display list of direct flights
display(filteredPaths)

一旦我们将前面的查询修改为 maxPathLength = 2,即一次转机,那么您将看到更多的航班选择:

# display list of one-stop flights between SFO and BUF
filteredPaths = tripGraph.bfs(
  fromExpr = "id = 'SFO'",
  toExpr = "id = 'BUF'",
  maxPathLength = 2)

# display list of flights
display(filteredPaths)

下表提供了此查询输出的简略版本:

转机
SFO MSP (明尼阿波利斯) BUF
SFO EWR (纽瓦克) BUF
SFO JFK (纽约) BUF
SFO ORD (芝加哥) BUF
SFO ATL (亚特兰大) BUF
SFO LAS (拉斯维加斯) BUF
SFO BOS (波士顿) BUF

但现在我已经有了机场列表,我该如何确定哪些转机机场在 SFO 和 BUF 之间更受欢迎?为了确定这一点,您现在可以运行以下查询:

# Display most popular layover cities by descending count
display(filteredPaths.groupBy("v1.id", "v1.City").count().orderBy(desc("count")).limit(10))

下面的条形图显示了输出结果:

使用广度优先搜索

使用 D3 可视化航班

要获取此数据集中航班路径和连接的强大且有趣的可视化,我们可以在我们的 Databricks 笔记本中使用 Airports D3 可视化 (mbostock.github.io/d3/talk/20111116/airports.html)。通过连接我们的 GraphFrames、DataFrames 和 D3 可视化,我们可以可视化所有航班连接的范围,正如数据集中所有准时或提前起飞的航班所注明的。

蓝色圆圈代表顶点(即机场),圆圈的大小代表进出这些机场的边的数量。黑色线条是边本身(即航班)以及它们与其他顶点(即机场)的相应连接。注意,对于超出屏幕的任何边,它们代表夏威夷和阿拉斯加州的顶点(即机场)。

为了使这可行,我们首先创建一个名为 d3ascala 包,该包嵌入在我们的笔记本中(你可以从这里下载:bit.ly/2kPkXkc)。因为我们使用 Databricks 笔记本,我们可以在 PySpark 笔记本中调用 Scala

%scala
// On-time and Early Arrivals
import d3a._
graphs.force(
  height = 800,
  width = 1200,
  clicks = sql("""select src, dst as dest, count(1) as count from departureDelays_geo where delay <= 0 group by src, dst""").as[Edge])

前一个查询的准时和提前到达航班的结果显示在下述屏幕截图:

使用 D3 可视化航班

你可以在机场 D3 可视化中悬停于机场(蓝色圆圈,顶点)上,其中线条代表边(航班)。前一个可视化是悬停在西雅图(SEA)机场时的快照;而下一个可视化是悬停在洛杉矶(LAX)机场时的快照:

使用 D3 可视化航班

摘要

正如本章所示,你可以通过针对图结构执行查询轻松执行大量强大的数据分析。使用 GraphFrames,你可以利用 DataFrame API 的强大、简单和性能来解决你的图问题。

关于 GraphFrames 的更多信息,请参阅以下资源:

在下一章中,我们将扩展我们的 PySpark 视野,进入深度学习领域,重点关注 TensorFlow 和 TensorFrames。

第八章。TensorFrames

本章将提供一个关于新兴领域深度学习及其重要性的高级入门,它将提供围绕特征学习和神经网络的基本原理,这些是深度学习所必需的。此外,本章还将为 Apache Spark 的 TensorFrames 提供快速入门。

在本章中,你将学习关于:

  • 什么是深度学习?

  • 特征学习入门

  • 什么是特征工程?

  • 什么是 TensorFlow?

  • 介绍 TensorFrames

  • TensorFrames – 快速入门

如前所述,我们将首先讨论深度学习——更具体地说,我们将从神经网络开始。

什么是深度学习?

深度学习是机器学习技术家族的一部分,它基于学习数据表示。深度学习松散地基于我们大脑自己的神经网络,这个结构的目的在于提供大量高度相互连接的元素(在生物系统中,这将是我们的大脑中的神经元);我们的大脑中大约有 1000 亿个神经元,每个神经元连接到大约 10000 个其他神经元,从而产生了令人难以置信的 10¹⁵ 个突触连接。这些元素通过学习过程共同解决问题——例如模式识别和数据分类。

在这个架构中学习涉及对相互连接的元素之间连接的修改,类似于我们自己的大脑如何调整神经元之间的突触连接:

什么是深度学习?

来源:维基媒体共享资源:文件:神经元网络.jpgcommons.wikimedia.org/wiki/File:Réseau_de_neurones.jpg

传统的算法方法涉及编程已知的步骤或数量,也就是说,你已经知道解决特定问题的步骤,现在重复解决方案并使其运行更快。神经网络是一个有趣的范例,因为神经网络通过示例学习,实际上并没有被编程去执行特定的任务。这使得神经网络(和深度学习)的训练过程非常重要,你必须提供好的示例供神经网络学习,否则它将“学习”错误的东西(即提供不可预测的结果)。

建立人工神经网络最常见的方法涉及创建三个层次:输入、隐藏和输出;如下面的图所示:

什么是深度学习?

每一层由一个或多个节点组成,这些节点之间有连接(即数据流),正如前面图中所注明的。输入节点是被动接收数据,但不修改信息。隐藏层和输出层中的节点将积极修改数据。例如,从输入层中的三个节点到第一个隐藏层中的一个节点的连接在以下图中表示:

什么是深度学习?

参考信号处理神经网络示例,每个输入(表示为什么是深度学习?)都应用了一个权重(表示为什么是深度学习?),这产生了新的值。在这种情况下,一个隐藏节点(表示为什么是深度学习?)是三个修改后的输入节点的结果:

什么是深度学习?

在训练过程中,还会应用一个偏差,以常数的形式调整总和。总和(在我们的例子中是h 1)通过所谓的激活函数,该函数决定了神经元的输出。以下图像中展示了此类激活函数的一些示例:

什么是深度学习?

此过程在隐藏层以及输出层中的每个节点上都会重复进行。输出节点是所有应用于每个激活层节点的输入值的权重的累积。学习过程是许多并行运行的迭代的结果,应用并重新应用这些权重(在本场景中)。

神经网络以各种大小和形状出现。最流行的是单层和多层前馈网络,类似于前面展示的结构;这种结构(即使只有两层和一个神经元!)在输出层中的神经元能够解决简单的回归问题(如线性回归和逻辑回归)到高度复杂的回归和分类任务(具有许多隐藏层和多个神经元)。另一种常用的类型是自组织图,有时也称为 Kohonen 网络,以芬兰研究者 Teuvo Kohonen 的名字命名,他首先提出了这种结构。这些结构是在没有教师的情况下进行训练的,也就是说,它们不需要目标(一个无监督学习范式)。这种结构最常用于解决聚类问题,其目的是在数据中找到潜在的规律。

注意

关于神经网络类型的更多信息,我们建议查看以下文档:www.ieee.cz/knihovna/Zhang/Zhang100-ch03.pdf

注意,除了 TensorFlow 之外,还有许多其他有趣的深度学习库;包括但不限于 Theano、Torch、Caffe、Microsoft 认知工具包(CNTK)、mxnet 和 DL4J。

神经网络和深度学习的需求

神经网络(以及深度学习)有许多潜在的应用。其中一些更受欢迎的应用包括面部识别、手写数字识别、游戏、语音识别、语言翻译和物体分类。关键之处在于它涉及到学习和模式识别。

尽管神经网络已经存在很长时间(至少在计算机科学的历史背景下),但它们现在变得更加流行,这得益于以下主题:分布式计算和研究的进步与可用性:

  • 分布式计算和硬件的进步与可用性:如 Apache Spark 这样的分布式计算框架允许你通过并行运行更多模型来更快地完成更多训练迭代,从而确定机器学习模型的最佳参数。随着 GPU(最初设计用于显示图形的图形处理单元)的普及,这些处理器擅长执行机器学习所需的资源密集型数学计算。与云计算相结合,由于前期成本较低、部署时间短、易于弹性扩展,因此更容易利用分布式计算和 GPU 的力量。

  • 深度学习研究的进步:这些硬件进步帮助神经网络重新回到了数据科学的前沿,例如 TensorFlow 以及其他流行的项目,如 Theano、Caffe、Torch、微软认知工具包(CNTK)、mxnet 和 DL4J。

要深入了解这些主题,两个很好的参考资料包括:

  • 从大规模部署深度学习中学到的经验 (blog.algorithmia.com/deploying-deep-learning-cloud-services/): 这篇博客文章由 Algorithmia 团队撰写,讨论了他们在大规模部署深度学习解决方案中的经验。

  • 《神经网络》由 Christos Stergio 和 Dimitrios Siganos 编写 (bit.ly/2hNSWar):这是一本关于神经网络的优秀入门书籍。

如前所述,深度学习是机器学习方法家族的一部分,基于学习数据表示。在学习表示的情况下,这也可以定义为特征学习。使深度学习如此令人兴奋的是,它有潜力取代或最小化对手动特征工程的需求。深度学习将使机器不仅能够学习特定任务,还能够学习完成该任务所需的特征。更简洁地说,自动化特征工程或教会机器如何学习学习(关于特征学习的一个很好的参考资料是斯坦福大学的无监督特征学习和深度学习教程:deeplearning.stanford.edu/tutorial/)。

将这些概念分解到基础,让我们从特征开始。正如在 Christopher Bishop 的《模式识别与机器学习》(柏林:Springer. ISBN 0-387-31073-8. 2006)中观察到的那样,以及在 MLlib 和 ML 的前几章中提到的,特征是观察现象的可测量属性。

如果你更熟悉统计学领域,一个特征将指的是在随机线性回归模型中的独立变量(x[1],x[2],…,x[n]):

神经网络和深度学习的必要性

在这个特定例子中,y是因变量,x i是自变量。

在机器学习场景的背景下,特征示例包括:

特征工程是确定这些特征(例如,在统计学中,独立变量)中哪些对于定义你正在创建的模型是重要的过程。通常,它涉及到使用领域知识来创建特征,以便 ML 模型能够工作。

提出特征是困难的,耗时,需要专业知识。

“应用机器学习”基本上是特征工程。

—Andrew Ng,《通过脑模拟进行机器学习和人工智能》(helper.ipam.ucla.edu/publications/gss2012/gss2012_10595.pdf)

特征工程是什么?

通常,执行特征工程涉及诸如特征选择(选择原始特征集的子集)或特征提取(从原始特征集中构建新的特征集)等概念:

数据与算法的桥梁

让我们以餐厅推荐为例,在特征选择的情况下,将特征和特征工程定义联系起来:

数据与算法的桥梁

虽然这是一个简化的模型,但这个比喻描述了应用机器学习的基本前提。确定这个餐厅推荐模型的关键特征将取决于数据科学家分析数据。

在我们的餐厅推荐案例中,虽然可能很容易假设地理位置和菜系类型是主要因素,但要了解用户(即餐厅顾客)是如何选择他们的餐厅偏好的,可能需要深入挖掘数据。不同的餐厅往往有不同的特征或权重。

例如,高端餐厅餐饮业务的关键特征通常与位置(即,靠近客户的位置)有关,能够为大型团体预订,以及酒单的多样性:

数据与算法的桥梁

同时,对于特色餐厅,往往很少涉及之前提到的因素;相反,重点是评论、评分、社交媒体的热度,以及,可能的话,餐厅是否适合孩子:

数据与算法的桥梁

将这些不同的餐厅(及其目标受众)进行细分的能力是应用机器学习的关键方面。这可能是一个费时的过程,你尝试不同的模型和算法,使用不同的变量和权重,然后在迭代训练和测试许多不同组合后重新尝试。但请注意,这种耗时迭代的本身就是一个可以潜在自动化的过程?这是构建算法、帮助机器“学会学习”的关键背景:深度学习有潜力在构建我们的模型时自动化学习过程。

什么是 TensorFlow?

TensorFlow 是一个使用数据流图进行数值计算的 Google 开源软件库;也就是说,一个专注于深度学习的开源机器学习库。基于神经网络,TensorFlow 是 Google Brain 团队的研究人员和工程师将深度学习应用于 Google 产品并构建各种 Google 团队(包括但不限于搜索、照片和语音)的生产模型的成果。

基于 C++ 并具有 Python 接口构建,它迅速成为短时间内最受欢迎的深度学习项目之一。以下截图显示了四个流行深度学习库之间的 Google 趋势比较;请注意,2015 年 11 月 8 日至 14 日(TensorFlow 发布时)的峰值以及过去一年内的快速上升(此快照是在 2016 年 12 月底拍摄的):

什么是 TensorFlow?

另一种衡量 TensorFlow 受欢迎程度的方法是注意到,根据www.theverge.com/2016/4/13/11420144/google-machine-learning-tensorflow-upgrade,TensorFlow 是 GitHub 上最受欢迎的机器学习框架。请注意,TensorFlow 仅在 2015 年 11 月发布,两个月后它已经成为最受欢迎的 GitHub ML 分支库。在下图中,您可以查看 2015 年创建的 GitHub 仓库(交互式可视化)donnemartin.com/viz/pages/2015

什么是 TensorFlow?

如前所述,TensorFlow 使用数据流图进行数值计算。当思考图(如前一章所述的 GraphFrames)时,该图的节点(或顶点)代表数学运算,而图边代表在不同节点(即数学运算)之间通信的多维数组(即张量)。

参考以下图,t 1 是一个 2x3 矩阵,而 t 2 是一个 3x2 矩阵;这些是张量(或张量图的边)。节点是表示为 op 1 的数学操作:

什么是 TensorFlow?

在这个例子中,op 1 是由以下图表示的矩阵乘法操作,尽管这可以是 TensorFlow 中可用的许多数学操作之一:

什么是 TensorFlow?

一起,为了在图中进行数值计算,多维数组(即张量)在数学操作(节点)之间流动,即张量的流动,或称 TensorFlow

为了更好地理解 TensorFlow 的工作原理,让我们首先在你的 Python 环境中安装 TensorFlow(最初不带 Spark)。完整的说明请参阅 TensorFlow | 下载和设置:www.tensorflow.org/versions/r0.12/get_started/os_setup.html

对于本章,让我们专注于在 Linux 或 Mac OS 上安装 Python pip 软件包管理系统。

安装 Pip

确保你已经安装了 pip;如果没有,请使用以下命令安装 Ubuntu/Linux 的 Python 软件包安装管理器:

# Ubuntu/Linux 64-bit 
$ sudo apt-get install python-pip python-dev

对于 Mac OS,你会使用以下命令:

# macOS 
$ sudo easy_install pip 
$ sudo easy_install --upgrade six

注意,对于 Ubuntu/Linux,你可能还想升级 pip,因为 Ubuntu 仓库中的 pip 很旧,可能不与新软件包兼容。为此,你可以运行以下命令:

# Ubuntu/Linux pip upgrade
$ pip install --upgrade pip 

安装 TensorFlow

要安装 TensorFlow(pip 已安装),你只需要执行以下命令:

$ pip install tensorflow

如果你有一台支持 GPU 的计算机,你可以改用以下命令:

$ pip install tensorflow-gpu

注意,如果前面的命令不起作用,根据你的 Python 版本(即 2.7、3.4 或 3.5)和 GPU 支持情况,有特定的说明来安装带有 GPU 支持的 TensorFlow。

例如,如果我想在 Mac OS 上为 Python 2.7 安装带有 GPU 启用的 TensorFlow,请执行以下命令:

# macOS, GPU enabled, Python 2.7: 
$ export TF_BINARY_URL=https://storage.googleapis.com/tensorflow/mac/gpu/tensorflow_gpu-0.12.0rc1-py2-none-any.whl
# Python 2 
$ sudo pip install --upgrade $TF_BINARY_URL

注意

请参阅 www.tensorflow.org/versions/r0.12/get_started/os_setup.html 获取最新的安装说明。

使用常数的矩阵乘法

为了更好地描述张量以及 TensorFlow 的工作原理,让我们从一个涉及两个常数的矩阵乘法计算开始。如以下图所示,我们有 c 13x1 矩阵)和 c 21x3 矩阵),其中操作(op 1)是矩阵乘法:

使用常数的矩阵乘法

我们现在将使用以下代码定义 c 11x3 矩阵)和 c 23x1 矩阵):

# Import TensorFlow
import tensorflow as tf
# Setup the matrix
#   c1: 1x3 matrix
#   c2: 3x1 matrix
c1 = tf.constant([[3., 2., 1.]])
c2 = tf.constant([[-1.], [2.], [1.]])

现在我们有了我们的常量,让我们使用以下代码运行我们的矩阵乘法。在 TensorFlow 图的上下文中,请记住,图中的节点被称为操作(或ops)。以下矩阵乘法是ops,而两个矩阵(c1c2)是张量(类型化的多维数组)。一个op可以接受零个或多个张量作为其输入,执行数学计算等操作,输出为零个或多个以numpy ndarray对象或 C,C++中的tensorflow::Tensor interfaces形式存在的张量:

# m3: matrix multiplication (m1 x m3)
mp = tf.matmul(c1, c2)

现在这个 TensorFlow 图已经建立,这个操作的执行(例如,在这种情况下,矩阵乘法)是在session的上下文中完成的;session将图ops放置在 CPU 或 GPU(即设备)上以执行:

# Launch the default graph
s = tf.Session()

# run: Execute the ops in graph
r = s.run(mp)
print(r)

输出为:

# [[ 2.]]

一旦你完成了你的操作,你可以关闭会话:

# Close the Session when completed
s.close()

使用占位符进行矩阵乘法

现在我们将执行与之前相同的任务,但这次我们将使用张量而不是常量。如以下图所示,我们将从两个矩阵(m1: 3x1, m2: 1x3)开始,使用与上一节相同的值:

使用占位符进行矩阵乘法

在 TensorFlow 中,我们将使用placeholder来定义我们的两个张量,如下代码片段所示:

# Setup placeholder for your model
#   t1: placeholder tensor
#   t2: placeholder tensor
t1 = tf.placeholder(tf.float32)
t2 = tf.placeholder(tf.float32)

# t3: matrix multiplication (m1 x m3)
tp = tf.matmul(t1, t2)

这种方法的优势在于,使用占位符,你可以使用相同的操作(即在这种情况下,矩阵乘法)与不同大小和形状的张量(只要它们满足操作的标准)。就像上一节中的操作一样,让我们定义两个矩阵并执行图(使用简化的会话执行)。

运行模型

以下代码片段与上一节的代码片段类似,但现在它使用占位符而不是常量:

# Define input matrices
m1 = [[3., 2., 1.]]
m2 = [[-1.], [2.], [1.]]

# Execute the graph within a session
with tf.Session() as s:
     print(s.run([tp], feed_dict={t1:m1, t2:m2}))

输出既包括值,也包括数据类型:

[array([[ 2.]], dtype=float32)]

运行另一个模型

现在我们已经有一个使用placeholders的图(尽管是一个简单的图),我们可以使用不同的张量来执行相同的操作,使用不同的输入矩阵。如以下图所示,我们有m1(4x1)和m2(1x4):

运行另一个模型

由于我们使用placeholders,我们可以轻松地在新的会话中重用相同的图,使用新的输入:

# setup input matrices
m1 = [[3., 2., 1., 0.]]
m2 = [[-5.], [-4.], [-3.], [-2.]]
# Execute the graph within a session
with tf.Session() as s:
     print(s.run([tp], feed_dict={t1:m1, t2:m2}))

输出为:

[array([[-26.]], dtype=float32)]

讨论

如前所述,TensorFlow 通过将计算表示为图来为用户提供使用 Python 库执行深度学习的能力,其中张量代表数据(图的边),操作代表要执行的内容(例如,数学计算)(图的顶点)。

更多信息请参阅:

介绍 TensorFrames

在撰写本文时,TensorFrames 是 Apache Spark 的一个实验性绑定;它于 2016 年初推出,紧随 TensorFlow 的发布之后。使用 TensorFrames,可以通过 TensorFlow 程序操作 Spark DataFrames。参考上一节中的张量图,我们已更新了以下图示,以展示 Spark DataFrames 如何与 TensorFlow 协同工作:

介绍 TensorFrames

如前图所示,TensorFrames 在 Spark DataFrames 和 TensorFlow 之间提供了一个桥梁。这使得您可以将您的 DataFrames 作为输入应用到 TensorFlow 的计算图中。TensorFrames 还允许您将 TensorFlow 计算图的输出推回 DataFrames,以便您可以继续您的下游 Spark 处理。

在 TensorFrames 的常见使用场景中,通常包括以下内容:

利用 TensorFlow 处理您的数据

TensorFlow 与 Apache Spark 的集成,通过 TensorFrames 允许数据科学家扩展他们的分析、流处理、图和机器学习能力,包括通过 TensorFlow 进行深度学习。这允许您在大规模上训练和部署模型。

并行训练以确定最佳超参数

在构建深度学习模型时,有几个配置参数(即超参数)会影响模型的训练方式。在深度学习/人工神经网络中常见的超参数包括定义学习率(如果速率高,则学习速度快,但它可能不会考虑到高度可变性的输入——也就是说,如果数据和速率的可变性太高,它可能学不好)以及您神经网络中每层的神经元数量(神经元太多会导致估计噪声,而神经元太少会导致网络学不好)。

如在《Apache Spark 和 TensorFlow 的深度学习》(databricks.com/blog/2016/01/25/deep-learning-with-apache-spark-and-tensorflow.html)中观察到的那样,使用 Spark 与 TensorFlow 结合以帮助找到神经网络训练的最佳超参数集,这导致训练时间减少了 10 个数量级,并且手写数字识别数据集的错误率降低了 34%。

如需了解更多关于深度学习和超参数的信息,请参阅:

在撰写本文时,TensorFrames 作为 Apache Spark 1.6(Scala 2.10)的官方支持,尽管目前大多数贡献都集中在 Spark 2.0(Scala 2.11)上。使用 TensorFrames 的最简单方法是通过 Spark Packages (spark-packages.org) 访问它。

TensorFrames – 快速入门

在所有这些前言之后,让我们通过这个快速入门教程快速开始使用 TensorFrames。您可以在 Databricks Community Edition 中下载并使用完整的笔记本,网址为 bit.ly/2hwGyuC

您也可以从 PySpark shell(或其他 Spark 环境)中运行此命令,就像运行任何其他 Spark 包一样:

# The version we're using in this notebook
$SPARK_HOME/bin/pyspark --packages tjhunter:tensorframes:0.2.2-s_2.10  

# Or use the latest version 
$SPARK_HOME/bin/pyspark --packages databricks:tensorframes:0.2.3-s_2.10

注意,您将只使用上述命令中的一个(即,不是两个)。有关更多信息,请参阅 databricks/tensorframes GitHub 仓库 (github.com/databricks/tensorframes)。

配置和设置

请按照以下顺序遵循配置和设置步骤:

启动 Spark 集群

使用 Spark 1.6(Hadoop 1)和 Scala 2.10 启动 Spark 集群。这已经在 Databricks Community Edition 上的 Spark 1.6、Spark 1.6.2 和 Spark 1.6.3(Hadoop 1)上进行了测试 (databricks.com/try-databricks)。

创建 TensorFrames 库

创建一个库以将 TensorFrames 0.2.2 附加到您的集群:tensorframes-0.2.2-s_2.10。请参阅第七章,GraphFrames,以回忆如何创建库。

在您的集群上安装 TensorFlow

在笔记本中运行以下命令之一以安装 TensorFlow。这已经与 TensorFlow 0.9 CPU 版本进行了测试:

  • TensorFlow 0.9,Ubuntu/Linux 64 位,仅 CPU,Python 2.7:

    /databricks/python/bin/pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.9.0rc0-cp27-none-linux_x86_64.whl
    
  • TensorFlow 0.9,Ubuntu/Linux 64 位,启用 GPU,Python 2.7:

    /databricks/python/bin/pip install https://storage.googleapis.com/tensorflow/linux/gpu/tensorflow-0.9.0rc0-cp27-none-linux_x86_64.whl
    

以下是将 TensorFlow 安装到 Apache Spark 驱动器的 pip 安装命令:

%sh
/databricks/python/bin/pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.9.0rc0-cp27-none-linux_x86_64.whl

成功安装应该有类似以下输出:

Collecting tensorflow==0.9.0rc0 from https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.9.0rc0-cp27-none-linux_x86_64.whl Downloading https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.9.0rc0-cp27-none-linux_x86_64.whl (27.6MB) Requirement already satisfied (use --upgrade to upgrade): numpy>=1.8.2 in /databricks/python/lib/python2.7/site-packages (from tensorflow==0.9.0rc0) Requirement already satisfied (use --upgrade to upgrade): six>=1.10.0 in /usr/lib/python2.7/dist-packages (from tensorflow==0.9.0rc0) Collecting protobuf==3.0.0b2 (from tensorflow==0.9.0rc0) Downloading protobuf-3.0.0b2-py2.py3-none-any.whl (326kB) Requirement already satisfied (use --upgrade to upgrade): wheel in /databricks/python/lib/python2.7/site-packages (from tensorflow==0.9.0rc0) Requirement already satisfied (use --upgrade to upgrade): setuptools in /databricks/python/lib/python2.7/site-packages (from protobuf==3.0.0b2->tensorflow==0.9.0rc0) Installing collected packages: protobuf, tensorflow Successfully installed protobuf-3.0.0b2 tensorflow-0.9.0rc0

在成功安装 TensorFlow 后,断开并重新连接您刚刚运行此命令的笔记本。您的集群现在已配置;您可以在驱动器上运行纯 TensorFlow 程序,或在整个集群上运行 TensorFrames 示例。

使用 TensorFlow 向现有列添加常数

这是一个简单的 TensorFrames 程序,其中op是执行简单的加法。请注意,原始源代码可以在databricks/tensorframes GitHub 存储库中找到。这是关于 TensorFrames Readme.md | 如何在 Python 中运行部分(github.com/databricks/tensorframes#how-to-run-in-python)。

我们首先将导入TensorFlowTensorFramespyspark.sql.row,然后基于浮点 RDD 创建一个 DataFrame:

# Import TensorFlow, TensorFrames, and Row
import tensorflow as tf
import tensorframes as tfs
from pyspark.sql import Row

# Create RDD of floats and convert into DataFrame `df`
rdd = [Row(x=float(x)) for x in range(10)]
df = sqlContext.createDataFrame(rdd)

要查看由浮点 RDD 生成的df DataFrame,我们可以使用show命令:

df.show()

这会产生以下结果:

使用 TensorFlow 向现有列添加常数

执行 Tensor 图

如前所述,这个张量图由将 3 加到由浮点 RDD 生成的df DataFrame 创建的张量组成。我们现在将执行以下代码片段:

# Run TensorFlow program executes:
#   The 'op' performs the addition (i.e. 'x' + '3')
#   Place the data back into a DataFrame
with tf.Graph().as_default() as g:

#   The placeholder that corresponds to column 'x'.
#   The shape of the placeholder is automatically
#   inferred from the DataFrame.
    x = tfs.block(df, "x")

    # The output that adds 3 to x
    z = tf.add(x, 3, name='z')

    # The resulting `df2` DataFrame
    df2 = tfs.map_blocks(z, df)

# Note that 'z' is the tensor output from the
# 'tf.add' operation
print z

## Output
Tensor("z:0", shape=(?,), dtype=float64)

这里是对前面代码片段的一些具体说明:

  • x使用tfs.block,其中block基于 DataFrame 中列的内容构建块占位符

  • z是 TensorFlow add方法(tf.add)的输出张量

  • df2是新的 DataFrame,它通过分块将z张量添加到df DataFrame 的额外列中

虽然z是张量(如前所述),但为了处理 TensorFlow 程序的结果,我们将使用df2 dataframe。df2.show()的输出如下:

执行 Tensor 图

分块减少操作示例

在下一节中,我们将展示如何处理分块减少操作。具体来说,我们将计算字段向量的和与最小值,通过处理行块以实现更有效的处理。

构建向量 DataFrame

首先,我们将创建一个包含向量的单列 DataFrame:

# Build a DataFrame of vectors
data = [Row(y=[float(y), float(-y)]) for y in range(10)]
df = sqlContext.createDataFrame(data)
df.show()

输出如下:

构建向量 DataFrame

分析 DataFrame

我们需要分析 DataFrame 以确定其形状(即向量的维度)。例如,在以下代码片段中,我们使用tfs.print_schema命令对df DataFrame 进行操作:

# Print the information gathered by TensorFlow to check the content of the DataFrame
tfs.print_schema(df)

## Output
root 
|-- y: array (nullable = true) double[?,?]

注意double[?,?],这意味着 TensorFlow 不知道向量的维度:

# Because the dataframe contains vectors, we need to analyze it
# first to find the dimensions of the vectors.
df2 = tfs.analyze(df)

# The information gathered by TF can be printed 
# to check the content:
tfs.print_schema(df2)

## Output
root 
|-- y: array (nullable = true) double[?,2] 

在分析df2 DataFrame 后,TensorFlow 推断出y包含大小为 2 的向量。对于小张量(标量和向量),TensorFrames 通常不需要初步分析就能推断出张量的形状。如果它无法做到这一点,错误信息将指示您首先运行 DataFrame 通过tfs.analyze()

计算所有向量的逐元素和与最小值

现在,让我们分析df DataFrame,使用tf.reduce_sumtf.reduce_min计算所有向量的sum和逐元素min

  • tf.reduce_sum:计算张量跨维度的元素总和,例如,如果x = [[3, 2, 1], [-1, 2, 1]],则tf.reduce_sum(x) ==> 8。更多信息可以在:https://www.tensorflow.org/api_docs/python/tf/reduce_sum找到。

  • tf.reduce_min:计算张量跨维度的元素最小值,例如,如果x = [[3, 2, 1], [-1, 2, 1]],则tf.reduce_min(x) ==> -1。更多信息可以在:https://www.tensorflow.org/api_docs/python/tf/reduce_min找到。

以下代码片段允许我们使用 TensorFlow 执行高效的元素级缩减,其中源数据位于 DataFrame 中:

# Note: First, let's make a copy of the 'y' column. 
# This is an inexpensive operation in Spark 2.0+
df3 = df2.select(df2.y, df2.y.alias("z"))

# Execute the Tensor Graph
with tf.Graph().as_default() as g:

  # The placeholders. 
  # Note the special name that end with '_input':
    y_input = tfs.block(df3, 'y', tf_name="y_input")
    z_input = tfs.block(df3, 'z', tf_name="z_input")

    # Perform elementwise sum and minimum 
    y = tf.reduce_sum(y_input, [0], name='y')
    z = tf.reduce_min(z_input, [0], name='z')

# The resulting dataframe
(data_sum, data_min) = tfs.reduce_blocks([y, z], df3)

# The finalresults are numpy arrays:
print "Elementwise sum: %s and minimum: %s " % (data_sum, data_min)

## Output
Elementwise sum: [ 45\. -45.] and minimum: [ 0\. -9.] 

使用 TensorFrames 的几行 TensorFlow 代码,我们可以从存储在df DataFrame 中的数据中提取数据,执行 Tensor 图以执行元素级求和和最小值,将数据合并回 DataFrame,并在我们的案例中打印出最终值。

摘要

在本章中,我们回顾了神经网络和深度学习的基础知识,包括特征工程组件。随着深度学习的所有这些新兴奋点,我们介绍了 TensorFlow 以及它是如何通过 TensorFrames 与 Apache Spark 紧密协作的。

TensorFrames 是一个强大的深度学习工具,允许数据科学家和工程师使用存储在 Spark DataFrame 中的数据与 TensorFlow 一起工作。这使您能够将 Apache Spark 的功能扩展到基于神经网络学习过程的强大深度学习工具集。为了帮助您继续深度学习之旅,以下是一些优秀的 TensorFlow 和 TensorFrames 资源:

第九章. 使用 Blaze 的多语言持久化

我们的世界是复杂的,没有一种单一的方法可以解决所有问题。同样,在数据世界中,也不能用一种技术来解决所有问题。

现在,任何大型科技公司(以某种形式)都使用 MapReduce 范式来筛选每天收集的数以千计(甚至数以万计)的数据。另一方面,在文档型数据库(如 MongoDB)中存储、检索、扩展和更新产品信息比在关系型数据库中要容易得多。然而,在关系型数据库中持久化交易记录有助于后续的数据汇总和报告。

即使这些简单的例子也表明,解决各种商业问题需要适应不同的技术。这意味着,如果你作为数据库管理员、数据科学家或数据工程师,想要用设计来轻松解决这些问题的工具来解决这些问题,你就必须分别学习所有这些技术。然而,这并不使你的公司变得敏捷,并且容易出错,需要对你的系统进行大量的调整和破解。

Blaze 抽象了大多数技术,并暴露了一个简单而优雅的数据结构和 API。

在本章中,你将学习:

  • 如何安装 Blaze

  • 多语言持久化的含义

  • 如何抽象存储在文件、pandas DataFrame 或 NumPy 数组中的数据

  • 如何处理归档(GZip)

  • 如何使用 Blaze 连接到 SQL(PostgreSQL 和 SQLite)和 No-SQL(MongoDB)数据库

  • 如何查询、连接、排序、转换数据,并执行简单的汇总统计

安装 Blaze

如果你运行 Anaconda,安装 Blaze 很容易。只需在你的 CLI(如果你不知道 CLI 是什么,请参阅奖励章节第一章,安装 Spark)中发出以下命令:

conda install blaze

一旦发出命令,你将看到类似于以下截图的屏幕:

安装 Blaze

我们将稍后使用 Blaze 连接到 PostgreSQL 和 MongoDB 数据库,因此我们需要安装一些 Blaze 在后台使用的附加包。

我们将安装 SQLAlchemy 和 PyMongo,它们都是 Anaconda 的一部分:

conda install sqlalchemy
conda install pymongo

现在剩下的只是在我们笔记本中导入 Blaze 本身:

import blaze as bl

多语言持久化

Neal Ford 于 2006 年引入了“多语言编程”这一概念,用以说明没有一种万能的解决方案,并提倡使用多种更适合特定问题的编程语言。

在数据并行的世界中,任何想要保持竞争力的企业都需要适应一系列技术,以便在尽可能短的时间内解决问题,从而最小化成本。

在 Hadoop 文件中存储事务数据是可能的,但意义不大。另一方面,使用关系数据库管理系统RDBMS)处理 PB 级的互联网日志也是不明智的。这些工具是为了解决特定类型的任务而设计的;尽管它们可以被用来解决其他问题,但适应这些工具以解决这些问题的成本将是巨大的。这就像试图将方钉塞入圆孔一样。

例如,考虑一家在线销售乐器和配件的公司(以及一系列商店)。从高层次来看,公司需要解决许多问题才能成功:

  1. 吸引客户到其商店(无论是虚拟的还是实体的)。

  2. 向他们展示相关的产品(你不会试图向钢琴家卖鼓组,对吧?!)。

  3. 一旦他们决定购买,处理付款并安排运输。

为了解决这些问题,公司可能会选择一系列旨在解决这些问题的技术:

  1. 将所有产品存储在文档数据库中,如 MongoDB、Cassandra、DynamoDB 或 DocumentDB。文档数据库具有多个优点:灵活的模式、分片(将更大的数据库分解为一系列更小、更易于管理的数据库)、高可用性和复制等。

  2. 使用基于图数据库(如 Neo4j、Tinkerpop/Gremlin 或 Spark 的 GraphFrames)来建模推荐:此类数据库反映了客户及其偏好的事实和抽象关系。挖掘这样的图非常有价值,可以为客户提供更个性化的服务。

  3. 对于搜索,公司可能会使用定制的搜索解决方案,如 Apache Solr 或 ElasticSearch。此类解决方案提供快速、索引的文本搜索功能。

  4. 一旦产品售出,交易通常有一个结构良好的模式(例如产品名称、价格等)。为了存储此类数据(以及稍后对其进行处理和报告),关系数据库是最适合的。

使用多语言持久性,公司总是选择最适合的工具来完成工作,而不是试图将单一技术强加于解决所有问题。

如我们所见,Blaze 将这些技术抽象化,并引入了一个简单的 API 来与之交互,因此您不需要学习每个想要使用的技术的 API。本质上,它是一个多语言持久性的优秀工作示例。

备注

要了解其他人如何做,请查看www.slideshare.net/Couchbase/couchbase-at-ebay-2014

或者

www.slideshare.net/bijoor1/case-study-polyglotpersistence-in-pharmaceutical-industry.

抽象化数据

Blaze 可以抽象许多不同的数据结构,并暴露一个单一、易于使用的 API。这有助于获得一致的行为并减少学习多个接口来处理数据的需要。如果你熟悉 pandas,实际上没有多少东西需要学习,因为语法上的差异是细微的。我们将通过一些示例来说明这一点。

使用 NumPy 数组

将数据从 NumPy 数组放入 Blaze 的 DataShape 对象中非常容易。首先,让我们创建一个简单的 NumPy 数组:我们首先加载 NumPy,然后创建一个两行三列的矩阵:

import numpy as np
simpleArray = np.array([
        [1,2,3],
        [4,5,6]
    ])

现在我们有了数组,我们可以使用 Blaze 的 DataShape 结构来抽象它:

simpleData_np = bl.Data(simpleArray)

就这样!简单得令人难以置信。

为了窥视结构,你可以使用 .peek() 方法:

simpleData_np.peek()

你应该看到以下截图所示的类似输出:

使用 NumPy 数组

你也可以使用(对于那些熟悉 pandas 语法的人来说很熟悉)的 .head(...) 方法。

注意

.peek().head(...) 之间的区别在于 .head(...) 允许指定行数作为其唯一参数,而 .peek() 不允许这样做,并且总是打印前 10 条记录。

如果你想要检索你的 DataShape 的第一列,你可以使用索引:

simpleData_np[0]

你应该看到一个表格,就像这里所示:

使用 NumPy 数组

另一方面,如果你对检索一行感兴趣,你所要做的(就像在 NumPy 中一样)就是转置你的 DataShape:

simpleData_np.T[0]

你将得到的结果如下所示:

使用 NumPy 数组

注意到列的名称是 None。DataShapes,就像 pandas 的 DataFrames 一样,支持命名列。因此,让我们指定我们字段的名称:

simpleData_np = bl.Data(simpleArray, fields=['a', 'b', 'c'])

现在,你可以通过调用列的名称来简单地检索数据:

simpleData_np['b']

作为回报,你将得到以下输出:

使用 NumPy 数组

如你所见,定义字段会转置 NumPy 数组,现在,数组的每个元素都形成一个 ,这与我们最初创建的 simpleData_np 不同。

使用 pandas 的 DataFrame

由于 pandas 的 DataFrame 内部使用 NumPy 数据结构,将 DataFrame 转换为 DataShape 是轻而易举的。

首先,让我们创建一个简单的 DataFrame。我们首先导入 pandas:

import pandas as pd

接下来,我们创建一个 DataFrame:

simpleDf = pd.DataFrame([
        [1,2,3],
        [4,5,6]
    ], columns=['a','b','c'])

然后,我们将它转换成一个 DataShape:

simpleData_df = bl.Data(simpleDf)

你可以使用与从 NumPy 数组创建的 DataShape 相同的方式检索数据。使用以下命令:

simpleData_df['a']

然后,它将产生以下输出:

使用 pandas 的 DataFrame

使用文件

DataShape 对象可以直接从 .csv 文件创建。在这个例子中,我们将使用一个包含 404,536 蒙哥马利县马里兰州发生的交通违规行为的数据集。

注意

我们于 2016 年 8 月 23 日从 catalog.data.gov/dataset/traffic-violations-56dda 下载了数据;数据集每日更新,因此如果你在稍后的日期检索数据集,交通违规的数量可能会有所不同。

我们将数据集存储在本地 ../Data 文件夹中。然而,我们稍微修改了数据集,以便我们可以将其存储在 MongoDB 中:在其原始形式中,带有日期列,从 MongoDB 中读取数据会导致错误。我们向 Blaze 提交了一个错误报告 github.com/blaze/blaze/issues/1580

import odo
traffic = bl.Data('../Data/TrafficViolations.csv')

如果你不知道任何数据集的列名,你可以从 DataShape 中获取这些信息。要获取所有字段的列表,可以使用以下命令:

print(traffic.fields)

处理文件

小贴士

对于熟悉 pandas 的你们来说,很容易识别 .fields.columns 属性之间的相似性,因为它们基本上以相同的方式工作——它们都返回列的列表(在 pandas DataFrame 的情况下),或者称为 Blaze DataShape 中的字段列表。

Blaze 还可以直接从 GZipped 归档中读取,节省空间:

traffic_gz = bl.Data('../Data/TrafficViolations.csv.gz')

为了验证我们得到的确切相同的数据,让我们从每个结构中检索前两个记录。你可以调用以下之一:

traffic.head(2)

或者,你也可以选择调用:

traffic_gz.head(2)

它产生相同的结果(此处省略列名):

处理文件

然而,很容易注意到从归档文件中检索数据需要显著更多的时间,因为 Blaze 需要解压缩数据。

你也可以一次从多个文件中读取并创建一个大数据集。为了说明这一点,我们将原始数据集按违规年份分割成四个 GZipped 数据集(这些存储在 ../Data/Years 文件夹中)。

Blaze 使用 odo 来处理将 DataShape 保存到各种格式。要保存按年份划分的交通违规数据,你可以这样调用 odo

import odo
for year in traffic.Stop_year.distinct().sort():
    odo.odo(traffic[traffic.Stop_year == year], 
        '../Data/Years/TrafficViolations_{0}.csv.gz'\
        .format(year))

上述指令将数据保存到 GZip 归档中,但你也可以将其保存到前面提到的任何格式。.odo(...) 方法的第一个参数指定输入对象(在我们的例子中,是 2013 年发生的交通违规的 DataShape),第二个参数是输出对象——我们想要保存数据的文件路径。正如我们即将学习的——存储数据不仅限于文件。

要从多个文件中读取,可以使用星号字符 *

traffic_multiple = bl.Data(
    '../Data/Years/TrafficViolations_*.csv.gz')
traffic_multiple.head(2)

上述代码片段,再次,将生成一个熟悉的表格:

处理文件

Blaze 的读取能力不仅限于 .csvGZip 文件:你可以从 JSON 或 Excel 文件(.xls.xlsx)、HDFS 或 bcolz 格式的文件中读取数据。

小贴士

要了解更多关于 bcolz 格式的信息,请查看其文档 github.com/Blosc/bcolz

处理数据库

Blaze 也可以轻松地从 SQL 数据库(如 PostgreSQL 或 SQLite)中读取。虽然 SQLite 通常是一个本地数据库,但 PostgreSQL 可以在本地或服务器上运行。

如前所述,Blaze 在后台使用 odo 来处理与数据库的通信。

注意

odo 是 Blaze 的一个要求,它将与包一起安装。在此处查看 github.com/blaze/odo

为了执行本节中的代码,您需要两样东西:一个运行中的本地 PostgreSQL 数据库实例,以及一个本地运行的 MongoDB 数据库。

小贴士

为了安装 PostgreSQL,从 www.postgresql.org/download/ 下载包,并遵循那里找到的适用于您操作系统的安装说明。

要安装 MongoDB,请访问 www.mongodb.org/downloads 并下载包;安装说明可以在 docs.mongodb.org/manual/installation/ 找到。

在您继续之前,我们假设您已经在 http://localhost:5432/ 上运行了一个 PostgreSQL 数据库,并且 MongoDB 数据库在 http://localhost:27017 上运行。

我们已经将交通数据加载到两个数据库中,并将它们存储在 traffic 表(PostgreSQL)或 traffic 集合(MongoDB)中。

小贴士

如果您不知道如何上传数据,我在我的另一本书中解释了这一点 www.packtpub.com/big-data-and-business-intelligence/practical-data-analysis-cookbook

与关系数据库交互

现在我们从 PostgreSQL 数据库中读取数据。访问 PostgreSQL 数据库的 统一资源标识符URI)具有以下语法 postgresql://<user_name>:<password>@<server>:<port>/<database>::<table>

要从 PostgreSQL 读取数据,只需将 URI 包裹在 .Data(...) 中 - Blaze 将处理其余部分:

traffic_psql = bl.Data(
    'postgresql://{0}:{1}@localhost:5432/drabast::traffic'\
    .format('<your_username>', '<your_password>')
)

我们使用 Python 的 .format(...) 方法来填充字符串以包含适当的数据。

小贴士

在前面的示例中替换您的凭据以访问 PostgreSQL 数据库。如果您想了解更多关于 .format(...) 方法的知识,可以查看 Python 3.5 文档 docs.python.org/3/library/string.html#format-string-syntax

将数据输出到 PostgreSQL 或 SQLite 数据库相当简单。在下面的示例中,我们将输出涉及 2016 年生产的汽车的交通违规数据到 PostgreSQL 和 SQLite 数据库。如前所述,我们将使用 odo 来管理传输:

traffic_2016 = traffic_psql[traffic_psql['Year'] == 2016]
# Drop commands
# odo.drop('sqlite:///traffic_local.sqlite::traffic2016')
# odo.drop('postgresql://{0}:{1}@localhost:5432/drabast::traffic'\
.format('<your_username>', '<your_password>'))
# Save to SQLite
odo.odo(traffic_2016,
'sqlite:///traffic_local.sqlite::traffic2016')
# Save to PostgreSQL
odo.odo(traffic_2016,  
    'postgresql://{0}:{1}@localhost:5432/drabast::traffic'\
    .format('<your_username>', '<your_password>'))

与 pandas 类似,为了过滤数据,我们实际上选择了Year列(第一行的traffic_psql['Year']部分)并创建一个布尔标志,通过检查该列中的每个记录是否等于2016。通过将traffic_psql对象索引为这样的真值向量,我们提取了对应值等于True的记录。

如果您数据库中已经存在traffic2016表,则应取消注释以下两行;否则odo将数据追加到表末尾。

SQLite 的 URI 与 PostgreSQL 略有不同;它的语法如下sqlite://</relative/path/to/db.sqlite>::<table_name>

到现在为止,从 SQLite 数据库读取数据应该对您来说很简单:

traffic_sqlt = bl.Data(
    'sqlite:///traffic_local.sqlite::traffic2016'
)

与 MongoDB 数据库交互

MongoDB 多年来获得了大量的流行度。它是一个简单、快速且灵活的文档型数据库。对于使用MEAN.js堆栈的所有全栈开发者来说,该数据库是一个首选的存储解决方案:这里的 M 代表 Mongo(见meanjs.org)。

由于 Blaze 旨在以非常熟悉的方式工作,无论数据源如何,从 MongoDB 读取与从 PostgreSQL 或 SQLite 数据库读取非常相似:

traffic_mongo = bl.Data(
    'mongodb://localhost:27017/packt::traffic'
)

数据操作

我们已经介绍了一些您将使用 DataShapes(例如,.peek())的最常见方法,以及根据列值过滤数据的方式。Blaze 实现了许多使处理任何数据变得极其容易的方法。

在本节中,我们将回顾许多其他常用的数据处理方式和与之相关的方法。对于来自pandas和/或 SQL 的您,我们将提供相应的语法,如果存在等效项。

访问列

有两种访问列的方式:您可以一次获取一个列,就像访问 DataShape 属性一样:

traffic.Year.head(2)

上述脚本将产生以下输出:

访问列

您还可以使用索引,允许一次选择多个列:

(traffic[['Location', 'Year', 'Accident', 'Fatal', 'Alcohol']]
    .head(2))

这将生成以下输出:

访问列

上述语法对于 pandas DataFrames 也是相同的。对于不熟悉 Python 和 pandas API 的您,请注意以下三点:

  1. 要指定多个列,您需要将它们放在另一个列表中:注意双括号[[]]

  2. 如果所有方法的链不适合一行(或者您想为了更好的可读性而断开链),您有两个选择:要么将整个方法链用括号(...)括起来,其中...是所有方法的链,或者,在换行前,在每个方法链的行末放置反斜杠字符\。我们更喜欢后者,并将在我们的示例中继续使用它。

  3. 注意,等效的 SQL 代码将是:

    SELECT *
    FROM traffic
    LIMIT 2
    

符号变换

Blaze 的美丽之处在于它可以符号化地操作。这意味着您可以在数据上指定变换、过滤器或其他操作,并将它们作为对象存储。然后,您可以用几乎任何符合原始模式的数据形式提供这样的对象,Blaze 将返回变换后的数据。

例如,让我们选择所有发生在 2013 年的交通违规行为,并仅返回'Arrest_Type''Color''Charge'列。首先,如果我们不能从一个已存在的对象中反映模式,我们就必须手动指定模式。为此,我们将使用.symbol(...)方法来实现;该方法的第一参数指定了变换的符号名称(我们倾向于保持与对象名称相同,但可以是任何名称),第二个参数是一个长字符串,以<column_name>: <column_type>的形式指定模式,用逗号分隔:

schema_example = bl.symbol('schema_exampl', 
                           '{id: int, name: string}')

现在,您可以使用schema_example对象并指定一些变换。然而,由于我们已经有了一个现有的traffic数据集,我们可以通过使用traffic.dshape并指定我们的变换来重用该模式:

traffic_s = bl.symbol('traffic', traffic.dshape)
traffic_2013 = traffic_s[traffic_s['Stop_year'] == 2013][
    ['Stop_year', 'Arrest_Type','Color', 'Charge']
]

为了展示这是如何工作的,让我们将原始数据集读入 pandas 的DataFrame

traffic_pd = pd.read_csv('../Data/TrafficViolations.csv')

一旦读取,我们直接将数据集传递给traffic_2013对象,并使用 Blaze 的.compute(...)方法进行计算;该方法的第一参数指定了变换对象(我们的对象是traffic_2013),第二个参数是要对变换执行的数据:

bl.compute(traffic_2013, traffic_pd).head(2)

以下是前一个代码片段的输出:

符号化变换

您也可以传递一个列表的列表或一个 NumPy 数组的列表。在这里,我们使用 DataFrame 的.values属性来访问构成 DataFrame 的底层 NumPy 数组列表:

bl.compute(traffic_2013, traffic_pd.values)[0:2]

此代码将产生我们预期的精确结果:

符号化变换

列操作

Blaze 允许对数值列进行简单的数学运算。数据集中引用的所有交通违规行为都发生在 2013 年至 2016 年之间。您可以通过使用.distinct()方法获取Stop_year列的所有不同值来检查这一点。.sort()方法按升序排序结果:

traffic['Stop_year'].distinct().sort()

上述代码生成了以下输出表:

列操作

对于 pandas,等效的语法如下:

traffic['Stop_year'].unique().sort()

对于 SQL,请使用以下代码:

SELECT DISTINCT Stop_year
FROM traffic

您也可以对列进行一些数学变换/算术运算。由于所有交通违规行为都发生在 2000 年之后,我们可以从Stop_year列中减去2000,而不会丢失任何精度:

traffic['Stop_year'].head(2) - 2000

这是您应该得到的结果:

列操作

使用 pandas DataFrame可以通过相同的语法达到相同的效果(假设traffic是 pandas DataFrame类型)。对于 SQL,等效的代码如下:

SELECT Stop_year - 2000 AS Stop_year
FROM traffic

然而,如果你想要进行一些更复杂的数学运算(例如,logpow),那么你首先需要使用 Blaze 提供的(在后台,它将你的命令转换为 NumPy、math 或 pandas 的合适方法)。

例如,如果你想要对Stop_year进行对数转换,你需要使用以下代码:

bl.log(traffic['Stop_year']).head(2)

这将产生以下输出:

列操作

减少数据

一些减少方法也是可用的,例如.mean()(计算平均值)、.std(计算标准差)或.max()(从列表中返回最大值)。执行以下代码:

traffic['Stop_year'].max()

它将返回以下输出:

减少数据

如果你有一个 pandas DataFrame,你可以使用相同的语法,而对于 SQL,可以使用以下代码完成相同操作:

SELECT MAX(Stop_year) AS Stop_year_max
FROM traffic

向你的数据集中添加更多列也非常简单。比如说,你想要计算在违规发生时汽车的年龄(以年为单位)。首先,你会从Stop_year中减去制造年份的Year

在下面的代码片段中,.transform(...)方法的第一个参数是要执行转换的 DataShape,其他参数会是转换列表。

traffic = bl.transform(traffic,
             Age_of_car = traffic.Stop_year - traffic.Year)
traffic.head(2)

注意

.transform(...)方法的源代码中,这样的列表会被表示为*args,因为你可以一次指定多个要创建的列。任何方法的*args参数可以接受任意数量的后续参数,并将其视为列表。

上述代码产生以下表格:

减少数据

在 pandas 中,可以通过以下代码实现等效操作:

traffic['Age_of_car'] = traffic.apply(
    lambda row: row.Stop_year - row.Year,
    axis = 1
)

对于 SQL,你可以使用以下代码:

SELECT *
    , Stop_year - Year AS Age_of_car
FROM traffic

如果你想要计算涉及致命交通事故的汽车的平均年龄并计算发生次数,你可以使用.by(...)操作执行group by操作:

bl.by(traffic['Fatal'], 
      Fatal_AvgAge=traffic.Age_of_car.mean(),
      Fatal_Count =traffic.Age_of_car.count()
)

.by(...)的第一个参数指定了 DataShape 中要执行聚合的列,后面跟着一系列我们想要得到的聚合。在这个例子中,我们选择了Age_of_car列,并计算了每个'Fatal'列值的平均值和行数。

前面的脚本生成了以下聚合结果:

减少数据

对于 pandas,等效的代码如下:

traffic\
    .groupby('Fatal')['Age_of_car']\
    .agg({
        'Fatal_AvgAge': np.mean,
        'Fatal_Count':  np.count_nonzero
    })

对于 SQL,代码如下:

SELECT Fatal
    , AVG(Age_of_car)   AS Fatal_AvgAge
    , COUNT(Age_of_car) AS Fatal_Count
FROM traffic
GROUP BY Fatal

连接

连接两个DataShapes同样简单。为了展示如何进行这一操作,尽管可以通过不同的方式得到相同的结果,我们首先通过违规类型(violation对象)和涉及安全带的违规(belts对象)选择所有交通违规:

violation = traffic[
    ['Stop_month','Stop_day','Stop_year',
     'Stop_hr','Stop_min','Stop_sec','Violation_Type']]
belts = traffic[
    ['Stop_month','Stop_day','Stop_year',
     'Stop_hr','Stop_min','Stop_sec','Belts']]

现在,我们将两个对象在六个日期和时间列上连接起来。

注意

如果我们只是简单地一次性选择了两个列:Violation_typeBelts,同样可以达到相同的效果。然而,这个例子是为了展示 .join(...) 方法的机制,所以请耐心等待。

.join(...) 方法的第一个参数是我们想要连接的第一个 DataShape,第二个参数是第二个 DataShape,而第三个参数可以是单个列或列的列表,用于执行连接操作:

violation_belts = bl.join(violation, belts, 
      ['Stop_month','Stop_day','Stop_year',
       'Stop_hr','Stop_min','Stop_sec'])

一旦我们有了完整的数据集,让我们检查有多少交通违规涉及安全带,以及司机受到了什么样的惩罚:

bl.by(violation_belts[['Violation_Type', 'Belts']],
      Violation_count=violation_belts.Belts.count()
).sort('Violation_count', ascending=False)

这是前面脚本的输出:

Joins

使用以下代码在 pandas 中可以实现相同的效果:

violation.merge(belts, 
    on=['Stop_month','Stop_day','Stop_year',
        'Stop_hr','Stop_min','Stop_sec']) \
    .groupby(['Violation_type','Belts']) \
    .agg({
        'Violation_count':  np.count_nonzero
    }) \
    .sort('Violation_count', ascending=False)

使用 SQL,您将使用以下片段:

SELECT innerQuery.*
FROM (
    SELECT a.Violation_type
        , b.Belts
        , COUNT() AS Violation_count
    FROM violation AS a
    INNER JOIN belts AS b
        ON      a.Stop_month = b.Stop_month
            AND a.Stop_day = b.Stop_day
            AND a.Stop_year = b.Stop_year
            AND a.Stop_hr = b.Stop_hr
            AND a.Stop_min = b.Stop_min
            AND a.Stop_sec = b.Stop_sec
    GROUP BY Violation_type
        , Belts
) AS innerQuery
ORDER BY Violation_count DESC

概述

本章中介绍的概念只是使用 Blaze 的道路起点。还有许多其他的使用方式和可以连接的数据源。将其视为构建你对多语言持久性的理解的基础。

然而,请注意,如今本章中解释的大多数概念都可以在 Spark 中原生获得,因为您可以直接在 Spark 中使用 SQLAlchemy,这使得与各种数据源一起工作变得容易。尽管需要投入学习 SQLAlchemy API 的初始成本,但这样做的好处是返回的数据将存储在 Spark DataFrame 中,您将能够访问 PySpark 提供的一切。这绝对不意味着您永远不应该使用 Blaze:选择,一如既往,是您的。

在下一章中,您将学习关于流式处理以及如何使用 Spark 进行流式处理。流式处理在当今已经成为一个越来越重要的主题,因为,每天(截至 2016 年为真),世界大约产生约 2.5 兆字节的数据(来源:www.northeastern.edu/levelblog/2016/05/13/how-much-data-produced-every-day/),这些数据需要被摄取、处理并赋予意义。

第十章:结构化流

本章将介绍 Spark Streaming 背后的概念以及它如何演变成结构化流(Structured Streaming)。结构化流的一个重要方面是它利用 Spark DataFrames。这种范式转变将使 Python 开发者更容易开始使用 Spark Streaming。

在本章中,你将学习:

  • 什么是 Spark Streaming?

  • 我们为什么需要 Spark Streaming?

  • Spark Streaming 应用程序数据流是什么?

  • 使用 DStream 的简单流应用程序

  • Spark Streaming 全球聚合的快速入门

  • 介绍结构化流

注意,本章的前几节将使用 Scala 代码示例,因为这是大多数 Spark Streaming 代码的编写方式。当我们开始关注结构化流时,我们将使用 Python 示例。

什么是 Spark Streaming?

在其核心,Spark Streaming 是一个可扩展、容错的流处理系统,它采用了 RDD 批处理范式(即批量处理数据)并加快了处理速度。虽然这是一个轻微的简化,但基本上 Spark Streaming 在微批或批处理间隔(从 500ms 到更大的间隔窗口)中运行。

如以下图所示,Spark Streaming 接收一个输入数据流,并将其内部分解成多个更小的批量(其大小基于 批处理间隔)。Spark 引擎处理这些输入数据批量,生成处理后的数据批量结果集。

什么是 Spark Streaming?

来源:Apache Spark Streaming 编程指南,请参阅:spark.apache.org/docs/latest/streaming-programming-guide.html

Spark Streaming 的关键抽象是离散流(Discretized Stream,DStream),它代表了之前提到的构成数据流的小批量。DStreams 建立在 RDD 之上,允许 Spark 开发者在 RDD 和批处理相同的上下文中工作,现在只是将其应用于他们的流处理问题。此外,一个重要的方面是,因为你使用 Apache Spark,Spark Streaming 与 MLlib、SQL、DataFrames 和 GraphX 集成。

以下图表示 Spark Streaming 的基本组件:

什么是 Spark Streaming?

来源:Apache Spark Streaming 编程指南,请参阅:spark.apache.org/docs/latest/streaming-programming-guide.html

Spark Streaming 是一个高级 API,为有状态操作提供容错精确一次语义。Spark Streaming 内置接收器,可以处理许多来源,其中最常见的是 Apache Kafka、Flume、HDFS/S3、Kinesis 和 Twitter。例如,Kafka 和 Spark Streaming 之间最常用的集成在 Spark Streaming + Kafka 集成指南中有很好的文档记录,该指南可在spark.apache.org/docs/latest/streaming-kafka-integration.html找到。

此外,您还可以创建自己的自定义接收器,例如 Meetup 接收器(github.com/actions/meetup-stream/blob/master/src/main/scala/receiver/MeetupReceiver.scala),它允许您使用 Spark Streaming 读取 Meetup 流式 API(www.meetup.com/meetup_api/docs/stream/2/rsvps/)。

注意

观看 Meetup 接收器在实际操作中的表现

如果您对查看 Spark Streaming Meetup 接收器在实际操作中的表现感兴趣,您可以参考以下 Databricks 笔记本:github.com/dennyglee/databricks/tree/master/notebooks/Users/denny%40databricks.com/content/Streaming%20Meetup%20RSVPs,这些笔记本使用了之前提到的 Meetup 接收器。

以下是在左侧窗口中查看 Spark UI(流式处理选项卡)时,笔记本的实际截图。

什么是 Spark Streaming?什么是 Spark Streaming?

您将能够使用 Spark Streaming 接收来自全国(或世界)的 Meetup RSVP,并通过州(或国家)获得近乎实时的 Meetup RSVP 摘要。注意,这些笔记本目前是用Scala编写的。

我们为什么需要 Spark Streaming?

如 Tathagata Das 所述——Apache Spark 项目的提交者和项目管理委员会(PMC)成员,以及 Spark Streaming 的首席开发者——在 Datanami 文章《Spark Streaming:它是什么以及谁在使用它》(www.datanami.com/2015/11/30/spark-streaming-what-is-it-and-whos-using-it/)中提到,对于流式处理存在业务需求。随着在线交易、社交媒体、传感器和设备的普及,公司正在以更快的速度生成和处理更多数据。

能够在规模和实时性上开发可操作的见解,为这些企业提供竞争优势。无论您是检测欺诈交易、提供传感器异常的实时检测,还是对下一个病毒式推文做出反应,流式分析正在成为数据科学家和数据工程师工具箱中越来越重要的组成部分。

Spark Streaming 之所以被迅速采用,是因为 Apache Spark 将所有这些不同的数据处理范式(通过 ML 和 MLlib 进行机器学习、Spark SQL 和流式处理)统一在同一个框架中。因此,你可以从训练机器学习模型(ML 或 MLlib)开始,到使用这些模型评分数据(流式处理),再到使用你喜欢的 BI 工具进行数据分析(SQL)——所有这些都在同一个框架内完成。包括 Uber、Netflix 和 Pinterest 在内的公司经常展示他们的 Spark Streaming 应用案例:

目前,围绕 Spark Streaming 有四个广泛的应用场景:

  • 流式 ETL:在将数据推送到下游之前,数据会持续进行清洗和聚合。这通常是为了减少最终数据存储中需要存储的数据量。

  • 触发器:实时检测行为或异常事件会触发立即和下游操作。例如,一个位于检测器或信标附近的设备将触发一个警报。

  • 数据丰富:将实时数据与其他数据集合并,以进行更深入的分析。例如,将实时天气信息与航班信息结合,以构建更好的旅行警报。

  • 复杂会话和持续学习:与实时流相关联的多组事件持续进行分析和/或更新机器学习模型。例如,与在线游戏相关的用户活动流,使我们能够更好地细分用户。

Spark Streaming 应用数据流是什么?

下图提供了 Spark 驱动程序、工作节点、流式数据源和目标之间的数据流:

Spark Streaming 应用数据流是什么?

所有这一切都始于 Spark Streaming 上下文,如前图所示ssc.start()

  1. 当 Spark Streaming 上下文启动时,驱动程序将在 executors(即 Spark 工作节点)上执行一个长时间运行的任务。

  2. 执行器上的接收器(此图中的Executor 1)从流式源接收数据流。随着数据流的到来,接收器将流分成块,并将这些块保存在内存中。

  3. 这些块也被复制到另一个执行器,以避免数据丢失。

  4. 块 ID 信息被传输到驱动程序上的块管理主节点

  5. 对于在 Spark Streaming 上下文中配置的每个批次间隔(通常这是每秒一次),驱动程序将启动 Spark 任务来处理这些块。然后,这些块被持久化到任意数量的目标数据存储中,包括云存储(例如,S3、WASB 等)、关系型数据存储(例如,MySQL、PostgreSQL 等)和 NoSQL 存储。

对于流式应用程序来说,有很多动态部分需要不断优化和配置。Spark Streaming 的大部分文档在 Scala 中更为完整,因此,当您使用 Python API 时,您可能有时需要参考 Scala 版本的文档。如果这种情况发生在您身上,请提交一个错误报告,并且/或者如果您有一个建议的修复方案,请填写一个 PR (issues.apache.org/jira/browse/spark/)。

关于这个主题的更深入探讨,请参阅:

  1. Spark 1.6 流式编程指南: spark.apache.org/docs/1.6.0/streaming-programming-guide.html

  2. 《达沙塔·达斯深入浅出 Spark Streaming(Spark Meetup 2013-06-17)》: www.slideshare.net/spark-project/deep-divewithsparkstreaming-tathagatadassparkmeetup20130617

使用 DStreams 的简单流式应用程序

让我们使用 Python 中的 Spark Streaming 创建一个简单的词频统计示例。对于这个示例,我们将使用 DStream——组成数据流的小批次的离散流。本书本节使用的示例可以在以下位置找到:github.com/drabastomek/learningPySpark/blob/master/Chapter10/streaming_word_count.py

这个词频统计示例将使用 Linux/Unix 的nc命令——这是一个简单的工具,可以在网络连接中读取和写入数据。我们将使用两个不同的 bash 终端,一个使用nc命令将单词发送到我们计算机的本地端口(9999),另一个终端将运行 Spark Streaming 以接收这些单词并计数。我们脚本的初始命令集在此处记录:

1\. # Create a local SparkContext and Streaming Contexts
2\. from pyspark import SparkContext
3\. from pyspark.streaming import StreamingContext
4\. 
5\. # Create sc with two working threads 
6\. sc = SparkContext("local[2]", "NetworkWordCount")
7\. 
8\. # Create local StreamingContextwith batch interval of 1 second
9\. ssc = StreamingContext(sc, 1)
10\. 
11\. # Create DStream that connects to localhost:9999
12\. lines = ssc.socketTextStream("localhost", 9999)

这里有一些关于前面命令的重要说明:

  1. 第 9 行的StreamingContext是 Spark Streaming 的入口点

  2. 第 9 行...(sc, 1)中的1批次间隔;在这种情况下,我们每秒运行微批次。

  3. 第 12 行的 lines 是通过 ssc.socketTextStream 提取的数据流的 DStream

  4. 如描述中所述,ssc.socketTextStream 是 Spark Streaming 方法,用于审查特定套接字的文本流;在这种情况下,你的本地计算机在套接字 9999 上。

下几行代码(如注释中所述),将行 DStream 分割成单词,然后使用 RDDs,对每个数据批次中的每个单词进行计数,并将此信息打印到控制台(第 9 行):

1\. # Split lines into words
2\. words = lines.flatMap(lambda line: line.split(" "))
3\. 
4\. # Count each word in each batch
5\. pairs = words.map(lambda word: (word, 1))
6\. wordCounts = pairs.reduceByKey(lambda x, y: x + y)
7\. 
8\. # Print the first ten elements of each RDD in this DStream 
9\. wordCounts.pprint()

代码的最后一行启动了 Spark Streaming (ssc.start()),然后等待一个终止命令来停止运行(例如,<Ctrl><C>)。如果没有发送终止命令,那么 Spark Streaming 程序将继续运行。

# Start the computation
ssc.start()             

# Wait for the computation to terminate
ssc.awaitTermination()  

现在你有了脚本,如之前所述,打开两个终端窗口——一个用于你的 nc 命令,另一个用于 Spark Streaming 程序。要启动 nc 命令,在你的一个终端中输入:

nc –lk 9999

从现在开始,你在这个终端中输入的所有内容都将被传输到端口 9999,如下面的截图所示:

使用 DStreams 的简单流应用程序

在这个例子(如之前所述)中,我输入了三次单词 green 和五次 blue。从另一个终端屏幕,让我们运行你刚刚创建的 Python 流脚本。在这个例子中,我将脚本命名为 streaming_word_count.py 并使用命令 ../bin/spark-submit streaming_word_count.py localhost 9999

这个命令将运行 streaming_word_count.py 脚本,读取你的本地计算机(即 localhost)端口 9999 以接收发送到该套接字的所有单词。由于你已经在第一个屏幕上向该端口发送了信息,脚本启动后不久,Spark Streaming 程序将读取发送到端口 9999 的单词并执行单词计数,如下面的截图所示:

使用 DStreams 的简单流应用程序

streaming_word_count.py 脚本将继续读取并打印任何新的信息到控制台。回到我们第一个终端(使用 nc 命令),我们现在可以输入下一组单词,如下面的截图所示:

使用 DStreams 的简单流应用程序

查看第二个终端中的流脚本,你会注意到这个脚本每秒继续运行(即配置的 批处理间隔),你会在几秒钟后注意到计算出的 gohawks 单词计数:

使用 DStreams 的简单流应用程序

使用这个相对简单的脚本,现在你可以看到 Spark Streaming 使用 Python 的实际应用。但是,如果你继续在 nc 终端中输入单词,你会注意到这些信息没有被聚合。例如,如果我们继续在 nc 终端中写入绿色(如下所示):

使用 DStreams 的简单流应用程序

Spark Streaming 终端将报告当前数据快照;即,这里提到的两个额外的 green 值:

使用 DStreams 的简单流式应用程序

没有发生的是全局聚合的概念,其中我们会保留该信息的 状态。这意味着,而不是报告 2 个新的 green,我们可以让 Spark Streaming 给我们提供绿色的总体计数,例如,7 个 green,5 个 blue,和 1 个 gohawks。我们将在下一节以 UpdateStateByKey / mapWithState 的形式讨论全局聚合。

小贴士

对于其他好的 PySpark 流式处理示例,请查看:

网络单词计数(在 Apache Spark GitHub 仓库中):github.com/apache/spark/blob/master/examples/src/main/python/streaming/network_wordcount.py

Python 流式处理示例:github.com/apache/spark/tree/master/examples/src/main/python/streaming

S3 FileStream Wordcount(Databricks 笔记本):docs.cloud.databricks.com/docs/latest/databricks_guide/index.html#07%20Spark%20Streaming/06%20FileStream%20Word%20Count%20-%20Python.html

全球聚合的快速入门

如前节所述,到目前为止,我们的脚本已经执行了点时间流式单词计数。以下图表示了 lines DStream 及其微批处理,正如我们在前一节中脚本执行的那样:

全球聚合的快速入门

在 1 秒的标记处,我们的 Python Spark Streaming 脚本返回了 {(blue, 5), (green, 3)} 的值,在 2 秒的标记处返回了 {(gohawks, 1)},在 4 秒的标记处返回了 {(green, 2)}。但如果你想要特定时间窗口内的聚合单词计数呢?

下图展示了我们计算状态聚合的过程:

全球聚合的快速入门

在这种情况下,我们有一个 0-5 秒的时间窗口。注意,在我们的脚本中,我们没有得到指定的时间窗口:每秒钟,我们计算单词的累积总和。因此,在 2 秒的标记处,输出不仅仅是 1 秒标记处的 greenblue,还包括 2 秒标记处的 gohawks{(blue, 5), (green, 3), (gohawks, 1)}。在 4 秒的标记处,额外的 2 个 green 使总数达到 {(blue, 5), (green, 5), (gohawks, 1)}

对于那些经常与关系型数据库工作的人来说,这似乎只是一个 GROUP BY, SUM() 语句。然而,在流式分析的情况下,持久化数据以运行 GROUP BY, SUM() 语句的时间比 批处理间隔(例如,1 秒)要长。这意味着我们将会不断落后并试图赶上数据流。

例如,如果你要运行 github.com/dennyglee/databricks/blob/master/notebooks/Users/denny%40databricks.com/content/Streaming%20Meetup%20RSVPs/1.%20Streaming%20and%20DataFrames.scala1. Streaming and DataFrames.scala Databricks 笔记本,并且查看 Spark UI 中的流式作业,你会得到以下类似图示:

关于全局聚合的快速入门

注意在图中,调度延迟总延迟的数字正在迅速增加(例如,平均总延迟为54 秒 254 毫秒,实际总延迟大于 2 分钟)并且远远超出 1 秒的 批处理间隔 阈值。我们看到这种延迟的原因是因为,在那个笔记本的流式代码内部,我们也运行了以下代码:

// Populate `meetup_stream` table
sqlContext.sql("insert into meetup_stream select * from meetup_stream_json")

即,插入任何新的数据块(即,1 秒 RDD 微批),将它们转换为 DataFrame(meetup_stream_json 表),并将数据插入到持久表中(meetup_stream 表)。以这种方式持久化数据导致了缓慢的流式性能和不断增长的调度延迟。为了通过 流式分析 解决这个问题,这就是通过 UpdateStateByKey(Spark 1.5 及之前)或 mapWithState(Spark 1.6 及以后)创建全局聚合的地方。

小贴士

关于 Spark Streaming 的可视化信息,请花时间查看 理解 Apache Spark Streaming 应用程序的新可视化databricks.com/blog/2015/07/08/new-visualizations-for-understanding-apache-spark-streaming-applications.html

了解这一点后,让我们重新编写原始的 streaming_word_count.py,现在我们有一个名为 stateful_streaming_word_count.py有状态 版本;你可以在这个脚本的全版本在 github.com/drabastomek/learningPySpark/blob/master/Chapter10/stateful_streaming_word_count.py

我们脚本的初始命令集合如下所示:

 1\. # Create a local SparkContext and Streaming Contexts
 2\. from pyspark import SparkContext
 3\. from pyspark.streaming import StreamingContext
 4\. 
 5\. # Create sc with two working threads 
 6\. sc = SparkContext("local[2]", "StatefulNetworkWordCount")
 7\. 
 8\. # Create local StreamingContext with batch interval of 1 sec
 9\. ssc = StreamingContext(sc, 1)
10\. 
11\. # Create checkpoint for local StreamingContext
12\. ssc.checkpoint("checkpoint")
13\. 
14\. # Define updateFunc: sum of the (key, value) pairs
15\. def updateFunc(new_values, last_sum):
16\.   return sum(new_values) + (last_sum or 0)
17\. 
18\. # Create DStream that connects to localhost:9999
19\. lines = ssc.socketTextStream("localhost", 9999)

如果你还记得 streaming_word_count.py,主要的不同之处从第 11 行开始:

  • 第 12 行的ssc.checkpoint("checkpoint")配置了一个 Spark Streaming 检查点。为了确保 Spark Streaming 由于持续运行而具有容错性,它需要将足够的信息检查点到容错存储中,以便在发生故障时恢复。注意,我们不会深入探讨这个概念(尽管在下面的小贴士部分有更多信息),因为许多这些配置都将通过 Structured Streaming 抽象化。

  • 第 15 行的updateFunc告诉程序通过UpdateStateByKey更新应用程序的状态(在代码的后面部分)。在这种情况下,它返回前一个值(last_sum)和新的值的总和(sum(new_values) + (last_sum or 0))。

  • 在第 19 行,我们有与上一个脚本相同的ssc.socketTextStream

    小贴士

    关于 Spark Streaming 检查点的更多信息,以下是一些好的参考资料:

    Spark Streaming 编程指南 > 检查点: spark.apache.org/docs/1.6.0/streaming-programming-guide.html#checkpointing

    探索 Apache Spark 中的有状态流: asyncified.io/2016/07/31/exploring-stateful-streaming-with-apache-spark/

代码的最后一部分如下:

 1\. # Calculate running counts
 2\. running_counts = lines.flatMap(lambda line: line.split(" "))\
 3\.           .map(lambda word: (word, 1))\
 4\.           .updateStateByKey(updateFunc)
 5\. 
 6\. # Print the first ten elements of each RDD generated in this 
 7\. # stateful DStream to the console
 8\. running_counts.pprint()
 9\. 
10\. # Start the computation
11\. ssc.start()             
12\. 
13\. # Wait for the computation to terminate
14\. ssc.awaitTermination()  

当第 10-14 行的代码与上一个脚本相同,但区别在于我们现在有一个running_counts变量,它将数据拆分以获取单词,并在每个批次中运行一个映射函数来计算每个单词(在之前的脚本中这是wordspairs变量)。

主要区别在于使用updateStateByKey方法,该方法将执行之前提到的updateFunc,该函数执行求和操作。updateStateByKey是 Spark Streaming 执行对数据流进行计算并高效更新每个键的状态的方法。需要注意的是,你通常会在 Spark 1.5 及更早版本中使用updateStateByKey;这些有状态的全局聚合的性能与状态的大小成正比。从 Spark 1.6 版本开始,你应该使用mapWithState,因为其性能与批次的大小成正比。

小贴士

注意,与updateStateByKey相比,mapWithState通常涉及更多的代码,因此示例是使用updateStateByKey编写的。

关于有状态 Spark Streaming 的更多信息,包括mapWithState的使用,请参阅:

有状态网络词频 Python 示例: github.com/apache/spark/blob/master/examples/src/main/python/streaming/stateful_network_wordcount.py

使用 mapWithState 在 Scala 中进行全局聚合:docs.cloud.databricks.com/docs/latest/databricks_guide/index.html#07%20Spark%20Streaming/12%20Global%20Aggregations%20-%20mapWithState.html

使用 mapWithState 在 Scala 中计算单词数量:docs.cloud.databricks.com/docs/spark/1.6/examples/Streaming%20mapWithState.html

在 Apache Spark Streaming 中实现更快的有状态流处理:databricks.com/blog/2016/02/01/faster-stateful-stream-processing-in-apache-spark-streaming.html

介绍结构化流

在 Spark 2.0 中,Apache Spark 社区正在通过引入 结构化流 的概念来简化流处理,该概念将流的概念与 Datasets/DataFrames 相结合(如下图中所示):

介绍结构化流

如前几章关于 DataFrames 所述,在 Spark SQL 引擎(和 Catalyst 优化器)中执行 SQL 和/或 DataFrame 查询的过程是围绕构建逻辑计划、构建多个物理计划、引擎根据其成本优化器选择正确的物理计划,然后生成代码(即 代码生成)以高效地提供结果。结构化流 引入的概念是 增量执行计划。当处理数据块时,结构化流会对其接收到的每一组新数据块重复应用执行计划。通过这种方式运行,引擎可以利用 Spark DataFrames/Datasets 中的优化,并将它们应用于传入的数据流。这将更容易集成 Spark 的其他 DataFrame 优化组件,包括 ML 流水线、GraphFrames、TensorFrames 以及许多其他组件。

使用结构化流也将简化你的代码。例如,以下是一个读取数据流从 S3 并将其保存到 MySQL 数据库的 批量聚合 的伪代码示例:

logs = spark.read.json('s3://logs')

logs.groupBy(logs.UserId).agg(sum(logs.Duration))
.write.jdbc('jdbc:mysql//...')

以下是一个 连续聚合 的伪代码示例:

logs = spark.readStream.json('s3://logs').load()

sq = logs.groupBy(logs.UserId).agg(sum(logs.Duration))
.writeStream.format('json').start()

创建 sq 变量的原因是它允许你检查你的结构化流作业的状态并终止它,如下所示:

# Will return true if the `sq` stream is active
sq.isActive

# Will terminate the `sq` stream
sq.stop()

让我们将使用 updateStateByKey 的有状态流式单词计数脚本转换为结构化流式单词计数脚本;你可以从以下链接获取完整的 structured_streaming_word_count.py 脚本:github.com/drabastomek/learningPySpark/blob/master/Chapter10/structured_streaming_word_count.py

与之前的脚本相反,我们现在使用更熟悉的 DataFrame 代码,如以下所示:

# Import the necessary classes and create a local SparkSession
from pyspark.sql import SparkSession
from pyspark.sql.functions import explode
from pyspark.sql.functions import split

spark = SparkSession \
   .builder \
   .appName("StructuredNetworkWordCount") \
   .getOrCreate()

脚本的最初几行导入必要的类并建立当前的 SparkSession。但是,与之前的流脚本相反,在脚本的下一行中注意到的这里,你不需要建立流上下文,因为这已经包含在 SparkSession 中:

 1\. # Create DataFrame representing the stream of input lines
 2\. # from connection to localhost:9999
 3\.  lines = spark\
 4\.    .readStream\
 5\.    .format('socket')\
 6\.    .option('host', 'localhost')\
 7\.   .option('port', 9999)\
 8\.   .load()
 9.
10\. # Split the lines into words
11\. words = lines.select(
12\.   explode(
13\.          split(lines.value, ' ')
14\.   ).alias('word')
15\.   )
16.
17\. # Generate running word count
18\. wordCounts = words.groupBy('word').count()

相反,代码中的流部分是通过在第 4 行调用 readStream 来启动的。

  • 第 3-8 行启动从端口 9999 的数据流 读取,就像前两个脚本一样

  • 我们不需要运行 RDD 的 flatMapmapreduceByKey 函数来分割读取的行到单词并计算每个批次中的每个单词,我们可以使用 PySpark SQL 函数 explodesplit,如第 10-15 行所示

  • 我们不需要运行 updateStateByKey 或创建 updateFunc,就像状态流词频脚本中所做的那样,我们可以使用熟悉的 DataFrame groupBy 语句和 count() 来生成运行词频,如第 17-18 行所示

要将此数据输出到控制台,我们将使用 writeStream,如以下所示:

 1\. # Start running the query that prints the 
 2\. # running counts to the console
 3\. query = wordCounts\
 4\.     .writeStream\
 5\.     .outputMode('complete')\
 6\.     .format('console')\
 7\.     .start()
 8\. 
 9\. # Await Spark Streaming termination
10\. query.awaitTermination()

我们不是使用 pprint(),而是明确调用 writeStream 来写入流,并定义格式和输出模式。虽然写起来稍微长一些,但这些方法和属性与 DataFrame 调用的语法相似,你只需要更改 outputModeformat 属性来将其保存到数据库、文件系统、控制台等。最后,正如第 10 行所注明的,我们将运行 awaitTermination 来等待取消此流作业。

让我们回到第一个终端并运行我们的 nc 作业:

$ nc –lk 9999
green green green blue blue blue blue blue
gohawks
green green

检查以下输出。正如你所见,你得到了具有状态流的优势,但使用了更熟悉的 DataFrame API:

介绍结构化流

摘要

重要的是要注意,结构化流目前(在撰写本文时)尚未准备好投入生产。然而,它却是 Spark 中的一个范式转变,有望使数据科学家和数据工程师更容易构建 持续应用程序。虽然在前面的章节中没有明确指出,但在处理流应用程序时,你将需要为许多潜在问题进行设计,例如迟到事件、部分输出、失败时的状态恢复、分布式读写等。使用结构化流,许多这些问题将被抽象化,以便你更容易构建 持续应用程序

我们鼓励你尝试 Spark 结构化流,这样你将能够轻松构建随着结构化流成熟的应用程序。正如 Reynold Xin 在他的 Spark Summit 2016 East 演讲 Spark 的实时未来 中指出(www.slideshare.net/rxin/the-future-of-realtime-in-spark):

"执行流式分析最简单的方法就是无需对流进行推理。"

更多信息,以下是一些额外的结构化流资源:

在下一章中,我们将向您展示如何模块化并打包您的 PySpark 应用程序,并程序化地提交它以执行。

第十一章. 打包 Spark 应用程序

到目前为止,我们一直在使用一种非常方便的方式来在 Spark 中开发代码 - Jupyter 笔记本。当您想开发一个概念验证并记录您所做的工作时,这种方法非常出色。

然而,如果您需要安排作业,Jupyter 笔记本将无法工作,因此它每小时运行一次。此外,打包您的应用程序相当困难,因为很难将脚本分割成具有良好定义的 API 的逻辑块 - 所有的内容都位于单个笔记本中。

在本章中,我们将学习如何以模块化的形式编写您的脚本,并编程提交作业到 Spark。

在开始之前,您可能想查看 Bonus Chapter 2, Free Spark Cloud Offering,其中我们提供了如何订阅和使用 Databricks 的社区版或 Microsoft 的 HDInsight Spark 提供的说明;如何做到这一点的说明可以在此处找到:www.packtpub.com/sites/default/files/downloads/FreeSparkCloudOffering.pdf

在本章中,您将学习:

  • spark-submit 命令是什么

  • 如何以编程方式打包和部署您的应用程序

  • 如何模块化您的 Python 代码并将其与 PySpark 脚本一起提交

spark-submit 命令

提交作业到 Spark(无论是本地还是集群)的入口点是 spark-submit 脚本。然而,该脚本不仅允许您提交作业(尽管这是其主要目的),还可以终止作业或检查其状态。

注意

在内部,spark-submit 命令将调用 spark-class 脚本,该脚本反过来启动一个启动器 Java 应用程序。对于感兴趣的人来说,可以查看 Spark 的 GitHub 仓库:github.com/apache/spark/blob/master/bin/sparksubmit

spark-submit 命令为在多种 Spark 支持的集群管理器(如 Mesos 或 Yarn)上部署应用程序提供了一个统一的 API,从而让您无需分别对每个应用程序进行配置。

在一般层面上,语法如下所示:

spark-submit [options] <python file> [app arguments]

我们很快就会查看所有选项的列表。app arguments 是您想要传递给应用程序的参数。

注意

您可以使用 sys.argv(在 import sys 之后)自行解析命令行参数,或者可以使用 Python 的 argparse 模块。

命令行参数

当使用 spark-submit 时,您可以传递大量针对 Spark 引擎的参数。

注意

在以下内容中,我们将仅介绍针对 Python 的特定参数(因为 spark-submit 也可以用于提交用 Scala 或 Java 编写的应用程序,并打包为 .jar 文件)。

我们将逐一介绍参数,以便您对从命令行可以执行的操作有一个良好的概述:

  • --master:用于设置主(头)节点 URL 的参数。允许的语法是:

    • local: 用于在你的本地机器上执行你的代码。如果你传递local,Spark 将随后在单个线程中运行(不利用任何并行性)。在多核机器上,你可以指定 Spark 要使用的确切核心数,通过指定local[n],其中n是要使用的核心数,或者使用local[*]运行 Spark,使其以机器上的核心数创建尽可能多的线程。

    • spark://host:port: 这是一个 Spark 独立集群的 URL 和端口号(不运行任何作业调度器,如 Mesos 或 Yarn)。

    • mesos://host:port: 这是一个部署在 Mesos 上的 Spark 集群的 URL 和端口号。

    • yarn: 用于从运行 Yarn 作为工作负载均衡器的头节点提交作业。

  • --deploy-mode: 参数允许你决定是否在本地(使用client)或集群中的某个工作机器上(使用cluster选项)启动 Spark 驱动程序进程。此参数的默认值为client。以下是 Spark 文档的摘录,它更具体地解释了差异(来源:bit.ly/2hTtDVE):

    一种常见的部署策略是从与你的工作机器物理上位于同一位置的门控机器上的屏幕会话提交你的应用程序(例如,独立 EC2 集群中的主节点)。在这种配置中,客户端模式是合适的。在客户端模式下,驱动程序直接在 spark-submit 过程中启动,该过程作为集群的客户端。应用程序的输入和输出连接到控制台。因此,这种模式特别适合涉及 REPL(例如 Spark shell)的应用程序。

    或者,如果你的应用程序是从远离工作机器的机器(例如,在你的笔记本电脑上本地)提交的,那么通常使用集群模式以最小化驱动程序和执行器之间的网络延迟。目前,独立模式不支持 Python 应用程序的集群模式。

  • --name: 你的应用程序的名称。请注意,如果你在创建SparkSession时以编程方式指定了应用程序的名称(我们将在下一节中介绍),则命令行参数将覆盖该参数。我们将在讨论--conf参数时简要解释参数的优先级。

  • --py-files: 要包含的.py.egg.zip文件的逗号分隔列表,用于 Python 应用程序。这些文件将被发送到每个执行器以供使用。在本章的后面部分,我们将向你展示如何将你的代码打包成模块。

  • --files: 该命令给出一个以逗号分隔的文件列表,这些文件也将被发送到每个执行器以供使用。

  • --conf: 参数允许你从命令行动态更改应用程序的配置。语法是<Spark 属性>=<属性值>。例如,你可以传递--conf spark.local.dir=/home/SparkTemp/--conf spark.app.name=learningPySpark;后者相当于之前解释的提交--name属性。

    注意

    Spark 从三个地方使用配置参数:在创建SparkContext时,你在应用程序中指定的SparkConf参数具有最高优先级,然后是任何从命令行传递给spark-submit脚本的参数,最后是conf/spark-defaults.conf文件中指定的任何参数。

  • --properties-file:包含配置的文件。它应该具有与conf/spark-defaults.conf文件相同的属性集,因为它将被读取而不是它。

  • --driver-memory:指定为驱动程序分配多少内存的应用程序参数。允许的值具有类似于 1,000M、2G 的语法。默认值为 1,024M。

  • --executor-memory:指定为每个执行器分配多少内存的应用程序参数。默认值为 1G。

  • --help:显示帮助信息并退出。

  • --verbose:在运行应用程序时打印额外的调试信息。

  • --version:打印 Spark 的版本。

仅在 Spark 独立和cluster部署模式中,或在 Yarn 上部署的集群中,你可以使用--driver-cores来指定驱动程序的核心数(默认为 1)。在 Spark 独立或 Mesos 的cluster部署模式中,你还有机会使用以下任何一个:

  • --supervise:如果指定,当驱动程序丢失或失败时,将重新启动驱动程序。这也可以通过将--deploy-mode设置为cluster在 Yarn 中设置。

  • --kill:将根据其submission_id结束进程

  • --status:如果指定此命令,它将请求指定应用程序的状态。

在 Spark 独立和 Mesos(仅使用client部署模式)中,你也可以指定--total-executor-cores,这是一个将请求所有执行器(而不是每个执行器)指定的核心数的参数。另一方面,在 Spark 独立和 YARN 中,只有--executor-cores参数指定了每个执行器的核心数(在 YARN 模式下默认为 1,或在独立模式下为工作节点上的所有可用核心)。

此外,当提交到 YARN 集群时,你可以指定:

  • --queue:此参数指定一个队列,将作业提交到 YARN(默认为default)。

  • --num-executors:指定为作业请求多少个执行器机器的参数。如果启用了动态分配,初始执行器数量至少为指定的数量。

现在我们已经讨论了所有参数,是时候将其付诸实践了。

以编程方式部署应用程序

与 Jupyter 笔记本不同,当你使用spark-submit命令时,你需要自己准备SparkSession并配置它,以确保应用程序正常运行。

在本节中,我们将学习如何创建和配置SparkSession,以及如何使用 Spark 外部模块。

注意

如果你还没有在 Databricks 或 Microsoft(或任何 Spark 的提供者)上创建你的免费账户,不要担心——我们仍然会使用你的本地机器,因为这更容易让我们开始。然而,如果你决定将你的应用程序迁移到云端,实际上只需要在提交作业时更改--master参数。

配置你的 SparkSession

使用 Jupyter 和通过编程方式提交作业之间的主要区别在于,你必须创建你的 Spark 上下文(如果你计划使用 HiveQL,还包括 Hive),而当你使用 Jupyter 运行 Spark 时,上下文会自动为你启动。

在本节中,我们将开发一个简单的应用程序,该应用程序将使用 Uber 的公共数据,这些数据是在 2016 年 6 月的纽约地区完成的行程;我们从s3.amazonaws.com/nyc-tlc/trip+data/yellow_tripdata_2016-06.csv(注意,它是一个几乎 3GB 的文件)下载的数据集。原始数据集包含 1100 万次行程,但为了我们的示例,我们只检索了 330 万次,并且只选择了所有可用列的一个子集。

注意

转换后的数据集可以从www.tomdrabas.com/data/LearningPySpark/uber_data_nyc_2016-06_3m_partitioned.csv.zip下载。下载文件并将其解压到 GitHub 的Chapter13文件夹中。文件可能看起来很奇怪,因为它实际上是一个包含四个文件的目录,当 Spark 读取时,将形成一个数据集。

那么,让我们开始吧!

创建 SparkSession

与之前的版本相比,Spark 2.0 在创建SparkContext方面变得稍微简单一些。实际上,Spark 目前使用SparkSession来暴露高级功能,而不是显式创建SparkContext。以下是这样做的方法:

from pyspark.sql import SparkSession

spark = SparkSession \
        .builder \
        .appName('CalculatingGeoDistances') \
        .getOrCreate()

print('Session created')

前面的代码就是你需要的一切!

小贴士

如果你仍然想使用 RDD API,你仍然可以。然而,你不再需要创建一个SparkContext,因为SparkSession在底层会自动启动一个。为了获取访问权限,你可以简单地调用(借鉴前面的示例):sc = spark.SparkContext

在这个示例中,我们首先创建SparkSession对象并调用其.builder内部类。.appName(...)方法允许我们给我们的应用程序一个名字,而.getOrCreate()方法要么创建一个,要么检索一个已经创建的SparkSession。给应用程序一个有意义的名字是一个好习惯,因为它有助于(1)在集群上找到你的应用程序,并且(2)减少每个人的困惑。

注意

在底层,Spark 会话创建一个SparkContext对象。当你对SparkSession调用.stop()时,它实际上会终止内部的SparkContext

代码模块化

以这种方式构建您的代码以便以后可以重用始终是一件好事。Spark 也可以这样做 - 您可以将方法模块化,然后在以后某个时间点重用它们。这也有助于提高代码的可读性和可维护性。

在这个例子中,我们将构建一个模块,它将对我们的数据集进行一些计算:它将计算从接货点到卸货点的“直线距离”(以英里为单位)(使用 Haversine 公式),并将计算出的距离从英里转换为公里。

注意

关于 Haversine 公式的更多信息可以在这里找到:www.movable-type.co.uk/scripts/latlong.html

因此,首先,我们将构建一个模块。

模块结构

我们将我们额外方法的代码放在了 additionalCode 文件夹中。

提示

如果您还没有这样做,请查看此书的 GitHub 仓库 github.com/drabastomek/learningPySpark/tree/master/Chapter11

文件夹的树状结构如下:

模块结构

如您所见,它具有某种正常 Python 包的结构:在最上面我们有 setup.py 文件,这样我们就可以打包我们的模块,然后内部包含我们的代码。

在我们的情况下,setup.py 文件如下所示:

from setuptools import setup

setup(
    name='PySparkUtilities',
    version='0.1dev',
    packages=['utilities', 'utilities/converters'],
    license='''
        Creative Commons 
        Attribution-Noncommercial-Share Alike license''',
    long_description='''
        An example of how to package code for PySpark'''
)

我们在这里不会深入探讨结构(它本身相当直观):您可以在以下链接中了解更多关于如何为其他项目定义 setup.py 文件的信息 pythonhosted.org/an_example_pypi_project/setuptools.html

工具文件夹中的 __init__.py 文件包含以下代码:

from .geoCalc import geoCalc
__all__ = ['geoCalc','converters']

它有效地暴露了 geoCalc.pyconverters(稍后将有更多介绍)。

计算两点之间的距离

我们提到的第一个方法使用 Haversine 公式来计算地图上任意两点之间的直接距离(笛卡尔坐标)。执行此操作的代码位于模块的 geoCalc.py 文件中。

calculateDistance(...)geoCalc 类的一个静态方法。它接受两个地理点,这些点以元组或包含两个元素(按顺序为纬度和经度)的列表的形式表示,并使用 Haversine 公式来计算距离。计算距离所需的地球半径以英里表示,因此计算出的距离也将以英里为单位。

转换距离单位

我们构建了工具包,使其更加通用。作为包的一部分,我们公开了用于在各个测量单位之间进行转换的方法。

注意

目前我们只限制距离,但功能可以进一步扩展到其他领域,如面积、体积或温度。

为了便于使用,任何实现为converter的类都应该公开相同的接口。这就是为什么建议这样的类从我们的BaseConverter类派生(参见base.py):

from abc import ABCMeta, abstractmethod

class BaseConverter(metaclass=ABCMeta):
    @staticmethod
    @abstractmethod
    def convert(f, t):
        raise NotImplementedError

这是一个纯抽象类,不能被实例化:它的唯一目的是强制派生类实现convert(...)方法。有关实现细节,请参阅distance.py文件。对于熟悉 Python 的人来说,代码应该是自解释的,所以我们不会一步一步地解释它。

构建一个蛋

现在我们已经将所有代码放在一起,我们可以打包它。PySpark 的文档指出,你可以使用--py-files开关将.py文件传递给spark-submit脚本,并用逗号分隔。然而,将我们的模块打包成.zip.egg会更方便。这时setup.py文件就派上用场了——你只需要在additionalCode文件夹中调用它:

python setup.py bdist_egg

如果一切顺利,你应该看到三个额外的文件夹:PySparkUtilities.egg-infobuilddist——我们感兴趣的是位于dist文件夹中的文件:PySparkUtilities-0.1.dev0-py3.5.egg

提示

在运行前面的命令后,你可能发现你的.egg文件名略有不同,因为你可能有不同的 Python 版本。你仍然可以在 Spark 作业中使用它,但你需要调整spark-submit命令以反映你的.egg文件名。

Spark 中的用户定义函数

在 PySpark 中对DataFrame进行操作时,你有两种选择:使用内置函数来处理数据(大多数情况下这足以实现所需的功能,并且推荐这样做,因为代码性能更好)或创建自己的用户定义函数。

要定义一个用户定义函数(UDF),你必须将 Python 函数包装在.udf(...)方法中,并定义其返回值类型。这就是我们在脚本中这样做的方式(检查calculatingGeoDistance.py文件):

import utilities.geoCalc as geo
from utilities.converters import metricImperial

getDistance = func.udf(
    lambda lat1, long1, lat2, long2: 
        geo.calculateDistance(
            (lat1, long1),
            (lat2, long2)
        )
    )

convertMiles = func.udf(lambda m: 
    metricImperial.convert(str(m) + ' mile', 'km'))

我们可以使用这样的函数来计算距离并将其转换为英里:

uber = uber.withColumn(
    'miles', 
        getDistance(
            func.col('pickup_latitude'),
            func.col('pickup_longitude'), 
            func.col('dropoff_latitude'), 
            func.col('dropoff_longitude')
        )
    )

uber = uber.withColumn(
    'kilometers', 
    convertMiles(func.col('miles')))

使用.withColumn(...)方法,我们创建额外的列,包含我们感兴趣的价值。

注意

这里需要提醒一点。如果你使用 PySpark 内置函数,即使你调用它们为 Python 对象,底层调用会被转换并执行为 Scala 代码。然而,如果你在 Python 中编写自己的方法,它不会被转换为 Scala,因此必须在驱动程序上执行。这会导致性能显著下降。查看 Stack Overflow 上的这个答案以获取更多详细信息:stackoverflow.com/questions/32464122/spark-performance-for-scala-vs-python

现在我们把所有的拼图放在一起,最终提交我们的工作。

提交一个工作

在你的 CLI 中输入以下内容(我们假设你保持文件夹结构与 GitHub 上的结构不变):

./launch_spark_submit.sh \
--master local[4] \
--py-files additionalCode/dist/PySparkUtilities-0.1.dev0-py3.5.egg \
calculatingGeoDistance.py

我们需要对launch_spark_submit.shshell 脚本进行一些解释。在 Bonus 第一章,安装 Spark中,我们配置了 Spark 实例以运行 Jupyter(通过设置PYSPARK_DRIVER_PYTHON系统变量为jupyter)。如果你在这样配置的机器上简单地使用spark-submit,你很可能会遇到以下错误的一些变体:

jupyter: 'calculatingGeoDistance.py' is not a Jupyter command

因此,在运行spark-submit命令之前,我们首先必须取消设置该变量,然后运行代码。这会迅速变得极其繁琐,所以我们通过launch_spark_submit.sh脚本自动化了它:

#!/bin/bash

unset PYSPARK_DRIVER_PYTHON
spark-submit $*
export PYSPARK_DRIVER_PYTHON=jupyter

如你所见,这不过是spark-submit命令的一个包装器。

如果一切顺利,你将在 CLI 中看到以下意识流

提交作业

从输出中你可以获得许多有用的信息:

  • 当前 Spark 版本:2.1.0

  • Spark UI(用于跟踪作业进度的工具)已成功在http://localhost:4040启动

  • 我们的成功添加了.egg文件到执行

  • uber_data_nyc_2016-06_3m_partitioned.csv已成功读取

  • 每个作业和任务的启动和停止都被列出

作业完成后,你将看到以下类似的内容:

提交作业

从前面的截图,我们可以看到距离被正确报告。你还可以看到 Spark UI 进程现在已经停止,并且所有清理工作都已执行。

监控执行

当你使用spark-submit命令时,Spark 会启动一个本地服务器,允许你跟踪作业的执行情况。以下是窗口的外观:

监控执行

在顶部,你可以切换到作业阶段视图;作业视图允许你跟踪执行整个脚本的独立作业,而阶段视图允许你跟踪所有执行的阶段。

你还可以通过点击阶段的链接来查看每个阶段的执行配置文件,并跟踪每个任务的执行。在以下截图中,你可以看到 Stage 3 的执行配置文件,其中运行了四个任务:

监控执行

小贴士

在集群设置中,你将看到driver/localhost而不是驱动器/本地主机,而是驱动器编号和主机的 IP 地址。

在一个作业或阶段内部,你可以点击 DAG 可视化来查看你的作业或阶段是如何执行的(左边的以下图表显示了作业视图,而右边的显示了阶段视图):

监控执行

Databricks 作业

如果你使用 Databricks 产品,从 Databricks 笔记本的开发到生产的一个简单方法就是使用 Databricks 作业功能。它将允许你:

  • 安排 Databricks 笔记本在现有或新集群上运行

  • 按您希望的频率(从分钟到月份)安排

  • 为您的作业安排超时和重试

  • 当作业开始、完成或出错时收到警报

  • 查看历史作业运行以及审查单个笔记本作业运行的记录

这种功能极大地简化了您作业提交的调度和生产工作流程。请注意,您需要将您的 Databricks 订阅(从社区版)升级才能使用此功能。

要使用此功能,请转到 Databricks 作业菜单并点击创建作业。从这里,填写作业名称,然后选择您想要转换为作业的笔记本,如图所示:

Databricks 作业

一旦您选择了笔记本,您还可以选择是否使用正在运行的现有集群,或者让作业调度器为该作业启动一个新集群,如图所示:

Databricks 作业

一旦您选择了笔记本和集群;您可以设置计划、警报、超时和重试。一旦您完成设置作业,它应该看起来类似于以下截图中的人口与价格线性回归作业

Databricks 作业

您可以通过点击活动运行下方的立即运行链接来测试作业。

Meetup Streaming RSVPs 作业中所述,您可以查看您已完成运行的记录;如图所示,对于这个笔记本,有50个完成的作业运行:

Databricks 作业

通过点击作业运行(在这种情况下,运行 50),您可以查看该作业运行的结果。您不仅可以查看开始时间、持续时间和服务状态,还可以查看该特定作业的结果:

Databricks 作业

备注

REST 作业服务器

运行作业的一种流行方式是使用 REST API。如果您使用 Databricks,您可以使用 Databricks REST API 运行作业。如果您更喜欢管理自己的作业服务器,一个流行的开源 REST 作业服务器是 spark-jobserver - 一个用于提交和管理 Apache Spark 作业、jar 和作业上下文的 RESTful 接口。该项目最近(在撰写本文时)进行了更新,以便它可以处理 PySpark 作业。

更多信息,请参阅 github.com/spark-jobserver/spark-jobserver

摘要

在本章中,我们向您介绍了如何从命令行将用 Python 编写的应用程序提交到 Spark 的步骤。我们讨论了 spark-submit 参数的选择。我们还向您展示了如何打包您的 Python 代码,并将其与 PySpark 脚本一起提交。此外,我们还向您展示了如何跟踪作业的执行。

此外,我们还提供了一个关于如何使用 Databricks Jobs 功能运行 Databricks 笔记本的快速概述。此功能简化了从开发到生产的过渡,允许您将笔记本作为一个端到端工作流程执行。

这本书的内容到此结束。我们希望您享受了这次旅程,并且书中包含的材料能帮助您开始使用 Python 与 Spark 进行工作。祝您好运!

posted @ 2025-10-23 15:16  绝不原创的飞龙  阅读(34)  评论(0)    收藏  举报