Spark-SQL-学习手册-全-

Spark SQL 学习手册(全)

原文:zh.annas-archive.org/md5/38E33AE602B4FA8FF02AE9F0398CDE84

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我们将从 Spark SQL 的基础知识和其在 Spark 应用中的作用开始。在对 Spark SQL 进行初步了解之后,我们将专注于使用 Spark SQL 执行所有大数据项目常见的任务,如处理各种类型的数据源、探索性数据分析和数据整理。我们还将看到如何利用 Spark SQL 和 SparkR 来实现典型的大规模数据科学任务。

作为 Spark SQL 核心的 DataFrame/Dataset API 和 Catalyst 优化器,在基于 Spark 技术栈的所有应用中发挥关键作用并不奇怪。这些应用包括大规模机器学习管道、大规模图应用和新兴的基于 Spark 的深度学习应用。此外,我们还将介绍基于 Spark SQL 的结构化流应用,这些应用部署在复杂的生产环境中作为连续应用。

我们还将回顾 Spark SQL 应用中的性能调优,包括 Spark 2.2 中引入的基于成本的优化(CBO)。最后,我们将介绍利用 Spark 模块和 Spark SQL 在实际应用中的应用架构。具体来说,我们将介绍大规模 Spark 应用中的关键架构组件和模式,这些组件和模式对架构师和设计师来说将是有用的构建块,用于他们自己特定用例的构建。

本书内容

第一章《开始使用 Spark SQL》概述了 Spark SQL,并通过实践让您熟悉 Spark 环境。

第二章《使用 Spark SQL 处理结构化和半结构化数据》将帮助您使用 Spark 处理关系数据库(MySQL)、NoSQL 数据库(MongoDB)、半结构化数据(JSON)以及 Hadoop 生态系统中常用的数据存储格式(Avro 和 Parquet)。

第三章《使用 Spark SQL 进行数据探索》演示了使用 Spark SQL 来探索数据集,执行基本的数据质量检查,生成样本和数据透视表,并使用 Apache Zeppelin 可视化数据。

第四章《使用 Spark SQL 进行数据整理》使用 Spark SQL 执行一些基本的数据整理/处理任务。它还向您介绍了一些处理缺失数据、错误数据、重复记录等技术。

第五章《在流应用中使用 Spark SQL》提供了使用 Spark SQL DataFrame/Dataset API 构建流应用的几个示例。此外,它还展示了如何在结构化流应用中使用 Kafka。

第六章《在机器学习应用中使用 Spark SQL》专注于在机器学习应用中使用 Spark SQL。在本章中,我们将主要探讨特征工程的关键概念,并实现机器学习管道。

第七章《在图应用中使用 Spark SQL》向您介绍了 GraphFrame 应用。它提供了使用 Spark SQL DataFrame/Dataset API 构建图应用并将各种图算法应用于图应用的示例。

第八章《使用 Spark SQL 与 SparkR》涵盖了 SparkR 架构和 SparkR DataFrames API。它提供了使用 SparkR 进行探索性数据分析(EDA)和数据整理任务、数据可视化和机器学习的代码示例。

第九章,使用 Spark SQL 开发应用程序,帮助您使用各种 Spark 模块构建 Spark 应用程序。它提供了将 Spark SQL 与 Spark Streaming、Spark 机器学习等相结合的应用程序示例。

第十章,在深度学习应用程序中使用 Spark SQL,向您介绍了 Spark 中的深度学习。在深入使用 BigDL 和 Spark 之前,它涵盖了一些流行的深度学习模型的基本概念。

第十一章,调整 Spark SQL 组件以提高性能,向您介绍了与调整 Spark 应用程序相关的基本概念,包括使用编码器进行数据序列化。它还涵盖了在 Spark 2.2 中引入的基于成本的优化器的关键方面,以自动优化 Spark SQL 执行。

第十二章,大规模应用架构中的 Spark SQL,教会您识别 Spark SQL 可以在大规模应用架构中实现典型功能和非功能需求的用例。

本书所需内容

本书基于 Spark 2.2.0(为 Apache Hadoop 2.7 或更高版本预构建)和 Scala 2.11.8。由于某些库的不可用性和报告的错误(在与 Apache Spark 2.2 一起使用时),也使用了 Spark 2.1.0 来进行一两个小节的讨论。硬件和操作系统规格包括最低 8GB RAM(强烈建议 16GB)、100GB HDD 和 OS X 10.11.6 或更高版本(或建议用于 Spark 开发的适当 Linux 版本)。

本书的受众

如果您是开发人员、工程师或架构师,并希望学习如何在大规模网络项目中使用 Apache Spark,那么这本书适合您。假定您具有 SQL 查询的先前知识。使用 Scala、Java、R 或 Python 的基本编程知识就足以开始阅读本书。

约定

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

文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和终端命令如下:"通过在训练数据集上调用fit()方法来训练模型。"

代码块设置如下:

scala> val inDiaDataDF = spark.read.option("header", true).csv("file:///Users/aurobindosarkar/Downloads/dataset_diabetes/diabetic_data.csv").cache()

任何命令行输入或输出都将如下所示:

head -n 8000 input.txt > val.txt
tail -n +8000 input.txt > train.txt

新术语重要单词以粗体显示。例如,您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:"单击“下一步”按钮将您移至下一个屏幕。"

警告或重要说明会出现如下。

技巧和窍门会出现如下。

第一章:开始使用 Spark SQL

Spark SQL 是使用 Spark 开发的所有应用程序的核心。在本书中,我们将详细探讨 Spark SQL 的使用方式,包括其在各种类型的应用程序中的使用以及其内部工作原理。开发人员和架构师将欣赏到每一章中呈现的技术概念和实践会话,因为他们在阅读本书时会逐步进展。

在本章中,我们将向您介绍与 Spark SQL 相关的关键概念。我们将从 SparkSession 开始,这是 Spark 2.0 中 Spark SQL 的新入口点。然后,我们将探索 Spark SQL 的接口 RDDs、DataFrames 和 Dataset APIs。随后,我们将解释有关 Catalyst 优化器和 Project Tungsten 的开发人员级细节。

最后,我们将介绍 Spark 2.0 中针对流应用程序的一项令人兴奋的新功能,称为结构化流。本章中将提供特定的实践练习(使用公开可用的数据集),以便您在阅读各个部分时能够积极参与其中。

更具体地,本章的各节将涵盖以下主题以及实践会话:

  • 什么是 Spark SQL?

  • 介绍 SparkSession

  • 了解 Spark SQL 概念

  • 了解 RDDs、DataFrames 和 Datasets

  • 了解 Catalyst 优化器

  • 了解 Project Tungsten

  • 在连续应用程序中使用 Spark SQL

  • 了解结构化流内部

什么是 Spark SQL?

Spark SQL 是 Apache Spark 最先进的组件之一。自 Spark 1.0 以来一直是核心分发的一部分,并支持 Python、Scala、Java 和 R 编程 API。如下图所示,Spark SQL 组件为 Spark 机器学习应用程序、流应用程序、图应用程序以及许多其他类型的应用程序架构提供了基础。

这些应用程序通常使用 Spark ML pipelines、结构化流和 GraphFrames,这些都是基于 Spark SQL 接口(DataFrame/Dataset API)的。这些应用程序以及 SQL、DataFrames 和 Datasets API 等构造自动获得 Catalyst 优化器的好处。该优化器还负责根据较低级别的 RDD 接口生成可执行的查询计划。

我们将在第六章中更详细地探讨 ML pipelines,在机器学习应用程序中使用 Spark SQL。GraphFrames 将在第七章中介绍,在图应用程序中使用 Spark SQL。而在本章中,我们将介绍有关结构化流和 Catalyst 优化器的关键概念,我们将在第五章和第十一章中获得更多关于它们的细节,在流应用程序中使用 Spark SQLTuning Spark SQL Components for Performance

在 Spark 2.0 中,DataFrame API 已与 Dataset API 合并,从而统一了跨 Spark 库的数据处理能力。这也使开发人员能够使用单一的高级和类型安全的 API。但是,Spark 软件堆栈并不阻止开发人员直接在其应用程序中使用低级别的 RDD 接口。尽管低级别的 RDD API 将继续可用,但预计绝大多数开发人员将(并建议)使用高级 API,即 Dataset 和 DataFrame API。

此外,Spark 2.0 通过包括一个新的 ANSI SQL 解析器扩展了 Spark SQL 的功能,支持子查询和 SQL:2003 标准。更具体地,子查询支持现在包括相关/不相关子查询,以及IN / NOT INEXISTS / NOT EXISTS谓词在WHERE / HAVING子句中。

Spark SQL 的核心是 Catalyst 优化器,它利用 Scala 的高级特性(如模式匹配)来提供可扩展的查询优化器。DataFrame、数据集和 SQL 查询共享相同的执行和优化管道;因此,使用这些结构中的任何一个(或使用任何受支持的编程 API)都不会对性能产生影响。开发人员编写的高级基于 DataFrame 的代码被转换为 Catalyst 表达式,然后通过该管道转换为低级 Java 字节码。

SparkSession是与 Spark SQL 相关功能的入口点,我们将在下一节中对其进行更详细的描述。

介绍 SparkSession

在 Spark 2.0 中,SparkSession表示操作 Spark 中数据的统一入口点。它最小化了开发人员在使用 Spark 时必须使用的不同上下文的数量。SparkSession取代了多个上下文对象,如SparkContextSQLContextHiveContext。这些上下文现在封装在SparkSession对象中。

在 Spark 程序中,我们使用构建器设计模式来实例化SparkSession对象。但是,在 REPL 环境(即在 Spark shell 会话中),SparkSession会自动创建并通过名为Spark的实例对象提供给您。

此时,在您的计算机上启动 Spark shell 以交互式地执行本节中的代码片段。随着 shell 的启动,您会注意到屏幕上出现了一堆消息,如下图所示。您应该看到显示SparkSession对象(作为 Spark)、Spark 版本为 2.2.0、Scala 版本为 2.11.8 和 Java 版本为 1.8.x 的消息。

SparkSession对象可用于配置 Spark 的运行时配置属性。例如,Spark 和 Yarn 管理的两个主要资源是 CPU 和内存。如果要设置 Spark 执行程序的核心数和堆大小,可以分别通过设置spark.executor.coresspark.executor.memory属性来实现。在本例中,我们将这些运行时属性分别设置为2个核心和4GB,如下所示:

    scala> spark.conf.set("spark.executor.cores", "2")

    scala> spark.conf.set("spark.executor.memory", "4g")

SparkSession对象可用于从各种来源读取数据,如 CSV、JSON、JDBC、流等。此外,它还可用于执行 SQL 语句、注册用户定义函数(UDFs)以及处理数据集和 DataFrame。以下会话演示了 Spark 中的一些基本操作。

在本例中,我们使用由威斯康星大学医院麦迪逊分校的 William H. Wolberg 博士创建的乳腺癌数据库。您可以从archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+(Original)下载原始数据集。数据集中的每一行包含样本编号、乳腺细针抽吸的九个细胞学特征(分级为110)以及label类别,即良性(2)恶性(4)

首先,我们为文件中的记录定义一个模式。字段描述可以在数据集的下载站点上找到。

scala> import org.apache.spark.sql.types._

scala> val recordSchema = new StructType().add("sample", "long").add("cThick", "integer").add("uCSize", "integer").add("uCShape", "integer").add("mAdhes", "integer").add("sECSize", "integer").add("bNuc", "integer").add("bChrom", "integer").add("nNuc", "integer").add("mitosis", "integer").add("clas", "integer")

接下来,我们使用在前一步中定义的记录模式从输入 CSV 文件创建一个 DataFrame:

val df = spark.read.format("csv").option("header", false).schema(recordSchema).load("file:///Users/aurobindosarkar/Downloads/breast-cancer-wisconsin.data")

新创建的 DataFrame 可以使用show()方法显示:

DataFrame 可以使用createOrReplaceTempView()方法注册为 SQL 临时视图。这允许应用程序使用 SparkSession 对象的sql函数运行 SQL 查询,并将结果作为 DataFrame 返回。

接下来,我们为 DataFrame 创建一个临时视图,并对其执行一个简单的 SQL 语句:

scala> df.createOrReplaceTempView("cancerTable") 

scala> val sqlDF = spark.sql("SELECT sample, bNuc from cancerTable") 

使用show()方法显示结果 DataFrame 的内容:

case class and the toDS() method. Then, we define a UDF to convert the clas column, currently containing 2's and 4's to  0's and 1's respectively. We register the UDF using the SparkSession object and use it in a SQL statement:
scala> case class CancerClass(sample: Long, cThick: Int, uCSize: Int, uCShape: Int, mAdhes: Int, sECSize: Int, bNuc: Int, bChrom: Int, nNuc: Int, mitosis: Int, clas: Int)

scala> val cancerDS = spark.sparkContext.textFile("file:///Users/aurobindosarkar/Documents/SparkBook/data/breast-cancer-wisconsin.data").map(_.split(",")).map(attributes => CancerClass(attributes(0).trim.toLong, attributes(1).trim.toInt, attributes(2).trim.toInt, attributes(3).trim.toInt, attributes(4).trim.toInt, attributes(5).trim.toInt, attributes(6).trim.toInt, attributes(7).trim.toInt, attributes(8).trim.toInt, attributes(9).trim.toInt, attributes(10).trim.toInt)).toDS()

scala> def binarize(s: Int): Int = s match {case 2 => 0 case 4 => 1 }

scala> spark.udf.register("udfValueToCategory", (arg: Int) => binarize(arg))

scala> val sqlUDF = spark.sql("SELECT *, udfValueToCategory(clas) from cancerTable")

scala> sqlUDF.show()

SparkSession公开了访问底层元数据的方法(通过 catalog 属性),例如可用数据库和表、注册的 UDF、临时视图等。此外,我们还可以缓存表、删除临时视图和清除缓存。这里展示了一些这些语句及其相应的输出:

scala> spark.catalog.currentDatabase

res5: String = default

scala> spark.catalog.isCached("cancerTable") 

res6: Boolean = false 

scala> spark.catalog.cacheTable("cancerTable") 

scala> spark.catalog.isCached("cancerTable") 

res8: Boolean = true 

scala> spark.catalog.clearCache 

scala> spark.catalog.isCached("cancerTable") 

res10: Boolean = false 

scala> spark.catalog.listDatabases.show()

还可以使用take方法在 DataFrame 中显示特定数量的记录:

scala> spark.catalog.listDatabases.take(1)
res13: Array[org.apache.spark.sql.catalog.Database] = Array(Database[name='default', description='Default Hive database', path='file:/Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7/spark-warehouse'])

scala> spark.catalog.listTables.show()

我们可以使用以下语句删除之前创建的临时表:

scala> spark.catalog.dropTempView("cancerTable")

scala> spark.catalog.listTables.show()

在接下来的几节中,我们将更详细地描述 RDD、DataFrame 和 Dataset 的构造。

理解 Spark SQL 概念

在本节中,我们将探讨与弹性分布式数据集(RDD)、DataFrame 和 Dataset、Catalyst Optimizer 和 Project Tungsten 相关的关键概念。

理解弹性分布式数据集(RDD)

RDD 是 Spark 的主要分布式数据集抽象。它是一个不可变的、分布式的、惰性评估的、类型推断的、可缓存的数据集合。在执行之前,开发人员的代码(使用诸如 SQL、DataFrame 和 Dataset API 等更高级别的构造)被转换为 RDD 的 DAG(准备执行)。

您可以通过并行化现有数据集合或访问存储在外部存储系统中的数据集合(例如文件系统或各种基于 Hadoop 的数据源)来创建 RDD。并行化的集合形成了一个分布式数据集,使得可以对其进行并行操作。

您可以从指定了分区数量的输入文件创建 RDD,如下所示:

scala> val cancerRDD = sc.textFile("file:///Users/aurobindosarkar/Downloads/breast-cancer-wisconsin.data", 4)

scala> cancerRDD.partitions.size
res37: Int = 4

您可以通过导入spark.implicits包并使用toDF()方法将 RDD 隐式转换为 DataFrame:

scala> import spark.implicits._scala> 
val cancerDF = cancerRDD.toDF()

要创建具有特定模式的 DataFrame,我们为 DataFrame 中包含的行定义一个 Row 对象。此外,我们将逗号分隔的数据拆分,转换为字段列表,然后将其映射到 Row 对象。最后,我们使用createDataFrame()创建具有指定模式的 DataFrame:

def row(line: List[String]): Row = { Row(line(0).toLong, line(1).toInt, line(2).toInt, line(3).toInt, line(4).toInt, line(5).toInt, line(6).toInt, line(7).toInt, line(8).toInt, line(9).toInt, line(10).toInt) }
val data = cancerRDD.map(_.split(",").to[List]).map(row)
val cancerDF = spark.createDataFrame(data, recordSchema)

此外,我们可以轻松地使用之前定义的case类将前述 DataFrame 转换为数据集:

scala> val cancerDS = cancerDF.as[CancerClass]

RDD 数据在逻辑上被划分为一组分区;此外,所有输入、中间和输出数据也被表示为分区。RDD 分区的数量定义了数据的碎片化程度。这些分区也是并行性的基本单元。Spark 执行作业被分成多个阶段,每个阶段一次操作一个分区,因此调整分区的数量非常重要。比活跃阶段少的分区意味着您的集群可能被低效利用,而过多的分区可能会影响性能,因为会导致更高的磁盘和网络 I/O。

RDD 的编程接口支持两种类型的操作:转换和动作。转换从现有数据集创建一个新的数据集,而动作返回计算结果的值。所有转换都是惰性评估的--实际执行只发生在执行动作以计算结果时。转换形成一个谱系图,而不是实际在多台机器上复制数据。这种基于图的方法实现了高效的容错模型。例如,如果丢失了一个 RDD 分区,那么可以根据谱系图重新计算它。

您可以控制数据持久性(例如缓存)并指定 RDD 分区的放置偏好,然后使用特定的操作符对其进行操作。默认情况下,Spark 将 RDD 持久化在内存中,但如果内存不足,它可以将它们溢出到磁盘。缓存通过几个数量级提高了性能;然而,它通常占用大量内存。其他持久性选项包括将 RDD 存储到磁盘并在集群中的节点之间复制它们。持久 RDD 的内存存储可以是反序列化或序列化的 Java 对象形式。反序列化选项更快,而序列化选项更节省内存(但更慢)。未使用的 RDD 将自动从缓存中删除,但根据您的要求;如果不再需要特定的 RDD,则也可以显式释放它。

理解 DataFrames 和 Datasets

DataFrame 类似于关系数据库中的表、pandas dataframe 或 R 中的数据框。它是一个分布式的行集合,组织成列。它使用 RDD 的不可变、内存中、弹性、分布式和并行能力,并对数据应用模式。DataFrames 也是惰性评估的。此外,它们为分布式数据操作提供了领域特定语言(DSL)。

从概念上讲,DataFrame 是一组通用对象Dataset[Row]的别名,其中行是通用的无类型对象。这意味着 DataFrame 的语法错误在编译阶段被捕获;然而,分析错误只在运行时被检测到。

DataFrame 可以从各种来源构建,例如结构化数据文件、Hive 表、数据库或 RDD。源数据可以从本地文件系统、HDFS、Amazon S3 和 RDBMS 中读取。此外,还支持其他流行的数据格式,如 CSV、JSON、Avro、Parquet 等。此外,您还可以创建和使用自定义数据源。

DataFrame API 支持 Scala、Java、Python 和 R 编程 API。DataFrame API 是声明式的,并与 Spark 的过程式代码结合使用,为应用程序中的关系和过程式处理提供了更紧密的集成。可以使用 Spark 的过程式 API 或使用关系 API(具有更丰富的优化)来操作 DataFrame。

在 Spark 的早期版本中,您必须编写操作 RDD 的任意 Java、Python 或 Scala 函数。在这种情况下,函数是在不透明的 Java 对象上执行的。因此,用户函数本质上是执行不透明计算的黑匣子,使用不透明对象和数据类型。这种方法非常通用,这样的程序可以完全控制每个数据操作的执行。然而,由于引擎不知道您正在执行的代码或数据的性质,因此无法优化这些任意的 Java 对象。此外,开发人员需要编写依赖于特定工作负载性质的高效程序。

在 Spark 2.0 中,使用 SQL、DataFrames 和 Datasets 的主要好处是,使用这些高级编程接口编程更容易,同时自动获得性能改进的好处。您只需编写更少的代码行,程序就会自动优化,并为您生成高效的代码。这样可以提高性能,同时显著减轻开发人员的负担。现在,开发人员可以专注于“做什么”,而不是“如何完成”。

数据集 API 首次添加到 Spark 1.6 中,以提供 RDD 和 Spark SQL 优化器的优点。数据集可以从 JVM 对象构造,然后使用mapfilter等函数变换进行操作。由于数据集是使用用户定义的 case 类指定的强类型对象的集合,因此可以在编译时检测到语法错误和分析错误。

统一的数据集 API 可以在 Scala 和 Java 中使用。但是 Python 目前还不支持数据集 API。

在下面的示例中,我们介绍了一些基本的 DataFrame/Dataset 操作。为此,我们将使用两个餐厅列表数据集,这些数据集通常用于重复记录检测和记录链接应用。来自 Zagat 和 Fodor 餐厅指南的两个列表之间存在重复记录。为了使这个例子简单,我们手动将输入文件转换为 CSV 格式。您可以从www.cs.utexas.edu/users/ml/riddle/data.html下载原始数据集。

首先,我们为两个文件中的记录定义一个case类:

scala> case class RestClass(name: String, street: String, city: String, phone: String, cuisine: String)

接下来,我们从两个文件创建数据集:

scala> val rest1DS = spark.sparkContext.textFile("file:///Users/aurobindosarkar/Documents/SparkBook/data/zagats.csv").map(_.split(",")).map(attributes => RestClass(attributes(0).trim, attributes(1).trim, attributes(2).trim, attributes(3).trim, attributes(4).trim)).toDS()

scala> val rest2DS = spark.sparkContext.textFile("file:///Users/aurobindosarkar/Documents/SparkBook/data/fodors.csv").map(_.split(",")).map(attributes => RestClass(attributes(0).trim, attributes(1).trim, attributes(2).trim, attributes(3).trim, attributes(4).trim)).toDS()

我们定义一个 UDF 来清理和转换第二个数据集中的电话号码,以匹配第一个文件中的格式:

scala> def formatPhoneNo(s: String): String = s match {case s if s.contains("/") => s.replaceAll("/", "-").replaceAll("- ", "-").replaceAll("--", "-") case _ => s } 

scala> val udfStandardizePhoneNos = udfString, String ) 

scala> val rest2DSM1 = rest2DS.withColumn("stdphone", udfStandardizePhoneNos(rest2DS.col("phone")))

接下来,我们从我们的数据集创建临时视图:

scala> rest1DS.createOrReplaceTempView("rest1Table") 

scala> rest2DSM1.createOrReplaceTempView("rest2Table")

我们可以通过在这些表上执行 SQL 语句来获取重复记录的数量:

scala> spark.sql("SELECT count(*) from rest1Table, rest2Table where rest1Table.phone = rest2Table.stdphone").show()

接下来,我们执行一个返回包含匹配电话号码的行的 DataFrame 的 SQL 语句:

scala> val sqlDF = spark.sql("SELECT a.name, b.name, a.phone, b.stdphone from rest1Table a, rest2Table b where a.phone = b.stdphone")

从两个表中列出的名称和电话号码列的结果可以显示,以直观地验证结果是否可能重复:

在下一节中,我们将把重点转移到 Spark SQL 内部,更具体地说,是 Catalyst 优化器和 Project Tungsten。

理解 Catalyst 优化器

Catalyst 优化器是 Spark SQL 的核心,用 Scala 实现。它实现了一些关键功能,例如模式推断(从 JSON 数据中),这在数据分析工作中非常有用。

下图显示了从包含 DataFrame/Dataset 的开发人员程序到最终执行计划的高级转换过程:

程序的内部表示是查询计划。查询计划描述诸如聚合、连接和过滤等数据操作,这些操作与查询中定义的内容相匹配。这些操作从输入数据集生成一个新的数据集。在我们有查询计划的初始版本后,Catalyst 优化器将应用一系列转换将其转换为优化的查询计划。最后,Spark SQL 代码生成机制将优化的查询计划转换为准备执行的 RDD 的 DAG。查询计划和优化的查询计划在内部表示为树。因此,在其核心,Catalyst 优化器包含一个用于表示树和应用规则来操作它们的通用库。在这个库之上,还有几个更具体于关系查询处理的其他库。

Catalyst 有两种类型的查询计划:逻辑物理计划。逻辑计划描述了数据集上的计算,而没有定义如何执行具体的计算。通常,逻辑计划在生成的行的一组约束下生成属性或列的列表作为输出。物理计划描述了数据集上的计算,并具体定义了如何执行它们(可执行)。

让我们更详细地探讨转换步骤。初始查询计划本质上是一个未解析的逻辑计划,也就是说,在这个阶段我们不知道数据集的来源或数据集中包含的列,我们也不知道列的类型。这个管道的第一步是分析步骤。在分析过程中,使用目录信息将未解析的逻辑计划转换为已解析的逻辑计划。

在下一步中,一组逻辑优化规则被应用于已解析的逻辑计划,从而产生一个优化的逻辑计划。在下一步中,优化器可能生成多个物理计划,并比较它们的成本以选择最佳的一个。建立在 Spark SQL 之上的第一个版本的基于成本的优化器CBO)已经在 Spark 2.2 中发布。有关基于成本的优化的更多细节,请参阅第十一章,调整 Spark SQL 组件以提高性能

所有三个--DataFrameDataset和 SQL--都共享如下图所示的相同优化管道:

理解 Catalyst 优化

在 Catalyst 中,有两种主要类型的优化:逻辑和物理:

  • 逻辑优化:这包括优化器将过滤谓词下推到数据源并使执行跳过无关数据的能力。例如,在 Parquet 文件的情况下,整个块可以被跳过,并且字符串的比较可以通过字典编码转换为更便宜的整数比较。在关系型数据库的情况下,谓词被下推到数据库以减少数据流量。

  • 物理优化:这包括智能地选择广播连接和洗牌连接以减少网络流量,执行更低级别的优化,如消除昂贵的对象分配和减少虚拟函数调用。因此,当在程序中引入 DataFrame 时,性能通常会提高。

规则执行器负责分析和逻辑优化步骤,而一组策略和规则执行器负责物理规划步骤。规则执行器通过批量应用一组规则将一个树转换为另一个相同类型的树。这些规则可以应用一次或多次。此外,每个规则都被实现为一个转换。转换基本上是一个函数,与每个树相关联,并用于实现单个规则。在 Scala 术语中,转换被定义为部分函数(对其可能的参数子集定义的函数)。这些通常被定义为 case 语句,以确定部分函数(使用模式匹配)是否对给定输入定义。

规则执行器使物理计划准备好执行,通过准备标量子查询,确保输入行满足特定操作的要求,并应用物理优化。例如,在排序合并连接操作中,输入行需要根据连接条件进行排序。优化器在执行排序合并连接操作之前插入适当的排序操作,如有必要。

理解 Catalyst 转换

在概念上,Catalyst 优化器执行两种类型的转换。第一种将输入树类型转换为相同的树类型(即,不改变树类型)。这种类型的转换包括将一个表达式转换为另一个表达式,一个逻辑计划转换为另一个逻辑计划,一个物理计划转换为另一个物理计划。第二种类型的转换将一个树类型转换为另一个类型,例如,从逻辑计划转换为物理计划。通过应用一组策略,逻辑计划被转换为物理计划。这些策略使用模式匹配将树转换为另一种类型。例如,我们有特定的模式用于匹配逻辑项目和过滤运算符到物理项目和过滤运算符。

一组规则也可以合并成一个单一的规则来完成特定的转换。例如,根据您的查询,诸如过滤器之类的谓词可以被推送下来以减少执行连接操作之前的总行数。此外,如果您的查询中有一个带有常量的表达式,那么常量折叠优化会在编译时一次计算表达式,而不是在运行时为每一行重复计算。此外,如果您的查询需要一部分列,那么列修剪可以帮助减少列到必要的列。所有这些规则可以合并成一个单一的规则,以实现所有三种转换。

在下面的示例中,我们测量了 Spark 1.6 和 Spark 2.2 上的执行时间差异。我们在下一个示例中使用 iPinYou 实时竞价数据集进行计算广告研究。该数据集包含 iPinYou 全球 RTB 竞价算法竞赛的三个赛季的数据。您可以从伦敦大学学院的数据服务器上下载该数据集,网址为data.computational-advertising.org/

首先,我们为bid transactionsregion文件中的记录定义case类:

scala> case class PinTrans(bidid: String, timestamp: String, ipinyouid: String, useragent: String, IP: String, region: String, city: String, adexchange: String, domain: String, url:String, urlid: String, slotid: String, slotwidth: String, slotheight: String, slotvisibility: String, slotformat: String, slotprice: String, creative: String, bidprice: String) 

scala> case class PinRegion(region: String, regionName: String)

接下来,我们从一个bids文件和region文件创建 DataFrames:

scala> val pintransDF = spark.sparkContext.textFile("file:///Users/aurobindosarkar/Downloads/make-ipinyou-data-master/original-data/ipinyou.contest.dataset/training1st/bid.20130314.txt").map(_.split("\t")).map(attributes => PinTrans(attributes(0).trim, attributes(1).trim, attributes(2).trim, attributes(3).trim, attributes(4).trim, attributes(5).trim, attributes(6).trim, attributes(7).trim, attributes(8).trim, attributes(9).trim, attributes(10).trim, attributes(11).trim, attributes(12).trim, attributes(13).trim, attributes(14).trim, attributes(15).trim, attributes(16).trim, attributes(17).trim, attributes(18).trim)).toDF() 

scala> val pinregionDF = spark.sparkContext.textFile("file:///Users/aurobindosarkar/Downloads/make-ipinyou-data-master/original-data/ipinyou.contest.dataset/region.en.txt").map(_.split("\t")).map(attributes => PinRegion(attributes(0).trim, attributes(1).trim)).toDF()

接下来,我们借用一个简单的基准函数(在几个 Databricks 示例笔记本中可用)来测量执行时间:

scala> def benchmark(name: String)(f: => Unit) { 
 val startTime = System.nanoTime 
 f 
 val endTime = System.nanoTime 
 println(s"Time taken in $name: " + (endTime - startTime).toDouble / 1000000000 + " seconds") 
}

我们使用 SparkSession 对象将整体阶段代码生成参数关闭(这大致相当于 Spark 1.6 环境)。我们还测量了两个 DataFrame 之间的join操作的执行时间:

scala> spark.conf.set("spark.sql.codegen.wholeStage", false) 
scala> benchmark("Spark 1.6") {  
|  pintransDF.join(pinregionDF, "region").count()  
| }
Time taken in Spark 1.6: 3.742190552 seconds 

接下来,我们将整体阶段代码生成参数设置为 true,并测量执行时间。我们注意到在 Spark 2.2 中,相同代码的执行时间要低得多:

scala> spark.conf.set("spark.sql.codegen.wholeStage", true) 
scala> benchmark("Spark 2.2") {  
|  pintransDF.join(pinregionDF, "region").count()  
| }
Time taken in Spark 2.2: 1.881881579 seconds    

我们使用explain()函数来打印出 Catalyst 转换管道中的各个阶段。我们将在第十一章中更详细地解释以下输出,调整 Spark SQL 组件以提高性能

scala> pintransDF.join(pinregionDF, "region").selectExpr("count(*)").explain(true) 

在接下来的部分中,我们将介绍 Project Tungsten 的与开发人员相关的细节。

引入 Project Tungsten

Project Tungsten 被吹捧为自项目成立以来对 Spark 执行引擎的最大改变。Project Tungsten 的动机是观察到在大多数 Spark 工作负载中,CPU 和内存而不是 I/O 和网络是瓶颈。

由于硬件改进(例如 SSD 和条带化 HDD 阵列用于存储)、Spark I/O 的优化(例如 shuffle 和网络层实现、输入数据修剪以减少磁盘 I/O 等)和数据格式的改进(例如 Parquet、二进制数据格式等),CPU 现在成为瓶颈。此外,Spark 中的大规模序列化和哈希任务是 CPU 绑定操作。

Spark 1.x 使用基于迭代器模型的查询评估策略(称为 Volcano 模型)。由于查询中的每个运算符都呈现了一个接口,该接口每次返回一个元组给树中的下一个运算符,因此这个接口允许查询执行引擎组合任意组合的运算符。在 Spark 2.0 之前,大部分 CPU 周期都花在无用的工作上,比如进行虚拟函数调用或者读取/写入中间数据到 CPU 缓存或内存。

Tungsten 项目专注于三个领域,以提高内存和 CPU 的效率,将性能推向底层硬件的极限。这三个领域是内存管理和二进制处理、缓存感知计算和代码生成。此外,集成在 Spark 2.0 中的第二代 Tungsten 执行引擎使用一种称为整体代码生成的技术。这种技术使引擎能够消除虚拟函数调度,并将中间数据从内存移动到 CPU 寄存器,并通过循环展开和 SIMD 利用现代 CPU 特性。此外,Spark 2.0 引擎还通过使用另一种称为矢量化的技术加速了被认为对于代码生成过于复杂的操作。

整体代码生成将整个查询折叠成一个单一函数。此外,它消除了虚拟函数调用,并使用 CPU 寄存器存储中间数据。这反过来显著提高了 CPU 效率和运行时性能。它实现了手写代码的性能,同时继续保持通用引擎。

在矢量化中,引擎以列格式批处理多行数据,每个运算符在一个批次内对数据进行迭代。然而,它仍然需要将中间数据放入内存,而不是保留在 CPU 寄存器中。因此,只有在无法进行整体代码生成时才使用矢量化。

Tungsten 内存管理改进侧重于将 Java 对象以紧凑的二进制格式存储,以减少 GC 开销,将内存中的数据格式更加密集,以减少溢出(例如 Parquet 格式),并且对于了解数据类型的运算符(在 DataFrames、Datasets 和 SQL 的情况下)直接针对内存中的二进制格式进行操作,而不是进行序列化/反序列化等操作。

代码生成利用现代编译器和 CPU 来实现改进。这包括更快的表达式评估和 DataFrame/SQL 运算符,以及更快的序列化器。在 JVM 上对表达式的通用评估非常昂贵,因为涉及虚拟函数调用、基于表达式类型的分支、对象创建和由于原始装箱而导致的内存消耗。通过动态生成自定义字节码,这些开销大大减少了。

在这里,我们介绍了启用了整体代码生成的前一节中的投标和地区 DataFrames 之间的连接操作的物理计划。在explain()输出中,当一个运算符标有星号*时,这意味着该运算符已启用整体代码生成。在以下物理计划中,这包括 Aggregate、Project、SortMergeJoin、Filter 和 Sort 运算符。然而,Exchange 不实现整体代码生成,因为它正在通过网络发送数据:

scala> pintransDF.join(pinregionDF, "region").selectExpr("count(*)").explain() 

Tungsten 项目极大地改进了 DataFrames 和 Datasets(适用于所有编程 API - Java、Scala、Python 和 R)和 Spark SQL 查询。此外,对于许多数据处理运算符,新引擎的速度提高了数个数量级。

在接下来的部分中,我们将把重点转移到一个名为 Structured Streaming 的新 Spark 2.0 功能,它支持基于 Spark 的流应用程序。

在流应用程序中使用 Spark SQL

流应用变得越来越复杂,因为这样的计算不是孤立运行的。它们需要与批处理数据交互,支持交互式分析,支持复杂的机器学习应用等。通常,这样的应用将传入的事件流存储在长期存储中,持续监视事件,并在存储的数据上运行机器学习模型,同时在传入流上启用持续学习。它们还具有交互式查询存储的数据的能力,同时提供精确一次的写入保证,处理延迟到达的数据,执行聚合等。这些类型的应用远不止是简单的流应用,因此被称为连续应用。

在 Spark 2.0 之前,流应用是建立在 DStreams 的概念上的。使用 DStreams 存在一些痛点。在 DStreams 中,时间戳是事件实际进入 Spark 系统的时间;事件中嵌入的时间不被考虑。此外,尽管相同的引擎可以处理批处理和流处理计算,但涉及的 API 虽然在 RDD(批处理)和 DStream(流处理)之间相似,但需要开发人员进行代码更改。DStream 流模型让开发人员承担了处理各种故障条件的负担,并且很难推理数据一致性问题。在 Spark 2.0 中,引入了结构化流处理来解决所有这些痛点。

结构化流处理是一种快速、容错、精确一次的有状态流处理方法。它使流分析无需考虑流的基本机制。在新模型中,输入可以被视为来自一个不断增长的追加表的数据。触发器指定了检查输入以获取新数据到达的时间间隔。如下图所示,查询表示查询或操作,例如 map、filter 和 reduce 在输入上的操作,结果表示根据指定的操作在每个触发间隔更新的最终表。输出定义了每个时间间隔写入数据接收器的结果的部分。

输出模式可以是 complete、delta 或 append,其中 complete 输出模式表示每次写入完整的结果表,delta 输出模式写入前一批次的更改行,append 输出模式分别只写入新行:

在 Spark 2.0 中,除了静态有界的 DataFrame,我们还有连续无界的 DataFrame 的概念。静态和连续的 DataFrame 都使用相同的 API,从而统一了流、交互和批处理查询。例如,您可以在流中聚合数据,然后使用 JDBC 提供服务。高级流 API 建立在 Spark SQL 引擎上,并与 SQL 查询和 DataFrame/Dataset API 紧密集成。主要好处是您可以使用相同的高级 Spark DataFrame 和 Dataset API,Spark 引擎会找出所需的增量和连续执行操作。

此外,还有查询管理 API,您可以使用它来管理多个并发运行的流查询。例如,您可以列出运行中的查询,停止和重新启动查询,在失败的情况下检索异常等。我们将在第五章中详细了解结构化流处理,在流应用中使用 Spark SQL

在下面的示例代码中,我们使用 iPinYou 数据集中的两个出价文件作为我们流数据的来源。首先,我们定义我们的输入记录模式并创建一个流输入 DataFrame:

scala> import org.apache.spark.sql.types._ 
scala> import org.apache.spark.sql.functions._ 
scala> import scala.concurrent.duration._ 
scala> import org.apache.spark.sql.streaming.ProcessingTime 
scala> import org.apache.spark.sql.streaming.OutputMode.Complete 

scala> val bidSchema = new StructType().add("bidid", StringType).add("timestamp", StringType).add("ipinyouid", StringType).add("useragent", StringType).add("IP", StringType).add("region", IntegerType).add("city", IntegerType).add("adexchange", StringType).add("domain", StringType).add("url:String", StringType).add("urlid: String", StringType).add("slotid: String", StringType).add("slotwidth", StringType).add("slotheight", StringType).add("slotvisibility", StringType).add("slotformat", StringType).add("slotprice", StringType).add("creative", StringType).add("bidprice", StringType) 

scala> val streamingInputDF = spark.readStream.format("csv").schema(bidSchema).option("header", false).option("inferSchema", true).option("sep", "\t").option("maxFilesPerTrigger", 1).load("file:///Users/aurobindosarkar/Downloads/make-ipinyou-data-master/original-data/ipinyou.contest.dataset/bidfiles")

接下来,我们定义我们的查询时间间隔为20 秒,输出模式为Complete

scala> val streamingCountsDF = streamingInputDF.groupBy($"city").count() 

scala> val query = streamingCountsDF.writeStream.format("console").trigger(ProcessingTime(20.seconds)).queryName("counts").outputMode(Complete).start()

在输出中,您将观察到每个区域的出价数量在每个时间间隔中随着新数据的到达而更新。您需要将新的出价文件(或者从原始数据集中开始使用多个出价文件,它们将根据maxFilesPerTrigger的值依次被处理)放入bidfiles目录中,以查看更新后的结果:

此外,您还可以查询系统中的活动流,如下所示:

scala> spark.streams.active.foreach(println) 
Streaming Query - counts [state = ACTIVE]

最后,您可以使用stop()方法停止流应用程序的执行,如下所示:

//Execute the stop() function after you have finished executing the code in the next section.
scala> query.stop()

在下一节中,我们将从概念上描述结构化流的内部工作原理。

理解结构化流的内部机制

为了启用结构化流功能,规划器会从源中轮询新数据,并在写入到接收器之前对其进行增量计算。此外,应用程序所需的任何运行聚合都将作为由Write-Ahead LogWAL)支持的内存状态进行维护。内存状态数据是在增量执行中生成和使用的。这类应用程序的容错需求包括能够恢复和重放系统中的所有数据和元数据。规划器在执行之前将偏移量写入到持久存储(如 HDFS)上的容错 WAL 中,如图所示:

如果规划器在当前的增量执行中失败,重新启动的规划器将从 WAL 中读取并重新执行所需的确切偏移范围。通常,诸如 Kafka 之类的源也是容错的,并且在规划器恢复的适当偏移量的情况下生成原始事务数据。状态数据通常在 Spark 工作节点中以版本化的键值映射形式进行维护,并由 HDFS 上的 WAL 支持。规划器确保使用正确的状态版本来重新执行故障后的事务。此外,接收器在设计上是幂等的,并且可以处理输出的重复执行而不会出现重复提交。因此,偏移跟踪在 WAL 中,状态管理以及容错源和接收器的整体组合提供了端到端的精确一次性保证。

我们可以使用explain方法列出结构化流示例的物理计划,如下所示:

scala> spark.streams.active(0).explain 

我们将在第十一章中更详细地解释上述输出,调整 Spark SQL 组件以提高性能

总结

在本章中,我们向您介绍了 Spark SQL、SparkSession(Spark SQL 的主要入口点)和 Spark SQL 接口(RDD、DataFrames 和 Dataset)。然后,我们描述了 Spark SQL 的一些内部机制,包括基于 Catalyst 和 Project Tungsten 的优化。最后,我们探讨了如何在流应用程序中使用 Spark SQL 以及结构化流的概念。本章的主要目标是让您了解 Spark SQL 的概况,同时通过实际操作(使用公共数据集)让您熟悉 Spark 环境。

在下一章中,我们将详细介绍如何使用 Spark SQL 来探索大数据应用程序中典型的结构化和半结构化数据。

第二章:使用 Spark SQL 处理结构化和半结构化数据

在本章中,我们将介绍如何使用 Spark SQL 与不同类型的数据源和数据存储格式。Spark 提供了易于使用的标准结构(即 RDD 和 DataFrame/Datasets),可用于处理结构化和半结构化数据。我们包括一些在大数据应用中最常用的数据源,如关系数据、NoSQL 数据库和文件(CSV、JSON、Parquet 和 Avro)。Spark 还允许您定义和使用自定义数据源。本章中的一系列实践练习将使您能够使用 Spark 处理不同类型的数据源和数据格式。

在本章中,您将学习以下主题:

  • 了解 Spark 应用中的数据源

  • 使用 JDBC 与关系数据库交互

  • 使用 Spark 与 MongoDB(NoSQL 数据库)

  • 处理 JSON 数据

  • 使用 Spark 与 Avro 和 Parquet 数据集

了解 Spark 应用中的数据源

Spark 可以连接到许多不同的数据源,包括文件、SQL 和 NoSQL 数据库。一些更受欢迎的数据源包括文件(CSV、JSON、Parquet、AVRO)、MySQL、MongoDB、HBase 和 Cassandra。

此外,它还可以连接到专用引擎和数据源,如 ElasticSearch、Apache Kafka 和 Redis。这些引擎可以在 Spark 应用中实现特定功能,如搜索、流处理、缓存等。例如,Redis 可以在高性能应用中部署缓存的机器学习模型。我们将在第十二章中讨论更多关于基于 Redis 的应用部署的内容,即大规模应用架构中的 Spark SQL。Kafka 在 Spark 流处理应用中非常受欢迎,我们将在第五章和第十二章中详细介绍基于 Kafka 的流处理应用,即在流处理应用中使用 Spark SQL大规模应用架构中的 Spark SQL。DataSource API 使 Spark 能够连接到各种数据源,包括自定义数据源。

请参考 Spark 软件包网站spark-packages.org/,以使用各种数据源、算法和专用数据集。

在第一章中,开始使用 Spark SQL,我们使用文件系统上的 CSV 和 JSON 文件作为输入数据源,并使用 SQL 进行查询。但是,使用 Spark SQL 查询存储在文件中的数据并不是使用数据库的替代品。最初,一些人使用 HDFS 作为数据源,因为使用 Spark SQL 查询此类数据的简单性和便利性。然而,执行性能可能会根据执行的查询和工作负载的性质而有显著差异。架构师和开发人员需要了解使用哪些数据存储来最好地满足其处理需求。我们将在下面讨论选择 Spark 数据源的一些高级考虑因素。

选择 Spark 数据源

文件系统是存储大量数据和支持大型数据集通用处理的理想场所。使用文件的一些好处包括廉价的存储、灵活的处理和可扩展性。将大规模数据存储在文件中的决定通常是由商业数据库存储同样数据的成本限制所驱动的。此外,当数据的性质不适合典型的数据库优化时,例如非结构化数据时,通常也会优先选择文件存储。此外,具有迭代内存处理需求和分布式算法的工作负载,例如机器学习应用,可能更适合在分布式文件系统上运行。

通常在文件系统上存储的数据类型包括归档数据、非结构化数据、大规模社交媒体和其他网络规模数据集,以及主要数据存储的备份副本。最适合在文件上支持的工作负载类型包括批处理工作负载、探索性数据分析、多阶段处理管道和迭代工作负载。使用文件的热门案例包括 ETL 管道、跨多种数据源拼接数据,如日志文件、CSV、Parquet、压缩文件格式等。此外,您可以选择以针对特定处理需求进行优化的多种格式存储相同的数据。

与 Spark 连接到文件系统不太适合的是频繁的随机访问、频繁的插入、频繁/增量更新以及在多用户情况下承受重负载条件下的报告或搜索操作。随着我们的深入,将更详细地讨论这些使用案例。

在 Spark 中支持从分布式存储中选择少量记录的查询,但效率不高,因为通常需要 Spark 浏览所有文件以找到结果行。这对于数据探索任务可能是可以接受的,但对于来自多个并发用户的持续处理负载则不行。如果您需要频繁和随机地访问数据,使用数据库可能是更有效的解决方案。使用传统的 SQL 数据库使数据可用于用户,并在关键列上创建索引可以更好地支持这种使用案例。另外,键值 NoSQL 存储也可以更有效地检索键的值。

每次插入都会创建一个新文件,插入速度相当快,但查询速度较慢,因为 Spark 作业需要打开所有这些文件并从中读取以支持查询。同样,用于支持频繁插入的数据库可能是更好的解决方案。另外,您还可以定期压缩 Spark SQL 表文件,以减少总文件数量。使用Select *coalesce DataFrame 命令,将从多个输入文件创建的 DataFrame 中的数据写入单个/组合输出文件。

其他操作和使用案例,如频繁/增量更新、报告和搜索,最好使用数据库或专门的引擎来处理。文件不适合更新随机行。然而,数据库非常适合执行高效的更新操作。您可以将 Spark 连接到 HDFS 并使用 BI 工具,如 Tableau,但最好将数据转储到数据库以为承受负载的并发用户提供服务。通常,最好使用 Spark 读取数据,执行聚合等操作,然后将结果写入为最终用户提供服务的数据库。在搜索使用案例中,Spark 将需要浏览每一行以查找并返回搜索结果,从而影响性能。在这种情况下,使用专门的引擎,如 ElasticSearch 和 Apache Solr,可能比使用 Spark 更好。

在数据严重倾斜的情况下,或者在集群上执行更快的连接时,我们可以使用集群或分桶技术来提高性能。

使用 Spark 与关系数据库

关于关系数据库是否适合大数据处理场景存在着巨大的争论。然而,不可否认的是,企业中大量结构化数据存储在这些数据库中,并且组织在关键业务交易中严重依赖现有的关系数据库管理系统。

绝大多数开发人员最喜欢使用关系数据库和主要供应商提供的丰富工具集。越来越多的云服务提供商,如亚马逊 AWS,已经简化了许多组织将其大型关系数据库转移到云端的管理、复制和扩展。

关系数据库的一些很好的大数据使用案例包括以下内容:

  • 复杂的 OLTP 事务

  • 需要 ACID 合规性的应用程序或功能

  • 支持标准 SQL

  • 实时自发查询功能

  • 实施许多复杂关系的系统

有关 NoSQL 和关系使用情况的出色覆盖,请参阅标题为“你到底在使用 NoSQL 做什么?”的博客highscalability.com/blog/2010/12/6/what-the-heck-are-you-actually-using-nosql-for.html

在 Spark 中,很容易处理关系数据并将其与不同形式和格式的其他数据源结合起来:

作为使用 Spark 与 MySQL 数据库的示例,我们将实现一个用例,其中我们将数据在 HDFS 和 MySQL 之间进行分割。MySQL 数据库将用于支持来自并发用户的交互式查询,而 HDFS 上的数据将用于批处理、运行机器学习应用程序以及向 BI 工具提供数据。在此示例中,我们假设交互式查询仅针对当前月份的数据。因此,我们将只保留当前月份的数据在 MySQL 中,并将其余数据写入 HDFS(以 JSON 格式)。

我们将遵循的实施步骤如下:

  1. 创建 MySQL 数据库。

  2. 定义一个表。

  3. 创建用户 ID 并授予权限。

  4. 使用 MySQL JDBC 驱动程序启动 Spark shell。

  5. 从输入数据文件创建一个 RDD,分离标题,定义模式并创建一个 DataFrame。

  6. 为时间戳创建一个新列。

  7. 根据时间戳值(当前月份数据和以前月份的其余数据)将数据分成两个 DataFrame。

  8. 删除原始 invoiceDate 列,然后将时间戳列重命名为 invoiceDate。

  9. 将包含当前月份数据的 DataFrame 写入 MySQL 表中。

  10. 将包含数据(除当前月份数据之外的数据)的 DataFrame 写入 HDFS(以 JSON 格式)。

如果您尚未安装和可用 MySQL,可以从www.mysql.com/downloads/下载。按照特定操作系统的安装说明安装数据库。此外,从同一网站下载可用的 JDBC 连接器。

在您的 MySQL 数据库服务器运行起来后,启动 MySQL shell。在接下来的步骤中,我们将创建一个新数据库并定义一个交易表。我们使用一个包含所有发生在 2010 年 12 月 1 日至 2011 年 12 月 9 日之间的交易的交易数据集,这是一个基于英国注册的非实体在线零售的数据集。该数据集由伦敦南岸大学工程学院公共分析小组主任 Dr Daqing Chen 贡献,并可在archive.ics.uci.edu/ml/datasets/Online+Retail上找到。

当您启动 MySQL shell 时,应该看到类似以下的屏幕:

  1. 创建一个名为retailDB的新数据库来存储我们的客户交易数据:
      mysql> create database retailDB;
      Connect to retailDB as follows:
      mysql> use retailDB;
  1. 在这里,我们使用transactionID作为主键定义了一个交易表。在生产场景中,您还将在其他字段上创建索引,例如CustomerID,以更有效地支持查询:
      mysql>create table transactions(transactionID integer not null 
      auto_increment, invoiceNovarchar(20), stockCodevarchar(20), 
      description varchar(255), quantity integer, unitPrice double, 
      customerIDvarchar(20), country varchar(100), invoiceDate 
      Timestamp, primary key(transactionID));

接下来,使用describe命令验证交易表模式,以确保它完全符合我们的要求:

mysql> describe transactions;

  1. 创建一个名为retaildbuser的用户 ID 并授予其所有权限。我们将从我们的 Spark shell 中使用此用户进行连接和执行查询。
      mysql> CREATE USER 'retaildbuser'@'localhost' IDENTIFIED BY 
             'mypass';
      mysql> GRANT ALL ON retailDB.* TO 'retaildbuser'@'localhost';
  1. 启动包含 MySQL JDBC 驱动程序路径的 Spark shell,如下所示:
      SPARK_CLASSPATH=/Users/aurobindosarkar/Downloads/mysql-connector-
      java-5.1.38/mysql-connector-java-5.1.38-bin.jar bin/spark-shell
  1. 创建一个包含我们下载的数据集中所有行的RDD
      scala> import org.apache.spark.sql.types._
      scala> import org.apache.spark.sql.Row
      scala> import java.util.Properties

      scala>val inFileRDD =       
      sc.textFile("file:///Users/aurobindosarkar/Downloads/UCI Online  
      Retail.txt")
  1. 将标题与其余数据分开:
      scala>val allRowsRDD = inFileRDD.map(line 
      =>line.split("\t").map(_.trim))
      scala>val header = allRowsRDD.first
      scala>val data = allRowsRDD.filter(_(0) != header(0))
  1. 定义字段并为我们的数据记录定义模式,如下所示:
      scala>val fields = Seq(
      | StructField("invoiceNo", StringType, true),
      | StructField("stockCode", StringType, true),
      | StructField("description", StringType, true),
      | StructField("quantity", IntegerType, true),
      | StructField("invoiceDate", StringType, true),
      | StructField("unitPrice", DoubleType, true),
      | StructField("customerID", StringType, true),
      | StructField("country", StringType, true)
      | )
      scala>val schema = StructType(fields)
  1. 创建一个包含 Row 对象的RDD,使用先前创建的模式创建一个 DataFrame:
      scala>val rowRDD = data.map(attributes => Row(attributes(0), 
      attributes(1), attributes(2), attributes(3).toInt, attributes(4), 
      attributes(5).toDouble, attributes(6), attributes(7)))

      scala>val r1DF = spark.createDataFrame(rowRDD, schema)
  1. 向 DataFrame 添加名为ts(时间戳列)的列,如下所示:
      scala>val ts = 
      unix_timestamp($"invoiceDate","dd/MM/yyHH:mm").cast("timestamp")
      scala>val r2DF = r1DF.withColumn("ts", ts)
      scala>r2DF.show()

  1. 创建一个表对象,并执行适当的 SQL 将表数据基于时间戳分成两个 DataFrame:
      scala> r2DF.createOrReplaceTempView("retailTable")
      scala>val r3DF = spark.sql("select * from retailTable where ts< 
      '2011-12-01'")
      scala>val r4DF = spark.sql("select * from retailTable where ts>= 
      '2011-12-01'")
  1. 删除我们新 DataFrame 中的invoiceDate列。
      scala>val selectData = r4DF.select("invoiceNo", "stockCode", 
      "description", "quantity", "unitPrice", "customerID", "country", 
      "ts")
  1. ts列重命名为invoiceDate,如下所示:
      scala>val writeData = selectData.withColumnRenamed("ts", 
      "invoiceDate")
      scala>writeData.show()

  1. 创建一个指向数据库 URL 的变量。另外,创建一个Properties对象来保存连接到retailDB所需的用户 ID 和密码。接下来,连接到 MySQL 数据库,并将“当前月份”的记录插入到 transactions 表中:
      scala>val dbUrl = "jdbc:mysql://localhost:3306/retailDB"
      scala>val prop = new Properties()
      scala>prop.setProperty("user", "retaildbuser")
      scala>prop.setProperty("password", "mypass")
      scala>writeData.write.mode("append").jdbc(dbUrl, "transactions", 
      prop)
  1. 从 DataFrame 中选择感兴趣的列(包含当前月份以外的数据),并以 JSON 格式将其写入 HDFS 文件系统:
      scala>val selectData = r3DF.select("invoiceNo", "stockCode", 
      "description", "quantity", "unitPrice", "customerID", "country", 
      "ts")

      scala>val writeData = selectData.withColumnRenamed("ts", 
      "invoiceDate")
      scala>writeData.select("*").write.format("json")
      .save("hdfs://localhost:9000/Users/r3DF")

使用 Spark 处理 MongoDB(NoSQL 数据库)

在本节中,我们将使用 Spark 与最流行的 NoSQL 数据库之一 - MongoDB。 MongoDB 是一个分布式文档数据库,以类似 JSON 的格式存储数据。与关系数据库中的严格模式不同,MongoDB 中的数据结构更加灵活,存储的文档可以具有任意字段。这种灵活性与高可用性和可扩展性功能结合在一起,使其成为许多应用程序中存储数据的良好选择。它还是免费和开源软件。

如果您尚未安装和可用 MongoDB,则可以从www.mongodb.org/downloads下载。按照特定操作系统的安装说明安装数据库。

本示例的纽约市学校目录数据集来自纽约市开放数据网站,可从nycplatform.socrata.com/data?browseSearch=&scope=&agency=&cat=education&type=datasets下载。

在您的 MongoDB 数据库服务器运行后,启动 MongoDB shell。在接下来的步骤中,我们将创建一个新数据库,定义一个集合,并使用命令行中的 MongoDB 导入实用程序插入纽约市学校的数据。

当您启动 MongoDB shell 时,应该看到类似以下的屏幕:

接下来,执行use <DATABASE>命令选择现有数据库或创建一个新数据库(如果不存在)。

如果在创建新集合时出现错误,可以使用db.dropDatabase()和/或db.collection.drop()命令分别删除数据库和/或集合,然后根据需要重新创建它。

>use nycschoolsDB
switched to dbnycschoolsDB

mongoimport实用程序需要从命令提示符(而不是mongodb shell)中执行:


mongoimport --host localhost --port 27017 --username <your user name here> --password "<your password here>" --collection schools --db nycschoolsDB --file <your download file name here>

您可以列出导入的集合并打印记录以验证导入操作,如下所示:

>show collections
 schools
 >db.schools.findOne()

您可以从repo1.maven.org/maven2/org/mongodb/spark/mongo-spark-connector_2.11/2.2.0/下载适用于 Spark 2.2 的mongo-spark-connector jarmongo-spark-connector_2.11-2.2.0-assembly.jar)。

接下来,使用命令行指定mongo-spark-connector_2.11-2.2.0-assembly.jar文件启动 Spark shell:

./bin/spark-shell --jars /Users/aurobindosarkar/Downloads/mongo-spark-connector_2.11-2.2.0-assembly.jar
scala> import org.apache.spark.sql.SQLContext
scala> import org.apache.spark.{SparkConf, SparkContext}
scala> import com.mongodb.spark.MongoSpark
scala> import com.mongodb.spark.config.{ReadConfig, WriteConfig}

接下来,我们定义了从 Spark 进行readwrite操作的 URI:

scala>val readConfig = ReadConfig(Map("uri" -> "mongodb://localhost:27017/nycschoolsDB.schools?readPreference=primaryPreferred"))

scala>val writeConfig = WriteConfig(Map("uri" -> "mongodb://localhost:27017/nycschoolsDB.outCollection"))

定义一个学校记录的case类,如下所示:

接下来,您可以从我们的集合创建一个 DataFrame,并显示新创建的 DataFrame 中的记录。

scala>val schoolsDF = MongoSpark.load(sc, readConfig).toDF[School]

scala>schoolsDF.take(1).foreach(println)

注意:以下各节将在稍后使用最新版本的连接器包进行更新。

在接下来的几节中,我们将描述使用 Spark 处理几种流行的大数据文件格式。

使用 Spark 处理 JSON 数据

JSON 是一种简单、灵活和紧凑的格式,在 Web 服务中广泛用作数据交换格式。Spark 对 JSON 的支持非常好。不需要为 JSON 数据定义模式,因为模式会自动推断。此外,Spark 极大地简化了访问复杂 JSON 数据结构中字段所需的查询语法。我们将在第十二章《大规模应用架构中的 Spark SQL》中详细介绍 JSON 数据的示例。

此示例的数据集包含大约 169 万条电子产品类别的亚马逊评论,可从以下网址下载:jmcauley.ucsd.edu/data/amazon/

我们可以直接读取 JSON 数据集以创建 Spark SQL DataFrame。我们将从 JSON 文件中读取一组订单记录的示例集:

scala>val reviewsDF = spark.read.json("file:///Users/aurobindosarkar/Downloads/reviews_Electronics_5.json")

您可以使用printSchema方法打印新创建的 DataFrame 的模式,以验证字段及其特性。

scala> reviewsDF.printSchema()

一旦 JSON 数据集转换为 Spark SQL DataFrame,您可以以标准方式进行大量操作。接下来,我们将执行 SQL 语句,从特定年龄段的客户接收的订单中选择特定列:

scala>reviewsDF.createOrReplaceTempView("reviewsTable")
scala>val selectedDF = spark.sql("SELECT asin, overall, reviewTime, reviewerID, reviewerName FROM reviewsTable WHERE overall >= 3")

使用show方法显示 SQL 执行结果(存储在另一个 DataFrame 中),如下所示:

scala> selectedDF.show()

我们可以使用 DSL 访问reviewDF DataFrame 中helpful列的数组元素,如下所示:

scala> val selectedJSONArrayElementDF = reviewsDF.select($"asin", $"overall", $"helpful").where($"helpful".getItem(0) < 3)

scala>selectedJSONArrayElementDF.show()

在前面的部分中,我们演示了将 DataFrame 写出为 JSON 文件的示例,其中我们从 DataFrame 中选择了感兴趣的列(包含当前月份之外的数据),并将其写出为 JSON 格式到 HDFS 文件系统。

使用 Avro 文件的 Spark

Avro 是一个非常流行的数据序列化系统,提供了紧凑和快速的二进制数据格式。Avro 文件是自描述的,因为模式与数据一起存储。

您可以从mvnrepository.com/artifact/com.databricks/spark-avro_2.11/3.2.0下载spark-avro connector JAR。

我们将在本节切换到 Spark 2.1。在撰写本书时,由于spark-avro connector库中的已记录的错误,我们在使用spark-avro connector 3.2与 Spark 2.2 时遇到异常。

启动包含 spark-avro JAR 的 Spark shell 会话:

Aurobindos-MacBook-Pro-2:spark-2.1.0-bin-hadoop2.7 aurobindosarkar$ bin/spark-shell --jars /Users/aurobindosarkar/Downloads/spark-avro_2.11-3.2.0.jar

我们将使用前一节中包含亚马逊评论数据的 JSON 文件来创建Avro文件。从输入 JSON 文件创建一个 DataFrame,并显示记录数:

scala> import com.databricks.spark.avro._
scala> val reviewsDF = spark.read.json("file:///Users/aurobindosarkar/Downloads/reviews_Electronics_5.json")

scala> reviewsDF.count()
res4: Long = 1689188  

接下来,我们过滤所有评分低于3的评论,将输出合并为单个文件,并将结果 DataFrame 写出为Avro文件:

scala> reviewsDF.filter("overall < 3").coalesce(1).write.avro("file:///Users/aurobindosarkar/Downloads/amazon_reviews/avro")

接下来,我们展示如何通过从上一步创建的Avro文件创建一个 DataFrame 来读取Avro文件,并显示其中的记录数:

scala> val reviewsAvroDF = spark.read.avro("file:///Users/aurobindosarkar/Downloads/amazon_reviews/avro/part-00000-c6b6b423-70d6-440f-acbe-0de65a6a7f2e.avro")

scala> reviewsAvroDF.count()
res5: Long = 190864

接下来,我们选择几列,并通过指定show(5)显示结果 DataFrame 的前五条记录:

scala> reviewsAvroDF.select("asin", "helpful", "overall", "reviewTime", "reviewerID", "reviewerName").show(5)

接下来,通过设置 Spark 会话配置值为Avro文件指定压缩选项:

scala> spark.conf.set("spark.sql.avro.compression.codec", "deflate")
scala> spark.conf.set("spark.sql.avro.deflate.level", "5")

现在,当我们写入 DataFrame 时,Avro文件以压缩格式存储:

scala> val reviewsAvroDF = spark.read.avro("file:////Users/aurobindosarkar/Downloads/amazon_reviews/avro/part-00000-c6b6b423-70d6-440f-acbe-0de65a6a7f2e.avro")

您还可以按特定列对 DataFrame 进行分区。在这里,我们基于overall列(每行包含值<3)进行分区:

scala> reviewsAvroDF.write.partitionBy("overall").avro("file:////Users/aurobindosarkar/Downloads/amazon_reviews/avro/partitioned")

此会话中 Avro 文件的屏幕截图显示在此处。请注意压缩版本(67 MB)与原始文件(97.4 MB)的大小。此外,请注意为分区(按overall值)Avro文件创建的两个单独目录。

有关spark-avro的更多详细信息,请参阅:github.com/databricks/spark-avro

使用 Parquet 文件的 Spark

Apache Parquet 是一种流行的列存储格式。它在 Hadoop 生态系统中的许多大数据应用程序中使用。Parquet 支持非常高效的压缩和编码方案,可以显著提高这些应用程序的性能。在本节中,我们向您展示了您可以直接将 Parquet 文件读入标准 Spark SQL DataFrame 的简单性。

在这里,我们使用之前从 Amazon 评论的 JSON 格式文件中创建的 reviewsDF,并将其以 Parquet 格式写出,以创建 Parquet 文件。我们使用coalesce(1)来创建一个单一的输出文件:

scala> reviewsDF.filter("overall < 3").coalesce(1).write.parquet("file:///Users/aurobindosarkar/Downloads/amazon_reviews/parquet")

在下一步中,我们使用一个语句从 Parquet 文件创建一个 DataFrame:

scala> val reviewsParquetDF = spark.read.parquet("file:///Users/aurobindosarkar/Downloads/amazon_reviews/parquet/part-00000-3b512935-ec11-48fa-8720-e52a6a29416b.snappy.parquet")

创建 DataFrame 后,您可以像处理来自任何其他数据源创建的 DataFrame 一样对其进行操作。在这里,我们将 DataFrame 注册为临时视图,并使用 SQL 进行查询:

scala> reviewsParquetDF.createOrReplaceTempView("reviewsTable")
scala> val reviews1RatingsDF = spark.sql("select asin, overall, reviewerID, reviewerName from reviewsTable where overall < 2")

在这里,我们指定了两个参数来显示结果 DataFrame 中的记录。第一个参数指定要显示的记录数,第二个参数的值为 false 时显示列中的完整值(不截断)。

scala> reviews1RatingsDF.show(5, false)

在 Spark 中定义和使用自定义数据源

您可以定义自己的数据源,并将这些数据源的数据与其他更标准的数据源(例如关系数据库、Parquet 文件等)的数据结合起来。在第五章中,在流应用中使用 Spark SQL,我们为从伦敦交通(TfL)网站提供的公共 API 中流式数据定义了一个自定义数据源。

参考视频Spark DataFrames Simple and Fast Analysis of Structured Data - Michael Armbrust (Databricks) www.youtube.com/watch?v=xWkJCUcD55w 中定义 Jira 数据源并从中创建 Spark SQL DataFrame 的良好示例。

总结

在本章中,我们演示了使用 Spark 与各种数据源和数据格式。我们使用 Spark 来处理关系数据库(MySQL)、NoSQL 数据库(MongoDB)、半结构化数据(JSON)以及在 Hadoop 生态系统中常用的数据存储格式(Avro 和 Parquet)。这为您非常好地准备了接下来更高级的 Spark 应用程序导向章节。

在下一章中,我们将把焦点从处理 Spark 的机制转移到如何使用 Spark SQL 来探索数据、执行数据质量检查和可视化数据。

第三章:使用 Spark SQL 进行数据探索

在本章中,我们将介绍如何使用 Spark SQL 进行探索性数据分析。我们将介绍计算一些基本统计数据、识别异常值和可视化、抽样和透视数据的初步技术。本章中的一系列实践练习将使您能够使用 Spark SQL 以及 Apache Zeppelin 等工具来开发对数据的直觉。

在本章中,我们将讨论以下主题:

  • 什么是探索性数据分析(EDA)

  • 为什么 EDA 很重要?

  • 使用 Spark SQL 进行基本数据分析

  • 使用 Apache Zeppelin 可视化数据

  • 使用 Spark SQL API 对数据进行抽样

  • 使用 Spark SQL 创建透视表

引入探索性数据分析(EDA)

探索性数据分析(EDA)或初始数据分析(IDA)是一种试图最大程度地洞察数据的数据分析方法。这包括评估数据的质量和结构,计算摘要或描述性统计数据,并绘制适当的图表。它可以揭示潜在的结构,并建议如何对数据进行建模。此外,EDA 帮助我们检测数据中的异常值、错误和异常,并决定如何处理这些数据通常比其他更复杂的分析更重要。EDA 使我们能够测试我们的基本假设,发现数据中的聚类和其他模式,并确定各种变量之间可能的关系。仔细的 EDA 过程对于理解数据至关重要,有时足以揭示数据质量差劣,以至于使用基于模型的更复杂分析是不合理的。

典型情况下,探索性数据分析(EDA)中使用的图形技术是简单的,包括绘制原始数据和简单的统计。重点是数据所揭示的结构和模型,或者最适合数据的模型。EDA 技术包括散点图、箱线图、直方图、概率图等。在大多数 EDA 技术中,我们使用所有数据,而不做任何基本假设。分析师通过这种探索建立直觉或获得对数据集的“感觉”。更具体地说,图形技术使我们能够有效地选择和验证适当的模型,测试我们的假设,识别关系,选择估计量,检测异常值等。

EDA 涉及大量的试错和多次迭代。最好的方法是从简单开始,然后随着进展逐渐增加复杂性。在建模中存在着简单和更准确之间的重大折衷。简单模型可能更容易解释和理解。这些模型可以让您很快达到 90%的准确性,而更复杂的模型可能需要几周甚至几个月才能让您获得额外的 2%的改进。例如,您应该绘制简单的直方图和散点图,以快速开始对数据进行直觉开发。

使用 Spark SQL 进行基本数据分析

交互式地处理和可视化大型数据是具有挑战性的,因为查询可能需要很长时间才能执行,而可视化界面无法容纳与数据点一样多的像素。Spark 支持内存计算和高度的并行性,以实现与大规模分布式数据的交互性。此外,Spark 能够处理百万亿字节的数据,并提供一组多功能的编程接口和库。这些包括 SQL、Scala、Python、Java 和 R API,以及用于分布式统计和机器学习的库。

对于适合放入单台计算机的数据,有许多好的工具可用,如 R、MATLAB 等。然而,如果数据不适合放入单台计算机,或者将数据传输到该计算机非常复杂,或者单台计算机无法轻松处理数据,那么本节将提供一些用于数据探索的好工具和技术。

在本节中,我们将进行一些基本的数据探索练习,以了解一个样本数据集。我们将使用一个包含与葡萄牙银行机构的直接营销活动(电话营销)相关数据的数据集。这些营销活动是基于对客户的电话呼叫。我们将使用包含 41,188 条记录和 20 个输入字段的bank-additional-full.csv文件,按日期排序(从 2008 年 5 月到 2010 年 11 月)。该数据集由 S. Moro、P. Cortez 和 P. Rita 贡献,并可从archive.ics.uci.edu/ml/datasets/Bank+Marketing下载。

  1. 首先,让我们定义一个模式并读取 CSV 文件以创建一个数据框架。您可以使用:paste命令将初始一组语句粘贴到您的 Spark shell 会话中(使用Ctrl+D退出粘贴模式),如下所示:

  1. 创建了数据框架之后,我们首先验证记录的数量:

  1. 我们还可以为我们的输入记录定义一个名为Callcase类,然后创建一个强类型的数据集,如下所示:

在下一节中,我们将通过识别数据集中的缺失数据来开始我们的数据探索。

识别缺失数据

数据集中的缺失数据可能是由于从疏忽到受访者拒绝提供特定数据点的原因而导致的。然而,在所有情况下,缺失数据都是真实世界数据集中的常见现象。缺失数据可能会在数据分析中造成问题,有时会导致错误的决策或结论。因此,识别缺失数据并制定有效的处理策略非常重要。

在本节中,我们分析了样本数据集中具有缺失数据字段的记录数量。为了模拟缺失数据,我们将编辑我们的样本数据集,将包含“unknown”值的字段替换为空字符串。

首先,我们从我们编辑的文件中创建了一个数据框架/数据集,如下所示:

以下两个语句给出了具有某些字段缺失数据的行数:

在第四章中,使用 Spark SQL 进行数据整理,我们将探讨处理缺失数据的有效方法。在下一节中,我们将计算样本数据集的一些基本统计数据,以改善我们对数据的理解。

计算基本统计数据

计算基本统计数据对于对我们的数据有一个良好的初步了解是至关重要的。首先,为了方便起见,我们创建了一个案例类和一个数据集,其中包含来自我们原始数据框架的一部分字段。在以下示例中,我们选择了一些数值字段和结果字段,即“订阅定期存款”的字段:

接下来,我们使用describe()计算数据集中数值列的countmeanstdevminmax值。describe()命令提供了一种快速检查数据的方法。例如,所选列的行数与数据框架中的总记录数匹配(没有空值或无效行),年龄列的平均值和值范围是否符合您的预期等。根据平均值和标准差的值,您可以选择某些数据元素进行更深入的分析。例如,假设正态分布,年龄的平均值和标准差值表明大多数年龄值在 30 到 50 岁的范围内,对于其他列,标准差值可能表明数据的偏斜(因为标准差大于平均值)。

此外,我们可以使用 stat 包计算额外的统计数据,如协方差和 Pearson 相关系数。协方差表示两个随机变量的联合变异性。由于我们处于 EDA 阶段,这些测量可以为我们提供有关一个变量如何相对于另一个变量变化的指标。例如,协方差的符号表示两个变量之间变异性的方向。在以下示例中,年龄和最后一次联系的持续时间之间的协方差方向相反,即随着年龄的增加,持续时间减少。相关性给出了这两个变量之间关系强度的大小。

我们可以创建两个变量之间的交叉表或交叉表,以评估它们之间的相互关系。例如,在以下示例中,我们创建了一个代表 2x2 列联表的年龄和婚姻状况的交叉表。从表中,我们了解到,对于给定年龄,各种婚姻状况下的个体总数的分布情况。我们还可以提取数据 DataFrame 列中最频繁出现的项目。在这里,我们选择教育水平作为列,并指定支持水平为0.3,即我们希望在 DataFrame 中找到出现频率大于0.3(至少观察到 30%的时间)的教育水平。最后,我们还可以计算 DataFrame 中数值列的近似分位数。在这里,我们计算年龄列的分位数概率为0.250.50.75(值为0是最小值,1是最大值,0.5是中位数)。

接下来,我们使用聚合函数对我们的数据进行汇总,以更好地了解它。在以下语句中,我们按是否订阅定期存款以及联系的客户总数、每位客户平均拨打电话次数、通话平均持续时间和向这些客户拨打的平均上次电话次数进行聚合。结果四舍五入到小数点后两位:

同样,执行以下语句会按客户年龄给出类似的结果:

在通过计算基本统计数据更好地了解我们的数据之后,我们将重点转向识别数据中的异常值。

识别数据异常值

异常值或异常值是数据中明显偏离数据集中其他观察值的观察值。这些错误的异常值可能是由于数据收集中的错误或测量的变异性。它们可能会对结果产生重大影响,因此在 EDA 过程中识别它们至关重要。

然而,这些技术将异常值定义为不属于簇的点。用户必须使用统计分布对数据点进行建模,并根据它们在与基础模型的关系中的出现方式来识别异常值。这些方法的主要问题是在 EDA 过程中,用户通常对基础数据分布没有足够的了解。

使用建模和可视化方法进行探索性数据分析(EDA)是获得对数据更深刻理解的好方法。Spark MLlib 支持大量(并不断增加)的分布式机器学习算法,使这项任务变得更简单。例如,我们可以应用聚类算法并可视化结果,以检测组合列中的异常值。在以下示例中,我们使用最后一次联系持续时间(以秒为单位)、在此客户(campaign)的此次活动期间执行的联系次数、在上一次活动期间客户最后一次联系后经过的天数(pdays)和在此客户(prev)的此次活动之前执行的联系次数来应用 k 均值聚类算法在我们的数据中计算两个簇:

用于探索性数据分析的其他分布式算法包括分类、回归、降维、相关性和假设检验。有关使用 Spark SQL 和这些算法的更多细节,请参阅第六章中的在机器学习应用中使用 Spark SQL

使用 Apache Zeppelin 可视化数据

通常,我们会生成许多图表来验证我们对数据的直觉。在探索性数据分析期间使用的许多快速而肮脏的图表最终被丢弃。探索性数据可视化对于数据分析和建模至关重要。然而,我们经常因为难以处理而跳过大数据的探索性可视化。例如,浏览器通常无法处理数百万个数据点。因此,我们必须在有效可视化数据之前对数据进行总结、抽样或建模。

传统上,BI 工具提供了广泛的聚合和透视功能来可视化数据。然而,这些工具通常使用夜间作业来总结大量数据。随后,总结的数据被下载并在从业者的工作站上可视化。Spark 可以消除许多这些批处理作业,以支持交互式数据可视化。

在本节中,我们将使用 Apache Zeppelin 探索一些基本的数据可视化技术。Apache Zeppelin 是一个支持交互式数据分析和可视化的基于 Web 的工具。它支持多种语言解释器,并具有内置的 Spark 集成。因此,使用 Apache Zeppelin 进行探索性数据分析是快速而简单的:

  1. 您可以从zeppelin.apache.org/下载 Appache Zeppelin。在硬盘上解压缩软件包,并使用以下命令启动 Zeppelin:
      Aurobindos-MacBook-Pro-2:zeppelin-0.6.2-bin-all aurobindosarkar$ 
      bin/zeppelin-daemon.sh start

  1. 您应该看到以下消息:
      Zeppelin start                                           [ OK  ] 
  1. 您应该能够在http://localhost:8080/看到 Zeppelin 主页:

  1. 单击“创建新笔记”链接,并指定笔记本的路径和名称,如下所示:

  2. 在下一步中,我们将粘贴本章开头的相同代码,以创建我们样本数据集的 DataFrame:

  1. 我们可以执行典型的 DataFrame 操作,如下所示:

  1. 接下来,我们从 DataFrame 创建一个表,并对其执行一些 SQL。单击所需的图表类型,可以对 SQL 语句的执行结果进行图表化。在这里,我们创建条形图,作为总结和可视化数据的示例:

  1. 我们可以创建散点图,如下图所示:

您还可以读取每个绘制点的坐标值:

  1. 此外,我们可以创建一个接受输入值的文本框,使体验更加交互式。在下图中,我们创建了一个文本框,可以接受不同的年龄参数值,并相应地更新条形图:

  1. 同样,我们还可以创建下拉列表,用户可以选择适当的选项:

表格的值或图表会自动更新:

我们将在第八章中使用 Spark SQL 和 SparkR 进行更高级的可视化。在下一节中,我们将探讨用于从数据中生成样本的方法。

使用 Spark SQL API 对数据进行抽样

通常,我们需要可视化个别数据点以了解我们数据的性质。统计学家广泛使用抽样技术进行数据分析。Spark 支持近似和精确的样本生成。近似抽样速度更快,在大多数情况下通常足够好。

在本节中,我们将探索用于生成样本的 Spark SQL API。我们将通过一些示例来演示使用 DataFrame/Dataset API 和基于 RDD 的方法生成近似和精确的分层样本,有放回和无放回。

使用 DataFrame/Dataset API 进行抽样

我们可以使用sampleBy创建一个无放回的分层样本。我们可以指定每个值被选入样本的百分比。

样本的大小和每种类型的记录数如下所示:

接下来,我们创建一个有放回的样本,选择总记录的一部分(总记录的 10%),并使用随机种子。使用sample不能保证提供数据集中总记录数的确切分数。我们还打印出样本中每种类型的记录数:

在下一节中,我们将探索使用 RDD 的抽样方法。

使用 RDD API 进行抽样

在本节中,我们使用 RDD 来创建有放回和无放回的分层样本。

首先,我们从 DataFrame 创建一个 RDD:

我们可以指定样本中每种记录类型的分数,如图所示:

在下面的示例中,我们使用sampleByKeysampleByKeyExact方法来创建我们的样本。前者是一个近似样本,而后者是一个精确样本。第一个参数指定样本是有放回还是无放回生成的:

接下来,我们打印出人口总记录数和每个样本中的记录数。您会注意到sampleByKeyExact会给出与指定分数完全相符的记录数:

sample 方法可用于创建包含指定记录分数的随机样本。接下来,我们创建一个有放回的样本,包含总记录的 10%:

其他统计操作,如假设检验、随机数据生成、可视化概率分布等,将在后面的章节中介绍。在下一节中,我们将使用 Spark SQL 来创建数据透视表来探索我们的数据。

使用 Spark SQL 创建数据透视表

数据透视表创建数据的替代视图,在数据探索过程中通常被使用。在下面的示例中,我们演示了如何使用 Spark DataFrames 进行数据透视:

下面的示例在已经采取的住房贷款上进行数据透视,并按婚姻状况计算数字:

在下一个示例中,我们创建一个 DataFrame,其中包含适当的列名,用于呼叫总数和平均呼叫次数:

在下一个示例中,我们创建一个 DataFrame,其中包含适当的列名,用于每个工作类别的呼叫总数和平均持续时间:

在下面的示例中,我们展示了数据透视,计算每个工作类别的平均呼叫持续时间,同时指定了一些婚姻状况的子集:

下一个示例与前一个相同,只是在这种情况下,我们还按住房贷款字段拆分了平均呼叫持续时间值:

接下来,我们将展示如何创建一个按月订阅的定期存款数据透视表的 DataFrame,将其保存到磁盘,并将其读取回 RDD:

此外,我们使用前面步骤中的 RDD 来计算订阅和未订阅定期贷款的季度总数:

我们将在本书的后面介绍其他类型数据的详细分析,包括流数据、大规模图形、时间序列数据等。

总结

在本章中,我们演示了使用 Spark SQL 来探索数据集,执行基本数据质量检查,生成样本和数据透视表,并使用 Apache Zeppelin 可视化数据。

在下一章中,我们将把重点转移到数据处理/整理。我们将介绍处理缺失数据、错误数据、重复记录等技术。我们还将进行大量的实践演练,演示使用 Spark SQL 处理常见数据整理任务。

第四章:使用 Spark SQL 进行数据整理

在这个代码密集的章节中,我们将介绍用于将原始数据转换为可用格式进行分析的关键数据整理技术。我们首先介绍适用于各种场景的一些通用数据整理步骤。然后,我们将把重点转移到特定类型的数据,包括时间序列数据、文本和用于 Spark MLlib 机器学习流水线的数据预处理步骤。我们将使用几个数据集来说明这些技术。

在本章中,我们将学习:

  • 什么是数据整理?

  • 探索数据整理技术

  • 使用连接合并数据

  • 文本数据整理

  • 时间序列数据整理

  • 处理可变长度记录

  • 为机器学习流水线准备数据

介绍数据整理

原始数据通常混乱不堪,需要经过一系列转换才能变得有用,用于建模和分析工作。这样的数据集可能存在缺失数据、重复记录、损坏数据、不完整记录等问题。在其最简单的形式中,数据整理或数据整理基本上是将原始数据转换为可用格式。在大多数项目中,这是最具挑战性和耗时的步骤。

然而,如果没有数据整理,您的项目可能会陷入垃圾进垃圾出的境地。

通常,您将执行一系列函数和过程,如子集、过滤、聚合、排序、合并、重塑等。此外,您还将进行类型转换、添加新字段/列、重命名字段/列等操作。

一个大型项目可能包含各种数据,数据质量不同。可能会混合使用数字、文本、时间序列、结构化和非结构化数据,包括音频和视频数据,一起或分开用于分析。这类项目的一个重要部分包括清洗和转换步骤,结合一些统计分析和可视化。

我们将使用几个数据集来演示为准备数据进行后续建模和分析所需的关键数据整理技术。以下是这些数据集及其来源:

  • 个人家庭电力消耗数据集:数据集的原始来源是法国 EDF R&D 的高级研究员 Georges Hebrail 和法国 Clamart 的 TELECOM ParisTech 工程师实习生 Alice Berard。该数据集包括近四年内一个家庭每分钟的电力消耗测量。该数据集可以从以下网址下载:

archive.ics.uci.edu/ml/datasets/Individual+household+electric+power+consumption

  • 基于机器学习的 ZZAlpha Ltd. 2012-2014 股票推荐数据集:该数据集包含了在 2012 年 1 月 1 日至 2014 年 12 月 31 日期间,每天早上针对各种美国交易的股票组合所做的推荐。该数据集可以从以下网址下载:

archive.ics.uci.edu/ml/datasets/Machine+Learning+based+ZZAlpha+Ltd.+Stock+Recommendations+2012-2014

  • 巴黎天气历史数据集:该数据集包含了巴黎的每日天气报告。我们下载了与家庭电力消耗数据集相同时间段的历史数据。该数据集可以从以下网址下载:

www.wunderground.com/history/airport/LFPG

  • 原始 20 个新闻组数据:该数据集包括来自 20 个 Usenet 新闻组的 20,000 条消息。该数据集的原始所有者和捐赠者是 Carnegie Mellon 大学计算机科学学院的 Tom Mitchell。大约每个新闻组中取了一千篇 Usenet 文章。每个新闻组存储在一个子目录中,每篇文章都存储为一个单独的文件。该数据集可以从以下网址下载:

kdd.ics.uci.edu/databases/20newsgroups/20newsgroups.html.

  • Yahoo 财务数据:该数据集包括了为期一年(从 2015 年 12 月 4 日至 2016 年 12 月 4 日)的六只股票的历史每日股价。所选股票符号的数据可以从以下网站下载:

 finance.yahoo.com/.

探索数据整理技术

在本节中,我们将介绍使用家庭电力消耗和天气数据集的几种数据整理技术。学习这些技术的最佳方法是练习操纵各种公开可用数据集中包含的数据的各种方法(除了这里使用的数据集)。你练习得越多,你就会变得越擅长。在这个过程中,你可能会发展出自己的风格,并开发出几种工具集和技术来实现你的整理目标。至少,你应该非常熟悉并能够在 RDD、DataFrame 和数据集之间进行操作,计算计数、不同计数和各种聚合,以交叉检查你的结果并匹配你对数据集的直觉理解。此外,根据执行任何给定的整理步骤的利弊来做出决策的能力也很重要。

在本节中,我们将尝试实现以下目标:

  1. 预处理家庭电力消耗数据集--读取输入数据集,为行定义 case 类,计算记录数,删除标题和包含缺失数据值的行,并创建 DataFrame。

  2. 计算基本统计数据和聚合

  3. 使用与分析相关的新信息增强数据集

  4. 执行其他必要的杂项处理步骤

  5. 预处理天气数据集--类似于步骤 1

  6. 分析缺失数据

  7. 使用 JOIN 合并数据集并分析结果

此时启动 Spark shell,并随着阅读本节和后续节的内容进行操作。

导入本节中使用的所有必需类:

家庭电力消耗数据集的预处理

为家庭电力消耗创建一个名为HouseholdEPCcase类:

将输入数据集读入 RDD 并计算其中的行数。

接下来,删除标题和包含缺失值的所有其他行(在输入中表示为?),如下面的步骤所示:

在下一步中,将RDD [String]转换为我们之前定义的case类的RDD,并将 RDD 转换为HouseholdEPC对象的 DataFrame。

在 DataFrame 中显示一些样本记录,并计算其中的行数,以验证 DataFrame 中的行数是否与输入数据集中预期的行数匹配。

计算基本统计数据和聚合

接下来,计算并显示 DataFrame 中数值列的一些基本统计数据,以了解我们将要处理的数据。

我们还可以显示一些或所有列的基本统计数据,四舍五入到四位小数。我们还可以通过在列名前加上r来重命名每个列,以使它们与原始列名区分开来。

此外,我们使用聚合函数计算包含在 DataFrame 中的数据的不同日期的数量:

增强数据集

我们可以为星期几、每月的日期、月份和年份信息在 DataFrame 中增加新的列。例如,我们可能对工作日和周末的用电量感兴趣。这可以通过可视化或基于这些字段的数据透视来更好地理解数据。

执行其他杂项处理步骤

如果需要,我们可以选择执行更多步骤来帮助进一步清洗数据,研究更多的聚合,或者转换为类型安全的数据结构等。

我们可以删除时间列,并使用聚合函数(如 sum 和 average)对各列的值进行聚合,以获取每天读数的值。在这里,我们使用d前缀来重命名列,以表示每日值。

我们从这个 DataFrame 中显示一些样本记录:

scala> finalDayDf1.show(5)

在这里,我们按年份和月份对读数进行分组,然后计算每个月的读数数量并显示出来。第一个月的读数数量较低,因为数据是在半个月内捕获的。

我们还可以使用case类将 DataFrame 转换为数据集,如下所示:

在这个阶段,我们已经完成了预处理家庭电力消耗数据集的所有步骤。现在我们将把重点转移到处理天气数据集上。

天气数据集的预处理

首先,我们为天气读数定义一个case类。

接下来,我们读取了四个文件的每日天气读数(从巴黎天气网站下载),大致与家庭电力消耗读数的持续时间相匹配。

从以下显示的每个输入文件中删除标题。我们已经显示了标题值的输出,以便您了解这些数据集中捕获的各种天气读数参数:

分析缺失数据

如果我们想要了解 RDD 中包含一个或多个缺失字段的行数,我们可以创建一个包含这些行的 RDD:

如果我们的数据以 DataFrame 的形式可用,我们也可以做同样的操作,如下所示:

快速检查数据集发现,大多数具有缺失数据的行也在“事件”和“最大阵风速度公里/小时”列中具有缺失值。根据这两列的值进行过滤实际上捕获了所有具有缺失字段值的行。这也与 RDD 中的缺失值的结果相匹配。

由于有许多行包含一个或多个缺失字段,我们选择保留这些行,以确保不丢失宝贵的信息。在下面的函数中,我们在 RDD 的所有缺失字段中插入0

我们可以用字符串字段中的0替换前一步骤中插入的NA,如下所示:

在这个阶段,我们可以使用union操作将四个数据集的行合并成一个数据集。

在这个阶段,我们第二个包含天气数据的数据集的处理已经完成。在接下来的部分,我们将使用join操作来合并这些预处理的数据集。

使用 JOIN 操作合并数据

在这一部分,我们将介绍 JOIN 操作,其中每日家庭电力消耗与天气数据进行了合并。我们假设家庭电力消耗的读数位置和天气读数的位置足够接近,以至于相关。

接下来,我们使用 JOIN 操作将每日家庭电力消耗数据集与天气数据集进行合并。

验证最终 DataFrame 中的行数是否与join操作后预期的行数相匹配,如下所示:

您可以计算新连接的数据集中各列之间的一系列相关性,以了解列之间的关系强度和方向,如下所示:

同样,您可以连接按年和月分组的数据集,以获得数据的更高级别的总结。

为了可视化总结的数据,我们可以在 Apache Zeppelin 笔记本中执行前面的语句。例如,我们可以通过将joinedMonthlyDF转换为表,并从中选择适当的列来绘制月度全球反应功率GRP)值,如下所示:

同样,如果您想按星期几分析读数,则按照以下步骤进行:

最后,我们打印连接的数据集的模式(增加了星期几列),以便您可以进一步探索此数据框架的各个字段之间的关系:

在下一节中,我们将把重点转移到整理文本数据上。

整理文本数据

在本节中,我们将探讨典型文本分析情况下的数据整理技术。许多基于文本的分析任务需要计算词频、去除停用词、词干提取等。此外,我们还将探讨如何逐个处理 HDFS 目录中的多个文件。

首先,我们导入本节中将使用的所有类:

处理多个输入数据文件

在接下来的几个步骤中,我们初始化一组变量,用于定义包含输入文件的目录和一个空的 RDD。我们还从输入 HDFS 目录创建文件名列表。在下面的示例中,我们将处理包含在单个目录中的文件;但是,这些技术可以很容易地扩展到所有 20 个新闻组子目录。

接下来,我们编写一个函数,计算每个文件的词频,并将结果收集到一个ArrayBuffer中:

我们已经包含了一个打印语句,以显示选定的文件名进行处理,如下所示:

我们使用union操作将行添加到单个 RDD 中:

我们可以直接执行联合步骤,因为每个文件被处理时,如下所示:

然而,使用RDD.union()会在血统图中创建一个新步骤,需要为每个新 RDD 添加额外的堆栈帧。这很容易导致堆栈溢出。相反,我们使用SparkContext.union(),它会一次性执行union操作,而不会产生额外的内存开销。

我们可以缓存并打印输出 RDD 中的样本行,如下所示:

在下一节中,我们将向您展示过滤停用词的方法。为简单起见,我们只关注文本中格式良好的单词。但是,您可以使用字符串函数和正则表达式轻松添加条件,以过滤数据中的特殊字符和其他异常情况(有关详细示例,请参阅第九章,使用 Spark SQL 开发应用程序)。

去除停用词

在我们的示例中,我们创建了一组停用词,并从每个文件中的单词中过滤掉它们。通常,在远程节点上执行的 Spark 操作会在函数中使用的变量的单独副本上工作。我们可以使用广播变量在集群中的每个节点上维护一个只读的缓存副本,而不是将其与要在节点上执行的任务一起传输。Spark 尝试有效地分发广播变量,以减少总体通信开销。此外,我们还过滤掉由于我们的过滤过程和停用词移除而返回的空列表。

我们可以从 RDD 中的每个元组中提取单词,并创建包含它们的 DataFrame,如下所示:

在下面的示例中,我们展示了另一种从单词列表中过滤出停用词的方法。为了改善两个列表之间的单词匹配,我们以与从输入文件中提取的单词类似的方式处理停用词文件。我们读取包含停用词的文件,去除开头和结尾的空格,转换为小写,替换特殊字符,过滤掉空单词,最后创建一个包含停用词的 DataFrame。

我们在示例中使用的停用词列表可在algs4.cs.princeton.edu/35applications/stopwords.txt中找到。

在这里,我们使用regex来过滤文件中包含的特殊字符。

接下来,我们比较在去除原始单词列表中的停用词之前和之后列表中单词的数量。剩下的最终单词数量表明我们输入文件中的大部分单词是停用词。

有关文本数据处理的更详细覆盖范围(包括年度10-K财务申报文件和其他文档语料库的处理、识别文档语料库中的主题、使用朴素贝叶斯分类器和开发机器学习应用程序),请参阅第九章,使用 Spark SQL 开发应用程序

在接下来的部分,我们将把重点转移到使用 Cloudera 的spark-time-series库对时间序列数据进行整理。

整理时间序列数据

时间序列数据是与时间戳相关联的一系列值。在本节中,我们使用 Cloudera 的spark-ts包来分析时间序列数据。

有关时间序列数据及其使用spark-ts进行处理的更多详细信息,请参阅Cloudera Engineering Blog使用 Apache Spark 分析时间序列数据的新库。该博客位于:github.com/sryza/spark-timeseries

spark-ts包可以通过以下说明进行下载和构建:

github.com/sryza/spark-timeseries

在接下来的子部分中,我们将尝试实现以下目标:

  • 预处理时间序列数据集

  • 处理日期字段

  • 持久化和加载数据

  • 定义日期时间索引

  • 使用TimeSeriesRDD对象

  • 处理缺失的时间序列数据

  • 计算基本统计数据

对于本节,请在启动 Spark shell 时指定包含spark-ts.jar文件。

我们从 Yahoo Finance 网站下载了包含六只股票一年期价格和成交量数据的数据集。在使用spark-ts包进行时间序列数据分析之前,我们需要对数据进行预处理。

导入本节所需的类。

预处理时间序列数据集

从输入数据文件中读取数据,并定义一个包含数据集中字段的case类 Stock,以及一个用于保存股票代码的字段。

接下来,我们从每个文件中移除标题,使用case类映射我们的 RDD 行,包括一个用于股票代码的字符串,并将 RDD 转换为 DataFrame。

接下来,我们使用union将每个 DataFrame 的行合并起来。

处理日期字段

接下来,我们将日期列分成包含日期、月份和年份信息的三个单独字段。

持久化和加载数据

在这个阶段,我们可以使用DataFrameWriter类将我们的 DataFrame 持久化到 CSV 文件中。覆盖模式允许您覆盖文件,如果它已经存在于write操作的先前执行中:

为了加载上一步写入磁盘的时间序列数据集,我们定义一个从文件加载观测值并返回 DataFrame 的函数:

定义日期时间索引

我们为我们拥有数据的期间定义一个日期时间索引,以便每条记录(针对特定的股票代码)包括一个时间序列,表示为一年中每一天的366个位置的数组(加上额外的一天,因为我们已经从 2015 年 12 月 4 日下载了数据到 2016 年 12 月 4 日)。工作日频率指定数据仅适用于一年中的工作日。

使用TimeSeriesRDD对象

spark-ts库中的主要抽象是称为TimeSeriesRDD的 RDD。数据是一组以元组(时间戳、键、值)表示的观测值。键是用于标识时间序列的标签。在下面的示例中,我们的元组是(时间戳、股票代码、收盘价)。RDD 中的每个系列都将股票代码作为键,将股票的每日收盘价作为值。

scala> val tickerTsrdd = TimeSeriesRDD.timeSeriesRDDFromObservations(dtIndex, tickerObs, "timestamp", "ticker", "close") 

我们可以缓存并显示 RDD 中的行数,这应该等于我们示例中的股票数量:

显示 RDD 中的几行以查看每行中的数据:

处理缺失的时间序列数据

接下来,我们检查 RDD 中是否有缺失数据。缺失数据标记为NaN值。在存在NaN值的情况下计算基本统计数据会导致错误。因此,我们需要用近似值替换这些缺失值。我们的示例数据不包含任何缺失字段。但是,作为练习,我们从输入数据集中删除一些值,以模拟 RDD 中的这些NaN值,然后使用线性插值来填补这些值。其他可用的近似值包括下一个、上一个和最近的值。

我们填写缺失值的近似值,如下所示:

计算基本统计数据

最后,我们计算每个系列的均值、标准差、最大值和最小值,如下所示:

使用TimeSeriesRDD对象进行探索性数据分析和数据整理还有许多其他有用的函数。这些包括将 RDD 收集为本地时间序列、查找特定时间序列、各种过滤和切片功能、对数据进行排序和重新分区、将时间序列写入 CSV 文件等等。

处理可变长度记录

在这一部分,我们将探讨处理可变长度记录的方法。我们的方法基本上将每一行转换为等于最大长度记录的固定长度记录。在我们的例子中,由于每行代表一个投资组合并且没有唯一标识符,这种方法对将数据转换为熟悉的固定长度记录情况非常有用。我们将生成所需数量的字段,使其等于最大投资组合中的股票数量。这将导致在股票数量少于任何投资组合中的最大股票数量时出现空字段。处理可变长度记录的另一种方法是使用explode()函数为给定投资组合中的每支股票创建新行(有关使用explode()函数的示例,请参阅第九章,使用 Spark SQL 开发应用程序)。

为了避免重复之前示例中的所有步骤来读取所有文件,我们在本例中将数据合并为一个单独的输入文件。

首先,我们导入所需的类并将输入文件读入 RDD:

我们计算投资组合的总数,并打印 RDD 中的一些记录。您可以看到,第一个和第二个投资组合各包含一支股票,而第三个投资组合包含两支股票。

将可变长度记录转换为固定长度记录

在我们的示例数据集中,没有缺失的字段,因此,我们可以使用每行逗号的数量来推导出每个投资组合中不同数量的股票相关字段。或者,这些信息可以从 RDD 的最后一个字段中提取出来。

接下来,我们创建一个 UDF 来间接计算每行中逗号的数量,通过计算数据集中所有行中逗号的最大数量来使用describe

在下一步中,我们用一个包含逗号数量的列来增加 DataFrame。

然后我们编写一个函数,在适当的位置插入每行中正确数量的逗号:

接下来,我们去掉逗号数量列,因为在后续步骤中不需要它:

在这个阶段,如果你想要去掉 DataFrame 中的重复行,那么你可以使用dropDuplicates方法,如下所示:

在下一步中,我们为最大投资组合中的最大股票数定义一个Portfoliocase类。

接下来,我们将 RDD 转换为 DataFrame。为了方便起见,我们将演示使用较少的与股票相关的列进行操作;然而,同样的操作可以扩展到投资组合中其他股票的字段:

我们可以用NA替换较小投资组合中股票的空字段,如下所示:

从“混乱”的列中提取数据

在这一节中,我们将继续上一节的工作,但是我们将只处理一个股票,以演示修改数据字段所需的数据操作,使得最终得到的数据比起开始时更加干净和丰富。

大多数字段包含多个信息,我们将执行一系列语句,将它们分开成独立的列:

在下一步中,我们将datestr列中的第一个下划线替换为一个空格。这样就分离出了日期字段:

接下来,我们分离股票列中的信息,因为它包含了几个有用的信息,包括股票代码、卖出价格和购买价格的比率,以及卖出价格和购买价格。首先,我们通过用空字符串替换股票列中的=来去掉=

接下来,将每列中由空格分隔的值转换为值的数组:

接下来,我们使用UDF从每列的数组中挑选出特定元素,放到它们自己的独立列中。

文件列对我们的分析来说并不特别有用,除了提取文件名开头的信息,表示任何给定投资组合的股票池。我们接下来就这样做:

以下是准备进行进一步分析的 DataFrame 的最终版本。在这个例子中,我们只处理了一个股票,但是你可以很容易地将相同的技术扩展到给定投资组合中的所有股票,得到最终的、干净且丰富的 DataFrame,可以用于查询、建模和分析。

在下一节中,我们简要介绍了为了使用 Spark MLlib 机器学习算法解决分类问题而准备数据所需的步骤。

为机器学习准备数据

在这一节中,我们介绍了在应用 Spark MLlib 算法之前准备输入数据的过程。通常情况下,我们需要有两列,称为标签和特征,用于使用 Spark MLlib 分类算法。我们将用下面描述的例子来说明这一点:

我们导入了本节所需的类:

scala> import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.classification.{RandomForestClassificationModel, RandomForestClassifier}
scala> import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
scala> import org.apache.spark.ml.feature.{IndexToString, StringIndexer, VectorIndexer} 
scala> import org.apache.spark.ml.linalg.Vectors 

为机器学习预处理数据

我们在本节中定义了一组在本节中使用的UDF。这些包括,例如,检查字符串是否包含特定子字符串,并返回0.01.0值以创建标签列。另一个UDF用于从 DataFrame 中的数字字段创建特征向量。

例如,我们可以通过以下方式将星期几字段转换为数字值进行分箱显示:

在我们的示例中,我们根据某一天是否下雨,从家庭电力消耗数据集的Events列中创建一个label。为了说明的目的,我们使用了之前连接的 DataFrame 中的家庭电力消耗读数的列,尽管来自天气数据集的读数可能更好地预测雨水。

最后,我们还可以将 DataFrame 拆分,创建包含随机选择的 70%和 30%读数的训练和测试数据集。这些数据集用于训练和测试机器学习算法。

创建和运行机器学习管道

在本节中,我们介绍了一个使用索引器和训练数据来训练随机森林模型的机器学习管道的示例。我们不会对步骤进行详细解释,因为我们在这里的主要目的是演示前一节中的准备步骤实际上是如何使用的。

scala> val rf = new RandomForestClassifier().setLabelCol("indexedLabel").setFeaturesCol("indexedFeatures").setNumTrees(10)

scala> // Convert indexed labels back to original labels.
scala> val labelConverter = new IndexToString().setInputCol("prediction").setOutputCol("predictedLabel").setLabels(labelIndexer.labels)

scala> // Chain indexers and forest in a Pipeline.
scala> val pipeline = new Pipeline().setStages(Array(labelIndexer, featureIndexer, rf, labelConverter))

scala> // Train model. This also runs the indexers.
scala> val model = pipeline.fit(trainingData)

scala> // Make predictions.
scala> val predictions = model.transform(testData)

scala> // Select example rows to display.
scala> predictions.select("predictedLabel", "label", "features").show(5)

scala> // Select (prediction, true label) and compute test error.
scala> val evaluator = new MulticlassClassificationEvaluator().setLabelCol("indexedLabel").setPredictionCol("prediction").setMetricName("accuracy")

scala> val accuracy = evaluator.evaluate(predictions)
accuracy: Double = 0.5341463414634147                                          

scala> println("Test Error = " + (1.0 - accuracy))
Test Error = 0.46585365853658534

scala> val rfModel = model.stages(2).asInstanceOf[RandomForestClassificationModel]

scala> println("Learned classification forest model:\n" + rfModel.toDebugString)

有关特定数据结构和操作的更多详细信息,包括向量、处理分类变量等等,用于 Spark MLlib 处理的内容,将在第六章中进行介绍,在机器学习应用中使用 Spark SQL,以及第九章中进行介绍,使用 Spark SQL 开发应用程序。此外,有关为图应用程序准备数据的技术将在第七章中进行介绍,在图应用程序中使用 Spark SQL

摘要

在本章中,我们探讨了使用 Spark SQL 执行一些基本的数据整理/处理任务。我们涵盖了整理文本数据,处理可变长度记录,从“混乱”的列中提取数据,使用 JOIN 组合数据,并为机器学习应用程序准备数据。此外,我们使用了spark-ts库来处理时间序列数据。

在下一章中,我们将把重点转向 Spark Streaming 应用程序。我们将介绍如何在这些应用程序中使用 Spark SQL。我们还将包括大量的实践课程,演示在 Spark Streaming 应用程序中实现常见用例时如何使用 Spark SQL。

第五章:在流应用中使用 Spark SQL

在本章中,我们将介绍在流应用中使用 Spark SQL 的典型用例。我们的重点将放在使用 Spark 2.0 中引入的 Dataset/DataFrame API 进行结构化流处理上。此外,我们还将介绍并使用 Apache Kafka,因为它是许多大规模网络流应用架构的重要组成部分。流应用通常涉及对传入数据或消息进行实时、上下文感知的响应。我们将使用几个示例来说明构建此类应用的关键概念和技术。

在本章中,我们将学习以下主题:

  • 什么是流数据应用?

  • 典型的流应用用例

  • 使用 Spark SQL DataFrame/Dataset API 构建流应用

  • 在结构化流应用中使用 Kafka

  • 为自定义数据源创建接收器

介绍流数据应用

传统的批处理应用通常运行数小时,处理存储在关系数据库中的所有或大部分数据。最近,基于 Hadoop 的系统已被用于支持基于 MapReduce 的批处理作业,以处理非常大量的分布式数据。相比之下,流处理发生在持续生成的流数据上。这种处理在各种分析应用中被使用,用于计算事件之间的相关性、聚合值、对传入数据进行抽样等。

流处理通常会逐步计算统计数据和其他功能,以记录/事件为基础,或者在滑动时间窗口上进行实时计算。

越来越多的流数据应用正在应用机器学习算法和复杂事件处理(CEP)算法,以提供战略洞察和快速、智能地对快速变化的业务条件做出反应。这类应用可以扩展以处理非常大量的流数据,并能够实时做出适当的响应。此外,许多组织正在实施包含实时层和批处理层的架构。在这种实现中,尽可能地保持这两个层的单一代码库非常重要(有关此类架构的示例,请参阅第十二章,大规模应用架构中的 Spark SQL)。Spark 结构化流 API 可以帮助我们以可扩展、可靠和容错的方式实现这些目标。

流应用的一些真实用例包括处理物联网应用中的传感器数据、股票市场应用(如风险管理和算法交易)、网络监控、监视应用、电子商务应用中的即时客户参与、欺诈检测等。

因此,许多平台已经出现,提供了构建流数据应用所需的基础设施,包括 Apache Kafka、Apache Spark Streaming、Apache Storm、Amazon Kinesis Streams 等。

在本章中,我们将探讨使用 Apache Spark 和 Apache Kafka 进行流处理。在接下来的几节中,我们将使用 Spark SQL DataFrame/Dataset API 详细探讨 Spark 结构化流。

构建 Spark 流应用

在本节中,我们将主要关注新引入的结构化流特性(在 Spark 2.0 中)。结构化流 API 在 Spark 2.2 中已经是 GA,并且使用它们是构建流式 Spark 应用的首选方法。Spark 2.2 还发布了对基于 Kafka 的处理组件的多个更新,包括性能改进。我们在第一章,开始使用 Spark SQL中介绍了结构化流,本章中我们将深入探讨这个主题,并提供几个代码示例来展示其能力。

简而言之,结构化流提供了一种快速、可扩展、容错、端到端的精确一次流处理,而开发人员无需考虑底层的流处理机制。

它建立在 Spark SQL 引擎上,流计算可以以与静态数据上的批处理计算相同的方式来表达。它提供了几种数据抽象,包括流查询、流源和流接收器,以简化流应用程序,而不涉及数据流的底层复杂性。编程 API 在 Scala、Java 和 Python 中都可用,您可以使用熟悉的 Dataset / DataFrame API 来实现您的应用程序。

在第一章中,开始使用 Spark SQL,我们使用 IPinYou 数据集创建了一个流 DataFrame,然后在其上定义了一个流查询。我们展示了结果在每个时间间隔内得到更新。在这里,我们重新创建我们的流 DataFrame,然后在其上执行各种函数,以展示在流输入数据上可能的计算类型。

首先,我们启动 Spark shell,并导入本章实际操作所需的必要类。在我们的大多数示例中,我们将使用文件源来模拟传入的数据:

scala> import org.apache.spark.sql.types._
scala> import org.apache.spark.sql.functions._
scala> import scala.concurrent.duration._
scala> import org.apache.spark.sql.streaming.ProcessingTime
scala> import org.apache.spark.sql.streaming.OutputMode.Complete
scala> import spark.implicits._

接下来,我们将为源文件中的出价记录定义模式,如下所示:

scala> val bidSchema = new StructType().add("bidid", StringType).add("timestamp", StringType).add("ipinyouid", StringType).add("useragent", StringType).add("IP", StringType).add("region", IntegerType).add("cityID", IntegerType).add("adexchange", StringType).add("domain", StringType).add("turl", StringType).add("urlid", StringType).add("slotid", StringType).add("slotwidth", StringType).add("slotheight", StringType).add("slotvisibility", StringType).add("slotformat", StringType).add("slotprice", StringType).add("creative", StringType).add("bidprice", StringType)

接下来,我们将基于输入的 CSV 文件定义一个流数据源。我们指定在上一步中定义的模式和其他必需的参数(使用选项)。我们还将每批处理的文件数量限制为一个:

scala> val streamingInputDF = spark.readStream.format("csv").schema(bidSchema).option("header", false).option("inferSchema", true).option("sep", "\t").option("maxFilesPerTrigger", 1).load("file:///Users/aurobindosarkar/Downloads/make-ipinyou-data-master/original-data/ipinyou.contest.dataset/bidfiles")

您可以像在静态数据的情况下一样打印流 DataFrame 的模式:

scala> streamingInputDF.printSchema()
root
|-- bidid: string (nullable = true)
|-- timestamp: string (nullable = true)
|-- ipinyouid: string (nullable = true)
|-- useragent: string (nullable = true)
|-- IP: string (nullable = true)
|-- region: integer (nullable = true)
|-- cityID: integer (nullable = true)
|-- adexchange: string (nullable = true)
|-- domain: string (nullable = true)
|-- turl: string (nullable = true)
|-- urlid: string (nullable = true)
|-- slotid: string (nullable = true)
|-- slotwidth: string (nullable = true)
|-- slotheight: string (nullable = true)
|-- slotvisibility: string (nullable = true)
|-- slotformat: string (nullable = true)
|-- slotprice: string (nullable = true)
|-- creative: string (nullable = true)
|-- bidprice: string (nullable = true)

实现基于滑动窗口的功能

在本小节中,我们将介绍对流数据进行滑动窗口操作。

由于时间戳数据格式不正确,我们将定义一个新列,并将输入时间戳字符串转换为适合我们处理的正确格式和类型:

scala> val ts = unix_timestamp($"timestamp", "yyyyMMddHHmmssSSS").cast("timestamp")

scala> val streamingCityTimeDF = streamingInputDF.withColumn("ts", ts).select($"cityID", $"ts")

接下来,我们将定义一个流查询,将输出写入标准输出。我们将在滑动窗口上定义聚合,其中我们按窗口和城市 ID 对数据进行分组,并计算每个组的计数。

有关结构化流编程的更详细描述,请参阅spark.apache.org/docs/latest/structured-streaming-programming-guide.html.

在这里,我们计算在 10 分钟的窗口内的出价数量,每五分钟更新一次,也就是说,在每五分钟滑动一次的 10 分钟窗口内收到的出价。使用窗口的流查询如下所示:

scala> val windowedCounts = streamingCityTimeDF.groupBy(window($"ts", "10 minutes", "5 minutes"), $"cityID").count().writeStream.outputMode("complete").format("console").start()

输出写入标准输出,因为我们在格式参数中使用了console关键字指定了Console Sink。输出包含窗口、城市 ID 和计算的计数列,如下所示。我们看到了两批数据,因为我们在输入目录中放置了两个文件:

将流数据集与静态数据集进行连接

在本小节中,我们将举例说明如何将流数据集与静态数据集进行连接。我们将基于cityID来连接数据集,以实现包含城市名称而不是cityID的用户友好输出。首先,我们为我们的城市记录定义一个模式,并从包含城市 ID 及其对应城市名称的 CSV 文件创建静态 DataFrame:

scala> val citySchema = new StructType().add("cityID", StringType).add("cityName", StringType)

scala> val staticDF = spark.read.format("csv").schema(citySchema).option("header", false).option("inferSchema", true).option("sep", "\t").load("file:///Users/aurobindosarkar/Downloads/make-ipinyou-data-master/original-data/ipinyou.contest.dataset/city.en.txt")

接下来,我们将连接流和静态 DataFrame,如下所示:

scala> val joinedDF = streamingCityTimeDF.join(staticDF, "cityID")

我们将执行我们之前的流查询,指定城市名称的列,而不是连接的 DataFrame 中的城市 ID:

scala> val windowedCityCounts = joinedDF.groupBy(window($"ts", "10 minutes", "5 minutes"), $"cityName").count().writeStream.outputMode("complete").format("console").start()

结果如下。在这里,我们看到了一批输出数据,因为我们已经从源目录中删除了一个输入文件。在本章的其余部分,我们将限制处理为单个输入文件,以节省空间:

接下来,我们创建一个带有时间戳列和从先前创建的 DataFrame 中选择的几列的新 DataFrame:

scala> val streamingCityNameBidsTimeDF = streamingInputDF.withColumn("ts", ts).select($"ts", $"bidid", $"cityID", $"bidprice", $"slotprice").join(staticDF, "cityID") 

由于我们不计算聚合,并且只是希望将流式出价附加到结果中,因此我们使用outputMode"append"而不是"complete",如下所示:

scala> val cityBids = streamingCityNameBidsTimeDF.select($"ts", $"bidid", $"bidprice", $"slotprice", $"cityName").writeStream.outputMode("append").format("console").start()

使用结构化流中的数据集 API

到目前为止,我们已经使用了与 DataFrame 不同的未类型化 API。为了使用类型化 API,我们可以从使用 DataFrame 切换到使用数据集。大多数流式操作都受到 DataFrame/Dataset API 的支持;但是,一些操作,如多个流式聚合和不支持的不同操作,尚不受支持。而其他操作,如外连接和排序,是有条件支持的。

有关不受支持和有条件支持的操作的完整列表,请参阅spark.apache.org/docs/latest/structured-streaming-programming-guide.html

在这里,我们提供了一些使用类型化 API 的示例。

首先,我们将定义一个名为Bidcase类:

scala> case class Bid(bidid: String, timestamp: String, ipinyouid: String, useragent: String, IP: String, region: Integer, cityID: Integer, adexchange: String, domain: String, turl: String, urlid: String, slotid: String, slotwidth: String, slotheight: String, slotvisibility: String, slotformat: String, slotprice: String, creative: String, bidprice: String)

我们可以使用在前一步中定义的case类,从流式 DataFrame 中定义一个流式数据集:

scala> val ds = streamingInputDF.as[Bid]

使用输出 sink

您可以将流式输出数据定向到各种输出 sink,包括文件、Foreach、控制台和内存 sink。通常,控制台和内存 sink 用于调试目的。由于我们已经在之前的部分中使用了控制台 sink,因此我们将更详细地讨论其他 sink 的用法。

使用 Foreach Sink 进行输出上的任意计算

如果您想对输出执行任意计算,那么可以使用Foreach Sink。为此,您需要实现ForeachWriter接口,如所示。在我们的示例中,我们只是打印记录,但您也可以根据您的要求执行其他计算:

import org.apache.spark.sql.ForeachWriter

val writer = new ForeachWriter[String] {
   override def open(partitionId: Long, version: Long) = true
   override def process(value: String) = println(value)
   override def close(errorOrNull: Throwable) = {}
}

在下一步中,我们将实现一个示例,使用在上一步中定义的Foreach sink。如下所示,指定在前一步中实现的ForeachWriter

scala> val dsForeach = ds.filter(_.adexchange == "3").map(_.useragent).writeStream.foreach(writer).start()

结果将显示用户代理信息,如下所示:

使用内存 Sink 将输出保存到表

如果您想将输出数据保存为表,可以使用内存 Sink;这对于交互式查询很有用。我们像以前一样定义一个流式 DataFrame。但是,我们将格式参数指定为memory,并指定表名。最后,我们对我们的表执行 SQL 查询,如下所示:

scala> val aggAdexchangeDF = streamingInputDF.groupBy($"adexchange").count()

scala> val aggQuery = aggAdexchangeDF.writeStream.queryName("aggregateTable").outputMode("complete").format("memory").start()

scala> spark.sql("select * from aggregateTable").show()   

使用文件 sink 将输出保存到分区表

我们还可以将输出保存为分区表。例如,我们可以按时间对输出进行分区,并将其存储为 HDFS 上的 Parquet 文件。在这里,我们展示了使用文件 sink 将输出存储为 Parquet 文件的示例。在给定的命令中,必须指定检查点目录位置:

scala> val cityBidsParquet = streamingCityNameBidsTimeDF.select($"bidid", $"bidprice", $"slotprice", $"cityName").writeStream.outputMode("append").format("parquet").option("path", "hdfs://localhost:9000/pout").option("checkpointLocation", "hdfs://localhost:9000/poutcp").start()

您可以检查 HDFS 文件系统,查看输出 Parquet 文件和检查点文件,如下所示:

Aurobindos-MacBook-Pro-2:~ aurobindosarkar$ hdfs dfs -ls /pout

Aurobindos-MacBook-Pro-2:~ aurobindosarkar$ hdfs dfs -ls /poutcp

在下一节中,我们将探索一些有用的功能,用于管理和监视流式查询。

监视流式查询

在这个阶段,如果您列出系统中的活动流查询,您应该会看到以下输出:

scala> spark.streams.active.foreach(x => println("ID:"+ x.id + "             Run ID:"+ x.runId + "               Status: "+ x.status))

ID:0ebe31f5-6b76-46ea-a328-cd0c637be49c             
Run ID:6f203d14-2a3a-4c9f-9ea0-8a6783d97873               
Status: {
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,
  "isTriggerActive" : false
}
ID:519cac9a-9d2f-4a01-9d67-afc15a6b03d2             
Run ID:558590a7-cbd3-42b8-886b-cdc32bb4f6d7               
Status: {
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,
  "isTriggerActive" : false
}
ID:1068bc38-8ba9-4d5e-8762-bbd2abffdd51             
Run ID:bf875a27-c4d8-4631-9ea2-d51a0e7cb232               
Status: {
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,
  "isTriggerActive" : false
}
ID:d69c4005-21f1-487a-9fe5-d804ca86f0ff             
Run ID:a6969c1b-51da-4986-b5f3-a10cd2397784               
Status: {
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,
  "isTriggerActive" : false
}
ID:1fa9e48d-091a-4888-9e69-126a2f1c081a             
Run ID:34dc2c60-eebc-4ed6-bf25-decd6b0ad6c3               
Status: {
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,  "isTriggerActive" : false
}
ID:a7ff2807-dc23-4a14-9a9c-9f8f1fa6a6b0             
Run ID:6c8f1a83-bb1c-4dd7-8974
83042a286bae               
Status: {
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,
  "isTriggerActive" : false
}

我们还可以监视和管理特定的流式查询,例如windowedCounts查询(一个StreamingQuery对象),如下所示:

scala> // get the unique identifier of the running query that persists across restarts from checkpoint data
scala> windowedCounts.id          
res6: java.util.UUID = 0ebe31f5-6b76-46ea-a328-cd0c637be49c

scala> // get the unique id of this run of the query, which will be generated at every start/restart
scala> windowedCounts.runId       
res7: java.util.UUID = 6f203d14-2a3a-4c9f-9ea0-8a6783d97873

scala> // the exception if the query has been terminated with error
scala> windowedCounts.exception       
res8: Option[org.apache.spark.sql.streaming.StreamingQueryException] = None

scala> // the most recent progress update of this streaming query
scala> windowedCounts.lastProgress 
res9: org.apache.spark.sql.streaming.StreamingQueryProgress =

要停止流式查询执行,您可以执行stop()命令,如下所示:

scala> windowedCounts.stop()

在下一节中,我们将把重点转移到使用 Kafka 作为结构化流应用程序中传入数据流的来源。

使用 Kafka 与 Spark 结构化流

Apache Kafka 是一个分布式流平台。它使您能够发布和订阅数据流,并在其产生时处理和存储它们。Kafka 被业界广泛采用于面向 Web 规模应用程序,因为它具有高吞吐量、低延迟、高可伸缩性、高并发性、可靠性和容错特性。

介绍 Kafka 概念

Kafka 通常用于构建实时流数据管道,可在系统之间可靠地移动数据,还可对数据流进行转换和响应。Kafka 作为一个或多个服务器上的集群运行。

这里描述了 Kafka 的一些关键概念:

  • 主题:用于发布消息的类别或流名称的高级抽象。一个主题可以有01或多个订阅其发布的消息的消费者。用户为每个新类别的消息定义一个新主题。

  • 生产者:向主题发布消息的客户端。

  • 消费者:从主题中消费消息的客户端。

  • Broker:一个或多个服务器,用于复制和持久化消息数据。

此外,生产者和消费者可以同时写入和读取多个主题。每个 Kafka 主题都被分区,写入每个分区的消息是顺序的。分区中的消息具有唯一标识每条消息的偏移量。

Apache Kafka 安装、教程和示例的参考网站是kafka.apache.org/

主题的分区是分布的,每个 Broker 处理一部分分区的请求。每个分区在可配置数量的 Broker 上复制。Kafka 集群保留所有发布的消息一段可配置的时间。Apache Kafka 使用 Apache ZooKeeper 作为其分布式进程的协调服务。

介绍 ZooKeeper 概念

ZooKeeper 是一个分布式的开源协调服务,用于分布式应用程序。它使开发人员不必从头开始实现协调服务。它使用共享的分层命名空间,允许分布式进程相互协调,并避免与竞争条件和死锁相关的错误。

Apache ZooKeeper 安装和教程的参考网站是zookeeper.apache.org/

ZooKeeper 数据保存在内存中,因此具有非常高的吞吐量和低延迟。它在一组主机上复制,以提供高可用性。ZooKeeper 提供一组保证,包括顺序一致性和原子性。

介绍 Kafka-Spark 集成

我们在这里提供一个简单的示例,以使您熟悉 Kafka-Spark 集成。本节的环境使用:Apache Spark 2.1.0 和 Apache Kafka 0.10.1.0(下载文件:kafka_2.11-0.10.1.0.tgz)

首先,我们使用 Apache Kafka 分发提供的脚本启动单节点 ZooKeeper,如下所示:

bin/zookeeper-server-start.sh config/zookeeper.properties

Zookeeper 节点启动后,我们使用 Apache Kafka 分发中提供的脚本启动 Kafka 服务器,如下所示:

bin/kafka-server-start.sh config/server.properties

接下来,我们创建一个名为test的主题,我们将向其发送消息以供 Spark 流处理。对于我们的简单示例,我们将复制因子和分区数都指定为1。我们可以使用为此目的提供的实用脚本,如下所示:

bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic test

我们可以使用此脚本查看主题列表(包括“test”):

bin/kafka-topics.sh --list --zookeeper localhost:2181

接下来,我们启动一个基于命令行的生产者来向 Kafka 发送消息,如下所示。在这里,每行都作为单独的消息发送。当您输入并按下回车时,您应该在 Spark 流查询中看到每行出现(在不同的窗口中运行)。

bin/kafka-console-producer.sh --broker-list localhost:9092 --topic test
This is the first message.
This is another message.

在一个单独的窗口中,启动 Spark shell,并在命令行中指定适当的 Kafka 包,如下所示:

Aurobindos-MacBook-Pro-2:spark-2.1.0-bin-hadoop2.7 aurobindosarkar$ ./bin/spark-shell --packages org.apache.spark:spark-streaming-kafka-0-10_2.11:2.1.0,org.apache.spark:spark-sql-kafka-0-10_2.11:2.1.0

Spark shell 启动后,我们将创建一个格式指定为"kafka"的流式数据集。此外,我们还将指定 Kafka 服务器和其运行的端口,并明确订阅我们之前创建的主题,如下所示。键和值字段被转换为字符串类型,以使输出易于阅读。

scala> val ds1 = spark.readStream.format("kafka").option("kafka.bootstrap.servers", "localhost:9092").option("subscribe", "test").load().selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)").as[(String, String)]

接下来,我们将启动一个流式查询,将流式数据集输出到标准输出,如下所示:

scala> val query = ds1.writeStream.outputMode("append").format("console").start()

当您在 Kafka 生产者窗口中输入句子时,您应该看到以下输出:

介绍 Kafka-Spark 结构化流

然后,我们将提供另一个 Kafka-Spark 结构化流的示例,其中我们将 iPinYou 竞价文件的内容定向到生产者,如下所示:

Aurobindos-MacBook-Pro-2:kafka_2.11-0.10.1.0 aurobindosarkar$ bin/kafka-console-producer.sh --broker-list localhost:9092 --topic connect-test < /Users/aurobindosarkar/Downloads/make-ipinyou-data-master/original-data/ipinyou.contest.dataset/bidfiles/bid.20130311.txt

我们还将创建一个名为connect-test的新主题,一个包含文件记录的新流式数据集,以及一个在屏幕上列出它们的新流式查询,如下所示:

scala> val ds2 = spark.readStream.format("kafka").option("kafka.bootstrap.servers", "localhost:9092").option("subscribe", "connect-test").load().selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)").as[(String, String)]

scala> val query = ds2.writeStream.outputMode("append").format("console").start()

截断的输出如下所示。记录分布在多个批次中,因为它们在流中传输:

在下一节中,我们将创建一个用于访问任意流式数据源的接收器。

为自定义数据源编写接收器

到目前为止,我们已经使用了在 Spark 中内置支持的数据源。但是,Spark 流式处理可以从任意源接收数据,但我们需要实现一个接收器来从自定义数据源接收数据。

在本节中,我们将为来自伦敦交通(TfL)网站提供的公共 API 定义一个自定义数据源。该网站为伦敦的每种交通方式提供了统一的 API。这些 API 提供对实时数据的访问,例如,铁路到达情况。输出以 XML 和 JSON 格式提供。我们将使用 API 来获取伦敦地铁特定线路的当前到达预测。

TfL 的参考网站是tfl.gov.uk; 在该网站上注册以生成用于访问 API 的应用程序密钥。

我们将首先扩展抽象类Receiver并实现onStart()onStop()方法。在onStart()方法中,我们启动负责接收数据的线程,在onStop()中,我们停止这些线程。receive方法使用 HTTP 客户端接收数据流,如下所示:

import org.apache.spark.storage.StorageLevel
import org.apache.spark.streaming.receiver.Receiver
import org.jfarcand.wcs.{TextListener, WebSocket}
import scala.util.parsing.json.JSON
import scalaj.http.Http
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
/**
* Spark Streaming Example TfL Receiver
*/
class TFLArrivalPredictionsByLine() extends ReceiverString with Runnable {
//Replace the app_key parameter with your own key
private val tflUrl = "https://api.tfl.gov.uk/Line/circle/Arrivals?stopPointId=940GZZLUERC&app_id=a73727f3&app_key=xxx"
@transient
private var thread: Thread = _
override def onStart(): Unit = {
   thread = new Thread(this)
   thread.start()
}
override def onStop(): Unit = {
   thread.interrupt()
}
override def run(): Unit = {
   while (true){
     receive();
     Thread.sleep(60*1000);
   }
}
private def receive(): Unit = {
   val httpClient = new DefaultHttpClient();
   val getRequest = new HttpGet(tflUrl);
   getRequest.addHeader("accept", "application/json");
   val response = httpClient.execute(getRequest);
   if (response.getStatusLine().getStatusCode() != 200) {
      throw new RuntimeException("Failed : HTTP error code : "
         + response.getStatusLine().getStatusCode());
   }
   val br = new BufferedReader(
      new InputStreamReader((response.getEntity().getContent())));
   var output=br.readLine();
   while(output!=null){        
      println(output)
      output=br.readLine()
   } 
}
}

以下对象创建了StreamingContext并启动了应用程序。awaitTermination()方法确保应用程序持续运行。

您可以使用*Ctrl *+ *C *来终止应用程序:

import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* Spark Streaming Example App
*/
object TFLStreamingApp {
def main(args: Array[String]) {
   val conf = new SparkConf().setAppName("TFLStreaming")
   val ssc = new StreamingContext(conf, Seconds(300))
   val stream = ssc.receiverStream(new TFLArrivalPredictionsByLine())
   stream.print()
   if (args.length > 2) {
      stream.saveAsTextFiles(args(2))
   }
   ssc.start()
   ssc.awaitTermination()
   }
}

用于编译和打包应用程序的sbt文件如下所示:

name := "spark-streaming-example"
version := "1.0"
scalaVersion := "2.11.7"
resolvers += "jitpack" at "https://jitpack.io"
libraryDependencies ++= Seq("org.apache.spark" %% "spark-core" % "2.0.0",       "org.apache.spark" %% "spark-streaming" % "2.0.0",
"org.apache.httpcomponents" % "httpclient" % "4.5.2",
"org.scalaj" %% "scalaj-http" % "2.2.1",
"org.jfarcand" % "wcs" % "1.5")

我们使用spark-submit命令来执行我们的应用程序,如下所示:

Aurobindos-MacBook-Pro-2:scala-2.11 aurobindosarkar$ /Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7/bin/spark-submit --class TFLStreamingApp --master local[*] spark-streaming-example_2.11-1.0.jar

流式程序的输出如下所示:

总结

在本章中,我们介绍了流式数据应用程序。我们提供了使用 Spark SQL DataFrame/Dataset API 构建流式应用程序的几个示例。此外,我们展示了 Kafka 在结构化流应用程序中的使用。最后,我们提供了一个为自定义数据源创建接收器的示例。

在下一章中,我们将把重点转移到在机器学习应用中使用 Spark SQL。具体来说,我们将探索特征工程和机器学习流水线的关键概念。

第六章:在机器学习应用中使用 Spark SQL

在本章中,我们将介绍在机器学习应用中使用 Spark SQL 的典型用例。我们将重点介绍名为spark.ml的 Spark 机器学习 API,这是实现 ML 工作流的推荐解决方案。spark.ml API 建立在 DataFrames 之上,并提供许多现成的包,包括特征提取器、转换器、选择器以及分类、回归和聚类算法等机器学习算法。我们还将使用 Apache Spark 来执行探索性数据分析EDA)、数据预处理、特征工程以及使用spark.ml API 和算法开发机器学习管道。

更具体地,在本章中,您将学习以下主题:

  • 机器学习应用

  • Spark ML 管道的关键组件

  • 理解特征工程

  • 实施机器学习管道/应用程序

  • 使用 Spark MLlib 的代码示例

介绍机器学习应用

机器学习,预测分析以及相关数据科学主题正在越来越受欢迎,用于解决各种业务领域的实际问题。

如今,机器学习应用正在推动许多组织的关键业务决策。这些应用包括推荐引擎、定向广告、语音识别、欺诈检测、图像识别和分类等。

在下一节中,我们将介绍 Spark ML 管道 API 的关键组件。

了解 Spark ML 管道及其组件

机器学习管道 API 在 Apache Spark 1.2 中引入。Spark MLlib 为开发人员提供了一个 API,用于创建和执行复杂的 ML 工作流。管道 API 使开发人员能够快速组装分布式机器学习管道,因为 API 已经标准化,可以应用不同的机器学习算法。此外,我们还可以将多个机器学习算法组合成一个管道。这些管道包括几个关键组件,可以简化数据分析和机器学习应用的实现。

ML 管道的主要组件如下:

  • 数据集:Spark SQL DataFrames/Datasets 用于存储和处理 ML 管道中的数据。DataFrames/Datasets API 提供了一个标准 API 和一种通用的处理静态数据(通常用于批处理)以及流数据(通常用于在线流处理)的方式。正如我们将在接下来的章节中看到的,这些数据集将被用于存储和处理输入数据、转换后的输入数据、特征向量、标签、预测等。

  • 管道:ML 工作流程被实现为由一系列阶段组成的管道。例如,您可以在 Edgar 网站的10-K申报的“完整提交文本文件”上有一个文本预处理管道。这样的管道会将文档中的行作为输入,经过一系列转换器(应用正则表达式和其他过滤器以特定顺序处理数据)后,产生一个单词列表作为输出。本章节以及第九章中提供了几个数据和 ML 管道的示例。

  • 管道阶段:每个管道阶段包括按指定顺序执行的转换器或估计器。

  • 变压器:这是一个将输入 DataFrame 转换为另一个 DataFrame,并向其添加一个或多个特征的算法。库中有几个变压器,如 RegexTokenizer、Binarizer、OneHotEncoder、各种索引器(例如StringIndexerVectorIndexer)等。您还可以像我们在第九章中所做的那样,定义自己的自定义变压器,使用 Spark SQL 开发应用程序

  • 估计器:这是一个从提供的输入数据中学习的机器学习算法。估计器的输入是一个 DataFrame,输出是一个变压器。MLlib 库中有几个可用的估计器,如LogisticRegressionRandomForest等。这些估计器的输出变压器是相应的模型,如 LogisticRegressionModel、RandomForestModel 等。

理解管道应用开发过程中的步骤

机器学习管道应用开发过程通常包括以下步骤:

  • 数据摄入:典型的机器学习管道摄入的输入数据来自多个数据源,通常以几种不同的格式(如第二章中描述的使用 Spark SQL 处理结构化和半结构化数据)。这些来源可以包括文件、数据库(关系型数据库、NoSQL、图数据库等)、Web 服务(例如 REST 端点)、Kafka 和 Amazon Kinesis 流等。

  • 数据清洗和预处理:数据清洗是整体数据分析管道中的关键步骤。这个预处理步骤修复数据质量问题,并使其适合机器学习模型消费。例如,我们可能需要从源 HTML 文档中删除 HTML 标记并替换特殊字符(如&nbsp;等)。我们可能需要根据 Spark MLlib 管道的标准化格式重命名列(或指定列)。最重要的是,我们还需要将 DataFrame 中的各个列组合成包含特征向量的单个列。

  • 特征工程:在这一步中,我们使用各种技术从输入数据中提取和生成特定特征。然后将这些特征组合成特征向量,并传递到流程的下一步。通常,使用VectorAssembler从指定的 DataFrame 列创建特征向量。

  • 模型训练:机器学习模型训练涉及指定算法和一些训练数据(模型可以从中学习)。通常,我们将输入数据集分割为训练数据集和测试数据集,通过随机选择一定比例的输入记录来创建这些数据集。通过在训练数据集上调用fit()方法来训练模型。

  • 模型验证:这一步涉及评估和调整 ML 模型,以评估预测的准确性。在这一步中,将模型应用于测试数据集,使用transform()方法,并计算模型的适当性能指标,例如准确性、误差等。

  • 模型选择:在这一步中,我们选择变压器和估计器的参数,以产生最佳的 ML 模型。通常,我们创建一个参数网格,并使用交叉验证的过程执行网格搜索,以找到给定模型的最合适的参数集。交叉验证返回的最佳模型可以保存,并在生产环境中加载。

  • 模型部署:最后,我们将最佳模型部署到生产环境中。对于一些模型,将模型参数(如系数、截距或具有分支逻辑的决策树)转换为其他格式(如 JSON),以便在复杂的生产环境中进行更简单、更高效的部署可能更容易。有关此类部署的更多详细信息,请参阅第十二章,大规模应用架构中的 Spark SQL

部署的模型将需要在生产环境中进行持续维护、升级、优化等。

引入特征工程

特征工程是利用数据的领域知识创建对应用机器学习算法至关重要的特征的过程。任何属性都可以成为特征,选择一组有助于解决问题并产生可接受结果的良好特征是整个过程的关键。这一步通常是机器学习应用中最具挑战性的方面。特征的质量和数量/数量对模型的整体质量有很大影响。

更好的特征也意味着更灵活,因为它们可以在使用不太理想的模型时产生良好的结果。大多数机器学习模型都能很好地捕捉基础数据的结构和模式。良好特征的灵活性使我们能够使用更简单、更快速、更易于理解和维护的模型。更好的特征通常也会导致更简单的模型。这些特征使得更容易选择正确的模型和最优化的参数。

有关特征工程的优秀博客,请参考:发现特征工程,如何进行特征工程以及如何擅长它,Jason Brownlee,网址:machinelearningmastery.com/discover-feature-engineering-how-to-engineer-features-and-how-to-get-good-at-it/

从处理和计算成本的角度来看,为真实世界数据集中的每一条信息生成一个特征向量是不切实际的。通常,特征转换,如索引和分箱,用于减少预测变量的维度。此外,通常会从模型中删除不相关和低频值,并将连续变量分组为合理数量的箱。一些原始特征可能高度相关或冗余,因此可以从进一步考虑中删除。此外,多个特征可以组合以产生新特征(从而降低总体维度)。根据模型,我们可能还需要对某些变量的值进行归一化,以避免使用绝对值产生偏斜的结果。我们对训练数据集应用转换,以获得将输入机器学习算法的特征向量。

因此,特征工程是一个迭代过程,包括多个数据选择和模型评估周期。如果问题定义良好,那么迭代过程可以在适当的时候停止,并尝试其他配置或模型。

从原始数据中创建新特征

从原始数据中选择特征可能会导致许多不同的特征集,但我们需要保留与要解决的问题最相关的特征。

特征选择可以揭示各种特征的重要性,但首先必须识别这些特征。特征的数量可能受到我们收集数据的能力的限制,但一旦收集到数据,它完全取决于我们的选择过程。通常,它们需要手动创建,这需要时间、耐心、创造力和对原始输入数据的熟悉程度。

原始输入数据的转换取决于数据的性质。例如,对于文本数据,这可能意味着生成文档向量,而对于图像数据,这可能意味着应用各种滤波器来提取图像的轮廓。因此,这个过程在很大程度上是手动的、缓慢的、迭代的,并且需要大量的领域专业知识。

估计特征的重要性

我们必须从数百甚至数千个潜在特征中选择一个子集,包括在建模过程中。做出这些选择需要更深入地了解可能对模型性能产生最大影响的特征。通常,正在考虑的特征会被评分,然后根据它们的分数排名。一般来说,得分最高的特征会被选择包括在训练数据集中,而其他特征会被忽略。此外,我们也可以从原始数据特征中生成新特征。我们如何知道这些生成的特征对手头的任务有帮助呢?

可以使用不同的方法来估计特征的重要性。例如,我们可以将相关特征集合分组,并比较没有这些特征的模型与完整模型的性能(包括删除的特征)。我们还可以对完整模型和删除模型进行 k 折交叉验证,并在各种统计指标上进行比较。然而,这种方法在生产中可能会太昂贵,因为它需要为每个特征组建立每个模型 k 次(对于 k 折交叉验证),而这可能会很多(取决于分组的级别)。因此,在实践中,这种练习定期在代表性模型样本上进行。

特征工程的其他有效技术包括可视化和应用已知对某些类型数据有效的特定方法。可视化可以是一个强大的工具,快速分析特征之间的关系,并评估生成特征的影响。在各个领域使用众所周知的方法和技术可以加速特征工程的过程。例如,对于文本数据,使用 n-gram、TF-IDF、特征哈希等方法是众所周知且广泛应用的特征工程方法。

理解降维

主要地,降维处理着眼于在模型中实现预测变量数量的适当减少。它通过选择成为训练数据集一部分的特征来帮助,在使用各种转换限制特征矩阵中结果列的数量后。被评估为与问题高度不相关的属性需要被移除。

一些特征对模型的准确性比其他特征更重要。在其他特征存在的情况下,有些特征会变得多余。

特征选择通过选择在解决问题中最有用的特征子集来解决这些挑战。特征选择算法可以计算相关系数、协方差和其他统计数据,以选择一组好的特征。如果一个特征与因变量(被预测的事物)高度相关,通常会包括该特征。我们还可以使用主成分分析(PCA)和无监督聚类方法进行特征选择。更先进的方法可能会通过自动创建和评估各种特征集来搜索,以得出最佳的预测特征子组。

好特征的衍生

在本节中,我们将提供有关提取良好特征和评估这些特征的措施的额外提示。这些特征可以由领域专家手工制作,也可以使用 PCA 或深度学习等方法自动化(有关这些方法的更多细节,请参见第十章,在深度学习应用中使用 Spark SQL)。这些方法可以独立或联合使用,以得到最佳的特征集。

在机器学习项目中,数据准备和清理等任务与解决业务问题所使用的实际学习模型和算法一样重要。在机器学习应用中缺乏数据预处理步骤时,得到的模式将不准确或无用,预测结果的准确性也会降低。

在这里,我们提供了一些关于提取良好的预处理特征和清理数据的一般提示:

  • 探索对分类值进行分组和/或限制成为特征矩阵中预测变量的分类值的数量,仅选择最常见的值。

  • 通过从提供的特征计算多项式特征来评估并添加新特征。但是,要小心避免过拟合,当模型紧密拟合包含过多特征的数据时可能会发生过拟合。这会导致模型记住数据,而不是从中学习,从而降低其准确预测新数据的能力。

  • 使用排名指标(如 Pearson 相关系数)独立地评估每个特征与类的相关性。然后我们可以选择特征的子集,例如排名前 10%或前 20%的特征。

  • 使用诸如 Gini 指数和熵之类的标准来评估每个特征的好坏。

  • 探索特征之间的协方差;例如,如果两个特征以相同的方式变化,选择它们作为特征可能不会为整体目的服务。

  • 模型也可能对训练数据集欠拟合,这将导致模型准确性降低。当模型对数据集欠拟合时,应考虑引入新特征。

  • 日期时间字段包含大量信息,模型很难利用其原始格式。将日期时间字段分解为月份、日期、年份等单独的字段,以便模型利用这些关系。

  • 将线性变换器应用于数值字段,例如权重和距离,以用于回归和其他算法。

  • 探索将数量度量(如权重或距离)存储为速率或时间间隔内的聚合数量,以暴露结构,如季节性。

在下一节中,我们将提供 Spark ML 管道的详细代码示例。

实施 Spark ML 分类模型

在实施机器学习模型的第一步是对输入数据进行 EDA。这种分析通常涉及使用 Zeppelin 等工具进行数据可视化,评估特征类型(数值/分类),计算基本统计数据,计算协方差和相关系数,创建数据透视表等(有关 EDA 的更多细节,请参见第三章,使用 Spark SQL 进行数据探索)。

下一步涉及执行数据预处理和/或数据整理操作。在几乎所有情况下,现实世界的输入数据都不会是高质量的数据,可以直接用于模型。需要进行几次转换,将特征从源格式转换为最终变量;例如,分类特征可能需要使用一种独热编码技术将每个分类值转换为二进制变量(有关数据整理的更多细节,请参见第四章,使用 Spark SQL 进行数据整理)。

接下来是特征工程步骤。在这一步中,我们将导出新特征,以及其他现有特征,包括在训练数据中。使用本章前面提供的提示来导出一组良好的特征,最终用于训练模型。

最后,我们将使用选定的特征训练模型,并使用测试数据集进行测试。

有一个很好的博客,其中包含了一个详细的分类模型应用于 Kaggle 知识挑战-泰坦尼克号:灾难中的机器学习的逐步示例,参见:使用 Apache Spark 构建分类模型,作者 Vishnu Viswanath,网址为:vishnuviswanath.com/spark_lr.html

这些步骤以及每个阶段的操作和输入/输出如下图所示:

我们将使用公开可用的糖尿病数据集,该数据集包含 101,766 行,代表了 130 家美国医院和综合配送网络的十年临床护理记录。它包括 50 多个特征(属性),代表患者和医院的结果。

数据集可以从 UCI 网站下载,网址为archive.ics.uci.edu/ml/datasets/Diabetes+130-US+hospitals+for+years+1999-2008

源 ZIP 文件包含两个 CSV 文件。第一个文件diabetic_data.csv是主要的输入数据集,第二个文件IDs_mapping.csvadmission_type_iddischarge_disposition_idadmission_source_id的主数据。第二个文件足够小,可以手动分成三部分,每部分对应一个 ID 映射集。

本节中的示例紧密遵循了 Beata Strack、Jonathan P. DeShazo、Chris Gennings、Juan L. Olmo、Sebastian Ventura、Krzysztof J. Cios 和 John N. Clore 在 Biomed Res Int. 2014; 2014: 781670 中提出的方法和分析:HbA1c 测量对医院再入院率的影响,可在europepmc.org/articles/PMC3996476上找到。

首先,我们将导入此编码练习所需的所有软件包:

探索糖尿病数据集

数据集包含最初由临床专家选择的属性/特征,这些属性/特征基于它们与糖尿病状况或管理的潜在联系。

特征及其描述的完整列表可在www.hindawi.com/journals/bmri/2014/781670/tab1/上找到。

我们将输入数据加载到 Spark DataFrame 中,如下所示:

scala> val inDiaDataDF = spark.read.option("header", true).csv("file:///Users/aurobindosarkar/Downloads/dataset_diabetes/diabetic_data.csv").cache() 

我们可以显示在上一步中创建的 DataFrame 的模式,以列出 DataFrame 中的列或字段,如下所示:

scala> inDiaDataDF.printSchema() 

接下来,我们打印一些样本记录,以对数据集中字段中包含的值有一个高层次的了解:

scala> inDiaDataDF.take(5).foreach(println)

我们还可以使用dataFrame.describe("column")计算数值列的基本统计信息。例如,我们显示一些数值数据列的计数、平均值、标准差以及最小和最大值:

scala> inDiaDataDF.select("num_lab_procedures", "num_procedures", "num_medications", "number_diagnoses").describe().show() 

+-------+------------------+------------------+------------------+------------------+ 
|summary|num_lab_procedures|    num_procedures|   num_medications|  number_diagnoses| 
+-------+------------------+------------------+------------------+------------------+ 
|  count|            101766|            101766|            101766|            101766| 
|   mean| 43.09564098028811| 1.339730361810428|16.021844230882614| 7.422606764538254| 
| stddev| 19.67436224914214|1.7058069791211583| 8.127566209167293|1.9336001449974298| 
|    min|                 1|                 0|                 1|                 1| 
|    max|                99|                 6|                 9|                 9| 
+-------+------------------+------------------+------------------+------------------+ 

原始输入数据集包含不完整、冗余和嘈杂的信息,这在任何真实世界的数据集中都是预期的。有几个字段存在高比例的缺失值。

我们计算具有特定字段缺失的记录数,如下所示:

scala> inDiaDataDF.select($"weight").groupBy($"weight").count().select($"weight", (($"count" / inDiaDataDF.count())*100).alias("percent_recs")).where("weight = '?'").show()
+------+-----------------+ 
|weight|     percent_recs| 
+------+-----------------+ 
|     ?|96.85847925633315| 
+------+-----------------+ 

scala> inDiaDataDF.select($"payer_code").groupBy($"payer_code").count().select($"payer_code", (($"count" / inDiaDataDF.count())*100).alias("percent_recs")).where("payer_code = '?'").show() 

+----------+----------------+ 
|payer_code|    percent_recs| 
+----------+----------------+ 
|         ?|39.5574160328597| 
+----------+----------------+ 

scala> inDiaDataDF.select($"medical_specialty").groupBy($"medical_specialty").count().select($"medical_specialty", (($"count" / inDiaDataDF.count())*100).alias("percent_recs")).where("medical_specialty = '?'").show() 

+-----------------+-----------------+ 
|medical_specialty|     percent_recs| 
+-----------------+-----------------+ 
|                ?|49.08220820313268| 
+-----------------+-----------------+ 

如前所述,具有许多缺失值的特征被确定为体重、付款码和医疗专业。我们删除体重和付款码列,但是医疗专业属性(可能是一个非常相关的特征)被保留:

scala> val diaDataDrpDF = inDiaDataDF.drop("weight", "payer_code") 

数据集还包含一些患者的多次住院就诊记录。在这里,我们提取了一组有多次住院就诊的患者。

我们观察到这类患者的总数是显著的:


scala> diaDataDrpDF.select($"patient_nbr").groupBy($"patient_nbr").count().where("count > 1").show(5) 

+-----------+-----+ 
|patient_nbr|count| 
+-----------+-----+ 
|    4311585|    2| 
|    4624767|    2| 
|   24962301|    3| 
|   11889666|    2| 
|    2585367|    2| 
+-----------+-----+ 
only showing top 5 rows 

scala>  diaDataDrpDF.select($"patient_nbr").groupBy($"patient_nbr").count().where("count > 1").count() 
res67: Long = 16773 

如参考研究/论文中所述,这样的观察结果不能被视为统计独立,因此我们只包括每位患者的第一次就诊。完成这些操作后,我们验证了 DataFrame 中没有剩余的患者记录对应于多次就诊记录:

scala> val w = Window.partitionBy($"patient_nbr").orderBy($"encounter_id".desc) 

scala> val diaDataSlctFirstDF = diaDataDrpDF.withColumn("rn", row_number.over(w)).where($"rn" === 1).drop("rn") 

scala> diaDataSlctFirstDF.select($"patient_nbr").groupBy($"patient_nbr").count().where("count > 1").show() 
+-----------+-----+ 
|patient_nbr|count| 
+-----------+-----+ 
+-----------+-----+ 

scala> diaDataSlctFirstDF.count() 
res35: Long = 71518 

与参考研究/论文中一样,我们也删除了导致患者死亡的就诊记录,以避免偏见:

scala> val diaDataAdmttedDF = diaDataSlctFirstDF.filter($"discharge_disposition_id" =!= "11") 

scala> diaDataAdmttedDF.count() 
res16: Long = 69934 

执行上述操作后,我们剩下了69,934次就诊,构成了用于进一步分析的最终数据集。

接下来,我们执行一系列的JOIN操作,以更好地了解数据在discharge dispositionadmission typesadmission sources的顶级类别方面的情况:

scala> joinDF.select("encounter_id", "dchrgDisp", "admType", "admission_source").show() 

scala> joinDF.select("encounter_id", "dchrgDisp").groupBy("dchrgDisp").count().orderBy($"count".desc).take(10).foreach(println) 

scala> joinDF.select("encounter_id", "admType").groupBy("admType").count().orderBy($"count".desc).take(5).foreach(println) 
[Emergency,35988] 
[Elective,13698] 
[Urgent,12799] 
[NULL,4373] 
[Not Available,2752] 

scala> joinDF.select("encounter_id", "admission_source").groupBy("admission_source").count().orderBy($"count".desc).take(5).foreach(println) 

[ Emergency Room,37649]                                                          
[ Physician Referral,21196] 
[NULL,4801] 
[Transfer from a hospital,2622] 
[ Transfer from another health care facility,1797] 

在接下来的部分,我们将执行一系列数据整理或数据预处理步骤,以提高整体数据质量。

数据预处理

预处理数据需要进行几个数据整理步骤。我们将从处理缺失字段值开始。在处理空值或缺失值时,我们有几个选项。我们可以使用df.na.drop()删除它们,或者使用df.na.fill()用默认值填充它们。这样的字段可以用该列的最常出现的值替换,对于数值字段,也可以用平均值替换。此外,您还可以对该列训练回归模型,并用它来预测缺失值的行的字段值。

在这里,数据集中的缺失字段用?表示,因此我们使用df.na.replace()函数将其替换为"Missing"字符串。

这个操作以medical_specialty字段为例,如下所示:

 scala> diaDataAdmttedDF.select("medical_specialty").where("medical_specialty = '?'").groupBy("medical_specialty").count().show() 

+-----------------+-----+ 
|medical_specialty|count| 
+-----------------+-----+ 
|                ?|33733| 
+-----------------+-----+ 

scala> val diaDataRplcMedSplDF = diaDataAdmttedDF.na.replace("medical_specialty", Map("?" -> "Missing")) 

medical_specialty字段可以有值,例如心脏病学、内科、家庭/全科医学、外科医生或缺失。对于模型来说,缺失值看起来就像medical_specialty的任何其他选择。我们可以创建一个名为has_medical_specialty的新二元特征,并在行包含该值时赋值为1,在未知或缺失时赋值为0。或者,我们也可以为medical_specialty的每个值创建一个二元特征,例如Is_CardiologyIs_SurgeonIs_Missing。然后,这些额外的特征可以在不同的模型中代替或补充medical_specialty特征。

接下来,根据原始论文中的分析,我们在本章的进一步分析中删除了一组列,以保持问题的规模合理,如下所示:

与参考研究/论文中一样,我们也考虑了四组就诊情况:未进行 HbA1c 测试、进行了 HbA1c 测试并且结果在正常范围内、进行了 HbA1c 测试并且结果大于 8%、进行了 HbA1c 测试并且结果大于 8%。

执行这些分组的步骤如下:

scala> diaDataDrpColsDF.groupBy($"A1Cresult").count().show() 

+---------+-----+                                                                
|A1Cresult|count| 
+---------+-----+ 
|     None|57645| 
|       >8| 5866| 
|     Norm| 3691| 
|       >7| 2732| 
+---------+-----+ 

scala> def udfA1CGrps() = udf[Double, String] { a => val x = a match { case "None" => 1.0; case ">8" => 2.0; case ">7" => 3.0; case "Norm" => 4.0;}; x;}  

scala> val diaDataA1CResultsDF = diaDataDrpColsDF.withColumn("A1CResGrp", udfA1CGrps()($"A1Cresult")) 

scala> diaDataA1CResultsDF.groupBy("A1CResGrp").count().withColumn("Percent_of_Population", ($"count" / diaDataA1CResultsDF.count())*100).withColumnRenamed("count", "Num_of_Encounters").show() 
+--------------------+-----------------+---------------------+ 
|           A1CResGrp|Num_of_Encounters|Percent_of_Population| 
+--------------------+-----------------+---------------------+ 
|No test was perfo...|            57645|    82.42771756227299| 
|Result was high a...|             5866|    8.387908599536706| 
|Normal result of ...|             3691|     5.27783338576372| 
|Result was high b...|             2732|    3.906540452426573| 
+--------------------+-----------------+---------------------+ 

由于我们的主要目标是关注导致早期再入院的因素,再入院属性(或结果)有两个值:Readmitted,如果患者在出院后 30 天内再次入院,或Not Readmitted,它涵盖了 30 天后的再入院和根本没有再入院。

我们创建了一个新的有序特征,名为Readmitted,有两个值:ReadmittedNot Readmitted。您也可以对年龄类别使用类似的方法:

scala> def udfReAdmBins() = udf[String, String] { a => val x = a match { case "<30" => "Readmitted"; case "NO" => "Not Readmitted"; case ">30" => "Not Readmitted";}; x;} 

scala> val diaDataReadmtdDF = diaDataA1CResultsDF.withColumn("Readmitted", udfReAdmBins()($"readmitted")) 

我们显示了几个特征的数量与目标变量的值,如下所示。这将有助于识别基于输入数据集中各种属性的记录数量的偏差:

scala> diaDataReadmtdDF.groupBy("race").pivot("Readmitted").agg(count("Readmitted")).show() 
+---------------+--------------+----------+                                      
|           race|Not Readmitted|Readmitted| 
+---------------+--------------+----------+ 
|      Caucasian|         49710|      2613| 
|          Other|          1095|        51| 
|AfricanAmerican|         12185|       423| 
|       Hispanic|          1424|        70| 
|          Asian|           478|        25| 
|              ?|          1795|        65| 
+---------------+--------------+----------+ 

scala> diaDataReadmtdDF.groupBy("A1CResGrp").pivot("Readmitted").agg(count("Readmitted")).orderBy("A1CResGrp").show() 
+--------------------+--------------+----------+                                 
|           A1CResGrp|Not Readmitted|Readmitted| 
+--------------------+--------------+----------+ 
|No test was perfo...|         54927|      2718| 
|Normal result of ...|          3545|       146| 
|Result was high a...|          5618|       248| 
|Result was high b...|          2597|       135| 
+--------------------+--------------+----------+ 

scala> diaDataReadmtdDF.groupBy("gender").pivot("Readmitted").agg(count("Readmitted")).show() 
+---------------+--------------+----------+                                      
|         gender|Not Readmitted|Readmitted| 
+---------------+--------------+----------+ 
|         Female|         35510|      1701| 
|Unknown/Invalid|             3|      null| 
|           Male|         31174|      1546| 
+---------------+--------------+----------+  

接下来,我们将各种年龄范围分组为不同的类别,并将其添加为一列,以获得我们的最终版本的数据集,如图所示。此外,我们删除了genderUnknown/Invalid的三行:

scala> def udfAgeBins() = udf[String, String] { a => val x = a match { case "0-10)" => "Young"; case "[10-20)" => "Young"; case "[20-30)" => "Young"; case "[30-40)" => "Middle"; case "[40-50)" => "Middle"; case "[50-60)" => "Middle"; case "[60-70)" => "Elder";  case "[70-80)" => "Elder"; case "[80-90)" => "Elder"; case "[90-100)" => "Elder";}; x;} 

scala> val diaDataAgeBinsDF = diaDataReadmtdDF.withColumn("age_category", udfAgeBins()($"age")) 

scala> val diaDataRmvGndrDF = diaDataAgeBinsDF.filter($"gender" =!= "Unknown/Invalid") 

经过预处理步骤后的最终 DataFrame 的模式如下所示:

scala> diaDataFinalDF.printSchema()

我们显示了最终数据框中的一些样本记录,如下所示:

scala> diaDataFinalDF.take(5).foreach(println)

完成数据预处理阶段后,我们现在将重点转移到构建机器学习管道上。

构建 Spark ML 管道

在我们的示例 ML 管道中,我们将有一系列管道组件,这些组件在以下部分详细说明。

使用 StringIndexer 对分类特征和标签进行索引

在这个练习中,我们将训练一个随机森林分类器。首先,我们将按照spark.ml的要求对分类特征和标签进行索引。接下来,我们将把特征列组装成一个向量列,因为每个spark.ml机器学习算法都需要它。最后,我们可以在训练数据集上训练我们的随机森林。可选地,我们还可以对标签进行反索引,以使它们更易读。

有几个现成的转换器可用于对分类特征进行索引。我们可以使用VectorAssembler将所有特征组装成一个向量(使用VectorAssembler),然后使用VectorIndexer对其进行索引。VectorIndexer的缺点是它将对每个具有少于maxCategories个不同值的特征进行索引。它不区分给定特征是否是分类的。或者,我们可以使用StringIndexer逐个对每个分类特征进行索引,如下所示。

我们使用StringIndexer将字符串特征转换为Double值。例如,raceIndexer是一个估计器,用于转换种族列,即为输入列中的不同种族生成索引,并创建一个名为raceCat的新输出列。

fit()方法然后将列转换为StringType并计算每个种族的数量。这些步骤如下所示:

scala> val raceIndexer = new StringIndexer().setInputCol("race").setOutputCol("raceCat").fit(diaDataFinalDF) 

scala> raceIndexer.transform(diaDataFinalDF).select("race", "raceCat").show() 

raceIndexer.transform()将生成的索引分配给列中每个种族的值。例如,AfricanAmerican被分配为1.0Caucasian被分配为0.0,依此类推。

scala> raceIndexer.transform(diaDataFinalDF).select("race", "raceCat").groupBy("raceCat").count().show() 
+-------+-----+ 
|raceCat|count| 
+-------+-----+ 
|    0.0|52323| 
|    1.0|12608| 
|    4.0| 1145| 
|    3.0| 1494| 
|    2.0| 1858| 
|    5.0|  503| 
+-------+-----+ 

scala> val raceIndexer = new StringIndexer().setInputCol("race").setOutputCol("raceCat").fit(diaDataFinalDF) 

scala> val rDF = raceIndexer.transform(diaDataFinalDF) 

类似地,我们为性别、年龄组、HbA1c 测试结果、药物变化和糖尿病处方药物创建索引器,并在每个步骤中将它们适配到生成的数据框中:

scala> val genderIndexer = new StringIndexer().setInputCol("gender").setOutputCol("genderCat").fit(rDF) 

scala> val gDF = genderIndexer.transform(rDF) 

scala>  val ageCategoryIndexer  = new StringIndexer().setInputCol("age").setOutputCol("ageCat").fit(gDF) 

scala> val acDF = ageCategoryIndexer.transform(gDF) 

scala>  val A1CresultIndexer  = new StringIndexer().setInputCol("A1CResGrp").setOutputCol("A1CResGrpCat").fit(acDF) 

scala> val a1crDF = A1CresultIndexer.transform(acDF) 

scala> val changeIndexer  = new StringIndexer().setInputCol("change").setOutputCol("changeCat").fit(a1crDF) 

scala> val cDF = changeIndexer.transform(a1crDF) 

scala> val diabetesMedIndexer  = new StringIndexer().setInputCol("diabetesMed").setOutputCol("diabetesMedCat").fit(cDF) 

scala> val dmDF = diabetesMedIndexer.transform(cDF)

我们打印包含各种索引器列的最终数据框的模式:

scala>  dmDF.printSchema() 

我们还可以使用StringIndexer对标签进行索引,如下所示:

scala> val labelIndexer = new StringIndexer().setInputCol("Readmitted").setOutputCol("indexedLabel") 

或者,我们也可以简洁地定义我们的特征索引器,如下所示。然后可以使用VectorAssemblerStringIndexers的序列与数值特征连接起来,以生成特征向量:

scala> val stringIndexers = catFeatColNames.map { colName => 
     |   new StringIndexer() 
     |     .setInputCol(colName) 
     |     .setOutputCol(colName + "Cat") 
     |     .fit(diaDataFinalDF) 
     | }

我们不需要为每个索引器显式调用fit()transform()方法,这可以由管道自动处理。

管道的行为可以总结如下:

  • 它将执行每个阶段,并将当前阶段的结果传递给下一个阶段

  • 如果阶段是一个转换器,那么管道会调用它的transform()

  • 如果阶段是一个估计器,那么管道首先调用fit(),然后调用transform()

  • 如果它是管道中的最后一个阶段,那么估计器在fit()之后不会调用transform()

使用 VectorAssembler 将特征组装成一个列

现在我们的索引工作已经完成,我们需要将所有特征列组装成一个包含所有特征的向量列。为此,我们将使用VectorAssembler转换器,如下所示。然而,首先,由于每个标签的记录数量存在显著偏差,我们需要以适当的比例对记录进行抽样,以使每个标签的记录数量几乎相等:

scala> val dataDF = dmDF.stat.sampleBy("Readmitted", Map("Readmitted" -> 1.0, "Not Readmitted" -> .030), 0) 

scala> val assembler = new VectorAssembler().setInputCols(Array("num_lab_procedures", "num_procedures", "num_medications", "number_outpatient", "number_emergency", "number_inpatient", "number_diagnoses", "admission_type_id", "discharge_disposition_id", "admission_source_id", "time_in_hospital", "raceCat", "genderCat", "ageCat", "A1CresultCat", "changeCat", "diabetesMedCat")).setOutputCol("features") 

或者,我们也可以按照以下步骤实现相同的效果:

scala> val numFeatNames = Seq("num_lab_procedures", "num_procedures", "num_medications", "number_outpatient", "number_emergency", "number_inpatient", "number_diagnoses", "admission_type_id", "discharge_disposition_id", "admission_source_id", "time_in_hospital") 

scala> val catFeatNames = catFeatColNames.map(_ + "Cat") 

scala> val allFeatNames = numFeatNames ++ catFeatNames 

scala> val assembler = new VectorAssembler().setInputCols(Array(allFeatNames: _*)).setOutputCol("features") 

我们应用transform()操作并打印生成的数据框的一些样本记录,如下所示:

scala> val df2 = assembler.transform(dataDF) 

scala> df2.select("Readmitted", "features").take(5).foreach(println) 

VectorIndexer用于对特征进行索引。我们将传递用于预测的所有特征列,以创建一个名为indexedFeatures的新向量列,如下所示:

scala> val featureIndexer = new VectorIndexer().setInputCol("features").setOutputCol("indexedFeatures").setMaxCategories(4).fit(df2) 

在接下来的部分,我们将训练一个随机森林分类器。

使用 Spark ML 分类器

现在数据已经符合spark.ml机器学习算法的预期格式,我们将创建一个RandomForestClassifier组件,如下所示:

scala> val rf = new RandomForestClassifier().setLabelCol("indexedLabel").setFeaturesCol("indexedFeatures").setNumTrees(10) 

标准化的 DataFrame 格式,允许轻松地用其他spark.ml分类器替换RandomForestClassifier,例如DecisionTreeClassifierGBTClassifier,如下所示:

scala> val dt = new DecisionTreeClassifier().setLabelCol("indexedLabel").setFeaturesCol("indexedFeatures") 

scala> val gbt = new GBTClassifier().setLabelCol("indexedLabel").setFeaturesCol("indexedFeatures").setMaxIter(10)

在接下来的部分,我们将通过将标签和特征索引器以及随机森林分类器组装成管道的阶段来创建我们的管道。

创建 Spark ML 管道

接下来,我们将使用到目前为止定义的所有组件来创建一个管道对象。由于所有不同的步骤都已经实现,我们可以组装我们的管道,如下所示:

scala> val pipeline = new Pipeline().setStages(Array(labelIndexer, featureIndexer, rf)) 

在接下来的部分,我们将从输入数据集中创建训练和测试数据集。

创建训练和测试数据集

为了训练和评估模型,我们将在两个 DataFrame 之间随机拆分输入数据:一个训练集(包含 80%的记录)和一个测试集(包含 20%的记录)。我们将使用训练集训练模型,然后使用测试集评估模型。以下内容可用于拆分输入数据:

scala> val Array(trainingData, testData) = df2.randomSplit(Array(0.8, 0.2), 11L) 

我们现在将使用管道来拟合训练数据。拟合管道到训练数据会返回一个PipelineModel对象:

scala> val model = pipeline.fit(trainingData) 

在接下来的部分,我们将对我们的测试数据集进行预测。

使用 PipelineModel 进行预测

前一步骤中的PipelineModel对象用于对测试数据集进行预测,如下所示:

scala> val predictions = model.transform(testData) 

scala> predictions.select("prediction", "indexedLabel", "features").show(25) 

接下来,我们将通过测量预测的准确性来评估我们的模型,如下所示:

scala> val evaluator = new MulticlassClassificationEvaluator().setLabelCol("indexedLabel").setPredictionCol("prediction").setMetricName("accuracy")

scala> val accuracy = evaluator.evaluate(predictions) 
accuracy: Double = 0.6483412322274882                                            

scala> println("Test Error = " + (1.0 - accuracy)) 
Test Error = 0.3516587677725118 

最后,我们还可以打印我们的随机森林模型,以了解我们模型中创建的十棵树中使用的逻辑,如下所示:

scala> val rfModel = model.stages(2).asInstanceOf[RandomForestClassificationModel] 

scala> println("Learned classification forest model:\n" + rfModel.toDebugString) 

在接下来的部分,我们将展示通过交叉验证从一组参数中选择最佳预测模型的过程。

选择最佳模型

为了选择最佳模型,我们将对一组参数进行网格搜索。对于每组参数的组合,我们将进行交叉验证,并根据某些性能指标保留最佳模型。这个过程可能会很繁琐,但是spark.ml通过易于使用的 API 简化了这一过程。

对于交叉验证,我们选择一个值k作为折叠数,例如,3的值将把数据集分为三部分。从这三部分中,将生成三个不同的训练和测试数据对(用于训练的数据占三分之二,用于测试的数据占三分之一)。模型将根据三个对的选择性能指标的平均值进行评估。

每个参数都分配了一组值。我们示例中使用的参数是maxBins(用于离散化连续特征和在每个节点分割特征的最大箱数)、maxDepth(树的最大深度)和impurity(用于信息增益计算的标准)。

首先,我们创建一个参数网格,如下所示:

scala> val paramGrid = new ParamGridBuilder().addGrid(rf.maxBins, Array(25, 28, 31)).addGrid(rf.maxDepth, Array(4, 6, 8)).addGrid(rf.impurity, Array("entropy", "gini")).build() 

接下来,我们将定义一个评估器,根据其名称,它将根据某些指标评估我们的模型。内置的评估器可用于回归,二元和多类分类模型。

scala> val evaluator = new BinaryClassificationEvaluator().setLabelCol("indexedLabel")

最后,选择k=2(对于真实世界的模型设置更高的数字),数据在交叉验证期间将被分成的折叠数,我们可以创建一个CrossValidator对象,如下所示:

scala> val cv = new CrossValidator().setEstimator(pipeline).setEvaluator(evaluator).setEstimatorParamMaps(paramGrid).setNumFolds(2) 

在运行交叉验证时,特别是在更大的数据集上,需要小心,因为它将训练k x p 个模型,其中p是网格中每个参数值的数量的乘积。因此,对于p18,交叉验证将训练36个不同的模型。

由于我们的CrossValidator是一个估计器,我们可以通过调用fit()方法来获得我们数据的最佳模型:

scala> val crossValidatorModel = cv.fit(df2) 

现在我们可以对testData进行预测,如下所示。与交叉验证之前的值相比,准确度值略有改善:

scala> val predictions = crossValidatorModel.transform(testData) 

scala> predictions.select("prediction", "indexedLabel", "features").show(25) 

scala>  val accuracy = evaluator.evaluate(predictions) 
accuracy: Double = 0.6823964115630783 

scala>  println("Test Error = " + (1.0 - accuracy)) 
Test Error = 0.3176035884369217 

在接下来的部分,我们将展示 Spark ML 公开的常用接口的强大功能,以便简化 ML 管道的开发和测试。

在管道中更改 ML 算法

在较早的部分中,我们展示了如何轻松地用其他分类器替换 RandomForestClassifier,例如 DecisionTreeClassifier 或 GBTClassifer。在本节中,我们将用逻辑回归模型替换随机森林分类器。逻辑回归解释了一个基于其他变量(称为自变量)的二元值因变量之间的关系。二元值01可以表示预测值,例如通过/不通过,是/否,死/活等。根据自变量的值,它预测因变量取一个分类值(例如01)的概率。

首先,我们将创建一个LogtisticRegression组件,如下所示:

scala> val lr = new LogisticRegression().setMaxIter(10).setRegParam(0.3).setElasticNetParam(0.8).setLabelCol("indexedLabel").setFeaturesCol("indexedFeatures") 

我们可以使用之前的标签和特征索引器,并将它们与逻辑回归组件结合起来创建一个新的管道,如下所示。此外,我们使用fit()transform()方法来训练,然后对测试数据集进行预测。请注意,代码看起来与之前用于随机森林管道的方法非常相似:

scala> val pipeline = new Pipeline().setStages(Array(labelIndexer, featureIndexer, lr)) 

scala> val Array(trainingData, testData) = df2.randomSplit(Array(0.8, 0.2), 11L) 

scala> val model = pipeline.fit(trainingData) 

scala> val predictions = model.transform(testData) 

scala> predictions.select("A1CResGrpCat", "indexedLabel", "prediction").show() 
+------------+------------+----------+ 
|A1CResGrpCat|indexedLabel|prediction| 
+------------+------------+----------+ 
|         0.0|         0.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         3.0|         0.0|       0.0| 
|         3.0|         0.0|       0.0| 
|         0.0|         1.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         0.0|         1.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         0.0|         1.0|       0.0| 
|         0.0|         1.0|       0.0| 
|         0.0|         1.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         0.0|         1.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         0.0|         0.0|       0.0| 
|         1.0|         0.0|       0.0| 
|         0.0|         0.0|       0.0| 
+------------+------------+----------+ 
only showing top 20 rows 

scala> predictions.select($"indexedLabel", $"prediction").where("indexedLabel != prediction").count() 
res104: Long = 407 

在接下来的几节中,我们将介绍 Spark 中提供的一系列工具和实用程序,可用于实现更好的 ML 模型。

介绍 Spark ML 工具和实用程序

在接下来的几节中,我们将探索 Spark ML 提供的各种工具和实用程序,以便轻松高效地选择特征并创建优秀的 ML 模型。

使用主成分分析选择特征

如前所述,我们可以使用主成分分析PCA)在数据上派生新特征。这种方法取决于问题,因此有必要对领域有很好的理解。

这个练习通常需要创造力和常识来选择一组可能与问题相关的特征。通常需要进行更广泛的探索性数据分析,以帮助更好地理解数据和/或识别导致一组良好特征的模式。

PCA 是一种统计程序,它将一组可能相关的变量转换为通常较少的一组线性不相关的变量。得到的一组不相关的变量称为主成分。PCA类训练一个模型,将向量投影到一个较低维度的空间。以下示例显示了如何将我们的多维特征向量投影到三维主成分。

根据维基百科en.wikipedia.org/wiki/Principal_component_analysis,“这种转换是这样定义的,以便第一个主成分具有最大可能的方差(即,它尽可能多地解释数据的变异性),而每个随后的成分依次具有在约束下可能的最高方差,即它与前面的成分正交。”

我们将使用用于在本章前面用于分类的随机森林算法拟合的数据集来构建我们的模型:

scala> val pca = new PCA().setInputCol("features").setOutputCol("pcaFeatures").setK(3).fit(df2) 

scala> val result = pca.transform(df2).select("pcaFeatures") 

scala> result.take(5).foreach(println) 
[[-52.49989457347012,-13.91558303051395,-0.9577895037038642]] 
[[-17.787698281398306,-2.3653156500575743,0.67773733633875]] 
[[-42.61350777796136,-8.019782413210889,4.744540532872854]] 
[[-36.62417236331611,-7.161756365322481,-0.06153645411567934]] 
[[-51.157132286686824,-2.6029561027003685,0.8995320464587268]] 

使用编码器

在本节中,我们使用独热编码将标签索引的列映射到二进制向量的列,最多只有一个值为 1。这种编码允许期望连续特征的算法(如LogisticRegression)使用分类特征:


scala> val indexer = new StringIndexer().setInputCol("race").setOutputCol("raceIndex").fit(df2) 

scala> val indexed = indexer.transform(df2) 

scala> val encoder = new OneHotEncoder().setInputCol("raceIndex").setOutputCol("raceVec") 

scala> val encoded = encoder.transform(indexed) 

scala> encoded.select("raceVec").show() 

使用 Bucketizer

Bucketizer 用于将连续特征的列转换为特征桶的列。我们指定n+1分割参数,将连续特征映射到 n 个桶中。分割应严格按升序排列。

通常,我们将Double.NegativeInfinityDouble.PositiveInfinity添加为分割的外部边界,以防止潜在的 Bucketizer 边界异常。在下面的示例中,我们指定了六个分割,然后为数据集中的num_lab_procedures特征(值在1126之间)定义了一个bucketizer,如下所示:

scala> val splits = Array(Double.NegativeInfinity, 20.0, 40.0, 60.0, 80.0, 100.0, Double.PositiveInfinity) 

scala> val bucketizer = new Bucketizer().setInputCol("num_lab_procedures").setOutputCol("bucketedLabProcs").setSplits(splits) 

scala> // Transform original data into its bucket index. 

scala> val bucketedData = bucketizer.transform(df2) 

scala> println(s"Bucketizer output with ${bucketizer.getSplits.length-1} buckets") 
Bucketizer output with 6 buckets 

使用 VectorSlicer

VectorSlicer是一个转换器,它接受一个特征向量并返回原始特征的子集的新特征向量。它用于从向量列中提取特征。我们可以使用VectorSlicer来测试我们的模型使用不同数量和组合的特征。

在下面的示例中,我们最初使用四个特征,然后放弃其中一个。这些特征切片可用于测试包括/排除特征对于特征集的重要性:

scala> val slicer = new VectorSlicer().setInputCol("features").setOutputCol("slicedfeatures").setNames(Array("raceCat", "genderCat", "ageCat", "A1CResGrpCat")) 

scala> val output = slicer.transform(df2) 

scala> output.select("slicedFeatures").take(5).foreach(println) 
[(4,[1],[1.0])] 
[[0.0,1.0,0.0,0.0]] 
[(4,[],[])] 
[(4,[1],[1.0])] 
[[1.0,1.0,1.0,0.0]] 

scala> val slicer = new VectorSlicer().setInputCol("features").setOutputCol("slicedfeatures").setNames(Array("raceCat", "genderCat", "ageCat")) 

scala> val output = slicer.transform(df2) 

scala> output.select("slicedFeatures").take(5).foreach(println) 
[(3,[1],[1.0])] 
[[0.0,1.0,0.0]] 
[(3,[],[])] 
[(3,[1],[1.0])] 
[[1.0,1.0,1.0]] 

使用卡方选择器

ChiSqSelector启用卡方特征选择。它在具有分类特征的标记数据上运行。ChiSqSelector使用独立性的卡方检验来选择特征。在我们的示例中,我们使用numTopFeatures来选择一定数量的具有最大预测能力的顶级特征:

scala> def udfReAdmLabels() = udf[Double, String] { a => val x = a match { case "Readmitted" => 0.0; case "Not Readmitted" => 0.0;}; x;} 

scala> val df3 = df2.withColumn("reAdmLabel", udfReAdmLabels()($"Readmitted")) 

scala> val selector = new ChiSqSelector().setNumTopFeatures(1).setFeaturesCol("features").setLabelCol("reAdmLabel").setOutputCol("selectedFeatures") 

scala> val result = selector.fit(df3).transform(df3) 

scala> println(s"ChiSqSelector output with top ${selector.getNumTopFeatures} features selected") 
ChiSqSelector output with top 1 features selected 

scala> result.select("selectedFeatures").show() 

使用标准化器

我们可以使用Normalizer对象(一个转换器)对数据进行标准化。Normalizer的输入是由VectorAssembler创建的列。它将列中的值标准化,产生一个包含标准化值的新列。

val normalizer = new Normalizer().setInputCol("raw_features ").setOutputCol("features") 

检索我们的原始标签

IndexToStringStringIndexer的反向操作,将索引转换回它们的原始标签。由RandomForestClassifier生成的模型的随机森林变换方法产生一个包含索引标签的预测列,我们需要对其进行非索引化以检索原始标签值,如下所示:

scala> val labelIndexer = new StringIndexer().setInputCol("Readmitted").setOutputCol("indexedLabel").fit(df2) 

scala> val featureIndexer = new VectorIndexer().setInputCol("features").setOutputCol("indexedFeatures").setMaxCategories(4).fit(df2) 

scala> val Array(trainingData, testData) = df2.randomSplit(Array(0.7, 0.3)) 

scala> val gbt = new GBTClassifier().setLabelCol("indexedLabel").setFeaturesCol("indexedFeatures").setMaxIter(10) 

scala> val labelConverter = new IndexToString().setInputCol("prediction").setOutputCol("predictedLabel").setLabels(labelIndexer.labels) 

scala> val pipeline = new Pipeline().setStages(Array(labelIndexer, featureIndexer, gbt, labelConverter)) 

scala> val model = pipeline.fit(trainingData) 

scala> val predictions = model.transform(testData) 

scala> predictions.select("predictedLabel", "indexedLabel", "features").show(5) 
+--------------+------------+--------------------+ 
|predictedLabel|indexedLabel|            features| 
+--------------+------------+--------------------+ 
|    Readmitted|         0.0|(17,[0,2,5,6,7,8,...| 
|Not Readmitted|         1.0|[43.0,1.0,7.0,0.0...| 
|    Readmitted|         1.0|(17,[0,2,5,6,7,8,...| 
|    Readmitted|         0.0|(17,[0,1,2,6,7,8,...| 
|    Readmitted|         0.0|(17,[0,2,6,7,8,9,...| 
+--------------+------------+--------------------+ 
only showing top 5 rows 

在下一节中,我们将转向介绍使用 k-means 算法的 Spark ML 聚类的示例。

实施 Spark ML 聚类模型

在本节中,我们将解释使用 Spark ML 进行聚类。我们将使用一个关于学生对某一主题的知识状态的公开可用数据集。

数据集可从 UCI 网站下载,网址为archive.ics.uci.edu/ml/datasets/User+Knowledge+Modeling

数据集中包含的记录属性已从前面提到的 UCI 网站复制在这里供参考:

  • STG:目标对象材料的学习时间程度(输入值)

  • SCG:用户对目标对象材料的重复次数程度(输入值)

  • STR:用户对目标对象相关对象的学习时间程度(输入值)

  • LPR:用户对目标对象相关对象的考试表现(输入值)

  • PEG:用户对目标对象(输入值)的考试表现

  • UNS:用户的知识水平(目标值)

首先,我们将编写一个 UDF 来创建两个级别,表示学生的两个类别——低于平均水平和高于平均水平,从原始数据集中包含的五个类别中。我们将训练和测试 CSV 文件合并,以获得足够数量的输入记录:

scala> def udfLabels() = udf[Integer, String] { a => val x = a match { case "very_low" => 0; case "Very Low" => 0; case "Low" => 0; case "Middle" => 1; case "High" => 1;}; x;} 

我们将读取输入数据集,并创建一个名为label的列,由 UDF 填充。这样可以让我们每个类别的记录数几乎相等:

scala> val inDataDF = spark.read.option("header", true).csv("file:///Users/aurobindosarkar/Downloads/Data_User_Modeling.csv").withColumn("label", udfLabels()($"UNS")) 

scala> inDataDF.select("label").groupBy("label").count().show() 
+-----+-----+ 
|label|count| 
+-----+-----+ 
|    1|  224| 
|    0|  179| 
+-----+-----+ 

scala> inDataDF.cache() 

接下来,我们将把数字字段转换为Double值,验证 DataFrame 中的记录数,显示一些样本记录,并打印模式,如下所示:

scala> val inDataFinalDF = inDataDF.select($"STG".cast(DoubleType), $"SCG".cast(DoubleType), $"STR".cast(DoubleType), $"LPR".cast(DoubleType), $"PEG".cast(DoubleType), $"UNS", $"label") 

scala> inDataFinalDF.count() 
res2: Long = 403 

scala> inDataFinalDF.take(5).foreach(println) 
[0.0,0.0,0.0,0.0,0.0,very_low,0] 
[0.08,0.08,0.1,0.24,0.9,High,1] 
[0.06,0.06,0.05,0.25,0.33,Low,0] 
[0.1,0.1,0.15,0.65,0.3,Middle,1] 
[0.08,0.08,0.08,0.98,0.24,Low,0] 

scala> inDataFinalDF.printSchema() 
root 
 |-- STG: double (nullable = true) 
 |-- SCG: double (nullable = true) 
 |-- STR: double (nullable = true) 
 |-- LPR: double (nullable = true) 
 |-- PEG: double (nullable = true) 
 |-- UNS: string (nullable = true) 
 |-- label: integer (nullable = true) 

接下来,我们将使用VectorAssembler来创建特征列,如下所示:

scala> val allFeatNames = Seq("STG", "SCG", "STR", "LPR", "PEG") 

scala> val assembler = new VectorAssembler().setInputCols(Array(allFeatNames: _*)).setOutputCol("features") 

scala> val df2 = assembler.transform(inDataFinalDF) 

scala> df2.cache() 

scala> import org.apache.spark.ml.clustering.KMeans 

Below, we create the k-means component with 2 clusters. 

scala> val kmeans = new KMeans().setK(2).setSeed(1L) 

scala> val model = kmeans.fit(df2) 

您可以使用explainParams来列出 k-means 模型的详细信息,如下所示:

scala> println(kmeans.explainParams) 
featuresCol: features column name (default: features) 
initMode: The initialization algorithm. Supported options: 'random' and 'k-means||'. (default: k-means||) 
initSteps: The number of steps for k-means|| initialization mode. Must be > 0\. (default: 2) 
k: The number of clusters to create. Must be > 1\. (default: 2, current: 2)
maxIter: maximum number of iterations (>= 0) (default: 20)
predictionCol: prediction column name (default: prediction)
seed: random seed (default: -1689246527, current: 1)
tol: the convergence tolerance for iterative algorithms (>= 0) (default: 1.0E-4)

通过计算平方误差总和WSSSE)来评估聚类的质量。标准的 k-means 算法旨在最小化每个簇中点到质心的距离的平方和。增加k的值可以减少这种误差。通常,最佳的k是 WSSSE 图中出现“拐点”的地方:

scala> val WSSSE = model.computeCost(df2) 
WSSSE: Double = 91.41199908476494 

scala> println(s"Within Set Sum of Squared Errors = $WSSSE") 
Within Set Sum of Squared Errors = 91.41199908476494 

scala> model.clusterCenters.foreach(println) 
[0.310042654028436,0.31230331753554513,0.41459715639810407,0.4508104265402843,0.2313886255924172] 
[0.4005052083333334,0.40389583333333334,0.5049739583333334,0.4099479166666665,0.7035937499999997] 

scala> val transformed =  model.transform(df2) 

scala> transformed.take(5).foreach(println) 
[0.0,0.0,0.0,0.0,0.0,very_low,0,(5,[],[]),0] 
[0.08,0.08,0.1,0.24,0.9,High,1,[0.08,0.08,0.1,0.24,0.9],1] 
[0.06,0.06,0.05,0.25,0.33,Low,0,[0.06,0.06,0.05,0.25,0.33],0] 
[0.1,0.1,0.15,0.65,0.3,Middle,1,[0.1,0.1,0.15,0.65,0.3],0] 
[0.08,0.08,0.08,0.98,0.24,Low,0,[0.08,0.08,0.08,0.98,0.24],0] 

在这里,我们将计算数据集中标签和预测值之间的差异数量:

scala> transformed.select("prediction").groupBy("prediction").count().orderBy("prediction").show() 
+----------+-----+ 
|prediction|count| 
+----------+-----+ 
|         0|  211| 
|         1|  192| 
+----------+-----+ 

scala> val y1DF = transformed.select($"label", $"prediction").where("label != prediction") 

scala> y1DF.count() 
res14: Long = 34 

现在,我们将分开包含预测值为01的 DataFrames 以显示样本记录:

scala> transformed.filter("prediction = 0").show() 

scala> transformed.filter("prediction = 1").show()

我们还可以使用describe来查看每个预测标签的摘要统计信息,如下所示:

scala> transformed.filter("prediction = 0").select("STG", "SCG", "STR", "LPR", "PEG").describe().show() 

scala> transformed.filter("prediction = 1").select("STG", "SCG", "STR", "LPR", "PEG").describe().show() 

scala> println("No. of mis-matches between predictions and labels =" + y1DF.count()+"\nTotal no. of records=  "+ transformed.count()+"\nCorrect predictions =  "+ (1-(y1DF.count()).toDouble/transformed.count())+"\nMis-match = "+ (y1DF.count()).toDouble/transformed.count()) 

No. of mis-matches between predictions and labels =34
Total no. of records= 403
Correct predictions = 0.9156327543424317
Mis-match = 0.08436724565756824

接下来,我们将输入一些测试输入记录,模型将预测它们的簇:

scala> val testDF = spark.createDataFrame(Seq((0.08,0.08,0.1,0.24,0.9, Vectors.dense(0.08,0.08,0.1,0.24,0.9)))).toDF("STG", "SCG", "STR", "LPR", "PEG", "features") 

scala> model.transform(testDF).show() 
+----+----+---+----+---+--------------------+----------+ 
| STG| SCG|STR| LPR|PEG|            features|prediction| 
+----+----+---+----+---+--------------------+----------+ 
|0.08|0.08|0.1|0.24|0.9|0.08,0.08,0.1,0....|         1| 
+----+----+---+----+---+--------------------+----------+ 

scala> val testDF = spark.createDataFrame(Seq((0.06,0.06,0.05,0.25,0.33, Vectors.dense(0.06,0.06,0.05,0.25,0.33)))).toDF("STG", "SCG", "STR", "LPR", "PEG", "features")

scala> model.transform(testDF).show() 
+----+----+----+----+----+--------------------+----------+ 
| STG| SCG| STR| LPR| PEG|            features|prediction| 
+----+----+----+----+----+--------------------+----------+ 
|0.06|0.06|0.05|0.25|0.33|[0.06,0.06,0.05,0...|         0| 
+----+----+----+----+----+--------------------+----------+ 

关于流式机器学习应用程序和大规模处理架构等主题,将在[第十二章中详细介绍,大规模应用架构中的 Spark SQL

总结

在本章中,我们介绍了机器学习应用程序。我们涵盖了机器学习中最重要的一个主题,称为特征工程。此外,我们提供了使用 Spark ML API 构建分类管道和聚类应用程序的代码示例。此外,我们还介绍了一些工具和实用程序,可以帮助更轻松、更高效地选择特征和构建模型。

在下一章中,我们将介绍 GraphFrame 应用程序,并提供使用 Spark SQL DataFrame/Dataset API 构建图应用程序的示例。我们还将对图应用程序应用各种图算法。

第七章:在图应用中使用 Spark SQL

在本章中,我们将介绍在图应用中使用 Spark SQL 的典型用例。图在许多不同的领域中很常见。通常,图是使用特殊的图处理引擎进行分析的。GraphX 是用于图计算的 Spark 组件。它基于 RDD,并支持图抽象和操作,如子图、aggregateMessages 等。此外,它还公开了 Pregel API 的变体。然而,我们的重点将放在建立在 Spark SQL Dataset/DataFrame API 之上的 GraphFrame API 上。GraphFrames 是一个集成系统,结合了图算法、模式匹配和查询。GraphFrame API 目前仍处于测试版(截至 Spark 2.2),但绝对是 Spark 应用的未来图处理 API。

具体来说,在本章中,您将学习以下主题:

  • 使用 GraphFrames 创建大规模图形

  • 执行一些基本的图操作

  • 使用 GraphFrames 进行模式分析

  • 使用 GraphFrames 处理子图

  • 执行图算法

  • 处理包含多种关系类型的图形

  • GraphFrames 中的分区

介绍大规模图应用

基于大型数据集的图分析在各个领域变得越来越重要,比如社交网络、通讯网络、引用网络、网页图、交通网络、产品共购网络等。通常,图是从表格或关系格式的源数据中创建的,然后应用程序(如搜索和图算法)在其上运行以得出关键见解。

GraphFrames 提供了一个声明式 API,可用于大规模图形上的交互式查询和独立程序。由于 GraphFrames 是建立在 Spark SQL 之上的,它能够实现跨计算的并行处理和优化:

GraphFrame API 中的主要编程抽象是 GraphFrame。在概念上,它由表示图的顶点和边的两个数据框组成。顶点和边可以具有多个属性,这些属性也可以用于查询。例如,在社交网络中,顶点可以包含姓名、年龄、位置等属性,而边可以表示节点(网络中的人)之间的关系。由于 GraphFrame 模型可以支持每个顶点和边的用户定义属性,因此它等同于属性图模型。此外,可以使用模式定义视图,以匹配网络中各种子图的形状。

在接下来的章节中,我们将从几个公共数据集中构建图形,这些数据集以关系格式可用,然后在它们上运行各种图操作和算法。GraphFrames 优化了关系和图计算的执行。这些计算可以使用关系运算符、模式和算法调用来指定。

在接下来的章节中,我们将使用 Spark shell 来定义图形、查询它们,并在其上交互式地运行算法。

使用 GraphFrames 探索图形

在本节中,我们使用 Spark GraphFrames 来探索建模为图的数据。图的顶点和边被存储为数据框,并且支持使用 Spark SQL 和基于数据框的查询来操作它们。由于数据框可以支持各种数据源,我们可以从关系表、文件(JSON、Parquet、Avro 和 CSV 等)等读取输入顶点和边的信息。

顶点数据框必须包含一个名为id的列,用于指定每个顶点的唯一 ID。同样,边数据框必须包含名为src(源顶点 ID)和dst(目标顶点 ID)的两列。顶点和边数据框都可以包含用于属性的额外列。

GraphFrames 提供了一个简洁的语言集成 API,统一了图分析和关系查询。该系统基于连接计划和执行代数优化来优化各个步骤。可以将机器学习代码、外部数据源和 UDF 与 GraphFrames 集成,以构建更复杂的应用程序。

我们将从一个包含亚马逊共购数据的文件中读取顶点和边开始我们的编码练习。节点代表各种物品,源和目标顶点之间的边定义了一个alsopurchased关系。这些练习的数据集可以从snap.stanford.edu/data/amazon0601.html下载。

按照以下方式启动 Spark shell,以在 Spark shell 环境中包含 GraphFrame 库:

./bin/spark-shell --packages graphframes:graphframes:0.3.0-spark2.0-s_2.11 --driver-memory 12g

首先,我们导入我们示例中需要的所有包,如下所示:

scala> import org.apache.spark.sql.types._
scala> import org.apache.spark.sql.functions._
scala> import spark.implicits._
scala> import org.apache.spark.sql.Row
scala> import org.graphframes._

构建图框架

可以使用两个 DataFrame 构建 GraphFrame:一个顶点 DataFrame 和一个边 DataFrame。在这里,我们从包含边信息的单个 DataFrame 中创建 GraphFrame。

我们将从包含在我们的输入文件中的边的源和目标顶点派生顶点 DataFrame。

读取输入文件以创建边的 RDD,如下所示:

scala> val edgesRDD = spark.sparkContext.textFile("file:///Users/aurobindosarkar/Downloads/amzncopurchase/amazon0601.txt")

接下来,为边定义一个模式,并将边 RDD 转换为 DataFrame,如下几个步骤所示:

scala> val schemaString = "src dst"
scala> val fields = schemaString.split(" ").map(fieldName => StructField(fieldName, StringType, nullable = false))

scala> val edgesSchema = new StructType(fields)

scala> val rowRDD = edgesRDD.map(_.split("\t")).map(attributes => Row(attributes(0).trim, attributes(1).trim))

scala> val edgesDF = spark.createDataFrame(rowRDD, edgesSchema)

接下来,通过从边 DataFrame 中选择不同的源和目标顶点,为顶点创建一个 DataFrame。两个结果 DataFrame 的并集,选择不同的顶点,给我们最终的顶点 DataFrame:

scala> val srcVerticesDF = edgesDF.select($"src").distinct
scala> val destVerticesDF = edgesDF.select($"dst").distinct
scala> val verticesDF = srcVerticesDF.union(destVerticesDF).distinct.select($"src".alias("id"))

我们可以通过将它们与源站点报告的数字进行匹配,来验证这些 DataFrame 中的节点和顶点的数量(对于我们的输入数据集):

scala> edgesDF.count()
res0: Long = 3387388                                                          
scala> verticesDF.count()
res1: Long = 403394

接下来,我们从顶点和边 DataFrame 创建一个GraphFrame,用于亚马逊共购数据:

scala> val g = GraphFrame(verticesDF, edgesDF)

在下一节中,我们将探索我们刚刚创建的图的一些属性。

基本图查询和操作

在本节中,我们将涵盖图的简单查询和操作,包括显示顶点、边以及顶点的入度和出度,如下所示:

scala> g.vertices.show(5)
+---+                                                                     
| id|
+---+
|296|
|467|
|675|
|691|
|829|
+---+
only showing top 5 rows
scala> g.edges.show(5)
+---+---+
|src|dst|
+---+---+
| 0| 1|
| 0| 2|
| 0| 3|
| 0| 4|
| 0| 5|
+---+---+
only showing top 5 rows
scala> g.inDegrees.show(5)
+----+--------+                                                                
| id|inDegree|
+----+--------+
| 467|     28|
|1090|      9|
| 296|      5|
|3959|      7|
|6240|     44|
+----+--------+
only showing top 5 rows
scala> g.outDegrees.show(5)
+---+---------+                                                                
| id|outDegree|
+---+---------+
|296|       10|
|467|       10|
|675|       10|
|691|       10|
|829|       10|
+---+---------+
only showing top 5 rows

我们还可以对边和顶点及其属性应用过滤器,如下所示:

scala> g.edges.filter("src == 2").show()
+---+---+
|src|dst|
+---+---+
|  2|  0|
|  2|  1|
|  2|  3|
|  2|  4|
|  2|  6|
|  2| 10|
|  2| 47|
|  2| 54|
|  2|118|
|  2|355|
+---+---+
scala> g.edges.filter("src == 2").count()
res6: Long = 10
scala> g.edges.filter("dst == 2").show()
+---+---+
|src|dst|
+---+---+
|  0|  2|
|  1|  2|
|  3|  2|
+---+---+
scala> g.inDegrees.filter("inDegree >= 10").show(5)
+----+--------+                                                                
| id|inDegree|
+----+--------+
| 467|      28|
|6240|      44|
|1159|      12|
|1512|     110|
| 675|      13|
+----+--------+
only showing top 5 rows

此外,我们还可以使用groupBysort操作,如下面的示例所示:

scala> g.inDegrees.groupBy("inDegree").count().sort(desc("inDegree")).show(5)
+--------+-----+                                                              
|inDegree|count|
+--------+-----+
|   2751|     1|
|   2487|     1|
|   2281|     1|
|   1512|     1|
|   1174|     1|
+--------+-----+
only showing top 5 rows
scala> g.outDegrees.groupBy("outDegree").count().sort(desc("outDegree")).show(5)
+---------+------+                                                            
|outDegree| count|
+---------+------+
|       10|279108|
|        9| 13297|
|        8| 11370|
|        7| 11906|
|        6| 12827|
+---------+------+
only showing top 5 rows

在下一节中,我们将探索图中存在的结构模式。

使用 GraphFrames 进行模式分析

查找模式有助于我们执行查询,以发现图中的结构模式。网络模式是图中反复出现的子图或模式,代表顶点之间的交互或关系。模式可以用于我们的产品共购图,以根据图表示的产品的结构属性、它们的属性和它们之间的关系,获得有关用户行为的见解。这些信息可以用于推荐和/或广告引擎。

例如,以下模式表示一个使用情况,其中购买产品(a)的客户也购买了另外两种产品(b)(c)

有关模式分析的详细内容,请参阅Abhishek Srivastava《亚马逊产品共购网络》中的模式分析

在本节中,我们将使用 GraphFrames 主要对代表购买网络数据集中各种关系的3-4-节点模式进行建模。GraphFrame 模式查找使用一种用于表达结构查询的声明性领域特定语言DSL)。在模式中,顶点和边被赋予名称。模式的基本单位是边。例如,(a) – [e] -> (b)表示从顶点a到顶点b的边e。顶点用括号(a)表示,而边用方括号[e]表示。模式被表示为边的并集,边模式可以用分号连接。

我们将从一个简单的查询开始我们的编码练习,在这个查询中,我们搜索购买产品a也意味着购买产品b,反之亦然的产品组合。这里的查找操作将搜索双向边连接的顶点对:

scala> val motifs = g.find("(a)-[e]->(b); (b)-[e2]->(a)")
scala> motifs.show(5)
+--------+---------------+--------+---------------+                            
|       a|             e|       b|             e2|
+--------+---------------+--------+---------------+
| [85609]| [85609,100018]|[100018]| [100018,85609]|
| [86839]| [86839,100042]|[100042]| [100042,86839]|
| [55528]| [55528,100087]|[100087]| [100087,55528]|
|[178970]|[178970,100124]|[100124]|[100124,178970]|
|[100124]|[100124,100125]|[100125]|[100125,100124]|
+--------+---------------+--------+---------------+
only showing top 5 rows

我们也可以对结果应用过滤器;例如,在下面的过滤器中,我们指定了顶点b的值为2

scala> motifs.filter("b.id == 2").show()
+---+-----+---+-----+                                                          
|  a|    e|  b|   e2|
+---+-----+---+-----+
|[3]|[3,2]|[2]|[2,3]|
|[0]|[0,2]|[2]|[2,0]|
|[1]|[1,2]|[2]|[2,1]|
+---+-----+---+-----+

下面的例子指定了从abc的两条单独的边。这种模式通常表示的情况是,当客户购买产品(a)时,她也会购买(b)(c)中的一个或两个:

此外,模式还指定了相同的顶点a是边e1e2的共同源:

scala> val motifs3 = g.find("(a)-[e1]->(b); (a)-[e2]->(c)").filter("(b != c)")

scala> motifs3.show(5)
+--------+---------------+--------+---------------+--------+                  
|       a|             e1|       b|             e2|       c|
+--------+---------------+--------+---------------+--------+
|[109254]|   [109254,8742]| [8742]|[109254,100010]|[100010]|
|[109254]|   [109254,8741]| [8741]|[109254,100010]|[100010]|
|[109254]| [109254,59782]| [59782]|[109254,100010]|[100010]|
|[109254]|[109254,115349]|[115349]|[109254,100010]|[100010]|
|[109254]| [109254,53996]| [53996]|[109254,100010]|[100010]|
+--------+---------------+--------+---------------+--------+
only showing top 5 rows

由于边的列包含冗余信息,当不需要时,我们可以省略模式中顶点或边的名称;例如,在模式(a)-[]->(b)中,[]表示顶点ab之间的任意边。

结果中没有边的列。同样,(a)-[e]->()表示顶点a的出边,但没有指定目标顶点的名称:

scala> val motifs3 = g.find("(a)-[]->(b); (a)-[]->(c)").filter("(b != c)")
scala> motifs3.show()
+--------+--------+--------+                                                  
|       a|       b|       c|
+--------+--------+--------+
|[109254]| [8742]| [100010]|
|[109254]| [8741]| [100010]|
|[109254]| [59782]|[100010]|
|[109254]|[115349]|[100010]|
|[109254]| [53996]|[100010]|
|[109254]|[109257]|[100010]|
|[109254]| [62046]|[100010]|
|[109254]| [94411]|[100010]|
|[109254]|[115348]|[100010]|
|[117041]| [73722]|[100010]|
|[117041]|[193912]|[100010]|
|[117041]| [52568]|[100010]|
|[117041]| [57835]|[100010]|
|[117041]|[164019]|[100010]|
|[117041]| [63821]|[100010]|
|[117041]|[162691]|[100010]|
|[117041]| [69365]|[100010]|
|[117041]| [4849]|[100010]|
|[148522]| [8742]|[100010]|
|[148522]|[100008]|[100010]|
+--------+--------+--------+
only showing top 20 rows
scala> motifs3.count()
res20: Long = 28196586

在下面的例子中,我们指定了从abc的两条单独的边,以及从ba的另一条边。这种模式通常表示的情况是,ab之间存在相互关系(表明产品之间存在密切的相似性):

scala> val motifs3 = g.find("(a)-[]->(b); (a)-[]->(c); (b)-[]->(a)").filter("(b != c)")
scala> motifs3.show()
+-------+--------+--------+                                                    
|      a|       b|       c|
+-------+--------+--------+
|[85609]|[100018]| [85611]|
|[85609]|[100018]| [85610]|
|[85609]|[100018]| [85752]|
|[85609]|[100018]| [28286]|
|[85609]|[100018]| [93910]|
|[85609]|[100018]| [85753]|
|[85609]|[100018]| [60945]|
|[85609]|[100018]| [47246]|
|[85609]|[100018]| [85614]|
|[86839]|[100042]|[100040]|
|[86839]|[100042]| [46600]|
|[86839]|[100042]|[100039]|
|[86839]|[100042]|[100041]|
|[86839]|[100042]| [27186]|
|[86839]|[100042]|[100044]|
|[86839]|[100042]|[100043]|
|[86839]|[100042]| [86841]|
|[86839]|[100042]| [86838]|
|[55528]|[100087]| [55531]|
|[55528]|[100087]| [40067]|
+-------+--------+--------+
only showing top 20 rows
scala> motifs3.count()
res17: Long = 15657738

在下面的例子中,我们指定了从acb的两条单独的边。这种模式通常表示的情况是,当客户购买不相关的产品(ac)时,他们也会购买b。这是一个汇聚的模式,企业可以利用这些信息,例如将这些产品一起存放:

scala> val motifs3 = g.find("(a)-[]->(b); (c)-[]->(b)").filter("(a != c)")
scala> motifs3.show(5)
+--------+------+--------+                                                    
|       a|     b|       c|
+--------+------+--------+
|[365079]|[8742]|[100010]|
|[241393]|[8742]|[100010]|
| [33284]|[8742]|[100010]|
|[198072]|[8742]|[100010]|
|[203728]|[8742]|[100010]|
+--------+------+--------+
only showing top 5 rows
scala> motifs3.count()
res24: Long = 119218310

在下面的例子中,我们指定了从ab和从bc的边,以及从cb的另一条边。这种模式通常表示的情况是,当客户购买产品(a)时,她可能也会购买(b),然后继续购买(c)。这可能表明购买商品时的一些优先考虑。此外,模式中的强连接组件表明了(b)(c)之间的密切关系:

scala> val motifs3 = g.find("(a)-[]->(b); (b)-[]->(c); (c)-[]->(b)")
scala> motifs3.show(5)
+--------+-------+--------+                                                    
|       a|      b|       c|
+--------+-------+--------+
|[188454]|[85609]|[100018]|
| [85611]|[85609]|[100018]|
| [98017]|[85609]|[100018]|
|[142029]|[85609]|[100018]|
| [64516]|[85609]|[100018]|
+--------+-------+--------+
only showing top 5 rows
scala> motifs3.count()
res26: Long = 23373805.

4-节点模式示例非常消耗资源,需要超过 100GB 的磁盘空间和超过 14GB 的 RAM。或者,您可以参考下一节创建一个较小的子图来运行这个示例。

在下一个例子中,我们展示了一个4-节点的模式。这种模式通常表示的情况是,客户购买(b)的概率更高:

scala> val motifs4 = g.find("(a)-[e1]->(b); (c)-[e2]->(b); (c)-[e3]->(d)").filter("(a != c) AND (d != b) AND (d != a)")
scala> motifs4.show(5)

scala> motifs4.count()
res2: Long = 945551688

在下一节中,我们将把重点转移到创建和处理子图。

处理子图

GraphFrames 提供了一种强大的方式来基于模式查找和 DataFrame 过滤器选择子图。以下示例展示了如何基于顶点和边过滤器选择子图:

scala> val v2 = g.vertices.filter("src < 10")
scala> val e2 = g.edges.filter("src < 10")
scala> val g2 = GraphFrame(v2, e2)
scala> g2.edges.groupBy("src").count().show()
+---+-----+                                                                    
|src|count|
+---+-----+
| 7|   10|
| 3|   10|
| 8|   10|
| 0|   10|
| 5|   10|
| 6|   10|
| 9|   10|
| 1|   10|
| 4|   10|
| 2|   10|
+---+-----+
scala> val paths = g.find("(a)-[e]->(b)").filter("e.src < e.dst")
scala> val e2 = paths.select("e.*")
scala> e2.show(5)
+------+------+                                                                
|   src|   dst|
+------+------+
|100008|100010|
|100226|100227|
|100225|100227|
|100224|100227|
|100223|100227|
+------+------+
only showing top 5 rows

在下一节中,我们将对我们的图应用一系列图算法。

应用图算法

GraphFrames 提供了一套标准的图算法。我们提供了图算法的简要描述和应用它们的代码片段。

首先,我们计算每个顶点的强连通分量SCC),并返回一个图,其中每个顶点都分配给包含该顶点的 SCC。我们显示 SCC 中节点的计数,如下所示:

val result = g.stronglyConnectedComponents.maxIter(10).run()
result.select("id", "component").groupBy("component").count().sort($"count".desc).show()
+---------+------+                                                            
|component| count|
+---------+------+
|        0|395234|
|   312598|   111|
|   379229|   105|
|   107295|    81|
|   359845|    70|
|    40836|    64|
|   357970|    64|
|   189338|    61|
|   369081|    59|
|   152634|    58|
|   167178|    55|
|    35086|    50|
|    81674|    48|
|   376845|    48|
|   177702|    47|
|   319708|    44|
|   130664|    43|
|   279703|    41|
|   273764|    41|
|   324355|    40|
+---------+------+
only showing top 20 rows

接下来,我们计算通过每个顶点的三角形数量。三角形的数量是顶点邻域密度的度量。在网络中,三角形计数有许多实际应用,例如社区检测、角色行为、垃圾邮件检测、检测具有共同主题的网页子集等:

val results = g.triangleCount.run()
results.select("id", "count").show()
+------+-----+                                                                  
|    id|count|
+------+-----+
|100010|   73|
|100140|   15|
|100227|  332|
|100263|    9|
|100320|    8|
|100553|   41|
|100704|    3|
|100735|   13|
|100768|   37|
| 10096|   58|
|100964|   87|
|101021|   30|
|101122|   52|
|101205|  152|
|101261|   31|
|101272|   19|
|102113|   38|
|102521|   23|
|102536|    2|
|102539|   37|
+------+-----+
only showing top 20 rows

在下面的示例中,我们将应用 PageRank 算法来确定产品的重要性的估计值。基本假设是更受欢迎的产品很可能会从其他产品节点接收更多的链接:

val results = g.pageRank.resetProbability(0.15).tol(0.01).run()
val prank = results.vertices.sort(desc("pagerank"))
prank.show(5)
+-----+------------------+                                                      
|   id|          pagerank|
+-----+------------------+
|   45| 586.2075242838272|
| 1036| 512.2355738350872|
| 1037|  506.900472599229|
|   50| 485.4457370914238|
| 1039|438.64149165397276|
+----+------------------+
only showing top 5 rows

在下一个示例中,我们应用标签传播算法来找到图中产品的社区:

val results = g.labelPropagation.maxIter(10).run()
results.select("id", "label").show()
+-----+-----+                                                                  
|label|count|
+-----+-----+
| 1677|    2|
| 5385|   23|
| 7279|   11|
| 9233|    7|
| 9458|   10|
| 9978|   80|
|10422|    8|
|11945|   13|
|13098|    6|
|13452|   12|
|14117|   49|
|21899|   20|
|23019|   12|
|27651|   80|
|29824|   17|
|30421|    9|
|32571|    4|
|37310|    1|
|41424|   48|
|45726|    4|
+-----+-----+
only showing top 20 rows
results.select("id", "label").groupBy("label").count().sort(desc("count")).show(5)
+-----+-----+                                                                  
|label|count|
+-----+-----+
| 1110| 2830|
|  352| 2266|
| 9965| 1413|
| 9982|  828|
|11224|  761|
+-----+-----+
only showing top 5 rows

在下面的示例中,我们应用最短路径算法来找到图中两个顶点之间的路径,以使其构成的边的数量最小化:

val results = g.shortestPaths.landmarks(Seq("1110", "352")).run()
results.select("id", "distances").take(5).foreach(println)
[8,Map(352 -> 3, 1110 -> 9)]                                                  
[22,Map(352 -> 4, 1110 -> 4)]
[290,Map(352 -> 4, 1110 -> 6)]
[752,Map()]
[2453,Map(352 -> 8, 1110 -> 11)]

在下一节中,我们将展示如何将 GraphFrames 保存到持久存储,然后检索相同的内容以重新创建原始的 GraphFrame。

保存和加载 GraphFrames

由于 GraphFrames 是建立在 DataFrame API 上的,它们支持保存和加载到各种数据源。在下面的代码中,我们展示了将顶点和边保存到 HDFS 上的 Parquet 文件:

g.vertices.write.parquet("hdfs://localhost:9000/gf/vertices")
g.edges.write.parquet("hdfs://localhost:9000/gf/edges")

我们可以从持久存储中重新创建顶点和边的 DataFrame,然后显示图,如下所示:

val v = spark.read.parquet("hdfs://localhost:9000/gf/vertices")
val e = spark.read.parquet("hdfs://localhost:9000/gf/edges")
val g = GraphFrame(v, e)

在下一节中,我们将使用更丰富的数据集来演示基于 GraphFrames 的应用中的顶点和边属性的使用。

分析建模为图的 JSON 输入

在本节中,我们将分析一个建模为图的 JSON 数据集。我们将应用前几节中的 GraphFrame 函数,并介绍一些新的函数。

在本节的实践练习中,我们使用一个包含亚马逊产品元数据的数据集;约 548,552 个产品的产品信息和评论。该数据集可以从snap.stanford.edu/data/amazon-meta.html下载。

为了简化处理,原始数据集被转换为 JSON 格式文件,每行代表一个完整的记录。使用本章提供的 Java 程序(Preprocess.java)进行转换。

首先,我们从输入文件创建一个 DataFrame,并打印出模式和一些示例记录。这是一个具有嵌套元素的复杂模式:

scala> val df1 = spark.read.json("file:///Users/aurobindosarkar/Downloads/input.json")
scala> df1.printSchema()
root
|-- ASIN: string (nullable = true)
|-- Id: long (nullable = true)
|-- ReviewMetaData: struct (nullable = true)
|   |-- avg_rating: double (nullable = true)
|   |-- downloaded: long (nullable = true)
|   |-- total: long (nullable = true)
|-- categories: long (nullable = true)
|-- categoryLines: array (nullable = true)
|   |-- element: struct (containsNull = true)
|   |   |-- category: string (nullable = true)
|-- group: string (nullable = true)
|-- reviewLines: array (nullable = true)
|   |-- element: struct (containsNull = true)
|   |   |-- review: struct (nullable = true)
|   |   |   |-- customerId: string (nullable = true)
|   |   |   |-- date: string (nullable = true)
|   |   |   |-- helpful: long (nullable = true)
|   |   |   |-- rating: long (nullable = true)
|   |   |   |-- votes: long (nullable = true)
|-- salerank: long (nullable = true)
|-- similarLines: array (nullable = true)
|   |-- element: struct (containsNull = true)
|   |   |-- similar: string (nullable = true)
|-- similars: long (nullable = true)
|-- title: string (nullable = true)
scala> df1.take(5).foreach(println)

我们可以打印出数据中的一组结构元素的数组。更具体地说,我们打印出与当前产品一起购买的类似产品的列表:

scala> val x1=df1.select(df1.col("similarLines"))
scala> df1.select(df1.col("similarLines.similar")).take(5).foreach(println)

我们还可以通过使用 explode 来展开评论元素的嵌套结构,并访问其中的特定元素:

scala> val flattened = df1.select($"ASIN", explode($"reviewLines.review").as("review_flat"))
scala> flattened.show()
+----------+--------------------+
|     ASIN|         review_flat|
+----------+--------------------+
|0827229534|[A2JW67OY8U6HHK,2...|
|0827229534|[A2VE83MZF98ITY,2...|
|0738700797|[A11NCO6YTE4BTJ,2...|
|0738700797|[A9CQ3PLRNIR83,20...|
|0738700797|[A13SG9ACZ9O5IM,2...|
|0738700797|[A1BDAI6VEYMAZA,2...|
|0738700797|[A2P6KAWXJ16234,2...|
|0738700797|[AMACWC3M7PQFR,20...|
|0738700797|[A3GO7UV9XX14D8,2...|
|0738700797|[A1GIL64QK68WKL,2...|
|0738700797|[AEOBOF2ONQJWV,20...|
|0738700797|[A3IGHTES8ME05L,2...|
|0738700797|[A1CP26N8RHYVVO,2...|
|0738700797|[ANEIANH0WAT9D,20...|
|0486287785|[A3IDGASRQAW8B2,2...|
|0842328327|[A2591BUPXCS705,2...|
|0486220125|[ATVPDKIKX0DER,19...|
|0486220125|[AUEZ7NVOEHYRY,19...|
|0486220125|[ATVPDKIKX0DER,19...|
|0486220125|[AJYG6ZJUQPZ9M,20...|
+----------+--------------------+
only showing top 20 rows
scala> val flatReview = flattened.select("ASIN", "review_flat.customerId")

接下来,我们创建节点和边的 DataFrame,如下所示:

scala> val nodesDF = df1.select($"ASIN".alias("id"), $"Id".alias("productId"), $"title", $"ReviewMetaData", $"categories", $"categoryLines", $"group", $"reviewLines", $"salerank", $"similarLines", $"similars")

对于边的 DataFrame,我们使用类似或也购买产品列similarLines上的 explode 来为数组中的每个元素创建新行:

scala> val edgesDF = df1.select($"ASIN".alias("src"), explode($"similarLines.similar").as("dst"))
scala> val g = GraphFrame(nodesDF, edgesDF)

接下来,我们展示一些使用节点属性的基本操作:

scala> g.edges.filter("salerank < 100").count()
res97: Long = 750                                                              
scala> g.vertices.groupBy("group").count().show()
+------------+------+                                                          
|       group| count|
+------------+------+
|       Video| 26131|
|         Toy|     8|
|         DVD| 19828|
|      Sports|     1|
|        null|  5868|
|Baby Product|     1|
| Video Games|     1|
|        Book|393561|
|       Music|103144|
|    Software|     5|
|          CE|     4|
+------------+------+

接下来,我们仅为图书组产品创建一个子图:

scala> val v2 = g.vertices.filter("group = 'Book'")
scala> val g2 = GraphFrame(v2, e2)
scala> g2.vertices.count()
res6: Long = 393561                                                            

重要的是要注意,边的数量等于原始图中的边的数量。GraphFrame 不会自动删除与图书组产品无关的边:

scala> g2.edges.count()
res7: Long = 1788725    

在接下来的步骤中,我们暂时将顶点和边的 DataFrame 连接起来,以摆脱 DataFrame 中的额外边,并创建一个仅与图书产品相关的GraphFrame

scala> val v2t = v2.select("id")
scala> val e2t = v2t.join(e2, v2t("id") === e2("src"))
scala> e2t.count()
res8: Long = 1316257                                                          
scala> val e2t1 = v2t.join(e2, v2t("id") === e2("src")).drop("id")
scala> val e2t2 = v2t.join(e2t1, v2t("id") === e2t1("dst")).drop("id")
scala> e2t2.count()
res9: Long = 911960                                                            
scala> val g2f = GraphFrame(v2, e2t2)
scala> g2f.vertices.count()
res10: Long = 393561                                                          
scala> g2f.edges.count()
res11: Long = 911960

我们可以将模式查找与包含顶点属性的过滤器相结合:

scala> g2.edges.take(5).foreach(println)
[B00008MNUJ,0822959046]                                                        
[0786632550,0793529395]
[0942272463,0942272692]
[0942272463,1567183298]
[0060537612,0689820305]
scala> val es = g.edges.filter("salerank < 100")
scala> val e3 = es.select("src", "dst")
scala> val g3 = GraphFrame(g.vertices, e3)
scala> val motifs = g3.find("(a)-[e]->(b); (b)-[e2]->(a)")
scala> motifs.show()

scala> motifs.filter("b.ReviewMetaData.avg_rating > 4.0").show()

scala> val paths = g3.find("(a)-[e]->(b)").filter("a.group = 'Book' AND b.group = 'Book'").filter("a.salerank < b.salerank")
scala> val e2 = paths.select("e.src", "e.dst")
scala> val g2 = GraphFrame(g.vertices, e2)
scala> g2.vertices.take(5).foreach(println)

GraphFrames 提供了AggregateMessages原语来开发图算法。该组件可用于在顶点之间发送消息,也可用于聚合每个顶点的消息。

在下面的示例中,我们计算相邻产品的购买产品数量的总和:

scala> import org.graphframes.lib.AggregateMessages
scala> val AM = AggregateMessages
scala> val msgToSrc = AM.dst("similars")
scala> val msgToDst = AM.src("similars")
scala> val agg = g.aggregateMessages.sendToSrc(msgToSrc).sendToDst(msgToDst).agg(sum(AM.msg).as("SummedSimilars"))
scala> agg.show()
+----------+--------------+                                                    
|       id| SummedSimilars|
+----------+--------------+
|0004708237|             5|
|0023605103|            35|
|0027861317|            30|
|0028624599|            30|
|0028633784|            40|
|0028642074|            45|
|0030259282|            10|
|0060082135|            20|
|0060279257|            20|
|0060298804|            25|
|0060392436|            25|
|0060540745|           125|
|0060611561|           100|
|0060921005|            15|
|0060925175|            48|
|0060929081|            54|
|0060959126|            10|
|0060960388|            29|
|006097060X|            50|
|0060988940|            25|
+----------+--------------+
only showing top 20 rows

在接下来的部分中,我们将探索包含多种关系类型的边的 GraphFrames。

处理包含多种关系类型的图

在接下来的几个示例中,我们使用包含关系列的增强边 DataFrame。我们根据相似购买数量和产品所属类别的数量在列中插入两种关系类型。

为此,我们将节点和边的 DataFrame 进行连接,然后在关系计算完成后删除与节点相关的列,以获得我们最终的边 DataFrame(关系列适当填充):

scala> val joinDF = nodesDF.join(edgesDF).where(nodesDF("id") === edgesDF("src")).withColumn("relationship", when(($"similars" > 4) and ($"categories" <= 3), "highSimilars").otherwise("alsoPurchased"))
scala> val edgesDFR = joinDF.select("src", "dst", "relationship")
scala> val gDFR = GraphFrame(nodesDF, edgesDFR)

接下来,我们计算每种关系类型的记录数量,并列出一些边以及关系值:

scala> gDFR.edges.groupBy("relationship").count().show()
+-------------+-------+                                                        
| relationship| count|
+-------------+-------+
|alsoPurchased|1034375|
| highSimilars| 754350|
+-------------+-------+
scala> gDFR.edges.show()
+----------+----------+-------------+                                          
|       src|       dst| relationship|
+----------+----------+-------------+
|0004708237|4770027508|alsoPurchased|
|0023605103|0830812717| highSimilars|
|0023605103|0830812865| highSimilars|
|0023605103|0800611365| highSimilars|
|0023605103|0801063914| highSimilars|
|0023605103|0802819478| highSimilars|
|0027861317|0803706197| highSimilars|
|0027861317|0525452710| highSimilars|
|0027861317|0152014829| highSimilars|
|0027861317|068980718X| highSimilars|
|0027861317|0761317910| highSimilars|
|0028624599|1889392138|alsoPurchased|
|0028624599|0934081239|alsoPurchased|
|0028624599|0761528245|alsoPurchased|
|0028624599|0761518045|alsoPurchased|
|0028624599|0811836878|alsoPurchased|
|0028633784|0812046943| highSimilars|
|0028633784|0812046005| highSimilars|
|0028633784|0028629051| highSimilars|
|0028633784|0140144358| highSimilars|
+----------+----------+-------------+
only showing top 20 rows

在下面的示例中,我们筛选出销售排名在 2,000,000 以下的产品顶点和具有highSimilars关系的边:

scala> val v2 = gDFR.vertices.filter("salerank < 2000000")
scala> val e2 = gDFR.edges.filter("relationship = 'highSimilars'")
scala> val g2 = GraphFrame(v2, e2)

在下面的示例中,我们从选定的列创建一个子图,并根据特定产品组进行过滤。我们还根据highSimilars关系选择边的子集。此外,我们找到图形并对其应用进一步的过滤,以获得最终结果:

scala> val v2 = gDFR.vertices.select("id", "group", "similars").filter("group = 'Book'")
scala> val e2 = gDFR.edges.filter("relationship = 'highSimilars'")
scala> val g2 = GraphFrame(v2, e2)
scala> val result1 = g2.find("(a)-[]->(b); (b)-[]->(c); !(a)-[]->(c)").filter("(a.group = c.group) and (a.similars = c.similars)")
scala> val result2 = result1.filter("a.id != c.id").select("a.id", "a.group", "a.similars", "c.id", "c.group", "c.similars")
scala> result2.show(5)
+----------+-----+--------+----------+-----+--------+                          
|       id|group|similars|       id|group|similars|
+----------+-----+--------+----------+-----+--------+
|0002551489| Book|       5|0002154129| Book|       5|
|0006388515| Book|       5|0679738711| Book|       5|
|0020438001| Book|       5|0395169615| Book|       5|
|0023078251| Book|       5|0394704371| Book|       5|
|0023237309| Book|       5|0874415098| Book|       5|
+----------+-----+--------+----------+-----+--------+
only showing top 5 rows

接下来,我们对基于节点和边关系属性的子图应用一些图算法。在下面的示例中,我们首先找到图中匹配模式的图形,然后根据节点和边属性的组合进行过滤。我们在最终子图上运行 BFS 算法:

scala> val paths = gDFR.find("(a)-[e]->(b)").filter("e.relationship = 'highSimilars'").filter("a.group = b.group")
scala> val e2 = paths.select("e.src", "e.dst", "e.relationship")
scala> val g2 = GraphFrame(gDFR.vertices, e2)
scala> val numEHS = g2.edges.count()
numEHS: Long = 511524  
scala> val bfsDF = gDFR.bfs.fromExpr("group = 'Book'").toExpr("categories < 3").edgeFilter("relationship != 'alsoPurchased'").maxPathLength(3).run()
scala> bfsDF.take(2).foreach(println)

在下面的示例中,我们在图书子图上运行 PageRank 算法,以找到前十本书的标题:

scala> val v2 = gDFR.vertices.select("id", "group", "title").filter("group = 'Book'")
scala> val e2 = gDFR.edges.filter("relationship = 'highSimilars'")
scala> val g2 = GraphFrame(v2, e2)
scala> val results = g2.pageRank.resetProbability(0.15).tol(0.01).run()
scala> val prank = results.vertices.sort(desc("pagerank"))
scala> prank.take(10).foreach(println)

了解 GraphFrame 的内部结构

在接下来的部分中,我们将简要介绍 GraphFrame 的内部结构,以及其执行计划和分区。

查看 GraphFrame 的物理执行计划

由于 GraphFrames 是构建在 Spark SQL DataFrames 上的,我们可以查看物理计划以了解图操作的执行,如下所示:

scala> g.edges.filter("salerank < 100").explain()

我们将在第十一章中更详细地探讨这一点,调整 Spark SQL 组件以提高性能

了解 GraphFrames 中的分区

Spark 将数据分割成分区,并并行在分区上执行计算。您可以调整分区级别以提高 Spark 计算的效率。

在下面的示例中,我们检查重新分区 GraphFrame 的结果。我们可以根据顶点 DataFrame 的列值对 GraphFrame 进行分区。在这里,我们使用组列中的值按组或产品类型进行分区。在这里,我们将通过比较记录的分布前后来呈现重新分区的结果。

首先,我们按照所示创建两个 GraphFrames。由于group列中存在空值,我们将其替换为unknown的值:

scala> val v1 = g.vertices.select("id", "group").na.fill("unknown")
scala> val g1 = GraphFrame(v1, g.edges)

接下来,在重新分区原始 GraphFrame 后,我们创建第二个 GraphFrame。在这里,我们使用组数作为我们的初始分区数:

scala> val v2 = g.vertices.select("id", "group").na.fill("unknown")
scala> val g2t1 = GraphFrame(v2, g.edges)
scala> val g2t2 = g2t1.vertices.repartition(11, $"group")
scala> val g2 = GraphFrame(g2t2, g.edges)

显示以下两个图中的顶点表明,第二个图中的记录是按组聚集在一起的:

scala> g1.vertices.show()
+----------+-------+
|       id|   group|
+----------+-------+
|0771044445|unknown|
|0827229534|   Book|
|0738700797|   Book|
|0486287785|   Book|
|0842328327|   Book|
|1577943082|   Book|
|0486220125|   Book|
|B00000AU3R|  Music|
|0231118597|   Book|
|1859677800|   Book|
|0375709363|   Book|
|0871318237|   Book|
|1590770218|   Book|
|0313230269|   Book|
|B00004W1W1|  Music|
|1559362022|   Book|
|0195110382|   Book|
|0849311012|   Book|
|B000007R0T|  Music|
|078510870X|   Book|
+----------+-------+
only showing top 20 rows
scala> g2.vertices.show()
+----------+-----+                                                            
|       id| group|
+----------+-----+
|6303360041|Video|
|B0000060T5|Video|
|6304286961|Video|
|B000063W82|Video|
|B0000060TP|Video|
|0970911300|Video|
|B00000IBNZ|Video|
|B00000IC8N|Video|
|6303454488|Video|
|B00005LAF3|Video|
|6304733542|Video|
|6301045734|Video|
|6301967917|Video|
|6304702329|Video|
|0792296176|Video|
|6301966422|Video|
|B00000I9PH|Video|
|6303864120|Video|
|6304972857|Video|
|6301701720|Video|
+----------+-----+
only showing top 20 rows

第一个图的默认分区数为9,而第二个图根据指定为11

scala> g1.vertices.rdd.partitions.size
res85: Int = 9
scala> g2.vertices.rdd.partitions.size
res86: Int = 11

我们还可以将分区的内容写入文件以探索其内容,如下所示:

scala> g1.vertices.write.csv("file:///Users/aurobindosarkar/Downloads/g1/partitions")
scala> g2.vertices.write.csv("file:///Users/aurobindosarkar/Downloads/g2/partitions")

以下是来自输出文件中一个分区的样本内容,用于显示第一个图中记录的混合情况:

以下是来自输出文件中一个分区的样本内容,用于显示属于同一组的记录的第二个图:

我们注意到我们的大部分记录都属于五个主要产品组,我们可能希望减少总分区数。我们使用 coalesce 操作来实现这一点,如下所示:

scala> val g2c = g2.vertices.coalesce(5)
scala> g2c.rdd.partitions.size
res90: Int = 5

摘要

在本章中,我们介绍了 GraphFrame 应用程序。我们提供了使用 Spark SQL DataFrame/Dataset API 构建图应用程序的示例。此外,我们还将各种图算法应用于图应用程序。

在下一章中,我们将把重点转移到使用 SparkR 的 Spark SQL。此外,我们还将探索使用 Spark SQL 和 SparkR 进行典型用例和数据可视化。

第八章:使用 Spark SQL 与 SparkR

许多数据科学家使用 R 进行探索性数据分析、数据可视化、数据整理、数据处理和机器学习任务。SparkR 是一个 R 包,通过利用 Apache Spark 的分布式处理能力,使从业者能够处理数据。在本章中,我们将介绍 SparkR(一个 R 前端包),它利用 Spark 引擎进行大规模数据分析。我们还将描述 SparkR 设计和实现的关键要素。

更具体地,在本章中,您将学习以下主题:

  • 什么是 SparkR?

  • 理解 SparkR 架构

  • 理解 SparkR 的 DataFrame

  • 使用 SparkR 进行探索性数据分析(EDA)和数据整理任务

  • 使用 SparkR 进行数据可视化

  • 使用 SparkR 进行机器学习

介绍 SparkR

R 是一种用于统计计算和数据可视化的语言和环境。它是统计学家和数据科学家使用最广泛的工具之一。R 是开源的,提供了一个动态交互环境,具有丰富的包和强大的可视化功能。它是一种解释性语言,包括对数值计算的广泛支持,具有用于向量、矩阵、数组的数据类型,以及用于执行数值操作的库。

R 提供了对使用 DataFrame 进行结构化数据处理的支持。R 的 DataFrame 使数据操作更简单、更方便。然而,R 的动态设计限制了可能的优化程度。此外,交互式数据分析能力和整体可伸缩性也受到限制,因为 R 运行时是单线程的,只能处理适合单台机器内存的数据集。

有关 R 的更多详细信息,请参阅R 项目网站

SparkR 解决了这些缺点,使数据科学家能够在分布式环境中处理大规模数据。SparkR 是一个 R 包,提供了一个轻量级的前端,让您可以从 R 中使用 Apache Spark。它结合了 Spark 的分布式处理功能、易于连接各种数据源的特性以及内存外数据结构,与 R 的动态环境、交互性、包和可视化功能。

传统上,数据科学家一直在使用 R 与其他框架,如 Hadoop MapReduce、Hive、Pig 等。然而,有了 SparkR,他们可以避免使用多个大数据工具和平台,以及在多种不同的语言中工作来实现他们的目标。SparkR 使他们可以在 R 中进行工作,并利用 Spark 的分布式计算模型。

SparkR 接口类似于 R 和 R 包,而不是我们迄今为止遇到的 Python/Scala/Java 接口。SparkR 实现了一个分布式 DataFrame,支持对大型数据集进行统计计算、列选择、SQL 执行、行过滤、执行聚合等操作。

SparkR 支持将本地 R DataFrame 转换为 SparkR。SparkR 与 Spark 项目的紧密集成使 SparkR 能够重用其他 Spark 模块,包括 Spark SQL、MLlib 等。此外,Spark SQL 数据源 API 使其能够从各种来源读取输入,如 HDFS、HBase、Cassandra,以及 CSV、JSON、Parquet、Avro 等文件格式。

在下一节中,我们将简要介绍 SparkR 架构。

理解 SparkR 架构

SparkR 的分布式 DataFrame 使编程语法对 R 用户来说非常熟悉。高级 DataFrame API 将 R API 与 Spark 中优化的 SQL 执行引擎集成在一起。

SparkR 的架构主要由两个组件组成:驱动程序上的 R 到 JVM 绑定,使 R 程序能够向 Spark 集群提交作业,并支持在 Spark 执行器上运行 R。

SparkR 的设计包括支持在 Spark 执行器机器上启动 R 进程。然而,序列化查询和在计算后反序列化结果会带来一些开销。随着在 R 和 JVM 之间传输的数据量增加,这些开销也会变得更加显著。然而,缓存可以实现在 SparkR 中高效的交互式查询处理。

有关 SparkR 设计和实现的详细描述,请参阅:"SparkR: Scaling R Programs with Spark" by Shivaram Venkataraman1, Zongheng Yang, et al,,可在cs.stanford.edu/~matei/papers/2016/sigmod_sparkr.pdf上找到。

在下一节中,我们将介绍 SparkR 的分布式 DataFrame 组件 Spark DataFrames 的概述。

理解 SparkR DataFrames

SparkR 的主要组件是一个名为SparkR DataFrames的分布式 DataFrame。Spark DataFrame API 类似于本地 R DataFrames,但使用 Spark 的执行引擎和关系查询优化器扩展到大型数据集。它是一个分布式的数据集合,以列的形式组织,类似于关系数据库表或 R DataFrame。

Spark DataFrames 可以从许多不同的数据源创建,例如数据文件、数据库、R DataFrames 等。数据加载后,开发人员可以使用熟悉的 R 语法执行各种操作,如过滤、聚合和合并。SparkR 对 DataFrame 操作执行延迟评估。

此外,SparkR 支持对 DataFrames 进行许多函数操作,包括统计函数。我们还可以使用诸如 magrittr 之类的库来链接命令。开发人员可以使用 SQL 命令在 SparkR DataFrames 上执行 SQL 查询。最后,可以使用 collect 运算符将 SparkR DataFrames 转换为本地 R DataFrame。

在下一节中,我们将介绍在 EDA 和数据整理任务中使用的典型 SparkR 编程操作。

使用 SparkR 进行 EDA 和数据整理任务

在本节中,我们将使用 Spark SQL 和 SparkR 对我们的数据集进行初步探索。本章中的示例使用几个公开可用的数据集来说明操作,并且可以在 SparkR shell 中运行。

SparkR 的入口点是 SparkSession。它将 R 程序连接到 Spark 集群。如果您在 SparkR shell 中工作,SparkSession 已经为您创建。

此时,启动 SparkR shell,如下所示:

Aurobindos-MacBook-Pro-2:spark-2.2.0-bin-hadoop2.7 aurobindosarkar$./bin/SparkR

您可以在 SparkR shell 中安装所需的库,例如 ggplot2,如下所示:

> install.packages('ggplot2', dep = TRUE)

读取和写入 Spark DataFrames

SparkR 通过 Spark DataFrames 接口支持对各种数据源进行操作。SparkR 的 DataFrames 支持多种方法来读取输入,执行结构化数据分析,并将 DataFrames 写入分布式存储。

read.df方法可用于从各种数据源创建 Spark DataFrames。我们需要指定输入数据文件的路径和数据源的类型。数据源 API 原生支持格式,如 CSV、JSON 和 Parquet。

可以在 API 文档中找到完整的函数列表:spark.apache.org/docs/latest/api/R/。对于初始的一组代码示例,我们将使用第三章中包含与葡萄牙银行机构的直接营销活动(电话营销)相关数据的数据集。

输入文件以逗号分隔值CSV)格式呈现,包含标题,并且字段由分号分隔。输入文件可以是 Spark 的任何数据源;例如,如果是 JSON 或 Parquet 格式,则只需将源参数更改为jsonparquet

我们可以使用read.df加载输入的 CSV 文件来创建SparkDataFrame,如下所示:

> csvPath <- "file:///Users/aurobindosarkar/Downloads/bank-additional/bank-additional-full.csv"

> df <- read.df(csvPath, "csv", header = "true", inferSchema = "true", na.strings = "NA", delimiter= ";")

类似地,我们可以使用write.df将 DataFrame 写入分布式存储。我们在 source 参数中指定输出 DataFrame 名称和格式(与read.df函数中一样)。

数据源 API 可用于将 Spark DataFrames 保存为多种不同的文件格式。例如,我们可以使用write.df将上一步创建的 Spark DataFrame 保存为 Parquet 文件:

write.df(df, path = "hdfs://localhost:9000/Users/aurobindosarkar/Downloads/df.parquet", source = "parquet", mode = "overwrite")

read.dfwrite.df函数用于将数据从存储传输到工作节点,并将数据从工作节点写入存储,分别。它不会将这些数据带入 R 进程。

探索 Spark DataFrames 的结构和内容

在本节中,我们探索了 Spark DataFrames 中包含的维度、模式和数据。

首先,使用 cache 或 persist 函数对 Spark DataFrame 进行性能缓存。我们还可以指定存储级别选项,例如DISK_ONLYMEMORY_ONLYMEMORY_AND_DISK等,如下所示:

> persist(df, "MEMORY_ONLY")

我们可以通过输入 DataFrame 的名称列出 Spark DataFrames 的列和关联的数据类型,如下所示:

> df

Spark DataFrames[age:int, job:string, marital:string, education:string, default:string, housing:string, loan:string, contact:string, month:string, day_of_week:string, duration:int, campaign:int, pdays:int, previous:int, poutcome:string, emp.var.rate:double, cons.price.idx:double, cons.conf.idx:double, euribor3m:double, nr.employed:double, y:string]

SparkR 可以自动从输入文件的标题行推断模式。我们可以打印 DataFrame 模式,如下所示:

> printSchema(df)

我们还可以使用names函数显示 DataFrame 中列的名称,如下所示:

> names(df)

接下来,我们显示 Spark DataFrame 中的一些样本值(从每个列中)和记录,如下所示:

> str(df)

> head(df, 2)

我们可以显示 DataFrame 的维度,如下所示。在使用MEMORY_ONLY选项的 cache 或 persist 函数后执行 dim 是确保 DataFrame 加载并保留在内存中以进行更快操作的好方法:

> dim(df)
[1] 41188 21

我们还可以使用 count 或nrow函数计算 DataFrame 中的行数:

> count(df)
[1] 41188

> nrow(df)
[1] 41188

此外,我们可以使用distinct函数获取指定列中包含的不同值的数量:

> count(distinct(select(df, df$age)))
[1] 78

在 Spark DataFrames 上运行基本操作

在这一部分,我们使用 SparkR 在 Spark DataFrames 上执行一些基本操作,包括聚合、拆分和抽样。例如,我们可以从 DataFrame 中选择感兴趣的列。在这里,我们只选择 DataFrame 中的education列:

> head(select(df, df$education))
education
1 basic.4y
2 high.school
3 high.school
4 basic.6y
5 high.school
6 basic.9y

或者,我们也可以指定列名,如下所示:

> head(select(df, "education"))

我们可以使用 subset 函数选择满足某些条件的行,例如,marital状态为married的行,如下所示:

> subsetMarried <- subset(df, df$marital == "married")

> head(subsetMarried, 2)

我们可以使用 filter 函数仅保留education水平为basic.4y的行,如下所示:

> head(filter(df, df$education == "basic.4y"), 2)

SparkR DataFrames 支持在分组后对数据进行一些常见的聚合。例如,我们可以计算数据集中marital状态值的直方图,如下所示。在这里,我们使用n运算符来计算每个婚姻状态出现的次数:

> maritaldf <- agg(groupBy(df, df$marital), count = n(df$marital))
> head(maritaldf)
marital count
1 unknown 80
2 divorced 4612
3 married 24928
4 single 11568

我们还可以对聚合的输出进行排序,以获取最常见的婚姻状态集,如下所示:

> maritalCounts <- summarize(groupBy(df, df$marital), count = n(df$marital))

> nMarriedCategories <- count(maritalCounts)

> head(arrange(maritalCounts, desc(maritalCounts$count)), num = nMarriedCategories)
marital count
1 married 24928
2 single 11568
3 divorced 4612
4 unknown 80

接下来,我们使用magrittr包来进行函数的管道处理,而不是嵌套它们,如下所示。

首先,使用install.packages命令安装magrittr包,如果该包尚未安装:

> install.packages("magrittr")

请注意,在 R 中加载和附加新包时,可能会出现名称冲突,其中一个函数掩盖了另一个函数。根据两个包的加载顺序,先加载的包中的一些函数会被后加载的包中的函数掩盖。在这种情况下,我们需要使用包名作为前缀来调用这些函数:

> library(magrittr)

我们在下面的示例中使用 filtergroupBysummarize 函数进行流水线处理:

> educationdf <- filter(df, df$education == "basic.4y") %>% groupBy(df$marital) %>% summarize(count = n(df$marital))

> head(educationdf)

接下来,我们从迄今为止使用的分布式 Spark 版本创建一个本地 DataFrame。我们使用 collect 函数将 Spark DataFrame 移动到 Spark 驱动程序上的本地/R DataFrame,如所示。通常,在将数据移动到本地 DataFrame 之前,您会对数据进行汇总或取样:

> collect(summarize(df,avg_age = mean(df$age)))
avg_age
1 40.02406

我们可以从我们的 DataFrame 创建一个 sample 并将其移动到本地 DataFrame,如下所示。在这里,我们取输入记录的 10% 并从中创建一个本地 DataFrame:

> ls1df <- collect(sample(df, FALSE, 0.1, 11L))
> nrow(df)
[1] 41188
> nrow(ls1df)
[1] 4157

SparkR 还提供了许多可以直接应用于数据处理和聚合的列的函数。

例如,我们可以向我们的 DataFrame 添加一个新列,该列包含从秒转换为分钟的通话持续时间,如下所示:

> df$durationMins <- round(df$duration / 60)

> head(df, 2)

以下是获得的输出:

在 Spark DataFrames 上执行 SQL 语句

Spark DataFrames 也可以在 Spark SQL 中注册为临时视图,这允许我们在其数据上运行 SQL 查询。sql 函数使应用程序能够以编程方式运行 SQL 查询,并将结果作为 Spark DataFrame 返回。

首先,我们将 Spark DataFrame 注册为临时视图:

> createOrReplaceTempView(df, "customer")

接下来,我们使用 sql 函数执行 SQL 语句。例如,我们选择年龄在 13 到 19 岁之间的客户的 educationagemaritalhousingloan 列,如下所示:

> sqldf <- sql("SELECT education, age, marital, housing, loan FROM customer WHERE age >= 13 AND age <= 19")

> head(sqldf)

合并 SparkR DataFrames

我们可以明确指定 SparkR 应该使用操作参数 byby.x/by.y 在哪些列上合并 DataFrames。合并操作根据值 all.xall.y 确定 SparkR 应该如何基于 xy 的行来合并 DataFrames。例如,我们可以通过明确指定 all.x = FALSEall.y = FALSE 来指定一个 inner join(默认),或者通过 all.x = TRUEall.y = FALSE 来指定一个左 outer join

有关 join 和 merge 操作的更多详细信息,请参阅 github.com/UrbanInstitute/sparkr-tutorials/blob/master/merging.md.

或者,我们也可以使用 join 操作按行合并 DataFrames。

在下面的示例中,我们使用位于 archive.ics.uci.edu/ml/Datasets/Communities+and+Crime+Unnormalized 的犯罪数据集。

与之前一样,我们读取输入数据集,如下所示:

> library(magrittr)
> csvPath <- "file:///Users/aurobindosarkar/Downloads/CommViolPredUnnormalizedData.csv"
> df <- read.df(csvPath, "csv", header = "false", inferSchema = "false", na.strings = "NA", delimiter= ",")

接下来,我们选择与犯罪类型相关的特定列,并将默认列名重命名为更有意义的名称,如下所示:

> crimesStatesSubset = subset(df, select = c(1,2, 130, 132, 134, 136, 138, 140, 142, 144))

> head(crimesStatesdf, 2)

接下来,我们读取包含美国州名的数据集,如下所示:

> state_names <- read.df("file:///Users/aurobindosarkar/downloads/csv_hus/states.csv", "csv", header = "true", inferSchema = "true", na.strings = "NA", delimiter= ",")

我们使用 names 函数列出了两个 DataFrames 的列。两个 DataFrames 之间的共同列是 "code" 列(包含州代码):

> names(crimesStatesdf)
[1] "comm" "code" "nmurders" "nrapes" "nrobberies"
[6] "nassaults" "nburglaries" "nlarcenies" "nautothefts" "narsons"

> names(state_names)
[1] "st" "name" "code"

接下来,我们使用共同列执行内部连接:

> m1df <- merge(crimesStatesdf, state_names)
> head(m1df, 2)

在这里,我们根据明确指定的表达式执行内部连接:

> m2df <- merge(crimesStatesdf, state_names, by = "code")
> head(m2df, 2)

在下面的示例中,我们使用位于 archive.ics.uci.edu/ml/Datasets/Tennis+Major+Tournament+Match+Statistics. 的网球锦标赛比赛统计数据集。

以下是获得的输出:

使用用户定义函数(UDFs)

在 SparkR 中,支持多种类型的用户定义函数UDFs)。例如,我们可以使用 dapplydapplyCollect 在大型数据集上运行给定的函数。dapply 函数将函数应用于 Spark DataFrame 的每个分区。函数的输出应该是一个 data.frame。

模式指定了生成的 Spark DataFrame 的行格式:

> df1 <- select(df, df$duration)

> schema <- structType(structField("duration", "integer"),
+ structField("durMins", "double"))

> df2 <- dapply(df1, function(x) { x <- cbind(x, x$duration / 60) }, schema)
> head(collect(df2))

类似于 dapply,dapplyCollect函数将函数应用于 Spark DataFrames 的每个分区,并收集结果。函数的输出应为data.frame,不需要 schema 参数。请注意,如果 UDF 的输出无法传输到驱动程序或适合驱动程序的内存中,则dapplyCollect可能会失败。

我们可以使用gapplygapplyCollect来对大型数据集进行分组运行给定函数。在下面的示例中,我们确定一组顶部持续时间值:

> df1 <- select(df, df$duration, df$age)

> schema <- structType(structField("age", "integer"), structField("maxDuration", "integer"))

> result <- gapply(
+ df1,
+ "age",
+ function(key, x) {
+ y <- data.frame(key, max(x$duration))
+ },
+ schema)
> head(collect(arrange(result, "maxDuration", decreasing = TRUE)))

gapplyCollect类似地将一个函数应用于 Spark DataFrames 的每个分区,但也将结果收集回到R data.frame中。

在下一节中,我们介绍 SparkR 函数来计算示例数据集的摘要统计信息。

使用 SparkR 计算摘要统计信息

描述(或摘要)操作创建一个新的 DataFrame,其中包含指定 DataFrame 或数值列列表的计数、平均值、最大值、平均值和标准偏差值:

> sumstatsdf <- describe(df, "duration", "campaign", "previous", "age")

> showDF(sumstatsdf)

在大型数据集上计算这些值可能会非常昂贵。因此,我们在这里呈现这些统计量的单独计算:

> avgagedf <- agg(df, mean = mean(df$age))

> showDF(avgagedf) # Print this DF
+-----------------+
| mean            |
+-----------------+
|40.02406040594348|
+-----------------+

接下来,我们创建一个列出最小值和最大值以及范围宽度的 DataFrame:

> agerangedf <- agg(df, minimum = min(df$age), maximum = max(df$age), range_width = abs(max(df$age) - min(df$age)))

> showDF(agerangedf)

接下来,我们计算样本方差和标准偏差,如下所示:

> agevardf <- agg(df, variance = var(df$age))

> showDF(agevardf)
+------------------+
| variance         |
+------------------+
|108.60245116511807|
+------------------+

> agesddf <- agg(df, std_dev = sd(df$age))

> showDF(agesddf)
+------------------+
| std_dev          |
+------------------+
|10.421249980934057|
+------------------+

操作approxQuantile返回 DataFrame 列的近似分位数。我们使用概率参数和relativeError参数来指定要近似的分位数。我们定义一个新的 DataFrame,df1,删除age的缺失值,然后计算近似的Q1Q2Q3值,如下所示:

> df1 <- dropna(df, cols = "age")

> quartilesdf <- approxQuantile(x = df1, col = "age", probabilities = c(0.25, 0.5, 0.75), relativeError = 0.001)

> quartilesdf
[[1]]
[1] 32
[[2]]
[1] 38
[[3]]
[1] 47

我们可以使用skewness操作来测量列分布的偏斜程度和方向。在下面的示例中,我们测量age列的偏斜度:

> ageskdf <- agg(df, skewness = skewness(df$age))

> showDF(ageskdf)
+------------------+
| skewness         |
+------------------+
|0.7846682380932389|
+------------------+

同样,我们可以测量列的峰度。在这里,我们测量age列的峰度:

> agekrdf <- agg(df, kurtosis = kurtosis(df$age))

> showDF(agekrdf)
+------------------+
| kurtosis         |
+------------------+
|0.7910698035274022|
+------------------+

接下来,我们计算两个 DataFrame 列之间的样本协方差和相关性。在这里,我们计算ageduration列之间的协方差和相关性:

> covagedurdf <- cov(df, "age", "duration")

> corragedurdf <- corr(df, "age", "duration", method = "pearson")

> covagedurdf
[1] -2.339147

> corragedurdf
[1] -0.000865705

接下来,我们为工作列创建一个相对频率表。每个不同的工作类别值的相对频率显示在百分比列中:

> n <- nrow(df)

> jobrelfreqdf <- agg(groupBy(df, df$job), Count = n(df$job), Percentage = n(df$job) * (100/n))

> showDF(jobrelfreqdf)

最后,我们使用crosstab操作在两个分类列之间创建一个列联表。在下面的示例中,我们为工作和婚姻列创建一个列联表:

> contabdf <- crosstab(df, "job", "marital")

> contabdf

在下一节中,我们使用 SparkR 执行各种数据可视化任务。

使用 SparkR 进行数据可视化

ggplot2 包的 SparkR 扩展ggplot2.SparkR允许 SparkR 用户构建强大的可视化。

在本节中,我们使用各种图表来可视化我们的数据。此外,我们还展示了在地图上绘制数据和可视化图表的示例:

> csvPath <- "file:///Users/aurobindosarkar/Downloads/bank-additional/bank-additional-full.csv"

> df <- read.df(csvPath, "csv", header = "true", inferSchema = "true", na.strings = "NA", delimiter= ";")

> persist(df, "MEMORY_ONLY")

> require(ggplot2)

请参考 ggplot 网站,了解如何改进每个图表的显示的不同选项,网址为docs.ggplot2.org

在下一步中,我们绘制一个基本的条形图,显示数据中不同婚姻状态的频率计数:

> ldf <- collect(select(df, df$age, df$duration, df$education, df$marital, df$job))

> g1 <- ggplot(ldf, aes(x = marital))

> g1 + geom_bar()

在下面的示例中,我们为年龄列绘制了一个直方图,并绘制了教育、婚姻状况和工作值的频率计数的几个条形图:

> library(MASS)

> par(mfrow=c(2,2))

> truehist(ldf$"age", h = 5, col="slategray3", xlab="Age Groups(5 years)")

> barplot((table(ldf$education)), names.arg=c("1", "2", "3", "4", "5", "6", "7", "8"), col=c("slateblue", "slateblue2", "slateblue3", "slateblue4", "slategray", "slategray2", "slategray3", "slategray4"), main="Education")

> barplot((table(ldf$marital)), names.arg=c("Divorce", "Married", "Single", "Unknown"), col=c("slategray", "slategray1", "slategray2", "slategray3"), main="Marital Status")

> barplot((table(ldf$job)), , names.arg=c("1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c"), main="Job")

以下表达式创建了一个条形图,描述了按婚姻类型分组的教育水平的比例频率:

> g2 <- ggplot(ldf, aes(x = marital, fill = education))

> g2 + geom_bar(position = "fill")

以下表达式绘制了一个直方图,显示了数据中分箱的年龄值的频率计数:

> g3 <- ggplot(ldf, aes(age))

> g3 + geom_histogram(binwidth=5)

以下表达式返回了一个与先前绘制的直方图相当的频率多边形:

> g3 + geom_freqpoly(binwidth=5)

以下表达式给出了一个箱线图,描述了不同婚姻状态下通话持续时间值的比例频率:

> g4 <- ggplot(ldf, aes(x = marital, y = duration))

> g4 + geom_boxplot()

以下表达式在不同教育水平上展示了年龄直方图:

> g3 + geom_histogram() + facet_wrap(~education)

在下面的例子中,我们同时展示了不同列的几个箱线图:

> par(mfrow=c(1,2))

> boxplot(ldf$age, col="slategray2", pch=19, main="Age")

> boxplot(ldf$duration, col="slategray2", pch=19, main="Duration")

在构建 SparkR 中的表达式或函数时,我们应该避免计算昂贵的操作。例如,即使在 SparkR 中 collect 操作允许我们利用 ggplot2 的特性,我们也应该尽量节约地收集数据,因为我们需要确保操作结果适合单个节点的可用内存。

以下散点图的问题是过度绘制。点被绘制在彼此之上,扭曲了图形的视觉效果。我们可以调整 alpha 参数的值来使用透明点:

> ggplot(ldf, aes(age, duration)) + geom_point(alpha = 0.3) + stat_smooth()I

要将面板显示为二维面板集或将面板包装成多行,我们使用facet_wrap

> ageAndDurationValuesByMarital <- ggplot(ldf, aes(age, duration)) + geom_point(alpha = "0.2") + facet_wrap(~marital)

> ageAndDurationValuesByMarital

调整后的 alpha 值改善了散点图的可视化效果;然而,我们可以将点总结为平均值并绘制它们,以获得更清晰的可视化效果,如下例所示:

> createOrReplaceTempView(df, "customer")

> localAvgDurationEducationAgeDF <- collect(sql("select education, avg(age) as avgAge, avg(duration) as avgDuration from customer group by education"))

> avgAgeAndDurationValuesByEducation <- ggplot(localAvgDurationEducationAgeDF, aes(group=education, x=avgAge, y=avgDuration)) + geom_point() + geom_text(data=localAvgDurationEducationAgeDF, mapping=aes(x=avgAge, y=avgDuration, label=education), size=2, vjust=2, hjust=0.75)

> avgAgeAndDurationValuesByEducation

在下一个例子中,我们创建了一个密度图,并叠加了通过平均值的线。密度图是查看变量分布的好方法;例如,在我们的例子中,我们绘制了通话持续时间的值:

> plot(density(ldf$duration), main = "Density Plot", xlab = "Duration", yaxt = 'n')

> abline(v = mean(ldf$duration), col = 'green', lwd = 2)

> legend('topright', legend = c("Actual Data", "Mean"), fill = c('black', 'green'))

在下一节中,我们将展示一个在地图上绘制值的例子。

在地图上可视化数据

在本节中,我们将描述如何合并两个数据集并在地图上绘制结果:

> csvPath <- "file:///Users/aurobindosarkar/Downloads/CommViolPredUnnormalizedData.csv"

> df <- read.df(csvPath, "csv", header = "false", inferSchema = "false", na.strings = "NA", delimiter= ",")

> persist(df, "MEMORY_ONLY")

> xdf = select(df, "_c1","_c143")

> newDF <- withColumnRenamed(xdf, "_c1", "state")

> arsonsstatesdf <- withColumnRenamed(newDF, "_c143", "narsons")

我们要可视化的数据集是按州计算的平均纵火次数,如下所示:

> avgArsons <- collect(agg(groupBy(arsonsstatesdf, "state"), AVG_ARSONS=avg(arsonsstatesdf$narsons)))

接下来,我们将states.csv数据集读入 R DataFrame:

> state_names <- read.csv("file:///Users/aurobindosarkar/downloads/csv_hus/states.csv")

接下来,我们使用factor变量将州代码替换为州名:

> avgArsons$region <- factor(avgArsons$state, levels=state_names$code, labels=tolower(state_names$name))

要创建一个美国地图,根据每个州的平均纵火次数着色,我们可以使用ggplot2map_data函数:

> states_map <- map_data("state")

最后,我们将数据集与地图合并,并使用ggplot显示地图,如下所示:

> merged_data <- merge(states_map, avgArsons, by="region")

> ggplot(merged_data, aes(x = long, y = lat, group = group, fill = AVG_ARSONS)) + geom_polygon(color = "white") + theme_bw()

有关在地理地图上绘图的更多信息,请参阅 Jose A. Dianes 的使用 SparkR 和 ggplot2 探索地理数据 www.codementor.io/spark/tutorial/exploratory-geographical-data-using-sparkr-and-ggplot2

在下一节中,我们将展示一个图形可视化的例子。

可视化图形节点和边缘

可视化图形对于了解整体结构特性非常重要。在本节中,我们将在 SparkR shell 中绘制几个图形。

有关更多详情,请参阅 Katherine Ognynova 的使用 R 进行静态和动态网络可视化 kateto.net/network-visualization

在下面的例子中,我们使用一个包含在 stack exchange 网站 Ask Ubuntu 上的交互网络的数据集:snap.stanford.edu/data/sx-askubuntu.html

我们从数据的百分之十的样本中创建一个本地 DataFrame,并创建一个图形的绘制,如下所示:

> library(igraph)

> library(magrittr)

> inDF <- read.df("file:///Users/aurobindosarkar/Downloads/sx-askubuntu.txt", "csv", header="false", delimiter=" ")

> linksDF <- subset(inDF, select = c(1, 2)) %>% withColumnRenamed("_c0", "src") %>% withColumnRenamed("_c1", "dst")

> llinksDF <- collect(sample(linksDF, FALSE, 0.01, 1L))

> g1 <- graph_from_data_frame(llinksDF, directed = TRUE, vertices = NULL)

> plot(g1, edge.arrow.size=.001, vertex.label=NA, vertex.size=0.1)

通过进一步减少样本量并移除某些边缘,例如循环,我们可以在这个例子中获得更清晰的可视化效果,如下所示:

> inDF <- read.df("file:///Users/aurobindosarkar/Downloads/sx-askubuntu.txt", "csv", header="false", delimiter=" ")

> linksDF <- subset(inDF, select = c(1, 2)) %>% withColumnRenamed("_c0", "src") %>% withColumnRenamed("_c1", "dst")

> llinksDF <- collect(sample(linksDF, FALSE, 0.0005, 1L))

> g1 <- graph_from_data_frame(llinksDF, directed = FALSE)

> g1 <- simplify(g1, remove.multiple = F, remove.loops = T)

> plot(g1, edge.color="black", vertex.color="red", vertex.label=NA, vertex.size=2)

在接下来的部分中,我们将探讨如何使用 SparkR 进行机器学习任务。

使用 SparkR 进行机器学习

SparkR 支持越来越多的机器学习算法,例如广义线性模型glm),朴素贝叶斯模型,K 均值模型,逻辑回归模型,潜在狄利克雷分配LDA)模型,多层感知分类模型,用于回归和分类的梯度提升树模型,用于回归和分类的随机森林模型,交替最小二乘ALS)矩阵分解模型等等。

SparkR 使用 Spark MLlib 来训练模型。摘要和 predict 函数分别用于打印拟合模型的摘要和对新数据进行预测。write.ml/read.ml操作可用于保存/加载拟合的模型。SparkR 还支持一些可用的 R 公式运算符,如~.:+-

在接下来的示例中,我们使用archive.ics.uci.edu/ml/Datasets/Wine+Quality上可用的葡萄酒质量数据集:

> library(magrittr)

> csvPath <- "file:///Users/aurobindosarkar/Downloads/winequality/winequality-white.csv"

> winedf <- mutate(indf, label = ifelse(indf$quality >= 6, 1, 0))

> winedf <- drop(winedf, "quality")

> seed <- 12345

我们使用 sample 函数创建训练和测试 DataFrames,如下所示:

> trainingdf <- sample(winedf, withReplacement=FALSE, fraction=0.9, seed=seed)

> testdf <- except(winedf, trainingdf)

接下来,我们对 SparkDataFrame 拟合逻辑回归模型,如下所示:

> model <- spark.logit(trainingdf, label ~ ., maxIter = 10, regParam = 0.1, elasticNetParam = 0.8)

接下来,我们使用 summary 函数打印拟合模型的摘要:

> summary(model)

接下来,我们使用 predict 函数对测试 DataFrame 进行预测:

> predictions <- predict(model, testdf)

> showDF(select(predictions, "label", "rawPrediction", "probability", "prediction"), 5)

仅显示前 5 行

接下来,我们计算标签和预测值之间的不匹配数量:

> nrow(filter(predictions, predictions$label != predictions$prediction))
[1] 111

在下面的示例中,我们在 Spark DataFrames 上拟合了一个随机森林分类模型。然后我们使用summary函数获取拟合的随机森林模型的摘要和predict函数对测试数据进行预测,如下所示:

> model <- spark.randomForest(trainingdf, label ~ ., type="classification", maxDepth = 5, numTrees = 10)

> summary(model)

> predictions <- predict(model, testdf)

> showDF(select(predictions, "label", "rawPrediction", "probability", "prediction"), 5)

> nrow(filter(predictions, predictions$label != predictions$prediction))
[1] 79

与之前的示例类似,我们在以下示例中拟合广义线性模型:

> csvPath <- "file:///Users/aurobindosarkar/Downloads/winequality/winequality-white.csv"

> trainingdf <- sample(indf, withReplacement=FALSE, fraction=0.9, seed=seed)

> testdf <- except(indf, trainingdf)

> model <- spark.glm(indf, quality ~ ., family = gaussian, tol = 1e-06, maxIter = 25, weightCol = NULL, regParam = 0.1)

> summary(model)

> predictions <- predict(model, testdf)

> showDF(select(predictions, "quality", "prediction"), 5)

接下来,我们将介绍一个聚类的例子,我们将针对 Spark DataFrames 拟合一个多变量高斯混合模型:

> winedf <- mutate(indf, label = ifelse(indf$quality >= 6, 1, 0))

> winedf <- drop(winedf, "quality")

> trainingdf <- sample(winedf, withReplacement=FALSE, fraction=0.9, seed=seed)

> testdf <- except(winedf, trainingdf)

> testdf <- except(winedf, trainingdf)

> model <- spark.gaussianMixture(trainingdf, ~ sulphates + citric_acid + fixed_acidity + total_sulfur_dioxide + chlorides + free_sulfur_dioxide + density + volatile_acidity + alcohol + pH + residual_sugar, k = 2)

> summary(model)

> predictions <- predict(model, testdf)

> showDF(select(predictions, "label", "prediction"), 5)

接下来,我们对从连续分布中抽样的数据执行双侧 Kolmogorov-Smirnov(KS)检验。我们比较数据的经验累积分布和理论分布之间的最大差异,以检验样本数据是否来自该理论分布的零假设。在下面的示例中,我们对fixed_acidity列针对正态分布进行了测试:

> test <- spark.kstest(indf, "fixed_acidity", "norm", c(0, 1))

> testSummary <- summary(test)

> testSummary

Kolmogorov-Smirnov 检验摘要:

degrees of freedom = 0

statistic = 0.9999276519560749

pValue = 0.0
#Very strong presumption against null hypothesis: Sample follows theoretical distribution.

最后,在下面的示例中,我们使用spark.lapply进行多模型的分布式训练。所有计算的结果必须适合单台机器的内存:

> library(magrittr)

> csvPath <- "file:///Users/aurobindosarkar/Downloads/winequality/winequality-white.csv"

> indf <- read.df(csvPath, "csv", header = "true", inferSchema = "true", na.strings = "NA", delimiter= ";") %>% withColumnRenamed("fixed acidity", "fixed_acidity") %>% withColumnRenamed("volatile acidity", "volatile_acidity") %>% withColumnRenamed("citric acid", "citric_acid") %>% withColumnRenamed("residual sugar", "residual_sugar") %>% withColumnRenamed("free sulfur dioxide", "free_sulfur_dioxide") %>% withColumnRenamed("total sulfur dioxide", "total_sulfur_dioxide")

> lindf <- collect(indf)

我们传递一个只读参数列表用于广义线性模型的 family 参数,如下所示:

> families <- c("gaussian", "poisson")

> train <- function(family) {
+ model <- glm(quality ~ ., lindf, family = family)
+ summary(model)
+ }

以下语句返回模型摘要的列表:

> model.summaries <- spark.lapply(families, train)

最后,我们可以打印两个模型的摘要,如下所示:

> print(model.summaries)

模型 1 的摘要是:

模型 2 的摘要是:

摘要

在本章中,我们介绍了 SparkR。我们涵盖了 SparkR 架构和 SparkR DataFrames API。此外,我们提供了使用 SparkR 进行探索性数据分析和数据整理任务、数据可视化和机器学习的代码示例。

在下一章中,我们将使用 Spark 模块的混合构建 Spark 应用程序。我们将展示将 Spark SQL 与 Spark Streaming、Spark 机器学习等结合的应用程序示例。

第九章:使用 Spark SQL 开发应用

在本章中,我们将介绍使用 Spark SQL 开发应用的几个示例。我们将主要关注基于文本分析的应用,包括预处理管道、词袋技术、计算财务文件的可读性指标、识别文档语料中的主题以及使用朴素贝叶斯分类器。此外,我们将描述一个机器学习示例的实现。

更具体地,您将在本章中了解以下内容:

  • 基于 Spark SQL 的应用开发

  • 预处理文本数据

  • 构建预处理数据管道

  • 在文档语料中识别主题

  • 使用朴素贝叶斯分类器

  • 开发机器学习应用

介绍 Spark SQL 应用

机器学习、预测分析和相关的数据科学主题正变得越来越受欢迎,用于解决商业领域的实际问题。这些应用正在推动许多组织中至关重要的业务决策。这些应用的例子包括推荐引擎、定向广告、语音识别、欺诈检测、图像识别和分类等。Spark(以及 Spark SQL)越来越成为这些大规模分布式应用的首选平台。

随着在线数据源(如财经新闻、收益电话会议、监管文件、社交媒体等)的可用性,人们对对各种格式的文本、音频和视频等非结构化数据进行自动化和智能化分析的兴趣日益增加。这些应用包括从监管文件中进行情感分析、对新闻文章和故事进行大规模自动化分析、Twitter 分析、股价预测应用等。

在本章中,我们将介绍一些处理文本数据的方法和技术。此外,我们将介绍一些应用机器学习模型对文本数据进行分类、从文档语料中得出见解以及为情感分析处理文本信息的示例。

在下一节中,我们将从几种方法开始介绍如何将监管文件转换为词语集合。这一步允许使用领域特定的词典来对文件的语气进行分类,训练算法来识别文件特征或识别作为一组文件的共同主题的隐藏结构。

有关会计和金融领域文本分析方法的更详细调查,请参阅 Tim Loughran 和 Bill McDonald 的《会计和金融中的文本分析:一项调查》,网址为papers.ssrn.com/sol3/papers.cfm?abstract_id=2504147

我们还将研究在实施文本分析应用中存在的典型问题、挑战和限制,例如将标记转换为词语、消除歧义句子以及清理财务披露文件中存在的嵌入式标签、文档和其他嘈杂元素。此外,请注意,使用 HTML 格式是解析文件时出现错误的主要来源。这种解析依赖于文本结构和相关标记语言的一致性,通常会导致重大错误。此外,重要的是要理解,我们通常对文本传达的意图和非意图信息都感兴趣。

理解文本分析应用

语言和书写的固有特性导致在分析文档时出现高维度的问题。因此,一些最广泛使用的文本方法依赖于独立性的关键假设,即单词的顺序和直接上下文并不重要。在忽略单词顺序的方法通常被标记为“词袋”技术。

与定量分析相比,文本分析更加不精确。文本数据需要额外的步骤将文本转化为定量指标,然后作为各种基于文本的分析或机器学习方法的输入。其中许多方法是基于将文档解构为术语-文档矩阵,其中包含单词行和单词计数列。

在使用词袋模型的应用中,规范化词频是很重要的,因为原始计数直接依赖于文档长度。简单地使用比例可以解决这个问题,但是我们可能也想要调整单词的权重。通常,这些方法是基于文档中给定术语的稀有程度,例如词频-逆文档频率tf-idf)。

在下一节中,我们将探讨使用 Spark SQL 进行财务文件的文本分析。

使用 Spark SQL 进行文本分析

在本节中,我们将展示一个典型的预处理示例,用于准备文本分析所需的数据(来自会计和金融领域)。我们还将计算一些可读性指标(接收信息的人是否能准确重构预期的消息)。

文本数据预处理

在本节中,我们将开发一组用于预处理10-K报告的函数。我们将使用 EDGAR 网站上的“完整提交文本文件”作为我们示例中的输入文本。

有关用于预处理10-K报告的Regex表达式的更多细节,请参阅 Jorg Hering 的年度报告算法:财务报表检索和文本信息提取,链接为airccj.org/CSCP/vol7/csit76615.pdf

首先,我们导入本章中所需的所有包:

scala> import spark.implicits._ 
scala> import org.apache.spark.sql._ 
scala> import org.apache.spark.sql.types._ 
scala> import scala.util.matching.Regex 
scala> import org.apache.spark.ml.{Pipeline, PipelineModel} 
scala> import org.apache.spark.rdd.RDD 
scala> import scala.math 
scala> import org.apache.spark.ml.feature.{HashingTF, IDF, RegexTokenizer, Tokenizer, NGram, StopWordsRemover, CountVectorizer} 
scala> import org.apache.spark.sql.{Row, DataFrame} 
scala> import org.apache.spark.ml.feature.{VectorAssembler, StringIndexer, IndexToString} scala> import org.apache.spark.ml.classification.{RandomForestClassificationModel, RandomForestClassifier, LogisticRegression, NaiveBayes, NaiveBayesModel} 
scala> import org.apache.spark.ml.Pipeline 
scala> import org.apache.spark.ml.evaluation.{RegressionEvaluator, MulticlassClassificationEvaluator} 
scala> import org.apache.spark.ml.linalg.Vector 
scala> import org.apache.spark.ml.tuning.{CrossValidator, ParamGridBuilder, TrainValidationSplit} 
scala> import org.apache.spark.ml.clustering.{LDA} 
scala> import scala.collection.mutable.WrappedArray 
scala> import org.apache.spark.ml._ 

//The following package will be created later in this Chapter. 
scala> import org.chap9.edgar10k._ 

接下来,我们读取输入文件,并将输入行转换为一个字符串以供我们处理。您可以从以下链接下载以下示例的输入文件:www.sec.gov/Archives/edgar/data/320193/000119312514383437/0001193125-14-383437-index.html

scala> val inputLines = sc.textFile("file:///Users/aurobindosarkar/Downloads/edgardata/0001193125-14-383437.txt") 

scala> val linesToString = inputLines.toLocalIterator.mkString  

随着预处理函数的执行,输入字符串的长度逐渐减少,因为在每个步骤中都删除了很多无关的文本/标签。我们计算原始字符串的起始长度,以跟踪在每个处理步骤中应用特定函数的影响:

scala> linesToString.length 
res0: Int = 11917240 
println statements to display the input and output string lengths (representing pre- and post-processing lengths):
scala> def deleteAbbrev(instr: String): String = { 
     |       //println("Input string length="+ instr.length()) 
     |       val pattern = new Regex("[A-Z]\\.([A-Z]\\.)+") 
     |       val str = pattern.replaceAllIn(instr, " ") 
     |       //println("Output string length ="+ str.length()) 
     |       //println("String length reduced by="+ (instr.length - str.length())) 
     |       str 
     | } 

scala> val lineRemAbbrev = deleteAbbrev(linesToString) 

此外,10-K文件由几个附件组成--XBRL、图形和其他文档(文件)类型嵌入在财务报表中;这些包括 Microsoft Excel 文件(文件扩展名*.xlsx)、ZIP 文件(文件扩展名*.zip)和编码的 PDF 文件(文件扩展名*.pdf)。

在接下来的步骤中,我们将应用额外的规则来删除这些嵌入式文档:

scala> def deleteDocTypes(instr: String): String = { 
     |       //println("Input string length="+ instr.length()) 
     |       val pattern = new Regex("(?s)<TYPE>(GRAPHIC|EXCEL|PDF|ZIP|COVER|CORRESP|EX-10[01].INS|EX-99.SDR [KL].INS|EX-10[01].SCH|EX-99.SDR [KL].SCH|EX-10[01].CAL|EX-99.SDR [KL].CAL|EX-10[01].DEF|EX-99.SDR [KL].LAB|EX-10[01].LAB|EX-99.SDR [KL].LAB|EX-10[01].PRE|EX-99.SDR [KL].PRE|EX-10[01].PRE|EX-99.SDR [KL].PRE).*?</TEXT>")    
     |       val str = pattern.replaceAllIn(instr, " ") 
     |       //println("Output string length ="+ str.length()) 
     |       //println("String length reduced by="+ (instr.length - str.length())) 
     |       str 
     | } 

scala> val lineRemDocTypes = deleteDocTypes(lineRemAbbrev)

接下来,我们删除核心文档和附件中包含的所有元数据,如下所示:

scala> def deleteMetaData(instr: String): String = { 
     |       val pattern1 = new Regex("<HEAD>.*?</HEAD>") 
     |       val str1 = pattern1.replaceAllIn(instr, " ") 
     |       val pattern2 = new Regex("(?s)<TYPE>.*?<SEQUENCE>.*?<FILENAME>.*?<DESCRIPTION>.*?") 
     |       val str2 = pattern2.replaceAllIn(str1, " ") 
     |       str2 
     | } 

scala> val lineRemMetaData = deleteMetaData(lineRemDocTypes)

在删除所有 HTML 元素及其对应的属性之前,我们先删除文档中的表格,因为它们通常包含非文本(定量)信息。

以下函数使用一组正则表达式来删除嵌入在财务报表中的表格和 HTML 元素:

scala> def deleteTablesNHTMLElem(instr: String): String = { 
     |       val pattern1 = new Regex("(?s)(?i)<Table.*?</Table>") 
     |       val str1 = pattern1.replaceAllIn(instr, " ") 
     |       val pattern2 = new Regex("(?s)<[^>]*>") 
     |       val str2 = pattern2.replaceAllIn(str1, " ") 
     |       str2 
     | } 

scala> val lineRemTabNHTML = deleteTablesNHTMLElem(lineRemMetaData) 

接下来,我们提取每个 HTML 格式文档的正文部分的文本。由于 EDGAR 系统接受包含扩展字符集的提交,比如&nbsp; &amp;&reg;等等--它们需要被解码和/或适当替换以进行文本分析。

在这个函数中,我们展示了一些例子:

scala> def deleteExtCharset(instr: String): String = { 
     |       val pattern1 = new Regex("(?s)( |&nbsp;|&#x(A|a)0;)") 
     |       val str1 = pattern1.replaceAllIn(instr, " ") 
     |       val pattern2 = new Regex("(’|’)") 
     |       val str2 = pattern2.replaceAllIn(str1, "'") 
     |       val pattern3 = new Regex("x") 
     |       val str3 = pattern3.replaceAllIn(str2, " ") 
     |       val pattern4 = new Regex("(¨|§|&reg;|™|&copy;)") 
     |       val str4 = pattern4.replaceAllIn(str3, " ") 
     |       val pattern5 = new Regex("(“|”|“|”)") 
     |       val str5 = pattern5.replaceAllIn(str4, "\"") 
     |       val pattern6 = new Regex("&amp;") 
     |       val str6 = pattern6.replaceAllIn(str5, "&") 
     |       val pattern7 = new Regex("(–|—|–)") 
     |       val str7 = pattern7.replaceAllIn(str6, "-") 
     |       val pattern8 = new Regex("⁄") 
     |       val str8 = pattern8.replaceAllIn(str7, "/") 
     |       str8 
     | } 

scala> val lineRemExtChrst = deleteExtCharset(lineRemTabNHTML) 

接下来,我们定义一个函数,清除多余的空格、换行和回车:

scala> def deleteExcessLFCRWS(instr: String): String = { 
     |       val pattern1 = new Regex("[\n\r]+") 
     |       val str1 = pattern1.replaceAllIn(instr, "\n") 
     |       val pattern2 = new Regex("[\t]+") 
     |       val str2 = pattern2.replaceAllIn(str1, " ") 
     |       val pattern3 = new Regex("\\s+") 
     |       val str3 = pattern3.replaceAllIn(str2, " ") 
     |       str3 
     | } 

scala> val lineRemExcessLFCRWS = deleteExcessLFCRWS(lineRemExtChrst) 

在下一个代码块中,我们定义一个函数来说明删除用户指定的一组字符串。这些字符串可以从输入文件或数据库中读取。如果您可以实现Regex来处理文档中通常存在的多余文本(并且从文档到文档可能会有所不同),但在文本分析中没有任何附加值,则不需要这一步:

scala> def deleteStrings(str: String): String = { 
     |       val strings = Array("IDEA: XBRL DOCUMENT", "\\/\\* Do Not Remove This Comment \\*\\/", "v2.4.0.8") 
     |       //println("str="+ str.length()) 
     |       var str1 = str 
     |       for(myString <- strings) { 
     |          var pattern1 = new Regex(myString) 
     |          str1 = pattern1.replaceAllIn(str1, " ") 
     |       } 
     |       str1 
     | } 

scala> val lineRemStrings = deleteStrings(lineRemExcessLFCRWS) 

在下一步中,我们将从文档字符串中删除所有的 URL、文件名、数字和标点符号(除了句号)。在这个阶段,句号被保留下来用于计算文本中句子的数量(如下一节所示):

scala> def deleteAllURLsFileNamesDigitsPunctuationExceptPeriod(instr: String): String = { 
     |       val pattern1 = new Regex("\\b(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]") 
     |       val str1 = pattern1.replaceAllIn(instr, "") 
     |       val pattern2 = new Regex("[_a-zA-Z0-9\\-\\.]+.(txt|sgml|xml|xsd|htm|html)") 
     |       val str2 = pattern2.replaceAllIn(str1, " ") 
     |       val pattern3 = new Regex("[^a-zA-Z|^.]") 
     |       val str3 = pattern3.replaceAllIn(str2, " ") 
     |       str3 
     | } 

scala> val lineRemAllUrlsFileNamesDigitsPuncXPeriod = deleteAllURLsFileNamesDigitsPunctuationExceptPeriod(lineRemStrings) 

在下一节中,我们将讨论一些通常用于衡量可读性的指标,即10-K申报中包含的文本信息是否对用户可访问。

计算可读性

雾指数和年度报告中包含的单词数量已被广泛用作年度报告(即Form 10-Ks)的可读性指标。雾指数是两个变量的函数:平均句子长度(以单词计)和复杂单词(定义为超过两个音节的单词的百分比):

*雾指数 = 0.4 (每句平均单词数 + 复杂单词的百分比)

雾指数方程估计了阅读文本所需的教育年限。因此,雾指数值为16意味着读者需要十六年的教育——基本上是大学学位——才能在第一次阅读时理解文本。一般来说,雾指数超过十八的文件被认为是不可读的,因为需要超过硕士学位才能理解文本。

解析10-K以计算每个句子的平均单词数通常是一个困难且容易出错的过程,因为这些文件包含各种缩写,并使用句号来界定部分标识符或作为空格。此外,真实世界的系统还需要识别这些申报中包含的许多列表(基于标点和行间距)。例如,这样的应用程序需要避免计算部分标题、省略号或其他情况中的句号,并假定剩下的句号是句子的结束。

平均每句单词数的度量是由单词数除以句子终止符的数量确定的。这通常是通过删除缩写和其他虚假的句号源,然后计算句子终止符和单词的数量来完成的。

我们计算文本中剩余的句号数量,如下所示:

scala> val countPeriods = lineRemAllUrlsFileNamesDigitsPuncXPeriod.count(_ == '.')   
countPeriods: Int = 2538 

接下来,我们将删除文本中剩余的所有句号(和任何其他非字母字符),以得到包含在我们原始文档中的初始单词集。请注意,所有这些单词可能仍然不是合法的单词:

scala> def keepOnlyAlphas(instr: String): String = { 
     |       val pattern1 = new Regex("[^a-zA-Z|]") 
     |       val str1 = pattern1.replaceAllIn(instr, " ") 
     |       val str2 = str1.replaceAll("[\\s]+", " ") 
     |       str2 
     | } 

scala> val lineWords = keepOnlyAlphas(lineRemAllUrlsFileNamesDigitsPuncXPeriod) 

在接下来的步骤中,我们将把单词字符串转换为 DataFrame,并使用explode()函数为每个单词创建一行:

scala> val wordsStringDF = sc.parallelize(List(lineWords)).toDF() 

scala> val wordsDF = wordsStringDF.withColumn("words10k", explode(split($"value", "[\\s]"))).drop("value") 

接下来,我们将读取一个词典(最好是领域特定的词典)。我们将把我们的单词列表与这个词典进行匹配,以得到我们最终的单词列表(在这个阶段它们应该都是合法的单词)。

scala> val dictDF = spark.read.format("csv").option("header", "true").load("file:///Users/aurobindosarkar/Downloads/edgardata/LoughranMcDonald_MasterDictionary_2014.csv") 

为了我们的目的,我们使用了 Loughran & McDonold 的主词典,因为它包含在 10-K 报告中通常找到的单词。您可以从www3.nd.edu/~mcdonald/Word_Lists.html下载LoughranMcDonald_MasterDictionary_2014.csv文件和相关文档。

在下一步中,我们将我们的单词列表 DataFrame 与词典连接,并计算我们最终列表中的单词数量:

scala> val joinWordsDict = wordsDF.join(dictDF, lower(wordsDF("words10k")) === lower(dictDF("Word"))) 

scala> val numWords = joinWordsDict.count() 
numWords: Long = 54701 

平均每句单词数是通过将单词数除以先前计算的句号数来计算的:

scala> val avgWordsPerSentence = numWords / countPeriods 
avgWordsPerSentence: Long = 21 

我们使用词典中的“音节”列来计算我们的单词列表中有多少单词有两个以上的音节,具体如下:

scala> val numPolySylb = joinWordsDict.select("words10k", "Syllables").where(joinWordsDict("Syllables") > 2) 

scala> val polySCount = numPolySylb.count() 
polySCount: Long = 14093 

最后,我们将参数插入到我们的方程中,计算 Fog 指数,如下所示:

scala> val fogIndex = 0.4*(avgWordsPerSentence+((polySCount/numWords)*100)) 
fogIndex: Double = 8.4 

反对使用可读性指标(例如 Fog 指数)在财务文件中的观点是,这些文件中的大多数并不能根据所使用的写作风格来区分。此外,即使这些文件中复杂词语的比例可能很高,这些词语或行业术语对这些文件的受众(例如投资者社区)来说是容易理解的。

作为年度报告可读性的简单指标,Loughran 和 McDonald 建议使用10-K文件大小的自然对数(完整提交文本文件)。与 Fog 指数相比,这个指标更容易获得,不需要复杂的解析10-K文件。

在下一步中,我们提供一个计算文件(或者更具体地说,RDD)大小的函数:

scala> def calcFileSize(rdd: RDD[String]): Long = { 
     |   rdd.map(_.getBytes("UTF-8").length.toLong) 
     |      .reduce(_+_) //add the sizes together 
     | } 

scala> val lines = sc.textFile("file:///Users/aurobindosarkar/Downloads/edgardata/0001193125-14-383437.txt") 

文件大小(以 MB 为单位)和文件大小的对数可以如下计算:

scala> val fileSize = calcFileSize(lines)/1000000.0 
fileSize: Double = 11.91724 

scala> math.log(fileSize) 
res1: Double = 2.477986091202679 

尽管文件大小是衡量文档可读性的一个很好的指标,比如10-K备案,但对于新闻稿、新闻稿件和盈利电话会议等文本可能不太合适。在这种情况下,由于文本长度变化不大,更适合采用更注重内容的其他方法。

在下一节中,我们将讨论在文本分析中使用单词列表。

使用单词列表

在衡量财务文件的语气或情绪时,从业者通常计算与特定情绪相关的单词数量,按照文档中的总单词数进行比例缩放。因此,例如,文档中更高比例的负面词语表明更悲观的语气。

使用词典来衡量语气具有几个重要优势。除了在大规模计算情绪时的便利性外,使用这种词典还通过消除个体主观性来促进标准化。对于分类单词的重要组成部分是识别每个分类中最常出现的单词。

在下一步中,我们使用词典中包含的单词情感指标来了解10-K备案的情感或语气。这可以相对于同一组织以往的备案进行计算,或者与同一或不同行业的其他组织进行比较。

scala> val negWordCount = joinWordsDict.select("words10k", "negative").where(joinWordsDict("negative") > 0).count() 
negWordCount: Long = 1004 

scala> val sentiment = negWordCount / (numWords.toDouble) 
sentiment: Double = 0.01835432624632091 

通常,在这种分析中,情态词的使用也很重要。例如,使用较弱的情态词(例如,may,could 和 might)可能会暗示公司存在问题:

scala> val modalWordCount = joinWordsDict.select("words10k", "modal").where(joinWordsDict("modal") > 0).groupBy("modal").count() 

在下面的代码中,我们计算每个情态词类别的单词数量。根据此处使用的词典的参考文档,1表示“强情态”(例如,“always”,“definitely”和“never”等词),2表示“中等情态”(例如,“can”,“generally”和“usually”等词),而3表示“弱情态”(例如,“almost”,“could”,“might”和“suggests”等词):

scala> modalWordCount.show() 
+-----+-----+ 
|modal|count| 
+-----+-----+ 
|    3|  386| 
|    1|  115| 
|    2|  221| 
+-----+-----+ 

在下一节中,我们将使用本节定义的一些函数来为10-K备案创建数据预处理流水线。

创建数据预处理流水线

在本节中,我们将一些先前部分的数据处理函数转换为自定义 Transformer。这些 Transformer 对象将输入 DataFrame 映射到输出 DataFrame,并通常用于为机器学习应用程序准备 DataFrame。

我们创建以下类作为UnaryTransformer对象,将转换应用于一个输入 DataFrame 列,并通过将新列(包含应用函数的处理结果)附加到其中来生成另一个列。然后,这些自定义 Transformer 对象可以成为处理流水线的一部分。

首先,我们创建四个自定义的UnaryTransformer类,我们将在示例中使用,如下所示:

TablesNHTMLElemCleaner.scala

package org.chap9.edgar10k
import org.apache.spark.ml.UnaryTransformer
import org.apache.spark.sql.types.{DataType, DataTypes, StringType}
import scala.util.matching.Regex
import org.apache.spark.ml.util.Identifiable

class TablesNHTMLElemCleaner(override val uid: String) extends UnaryTransformer[String, String, TablesNHTMLElemCleaner] {
   def this() = this(Identifiable.randomUID("cleaner"))
   def deleteTablesNHTMLElem(instr: String): String = {
      val pattern1 = new Regex("(?s)(?i)<Table.*?</Table>")
      val str1 = pattern1.replaceAllIn(instr, " ")
      val pattern2 = new Regex("(?s)<[^>]*>")
      val str2 = pattern2.replaceAllIn(str1, " ")
      str2
   }

override protected def createTransformFunc: String => String = {
   deleteTablesNHTMLElem _
}

override protected def validateInputType(inputType: DataType): Unit = {
   require(inputType == StringType)
}

override protected def outputDataType: DataType = DataTypes.StringType
}

AllURLsFileNamesDigitsPunctuationExceptPeriodCleaner.scala

package org.chap9.edgar10k
import org.apache.spark.ml.UnaryTransformer
import org.apache.spark.sql.types.{DataType, DataTypes, StringType}
import scala.util.matching.Regex
import org.apache.spark.ml.util.Identifiable

class AllURLsFileNamesDigitsPunctuationExceptPeriodCleaner(override val uid: String) extends UnaryTransformer[String, String, AllURLsFileNamesDigitsPunctuationExceptPeriodCleaner] {
   def this() = this(Identifiable.randomUID("cleaner"))
   def deleteAllURLsFileNamesDigitsPunctuationExceptPeriod(instr: String): String = {
      val pattern1 = new Regex("\\b(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]")
      val str1 = pattern1.replaceAllIn(instr, "")
      val pattern2 = new Regex("[_a-zA-Z0-9\\-\\.]+.(txt|sgml|xml|xsd|htm|html)")
      val str2 = pattern2.replaceAllIn(str1, " ")
      val pattern3 = new Regex("[^a-zA-Z|^.]")
      val str3 = pattern3.replaceAllIn(str2, " ")
      str3
}

override protected def createTransformFunc: String => String = {
   deleteAllURLsFileNamesDigitsPunctuationExceptPeriod _
}

override protected def validateInputType(inputType: DataType): Unit = {
   require(inputType == StringType)
}

override protected def outputDataType: DataType = DataTypes.StringType
}

OnlyAlphasCleaner.scala

package org.chap9.edgar10k
import org.apache.spark.ml.UnaryTransformer
import org.apache.spark.sql.types.{DataType, DataTypes, StringType}
import scala.util.matching.Regex
import org.apache.spark.ml.util.Identifiable
class OnlyAlphasCleaner(override val uid: String) extends UnaryTransformer[String, String, OnlyAlphasCleaner] {
   def this() = this(Identifiable.randomUID("cleaner"))
   def keepOnlyAlphas(instr: String): String = {
      val pattern1 = new Regex("[^a-zA-Z|]")
      val str1 = pattern1.replaceAllIn(instr, " ")
      val str2 = str1.replaceAll("[\\s]+", " ")
      str2
   }
override protected def createTransformFunc: String => String = {
   keepOnlyAlphas _
}

override protected def validateInputType(inputType: DataType): Unit = {
require(inputType == StringType)
}

override protected def outputDataType: DataType = DataTypes.StringType
}

ExcessLFCRWSCleaner.scala

package org.chap9.edgar10k
import org.apache.spark.ml.UnaryTransformer
import org.apache.spark.sql.types.{DataType, DataTypes, StringType}
import scala.util.matching.Regex
import org.apache.spark.ml.util.Identifiable

class ExcessLFCRWSCleaner(override val uid: String) extends UnaryTransformer[String, String, ExcessLFCRWSCleaner] {
   def this() = this(Identifiable.randomUID("cleaner"))
   def deleteExcessLFCRWS(instr: String): String = {
   val pattern1 = new Regex("[\n\r]+")
   val str1 = pattern1.replaceAllIn(instr, "\n")
   val pattern2 = new Regex("[\t]+")
   val str2 = pattern2.replaceAllIn(str1, " ")
   val pattern3 = new Regex("\\s+")
   val str3 = pattern3.replaceAllIn(str2, " ")
   str3
}

override protected def createTransformFunc: String => String = {
   deleteExcessLFCRWS _
}

override protected def validateInputType(inputType: DataType): Unit = {
   require(inputType == StringType)
}

override protected def outputDataType: DataType = DataTypes.StringType
}

创建以下build.sbt文件来编译和打包目标类:

name := "Chapter9"
version := "2.0"
scalaVersion := "2.11.8"
libraryDependencies ++= Seq(
("org.apache.spark" % "spark-core_2.11" % "2.2.0" % "provided"),
("org.apache.spark" % "spark-sql_2.11" % "2.2.0" % "provided"),
("org.apache.spark" % "spark-mllib_2.11" % "2.2.0" % "provided")
)
libraryDependencies += "com.github.scopt" %% "scopt" % "3.4.0"
libraryDependencies += "com.typesafe" % "config" % "1.3.0"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging-api" % "2.1.2"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging-slf4j" % "2.1.2"
libraryDependencies += "org.scalatest" % "scalatest_2.11" % "3.0.1" % "test"

使用以下SBT命令编译并打包类成一个 JAR 文件:

Aurobindos-MacBook-Pro-2:Chapter9 aurobindosarkar$ sbt package

最后,重新启动 Spark shell,并将前面的 JAR 文件包含在会话中:

Aurobindos-MacBook-Pro-2:spark-2.2.1-SNAPSHOT-bin-hadoop2.7 aurobindosarkar$ bin/spark-shell --driver-memory 12g --conf spark.driver.maxResultSize=12g --conf spark.sql.shuffle.partitions=800 --jars /Users/aurobindosarkar/Downloads/Chapter9/target/scala-2.11/chapter9_2.11-2.0.jar

以下示例的数据集,Reuters-21578,Distribution 1.0,可以从archive.ics.uci.edu/ml/datasets/reuters-21578+text+categorization+collection下载。

在这里,我们将使用下载的 SGML 文件中由<Reuters>...</Reuters>标记分隔的条目之一,创建一个包含单个故事的新输入文件。这大致模拟了一个新故事进入我们的管道。更具体地说,这个故事可能是通过 Kafka 队列进入的,我们可以创建一个连续的 Spark SQL 应用程序来处理传入的故事文本。

首先,我们将新创建的文件读入 DataFrame 中,如下所示:

scala> val linesDF1 = sc.textFile("file:///Users/aurobindosarkar/Downloads/reuters21578/reut2-020-1.sgm").toDF()

接下来,我们使用本节中之前定义的类创建 Transformer 的实例。通过将每个 Transformer 的输出列指定为链中下一个 Transformer 的输入列,将 Transformer 在管道中链接在一起:

scala> val tablesNHTMLElemCleaner = new TablesNHTMLElemCleaner().setInputCol("value").setOutputCol("tablesNHTMLElemCleaned")

scala> val allURLsFileNamesDigitsPunctuationExceptPeriodCleaner = new AllURLsFileNamesDigitsPunctuationExceptPeriodCleaner().setInputCol("tablesNHTMLElemCleaned").setOutputCol("allURLsFileNamesDigitsPunctuationExceptPeriodCleaned")

scala> val onlyAlphasCleaner = new OnlyAlphasCleaner().setInputCol("allURLsFileNamesDigitsPunctuationExceptPeriodCleaned").setOutputCol("text")

scala> val excessLFCRWSCleaner = new ExcessLFCRWSCleaner().setInputCol("text").setOutputCol("cleaned")

在通过我们的清洗组件处理文本后,我们添加了另外两个阶段,以对我们的文本进行标记化和去除停用词,如示例所示。我们使用可在www3.nd.edu/~mcdonald/Word_Lists.html上找到的通用停用词列表文件:

scala> val tokenizer = new RegexTokenizer().setInputCol("cleaned").setOutputCol("words").setPattern("\\W")

scala> val stopwords: Array[String] = sc.textFile("file:///Users/aurobindosarkar/Downloads/StopWords_GenericLong.txt").flatMap(_.stripMargin.split("\\s+")).collect

scala> val remover = new StopWordsRemover().setStopWords(stopwords).setCaseSensitive(false).setInputCol("words").setOutputCol("filtered")

在这个阶段,所有处理阶段的组件都准备好被组装成一个管道。

有关 Spark 管道的更多详细信息,请参阅spark.apache.org/docs/latest/ml-pipeline.html

我们创建一个管道,并将所有 Transformer 链接在一起,以指定管道阶段,如下所示:

scala> val pipeline = new Pipeline().setStages(Array(tablesNHTMLElemCleaner, allURLsFileNamesDigitsPunctuationExceptPeriodCleaner, onlyAlphasCleaner, excessLFCRWSCleaner, tokenizer, remover))

在原始包含原始文本文档的 DataFrame 上调用pipeline.fit()方法:

scala> val model = pipeline.fit(linesDF1)

我们可以使用前面步骤的管道模型来转换我们原始的数据集,以满足其他下游文本应用程序的要求。我们还删除了中间处理步骤的列,以清理我们的 DataFrame:

scala> val cleanedDF = model.transform(linesDF1).drop("value").drop("tablesNHTMLElemCleaned").drop("excessLFCRWSCleaned").drop("allURLsFileNamesDigitsPunctuationExceptPeriodCleaned").drop("text").drop("word")

此外,我们可以通过删除包含空字符串或空格的任何行来清理最终输出列,如下所示:

scala> val finalDF = cleanedDF.filter(($"cleaned" =!= "") && ($"cleaned" =!= " "))
scala> cleanedDF.count()
res3: Long = 62

其余处理步骤与我们之前介绍的类似。以下步骤将包含我们单词的列分解为单独的行,将我们最终的单词列表与字典连接,然后计算情感和情态词的使用:

scala> val wordsInStoryDF = finalDF.withColumn("wordsInStory", explode(split($"cleaned", "[\\s]"))).drop("cleaned")

scala> val joinWordsDict = wordsInStoryDF.join(dictDF, lower(wordsInStoryDF("wordsInStory")) === lower(dictDF("Word")))

scala> wordsInStoryDF.count()
res4: Long = 457

scala> val numWords = joinWordsDict.count().toDouble
numWords: Double = 334.0

scala> joinWordsDict.select("wordsInStory").show()

scala> val negWordCount = joinWordsDict.select("wordsInStory", "negative").where(joinWordsDict("negative") > 0).count()
negWordCount: Long = 8

scala> val sentiment = negWordCount / (numWords.toDouble)
sentiment: Double = 0.023952095808383235

scala> val modalWordCount = joinWordsDict.select("wordsInStory", "modal").where(joinWordsDict("modal") > 0).groupBy("modal").count()

scala> modalWordCount.show()
+-----+-----+
|modal|count|
+-----+-----+
|    3|    2|
|    1|    5|
|    2|    4|
+-----+-----+

下一组步骤演示了使用前面的管道处理我们语料库中的另一个故事。然后我们可以比较这些结果,以获得故事中悲观情绪的相对感知:

scala> val linesDF2 = sc.textFile("file:///Users/aurobindosarkar/Downloads/reuters21578/reut2-008-1.sgm").toDF()

scala> val cleanedDF = model.transform(linesDF2).drop("value").drop("tablesNHTMLElemCleaned").drop("excessLFCRWSCleaned").drop("allURLsFileNamesDigitsPunctuationExceptPeriodCleaned").drop("text").drop("word")
cleanedDF: org.apache.spark.sql.DataFrame = [cleaned: string,

scala> val finalDF = cleanedDF.filter(($"cleaned" =!= "") && ($"cleaned" =!= " "))

scala> cleanedDF.count()
res7: Long = 84

scala> val wordsInStoryDF = finalDF.withColumn("wordsInStory", explode(split($"cleaned", "[\\s]"))).drop("cleaned")

scala> val joinWordsDict = wordsInStoryDF.join(dictDF, lower(wordsInStoryDF("wordsInStory")) === lower(dictDF("Word")))

scala> wordsInStoryDF.count()
res8: Long = 598

scala> val numWords = joinWordsDict.count().toDouble
numWords: Double = 483.0

scala> joinWordsDict.select("wordsInStory").show()

scala> val negWordCount = joinWordsDict.select("wordsInStory", "negative").where(joinWordsDict("negative") > 0).count()
negWordCount: Long = 15

根据以下负面情感计算,我们可以得出结论,这个故事相对来说比之前分析的更悲观:

scala> val sentiment = negWordCount / (numWords.toDouble)
sentiment: Double = 0.031055900621118012

scala> val modalWordCount = joinWordsDict.select("wordsInStory", "modal").where(joinWordsDict("modal") > 0).groupBy("modal").count()

scala> modalWordCount.show()
+-----+-----+
|modal|count|
+-----+-----+
|    3|    1|
|    1|    3|
|    2|    4|
+-----+-----+

在接下来的部分,我们将把重点转移到识别文档语料库中的主要主题。

理解文档语料库中的主题

基于词袋技术也可以用于对文档中的常见主题进行分类,或者用于识别文档语料库中的主题。广义上讲,这些技术,像大多数技术一样,试图基于每个词与潜在变量的关系来减少术语-文档矩阵的维度。

这种分类的最早方法之一是潜在语义分析LSA)。LSA 可以避免与同义词和具有多重含义的术语相关的基于计数的方法的限制。多年来,LSA 的概念演变成了另一个称为潜在狄利克雷分配LDA)的模型。

LDA 允许我们在一系列文档中识别潜在的主题结构。LSA 和 LDA 都使用术语-文档矩阵来降低术语空间的维度,并生成主题权重。LSA 和 LDA 技术的一个限制是它们在应用于大型文档时效果最佳。

有关 LDA 的更详细解释,请参阅 David M. Blei、Andrew Y. Ng 和 Michael I. Jordan 的潜在狄利克雷分配,网址为ai.stanford.edu/~ang/papers/jair03-lda.pdf

现在,我们展示了在 XML 文档语料库上使用 LDA 的示例。

使用包含用于读取 XML 文档的包启动 Spark shell,因为我们将在本节中读取基于 XML 的语料库:

Aurobindos-MacBook-Pro-2:spark-2.2.1-SNAPSHOT-bin-hadoop2.7 aurobindosarkar$ bin/spark-shell --driver-memory 12g --conf spark.driver.maxResultSize=12g --conf spark.sql.shuffle.partitions=800 --packages com.databricks:spark-xml_2.11:0.4.1

接下来,我们定义了一些常量,包括主题数量、最大迭代次数和词汇量大小,如下所示:

scala> val numTopics: Int = 10
scala> val maxIterations: Int = 100
scala> val vocabSize: Int = 10000

PERMISSIVE 模式如下所示,允许我们在解析过程中遇到损坏记录时继续创建 DataFrame。rowTag 参数指定要读取的 XML 节点。在这里,我们对使用 LDA 进行主题分析时文档中的句子感兴趣。

此示例的数据集包含来自澳大利亚联邦法院FCA)的 4,000 个法律案例,可从archive.ics.uci.edu/ml/datasets/Legal+Case+Reports下载。

我们读取所有案例文件,如下所示:

scala> val df = spark.read.format("com.databricks.spark.xml").option("rowTag", "sentences").option("mode", "PERMISSIVE").load("file:///Users/aurobindosarkar/Downloads/corpus/fulltext/*.xml")

接下来,我们为每个法律案例生成文档 ID,如下所示:

scala> val docDF = df.select("sentence._VALUE").withColumn("docId", monotonically_increasing_id()).withColumn("sentences", concat_ws(",", $"_VALUE")).drop("_VALUE")

scala> // Split each document into words
scala> val tokens = new RegexTokenizer().setGaps(false).setPattern("\\p{L}+").setInputCol("sentences").setOutputCol("words").transform(docDF)

scala> //Remove stop words using the default stop word list provided with the Spark distribution.
scala> val filteredTokens = new StopWordsRemover().setCaseSensitive(false).setInputCol("words").setOutputCol("filtered").transform(tokens)

我们使用CountVectorizer(和CountVectorizerModel)将我们的法律文件集转换为标记计数的向量。在这里,我们没有预先的词典可用,因此CountVectorizer被用作估计器来提取词汇并生成CountVectorizerModel。该模型为文档生成了在词汇表上的稀疏表示,然后传递给 LDA 算法。在拟合过程中,CountVectorizer将选择跨语料库按词项频率排序的前vocabSize个词:

scala> val cvModel = new CountVectorizer().setInputCol("filtered").setOutputCol("features").setVocabSize(vocabSize).fit(filteredTokens)

scala> val termVectors = cvModel.transform(filteredTokens).select("docId", "features")

scala> val lda = new LDA().setK(numTopics).setMaxIter(maxIterations)

scala> val ldaModel = lda.fit(termVectors)

scala> println("Model was fit using parameters: " + ldaModel.parent.extractParamMap)

Model was fit using parameters: {
lda_8b00356ca964-checkpointInterval: 10,
lda_8b00356ca964-featuresCol: features,
lda_8b00356ca964-k: 10,
lda_8b00356ca964-keepLastCheckpoint: true,
lda_8b00356ca964-learningDecay: 0.51,
lda_8b00356ca964-learningOffset: 1024.0,
lda_8b00356ca964-maxIter: 100,
lda_8b00356ca964-optimizeDocConcentration: true,
lda_8b00356ca964-optimizer: online,
lda_8b00356ca964-seed: 1435876747,
lda_8b00356ca964-subsamplingRate: 0.05,
lda_8b00356ca964-topicDistributionCol: topicDistribution
}

我们计算 LDA 模型的对数似然和对数困惑度,如下所示。具有更高似然的模型意味着更好的模型。同样,更低的困惑度代表更好的模型:

scala> val ll = ldaModel.logLikelihood(termVectors)
ll: Double = -6.912755229181568E7

scala> val lp = ldaModel.logPerplexity(termVectors)
lp: Double = 7.558777992719632

scala> println(s"The lower bound on the log likelihood of the entire corpus: $ll")
The lower bound on the log likelihood of the entire corpus: -6.912755229181568E7

scala> println(s"The upper bound on perplexity: $lp")
The upper bound on perplexity: 7.558777992719632

接下来,我们使用describeTopics()函数显示由其权重最高的术语描述的主题,如下所示:

scala> val topicsDF = ldaModel.describeTopics(3)

scala> println("The topics described by their top-weighted terms:")
The topics described by their top-weighted terms are the following:

scala> topicsDF.show(false)

scala> val transformed = ldaModel.transform(termVectors)

scala> transformed.select("docId", "topicDistribution").take(3).foreach(println)

[0,[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]]
[8589934592,[8.963966883240337E-5,7.477786237947913E-5,1.1695214724007773E-4,7.651092413869693E-5,5.878144972523343E-5,1.533289774455994E-4,8.250794034920294E-5,6.472049126896475E-5,7.008103300313653E-5,0.9992126995056172]]
[17179869184,[9.665344356333612E-6,8.06287932260242E-6,0.13933607311582796,8.249745717562721E-6,6.338075472527743E-6,1.6528598250017008E-5,8.89637068587104E-6,6.978449157294409E-6,0.029630980885952427,0.8309682265352574]]

scala> val vocab = cvModel.vocabulary

我们可以显示包含与术语索引对应的实际术语的结果,如下所示。从这里显示的词语中很明显,我们正在处理一个法律语料库。

scala> for ((row) <- topicsDF) {
| var i = 0
| var termsString = ""
| var topicTermIndicesString = ""
| val topicNumber = row.get(0)
| val topicTerms:WrappedArray[Int] = row.get(1).asInstanceOf[WrappedArray[Int]]
|
| for (i <- 0 to topicTerms.length-1){
| topicTermIndicesString += topicTerms(i) +", "
| termsString += vocab(topicTerms(i)) +", "
| }
|
| println ("Topic: "+ topicNumber+ "|["+topicTermIndicesString + "]|[" + termsString +"]")
| }

Topic: 1|[3, 231, 292, ]|[act, title, native, ]
Topic: 5|[4, 12, 1, ]|[tribunal, appellant, applicant, ]
Topic: 6|[0, 6, 13, ]|[mr, evidence, said, ]
Topic: 0|[40, 168, 0, ]|[company, scheme, mr, ]
Topic: 2|[0, 32, 3, ]|[mr, agreement, act, ]
Topic: 7|[124, 198, 218, ]|[commissioner, income, tax, ]
Topic: 3|[3, 44, 211, ]|[act, conduct, price, ]
Topic: 8|[197, 6, 447, ]|[trade, evidence, mark, ]
Topic: 4|[559, 130, 678, ]|[patent, dr, university, ]
Topic: 9|[2, 1, 9, ]|[court, applicant, respondent, ]Using

文本分析的下一个主要主题是搭配词。对于一些词来说,它们的意义很大程度上来自于与其他词的搭配。基于搭配来预测词义通常是简单词袋方法之外最常见的扩展之一。

在下一节中,我们将研究在 n-gram 上使用朴素贝叶斯分类器。

使用朴素贝叶斯分类器

朴素贝叶斯分类器是一类基于贝叶斯条件概率定理的概率分类器。这些分类器假设特征之间相互独立。朴素贝叶斯通常是文本分类的基准方法,使用词频作为特征集。尽管存在强烈的独立性假设,朴素贝叶斯分类器快速且易于实现;因此,在实践中它们被广泛使用。

尽管朴素贝叶斯非常受欢迎,但它也存在可能导致偏向某一类别的错误。例如,倾斜的数据可能导致分类器偏向某一类别。同样,独立性假设可能导致错误的分类权重,偏向某一类别。

有关处理朴素贝叶斯分类器相关问题的具体启发式方法,请参阅 Rennie,Shih 等人的《解决朴素贝叶斯文本分类器的错误假设》people.csail.mit.edu/jrennie/papers/icml03-nb.pdf

朴素贝叶斯方法的主要优点之一是它不需要大量的训练数据集来估计分类所需的参数。在使用监督机器学习进行单词分类的各种方法中,朴素贝叶斯方法非常受欢迎;例如,年度报告中哪些句子可以被分类为“负面”、“正面”或“中性”。朴素贝叶斯方法也是最常与 n-grams 和支持向量机(SVMs)一起使用的。

N-grams 用于各种不同的任务。例如,n-grams 可用于为监督机器学习模型(如 SVMs,最大熵模型和朴素贝叶斯)开发特征。当N的值为1时,n-grams 被称为 unigrams(实质上是句子中的单个词;当N的值为2时,它们被称为 bigrams,当N3时,它们被称为 trigrams,依此类推。这里的主要思想是在特征空间中使用 bigrams 等标记,而不是单个词或 unigrams。

该示例的数据集包含大约 169 万条电子产品类别的亚马逊评论,可从jmcauley.ucsd.edu/data/amazon/下载。

有关此示例中使用的步骤的更详细解释,请查看 Mike Seddon 的《使用 Apache Spark ML 和亚马逊评论进行自然语言处理》(第 1 和第二部分)mike.seddon.ca/natural-language-processing-with-apache-spark-ml-and-amazon-reviews-part-1/

首先,我们读取输入的 JSON 文件以创建我们的输入 DataFrame:

scala> val inDF = spark.read.json("file:///Users/aurobindosarkar/Downloads/reviews_Electronics_5.json")

scala> inDF.show()

您可以打印模式,如下所示:

scala> inDF.printSchema()
root
|-- asin: string (nullable = true)
|-- helpful: array (nullable = true)
| |-- element: long (containsNull = true)
|-- overall: double (nullable = true)
|-- reviewText: string (nullable = true)
|-- reviewTime: string (nullable = true)
|-- reviewerID: string (nullable = true)
|-- reviewerName: string (nullable = true)
|-- summary: string (nullable = true)
|-- unixReviewTime: long (nullable = true)

接下来,我们打印出每个评分值的记录数,如下所示。请注意,记录数在评分五方面严重倾斜。这种倾斜可能会影响我们的结果,使评分五相对于其他评分更有利:

scala> inDF.groupBy("overall").count().orderBy("overall").show()
+-------+-------+
|overall| count|
+-------+-------+
|    1.0| 108725|
|    2.0|  82139|
|    3.0| 142257|
|    4.0| 347041|
|    5.0|1009026|
+-------+-------+

我们从 DataFrame 创建一个视图,如下所示。这一步可以方便地帮助我们创建一个更平衡的训练 DataFrame,其中包含每个评分类别的相等数量的记录:

scala> inDF.createOrReplaceTempView("reviewsTable")

scala> val reviewsDF = spark.sql(
| """
| SELECT text, label, rowNumber FROM (
| SELECT
| overall AS label, reviewText AS text, row_number() OVER (PARTITION BY overall ORDER BY rand()) AS rowNumber FROM reviewsTable
| ) reviewsTable
| WHERE rowNumber <= 60000
| """
| )

scala> reviewsDF.groupBy("label").count().orderBy("label").show()
+-----+-----+
|label|count|
+-----+-----+
|  1.0|60000|
|  2.0|60000|
|  3.0|60000|
|  4.0|60000|
| 5.0|60000|
+-----+-----+

现在,我们使用行号创建我们的训练和测试数据集:

scala> val trainingData = reviewsDF.filter(reviewsDF("rowNumber") <= 50000).select("text","label")

scala> val testData = reviewsDF.filter(reviewsDF("rowNumber") > 10000).select("text","label")

在给定的步骤中,我们对文本进行标记化,去除停用词,并创建 bigrams 和 trigrams:

scala> val regexTokenizer = new RegexTokenizer().setPattern("[a-zA-Z']+").setGaps(false).setInputCol("text")

scala> val remover = new StopWordsRemover().setInputCol(regexTokenizer.getOutputCol)

scala> val bigrams = new NGram().setN(2).setInputCol(remover.getOutputCol)

scala> val trigrams = new NGram().setN(3).setInputCol(remover.getOutputCol)

在接下来的步骤中,我们为 unigrams、bigrams 和 trigrams 定义HashingTF实例:

scala> val removerHashingTF = new HashingTF().setInputCol(remover.getOutputCol)

scala> val ngram2HashingTF = new HashingTF().setInputCol(bigrams.getOutputCol)

scala> val ngram3HashingTF = new HashingTF().setInputCol(trigrams.getOutputCol)

scala> val assembler = new VectorAssembler().setInputCols(Array(removerHashingTF.getOutputCol, ngram2HashingTF.getOutputCol, ngram3HashingTF.getOutputCol))

scala> val labelIndexer = new StringIndexer().setInputCol("label").setOutputCol("indexedLabel").fit(reviewsDF)

scala> val labelConverter = new IndexToString().setInputCol("prediction").setOutputCol("predictedLabel").setLabels(labelIndexer.labels)

然后,我们创建一个朴素贝叶斯分类器的实例:

scala> val nb = new NaiveBayes().setLabelCol(labelIndexer.getOutputCol).setFeaturesCol(assembler.getOutputCol).setPredictionCol("prediction").setModelType("multinomial")

我们组装我们的处理管道,如图所示:

scala> val pipeline = new Pipeline().setStages(Array(regexTokenizer, remover, bigrams, trigrams, removerHashingTF, ngram2HashingTF, ngram3HashingTF, assembler, labelIndexer, nb, labelConverter))

我们创建一个参数网格,用于交叉验证以得到我们模型的最佳参数集,如下所示:

scala> val paramGrid = new ParamGridBuilder().addGrid(removerHashingTF.numFeatures, Array(1000,10000)).addGrid(ngram2HashingTF.numFeatures, Array(1000,10000)).addGrid(ngram3HashingTF.numFeatures, Array(1000,10000)).build()

paramGrid: Array[org.apache.spark.ml.param.ParamMap] =
Array({
hashingTF_4b2023cfcec8-numFeatures: 1000,
hashingTF_7bd4dd537583-numFeatures: 1000,
hashingTF_7cd2d166ac2c-numFeatures: 1000
}, {
hashingTF_4b2023cfcec8-numFeatures: 10000,
hashingTF_7bd4dd537583-numFeatures: 1000,
hashingTF_7cd2d166ac2c-numFeatures: 1000
}, {
hashingTF_4b2023cfcec8-numFeatures: 1000,
hashingTF_7bd4dd537583-numFeatures: 10000,
hashingTF_7cd2d166ac2c-numFeatures: 1000
}, {
hashingTF_4b2023cfcec8-numFeatures: 10000,
hashingTF_7bd4dd537583-numFeatures: 10000,
hashingTF_7cd2d166ac2c-numFeatures: 1000
}, {
hashingTF_4b2023cfcec8-numFeatures: 1000,
hashingTF_7bd4dd537583-numFeatures: 1000,
hashingTF_7cd2d166ac2c-numFeatures: 10000
}, {
hashingTF_4b2023cfcec8-numFeatures: 10000,
hashingTF_7bd4dd537...

在下一步中,请注意,k 折交叉验证通过将数据集分割为一组不重叠的、随机分区的折叠来执行模型选择;例如,对于k=3折叠,k 折交叉验证将生成三个(训练、测试)数据集对,每个数据集对使用数据的2/3进行训练和1/3进行测试。

每个折叠都恰好被用作测试集一次。为了评估特定的ParamMapCrossValidator计算在两个不同(训练,测试)数据集对上拟合估计器产生的三个模型的平均评估指标。在识别出最佳的ParamMap后,CrossValidator最终使用整个数据集使用最佳的ParamMap重新拟合估计器:

scala> val cv = new CrossValidator().setEstimator(pipeline).setEvaluator(new MulticlassClassificationEvaluator().setLabelCol("indexedLabel").setPredictionCol("prediction").setMetricName("accuracy")).setEstimatorParamMaps(paramGrid).setNumFolds(5)

scala> val cvModel = cv.fit(trainingData)

scala> val predictions = cvModel.transform(testData)

scala> val evaluator = new MulticlassClassificationEvaluator().setLabelCol("indexedLabel").setPredictionCol("prediction").setMetricName("accuracy")

scala> val accuracy = evaluator.evaluate(predictions)
accuracy: Double = 0.481472

scala> println("Test Error = " + (1.0 - accuracy))
Test Error = 0.518528

在执行前一组步骤后,获得的预测结果并不理想。让我们检查是否可以通过减少评论类别的数量并增加训练集中的记录数量来改善结果,如图所示:

scala> def udfReviewBins() = udf[Double, Double] { a => val x = a match { case 1.0 => 1.0; case 2.0 => 1.0; case 3.0 => 2.0; case 4.0 => 3.0; case 5.0 => 3.0;}; x;}

scala> val modifiedInDF = inDF.withColumn("rating", udfReviewBins()($"overall")).drop("overall")

scala> modifiedInDF.show()

scala> modifiedInDF.groupBy("rating").count().orderBy("rating").show()
+------+-------+
|rating| count|
+------+-------+
|   1.0| 190864|
|   2.0| 142257|
|   3.0|1356067|
+------+-------+

scala> modifiedInDF.createOrReplaceTempView("modReviewsTable")

scala> val reviewsDF = spark.sql(
| """
| SELECT text, label, rowNumber FROM (
| SELECT
| rating AS label, reviewText AS text, row_number() OVER (PARTITION BY rating ORDER BY rand()) AS rowNumber FROM modReviewsTable
| ) modReviewsTable
| WHERE rowNumber <= 120000
| """
| )
reviewsDF: org.apache.spark.sql.DataFrame = [text: string,

scala> reviewsDF.groupBy("label").count().orderBy("label").show()
+-----+------+
|label| count|
+-----+------+
|  1.0|120000|
|  2.0|120000|
|  3.0|120000|
+-----+------+

scala> val trainingData = reviewsDF.filter(reviewsDF("rowNumber") <= 100000).select("text","label")

scala> val testData = reviewsDF.filter(reviewsDF("rowNumber") > 20000).select("text","label")

scala> val regexTokenizer = new RegexTokenizer().setPattern("[a-zA-Z']+").setGaps(false).setInputCol("text")

scala> val remover = new StopWordsRemover().setInputCol(regexTokenizer.getOutputCol)

scala> val bigrams = new NGram().setN(2).setInputCol(remover.getOutputCol)

scala> val trigrams = new NGram().setN(3).setInputCol(remover.getOutputCol)

scala> val removerHashingTF = new HashingTF().setInputCol(remover.getOutputCol)

scala> val ngram2HashingTF = new HashingTF().setInputCol(bigrams.getOutputCol)

scala> val ngram3HashingTF = new HashingTF().setInputCol(trigrams.getOutputCol)

scala> val assembler = new VectorAssembler().setInputCols(Array(removerHashingTF.getOutputCol, ngram2HashingTF.getOutputCol, ngram3HashingTF.getOutputCol))

scala> val labelIndexer = new StringIndexer().setInputCol("label").setOutputCol("indexedLabel").fit(reviewsDF)

标签转换器可用于恢复原始标签的文本,以提高可读性,如果是文本标签的话:

scala> val labelConverter = new IndexToString().setInputCol("prediction").setOutputCol("predictedLabel").setLabels(labelIndexer.labels)

接下来,我们创建我们朴素贝叶斯分类器的一个实例:

scala> val nb = new NaiveBayes().setLabelCol(labelIndexer.getOutputCol).setFeaturesCol(assembler.getOutputCol).setPredictionCol("prediction").setModelType("multinomial")

我们使用所有转换器和朴素贝叶斯估计器组装我们的管道,如下所示:

scala> val pipeline = new Pipeline().setStages(Array(regexTokenizer, remover, bigrams, trigrams, removerHashingTF, ngram2HashingTF, ngram3HashingTF, assembler, labelIndexer, nb, labelConverter))

我们使用交叉验证来选择模型的最佳参数,如图所示:

scala> val paramGrid = new ParamGridBuilder().addGrid(removerHashingTF.numFeatures, Array(1000,10000)).addGrid(ngram2HashingTF.numFeatures, Array(1000,10000)).addGrid(ngram3HashingTF.numFeatures, Array(1000,10000)).build()

paramGrid: Array[org.apache.spark.ml.param.ParamMap] =
Array({
hashingTF_2f3a479f07ef-numFeatures: 1000,
hashingTF_0dc7c74af716-numFeatures: 1000,
hashingTF_17632a08c82c-numFeatures: 1000
}, {
hashingTF_2f3a479f07ef-numFeatures: 10000,
hashingTF_0dc7c74af716-numFeatures: 1000,
hashingTF_17632a08c82c-numFeatures: 1000
}, {
hashingTF_2f3a479f07ef-numFeatures: 1000,
hashingTF_0dc7c74af716-numFeatures: 10000,
hashingTF_17632a08c82c-numFeatures: 1000
}, {
hashingTF_2f3a479f07ef-numFeatures: 10000,
hashingTF_0dc7c74af716-numFeatures: 10000,
hashingTF_17632a08c82c-numFeatures: 1000
}, {
hashingTF_2f3a479f07ef-numFeatures: 1000,
hashingTF_0dc7c74af716-numFeatures: 1000,
hashingTF_17632a08c82c-numFeatures: 10000
}, {
hashingTF_2f3a479f07ef-numFeatures: 10000,
hashingTF_0dc7c74af...

scala> val cv = new CrossValidator().setEstimator(pipeline).setEvaluator(new MulticlassClassificationEvaluator().setLabelCol("indexedLabel").setPredictionCol("prediction").setMetricName("accuracy")).setEstimatorParamMaps(paramGrid).setNumFolds(5)

scala> val cvModel = cv.fit(trainingData)

scala> val predictions = cvModel.transform(testData)

scala> val evaluator = new MulticlassClassificationEvaluator().setLabelCol("indexedLabel").setPredictionCol("prediction").setMetricName("accuracy")

scala> val accuracy = evaluator.evaluate(predictions)
accuracy: Double = 0.63663

scala> println("Test Error = " + (1.0 - accuracy))
Test Error = 0.36336999999999997

注意将评级合并为较少类别并增加训练模型的记录数量后,我们的预测结果显著改善。

在下一节中,我们将介绍一个关于文本数据的机器学习示例。

开发一个机器学习应用程序

在本节中,我们将介绍一个文本分析的机器学习示例。有关本节中提供的机器学习代码的更多详细信息,请参阅第六章,在机器学习应用中使用 Spark SQL

以下示例中使用的数据集包含巴西公司的自由文本业务描述的 1,080 个文档,分类为九个子类别。您可以从archive.ics.uci.edu/ml/datasets/CNAE-9下载此数据集。

scala> val inRDD = spark.sparkContext.textFile("file:///Users/aurobindosarkar/Downloads/CNAE-9.data")

scala> val rowRDD = inRDD.map(_.split(",")).map(attributes => Row(attributes(0).toDouble, attributes(1).toDouble, attributes(2).toDouble, attributes(3).toDouble, attributes(4).toDouble, attributes(5).toDouble,
.
.
.
attributes(852).toDouble, attributes(853).toDouble, attributes(854).toDouble, attributes(855).toDouble, attributes(856).toDouble))

接下来,我们为输入记录定义一个模式:

scala> val schemaString = "label _c715 _c195 _c480 _c856 _c136 _c53 _c429 _c732 _c271 _c742 _c172 _c45 _c374 _c233 _c720
.
.
.
_c408 _c604 _c766 _c676 _c52 _c755 _c728 _c693 _c119 _c160 _c141 _c516 _c419 _c69 _c621 _c423 _c137 _c549 _c636 _c772 _c799 _c336 _c841 _c82 _c123 _c474 _c470 _c286 _c555 _c36 _c299 _c829 _c361 _c263 _c522 _c495 _c135"

scala> val fields = schemaString.split(" ").map(fieldName => StructField(fieldName, DoubleType, nullable = false))

scala> val schema = StructType(fields)

然后,我们使用模式将 RDD 转换为 DataFrame,如图所示:

scala> val inDF = spark.createDataFrame(rowRDD, schema)

scala> inDF.take(1).foreach(println)

接下来,我们使用monotonically_increasing_id()函数向 DataFrame 添加索引列,如图所示:

scala> val indexedDF= inDF.withColumn("id", monotonically_increasing_id())

scala> indexedDF.select("label", "id").show()

在接下来的步骤中,我们组装特征向量:

scala> val columnNames = Array("_c715","_c195","_c480","_c856","_c136","_c53","_c429","_c732","_c271","_c742","_c172","_c45","_c374","_c233","_c720","_c294","_c461","_c87","_c599","_c84","_c28","_c79","_c615","_c243","_c603","_c531","_c503","_c630","_c33","_c428","_c385","_c751","_c664","_c540","_c626","_c730","_c9","_c699","_c117","
.
.
.
c693","_c119","_c160","_c141","_c516","_c419","_c69","_c621","_c423","_c137","_c549","_c636","_c772","_c799","_c336","_c841","_c82","_c123","id","_c474","_c470","_c286","_c555","_c36","_c299","_c829","_c361","_c263","_c522","_c495","_c135")

scala> val assembler = new VectorAssembler().setInputCols(columnNames).setOutputCol("features")

scala> val output = assembler.transform(indexedDF)

scala> output.select("id", "label", "features").take(5).foreach(println)
[0,1.0,(857,[333,606,829],[1.0,1.0,1.0])]
[1,2.0,(857,[725,730,740,844],[1.0,1.0,1.0,1.0])]
[2,3.0,(857,[72,277,844],[1.0,1.0,2.0])]
[3,4.0,(857,[72,606,813,822,844],[1.0,1.0,1.0,1.0,3.0])]
[4,5.0,(857,[215,275,339,386,475,489,630,844],[1.0,1.0,1.0,1.0,1.0,1.0,1.0,4.0])]

我们将输入数据集分为训练数据集(记录的 90%)和测试数据集(记录的 10%):

scala> val Array(trainingData, testData) = output.randomSplit(Array(0.9, 0.1), seed = 12345)

scala> val copyTestData = testData.drop("id").drop("features")
copyTestData: org.apache.spark.sql.DataFrame = [label: double, _c715: double ... 855 more fields]

scala> copyTestData.coalesce(1).write.format("csv").option("header", "false").mode("overwrite").save("file:///Users/aurobindosarkar/Downloads/CNAE-9/input")

在接下来的步骤中,我们在训练数据集上创建并拟合逻辑回归模型:

scala> val lr = new LogisticRegression().setMaxIter(20).setRegParam(0.3).setElasticNetParam(0.8)

scala> // Fit the model

scala> val lrModel = lr.fit(trainingData)

我们可以列出模型的参数,如图所示:

scala> println("Model was fit using parameters: " + lrModel.parent.extractParamMap)

Model was fit using parameters: {
logreg_801d78ffc37d-aggregationDepth: 2,
logreg_801d78ffc37d-elasticNetParam: 0.8,
logreg_801d78ffc37d-family: auto,
logreg_801d78ffc37d-featuresCol: features,
logreg_801d78ffc37d-fitIntercept: true,
logreg_801d78ffc37d-labelCol: label,
logreg_801d78ffc37d-maxIter: 20,
logreg_801d78ffc37d-predictionCol: prediction,
logreg_801d78ffc37d-probabilityCol: probability,
logreg_801d78ffc37d-rawPredictionCol: rawPrediction,
logreg_801d78ffc37d-regParam: 0.3,
logreg_801d78ffc37d-standardization: true,
logreg_801d78ffc37d-threshold: 0.5,
logreg_801d78ffc37d-tol: 1.0E-6
}

接下来,我们显示逻辑回归模型的系数和截距值:

scala> println(s"Coefficients: \n${lrModel.coefficientMatrix}")
Coefficients:
10 x 857 CSCMatrix
(1,206) 0.33562831098750884
(5,386) 0.2803498729889301
(7,545) 0.525713129850472

scala> println(s"Intercepts: ${lrModel.interceptVector}")
Intercepts: [-5.399655915806082,0.6130722758222028,0.6011509547631415,0.6381333836655702,0.6011509547630515,0.5542027670693254,0.6325214445680327,0.5332703316733128,0.6325214445681948,0.5936323589132501]

接下来,我们使用模型对测试数据集进行预测,如图所示:

scala> val predictions = lrModel.transform(testData)

选择示例行以显示预测 DataFrame 中的关键列,如图所示:

scala> predictions.select("prediction", "label", "features").show(5)

我们使用评估器来计算测试错误,如下所示:

scala> val evaluator = new MulticlassClassificationEvaluator().setLabelCol("label").setPredictionCol("prediction").setMetricName("accuracy")
scala> val accuracy = evaluator.evaluate(predictions)
accuracy: Double = 0.2807017543859649
scala> println("Test Error = " + (1.0 - accuracy))
Test Error = 0.7192982456140351

将逻辑回归模型应用于我们的数据集的结果并不特别好。现在,我们定义参数网格,以探索是否更好的参数集可以改善模型的整体预测结果:

scala> val lr = new LogisticRegression()
lr: org.apache.spark.ml.classification.LogisticRegression = logreg_3fa32a4b5c6d

scala> val paramGrid = new ParamGridBuilder().addGrid(lr.regParam, Array(0.1, 0.01)).addGrid(lr.elasticNetParam, Array(0.0, 0.5, 1.0)).build()

paramGrid: Array[org.apache.spark.ml.param.ParamMap] =
Array({
logreg_3fa32a4b5c6d-elasticNetParam: 0.0,
logreg_3fa32a4b5c6d-regParam: 0.1
}, {
logreg_3fa32a4b5c6d-elasticNetParam: 0.0,
logreg_3fa32a4b5c6d-regParam: 0.01
}, {
logreg_3fa32a4b5c6d-elasticNetParam: 0.5,
logreg_3fa32a4b5c6d-regParam: 0.1
}, {
logreg_3fa32a4b5c6d-elasticNetParam: 0.5,
logreg_3fa32a4b5c6d-regParam: 0.01
}, {
logreg_3fa32a4b5c6d-elasticNetParam: 1.0,
logreg_3fa32a4b5c6d-regParam: 0.1
}, {
logreg_3fa32a4b5c6d-elasticNetParam: 1.0,
logreg_3fa32a4b5c6d-regParam: 0.01
})

在这里,我们展示了使用CrossValidator来选择模型的更好参数:

scala> val cv = new CrossValidator().setEstimator(lr).setEvaluator(new MulticlassClassificationEvaluator).setEstimatorParamMaps(paramGrid).setNumFolds(5)

接下来,我们运行交叉验证以选择最佳的参数集:

scala> val cvModel = cv.fit(trainingData)

scala> println("Model was fit using parameters: " + cvModel.parent.extractParamMap)

Model was fit using parameters: {
cv_00543dadc091-estimator: logreg_41377555b425,
cv_00543dadc091-estimatorParamMaps: Lorg.apache.spark.ml.param.ParamMap;@14ca1e23,
cv_00543dadc091-evaluator: mcEval_0435b4f19e2a,
cv_00543dadc091-numFolds: 5,
cv_00543dadc091-seed: -1191137437
}

我们对测试数据集进行预测。这里,cvModel使用找到的最佳模型(lrModel):

scala> cvModel.transform(testData).select("id", "label", "probability", "prediction").collect().foreach { case Row(id: Long, label: Double, prob: Vector, prediction: Double) =>
| println(s"($id, $label) --> prob=$prob, prediction=$prediction")
| }

![

scala> val cvPredictions = cvModel.transform(testData)
cvPredictions: org.apache.spark.sql.DataFrame = [label: double, _c715: double ... 860 more fields]

scala> cvPredictions.select("prediction", "label", "features").show(5)

scala> val evaluator = new MulticlassClassificationEvaluator().setLabelCol("label").setPredictionCol("prediction").setMetricName("accuracy")

注意交叉验证结果中预测准确率的显著提高:

scala> val accuracy = evaluator.evaluate(cvPredictions)
accuracy: Double = 0.9736842105263158

scala> println("Test Error = " + (1.0 - accuracy))
Test Error = 0.02631578947368418

最后,将模型保存到文件系统。我们将在本节的程序中从文件系统中检索它:

scala> cvModel.write.overwrite.save("file:///Users/aurobindosarkar/Downloads/CNAE-9/model")

接下来,我们构建一个应用程序,编译,打包,并使用来自我们 spark-shell 会话的代码和保存的模型和测试数据来执行它,如图所示:

LRExample.scala

package org.chap9.ml
import scala.collection.mutable
import scopt.OptionParser
import org.apache.spark.sql._
import org.apache.spark.sql.types._
import org.apache.spark.ml._
import org.apache.spark.examples.mllib.AbstractParams
import org.apache.spark.ml.feature.{VectorAssembler}
import org.apache.spark.ml.classification.{LogisticRegression, LogisticRegressionModel}
import org.apache.spark.ml.tuning.{CrossValidator, CrossValidatorModel}
import org.apache.spark.ml.evaluation.{MulticlassClassificationEvaluator}
import org.apache.spark.sql.{DataFrame, SparkSession}

object LRExample {
   case class Params(
   inputModelPath: String = null,
   testInput: String = ""
) extends AbstractParams[Params]

def main(args: Array[String]) {
   val defaultParams = Params()
   val parser = new OptionParserParams {
      head("LRExample: an example Logistic Regression.")
      argString
      .text(s"input path to saved model.")
      .required()
      .action((x, c) => c.copy(inputModelPath = x))
      argString
      .text("test input path to new data")
      .required()
      .action((x, c) => c.copy(testInput = x))
   checkConfig { params =>
      if ((params.testInput == null) || (params.inputModelPath == null)) {
         failure(s"Both Test Input File && input model path values need to be provided.")
      } else {
         success
      }
   }
}
parser.parse(args, defaultParams) match {
   case Some(params) => run(params)
   case _ => sys.exit(1)
  }
}
def run(params: Params): Unit = {
   val spark = SparkSession
      .builder
      .appName(s"LogisticRegressionExample with $params")
      .getOrCreate()
   println(s"LogisticRegressionExample with parameters:\n$params")
   val inRDD = spark.sparkContext.textFile("file://" + params.testInput)

   val rowRDD = inRDD.map(_.split(",")).map(attributes => Row(attributes(0).toDouble, attributes(1).toDouble, attributes(2).toDouble, attributes(3).toDouble, attributes(4).toDouble, attributes(5).toDouble, attributes(6).toDouble,
.
.
.
attributes(850).toDouble, attributes(851).toDouble, attributes(852).toDouble, attributes(853).toDouble, attributes(854).toDouble, attributes(855).toDouble, attributes(856).toDouble))
val schemaString = "label _c715 _c195 _c480 _c856 _c136 _c53 _c429 _c732 _c271 _c742 _c172 _c45 _c374 _c233 _c720 _c294 _c461 _c87 _c599 _c84 _c28 _c79 _c615 _c243
.
.
.
_c336 _c841 _c82 _c123 _c474 _c470 _c286 _c555 _c36 _c299 _c829 _c361 _c263 _c522 _c495 _c135"
val fields = schemaString.split(" ").map(fieldName => StructField(fieldName, DoubleType, nullable = false))
val schema = StructType(fields)
val inDF = spark.createDataFrame(rowRDD, schema)
val indexedDF= inDF.withColumn("id",org.apache.spark.sql.functions.monotonically_increasing_id())
val columnNames = Array("_c715","_c195","_c480","_c856","_c136","_c53","_c429","_c732","_c271","_c742","_c172","_c45","_c374","_c233","_c720","_c294","_c461","_c87","_c599","_c84","_c28","_c
.
.
.
141","_c516","_c419","_c69","_c621","_c423","_c137","_c549","_c636","_c772","_c799","_c336","_c841","_c82","_c123","id","_c474","_c470","_c286","_c555","_c36","_c299","_c829","_c361","_c263","_c522","_c495","_c135")
   val assembler = new VectorAssembler().setInputCols(columnNames).setOutputCol("features")
   val output = assembler.transform(indexedDF)
   val cvModel = CrossValidatorModel.load("file://"+ params.inputModelPath)
   val cvPredictions = cvModel.transform(output)
   val evaluator = new MulticlassClassificationEvaluator().setLabelCol("label").setPredictionCol("prediction").setMetricName("accuracy")
   val accuracy = evaluator.evaluate(cvPredictions)
   println("Test Error = " + (1.0 - accuracy))
   spark.stop()
  }
}

在根SBT目录(包含build.sbt文件的目录)内创建一个lib文件夹,并将 spark 分发的examples目录中的scopt_2.11-3.3.0.jarspark-examples_2.11-2.2.1-SNAPSHOT.jar复制到其中。

接下来,使用与前一节中相同的build.sbt文件编译和打包源代码,如下所示:

Aurobindos-MacBook-Pro-2:Chapter9 aurobindosarkar$ sbt package

最后,使用 spark-submit 执行您的 Spark Scala 程序,如下所示:

Aurobindos-MacBook-Pro-2:scala-2.11 aurobindosarkar$ /Users/aurobindosarkar/Downloads/spark-2.2.1-SNAPSHOT-bin-hadoop2.7/bin/spark-submit --jars /Users/aurobindosarkar/Downloads/Chapter9/lib/spark-examples_2.11-2.2.1-SNAPSHOT.jar,/Users/aurobindosarkar/Downloads/Chapter9/lib/scopt_2.11-3.3.0.jar --class org.chap9.ml.LRExample --master local[*] chapter9_2.11-2.0.jar /Users/aurobindosarkar/Downloads/CNAE-9/model /Users/aurobindosarkar/Downloads/CNAE-9/input/part-00000-61f03111-53bb-4404-bef7-0dd4ac1be950-c000.csv

LogisticRegressionExample with parameters:
{
inputModelPath: /Users/aurobindosarkar/Downloads/CNAE-9/model,
testInput: /Users/aurobindosarkar/Downloads/CNAE-9/input/part-00000-61f03111-53bb-4404-bef7-0dd4ac1be950-c000.csv
}
Test Error = 0.02631578947368418

总结

在本章中,我们介绍了一些在文本分析领域中使用 Spark SQL 的应用程序。此外,我们提供了详细的代码示例,包括构建数据预处理流水线,实现情感分析,使用朴素贝叶斯分类器与 n-gram,并实现一个 LDA 应用程序来识别文档语料库中的主题。另外,我们还详细介绍了实现一个机器学习示例的细节。

在下一章中,我们将专注于在深度学习应用中使用 Spark SQL 的用例。我们将探索一些新兴的深度学习库,并提供实现深度学习相关应用程序的示例。

第十章:在深度学习应用中使用 Spark SQL

在过去的十年中,深度学习已经成为解决机器学习中几个困难问题的优越解决方案。我们听说深度学习被部署到许多不同领域,包括计算机视觉、语音识别、自然语言处理、音频识别、社交媒体应用、机器翻译和生物学。通常,使用深度学习方法产生的结果与或优于人类专家产生的结果。

已经有几种不同类型的深度学习模型被应用到不同的问题上。我们将回顾这些模型的基本概念并呈现一些代码。这是 Spark 中一个新兴的领域,所以尽管有几种不同的库可用,但很多都还处于早期版本或者每天都在不断发展。我们将简要概述其中一些库,包括使用 Spark 2.1.0、Scala 和 BigDL 的一些代码示例。我们选择 BigDL 是因为它是少数几个直接在 Spark Core 上运行的库之一(类似于其他 Spark 包),并且使用 Scala API 与 Spark SQL DataFrame API 和 ML pipelines 一起工作。

更具体地,在本章中,您将学习以下内容:

  • 什么是深度学习?

  • 了解各种深度学习模型的关键概念

  • 了解 Spark 中的深度学习

  • 使用 BigDL 和 Spark

神经网络介绍

神经网络,或者人工神经网络(ANN),是一组松散模拟人脑的算法或实际硬件。它们本质上是一组相互连接的处理节点,旨在识别模式。它们适应于或从一组训练模式中学习,如图像、声音、文本、时间序列等。

神经网络通常组织成由相互连接的节点组成的层。这些节点通过连接彼此进行通信。模式通过输入层呈现给网络,然后传递给一个或多个隐藏层。实际的计算是在这些隐藏层中执行的。最后一个隐藏层连接到一个输出层,输出最终答案。

特定节点的总输入通常是连接节点的每个输出的函数。这些输入对节点的贡献可以是兴奋的或抑制的,并最终有助于确定信号是否以及在多大程度上通过网络进一步传播(通过激活函数)。通常,Sigmoid 激活函数非常受欢迎。在一些应用中,也使用了线性、半线性或双曲正切(Tanh)函数。在节点的输出是总输入的随机函数的情况下,输入决定了给定节点获得高激活值的概率。

网络内部连接的权重根据学习规则进行修改;例如,当神经网络最初呈现一种模式时,它会猜测权重可能是什么。然后,它评估其答案与实际答案的差距,并对其连接权重进行适当调整。

有关神经网络基础知识的良好介绍,请参考:Bolo 的《神经网络基础介绍》,网址:pages.cs.wisc.edu/~bolo/shipyard/neural/local.html

在接下来的章节中,我们将介绍各种类型的神经网络的更具体细节。

了解深度学习

深度学习是将人工神经网络应用于学习任务。深度学习方法基于学习数据表示,而不是特定于任务的算法。尽管学习可以是监督的或无监督的,但最近的重点是创建能够从大规模未标记数据集中学习这些表示的高效系统。

下图描述了一个具有两个隐藏层的简单深度学习神经网络:

深度学习通常包括多层处理单元,每一层都在其中学习特征表示。这些层形成特征的层次结构,深度学习假设这种层次结构对应于抽象级别。因此,它利用了分层解释因素的思想,更高级别的更抽象的概念是从更低级别的概念中学习的。通过改变层数和层大小,可以提供不同数量的抽象,根据使用情况的需要。

理解表示学习

深度学习方法是具有多个抽象层次的表示学习方法。在这里,非线性模块将原始输入转换为更高、稍微更抽象级别的表示。最终,通过组合足够数量的这样的层,可以学习非常复杂的函数。

有关深度学习的综述论文,请参阅 Yann LeCun、Yoshua Bengio 和 Geoffrey Hinton 的《深度学习》,可在www.nature.com/nature/journal/v521/n7553/full/nature14539.html?foxtrotcallback=true上找到。

现在,我们将说明在传统模式识别任务中学习表示和特征的过程:

传统的机器学习技术在处理自然数据的原始形式时受到限制。构建这样的机器学习系统需要深入的领域专业知识和大量的努力,以识别(并保持更新)学习子系统,通常是分类器,可以从中检测或分类输入中的模式的特征。

许多传统的机器学习应用程序使用手工制作的特征上的线性分类器。这样的分类器通常需要一个良好的特征提取器,产生对图像方面有选择性的表示。然而,如果可以使用通用学习程序自动学习良好的特征,那么所有这些努力都是不必要的。深度学习的这一特定方面代表了深度学习的一个关键优势。

与早期的机器学习技术相比,深度学习中的高级过程通常是,其中端到端的学习过程还涉及从数据中学习的特征。这在这里有所说明:

在下一节中,我们将简要讨论一种最常用的函数,即随机梯度下降,用于调整网络中的权重。

理解随机梯度下降

深度学习系统可以包括数百万个可调整的权重,并且使用数百万个标记的示例来训练机器。在实践中,随机梯度下降SGD)优化被广泛应用于许多不同的情况。在 SGD 中,梯度描述了网络的错误与单个权重之间的关系,即当调整权重时错误如何变化。

这种优化方法包括:

  • 为一些示例呈现输入向量

  • 计算输出和错误

  • 计算示例的平均梯度

  • 适当调整权重

这个过程对许多小的训练示例集重复进行。当目标函数的平均值停止减少时,过程停止。

与更复杂的优化技术相比,这个简单的过程通常能够非常有效地产生一组良好的权重。此外,训练过程所需的时间也要短得多。训练过程完成后,通过在测试数据集上运行经过训练的模型来衡量系统的性能。测试集包含机器之前在训练阶段未见过的新输入。

在深度学习神经网络中,激活函数通常设置在层级,并应用于特定层中的所有神经元或节点。此外,多层深度学习神经网络的输出层起着特定的作用;例如,在监督学习(带有标记的输入)中,它基于从前一层接收到的信号应用最可能的标签。输出层上的每个节点代表一个标签,并且该节点产生两种可能的结果之一,即01。虽然这样的神经网络产生二进制输出,但它们接收的输入通常是连续的;例如,推荐引擎的输入可以包括客户上个月的消费金额和过去一个月每周平均客户访问次数等因素。输出层必须将这些信号处理成给定输入的概率度量。

在 Spark 中介绍深度学习

在本节中,我们将回顾一些使用 Spark 的更受欢迎的深度学习库。这些包括 CaffeOnSpark、DL4J、TensorFrames 和 BigDL。

介绍 CaffeOnSpark

CaffeOnSpark 是 Yahoo 为 Hadoop 集群上的大规模分布式深度学习开发的。通过将深度学习框架 Caffe 的特性与 Apache Spark(和 Apache Hadoop)结合,CaffeOnSpark 实现了在 GPU 和 CPU 服务器集群上的分布式深度学习。

有关 CaffeOnSpark 的更多详细信息,请参阅github.com/yahoo/CaffeOnSpark

CaffeOnSpark 支持神经网络模型的训练、测试和特征提取。它是非深度学习库 Spark MLlib 和 Spark SQL 的补充。CaffeOnSpark 的 Scala API 为 Spark 应用程序提供了一种简单的机制,以在分布式数据集上调用深度学习算法。在这里,深度学习通常是在现有数据处理流水线的同一集群中进行,以支持特征工程和传统的机器学习应用。因此,CaffeOnSpark 允许将深度学习训练和测试过程嵌入到 Spark 应用程序中。

介绍 DL4J

DL4J 支持在 Spark 集群上训练神经网络,以加速网络训练。当前版本的 DL4J 在每个集群节点上使用参数平均化的过程来训练网络。当主节点拥有训练好的网络的副本时,训练就完成了。

有关 DL4J 的更多详细信息,请参阅deeplearning4j.org/spark

介绍 TensorFrames

实验性的 Scala 和 Apache Spark 的 TensorFlow 绑定目前在 GitHub 上可用。TensorFrames 本质上是 Spark Dataframes 上的 TensorFlow,它允许您使用 TensorFlow 程序操作 Apache Spark 的 DataFrames。目前,Scala 支持比 Python 更有限--Scala DSL 具有 TensorFlow 变换的子集。

有关 TensorFrames 的更多详细信息,请访问github.com/databricks/tensorframes

在 Scala 中,操作可以从以ProtocolBuffers格式定义的现有图形中加载,也可以使用简单的 Scala DSL。然而,鉴于 TensorFlow 的整体流行,这个库正在受到关注,并且在 Python 社区中更受欢迎。

使用 BigDL

BigDL 是 Apache Spark 的开源分布式深度学习库。最初由英特尔开发并开源。使用 BigDL,开发人员可以将深度学习应用程序编写为标准的 Spark 程序。这些程序直接在现有的 Spark 或 Hadoop 集群上运行,如图所示:

BigDL 是基于 Torch 建模的,它支持深度学习,包括数值计算(通过张量)和神经网络。此外,开发人员可以将预训练的CaffeTorch模型加载到 BigDL-Spark 程序中,如下图所示:

为了实现高性能,BigDL 在每个 Spark 任务中使用Intel MKL和多线程编程。

有关 BigDL 文档、示例和 API 指南,请访问bigdl-project.github.io/master/

下图显示了 BigDL 程序在 Spark 集群上的高级执行方式。借助集群管理器和驱动程序,Spark 任务分布在 Spark 工作节点或容器(执行器)上:

我们将在本章的后面几节中执行 BigDL 分发中提供的几个深度神经网络的示例。目前,这是少数几个与 Spark SQL DataFrame API 和 ML 管道一起使用的库之一。

在下一节中,我们将重点介绍如何利用 Spark 并行调整超参数。

调整深度学习模型的超参数

构建神经网络时,有许多重要的超参数需要仔细选择。考虑以下示例:

  • 每层神经元的数量:很少的神经元会降低网络的表达能力,但太多的神经元会大大增加运行时间并返回嘈杂的估计值

  • 学习率:如果学习率太高,神经网络将只关注最近看到的几个样本,并忽略之前积累的所有经验;如果学习率太低,将需要很长时间才能达到良好的状态

超参数调整过程是“尴尬并行”的,可以使用 Spark 进行分布。

有关更多详细信息,请参阅 Tim Hunter 的Deep Learning with Apache Spark and TensorFlow,网址为databricks.com/blog/2016/01/25/deep-learning-with-apache-spark-and-tensorflow.html

介绍深度学习管道

Spark 中有一个新兴的库,用于支持深度学习管道,它提供了用于 Python 中可扩展深度学习的高级 API。目前支持 TensorFlow 和基于 TensorFlow 的 Keras 工作流程,重点是在规模化图像数据上进行模型推断/评分和迁移学习。

要关注 Spark 中深度学习管道的发展,请访问github.com/databricks/spark-deep-learning

此外,它为数据科学家和机器学习专家提供了工具,可以将深度学习模型转换为 SQL UDF,这样更广泛的用户群体就可以使用。这也是生产深度学习模型的一种好方法。

在下一节中,我们将把重点转移到监督学习上。

理解监督学习

最常见的机器学习形式是监督学习;例如,如果我们正在构建一个用于分类特定图像集的系统,我们首先收集来自相同类别的大量图像数据集。在训练期间,机器显示一幅图像,并产生一个以每个类别为一个分数的向量形式的输出。作为训练的结果,我们期望所需的类别在所有类别中具有最高的分数。

深度网络的一种特殊类型——卷积神经网络(ConvNet/CNN)——比全连接网络更容易训练,泛化能力也更好。在监督学习场景中,深度卷积网络显著改善了图像、视频、语音和音频数据的处理结果。同样,循环网络也为顺序数据(如文本和语音)带来了曙光。我们将在接下来的部分探讨这些类型的神经网络。

理解卷积神经网络

卷积神经网络是一种特殊类型的多层神经网络,它们被设计来直接从像素图像中识别视觉模式,需要最少的预处理。它们可以识别具有广泛变化的模式,并且可以有效地处理扭曲和简单的几何变换。CNN 也是使用反向传播算法的一种版本进行训练。

典型 ConvNet 的架构被构造为一系列包含多个堆叠卷积、非线性和池化层的阶段,然后是额外的卷积和全连接层。非线性函数通常是修正线性单元(ReLU)函数,池化层的作用是将相似特征语义地合并为一个。因此,池化允许表示在前一层的元素在位置和外观上变化很少时也能变化很小。

LeNet-5 是一个专为手写和机器打印字符识别设计的卷积网络。在这里,我们介绍了 BigDL 分发中可用的 Lenet-5 的一个例子。

该示例的完整源代码可在github.com/intel-analytics/BigDL/tree/master/spark/dl/src/main/scala/com/intel/analytics/bigdl/models/lenet找到。

在这里,我们将使用 Spark shell 执行相同的代码。请注意,常量的值都取自上述网站提供的源代码。

首先,执行bigdl shell 脚本来设置环境:

source /Users/aurobindosarkar/Downloads/BigDL-master/scripts/bigdl.sh

然后,我们使用适当指定 BigDL JAR 启动 Spark shell:

bin/spark-shell --properties-file /Users/aurobindosarkar/Downloads/BigDL-master/spark/dist/target/bigdl-0.2.0-SNAPSHOT-spark-2.0.0-scala-2.11.8-mac-dist/conf/spark-bigdl.conf --jars /Users/aurobindosarkar/Downloads/BigDL-master/spark/dist/target/bigdl-0.2.0-SNAPSHOT-spark-2.0.0-scala-2.11.8-mac-dist/lib/bigdl-0.2.0-SNAPSHOT-jar-with-dependencies.jar

这个例子的数据集可以从yann.lecun.com/exdb/mnist/下载。

本例的 Spark shell 会话如下所示:

scala> import com.intel.analytics.bigdl._
scala> import com.intel.analytics.bigdl.dataset.DataSet
scala> import com.intel.analytics.bigdl.dataset.image.{BytesToGreyImg, GreyImgNormalizer, GreyImgToBatch, GreyImgToSample}
scala> import com.intel.analytics.bigdl.nn.{ClassNLLCriterion, Module}
scala> import com.intel.analytics.bigdl.numeric.NumericFloat
scala> import com.intel.analytics.bigdl.optim._
scala> import com.intel.analytics.bigdl.utils.{Engine, T,
scala> import com.intel.analytics.bigdl.nn._
scala> import java.nio.ByteBuffer
scala> import java.nio.file.{Files, Path, Paths}
scala> import com.intel.analytics.bigdl.dataset.ByteRecord
scala> import com.intel.analytics.bigdl.utils.File

scala> val trainData = "/Users/aurobindosarkar/Downloads/mnist/train-images-idx3-ubyte"
scala> val trainLabel = "/Users/aurobindosarkar/Downloads/mnist/train-labels-idx1-ubyte"
scala> val validationData = "/Users/aurobindosarkar/Downloads/mnist/t10k-images-idx3-ubyte"
scala> val validationLabel = "/Users/aurobindosarkar/Downloads/mnist/t10k-labels-idx1-ubyte"

scala> val nodeNumber = 1 //Number of nodes
scala> val coreNumber = 2 //Number of cores

scala> Engine.init

scala> val model = Sequential[Float]()
model: com.intel.analytics.bigdl.nn.Sequential[Float] =
nn.Sequential {
[input -> -> output]
}

scala> val classNum = 10 //Number of classes (digits)
scala> val batchSize = 12
//The model uses the Tanh function for non-linearity.
//It has two sets layers comprising of Convolution-Non-Linearity-Pooling
//It uses a Softmax function to output the results

scala> model.add(Reshape(Array(1, 28, 28))).add(SpatialConvolution(1, 6, 5, 5)).add(Tanh()).add(SpatialMaxPooling(2, 2, 2, 2)).add(Tanh()).add(SpatialConvolution(6, 12, 5, 5)).add(SpatialMaxPooling(2, 2, 2, 2)).add(Reshape(Array(12 * 4 * 4))).add(Linear(12 * 4 * 4, 100)).add(Tanh()).add(Linear(100, classNum)).add(LogSoftMax())

res1: model.type =
nn.Sequential {
[input -> (1) -> (2) -> (3) -> (4) -> (5) -> (6) -> (7) -> (8) -> (9) -> (10) -> (11) -> (12) -> output]
(1): nn.Reshape(1x28x28)
(2): nn.SpatialConvolution(1 -> 6, 5 x 5, 1, 1, 0, 0)
(3): nn.Tanh
(4): nn.SpatialMaxPooling(2, 2, 2, 2, 0, 0)
(5): nn.Tanh
(6): nn.SpatialConvolution(6 -> 12, 5 x 5, 1, 1, 0, 0)
(7): nn.SpatialMaxPooling(2, 2, 2, 2, 0, 0)
(8): nn.Reshape(192)
(9): nn.Linear(192 -> 100)
(10): nn.Tanh
(11): nn.Linear(100 -> 10)
(12): nn.LogSoftMax
}

//The following is a private function in Utils.
scala> def load(featureFile: String, labelFile: String): Array[ByteRecord] = {
|    val featureBuffer = ByteBuffer.wrap(Files.readAllBytes(Paths.get(featureFile)))
|    val labelBuffer = ByteBuffer.wrap(Files.readAllBytes(Paths.get(labelFile)));
|    val labelMagicNumber = labelBuffer.getInt();
|    require(labelMagicNumber == 2049);
|    val featureMagicNumber = featureBuffer.getInt();
|    require(featureMagicNumber == 2051);
|    val labelCount = labelBuffer.getInt();
|    val featureCount = featureBuffer.getInt();
|    require(labelCount == featureCount);
|    val rowNum = featureBuffer.getInt();
|    val colNum = featureBuffer.getInt();
|    val result = new ArrayByteRecord;
|    var i = 0;
|    while (i < featureCount) {
|       val img = new ArrayByte);
|       var y = 0;
|       while (y < rowNum) {
|          var x = 0;
|          while (x < colNum) {
|             img(x + y * colNum) = featureBuffer.get();
|             x += 1;
|          }
|          y += 1;
|       }
|       result(i) = ByteRecord(img, labelBuffer.get().toFloat + 1.0f);
|       i += 1;
|    }
|    result;
| }

scala> val trainMean = 0.13066047740239506
scala> val trainStd = 0.3081078

scala> val trainSet = DataSet.array(load(trainData, trainLabel), sc) -> BytesToGreyImg(28, 28) -> GreyImgNormalizer(trainMean, trainStd) -> GreyImgToBatch(batchSize)

scala> val optimizer = Optimizer(model = model, dataset = trainSet, criterion = ClassNLLCriterion[Float]())

scala> val testMean = 0.13251460696903547
scala> val testStd = 0.31048024
scala> val maxEpoch = 2

scala> val validationSet = DataSet.array(load(validationData, validationLabel), sc) -> BytesToGreyImg(28, 28) -> GreyImgNormalizer(testMean, testStd) -> GreyImgToBatch(batchSize)

scala> optimizer.setEndWhen(Trigger.maxEpoch(2))
scala> optimizer.setState(T("learningRate" -> 0.05, "learningRateDecay" -> 0.0))
scala> optimizer.setCheckpoint("/Users/aurobindosarkar/Downloads/mnist/checkpoint", Trigger.severalIteration(500))
scala> optimizer.setValidation(trigger = Trigger.everyEpoch, dataset = validationSet, vMethods = Array(new Top1Accuracy, new Top5Accuracy[Float], new Loss[Float]))

scala> optimizer.optimize()

scala> model.save("/Users/aurobindosarkar/Downloads/mnist/model") //Save the trained model to disk.
scala> val model = Module.loadFloat //Retrieve the model from the disk
scala> val partitionNum = 2
scala> val rddData = sc.parallelize(load(validationData, validationLabel), partitionNum)

scala> val transformer = BytesToGreyImg(28, 28) -> GreyImgNormalizer(testMean, testStd) -> GreyImgToSample()

scala> val evaluationSet = transformer(rddData)

scala> val result = model.evaluate(evaluationSet, Array(new Top1Accuracy[Float]), Some(batchSize))

scala> result.foreach(r => println(s"${r._2} is ${r._1}"))
Top1Accuracy is Accuracy(correct: 9831, count: 10000, accuracy: 0.9831)

在下一节中,我们将介绍一个文本分类的例子。

使用神经网络进行文本分类

其他越来越重要的应用包括自然语言理解和语音识别。

本节中的示例作为 BigDL 分发的一部分可用,完整的源代码可在github.com/intel-analytics/BigDL/tree/master/spark/dl/src/main/scala/com/intel/analytics/bigdl/example/textclassification找到。

它使用预训练的 GloVe 嵌入将单词转换为向量,然后用它在包含二十个不同类别的二十个新闻组数据集上训练文本分类模型。这个模型在只训练两个时期后就可以达到 90%以上的准确率。

这里呈现了定义 CNN 模型和优化器的关键部分代码:

val model = Sequential[Float]()

//The model has 3 sets of Convolution and Pooling layers.
model.add(Reshape(Array(param.embeddingDim, 1, param.maxSequenceLength)))
model.add(SpatialConvolution(param.embeddingDim, 128, 5, 1))
model.add(ReLU())
model.add(SpatialMaxPooling(5, 1, 5, 1))
model.add(SpatialConvolution(128, 128, 5, 1))
model.add(ReLU())
model.add(SpatialMaxPooling(5, 1, 5, 1))
model.add(SpatialConvolution(128, 128, 5, 1))
model.add(ReLU())
model.add(SpatialMaxPooling(35, 1, 35, 1))
model.add(Reshape(Array(128)))
model.add(Linear(128, 100))
model.add(Linear(100, classNum))
model.add(LogSoftMax())

//The optimizer uses the Adagrad method
val optimizer = Optimizer(
model = buildModel(classNum),
sampleRDD = trainingRDD,
criterion = new ClassNLLCriterion[Float](),
batchSize = param.batchSize
)

optimizer
.setOptimMethod(new Adagrad(learningRate = 0.01, learningRateDecay = 0.0002))
.setValidation(Trigger.everyEpoch, valRDD, Array(new Top1Accuracy[Float]), param.batchSize)
.setEndWhen(Trigger.maxEpoch(20))
.optimize()

输入数据集的描述如下,以及它们的下载 URL:

在我们的示例中,我们将类别数量减少到八个,以避免在内存小于 16GB 的笔记本电脑上出现内存不足异常。将这些数据集放在BASE_DIR中;最终的目录结构应如图所示:

使用以下命令执行文本分类器:

Aurobindos-MacBook-Pro-2:BigDL aurobindosarkar$ /Users/aurobindosarkar/Downloads/BigDL-master/scripts/bigdl.sh -- /Users/aurobindosarkar/Downloads/spark-2.1.0-bin-hadoop2.7/bin/spark-submit --master "local[2]" --driver-memory 14g --class com.intel.analytics.bigdl.example.textclassification.TextClassifier /Users/aurobindosarkar/Downloads/BigDL-master/spark/dist/target/bigdl-0.2.0-SNAPSHOT-spark-2.0.0-scala-2.11.8-mac-dist/lib/bigdl-0.2.0-SNAPSHOT-jar-with-dependencies.jar --batchSize 128 -b /Users/aurobindosarkar/Downloads/textclassification -p 4

这里给出了示例输出以供参考:

17/08/16 14:50:07 INFO textclassification.TextClassifier$: Current parameters: TextClassificationParams(/Users/aurobindosarkar/Downloads/textclassification,1000,20000,0.8,128,100,4)
17/08/16 14:50:07 INFO utils.ThreadPool$: Set mkl threads to 1 on thread 1
17/08/16 14:50:09 INFO utils.Engine$: Auto detect executor number and executor cores number
17/08/16 14:50:09 INFO utils.Engine$: Executor number is 1 and executor cores number is 2
17/08/16 14:50:09 INFO utils.Engine$: Find existing spark context. Checking the spark conf...
17/08/16 14:50:10 INFO utils.TextClassifier: Found 8000 texts.
17/08/16 14:50:10 INFO utils.TextClassifier: Found 8 classes
17/08/16 14:50:13 INFO utils.TextClassifier: Indexing word vectors.
17/08/16 14:50:16 INFO utils.TextClassifier: Found 17424 word vectors.
17/08/16 14:50:16 INFO optim.DistriOptimizer$: caching training rdd ...
17/08/16 14:50:37 INFO optim.DistriOptimizer$: Cache thread models...
17/08/16 14:50:37 INFO optim.DistriOptimizer$: model thread pool size is 1
17/08/16 14:50:37 INFO optim.DistriOptimizer$: Cache thread models... done
17/08/16 14:50:37 INFO optim.DistriOptimizer$: config {
learningRate: 0.01
maxDropPercentage: 0.0
computeThresholdbatchSize: 100
warmupIterationNum: 200
learningRateDecay: 2.0E-4
dropPercentage: 0.0
}
17/08/16 14:50:37 INFO optim.DistriOptimizer$: Shuffle data
17/08/16 14:50:37 INFO optim.DistriOptimizer$: Shuffle data complete. Takes 0.012679728s
17/08/16 14:50:38 INFO optim.DistriOptimizer$: [Epoch 1 0/6458][Iteration 1][Wall Clock 0.0s] Train 128 in 0.962042186seconds. Throughput is 133.0503 records/second. Loss is 2.0774076.
17/08/16 14:50:40 INFO optim.DistriOptimizer$: [Epoch 1 128/6458][Iteration 2][Wall Clock 0.962042186s] Train 128 in 1.320501728seconds. Throughput is 96.93285 records/second. Loss is 4.793501.
17/08/16 14:50:40 INFO optim.DistriOptimizer$: [Epoch 1 256/6458][Iteration 3][Wall Clock 2.282543914s] Train 128 in 0.610049842seconds. Throughput is 209.81892 records/second. Loss is 2.1110187.
17/08/16 14:50:41 INFO optim.DistriOptimizer$: [Epoch 1 384/6458][Iteration 4][Wall Clock 2.892593756s] Train 128 in 0.609548069seconds. Throughput is 209.99164 records/second. Loss is 2.0820618.
17/08/16 14:50:42 INFO optim.DistriOptimizer$: [Epoch 1 512/6458][Iteration 5][Wall Clock 3.502141825s] Train 128 in 0.607720212seconds. Throughput is 210.62325 records/second. Loss is 2.0860045.
17/08/16 14:50:42 INFO optim.DistriOptimizer$: [Epoch 1 640/6458][Iteration 6][Wall Clock 4.109862037s] Train 128 in 0.607034064seconds. Throughput is 210.86131 records/second. Loss is 2.086178.
.
.
.
17/08/16 15:04:57 INFO optim.DistriOptimizer$: [Epoch 20 6144/6458][Iteration 1018][Wall Clock 855.715191033s] Train 128 in 0.771615991seconds. Throughput is 165.88562 records/second. Loss is 2.4244189E-4.
17/08/16 15:04:58 INFO optim.DistriOptimizer$: [Epoch 20 6272/6458][Iteration 1019][Wall Clock 856.486807024s] Train 128 in 0.770584628seconds. Throughput is 166.10765 records/second. Loss is 0.04117684.
17/08/16 15:04:59 INFO optim.DistriOptimizer$: [Epoch 20 6400/6458][Iteration 1020][Wall Clock 857.257391652s] Train 128 in 0.783425485seconds. Throughput is 163.38503 records/second. Loss is 3.2506883E-4.
17/08/16 15:04:59 INFO optim.DistriOptimizer$: [Epoch 20 6400/6458][Iteration 1020][Wall Clock 857.257391652s] Epoch finished. Wall clock time is 861322.002763ms
17/08/16 15:04:59 INFO optim.DistriOptimizer$: [Wall Clock 861.322002763s] Validate model...
17/08/16 15:05:02 INFO optim.DistriOptimizer$: Top1Accuracy is Accuracy(correct: 1537, count: 1542, accuracy: 0.996757457846952)

在下一节中,我们将探讨使用深度神经网络进行语言处理。

使用深度神经网络进行语言处理

如第九章中所讨论的,使用 Spark SQL 开发应用程序,语言的统计建模通常基于 n-grams 的出现频率。在大多数实际用例中,这通常需要非常大的训练语料库。此外,n-grams 将每个单词视为独立单元,因此它们无法概括语义相关的单词序列。相比之下,神经语言模型将每个单词与一组实值特征向量相关联,因此语义相关的单词在该向量空间中靠近。学习单词向量在单词序列来自大型真实文本语料库时也非常有效。这些单词向量由神经网络自动发现的学习特征组成。

从文本中学习的单词的向量表示现在在自然语言应用中被广泛使用。在下一节中,我们将探讨递归神经网络及其在文本分类任务中的应用。

理解递归神经网络

通常,对于涉及顺序输入的任务,建议使用递归神经网络RNNs)。这样的输入一次处理一个元素,同时保持一个“状态向量”(在隐藏单元中)。状态隐含地包含有关序列中所有过去元素的信息。

通常,在传统的 RNN 中,很难长时间存储信息。为了长时间记住输入,网络可以增加显式内存。这也是长短期记忆LSTM)网络中使用的方法;它们使用可以记住输入的隐藏单元。LSTM 网络已被证明比传统的 RNN 更有效。

在本节中,我们将探讨用于建模序列数据的递归神经网络。下图说明了一个简单的递归神经网络或 Elman 网络:

这可能是最简单的递归神经网络的版本,易于实现和训练。网络有一个输入层,一个隐藏层(也称为上下文层或状态),和一个输出层。网络在时间t的输入是Input(t),输出表示为Output(t)Context(t)是网络的状态(隐藏层)。输入向量是通过连接表示当前单词的向量和时间t-1的上下文层中神经元的输出来形成的。

这些网络在几个时期内进行训练,其中训练语料库中的所有数据都被顺序呈现。为了训练网络,我们可以使用随机梯度下降的标准反向传播算法。每个时期后,网络都会在验证数据上进行测试。如果验证数据的对数似然性增加,训练将在新的时期继续。如果没有观察到显著的改善,学习率可以在每个新时期开始时减半。如果改变学习率没有显著改善,训练就结束了。这样的网络通常在 10-20 个时期后收敛。

这里,输出层表示在给定上一个单词和Context(t − 1)时下一个单词的概率分布。Softmax 确保概率分布是有效的。在每个训练步骤中,计算错误向量,并使用标准的反向传播算法更新权重,如下所示:

error(t) = desired(t) − Output(t)

这里,desired 是使用1-of-N编码的向量,表示在特定上下文中应该被预测的单词,Output(t)是网络的实际输出。

为了提高性能,我们可以将在训练文本中出现次数少于给定阈值的所有单词合并为一个特殊的稀有标记。因此,所有稀有单词都被平等对待,即它们之间的概率均匀分布。

现在,我们执行 BigDL 库中提供的一个简单的 RNN 示例。该网络是一个全连接的 RNN,其中输出被反馈到输入中。该示例模型支持序列到序列处理,并且是用于语言建模的简单循环神经网络的实现。

有关此示例的完整源代码,请参阅github.com/intel-analytics/BigDL/tree/master/spark/dl/src/main/scala/com/intel/analytics/bigdl/models/rnn

输入数据集 Tiny Shakespeare Texts 可以从raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt下载。

下载文本后,将其放入适当的目录。我们将输入数据集拆分为单独的train.txtval.txt文件。在我们的示例中,我们选择 80%的输入作为训练数据集,剩下的 20%作为验证数据集。

通过执行以下命令将输入数据集拆分:

head -n 8000 input.txt > val.txt
tail -n +8000 input.txt > train.txt

SentenceSplitterSentenceTokenizer类使用Apache OpenNLP库。训练模型文件--en-token.binen-sent.bin--可以从opennlp.sourceforge.net/models-1.5/下载。

与模型和优化器相关的关键部分代码如下:

val model = Sequential[Float]()
//The RNN is created with the time-related parameter.
model.add(Recurrent[Float]()
.add(RnnCellFloat)))
.add(TimeDistributedFloat))

//The optimization method used is SGD.
val optimMethod = if (param.stateSnapshot.isDefined) {
OptimMethod.loadFloat
} else {
   new SGDFloat
}

val optimizer = Optimizer(
model = model,
dataset = trainSet,
criterion = TimeDistributedCriterionFloat, sizeAverage = true)
)

optimizer
.setValidation(Trigger.everyEpoch, validationSet, Array(new LossFloat, sizeAverage = true))))
.setOptimMethod(optimMethod)
.setEndWhen(Trigger.maxEpoch(param.nEpochs))
.setCheckpoint(param.checkpoint.get, Trigger.everyEpoch)
.optimize()

以下命令执行训练程序。修改特定于您的环境的参数:

Aurobindos-MacBook-Pro-2:bigdl-rnn aurobindosarkar$ /Users/aurobindosarkar/Downloads/BigDL-master/scripts/bigdl.sh -- \
> /Users/aurobindosarkar/Downloads/spark-2.1.0-bin-hadoop2.7/bin/spark-submit \
> --master local[2] \
> --executor-cores 2 \
> --total-executor-cores 2 \
> --class com.intel.analytics.bigdl.models.rnn.Train \
> /Users/aurobindosarkar/Downloads/dist-spark-2.1.1-scala-2.11.8-mac-0.3.0-20170813.202825-21-dist/lib/bigdl-SPARK_2.1-0.3.0-SNAPSHOT-jar-with-dependencies.jar \
> -f /Users/aurobindosarkar/Downloads/bigdl-rnn/inputdata/ -s /Users/aurobindosarkar/Downloads/bigdl-rnn/saveDict/ --checkpoint /Users/aurobindosarkar/Downloads/bigdl-rnn/model/ --batchSize 12 -e 2

下面是训练过程中生成的输出的一部分:

17/08/16 21:32:38 INFO utils.ThreadPool$: Set mkl threads to 1 on thread 1
17/08/16 21:32:39 INFO utils.Engine$: Auto detect executor number and executor cores number
17/08/16 21:32:39 INFO utils.Engine$: Executor number is 1 and executor cores number is 2
17/08/16 21:32:39 INFO utils.Engine$: Find existing spark context. Checking the spark conf...
17/08/16 21:32:41 INFO text.Dictionary: 272304 words and32885 sentences processed
17/08/16 21:32:41 INFO text.Dictionary: save created dictionary.txt and discard.txt to/Users/aurobindosarkar/Downloads/bigdl-rnn/saveDict
17/08/16 21:32:41 INFO rnn.Train$: maxTrain length = 25, maxVal = 22
17/08/16 21:32:42 INFO optim.DistriOptimizer$: caching training rdd ...
17/08/16 21:32:42 INFO optim.DistriOptimizer$: Cache thread models...
17/08/16 21:32:42 INFO optim.DistriOptimizer$: model thread pool size is 1
17/08/16 21:32:42 INFO optim.DistriOptimizer$: Cache thread models... done
17/08/16 21:32:42 INFO optim.DistriOptimizer$: config {
maxDropPercentage: 0.0
computeThresholdbatchSize: 100
warmupIterationNum: 200
isLayerwiseScaled: false
dropPercentage: 0.0
}
17/08/16 21:32:42 INFO optim.DistriOptimizer$: Shuffle data
17/08/16 21:32:42 INFO optim.DistriOptimizer$: Shuffle data complete. 
Takes 0.011933988s
17/08/16 21:32:43 INFO optim.DistriOptimizer$: [Epoch 1 0/32885][Iteration 1][Wall Clock 0.0s] Train 12 in 0.642820037seconds. Throughput is 18.667744 records/second. Loss is 8.302014\. Current learning rate is 0.1.
17/08/16 21:32:43 INFO optim.DistriOptimizer$: [Epoch 1 12/32885][Iteration 2][Wall Clock 0.642820037s] Train 12 in 0.211497603seconds. Throughput is 56.73823 records/second. Loss is 8.134232\. Current learning rate is 0.1.
17/08/16 21:32:44 INFO optim.DistriOptimizer$: [Epoch 1 24/32885][Iteration 3][Wall Clock 0.85431764s] Train 12 in 0.337422962seconds. Throughput is 35.56367 records/second. Loss is 7.924248\. Current learning rate is 0.1.
17/08/16 21:32:44 INFO optim.DistriOptimizer$: [Epoch 1 36/32885][Iteration 4][Wall Clock 1.191740602s] Train 12 in 0.189710956seconds. Throughput is 63.25412 records/second. Loss is 7.6132483\. Current learning rate is 0.1.
17/08/16 21:32:44 INFO optim.DistriOptimizer$: [Epoch 1 48/32885][Iteration 5][Wall Clock 1.381451558s] Train 12 in 0.180944071seconds. Throughput is 66.31883 records/second. Loss is 7.095647\. Current learning rate is 0.1.
17/08/16 21:32:44 INFO optim.DistriOptimizer$: [Epoch 1 60/32885][Iteration 6][Wall Clock 1.562395629s] Train 12 in 0.184258125seconds. Throughput is 65.12603 records/second. Loss is 6.3607793\. Current learning rate is 0.1..
.
.
17/08/16 21:50:00 INFO optim.DistriOptimizer$: [Epoch 2 32856/32885][Iteration 5480][Wall Clock 989.905619531s] Train 12 in 0.19739412seconds. Throughput is 60.792084 records/second. Loss is 1.5389917\. Current learning rate is 0.1.
17/08/16 21:50:00 INFO optim.DistriOptimizer$: [Epoch 2 32868/32885][Iteration 5481][Wall Clock 990.103013651s] Train 12 in 0.192780994seconds. Throughput is 62.2468 records/second. Loss is 1.3890615\. Current learning rate is 0.1.
17/08/16 21:50:01 INFO optim.DistriOptimizer$: [Epoch 2 32880/32885][Iteration 5482][Wall Clock 990.295794645s] Train 12 in 0.197826032seconds. Throughput is 60.65936 records/second. Loss is 1.5320908\. Current learning rate is 0.1.
17/08/16 21:50:01 INFO optim.DistriOptimizer$: [Epoch 2 32880/32885][Iteration 5482][Wall Clock 990.295794645s] Epoch finished. Wall clock time is 1038274.610521ms
17/08/16 21:50:01 INFO optim.DistriOptimizer$: [Wall Clock 1038.274610521s] Validate model...
17/08/16 21:50:52 INFO optim.DistriOptimizer$: Loss is (Loss: 1923.4493, count: 1388, Average Loss: 1.3857704)
[Wall Clock 1038.274610521s] Save model to /Users/aurobindosarkar/Downloads/bigdl-rnn/model//20170816_213242

接下来,我们使用保存的模型在测试数据集上运行,如下所示:

Aurobindos-MacBook-Pro-2:bigdl-rnn aurobindosarkar$ /Users/aurobindosarkar/Downloads/BigDL-master/scripts/bigdl.sh -- \
> /Users/aurobindosarkar/Downloads/spark-2.1.0-bin-hadoop2.7/bin/spark-submit \
> --master local[2] \
> --executor-cores 1 \
> --total-executor-cores 2 \
> --class com.intel.analytics.bigdl.models.rnn.Test \
> /Users/aurobindosarkar/Downloads/dist-spark-2.1.1-scala-2.11.8-mac-0.3.0-20170813.202825-21-dist/lib/bigdl-SPARK_2.1-0.3.0-SNAPSHOT-jar-with-dependencies.jar \
> -f /Users/aurobindosarkar/Downloads/bigdl-rnn/saveDict --model /Users/aurobindosarkar/Downloads/bigdl-rnn/model/20170816_213242/model.5483 --words 20 --batchSize 12
17/08/16 21:53:21 INFO utils.ThreadPool$: Set mkl threads to 1 on thread 1
17/08/16 21:53:22 INFO utils.Engine$: Auto detect executor number and executor cores number
17/08/16 21:53:22 INFO utils.Engine$: Executor number is 1 and executor cores number is 2
17/08/16 21:53:22 INFO utils.Engine$: Find existing spark context. Checking the spark conf...
17/08/16 21:53:24 WARN optim.Validator$: Validator(model, dataset) is deprecated. 
17/08/16 21:53:24 INFO optim.LocalValidator$: model thread pool size is 1
17/08/16 21:53:24 INFO optim.LocalValidator$: [Validation] 12/13 Throughput is 84.44181986758397 record / sec
17/08/16 21:53:24 INFO optim.LocalValidator$: [Validation] 13/13 Throughput is 115.81166197957567 record / sec
Loss is (Loss: 11.877369, count: 3, Average Loss: 3.959123)

引入自动编码器

自动编码器神经网络是一种无监督学习算法,它将目标值设置为等于输入值。因此,自动编码器试图学习一个恒等函数的近似。

学习一个恒等函数似乎并不是一项值得的练习;然而,通过对网络施加约束,比如限制隐藏单元的数量,我们可以发现关于数据的有趣结构。自动编码器的关键组件如下图所示:

原始输入,压缩表示以及自动编码器的输出层也在下图中进行了说明。更具体地说,该图表示了一个情况,例如,输入图像具有来自 10×10 图像(100 像素)的像素强度值,并且在第二层中有50个隐藏单元。在这里,网络被迫学习输入的“压缩”表示,其中它必须尝试使用50个隐藏单元“重建”100 像素的输入:

有关自动编码器的更多细节,请参阅 G.E. Hinton 和 R.R. Salakhutdinov 的《使用神经网络降低数据的维度》,可在www.cs.toronto.edu/~hinton/science.pdf上获得。

现在,我们展示了 BigDL 分发中针对 MNIST 数据集的自动编码器示例。

要训练自动编码器,您需要从yann.lecun.com/exdb/mnist/下载 MNIST 数据集。

您需要下载以下内容:

train-images-idx3-ubyte.gz
train-labels-idx1-ubyte.gz (the labels file is not actually used in this example)

然后,您需要解压它们以获得以下文件:

train-images-idx3-ubyte
train-labels-idx1-ubyte

对于我们的实现,ReLU 被用作激活函数,均方误差被用作损失函数。此示例中使用的模型和优化器代码的关键部分如下所示:

val rowN = 28
val colN = 28
val featureSize = rowN * colN
val classNum = 32
//The following model uses ReLU

val model = Sequential[Float]()
model.add(new Reshape(Array(featureSize)))
model.add(new Linear(featureSize, classNum))
model.add(new ReLU[Float]())
model.add(new Linear(classNum, featureSize))
model.add(new Sigmoid[Float]())

val optimMethod = new AdagradFloat

val optimizer = Optimizer(
   model = model,
   dataset = trainDataSet,
   criterion = new MSECriterion[Float]()
)
optimizer.setOptimMethod(optimMethod).setEndWhen(Trigger.maxEpoch(param.maxEpoch)).optimize()

以下是执行自动编码器示例的命令:

Aurobindos-MacBook-Pro-2:bigdl-rnn aurobindosarkar$ /Users/aurobindosarkar/Downloads/BigDL-master/scripts/bigdl.sh -- /Users/aurobindosarkar/Downloads/spark-2.1.0-bin-hadoop2.7/bin/spark-submit --master local[2] --class com.intel.analytics.bigdl.models.autoencoder.Train /Users/aurobindosarkar/Downloads/BigDL-master/spark/dist/target/bigdl-0.2.0-SNAPSHOT-spark-2.0.0-scala-2.11.8-mac-dist/lib/bigdl-0.2.0-SNAPSHOT-jar-with-dependencies.jar -b 150 -f /Users/aurobindosarkar/Downloads/mnist --maxEpoch 2 --checkpoint /Users/aurobindosarkar/Downloads/mnist

示例生成的输出如下:

17/08/16 22:52:16 INFO utils.ThreadPool$: Set mkl threads to 1 on thread 1
17/08/16 22:52:17 INFO utils.Engine$: Auto detect executor number and executor cores number
17/08/16 22:52:17 INFO utils.Engine$: Executor number is 1 and executor cores number is 2
17/08/16 22:52:17 INFO utils.Engine$: Find existing spark context. Checking the spark conf...
17/08/16 22:52:18 INFO optim.DistriOptimizer$: caching training rdd ...
17/08/16 22:52:19 INFO optim.DistriOptimizer$: Cache thread models...
17/08/16 22:52:19 INFO optim.DistriOptimizer$: model thread pool size is 1
17/08/16 22:52:19 INFO optim.DistriOptimizer$: Cache thread models... done
17/08/16 22:52:19 INFO optim.DistriOptimizer$: config {
weightDecay: 5.0E-4
learningRate: 0.01
maxDropPercentage: 0.0
computeThresholdbatchSize: 100
momentum: 0.9
warmupIterationNum: 200
dampening: 0.0
dropPercentage: 0.0
}
17/08/16 22:52:19 INFO optim.DistriOptimizer$: Shuffle data
17/08/16 22:52:19 INFO optim.DistriOptimizer$: Shuffle data complete. Takes 0.013076416s
17/08/16 22:52:19 INFO optim.DistriOptimizer$: [Epoch 1 0/60000][Iteration 1][Wall Clock 0.0s] Train 150 in 0.217233789seconds. Throughput is 690.5003 records/second. Loss is 1.2499084.
17/08/16 22:52:20 INFO optim.DistriOptimizer$: [Epoch 1 150/60000][Iteration 2][Wall Clock 0.217233789s] Train 150 in 0.210093679seconds. Throughput is 713.9672 records/second. Loss is 1.1829382.
17/08/16 22:52:20 INFO optim.DistriOptimizer$: [Epoch 1 300/60000][Iteration 3][Wall Clock 0.427327468s] Train 150 in 0.05808109seconds. Throughput is 2582.5962 records/second. Loss is 1.089432.
17/08/16 22:52:20 INFO optim.DistriOptimizer$: [Epoch 1 450/60000][Iteration 4][Wall Clock 0.485408558s] Train 150 in 0.053720011seconds. Throughput is 2792.2556 records/second. Loss is 0.96986365.
17/08/16 22:52:20 INFO optim.DistriOptimizer$: [Epoch 1 600/60000][Iteration 5][Wall Clock 0.539128569s] Train 150 in 0.052071024seconds. Throughput is 2880.681 records/second. Loss is 0.9202304.
.
.
.
17/08/16 22:52:45 INFO optim.DistriOptimizer$: [Epoch 2 59400/60000][Iteration 797][Wall Clock 26.151645532s] Train 150 in 0.026734804seconds. Throughput is 5610.6636 records/second. Loss is 0.5562006.
17/08/16 22:52:45 INFO optim.DistriOptimizer$: [Epoch 2 59550/60000][Iteration 798][Wall Clock 26.178380336s] Train 150 in 0.031001227seconds. Throughput is 4838.518 records/second. Loss is 0.55211174.
17/08/16 22:52:45 INFO optim.DistriOptimizer$: [Epoch 2 59700/60000][Iteration 799][Wall Clock 26.209381563s] Train 150 in 0.027455972seconds. Throughput is 5463.292 records/second. Loss is 0.5566905.
17/08/16 22:52:45 INFO optim.DistriOptimizer$: [Epoch 2 59850/60000][Iteration 800][Wall Clock 26.236837535s] Train 150 in 0.037863017seconds. Throughput is 3961.6494 records/second. Loss is 0.55880654.
17/08/16 22:52:45 INFO optim.DistriOptimizer$: [Epoch 2 59850/60000][Iteration 800][Wall Clock 26.236837535s] Epoch finished. Wall clock time is 26374.372173ms
[Wall Clock 26.374372173s] Save model to /Users/aurobindosarkar/Downloads/mnist/20170816_225219

总结

在本章中,我们介绍了 Spark 中的深度学习。我们讨论了各种类型的深度神经网络及其应用。我们还探索了 BigDL 分发中提供的一些代码示例。由于这是 Spark 中一个快速发展的领域,目前,我们期望这些库能够提供更多使用 Spark SQL 和 DataFrame/Dataset API 的功能。此外,我们还期望它们在未来几个月内变得更加成熟和稳定。

在下一章中,我们将把重点转向调整 Spark SQL 应用程序。我们将涵盖关于使用编码器进行序列化/反序列化以及与查询执行相关的逻辑和物理计划的关键基础知识,然后介绍 Spark 2.2 中发布的基于成本的优化CBO)功能的详细信息。此外,我们还将介绍开发人员可以使用的一些技巧和窍门来提高其应用程序的性能。

第十一章:调优 Spark SQL 组件以提高性能

在本章中,我们将重点关注基于 Spark SQL 的组件的性能调优方面。Spark SQL Catalyst 优化器是许多 Spark 应用程序(包括ML PipelinesStructured StreamingGraphFrames)高效执行的核心。我们将首先解释与查询执行相关的序列化/反序列化使用编码器的逻辑和物理计划的关键基础方面,然后介绍 Spark 2.2 中发布的基于成本的优化CBO)功能的详细信息。此外,我们将在整个章节中提供一些开发人员可以使用的技巧和窍门,以改善其应用程序的性能。

更具体地说,在本章中,您将学习以下内容:

  • 理解性能调优的基本概念

  • 理解驱动性能的 Spark 内部原理

  • 理解基于成本的优化

  • 理解启用整体代码生成的性能影响

介绍 Spark SQL 中的性能调优

Spark 计算通常是内存中的,并且可能受到集群资源的限制:CPU、网络带宽或内存。此外,即使数据适合内存,网络带宽可能也是一个挑战。

调优 Spark 应用程序是减少网络传输的数据数量和大小和/或减少计算的整体内存占用的必要步骤。

在本章中,我们将把注意力集中在 Spark SQL Catalyst 上,因为它对从整套应用程序组件中获益至关重要。

Spark SQL 是最近对 Spark 进行的重大增强的核心,包括ML PipelinesStructured StreamingGraphFrames。下图说明了Spark SQLSpark Core和构建在其之上的高级 API 之间发挥的关键作用:

在接下来的几节中,我们将介绍调优 Spark SQL 应用程序所需的基本理解。我们将从DataFrame/Dataset API 开始。

理解 DataFrame/Dataset API

数据集是一种强类型的领域特定对象的集合,可以使用函数或关系操作并行转换。每个数据集还有一个称为DataFrame的视图,它不是强类型的,本质上是一组行对象的数据集。

Spark SQL 将结构化视图应用于来自不同数据格式的不同源系统的数据。结构化 API(如 DataFrame/Dataset API)允许开发人员使用高级 API 编写程序。这些 API 允许他们专注于数据处理所需的“是什么”,而不是“如何”。

尽管应用结构可能会限制可以表达的内容,但实际上,结构化 API 可以容纳应用开发中所需的绝大多数计算。此外,正是这些由结构化 API 所施加的限制,提供了一些主要的优化机会。

在下一节中,我们将探讨编码器及其在高效序列化和反序列化中的作用。

优化数据序列化

编码器是 Spark SQL 2.0 中序列化反序列化SerDe)框架中的基本概念。Spark SQL 使用 SerDe 框架进行 I/O,从而实现更高的时间和空间效率。数据集使用专门的编码器来序列化对象,以便在处理或通过网络传输时使用,而不是使用 Java 序列化或 Kryo。

编码器需要有效地支持领域对象。这些编码器将领域对象类型T映射到 Spark 的内部类型系统,Encoder [T]用于将类型T的对象或原语转换为 Spark SQL 的内部二进制行格式表示(使用 Catalyst 表达式和代码生成)。结果的二进制结构通常具有更低的内存占用,并且针对数据处理的效率进行了优化(例如,以列格式)。

高效的序列化是实现分布式应用程序良好性能的关键。序列化对象速度慢的格式将显著影响性能。通常,这将是您调优以优化 Spark 应用程序的第一步。

编码器经过高度优化,并使用运行时代码生成来构建用于序列化和反序列化的自定义字节码。此外,它们使用一种格式,允许 Spark 执行许多操作,如过滤和排序,而无需将其反序列化为对象。由于编码器知道记录的模式,它们可以提供显著更快的序列化和反序列化(与默认的 Java 或 Kryo 序列化器相比)。

除了速度之外,编码器输出的序列化大小也可以显著减小,从而降低网络传输的成本。此外,序列化数据已经是钨丝二进制格式,这意味着许多操作可以就地执行,而无需实例化对象。Spark 内置支持自动生成原始类型(如 String 和 Integer)和案例类的编码器。

在这里,我们展示了从第一章Getting Started with Spark SQL**中为 Bid 记录创建自定义编码器的示例。请注意,通过导入spark.implicits._,大多数常见类型的编码器都会自动提供,并且默认的编码器已经在 Spark shell 中导入。

首先,让我们导入本章代码所需的所有类:

scala> import org.apache.spark.sql._ 
scala> import org.apache.spark.sql.types._ 
scala> import org.apache.spark.sql.functions._ 
scala> import org.apache.spark.sql.streaming._ 
scala> import spark.implicits._ 
scala> import spark.sessionState.conf 
scala> import org.apache.spark.sql.internal.SQLConf.SHUFFLE_PARTITIONS 
scala> import org.apache.spark.sql.Encoders 
scala> import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder 

接下来,我们将为输入数据集中Bid记录的领域对象定义一个case类:

scala> case class Bid(bidid: String, timestamp: String, ipinyouid: String, useragent: String, IP: String, region: Integer, cityID: Integer, adexchange: String, domain: String, turl: String, urlid: String, slotid: String, slotwidth: String, slotheight: String, slotvisibility: String, slotformat: String, slotprice: String, creative: String, bidprice: String) 

接下来,我们将使用上一步的case类创建一个Encoder对象,如下所示:

scala> val bidEncoder = Encoders.product[Bid] 

可以使用 schema 属性访问模式,如下所示:

scala> bidEncoder.schema

我们使用了ExpressionEncoder的实现(这是 Spark SQL 2 中唯一可用的编码器特性的实现):

scala> val bidExprEncoder = bidEncoder.asInstanceOf[ExpressionEncoder[Bid]] 

以下是编码器的序列化器和反序列化器部分:

scala> bidExprEncoder.serializer 

scala> bidExprEncoder.namedExpressions 

接下来,我们将演示如何读取我们的输入数据集:

scala> val bidsDF = spark.read.format("csv").schema(bidEncoder.schema).option("sep", "\t").option("header", false).load("file:///Users/aurobindosarkar/Downloads/make-ipinyou-data-master/original-data/ipinyou.contest.dataset/bidfiles") 

然后,我们将从我们新创建的 DataFrame 中显示一个Bid记录,如下所示:

scala> bidsDF.take(1).foreach(println) 

[e3d962536ef3ac7096b31fdd1c1c24b0,20130311172101557,37a6259cc0c1dae299a7866489dff0bd,Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0; QQDownload 734; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; eSobiSubscriber 2.0.4.16; MAAR),gzip(gfe),gzip(gfe),219.232.120.*,1,1,2,DF9blS9bQqsIFYB4uA5R,b6c5272dfc63032f659be9b786c5f8da,null,2006366309,728,90,1,0,5,5aca4c5f29e59e425c7ea657fdaac91e,300] 

为了方便起见,我们可以使用上一步的记录创建一个新记录,如在Dataset[Bid]中:

scala> val bid = Bid("e3d962536ef3ac7096b31fdd1c1c24b0","20130311172101557","37a6259cc0c1dae299a7866489dff0bd","Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0; QQDownload 734; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; eSobiSubscriber 2.0.4.16; MAAR),gzip(gfe),gzip(gfe)","219.232.120.*",1,1,"2","","DF9blS9bQqsIFYB4uA5R,b6c5272dfc63032f659be9b786c5f8da",null,"2006366309","728","90","1","0","5","5aca4c5f29e59e425c7ea657fdaac91e","300") 

然后,我们将记录序列化为内部表示,如下所示:

scala> val row = bidExprEncoder.toRow(bid)  

Spark 在 I/O 中内部使用InternalRows。因此,我们将字节反序列化为 JVM 对象,即Scala对象,如下所示。但是,我们需要导入Dsl表达式,并明确指定DslSymbol,因为在 Spark shell 中存在竞争的隐式:

scala> import org.apache.spark.sql.catalyst.dsl.expressions._ 

scala> val attrs = Seq(DslSymbol('bidid).string, DslSymbol('timestamp).string, DslSymbol('ipinyouid).string, DslSymbol('useragent).string, DslSymbol('IP).string, DslSymbol('region).int, DslSymbol('cityID).int, DslSymbol('adexchange).string, DslSymbol('domain).string, DslSymbol('turl).string, DslSymbol('urlid).string, DslSymbol('slotid).string, DslSymbol('slotwidth).string, DslSymbol('slotheight).string, DslSymbol('slotvisibility).string, DslSymbol('slotformat).string, DslSymbol('slotprice).string, DslSymbol('creative).string, DslSymbol('bidprice).string) 

在这里,我们检索序列化的Bid对象:

scala> val getBackBid = bidExprEncoder.resolveAndBind(attrs).fromRow(row) 

我们可以验证两个对象是否相同,如下所示:

scala> bid == getBackBid 
res30: Boolean = true 

在下一节中,我们将把重点转移到 Spark SQL 的 Catalyst 优化。

理解 Catalyst 优化

我们在第一章* 使用 Spark SQL 入门中简要探讨了 Catalyst 优化器。基本上,Catalyst 具有用户程序的内部表示,称为查询计划。一组转换在初始查询计划上执行,以产生优化的查询计划。最后,通过 Spark SQL 的代码生成机制,优化的查询计划转换为 RDD 的 DAG,准备执行。在其核心,Catalyst 优化器定义了用户程序的抽象为树,以及从一棵树到另一棵树的转换。

为了利用优化机会,我们需要一个优化器,它可以自动找到执行数据操作的最有效计划(在用户程序中指定)。在本章的上下文中,Spark SQL 的 Catalyst 优化器充当用户高级编程构造和低级执行计划之间的接口。

理解数据集/DataFrame API

数据集或 DataFrame 通常是通过从数据源读取或执行查询而创建的。在内部,查询由运算符树表示,例如逻辑和物理树。数据集表示描述生成数据所需的逻辑计划。当调用动作时,Spark 的查询优化器会优化逻辑计划,并生成用于并行和分布式执行的物理计划。

查询计划用于描述数据操作,例如聚合、连接或过滤,以使用不同类型的输入数据集生成新的数据集。

第一种查询计划是逻辑计划,它描述了在数据集上所需的计算,而不具体定义实际计算的机制。它给我们提供了用户程序的抽象,并允许我们自由地转换查询计划,而不用担心执行细节。

查询计划是 Catalyst 的一部分,它对关系运算符树进行建模,即结构化查询。查询计划具有statePrefix,在显示计划时使用!表示无效计划,使用'表示未解析计划。如果存在缺少的输入属性并且子节点非空,则查询计划无效;如果列名尚未经过验证并且列类型尚未在目录中查找,则查询计划未解析。

作为优化的一部分,Catalyst 优化器应用各种规则来在阶段中操作这些树。我们可以使用 explain 函数来探索逻辑计划以及优化后的物理计划。

现在,我们将以三个数据集的简单示例,并使用explain()函数显示它们的优化计划:

scala> val t1 = spark.range(7) 
scala> val t2 = spark.range(13) 
scala> val t3 = spark.range(19) 

scala> t1.explain() 
== Physical Plan == 
*Range (0, 7, step=1, splits=8) 

scala> t1.explain(extended=true) 
== Parsed Logical Plan == 
Range (0, 7, step=1, splits=Some(8)) 

== Analyzed Logical Plan == 
id: bigint 
Range (0, 7, step=1, splits=Some(8)) 

== Optimized Logical Plan == 
Range (0, 7, step=1, splits=Some(8)) 

== Physical Plan == 
*Range (0, 7, step=1, splits=8) 

scala> t1.filter("id != 0").filter("id != 2").explain(true) 
== Parsed Logical Plan == 
'Filter NOT ('id = 2) 
+- Filter NOT (id#0L = cast(0 as bigint)) 
   +- Range (0, 7, step=1, splits=Some(8)) 

== Analyzed Logical Plan == 
id: bigint 
Filter NOT (id#0L = cast(2 as bigint)) 
+- Filter NOT (id#0L = cast(0 as bigint)) 
   +- Range (0, 7, step=1, splits=Some(8)) 

== Optimized Logical Plan == 
Filter (NOT (id#0L = 0) && NOT (id#0L = 2)) 
+- Range (0, 7, step=1, splits=Some(8)) 

== Physical Plan == 
*Filter (NOT (id#0L = 0) && NOT (id#0L = 2)) 
+- *Range (0, 7, step=1, splits=8) 

分析逻辑计划是在初始解析计划上应用分析器的检查规则的结果。分析器是 Spark SQL 中的逻辑查询计划分析器,它在语义上验证和转换未解析的逻辑计划为分析的逻辑计划(使用逻辑评估规则):

scala> spark.sessionState.analyzer 
res30: org.apache.spark.sql.catalyst.analysis.Analyzer = org.apache.spark.sql.hive.HiveSessionStateBuilder$$anon$1@21358f6c 

启用会话特定记录器的TRACEDEBUG日志级别,以查看分析器内部发生的情况。例如,将以下行添加到conf/log4j属性中:

log4j.logger.org.apache.spark.sql.hive.HiveSessionStateBuilder$$anon$1=DEBUG scala> val t1 = spark.range(7) 
17/07/13 10:25:38 DEBUG HiveSessionStateBuilder$$anon$1:  
=== Result of Batch Resolution === 
!'DeserializeToObject unresolveddeserializer(staticinvoke(class java.lang.Long, ObjectType(class java.lang.Long), valueOf, upcast(getcolumnbyordinal(0, LongType), LongType, - root class: "java.lang.Long"), true)), obj#2: java.lang.Long   DeserializeToObject staticinvoke(class java.lang.Long, ObjectType(class java.lang.Long), valueOf, cast(id#0L as bigint), true), obj#2: java.lang.Long 
 +- LocalRelation <empty>, [id#0L]                                                                                                                                                                                                            +- LocalRelation <empty>, [id#0L] 

t1: org.apache.spark.sql.Dataset[Long] = [id: bigint] 

分析器是一个规则执行器,定义了解析和修改逻辑计划评估规则。它使用会话目录解析未解析的关系和函数。固定点的优化规则和批处理中的一次性规则(一次策略)也在这里定义。

在逻辑计划优化阶段,执行以下一系列操作:

  • 规则将逻辑计划转换为语义上等效的计划,以获得更好的性能

  • 启发式规则用于推送下推断列,删除未引用的列等

  • 较早的规则使后续规则的应用成为可能;例如,合并查询块使全局连接重新排序

SparkPlan是用于构建物理查询计划的 Catalyst 查询计划的物理运算符。在执行时,物理运算符会产生行的 RDD。可用的逻辑计划优化可以扩展,并且可以注册额外的规则作为实验方法。

scala> t1.filter("id != 0").filter("id != 2") 
17/07/13 10:43:17 DEBUG HiveSessionStateBuilder$$anon$1:  
=== Result of Batch Resolution === 
!'Filter NOT ('id = 0)                      
Filter NOT (id#0L = cast(0 as bigint)) 
 +- Range (0, 7, step=1, splits=Some(8))    
+- Range (0, 7, step=1, splits=Some(8)) 
... 

17/07/13 10:43:17 DEBUG HiveSessionStateBuilder$$anon$1:  
=== Result of Batch Resolution === 
!'Filter NOT ('id = 2)                         
Filter NOT (id#0L = cast(2 as bigint)) 
 +- Filter NOT (id#0L = cast(0 as bigint))     
   +- Filter NOT (id#0L = cast(0 as bigint)) 
    +- Range (0, 7, step=1, splits=Some(8))       
   +- Range (0, 7, step=1, splits=Some(8)) 

理解 Catalyst 转换

在这一部分,我们将详细探讨 Catalyst 转换。在 Spark 中,转换是纯函数,也就是说,在转换过程中不会改变树的结构(而是生成一个新的树)。在 Catalyst 中,有两种类型的转换:

  • 在第一种类型中,转换不会改变树的类型。使用这种转换,我们可以将一个表达式转换为另一个表达式,一个逻辑计划转换为另一个逻辑计划,或者一个物理计划转换为另一个物理计划。

  • 第二种类型的转换将一个树从一种类型转换为另一种类型。例如,这种类型的转换用于将逻辑计划转换为物理计划。

一个函数(与给定树相关联)用于实现单个规则。例如,在表达式中,这可以用于常量折叠优化。转换被定义为部分函数。(回想一下,部分函数是为其可能的参数子集定义的函数。)通常,case 语句会判断规则是否被触发;例如,谓词过滤器被推到JOIN节点下面,因为它减少了JOIN的输入大小;这被称为谓词下推。类似地,投影仅针对查询中使用的所需列执行。这样,我们可以避免读取不必要的数据。

通常,我们需要结合不同类型的转换规则。规则执行器用于组合多个规则。它通过应用许多规则(批处理中定义的)将一个树转换为相同类型的另一个树。

有两种方法用于应用规则:

  • 在第一种方法中,我们重复应用规则,直到树不再发生变化(称为固定点)

  • 在第二种类型中,我们一次批处理应用所有规则(一次策略)

接下来,我们将看看第二种类型的转换,即从一种树转换为另一种树:更具体地说,Spark 如何将逻辑计划转换为物理计划。通过应用一组策略,可以将逻辑计划转换为物理计划。主要是采用模式匹配的方法进行这些转换。例如,一个策略将逻辑投影节点转换为物理投影节点,逻辑过滤节点转换为物理过滤节点,依此类推。策略可能无法转换所有内容,因此在代码的特定点内置了触发其他策略的机制(例如planLater方法)。

优化过程包括三个步骤:

  1. 分析(规则执行器):这将一个未解析的逻辑计划转换为已解析的逻辑计划。未解析到已解析的状态使用目录来查找数据集和列的来源以及列的类型。

  2. 逻辑优化(规则执行器):这将一个已解析的逻辑计划转换为优化的逻辑计划。

  3. 物理规划(策略+规则执行器):包括两个阶段:

  • 将优化的逻辑计划转换为物理计划。

  • 规则执行器用于调整物理计划,使其准备好执行。这包括我们如何洗牌数据以及如何对其进行分区。

如下例所示,表达式表示一个新值,并且它是基于其输入值计算的,例如,将一个常量添加到列中的每个元素,例如1 + t1.normal。类似地,属性是数据集中的一列(例如,t1.id)或者由特定数据操作生成的列,例如 v。

输出中列出了由此逻辑计划生成的属性列表,例如 id 和 v。逻辑计划还具有关于此计划生成的行的一组不变量,例如,t2.id > 5000000。最后,我们有统计信息,行/字节中计划的大小,每列统计信息,例如最小值、最大值和不同值的数量,以及空值的数量。

第二种查询计划是物理计划,它描述了对具有特定定义的数据集进行计算所需的计算。物理计划实际上是可执行的:

scala> val t0 = spark.range(0, 10000000) 
scala> val df1 = t0.withColumn("uniform", rand(seed=10)) 
scala> val df2 = t0.withColumn("normal", randn(seed=27)) 
scala> df1.createOrReplaceTempView("t1") 
scala> df2.createOrReplaceTempView("t2") 

scala> spark.sql("SELECT sum(v) FROM (SELECT t1.id, 1 + t1.normal AS v FROM t1 JOIN t2 WHERE t1.id = t2.id AND t2.id > 5000000) tmp").explain(true) 

前述查询的所有计划都显示在以下代码块中。请注意我们在解析逻辑计划中的注释,反映了原始 SQL 查询的部分内容:

== Parsed Logical Plan == 
'Project [unresolvedalias('sum('v), None)] ------------------> SELECT sum(v) 
+- 'SubqueryAlias tmp 
   +- 'Project ['t1.id, (1 + 't1.normal) AS v#79] ----------->       SELECT t1.id,  
                                                               1 + t1.normal as v 
      +- 'Filter (('t1.id = 't2.id) && ('t2.id > 5000000))---> WHERE t1.id = t2.id,  
                                                                    t2.id > 5000000 
         +- 'Join Inner -------------------------------------> t1 JOIN t2 
            :- 'UnresolvedRelation `t1` 
            +- 'UnresolvedRelation `t2` 

== Analyzed Logical Plan == 
sum(v): double 
Aggregate [sum(v#79) AS sum(v)#86] 
+- SubqueryAlias tmp 
   +- Project [id#10L, (cast(1 as double) + normal#13) AS v#79] 
      +- Filter ((id#10L = id#51L) && (id#51L > cast(5000000 as bigint))) 
         +- Join Inner 
            :- SubqueryAlias t1 
            :  +- Project [id#10L, randn(27) AS normal#13] 
            :     +- Range (0, 10000000, step=1, splits=Some(8)) 
            +- SubqueryAlias t2 
               +- Project [id#51L, rand(10) AS uniform#54] 
                  +- Range (0, 10000000, step=1, splits=Some(8)) 

== Optimized Logical Plan == 
Aggregate [sum(v#79) AS sum(v)#86] 
+- Project [(1.0 + normal#13) AS v#79] 
   +- Join Inner, (id#10L = id#51L) 
      :- Filter (id#10L > 5000000) 
      :  +- Project [id#10L, randn(27) AS normal#13] 
      :     +- Range (0, 10000000, step=1, splits=Some(8)) 
      +- Filter (id#51L > 5000000) 
         +- Range (0, 10000000, step=1, splits=Some(8)) 

== Physical Plan == 
*HashAggregate(keys=[], functions=[sum(v#79)], output=[sum(v)#86]) 
+- Exchange SinglePartition 
   +- *HashAggregate(keys=[], functions=[partial_sum(v#79)], output=[sum#88]) 
      +- *Project [(1.0 + normal#13) AS v#79] 
         +- *SortMergeJoin [id#10L], [id#51L], Inner 
            :- *Sort [id#10L ASC NULLS FIRST], false, 0 
            :  +- Exchange hashpartitioning(id#10L, 200) 
            :     +- *Filter (id#10L > 5000000) 
            :        +- *Project [id#10L, randn(27) AS normal#13] 
            :           +- *Range (0, 10000000, step=1, splits=8) 
            +- *Sort [id#51L ASC NULLS FIRST], false, 0 
               +- Exchange hashpartitioning(id#51L, 200) 
                  +- *Filter (id#51L > 5000000) 
                     +- *Range (0, 10000000, step=1, splits=8) 

您可以使用 Catalyst 的 API 自定义 Spark 以推出自己的计划规则。

有关 Spark SQL Catalyst 优化器的更多详细信息,请参阅spark-summit.org/2017/events/a-deep-dive-into-spark-sqls-catalyst-optimizer/

可视化 Spark 应用程序执行

在本节中,我们将介绍 SparkUI 界面的关键细节,这对于调整任务至关重要。监视 Spark 应用程序有几种方法,例如使用 Web UI、指标和外部仪表。显示的信息包括调度器阶段和任务列表、RDD 大小和内存使用摘要、环境信息以及有关正在运行的执行器的信息。

可以通过简单地在 Web 浏览器中打开http://<driver-node>:4040http://localhost:4040)来访问此界面。在同一主机上运行的其他SparkContexts绑定到连续的端口:4041、4042 等。

有关 Spark 监控和仪表的更详细覆盖范围,请参阅spark.apache.org/docs/latest/monitoring.html

我们将使用两个示例可视化地探索 Spark SQL 执行。首先,我们创建两组数据集。第一组(t1t2t3)与第二组(t4t5t6)的Dataset[Long]之间的区别在于大小:

scala> val t1 = spark.range(7) 
scala> val t2 = spark.range(13) 
scala> val t3 = spark.range(19) 
scala> val t4 = spark.range(1e8.toLong) 
scala> val t5 = spark.range(1e8.toLong) 
scala> val t6 = spark.range(1e3.toLong)  

我们将执行以下JOIN查询,针对两组数据集,以可视化 SparkUI 仪表板中的 Spark 作业信息:

scala> val query = t1.join(t2).where(t1("id") === t2("id")).join(t3).where(t3("id") === t1("id")).explain() 
== Physical Plan == 
*BroadcastHashJoin [id#6L], [id#12L], Inner, BuildRight 
:- *BroadcastHashJoin [id#6L], [id#9L], Inner, BuildRight 
:  :- *Range (0, 7, step=1, splits=8) 
:  +- BroadcastExchange HashedRelationBroadcastMode(List(input[0, bigint, false])) 
:     +- *Range (0, 13, step=1, splits=8) 
+- BroadcastExchange HashedRelationBroadcastMode(List(input[0, bigint, false])) 
   +- *Range (0, 19, step=1, splits=8) 
query: Unit = () 

scala> val query = t1.join(t2).where(t1("id") === t2("id")).join(t3).where(t3("id") === t1("id")).count() 
query: Long = 7 

以下屏幕截图显示了事件时间轴:

生成的DAG 可视化显示了阶段和洗牌(Exchange):

作业摘要,包括执行持续时间、成功任务和总任务数等,显示在此处:

单击 SQL 选项卡以查看详细的执行流程,如下所示:

接下来,我们将在更大的数据集上运行相同的查询。请注意,由于输入数据集的增加,第一个示例中的BroadcastHashJoin现在变为SortMergeJoin

scala> val query = t4.join(t5).where(t4("id") === t5("id")).join(t6).where(t4("id") === t6("id")).explain() 
== Physical Plan == 
*BroadcastHashJoin [id#72L], [id#78L], Inner, BuildRight 
:- *SortMergeJoin [id#72L], [id#75L], Inner 
:  :- *Sort [id#72L ASC NULLS FIRST], false, 0 
:  :  +- Exchange hashpartitioning(id#72L, 200) 
:  :     +- *Range (0, 100000000, step=1, splits=8) 
:  +- *Sort [id#75L ASC NULLS FIRST], false, 0 
:     +- ReusedExchange [id#75L], Exchange hashpartitioning(id#72L, 200) 
+- BroadcastExchange HashedRelationBroadcastMode(List(input[0, bigint, false])) 
   +- *Range (0, 1000, step=1, splits=8) 
query: Unit = () 

执行 DAG 如下图所示:

作业执行摘要如下所示:

SQL 执行详细信息如下图所示:

除了在 UI 中显示,指标也可用作 JSON 数据。这为开发人员提供了一个很好的方式来为 Spark 创建新的可视化和监控工具。REST 端点挂载在/api/v1;例如,它们通常可以在http://localhost:4040/api/v1上访问。这些端点已经强烈版本化,以便更容易地使用它们开发应用程序。

探索 Spark 应用程序执行指标

Spark 具有基于Dropwizard Metrics库的可配置度量系统。这允许用户将 Spark 指标报告给各种接收器,包括HTTPJMXCSV文件。与 Spark 组件对应的 Spark 指标包括 Spark 独立主进程,主进程中报告各种应用程序的应用程序,Spark 独立工作进程,Spark 执行程序,Spark 驱动程序进程和 Spark 洗牌服务。

下一系列的屏幕截图包含详细信息,包括摘要指标和针对较大数据集的 JOIN 查询的一个阶段的执行程序的聚合指标:

已完成任务的摘要指标如下所示:

按执行程序聚合的指标如下所示:

使用外部工具进行性能调优

通常使用外部监视工具来分析大型Spark 集群中 Spark 作业的性能。例如,Ganglia 可以提供有关整体集群利用率和资源瓶颈的见解。此外,OS profiling工具和JVM实用程序可以提供有关单个节点的细粒度分析和用于处理JVM内部的工具。

有关可视化 Spark 应用程序执行的更多详细信息,请参阅databricks.com/blog/2015/06/22/understanding-your-spark-application-through-visualization.html

在下一节中,我们将把焦点转移到 Spark 2.2 中发布的新成本优化器。

Apache Spark 2.2 中的成本优化器

在 Spark 中,优化器的目标是最小化端到端查询响应时间。它基于两个关键思想:

尽早剪枝不必要的数据,例如,过滤器下推和列修剪。

最小化每个操作员的成本,例如广播与洗牌和最佳连接顺序。

直到 Spark 2.1,Catalyst 本质上是一个基于规则的优化器。大多数 Spark SQL 优化器规则都是启发式规则:PushDownPredicateColumnPruningConstantFolding等。它们在估计JOIN关系大小时不考虑每个操作员的成本或选择性。因此,JOIN顺序大多由其在SQL 查询中的位置决定,并且基于启发式规则决定物理连接实现。这可能导致生成次优计划。然而,如果基数事先已知,就可以获得更有效的查询。CBO 优化器的目标正是自动执行这一点。

华为最初在 Spark SQL 中实施了 CBO;在他们开源了他们的工作之后,包括 Databricks 在内的许多其他贡献者致力于完成其第一个版本。与 Spark SQL 相关的 CBO 更改,特别是进入 Spark SQL 数据结构和工作流的主要入口点,已经以一种非侵入性的方式进行了设计和实施。

配置参数spark.sql.cbo可用于启用/禁用此功能。目前(在 Spark 2.2 中),默认值为 false。

有关更多详细信息,请参阅华为的设计文档,网址为issues.apache.org/jira/browse/SPARK-16026

Spark SQL 的 Catalyst 优化器实施了许多基于规则的优化技术,例如谓词下推以减少连接操作执行之前的符合记录数量,以及项目修剪以减少进一步处理之前参与的列数量。然而,如果没有关于数据分布的详细列统计信息,就很难准确估计过滤因子和基数,从而难以准确估计数据库操作员的输出大小。使用不准确和/或误导性的统计信息,优化器最终可能会选择次优的查询执行计划。

为了改进查询执行计划的质量,Spark SQL 优化器已经增强了详细的统计信息。更好地估计输出记录的数量和输出大小(对于每个数据库运算符)有助于优化器选择更好的查询计划。CBO 实现收集、推断和传播源/中间数据的表/列统计信息。查询树被注释了这些统计信息。此外,它还计算每个运算符的成本,例如输出行数、输出大小等。基于这些成本计算,它选择最优的查询执行计划。

了解 CBO 统计收集

Statistics类是保存统计信息的关键数据结构。当我们执行统计收集 SQL 语句以将信息保存到系统目录中时,会引用这个数据结构。当我们从系统目录中获取统计信息以优化查询计划时,也会引用这个数据结构。

CBO 依赖于详细的统计信息来优化查询执行计划。以下 SQL 语句可用于收集表级统计信息,例如行数、文件数(或 HDFS 数据块数)和表大小(以字节为单位)。它收集表级统计信息并将其保存在元数据存储中。在 2.2 版本之前,我们只有表大小,而没有行数:

ANALYZE TABLE table_name COMPUTE STATISTICS 

类似地,以下 SQL 语句可用于收集指定列的列级统计信息。收集的信息包括最大列值、最小列值、不同值的数量、空值的数量等。它收集列级统计信息并将其保存在元数据存储中。通常,它仅针对WHEREGROUP BY子句中的列执行:

ANALYZE TABLE table_name COMPUTE STATISTICS FOR COLUMNS column-name1, column-name2, .... 

给定的 SQL 语句以扩展格式显示表的元数据,包括表级统计信息:

DESCRIBE EXTENDED table_name 

customers表是在本章的后面部分创建的:

scala> sql("DESCRIBE EXTENDED customers").collect.foreach(println) 
[# col_name,data_type,comment] 
[id,bigint,null] 
[name,string,null] 
[,,] 
[# Detailed Table Information,,] 
[Database,default,] 
[Table,customers,] 
[Owner,aurobindosarkar,] 
[Created,Sun Jul 09 23:16:38 IST 2017,] 
[Last Access,Thu Jan 01 05:30:00 IST 1970,] 
[Type,MANAGED,] 
[Provider,parquet,] 
[Properties,[serialization.format=1],] 
[Statistics,1728063103 bytes, 200000000 rows,] 
[Location,file:/Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7/spark-warehouse/customers,] 
[Serde Library,org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe,] 
[InputFormat,org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat,] 
[OutputFormat,org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat,] 

以下 SQL 语句可用于显示优化后的逻辑计划中的统计信息:

EXPLAIN COST SELECT * FROM table_name WHERE condition 

统计收集函数

统计信息是使用一组函数收集的,例如,行数实际上是通过运行 SQL 语句获得的,例如select count(1) from table_name。使用 SQL 语句获取行数是快速的,因为我们利用了 Spark SQL 的执行并行性。类似地,analyzeColumns函数获取给定列的基本统计信息。基本统计信息,如最大值最小值不同值的数量,也是通过运行 SQL 语句获得的。

过滤运算符

过滤条件是 SQL select 语句的WHERE子句中指定的谓词表达式。当我们评估整体过滤因子时,谓词表达式可能非常复杂。

有几个运算符执行过滤基数估计,例如,在ANDORNOT逻辑表达式之间,以及逻辑表达式如=<<=>>=in

对于过滤运算符,我们的目标是计算过滤条件,以找出应用过滤条件后前一个(或子)运算符输出的部分。过滤因子是一个介于0.01.0之间的双精度数。过滤运算符的输出行数基本上是其子节点的输出行数乘以过滤因子。其输出大小是其子节点的输出大小乘以过滤因子。

连接运算符

在计算两个表连接输出的基数之前,我们应该已经有其两侧子节点的输出基数。每个连接侧的基数不再是原始连接表中的记录数。相反,它是在此连接运算符之前应用所有执行运算符后合格记录的数量。

如果用户收集join column统计信息,那么我们就知道每个join column的不同值的数量。由于我们还知道连接关系上的记录数量,我们可以判断join column是否是唯一键。我们可以计算join column上不同值的数量与连接关系中记录数量的比率。如果比率接近1.0(比如大于0.95),那么我们可以假设join column是唯一的。因此,如果join column是唯一的,我们可以精确确定每个不同值的记录数量。

构建侧选择

CBO 可以为执行操作符选择一个良好的物理策略。例如,CBO 可以选择hash join操作的build side选择。对于双向哈希连接,我们需要选择一个操作数作为build side,另一个作为probe side。该方法选择成本较低的子节点作为hash joinbuild side

在 Spark 2.2 之前,构建侧是基于原始表大小选择的。对于以下 Join 查询示例,早期的方法会选择BuildRight。然而,使用 CBO,构建侧是基于连接之前各种操作符的估计成本选择的。在这里,会选择BuildLeft。它还可以决定是否执行广播连接。此外,可以重新排列给定查询的数据库操作符的执行顺序。cbo可以在给定查询的多个候选计划中选择最佳计划。目标是选择具有最低成本的候选计划:

scala> spark.sql("DROP TABLE IF EXISTS t1") 
scala> spark.sql("DROP TABLE IF EXISTS t2") 
scala> spark.sql("CREATE TABLE IF NOT EXISTS t1(id long, value long) USING parquet") 
scala> spark.sql("CREATE TABLE IF NOT EXISTS t2(id long, value string) USING parquet") 

scala> spark.range(5E8.toLong).select('id, (rand(17) * 1E6) cast "long").write.mode("overwrite").insertInto("t1") 
scala> spark.range(1E8.toLong).select('id, 'id cast "string").write.mode("overwrite").insertInto("t2") 

scala> sql("SELECT t1.id FROM t1, t2 WHERE t1.id = t2.id AND t1.value = 100").explain() 
== Physical Plan == 
*Project [id#79L] 
+- *SortMergeJoin [id#79L], [id#81L], Inner 
   :- *Sort [id#79L ASC NULLS FIRST], false, 0 
   :  +- Exchange hashpartitioning(id#79L, 200) 
   :     +- *Project [id#79L] 
   :        +- *Filter ((isnotnull(value#80L) && (value#80L = 100)) && isnotnull(id#79L)) 
   :           +- *FileScan parquet default.t1[id#79L,value#80L] Batched: true, Format: Parquet, Location: InMemoryFileIndex[file:/Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7/spark-warehouse..., PartitionFilters: [], PushedFilters: [IsNotNull(value), EqualTo(value,100), IsNotNull(id)], ReadSchema: struct<id:bigint,value:bigint> 
   +- *Sort [id#81L ASC NULLS FIRST], false, 0 
      +- Exchange hashpartitioning(id#81L, 200) 
         +- *Project [id#81L] 
            +- *Filter isnotnull(id#81L) 
               +- *FileScan parquet default.t2[id#81L] Batched: true, Format: Parquet, Location: InMemoryFileIndex[file:/Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7/spark-warehouse..., PartitionFilters: [], PushedFilters: [IsNotNull(id)], ReadSchema: struct<id:bigint> 

在下一节中,我们将探讨多向连接中的 CBO 优化。

理解多向连接排序优化

Spark SQL 优化器的启发式规则可以将SELECT语句转换为具有以下特征的查询计划:

  • 过滤操作符和投影操作符被推送到连接操作符下面,也就是说,过滤和投影操作符在连接操作符之前执行。

  • 没有子查询块时,连接操作符被推送到聚合操作符下面,也就是说,连接操作符通常在聚合操作符之前执行。

通过这一观察,我们从 CBO 中可以获得的最大好处是多向连接排序优化。使用动态规划技术,我们尝试为多向连接查询获得全局最优的连接顺序。

有关 Spark 2.2 中多向连接重新排序的更多详细信息,请参阅spark-summit.org/2017/events/cost-based-optimizer-in-apache-spark-22/

显然,连接成本是选择最佳连接顺序的主要因素。成本公式取决于 Spark SQL 执行引擎的实现。

Spark 中的连接成本公式如下:

权重基数+大小(1-权重)

公式中的权重是通过spark.sql.cbo.joinReorder.card.weight参数配置的调整参数(默认值为0.7)。计划的成本是所有中间表的成本之和。请注意,当前的成本公式非常粗糙,预计 Spark 的后续版本将具有更精细的公式。

有关使用动态规划算法重新排序连接的更多详细信息,请参阅 Selinger 等人的论文,网址为citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.129.5879&rep=rep1&type=pdf

首先,我们将所有项目(基本连接节点)放入级别 1,然后从级别 1 的计划(单个项目)构建级别 2 的所有双向连接,然后从先前级别的计划(双向连接和单个项目)构建所有三向连接,然后是四向连接,依此类推,直到我们构建了所有 n 向连接,并在每个阶段选择最佳计划。

在构建 m 路连接时,我们只保留相同 m 个项目集的最佳计划(成本最低)。例如,对于三路连接,我们只保留项目集{A, B, C}的最佳计划,包括(A J B) J C(A J C) J B(B J C) J A

这个算法的一个缺点是假设最低成本的计划只能在其前一级的最低成本计划中生成。此外,由于选择排序合并连接(保留其输入顺序)与其他连接方法的决定是在查询规划阶段完成的,因此我们没有这些信息来在优化器中做出良好的决策。

接下来,我们展示了一个扩展的例子,展示了关闭和打开cbojoinReorder参数后的速度改进:

scala> sql("CREATE TABLE IF NOT EXISTS customers(id long, name string) USING parquet") 
scala> sql("CREATE TABLE IF NOT EXISTS goods(id long, price long) USING parquet") 
scala> sql("CREATE TABLE IF NOT EXISTS orders(customer_id long, good_id long) USING parquet") 

scala> import org.apache.spark.sql.functions.rand 

scala> spark.sql("CREATE TABLE IF NOT EXISTS customers(id long, name string) USING parquet") 
scala> spark.sql("CREATE TABLE IF NOT EXISTS goods(id long, price long) USING parquet") 
scala> spark.sql("CREATE TABLE IF NOT EXISTS orders(customer_id long, good_id long) USING parquet") 

scala> spark.range(2E8.toLong).select('id, 'id cast "string").write.mode("overwrite").insertInto("customers") 

scala> spark.range(1E8.toLong).select('id, (rand(17) * 1E6 + 2) cast "long").write.mode("overwrite").insertInto("goods") 
spark.range(1E7.toLong).select(rand(3) * 2E8 cast "long", (rand(5) * 1E8) cast "long").write.mode("overwrite").insertInto("orders") 

我们定义了一个 benchmark 函数来测量我们查询的执行时间:

scala> def benchmark(name: String)(f: => Unit) { 
     |      val startTime = System.nanoTime 
     |      f 
     |      val endTime = System.nanoTime 
     |      println(s"Time taken with $name: " + (endTime - 
                    startTime).toDouble / 1000000000 + " seconds") 
     | } 

在第一个例子中,如所示,我们关闭了cbojoinReorder参数:


scala> val conf = spark.sessionState.conf 

scala> spark.conf.set("spark.sql.cbo.enabled", false) 

scala> conf.cboEnabled 
res1: Boolean = false 

scala> conf.joinReorderEnabled 
res2: Boolean = false 

scala> benchmark("CBO OFF & JOIN REORDER DISABLED"){ sql("SELECT name FROM customers, orders, goods WHERE customers.id = orders.customer_id AND orders.good_id = goods.id AND goods.price > 1000000").show() } 

以下是在命令行上的输出:

在下一个例子中,我们打开了cbo但保持joinReorder参数禁用:

scala> spark.conf.set("spark.sql.cbo.enabled", true) 
scala> conf.cboEnabled 
res11: Boolean = true 
scala> conf.joinReorderEnabled 
res12: Boolean = false 

scala> benchmark("CBO ON & JOIN REORDER DIABLED"){ sql("SELECT name FROM customers, orders, goods WHERE customers.id = orders.customer_id AND orders.good_id = goods.id AND goods.price > 1000000").show()} 

以下是在命令行上的输出:

请注意,在启用cbo参数的情况下,查询的执行时间略有改善。

在最后一个例子中,我们同时打开了cbojoinReorder参数:

scala> spark.conf.set("spark.sql.cbo.enabled", true) 
scala> spark.conf.set("spark.sql.cbo.joinReorder.enabled", true) 
scala> conf.cboEnabled 
res2: Boolean = true 
scala> conf.joinReorderEnabled 
res3: Boolean = true 

scala> benchmark("CBO ON & JOIN REORDER ENABLED"){ sql("SELECT name FROM customers, orders, goods WHERE customers.id = orders.customer_id AND orders.good_id = goods.id AND goods.price > 1000000").show()} 

以下是在命令行上的输出:

请注意,在启用了这两个参数的情况下,查询的执行时间有了显著的改进。

在接下来的部分中,我们将检查使用整体代码生成实现的各种JOINs的性能改进。

使用整体代码生成理解性能改进

在本节中,我们首先概述了 Spark SQL 中整体代码生成的高级概述,然后通过一系列示例展示了使用 Catalyst 的代码生成功能改进各种JOINs的性能。

在我们有了优化的查询计划之后,需要将其转换为 RDD 的 DAG,以在集群上执行。我们使用这个例子来解释 Spark SQL 整体代码生成的基本概念:

scala> sql("select count(*) from orders where customer_id = 26333955").explain() 

== Optimized Logical Plan == 
Aggregate [count(1) AS count(1)#45L] 
+- Project 
   +- Filter (isnotnull(customer_id#42L) && (customer_id#42L = 
              26333955)) 
      +- Relation[customer_id#42L,good_id#43L] parquet 

优化的逻辑计划可以看作是一系列的扫描过滤投影聚合操作,如下图所示:

传统数据库通常基于 Volcano 迭代器模型执行前面的查询,其中每个操作符实现一个迭代器接口,并从其输入操作符消耗记录,并向其后顺序的操作符输出记录。这个模型使得可以轻松添加新的操作符,而不受其与其他操作符的交互影响。它还促进了操作符的可组合性。然而,Volcano 模型效率低下,因为它涉及执行许多虚拟函数调用,例如,每个记录在Aggregate函数中执行三次调用。此外,它需要大量的内存访问(由于按照迭代器接口在每个操作符中的读/写)。在 Volcano 模型上利用现代 CPU 特性(如流水线处理、预取和分支预测)也是具有挑战性的。

Spark SQL 不是为每个操作符生成迭代器代码,而是尝试为 SQL 语句中的操作符集生成一个单一函数。例如,前面查询的伪代码可能看起来像下面这样。这里,for循环遍历所有行(扫描操作),if 条件大致对应于过滤条件,而聚合本质上是计数:

long count = 0; 
for (customer_id in orders) {  
   if (customer_id == 26333955) { 
         count += 1; 
   } 
} 

请注意,简单的代码中没有虚拟函数调用,而且增加的计数变量存储在 CPU 寄存器中。这段代码易于编译器理解,因此现代硬件可以利用来加速这样的查询。

整个阶段代码生成的关键思想包括将操作符融合在一起,识别操作符链(阶段),并将每个阶段编译成单个函数。这导致生成的代码模仿手写优化代码来执行查询。

有关在现代硬件上编译查询计划的更多详细信息,请参阅www.vldb.org/pvldb/vol4/p539-neumann.pdf

我们可以使用EXPLAIN CODEGEN来探索为查询生成的代码,如下所示:

scala> sql("EXPLAIN CODEGEN SELECT name FROM customers, orders, goods WHERE customers.id = orders.customer_id AND orders.good_id = goods.id AND goods.price > 1000000").take(1).foreach(println) 
[Found 6 WholeStageCodegen subtrees.                                             
== Subtree 1 / 6 == 
*Project [id#11738L] 
+- *Filter ((isnotnull(price#11739L) && (price#11739L > 1000000)) && isnotnull(id#11738L)) 
   +- *FileScan parquet default.goods[id#11738L,price#11739L] Batched: true, Format: Parquet, Location: InMemoryFileIndex[file:/Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7/spark-warehouse..., PartitionFilters: [], PushedFilters: [IsNotNull(price), GreaterThan(price,1000000), IsNotNull(id)], ReadSchema: struct<id:bigint,price:bigint> 

Generated code: 
/* 001 */ public Object generate(Object[] references) { 
/* 002 */   return new GeneratedIterator(references); 
/* 003 */ } 
... 
== Subtree 6 / 6 == 
*Sort [id#11734L ASC NULLS FIRST], false, 0 
+- Exchange hashpartitioning(id#11734L, 200) 
   +- *Project [id#11734L, name#11735] 
      +- *Filter isnotnull(id#11734L) 
         +- *FileScan parquet default.customers[id#11734L,name#11735] Batched: true, Format: Parquet, Location: InMemoryFileIndex[file:/Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7/spark-warehouse..., PartitionFilters: [], PushedFilters: [IsNotNull(id)], ReadSchema: struct<id:bigint,name:string> 

Generated code: 
/* 001 */ public Object generate(Object[] references) { 
/* 002 */   return new GeneratedIterator(references); 
/* 003 */ } 
... 
] 

在这里,我们提供了一系列使用关闭和随后打开整个阶段代码生成的JOIN示例,以查看对执行性能的显着影响。

本节中的示例取自github.com/apache/spark/blob/master/sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/JoinBenchmark.scala中可用的JoinBenchmark.scala类。

在以下示例中,我们介绍了获取使用长值进行 JOIN 操作的执行时间的详细信息:

scala> spark.conf.set("spark.sql.codegen.wholeStage", false) 

scala> conf.wholeStageEnabled 
res77: Boolean = false 

scala> val N = 20 << 20 
N: Int = 20971520 

scala> val M = 1 << 16 
M: Int = 65536 

scala> val dim = broadcast(spark.range(M).selectExpr("id as k", "cast(id as string) as v")) 

scala> benchmark("Join w long") { 
     |   spark.range(N).join(dim, (col("id") % M) === col("k")).count() 
     | } 
Time taken in Join w long: 2.612163207 seconds                                   

scala> spark.conf.set("spark.sql.codegen.wholeStage", true) 

scala> conf.wholeStageEnabled 
res80: Boolean = true 

scala> val dim = broadcast(spark.range(M).selectExpr("id as k", "cast(id as string) as v")) 

scala> benchmark("Join w long") { 
     |   spark.range(N).join(dim, (col("id") % M) === col("k")).count() 
     | } 
Time taken in Join w long: 0.777796256 seconds 

对于以下一组示例,我们仅呈现获取其执行时间的基本要素,包括是否使用整个阶段代码生成。请参考前面的示例,并按照相同的步骤顺序复制以下示例:

scala> val dim = broadcast(spark.range(M).selectExpr("id as k", "cast(id as string) as v")) 
scala> benchmark("Join w long duplicated") { 
     |     val dim = broadcast(spark.range(M).selectExpr("cast(id/10 as long) as k")) 
     |     spark.range(N).join(dim, (col("id") % M) === col("k")).count() 
     | } 
Time taken in Join w long duplicated: 1.514799811 seconds           
Time taken in Join w long duplicated: 0.278705816 seconds 

scala> val dim3 = broadcast(spark.range(M).selectExpr("id as k1", "id as k2", "cast(id as string) as v")) 
scala> benchmark("Join w 2 longs") { 
     |     spark.range(N).join(dim3, (col("id") % M) === col("k1") && (col("id") % M) === col("k2")).count() 
     | } 
Time taken in Join w 2 longs: 2.048950962 seconds       
Time taken in Join w 2 longs: 0.681936701 seconds 

scala> val dim4 = broadcast(spark.range(M).selectExpr("cast(id/10 as long) as k1", "cast(id/10 as long) as k2")) 
scala> benchmark("Join w 2 longs duplicated") { 
     |     spark.range(N).join(dim4, (col("id") bitwiseAND M) === col("k1") && (col("id") bitwiseAND M) === col("k2")).count() 
     | } 
Time taken in Join w 2 longs duplicated: 4.924196601 seconds      
Time taken in Join w 2 longs duplicated: 0.818748429 seconds      

scala> val dim = broadcast(spark.range(M).selectExpr("id as k", "cast(id as string) as v")) 
scala> benchmark("outer join w long") { 
     |     spark.range(N).join(dim, (col("id") % M) === col("k"), "left").count() 
     | } 
Time taken in outer join w long: 1.580664228 seconds        
Time taken in outer join w long: 0.280608235 seconds 

scala> val dim = broadcast(spark.range(M).selectExpr("id as k", "cast(id as string) as v")) 
scala> benchmark("semi join w long") { 
     |     spark.range(N).join(dim, (col("id") % M) === col("k"), "leftsemi").count() 
     | } 
Time taken in semi join w long: 1.027175143 seconds             
Time taken in semi join w long: 0.180771478 seconds 

scala> val N = 2 << 20 
N: Int = 2097152 
scala> benchmark("merge join") { 
     |     val df1 = spark.range(N).selectExpr(s"id * 2 as k1") 
     |     val df2 = spark.range(N).selectExpr(s"id * 3 as k2") 
     |     df1.join(df2, col("k1") === col("k2")).count() 
     | } 
Time taken in merge join: 2.260524298 seconds          
Time taken in merge join: 2.053497825 seconds             

scala> val N = 2 << 20 
N: Int = 2097152 
scala> benchmark("sort merge join") { 
     |     val df1 = spark.range(N).selectExpr(s"(id * 15485863) % ${N*10} as k1") 
     |     val df2 = spark.range(N).selectExpr(s"(id * 15485867) % ${N*10} as k2") 
     |     df1.join(df2, col("k1") === col("k2")).count() 
     | } 
Time taken in sort merge join: 2.481585466 seconds                
Time taken in sort merge join: 1.992168281 seconds                

作为练习,请使用本节中的示例来探索它们的逻辑和物理计划,并使用 SparkUI 查看和理解它们的执行。

在调整任务中使用了几个 Spark SQL 参数设置。SQLConf是 Spark SQL 中用于参数和提示的内部键值配置存储。要打印出这些参数的所有当前值,请使用以下语句:

scala> conf.getAllConfs.foreach(println) 
(spark.driver.host,192.168.1.103) 
(spark.sql.autoBroadcastJoinThreshold,1000000) 
(spark.driver.port,57085) 
(spark.repl.class.uri,spark://192.168.1.103:57085/classes) 
(spark.jars,) 
(spark.repl.class.outputDir,/private/var/folders/tj/prwqrjj16jn4k5jh6g91rwtc0000gn/T/spark-9f8b5ba4-e8f4-4c60-b01b-30c4b71a06e1/repl-ae75dedc-703a-41b8-b949-b91ed3b362f1) 
(spark.app.name,Spark shell) 
(spark.driver.memory,14g) 
(spark.sql.codegen.wholeStage,true) 
(spark.executor.id,driver) 
(spark.sql.cbo.enabled,true) 
(spark.sql.join.preferSortMergeJoin,false) 
(spark.submit.deployMode,client) 
(spark.master,local[*]) 
(spark.home,/Users/aurobindosarkar/Downloads/spark-2.2.0-bin-hadoop2.7) 
(spark.sql.catalogImplementation,hive) 
(spark.app.id,local-1499953390374) 
(spark.sql.shuffle.partitions,2) 

您还可以使用以下语句列出所有已定义配置参数的扩展集:

scala> conf.getAllDefinedConfs.foreach(println) 

摘要

在本章中,我们介绍了与调整 Spark 应用程序相关的基本概念,包括使用编码器进行数据序列化。我们还介绍了在 Spark 2.2 中引入的基于成本的优化器的关键方面,以自动优化 Spark SQL 执行。最后,我们提供了一些JOIN操作的示例,以及使用整个阶段代码生成导致执行时间改进的情况。

在下一章中,我们将探讨利用 Spark 模块和 Spark SQL 的应用程序架构在实际应用中的应用。我们还将描述用于批处理、流处理应用和机器学习流水线的一些主要处理模型的部署。

第十二章:Spark SQL 在大规模应用程序架构中的应用

在本书中,我们从 Spark SQL 及其组件的基础知识开始,以及它在 Spark 应用程序中的作用。随后,我们提出了一系列关于其在各种类型应用程序中的使用的章节。作为 Spark SQL 的核心,DataFrame/Dataset API 和 Catalyst 优化器在所有基于 Spark 技术栈的应用程序中发挥关键作用,这并不奇怪。这些应用程序包括大规模机器学习、大规模图形和深度学习应用程序。此外,我们提出了基于 Spark SQL 的结构化流应用程序,这些应用程序作为连续应用程序在复杂环境中运行。在本章中,我们将探讨在现实世界应用程序中利用 Spark 模块和 Spark SQL 的应用程序架构。

更具体地,我们将涵盖大规模应用程序中的关键架构组件和模式,这些对架构师和设计师来说将作为特定用例的起点。我们将描述一些用于批处理、流处理应用程序和机器学习管道的主要处理模型的部署。这些处理模型的基础架构需要支持在一端到达高速的各种类型数据的大量数据,同时在另一端使输出数据可供分析工具、报告和建模软件使用。此外,我们将使用 Spark SQL 提供支持代码,用于监控、故障排除和收集/报告指标。

我们将在本章中涵盖以下主题:

  • 理解基于 Spark 的批处理和流处理架构

  • 理解 Lambda 和 Kappa 架构

  • 使用结构化流实现可扩展的流处理

  • 使用 Spark SQL 构建强大的 ETL 管道

  • 使用 Spark SQL 实现可扩展的监控解决方案

  • 部署 Spark 机器学习管道

  • 使用集群管理器:Mesos 和 Kubernetes

理解基于 Spark 的应用程序架构

Apache Spark 是一个新兴的平台,利用分布式存储和处理框架来支持规模化的查询、报告、分析和智能应用。Spark SQL 具有必要的功能,并支持所需的关键机制,以访问各种数据源和格式的数据,并为下游应用程序做准备,无论是低延迟的流数据还是高吞吐量的历史数据存储。下图显示了典型的基于 Spark 的批处理和流处理应用程序中包含这些要求的高级架构:

此外,随着组织开始在许多项目中采用大数据和 NoSQL 解决方案,仅由 RDBMS 组成的数据层不再被认为是现代企业应用程序所有用例的最佳选择。仅基于 RDBMS 的架构在下图所示的行业中迅速消失,以满足典型大数据应用程序的要求:

下图显示了一个更典型的场景,其中包含多种类型的数据存储。如今的应用程序使用多种数据存储类型,这些类型最适合特定的用例。根据应用程序使用数据的方式选择多种数据存储技术,称为多语言持久性。Spark SQL 在云端或本地部署中是这种和其他类似持久性策略的极好的实现者:

此外,我们观察到,现实世界中只有一小部分 ML 系统由 ML 代码组成(下图中最小的方框)。然而,围绕这些 ML 代码的基础设施是庞大且复杂的。在本章的后面,我们将使用 Spark SQL 来创建这些应用程序中的一些关键部分,包括可扩展的 ETL 管道和监控解决方案。随后,我们还将讨论机器学习管道的生产部署,以及使用 Mesos 和 Kubernetes 等集群管理器:

参考:“机器学习系统中的隐藏技术债务”,Google NIPS 2015

在下一节中,我们将讨论基于 Spark 的批处理和流处理架构中的关键概念和挑战。

使用 Apache Spark 进行批处理

通常,批处理是针对大量数据进行的,以创建批量视图,以支持特定查询和 MIS 报告功能,和/或应用可扩展的机器学习算法,如分类、聚类、协同过滤和分析应用。

由于批处理涉及的数据量较大,这些应用通常是长时间运行的作业,并且很容易延长到几个小时、几天或几周,例如,聚合查询,如每日访问者数量、网站的独立访问者和每周总销售额。

越来越多的人开始将 Apache Spark 作为大规模数据处理的引擎。它可以在内存中运行程序,比 Hadoop MapReduce 快 100 倍,或者在磁盘上快 10 倍。Spark 被迅速采用的一个重要原因是,它需要相似的编码来满足批处理和流处理的需求。

在下一节中,我们将介绍流处理的关键特征和概念。

使用 Apache Spark 进行流处理

大多数现代企业都在努力处理大量数据(以及相关数据的快速和无限增长),同时还需要低延迟的处理需求。此外,与传统的批处理 MIS 报告相比,从实时流数据中获得的近实时业务洞察力被赋予了更高的价值。与流处理系统相反,传统的批处理系统旨在处理一组有界数据的大量数据。这些系统在执行开始时就提供了它们所需的所有数据。随着输入数据的不断增长,这些批处理系统提供的结果很快就会过时。

通常,在流处理中,数据在触发所需处理之前不会在显著的时间段内收集。通常,传入的数据被移动到排队系统,例如 Apache Kafka 或 Amazon Kinesis。然后,流处理器访问这些数据,并对其执行某些计算以生成结果输出。典型的流处理管道创建增量视图,这些视图通常根据流入系统的增量数据进行更新。

增量视图通过Serving Layer提供,以支持查询和实时分析需求,如下图所示:

在流处理系统中有两种重要的时间类型:事件时间和处理时间。事件时间是事件实际发生的时间(在源头),而处理时间是事件在处理系统中被观察到的时间。事件时间通常嵌入在数据本身中,对于许多用例来说,这是您想要操作的时间。然而,从数据中提取事件时间,并处理延迟或乱序数据在流处理应用程序中可能会带来重大挑战。此外,由于资源限制、分布式处理模型等原因,事件时间和处理时间之间存在偏差。有许多用例需要按事件时间进行聚合;例如,在一个小时的窗口中系统错误的数量。

还可能存在其他问题;例如,在窗口功能中,我们需要确定是否已观察到给定事件时间的所有数据。这些系统需要设计成能够在不确定的环境中良好运行。例如,在 Spark 结构化流处理中,可以为数据流一致地定义基于事件时间的窗口聚合查询,因为它可以处理延迟到达的数据,并适当更新旧的聚合。

在处理大数据流应用程序时,容错性至关重要,例如,一个流处理作业可以统计到目前为止看到的所有元组的数量。在这里,每个元组可能代表用户活动的流,应用程序可能希望报告到目前为止看到的总活动。在这样的系统中,节点故障可能导致计数不准确,因为有未处理的元组(在失败的节点上)。

从这种情况中恢复的一个天真的方法是重新播放整个数据集。考虑到涉及的数据规模,这是一个昂贵的操作。检查点是一种常用的技术,用于避免重新处理整个数据集。在发生故障的情况下,应用程序数据状态将恢复到最后一个检查点,并且从那一点开始重新播放元组。为了防止 Spark Streaming 应用程序中的数据丢失,使用了预写式日志WAL),在故障后可以从中重新播放数据。

在下一节中,我们将介绍 Lambda 架构,这是在 Spark 中心应用程序中实施的一种流行模式,因为它可以使用非常相似的代码满足批处理和流处理的要求。

理解 Lambda 架构

Lambda 架构模式试图结合批处理和流处理的优点。该模式由几个层组成:批处理层(在持久存储上摄取和处理数据,如 HDFS 和 S3),速度层(摄取和处理尚未被批处理层处理的流数据),以及服务层(将批处理速度层的输出合并以呈现合并结果)。这是 Spark 环境中非常流行的架构,因为它可以支持批处理速度层的实现,两者之间的代码差异很小。

给定的图表描述了 Lambda 架构作为批处理和流处理的组合:

下图显示了使用 AWS 云服务(Amazon KinesisAmazon S3存储,Amazon EMRAmazon DynamoDB等)和 Spark 实现 Lambda 架构:

有关 AWS 实施 Lambda 架构的更多详细信息,请参阅d0.awsstatic.com/whitepapers/lambda-architecure-on-for-batch-aws.pdf

在下一节中,我们将讨论一个更简单的架构,称为 Kappa 架构,它完全放弃了批处理层,只在速度层中进行流处理。

理解 Kappa 架构

Kappa 架构比 Lambda 模式更简单,因为它只包括速度层和服务层。所有计算都作为流处理进行,不会对完整数据集进行批量重新计算。重新计算仅用于支持更改和新需求。

通常,传入的实时数据流在内存中进行处理,并持久化在数据库或 HDFS 中以支持查询,如下图所示:

Kappa 架构可以通过使用 Apache Spark 结合排队解决方案(如 Apache Kafka)来实现。如果数据保留时间限制在几天到几周,那么 Kafka 也可以用来保留数据一段有限的时间。

在接下来的几节中,我们将介绍一些使用 Apache Spark、Scala 和 Apache Kafka 的实际应用开发环境中非常有用的实践练习。我们将首先使用 Spark SQL 和结构化流来实现一些流式使用案例。

构建可扩展流处理应用的设计考虑

构建健壮的流处理应用是具有挑战性的。与流处理相关的典型复杂性包括以下内容:

  • 复杂数据:多样化的数据格式和数据质量在流应用中带来了重大挑战。通常,数据以各种格式可用,如 JSON、CSV、AVRO 和二进制。此外,脏数据、延迟到达和乱序数据会使这类应用的设计变得极其复杂。

  • 复杂工作负载:流应用需要支持多样化的应用需求,包括交互式查询、机器学习流水线等。

  • 复杂系统:具有包括 Kafka、S3、Kinesis 等多样化存储系统,系统故障可能导致重大的重新处理或错误结果。

使用 Spark SQL 进行流处理可以快速、可扩展和容错。它提供了一套高级 API 来处理复杂数据和工作负载。例如,数据源 API 可以与许多存储系统和数据格式集成。

有关构建可扩展和容错的结构化流处理应用的详细覆盖范围,请参阅spark-summit.org/2017/events/easy-scalable-fault-tolerant-stream-processing-with-structured-streaming-in-apache-spark/

流查询允许我们指定一个或多个数据源,使用 DataFrame/Dataset API 或 SQL 转换数据,并指定各种接收器来输出结果。内置支持多种数据源,如文件、Kafka 和套接字,如果需要,还可以组合多个数据源。

Spark SQL Catalyst 优化器可以找出增量执行转换的机制。查询被转换为一系列对新数据批次进行操作的增量执行计划。接收器接受每个批次的输出,并在事务上下文中完成更新。您还可以指定各种输出模式(完整更新追加)和触发器来控制何时输出结果。如果未指定触发器,则结果将持续更新。通过持久化检查点来管理给定查询的进度和故障后的重启。

选择适当的数据格式

有关结构化流内部的详细说明,请查看spark.apache.org/docs/latest/structured-streaming-programming-guide.html

Spark 结构化流使得流式分析变得简单,无需担心使流式工作的复杂底层机制。在这个模型中,输入可以被视为来自一个不断增长的追加表的数据。触发器指定了检查输入是否到达新数据的时间间隔,查询表示对输入进行的操作,如映射、过滤和减少。结果表示在每个触发间隔中更新的最终表(根据指定的查询操作)。

在下一节中,我们将讨论 Spark SQL 功能,这些功能可以帮助构建强大的 ETL 管道。

使用 Spark SQL 构建强大的 ETL 管道

ETL 管道在源数据上执行一系列转换,以生成经过清洗、结构化并准备好供后续处理组件使用的输出。需要应用在源数据上的转换将取决于数据的性质。输入或源数据可以是结构化的(关系型数据库,Parquet 等),半结构化的(CSV,JSON 等)或非结构化数据(文本,音频,视频等)。通过这样的管道处理后,数据就可以用于下游数据处理、建模、分析、报告等。

下图说明了一个应用架构,其中来自 Kafka 和其他来源(如应用程序和服务器日志)的输入数据在存储到企业数据存储之前经过清洗和转换(使用 ETL 管道)。这个数据存储最终可以供其他应用程序使用(通过 Kafka),支持交互式查询,将数据的子集或视图存储在服务数据库中,训练 ML 模型,支持报告应用程序等。

在下一节中,我们将介绍一些标准,可以帮助您选择适当的数据格式,以满足特定用例的要求。

正如缩写(ETL)所示,我们需要从各种来源检索数据(提取),转换数据以供下游使用(转换),并将其传输到不同的目的地(加载)。

在接下来的几节中,我们将使用 Spark SQL 功能来访问和处理各种数据源和数据格式,以实现 ETL 的目的。Spark SQL 灵活的 API,结合 Catalyst 优化器和 tungsten 执行引擎,使其非常适合构建端到端的 ETL 管道。

在下面的代码块中,我们提供了一个简单的单个 ETL 查询的框架,结合了所有三个(提取、转换和加载)功能。这些查询也可以扩展到执行包含来自多个来源和来源格式的数据的表之间的复杂连接:

spark.read.json("/source/path") //Extract
.filter(...) //Transform
.agg(...) //Transform
.write.mode("append") .parquet("/output/path") //Load

我们还可以对流数据执行滑动窗口操作。在这里,我们定义了对滑动窗口的聚合,其中我们对数据进行分组并计算适当的聚合(对于每个组)。

在企业设置中,数据以许多不同的数据源和格式可用。Spark SQL 支持一组内置和第三方连接器。此外,我们还可以定义自定义数据源连接器。数据格式包括结构化、半结构化和非结构化格式,如纯文本、JSON、XML、CSV、关系型数据库记录、图像和视频。最近,Parquet、ORC 和 Avro 等大数据格式变得越来越受欢迎。一般来说,纯文本文件等非结构化格式更灵活,而 Parquet 和 AVRO 等结构化格式在存储和性能方面更有效率。

在结构化数据格式的情况下,数据具有严格的、明确定义的模式或结构。例如,列式数据格式使得从列中提取值更加高效。然而,这种严格性可能会使对模式或结构的更改变得具有挑战性。相比之下,非结构化数据源,如自由格式文本,不包含 CSV 或 TSV 文件中的标记或分隔符。这样的数据源通常需要一些关于数据的上下文;例如,你需要知道文件的内容包含来自博客的文本。

通常,我们需要许多转换和特征提取技术来解释不同的数据集。半结构化数据在记录级别上是结构化的,但不一定在所有记录上都是结构化的。因此,每个数据记录都包含相关的模式信息。

JSON 格式可能是半结构化数据最常见的例子。JSON 记录以人类可读的形式呈现,这对于开发和调试来说更加方便。然而,这些格式受到解析相关的开销的影响,通常不是支持特定查询功能的最佳选择。

通常,应用程序需要设计成能够跨越各种数据源和格式高效存储和处理数据。例如,当需要访问完整的数据行时,Avro 是一个很好的选择,就像在 ML 管道中访问特征的情况一样。在需要模式的灵活性的情况下,使用 JSON 可能是数据格式的最合适选择。此外,在数据没有固定模式的情况下,最好使用纯文本文件格式。

ETL 管道中的数据转换

通常,诸如 JSON 之类的半结构化格式包含 struct、map 和 array 数据类型;例如,REST Web 服务的请求和/或响应负载包含具有嵌套字段和数组的 JSON 数据。

在这一部分,我们将展示基于 Spark SQL 的 Twitter 数据转换的示例。输入数据集是一个文件(cache-0.json.gz),其中包含了在 2012 年美国总统选举前三个月内收集的超过1.7 亿条推文中的1 千万条推文。这个文件可以从datahub.io/dataset/twitter-2012-presidential-election下载。

在开始以下示例之前,按照第五章中描述的方式启动 Zookeeper 和 Kafka 代理。另外,创建一个名为 tweetsa 的新 Kafka 主题。我们从输入 JSON 数据集生成模式,如下所示。这个模式定义将在本节后面使用:

scala> val jsonDF = spark.read.json("file:///Users/aurobindosarkar/Downloads/cache-0-json")

scala> jsonDF.printSchema()

scala> val rawTweetsSchema = jsonDF.schema

scala> val jsonString = rawTweetsSchema.json

scala> val schema = DataType.fromJson(jsonString).asInstanceOf[StructType]

设置从 Kafka 主题(tweetsa)中读取流式推文,并使用上一步的模式解析 JSON 数据。

在这个声明中,我们通过指定数据.*来选择推文中的所有字段:

scala> val rawTweets = spark.readStream.format("kafka").option("kafka.bootstrap.servers", "localhost:9092").option("subscribe", "tweetsa").load()

scala> val parsedTweets = rawTweets.selectExpr("cast (value as string) as json").select(from_json($"json", schema).as("data")).select("data.*")

在你通过示例工作时,你需要反复使用以下命令将输入文件中包含的推文传输到 Kafka 主题中,如下所示:

Aurobindos-MacBook-Pro-2:kafka_2.11-0.10.2.1 aurobindosarkar$ bin/kafka-console-producer.sh --broker-list localhost:9092 --topic tweetsa < /Users/aurobindosarkar/Downloads/cache-0-json

考虑到输入文件的大小,这可能会导致您的计算机出现空间相关的问题。如果发生这种情况,请使用适当的 Kafka 命令来删除并重新创建主题(参考kafka.apache.org/0102/documentation.html)。

在这里,我们重现了一个模式的部分,以帮助理解我们在接下来的几个示例中要处理的结构:

我们可以从 JSON 字符串中的嵌套列中选择特定字段。我们使用.(点)运算符来选择嵌套字段,如下所示:

scala> val selectFields = parsedTweets.select("place.country").where($"place.country".isNotNull)

接下来,我们将输出流写入屏幕以查看结果。您需要在每个转换之后执行以下语句,以查看和评估结果。此外,为了节省时间,您应该在看到足够的屏幕输出后执行s5.stop()。或者,您可以选择使用从原始输入文件中提取的较小数据集进行工作:

scala> val s5 = selectFields.writeStream.outputMode("append").format("console").start()

在下一个示例中,我们将使用星号(*)展平一个 struct 以选择 struct 中的所有子字段:

scala> val selectFields = parsedTweets.select("place.*").where($"place.country".isNotNull)

可以通过编写输出流来查看结果,如前面的示例所示:

我们可以使用 struct 函数创建一个新的 struct(用于嵌套列),如下面的代码片段所示。我们可以选择特定字段或字段来创建新的 struct。如果需要,我们还可以使用星号(*)嵌套所有列。

在这里,我们重现了此示例中使用的模式部分:

scala> val selectFields = parsedTweets.select(struct("place.country_code", "place.name") as 'locationInfo).where($"locationInfo.country_code".isNotNull)

在下一个示例中,我们使用getItem()选择单个数组(或映射)元素。在这里,我们正在操作模式的以下部分:

scala> val selectFields = parsedTweets.select($"entities.hashtags" as 'tags).select('tags.getItem(0) as 'x).select($"x.indices" as 'y).select($"y".getItem(0) as 'z).where($"z".isNotNull)

scala> val selectFields = parsedTweets.select($"entities.hashtags" as 'tags).select('tags.getItem(0) as 'x).select($"x.text" as 'y).where($"y".isNotNull)

我们可以使用explode()函数为数组中的每个元素创建新行,如所示。为了说明explode()的结果,我们首先展示包含数组的行,然后展示应用 explode 函数的结果:

scala> val selectFields = parsedTweets.select($"entities.hashtags.indices" as 'tags).select(explode('tags))

获得以下输出:

请注意,在应用 explode 函数后,为数组元素创建了单独的行:

scala> val selectFields = parsedTweets.select($"entities.hashtags.indices".getItem(0) as 'tags).select(explode('tags))

获得的输出如下:

Spark SQL 还具有诸如to_json()之类的函数,用于将struct转换为 JSON 字符串,以及from_json(),用于将 JSON 字符串转换为struct。这些函数对于从 Kafka 主题读取或写入非常有用。例如,如果“value”字段包含 JSON 字符串中的数据,则我们可以使用from_json()函数提取数据,转换数据,然后将其推送到不同的 Kafka 主题,并/或将其写入 Parquet 文件或服务数据库。

在以下示例中,我们使用to_json()函数将 struct 转换为 JSON 字符串:

scala> val selectFields = parsedTweets.select(struct($"entities.media.type" as 'x, $"entities.media.url" as 'y) as 'z).where($"z.x".isNotNull).select(to_json('z) as 'c)

我们可以使用from_json()函数将包含 JSON 数据的列转换为struct数据类型。此外,我们可以将前述结构展平为单独的列。我们在后面的部分中展示了使用此函数的示例。

有关转换函数的更详细覆盖范围,请参阅databricks.com/blog/2017/02/23/working-complex-data-formats-structured-streaming-apache-spark-2-1.html

解决 ETL 管道中的错误

ETL 任务通常被认为是复杂、昂贵、缓慢和容易出错的。在这里,我们将研究 ETL 过程中的典型挑战,以及 Spark SQL 功能如何帮助解决这些挑战。

Spark 可以自动从 JSON 文件中推断模式。例如,对于以下 JSON 数据,推断的模式包括基于内容的所有标签和数据类型。在这里,输入数据中所有元素的数据类型默认为长整型:

test1.json

{"a":1, "b":2, "c":3}
{"a":2, "d":5, "e":3}
{"d":1, "c":4, "f":6}
{"a":7, "b":8}
{"c":5, "e":4, "d":3}
{"f":3, "e":3, "d":4}
{"a":1, "b":2, "c":3, "f":3, "e":3, "d":4}

您可以打印模式以验证数据类型,如下所示:

scala> spark.read.json("file:///Users/aurobindosarkar/Downloads/test1.json").printSchema()
root
|-- a: long (nullable = true)
|-- b: long (nullable = true)
|-- c: long (nullable = true)
|-- d: long (nullable = true)
|-- e: long (nullable = true)
|-- f: long (nullable = true)

然而,在以下 JSON 数据中,如果第三行中的e的值和最后一行中的b的值被更改以包含分数,并且倒数第二行中的f的值被包含在引号中,那么推断的模式将更改be的数据类型为 double,f的数据类型为字符串:

{"a":1, "b":2, "c":3}
{"a":2, "d":5, "e":3}
{"d":1, "c":4, "f":6}
{"a":7, "b":8}
{"c":5, "e":4.5, "d":3}
{"f":"3", "e":3, "d":4}
{"a":1, "b":2.1, "c":3, "f":3, "e":3, "d":4}

scala> spark.read.json("file:///Users/aurobindosarkar/Downloads/test1.json").printSchema()
root
|-- a: long (nullable = true)
|-- b: double (nullable = true)
|-- c: long (nullable = true)
|-- d: long (nullable = true)
|-- e: double (nullable = true)
|-- f: string (nullable = true)

如果我们想要将特定结构或数据类型与元素关联起来,我们需要使用用户指定的模式。在下一个示例中,我们使用包含字段名称的标题的 CSV 文件。模式中的字段名称来自标题,并且用户定义的模式中指定的数据类型将用于它们,如下所示:

a,b,c,d,e,f
1,2,3,,,
2,,,5,3,
,,4,1,,,6
7,8,,,,f
,,5,3,4.5,
,,,4,3,"3"
1,2.1,3,3,3,4

scala> val schema = new StructType().add("a", "int").add("b", "double")

scala> spark.read.option("header", true).schema(schema).csv("file:///Users/aurobindosarkar/Downloads/test1.csv").show()

获取以下输出:

由于文件和数据损坏,ETL 管道中也可能出现问题。如果数据不是关键任务,并且损坏的文件可以安全地忽略,我们可以设置config property spark.sql.files.ignoreCorruptFiles = true。此设置允许 Spark 作业继续运行,即使遇到损坏的文件。请注意,成功读取的内容将继续返回。

在下一个示例中,第 4 行的b存在错误数据。我们仍然可以使用PERMISSIVE模式读取数据。在这种情况下,DataFrame 中会添加一个名为_corrupt_record的新列,并且损坏行的内容将出现在该列中,其余字段初始化为 null。我们可以通过查看该列中的数据来关注数据问题,并采取适当的措施来修复它们。通过设置spark.sql.columnNameOfCorruptRecord属性,我们可以配置损坏内容列的默认名称:

{"a":1, "b":2, "c":3}
{"a":2, "d":5, "e":3}
{"d":1, "c":4, "f":6}
{"a":7, "b":{}
{"c":5, "e":4.5, "d":3}
{"f":"3", "e":3, "d":4}
{"a":1, "b":2.1, "c":3, "f":3, "e":3, "d":4}

scala> spark.read.option("mode", "PERMISSIVE").option("columnNameOfCorruptRecord", "_corrupt_record").json("file:///Users/aurobindosarkar/Downloads/test1.json").show()

现在,我们使用DROPMALFORMED选项来删除所有格式不正确的记录。在这里,由于b的坏值,第四行被删除:

scala> spark.read.option("mode", "DROPMALFORMED").json("file:///Users/aurobindosarkar/Downloads/test1.json").show()

对于关键数据,我们可以使用FAILFAST选项,在遇到坏记录时立即失败。例如,在以下示例中,由于第四行中b的值,操作会抛出异常并立即退出:

{"a":1, "b":2, "c":3}
{"a":2, "d":5, "e":3}
{"d":1, "c":4, "f":6}
{"a":7, "b":$}
{"c":5, "e":4.5, "d":3}
{"f":"3", "e":3, "d":4}
{"a":1, "b":2.1, "c":3, "f":3, "e":3, "d":4}

scala> spark.read.option("mode", "FAILFAST").json("file:///Users/aurobindosarkar/Downloads/test1.json").show()

在下一个示例中,我们有一条跨越两行的记录;我们可以通过将wholeFile选项设置为 true 来读取此记录:

{"a":{"a1":2, "a2":8},
"b":5, "c":3}

scala> spark.read.option("wholeFile",true).option("mode", "PERMISSIVE").option("columnNameOfCorruptRecord", "_corrupt_record").json("file:///Users/aurobindosarkar/Downloads/testMultiLine.json").show()
+-----+---+---+
|    a|  b|  c|
+-----+---+---+
|[2,8]|  5|  3|
+-----+---+---+

有关基于 Spark SQL 的 ETL 管道和路线图的更多详细信息,请访问spark-summit.org/2017/events/building-robust-etl-pipelines-with-apache-spark/

上述参考介绍了几个高阶 SQL 转换函数,DataframeWriter API 的新格式以及 Spark 2.2 和 2.3-Snapshot 中的统一Create Table(作为Select)构造。

Spark SQL 解决的其他要求包括可扩展性和使用结构化流进行持续 ETL。我们可以使用结构化流来使原始数据尽快可用作结构化数据,以进行分析、报告和决策,而不是产生通常与运行周期性批处理作业相关的几小时延迟。这种处理在应用程序中尤为重要,例如异常检测、欺诈检测等,时间至关重要。

在下一节中,我们将把重点转移到使用 Spark SQL 构建可扩展的监控解决方案。

实施可扩展的监控解决方案

为大规模部署构建可扩展的监控功能可能具有挑战性,因为每天可能捕获数十亿个数据点。此外,日志的数量和指标的数量可能难以管理,如果没有适当的具有流式处理和可视化支持的大数据平台。

从应用程序、服务器、网络设备等收集的大量日志被处理,以提供实时监控,帮助检测错误、警告、故障和其他问题。通常,各种守护程序、服务和工具用于收集/发送日志记录到监控系统。例如,以 JSON 格式的日志条目可以发送到 Kafka 队列或 Amazon Kinesis。然后,这些 JSON 记录可以存储在 S3 上作为文件和/或流式传输以实时分析(在 Lambda 架构实现中)。通常,会运行 ETL 管道来清理日志数据,将其转换为更结构化的形式,然后加载到 Parquet 文件或数据库中,以进行查询、警报和报告。

下图说明了一个使用Spark Streaming Jobs可扩展的时间序列数据库(如 OpenTSDB 或 Graphite)和可视化工具(如 Grafana)的平台:

有关此解决方案的更多详细信息,请参阅spark-summit.org/2017/events/scalable-monitoring-using-apache-spark-and-friends/

在由多个具有不同配置和版本、运行不同类型工作负载的 Spark 集群组成的大型分布式环境中,监控和故障排除问题是具有挑战性的任务。在这些环境中,可能会收到数十万条指标。此外,每秒生成数百 MB 的日志。这些指标需要被跟踪,日志需要被分析以发现异常、故障、错误、环境问题等,以支持警报和故障排除功能。

下图说明了一个基于 AWS 的数据管道,将所有指标和日志(结构化和非结构化)推送到 Kinesis。结构化流作业可以从 Kinesis 读取原始日志,并将数据保存为 S3 上的 Parquet 文件。

结构化流查询可以剥离已知的错误模式,并在观察到新的错误类型时提出适当的警报。其他 Spark 批处理和流处理应用程序可以使用这些 Parquet 文件进行额外处理,并将其结果输出为 S3 上的新 Parquet 文件:

在这种架构中,可能需要从非结构化日志中发现问题,以确定其范围、持续时间和影响。原始日志通常包含许多近似重复的错误消息。为了有效处理这些日志,我们需要对其进行规范化、去重和过滤已知的错误条件,以发现和揭示新的错误。

有关处理原始日志的管道的详细信息,请参阅spark-summit.org/2017/events/lessons-learned-from-managing-thousands-of-production-apache-spark-clusters-daily/

在本节中,我们将探讨 Spark SQL 和结构化流提供的一些功能,以创建可扩展的监控解决方案。

首先,使用 Kafka 包启动 Spark shell:

Aurobindos-MacBook-Pro-2:spark-2.2.0-bin-hadoop2.7 aurobindosarkar$ ./bin/spark-shell --packages org.apache.spark:spark-streaming-kafka-0-10_2.11:2.1.1,org.apache.spark:spark-sql-kafka-0-10_2.11:2.1.1 --driver-memory 12g

下载 1995 年 7 月的痕迹,其中包含了对佛罗里达州 NASA 肯尼迪航天中心 WWW 服务器的 HTTP 请求ita.ee.lbl.gov/html/contrib/NASA-HTTP.html

在本章的实践练习中,导入以下包:

scala> import org.apache.spark.sql.types._
scala> import org.apache.spark.sql.functions._
scala> import spark.implicits._
scala> import org.apache.spark.sql.streaming._

接下来,为文件中的记录定义模式:

scala> val schema = new StructType().add("clientIpAddress", "string").add("rfc1413ClientIdentity", "string").add("remoteUser", "string").add("dateTime", "string").add("zone", "string").add("request","string").add("httpStatusCode", "string").add("bytesSent", "string").add("referer", "string").add("userAgent", "string")

为简单起见,我们将输入文件读取为以空格分隔的 CSV 文件,如下所示:

scala> val rawRecords = spark.readStream.option("header", false).schema(schema).option("sep", " ").format("csv").load("file:///Users/aurobindosarkar/Downloads/NASA")

scala> val ts = unix_timestamp(concat($"dateTime", lit(" "), $"zone"), "[dd/MMM/yyyy:HH:mm:ss Z]").cast("timestamp")

接下来,我们创建一个包含日志事件的 DataFrame。由于时间戳在前面的步骤中更改为本地时区(默认情况下),我们还在original_dateTime列中保留了带有时区信息的原始时间戳,如下所示:

scala> val logEvents = rawRecords.withColumn("ts", ts).withColumn("date", ts.cast(DateType)).select($"ts", $"date", $"clientIpAddress", concat($"dateTime", lit(" "), $"zone").as("original_dateTime"), $"request", $"httpStatusCode", $"bytesSent")

我们可以检查流式读取的结果,如下所示:

scala> val query = logEvents.writeStream.outputMode("append").format("console").start()

我们可以将流输入保存为 Parquet 文件,按日期分区以更有效地支持查询,如下所示:

scala> val streamingETLQuery = logEvents.writeStream.trigger(Trigger.ProcessingTime("2 minutes")).format("parquet").partitionBy("date").option("path", "file:///Users/aurobindosarkar/Downloads/NASALogs").option("checkpointLocation", "file:///Users/aurobindosarkar/Downloads/NASALogs/checkpoint/").start()

我们可以通过指定latestFirst选项来读取输入,以便最新的记录首先可用:

val rawCSV = spark.readStream.schema(schema).option("latestFirst", "true").option("maxFilesPerTrigger", "5").option("header", false).option("sep", " ").format("csv").load("file:///Users/aurobindosarkar/Downloads/NASA")

我们还可以按日期将输出以 JSON 格式输出,如下所示:

val streamingETLQuery = logEvents.writeStream.trigger(Trigger.ProcessingTime("2 minutes")).format("json").partitionBy("date").option("path", "file:///Users/aurobindosarkar/Downloads/NASALogs").option("checkpointLocation", "file:///Users/aurobindosarkar/Downloads/NASALogs/checkpoint/").start()

现在,我们展示了在流式 Spark 应用程序中使用 Kafka 进行输入和输出的示例。在这里,我们必须将格式参数指定为kafka,并指定 kafka 代理和主题:

scala> val kafkaQuery = logEvents.selectExpr("CAST(ts AS STRING) AS key", "to_json(struct(*)) AS value").writeStream.format("kafka").option("kafka.bootstrap.servers", "localhost:9092").option("topic", "topica").option("checkpointLocation", "file:///Users/aurobindosarkar/Downloads/NASALogs/kafkacheckpoint/").start()

现在,我们正在从 Kafka 中读取 JSON 数据流。将起始偏移设置为最早以指定查询的起始点。这仅适用于启动新的流式查询时:

scala> val kafkaDF = spark.readStream.format("kafka").option("kafka.bootstrap.servers", "localhost:9092").option("subscribe", "topica").option("startingOffsets", "earliest").load()

我们可以按以下方式打印从 Kafka 读取的记录的模式:

scala> kafkaDF.printSchema()
root
|-- key: binary (nullable = true)
|-- value: binary (nullable = true)
|-- topic: string (nullable = true)
|-- partition: integer (nullable = true)
|-- offset: long (nullable = true)
|-- timestamp: timestamp (nullable = true)
|-- timestampType: integer (nullable = true)

接下来,我们定义输入记录的模式,如下所示:

scala> val kafkaSchema = new StructType().add("ts", "timestamp").add("date", "string").add("clientIpAddress", "string").add("rfc1413ClientIdentity", "string").add("remoteUser", "string").add("original_dateTime", "string").add("request", "string").add("httpStatusCode", "string").add("bytesSent", "string")

接下来,我们可以指定模式,如所示。星号*运算符用于选择struct中的所有subfields

scala> val kafkaDF1 = kafkaDF.select(col("key").cast("string"), from_json(col("value").cast("string"), kafkaSchema).as("data")).select("data.*")

接下来,我们展示选择特定字段的示例。在这里,我们将outputMode设置为 append,以便只有追加到结果表的新行被写入外部存储。这仅适用于查询结果表中现有行不会发生变化的情况:

scala> val kafkaQuery1 = kafkaDF1.select($"ts", $"date", $"clientIpAddress", $"original_dateTime", $"request", $"httpStatusCode", $"bytesSent").writeStream.outputMode("append").format("console").start()

我们还可以指定read(而不是readStream)将记录读入常规 DataFrame 中:

scala> val kafkaDF2 = spark.read.format("kafka").option("kafka.bootstrap.servers","localhost:9092").option("subscribe", "topica").load().selectExpr("CAST(value AS STRING) as myvalue")

现在,我们可以对这个 DataFrame 执行所有标准的 DataFrame 操作;例如,我们创建一个表并查询它,如下所示:

scala> kafkaDF2.registerTempTable("topicData3")

scala> spark.sql("select myvalue from topicData3").take(3).foreach(println)

然后,我们从 Kafka 中读取记录并应用模式:

scala> val parsed = spark.readStream.format("kafka").option("kafka.bootstrap.servers", "localhost:9092").option("subscribe", "topica").option("startingOffsets", "earliest").load().select(from_json(col("value").cast("string"), kafkaSchema).alias("parsed_value"))

我们可以执行以下查询来检查记录的内容:

scala> val query = parsed.writeStream.outputMode("append").format("console").start()

我们可以从记录中选择所有字段,如下所示:

scala> val selectAllParsed = parsed.select("parsed_value.*")

我们还可以从 DataFrame 中选择感兴趣的特定字段:

scala> val selectFieldsParsed = selectAllParsed.select("ts", "clientIpAddress", "request", "httpStatusCode")

接下来,我们可以使用窗口操作,并为各种 HTTP 代码维护计数,如所示。在这里,我们将outputMode设置为complete,因为我们希望将整个更新后的结果表写入外部存储:

scala> val s1 = selectFieldsParsed.groupBy(window($"ts", "10 minutes", "5 minutes"), $"httpStatusCode").count().writeStream.outputMode("complete").format("console").start()

接下来,我们展示了另一个使用groupBy和计算各窗口中各种页面请求计数的示例。这可用于计算和报告访问类型指标中的热门页面:

scala> val s2 = selectFieldsParsed.groupBy(window($"ts", "10 minutes", "5 minutes"), $"request").count().writeStream.outputMode("complete").format("console").start()

请注意,前面提到的示例是有状态处理的实例。计数必须保存为触发器之间的分布式状态。每个触发器读取先前的状态并写入更新后的状态。此状态存储在内存中,并由持久的 WAL 支持,通常位于 HDFS 或 S3 存储上。这使得流式应用程序可以自动处理延迟到达的数据。保留此状态允许延迟数据更新旧窗口的计数。

然而,如果不丢弃旧窗口,状态的大小可能会无限增加。水印方法用于解决此问题。水印是预期数据延迟的移动阈值,以及何时丢弃旧状态。它落后于最大观察到的事件时间。水印之后的数据可能会延迟,但允许进入聚合,而水印之前的数据被认为是“太晚”,并被丢弃。此外,水印之前的窗口会自动删除,以限制系统需要维护的中间状态的数量。

在前一个查询中指定的水印在这里给出:

scala> val s4 = selectFieldsParsed.withWatermark("ts", "10 minutes").groupBy(window($"ts", "10 minutes", "5 minutes"), $"request").count().writeStream.outputMode("complete").format("console").start()

有关水印的更多详细信息,请参阅databricks.com/blog/2017/05/08/event-time-aggregation-watermarking-apache-sparks-structured-streaming.html

在下一节中,我们将把重点转移到在生产环境中部署基于 Spark 的机器学习管道。

部署 Spark 机器学习管道

下图以概念级别说明了机器学习管道。然而,现实生活中的 ML 管道要复杂得多,有多个模型被训练、调整、组合等:

下图显示了典型机器学习应用程序的核心元素分为两部分:建模,包括模型训练,以及部署的模型(用于流数据以输出结果):

通常,数据科学家在 Python 和/或 R 中进行实验或建模工作。然后在部署到生产环境之前,他们的工作会在 Java/Scala 中重新实现。企业生产环境通常包括 Web 服务器、应用服务器、数据库、中间件等。将原型模型转换为生产就绪模型会导致额外的设计和开发工作,从而导致更新模型的推出延迟。

我们可以使用 Spark MLlib 2.x 模型序列化直接在生产环境中加载数据科学家保存的模型和管道(到磁盘)的模型文件。

在以下示例中(来源:spark.apache.org/docs/latest/ml-pipeline.html),我们将演示在 Python 中创建和保存 ML 管道(使用pyspark shell),然后在 Scala 环境中检索它。

启动pyspark shell 并执行以下 Python 语句序列:

>>> from pyspark.ml import Pipeline
>>> from pyspark.ml.classification import LogisticRegression
>>> from pyspark.ml.feature import HashingTF, Tokenizer
>>> training = spark.createDataFrame([
... (0, "a b c d e spark", 1.0),
... (1, "b d", 0.0),
... (2, "spark f g h", 1.0),
... (3, "hadoop mapreduce", 0.0)
... ], ["id", "text", "label"])
>>> tokenizer = Tokenizer(inputCol="text", outputCol="words")
>>> hashingTF = HashingTF(inputCol=tokenizer.getOutputCol(), outputCol="features")
>>> lr = LogisticRegression(maxIter=10, regParam=0.001)
>>> pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
>>> model = pipeline.fit(training)
>>> model.save("file:///Users/aurobindosarkar/Downloads/spark-logistic-regression-model")
>>> quit()

启动 Spark shell 并执行以下 Scala 语句序列:

scala> import org.apache.spark.ml.{Pipeline, PipelineModel}
scala> import org.apache.spark.ml.classification.LogisticRegression
scala> import org.apache.spark.ml.feature.{HashingTF, Tokenizer}
scala> import org.apache.spark.ml.linalg.Vector
scala> import org.apache.spark.sql.Row

scala> val sameModel = PipelineModel.load("file:///Users/aurobindosarkar/Downloads/spark-logistic-regression-model")

接下来,我们创建一个test数据集,并通过 ML 管道运行它:

scala> val test = spark.createDataFrame(Seq(
| (4L, "spark i j k"),
| (5L, "l m n"),
| (6L, "spark hadoop spark"),
| (7L, "apache hadoop")
| )).toDF("id", "text")

test数据集上运行模型的结果如下:

scala> sameModel.transform(test).select("id", "text", "probability", "prediction").collect().foreach { case Row(id: Long, text: String, prob: Vector, prediction: Double) => println(s"($id, $text) --> prob=$prob, prediction=$prediction")}

(4, spark i j k) --> prob=[0.15554371384424398,0.844456286155756], prediction=1.0
(5, l m n) --> prob=[0.8307077352111738,0.16929226478882617], prediction=0.0
(6, spark hadoop spark) --> prob=[0.06962184061952888,0.9303781593804711], prediction=1.0
(7, apache hadoop) --> prob=[0.9815183503510166,0.018481649648983405], prediction=0.0

保存的逻辑回归模型的关键参数被读入 DataFrame,如下面的代码块所示。在之前,当模型在pyspark shell 中保存时,这些参数被保存到与我们管道的最终阶段相关的子目录中的 Parquet 文件中:

scala> val df = spark.read.parquet("file:///Users/aurobindosarkar/Downloads/spark-logistic-regression-model/stages/2_LogisticRegression_4abda37bdde1ddf65ea0/data/part-00000-415bf215-207a-4a49-985e-190eaf7253a7-c000.snappy.parquet")

scala> df.show()

获得以下输出:

scala> df.collect.foreach(println)

输出如下:

有关如何将 ML 模型投入生产的更多详细信息,请参阅spark-summit.org/2017/events/how-to-productionize-your-machine-learning-models-using-apache-spark-mllib-2x/

了解典型 ML 部署环境中的挑战

ML 模型的生产部署环境可能非常多样化和复杂。例如,模型可能需要部署在 Web 应用程序、门户、实时和批处理系统中,以及作为 API 或 REST 服务,嵌入设备或大型遗留环境中。

此外,企业技术堆栈可以包括 Java 企业、C/C++、遗留主机环境、关系数据库等。与响应时间、吞吐量、可用性和正常运行时间相关的非功能性要求和客户 SLA 也可能差异很大。然而,在几乎所有情况下,我们的部署过程需要支持 A/B 测试、实验、模型性能评估,并且需要灵活和响应业务需求。

通常,从业者使用各种方法来对新模型或更新模型进行基准测试和逐步推出,以避免高风险、大规模的生产部署。

在下一节中,我们将探讨一些模型部署架构。

了解模型评分架构的类型

最简单的模型是使用 Spark(批处理)预计算模型结果,将结果保存到数据库,然后从数据库为 Web 和移动应用程序提供结果。许多大规模的推荐引擎和搜索引擎使用这种架构:

第二种模型评分架构使用 Spark Streaming 计算特征并运行预测算法。预测结果可以使用缓存解决方案(如 Redis)进行缓存,并可以通过 API 提供。其他应用程序可以使用这些 API 从部署的模型中获取预测结果。此选项在此图中有所说明:

在第三种架构模型中,我们可以仅使用 Spark 进行模型训练。然后将模型复制到生产环境中。例如,我们可以从 JSON 文件中加载逻辑回归模型的系数和截距。这种方法资源高效,并且会产生高性能的系统。在现有或复杂环境中部署也更加容易。

如图所示:

继续我们之前的例子,我们可以从 Parquet 文件中读取保存的模型参数,并将其转换为 JSON 格式,然后可以方便地导入到任何应用程序(在 Spark 环境内部或外部)并应用于新数据:

scala> spark.read.parquet("file:///Users/aurobindosarkar/Downloads/spark-logistic-regression-model/stages/2_LogisticRegression_4abda37bdde1ddf65ea0/data/part-00000-415bf215-207a-4a49-985e-190eaf7253a7-c000.snappy.parquet").write.mode("overwrite").json("file:///Users/aurobindosarkar/Downloads/lr-model-json")

我们可以使用标准操作系统命令显示截距、系数和其他关键参数,如下所示:

Aurobindos-MacBook-Pro-2:lr-model-json aurobindosarkar$ more part-00000-e2b14eb8-724d-4262-8ea5-7c23f846fed0-c000.json

随着模型变得越来越大和复杂,部署和提供服务可能会变得具有挑战性。模型可能无法很好地扩展,其资源需求可能变得非常昂贵。Databricks 和 Redis-ML 提供了部署训练模型的解决方案。

在 Redis-ML 解决方案中,模型直接应用于 Redis 环境中的新数据。

这可以以比在 Spark 环境中运行模型的价格更低的价格提供所需的整体性能、可伸缩性和可用性。

下图显示了 Redis-ML 作为服务引擎的使用情况(实现了先前描述的第三种模型评分架构模式):

在下一节中,我们将简要讨论在生产环境中使用 Mesos 和 Kubernetes 作为集群管理器。

使用集群管理器

在本节中,我们将在概念层面简要讨论 Mesos 和 Kubernetes。Spark 框架可以通过 Apache Mesos、YARN、Spark Standalone 或 Kubernetes 集群管理器进行部署,如下所示:

Mesos 可以实现数据的轻松扩展和复制,并且是异构工作负载的良好统一集群管理解决方案。

要从 Spark 使用 Mesos,Spark 二进制文件应该可以被 Mesos 访问,并且 Spark 驱动程序配置为连接到 Mesos。或者,您也可以在所有 Mesos 从属节点上安装 Spark 二进制文件。驱动程序创建作业,然后发出任务进行调度,而 Mesos 确定处理它们的机器。

Spark 可以在 Mesos 上以两种模式运行:粗粒度(默认)和细粒度(在 Spark 2.0.0 中已弃用)。在粗粒度模式下,每个 Spark 执行器都作为单个 Mesos 任务运行。这种模式具有显着较低的启动开销,但会为应用程序的持续时间保留 Mesos 资源。Mesos 还支持根据应用程序的统计数据调整执行器数量的动态分配。

下图说明了将 Mesos Master 和 Zookeeper 节点放置在一起的部署。Mesos Slave 和 Cassandra 节点也放置在一起,以获得更好的数据局部性。此外,Spark 二进制文件部署在所有工作节点上:

另一个新兴的 Spark 集群管理解决方案是 Kubernetes,它正在作为 Spark 的本机集群管理器进行开发。它是一个开源系统,可用于自动化容器化 Spark 应用程序的部署、扩展和管理。

下图描述了 Kubernetes 的高层视图。每个节点都包含一个名为 Kublet 的守护程序,它与 Master 节点通信。用户还可以与 Master 节点通信,以声明性地指定他们想要运行的内容。例如,用户可以请求运行特定数量的 Web 服务器实例。Master 将接受用户的请求并在节点上安排工作负载:

节点运行一个或多个 pod。Pod 是容器的更高级抽象,每个 pod 可以包含一组共同放置的容器。每个 pod 都有自己的 IP 地址,并且可以与其他节点中的 pod 进行通信。存储卷可以是本地的或网络附加的。这可以在下图中看到:

Kubernetes 促进不同类型的 Spark 工作负载之间的资源共享,以减少运营成本并提高基础设施利用率。此外,可以使用几个附加服务与 Spark 应用程序一起使用,包括日志记录、监视、安全性、容器间通信等。

有关在 Kubernetes 上使用 Spark 的更多详细信息,请访问github.com/apache-spark-on-k8s/spark

在下图中,虚线将 Kubernetes 与 Spark 分隔开。Spark Core 负责获取新的执行器、推送新的配置、移除执行器等。Kubernetes 调度器后端接受 Spark Core 的请求,并将其转换为 Kubernetes 可以理解的原语。此外,它处理所有资源请求和与 Kubernetes 的所有通信。

其他服务,如文件暂存服务器,可以使您的本地文件和 JAR 文件可用于 Spark 集群,Spark 洗牌服务可以存储动态分配资源的洗牌数据;例如,它可以实现弹性地改变特定阶段的执行器数量。您还可以扩展 Kubernetes API 以包括自定义或特定于应用程序的资源;例如,您可以创建仪表板来显示作业的进度。

Kubernetes 还提供了一些有用的管理功能,以帮助管理集群,例如 RBAC 和命名空间级别的资源配额、审计日志记录、监视节点、pod、集群级别的指标等。

总结

在本章中,我们介绍了几种基于 Spark SQL 的应用程序架构,用于构建高度可扩展的应用程序。我们探讨了批处理和流处理中的主要概念和挑战。我们讨论了 Spark SQL 的特性,可以帮助构建强大的 ETL 流水线。我们还介绍了一些构建可扩展监控应用程序的代码。此外,我们探讨了一种用于机器学习流水线的高效部署技术,以及使用 Mesos 和 Kubernetes 等集群管理器的一些基本概念。

总之,本书试图帮助您在 Spark SQL 和 Scala 方面建立坚实的基础。然而,仍然有许多领域可以深入探索,以建立更深入的专业知识。根据您的特定领域,数据的性质和问题可能差异很大,您解决问题的方法通常会涵盖本书中描述的一个或多个领域。然而,在所有情况下,都需要 EDA 和数据整理技能,而您练习得越多,就会变得越熟练。尝试下载并处理不同类型的数据,包括结构化、半结构化和非结构化数据。此外,阅读各章节中提到的参考资料,以深入了解其他数据科学从业者如何解决问题。参考 Apache Spark 网站获取软件的最新版本,并探索您可以在 ML 流水线中使用的其他机器学习算法。最后,诸如深度学习和基于成本的优化等主题在 Spark 中仍在不断发展,尝试跟上这些领域的发展,因为它们将是解决未来许多有趣问题的关键。

posted @ 2024-05-21 12:53  绝不原创的飞龙  阅读(61)  评论(0编辑  收藏  举报