Scala-和-Spark-大数据分析-全-

Scala 和 Spark 大数据分析(全)

原文:zh.annas-archive.org/md5/39EECC62E023387EE8C22CA10D1A221A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

数据持续增长,加上对这些数据进行越来越复杂的决策的需求,正在创造巨大的障碍,阻止组织利用传统的分析方法及时获取洞察力。大数据领域与这些框架密切相关,其范围由这些框架能处理的内容来定义。无论您是在审查数百万访问者的点击流以优化在线广告位置,还是在筛选数十亿交易以识别欺诈迹象,对于从海量数据中自动获取洞察力的高级分析(如机器学习和图处理)的需求比以往任何时候都更加明显。

Apache Spark,作为大数据处理、分析和数据科学在所有学术界和行业中的事实标准,提供了机器学习和图处理库,使公司能够轻松应对复杂问题,利用高度可扩展和集群化的计算机的强大能力。Spark 的承诺是进一步推动使用 Scala 编写分布式程序感觉像为 Spark 编写常规程序。Spark 将在提高 ETL 管道性能和减轻一些痛苦方面做得很好,这些痛苦来自 MapReduce 程序员每天对 Hadoop 神明的绝望呼唤。

在本书中,我们使用 Spark 和 Scala 进行努力,将最先进的高级数据分析与机器学习、图处理、流处理和 SQL 引入 Spark,并将它们贡献给 MLlib、ML、SQL、GraphX 和其他库。

我们从 Scala 开始,然后转向 Spark 部分,最后,涵盖了一些关于使用 Spark 和 Scala 进行大数据分析的高级主题。在附录中,我们将看到如何扩展您的 Scala 知识,以用于 SparkR、PySpark、Apache Zeppelin 和内存中的 Alluxio。本书不是要从头到尾阅读的。跳到一个看起来像您要完成的任务或简单激起您兴趣的章节。

祝阅读愉快!

本书内容

第一章,Scala 简介,将教授使用基于 Scala 的 Spark API 进行大数据分析。Spark 本身是用 Scala 编写的,因此作为起点,我们将讨论 Scala 的简要介绍,例如其历史、目的以及如何在 Windows、Linux 和 Mac OS 上安装 Scala。之后,将简要讨论 Scala web 框架。然后,我们将对 Java 和 Scala 进行比较分析。最后,我们将深入 Scala 编程,开始使用 Scala。

第二章,面向对象的 Scala,说道面向对象编程(OOP)范式提供了全新的抽象层。简而言之,本章讨论了面向对象编程语言的一些最大优势:可发现性、模块化和可扩展性。特别是,我们将看到如何处理 Scala 中的变量;Scala 中的方法、类和对象;包和包对象;特征和特征线性化;以及 Java 互操作性。

第三章,函数式编程概念,展示了 Scala 中的函数式编程概念。更具体地,我们将学习几个主题,比如为什么 Scala 是数据科学家的武器库,为什么学习 Spark 范式很重要,纯函数和高阶函数(HOFs)。还将展示使用 HOFs 的实际用例。然后,我们将看到如何在 Scala 的标准库中处理高阶函数在集合之外的异常。最后,我们将看看函数式 Scala 如何影响对象的可变性。

第四章《集合 API》介绍了吸引大多数 Scala 用户的功能之一——集合 API。它非常强大和灵活,并且具有许多相关操作。我们还将演示 Scala 集合 API 的功能以及如何使用它来适应不同类型的数据并解决各种不同的问题。在本章中,我们将涵盖 Scala 集合 API、类型和层次结构、一些性能特征、Java 互操作性以及 Scala 隐式。

第五章《应对大数据 - Spark 加入派对》概述了数据分析和大数据;我们看到大数据带来的挑战,以及它们是如何通过分布式计算来处理的,以及函数式编程提出的方法。我们介绍了谷歌的 MapReduce、Apache Hadoop,最后是 Apache Spark,并看到它们是如何采纳这种方法和这些技术的。我们将探讨 Apache Spark 的演变:为什么首先创建了 Apache Spark 以及它如何为大数据分析和处理的挑战带来价值。

第六章《开始使用 Spark - REPL 和 RDDs》涵盖了 Spark 的工作原理;然后,我们介绍了 RDDs,这是 Apache Spark 背后的基本抽象,看到它们只是暴露类似 Scala 的 API 的分布式集合。我们将研究 Apache Spark 的部署选项,并在本地运行它作为 Spark shell。我们将学习 Apache Spark 的内部工作原理,RDD 是什么,RDD 的 DAG 和谱系,转换和操作。

第七章《特殊 RDD 操作》着重介绍了如何定制 RDD 以满足不同的需求,以及这些 RDD 提供了新的功能(和危险!)此外,我们还研究了 Spark 提供的其他有用对象,如广播变量和累加器。我们将学习聚合技术、洗牌。

第八章《引入一点结构 - SparkSQL》教您如何使用 Spark 分析结构化数据,作为 RDD 的高级抽象,以及 Spark SQL 的 API 如何使查询结构化数据变得简单而健壮。此外,我们介绍数据集,并查看数据集、数据框架和 RDD 之间的区别。我们还将学习使用数据框架 API 进行复杂数据分析的连接操作和窗口函数。

第九章《带我上流 - Spark Streaming》带您了解 Spark Streaming 以及我们如何利用它来使用 Spark API 处理数据流。此外,在本章中,读者将学习使用实际示例处理实时数据流的各种方法,以消费和处理来自 Twitter 的推文。我们将研究与 Apache Kafka 的集成以进行实时处理。我们还将研究结构化流,它可以为您的应用程序提供实时查询。

第十章《一切都相连 - GraphX》中,我们将学习许多现实世界的问题可以使用图来建模(和解决)。我们将以 Facebook 为例看图论,Apache Spark 的图处理库 GraphX,VertexRDD 和 EdgeRDDs,图操作符,aggregateMessages,TriangleCounting,Pregel API 以及 PageRank 算法等用例。

第十一章,“学习机器学习-Spark MLlib 和 ML”,本章的目的是提供统计机器学习的概念介绍。我们将重点介绍 Spark 的机器学习 API,称为 Spark MLlib 和 ML。然后我们将讨论如何使用决策树和随机森林算法解决分类任务,以及使用线性回归算法解决回归问题。我们还将展示在训练分类模型之前如何从使用独热编码和降维算法在特征提取中受益。在后面的部分,我们将逐步展示开发基于协同过滤的电影推荐系统的示例。

第十二章,“高级机器学习最佳实践”,提供了一些关于使用 Spark 进行机器学习的高级主题的理论和实践方面。我们将看到如何使用网格搜索、交叉验证和超参数调整来调整机器学习模型以获得最佳性能。在后面的部分,我们将介绍如何使用 ALS 开发可扩展的推荐系统,这是一个基于模型的推荐算法的示例。最后,将演示主题建模应用作为文本聚类技术。

第十三章,“我的名字是贝叶斯,朴素贝叶斯”,指出大数据中的机器学习是一个革命性的组合,对学术界和工业界的研究领域产生了巨大影响。大数据对机器学习、数据分析工具和算法提出了巨大挑战,以找到真正的价值。然而,基于这些庞大数据集进行未来预测从未容易。考虑到这一挑战,在本章中,我们将深入探讨机器学习,了解如何使用简单而强大的方法构建可扩展的分类模型,以及多项式分类、贝叶斯推断、朴素贝叶斯、决策树和朴素贝叶斯与决策树的比较分析等概念。

第十四章,“整理数据的时候到了-Spark MLlib 对数据进行聚类”,让您了解 Spark 在集群模式下的工作原理及其基础架构。在之前的章节中,我们看到了如何使用不同的 Spark API 开发实际应用程序。最后,我们将看到如何在集群上部署完整的 Spark 应用程序,无论是使用现有的 Hadoop 安装还是不使用。

第十五章,“使用 Spark ML 进行文本分析”,概述了使用 Spark ML 进行文本分析的广泛领域。文本分析是机器学习中的一个广泛领域,在许多用例中非常有用,例如情感分析、聊天机器人、电子邮件垃圾邮件检测、自然语言处理等。我们将学习如何使用 Spark 进行文本分析,重点关注使用包含 1 万个样本的 Twitter 数据集进行文本分类的用例。我们还将研究 LDA,这是一种从文档中生成主题的流行技术,而不需要了解实际文本内容,并将在 Twitter 数据上实现文本分类,以了解所有内容是如何结合在一起的。

第十六章,“Spark 调优”,深入挖掘 Apache Spark 内部,并表示虽然 Spark 在让我们感觉好像只是使用另一个 Scala 集合方面做得很好,但我们不应忘记 Spark 实际上是在分布式系统中运行。因此,在本章中,我们将介绍如何监视 Spark 作业、Spark 配置、Spark 应用程序开发中的常见错误以及一些优化技术。

第十七章,去集群之旅-在集群上部署 Spark,探讨了 Spark 在集群模式下的工作方式及其基础架构。我们将看到集群中的 Spark 架构,Spark 生态系统和集群管理,以及如何在独立、Mesos、Yarn 和 AWS 集群上部署 Spark。我们还将看到如何在基于云的 AWS 集群上部署您的应用程序。

第十八章,测试和调试 Spark,解释了在分布式环境中测试应用程序有多么困难;然后,我们将看到一些解决方法。我们将介绍如何在分布式环境中进行测试,以及测试和调试 Spark 应用程序。

第十九章,PySpark 和 SparkR,涵盖了使用 R 和 Python 编写 Spark 代码的另外两种流行 API,即 PySpark 和 SparkR。特别是,我们将介绍如何开始使用 PySpark 并与 PySpark 交互 DataFrame API 和 UDF,然后我们将使用 PySpark 进行一些数据分析。本章的第二部分涵盖了如何开始使用 SparkR。我们还将看到如何进行数据处理和操作,以及如何使用 SparkR 处理 RDD 和 DataFrames,最后,使用 SparkR 进行一些数据可视化。

附录 A,使用 Alluxio 加速 Spark,展示了如何使用 Alluxio 与 Spark 来提高处理速度。Alluxio 是一个开源的分布式内存存储系统,可用于提高跨平台的许多应用程序的速度,包括 Apache Spark。我们将探讨使用 Alluxio 的可能性以及 Alluxio 集成如何在运行 Spark 作业时提供更高的性能而无需每次都将数据缓存到内存中。

附录 B,使用 Apache Zeppelin 进行交互式数据分析,从数据科学的角度来看,交互式可视化数据分析也很重要。Apache Zeppelin 是一个基于 Web 的笔记本,用于具有多个后端和解释器的交互式和大规模数据分析。在本章中,我们将讨论如何使用 Apache Zeppelin 进行大规模数据分析,使用 Spark 作为后端的解释器。

本书所需的内容

所有示例都是在 Ubuntu Linux 64 位上使用 Python 版本 2.7 和 3.5 实现的,包括 TensorFlow 库版本 1.0.1。然而,在本书中,我们只展示了与 Python 2.7 兼容的源代码。与 Python 3.5+兼容的源代码可以从 Packt 存储库下载。您还需要以下 Python 模块(最好是最新版本):

  • Spark 2.0.0(或更高)

  • Hadoop 2.7(或更高)

  • Java(JDK 和 JRE)1.7+/1.8+

  • Scala 2.11.x(或更高)

  • Python 2.7+/3.4+

  • R 3.1+和 RStudio 1.0.143(或更高)

  • Eclipse Mars,Oxygen 或 Luna(最新)

  • Maven Eclipse 插件(2.9 或更高)

  • Eclipse 的 Maven 编译器插件(2.3.2 或更高)

  • Eclipse 的 Maven 汇编插件(2.4.1 或更高)

**操作系统:**首选 Linux 发行版(包括 Debian,Ubuntu,Fedora,RHEL 和 CentOS),更具体地说,对于 Ubuntu,建议安装完整的 14.04(LTS)64 位(或更高版本),VMWare player 12 或 Virtual box。您可以在 Windows(XP/7/8/10)或 Mac OS X(10.4.7+)上运行 Spark 作业。

**硬件配置:**处理器 Core i3,Core i5(推荐)或 Core i7(以获得最佳结果)。然而,多核处理将提供更快的数据处理和可伸缩性。您至少需要 8-16 GB RAM(推荐)以独立模式运行,至少需要 32 GB RAM 以单个 VM 运行-并且对于集群来说需要更高。您还需要足够的存储空间来运行繁重的作业(取决于您处理的数据集大小),最好至少有 50 GB 的免费磁盘存储空间(用于独立的单词丢失和 SQL 仓库)。

这本书适合谁

任何希望通过利用 Spark 的力量来学习数据分析的人都会发现这本书非常有用。我们不假设您具有 Spark 或 Scala 的知识,尽管先前的编程经验(特别是使用其他 JVM 语言)将有助于更快地掌握这些概念。在过去几年中,Scala 的采用率一直在稳步上升,特别是在数据科学和分析领域。与 Scala 齐头并进的是 Apache Spark,它是用 Scala 编程的,并且在分析领域被广泛使用。本书将帮助您利用这两种工具的力量来理解大数据。

约定

在本书中,您将找到一些区分不同信息类型的文本样式。以下是一些这些样式的示例及其含义的解释。文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“下一行代码读取链接并将其分配给BeautifulSoup函数。”

代码块设置如下:

package com.chapter11.SparkMachineLearning
import org.apache.spark.mllib.feature.StandardScalerModel
import org.apache.spark.mllib.linalg.{ Vector, Vectors }
import org.apache.spark.sql.{ DataFrame }
import org.apache.spark.sql.SparkSession

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

val spark = SparkSession
                 .builder
                 .master("local[*]")
                 .config("spark.sql.warehouse.dir", "E:/Exp/")
                 .config("spark.kryoserializer.buffer.max", "1024m")
                 .appName("OneVsRestExample")        
           .getOrCreate()

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

$./bin/spark-submit --class com.chapter11.RandomForestDemo \
--master spark://ip-172-31-21-153.us-west-2.compute:7077 \
--executor-memory 2G \
--total-executor-cores 2 \
file:///home/KMeans-0.0.1-SNAPSHOT.jar \
file:///home/mnist.bz2

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

警告或重要说明会以这种方式出现。

提示和技巧会以这种方式出现。

第一章:Scala 简介

“我是 Scala。我是一种可扩展的、函数式的、面向对象的编程语言。我可以随着你的成长而成长,你可以通过输入一行表达式来与我互动,并立即观察结果。”

  • Scala 引用

在过去的几年里,Scala 在数据科学和分析领域特别是开发人员和从业者中得到了稳步增长和广泛采用。另一方面,使用 Scala 编写的 Apache Spark 是用于大规模数据处理的快速通用引擎。Spark 的成功归功于许多因素:易于使用的 API、清晰的编程模型、性能等等。因此,自然而然地,Spark 对 Scala 的支持更多:与 Python 或 Java 相比,Scala 有更多的 API 可用;尽管如此,新的 Scala API 在 Java、Python 和 R 之前就已经可用。

在我们开始使用 Spark 和 Scala(第二部分)编写数据分析程序之前,我们将首先详细了解 Scala 的函数式编程概念、面向对象的特性和 Scala 集合 API(第一部分)。作为起点,我们将在本章节中简要介绍 Scala。我们将涵盖 Scala 的一些基本方面,包括其历史和目的。然后我们将看到如何在不同平台上安装 Scala,包括 Windows、Linux 和 Mac OS,以便您可以在您喜爱的编辑器和 IDE 上编写数据分析程序。在本章的后面,我们将对 Java 和 Scala 进行比较分析。最后,我们将通过一些示例深入学习 Scala 编程。

简而言之,以下主题将被涵盖:

  • Scala 的历史和目的

  • 平台和编辑器

  • 安装和设置 Scala

  • Scala:可扩展的语言

  • 面向 Java 程序员的 Scala

  • Scala 初学者

  • 摘要

Scala 的历史和目的

Scala 是一种通用编程语言,支持函数式编程和强大的静态类型系统。Scala 的源代码旨在编译成Java字节码,以便生成的可执行代码可以在Java 虚拟机(JVM)上运行。

Martin Odersky 于 2001 年在洛桑联邦理工学院EPFL)开始设计 Scala。这是他在 Funnel 上的工作的延伸,Funnel 是一种使用函数式编程和 Petri 网的编程语言。首次公开发布是在 2004 年,但只支持 Java 平台。随后,在 2004 年 6 月,.NET 框架也开始支持。

Scala 因不仅支持面向对象的编程范式,而且还包含了函数式编程概念,因此变得非常受欢迎并得到了广泛的采用。此外,尽管 Scala 的符号操作符很难阅读,与 Java 相比,大多数 Scala 代码相对简洁易读——例如,Java 太啰嗦了。

与其他编程语言一样,Scala 是为特定目的而提出和开发的。现在,问题是,为什么创建了 Scala,它解决了什么问题?为了回答这些问题,Odersky 在他的博客中说:

“Scala 的工作源于开发组件软件的更好语言支持的研究工作。我们希望通过 Scala 实验验证两个假设。首先,我们假设组件软件的编程语言需要在描述小部分和大部分时使用相同的概念。因此,我们集中于抽象、组合和分解的机制,而不是添加大量原语,这些原语在某个规模级别上可能对组件有用,但在其他级别上则不是。其次,我们假设组件的可扩展支持可以通过统一和泛化面向对象和函数式编程的编程语言来提供。对于 Scala 这样的静态类型语言,这两种范式到目前为止基本上是分开的。”

然而,Scala 也提供了模式匹配和高阶函数等功能,不是为了填补函数式编程和面向对象编程之间的差距,而是因为它们是函数式编程的典型特征。因此,它具有一些非常强大的模式匹配功能,还有一个基于 actor 的并发框架。此外,它还支持一阶和高阶函数。总之,"Scala"这个名字是可伸缩语言的混成词,意味着它被设计成能够满足用户需求的语言。

平台和编辑器

Scala 在Java 虚拟机JVM)上运行,这使得 Scala 对于希望在代码中添加函数式编程风格的 Java 程序员来说是一个不错的选择。在编辑器方面有很多选择。最好花一些时间对可用的编辑器进行比较研究,因为熟悉 IDE 是成功编程经验的关键因素之一。以下是一些可供选择的选项:

  • Scala IDE

  • Eclipse 的 Scala 插件

  • IntelliJ IDEA

  • Emacs

  • VIM

Scala 在 Eclipse 上的编程支持使用了许多 beta 插件。Eclipse 提供了一些令人兴奋的功能,如本地、远程和高级调试功能,以及用于 Scala 的语义突出显示和代码补全。您可以使用 Eclipse 同样轻松地进行 Java 和 Scala 应用程序开发。但是,我还建议 Scala IDE(scala-ide.org/)- 这是一个基于 Eclipse 的全功能 Scala 编辑器,并且定制了一系列有趣的功能(例如 Scala 工作表、ScalaTest 支持、Scala 重构等);

在我看来,第二个最佳选择是 IntelliJ IDEA。第一个版本于 2001 年发布,是第一个具有高级代码导航和重构功能的 Java IDE。根据 InfoWorld 报告(请参阅www.infoworld.com/article/2683534/development-environments/infoworld-review--top-java-programming-tools.html),在四个顶级 Java 编程 IDE(即 Eclipse、IntelliJ IDEA、NetBeans 和 JDeveloper)中,IntelliJ 获得了最高的测试中心评分 8.5 分(满分 10 分)。

相应的评分如下图所示:

图 1: Scala/Java 开发人员最佳 IDE

从上面的图中,您可能对使用其他 IDE,如 NetBeans 和 JDeveloper 也感兴趣。最终,选择是开发人员之间永恒的辩论,这意味着最终选择取决于您。

安装和设置 Scala

正如我们已经提到的,Scala 使用 JVM,因此请确保您的机器上已安装 Java。如果没有,请参考下一小节,其中介绍了如何在 Ubuntu 上安装 Java。在本节中,首先我们将向您展示如何在 Ubuntu 上安装 Java 8。然后,我们将看到如何在 Windows、Mac OS 和 Linux 上安装 Scala。

安装 Java

为简单起见,我们将展示如何在 Ubuntu 14.04 LTS 64 位机器上安装 Java 8。但是对于 Windows 和 Mac OS,最好花一些时间在 Google 上了解一下。对于 Windows 用户的最小线索:请参考此链接获取详细信息java.com/en/download/help/windows_manual_download.xml

现在,让我们看看如何通过逐步命令和说明在 Ubuntu 上安装 Java 8。首先,检查 Java 是否已安装:

$ java -version 

如果返回程序 java 在以下包中找不到,则说明 Java 尚未安装。然后您可以执行以下命令来摆脱这个问题:

 $ sudo apt-get install default-jre 

这将安装Java Runtime EnvironmentJRE)。但是,如果您可能需要Java Development KitJDK),通常需要在 Apache Ant、Apache Maven、Eclipse 和 IntelliJ IDEA 上编译 Java 应用程序。

Oracle JDK 是官方 JDK,但是 Oracle 不再将其作为 Ubuntu 的默认安装提供。您仍然可以使用 apt-get 安装它。要安装任何版本,首先执行以下命令:

$ sudo apt-get install python-software-properties
$ sudo apt-get update
$ sudo add-apt-repository ppa:webupd8team/java
$ sudo apt-get update 

然后,根据您要安装的版本,执行以下命令之一:

$ sudo apt-get install oracle-java8-installer

安装完成后,不要忘记设置 Java 主目录环境变量。只需应用以下命令(为简单起见,我们假设 Java 安装在/usr/lib/jvm/java-8-oracle):

$ echo "export JAVA_HOME=/usr/lib/jvm/java-8-oracle" >> ~/.bashrc  
$ echo "export PATH=$PATH:$JAVA_HOME/bin" >> ~/.bashrc
$ source ~/.bashrc 

现在,让我们看一下Java_HOME如下:

$ echo $JAVA_HOME

您应该在终端上观察到以下结果:

 /usr/lib/jvm/java-8-oracle

现在,让我们通过输入以下命令来检查 Java 是否已成功安装(您可能会看到最新版本!):

$ java -version

您将获得以下输出:

java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

太棒了!现在您的机器上已经安装了 Java,因此一旦安装了 Scala,您就可以准备好编写 Scala 代码了。让我们在接下来的几个小节中做这个。

Windows

本部分将重点介绍在 Windows 7 上安装 Scala 的 PC,但最终,您当前运行的 Windows 版本将不重要:

  1. 第一步是从官方网站下载 Scala 的压缩文件。您可以在www.Scala-lang.org/download/all.html找到它。在此页面的其他资源部分,您将找到一个存档文件列表,您可以从中安装 Scala。我们将选择下载 Scala 2.11.8 的压缩文件,如下图所示:

图 2: Windows 的 Scala 安装程序

  1. 下载完成后,解压文件并将其放在您喜欢的文件夹中。您还可以将文件重命名为 Scala 以提高导航灵活性。最后,需要为 Scala 创建一个PATH变量,以便在您的操作系统中全局看到。为此,请转到计算机 | 属性,如下图所示:

图 3: Windows 上的环境变量选项卡

  1. 从中选择环境变量,并获取 Scala 的bin文件夹的位置;然后,将其附加到PATH环境变量。应用更改,然后按 OK,如下截图所示:

图 4: 为 Scala 添加环境变量

  1. 现在,您可以开始进行 Windows 安装。打开 CMD,只需输入scala。如果安装过程成功,您应该会看到类似以下截图的输出:

图 5: 从“Scala shell”访问 Scala

Mac OS

现在是时候在您的 Mac 上安装 Scala 了。有很多种方法可以在 Mac 上安装 Scala,在这里,我们将提到其中两种:

使用 Homebrew 安装程序

  1. 首先,检查您的系统是否已安装 Xcode,因为这一步骤需要。您可以免费从 Apple App Store 安装它。

  2. 接下来,您需要通过在终端中运行以下命令来安装Homebrew

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

注意:Homebrew 的前面的命令会不时更改。如果命令似乎无效,请查看 Homebrew 网站获取最新的命令:brew.sh/

  1. 现在,您已经准备好通过在终端中键入此命令brew install scala来安装 Scala。

  2. 最后,您只需在终端中输入 Scala,就可以开始了(第二行),您将在终端上看到以下内容:

图 6: macOS 上的 Scala shell

手动安装

在手动安装 Scala 之前,选择您喜欢的 Scala 版本,并从www.scala-lang.org/download/下载相应版本的.tgz文件Scala-verion.tgz。下载您喜欢的 Scala 版本后,按以下步骤提取:

$ tar xvf scala-2.11.8.tgz

然后,将其移动到/usr/local/share,如下所示:

$ sudo mv scala-2.11.8 /usr/local/share

现在,要使安装永久生效,请执行以下命令:

$ echo "export SCALA_HOME=/usr/local/share/scala-2.11.8" >> ~/.bash_profile
$ echo "export PATH=$PATH: $SCALA_HOME/bin" >> ~/.bash_profile 

就是这样。现在,让我们看看在下一小节中如何在 Ubuntu 等 Linux 发行版上完成这个过程。

Linux

在本小节中,我们将向您展示如何在 Linux 的 Ubuntu 发行版上安装 Scala。在开始之前,让我们检查一下确保 Scala 已经正确安装。使用以下命令检查这一点非常简单:

$ scala -version

如果 Scala 已经安装在您的系统上,您应该在终端上收到以下消息:

Scala code runner version 2.11.8 -- Copyright 2002-2016, LAMP/EPFL

请注意,在编写本安装过程时,我们使用了 Scala 的最新版本,即 2.11.8。如果您的系统上没有安装 Scala,请确保在进行下一步之前安装它。您可以从 Scala 网站www.scala-lang.org/download/下载最新版本的 Scala(更清晰的视图,请参考图 2)。为了方便起见,让我们下载 Scala 2.11.8,如下所示:

$ cd Downloads/
$ wget https://downloads.lightbend.com/scala/2.11.8/scala-2.11.8.tgz

下载完成后,您应该在下载文件夹中找到 Scala 的 tar 文件。

用户应该首先使用以下命令进入Download目录:$ cd /Downloads/。请注意,下载文件夹的名称可能会根据系统选择的语言而变化。

要从其位置提取 Scala 的tar文件或更多,请输入以下命令。使用这个命令,Scala 的 tar 文件可以从终端中提取:

$ tar -xvzf scala-2.11.8.tgz

现在,通过以下命令或手动将 Scala 分发到用户的视角(例如,/usr/local/scala/share):

 $ sudo mv scala-2.11.8 /usr/local/share/

进入您的主目录问题使用以下命令:

$ cd ~

然后,使用以下命令设置 Scala 主目录:

$ echo "export SCALA_HOME=/usr/local/share/scala-2.11.8" >> ~/.bashrc 
$ echo "export PATH=$PATH:$SCALA_HOME/bin" >> ~/.bashrc

然后,使用以下命令使更改在会话中永久生效:

$ source ~/.bashrc

安装完成后,最好使用以下命令进行验证:

$ scala -version

如果 Scala 已经成功配置在您的系统上,您应该在终端上收到以下消息:

Scala code runner version 2.11.8 -- Copyright 2002-2016, LAMP/EPFL

干得好!现在,让我们通过在终端上输入;scala命令来进入 Scala shell,如下图所示:

****图 7: Linux 上的 Scala shell(Ubuntu 发行版)

最后,您也可以使用 apt-get 命令安装 Scala,如下所示:

$ sudo apt-get install scala

这个命令将下载 Scala 的最新版本(即 2.12.x)。然而,Spark 目前还不支持 Scala 2.12(至少在我们写这一章节时是这样)。因此,我们建议使用前面描述的手动安装。

Scala:可伸缩语言

Scala 的名称来自于可伸缩语言,因为 Scala 的概念很好地适用于大型程序。其他语言中的一些程序可能需要编写数十行代码,但在 Scala 中,您将获得以简洁而有效的方式表达编程的一般模式和概念的能力。在本节中,我们将描述 Odersky 为我们创建的 Scala 的一些令人兴奋的特性:

Scala 是面向对象的

Scala 是面向对象语言的一个很好的例子。要为您的对象定义类型或行为,您需要使用类和特征的概念,这将在下一章节中进行解释。Scala 不支持直接的多重继承,但要实现这种结构,您需要使用 Scala 的子类化基于混合的组合。这将在后面的章节中讨论。

Scala 是功能性的

函数式编程将函数视为一等公民。在 Scala 中,通过语法糖和扩展特性(如Function2)来实现这一点,但这就是 Scala 中实现函数式编程的方式。此外,Scala 定义了一种简单易行的方法来定义匿名 函数(没有名称的函数)。它还支持高阶函数,并允许嵌套函数**。**这些概念的语法将在接下来的章节中详细解释。

此外,它还可以帮助您以不可变的方式编码,通过这种方式,您可以轻松地将其应用于同步和并发的并行处理。

Scala 是静态类型的

与 Pascal、Rust 等其他静态类型语言不同,Scala 不要求您提供冗余的类型信息。在大多数情况下,您不必指定类型。最重要的是,您甚至不需要再次重复它们。

如果在编译时知道变量的类型,则编程语言被称为静态类型:这也意味着作为程序员,您必须指定每个变量的类型。例如,Scala、Java、C、OCaml、Haskell、C++等。另一方面,Perl、Ruby、Python 等是动态类型语言,其中类型与变量或字段无关,而与运行时值有关。

Scala 的静态类型特性确保编译器完成了所有类型的检查。Scala 这一极其强大的特性帮助您在执行之前找到/捕获大多数微不足道的错误和错误。

Scala 在 JVM 上运行

就像 Java 一样,Scala 也被编译成字节码,这可以很容易地由 JVM 执行。这意味着 Scala 和 Java 的运行时平台是相同的,因为两者都生成字节码作为编译输出。因此,您可以轻松地从 Java 切换到 Scala,您也可以轻松地集成两者,甚至在 Android 应用程序中使用 Scala 添加功能风格。

请注意,虽然在 Scala 程序中使用 Java 代码非常容易,但相反的情况非常困难,主要是因为 Scala 的语法糖。

javac命令一样,它将 Java 代码编译成字节码,Scala 也有scalas命令,它将 Scala 代码编译成字节码。

Scala 可以执行 Java 代码

如前所述,Scala 也可以用于执行您的 Java 代码。不仅安装您的 Java 代码;它还使您能够在 Scala 环境中使用 Java SDK 中的所有可用类,甚至您自己预定义的类、项目和包。

Scala 可以进行并发和同步处理

其他语言中的一些程序可能需要数十行代码,但在 Scala 中,您将获得以简洁有效的方式表达编程的一般模式和概念的能力。此外,它还可以帮助您以不可变的方式编码,通过这种方式,您可以轻松地将其应用于同步和并发的并行处理。

Java 程序员的 Scala

Scala 具有一组与 Java 完全不同的特性。在本节中,我们将讨论其中一些特性。对于那些来自 Java 背景或至少熟悉基本 Java 语法和语义的人来说,本节将是有帮助的。

所有类型都是对象

如前所述,Scala 中的每个值看起来都像一个对象。这意味着一切看起来都像对象,但其中一些实际上并不是对象,您将在接下来的章节中看到这一解释(例如,在 Scala 中,字符串会被隐式转换为字符集合,但在 Java 中不会!)

类型推断

如果您不熟悉这个术语,那就是在编译时推断类型。等等,这不就是动态类型的意思吗?嗯,不是。请注意,我说的是类型的推断;这与动态类型语言所做的事情完全不同,另一件事是,它是在编译时而不是运行时完成的。许多语言都内置了这个功能,但实现方式各不相同。这可能在开始时会让人困惑,但通过代码示例将会更加清晰。让我们进入 Scala REPL 进行一些实验。

在 Java 中,您只能在代码文件的顶部导入包,就在包语句之后。在 Scala 中情况不同;您几乎可以在源文件的任何地方编写导入语句(例如,甚至可以在类或方法内部编写导入语句)。您只需要注意您的导入语句的作用域,因为它继承了类的成员或方法内部局部变量的作用域。在 Scala 中,_(下划线)用于通配符导入,类似于 Java 中您将使用的*(星号):Scala REPL

Scala REPL 是一个强大的功能,使得在 Scala shell 上编写 Scala 代码更加简单和简洁。REPL代表读取-评估-打印-循环,也称为交互式解释器。这意味着它是一个用于:

  1. ;读取您输入的表达式。

  2. 使用 Scala 编译器评估第 1 步中的表达式。

  3. 打印出第 2 步评估的结果。

  4. 等待(循环)您输入更多表达式。

图 8: Scala REPL 示例 1

从图中可以看出,这并没有什么神奇之处,变量在编译时会自动推断出最适合的类型。如果您仔细观察,当我尝试声明时:

 i:Int = "hello"

然后,Scala shell 会抛出一个错误,显示如下:

<console>:11: error: type mismatch;
  found   : String("hello")
  required: Int
        val i:Int = "hello"
                    ^

根据 Odersky 的说法,“将字符映射到 RichString 上的字符映射应该再次产生一个 RichString,如下与 Scala REP 的交互”。可以使用以下代码来证明前述声明:

scala> "abc" map (x => (x + 1).toChar) 
res0: String = bcd

然而,如果有人将Char的方法应用于IntString,那会发生什么?在这种情况下,Scala 会将它们转换为整数向量,也称为 Scala 集合的不可变特性,如图 9所示。我们将在第四章中详细介绍 Scala 集合 API。

"abc" map (x => (x + 1)) 
res1: scala.collection.immutable.IndexedSeq[Int] = Vector(98, 99, 100)

对象的静态方法和实例方法也都可用。例如,如果您将x声明为字符串hello,然后尝试访问对象x的静态和实例方法,它们是可用的。在 Scala shell 中,键入x,然后键入.<tab>,然后您将找到可用的方法:

scala> val x = "hello"
x: java.lang.String = hello
scala> x.re<tab>
reduce             reduceRight         replaceAll            reverse
reduceLeft         reduceRightOption   replaceAllLiterally   reverseIterator
reduceLeftOption   regionMatches       replaceFirst          reverseMap
reduceOption       replace             repr
scala> 

由于这一切都是通过反射动态完成的,即使您刚刚定义了匿名类,它们也同样可以访问:

scala> val x = new AnyRef{def helloWord = "Hello, world!"}
x: AnyRef{def helloWord: String} = $anon$1@58065f0c
 scala> x.helloWord
 def helloWord: String
 scala> x.helloWord
 warning: there was one feature warning; re-run with -feature for details
 res0: String = Hello, world!

前两个示例可以在 Scala shell 上显示如下:

嵌套函数

“原来 map 根据传递的函数参数的结果类型产生不同的类型!”

  • Odersky

;

为什么您需要在编程语言中支持嵌套函数?大多数情况下,我们希望保持我们的方法只有几行,并避免过大的函数。在 Java 中,这个问题的典型解决方案是在类级别上定义所有这些小函数,但是任何其他方法都可以轻松地引用和访问它们,即使它们是辅助方法。在 Scala 中情况不同,您可以在彼此内部定义函数,从而防止任何外部访问这些函数:

def sum(vector: List[Int]): Int = {
  // Nested helper method (won't be accessed from outside this function
  def helper(acc: Int, remaining: List[Int]): Int = remaining match {
    case Nil => acc
    case _   => helper(acc + remaining.head, remaining.tail)
  }
  // Call the nested method
  helper(0, vector)
}

我们不希望您理解这些代码片段,它们展示了 Scala 和 Java 之间的区别。

导入语句

图 9: Scala REPL 示例 2

// Import everything from the package math 
import math._

您还可以使用这些{ }来指示从同一父包中导入一组导入,只需一行代码。在 Java 中,您需要使用多行代码来实现这一点:

// Import math.sin and math.cos
import math.{sin, cos}

与 Java 不同,Scala 没有静态导入的概念。换句话说,静态的概念在 Scala 中不存在。然而,作为开发人员,显然,您可以使用常规导入语句导入一个对象的一个成员或多个成员。前面的例子已经展示了这一点,我们从名为 math 的包对象中导入了 sin 和 cos 方法。为了演示一个例子,前面的;代码片段可以从 Java 程序员的角度定义如下:

import static java.lang.Math.sin;
import static java.lang.Math.cos;

Scala 的另一个美妙之处在于,在 Scala 中,您还可以重命名导入的包。或者,您可以重命名导入的包以避免与具有相似成员的包发生类型冲突。以下语句在 Scala 中是有效的:

// Import Scala.collection.mutable.Map as MutableMap 
import Scala.collection.mutable.{Map => MutableMap}

最后,您可能希望排除包的成员以避免冲突或其他目的。为此,您可以使用通配符来实现:

// Import everything from math, but hide cos 
import math.{cos => _, _}

运算符作为方法

值得一提的是,Scala 不支持运算符重载。您可能会认为 Scala 根本没有运算符。

调用只有一个参数的方法的另一种语法是使用中缀语法。中缀语法为您提供了一种味道,就像您在 C++中进行运算符重载一样。例如:

val x = 45
val y = 75

在下面的情况中,+;表示类Int中的一个方法。以下;代码是一种非常规的方法调用语法:

val add1 = x.+(y)

更正式地,可以使用中缀语法来完成相同的操作,如下所示:

val add2 = x + y

此外,您可以利用中缀语法。但是,该方法只有一个参数,如下所示:

val my_result = List(3, 6, 15, 34, 76) contains 5

在使用中缀语法时有一个特殊情况。也就是说,如果方法名以:(冒号)结尾,那么调用将是右结合的。这意味着该方法在右参数上调用,左侧的表达式作为参数,而不是相反。例如,在 Scala 中以下是有效的:

val my_list = List(3, 6, 15, 34, 76)

前面的;语句表示:my_list.+:(5)而不是5.+:(my_list),更正式地说:;

val my_result = 5 +: my_list

现在,让我们在 Scala REPL 上看一下前面的例子:

scala> val my_list = 5 +: List(3, 6, 15, 34, 76)
 my_list: List[Int] = List(5, 3, 6, 15, 34, 76)
scala> val my_result2 = 5+:my_list
 my_result2: List[Int] = List(5, 5, 3, 6, 15, 34, 76)
scala> println(my_result2)
 List(5, 5, 3, 6, 15, 34, 76)
scala>

除了上述之外,这里的运算符只是方法,因此它们可以像方法一样简单地被重写。

方法和参数列表

在 Scala 中,一个方法可以有多个参数列表,甚至根本没有参数列表。另一方面,在 Java 中,一个方法总是有一个参数列表,带有零个或多个参数。例如,在 Scala 中,以下是有效的方法定义(以currie notation编写),其中一个方法有两个参数列表:

def sum(x: Int)(y: Int) = x + y     

前面的;方法不能被写成:

def sum(x: Int, y: Int) = x + y

一个方法,比如;sum2,可以根本没有参数列表,如下所示:

def sum2 = sum(2) _

现在,您可以调用方法add2,它返回一个带有一个参数的函数。然后,它使用参数5调用该函数,如下所示:

val result = add2(5)

方法内部的方法

有时,您可能希望通过避免过长和复杂的方法使您的应用程序、代码模块化。Scala 为您提供了这种便利,以避免您的方法变得过大,以便将它们拆分成几个较小的方法。

另一方面,Java 只允许您在类级别定义方法。例如,假设您有以下方法定义:

def main_method(xs: List[Int]): Int = {
  // This is the nested helper/auxiliary method
  def auxiliary_method(accu: Int, rest: List[Int]): Int = rest match {
    case Nil => accu
    case _   => auxiliary_method(accu + rest.head, rest.tail)
  }
}

现在,您可以按以下方式调用嵌套的辅助/辅助方法:

auxiliary_method(0, xs)

考虑到上述内容,以下是有效的完整代码段:

def main_method(xs: List[Int]): Int = {
  // This is the nested helper/auxiliary method
  def auxiliary_method(accu: Int, rest: List[Int]): Int = rest match {
    case Nil => accu
    case _   => auxiliary_method(accu + rest.head, rest.tail)
  }
   auxiliary_method(0, xs)
}

Scala 中的构造函数

关于 Scala 的一个令人惊讶的事情是,Scala 类的主体本身就是一个构造函数。然而,Scala 确实这样做;事实上,以一种更明确的方式。之后,该类的一个新实例被创建并执行。此外,您可以在类声明行中指定构造函数的参数。

因此,构造函数参数可以从该类中定义的所有方法中访问。例如,以下类和构造函数定义在 Scala 中是有效的:

class Hello(name: String) {
  // Statement executed as part of the constructor
  println("New instance with name: " + name)
  // Method which accesses the constructor argument
  def sayHello = println("Hello, " + name + "!")
}

等效的 Java 类如下所示:

public class Hello {
  private final String name;
  public Hello(String name) {
    System.out.println("New instance with name: " + name);
    this.name = name;
  }
  public void sayHello() {
    System.out.println("Hello, " + name + "!");
  }
}

对象而不是静态方法

如前所述,Scala 中不存在静态。你不能进行静态导入,也不能向类添加静态方法。在 Scala 中,当你在同一源文件中以相同的名称定义一个对象和类时,那么该对象被称为该类的伴生对象。在类的伴生对象中定义的函数就像 Java 类中的静态方法:

class HelloCity(CityName: String) {
  def sayHelloToCity = println("Hello, " + CityName + "!") 
}

这是你可以为类 hello 定义一个伴生对象的方法:

object HelloCity { 
  // Factory method 
  def apply(CityName: String) = new Hello(CityName) 
}

等效的 Java 类如下所示:

public class HelloCity { 
  private final String CityName; 
  public HelloCity(String CityName) { 
    this.CityName = CityName; 
  }
  public void sayHello() {
    System.out.println("Hello, " + CityName + "!"); 
  }
  public static HelloCity apply(String CityName) { 
    return new Hello(CityName); 
  } 
}

所以,这个简单的类中有很多冗长的内容,不是吗?Scala 中的 apply 方法被以一种不同的方式处理,因此你可以找到一种特殊的快捷语法来调用它。这是调用方法的熟悉方式:

val hello1 = Hello.apply("Dublin")

以下是等效于之前的快捷语法:

 val hello2 = Hello("Dublin")

请注意,这仅在你的代码中使用了 apply 方法时才有效,因为 Scala 以不同的方式处理被命名为 apply 的方法。

特征

Scala 为你提供了一个很好的功能,以扩展和丰富你的类的行为。这些特征类似于接口,你可以在其中定义函数原型或签名。因此,你可以从不同的特征中获得功能的混合,并丰富你的类的行为。那么,Scala 中的特征有什么好处呢?它们使得从这些特征组合类成为可能,特征是构建块。和往常一样,让我们通过一个例子来看看。这是在 Java 中设置传统日志记录例程的方法:

请注意,尽管你可以混入任意数量的特征,但是和 Java 一样,Scala 不支持多重继承。然而,在 Java 和 Scala 中,子类只能扩展一个父类。例如,在 Java 中:

class SomeClass {
  //First, to have to log for a class, you must initialize it
  final static Logger log = LoggerFactory.getLogger(this.getClass());
  ...
  //For logging to be efficient, you must always check, if logging level for current message is enabled                
  //BAD, you will waste execution time if the log level is an error, fatal, etc.
  log.debug("Some debug message");
  ...
  //GOOD, it saves execution time for something more useful
  if (log.isDebugEnabled()) { log.debug("Some debug message"); }
  //BUT looks clunky, and it's tiresome to write this construct every time you want to log something.
}

有关更详细的讨论,请参阅此 URL stackoverflow.com/questions/963492/in-log4j-does-checking-isdebugenabled-before-logging-improve-performance/963681#963681

然而,特征是不同的。总是检查日志级别是否启用非常繁琐。如果你能够编写这个例程并在任何类中立即重用它,那就太好了。Scala 中的特征使这一切成为可能。例如:

trait Logging {
  lazy val log = LoggerFactory.getLogger(this.getClass.getName)     
  //Let's start with info level...
  ...
  //Debug level here...
  def debug() {
    if (log.isDebugEnabled) log.info(s"${msg}")
  }
  def debug(msg: => Any, throwable: => Throwable) {
    if (log.isDebugEnabled) log.info(s"${msg}", throwable)
  }
  ...
  //Repeat it for all log levels you want to use
}

如果你看前面的代码,你会看到一个以s开头的字符串的使用示例。这种方式,Scala 提供了从数据创建字符串的机制,称为字符串插值

字符串插值允许你直接在处理的字符串文字中嵌入变量引用。例如:

scala> val name = "John Breslin"

scala> println(s"Hello, $name") ; // Hello, John Breslin

现在,我们可以以更传统的方式获得一个高效的日志记录例程作为可重用的代码块。要为任何类启用日志记录,我们只需混入我们的Logging特征!太棒了!现在,这就是为你的类添加日志记录功能所需的全部内容:

class SomeClass extends Logging {
  ...
  //With logging trait, no need for declaring a logger manually for every class
  //And now, your logging routine is either efficient and doesn't litter the code!

  log.debug("Some debug message")
  ...
}

甚至可以混合多个特征。例如,对于前面的特征(即Logging),你可以按以下顺序不断扩展:

trait Logging  {
  override def toString = "Logging "
}
class A extends Logging  {
  override def toString = "A->" + super.toString
}
trait B extends Logging  {
  override def toString = "B->" + super.toString
}
trait C extends Logging  {
  override def toString = "C->" + super.toString
}
class D extends A with B with C {
  override def toString = "D->" + super.toString
}

然而,需要注意的是,Scala 类可以一次扩展多个特征,但 JVM 类只能扩展一个父类。

现在,要调用上述特征和类,可以在 Scala REPL 中使用new D(),如下图所示:

图 10:混合多个特征

到目前为止,本章一切顺利。现在,让我们转到一个新的部分,讨论一些初学者想要进入 Scala 编程领域的主题。

Scala 初学者

在这一部分,你会发现我们假设你对任何之前的编程语言有基本的了解。如果 Scala 是你进入编程世界的第一步,那么你会发现有很多在线材料甚至课程可以为初学者解释 Scala。正如前面提到的,有很多教程、视频和课程。

在 Coursera 上有一个包含这门课程的整个专业课程:www.coursera.org/specializations/scala。由 Scala 的创始人 Martin Odersky 教授,这个在线课程以一种相当学术的方式教授函数式编程的基础知识。通过解决编程作业,你将学到很多关于 Scala 的知识。此外,这个专业课程还包括一个关于 Apache Spark 的课程。此外,Kojo (www.kogics.net/sf:kojo)是一个使用 Scala 编程来探索和玩耍数学、艺术、音乐、动画和游戏的交互式学习环境。

你的第一行代码

作为第一个例子,我们将使用非常常见的Hello, world!程序来向你展示如何在不太了解它的情况下使用 Scala 及其工具。让我们打开你喜欢的编辑器(这个例子在 Windows 7 上运行,但在 Ubuntu 或 macOS 上也可以类似地运行),比如 Notepad++,并输入以下代码:

object HelloWorld {
  def main(args: Array[String]){ 
    println("Hello, world!")  
  } 
}

现在,保存代码为一个名字,比如HelloWorld.scala,如下图所示:

**图 11:**使用 Notepad++保存你的第一个 Scala 源代码

让我们按照以下方式编译源文件:

C:\>scalac HelloWorld.scala
 C:\>scala HelloWorld
 Hello, world!
 C:\>

我是 hello world 程序,好好解释给我听!

这个程序对于有一些编程经验的人来说应该很熟悉。它有一个主方法,打印字符串Hello, world!到你的控制台。接下来,为了看到我们如何定义main函数,我们使用了def main()奇怪的语法来定义它。def是 Scala 的关键字,用来声明/定义一个方法,我们将在下一章中更多地涵盖关于方法和不同的写法。所以,我们有一个Array[String]作为这个方法的参数,这是一个可以用于程序的初始配置的字符串数组,也可以省略。然后,我们使用常见的println()方法,它接受一个字符串(或格式化的字符串)并将其打印到控制台。一个简单的 hello world 打开了许多要学习的话题;特别是三个:

● ; ; ;方法(在后面的章节中涵盖)

● ; ; ;对象和类(在后面的章节中涵盖)

● ; ; ;类型推断 - Scala 是一种静态类型语言的原因 - 之前解释过

交互式运行 Scala!

scala命令为你启动了交互式 shell,你可以在其中交互地解释 Scala 表达式:

> scala
Welcome to Scala 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_121).
Type in expressions for evaluation. Or try :help.
scala>
scala> object HelloWorld {
 |   def main(args: Array[String]){
 |     println("Hello, world!")
 |   }
 | }
defined object HelloWorld
scala> HelloWorld.main(Array())
Hello, world!
scala>

快捷键:q代表内部 shell 命令:quit,用于退出解释器。

编译它!

scalac命令,类似于javac命令,编译一个或多个 Scala 源文件,并生成一个字节码作为输出,然后可以在任何 Java 虚拟机上执行。要编译你的 hello world 对象,使用以下命令:

> scalac HelloWorld.scala

默认情况下,scalac将类文件生成到当前工作目录。你可以使用-d选项指定不同的输出目录:

> scalac -d classes HelloWorld.scala

但是,请注意,在执行这个命令之前必须创建一个名为classes的目录。

用 Scala 命令执行它

scala命令执行由解释器生成的字节码:

$ scala HelloWorld

Scala 允许我们指定命令选项,比如-classpath(别名-cp)选项:

$ scala -cp classes HelloWorld

在使用scala命令执行源文件之前,你应该有一个作为应用程序入口点的主方法。否则,你应该有一个扩展Trait Scala.AppObject,然后这个对象内的所有代码将被命令执行。以下是相同的Hello, world!例子,但使用了App特性:

#!/usr/bin/env Scala 
object HelloWorld extends App {  
  println("Hello, world!") 
}
HelloWorld.main(args)

上面的脚本可以直接从命令行运行:

./script.sh

注:我们假设文件script.sh具有执行权限:;

$ sudo chmod +x script.sh

然后,在$PATH环境变量中指定了scala命令的搜索路径。

总结

在本章中,您已经学习了 Scala 编程语言的基础知识、特性和可用的编辑器。我们还简要讨论了 Scala 及其语法。我们演示了安装和设置指南,供那些新手学习 Scala 编程的人参考。在本章后面,您将学习如何编写、编译和执行 Scala 代码示例。此外,我们还为那些来自 Java 背景的人提供了 Scala 和 Java 的比较讨论。下面是 Scala 和 Python 的简要比较:

Scala 是静态类型的,而 Python 是动态类型的。Scala(大多数情况下)采用函数式编程范式,而 Python 不是。Python 具有独特的语法,缺少大部分括号,而 Scala(几乎)总是需要它们。在 Scala 中,几乎所有东西都是表达式;而在 Python 中并非如此。然而,有一些看似复杂的优点。类型复杂性大多是可选的。其次,根据stackoverflow.com/questions/1065720/what-is-the-purpose-of-scala-programming-language/5828684#5828684提供的文档;Scala 编译器就像自由测试和文档一样,随着圈复杂度和代码行数的增加。当 Scala 得到恰当实现时,可以在一致和连贯的 API 背后执行几乎不可能的操作。

在下一章中,我们将讨论如何改进我们对基础知识的理解,了解 Scala 如何实现面向对象的范式,以便构建模块化软件系统。

第二章:面向对象的 Scala

"面向对象的模型使通过增加程序变得容易。实际上,这经常意味着它提供了一种结构化的方式来编写意大利面代码。"

  • Paul Graham

在上一章中,我们看了如何开始使用 Scala 进行编程。如果您正在编写我们在上一章中遵循的过程式程序,可以通过创建过程或函数来强制实现代码的可重用性。但是,如果您继续工作,因此,您的程序会变得更长、更大和更复杂。在某一点上,您甚至可能没有其他更简单的方法来在生产之前组织整个代码。

相反,面向对象编程OOP)范式提供了一个全新的抽象层。您可以通过定义具有相关属性和方法的 OOP 实体(如类)来模块化代码。您甚至可以通过使用继承或接口定义这些实体之间的关系。您还可以将具有类似功能的类分组在一起,例如辅助类;因此,使您的项目突然感觉更宽敞和可扩展。简而言之,面向对象编程语言的最大优势在于可发现性、模块化和可扩展性。

考虑到前面介绍的面向对象编程语言的特性,在本章中,我们将讨论 Scala 中的基本面向对象特性。简而言之,本章将涵盖以下主题:

  • Scala 中的变量

  • Scala 中的方法、类和对象

  • 包和包对象

  • 特征和特征线性化

  • Java 互操作性

然后,我们将讨论模式匹配,这是来自函数式编程概念的一个特性。此外,我们将讨论 Scala 中的一些内置概念,如隐式和泛型。最后,我们将讨论一些广泛使用的构建工具,这些工具对于将我们的 Scala 应用程序构建成 jar 文件是必需的。

Scala 中的变量

在深入了解面向对象编程特性之前,首先需要了解 Scala 中不同类型的变量和数据类型的详细信息。要在 Scala 中声明变量,您需要使用varval关键字。在 Scala 中声明变量的正式语法如下:

val or var VariableName : DataType = Initial_Value

例如,让我们看看如何声明两个数据类型明确指定的变量:

var myVar : Int = 50
val myVal : String = "Hello World! I've started learning Scala."

您甚至可以只声明一个变量而不指定DataType。例如,让我们看看如何使用valvar声明变量,如下所示:

var myVar = 50
val myVal = "Hello World! I've started learning Scala."

Scala 中有两种类型的变量:可变和不可变,可以定义如下:

  • **可变:**其值可以在以后更改的变量

  • **不可变:**一旦设置,其值就无法更改的变量

通常,用var关键字声明可变变量。另一方面,为了指定不可变变量,使用val关键字。为了展示使用可变和不可变变量的示例,让我们考虑以下代码段:

package com.chapter3.OOP 
object VariablesDemo {
  def main(args: Array[String]) {
    var myVar : Int = 50 
    valmyVal : String = "Hello World! I've started learning Scala."  
    myVar = 90  
    myVal = "Hello world!"   
    println(myVar) 
    println(myVal) 
  } 
}

前面的代码在myVar = 90之前都可以正常工作,因为**myVar**是一个可变变量。但是,如果您尝试更改不可变变量(即myVal)的值,如前所示,您的 IDE 将显示编译错误,指出重新分配给val,如下所示:

**图 1:**在 Scala 变量范围内不允许重新分配不可变变量

不要担心看前面的带有对象和方法的代码!我们将在本章后面讨论类、方法和对象,然后事情会变得更清晰。

在 Scala 变量中,我们可以有三种不同的范围,取决于您声明它们的位置:

  • **字段:**这些是属于您 Scala 代码实例的变量。因此,这些字段可以从对象中的每个方法中访问。但是,根据访问修饰符的不同,字段可以被其他类的实例访问。

如前所述,对象字段可以是可变的,也可以是不可变的(根据使用varval声明类型)。但是,它们不能同时是两者。

  • **方法参数:**这些是变量,当调用方法时,可以用它们来传递方法内部的值。方法参数只能从方法内部访问。但是,传递的对象可能可以从外部访问。

需要注意的是,方法参数/参数始终是不可变的,无论指定了什么关键字。

  • **局部变量:**这些变量在方法内部声明,并且可以从方法内部访问。但是,调用代码可以访问返回的值。

引用与值的不可变性

根据前面的部分,val用于声明不可变变量,那么我们可以更改这些变量的值吗?这是否类似于 Java 中的 final 关键字?为了帮助我们更多地了解这一点,我们将使用以下代码片段:

scala> var testVar = 10
testVar: Int = 10

scala> testVar = testVar + 10
testVar: Int = 20

scala> val testVal = 6
testVal: Int = 6

scala> testVal = testVal + 10
<console>:12: error: reassignment to val
 testVal = testVal + 10
 ^
scala>

如果运行上述代码,将会在编译时注意到一个错误,它会告诉您正在尝试重新分配给val变量。一般来说,可变变量带来了性能优势。原因是这更接近计算机的行为,因为引入不可变值会迫使计算机在需要对特定实例进行任何更改(无论多么小)时创建一个全新的对象实例

Scala 中的数据类型

如前所述,Scala 是一种 JVM 语言,因此它与 Java 有很多共同之处。其中一个共同点就是数据类型;Scala 与 Java 共享相同的数据类型。简而言之,Scala 具有与 Java 相同的所有数据类型,具有相同的内存占用和精度。如第一章中所述,介绍 Scala,在 Scala 中几乎到处都是对象。所有数据类型都是对象,您可以按如下方式在其中调用方法:

Sr.No 数据类型和描述
1 Byte:8 位有符号值。范围从-128 到 127
2 Short:16 位有符号值。范围为-32768 至 32767
3 Int:32 位有符号值。范围为-2147483648 至 2147483647
4 Long:64 位有符号值。-9223372036854775808 至 9223372036854775807
5 Float:32 位 IEEE 754 单精度浮点数
6 Double:64 位 IEEE 754 双精度浮点数
7 Char:16 位无符号 Unicode 字符。范围从 U+0000 到 U+FFFF
8 String:一系列字符
9 Boolean:要么是文字true,要么是文字false
10 Unit:对应于无值
11 Null:空值或空引用
12 Nothing:每种其他类型的子类型;不包括任何值
13 Any:任何类型的超类型;任何对象都是Any类型
14 AnyRef:任何引用类型的超类型

**表 1:**Scala 数据类型、描述和范围

在前面的表中列出的所有数据类型都是对象。但是,请注意,没有原始类型,就像在 Java 中一样。这意味着您可以在IntLong等上调用方法。

val myVal = 20
//use println method to print it to the console; you will also notice that if will be inferred as Int
println(myVal + 10)
val myVal = 40
println(myVal * "test")

现在,您可以开始玩弄这些变量。让我们对如何初始化变量和处理类型注释有一些想法。

变量初始化

在 Scala 中,初始化变量一旦声明就是一个好习惯。但是,需要注意的是,未初始化的变量不一定是空值(考虑IntLongDoubleChar等类型),而初始化的变量也不一定是非空值(例如val s: String = null)。实际原因是:

  • 在 Scala 中,类型是从分配的值中推断出来的。这意味着必须为编译器分配一个值才能推断出类型(编译器应该如何考虑这段代码:val a?由于没有给出值,编译器无法推断出类型;由于它无法推断出类型,它将不知道如何初始化它)。

  • 在 Scala 中,大多数时候,你会使用val。由于这些是不可变的,你将无法先声明它们,然后再初始化它们。

尽管 Scala 语言要求你在使用实例变量之前初始化它,但 Scala 不为你的变量提供默认值。相反,你必须手动设置它的值,使用通配符下划线,它就像一个默认值一样,如下所示:

var name:String = _

你可以定义自己的名称,而不是使用val1val2等名称:

scala> val result = 6 * 5 + 8
result: Int = 38

你可以在后续的表达式中使用这些名称,如下所示:

scala> 0.5 * result
res0: Double = 19.0

类型标注

如果你使用valvar关键字来声明一个变量,它的数据类型将根据你为这个变量分配的值自动推断。你还可以在声明时明确指定变量的数据类型。

val myVal : Integer = 10

现在,让我们看一些在使用 Scala 中的变量和数据类型时需要的其他方面。我们将看到如何使用类型标注和lazy变量。

类型标注

类型标注用于告诉编译器你期望从表达式中得到的类型,从所有可能的有效类型中。因此,如果一个类型符合现有的约束,比如变异和类型声明,并且它是表达式所适用的类型之一,或者在范围内有一个适用的转换,那么这个类型就是有效的。因此,从技术上讲,java.lang.String扩展了java.lang.Object,因此任何String也是Object。例如:

scala> val s = "Ahmed Shadman" 
s: String = Ahmed Shadman

scala> val p = s:Object 
p: Object = Ahmed Shadman 

scala>

延迟值

lazy val的主要特点是绑定的表达式不会立即被评估,而是在第一次访问时。这就是vallazy val之间的主要区别所在。当初始访问发生时,表达式被评估,并且结果被绑定到标识符,即lazy val。在后续访问中,不会发生进一步的评估,而是立即返回存储的结果。让我们看一个有趣的例子:

scala> lazy val num = 1 / 0
num: Int = <lazy>

如果你在 Scala REPL 中查看前面的代码,你会注意到代码运行得很好,即使你将一个整数除以 0 也不会抛出任何错误!让我们看一个更好的例子:

scala> val x = {println("x"); 20}
x
x: Int = 20

scala> x
res1: Int = 20
scala>

这样做后,以后可以在需要时访问变量x的值。这些只是使用延迟val概念的一些例子。感兴趣的读者应该访问此页面以获取更多详细信息:blog.codecentric.de/en/2016/02/lazy-vals-scala-look-hood/.

Scala 中的方法、类和对象

在前一节中,我们看到了如何使用 Scala 变量、不同的数据类型以及它们的可变性和不可变性,以及它们的使用范围。然而,在本节中,为了真正理解面向对象编程的概念,我们将处理方法、对象和类。Scala 的这三个特性将帮助我们理解 Scala 的面向对象的特性和其特点。

Scala 中的方法

在这部分中,我们将讨论 Scala 中的方法。当你深入学习 Scala 时,你会发现有很多种方法来定义 Scala 中的方法。我们将以一些方式来演示它们:

def min(x1:Int, x2:Int) : Int = {
  if (x1 < x2) x1 else x2
}

前面的方法声明接受两个变量并返回它们中的最小值。在 Scala 中,所有方法都必须以 def 关键字开头,然后是这个方法的名称。可选地,你可以决定不向方法传递任何参数,甚至决定不返回任何东西。你可能想知道最小值是如何返回的,但我们稍后会讨论这个问题。此外,在 Scala 中,你可以定义不带大括号的方法:

def min(x1:Int, x2:Int):Int= if (x1 < x2) x1 else x2

如果你的方法体很小,你可以像这样声明你的方法。否则,最好使用大括号以避免混淆。如前所述,如果需要,你可以不传递任何参数给方法:

def getPiValue(): Double = 3.14159

带有或不带有括号的方法表示副作用的存在或不存在。此外,它与统一访问原则有着深刻的联系。因此,您也可以避免使用大括号,如下所示:

def getValueOfPi : Double = 3.14159

还有一些方法通过显式指定返回类型来返回值。例如:

def sayHello(person :String) = "Hello " + person + "!"

应该提到的是,前面的代码之所以能够工作,是因为 Scala 编译器能够推断返回类型,就像值和变量一样。

这将返回Hello与传递的人名连接在一起。例如:

scala> def sayHello(person :String) = "Hello " + person + "!"
sayHello: (person: String)String

scala> sayHello("Asif")
res2: String = Hello Asif!

scala>

Scala 中的返回

在学习 Scala 方法如何返回值之前,让我们回顾一下 Scala 方法的结构:

def functionName ([list of parameters]) : [return type] = {
  function body
  value_to_return
}

对于前面的语法,返回类型可以是任何有效的 Scala 数据类型,参数列表将是用逗号分隔的变量列表,参数列表和返回类型是可选的。现在,让我们定义一个方法,它将两个正整数相加并返回结果,这也是一个整数值:

scala> def addInt( x:Int, y:Int ) : Int = {
 |       var sum:Int = 0
 |       sum = x + y
 |       sum
 |    }
addInt: (x: Int, y: Int)Int

scala> addInt(20, 34)
res3: Int = 54

scala>

如果您现在从main()方法中使用真实值调用前面的方法,比如addInt(10, 30),该方法将返回一个整数值和,等于40。由于使用关键字return是可选的,Scala 编译器设计成在没有return关键字的情况下,最后的赋值将被返回。在这种情况下,将返回较大的值:

scala> def max(x1 : Int , x2: Int)  = {
 |     if (x1>x2) x1 else x2
 | }
max: (x1: Int, x2: Int)Int

scala> max(12, 27)
res4: Int = 27

scala>

干得好!我们已经看到了如何在 Scala REPL 中使用变量以及如何声明方法。现在,是时候看看如何将它们封装在 Scala 方法和类中了。下一节将讨论 Scala 对象。

Scala 中的类

类被认为是一个蓝图,然后你实例化这个类以创建实际上将在内存中表示的东西。它们可以包含方法、值、变量、类型、对象、特征和类,这些统称为成员。让我们通过以下示例来演示:

class Animal {
  var animalName = null
  var animalAge = -1
  def setAnimalName (animalName:String)  {
    this.animalName = animalName
  }
  def setAnaimalAge (animalAge:Int) {
    this.animalAge = animalAge
  }
  def getAnimalName () : String = {
    animalName
  }
  def getAnimalAge () : Int = {
    animalAge
  }
}

我们有两个变量animalNameanimalAge以及它们的设置器和获取器。现在,我们如何使用它们来解决我们的目的呢?这就是 Scala 对象的用法。现在,我们将讨论 Scala 对象,然后我们将追溯到我们的下一个讨论。

Scala 中的对象

Scala 中的object的含义与传统的 OOP 有些不同,这种差异应该得到解释。特别是在 OOP 中,对象是类的一个实例,而在 Scala 中,任何声明为对象的东西都不能被实例化!object是 Scala 中的一个关键字。在 Scala 中声明对象的基本语法如下:

object <identifier> [extends <identifier>] [{ fields, methods, and classes }]

为了理解前面的语法,让我们重新看一下 hello world 程序:

object HelloWorld {
  def main(args : Array[String]){
    println("Hello world!")
  }
}

这个 hello world 示例与 Java 的示例非常相似。唯一的区别是 main 方法不在一个类中,而是在一个对象中。在 Scala 中,关键字 object 可以表示两种不同的东西:

  • 就像在 OOP 中,一个对象可以表示一个类的实例

  • 用于描述一种非常不同的实例对象,称为Singleton

单例和伴生对象

在这一小节中,我们将看到 Scala 和 Java 中的单例对象之间的比较分析。单例模式的理念是确保一个类的实例只能存在一个。以下是 Java 中单例模式的示例:

public class DBConnection {
  private static DBConnection dbInstance;
  private DBConnection() {
  }
  public static DBConnection getInstance() {
    if (dbInstance == null) {
      dbInstance = new DBConnection();
    }
    return dbInstance;
  }
}

Scala 对象也做了类似的事情,并且它由编译器很好地处理。由于只会有一个实例,因此在这里没有对象创建的方式:

**图 3:**Scala 中的对象创建

伴生对象

当一个singleton object与一个类同名时,它被称为companion object。伴生对象必须在与类相同的源文件中定义。让我们通过这个例子来演示:

class Animal {
  var animalName:String  = "notset"
  def setAnimalName(name: String) {
    animalName = name
  }
  def getAnimalName: String = {
    animalName
  }
  def isAnimalNameSet: Boolean = {
    if (getAnimalName == "notset") false else true
  }
}

以下是通过伴生对象调用方法的方式(最好与相同的名称 - 也就是Animal):

object Animal{
  def main(args: Array[String]): Unit= {
    val obj: Animal = new Animal
    var flag:Boolean  = false        
    obj.setAnimalName("dog")
    flag = obj.isAnimalNameSet
    println(flag)  // prints true 

    obj.setAnimalName("notset")
    flag = obj.isAnimalNameSet
    println(flag)   // prints false     
  }
}

Java 的等价物将非常相似,如下所示:

public class Animal {
  public String animalName = "null";
  public void setAnimalName(String animalName) {
    this.animalName = animalName;
  }
  public String getAnimalName() {
    return animalName;
  }
  public boolean isAnimalNameSet() {
    if (getAnimalName() == "notset") {
      return false;
    } else {
      return true;
    }
  }

  public static void main(String[] args) {
    Animal obj = new Animal();
    boolean flag = false;         
    obj.setAnimalName("dog");
    flag = obj.isAnimalNameSet();
    System.out.println(flag);        

    obj.setAnimalName("notset");
    flag = obj.isAnimalNameSet();
    System.out.println(flag);
  }
}

干得好!到目前为止,我们已经看到了如何使用 Scala 对象和类。然而,使用方法来实现和解决数据分析问题的方法更加重要。因此,我们现在将简要介绍如何使用 Scala 方法。

object RunAnimalExample {
  val animalObj = new Animal
  println(animalObj.getAnimalName) //prints the initial name
  println(animalObj.getAnimalAge) //prints the initial age
  // Now try setting the values of animal name and age as follows:   
  animalObj.setAnimalName("dog") //setting animal name
  animalObj.setAnaimalAge(10) //seting animal age
  println(animalObj.getAnimalName) //prints the new name of the animal 
  println(animalObj.getAnimalAge) //Prints the new age of the animal
}

输出如下:

notset 
-1 
dog 
10

现在,让我们在下一节中简要概述 Scala 类的可访问性和可见性。

比较和对比:val 和 final

与 Java 一样,Scala 中也存在 final 关键字,它的工作方式与 val 关键字类似。为了区分 Scala 中的valfinal关键字,让我们声明一个简单的动物类,如下所示:

class Animal {
  val age = 2  
}

如第一章中所述,Scala 简介,在列出 Scala 特性时,Scala 可以覆盖 Java 中不存在的变量:

class Cat extends Animal{
  override val age = 3
  def printAge ={
    println(age)
  }
}

现在,在深入讨论之前,关键字extends的快速讨论是必需的。有关详细信息,请参阅以下信息框。

使用 Scala,类可以是可扩展的。使用 extends 关键字的子类机制使得可以通过继承给定超类的所有成员并定义额外的类成员来专门化类。让我们看一个例子,如下所示:

class Coordinate(xc: Int, yc: Int) {

val x: Int = xc

val y: Int = yc

def move(dx: Int, dy: Int): Coordinate = new Coordinate(x + dx, y + dy)

}

class ColorCoordinate(u: Int, v: Int, c: String) extends Coordinate(u, v) {

val color: String = c

def compareWith(pt: ColorCoordinate): Boolean = (pt.x == x) && (pt.y == y) && (pt.color == color)

override def move(dx: Int, dy: Int): ColorCoordinate = new ColorCoordinate(x + dy, y + dy, color)

}

但是,如果我们在Animal类中将年龄变量声明为 final,那么Cat类将无法覆盖它,并且将会出现以下错误。对于这个Animal示例,您应该学会何时使用final关键字。让我们看一个例子:

scala> class Animal {
 |     final val age = 3
 | }
defined class Animal
scala> class Cat extends Animal {
 |     override val age = 5
 | }
<console>:13: error: overriding value age in class Animal of type Int(3);
 value age cannot override final member
 override val age = 5
 ^
scala>

干得好!为了实现最佳封装-也称为信息隐藏-您应该始终使用最少可见性声明方法。在下一小节中,我们将学习类、伴生对象、包、子类和项目的访问和可见性如何工作。

访问和可见性

在本小节中,我们将尝试理解 OOP 范式中 Scala 变量和不同数据类型的访问和可见性。让我们看看 Scala 中的访问修饰符。Scala 的类似之一:

修饰符 伴生对象 子类 项目
默认/无修饰符
受保护
私有

公共成员:与私有和受保护成员不同,对于公共成员,不需要为公共成员指定 public 关键字。公共成员没有显式的修饰符。这些成员可以从任何地方访问。例如:

class OuterClass { //Outer class
  class InnerClass {
    def printName() { println("My name is Asif Karim!") }

    class InnerMost { //Inner class
      printName() // OK
    }
  }
  (new InnerClass).printName() // OK because now printName() is public
}

私有成员:私有成员仅在包含成员定义的类或对象内部可见。让我们看一个例子,如下所示:

package MyPackage {
  class SuperClass {
    private def printName() { println("Hello world, my name is Asif Karim!") }
  }   
  class SubClass extends SuperClass {
    printName() //ERROR
  }   
  class SubsubClass {
    (new SuperClass).printName() // Error: printName is not accessible
  }
}

受保护成员:受保护成员只能从定义成员的类的子类中访问。让我们看一个例子,如下所示:

package MyPackage {
  class SuperClass {
    protected def printName() { println("Hello world, my name is Asif
                                         Karim!") }
  }   
  class SubClass extends SuperClass {
    printName()  //OK
  }   
  class SubsubClass {
    (new SuperClass).printName() // ERROR: printName is not accessible
  }
}

Scala 中的访问修饰符可以通过限定符进行增强。形式为private[X]protected[X]的修饰符意味着访问是私有的或受保护的,直到X,其中X指定封闭的包、类或单例对象。让我们看一个例子:

package Country {
  package Professional {
    class Executive {
      private[Professional] var jobTitle = "Big Data Engineer"
      private[Country] var friend = "Saroar Zahan" 
      protected[this] var secret = "Age"

      def getInfo(another : Executive) {
        println(another.jobTitle)
        println(another.friend)
        println(another.secret) //ERROR
        println(this.secret) // OK
      }
    }
  }
}

在前面的代码段中有一个简短的说明:

  • 变量jboTitle将对封闭包Professional中的任何类可访问

  • 变量friend将对封闭包Country中的任何类可访问

  • 变量secret只能在实例方法(this)中被隐式对象访问

如果您看一下前面的例子,我们使用了关键字package。然而,我们到目前为止还没有讨论这个问题。但不要担心;本章后面将有一个专门的部分。构造函数是任何面向对象编程语言的一个强大特性。Scala 也不例外。现在,让我们简要概述一下构造函数。

构造函数

在 Scala 中,构造函数的概念和用法与 C#或 Java 中的有些不同。Scala 中有两种类型的构造函数 - 主构造函数和辅助构造函数。主构造函数是类的主体,其参数列表紧跟在类名后面。

例如,以下代码段描述了在 Scala 中使用主构造函数的方法:

class Animal (animalName:String, animalAge:Int) {
  def getAnimalName () : String = {
    animalName
  }
  def getAnimalAge () : Int = {
    animalAge
  }
}

现在,要使用前面的构造函数,这个实现与之前的实现类似,只是没有设置器和获取器。相反,我们可以在这里获取动物的名称和年龄:

object RunAnimalExample extends App{
  val animalObj = new animal("Cat",-1)
  println(animalObj.getAnimalName)
  println(animalObj.getAnimalAge)
}

在类定义时给出参数以表示构造函数。如果我们声明了一个构造函数,那么就不能在不提供构造函数中指定的参数的默认值的情况下创建类。此外,Scala 允许在不提供必要参数给其构造函数的情况下实例化对象:当所有构造函数参数都有默认值定义时会发生这种情况。

尽管使用辅助构造函数有一些限制,但我们可以自由地添加任意数量的额外辅助构造函数。辅助构造函数必须在其主体的第一行调用在其之前声明的另一个辅助构造函数或主构造函数。为了遵守这个规则,每个辅助构造函数最终都会直接或间接地调用主构造函数。

例如,以下代码段演示了在 Scala 中使用辅助构造函数:

class Hello(primaryMessage: String, secondaryMessage: String) {
  def this(primaryMessage: String) = this(primaryMessage, "")
  // auxilary constructor
  def sayHello() = println(primaryMessage + secondaryMessage)
}
object Constructors {
  def main(args: Array[String]): Unit = {
    val hello = new Hello("Hello world!", " I'm in a trouble,
                          please help me out.")
    hello.sayHello()
  }
}

在之前的设置中,我们在主构造函数中包含了一个次要(即第二个)消息。主构造函数将实例化一个新的Hello对象。方法sayHello()将打印连接的消息。

辅助构造函数:在 Scala 中,为 Scala 类定义一个或多个辅助构造函数可以让类的消费者以不同的方式创建对象实例。在类中将辅助构造函数定义为 this 的方法。您可以定义多个辅助构造函数,但它们必须具有不同的签名(参数列表)。此外,每个构造函数必须调用先前定义的构造函数之一。

现在让我们来看一下 Scala 中另一个重要但相对较新的概念,称为特征。我们将在下一节中讨论这个问题。

Scala 中的特征

Scala 中的一个新特性是特征,它与 Java 中接口的概念非常相似,只是它还可以包含具体方法。尽管 Java 8 已经支持这一点。另一方面,特征是 Scala 中的一个新概念。但这个特性已经存在于面向对象编程中。因此,它们看起来像抽象类,只是它们没有构造函数。

特征语法

您需要使用trait关键字来声明一个特征,后面应该跟着特征名称和主体:

trait Animal {
  val age : Int
  val gender : String
  val origin : String
 }

扩展特征

为了扩展特征或类,您需要使用extend关键字。特征不能被实例化,因为它可能包含未实现的方法。因此,必须实现特征中的抽象成员:

trait Cat extends Animal{ }

不允许值类扩展特征。为了允许值类扩展特征,引入了通用特征,它扩展了Any。例如,假设我们已经定义了以下特征:

trait EqualityChecking {
  def isEqual(x: Any): Boolean
  def isNotEqual(x: Any): Boolean = !isEqual(x)
}

现在,要使用通用特征在 Scala 中扩展前面的特征,我们遵循以下代码段:

trait EqualityPrinter extends Any {
  def print(): Unit = println(this)
}

那么,在 Scala 中抽象类和特征之间有什么区别呢?正如您所见,Scala 中的抽象类可以具有构造参数、类型参数和多个参数。但是,Scala 中的特征只能具有类型参数。

如果一个特征不包含任何实现代码,那么它才是完全可互操作的。此外,Scala 特征在 Scala 2.12 中与 Java 接口完全可互操作。因为 Java 8 也允许在其接口中进行方法实现。

可能还有其他情况适用于特征,例如,抽象类可以扩展特征,或者如果需要,任何普通类(包括 case 类)都可以扩展现有的特征。例如,抽象类也可以扩展特征:

abstract class Cat extends Animal { }

最后,普通的 Scala 类也可以扩展 Scala 特征。由于类是具体的(即可以创建实例),特征的抽象成员应该被实现。在下一节中,我们将讨论 Scala 代码的 Java 互操作性。现在让我们来了解 OOP 中的另一个重要概念,称为抽象类。我们将在下一节中讨论这个问题。

抽象类

在 Scala 中,抽象类可以具有构造参数以及类型参数。Scala 中的抽象类与 Java 完全可互操作。换句话说,可以在 Java 代码中调用它们,而无需任何中间包装器。

那么,在 Scala 中抽象类和特征之间有什么区别呢?正如您所见,Scala 中的抽象类可以具有构造参数、类型参数和多个参数。但是,Scala 中的特征只能具有类型参数。以下是抽象类的一个简单示例:

abstract class Animal(animalName:String = "notset") {
  //Method with definition/return type
  def getAnimalAge
  //Method with no definition with String return type
  def getAnimalGender : String
  //Explicit way of saying that no implementation is present
  def getAnimalOrigin () : String {} 
  //Method with its functionality implemented
  //Need not be implemented by subclasses, can be overridden if required
  def getAnimalName : String = {
    animalName
  }
}

为了通过另一个类扩展这个类,我们需要实现之前未实现的方法getAnimalAgegetAnimalGendergetAnimalOrigin。对于getAnimalName,我们可以覆盖它,也可以不覆盖,因为它的实现已经存在。

抽象类和 override 关键字

如果要覆盖父类的具体方法,则需要 override 修饰符。但是,如果要实现抽象方法,则不一定需要添加 override 修饰符。Scala 使用override关键字来覆盖父类的方法。例如,假设您有以下抽象类和一个printContents()方法来在控制台上打印您的消息:

abstract class MyWriter {
  var message: String = "null"
  def setMessage(message: String):Unit
  def printMessage():Unit
}

现在,添加前面的抽象类的具体实现以在控制台上打印内容,如下所示:

class ConsolePrinter extends MyWriter {
  def setMessage(contents: String):Unit= {
    this.message = contents
  }

  def printMessage():Unit= {
    println(message)
  }
}

其次,如果您想创建一个特征来修改前面的具体类的行为,如下所示:

trait lowerCase extends MyWriter {
  abstract override def setMessage(contents: String) = printMessage()
}

如果您仔细查看前面的代码段,您会发现两个修饰符(即 abstract 和 override)。现在,在前面的设置下,您可以执行以下操作来使用前面的类:

val printer:ConsolePrinter = new ConsolePrinter()
printer.setMessage("Hello! world!")
printer.printMessage()

总之,我们可以在方法前面添加override关键字以使其按预期工作。

Scala 中的 Case 类

case类是一个可实例化的类,其中包括几个自动生成的方法。它还包括一个自动生成的伴生对象,其中包括自己的自动生成的方法。Scala 中 case 类的基本语法如下:

case class <identifier> ([var] <identifier>: <type>[, ... ])[extends <identifier>(<input parameters>)] [{ fields and methods }]

Case 类可以进行模式匹配,并且已经实现了以下方法:hashCode方法(位置/范围是类),apply方法(位置/范围是对象),copy方法(位置/范围是类),equals方法(位置/范围是类),toString方法(位置/范围是类),和unapply方法(位置/范围是对象)。

与普通类一样,case 类自动为构造参数定义 getter 方法。为了对前面的特性或 case 类有实际的了解,让我们看下面的代码段:

package com.chapter3.OOP 
object CaseClass {
  def main(args: Array[String]) {
    case class Character(name: String, isHacker: Boolean) // defining a
                               class if a person is a computer hacker     
    //Nail is a hacker
    val nail = Character("Nail", true)     
    //Now let's return a copy of the instance with any requested changes
    val joyce = nail.copy(name = "Joyce")
    // Let's check if both Nail and Joyce are Hackers
    println(nail == joyce)    
    // Let's check if both Nail and Joyce equal
    println(nail.equals(joyce))        
    // Let's check if both Nail and Nail equal
    println(nail.equals(nail))    
    // Let's the hasing code for nail
    println(nail.hashCode())    
    // Let's the hasing code for nail
    println(nail)
    joyce match {
      case Character(x, true) => s"$x is a hacker"
      case Character(x, false) => s"$x is not a hacker"
    }
  }
}

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

false 
false 
true 
-112671915 
Character(Nail,true) 
Joyce is a hacker

对于 REPL 和正则表达式匹配的输出,如果您执行前面的代码(除了Objectmain方法),您应该能够看到更多的交互式输出,如下所示:

图 2: 用于 case 类的 Scala REPL

包和包对象

就像 Java 一样,包是一个特殊的容器或对象,其中包含/定义一组对象、类甚至包。每个 Scala 文件都自动导入以下内容:

  • java.lang._

  • scala._

  • scala.Predef._

以下是基本导入的示例:

// import only one member of a package
import java.io.File
// Import all members in a specific package
import java.io._
// Import many members in a single import statement
import java.io.{File, IOException, FileNotFoundException}
// Import many members in a multiple import statement
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException

您甚至可以在导入时重命名成员,这是为了避免具有相同成员名称的包之间的冲突。这种方法也被称为类别名

import java.util.{List => UtilList}
import java.awt.{List => AwtList}
// In the code, you can use the alias that you have created
val list = new UtilList

正如在第一章中所述,《Scala 简介》,您还可以导入包的所有成员,但有些成员也被称为成员隐藏

import java.io.{File => _, _}

如果您在 REPL 中尝试了这个,它只是告诉编译器定义的类或对象的完整规范名称:

package fo.ba
class Fo {
  override def toString = "I'm fo.ba.Fo"
}

您甚至可以使用大括号定义包的样式。您可以有一个单一的包和嵌套包,即包中的包。例如,以下代码段定义了一个名为singlePackage的单一包,其中包含一个名为Test的单一类。另一方面,Test类包含一个名为toString()的单一方法。

package singlePack {
  class Test { override def toString = "I am SinglePack.Test" }
}

现在,您可以将包进行嵌套。换句话说,您可以以嵌套的方式拥有多个包。例如,对于下面的情况,我们有两个包,分别是NestParentPackNestChildPack,每个包都包含自己的类。

package nestParentPack {
  class Test { override def toString = "I am NestParentPack.Test" }

  package nestChildPack {
    class TestChild { override def toString = "I am nestParentPack.nestChildPack.TestChild" }
  }
}

让我们创建一个新对象(我们将其命名为MainProgram),在其中我们将调用刚刚定义的方法和类:

object MainProgram {
  def main(args: Array[String]): Unit = {
    println(new nestParentPack.Test())
    println(new nestParentPack.nestChildPack.TestChild())
  }
}

您将在互联网上找到更多的例子,描述了包和包对象的复杂用例。在下一节中,我们将讨论 Scala 代码的 Java 互操作性。

Java 互操作性

Java 是最流行的语言之一,许多程序员将 Java 编程作为他们进入编程世界的第一步。自 1995 年首次发布以来,Java 的受欢迎程度一直在增加。Java 之所以受欢迎有很多原因。其中之一是其平台的设计,使得任何 Java 代码都将被编译为字节码,然后在 JVM 上运行。有了这一绝妙的特性,Java 语言可以编写一次,然后在任何地方运行。因此,Java 是一种跨平台语言。

此外,Java 得到了来自其社区的大量支持和许多包的支持,这些包将帮助您借助这些包实现您的想法。然后是 Scala,它具有许多 Java 所缺乏的特性,例如类型推断和可选的分号,不可变集合直接内置到 Scala 核心中,以及更多功能(在第一章中介绍,《Scala 简介》)。Scala 也像 Java 一样在 JVM 上运行。

Scala 中的分号: 分号是完全可选的,当需要在一行上编写多行代码时才需要。这可能是为什么编译器不会抱怨如果在行尾放上一个分号的原因:它被认为是一个代码片段,后面跟着一个空的代码片段,巧合的是,它们都在同一行上。

正如您所看到的,Scala 和 Java 都在 JVM 上运行,因此在同一个程序中同时使用它们是有意义的,而且编译器也不会有任何投诉。让我们通过一个示例来演示这一点。考虑以下 Java 代码:

ArrayList<String> animals = new ArrayList<String>();
animals.add("cat");
animals.add("dog");
animals.add("rabbit");
for (String animal : animals) {
  System.out.println(animal);
}

为了在 Scala 中编写相同的代码,您可以利用 Java 包。让我们借助使用 Java 集合(如ArrayList)将前面的示例翻译成 Scala:

import java.util.ArrayList
val animals = new ArrayList[String]
animals.add("cat")
animals.add("dog")
animals.add("rabbit")
for (animal <- animals) {
  println(animal)
}

前面的混合适用于 Java 的标准包,但是如果您想使用未打包在 Java 标准库中的库,甚至想使用自己的类。那么,您需要确保它们位于类路径中。

模式匹配

Scala 的一个广泛使用的特性是模式匹配。每个模式匹配都有一组备选项,每个备选项都以关键字case开头。每个备选项都有一个模式和表达式,如果模式匹配成功,箭头符号=>将模式与表达式分开。以下是一个示例,演示如何匹配整数:

object PatternMatchingDemo1 {
  def main(args: Array[String]) {
    println(matchInteger(3))
  }   
  def matchInteger(x: Int): String = x match {
    case 1 => "one"
    case 2 => "two"
    case _ => "greater than two"
  }
}

您可以通过将此文件保存为PatternMatchingDemo1.scala并使用以下命令来运行前面的程序。只需使用以下命令:

>scalac Test.scala
>scala Test

您将获得以下输出:

Greater than two

case 语句用作将整数映射到字符串的函数。以下是另一个示例,用于匹配不同类型:

object PatternMatchingDemo2 {
  def main(args: Array[String]): Unit = {
    println(comparison("two"))
    println(comparison("test"))
    println(comparison(1))
  }
  def comparison(x: Any): Any = x match {
    case 1 => "one"
    case "five" => 5
    case _ => "nothing else"
  }
}

您可以通过对之前的示例执行相同的操作来运行此示例,并将获得以下输出:

nothing else
nothing else
one

模式匹配是一种检查值与模式匹配的机制。成功的匹配还可以将值解构为其组成部分。它是 Java 中 switch 语句的更强大版本,也可以用来代替一系列的if...else语句。您可以通过参考 Scala 的官方文档(URL:www.scala-lang.org/files/archive/spec/2.11/08-pattern-matching.html)了解更多关于模式匹配的内容。

在下一节中,我们将讨论 Scala 中的一个重要特性,它使我们能够自动传递一个值,或者说自动进行一种类型到另一种类型的转换。

Scala 中的隐式

隐式是 Scala 引入的另一个令人兴奋和强大的特性,它可以指两种不同的东西:

  • 可以自动传递的值

  • 从一种类型自动转换为另一种类型

  • 它们可以用于扩展类的功能

实际的自动转换可以通过隐式 def 完成,如下面的示例所示(假设您正在使用 Scala REPL):

scala> implicit def stringToInt(s: String) = s.toInt
stringToInt: (s: String)Int

现在,在我的范围内有了前面的代码,我可以做类似这样的事情:

scala> def add(x:Int, y:Int) = x + y
add: (x: Int, y: Int)Int

scala> add(1, "2")
res5: Int = 3
scala>

即使传递给add()的参数之一是String(并且add()需要您提供两个整数),在范围内具有隐式转换允许编译器自动从String转换为Int。显然,这个特性可能非常危险,因为它使代码变得不太可读;而且,一旦定义了隐式转换,就不容易告诉编译器何时使用它,何时避免使用它。

第一种隐式是可以自动传递隐式参数的值。这些参数在调用方法时像任何普通参数一样传递,但 Scala 的编译器会尝试自动填充它们。如果 Scala 的编译器无法自动填充这些参数,它会报错。以下是演示第一种隐式的示例:

def add(implicit num: Int) = 2 + num

通过这样做,您要求编译器查找num的隐式值,如果在调用方法时未提供。您可以像这样向编译器定义隐式值:

implicit val adder = 2

然后,我们可以简单地这样调用函数:

add

在这里,没有传递参数,因此 Scala 的编译器将寻找隐式值,即2,然后返回方法调用的输出4。然而,还有很多其他选项,引发了一些问题,比如:

  • 一个方法可以同时包含显式参数和隐式参数吗?答案是可以。让我们在 Scala REPL 上看一个例子:
 scala> def helloWold(implicit a: Int, b: String) = println(a, b)
 helloWold: (implicit a: Int, implicit b: String)Unit

 scala> val i = 2
 i: Int = 2

 scala> helloWorld(i, implicitly)
 (2,)

 scala>

  • 一个方法可以包含多个隐式参数吗?答案是可以。让我们在 Scala REPL 上看一个例子:
 scala> def helloWold(implicit a: Int, b: String) = println(a, b)
 helloWold: (implicit a: Int, implicit b: String)Unit

 scala> helloWold(i, implicitly)
 (1,)

 scala>

  • 隐式参数可以显式提供吗?答案是可以。让我们在 Scala REPL 上看一个例子:
 scala> def helloWold(implicit a: Int, b: String) = println(a, b)
 helloWold: (implicit a: Int, implicit b: String)Unit

 scala> helloWold(20, "Hello world!")
 (20,Hello world!)
 scala>

如果同一作用域中包含更多的隐式参数,会发生什么,隐式参数是如何解析的?隐式参数的解析顺序是否有任何顺序?要了解这两个问题的答案,请参考此 URL:stackoverflow.com/questions/9530893/good-example-of-implicit-parameter-in-scala

在下一节中,我们将讨论 Scala 中的泛型,并提供一些示例。

Scala 中的泛型

泛型类是以类型作为参数的类。它们对于集合类特别有用。泛型类可以用于日常数据结构实现,如栈、队列、链表等。我们将看到一些示例。

定义一个泛型类

通用类在方括号[]内以类型作为参数。一个惯例是使用字母A作为类型参数标识符,尽管可以使用任何参数名称。让我们看一个 Scala REPL 的最小示例,如下所示:

scala> class Stack[A] {
 |       private var elements: List[A] = Nil
 |       def push(x: A) { elements = x :: elements }
 |       def peek: A = elements.head
 |       def pop(): A = {
 |         val currentTop = peek
 |         elements = elements.tail
 |         currentTop
 |       }
 |     }
defined class Stack
scala>

前面的Stack类的实现将任何类型 A 作为参数。这意味着底层列表var elements: List[A] = Nil只能存储类型为A的元素。过程 def push 只接受类型为A的对象(注意:elements = x :: elements重新分配元素到一个新列表,该列表由将x前置到当前元素创建)。让我们看一个如何使用前面的类来实现一个栈的示例:

object ScalaGenericsForStack {
  def main(args: Array[String]) {
    val stack = new Stack[Int]
    stack.push(1)
    stack.push(2)
    stack.push(3)
    stack.push(4)
    println(stack.pop) // prints 4
    println(stack.pop) // prints 3
    println(stack.pop) // prints 2
    println(stack.pop) // prints 1
  }
}

输出如下:

4
3
2
1

第二个用例可能也是实现一个链表。例如,如果 Scala 没有一个链表类,而您想要编写自己的链表,您可以像这样编写基本功能:

class UsingGenericsForLinkedList[X] { // Create a user specific linked list to print heterogenous values
  private class NodeX {
    var next: Node[X] = _
    override def toString = elem.toString
  }

  private var head: Node[X] = _

  def add(elem: X) { //Add element in the linekd list
    val value = new Node(elem)
    value.next = head
    head = value
  }

  private def printNodes(value: Node[X]) { // prining value of the nodes
    if (value != null) {
      println(value)
      printNodes(value.next)
    }
  }
  def printAll() { printNodes(head) } //print all the node values at a time
}

现在,让我们看看如何使用前面的链表实现:

object UsingGenericsForLinkedList {
  def main(args: Array[String]) {
    // To create a list of integers with this class, first create an instance of it, with type Int:
    val ints = new UsingGenericsForLinkedList[Int]()
    // Then populate it with Int values:
    ints.add(1)
    ints.add(2)
    ints.add(3)
    ints.printAll()

    // Because the class uses a generic type, you can also create a LinkedList of String:
    val strings = new UsingGenericsForLinkedList[String]()
    strings.add("Salman Khan")
    strings.add("Xamir Khan")
    strings.add("Shah Rukh Khan")
    strings.printAll()

    // Or any other type such as Double to use:
    val doubles = new UsingGenericsForLinkedList[Double]()
    doubles.add(10.50)
    doubles.add(25.75)
    doubles.add(12.90)
    doubles.printAll()
  }
}

输出如下:

3
2
1
Shah Rukh Khan
Aamir Khan
Salman Khan
12.9
25.75
10.5

总之,在 Scala 的基本级别上,创建一个泛型类就像在 Java 中创建一个泛型类一样,只是方括号的例外。好了!到目前为止,我们已经了解了一些基本功能,以便开始使用面向对象的编程语言 Scala。

尽管我们还没有涵盖一些其他方面,但我们仍然认为你可以继续工作。在第一章 Scala 简介中,我们讨论了 Scala 的可用编辑器。在接下来的部分,我们将看到如何设置构建环境。具体来说,我们将涵盖三种构建系统,如 Maven、SBT 和 Gradle。

SBT 和其他构建系统

对于任何企业软件项目,使用构建工具是必要的。有许多构建工具可供选择,例如 Maven、Gradle、Ant 和 SBT。一个好的构建工具选择是让您专注于编码而不是编译复杂性的工具。

使用 SBT 构建

在这里,我们将简要介绍 SBT。在继续之前,您需要使用官方安装方法(URL:www.scala-sbt.org/release/docs/Setup.html)安装 SBT。

因此,让我们从 SBT 开始,在终端中演示 SBT 的使用。对于这个构建工具教程,我们假设您的源代码文件在一个目录中。您需要执行以下操作:

  1. 打开终端并使用cd命令将路径更改为该目录,

  2. 创建一个名为build.sbt的构建文件。

  3. 然后,使用以下行填充该构建文件:

           name := "projectname-sbt"
           organization :="org.example"
           scalaVersion :="2.11.8"
           version := "0.0.1-SNAPSHOT"

让我们看看这些行的含义:

  • name定义了项目的名称。这个名称将在生成的 jar 文件中使用。

  • organization是一个命名空间,用于防止具有相似名称的项目之间的冲突。

  • scalaVersion设置您要构建的 Scala 版本。

  • Version指定了项目的当前构建版本,您可以使用-SNAPSHOT来表示尚未发布的版本。

创建此构建文件后,您需要在终端中运行sbt命令,然后会为您打开一个以>开头的提示符。在此提示符中,您可以输入compile以编译您的 Scala 或 Java 源文件。此外,您还可以在 SBT 提示符中输入命令以运行可运行的程序。或者您可以使用 SBT 提示符中的 package 命令生成一个.jar文件,该文件将存在一个名为target的子目录中。要了解有关 SBT 和更复杂示例的更多信息,您可以参考 SBT 的官方网站。

Eclipse 中的 Maven

在 Eclipse 中使用 Maven 作为构建工具作为 Scala IDE 非常容易和直接。在本节中,我们将通过截图演示如何在 Eclipse 和 Maven 中使用 Scala。要在 Eclipse 中使用 Maven,您需要安装其插件,这将在不同版本的 Eclipse 中有所不同。安装 Maven 插件后,您会发现它不直接支持 Scala。为了使 Maven 插件支持 Scala 项目,我们需要安装一个名为 m2eclipse-scala 的连接器。

如果您在尝试向 Eclipse 添加新软件时粘贴此 URL(alchim31.free.fr/m2e-scala/update-site),您会发现 Eclipse 理解该 URL 并建议您添加一些插件:

**图 4:**在 Eclipse 上安装 Maven 插件以启用 Maven 构建

安装 Maven 和 Scala 支持连接器后,我们将创建一个新的 Scala Maven 项目。要创建一个新的 Scala Maven 项目,您需要导航到新建 | 项目 | 其他,然后选择 Maven 项目。之后,选择 net.alchim31.maven 作为 Group Id 的选项:

**图 5:**在 Eclipse 上创建一个 Scala Maven 项目

选择后,您需要按照向导输入所需的值,如 Group Id 等。然后,点击完成,这样就在工作区中创建了具有 Maven 支持的第一个 Scala 项目。在项目结构中,您会发现一个名为pom.xml的文件,您可以在其中添加所有依赖项和其他内容。

有关如何向项目添加依赖项的更多信息,请参考此链接:docs.scala-lang.org/tutorials/scala-with-maven.html

作为本节的延续,我们将在接下来的章节中展示如何构建用 Scala 编写的 Spark 应用程序。

Eclipse 中的 Gradle

Gradle Inc.为 Eclipse IDE 提供了 Gradle 工具和插件。该工具允许您在 Eclipse IDE 中创建和导入启用 Gradle 的项目。此外,它允许您运行 Gradle 任务并监视任务的执行。

Eclipse 项目本身称为Buildship。该项目的源代码可在 GitHub 上找到:github.com/eclipse/Buildship

在 Eclipse 上安装 Gradle 插件有两个选项。如下所示:

  • 通过 Eclipse Marketplace

  • 通过 Eclipse 更新管理器

首先,让我们看看如何在 Eclipse 上使用 Marketplace 安装 Grade 构建的 Buildship 插件:Eclipse | 帮助 | Eclipse Marketplace:

**图 6:**在 Eclipse 上使用 Marketplace 安装 Grade 构建的 Buildship 插件

在 Eclipse 上安装 Gradle 插件的第二个选项是从帮助 | 安装新软件...菜单路径安装 Gradle 工具,如下图所示:

**图 7:**在 Eclipse 上使用安装新软件安装 Grade 构建的 Buildship 插件

例如,可以使用以下 URL 来下载 Eclipse 4.6(Neon)版本:download.eclipse.org/releases/neon

一旦您按照之前描述的任一方法安装了 Gradle 插件,Eclipse Gradle 将帮助您设置基于 Scala 的 Gradle 项目:文件|新建|项目|选择向导|Gradle|Gradle 项目。

******图 8:**在 Eclipse 上创建 Gradle 项目

现在,如果您按下 Next>,您将获得以下向导,以指定项目名称以满足您的目的:

******图 9:**在 Eclipse 上创建 Gradle 项目并指定项目名称

最后,按下 Finish 按钮创建项目。按下 Finish 按钮实质上触发了 Gradle init --type java-library命令并导入了项目。然而,如果您想在创建之前预览配置,请按 Next>以获得以下向导:

**图 10:**在创建之前预览配置

最后,您将在 Eclipse 上看到以下项目结构。然而,我们将在后面的章节中看到如何使用 Maven、SBT 和 Gradle 构建 Spark 应用程序。原因是,在开始项目之前,更重要的是学习 Scala 和 Spark。

**图 11:**在 Eclipse 上使用 Gradle 的项目结构

在本节中,我们已经看到了三种构建系统,包括 SBT、Maven 和 Gradle。然而,在接下来的章节中,我将尽量主要使用 Maven,因为它简单且代码兼容性更好。然而,在后面的章节中,我们将使用 SBT 来创建您的 Spark 应用程序的 JARS。

摘要

以合理的方式构建代码,使用类和特征增强了您的代码的可重用性,使用泛型创建了一个具有标准和广泛工具的项目。改进基础知识,了解 Scala 如何实现面向对象范式,以允许构建模块化软件系统。在本章中,我们讨论了 Scala 中的基本面向对象特性,如类和对象、包和包对象、特征和特征线性化、Java 互操作性、模式匹配、隐式和泛型。最后,我们讨论了 SBT 和其他构建系统,这些系统将需要在 Eclipse 或其他 IDE 上构建我们的 Spark 应用程序。

在下一章中,我们将讨论函数式编程是什么,以及 Scala 如何支持它。我们将了解为什么它很重要以及使用函数式概念的优势是什么。接下来,您将学习纯函数、高阶函数、Scala 集合基础(map、flatMap、filter)、for - comprehensions、单子处理,以及使用 Scala 标准库在集合之外扩展高阶函数。

第三章:函数式编程概念

“面向对象编程通过封装移动部分使代码易于理解。函数式编程通过最小化移动部分使代码易于理解。”

  • Michael Feathers

使用 Scala 和 Spark 是学习大数据分析的很好组合。然而,除了面向对象编程范式,我们还需要知道为什么函数式概念对编写最终分析数据的 Spark 应用程序很重要。正如前几章所述,Scala 支持两种编程范式:面向对象编程范式和函数式编程概念。在第二章中的面向对象 Scala中,我们探讨了面向对象编程范式,看到了如何在蓝图(类)中表示现实世界对象,然后将其实例化为具有真实内存表示的对象。

在本章中,我们将重点关注第二种范式(即函数式编程)。我们将看到函数式编程是什么,Scala 如何支持它,为什么它很重要以及使用这个概念的相关优势。具体来说,我们将学习几个主题,比如为什么 Scala 是数据科学家的武器库,为什么学习 Spark 范式很重要,纯函数和高阶函数HOFs)的相关内容。本章还将展示使用 HOF 的真实用例。然后,我们将看到如何在 Scala 的标准库中处理集合外的高阶函数中的异常。最后,我们将学习函数式 Scala 如何影响对象的可变性。

简而言之,本章将涵盖以下主题:

  • 函数式编程介绍

  • 数据科学家的函数式 Scala

  • 为什么函数式编程和 Scala 对学习 Spark 很重要?

  • 纯函数和高阶函数

  • 使用高阶函数:一个真实用例

  • 在函数式 Scala 中处理错误

  • 函数式编程和数据可变性

函数式编程介绍

在计算机科学中,函数式编程(FP)是一种编程范式和一种构建计算机程序结构和元素的独特风格。这种独特性有助于将计算视为数学函数的评估,并避免改变状态和可变数据。因此,通过使用 FP 概念,您可以学会以自己的方式编写代码,确保数据的不可变性。换句话说,FP 是关于编写纯函数,消除尽可能多的隐藏输入和输出,以便我们的代码尽可能地描述输入和输出之间的关系。

这并不是一个新概念,但Lambda Calculus首次出现在上世纪 30 年代,它为 FP 提供了基础。然而,在编程语言领域,函数式编程一词指的是一种新的声明式编程范式,意味着编程可以通过控制、声明或表达式来完成,而不是传统语句,比如 C 语言中常用的语句。

函数式编程的优势

函数式编程范式中有一些令人兴奋和酷炫的特性,比如组合管道化高阶函数,有助于避免编写非函数式代码。或者至少在后期,这有助于将非函数式程序转换为函数式风格,朝向命令式风格。最后,现在让我们看看如何从计算机科学的角度定义函数式编程这个术语。函数式编程是计算机科学中的一个常见概念,其中计算和程序的构建结构被视为评估数学函数,支持不可变数据并避免状态改变。在函数式编程中,每个函数对于相同的输入参数值具有相同的映射或输出。

随着复杂软件的需求,需要良好结构化的程序和易于编写和调试的软件。我们还需要编写可扩展的代码,这将节省我们未来的编程成本,并有助于代码的轻松编写和调试;甚至更模块化的软件,易于扩展,需要较少的编程工作。由于函数式编程的后一种贡献,模块化,函数式编程被认为是软件开发的一大优势。

在函数式编程中,其结构中有一个基本构建块称为没有副作用的函数(或者至少在大部分代码中没有)。没有副作用,评估的顺序真的无关紧要。在编程语言的观点上,有方法可以强制执行特定的顺序。在一些 FP 语言(例如,渴望语言如 Scheme)中,对参数没有评估顺序,您可以将这些表达式嵌套在它们自己的 lambda 形式中,如下所示:

((lambda (val1) 
  ((lambda (val2) 
    ((lambda (val3) (/ (* val1 val2) val3)) 
      expression3)) ; evaluated third
      expression2))   ; evaluated second
    expression1)      ; evaluated first

在函数式编程中,编写数学函数,其中执行顺序并不重要,通常会使您的代码更易读。有时,有人会争论我们也需要有副作用的函数。实际上,这是大多数函数式编程语言的主要缺点之一,因为通常很难编写不需要任何 I/O 的函数;另一方面,在函数式编程中难以实现需要 I/O 的函数。从图 1中可以看出,Scala 也是一种混合语言,它通过从 Java 等命令式语言和 Lisp 等函数式语言中获取特性而发展而来。

但幸运的是,在这里我们正在处理一种混合语言,其中允许面向对象和函数式编程范式,因此编写需要 I/O 的函数非常容易。函数式编程还具有比基本编程更大的优势,例如理解和缓存。

函数式编程的一个主要优势是简洁,因为使用函数式编程可以编写更紧凑、简洁的代码。并发也被认为是一个主要优势,在函数式编程中更容易实现。因此,像 Scala 这样的函数式语言提供了许多其他功能和工具,鼓励编程人员对更数学化的思维方式进行整体范式转变。

图 1: 展示了使用函数式编程概念的概念视图

通过将焦点缩小到一小部分可组合的抽象概念,如函数、函数组合和抽象代数,函数式编程概念相对于其他范式提供了几个优势。例如:

  • 更贴近数学思维: 你倾向于以接近数学定义而不是迭代程序的格式表达你的想法。

  • 没有(或者至少更少)副作用:您的函数不会影响其他函数,这对并发和并行化非常有利,也有利于调试。

  • 更少的代码行数而不牺牲概念上的清晰度: Lisp 比非函数式语言更强大。虽然你需要花费更多的时间思考而不是写作,但最终你可能会发现你更有生产力。

通过这些令人兴奋的特性,函数式编程实现了显著的表达力。例如,机器学习算法可能需要数百行命令式代码来实现,但在函数式编程中可以用少数方程式来定义。

数据科学家的函数式 Scala

对于进行交互式数据清洗、处理、整理和分析,许多数据科学家使用 R 或 Python 作为他们最喜欢的工具。然而,有许多数据科学家倾向于非常依赖他们最喜欢的工具--也就是 Python 或 R,并试图使用该工具解决所有数据分析问题或工作。因此,在大多数情况下,向他们介绍新工具可能非常具有挑战性,因为新工具有更多的语法和一套新的模式需要学习才能使用新工具来解决他们的目的。

Spark 中还有其他用 Python 和 R 编写的 API,例如 PySpark 和 SparkR,分别允许您从 Python 或 R 中使用它们。然而,大多数 Spark 书籍和在线示例都是用 Scala 编写的。我们认为,学习如何使用与 Spark 代码相同的语言来使用 Spark 将比 Java、Python 或 R 作为数据科学家带来更多优势:

  • 更好的性能并消除数据处理开销

  • 提供对 Spark 最新和最优秀的功能的访问

  • 帮助以透明的方式理解 Spark 的哲学

分析数据意味着您正在编写 Scala 代码,使用 Spark 及其 API(即 SparkR、SparkSQL、Spark Streaming、Spark MLlib 和 Spark GraphX)从集群中检索数据。或者,您正在使用 Scala 开发一个 Spark 应用程序,在本地机器上操作数据。在这两种情况下,Scala 都是您真正的朋友,并将在时间上为您带来回报。

为什么要学习 Spark 的 FP 和 Scala?

在本节中,我们将讨论为什么要学习 Spark 来解决我们的数据分析问题。然后,我们将讨论为什么 Scala 中的函数式编程概念对于使数据分析对数据科学家更容易非常重要。我们还将讨论 Spark 编程模型及其生态系统,以使它们更清晰。

为什么 Spark?

Spark 是一个快速的集群计算框架,主要设计用于快速计算。Spark 基于 Hadoop MapReduce 模型,并在更多形式和类型的计算中使用 MapReduce,如交互式查询和流处理。Spark 的主要特点之一是内存处理,这有助于提高应用程序的性能和处理速度。Spark 支持各种应用程序和工作负载,如以下内容:

  • 基于批处理的应用程序

  • 以前无法快速运行的迭代算法

  • 交互式查询和流处理

此外,学习 Spark 并在应用程序中实现它并不需要太多时间,而无需了解并发和分布式系统的内部细节。Spark 是在加州大学伯克利分校的 AMPLab 于 2009 年实施的。2010 年,他们决定将其开源。然后,Spark 在 2013 年成为 Apache 发布,并自那时起被认为是最著名/使用最广泛的 Apache 发布软件。Apache Spark 因其功能而变得非常出名:

  • 快速计算:由于其黄金特性--内存处理,Spark 帮助您运行比 Hadoop 更快的应用程序。

  • 支持多种编程语言:Apache Spark 提供了不同语言的包装器和内置 API,如 Scala、Java、Python,甚至 R。

  • 更多的分析:如前所述,Spark 支持 MapReduce 操作,还支持更高级的分析,如机器学习MLlib)、数据流和图处理算法。

正如前面提到的,Spark 是建立在 Hadoop 软件之上的,您可以以不同的方式部署 Spark:

  • 独立集群:这意味着 Spark 将在Hadoop 分布式文件系统HDFS)之上运行,并且空间实际上将分配给 HDFS。Spark 和 MapReduce 将并行运行,以服务所有 Spark 作业。

  • Hadoop YARN 集群:这意味着 Spark 只需在 YARN 上运行,无需任何根权限或预安装。

  • Mesos 集群:当驱动程序创建一个 Spark 作业并开始分配相关任务进行调度时,Mesos 确定哪些计算节点将处理哪些任务。我们假设您已经在计算机上配置并安装了 Mesos。

  • 按需部署在集群上:您可以在 AWS EC2 上以真实集群模式部署 Spark 作业。为了使您的应用程序在 Spark 集群模式下运行并实现更好的可伸缩性,您可以考虑将Amazon 弹性计算云EC2)服务作为基础设施即服务IaaS)或平台即服务PaaS)。

有关如何在真实集群上使用 Scala 和 Spark 部署数据分析应用程序,请参阅第十七章,前往集群部署 Spark和第十八章,测试和调试 Spark

Scala 和 Spark 编程模型

Spark 编程始于数据集,通常驻留在分布式和持久存储(如 HDFS)中。Spark 提供的典型 RDD 编程模型可以描述如下:

  • 从环境变量、Spark 上下文(Spark shell 为您提供了一个 Spark 上下文,或者您可以自己创建,这将在本章后面描述)创建初始数据引用 RDD 对象。

  • 按照函数式编程风格(稍后将讨论)转换初始 RDD 以创建更多 RDD 对象。

  • 从驱动程序向集群管理器节点发送代码、算法或应用程序。然后,集群管理器为每个计算节点提供一个副本。

  • 计算节点保存对其分区中的 RDD 的引用(同样,驱动程序也保存数据引用)。但是,计算节点也可以由集群管理器提供输入数据集。

  • 在转换(通过窄转换或宽转换)之后,生成的结果将是全新的 RDD,因为原始 RDD 不会发生变异。

  • 最后,通过操作将 RDD 对象或更多(具体来说,数据引用)实现为将 RDD 转储到存储中。

  • 驱动程序可以向计算节点请求程序分析或可视化的结果块。

等等!到目前为止,我们一切顺利。我们假设您将把应用程序代码发送到集群中的计算节点。但是,您还需要将输入数据集上传或发送到集群,以便在计算节点之间进行分发。即使在批量上传期间,您也需要通过网络传输数据。我们还认为应用程序代码和结果的大小是可以忽略或微不足道的。另一个障碍是,如果您希望 Spark 进行规模化计算,可能需要首先从多个分区合并数据对象。这意味着我们需要在工作/计算节点之间进行数据洗牌,通常通过partition()intersection()join()转换操作来完成。

Scala 和 Spark 生态系统

为了提供更多的增强和额外的大数据处理能力,Spark 可以配置并运行在现有基于 Hadoop 的集群之上。另一方面,Spark 中的核心 API 是用 Java、Scala、Python 和 R 编写的。与 MapReduce 相比,Spark 提供了更一般和强大的编程模型,还提供了几个库,这些库是 Spark 生态系统的一部分,用于通用数据处理和分析、图处理、大规模结构化 SQL 和机器学习ML)领域的额外功能。

Spark 生态系统包括以下组件(有关详细信息,请参阅第十六章,Spark 调优):

  • Apache Spark 核心:这是 Spark 平台的基础引擎,所有其他功能都是在其上构建的。此外,它提供了内存处理。

  • Spark SQL:如前所述,Spark 核心是底层引擎,所有其他组件或功能都是构建在其之上的。Spark SQL 是提供对不同数据结构(结构化和半结构化数据)支持的 Spark 组件。

  • Spark streaming:这个组件负责流式数据分析,并将其转换为可以后续用于分析的小批处理。

  • MLlib(机器学习库):MLlib 是一个支持大量 ML 算法的分布式机器学习框架。

  • GraphX:一个建立在 Spark 之上的分布式图形框架,以并行方式表达用户定义的图形组件。

正如前面提到的,大多数函数式编程语言允许用户编写漂亮、模块化和可扩展的代码。此外,函数式编程通过编写看起来像数学函数的函数来鼓励安全的编程方式。现在,Spark 是如何使所有 API 作为一个单一单元工作的?这是可能的,因为硬件的进步,当然还有函数式编程的概念。由于添加语法糖以轻松地进行 lambda 表达式并不足以使一种语言成为函数式的,这只是一个开始。

尽管 Spark 中的 RDD 概念运行得相当不错,但在许多用例中,由于其不可变性,它有点复杂。对于下面的例子,这是计算平均值的经典例子,使源代码健壮且可读;当然,为了减少总体成本,人们不希望首先计算总数,然后计数,即使数据被缓存在主内存中。

val data: RDD[People] = ...
data.map(person => (person.name, (person.age, 1)))
.reduceByKey(_ |+| _)
.mapValues { case (total, count) =>
  total.toDouble / count
}.collect()

数据框架 API(这将在后面的章节中详细讨论)产生同样简洁和可读的代码,其中函数 API 非常适合大多数用例,并最小化了 MapReduce 阶段;有许多洗牌可能会造成巨大的成本,其主要原因如下:

  • 大型代码库需要静态类型以消除微不足道的错误,比如aeg而不是age立即

  • 复杂的代码需要透明的 API 来清晰地传达设计

  • 通过封装 OOP 状态并使用 mapPartitions 和 combineByKey 同样可以实现 DataFrames API 的 2 倍速度提升

  • 需要灵活性和 Scala 特性来快速构建功能

在巴克莱,将 OOP 和 FP 与 Spark 结合可以使一个相当困难的问题变得更容易。例如,在巴克莱,最近开发了一个名为 Insights Engine 的应用程序,用于执行任意数量 N 个类似 SQL 的查询。该应用程序可以以一种可以随着 N 的增加而扩展的方式执行它们。

现在让我们谈谈纯函数、高阶函数和匿名函数,这是 Scala 函数式编程中的三个重要概念。

纯函数和高阶函数

从计算机科学的角度来看,函数可以有许多形式,如一阶函数、高阶函数或纯函数。从数学的角度来看也是如此。使用高阶函数可以执行以下操作之一:

  • 将一个或多个函数作为参数来执行一些操作

  • 将一个函数作为其结果返回

除了高阶函数之外的所有其他函数都是一阶函数。然而,从数学的角度来看,高阶函数也被称为操作符函数式。另一方面,如果一个函数的返回值仅由其输入决定,当然没有可观察的副作用,那么它被称为纯函数

在本节中,我们将简要讨论为什么以及如何在 Scala 中使用不同的函数式范式。特别是,将讨论纯函数和高阶函数。在本节结束时,还将提供使用匿名函数的简要概述,因为在使用 Scala 开发 Spark 应用程序时经常使用它。

纯函数

函数式编程的最重要原则之一是纯函数。那么纯函数是什么,我们为什么要关心它们?在本节中,我们将讨论函数式编程的这一重要特性。函数式编程的最佳实践之一是实现程序,使得程序/应用程序的核心由纯函数构成,而所有 I/O 函数或诸如网络开销和异常之类的副作用都在一个公开的外部层中。

那么纯函数的好处是什么?纯函数通常比普通函数小(尽管这取决于其他因素,如编程语言),甚至更容易解释和理解,因为它看起来像一个数学函数。

然而,您可能会反对这一点,因为大多数开发人员仍然认为命令式编程更容易理解!纯函数要容易实现和测试得多。让我们通过一个例子来演示这一点。假设我们有以下两个单独的函数:

def pureFunc(cityName: String) = s"I live in $cityName"
def notpureFunc(cityName: String) = println(s"I live in $cityName")

因此,在前面的两个示例中,如果要测试pureFunc纯函数,我们只需断言来自纯函数的返回值与我们根据输入所期望的值相匹配即可:

assert(pureFunc("Dublin") == "I live in Dublin")

但另一方面,如果我们想测试我们的notpureFunc不纯函数,那么我们需要重定向标准输出,然后对其应用断言。下一个实用的提示是,函数式编程使程序员更加高效,因为如前所述,纯函数更小更容易编写,您可以轻松地将它们组合在一起。此外,代码的重复最小化,您可以轻松地重用您的代码。现在让我们通过一个更好的例子来演示这个优势。考虑这两个函数:

scala> def pureMul(x: Int, y: Int) = x * y
pureMul: (x: Int, y: Int)Int 
scala> def notpureMul(x: Int, y: Int) = println(x * y)
notpureMul: (x: Int, y: Int)Unit

然而,可变性可能会产生副作用;使用纯函数(即没有可变性)有助于我们推理和测试代码:

def pureIncrease(x: Int) = x + 1

这个优势是有利的,非常容易解释和使用。然而,让我们看另一个例子:

varinc = 0
def impureIncrease() = {
  inc += 1
  inc
}

现在,考虑一下这可能有多令人困惑:在多线程环境中会输出什么?正如您所看到的,我们可以轻松地使用我们的纯函数pureMul来乘以任何一系列数字,而不像我们的notpureMul不纯函数。让我们通过以下示例来演示这一点:

scala> Seq.range(1,10).reduce(pureMul)
res0: Int = 362880

前面示例的完整代码如下所示(使用一些真实值调用了方法):

package com.chapter3.ScalaFP

object PureAndNonPureFunction {
  def pureFunc(cityName: String) = s"I live in $cityName"
  def notpureFunc(cityName: String) = println(s"I live in $cityName")
  def pureMul(x: Int, y: Int) = x * y
  def notpureMul(x: Int, y: Int) = println(x * y)  

  def main(args: Array[String]) {
    //Now call all the methods with some real values
    pureFunc("Galway") //Does not print anything
    notpureFunc("Dublin") //Prints I live in Dublin
    pureMul(10, 25) //Again does not print anything
    notpureMul(10, 25) // Prints the multiplicaiton -i.e. 250   

    //Now call pureMul method in a different way
    val data = Seq.range(1,10).reduce(pureMul)
    println(s"My sequence is: " + data)
  }
}

前面代码的输出如下:

I live in Dublin 250 
My sequence is: 362880

如前所述,您可以将纯函数视为函数式编程的最重要特性之一,并且作为最佳实践;您需要使用纯函数构建应用程序的核心。

函数与方法:

在编程领域,函数是通过名称调用的一段代码。数据(作为参数)可以传递以进行操作,并且可以返回数据(可选)。传递给函数的所有数据都是显式传递的。另一方面,方法也是通过名称调用的一段代码。然而,方法总是与对象相关联。听起来相似?在大多数情况下,方法与函数相同,除了两个关键差异:

  1. 方法隐式传递了调用它的对象。

  2. 方法能够操作类中包含的数据。

在前一章中已经说明,对象是类的一个实例--类是定义,对象是该数据的一个实例。

现在是学习高阶函数的时候了。然而,在此之前,我们应该学习函数式 Scala 中的另一个重要概念--匿名函数。通过这个,我们还将学习如何在函数式 Scala 中使用 lambda 表达式。

匿名函数

有时在你的代码中,你不想在使用之前定义一个函数,也许是因为你只会在一个地方使用它。在函数式编程中,有一种非常适合这种情况的函数类型。它被称为匿名函数。让我们使用转账的前面示例来演示匿名函数的使用:

def TransferMoney(money: Double, bankFee: Double => Double): Double = {
  money + bankFee(money)
}

现在,让我们用一些真实的值调用TransferMoney()方法如下:

 TransferMoney(100, (amount: Double) => amount * 0.05)

Lambda 表达式:

正如已经说明的,Scala 支持头等函数,这意味着函数可以用函数文字语法来表示;函数可以被称为对象,称为函数值。尝试以下表达式,它创建了一个整数的后继函数:

scala> var apply = (x:Int) => x+1

apply: Int => Int = <function1>

现在 apply 变量是一个可以像下面这样通常使用的函数:

scala> var x = apply(7)

x: Int = 8

我们在这里所做的只是使用函数的核心部分:参数列表,然后是函数箭头和函数体。这不是黑魔法,而是一个完整的函数,只是没有给定的名称--也就是匿名的。如果你以这种方式定义一个函数,将没有办法在之后引用该函数,因此你不能在之后调用该函数,因为没有名称它就是匿名的。此外,我们有一个所谓的lambda 表达式!它只是一个纯粹的、匿名的函数定义。

上述代码的输出如下:

105.0

因此,在前面的示例中,我们直接传递了一个匿名函数,而不是声明一个单独的callback函数,它和bankFee函数一样完成了相同的工作。你也可以在匿名函数中省略类型,它将根据传递的参数直接推断出类型,就像这样:

TransferMoney(100, amount => amount * 0.05)

上述代码的输出如下:

105.0

让我们在 Scala shell 中演示前面的例子,如下面的截图所示:

**图 6:**在 Scala 中使用匿名函数

一些支持函数的编程语言使用 lambda 函数的名称,而不是匿名函数。

高阶函数

在 Scala 的函数式编程中,你可以允许将函数作为参数传递,甚至从另一个函数中返回一个函数;这定义了所谓的高阶函数。

让我们通过一个例子来演示这个特性。考虑以下函数testHOF,它接受另一个函数func,然后将这个函数应用到它的第二个参数值上:

object Test {
  def main(args: Array[String]) {
    println( testHOF( paramFunc, 10) )
  }
  def testHOF(func: Int => String, value: Int) = func(value)
  def paramFuncA = "[" + x.toString() + "]"
}

在演示了 Scala 函数式编程的基础知识之后,现在我们准备转向更复杂的函数式编程案例。如前所述,我们可以将高阶函数定义为接受其他函数作为参数并将它们作为结果返回的函数。如果你来自面向对象的编程背景,你会发现这是一种非常不同的方法,但随着我们的学习,你会发现它变得更容易理解。

让我们从定义一个简单的函数开始:

def quarterMaker(value: Int): Double = value.toDouble/4

前面的函数非常简单。它是一个接受 Int 值然后返回这个值的四分之一的函数,返回类型是Double。让我们定义另一个简单的函数:

def addTwo(value: Int): Int = value + 2

第二个函数addTwo比第一个函数更简单。它接受一个Int值,然后将 2 加到它上。正如你所看到的,这两个函数有一些共同之处。它们都接受Int并返回另一个经过处理的值,我们可以称之为AnyVal。现在,让我们定义一个接受另一个函数作为参数的高阶函数:

def applyFuncOnRange(begin: Int, end: Int, func: Int => AnyVal): Unit = {
  for (i <- begin to end)
    println(func(i))
}

正如您所看到的,前面的applyFuncOnRange函数接受两个Int值,作为序列的开始和结束,并接受一个具有Int => AnyVal签名的函数,就像先前定义的简单函数(quarterMakderaddTwo)一样。现在让我们通过将两个简单函数中的一个作为第三个参数传递给它来演示我们之前的高阶函数(如果您想要传递自己的函数,那么请确保它具有相同的签名Int => AnyVal)。

**Scala 循环范围的语法:**在 Scala 中使用 for 循环与范围的最简单语法是:

for( var x <- range ){

语句(s)

}

这里,range可以是一系列数字,表示为ij,有时像i直到j。左箭头“←”操作符被称为生成器,因为它从范围生成单个值。让我们看一个具体的例子:

object UsingRangeWithForLoop {

def main(args: Array[String]):Unit= {

var i = 0;

// 使用范围进行 for 循环执行

for(i <- 1 to 10){

println("i 的值:" + i)

}

`}

}

上述代码的输出如下:

i 的值:1

i 的值:2

i 的值:3

i 的值:4

i 的值:5

i 的值:6

i 的值:7

i 的值:8

i 的值:9

i 的值:10

在开始使用它们之前,让我们首先定义我们的函数,如下截图所示:

图 2:在 Scala 中定义高阶函数的示例

现在,让我们首先调用我们的高阶函数applyFuncOnRange,并将quarterMaker函数作为第三个参数传递:

图 3:调用高阶函数

我们甚至可以应用另一个函数addTwo,因为它具有与以下截图中显示的相同签名:

图 4:调用高阶函数的另一种方式

在进入更多的例子之前,让我们定义所谓的回调函数。回调函数是一个可以作为参数传递给另一个函数的函数。其他函数只是普通函数。让我们演示使用不同回调函数的更多例子。考虑以下高阶函数,负责从您的账户转移特定金额的资金:

def TransferMoney(money: Double, bankFee: Double => Double): Double = {
  money + bankFee(money)
}
def bankFee(amount: Double) = amount * 0.05

在 100 上调用TransferMoney函数后:

TransferMoney(100, bankFee)

上述代码的输出如下:

105.0

从函数式编程的角度来看,这段代码还没有准备好集成到银行系统中,因为您需要对资金参数应用不同的验证,比如它必须是正数,并且大于银行指定的特定金额。然而,在这里,我们只是演示高阶函数和回调函数的使用。

因此,这个例子的工作方式如下:您想要将特定金额的资金转移到另一个银行账户或资金代理。银行有特定的费用要根据您转移的金额来应用,这就是回调函数的作用。它接受要转移的金额,并对其应用银行手续费,以得出总金额。

TransferMoney函数接受两个参数:第一个是要转移的金额,第二个是一个带有Double => Double签名的回调函数,该函数应用于金额参数,以确定转移金额的银行手续费。

图 5:调用并赋予高阶函数额外的权力

上述示例的完整源代码如下(我们使用了一些真实值来调用这些方法):

package com.chapter3.ScalaFP
object HigherOrderFunction {
  def quarterMaker(value: Int): Double = value.toDouble / 4
  def testHOF(func: Int => String, value: Int) = func(value)
  def paramFuncA = "[" + x.toString() + "]"
  def addTwo(value: Int): Int = value + 2
  def applyFuncOnRange(begin: Int, end: Int, func: Int => AnyVal): Unit = {
    for (i <- begin to end)
      println(func(i))
  }
  def transferMoney(money: Double, bankFee: Double => Double): Double = {
    money + bankFee(money)
  }
  def bankFee(amount: Double) = amount * 0.05
  def main(args: Array[String]) {
    //Now call all the methods with some real values
    println(testHOF(paramFunc, 10)) // Prints [10]
    println(quarterMaker(20)) // Prints 5.0
    println(paramFunc(100)) //Prints [100]
    println(addTwo(90)) // Prints 92
    println(applyFuncOnRange(1, 20, addTwo)) // Prints 3 to 22 and ()
    println(TransferMoney(105.0, bankFee)) //prints 110.25
  }
}

上述代码的输出如下:

[10] 
5.0 
[100] 
92 
3 4 5 6 7 8 9 10 11 12 13 14 15 16 1718 19 20 21 22 () 
110.25

通过使用回调函数,您为高阶函数赋予了额外的权力;因此,这是一种使您的程序更加优雅、灵活和高效的强大机制。

函数作为返回值

如前所述,高阶函数还支持将函数作为结果返回。让我们通过一个例子来演示这一点:

def transferMoney(money: Double) = {
  if (money > 1000)
    (money: Double) => "Dear customer we are going to add the following
                        amount as Fee: "+money * 0.05
  else
    (money: Double) => "Dear customer we are going to add the following
                        amount as Fee: "+money * 0.1
} 
val returnedFunction = TransferMoney(1500)
returnedFunction(1500)

前面的代码段将产生以下输出:

Dear customer, we are going to add the following amount as Fee: 75.0

让我们按照以下截图中显示的方式运行前面的示例;它展示了如何将函数用作返回值:

**图 7:**函数作为返回值

前面示例的完整代码如下所示:

package com.chapter3.ScalaFP
object FunctionAsReturnValue {
  def transferMoney(money: Double) = {
    if (money > 1000)
      (money: Double) => "Dear customer, we are going to add following
                          amount as Fee: " + money * 0.05
    else
      (money: Double) => "Dear customer, we are going to add following
                          amount as Fee: " + money * 0.1
  }  
  def main(args: Array[String]) {
    val returnedFunction = transferMoney(1500.0)
    println(returnedFunction(1500)) //Prints Dear customer, we are 
                         going to add following amount as Fee: 75.0
  }
}

前面代码的输出如下所示:

Dear customer, we are going to add following amount as Fee: 75.0

在结束对 HFO 的讨论之前,让我们看一个现实生活的例子,即使用 HFO 进行柯里化。

使用高阶函数

假设您在一家餐厅里担任厨师,您的一个同事问您一个问题:实现一个HOF(高阶函数),执行柯里化。寻找线索?假设您的 HOF 有以下两个签名:

def curryX,Y,Z => Z) : X => Y => Z

同样,实现一个执行 uncurrying 的函数,如下所示:

def uncurryX,Y,Z: (X,Y) => Z

现在,您如何使用 HOF 来执行柯里化操作呢?嗯,您可以创建一个封装两个 HOF 签名(即 curry 和 uncurry)的特性,如下所示:

trait Curry {
  def curryA, B, C => C): A => B => C
  def uncurryA, B, C: (A, B) => C
}

现在,您可以按照以下方式将此特性实现并扩展为对象:


object CurryImplement extends Curry {
  def uncurryX, Y, Z: (X, Y) => Z = { (a: X, b: Y) => f(a)(b) }
  def curryX, Y, Z => Z): X => Y => Z = { (a: X) => { (b: Y) => f(a, b) } }
}

这里我首先实现了 uncurry,因为它更容易。等号后面的两个大括号是一个匿名函数,用于接受两个参数(即类型为XYab)。然后,这两个参数可以在一个还返回函数的函数中使用。然后,它将第二个参数传递给返回的函数。最后,它返回第二个函数的值。第二个函数字面量接受一个参数并返回一个新的函数,即curry()。最终,当调用时返回另一个函数。

现在问题来了:如何在实际实现中使用扩展基本特性的前面对象。以下是一个例子:

object CurryingHigherOrderFunction {
  def main(args: Array[String]): Unit = {
    def add(x: Int, y: Long): Double = x.toDouble + y
    val addSpicy = CurryImplement.curry(add) 
    println(addSpicy(3)(1L)) // prints "4.0"    
    val increment = addSpicy(2) 
    println(increment(1L)) // prints "3.0"    
    val unspicedAdd = CurryImplement.uncurry(addSpicy) 
    println(unspicedAdd(1, 6L)) // prints "7.0"
  }
}

在前面的对象和主方法中:

  • addSpicy保存了一个函数,它将一个 long 类型的数加 1,然后打印出 4.0。

  • increment保存了一个函数,它将一个 long 类型的数加 2,最后打印出 3.0。

  • unspicedAdd保存了一个函数,它将 1 加上并将其类型定义为 long。最后,它打印出 7.0。

前面代码的输出如下所示:

4.0
3.0
7.0

在数学和计算机科学中,柯里化是将接受多个参数(或参数元组)的函数的求值转换为求值一系列函数的技术,每个函数只接受一个参数。柯里化与偏函数应用相关,但并不相同:

**柯里化:**柯里化在实际和理论环境中都很有用。在函数式编程语言和许多其他语言中,它提供了一种自动管理函数和异常传递参数的方式。在理论计算机科学中,它提供了一种研究具有多个参数的函数的方式,这些函数在更简单的理论模型中只提供一个参数。

**反柯里化:**反柯里化是柯里化的对偶转换,可以看作是一种去函数化的形式。它接受一个返回值为另一个函数g的函数f,并产生一个新的函数f′,该函数接受fg的参数作为参数,并作为结果返回f和随后g对这些参数的应用。这个过程可以迭代。

到目前为止,我们已经看到了如何在 Scala 中处理纯函数、高阶函数和匿名函数。现在,让我们简要概述如何在接下来的部分中使用ThrowTryEitherFuture来扩展高阶函数。

在函数式 Scala 中的错误处理

到目前为止,我们专注于确保 Scala 函数的主体执行其预期的操作,不做其他事情(即错误或异常)。现在,为了利用任何编程并避免产生容易出错的代码,你需要知道如何在这种语言中捕获异常和处理错误。我们将看到如何使用 Scala 的一些特殊特性,如TryEitherFuture,来扩展集合之外的高阶函数。

Scala 中的故障和异常

首先,让我们定义一般情况下我们所说的故障是什么(来源:tersesystems.com/2012/12/27/error-handling-in-scala/):

  • 意外的内部故障:操作失败,因为未实现的期望,比如空指针引用,违反的断言,或者简单的坏状态

  • 预期的内部故障:操作故意失败,因为内部状态,即黑名单或断路器

  • 预期的外部故障:操作失败,因为它被告知处理一些原始输入,并且如果无法处理原始输入,就会失败

  • 意外的外部故障:操作失败,因为系统依赖的资源不存在:有一个松散的文件句柄,数据库连接失败,或者网络中断了

不幸的是,除非故障是由一些可管理的异常引起的,否则没有具体的方法来阻止故障。另一方面,Scala 使checked versus unchecked非常简单:它没有检查异常。在 Scala 中,所有异常都是未经检查的,甚至SQLExceptionIOException等等。现在让我们看看如何至少处理这样的异常。

抛出异常

Scala 方法可能会因为意外的工作流程而抛出异常。你创建一个异常对象,然后用throw关键字抛出它,如下所示。例如:

//code something
throw new IllegalArgumentException("arg 2 was wrong...");
//nothing will be executed from here.

请注意,使用异常处理的主要目标不是生成友好的消息,而是退出 Scala 程序的正常流程。

使用 try 和 catch 捕获异常

Scala 允许你在一个单一的块中尝试/捕获任何异常,然后使用 case 块对其进行模式匹配。在 Scala 中使用try...catch的基本语法如下:

try
{
  // your scala code should go here
} 
catch
{
  case foo: FooException => handleFooException(foo)
  case bar: BarException => handleBarException(bar)
  case _: Throwable => println("Got some other kind of exception")
}
finally
{
  // your scala code should go here, such as to close a database connection 
}

因此,如果你抛出异常,那么你需要使用try...catch块来优雅地处理它,而不是用内部异常消息崩溃:

package com.chapter3.ScalaFP
import java.io.IOException
import java.io.FileReader
import java.io.FileNotFoundException

object TryCatch {
  def main(args: Array[String]) {
    try {
      val f = new FileReader("data/data.txt")
    } catch {
      case ex: FileNotFoundException => println("File not found exception")
      case ex: IOException => println("IO Exception") 
    } 
  }
}

如果在项目树下的路径/数据中没有名为data.txt的文件,你将会遇到FileNotFoundException,如下所示:

前面代码的输出如下:

File not found exception

现在,让我们简要介绍一下在 Scala 中使用finally子句使try...catch块完整的例子。

最后

假设你想执行你的代码,不管是否抛出异常,那么你应该使用finally子句。你可以将它放在try block中,如下所示。这是一个例子:

try {
    val f = new FileReader("data/data.txt")
  } catch {
    case ex: FileNotFoundException => println("File not found exception")
  } finally { println("Dude! this code always executes") }
}

现在,这是使用try...catch...finally的完整示例:

package com.chapter3.ScalaFP
import java.io.IOException
import java.io.FileReader
import java.io.FileNotFoundException

object TryCatch {
  def main(args: Array[String]) {
    try {
      val f = new FileReader("data/data.txt")
    } catch {
      case ex: FileNotFoundException => println("File not found 
                                                 exception")
      case ex: IOException => println("IO Exception") 
    } finally {
      println("Finally block always executes!")
    }
  }
}

前面代码的输出如下:

File not found exception 
Finally block always executes!

接下来,我们将讨论 Scala 中的另一个强大特性,称为Either

创建一个 Either

Either[X, Y] 是一个实例,它包含了X的实例或Y的实例,但不会同时包含两者。我们称这些子类型为 Either 的左和右。创建一个 Either 是微不足道的。但有时在程序中使用它非常强大:

package com.chapter3.ScalaFP
import java.net.URL
import scala.io.Source
object Either {
  def getData(dataURL: URL): Either[String, Source] =
    if (dataURL.getHost.contains("xxx"))
      Left("Requested URL is blocked or prohibited!")
    else
      Right(Source.fromURL(dataURL))      
  def main(args: Array[String]) {
      val either1 = getData(new URL("http://www.xxx.com"))    
      println(either1)      
      val either2 = getData(new URL("http://www.google.com"))    
      println(either2)
  }
}

现在,如果我们传递任意不包含xxx的 URL,那么我们将得到一个包装在Right子类型中的Scala.io.Source。如果 URL 包含xxx,那么我们将得到一个包装在Left子类型中的String。为了使前面的陈述更清晰,让我们看看前面代码段的输出:

Left(Requested URL is blocked or prohibited!) Right(non-empty iterator)

接下来,我们将探讨 Scala 的另一个有趣特性,称为Future,它用于以非阻塞方式执行任务。这也是在任务完成时处理结果的更好方式。

Future

如果你只是想以非阻塞的方式运行任务,并且需要一种在任务完成时处理结果的方法,Scala 为你提供了 Futures,例如,如果你想以并行方式进行多个 web 服务调用,并在 web 服务处理所有这些调用后处理结果。下面的部分提供了使用 Future 的例子。

运行一个任务,但是阻塞

下面的例子演示了如何创建一个 Future,然后阻塞执行顺序以等待其结果。创建 Futures 很简单。你只需要把它传递给你想要的代码。下面的例子在未来执行 2+2,然后返回结果:

package com.chapter3.ScalaFP
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
import scala.concurrent.{Await, Future}

object RunOneTaskbutBlock {
  def main(args: Array[String]) {
    // Getting the current time in Milliseconds
    implicit val baseTime = System.currentTimeMillis    
    // Future creation
    val testFuture = Future {
      Thread.sleep(300)
      2 + 2
    }    
    // this is the blocking part
    val finalOutput = Await.result(testFuture, 2 second)
    println(finalOutput)
  }
}

Await.result方法等待最多 2 秒,直到Future返回结果;如果在 2 秒内没有返回结果,它会抛出下面的异常,你可能想要处理或捕获:

java.util.concurrent.TimeoutException

现在是时候结束这一章了。然而,我想借此机会讨论一下我对 Scala 函数式编程和对象可变性的重要观点。

函数式编程和数据可变性

纯函数式编程是函数式编程中的最佳实践之一,你应该坚持下去。编写纯函数将使你的编程生活更轻松,你将能够编写易于维护和扩展的代码。此外,如果你想并行化你的代码,那么如果你编写纯函数,这将更容易实现。

如果你是一个 FP 纯粹主义者,在 Scala 中使用函数式编程的一个缺点是 Scala 同时支持 OOP 和 FP(见图 1),因此可能会在同一个代码库中混合这两种编码风格。在本章中,我们看到了几个例子,表明编写纯函数是容易的。然而,将它们组合成一个完整的应用程序是困难的。你可能会同意,像单子这样的高级主题使 FP 变得令人生畏。

我和很多人交谈过,他们认为递归并不是很自然的。当你使用不可变对象时,你永远不能用其他东西来改变它们。没有时候你被允许这样做。这就是不可变对象的全部意义!有时我经历过的是,纯函数和数据输入或输出真的混在一起。然而,当你需要改变时,你可以创建一个包含你改变字段的对象的副本。因此,从理论上讲,没有必要混合。最后,只使用不可变值和递归可能会导致 CPU 使用和 RAM 方面的性能问题。

总结

在这一章中,我们探讨了 Scala 中的一些函数式编程概念。我们看到了函数式编程是什么,以及 Scala 如何支持它,为什么它很重要,以及使用函数式概念的优势。我们看到了为什么学习 FP 概念在学习 Spark 范式中很重要。纯函数、匿名函数和高阶函数都有适当的例子进行了讨论。在本章后期,我们看到了如何在 Scala 的标准库中处理高阶函数外的集合中的异常。最后,我们讨论了函数式 Scala 如何影响对象的可变性。

在下一章中,我们将对集合 API 进行深入分析,这是标准库中最突出的特性之一。

第四章:集合 API

“我们变成什么取决于我们在所有教授结束后读了什么。最伟大的大学是一堆书。”

  • 托马斯·卡莱尔

吸引大多数 Scala 用户的功能之一是其集合 API 非常强大、灵活,并且具有许多与之相关的操作。广泛的操作范围将使您轻松处理任何类型的数据。我们将介绍 Scala 集合 API,包括它们的不同类型和层次结构,以适应不同类型的数据并解决各种不同的问题。简而言之,本章将涵盖以下主题:

  • Scala 集合 API

  • 类型和层次结构

  • 性能特征

  • Java 互操作性

  • 使用 Scala 隐式

Scala 集合 API

Scala 集合是一种被广泛理解和频繁使用的编程抽象,可以区分为可变和不可变集合。像可变变量一样,可变集合在必要时可以被更改、更新或扩展。然而,像不可变变量一样,不可变集合无法更改。大多数使用它们的集合类位于scala.collectionscala.collection.immutablescala.collection.mutable包中。

Scala 的这一极其强大的特性为您提供了以下使用和操作数据的便利:

  • 易于使用: 例如,它帮助您消除迭代器和集合更新之间的干扰。因此,一个由 20-50 个方法组成的小词汇表应该足以解决您数据分析解决方案中的大多数集合问题。

  • 简洁: 您可以使用轻量级语法进行功能操作,并组合操作,最后,您会感觉自己在使用自定义代数。

  • 安全: 帮助您在编码时处理大多数错误。

  • 快速: 大多数集合对象都经过精心调整和优化;这使得您可以以更快的方式进行数据计算。

  • 通用: 集合使您能够在任何地方对任何类型执行相同的操作。

在接下来的部分中,我们将探讨 Scala 集合 API 的类型和相关层次结构。我们将看到在集合 API 中使用大多数功能的几个示例。

类型和层次结构

Scala 集合是一种被广泛理解和频繁使用的编程抽象,可以区分为可变和不可变集合。像可变变量一样,可变集合在必要时可以被更改、更新或扩展。像不可变变量一样,不可变集合无法更改。大多数使用它们的集合类位于scala.collectionscala.collection.immutablescala.collection.mutable包中。

以下分层图表(图 1)显示了 Scala 集合 API 的层次结构,根据 Scala 的官方文档。这些都是高级抽象类或特征。这些都有可变和不可变的实现。

图 1: scala.collection 包下的集合

Traversable

Traversable是集合层次结构的根。在 Traversable 中,有 Scala 集合 API 提供的各种操作的定义。在 Traversable 中只有一个抽象方法,即foreach方法。

def foreachU: Unit

这个方法对 Traversable 中包含的所有操作都是必不可少的。如果您学过数据结构,您将熟悉遍历数据结构元素并在每个元素上执行函数的过程。foreach方法正是这样做的,它遍历集合中的元素,并在每个元素上执行函数f。正如我们提到的,这是一个抽象方法,它被设计为根据将使用它的底层集合的不同定义,以确保为每个集合高度优化的代码。

Iterable

Iterable是 Scala 集合 API 层次结构图中的第二个根。它有一个名为 iterator 的抽象方法,必须在所有其他子集合中实现/定义。它还实现了根中的foreach方法,即 Traversable。但正如我们提到的,所有后代子集合将覆盖此实现,以进行与该子集合相关的特定优化。

Seq、LinearSeq 和 IndexedSeq

序列与通常的 Iterable 有一些不同之处,它有一个定义的长度和顺序。Seq 有两个子特征,如LinearSeqIndexedSeq。让我们快速概述一下它们。

LinearSeq是线性序列的基本特征。线性序列具有相当高效的 head、tail 和isEmpty方法。如果这些方法提供了最快的遍历集合的方式,那么扩展此特征的集合Coll也应该扩展LinearSeqOptimized[A, Coll[A]]LinearSeq有三个具体方法:

  • isEmpty: 这检查列表是否为空

  • head: 这返回列表/序列中的第一个元素

  • tail: 这返回列表的所有元素,但不包括第一个元素。继承LinearSeq的每个子集合都将有自己的这些方法的实现,以确保良好的性能。继承/扩展的两个集合是 streams 和 lists。

有关此主题的更多信息,请参阅www.scala-lang.org/api/current/scala/collection/LinearSeq.html.

最后,IndexedSeq有两个方法,它是根据它们定义的:

  • Apply: 这通过索引查找元素。

  • length: 这返回序列的长度。通过子集合的性能良好的实现来按索引查找元素。其中两个索引序列是VectorArrayBuffer

可变和不可变

在 Scala 中,您会发现可变和不可变的集合。一个集合可以有一个可变的实现和一个不可变的实现。这就是为什么在 Java 中,List不能同时是LinkedListArrayList,但ListLinkedList实现和ArrayList实现的原因。以下图显示了包scala.collection.immutable中的所有集合:

图 2: scala.collection.immutable 包中的所有集合

Scala 默认导入不可变集合,如果需要使用可变集合,则需要自己导入。现在,要简要了解包scala.collection.mutable中的所有集合,请参考以下图表:

图 3: Scala.collection.mutable 包中的所有集合

在每个面向对象编程和函数式编程语言中,数组都是一个重要的集合包,它帮助我们存储数据对象,以便以后可以很容易地访问它们。在下一小节中,我们将看到关于数组的详细讨论,并附有一些示例。

数组

数组是一个可变集合。在数组中,元素的顺序将被保留,并且重复的元素将被保留。作为可变集合,您可以通过访问其索引号来更改数组的任何元素的值。让我们通过几个示例演示数组。使用以下代码行来声明一个简单的数组:

val numbers: Array[Int] = ArrayInt // A simple array

现在,打印数组的所有元素:

println("The full array is: ")
  for (i <- numbers) {
    print(" " + i)
  }

现在,打印特定的元素:例如,元素 3:

println(numbers(2))

让我们对所有元素求和并打印出来:

var total = 0;
for (i <- 0 to (numbers.length - 1)) {
  total = total + numbers(i)
}
println("Sum: = " + total)

查找最小的元素:

var min = numbers(0)
for (i <- 1 to (numbers.length - 1)) {
  if (numbers(i) < min) min = numbers(i)
}
println("Min is: " + min)

查找最大的元素:

var max = numbers(0);
for (i <- 1 to (numbers.length - 1)) {
  if (numbers(i) > max) max = numbers(i)
}
println("Max is: " + max)

另一种创建和定义数组的方法是使用range()方法,如下所示:

//Creating array using range() method
var myArray1 = range(5, 20, 2)
var myArray2 = range(5, 20)

上面的代码行意味着我创建了一个数组,其中的元素在 5 到 20 之间,范围差为 2。如果不指定第三个参数,Scala 将假定范围差为:

//Creating array using range() method without range difference
var myArray1 = range(5, 20, 2)

现在,让我们看如何访问元素:

// Print all the array elements
for (x <- myArray1) {
  print(" " + x)
}
println()
for (x <- myArray2) {
  print(" " + x)
}

甚至可以使用concat()方法连接两个数组,如下所示:

//Array concatenation
var myArray3 =  concat( myArray1, myArray2)      
// Print all the array elements
for ( x <- myArray3 ) {
  print(" "+ x)
}

请注意,要使用range()concat()方法,您需要导入 ScalaArray包,如下所示:

Import Array._

最后,甚至可以定义和使用多维数组如下:

var myMatrix = ofDimInt

现在,首先使用前面的数组创建一个矩阵如下:

var myMatrix = ofDimInt
// build a matrix
for (i <- 0 to 3) {
  for (j <- 0 to 3) {
    myMatrix(i)(j) = j
  }
}
println()

按照以下方式打印先前的矩阵:

// Print two dimensional array
for (i <- 0 to 3) {
  for (j <- 0 to 3) {
    print(" " + myMatrix(i)(j))
  }
  println()
}

前面示例的完整源代码如下所示:

package com.chapter4.CollectionAPI
import Array._                                                                                         object ArrayExample {
  def main(args: Array[String]) {
    val numbers: Array[Int] = ArrayInt
    // A simple array
    // Print all the element of the array
    println("The full array is: ")
    for (i <- numbers) {
      print(" " + i)
    }
    //Print a particular element for example element 3
    println(numbers(2))
    //Summing all the elements
    var total = 0
    for (i <- 0 to (numbers.length - 1)) {
      total = total + numbers(i)
    }
    println("Sum: = " + total)
    // Finding the smallest element
    var min = numbers(0)
    for (i <- 1 to (numbers.length - 1)) {
      if (numbers(i) < min) min = numbers(i)
    }
    println("Min is: " + min)
    // Finding the largest element
    var max = numbers(0)
    for (i <- 1 to (numbers.length - 1)) {
      if (numbers(i) > max) max = numbers(i)
    }
    println("Max is: " + max)
    //Creating array using range() method
    var myArray1 = range(5, 20, 2)
    var myArray2 = range(5, 20)
    // Print all the array elements
    for (x <- myArray1) {
      print(" " + x)
    }
    println()
    for (x <- myArray2) {
      print(" " + x)
    }
    //Array concatenation
    var myArray3 = concat(myArray1, myArray2)
    // Print all the array elements
    for (x <- myArray3) {
      print(" " + x)
    }
    //Multi-dimensional array
    var myMatrix = ofDimInt
    // build a matrix
    for (i <- 0 to 3) {
      for (j <- 0 to 3) {
        myMatrix(i)(j) = j
      }
    }
    println();
    // Print two dimensional array
    for (i <- 0 to 3) {
      for (j <- 0 to 3) {
        print(" " + myMatrix(i)(j))
      }
      println();
    }
  }
}

您将获得以下输出:

The full array is: 1 2 3 4 5 1 2 3 3 4 53 
Sum: = 33 
Min is: 1 
Max is: 5 
5 7 9 11 13 15 17 19 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 5 7 9 11 13 15 17 19 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 
0 1 2 3 
0 1 2 3 
0 1 2 3 
0 1 2 3

在 Scala 中,列表保留顺序,保留重复元素,并检查其不可变性。现在,让我们在下一小节中看一些在 Scala 中使用列表的示例。

名单

如前所述,Scala 提供了可变和不可变的集合。不可变集合默认导入,但如果需要使用可变集合,则需要自行导入。列表是不可变集合,如果您希望元素之间保持顺序并保留重复项,则可以使用它。让我们演示一个例子,看看列表如何保留顺序并保留重复元素,并检查其不可变性:

scala> val numbers = List(1, 2, 3, 4, 5, 1, 2, 3, 4, 5)
numbers: List[Int] = List(1, 2, 3, 4, 5, 1, 2, 3, 4, 5) 
scala> numbers(3) = 10 
<console>:12: error: value update is not a member of List[Int] 
numbers(3) = 10 ^

您可以使用两种不同的构建块来定义列表。Nil表示List的尾部,之后是一个空的List。因此,前面的例子可以重写为:

scala> val numbers = 1 :: 2 :: 3 :: 4 :: 5 :: 1 :: 2 :: 3:: 4:: 5 :: Nil
numbers: List[Int] = List(1, 2, 3, 4, 5, 1, 2, 3,4, 5

让我们在以下详细示例中检查列表及其方法:

package com.chapter4.CollectionAPI

object ListExample {
  def main(args: Array[String]) {
    // List of cities
    val cities = "Dublin" :: "London" :: "NY" :: Nil

    // List of Even Numbers
    val nums = 2 :: 4 :: 6 :: 8 :: Nil

    // Empty List.
    val empty = Nil

    // Two dimensional list
    val dim = 1 :: 2 :: 3 :: Nil ::
                   4 :: 5 :: 6 :: Nil ::
                   7 :: 8 :: 9 :: Nil :: Nil
    val temp = Nil

    // Getting the first element in the list
    println( "Head of cities : " + cities.head )

    // Getting all the elements but the last one
    println( "Tail of cities : " + cities.tail )

    //Checking if cities/temp list is empty
    println( "Check if cities is empty : " + cities.isEmpty )
    println( "Check if temp is empty : " + temp.isEmpty )

    val citiesEurope = "Dublin" :: "London" :: "Berlin" :: Nil
    val citiesTurkey = "Istanbul" :: "Ankara" :: Nil

    //Concatenate two or more lists with :::
    var citiesConcatenated = citiesEurope ::: citiesTurkey
    println( "citiesEurope ::: citiesTurkey : "+citiesConcatenated )

    // using the concat method
    citiesConcatenated = List.concat(citiesEurope, citiesTurkey)
    println( "List.concat(citiesEurope, citiesTurkey) : " +
             citiesConcatenated  )

  }
}

您将获得以下输出:

Head of cities : Dublin
Tail of cities : List(London, NY)
Check if cities is empty : false
Check if temp is empty : true
citiesEurope ::: citiesTurkey : List(Dublin, London, Berlin, Istanbul, Ankara)
List.concat(citiesEurope, citiesTurkey) : List(Dublin, London, Berlin, Istanbul, Ankara)

现在,让我们在下一小节中快速概述如何在 Scala 应用程序中使用集合。

集合

集合是最广泛使用的集合之一。在集合中,顺序不会被保留,集合不允许重复元素。你可以把它看作是集合的数学表示法。让我们通过一个例子来演示一下,我们将看到集合不保留顺序,也不允许重复:

scala> val numbers = Set( 1, 2, 3, 4, 5, 1, 2, 3, 4, 5)
numbers: scala.collection.immutable.Set[Int] = Set(5, 1, 2, 3, 4)

以下源代码显示了在 Scala 程序中使用集合的不同方法:

package com.chapter4.CollectionAPI
object SetExample {
  def main(args: Array[String]) {
    // Empty set of integer type
    var sInteger : Set[Int] = Set()
    // Set of even numbers
    var sEven : Set[Int] = Set(2,4,8,10)
    //Or you can use this syntax
    var sEven2 = Set(2,4,8,10)
    val cities = Set("Dublin", "London", "NY")
    val tempNums: Set[Int] = Set()
    //Finding Head, Tail, and checking if the sets are empty
    println( "Head of cities : " + cities.head )
    println( "Tail of cities : " + cities.tail )
    println( "Check if cities is empty : " + cities.isEmpty )
    println( "Check if tempNums is empty : " + tempNums.isEmpty )
    val citiesEurope = Set("Dublin", "London", "NY")
    val citiesTurkey = Set("Istanbul", "Ankara")
    // Sets Concatenation using ++ operator
    var citiesConcatenated = citiesEurope ++ citiesTurkey
    println( "citiesEurope ++ citiesTurkey : " + citiesConcatenated )
    //Also you can use ++ as a method
    citiesConcatenated = citiesEurope.++(citiesTurkey)
    println( "citiesEurope.++(citiesTurkey) : " + citiesConcatenated )
    //Finding minimum and maximum elements in the set
    val evenNumbers = Set(2,4,6,8)
    // Using the min and max methods
    println( "Minimum element in Set(2,4,6,8) : " + evenNumbers.min )
    println( "Maximum element in Set(2,4,6,8) : " + evenNumbers.max )
  }
}

您将获得以下输出:

Head of cities : Dublin
Tail of cities : Set(London, NY)
Check if cities is empty : false
Check if tempNums is empty : true
citiesEurope ++ citiesTurkey : Set(London, Dublin, Ankara, Istanbul, NY)
citiesEurope.++(citiesTurkey) : Set(London, Dublin, Ankara, Istanbul, NY)
Minimum element in Set(2,4,6,8) : 2
Maximum element in Set(2,4,6,8) : 8

根据我个人的经验,在使用 Java 或 Scala 开发 Spark 应用程序时,我发现元组的使用非常频繁,特别是用于分组元素的集合,而不使用任何显式类。在下一小节中,我们将看到如何在 Scala 中开始使用元组。

元组

Scala 元组用于将固定数量的项目组合在一起。这种分组的最终目标是帮助匿名函数,以便它们可以作为一个整体传递。与数组或列表的真正区别在于,元组可以容纳不同类型的对象,同时保持每个元素类型的信息,而集合不会,并且使用公共类型作为类型(例如,在前面的例子中,该集合的类型将是Set[Any])。

从计算的角度来看,Scala 元组也是不可变的。换句话说,元组使用类来存储元素(例如,Tuple2Tuple3Tuple22等)。

以下是一个包含整数、字符串和控制台的元组的示例:

val tuple_1 = (20, "Hello", Console)

这是以下的语法糖(快捷方式):

val t = new Tuple3(20, "Hello", Console)

另一个例子:

scala> val cityPop = ("Dublin", 2)
cityPop: (String, Int) = (Dublin,2)

您无法使用命名访问器访问元组数据,而是需要使用基于位置的访问器,其基于 1 而不是 0。例如:

scala> val cityPop = ("Dublin", 2)
cityPop: (String, Int) = (Dublin,2) 
scala> cityPop._1
res3: String = Dublin 
scala> cityPop._2
res4: Int = 2

此外,元组可以完美地适应模式匹配。例如:

cityPop match {
  case ("Dublin", population) => ...
  case ("NY", population) => ...
}

您甚至可以使用特殊运算符->来编写 2 值元组的紧凑语法。例如:

scala> "Dublin" -> 2
res0: (String, Int) = (Dublin,2)

以下是一个更详细的示例,以演示元组功能:

package com.chapter4.CollectionAPI
object TupleExample {
  def main(args: Array[String]) {
    val evenTuple = (2,4,6,8)
    val sumTupleElements =evenTuple._1 + evenTuple._2 + evenTuple._3 + evenTuple._4
    println( "Sum of Tuple Elements: "  + sumTupleElements )      
    // You can also iterate over the tuple and print it's element using the foreach method
    evenTuple.productIterator.foreach{ evenTuple =>println("Value = " + evenTuple )}
  }
}

您将获得以下输出:

Sum of Tuple Elements: 20 Value = 2 Value = 4 Value = 6 Value = 8

现在,让我们深入了解在 Scala 中使用地图,这些地图被广泛用于保存基本数据类型。

地图

地图是由键和值对(也称为映射或关联)组成的Iterable。地图也是最广泛使用的连接之一,因为它可以用于保存基本数据类型。例如:

scala> Map(1 -> 2)
res7: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2)                                                scala> Map("X" -> "Y")
res8: scala.collection.immutable.Map[String,String] = Map(X -> Y)

Scala 的Predef对象提供了一个隐式转换,让您可以将key -> value写成pair (key, value)的替代语法。例如,Map("a" -> 10, "b" -> 15, "c" -> 16)的含义与Map(("a", 10), ("b", 15), ("c", 16))完全相同,但读起来更好。

此外,Map可以简单地被视为Tuple2s的集合:

Map(2 -> "two", 4 -> "four")

前一行将被理解为:

Map((2, "two"), (4, "four"))

在这个例子中,我们可以说使用Map可以存储一个函数,这就是函数在函数式编程语言中的全部意义:它们是头等公民,可以在任何地方使用。

假设你有一个用于查找数组中最大元素的方法如下:

var myArray = range(5, 20, 2)
  def getMax(): Int = {
    // Finding the largest element
    var max = myArray(0)
    for (i <- 1 to (myArray.length - 1)) {
      if (myArray(i) > max)
        max = myArray(i)
    }
    max
  }

现在,让我们映射它,以便使用Map存储该方法:

scala> val myMax = Map("getMax" -> getMax()) 
scala> println("My max is: " + myMax )

让我们看另一个使用映射的例子:

scala> Map( 2 -> "two", 4 -> "four")
res9: scala.collection.immutable.Map[Int,String] = Map(2 -> two, 4 -> four)
scala> Map( 1 -> Map("X"-> "Y"))
res10: scala.collection.immutable.Map[Int,scala.collection.immutable.Map[String,String]] = Map(1 -> Map(X -> Y))

以下是一个详细的示例,演示了Map的功能:

package com.chapter4.CollectionAPI
import Array._

object MapExample {
  var myArray = range(5, 20, 2)

  def getMax(): Int = {
    // Finding the largest element
    var max = myArray(0)
    for (i <- 1 to (myArray.length - 1)) {
      if (myArray(i) > max)
        max = myArray(i)
    }
    max
  }

  def main(args: Array[String]) {
    val capitals = Map("Ireland" -> "Dublin", "Britain" -> "London", 
    "Germany" -> "Berlin")

    val temp: Map[Int, Int] = Map()
    val myMax = Map("getMax" -> getMax())
    println("My max is: " + myMax )

    println("Keys in capitals : " + capitals.keys)
    println("Values in capitals : " + capitals.values)
    println("Check if capitals is empty : " + capitals.isEmpty)
    println("Check if temp is empty : " + temp.isEmpty)

    val capitals1 = Map("Ireland" -> "Dublin", "Turkey" -> "Ankara",
    "Egypt" -> "Cairo")
    val capitals2 = Map("Germany" -> "Berlin", "Saudi Arabia" ->
    "Riyadh")

    // Map concatenation using ++ operator
    var capitalsConcatenated = capitals1 ++ capitals2
    println("capitals1 ++ capitals2 : " + capitalsConcatenated)

    // use two maps with ++ as method
    capitalsConcatenated = capitals1.++(capitals2)
    println("capitals1.++(capitals2)) : " + capitalsConcatenated)

  }
}

您将得到以下输出:

My max is: Map(getMax -> 19)
Keys in capitals : Set(Ireland, Britain, Germany)
Values in capitals : MapLike(Dublin, London, Berlin)
Check if capitals is empty : false
Check if temp is empty : true
capitals1 ++ capitals2 : Map(Saudi Arabia -> Riyadh, Egypt -> Cairo, Ireland -> Dublin, Turkey -> Ankara, Germany -> Berlin)
capitals1.++(capitals2)) : Map(Saudi Arabia -> Riyadh, Egypt -> Cairo, Ireland -> Dublin, Turkey -> Ankara, Germany -> Berlin)

现在,让我们快速概述一下在 Scala 中使用选项;这基本上是一个可以容纳数据的数据容器。

选项

Option类型在 Scala 程序中经常使用,您可以将其与 Java 中的空值进行比较,空值表示没有值。Scala 的Option [T]是给定类型的零个或一个元素的容器。Option [T]可以是Some [T]None对象,它表示缺少值。例如,Scala 的Map的 get 方法如果找到与给定键对应的值,则产生Some(value),如果给定键在Map中未定义,则产生None

Option的基本特征如下:

trait Option[T] {
  def get: A // Returns the option's value.
  def isEmpty: Boolean // Returns true if the option is None, false
  otherwise.
  def productArity: Int // The size of this product. For a product
  A(x_1, ..., x_k), returns k
  def productElement(n: Int): Any // The nth element of this product,
  0-based
  def exists(p: (A) => Boolean): Boolean // Returns true if this option
  is nonempty 
  def filter(p: (A) => Boolean): Option[A] // Returns this Option if it
  is nonempty 
  def filterNot(p: (A) => Boolean): Option[A] // Returns this Option if
  it is nonempty or return None.
  def flatMapB => Option[B]): Option[B] // Returns result of
  applying f to this Option's 
  def foreachU => U): Unit // Apply given procedure f to the
  option's value, if it is nonempty.  
  def getOrElseB >: A: B // Returns the option's value
  if the option is nonempty, 
  def isDefined: Boolean // Returns true if the option is an instance
  of Some, false otherwise.
  def iterator: Iterator[A] // Returns a singleton iterator returning
  Option's value if it is nonempty
  def mapB => B): Option[B] // Returns a Some containing
  result of applying f to this Option's 
  def orElseB >: A: Option[B] // Returns
  this Option if it is nonempty
  def orNull // Returns the option's value if it is nonempty,
                or null if it is empty.  
}

例如,在下面的代码中,我们试图映射并显示一些位于一些国家的大城市,如印度孟加拉国日本美国

object ScalaOptions {
  def main(args: Array[String]) {
    val megacity = Map("Bangladesh" -> "Dhaka", "Japan" -> "Tokyo",
    "India" -> "Kolkata", "USA" -> "New York")
    println("megacity.get( \"Bangladesh\" ) : " + 
    show(megacity.get("Bangladesh")))
    println("megacity.get( \"India\" ) : " + 
    show(megacity.get("India")))
  }
}

现在,为了使前面的代码工作,我们需要在某个地方定义show()方法。在这里,我们可以使用Option通过 Scala 模式匹配来实现:

def show(x: Option[String]) = x match {
  case Some(s) => s
  case None => "?"
}

将它们组合如下应该打印出我们期望的准确结果:

package com.chapter4.CollectionAPI
object ScalaOptions {
  def show(x: Option[String]) = x match {
    case Some(s) => s
    case None => "?"
  } 
  def main(args: Array[String]) {
    val megacity = Map("Bangladesh" -> "Dhaka", "Japan" -> "Tokyo",
    "India" -> "Kolkata", "USA" -> "New York")
    println("megacity.get( \"Bangladesh\" ) : " +
    show(megacity.get("Bangladesh")))
    println("megacity.get( \"India\" ) : " +
    show(megacity.get("India")))
  }
}

您将得到以下输出:

megacity.get( "Bangladesh" ) : Dhaka
megacity.get( "India" ) : Kolkata

使用getOrElse()方法,可以在没有值时访问值或默认值。例如:

// Using getOrElse() method: 
val message: Option[String] = Some("Hello, world!")
val x: Option[Int] = Some(20)
val y: Option[Int] = None
println("message.getOrElse(0): " + message.getOrElse(0))
println("x.getOrElse(0): " + x.getOrElse(0))
println("y.getOrElse(10): " + y.getOrElse(10))

您将得到以下输出:

message.getOrElse(0): Hello, world!
x.getOrElse(0): 20
y.getOrElse(10): 10

此外,使用isEmpty()方法,您可以检查选项是否为None。例如:

println("message.isEmpty: " + message.isEmpty)
println("x.isEmpty: " + x.isEmpty)
println("y.isEmpty: " + y.isEmpty)

现在,这是完整的程序:

package com.chapter4.CollectionAPI
object ScalaOptions {
  def show(x: Option[String]) = x match {
    case Some(s) => s
    case None => "?"
  }
  def main(args: Array[String]) {
    val megacity = Map("Bangladesh" -> "Dhaka", "Japan" -> "Tokyo",
    "India" -> "Kolkata", "USA" -> "New York")
    println("megacity.get( \"Bangladesh\" ) : " +
    show(megacity.get("Bangladesh")))
    println("megacity.get( \"India\" ) : " +
    show(megacity.get("India")))

    // Using getOrElse() method: 
    val message: Option[String] = Some("Hello, world")
    val x: Option[Int] = Some(20)
    val y: Option[Int] = None

    println("message.getOrElse(0): " + message.getOrElse(0))
    println("x.getOrElse(0): " + x.getOrElse(0))
    println("y.getOrElse(10): " + y.getOrElse(10))

    // Using isEmpty()
    println("message.isEmpty: " + message.isEmpty)
    println("x.isEmpty: " + x.isEmpty)
    println("y.isEmpty: " + y.isEmpty)
  }
}

您将得到以下输出:

megacity.get( "Bangladesh" ) : Dhaka
megacity.get( "India" ) : Kolkata
message.getOrElse(0): Hello, world
x.getOrElse(0): 20
y.getOrElse(10): 10
message.isEmpty: false
x.isEmpty: false
y.isEmpty: true

让我们看看何时使用Option的其他示例。例如,Map.get()方法使用Option来告诉用户他尝试访问的元素是否存在。例如:

scala> val numbers = Map("two" -> 2, "four" -> 4)
numbers: scala.collection.immutable.Map[String,Int] = Map(two -> 2, four -> 4)
scala> numbers.get("four")
res12: Option[Int] = Some(4)
scala> numbers.get("five")
res13: Option[Int] = None

现在,我们将看到如何使用 exists,它用于检查遍历集合中一组元素的子集是否满足谓词。

Exists

Exists 检查是否至少有一个元素在 Traversable 集合中满足谓词。例如:

def exists(p: ((A, B)) ⇒ Boolean): Boolean  

使用 fat arrow: =>称为右箭头粗箭头火箭,用于通过名称传递参数。这意味着当访问参数时,表达式将被评估。它实际上是一个零参数函数call: x: () => Boolean的语法糖。让我们看一个使用这个操作符的例子如下:

package com.chapter4.CollectionAPI

object UsingFatArrow {

def fliesPerSecond(callback: () => Unit) {

while (true) { callback(); Thread sleep 1000 }

}

def main(args: Array[String]): Unit= {

fliesPerSecond(() => println("时间和潮汐等待着没有,但飞得像一支箭..."))

}

}

您将得到以下输出:

时间和潮汐等待着没有,但飞得像一支箭...

时间和潮汐等待着没有,但飞得像一支箭...

时间和潮汐等待着没有,但飞得像一支箭...

时间和潮汐等待着没有,但飞得像一支箭...

时间和潮汐等待着没有,但飞得像一支箭...

时间和潮汐等待着没有,但飞得像一支箭...

可以在以下代码中看到一个详细的示例:

package com.chapter4.CollectionAPI

object ExistsExample {
  def main(args: Array[String]) {
    // Given a list of cities and now check if "Dublin" is included in
    the list     
    val cityList = List("Dublin", "NY", "Cairo")
    val ifExisitsinList = cityList exists (x => x == "Dublin")
    println(ifExisitsinList)

    // Given a map of countries and their capitals check if Dublin is
    included in the Map 
    val cityMap = Map("Ireland" -> "Dublin", "UK" -> "London")
    val ifExistsinMap =  cityMap exists (x => x._2 == "Dublin")
    println(ifExistsinMap)
  }
}

您将得到以下输出:

true
true

注意:在 Scala 中使用中缀运算符

在之前的例子和后续的部分中,我们使用了 Scala 的中缀表示法。假设你想对复数执行一些操作,并且有一个带有添加两个复数的方法的案例类:

case class Complex(i: Double, j: Double) {
   def plus(other: Complex): Complex = Complex(i + other.i, j + other.j)
 }

现在,为了访问这个类的属性,你需要创建一个像这样的对象:

val obj = Complex(10, 20)

此外,假设你已经定义了以下两个复数:

val a = Complex(6, 9)
 val b = Complex(3, -6)

现在要从案例类中访问plus()方法,你需要这样做:

val z = obj.plus(a)

这应该给你输出:Complex(16.0,29.0)。然而,如果你像这样调用方法会不会更好:

val c = a plus b

它确实像魅力一样起作用。以下是完整的示例:

package com.chapter4.CollectionAPI
 object UsingInfix {
   case class Complex(i: Double, j: Double) {
     def plus(other: Complex): Complex = Complex(i + other.i, j + other.j)
   }  
   def main(args: Array[String]): Unit = {    
     val obj = Complex(10, 20)
     val a = Complex(6, 9)
     val b = Complex(3, -6)
     val c = a plus b
     val z = obj.plus(a)
     println(c)
     println(z)
   }
 }

中缀运算符的优先级:这由运算符的第一个字符决定。字符按优先级递增的顺序列在下面,同一行上的字符具有相同的优先级:

(all letters)
 |
 ^
 &
 = !
 < >
 :
 + -
 * / %
 (all other special characters)

一般警告:不鼓励使用中缀表示法来调用常规的非符号方法,只有在它显著提高可读性时才应该使用。中缀表示法的一个充分动机的例子是ScalaTest中的匹配器和测试定义的其他部分。

Scala 集合包中的另一个有趣的元素是使用forall。它用于检查谓词是否对Traversable集合中的每个元素成立。在下一小节中,我们将看到一个例子。

Forall

Forall 检查谓词是否对Traversable集合中的每个元素成立。可以正式定义如下:

def forall (p: (A) ⇒ Boolean): Boolean  

让我们看一个例子如下:

scala> Vector(1, 2, 8, 10) forall (x => x % 2 == 0)
res2: Boolean = false

在编写 Scala 代码进行预处理时,我们经常需要过滤选定的数据对象。Scala 集合 API 的过滤功能用于此目的。在下一小节中,我们将看到使用过滤的例子。

Filter

filter选择所有满足特定谓词的元素。可以正式定义如下:

def filter(p: (A) ⇒ Boolean): Traversable[A]  

让我们看一个例子如下:

scala> //Given a list of tuples (cities, Populations)
scala> // Get all cities that has population more than 5 million
scala> List(("Dublin", 2), ("NY", 8), ("London", 8)) filter (x =>x._2 >= 5)
res3: List[(String, Int)] = List((NY,8), (London,8))

Map 用于通过对集合的所有元素应用函数来构建新的集合或元素集。在下一小节中,我们将看到使用Map的例子。

Map

Map 用于通过对集合的所有元素应用函数来构建新的集合或元素集。可以正式定义如下:

def mapB ⇒ B): Map[B]  

让我们看一个例子如下:

scala> // Given a list of integers
scala> // Get a list with all the elements square.
scala> List(2, 4, 5, -6) map ( x=> x * x)
res4: List[Int] = List(4, 16, 25, 36)

在使用 Scala 的集合 API 时,你经常需要选择列表或数组的第 n 个元素。在下一小节中,我们将探讨使用 take 的例子。

Take

Take 用于获取集合的前 n 个元素。使用take的正式定义如下:

def take(n: Int): Traversable[A]

让我们看一个例子如下:

// Given an infinite recursive method creating a stream of odd numbers.
def odd: Stream[Int] = {
  def odd0(x: Int): Stream[Int] =
    if (x%2 != 0) x #:: odd0(x+1)
    else odd0(x+1)
      odd0(1)
}// Get a list of the 5 first odd numbers.
odd take (5) toList

你将得到以下输出:

res5: List[Int] = List(1, 3, 5, 7, 9)

在 Scala 中,如果想要根据特定的分区函数将特定的集合分成另一个Traversable集合的映射,可以使用groupBy()方法。在下一小节中,我们将展示使用groupBy()的一些例子。

GroupBy

GroupBy 用于根据特定的分区函数将特定的集合分成其他Traversable集合的映射。可以正式定义如下:

def groupByK) ⇒ K): Map[K, Map[A, B]]  

让我们看一个例子如下:

scala> // Given a list of numbers
scala> // Group them as positive and negative numbers.
scala> List(1,-2,3,-4) groupBy (x => if (x >= 0) "positive" else "negative")
res6: scala.collection.immutable.Map[String,List[Int]] = Map(negative -> List(-2, -4), positive -> List(1, 3))

在 Scala 中,如果你想选择Traversable集合中除了最后一个元素之外的所有元素,可以使用init。在下一小节中,我们将看到它的例子。

Init

init选择Traversable集合中除了最后一个元素之外的所有元素。可以正式定义如下:

def init: Traversable[A]  

让我们看一个例子如下:

scala> List(1,2,3,4) init
res7: List[Int] = List(1, 2, 3)

在 Scala 中,如果你想选择除了前 n 个元素之外的所有元素,你应该使用 drop。在下一小节中,我们将看到如何使用 drop。

Drop

drop用于选择除了前 n 个元素之外的所有元素。可以正式定义如下:

def drop(n: Int): Traversable[A]  

让我们看一个例子如下:

// Drop the first three elements
scala> List(1,2,3,4) drop 3
res8: List[Int] = List(4)

在 Scala 中,如果你想要在满足谓词的情况下获取一组元素,你应该使用takeWhile。在下一小节中,我们将看到如何使用takeWhile

TakeWhile

TakeWhile 用于获取一组元素,直到满足谓词。可以正式定义如下:

def takeWhile(p: (A) ⇒ Boolean): Traversable[A]  

让我们看一个例子如下:

// Given an infinite recursive method creating a stream of odd numbers.
def odd: Stream[Int] = {
  def odd0(x: Int): Stream[Int] =
    if (x%2 != 0) x #:: odd0(x+1)
    else odd0(x+1)
      odd0(1)
}
// Return a list of all the odd elements until an element isn't less then 9\. 
odd takeWhile (x => x < 9) toList

您将得到以下输出:

res11: List[Int] = List(1, 3, 5, 7)

在 Scala 中,如果您想要省略一组元素,直到满足谓词,您应该使用dropWhile。我们将在下一小节中看到一些此类示例。

DropWhile

dropWhile用于省略一组元素,直到满足谓词。可以正式定义如下:

def dropWhile(p: (A) ⇒ Boolean): Traversable[A]  

让我们看一个例子如下:

//Drop values till reaching the border between numbers that are greater than 5 and less than 5
scala> List(2,3,4,9,10,11) dropWhile(x => x <5)
res1: List[Int] = List(9, 10, 11)

在 Scala 中,如果您想要使用您的用户定义函数UDF),使其接受嵌套列表中的函数作为参数,并将输出组合在一起,flatMap()是一个完美的选择。我们将在下一节中看到使用flatMap()的例子。

FlatMap

FltatMap 接受一个函数作为参数。给定给flatMap()的函数不适用于嵌套列表,但它会产生一个新的集合。可以正式定义如下:

def flatMapB ⇒ GenTraversableOnce[B]): Traversable[B]  

让我们看一个例子如下:

//Applying function on nested lists and then combining output back together
scala> List(List(2,4), List(6,8)) flatMap(x => x.map(x => x * x))
res4: List[Int] = List(4, 16, 36, 64)

我们几乎已经完成了对 Scala 集合功能的使用。还要注意,诸如Fold()Reduce()Aggregate()Collect()Count()Find()Zip()等方法可以用于从一个集合传递到另一个集合(例如,toVectortoSeqtoSettoArray)。但是,我们将在即将到来的章节中看到这样的例子。目前,是时候看一下不同 Scala 集合 API 的性能特征了。

性能特征

在 Scala 中,不同的集合具有不同的性能特征,这些性能特征是您选择一个集合而不是其他集合的原因。在本节中,我们将从操作和内存使用的角度评估 Scala 集合对象的性能特征。在本节结束时,我们将为您的代码和问题类型选择适当的集合对象提供一些指导方针。

序列类型(不可变)的性能特征

以下是 Scala 集合的性能特征,基于 Scala 的官方文档。

  • 常数:该操作只需要常数时间。

  • eConst:该操作实际上需要常数时间,但这可能取决于一些假设,例如向量的最大长度或哈希键的分布。

  • 线性:该操作随着集合大小线性增长。

  • 日志:该操作随着集合大小对数增长。

  • aConst:该操作需要摊销常数时间。该操作的一些调用可能需要更长时间,但如果平均执行许多操作,每个操作只需要常数时间。

  • NA:不支持该操作。

不可变序列类型的性能特征在下表中呈现。

不可变 CO* 应用 更新 前置 附加 插入
列表 常数 常数 线性 线性 常数 线性 NA
常数 常数 线性 线性 常数 线性 NA
向量 eConst eConst eConst eConst eConst eConst NA
常数 常数 线性 线性 常数 线性 线性
队列 aConst aConst 线性 线性 常数 常数 NA
范围 常数 常数 常数 NA NA NA NA
字符串 常数 线性 常数 线性 线性 线性 NA

**表 1:**序列类型(不可变)的性能特征[*CO==集合对象]

以下表格显示了在表 1表 3中描述的操作的含义:

用于选择现有序列的前几个元素。
用于选择除第一个元素之外的所有元素,并返回一个新序列。
应用 用于索引目的。
更新 用作不可变序列的函数更新。对于可变序列,它是一个具有副作用的更新(对于可变序列的更新)。
前置 用于在现有序列的前面添加元素。对于不可变序列,会生成一个新序列。对于可变序列,会修改现有序列。
追加 用于在现有序列的末尾添加元素。对于不可变序列,会生成一个新序列。对于可变序列,会修改现有序列。
插入 用于在现有序列的任意位置插入元素。对于可变序列,可以直接进行操作。

**表 2:**表 1 中描述的操作的含义

序列类型(可变)的性能特征如表 3所示:

可变 CO* 应用 更新 前置 追加 插入
ArrayBuffer 常数 线性 常数 常数 线性 常数 线性
ListBuffer 常数 线性 线性 线性 常数 常数 线性
StringBuilder 常数 线性 常数 常数 线性 常数 线性
MutableList 常数 线性 线性 线性 常数 常数 线性
Queue 常数 线性 线性 线性 常数 常数 线性
ArraySeq 常数 线性 常数 常数 NA NA NA
常数 线性 线性 线性 常数 线性 线性
ArrayStack 常数 线性 常数 常数 常数 线性 线性
Array 常数 线性 常数 常数 NA NA NA

**表 3:**序列类型(可变)的性能特征[*CO==集合对象]

有关可变集合和其他类型的集合的更多信息,您可以参考此链接(docs.scala-lang.org/overviews/collections/performance-characteristics.html)。

集合和映射类型的性能特征如下表所示:

集合类型 查找 添加 移除 最小
不可变 - - - -
HashSet/HashMap 常数 常数 常数 线性
TreeSet/TreeMap 对数 对数 对数 对数
BitSet 常数 线性 线性 常数*
ListMap 线性 线性 线性 线性
集合类型 查找 添加 移除 最小
可变 - - - -
HashSet/HashMap 常数 常数 常数 线性
WeakHashMap 常数 常数 常数 线性
BitSet 常数 常数 常数 线性
TreeSet 对数 对数 对数 对数

**表 4:**集合和映射类型的性能特征[*仅当位密集打包时适用]

以下表格显示了表 4 中描述的每个操作的含义:

操作 含义
查找 用于测试元素是否包含在集合中。其次,也用于选择与特定键关联的值。
添加 用于向集合添加新元素。其次,也用于向映射添加新的键/值对。
移除 用于从集合中移除元素或从映射中移除键。
最小 用于选择集合中最小的元素或映射中最小的键。

**表 5:**表 4 中描述的每个操作的含义

基本性能指标之一是特定集合对象的内存使用情况。在下一节中,我们将提供一些关于如何基于内存使用情况来衡量这些指标的指导方针。

集合对象的内存使用情况

有时,会有一些基准测试问题,例如:ListVector更适合你正在做的事情,还是VectorList更快?使用非包装的数组来存储原始数据可以节省多少内存?当您执行性能技巧时,例如预先分配数组或使用while循环而不是foreach调用,这到底有多重要?var l: List还是val b: mutable.Buffer?可以使用不同的 Scala 基准测试代码来估算内存使用情况,例如,请参阅github.com/lihaoyi/scala-bench

表 6 在这里显示了各种不可变集合的估计大小(字节),从 0 个元素,1 个元素,4 个元素和 4 的幂一直到 1,048,576 个元素。尽管大多数是确定性的,但这些可能会根据您的平台而改变:

大小 0 1 4 16 64 256 1,024 4,069 16,192 65,536 262,144 1,048,576
向量 56 216 264 456 1,512 5,448 21,192 84,312 334,440 1,353,192 5,412,168 21,648,072
数组[对象] 16 40 96 336 1,296 5,136 20,496 81,400 323,856 1,310,736 5,242,896 20,971,536
列表 16 56 176 656 2,576 10,256 40,976 162,776 647,696 2,621,456 10,485,776 41,943,056
流(未强制) 16 160 160 160 160 160 160 160 160 160 160 160
流(强制) 16 56 176 656 2,576 10,256 40,976 162,776 647,696 2,621,456 10,485,776 41,943,056
集合 16 32 96 880 3,720 14,248 59,288 234,648 895,000 3,904,144 14,361,000 60,858,616
地图 16 56 176 1,648 6,800 26,208 109,112 428,592 1,674,568 7,055,272 26,947,840 111,209,368
排序集 40 104 248 824 3,128 12,344 49,208 195,368 777,272 3,145,784 12,582,968 50,331,704
队列 40 80 200 680 2,600 10,280 41,000 162,800 647,720 2,621,480 10,485,800 41,943,080
字符串 40 48 48 72 168 552 2,088 8,184 32,424 131,112 524,328 2,097,192

**表 6:**各种集合的估计大小(字节)

下表显示了在 Scala 中使用的数组的估计大小(字节),其中包括 0 个元素,1 个元素,4 个元素和 4 的幂一直到 1,048,576 个元素。尽管大多数是确定性的,但这些可能会根据您的平台而改变:

大小 0 1 4 16 64 256 1,024 4,069 16,192 65,536 262,144 1,048,576
数组[对象] 16 40 96 336 1,296 5,136 20,496 81,400 323,856 1,310,736 5,242,896 20,971,536
大小 0 1 4 16 64 256 1,024 4,069 16,192 65,536 262,144 1,048,576
数组[Boolean] 16 24 24 32 80 272 1,040 4,088 16,208 65,552 262,160 1,048,592
数组[字节] 16 24 24 32 80 272 1,040 4,088 16,208 65,552 262,160 1,048,592
数组[短] 16 24 24 48 144 528 2,064 8,160 32,400 131,088 524,304 2,097,168
数组[整数] 16 24 32 80 272 1,040 4,112 16,296 64,784 262,160 1,048,592 4,194,320
数组[长] 16 24 48 144 528 2,064 8,208 32,568 129,552 524,304 2,097,168 8,388,624
包装数组[Boolean] 16 40 64 112 304 1,072 4,144 16,328 64,816 262,192 1,048,624 4,194,352
包装数组[字节] 16 40 96 336 1,296 5,136 8,208 20,392 68,880 266,256 1,052,688 4,198,416
包装数组[短] 16 40 96 336 1,296 5,136 20,496 81,400 323,856 1,310,736 5,230,608 20,910,096
包装的 Array[Int] 16 40 96 336 1,296 5,136 20,496 81,400 323,856 1,310,736 5,242,896 20,971,536
包装的 Array[Long] 16 48 128 464 1,808 7,184 28,688 113,952 453,392 1,835,024 7,340,048 29,360,144

表 7:Scala 数组的估计大小(字节)

然而,本书并不打算在广泛的范围内对它们进行区分,因此我们将省略对这些主题的讨论。有关这些主题的进一步指南,请参考以下信息框:

有关 Scala 集合的详细基准测试,请参阅 GitHub 上的此链接(github.com/lihaoyi/scala-bench/tree/master/bench/src/main/scala/bench)。

正如我们在第一章中提到的,Scala 简介,Scala 拥有非常丰富的集合 API。Java 也是如此,但是两种集合 API 之间存在许多差异。在下一节中,我们将看到一些关于 Java 互操作性的示例。

Java 互操作性

正如我们之前提到的,Scala 拥有非常丰富的集合 API。Java 也是如此,但是两种集合 API 之间存在许多差异。例如,两种 API 都有 iterable、iterators、maps、sets 和 sequences。但是 Scala 有优势;它更加关注不可变集合,并提供更多的操作,以便生成另一个集合。有时,您希望使用或访问 Java 集合,反之亦然。

JavaConversions不再是一个明智的选择。JavaConverters使得 Scala 和 Java 集合之间的转换变得明确,您不太可能遇到意外使用的隐式转换。

事实上,这样做相当简单,因为 Scala 以一种隐式的方式在JavaConversion对象中提供了在两种 API 之间进行转换的功能。因此,您可能会发现以下类型的双向转换:

Iterator               <=>     java.util.Iterator
Iterator               <=>     java.util.Enumeration
Iterable               <=>     java.lang.Iterable
Iterable               <=>     java.util.Collection
mutable.Buffer         <=>     java.util.List
mutable.Set            <=>     java.util.Set
mutable.Map            <=>     java.util.Map
mutable.ConcurrentMap  <=>     java.util.concurrent.ConcurrentMap

为了能够使用这种转换,您需要从JavaConversions对象中导入它们。例如:

scala> import collection.JavaConversions._
import collection.JavaConversions._

通过这种方式,您可以在 Scala 集合和其对应的 Java 集合之间进行自动转换:

scala> import collection.mutable._
import collection.mutable._
scala> val jAB: java.util.List[Int] = ArrayBuffer(3,5,7)
jAB: java.util.List[Int] = [3, 5, 7]
scala> val sAB: Seq[Int] = jAB
sAB: scala.collection.mutable.Seq[Int] = ArrayBuffer(3, 5, 7)
scala> val jM: java.util.Map[String, Int] = HashMap("Dublin" -> 2, "London" -> 8)
jM: java.util.Map[String,Int] = {Dublin=2, London=8}

您还可以尝试将其他 Scala 集合转换为 Java 集合。例如:

Seq           =>    java.util.List
mutable.Seq   =>    java.utl.List
Set           =>    java.util.Set
Map           =>    java.util.Map 

Java 不提供区分不可变和可变集合的功能。List将是java.util.List,对其元素进行任何尝试修改都会抛出异常。以下是一个示例来演示这一点:

scala> val jList: java.util.List[Int] = List(3,5,7)
jList: java.util.List[Int] = [3, 5, 7]
scala> jList.add(9)
java.lang.UnsupportedOperationException
 at java.util.AbstractList.add(AbstractList.java:148)
 at java.util.AbstractList.add(AbstractList.java:108)
 ... 33 elided

在第二章中,面向对象的 Scala,我们简要讨论了使用隐式。然而,在下一节中,我们将详细讨论使用隐式。

使用 Scala 隐式

我们在之前的章节中已经讨论了隐式,但在这里我们将看到更多示例。隐式参数与默认参数非常相似,但它们使用不同的机制来查找默认值。

隐式参数是传递给构造函数或方法的参数,并且被标记为 implicit,这意味着如果您没有为该参数提供值,编译器将在范围内搜索隐式值。例如:

scala> def func(implicit x:Int) = print(x) 
func: (implicit x: Int)Unit
scala> func
<console>:9: error: could not find implicit value for parameter x: Int
 func
 ^
scala> implicit val defVal = 2
defVal: Int = 2
scala> func(3)
3

隐式对于集合 API 非常有用。例如,集合 API 使用隐式参数为这些集合中的许多方法提供CanBuildFrom对象。这通常发生是因为用户不关心这些参数。

一个限制是每个方法不能有多个 implicit 关键字,并且必须位于参数列表的开头。以下是一些无效的示例:

scala> def func(implicit x:Int, y:Int)(z:Int) = println(y,x)
<console>:1: error: '=' expected but '(' found.
 def func(implicit x:Int, y:Int)(z:Int) = println(y,x)
 ^

**隐式参数的数量:**请注意,您可以有多个隐式参数。但是,您不能有多个隐式参数组。

对于多个隐式参数,如下所示:

scala> def func(implicit x:Int, y:Int)(implicit z:Int, f:Int) = println(x,y)
<console>:1: error: '=' expected but '(' found.
 def func(implicit x:Int, y:Int)(implicit z:Int, f:Int) = println(x,y)
 ^

函数的最终参数列表可以被标识或标记为隐式。这意味着值将从上下文中被调用时被取出。换句话说,如果在范围内没有确切类型的隐式值,使用隐式的源代码将不会被编译。原因很简单:由于隐式值必须解析为单一值类型,最好将类型特定于其目的,以避免隐式冲突。

此外,你不需要方法来找到一个隐式。例如:

// probably in a library
class Prefixer(val prefix: String)
def addPrefix(s: String)(implicit p: Prefixer) = p.prefix + s
// then probably in your application
implicit val myImplicitPrefixer = new Prefixer("***")
addPrefix("abc")  // returns "***abc"

当你的 Scala 编译器发现一个表达式的类型与上下文不符时,它会寻找一个隐式函数值来进行类型检查。因此,你的常规方法与标记为隐式的方法之间的区别在于,当发现Double但需要Int时,编译器会为你插入标记为隐式的方法。例如:

scala> implicit def doubleToInt(d: Double) = d.toInt
val x: Int = 42.0

之前的代码将与以下代码相同:

scala> def doubleToInt(d: Double) = d.toInt
val x: Int = doubleToInt(42.0)

在第二个例子中,我们手动插入了转换。起初,编译器会自动执行这个操作。之所以需要转换是因为左侧有类型注释。

在处理数据时,我们经常需要将一种类型转换为另一种类型。Scala 隐式类型转换为我们提供了这种便利。我们将在下一节中看到它的几个例子。

Scala 中的隐式转换

从类型S到类型T的隐式转换是由具有函数类型S => T的隐式值定义的,或者由可转换为该类型值的隐式方法定义。隐式转换适用于两种情况(来源:docs.scala-lang.org/tutorials/tour/implicit-conversions):

  • 如果表达式 e 的类型为S,并且 S 不符合表达式的预期类型T

  • 在选择e.m中,e的类型为S,如果选择器m不表示S的成员。

好了,我们已经看到了如何在 Scala 中使用中缀运算符。现在,让我们看一些 Scala 隐式转换的用例。假设我们有以下代码段:

class Complex(val real: Double, val imaginary: Double) {
  def plus(that: Complex) = new Complex(this.real + that.real, this.imaginary + that.imaginary)
  def minus(that: Complex) = new Complex(this.real - that.real, this.imaginary - that.imaginary)
  def unary(): Double = {
    val value = Math.sqrt(real * real + imaginary * imaginary)
    value
  }
  override def toString = real + " + " + imaginary + "i"
}
object UsingImplicitConversion {
  def main(args: Array[String]): Unit = {
    val obj = new Complex(5.0, 6.0)
    val x = new Complex(4.0, 3.0)
    val y = new Complex(8.0, -7.0)

    println(x) // prints 4.0 + 3.0i
    println(x plus y) // prints 12.0 + -4.0i
    println(x minus y) // -4.0 + 10.0i
    println(obj.unary) // prints 7.810249675906654
  }
}

在前面的代码中,我们定义了一些方法来执行复数(即实部和虚部)的加法、减法和一元操作。在main()方法中,我们用实数调用了这些方法。输出如下:

4.0 + 3.0i
12.0 + -4.0i
-4.0 + 10.0i
7.810249675906654

但是,如果我们想要支持将一个普通数字添加到一个复数,我们该怎么做呢?我们当然可以重载我们的plus方法以接受一个Double参数,这样它就可以支持以下表达式。

val sum = myComplexNumber plus 6.5

为此,我们可以使用 Scala 隐式转换。它支持数学运算的实数和复数的隐式转换。因此,我们可以将该元组作为隐式转换的参数,并将其转换为Complex,参见以下内容:

implicit def Tuple2Complex(value: Tuple2[Double, Double]) = new Complex(value._1, value._2)

或者,对于双精度到复数的转换如下:

implicit def Double2Complex(value : Double) = new Complex(value,0.0) 

为了利用这种转换,我们需要导入以下内容:

import ComplexImplicits._ // for complex numbers
import scala.language.implicitConversions // in general

现在,我们可以在 Scala REPL/IDE 上执行类似这样的操作:

val z = 4 plus y
println(z) // prints 12.0 + -7.0i
val p = (1.0, 1.0) plus z
println(p) // prints 13.0 + -6.0i 

你将得到以下输出:

12.0 + -7.0i
13.0 + -6.0i

这个例子的完整源代码可以如下所示:

package com.chapter4.CollectionAPI
import ComplexImplicits._
import scala.language.implicitConversions
class Complex(val real: Double, val imaginary: Double) {
  def plus(that: Complex) = new Complex(this.real + that.real, this.imaginary + that.imaginary)
  def plus(n: Double) = new Complex(this.real + n, this.imaginary)
  def minus(that: Complex) = new Complex(this.real - that.real, this.imaginary - that.imaginary)
  def unary(): Double = {
    val value = Math.sqrt(real * real + imaginary * imaginary)
    value
  }
  override def toString = real + " + " + imaginary + "i"
}
object ComplexImplicits {
  implicit def Double2Complex(value: Double) = new Complex(value, 0.0)
  implicit def Tuple2Complex(value: Tuple2[Double, Double]) = new Complex(value._1, value._2)
}
object UsingImplicitConversion {
  def main(args: Array[String]): Unit = {
    val obj = new Complex(5.0, 6.0)
    val x = new Complex(4.0, 3.0)
    val y = new Complex(8.0, -7.0)
    println(x) // prints 4.0 + 3.0i
    println(x plus y) // prints 12.0 + -4.0i
    println(x minus y) // -4.0 + 10.0i
    println(obj.unary) // prints 7.810249675906654
    val z = 4 plus y
    println(z) // prints 12.0 + -7.0i
    val p = (1.0, 1.0) plus z
    println(p) // prints 13.0 + -6.0i
  }
} 

我们现在或多或少地涵盖了 Scala 集合 API。还有其他特性,但是页面限制阻止我们覆盖它们。对于仍然想要探索的感兴趣的读者,可以参考这个页面www.scala-lang.org/docu/files/collections-api/collections.html

总结

在本章中,我们看到了许多使用 Scala 集合 API 的示例。它非常强大、灵活,并且具有许多与之相关的操作。这种广泛的操作范围将使您在处理任何类型的数据时更加轻松。我们介绍了 Scala 集合 API 及其不同类型和层次结构。我们还展示了 Scala 集合 API 的功能以及如何使用它来适应不同类型的数据并解决各种不同的问题。总之,您了解了类型和层次结构、性能特征、Java 互操作性以及隐式的使用。因此,这或多或少是学习 Scala 的结束。然而,您将在接下来的章节中继续学习更高级的主题和操作。

在下一章中,我们将探讨数据分析和大数据,以了解大数据提供的挑战以及它们是如何通过分布式计算和函数式编程所提出的方法来解决的。您还将了解 MapReduce、Apache Hadoop,最后还会了解 Apache Spark,并看到它们是如何采用这种方法和这些技术的。

第五章:解决大数据问题- Spark 加入派对

对正确问题的近似答案比对近似问题的精确答案更有价值。

  • 约翰·图基

在本章中,您将了解数据分析和大数据;我们将看到大数据提供的挑战以及如何应对。您将了解分布式计算和函数式编程建议的方法;我们介绍 Google 的 MapReduce,Apache Hadoop,最后是 Apache Spark,并看到它们如何采用这种方法和这些技术。

简而言之,本章将涵盖以下主题:

  • 数据分析简介

  • 大数据简介

  • 使用 Apache Hadoop 进行分布式计算

  • Apache Spark 来了

数据分析简介

数据分析是在检查数据时应用定性和定量技术的过程,目的是提供有价值的见解。使用各种技术和概念,数据分析可以提供探索数据探索性数据分析EDA)以及对数据验证性数据分析CDA)的结论的手段。EDA 和 CDA 是数据分析的基本概念,重要的是要理解两者之间的区别。

EDA 涉及用于探索数据的方法、工具和技术,目的是在数据中找到模式和数据各个元素之间的关系。CDA 涉及用于根据假设和统计技术或对数据的简单观察提供关于特定问题的见解或结论的方法、工具和技术。

一个快速的例子来理解这些想法是杂货店,他们要求您提供改善销售和顾客满意度以及保持运营成本低的方法。

以下是一个有各种产品过道的杂货店:

假设杂货店的所有销售数据都存储在某个数据库中,并且您可以访问过去 3 个月的数据。通常,企业会将数据存储多年,因为您需要足够长时间的数据来建立任何假设或观察任何模式。在这个例子中,我们的目标是根据顾客购买产品的方式更好地放置各种过道中的产品。一个假设是,顾客经常购买产品,这些产品既在视线范围内,又彼此靠近。例如,如果牛奶在商店的一个角落,酸奶在商店的另一个角落,一些顾客可能会选择牛奶或酸奶中的任何一种,然后离开商店,导致业务损失。更严重的影响可能导致顾客选择另一家产品摆放更好的商店,因为他们觉得在这家商店很难找到东西。一旦这种感觉产生,它也会传播给朋友和家人,最终导致不良的社交影响。这种现象在现实世界中并不罕见,导致一些企业成功,而其他企业失败,尽管它们在产品和价格上似乎非常相似。

有许多方法可以解决这个问题,从客户调查到专业统计学家再到机器学习科学家。我们的方法是仅从销售交易中了解我们可以得到什么。

以下是交易可能看起来像的一个例子:

以下是您可以作为 EDA 的一部分遵循的步骤:

  1. 计算每天购买的产品平均数量=一天内所有售出的产品总数/当天的收据总数

  2. 重复上一步骤,为过去 1 周、1 个月和 1 个季度。

  3. 尝试了解周末和工作日之间以及一天中的时间(早上、中午和晚上)是否有差异

  4. 对于每种产品,创建一个所有其他产品的列表,以查看通常一起购买哪些产品(同一张收据)

  5. 重复上一步骤,为 1 天、1 周、1 个月和 1 个季度。

  6. 尝试通过交易数量(按降序排列)确定哪些产品应该靠近放置。

完成了前面的 6 个步骤后,我们可以尝试得出一些 CDA 的结论。

假设这是我们得到的输出:

商品 星期几 数量
牛奶 星期日 1244
面包 星期一 245
牛奶 星期一 190

在这种情况下,我们可以说牛奶周末购买更多,因此最好在周末增加牛奶产品的数量和种类。看一下下表:

商品 1 商品 2 数量
牛奶 鸡蛋 360
面包 奶酪 335
洋葱 西红柿 310

在这种情况下,我们可以说牛奶鸡蛋在一次购买中被更多顾客购买,接着是面包奶酪。因此,我们建议商店重新调整通道和货架,将牛奶鸡蛋靠近彼此。

我们得出的两个结论是:

  • 牛奶周末购买更多,因此最好在周末增加牛奶产品的数量和种类。

  • 牛奶鸡蛋在一次购买中被更多顾客购买,接着是面包奶酪。因此,我们建议商店重新调整通道和货架,将牛奶鸡蛋靠近彼此。

结论通常会在一段时间内进行跟踪以评估收益。如果在采纳前述两项建议 6 个月后销售额没有显着影响,那么我们只是投资于无法给您良好投资回报率(ROI)的建议。

同样,您也可以进行一些关于利润率和定价优化的分析。这就是为什么您通常会看到单个商品的成本高于购买多个相同商品的平均成本。购买一瓶洗发水 7 美元,或者两瓶洗发水 12 美元。

考虑一下您可以探索和为杂货店推荐的其他方面。例如,您能否根据这些产品对任何特定产品都没有亲和力这一事实,猜测哪些产品应该靠近结账柜台--口香糖、杂志等。

数据分析举措支持各种各样的业务用途。例如,银行和信用卡公司分析取款和消费模式以防止欺诈和身份盗用。广告公司分析网站流量以确定有高转化可能性的潜在客户。百货商店分析客户数据,以确定更好的折扣是否有助于提高销售额。手机运营商可以制定定价策略。有线电视公司不断寻找可能会流失客户的客户,除非给予一些优惠或促销价格来留住他们的客户。医院和制药公司分析数据,以提出更好的产品,并检测处方药的问题或衡量处方药的表现。

在数据分析过程中

数据分析应用不仅涉及数据分析。在计划任何分析之前,还需要投入时间和精力来收集、整合和准备数据,检查数据的质量,然后开发、测试和修订分析方法。一旦数据被认为准备就绪,数据分析师和科学家可以使用统计方法(如 SAS)或使用 Spark ML 的机器学习模型来探索和分析数据。数据本身由数据工程团队准备,数据质量团队检查收集的数据。数据治理也成为一个因素,以确保数据的正确收集和保护。另一个不常为人所知的角色是数据监护人,他专门研究数据到字节的理解,确切地了解数据的来源,所有发生的转换,以及业务真正需要的数据列或字段。

企业中的各种实体可能以不同的方式处理地址,例如123 N Main St123 North Main Street。但是,我们的分析取决于获取正确的地址字段;否则上述两个地址将被视为不同,我们的分析将无法达到相同的准确性。

分析过程始于根据分析师可能需要的数据仓库中收集数据,收集组织中各种类型的数据(销售、营销、员工、工资单、人力资源等)。数据监护人和治理团队在这里非常重要,以确保收集正确的数据,并且任何被视为机密或私人的信息都不会被意外地导出,即使最终用户都是员工。

社会安全号码或完整地址可能不适合包含在分析中,因为这可能会给组织带来很多问题。

必须建立数据质量流程,以确保收集和工程化的数据是正确的,并且能够满足数据科学家的需求。在这个阶段,主要目标是发现和修复可能影响分析需求准确性的数据质量问题。常见的技术包括对数据进行概要分析和清洗,以确保数据集中的信息是一致的,并且移除任何错误和重复记录。

来自不同来源系统的数据可能需要使用各种数据工程技术进行合并、转换和规范化,例如分布式计算或 MapReduce 编程、流处理或 SQL 查询,然后存储在 Amazon S3、Hadoop 集群、NAS 或 SAN 存储设备上,或者传统的数据仓库,如 Teradata。数据准备或工程工作涉及操纵和组织数据的技术,以满足计划中的分析需求。

一旦数据准备并经过质量检查,并且可供数据科学家或分析师使用,实际的分析工作就开始了。数据科学家现在可以使用预测建模工具和语言,如 SAS、Python、R、Scala、Spark、H2O 等来构建分析模型。模型最初针对部分数据集进行运行,以测试其在训练阶段的准确性。在任何分析项目中,训练阶段的多次迭代是常见且预期的。在模型层面进行调整后,或者有时需要到数据监护人那里获取或修复一些被收集或准备的数据,模型的输出往往会变得越来越好。最终,当进一步调整不会明显改变结果时,我们可以认为模型已经准备好投入生产使用。

现在,模型可以针对完整数据集运行,并根据我们训练模型的方式生成结果或成果。在构建分析时所做的选择,无论是统计还是机器学习,都直接影响模型的质量和目的。你不能仅仅通过杂货销售来判断亚洲人是否比墨西哥人购买更多的牛奶,因为这需要来自人口统计数据的额外元素。同样,如果我们的分析侧重于客户体验(产品退货或换货),那么它所基于的技术和模型与我们试图专注于收入或向客户推销产品时是不同的。

您将在后面的章节中看到各种机器学习技术。

因此,分析应用可以利用多种学科、团队和技能集来实现。分析应用可以用于生成报告,甚至自动触发业务行动。例如,你可以简单地创建每天早上 8 点给所有经理发送的每日销售报告。但是,你也可以与业务流程管理应用程序或一些定制的股票交易应用程序集成,以采取行动,如在股票市场上进行买卖或警报活动。你还可以考虑接收新闻文章或社交媒体信息,以进一步影响要做出的决策。

数据可视化是数据分析的重要组成部分,当你看着大量的指标和计算时,很难理解数字。相反,人们越来越依赖商业智能工具,如 Tableau、QlikView 等,来探索和分析数据。当然,像显示全国所有优步车辆或显示纽约市供水的热力图这样的大规模可视化需要构建更多定制应用程序或专门的工具。

在各行各业的许多不同规模的组织中,管理和分析数据一直是一个挑战。企业一直在努力寻找一个实用的方法来获取有关他们的客户、产品和服务的信息。当公司只有少数客户购买少量商品时,这并不困难。随着时间的推移,市场上的公司开始增长。事情变得更加复杂。现在,我们有品牌信息和社交媒体。我们有在互联网上销售和购买的商品。我们需要提出不同的解决方案。网站开发、组织、定价、社交网络和细分;我们处理的数据有很多不同的类型,这使得处理、管理、组织和尝试从数据中获得一些见解变得更加复杂。

大数据介绍

在前面的部分中可以看到,数据分析包括探索和分析数据的技术、工具和方法,以产生业务的可量化结果。结果可能是简单的选择商店外观的颜色,也可能是更复杂的客户行为预测。随着企业的发展,越来越多种类的分析出现在画面中。在 20 世纪 80 年代或 90 年代,我们所能得到的只是 SQL 数据仓库中可用的数据;如今,许多外部因素都在影响企业运营的方式。

Twitter、Facebook、亚马逊、Verizon、Macy's 和 Whole Foods 都是利用数据分析来经营业务并基于数据做出许多决策的公司。想想他们可能收集的数据类型、可能收集的数据量,以及他们可能如何使用这些数据。

让我们看一下之前提到的杂货店的例子。如果商店开始扩大业务,建立数百家店铺,那么销售交易将不可避免地需要以比单一店铺多数百倍的规模进行收集和存储。但是,现在没有任何企业是独立运作的。从当地新闻、推特、yelp 评论、客户投诉、调查活动、其他商店的竞争、人口构成的变化,以及当地经济等方面都有大量信息。所有这些额外的数据都可以帮助更好地理解客户行为和收入模型。

例如,如果我们发现关于商店停车设施的负面情绪在增加,那么我们可以分析这一点,并采取纠正措施,比如提供验证停车或与城市公共交通部门协商,提供更频繁的火车或公交车,以便更好地到达。

这种不断增加的数量和多样性的数据,虽然提供了更好的分析,但也给企业 IT 组织存储、处理和分析所有数据带来了挑战。事实上,看到 TB 级别的数据并不罕见。

每天,我们创造超过 2 百万亿字节的数据(2 艾字节),据估计,超过 90%的数据仅在过去几年内生成。

1 KB = 1024 字节

1 MB = 1024 KB

1 GB = 1024 MB

1 TB = 1024 GB ~ 1,000,000 MB

1 PB = 1024 TB ~ 1,000,000 GB ~ 1,000,000,000 MB

1 EB = 1024 PB ~ 1,000,000 TB ~ 1,000,000,000 GB ~ 1,000,000,000,000 MB

自 20 世纪 90 年代以来的大量数据以及理解和理解数据的需求,催生了“大数据”这个术语。

大数据这个跨越计算机科学和统计/计量经济学的术语,可能起源于 20 世纪 90 年代中期 Silicon Graphics 的午餐桌谈话,John Mashey 在其中扮演了重要角色。

2001 年,当时是咨询公司 Meta Group Inc(后来被 Gartner 收购)的分析师的 Doug Laney 提出了 3V(多样性、速度和数量)的概念。现在,我们提到 4 个 V,而不是 3 个 V,增加了数据的真实性到 3 个 V。

大数据的 4 个 V

以下是用于描述大数据属性的 4 个 V。

数据的多样性

数据可以来自气象传感器、汽车传感器、人口普查数据、Facebook 更新、推文、交易、销售和营销。数据格式既结构化又非结构化。数据类型也可以不同;二进制、文本、JSON 和 XML。

数据的速度

数据可以来自数据仓库、批处理文件存档、近实时更新,或者刚刚预订的 Uber 车程的即时实时更新。

数据量

数据可以收集和存储一小时、一天、一个月、一年或 10 年。对于许多公司来说,数据的大小正在增长到数百 TB。

数据的真实性

数据可以分析出可操作的见解,但由于来自各种数据源的大量数据被分析,确保正确性和准确性证明是非常困难的。

以下是大数据的 4 个 V:

为了理解所有数据并将数据分析应用于大数据,我们需要扩展数据分析的概念,以在更大的规模上处理大数据的 4 个 V。这不仅改变了分析数据所使用的工具、技术和方法,还改变了我们处理问题的方式。如果在 1999 年业务中使用 SQL 数据库来处理数据,现在为了处理同一业务的数据,我们将需要一个可扩展和适应大数据空间细微差别的分布式 SQL 数据库。

大数据分析应用通常包括来自内部系统和外部来源的数据,例如天气数据或第三方信息服务提供商编制的有关消费者的人口统计数据。此外,流式分析应用在大数据环境中变得常见,因为用户希望对通过 Spark 的 Spark 流模块或其他开源流处理引擎(如 Flink 和 Storm)输入 Hadoop 系统的数据进行实时分析。

早期的大数据系统大多部署在大型组织的内部,这些组织正在收集、组织和分析大量数据。但云平台供应商,如亚马逊网络服务(AWS)和微软,已经让在云中设置和管理 Hadoop 集群变得更加容易,Hadoop 供应商,如 Cloudera 和 Hortonworks,也支持它们在 AWS 和微软 Azure 云上的大数据框架分发。用户现在可以在云中启动集群,运行所需的时间,然后将其下线,使用基于使用量的定价,无需持续的软件许可证。

在大数据分析项目中可能会遇到的潜在问题包括缺乏内部分析技能以及雇佣经验丰富的数据科学家和数据工程师的高成本来填补这些空缺。

通常涉及的数据量及其多样性可能会导致数据管理问题,包括数据质量、一致性和治理;此外,在大数据架构中使用不同平台和数据存储可能会导致数据孤立。此外,将 Hadoop、Spark 和其他大数据工具集成到满足组织大数据分析需求的统一架构中对许多 IT 和分析团队来说是一个具有挑战性的任务,他们必须确定合适的技术组合,然后将各个部分组合在一起。

使用 Apache Hadoop 进行分布式计算

我们的世界充满了各种设备,从智能冰箱、智能手表、手机、平板电脑、笔记本电脑、机场的信息亭、向您提供现金的 ATM 等等。我们能够做一些我们几年前无法想象的事情。Instagram、Snapchat、Gmail、Facebook、Twitter 和 Pinterest 是我们现在如此习惯的一些应用程序;很难想象一天没有访问这些应用程序。

随着云计算的出现,我们能够通过几次点击在 AWS、Azure(微软)或 Google Cloud 等平台上启动数百甚至数千台机器,并利用巨大的资源实现各种业务目标。

云计算为我们引入了 IaaS、PaaS 和 SaaS 的概念,使我们能够构建和运营满足各种用例和业务需求的可扩展基础设施。

IaaS(基础设施即服务)-提供可靠的托管硬件,无需数据中心、电源线、空调等。

PaaS(平台即服务)-在 IaaS 之上,提供 Windows、Linux、数据库等托管平台。

SaaS(软件即服务)-在 SaaS 之上,为每个人提供 SalesForce、Kayak.com等托管服务。

幕后是高度可扩展的分布式计算世界,这使得存储和处理 PB(百万亿字节)数据成为可能。

1 艾克萨字节=1024 百万亿字节(5000 万部蓝光电影)

1 PB=1024 TB(50,000 部蓝光电影)

1 TB=1024 GB(50 部蓝光电影)

电影蓝光光盘的平均大小约为 20 GB

现在,分布式计算范式并不是一个真正全新的话题,几十年来一直在研究机构以及一些商业产品公司主要进行研究和追求。大规模并行处理(MPP)是几十年前在海洋学、地震监测和太空探索等领域使用的一种范式。很多公司如 Teradata 也实施了 MPP 平台并提供商业产品和应用。最终,谷歌、亚马逊等科技公司推动了可扩展分布式计算这一小众领域的新阶段,最终导致了伯克利大学创建了 Apache Spark。

谷歌发表了关于Map Reduce(MR)以及Google File System(GFS)的论文,将分布式计算原理带给了每个人。当然,应该给予 Doug Cutting 应有的赞誉,他通过实施谷歌白皮书中的概念并向世界介绍 Hadoop,使这一切成为可能。

Apache Hadoop 框架是用 Java 编写的开源软件框架。框架提供的两个主要领域是存储和处理。对于存储,Apache Hadoop 框架使用基于 2003 年 10 月发布的 Google 文件系统论文的 Hadoop 分布式文件系统(HDFS)。对于处理或计算,该框架依赖于基于 2004 年 12 月发布的 Google 关于 MR 的论文的 MapReduce。

MapReduce 框架从 V1(基于作业跟踪器和任务跟踪器)发展到 V2(基于 YARN)。

Hadoop 分布式文件系统(HDFS)

HDFS 是用 Java 实现的软件文件系统,位于本地文件系统之上。HDFS 背后的主要概念是将文件分成块(通常为 128 MB),而不是将整个文件处理。这允许许多功能,例如分布、复制、故障恢复,更重要的是使用多台机器对块进行分布式处理。

块大小可以是 64 MB、128 MB、256 MB 或 512 MB,适合任何目的。对于具有 128 MB 块的 1 GB 文件,将有 1024 MB / 128 MB = 8 个块。如果考虑复制因子为 3,这将使其成为 24 个块。

HDFS 提供了具有容错和故障恢复功能的分布式存储系统。HDFS 有两个主要组件:NameNode 和 DataNode。NameNode 包含文件系统所有内容的所有元数据。DataNode 连接到 NameNode,并依赖于 NameNode 提供有关文件系统内容的所有元数据信息。如果 NameNode 不知道任何信息,DataNode 将无法将其提供给任何想要读取/写入 HDFS 的客户端。

以下是 HDFS 架构:

NameNode 和 DataNode 都是 JVM 进程,因此任何支持 Java 的机器都可以运行 NameNode 或 DataNode 进程。只有一个 NameNode(如果计算 HA 部署,则还会有第二个 NameNode),但有 100 个或 1000 个 DataNode。

不建议拥有 1000 个 DataNode,因为来自所有 DataNode 的所有操作都会倾向于在具有大量数据密集型应用程序的真实生产环境中压倒 NameNode。

在集群中存在一个 NameNode 极大地简化了系统的架构。NameNode 是 HDFS 元数据的仲裁者和存储库,任何想要读取/写入数据的客户端都首先与 NameNode 联系以获取元数据信息。数据永远不会直接流经 NameNode,这允许 1 个 NameNode 管理 100 个 DataNode(PB 级数据)。

HDFS 支持传统的分层文件组织,具有类似于大多数其他文件系统的目录和文件。您可以创建、移动和删除文件和目录。NameNode 维护文件系统命名空间,并记录文件系统的所有更改和状态。应用程序可以指定 HDFS 应该维护的文件副本数量,这些信息也由 NameNode 存储。

HDFS 旨在以分布式方式可靠存储非常大的文件,跨大型数据节点集群中的机器进行存储。为了处理复制、容错以及分布式计算,HDFS 将每个文件存储为一系列块。

NameNode 对块的复制做出所有决定。这主要取决于集群中每个 DataNode 定期在心跳间隔处接收的块报告。块报告包含 DataNode 上所有块的列表,然后 NameNode 将其存储在其元数据存储库中。

NameNode 将所有元数据存储在内存中,并为从/写入 HDFS 的客户端提供所有请求。但是,由于这是维护有关 HDFS 的所有元数据的主节点,因此维护一致且可靠的元数据信息至关重要。如果丢失此信息,则无法访问 HDFS 上的内容。

为此,HDFS NameNode 使用称为 EditLog 的事务日志,该日志持久记录文件系统元数据发生的每个更改。创建新文件会更新 EditLog,移动文件或重命名文件,或删除文件也会如此。整个文件系统命名空间,包括块到文件的映射和文件系统属性,都存储在一个名为FsImage的文件中。NameNode也将所有内容保存在内存中。当 NameNode 启动时,它加载 EditLog 和FsImage,并初始化自身以设置 HDFS。

然而,DataNodes 对于 HDFS 一无所知,完全依赖于存储的数据块。DataNodes 完全依赖于 NameNode 执行任何操作。即使客户端想要连接以读取文件或写入文件,也是 NameNode 告诉客户端要连接到哪里。

HDFS 高可用性

HDFS 是一个主从集群,其中 NameNode 是主节点,而 DataNodes 是从节点,如果不是数百,就是数千个,由主节点管理。这在集群中引入了单点故障SPOF),因为如果主 NameNode 因某种原因而崩溃,整个集群将无法使用。HDFS 1.0 支持另一个称为Secondary NameNode的附加主节点,以帮助恢复集群。这是通过维护文件系统的所有元数据的副本来完成的,绝不是一个需要手动干预和维护工作的高可用系统。HDFS 2.0 通过添加对完整高可用性HA)的支持将其提升到下一个级别。

HA 通过将两个 NameNode 设置为主备模式来工作,其中一个 NameNode 是活动的,另一个是被动的。当主 NameNode 发生故障时,被动 NameNode 将接管主节点的角色。

以下图表显示了主备 NameNode 对的部署方式:

HDFS 联邦

HDFS 联邦是使用多个名称节点来分布文件系统命名空间的一种方式。与最初的 HDFS 版本不同,最初的 HDFS 版本仅使用单个 NameNode 管理整个集群,随着集群规模的增长,这种方式并不那么可扩展,HDFS 联邦可以支持规模显著更大的集群,并且可以使用多个联邦名称节点水平扩展 NameNode 或名称服务。请看下面的图表:

HDFS 快照

Hadoop 2.0 还增加了一个新功能:对存储在数据节点上的文件系统(数据块)进行快照(只读副本和写时复制)。使用快照,可以使用 NameNode 的数据块元数据无缝地对目录进行快照。快照创建是瞬时的,不需要干预其他常规 HDFS 操作。

以下是快照在特定目录上的工作原理的示例:

HDFS 读取

客户端连接到 NameNode,并使用文件名询问文件。NameNode 查找文件的块位置并将其返回给客户端。然后客户端可以连接到 DataNodes 并读取所需的块。NameNode 不参与数据传输。

以下是客户端的读取请求流程。首先,客户端获取位置,然后从 DataNodes 拉取块。如果 DataNode 在中途失败,客户端将从另一个 DataNode 获取块的副本。

HDFS 写入

客户端连接到 NameNode,并要求 NameNode 让其写入 HDFS。NameNode 查找信息并计划块、用于存储块的 Data Nodes 以及要使用的复制策略。NameNode 不处理任何数据,只告诉客户端在哪里写入。一旦第一个 DataNode 接收到块,根据复制策略,NameNode 告诉第一个 DataNode 在哪里复制。因此,从客户端接收的 DataNode 将块发送到第二个 DataNode(应该写入块的副本所在的地方),然后第二个 DataNode 将其发送到第三个 DataNode(如果复制因子为 3)。

以下是来自客户端的写入请求的流程。首先,客户端获取位置,然后写入第一个 DataNode。接收块的 DataNode 将块复制到应该保存块副本的 DataNodes。这对从客户端写入的所有块都是如此。如果一个 DataNode 在中间失败,那么块将根据 NameNode 确定的另一个 DataNode 进行复制。

到目前为止,我们已经看到 HDFS 使用块、NameNode 和 DataNodes 提供了分布式文件系统。一旦数据存储在 PB 规模,实际处理数据以满足业务的各种用例也变得非常重要。

MapReduce 框架是在 Hadoop 框架中创建的,用于执行分布式计算。我们将在下一节中进一步讨论这个问题。

MapReduce 框架

MapReduce (MR)框架使您能够编写分布式应用程序,以可靠和容错的方式处理来自文件系统(如 HDFS)的大量数据。当您想要使用 MapReduce 框架处理数据时,它通过创建一个作业来运行框架以执行所需的任务。

MapReduce 作业通常通过在多个工作节点上运行Mapper任务并行地分割输入数据来工作。此时,无论是在 HDFS 级别发生的任何故障,还是 Mapper 任务的故障,都会自动处理以实现容错。一旦 Mapper 完成,结果就会通过网络复制到运行Reducer任务的其他机器上。

理解这个概念的一个简单方法是想象你和你的朋友想要把一堆水果分成盒子。为此,你想要指派每个人的任务是去处理一个原始的水果篮子(全部混在一起),并将水果分成不同的盒子。然后每个人都用同样的方法处理这个水果篮子。

最后,你最终会得到很多盒子水果,都是来自你的朋友。然后,你可以指派一个小组将相同种类的水果放在一起放进一个盒子里,称重,封箱以便运输。

以下描述了将水果篮子拿来按水果类型分类的想法:

MapReduce 框架由一个资源管理器和多个节点管理器组成(通常节点管理器与 HDFS 的数据节点共存)。当应用程序想要运行时,客户端启动应用程序主管,然后与资源管理器协商以获取容器形式的集群资源。

容器表示分配给单个节点用于运行任务和进程的 CPU(核心)和内存。容器由节点管理器监督,并由资源管理器调度。

容器的示例:

1 核+4GB RAM

2 核+6GB RAM

4 核+20GB RAM

一些容器被分配为 Mappers,其他容器被分配为 Reducers;所有这些都由应用程序主管与资源管理器协调。这个框架被称为Yet Another Resource Negotiator (YARN)

以下是 YARN 的描述:

展示 MapReduce 框架工作的一个经典例子是单词计数示例。以下是处理输入数据的各个阶段,首先是将输入分割到多个工作节点,最后生成单词计数的输出:

尽管 MapReduce 框架在全球范围内非常成功,并且已被大多数公司采用,但它确实遇到了问题,主要是因为它处理数据的方式。已经出现了几种技术来尝试使 MapReduce 更易于使用,例如 Hive 和 Pig,但复杂性仍然存在。

Hadoop MapReduce 有一些限制,例如:

  • 由于基于磁盘的处理而导致性能瓶颈

  • 批处理无法满足所有需求

  • 编程可能冗长复杂

  • 任务调度速度慢,因为资源的重复利用不多

  • 没有很好的实时事件处理方式

  • 机器学习太慢,因为通常 ML 涉及迭代处理,而 MR 对此太慢

Hive 是 Facebook 创建的 MR 的类似 SQL 接口。Pig 是 Yahoo 创建的 MR 的脚本接口。此外,还有一些增强功能,如 Tez(Hortonworks)和 LLAP(Hive2.x),它们利用内存优化来规避 MapReduce 的限制。

在下一节中,我们将看一下 Apache Spark,它已经解决了 Hadoop 技术的一些限制。

Apache Spark 来了

Apache Spark 是一个统一的分布式计算引擎,可跨不同的工作负载和平台进行连接。Spark 可以连接到不同的平台,并使用各种范例处理不同的数据工作负载,如 Spark 流式处理、Spark ML、Spark SQL 和 Spark GraphX。

Apache Spark 是一个快速的内存数据处理引擎,具有优雅和富有表现力的开发 API,允许数据工作者高效执行流式机器学习或 SQL 工作负载,需要快速交互式访问数据集。Apache Spark 由 Spark 核心和一组库组成。核心是分布式执行引擎,Java、Scala 和 Python API 提供了分布式应用程序开发的平台。在核心之上构建的其他库允许流式、SQL、图处理和机器学习工作负载。例如,Spark ML 专为数据科学而设计,其抽象使数据科学更容易。

Spark 提供实时流式处理、查询、机器学习和图处理。在 Apache Spark 之前,我们必须使用不同的技术来处理不同类型的工作负载,一个用于批量分析,一个用于交互式查询,一个用于实时流处理,另一个用于机器学习算法。然而,Apache Spark 可以只使用 Apache Spark 来完成所有这些工作,而不是使用不一定总是集成的多种技术。

使用 Apache Spark,可以处理各种类型的工作负载,Spark 还支持 Scala、Java、R 和 Python 作为编写客户端程序的手段。

Apache Spark 是一个开源的分布式计算引擎,相对于 MapReduce 范式具有关键优势:

  • 尽可能使用内存处理

  • 通用引擎用于批处理、实时工作负载

  • 与 YARN 和 Mesos 兼容

  • 与 HBase、Cassandra、MongoDB、HDFS、Amazon S3 和其他文件系统和数据源良好集成

Spark 是 2009 年在伯克利创建的,是构建 Mesos 的项目的结果,Mesos 是一个支持不同类型的集群计算系统的集群管理框架。看一下下表:

版本 发布日期 里程碑
0.5 2012-10-07 非生产使用的第一个可用版本
0.6 2013-02-07 各种更改的点版本发布
0.7 2013-07-16 各种更改的点版本发布
0.8 2013-12-19 各种更改的点版本发布
0.9 2014-07-23 各种更改的点版本发布
1.0 2014-08-05 第一个生产就绪,向后兼容的发布。Spark Batch,Streaming,Shark,MLLib,GraphX
1.1 2014-11-26 各种变更的点发布
1.2 2015-04-17 结构化数据,SchemaRDD(后来演变为 DataFrames)
1.3 2015-04-17 提供统一的 API 来从结构化和半结构化源读取的 API
1.4 2015-07-15 SparkR,DataFrame API,Tungsten 改进
1.5 2015-11-09 各种变更的点发布
1.6 2016-11-07 引入数据集 DSL
2.0 2016-11-14 DataFrames 和 Datasets API 作为机器学习、结构化流处理、SparkR 改进的基本层。
2.1 2017-05-02 事件时间水印,机器学习,GraphX 改进

2.2 已于 2017-07-11 发布,其中有几项改进,特别是结构化流处理现在是 GA。

Spark 是一个分布式计算平台,具有几个特点:

  • 通过简单的 API 在多个节点上透明地处理数据

  • 具有弹性处理故障

  • 根据需要将数据溢出到磁盘,尽管主要使用内存

  • 支持 Java,Scala,Python,R 和 SQL API

  • 相同的 Spark 代码可以独立运行,在 Hadoop YARN,Mesos 和云中

Scala 的特性,如隐式,高阶函数,结构化类型等,使我们能够轻松构建 DSL,并将其与语言集成。

Apache Spark 不提供存储层,并依赖于 HDFS 或 Amazon S3 等。因此,即使将 Apache Hadoop 技术替换为 Apache Spark,仍然需要 HDFS 来提供可靠的存储层。

Apache Kudu 提供了 HDFS 的替代方案,Apache Spark 和 Kudu 存储层之间已经有集成,进一步解耦了 Apache Spark 和 Hadoop 生态系统。

Hadoop 和 Apache Spark 都是流行的大数据框架,但它们实际上并不提供相同的功能。虽然 Hadoop 提供了分布式存储和 MapReduce 分布式计算框架,但 Spark 则是一个在其他技术提供的分布式数据存储上运行的数据处理框架。

Spark 通常比 MapReduce 快得多,因为它处理数据的方式不同。MapReduce 使用磁盘操作来操作拆分,而 Spark 比 MapReduce 更有效地处理数据集,Apache Spark 性能改进的主要原因是高效的堆外内存处理,而不仅仅依赖于基于磁盘的计算。

如果您的数据操作和报告需求大部分是静态的,并且可以使用批处理来满足您的需求,那么 MapReduce 的处理方式可能足够了,但是如果您需要对流数据进行分析,或者您的处理需求需要多阶段处理逻辑,那么您可能会选择 Spark。

Spark 堆栈中有三层。底层是集群管理器,可以是独立的,YARN 或 Mesos。

使用本地模式,您不需要集群管理器来处理。

在集群管理器之上的中间层是 Spark 核心层,它提供了执行任务调度和与存储交互的所有基础 API。

顶部是在 Spark 核心之上运行的模块,如 Spark SQL 提供交互式查询,Spark streaming 用于实时分析,Spark ML 用于机器学习,Spark GraphX 用于图处理。

这三层分别是:

如前图所示,各种库(如 Spark SQL,Spark streaming,Spark ML 和 GraphX)都位于 Spark 核心之上,而 Spark 核心位于中间层。底层显示了各种集群管理器选项。

现在让我们简要地看一下每个组件:

Spark 核心

Spark 核心是构建在其上的所有其他功能的基础通用执行引擎。Spark 核心包含运行作业所需的基本 Spark 功能,并且其他组件需要这些功能。它提供了内存计算和引用外部存储系统中的数据集,最重要的是弹性分布式数据集RDD)。

此外,Spark 核心包含访问各种文件系统(如 HDFS、Amazon S3、HBase、Cassandra、关系数据库等)的逻辑。Spark 核心还提供了支持网络、安全、调度和数据洗牌的基本功能,以构建一个高可伸缩、容错的分布式计算平台。

我们在第六章 开始使用 Spark - REPL和 RDDs 以及第七章 特殊 RDD 操作中详细介绍了 Spark 核心。

在许多用例中,构建在 RDD 之上并由 Spark SQL 引入的 DataFrame 和数据集现在正在成为 RDD 的标准。就处理完全非结构化数据而言,RDD 仍然更灵活,但在未来,数据集 API 可能最终成为核心 API。

Spark SQL

Spark SQL 是 Spark 核心之上的一个组件,引入了一个名为SchemaRDD的新数据抽象,它提供对结构化和半结构化数据的支持。Spark SQL 提供了用于操作大型分布式结构化数据集的函数,使用 Spark 和 Hive QL 支持的 SQL 子集。Spark SQL 通过 DataFrame 和数据集简化了对结构化数据的处理,作为 Tungsten 计划的一部分,它在更高的性能水平上运行。Spark SQL 还支持从各种结构化格式和数据源(文件、parquet、orc、关系数据库、Hive、HDFS、S3 等)读取和写入数据。Spark SQL 提供了一个名为Catalyst的查询优化框架,以优化所有操作以提高速度(与 RDD 相比,Spark SQL 快几倍)。Spark SQL 还包括一个 Thrift 服务器,可以被外部系统使用,通过经典的 JDBC 和 ODBC 协议通过 Spark SQL 查询数据。

我们在第八章 引入一点结构 - Spark SQL中详细介绍了 Spark SQL。

Spark 流处理

Spark 流处理利用 Spark 核心的快速调度能力,通过从各种来源(如 HDFS、Kafka、Flume、Twitter、ZeroMQ、Kinesis 等)摄取实时流数据来执行流式分析。Spark 流处理使用数据的微批处理来处理数据,并且使用称为 DStreams 的概念,Spark 流处理可以在 RDD 上操作,将转换和操作应用于 Spark 核心 API 中的常规 RDD。Spark 流处理操作可以使用各种技术自动恢复失败。Spark 流处理可以与其他 Spark 组件结合在一个程序中,将实时处理与机器学习、SQL 和图操作统一起来。

我们在第九章 Stream Me Up, Scotty - Spark Streaming中详细介绍了 Spark 流处理。

此外,新的 Structured Streaming API 使得 Spark 流处理程序更类似于 Spark 批处理程序,并且还允许在流数据之上进行实时查询,这在 Spark 2.0+之前的 Spark 流处理库中是复杂的。

Spark GraphX

GraphX 是在 Spark 之上的分布式图形处理框架。图形是由顶点和连接它们的边组成的数据结构。GraphX 提供了用于构建图形的函数,表示为图形 RDD。它提供了一个 API,用于表达可以使用 Pregel 抽象 API 模拟用户定义的图形的图形计算。它还为此抽象提供了优化的运行时。GraphX 还包含图论中最重要的算法的实现,例如 PageRank、连通组件、最短路径、SVD++等。

我们在第十章中详细介绍了 Spark Graphx,一切都连接在一起-GraphX

一个名为 GraphFrames 的新模块正在开发中,它使使用基于 DataFrame 的图形处理变得更加容易。GraphX 对 RDDs 的作用类似于 GraphFrames 对 DataFrame/数据集的作用。此外,目前这与 GraphX 是分开的,并且预计在未来将支持 GraphX 的所有功能,届时可能会切换到 GraphFrames。

Spark ML

MLlib 是在 Spark 核心之上的分布式机器学习框架,处理用于转换 RDD 形式的数据集的机器学习模型。Spark MLlib 是一个机器学习算法库,提供各种算法,如逻辑回归、朴素贝叶斯分类、支持向量机(SVMs)、决策树、随机森林、线性回归、交替最小二乘法(ALS)和 k 均值聚类。Spark ML 与 Spark 核心、Spark 流、Spark SQL 和 GraphX 集成非常好,提供了一个真正集成的平台,其中数据可以是实时的或批处理的。

我们在第十一章中详细介绍了 Spark ML,学习机器学习-Spark MLlib 和 ML

此外,PySpark 和 SparkR 也可用作与 Spark 集群交互并使用 Python 和 R API 的手段。Python 和 R 的集成真正为数据科学家和机器学习建模者打开了 Spark,因为一般数据科学家使用的最常见的语言是 Python 和 R。这也是 Spark 支持 Python 集成和 R 集成的原因,以避免学习 Scala 这种新语言的成本。另一个原因是可能存在大量用 Python 和 R 编写的现有代码,如果我们可以利用其中的一些代码,那将提高团队的生产力,而不是从头开始构建所有内容。

越来越多的人开始使用 Jupyter 和 Zeppelin 等笔记本技术,这使得与 Spark 进行交互变得更加容易,特别是在 Spark ML 中,预计会有很多假设和分析。

PySpark

PySpark 使用基于 Python 的SparkContext和 Python 脚本作为任务,然后使用套接字和管道来执行进程,以在基于 Java 的 Spark 集群和 Python 脚本之间进行通信。PySpark 还使用Py4J,这是一个在 PySpark 中集成的流行库,它让 Python 动态地与基于 Java 的 RDD 进行交互。

在运行 Spark 执行程序的所有工作节点上必须安装 Python。

以下是 PySpark 通过在 Java 进程和 Python 脚本之间进行通信的方式:

SparkR

SparkR是一个 R 包,提供了一个轻量级的前端,用于从 R 中使用 Apache Spark。SparkR 提供了一个分布式数据框架实现,支持诸如选择、过滤、聚合等操作。SparkR 还支持使用 MLlib 进行分布式机器学习。SparkR 使用基于 R 的SparkContext和 R 脚本作为任务,然后使用 JNI 和管道来执行进程,以在基于 Java 的 Spark 集群和 R 脚本之间进行通信。

在运行 Spark 执行程序的所有工作节点上必须安装 R。

以下是 SparkR 通过在 Java 进程和 R 脚本之间进行通信的方式:

总结

我们探讨了 Hadoop 和 MapReduce 框架的演变,并讨论了 YARN、HDFS 概念、HDFS 读写、关键特性以及挑战。然后,我们讨论了 Apache Spark 的演变,为什么首次创建了 Apache Spark,以及它可以为大数据分析和处理的挑战带来的价值。

最后,我们还瞥见了 Apache Spark 中的各种组件,即 Spark 核心、Spark SQL、Spark 流处理、Spark GraphX 和 Spark ML,以及 PySpark 和 SparkR 作为将 Python 和 R 语言代码与 Apache Spark 集成的手段。

现在我们已经了解了大数据分析、Hadoop 分布式计算平台的空间和演变,以及 Apache Spark 的最终发展,以及 Apache Spark 如何解决一些挑战的高层概述,我们准备开始学习 Spark 以及如何在我们的用例中使用它。

在下一章中,我们将更深入地了解 Apache Spark,并开始深入了解它的工作原理,《第六章》开始使用 Spark - REPL 和 RDDs

第六章:开始使用 Spark - REPL 和 RDDs

“所有这些现代技术只是让人们试图一次做所有事情。”

  • 比尔·沃特森

在本章中,您将了解 Spark 的工作原理;然后,您将介绍 RDDs,这是 Apache Spark 背后的基本抽象,并且您将了解它们只是暴露类似 Scala 的 API 的分布式集合。然后,您将看到如何下载 Spark 以及如何通过 Spark shell 在本地运行它。

简而言之,本章将涵盖以下主题:

  • 深入了解 Apache Spark

  • Apache Spark 安装

  • 介绍 RDDs

  • 使用 Spark shell

  • 操作和转换

  • 缓存

  • 加载和保存数据

深入了解 Apache Spark

Apache Spark 是一个快速的内存数据处理引擎,具有优雅和富有表现力的开发 API,允许数据工作者高效地执行流式机器学习或 SQL 工作负载,这些工作负载需要对数据集进行快速交互式访问。Apache Spark 由 Spark 核心和一组库组成。核心是分布式执行引擎,Java,Scala 和 Python API 提供了分布式应用程序开发的平台。

构建在核心之上的附加库允许流处理,SQL,图处理和机器学习的工作负载。例如,SparkML 专为数据科学而设计,其抽象使数据科学变得更容易。

为了计划和执行分布式计算,Spark 使用作业的概念,该作业在工作节点上使用阶段和任务执行。Spark 由驱动程序组成,该驱动程序在工作节点集群上协调执行。驱动程序还负责跟踪所有工作节点以及每个工作节点当前执行的工作。

让我们更深入地了解一下各个组件。关键组件是 Driver 和 Executors,它们都是 JVM 进程(Java 进程):

  • Driver:Driver 程序包含应用程序,主程序。如果您使用 Spark shell,那就成为了 Driver 程序,并且 Driver 在整个集群中启动执行者,并且还控制任务的执行。

  • Executor:接下来是执行者,它们是在集群中的工作节点上运行的进程。在执行者内部,运行单个任务或计算。每个工作节点中可能有一个或多个执行者,同样,每个执行者内部可能有多个任务。当 Driver 连接到集群管理器时,集群管理器分配资源来运行执行者。

集群管理器可以是独立的集群管理器,YARN 或 Mesos。

集群管理器负责在形成集群的计算节点之间进行调度和资源分配。通常,这是通过具有了解和管理资源集群的管理进程来完成的,并将资源分配给请求进程,例如 Spark。我们将在接下来的章节中更深入地了解三种不同的集群管理器:独立,YARN 和 Mesos。

以下是 Spark 在高层次上的工作方式:

Spark 程序的主要入口点称为SparkContextSparkContext位于Driver组件内部,表示与集群的连接以及运行调度器和任务分发和编排的代码。

在 Spark 2.x 中,引入了一个名为SparkSession的新变量。 SparkContextSQLContextHiveContext现在是SparkSession的成员变量。

当启动Driver程序时,使用SparkContext向集群发出命令,然后executors将执行指令。执行完成后,Driver程序完成作业。此时,您可以发出更多命令并执行更多作业。

保持和重用SparkContext的能力是 Apache Spark 架构的一个关键优势,与 Hadoop 框架不同,Hadoop 框架中每个MapReduce作业或 Hive 查询或 Pig 脚本都需要从头开始进行整个处理,而且使用昂贵的磁盘而不是内存。

SparkContext可用于在集群上创建 RDD、累加器和广播变量。每个 JVM/Java 进程只能有一个活动的SparkContext。在创建新的SparkContext之前,必须stop()活动的SparkContext

Driver解析代码,并将字节级代码序列化传输到执行者以执行。当我们进行任何计算时,实际上是每个节点在本地级别使用内存处理进行计算。

解析代码并规划执行的过程是由Driver进程实现的关键方面。

以下是 Spark Driver如何协调整个集群上的计算:

有向无环图DAG)是 Spark 框架的秘密武器。Driver进程为您尝试使用分布式处理框架运行的代码创建任务的 DAG。然后,任务调度程序通过与集群管理器通信以获取资源来运行执行者,实际上按阶段和任务执行 DAG。DAG 代表一个作业,作业被分割成子集,也称为阶段,每个阶段使用一个核心作为任务执行。

一个简单作业的示例以及 DAG 如何分割成阶段和任务的示意图如下两个图示;第一个显示作业本身,第二个图表显示作业中的阶段和任务:

以下图表将作业/DAG 分解为阶段和任务:

阶段的数量和阶段的内容取决于操作的类型。通常,任何转换都会进入与之前相同的阶段,但每个操作(如 reduce 或 shuffle)总是创建一个新的执行阶段。任务是阶段的一部分,与在执行者上执行操作的核心直接相关。

如果您使用 YARN 或 Mesos 作为集群管理器,可以使用动态 YARN 调度程序在需要执行更多工作时增加执行者的数量,以及终止空闲执行者。

因此,Driver 管理整个执行过程的容错。一旦 Driver 完成作业,输出可以写入文件、数据库,或者简单地输出到控制台。

请记住,Driver 程序本身的代码必须完全可序列化,包括所有变量和对象。

经常看到的异常是不可序列化异常,这是由于包含来自块外部的全局变量。

因此,Driver 进程负责整个执行过程,同时监视和管理使用的资源,如执行者、阶段和任务,确保一切按计划进行,并从故障中恢复,如执行者节点上的任务故障或整个执行者节点作为整体的故障。

Apache Spark 安装

Apache Spark 是一个跨平台框架,可以部署在 Linux、Windows 和 Mac 机器上,只要我们在机器上安装了 Java。在本节中,我们将看看如何安装 Apache Spark。

Apache Spark 可以从spark.apache.org/downloads.html下载

首先,让我们看看机器上必须可用的先决条件:

  • Java 8+(作为所有 Spark 软件都作为 JVM 进程运行,因此是必需的)

  • Python 3.4+(可选,仅在使用 PySpark 时使用)

  • R 3.1+(可选,仅在使用 SparkR 时使用)

  • Scala 2.11+(可选,仅用于编写 Spark 程序)

Spark 可以部署在三种主要的部署模式中,我们将会看到:

  • Spark 独立

  • YARN 上的 Spark

  • Mesos 上的 Spark

Spark 独立

Spark 独立模式使用内置调度程序,不依赖于任何外部调度程序,如 YARN 或 Mesos。要在独立模式下安装 Spark,你必须将 Spark 二进制安装包复制到集群中的所有机器上。

在独立模式下,客户端可以通过 spark-submit 或 Spark shell 与集群交互。在任何情况下,Driver 都会与 Spark 主节点通信,以获取可以为此应用程序启动的工作节点。

与集群交互的多个客户端在 Worker 节点上创建自己的执行器。此外,每个客户端都将有自己的 Driver 组件。

以下是使用主节点和工作节点的独立部署 Spark:

现在让我们下载并安装 Spark 在独立模式下使用 Linux/Mac:

  1. 从链接spark.apache.org/downloads.html下载 Apache Spark:

  1. 在本地目录中解压包:
 tar -xvzf spark-2.2.0-bin-hadoop2.7.tgz

  1. 切换到新创建的目录:
 cd spark-2.2.0-bin-hadoop2.7

  1. 通过实施以下步骤设置JAVA_HOMESPARK_HOME的环境变量:

  2. JAVA_HOME应该是你安装 Java 的地方。在我的 Mac 终端上,这是设置为:

 export JAVA_HOME=/Library/Java/JavaVirtualMachines/
                             jdk1.8.0_65.jdk/Contents/Home/

    1. SPARK_HOME应该是新解压的文件夹。在我的 Mac 终端上,这是设置为:
 export SPARK_HOME= /Users/myuser/spark-2.2.0-bin-
                               hadoop2.7

  1. 运行 Spark shell 来查看是否可以工作。如果不工作,检查JAVA_HOMESPARK_HOME环境变量:./bin/spark-shell

  2. 现在你将看到如下所示的 shell。

  1. 你将在最后看到 Scala/Spark shell,现在你已经准备好与 Spark 集群交互了:
 scala>

现在,我们有一个连接到自动设置的本地集群运行 Spark 的 Spark-shell。这是在本地机器上启动 Spark 的最快方式。然而,你仍然可以控制工作节点/执行器,并连接到任何集群(独立/YARN/Mesos)。这就是 Spark 的强大之处,它使你能够快速从交互式测试转移到集群测试,随后在大型集群上部署你的作业。无缝集成提供了许多好处,这是你无法通过 Hadoop 和其他技术实现的。

如果你想了解所有设置,可以参考官方文档spark.apache.org/docs/latest/

有几种启动 Spark shell 的方式,如下面的代码片段所示。我们将在后面的部分中看到更多选项,更详细地展示 Spark shell:

  • 在本地机器上自动选择本地机器作为主节点的默认 shell:
 ./bin/spark-shell

  • 在本地机器上指定本地机器为主节点并使用n线程的默认 shell:
 ./bin/spark-shell --master local[n]

  • 在本地机器上连接到指定的 spark 主节点的默认 shell:
 ./bin/spark-shell --master spark://<IP>:<Port>

  • 在本地机器上使用客户端模式连接到 YARN 集群的默认 shell:
 ./bin/spark-shell --master yarn --deploy-mode client

  • 在本地机器上连接到 YARN 集群使用集群模式的默认 shell:
 ./bin/spark-shell --master yarn --deploy-mode cluster

Spark Driver 也有一个 Web UI,可以帮助你了解关于 Spark 集群、正在运行的执行器、作业和任务、环境变量和缓存的一切。当然,最重要的用途是监视作业。

http://127.0.0.1:4040/jobs/上启动本地 Spark 集群的 Web UI

Web UI 中的作业选项卡如下:

以下是显示集群所有执行器的选项卡:

Spark on YARN

在 YARN 模式下,客户端与 YARN 资源管理器通信,并获取容器来运行 Spark 执行。你可以把它看作是为你部署的一个迷你 Spark 集群。

与集群交互的多个客户端在集群节点(节点管理器)上创建自己的执行器。此外,每个客户端都将有自己的 Driver 组件。

在使用 YARN 时,Spark 可以在 YARN 客户端模式或 YARN 集群模式下运行。

YARN 客户端模式

在 YARN 客户端模式中,驱动程序在集群外的节点上运行(通常是客户端所在的地方)。驱动程序首先联系资源管理器请求资源来运行 Spark 作业。资源管理器分配一个容器(容器零)并回应驱动程序。然后驱动程序在容器零中启动 Spark 应用程序主节点。Spark 应用程序主节点然后在资源管理器分配的容器上创建执行器。YARN 容器可以在由节点管理器控制的集群中的任何节点上。因此,所有分配都由资源管理器管理。

即使 Spark 应用程序主节点也需要与资源管理器通信,以获取后续容器来启动执行器。

以下是 Spark 的 YARN 客户端模式部署:

YARN 集群模式

在 YARN 集群模式中,驱动程序在集群内的节点上运行(通常是应用程序主节点所在的地方)。客户端首先联系资源管理器请求资源来运行 Spark 作业。资源管理器分配一个容器(容器零)并回应客户端。然后客户端将代码提交到集群,然后在容器零中启动驱动程序和 Spark 应用程序主节点。驱动程序与应用程序主节点一起运行,然后在资源管理器分配的容器上创建执行器。YARN 容器可以在由节点管理器控制的集群中的任何节点上。因此,所有分配都由资源管理器管理。

即使 Spark 应用程序主节点也需要与资源管理器通信,以获取后续容器来启动执行器。

以下是 Spark 的 Yarn 集群模式部署:

在 YARN 集群模式中没有 shell 模式,因为驱动程序本身正在 YARN 中运行。

Mesos 上的 Spark

Mesos 部署类似于 Spark 独立模式,驱动程序与 Mesos 主节点通信,然后分配所需的资源来运行执行器。与独立模式一样,驱动程序然后与执行器通信以运行作业。因此,Mesos 部署中的驱动程序首先与主节点通信,然后在所有 Mesos 从节点上保证容器的请求。

当容器分配给 Spark 作业时,驱动程序然后启动执行器,然后在执行器中运行代码。当 Spark 作业完成并且驱动程序退出时,Mesos 主节点会收到通知,并且在 Mesos 从节点上以容器的形式的所有资源都会被回收。

与集群交互的多个客户端在从节点上创建自己的执行器。此外,每个客户端都将有自己的驱动程序组件。就像 YARN 模式一样,客户端模式和集群模式都是可能的

以下是基于 Mesos 的 Spark 部署,描述了驱动程序连接到Mesos 主节点,该主节点还具有所有 Mesos 从节点上所有资源的集群管理器:

RDD 介绍

弹性分布式数据集RDD)是不可变的、分布式的对象集合。Spark RDD 是具有弹性或容错性的,这使得 Spark 能够在面对故障时恢复 RDD。一旦创建,不可变性使得 RDD 一旦创建就是只读的。转换允许对 RDD 进行操作以创建新的 RDD,但原始 RDD 一旦创建就不会被修改。这使得 RDD 免受竞争条件和其他同步问题的影响。

RDD 的分布式特性是因为 RDD 只包含对数据的引用,而实际数据包含在集群中的节点上的分区中。

在概念上,RDD 是分布在集群中多个节点上的元素的分布式集合。我们可以简化 RDD 以更好地理解,将 RDD 视为分布在机器上的大型整数数组。

RDD 实际上是一个数据集,已经在集群中进行了分区,分区的数据可能来自 HDFS(Hadoop 分布式文件系统)、HBase 表、Cassandra 表、Amazon S3。

在内部,每个 RDD 都具有五个主要属性:

  • 分区列表

  • 计算每个分区的函数

  • 对其他 RDD 的依赖列表

  • 可选地,用于键-值 RDD 的分区器(例如,指定 RDD 是哈希分区的)

  • 可选地,计算每个分区的首选位置列表(例如,HDFS 文件的块位置)

看一下下面的图表:

在你的程序中,驱动程序将 RDD 对象视为分布式数据的句柄。这类似于指向数据的指针,而不是实际使用的数据,当需要时用于访问实际数据。

RDD 默认使用哈希分区器在集群中对数据进行分区。分区的数量与集群中节点的数量无关。很可能集群中的单个节点有多个数据分区。存在的数据分区数量完全取决于集群中节点的数量和数据的大小。如果你看节点上任务的执行,那么在 worker 节点上执行的执行器上的任务可能会处理同一本地节点或远程节点上可用的数据。这被称为数据的局部性,执行任务会选择尽可能本地的数据。

局部性会显著影响作业的性能。默认情况下,局部性的优先顺序可以显示为

PROCESS_LOCAL > NODE_LOCAL > NO_PREF > RACK_LOCAL > ANY

节点可能会得到多少分区是没有保证的。这会影响任何执行器的处理效率,因为如果单个节点上有太多分区在处理多个分区,那么处理所有分区所需的时间也会增加,超载执行器上的核心,从而减慢整个处理阶段的速度,直接减慢整个作业的速度。实际上,分区是提高 Spark 作业性能的主要调优因素之一。参考以下命令:

class RDD[T: ClassTag]

让我们进一步了解当我们加载数据时 RDD 会是什么样子。以下是 Spark 如何使用不同的 worker 加载数据的示例:

无论 RDD 是如何创建的,初始 RDD 通常被称为基础 RDD,而由各种操作创建的任何后续 RDD 都是 RDD 的血统的一部分。这是另一个非常重要的方面要记住,因为容错和恢复的秘密是Driver维护 RDD 的血统,并且可以执行血统来恢复任何丢失的 RDD 块。

以下是一个示例,显示了作为操作结果创建的多个 RDD。我们从Base RDD开始,它有 24 个项目,并派生另一个 RDD carsRDD,其中只包含与汽车匹配的项目(3):

在这些操作期间,分区的数量不会改变,因为每个执行器都会在内存中应用过滤转换,生成与原始 RDD 分区对应的新 RDD 分区。

接下来,我们将看到如何创建 RDDs

RDD 创建

RDD 是 Apache Spark 中使用的基本对象。它们是不可变的集合,代表数据集,并具有内置的可靠性和故障恢复能力。从本质上讲,RDD 在进行任何操作(如转换或动作)时会创建新的 RDD。RDD 还存储了用于从故障中恢复的血统。我们在上一章中也看到了有关如何创建 RDD 以及可以应用于 RDD 的操作的一些细节。

可以通过多种方式创建 RDD:

  • 并行化集合

  • 从外部源读取数据

  • 现有 RDD 的转换

  • 流式 API

并行化集合

通过在驱动程序内部的集合上调用parallelize()来并行化集合。当驱动程序尝试并行化集合时,它将集合分割成分区,并将数据分区分布到集群中。

以下是使用 SparkContext 和parallelize()函数从数字序列创建 RDD 的 RDD。parallelize()函数基本上将数字序列分割成分布式集合,也称为 RDD。

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> rdd_one.take(10)
res0: Array[Int] = Array(1, 2, 3)

从外部源读取数据

创建 RDD 的第二种方法是从外部分布式源(如 Amazon S3、Cassandra、HDFS 等)读取数据。例如,如果您从 HDFS 创建 RDD,则 Spark 集群中的各个节点都会读取 HDFS 中的分布式块。

Spark 集群中的每个节点基本上都在进行自己的输入输出操作,每个节点都独立地从 HDFS 块中读取一个或多个块。一般来说,Spark 会尽最大努力将尽可能多的 RDD 放入内存中。有能力通过在 Spark 集群中启用节点来缓存数据,以减少输入输出操作,避免重复读取操作,比如从可能远离 Spark 集群的 HDFS 块。在您的 Spark 程序中可以使用一整套缓存策略,我们将在缓存部分后面详细讨论。

以下是从文本文件加载的文本行 RDD,使用 Spark Context 和textFile()函数。textFile函数将输入数据加载为文本文件(每个换行符\n终止的部分成为 RDD 中的一个元素)。该函数调用还自动使用 HadoopRDD(在下一章中显示)来检测和加载所需的分区形式的数据,分布在集群中。

scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24

scala> rdd_two.count
res6: Long = 9

scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.

现有 RDD 的转换

RDD 本质上是不可变的;因此,可以通过对任何现有 RDD 应用转换来创建您的 RDD。过滤器是转换的一个典型例子。

以下是一个简单的整数rdd,通过将每个整数乘以2进行转换。同样,我们使用SparkContextparallelize函数将整数序列分布为分区形式的 RDD。然后,我们使用map()函数将 RDD 转换为另一个 RDD,将每个数字乘以2

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> rdd_one.take(10)
res0: Array[Int] = Array(1, 2, 3)

scala> val rdd_one_x2 = rdd_one.map(i => i * 2)
rdd_one_x2: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[9] at map at <console>:26

scala> rdd_one_x2.take(10)
res9: Array[Int] = Array(2, 4, 6)

流式 API

RDD 也可以通过 spark streaming 创建。这些 RDD 称为离散流 RDD(DStream RDD)。

我们将在第九章中进一步讨论这个问题,Stream Me Up, Scotty - Spark Streaming

在下一节中,我们将创建 RDD 并使用 Spark-Shell 探索一些操作。

使用 Spark shell

Spark shell 提供了一种简单的方式来执行数据的交互式分析。它还使您能够通过快速尝试各种 API 来学习 Spark API。此外,与 Scala shell 的相似性和对 Scala API 的支持还让您能够快速适应 Scala 语言构造,并更好地利用 Spark API。

Spark shell 实现了读取-求值-打印-循环REPL)的概念,允许您通过键入要评估的代码与 shell 进行交互。然后在控制台上打印结果,无需编译即可构建可执行代码。

在安装 Spark 的目录中运行以下命令启动它:

./bin/spark-shell

Spark shell 启动时,会自动创建SparkSessionSparkContext对象。SparkSession可作为 Spark 使用,SparkContext可作为 sc 使用。

spark-shell可以通过以下片段中显示的几个选项启动(最重要的选项用粗体显示):

./bin/spark-shell --help
Usage: ./bin/spark-shell [options]

Options:
 --master MASTER_URL spark://host:port, mesos://host:port, yarn, or local.
 --deploy-mode DEPLOY_MODE Whether to launch the driver program locally ("client") or
 on one of the worker machines inside the cluster ("cluster")
 (Default: client).
 --class CLASS_NAME Your application's main class (for Java / Scala apps).
 --name NAME A name of your application.
 --jars JARS Comma-separated list of local jars to include on the driver
 and executor classpaths.
 --packages Comma-separated list of maven coordinates of jars to include
 on the driver and executor classpaths. Will search the local
 maven repo, then maven central and any additional remote
 repositories given by --repositories. The format for the
 coordinates should be groupId:artifactId:version.
 --exclude-packages Comma-separated list of groupId:artifactId, to exclude while
 resolving the dependencies provided in --packages to avoid
 dependency conflicts.
 --repositories Comma-separated list of additional remote repositories to
 search for the maven coordinates given with --packages.
 --py-files PY_FILES Comma-separated list of .zip, .egg, or .py files to place
 on the PYTHONPATH for Python apps.
 --files FILES Comma-separated list of files to be placed in the working
 directory of each executor.

 --conf PROP=VALUE Arbitrary Spark configuration property.
 --properties-file FILE Path to a file from which to load extra properties. If not
 specified, this will look for conf/spark-defaults.conf.

 --driver-memory MEM Memory for driver (e.g. 1000M, 2G) (Default: 1024M).
 --driver-Java-options Extra Java options to pass to the driver.
 --driver-library-path Extra library path entries to pass to the driver.
 --driver-class-path Extra class path entries to pass to the driver. Note that
 jars added with --jars are automatically included in the
 classpath.

 --executor-memory MEM Memory per executor (e.g. 1000M, 2G) (Default: 1G).

 --proxy-user NAME User to impersonate when submitting the application.
 This argument does not work with --principal / --keytab.

 --help, -h Show this help message and exit.
 --verbose, -v Print additional debug output.
 --version, Print the version of current Spark.

 Spark standalone with cluster deploy mode only:
 --driver-cores NUM Cores for driver (Default: 1).

 Spark standalone or Mesos with cluster deploy mode only:
 --supervise If given, restarts the driver on failure.
 --kill SUBMISSION_ID If given, kills the driver specified.
 --status SUBMISSION_ID If given, requests the status of the driver specified.

 Spark standalone and Mesos only:
 --total-executor-cores NUM Total cores for all executors.

 Spark standalone and YARN only:
 --executor-cores NUM Number of cores per executor. (Default: 1 in YARN mode,
 or all available cores on the worker in standalone mode)

 YARN-only:
 --driver-cores NUM Number of cores used by the driver, only in cluster mode
 (Default: 1).
 --queue QUEUE_NAME The YARN queue to submit to (Default: "default").
 --num-executors NUM Number of executors to launch (Default: 2).
 If dynamic allocation is enabled, the initial number of
 executors will be at least NUM.
 --archives ARCHIVES Comma separated list of archives to be extracted into the
 working directory of each executor.
 --principal PRINCIPAL Principal to be used to login to KDC, while running on
 secure HDFS.
 --keytab KEYTAB The full path to the file that contains the keytab for the
 principal specified above. This keytab will be copied to
 the node running the Application Master via the Secure
 Distributed Cache, for renewing the login tickets and the
 delegation tokens periodically.

您还可以以可执行的 Java jar 的形式提交 Spark 代码,以便在集群中执行作业。通常,您在使用 shell 达到可行解决方案后才这样做。

在提交 Spark 作业到集群(本地、YARN 和 Mesos)时,请使用./bin/spark-submit

以下是 Shell 命令(最重要的命令用粗体标出):

scala> :help
All commands can be abbreviated, e.g., :he instead of :help.
:edit <id>|<line> edit history
:help [command] print this summary or command-specific help
:history [num] show the history (optional num is commands to show)
:h? <string> search the history
:imports [name name ...] show import history, identifying sources of names
:implicits [-v] show the implicits in scope
:javap <path|class> disassemble a file or class name
:line <id>|<line> place line(s) at the end of history
:load <path> interpret lines in a file
:paste [-raw] [path] enter paste mode or paste a file
:power enable power user mode
:quit exit the interpreter
:replay [options] reset the repl and replay all previous commands
:require <path> add a jar to the classpath
:reset [options] reset the repl to its initial state, forgetting all session entries
:save <path> save replayable session to a file
:sh <command line> run a shell command (result is implicitly => List[String])
:settings <options> update compiler options, if possible; see reset
:silent disable/enable automatic printing of results
:type [-v] <expr> display the type of an expression without evaluating it
:kind [-v] <expr> display the kind of expression's type
:warnings show the suppressed warnings from the most recent line which had any

使用 spark-shell,我们现在将一些数据加载为 RDD:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> rdd_one.take(10)
res0: Array[Int] = Array(1, 2, 3)

如您所见,我们正在逐个运行命令。或者,我们也可以粘贴命令:

scala> :paste
// Entering paste mode (ctrl-D to finish)

val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one.take(10)

// Exiting paste mode, now interpreting.
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[10] at parallelize at <console>:26
res10: Array[Int] = Array(1, 2, 3)

在下一节中,我们将深入研究这些操作。

动作和转换

RDDs 是不可变的,每个操作都会创建一个新的 RDD。现在,你可以在 RDD 上执行的两个主要操作是转换动作

转换改变 RDD 中的元素,例如拆分输入元素、过滤元素和执行某种计算。可以按顺序执行多个转换;但是在规划期间不会执行任何操作。

对于转换,Spark 将它们添加到计算的 DAG 中,只有当驱动程序请求一些数据时,这个 DAG 才会实际执行。这被称为延迟评估。

延迟评估的原因是,Spark 可以查看所有的转换并计划执行,利用驱动程序对所有操作的理解。例如,如果筛选转换立即应用于其他一些转换之后,Spark 将优化执行,以便每个执行器有效地对数据的每个分区执行转换。现在,只有当 Spark 等待执行时才有可能。

动作是实际触发计算的操作。在遇到动作操作之前,Spark 程序内的执行计划以 DAG 的形式创建并且不执行任何操作。显然,在执行计划中可能有各种转换,但在执行动作之前什么也不会发生。

以下是对一些任意数据的各种操作的描述,我们只想删除所有的笔和自行车,只计算汽车的数量**。**每个打印语句都是一个动作,触发 DAG 执行计划中到那一点的所有转换步骤的执行,如下图所示:

例如,对转换的有向无环图执行计数动作会触发执行直到基本 RDD 的所有转换。如果执行了另一个动作,那么可能会发生新的执行链。这清楚地说明了为什么在有向无环图的不同阶段可以进行任何缓存,这将极大地加快程序的下一次执行。另一种优化执行的方式是通过重用上一次执行的洗牌文件。

另一个例子是 collect 动作,它从所有节点收集或拉取所有数据到驱动程序。在调用 collect 时,您可以使用部分函数有选择地拉取数据。

转换

转换通过将转换逻辑应用于现有 RDD 中的每个元素,从现有 RDD 创建新的 RDD。一些转换函数涉及拆分元素、过滤元素和执行某种计算。可以按顺序执行多个转换。但是,在规划期间不会执行任何操作。

转换可以分为四类,如下所示。

通用转换

通用转换是处理大多数通用用例的转换函数,将转换逻辑应用于现有的 RDD 并生成新的 RDD。聚合、过滤等常见操作都称为通用转换。

通用转换函数的示例包括:

  • map

  • filter

  • flatMap

  • groupByKey

  • sortByKey

  • combineByKey

数学/统计转换

数学或统计转换是处理一些统计功能的转换函数,通常对现有的 RDD 应用一些数学或统计操作,生成一个新的 RDD。抽样是一个很好的例子,在 Spark 程序中经常使用。

此类转换的示例包括:

  • sampleByKey

  • randomSplit

集合理论/关系转换

集合理论/关系转换是处理数据集的连接和其他关系代数功能(如cogroup)的转换函数。这些函数通过将转换逻辑应用于现有的 RDD 并生成新的 RDD 来工作。

此类转换的示例包括:

  • cogroup

  • join

  • subtractByKey

  • fullOuterJoin

  • leftOuterJoin

  • rightOuterJoin

基于数据结构的转换

基于数据结构的转换是操作 RDD 的基础数据结构,即 RDD 中的分区的转换函数。在这些函数中,您可以直接在分区上工作,而不直接触及 RDD 内部的元素/数据。这些在任何 Spark 程序中都是必不可少的,超出了简单程序的范围,您需要更多地控制分区和分区在集群中的分布。通常,通过根据集群状态和数据大小以及确切的用例要求重新分配数据分区,可以实现性能改进。

此类转换的示例包括:

  • partitionBy

  • repartition

  • zipwithIndex

  • coalesce

以下是最新 Spark 2.1.1 中可用的转换函数列表:

转换 意义
map(func) 通过将源数据的每个元素传递给函数func来返回一个新的分布式数据集。
filter(func) 返回一个由源数据集中 func 返回 true 的元素组成的新数据集。
flatMap(func) 类似于 map,但每个输入项可以映射到 0 个或多个输出项(因此func应返回Seq而不是单个项)。
mapPartitions(func) 类似于 map,但在 RDD 的每个分区(块)上单独运行,因此当在类型为T的 RDD 上运行时,func必须是Iterator<T> => Iterator<U>类型。
mapPartitionsWithIndex(func) 类似于mapPartitions,但还为func提供一个整数值,表示分区的索引,因此当在类型为T的 RDD 上运行时,func必须是(Int, Iterator<T>) => Iterator<U>类型。
sample(withReplacement, fraction, seed) 使用给定的随机数生成器种子,对数据的一部分进行抽样,可以有或没有替换。
union(otherDataset) 返回一个包含源数据集和参数中元素并集的新数据集。
intersection(otherDataset) 返回一个包含源数据集和参数中元素交集的新 RDD。
distinct([numTasks])) 返回一个包含源数据集的不同元素的新数据集。

| groupByKey([numTasks]) | 当在(K, V)对的数据集上调用时,返回一个(K, Iterable<V>)对的数据集。注意:如果要对每个键执行聚合(例如求和或平均值),使用reduceByKeyaggregateByKey将获得更好的性能。

注意:默认情况下,输出中的并行级别取决于父 RDD 的分区数。您可以传递一个可选的numTasks参数来设置不同数量的任务。|

reduceByKey(func, [numTasks]) 当在(K, V)对的数据集上调用时,返回一个(K, V)对的数据集,其中每个键的值使用给定的reduce函数func进行聚合,func必须是(V,V) => V类型。与groupByKey一样,通过可选的第二个参数可以配置 reduce 任务的数量。
aggregateByKey(zeroValue)(seqOp, combOp, [numTasks]) 当在(K, V)对的数据集上调用时,返回使用给定的组合函数和中性“零”值对每个键的值进行聚合的(K, U)对的数据集。允许聚合值类型与输入值类型不同,同时避免不必要的分配。与groupByKey一样,通过可选的第二个参数可以配置减少任务的数量。
sortByKey([ascending], [numTasks]) 当在实现有序的(K, V)对的数据集上调用时,返回按键按升序或降序排序的(K, V)对的数据集,如布尔值升序参数中指定的那样。
join(otherDataset, [numTasks]) 当在类型为(K, V)(K, W)的数据集上调用时,返回每个键的所有元素对的(K, (V, W))对的数据集。通过leftOuterJoinrightOuterJoinfullOuterJoin支持外连接。
cogroup(otherDataset, [numTasks]) 当在类型为(K, V)(K, W)的数据集上调用时,返回(K, (Iterable<V>, Iterable<W>))元组的数据集。此操作也称为groupWith
cartesian(otherDataset) 当在类型为TU的数据集上调用时,返回(T, U)对的数据集(所有元素的所有对)。
pipe(command, [envVars]) 将 RDD 的每个分区通过 shell 命令(例如 Perl 或 bash 脚本)进行管道传输。RDD 元素被写入进程的stdin,并且输出到其stdout的行将作为字符串的 RDD 返回。
coalesce(numPartitions) 将 RDD 中的分区数减少到numPartitions。在筛选大型数据集后更有效地运行操作时非常有用。
repartition(numPartitions) 随机重排 RDD 中的数据,以创建更多或更少的分区并在它们之间平衡。这总是通过网络洗牌所有数据。
repartitionAndSortWithinPartitions(partitioner) 根据给定的分区器重新分区 RDD,并在每个生成的分区内按其键对记录进行排序。这比调用repartition然后在每个分区内排序更有效,因为它可以将排序推入洗牌机制中。

我们将说明最常见的转换:

map 函数

map将转换函数应用于输入分区,以生成输出 RDD 中的输出分区。

如下面的代码片段所示,这是我们如何将文本文件的 RDD 映射到文本行的长度的 RDD:

scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24

scala> rdd_two.count
res6: Long = 9

scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.

scala> val rdd_three = rdd_two.map(line => line.length)
res12: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[11] at map at <console>:2

scala> rdd_three.take(10)
res13: Array[Int] = Array(271, 165, 146, 138, 231, 159, 159, 410, 281)

下图解释了map()的工作原理。您可以看到 RDD 的每个分区都会在新的 RDD 中产生一个新的分区,从而在 RDD 的所有元素上应用转换:

flatMap 函数

flatMap()将转换函数应用于输入分区,以生成输出 RDD 中的输出分区,就像map()函数一样。但是,flatMap()还会展平输入 RDD 元素中的任何集合。

flatMap() on a RDD of a text file to convert the lines in the text to a RDD containing the individual words. We also show map() called on the same RDD before flatMap() is called just to show the difference in behavior:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24

scala> rdd_two.count
res6: Long = 9

scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.

scala> val rdd_three = rdd_two.map(line => line.split(" "))
rdd_three: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[16] at map at <console>:26

scala> rdd_three.take(1)
res18: Array[Array[String]] = Array(Array(Apache, Spark, provides, programmers, with, an, application, programming, interface, centered, on, a, data, structure, called, the, resilient, distributed, dataset, (RDD),, a, read-only, multiset, of, data, items, distributed, over, a, cluster, of, machines,, that, is, maintained, in, a, fault-tolerant, way.)

scala> val rdd_three = rdd_two.flatMap(line => line.split(" "))
rdd_three: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[17] at flatMap at <console>:26

scala> rdd_three.take(10)
res19: Array[String] = Array(Apache, Spark, provides, programmers, with, an, application, programming, interface, centered)

下图解释了flatMap()的工作原理。您可以看到 RDD 的每个分区都会在新的 RDD 中产生一个新的分区,从而在 RDD 的所有元素上应用转换:

filter 函数

filter 将转换函数应用于输入分区,以生成输出 RDD 中的过滤后的输出分区。

Spark:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24

scala> rdd_two.count
res6: Long = 9

scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.

scala> val rdd_three = rdd_two.filter(line => line.contains("Spark"))
rdd_three: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[20] at filter at <console>:26

scala>rdd_three.count
res20: Long = 5

下图解释了filter的工作原理。您可以看到 RDD 的每个分区都会在新的 RDD 中产生一个新的分区,从而在 RDD 的所有元素上应用过滤转换。

请注意,分区不会改变,应用筛选时有些分区可能也是空的

coalesce

coalesce将转换函数应用于输入分区,以将输入分区合并为输出 RDD 中的较少分区。

如下面的代码片段所示,这是我们如何将所有分区合并为单个分区:

scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24

scala> rdd_two.partitions.length
res21: Int = 2

scala> val rdd_three = rdd_two.coalesce(1)
rdd_three: org.apache.spark.rdd.RDD[String] = CoalescedRDD[21] at coalesce at <console>:26

scala> rdd_three.partitions.length
res22: Int = 1

以下图表解释了coalesce的工作原理。您可以看到,从原始 RDD 创建了一个新的 RDD,基本上通过根据需要组合它们来减少分区的数量:

重新分区

repartitiontransformation函数应用于输入分区,以将输入重新分区为输出 RDD 中的更少或更多的输出分区。

如下面的代码片段所示,这是我们如何将文本文件的 RDD 映射到具有更多分区的 RDD:

scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24

scala> rdd_two.partitions.length
res21: Int = 2

scala> val rdd_three = rdd_two.repartition(5)
rdd_three: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[25] at repartition at <console>:26

scala> rdd_three.partitions.length
res23: Int = 5

以下图表解释了repartition的工作原理。您可以看到,从原始 RDD 创建了一个新的 RDD,基本上通过根据需要组合/拆分分区来重新分配分区:

动作

动作触发到目前为止构建的所有转换的整个DAG有向无环图)通过运行代码块和函数来实现。现在,所有操作都按照 DAG 指定的方式执行。

有两种类型的动作操作:

  • 驱动程序:一种动作是驱动程序动作,例如收集计数、按键计数等。每个此类动作在远程执行器上执行一些计算,并将数据拉回驱动程序。

基于驱动程序的动作存在一个问题,即对大型数据集的操作可能会轻松地压倒驱动程序上可用的内存,从而使应用程序崩溃,因此应谨慎使用涉及驱动程序的动作

  • 分布式:另一种动作是分布式动作,它在集群中的节点上执行。这种分布式动作的示例是saveAsTextfile。由于操作的理想分布式性质,这是最常见的动作操作。

以下是最新的 Spark 2.1.1 中可用的动作函数列表:

动作 意义
reduce(func) 使用函数func(接受两个参数并返回一个参数)聚合数据集的元素。该函数应该是可交换和可结合的,以便可以正确并行计算。
collect() 将数据集的所有元素作为数组返回到驱动程序。这通常在过滤或其他返回数据的操作之后非常有用,这些操作返回了数据的足够小的子集。
count() 返回数据集中元素的数量。
first() 返回数据集的第一个元素(类似于take(1))。
take(n) 返回数据集的前n个元素的数组。
takeSample(withReplacement, num, [seed]) 返回数据集的num个元素的随机样本数组,可替换或不可替换,可选择预先指定随机数生成器种子。
takeOrdered(n, [ordering]) 使用它们的自然顺序或自定义比较器返回 RDD 的前n个元素。
saveAsTextFile(path) 将数据集的元素作为文本文件(或一组文本文件)写入本地文件系统、HDFS 或任何其他支持 Hadoop 的文件系统中的给定目录。Spark 将对每个元素调用toString以将其转换为文件中的文本行。
saveAsSequenceFile(path)(Java 和 Scala) 将数据集的元素作为 Hadoop SequenceFile 写入本地文件系统、HDFS 或任何其他支持 Hadoop 的文件系统中的给定路径。这适用于实现 Hadoop 的Writable接口的键值对 RDD。在 Scala 中,它也适用于隐式转换为Writable的类型(Spark 包括基本类型如IntDoubleString等的转换)。
saveAsObjectFile(path)(Java 和 Scala) 使用 Java 序列化以简单格式写入数据集的元素,然后可以使用SparkContext.objectFile()加载。
countByKey() 仅适用于类型为(K, V)的 RDD。返回一个(K, Int)对的哈希映射,其中包含每个键的计数。
foreach(func) 对数据集的每个元素运行函数func。这通常用于诸如更新累加器(spark.apache.org/docs/latest/programming-guide.html#accumulators)或与外部存储系统交互等副作用。注意:在foreach()之外修改除累加器之外的变量可能导致未定义的行为。有关更多详细信息,请参见理解闭包(spark.apache.org/docs/latest/programming-guide.html#understanding-closures-a-nameclosureslinka)。

reduce

reduce()将 reduce 函数应用于 RDD 中的所有元素,并将其发送到 Driver。

以下是一个示例代码,用于说明这一点。您可以使用SparkContext和 parallelize 函数从整数序列创建一个 RDD。然后,您可以使用 RDD 上的reduce函数将 RDD 中所有数字相加。

由于这是一个动作,所以一旦运行reduce函数,结果就会被打印出来。

下面显示了从一组小数字构建一个简单 RDD 的代码,然后在 RDD 上执行 reduce 操作:

scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[26] at parallelize at <console>:24

scala> rdd_one.take(10)
res28: Array[Int] = Array(1, 2, 3, 4, 5, 6)

scala> rdd_one.reduce((a,b) => a +b)
res29: Int = 21

以下图示是reduce()的说明。Driver 在执行器上运行 reduce 函数,并在最后收集结果。

count

count()简单地计算 RDD 中的元素数量并将其发送到 Driver。

以下是这个函数的一个例子。我们使用 SparkContext 和 parallelize 函数从整数序列创建了一个 RDD,然后调用 RDD 上的 count 函数来打印 RDD 中元素的数量。

scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[26] at parallelize at <console>:24

scala> rdd_one.count
res24: Long = 6

以下是count()的说明。Driver 要求每个执行器/任务计算任务处理的分区中元素的数量,然后在 Driver 级别将所有任务的计数相加。

collect

collect()简单地收集 RDD 中的所有元素并将其发送到 Driver。

这里展示了 collect 函数的一个例子。当你在 RDD 上调用 collect 时,Driver 会通过将 RDD 的所有元素拉到 Driver 上来收集它们。

在大型 RDD 上调用 collect 会导致 Driver 出现内存不足的问题。

下面显示了收集 RDD 的内容并显示它的代码:

scala> rdd_two.collect
res25: Array[String] = Array(Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way., It was developed in response to limitations in the MapReduce cluster computing paradigm, which forces a particular linear dataflow structure on distributed programs., "MapReduce programs read input data from disk, map a function across the data, reduce the results of the map, and store reduction results on disk. ", Spark's RDDs function as a working set for distributed programs that offers a (deliberately) restricted form of distributed shared memory., The availability of RDDs facilitates t...

以下是collect()的说明。使用 collect,Driver 从所有分区中拉取 RDD 的所有元素。

缓存

缓存使 Spark 能够在计算和操作之间持久保存数据。事实上,这是 Spark 中最重要的技术之一,可以加速计算,特别是在处理迭代计算时。

缓存通过尽可能多地将 RDD 存储在内存中来工作。如果内存不足,那么根据 LRU 策略会将当前存储中的数据清除。如果要缓存的数据大于可用内存,性能将下降,因为将使用磁盘而不是内存。

您可以使用persist()cache()将 RDD 标记为已缓存

cache()只是persist(MEMORY_ONLY)的同义词

persist可以使用内存或磁盘或两者:

persist(newLevel: StorageLevel) 

以下是存储级别的可能值:

存储级别 含义
MEMORY_ONLY 将 RDD 存储为 JVM 中的反序列化 Java 对象。如果 RDD 不适合内存,则某些分区将不会被缓存,并且每次需要时都会在飞行中重新计算。这是默认级别。
MEMORY_AND_DISK 将 RDD 存储为 JVM 中的反序列化 Java 对象。如果 RDD 不适合内存,则将不适合内存的分区存储在磁盘上,并在需要时从磁盘中读取它们。
MEMORY_ONLY_SER(Java 和 Scala) 将 RDD 存储为序列化的 Java 对象(每个分区一个字节数组)。通常情况下,这比反序列化对象更节省空间,特别是在使用快速序列化器时,但读取时更消耗 CPU。
MEMORY_AND_DISK_SER(Java 和 Scala) 类似于MEMORY_ONLY_SER,但将不适合内存的分区溢出到磁盘,而不是每次需要时动态重新计算它们。
DISK_ONLY 仅将 RDD 分区存储在磁盘上。
MEMORY_ONLY_2MEMORY_AND_DISK_2 与前面的级别相同,但在两个集群节点上复制每个分区。
OFF_HEAP(实验性) 类似于MEMORY_ONLY_SER,但将数据存储在堆外内存中。这需要启用堆外内存。

选择的存储级别取决于情况

  • 如果 RDD 适合内存,则使用MEMORY_ONLY,因为这是执行性能最快的选项

  • 尝试MEMORY_ONLY_SER,如果使用了可序列化对象,以使对象更小

  • 除非您的计算成本很高,否则不应使用DISK

  • 如果可以承受额外的内存,使用复制存储以获得最佳的容错性。这将防止丢失分区的重新计算,以获得最佳的可用性。

unpersist()只是释放缓存的内容。

以下是使用不同类型的存储(内存或磁盘)调用persist()函数的示例:

scala> import org.apache.spark.storage.StorageLevel
import org.apache.spark.storage.StorageLevel

scala> rdd_one.persist(StorageLevel.MEMORY_ONLY)
res37: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24

scala> rdd_one.unpersist()
res39: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24

scala> rdd_one.persist(StorageLevel.DISK_ONLY)
res40: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24

scala> rdd_one.unpersist()
res41: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24

以下是缓存带来的性能改进的示例。

首先,我们将运行代码:

scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> rdd_one.count
res0: Long = 6

scala> rdd_one.cache
res1: rdd_one.type = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> rdd_one.count
res2: Long = 6

您可以使用 WebUI 查看所示的改进,如以下屏幕截图所示:

加载和保存数据

将数据加载到 RDD 和将 RDD 保存到输出系统都支持多种不同的方法。我们将在本节中介绍最常见的方法。

加载数据

通过使用SparkContext可以将数据加载到 RDD 中。一些最常见的方法是:

  • textFile

  • wholeTextFiles

  • 从 JDBC 数据源加载

textFile

textFile()可用于将 textFiles 加载到 RDD 中,每行成为 RDD 中的一个元素。

sc.textFile(name, minPartitions=None, use_unicode=True)

以下是使用textFile()textfile加载到 RDD 中的示例:

scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24

scala> rdd_two.count
res6: Long = 9

wholeTextFiles

wholeTextFiles()可用于将多个文本文件加载到包含对<filename,textOfFile>的配对 RDD 中,表示文件名和文件的整个内容。这在加载多个小文本文件时很有用,并且与textFile API 不同,因为使用整个TextFiles()时,文件的整个内容将作为单个记录加载:

sc.wholeTextFiles(path, minPartitions=None, use_unicode=True)

以下是使用wholeTextFiles()textfile加载到 RDD 中的示例:

scala> val rdd_whole = sc.wholeTextFiles("wiki1.txt")
rdd_whole: org.apache.spark.rdd.RDD[(String, String)] = wiki1.txt MapPartitionsRDD[37] at wholeTextFiles at <console>:25

scala> rdd_whole.take(10)
res56: Array[(String, String)] =
Array((file:/Users/salla/spark-2.1.1-bin-hadoop2.7/wiki1.txt,Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data 

从 JDBC 数据源加载

您可以从支持Java 数据库连接JDBC)的外部数据源加载数据。使用 JDBC 驱动程序,您可以连接到关系数据库,如 Mysql,并将表的内容加载到 Spark 中,如下面的代码片段所示:

 sqlContext.load(path=None, source=None, schema=None, **options)

以下是从 JDBC 数据源加载的示例:

val dbContent = sqlContext.load(source="jdbc",  url="jdbc:mysql://localhost:3306/test",  dbtable="test",  partitionColumn="id")

保存 RDD

将数据从 RDD 保存到文件系统可以通过以下方式之一完成:

  • saveAsTextFile

  • saveAsObjectFile

以下是将 RDD 保存到文本文件的示例

scala> rdd_one.saveAsTextFile("out.txt")

在集成 HBase、Cassandra 等时,还有许多其他加载和保存数据的方法。

摘要

在本章中,我们讨论了 Apache Spark 的内部工作原理,RDD 是什么,DAG 和 RDD 的血统,转换和操作。我们还看了 Apache Spark 使用独立、YARN 和 Mesos 部署的各种部署模式。我们还在本地机器上进行了本地安装,然后看了 Spark shell 以及如何与 Spark 进行交互。

此外,我们还研究了将数据加载到 RDD 中以及将 RDD 保存到外部系统以及 Spark 卓越性能的秘密武器,缓存功能以及如何使用内存和/或磁盘来优化性能。

在下一章中,我们将深入研究 RDD API 以及它在《第七章》特殊 RDD 操作中的全部工作原理。

第七章:特殊的 RDD 操作

“它应该是自动的,但实际上你必须按下这个按钮。”

  • 约翰·布鲁纳

在本章中,您将了解如何根据不同的需求定制 RDD,以及这些 RDD 如何提供新的功能(和危险!)此外,我们还将研究 Spark 提供的其他有用对象,如广播变量和累加器。

简而言之,本章将涵盖以下主题:

  • RDD 的类型

  • 聚合

  • 分区和洗牌

  • 广播变量

  • 累加器

RDD 的类型

弹性分布式数据集RDD)是 Apache Spark 中使用的基本对象。RDD 是不可变的集合,代表数据集,并具有内置的可靠性和故障恢复能力。根据性质,RDD 在任何操作(如转换或动作)时创建新的 RDD。它们还存储血统,用于从故障中恢复。在上一章中,我们还看到了有关如何创建 RDD 以及可以应用于 RDD 的操作的一些详细信息。

以下是 RDD 血统的简单示例:

让我们再次从一系列数字创建最简单的 RDD 开始查看:

scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[28] at parallelize at <console>:25

scala> rdd_one.take(100)
res45: Array[Int] = Array(1, 2, 3, 4, 5, 6)

前面的示例显示了整数 RDD,对 RDD 进行的任何操作都会产生另一个 RDD。例如,如果我们将每个元素乘以3,结果将显示在以下片段中:

scala> val rdd_two = rdd_one.map(i => i * 3)
rdd_two: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[29] at map at <console>:27

scala> rdd_two.take(10)
res46: Array[Int] = Array(3, 6, 9, 12, 15, 18)

让我们再做一个操作,将每个元素加2,并打印所有三个 RDD:

scala> val rdd_three = rdd_two.map(i => i+2)
rdd_three: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[30] at map at <console>:29

scala> rdd_three.take(10)
res47: Array[Int] = Array(5, 8, 11, 14, 17, 20)

一个有趣的事情是使用toDebugString函数查看每个 RDD 的血统:

scala> rdd_one.toDebugString
res48: String = (8) ParallelCollectionRDD[28] at parallelize at <console>:25 []

scala> rdd_two.toDebugString
res49: String = (8) MapPartitionsRDD[29] at map at <console>:27 []
 | ParallelCollectionRDD[28] at parallelize at <console>:25 []

scala> rdd_three.toDebugString
res50: String = (8) MapPartitionsRDD[30] at map at <console>:29 []
 | MapPartitionsRDD[29] at map at <console>:27 []
 | ParallelCollectionRDD[28] at parallelize at <console>:25 []

以下是在 Spark web UI 中显示的血统:

RDD 不需要与第一个 RDD(整数)相同的数据类型。以下是一个 RDD,它写入了一个不同数据类型的元组(字符串,整数)。

scala> val rdd_four = rdd_three.map(i => ("str"+(i+2).toString, i-2))
rdd_four: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[33] at map at <console>:31

scala> rdd_four.take(10)
res53: Array[(String, Int)] = Array((str7,3), (str10,6), (str13,9), (str16,12), (str19,15), (str22,18))

以下是StatePopulation文件的 RDD,其中每个记录都转换为upperCase

scala> val upperCaseRDD = statesPopulationRDD.map(_.toUpperCase)
upperCaseRDD: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[69] at map at <console>:27

scala> upperCaseRDD.take(10)
res86: Array[String] = Array(STATE,YEAR,POPULATION, ALABAMA,2010,4785492, ALASKA,2010,714031, ARIZONA,2010,6408312, ARKANSAS,2010,2921995, CALIFORNIA,2010,37332685, COLORADO,2010,5048644, DELAWARE,2010,899816, DISTRICT OF COLUMBIA,2010,605183, FLORIDA,2010,18849098)

以下是前述转换的图表:

Pair RDD

Pair RDD 是由键值元组组成的 RDD,适用于许多用例,如聚合、排序和连接数据。键和值可以是简单类型,如整数和字符串,也可以是更复杂的类型,如案例类、数组、列表和其他类型的集合。基于键值的可扩展数据模型提供了许多优势,并且是 MapReduce 范式背后的基本概念。

通过对任何 RDD 应用转换来轻松创建PairRDD,将 RDD 转换为键值对的 RDD。

让我们使用SparkContextstatesPopulation.csv读入 RDD,该SparkContext可用作sc

以下是一个基本 RDD 的示例,显示了州人口以及相同 RDD 的PairRDD是什么样子,将记录拆分为州和人口的元组(对):

scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv") statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[47] at textFile at <console>:25
 scala> statesPopulationRDD.first
res4: String = State,Year,Population

scala> statesPopulationRDD.take(5)
res5: Array[String] = Array(State,Year,Population, Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995)

scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(2)))
pairRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[48] at map at <console>:27

scala> pairRDD.take(10)
res59: Array[(String, String)] = Array((Alabama,4785492), (Alaska,714031), (Arizona,6408312), (Arkansas,2921995), (California,37332685), (Colorado,5048644), (Delaware,899816), (District of Columbia,605183), (Florida,18849098))

以下是前面示例的图表,显示了 RDD 元素如何转换为(键 - 值)对:

DoubleRDD

DoubleRDD 是由一系列双精度值组成的 RDD。由于这个属性,许多统计函数可以与 DoubleRDD 一起使用。

以下是我们从一系列双精度数字创建 RDD 的 DoubleRDD 示例:

scala> val rdd_one = sc.parallelize(Seq(1.0,2.0,3.0))
rdd_one: org.apache.spark.rdd.RDD[Double] = ParallelCollectionRDD[52] at parallelize at <console>:25

scala> rdd_one.mean
res62: Double = 2.0

scala> rdd_one.min
res63: Double = 1.0

scala> rdd_one.max
res64: Double = 3.0

scala> rdd_one.stdev
res65: Double = 0.816496580927726

以下是 DoubleRDD 的图表,以及如何在 DoubleRDD 上运行sum()函数:

SequenceFileRDD

SequenceFileRDD是从 Hadoop 文件系统中的SequenceFile创建的格式。SequenceFile可以是压缩或未压缩的。

Map Reduce 进程可以使用 SequenceFiles,这是键和值的对。键和值是 Hadoop 可写数据类型,如 Text、IntWritable 等。

以下是一个SequenceFileRDD的示例,显示了如何写入和读取SequenceFile

scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(2)))
pairRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[60] at map at <console>:27

scala> pairRDD.saveAsSequenceFile("seqfile")

scala> val seqRDD = sc.sequenceFileString, String
seqRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[62] at sequenceFile at <console>:25

scala> seqRDD.take(10)
res76: Array[(String, String)] = Array((State,Population), (Alabama,4785492), (Alaska,714031), (Arizona,6408312), (Arkansas,2921995), (California,37332685), (Colorado,5048644), (Delaware,899816), (District of Columbia,605183), (Florida,18849098))

以下是在前面示例中看到的SequenceFileRDD的图表:

CoGroupedRDD

CoGroupedRDD是一个 cogroup 其父级的 RDD。这个工作的两个父 RDD 都必须是 pairRDDs,因为 cogroup 实质上生成一个由来自两个父 RDD 的公共键和值列表组成的 pairRDD。看一下下面的代码片段:

class CoGroupedRDD[K] extends RDD[(K, Array[Iterable[_]])] 

以下是一个 CoGroupedRDD 的示例,我们在其中创建了两个 pairRDDs 的 cogroup,一个具有州、人口对,另一个具有州、年份对:

scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(2)))
pairRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[60] at map at <console>:27

scala> val pairRDD2 = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(1)))
pairRDD2: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[66] at map at <console>:27

scala> val cogroupRDD = pairRDD.cogroup(pairRDD2)
cogroupRDD: org.apache.spark.rdd.RDD[(String, (Iterable[String], Iterable[String]))] = MapPartitionsRDD[68] at cogroup at <console>:31

scala> cogroupRDD.take(10)
res82: Array[(String, (Iterable[String], Iterable[String]))] = Array((Montana,(CompactBuffer(990641, 997821, 1005196, 1014314, 1022867, 1032073, 1042520),CompactBuffer(2010, 2011, 2012, 2013, 2014, 2015, 2016))), (California,(CompactBuffer(37332685, 37676861, 38011074, 38335203, 38680810, 38993940, 39250017),CompactBuffer(2010, 2011, 2012, 2013, 2014, 2015, 2016))),

下面是通过为每个键创建值对的pairRDDpairRDD2的 cogroup 的图表:

ShuffledRDD

ShuffledRDD通过键对 RDD 元素进行洗牌,以便在同一个执行器上累积相同键的值,以允许聚合或组合逻辑。一个很好的例子是看看在 PairRDD 上调用reduceByKey()时会发生什么:

class ShuffledRDD[K, V, C] extends RDD[(K, C)] 

以下是对pairRDD进行reduceByKey操作,以按州聚合记录的示例:

scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), 1))
pairRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[82] at map at <console>:27

scala> pairRDD.take(5)
res101: Array[(String, Int)] = Array((State,1), (Alabama,1), (Alaska,1), (Arizona,1), (Arkansas,1))

scala> val shuffledRDD = pairRDD.reduceByKey(_+_)
shuffledRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[83] at reduceByKey at <console>:29

scala> shuffledRDD.take(5)
res102: Array[(String, Int)] = Array((Montana,7), (California,7), (Washington,7), (Massachusetts,7), (Kentucky,7))

以下图表是按键进行洗牌以将相同键(州)的记录发送到相同分区的示例:

UnionRDD

UnionRDD是两个 RDD 的并集操作的结果。Union 简单地创建一个包含来自两个 RDD 的元素的 RDD,如下面的代码片段所示:

class UnionRDDT: ClassTag extends RDDT

UnionRDD by combining the elements of the two RDDs:
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[85] at parallelize at <console>:25

scala> val rdd_two = sc.parallelize(Seq(4,5,6))
rdd_two: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[86] at parallelize at <console>:25

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[87] at parallelize at <console>:25

scala> rdd_one.take(10)
res103: Array[Int] = Array(1, 2, 3)

scala> val rdd_two = sc.parallelize(Seq(4,5,6))
rdd_two: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[88] at parallelize at <console>:25

scala> rdd_two.take(10)
res104: Array[Int] = Array(4, 5, 6)

scala> val unionRDD = rdd_one.union(rdd_two)
unionRDD: org.apache.spark.rdd.RDD[Int] = UnionRDD[89] at union at <console>:29

scala> unionRDD.take(10)
res105: Array[Int] = Array(1, 2, 3, 4, 5, 6)

下面的图表是两个 RDD 的并集的示例,其中来自RDD 1RDD 2的元素被合并到一个新的 RDD UnionRDD中:

HadoopRDD

HadoopRDD提供了使用 Hadoop 1.x 库的 MapReduce API 从 HDFS 中读取数据的核心功能。HadoopRDD是默认使用的,可以在从任何文件系统加载数据到 RDD 时看到:

class HadoopRDD[K, V] extends RDD[(K, V)]

当从 CSV 加载州人口记录时,底层基本 RDD 实际上是HadoopRDD,如下面的代码片段所示:

scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv")
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[93] at textFile at <console>:25

scala> statesPopulationRDD.toDebugString
res110: String =
(2) statesPopulation.csv MapPartitionsRDD[93] at textFile at <console>:25 []
 | statesPopulation.csv HadoopRDD[92] at textFile at <console>:25 []

下面的图表是通过将文本文件从文件系统加载到 RDD 中创建的HadoopRDD的示例:

NewHadoopRDD

NewHadoopRDD提供了使用 Hadoop 2.x 库的新 MapReduce API 从 HDFS、HBase 表、Amazon S3 中读取数据的核心功能。NewHadoopRDD可以从许多不同的格式中读取数据,因此用于与多个外部系统交互。

NewHadoopRDD之前,HadoopRDD是唯一可用的选项,它使用了 Hadoop 1.x 的旧 MapReduce API

class NewHadoopRDDK, V
extends RDD[(K, V)]

NewHadoopRDD takes an input format class, a key class, and a value class. Let's look at examples of NewHadoopRDD.

最简单的例子是使用 SparkContext 的wholeTextFiles函数创建WholeTextFileRDD。现在,WholeTextFileRDD实际上扩展了NewHadoopRDD,如下面的代码片段所示:

scala> val rdd_whole = sc.wholeTextFiles("wiki1.txt")
rdd_whole: org.apache.spark.rdd.RDD[(String, String)] = wiki1.txt MapPartitionsRDD[3] at wholeTextFiles at <console>:31

scala> rdd_whole.toDebugString
res9: String =
(1) wiki1.txt MapPartitionsRDD[3] at wholeTextFiles at <console>:31 []
 | WholeTextFileRDD[2] at wholeTextFiles at <console>:31 []

让我们看另一个例子,我们将使用SparkContextnewAPIHadoopFile函数:

import org.apache.hadoop.mapreduce.lib.input.KeyValueTextInputFormat

import org.apache.hadoop.io.Text

val newHadoopRDD = sc.newAPIHadoopFile("statesPopulation.csv", classOf[KeyValueTextInputFormat], classOf[Text],classOf[Text])

聚合

聚合技术允许您以任意方式组合 RDD 中的元素以执行一些计算。事实上,聚合是大数据分析中最重要的部分。没有聚合,我们将无法生成报告和分析,比如按人口排名的州,这似乎是在给定过去 200 年所有州人口的数据集时提出的一个合乎逻辑的问题。另一个更简单的例子是只需计算 RDD 中元素的数量,这要求执行器计算每个分区中的元素数量并发送给 Driver,然后将子集相加以计算 RDD 中元素的总数。

在本节中,我们的主要重点是聚合函数,用于按键收集和组合数据。正如本章前面所看到的,PairRDD 是一个(key - value)对的 RDD,其中 key 和 value 是任意的,并且可以根据用例进行自定义。

在我们的州人口示例中,PairRDD 可以是<State,<Population,Year>>的对,这意味着State被视为键,元组<Population,Year>被视为值。通过这种方式分解键和值可以生成诸如每个州人口最多的年份之类的聚合。相反,如果我们的聚合是围绕年份进行的,比如每年人口最多的州,我们可以使用<Year,<State,Population>>pairRDD

以下是从StatePopulation数据集生成pairRDD的示例代码,其中State作为键,Year也作为键:

scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv")
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[157] at textFile at <console>:26

scala> statesPopulationRDD.take(5)
res226: Array[String] = Array(State,Year,Population, Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995)

接下来,我们可以生成一个pairRDD,使用State作为键,<Year,Population>元组作为值,如下面的代码片段所示:

scala> val pairRDD = statesPopulationRDD.map(record => record.split(",")).map(t => (t(0), (t(1), t(2))))
pairRDD: org.apache.spark.rdd.RDD[(String, (String, String))] = MapPartitionsRDD[160] at map at <console>:28

scala> pairRDD.take(5)
res228: Array[(String, (String, String))] = Array((State,(Year,Population)), (Alabama,(2010,4785492)), (Alaska,(2010,714031)), (Arizona,(2010,6408312)), (Arkansas,(2010,2921995)))

如前所述,我们还可以生成一个PairRDD,使用Year作为键,<State,Population>元组作为值,如下面的代码片段所示:

scala> val pairRDD = statesPopulationRDD.map(record => record.split(",")).map(t => (t(1), (t(0), t(2))))
pairRDD: org.apache.spark.rdd.RDD[(String, (String, String))] = MapPartitionsRDD[162] at map at <console>:28

scala> pairRDD.take(5)
res229: Array[(String, (String, String))] = Array((Year,(State,Population)), (2010,(Alabama,4785492)), (2010,(Alaska,714031)), (2010,(Arizona,6408312)), (2010,(Arkansas,2921995)))

现在我们将看看如何在<State,<Year,Population>>pairRDD上使用常见的聚合函数:

  • groupByKey

  • reduceByKey

  • aggregateByKey

  • combineByKey

groupByKey

groupByKey将 RDD 中每个键的值分组为单个序列。groupByKey还允许通过传递分区器来控制生成的键值对 RDD 的分区。默认情况下,使用HashPartitioner,但可以作为参数给出自定义分区器。每个组内元素的顺序不能保证,并且每次评估结果 RDD 时甚至可能不同。

groupByKey是一个昂贵的操作,因为需要所有的数据洗牌。reduceByKeyaggregateByKey提供了更好的性能。我们将在本节的后面进行讨论。

groupByKey可以使用自定义分区器调用,也可以只使用默认的HashPartitioner,如下面的代码片段所示:

def groupByKey(partitioner: Partitioner): RDD[(K, Iterable[V])] 

def groupByKey(numPartitions: Int): RDD[(K, Iterable[V])] 

目前实现的groupByKey必须能够在内存中保存任何键的所有键值对。如果一个键有太多的值,可能会导致OutOfMemoryError

groupByKey通过将分区的所有元素发送到基于分区器的分区,以便将相同键的所有键值对收集到同一分区中。完成此操作后,可以轻松进行聚合操作。

这里显示了调用groupByKey时发生的情况的示例:

reduceByKey

groupByKey涉及大量的数据洗牌,而reduceByKey倾向于通过不使用洗牌发送PairRDD的所有元素来提高性能,而是使用本地组合器首先在本地进行一些基本的聚合,然后像groupByKey一样发送结果元素。这大大减少了数据传输,因为我们不需要发送所有内容。reduceBykey通过使用关联和可交换的减少函数合并每个键的值。当然,首先这将

还可以在每个 mapper 上本地执行合并,然后将结果发送到 reducer。

如果您熟悉 Hadoop MapReduce,这与 MapReduce 编程中的组合器非常相似。

reduceByKey可以使用自定义分区器调用,也可以只使用默认的HashPartitioner,如下面的代码片段所示:

def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)]

def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)] 

def reduceByKey(func: (V, V) => V): RDD[(K, V)] 

reduceByKey通过将分区的所有元素发送到基于partitioner的分区,以便将相同键的所有键值对收集到同一分区中。但在洗牌之前,还进行本地聚合,减少要洗牌的数据。完成此操作后,可以在最终分区中轻松进行聚合操作。

下图是调用reduceBykey时发生的情况的示例:

aggregateByKey

aggregateByKeyreduceByKey非常相似,只是aggregateByKey允许更灵活和定制如何在分区内和分区之间进行聚合,以允许更复杂的用例,例如在一个函数调用中生成所有<Year, Population>对的列表以及每个州的总人口。

aggregateByKey通过使用给定的组合函数和中性初始/零值对每个键的值进行聚合。

这个函数可以返回一个不同的结果类型U,而不是这个 RDDV中的值的类型,这是最大的区别。因此,我们需要一个操作将V合并为U,以及一个操作将两个U合并。前一个操作用于在分区内合并值,后一个用于在分区之间合并值。为了避免内存分配,这两个函数都允许修改并返回它们的第一个参数,而不是创建一个新的U

def aggregateByKeyU: ClassTag(seqOp: (U, V) => U,
 combOp: (U, U) => U): RDD[(K, U)] 

def aggregateByKeyU: ClassTag(seqOp: (U, V) => U,
 combOp: (U, U) => U): RDD[(K, U)] 

def aggregateByKeyU: ClassTag(seqOp: (U, V) => U,
 combOp: (U, U) => U): RDD[(K, U)] 

aggregateByKey通过在分区内对每个分区的所有元素进行聚合操作,然后在合并分区本身时应用另一个聚合逻辑来工作。最终,相同 Key 的所有(键-值)对都被收集在同一个分区中;然而,与groupByKeyreduceByKey中的固定输出不同,使用aggregateByKey时更灵活和可定制。

下图是调用aggregateByKey时发生的情况的示例。与groupByKeyreduceByKey中添加计数不同,这里我们为每个 Key 生成值列表:

combineByKey

combineByKeyaggregateByKey非常相似;实际上,combineByKey在内部调用combineByKeyWithClassTag,这也被aggregateByKey调用。与aggregateByKey一样,combineByKey也通过在每个分区内应用操作,然后在组合器之间工作。

combineByKeyRDD[K,V]转换为RDD[K,C],其中C是在名称键K下收集或组合的 V 的列表。

调用 combineByKey 时期望有三个函数。

  • createCombiner,将V转换为C,这是一个元素列表

  • mergeValueV合并到C中,将V附加到列表的末尾

  • mergeCombiners将两个 C 合并为一个

aggregateByKey中,第一个参数只是一个零值,但在combineByKey中,我们提供了以当前值作为参数的初始函数。

combineByKey可以使用自定义分区器调用,也可以只使用默认的 HashPartitioner,如下面的代码片段所示:

def combineByKeyC => C, mergeCombiners: (C, C) => C, numPartitions: Int): RDD[(K, C)]

def combineByKeyC => C, mergeCombiners: (C, C) => C, partitioner: Partitioner, mapSideCombine: Boolean = true, serializer: Serializer = null): RDD[(K, C)]

combineByKey通过在分区内对每个分区的所有元素进行聚合操作,然后在合并分区本身时应用另一个聚合逻辑来工作。最终,相同 Key 的所有(键-值)对都被收集在同一个分区中,但是与groupByKeyreduceByKey中的固定输出不同,使用combineByKey时更灵活和可定制。

下图是调用combineBykey时发生的情况的示例:

groupByKey、reduceByKey、combineByKey 和 aggregateByKey 的比较

让我们考虑 StatePopulation RDD 生成一个pairRDD的例子,其中包含<State, <Year, Population>>

groupByKey如前面的部分所示,将通过生成键的哈希码对PairRDD进行HashPartitioning,然后洗牌数据以在同一分区中收集每个键的值。这显然会导致过多的洗牌。

reduceByKey通过使用本地组合逻辑改进了groupByKey,以最小化在洗牌阶段发送的数据。结果与groupByKey相同,但性能更高。

aggregateByKey在工作方式上与reduceByKey非常相似,但有一个重大区别,这使它成为这三种方法中最强大的一个。aggregateBykey不需要在相同的数据类型上操作,并且可以在分区内进行不同的聚合,在分区之间进行不同的聚合。

combineByKey在性能上与aggregateByKey非常相似,除了用于创建组合器的初始函数。

要使用的函数取决于您的用例,但如果有疑问,只需参考本节关于聚合的部分,选择适合您用例的正确函数。此外,要特别关注下一节,因为分区和洗牌将在该部分中介绍。

以下是显示通过州计算总人口的四种方法的代码。

第 1 步。初始化 RDD:

scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv").filter(_.split(",")(0) != "State") 
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[1] at textFile at <console>:24

scala> statesPopulationRDD.take(10)
res27: Array[String] = Array(Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995, California,2010,37332685, Colorado,2010,5048644, Delaware,2010,899816, District of Columbia,2010,605183, Florida,2010,18849098, Georgia,2010,9713521)

第 2 步。转换为成对的 RDD:

scala> val pairRDD = statesPopulationRDD.map(record => record.split(",")).map(t => (t(0), (t(1).toInt, t(2).toInt)))
pairRDD: org.apache.spark.rdd.RDD[(String, (Int, Int))] = MapPartitionsRDD[26] at map at <console>:26

scala> pairRDD.take(10)
res15: Array[(String, (Int, Int))] = Array((Alabama,(2010,4785492)), (Alaska,(2010,714031)), (Arizona,(2010,6408312)), (Arkansas,(2010,2921995)), (California,(2010,37332685)), (Colorado,(2010,5048644)), (Delaware,(2010,899816)), (District of Columbia,(2010,605183)), (Florida,(2010,18849098)), (Georgia,(2010,9713521)))

第 3 步。groupByKey - 分组值,然后添加人口:

scala> val groupedRDD = pairRDD.groupByKey.map(x => {var sum=0; x._2.foreach(sum += _._2); (x._1, sum)})
groupedRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[38] at map at <console>:28

scala> groupedRDD.take(10)
res19: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))

第 4 步。reduceByKey - 通过简单地添加人口来减少键的值:


scala> val reduceRDD = pairRDD.reduceByKey((x, y) => (x._1, x._2+y._2)).map(x => (x._1, x._2._2))
reduceRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[46] at map at <console>:28

scala> reduceRDD.take(10)
res26: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))

第 5 步。按键聚合 - 聚合每个键下的人口并将它们相加:

Initialize the array
scala> val initialSet = 0
initialSet: Int = 0

provide function to add the populations within a partition
scala> val addToSet = (s: Int, v: (Int, Int)) => s+ v._2
addToSet: (Int, (Int, Int)) => Int = <function2>

provide funtion to add populations between partitions
scala> val mergePartitionSets = (p1: Int, p2: Int) => p1 + p2
mergePartitionSets: (Int, Int) => Int = <function2>

scala> val aggregatedRDD = pairRDD.aggregateByKey(initialSet)(addToSet, mergePartitionSets)
aggregatedRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[41] at aggregateByKey at <console>:34

scala> aggregatedRDD.take(10)
res24: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))

第 6 步。combineByKey - 在分区内进行组合,然后合并组合器:

createcombiner function
scala> val createCombiner = (x:(Int,Int)) => x._2
createCombiner: ((Int, Int)) => Int = <function1>

function to add within partition
scala> val mergeValues = (c:Int, x:(Int, Int)) => c +x._2
mergeValues: (Int, (Int, Int)) => Int = <function2>

function to merge combiners
scala> val mergeCombiners = (c1:Int, c2:Int) => c1 + c2
mergeCombiners: (Int, Int) => Int = <function2>

scala> val combinedRDD = pairRDD.combineByKey(createCombiner, mergeValues, mergeCombiners)
combinedRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[42] at combineByKey at <console>:34

scala> combinedRDD.take(10)
res25: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))

如您所见,所有四种聚合都产生相同的输出。只是它们的工作方式不同。

分区和洗牌

我们已经看到 Apache Spark 如何比 Hadoop 更好地处理分布式计算。我们还看到了内部工作,主要是基本数据结构,称为弹性分布式数据集RDD)。RDD 是不可变的集合,代表数据集,并具有内置的可靠性和故障恢复能力。RDD 在数据上的操作不是作为单个数据块,而是在整个集群中分布的分区中管理和操作数据。因此,数据分区的概念对于 Apache Spark 作业的正常运行至关重要,并且可能对性能以及资源的利用方式产生重大影响。

RDD 由数据分区组成,所有操作都是在 RDD 的数据分区上执行的。诸如转换之类的几个操作是由执行器在正在操作的特定数据分区上执行的函数。然而,并非所有操作都可以通过在各自的执行器上对数据分区执行孤立的操作来完成。像聚合(在前面的部分中看到)这样的操作需要在整个集群中移动数据,这个阶段被称为洗牌。在本节中,我们将更深入地了解分区和洗牌的概念。

让我们通过执行以下代码来查看整数的简单 RDD。Spark 上下文的parallelize函数从整数序列创建 RDD。然后,使用getNumPartitions()函数,我们可以获取此 RDD 的分区数。

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[120] at parallelize at <console>:25

scala> rdd_one.getNumPartitions
res202: Int = 8

RDD 可以如下图所示进行可视化,显示了 RDD 中的 8 个分区:

分区数很重要,因为这个数字直接影响将运行 RDD 转换的任务数量。如果分区数太小,那么我们将在大量数据上只使用少量 CPU/核心,从而导致性能较慢,并且使集群利用不足。另一方面,如果分区数太大,那么您将使用比实际需要更多的资源,在多租户环境中可能会导致为您或您团队中的其他作业运行的资源饥饿。

分区器

RDD 的分区是由分区器完成的。分区器为 RDD 中的元素分配分区索引。同一分区中的所有元素将具有相同的分区索引。

Spark 提供了两种分区器HashPartitionerRangePartitioner。除此之外,您还可以实现自定义分区器。

HashPartitioner

HashPartitioner是 Spark 中的默认分区器,它通过为 RDD 元素的每个键计算哈希值来工作。所有具有相同哈希码的元素最终都会进入同一个分区,如下面的代码片段所示:

partitionIndex = hashcode(key) % numPartitions

以下是 String hashCode()函数的示例,以及我们如何生成partitionIndex

scala> val str = "hello"
str: String = hello

scala> str.hashCode
res206: Int = 99162322

scala> val numPartitions = 8
numPartitions: Int = 8

scala> val partitionIndex = str.hashCode % numPartitions
partitionIndex: Int = 2

默认分区数要么来自 Spark 配置参数spark.default.parallelism,要么来自集群中的核心数

以下图示说明了哈希分区的工作原理。我们有一个包含 3 个元素abe的 RDD。使用 String 哈希码,我们可以根据设置的 6 个分区得到每个元素的partitionIndex

RangePartitioner

RangePartitioner通过将 RDD 分区为大致相等的范围来工作。由于范围必须知道任何分区的起始和结束键,因此在使用RangePartitioner之前,RDD 需要首先进行排序。

RangePartitioning 首先需要根据 RDD 确定合理的分区边界,然后创建一个从键 K 到partitionIndex的函数,该函数确定元素所属的分区。最后,我们需要根据RangePartitioner重新分区 RDD,以便根据我们确定的范围正确分发 RDD 元素。

以下是我们如何使用RangePartitioningPairRDD进行分区的示例。我们还可以看到在使用RangePartitioner重新分区 RDD 后分区发生了变化:

import org.apache.spark.RangePartitioner
scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv")
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[135] at textFile at <console>:26

scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), 1))
pairRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[136] at map at <console>:28

scala> val rangePartitioner = new RangePartitioner(5, pairRDD)
rangePartitioner: org.apache.spark.RangePartitioner[String,Int] = org.apache.spark.RangePartitioner@c0839f25

scala> val rangePartitionedRDD = pairRDD.partitionBy(rangePartitioner)
rangePartitionedRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[130] at partitionBy at <console>:32

scala> pairRDD.mapPartitionsWithIndex((i,x) => Iterator(""+i + ":"+x.length)).take(10)
res215: Array[String] = Array(0:177, 1:174)

scala> rangePartitionedRDD.mapPartitionsWithIndex((i,x) => Iterator(""+i + ":"+x.length)).take(10)
res216: Array[String] = Array(0:70, 1:77, 2:70, 3:63, 4:71)

以下图示说明了RangePartitioner,就像在前面的示例中看到的那样:

洗牌

无论使用何种分区器,许多操作都会导致 RDD 数据在分区之间进行重新分区。可以创建新分区,也可以合并/压缩多个分区。为了进行重新分区所需的所有数据移动都称为shuffling,这是编写 Spark 作业时需要理解的重要概念。洗牌可能会导致性能严重下降,因为计算不再在同一个执行器的内存中进行,而是执行器在网络上传输数据。

一个很好的例子是我们在聚合部分早些时候看到的groupByKey()的例子。显然,大量数据在执行器之间流动,以确保所有键的值都被收集到同一个执行器上执行groupBy操作。

Shuffling 还确定了 Spark 作业的执行过程,并影响作业如何分成阶段。正如我们在本章和上一章中所看到的,Spark 保存了 RDD 的 DAG,它代表了 RDD 的血统,因此 Spark 不仅使用血统来规划作业的执行,而且可以从中恢复任何执行器的丢失。当 RDD 正在进行转换时,会尝试确保操作在与数据相同的节点上执行。然而,通常我们使用连接操作、reduce、group 或聚合等操作,这些操作会有意或无意地导致重新分区。这种洗牌反过来又决定了处理中的特定阶段在哪里结束,新阶段从哪里开始。

以下图示说明了 Spark 作业如何分成阶段。此示例显示了对pairRDD进行过滤,使用 map 进行转换,然后调用groupByKey,最后使用map()进行最后一次转换:

我们进行的洗牌越多,作业执行中就会出现越多的阶段,从而影响性能。Spark Driver 用于确定阶段的两个关键方面是定义 RDD 的两种依赖关系,即窄依赖和宽依赖。

窄依赖

当一个 RDD 可以通过简单的一对一转换(如filter()函数、map()函数、flatMap()函数等)从另一个 RDD 派生出来时,子 RDD 被认为是依赖于父 RDD 的一对一基础。这种依赖关系被称为窄依赖,因为数据可以在包含原始 RDD/父 RDD 分区的同一节点上进行转换,而无需在其他执行器之间进行任何数据传输。

窄依赖在作业执行的同一阶段中。

下图是一个窄依赖如何将一个 RDD 转换为另一个 RDD 的示例,对 RDD 元素进行一对一的转换:

广泛依赖

当一个 RDD 可以通过在线传输数据或使用函数进行数据重分区或重新分发数据(如aggregateByKeyreduceByKey等)从一个或多个 RDD 派生出来时,子 RDD 被认为依赖于参与洗牌操作的父 RDD。这种依赖关系被称为广泛依赖,因为数据不能在包含原始 RDD/父 RDD 分区的同一节点上进行转换,因此需要在其他执行器之间通过网络传输数据。

广泛的依赖关系引入了作业执行中的新阶段。

下图是一个广泛依赖如何在执行器之间洗牌数据将一个 RDD 转换为另一个 RDD 的示例:

广播变量

广播变量是所有执行器共享的变量。广播变量在驱动程序中创建一次,然后在执行器上只读。虽然理解简单数据类型的广播,比如Integer,是很简单的,但广播在概念上比简单的变量要大得多。整个数据集可以在 Spark 集群中广播,以便执行器可以访问广播的数据。在执行器中运行的所有任务都可以访问广播变量。

广播使用各种优化方法使广播的数据对所有执行器都可访问。这是一个重要的挑战,因为如果广播的数据集的大小很大,你不能指望 100 个或 1000 个执行器连接到驱动程序并拉取数据集。相反,执行器通过 HTTP 连接拉取数据,还有一个类似于 BitTorrent 的最近添加的方法,其中数据集本身就像种子一样分布在集群中。这使得将广播变量分发给所有执行器的方法比每个执行器逐个从驱动程序拉取数据更具可伸缩性,这可能会导致驱动程序在有大量执行器时出现故障。

驱动程序只能广播它拥有的数据,你不能使用引用来广播 RDD。这是因为只有驱动程序知道如何解释 RDD,执行器只知道它们正在处理的数据的特定分区。

如果你深入研究广播的工作原理,你会发现这种机制首先由驱动程序将序列化对象分成小块,然后将这些块存储在驱动程序的 BlockManager 中。当代码被序列化以在执行器上运行时,每个执行器首先尝试从自己的内部 BlockManager 中获取对象。如果广播变量之前已经被获取过,它会找到并使用它。然而,如果它不存在,执行器将使用远程获取从驱动程序和/或其他可用的执行器中获取小块。一旦获取了这些块,它就会将这些块放入自己的 BlockManager 中,准备让其他执行器从中获取。这可以防止驱动程序成为发送广播数据的瓶颈(每个执行器一个副本)。

下图是一个 Spark 集群中广播工作的示例:

广播变量既可以创建也可以销毁。我们将研究广播变量的创建和销毁。还有一种方法可以从内存中删除广播变量,我们也将研究。

创建广播变量

可以使用 Spark 上下文的broadcast()函数在任何数据类型的任何数据上创建广播变量,前提是数据/变量是可序列化的。

让我们看看如何广播一个整数变量,然后在执行程序上执行转换操作时使用广播变量:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at parallelize at <console>:25

scala> val i = 5
i: Int = 5

scala> val bi = sc.broadcast(i)
bi: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(147)

scala> bi.value
res166: Int = 5

scala> rdd_one.take(5)
res164: Array[Int] = Array(1, 2, 3)

scala> rdd_one.map(j => j + bi.value).take(5)
res165: Array[Int] = Array(6, 7, 8)

广播变量也可以创建在不仅仅是原始数据类型上,如下一个示例所示,我们将从 Driver 广播一个HashMap

以下是通过查找 HashMap 将整数 RDD 进行简单转换的示例,将 RDD 的 1,2,3 转换为 1 X 2,2 X 3,3 X 4 = 2,6,12:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[109] at parallelize at <console>:25

scala> val m = scala.collection.mutable.HashMap(1 -> 2, 2 -> 3, 3 -> 4)
m: scala.collection.mutable.HashMap[Int,Int] = Map(2 -> 3, 1 -> 2, 3 -> 4)

scala> val bm = sc.broadcast(m)
bm: org.apache.spark.broadcast.Broadcast[scala.collection.mutable.HashMap[Int,Int]] = Broadcast(178)

scala> rdd_one.map(j => j * bm.value(j)).take(5)
res191: Array[Int] = Array(2, 6, 12)

清理广播变量

广播变量在所有执行程序上占用内存,并且根据广播变量中包含的数据的大小,这可能会在某个时刻引起资源问题。有一种方法可以从所有执行程序的内存中删除广播变量。

在广播变量上调用unpersist()会从所有执行程序的内存缓存中删除广播变量的数据,以释放资源。如果再次使用变量,则数据将重新传输到执行程序,以便再次使用。但是,Driver 会保留内存,如果 Driver 没有数据,则广播变量将不再有效。

接下来我们将看看如何销毁广播变量。

以下是如何在广播变量上调用unpersist()。调用unpersist后,如果我们再次访问广播变量,则它会像往常一样工作,但在幕后,执行程序再次获取变量的数据。

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at parallelize at <console>:25

scala> val k = 5
k: Int = 5

scala> val bk = sc.broadcast(k)
bk: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(163)

scala> rdd_one.map(j => j + bk.value).take(5)
res184: Array[Int] = Array(6, 7, 8)

scala> bk.unpersist

scala> rdd_one.map(j => j + bk.value).take(5)
res186: Array[Int] = Array(6, 7, 8)

销毁广播变量

您还可以销毁广播变量,将其从所有执行程序和 Driver 中完全删除,使其无法访问。这在跨集群有效地管理资源方面非常有帮助。

在广播变量上调用destroy()会销毁与指定广播变量相关的所有数据和元数据。一旦广播变量被销毁,就无法再次使用,必须重新创建。

以下是销毁广播变量的示例:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at parallelize at <console>:25

scala> val k = 5
k: Int = 5

scala> val bk = sc.broadcast(k)
bk: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(163)

scala> rdd_one.map(j => j + bk.value).take(5)
res184: Array[Int] = Array(6, 7, 8)

scala> bk.destroy

如果尝试使用已销毁的广播变量,则会抛出异常

以下是尝试重用已销毁的广播变量的示例:

scala> rdd_one.map(j => j + bk.value).take(5)
17/05/27 14:07:28 ERROR Utils: Exception encountered
org.apache.spark.SparkException: Attempted to use Broadcast(163) after it was destroyed (destroy at <console>:30)
 at org.apache.spark.broadcast.Broadcast.assertValid(Broadcast.scala:144)
 at org.apache.spark.broadcast.TorrentBroadcast$$anonfun$writeObject$1.apply$mcV$sp(TorrentBroadcast.scala:202)
 at org.apache.spark.broadcast.TorrentBroadcast$$anonfun$wri

因此,广播功能可以用于大大提高 Spark 作业的灵活性和性能。

累加器

累加器是跨执行程序共享的变量,通常用于向 Spark 程序添加计数器。如果您有一个 Spark 程序,并且想要知道错误或总记录数或两者,可以通过两种方式实现。一种方法是添加额外的逻辑来仅计算错误或总记录数,当处理所有可能的计算时变得复杂。另一种方法是保持逻辑和代码流相当完整,并添加累加器。

累加器只能通过将值添加到值来更新。

以下是使用 Spark 上下文和longAccumulator函数创建和使用长累加器的示例,以将新创建的累加器变量初始化为零。由于累加器在 map 转换内部使用,因此累加器会递增。操作结束时,累加器保持值为 351。

scala> val acc1 = sc.longAccumulator("acc1")
acc1: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 10355, name: Some(acc1), value: 0)

scala> val someRDD = statesPopulationRDD.map(x => {acc1.add(1); x})
someRDD: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[99] at map at <console>:29

scala> acc1.value
res156: Long = 0  /*there has been no action on the RDD so accumulator did not get incremented*/

scala> someRDD.count
res157: Long = 351

scala> acc1.value
res158: Long = 351

scala> acc1
res145: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 10355, name: Some(acc1), value: 351)

有内置的累加器可用于许多用例:

  • LongAccumulator:用于计算 64 位整数的总和、计数和平均值

  • DoubleAccumulator:用于计算双精度浮点数的总和、计数和平均值。

  • CollectionAccumulator[T]:用于收集元素列表

所有前面的累加器都是建立在AccumulatorV2类之上的。通过遵循相同的逻辑,我们可以潜在地构建非常复杂和定制的累加器来在我们的项目中使用。

我们可以通过扩展AccumulatorV2类来构建自定义累加器。以下是一个示例,显示了实现所需函数的必要性。在下面的代码中,AccumulatorV2[Int, Int]表示输入和输出都是整数类型:

class MyAccumulator extends AccumulatorV2[Int, Int] {
  //simple boolean check
 override def isZero: Boolean = ??? //function to copy one Accumulator and create another one override def copy(): AccumulatorV2[Int, Int] = ??? //to reset the value override def reset(): Unit = ??? //function to add a value to the accumulator override def add(v: Int): Unit = ??? //logic to merge two accumulators override def merge(other: AccumulatorV2[Int, Int]): Unit = ??? //the function which returns the value of the accumulator override def value: Int = ???
}

接下来,我们将看一个自定义累加器的实际例子。同样,我们将使用statesPopulation CSV 文件。我们的目标是在自定义累加器中累积年份的总和和人口的总和。

步骤 1. 导入包含 AccumulatorV2 类的包:

import org.apache.spark.util.AccumulatorV2

步骤 2. 包含年份和人口的 Case 类:

case class YearPopulation(year: Int, population: Long)

步骤 3. StateAccumulator 类扩展 AccumulatorV2:


class StateAccumulator extends AccumulatorV2[YearPopulation, YearPopulation] { 
      //declare the two variables one Int for year and Long for population
      private var year = 0 
 private var population:Long = 0L

      //return iszero if year and population are zero
      override def isZero: Boolean = year == 0 && population == 0L

      //copy accumulator and return a new accumulator
     override def copy(): StateAccumulator = { 
 val newAcc = new StateAccumulator 
 newAcc.year =     this.year 
 newAcc.population = this.population 
 newAcc 
 }

       //reset the year and population to zero 
       override def reset(): Unit = { year = 0 ; population = 0L }

       //add a value to the accumulator
       override def add(v: YearPopulation): Unit = { 
 year += v.year 
 population += v.population 
 }

       //merge two accumulators
      override def merge(other: AccumulatorV2[YearPopulation, YearPopulation]): Unit = { 
 other match { 
 case o: StateAccumulator => { 
 year += o.year 
 population += o.population 
 } 
 case _ => 
 } 
 }

       //function called by Spark to access the value of accumulator
       override def value: YearPopulation = YearPopulation(year, population)
}

步骤 4. 创建一个新的 StateAccumulator 并在 SparkContext 中注册:

val statePopAcc = new StateAccumulator

sc.register(statePopAcc, "statePopAcc")

步骤 5. 将 statesPopulation.csv 作为 RDD 读取:


val statesPopulationRDD = sc.textFile("statesPopulation.csv").filter(_.split(",")(0) != "State")

scala> statesPopulationRDD.take(10)
res1: Array[String] = Array(Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995, California,2010,37332685, Colorado,2010,5048644, Delaware,2010,899816, District of Columbia,2010,605183, Florida,2010,18849098, Georgia,2010,9713521)

步骤 6. 使用 StateAccumulator:

statesPopulationRDD.map(x => { 
 val toks = x.split(",") 
 val year = toks(1).toInt 
 val pop = toks(2).toLong 
 statePopAcc.add(YearPopulation(year, pop)) 
 x
}).count

步骤 7. 现在,我们可以检查 StateAccumulator 的值:

scala> statePopAcc
res2: StateAccumulator = StateAccumulator(id: 0, name: Some(statePopAcc), value: YearPopulation(704550,2188669780))

在这一部分,我们研究了累加器以及如何构建自定义累加器。因此,使用前面举例的例子,您可以创建复杂的累加器来满足您的需求。

总结

在这一章中,我们讨论了许多类型的 RDD,比如shuffledRDDpairRDDsequenceFileRDDHadoopRDD等等。我们还看了三种主要的聚合类型,groupByKeyreduceByKeyaggregateByKey。我们研究了分区是如何工作的,以及为什么围绕分区需要一个合适的计划来提高性能。我们还研究了洗牌和窄依赖和宽依赖的概念,这些是 Spark 作业被分成阶段的基本原则。最后,我们看了广播变量和累加器的重要概念。

RDD 的灵活性使其易于适应大多数用例,并执行必要的操作以实现目标。

在下一章中,我们将转向 RDD 的更高抽象层,作为 Tungsten 计划的一部分添加到 RDD 中的 DataFrame 和 Spark SQL,以及它们如何在第八章 引入一点结构 - Spark SQL中结合在一起。

第八章:引入一点结构 - Spark SQL

“一台机器可以完成五十个普通人的工作。没有一台机器可以完成一个非凡人的工作。”

  • Elbert Hubbard

在本章中,您将学习如何使用 Spark 分析结构化数据(非结构化数据,例如包含任意文本或其他格式的文档必须转换为结构化形式);我们将看到 DataFrames/datasets 在这里是基石,以及 Spark SQL 的 API 如何使查询结构化数据变得简单而强大。此外,我们将介绍数据集,并看到数据集、DataFrames 和 RDD 之间的区别。简而言之,本章将涵盖以下主题:

  • Spark SQL 和 DataFrames

  • DataFrame 和 SQL API

  • DataFrame 模式

  • 数据集和编码器

  • 加载和保存数据

  • 聚合

  • 连接

Spark SQL 和 DataFrames

在 Apache Spark 之前,每当有人想在大量数据上运行类似 SQL 的查询时,Apache Hive 都是首选技术。Apache Hive 基本上将 SQL 查询转换为类似 MapReduce 的逻辑,自动使得在大数据上执行许多种类的分析变得非常容易,而无需实际学习如何用 Java 和 Scala 编写复杂的代码。

随着 Apache Spark 的出现,我们在大数据规模上执行分析的方式发生了范式转变。Spark SQL 在 Apache Spark 的分布式计算能力之上提供了一个易于使用的类似 SQL 的层。事实上,Spark SQL 可以用作在线分析处理数据库。

Spark SQL 通过将类似 SQL 的语句解析为抽象语法树AST)来工作,随后将该计划转换为逻辑计划,然后将逻辑计划优化为可以执行的物理计划。最终的执行使用底层的 DataFrame API,使任何人都可以通过简单地使用类似 SQL 的接口而不是学习所有内部细节来使用 DataFrame API。由于本书深入探讨了各种 API 的技术细节,我们将主要涵盖 DataFrame API,并在某些地方展示 Spark SQL API,以对比使用 API 的不同方式。

因此,DataFrame API 是 Spark SQL 下面的基础层。在本章中,我们将向您展示如何使用各种技术创建 DataFrames,包括 SQL 查询和对 DataFrames 执行操作。

DataFrame 是弹性分布式数据集RDD)的抽象,处理使用 catalyst 优化器优化的更高级函数,并且通过 Tungsten 计划也非常高效。您可以将数据集视为具有经过高度优化的数据的 RDD 的有效表。使用编码器实现了数据的二进制表示,编码器将各种对象序列化为二进制结构,比 RDD 表示具有更好的性能。由于 DataFrames 内部使用 RDD,因此 DataFrame/数据集也像 RDD 一样分布,因此也是分布式数据集。显然,这也意味着数据集是不可变的。

以下是数据的二进制表示的示例:

数据集在 Spark 1.6 中添加,并在 DataFrames 之上提供了强类型的好处。事实上,自 Spark 2.0 以来,DataFrame 只是数据集的别名。

org.apache.spark.sql定义类型DataFramedataset[Row],这意味着大多数 API 将与数据集和DataFrames一起很好地工作

类型 DataFrame = dataset[Row]

DataFrame 在概念上类似于关系数据库中的表。因此,DataFrame 包含数据行,每行由多个列组成。

我们需要牢记的第一件事就是,与 RDD 一样,DataFrames 是不可变的。DataFrames 具有不可变性的属性意味着每次转换或操作都会创建一个新的 DataFrame。

让我们更深入地了解 DataFrame 以及它们与 RDD 的不同之处。如前所述,RDD 代表 Apache Spark 中数据操作的低级 API。DataFrame 是在 RDD 的基础上创建的,以抽象出 RDD 的低级内部工作,并公开易于使用且提供大量功能的高级 API。DataFrame 是通过遵循 Python pandas 包、R 语言、Julia 语言等中发现的类似概念创建的。

正如我们之前提到的,DataFrame 将 SQL 代码和特定领域语言表达式转换为优化的执行计划,以在 Spark Core API 之上运行 SQL 语句执行各种操作。DataFrame 支持许多不同类型的输入数据源和许多类型的操作。这包括所有类型的 SQL 操作,例如连接、分组、聚合和窗口函数,就像大多数数据库一样。Spark SQL 也与 Hive 查询语言非常相似,由于 Spark 提供了与 Apache Hive 的自然适配器,因此在 Apache Hive 中工作的用户可以轻松将其知识转移到 Spark SQL 中,从而最小化过渡时间。

DataFrame 基本上依赖于表的概念,如前所述。表可以操作得非常类似于 Apache Hive 的工作方式。实际上,Apache Spark 中表的许多操作与 Apache Hive 处理表和对这些表进行操作的方式非常相似。一旦有了作为 DataFrame 的表,就可以将 DataFrame 注册为表,并且可以使用 Spark SQL 语句操作数据,而不是使用 DataFrame API。

DataFrame 依赖于催化剂优化器和 Tungsten 性能改进,因此让我们简要地了解一下催化剂优化器的工作原理。催化剂优化器从输入 SQL 创建解析的逻辑计划,然后通过查看 SQL 语句中使用的所有各种属性和列来分析逻辑计划。一旦创建了分析的逻辑计划,催化剂优化器进一步尝试通过组合多个操作和重新排列逻辑来优化计划以获得更好的性能。

为了理解催化剂优化器,可以将其视为一种常识逻辑优化器,可以重新排序操作,例如过滤和转换,有时将几个操作组合成一个,以便最小化在工作节点之间传输的数据量。例如,催化剂优化器可能决定在执行不同数据集之间的联接操作时广播较小的数据集。使用 explain 查看任何数据框的执行计划。催化剂优化器还计算 DataFrame 的列和分区的统计信息,提高执行速度。

例如,如果数据分区上有转换和过滤器,那么过滤数据和应用转换的顺序对操作的整体性能非常重要。由于所有优化的结果,生成了优化的逻辑计划,然后将其转换为物理计划。显然,有几种物理计划可以执行相同的 SQL 语句并生成相同的结果。成本优化逻辑根据成本优化和估算确定并选择一个良好的物理计划。

钨性能改进是 Spark 2.x 背后的秘密酱的另一个关键因素,与之前的版本(如 Spark 1.6 和更早版本)相比,它提供了卓越的性能改进。钨实现了对内存管理和其他性能改进的彻底改革。最重要的内存管理改进使用对象的二进制编码,并在堆外和堆内存中引用它们。因此,钨允许使用二进制编码机制来编码所有对象的堆外内存。二进制编码的对象占用的内存要少得多。Tungsten 项目还改进了洗牌性能。

数据通常通过DataFrameReader加载到 DataFrame 中,并且数据通过DataFrameWriter保存。

DataFrame API 和 SQL API

可以通过多种方式创建 DataFrame:

  • 通过执行 SQL 查询

  • 加载 Parquet、JSON、CSV、文本、Hive、JDBC 等外部数据

  • 将 RDD 转换为数据框

可以通过加载 CSV 文件来创建 DataFrame。我们将查看一个名为statesPopulation.csv的 CSV 文件,它被加载为 DataFrame。

CSV 文件具有 2010 年至 2016 年美国各州人口的以下格式。

年份 人口
阿拉巴马州 2010 4785492
阿拉斯加州 2010 714031
亚利桑那州 2010 6408312
阿肯色州 2010 2921995
加利福尼亚州 2010 37332685

由于此 CSV 具有标题,因此我们可以使用它快速加载到具有隐式模式检测的 DataFrame 中。

scala> val statesDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")
statesDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]

加载 DataFrame 后,可以检查其模式:

scala> statesDF.printSchema
root
 |-- State: string (nullable = true)
 |-- Year: integer (nullable = true)
 |-- Population: integer (nullable = true)

option("header", "true").option("inferschema", "true").option("sep", ",") 告诉 Spark CSV 文件有header;逗号分隔符用于分隔字段/列,还可以隐式推断模式。

DataFrame 通过解析逻辑计划、分析逻辑计划、优化计划,最后执行执行物理计划来工作。

使用 DataFrame 上的 explain 显示执行计划:

scala> statesDF.explain(true)
== Parsed Logical Plan ==
Relation[State#0,Year#1,Population#2] csv
== Analyzed Logical Plan ==
State: string, Year: int, Population: int
Relation[State#0,Year#1,Population#2] csv
== Optimized Logical Plan ==
Relation[State#0,Year#1,Population#2] csv
== Physical Plan ==
*FileScan csv [State#0,Year#1,Population#2] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/states.csv], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<State:string,Year:int,Population:int>

DataFrame 还可以注册为表名(如下所示),然后您可以像关系数据库一样输入 SQL 语句。

scala> statesDF.createOrReplaceTempView("states")

一旦我们将 DataFrame 作为结构化 DataFrame 或表,我们可以运行命令来操作数据:

scala> statesDF.show(5)
scala> spark.sql("select * from states limit 5").show
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
| Alabama|2010| 4785492|
| Alaska|2010| 714031|
| Arizona|2010| 6408312|
| Arkansas|2010| 2921995|
|California|2010| 37332685|
+----------+----+----------+

如果您看到上述代码片段,我们已经编写了类似 SQL 的语句,并使用spark.sql API 执行了它。

请注意,Spark SQL 只是转换为 DataFrame API 以进行执行,SQL 只是用于方便使用的 DSL。

使用 DataFrame 上的sort操作,可以按任何列对 DataFrame 中的行进行排序。我们可以看到使用Population列进行降序sort的效果如下。行按人口数量降序排序。

scala> statesDF.sort(col("Population").desc).show(5)
scala> spark.sql("select * from states order by Population desc limit 5").show
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
|California|2016| 39250017|
|California|2015| 38993940|
|California|2014| 38680810|
|California|2013| 38335203|
|California|2012| 38011074|
+----------+----+----------+

使用groupBy可以按任何列对 DataFrame 进行分组。以下是按State分组行,然后对每个StatePopulation计数进行求和的代码。

scala> statesDF.groupBy("State").sum("Population").show(5)
scala> spark.sql("select State, sum(Population) from states group by State limit 5").show
+---------+---------------+
| State|sum(Population)|
+---------+---------------+
| Utah| 20333580|
| Hawaii| 9810173|
|Minnesota| 37914011|
| Ohio| 81020539|
| Arkansas| 20703849|
+---------+---------------+

使用agg操作,您可以对 DataFrame 的列执行许多不同的操作,例如查找列的minmaxavg。您还可以执行操作并同时重命名列,以适应您的用例。

scala> statesDF.groupBy("State").agg(sum("Population").alias("Total")).show(5)
scala> spark.sql("select State, sum(Population) as Total from states group by State limit 5").show
+---------+--------+
| State| Total|
+---------+--------+
| Utah|20333580|
| Hawaii| 9810173|
|Minnesota|37914011|
| Ohio|81020539|
| Arkansas|20703849|
+---------+--------+

自然,逻辑越复杂,执行计划也越复杂。让我们看看groupByagg API 调用的执行计划,以更好地了解底层发生了什么。以下是显示按State分组和人口总和的执行计划的代码:

scala> statesDF.groupBy("State").agg(sum("Population").alias("Total")).explain(true)
== Parsed Logical Plan ==
'Aggregate [State#0], [State#0, sum('Population) AS Total#31886]
+- Relation[State#0,Year#1,Population#2] csv

== Analyzed Logical Plan ==
State: string, Total: bigint
Aggregate [State#0], [State#0, sum(cast(Population#2 as bigint)) AS Total#31886L]
+- Relation[State#0,Year#1,Population#2] csv

== Optimized Logical Plan ==
Aggregate [State#0], [State#0, sum(cast(Population#2 as bigint)) AS Total#31886L]
+- Project [State#0, Population#2]
 +- Relation[State#0,Year#1,Population#2] csv

== Physical Plan ==
*HashAggregate(keys=[State#0], functions=[sum(cast(Population#2 as bigint))], output=[State#0, Total#31886L])
+- Exchange hashpartitioning(State#0, 200)
 +- *HashAggregate(keys=[State#0], functions=[partial_sum(cast(Population#2 as bigint))], output=[State#0, sum#31892L])
 +- *FileScan csv [State#0,Population#2] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/states.csv], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<State:string,Population:int>

DataFrame 操作可以很好地链接在一起,以便执行可以利用成本优化(钨性能改进和催化剂优化器共同工作)。

我们还可以将操作链接在一条语句中,如下所示,我们不仅按State列对数据进行分组,然后对Population值进行求和,还对 DataFrame 进行排序:

scala> statesDF.groupBy("State").agg(sum("Population").alias("Total")).sort(col("Total").desc).show(5)
scala> spark.sql("select State, sum(Population) as Total from states group by State order by Total desc limit 5").show
+----------+---------+
| State| Total|
+----------+---------+
|California|268280590|
| Texas|185672865|
| Florida|137618322|
| New York|137409471|
| Illinois| 89960023|
+----------+---------+

前面的链式操作包括多个转换和操作,可以使用以下图表进行可视化:

也可以同时创建多个聚合,如下所示:

scala> statesDF.groupBy("State").agg(
             min("Population").alias("minTotal"), 
             max("Population").alias("maxTotal"),        
             avg("Population").alias("avgTotal"))
           .sort(col("minTotal").desc).show(5) 
scala> spark.sql("select State, min(Population) as minTotal, max(Population) as maxTotal, avg(Population) as avgTotal from states group by State order by minTotal desc limit 5").show
+----------+--------+--------+--------------------+
| State|minTotal|maxTotal| avgTotal|
+----------+--------+--------+--------------------+
|California|37332685|39250017|3.8325798571428575E7|
| Texas|25244310|27862596| 2.6524695E7|
| New York|19402640|19747183| 1.962992442857143E7|
| Florida|18849098|20612439|1.9659760285714287E7|
| Illinois|12801539|12879505|1.2851431857142856E7|
+----------+--------+--------+--------------------+

旋转

旋转是将表转换为不同视图的一种很好的方式,更适合进行许多汇总和聚合。这是通过取列的值并使每个值成为实际列来实现的。

为了更好地理解这一点,让我们通过Year来旋转 DataFrame 的行并检查结果,结果显示,现在,列Year通过将每个唯一值转换为实际列创建了几个新列。这样做的最终结果是,现在,我们不仅可以查看年份列,还可以使用按年份创建的列来进行汇总和聚合。

scala> statesDF.groupBy("State").pivot("Year").sum("Population").show(5)
+---------+--------+--------+--------+--------+--------+--------+--------+
| State| 2010| 2011| 2012| 2013| 2014| 2015| 2016|
+---------+--------+--------+--------+--------+--------+--------+--------+
| Utah| 2775326| 2816124| 2855782| 2902663| 2941836| 2990632| 3051217|
| Hawaii| 1363945| 1377864| 1391820| 1406481| 1416349| 1425157| 1428557|
|Minnesota| 5311147| 5348562| 5380285| 5418521| 5453109| 5482435| 5519952|
| Ohio|11540983|11544824|11550839|11570022|11594408|11605090|11614373|
| Arkansas| 2921995| 2939493| 2950685| 2958663| 2966912| 2977853| 2988248|
+---------+--------+--------+--------+--------+--------+--------+--------+

过滤器

DataFrame 还支持过滤器,可以用于快速过滤 DataFrame 行以生成新的 DataFrame。过滤器使得数据的重要转换变得非常重要,可以将 DataFrame 缩小到我们的用例。例如,如果您只想分析加利福尼亚州的情况,那么使用filter API 可以在每个数据分区上消除不匹配的行,从而提高操作的性能。

让我们查看过滤 DataFrame 以仅考虑加利福尼亚州的执行计划。

scala> statesDF.filter("State == 'California'").explain(true)

== Parsed Logical Plan ==
'Filter ('State = California)
+- Relation[State#0,Year#1,Population#2] csv

== Analyzed Logical Plan ==
State: string, Year: int, Population: int
Filter (State#0 = California)
+- Relation[State#0,Year#1,Population#2] csv

== Optimized Logical Plan ==
Filter (isnotnull(State#0) && (State#0 = California))
+- Relation[State#0,Year#1,Population#2] csv

== Physical Plan ==
*Project [State#0, Year#1, Population#2]
+- *Filter (isnotnull(State#0) && (State#0 = California))
 +- *FileScan csv [State#0,Year#1,Population#2] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/states.csv], PartitionFilters: [], PushedFilters: [IsNotNull(State), EqualTo(State,California)], ReadSchema: struct<State:string,Year:int,Population:int>

现在我们可以看到执行计划,让我们执行filter命令,如下所示:

scala> statesDF.filter("State == 'California'").show
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
|California|2010| 37332685|
|California|2011| 37676861|
|California|2012| 38011074|
|California|2013| 38335203|
|California|2014| 38680810|
|California|2015| 38993940|
|California|2016| 39250017|
+----------+----+----------+

用户定义函数(UDFs)

UDFs 定义了扩展 Spark SQL 功能的新基于列的函数。通常,Spark 提供的内置函数不能处理我们确切的需求。在这种情况下,Apache Spark 支持创建可以使用的 UDF。

udf()在内部调用一个案例类用户定义函数,它本身在内部调用 ScalaUDF。

让我们通过一个简单将 State 列值转换为大写的 UDF 示例来进行说明。

首先,我们在 Scala 中创建我们需要的函数。

import org.apache.spark.sql.functions._

scala> val toUpper: String => String = _.toUpperCase
toUpper: String => String = <function1>

然后,我们必须将创建的函数封装在udf中以创建 UDF。

scala> val toUpperUDF = udf(toUpper)
toUpperUDF: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType)))

现在我们已经创建了udf,我们可以使用它将 State 列转换为大写。

scala> statesDF.withColumn("StateUpperCase", toUpperUDF(col("State"))).show(5)
+----------+----+----------+--------------+
| State|Year|Population|StateUpperCase|
+----------+----+----------+--------------+
| Alabama|2010| 4785492| ALABAMA|
| Alaska|2010| 714031| ALASKA|
| Arizona|2010| 6408312| ARIZONA|
| Arkansas|2010| 2921995| ARKANSAS|
|California|2010| 37332685| CALIFORNIA|
+----------+----+----------+--------------+

数据的模式结构

模式是数据结构的描述,可以是隐式的或显式的。

由于 DataFrame 在内部基于 RDD,因此有两种将现有 RDD 转换为数据集的主要方法。可以使用反射将 RDD 转换为数据集,以推断 RDD 的模式。创建数据集的第二种方法是通过编程接口,使用该接口可以获取现有 RDD 并提供模式以将 RDD 转换为具有模式的数据集。

为了通过反射推断模式从 RDD 创建 DataFrame,Spark 的 Scala API 提供了可以用来定义表模式的案例类。DataFrame 是通过 RDD 以编程方式创建的,因为在所有情况下都不容易使用案例类。例如,在 1000 列表上创建案例类是耗时的。

隐式模式

让我们看一个将CSV(逗号分隔值)文件加载到 DataFrame 中的示例。每当文本文件包含标题时,读取 API 可以通过读取标题行来推断模式。我们还可以选择指定用于拆分文本文件行的分隔符。

我们从标题行推断模式读取csv并使用逗号(,)作为分隔符。我们还展示了schema命令和printSchema命令来验证输入文件的模式。

scala> val statesDF = spark.read.option("header", "true")
                                .option("inferschema", "true")
                                .option("sep", ",")
                                .csv("statesPopulation.csv")
statesDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]

scala> statesDF.schema
res92: org.apache.spark.sql.types.StructType = StructType(
                                                  StructField(State,StringType,true),
                                                  StructField(Year,IntegerType,true),
                                                  StructField(Population,IntegerType,true))
scala> statesDF.printSchema
root
 |-- State: string (nullable = true)
 |-- Year: integer (nullable = true)
 |-- Population: integer (nullable = true)

显式模式

使用StructType来描述模式,它是StructField对象的集合。

StructTypeStructField属于org.apache.spark.sql.types包。

诸如IntegerTypeStringType之类的数据类型也属于org.apache.spark.sql.types包。

使用这些导入,我们可以定义一个自定义的显式模式。

首先,导入必要的类:

scala> import org.apache.spark.sql.types.{StructType, IntegerType, StringType}
import org.apache.spark.sql.types.{StructType, IntegerType, StringType}

定义一个包含两列/字段的模式-一个Integer,后面是一个String

scala> val schema = new StructType().add("i", IntegerType).add("s", StringType)
schema: org.apache.spark.sql.types.StructType = StructType(StructField(i,IntegerType,true), StructField(s,StringType,true))

打印新创建的schema很容易:

scala> schema.printTreeString
root
 |-- i: integer (nullable = true)
 |-- s: string (nullable = true)

还有一个选项可以打印 JSON,如下所示,使用prettyJson函数:

scala> schema.prettyJson
res85: String =
{
 "type" : "struct",
 "fields" : [ {
 "name" : "i",
 "type" : "integer",
 "nullable" : true,
 "metadata" : { }
 }, {
 "name" : "s",
 "type" : "string",
 "nullable" : true,
 "metadata" : { }
 } ]
}

Spark SQL 的所有数据类型都位于包org.apache.spark.sql.types中。您可以通过以下方式访问它们:

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

Encoders

Spark 2.x 支持一种不同的方式来定义复杂数据类型的模式。首先,让我们来看一个简单的例子。

为了使用 Encoders,必须使用 import 语句导入 Encoders:

import org.apache.spark.sql.Encoders 

让我们来看一个简单的例子,定义一个元组作为数据类型在数据集 API 中使用:


scala> Encoders.product[(Integer, String)].schema.printTreeString
root
 |-- _1: integer (nullable = true)
 |-- _2: string (nullable = true)

上述代码看起来在任何时候都很复杂,所以我们也可以为我们的需求定义一个案例类,然后使用它。我们可以定义一个名为Record的案例类,有两个字段-一个Integer和一个String

scala> case class Record(i: Integer, s: String)
defined class Record

使用Encoders,我们可以轻松地在案例类之上创建一个schema,从而使我们能够轻松使用各种 API:

scala> Encoders.product[Record].schema.printTreeString
root
 |-- i: integer (nullable = true)
 |-- s: string (nullable = true)

Spark SQL 的所有数据类型都位于包**org.apache.spark.sql.types**中。您可以通过以下方式访问它们:

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

您应该在代码中使用DataTypes对象来创建复杂的 Spark SQL 类型,如数组或映射,如下所示:

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

scala> val arrayType = DataTypes.createArrayType(IntegerType)
arrayType: org.apache.spark.sql.types.ArrayType = ArrayType(IntegerType,true)

以下是 Spark SQL API 中支持的数据类型:

数据类型 Scala 中的值类型 访问或创建数据类型的 API
ByteType Byte ByteType
ShortType Short ShortType
IntegerType Int IntegerType
LongType Long LongType
FloatType Float FloatType
DoubleType Double DoubleType
DecimalType java.math.BigDecimal DecimalType
StringType String StringType
BinaryType Array[Byte] BinaryType
BooleanType Boolean BooleanType
TimestampType java.sql.Timestamp TimestampType
DateType java.sql.Date DateType
ArrayType scala.collection.Seq ArrayType(elementType, [containsNull])
MapType scala.collection.Map MapType(keyType, valueType, [valueContainsNull]) 注意:valueContainsNull的默认值为true
StructType org.apache.spark.sql.Row StructType(fields) 注意:fields 是StructFieldsSeq。另外,不允许有相同名称的两个字段。

加载和保存数据集

我们需要将数据读入集群作为输入和输出,或者将结果写回存储,以便对我们的代码进行任何实际操作。输入数据可以从各种数据集和来源中读取,如文件、Amazon S3 存储、数据库、NoSQL 和 Hive,输出也可以类似地保存到文件、S3、数据库、Hive 等。

几个系统通过连接器支持 Spark,并且随着更多系统接入 Spark 处理框架,这个数字正在日益增长。

加载数据集

Spark SQL 可以通过DataFrameReader接口从外部存储系统,如文件、Hive 表和 JDBC 数据库中读取数据。

API 调用的格式是spark.read.inputtype

  • Parquet

  • CSV

  • Hive 表

  • JDBC

  • ORC

  • 文本

  • JSON

让我们来看一些简单的例子,将 CSV 文件读入 DataFrame 中:

scala> val statesPopulationDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")
statesPopulationDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]

scala> val statesTaxRatesDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesTaxRates.csv")
statesTaxRatesDF: org.apache.spark.sql.DataFrame = [State: string, TaxRate: double]

保存数据集

Spark SQL 可以将数据保存到外部存储系统,如文件、Hive 表和 JDBC 数据库,通过DataFrameWriter接口。

API 调用的格式是dataframe``.write.outputtype

  • Parquet

  • ORC

  • 文本

  • Hive 表

  • JSON

  • CSV

  • JDBC

让我们来看一些将 DataFrame 写入或保存到 CSV 文件的例子:

scala> statesPopulationDF.write.option("header", "true").csv("statesPopulation_dup.csv")

scala> statesTaxRatesDF.write.option("header", "true").csv("statesTaxRates_dup.csv")

聚合

聚合是根据条件收集数据并对数据进行分析的方法。聚合对于理解各种规模的数据非常重要,因为仅仅拥有原始数据记录对于大多数用例来说并不那么有用。

例如,如果你看下面的表,然后看聚合视图,很明显,仅仅原始记录并不能帮助你理解数据。

想象一个包含世界上每个城市每天的一次温度测量的表,为期五年。

下面是一个包含每个城市每天平均温度记录的表:

城市 日期 温度
Boston 12/23/2016 32
New York 12/24/2016 36
Boston 12/24/2016 30
Philadelphia 12/25/2016 34
Boston 12/25/2016 28

如果我们想要计算上表中我们有测量数据的所有天的每个城市的平均温度,我们可以看到类似以下表的结果:

城市 平均温度
Boston 30 - (32 + 30 + 28)/3
New York 36
Philadelphia 34

聚合函数

大多数聚合可以使用org.apache.spark.sql.functions包中的函数来完成。此外,还可以创建自定义聚合函数,也称为用户定义的聚合函数UDAF)。

每个分组操作都返回一个RelationalGroupeddataset,您可以在其中指定聚合。

我们将加载示例数据,以说明本节中所有不同类型的聚合函数:

val statesPopulationDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")

计数

计数是最基本的聚合函数,它只是计算指定列的行数。扩展是countDistinct,它还可以消除重复项。

count API 有几种实现,如下所示。使用的确切 API 取决于特定的用例:

def count(columnName: String): TypedColumn[Any, Long]
Aggregate function: returns the number of items in a group.

def count(e: Column): Column
Aggregate function: returns the number of items in a group.

def countDistinct(columnName: String, columnNames: String*): Column
Aggregate function: returns the number of distinct items in a group.

def countDistinct(expr: Column, exprs: Column*): Column
Aggregate function: returns the number of distinct items in a group.

让我们看看如何在 DataFrame 上调用countcountDistinct来打印行计数:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("*")).agg(count("State")).show
scala> statesPopulationDF.select(count("State")).show
+------------+
|count(State)|
+------------+
| 350|
+------------+

scala> statesPopulationDF.select(col("*")).agg(countDistinct("State")).show
scala> statesPopulationDF.select(countDistinct("State")).show
+---------------------+
|count(DISTINCT State)|
+---------------------+
| 50|

首先

获取RelationalGroupeddataset中的第一条记录。

first API 有几种实现,如下所示。使用的确切 API 取决于特定的用例:

def first(columnName: String): Column
Aggregate function: returns the first value of a column in a group.

def first(e: Column): Column
Aggregate function: returns the first value in a group.

def first(columnName: String, ignoreNulls: Boolean): Column
Aggregate function: returns the first value of a column in a group.

def first(e: Column, ignoreNulls: Boolean): Column
Aggregate function: returns the first value in a group.

让我们看一个在 DataFrame 上调用first来输出第一行的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(first("State")).show
+-------------------+
|first(State, false)|
+-------------------+
| Alabama|
+-------------------+

最后

获取RelationalGroupeddataset中的最后一条记录。

last API 有几种实现,如下所示。使用的确切 API 取决于特定的用例:

def last(columnName: String): Column
Aggregate function: returns the last value of the column in a group.

def last(e: Column): Column
Aggregate function: returns the last value in a group.

def last(columnName: String, ignoreNulls: Boolean): Column
Aggregate function: returns the last value of the column in a group.

def last(e: Column, ignoreNulls: Boolean): Column
Aggregate function: returns the last value in a group.

让我们看一个在 DataFrame 上调用last来输出最后一行的例子。

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(last("State")).show
+------------------+
|last(State, false)|
+------------------+
| Wyoming|
+------------------+

approx_count_distinct

近似不同计数要快得多,它可以近似计算不同记录的数量,而不是进行精确计数,后者通常需要大量的洗牌和其他操作。虽然近似计数不是 100%准确,但许多用例即使没有精确计数也可以表现得同样好。

approx_count_distinct API 有几种实现,如下所示。使用的确切 API 取决于特定的用例。

def approx_count_distinct(columnName: String, rsd: Double): Column
Aggregate function: returns the approximate number of distinct items in a group.

def approx_count_distinct(e: Column, rsd: Double): Column
Aggregate function: returns the approximate number of distinct items in a group.

def approx_count_distinct(columnName: String): Column
Aggregate function: returns the approximate number of distinct items in a group.

def approx_count_distinct(e: Column): Column
Aggregate function: returns the approximate number of distinct items in a group.

让我们看一个在 DataFrame 上调用approx_count_distinct来打印 DataFrame 的近似计数的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("*")).agg(approx_count_distinct("State")).show
+----------------------------+
|approx_count_distinct(State)|
+----------------------------+
| 48|
+----------------------------+

scala> statesPopulationDF.select(approx_count_distinct("State", 0.2)).show
+----------------------------+
|approx_count_distinct(State)|
+----------------------------+
| 49|
+----------------------------+

最小

DataFrame 中某一列的最小值。例如,如果要查找城市的最低温度。

min API 有几种实现,如下所示。使用的确切 API 取决于特定的用例:

def min(columnName: String): Column
Aggregate function: returns the minimum value of the column in a group.

def min(e: Column): Column
Aggregate function: returns the minimum value of the expression in a group.

让我们看一个在 DataFrame 上调用min来打印最小人口的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(min("Population")).show
+---------------+
|min(Population)|
+---------------+
| 564513|
+---------------+

最大

DataFrame 中某一列的最大值。例如,如果要查找城市的最高温度。

max API 有几种实现,如下所示。使用的确切 API 取决于特定的用例。

def max(columnName: String): Column
Aggregate function: returns the maximum value of the column in a group.

def max(e: Column): Column
Aggregate function: returns the maximum value of the expression in a group.

让我们看一个在 DataFrame 上调用max来打印最大人口的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(max("Population")).show
+---------------+
|max(Population)|
+---------------+
| 39250017|
+---------------+

平均

值的平均数是通过将值相加并除以值的数量来计算的。

1,2,3 的平均值是(1 + 2 + 3) / 3 = 6/3 = 2

avg API 有几种实现,如下所示。使用的确切 API 取决于特定的用例:

def avg(columnName: String): Column
Aggregate function: returns the average of the values in a group.

def avg(e: Column): Column
Aggregate function: returns the average of the values in a group.

让我们看一个在 DataFrame 上调用avg来打印平均人口的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(avg("Population")).show
+-----------------+
| avg(Population)|
+-----------------+
|6253399.371428572|
+-----------------+

总和

计算列值的总和。可以选择使用sumDistinct仅添加不同的值。

sum API 有几种实现,如下所示。使用的确切 API 取决于特定的用例:

def sum(columnName: String): Column
Aggregate function: returns the sum of all values in the given column.

def sum(e: Column): Column
Aggregate function: returns the sum of all values in the expression.

def sumDistinct(columnName: String): Column
Aggregate function: returns the sum of distinct values in the expression

def sumDistinct(e: Column): Column
Aggregate function: returns the sum of distinct values in the expression.

让我们看一个在 DataFrame 上调用sum的例子,打印Population的总和。

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(sum("Population")).show
+---------------+
|sum(Population)|
+---------------+
| 2188689780|
+---------------+

峰度

峰度是量化分布形状差异的一种方式,这些分布在均值和方差方面可能看起来非常相似,但实际上是不同的。在这种情况下,峰度成为分布尾部的权重与分布中部的权重相比的一个很好的度量。

kurtosis API 有几种实现,具体使用的 API 取决于特定的用例。

def kurtosis(columnName: String): Column
Aggregate function: returns the kurtosis of the values in a group.

def kurtosis(e: Column): Column
Aggregate function: returns the kurtosis of the values in a group.

让我们看一个在 DataFrame 的Population列上调用kurtosis的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(kurtosis("Population")).show
+--------------------+
|kurtosis(Population)|
+--------------------+
| 7.727421920829375|
+--------------------+

Skewness

Skewness 测量数据中值围绕平均值或均值的不对称性。

skewness API 有几种实现,具体使用的 API 取决于特定的用例。

def skewness(columnName: String): Column
Aggregate function: returns the skewness of the values in a group.

def skewness(e: Column): Column
Aggregate function: returns the skewness of the values in a group.

让我们看一个在人口列上调用skewness的 DataFrame 的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(skewness("Population")).show
+--------------------+
|skewness(Population)|
+--------------------+
| 2.5675329049100024|
+--------------------+

方差

方差是每个值与均值的差的平方的平均值。

var API 有几种实现,具体使用的 API 取决于特定的用例:

def var_pop(columnName: String): Column
Aggregate function: returns the population variance of the values in a group.

def var_pop(e: Column): Column
Aggregate function: returns the population variance of the values in a group.

def var_samp(columnName: String): Column
Aggregate function: returns the unbiased variance of the values in a group.

def var_samp(e: Column): Column
Aggregate function: returns the unbiased variance of the values in a group.

现在,让我们看一个在测量Population方差的 DataFrame 上调用var_pop的例子:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(var_pop("Population")).show
+--------------------+
| var_pop(Population)|
+--------------------+
|4.948359064356177E13|
+--------------------+

标准差

标准差是方差的平方根(见前文)。

stddev API 有几种实现,具体使用的 API 取决于特定的用例:

def stddev(columnName: String): Column
Aggregate function: alias for stddev_samp.

def stddev(e: Column): Column
Aggregate function: alias for stddev_samp.

def stddev_pop(columnName: String): Column
Aggregate function: returns the population standard deviation of the expression in a group.

def stddev_pop(e: Column): Column
Aggregate function: returns the population standard deviation of the expression in a group.

def stddev_samp(columnName: String): Column
Aggregate function: returns the sample standard deviation of the expression in a group.

def stddev_samp(e: Column): Column
Aggregate function: returns the sample standard deviation of the expression in a group.

让我们看一个在 DataFrame 上调用stddev的例子,打印Population的标准差:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(stddev("Population")).show
+-----------------------+
|stddev_samp(Population)|
+-----------------------+
| 7044528.191173398|
+-----------------------+

协方差

协方差是两个随机变量联合变异性的度量。如果一个变量的较大值主要对应于另一个变量的较大值,并且较小值也是如此,那么这些变量倾向于显示相似的行为,协方差是正的。如果相反是真的,并且一个变量的较大值对应于另一个变量的较小值,那么协方差是负的。

covar API 有几种实现,具体使用的 API 取决于特定的用例。

def covar_pop(columnName1: String, columnName2: String): Column
Aggregate function: returns the population covariance for two columns.

def covar_pop(column1: Column, column2: Column): Column
Aggregate function: returns the population covariance for two columns.

def covar_samp(columnName1: String, columnName2: String): Column
Aggregate function: returns the sample covariance for two columns.

def covar_samp(column1: Column, column2: Column): Column
Aggregate function: returns the sample covariance for two columns.

让我们看一个在 DataFrame 上调用covar_pop的例子,计算年份和人口列之间的协方差:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(covar_pop("Year", "Population")).show
+---------------------------+
|covar_pop(Year, Population)|
+---------------------------+
| 183977.56000006935|
+---------------------------+

groupBy

数据分析中常见的任务是将数据分组为分组类别,然后对结果数据组执行计算。

理解分组的一种快速方法是想象被要求迅速评估办公室所需的物品。您可以开始四处看看,并将不同类型的物品分组,例如笔、纸、订书机,并分析您拥有的和您需要的。

让我们在DataFrame上运行groupBy函数,打印每个州的聚合计数:

scala> statesPopulationDF.groupBy("State").count.show(5)
+---------+-----+
| State|count|
+---------+-----+
| Utah| 7|
| Hawaii| 7|
|Minnesota| 7|
| Ohio| 7|
| Arkansas| 7|
+---------+-----+

您还可以groupBy,然后应用先前看到的任何聚合函数,例如minmaxavgstddev等:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.groupBy("State").agg(min("Population"), avg("Population")).show(5)
+---------+---------------+--------------------+
| State|min(Population)| avg(Population)|
+---------+---------------+--------------------+
| Utah| 2775326| 2904797.1428571427|
| Hawaii| 1363945| 1401453.2857142857|
|Minnesota| 5311147| 5416287.285714285|
| Ohio| 11540983|1.1574362714285715E7|
| Arkansas| 2921995| 2957692.714285714|
+---------+---------------+--------------------+

Rollup

Rollup 是用于执行分层或嵌套计算的多维聚合。例如,如果我们想显示每个州+年份组的记录数,以及每个州的记录数(聚合所有年份以给出每个State的总数,而不考虑Year),我们可以使用rollup如下:

scala> statesPopulationDF.rollup("State", "Year").count.show(5)
+------------+----+-----+
| State|Year|count|
+------------+----+-----+
|South Dakota|2010| 1|
| New York|2012| 1|
| California|2014| 1|
| Wyoming|2014| 1|
| Hawaii|null| 7|
+------------+----+-----+

rollup计算州和年份的计数,例如加利福尼亚+2014,以及加利福尼亚州(所有年份的总和)。

Cube

Cube 是用于执行分层或嵌套计算的多维聚合,就像 rollup 一样,但不同之处在于 cube 对所有维度执行相同的操作。例如,如果我们想显示每个StateYear组的记录数,以及每个State的记录数(聚合所有年份以给出每个State的总数,而不考虑Year),我们可以使用 rollup 如下。此外,cube还显示每年的总数(不考虑State):

scala> statesPopulationDF.cube("State", "Year").count.show(5)
+------------+----+-----+
| State|Year|count|
+------------+----+-----+
|South Dakota|2010| 1|
| New York|2012| 1|
| null|2014| 50|
| Wyoming|2014| 1|
| Hawaii|null| 7|
+------------+----+-----+

窗口函数

窗口函数允许您在数据窗口上执行聚合,而不是整个数据或一些经过筛选的数据。这些窗口函数的用例包括:

  • 累积总和

  • 与先前相同键的前一个值的增量

  • 加权移动平均

理解窗口函数的最佳方法是想象一个滑动窗口覆盖整个数据集。您可以指定一个窗口,查看三行 T-1、T 和 T+1,并进行简单的计算。您还可以指定最新/最近的十个值的窗口:

窗口规范的 API 需要三个属性,partitionBy()orderBy()rowsBetween()partitionBy将数据分成由partitionBy()指定的分区/组。orderBy()用于对数据进行排序,以便在每个数据分区内进行排序。

rowsBetween()指定了滑动窗口的窗口帧或跨度来执行计算。

要尝试窗口函数,需要某些包。您可以使用导入指令导入必要的包,如下所示:

import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.functions.col import org.apache.spark.sql.functions.max

现在,您已经准备好编写一些代码来了解窗口函数。让我们为按Population排序并按State分区的分区创建一个窗口规范。还要指定我们希望将当前行之前的所有行视为Window的一部分。

 val windowSpec = Window
 .partitionBy("State")
 .orderBy(col("Population").desc)
 .rowsBetween(Window.unboundedPreceding, Window.currentRow)

计算窗口规范上的rank。结果将是一个排名(行号)添加到每一行,只要它在指定的Window内。在这个例子中,我们选择按State进行分区,然后进一步按降序对每个State的行进行排序。因此,所有州的行都有自己的排名号码分配。

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("State"), col("Year"), max("Population").over(windowSpec), rank().over(windowSpec)).sort("State", "Year").show(10)
+-------+----+------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
| State|Year|max(Population) OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|RANK() OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|
+-------+----+------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
|Alabama|2010| 4863300| 6|
|Alabama|2011| 4863300| 7|
|Alabama|2012| 4863300| 5|
|Alabama|2013| 4863300| 4|
|Alabama|2014| 4863300| 3|

ntiles

ntiles 是窗口上的一种常见聚合,通常用于将输入数据集分成 n 部分。例如,在预测分析中,通常使用十分位数(10 部分)首先对数据进行分组,然后将其分成 10 部分以获得数据的公平分布。这是窗口函数方法的自然功能,因此 ntiles 是窗口函数如何帮助的一个很好的例子。

例如,如果我们想要按statesPopulationDFState进行分区(窗口规范如前所示),按人口排序,然后分成两部分,我们可以在windowspec上使用ntile

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("State"), col("Year"), ntile(2).over(windowSpec), rank().over(windowSpec)).sort("State", "Year").show(10)
+-------+----+-----------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
| State|Year|ntile(2) OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|RANK() OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|
+-------+----+-----------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
|Alabama|2010| 2| 6|
|Alabama|2011| 2| 7|
|Alabama|2012| 2| 5|
|Alabama|2013| 1| 4|
|Alabama|2014| 1| 3|
|Alabama|2015| 1| 2|
|Alabama|2016| 1| 1|
| Alaska|2010| 2| 7|
| Alaska|2011| 2| 6|
| Alaska|2012| 2| 5|
+-------+----+-----------------------------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------

如前所示,我们已经使用Window函数和ntile()一起将每个State的行分成两个相等的部分。

这个函数的一个常见用途是计算数据科学模型中使用的十分位数。

连接

在传统数据库中,连接用于将一个交易表与另一个查找表连接,以生成更完整的视图。例如,如果您有一个按客户 ID 分类的在线交易表,另一个包含客户城市和客户 ID 的表,您可以使用连接来生成有关按城市分类的交易的报告。

交易表:以下表有三列,CustomerID购买的物品,以及客户为该物品支付了多少钱:

CustomerID 购买的物品 支付的价格
1 Headphone 25.00
2 手表 100.00
3 键盘 20.00
1 鼠标 10.00
4 电缆 10.00
3 Headphone 30.00

客户信息表:以下表有两列,CustomerID和客户居住的City

CustomerID City
1 波士顿
2 纽约
3 费城
4 波士顿

将交易表与客户信息表连接将生成以下视图:

CustomerID 购买的物品 支付的价格 城市
1 Headphone 25.00 波士顿
2 手表 100.00 纽约
3 键盘 20.00 费城
1 鼠标 10.00 波士顿
4 电缆 10.00 波士顿
3 Headphone 30.00 Philadelphia

现在,我们可以使用这个连接的视图来生成按城市总销售价格的报告:

城市 #物品 总销售价格
波士顿 3 45.00
Philadelphia 2 50.00
New York 1 100.00

连接是 Spark SQL 的重要功能,因为它使您能够将两个数据集合并在一起,正如之前所见。当然,Spark 不仅仅是用来生成报告的,而是用来处理 PB 级别的数据,处理实时流处理用例,机器学习算法或纯粹的分析。为了实现这些目标,Spark 提供了所需的 API 函数。

两个数据集之间的典型连接是使用左侧和右侧数据集的一个或多个键进行的,然后对键集合上的条件表达式进行布尔表达式的评估。如果布尔表达式的结果为 true,则连接成功,否则连接的 DataFrame 将不包含相应的连接。

连接 API 有 6 种不同的实现:

join(right: dataset[_]): DataFrame
Condition-less inner join

join(right: dataset[_], usingColumn: String): DataFrame
Inner join with a single column

join(right: dataset[_], usingColumns: Seq[String]): DataFrame 
Inner join with multiple columns

join(right: dataset[_], usingColumns: Seq[String], joinType: String): DataFrame
Join with multiple columns and a join type (inner, outer,....)

join(right: dataset[_], joinExprs: Column): DataFrame
Inner Join using a join expression

join(right: dataset[_], joinExprs: Column, joinType: String): DataFrame 
Join using a Join expression and a join type (inner, outer, ...)

我们将使用其中一个 API 来了解如何使用连接 API;然而,您可以根据用例选择使用其他 API:

def   join(right: dataset[_], joinExprs: Column, joinType: String): DataFrame Join with another DataFrame using the given join expression

right: Right side of the join.
joinExprs: Join expression.
joinType : Type of join to perform. Default is *inner* join

// Scala:
import org.apache.spark.sql.functions._
import spark.implicits._
df1.join(df2, $"df1Key" === $"df2Key", "outer") 

请注意,连接将在接下来的几个部分中详细介绍。

连接的内部工作方式

连接通过使用多个执行器对 DataFrame 的分区进行操作。然而,实际操作和随后的性能取决于join的类型和被连接的数据集的性质。在下一节中,我们将看看连接的类型。

Shuffle 连接

两个大数据集之间的连接涉及到分区连接,其中左侧和右侧数据集的分区被分布到执行器上。Shuffles 是昂贵的,重要的是要分析逻辑,以确保分区和 Shuffles 的分布是最优的。以下是内部展示 Shuffle 连接的示例:

广播连接

通过将较小的数据集广播到所有执行器,可以对一个大数据集和一个小数据集进行连接,其中左侧数据集的分区存在。以下是广播连接内部工作的示例:

连接类型

以下是不同类型连接的表格。这很重要,因为在连接两个数据集时所做的选择在输出和性能上都有很大的区别。

Join type Description
inner 内连接将left中的每一行与right中的行进行比较,并仅在两者都具有非 NULL 值时才组合匹配的leftright数据集的行。
cross cross join 将left中的每一行与right中的每一行匹配,生成笛卡尔积。
outer, full, fullouter full outer Join 给出leftright中的所有行,如果只在rightleft中,则填充 NULL。
leftanti leftanti Join 仅基于right一侧的不存在给出left中的行。
left, leftouter leftouter Join 给出left中的所有行以及leftright的公共行(内连接)。如果right中没有,则填充 NULL。
leftsemi leftsemi Join 仅基于right一侧的存在给出left中的行。不包括right一侧的值。
right, rightouter rightouter Join 给出right中的所有行以及leftright的公共行(内连接)。如果left中没有,则填充 NULL。

我们将使用示例数据集来研究不同连接类型的工作方式。

scala> val statesPopulationDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")
statesPopulationDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]

scala> val statesTaxRatesDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesTaxRates.csv")
statesTaxRatesDF: org.apache.spark.sql.DataFrame = [State: string, TaxRate: double]

scala> statesPopulationDF.count
res21: Long = 357

scala> statesTaxRatesDF.count
res32: Long = 47

%sql
statesPopulationDF.createOrReplaceTempView("statesPopulationDF")
statesTaxRatesDF.createOrReplaceTempView("statesTaxRatesDF")

内连接

当州在两个数据集中都不为 NULL 时,内连接会给出statesPopulationDFstatesTaxRatesDF的行。

通过州列连接两个数据集如下:

val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "inner")

%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF INNER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")

scala> joinDF.count
res22: Long = 329

scala> joinDF.show
+--------------------+----+----------+--------------------+-------+
| State|Year|Population| State|TaxRate|
+--------------------+----+----------+--------------------+-------+
| Alabama|2010| 4785492| Alabama| 4.0|
| Arizona|2010| 6408312| Arizona| 5.6|
| Arkansas|2010| 2921995| Arkansas| 6.5|
| California|2010| 37332685| California| 7.5|
| Colorado|2010| 5048644| Colorado| 2.9|
| Connecticut|2010| 3579899| Connecticut| 6.35|

您可以在joinDF上运行explain()来查看执行计划:

scala> joinDF.explain
== Physical Plan ==
*BroadcastHashJoin [State#570], [State#577], Inner, BuildRight
:- *Project [State#570, Year#571, Population#572]
: +- *Filter isnotnull(State#570)
: +- *FileScan csv [State#570,Year#571,Population#572] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/spark-2.1.0-bin-hadoop2.7/statesPopulation.csv], PartitionFilters: [], PushedFilters: [IsNotNull(State)], ReadSchema: struct<State:string,Year:int,Population:int>
+- BroadcastExchange HashedRelationBroadcastMode(List(input[0, string, true]))
 +- *Project [State#577, TaxRate#578]
 +- *Filter isnotnull(State#577)
 +- *FileScan csv [State#577,TaxRate#578] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/spark-2.1.0-bin-hadoop2.7/statesTaxRates.csv], PartitionFilters: [], PushedFilters: [IsNotNull(State)], ReadSchema: struct<State:string,TaxRate:double>

Left outer join

Left outer join 结果包括statesPopulationDF中的所有行,包括statesPopulationDFstatesTaxRatesDF中的任何公共行。

通过州列连接两个数据集,如下所示:

val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "leftouter")

%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF LEFT OUTER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")

scala> joinDF.count
res22: Long = 357

scala> joinDF.show(5)
+----------+----+----------+----------+-------+
| State|Year|Population| State|TaxRate|
+----------+----+----------+----------+-------+
| Alabama|2010| 4785492| Alabama| 4.0|
| Alaska|2010| 714031| null| null|
| Arizona|2010| 6408312| Arizona| 5.6|
| Arkansas|2010| 2921995| Arkansas| 6.5|
|California|2010| 37332685|California| 7.5|
+----------+----+----------+----------+-------+

Right outer join

Right outer join 结果包括statesTaxRatesDF中的所有行,包括statesPopulationDFstatesTaxRatesDF中的任何公共行。

按照State列连接两个数据集如下:

val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "rightouter")

%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF RIGHT OUTER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")

scala> joinDF.count
res22: Long = 323

scala> joinDF.show
+--------------------+----+----------+--------------------+-------+
| State|Year|Population| State|TaxRate|
+--------------------+----+----------+--------------------+-------+
| Colorado|2011| 5118360| Colorado| 2.9|
| Colorado|2010| 5048644| Colorado| 2.9|
| null|null| null|Connecticut| 6.35|
| Florida|2016| 20612439| Florida| 6.0|
| Florida|2015| 20244914| Florida| 6.0|
| Florida|2014| 19888741| Florida| 6.0|

外连接

外连接结果包括statesPopulationDFstatesTaxRatesDF中的所有行。

按照State列连接两个数据集如下:

val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "fullouter")

%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF FULL OUTER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")

scala> joinDF.count
res22: Long = 351

scala> joinDF.show
+--------------------+----+----------+--------------------+-------+
| State|Year|Population| State|TaxRate|
+--------------------+----+----------+--------------------+-------+
| Delaware|2010| 899816| null| null|
| Delaware|2011| 907924| null| null|
| West Virginia|2010| 1854230| West Virginia| 6.0|
| West Virginia|2011| 1854972| West Virginia| 6.0|
| Missouri|2010| 5996118| Missouri| 4.225|
| null|null| null|  Connecticut|   6.35|

左反连接

左反连接的结果只包括statesPopulationDF中的行,如果且仅如果在statesTaxRatesDF中没有相应的行。

按照以下方式通过State列连接两个数据集:

val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "leftanti")

%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF LEFT ANTI JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")

scala> joinDF.count
res22: Long = 28

scala> joinDF.show(5)
+--------+----+----------+
| State|Year|Population|
+--------+----+----------+
| Alaska|2010| 714031|
|Delaware|2010| 899816|
| Montana|2010| 990641|
| Oregon|2010| 3838048|
| Alaska|2011| 722713|
+--------+----+----------+

左半连接

左半连接的结果只包括statesPopulationDF中的行,如果且仅如果在statesTaxRatesDF中有相应的行。

按照州列连接两个数据集如下:

val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "leftsemi")

%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF LEFT SEMI JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")

scala> joinDF.count
res22: Long = 322

scala> joinDF.show(5)
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
| Alabama|2010| 4785492|
| Arizona|2010| 6408312|
| Arkansas|2010| 2921995|
|California|2010| 37332685|
| Colorado|2010| 5048644|
+----------+----+----------+

交叉连接

交叉连接将left中的每一行与right中的每一行进行匹配,生成笛卡尔乘积。

按照以下方式通过State列连接两个数据集:

scala> val joinDF=statesPopulationDF.crossJoin(statesTaxRatesDF)
joinDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 3 more fields]

%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF CROSS JOIN statesTaxRatesDF")

scala> joinDF.count
res46: Long = 16450

scala> joinDF.show(10)
+-------+----+----------+-----------+-------+
| State|Year|Population| State|TaxRate|
+-------+----+----------+-----------+-------+
|Alabama|2010| 4785492| Alabama| 4.0|
|Alabama|2010| 4785492| Arizona| 5.6|
|Alabama|2010| 4785492| Arkansas| 6.5|
|Alabama|2010| 4785492| California| 7.5|
|Alabama|2010| 4785492| Colorado| 2.9|
|Alabama|2010| 4785492|Connecticut| 6.35|
|Alabama|2010| 4785492| Florida| 6.0|
|Alabama|2010| 4785492| Georgia| 4.0|
|Alabama|2010| 4785492| Hawaii| 4.0|
|Alabama|2010| 4785492| Idaho| 6.0|
+-------+----+----------+-----------+-------+

您还可以使用交叉连接类型的连接,而不是调用交叉连接 API。statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State").isNotNull, "cross").count

连接的性能影响

选择的连接类型直接影响连接的性能。这是因为连接需要在执行任务之间对数据进行洗牌,因此在使用连接时需要考虑不同的连接,甚至连接的顺序。

以下是编写Join代码时可以参考的表:

连接类型 性能考虑和提示
inner 内连接要求左表和右表具有相同的列。如果左侧或右侧的键有重复或多个副本,连接将迅速膨胀成一种笛卡尔连接,完成时间比设计正确以最小化多个键的连接要长得多。
cross Cross Join 将left中的每一行与right中的每一行进行匹配,生成笛卡尔乘积。这需要谨慎使用,因为这是性能最差的连接,只能在特定用例中使用。
outer, full, fullouter Fullouter Join 给出leftright中的所有行,如果只在rightleft中,则填充 NULL。如果在共同点很少的表上使用,可能导致非常大的结果,从而降低性能。
leftanti Leftanti Join 仅基于right一侧的不存在给出left中的行。性能非常好,因为只考虑一个表,另一个表只需检查连接条件。
left, leftouter Leftouter Join 给出left中的所有行以及leftright中的共同行(内连接)。如果right中没有,则填充 NULL。如果在共同点很少的表上使用,可能导致非常大的结果,从而降低性能。
leftsemi Leftsemi Join 仅基于right一侧的存在给出left中的行。不包括right一侧的值。性能非常好,因为只考虑一个表,另一个表只需检查连接条件。
right, rightouter Rightouter Join 给出right中的所有行以及leftright中的共同行(内连接)。如果left中没有,则填充 NULL。性能与上表中先前提到的 leftouter join 类似。

总结

在本章中,我们讨论了 DataFrame 的起源以及 Spark SQL 如何在 DataFrame 之上提供 SQL 接口。DataFrame 的强大之处在于,执行时间比原始基于 RDD 的计算减少了很多倍。拥有这样一个强大的层和一个简单的类似 SQL 的接口使它们变得更加强大。我们还研究了各种 API 来创建和操作 DataFrame,并深入挖掘了聚合的复杂特性,包括groupByWindowrollupcubes。最后,我们还研究了连接数据集的概念以及可能的各种连接类型,如内连接、外连接、交叉连接等。

在下一章中,我们将探索实时数据处理和分析的激动人心的世界,即第九章,Stream Me Up, Scotty - Spark Streaming

第九章:Stream Me Up, Scotty - Spark Streaming

“我真的很喜欢流媒体服务。这是人们发现你的音乐的好方法。”

  • Kygo

在本章中,我们将学习 Spark Streaming,并了解如何利用它来使用 Spark API 处理数据流。此外,在本章中,我们将通过一个实际的例子学习处理实时数据流的各种方法,以消费和处理来自 Twitter 的推文。简而言之,本章将涵盖以下主题:

  • 流媒体的简要介绍

  • Spark Streaming

  • 离散流

  • 有状态/无状态转换

  • 检查点

  • 与流媒体平台的互操作性(Apache Kafka)

  • 结构化流

流媒体的简要介绍

在当今互联设备和服务的世界中,很难一天中甚至只有几个小时不使用我们的智能手机来检查 Facebook,或者预订 Uber 出行,或者发推文关于你刚买的汉堡,或者查看你最喜欢的球队的最新新闻或体育更新。我们依赖手机和互联网,无论是完成工作,浏览,还是给朋友发电子邮件,都需要它们。这种现象是无法避免的,应用程序和服务的数量和种类只会随着时间的推移而增长。

因此,智能设备随处可见,它们一直在产生大量数据。这种现象,也广泛称为物联网,已经永久改变了数据处理的动态。每当你在 iPhone、Droid 或 Windows 手机上使用任何服务或应用时,实时数据处理都在发挥作用。由于很多东西都取决于应用的质量和价值,各种初创公司和成熟公司如何应对SLA服务级别协议)的复杂挑战,以及数据的有用性和及时性都受到了很多关注。

组织和服务提供商正在研究和采用的范式之一是在非常尖端的平台或基础设施上构建非常可扩展的、接近实时或实时的处理框架。一切都必须快速,并且对变化和故障也要有反应。如果你的 Facebook 每小时只更新一次,或者你一天只收到一次电子邮件,你肯定不会喜欢;因此,数据流、处理和使用都尽可能接近实时是至关重要的。我们感兴趣监控或实施的许多系统产生了大量数据,作为一个无限持续的事件流。

与任何其他数据处理系统一样,我们面临着数据的收集、存储和处理的基本挑战。然而,额外的复杂性是由于平台的实时需求。为了收集这种无限的事件流,并随后处理所有这些事件以生成可操作的见解,我们需要使用高度可扩展的专门架构来处理巨大的事件速率。因此,多年来已经建立了许多系统,从 AMQ、RabbitMQ、Storm、Kafka、Spark、Flink、Gearpump、Apex 等等。

为了处理如此大量的流数据,现代系统采用了非常灵活和可扩展的技术,这些技术不仅非常高效,而且比以前更好地实现了业务目标。使用这些技术,可以从各种数据源中获取数据,然后几乎立即或在需要时在各种用例中使用它。

让我们来谈谈当你拿出手机预订 Uber 去机场的时候会发生什么。通过几次触摸屏幕,你可以选择一个地点,选择信用卡,付款,预订车辆。一旦交易完成,你就可以实时在手机地图上监控车辆的进度。当车辆向你靠近时,你可以准确地知道车辆的位置,也可以决定在等车的时候去当地的星巴克买咖啡。

你还可以通过查看车辆的预计到达时间来对车辆和随后的机场行程做出明智的决定。如果看起来车辆要花很长时间来接你,而且这可能对你即将要赶的航班构成风险,你可以取消预订并搭乘附近的出租车。另外,如果交通状况不允许你按时到达机场,从而对你即将要赶的航班构成风险,你也可以决定重新安排或取消你的航班。

现在,为了理解这样的实时流架构是如何提供如此宝贵的信息的,我们需要了解流架构的基本原则。一方面,实时流架构能够以非常高的速率消耗极大量的数据,另一方面,还要确保数据被摄入后也得到合理的处理。

下图显示了一个带有生产者将事件放入消息系统的通用流处理系统,而消费者正在从消息系统中读取事件:

实时流数据的处理可以分为以下三种基本范式:

  • 至少一次处理

  • 至多一次处理

  • 精确一次处理

让我们看看这三种流处理范式对我们的业务用例意味着什么。

虽然对于我们来说,实时事件的精确一次处理是最终的理想境界,但在不同的场景中总是实现这一目标非常困难。在那些保证的好处被实现的复杂性所压倒的情况下,我们不得不在精确一次处理的属性上做出妥协。

至少一次处理

至少一次处理范式涉及一种机制,即只有在事件实际处理并且结果被持久化之后才保存最后接收到的事件的位置,以便在发生故障并且消费者重新启动时,消费者将再次读取旧事件并处理它们。然而,由于无法保证接收到的事件根本没有被处理或部分处理,这会导致事件的潜在重复,因此事件至少被处理一次。

至少一次处理理想地适用于任何涉及更新瞬时标记或表盘以显示当前值的应用程序。任何累积总和、计数器或依赖于聚合的准确性(sumgroupBy等)都不适用于这种处理的用例,因为重复的事件会导致不正确的结果。

消费者的操作顺序如下:

  1. 保存结果

  2. 保存偏移量

下面是一个示例,说明了如果出现故障并且消费者重新启动会发生什么。由于事件已经被处理,但偏移量没有保存,消费者将从之前保存的偏移量读取,从而导致重复。在下图中,事件 0 被处理了两次:

至多一次处理

至多一次处理范式涉及一种机制,在事件实际被处理并结果被持久化到某个地方之前,保存最后接收到的事件的位置,以便在发生故障并且消费者重新启动时,消费者不会尝试再次读取旧事件。然而,由于无法保证接收到的事件是否全部被处理,这可能导致事件的潜在丢失,因为它们永远不会再次被获取。这导致事件最多被处理一次或根本不被处理。

至多一次理想适用于任何需要更新一些即时标记或计量器以显示当前值的应用程序,以及任何累积总和、计数器或其他聚合,只要准确性不是必需的或应用程序绝对需要所有事件。任何丢失的事件都将导致不正确的结果或缺失的结果。

消费者的操作顺序如下:

  1. 保存偏移量

  2. 保存结果

以下是如果发生故障并且消费者重新启动时会发生的情况的示例。由于事件尚未被处理但偏移量已保存,消费者将从保存的偏移量读取,导致事件被消费时出现间隙。在以下图中,事件 0 从未被处理:

一次性处理

一次性处理范式类似于至少一次处理范式,并涉及一种机制,只有在事件实际被处理并且结果被持久化到某个地方后,才保存最后接收到的事件的位置,以便在发生故障并且消费者重新启动时,消费者将再次读取旧事件并处理它们。然而,由于无法保证接收到的事件是否根本未被处理或部分处理,这可能导致事件的潜在重复,因为它们会再次被获取。然而,与至少一次处理范式不同,重复的事件不会被处理,而是被丢弃,从而导致一次性处理范式。

一次性处理范式适用于任何需要准确计数器、聚合或一般需要每个事件仅被处理一次且绝对一次(无损失)的应用程序。

消费者的操作顺序如下:

  1. 保存结果

  2. 保存偏移量

以下是示例显示了如果发生故障并且消费者重新启动时会发生的情况。由于事件已经被处理但偏移量尚未保存,消费者将从先前保存的偏移量读取,从而导致重复。在以下图中,事件 0 仅被处理一次,因为消费者丢弃了重复的事件 0:

一次性范式如何丢弃重复项?这里有两种技术可以帮助:

  1. 幂等更新

  2. 事务更新

Spark Streaming 还在 Spark 2.0+中实现了结构化流处理,支持一次性处理。我们将在本章后面讨论结构化流处理。

幂等更新涉及基于生成的某个唯一 ID/键保存结果,以便如果有重复,生成的唯一 ID/键已经存在于结果中(例如,数据库),因此消费者可以丢弃重复项而不更新结果。这很复杂,因为并非总是可能或容易生成唯一键。它还需要在消费者端进行额外的处理。另一点是,数据库可以分开用于结果和偏移量。

事务更新以批量方式保存结果,其中包括事务开始和事务提交阶段,因此在提交发生时,我们知道事件已成功处理。因此,当接收到重复事件时,可以在不更新结果的情况下丢弃它们。这种技术比幂等更新复杂得多,因为现在我们需要一些事务性数据存储。另一点是,数据库必须用于结果和偏移量。

您应该研究您正在构建的用例,并查看至少一次处理或最多一次处理是否可以合理地广泛应用,并且仍然可以实现可接受的性能和准确性。

在接下来的章节中,我们将仔细研究 Spark Streaming 的范例,以及如何使用 Spark Streaming 并从 Apache Kafka 中消费事件。

Spark Streaming

Spark Streaming 并不是第一个出现的流处理架构。 随着时间的推移,出现了几种技术来处理各种业务用例的实时处理需求。 Twitter Storm 是最早流行的流处理技术之一,并被许多组织使用,满足了许多企业的需求。

Apache Spark 配备了一个流处理库,它迅速发展成为最广泛使用的技术。 Spark Streaming 相对于其他技术具有一些明显的优势,首先是 Spark Streaming API 与 Spark 核心 API 之间的紧密集成,使得构建双重用途的实时和批量分析平台比以往更可行和高效。 Spark Streaming 还与 Spark ML 和 Spark SQL 以及 GraphX 集成,使其成为可以满足许多独特和复杂用例的最强大的流处理技术。 在本节中,我们将更深入地了解 Spark Streaming 的全部内容。

有关 Spark Streaming 的更多信息,您可以参考spark.apache.org/docs/2.1.0/streaming-programming-guide.html

Spark Streaming 支持多种输入源,并可以将结果写入多个接收器。

虽然 Flink、Heron(Twitter Storm 的继任者)、Samza 等都可以在收集事件时以最小的延迟处理事件,但 Spark Streaming 会消耗连续的数据流,然后以微批次的形式处理收集到的数据。 微批次的大小可以低至 500 毫秒,但通常不会低于这个值。

Apache Apex、Gear pump、Flink、Samza、Heron 或其他即将推出的技术在某些用例中与 Spark Streaming 竞争。 如果您需要真正的事件处理,那么 Spark Streaming 不适合您的用例。

流媒体的工作方式是根据配置定期创建事件批次,并在每个指定的时间间隔交付数据的微批次以进行进一步处理。

就像SparkContext一样,Spark Streaming 也有一个StreamingContext,它是流作业/应用程序的主要入口点。 StreamingContext依赖于SparkContext。 实际上,SparkContext可以直接在流作业中使用。 StreamingContext类似于SparkContext,只是StreamingContext还需要程序指定批处理间隔的时间间隔或持续时间,可以是毫秒或分钟。

请记住,SparkContext是入口点,任务调度和资源管理是SparkContext的一部分,因此StreamingContext重用了这一逻辑。

StreamingContext

StreamingContext是流处理的主要入口点,基本上负责流处理应用程序,包括 DStreams 的检查点、转换和操作。

创建 StreamingContext

可以通过两种方式创建新的 StreamingContext:

  1. 使用现有的SparkContext创建StreamingContext如下:
 StreamingContext(sparkContext: SparkContext, batchDuration: Duration) scala> val ssc = new StreamingContext(sc, Seconds(10))

  1. 通过提供新的SparkContext所需的配置来创建StreamingContext如下:
 StreamingContext(conf: SparkConf, batchDuration: Duration) scala> val conf = new SparkConf().setMaster("local[1]")
                                       .setAppName("TextStreams")
      scala> val ssc = new StreamingContext(conf, Seconds(10))

  1. 第三种方法是使用getOrCreate(),它用于从检查点数据重新创建StreamingContext,或创建一个新的StreamingContext。如果检查点数据存在于提供的checkpointPath中,则将从检查点数据重新创建StreamingContext。如果数据不存在,则将通过调用提供的creatingFunc创建StreamingContext
        def getOrCreate(
          checkpointPath: String,
          creatingFunc: () => StreamingContext,
          hadoopConf: Configuration = SparkHadoopUtil.get.conf,
          createOnError: Boolean = false
        ): StreamingContext

开始 StreamingContext

start()方法启动使用StreamingContext定义的流的执行。这实质上启动了整个流应用程序:

def start(): Unit 

scala> ssc.start()

停止 StreamingContext

停止StreamingContext将停止所有处理,您将需要重新创建一个新的StreamingContext并在其上调用start()来重新启动应用程序。有两个有用的 API 用于停止流处理应用程序。

立即停止流的执行(不等待所有接收到的数据被处理):

def stop(stopSparkContext: Boolean) scala> ssc.stop(false)

停止流的执行,并确保所有接收到的数据都已被处理:

def stop(stopSparkContext: Boolean, stopGracefully: Boolean) scala> ssc.stop(true, true)

输入流

有几种类型的输入流,如receiverStreamfileStream,可以使用StreamingContext创建,如下面的子节所示:

receiverStream

使用任意用户实现的接收器创建一个输入流。它可以定制以满足用例。

spark.apache.org/docs/latest/streaming-custom-receivers.html找到更多细节。

以下是receiverStream的 API 声明:

 def receiverStreamT: ClassTag: ReceiverInputDStream[T]

socketTextStream

这将从 TCP 源hostname:port创建一个输入流。使用 TCP 套接字接收数据,并将接收到的字节解释为 UTF8 编码的\n分隔行:

def socketTextStream(hostname: String, port: Int,
 storageLevel: StorageLevel = StorageLevel.MEMORY_AND_DISK_SER_2):
    ReceiverInputDStream[String]

rawSocketStream

从网络源hostname:port创建一个输入流,其中数据作为序列化块(使用 Spark 的序列化器进行序列化)接收,可以直接推送到块管理器而无需对其进行反序列化。这是最有效的

接收数据的方法。

def rawSocketStreamT: ClassTag:
    ReceiverInputDStream[T]

fileStream

创建一个输入流,监视 Hadoop 兼容文件系统以获取新文件,并使用给定的键值类型和输入格式进行读取。文件必须通过将它们从同一文件系统中的另一个位置移动到监视目录中来写入。以点(.)开头的文件名将被忽略,因此这是在监视目录中移动文件名的明显选择。使用原子文件重命名函数调用,以.开头的文件名现在可以重命名为实际可用的文件名,以便fileStream可以捡起它并让我们处理文件内容:

def fileStream[K: ClassTag, V: ClassTag, F <: NewInputFormat[K, V]: ClassTag] (directory: String): InputDStream[(K, V)]

textFileStream

创建一个输入流,监视 Hadoop 兼容文件系统以获取新文件,并将它们作为文本文件读取(使用LongWritable作为键,Text 作为值,TextInputFormat作为输入格式)。文件必须通过将它们从同一文件系统中的另一个位置移动到监视目录中来写入。以.开头的文件名将被忽略:

def textFileStream(directory: String): DStream[String]

binaryRecordsStream

创建一个输入流,监视 Hadoop 兼容文件系统以获取新文件,并将它们作为固定长度的二进制文件读取,生成每个记录的一个字节数组。文件必须通过将它们从同一文件系统中的另一个位置移动到监视目录中来写入。以.开头的文件名将被忽略:

def binaryRecordsStream(directory: String, recordLength: Int): DStream[Array[Byte]]

queueStream

从 RDD 队列创建一个输入流。在每个批处理中,它将处理队列返回的一个或所有 RDD:

def queueStreamT: ClassTag: InputDStream[T]

textFileStream 示例

以下是使用textFileStream的 Spark Streaming 的简单示例。在这个例子中,我们从 spark-shell 的SparkContextsc)和一个间隔为 10 秒的时间间隔创建了一个StreamingContext。这将启动textFileStream,监视名为streamfiles的目录,并处理在目录中找到的任何新文件。在这个例子中,我们只是打印 RDD 中的元素数量:

scala> import org.apache.spark._
scala> import org.apache.spark.streaming._

scala> val ssc = new StreamingContext(sc, Seconds(10))

scala> val filestream = ssc.textFileStream("streamfiles")

scala> filestream.foreachRDD(rdd => {println(rdd.count())})

scala> ssc.start

twitterStream 示例

让我们看另一个示例,说明我们如何使用 Spark Streaming 处理来自 Twitter 的推文:

  1. 首先,打开一个终端并将目录更改为spark-2.1.1-bin-hadoop2.7

  2. 在您安装了 spark 的spark-2.1.1-bin-hadoop2.7文件夹下创建一个streamouts文件夹。当应用程序运行时,streamouts文件夹将收集推文到文本文件中。

  3. 将以下 jar 文件下载到目录中:

  1. 使用指定的 Twitter 集成所需的 jar 启动 spark-shell:
 ./bin/spark-shell --jars twitter4j-stream-4.0.6.jar,
                               twitter4j-core-4.0.6.jar,
                               spark-streaming-twitter_2.11-2.1.0.jar

  1. 现在,我们可以编写一个示例代码。以下是用于测试 Twitter 事件处理的代码:
        import org.apache.spark._
        import org.apache.spark.streaming._
        import org.apache.spark.streaming.Twitter._
        import twitter4j.auth.OAuthAuthorization
        import twitter4j.conf.ConfigurationBuilder

        //you can replace the next 4 settings with your own Twitter
              account settings.
        System.setProperty("twitter4j.oauth.consumerKey",
                           "8wVysSpBc0LGzbwKMRh8hldSm") 
        System.setProperty("twitter4j.oauth.consumerSecret",
                  "FpV5MUDWliR6sInqIYIdkKMQEKaAUHdGJkEb4MVhDkh7dXtXPZ") 
        System.setProperty("twitter4j.oauth.accessToken",
                  "817207925756358656-yR0JR92VBdA2rBbgJaF7PYREbiV8VZq") 
        System.setProperty("twitter4j.oauth.accessTokenSecret",
                  "JsiVkUItwWCGyOLQEtnRpEhbXyZS9jNSzcMtycn68aBaS")

        val ssc = new StreamingContext(sc, Seconds(10))

        val twitterStream = TwitterUtils.createStream(ssc, None)

        twitterStream.saveAsTextFiles("streamouts/tweets", "txt")
        ssc.start()

        //wait for 30 seconds

        ss.stop(false)

您将看到streamouts文件夹中包含几个文本文件中的tweets输出。您现在可以打开streamouts目录并检查文件是否包含tweets

离散流

Spark Streaming 是建立在一个称为离散流的抽象上的,称为DStreams。DStream 被表示为一系列 RDD,每个 RDD 在每个时间间隔创建。DStream 可以以类似于常规 RDD 的方式进行处理,使用类似的概念,如基于有向无环图的执行计划(有向无环图)。就像常规 RDD 处理一样,执行计划中的转换和操作也适用于 DStreams。

DStream 基本上将一个永无止境的数据流分成较小的块,称为微批处理,基于时间间隔,将每个单独的微批处理实现为一个 RDD,然后可以像常规 RDD 一样进行处理。每个这样的微批处理都是独立处理的,微批处理之间不保留状态,因此本质上是无状态的处理。假设批处理间隔为 5 秒,那么在事件被消耗时,每 5 秒间隔都会创建一个实时和微批处理,并将微批处理作为 RDD 交给进一步处理。Spark Streaming 的一个主要优势是用于处理事件微批处理的 API 调用与 spark 的 API 紧密集成,以提供与架构的其余部分无缝集成。当创建一个微批处理时,它会转换为一个 RDD,这使得使用 spark API 进行无缝处理成为可能。

DStream类在源代码中如下所示,显示了最重要的变量,即HashMap[Time, RDD]对:

class DStream[T: ClassTag] (var ssc: StreamingContext)

//hashmap of RDDs in the DStream
var generatedRDDs = new HashMap[Time, RDD[T]]()

以下是一个由每T秒创建的 RDD 组成的 DStream 的示例:

在以下示例中,创建了一个流上下文,以便每 5 秒创建一个微批处理,并创建一个 RDD,它就像 Spark 核心 API RDD 一样。DStream 中的 RDD 可以像任何其他 RDD 一样进行处理。

构建流应用程序涉及的步骤如下:

  1. SparkContext创建一个StreamingContext

  2. StreamingContext创建一个DStream

  3. 提供可以应用于每个 RDD 的转换和操作。

  4. 最后,通过在StreamingContext上调用start()来启动流应用程序。这将启动消费和处理实时事件的整个过程。

一旦 Spark Streaming 应用程序启动,就不能再添加其他操作了。停止的上下文无法重新启动,如果有这样的需要,您必须创建一个新的流上下文。

以下是一个访问 Twitter 的简单流作业的示例:

  1. SparkContext创建StreamingContext
 scala> val ssc = new StreamingContext(sc, Seconds(5))
      ssc: org.apache.spark.streaming.StreamingContext = 
 org.apache.spark.streaming.StreamingContext@8ea5756

  1. StreamingContext创建DStream
 scala> val twitterStream = TwitterUtils.createStream(ssc, None)
      twitterStream: org.apache.spark.streaming.dstream
 .ReceiverInputDStream[twitter4j.Status] = 
 org.apache.spark.streaming.Twitter.TwitterInputDStream@46219d14

  1. 提供可应用于每个 RDD 的转换和操作:
 val aggStream = twitterStream
 .flatMap(x => x.getText.split(" ")).filter(_.startsWith("#"))
 .map(x => (x, 1))
 .reduceByKey(_ + _)

  1. 最后,通过在StreamingContext上调用start()来启动流应用程序。这将启动整个实时事件的消费和处理过程:
 ssc.start()      //to stop just call stop on the StreamingContext
 ssc.stop(false)

  1. 创建了一个ReceiverInputDStream类型的DStream,它被定义为定义任何必须在工作节点上启动接收器以接收外部数据的InputDStream的抽象类。在这里,我们从 Twitter 流接收:
        class InputDStreamT: ClassTag extends
                                        DStreamT

        class ReceiverInputDStreamT: ClassTag
                                  extends InputDStreamT

  1. 如果在twitterStream上运行flatMap()转换,将得到一个FlatMappedDStream,如下所示:
 scala> val wordStream = twitterStream.flatMap(x => x.getText()
                                                          .split(" "))
      wordStream: org.apache.spark.streaming.dstream.DStream[String] = 
 org.apache.spark.streaming.dstream.FlatMappedDStream@1ed2dbd5

转换

DStream 上的转换类似于适用于 Spark 核心 RDD 的转换。由于 DStream 由 RDD 组成,因此转换也适用于每个 RDD,以生成转换后的 RDD,然后创建转换后的 DStream。每个转换都创建一个特定的DStream派生类。

以下图表显示了从父DStream类开始的DStream类的层次结构。我们还可以看到从父类继承的不同类:

有很多DStream类是专门为功能而构建的。映射转换、窗口函数、减少操作和不同类型的输入流都是使用从DStream类派生的不同类来实现的。

以下是对基本 DStream 进行转换以生成过滤 DStream 的示例。同样,任何转换都适用于 DStream:

参考以下表格,了解可能的转换类型。

转换 意义
map(func) 将转换函数应用于 DStream 的每个元素,并返回一个新的 DStream。
flatMap(func) 这类似于 map;然而,就像 RDD 的flatMap与 map 一样,使用flatMap对每个元素进行操作并应用flatMap,从而为每个输入产生多个输出项。
filter(func) 这将过滤掉 DStream 的记录,返回一个新的 DStream。
repartition(numPartitions) 这将创建更多或更少的分区以重新分发数据以更改并行性。
union(otherStream) 这将合并两个源 DStream 中的元素,并返回一个新的 DStream。
count() 通过计算源 DStream 的每个 RDD 中的元素数量,返回一个新的 DStream。
reduce(func) 通过在源 DStream 的每个元素上应用reduce函数,返回一个新的 DStream。
countByValue() 这计算每个键的频率,并返回一个新的(key, long)对的 DStream。
reduceByKey(func, [numTasks]) 这将按键聚合源 DStream 的 RDD,并返回一个新的(key, value)对的 DStream。
join(otherStream, [numTasks]) 这将连接两个*(K, V)(K, W)对的 DStream,并返回一个新的(K, (V, W))*对的 DStream,结合了两个 DStream 的值。
cogroup(otherStream, [numTasks]) cogroup()在对*(K, V)(K, W)对的 DStream 调用时,将返回一个新的(K, Seq[V], Seq[W])*元组的 DStream。
transform(func) 这在源 DStream 的每个 RDD 上应用转换函数,并返回一个新的 DStream。
updateStateByKey(func) 这通过在键的先前状态和键的新值上应用给定的函数来更新每个键的状态。通常用于维护状态机。

窗口操作

Spark Streaming 提供了窗口处理,允许您在事件的滑动窗口上应用转换。滑动窗口是在指定的间隔内创建的。每当窗口在源 DStream 上滑动时,窗口规范内的源 RDD 将被组合并操作以生成窗口化的 DStream。窗口需要指定两个参数:

  • 窗口长度:指定为窗口考虑的间隔长度

  • 滑动间隔:这是创建窗口的间隔

窗口长度和滑动间隔都必须是块间隔的倍数。

以下是一个示例,显示了具有滑动窗口操作的 DStream,显示了旧窗口(虚线矩形)如何在一个间隔内向右滑动到新窗口(实线矩形):

一些常见的窗口操作如下。

转换 意义
window(windowLength, slideInterval) 在源 DStream 上创建窗口,并返回一个新的 DStream。
countByWindow(windowLength, slideInterval) 通过应用滑动窗口返回 DStream 中元素的计数。
reduceByWindow(func, windowLength, slideInterval) 创建一个新的 DStream,通过在创建长度为windowLength的滑动窗口后,对源 DStream 的每个元素应用 reduce 函数来实现。
reduceByKeyAndWindow(func, windowLength, slideInterval, [numTasks]) 在应用于源 DStream 的 RDD 的窗口中按键聚合数据,并返回新的(键,值)对的 DStream。计算由函数func提供。
reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks]) 在应用于源 DStream 的 RDD 的窗口中按键聚合数据,并返回新的(键,值)对的 DStream。与前一个函数的关键区别在于invFunc,它提供了在滑动窗口开始时要执行的计算。
countByValueAndWindow(windowLength, slideInterval, [numTasks]) 这计算每个键的频率,并返回指定滑动窗口内的新 DStream 的(键,长)对。

让我们更详细地看一下 Twitter 流示例。我们的目标是每五秒打印流式传输的推文中使用的前五个单词,使用长度为 15 秒的窗口,每 10 秒滑动一次。因此,我们可以在 15 秒内获得前五个单词。

要运行此代码,请按照以下步骤操作:

  1. 首先,打开终端并切换到spark-2.1.1-bin-hadoop2.7目录。

  2. 在安装了 spark 的spark-2.1.1-bin-hadoop2.7文件夹下创建一个名为streamouts的文件夹。当应用程序运行时,streamouts文件夹将收集推文到文本文件中。

  3. 将以下 jar 包下载到目录中:

  1. 使用指定的 Twitter 集成所需的 jar 启动 spark-shell:
 ./bin/spark-shell --jars twitter4j-stream-4.0.6.jar,
                               twitter4j-core-4.0.6.jar,
                               spark-streaming-twitter_2.11-2.1.0.jar

  1. 现在,我们可以编写代码。以下是用于测试 Twitter 事件处理的代码:
        import org.apache.log4j.Logger
        import org.apache.log4j.Level
        Logger.getLogger("org").setLevel(Level.OFF)

       import java.util.Date
       import org.apache.spark._
       import org.apache.spark.streaming._
       import org.apache.spark.streaming.Twitter._
       import twitter4j.auth.OAuthAuthorization
       import twitter4j.conf.ConfigurationBuilder

       System.setProperty("twitter4j.oauth.consumerKey",
                          "8wVysSpBc0LGzbwKMRh8hldSm")
       System.setProperty("twitter4j.oauth.consumerSecret",
                  "FpV5MUDWliR6sInqIYIdkKMQEKaAUHdGJkEb4MVhDkh7dXtXPZ")
       System.setProperty("twitter4j.oauth.accessToken",
                  "817207925756358656-yR0JR92VBdA2rBbgJaF7PYREbiV8VZq")
       System.setProperty("twitter4j.oauth.accessTokenSecret",
                  "JsiVkUItwWCGyOLQEtnRpEhbXyZS9jNSzcMtycn68aBaS")

       val ssc = new StreamingContext(sc, Seconds(5))

       val twitterStream = TwitterUtils.createStream(ssc, None)

       val aggStream = twitterStream
             .flatMap(x => x.getText.split(" "))
             .filter(_.startsWith("#"))
             .map(x => (x, 1))
             .reduceByKeyAndWindow(_ + _, _ - _, Seconds(15),
                                   Seconds(10), 5)

       ssc.checkpoint("checkpoints")
       aggStream.checkpoint(Seconds(10))

       aggStream.foreachRDD((rdd, time) => {
         val count = rdd.count()

         if (count > 0) {
           val dt = new Date(time.milliseconds)
           println(s"\n\n$dt rddCount = $count\nTop 5 words\n")
           val top5 = rdd.sortBy(_._2, ascending = false).take(5)
           top5.foreach {
             case (word, count) =>
             println(s"[$word] - $count")
           }
         }
       })

       ssc.start

       //wait 60 seconds
       ss.stop(false)

  1. 输出每 15 秒在控制台上显示,并且看起来像下面这样:
 Mon May 29 02:44:50 EDT 2017 rddCount = 1453
 Top 5 words

 [#RT] - 64
 [#de] - 24
 [#a] - 15
 [#to] - 15
 [#the] - 13

 Mon May 29 02:45:00 EDT 2017 rddCount = 3312
 Top 5 words

 [#RT] - 161
 [#df] - 47
 [#a] - 35
 [#the] - 29
 [#to] - 29

有状态/无状态转换

如前所述,Spark Streaming 使用 DStreams 的概念,这些 DStreams 实质上是作为 RDDs 创建的微批数据。我们还看到了在 DStreams 上可能的转换类型。DStreams 上的转换可以分为两种类型:无状态转换有状态转换

在无状态转换中,每个微批处理的处理不依赖于先前的数据批处理。因此,这是一个无状态的转换,每个批处理都独立于此批处理之前发生的任何事情进行处理。

在有状态转换中,每个微批处理的处理取决于先前的数据批处理,完全或部分地。因此,这是一个有状态的转换,每个批处理都考虑了此批处理之前发生的事情,并在计算此批处理中的数据时使用这些信息。

无状态转换

无状态转换通过对 DStream 中的每个 RDD 应用转换来将一个 DStream 转换为另一个 DStream。诸如map()flatMap()union()join()reduceByKey等转换都是无状态转换的示例。

下面的示例显示了对inputDStream进行map()转换以生成新的mapDstream

有状态转换

有状态转换在 DStream 上进行操作,但计算取决于先前的处理状态。诸如countByValueAndWindowreduceByKeyAndWindowmapWithStateupdateStateByKey等操作都是有状态转换的示例。实际上,所有基于窗口的转换都是有状态的,因为根据窗口操作的定义,我们需要跟踪 DStream 的窗口长度和滑动间隔。

检查点

实时流应用程序旨在长时间运行并对各种故障具有弹性。Spark Streaming 实现了一个检查点机制,可以维护足够的信息以从故障中恢复。

需要检查点的两种数据类型:

  • 元数据检查点

  • 数据检查点

可以通过在StreamingContext上调用checkpoint()函数来启用检查点,如下所示:

def checkpoint(directory: String)

指定可靠存储检查点数据的目录。

请注意,这必须是像 HDFS 这样的容错文件系统。

一旦设置了检查点目录,任何 DStream 都可以根据指定的间隔检查点到该目录中。看看 Twitter 的例子,我们可以每 10 秒将每个 DStream 检查点到checkpoints目录中:

val ssc = new StreamingContext(sc, Seconds(5))

val twitterStream = TwitterUtils.createStream(ssc, None)

val wordStream = twitterStream.flatMap(x => x.getText().split(" "))

val aggStream = twitterStream
 .flatMap(x => x.getText.split(" ")).filter(_.startsWith("#"))
 .map(x => (x, 1))
 .reduceByKeyAndWindow(_ + _, _ - _, Seconds(15), Seconds(10), 5)

ssc.checkpoint("checkpoints")

aggStream.checkpoint(Seconds(10))

wordStream.checkpoint(Seconds(10))

几秒钟后,checkpoints目录看起来像下面这样,显示了元数据以及 RDDs,logfiles也作为检查点的一部分进行维护:

元数据检查点

元数据检查点保存定义流操作的信息,这些信息由有向无环图DAG)表示到 HDFS。这可以用于在发生故障并且应用程序重新启动时恢复 DAG。驱动程序重新启动并从 HDFS 读取元数据,并重建 DAG 并恢复崩溃之前的所有操作状态。

元数据包括以下内容:

  • 配置:用于创建流应用程序的配置

  • DStream 操作:定义流应用程序的 DStream 操作集

  • 不完整的批处理:作业已排队但尚未完成的批处理

数据检查点

数据检查点将实际的 RDD 保存到 HDFS,以便如果流应用程序发生故障,应用程序可以恢复检查点的 RDD 并从中断的地方继续。虽然流应用程序恢复是数据检查点的一个很好的用例,但检查点还有助于在某些 RDD 由于缓存清理或执行器丢失而丢失时实例化生成的 RDD,而无需等待所有父 RDD 在血统(DAG)中重新计算。

对于具有以下任何要求的应用程序,必须启用检查点:

  • 使用有状态转换:如果应用程序中使用了updateStateByKeyreduceByKeyAndWindow(带有逆函数),则必须提供检查点目录以允许定期 RDD 检查点。

  • 从运行应用程序的驱动程序的故障中恢复:元数据检查点用于恢复进度信息。

如果您的流应用程序没有有状态的转换,则可以在不启用检查点的情况下运行应用程序。

您的流应用程序中可能会丢失已接收但尚未处理的数据。

请注意,RDD 的检查点会产生将每个 RDD 保存到存储的成本。这可能会导致 RDD 检查点的批次处理时间增加。因此,检查点的间隔需要谨慎设置,以免引起性能问题。在小批量大小(比如 1 秒)的情况下,每个小批量频繁检查点可能会显著降低操作吞吐量。相反,检查点太不频繁会导致血统和任务大小增长,这可能会导致处理延迟,因为要持久化的数据量很大。

对于需要 RDD 检查点的有状态转换,默认间隔是批处理间隔的倍数,至少为 10 秒。

一个 5 到 10 个滑动间隔的 DStream 的检查点间隔是一个很好的起点设置。

驱动程序故障恢复

使用StreamingContext.getOrCreate()可以实现驱动程序故障恢复,以初始化StreamingContext从现有检查点或创建新的 StreamingContext。

流应用程序启动时的两个条件如下:

  • 当程序第一次启动时,需要从检查点目录中的检查点数据初始化一个新的StreamingContext,设置所有流,然后调用start()

  • 在故障后重新启动程序时,需要从检查点目录中的检查点数据初始化一个StreamingContext,然后调用start()

我们将实现一个名为createStreamContext()的函数,它创建StreamingContext并设置各种 DStreams 来解析推文,并使用窗口每 15 秒生成前五个推文标签。但是,我们将调用getOrCreate()而不是调用createStreamContext()然后调用ssc.start(),这样如果checkpointDirectory存在,那么上下文将从检查点数据中重新创建。如果目录不存在(应用程序第一次运行),那么将调用函数createStreamContext()来创建一个新的上下文并设置 DStreams:

val ssc = StreamingContext.getOrCreate(checkpointDirectory,
                                       createStreamContext _)

以下是显示函数定义以及如何调用getOrCreate()的代码:

val checkpointDirectory = "checkpoints"

// Function to create and setup a new StreamingContext
def createStreamContext(): StreamingContext = {
  val ssc = new StreamingContext(sc, Seconds(5))

  val twitterStream = TwitterUtils.createStream(ssc, None)

  val wordStream = twitterStream.flatMap(x => x.getText().split(" "))

  val aggStream = twitterStream
    .flatMap(x => x.getText.split(" ")).filter(_.startsWith("#"))
    .map(x => (x, 1))
    .reduceByKeyAndWindow(_ + _, _ - _, Seconds(15), Seconds(10), 5)

  ssc.checkpoint(checkpointDirectory)

  aggStream.checkpoint(Seconds(10))

  wordStream.checkpoint(Seconds(10))

  aggStream.foreachRDD((rdd, time) => {
    val count = rdd.count()

    if (count > 0) {
      val dt = new Date(time.milliseconds)
      println(s"\n\n$dt rddCount = $count\nTop 5 words\n")
      val top10 = rdd.sortBy(_._2, ascending = false).take(5)
      top10.foreach {
        case (word, count) => println(s"[$word] - $count")
      }
    }
  })
  ssc
}

// Get StreamingContext from checkpoint data or create a new one
val ssc = StreamingContext.getOrCreate(checkpointDirectory, createStreamContext _)

与流平台(Apache Kafka)的互操作性

Spark Streaming 与 Apache Kafka 有非常好的集成,这是当前最流行的消息平台。Kafka 集成有几种方法,并且该机制随着时间的推移而不断发展,以提高性能和可靠性。

将 Spark Streaming 与 Kafka 集成有三种主要方法:

  • 基于接收器的方法

  • 直接流方法

  • 结构化流

基于接收器的方法

基于接收器的方法是 Spark 和 Kafka 之间的第一个集成。在这种方法中,驱动程序在执行程序上启动接收器,使用高级 API 从 Kafka 代理中拉取数据。由于接收器从 Kafka 代理中拉取事件,接收器会将偏移量更新到 Zookeeper 中,这也被 Kafka 集群使用。关键之处在于使用WAL(预写式日志),接收器在从 Kafka 消耗数据时会不断写入。因此,当出现问题并且执行程序或接收器丢失或重新启动时,可以使用 WAL 来恢复事件并处理它们。因此,这种基于日志的设计既提供了耐用性又提供了一致性。

每个接收器都会从 Kafka 主题创建一个输入 DStream,同时查询 Zookeeper 以获取 Kafka 主题、代理、偏移量等。在此之后,我们在前几节中讨论过的 DStreams 就会发挥作用。

长时间运行的接收器使并行性变得复杂,因为随着应用程序的扩展,工作负载不会得到适当的分布。依赖 HDFS 也是一个问题,还有写操作的重复。至于一次性处理所需的可靠性,只有幂等方法才能起作用。接收器基于事务的方法无法起作用的原因是,无法从 HDFS 位置或 Zookeeper 访问偏移量范围。

基于接收器的方法适用于任何消息系统,因此更通用。

您可以通过调用createStream() API 创建基于接收器的流,如下所示:

def createStream(
 ssc: StreamingContext, // StreamingContext object
 zkQuorum: String, //Zookeeper quorum (hostname:port,hostname:port,..)
 groupId: String, //The group id for this consumer
 topics: Map[String, Int], //Map of (topic_name to numPartitions) to
                  consume. Each partition is consumed in its own thread
 storageLevel: StorageLevel = StorageLevel.MEMORY_AND_DISK_SER_2 
  Storage level to use for storing the received objects
  (default: StorageLevel.MEMORY_AND_DISK_SER_2)
): ReceiverInputDStream[(String, String)] //DStream of (Kafka message key, Kafka message value)

以下是创建基于接收器的流的示例,从 Kafka 代理中拉取消息:

val topicMap = topics.split(",").map((_, numThreads.toInt)).toMap
val lines = KafkaUtils.createStream(ssc, zkQuorum, group,
                                    topicMap).map(_._2)

以下是驱动程序如何在执行程序上启动接收器,使用高级 API 从 Kafka 中拉取数据的示例。接收器从 Kafka Zookeeper 集群中拉取主题偏移量范围,然后在从代理中拉取事件时也更新 Zookeeper:

直接流

基于直接流的方法是相对于 Kafka 集成的较新方法,通过使用驱动程序直接连接到代理并拉取事件。关键之处在于使用直接流 API,Spark 任务在处理 Spark 分区到 Kafka 主题/分区时是一对一的比例。不依赖于 HDFS 或 WAL 使其灵活。此外,由于现在我们可以直接访问偏移量,我们可以使用幂等或事务性方法进行一次性处理。

创建一个直接从 Kafka 代理中拉取消息而不使用任何接收器的输入流。此流可以保证每条来自 Kafka 的消息在转换中被包含一次。

直接流的属性如下:

  • 没有接收器:此流不使用任何接收器,而是直接查询 Kafka。

  • 偏移量:这不使用 Zookeeper 来存储偏移量,而是由流本身跟踪消耗的偏移量。您可以从生成的 RDD 中访问每个批次使用的偏移量。

  • 故障恢复:要从驱动程序故障中恢复,必须在StreamingContext中启用检查点。

  • 端到端语义:此流确保每条记录被有效接收和转换一次,但不能保证转换后的数据是否被输出一次。

您可以使用 KafkaUtils 的createDirectStream() API 创建直接流,如下所示:

def createDirectStream[
 K: ClassTag, //K type of Kafka message key
 V: ClassTag, //V type of Kafka message value
 KD <: Decoder[K]: ClassTag, //KD type of Kafka message key decoder
 VD <: Decoder[V]: ClassTag, //VD type of Kafka message value decoder
 R: ClassTag //R type returned by messageHandler
](
 ssc: StreamingContext, //StreamingContext object
 KafkaParams: Map[String, String], 
  /*
  KafkaParams Kafka <a  href="http://Kafka.apache.org/documentation.html#configuration">
  configuration parameters</a>. Requires "metadata.broker.list" or   "bootstrap.servers"
to be set with Kafka broker(s) (NOT zookeeper servers) specified in
  host1:port1,host2:port2 form.
  */
 fromOffsets: Map[TopicAndPartition, Long], //fromOffsets Per- topic/partition Kafka offsets defining the (inclusive) starting point of the stream
 messageHandler: MessageAndMetadata[K, V] => R //messageHandler Function for translating each message and metadata into the desired type
): InputDStream[R] //DStream of R

以下是创建直接流的示例,从 Kafka 主题中拉取数据并创建 DStream:

val topicsSet = topics.split(",").toSet
val KafkaParams : Map[String, String] =
        Map("metadata.broker.list" -> brokers,
            "group.id" -> groupid )

val rawDstream = KafkaUtils.createDirectStreamString, String, StringDecoder, StringDecoder

直接流 API 只能与 Kafka 一起使用,因此这不是一种通用方法。

以下是驱动程序如何从 Zookeeper 中拉取偏移量信息,并指示执行程序根据驱动程序指定的偏移量范围启动任务从代理中拉取事件的示例:

结构化流

结构化流是 Apache Spark 2.0+中的新功能,从 Spark 2.2 版本开始已经是 GA。您将在下一节中看到详细信息,以及如何使用结构化流的示例。

有关结构化流中 Kafka 集成的更多详细信息,请参阅spark.apache.org/docs/latest/structured-streaming-kafka-integration.html

使用结构化流中的 Kafka 源流的示例如下:

val ds1 = spark
 .readStream
 .format("Kafka")
 .option("Kafka.bootstrap.servers", "host1:port1,host2:port2")
 .option("subscribe", "topic1")
 .load()

ds1.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
 .as[(String, String)]

使用 Kafka 源而不是源流的示例(如果您想要更多的批量分析方法)如下:

val ds1 = spark
 .read
 .format("Kafka")
 .option("Kafka.bootstrap.servers", "host1:port1,host2:port2")
 .option("subscribe", "topic1")
 .load()

ds1.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
 .as[(String, String)]

结构化流

结构化流是建立在 Spark SQL 引擎之上的可伸缩和容错的流处理引擎。这将流处理和计算更接近批处理,而不是 DStream 范式和当前时刻涉及的 Spark 流处理 API 的挑战。结构化流引擎解决了诸多挑战,如精确一次的流处理、处理结果的增量更新、聚合等。

结构化流 API 还提供了解决 Spark 流的一个重大挑战的手段,即,Spark 流以微批处理方式处理传入数据,并使用接收时间作为数据分割的手段,因此不考虑数据的实际事件时间。结构化流允许您在接收的数据中指定这样一个事件时间,以便自动处理任何延迟的数据。

结构化流在 Spark 2.2 中是 GA 的,API 已标记为 GA。请参阅spark.apache.org/docs/latest/structured-streaming-programming-guide.html

结构化流的关键思想是将实时数据流视为不断追加到的无界表,随着事件从流中处理,可以运行计算和 SQL 查询。例如,Spark SQL 查询将处理无界表:

随着 DStream 随时间的变化,将处理更多的数据以生成结果。因此,无界输入表用于生成结果表。输出或结果表可以写入称为输出的外部接收器。

输出是写出的内容,可以以不同的模式定义:

  • 完整模式:整个更新后的结果表将写入外部存储。由存储连接器决定如何处理整个表的写入。

  • 追加模式:自上次触发以来附加到结果表的任何新行都将写入外部存储。这仅适用于查询,其中不希望更改结果表中的现有行。

  • 更新模式:自上次触发以来更新的行将写入外部存储。请注意,这与完整模式不同,因为此模式仅输出自上次触发以来发生更改的行。如果查询不包含聚合,它将等同于追加模式。

下面是从无界表输出的示例:

我们将展示一个示例,通过监听本地端口 9999 来创建一个结构化流查询。

如果使用 Linux 或 Mac,在端口 9999 上启动一个简单的服务器很容易:nc -lk 9999。

下面是一个示例,我们首先通过调用 SparkSession 的readStream API 创建一个inputStream,然后从行中提取单词。然后我们对单词进行分组和计数,最后将结果写入输出流:

//create stream reading from localhost 9999
val inputLines = spark.readStream
 .format("socket")
 .option("host", "localhost")
 .option("port", 9999)
 .load()
inputLines: org.apache.spark.sql.DataFrame = [value: string]

// Split the inputLines into words
val words = inputLines.as[String].flatMap(_.split(" "))
words: org.apache.spark.sql.Dataset[String] = [value: string]

// Generate running word count
val wordCounts = words.groupBy("value").count()
wordCounts: org.apache.spark.sql.DataFrame = [value: string, count: bigint]

val query = wordCounts.writeStream
 .outputMode("complete")
 .format("console")
query: org.apache.spark.sql.streaming.DataStreamWriter[org.apache.spark.sql.Row] = org.apache.spark.sql.streaming.DataStreamWriter@4823f4d0

query.start()

当您在终端中不断输入单词时,查询会不断更新并生成结果,这些结果将打印在控制台上:

scala> -------------------------------------------
Batch: 0
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
| dog| 1|
+-----+-----+

-------------------------------------------
Batch: 1
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
| dog| 1|
| cat| 1|
+-----+-----+

scala> -------------------------------------------
Batch: 2
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
| dog| 2|
| cat| 1|
+-----+-----+

处理事件时间和延迟数据

事件时间是数据本身的时间。传统的 Spark 流处理只处理 DStream 目的的接收时间,但这对于许多需要事件时间的应用程序来说是不够的。例如,如果要每分钟获取推文中特定标签出现的次数,则应该使用生成数据时的时间,而不是 Spark 接收事件时的时间。通过将事件时间作为行/事件中的列来将事件时间纳入结构化流中是非常容易的。这允许基于窗口的聚合使用事件时间而不是接收时间运行。此外,该模型自然地处理了根据其事件时间到达的数据。由于 Spark 正在更新结果表,因此它可以完全控制在出现延迟数据时更新旧的聚合,以及清理旧的聚合以限制中间状态数据的大小。还支持为事件流设置水印,允许用户指定延迟数据的阈值,并允许引擎相应地清理旧状态。

水印使引擎能够跟踪当前事件时间,并通过检查数据的延迟阈值来确定是否需要处理事件或已经通过处理。例如,如果事件时间由eventTime表示,延迟到达数据的阈值间隔为lateThreshold,则通过检查max(eventTime) - lateThreshold的差异,并与从时间 T 开始的特定窗口进行比较,引擎可以确定是否可以在此窗口中考虑处理事件。

下面是对结构化流的前面示例的扩展,监听端口 9999。在这里,我们启用Timestamp作为输入数据的一部分,以便我们可以对无界表执行窗口操作以生成结果:

import java.sql.Timestamp import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._ // Create DataFrame representing the stream of input lines from connection to host:port
val inputLines = spark.readStream
 .format("socket")
 .option("host", "localhost")
 .option("port", 9999)
 .option("includeTimestamp", true)
 .load() // Split the lines into words, retaining timestamps
val words = inputLines.as[(String, Timestamp)].flatMap(line =>
 line._1.split(" ").map(word => (word, line._2))
).toDF("word", "timestamp") // Group the data by window and word and compute the count of each group
val windowedCounts = words.withWatermark("timestamp", "10 seconds")
.groupBy(
 window($"timestamp", "10 seconds", "10 seconds"), $"word"
).count().orderBy("window") // Start running the query that prints the windowed word counts to the console
val query = windowedCounts.writeStream
 .outputMode("complete")
 .format("console")
 .option("truncate", "false")

query.start()
query.awaitTermination()

容错语义

实现“端到端精确一次语义”是结构化流设计的关键目标之一,它实现了结构化流源、输出接收器和执行引擎,可可靠地跟踪处理的确切进度,以便能够通过重新启动和/或重新处理来处理任何类型的故障。假定每个流式源都有偏移量(类似于 Kafka 偏移量)来跟踪流中的读取位置。引擎使用检查点和预写日志来记录每个触发器中正在处理的数据的偏移量范围。流式输出接收器设计为幂等,以处理重新处理。通过使用可重放的源和幂等的接收器,结构化流可以确保在任何故障情况下实现端到端的精确一次语义。

请记住,传统流式处理中的范式更加复杂,需要使用一些外部数据库或存储来维护偏移量。

结构化流仍在不断发展,并且在被广泛使用之前需要克服一些挑战。其中一些挑战如下:

  • 流式数据集上尚不支持多个流式聚合

  • 流式数据集上不支持限制和获取前N

  • 流式数据集上不支持不同的操作

  • 在执行聚合步骤之后,流式数据集上仅支持排序操作,而且仅在完整输出模式下才支持

  • 目前还不支持任何两个流式数据集之间的连接操作。

  • 只支持少数类型的接收器 - 文件接收器和每个接收器

总结

在本章中,我们讨论了流处理系统、Spark 流处理、Apache Spark 的 DStreams 概念、DStreams 是什么、DStreams 的 DAG 和血统、转换和操作。我们还研究了流处理的窗口概念。我们还看了使用 Spark 流处理从 Twitter 消费推文的实际示例。

此外,我们还研究了从 Kafka 消费数据的基于接收者和直接流的方法。最后,我们还研究了新的结构化流处理,它承诺解决许多挑战,如流上的容错和精确一次语义。我们还讨论了结构化流处理如何简化与 Kafka 或其他消息系统的集成。

在下一章中,我们将看一下图形处理以及它是如何运作的。

第十章:一切都相连 - GraphX

“技术使大规模人口成为可能;大规模人口现在使技术成为必不可少。”

  • Joseph Wood Krutch

在本章中,我们将学习如何使用图表对许多现实世界的问题进行建模(和解决)。我们看到 Apache Spark 自带了自己的图表库,你学到的关于 RDD 的知识也可以在这里使用(这次作为顶点和边的 RDD)。

简而言之,本章将涵盖以下主题:

  • 图论简介

  • GraphX

  • VertexRDD 和 EdgeRDD

  • 图操作符

  • Pregel API

  • PageRank

图论简介

为了更好地理解图表,让我们看看 Facebook 以及你通常如何使用 Facebook。每天你都在用智能手机在朋友的墙上发布消息或更新你的状态。你的朋友们都在发布自己的消息、照片和视频。

你有朋友,你的朋友有朋友,他们有朋友,依此类推。Facebook 有设置,让你添加新朋友或从朋友列表中删除朋友。Facebook 还有权限,可以对谁看到什么以及谁可以与谁交流进行细粒度控制。

现在,当你考虑到有十亿 Facebook 用户时,所有用户的朋友和朋友的朋友列表变得非常庞大和复杂。甚至很难理解和管理所有不同的关系或友谊。

因此,如果有人想找出你和另一个人X是否有任何关系,他们可以简单地从你所有的朋友和你所有朋友的朋友开始,依此类推,试图找到X。如果X是一个朋友的朋友,那么你和X是间接连接的。

在你的 Facebook 账户中搜索一两个名人,看看是否有人是你朋友的朋友。也许你可以尝试添加他们为朋友。

我们需要构建关于人和他们朋友的存储和检索,以便让我们能够回答诸如:

  • X 是 Y 的朋友吗?

  • X 和 Y 直接连接还是在两步之内连接?

  • X 有多少个朋友?

我们可以从尝试一个简单的数据结构开始,比如每个人都有一个朋友数组。现在,只需取数组的长度就可以回答 3。我们也可以扫描数组并快速回答 1。现在,问题 2 需要更多的工作,取X的朋友数组,对于每个这样的朋友扫描朋友数组。

我们通过使用专门的数据结构来解决了这个问题,如下例所示,我们创建了一个Person的案例类,然后添加朋友来建立这样的关系john | ken | mary | dan

case class Person(name: String) {
 val friends = scala.collection.mutable.ArrayBuffer[Person]() 
 def numberOfFriends() = friends.length 
 def isFriend(other: Person) = friends.find(_.name == other.name) 
 def isConnectedWithin2Steps(other: Person) = {
 for {f <- friends} yield {f.name == other.name ||
                              f.isFriend(other).isDefined}
 }.find(_ == true).isDefined
 }

scala> val john = Person("John")
john: Person = Person(John)

scala> val ken = Person("Ken")
ken: Person = Person(Ken)

scala> val mary = Person("Mary")
mary: Person = Person(Mary)

scala> val dan = Person("Dan")
dan: Person = Person(Dan)

scala> john.numberOfFriends
res33: Int = 0

scala> john.friends += ken
res34: john.friends.type = ArrayBuffer(Person(Ken))     //john -> ken

scala> john.numberOfFriends
res35: Int = 1

scala> ken.friends += mary
res36: ken.friends.type = ArrayBuffer(Person(Mary))    //john -> ken -> mary

scala> ken.numberOfFriends
res37: Int = 1

scala> mary.friends += dan
res38: mary.friends.type = ArrayBuffer(Person(Dan))   //john -> ken -> mary -> dan

scala> mary.numberOfFriends
res39: Int = 1

scala> john.isFriend(ken)
res40: Option[Person] = Some(Person(Ken))         //Yes, ken is a friend of john

scala> john.isFriend(mary)
res41: Option[Person] = None        //No, mary is a friend of ken not john

scala> john.isFriend(dan)
res42: Option[Person] = None      //No, dan is a friend of mary not john

scala> john.isConnectedWithin2Steps(ken)
res43: Boolean = true     //Yes, ken is a friend of john

scala> john.isConnectedWithin2Steps(mary)
res44: Boolean = true     //Yes, mary is a friend of ken who is a friend of john

scala> john.isConnectedWithin2Steps(dan)
res45: Boolean = false    //No, dan is a friend of mary who is a friend of ken who is a friend of john

如果我们为所有 Facebook 用户构建Person()实例并像前面的代码所示将朋友添加到数组中,那么最终,我们将能够执行许多关于谁是朋友以及任何两个人之间的关系的查询。

以下图表显示了数据结构的Person()实例以及它们在逻辑上是如何相关的:

如果你想使用前面的图表,只需找出John的朋友,John的朋友的朋友等等,这样我们就可以快速找出直接朋友、间接朋友(朋友的朋友),以及第 3 级(朋友的朋友的朋友),你会看到类似以下图表:

我们可以轻松地扩展Person()类并提供越来越多的功能来回答不同的问题。这不是重点,我们想要看的是前面的图表显示了PersonPerson的朋友,以及如何绘制每个Person的所有朋友会产生人与人之间的关系网。

现在我们介绍图论,它源自数学领域。图论将图定义为由顶点、节点或点构成的结构,这些结构由边缘、弧和线连接。如果你将一组Vertices视为V,一组Edges视为E,那么Graph G可以被定义为VE的有序对。

Graph G = (V, E)
V - set of Vertices
E - set of Edges

在我们的 Facebook 朋友绘图示例中,我们可以简单地将每个人视为顶点集中的一个顶点,然后将任意两个人之间的每个链接视为边缘集中的一条边。

按照这个逻辑,我们可以列出如下图中的VerticesEdges

将这种数学图的描述转化为各种使用数学技术进行遍历和查询图的方法。当这些技术被应用于计算机科学,作为开发程序方法来执行必要的数学运算时,正式的方法当然是开发算法,以在可扩展高效的水平上实现数学规则。

我们已经尝试使用Person这个案例类来实现一个类似图的程序,但这只是最简单的用例,这应该让人明白,还有很多复杂的扩展可能,比如以下问题需要回答:

  • 从 X 到 Y 的最佳方式是什么?这样一个问题的例子可以是你的车载 GPS 告诉你去杂货店的最佳路线。

  • 识别可能导致图分区的关键边缘?这样一个问题的例子是确定连接州内各个城市的互联网服务/水管/电力线的关键链路。关键边缘会打破连接,并产生两个互相连接良好的子图,但两个子图之间不会有任何通信。

回答上述问题会产生一些算法,例如最小生成树、最短路径、页面排名、ALS交替最小二乘法)和最大割最小流算法等,适用于广泛的用例。

其他的例子包括 LinkedIn 的个人资料和联系人、Twitter 的关注者、Google 的页面排名、航空公司的航班安排、你车上的 GPS 等等,你可以清楚地看到顶点和边的图。使用图算法,可以使用各种算法分析先前在 Facebook、LinkedIn、Google 示例中看到的图,以产生不同的商业用例。

下面是一些图的实际用例的示例,展示了图和图算法在一些实际用例中的使用,例如:

  • 帮助确定机场之间的航班路线

  • 规划如何布置本地所有家庭的水管道

  • 让你的车载 GPS 规划驾驶到杂货店的路线

  • 设计互联网流量如何从城市到城市、州到州、国家到国家的路由

现在让我们深入了解如何使用 Spark GraphX。

GraphX

正如前面的部分所示,我们可以将许多实际用例建模为具有一组顶点和一组连接顶点的边的图。我们还编写了简单的代码,试图实现一些基本的图操作和查询,比如* X 是 Y 的朋友吗*?然而,随着我们的探索,算法变得更加复杂,用例也更加复杂,图的规模远远大于一个机器可以处理的规模。

将十亿 Facebook 用户及其所有的友谊关系放入一个甚至几个机器中是不可能的。

我们需要做的是超越单台机器和几台机器的组合,而是开始考虑高度可扩展的架构,以实现复杂的图算法,可以处理数据的数量和数据元素的复杂相互关系。我们已经看到了 Spark 的介绍,Spark 如何解决分布式计算和大数据分析的一些挑战。我们还看到了实时流处理和 Spark SQL 以及 DataFrames 和 RDDs。我们是否也可以解决图算法的挑战?答案是 GraphX,它与 Apache Spark 一起提供,并且与其他库一样,位于 Spark Core 之上。

GraphX 通过在 RDD 概念之上提供图抽象来扩展 Spark RDD。GraphX 中的图是使用顶点或节点来表示对象,使用边或链接来描述对象之间的关系,并且 GraphX 提供了实现适合图处理范式的许多用例的手段。在本节中,我们将学习 GraphX,如何创建顶点、边和包含顶点和边的图。我们还将编写代码,通过示例学习围绕图算法和处理的一些技术。

要开始,您需要导入以下列出的一些包:

import org.apache.spark._
import org.apache.spark.graphx._
import org.apache.spark.rdd.RDD

import org.apache.spark.graphx.GraphLoader
import org.apache.spark.graphx.GraphOps

GraphX 的基本数据结构是图,它抽象地表示具有与顶点和边关联的任意对象的图。图提供了基本操作,用于访问和操作与顶点和边关联的数据,以及底层结构。与 Spark RDD 一样,图是一个功能性数据结构,其中变异操作返回新的图。Graph对象的不可变性使得可以进行大规模并行计算,而不会遇到同步问题的风险。

并发更新或修改对象是许多程序中进行复杂多线程编程的主要原因。

图定义了基本的数据结构,还有一个辅助类GraphOps,其中包含额外的便利操作和图算法。

图被定义为一个类模板,具有两个属性,指定构成图的两个部分的数据类型,即顶点和边:

class Graph[VD: ClassTag, ED: ClassTag] 

如我们已经讨论过的,图由顶点和边组成。顶点集合存储在称为VertexRDD的特殊数据结构中。同样,边的集合存储在称为EdgeRDD的特殊数据结构中。顶点和边一起形成图,所有后续操作都可以使用这两个数据结构进行。

因此,Graph类的声明如下:

class Graph[VD, ED] {
  //A RDD containing the vertices and their associated attributes.
  val vertices: VertexRDD[VD]

  //A RDD containing the edges and their associated attributes. 
    The entries in the RDD contain just the source id and target id
    along with the edge data.
  val edges: EdgeRDD[ED]

  //A RDD containing the edge triplets, which are edges along with the
    vertex data associated with the adjacent vertices.
  val triplets: RDD[EdgeTriplet[VD, ED]]
}

现在,让我们看看Graph类的两个主要组件,即VertexRDDEdgeRDD

VertexRDD 和 EdgeRDD

VertexRDD包含一组顶点或节点,存储在特殊的数据结构中,而EdgeRDD包含一组边或链接,连接了节点/顶点,同样存储在特殊的数据结构中。VertexRDDEdgeRDD都基于 RDD,并且VertexRDD处理图中的每个单个节点,而EdgeRDD包含所有节点之间的所有链接。在本节中,我们将看看如何创建VertexRDDEdgeRDD,然后在构建图时使用这些对象。

VertexRDD

如前所述,VertexRDD是一个包含顶点及其关联属性的 RDD。RDD 中的每个元素表示图中的一个顶点或节点。为了保持顶点的唯一性,我们需要一种方法为每个顶点分配一个唯一的 ID。为此,GraphX 定义了一个非常重要的标识符,称为VertexId

VertexId被定义为一个 64 位的顶点标识符,用于唯一标识图中的顶点。它不需要遵循任何顺序或除唯一性之外的任何约束。

VertexId的声明如下,只是 64 位Long数字的别名:

type VertexId = Long

VertexRDD扩展了由RDD[(VertexId, VD)]表示的一对 VertexID 和顶点属性的 RDD。它还确保每个顶点只有一个条目,并通过预索引条目以进行快速、高效的连接。具有相同索引的两个 VertexRDD 可以有效地连接。

class VertexRDD[VD]() extends RDD[(VertexId, VD)]

VertexRDD还实现了许多函数,提供了与图操作相关的重要功能。每个函数通常接受由VertexRDD表示的顶点作为输入。

让我们将用户加载到VertexRDD中。为此,我们首先声明一个User case 类,如下所示:

case class User(name: String, occupation: String)

现在,使用文件users.txt,创建VertexRDD

VertexID Name Occupation
1 John 会计师
2 Mark 医生
3 Sam 律师
4 Liz 医生
5 Eric 会计师
6 Beth 会计师
7 Larry 工程师
8 Marry 收银员
9 Dan 医生
10 Ken 图书管理员

文件users.txt的每一行包含VertexIdNameOccupation,因此我们可以在这里使用String split 函数:

scala> val users = sc.textFile("users.txt").map{ line =>
 val fields = line.split(",")
 (fields(0).toLong, User(fields(1), fields(2)))
}
users: org.apache.spark.rdd.RDD[(Long, User)] = MapPartitionsRDD[2645] at map at <console>:127

scala> users.take(10)
res103: Array[(Long, User)] = Array((1,User(John,Accountant)), (2,User(Mark,Doctor)), (3,User(Sam,Lawyer)), (4,User(Liz,Doctor)), (5,User(Eric,Accountant)), (6,User(Beth,Accountant)), (7,User(Larry,Engineer)), (8,User(Mary,Cashier)), (9,User(Dan,Doctor)), (10,User(Ken,Librarian)))

EdgeRDD

EdgeRDD表示顶点之间的边的集合,并且是 Graph 类的成员,就像之前看到的那样。EdgeRDDVertexRDD一样,都是从 RDD 扩展而来,并且都带有 Edge 属性和 Vertex 属性。

EdgeRDD[ED, VD]通过在每个分区上以列格式存储边缘来扩展RDD[Edge[ED]],以提高性能。它还可以存储与每条边相关联的顶点属性,以提供三元组视图:

class EdgeRDD[ED]() extends RDD[Edge[ED]]

EdgeRDD 还实现了许多函数,提供了与图操作相关的重要功能。每个函数通常接受由 EdgeRDD 表示的边的输入。每个 Edge 包括源 vertexId、目标 vertexId 和边属性,例如StringInteger或任何 case 类。在下面的示例中,我们使用String friend 作为属性。在本章的后面,我们将使用英里数(Integer)作为属性。

我们可以通过读取一对 vertexIds 的文件来创建 EdgeRDD:

源顶点 ID 目标/目的地顶点 ID 英里数
1 3 5
3 1 5
1 2 1
2 1 1
4 10 5
10 4 5
1 10 5
10 1 5
2 7 6
7 2 6
7 4 3
4 7 3
2 3 2

friends.txt文件的每一行包含源vertexId和目标vertexId,因此我们可以在这里使用String split 函数:

scala> val friends = sc.textFile("friends.txt").map{ line =>
 val fields = line.split(",")
 Edge(fields(0).toLong, fields(1).toLong, "friend")
}
friends: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] = MapPartitionsRDD[2648] at map at <console>:125

scala> friends.take(10)
res109: Array[org.apache.spark.graphx.Edge[String]] = Array(Edge(1,3,friend), Edge(3,1,friend), Edge(1,2,friend), Edge(2,1,friend), Edge(4,10,friend), Edge(10,4,friend), Edge(1,10,friend), Edge(10,1,friend), Edge(2,7,friend), Edge(7,2,friend))

现在我们有了顶点和边缘,是时候将所有内容放在一起,探索如何从顶点和边缘列表构建Graph

scala> val graph = Graph(users, friends)
graph: org.apache.spark.graphx.Graph[User,String] = org.apache.spark.graphx.impl.GraphImpl@327b69c8

scala> graph.vertices
res113: org.apache.spark.graphx.VertexRDD[User] = VertexRDDImpl[2658] at RDD at VertexRDD.scala:57

scala> graph.edges
res114: org.apache.spark.graphx.EdgeRDD[String] = EdgeRDDImpl[2660] at RDD at EdgeRDD.scala:41

使用Graph对象,我们可以使用collect()函数查看顶点和边,这将显示所有顶点和边。每个顶点的形式为(VertexIdUser),每条边的形式为(srcVertexIddstVertexIdedgeAttribute)。

scala> graph.vertices.collect
res111: Array[(org.apache.spark.graphx.VertexId, User)] = Array((4,User(Liz,Doctor)), (6,User(Beth,Accountant)), (8,User(Mary,Cashier)), (10,User(Ken,Librarian)), (2,User(Mark,Doctor)), (1,User(John,Accountant)), (3,User(Sam,Lawyer)), (7,User(Larry,Engineer)), (9,User(Dan,Doctor)), (5,User(Eric,Accountant)))

scala> graph.edges.collect
res112: Array[org.apache.spark.graphx.Edge[String]] = Array(Edge(1,2,friend), Edge(1,3,friend), Edge(1,10,friend), Edge(2,1,friend), Edge(2,3,friend), Edge(2,7,friend), Edge(3,1,friend), Edge(3,2,friend), Edge(3,10,friend), Edge(4,7,friend), Edge(4,10,friend), Edge(7,2,friend), Edge(7,4,friend), Edge(10,1,friend), Edge(10,4,friend), Edge(3,5,friend), Edge(5,3,friend), Edge(5,9,friend), Edge(6,8,friend), Edge(6,10,friend), Edge(8,6,friend), Edge(8,9,friend), Edge(8,10,friend), Edge(9,5,friend), Edge(9,8,friend), Edge(10,6,friend), Edge(10,8,friend))

现在我们创建了一个图,我们将在下一节中查看各种操作。

图操作符

让我们从我们可以直接使用Graph对象执行的操作开始,例如根据对象的某个属性过滤图的顶点和边缘。我们还将看到mapValues()的示例,它可以将图转换为生成自定义 RDD。

首先,让我们使用前一节中创建的Graph对象检查顶点和边,然后查看一些图操作符。

scala> graph.vertices.collect
res111: Array[(org.apache.spark.graphx.VertexId, User)] = Array((4,User(Liz,Doctor)), (6,User(Beth,Accountant)), (8,User(Mary,Cashier)), (10,User(Ken,Librarian)), (2,User(Mark,Doctor)), (1,User(John,Accountant)), (3,User(Sam,Lawyer)), (7,User(Larry,Engineer)), (9,User(Dan,Doctor)), (5,User(Eric,Accountant)))

scala> graph.edges.collect
res112: Array[org.apache.spark.graphx.Edge[String]] = Array(Edge(1,2,friend), Edge(1,3,friend), Edge(1,10,friend), Edge(2,1,friend), Edge(2,3,friend), Edge(2,7,friend), Edge(3,1,friend), Edge(3,2,friend), Edge(3,10,friend), Edge(4,7,friend), Edge(4,10,friend), Edge(7,2,friend), Edge(7,4,friend), Edge(10,1,friend), Edge(10,4,friend), Edge(3,5,friend), Edge(5,3,friend), Edge(5,9,friend), Edge(6,8,friend), Edge(6,10,friend), Edge(8,6,friend), Edge(8,9,friend), Edge(8,10,friend), Edge(9,5,friend), Edge(9,8,friend), Edge(10,6,friend), Edge(10,8,friend))

过滤

filter()的函数调用将顶点集限制为满足给定谓词的顶点集。此操作保留了用于与原始 RDD 进行高效连接的索引,并且在位掩码中设置位,而不是分配新内存:

def filter(pred: Tuple2[VertexId, VD] => Boolean): VertexRDD[VD] 

使用filter,我们可以过滤掉除了用户Mark的顶点之外的所有内容,可以使用顶点 ID 或User.name属性来完成。我们还可以过滤User.occupation属性。

以下是实现相同目的的代码:

scala> graph.vertices.filter(x => x._1 == 2).take(10)
res118: Array[(org.apache.spark.graphx.VertexId, User)] = Array((2,User(Mark,Doctor)))

scala> graph.vertices.filter(x => x._2.name == "Mark").take(10)
res119: Array[(org.apache.spark.graphx.VertexId, User)] = Array((2,User(Mark,Doctor)))

scala> graph.vertices.filter(x => x._2.occupation == "Doctor").take(10)
res120: Array[(org.apache.spark.graphx.VertexId, User)] = Array((4,User(Liz,Doctor)), (2,User(Mark,Doctor)), (9,User(Dan,Doctor)))

我们也可以对边进行filter操作,使用源顶点 ID 或目标顶点 ID。因此,我们可以过滤出仅显示从John(vertexId = 1)发出的边。

scala> graph.edges.filter(x => x.srcId == 1)
res123: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] = MapPartitionsRDD[2672] at filter at <console>:134

scala> graph.edges.filter(x => x.srcId == 1).take(10)
res124: Array[org.apache.spark.graphx.Edge[String]] = Array(Edge(1,2,friend), Edge(1,3,friend), Edge(1,10,friend))

MapValues

mapValues()映射每个顶点属性,保留索引以不改变 vertexId。改变 vertexId 会导致索引变化,从而导致后续操作失败,顶点将不再可达。因此,重要的是不要改变 vertexIds。

此函数的声明如下所示:

def mapValuesVD2: ClassTag: VertexRDD[VD2]
//A variant of the mapValues() function accepts a vertexId in addition  
  to the vertices.
def mapValuesVD2: ClassTag => VD2): VertexRDD[VD2]

mapValues()也可以在边上操作,并映射边分区中的值,保留结构但更改值:

def mapValuesED2: ClassTag: EdgeRDD[ED2]

以下是在顶点和边上调用mapValues()的示例代码。在顶点上的 MapValues 将顶点转换为(vertexIdUser.name)对的列表。在边上的 MapValues 将边转换为(srcIddstIdstring)的三元组:

scala> graph.vertices.mapValues{(id, u) => u.name}.take(10)
res142: Array[(org.apache.spark.graphx.VertexId, String)] = Array((4,Liz), (6,Beth), (8,Mary), (10,Ken), (2,Mark), (1,John), (3,Sam), (7,Larry), (9,Dan), (5,Eric))

scala> graph.edges.mapValues(x => s"${x.srcId} -> ${x.dstId}").take(10)
7), Edge(3,1,3 -> 1), Edge(3,2,3 -> 2), Edge(3,10,3 -> 10), Edge(4,7,4 -> 7))

aggregateMessages

GraphX 中的核心聚合操作是aggregateMessages,它对图中的每个边三元组应用用户定义的sendMsg函数,然后使用mergeMsg函数在目标顶点处聚合这些消息。aggregateMessages在许多图算法中使用,其中我们必须在顶点之间交换信息。

以下是此 API 的签名:

def aggregateMessagesMsg: ClassTag => Msg,
 tripletFields: TripletFields = TripletFields.All)
 : VertexRDD[Msg]

关键函数是sendMsgmergeMsg,它们确定发送到边的源顶点或目标顶点的内容。然后,mergeMsg处理从所有边接收到的消息并执行计算或聚合。

以下是在Graph图上调用aggregateMessages的简单示例,我们向所有目标顶点发送消息。每个顶点的合并策略只是将接收到的所有消息相加:

scala> graph.aggregateMessagesInt, _ + _).collect
res207: Array[(org.apache.spark.graphx.VertexId, Int)] = Array((4,2), (6,2), (8,3), (10,4), (2,3), (1,3), (3,3), (7,2), (9,2), (5,2))

TriangleCounting

如果一个顶点的两个邻居通过一条边相连,就会形成一个三角形。换句话说,用户将与彼此为朋友的两个朋友创建一个三角形。

图形具有一个名为triangleCount()的函数,用于计算图中的三角形。

以下是用于计算图中三角形数量的代码,首先调用triangleCount函数,然后通过将三角形与顶点(用户)连接来生成每个用户和用户所属的三角形的输出:

scala> val triangleCounts = graph.triangleCount.vertices
triangleCounts: org.apache.spark.graphx.VertexRDD[Int] = VertexRDDImpl[3365] at RDD at VertexRDD.scala:57

scala> triangleCounts.take(10)
res171: Array[(org.apache.spark.graphx.VertexId, Int)] = Array((4,0), (6,1), (8,1), (10,1), (2,1), (1,1), (3,1), (7,0), (9,0), (5,0))

scala> val triangleCountsPerUser = users.join(triangleCounts).map { case(id, (User(x,y), k)) => ((x,y), k) }
triangleCountsPerUser: org.apache.spark.rdd.RDD[((String, String), Int)] = MapPartitionsRDD[3371] at map at <console>:153

scala> triangleCountsPerUser.collect.mkString("\n")
res170: String =
((Liz,Doctor),0)
((Beth,Accountant),1)  *//1 count means this User is part of 1 triangle*
((Mary,Cashier),1)  *//1 count means this User is part of 1 triangle*
((Ken,Librarian),1)  *//1 count means this User is part of 1 triangle*
((Mark,Doctor),1)  * //1 count means this User is part of 1 triangle*
((John,Accountant),1)  *//1 count means this User is part of 1 triangle*
((Sam,Lawyer),1)   *//1 count means this User is part of 1 triangle*
((Larry,Engineer),0)
((Dan,Doctor),0)
((Eric,Accountant),0)

我们刚刚在前面的代码中计算的两个三角形的图示如下,(John, Mark, Sam)和(Ken, Mary, Beth):

Pregel API

图形本质上是递归数据结构,因为顶点的属性取决于其邻居的属性,而邻居的属性又取决于它们自己的邻居的属性。因此,许多重要的图算法会迭代地重新计算每个顶点的属性,直到达到固定点条件。已经提出了一系列图并行抽象来表达这些迭代算法。GraphX 公开了 Pregel API 的变体。

在高层次上,GraphX 中的 Pregel 运算符是一种受限于图的拓扑结构的批量同步并行消息传递抽象。Pregel 运算符在一系列步骤中执行,其中顶点接收来自上一个超级步骤的入站消息的总和,计算顶点属性的新值,然后在下一个超级步骤中向相邻顶点发送消息。使用 Pregel,消息并行计算作为边三元组的函数,并且消息计算可以访问源和目标顶点属性。在超级步骤中,不接收消息的顶点将被跳过。当没有剩余消息时,Pregel 运算符终止迭代并返回最终图。

使用 Pregel API 内置的一些算法如下:

  • ConnectedComponents

  • ShortestPaths

  • 旅行推销员

  • PageRank(在下一节中介绍)

Pregel API 的签名如下所示,显示了所需的各种参数。确切的用法将在后续部分中显示,因此您可以参考此签名以进行澄清:

def pregel[A]
 (initialMsg: A, // the initial message to all vertices
 maxIter: Int = Int.MaxValue, // number of iterations
 activeDir: EdgeDirection = EdgeDirection.Out) // incoming or outgoing edges
 (vprog: (VertexId, VD, A) => VD,
 sendMsg: EdgeTriplet[VD, ED] => Iterator[(VertexId, A)], //send message function
 mergeMsg: (A, A) => A) //merge strategy
 : Graph[VD, ED] 

ConnectedComponents

连接组件本质上是图中的子图,其中顶点以某种方式相互连接。这意味着同一组件中的每个顶点都与组件中的某个其他顶点有边相连。每当没有其他边存在以连接顶点到组件时,就会创建一个具有特定顶点的新组件。这将继续,直到所有顶点都在某个组件中。

图对象提供了一个connectComponents()函数来计算连接的组件。这使用 Pregel API 来计算顶点所属的组件。以下是计算图中连接组件的代码。显然,在这个例子中,我们只有一个连接的组件,所以它显示所有用户的组件编号为 1:

scala> graph.connectedComponents.vertices.collect res198: Array[(org.apache.spark.graphx.VertexId, org.apache.spark.graphx.VertexId)] = Array((4,1), (6,1), (8,1), (10,1), (2,1), (1,1), (3,1), (7,1), (9,1), (5,1))
 scala> graph.connectedComponents.vertices.join(users).take(10)
res197: Array[(org.apache.spark.graphx.VertexId, (org.apache.spark.graphx.VertexId, User))] = Array((4,(1,User(Liz,Doctor))), (6,(1,User(Beth,Accountant))), (8,(1,User(Mary,Cashier))), (10,(1,User(Ken,Librarian))), (2,(1,User(Mark,Doctor))), (1,(1,User(John,Accountant))), (3,(1,User(Sam,Lawyer))), (7,(1,User(Larry,Engineer))), (9,(1,User(Dan,Doctor))), (5,(1,User(Eric,Accountant))))

旅行推销员问题

旅行推销员问题试图在遍历每个顶点的无向图中找到最短路径,例如,用户 John 想要驾驶到每个其他用户,最小化总驾驶距离。随着顶点和边的数量增加,排列的数量也会多项式增加,以覆盖从顶点到顶点的所有可能路径。时间复杂度多项式增加到一个问题可能需要很长时间来解决。与其完全准确地解决它,不如使用一种称为贪婪算法的方法尽可能地解决问题。

为了解决旅行推销员问题,贪婪方法是快速选择最短边,知道这可能是一个非最优选择,如果我们继续更深层次地遍历。

下图显示了在用户和朋友图上的贪婪算法的图表,我们可以看到遍历每个顶点时选择最短加权边。还要注意,顶点Larry7)和Liz4)从未被访问:

ShortestPaths

最短路径算法通过从源顶点开始,然后遍历连接顶点到其他顶点的边,直到到达目标顶点,找到两个顶点之间的路径。最短路径算法通过在各个顶点之间交换消息来工作。此最短路径算法不是GraphGraphOps对象的直接部分,而是必须使用lib.ShortestPaths()来调用:

scala> lib.ShortestPaths.run(graph,Array(1)).vertices.join(users).take(10)

res204: Array[(org.apache.spark.graphx.VertexId, (org.apache.spark.graphx.lib.ShortestPaths.SPMap, User))] = Array((4,(Map(1 -> 2),User(Liz,Doctor))), (6,(Map(1 -> 2),User(Beth,Accountant))), (8,(Map(1 -> 2),User(Mary,Cashier))), (10,(Map(1 -> 1),User(Ken,Librarian))), (2,(Map(1 -> 1),User(Mark,Doctor))), (1,(Map(1 -> 0),User(John,Accountant))), (3,(Map(1 -> 1),User(Sam,Lawyer))), (7,(Map(1 -> 2),User(Larry,Engineer))), (9,(Map(1 -> 3),User(Dan,Doctor))), (5,(Map(1 -> 2),User(Eric,Accountant))))

ShortestPaths选择两个顶点之间的最短路径,以跳数计算。以下图表显示了JohnLarry有三种方式,其中两种路径长度为 2,一种长度为 3。从前面代码的结果来看,清楚地显示了从Larry到 John 选择的路径长度为 2。

在上面的代码块的输出中,显示了一个包含路径长度和节点的向量(7,(Map(1 -> 2),User(Larry,Engineer)))

我们还可以使用加权边来计算最短路径,这意味着连接用户的每条边都不相同。例如,如果我们可以将边值/权重/属性视为每个用户所住地点之间的距离,我们就得到了一个加权图。在这种情况下,最短路径是通过英里数计算两个用户之间的距离:

scala> val srcId = 1 //vertex ID 1 is the user John
srcId: Int = 1

scala> val initGraph = graph.mapVertices((id, x) => if(id == srcId) 0.0 else Double.PositiveInfinity)
initGraph: org.apache.spark.graphx.Graph[Double,Long] = org.apache.spark.graphx.impl.GraphImpl@2b9b8608

scala> val weightedShortestPath = initGraph.pregel(Double.PositiveInfinity, 5)(
 | (id, dist, newDist) => math.min(dist, newDist),
 | triplet => {
 | if (triplet.srcAttr + triplet.attr < triplet.dstAttr) {
 | Iterator((triplet.dstId, triplet.srcAttr + triplet.attr))
 | }
 | else {
 | Iterator.empty
 | }
 | },
 | (a, b) => math.min(a, b)
 | )
weightedShortestPath: org.apache.spark.graphx.Graph[Double,Long] = org.apache.spark.graphx.impl.GraphImpl@1f87fdd3

scala> weightedShortestPath.vertices.take(10).mkString("\n")
res247: String =
(4,10.0)
(6,6.0)
(8,6.0)
(10,5.0)
(2,1.0)
(1,0.0)
(3,3.0)
(7,7.0)
(9,5.0)
(5,4.0)

以下是一个使用 Pregel API 从JohnLarry计算单源最短路径的图表,从初始化开始,逐次迭代直到找到最佳路径。

通过将代表John的顶点的值设置为零,将所有其他顶点设置为正无穷来初始化图:

初始化完成后,我们将使用 Pregel 进行四次迭代来重新计算顶点的值。在每次迭代中,我们遍历所有顶点,并在每个顶点处检查是否有从源顶点到目标顶点的更好路径。如果有这样的边/路径,那么顶点的值将被更新。

让我们定义两个函数distance(v)distance(s, t),其中distance(v)给出一个顶点的值,distance(s,t)给出连接st的边的值。

在第一次迭代中,除了 John 之外的每个用户都被设置为无穷大,John 的值为 0,因为他是源顶点。现在,我们使用 Pregel 循环遍历顶点,并检查是否有比无穷大更好的路径。以 Ken 为例,我们将检查distance("John") + distance("John", "Ken") < distance("Ken")

这相当于检查0 + 5 < 无穷大,结果是true;所以我们将 Ken 的距离更新为5

同样,我们检查 Mary,distance("Ken") + distance("Ken", "Mary") < distance("Mary"),结果是false,因为那时 Ken 仍然是无穷大。因此,在第一次迭代中,我们只能更新与 John 相连的用户。

在下一次迭代中,Mary、Liz、Eric 等等,都会被更新,因为现在我们已经更新了 Ken、Mark 和 Sam 在第一次迭代中的数值。这将持续一段时间,具体次数由 Pregel API 调用中指定。

下面是在计算图上的单源最短路径时的各种迭代的示例:

经过四次迭代后,从JohnLarry的最短路径显示为五英里。从JohnLarry的路径可以通过John | Mark | Sam | Larry来看到。

PageRank

PageRank是图处理领域中最重要的算法之一。这个算法起源于谷歌,以谷歌创始人拉里·佩奇的名字命名,基于根据关系或边对顶点或节点进行排名的概念,已经发展成许多类型的用例。

Google PageRank 通过计算指向页面的链接数量和质量来确定网站的重要性的大致估计。基本假设是更重要的网站很可能会从其他网站获得更多的链接。有关更多信息,您可以阅读en.wikipedia.org/wiki/PageRank中的描述。

以 Google PageRank 为例,您可以通过在其他流行网站和技术博客中推广网页来提高公司网站或博客上网页的相对重要性。使用这种方法,如果有很多第三方网站展示您的博客网站和内容,您的博客网站可能会在 Google 搜索结果中关于某篇文章的排名比其他类似网页更高。

搜索引擎优化SEO)是营销世界中最大的行业之一,几乎每个网站都在投资这项技术。SEO 涉及各种技术和策略,基本上是为了提高你的网站在任何搜索引擎结果中出现的排名,当有人搜索相关词语时。这是基于 Google PageRank 的概念。

如果你把网页看作节点/顶点,网页之间的超链接看作边,我们实际上创建了一个图。现在,如果你可以把网页的排名看作指向它的超链接/边的数量,比如你的myblog.com网站在cnn.commsnbc.com上有链接,这样用户就可以点击链接来到你的myblog.com页面。这可以作为代表myblog.com顶点重要性的因素。如果我们递归应用这个简单的逻辑,最终我们会得到每个顶点的排名,这是根据入边的数量和基于源顶点排名的 PageRank 计算得出的。一个被许多具有高 PageRank 的页面链接的页面本身也会获得高排名。让我们看看如何使用 Spark GraphX 解决大数据规模下的 PageRank 问题。正如我们所见,PageRank 衡量了图中每个顶点的重要性,假设从ab的边代表了ba提升的价值。例如,如果一个 Twitter 用户被许多其他用户关注,那么该用户将被高排名。

GraphX 提供了 PageRank 的静态和动态实现,作为pageRank对象的方法。静态 PageRank 运行固定次数的迭代,而动态 PageRank 则运行直到排名收敛。GraphOps允许直接调用这些算法作为图上的方法:

scala> val prVertices = graph.pageRank(0.0001).vertices
prVertices: org.apache.spark.graphx.VertexRDD[Double] = VertexRDDImpl[8245] at RDD at VertexRDD.scala:57

scala> prVertices.join(users).sortBy(_._2._1, false).take(10)
res190: Array[(org.apache.spark.graphx.VertexId, (Double, User))] = Array((10,(1.4600029149839906,User(Ken,Librarian))), (8,(1.1424200609462447,User(Mary,Cashier))), (3,(1.1279748817993318,User(Sam,Lawyer))), (2,(1.1253662371576425,User(Mark,Doctor))), (1,(1.0986118723393328,User(John,Accountant))), (9,(0.8215535923013982,User(Dan,Doctor))), (5,(0.8186673059832846,User(Eric,Accountant))), (7,(0.8107902215195832,User(Larry,Engineer))), (4,(0.8047583729877394,User(Liz,Doctor))), (6,(0.783902117150218,User(Beth,Accountant))))

PageRank 算法在图上的图表如下:

总结

在本章中,我们以 Facebook 为例介绍了图论;Apache Spark 的图处理库 GraphX,VertexRDD和 EdgeRDDs;图操作符,aggregateMessagesTriangleCounting和 Pregel API;以及 PageRank 算法等用例。我们还看到了旅行推销员问题和连通组件等。我们看到了 GraphX API 如何用于开发大规模图处理算法。

在第十一章中,学习机器学习 - Spark MLlib 和 ML,我们将探索 Apache Spark 的机器学习库的激动人心的世界。

第十一章:学习机器学习- Spark MLlib 和 Spark ML

“我们每个人,实际上每个动物,都是数据科学家。我们从传感器中收集数据,然后处理数据,得到抽象规则来感知我们的环境,并控制我们在环境中的行为,以最大程度地减少痛苦和/或最大化快乐。我们有记忆来将这些规则存储在我们的大脑中,然后在需要时回忆和使用它们。学习是终身的;当规则不再适用或环境发生变化时,我们会忘记规则或修订规则。”

  • Ethem Alpaydin,《机器学习:新人工智能》

本章的目的是为那些在典型的统计培训中可能不会接触到这些方法的人提供统计机器学习(ML)技术的概念介绍。本章还旨在通过几个步骤,将新手从对机器学习了解甚少,提升到成为了解的实践者。我们将以理论和实践的方式专注于 Spark 的机器学习 API,称为 Spark MLlib 和 ML。此外,我们将提供一些涵盖特征提取和转换、降维、回归和分类分析的示例。简而言之,本章将涵盖以下主题:

  • 机器学习介绍

  • Spark 机器学习 API

  • 特征提取和转换

  • 使用 PCA 进行回归的降维

  • 二元和多类分类

机器学习介绍

在本节中,我们将尝试从计算机科学、统计学和数据分析的角度定义机器学习。**机器学习(ML)**是计算机科学的一个分支,它使计算机能够在没有明确编程的情况下学习(1959 年 Arthur Samuel)。这一研究领域是从人工智能中的模式识别和计算学习理论中发展而来的。

更具体地说,ML 探索了可以从启发式学习并对数据进行预测的算法的研究和构建。这种算法通过从样本输入构建模型,克服了严格静态的程序指令,进行数据驱动的预测或决策。现在让我们从计算机科学的角度更明确和多样化地定义,来自 Tom M. Mitchell 教授的定义,解释了机器学习从计算机科学的角度真正意味着什么:

计算机程序被认为是在某类任务 T 和性能度量 P 方面从经验 E 中学习,如果它在 T 中的任务表现,根据 P 的度量,随着经验 E 的提高而改善。

根据这个定义,我们可以得出结论,计算机程序或机器可以:

  • 从数据和历史中学习

  • 通过经验改进

  • 交互式地增强可以用来预测问题结果的模型

典型的机器学习任务包括概念学习、预测建模、聚类和发现有用的模式。最终目标是通过改进学习方式,使其变得自动化,以至于不再需要人类干预,或者尽可能减少人类干预的程度。尽管机器学习有时与知识发现和数据挖掘KDDM)混淆,但 KDDM 更侧重于探索性数据分析,被称为无监督学习。典型的机器学习应用可以分为科学知识发现和更商业化的应用,从机器人技术或人机交互HCI)到反垃圾邮件过滤和推荐系统。

典型的机器学习工作流程

典型的机器学习应用包括从输入、处理到输出的几个步骤,形成了一个科学工作流程,如图 1所示。典型的机器学习应用涉及以下步骤:

  1. 加载样本数据。

  2. 将数据解析成算法的输入格式。

  3. 预处理数据和处理缺失值。

  4. 将数据分成两组:用于构建模型的训练数据集和用于测试模型的验证数据集。

  5. 运行算法来构建或训练您的 ML 模型。

  6. 使用训练数据进行预测并观察结果。

  7. 使用测试数据测试和评估模型,或者使用交叉验证技术使用第三个数据集(验证数据集)验证模型。

  8. 调整模型以获得更好的性能和准确性。

  9. 扩展模型,以便能够处理未来的大规模数据集。

  10. 在商业化中部署 ML 模型。

图 1:机器学习工作流程

通常,机器学习算法有一些方法来处理数据集中的偏斜。这种偏斜有时候是巨大的。在步骤 4 中,实验数据集通常被随机分成训练集和测试集,这被称为抽样。训练数据集用于训练模型,而测试数据集用于评估最佳模型的性能。更好的做法是尽可能多地使用训练数据集来提高泛化性能。另一方面,建议只使用测试数据集一次,以避免在计算预测误差和相关指标时出现过拟合问题。

机器学习任务

根据学习系统可用的学习反馈的性质,ML 任务或过程通常分为三大类:监督学习、无监督学习和强化学习,如图 2 所示。此外,还有其他机器学习任务,例如降维、推荐系统、频繁模式挖掘等等。

图 2:机器学习任务

监督学习

监督学习应用是基于一组示例进行预测的,其目标是学习将输入映射到与现实世界一致的输出的一般规则。例如,用于垃圾邮件过滤的数据集通常包含垃圾邮件和非垃圾邮件。因此,我们能够知道训练集中的消息是垃圾邮件还是正常邮件。然而,我们可能有机会利用这些信息来训练我们的模型,以便对新的未见过的消息进行分类。下图显示了监督学习的示意图。算法找到所需的模式后,这些模式可以用于对未标记的测试数据进行预测。这是最流行和有用的机器学习任务类型,对 Spark 也不例外,那里的大多数算法都是监督学习技术:

图 3:监督学习实例

例如,分类和回归用于解决监督学习问题。我们将提供几个监督学习的例子,比如逻辑回归、随机森林、决策树、朴素贝叶斯、一对多等等。然而,为了让讨论更具体,本书只会讨论逻辑回归和随机森林,其他算法将在第十二章《高级机器学习最佳实践》中讨论,并附有一些实际例子。另一方面,线性回归将用于回归分析。

无监督学习

在无监督学习中,数据点没有与之相关的标签。因此,我们需要以算法方式对其进行标记,如下图所示。换句话说,在无监督学习中,训练数据集的正确类别是未知的。因此,类别必须从非结构化数据中推断出来,这意味着无监督学习算法的目标是通过描述其结构来对数据进行某种结构化的预处理。

为了克服无监督学习中的障碍,通常使用聚类技术根据某些相似性度量对未标记的样本进行分组。因此,这项任务还涉及挖掘隐藏模式以进行特征学习。聚类是智能地对数据集中的项目进行分类的过程。总体思想是,同一聚类中的两个项目比属于不同聚类的项目“更接近”。这是一般定义,留下了“接近”的解释。

图 4:无监督学习

示例包括聚类、频繁模式挖掘和降维以解决无监督学习问题(也可以应用于监督学习问题)。我们将在本书中提供几个无监督学习的例子,如 k 均值、二分 k 均值、高斯混合模型、潜在狄利克雷分配LDA)等。我们还将展示如何通过回归分析在监督学习中使用降维算法,如主成分分析PCA)或奇异值分解SVD)。

降维DR):降维是一种在特定条件下减少随机变量数量的技术。这种技术用于监督学习和无监督学习。使用降维技术的典型优势如下:

  • 它减少了机器学习任务所需的时间和存储空间

  • 它有助于消除多重共线性,并提高机器学习模型的性能

  • 数据可视化变得更容易,当降低到非常低的维度,如 2D 或 3D 时

强化学习

作为人类,你和我们也从过去的经验中学习。我们并不是偶然变得迷人的。多年来的积极赞美和负面批评都帮助塑造了我们今天的样子。通过与朋友、家人甚至陌生人的互动,你学会了如何让人们快乐,通过尝试不同的肌肉运动,你学会了如何骑自行车,直到顿悟。当你执行动作时,有时会立即得到奖励。例如,找到附近的购物中心可能会带来即时的满足感。其他时候,奖励不会立即出现,比如长途旅行找到一个特别好的吃饭地方。这些都是关于强化学习(RL)的。

因此,RL 是一种技术,模型本身从一系列行为或动作中学习。数据集的复杂性或样本复杂性对于强化学习需要的算法成功学习目标函数非常重要。此外,为了实现最终目标,与外部环境交互时应确保最大化奖励函数,如下图所示:

图 5:强化学习

强化学习技术正在许多领域中使用。以下是一个非常简短的列表:

  • 广告有助于学习排名,对新出现的项目使用一次性学习,新用户将带来更多的收入

  • 教导机器人新任务,同时保留先前的知识

  • 从国际象棋开局到交易策略推导复杂的分层方案

  • 路由问题,例如,管理船队,分配卡车/司机到哪个货物

  • 在机器人技术中,算法必须根据一组传感器读数选择机器人的下一个动作

  • 它也是物联网IoT)应用的自然选择,其中计算机程序与动态环境进行交互,必须在没有明确导师的情况下实现某个目标

  • 最简单的强化学习问题之一被称为 n 臂老丨虎丨机。问题在于有 n 台老丨虎丨机,但每台的固定支付概率不同。目标是通过始终选择支付最佳的机器来最大化利润。

  • 一个新兴的应用领域是股票市场交易。在这里,交易员就像一个强化学习代理,因为购买和出售(即行动)特定股票会通过产生利润或损失来改变交易员的状态,即奖励。

推荐系统

推荐系统是信息过滤系统的一个子类,旨在预测用户通常对物品提供的评分或偏好。推荐系统的概念近年来变得非常普遍,并随后被应用于不同的应用程序。

图 6:不同的推荐系统

最流行的可能是产品(例如电影、音乐、书籍、研究文章、新闻、搜索查询、社交标签等)。推荐系统通常可以被分类为以下四类:

  • 协同过滤,也称为社交过滤,通过使用其他人的推荐来过滤信息。问题在于过去对某些物品评价意见一致的人未来可能再次意见一致。因此,例如,想要观看电影的人可能会向朋友们寻求推荐。一旦他收到了一些有相似兴趣的朋友的推荐,这些推荐就比其他人的推荐更可信。这些信息被用于决定要观看哪部电影。

  • 基于内容的过滤(也称为认知过滤),根据物品内容和用户个人资料之间的比较来推荐物品。每个物品的内容被表示为一组描述符或术语,通常是文档中出现的词语。用户个人资料也用相同的术语表示,并通过分析用户已经看过的物品的内容来构建。然而,在实施这些类型的推荐系统时,需要考虑以下问题:

  • 首先,术语可以自动或手动分配。对于自动分配,必须选择一种方法,以便可以从物品列表中提取这些物品。其次,术语必须以一种方式表示,以便用户个人资料和物品可以进行有意义的比较。学习算法本身必须明智地选择,以便能够基于已观察到的(即已看到的)物品学习用户个人资料,并根据这个用户个人资料做出适当的推荐。基于内容的过滤系统主要用于文本文档,其中术语解析器用于从文档中选择单词。向量空间模型和潜在语义索引是使用这些术语将文档表示为多维空间中的向量的两种方法。此外,它还用于相关反馈、遗传算法、神经网络和贝叶斯分类器来学习用户个人资料。

  • 混合推荐系统是最近的研究和混合方法(即,结合协同过滤和基于内容的过滤)。Netflix 就是这样一个推荐系统的很好的例子,它使用了受限玻尔兹曼机RBM)和一种矩阵分解算法,用于像 IMDb 这样的大型电影数据库(详见pdfs.semanticscholar.org/789a/d4218d1e2e920b4d192023f840fe8246d746.pdf)。这种推荐系统通过比较相似用户的观看和搜索习惯来简单地推荐电影、剧集或流媒体,称为评分预测。

  • 基于知识的系统,其中使用有关用户和产品的知识来推断满足用户需求的内容,使用感知树、决策支持系统和基于案例的推理。

在本章中,我们将讨论基于协同过滤的电影推荐系统。

半监督学习

在监督学习和无监督学习之间,半监督学习有一小部分空间。在这种情况下,ML 模型通常接收不完整的训练信号。更具体地说,ML 模型接收到一组带有一些目标输出缺失的训练集。半监督学习更多地是基于假设,并且通常使用三种假设算法作为未标记数据的学习算法。使用以下假设:平滑性、聚类和流形。换句话说,半监督学习还可以被称为弱监督或使用未标记示例的自举技术,以增强从少量标记数据中学习的隐藏财富。

如前所述,学习问题的标记数据的获取通常需要一个熟练的人类代理。因此,与标记过程相关的成本可能使得完全标记的训练集变得不可行,而未标记数据的获取相对廉价。

例如:转录音频片段,确定蛋白质的 3D 结构或确定特定位置是否存在石油,期望最小化和人类认知,以及传递性。在这种情况下,半监督学习可以具有很大的实际价值。

Spark 机器学习 API

在本节中,我们将描述由 Spark 机器学习库(Spark MLlib 和 Spark ML)引入的两个关键概念,以及与我们在前几节中讨论的监督和无监督学习技术相一致的最常用的实现算法。

Spark 机器学习库

如前所述,在 Spark 之前的时代,大数据建模者通常使用统计语言(如 R、STATA 和 SAS)构建他们的 ML 模型。然而,这种工作流程(即这些 ML 算法的执行流程)缺乏效率、可伸缩性和吞吐量,以及准确性,当然,执行时间也更长。

然后,数据工程师过去常常需要在 Java 中重新实现相同的模型,例如在 Hadoop 上部署。使用 Spark,相同的 ML 模型可以被重建、采用和部署,使整个工作流程更加高效、稳健和快速,从而使您能够提供实时见解以提高性能。此外,在 Hadoop 中实现这些算法意味着这些算法可以并行运行,而这是 R、STATA 和 SAS 等软件无法实现的。Spark 机器学习库分为两个包:Spark MLlib(spark.mllib)和 Spark ML(spark.ml)。

Spark MLlib

MLlib 是 Spark 的可扩展机器学习库,是 Spark Core API 的扩展,提供了一系列易于使用的机器学习算法库。Spark 算法是用 Scala 实现的,然后暴露给 Java、Scala、Python 和 R 的 API。Spark 支持存储在单台机器上的本地向量和矩阵数据类型,以及由一个或多个 RDD 支持的分布式矩阵。Spark MLlib 的优点是多种多样的。例如,算法具有高度可扩展性,并利用 Spark 处理大量数据的能力。

  • 它们是为并行计算而设计的快速前进,具有基于内存的操作,比 MapReduce 数据处理快 100 倍(它们还支持基于磁盘的操作,比 MapReduce 的普通数据处理快 10 倍)。

  • 它们是不同的,因为它们涵盖了用于回归分析、分类、聚类、推荐系统、文本分析和频繁模式挖掘的常见机器学习算法,并且显然涵盖了构建可扩展机器学习应用程序所需的所有步骤。

Spark ML

Spark ML 添加了一组新的机器学习 API,让用户可以快速在数据集上组装和配置实用的机器学习管道。Spark ML 旨在提供一组统一的高级 API,建立在 DataFrame 而不是 RDD 之上,帮助用户创建和调整实用的机器学习管道。Spark ML API 标准化了机器学习算法,使得将多个算法组合成单个管道或数据工作流程更容易,供数据科学家使用。Spark ML 使用 DataFrame 和 Datasets 的概念,这些概念是在 Spark 1.6 中引入的(作为实验性功能),然后在 Spark 2.0+中使用。

在 Scala 和 Java 中,DataFrame 和 Dataset 已经统一,也就是说,DataFrame 只是行数据集的类型别名。在 Python 和 R 中,由于缺乏类型安全性,DataFrame 是主要的编程接口。

数据集包含各种数据类型,例如存储文本、特征向量和数据的真实标签的列。除此之外,Spark ML 还使用转换器将一个 DataFrame 转换为另一个,或者反之亦然,其中估计器的概念用于拟合 DataFrame 以生成新的转换器。另一方面,管道 API 可以将多个转换器和估计器组合在一起,以指定一个 ML 数据工作流程。在开发 ML 应用程序时,参数的概念被引入,以便指定所有转换器和估计器在一个统一的 API 下共享。

Spark MLlib 还是 Spark ML?

Spark ML 提供了一个基于 DataFrame 构建 ML 管道的高级 API。基本上,Spark ML 为你提供了一个工具集,用于在数据上构建不同的机器学习相关的转换管道。例如,它可以轻松地将特征提取、降维和分类器的训练链在一起,作为一个整体,后续可以用于分类。然而,MLlib 更老,开发时间更长,因此它具有更多的功能。因此,建议使用 Spark ML,因为其 API 更加灵活多样,适用于 DataFrame。

特征提取和转换

假设你要构建一个机器学习模型,用于预测信用卡交易是否欺诈。现在,基于可用的背景知识和数据分析,你可能会决定哪些数据字段(也就是特征)对于训练模型是重要的。例如,金额、客户姓名、购买公司名称和信用卡所有者的地址都值得提供给整个学习过程。这些都是重要考虑的因素,因为如果你只提供一个随机生成的交易 ID,那将不会携带任何信息,因此毫无用处。因此,一旦你决定在训练集中包括哪些特征,你就需要转换这些特征以便更好地训练模型。特征转换可以帮助你向训练数据添加额外的背景信息。这些信息使得机器学习模型最终能够从这种经验中受益。为了使前面的讨论更具体,假设你有一个客户的地址如下所示的字符串:

"123 Main Street, Seattle, WA 98101"

如果你看到上述地址,你会发现地址缺乏适当的语义。换句话说,该字符串的表达能力有限。例如,这个地址只对学习与数据库中的确切地址相关的地址模式有用。然而,将其分解为基本部分可以提供额外的特征,例如以下内容:

  • “地址”(123 Main Street)

  • "City" (Seattle)

  • "State" (WA)

  • "Zip" (98101)

如果您看到前面的模式,您的 ML 算法现在可以将更多不同的交易分组在一起,并发现更广泛的模式。这是正常的,因为一些客户的邮政编码比其他客户的邮政编码更容易产生欺诈活动。Spark 提供了几种用于特征提取和使转换更容易的算法。例如,当前版本提供了以下算法用于特征提取:

  • TF-IDF

  • Word2vec

  • CountVectorizer

另一方面,特征转换器是一个包括特征转换器和学习模型的抽象。从技术上讲,转换器实现了一个名为transform()的方法,它通过附加一个或多个列,将一个 DataFrame 转换为另一个 DataFrame。Spark 支持以下转换器到 RDD 或 DataFrame:

  • 分词器

  • StopWordsRemover

  • n-gram

  • Binarizer

  • PCA

  • PolynomialExpansion

  • 离散余弦变换(DCT)

  • StringIndexer

  • IndexToString

  • OneHotEncoder

  • VectorIndexer

  • Interaction

  • Normalizer

  • StandardScaler

  • MinMaxScaler

  • MaxAbsScaler

  • Bucketizer

  • ElementwiseProduct

  • SQLTransformer

  • VectorAssembler

  • QuantileDiscretizer

由于页面限制,我们无法描述所有内容。但我们将讨论一些广泛使用的算法,如CountVectorizerTokenizerStringIndexerStopWordsRemoverOneHotEncoder等。PCA,通常用于降维,将在下一节中讨论。

CountVectorizer

CountVectorizerCountVectorizerModel旨在帮助将一组文本文档转换为标记计数的向量。当先前的字典不可用时,CountVectorizer可以用作估计器来提取词汇表并生成CountVectorizerModel。该模型为文档在词汇表上生成了稀疏表示,然后可以传递给其他算法,如 LDA。

假设我们有以下文本语料库:

图 7:仅包含名称的文本语料库

现在,如果我们想将前面的文本集合转换为标记计数的向量,Spark 提供了CountVectorizer()API 来实现。首先,让我们为前面的表创建一个简单的 DataFrame,如下所示:

val df = spark.createDataFrame(
Seq((0, Array("Jason", "David")),
(1, Array("David", "Martin")),
(2, Array("Martin", "Jason")),
(3, Array("Jason", "Daiel")),
(4, Array("Daiel", "Martin")),
(5, Array("Moahmed", "Jason")),
(6, Array("David", "David")),
(7, Array("Jason", "Martin")))).toDF("id", "name")
df.show(false)

在许多情况下,您可以使用setInputCol设置输入列。让我们看一个例子,并让我们从语料库中拟合一个CountVectorizerModel对象,如下所示:

val cvModel: CountVectorizerModel = new CountVectorizer()
                           .setInputCol("name")
                           .setOutputCol("features")
                           .setVocabSize(3)
                           .setMinDF(2)
                           .fit(df)

现在让我们使用提取器下游化向量化器,如下所示:

val feature = cvModel.transform(df)
spark.stop()

现在让我们检查一下,确保它正常工作:

feature.show(false)

上一行代码产生了以下输出:

图 8:名称文本语料库已被特征化

现在让我们转到特征转换器。最重要的转换器之一是分词器,它经常用于处理分类数据的机器学习任务。我们将在下一节中看到如何使用这个转换器。

分词器

标记化是从原始文本中提取重要组件(如单词和句子),并将原始文本分解为单个术语(也称为单词)的过程。如果您想对正则表达式匹配进行更高级的标记化,RegexTokenizer是一个很好的选择。默认情况下,参数pattern(regex,默认:s+)用作分隔符来分割输入文本。否则,您还可以将参数gaps设置为 false,表示正则表达式pattern表示tokens而不是分割间隙。这样,您可以找到所有匹配的出现作为标记化结果。

假设您有以下句子:

  • 标记化,是从原始文本中提取单词的过程。

  • 如果您想进行更高级的标记化,RegexTokenizer是一个不错的选择。

  • 在这里,我们将提供一个示例,演示如何对句子进行标记化。

  • 这样,您可以找到所有匹配的出现。

现在,您希望从前面的四个句子中对每个有意义的单词进行标记化。让我们从前面的句子中创建一个 DataFrame,如下所示:

val sentence = spark.createDataFrame(Seq(
 (0, "Tokenization,is the process of enchanting words,from the raw text"),
 (1, " If you want,to have more advance tokenization,RegexTokenizer,
       is a good option"),
 (2, " Here,will provide a sample example on how to tockenize sentences"),
 (3, "This way,you can find all matching occurrences"))).toDF("id",
                                                        "sentence")

现在,通过实例化Tokenizer()API 创建一个标记器,如下所示:

val tokenizer = new Tokenizer().setInputCol("sentence").setOutputCol("words") 

现在,使用 UDF 计算每个句子中的标记数,如下所示:import org.apache.spark.sql.functions._

val countTokens = udf { (words: Seq[String]) => words.length } 

现在对每个句子中的单词进行标记化,如下所示:

val tokenized = tokenizer.transform(sentence) 

最后,按如下方式显示每个原始句子的每个标记:

tokenized.select("sentence", "words")
.withColumn("tokens", countTokens(col("words")))
.show(false) 

上一行代码打印了一个从标记化的 DataFrame 中获取原始句子、词袋和标记数的快照:

**图 9:**从原始文本中标记化的单词

但是,如果您使用RegexTokenizerAPI,您将获得更好的结果。操作如下:

通过实例化RegexTokenizer()API 创建一个正则表达式标记器:

val regexTokenizer = new RegexTokenizer()
                     .setInputCol("sentence")
                     .setOutputCol("words")
                     .setPattern("\\W+")
                     .setGaps(true)

现在对每个句子中的单词进行标记化,如下所示:

val regexTokenized = regexTokenizer.transform(sentence) 
regexTokenized.select("sentence", "words") 
              .withColumn("tokens", countTokens(col("words")))
              .show(false)

上一行代码打印了一个使用 RegexTokenizer 包含原始句子、词袋和标记数的标记化 DataFrame 的快照:

**图 10:**使用 RegexTokenizer 更好的标记化

StopWordsRemover

停用词是应该从输入中排除的单词,通常是因为这些单词频繁出现且没有太多含义。Spark 的StopWordsRemover接受一个字符串序列作为输入,该序列由TokenizerRegexTokenizer标记化。然后,它从输入序列中删除所有停用词。停用词列表由stopWords参数指定。StopWordsRemoverAPI 的当前实现为丹麦语、荷兰语、芬兰语、法语、德语、匈牙利语、意大利语、挪威语、葡萄牙语、俄语、西班牙语、瑞典语、土耳其语和英语提供了选项。举个例子,我们可以简单地扩展上一节中的Tokenizer示例,因为它们已经被标记化。但是,对于此示例,我们将使用RegexTokenizerAPI。

首先,通过StopWordsRemover()API 创建一个停用词移除器实例,如下所示:

val remover = new StopWordsRemover()
             .setInputCol("words")
             .setOutputCol("filtered")

现在,让我们删除所有停用词并按如下方式打印结果:

val newDF = remover.transform(regexTokenized)
 newDF.select("id", "filtered").show(false)

上一行代码打印了一个从过滤后的 DataFrame 中排除停用词的快照:

**图 11:**过滤(即去除停用词)的标记

StringIndexer

StringIndexer 将标签的字符串列编码为标签索引列。索引在0,numLabels)中,按标签频率排序,因此最常见的标签获得索引 0。如果输入列是数字,我们将其转换为字符串并索引字符串值。当下游管道组件(如估计器或转换器)使用此字符串索引标签时,您必须将组件的输入列设置为此字符串索引列名称。在许多情况下,您可以使用setInputCol设置输入列。假设您有以下格式的一些分类数据:

**图 12:**应用 String Indexer 的 DataFrame

现在,我们想要对名称列进行索引,以便最常见的名称(在我们的案例中为 Jason)获得索引 0。为此,Spark 提供了StringIndexerAPI。对于我们的示例,可以按如下方式完成:首先,让我们为上表创建一个简单的 DataFrame:

val df = spark.createDataFrame( 
  Seq((0, "Jason", "Germany"), 
      (1, "David", "France"), 
      (2, "Martin", "Spain"), 
      (3, "Jason", "USA"), 
      (4, "Daiel", "UK"), 
      (5, "Moahmed", "Bangladesh"), 
      (6, "David", "Ireland"), 
      (7, "Jason", "Netherlands"))
).toDF("id", "name", "address")

现在让我们对名称列进行索引,如下所示:

val indexer = new StringIndexer() 
    .setInputCol("name") 
    .setOutputCol("label") 
    .fit(df)

现在让我们使用转换器下游索引器,如下所示:

val indexed = indexer.transform(df)

现在让我们检查一下是否它正常工作:

indexed.show(false)

**图 13:**使用 StringIndexer 创建标签

另一个重要的转换器是 OneHotEncoder,在处理分类数据的机器学习任务中经常使用。我们将在下一节中看到如何使用这个转换器。

OneHotEncoder

一种独热编码将标签索引列映射到具有最多一个值的二进制向量列。这种编码允许期望连续特征(例如逻辑回归)的算法使用分类特征。假设您有以下格式的一些分类数据(与我们在上一节中描述StringIndexer时使用的相同):

**图 14:**应用 OneHotEncoder 的 DataFrame

现在,我们想要对名称列进行索引,以便数据集中最常见的名称(即我们的情况下的Jason)获得索引0。然而,仅仅对它们进行索引有什么用呢?换句话说,您可以进一步将它们向量化,然后可以轻松地将 DataFrame 提供给任何 ML 模型。由于我们已经在上一节中看到如何创建 DataFrame,在这里,我们将展示如何将它们编码为向量:

val indexer = new StringIndexer()
                  .setInputCol("name")
                  .setOutputCol("categoryIndex")
                  .fit(df)
val indexed = indexer.transform(df)
val encoder = new OneHotEncoder()
                  .setInputCol("categoryIndex")
                  .setOutputCol("categoryVec")

现在让我们使用Transformer将其转换为向量,然后查看内容,如下所示:

val encoded = encoder.transform(indexed)
encoded.show()

包含快照的结果 DataFrame 如下:

图 15:使用 OneHotEncoder 创建类别索引和向量

现在您可以看到,结果 DataFrame 中添加了一个包含特征向量的新列。

Spark ML 管道

MLlib 的目标是使实际机器学习(ML)可扩展且易于使用。Spark 引入了管道 API,用于轻松创建和调整实际的 ML 管道。如前所述,在 ML 管道创建中通过特征工程提取有意义的知识涉及一系列数据收集、预处理、特征提取、特征选择、模型拟合、验证和模型评估阶段。例如,对文本文档进行分类可能涉及文本分割和清理、提取特征以及使用交叉验证训练分类模型进行调整。大多数 ML 库都不是为分布式计算设计的,或者它们不提供管道创建和调整的本地支持。

数据集抽象

从另一种编程语言(例如 Java)运行 SQL 查询时,结果将作为 DataFrame 返回。DataFrame 是一种分布式的数据集合,组织成具有命名列的数据。另一方面,数据集是一个接口,试图提供 Spark SQL 中 RDD 的好处。数据集可以从一些 JVM 对象构造,例如原始类型(例如StringIntegerLong)、Scala case 类和 Java Beans。ML 管道涉及一系列数据集转换和模型。每个转换都接受一个输入数据集,并输出转换后的数据集,这成为下一阶段的输入。因此,数据导入和导出是 ML 管道的起点和终点。为了使这些更容易,Spark MLlib 和 Spark ML 提供了数据集、DataFrame、RDD 和模型的导入和导出工具,适用于几种特定应用类型,包括:

  • 用于分类和回归的 LabeledPoint

  • 用于交叉验证和潜在狄利克雷分配(LDA)的 LabeledDocument

  • 协同过滤的评分和排名

然而,真实数据集通常包含多种类型,例如用户 ID、项目 ID、标签、时间戳和原始记录。不幸的是,Spark 实现的当前工具不能轻松处理由这些类型组成的数据集,特别是时间序列数据集。特征转换通常占据实际 ML 管道的大部分。特征转换可以被视为从现有列创建新列的附加或删除。

在下图中,您将看到文本标记器将文档分解为词袋。之后,TF-IDF 算法将词袋转换为特征向量。在转换过程中,标签需要保留以用于模型拟合阶段:

图 16:用于机器学习模型的文本处理(DS 表示数据源)

在这里,ID、文本和单词在转换步骤中被让步。它们对于进行预测和模型检查是有用的。然而,它们实际上对于模型拟合来说是不必要的。如果预测数据集只包含预测标签,它们也不提供太多信息。因此,如果你想要检查预测指标,比如准确性、精确度、召回率、加权真阳性和加权假阳性,查看预测标签以及原始输入文本和标记化单词是非常有用的。相同的建议也适用于使用 Spark ML 和 Spark MLlib 的其他机器学习应用。

因此,RDD、数据集和 DataFrame 之间的简单转换已经成为可能,用于内存、磁盘或外部数据源,如 Hive 和 Avro。虽然使用用户定义的函数从现有列创建新列很容易,但数据集的显现是一种懒惰的操作。相反,数据集仅支持一些标准数据类型。然而,为了增加可用性并使其更适合机器学习模型,Spark 还添加了对Vector类型的支持,作为一种支持mllib.linalg.DenseVectormllib.linalg.Vector下的稠密和稀疏特征向量的用户定义类型。

在 Spark 分发的examples/src/main/文件夹中可以找到 Java、Scala 和 Python 中的完整 DataFrame、数据集和 RDD 示例。感兴趣的读者可以参考 Spark SQL 的用户指南spark.apache.org/docs/latest/sql-programming-guide.html来了解更多关于 DataFrame、数据集以及它们支持的操作。

创建一个简单的管道

Spark 在 Spark ML 下提供了管道 API。管道包括一系列由转换器和估计器组成的阶段。管道阶段有两种基本类型,称为转换器和估计器:

  • 转换器将数据集作为输入,并产生增强的数据集作为输出,以便输出可以被传递到下一步。例如,TokenizerHashingTF是两个转换器。Tokenizer 将具有文本的数据集转换为具有标记化单词的数据集。另一方面,HashingTF 产生术语频率。标记化和 HashingTF 的概念通常用于文本挖掘和文本分析。

  • 相反,估计器必须是输入数据集中的第一个,以产生模型。在这种情况下,模型本身将被用作转换器,将输入数据集转换为增强的输出数据集。例如,在拟合训练数据集与相应的标签和特征之后,可以将逻辑回归或线性回归用作估计器。

然后,它产生一个逻辑或线性回归模型,这意味着开发管道是简单而容易的。你所需要做的就是声明所需的阶段,然后配置相关阶段的参数;最后,将它们链接在一个管道对象中,如下图所示:

图 17:使用逻辑回归估计器的 Spark ML 管道模型(DS 表示数据存储,在虚线内的步骤仅在管道拟合期间发生)

如果你看一下图 17,拟合的模型包括一个 Tokenizer,一个 HashingTF 特征提取器和一个拟合的逻辑回归模型。拟合的管道模型充当了一个转换器,可以用于预测、模型验证、模型检查,最后是模型部署。然而,为了提高预测准确性的性能,模型本身需要进行调整。

现在我们知道了 Spark MLlib 和 ML 中可用的算法,现在是时候在正式解决监督和无监督学习问题之前做好准备了。在下一节中,我们将开始进行特征提取和转换。

无监督机器学习

在本节中,为了使讨论更具体,将仅讨论使用 PCA 进行降维和用于文本聚类的 LDA 主题建模。其他无监督学习算法将在第十三章中讨论,我的名字是贝叶斯,朴素贝叶斯,并附有一些实际示例。

降维

降维是减少考虑的变量数量的过程。它可以用于从原始和嘈杂的特征中提取潜在特征,或者在保持结构的同时压缩数据。Spark MLlib 支持对RowMatrix类进行降维。用于降低数据维度的最常用算法是 PCA 和 SVD。然而,在本节中,我们将仅讨论 PCA 以使讨论更具体。

PCA

PCA 是一种统计程序,它使用正交变换将可能相关的变量的一组观察转换为一组称为主成分的线性不相关变量的值。PCA 算法可以用来使用 PCA 将向量投影到低维空间。然后,基于降维后的特征向量,可以训练一个 ML 模型。以下示例显示了如何将 6D 特征向量投影到四维主成分中。假设你有一个特征向量如下:

val data = Array(
 Vectors.dense(3.5, 2.0, 5.0, 6.3, 5.60, 2.4),
 Vectors.dense(4.40, 0.10, 3.0, 9.0, 7.0, 8.75),
 Vectors.dense(3.20, 2.40, 0.0, 6.0, 7.4, 3.34) )

现在让我们从中创建一个 DataFrame,如下所示:

val df = spark.createDataFrame(data.map(Tuple1.apply)).toDF("features")
df.show(false)

上述代码生成了一个具有 6D 特征向量的特征 DataFrame 用于 PCA:

图 18:创建一个特征 DataFrame(6 维特征向量)用于 PCA

现在让我们通过设置必要的参数来实例化 PCA 模型,如下所示:

val pca = new PCA()
 .setInputCol("features")
 .setOutputCol("pcaFeatures")
 .setK(4) 
 .fit(df)

现在,为了有所不同,我们使用setOutputCol()方法将输出列设置为pcaFeatures。然后,我们设置 PCA 的维度。最后,我们拟合 DataFrame 以进行转换。请注意,PCA 模型包括一个explainedVariance成员。可以从这样的旧数据加载模型,但explainedVariance将为空向量。现在让我们展示结果特征:

val result = pca.transform(df).select("pcaFeatures") 
result.show(false)

上述代码生成了一个使用 PCA 的主成分作为 4D 特征向量的特征 DataFrame:

图 19:四维主成分(PCA 特征)

使用 PCA

PCA 广泛用于降维,是一种帮助找到旋转矩阵的统计方法。例如,如果我们想检查第一个坐标是否具有最大可能的方差。它还有助于检查是否有任何后续坐标会产生最大可能的方差。

最终,PCA 模型计算这些参数并将它们作为旋转矩阵返回。旋转矩阵的列被称为主成分。Spark MLlib 支持对以行为导向格式存储的高瘦矩阵和任何向量进行 PCA。

回归分析 - PCA 的实际用途

在本节中,我们将首先探索将用于回归分析的MSD百万首歌数据集)。然后我们将展示如何使用 PCA 来减少数据集的维度。最后,我们将评估回归质量的线性回归模型。

数据集收集和探索

在本节中,我们将描述非常著名的 MNIST 数据集。这个数据集将在本章中使用。手写数字的 MNIST 数据库(从www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass.html下载)有一个包含 60,000 个示例的训练集和一个包含 10,000 个示例的测试集。它是 NIST 提供的更大数据集的子集。这些数字已经被大小标准化并居中在固定大小的图像中。因此,对于那些试图在实际数据上学习技术和模式识别方法,同时在预处理和格式化上付出最少努力的人来说,这是一个非常好的示例数据集。来自 NIST 的原始黑白(双级)图像被大小标准化以适应 20 x 20 像素的框,同时保持它们的长宽比。

MNIST 数据库是从 NIST 的特殊数据库 3 和特殊数据库 1 构建的,其中包含手写数字的二进制图像。数据集的样本如下所示:

图 20:MNIST 数据集的快照

您可以看到总共有 780 个特征。因此,有时许多机器学习算法会因数据集的高维特性而失败。因此,为了解决这个问题,在下一节中,我们将向您展示如何在不牺牲机器学习任务的质量的情况下减少维度。然而,在深入研究问题之前,让我们先了解一些关于回归分析的背景知识。

什么是回归分析?

线性回归属于回归算法家族。回归的目标是找到变量之间的关系和依赖性。它是使用线性函数对连续标量因变量y(也称为标签或目标,在机器学习术语中)和一个或多个(D 维向量)解释变量(也称为自变量、输入变量、特征、观察数据、观测、属性、维度、数据点等)x之间的关系进行建模。在回归分析中,目标是预测连续的目标变量,如下图所示:

图 21:回归算法旨在产生连续输出。输入可以是

离散或连续(来源:Nishant Shukla,使用 TensorFlow 进行机器学习,Manning Publications co. 2017)

现在,您可能对分类和回归问题的基本区别有些困惑。以下信息框将使其更清晰:

**回归与分类:**另一方面,另一个领域称为分类,是关于从有限集合中预测标签,但具有离散值。这种区别很重要,因为离散值输出更适合分类,这将在接下来的部分中讨论。

涉及输入变量的多元回归模型采用以下形式:

y = ss[0] + ss[1]x[1] + ss[2]x[2] + ss[3]x[3] +..... + e

图 22 显示了一个简单线性回归的例子,其中有一个自变量(x轴)。模型(红线)是使用训练数据(蓝点)计算的,其中每个点都有一个已知的标签(y轴),以尽可能准确地拟合点,通过最小化所选损失函数的值。然后我们可以使用模型来预测未知的标签(我们只知道x值,想要预测y值)。

图 22:回归图分离数据点(点【.】指图中的数据点,红线指回归)

Spark 提供了基于 RDD 的线性回归算法实现。您可以使用随机梯度下降来训练无正则化的线性回归模型。这解决了最小二乘回归公式 f (weights) = 1/n ||A weights-y||²(即均方误差)。在这里,数据矩阵有n行,输入 RDD 保存了A的一组行,每个行都有其相应的右手边标签y。有关更多信息,请参阅github.com/apache/spark/blob/master/mllib/src/main/scala/org/apache/spark/mllib/regression/LinearRegression.scala

步骤 1. 加载数据集并创建 RDD

要以 LIBSVM 格式加载 MNIST 数据集,我们在这里使用了 Spark MLlib 的内置 API MLUtils:

val data = MLUtils.loadLibSVMFile(spark.sparkContext, "data/mnist.bz2") 

步骤 2. 计算特征数以便更容易进行降维:

val featureSize = data.first().features.size
println("Feature Size: " + featureSize)

这将导致以下输出:

Feature Size: 780

因此,数据集有 780 列 - 即特征,因此可以将其视为高维数据(特征)。因此,有时值得减少数据集的维度。

步骤 3. 现在按以下方式准备训练和测试集:

问题是我们将两次训练LinearRegressionwithSGD模型。首先,我们将使用原始特征的正常数据集,其次,使用一半的特征。对于原始数据集,训练和测试集的准备如下进行:

val splits = data.randomSplit(Array(0.75, 0.25), seed = 12345L)
val (training, test) = (splits(0), splits(1))

现在,对于减少的特征,训练如下进行:

val pca = new PCA(featureSize/2).fit(data.map(_.features))
val training_pca = training.map(p => p.copy(features = pca.transform(p.features)))
val test_pca = test.map(p => p.copy(features = pca.transform(p.features))) 

步骤 4. 训练线性回归模型

现在迭代 20 次,并分别对正常特征和减少特征进行LinearRegressionWithSGD训练,如下所示:

val numIterations = 20
val stepSize = 0.0001
val model = LinearRegressionWithSGD.train(training, numIterations)
val model_pca = LinearRegressionWithSGD.train(training_pca, numIterations)

注意!有时,LinearRegressionWithSGD()会返回NaN。在我看来,这种情况发生有两个原因:

  • 如果stepSize很大。在这种情况下,您应该使用较小的值,例如 0.0001、0.001、0.01、0.03、0.1、0.3、1.0 等。

  • 您的训练数据有NaN。如果是这样,结果可能会是NaN。因此,建议在训练模型之前删除空值。

步骤 5. 评估两个模型

在评估分类模型之前,首先让我们准备计算正常情况下的 MSE,以查看降维对原始预测的影响。显然,如果您想要一种正式的方法来量化模型的准确性,并可能增加精度并避免过拟合。尽管如此,您可以通过残差分析来做。还值得分析用于模型构建和评估的训练和测试集的选择。最后,选择技术可以帮助您描述模型的各种属性:

val valuesAndPreds = test.map { point =>
                      val score = model.predict(point.features)
                      (score, point.label)
                     }

现在按以下方式计算 PCA 的预测集:

val valuesAndPreds_pca = test_pca.map { point =>
                         val score = model_pca.predict(point.features)
                         (score, point.label)
                       }

现在按以下方式计算 MSE 并打印每种情况:

val MSE = valuesAndPreds.map { case (v, p) => math.pow(v - p 2) }.mean()
val MSE_pca = valuesAndPreds_pca.map { case (v, p) => math.pow(v - p, 2) }.mean()
println("Mean Squared Error = " + MSE)
println("PCA Mean Squared Error = " + MSE_pca)

您将得到以下输出:

Mean Squared Error = 2.9164359135973043E78
PCA Mean Squared Error = 2.9156682256149184E78

请注意,MSE 实际上是使用以下公式计算的:

步骤 6. 观察两个模型的模型系数

按以下方式计算模型系数:

println("Model coefficients:"+ model.toString())
println("Model with PCA coefficients:"+ model_pca.toString())

现在您应该在终端/控制台上观察以下输出:

Model coefficients: intercept = 0.0, numFeatures = 780
Model with PCA coefficients: intercept = 0.0, numFeatures = 390

二元和多类分类

二元分类器用于将给定数据集的元素分为两种可能的组(例如,欺诈或非欺诈),是多类分类的特例。大多数二元分类指标可以推广为多类分类指标。多类分类描述了一个分类问题,其中每个数据点有M>2个可能的标签(M=2的情况是二元分类问题)。

对于多类指标,正例和负例的概念略有不同。预测和标签仍然可以是正面或负面,但必须考虑特定类别的上下文。每个标签和预测都取多个类别中的一个值,因此它们被认为是其特定类别的正面,对于所有其他类别则是负面。因此,每当预测和标签匹配时,就会出现真正的正例,而当预测和标签都不取给定类别的值时,就会出现真负例。按照这个约定,对于给定的数据样本,可能会有多个真负例。从前面对正负标签的定义扩展出的假负例和假正例是直接的。

性能指标

虽然有许多不同类型的分类算法,但评估指标或多或少共享相似的原则。在监督分类问题中,每个数据点都存在真实输出和模型生成的预测输出。因此,每个数据点的结果可以分配到四个类别中的一个:

  • 真正例TP):标签为正,预测也为正。

  • 真负例TN):标签为负,预测也为负。

  • 假正例FP):标签为负,但预测为正。

  • 假负例FN):标签为正,但预测为负。

现在,为了更清楚地了解这些参数,请参考以下图:

图 23:预测分类器(即混淆矩阵)

TP、FP、TN、FN 是大多数分类器评估指标的基本组成部分。在考虑分类器评估时的一个基本观点是,纯粹的准确性(即预测是否正确或不正确)通常不是一个好的度量标准。这是因为数据集可能高度不平衡。例如,如果一个模型被设计为从一个数据集中预测欺诈,其中 95%的数据点不是欺诈,5%的数据点是欺诈。然后假设一个天真的分类器预测不是欺诈(不考虑输入)将有 95%的准确率。因此,通常使用精确度和召回率等指标,因为它们考虑了错误的类型。在大多数应用中,精确度和召回率之间存在一定的平衡,这可以通过将两者结合成一个单一指标来捕捉,称为F-度量

精确度表示被正确分类的正例有多少是相关的。另一方面,召回率表示测试在检测阳性方面有多好?在二元分类中,召回率称为敏感性。重要的是要注意,精确度可能不会随着召回率而下降。召回率和精确度之间的关系可以在图中的阶梯区域中观察到:

  • 接收器操作特性(ROC)

  • ROC 曲线下的面积

  • 精确度-召回率曲线下的面积

这些曲线通常用于二元分类来研究分类器的输出。然而,有时结合精确度和召回率来选择两个模型是很好的。相比之下,使用多个数字评估指标的精确度和召回率使得比较算法变得更加困难。假设您有两个算法的表现如下:

分类器 精确度 召回率
X 96% 89%
Y 99% 84%

在这里,没有一个分类器显然优于另一个,因此它并不能立即指导您选择最佳的分类器。但是使用 F1 分数,这是一个结合了精确度和召回率(即精确度和召回率的调和平均值)的度量,平衡了 F1 分数。让我们计算一下,并将其放在表中:

分类器 精确度 召回率 F1 分数
X 96% 89% 92.36%
Y 99% 84% 90.885%

因此,具有 F1 分数有助于从大量分类器中进行选择。它为所有分类器提供了清晰的偏好排名,因此为进展提供了明确的方向,即分类器X

对于二元分类,可以计算前述性能指标如下:

图 24:计算二元分类器性能指标的数学公式(来源:spark.apache.org/docs/2.1.0/mllib-evaluation-metrics.html

然而,在多类分类问题中,与两个预测标签相关联的情况下,计算先前的指标更加复杂,但可以使用以下数学方程进行计算:

图 25:计算多类分类器性能指标的数学公式

修改后的δ函数称为修改的δ函数,可以定义如下(来源:spark.apache.org/docs/2.1.0/mllib-evaluation-metrics.html):

使用逻辑回归进行二元分类

逻辑回归广泛用于预测二元响应。这是一种可以用数学方式表示的线性方法:

在上述方程中,*L(w; x, y)*是称为逻辑损失的损失函数。

对于二元分类问题,该算法将输出一个二元逻辑回归模型。给定一个新的数据点,用x表示,该模型通过应用逻辑函数进行预测:

其中z = w^Tx,默认情况下,如果f(w^Tx)>0.5,结果为正,否则为负,尽管与线性支持向量机不同,逻辑回归模型的原始输出f(z)具有概率解释(即x为正的概率)。

线性支持向量机是最新的极快的机器学习(数据挖掘)算法,用于解决超大数据集的多类分类问题,实现了一个原始专有版本的线性支持向量机的切割平面算法(来源:www.linearsvm.com/)。

使用 Spark ML 的逻辑回归进行乳腺癌预测

在本节中,我们将看看如何使用 Spark ML 开发癌症诊断管道。将使用真实数据集来预测乳腺癌的概率。更具体地说,将使用威斯康星乳腺癌数据集。

数据集收集

在这里,我们使用了更简单的数据集,这些数据集经过结构化和手动筛选,用于机器学习应用程序开发,当然,其中许多数据集显示出良好的分类准确性。来自 UCI 机器学习库的威斯康星乳腺癌数据集([archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+(Original)](https://archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+(Original))包含了由威斯康星大学的研究人员捐赠的数据,并包括来自乳腺肿块的细针抽吸的数字化图像的测量。这些值代表数字图像中细胞核的特征,如下一小节所述:

0\. Sample code number id number
1\. Clump Thickness 1 - 10
2\. Uniformity of Cell Size 1 - 10
3\. Uniformity of Cell Shape 1 - 10
4\. Marginal Adhesion 1 - 10
5\. Single Epithelial Cell Size 1 - 10
6\. Bare Nuclei 1 - 10
7\. Bland Chromatin 1 - 10
8\. Normal Nucleoli 1 - 10
9\. Mitoses 1 - 10
10\. Class: (2 for benign, 4 for malignant)

要了解更多关于威斯康星乳腺癌数据集的信息,请参阅作者的出版物:用于乳腺肿瘤诊断的核特征提取IS&T/SPIE 1993 国际电子成像研讨会:科学与技术,卷 1905,第 861-870 页,作者为W.N. StreetW.H. WolbergO.L. Mangasarian,1993 年。

使用 Spark ML 开发管道

现在我们将向您展示如何通过逐步示例预测乳腺癌的可能性:

步骤 1:加载和解析数据

val rdd = spark.sparkContext.textFile("data/wbcd.csv") 
val cancerRDD = parseRDD(rdd).map(parseCancer) 

parseRDD()方法如下:

def parseRDD(rdd: RDD[String]): RDD[Array[Double]] = { 
  rdd.map(_.split(",")).filter(_(6) != "?").map(_.drop(1)).map(_.map(_.toDouble)) 
} 

parseCancer()方法如下:

def parseCancer(line: Array[Double]): Cancer = { 
  Cancer(if (line(9) == 4.0) 1 else 0, line(0), line(1), line(2), line(3), line(4), line(5), line(6), line(7), line(8)) 
}  

请注意,这里我们简化了数据集。对于值 4.0,我们已将其转换为 1.0,否则为 0.0。Cancer类是一个可以定义如下的案例类:

case class Cancer(cancer_class: Double, thickness: Double, size: Double, shape: Double, madh: Double, epsize: Double, bnuc: Double, bchrom: Double, nNuc: Double, mit: Double)

步骤 2:将 RDD 转换为 ML 管道的 DataFrame

import spark.sqlContext.implicits._
val cancerDF = cancerRDD.toDF().cache() 
cancerDF.show() 

DataFrame 如下所示:

**图 26:**癌症数据集的快照

步骤 3:特征提取和转换

首先,让我们选择特征列,如下所示:

val featureCols = Array("thickness", "size", "shape", "madh", "epsize", "bnuc", "bchrom", "nNuc", "mit") 

现在让我们将它们组装成一个特征向量,如下所示:

val assembler = new VectorAssembler().setInputCols(featureCols).setOutputCol("features") 

现在将它们转换为 DataFrame,如下所示:

val df2 = assembler.transform(cancerDF) 

让我们看一下转换后的 DataFrame 的结构:

df2.show() 

现在,您应该观察到一个包含基于左侧列计算的特征的 DataFrame:

**图 27:**包含特征的新 DataFrame

最后,让我们使用StringIndexer为训练数据集创建标签,如下所示:

val labelIndexer = new StringIndexer().setInputCol("cancer_class").setOutputCol("label")
val df3 = labelIndexer.fit(df2).transform(df2)
df3.show() 

现在,您应该观察到一个包含基于左侧列计算的特征和标签的 DataFrame:

**图 28:**包含用于训练 ML 模型的特征和标签的新 DataFrame

步骤 4:创建测试和训练集

val splitSeed = 1234567 
val Array(trainingData, testData) = df3.randomSplit(Array(0.7, 0.3), splitSeed)

步骤 5:使用训练集创建估计器

让我们使用带有elasticNetParam的逻辑回归创建管道的估计器。我们还指定最大迭代次数和回归参数,如下所示:

val lr = new LogisticRegression().setMaxIter(50).setRegParam(0.01).setElasticNetParam(0.01) 
val model = lr.fit(trainingData)  

步骤 6:获取测试集的原始预测、概率和预测

使用测试集转换模型以获取测试集的原始预测、概率和预测:

val predictions = model.transform(testData) 
predictions.show() 

生成的 DataFrame 如下所示:

**图 29:**包含每行的原始预测和实际预测的新 DataFrame

步骤 7:生成训练的目标历史

让我们生成模型在每次迭代中的目标历史,如下所示:

val trainingSummary = model.summary 
val objectiveHistory = trainingSummary.objectiveHistory 
objectiveHistory.foreach(loss => println(loss))

前面的代码段产生了以下关于训练损失的输出:

    0.6562291876496595
    0.6087867761081431
    0.538972588904556
    0.4928455913405332
    0.46269258074999386
    0.3527914819973198
    0.20206901337404978
    0.16459454874996993
    0.13783437051276512
    0.11478053164710095
    0.11420433621438157
    0.11138884788059378
    0.11041889032338036
    0.10849477236373875
    0.10818880537879513
    0.10682868640074723
    0.10641395229253267
    0.10555411704574749
    0.10505186414044905
    0.10470425580130915
    0.10376219754747162
    0.10331139609033112
    0.10276173290225406
    0.10245982201904923
    0.10198833366394071
    0.10168248313103552
    0.10163242551955443
    0.10162826209311404
    0.10162119367292953
    0.10161235376791203
    0.1016114803209495
    0.10161090505556039
    0.1016107261254795
    0.10161056082112738
    0.10161050381332608
    0.10161048515341387
    0.10161043900301985
    0.10161042057436288
    0.10161040971267737
    0.10161040846923354
    0.10161040625542347
    0.10161040595207525
    0.10161040575664354
    0.10161040565870835
    0.10161040519559975
    0.10161040489834573
    0.10161040445215266
    0.1016104043469577
    0.1016104042793553
    0.1016104042606048
    0.10161040423579716 

正如您所看到的,损失在后续迭代中逐渐减少。

步骤 8:评估模型

首先,我们必须确保我们使用的分类器来自二元逻辑回归摘要:

val binarySummary = trainingSummary.asInstanceOf[BinaryLogisticRegressionSummary]

现在让我们获取 ROC 作为DataFrameareaUnderROC。接近 1.0 的值更好:

val roc = binarySummary.roc 
roc.show() 
println("Area Under ROC: " + binarySummary.areaUnderROC)

前面的行打印了areaUnderROC的值,如下所示:

Area Under ROC: 0.9959095884623509

这太棒了!现在让我们计算其他指标,如真阳性率、假阳性率、假阴性率、总计数以及正确和错误预测的实例数量,如下所示:

import org.apache.spark.sql.functions._

// Calculate the performance metrics
val lp = predictions.select("label", "prediction") 
val counttotal = predictions.count() 
val correct = lp.filter($"label" === $"prediction").count() 
val wrong = lp.filter(not($"label" === $"prediction")).count() 
val truep = lp.filter($"prediction" === 0.0).filter($"label" === $"prediction").count() 
val falseN = lp.filter($"prediction" === 0.0).filter(not($"label" === $"prediction")).count() 
val falseP = lp.filter($"prediction" === 1.0).filter(not($"label" === $"prediction")).count() 
val ratioWrong = wrong.toDouble / counttotal.toDouble 
val ratioCorrect = correct.toDouble / counttotal.toDouble 

println("Total Count: " + counttotal) 
println("Correctly Predicted: " + correct) 
println("Wrongly Identified: " + wrong) 
println("True Positive: " + truep) 
println("False Negative: " + falseN) 
println("False Positive: " + falseP) 
println("ratioWrong: " + ratioWrong) 
println("ratioCorrect: " + ratioCorrect) 

现在,您应该观察到前面代码的输出如下:

总计数:209 正确预测:202 错误识别:7 真阳性:140 假阴性:4 假阳性:3 错误比率:0.03349282296650718 正确比率:0.9665071770334929

最后,让我们评估模型的准确性。但是,首先,我们需要将模型阈值设置为最大化fMeasure

val fMeasure = binarySummary.fMeasureByThreshold 
val fm = fMeasure.col("F-Measure") 
val maxFMeasure = fMeasure.select(max("F-Measure")).head().getDouble(0) 
val bestThreshold = fMeasure.where($"F-Measure" === maxFMeasure).select("threshold").head().getDouble(0) 
model.setThreshold(bestThreshold) 

现在让我们计算准确性,如下所示:

val evaluator = new BinaryClassificationEvaluator().setLabelCol("label") 
val accuracy = evaluator.evaluate(predictions) 
println("Accuracy: " + accuracy)     

前面的代码产生了以下输出,几乎为 99.64%:

Accuracy: 0.9963975418520874

使用逻辑回归进行多类分类

二元逻辑回归可以推广为多项式逻辑回归,用于训练和预测多类分类问题。例如,对于K个可能的结果,可以选择其中一个结果作为枢轴,其他K−1个结果可以分别对枢轴结果进行回归。在spark.mllib中,选择第一个类 0 作为pivot类。

对于多类分类问题,算法将输出一个多项式逻辑回归模型,其中包含k−1 个二元逻辑回归模型,回归到第一个类。给定一个新的数据点,将运行*k−1 个模型,并选择具有最大概率的类作为预测类。在本节中,我们将通过使用带有 L-BFGS 的逻辑回归的分类示例来向您展示。

步骤 1:在 LIVSVM 格式中加载和解析 MNIST 数据集

// Load training data in LIBSVM format.
 val data = MLUtils.loadLibSVMFile(spark.sparkContext, "data/mnist.bz2")

步骤 2. 准备训练和测试集

将数据分割为训练集(75%)和测试集(25%),如下所示:

val splits = data.randomSplit(Array(0.75, 0.25), seed = 12345L)
val training = splits(0).cache()
val test = splits(1)

步骤 3. 运行训练算法来构建模型

运行训练算法,通过设置一定数量的类别(对于这个数据集为 10)来构建模型。为了获得更好的分类准确性,您还可以指定截距,并使用布尔值 true 来验证数据集,如下所示:

val model = new LogisticRegressionWithLBFGS()
           .setNumClasses(10)
           .setIntercept(true)
           .setValidateData(true)
           .run(training)

如果算法应该使用setIntercept()添加一个截距,则将截距设置为 true。如果您希望算法在模型构建之前验证训练集,您应该使用setValidateData()方法将值设置为 true。

步骤 4. 清除默认阈值

清除默认阈值,以便训练不使用默认设置进行,如下所示:

model.clearThreshold()

步骤 5. 在测试集上计算原始分数

在测试集上计算原始分数,以便我们可以使用上述性能指标评估模型,如下所示:

val scoreAndLabels = test.map { point =>
  val score = model.predict(point.features)
  (score, point.label)
}

步骤 6. 实例化一个多类度量以进行评估

val metrics = new MulticlassMetrics(scoreAndLabels)

步骤 7. 构建混淆矩阵

println("Confusion matrix:")
println(metrics.confusionMatrix)

在混淆矩阵中,矩阵的每一列代表预测类别中的实例,而每一行代表实际类别中的实例(反之亦然)。名称源自于这样一个事实,即它很容易看出系统是否混淆了两个类别。更多信息,请参阅矩阵(en.wikipedia.org/wiki/Confusion_matrix.Confusion):

图 30: 逻辑回归分类器生成的混淆矩阵

步骤 8. 整体统计

现在让我们计算整体统计数据来判断模型的性能:

val accuracy = metrics.accuracy
println("Summary Statistics")
println(s"Accuracy = $accuracy")
// Precision by label
val labels = metrics.labels
labels.foreach { l =>
  println(s"Precision($l) = " + metrics.precision(l))
}
// Recall by label
labels.foreach { l =>
  println(s"Recall($l) = " + metrics.recall(l))
}
// False positive rate by label
labels.foreach { l =>
  println(s"FPR($l) = " + metrics.falsePositiveRate(l))
}
// F-measure by label
labels.foreach { l =>
  println(s"F1-Score($l) = " + metrics.fMeasure(l))
}

前面的代码段产生了以下输出,包含一些性能指标,如准确度、精确度、召回率、真正率、假正率和 f1 分数:

Summary Statistics
 ----------------------
 Accuracy = 0.9203609775377116
 Precision(0.0) = 0.9606815203145478
 Precision(1.0) = 0.9595732734418866
 .
 .
 Precision(8.0) = 0.8942172073342737
 Precision(9.0) = 0.9027210884353741

 Recall(0.0) = 0.9638395792241946
 Recall(1.0) = 0.9732346241457859
 .
 .
 Recall(8.0) = 0.8720770288858322
 Recall(9.0) = 0.8936026936026936

 FPR(0.0) = 0.004392386530014641
 FPR(1.0) = 0.005363128491620112
 .
 .
 FPR(8.0) = 0.010927369417935456
 FPR(9.0) = 0.010441004672897197

 F1-Score(0.0) = 0.9622579586478502
 F1-Score(1.0) = 0.966355668645745
 .
 .
 F1-Score(9.0) = 0.8981387478849409

现在让我们计算整体统计数据,即总结统计数据:

println(s"Weighted precision: ${metrics.weightedPrecision}")
println(s"Weighted recall: ${metrics.weightedRecall}")
println(s"Weighted F1 score: ${metrics.weightedFMeasure}")
println(s"Weighted false positive rate: ${metrics.weightedFalsePositiveRate}") 

前面的代码段打印了包含加权精确度、召回率、f1 分数和假正率的以下输出:

Weighted precision: 0.920104303076327
 Weighted recall: 0.9203609775377117
 Weighted F1 score: 0.9201934861645358
 Weighted false positive rate: 0.008752250453215607

总体统计数据表明,模型的准确性超过 92%。然而,我们仍然可以通过使用更好的算法(如随机森林 RF)来改进。在下一节中,我们将看一下随机森林实现,以对同一模型进行分类。

使用随机森林提高分类准确性

随机森林(有时也称为随机决策森林)是决策树的集成。随机森林是分类和回归中最成功的机器学习模型之一。它们结合了许多决策树,以减少过拟合的风险。与决策树一样,随机森林处理分类特征,扩展到多类分类设置,不需要特征缩放,并且能够捕捉非线性和特征交互。RF 有许多优点。它们可以通过组合许多决策树来克服其训练数据集上的过拟合问题。

RF 或 RDF 中的森林通常由数十万棵树组成。这些树实际上是在同一训练集的不同部分上训练的。更技术上地说,生长得非常深的单个树往往会学习到高度不可预测的模式。树的这种性质会在训练集上产生过拟合问题。此外,低偏差使得分类器即使在特征质量良好的情况下也表现不佳。另一方面,RF 有助于通过计算案例之间的接近度将多个决策树平均在一起,以减少方差,以确保一致性。

然而,这会增加一些偏差或结果的可解释性的损失。但是,最终模型的性能会显著提高。在使用 RF 作为分类器时,以下是参数设置:

  • 如果树的数量为 1,则根本不使用自举;但是,如果树的数量为*> 1*,则会进行自举。支持的值为autoallsqrtlog2onethird

  • 支持的数值范围为*(0.0-1.0][1-n]*。但是,如果featureSubsetStrategy选择为auto,算法会自动选择最佳的特征子集策略。

  • 如果numTrees == 1,则featureSubsetStrategy设置为all。但是,如果numTrees > 1(即森林),则featureSubsetStrategy设置为sqrt用于分类。

  • 此外,如果在范围*(0, 1.0]内设置了实际值n*,将使用n*number_of_features。但是,如果在范围(1,特征数)内设置了整数值n,则只会交替使用n个特征。

  • categoricalFeaturesInfo参数是一个用于存储任意分类特征的映射。条目*(n → k)表示特征n是具有k个类别的分类特征,索引从0: {0, 1,...,k-1}*。

  • 仅用于信息增益计算的杂质标准。支持的值分别为ginivariance,用于分类和回归。

  • maxDepth是树的最大深度(例如,深度 0 表示 1 个叶节点,深度 1 表示 1 个内部节点+2 个叶节点,依此类推)。

  • maxBins表示用于分割特征的最大箱数,建议值为 100 以获得更好的结果。

  • 最后,随机种子用于自举和选择特征子集,以避免结果的随机性。

如前所述,由于 RF 对大规模数据集的快速和可扩展性足够强,Spark 是实现 RF 以实现大规模可扩展性的合适技术。但是,如果计算了接近度,存储需求也会呈指数级增长。

使用随机森林对 MNIST 数据集进行分类

在本节中,我们将展示使用随机森林进行分类的示例。我们将逐步分解代码,以便您可以轻松理解解决方案。

步骤 1. 加载和解析 LIVSVM 格式的 MNIST 数据集

// Load training data in LIBSVM format.
 val data = MLUtils.loadLibSVMFile(spark.sparkContext, "data/mnist.bz2")

步骤 2. 准备训练和测试集

将数据分为训练集(75%)和测试集(25%),并设置种子以实现可重现性,如下所示:

val splits = data.randomSplit(Array(0.75, 0.25), seed = 12345L)
val training = splits(0).cache()
val test = splits(1)

步骤 3. 运行训练算法来构建模型

使用空的categoricalFeaturesInfo训练随机森林模型。这是必需的,因为数据集中的所有特征都是连续的:

val numClasses = 10 //number of classes in the MNIST dataset
val categoricalFeaturesInfo = Map[Int, Int]()
val numTrees = 50 // Use more in practice.More is better
val featureSubsetStrategy = "auto" // Let the algorithm choose.
val impurity = "gini" // see above notes on RandomForest for explanation
val maxDepth = 30 // More is better in practice
val maxBins = 32 // More is better in practice 
val model = RandomForest.trainClassifier(training, numClasses, categoricalFeaturesInfo, numTrees, featureSubsetStrategy, impurity, maxDepth, maxBins)

请注意,训练随机森林模型需要大量资源。因此,它将占用更多内存,所以要注意 OOM。我建议在运行此代码之前增加 Java 堆空间。

步骤 4. 在测试集上计算原始分数

在测试集上计算原始分数,以便我们可以使用上述性能指标评估模型,如下所示:

val scoreAndLabels = test.map { point =>
  val score = model.predict(point.features)
  (score, point.label)
}

步骤 5. 实例化一个多类指标进行评估

val metrics = new MulticlassMetrics(scoreAndLabels)

步骤 6. 构建混淆矩阵

println("Confusion matrix:")
println(metrics.confusionMatrix)

上述代码打印了我们分类的以下混淆矩阵:

**图 31:**由随机森林分类器生成的混淆矩阵

步骤 7. 总体统计

现在让我们计算总体统计数据,以评估模型的性能:

val accuracy = metrics.accuracy
println("Summary Statistics")
println(s"Accuracy = $accuracy")
// Precision by label
val labels = metrics.labels
labels.foreach { l =>
  println(s"Precision($l) = " + metrics.precision(l))
}
// Recall by label
labels.foreach { l =>
  println(s"Recall($l) = " + metrics.recall(l))
}
// False positive rate by label
labels.foreach { l =>
  println(s"FPR($l) = " + metrics.falsePositiveRate(l))
}
// F-measure by label
labels.foreach { l =>
  println(s"F1-Score($l) = " + metrics.fMeasure(l))
} 

上述代码段产生以下输出,包含一些性能指标,如准确度、精度、召回率、真正率、假正率和 F1 分数:

Summary Statistics:
 ------------------------------
 Precision(0.0) = 0.9861932938856016
 Precision(1.0) = 0.9891799544419134
 .
 .
 Precision(8.0) = 0.9546079779917469
 Precision(9.0) = 0.9474747474747475

 Recall(0.0) = 0.9778357235984355
 Recall(1.0) = 0.9897435897435898
 .
 .
 Recall(8.0) = 0.9442176870748299
 Recall(9.0) = 0.9449294828744124

 FPR(0.0) = 0.0015387997362057595
 FPR(1.0) = 0.0014151646059883808
 .
 .
 FPR(8.0) = 0.0048136532710962
 FPR(9.0) = 0.0056967572304995615

 F1-Score(0.0) = 0.9819967266775778
 F1-Score(1.0) = 0.9894616918256907
 .
 .
 F1-Score(8.0) = 0.9493844049247605
 F1-Score(9.0) = 0.9462004034969739

现在让我们计算总体统计数据,如下所示:

println(s"Weighted precision: ${metrics.weightedPrecision}")
println(s"Weighted recall: ${metrics.weightedRecall}")
println(s"Weighted F1 score: ${metrics.weightedFMeasure}")
println(s"Weighted false positive rate: ${metrics.weightedFalsePositiveRate}")
val testErr = labelAndPreds.filter(r => r._1 != r._2).count.toDouble / test.count()
println("Accuracy = " + (1-testErr) * 100 + " %")

上述代码段打印以下输出,包含加权精度、召回率、F1 分数和假正率:

Overall statistics
 ----------------------------
 Weighted precision: 0.966513107682512
 Weighted recall: 0.9664712469534286
 Weighted F1 score: 0.9664794711607312
 Weighted false positive rate: 0.003675328222679072
 Accuracy = 96.64712469534287 %

总体统计数据表明,模型的准确度超过 96%,比逻辑回归的准确度更高。但是,我们仍然可以通过更好的模型调整来改进它。

摘要

在本章中,我们简要介绍了这个主题,并掌握了简单但强大和常见的机器学习技术。最后,您学会了如何使用 Spark 构建自己的预测模型。您学会了如何构建分类模型,如何使用模型进行预测,最后,如何使用常见的机器学习技术,如降维和独热编码。

在后面的部分,您看到了如何将回归技术应用于高维数据集。然后,您看到了如何应用二元和多类分类算法进行预测分析。最后,您看到了如何使用随机森林算法实现出色的分类准确性。然而,我们还有其他机器学习的主题需要涵盖,例如推荐系统和模型调优,以获得更稳定的性能,然后再部署模型。

在下一章中,我们将涵盖一些 Spark 的高级主题。我们将提供机器学习模型调优的示例,以获得更好的性能,我们还将分别介绍电影推荐和文本聚类的两个示例。

第十二章:高级机器学习最佳实践

“超参数优化或模型选择是选择学习算法的一组超参数[何时定义为?]的问题,通常目标是优化算法在独立数据集上的性能度量。”

  • 机器学习模型调整报价

在本章中,我们将提供一些关于使用 Spark 进行机器学习(ML)的一些高级主题的理论和实践方面。我们将看到如何使用网格搜索、交叉验证和超参数调整来调整机器学习模型,以获得更好和优化的性能。在后面的部分,我们将介绍如何使用 ALS 开发可扩展的推荐系统,这是一个基于模型的推荐算法的示例。最后,将演示一种文本聚类技术作为主题建模应用。

简而言之,本章中我们将涵盖以下主题:

  • 机器学习最佳实践

  • ML 模型的超参数调整

  • 使用潜在狄利克雷分配(LDA)进行主题建模

  • 使用协同过滤的推荐系统

机器学习最佳实践

有时,建议考虑错误率而不仅仅是准确性。例如,假设一个 ML 系统的准确率为 99%,错误率为 50%,比一个准确率为 90%,错误率为 25%的系统更差。到目前为止,我们已经讨论了以下机器学习主题:

  • 回归:用于预测线性可分离的值

  • 异常检测:用于发现异常数据点,通常使用聚类算法进行

  • 聚类:用于发现数据集中同质数据点的隐藏结构

  • 二元分类:用于预测两个类别

  • 多类分类:用于预测三个或更多类别

好吧,我们也看到了一些适合这些任务的好算法。然而,选择适合您问题类型的正确算法是实现 ML 算法更高和更出色准确性的棘手任务。为此,我们需要通过从数据收集、特征工程、模型构建、评估、调整和部署的阶段采用一些良好的实践。考虑到这些,在本节中,我们将在使用 Spark 开发 ML 应用程序时提供一些建议。

注意过拟合和欠拟合

一条直线穿过一个弯曲的散点图将是欠拟合的一个很好的例子,正如我们在这里的图表中所看到的。然而,如果线条过于贴合数据,就会出现一个相反的问题,称为过拟合。当我们说一个模型过拟合了数据集,我们的意思是它可能在训练数据上有低错误率,但在整体数据中不能很好地泛化。

图 1:过拟合-欠拟合权衡(来源:亚当吉布森,乔什帕特森的书《深度学习》)

更具体地说,如果您在训练数据上评估模型而不是测试或验证数据,您可能无法确定您的模型是否过拟合。常见的症状如下:

  • 用于训练的数据的预测准确性可能过于准确(即有时甚至达到 100%)。

  • 与随机预测相比,模型可能在新数据上表现更好。

  • 我们喜欢将数据集拟合到分布中,因为如果数据集与分布相当接近,我们可以基于理论分布对我们如何处理数据进行假设。因此,数据中的正态分布使我们能够假设在指定条件下统计的抽样分布是正态分布的。正态分布由其均值和标准差定义,并且在所有变化中通常具有相同的形状。

图 2:数据中的正态分布有助于克服过度拟合和拟合不足(来源:Adam Gibson、Josh Patterson 的《深度学习》一书)

有时,ML 模型本身对特定调整或数据点拟合不足,这意味着模型变得过于简单。我们的建议(我们相信其他人也是如此)如下:

  • 将数据集分为两组以检测过度拟合情况——第一组用于训练和模型选择的训练集,第二组是用于评估模型的测试集,开始替代 ML 工作流程部分。

  • 或者,您还可以通过使用更简单的模型(例如,线性分类器而不是高斯核 SVM)或增加 ML 模型的正则化参数(如果可用)来避免过度拟合。

  • 调整模型的正确数据值参数,以避免过度拟合和拟合不足。

  • 因此,解决拟合不足是首要任务,但大多数机器学习从业者建议花更多时间和精力尝试不要过度拟合数据。另一方面,许多机器学习从业者建议将大规模数据集分为三组:训练集(50%)、验证集(25%)和测试集(25%)。他们还建议使用训练集构建模型,并使用验证集计算预测误差。测试集被推荐用于评估最终模型的泛化误差。然而,在监督学习期间,如果可用的标记数据量较小,则不建议拆分数据集。在这种情况下,使用交叉验证。更具体地说,将数据集分为大致相等的 10 个部分;然后,对这 10 个部分中的每一个,迭代训练分类器,并使用第 10 个部分来测试模型。

请继续关注 Spark MLlib 和 Spark ML

管道设计的第一步是创建构件块(作为由节点和边组成的有向或无向图),并在这些块之间建立联系。然而,作为一名数据科学家,您还应该专注于扩展和优化节点(原语),以便在后期处理大规模数据集时能够扩展应用程序,使您的 ML 管道能够持续执行。管道过程还将帮助您使模型适应新数据集。然而,其中一些原语可能会明确定义为特定领域和数据类型(例如文本、图像和视频、音频和时空)。

除了这些类型的数据之外,原语还应该适用于通用领域统计或数学。将您的 ML 模型转换为这些原语将使您的工作流程更加透明、可解释、可访问和可解释。

最近的一个例子是 ML-matrix,它是一个可以在 Spark 之上使用的分布式矩阵库。请参阅JIRA 问题

图 3:保持关注并相互操作 ML 和 MLlib

正如我们在前一节中已经提到的,作为开发人员,您可以无缝地将 Spark MLlib 中的实现技术与 Spark ML、Spark SQL、GraphX 和 Spark Streaming 中开发的算法结合起来,作为 RDD、DataFrame 和数据集的混合或可互操作的 ML 应用程序,如图 3所示。因此,这里的建议是与您周围的最新技术保持同步,以改善您的 ML 应用程序。

为您的应用程序选择正确的算法

“我应该使用什么机器学习算法?”是一个非常常见的问题,但答案总是“这取决于”。更详细地说:

  • 这取决于你要测试/使用的数据的数量、质量、复杂性和性质

  • 这取决于外部环境和参数,比如你的计算系统配置或基础设施

  • 这取决于你想要用答案做什么

  • 这取决于算法的数学和统计公式如何被转化为计算机的机器指令

  • 这取决于你有多少时间

事实上,即使是最有经验的数据科学家或数据工程师在尝试所有算法之前也无法直接推荐哪种机器学习算法会表现最好。大多数同意/不同意的陈述都以“这取决于...嗯...”开始。习惯上,你可能会想知道是否有机器学习算法的备忘单,如果有的话,你应该如何使用?一些数据科学家表示,找到最佳算法的唯一方法是尝试所有算法;因此,没有捷径!让我们更清楚地说明一下;假设你有一组数据,你想做一些聚类。从技术上讲,如果你的数据有标签,这可能是一个分类或回归问题。然而,如果你有一个无标签的数据集,你将使用聚类技术。现在,你脑海中出现的问题如下:

  • 在选择适当的算法之前,我应该考虑哪些因素?还是应该随机选择一个算法?

  • 我如何选择适用于我的数据的任何数据预处理算法或工具?

  • 我应该使用什么样的特征工程技术来提取有用的特征?

  • 什么因素可以提高我的机器学习模型的性能?

  • 我如何适应新的数据类型?

  • 我能否扩展我的机器学习应用以处理大规模数据集?等等。

在本节中,我们将尝试用我们有限的机器学习知识来回答这些问题。

选择算法时的考虑因素

我们在这里提供的建议或建议是给那些刚开始学习机器学习的新手数据科学家。这些对于试图选择一个最佳算法来开始使用 Spark ML API 的专家数据科学家也会有用。不用担心,我们会指导你的方向!我们还建议在选择算法时考虑以下算法属性:

  • 准确性:是否达到最佳分数是目标,还是在精确度、召回率、f1 分数或 AUC 等方面进行权衡,得到一个近似解(足够好),同时避免过拟合。

  • 训练时间:训练模型的可用时间(包括模型构建、评估和训练时间)。

  • 线性度:模型复杂性的一个方面,涉及问题建模的方式。由于大多数非线性模型通常更复杂,难以理解和调整。

  • 参数数量

  • 特征数量:拥有的属性比实例多的问题,即p>>n问题。这通常需要专门处理或使用降维或更好的特征工程方法。

准确性

从你的机器学习应用中获得最准确的结果并非总是必不可少的。根据你想要使用它的情况,有时近似解就足够了。如果情况是这样的,你可以通过采用更好的估计方法大大减少处理时间。当你熟悉了 Spark 机器学习 API 的工作流程后,你将享受到更多的近似方法的优势,因为这些近似方法将自动避免你的机器学习模型的过拟合问题。现在,假设你有两个二元分类算法的表现如下:

分类器 精确度 召回率
X 96% 89%
Y 99% 84%

在这里,没有一个分类器显然优于其他分类器,因此它不会立即指导您选择最佳的分类器。F1 分数是精确度和召回率的调和平均值,它可以帮助您。让我们计算一下,并将其放在表中:

分类器 精度 召回率 F1 分数
X 96% 89% 92.36%
Y 99% 84% 90.885%

因此,具有 F1 分数有助于从大量分类器中进行选择。它为所有分类器提供了清晰的偏好排序,因此也为进展提供了明确的方向--即分类器X

训练时间

训练时间通常与模型训练和准确性密切相关。此外,通常您会发现,与其他算法相比,有些算法对数据点的数量更加难以捉摸。然而,当您的时间不足但训练集又很大且具有许多特征时,您可以选择最简单的算法。在这种情况下,您可能需要牺牲准确性。但至少它将满足您的最低要求。

线性

最近开发了许多利用线性的机器学习算法(也可在 Spark MLlib 和 Spark ML 中使用)。例如,线性分类算法假设类别可以通过绘制不同的直线或使用高维等价物来分离。另一方面,线性回归算法假设数据趋势简单地遵循一条直线。对于一些机器学习问题,这种假设并不天真;然而,在某些其他情况下,准确性可能会下降。尽管存在危险,线性算法在数据工程师和数据科学家中非常受欢迎,作为爆发的第一线。此外,这些算法还倾向于简单和快速,以在整个过程中训练您的模型。

在选择算法时检查您的数据

您将在 UC Irvine 机器学习库中找到许多机器学习数据集。以下数据属性也应该优先考虑:

  • 参数数量

  • 特征数量

  • 训练数据集的大小

参数数量

参数或数据属性是数据科学家在设置算法时的抓手。它们是影响算法性能的数字,如误差容限或迭代次数,或者是算法行为变体之间的选项。算法的训练时间和准确性有时可能非常敏感,这使得难以找到正确的设置。通常,具有大量参数的算法需要更多的试错来找到最佳组合。

尽管这是跨越参数空间的一个很好的方法,但随着参数数量的增加,模型构建或训练时间呈指数增长。这既是一个困境,也是一个时间性能的权衡。积极的方面是:

  • 具有许多参数特征性地表明了 ML 算法的更大灵活性

  • 您的 ML 应用程序实现了更高的准确性

你的训练集有多大?

如果您的训练集较小,具有低方差的高偏差分类器,如朴素贝叶斯,比具有高方差的低偏差分类器(也可用于回归)如k 最近邻算法kNN)更有优势。

**偏差、方差和 kNN 模型:**实际上,增加 k减少方差,但会增加偏差。另一方面,减少 k增加方差减少偏差。随着k的增加,这种变异性减少。但如果我们增加k太多,那么我们就不再遵循真实的边界线,我们会观察到高偏差。这就是偏差-方差权衡的本质。

我们已经看到了过拟合和欠拟合的问题。现在,可以假设处理偏差和方差就像处理过拟合和欠拟合一样。随着模型复杂性的增加,偏差减小,方差增加。随着模型中添加更多参数,模型的复杂性增加,方差成为我们关注的主要问题,而偏差稳步下降。换句话说,偏差对模型复杂性的响应具有负的一阶导数,而方差具有正的斜率。请参考以下图表以更好地理解:

图 4: 偏差和方差对总误差的影响

因此,后者会过拟合。但是低偏差高方差的分类器,在训练集线性或指数增长时,开始获胜,因为它们具有更低的渐近误差。高偏差分类器不足以提供准确的模型。

特征数

对于某些类型的实验数据集,提取的特征数量可能与数据点本身的数量相比非常大。这在基因组学、生物医学或文本数据中经常发生。大量的特征可能会淹没一些学习算法,使训练时间变得非常长。支持向量机SVM)特别适用于这种情况,因为它具有高准确性,对过拟合有良好的理论保证,并且具有适当的核函数。

支持向量机和核函数: 任务是找到一组权重和偏差,使间隔最大化函数:

y = w*¥(x) +b,

其中w是权重,¥是特征向量,b是偏差。现在如果y> 0,那么我们将数据分类到类1,否则到类0,而特征向量¥(x)使数据线性可分。然而,使用核函数可以使计算过程更快、更容易,特别是当特征向量¥包含非常高维的数据时。让我们看一个具体的例子。假设我们有以下值xyx = (x1, x2, x3)y = (y1, y2, y3),那么对于函数f(x) = (x1x1, x1x2, x1x3, x2x1, x2x2, x2x3, x3x1, x3x2, x3x3),核函数是K(x, y ) = (<x, y>)²。根据上述,如果x = (1, 2, 3)y = (4, 5, 6),那么我们有以下值:

f(x) = (1, 2, 3, 2, 4, 6, 3, 6, 9)

f(y) = (16, 20, 24, 20, 25, 30, 24, 30, 36)

<f(x), f(y)> = 16 + 40 + 72 + 40 + 100+ 180 + 72 + 180 + 324 = 1024

这是一个简单的线性代数,将一个 3 维空间映射到一个 9 维空间。另一方面,核函数是用于支持向量机的相似性度量。因此,建议根据对不变性的先验知识选择适当的核值。核和正则化参数的选择可以通过优化基于交叉验证的模型选择来自动化。

然而,自动选择核和核参数是一个棘手的问题,因为很容易过度拟合模型选择标准。这可能导致比开始时更糟糕的模型。现在,如果我们使用核函数K(x, y),这将给出相同的值,但计算更简单 - 即(4 + 10 + 18) ² = 32² = 1024。

机器学习模型的超参数调整

调整算法只是一个过程,通过这个过程,使算法在运行时间和内存使用方面表现最佳。在贝叶斯统计中,超参数是先验分布的参数。在机器学习方面,超参数指的是那些不能直接从常规训练过程中学习到的参数。超参数通常在实际训练过程开始之前固定。这是通过为这些超参数设置不同的值,训练不同的模型,并通过测试来决定哪些模型效果最好来完成的。以下是一些典型的超参数示例:

  • 叶子节点数、箱数或树的深度

  • 迭代次数

  • 矩阵分解中的潜在因子数量

  • 学习率

  • 深度神经网络中的隐藏层数量

  • k 均值聚类中的簇数量等。

在本节中,我们将讨论如何使用交叉验证技术和网格搜索进行超参数调整。

超参数调整

超参数调整是一种根据呈现数据的性能选择正确的超参数组合的技术。这是从实践中获得机器学习算法的有意义和准确结果的基本要求之一。下图显示了模型调整过程、考虑因素和工作流程:

图 5:模型调整过程、考虑因素和工作流程

例如,假设我们有两个要为管道调整的超参数,该管道在第十一章中的图 17中呈现,使用逻辑回归估计器的 Spark ML 管道模型(虚线只会在管道拟合期间出现)。我们可以看到我们为每个参数放置了三个候选值。因此,总共会有九种组合。但是,在图中只显示了四种,即 Tokenizer、HashingTF、Transformer 和 Logistic Regression(LR)。现在,我们要找到最终会导致具有最佳评估结果的模型。拟合的模型包括 Tokenizer、HashingTF 特征提取器和拟合的逻辑回归模型:

如果您回忆起第十一章中的图 17学习机器学习 - Spark MLlib 和 Spark ML,虚线只会在管道拟合期间出现。正如前面提到的,拟合的管道模型是一个 Transformer。Transformer 可用于预测、模型验证和模型检查。此外,我们还认为 ML 算法的一个不幸的特点是,它们通常有许多需要调整以获得更好性能的超参数。例如,这些超参数中的正则化程度与 Spark MLlib 优化的模型参数有所不同。

因此,如果没有对数据和要使用的算法的专业知识,很难猜测或衡量最佳超参数组合。由于复杂数据集基于 ML 问题类型,管道的大小和超参数的数量可能会呈指数级增长(或线性增长);即使对于 ML 专家来说,超参数调整也会变得繁琐,更不用说调整参数的结果可能会变得不可靠。

根据 Spark API 文档,用于指定 Spark ML 估计器和 Transformer 的是一个独特且统一的 API。ParamMap是一组(参数,值)对,其中 Param 是由 Spark 提供的具有自包含文档的命名参数。从技术上讲,有两种方法可以将参数传递给算法,如下所示:

  • 设置参数:如果 LR 是逻辑回归的实例(即估计器),则可以调用setMaxIter()方法,如下所示:LR.setMaxIter(5)。它基本上将模型拟合到回归实例,如下所示:LR.fit()。在这个特定的例子中,最多会有五次迭代。

  • 第二个选项:这涉及将ParamMaps传递给fit()transform()(有关详细信息,请参见图 5)。在这种情况下,任何参数都将被先前通过 ML 应用程序特定代码或算法中的 setter 方法指定的ParamMaps覆盖。

网格搜索参数调整

假设您在必要的特征工程之后选择了您的超参数。在这方面,对超参数和特征空间进行完整的网格搜索计算量太大。因此,您需要执行 K 折交叉验证的折叠,而不是进行完整的网格搜索:

  • 在折叠的训练集上使用交叉验证来调整所需的超参数,使用所有可用的特征

  • 使用这些超参数选择所需的特征

  • 对 K 中的每个折叠重复计算

  • 最终模型是使用从每个 CV 折叠中选择的 N 个最常见特征构建的所有数据

有趣的是,超参数也将在交叉验证循环中再次进行调整。与完整的网格搜索相比,这种方法会有很大的不利因素吗?实质上,我在每个自由参数的维度上进行线性搜索(找到一个维度中的最佳值,将其保持恒定,然后找到下一个维度中的最佳值),而不是每个参数设置的所有组合。沿着单个参数搜索而不是一起优化它们的最重要的不利因素是,您忽略了相互作用。

例如,很常见的是,不止一个参数影响模型复杂性。在这种情况下,您需要查看它们的相互作用,以成功地优化超参数。根据您的数据集有多大以及您比较了多少个模型,返回最大观察性能的优化策略可能会遇到麻烦(这对网格搜索和您的策略都是如此)。

原因是在大量性能估计中寻找最大值会削弱性能估计的方差:您可能最终只得到一个模型和训练/测试分割组合,碰巧看起来不错。更糟糕的是,您可能会得到几个看起来完美的组合,然后优化无法知道选择哪个模型,因此变得不稳定。

交叉验证

交叉验证(也称为旋转估计RE))是一种模型验证技术,用于评估统计分析和结果的质量。目标是使模型向独立测试集泛化。交叉验证技术的一个完美用途是从机器学习模型中进行预测。如果您想要估计在实践中部署为 ML 应用时预测模型的准确性,这将有所帮助。在交叉验证过程中,模型通常是使用已知类型的数据集进行训练的。相反,它是使用未知类型的数据集进行测试的。

在这方面,交叉验证有助于描述数据集,以便在训练阶段使用验证集测试模型。有两种类型的交叉验证,可以如下分类:

  • 穷举交叉验证:这包括留 p-out 交叉验证和留一出交叉验证。

  • 非穷尽交叉验证:这包括 K 折交叉验证和重复随机子采样交叉验证。

在大多数情况下,研究人员/数据科学家/数据工程师使用 10 折交叉验证,而不是在验证集上进行测试。这是最广泛使用的交叉验证技术,如下图所示:

**图 6:**交叉验证基本上将您的完整可用训练数据分成多个折叠。可以指定此参数。然后,整个流程对每个折叠运行一次,并为每个折叠训练一个机器学习模型。最后,通过分类器的投票方案或回归的平均值将获得的不同机器学习模型结合起来

此外,为了减少变异性,使用不同分区进行多次交叉验证迭代;最后,将验证结果在各轮上进行平均。下图显示了使用逻辑回归进行超参数调整的示例:

**图 7:**使用逻辑回归进行超参数调整的示例

使用交叉验证而不是传统验证有以下两个主要优点:

  • 首先,如果没有足够的数据可用于在单独的训练和测试集之间进行分区,就有可能失去重要的建模或测试能力。

  • 其次,K 折交叉验证估计器的方差低于单个留出集估计器。这种低方差限制了变异性,如果可用数据量有限,这也是非常重要的。

在这些情况下,一个公平的方法来正确估计模型预测和相关性能是使用交叉验证作为模型选择和验证的强大通用技术。如果我们需要对模型调整进行手动特征和参数选择,然后,我们可以在整个数据集上进行 10 折交叉验证的模型评估。什么是最佳策略?我们建议您选择提供乐观分数的策略如下:

  • 将数据集分为训练集(80%)和测试集(20%)或您选择的其他比例

  • 在训练集上使用 K 折交叉验证来调整您的模型

  • 重复 CV,直到找到优化并调整您的模型。

现在,使用您的模型在测试集上进行预测,以获得模型外误差的估计。

信用风险分析-超参数调整的一个例子

在本节中,我们将展示一个实际的机器学习超参数调整的示例,涉及网格搜索和交叉验证技术。更具体地说,首先,我们将开发一个信用风险管道,这在金融机构如银行和信用合作社中常用。随后,我们将看看如何通过超参数调整来提高预测准确性。在深入示例之前,让我们快速概述一下信用风险分析是什么,以及为什么它很重要?

什么是信用风险分析?为什么它很重要?

当申请人申请贷款并且银行收到该申请时,根据申请人的资料,银行必须决定是否批准贷款申请。在这方面,银行对贷款申请的决定存在两种风险:

  • 申请人是一个良好的信用风险:这意味着客户或申请人更有可能偿还贷款。然后,如果贷款未获批准,银行可能会遭受业务损失。

  • 申请人是一个不良的信用风险:这意味着客户或申请人很可能不会偿还贷款。在这种情况下,向客户批准贷款将导致银行的财务损失。

该机构表示第二个风险比第一个更高,因为银行有更高的机会无法收回借款金额。因此,大多数银行或信用合作社评估向客户、申请人或顾客放贷所涉及的风险。在业务分析中,最小化风险往往会最大化银行自身的利润。

换句话说,从财务角度来看,最大化利润和最小化损失是重要的。通常,银行根据申请人的不同因素和参数,如贷款申请的人口统计和社会经济状况,来决定是否批准贷款申请。

数据集探索

德国信用数据集是从 UCI 机器学习库archive.ics.uci.edu/ml/machine-learning-databases/statlog/german/下载的。尽管链接中提供了数据集的详细描述,但我们在表 3中提供了一些简要的见解。数据包含 21 个变量的与信用相关的数据,以及对于 1000 个贷款申请人是否被认为是良好还是不良的信用风险的分类(即二元分类问题)。

以下表格显示了在将数据集提供在线之前考虑的每个变量的详细信息:

条目 变量 解释
1 creditability 有能力偿还:值为 1.0 或 0.0
2 balance 当前余额
3 duration 申请贷款的期限
4 history 是否有不良贷款历史?
5 purpose 贷款目的
6 amount 申请金额
7 savings 每月储蓄
8 employment 就业状况
9 instPercent 利息百分比
10 sexMarried 性别和婚姻状况
11 guarantors 是否有担保人?
12 residenceDuration 目前地址的居住时间
13 assets 净资产
14 age 申请人年龄
15 concCredit 并发信用
16 apartment 住房状况
17 credits 当前信用
18 occupation 职业
19 dependents 受抚养人数
20 hasPhone 申请人是否使用电话
21 foreign 申请人是否是外国人

请注意,尽管表 3描述了具有相关标题的变量,但数据集中没有相关标题。在表 3中,我们展示了每个变量的变量、位置和相关重要性。

使用 Spark ML 的逐步示例

在这里,我们将提供使用随机森林分类器进行信用风险预测的逐步示例。步骤包括数据摄入、一些统计分析、训练集准备,最后是模型评估:

步骤 1. 加载并解析数据集为 RDD:

val creditRDD = parseRDD(sc.textFile("data/germancredit.csv")).map(parseCredit) 

对于前一行,parseRDD()方法用于使用,拆分条目,然后将它们全部转换为Double值(即数值)。该方法如下:

def parseRDD(rdd: RDD[String]): RDD[Array[Double]] = { 
rdd.map(_.split(",")).map(_.map(_.toDouble)) 
  } 

另一方面,parseCredit()方法用于基于Credit case 类解析数据集:

def parseCredit(line: Array[Double]): Credit = { 
Credit( 
line(0), line(1) - 1, line(2), line(3), line(4), line(5), 
line(6) - 1, line(7) - 1, line(8), line(9) - 1, line(10) - 1, 
line(11) - 1, line(12) - 1, line(13), line(14) - 1, line(15) - 1, 
line(16) - 1, line(17) - 1, line(18) - 1, line(19) - 1, line(20) - 1) 
  } 

Credit case 类如下所示:

case class Credit( 
creditability: Double, 
balance: Double, duration: Double, history: Double, purpose: Double, amount: Double, 
savings: Double, employment: Double, instPercent: Double, sexMarried: Double, guarantors: Double, 
residenceDuration: Double, assets: Double, age: Double, concCredit: Double, apartment: Double, 
credits: Double, occupation: Double, dependents: Double, hasPhone: Double, foreign: Double) 

步骤 2.准备 ML 管道的 DataFrame - 获取 ML 管道的 DataFrame

val sqlContext = new SQLContext(sc) 
import sqlContext._ 
import sqlContext.implicits._ 
val creditDF = creditRDD.toDF().cache() 

将它们保存为临时视图,以便更容易进行查询:

creditDF.createOrReplaceTempView("credit") 

让我们来看一下 DataFrame 的快照:

creditDF.show

前面的show()方法打印了信用 DataFrame:

**图 8:**信用数据集的快照

步骤 3.观察相关统计数据 - 首先,让我们看一些聚合值:

sqlContext.sql("SELECT creditability, avg(balance) as avgbalance, avg(amount) as avgamt, avg(duration) as avgdur  FROM credit GROUP BY creditability ").show 

让我们看一下余额的统计信息:

creditDF.describe("balance").show 

现在,让我们看一下平均余额的信用性:

creditDF.groupBy("creditability").avg("balance").show 

三行的输出:

**图 9:**数据集的一些统计信息

步骤 4.特征向量和标签的创建 - 如您所见,可信度列是响应列,为了得到结果,我们需要创建不考虑此列的特征向量。现在,让我们创建特征列如下:

val featureCols = Array("balance", "duration", "history", "purpose", "amount", "savings", "employment", "instPercent", "sexMarried",
"guarantors", "residenceDuration", "assets", "age", "concCredit",
"apartment", "credits", "occupation", "dependents", "hasPhone",
"foreign") 

让我们使用VectorAssembler() API 组装这些选定列的所有特征:

val assembler = new VectorAssembler().setInputCols(featureCols).setOutputCol("features") 
val df2 = assembler.transform(creditDF) 

现在让我们看一下特征向量的样子:

df2.select("features").show

前一行显示了由 VectorAssembler 转换器创建的特征:

**图 10:**使用 VectorAssembler 为 ML 模型生成特征

现在,让我们使用StringIndexer从旧的响应列 creditability 创建一个新的标签列,如下所示:

val labelIndexer = new StringIndexer().setInputCol("creditability").setOutputCol("label") 
val df3 = labelIndexer.fit(df2).transform(df2) 
df3.select("label", "features").show

前一行显示了VectorAssembler转换器创建的特征和标签:

图 11: 使用 VectorAssembler 的 ML 模型的相应标签和特征

步骤 5. 准备训练集和测试集:

val splitSeed = 5043 
val Array(trainingData, testData) = df3.randomSplit(Array(0.80, 0.20), splitSeed) 

步骤 6. 训练随机森林模型 - 首先,实例化模型:

val classifier = new RandomForestClassifier() 
      .setImpurity("gini") 
      .setMaxDepth(30) 
      .setNumTrees(30) 
      .setFeatureSubsetStrategy("auto") 
      .setSeed(1234567) 
      .setMaxBins(40) 
      .setMinInfoGain(0.001) 

有关上述参数的解释,请参阅本章中的随机森林算法部分。现在,让我们使用训练集训练模型:

val model = classifier.fit(trainingData)

步骤 7. 计算测试集的原始预测:

val predictions = model.transform(testData) 

让我们看看这个 DataFrame 的前 20 行:

predictions.select("label","rawPrediction", "probability", "prediction").show()

前一行显示了包含标签、原始预测、概率和实际预测的 DataFrame:

图 12: 包含测试集的原始和实际预测的 DataFrame

现在,在看到最后一列的预测之后,银行可以对申请做出决定,决定是否接受申请。

步骤 8. 模型调优前的模型评估 - 实例化二元评估器:

val binaryClassificationEvaluator = new BinaryClassificationEvaluator() 
      .setLabelCol("label") 
      .setRawPredictionCol("rawPrediction") 

计算测试集的预测准确率如下:

val accuracy = binaryClassificationEvaluator.evaluate(predictions) 
println("The accuracy before pipeline fitting: " + accuracy) 

管道拟合前的准确率:0.751921784149243

这一次,准确率是 75%,并不是很好。让我们计算二元分类器的其他重要性能指标,比如接收器操作特征下面积AUROC)和精确度召回曲线下面积AUPRC):

println("Area Under ROC before tuning: " + printlnMetric("areaUnderROC"))         
println("Area Under PRC before tuning: "+  printlnMetric("areaUnderPR")) 
Area Under ROC before tuning: 0.8453079178885631 Area Under PRC before tuning: 0.751921784149243

printlnMetric() 方法如下:

def printlnMetric(metricName: String): Double = { 
  val metrics = binaryClassificationEvaluator.setMetricName(metricName)
                                             .evaluate(predictions) 
  metrics 
} 

最后,让我们使用训练过程中使用的随机森林模型的RegressionMetrics() API 计算一些额外的性能指标:

val rm = new RegressionMetrics( 
predictions.select("prediction", "label").rdd.map(x => 
        (x(0).asInstanceOf[Double], x(1).asInstanceOf[Double]))) 

现在,让我们看看我们的模型如何:

println("MSE: " + rm.meanSquaredError) 
println("MAE: " + rm.meanAbsoluteError) 
println("RMSE Squared: " + rm.rootMeanSquaredError) 
println("R Squared: " + rm.r2) 
println("Explained Variance: " + rm.explainedVariance + "\n") 

我们得到以下输出:

MSE: 0.2578947368421053
MAE: 0.2578947368421053
RMSE Squared: 0.5078333750770082
R Squared: -0.13758553274682295
Explained Variance: 0.16083102493074794

不算太糟!但也不尽如人意,对吧?让我们使用网格搜索和交叉验证技术调优模型。

步骤 9. 使用网格搜索和交叉验证进行模型调优 - 首先,让我们使用ParamGridBuilder API 构建一个参数网格,搜索 20 到 70 棵树,maxBins在 25 到 30 之间,maxDepth在 5 到 10 之间,以及熵和基尼作为不纯度:

val paramGrid = new ParamGridBuilder()
                    .addGrid(classifier.maxBins, Array(25, 30))
                    .addGrid(classifier.maxDepth, Array(5, 10))
                    .addGrid(classifier.numTrees, Array(20, 70))
                    .addGrid(classifier.impurity, Array("entropy", "gini"))
                    .build()

让我们使用训练集训练交叉验证模型:

val cv = new CrossValidator()
             .setEstimator(pipeline)
             .setEvaluator(binaryClassificationEvaluator)
             .setEstimatorParamMaps(paramGrid)
             .setNumFolds(10)
val pipelineFittedModel = cv.fit(trainingData)

按以下方式计算测试集的原始预测:

val predictions2 = pipelineFittedModel.transform(testData) 

步骤 10. 调优后模型的评估 - 让我们看看准确率:

val accuracy2 = binaryClassificationEvaluator.evaluate(predictions2)
println("The accuracy after pipeline fitting: " + accuracy2)

我们得到以下输出:

The accuracy after pipeline fitting: 0.8313782991202348

现在,准确率超过 83%。确实有很大的改进!让我们看看计算 AUROC 和 AUPRC 的另外两个指标:

def printlnMetricAfter(metricName: String): Double = { 
val metrics = binaryClassificationEvaluator.setMetricName(metricName).evaluate(predictions2) 
metrics 
    } 
println("Area Under ROC after tuning: " + printlnMetricAfter("areaUnderROC"))     
println("Area Under PRC after tuning: "+  printlnMetricAfter("areaUnderPR"))

我们得到以下输出:

Area Under ROC after tuning: 0.8313782991202345
 Area Under PRC after tuning: 0.7460301367852662

现在基于RegressionMetrics API,计算其他指标:

val rm2 = new RegressionMetrics(predictions2.select("prediction", "label").rdd.map(x => (x(0).asInstanceOf[Double], x(1).asInstanceOf[Double]))) 
 println("MSE: " + rm2.meanSquaredError) 
println("MAE: " + rm2.meanAbsoluteError) 
println("RMSE Squared: " + rm2.rootMeanSquaredError) 
println("R Squared: " + rm2.r2) 
println("Explained Variance: " + rm2.explainedVariance + "\n")  

我们得到以下输出:

MSE: 0.268421052631579
 MAE: 0.26842105263157895
 RMSE Squared: 0.5180936716768301
 R Squared: -0.18401759530791795
 Explained Variance: 0.16404432132963992

步骤 11. 寻找最佳的交叉验证模型 - 最后,让我们找到最佳的交叉验证模型信息:

pipelineFittedModel 
      .bestModel.asInstanceOf[org.apache.spark.ml.PipelineModel] 
      .stages(0) 
      .extractParamMap 
println("The best fitted model:" + pipelineFittedModel.bestModel.asInstanceOf[org.apache.spark.ml.PipelineModel].stages(0)) 

我们得到以下输出:

The best fitted model:RandomForestClassificationModel (uid=rfc_1fcac012b37c) with 70 trees

使用 Spark 的推荐系统

推荐系统试图根据其他用户的历史来预测用户可能感兴趣的潜在项目。基于模型的协同过滤在许多公司中被广泛使用,如 Netflix。需要注意的是,Netflix 是一家美国娱乐公司,由里德·黑斯廷斯和马克·兰道夫于 1997 年 8 月 29 日在加利福尼亚州斯科茨谷成立。它专门提供流媒体和在线点播以及 DVD 邮寄服务。2013 年,Netflix 扩展到了电影和电视制作,以及在线发行。截至 2017 年,该公司总部位于加利福尼亚州洛斯加托斯(来源:维基百科)。Netflix 是一个实时电影推荐系统。在本节中,我们将看到一个完整的示例,说明它是如何为新用户推荐电影的。

使用 Spark 进行基于模型的推荐

Spark MLlib 中的实现支持基于模型的协同过滤。在基于模型的协同过滤技术中,用户和产品由一小组因子描述,也称为潜在因子LFs)。从下图中,您可以对不同的推荐系统有一些了解。图 13 说明了为什么我们将在电影推荐示例中使用基于模型的协同过滤:

图 13:不同推荐系统的比较视图

然后使用 LFs 来预测缺失的条目。Spark API 提供了交替最小二乘(也称为 ALS 广泛)算法的实现,该算法通过考虑六个参数来学习这些潜在因素,包括:

  • numBlocks:这是用于并行计算的块数(设置为-1 以自动配置)。

  • rank:这是模型中潜在因素的数量。

  • iterations:这是运行 ALS 的迭代次数。ALS 通常在 20 次迭代或更少的情况下收敛到一个合理的解决方案。

  • lambda:这指定 ALS 中的正则化参数。

  • implicitPrefs:这指定是否使用显式反馈ALS 变体或适用于隐式反馈数据的变体。

  • alpha:这是适用于 ALS 隐式反馈变体的参数,它控制对偏好观察的基线置信度。

请注意,要使用默认参数构建 ALS 实例,您可以根据自己的需求设置值。默认值如下:numBlocks: -1rank: 10iterations: 10lambda: 0.01implicitPrefs: false,和alpha: 1.0

数据探索

电影和相应的评分数据集是从 MovieLens 网站(movielens.org)下载的。根据 MovieLens 网站上的数据描述,所有评分都在ratings.csv文件中描述。该文件的每一行在标题之后表示一个用户对一部电影的评分。

CSV 数据集有以下列:userIdmovieIdratingtimestamp,如图 14所示。行首先按userId排序,然后按movieId排序。评分是在五星级评分上进行的,可以增加半星(0.5 星至 5.0 星)。时间戳表示自 1970 年 1 月 1 日协调世界时(UTC)午夜以来的秒数,我们有来自 668 个用户对 10325 部电影的 105339 个评分:

图 14:评分数据集的快照

另一方面,电影信息包含在movies.csv文件中。除了标题信息之外,每一行代表一个包含列:movieId,title 和 genres 的电影(见图 14)。电影标题可以手动创建或插入,也可以从电影数据库网站www.themoviedb.org/导入。然而,发行年份显示在括号中。由于电影标题是手动插入的,因此这些标题可能存在一些错误或不一致。因此,建议读者检查 IMDb 数据库(www.ibdb.com/)以确保没有不一致或不正确的标题与其对应的发行年份。

类型是一个分开的列表,可以从以下类型类别中选择:

  • 动作,冒险,动画,儿童,喜剧,犯罪

  • 纪录片,戏剧,奇幻,黑色电影,恐怖,音乐

  • 神秘,浪漫,科幻,惊悚,西部,战争

图 15:前 20 部电影的标题和类型

使用 ALS 进行电影推荐

在本小节中,我们将通过从数据收集到电影推荐的逐步示例向您展示如何为其他用户推荐电影。

步骤 1. 加载、解析和探索电影和评分数据集 - 以下是示例代码:

val ratigsFile = "data/ratings.csv"
val df1 = spark.read.format("com.databricks.spark.csv").option("header", true).load(ratigsFile)
val ratingsDF = df1.select(df1.col("userId"), df1.col("movieId"), df1.col("rating"), df1.col("timestamp"))
ratingsDF.show(false)

这段代码应该返回您的评分数据框。另一方面,以下代码段显示了电影的数据框:

val moviesFile = "data/movies.csv"
val df2 = spark.read.format("com.databricks.spark.csv").option("header", "true").load(moviesFile)
val moviesDF = df2.select(df2.col("movieId"), df2.col("title"), df2.col("genres"))

步骤 2. 注册两个数据框为临时表,以便更轻松地查询 - 要注册两个数据集,我们可以使用以下代码:

ratingsDF.createOrReplaceTempView("ratings")
moviesDF.createOrReplaceTempView("movies")

这将通过在内存中创建一个临时视图作为表来加快内存中的查询速度。使用createOrReplaceTempView()方法创建的临时表的生命周期与用于创建此 DataFrame 的[[SparkSession]]相关联。

步骤 3. 探索和查询相关统计数据 - 让我们检查与评分相关的统计数据。只需使用以下代码行:

val numRatings = ratingsDF.count()
val numUsers = ratingsDF.select(ratingsDF.col("userId")).distinct().count()
val numMovies = ratingsDF.select(ratingsDF.col("movieId")).distinct().count()
println("Got " + numRatings + " ratings from " + numUsers + " users on " + numMovies + " movies.")

你应该找到来自 668 个用户对 10325 部电影进行了 105339 次评分。现在,让我们获取最大和最小评分,以及对电影进行评分的用户数量。然而,你需要在我们在上一步中在内存中创建的评分表上执行 SQL 查询。在这里进行查询很简单,类似于从 MySQL 数据库或 RDBMS 进行查询。然而,如果你不熟悉基于 SQL 的查询,建议查看 SQL 查询规范,了解如何使用SELECT从特定表中进行选择,如何使用ORDER进行排序,以及如何使用JOIN关键字进行连接操作。

嗯,如果你知道 SQL 查询,你应该通过使用以下复杂的 SQL 查询来获得一个新的数据集:

// Get the max, min ratings along with the count of users who have rated a movie.
val results = spark.sql("select movies.title, movierates.maxr, movierates.minr, movierates.cntu "
       + "from(SELECT ratings.movieId,max(ratings.rating) as maxr,"
       + "min(ratings.rating) as minr,count(distinct userId) as cntu "
       + "FROM ratings group by ratings.movieId) movierates "
       + "join movies on movierates.movieId=movies.movieId "
       + "order by movierates.cntu desc") 
results.show(false) 

我们得到以下输出:

图 16:最大、最小评分以及对电影进行评分的用户数量

为了更深入地了解,我们需要了解更多关于用户和他们的评分。现在,让我们找出最活跃的用户以及他们对电影进行评分的次数:

// Show the top 10 mostactive users and how many times they rated a movie
val mostActiveUsersSchemaRDD = spark.sql("SELECT ratings.userId, count(*) as ct from ratings "
               + "group by ratings.userId order by ct desc limit 10")
mostActiveUsersSchemaRDD.show(false)

图 17:前 10 名最活跃用户以及他们对电影进行评分的次数

让我们看看一个特定的用户,并找出,比如说用户 668,对哪些电影进行了高于 4 的评分:

// Find the movies that user 668 rated higher than 4
val results2 = spark.sql(
"SELECT ratings.userId, ratings.movieId,"
         + "ratings.rating, movies.title FROM ratings JOIN movies"
         + "ON movies.movieId=ratings.movieId"
         + "where ratings.userId=668 and ratings.rating > 4")
results2.show(false)

图 18:用户 668 对评分高于 4 的电影

步骤 4. 准备训练和测试评分数据并查看计数 - 以下代码将评分 RDD 分割为训练数据 RDD(75%)和测试数据 RDD(25%)。这里的种子是可选的,但是出于可重现性的目的是必需的:

// Split ratings RDD into training RDD (75%) & test RDD (25%)
val splits = ratingsDF.randomSplit(Array(0.75, 0.25), seed = 12345L)
val (trainingData, testData) = (splits(0), splits(1))
val numTraining = trainingData.count()
val numTest = testData.count()
println("Training: " + numTraining + " test: " + numTest)

你应该发现训练中有 78792 个评分,测试中有 26547 个评分

DataFrame。

步骤 5. 准备数据以构建使用 ALS 的推荐模型 - ALS 算法使用训练目的的Rating的 RDD。以下代码说明了使用 API 构建推荐模型的过程:

val ratingsRDD = trainingData.rdd.map(row => {
  val userId = row.getString(0)
  val movieId = row.getString(1)
  val ratings = row.getString(2)
  Rating(userId.toInt, movieId.toInt, ratings.toDouble)
})

ratingsRDD是一个包含来自我们在上一步准备的训练数据集的userIdmovieId和相应评分的评分的 RDD。另一方面,还需要一个测试 RDD 来评估模型。以下testRDD也包含了来自我们在上一步准备的测试 DataFrame 的相同信息:

val testRDD = testData.rdd.map(row => {
  val userId = row.getString(0)
  val movieId = row.getString(1)
  val ratings = row.getString(2)
  Rating(userId.toInt, movieId.toInt, ratings.toDouble)
}) 

步骤 6. 构建 ALS 用户产品矩阵 - 基于ratingsRDD构建 ALS 用户矩阵模型,指定最大迭代次数、块数、alpha、rank、lambda、种子和implicitPrefs。基本上,这种技术根据其他用户对其他电影的评分来预测特定用户对特定电影的缺失评分。

val rank = 20
val numIterations = 15
val lambda = 0.10
val alpha = 1.00
val block = -1
val seed = 12345L
val implicitPrefs = false
val model = new ALS()
           .setIterations(numIterations)
           .setBlocks(block)
           .setAlpha(alpha)
           .setLambda(lambda)
           .setRank(rank)
           .setSeed(seed)
           .setImplicitPrefs(implicitPrefs)
           .run(ratingsRDD) 

最后,我们对模型进行了 15 次学习迭代。通过这个设置,我们得到了良好的预测准确性。建议读者对超参数进行调整,以了解这些参数的最佳值。此外,设置用户块和产品块的块数以将计算并行化为一次传递-1 以进行自动配置的块数。该值为-1。

步骤 7. 进行预测 - 让我们为用户 668 获取前六部电影的预测。以下源代码可用于进行预测:

// Making Predictions. Get the top 6 movie predictions for user 668
println("Rating:(UserID, MovieID, Rating)")
println("----------------------------------")
val topRecsForUser = model.recommendProducts(668, 6)
for (rating <- topRecsForUser) {
  println(rating.toString())
}
println("----------------------------------")

前面的代码段产生了包含UserIDMovieID和相应Rating的评分预测的输出:

图 19:用户 668 的前六部电影预测

第 8 步。评估模型 - 为了验证模型的质量,使用均方根误差RMSE)来衡量模型预测值与实际观察值之间的差异。默认情况下,计算出的误差越小,模型越好。为了测试模型的质量,使用测试数据(在第 4 步中拆分)进行测试。根据许多机器学习从业者的说法,RMSE 是衡量准确性的一个很好的指标,但只能用于比较特定变量的不同模型的预测误差,而不能用于变量之间的比较,因为它依赖于比例。以下代码行计算了使用训练集训练的模型的 RMSE 值。

var rmseTest = computeRmse(model, testRDD, true)
println("Test RMSE: = " + rmseTest) //Less is better 

需要注意的是computeRmse()是一个 UDF,其步骤如下:

  def computeRmse(model: MatrixFactorizationModel, data: RDD[Rating], implicitPrefs: Boolean): Double = {
    val predictions: RDD[Rating] = model.predict(data.map(x => (x.user, x.product)))
    val predictionsAndRatings = predictions.map { x => ((x.user, x.product), x.rating)
  }.join(data.map(x => ((x.user, x.product), x.rating))).values
  if (implicitPrefs) {
    println("(Prediction, Rating)")
    println(predictionsAndRatings.take(5).mkString("\n"))
  }
  math.sqrt(predictionsAndRatings.map(x => (x._1 - x._2) * (x._1 - x._2)).mean())
}

前面的方法计算了 RMSE 以评估模型。RMSE 越小,模型及其预测能力就越好。

对于先前的设置,我们得到了以下输出:

Test RMSE: = 0.9019872589764073

我们相信前面模型的性能可以进一步提高。感兴趣的读者应该参考此网址,了解有关调整基于 ML 的 ALS 模型的更多信息spark.apache.org/docs/preview/ml-collaborative-filtering.html

主题建模技术广泛用于从大量文档中挖掘文本的任务。然后可以使用这些主题来总结和组织包括主题术语及其相对权重的文档。在下一节中,我们将展示使用潜在狄利克雷分配LDA)算法进行主题建模的示例。

主题建模-文本聚类的最佳实践

主题建模技术广泛用于从大量文档中挖掘文本的任务。然后可以使用这些主题来总结和组织包括主题术语及其相对权重的文档。将用于此示例的数据集只是以纯文本的形式存在,但是以非结构化格式存在。现在具有挑战性的部分是使用称为主题建模的 LDA 找到有关数据的有用模式。

LDA 是如何工作的?

LDA 是一种主题模型,它从一系列文本文档中推断主题。LDA 可以被视为一种聚类算法,其中主题对应于簇中心,文档对应于数据集中的示例(行)。主题和文档都存在于特征空间中,其中特征向量是词频的向量(词袋)。LDA 不是使用传统距离来估计聚类,而是使用基于文本文档生成的统计模型的函数。

LDA 通过setOptimizer函数支持不同的推断算法。EMLDAOptimizer使用期望最大化来学习聚类,并产生全面的结果,而OnlineLDAOptimizer使用迭代小批量抽样进行在线变分推断,并且通常对内存友好。LDA 接受一系列文档作为词频向量以及以下参数(使用构建器模式设置):

  • k:主题数(即,簇中心)。

  • optimizer:用于学习 LDA 模型的优化器,可以是EMLDAOptimizerOnlineLDAOptimizer

  • docConcentration:文档分布在主题上的 Dirichlet 先验参数。较大的值鼓励更平滑的推断分布。

  • topicConcentration:主题分布在术语(词)上的 Dirichlet 先验参数。较大的值鼓励更平滑的推断分布。

  • maxIterations:迭代次数上限。

  • checkpointInterval:如果使用检查点(在 Spark 配置中设置),此参数指定将创建检查点的频率。如果maxIterations很大,使用检查点可以帮助减少磁盘上的洗牌文件大小,并有助于故障恢复。

特别是,我们想讨论人们在大量文本中谈论的主题。自 Spark 1.3 发布以来,MLlib 支持 LDA,这是文本挖掘和自然语言处理NLP)领域中最成功使用的主题建模技术之一。此外,LDA 也是第一个采用 Spark GraphX 的 MLlib 算法。

要了解 LDA 背后的理论如何工作的更多信息,请参考 David M. Blei,Andrew Y. Ng 和 Michael I. Jordan,Latent,Dirichlet Allocation,Journal of Machine Learning Research 3(2003)993-1022。

以下图显示了从随机生成的推文文本中的主题分布:

图 20:主题分布及其外观

在本节中,我们将看一个使用 Spark MLlib 的 LDA 算法对非结构化原始推文数据集进行主题建模的示例。请注意,这里我们使用了 LDA,这是最常用于文本挖掘的主题建模算法之一。我们可以使用更健壮的主题建模算法,如概率潜在情感分析pLSA)、赌博分配模型PAM)或分层狄利克雷过程HDP)算法。

然而,pLSA 存在过拟合问题。另一方面,HDP 和 PAM 是更复杂的主题建模算法,用于复杂的文本挖掘,如从高维文本数据或非结构化文档中挖掘主题。此外,迄今为止,Spark 只实现了一个主题建模算法,即 LDA。因此,我们必须合理使用 LDA。

使用 Spark MLlib 进行主题建模

在这个小节中,我们使用 Spark 表示了一种半自动的主题建模技术。使用其他选项作为默认值,我们在从 GitHub URL 下载的数据集上训练 LDA,网址为github.com/minghui/Twitter-LDA/tree/master/data/Data4Model/test。以下步骤展示了从数据读取到打印主题及其词权重的主题建模过程。以下是主题建模流程的简要工作流程:

object topicModellingwithLDA {
  def main(args: Array[String]): Unit = {
    val lda = new LDAforTM() // actual computations are done here
    val defaultParams = Params().copy(input = "data/docs/") 
    // Loading the parameters
    lda.run(defaultParams) // Training the LDA model with the default
                              parameters.
  }
} 

主题建模的实际计算是在LDAforTM类中完成的。Params是一个案例类,用于加载参数以训练 LDA 模型。最后,我们使用Params类设置的参数来训练 LDA 模型。现在,我们将逐步解释每个步骤的源代码:

步骤 1. 创建一个 Spark 会话 - 让我们通过定义计算核心数量、SQL 仓库和应用程序名称来创建一个 Spark 会话,如下所示:

val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName("LDA for topic modelling")
    .getOrCreate() 

步骤 2. 创建词汇表,标记计数以在文本预处理后训练 LDA - 首先,加载文档,并准备好 LDA,如下所示:

// Load documents, and prepare them for LDA.

val preprocessStart = System.nanoTime()
val (corpus, vocabArray, actualNumTokens) = preprocess(params.input, params.vocabSize, params.stopwordFile)  

预处理方法用于处理原始文本。首先,让我们使用wholeTextFiles()方法读取整个文本,如下所示:

val initialrdd = spark.sparkContext.wholeTextFiles(paths).map(_._2)
initialrdd.cache()  

在上述代码中,paths 是文本文件的路径。然后,我们需要根据词形文本准备一个形态学 RDD,如下所示:

val rdd = initialrdd.mapPartitions { partition =>
  val morphology = new Morphology()
  partition.map { value => helperForLDA.getLemmaText(value, morphology) }
}.map(helperForLDA.filterSpecialCharacters)

在这里,helperForLDA类中的getLemmaText()方法在使用filterSpaecialChatacters()方法过滤特殊字符(例如("""[! @ # $ % ^ & * ( ) _ + - − , " ' ; : . ? --]`)后提供了词形文本。

需要注意的是,Morphology()类计算英语单词的基本形式,只删除屈折(不是派生形态)。也就是说,它只处理名词复数、代词格和动词词尾,而不处理比较级形容词或派生名词等。这来自于斯坦福 NLP 组。要使用这个,你应该在主类文件中包含以下导入:edu.stanford.nlp.process.Morphology。在pom.xml文件中,你将需要包含以下条目作为依赖项:

<dependency>
    <groupId>edu.stanford.nlp</groupId>
    <artifactId>stanford-corenlp</artifactId>
    <version>3.6.0</version>
</dependency>
<dependency>
    <groupId>edu.stanford.nlp</groupId>
    <artifactId>stanford-corenlp</artifactId>
    <version>3.6.0</version>
    <classifier>models</classifier>
</dependency>

方法如下:

def getLemmaText(document: String, morphology: Morphology) = {
  val string = new StringBuilder()
  val value = new Document(document).sentences().toList.flatMap { a =>
  val words = a.words().toList
  val tags = a.posTags().toList
  (words zip tags).toMap.map { a =>
    val newWord = morphology.lemma(a._1, a._2)
    val addedWoed = if (newWord.length > 3) {
      newWord
    } else { "" }
      string.append(addedWoed + " ")
    }
  }
  string.toString()
} 

filterSpecialCharacters()如下所示:

def filterSpecialCharacters(document: String) = document.replaceAll("""[! @ # $ % ^ & * ( ) _ + - − , " ' ; : . ? --]""", " ")`。一旦我们手头有去除特殊字符的 RDD,我们就可以创建一个用于构建文本分析管道的 DataFrame:

rdd.cache()
initialrdd.unpersist()
val df = rdd.toDF("docs")
df.show() 

因此,DataFrame 仅包含文档标签。DataFrame 的快照如下:

图 21:原始文本

现在,如果您仔细检查前面的 DataFrame,您会发现我们仍然需要对项目进行标记。此外,在这样的 DataFrame 中还有停用词,因此我们也需要将它们删除。首先,让我们使用RegexTokenizer API 对它们进行标记如下:

val tokenizer = new RegexTokenizer().setInputCol("docs").setOutputCol("rawTokens") 

现在,让我们按如下方式删除所有停用词:

val stopWordsRemover = new StopWordsRemover().setInputCol("rawTokens").setOutputCol("tokens")
stopWordsRemover.setStopWords(stopWordsRemover.getStopWords ++ customizedStopWords)

此外,我们还需要应用计数胜利以仅从标记中找到重要特征。这将有助于使管道在管道阶段链接。让我们按如下方式做:

val countVectorizer = new CountVectorizer().setVocabSize(vocabSize).setInputCol("tokens").setOutputCol("features") 

现在,通过链接转换器(tokenizerstopWordsRemovercountVectorizer)创建管道如下:

val pipeline = new Pipeline().setStages(Array(tokenizer, stopWordsRemover, countVectorizer))

让我们拟合和转换管道以适应词汇和标记数:

val model = pipeline.fit(df)
val documents = model.transform(df).select("features").rdd.map {
  case Row(features: MLVector) =>Vectors.fromML(features)
}.zipWithIndex().map(_.swap)

最后,返回词汇和标记计数对如下:

(documents, model.stages(2).asInstanceOf[CountVectorizerModel].vocabulary, documents.map(_._2.numActives).sum().toLong)

现在,让我们看看训练数据的统计信息:

println()
println("Training corpus summary:")
println("-------------------------------")
println("Training set size: " + actualCorpusSize + " documents")
println("Vocabulary size: " + actualVocabSize + " terms")
println("Number of tockens: " + actualNumTokens + " tokens")
println("Preprocessing time: " + preprocessElapsed + " sec")
println("-------------------------------")
println()

我们得到以下输出:

Training corpus summary:
 -------------------------------
 Training set size: 18 documents
 Vocabulary size: 21607 terms
 Number of tockens: 75758 tokens
 Preprocessing time: 39.768580981 sec
 **-------------------------------**

步骤 4. 在训练之前实例化 LDA 模型

val lda = new LDA()

步骤 5:设置 NLP 优化器

为了从 LDA 模型获得更好和优化的结果,我们需要为 LDA 模型设置优化器。这里我们使用EMLDAOPtimizer优化器。您还可以使用OnlineLDAOptimizer()优化器。但是,您需要将(1.0/actualCorpusSize)添加到MiniBatchFraction中,以使其在小型数据集上更加稳健。整个操作如下。首先,实例化EMLDAOptimizer如下:

val optimizer = params.algorithm.toLowerCase match {
  case "em" => new EMLDAOptimizer
  case "online" => new OnlineLDAOptimizer().setMiniBatchFraction(0.05 + 1.0 / actualCorpusSize)
  case _ => throw new IllegalArgumentException("Only em is supported, got ${params.algorithm}.")
}

现在使用 LDA API 的setOptimizer()方法设置优化器如下:

lda.setOptimizer(optimizer)
  .setK(params.k)
  .setMaxIterations(params.maxIterations)
  .setDocConcentration(params.docConcentration)
  .setTopicConcentration(params.topicConcentration)
  .setCheckpointInterval(params.checkpointInterval)

Params case 类用于定义训练 LDA 模型的参数。具体如下:

 //Setting the parameters before training the LDA model
case class Params(input: String = "",
                  k: Int = 5,
                  maxIterations: Int = 20,
                  docConcentration: Double = -1,
                  topicConcentration: Double = -1,
                  vocabSize: Int = 2900000,
                  stopwordFile: String = "data/stopWords.txt",
                  algorithm: String = "em",
                  checkpointDir: Option[String] = None,
                  checkpointInterval: Int = 10)

为了获得更好的结果,您可以以一种天真的方式设置这些参数。或者,您应该进行交叉验证以获得更好的性能。现在,如果您想要对当前参数进行检查点,请使用以下代码行:

if (params.checkpointDir.nonEmpty) {
  spark.sparkContext.setCheckpointDir(params.checkpointDir.get)
}

步骤 6. 训练 LDA 模型:

val startTime = System.nanoTime()
//Start training the LDA model using the training corpus 
val ldaModel = lda.run(corpus)
val elapsed = (System.nanoTime() - startTime) / 1e9
println(s"Finished training LDA model.  Summary:") 
println(s"t Training time: $elapsed sec")

对于我们拥有的文本,LDA 模型花费了 6.309715286 秒进行训练。请注意,这些时间代码是可选的。我们提供它们仅供参考,只是为了了解训练时间。

步骤 7. 测量数据的可能性 - 现在,为了获得有关数据的更多统计信息,如最大似然或对数似然,我们可以使用以下代码:

if (ldaModel.isInstanceOf[DistributedLDAModel]) {
  val distLDAModel = ldaModel.asInstanceOf[DistributedLDAModel]
  val avgLogLikelihood = distLDAModel.logLikelihood / actualCorpusSize.toDouble
  println("The average log likelihood of the training data: " +  avgLogLikelihood)
  println()
}

前面的代码计算了平均对数似然性,如果 LDA 模型是分布式版本的 LDA 模型的实例。我们得到以下输出:

The average log-likelihood of the training data: -208599.21351837728  

似然性在数据可用后用于描述给定结果的参数(或参数向量)的函数。这对于从一组统计数据中估计参数特别有帮助。有关似然性测量的更多信息,感兴趣的读者应参考en.wikipedia.org/wiki/Likelihood_function

步骤 8. 准备感兴趣的主题 - 准备前五个主题,每个主题有 10 个术语。包括术语及其相应的权重。

val topicIndices = ldaModel.describeTopics(maxTermsPerTopic = 10)
println(topicIndices.length)
val topics = topicIndices.map {case (terms, termWeights) => terms.zip(termWeights).map { case (term, weight) => (vocabArray(term.toInt), weight) } }

步骤 9. 主题建模 - 打印前十个主题,显示每个主题的权重最高的术语。还包括每个主题的总权重如下:

var sum = 0.0
println(s"${params.k} topics:")
topics.zipWithIndex.foreach {
  case (topic, i) =>
  println(s"TOPIC $i")
  println("------------------------------")
  topic.foreach {
    case (term, weight) =>
    println(s"$termt$weight")
    sum = sum + weight
  }
  println("----------------------------")
  println("weight: " + sum)
  println()

现在,让我们看看我们的 LDA 模型对主题建模的输出:

    5 topics:
    TOPIC 0
    ------------------------------
    think 0.0105511077762379
    look  0.010393384083882656
    know  0.010121680765600402
    come  0.009999416569525854
    little      0.009880422850906338
    make  0.008982740529851225
    take  0.007061048216197747
    good  0.007040301924830752
    much  0.006273732732002744
    well  0.0062484438391950895
    ----------------------------
    weight: 0.0865522792882307

    TOPIC 1
    ------------------------------
    look  0.008658099588372216
    come  0.007972622171954474
    little      0.007596460821298818
    hand  0.0065409990798624565
    know  0.006314616294309573
    lorry 0.005843633203040061
    upon  0.005545300032552888
    make  0.005391780686824741
    take  0.00537353581562707
    time  0.005030870790464942
    ----------------------------
    weight: 0.15082019777253794

    TOPIC 2
    ------------------------------
    captain     0.006865463831587792
    nautilus    0.005175561004431676
    make  0.004910586984657019
    hepzibah    0.004378298053191463
    water 0.004063096964497903
    take  0.003959626037381751
    nemo  0.0037687537789531005
    phoebe      0.0037683642100062313
    pyncheon    0.003678496229955977
    seem  0.0034594205003318193
    ----------------------------
    weight: 0.19484786536753268

    TOPIC 3
    ------------------------------
    fogg  0.009552022075897986
    rodney      0.008705705501603078
    make  0.007016635545801613
    take  0.00676049232003675
    passepartout      0.006295907851484774
    leave 0.005565220660514245
    find  0.005077555215275536
    time  0.004852923943330551
    luke  0.004729546554304362
    upon  0.004707181805179265
    ----------------------------
    weight: 0.2581110568409608

    TOPIC 4
    ------------------------------
    dick  0.013754147765988699
    thus  0.006231933402776328
    ring  0.0052746290878481926
    bear  0.005181637978658836
    fate  0.004739983892853129
    shall 0.0046221874997173906
    hand  0.004610810387565958
    stand 0.004121100025638923
    name  0.0036093879729237
    trojan      0.0033792362039766505
    ----------------------------
    weight: 0.31363611105890865

从前面的输出中,我们可以看到输入文档的主题是主题 5,其权重最高为0.31363611105890865。该主题讨论了爱、长、海岸、淋浴、戒指、带来、承担等术语。现在,为了更好地理解流程,这是完整的源代码:

package com.chapter11.SparkMachineLearning

import edu.stanford.nlp.process.Morphology
import edu.stanford.nlp.simple.Document
import org.apache.log4j.{ Level, Logger }
import scala.collection.JavaConversions._
import org.apache.spark.{ SparkConf, SparkContext }
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.feature._
import org.apache.spark.ml.linalg.{ Vector => MLVector }
import org.apache.spark.mllib.clustering.{ DistributedLDAModel, EMLDAOptimizer, LDA, OnlineLDAOptimizer }
import org.apache.spark.mllib.linalg.{ Vector, Vectors }
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.{ Row, SparkSession }

object topicModellingwithLDA {
  def main(args: Array[String]): Unit = {
    val lda = new LDAforTM() // actual computations are done here
    val defaultParams = Params().copy(input = "data/docs/") 
    // Loading the parameters to train the LDA model
    lda.run(defaultParams) // Training the LDA model with the default
                              parameters.
  }
}
//Setting the parameters before training the LDA model
caseclass Params(input: String = "",
                 k: Int = 5,
                 maxIterations: Int = 20,
                 docConcentration: Double = -1,
                 topicConcentration: Double = -1,
                 vocabSize: Int = 2900000,
                 stopwordFile: String = "data/docs/stopWords.txt",
                 algorithm: String = "em",
                 checkpointDir: Option[String] = None,
                 checkpointInterval: Int = 10)

// actual computations for topic modeling are done here
class LDAforTM() {
  val spark = SparkSession
              .builder
              .master("local[*]")
              .config("spark.sql.warehouse.dir", "E:/Exp/")
              .appName("LDA for topic modelling")
              .getOrCreate()

  def run(params: Params): Unit = {
    Logger.getRootLogger.setLevel(Level.WARN)
    // Load documents, and prepare them for LDA.
    val preprocessStart = System.nanoTime()
    val (corpus, vocabArray, actualNumTokens) = preprocess(params
                      .input, params.vocabSize, params.stopwordFile)
    val actualCorpusSize = corpus.count()
    val actualVocabSize = vocabArray.length
    val preprocessElapsed = (System.nanoTime() - preprocessStart) / 1e9
    corpus.cache() //will be reused later steps
    println()
    println("Training corpus summary:")
    println("-------------------------------")
    println("Training set size: " + actualCorpusSize + " documents")
    println("Vocabulary size: " + actualVocabSize + " terms")
    println("Number of tockens: " + actualNumTokens + " tokens")
    println("Preprocessing time: " + preprocessElapsed + " sec")
    println("-------------------------------")
    println()
    // Instantiate an LDA model
    val lda = new LDA()
    val optimizer = params.algorithm.toLowerCase match {
      case "em" => new EMLDAOptimizer
      // add (1.0 / actualCorpusSize) to MiniBatchFraction be more
         robust on tiny datasets.
     case "online" => new OnlineLDAOptimizer()
                  .setMiniBatchFraction(0.05 + 1.0 / actualCorpusSize)
      case _ => thrownew IllegalArgumentException("Only em, online are
                             supported but got ${params.algorithm}.")
    }
    lda.setOptimizer(optimizer)
      .setK(params.k)
      .setMaxIterations(params.maxIterations)
      .setDocConcentration(params.docConcentration)
      .setTopicConcentration(params.topicConcentration)
      .setCheckpointInterval(params.checkpointInterval)
    if (params.checkpointDir.nonEmpty) {
      spark.sparkContext.setCheckpointDir(params.checkpointDir.get)
    }
    val startTime = System.nanoTime()
    //Start training the LDA model using the training corpus
    val ldaModel = lda.run(corpus)
    val elapsed = (System.nanoTime() - startTime) / 1e9
    println("Finished training LDA model. Summary:")
    println("Training time: " + elapsed + " sec")
    if (ldaModel.isInstanceOf[DistributedLDAModel]) {
      val distLDAModel = ldaModel.asInstanceOf[DistributedLDAModel]
      val avgLogLikelihood = distLDAModel.logLikelihood /
                             actualCorpusSize.toDouble
      println("The average log likelihood of the training data: " +
              avgLogLikelihood)
      println()
    }
    // Print the topics, showing the top-weighted terms for each topic.
    val topicIndices = ldaModel.describeTopics(maxTermsPerTopic = 10)
    println(topicIndices.length)
    val topics = topicIndices.map {case (terms, termWeights) =>
                 terms.zip(termWeights).map { case (term, weight) =>
                 (vocabArray(term.toInt), weight) } }
    var sum = 0.0
    println(s"${params.k} topics:")
    topics.zipWithIndex.foreach {
      case (topic, i) =>
      println(s"TOPIC $i")
      println("------------------------------")
      topic.foreach {
        case (term, weight) =>
        term.replaceAll("\\s", "")
        println(s"$term\t$weight")
        sum = sum + weight
      }
      println("----------------------------")
      println("weight: " + sum)
      println()
    }
    spark.stop()
  }
  //Pre-processing of the raw texts
import org.apache.spark.sql.functions._
def preprocess(paths: String, vocabSize: Int, stopwordFile: String): (RDD[(Long, Vector)], Array[String], Long) = {
  import spark.implicits._
  //Reading the Whole Text Files
  val initialrdd = spark.sparkContext.wholeTextFiles(paths).map(_._2)
  initialrdd.cache()
  val rdd = initialrdd.mapPartitions { partition =>
    val morphology = new Morphology()
    partition.map {value => helperForLDA.getLemmaText(value,
                                                      morphology)}
  }.map(helperForLDA.filterSpecialCharacters)
    rdd.cache()
    initialrdd.unpersist()
    val df = rdd.toDF("docs")
    df.show()
    //Customizing the stop words
    val customizedStopWords: Array[String] = if(stopwordFile.isEmpty) {
      Array.empty[String]
    } else {
      val stopWordText = spark.sparkContext.textFile(stopwordFile)
                            .collect()
      stopWordText.flatMap(_.stripMargin.split(","))
    }
    //Tokenizing using the RegexTokenizer
    val tokenizer = new RegexTokenizer().setInputCol("docs")
                                       .setOutputCol("rawTokens")
    //Removing the Stop-words using the Stop Words remover
    val stopWordsRemover = new StopWordsRemover()
                       .setInputCol("rawTokens").setOutputCol("tokens")
    stopWordsRemover.setStopWords(stopWordsRemover.getStopWords ++
                                  customizedStopWords)
    //Converting the Tokens into the CountVector
    val countVectorizer = new CountVectorizer().setVocabSize(vocabSize)
                        .setInputCol("tokens").setOutputCol("features")
    val pipeline = new Pipeline().setStages(Array(tokenizer,
                                    stopWordsRemover, countVectorizer))
    val model = pipeline.fit(df)
    val documents = model.transform(df).select("features").rdd.map {
      case Row(features: MLVector) => Vectors.fromML(features)
    }.zipWithIndex().map(_.swap)
    //Returning the vocabulary and tocken count pairs
    (documents, model.stages(2).asInstanceOf[CountVectorizerModel]
     .vocabulary, documents.map(_._2.numActives).sum().toLong)
    }
  }
  object helperForLDA {
    def filterSpecialCharacters(document: String) = 
      document.replaceAll("""[! @ # $ % ^ & * ( ) _ + - − ,
                          " ' ; : . ` ? --]""", " ")
    def getLemmaText(document: String, morphology: Morphology) = {
      val string = new StringBuilder()
      val value =new Document(document).sentences().toList.flatMap{a =>
      val words = a.words().toList
      val tags = a.posTags().toList
      (words zip tags).toMap.map { a =>
        val newWord = morphology.lemma(a._1, a._2)
        val addedWoed = if (newWord.length > 3) {
          newWord
        } else { "" }
        string.append(addedWoed + " ")
      }
    }
    string.toString()
  }
}

LDA 的可扩展性

前面的示例展示了如何使用 LDA 算法进行主题建模作为独立应用程序。LDA 的并行化并不直接,已经有许多研究论文提出了不同的策略。在这方面的关键障碍是所有方法都涉及大量的通信。根据 Databricks 网站上的博客(databricks.com/blog/2015/03/25/topic-modeling-with-lda-mllib-meets-graphx.html),以下是在实验过程中使用的数据集和相关训练和测试集的统计数据:

  • 训练集大小:460 万个文档

  • 词汇量:110 万个术语

  • 训练集大小:110 亿个标记(~每个文档 239 个词)

  • 100 个主题

  • 16 个 worker 的 EC2 集群,例如 M4.large 或 M3.medium,具体取决于预算和要求

对于前述设置,平均每次迭代的时间结果为 176 秒/迭代,共进行了 10 次迭代。从这些统计数据可以清楚地看出,对于非常大量的语料库,LDA 是相当可扩展的。

摘要

在本章中,我们提供了有关 Spark 机器学习一些高级主题的理论和实践方面。我们还提供了一些关于机器学习最佳实践的建议。在此之后,我们已经看到如何使用网格搜索、交叉验证和超参数调整来调整机器学习模型,以获得更好和优化的性能。在后面的部分,我们看到了如何使用 ALS 开发可扩展的推荐系统,这是使用基于模型的协同过滤方法的基于模型的推荐系统的一个示例。最后,我们看到了如何开发主题建模应用作为文本聚类技术。

对于机器学习最佳实践的其他方面和主题,感兴趣的读者可以参考名为Large Scale Machine Learning with Spark的书籍www.packtpub.com/big-data-and-business-intelligence/large-scale-machine-learning-spark.

在下一章中,我们将进入更高级的 Spark 使用。虽然我们已经讨论并提供了关于二元和多类分类的比较分析,但我们将更多地了解 Spark 中的其他多项式分类算法,如朴素贝叶斯、决策树和一对多分类器。

第十三章:我的名字是贝叶斯,朴素贝叶斯

“预测是非常困难的,尤其是关于未来的预测”

-尼尔斯·玻尔

机器学习(ML)与大数据的结合是一种革命性的组合,对学术界和工业界的研究产生了巨大影响。此外,许多研究领域也进入了大数据领域,因为数据集以前所未有的方式从各种来源和技术产生和生成,通常被称为数据洪流。这给机器学习、数据分析工具和算法带来了巨大挑战,以从大数据的诸如容量、速度和多样性等标准中找到真正的价值。然而,从这些庞大数据集中进行预测从来都不容易。

考虑到这一挑战,在本章中我们将深入探讨机器学习,并了解如何使用一种简单而强大的方法来构建可扩展的分类模型,甚至更多。简而言之,本章将涵盖以下主题:

  • 多项式分类

  • 贝叶斯推断

  • 朴素贝叶斯

  • 决策树

  • 朴素贝叶斯与决策树

多项式分类

在机器学习中,多项式(也称为多类)分类是将数据对象或实例分类为两个以上类别的任务,即具有两个以上标签或类别。将数据对象或实例分类为两个类别称为二进制分类。更具体地说,在多项式分类中,每个训练实例属于 N 个不同类别中的一个,其中N >=2。目标是构建一个能够正确预测新实例所属类别的模型。可能存在许多情景,其中数据点属于多个类别。然而,如果给定点属于多个类别,这个问题可以轻松地分解为一组不相关的二进制问题,可以使用二进制分类算法自然地解决。

建议读者不要混淆多类分类和多标签分类,多标签分类是要为每个实例预测多个标签。对于基于 Spark 的多标签分类的实现,感兴趣的读者应参考spark.apache.org/docs/latest/mllib-evaluation-metrics.html#multilabel-classification

多类分类技术可以分为以下几类:

  • 转换为二进制

  • 从二进制扩展

  • 分层分类

转换为二进制

使用转换为二进制的技术,多类分类问题可以转化为多个二进制分类问题的等效策略。换句话说,这种技术可以称为问题转换技术。从理论和实践角度进行详细讨论超出了本章的范围。因此,这里我们只讨论问题转换技术的一个例子,即代表这一类别的一对多(OVTR)算法。

使用一对多方法进行分类

在这一小节中,我们将通过将问题转化为等效的多个二进制分类问题,来描述使用 OVTR 算法进行多类分类的示例。OVTR 策略将问题分解,并针对每个类训练每个二进制分类器。换句话说,OVTR 分类器策略包括为每个类拟合一个二进制分类器。然后将当前类的所有样本视为正样本,因此其他分类器的样本被视为负样本。

毫无疑问,这是一种模块化的机器学习技术。然而,这种策略的缺点是需要来自多类家族的基本分类器。原因是分类器必须产生一个实值,也称为置信分数,而不是实际标签的预测。这种策略的第二个缺点是,如果数据集(也称为训练集)包含离散的类标签,这最终会导致模糊的预测结果。在这种情况下,一个样本可能被预测为多个类。为了使前面的讨论更清晰,现在让我们看一个例子。

假设我们有一组 50 个观察结果,分为三类。因此,我们将使用与之前相同的逻辑来选择负例。对于训练阶段,让我们有以下设置:

  • 分类器 1有 30 个正例和 20 个负例

  • 分类器 2有 36 个正例和 14 个负例

  • 分类器 3有 14 个正例和 24 个负例

另一方面,在测试阶段,假设我有一个新实例需要分类到之前的某个类别中。当然,每个分类器都会产生一个关于估计的概率。这是一个实例属于分类器中的负面或正面示例的估计?在这种情况下,我们应该总是比较一个类中的正面概率与其他类。现在对于N个类,我们将有N个正面类的概率估计值。比较它们,无论哪个概率是N个概率中的最大值,都属于那个特定的类。Spark 提供了 OVTR 算法的多类到二进制的缩减,其中逻辑回归算法被用作基本分类器。

现在让我们看另一个真实数据集的例子,以演示 Spark 如何使用 OVTR 算法对所有特征进行分类。OVTR 分类器最终预测来自光学字符识别(OCR)数据集的手写字符。然而,在深入演示之前,让我们先探索 OCR 数据集,以了解数据的探索性质。需要注意的是,当 OCR 软件首次处理文档时,它将纸张或任何对象分成一个矩阵,以便网格中的每个单元格包含一个单一的字形(也称为不同的图形形状),这只是一种指代字母、符号、数字或来自纸张或对象的任何上下文信息的复杂方式。

为了演示 OCR 管道,假设文档只包含与 26 个大写字母中的一个匹配的英文 alpha 字符,即AZ。我们将使用来自UCI 机器学习数据存储库的 OCR 字母数据集。该数据集由 W*. FreyD. J. Slate.*标记。在探索数据集时,您应该观察到 20,000 个例子,其中包含 26 个英文大写字母。大写字母以 20 种不同的、随机重塑和扭曲的黑白字体作为不同形状的字形打印。简而言之,从 26 个字母中预测所有字符将问题本身转变为一个具有 26 个类的多类分类问题。因此,二元分类器将无法满足我们的目的。

图 1: 一些印刷字形(来源:使用 Holland 风格自适应分类器进行字母识别,ML,V. 6,p. 161-182,作者 W. Frey 和 D.J. Slate [1991])

前面的图显示了我之前解释过的图像。数据集提供了一些以这种方式扭曲的印刷字形的示例;因此,这些字母对计算机来说是具有挑战性的。然而,这些字形对人类来说很容易识别。下图显示了前 20 行的统计属性:

图 2: 数据框架显示的数据集快照

OCR 数据集的探索和准备

根据数据集描述,字形是使用 OCR 阅读器扫描到计算机上,然后它们自动转换为像素。因此,所有 16 个统计属性(在图 2中)也记录到计算机中。盒子各个区域的黑色像素的浓度提供了一种区分 26 个字母的方法,使用 OCR 或机器学习算法进行训练。

回想一下,支持向量机SVM),逻辑回归,朴素贝叶斯分类器,或者任何其他分类器算法(以及它们关联的学习器)都要求所有特征都是数字。LIBSVM 允许您使用非常规格式的稀疏训练数据集。在将正常训练数据集转换为 LIBSVM 格式时,只有数据集中包含的非零值存储在稀疏数组/矩阵形式中。索引指定实例数据的列(特征索引)。但是,任何缺失的数据也被视为零值。索引用作区分特征/参数的一种方式。例如,对于三个特征,索引 1、2 和 3 分别对应于xyz坐标。不同数据实例的相同索引值之间的对应仅在构建超平面时是数学的;这些用作坐标。如果您在中间跳过任何索引,它应该被分配一个默认值为零。

在大多数实际情况下,我们可能需要对所有特征点进行数据归一化。简而言之,我们需要将当前的制表符分隔的 OCR 数据转换为 LIBSVM 格式,以使训练步骤更容易。因此,我假设您已经下载了数据并使用它们自己的脚本转换为 LIBSVM 格式。转换为 LIBSVM 格式的结果数据集包括标签和特征,如下图所示:

**图 3:**LIBSVM 格式的 OCR 数据集的 20 行快照

感兴趣的读者可以参考以下研究文章以获得深入的知识:Chih-Chung ChangChih-Jen LinLIBSVM:支持向量机库ACM 智能系统与技术交易,2:27:1--27:27,2011 年。您还可以参考我在 GitHub 存储库上提供的公共脚本,该脚本直接将 CSV 中的 OCR 数据转换为 LIBSVM 格式。我读取了所有字母的数据,并为每个字母分配了唯一的数值。您只需要显示输入和输出文件路径并运行脚本。

现在让我们来看一个例子。我将演示的例子包括 11 个步骤,包括数据解析、Spark 会话创建、模型构建和模型评估。

步骤 1. 创建 Spark 会话 - 通过指定主 URL、Spark SQL 仓库和应用程序名称来创建 Spark 会话,如下所示:

val spark = SparkSession.builder
                     .master("local[*]") //change acordingly
                     .config("spark.sql.warehouse.dir", "/home/exp/")
                     .appName("OneVsRestExample") 
                     .getOrCreate()

步骤 2. 加载、解析和创建数据框 - 从 HDFS 或本地磁盘加载数据文件,并创建数据框,最后显示数据框结构如下:

val inputData = spark.read.format("libsvm")
                     .load("data/Letterdata_libsvm.data")
inputData.show()

步骤 3. 生成训练和测试集以训练模型 - 让我们通过将 70%用于训练和 30%用于测试来生成训练和测试集:

val Array(train, test) = inputData.randomSplit(Array(0.7, 0.3))

步骤 4. 实例化基本分类器 - 这里基本分类器充当多类分类器。在这种情况下,可以通过指定最大迭代次数、容差、回归参数和弹性网参数来实例化逻辑回归算法。

请注意,当因变量是二元的时,逻辑回归是适当的回归分析。与所有回归分析一样,逻辑回归是一种预测性分析。逻辑回归用于描述数据并解释一个因变量二进制变量和一个或多个名义,有序,间隔或比率水平自变量之间的关系。

对于基于 Spark 的逻辑回归算法的实现,感兴趣的读者可以参考spark.apache.org/docs/latest/mllib-linear-methods.html#logistic-regression

简而言之,以下参数用于训练逻辑回归分类器:

  • MaxIter:这指定了最大迭代次数。一般来说,越多越好。

  • Tol:这是停止标准的公差。一般来说,越少越好,这有助于更加强烈地训练模型。默认值为 1E-4。

  • FirIntercept:这表示是否在生成概率解释时拦截决策函数。

  • Standardization:这表示一个布尔值,取决于是否要对训练进行标准化。

  • AggregationDepth:越多越好。

  • RegParam:这表示回归参数。在大多数情况下,越少越好。

  • ElasticNetParam:这表示更先进的回归参数。在大多数情况下,越少越好。

然而,您可以指定拟合拦截作为Boolean值,取决于您的问题类型和数据集属性:

 val classifier = new LogisticRegression()
                        .setMaxIter(500)          
                        .setTol(1E-4)                                                                                                  
                        .setFitIntercept(true)
                        .setStandardization(true) 
                        .setAggregationDepth(50) 
                        .setRegParam(0.0001) 
                        .setElasticNetParam(0.01)

第 5 步。 实例化 OVTR 分类器 - 现在实例化一个 OVTR 分类器,将多类分类问题转换为多个二进制分类问题如下:

val ovr = new OneVsRest().setClassifier(classifier)

这里classifier是逻辑回归估计器。现在是训练模型的时候了。

第 6 步。 训练多类模型 - 让我们使用训练集来训练模型如下:

val ovrModel = ovr.fit(train)

第 7 步。 在测试集上对模型进行评分 - 我们可以使用转换器(即ovrModel)对测试数据进行评分如下:

val predictions = ovrModel.transform(test)

第 8 步。 评估模型 - 在这一步中,我们将预测第一列中字符的标签。但在此之前,我们需要实例化一个evaluator来计算分类性能指标,如准确性,精确度,召回率和f1度量如下:

val evaluator = new MulticlassClassificationEvaluator()
                           .setLabelCol("label")
                           .setPredictionCol("prediction")    
val evaluator1 = evaluator.setMetricName("accuracy")
val evaluator2 = evaluator.setMetricName("weightedPrecision")
val evaluator3 = evaluator.setMetricName("weightedRecall")
val evaluator4 = evaluator.setMetricName("f1")

第 9 步。 计算性能指标 - 计算测试数据的分类准确性,精确度,召回率,f1度量和错误如下:

val accuracy = evaluator1.evaluate(predictions)
val precision = evaluator2.evaluate(predictions)
val recall = evaluator3.evaluate(predictions)
val f1 = evaluator4.evaluate(predictions)

第 10 步。 打印性能指标:

println("Accuracy = " + accuracy)
println("Precision = " + precision)
println("Recall = " + recall)
println("F1 = " + f1)
println(s"Test Error = ${1 - accuracy}")

您应该观察到以下值:

Accuracy = 0.5217246545696688
Precision = 0.488360500637862
Recall = 0.5217246545696688
F1 = 0.4695649096879411
Test Error = 0.47827534543033123

第 11 步。 停止 Spark 会话:

spark.stop() // Stop Spark session

通过这种方式,我们可以将多项分类问题转换为多个二进制分类问题,而不会牺牲问题类型。然而,从第 10 步可以观察到分类准确性并不好。这可能是由于多种原因,例如我们用来训练模型的数据集的性质。而且更重要的是,在训练逻辑回归模型时,我们没有调整超参数。此外,在执行转换时,OVTR 不得不牺牲一些准确性。

分层分类

在分层分类任务中,分类问题可以通过将输出空间划分为树来解决。在该树中,父节点被划分为多个子节点。该过程持续进行,直到每个子节点表示一个单一类别。基于分层分类技术提出了几种方法。计算机视觉是这样的领域的一个例子,其中识别图片或书面文本是使用分层处理的内容。本章对这个分类器的广泛讨论超出了范围。

从二进制扩展

这是一种将现有的二元分类器扩展为解决多类分类问题的技术。为了解决多类分类问题,基于神经网络、决策树、随机森林、k-最近邻、朴素贝叶斯和支持向量机等算法已经被提出和发展。在接下来的部分中,我们将讨论朴素贝叶斯和决策树算法作为这一类别的代表。

现在,在开始使用朴素贝叶斯算法解决多类分类问题之前,让我们在下一节简要概述贝叶斯推断。

贝叶斯推断

在本节中,我们将简要讨论贝叶斯推断BI)及其基本理论。读者将从理论和计算的角度熟悉这个概念。

贝叶斯推断概述

贝叶斯推断是一种基于贝叶斯定理的统计方法。它用于更新假设的概率(作为强有力的统计证据),以便统计模型可以反复更新以实现更准确的学习。换句话说,在贝叶斯推断方法中,所有类型的不确定性都以统计概率的形式显现出来。这是理论统计学和数学统计学中的重要技术。我们将在后面的部分广泛讨论贝叶斯定理。

此外,贝叶斯更新在数据集序列的增量学习和动态分析中占据主导地位。例如,在时间序列分析、生物医学数据分析中的基因组测序、科学、工程、哲学和法律等领域,广泛使用贝叶斯推断。从哲学和决策理论的角度来看,贝叶斯推断与预测概率密切相关。然而,这个理论更正式地被称为贝叶斯概率

什么是推断?

推断或模型评估是更新模型得出的结果的概率的过程。因此,所有的概率证据最终都会根据手头的观察结果得知,以便在使用贝叶斯模型进行分类分析时更新观察结果。随后,这些信息通过将一致性实例化到数据集中的所有观察结果中,被提取到贝叶斯模型中。被提取到模型中的规则被称为先验概率,其中在参考某些相关观察结果之前评估概率,特别是主观地或者假设所有可能的结果具有相同的概率。然后,当所有证据都已知时,信念就会被计算为后验概率。这些后验概率反映了基于更新的证据计算出的假设水平。

贝叶斯定理用于计算表示两个前提的结果的后验概率。基于这些前提,从统计模型中推导出先验概率和似然函数,用于新数据的模型适应性。我们将在后面的部分进一步讨论贝叶斯定理。

它是如何工作的?

在这里,我们讨论了统计推断问题的一般设置。首先,从数据中估计所需的数量,可能还有一些未知的数量,我们也想要估计。它可能只是一个响应变量或预测变量,一个类别,一个标签,或者只是一个数字。如果您熟悉频率主义方法,您可能知道在这种方法中,假设未知的数量θ被假定为一个固定的(非随机的)数量,它将由观察到的数据来估计。

然而,在贝叶斯框架中,一个未知的量θ被视为一个随机变量。更具体地说,假设我们对θ的分布有一个初始猜测,通常称为先验分布。现在,在观察到一些数据后,θ的分布被更新。通常使用贝叶斯定理来执行这一步骤(有关更多细节,请参阅下一节)。这就是为什么这种方法被称为贝叶斯方法。然而,简而言之,从先验分布中,我们可以计算未来观察的预测分布。

这种不矫揉造作的过程可以通过许多论据来证明是不确定推理的适当方法。然而,这些论据的合理性原则是保持一致的。尽管有这些强有力的数学证据,许多机器学习从业者对使用贝叶斯方法感到不舒服,有些不情愿。其背后的原因是他们经常认为选择后验概率或先验是任意和主观的;然而,实际上这是主观的但不是任意的。

不恰当地,许多贝叶斯派并不真正以真正的贝叶斯方式思考。因此,人们可以在文献中找到许多伪贝叶斯程序,其中使用的模型和先验不能被认真地看作是先验信念的表达。贝叶斯方法也可能存在计算困难。其中许多可以通过马尔可夫链蒙特卡洛方法来解决,这也是我研究的另一个主要焦点。随着您阅读本章,这种方法的细节将更加清晰。

朴素贝叶斯

在机器学习中,朴素贝叶斯NB)是一个基于著名的贝叶斯定理和特征之间强独立假设的概率分类器的例子。我们将在本节详细讨论朴素贝叶斯。

贝叶斯定理概述

在概率论中,贝叶斯定理描述了基于与某一事件相关的先验条件的先验知识来计算该事件的概率。这是由托马斯·贝叶斯牧师最初陈述的概率定理。换句话说,它可以被看作是一种理解概率论如何受新信息影响的方式。例如,如果癌症与年龄有关,关于年龄的信息可以用来更准确地评估一个人可能患癌症的概率*。*

贝叶斯定理在数学上陈述如下方程:

在上述方程中,AB是具有P (B) ≠ 0的事件,其他项可以描述如下:

  • P(A)和P(B)是观察到AB的概率,而不考虑彼此(即独立性)

  • P(A | B)是在B为真的情况下观察到事件A的条件概率

  • P(B| A)是在A为真的情况下观察到事件B的条件概率

您可能知道,一项著名的哈佛大学研究显示,只有 10%的快乐人群是富裕的。然而,您可能认为这个统计数据非常有说服力,但您可能对知道富裕人群中也真的很快乐的百分比感兴趣*。*贝叶斯定理可以帮助您计算这个逆转统计,使用两个额外线索:

  1. 总体上快乐的人的百分比,即P(A).

  2. 总体上富裕的人的百分比,即P(B).

贝叶斯定理背后的关键思想是逆转统计考虑整体比率**。**假设以下信息作为先验可用:

  1. 40%的人是快乐的*=> P(A).*

  2. 5%的人是富裕的*=> P(B).*

现在让我们假设哈佛大学的研究是正确的,即P(B|A) = 10%。现在富裕人群中快乐的人的比例,即P(A | B), 可以计算如下:

P(A|B) = {P(A) P(B| A)}/ P(B) = (40%10%)/5% = 80%

因此,大多数人也很高兴!很好。为了更清楚,现在让我们假设整个世界的人口为 1,000,以便简化。然后,根据我们的计算,存在两个事实:

  • 事实 1:这告诉我们有 400 人很高兴,哈佛的研究告诉我们这些快乐的人中有 40 个也很富有。

  • 事实 2:总共有 50 个富人,所以快乐的比例是 40/50 = 80%。

这证明了贝叶斯定理及其有效性。然而,更全面的例子可以在onlinecourses.science.psu.edu/stat414/node/43找到。

我的名字是贝叶斯,朴素贝叶斯

我是贝叶斯,朴素贝叶斯(NB)。我是一个成功的分类器,基于最大后验概率MAP)原理。作为一个分类器,我具有高度可扩展性,需要的参数数量与学习问题中的变量(特征/预测器)数量成正比。我有几个特性,例如,我在计算上更快,如果你雇佣我来分类一些东西,我很容易实现,并且我可以很好地处理高维数据集。此外,我可以处理数据集中的缺失值。然而,我是适应性的,因为模型可以通过新的训练数据进行修改而无需重建模型。

在贝叶斯统计学中,MAP 估计是未知数量的估计,等于后验分布的模。MAP 估计可用于根据经验数据获得未观察到的数量的点估计。

听起来有点像詹姆斯·邦德电影?好吧,你/我们可以把分类器看作是 007 特工,对吧?开玩笑。我相信我不像朴素贝叶斯分类器的参数,例如先验和条件概率是通过一组确定的步骤学习或确定的:这涉及两个非常微不足道的操作,在现代计算机上可以非常快速,即计数和除法。没有迭代。没有时代。没有优化成本方程(这可能是复杂的,平均为三次方或至少为二次方复杂度)。没有错误反向传播。没有涉及解矩阵方程的操作。这使得朴素贝叶斯及其整体训练更快。

然而,在雇佣这个代理之前,你/我们可以发现他的优缺点,这样我们才能像使用王牌一样利用它的优势。好吧,下面是总结这个代理的优缺点的表格:

代理 优点 缺点 擅长
朴素贝叶斯(NB) - 计算速度快- 实现简单- 在高维度下工作良好- 可处理缺失值- 需要少量数据来训练模型- 可扩展- 适应性强,因为模型可以通过新的训练数据进行修改而无需重建模型 - 依赖独立假设,如果假设不成立则性能较差- 相对较低的准确性- 如果类标签和某个属性值没有出现在一起,则基于频率的概率估计将为零 - 当数据有很多缺失值时- 当特征之间的依赖关系相似- 垃圾邮件过滤和分类- 对科技、政治、体育等新闻文章进行分类- 文本挖掘

**表 1:**朴素贝叶斯算法的优缺点

使用 NB 构建可扩展的分类器

在这一部分,我们将看到使用朴素贝叶斯NB)算法的逐步示例。如前所述,NB 具有高度可扩展性,需要的参数数量与学习问题中的变量(特征/预测器)数量成正比。这种可扩展性使得 Spark 社区能够使用这种算法对大规模数据集进行预测分析。Spark MLlib 中 NB 的当前实现支持多项式 NB 和伯努利 NB。

如果特征向量是二进制的,伯努利 NB 是有用的。一个应用可能是使用词袋(BOW)方法进行文本分类。另一方面,多项式 NB 通常用于离散计数。例如,如果我们有一个文本分类问题,我们可以进一步采用伯努利试验的想法,而不是在文档中使用 BOW,我们可以使用文档中的频率计数。

在本节中,我们将看到如何通过整合 Spark 机器学习 API(包括 Spark MLlib、Spark ML 和 Spark SQL)来预测基于笔的手写数字识别数据集中的数字:

步骤 1. 数据收集、预处理和探索 - 从 UCI 机器学习库www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits下载了基于笔的手写数字数据集。该数据集是在从 44 位作者那里收集了大约 250 个数字样本后生成的,这些数字样本与笔在 100 毫秒的固定时间间隔内的位置相关。然后,每个数字都写在一个 500 x 500 像素的框内。最后,这些图像被缩放到 0 到 100 之间的整数值,以创建每个观察之间的一致缩放。一个众所周知的空间重采样技术被用来获得弧轨迹上的 3 和 8 个等间距点。可以通过根据它们的(x,y)坐标绘制 3 或 8 个采样点来可视化一个样本图像以及点与点之间的线;它看起来像下表所示:

集合 '0' '1' '2' '3' '4' '5' '6' '7' '8' '9' 总计
训练 780 779 780 719 780 720 720 778 718 719 7493
测试 363 364 364 336 364 335 336 364 335 336 3497

表 2:用于训练和测试集的数字数量

如前表所示,训练集由 30 位作者撰写的样本组成,测试集由 14 位作者撰写的样本组成。

图 4:数字 3 和 8 的示例

有关该数据集的更多信息可以在archive.ics.uci.edu/ml/machine-learning-databases/pendigits/pendigits-orig.names找到。数据集的一个样本快照的数字表示如下图所示:

图 5:手写数字数据集的 20 行快照

现在,为了使用独立变量(即特征)预测因变量(即标签),我们需要训练一个多类分类器,因为如前所示,数据集现在有九个类别,即九个手写数字。对于预测,我们将使用朴素贝叶斯分类器并评估模型的性能。

步骤 2. 加载所需的库和包:

import org.apache.spark.ml.classification.NaiveBayes
import org.apache.spark.ml.evaluation
                                 .MulticlassClassificationEvaluator
import org.apache.spark.sql.SparkSession

步骤 3. 创建一个活跃的 Spark 会话:

val spark = SparkSession
              .builder
              .master("local[*]")
              .config("spark.sql.warehouse.dir", "/home/exp/")
              .appName(s"NaiveBayes")
              .getOrCreate()

请注意,这里的主 URL 已设置为local[*],这意味着您的计算机的所有核心将用于处理 Spark 作业。您应该根据要求相应地设置 SQL 数据仓库和其他配置参数。

步骤 4. 创建 DataFrame - 将以 LIBSVM 格式存储的数据加载为 DataFrame:

val data = spark.read.format("libsvm")
                     .load("data/pendigits.data")

对于数字分类,输入特征向量通常是稀疏的,应该将稀疏向量作为输入以利用稀疏性。由于训练数据只使用一次,而且数据集的大小相对较小(即几 MB),如果您多次使用 DataFrame,可以将其缓存。

步骤 5. 准备训练和测试集 - 将数据分割为训练集和测试集(25%用于测试):

val Array(trainingData, testData) = data
                  .randomSplit(Array(0.75, 0.25), seed = 12345L)

步骤 6. 训练朴素贝叶斯模型 - 使用训练集训练朴素贝叶斯模型如下:

val nb = new NaiveBayes()
val model = nb.fit(trainingData)

步骤 7: 计算测试集上的预测 - 使用模型变换器计算预测,最后显示针对每个标签的预测,如下所示:

val predictions = model.transform(testData)
predictions.show()

图 6: 针对每个标签(即每个数字)的预测

如前图所示,一些标签被准确预测,而另一些标签则错误。再次,我们需要了解加权准确性、精确度、召回率和 F1 度量,而不是简单地评估模型。

步骤 8: 评估模型 - 选择预测和真实标签来计算测试错误和分类性能指标,如准确性、精确度、召回率和 F1 度量,如下所示:

val evaluator = new MulticlassClassificationEvaluator()
                           .setLabelCol("label")
                           .setPredictionCol("prediction")    
val evaluator1 = evaluator.setMetricName("accuracy")
val evaluator2 = evaluator.setMetricName("weightedPrecision")
val evaluator3 = evaluator.setMetricName("weightedRecall")
val evaluator4 = evaluator.setMetricName("f1")

步骤 9: 计算性能指标 - 计算测试数据的分类准确性、精确度、召回率、F1 度量和错误,如下所示:

val accuracy = evaluator1.evaluate(predictions)
val precision = evaluator2.evaluate(predictions)
val recall = evaluator3.evaluate(predictions)
val f1 = evaluator4.evaluate(predictions)

步骤 10: 打印性能指标:

println("Accuracy = " + accuracy)
println("Precision = " + precision)
println("Recall = " + recall)
println("F1 = " + f1)
println(s"Test Error = ${1 - accuracy}")

您应该观察到以下值:

Accuracy = 0.8284365162644282
Precision = 0.8361211320692463
Recall = 0.828436516264428
F1 = 0.8271828540349192
Test Error = 0.17156348373557184

性能并不是那么糟糕。但是,您仍然可以通过进行超参数调整来提高分类准确性。通过交叉验证和训练集拆分,可以进一步提高预测准确性,这将在下一节中讨论。

调整我!

您已经了解我的优缺点,我的一个缺点是,我的分类准确性相对较低。但是,如果您调整我,我可以表现得更好。好吧,我们应该相信朴素贝叶斯吗?如果是这样,我们不应该看看如何提高这家伙的预测性能吗?比如使用 WebSpam 数据集。首先,我们应该观察 NB 模型的性能,然后再看如何使用交叉验证技术提高性能。

www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/webspam_wc_normalized_trigram.svm.bz2下载的 WebSpam 数据集包含特征和相应的标签,即垃圾邮件或正常邮件。因此,这是一个监督式机器学习问题,这里的任务是预测给定消息是垃圾邮件还是正常邮件(即非垃圾邮件)。原始数据集大小为 23.5 GB,类别标签为+1 或-1(即二元分类问题)。后来,我们将-1 替换为 0.0,+1 替换为 1.0,因为朴素贝叶斯不允许使用有符号整数。修改后的数据集如下图所示:

图 7: WebSpam 数据集的 20 行快照

首先,我们需要导入必要的包,如下所示:

import org.apache.spark.ml.classification.NaiveBayes
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
import org.apache.spark.sql.SparkSession
import org.apache.spark.ml.Pipeline;
import org.apache.spark.ml.PipelineStage;
import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator
import org.apache.spark.ml.feature.{HashingTF, Tokenizer}
import org.apache.spark.ml.linalg.Vector
import org.apache.spark.ml.tuning.{CrossValidator, ParamGridBuilder}

现在创建 Spark 会话作为代码的入口点,如下所示:

val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "/home/exp/")
      .appName("Tuned NaiveBayes")
      .getOrCreate()

让我们加载 WebSpam 数据集并准备训练集来训练朴素贝叶斯模型,如下所示:

// Load the data stored in LIBSVM format as a DataFrame.
 val data = spark.read.format("libsvm").load("hdfs://data/ webspam_wc_normalized_trigram.svm")
 // Split the data into training and test sets (30% held out for testing)
 val Array(trainingData, testData) = data.randomSplit(Array(0.75, 0.25), seed = 12345L)
 // Train a NaiveBayes model with using the training set
 val nb = new NaiveBayes().setSmoothing(0.00001)
 val model = nb.fit(trainingData)

在前面的代码中,设置种子是为了可重现性。现在让我们在验证集上进行预测,如下所示:

val predictions = model.transform(testData)
predictions.show()

现在让我们获取evaluator并计算分类性能指标,如准确性、精确度、召回率和f1度量,如下所示:

val evaluator = new MulticlassClassificationEvaluator()
                    .setLabelCol("label")
                    .setPredictionCol("prediction")    
val evaluator1 = evaluator.setMetricName("accuracy")
val evaluator2 = evaluator.setMetricName("weightedPrecision")
val evaluator3 = evaluator.setMetricName("weightedRecall")
val evaluator4 = evaluator.setMetricName("f1")

现在让我们计算并打印性能指标:

val accuracy = evaluator1.evaluate(predictions)
val precision = evaluator2.evaluate(predictions)
val recall = evaluator3.evaluate(predictions)
val f1 = evaluator4.evaluate(predictions)   
// Print the performance metrics
println("Accuracy = " + accuracy)
println("Precision = " + precision)
println("Recall = " + recall)
println("F1 = " + f1)
println(s"Test Error = ${1 - accuracy}")

您应该收到以下输出:

Accuracy = 0.8839357429715676
Precision = 0.86393574297188752
Recall = 0.8739357429718876
F1 = 0.8739357429718876
Test Error = 0.11606425702843237

尽管准确性达到了令人满意的水平,但我们可以通过应用交叉验证技术进一步提高它。该技术的步骤如下:

  • 通过链接一个 NB 估计器作为管道的唯一阶段来创建管道

  • 现在为调整准备参数网格

  • 执行 10 折交叉验证

  • 现在使用训练集拟合模型

  • 计算验证集上的预测

诸如交叉验证之类的模型调整技术的第一步是创建管道。可以通过链接变换器、估计器和相关参数来创建管道。

步骤 1: 创建管道 - 让我们创建一个朴素贝叶斯估计器(在下面的情况中nb是一个估计器),并通过链接估计器来创建管道,如下所示:

val nb = new NaiveBayes().setSmoothing(00001)
val pipeline = new Pipeline().setStages(Array(nb))

管道可以被视为用于训练和预测的数据工作流系统。ML 管道提供了一组统一的高级 API,构建在DataFrames之上,帮助用户创建和调整实用的机器学习管道。DataFrame、转换器、估计器、管道和参数是管道创建中最重要的五个组件。有兴趣的读者可以参考spark.apache.org/docs/latest/ml-pipeline.html了解更多关于管道的信息。

在早期情况下,我们管道中的唯一阶段是一个估计器,它是用于在 DataFrame 上拟合的算法,以产生一个转换器,以确保训练成功进行。

步骤 2. 创建网格参数 - 让我们使用ParamGridBuilder构建一个参数网格进行搜索:

val paramGrid = new ParamGridBuilder()
              .addGrid(nb.smoothing, Array(0.001, 0.0001))
              .build()

步骤 3. 执行 10 折交叉验证 - 现在我们将管道视为一个估计器,将其包装在一个交叉验证实例中。这将允许我们共同选择所有管道阶段的参数。CrossValidator需要一个估计器、一组估计器ParamMaps和一个评估器。请注意,这里的评估器是BinaryClassificationEvaluator,其默认指标是areaUnderROC。但是,如果您将评估器用作MultiClassClassificationEvaluator,您将能够使用其他性能指标:

val cv = new CrossValidator()
            .setEstimator(pipeline)
            .setEvaluator(new BinaryClassificationEvaluator)
            .setEstimatorParamMaps(paramGrid)
            .setNumFolds(10)  // Use 3+ in practice

步骤 4. 按以下方式使用训练集拟合交叉验证模型:

val model = cv.fit(trainingData)

步骤 5. 按以下方式计算性能:

val predictions = model.transform(validationData)
predictions.show()

步骤 6. 获取评估器,计算性能指标并显示结果。现在让我们获取evaluator并计算分类性能指标,如准确度、精确度、召回率和 f1 度量。这里将使用MultiClassClassificationEvaluator来计算准确度、精确度、召回率和 f1 度量:

val evaluator = new MulticlassClassificationEvaluator()
                            .setLabelCol("label")
                            .setPredictionCol("prediction")    
val evaluator1 = evaluator.setMetricName("accuracy")
val evaluator2 = evaluator.setMetricName("weightedPrecision")
val evaluator3 = evaluator.setMetricName("weightedRecall")
val evaluator4 = evaluator.setMetricName("f1")

现在按照以下步骤计算测试数据的分类准确度、精确度、召回率、f1 度量和错误:

val accuracy = evaluator1.evaluate(predictions)
val precision = evaluator2.evaluate(predictions)
val recall = evaluator3.evaluate(predictions)
val f1 = evaluator4.evaluate(predictions)

现在让我们打印性能指标:

println("Accuracy = " + accuracy)
println("Precision = " + precision)
println("Recall = " + recall)
println("F1 = " + f1)
println(s"Test Error = ${1 - accuracy}")

您现在应该收到以下结果:

Accuracy = 0.9678714859437751
Precision = 0.9686742518830365
Recall = 0.9678714859437751
F1 = 0.9676697179934564
Test Error = 0.032128514056224855

现在这比之前的好多了,对吧?请注意,由于数据集的随机分割和您的平台,您可能会收到略有不同的结果。

决策树

在本节中,我们将详细讨论决策树算法。还将讨论朴素贝叶斯和决策树的比较分析。决策树通常被认为是一种用于解决分类和回归任务的监督学习技术。决策树简单地说是一种决策支持工具,它使用树状图(或决策模型)及其可能的后果,包括机会事件结果、资源成本和效用。更技术性地说,决策树中的每个分支代表了一个可能的决策、发生或反应,以统计概率的形式。

与朴素贝叶斯相比,决策树是一种更加健壮的分类技术。原因在于决策树首先将特征分为训练集和测试集。然后它产生了一个很好的泛化来推断预测的标签或类。最有趣的是,决策树算法可以处理二元和多类分类问题。

图 8: 使用 Rattle 软件包在入学测试数据集上的一个样本决策树

例如,在前面的示例图中,决策树从入学数据中学习,用一组if...else决策规则来逼近正弦曲线。数据集包含每个申请入学的学生的记录,比如申请美国大学。每条记录包含研究生入学考试成绩、CGPA 成绩和列的排名。现在我们需要根据这三个特征(变量)来预测谁是胜任的。在训练决策树模型并修剪树的不需要的分支后,决策树可以用来解决这种问题。一般来说,树越深,决策规则越复杂,模型拟合得越好。因此,树越深,决策规则越复杂,模型拟合得越好。

如果你想绘制前面的图,只需运行我的 R 脚本,在 RStudio 上执行,并提供入学数据。脚本和数据集可以在我的 GitHub 存储库中找到github.com/rezacsedu/AdmissionUsingDecisionTree

使用决策树的优缺点

在雇佣我之前,你可以从表 3 中了解我的优缺点以及我最擅长的工作时间,这样你就不会有任何迟来的后悔!

代理 优点 缺点 擅长
决策树(DTs) -简单实现、训练和解释-树可以可视化-准备数据很少-模型构建和预测时间少-可以处理数值和分类数据-可以使用统计测试验证模型-对噪声和缺失值很健壮-高准确性 -大型和复杂树的解释很困难-同一子树内可能会出现重复-可能出现对角决策边界问题-DT 学习者可能会创建不能很好泛化数据的过于复杂的树-有时由于数据的微小变化,决策树可能不稳定-学习决策树本身是一个 NP 完全问题-如果某些类占主导地位,DT 学习者会创建有偏见的树 -针对高准确性分类-医学诊断和预后-信用风险分析

表 3: 决策树的优缺点

决策树与朴素贝叶斯

如前表所述,由于其对训练数据的灵活性,决策树非常容易理解和调试。它们可以处理分类问题和回归问题。

如果你想要预测分类值或连续值,决策树都可以处理。因此,如果你只有表格数据,将其提供给决策树,它将构建模型以对数据进行分类,而无需任何额外的前期或手动干预。总之,决策树非常简单实现、训练和解释。准备数据很少,决策树就可以用更少的预测时间构建模型。正如前面所说,它们可以处理数值和分类数据,并且对噪声和缺失值非常健壮。使用统计测试非常容易验证模型。更有趣的是,构建的树可以可视化。总的来说,它们提供了非常高的准确性。

然而,决策树有时倾向于过拟合训练数据的问题。这意味着通常需要修剪树,并找到一个更好的分类或回归准确性的最佳树。此外,同一子树内可能会出现重复。有时它还会在对角决策边界问题上出现问题,导致过拟合和欠拟合。此外,DT 学习者可能会创建不能很好泛化数据的过于复杂的树,这使得整体解释很困难。由于数据的微小变化,决策树可能不稳定,因此学习决策树本身是一个 NP 完全问题。最后,如果某些类占主导地位,DT 学习者会创建有偏见的树。

建议读者参考表 13,以获得朴素贝叶斯和 DT 之间的比较摘要。

另一方面,在使用朴素贝叶斯时有一句话:NB 需要您手动构建分类。无法将大量表格数据输入其中,然后选择最佳的特征进行分类。然而,在这种情况下,选择正确的特征和重要的特征取决于用户,也就是您。另一方面,DT 将从表格数据中选择最佳的特征。鉴于这一事实,您可能需要将朴素贝叶斯与其他统计技术结合起来,以帮助进行最佳特征提取并稍后对其进行分类。或者,使用 DT 以获得更好的精度、召回率和 f1 度量的准确性。朴素贝叶斯的另一个优点是它将作为连续分类器进行回答。然而,缺点是它们更难调试和理解。当训练数据没有良好特征且数据量较小时,朴素贝叶斯表现得相当不错。

总之,如果您试图从这两者中选择更好的分类器,通常最好的方法是测试每个来解决问题。我的建议是使用您拥有的训练数据构建 DT 和朴素贝叶斯分类器,然后使用可用的性能指标比较性能,然后决定哪一个最适合解决您的问题,取决于数据集的性质。

使用 DT 算法构建可扩展分类器

正如您已经看到的,使用 OVTR 分类器,我们观察到 OCR 数据集上性能指标的以下值:

Accuracy = 0.5217246545696688
Precision = 0.488360500637862
Recall = 0.5217246545696688
F1 = 0.4695649096879411
Test Error = 0.47827534543033123

这表明该数据集上模型的准确性非常低。在本节中,我们将看到如何使用 DT 分类器来提高性能。将使用相同的 OCR 数据集展示 Spark 2.1.0 的示例。该示例将包括数据加载、解析、模型训练以及最终的模型评估等多个步骤。

由于我们将使用相同的数据集,为了避免冗余,我们将跳过数据集探索步骤,直接进入示例:

步骤 1. 加载所需的库和包如下:

import org.apache.spark.ml.Pipeline // for Pipeline creation
import org.apache.spark.ml.classification
                         .DecisionTreeClassificationModel 
import org.apache.spark.ml.classification.DecisionTreeClassifier 
import org.apache.spark.ml.evaluation
                         .MulticlassClassificationEvaluator 
import org.apache.spark.ml.feature
                         .{IndexToString, StringIndexer, VectorIndexer} 
import org.apache.spark.sql.SparkSession //For a Spark session

步骤 2. 创建一个活跃的 Spark 会话如下:

val spark = SparkSession
              .builder
              .master("local[*]")
              .config("spark.sql.warehouse.dir", "/home/exp/")
              .appName("DecisionTreeClassifier")
              .getOrCreate()

请注意,这里将主 URL 设置为local[*],这意味着您的计算机的所有核心将用于处理 Spark 作业。您应该根据要求设置 SQL 仓库和其他配置参数。

步骤 3. 创建 DataFrame - 加载以 LIBSVM 格式存储的数据作为 DataFrame 如下:

val data = spark.read.format("libsvm").load("datab
                             /Letterdata_libsvm.data")

对于数字的分类,输入特征向量通常是稀疏的,应该提供稀疏向量作为输入以利用稀疏性。由于训练数据只使用一次,而且数据集的大小相对较小(即几 MB),如果您多次使用 DataFrame,可以将其缓存起来。

步骤 4. 标签索引 - 对标签进行索引,为标签列添加元数据。然后让我们在整个数据集上进行拟合,以包含索引中的所有标签:

val labelIndexer = new StringIndexer()
               .setInputCol("label")
               .setOutputCol("indexedLabel")
               .fit(data)

步骤 5. 识别分类特征 - 以下代码段自动识别分类特征并对其进行索引:

val featureIndexer = new VectorIndexer()
              .setInputCol("features")
              .setOutputCol("indexedFeatures")
              .setMaxCategories(4)
              .fit(data)

对于这种情况,如果特征的数量超过四个不同的值,它们将被视为连续的。

步骤 6. 准备训练和测试集 - 将数据分割为训练集和测试集(25%用于测试):

val Array(trainingData, testData) = data.randomSplit
                                      (Array(0.75, 0.25), 12345L)

步骤 7. 训练 DT 模型如下:

val dt = new DecisionTreeClassifier()
                     .setLabelCol("indexedLabel")
                     .setFeaturesCol("indexedFeatures")

步骤 8. 将索引的标签转换回原始标签如下:

val labelConverter = new IndexToString()
                .setInputCol("prediction")
                .setOutputCol("predictedLabel")
                .setLabels(labelIndexer.labels)

步骤 9. 创建 DT 管道 - 让我们通过更改索引器、标签转换器和树来创建一个 DT 管道:

val pipeline = new Pipeline().setStages(Array(labelIndexer,
                              featureIndexer, dt, labelconverter))

步骤 10. 运行索引器 - 使用转换器训练模型并运行索引器:

val model = pipeline.fit(trainingData)

步骤 11. 计算测试集上的预测 - 使用模型转换器计算预测,最后显示每个标签的预测如下:

val predictions = model.transform(testData)
predictions.show()

图 9: 预测与每个标签(即每个字母)相对应

从上图可以看出,一些标签被准确预测,而另一些则被错误预测。然而,我们知道加权准确性、精确度、召回率和 f1 度量,但我们需要先评估模型。

步骤 12. 评估模型 - 选择预测和真实标签来计算测试错误和分类性能指标,如准确性、精确度、召回率和 f1 度量,如下所示:

val evaluator = new MulticlassClassificationEvaluator()
                             .setLabelCol("label")
                             .setPredictionCol("prediction")    
val evaluator1 = evaluator.setMetricName("accuracy")
val evaluator2 = evaluator.setMetricName("weightedPrecision")
val evaluator3 = evaluator.setMetricName("weightedRecall")
val evaluator4 = evaluator.setMetricName("f1")

步骤 13. 计算性能指标 - 计算测试数据的分类准确性、精确度、召回率、f1 度量和错误,如下所示:

val accuracy = evaluator1.evaluate(predictions)
val precision = evaluator2.evaluate(predictions)
val recall = evaluator3.evaluate(predictions)
val f1 = evaluator4.evaluate(predictions)

步骤 14. 打印性能指标:

println("Accuracy = " + accuracy)
println("Precision = " + precision)
println("Recall = " + recall)
println("F1 = " + f1)
println(s"Test Error = ${1 - accuracy}")

您应该按以下数值观察:

Accuracy = 0.994277821625888
Precision = 0.9904583933020722
Recall = 0.994277821625888
F1 = 0.9919966504321712
Test Error = 0.005722178374112041

现在性能很好,对吧?然而,您仍然可以通过执行超参数调整来提高分类准确性。通过交叉验证和训练集拆分,可以进一步提高预测准确性,选择适当的算法(即分类器或回归器)。

步骤 15. 打印决策树节点:

val treeModel = model.stages(2).asInstanceOf
                                [DecisionTreeClassificationModel]
println("Learned classification tree model:\n" + treeModel
                 .toDebugString)

最后,我们将打印决策树中的一些节点,如下图所示:

图 10: 在模型构建过程中生成的一些决策树节点

总结

在本章中,我们讨论了一些机器学习中的高级算法,并发现了如何使用一种简单而强大的贝叶斯推断方法来构建另一种分类模型,即多项式分类算法。此外,从理论和技术角度广泛讨论了朴素贝叶斯算法。最后,讨论了决策树和朴素贝叶斯算法之间的比较分析,并提供了一些指导方针。

在下一章中,我们将更深入地研究机器学习,并找出如何利用机器学习来对属于无监督观测数据集的记录进行聚类。

第十四章:是时候整理一下了-用 Spark MLlib 对数据进行聚类

"如果你拿一个星系并试图让它变得更大,它就会成为一群星系,而不是一个星系。如果你试图让它变得比那小,它似乎会自己爆炸"

  • Jeremiah P. Ostriker

在本章中,我们将深入研究机器学习,并找出如何利用它来对无监督观测数据集中属于某一组或类的记录进行聚类。简而言之,本章将涵盖以下主题:

  • 无监督学习

  • 聚类技术

  • 层次聚类(HC)

  • 基于质心的聚类(CC)

  • 基于分布的聚类(DC)

  • 确定聚类数量

  • 聚类算法之间的比较分析

  • 在计算集群上提交作业

无监督学习

在本节中,我们将用适当的示例简要介绍无监督机器学习技术。让我们从一个实际例子开始讨论。假设你在硬盘上有一个拥挤而庞大的文件夹里有大量非盗版-完全合法的 mp3。现在,如果你可以建立一个预测模型,帮助自动将相似的歌曲分组并组织到你喜欢的类别中,比如乡村音乐、说唱、摇滚等。这种将项目分配到一个组中的行为,例如将 mp3 添加到相应的播放列表,是一种无监督的方式。在之前的章节中,我们假设你有一个正确标记数据的训练数据集。不幸的是,在现实世界中收集数据时,我们并不总是有这种奢侈。例如,假设我们想将大量音乐分成有趣的播放列表。如果我们没有直接访问它们的元数据,我们如何可能将歌曲分组在一起呢?一种可能的方法可能是混合各种机器学习技术,但聚类通常是解决方案的核心。

简而言之,在无监督机器学习问题中,训练数据集的正确类别不可用或未知。因此,类别必须从结构化或非结构化数据集中推导出来,如图 1所示。这基本上意味着这种算法的目标是以某种结构化的方式预处理数据。换句话说,无监督学习算法的主要目标是探索未标记的输入数据中的未知/隐藏模式。然而,无监督学习也包括其他技术,以探索性的方式解释数据的关键特征,以找到隐藏的模式。为了克服这一挑战,聚类技术被广泛使用,以无监督的方式基于某些相似性度量对未标记的数据点进行分组。

有关无监督算法工作原理的深入理论知识,请参考以下三本书:BousquetO.;von LuxburgU.;RaetschG.,编辑(2004)。机器学习的高级讲座Springer-Verlag。ISBN 978-3540231226。或者Duda*,Richard O.HartPeter E.StorkDavid G。(2001)。无监督学习和聚类模式分类(第 2 版)。Wiley。ISBN 0-471-05669-3 和JordanMichael I.BishopChristopher M。(2004)神经网络。在Allen B. Tucker 计算机科学手册,第二版(第 VII 部分:智能系统)。博卡拉顿,FL:查普曼和霍尔/ CRC 出版社。ISBN 1-58488-360-X。

**图 1:**使用 Spark 进行无监督学习

无监督学习示例

在聚类任务中,算法通过分析输入示例之间的相似性将相关特征分组到类别中,其中相似的特征被聚类并用圆圈标记。聚类的用途包括但不限于以下内容:搜索结果分组,如客户分组,用于发现可疑模式的异常检测,用于在文本中找到有用模式的文本分类,用于找到连贯群体的社交网络分析,用于将相关计算机放在一起的数据中心计算集群,用于基于相似特征识别社区的房地产数据分析。我们将展示一个基于 Spark MLlib 的解决方案,用于最后一种用例。

聚类技术

在本节中,我们将讨论聚类技术以及相关挑战和适当的示例。还将提供对层次聚类、基于质心的聚类和基于分布的聚类的简要概述。

无监督学习和聚类

聚类分析是关于将数据样本或数据点分成相应的同类或簇的过程。因此,聚类的一个简单定义可以被认为是将对象组织成成员在某种方式上相似的组。

因此,是一组对象,它们在彼此之间是相似的,并且与属于其他簇的对象是不相似的。如图 2所示,如果给定一组对象,聚类算法会根据相似性将这些对象放入一组中。例如,K 均值这样的聚类算法已经找到了数据点组的质心。然而,为了使聚类准确和有效,算法评估了每个点与簇的质心之间的距离。最终,聚类的目标是确定一组未标记数据中的内在分组。

图 2: 聚类原始数据

Spark 支持许多聚类算法,如K 均值高斯混合幂迭代聚类PIC),潜在狄利克雷分配LDA),二分 K 均值流式 K 均值。LDA 用于文档分类和文本挖掘中常用的聚类。PIC 用于将具有成对相似性的图的顶点聚类为边属性。然而,为了使本章的目标更清晰和集中,我们将限制我们的讨论在 K 均值,二分 K 均值和高斯混合算法上。

层次聚类

层次聚类技术基于一个基本思想,即对象或特征与附近的对象比与远处的对象更相关。二分 K 均值就是这样一种层次聚类算法的例子,它根据它们的相应距离连接数据对象以形成簇。

在层次聚类技术中,一个簇可以通过连接簇的部分所需的最大距离来简单描述。因此,不同的簇将在不同的距离下形成。从图形上看,这些簇可以使用树状图来表示。有趣的是,常见的名字层次聚类来源于树状图的概念。

基于质心的聚类

在基于质心的聚类技术中,聚类由一个中心向量表示。然而,这个向量本身不一定是数据点的成员。在这种类型的学习中,必须在训练模型之前提供一些可能的聚类。K 均值是这种学习类型的一个非常著名的例子,如果将聚类的数量设置为一个固定的整数 K,K 均值算法提供了一个正式的定义作为一个优化问题,这是一个单独的问题,需要解决以找到 K 个聚类中心并将数据对象分配给最近的聚类中心。简而言之,这是一个优化问题,其目标是最小化聚类的平方距离。

基于分布的聚类

基于分布的聚类算法基于提供更方便的方式将相关数据对象聚类到相同分布的统计分布模型。尽管这些算法的理论基础非常健全,但它们大多数时候会受到过拟合的影响。然而,这种限制可以通过对模型复杂性加以约束来克服。

基于质心的聚类(CC)

在本节中,我们将讨论基于质心的聚类技术及其计算挑战。我们将展示使用 Spark MLlib 的 K 均值的示例,以更好地理解基于质心的聚类。

CC 算法中的挑战

如前所述,在像 K 均值这样的基于质心的聚类算法中,设置聚类数量 K 的最佳值是一个优化问题。这个问题可以被描述为 NP-hard(即非确定性多项式时间难题),具有高算法复杂性,因此常见的方法是尝试只获得一个近似解。因此,解决这些优化问题会带来额外的负担,因此也会带来非平凡的缺点。此外,K 均值算法期望每个聚类的大小大致相似。换句话说,每个聚类中的数据点必须是均匀的,以获得更好的聚类性能。

这个算法的另一个主要缺点是,这个算法试图优化聚类中心,而不是聚类边界,这经常会导致不恰当地切割聚类之间的边界。然而,有时我们可以利用视觉检查的优势,这在超平面或多维数据上通常是不可用的。尽管如此,如何找到 K 的最佳值的完整部分将在本章后面讨论。

K 均值算法是如何工作的?

假设我们有n个数据点x[i]i=1...n,需要被分成k个聚类。现在目标是为每个数据点分配一个聚类。K 均值的目标是找到最小化数据点到聚类的距离的聚类位置μ[i],i=1...k。从数学上讲,K 均值算法试图通过解决以下方程来实现目标,即一个优化问题:

在上述方程中,c[i]是分配给聚类i的数据点集合,*d(x,μ[i]) =||x−μ[i]||²[2]*是要计算的欧几里德距离(我们将很快解释为什么我们应该使用这个距离测量)。因此,我们可以理解,使用 K 均值进行整体聚类操作不是一个平凡的问题,而是一个 NP-hard 的优化问题。这也意味着 K 均值算法不仅试图找到全局最小值,而且经常陷入不同的解决方案。

现在,让我们看看在将数据提供给 K 均值模型之前,我们如何制定算法。首先,我们需要事先决定试探性聚类的数量k。然后,通常情况下,您需要按照以下步骤进行:

这里的*|c|c*中的元素数量。

使用 K-means 算法进行聚类的过程是通过将所有坐标初始化为质心开始的。在算法的每一次迭代中,根据某种距离度量,通常是欧氏距离,将每个点分配给其最近的质心。

**距离计算:**请注意,还有其他计算距离的方法,例如:

切比雪夫距离可用于仅考虑最显著维度的距离测量。

Hamming 距离算法可以识别两个字符串之间的差异。另一方面,为了使距离度量尺度无关,可以使用马哈拉诺比斯距离来规范化协方差矩阵。曼哈顿距离用于仅考虑轴对齐方向的距离。闵可夫斯基距离算法用于计算欧氏距离、曼哈顿距离和切比雪夫距离。Haversine 距离用于测量球面上两点之间的大圆距离,即经度和纬度。

考虑到这些距离测量算法,很明显,欧氏距离算法将是解决 K-means 算法中距离计算目的最合适的方法。然后,质心被更新为该迭代中分配给它的所有点的中心。这一过程重复,直到中心发生最小变化。简而言之,K-means 算法是一个迭代算法,分为两个步骤:

  • 聚类分配步骤:K-means 遍历数据集中的每个 m 个数据点,将其分配给由 k 个质心中最接近的质心表示的聚类。对于每个点,然后计算到每个质心的距离,简单地选择最近的一个。

  • 更新步骤:对于每个聚类,计算所有点的平均值作为新的聚类中心。从上一步,我们有一组分配给一个聚类的点。现在,对于每个这样的集合,我们计算一个平均值,宣布它为聚类的新中心。

使用 Spark MLlib 的 K-means 聚类的示例

为了进一步演示聚类示例,我们将使用从course1.winona.edu/bdeppa/Stat%20425/Datasets.html下载的Saratoga NY Homes数据集作为使用 Spark MLlib 的无监督学习技术。该数据集包含纽约市郊区房屋的几个特征。例如,价格、地块大小、水景、年龄、土地价值、新建、中央空调、燃料类型、供暖类型、下水道类型、居住面积、大学百分比、卧室、壁炉、浴室和房间数。然而,以下表格中只显示了一些特征:

价格 地块大小 水景 年龄 土地价值 房间数
132,500 0.09 0 42 5,000 5
181,115 0.92 0 0 22,300 6
109,000 0.19 0 133 7,300 8
155,000 0.41 0 13 18,700 5
86,060 0.11 0 0 15,000 3
120,000 0.68 0 31 14,000 8
153,000 0.4 0 33 23,300 8
170,000 1.21 0 23 146,000 9
90,000 0.83 0 36 222,000 8
122,900 1.94 0 4 212,000 6
325,000 2.29 0 123 126,000 12

**表 1:**Saratoga NY Homes 数据集的样本数据

这里的聚类技术的目标是基于城市中每栋房屋的特征进行探索性分析,以找到可能的邻近区域。在执行特征提取之前,我们需要加载和解析 Saratoga NY Homes 数据集。这一步还包括加载包和相关依赖项,将数据集读取为 RDD,模型训练,预测,收集本地解析数据以及聚类比较。

步骤 1。导入相关的包:

package com.chapter13.Clustering
import org.apache.spark.{SparkConf, SparkContext}
import org.apache.spark.mllib.clustering.{KMeans, KMeansModel}
import org.apache.spark.mllib.linalg.Vectors
import org.apache.spark._
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.apache.spark.sql._
import org.apache.spark.sql.SQLContext

步骤 2.创建一个 Spark 会话 - 入口点 - 在这里,我们首先通过设置应用程序名称和主 URL 来设置 Spark 配置。为了简单起见,它是独立的,使用您机器上的所有核心:

val spark = SparkSession
                 .builder
                 .master("local[*]")
                 .config("spark.sql.warehouse.dir", "E:/Exp/")
                 .appName("KMeans")
                 .getOrCreate()

步骤 3.加载和解析数据集 - 从数据集中读取、解析和创建 RDD 如下:

//Start parsing the dataset
val start = System.currentTimeMillis()
val dataPath = "data/Saratoga NY Homes.txt"
//val dataPath = args(0)
val landDF = parseRDD(spark.sparkContext.textFile(dataPath))
                                 .map(parseLand).toDF().cache()
landDF.show()

请注意,为了使前面的代码起作用,您应该导入以下包:

import spark.sqlContext.implicits._

您将得到以下输出:

图 3: Saratoga NY Homes 数据集的快照

以下是parseLand方法,用于从Double数组创建Land类如下:

// function to create a  Land class from an Array of Double
def parseLand(line: Array[Double]): Land = {
  Land(line(0), line(1), line(2), line(3), line(4), line(5),
   line(6), line(7), line(8), line(9), line(10),
   line(11), line(12), line(13), line(14), line(15)
  )
}

读取所有特征作为双精度的Land类如下:

case class Land(
  Price: Double, LotSize: Double, Waterfront: Double, Age: Double,
  LandValue: Double, NewConstruct: Double, CentralAir: Double, 
  FuelType: Double, HeatType: Double, SewerType: Double, 
  LivingArea: Double, PctCollege: Double, Bedrooms: Double,
  Fireplaces: Double, Bathrooms: Double, rooms: Double
)

正如您已经知道的,为了训练 K 均值模型,我们需要确保所有数据点和特征都是数字。因此,我们进一步需要将所有数据点转换为双精度,如下所示:

// method to transform an RDD of Strings into an RDD of Double
def parseRDD(rdd: RDD[String]): RDD[Array[Double]] = {
  rdd.map(_.split(",")).map(_.map(_.toDouble))
}

步骤 4.准备训练集 - 首先,我们需要将数据框(即landDF)转换为双精度的 RDD,并缓存数据以创建一个新的数据框来链接集群编号如下:

val rowsRDD = landDF.rdd.map(r => (
  r.getDouble(0), r.getDouble(1), r.getDouble(2),
  r.getDouble(3), r.getDouble(4), r.getDouble(5),
  r.getDouble(6), r.getDouble(7), r.getDouble(8),
  r.getDouble(9), r.getDouble(10), r.getDouble(11),
  r.getDouble(12), r.getDouble(13), r.getDouble(14),
  r.getDouble(15))
)
rowsRDD.cache()

现在我们需要将前面的双精度 RDD 转换为密集向量的 RDD 如下:

// Get the prediction from the model with the ID so we can
   link them back to other information
val predictions = rowsRDD.map{r => (
  r._1, model.predict(Vectors.dense(
    r._2, r._3, r._4, r._5, r._6, r._7, r._8, r._9,
    r._10, r._11, r._12, r._13, r._14, r._15, r._16
  )
))}

步骤 5.训练 K 均值模型 - 通过指定 10 个集群、20 次迭代和 10 次运行来训练模型如下:

val numClusters = 5
val numIterations = 20
val run = 10
val model = KMeans.train(numericHome, numClusters,numIterations, run,
                         KMeans.K_MEANS_PARALLEL)

基于 Spark 的 K 均值实现通过使用K-means++算法初始化一组集群中心开始工作,这是由Bahmani 等人在 2012 年的*可扩展 K-Means++*中提出的一种 K 均值++的变体。这是一种试图通过从随机中心开始,然后进行更多中心的选择的传递,选择概率与它们到当前集群集的平方距离成比例的方法来找到不同的集群中心。它导致对最佳聚类的可证近似。原始论文可以在theory.stanford.edu/~sergei/papers/vldb12-kmpar.pdf找到。

步骤 6.评估模型的错误率 - 标准的 K 均值算法旨在最小化每个集合点之间的距离的平方和,即平方欧几里得距离,这是 WSSSE 的目标。K 均值算法旨在最小化每个集合点(即集群中心)之间的距离的平方和。然而,如果您真的想要最小化每个集合点之间的距离的平方和,您最终会得到一个模型,其中每个集群都是其自己的集群中心;在这种情况下,该度量将为 0。

因此,一旦您通过指定参数训练了模型,就可以使用平方误差和WSSE)来评估结果。从技术上讲,它类似于可以计算如下的每个 K 簇中每个观察的距离之和:

// Evaluate clustering by computing Within Set Sum of Squared Errors
val WCSSS = model.computeCost(landRDD)
println("Within-Cluster Sum of Squares = " + WCSSS)

前面的模型训练集产生了 WCSSS 的值:

Within-Cluster Sum of Squares = 1.455560123603583E12 

步骤 7.计算并打印集群中心 - 首先,我们从模型中获取带有 ID 的预测,以便我们可以将它们与与每栋房子相关的其他信息联系起来。请注意,我们将使用在步骤 4 中准备的行的 RDD*:*

// Get the prediction from the model with the ID so we can link them
   back to other information
val predictions = rowsRDD.map{r => (
  r._1, model.predict(Vectors.dense(
    r._2, r._3, r._4, r._5, r._6, r._7, r._8, r._9, r._10,
    r._11, r._12, r._13, r._14, r._15, r._16
  )
))}

然而,当需要关于价格的预测时,应该提供如下:

val predictions = rowsRDD.map{r => (
  r._1, model.predict(Vectors.dense(
    r._1, r._2, r._3, r._4, r._5, r._6, r._7, r._8, r._9, r._10,
    r._11, r._12, r._13, r._14, r._15, r._16
  )
))}

为了更好地可见性和探索性分析,将 RDD 转换为数据框如下:

import spark.sqlContext.implicits._val predCluster = predictions.toDF("Price", "CLUSTER")
predCluster.show()

这应该产生以下图中显示的输出:

图 4: 预测集群的快照

由于数据集中没有可区分的 ID,我们表示Price字段以进行链接。从前面的图中,您可以了解到具有特定价格的房屋属于哪个集群。现在为了更好地可见性,让我们将预测数据框与原始数据框连接起来,以了解每栋房子的个体集群编号:

val newDF = landDF.join(predCluster, "Price") 
newDF.show()

您应该观察以下图中的输出:

图 5: 预测每个房屋所在集群的快照

为了进行分析,我们将输出转储到 RStudio 中,并生成图 6中显示的集群。R 脚本可以在我的 GitHub 存储库github.com/rezacsedu/ScalaAndSparkForBigDataAnalytics上找到。或者,您可以编写自己的脚本,并相应地进行可视化。

图 6: 社区的集群

现在,为了进行更广泛的分析和可见性,我们可以观察每个集群的相关统计数据。例如,下面我分别打印了图 8图 9中与集群 3 和 4 相关的统计数据:

newDF.filter("CLUSTER = 0").show() 
newDF.filter("CLUSTER = 1").show()
newDF.filter("CLUSTER = 2").show()
newDF.filter("CLUSTER = 3").show()
newDF.filter("CLUSTER = 4").show()

现在获取每个集群的描述性统计数据如下:

newDF.filter("CLUSTER = 0").describe().show()
newDF.filter("CLUSTER = 1").describe().show()
newDF.filter("CLUSTER = 2").describe().show()
newDF.filter("CLUSTER = 3").describe().show() 
newDF.filter("CLUSTER = 4").describe().show()

首先,让我们观察以下图中集群 3 的相关统计数据:

图 7: 集群 3 的统计数据

现在让我们观察以下图中集群 4 的相关统计数据:

图 8: 集群 4 的统计数据

请注意,由于原始截图太大,无法放入此页面,因此对原始图像进行了修改,并删除了包含房屋其他变量的列。

由于该算法的随机性质,每次成功迭代可能会得到不同的结果。但是,您可以通过以下方式设置种子来锁定该算法的随机性质:

val numClusters = 5 
val numIterations = 20 
val seed = 12345 
val model = KMeans.train(landRDD, numClusters, numIterations, seed)

步骤 8. 停止 Spark 会话 - 最后,使用 stop 方法停止 Spark 会话:

spark.stop()

在前面的例子中,我们处理了一组非常小的特征;常识和视觉检查也会导致相同的结论。从上面使用 K 均值算法的例子中,我们可以理解这个算法的一些局限性。例如,很难预测 K 值,并且全局集群效果不佳。此外,不同的初始分区可能导致不同的最终集群,最后,它在不同大小和密度的集群中效果不佳。

为了克服这些局限性,本书中还有一些更健壮的算法,如 MCMC(马尔可夫链蒙特卡洛;也可参见en.wikipedia.org/wiki/Markov_chain_Monte_Carlo)在书中介绍:Tribble, Seth D., Markov chain Monte Carlo algorithms using completely uniformly distributed driving sequences, Diss. Stanford University, 2007.

层次聚类(HC)

在本节中,我们将讨论层次聚类技术及其计算挑战。还将展示使用 Spark MLlib 的层次聚类的双分 K 均值算法的示例,以更好地理解层次聚类。

HC 算法概述和挑战

层次聚类技术在计算上与基于质心的聚类有所不同,距离的计算方式也不同。这是最受欢迎和广泛使用的聚类分析技术之一,旨在构建一个集群的层次结构。由于一个集群通常包含多个对象,也会有其他候选对象来计算距离。因此,除了通常选择的距离函数之外,您还需要决定要使用的链接标准。简而言之,层次聚类有两种策略:

  • 自底向上方法:在这种方法中,每个观察开始在自己的集群中。之后,将集群对合并在一起,然后向上移动层次结构。

  • 自顶向下方法:在这种方法中,所有观察开始在一个集群中,递归地进行分裂,然后向下移动层次结构。

这些自底向上或自顶向下的方法基于单链接聚类SLINK)技术,考虑最小对象距离,完全链接聚类CLINK),考虑对象距离的最大值,以及无权重对组平均法UPGMA)。后者也被称为平均链接聚类。从技术上讲,这些方法不会从数据集中产生唯一的分区(即不同的簇)。

关于这三种方法的比较分析可以在nlp.stanford.edu/IR-book/completelink.html找到。

然而,用户仍然需要从层次结构中选择适当的簇以获得更好的簇预测和分配。尽管像二分 K-means 这类的算法在计算上比 K-means 算法更快,但这种类型的算法有三个缺点:

  • 首先,这些方法对异常值或包含噪声或缺失值的数据集不够稳健。这个缺点要么会导致额外的簇,要么甚至会导致其他簇合并。这个问题通常被称为链接现象,特别是对于单链接聚类。

  • 其次,从算法分析来看,凝聚式聚类和分裂式聚类的复杂性分别为 O(n³)和 O(n²),这使它们在处理大型数据集时过于缓慢。

  • 第三,SLINK 和 CLINK 以前被广泛用于数据挖掘任务,作为聚类分析的理论基础,但现在被认为是过时的。

使用 Spark MLlib 的二分 K-means

二分 K-means 通常比常规 K-means 快得多,但通常会产生不同的聚类。二分 K-means 算法基于SteinbachKarypisKumar的论文《文档聚类技术的比较》,并进行了修改以适应 Spark MLlib。

二分 K-means 是一种分裂算法,从包含所有数据点的单个簇开始。然后,它迭代地在底层找到所有可分的簇,并使用 K-means 将它们中的每一个二分,直到总共有 K 个叶簇或没有可分的叶簇。之后,将同一级别的簇分组在一起以增加并行性。换句话说,二分 K-means 在计算上比常规 K-means 算法更快。请注意,如果在底层二分所有可分的簇导致超过 K 个叶簇,较大的簇将始终优先考虑。

请注意,如果在底层二分所有可分的簇导致超过 K 个叶簇,较大的簇将始终优先考虑。Spark MLlib 实现中使用以下参数:

  • K:这是期望的叶簇的数量。然而,如果在计算过程中没有可分的叶簇,则实际数量可能会更小。默认值为 4。

  • MaxIterations:这是将簇分裂的 K-means 迭代的最大次数。默认值为 20。

  • MinDivisibleClusterSize:这是点的最小数量。默认值设置为 1。

  • Seed:这是一个随机种子,禁止随机聚类,并尝试在每次迭代中提供几乎相似的结果。但建议使用长种子值,如 12345 等。

使用 Spark MLlib 对邻域进行二分 K-means 聚类

在前一节中,我们看到如何将相似的房屋聚类在一起以确定邻域。二分 K-means 与常规 K-means 类似,只是模型训练采用不同的训练参数,如下所示:

// Cluster the data into two classes using KMeans 
val bkm = new BisectingKMeans() 
                 .setK(5) // Number of clusters of the similar houses
                 .setMaxIterations(20)// Number of max iteration
                 .setSeed(12345) // Setting seed to disallow randomness 
val model = bkm.run(landRDD)

您应该参考前面的示例,只需重复前面的步骤以获得训练数据。现在让我们通过计算 WSSSE 来评估聚类,如下所示:

val WCSSS = model.computeCost(landRDD)
println("Within-Cluster Sum of Squares = " + WCSSS) // Less is better    

您应该观察到以下输出:Within-Cluster Sum of Squares = 2.096980212594632E11。现在,要进行更多分析,请参考上一节的第 5 步。

基于分布的聚类(DC)

在本节中,我们将讨论基于分布的聚类技术及其计算挑战。将展示使用 Spark MLlib 进行高斯混合模型GMMs)的示例,以更好地理解基于分布的聚类。

DC 算法中的挑战

像 GMM 这样的基于分布的聚类算法是一种期望最大化算法。为了避免过拟合问题,GMM 通常使用固定数量的高斯分布对数据集进行建模。分布是随机初始化的,并且相关参数也经过迭代优化,以更好地适应训练数据集。这是 GMM 最健壮的特性,并有助于模型收敛到局部最优解。然而,多次运行该算法可能会产生不同的结果。

换句话说,与二分 K 均值算法和软聚类不同,GMM 针对硬聚类进行了优化,为了获得这种类型,对象通常被分配到高斯分布中。GMM 的另一个有利特性是,它通过捕获数据点和属性之间的所有必要相关性和依赖关系来产生复杂的聚类模型。

不利的一面是,GMM 对数据的格式和形状有一些假设,这给我们(即用户)增加了额外的负担。更具体地说,如果以下两个标准不满足,性能会急剧下降:

  • 非高斯数据集:GMM 算法假设数据集具有潜在的高斯生成分布。然而,许多实际数据集不满足这一假设,这会导致低聚类性能。

  • 如果簇的大小不均匀,小簇被大簇主导的可能性很高。

高斯混合模型是如何工作的?

使用 GMM 是一种流行的软聚类技术。GMM 试图将所有数据点建模为有限数量的高斯分布的混合物;计算每个点属于每个簇的概率以及与簇相关的统计数据,并表示一个混合分布:其中所有点都来自于K个高斯子分布之一,具有自己的概率。简而言之,GMM 的功能可以用三步伪代码来描述:

  1. **目标函数:**使用期望-最大化(EM)作为框架计算和最大化对数似然

  2. EM 算法:

  • **E 步:**计算成员的后验概率-即更接近的数据点

  • M 步:优化参数。

  1. 分配:在步骤 E 期间执行软分配。

从技术上讲,当给定一个统计模型时,该模型的参数(即应用于数据集时)是使用最大似然估计MLE)来估计的。另一方面,EM算法是一种找到最大似然的迭代过程。

由于 GMM 是一种无监督算法,GMM 模型取决于推断变量。然后 EM 迭代旋转以执行期望(E)和最大化(M)步骤。

Spark MLlib 实现使用期望最大化算法从给定的一组数据点中诱导最大似然模型。当前的实现使用以下参数:

  • K是要对数据点进行聚类的期望簇的数量

  • ConvergenceTol是我们认为达到收敛的对数似然的最大变化。

  • MaxIterations是在达到收敛点之前执行的最大迭代次数。

  • InitialModel是一个可选的起始点,从中开始 EM 算法。如果省略此参数,将从数据构造一个随机起始点。

使用 Spark MLlib 进行 GMM 聚类的示例

在前面的部分中,我们看到了如何将相似的房屋聚集在一起以确定邻域。使用 GMM,也可以将房屋聚类以找到邻域,除了模型训练,还需要不同的训练参数,如下所示:

val K = 5 
val maxIteration = 20 
val model = new GaussianMixture()
                .setK(K)// Number of desired clusters
                .setMaxIterations(maxIteration)//Maximum iterations
                .setConvergenceTol(0.05) // Convergence tolerance. 
                .setSeed(12345) // setting seed to disallow randomness
                .run(landRDD) // fit the model using the training set

您应该参考前面的示例,只需重复获取训练数据的先前步骤。现在,为了评估模型的性能,GMM 不提供任何性能指标,如 WCSS 作为成本函数。然而,GMM 提供一些性能指标,如 mu、sigma 和 weight。这些参数表示了不同簇(在我们的情况下为五个簇)之间的最大似然。这可以如下所示:

// output parameters of max-likelihood model
for (i <- 0 until model.K) {
  println("Cluster " + i)
  println("Weight=%f\nMU=%s\nSigma=\n%s\n" format(model.weights(i),   
           model.gaussians(i).mu, model.gaussians(i).sigma))
}

您应该观察以下输出:

图 9:聚类 1 图 10:聚类 2 图 11:聚类 3 图 12:聚类 4 图 13:聚类 5

簇 1 到 4 的权重表明,这些簇是同质的,并且与簇 5 相比显著不同。

确定聚类的数量

像 K 均值算法这样的聚类算法的美妙之处在于它可以对具有无限特征的数据进行聚类。当您有原始数据并想了解数据中的模式时,它是一个很好的工具。然而,在进行实验之前决定聚类的数量可能不成功,有时可能会导致过度拟合或拟合不足的问题。另一方面,所有三种算法(即 K 均值、二分 K 均值和高斯混合)的一个共同点是,聚类的数量必须事先确定并作为参数提供给算法。因此,非正式地说,确定聚类的数量是一个要解决的单独优化问题。

在本节中,我们将使用基于 Elbow 方法的一种启发式方法。我们从 K = 2 个簇开始,然后通过增加 K 并观察成本函数簇内平方和WCSS)的值来运行相同数据集的 K 均值算法。在某个时候,可以观察到成本函数的大幅下降,但随着 K 值的增加,改进变得微不足道。正如聚类分析文献中建议的那样,我们可以选择 WCSS 的最后一个大幅下降后的 K 作为最佳值。

通过分析以下参数,您可以找出 K 均值的性能:

  • **内部性:**这是平方和之间也称为簇内相似度

  • **内部性:**这是平方和之间也称为簇内相似度

  • **Totwithinss:**这是所有簇的内部性之和,也称为总簇内相似度

需要注意的是,一个健壮而准确的聚类模型将具有较低的内部性值和较高的内部性值。然而,这些值取决于聚类的数量,即在构建模型之前选择的 K。

现在让我们讨论如何利用 Elbow 方法来确定聚类的数量。如下所示,我们计算了作为 K 均值算法应用于所有特征的家庭数据的聚类数量的成本函数 WCSS。可以观察到当 K = 5 时有一个大幅下降。因此,我们选择了 5 个簇的数量,如图 10所示。基本上,这是最后一个大幅下降后的一个。

图 14:作为 WCSS 函数的聚类数量

聚类算法之间的比较分析

高斯混合主要用于期望最小化,这是优化算法的一个例子。比普通 K-means 更快的 bisecting K-means 也会产生略有不同的聚类结果。下面我们尝试比较这三种算法。我们将展示模型构建时间和每种算法的计算成本的性能比较。如下所示,我们可以计算 WCSS 的成本。以下代码行可用于计算 K-means 和bisecting 算法的 WCSS:

val WCSSS = model.computeCost(landRDD) // land RDD is the training set 
println("Within-Cluster Sum of Squares = " + WCSSS) // Less is better 

在本章中我们使用的数据集,我们得到了以下 WCSS 的值:

Within-Cluster Sum of Squares of Bisecting K-means = 2.096980212594632E11 
Within-Cluster Sum of Squares of K-means = 1.455560123603583E12

这意味着 K-means 在计算成本方面表现略好一些。不幸的是,我们没有 GMM 算法的 WCSS 等指标。现在让我们观察这三种算法的模型构建时间。我们可以在开始模型训练之前启动系统时钟,并在训练结束后立即停止,如下所示(对于 K-means):

val start = System.currentTimeMillis() 
val numClusters = 5 
val numIterations = 20  
val seed = 12345 
val runs = 50 
val model = KMeans.train(landRDD, numClusters, numIterations, seed) 
val end = System.currentTimeMillis()
println("Model building and prediction time: "+ {end - start} + "ms")

在本章中我们使用的训练集,我们得到了以下模型构建时间的值:

Model building and prediction time for Bisecting K-means: 2680ms 
Model building and prediction time for Gaussian Mixture: 2193ms 
Model building and prediction time for K-means: 3741ms

在不同的研究文章中,已经发现,bisecting K-means 算法已经被证明可以更好地为数据点分配聚类。此外,与 K-means 相比,bisecting K-means 也更快地收敛到全局最小值。另一方面,K-means 会陷入局部最小值。换句话说,使用 bisecting K-means 算法,我们可以避免 K-means 可能遭受的局部最小值。

请注意,根据您的机器硬件配置和数据集的随机性质,您可能会观察到前述参数的不同值。

更详细的分析留给读者从理论角度来看。有兴趣的读者还应参考spark.apache.org/docs/latest/mllib-clustering.html中基于 Spark MLlib 的聚类技术,以获得更多见解。

提交 Spark 集群分析作业

本章中展示的示例可以扩展到更大的数据集,以满足不同的目的。您可以将所有三种聚类算法与所有必需的依赖项打包,并将它们作为 Spark 作业提交到集群。现在使用以下代码行提交您的 K-means 聚类的 Spark 作业,例如(对其他类使用类似的语法),用于 Saratoga NY Homes 数据集:

# Run application as standalone mode on 8 cores 
SPARK_HOME/bin/spark-submit \   
--class org.apache.spark.examples.KMeansDemo \   
--master local[8] \   
KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar \   
Saratoga_NY_Homes.txt

# Run on a YARN cluster 
export HADOOP_CONF_DIR=XXX 
SPARK_HOME/bin/spark-submit \   
--class org.apache.spark.examples.KMeansDemo \   
--master yarn \   
--deploy-mode cluster \  # can be client for client mode   
--executor-memory 20G \   
--num-executors 50 \   
KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar \   
Saratoga_NY_Homes.txt

# Run on a Mesos cluster in cluster deploy mode with supervising 
SPARK_HOME/bin/spark-submit \  
--class org.apache.spark.examples.KMeansDemo \  
--master mesos://207.184.161.138:7077 \ # Use your IP aadress   
--deploy-mode cluster \   
--supervise \   
--executor-memory 20G \   
--total-executor-cores 100 \   
KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar \   
Saratoga_NY_Homes.txt

总结

在本章中,我们更深入地研究了机器学习,并发现了如何利用机器学习来对无监督观测数据集中的记录进行聚类。因此,您学会了在可用数据上快速而有效地应用监督和无监督技术,以解决新问题的实际知识,这些知识是基于前几章的理解的一些广泛使用的示例。我们所说的示例将从 Spark 的角度进行演示。对于 K-means、bisecting K-means 和高斯混合算法中的任何一个,不能保证如果多次运行算法将产生相同的聚类。例如,我们观察到使用相同参数多次运行 K-means 算法会在每次运行时生成略有不同的结果。

有关 K-means 和高斯混合的性能比较,请参阅Jung. et. al 和聚类分析讲义。除了 K-means、bisecting K-means 和高斯混合外,MLlib 还提供了另外三种聚类算法的实现,即 PIC、LDA 和流式 K-means。还值得一提的是,为了对聚类分析进行微调,通常需要删除不需要的数据对象,称为离群值或异常值。但是使用基于距离的聚类很难识别这样的数据点。因此,除了欧几里得距离之外,还可以使用其他距离度量。然而,这些链接将是开始的好资源:

  1. mapr.com/ebooks/spark/08-unsupervised-anomaly-detection-apache-spark.html

  2. github.com/keiraqz/anomaly-detection

  3. www.dcc.fc.up.pt/~ltorgo/Papers/ODCM.pdf

在下一章中,我们将更深入地挖掘调优 Spark 应用程序以获得更好性能的方法。我们将看到一些优化 Spark 应用程序性能的最佳实践。

第十五章:使用 Spark ML 进行文本分析

“程序必须为人们阅读而编写,只是偶然为机器执行。”

  • Harold Abelson

在本章中,我们将讨论使用 Spark ML 进行文本分析的精彩领域。文本分析是机器学习中的一个广泛领域,在许多用例中非常有用,如情感分析、聊天机器人、电子邮件垃圾邮件检测和自然语言处理。我们将学习如何使用 Spark 进行文本分析,重点关注使用包含 1 万个 Twitter 数据样本的文本分类用例。

简而言之,本章将涵盖以下主题:

  • 理解文本分析

  • 转换器和估计器

  • 标记器

  • StopWordsRemover

  • N-Grams

  • TF-IDF

  • Word2Vec

  • CountVectorizer

  • 使用 LDA 进行主题建模

  • 实施文本分类

理解文本分析

在过去的几章中,我们已经探索了机器学习的世界和 Apache Spark 对机器学习的支持。正如我们讨论的那样,机器学习有一个工作流程,下面解释了以下步骤:

  1. 加载或摄取数据。

  2. 清洗数据。

  3. 从数据中提取特征。

  4. 在数据上训练模型,以生成基于特征的期望结果。

  5. 根据数据评估或预测某种结果。

典型管道的简化视图如下图所示:

因此,在模型训练之前和随后部署之前,可能存在多个数据转换阶段。此外,我们应该期望特征和模型属性的改进。我们甚至可以探索完全不同的算法,重复整个任务序列作为新工作流的一部分。

可以使用多个转换步骤创建一个管道,并且为此目的,我们使用特定领域的语言(DSL)来定义节点(数据转换步骤)以创建节点的有向无环图(DAG)。因此,ML 管道是一系列转换器和估计器,用于将管道模型拟合到输入数据集。管道中的每个阶段称为管道阶段,列举如下:

  • 估计器

  • 模型

  • 管道

  • 变压器

  • 预测器

当你看一行文本时,我们看到句子、短语、单词、名词、动词、标点等等,这些放在一起时有意义和目的。人类非常擅长理解句子、单词和俚语,以及注释或上下文。这来自多年的练习和学习如何阅读/写作、正确的语法、标点、感叹号等等。那么,我们如何编写计算机程序来尝试复制这种能力呢?

文本分析

文本分析是从一系列文本中解锁含义的方法。通过使用各种技术和算法来处理和分析文本数据,我们可以发现数据中的模式和主题。所有这些的目标是理解非结构化文本,以便推导出上下文的含义和关系。

文本分析利用了几种广泛的技术类别,接下来我们将介绍。

情感分析

分析人们在 Facebook、Twitter 和其他社交媒体上的政治观点是情感分析的一个很好的例子。同样,分析 Yelp 上餐厅的评论也是情感分析的另一个很好的例子。

自然语言处理(NLP)框架和库,如 OpenNLP 和 Stanford NLP,通常用于实现情感分析。

主题建模

主题建模是一种用于检测语料库中主题或主题的有用技术。这是一种无监督算法,可以在一组文档中找到主题。一个例子是检测新闻文章中涵盖的主题。另一个例子是检测专利申请中的想法。

潜在狄利克雷分配(LDA)是使用无监督算法的流行聚类模型,而潜在语义分析(LSA)使用共现数据的概率模型。

TF-IDF(词项频率 - 逆文档频率)

TF-IDF 衡量单词在文档中出现的频率以及在文档集中的相对频率。这些信息可以用于构建分类器和预测模型。例如垃圾邮件分类、聊天对话等。

命名实体识别(NER)

命名实体识别检测句子中单词和名词的使用,以提取有关个人、组织、位置等信息。这提供了有关文档实际内容的重要上下文信息,而不仅仅将单词视为主要实体。

斯坦福 NLP 和 OpenNLP 都实现了 NER 算法。

事件提取

事件提取扩展了 NER,建立了围绕检测到的实体的关系。这可以用于推断两个实体之间的关系。因此,还有一个额外的语义理解层来理解文档内容。

变压器和估计器

变压器是一个函数对象,通过将变换逻辑(函数)应用于输入数据集,将一个数据集转换为另一个数据集。有两种类型的变压器,标准变压器和估计器变压器。

标准变压器

标准变压器将输入数据集显式地转换为输出数据集,应用变换函数到输入数据上。除了读取输入列并生成输出列之外,不依赖于输入数据。

这些变压器如下所示被调用:

*outputDF = transfomer.*transform*(inputDF)*

标准变压器的示例如下,并将在后续部分详细解释:

  • Tokenizer:这使用空格作为分隔符将句子分割成单词

  • RegexTokenizer:这使用正则表达式将句子分割成单词

  • StopWordsRemover:这从单词列表中移除常用的停用词

  • Binarizer:这将字符串转换为二进制数字 0/1

  • NGram:这从句子中创建 N 个词组

  • HashingTF:这使用哈希表创建词项频率计数以索引单词

  • SQLTransformer:这实现了由 SQL 语句定义的转换

  • VectorAssembler:这将给定的列列表合并成一个单独的向量列

标准变压器的图示如下,其中来自输入数据集的输入列被转换为生成输出数据集的输出列:

估计器变压器

估计器变压器通过首先基于输入数据集生成一个变压器,然后变压器处理输入数据,读取输入列并在输出数据集中生成输出列来将输入数据集转换为输出数据集。

这些变压器如下所示被调用:

*transformer = estimator.*fit*(inputDF)* *outputDF = transformer.*transform*(inputDF)*

估计器变压器的示例如下:

  • IDF

  • LDA

  • Word2Vec

估计器变压器的图示如下,其中来自输入数据集的输入列被转换为生成输出数据集的输出列:

在接下来的几节中,我们将深入研究使用一个简单示例数据集进行文本分析,该数据集由文本行(句子)组成,如下截图所示:

即将出现的代码用于将文本数据加载到输入数据集中。

使用下面显示的 ID 和文本对序列初始化一个名为 lines 的句子序列。

val lines = Seq(
 | (1, "Hello there, how do you like the book so far?"),
 | (2, "I am new to Machine Learning"),
 | (3, "Maybe i should get some coffee before starting"),
 | (4, "Coffee is best when you drink it hot"),
 | (5, "Book stores have coffee too so i should go to a book store")
 | )
lines: Seq[(Int, String)] = List((1,Hello there, how do you like the book so far?), (2,I am new to Machine Learning), (3,Maybe i should get some coffee before starting), (4,Coffee is best when you drink it hot), (5,Book stores have coffee too so i should go to a book store))

接下来,调用createDataFrame()函数从我们之前看到的句子序列创建一个 DataFrame。

scala> val sentenceDF = spark.createDataFrame(lines).toDF("id", "sentence")
sentenceDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string]

现在您可以看到新创建的数据集,其中显示了包含两列 ID 和句子的句子 DataFrame。

scala> sentenceDF.show(false)
|id|sentence |
|1 |Hello there, how do you like the book so far? |
|2 |I am new to Machine Learning |
|3 |Maybe i should get some coffee before starting |
|4 |Coffee is best when you drink it hot |
|5 |Book stores have coffee too so i should go to a book store|

标记化

Tokenizer将输入字符串转换为小写,然后使用空格将字符串分割为单独的标记。给定的句子被分割成单词,可以使用默认的空格分隔符,也可以使用基于正则表达式的分词器。在任何情况下,输入列都会被转换为输出列。特别是,输入列通常是一个字符串,输出列是一个单词序列。

通过导入下面显示的两个包,可以使用分词器:TokenizerRegexTokenize

import org.apache.spark.ml.feature.Tokenizer
import org.apache.spark.ml.feature.RegexTokenizer

首先,您需要初始化一个Tokenizer,指定输入列和输出列:

scala> val tokenizer = new Tokenizer().setInputCol("sentence").setOutputCol("words")
tokenizer: org.apache.spark.ml.feature.Tokenizer = tok_942c8332b9d8

接下来,在输入数据集上调用transform()函数会产生一个输出数据集:

scala> val wordsDF = tokenizer.transform(sentenceDF)
wordsDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 1 more field]

以下是输出数据集,显示了输入列 ID、句子和包含单词序列的输出列 words:

scala> wordsDF.show(false)
|id|sentence |words |
|1 |Hello there, how do you like the book so far? |[hello, there,, how, do, you, like, the, book, so, far?] |
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|

另一方面,如果您想要设置基于正则表达式的Tokenizer,您需要使用RegexTokenizer而不是Tokenizer。为此,您需要初始化一个RegexTokenizer,指定输入列和输出列,以及要使用的正则表达式模式:

scala> val regexTokenizer = new RegexTokenizer().setInputCol("sentence").setOutputCol("regexWords").setPattern("\\W")
regexTokenizer: org.apache.spark.ml.feature.RegexTokenizer = regexTok_15045df8ce41

接下来,在输入数据集上调用transform()函数会产生一个输出数据集:

scala> val regexWordsDF = regexTokenizer.transform(sentenceDF)
regexWordsDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 1 more field]

以下是输出数据集,显示了输入列 ID、句子和包含单词序列的输出列regexWordsDF

scala> regexWordsDF.show(false)
|id|sentence |regexWords |
|1 |Hello there, how do you like the book so far? |[hello, there, how, do, you, like, the, book, so, far] |
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|

Tokenizer的图示如下,其中来自输入文本的句子使用空格分隔成单词:

StopWordsRemover

StopWordsRemover是一个转换器,它接受一个String数组的单词,并在删除所有定义的停用词后返回一个String数组。一些停用词的例子是 I,you,my,and,or 等,在英语中非常常用。您可以覆盖或扩展停用词集以适应用例的目的。如果没有进行这种清洗过程,后续的算法可能会因为常用单词而产生偏见。

为了调用StopWordsRemover,您需要导入以下包:

import org.apache.spark.ml.feature.StopWordsRemover

首先,您需要初始化一个StopWordsRemover,指定输入列和输出列。在这里,我们选择了Tokenizer创建的单词列,并生成了一个输出列,用于删除停用词后的过滤单词:

scala> val remover = new StopWordsRemover().setInputCol("words").setOutputCol("filteredWords")
remover: org.apache.spark.ml.feature.StopWordsRemover = stopWords_48d2cecd3011

接下来,在输入数据集上调用transform()函数会产生一个输出数据集:

scala> val noStopWordsDF = remover.transform(wordsDF)
noStopWordsDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 2 more fields]

以下是输出数据集,显示了输入列 ID、句子和包含单词序列的输出列filteredWords

scala> noStopWordsDF.show(false)
|id|sentence |words |filteredWords |
|1 |Hello there, how do you like the book so far? |[hello, there,, how, do, you, like, the, book, so, far?] |[hello, there,, like, book, far?] |
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |[new, machine, learning] |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |[maybe, get, coffee, starting] |
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |[coffee, best, drink, hot] |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|[book, stores, coffee, go, book, store]|

以下是输出数据集,只显示了句子和filteredWords,其中包含过滤后的单词序列:


scala> noStopWordsDF.select("sentence", "filteredWords").show(5,false)
|sentence |filteredWords |
|Hello there, how do you like the book so far? |[hello, there,, like, book, far?] |
|I am new to Machine Learning |[new, machine, learning] |
|Maybe i should get some coffee before starting |[maybe, get, coffee, starting] |
|Coffee is best when you drink it hot |[coffee, best, drink, hot] |
|Book stores have coffee too so i should go to a book store|[book, stores, coffee, go, book, store]|

StopWordsRemover的图示如下,显示了过滤后的单词,删除了诸如 I,should,some 和 before 等停用词:

停用词默认设置,但可以非常容易地被覆盖或修改,如下面的代码片段所示,在这里我们将从过滤后的单词中删除 hello,将 hello 视为停用词:

scala> val noHello = Array("hello") ++ remover.getStopWords
noHello: Array[String] = Array(hello, i, me, my, myself, we, our, ours, ourselves, you, your, yours, yourself, yourselves, he, him, his, himself, she, her, hers, herself, it, its, itself, they, them, their, theirs, themselves, what, which, who, whom, this, that, these, those, am, is, are, was, were ...
scala>

//create new transfomer using the amended Stop Words list
scala> val removerCustom = new StopWordsRemover().setInputCol("words").setOutputCol("filteredWords").setStopWords(noHello)
removerCustom: org.apache.spark.ml.feature.StopWordsRemover = stopWords_908b488ac87f

//invoke transform function
scala> val noStopWordsDFCustom = removerCustom.transform(wordsDF)
noStopWordsDFCustom: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 2 more fields]

//output dataset showing only sentence and filtered words - now will not show hello
scala> noStopWordsDFCustom.select("sentence", "filteredWords").show(5,false)
+----------------------------------------------------------+---------------------------------------+
|sentence |filteredWords |
+----------------------------------------------------------+---------------------------------------+
|Hello there, how do you like the book so far? |[there,, like, book, far?] |
|I am new to Machine Learning |[new, machine, learning] |
|Maybe i should get some coffee before starting |[maybe, get, coffee, starting] |
|Coffee is best when you drink it hot |[coffee, best, drink, hot] |
|Book stores have coffee too so i should go to a book store|[book, stores, coffee, go, book, store]|
+----------------------------------------------------------+---------------------------------------+

NGrams

NGrams 是由单词组合而成的单词序列。N 代表序列中的单词数。例如,2-gram 是两个单词在一起,3-gram 是三个单词在一起。setN()用于指定N的值。

为了生成 NGrams,您需要导入该包:

import org.apache.spark.ml.feature.NGram

首先,您需要初始化一个NGram生成器,指定输入列和输出列。在这里,我们选择了StopWordsRemover创建的过滤后的单词列,并生成了一个输出列,用于删除停用词后的过滤单词:

scala> val ngram = new NGram().setN(2).setInputCol("filteredWords").setOutputCol("ngrams")
ngram: org.apache.spark.ml.feature.NGram = ngram_e7a3d3ab6115

接下来,在输入数据集上调用transform()函数会产生一个输出数据集:

scala> val nGramDF = ngram.transform(noStopWordsDF)
nGramDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 3 more fields]

以下是输出数据集,显示了输入列 ID、句子和包含 n-gram 序列的输出列ngram

scala> nGramDF.show(false)
|id|sentence |words |filteredWords |ngrams |
|1 |Hello there, how do you like the book so far? |[hello, there,, how, do, you, like, the, book, so, far?] |[hello, there,, like, book, far?] |[hello there,, there, like, like book, book far?] |
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |[new, machine, learning] |[new machine, machine learning] |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |[maybe, get, coffee, starting] |[maybe get, get coffee, coffee starting] |
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |[coffee, best, drink, hot] |[coffee best, best drink, drink hot] |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|[book, stores, coffee, go, book, store]|[book stores, stores coffee, coffee go, go book, book store]|

以下是输出数据集,显示了句子和 2-gram:

scala> nGramDF.select("sentence", "ngrams").show(5,false)
|sentence |ngrams |
|Hello there, how do you like the book so far? |[hello there,, there, like, like book, book far?] |
|I am new to Machine Learning |[new machine, machine learning] |
|Maybe i should get some coffee before starting |[maybe get, get coffee, coffee starting] |
|Coffee is best when you drink it hot |[coffee best, best drink, drink hot] |
|Book stores have coffee too so i should go to a book store|[book stores, stores coffee, coffee go, go book, book store]|

NGram 的图如下所示,显示了在分词和去除停用词后生成的 2-gram:

TF-IDF

TF-IDF 代表词项频率-逆文档频率,它衡量了一个词在文档集合中对于一个文档的重要性。它在信息检索中被广泛使用,反映了词在文档中的权重。TF-IDF 值与词的出现次数成正比增加,也就是词频,由词频和逆文档频率两个关键元素组成。

TF 是词项频率,即文档中词/术语的频率。

对于一个术语ttf衡量了术语t在文档d中出现的次数。tf在 Spark 中使用哈希实现,其中一个术语通过应用哈希函数映射到一个索引。

IDF 是逆文档频率,代表了一个术语提供的关于该术语在文档中出现倾向的信息。 IDF 是包含该术语的文档的对数缩放的逆函数:

IDF = 总文档数/包含该词的文档数

一旦我们有了TFIDF,我们可以通过将它们相乘来计算TF-IDF值:

TF-IDF = TF * IDF

我们现在将看一下如何使用 Spark ML 中的 HashingTF Transformer 生成TF

HashingTF

HashingTF是一个 Transformer,它接受一组术语,并通过使用哈希函数对每个术语进行哈希,将它们转换为固定长度的向量。然后,使用哈希表的索引生成词项频率。

在 Spark 中,HashingTF 使用MurmurHash3算法对术语进行哈希。

为了使用HashingTF,你需要导入以下包:

import org.apache.spark.ml.feature.HashingTF

首先,你需要初始化一个HashingTF,指定输入列和输出列。在这里,我们选择了StopWordsRemover Transformer 创建的过滤词列,并生成一个输出列rawFeaturesDF。我们还选择了 100 个特征:

scala> val hashingTF = new HashingTF().setInputCol("filteredWords").setOutputCol("rawFeatures").setNumFeatures(100)
hashingTF: org.apache.spark.ml.feature.HashingTF = hashingTF_b05954cb9375

接下来,在输入数据集上调用transform()函数会产生一个输出数据集:

scala> val rawFeaturesDF = hashingTF.transform(noStopWordsDF)
rawFeaturesDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 3 more fields]

以下是输出数据集,显示了输入列 ID、句子和输出列rawFeaturesDF,其中包含由向量表示的特征:

scala> rawFeaturesDF.show(false)
|id |sentence |words |filteredWords |rawFeatures |
|1 |Hello there, how do you like the book so far? |[hello, there,, how, do, you, like, the, book, so, far?] |[hello, there,, like, book, far?] |(100,[30,48,70,93],[2.0,1.0,1.0,1.0]) |
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |[new, machine, learning] |(100,[25,52,72],[1.0,1.0,1.0]) |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |[maybe, get, coffee, starting] |(100,[16,51,59,99],[1.0,1.0,1.0,1.0]) |
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |[coffee, best, drink, hot] |(100,[31,51,63,72],[1.0,1.0,1.0,1.0]) |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|[book, stores, coffee, go, book, store]|(100,[43,48,51,77,93],[1.0,1.0,1.0,1.0,2.0])|

让我们看一下前面的输出,以便更好地理解。如果你只看filteredWordsrawFeatures两列,你会发现,

  1. 单词数组[hello, there, like, book, and far]被转换为原始特征向量(100,[30,48,70,93],[2.0,1.0,1.0,1.0])

  2. 单词数组(book, stores, coffee, go, book, and store)被转换为原始特征向量(100,[43,48,51,77,93],[1.0,1.0,1.0,1.0,2.0])

那么,这个向量代表什么呢?其基本逻辑是,每个单词被哈希成一个整数,并计算在单词数组中出现的次数。

Spark 在内部使用mutable.HashMap.empty[Int, Double]来存储每个单词的哈希值作为Integer键和出现次数作为 double 值。使用 Double 是为了能够与 IDF 一起使用(我们将在下一节讨论)。使用这个映射,数组[book, stores, coffee, go, book, store]可以看作是[hashFunc(book), hashFunc(stores), hashFunc(coffee), hashFunc(go), hashFunc(book), hashFunc(store)],即[43,48,51,77,93]。然后,如果你也计算出现次数,即book-2, coffee-1,go-1,store-1,stores-1

结合前面的信息,我们可以生成一个向量(numFeatures, hashValues, Frequencies),在这种情况下将是(100,[43,48,51,77,93],[1.0,1.0,1.0,1.0,2.0])

逆文档频率(IDF)

逆文档频率IDF)是一个估计器,它适用于数据集,然后通过缩放输入特征生成特征。因此,IDF 作用于 HashingTF Transformer 的输出。

为了调用 IDF,您需要导入该包:

import org.apache.spark.ml.feature.IDF

首先,您需要初始化一个IDF,指定输入列和输出列。在这里,我们选择由 HashingTF 创建的rawFeatures单词列,并生成一个输出列特征:

scala> val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features")
idf: org.apache.spark.ml.feature.IDF = idf_d8f9ab7e398e

接下来,在输入数据集上调用fit()函数会产生一个输出 Transformer:

scala> val idfModel = idf.fit(rawFeaturesDF)
idfModel: org.apache.spark.ml.feature.IDFModel = idf_d8f9ab7e398e

此外,在输入数据集上调用transform()函数会产生一个输出数据集:

scala> val featuresDF = idfModel.transform(rawFeaturesDF)
featuresDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 4 more fields]

以下是显示输入列 ID 和输出列特征的输出数据集,其中包含由前一个转换中的 HashingTF 生成的缩放特征的向量:

scala> featuresDF.select("id", "features").show(5, false)
|id|features |
|1 |(20,[8,10,13],[0.6931471805599453,3.295836866004329,0.6931471805599453]) |
|2 |(20,[5,12],[1.0986122886681098,1.3862943611198906]) |
|3 |(20,[11,16,19],[0.4054651081081644,1.0986122886681098,2.1972245773362196]) |
|4 |(20,[3,11,12],[0.6931471805599453,0.8109302162163288,0.6931471805599453]) |
|5 |(20,[3,8,11,13,17],[0.6931471805599453,0.6931471805599453,0.4054651081081644,1.3862943611198906,1.0986122886681098])|

以下是显示输入列 ID、句子、rawFeatures和输出列特征的输出数据集,其中包含由前一个转换中的 HashingTF 生成的缩放特征的向量:


scala> featuresDF.show(false)
|id|sentence |words |filteredWords |rawFeatures |features |
|1 |Hello there, how do you like the book so far? |[hello, there,, how, do, you, like, the, book, so, far?] |[hello, there,, like, book, far?] |(20,[8,10,13],[1.0,3.0,1.0]) |(20,[8,10,13],[0.6931471805599453,3.295836866004329,0.6931471805599453]) |
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |[new, machine, learning] |(20,[5,12],[1.0,2.0]) |(20,[5,12],[1.0986122886681098,1.3862943611198906]) |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |[maybe, get, coffee, starting] |(20,[11,16,19],[1.0,1.0,2.0]) |(20,[11,16,19],[0.4054651081081644,1.0986122886681098,2.1972245773362196]) |
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |[coffee, best, drink, hot] |(20,[3,11,12],[1.0,2.0,1.0]) |(20,[3,11,12],[0.6931471805599453,0.8109302162163288,0.6931471805599453]) |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|[book, stores, coffee, go, book, store]|(20,[3,8,11,13,17],[1.0,1.0,1.0,2.0,1.0])|(20,[3,8,11,13,17],[0.6931471805599453,0.6931471805599453,0.4054651081081644,1.3862943611198906,1.0986122886681098])|

TF-IDF 的图如下,显示了TF-IDF 特征的生成:

Word2Vec

Word2Vec 是一种复杂的神经网络风格的自然语言处理工具,使用一种称为skip-grams的技术将单词句子转换为嵌入式向量表示。让我们看一个关于动物的句子集合的示例:

  • 一只狗在吠叫

  • 一些奶牛在吃草

  • 狗通常会随机吠叫

  • 牛喜欢草

使用具有隐藏层的神经网络(在许多无监督学习应用中使用的机器学习算法),我们可以学习(有足够的例子)dogbarking相关,cowgrass相关,它们经常一起出现,这是由概率来衡量的。Word2vec的输出是Double特征的向量。

为了调用Word2vec,您需要导入该包:

import org.apache.spark.ml.feature.Word2Vec

首先,您需要初始化一个Word2vec Transformer,指定输入列和输出列。在这里,我们选择由Tokenizer创建的单词列,并生成大小为 3 的单词向量输出列:

scala> val word2Vec = new Word2Vec().setInputCol("words").setOutputCol("wordvector").setVectorSize(3).setMinCount(0)
word2Vec: org.apache.spark.ml.feature.Word2Vec = w2v_fe9d488fdb69

接下来,在输入数据集上调用fit()函数会产生一个输出 Transformer:

scala> val word2VecModel = word2Vec.fit(noStopWordsDF)
word2VecModel: org.apache.spark.ml.feature.Word2VecModel = w2v_fe9d488fdb69

此外,在输入数据集上调用transform()函数会产生一个输出数据集:

scala> val word2VecDF = word2VecModel.transform(noStopWordsDF)
word2VecDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 3 more fields]

以下是显示输入列 ID、句子和输出列wordvector的输出数据集:

scala> word2VecDF.show(false)
|id|sentence |words |filteredWords |wordvector |
|1 |Hello there, how do you like the book so far? |[hello, there,, how, do, you, like, the, book, so, far?] |[hello, there,, like, book, far?] |[0.006875938177108765,-0.00819675214588642,0.0040686681866645815]|
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |[new, machine, learning] |[0.026012470324834187,0.023195965060343344,-0.10863214979569116] |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |[maybe, get, coffee, starting] |[-0.004304863978177309,-0.004591284319758415,0.02117823390290141]|
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |[coffee, best, drink, hot] |[0.054064739029854536,-0.003801364451646805,0.06522738828789443] |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|[book, stores, coffee, go, book, store]|[-0.05887459063281615,-0.07891856770341595,0.07510609552264214] |

Word2Vec 特征的图如下,显示了单词被转换为向量:

CountVectorizer

CountVectorizer用于将一组文本文档转换为标记计数的向量,从本质上为文档生成稀疏表示。最终结果是一组特征向量,然后可以传递给其他算法。稍后,我们将看到如何在 LDA 算法中使用CountVectorizer的输出执行主题检测。

为了调用CountVectorizer,您需要导入该包:

import org.apache.spark.ml.feature.CountVectorizer

首先,您需要初始化一个CountVectorizer Transformer,指定输入列和输出列。在这里,我们选择由StopWordRemover创建的filteredWords列,并生成输出列特征:

scala> val countVectorizer = new CountVectorizer().setInputCol("filteredWords").setOutputCol("features")
countVectorizer: org.apache.spark.ml.feature.CountVectorizer = cntVec_555716178088

接下来,在输入数据集上调用fit()函数会产生一个输出 Transformer:

scala> val countVectorizerModel = countVectorizer.fit(noStopWordsDF)
countVectorizerModel: org.apache.spark.ml.feature.CountVectorizerModel = cntVec_555716178088

此外,在输入数据集上调用transform()函数会产生一个输出数据集。

scala> val countVectorizerDF = countVectorizerModel.transform(noStopWordsDF)
countVectorizerDF: org.apache.spark.sql.DataFrame = [id: int, sentence: string ... 3 more fields]

以下是显示输入列 ID、句子和输出列特征的输出数据集:

scala> countVectorizerDF.show(false)
|id |sentence |words |filteredWords |features |
|1 |Hello there, how do you like the book so far? |[hello, there,, how, do, you, like, the, book, so, far?] |[hello, there,, like, book, far?] |(18,[1,4,5,13,15],[1.0,1.0,1.0,1.0,1.0])|
|2 |I am new to Machine Learning |[i, am, new, to, machine, learning] |[new, machine, learning] |(18,[6,7,16],[1.0,1.0,1.0]) |
|3 |Maybe i should get some coffee before starting |[maybe, i, should, get, some, coffee, before, starting] |[maybe, get, coffee, starting] |(18,[0,8,9,14],[1.0,1.0,1.0,1.0]) |
|4 |Coffee is best when you drink it hot |[coffee, is, best, when, you, drink, it, hot] |[coffee, best, drink, hot] |(18,[0,3,10,12],[1.0,1.0,1.0,1.0]) |
|5 |Book stores have coffee too so i should go to a book store|[book, stores, have, coffee, too, so, i, should, go, to, a, book, store]|[book, stores, coffee, go, book, store]|(18,[0,1,2,11,17],[1.0,2.0,1.0,1.0,1.0])|

CountVectorizer的图如下,显示了从StopWordsRemover转换生成的特征:

使用 LDA 进行主题建模

LDA 是一种主题模型,它从一组文本文档中推断主题。LDA 可以被视为一种无监督的聚类算法,如下所示:

  • 主题对应于聚类中心,文档对应于数据集中的行

  • 主题和文档都存在于特征空间中,特征向量是单词计数的向量

  • LDA 不是使用传统距离估计聚类,而是使用基于文本文档生成的统计模型的函数

为了调用 LDA,您需要导入该包:

import org.apache.spark.ml.clustering.LDA

步骤 1. 首先,您需要初始化一个设置 10 个主题和 10 次聚类的 LDA 模型:

scala> val lda = new LDA().setK(10).setMaxIter(10)
lda: org.apache.spark.ml.clustering.LDA = lda_18f248b08480

步骤 2. 在输入数据集上调用fit()函数会产生一个输出转换器:

scala> val ldaModel = lda.fit(countVectorizerDF)
ldaModel: org.apache.spark.ml.clustering.LDAModel = lda_18f248b08480

步骤 3. 提取logLikelihood,它计算了给定推断主题的文档的下限:

scala> val ll = ldaModel.logLikelihood(countVectorizerDF)
ll: Double = -275.3298948279124

步骤 4. 提取logPerplexity,它计算了给定推断主题的文档的困惑度的上限:

scala> val lp = ldaModel.logPerplexity(countVectorizerDF)
lp: Double = 12.512670220189033

步骤 5. 现在,我们可以使用describeTopics()来获取 LDA 生成的主题:

scala> val topics = ldaModel.describeTopics(10)
topics: org.apache.spark.sql.DataFrame = [topic: int, termIndices: array<int> ... 1 more field]

步骤 6. 以下是 LDA 模型计算的topictermIndicestermWeights的输出数据集:

scala> topics.show(10, false)
|topic|termIndices |termWeights |
|0 |[2, 5, 7, 12, 17, 9, 13, 16, 4, 11] |[0.06403877783050851, 0.0638177222807826, 0.06296749987731722, 0.06129482302538905, 0.05906095287220612, 0.0583855194291998, 0.05794181263149175, 0.057342702589298085, 0.05638654243412251, 0.05601913313272188] |
|1 |[15, 5, 13, 8, 1, 6, 9, 16, 2, 14] |[0.06889315890755099, 0.06415969116685549, 0.058990446579892136, 0.05840283223031986, 0.05676844625413551, 0.0566842803396241, 0.05633554021408156, 0.05580861561950114, 0.055116582320533423, 0.05471754535803045] |
|2 |[17, 14, 1, 5, 12, 2, 4, 8, 11, 16] |[0.06230542516700517, 0.06207673834677118, 0.06089143673912089, 0.060721809302399316, 0.06020894045877178, 0.05953822260375286, 0.05897033457363252, 0.057504989644756616, 0.05586725037894327, 0.05562088924566989] |
|3 |[15, 2, 11, 16, 1, 7, 17, 8, 10, 3] |[0.06995373276880751, 0.06249041124300946, 0.061960612781077645, 0.05879695651399876, 0.05816564815895558, 0.05798721645705949, 0.05724374708387087, 0.056034215734402475, 0.05474217418082123, 0.05443850583761207] |
|4 |[16, 9, 5, 7, 1, 12, 14, 10, 13, 4] |[0.06739359010780331, 0.06716438619386095, 0.06391509491709904, 0.062049068666162915, 0.06050715515506004, 0.05925113958472128, 0.057946856127790804, 0.05594837087703049, 0.055000929117413805, 0.053537418286233956]|
|5 |[5, 15, 6, 17, 7, 8, 16, 11, 10, 2] |[0.061611492476326836, 0.06131944264846151, 0.06092975441932787, 0.059812552365763404, 0.05959889552537741, 0.05929123338151455, 0.05899808901872648, 0.05892061664356089, 0.05706951425713708, 0.05636134431063274] |
|6 |[15, 0, 4, 14, 2, 10, 13, 7, 6, 8] |[0.06669864676186414, 0.0613859230159798, 0.05902091745149218, 0.058507882633921676, 0.058373998449322555, 0.05740944364508325, 0.057039150886628136, 0.057021822698594314, 0.05677330199892444, 0.056741558062814376]|
|7 |[12, 9, 8, 15, 16, 4, 7, 13, 17, 10]|[0.06770789917351365, 0.06320078344027158, 0.06225712567900613, 0.058773135159638154, 0.05832535181576588, 0.057727684814461444, 0.056683575112703555, 0.05651178333610803, 0.056202395617563274, 0.05538103218174723]|
|8 |[14, 11, 10, 7, 12, 9, 13, 16, 5, 1]|[0.06757347958335463, 0.06362319365053591, 0.063359294927315, 0.06319462709331332, 0.05969320243218982, 0.058380063437908046, 0.057412693576813126, 0.056710451222381435, 0.056254581639201336, 0.054737785085167814] |
|9 |[3, 16, 5, 7, 0, 2, 10, 15, 1, 13] |[0.06603941595604573, 0.06312775362528278, 0.06248795574460503, 0.06240547032037694, 0.0613859713404773, 0.06017781222489122, 0.05945655694365531, 0.05910351349013983, 0.05751269894725456, 0.05605239791764803] |

LDA 的图如下所示,显示了从 TF-IDF 特征创建的主题:

实施文本分类

文本分类是机器学习领域中最常用的范例之一,在垃圾邮件检测和电子邮件分类等用例中非常有用,就像任何其他机器学习算法一样,工作流程由转换器和算法构建。在文本处理领域,预处理步骤如去除停用词、词干提取、标记化、n-gram 提取、TF-IDF 特征加权等起着重要作用。一旦所需的处理完成,模型就会被训练来将文档分类为两个或更多类别。

二元分类是将输入分为两个输出类别,例如垃圾邮件/非垃圾邮件和给定的信用卡交易是否欺诈。多类分类可以生成多个输出类别,例如热、冷、冰冻和多雨。还有一种称为多标签分类的技术,可以从汽车特征的描述中生成多个标签,例如速度、安全性和燃油效率。

为此,我们将使用一个包含 10k 条推文的样本数据集,并在该数据集上使用前述技术。然后,我们将将文本行标记为单词,删除停用词,然后使用CountVectorizer构建单词(特征)的向量。

然后,我们将数据分为训练(80%)-测试(20%),并训练一个逻辑回归模型。最后,我们将根据测试数据进行评估,并查看其表现如何。

工作流程中的步骤如下图所示:

步骤 1. 加载包含 10k 条推文以及标签和 ID 的输入文本数据:

scala> val inputText = sc.textFile("Sentiment_Analysis_Dataset10k.csv")
inputText: org.apache.spark.rdd.RDD[String] = Sentiment_Analysis_Dataset10k.csv MapPartitionsRDD[1722] at textFile at <console>:77

步骤 2. 将输入行转换为 DataFrame:

scala> val sentenceDF = inputText.map(x => (x.split(",")(0), x.split(",")(1), x.split(",")(2))).toDF("id", "label", "sentence")
sentenceDF: org.apache.spark.sql.DataFrame = [id: string, label: string ... 1 more field]

步骤 3. 使用带有空格分隔符的Tokenizer将数据转换为单词:

scala> import org.apache.spark.ml.feature.Tokenizer
import org.apache.spark.ml.feature.Tokenizer

scala> val tokenizer = new Tokenizer().setInputCol("sentence").setOutputCol("words")
tokenizer: org.apache.spark.ml.feature.Tokenizer = tok_ebd4c89f166e

scala> val wordsDF = tokenizer.transform(sentenceDF)
wordsDF: org.apache.spark.sql.DataFrame = [id: string, label: string ... 2 more fields]

scala> wordsDF.show(5, true)
| id|label| sentence| words|
| 1| 0|is so sad for my ...|[is, so, sad, for...|
| 2| 0|I missed the New ...|[i, missed, the, ...|
| 3| 1| omg its already ...|[, omg, its, alre...|
| 4| 0| .. Omgaga. Im s...|[, , .., omgaga.,...|
| 5| 0|i think mi bf is ...|[i, think, mi, bf...|

步骤 4. 删除停用词并创建一个新的 DataFrame,其中包含过滤后的单词:

scala> import org.apache.spark.ml.feature.StopWordsRemover
import org.apache.spark.ml.feature.StopWordsRemover

scala> val remover = new StopWordsRemover().setInputCol("words").setOutputCol("filteredWords")
remover: org.apache.spark.ml.feature.StopWordsRemover = stopWords_d8dd48c9cdd0

scala> val noStopWordsDF = remover.transform(wordsDF)
noStopWordsDF: org.apache.spark.sql.DataFrame = [id: string, label: string ... 3 more fields]

scala> noStopWordsDF.show(5, true)
| id|label| sentence| words| filteredWords|
| 1| 0|is so sad for my ...|[is, so, sad, for...|[sad, apl, friend...|
| 2| 0|I missed the New ...|[i, missed, the, ...|[missed, new, moo...|
| 3| 1| omg its already ...|[, omg, its, alre...|[, omg, already, ...|
| 4| 0| .. Omgaga. Im s...|[, , .., omgaga.,...|[, , .., omgaga.,...|
| 5| 0|i think mi bf is ...|[i, think, mi, bf...|[think, mi, bf, c...|

步骤 5. 从过滤后的单词创建特征向量:

scala> import org.apache.spark.ml.feature.CountVectorizer
import org.apache.spark.ml.feature.CountVectorizer

scala> val countVectorizer = new CountVectorizer().setInputCol("filteredWords").setOutputCol("features")
countVectorizer: org.apache.spark.ml.feature.CountVectorizer = cntVec_fdf1512dfcbd

scala> val countVectorizerModel = countVectorizer.fit(noStopWordsDF)
countVectorizerModel: org.apache.spark.ml.feature.CountVectorizerModel = cntVec_fdf1512dfcbd

scala> val countVectorizerDF = countVectorizerModel.transform(noStopWordsDF)
countVectorizerDF: org.apache.spark.sql.DataFrame = [id: string, label: string ... 4 more fields]

scala> countVectorizerDF.show(5,true)
| id|label| sentence| words| filteredWords| features|
| 1| 0|is so sad for my ...|[is, so, sad, for...|[sad, apl, friend...|(23481,[35,9315,2...|
| 2| 0|I missed the New ...|[i, missed, the, ...|[missed, new, moo...|(23481,[23,175,97...|
| 3| 1| omg its already ...|[, omg, its, alre...|[, omg, already, ...|(23481,[0,143,686...|
| 4| 0| .. Omgaga. Im s...|[, , .., omgaga.,...|[, , .., omgaga.,...|(23481,[0,4,13,27...|
| 5| 0|i think mi bf is ...|[i, think, mi, bf...|[think, mi, bf, c...|(23481,[0,33,731,...|

步骤 6. 创建只有标签和特征的inputData DataFrame:


scala> val inputData=countVectorizerDF.select("label", "features").withColumn("label", col("label").cast("double"))
inputData: org.apache.spark.sql.DataFrame = [label: double, features: vector]

步骤 7. 使用随机拆分将数据拆分为 80%的训练数据集和 20%的测试数据集:

scala> val Array(trainingData, testData) = inputData.randomSplit(Array(0.8, 0.2))
trainingData: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [label: double, features: vector]
testData: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [label: double, features: vector]

步骤 8. 创建一个逻辑回归模型:

scala> import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.classification.LogisticRegression

scala> val lr = new LogisticRegression()
lr: org.apache.spark.ml.classification.LogisticRegression = logreg_a56accef5728

步骤 9. 通过拟合trainingData创建一个逻辑回归模型:

scala> var lrModel = lr.fit(trainingData)
lrModel: org.apache.spark.ml.classification.LogisticRegressionModel = logreg_a56accef5728

scala> lrModel.coefficients
res160: org.apache.spark.ml.linalg.Vector = [7.499178040193577,8.794520490564185,4.837543313917086,-5.995818019393418,1.1754740390468577,3.2104594489397584,1.7840290776286476,-1.8391923375331787,1.3427471762591,6.963032309971087,-6.92725055841986,-10.781468845891563,3.9752.836891070557657,3.8758544006087523,-11.760894935576934,-6.252988307540...

scala> lrModel.intercept
res161: Double = -5.397920610780994

步骤 10. 检查模型摘要,特别是areaUnderROC,对于一个好的模型应该是*> 0.90*:

scala> import org.apache.spark.ml.classification.BinaryLogisticRegressionSummary
import org.apache.spark.ml.classification.BinaryLogisticRegressionSummary

scala> val summary = lrModel.summary
summary: org.apache.spark.ml.classification.LogisticRegressionTrainingSummary = org.apache.spark.ml.classification.BinaryLogisticRegressionTrainingSummary@1dce712c

scala> val bSummary = summary.asInstanceOf[BinaryLogisticRegressionSummary]
bSummary: org.apache.spark.ml.classification.BinaryLogisticRegressionSummary = org.apache.spark.ml.classification.BinaryLogisticRegressionTrainingSummary@1dce712c

scala> bSummary.areaUnderROC
res166: Double = 0.9999231930196596

scala> bSummary.roc
res167: org.apache.spark.sql.DataFrame = [FPR: double, TPR: double]

scala> bSummary.pr.show()
| recall|precision|
| 0.0| 1.0|
| 0.2306543172990738| 1.0|
| 0.2596354944726621| 1.0|
| 0.2832387212429041| 1.0|
|0.30504929787869733| 1.0|
| 0.3304451747833881| 1.0|
|0.35255452644158947| 1.0|
| 0.3740663280549746| 1.0|
| 0.3952793546459516| 1.0|

步骤 11. 使用训练和测试数据集使用训练好的模型进行转换:

scala> val training = lrModel.transform(trainingData)
training: org.apache.spark.sql.DataFrame = [label: double, features: vector ... 3 more fields]

scala> val test = lrModel.transform(testData)
test: org.apache.spark.sql.DataFrame = [label: double, features: vector ... 3 more fields]

步骤 12. 计算具有匹配标签和预测列的记录数。它们应该匹配以进行正确的模型评估,否则它们将不匹配:

scala> training.filter("label == prediction").count
res162: Long = 8029

scala> training.filter("label != prediction").count
res163: Long = 19

scala> test.filter("label == prediction").count
res164: Long = 1334

scala> test.filter("label != prediction").count
res165: Long = 617

结果可以放入下表中:

数据集 总数 标签==预测 标签!=预测
训练 8048 8029 ( 99.76%) 19 (0.24%)
测试 1951 1334 (68.35%) 617 (31.65%)

虽然训练数据产生了很好的匹配,但测试数据只有 68.35%的匹配。因此,还有改进的空间,可以通过探索模型参数来实现。

逻辑回归是一种易于理解的方法,用于使用输入的线性组合和逻辑随机变量的随机噪声来预测二元结果。因此,可以使用多个参数来调整逻辑回归模型。(本章不涵盖调整此类逻辑回归模型的全部参数及方法。)

可以用于调整模型的一些参数是:

  • 模型超参数包括以下参数:

  • elasticNetParam:此参数指定您希望如何混合 L1 和 L2 正则化

  • regParam:此参数确定输入在传递到模型之前应如何正则化

  • 训练参数包括以下参数:

  • maxIter:这是停止之前的总交互次数

  • weightCol:这是用于对某些行进行加权的权重列的名称

  • 预测参数包括以下参数:

  • threshold:这是用于二元预测的概率阈值。这决定了预测给定类别的最小概率。

我们现在已经了解了如何构建一个简单的分类模型,因此可以根据训练集对任何新的推文进行标记。逻辑回归只是可以使用的模型之一。

可以用于替代逻辑回归的其他模型如下:

  • 决策树

  • 随机森林

  • 梯度提升树

  • 多层感知器

摘要

在本章中,我们介绍了使用 Spark ML 进行文本分析的世界,重点是文本分类。我们了解了转换器和估计器。我们看到了如何使用分词器将句子分解为单词,如何去除停用词,并生成 n-gram。我们还看到了如何实现HashingTFIDF来生成基于 TF-IDF 的特征。我们还研究了Word2Vec如何将单词序列转换为向量。

然后,我们还研究了 LDA,这是一种流行的技术,用于从文档中生成主题,而不需要了解实际文本。最后,我们在 Twitter 数据集的 10k 条推文集上实现了文本分类,以查看如何使用转换器、估计器和逻辑回归模型来执行二元分类。

在下一章中,我们将更深入地探讨调整 Spark 应用程序以获得更好性能。

第十六章:Spark 调优

“竖琴手的 90%的时间都在调琴,10%的时间在弹走音。”

  • 伊戈尔·斯特拉文斯基

在本章中,我们将深入了解 Apache Spark 的内部,并看到,虽然 Spark 在让我们感觉像是在使用另一个 Scala 集合方面做得很好,但我们不必忘记 Spark 实际上是在分布式系统中运行的。因此,需要额外小心。简而言之,本章将涵盖以下主题:

  • 监视 Spark 作业

  • Spark 配置

  • Spark 应用程序开发中的常见错误

  • 优化技术

监视 Spark 作业

Spark 为监视计算节点(驱动程序或执行程序)上运行或已完成的所有作业提供了 Web UI。在本节中,我们将简要讨论如何使用适当的示例使用 Spark Web UI 监视 Spark 作业的进度。我们将看到如何监视作业的进度(包括已提交、排队和运行的作业)。将简要讨论 Spark Web UI 中的所有选项卡。最后,我们将讨论 Spark 的日志记录过程,以便更好地进行调优。

Spark Web 界面

Web UI(也称为 Spark UI)是用于在 Web 浏览器(如 Firefox 或 Google Chrome)上监视 Spark 应用程序的执行的 Web 界面。当 SparkContext 启动时,独立模式下将在端口 4040 上启动显示有关应用程序的有用信息的 Web UI。Spark Web UI 的可用性取决于应用程序是否仍在运行或已完成执行。

此外,您可以在应用程序完成执行后使用 Web UI,方法是使用EventLoggingListener持久化所有事件。但是,EventLoggingListener不能单独工作,需要结合 Spark 历史服务器。结合这两个功能,可以实现以下功能:

  • 调度程序阶段和任务的列表

  • RDD 大小的摘要

  • 内存使用情况

  • 环境信息

  • 有关正在运行的执行程序的信息

您可以在 Web 浏览器中访问 UI,网址为http://<driver-node>:4040。例如,以独立模式提交并运行的 Spark 作业可以在http://localhost:4040上访问。

请注意,如果同一主机上运行多个 SparkContext,则它们将绑定到从 4040 开始的连续端口,如 4040、4041、4042 等。默认情况下,此信息仅在您的 Spark 应用程序运行期间可用。这意味着当您的 Spark 作业完成执行时,绑定将不再有效或可访问。

只要作业正在运行,就可以在 Spark UI 上观察到阶段。但是,要在作业完成执行后查看 Web UI,可以尝试在提交 Spark 作业之前将spark.eventLog.enabled设置为 true。这将强制 Spark 记录所有已在存储中持久化的事件,以便在 UI 中显示。

在上一章中,我们看到如何将 Spark 作业提交到集群。让我们重用提交 k 均值聚类的命令之一,如下所示:

# Run application as standalone mode on 8 cores
SPARK_HOME/bin/spark-submit \
 --class org.apache.spark.examples.KMeansDemo \
 --master local[8] \
 KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar \
 Saratoga_NY_Homes.txt

如果使用上述命令提交作业,则将无法查看已完成执行的作业的状态,因此要使更改永久生效,请使用以下两个选项:

spark.eventLog.enabled=true 
spark.eventLog.dir=file:///home/username/log"

通过设置前两个配置变量,我们要求 Spark 驱动程序启用事件记录以保存在file:///home/username/log

总之,通过以下更改,您的提交命令将如下所示:

# Run application as standalone mode on 8 cores
SPARK_HOME/bin/spark-submit \
 --conf "spark.eventLog.enabled=true" \
 --conf "spark.eventLog.dir=file:///tmp/test" \
 --class org.apache.spark.examples.KMeansDemo \
 --master local[8] \
 KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar \
 Saratoga_NY_Homes.txt

图 1:Spark Web UI

如前面的屏幕截图所示,Spark Web UI 提供以下选项卡:

  • 作业

  • 阶段

  • 存储

  • 环境

  • 执行程序

  • SQL

需要注意的是,并非所有功能都可以一次性显示,因为它们是按需懒惰创建的,例如,在运行流式作业时。

作业

根据 SparkContext 的不同,作业选项卡显示了 Spark 应用程序中所有 Spark 作业的状态。当您在 Spark UI 上使用 Web 浏览器访问http://localhost:4040的作业选项卡(对于独立模式),您应该观察以下选项:

  • 用户:显示已提交 Spark 作业的活跃用户

  • 总正常运行时间:显示作业的总正常运行时间

  • 调度模式:在大多数情况下,它是先进先出(FIFO)模式

  • 活跃作业:显示活跃作业的数量

  • 已完成的作业:显示已完成的作业数量

  • 事件时间轴:显示已完成执行的作业的时间轴

在内部,作业选项卡由JobsTab类表示,它是一个带有作业前缀的自定义 SparkUI 选项卡。作业选项卡使用JobProgressListener来访问有关 Spark 作业的统计信息,以在页面上显示上述信息。请查看以下屏幕截图:

**图 2:**Spark Web UI 中的作业选项卡

如果您在作业选项卡中进一步展开“Active Jobs”选项,您将能够看到执行计划、状态、已完成阶段的数量以及该特定作业的作业 ID,如下所示:

**图 3:**Spark Web UI 中任务的 DAG 可视化(摘要)

当用户在 Spark 控制台(例如,Spark shell 或使用 Spark submit)中输入代码时,Spark Core 会创建一个操作符图。这基本上是当用户在特定节点上执行操作(例如,reduce、collect、count、first、take、countByKey、saveAsTextFile)或转换(例如,map、flatMap、filter、mapPartitions、sample、union、intersection、distinct)时发生的情况,这些操作是在 RDD 上进行的(它们是不可变对象)。

**图 4:**DAG 调度程序将 RDD 谱系转换为阶段 DAG

在转换或操作期间,使用有向无环图DAG)信息来将节点恢复到最后的转换和操作(参见图 4图 5以获得更清晰的图像),以维护数据的弹性。最后,图被提交给 DAG 调度程序。

Spark 如何从 RDD 计算 DAG,然后执行任务?

在高层次上,当 RDD 上调用任何操作时,Spark 会创建 DAG 并将其提交给 DAG 调度程序。DAG 调度程序将操作符划分为任务阶段。一个阶段包括基于输入数据的分区的任务。DAG 调度程序将操作符进行流水线处理。例如,可以在单个阶段中安排多个 map 操作符。DAG 调度程序的最终结果是一组阶段。这些阶段被传递给任务调度程序。任务调度程序通过集群管理器(Spark Standalone/YARN/Mesos)启动任务。任务调度程序不知道阶段的依赖关系。工作节点在阶段上执行任务。

然后,DAG 调度程序跟踪阶段输出的 RDDs。然后,它找到运行作业的最小调度,并将相关的操作符划分为任务阶段。基于输入数据的分区,一个阶段包括多个任务。然后,操作符与 DAG 调度程序一起进行流水线处理。实际上,可以在单个阶段中安排多个 map 或 reduce 操作符(例如)。

**图 5:**执行操作导致 DAGScheduler 中的新 ResultStage 和 ActiveJob

DAG 调度程序中的两个基本概念是作业和阶段。因此,它必须通过内部注册表和计数器来跟踪它们。从技术上讲,DAG 调度程序是 SparkContext 初始化的一部分,它专门在驱动程序上工作(在任务调度程序和调度程序后端准备就绪后立即进行)。DAG 调度程序在 Spark 执行中负责三项主要任务。它计算作业的执行 DAG,即阶段的 DAG。它确定每个任务运行的首选节点,并处理由于丢失洗牌输出文件而导致的故障。

图 6: 由 SparkContext 创建的 DAGScheduler 与其他服务

DAG 调度程序的最终结果是一组阶段。因此,大部分统计信息和作业的状态可以使用此可视化来查看,例如执行计划、状态、已完成阶段的数量以及该特定作业的作业 ID。

阶段

Spark UI 中的阶段选项卡显示了 Spark 应用程序中所有阶段的当前状态,包括任务和阶段的统计信息以及池详细信息的两个可选页面。请注意,此信息仅在应用程序以公平调度模式运行时才可用。您应该能够在http://localhost:4040/stages上访问阶段选项卡。请注意,当没有提交作业时,该选项卡除了标题外什么也不显示。阶段选项卡显示了 Spark 应用程序中的阶段。该选项卡中可以看到以下阶段:

  • 活动阶段

  • 待处理的阶段

  • 已完成的阶段

例如,当您在本地提交一个 Spark 作业时,您应该能够看到以下状态:

图 7: Spark Web UI 中所有作业的阶段

在这种情况下,只有一个处于活动状态的阶段。然而,在接下来的章节中,当我们将 Spark 作业提交到 AWS EC2 集群时,我们将能够观察到其他阶段。

要进一步了解已完成作业的摘要,请单击描述列中包含的任何链接,您应该能够找到与执行时间相关的统计信息。在以下图中还可以看到指标的最小值、中位数、25th 百分位数、75th 百分位数和最大值:

图 8: Spark Web UI 上已完成作业的摘要

您的情况可能不同,因为在撰写本书期间,我只执行和提交了两个作业以进行演示。您还可以查看有关执行程序的其他统计信息。对于我的情况,我使用 8 个核心和 32GB 的 RAM 在独立模式下提交了这些作业。此外,还显示了与执行程序相关的信息,例如 ID、关联端口号的 IP 地址、任务完成时间、任务数量(包括失败任务、被杀任务和成功任务的数量)以及数据集每条记录的输入大小。

图像中的另一部分显示了与这两个任务相关的其他信息,例如索引、ID、尝试次数、状态、本地级别、主机信息、启动时间、持续时间,垃圾收集(GC)时间等。

存储

存储选项卡显示了每个 RDD、DataFrame 或 Dataset 的大小和内存使用情况。您应该能够看到 RDD、DataFrame 或 Dataset 的存储相关信息。以下图显示了存储元数据,如 RDD 名称、存储级别、缓存分区的数量、缓存的数据比例的百分比以及 RDD 在主内存中的大小:

图 9: 存储选项卡显示 RDD 在磁盘中消耗的空间

请注意,如果 RDD 无法缓存在主内存中,则将使用磁盘空间。本章的后续部分将进行更详细的讨论。

图 10: 数据分布和 RDD 在磁盘中使用的存储

环境

环境选项卡显示了当前设置在您的机器(即驱动程序)上的环境变量。更具体地说,可以在运行时信息下看到 Java Home、Java Version 和 Scala Version 等运行时信息。还可以看到 Spark 属性,如 Spark 应用程序 ID、应用程序名称、驱动程序主机信息、驱动程序端口、执行程序 ID、主 URL 和调度模式。此外,还可以在系统属性下看到其他与系统相关的属性和作业属性,例如 AWT 工具包版本、文件编码类型(例如 UTF-8)和文件编码包信息(例如 sun.io)。

图 11: Spark Web UI 上的环境选项卡

执行程序

执行器选项卡使用ExecutorsListener收集有关 Spark 应用程序的执行器信息。执行器是负责执行任务的分布式代理。执行器以不同的方式实例化。例如,当CoarseGrainedExecutorBackend接收到 Spark Standalone 和 YARN 的RegisteredExecutor消息时,它们可以被实例化。第二种情况是当 Spark 作业提交到 Mesos 时。Mesos 的MesosExecutorBackend会被注册。第三种情况是当您在本地运行 Spark 作业时,即创建LocalEndpoint。执行器通常在 Spark 应用程序的整个生命周期内运行,这称为执行器的静态分配,尽管您也可以选择动态分配。执行器后端专门管理计算节点或集群中的所有执行器。执行器定期向驱动程序的HeartbeatReceiver RPC 端点报告活动任务的心跳和部分指标,并将结果发送给驱动程序。它们还通过块管理器为用户程序缓存的 RDD 提供内存存储。有关此内容的更清晰的想法,请参考以下图:

图 12:Spark 驱动程序实例化一个执行器,负责处理 HeartbeatReceiver 的心跳消息处理程序

当执行器启动时,它首先向驱动程序注册,并直接通信以执行任务,如下图所示:

图 13:使用 TaskRunners 在执行器上启动任务

您应该能够在http://localhost:4040/executors访问执行器选项卡。

图 14:Spark Web UI 上的执行器选项卡

如前图所示,可以看到有关执行器的执行器 ID、地址、状态、RDD 块、存储内存、已使用磁盘、核心、活动任务、失败任务、完成任务、总任务、任务时间(GC 时间)、输入、Shuffle 读取、Shuffle 写入以及线程转储的信息。

SQL

Spark UI 中的 SQL 选项卡显示每个操作符的所有累加器值。您应该能够在http://localhost:4040/SQL/访问 SQL 选项卡。它默认显示所有 SQL 查询执行和底层信息。但是,只有在选择查询后,SQL 选项卡才会显示 SQL 查询执行的详细信息。

关于 SQL 的详细讨论超出了本章的范围。感兴趣的读者应参考spark.apache.org/docs/latest/sql-programming-guide.html#sql了解如何提交 SQL 查询并查看其结果输出。

使用 Web UI 可视化 Spark 应用程序

当提交 Spark 作业进行执行时,将启动一个 Web 应用程序 UI,显示有关应用程序的有用信息。事件时间轴显示应用程序事件的相对顺序和交错。时间轴视图可在三个级别上使用:跨所有作业、在一个作业内以及在一个阶段内。时间轴还显示执行器的分配和释放。

图 15:Spark 作业在 Spark Web UI 上以 DAG 形式执行

观察正在运行和已完成的 Spark 作业

要访问和观察正在运行和已完成的 Spark 作业,请在 Web 浏览器中打开http://spark_driver_host:4040。请注意,您将需要相应地用 IP 地址或主机名替换spark_driver_host

请注意,如果同一主机上运行多个 SparkContexts,它们将绑定到从 4040 开始的连续端口,4040、4041、4042 等。默认情况下,此信息仅在您的 Spark 应用程序运行期间可用。这意味着当您的 Spark 作业完成执行时,绑定将不再有效或可访问。

现在,要访问仍在执行的活动作业,请单击 Active Jobs 链接,您将看到这些作业的相关信息。另一方面,要访问已完成作业的状态,请单击 Completed Jobs,您将看到信息以 DAG 样式显示,如前一节所述。

**图 16:**观察正在运行和已完成的 Spark 作业

您可以通过单击 Active Jobs 或 Completed Jobs 下的作业描述链接来实现这些。

使用日志调试 Spark 应用程序

查看所有正在运行的 Spark 应用程序的信息取决于您使用的集群管理器。在调试 Spark 应用程序时,应遵循这些说明:

  • Spark Standalone:转到http://master:18080上的 Spark 主 UI。主节点和每个工作节点显示集群和相关作业统计信息。此外,每个作业的详细日志输出也写入到每个工作节点的工作目录中。我们将讨论如何使用log4j手动启用日志记录。

  • YARN:如果您的集群管理器是 YARN,并且假设您正在 Cloudera(或任何其他基于 YARN 的平台)上运行 Spark 作业,则转到 Cloudera Manager 管理控制台中的 YARN 应用程序页面。现在,要调试在 YARN 上运行的 Spark 应用程序,请查看 Node Manager 角色的日志。要实现这一点,打开日志事件查看器,然后过滤事件流以选择时间窗口和日志级别,并显示 Node Manager 源。您也可以通过命令访问日志。命令的格式如下:

 yarn logs -applicationId <application ID> [OPTIONS]

例如,以下是这些 ID 的有效命令:

 yarn logs -applicationId application_561453090098_0005 
 yarn logs -applicationId application_561453090070_0005 userid

请注意,用户 ID 是不同的。但是,只有在yarn-site.xml中的yarn.log-aggregation-enable为 true 并且应用程序已经完成执行时,才是真的。

使用 log4j 记录 Spark

Spark 使用log4j进行自身的日志记录。发生在后端的所有操作都会记录到 Spark shell 控制台(已配置为基础存储)。Spark 提供了log4j的属性文件模板,我们可以扩展和修改该文件以记录 Spark 中的日志。转到SPARK_HOME/conf目录,您应该看到log4j.properties.template文件。这可以作为我们自己日志系统的起点。

现在,让我们在运行 Spark 作业时创建自己的自定义日志系统。完成后,将文件重命名为log4j.properties并将其放在相同的目录(即项目树)下。文件的示例快照如下:

**图 17:**log4j.properties 文件的快照

默认情况下,所有内容都会输出到控制台和文件。但是,如果您想将所有噪音日志绕过并记录到系统文件中,例如/var/log/sparkU.log,则可以在log4j.properties文件中设置这些属性如下:

log4j.logger.spark.storage=INFO, RollingAppender
log4j.additivity.spark.storage=false
log4j.logger.spark.scheduler=INFO, RollingAppender
log4j.additivity.spark.scheduler=false
log4j.logger.spark.CacheTracker=INFO, RollingAppender
log4j.additivity.spark.CacheTracker=false
log4j.logger.spark.CacheTrackerActor=INFO, RollingAppender
log4j.additivity.spark.CacheTrackerActor=false
log4j.logger.spark.MapOutputTrackerActor=INFO, RollingAppender
log4j.additivity.spark.MapOutputTrackerActor=false
log4j.logger.spark.MapOutputTracker=INFO, RollingAppender
log4j.additivty.spark.MapOutputTracker=false

基本上,我们希望隐藏 Spark 生成的所有日志,以便我们不必在 shell 中处理它们。我们将它们重定向到文件系统中进行记录。另一方面,我们希望我们自己的日志记录在 shell 和单独的文件中进行记录,以便它们不会与 Spark 的日志混在一起。从这里,我们将指向 Splunk 的文件,其中我们自己的日志记录,特别是/var/log/sparkU.log.

然后,当应用程序启动时,Spark 会读取log4j.properties文件,因此我们除了将其放在指定位置外,无需进行其他操作。

现在让我们看看如何创建我们自己的日志记录系统。看看以下代码,并尝试理解这里发生了什么:

import org.apache.spark.{SparkConf, SparkContext}
import org.apache.log4j.LogManager
import org.apache.log4j.Level
import org.apache.log4j.Logger

object MyLog {
 def main(args: Array[String]):Unit= {
   // Stting logger level as WARN
   val log = LogManager.getRootLogger
   log.setLevel(Level.WARN)

   // Creating Spark Context
   val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
   val sc = new SparkContext(conf)

   //Started the computation and printing the logging information
   log.warn("Started")                        
   val data = sc.parallelize(1 to 100000)
   log.warn("Finished")
 }
}

前面的代码概念上仅记录警告消息。它首先打印警告消息,然后通过并行化从 1 到 100,000 的数字创建 RDD。一旦 RDD 作业完成,它会打印另一个警告日志。但是,我们尚未注意到先前代码段中的问题。

org.apache.log4j.Logger类的一个缺点是它不可序列化(有关更多详细信息,请参阅优化技术部分),这意味着我们不能在对 Spark API 的某些部分进行操作时在闭包内使用它。例如,如果尝试执行以下代码,则应该会遇到一个说任务不可序列化的异常:

object MyLog {
  def main(args: Array[String]):Unit= {
    // Stting logger level as WARN
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    // Creating Spark Context
    val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
    val sc = new SparkContext(conf)
    //Started the computation and printing the logging information
    log.warn("Started")
    val i = 0
    val data = sc.parallelize(i to 100000)
    data.foreach(i => log.info("My number"+ i))
    log.warn("Finished")
  }
}

解决这个问题也很容易;只需声明带有extends Serializable的 Scala 对象,现在代码看起来如下:

class MyMapper(n: Int) extends Serializable{
  @transient lazy val log = org.apache.log4j.LogManager.getLogger
                                ("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] =
   rdd.map{ i =>
    log.warn("mapping: " + i)
    (i + n).toString
  }
}

在前面的代码中发生的情况是,闭包无法整洁地分布到所有分区,因为它无法关闭记录器;因此,类型为MyMapper的整个实例分布到所有分区;一旦完成此操作,每个分区都会创建一个新的记录器并用于记录。

总之,以下是帮助我们摆脱这个问题的完整代码:

package com.example.Personal
import org.apache.log4j.{Level, LogManager, PropertyConfigurator}
import org.apache.spark._
import org.apache.spark.rdd.RDD

class MyMapper(n: Int) extends Serializable{
  @transient lazy val log = org.apache.log4j.LogManager.getLogger
                                ("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] =
   rdd.map{ i =>
    log.warn("Serialization of: " + i)
    (i + n).toString
  }
}

object MyMapper{
  def apply(n: Int): MyMapper = new MyMapper(n)
}

object MyLog {
  def main(args: Array[String]) {
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
    val sc = new SparkContext(conf)
    log.warn("Started")
    val data = sc.parallelize(1 to 100000)
    val mapper = MyMapper(1)
    val other = mapper.MyMapperDosomething(data)
    other.collect()
    log.warn("Finished")
  }
}

输出如下:

17/04/29 15:33:43 WARN root: Started 
.
.
17/04/29 15:31:51 WARN myLogger: mapping: 1 
17/04/29 15:31:51 WARN myLogger: mapping: 49992
17/04/29 15:31:51 WARN myLogger: mapping: 49999
17/04/29 15:31:51 WARN myLogger: mapping: 50000 
.
. 
17/04/29 15:31:51 WARN root: Finished

我们将在下一节讨论 Spark 的内置日志记录。

Spark 配置

有多种方法可以配置您的 Spark 作业。在本节中,我们将讨论这些方法。更具体地说,根据 Spark 2.x 版本,有三个位置可以配置系统:

  • Spark 属性

  • 环境变量

  • 日志记录

Spark 属性

如前所述,Spark 属性控制大部分应用程序特定的参数,并且可以使用 Spark 的SparkConf对象进行设置。或者,这些参数可以通过 Java 系统属性进行设置。SparkConf允许您配置一些常见属性,如下所示:

setAppName() // App name 
setMaster() // Master URL 
setSparkHome() // Set the location where Spark is installed on worker nodes. 
setExecutorEnv() // Set single or multiple environment variables to be used when launching executors. 
setJars() // Set JAR files to distribute to the cluster. 
setAll() // Set multiple parameters together.

应用程序可以配置为使用计算机上的多个可用核心。例如,我们可以初始化一个具有两个线程的应用程序如下。请注意,我们使用local [2]运行,表示两个线程,这代表最小的并行性,并使用local [*],它利用计算机上所有可用的核心。或者,您可以在提交 Spark 作业时使用以下 spark-submit 脚本指定执行程序的数量:

val conf = new SparkConf() 
             .setMaster("local[2]") 
             .setAppName("SampleApp") 
val sc = new SparkContext(conf)

可能会有一些特殊情况,您需要在需要时动态加载 Spark 属性。您可以在通过 spark-submit 脚本提交 Spark 作业时执行此操作。更具体地说,您可能希望避免在SparkConf中硬编码某些配置。

Apache Spark 优先级:

Spark 对提交的作业具有以下优先级:来自配置文件的配置具有最低优先级。来自实际代码的配置相对于来自配置文件的配置具有更高的优先级,而通过 Spark-submit 脚本通过 CLI 传递的配置具有更高的优先级。

例如,如果要使用不同的主节点、执行程序或不同数量的内存运行应用程序,Spark 允许您简单地创建一个空配置对象,如下所示:

val sc = new SparkContext(new SparkConf())

然后您可以在运行时为您的 Spark 作业提供配置,如下所示:

SPARK_HOME/bin/spark-submit 
 --name "SmapleApp" \
 --class org.apache.spark.examples.KMeansDemo \
 --master mesos://207.184.161.138:7077 \ # Use your IP address
 --conf spark.eventLog.enabled=false 
 --conf "spark.executor.extraJavaOptions=-XX:+PrintGCDetails" \ 
 --deploy-mode cluster \
 --supervise \
 --executor-memory 20G \
 myApp.jar

SPARK_HOME/bin/spark-submit还将从SPARK_HOME /conf/spark-defaults.conf中读取配置选项,其中每行由空格分隔的键和值组成。示例如下:

spark.master  spark://5.6.7.8:7077 
spark.executor.memor y   4g 
spark.eventLog.enabled true 
spark.serializer org.apache.spark.serializer.KryoSerializer

在属性文件中指定为标志的值将传递给应用程序,并与通过SparkConf指定的值合并。最后,如前所述,应用程序 Web UI 在http://<driver>:4040下的环境选项卡下列出所有 Spark 属性。

环境变量

环境变量可用于设置计算节点或机器设置中的设置。例如,IP 地址可以通过每个计算节点上的conf/spark-env.sh脚本进行设置。以下表列出了需要设置的环境变量的名称和功能:

**图 18:**环境变量及其含义

日志记录

最后,可以通过在 Spark 应用程序树下的log4j.properties文件中配置日志记录,如前一节所述。Spark 使用 log4j 进行日志记录。log4j 支持几个有效的日志记录级别,它们如下:

日志级别 用途
OFF 这是最具体的,完全不允许记录日志
FATAL 这是最具体的,显示了有很少数据的严重错误
ERROR 这只显示一般错误
WARN 这显示了建议修复但不是强制的警告
INFO 这显示了您的 Spark 作业所需的信息
DEBUG 在调试时,这些日志将被打印
TRACE 这提供了具有大量数据的最不具体的错误跟踪
ALL 具有所有数据的最不具体的消息

表 1: 使用 log4j 和 Spark 的日志级别

您可以在conf/log4j.properties中设置 Spark shell 的默认日志记录。在独立的 Spark 应用程序中或在 Spark Shell 会话中,可以使用conf/log4j.properties.template作为起点。在本章的前一节中,我们建议您在像 Eclipse 这样的基于 IDE 的环境中将log4j.properties文件放在项目目录下。但是,要完全禁用日志记录,您应该将以下conf/log4j.properties.template设置为log4j.properties。只需将log4j.logger.org标志设置为 OFF,如下所示:

log4j.logger.org=OFF

在下一节中,我们将讨论开发和提交 Spark 作业时开发人员或程序员常犯的一些常见错误。

Spark 应用程序开发中的常见错误

经常发生的常见错误包括应用程序失败、由于多种因素而卡住的作业运行缓慢、聚合操作中的错误、动作或转换中的错误、主线程中的异常,当然还有内存不足OOM)。

应用程序失败

大多数情况下,应用程序失败是因为一个或多个阶段最终失败。如本章前面所述,Spark 作业包括多个阶段。阶段不是独立执行的:例如,处理阶段无法在相关的输入读取阶段之前发生。因此,假设阶段 1 成功执行,但阶段 2 无法执行,整个应用程序最终将失败。可以如下所示:

图 19: 典型 Spark 作业中的两个阶段

举个例子,假设您有以下三个 RDD 操作作为阶段。可以将其可视化为图 20图 21图 22所示:

val rdd1 = sc.textFile(“hdfs://data/data.csv”)
                       .map(someMethod)
                       .filter(filterMethod)   

图 20: rdd1 的第 1 阶段

val rdd2 = sc.hadoopFile(“hdfs://data/data2.csv”)
                      .groupByKey()
                      .map(secondMapMethod)

从概念上讲,可以如图 21所示,首先使用hadoopFile()方法解析数据,然后使用groupByKey()方法对其进行分组,最后对其进行映射:

图 21: rdd2 的第 2 阶段

val rdd3 = rdd1.join(rdd2).map(thirdMapMethod)

从概念上讲,可以如图 22所示,首先解析数据,然后将其连接,最后映射:

图 22: rdd3 的第 3 阶段

现在,您可以执行聚合函数,例如 collect,如下所示:

rdd3.collect()

噢!您已经开发了一个包含三个阶段的 Spark 作业。从概念上讲,可以如下所示:

图 23: rdd3.collect()操作的三个阶段

现在,如果其中一个阶段失败,您的作业最终将失败。因此,最终的rdd3.collect()语句将抛出有关阶段失败的异常。此外,您可能会遇到以下四个因素的问题:

  • 聚合操作中的错误

  • 主线程中的异常

  • OOP

  • 使用spark-submit脚本提交作业时出现类找不到异常

  • Spark 核心库中一些 API/方法的误解

为了摆脱上述问题,我们的一般建议是确保在执行任何 map、flatMap 或 aggregate 操作时没有犯任何错误。其次,在使用 Java 或 Scala 开发应用程序的主方法中确保没有缺陷。有时您在代码中看不到任何语法错误,但重要的是您为应用程序开发了一些小的测试用例。主方法中最常见的异常如下:

  • java.lang.noclassdeffounderror

  • java.lang.nullpointerexception

  • java.lang.arrayindexoutofboundsexception

  • java.lang.stackoverflowerror

  • java.lang.classnotfoundexception

  • java.util.inputmismatchexception

通过谨慎编写 Spark 应用程序可以避免这些异常。或者,广泛使用 Eclipse(或任何其他 IDE)的代码调试功能来消除语义错误以避免异常。对于第三个问题,即 OOM,这是一个非常常见的问题。需要注意的是,Spark 至少需要 8GB 的主内存,并且独立模式下需要足够的磁盘空间。另一方面,为了获得完整的集群计算功能,这个要求通常很高。

准备一个包含所有依赖项的 JAR 文件来执行 Spark 作业非常重要。许多从业者使用谷歌的 Guava;它包含在大多数发行版中,但不能保证向后兼容。这意味着有时即使您明确提供了 Guava 类,您的 Spark 作业也找不到 Guava 类;这是因为 Guava 库的两个版本中的一个优先于另一个,而这个版本可能不包括所需的类。为了解决这个问题,通常会使用 shading。

确保如果您使用 IntelliJ、Vim、Eclipse、记事本等编码,已使用-Xmx 参数设置了 Java 堆空间,并设置了足够大的值。在集群模式下工作时,应在使用 Spark-submit 脚本提交 Spark 作业时指定执行器内存。假设您有一个要解析的 CSV 文件,并使用随机森林分类器进行一些预测分析,您可能需要指定正确的内存量,比如 20GB,如下所示:

--executor-memory 20G

即使收到 OOM 错误,您也可以将此金额增加到 32GB 或更多。由于随机森林计算密集,需要更大的内存,这只是随机森林的一个例子。您可能在仅解析数据时遇到类似的问题。甚至由于此 OOM 错误,特定阶段可能会失败。因此,请确保您知道这个错误。

对于class not found exception,请确保您已经在生成的 JAR 文件中包含了主类。JAR 文件应该准备好包含所有依赖项,以便在集群节点上执行您的 Spark 作业。我们将在第十七章中提供一份逐步的 JAR 准备指南,时候去集群 - 在集群上部署 Spark

对于最后一个问题,我们可以提供一些关于 Spark Core 库的一些误解的例子。例如,当您使用wholeTextFiles方法从多个文件准备 RDDs 或 DataFrames 时,Spark 不会并行运行;在 YARN 的集群模式下,有时可能会耗尽内存。

有一次,我遇到了一个问题,首先我将六个文件从我的 S3 存储复制到 HDFS。然后,我尝试创建一个 RDD,如下所示:

sc.wholeTextFiles("/mnt/temp") // note the location of the data files is /mnt/temp/

然后,我尝试使用 UDF 逐行处理这些文件。当我查看我的计算节点时,我发现每个文件只有一个执行器在运行。然而,后来我收到了一条错误消息,说 YARN 已经耗尽了内存。为什么呢?原因如下:

  • wholeTextFiles的目标是每个要处理的文件只有一个执行器

  • 如果您使用.gz文件,例如,您将每个文件只有一个执行器,最多

慢作业或无响应

有时,如果 SparkContext 无法连接到 Spark 独立主节点,那么驱动程序可能会显示以下错误:

02/05/17 12:44:45 ERROR AppClient$ClientActor: All masters are unresponsive! Giving up. 
02/05/17 12:45:31 ERROR SparkDeploySchedulerBackend: Application has been killed. Reason: All masters are unresponsive! Giving up. 
02/05/17 12:45:35 ERROR TaskSchedulerImpl: Exiting due to error from cluster scheduler: Spark cluster looks down

在其他时候,驱动程序能够连接到主节点,但主节点无法与驱动程序进行通信。然后,尝试多次连接,即使驱动程序会报告无法连接到主节点的日志目录。

此外,您可能经常会在 Spark 作业中经历非常缓慢的性能和进展。这是因为您的驱动程序程序计算速度不够快。正如前面讨论的,有时特定阶段可能需要比平常更长的时间,因为可能涉及洗牌、映射、连接或聚合操作。即使计算机的磁盘存储或主内存用尽,您也可能会遇到这些问题。例如,如果您的主节点没有响应,或者在一定时间内计算节点出现不响应,您可能会认为您的 Spark 作业在某个阶段停滞不前。

**图 24:**执行器/驱动程序不响应的示例日志

可能的解决方案可能有几种,包括以下内容:

  1. 请确保工作节点和驱动程序正确配置为连接到 Spark 主节点上的确切地址,该地址在 Spark 主节点 web UI/logs 中列出。然后,在启动 Spark shell 时明确提供 Spark 集群的主 URL:
 $ bin/spark-shell --master spark://master-ip:7077

  1. SPARK_LOCAL_IP设置为驱动程序、主节点和工作进程的集群可寻址主机名。

有时,由于硬件故障,我们会遇到一些问题。例如,如果计算节点中的文件系统意外关闭,即 I/O 异常,您的 Spark 作业最终也会失败。这是显而易见的,因为您的 Spark 作业无法将生成的 RDD 或数据写入本地文件系统或 HDFS。这也意味着由于阶段失败,DAG 操作无法执行。

有时,这种 I/O 异常是由底层磁盘故障或其他硬件故障引起的。这通常会提供日志,如下所示:

**图 25:**文件系统关闭示例

然而,您经常会遇到作业计算性能较慢的问题,因为您的 Java GC 有些忙碌,或者无法快速进行 GC。例如,以下图显示了对于任务 0,完成 GC 花了 10 个小时!我在 2014 年遇到了这个问题,当时我刚开始使用 Spark。然而,这些问题的控制并不在我们手中。因此,我们的建议是您应该释放 JVM 并尝试重新提交作业。

**图 26:**GC 在中间停滞的示例

第四个因素可能是响应缓慢或作业性能较慢是由于数据序列化不足。这将在下一节中讨论。第五个因素可能是代码中的内存泄漏,这将导致应用程序消耗更多内存,使文件或逻辑设备保持打开状态。因此,请确保没有导致内存泄漏的选项。例如,通过调用sc.stop()spark.stop()完成您的 Spark 应用程序是一个好习惯。这将确保一个 SparkContext 仍然是打开和活动的。否则,您可能会遇到不必要的异常或问题。第六个问题是我们经常保持太多的打开文件,有时会在洗牌或合并阶段中创建FileNotFoundException

优化技术

有几个方面可以调整 Spark 应用程序以实现更好的优化技术。在本节中,我们将讨论如何通过调整主内存和更好的内存管理来进一步优化我们的 Spark 应用程序,通过应用数据序列化来优化性能。另一方面,通过在开发 Spark 应用程序时调整 Scala 代码中的数据结构,也可以优化性能。另外,通过利用序列化 RDD 存储,可以很好地维护存储。

最重要的一个方面是垃圾收集,以及如果您使用 Java 或 Scala 编写了 Spark 应用程序,则需要调整。我们将看看如何为优化性能调整这一点。对于分布式环境和基于集群的系统,必须确保一定程度的并行性和数据局部性。此外,通过使用广播变量,性能还可以进一步提高。

数据序列化

序列化是任何分布式计算环境中性能改进和优化的重要调整。Spark 也不例外,但 Spark 作业通常涉及数据和计算。因此,如果您的数据对象格式不好,那么您首先需要将它们转换为序列化数据对象。这需要大量的内存字节。最终,整个过程将严重减慢整个处理和计算的速度。

因此,您经常会发现计算节点的响应速度很慢。这意味着我们有时无法充分利用计算资源。事实上,Spark 试图在便利性和性能之间保持平衡。这也意味着数据序列化应该是 Spark 调整性能的第一步。

Spark 提供了两种数据序列化选项:Java 序列化和 Kryo 序列化库:

  • Java 序列化: Spark 使用 Java 的ObjectOutputStream框架对对象进行序列化。您可以通过创建任何实现java.io.Serializable的类来处理序列化。Java 序列化非常灵活,但通常相当慢,不适合大数据对象序列化。

  • Kryo 序列化: 您还可以使用 Kryo 库更快地序列化数据对象。与 Java 序列化相比,Kryo 序列化速度更快,速度提高了 10 倍,比 Java 更紧凑。但是,它有一个问题,即它不支持所有可序列化类型,但您需要要求您的类进行注册。

您可以通过初始化 Spark 作业并调用conf.set(spark.serializer, org.apache.spark.serializer.KryoSerializer)来开始使用 Kryo。要使用 Kryo 注册自定义类,请使用registerKryoClasses方法,如下所示:

val conf = new SparkConf()
               .setMaster(“local[*]”)
               .setAppName(“MyApp”)
conf.registerKryoClasses(Array(classOf[MyOwnClass1], classOf[MyOwnClass2]))
val sc = new SparkContext(conf)

如果您的对象很大,您可能还需要增加spark.kryoserializer.buffer配置。这个值需要足够大,以容纳您序列化的最大对象。最后,如果您没有注册自定义类,Kryo 仍然可以工作;但是,每个对象的完整类名都需要被存储,这实际上是浪费的。

例如,在监控 Spark 作业部分的日志子部分中,可以使用Kryo序列化来优化日志记录和计算。首先,只需创建MyMapper类作为普通类(即,不进行任何序列化),如下所示:

class MyMapper(n: Int) { // without any serialization
  @transient lazy val log = org.apache.log4j.LogManager.getLogger("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] = rdd.map { i =>
    log.warn("mapping: " + i)
    (i + n).toString
  }
}

现在,让我们将这个类注册为Kyro序列化类,然后设置Kyro序列化如下:

conf.registerKryoClasses(Array(classOf[MyMapper])) // register the class with Kyro
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") // set Kayro serialization

这就是你需要的全部内容。此示例的完整源代码如下。您应该能够运行并观察相同的输出,但与上一个示例相比是优化的:

package com.chapter14.Serilazition
import org.apache.spark._
import org.apache.spark.rdd.RDD
class MyMapper(n: Int) { // without any serilization
  @transient lazy val log = org.apache.log4j.LogManager.getLogger
                                ("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] = rdd.map { i =>
    log.warn("mapping: " + i)
    (i + n).toString
  }
}
//Companion object
object MyMapper {
  def apply(n: Int): MyMapper = new MyMapper(n)
}
//Main object
object KyroRegistrationDemo {
  def main(args: Array[String]) {
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    val conf = new SparkConf()
      .setAppName("My App")
      .setMaster("local[*]")
    conf.registerKryoClasses(Array(classOf[MyMapper2]))
     // register the class with Kyro
    conf.set("spark.serializer", "org.apache.spark.serializer
             .KryoSerializer") // set Kayro serilazation
    val sc = new SparkContext(conf)
    log.warn("Started")
    val data = sc.parallelize(1 to 100000)
    val mapper = MyMapper(1)
    val other = mapper.MyMapperDosomething(data)
    other.collect()
    log.warn("Finished")
  }
}

输出如下:

17/04/29 15:33:43 WARN root: Started 
.
.
17/04/29 15:31:51 WARN myLogger: mapping: 1 
17/04/29 15:31:51 WARN myLogger: mapping: 49992
17/04/29 15:31:51 WARN myLogger: mapping: 49999
17/04/29 15:31:51 WARN myLogger: mapping: 50000 
.
.                                                                                
17/04/29 15:31:51 WARN root: Finished

干得好!现在让我们快速看一下如何调整内存。我们将在下一节中看一些高级策略,以确保主内存的有效使用。

内存调整

在本节中,我们将讨论一些高级策略,用户可以使用这些策略来确保在执行 Spark 作业时对内存的有效使用。更具体地说,我们将展示如何计算对象的内存使用情况。我们将建议一些高级方法来通过优化数据结构或将数据对象转换为使用 Kryo 或 Java 序列化的序列化格式来改进它。最后,我们将看看如何调整 Spark 的 Java 堆大小、缓存大小和 Java 垃圾收集器。

调整内存使用时有三个考虑因素:

  • 您的对象使用的内存量:甚至可能希望整个数据集都能放入内存中

  • 访问这些对象的成本

  • 垃圾收集的开销:如果对象的周转率很高

尽管 Java 对象访问速度足够快,但它们很容易消耗实际数据字段的 2 到 5 倍的空间。例如,每个不同的 Java 对象都有 16 字节的开销与对象头。例如,Java 字符串比原始字符串多出近 40 字节的额外开销。此外,还使用了 Java 集合类,如SetListQueueArrayListVectorLinkedListPriorityQueueHashSetLinkedHashSetTreeSet等。另一方面,链式数据结构过于复杂,占用了太多额外的空间,因为数据结构中的每个条目都有一个包装对象。最后,基本类型的集合经常将它们存储在内存中作为装箱对象,例如java.lang.Doublejava.lang.Integer

内存使用和管理

您的 Spark 应用程序和底层计算节点的内存使用可以分为执行和存储。执行内存用于合并、洗牌、连接、排序和聚合计算过程中。另一方面,存储内存用于在集群中缓存和传播内部数据。简而言之,这是由于网络上的大量 I/O。

从技术上讲,Spark 将网络数据缓存在本地。在使用 Spark 进行迭代或交互式工作时,缓存或持久化是 Spark 中的优化技术。这两种技术有助于保存中间部分结果,以便它们可以在后续阶段重复使用。然后这些中间结果(作为 RDD)可以保存在内存(默认)或更可靠的存储介质,如磁盘,并/或复制。此外,RDD 也可以使用缓存操作进行缓存。它们也可以使用持久化操作进行持久化。缓存和持久化操作之间的区别纯粹是语法上的。缓存是持久化或持久化(MEMORY_ONLY)的同义词,即缓存仅以默认存储级别MEMORY_ONLY进行持久化。

如果您在 Spark web UI 中转到存储选项卡,您应该观察 RDD、DataFrame 或 Dataset 对象使用的内存/存储,如图 10所示。尽管在 Spark 中有两个相关的内存调整配置,用户不需要重新调整它们。原因是配置文件中设置的默认值足以满足您的需求和工作负载。

spark.memory.fraction 是统一区域大小占(JVM 堆空间-300 MB)的比例(默认为 0.6)。其余空间(40%)用于用户数据结构、Spark 内部元数据和防止在稀疏和异常大记录的情况下发生 OOM 错误。另一方面,spark.memory.storageFraction表示 R 存储空间大小占统一区域的比例(默认为 0.5)。该参数的默认值是 Java 堆空间的 50%,即 300 MB。

有关内存使用和存储的更详细讨论,请参阅第十五章,使用 Spark ML 进行文本分析

现在,您可能会想到一个问题:选择哪种存储级别?为了回答这个问题,Spark 存储级别为您提供了在内存使用和 CPU 效率之间的不同权衡。如果您的 RDD 与默认存储级别(MEMORY_ONLY)相适应,请让您的 Spark driver 或 master 使用它。这是最节省内存的选项,允许对 RDD 进行的操作尽可能快地运行。您应该让它使用这个选项,因为这是最节省内存的选项。这也允许对 RDD 进行的众多操作尽可能快地完成。

如果您的 RDD 不适合主内存,也就是说,如果MEMORY_ONLY不起作用,您应该尝试使用MEMORY_ONLY_SER。强烈建议不要将 RDD 溢出到磁盘,除非您的UDF(即为处理数据集定义的用户定义函数)太昂贵。如果您的 UDF 在执行阶段过滤了大量数据,也适用于此。在其他情况下,重新计算分区,即重新分区,可能更快地从磁盘读取数据对象。最后,如果您希望快速故障恢复,请使用复制的存储级别。

总之,在 Spark 2.x 中支持以下 StorageLevels:(名称中的数字 _2 表示 2 个副本):

  • DISK_ONLY: 这是 RDD 的基于磁盘的操作

  • DISK_ONLY_2: 这是 RDD 的基于磁盘的操作,带有 2 个副本

  • MEMORY_ONLY: 这是 RDD 的内存缓存操作的默认值

  • MEMORY_ONLY_2: 这是 RDD 具有 2 个副本的内存缓存操作的默认值

  • MEMORY_ONLY_SER: 如果您的 RDD 不适合主内存,也就是说,如果MEMORY_ONLY不起作用,这个选项特别有助于以序列化形式存储数据对象

  • MEMORY_ONLY_SER_2: 如果您的 RDD 不适合主内存,也就是说,如果MEMORY_ONLY不适合 2 个副本,这个选项也有助于以序列化形式存储数据对象

  • MEMORY_AND_DISK: 基于内存和磁盘(也称为组合)的 RDD 持久性

  • MEMORY_AND_DISK_2: 基于内存和磁盘(也称为组合)的 RDD 持久性,带有 2 个副本

  • MEMORY_AND_DISK_SER: 如果MEMORY_AND_DISK不起作用,可以使用它

  • MEMORY_AND_DISK_SER_2: 如果MEMORY_AND_DISK不适用于 2 个副本,可以使用此选项

  • OFF_HEAP: 不允许写入 Java 堆空间

请注意,缓存是持久化的同义词(MEMORY_ONLY)。这意味着缓存仅使用默认存储级别,即MEMORY_ONLY。详细信息可以在jaceklaskowski.gitbooks.io/mastering-apache-spark/content/spark-rdd-StorageLevel.html找到。

调整数据结构

减少额外内存使用的第一种方法是避免 Java 数据结构中的一些特性,这些特性会带来额外的开销。例如,基于指针的数据结构和包装对象会导致非常大的开销。为了调整您的源代码以使用更好的数据结构,我们在这里提供了一些建议,这可能会有所帮助。

首先,设计您的数据结构,以便更多地使用对象和基本类型的数组。因此,这也建议更频繁地使用标准的 Java 或 Scala 集合类,如SetListQueueArrayListVectorLinkedListPriorityQueueHashSetLinkedHashSetTreeSet

其次,尽可能避免使用具有大量小对象和指针的嵌套结构,以使您的源代码更加优化和简洁。第三,尽可能考虑使用数字 ID,有时使用枚举对象而不是使用字符串作为键。这是因为,正如我们已经提到的,单个 Java 字符串对象会产生额外的 40 字节开销。最后,如果您的主内存(即 RAM)少于 32GB,请设置 JVM 标志-XX:+UseCompressedOops,以使指针为 4 字节而不是 8 字节。

早期的选项可以在SPARK_HOME/conf/spark-env.sh.template中设置。只需将文件重命名为spark-env.sh并立即设置值!

序列化 RDD 存储

正如前面讨论的,尽管有其他类型的内存调整,但当您的对象太大而无法有效地适应主内存或磁盘时,减少内存使用的一个更简单和更好的方法是以序列化形式存储它们。

这可以通过 RDD 持久性 API 中的序列化存储级别(如MEMORY_ONLY_SER)来实现。有关更多信息,请参阅前一节关于内存管理的内容,并开始探索可用的选项。

如果您指定使用MEMORY_ONLY_SER,Spark 将把每个 RDD 分区存储为一个大的字节数组。然而,这种方法的唯一缺点是它可能会减慢数据访问速度。这是合理的,也很明显;公平地说,没有办法避免它,因为每个对象都需要在重用时动态反序列化。

如前所述,我们强烈建议使用 Kryo 序列化而不是 Java 序列化,以使数据访问速度更快。

垃圾收集调优

尽管在您的 Java 或 Scala 程序中,只是顺序或随机读取 RDD 一次,然后对其执行大量操作并不是一个主要问题,但是如果您的驱动程序中存储了大量数据对象,就会导致Java 虚拟机JVM)GC 变得棘手和复杂。当 JVM 需要从旧对象中删除过时和未使用的对象以为新对象腾出空间时,有必要识别它们并最终从内存中删除它们。然而,这在处理时间和存储方面是一项昂贵的操作。您可能会想到 GC 的成本与存储在主内存中的 Java 对象数量成正比。因此,我们强烈建议您调整数据结构。此外,建议减少存储在内存中的对象数量。

GC 调优的第一步是收集有关 JVM 在您的计算机上频繁进行垃圾收集的相关统计信息。在这方面需要的第二个统计数据是 JVM 在您的计算机或计算节点上花费在 GC 上的时间。这可以通过在您的 IDE(如 Eclipse)中的 JVM 启动参数中添加-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps来实现,并指定 GC 日志文件的名称和位置,如下所示:

**图 27:**在 Eclipse 上设置 GC 详细信息

或者,您可以在使用 Spark-submit 脚本提交 Spark 作业时指定verbose:gc,如下所示:

--conf “spark.executor.extraJavaOptions = -verbose:gc -XX:-PrintGCDetails -XX:+PrintGCTimeStamps"

简而言之,在为 Spark 指定 GC 选项时,您必须确定要在执行程序或驱动程序上指定 GC 选项。当您提交作业时,指定--driver-java-options -XX:+PrintFlagsFinal -verbose:gc等。对于执行程序,指定--conf spark.executor.extraJavaOptions=-XX:+PrintFlagsFinal -verbose:gc等。

现在,当执行您的 Spark 作业时,每次发生 GC 时,您都可以在工作节点的/var/log/logs中看到打印的日志和消息。这种方法的缺点是这些日志不会出现在您的驱动程序上,而是出现在集群的工作节点上。

需要注意的是,verbose:gc只会在每次 GC 收集后打印适当的消息或日志。相应地,它会打印有关内存的详细信息。但是,如果您对寻找更严重的问题(如内存泄漏)感兴趣,verbose:gc可能不够。在这种情况下,您可以使用一些可视化工具,如 jhat 和 VisualVM。您可以在databricks.com/blog/2015/05/28/tuning-java-garbage-collection-for-spark-applications.html上阅读有关在 Spark 应用程序中进行更好的 GC 调优的信息。

并行级别

尽管您可以通过SparkContext.text文件的可选参数来控制要执行的映射任务的数量,但 Spark 会根据文件的大小自动设置每个文件的映射任务数量。此外,对于分布式的reduce操作,如groupByKeyreduceByKey,Spark 会使用最大父 RDD 的分区数。然而,有时我们会犯一个错误,即未充分利用计算集群中节点的全部计算资源。因此,除非您明确设置和指定 Spark 作业的并行级别,否则将无法充分利用全部计算资源。因此,您应该将并行级别设置为第二个参数。

有关此选项的更多信息,请参阅spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.rdd.PairRDDFunctions.

或者,您可以通过设置配置属性 spark.default.parallelism 来更改默认设置。对于没有父 RDD 的并行操作,并行级别取决于集群管理器,即独立模式、Mesos 或 YARN。对于本地模式,将并行级别设置为本地机器上的核心数。对于 Mesos 或 YARN,将细粒度模式设置为 8。在其他情况下,所有执行程序节点上的总核心数或 2,以较大者为准,通常建议在集群中每个 CPU 核心使用 2-3 个任务。

广播

广播变量使 Spark 开发人员能够在每个驱动程序程序上缓存一个实例或类变量的只读副本,而不是将其自己的副本与依赖任务一起传输。但是,只有当多个阶段的任务需要以反序列化形式的相同数据时,显式创建广播变量才有用。

在 Spark 应用程序开发中,使用 SparkContext 的广播选项可以大大减小每个序列化任务的大小。这也有助于减少在集群中启动 Spark 作业的成本。如果您的 Spark 作业中有某个任务使用了驱动程序中的大对象,您应该将其转换为广播变量。

要在 Spark 应用程序中使用广播变量,可以使用SparkContext.broadcast进行实例化。然后,使用该类的 value 方法来访问共享值,如下所示:

val m = 5
val bv = sc.broadcast(m)

输出/日志:bv: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(0)

bv.value()

输出/日志:res0: Int = 1

图 28: 从驱动程序向执行程序广播一个值

Spark 的广播功能使用SparkContext创建广播值。之后,BroadcastManagerContextCleaner用于控制它们的生命周期,如下图所示:

图 29: SparkContext 使用 BroadcastManager 和 ContextCleaner 广播变量/值

驱动程序中的 Spark 应用程序会自动打印每个任务在驱动程序上的序列化大小。因此,您可以决定您的任务是否过大而无法并行。如果您的任务大于 20 KB,可能值得优化。

数据本地性

数据本地性意味着数据与要处理的代码的接近程度。从技术上讲,数据本地性对于在本地或集群模式下执行的 Spark 作业的性能可能会产生重大影响。因此,如果数据和要处理的代码是绑定在一起的,计算速度应该会更快。通常情况下,从驱动程序向执行程序发送序列化代码要快得多,因为代码大小比数据小得多。

在 Spark 应用程序开发和作业执行中,存在几个级别的本地性。从最接近到最远,级别取决于您需要处理的数据的当前位置:

数据本地性 含义 特殊说明
PROCESS_LOCAL 数据和代码位于同一位置 最佳的位置可能
NODE_LOCAL 数据和代码位于同一节点上,例如,存储在 HDFS 上的数据 PROCESS_LOCAL慢一点,因为数据必须在进程和网络之间传播
NO_PREF 数据可以从其他地方平等访问 没有位置偏好
RACK_LOCAL 数据位于同一机架上的服务器上 适用于大规模数据处理
ANY 数据在网络的其他地方,不在同一机架上 除非没有其他选择,否则不建议使用

表 2: 数据位置和 Spark

Spark 被开发成优先在最佳位置调度所有任务,但这并不是保证的,也并非总是可能的。因此,基于计算节点的情况,如果可用的计算资源过于繁忙,Spark 会切换到较低的位置级别。此外,如果您想要最佳的数据位置,有两种选择:

  • 等待繁忙的 CPU 空闲下来,在同一台服务器或同一节点上启动任务

  • 立即开始一个新的任务,需要将数据移动到那里

总结

在本章中,我们讨论了一些关于 Spark 的高级主题,以使您的 Spark 作业性能更好。我们讨论了一些调整 Spark 作业的基本技术。我们讨论了如何通过访问 Spark web UI 来监视您的作业。我们讨论了如何设置 Spark 配置参数。我们还讨论了一些 Spark 用户常见的错误,并提供了一些建议。最后,我们讨论了一些优化技术,帮助调整 Spark 应用程序。

在下一章中,您将看到如何测试 Spark 应用程序并调试以解决最常见的问题。

第十七章:前往集群之地的时候——在集群上部署 Spark

"我看见月亮像一块剪下的银子。星星像镀金的蜜蜂一样围绕着她"

  • 奥斯卡·王尔德

在前几章中,我们已经看到如何使用不同的 Spark API 开发实际应用程序。然而,在本章中,我们将看到 Spark 在集群模式下的工作方式及其底层架构。最后,我们将看到如何在集群上部署完整的 Spark 应用程序。简而言之,本章将涵盖以下主题:

  • 集群中的 Spark 架构

  • Spark 生态系统和集群管理

  • 在集群上部署 Spark

  • 在独立集群上部署 Spark

  • 在 Mesos 集群上部署 Spark

  • 在 YARN 集群上部署 Spark

  • 基于云的部署

  • 在 AWS 上部署 Spark

集群中的 Spark 架构

基于 Hadoop 的 MapReduce 框架在过去几年被广泛使用;然而,它在 I/O、算法复杂性、低延迟流式作业和完全基于磁盘的操作方面存在一些问题。Hadoop 提供了 Hadoop 分布式文件系统(HDFS)来进行高效的计算和廉价存储大数据,但你只能使用基于 Hadoop 的 MapReduce 框架进行高延迟批处理模型或静态数据的计算。Spark 为我们带来的主要大数据范式是引入了内存计算和缓存抽象。这使得 Spark 非常适合大规模数据处理,并使计算节点能够通过访问相同的输入数据执行多个操作。

Spark 的弹性分布式数据集(RDD)模型可以做到 MapReduce 范式所能做的一切,甚至更多。然而,Spark 可以在规模上对数据集进行迭代计算。这个选项有助于以更快的速度执行机器学习、通用数据处理、图分析和结构化查询语言(SQL)算法,无论是否依赖于 Hadoop。因此,此时重振 Spark 生态系统是一个需求。

足够了解 Spark 的美丽和特性。此时,重振 Spark 生态系统是您了解 Spark 如何工作的需求。

Spark 生态系统简介

为了为您提供更先进和额外的大数据处理能力,您的 Spark 作业可以在基于 Hadoop(又名 YARN)或基于 Mesos 的集群上运行。另一方面,Spark 中的核心 API 是用 Scala 编写的,使您能够使用多种编程语言(如 Java、Scala、Python 和 R)开发您的 Spark 应用程序。Spark 提供了几个库,这些库是 Spark 生态系统的一部分,用于通用数据处理和分析、图处理、大规模结构化 SQL 和机器学习(ML)领域的额外功能。Spark 生态系统包括以下组件:

图 1: Spark 生态系统(截至 Spark 2.1.0)

Spark 的核心引擎是用 Scala 编写的,但支持不同的语言来开发您的 Spark 应用程序,如 R、Java、Python 和 Scala。Spark 核心引擎中的主要组件/ API 如下:

  1. SparkSQL:这有助于无缝地将 SQL 查询与 Spark 程序混合在一起,以便在 Spark 程序内查询结构化数据。

  2. Spark Streaming:这是用于大规模流应用程序开发的,提供了与其他流数据源(如 Kafka、Flink 和 Twitter)无缝集成的 Spark。

  3. SparkMLlib 和 SparKML:这些是用于基于 RDD 和数据集/ DataFrame 的机器学习和管道创建。

  4. GraphX:这是用于大规模图计算和处理,使您的图数据对象完全连接。

  5. SparkR:R on Spark 有助于基本的统计计算和机器学习。

正如我们已经提到的,可以无缝地结合这些 API 来开发大规模的机器学习和数据分析应用程序。此外,Spark 作业可以通过 Hadoop YARN、Mesos 和独立的集群管理器提交和执行,也可以通过访问数据存储和源(如 HDFS、Cassandra、HBase、Amazon S3 甚至 RDBMS)在云中执行。然而,要充分利用 Spark 的功能,我们需要在计算集群上部署我们的 Spark 应用程序。

集群设计

Apache Spark 是一个分布式和并行处理系统,它还提供了内存计算能力。这种类型的计算范式需要一个关联的存储系统,以便您可以在大数据集群上部署您的应用程序。为了实现这一点,您将需要使用 HDFS、S3、HBase 和 Hive 等分布式存储系统。为了移动数据,您将需要其他技术,如 Sqoop、Kinesis、Twitter、Flume 和 Kafka。

在实践中,您可以很容易地配置一个小型的 Hadoop 集群。您只需要一个主节点和多个工作节点。在您的 Hadoop 集群中,通常一个主节点包括 NameNodes、DataNodes、JobTracker 和 TaskTracker。另一方面,工作节点可以配置为既作为 DataNode 又作为 TaskTracker。

出于安全原因,大多数大数据集群可能会设置在网络防火墙后,以便计算节点可以克服或至少减少防火墙造成的复杂性。否则,计算节点无法从网络外部访问,即外部网络。以下图片显示了一个常用的 Spark 简化大数据集群:

**图 2:**带有 JVM 的大数据处理的一般架构

上图显示了一个由五个计算节点组成的集群。每个节点都有一个专用的执行器 JVM,每个 CPU 核心一个,以及位于集群外部的 Spark Driver JVM。磁盘直接连接到节点上,使用 JBOD(Just a bunch of disks)方法。非常大的文件被分区存储在磁盘上,而像 HDFS 这样的虚拟文件系统将这些块作为一个大的虚拟文件提供。以下简化的组件模型显示了位于集群外部的驱动程序 JVM。它与集群管理器(见图 4)通信,以获取在工作节点上调度任务的权限,因为集群管理器跟踪集群上运行的所有进程的资源分配情况。

如果您使用 Scala 或 Java 开发了您的 Spark 应用程序,这意味着您的作业是基于 JVM 的进程。对于基于 JVM 的进程,您可以通过指定以下两个参数来简单配置 Java 堆空间:

  • -Xmx:这个参数指定了 Java 堆空间的上限

  • -Xms:这个参数是 Java 堆空间的下限

一旦您提交了一个 Spark 作业,就需要为您的 Spark 作业分配堆内存。以下图片提供了一些关于如何分配堆内存的见解:

**图 3:**JVM 内存管理

如前图所示,Spark 以 512MB 的 JVM 堆空间启动 Spark 作业。然而,为了保证 Spark 作业的不间断处理并避免内存不足(OOM)错误,Spark 允许计算节点仅利用堆的 90%(即约 461MB),这最终通过控制 Spark 环境中的spark.storage.safetyFraction参数来增加或减少。更加现实的情况是,JVM 可以被看作是存储(Java 堆的 60%)、执行(即 Shuffle 的堆的 20%)和其他存储的 20%的连接。

此外,Spark 是一种集群计算工具,试图同时利用内存和基于磁盘的计算,并允许用户将一些数据存储在内存中。实际上,Spark 仅利用主内存作为其 LRU 缓存。为了实现不间断的缓存机制,需要保留一小部分内存用于应用程序特定的数据处理。非正式地说,这大约占据了由spark.memory.fraction控制的 Java 堆空间的 60%。

因此,如果您想要查看或计算在您的 Spark 应用程序中可以缓存多少应用程序特定数据,您只需将所有执行程序使用的堆大小总和,并将其乘以safetyFractionspark.memory.fraction。实际上,您可以允许 Spark 计算节点使用总堆大小的 54%(276.48 MB)。现在,洗牌内存的计算如下:

Shuffle memory= Heap Size * spark.shuffle.safetyFraction * spark.shuffle.memoryFraction

spark.shuffle.safetyFractionspark.shuffle.memoryFraction的默认值分别为 80%和 20%。因此,在实际中,您可以使用0.80.2 = 16%*的 JVM 堆用于洗牌。最后,展开内存是计算节点中可以被展开进程利用的主内存量。计算如下:

Unroll memory = spark.storage.unrollFraction * spark.storage.memoryFraction * spark.storage.safetyFraction

上述计算约占堆的 11%(0.20.60.9 = 10.8~11%),即 Java 堆空间的 56.32 MB。

更详细的讨论可以在spark.apache.org/docs/latest/configuration.html找到。

正如我们将在后面看到的,存在各种不同的集群管理器,其中一些还能够同时管理其他 Hadoop 工作负载或非 Hadoop 应用程序。请注意,执行程序和驱动程序始终具有双向通信,因此在网络方面它们也应该坐得很近。

图 4: Spark 集群中的驱动程序、主节点和工作节点架构

Spark 使用驱动程序(又称驱动程序)、主节点和工作节点架构(又称主机、从节点或计算节点)。驱动程序(或机器)与称为主节点的协调器进行通信。主节点实际上管理所有工作节点(又称从节点或计算节点),其中多个执行程序在集群中并行运行。需要注意的是,主节点也是一个具有大内存、存储、操作系统和底层计算资源的计算节点。从概念上讲,这种架构可以在图 4中显示。更多细节将在本节后面讨论。

在实际的集群模式中,集群管理器(又称资源管理器)管理集群中所有计算节点的所有资源。通常,防火墙在为集群增加安全性的同时也增加了复杂性。系统组件之间的端口需要打开,以便它们可以相互通信。例如,Zookeeper 被许多组件用于配置。Apache Kafka 是一个订阅消息系统,使用 Zookeeper 来配置其主题、组、消费者和生产者。因此,需要打开到 Zookeeper 的客户端端口,可能要穿过防火墙。

最后,需要考虑将系统分配给集群节点。例如,如果 Apache Spark 使用 Flume 或 Kafka,那么将使用内存通道。Apache Spark 不应该与其他 Apache 组件竞争内存使用。根据数据流和内存使用情况,可能需要在不同的集群节点上安装 Spark、Hadoop、Zookeeper、Flume 和其他工具。或者,也可以使用资源管理器,如 YARN、Mesos 或 Docker 等来解决这个问题。在标准的 Hadoop 环境中,很可能已经有 YARN 了。

作为工作节点或 Spark 主节点的计算节点将需要比防火墙内的集群处理节点更多的资源。当集群上部署了许多 Hadoop 生态系统组件时,所有这些组件都将需要主服务器上额外的内存。您应该监视工作节点的资源使用情况,并根据需要调整资源和/或应用程序位置。例如,YARN 正在处理这个问题。

本节简要介绍了 Apache Spark、Hadoop 和其他工具在大数据集群中的情况。然而,Apache Spark 集群本身在大数据集群中如何配置?例如,可能有许多类型的 Spark 集群管理器。下一节将对此进行探讨,并描述每种类型的 Apache Spark 集群管理器。

集群管理

Spark 上下文可以通过 Spark 配置对象(即SparkConf)和 Spark URL 来定义。首先,Spark 上下文的目的是连接 Spark 集群管理器,您的 Spark 作业将在其中运行。然后,集群或资源管理器会为您的应用程序在计算节点之间分配所需的资源。集群管理器的第二个任务是在集群工作节点之间分配执行程序,以便执行您的 Spark 作业。第三,资源管理器还会将驱动程序(也称为应用程序 JAR 文件、R 代码或 Python 脚本)复制到计算节点。最后,资源管理器将计算任务分配给计算节点。

以下小节描述了当前 Spark 版本(即本书撰写时的 Spark 2.1.0)提供的可能的 Apache Spark 集群管理器选项。要了解资源管理器(也称为集群管理器)的资源管理情况,以下内容显示了 YARN 如何管理其所有底层计算资源。但是,无论您使用的是哪种集群管理器(例如 Mesos 或 YARN),情况都是一样的:

图 5: 使用 YARN 进行资源管理

详细讨论可在spark.apache.org/docs/latest/cluster-overview.html#cluster-manager-types找到。

伪集群模式(也称为 Spark 本地)

正如您已经知道的,Spark 作业可以在本地模式下运行。有时这被称为伪集群执行模式。这也是一种非分布式和基于单个 JVM 的部署模式,其中 Spark 将所有执行组件(例如驱动程序、执行程序、LocalSchedulerBackend 和主节点)放入单个 JVM 中。这是唯一一种驱动程序本身被用作执行程序的模式。下图显示了提交 Spark 作业的本地模式的高级架构:

图 6: Spark 作业本地模式的高级架构(来源:jaceklaskowski.gitbooks.io/mastering-apache-spark/content/spark-local.html)

这太令人惊讶了吗?不,我想不是,因为您也可以实现某种并行性,其中默认并行性是在主 URL 中指定的线程数(也称为使用的核心),即 local [4]表示 4 个核心/线程,local [*]表示所有可用的线程。我们将在本章后面讨论这个话题。

独立

通过指定 Spark 配置本地 URL,可以使应用程序在本地运行。通过指定local[n],可以让 Spark 使用n个线程在本地运行应用程序。这是一个有用的开发和测试选项,因为您还可以测试某种并行化场景,但将所有日志文件保留在单台机器上。独立模式使用了 Apache Spark 提供的基本集群管理器。Spark 主 URL 将如下所示:

spark://<hostname>:7077

在这里,<hostname>是运行 Spark 主的主机名。我指定了 7077 作为端口,这是默认值,但它是可配置的。这个简单的集群管理器目前只支持FIFO(先进先出)调度。您可以通过为每个应用程序设置资源配置选项来构想允许并发应用程序调度。例如,spark.core.max用于在应用程序之间共享处理器核心。本章后面将进行更详细的讨论。

Apache YARN

如果将 Spark 主值设置为 YARN-cluster,则可以将应用程序提交到集群,然后终止。集群将负责分配资源和运行任务。然而,如果应用程序主作为 YARN-client 提交,则应用程序在处理的生命周期中保持活动,并从 YARN 请求资源。这在与 Hadoop YARN 集成时适用于更大规模。本章后面将提供逐步指南,以配置单节点 YARN 集群,以启动需要最少资源的 Spark 作业。

Apache Mesos

Apache Mesos 是一个用于跨集群资源共享的开源系统。它允许多个框架通过管理和调度资源来共享集群。它是一个集群管理器,使用 Linux 容器提供隔离,允许多个系统(如 Hadoop、Spark、Kafka、Storm 等)安全地共享集群。这是一个基于主从的系统,使用 Zookeeper 进行配置管理。这样,您可以将 Spark 作业扩展到数千个节点。对于单个主节点 Mesos 集群,Spark 主 URL 将采用以下形式:

mesos://<hostname>:5050

通过专门使用 Mesos 提交 Spark 作业的后果可以在以下图中以可视化方式显示:

**图 7:**Mesos 在操作中(图片来源:jaceklaskowski.gitbooks.io/mastering-apache-spark/content/spark-architecture.html)

在前面的图中,<hostname>是 Mesos 主服务器的主机名,端口定义为 5050,这是默认的 Mesos 主端口(可配置)。如果在大规模高可用性 Mesos 集群中有多个 Mesos 主服务器,则 Spark 主 URL 将如下所示:

mesos://zk://<hostname>:2181

因此,Mesos 主服务器的选举将由 Zookeeper 控制。<hostname>将是 Zookeeper 群的主机名。此外,端口号 2181 是 Zookeeper 的默认主端口。

基于云的部署

云计算范式中有三种不同的抽象级别:

  • 基础设施即服务(简称 IaaS)

  • 平台即服务(简称 PaaS)

  • 软件即服务(简称 SaaS)

IaaS 通过空虚拟机提供计算基础设施,用于运行作为 SaaS 的软件。这对于在 OpenStack 上的 Apache Spark 也是如此。

OpenStack 的优势在于它可以在多个不同的云提供商之间使用,因为它是一个开放标准,也是基于开源的。您甚至可以在本地数据中心使用 OpenStack,并在本地、专用和公共云数据中心之间透明动态地移动工作负载。

相比之下,PaaS 从您身上解除了安装和操作 Apache Spark 集群的负担,因为这是作为服务提供的。换句话说,您可以将其视为类似于操作系统的一层。

有时,甚至可以将 Spark 应用程序 Docker 化并以云平台独立方式部署。然而,关于 Docker 是 IaaS 还是 PaaS 正在进行讨论,但在我们看来,这只是一种轻量级预安装虚拟机的形式,更多的是 IaaS。

最后,SaaS 是云计算范式提供和管理的应用层。坦率地说,您不会看到或必须担心前两层(IaaS 和 PaaS)。

Google Cloud,Amazon AWS,Digital Ocean 和 Microsoft Azure 是提供这三个层作为服务的云计算服务的良好示例。我们将在本章后面展示如何在云顶部使用 Amazon AWS 部署您的 Spark 集群的示例。

在集群上部署 Spark 应用程序

在本节中,我们将讨论如何在计算集群上部署 Spark 作业。我们将看到如何在三种部署模式(独立,YARN 和 Mesos)中部署集群。以下图总结了本章中需要引用集群概念的术语:

**图 8:**需要引用集群概念的术语(来源:http://spark.apache.org/docs/latest/cluster-overview.html#glossary)

但是,在深入研究之前,我们需要了解如何一般提交 Spark 作业。

提交 Spark 作业

一旦将 Spark 应用程序打包为 jar 文件(用 Scala 或 Java 编写)或 Python 文件,就可以使用 Spark 分发(即$SPARK_HOME/bin下的 bin 目录中的 Spark-submit 脚本)提交。根据 Spark 网站提供的 API 文档(spark.apache.org/docs/latest/submitting-applications.html),该脚本负责以下内容:

  • 设置JAVA_HOMESCALA_HOME与 Spark 的类路径

  • 设置执行作业所需的所有依赖项

  • 管理不同的集群管理器

  • 最后,部署 Spark 支持的模型

简而言之,Spark 作业提交语法如下:

$ spark-submit [options] <app-jar | python-file> [app arguments]

在这里,[options]可以是:--conf <configuration_parameters> --class <main-class> --master <master-url> --deploy-mode <deploy-mode> ... # other options

  • <main-class>是主类名。这实际上是我们 Spark 应用程序的入口点。

  • --conf表示所有使用的 Spark 参数和配置属性。配置属性的格式是键=值格式。

  • <master-url>指定集群的主 URL(例如,spark://HOST_NAME:PORT用于连接到 Spark 独立集群的主机,local用于在本地运行 Spark 作业。默认情况下,它只允许您使用一个工作线程,没有并行性。local [k]可用于在本地运行具有K工作线程的 Spark 作业。需要注意的是,K 是您计算机上的核心数。最后,如果您指定主机为local[*]以在本地运行 Spark 作业,您将允许spark-submit脚本利用计算机上所有工作线程(逻辑核心)。最后,您可以指定主机为mesos://IP_ADDRESS:PORT以连接到可用的 Mesos 集群。或者,您可以指定使用yarn在基于 YARN 的集群上运行 Spark 作业。

有关 Master URL 的其他选项,请参考以下图:

**图 9:**Spark 支持的主 URL 的详细信息

  • <deploy-mode>如果要在 worker 节点(集群)上部署驱动程序,或者在外部客户端(客户端)上本地部署,必须指定。支持四种(4)模式:local,standalone,YARN 和 Mesos。

  • <app-jar>是您使用依赖项构建的 JAR 文件。在提交作业时,只需传递 JAR 文件。

  • <python-file>是使用 Python 编写的应用程序主要源代码。在提交作业时,只需传递.py文件。

  • [app-arguments]可以是应用程序开发人员指定的输入或输出参数。

在使用 spark-submit 脚本提交 Spark 作业时,可以使用--jars选项指定 Spark 应用程序的主要 jar(以及包括的其他相关 JAR 包)。然后所有的 JAR 包将被传输到集群。在--jars之后提供的 URL 必须用逗号分隔。

然而,如果您使用 URL 指定 jar 包,最好在--jars之后使用逗号分隔 JAR 包。Spark 使用以下 URL 方案来允许不同的 JAR 包传播策略:

  • file: 指定绝对路径和file:/

  • hdfs**:http:https:ftp:** JAR 包或任何其他文件将从您指定的 URL/URI 中按预期进行下载

  • local:local:/开头的 URI 可用于指向每个计算节点上的本地 jar 文件

需要注意的是,依赖的 JAR 包、R 代码、Python 脚本或任何其他相关的数据文件需要复制或复制到每个计算节点上的工作目录中。这有时会产生很大的开销,并且需要大量的磁盘空间。磁盘使用量会随时间增加。因此,在一定时间内,需要清理未使用的数据对象或相关的代码文件。然而,使用 YARN 可以很容易地实现这一点。YARN 会定期处理清理工作,并可以自动处理。例如,在 Spark 独立模式下,可以通过spark.worker.cleanup.appDataTtl属性配置自动清理提交 Spark 作业时。

在计算上,Spark 被设计为在作业提交时(使用spark-submit脚本),可以从属性文件加载默认的 Spark 配置值,并将其传播到 Spark 应用程序。主节点将从名为spark-default.conf的配置文件中读取指定的选项。确切的路径是您的 Spark 分发目录中的SPARK_HOME/conf/spark-defaults.conf。然而,如果您在命令行中指定了所有参数,这将获得更高的优先级,并且将相应地使用。

在本地和独立运行 Spark 作业

示例显示在第十三章,我的名字是贝叶斯,朴素贝叶斯,并且可以扩展到更大的数据集以解决不同的目的。您可以将这三个聚类算法与所有必需的依赖项打包,并将它们作为 Spark 作业提交到集群中。如果您不知道如何制作一个包并从 Scala 类创建 jar 文件,您可以使用 SBT 或 Maven 将应用程序与所有依赖项捆绑在一起。

根据 Spark 文档spark.apache.org/docs/latest/submitting-applications.html#advanced-dependency-management,SBT 和 Maven 都有汇编插件,用于将您的 Spark 应用程序打包为一个 fat jar。如果您的应用程序已经捆绑了所有的依赖项,可以使用以下代码行提交您的 k-means 聚类 Spark 作业,例如(对其他类使用类似的语法),用于 Saratoga NY Homes 数据集。要在本地提交和运行 Spark 作业,请在 8 个核心上运行以下命令:

$ SPARK_HOME/bin/spark-submit 
 --class com.chapter15.Clustering.KMeansDemo 
 --master local[8] 
 KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar 
 Saratoga_NY_Homes.txt

在上述代码中,com.chapter15.KMeansDemo是用 Scala 编写的主类文件。Local [8]是使用您机器的八个核心的主 URL。KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar是我们刚刚通过 Maven 项目生成的应用程序 JAR 文件;Saratoga_NY_Homes.txt是 Saratoga NY Homes 数据集的输入文本文件。如果应用程序成功执行,您将在下图中找到包括输出的消息(摘要):

图 10: 终端上的 Spark 作业输出[本地模式]

现在,让我们深入研究独立模式下的集群设置。要安装 Spark 独立模式,您应该在集群的每个节点上放置每个版本的预构建版本的 Spark。或者,您可以自己构建它,并根据spark.apache.org/docs/latest/building-spark.html上的说明使用它。

要将环境配置为 Spark 独立模式,您将需要为集群的每个节点提供所需版本的预构建版本的 Spark。或者,您可以自己构建它,并根据spark.apache.org/docs/latest/building-spark.html上的说明使用它。现在我们将看到如何手动启动独立集群。您可以通过执行以下命令启动独立主节点:

$ SPARK_HOME/sbin/start-master.sh

一旦启动,您应该在终端上观察以下日志:

Starting org.apache.spark.deploy.master.Master, logging to <SPARK_HOME>/logs/spark-asif-org.apache.spark.deploy.master.Master-1-ubuntu.out

您应该能够默认访问http://localhost:8080的 Spark Web UI。观察以下 UI,如下图所示:

**图 11:**Spark 主节点作为独立节点

您可以通过编辑以下参数更改端口号:

SPARK_MASTER_WEBUI_PORT=8080

SPARK_HOME/sbin/start-master.sh中,只需更改端口号,然后应用以下命令:

$ sudo chmod +x SPARK_HOME/sbin/start-master.sh.

或者,您可以重新启动 Spark 主节点以实现前面的更改。但是,您将不得不在SPARK_HOME/sbin/start-slave.sh中进行类似的更改。

正如您在这里所看到的,没有与主节点关联的活动工作节点。现在,要创建一个从节点(也称为工作节点或计算节点),请创建工作节点并使用以下命令将其连接到主节点:

$ SPARK_HOME/sbin/start-slave.sh <master-spark-URL>

成功完成上述命令后,您应该在终端上观察以下日志:

Starting org.apache.spark.deploy.worker.Worker, logging to <SPARK_HOME>//logs/spark-asif-org.apache.spark.deploy.worker.Worker-1-ubuntu.out 

一旦您的一个工作节点启动,您可以在 Spark Web UI 的http://localhost:8081上查看其状态。但是,如果您启动另一个工作节点,您可以在连续的端口(即 8082、8083 等)上访问其状态。您还应该在那里看到新节点的列表,以及其 CPU 和内存的数量,如下图所示:

**图 12:**Spark 工作节点作为独立节点

现在,如果您刷新http://localhost:8080,您应该看到与您的主节点关联的一个工作节点已添加,如下图所示:

**图 13:**Spark 主节点现在有一个独立的工作节点

最后,如下图所示,这些都是可以传递给主节点和工作节点的配置选项:

**图 14:**可以传递给主节点和工作节点的配置选项(来源:spark.apache.org/docs/latest/spark-standalone.html#starting-a-cluster-manually)

现在您的一个主节点和一个工作节点正在读取和活动。最后,您可以提交与本地模式不同的独立模式下的相同 Spark 作业,使用以下命令:

$ SPARK_HOME/bin/spark-submit  
--class "com.chapter15.Clustering.KMeansDemo"  
--master spark://ubuntu:7077   
KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar  
Saratoga_NY_Homes.txt

作业启动后,访问http://localhost:80810的 Spark Web UI 以查看主节点和http://localhost:8081的工作节点,您可以看到作业的进度,如第十四章中所讨论的那样,Time to Put Some Order - Cluster Your Data with Spark MLlib

总结这一部分,我们想引导您查看下图(即图 15),显示了以下 shell 脚本用于启动或停止集群的用法:

**图 15:**用于启动或停止集群的 shell 脚本的用法

Hadoop YARN

如前所述,Apache Hadoop YARN 有两个主要组件:调度程序和应用程序管理器,如下图所示:

**图 16:**Apache Hadoop YARN 架构(蓝色:系统组件;黄色和粉色:两个正在运行的应用程序)

现在使用调度程序和应用程序管理器,可以配置以下两种部署模式来在基于 YARN 的集群上启动 Spark 作业:

  • 集群模式:在集群模式下,Spark 驱动程序在 YARN 的应用程序管理器管理的应用程序的主进程内工作。即使客户端在应用程序启动后被终止或断开连接,应用程序也可以继续运行。

  • 客户端模式:在此模式下,Spark 驱动程序在客户端进程内运行。之后,Spark 主节点仅用于从 YARN(YARN 资源管理器)请求计算节点的计算资源。

在 Spark 独立模式和 Mesos 模式中,需要在--master参数中指定主节点(即地址)。然而,在 YARN 模式中,资源管理器的地址是从 Hadoop 配置文件中读取的。因此,--master参数是yarn。在提交 Spark 作业之前,您需要设置好 YARN 集群。下一小节将逐步展示如何操作。

配置单节点 YARN 集群

在本小节中,我们将看到如何在在 YARN 集群上运行 Spark 作业之前设置 YARN 集群。有几个步骤,所以请耐心按照以下步骤操作:

步骤 1:下载 Apache Hadoop

从 Hadoop 网站(hadoop.apache.org/)下载最新的发行版。我在 Ubuntu 14.04 上使用了最新的稳定版本 2.7.3,如下所示:

$  cd /home
$  wget http://mirrors.ibiblio.org/apache/hadoop/common/hadoop-2.7.3/hadoop-2.7.3.tar.gz

接下来,按以下方式创建并提取包在/opt/yarn中:

$  mkdir –p /opt/yarn
$  cd /opt/yarn
$  tar xvzf /root/hadoop-2.7.3.tar.gz

步骤 2:设置 JAVA_HOME

有关详细信息,请参阅第一章中的 Java 设置部分,Scala 简介,并应用相同的更改。

步骤 3:创建用户和组

可以按以下方式创建hadoop组的yarnhdfsmapred用户帐户:

$  groupadd hadoop
$  useradd -g hadoop yarn
$  useradd -g hadoop hdfs
$  useradd -g hadoop mapred

步骤 4:创建数据和日志目录

要使用 Hadoop 运行 Spark 作业,需要具有具有各种权限的数据和日志目录。您可以使用以下命令:

$  mkdir -p /var/data/hadoop/hdfs/nn
$  mkdir -p /var/data/hadoop/hdfs/snn
$  mkdir -p /var/data/hadoop/hdfs/dn
$  chown hdfs:hadoop /var/data/hadoop/hdfs –R
$  mkdir -p /var/log/hadoop/yarn
$  chown yarn:hadoop /var/log/hadoop/yarn -R

现在您需要创建 YARN 安装的日志目录,然后按以下方式设置所有者和组:

$  cd /opt/yarn/hadoop-2.7.3
$  mkdir logs
$  chmod g+w logs
$  chown yarn:hadoop . -R

步骤 5:配置 core-site.xml

两个属性(即fs.default.namehadoop.http.staticuser.user)需要设置到etc/hadoop/core-site.xml文件中。只需复制以下代码行:

<configuration>
       <property>
               <name>fs.default.name</name>
               <value>hdfs://localhost:9000</value>
       </property>
       <property>
               <name>hadoop.http.staticuser.user</name>
               <value>hdfs</value>
       </property>
</configuration>

步骤 6:配置 hdfs-site.xml

五个属性(即dfs.replicationdfs.namenode.name.dirfs.checkpoint.dirfs.checkpoint.edits.dirdfs.datanode.data.dir)需要设置到etc/hadoop/hdfs-site.xml文件中。只需复制以下代码行:

<configuration>
 <property>
   <name>dfs.replication</name>
   <value>1</value>
 </property>
 <property>
   <name>dfs.namenode.name.dir</name>
   <value>file:/var/data/hadoop/hdfs/nn</value>
 </property>
 <property>
   <name>fs.checkpoint.dir</name>
   <value>file:/var/data/hadoop/hdfs/snn</value>
 </property>
 <property>
   <name>fs.checkpoint.edits.dir</name>
   <value>file:/var/data/hadoop/hdfs/snn</value>
 </property>
 <property>
   <name>dfs.datanode.data.dir</name>
   <value>file:/var/data/hadoop/hdfs/dn</value>
 </property>
</configuration>

步骤 7:配置 mapred-site.xml

有一个属性(即mapreduce.framework.name)需要设置到etc/hadoop/mapred-site.xml文件中。首先,将原始模板文件复制并替换为以下内容到mapred-site.xml中:

$  cp mapred-site.xml.template mapred-site.xml

现在,只需复制以下代码行:

<configuration>
<property>
   <name>mapreduce.framework.name</name>
   <value>yarn</value>
 </property>
</configuration>

步骤 8:配置 yarn-site.xml

两个属性(即yarn.nodemanager.aux-servicesyarn.nodemanager.aux-services.mapreduce.shuffle.class)需要设置到etc/hadoop/yarn-site.xml文件中。只需复制以下代码行:

<configuration>
<property>
   <name>yarn.nodemanager.aux-services</name>
   <value>mapreduce_shuffle</value>
 </property>
 <property>
   <name>yarn.nodemanager.aux-services.mapreduce.shuffle.class</name>
   <value>org.apache.hadoop.mapred.ShuffleHandler</value>
 </property>
</configuration>

步骤 9:设置 Java 堆空间

要在基于 Hadoop 的 YARN 集群上运行 Spark 作业,需要为 JVM 指定足够的堆空间。您需要编辑etc/hadoop/hadoop-env.sh文件。启用以下属性:

HADOOP_HEAPSIZE="500"
HADOOP_NAMENODE_INIT_HEAPSIZE="500"

现在您还需要编辑mapred-env.sh文件,添加以下行:

HADOOP_JOB_HISTORYSERVER_HEAPSIZE=250

最后,请确保已编辑yarn-env.sh以使更改对 Hadoop YARN 永久生效:

JAVA_HEAP_MAX=-Xmx500m
YARN_HEAPSIZE=500

步骤 10:格式化 HDFS

如果要启动 HDFS NameNode,Hadoop 需要初始化一个目录,用于存储或持久化其用于跟踪文件系统所有元数据的数据。格式化将销毁所有内容并设置一个新的文件系统。然后它使用etc/hadoop/hdfs-site.xmldfs.namenode.name.dir参数设置的值。要进行格式化,首先转到bin目录并执行以下命令:

$  su - hdfs
$ cd /opt/yarn/hadoop-2.7.3/bin
$ ./hdfs namenode -format

如果前面的命令执行成功,您应该在 Ubuntu 终端上看到以下内容:

INFO common.Storage: Storage directory /var/data/hadoop/hdfs/nn has been successfully formatted

第 11 步:启动 HDFS

在第 10 步的bin目录中,执行以下命令:

$ cd ../sbin
$ ./hadoop-daemon.sh start namenode

在执行前面的命令成功后,您应该在终端上看到以下内容:

starting namenode, logging to /opt/yarn/hadoop-2.7.3/logs/hadoop-hdfs-namenode-limulus.out

要启动secondarynamenodedatanode,您应该使用以下命令:

$ ./hadoop-daemon.sh start secondarynamenode

如果前面的命令成功,您应该在终端上收到以下消息:

Starting secondarynamenode, logging to /opt/yarn/hadoop-2.7.3/logs/hadoop-hdfs-secondarynamenode-limulus.out

然后使用以下命令启动数据节点:

$ ./hadoop-daemon.sh start datanode

如果前面的命令成功,您应该在终端上收到以下消息:

starting datanode, logging to /opt/yarn/hadoop-2.7.3/logs/hadoop-hdfs-datanode-limulus.out

现在确保检查所有与这些节点相关的服务是否正在运行,请使用以下命令:

$ jps

您应该观察到类似以下的内容:

35180 SecondaryNameNode
45915 NameNode
656335 Jps
75814 DataNode

第 12 步:启动 YARN

要使用 YARN,必须以用户 yarn 启动一个resourcemanager和一个节点管理器:

$  su - yarn
$ cd /opt/yarn/hadoop-2.7.3/sbin
$ ./yarn-daemon.sh start resourcemanager

如果前面的命令成功,您应该在终端上收到以下消息:

starting resourcemanager, logging to /opt/yarn/hadoop-2.7.3/logs/yarn-yarn-resourcemanager-limulus.out

然后执行以下命令启动节点管理器:

$ ./yarn-daemon.sh start nodemanager

如果前面的命令成功,您应该在终端上收到以下消息:

starting nodemanager, logging to /opt/yarn/hadoop-2.7.3/logs/yarn-yarn-nodemanager-limulus.out

如果要确保这些节点中的所有服务都在运行,应该使用$jsp命令。此外,如果要停止资源管理器或nodemanager,请使用以下g命令:

$ ./yarn-daemon.sh stop nodemanager
$ ./yarn-daemon.sh stop resourcemanager

第 13 步:在 Web UI 上进行验证

访问http://localhost:50070查看 NameNode 的状态,并在浏览器上访问http://localhost:8088查看资源管理器。

前面的步骤展示了如何配置基于 Hadoop 的 YARN 集群,只有几个节点。但是,如果您想要配置从几个节点到拥有数千个节点的极大集群的基于 Hadoop 的 YARN 集群,请参考hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-common/ClusterSetup.html

在 YARN 集群上提交 Spark 作业

现在,我们的 YARN 集群已经满足最低要求(用于执行一个小的 Spark 作业),要在 YARN 的集群模式下启动 Spark 应用程序,可以使用以下提交命令:

$ SPARK_HOME/bin/spark-submit --classpath.to.your.Class --master yarn --deploy-mode cluster [options] <app jar> [app options]

要运行我们的KMeansDemo,应该这样做:

$ SPARK_HOME/bin/spark-submit  
    --class "com.chapter15.Clustering.KMeansDemo"  
    --master yarn  
    --deploy-mode cluster  
    --driver-memory 16g  
    --executor-memory 4g  
    --executor-cores 4  
    --queue the_queue  
    KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar  
    Saratoga_NY_Homes.txt

前面的submit命令以默认应用程序主节点启动 YARN 集群模式。然后KMeansDemo将作为应用程序主节点的子线程运行。为了获取状态更新并在控制台中显示它们,客户端将定期轮询应用程序主节点。当您的应用程序(即我们的情况下的KMeansDemo)执行完毕时,客户端将退出。

提交作业后,您可能希望使用 Spark web UI 或 Spark 历史服务器查看进度。此外,您应该参考第十八章,测试和调试 Spark)以了解如何分析驱动程序和执行程序日志。

要以客户端模式启动 Spark 应用程序,应该使用之前的命令,只是您将不得不将集群替换为客户端。对于想要使用 Spark shell 的人,请在客户端模式下使用以下命令:

$ SPARK_HOME/bin/spark-shell --master yarn --deploy-mode client

在 YARN 集群中进行高级作业提交

如果您选择更高级的方式将 Spark 作业提交到您的 YARN 集群中进行计算,您可以指定其他参数。例如,如果要启用动态资源分配,请将spark.dynamicAllocation.enabled参数设置为 true。但是,为了这样做,您还需要指定minExecutorsmaxExecutorsinitialExecutors,如下所述。另一方面,如果要启用洗牌服务,请将spark.shuffle.service.enabled设置为true。最后,您还可以尝试使用spark.executor.instances参数指定将运行多少执行程序实例。

现在,为了使前面的讨论更具体,您可以参考以下提交命令:

$ SPARK_HOME/bin/spark-submit   
    --class "com.chapter13.Clustering.KMeansDemo"  
    --master yarn  
    --deploy-mode cluster  
    --driver-memory 16g  
    --executor-memory 4g  
    --executor-cores 4  
    --queue the_queue  
    --conf spark.dynamicAllocation.enabled=true  
    --conf spark.shuffle.service.enabled=true  
    --conf spark.dynamicAllocation.minExecutors=1  
    --conf spark.dynamicAllocation.maxExecutors=4  
    --conf spark.dynamicAllocation.initialExecutors=4  
    --conf spark.executor.instances=4  
    KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar  
    Saratoga_NY_Homes.txt

然而,前面的作业提交脚本的后果是复杂的,有时是不确定的。根据我的以往经验,如果您从代码中增加分区和执行程序的数量,那么应用程序将更快完成,这是可以接受的。但是,如果您只增加执行程序核心,完成时间是相同的。然而,您可能期望时间比初始时间更短。其次,如果您两次启动前面的代码,您可能期望两个作业都在 60 秒内完成,但这也可能不会发生。通常情况下,两个作业可能在 120 秒后才完成。这有点奇怪,不是吗?然而,下面是一个解释,可以帮助您理解这种情况。

假设您的机器上有 16 个核心和 8GB 内存。现在,如果您使用四个每个核心的执行程序,会发生什么?当您使用执行程序时,Spark 会从 YARN 中保留它,并且 YARN 会分配所需的核心数(例如,在我们的情况下为 1)和所需的内存。实际上,为了更快地处理,所需的内存要比您实际请求的更多。如果您请求 1GB,实际上它将分配几乎 1.5GB,其中包括 500MB 的开销。此外,它可能会为驱动程序分配一个执行程序,可能使用 1024MB 内存(即 1GB)。

有时,不管您的 Spark 作业需要多少内存,而是需要预留多少内存。在前面的例子中,它不会占用 50MB 的内存,而是大约 1.5GB(包括开销)每个执行程序。我们将在本章后面讨论如何在 AWS 上配置 Spark 集群。

Apache Mesos

当使用 Mesos 时,Mesos 主节点通常会取代 Spark 主节点作为集群管理器(也称为资源管理器)。现在,当驱动程序创建一个 Spark 作业并开始分配相关任务进行调度时,Mesos 确定哪些计算节点处理哪些任务。我们假设您已经在您的机器上配置和安装了 Mesos。

要开始,以下链接可能有助于在您的机器上安装 Mesos。blog.madhukaraphatak.com/mesos-single-node-setup-ubuntu/, mesos.apache.org/gettingstarted/.

根据硬件配置的不同,需要一段时间。在我的机器上(Ubuntu 14.04 64 位,带有 Core i7 和 32GB RAM),完成构建需要 1 小时。

要通过利用 Mesos 集群模式提交和计算您的 Spark 作业,请确保检查 Spark 二进制包是否可在 Mesos 可访问的位置。此外,请确保您的 Spark 驱动程序可以配置成自动连接到 Mesos。第二个选项是在与 Mesos 从属节点相同的位置安装 Spark。然后,您将需要配置spark.mesos.executor.home参数来指向 Spark 分发的位置。需要注意的是,可能指向的默认位置是SPARK_HOME

当 Mesos 在 Mesos 工作节点(也称为计算节点)上首次执行 Spark 作业时,Spark 二进制包必须在该工作节点上可用。这将确保 Spark Mesos 执行程序在后台运行。

Spark 二进制包可以托管到 Hadoop 上,以便让它们可以被访问:

  1. 通过http://使用 URI/URL(包括 HTTP),

  2. 通过s3n://使用 Amazon S3,

  3. 通过hdfs://使用 HDFS。

如果设置了HADOOP_CONF_DIR环境变量,参数通常设置为hdfs://...;否则为file://

您可以按以下方式指定 Mesos 的主 URL:

  1. 对于单主 Mesos 集群,使用mesos://host:5050,对于由 ZooKeeper 控制的多主 Mesos 集群,使用mesos://zk://host1:2181,host2:2181,host3:2181/mesos

有关更详细的讨论,请参阅spark.apache.org/docs/latest/running-on-mesos.html

客户端模式

在此模式下,Mesos 框架以这样的方式工作,即 Spark 作业直接在客户端机器上启动。然后等待计算结果,也称为驱动程序输出。然而,为了与 Mesos 正确交互,驱动程序期望在SPARK_HOME/conf/spark-env.sh中指定一些特定于应用程序的配置。为了实现这一点,在$SPARK_HOME /conf下修改spark-env.sh.template文件,并在使用此客户端模式之前,在您的spark-env.sh中设置以下环境变量:

$ export MESOS_NATIVE_JAVA_LIBRARY=<path to libmesos.so>

在 Ubuntu 上,此路径通常为/usr/local /lib/libmesos.so。另一方面,在 macOS X 上,相同的库称为libmesos.dylib,而不是libmesos.so

$ export SPARK_EXECUTOR_URI=<URL of spark-2.1.0.tar.gz uploaded above>

现在,当提交和启动要在集群上执行的 Spark 应用程序时,您将需要将 Mesos :// HOST:PORT作为主 URL 传递。这通常是在 Spark 应用程序开发中创建SparkContext时完成的,如下所示:

val conf = new SparkConf()              
                   .setMaster("mesos://HOST:5050")  
                   .setAppName("My app")             
                  .set("spark.executor.uri", "<path to spark-2.1.0.tar.gz uploaded above>")
val sc = new SparkContext(conf)

另一种方法是使用spark-submit脚本,并在SPARK_HOME/conf/spark-defaults.conf文件中配置spark.executor.uri。在运行 shell 时,spark.executor.uri参数从SPARK_EXECUTOR_URI继承,因此不需要作为系统属性冗余传递。只需使用以下命令从您的 Spark shell 访问客户端模式:

$ SPARK_HOME/bin/spark-shell --master mesos://host:5050

集群模式

Mesos 上的 Spark 还支持集群模式。如果驱动程序已经启动了 Spark 作业(在集群上),并且计算也已经完成,客户端可以从 Mesos Web UI 访问(驱动程序的)结果。如果您通过SPARK_HOME/sbin/start-mesos-dispatcher.sh脚本在集群中启动了MesosClusterDispatcher,则可以使用集群模式。

同样,条件是在创建 Spark 应用程序的SparkContext时,您必须传递 Mesos 主 URL(例如,mesos://host:5050)。在集群模式下启动 Mesos 还会启动作为守护程序在主机上运行的MesosClusterDispatcher

为了获得更灵活和高级的执行 Spark 作业,您还可以使用Marathon。使用 Marathon 的优点是可以使用 Marathon 运行MesosClusterDispatcher。如果这样做,请确保MesosClusterDispatcher在前台运行。

Marathon是 Mesos 的一个框架,旨在启动长时间运行的应用程序,在 Mesosphere 中,它作为传统 init 系统的替代品。它具有许多功能,简化了在集群环境中运行应用程序,如高可用性、节点约束、应用程序健康检查、用于脚本编写和服务发现的 API,以及易于使用的 Web 用户界面。它将其扩展和自我修复功能添加到 Mesosphere 功能集中。Marathon 可用于启动其他 Mesos 框架,还可以启动可以在常规 shell 中启动的任何进程。由于它设计用于长时间运行的应用程序,它将确保其启动的应用程序将继续运行,即使它们正在运行的从节点失败。有关在 Mesosphere 中使用 Marathon 的更多信息,请参考 GitHub 页面github.com/mesosphere/marathon

更具体地说,从客户端,您可以使用spark-submit脚本提交 Spark 作业到您的 Mesos 集群,并指定主 URL 为MesosClusterDispatcher的 URL(例如,mesos://dispatcher:7077)。操作如下:

$ SPARK_HOME /bin/spark-class org.apache.spark.deploy.mesos.MesosClusterDispatcher

您可以在 Spark 集群 web UI 上查看驱动程序状态。例如,使用以下作业提交命令来执行:

$ SPARK_HOME/bin/spark-submit   
--class com.chapter13.Clustering.KMeansDemo   
--master mesos://207.184.161.138:7077    
--deploy-mode cluster   
--supervise   
--executor-memory 20G   
--total-executor-cores 100   
KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar   
Saratoga_NY_Homes.txt

请注意,传递给 Spark-submit 的 JARS 或 Python 文件应该是 Mesos 从节点可以访问的 URI,因为 Spark 驱动程序不会自动上传本地 jar 文件。最后,Spark 可以在 Mesos 上以两种模式运行:粗粒度(默认)和细粒度(已弃用)。有关更多详细信息,请参考spark.apache.org/docs/latest/running-on-mesos.html

在集群模式下,Spark 驱动程序在不同的机器上运行,也就是说,驱动程序、主节点和计算节点是不同的机器。因此,如果尝试使用SparkContext.addJar添加 JARS,这将不起作用。为了避免这个问题,请确保客户端上的 jar 文件也可以通过SparkContext.addJar使用启动命令中的--jars选项。

$ SPARK_HOME/bin/spark-submit --class my.main.Class    
     --master yarn    
     --deploy-mode cluster    
     --jars my-other-jar.jar, my-other-other-jar.jar    
     my-main-jar.jar    
     app_arg1 app_arg2

在 AWS 上部署

在前一节中,我们说明了如何在本地、独立或部署模式(YARN 和 Mesos)中提交 spark 作业。在这里,我们将展示如何在 AWS EC2 上的真实集群模式中运行 spark 应用程序。为了使我们的应用程序在 spark 集群模式下运行并实现更好的可扩展性,我们将考虑Amazon 弹性计算云EC2)服务作为 IaaS 或平台即服务PaaS)。有关定价和相关信息,请参考aws.amazon.com/ec2/pricing/

步骤 1:密钥对和访问密钥配置

我们假设您已经创建了 EC2 账户。首先要求是创建 EC2 密钥对和 AWS 访问密钥。EC2 密钥对是您在通过 SSH 进行安全连接到 EC2 服务器或实例时需要的私钥。要创建密钥,您必须通过 AWS 控制台进行操作,网址为docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair。请参考以下图示,显示了 EC2 账户的密钥对创建页面:

图 17: AWS 密钥对生成窗口

下载后将其命名为aws_key_pair.pem并保存在本地计算机上。然后通过执行以下命令确保权限(出于安全目的,您应该将此文件存储在安全位置,例如/usr/local/key):

$ sudo chmod 400 /usr/local/key/aws_key_pair.pem

现在您需要的是 AWS 访问密钥和您的帐户凭据。如果您希望使用spark-ec2脚本从本地机器提交 Spark 作业到计算节点,则需要这些内容。要生成并下载密钥,请登录到您的 AWS IAM 服务,网址为docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_CreateAccessKey

下载完成后(即/usr/local/key),您需要在本地机器上设置两个环境变量。只需执行以下命令:

$ echo "export AWS_ACCESS_KEY_ID=<access_key_id>" >> ~/.bashrc 
$ echo " export AWS_SECRET_ACCESS_KEY=<secret_access_key_id>" >> ~/.bashrc 
$ source ~/.bashrc

第 2 步:在 EC2 上配置 Spark 集群

在 Spark 1.6.3 版本发布之前,Spark 分发(即/SPARK_HOME/ec2)提供了一个名为spark-ec2的 shell 脚本,用于从本地机器启动 EC2 实例中的 Spark 集群。这最终有助于在 AWS 上启动、管理和关闭您将在其中使用的 Spark 集群。然而,自 Spark 2.x 以来,相同的脚本已经移至 AMPLab,以便更容易修复错误并单独维护脚本本身。

该脚本可以从 GitHub 仓库github.com/amplab/spark-ec2中访问和使用。

在 AWS 上启动和使用集群将会产生费用。因此,当计算完成时,停止或销毁集群始终是一个好习惯。否则,这将给您带来额外的费用。有关 AWS 定价的更多信息,请参阅aws.amazon.com/ec2/pricing/

您还需要为您的 Amazon EC2 实例(控制台)创建 IAM 实例配置文件。有关详细信息,请参阅docs.aws.amazon.com/codedeploy/latest/userguide/getting-started-create-iam-instance-profile.html。为简单起见,让我们下载脚本并将其放置在 Spark 主目录($SPARK_HOME/ec2)下的一个名为ec2的目录中。一旦您执行以下命令启动一个新实例,它会自动在集群上设置 Spark、HDFS 和其他依赖项:

$ SPARK_HOME/spark-ec2 
--key-pair=<name_of_the_key_pair> 
--identity-file=<path_of_the key_pair>  
--instance-type=<AWS_instance_type > 
--region=<region> zone=<zone> 
--slaves=<number_of_slaves> 
--hadoop-major-version=<Hadoop_version> 
--spark-version=<spark_version> 
--instance-profile-name=<profile_name>
launch <cluster-name>

我们相信这些参数是不言自明的。或者,如需更多详细信息,请参阅github.com/amplab/spark-ec2#readme

如果您已经有一个 Hadoop 集群并希望在其上部署 spark:如果您正在使用 Hadoop-YARN(甚至是 Apache Mesos),运行 spark 作业相对较容易。即使您不使用其中任何一个,Spark 也可以以独立模式运行。Spark 运行一个驱动程序,然后调用 spark 执行程序。这意味着您需要告诉 Spark 您希望您的 spark 守护程序在哪些节点上运行(以主/从的形式)。在您的spark/conf目录中,您可以看到一个名为slaves的文件。更新它以提及您想要使用的所有机器。您可以从源代码设置 spark,也可以从网站使用二进制文件。您应该始终为所有节点使用完全限定域名FQDN),并确保这些机器中的每一台都可以从您的主节点无密码访问。

假设您已经创建并配置了一个实例配置文件。现在您已经准备好启动 EC2 集群。对于我们的情况,它可能类似于以下内容:

$ SPARK_HOME/spark-ec2 
 --key-pair=aws_key_pair 
 --identity-file=/usr/local/aws_key_pair.pem 
 --instance-type=m3.2xlarge 
--region=eu-west-1 --zone=eu-west-1a --slaves=2 
--hadoop-major-version=yarn 
--spark-version=2.1.0 
--instance-profile-name=rezacsedu_aws
launch ec2-spark-cluster-1

以下图显示了您在 AWS 上的 Spark 主目录:

图 18:AWS 上的集群主页

成功完成后,spark 集群将在您的 EC2 帐户上实例化两个工作节点(从节点)。然而,这个任务有时可能需要大约半个小时,具体取决于您的互联网速度和硬件配置。因此,您可能想要休息一下。在集群设置成功完成后,您将在终端上获得 Spark 集群的 URL。为了确保集群真的在运行,可以在浏览器上检查https://<master-hostname>:8080,其中master-hostname是您在终端上收到的 URL。如果一切正常,您将发现您的集群正在运行;请参见图 18中的集群主页。

第 3 步:在 AWS 集群上运行 Spark 作业

现在您的主节点和工作节点都是活动的并正在运行。这意味着您可以将 Spark 作业提交给它们进行计算。但在此之前,您需要使用 SSH 登录远程节点。为此,请执行以下命令以 SSH 远程 Spark 集群:

$ SPARK_HOME/spark-ec2 
--key-pair=<name_of_the_key_pair> 
--identity-file=<path_of_the _key_pair> 
--region=<region> 
--zone=<zone>
login <cluster-name> 

对于我们的情况,应该是以下内容:

$ SPARK_HOME/spark-ec2 
--key-pair=my-key-pair 
--identity-file=/usr/local/key/aws-key-pair.pem 
--region=eu-west-1 
--zone=eu-west-1
login ec2-spark-cluster-1

现在将您的应用程序,即 JAR 文件(或 python/R 脚本),复制到远程实例(在我们的情况下是ec2-52-48-119-121.eu-west-1.compute.amazonaws.com)中,通过执行以下命令(在新的终端中):

$ scp -i /usr/local/key/aws-key-pair.pem /usr/local/code/KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar ec2-user@ec2-52-18-252-59.eu-west-1.compute.amazonaws.com:/home/ec2-user/

然后,通过执行以下命令将您的数据(在我们的情况下是/usr/local/data/Saratoga_NY_Homes.txt)复制到同一远程实例:

$ scp -i /usr/local/key/aws-key-pair.pem /usr/local/data/Saratoga_NY_Homes.txt ec2-user@ec2-52-18-252-59.eu-west-1.compute.amazonaws.com:/home/ec2-user/

请注意,如果您已经在远程机器上配置了 HDFS 并放置了您的代码/数据文件,您就不需要将 JAR 和数据文件复制到从节点;主节点会自动执行这些操作。

干得好!您几乎完成了!现在,最后,您需要提交您的 Spark 作业以由从节点进行计算。要这样做,只需执行以下命令:

$SPARK_HOME/bin/spark-submit 
 --class com.chapter13.Clustering.KMeansDemo 
--master spark://ec2-52-48-119-121.eu-west-1.compute.amazonaws.com:7077 
file:///home/ec2-user/KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar 
file:///home/ec2-user/Saratoga_NY_Homes.txt

如果您的机器上没有设置 HDFS,请将输入文件放在file:///input.txt下。

如果您已经将数据放在 HDFS 上,您应该发出类似以下命令的提交命令:

$SPARK_HOME/bin/spark-submit 
 --class com.chapter13.Clustering.KMeansDemo 
--master spark://ec2-52-48-119-121.eu-west-1.compute.amazonaws.com:7077 
hdfs://localhost:9000/KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar 
hdfs://localhost:9000//Saratoga_NY_Homes.txt

在作业计算成功完成后,您应该在端口 8080 上看到作业的状态和相关统计信息。

第 4 步:暂停、重新启动和终止 Spark 集群

当您的计算完成后,最好停止您的集群以避免额外的成本。要停止您的集群,请从本地机器执行以下命令:

$ SPARK_HOME/ec2/spark-ec2 --region=<ec2-region> stop <cluster-name>

对于我们的情况,应该是以下内容:

$ SPARK_HOME/ec2/spark-ec2 --region=eu-west-1 stop ec2-spark-cluster-1

要在以后重新启动集群,请执行以下命令:

$ SPARK_HOME/ec2/spark-ec2 -i <key-file> --region=<ec2-region> start <cluster-name>

对于我们的情况,应该是以下内容:

$ SPARK_HOME/ec2/spark-ec2 --identity-file=/usr/local/key/-key-pair.pem --region=eu-west-1 start ec2-spark-cluster-1

最后,要在 AWS 上终止您的 Spark 集群,我们使用以下代码:

$ SPARK_HOME/ec2/spark-ec2 destroy <cluster-name>

在我们的情况下,应该是以下内容:

$ SPARK_HOME /spark-ec2 --region=eu-west-1 destroy ec2-spark-cluster-1

Spot 实例非常适合降低 AWS 成本,有时可以将实例成本降低一个数量级。使用这种设施的逐步指南可以在blog.insightdatalabs.com/spark-cluster-step-by-step/上找到。

有时,移动大型数据集,比如 1TB 的原始数据文件,是困难的。在这种情况下,如果您希望您的应用程序能够扩展到更大规模的数据集,最快的方法是将它们从 Amazon S3 或 EBS 设备加载到节点上的 HDFS,并使用hdfs://指定数据文件路径。

数据文件或任何其他文件(数据、jar 包、脚本等)都可以托管在 HDFS 上,以使它们具有高度的可访问性:

  1. 通过http://获取 URI/URL(包括 HTTP)

  2. 通过s3n://使用 Amazon S3

  3. 通过hdfs://使用 HDFS

如果设置了HADOOP_CONF_DIR环境变量,参数通常设置为hdfs://...;否则为file://

摘要

在本章中,我们讨论了 Spark 在集群模式下的工作原理及其基础架构。您还看到了如何在集群上部署完整的 Spark 应用程序。您看到了如何在不同的集群模式(如本地、独立、YARN 和 Mesos)中部署集群以运行 Spark 应用程序。最后,您看到了如何使用 EC2 脚本在 AWS 上配置 Spark 集群。我们相信本章将帮助您对 Spark 有一些良好的理解。然而,由于页面限制,我们无法涵盖许多 API 及其底层功能。

如果您遇到任何问题,请不要忘记向 Spark 用户邮件列表user@spark.apache.org报告。在这样做之前,请确保您已经订阅了它。在下一章中,您将看到如何测试和调试 Spark 应用程序。

第十八章:测试和调试 Spark

“每个人都知道调试比一开始编写程序要难两倍。所以,如果你在编写程序时尽可能聪明,那么你将如何调试它?”

  • Brian W. Kernighan

在理想的世界中,我们编写完美的 Spark 代码,一切都完美运行,对吧?开个玩笑;实际上,我们知道处理大规模数据集几乎从来都不那么容易,必然会有一些数据点会暴露出代码的任何边缘情况。

因此,考虑到上述挑战,在本章中,我们将看到如果应用程序是分布式的,测试可能有多么困难;然后,我们将看到一些解决方法。简而言之,本章将涵盖以下主题:

  • 在分布式环境中进行测试

  • 测试 Spark 应用程序

  • 调试 Spark 应用程序

在分布式环境中进行测试

莱斯利·兰波特(Leslie Lamport)对分布式系统的定义如下:

“分布式系统是指我无法完成任何工作,因为我从未听说过的某台机器已经崩溃了。”

通过万维网(又称WWW)进行资源共享,连接的计算机网络(又称集群),是分布式系统的一个很好的例子。这些分布式环境通常非常复杂,经常发生许多异构性。在这些异构环境中进行测试也是具有挑战性的。在本节中,首先我们将观察一些在使用这种系统时经常出现的常见问题。

分布式环境

有许多关于分布式系统的定义。让我们看一些定义,然后我们将尝试在之后将上述类别相关联。Coulouris 将分布式系统定义为一个系统,其中位于网络计算机上的硬件或软件组件仅通过消息传递进行通信和协调。另一方面,Tanenbaum 以多种方式定义这个术语:

  • 一组独立的计算机,对系统的用户来说,它们看起来像是一个单一的计算机。

  • 由两个或两个以上独立计算机组成的系统,它们通过同步或异步消息传递来协调它们的处理。

  • 分布式系统是由网络连接的自主计算机组成的集合,其软件旨在产生一个集成的计算设施。

现在,根据前面的定义,分布式系统可以分为以下几类:

  • 只有硬件和软件是分布式的:本地分布式系统通过局域网连接。

  • 用户是分布式的,但是运行后端的计算和硬件资源,例如 WWW。

  • 用户和硬件/软件都是分布式的:通过 WAN 连接的分布式计算集群。例如,您可以在使用 Amazon AWS、Microsoft Azure、Google Cloud 或 Digital Ocean 的 droplets 时获得这些类型的计算设施。

分布式系统中的问题

在这里,我们将讨论一些在软件和硬件测试过程中需要注意的主要问题,以便 Spark 作业在集群计算中顺利运行,这本质上是一个分布式计算环境。

请注意,所有这些问题都是不可避免的,但我们至少可以调整它们以获得更好的效果。您应该遵循上一章中给出的指示和建议。根据Kamal Sheel MishraAnil Kumar Tripathi国际计算机科学和信息技术杂志第 5 卷(4),2014 年,4922-4925 页中的分布式软件系统的一些问题、挑战和问题,URL:pdfs.semanticscholar.org/4c6d/c4d739bad13bcd0398e5180c1513f18275d8.pdf,在分布式环境中工作时需要解决几个问题:

  • 可扩展性

  • 异构语言、平台和架构

  • 资源管理

  • 安全和隐私

  • 透明度

  • 开放性

  • 互操作性

  • 服务质量

  • 失败管理

  • 同步

  • 通信

  • 软件架构

  • 性能分析

  • 生成测试数据

  • 测试组件选择

  • 测试顺序

  • 测试系统的可伸缩性和性能

  • 源代码的可用性

  • 事件的可重现性

  • 死锁和竞争条件

  • 测试容错性

  • 分布式系统的调度问题

  • 分布式任务分配

  • 测试分布式软件

  • 从硬件抽象级别的监控和控制机制

的确,我们无法完全解决所有这些问题,但是,使用 Spark,我们至少可以控制一些与分布式系统相关的问题。例如,可伸缩性、资源管理、服务质量、故障管理、同步、通信、分布式系统的调度问题、分布式任务分配以及测试分布式软件中的监控和控制机制。其中大部分在前两章中已经讨论过。另一方面,我们可以解决一些与测试和软件相关的问题:如软件架构、性能分析、生成测试数据、测试组件选择、测试顺序、测试系统的可伸缩性和性能,以及源代码的可用性。这些问题至少在本章中将被明确或隐含地涵盖。

在分布式环境中软件测试的挑战

在敏捷软件开发中有一些常见的挑战,而在最终部署之前在分布式环境中测试软件时,这些挑战变得更加复杂。通常团队成员需要在错误不断增加后并行合并软件组件。然而,基于紧急性,合并通常发生在测试阶段之前。有时,许多利益相关者分布在不同的团队中。因此,存在误解的巨大潜力,团队经常在其中失去。

例如,Cloud Foundry(www.cloudfoundry.org/)是一个开源的、高度分布式的 PaaS 软件系统,用于管理云中应用程序的部署和可伸缩性。它承诺不同的功能,如可伸缩性、可靠性和弹性,这些功能在 Cloud Foundry 上的部署中是内在的,需要底层分布式系统实施措施来确保健壮性、弹性和故障转移。

众所周知,软件测试的过程包括单元测试集成测试烟雾测试验收测试可伸缩性测试性能测试服务质量测试。在 Cloud Foundry 中,测试分布式系统的过程如下图所示:

**图 1:**像 Cloud 这样的分布式环境中软件测试的一个例子

如前图(第一列)所示,在像 Cloud 这样的分布式环境中进行测试的过程始于针对系统中最小的接口点运行单元测试。在所有单元测试成功执行后,运行集成测试来验证作为单一连贯软件系统的相互作用组件的行为(第二列),这些组件运行在单个盒子上(例如,一个虚拟机(VM)或裸机)。然而,虽然这些测试验证了系统作为单体的整体行为,但并不保证在分布式部署中系统的有效性。一旦集成测试通过,下一步(第三列)是验证系统的分布式部署并运行烟雾测试。

正如您所知,软件的成功配置和单元测试的执行使我们能够验证系统行为的可接受性。通过运行验收测试(第四列)来进行验证。现在,为了克服分布式环境中前面提到的问题和挑战,还有其他隐藏的挑战需要研究人员和大数据工程师来解决,但这些实际上超出了本书的范围。

现在我们知道了分布式环境中软件测试的真正挑战是什么,现在让我们开始对我们的 Spark 代码进行一些测试。下一节将专门讨论测试 Spark 应用程序。

测试 Spark 应用程序

有许多方法可以尝试测试您的 Spark 代码,具体取决于它是 Java(您可以进行基本的 JUnit 测试来测试非 Spark 部分)还是 ScalaTest 用于您的 Scala 代码。您还可以通过在本地或小型测试集群上运行 Spark 来进行完整的集成测试。Holden Karau 提供的另一个很棒的选择是使用 Spark-testing base。您可能知道目前还没有用于 Spark 的本机单元测试库。尽管如此,我们可以有以下两种替代方法来使用两个库:

  • ScalaTest

  • Spark 测试基础

但是,在开始测试用 Scala 编写的 Spark 应用程序之前,对单元测试和测试 Scala 方法的背景知识是必需的。

测试 Scala 方法

在这里,我们将看到一些测试 Scala 方法的简单技术。对于 Scala 用户来说,这是最熟悉的单元测试框架(您也可以用它来测试 Java 代码,很快也可以用于 JavaScript)。ScalaTest 支持多种不同的测试样式,每种样式都设计用于支持特定类型的测试需求。有关详细信息,请参阅 ScalaTest 用户指南www.scalatest.org/user_guide/selecting_a_style。尽管 ScalaTest 支持许多样式,但快速入门的一种方法是使用以下 ScalaTest 特质,并以TDD(测试驱动开发)风格编写测试:

  1. FunSuite

  2. Assertions

  3. BeforeAndAfter

随时浏览前述 URL 以了解有关这些特质的更多信息;这将使本教程的其余部分顺利进行。

需要注意的是 TDD 是一种开发软件的编程技术,它规定您应该从测试开始开发。因此,它不影响测试的编写方式,而是测试的编写时间。在ScalaTest.FunSuite中没有特质或测试样式来强制或鼓励 TDD,AssertionsBeforeAndAfter只是更类似于 xUnit 测试框架。

在任何样式特质中,ScalaTest 中有三种断言可用:

  • assert:这用于在您的 Scala 程序中进行一般断言。

  • assertResult:这有助于区分预期值和实际值。

  • assertThrows:这用于确保一小段代码抛出预期的异常。

ScalaTest 的断言是在特质Assertions中定义的,该特质进一步由Suite扩展。简而言之,Suite特质是所有样式特质的超级特质。根据 ScalaTest 文档www.scalatest.org/user_guide/using_assertionsAssertions特质还提供以下功能:

  • assume:有条件地取消测试

  • fail:无条件地使测试失败

  • cancel:无条件地取消测试

  • succeed:无条件使测试成功

  • intercept:确保一小段代码抛出预期的异常,然后对异常进行断言

  • assertDoesNotCompile:确保一小段代码不会编译

  • assertCompiles:确保一小段代码确实编译

  • assertTypeError:确保一小段代码由于类型(而不是解析)错误而无法编译

  • withClue:添加有关失败的更多信息

从前面的列表中,我们将展示其中的一些。在您的 Scala 程序中,您可以通过调用assert并传递Boolean表达式来编写断言。您可以简单地使用Assertions开始编写简单的单元测试用例。Predef是一个对象,其中定义了 assert 的行为。请注意,Predef的所有成员都会被导入到您的每个 Scala 源文件中。以下源代码将为以下情况打印Assertion success

package com.chapter16.SparkTesting
object SimpleScalaTest {
  def main(args: Array[String]):Unit= {
    val a = 5
    val b = 5
    assert(a == b)
      println("Assertion success")       
  }
}

然而,如果您使a = 2b = 1,例如,断言将失败,您将看到以下输出:

图 2:断言失败的一个示例

如果传递一个真表达式,assert 将正常返回。但是,如果提供的表达式为假,assert 将突然终止并出现断言错误。与AssertionErrorTestFailedException形式不同,ScalaTest 的 assert 提供了更多信息,可以告诉您测试用例失败的确切行或表达式。因此,ScalaTest 的 assert 提供了比 Scala 的 assert 更好的错误消息。

例如,对于以下源代码,您应该会遇到TestFailedException,告诉您 5 不等于 4:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object SimpleScalaTest {
  def main(args: Array[String]):Unit= {
    val a = 5
    val b = 4
    assert(a == b)
      println("Assertion success")       
  }
}

以下图显示了前面的 Scala 测试的输出:

图 3:TestFailedException 的一个示例

以下源代码解释了使用assertResult单元测试来测试方法的结果:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object AssertResult {
  def main(args: Array[String]):Unit= {
    val x = 10
    val y = 6
    assertResult(3) {
      x - y
    }
  }
}

前面的断言将失败,Scala 将抛出异常TestFailedException并打印Expected 3 but got 4图 4):

图 4:TestFailedException 的另一个示例

现在,让我们看一个单元测试来显示预期的异常:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object ExpectedException {
  def main(args: Array[String]):Unit= {
    val s = "Hello world!"
    try {
      s.charAt(0)
      fail()
    } catch {
      case _: IndexOutOfBoundsException => // Expected, so continue
    }
  }
}

如果尝试访问超出索引的数组元素,前面的代码将告诉您是否允许访问前面字符串Hello world!的第一个字符。如果您的 Scala 程序可以访问索引中的值,断言将失败。这也意味着测试用例失败了。因此,前面的测试用例自然会失败,因为第一个索引包含字符H,您应该看到以下错误消息(图 5):

图 5:TestFailedException 的第三个示例

然而,现在让我们尝试访问位置为-1的索引,如下所示:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object ExpectedException {
  def main(args: Array[String]):Unit= {
    val s = "Hello world!"
    try {
      s.charAt(-1)
      fail()
    } catch {
      case _: IndexOutOfBoundsException => // Expected, so continue
    }
  }
}

现在断言应该为真,因此测试用例将通过。最后,代码将正常终止。现在,让我们检查我们的代码片段是否会编译。很多时候,您可能希望确保代表出现的“用户错误”的代码的某种排序根本不会编译。目标是检查库对错误的强度,以阻止不需要的结果和行为。ScalaTest 的Assertions trait 包括以下语法:

assertDoesNotCompile("val a: String = 1")

如果您想确保一段代码由于类型错误(而不是语法错误)而无法编译,请使用以下方法:

assertTypeError("val a: String = 1")

语法错误仍会导致抛出TestFailedException。最后,如果您想要声明一段代码确实编译,可以使用以下方法更明显地表达:

assertCompiles("val a: Int = 1")

完整的示例如下所示:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._ 
object CompileOrNot {
  def main(args: Array[String]):Unit= {
    assertDoesNotCompile("val a: String = 1")
    println("assertDoesNotCompile True")

    assertTypeError("val a: String = 1")
    println("assertTypeError True")

    assertCompiles("val a: Int = 1")
    println("assertCompiles True")

    assertDoesNotCompile("val a: Int = 1")
    println("assertDoesNotCompile True")
  }
}

前面代码的输出如下图所示:

图 6:多个测试一起

由于页面限制,我们现在想要结束基于 Scala 的单元测试。但是,对于其他单元测试用例,您可以参考 Scala 测试指南www.scalatest.org/user_guide

单元测试

在软件工程中,通常会对源代码的单个单元进行测试,以确定它们是否适合使用。这种软件测试方法也称为单元测试。这种测试确保软件工程师或开发人员开发的源代码符合设计规范并按预期工作。

另一方面,单元测试的目标是以模块化的方式分离程序的每个部分。然后尝试观察所有单独部分是否正常工作。在任何软件系统中,单元测试有几个好处:

  • **早期发现问题:**它可以在开发周期的早期发现错误或规范的缺失部分。

  • **促进变更:**它有助于重构和升级,而不必担心破坏功能。

  • **简化集成:**它使集成测试更容易编写。

  • **文档:**它提供了系统的实时文档。

  • **设计:**它可以作为项目的正式设计。

测试 Spark 应用程序

我们已经看到如何使用 Scala 的内置ScalaTest包测试您的 Scala 代码。但是,在本小节中,我们将看到如何测试我们用 Scala 编写的 Spark 应用程序。将讨论以下三种方法:

  • **方法 1:**使用 JUnit 测试 Spark 应用程序

  • **方法 2:**使用ScalaTest包测试 Spark 应用程序

  • **方法 3:**使用 Spark 测试基础测试 Spark 应用程序

这里将讨论方法 1 和方法 2,并提供一些实际代码。但是,对方法 3 的详细讨论将在下一小节中提供。为了使理解简单易懂,我们将使用著名的单词计数应用程序来演示方法 1 和方法 2。

方法 1:使用 Scala JUnit 测试

假设您已经在 Scala 中编写了一个应用程序,可以告诉您文档或文本文件中有多少个单词,如下所示:

package com.chapter16.SparkTesting
import org.apache.spark._
import org.apache.spark.sql.SparkSession
class wordCounterTestDemo {
  val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName(s"OneVsRestExample")
    .getOrCreate()
  def myWordCounter(fileName: String): Long = {
    val input = spark.sparkContext.textFile(fileName)
    val counts = input.flatMap(_.split(" ")).distinct()
    val counter = counts.count()
    counter
  }
}

上述代码简单地解析文本文件,并通过简单地拆分单词执行flatMap操作。然后,它执行另一个操作,只考虑不同的单词。最后,myWordCounter方法计算有多少个单词,并返回计数器的值。

现在,在进行正式测试之前,让我们检查上述方法是否有效。只需添加主方法并创建一个对象,如下所示:

package com.chapter16.SparkTesting
import org.apache.spark._
import org.apache.spark.sql.SparkSession
object wordCounter {
  val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName("Testing")
    .getOrCreate()    
  val fileName = "data/words.txt";
  def myWordCounter(fileName: String): Long = {
    val input = spark.sparkContext.textFile(fileName)
    val counts = input.flatMap(_.split(" ")).distinct()
    val counter = counts.count()
    counter
  }
  def main(args: Array[String]): Unit = {
    val counter = myWordCounter(fileName)
    println("Number of words: " + counter)
  }
}

如果您执行上述代码,您应该观察到以下输出:单词数量:214。太棒了!它真的作为一个本地应用程序运行。现在,使用 Scala JUnit 测试用例测试上述测试用例。

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
import org.junit.Test
import org.apache.spark.sql.SparkSession
class wordCountTest {
  val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName(s"OneVsRestExample")
    .getOrCreate()   
    @Test def test() {
      val fileName = "data/words.txt"
      val obj = new wordCounterTestDemo()
      assert(obj.myWordCounter(fileName) == 214)
           }
    spark.stop()
}

如果您仔细查看先前的代码,您会发现在test()方法之前我使用了Test注解。在test()方法内部,我调用了assert()方法,其中实际的测试发生。在这里,我们尝试检查myWordCounter()方法的返回值是否等于 214。现在将先前的代码作为 Scala 单元测试运行,如下所示(图 7):

**图 7:**将 Scala 代码作为 Scala JUnit 测试运行

现在,如果测试用例通过,您应该在 Eclipse IDE 上观察以下输出(图 8):

**图 8:**单词计数测试用例通过

例如,尝试以以下方式断言:

assert(obj.myWordCounter(fileName) == 210)

如果上述测试用例失败,您应该观察到以下输出(图 9):

**图 9:**测试用例失败

现在让我们看一下方法 2 以及它如何帮助我们改进。

方法 2:使用 FunSuite 测试 Scala 代码

现在,让我们通过仅返回文档中文本的 RDD 来重新设计上述测试用例,如下所示:

package com.chapter16.SparkTesting
import org.apache.spark._
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.SparkSession
class wordCountRDD {
  def prepareWordCountRDD(file: String, spark: SparkSession): RDD[(String, Int)] = {
    val lines = spark.sparkContext.textFile(file)
    lines.flatMap(_.split(" ")).map((_, 1)).reduceByKey(_ + _)
  }
}

因此,上述类中的prepareWordCountRDD()方法返回一个字符串和整数值的 RDD。现在,如果我们想要测试prepareWordCountRDD()方法的功能,我们可以通过将测试类扩展为ScalaTest包的FunSuiteBeforeAndAfterAll来更明确地进行测试。测试以以下方式进行:

  • 将测试类扩展为ScalaTest包的FunSuiteBeforeAndAfterAll

  • 覆盖beforeAll()创建 Spark 上下文

  • 使用test()方法执行测试,并在test()方法内部使用assert()方法

  • 覆盖afterAll()方法停止 Spark 上下文

根据前面的步骤,让我们看一个用于测试前面的prepareWordCountRDD()方法的类:

package com.chapter16.SparkTesting
import org.scalatest.{ BeforeAndAfterAll, FunSuite }
import org.scalatest.Assertions._
import org.apache.spark.sql.SparkSession
import org.apache.spark.rdd.RDD
class wordCountTest2 extends FunSuite with BeforeAndAfterAll {
  var spark: SparkSession = null
  def tokenize(line: RDD[String]) = {
    line.map(x => x.split(' ')).collect()
  }
  override def beforeAll() {
    spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName(s"OneVsRestExample")
      .getOrCreate()
  }  
  test("Test if two RDDs are equal") {
    val input = List("To be,", "or not to be:", "that is the question-", "William Shakespeare")
    val expected = Array(Array("To", "be,"), Array("or", "not", "to", "be:"), Array("that", "is", "the", "question-"), Array("William", "Shakespeare"))
    val transformed = tokenize(spark.sparkContext.parallelize(input))
    assert(transformed === expected)
  }  
  test("Test for word count RDD") {
    val fileName = "C:/Users/rezkar/Downloads/words.txt"
    val obj = new wordCountRDD
    val result = obj.prepareWordCountRDD(fileName, spark)    
    assert(result.count() === 214)
  }
  override def afterAll() {
    spark.stop()
  }
}

第一个测试说,如果两个 RDD 以两种不同的方式实现,内容应该是相同的。因此,第一个测试应该通过。我们将在下面的示例中看到这一点。现在,对于第二个测试,正如我们之前看到的,RDD 的单词计数为 214,但让我们假设它暂时未知。如果它恰好是 214,测试用例应该通过,这是预期的行为。

因此,我们期望两个测试都通过。现在,在 Eclipse 上,运行测试套件作为ScalaTest-File,如下图所示:

**图 10:**作为 ScalaTest-File 运行测试套件

现在您应该观察以下输出(图 11)。输出显示我们执行了多少个测试用例,其中有多少通过、失败、取消、忽略或挂起。它还显示了执行整体测试所需的时间。

**图 11:**运行两个测试套件作为 ScalaTest 文件的测试结果

太棒了!测试用例通过了。现在,让我们尝试使用test()方法在两个单独的测试中更改断言中的比较值,如下所示:

test("Test for word count RDD") { 
  val fileName = "data/words.txt"
  val obj = new wordCountRDD
  val result = obj.prepareWordCountRDD(fileName, spark)    
  assert(result.count() === 210)
}
test("Test if two RDDs are equal") {
  val input = List("To be", "or not to be:", "that is the question-", "William Shakespeare")
  val expected = Array(Array("To", "be,"), Array("or", "not", "to", "be:"), Array("that", "is", "the", "question-"), Array("William", "Shakespeare"))
  val transformed = tokenize(spark.sparkContext.parallelize(input))
  assert(transformed === expected)
}

现在,您应该期望测试用例将失败。现在运行之前的类作为ScalaTest-File图 12):

**图 12:**运行前面的两个测试套件作为 ScalaTest-File 的测试结果

干得好!我们已经学会了如何使用 Scala 的 FunSuite 进行单元测试。然而,如果你仔细评估前面的方法,你会同意存在一些缺点。例如,您需要确保显式管理SparkContext的创建和销毁。作为开发人员或程序员,您必须编写更多的代码行来测试一个样本方法。有时,代码重复出现,因为BeforeAfter步骤必须在所有测试套件中重复。然而,这是值得讨论的,因为通用代码可以放在一个共同的特性中。

现在的问题是我们如何改善我们的体验?我的建议是使用 Spark 测试基础使生活更轻松和更直接。我们将讨论如何使用 Spark 测试基础进行单元测试。

方法 3:使用 Spark 测试基础使生活更轻松

Spark 测试基础帮助您轻松测试大部分 Spark 代码。那么,这种方法的优点是什么呢?实际上有很多。例如,使用这种方法,代码不啰嗦,但我们可以得到非常简洁的代码。API 本身比 ScalaTest 或 JUnit 更丰富。多语言支持,例如 Scala、Java 和 Python。它支持内置的 RDD 比较器。您还可以用它来测试流应用程序。最后但最重要的是,它支持本地和集群模式的测试。这对于在分布式环境中进行测试非常重要。

GitHub 仓库位于github.com/holdenk/spark-testing-base

在使用 Spark 测试基础进行单元测试之前,您应该在 Maven 友好的pom.xml文件中包含以下依赖项,以便在 Spark 2.x 项目树中使用:

<dependency>
  <groupId>com.holdenkarau</groupId>
  <artifactId>spark-testing-base_2.10</artifactId>
  <version>2.0.0_0.6.0</version>
</dependency>

对于 SBT,您可以添加以下依赖项:

"com.holdenkarau" %% "spark-testing-base" % "2.0.0_0.6.0"

请注意,建议在 Maven 和 SBT 的情况下通过指定<scope>test</scope>将前面的依赖项添加到test范围中。除此之外,还有其他考虑因素,如内存需求和 OOM 以及禁用并行执行。SBT 测试中的默认 Java 选项太小,无法支持运行多个测试。有时,如果作业以本地模式提交,测试 Spark 代码会更加困难!现在您可以自然地理解在真正的集群模式下(即 YARN 或 Mesos)会有多么困难。

为了摆脱这个问题,您可以在项目树中的build.sbt文件中增加内存量。只需添加以下参数:

javaOptions ++= Seq("-Xms512M", "-Xmx2048M", "-XX:MaxPermSize=2048M", "-XX:+CMSClassUnloadingEnabled")

但是,如果您使用 Surefire,可以添加以下内容:

<argLine>-Xmx2048m -XX:MaxPermSize=2048m</argLine>

在基于 Maven 的构建中,您可以通过设置环境变量的值来实现。有关此问题的更多信息,请参阅maven.apache.org/configure.html

这只是一个运行 spark 测试基础自己测试的例子。因此,您可能需要设置更大的值。最后,请确保您已经通过添加以下代码行来禁用 SBT 中的并行执行:

parallelExecution in Test := false

另一方面,如果您使用 surefire,请确保forkCountreuseForks分别设置为 1 和 true。让我们看一个使用 Spark 测试基础的例子。以下源代码有三个测试用例。第一个测试用例是一个比较,看看 1 是否等于 1,显然会通过。第二个测试用例计算句子中单词的数量,比如Hello world! My name is Reza,并比较是否有六个单词。最后一个测试用例尝试比较两个 RDD:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
import org.apache.spark.rdd.RDD
import com.holdenkarau.spark.testing.SharedSparkContext
import org.scalatest.FunSuite
class TransformationTestWithSparkTestingBase extends FunSuite with SharedSparkContext {
  def tokenize(line: RDD[String]) = {
    line.map(x => x.split(' ')).collect()
  }
  test("works, obviously!") {
    assert(1 == 1)
  }
  test("Words counting") {
    assert(sc.parallelize("Hello world My name is Reza".split("\\W")).map(_ + 1).count == 6)
  }
  test("Testing RDD transformations using a shared Spark Context") {
    val input = List("Testing", "RDD transformations", "using a shared", "Spark Context")
    val expected = Array(Array("Testing"), Array("RDD", "transformations"), Array("using", "a", "shared"), Array("Spark", "Context"))
    val transformed = tokenize(sc.parallelize(input))
    assert(transformed === expected)
  }
}

从前面的源代码中,我们可以看到我们可以使用 Spark 测试基础执行多个测试用例。成功执行后,您应该观察到以下输出(图 13):

**图 13:**使用 Spark 测试基础进行成功执行和通过测试的示例

在 Windows 上配置 Hadoop 运行时

我们已经看到如何在 Eclipse 或 IntelliJ 上测试用 Scala 编写的 Spark 应用程序,但还有一个潜在的问题不容忽视。尽管 Spark 可以在 Windows 上运行,但 Spark 是设计为在类 UNIX 操作系统上运行的。因此,如果您在 Windows 环境中工作,则需要额外小心。

在使用 Eclipse 或 IntelliJ 在 Windows 上开发用于解决数据分析、机器学习、数据科学或深度学习应用程序的 Spark 应用程序时,您可能会遇到 I/O 异常错误,您的应用程序可能无法成功编译或可能被中断。实际上,问题在于 Spark 期望在 Windows 上也有一个 Hadoop 的运行时环境。例如,如果您在 Eclipse 上首次运行 Spark 应用程序,比如KMeansDemo.scala,您将遇到一个 I/O 异常,内容如下:

17/02/26 13:22:00 ERROR Shell: Failed to locate the winutils binary in the hadoop binary path java.io.IOException: Could not locate executable null\bin\winutils.exe in the Hadoop binaries.

原因是默认情况下,Hadoop 是为 Linux 环境开发的,如果您在 Windows 平台上开发 Spark 应用程序,则需要一个桥梁,为 Spark 的 Hadoop 运行时提供一个正确执行的环境。I/O 异常的详细信息可以在下图中看到:

**图 14:**由于未能在 Hadoop 二进制路径中找到 winutils 二进制而发生的 I/O 异常

那么,如何解决这个问题呢?解决方案很简单。正如错误消息所说,我们需要一个可执行文件,即winutils.exe。现在从github.com/steveloughran/winutils/tree/master/hadoop-2.7.1/bin下载winutils.exe文件,将其粘贴到 Spark 分发目录中,并配置 Eclipse。更具体地说,假设您的包含 Hadoop 的 Spark 分发位于C:/Users/spark-2.1.0-bin-hadoop2.7。在 Spark 分发中,有一个名为 bin 的目录。现在,将可执行文件粘贴到那里(即路径=C:/Users/spark-2.1.0-binhadoop2.7/bin/)。

解决方案的第二阶段是转到 Eclipse,然后选择主类(即本例中的KMeansDemo.scala),然后转到运行菜单。从运行菜单中,转到运行配置选项,然后从中选择环境选项卡,如下图所示:

**图 15:**由于 Hadoop 二进制路径中缺少 winutils 二进制而发生的 I/O 异常的解决方案

如果您选择该选项卡,您将有选项为 Eclipse 使用 JVM 创建新的环境变量。现在创建一个名为HADOOP_HOME的新环境变量,并将值设置为C:/Users/spark-2.1.0-bin-hadoop2.7/。现在点击“应用”按钮并重新运行您的应用程序,您的问题应该得到解决。

需要注意的是,在 Windows 上使用 PySpark 时,也需要winutils.exe文件。有关 PySpark 的参考,请参阅第十九章,PySpark 和 SparkR

请注意,前面的解决方案也适用于调试您的应用程序。有时,即使出现前面的错误,您的 Spark 应用程序也会正常运行。但是,如果数据集的大小很大,前面的错误很可能会发生。

调试 Spark 应用程序

在本节中,我们将看到如何调试在 Eclipse 或 IntelliJ 上本地运行(独立或集群模式在 YARN 或 Mesos 中)的 Spark 应用程序。然而,在深入讨论之前,有必要了解 Spark 应用程序中的日志记录。

使用 log4j 记录 Spark 回顾

我们已经在第十四章,使用 Spark MLlib 对数据进行集群化中讨论过这个话题。然而,让我们重复相同的内容,以使您的思维与当前讨论调试 Spark 应用程序保持一致。如前所述,Spark 使用 log4j 进行自身的日志记录。如果您正确配置了 Spark,Spark 会将所有操作记录到 shell 控制台。以下是文件的样本快照:

图 16: log4j.properties 文件的快照

将默认的 spark-shell 日志级别设置为 WARN。运行 spark-shell 时,此类的日志级别用于覆盖根记录器的日志级别,以便用户可以为 shell 和常规 Spark 应用程序设置不同的默认值。当启动由执行器执行并由驱动程序管理的作业时,我们还需要附加 JVM 参数。为此,您应该编辑conf/spark-defaults.conf。简而言之,可以添加以下选项:

spark.executor.extraJavaOptions=-Dlog4j.configuration=file:/usr/local/spark-2.1.1/conf/log4j.properties spark.driver.extraJavaOptions=-Dlog4j.configuration=file:/usr/local/spark-2.1.1/conf/log4j.properties

为了使讨论更清晰,我们需要隐藏 Spark 生成的所有日志。然后我们可以将它们重定向到文件系统中进行记录。另一方面,我们希望我们自己的日志被记录在 shell 和单独的文件中,这样它们就不会与 Spark 的日志混在一起。从这里开始,我们将指向 Spark 的文件,其中我们自己的日志所在,特别是/var/log/sparkU.log。这个log4j.properties文件在应用程序启动时被 Spark 接管,因此我们除了将其放在指定的位置之外,不需要做任何事情:

package com.chapter14.Serilazition
import org.apache.log4j.LogManager
import org.apache.log4j.Level
import org.apache.spark.sql.SparkSession
object myCustomLog {
  def main(args: Array[String]): Unit = {   
    val log = LogManager.getRootLogger    
    //Everything is printed as INFO once the log level is set to INFO untill you set the level to new level for example WARN. 
    log.setLevel(Level.INFO)
    log.info("Let's get started!")    
    // Setting logger level as WARN: after that nothing prints other than WARN
    log.setLevel(Level.WARN)    
    // Creating Spark Session
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName("Logging")
      .getOrCreate()
    // These will note be printed!
    log.info("Get prepared!")
    log.trace("Show if there is any ERROR!")
    //Started the computation and printing the logging information
    log.warn("Started")
    spark.sparkContext.parallelize(1 to 20).foreach(println)
    log.warn("Finished")
  }
}

在上述代码中,一旦将日志级别设置为INFO,则所有内容都将以 INFO 打印,直到将级别设置为新级别,例如WARN。然而,在那之后,不会打印任何信息或跟踪等,不会被打印。除此之外,log4j 与 Spark 支持几个有效的日志记录级别。前面的代码成功执行应该生成以下输出:

17/05/13 16:39:14 INFO root: Let's get started!
17/05/13 16:39:15 WARN root: Started
4 
1 
2 
5 
3 
17/05/13 16:39:16 WARN root: Finished

您还可以在conf/log4j.properties中设置 Spark shell 的默认日志记录。Spark 提供了 log4j 的模板作为属性文件,我们可以扩展和修改该文件以记录 Spark 的日志。转到SPARK_HOME/conf目录,您应该看到log4j.properties.template文件。将其重命名为log4j.properties后,您应该使用以下conf/log4j.properties.template。在开发 Spark 应用程序时,您可以将log4j.properties文件放在项目目录下,例如在 Eclipse 等基于 IDE 的环境中工作。但是,要完全禁用日志记录,只需将log4j.logger.org标志设置为OFF,如下所示:

log4j.logger.org=OFF

到目前为止,一切都很容易。然而,在前面的代码段中,我们还没有注意到一个问题。org.apache.log4j.Logger类的一个缺点是它不可序列化,这意味着我们不能在对 Spark API 的某些部分进行操作时在闭包内使用它。例如,假设我们在我们的 Spark 代码中执行以下操作:

object myCustomLogger {
  def main(args: Array[String]):Unit= {
    // Setting logger level as WARN
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    // Creating Spark Context
    val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
    val sc = new SparkContext(conf)
    //Started the computation and printing the logging information
    //log.warn("Started")
    val i = 0
    val data = sc.parallelize(i to 100000)
    data.map{number =>
      log.info(“My number”+ i)
      number.toString
    }
    //log.warn("Finished")
  }
}

您应该会遇到一个异常,显示“任务”不可序列化,如下所示:

org.apache.spark.SparkException: Job aborted due to stage failure: Task not serializable: java.io.NotSerializableException: ...
Exception in thread "main" org.apache.spark.SparkException: Task not serializable 
Caused by: java.io.NotSerializableException: org.apache.log4j.spi.RootLogger
Serialization stack: object not serializable

首先,我们可以尝试以一种天真的方式解决这个问题。您可以做的是使执行实际操作的 Scala 类(使用extends Serializable)可序列化。例如,代码如下所示:

class MyMapper(n: Int) extends Serializable {
  @transient lazy val log = org.apache.log4j.LogManager.getLogger("myLogger")
  def logMapper(rdd: RDD[Int]): RDD[String] =
    rdd.map { i =>
      log.warn("mapping: " + i)
      (i + n).toString
    }
  }

本节旨在讨论日志记录。然而,我们借此机会使其更具通用性,适用于 Spark 编程和问题。为了更有效地克服“任务不可序列化”错误,编译器将尝试通过使其可序列化并强制 SPark 接受整个对象(而不仅仅是 lambda)来发送整个对象。然而,这会显著增加洗牌,特别是对于大对象!其他方法包括使整个类Serializable或仅在 map 操作中传递的 lambda 函数内声明实例。有时,跨节点保留不可序列化的对象也可以起作用。最后,使用forEachPartition()mapPartitions()而不仅仅是map()并创建不可序列化的对象。总之,这些是解决该问题的方法:

  • 使类可序列化

  • 仅在 map 中传递的 lambda 函数内声明实例

  • 将 NotSerializable 对象设置为静态,并在每台机器上创建一次

  • 调用forEachPartition()mapPartitions()而不是map()并创建 NotSerializable 对象

在前面的代码中,我们使用了@transient lazy注解,将Logger类标记为非持久化。另一方面,包含apply方法(即MyMapperObject)的对象,它实例化了MyMapper类的对象如下:

//Companion object 
object MyMapper {
  def apply(n: Int): MyMapper = new MyMapper(n)
}

最后,包含main()方法的对象如下:

//Main object
object myCustomLogwithClosureSerializable {
  def main(args: Array[String]) {
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName("Testing")
      .getOrCreate()
    log.warn("Started")
    val data = spark.sparkContext.parallelize(1 to 100000)
    val mapper = MyMapper(1)
    val other = mapper.logMapper(data)
    other.collect()
    log.warn("Finished")
  }

现在,让我们看另一个例子,它提供了更好的洞察力,以继续解决我们正在讨论的问题。假设我们有一个计算两个整数乘法的类如下:

class MultiplicaitonOfTwoNumber {
  def multiply(a: Int, b: Int): Int = {
    val product = a * b
    product
  }
}

现在,如果您尝试在 lambda 闭包中使用此类来计算乘法,您将得到我们之前描述的“任务不可序列化”错误。现在我们可以简单地使用foreachPartition()和 lambda,如下所示:

val myRDD = spark.sparkContext.parallelize(0 to 1000)
    myRDD.foreachPartition(s => {
      val notSerializable = new MultiplicaitonOfTwoNumber
      println(notSerializable.multiply(s.next(), s.next()))
    })

现在,如果您编译它,应该返回所需的结果。为了方便起见,包含main()方法的完整代码如下:

package com.chapter16.SparkTesting
import org.apache.spark.sql.SparkSession
class MultiplicaitonOfTwoNumber {
  def multiply(a: Int, b: Int): Int = {
    val product = a * b
    product
  }
}
object MakingTaskSerilazible {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName("MakingTaskSerilazible")
      .getOrCreate()
 val myRDD = spark.sparkContext.parallelize(0 to 1000)
    myRDD.foreachPartition(s => {
      val notSerializable = new MultiplicaitonOfTwoNumber
      println(notSerializable.multiply(s.next(), s.next()))
    })
  }
}

输出如下:

0
5700
1406
156
4032
7832
2550
650

调试 Spark 应用程序

在本节中,我们将讨论如何在 Eclipse 或 IntelliJ 上本地运行或以 YARN 或 Mesos 的独立或集群模式运行的 Spark 应用程序进行调试。在开始之前,您还可以阅读https://hortonworks.com/hadoop-tutorial/setting-spark-development-environment-scala/上的调试文档。

在 Eclipse 上调试 Spark 应用程序作为 Scala 调试

为了实现这一点,只需将您的 Eclipse 配置为调试您的 Spark 应用程序,就像调试常规的 Scala 代码一样。要配置,请选择 Run | Debug Configuration | Scala Application,如下图所示:

**图 17:**配置 Eclipse 以调试 Spark 应用程序,作为常规的 Scala 代码调试

假设我们想要调试我们的KMeansDemo.scala并要求 Eclipse(您也可以在 InteliJ IDE 上有类似的选项)从第 56 行开始执行,并在第 95 行设置断点。要这样做,运行您的 Scala 代码进行调试,您应该在 Eclipse 上观察到以下情景:

**图 18:**在 Eclipse 上调试 Spark 应用程序

然后,Eclipse 将在你要求它在第 95 行停止执行时暂停,如下面的截图所示:

**图 19:**在 Eclipse 上调试 Spark 应用程序(断点)

总之,为了简化上面的例子,如果在第 56 行和第 95 行之间有任何错误,Eclipse 将显示错误实际发生的位置。否则,如果没有中断,它将按照正常的工作流程进行。

在本地和独立模式下运行 Spark 作业的调试

在本地或独立模式下调试你的 Spark 应用程序时,你应该知道调试驱动程序程序和调试执行程序之间是不同的,因为使用这两种类型的节点需要传递不同的提交参数给spark-submit。在本节中,我将使用端口 4000 作为地址。例如,如果你想调试驱动程序程序,你可以将以下内容添加到你的spark-submit命令中:

--driver-java-options -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000

之后,你应该设置你的远程调试器连接到你提交驱动程序的节点。对于前面的情况,指定了端口号 4000。然而,如果某些东西(即其他 Spark 作业、其他应用程序或服务等)已经在该端口上运行,你可能还需要自定义该端口,即更改端口号。

另一方面,连接到执行程序与前面的选项类似,除了地址选项。更具体地说,你需要用你本地机器的地址(IP 地址或带有端口号的主机名)替换地址。然而,测试你是否可以从实际计算发生的 Spark 集群访问你的本地机器是一种良好的实践和建议。例如,你可以使用以下选项使调试环境对你的spark-submit命令启用:

--num-executors 1\
--executor-cores 1 \
--conf "spark.executor.extraJavaOptions=-agentlib:jdwp=transport=dt_socket,server=n,address=localhost:4000,suspend=n"

总之,使用以下命令提交你的 Spark 作业(在这种情况下是KMeansDemo应用程序):

$ SPARK_HOME/bin/spark-submit \
--class "com.chapter13.Clustering.KMeansDemo" \
--master spark://ubuntu:7077 \
--num-executors 1\
--executor-cores 1 \
--conf "spark.executor.extraJavaOptions=-agentlib:jdwp=transport=dt_socket,server=n,address= host_name_to_your_computer.org:5005,suspend=n" \
--driver-java-options -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000 \
 KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar \
Saratoga_NY_Homes.txt

现在,启动你的本地调试器处于监听模式,并启动你的 Spark 程序。最后,等待执行程序连接到你的调试器。你将在你的终端上看到以下消息:

Listening for transport dt_socket at address: 4000 

重要的是要知道,你只需要将执行程序的数量设置为 1。设置多个执行程序将尝试连接到你的调试器,并最终创建一些奇怪的问题。需要注意的是,有时设置SPARK_JAVA_OPTS有助于调试在本地或独立模式下运行的 Spark 应用程序。命令如下:

$ export SPARK_JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,address=4000,suspend=y,onuncaught=n

然而,自 Spark 1.0.0 发布以来,SPARK_JAVA_OPTS已被弃用,并由spark-defaults.conf和传递给 Spark-submit 或 Spark-shell 的命令行参数取代。需要注意的是,在spark-defaults.conf中设置spark.driver.extraJavaOptionsspark.executor.extraJavaOptions并不是SPARK_JAVA_OPTS的替代。但坦率地说,SPARK_JAVA_OPTS仍然运行得很好,你也可以尝试一下。

在 YARN 或 Mesos 集群上调试 Spark 应用程序

在 YARN 上运行 Spark 应用程序时,有一个选项可以通过修改yarn-env.sh来启用:

YARN_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=4000 $YARN_OPTS"

现在,远程调试将通过 Eclipse 或 IntelliJ IDE 上的端口 4000 可用。第二个选项是通过设置SPARK_SUBMIT_OPTS。你可以使用 Eclipse 或 IntelliJ 开发你的 Spark 应用程序,然后将其提交以在远程多节点 YARN 集群上执行。我在 Eclipse 或 IntelliJ 上创建一个 Maven 项目,并将我的 Java 或 Scala 应用程序打包为一个 jar 文件,然后将其提交为一个 Spark 作业。然而,为了将你的 IDE(如 Eclipse 或 IntelliJ)调试器连接到你的 Spark 应用程序,你可以使用SPARK_SUBMIT_OPTS环境变量定义所有的提交参数,如下所示:

$ export SPARK_SUBMIT_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000

然后按照以下方式提交你的 Spark 作业(请根据你的需求和设置相应地更改值):

$ SPARK_HOME/bin/spark-submit \
--class "com.chapter13.Clustering.KMeansDemo" \
--master yarn \
--deploy-mode cluster \
--driver-memory 16g \
--executor-memory 4g \
--executor-cores 4 \
--queue the_queue \
--num-executors 1\
--executor-cores 1 \
--conf "spark.executor.extraJavaOptions=-agentlib:jdwp=transport=dt_socket,server=n,address= host_name_to_your_computer.org:4000,suspend=n" \
--driver-java-options -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000 \
 KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar \
Saratoga_NY_Homes.txt

运行上述命令后,它将等待您连接调试器,如下所示:Listening for transport dt_socket at address: 4000。现在,您可以在 IntelliJ 调试器上配置您的 Java 远程应用程序(Scala 应用程序也可以),如下截图所示:

图 20:在 IntelliJ 上配置远程调试器

对于上述情况,10.200.1.101 是远程计算节点的 IP 地址,您的 Spark 作业基本上是在该节点上运行的。最后,您将需要通过在 IntelliJ 的运行菜单下单击“调试”来启动调试器。然后,如果调试器连接到您的远程 Spark 应用程序,您将在 IntelliJ 的应用程序控制台中看到日志信息。现在,如果您可以设置断点,其他操作都是正常的调试。下图显示了在 IntelliJ 上暂停具有断点的 Spark 作业时的示例:

图 21:在 IntelliJ 上暂停 Spark 作业并设置断点时的示例

尽管它运行良好,但有时我发现在 Eclipse 甚至 IntelliJ 上使用SPARK_JAVA_OPTS并不会对调试过程有太大帮助。相反,当在真实集群(YARN、Mesos 或 AWS)上运行 Spark 作业时,请使用和导出SPARK_WORKER_OPTSSPARK_MASTER_OPTS,如下所示:

$ export SPARK_WORKER_OPTS="-Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=4000,suspend=n"
$ export SPARK_MASTER_OPTS="-Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=4000,suspend=n"

然后按以下方式启动您的 Master 节点:

$ SPARKH_HOME/sbin/start-master.sh

现在打开一个 SSH 连接到实际运行 Spark 作业的远程机器,并将您的本地主机映射到host_name_to_your_computer.org:5000的 4000 端口(即localhost:4000),假设集群位于host_name_to_your_computer.org:5000并在端口 5000 上监听。现在,您的 Eclipse 将认为您只是在调试本地 Spark 应用程序或进程。但是,要实现这一点,您将需要在 Eclipse 上配置远程调试器,如下图所示:

图 22:在 Eclipse 上连接远程主机以调试 Spark 应用程序

就是这样!现在您可以像在桌面上一样在您的实时集群上进行调试。上述示例是在将 Spark Master 设置为 YARN-client 模式下运行时的。但是,当在 Mesos 集群上运行时,它也应该起作用。如果您使用 YARN-cluster 模式运行,您可能需要将驱动程序设置为连接到调试器,而不是将调试器附加到驱动程序,因为您不一定会预先知道驱动程序将在哪种模式下执行。

使用 SBT 调试 Spark 应用程序

上述设置在大多数情况下适用于使用 Maven 项目的 Eclipse 或 IntelliJ。假设您已经完成了应用程序,并且正在使用您喜欢的 IDE(如 IntelliJ 或 Eclipse)进行工作,如下所示:

object DebugTestSBT {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "C:/Exp/")
      .appName("Logging")
      .getOrCreate()      
    spark.sparkContext.setCheckpointDir("C:/Exp/")
    println("-------------Attach debugger now!--------------")
    Thread.sleep(8000)
    // code goes here, with breakpoints set on the lines you want to pause
  }
}

现在,如果您想将此作业提交到本地集群(独立运行),第一步是将应用程序及其所有依赖项打包成一个 fat JAR。为此,请使用以下命令:

$ sbt assembly

这将生成 fat JAR。现在的任务是将 Spark 作业提交到本地集群。您需要在系统的某个地方有 spark-submit 脚本:

$ export SPARK_JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

上述命令导出一个 Java 参数,该参数将用于启动带有调试器的 Spark:

$ SPARK_HOME/bin/spark-submit --class Test --master local[*] --driver-memory 4G --executor-memory 4G /path/project-assembly-0.0.1.jar

在上述命令中,--class需要指向作业的完全限定类路径。成功执行此命令后,您的 Spark 作业将在不中断断点的情况下执行。现在,要在您的 IDE(比如 IntelliJ)上获得调试功能,您需要配置连接到集群。有关官方 IDEA 文档的更多详细信息,请参考stackoverflow.com/questions/21114066/attach-intellij-idea-debugger-to-a-running-java-process

需要注意的是,如果您只创建一个默认的远程运行/调试配置并保留默认端口 5005,它应该可以正常工作。现在,当您提交下一次作业并看到附加调试器的消息时,您有八秒钟切换到 IntelliJ IDEA 并触发此运行配置。程序将继续执行并在您定义的任何断点处暂停。然后,您可以像任何普通的 Scala/Java 程序一样逐步执行它。您甚至可以进入 Spark 函数以查看它在幕后做了什么。

总结

在本章中,您看到了测试和调试 Spark 应用程序有多么困难。在分布式环境中,这甚至可能更加关键。我们还讨论了一些解决这些问题的高级方法。总之,您学会了在分布式环境中进行测试的方法。然后,您学会了更好地测试您的 Spark 应用程序。最后,我们讨论了一些调试 Spark 应用程序的高级方法。

我们相信这本书将帮助您对 Spark 有一些很好的理解。然而,由于页面限制,我们无法涵盖许多 API 及其基本功能。如果您遇到任何问题,请不要忘记向 Spark 用户邮件列表user@spark.apache.org报告。在这样做之前,请确保您已经订阅了它。

这更多或多少是我们在 Spark 高级主题上的小旅程的结束。现在,我们对您作为读者的一般建议是,如果您对数据科学、数据分析、机器学习、Scala 或 Spark 相对较新,您应该首先尝试了解您想要执行的分析类型。更具体地说,例如,如果您的问题是一个机器学习问题,尝试猜测哪种类型的学习算法应该是最合适的,即分类、聚类、回归、推荐或频繁模式挖掘。然后定义和规划问题,之后,您应该基于我们之前讨论过的 Spark 的特征工程概念生成或下载适当的数据。另一方面,如果您认为您可以使用深度学习算法或 API 解决问题,您应该使用其他第三方算法并与 Spark 集成并立即工作。

我们最后的建议是,读者定期浏览 Spark 网站(spark.apache.org/)以获取更新,并尝试将常规提供的 Spark API 与其他第三方应用程序或工具结合起来,以获得合作的最佳结果。

第十九章:PySpark 和 SparkR

在本章中,我们将讨论另外两个流行的 API:PySpark 和 SparkR,分别用于使用 Python 和 R 编程语言编写 Spark 代码。本章的第一部分将涵盖在使用 PySpark 时的一些技术方面。然后我们将转向 SparkR,并看看如何轻松使用它。本章将在整个过程中讨论以下主题:

  • PySpark 介绍

  • 安装和开始使用 PySpark

  • 与 DataFrame API 交互

  • 使用 PySpark 的 UDFs

  • 使用 PySpark 进行数据分析

  • SparkR 介绍

  • 为什么要使用 SparkR?

  • 安装和开始使用 SparkR

  • 数据处理和操作

  • 使用 SparkR 处理 RDD 和 DataFrame

  • 使用 SparkR 进行数据可视化

PySpark 介绍

Python 是最受欢迎的通用编程语言之一,具有许多令人兴奋的特性,可用于数据处理和机器学习任务。为了从 Python 中使用 Spark,最初开发了 PySpark 作为 Python 到 Apache Spark 的轻量级前端,并使用 Spark 的分布式计算引擎。在本章中,我们将讨论使用 Python IDE(如 PyCharm)从 Python 中使用 Spark 的一些技术方面。

许多数据科学家使用 Python,因为它具有丰富的数值库,具有统计、机器学习或优化的重点。然而,在 Python 中处理大规模数据集通常很麻烦,因为运行时是单线程的。因此,只能处理适合主内存的数据。考虑到这一限制,并为了在 Python 中获得 Spark 的全部功能,PySpark 最初被开发为 Python 到 Apache Spark 的轻量级前端,并使用 Spark 的分布式计算引擎。这样,Spark 提供了非 JVM 语言(如 Python)的 API。

这个 PySpark 部分的目的是提供使用 PySpark 的基本分布式算法。请注意,PySpark 是用于基本测试和调试的交互式 shell,不应该用于生产环境。

安装和配置

有许多安装和配置 PySpark 在 Python IDEs 如 PyCharm,Spider 等的方法。或者,如果您已经安装了 Spark 并配置了SPARK_HOME,您可以使用 PySpark。第三,您也可以从 Python shell 使用 PySpark。接下来我们将看到如何配置 PySpark 来运行独立的作业。

通过设置 SPARK_HOME

首先,下载并将 Spark 分发放在您喜欢的位置,比如/home/asif/Spark。现在让我们设置SPARK_HOME如下:

echo "export SPARK_HOME=/home/asif/Spark" >> ~/.bashrc

现在让我们设置PYTHONPATH如下:

echo "export PYTHONPATH=$SPARK_HOME/python/" >> ~/.bashrc
echo "export PYTHONPATH=$SPARK_HOME/python/lib/py4j-0.10.1-src.zip" >> ~/.bashrc

现在我们需要将以下两个路径添加到环境路径中:

echo "export PATH=$PATH:$SPARK_HOME" >> ~/.bashrc
echo "export PATH=$PATH:$PYTHONPATH" >> ~/.bashrc

最后,让我们刷新当前终端,以便使用新修改的PATH变量:

source ~/.bashrc

PySpark 依赖于py4j Python 包。它帮助 Python 解释器动态访问来自 JVM 的 Spark 对象。可以在 Ubuntu 上安装此软件包,方法如下:

$ sudo pip install py4j

或者,也可以使用默认的py4j,它已经包含在 Spark 中($SPARK_HOME/python/lib)。

使用 Python shell

与 Scala 交互式 shell 一样,Python 也有一个交互式 shell。您可以从 Spark 根文件夹执行 Python 代码,如下所示:

$ cd $SPARK_HOME
$ ./bin/pyspark

如果命令执行正常,您应该在终端(Ubuntu)上观察到以下屏幕:

图 1:使用 PySpark shell 入门

现在您可以使用 Python 交互式 shell 来使用 Spark。这个 shell 可能足够用于实验和开发。但是,对于生产级别,您应该使用独立的应用程序。

PySpark 现在应该在系统路径中可用。编写 Python 代码后,可以简单地使用 Python 命令运行代码,然后它将在本地 Spark 实例中以默认配置运行:

$ python <python_file.py>

请注意,当前版本的 Spark 仅兼容 Python 2.7+。因此,我们将严格遵守这一点。

此外,如果您想在运行时传递配置值,最好使用spark-submit脚本。该命令与 Scala 的命令非常相似:

$ cd $SPARK_HOME
$ ./bin/spark-submit  --master local[*] <python_file.py>

配置值可以在运行时传递,或者可以在conf/spark-defaults.conf文件中进行更改。在配置 Spark 配置文件之后,运行 PySpark 应用程序时,这些更改也会反映出来,只需使用简单的 Python 命令。

然而,不幸的是,在撰写本文时,使用 PySpark 没有 pip 安装优势。但预计在 Spark 2.2.0 版本中将可用(有关更多信息,请参阅issues.apache.org/jira/browse/SPARK-1267)。为什么 PySpark 没有 pip 安装的原因可以在 JIRA 票证issues.apache.org/jira/browse/SPARK-1267中找到。

通过在 Python IDEs 上设置 PySpark

我们还可以在 Python IDEs(如 PyCharm)中配置和运行 PySpark。在本节中,我们将展示如何操作。如果您是学生,您可以在www.jetbrains.com/student/上使用您的大学/学院/研究所电子邮件地址注册后获得 PyCharm 的免费许可副本。此外,PyCharm 还有一个社区(即免费)版本,因此您不需要是学生才能使用它。

最近,PySpark 已经发布了 Spark 2.2.0 PyPI(请参阅pypi.python.org/pypi/pyspark)。这是一个漫长的过程(以前的版本包括 pip 可安装的构件,由于各种原因无法发布到 PyPI)。因此,如果您(或您的朋友)希望能够在笔记本电脑上本地使用 PySpark,您可以更容易地开始,只需执行以下命令:

$ sudo pip install pyspark # for python 2.7 
$ sudo pip3 install pyspark # for python 3.3+

然而,如果您使用的是 Windos 7、8 或 10,您应该手动安装 pyspark。例如,使用 PyCharm,您可以按照以下步骤操作:

图 2:在 Windows 10 上的 Pycharm IDE 上安装 PySpark

首先,您应该创建一个带有项目解释器的 Python 脚本,解释器为 Python 2.7+。然后,您可以按照以下方式导入 pyspark 以及其他所需的模块:

import os
import sys
import pyspark

现在,如果您是 Windows 用户,Python 还需要具有 Hadoop 运行时;您应该将winutils.exe文件放在SPARK_HOME/bin文件夹中。然后按以下方式创建环境变量:

选择您的 python 文件 | 运行 | 编辑配置 | 创建一个环境变量,其键为HADOOP_HOME,值为PYTHON_PATH,例如对于我的情况,它是C:\Users\admin-karim\Downloads\spark-2.1.0-bin-hadoop2.7。最后,按下 OK,然后您就完成了:

图 3:在 Windows 10 上的 Pycharm IDE 上设置 Hadoop 运行时环境

这就是您需要的全部。现在,如果您开始编写 Spark 代码,您应该首先将导入放在try块中,如下所示(仅供参考):

try: 
    from pyspark.ml.featureimport PCA
    from pyspark.ml.linalgimport Vectors
    from pyspark.sqlimport SparkSession
    print ("Successfully imported Spark Modules")

catch块可以放在以下位置:

ExceptImportErroras e: 
    print("Can not import Spark Modules", e)
    sys.exit(1)

请参考以下图,显示在 PySpark shell 中导入和放置 Spark 包:

图 4:在 PySpark shell 中导入和放置 Spark 包

如果这些块成功执行,您应该在控制台上观察到以下消息:

图 5:PySpark 包已成功导入

开始使用 PySpark

在深入之前,首先我们需要看一下如何创建 Spark 会话。可以按照以下步骤完成:

spark = SparkSession\
         .builder\
         .appName("PCAExample")\
         .getOrCreate()

现在在这个代码块下,您应该放置您的代码,例如:

data = [(Vectors.sparse(5, [(1, 1.0), (3, 7.0)]),),
         (Vectors.dense([2.0, 0.0, 3.0, 4.0, 5.0]),),
         (Vectors.dense([4.0, 0.0, 0.0, 6.0, 7.0]),)]
 df = spark.createDataFrame(data, ["features"])

 pca = PCA(k=3, inputCol="features", outputCol="pcaFeatures")
 model = pca.fit(df)

 result = model.transform(df).select("pcaFeatures")
 result.show(truncate=False)

上述代码演示了如何在 RowMatrix 上计算主要成分,并将它们用于将向量投影到低维空间。为了更清晰地了解情况,请参考以下代码,该代码显示了如何在 PySpark 上使用 PCA 算法:

import os
import sys

try:
from pyspark.sql import SparkSession
from pyspark.ml.feature import PCA
from pyspark.ml.linalg import Vectors
print ("Successfully imported Spark Modules")

except ImportErrorase:
print ("Can not import Spark Modules", e)
 sys.exit(1)

spark = SparkSession\
   .builder\
   .appName("PCAExample")\
   .getOrCreate()

data = [(Vectors.sparse(5, [(1, 1.0), (3, 7.0)]),),
    (Vectors.dense([2.0, 0.0, 3.0, 4.0, 5.0]),),
    (Vectors.dense([4.0, 0.0, 0.0, 6.0, 7.0]),)]
df = spark.createDataFrame(data, ["features"])

pca = PCA(k=3, inputCol="features", outputCol="pcaFeatures")
model = pca.fit(df)

result = model.transform(df).select("pcaFeatures")
result.show(truncate=False)

spark.stop()

输出如下:

图 6:Python 脚本成功执行后的 PCA 结果

使用 DataFrames 和 RDDs

SparkDataFrame 是具有命名列的分布式行集合。从技术上讲,它可以被视为具有列标题的关系数据库中的表。此外,PySpark DataFrame 类似于 Python pandas。但是,它还与 RDD 共享一些相同的特征:

  • 不可变:就像 RDD 一样,一旦创建了 DataFrame,就无法更改。在应用转换后,我们可以将 DataFrame 转换为 RDD,反之亦然。

  • 惰性评估:其性质是惰性评估。换句话说,任务直到执行操作才会被执行。

  • 分布式:RDD 和 DataFrame 都具有分布式特性。

与 Java/Scala 的 DataFrame 一样,PySpark DataFrame 专为处理大量结构化数据而设计;甚至可以处理 PB 级数据。表格结构帮助我们了解 DataFrame 的模式,这也有助于优化 SQL 查询的执行计划。此外,它具有广泛的数据格式和来源。

您可以使用 PySpark 以多种方式创建 RDD、数据集和 DataFrame。在接下来的小节中,我们将展示一些示例。

以 Libsvm 格式读取数据集

让我们看看如何使用读取 API 和load()方法以指定数据格式(即libsvm)来以 LIBSVM 格式读取数据:

# Creating DataFrame from libsvm dataset
 myDF = spark.read.format("libsvm").load("C:/Exp//mnist.bz2")

可以从www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/mnist.bz2下载前述的 MNIST 数据集。这将返回一个 DataFrame,可以通过调用show()方法查看内容如下:

myDF.show() 

输出如下:

图 7:LIBSVM 格式手写数据集的快照

您还可以指定其他选项,例如原始数据集中要给 DataFrame 的特征数量如下:

myDF= spark.read.format("libsvm")
           .option("numFeatures", "780")
           .load("data/Letterdata_libsvm.data")

现在,如果您想从相同的数据集创建 RDD,可以使用pyspark.mllib.util中的 MLUtils API 如下:

*Creating RDD from the libsvm data file* myRDD = MLUtils.loadLibSVMFile(spark.sparkContext, "data/Letterdata_libsvm.data")

现在,您可以按以下方式将 RDD 保存在首选位置:

myRDD.saveAsTextFile("data/myRDD")

读取 CSV 文件

让我们从加载、解析和查看简单的航班数据开始。首先,从s3-us-west-2.amazonaws.com/sparkr-data/nycflights13.csv下载 NYC 航班数据集作为 CSV。现在让我们使用 PySpark 的read.csv() API 加载和解析数据集:

# Creating DataFrame from data file in CSV formatdf = spark.read.format("com.databricks.spark.csv")
          .option("header", "true")
          .load("data/nycflights13.csv")

这与读取 libsvm 格式非常相似。现在您可以查看生成的 DataFrame 的结构如下:

df.printSchema() 

输出如下:

图 8:NYC 航班数据集的模式

现在让我们使用show()方法查看数据集的快照如下:

df.show() 

现在让我们查看数据的样本如下:

图 9:NYC 航班数据集的样本

读取和操作原始文本文件

您可以使用textFile()方法读取原始文本数据文件。假设您有一些购买的日志:

number\tproduct_name\ttransaction_id\twebsite\tprice\tdate0\tjeans\t30160906182001\tebay.com\t100\t12-02-20161\tcamera\t70151231120504\tamazon.com\t450\t09-08-20172\tlaptop\t90151231120504\tebay.ie\t1500\t07--5-20163\tbook\t80151231120506\tpackt.com\t45\t03-12-20164\tdrone\t8876531120508\talibaba.com\t120\t01-05-2017

现在,使用textFile()方法读取和创建 RDD 非常简单如下:

myRDD = spark.sparkContext.textFile("sample_raw_file.txt")
$cd myRDD
$ cat part-00000  
number\tproduct_name\ttransaction_id\twebsite\tprice\tdate  0\tjeans\t30160906182001\tebay.com\t100\t12-02-20161\tcamera\t70151231120504\tamazon.com\t450\t09-08-2017

如您所见,结构并不那么可读。因此,我们可以考虑通过将文本转换为 DataFrame 来提供更好的结构。首先,我们需要收集标题信息如下:

header = myRDD.first() 

现在过滤掉标题,并确保其余部分看起来正确如下:

textRDD = myRDD.filter(lambda line: line != header)
newRDD = textRDD.map(lambda k: k.split("\\t"))

我们仍然有 RDD,但数据结构稍微好一些。但是,将其转换为 DataFrame 将提供更好的事务数据视图。

以下代码通过指定header.split来创建 DataFrame,提供列的名称:

 textDF = newRDD.toDF(header.split("\\t"))
 textDF.show()

输出如下:

图 10:事务数据的样本

现在,您可以将此 DataFrame 保存为视图并进行 SQL 查询。现在让我们对此 DataFrame 进行查询:

textDF.createOrReplaceTempView("transactions")
spark.sql("SELECT *** FROM transactions").show()
spark.sql("SELECT product_name, price FROM transactions WHERE price >=500 ").show()
spark.sql("SELECT product_name, price FROM transactions ORDER BY price DESC").show()

输出如下:

图 11:使用 Spark SQL 对事务数据进行查询的结果

在 PySpark 上编写 UDF

与 Scala 和 Java 一样,您也可以在 PySpark 上使用用户定义的函数(也称为UDF)。让我们在下面看一个例子。假设我们想要根据一些在大学上课程的学生的分数来查看成绩分布。

我们可以将它们存储在两个单独的数组中,如下所示:

# Let's generate somerandom lists
 students = ['Jason', 'John', 'Geroge', 'David']
 courses = ['Math', 'Science', 'Geography', 'History', 'IT', 'Statistics']

现在让我们声明一个空数组,用于存储有关课程和学生的数据,以便稍后可以将两者都附加到此数组中,如下所示:

rawData = []
for (student, course) in itertools.product(students, courses):
    rawData.append((student, course, random.randint(0, 200)))

请注意,为了使前面的代码工作,请在文件开头导入以下内容:

import itertools
import random

现在让我们从这两个对象创建一个 DataFrame,以便将相应的成绩转换为每个成绩的分数。为此,我们需要定义一个显式模式。假设在您计划的 DataFrame 中,将有三列名为StudentCourseScore

首先,让我们导入必要的模块:

from pyspark.sql.types
import StructType, StructField, IntegerType, StringType

现在模式可以定义如下:

schema = StructType([StructField("Student", StringType(), nullable=False),
                     StructField("Course", StringType(), nullable=False),
                     StructField("Score", IntegerType(), nullable=False)])

现在让我们从原始数据创建一个 RDD,如下所示:

courseRDD = spark.sparkContext.parallelize(rawData)

现在让我们将 RDD 转换为 DataFrame,如下所示:

courseDF = spark.createDataFrame(courseRDD, schema) 
coursedDF.show() 

输出如下:

图 12:随机生成的学科学生分数样本

好了,现在我们有了三列。但是,我们需要将分数转换为等级。假设您有以下分级模式:

  • 90~100=> A

  • 80~89 ⇒ B

  • 60~79 ⇒ C

  • 0~59 ⇒ D

为此,我们可以创建自己的 UDF,使其将数字分数转换为等级。可以用几种方法来做。以下是一个这样做的例子:

# Define udfdef scoreToCategory(grade):
 if grade >= 90:
 return 'A'
 elif grade >= 80:
 return 'B'
 elif grade >= 60:
 return 'C'
 else:
 return 'D'

现在我们可以有自己的 UDF 如下:

from pyspark.sql.functions
import udf
udfScoreToCategory = udf(scoreToCategory, StringType())

udf()方法中的第二个参数是方法的返回类型(即scoreToCategory)。现在您可以调用此 UDF 以一种非常直接的方式将分数转换为等级。让我们看一个例子:

courseDF.withColumn("Grade", udfScoreToCategory("Score")).show(100)

前一行将接受分数作为所有条目的输入,并将分数转换为等级。此外,将添加一个名为Grade的新 DataFrame 列。

输出如下:

图 13:分配的成绩

现在我们也可以在 SQL 语句中使用 UDF。但是,为此,我们需要将此 UDF 注册如下:

spark.udf.register("udfScoreToCategory", scoreToCategory, StringType()) 

前一行将默认情况下在数据库中将 UDF 注册为临时函数。现在我们需要创建一个团队视图,以允许执行 SQL 查询:

courseDF.createOrReplaceTempView("score")

现在让我们对视图score执行 SQL 查询,如下所示:

spark.sql("SELECT Student, Score, udfScoreToCategory(Score) as Grade FROM score").show() 

输出如下:

图 14:关于学生分数和相应成绩的查询

此示例的完整源代码如下:

import os
import sys
import itertools
import random

from pyspark.sql import SparkSession
from pyspark.sql.types import StructType, StructField, IntegerType, StringType
from pyspark.sql.functions import udf

spark = SparkSession \
        .builder \
        .appName("PCAExample") \
        .getOrCreate()

# Generate Random RDD
students = ['Jason', 'John', 'Geroge', 'David']
courses = ['Math', 'Science', 'Geography', 'History', 'IT', 'Statistics']
rawData = []
for (student, course) in itertools.product(students, courses):
    rawData.append((student, course, random.randint(0, 200)))

# Create Schema Object
schema = StructType([
    StructField("Student", StringType(), nullable=False),
    StructField("Course", StringType(), nullable=False),
    StructField("Score", IntegerType(), nullable=False)
])

courseRDD = spark.sparkContext.parallelize(rawData)
courseDF = spark.createDataFrame(courseRDD, schema)
courseDF.show()

# Define udf
def scoreToCategory(grade):
    if grade >= 90:
        return 'A'
    elif grade >= 80:
        return 'B'
    elif grade >= 60:
        return 'C'
    else:
        return 'D'

udfScoreToCategory = udf(scoreToCategory, StringType())
courseDF.withColumn("Grade", udfScoreToCategory("Score")).show(100)

spark.udf.register("udfScoreToCategory", scoreToCategory, StringType())
courseDF.createOrReplaceTempView("score")
spark.sql("SELECT Student, Score, udfScoreToCategory(Score) as Grade FROM score").show()

spark.stop()

关于使用 UDF 的更详细讨论可以在jaceklaskowski.gitbooks.io/mastering-apache-spark/content/spark-sql-udfs.html找到。

现在让我们在 PySpark 上进行一些分析任务。在下一节中,我们将展示使用 k-means 算法进行聚类任务的示例。

让我们使用 k-means 聚类进行一些分析

异常数据是指与正态分布不同寻常的数据。因此,检测异常是网络安全的重要任务,异常的数据包或请求可能被标记为错误或潜在攻击。

在此示例中,我们将使用 KDD-99 数据集(可以在此处下载:kdd.ics.uci.edu/databases/kddcup99/kddcup99.html)。将根据数据点的某些标准过滤出许多列。这将帮助我们理解示例。其次,对于无监督任务;我们将不得不删除标记的数据。让我们将数据集加载并解析为简单的文本。然后让我们看看数据集中有多少行:

INPUT = "C:/Users/rezkar/Downloads/kddcup.data" spark = SparkSession\
         .builder\
         .appName("PCAExample")\
         .getOrCreate()

 kddcup_data = spark.sparkContext.textFile(INPUT)

这本质上返回一个 RDD。让我们看看数据集中有多少行,使用count()方法如下所示:

count = kddcup_data.count()
print(count)>>4898431

所以,数据集非常大,具有许多特征。由于我们已将数据集解析为简单文本,因此不应期望看到数据集的更好结构。因此,让我们朝着将 RDD 转换为 DataFrame 的方向努力:

kdd = kddcup_data.map(lambda l: l.split(","))
from pyspark.sql import SQLContext
sqlContext = SQLContext(spark)
df = sqlContext.createDataFrame(kdd)

然后让我们看一下 DataFrame 中的一些选定列:

df.select("_1", "_2", "_3", "_4", "_42").show(5)

输出如下:

图 15:KKD 杯 99 数据集样本

因此,这个数据集已经被标记。这意味着恶意网络行为的类型已被分配到标签为最后一列(即_42)的行中。DataFrame 的前五行被标记为正常。这意味着这些数据点是正常的。现在是我们需要确定整个数据集中每种标签的计数的时候了:

#Identifying the labels for unsupervised tasklabels = kddcup_data.map(lambda line: line.strip().split(",")[-1])
from time import time
start_label_count = time()
label_counts = labels.countByValue()
label_count_time = time()-start_label_count

from collections import OrderedDict
sorted_labels = OrderedDict(sorted(label_counts.items(), key=lambda t: t[1], reverse=True))
for label, count in sorted_labels.items():
 print label, count

输出如下:

图 16:KDD 杯数据集中可用的标签(攻击类型)

我们可以看到有 23 个不同的标签(数据对象的行为)。大多数数据点属于 Smurf。这是一种异常行为,也称为 DoS 数据包洪水。Neptune 是第二高的异常行为。正常事件是数据集中第三种最常发生的事件类型。然而,在真实的网络数据集中,你不会看到任何这样的标签。

此外,正常流量将远远高于任何异常流量。因此,从大规模未标记的数据中识别异常攻击或异常将是费时的。为简单起见,让我们忽略最后一列(即标签),并认为这个数据集也是未标记的。在这种情况下,唯一可以概念化异常检测的方法是使用无监督学习算法,如 k-means 进行聚类。

现在让我们开始对数据点进行聚类。关于 K-means 的一个重要事项是,它只接受数值值进行建模。然而,我们的数据集还包含一些分类特征。现在我们可以根据它们是否为TCP,为分类特征分配二进制值 1 或 0。可以按如下方式完成:

from numpy import array
def parse_interaction(line):
     line_split = line.split(",")
     clean_line_split = [line_split[0]]+line_split[4:-1]
     return (line_split[-1], array([float(x) for x in clean_line_split]))

 parsed_data = kddcup_data.map(parse_interaction)
 pd_values = parsed_data.values().cache()

因此,我们的数据集几乎准备好了。现在我们可以准备我们的训练集和测试集,轻松地训练 k-means 模型:

 kdd_train = pd_values.sample(False, .75, 12345)
 kdd_test = pd_values.sample(False, .25, 12345)
 print("Training set feature count: " + str(kdd_train.count()))
 print("Test set feature count: " + str(kdd_test.count()))

输出如下:

Training set feature count: 3674823 Test set feature count: 1225499

然而,由于我们将一些分类特征转换为数值特征,因此还需要进行一些标准化。标准化可以提高优化过程中的收敛速度,还可以防止具有非常大方差的特征在模型训练过程中产生影响。

现在我们将使用 StandardScaler,这是一个特征转换器。它通过将特征缩放到单位方差来帮助我们标准化特征。然后使用训练集样本中的列汇总统计将均值设置为零:

standardizer = StandardScaler(True, True) 

现在让我们通过拟合前面的转换器来计算汇总统计信息:

standardizer_model = standardizer.fit(kdd_train) 

现在问题是,我们用于训练 k-means 的数据没有正态分布。因此,我们需要对训练集中的每个特征进行标准化,使其具有单位标准差。为实现这一点,我们需要进一步转换前面的标准化模型,如下所示:

data_for_cluster = standardizer_model.transform(kdd_train) 

干得好!现在训练集终于准备好训练 k-means 模型了。正如我们在聚类章节中讨论的那样,聚类算法中最棘手的事情是通过设置 K 的值找到最佳聚类数,使数据对象能够自动聚类。

一个天真的方法是采用蛮力法,设置K=2并观察结果,直到获得最佳结果。然而,一个更好的方法是肘部法,我们可以不断增加K的值,并计算集合内平方误差和WSSSE)作为聚类成本。简而言之,我们将寻找最小化 WSSSE 的最佳K值。每当观察到急剧下降时,我们将知道最佳的K值:

import numpy
our_k = numpy.arange(10, 31, 10)
metrics = []
def computeError(point):
 center = clusters.centers[clusters.predict(point)]
 denseCenter = DenseVector(numpy.ndarray.tolist(center))
return sqrt(sum([x**2 for x in (DenseVector(point.toArray()) - denseCenter)]))
for k in our_k:
      clusters = KMeans.train(data_for_cluster, k, maxIterations=4, initializationMode="random")
      WSSSE = data_for_cluster.map(lambda point: computeError(point)).reduce(lambda x, y: x + y)
      results = (k, WSSSE)
 metrics.append(results)
print(metrics)

输出如下:

[(10, 3364364.5203123973), (20, 3047748.5040717563), (30, 2503185.5418753517)]

在这种情况下,30 是 k 的最佳值。让我们检查每个数据点的簇分配,当我们有 30 个簇时。下一个测试将是运行k值为 30、35 和 40。三个 k 值不是您在单次运行中测试的最多值,但仅用于此示例:

modelk30 = KMeans.train(data_for_cluster, 30, maxIterations=4, initializationMode="random")
 cluster_membership = data_for_cluster.map(lambda x: modelk30.predict(x))
 cluster_idx = cluster_membership.zipWithIndex()
 cluster_idx.take(20)
 print("Final centers: " + str(modelk30.clusterCenters))

输出如下:

**图 17:**每种攻击类型的最终簇中心(摘要)

现在让我们计算并打印整体聚类的总成本如下:

print("Total Cost: " + str(modelk30.computeCost(data_for_cluster)))

输出如下:

Total Cost: 68313502.459

最后,我们的 k 均值模型的 WSSSE 可以计算并打印如下:

WSSSE = data_for_cluster.map(lambda point: computeError
(point)).reduce(lambda x, y: x + y)
 print("WSSSE: " + str(WSSSE))

输出如下:

WSSSE: 2503185.54188

您的结果可能略有不同。这是由于在我们首次开始聚类算法时,质心的随机放置。多次执行可以让您看到数据中的点如何改变其 k 值或保持不变。此解决方案的完整源代码如下所示:

import os
import sys
import numpy as np
from collections import OrderedDict

try:
    from collections import OrderedDict
    from numpy import array
    from math import sqrt
    import numpy
    import urllib
    import pyspark
    from pyspark.sql import SparkSession
    from pyspark.mllib.feature import StandardScaler
    from pyspark.mllib.clustering import KMeans, KMeansModel
    from pyspark.mllib.linalg import DenseVector
    from pyspark.mllib.linalg import SparseVector
    from collections import OrderedDict
    from time import time
    from pyspark.sql.types import *
    from pyspark.sql import DataFrame
    from pyspark.sql import SQLContext
    from pyspark.sql import Row
    print("Successfully imported Spark Modules")

except ImportError as e:
    print ("Can not import Spark Modules", e)
    sys.exit(1)

spark = SparkSession\
        .builder\
        .appName("PCAExample")\
        .getOrCreate()

INPUT = "C:/Exp/kddcup.data.corrected"
kddcup_data = spark.sparkContext.textFile(INPUT)
count = kddcup_data.count()
print(count)
kddcup_data.take(5)
kdd = kddcup_data.map(lambda l: l.split(","))
sqlContext = SQLContext(spark)
df = sqlContext.createDataFrame(kdd)
df.select("_1", "_2", "_3", "_4", "_42").show(5)

#Identifying the leabels for unsupervised task
labels = kddcup_data.map(lambda line: line.strip().split(",")[-1])
start_label_count = time()
label_counts = labels.countByValue()
label_count_time = time()-start_label_count

sorted_labels = OrderedDict(sorted(label_counts.items(), key=lambda t: t[1], reverse=True))
for label, count in sorted_labels.items():
    print(label, count)

def parse_interaction(line):
    line_split = line.split(",")
    clean_line_split = [line_split[0]]+line_split[4:-1]
    return (line_split[-1], array([float(x) for x in clean_line_split]))

parsed_data = kddcup_data.map(parse_interaction)
pd_values = parsed_data.values().cache()

kdd_train = pd_values.sample(False, .75, 12345)
kdd_test = pd_values.sample(False, .25, 12345)
print("Training set feature count: " + str(kdd_train.count()))
print("Test set feature count: " + str(kdd_test.count()))

standardizer = StandardScaler(True, True)
standardizer_model = standardizer.fit(kdd_train)
data_for_cluster = standardizer_model.transform(kdd_train)

initializationMode="random"

our_k = numpy.arange(10, 31, 10)
metrics = []

def computeError(point):
    center = clusters.centers[clusters.predict(point)]
    denseCenter = DenseVector(numpy.ndarray.tolist(center))
    return sqrt(sum([x**2 for x in (DenseVector(point.toArray()) - denseCenter)]))

for k in our_k:
     clusters = KMeans.train(data_for_cluster, k, maxIterations=4, initializationMode="random")
     WSSSE = data_for_cluster.map(lambda point: computeError(point)).reduce(lambda x, y: x + y)
     results = (k, WSSSE)
     metrics.append(results)
print(metrics)

modelk30 = KMeans.train(data_for_cluster, 30, maxIterations=4, initializationMode="random")
cluster_membership = data_for_cluster.map(lambda x: modelk30.predict(x))
cluster_idx = cluster_membership.zipWithIndex()
cluster_idx.take(20)
print("Final centers: " + str(modelk30.clusterCenters))
print("Total Cost: " + str(modelk30.computeCost(data_for_cluster)))
WSSSE = data_for_cluster.map(lambda point: computeError(point)).reduce(lambda x, y: x + y)
print("WSSSE" + str(WSSSE))

有关此主题的更全面讨论,请参阅github.com/jadianes/kdd-cup-99-spark。此外,感兴趣的读者可以参考 PySpark API 的主要和最新文档,网址为spark.apache.org/docs/latest/api/python/

现在是时候转向 SparkR,这是另一个与名为 R 的流行统计编程语言一起使用的 Spark API。

SparkR 简介

R 是最流行的统计编程语言之一,具有许多令人兴奋的功能,支持统计计算、数据处理和机器学习任务。然而,在 R 中处理大规模数据集通常很繁琐,因为运行时是单线程的。因此,只有适合机器内存的数据集才能被处理。考虑到这一限制,并为了充分体验 R 中 Spark 的功能,SparkR 最初在 AMPLab 开发,作为 R 到 Apache Spark 的轻量级前端,并使用 Spark 的分布式计算引擎。

这样可以使 R 程序员从 RStudio 使用 Spark 进行大规模数据分析。在 Spark 2.1.0 中,SparkR 提供了一个支持选择、过滤和聚合等操作的分布式数据框实现。这与 R 数据框(如dplyr)有些类似,但可以扩展到大规模数据集。

为什么选择 SparkR?

您也可以使用 SparkR 编写支持 MLlib 的分布式机器学习的 Spark 代码。总之,SparkR 从与 Spark 紧密集成中继承了许多好处,包括以下内容:

  • 支持各种数据源 API:SparkR 可以用于从各种来源读取数据,包括 Hive 表、JSON 文件、关系型数据库和 Parquet 文件。

  • 数据框优化:SparkR 数据框也继承了计算引擎的所有优化,包括代码生成、内存管理等。从下图可以观察到,Spark 的优化引擎使得 SparkR 能够与 Scala 和 Python 竞争力十足:

**图 18:**SparkR 数据框与 Scala/Python 数据框

  • **可扩展性:**在 SparkR 数据框上执行的操作会自动分布到 Spark 集群上所有可用的核心和机器上。因此,SparkR 数据框可以用于大量数据,并在具有数千台机器的集群上运行。

安装和入门

使用 SparkR 的最佳方式是从 RStudio 开始。您可以使用 R shell、Rescript 或其他 R IDE 将您的 R 程序连接到 Spark 集群。

选项 1. 在环境中设置SPARK_HOME(您可以查看stat.ethz.ch/R-manual/R-devel/library/base/html/Sys.getenv.html),加载 SparkR 包,并调用sparkR.session如下。它将检查 Spark 安装,如果找不到,将自动下载和缓存:

if (nchar(Sys.getenv("SPARK_HOME")) < 1) { 
Sys.setenv(SPARK_HOME = "/home/spark") 
} 
library(SparkR, lib.loc = c(file.path(Sys.getenv("SPARK_HOME"), "R", "lib"))) 

选项 2. 您还可以在 RStudio 上手动配置 SparkR。为此,请在 R 脚本中执行以下 R 代码行:

SPARK_HOME = "spark-2.1.0-bin-hadoop2.7/R/lib" 
HADOOP_HOME= "spark-2.1.0-bin-hadoop2.7/bin" 
Sys.setenv(SPARK_MEM = "2g") 
Sys.setenv(SPARK_HOME = "spark-2.1.0-bin-hadoop2.7") 
.libPaths(c(file.path(Sys.getenv("SPARK_HOME"), "R", "lib"), .libPaths())) 

现在加载 SparkR 库如下:

library(SparkR, lib.loc = SPARK_HOME)

现在,就像 Scala/Java/PySpark 一样,您的 SparkR 程序的入口点是通过调用sparkR.session创建的 SparkR 会话,如下所示:

sparkR.session(appName = "Hello, Spark!", master = "local[*]")

此外,如果您愿意,还可以指定特定的 Spark 驱动程序属性。通常,这些应用程序属性和运行时环境无法以编程方式设置,因为驱动程序 JVM 进程已经启动;在这种情况下,SparkR 会为您处理这些设置。要设置它们,将它们传递给sparkR.session()sparkConfig参数,如下所示:

sparkR.session(master = "local[*]", sparkConfig = list(spark.driver.memory = "2g")) 

此外,以下 Spark 驱动程序属性可以在 RStudio 中使用sparkConfigsparkR.session进行设置:

图 19:可以在 RStudio 中使用sparkConfigsparkR.session设置 Spark 驱动程序属性

入门

让我们从加载、解析和查看简单的航班数据开始。首先,从s3-us-west-2.amazonaws.com/sparkr-data/nycflights13.csv下载 NY 航班数据集作为 CSV。现在让我们使用 R 的read.csv() API 加载和解析数据集:

#Creating R data frame
dataPath<- "C:/Exp/nycflights13.csv"
df<- read.csv(file = dataPath, header = T, sep =",")

现在让我们使用 R 的View()方法查看数据集的结构如下:

View(df)

图 20:NYC 航班数据集的快照

现在让我们从 R DataFrame 创建 Spark DataFrame 如下:

##Converting Spark DataFrame 
 flightDF<- as.DataFrame(df)

让我们通过探索 DataFrame 的模式来查看结构:

printSchema(flightDF)

输出如下:

图 21:NYC 航班数据集的模式

现在让我们看 DataFrame 的前 10 行:

showDF(flightDF, numRows = 10)

输出如下:

图 22:NYC 航班数据集的前 10 行

因此,您可以看到相同的结构。但是,这不可扩展,因为我们使用标准 R API 加载了 CSV 文件。为了使其更快速和可扩展,就像在 Scala 中一样,我们可以使用外部数据源 API。

使用外部数据源 API

如前所述,我们也可以使用外部数据源 API 来创建 DataFrame。在以下示例中,我们使用com.databricks.spark.csv API 如下:

flightDF<- read.df(dataPath,  
header='true',  
source = "com.databricks.spark.csv",  
inferSchema='true') 

让我们通过探索 DataFrame 的模式来查看结构:

printSchema(flightDF)

输出如下:

图 23:使用外部数据源 API 查看 NYC 航班数据集的相同模式

现在让我们看看 DataFrame 的前 10 行:

showDF(flightDF, numRows = 10)

输出如下:

图 24:使用外部数据源 API 的 NYC 航班数据集的相同样本数据

因此,您可以看到相同的结构。干得好!现在是时候探索更多内容了,比如使用 SparkR 进行数据操作。

数据操作

显示 SparkDataFrame 中的列名如下:

columns(flightDF)
[1] "year" "month" "day" "dep_time" "dep_delay" "arr_time" "arr_delay" "carrier" "tailnum" "flight" "origin" "dest" 
[13] "air_time" "distance" "hour" "minute" 

显示 SparkDataFrame 中的行数如下:

count(flightDF)
[1] 336776

过滤目的地仅为迈阿密的航班数据,并显示前六个条目如下:

 showDF(flightDF[flightDF$dest == "MIA", ], numRows = 10)

输出如下:

图 25:目的地仅为迈阿密的航班

选择特定列。例如,让我们选择所有前往爱荷华州的延误航班。还包括起飞机场名称:

delay_destination_DF<- select(flightDF, "flight", "dep_delay", "origin", "dest") 
 delay_IAH_DF<- filter(delay_destination_DF, delay_destination_DF$dest == "IAH") showDF(delay_IAH_DF, numRows = 10)

输出如下:

图 26:所有前往爱荷华州的延误航班

我们甚至可以使用它来链接数据框操作。举个例子,首先按日期分组航班,然后找到平均每日延误。最后,将结果写入 SparkDataFrame 如下:

install.packages(c("magrittr")) 
library(magrittr) 
groupBy(flightDF, flightDF$day) %>% summarize(avg(flightDF$dep_delay), avg(flightDF$arr_delay)) ->dailyDelayDF 

现在打印计算出的 DataFrame:

head(dailyDelayDF)

输出如下:

图 27:按日期分组航班,然后找到平均每日延误

让我们看另一个示例,对整个目的地机场的平均到达延误进行聚合:

avg_arr_delay<- collect(select(flightDF, avg(flightDF$arr_delay))) 
 head(avg_arr_delay)
avg(arr_delay)
 1 6.895377

还可以执行更复杂的聚合。例如,以下代码对每个目的地机场的平均、最大和最小延误进行了聚合。它还显示了降落在这些机场的航班数量:

flight_avg_arrival_delay_by_destination<- collect(agg( 
 groupBy(flightDF, "dest"), 
 NUM_FLIGHTS=n(flightDF$dest), 
 AVG_DELAY = avg(flightDF$arr_delay), 
 MAX_DELAY=max(flightDF$arr_delay), 
 MIN_DELAY=min(flightDF$arr_delay) 
 ))
head(flight_avg_arrival_delay_by_destination)

输出如下:

图 28:每个目的地机场的最大和最小延误

查询 SparkR DataFrame

与 Scala 类似,一旦将 DataFrame 保存为TempView,我们就可以对其执行 SQL 查询,使用createOrReplaceTempView()方法。让我们看一个例子。首先,让我们保存航班 DataFrame(即flightDF)如下:

# First, register the flights SparkDataFrame as a table
createOrReplaceTempView(flightDF, "flight")

现在让我们选择所有航班的目的地和目的地的承运人信息如下:

destDF<- sql("SELECT dest, origin, carrier FROM flight") 
 showDF(destDF, numRows=10)

输出如下:

图 29:所有航班的目的地和承运人信息

现在让我们使 SQL 复杂一些,比如找到所有至少延误 120 分钟的航班的目的地机场如下:

selected_flight_SQL<- sql("SELECT dest, origin, arr_delay FROM flight WHERE arr_delay>= 120")
showDF(selected_flight_SQL, numRows = 10)

前面的代码段查询并显示了所有至少延误 2 小时的航班的机场名称:

图 30:所有至少延误 2 小时的航班的目的地机场

现在让我们进行更复杂的查询。让我们找到所有飞往爱荷华的航班的起点,至少延误 2 小时。最后,按到达延误排序,并将计数限制为 20 如下:

selected_flight_SQL_complex<- sql("SELECT origin, dest, arr_delay FROM flight WHERE dest='IAH' AND arr_delay>= 120 ORDER BY arr_delay DESC LIMIT 20")
showDF(selected_flight_SQL_complex, numRows=20)

前面的代码段查询并显示了所有至少延误 2 小时到爱荷华的航班的机场名称:

图 31:所有航班的起点都至少延误 2 小时,目的地是爱荷华

在 RStudio 上可视化您的数据

在前一节中,我们已经看到了如何加载、解析、操作和查询 DataFrame。现在如果我们能够展示数据以便更好地看到就更好了。例如,对航空公司可以做些什么?我的意思是,是否可能从图表中找到最频繁的航空公司?让我们试试ggplot2。首先,加载相同的库:

library(ggplot2) 

现在我们已经有了 SparkDataFrame。如果我们直接尝试在ggplot2中使用我们的 SparkSQL DataFrame 类会怎么样?

my_plot<- ggplot(data=flightDF, aes(x=factor(carrier)))
>>
ERROR: ggplot2 doesn't know how to deal with data of class SparkDataFrame.

显然,这样是行不通的,因为ggplot2函数不知道如何处理这些类型的分布式数据框架(Spark 的数据框架)。相反,我们需要在本地收集数据并将其转换回传统的 R 数据框架如下:

flight_local_df<- collect(select(flightDF,"carrier"))

现在让我们使用str()方法查看我们得到了什么:

str(flight_local_df)

输出如下:

'data.frame':  336776 obs. of 1 variable: $ carrier: chr "UA" "UA" "AA" "B6" ...

这很好,因为当我们从 SparkSQL DataFrame 中收集结果时,我们得到一个常规的 R data.frame。这也非常方便,因为我们可以根据需要对其进行操作。现在我们准备创建ggplot2对象如下:

my_plot<- ggplot(data=flight_local_df, aes(x=factor(carrier)))

最后,让我们给图表一个适当的表示,作为条形图如下:

my_plot + geom_bar() + xlab("Carrier")

输出如下:

图 32:最频繁的航空公司是 UA、B6、EV 和 DL

从图表中可以清楚地看出,最频繁的航空公司是 UA、B6、EV 和 DL。这在 R 中的以下代码行中更清晰:

carrierDF = sql("SELECT carrier, COUNT(*) as cnt FROM flight GROUP BY carrier ORDER BY cnt DESC")
showDF(carrierDF)

输出如下:

图 33:最频繁的航空公司是 UA、B6、EV 和 DL

前面分析的完整源代码如下,以了解代码的流程:

#Configure SparkR
SPARK_HOME = "C:/Users/rezkar/Downloads/spark-2.1.0-bin-hadoop2.7/R/lib"
HADOOP_HOME= "C:/Users/rezkar/Downloads/spark-2.1.0-bin-hadoop2.7/bin"
Sys.setenv(SPARK_MEM = "2g")
Sys.setenv(SPARK_HOME = "C:/Users/rezkar/Downloads/spark-2.1.0-bin-hadoop2.7")
.libPaths(c(file.path(Sys.getenv("SPARK_HOME"), "R", "lib"), .libPaths()))

#Load SparkR
library(SparkR, lib.loc = SPARK_HOME)

# Initialize SparkSession
sparkR.session(appName = "Example", master = "local[*]", sparkConfig = list(spark.driver.memory = "8g"))
# Point the data file path:
dataPath<- "C:/Exp/nycflights13.csv"

#Creating DataFrame using external data source API
flightDF<- read.df(dataPath,
header='true',
source = "com.databricks.spark.csv",
inferSchema='true')
printSchema(flightDF)
showDF(flightDF, numRows = 10)
# Using SQL to select columns of data

# First, register the flights SparkDataFrame as a table
createOrReplaceTempView(flightDF, "flight")
destDF<- sql("SELECT dest, origin, carrier FROM flight")
showDF(destDF, numRows=10)

#And then we can use SparkR sql function using condition as follows:
selected_flight_SQL<- sql("SELECT dest, origin, arr_delay FROM flight WHERE arr_delay>= 120")
showDF(selected_flight_SQL, numRows = 10)

#Bit complex query: Let's find the origins of all the flights that are at least 2 hours delayed where the destiantionn is Iowa. Finally, sort them by arrival delay and limit the count upto 20 and the destinations
selected_flight_SQL_complex<- sql("SELECT origin, dest, arr_delay FROM flight WHERE dest='IAH' AND arr_delay>= 120 ORDER BY arr_delay DESC LIMIT 20")
showDF(selected_flight_SQL_complex)

# Stop the SparkSession now
sparkR.session.stop()

摘要

在本章中,我们展示了如何在 Python 和 R 中编写您的 Spark 代码的一些示例。这些是数据科学家社区中最流行的编程语言。

我们讨论了使用 PySpark 和 SparkR 进行大数据分析的动机,几乎与 Java 和 Scala 同样简单。我们讨论了如何在流行的 IDE(如 PyCharm 和 RStudio)上安装这些 API。我们还展示了如何从这些 IDE 中使用 DataFrames 和 RDDs。此外,我们还讨论了如何从 PySpark 和 SparkR 中执行 Spark SQL 查询。然后,我们还讨论了如何对数据集进行可视化分析。最后,我们看到了如何使用 UDFs 来进行 PySpark 的示例。

因此,我们讨论了两个 Spark 的 API:PySpark 和 SparkR 的几个方面。还有更多内容可以探索。感兴趣的读者应该参考它们的网站获取更多信息。

第二十章:使用 Alluxio 加速 Spark

“显而易见,我们的技术已经超出了我们的人性。”

  • 阿尔伯特·爱因斯坦

在这里,您将学习如何使用 Alluxio 与 Spark 加速处理速度。Alluxio 是一个开源的分布式内存存储系统,可用于加速跨平台的许多应用程序的速度,包括 Apache Spark。

简而言之,本章将涵盖以下主题:

  • 对 Alluxio 的需求

  • 开始使用 Alluxio

  • 与 YARN 集成

  • 在 Spark 中使用 Alluxio

对 Alluxio 的需求

我们已经了解了 Apache Spark 以及围绕 Spark 核心、流式处理、GraphX、Spark SQL 和 Spark 机器学习的各种功能。我们还看了许多围绕数据操作和处理的用例和操作。任何处理任务中的关键步骤是数据输入、数据处理和数据输出。

这里显示了一个 Spark 作业的示例:

如图所示,作业的输入和输出通常依赖于基于磁盘的较慢存储选项,而处理通常是使用内存/RAM 完成的。由于内存比磁盘访问快 100 倍,如果我们可以减少磁盘使用并更多地使用内存,作业的性能显然可以显著提高。在任何作业中,不需要甚至不可能完全不使用任何磁盘;相反,我们只是打算尽可能多地使用内存。

首先,我们可以尝试尽可能多地在内存中缓存数据,以加速使用执行器进行处理。虽然这对某些作业可能有效,但对于在运行 Spark 的分布式集群中运行的大型作业来说,不可能拥有如此多的 GB 或 TB 内存。此外,即使您的使用环境中有一个大型集群,也会有许多用户,因此很难为所有作业使用如此多的资源。

我们知道分布式存储系统,如 HDFS、S3 和 NFS。同样,如果我们有一个分布式内存系统,我们可以将其用作所有作业的存储系统,以减少作业或管道中的中间作业所需的 I/O。Alluxio 正是通过实现分布式内存文件系统来提供这一点,Spark 可以使用它来满足所有输入/输出需求。

开始使用 Alluxio

Alluxio,以前称为 Tachyon,统一了数据访问并桥接了计算框架和底层存储系统。Alluxio 的内存为中心的架构使得数据访问比现有解决方案快几个数量级。Alluxio 也与 Hadoop 兼容,因此可以无缝集成到现有基础设施中。现有的数据分析应用程序,如 Spark 和 MapReduce 程序,可以在 Alluxio 之上运行,而无需进行任何代码更改,这意味着过渡时间微不足道,而性能更好:

下载 Alluxio

您可以通过在www.alluxio.org/download网站上注册您的姓名和电子邮件地址来下载 Alluxio:

或者,您也可以直接转到downloads.alluxio.org/downloads/files并下载最新版本:

安装和在本地运行 Alluxio

我们将在本地安装和运行 1.5.0。您可以使用任何其他版本进行相同操作。如果您下载了版本 1.5.0,您将看到一个名为alluxio-1.5.0-hadoop2.7-bin.tar.gz的文件。

使用 Alluxio 的先决条件是已安装 JDK 7 或更高版本。

解压下载的alluxio-1.5.0-hadoop2.7-bin.tar.gz文件:

tar -xvzf alluxio-1.5.0-hadoop2.7-bin.tar.gz
cd alluxio-1.5.0-hadoop-2.7

此外,如果在本地运行,Alluxio 将需要一个环境变量才能正确绑定到主机,因此运行以下命令:

export ALLUXIO_MASTER_HOSTNAME=localhost

使用/bin/alluxio命令格式化 Alluxio 文件系统。

只有在首次运行 Alluxio 时才需要此步骤,运行时,Alluxio 文件系统中以前存储的所有数据和元数据将被删除。

运行/bin/alluxio格式命令来格式化文件系统:

falcon:alluxio-1.5.0-hadoop-2.7 salla$ ./bin/alluxio format
Waiting for tasks to finish...
All tasks finished, please analyze the log at /Users/salla/alluxio-1.5.0-hadoop-2.7/bin/../logs/task.log.
Formatting Alluxio Master @ falcon

在本地启动 Alluxio 文件系统:

falcon:alluxio-1.5.0-hadoop-2.7 salla$ ./bin/alluxio-start.sh local
Waiting for tasks to finish...
All tasks finished, please analyze the log at /Users/salla/alluxio-1.5.0-hadoop-2.7/bin/../logs/task.log.
Waiting for tasks to finish...
All tasks finished, please analyze the log at /Users/salla/alluxio-1.5.0-hadoop-2.7/bin/../logs/task.log.
Killed 0 processes on falcon
Killed 0 processes on falcon
Starting master @ falcon. Logging to /Users/salla/alluxio-1.5.0-hadoop-2.7/logs
Formatting RamFS: ramdisk 2142792 sectors (1gb).
Started erase on disk2
Unmounting disk
Erasing
Initialized /dev/rdisk2 as a 1 GB case-insensitive HFS Plus volume
Mounting disk
Finished erase on disk2 ramdisk
Starting worker @ falcon. Logging to /Users/salla/alluxio-1.5.0-hadoop-2.7/logs
Starting proxy @ falcon. Logging to /Users/salla/alluxio-1.5.0-hadoop-2.7/logs

您可以使用类似的语法停止 Alluxio。

您可以通过在本地运行./bin/alluxio-stop.sh来停止 Alluxio。

通过使用runTests参数运行 Alluxio 脚本来验证 Alluxio 是否正在运行:

falcon:alluxio-1.5.0-hadoop-2.7 salla$ ./bin/alluxio runTests
2017-06-11 10:31:13,997 INFO type (MetricsSystem.java:startSinksFromConfig) - Starting sinks with config: {}.
2017-06-11 10:31:14,256 INFO type (AbstractClient.java:connect) - Alluxio client (version 1.5.0) is trying to connect with FileSystemMasterClient master @ localhost/127.0.0.1:19998
2017-06-11 10:31:14,280 INFO type (AbstractClient.java:connect) - Client registered with FileSystemMasterClient master @ localhost/127.0.0.1:19998
runTest Basic CACHE_PROMOTE MUST_CACHE
2017-06-11 10:31:14,585 INFO type (AbstractClient.java:connect) - Alluxio client (version 1.5.0) is trying to connect with BlockMasterClient master @ localhost/127.0.0.1:19998
2017-06-11 10:31:14,587 INFO type (AbstractClient.java:connect) - Client registered with BlockMasterClient master @ localhost/127.0.0.1:19998
2017-06-11 10:31:14,633 INFO type (ThriftClientPool.java:createNewResource) - Created a new thrift client alluxio.thrift.BlockWorkerClientService$Client@36b4cef0
2017-06-11 10:31:14,651 INFO type (ThriftClientPool.java:createNewResource) - Created a new thrift client alluxio.thrift.BlockWorkerClientService$Client@4eb7f003
2017-06-11 10:31:14,779 INFO type (BasicOperations.java:writeFile) - writeFile to file /default_tests_files/Basic_CACHE_PROMOTE_MUST_CACHE took 411 ms.
2017-06-11 10:31:14,852 INFO type (BasicOperations.java:readFile) - readFile file /default_tests_files/Basic_CACHE_PROMOTE_MUST_CACHE took 73 ms.
Passed the test!

有关其他选项和详细信息,请参阅www.alluxio.org/docs/master/en/Running-Alluxio-Locally.html

您还可以使用 Web UI 来查看 Alluxio 进程,方法是打开浏览器并输入http://localhost:19999/

概述

概述选项卡显示摘要信息,例如主地址、运行的工作节点、版本和集群的正常运行时间。还显示了集群使用摘要,显示了工作节点的容量和文件系统 UnderFS 容量。然后,还可以看到存储使用摘要,显示了空间容量和已使用空间:

浏览

浏览选项卡允许您查看内存文件系统的当前内容。此选项卡显示文件系统中的内容,文件的名称、大小和块大小,我们是否将数据加载到内存中,以及文件的 ACL 和权限,指定谁可以访问它并执行读写等操作。您将在浏览选项卡中看到 Alluxio 中管理的所有文件:

配置

配置选项卡显示使用的所有配置参数。一些最重要的参数是使用的配置目录、主节点和工作节点的 CPU 资源和内存资源分配。还可以看到文件系统名称、路径、JDK 设置等。所有这些都可以被覆盖以定制 Alluxio 以适应您的用例。这里的任何更改也将需要重新启动集群。

工作者

Workers 选项卡只是显示 Alluxio 集群中的工作节点。在我们的本地设置中,这只会显示本地机器,但在典型的许多工作节点的集群中,您将看到所有工作节点以及节点的状态,工作节点的容量,已使用的空间和最后接收到的心跳,这显示了工作节点是否存活并参与集群操作:

内存数据

内存数据选项卡显示 Alluxio 文件系统内存中的当前数据。这显示了集群内存中的内容。内存中每个数据集显示的典型信息包括权限、所有权、创建和修改时间:

日志

日志选项卡允许您查看各种日志文件,用于调试和监视目的。您将看到名为master.log的主节点的日志文件,名为worker.log的工作节点的日志文件,task.logproxy.log以及用户日志。每个日志文件都会独立增长,并且在诊断问题或仅监视集群的健康状况方面非常有用:

指标

指标选项卡显示有用的指标,用于监视 Alluxio 文件系统的当前状态。这里的主要信息包括主节点和文件系统容量。还显示了各种操作的计数器,例如文件创建和删除的逻辑操作,以及目录创建和删除。另一部分显示了 RPC 调用,您可以使用它来监视 CreateFile、DeleteFile 和 GetFileBlockInfo 等操作:

当前功能

正如前面所看到的,Alluxio 提供了许多功能,以支持高速内存文件系统,显着加速 Spark 或许多其他计算系统。当前版本具有许多功能,以下是一些主要功能的描述:

  • 灵活的文件 API提供了与 Hadoop 兼容的文件系统,允许 Hadoop MapReduce 和 Spark 使用 Alluxio。

  • 可插拔的底层存储将内存中的数据检查点到底层存储系统,支持 Amazon S3、Google Cloud Storage、OpenStack Swift、HDFS 等。

  • 分层存储可以管理 SSD 和 HDD,除了内存,还允许将更大的数据集存储在 Alluxio 中。

  • 统一命名空间通过挂载功能在不同存储系统之间实现有效的数据管理。此外,透明命名确保在将这些对象持久化到底层存储系统时,Alluxio 中创建的对象的文件名和目录层次结构得以保留。

  • 血统可以通过血统实现高吞吐量写入,而不会影响容错性,其中丢失的输出通过重新执行创建输出的作业来恢复,就像 Apache Spark 中的 DAG 一样。

  • Web UI 和命令行允许用户通过 Web UI 轻松浏览文件系统。在调试模式下,管理员可以查看每个文件的详细信息,包括位置和检查点路径。用户还可以使用./bin/alluxio fs与 Alluxio 进行交互,例如,复制数据进出文件系统。

有关最新功能和更多最新信息,请参阅www.alluxio.org/

这已经足够让 Alluxio 在本地启动了。接下来,我们将看到如何与集群管理器(如 YARN)集成。

与 YARN 集成

YARN 是最常用的集群管理器之一,其次是 Mesos。如果您还记得第五章中的内容,处理大数据 - Spark 加入派对,YARN 可以管理 Hadoop 集群的资源,并允许数百个应用程序共享集群资源。我们可以使用 YARN 和 Spark 集成来运行长时间运行的 Spark 作业,以处理实时信用卡交易,例如。

但是,不建议尝试将 Alluxio 作为 YARN 应用程序运行;相反,应该将 Alluxio 作为独立集群与 YARN 一起运行。Alluxio 应该与 YARN 一起运行,以便所有 YARN 节点都可以访问本地的 Alluxio worker。为了使 YARN 和 Alluxio 共存,我们必须通知 YARN 有关 Alluxio 使用的资源。例如,YARN 需要知道为 Alluxio 留下多少内存和 CPU。

Alluxio worker 内存

Alluxio worker 需要一些内存用于其 JVM 进程和一些内存用于其 RAM 磁盘;通常 1GB 对于 JVM 内存来说是足够的,因为这些内存仅用于缓冲和元数据。

RAM 磁盘内存可以通过设置alluxio.worker.memory.size进行配置。

存储在非内存层中的数据,如 SSD 或 HDD,不需要包括在内存大小计算中。

Alluxio master 内存

Alluxio master 存储有关 Alluxio 中每个文件的元数据,因此对于更大的集群部署,它应该至少为 1GB,最多为 32GB。

CPU vcores

每个 Alluxio worker 至少应该有一个 vcore,生产部署中 Alluxio master 可以使用至少一个到四个 vcores。

要通知 YARN 在每个节点上为 Alluxio 保留的资源,请修改yarn-site.xml中的 YARN 配置参数。

yarn.nodemanager.resource.memory-mb更改为为 Alluxio worker 保留一些内存。

确定在节点上为 Alluxio 分配多少内存后,从yarn.nodemanager.resource.memory-mb中减去这个值,并使用新值更新参数。

yarn.nodemanager.resource.cpu-vcores更改为为 Alluxio worker 保留 CPU vcores。

确定在节点上为 Alluxio 分配多少内存后,从yarn.nodemanager.resource.cpu-vcores中减去这个值,并使用新值更新参数。

更新 YARN 配置后,重新启动 YARN 以使其应用更改。

使用 Alluxio 与 Spark

为了在 Spark 中使用 Alluxio,您将需要一些依赖的 JAR 文件。这是为了使 Spark 能够连接到 Alluxio 文件系统并读取/写入数据。一旦我们启动具有 Alluxio 集成的 Spark,大部分 Spark 代码仍然保持完全相同,只有代码的读取和写入部分发生了变化,因为现在您必须使用alluxio://来表示 Alluxio 文件系统。

然而,一旦设置了 Alluxio 集群,Spark 作业(执行器)将连接到 Alluxio 主服务器以获取元数据,并连接到 Alluxio 工作节点进行实际数据读取/写入操作。

这里显示了从 Spark 作业中使用的 Alluxio 集群的示例:

以下是如何使用 Alluxio 启动 Spark-shell 并运行一些代码的步骤:

第 1 步,将目录更改为提取 Spark 的目录:

 cd spark-2.2.0-bin-hadoop2.7

第 2 步,将 JAR 文件从 Alluxio 复制到 Spark:

cp ../alluxio-1.5.0-hadoop-2.7/core/common/target/alluxio-core-common-1.5.0.jar .
cp ../alluxio-1.5.0-hadoop-2.7/core/client/hdfs/target/alluxio-core-client-hdfs-1.5.0.jar .
cp ../alluxio-1.5.0-hadoop-2.7/core/client/fs/target/alluxio-core-client-fs-1.5.0.jar .
cp ../alluxio-1.5.0-hadoop-2.7/core/protobuf/target/alluxio-core-protobuf-1.5.0.jar . 

第 3 步,使用 Alluxio JAR 文件启动 Spark-shell:

./bin/spark-shell --master local[2] --jars alluxio-core-common-1.5.0.jar,alluxio-core-client-fs-1.5.0.jar,alluxio-core-client-hdfs-1.5.0.jar,alluxio-otobuf-1.5.0.jar

第 4 步,将样本数据集复制到 Alluxio 文件系统中:

$ ./bin/alluxio fs copyFromLocal ../spark-2.1.1-bin-hadoop2.7/Sentiment_Analysis_Dataset10k.csv /Sentiment_Analysis_Dataset10k.csv
Copied ../spark-2.1.1-bin-hadoop2.7/Sentiment_Analysis_Dataset10k.csv to /Sentiment_Analysis_Dataset10k.csv

您可以使用浏览选项卡在 Alluxio 中验证文件;它是大小为 801.29KB 的 Sentiment_Analysis_Dataset10k.csv 文件:

第 4 步。访问带有和不带有 Alluxio 的文件。

首先,在 shell 中设置 Alluxio 文件系统配置:

scala> sc.hadoopConfiguration.set("fs.alluxio.impl", "alluxio.hadoop.FileSystem")

从 Alluxio 加载文本文件:

scala> val alluxioFile = sc.textFile("alluxio://localhost:19998/Sentiment_Analysis_Dataset10k.csv")
alluxioFile: org.apache.spark.rdd.RDD[String] = alluxio://localhost:19998/Sentiment_Analysis_Dataset10k.csv MapPartitionsRDD[39] at textFile at <console>:24

scala> alluxioFile.count
res24: Long = 9999

从本地文件系统加载相同的文本文件:

scala> val localFile = sc.textFile("Sentiment_Analysis_Dataset10k.csv")
localFile: org.apache.spark.rdd.RDD[String] = Sentiment_Analysis_Dataset10k.csv MapPartitionsRDD[41] at textFile at <console>:24

scala> localFile.count
res23: Long = 9999

如果您可以加载大量数据到 Alluxio 中,Alluxio 集成将提供更高的性能,而无需缓存数据。这带来了几个优势,包括消除了每个使用 Spark 集群的用户缓存大型数据集的需要。

摘要

在本附录中,我们探讨了使用 Alluxio 作为加速 Spark 应用程序的一种方式,利用了 Alluxio 的内存文件系统功能。这带来了几个优势,包括消除了每个使用 Spark 集群的用户缓存大型数据集的需要。

在下一个附录中,我们将探讨如何使用 Apache Zeppelin,一个基于 Web 的笔记本,进行交互式数据分析。

第二十一章:使用 Apache Zeppelin 进行交互式数据分析

从数据科学的角度来看,交互式可视化数据分析也很重要。Apache Zeppelin 是一个基于 Web 的笔记本,用于交互式和大规模数据分析,具有多个后端和解释器,如 Spark、Scala、Python、JDBC、Flink、Hive、Angular、Livy、Alluxio、PostgreSQL、Ignite、Lens、Cassandra、Kylin、Elasticsearch、JDBC、HBase、BigQuery、Pig、Markdown、Shell 等等。

毫无疑问,Spark 有能力以可扩展和快速的方式处理大规模数据集。但是,Spark 中缺少一件事--它没有实时或交互式的可视化支持。考虑到 Zeppelin 的上述令人兴奋的功能,在本章中,我们将讨论如何使用 Apache Zeppelin 进行大规模数据分析,使用 Spark 作为后端的解释器。总之,将涵盖以下主题:

  • Apache Zeppelin 简介

  • 安装和入门

  • 数据摄入

  • 数据分析

  • 数据可视化

  • 数据协作

Apache Zeppelin 简介

Apache Zeppelin 是一个基于 Web 的笔记本,可以让您以交互方式进行数据分析。使用 Zeppelin,您可以使用 SQL、Scala 等制作美丽的数据驱动、交互式和协作文档。Apache Zeppelin 解释器概念允许将任何语言/数据处理后端插入 Zeppelin。目前,Apache Zeppelin 支持许多解释器,如 Apache Spark、Python、JDBC、Markdown 和 Shell。Apache Zeppelin 是 Apache 软件基金会的一个相对较新的技术,它使数据科学家、工程师和从业者能够利用数据探索、可视化、共享和协作功能。

安装和入门

由于使用其他解释器不是本书的目标,而是在 Zeppelin 上使用 Spark,所有代码都将使用 Scala 编写。因此,在本节中,我们将展示如何使用仅包含 Spark 解释器的二进制包配置 Zeppelin。Apache Zeppelin 官方支持并在以下环境上进行了测试:

要求 值/版本 其他要求
Oracle JDK 1.7 或更高版本 设置JAVA_HOME

| 操作系统 | macOS 10.X+ Ubuntu 14.X+

CentOS 6.X+

Windows 7 Pro SP1+ | - |

安装和配置

如前表所示,要在 Zeppelin 上执行 Spark 代码,需要 Java。因此,如果尚未设置,请在上述任何平台上安装和设置 Java。或者,您可以参考第一章,Scala 简介,了解如何在计算机上设置 Java。

可以从zeppelin.apache.org/download.html下载最新版本的 Apache Zeppelin。每个版本都有三个选项:

  1. 带有所有解释器的二进制包:它包含对许多解释器的支持。例如,Spark、JDBC、Pig、Beam、Scio、BigQuery、Python、Livy、HDFS、Alluxio、Hbase、Scalding、Elasticsearch、Angular、Markdown、Shell、Flink、Hive、Tajo、Cassandra、Geode、Ignite、Kylin、Lens、Phoenix 和 PostgreSQL 目前在 Zeppelin 中得到支持。

  2. 带有 Spark 解释器的二进制包:通常只包含 Spark 解释器。它还包含解释器的网络安装脚本。

  3. 源代码:您还可以从 GitHub 存储库构建 Zeppelin(更多内容将在后续介绍)。

为了展示如何安装和配置 Zeppelin,我们从以下站点镜像下载了二进制包:

www.apache.org/dyn/closer.cgi/zeppelin/zeppelin-0.7.1/zeppelin-0.7.1-bin-netinst.tgz

下载后,在计算机上的某个位置解压缩它。假设您解压缩文件的路径是/home/Zeppelin/

从源代码构建

您还可以从 GitHub 存储库中构建所有最新更改的 Zeppelin。如果要从源代码构建,必须首先安装以下工具:

  • Git:任何版本

  • Maven:3.1.x 或更高版本

  • JDK:1.7 或更高版本

  • npm:最新版本

  • libfontconfig:最新版本

如果您尚未安装 Git 和 Maven,请从zeppelin.apache.org/docs/0.8.0-SNAPSHOT/install/build.html#build-requirements检查构建要求说明。但是,由于页面限制,我们没有详细讨论所有步骤。如果您感兴趣,可以参考此 URL 获取更多详细信息:zeppelin.apache.org/docs/snapshot/install/build.html

启动和停止 Apache Zeppelin

在所有类 Unix 平台(例如 Ubuntu、macOS 等)上,使用以下命令:

$ bin/zeppelin-daemon.sh start

如果前面的命令成功执行,您应该在终端上看到以下日志:

图 1:从 Ubuntu 终端启动 Zeppelin

如果您使用 Windows,使用以下命令:

$ bin\zeppelin.cmd

Zeppelin 成功启动后,使用您的网络浏览器转到http://localhost:8080,您将看到 Zeppelin 正在运行。更具体地说,您将在浏览器上看到以下视图:

图 2:Zeppelin 正在 http://localhost:8080 上运行

恭喜!您已成功安装了 Apache Zeppelin!现在,让我们继续使用 Zeppelin,并在配置了首选解释器后开始我们的数据分析。

现在,要从命令行停止 Zeppelin,请发出以下命令:

$ bin/zeppelin-daemon.sh stop

创建笔记本

一旦您在http://localhost:8080/上,您可以探索不同的选项和菜单,以帮助您了解如何熟悉 Zeppelin。您可以在zeppelin.apache.org/docs/0.7.1/quickstart/explorezeppelinui.html上找到更多关于 Zeppelin 及其用户友好的 UI 的信息(您也可以根据可用版本参考最新的快速入门文档)。

现在,让我们首先创建一个示例笔记本并开始。如下图所示,您可以通过单击“创建新笔记”选项来创建一个新的笔记本:

图 3:创建一个示例 Zeppelin 笔记本

如前图所示,默认解释器选择为 Spark。在下拉列表中,您还将只看到 Spark,因为我们已经为 Zeppelin 下载了仅包含 Spark 的二进制包。

配置解释器

每个解释器都属于一个解释器组。解释器组是启动/停止解释器的单位。默认情况下,每个解释器属于一个单一组,但该组可能包含更多的解释器。例如,Spark 解释器组包括 Spark 支持、pySpark、Spark SQL 和依赖项加载器。如果您想在 Zeppelin 上执行 SQL 语句,应该使用%符号指定解释器类型;例如,要使用 SQL,应该使用%sql;要使用标记,使用%md,依此类推。

有关更多信息,请参考以下图片:

图 4:在 Zeppelin 上使用 Spark 的解释器属性数据摄入

好了,一旦您创建了笔记本,就可以直接在代码部分编写 Spark 代码。对于这个简单的例子,我们将使用银行数据集,该数据集可供研究使用,并可从archive.ics.uci.edu/ml/machine-learning-databases/00222/下载,由 S. Moro、R. Laureano 和 P. Cortez 提供,使用数据挖掘进行银行直接营销:CRISP-DM 方法的应用。数据集包含诸如年龄、职业头衔、婚姻状况、教育、是否为违约者、银行余额、住房、是否从银行借款等客户的信息,以 CSV 格式提供。以下是数据集的样本:

图 5:银行数据集样本

现在,让我们首先在 Zeppelin 笔记本上加载数据:

valbankText = sc.textFile("/home/asif/bank/bank-full.csv")

执行此代码行后,创建一个新的段落,并将其命名为数据摄入段落:

图 6:数据摄入段落

如果您仔细观察前面的图像,代码已经运行,我们不需要定义 Spark 上下文。原因是它已经在那里定义为sc。甚至不需要隐式定义 Scala。稍后我们将看到一个例子。

数据处理和可视化

现在,让我们创建一个案例类,告诉我们如何从数据集中选择所需的字段:

case class Bank(age:Int, job:String, marital : String, education : String, balance : Integer)

现在,将每行拆分,过滤掉标题(以age开头),并将其映射到Bank案例类中,如下所示:

val bank = bankText.map(s=>s.split(";")).filter(s => (s.size)>5).filter(s=>s(0)!="\"age\"").map( 
  s=>Bank(s(0).toInt,  
  s(1).replaceAll("\"", ""), 
  s(2).replaceAll("\"", ""), 
  s(3).replaceAll("\"", ""), 
  s(5).replaceAll("\"", "").toInt 
        ) 
) 

最后,转换为 DataFrame 并创建临时表:

bank.toDF().createOrReplaceTempView("bank")

以下截图显示所有代码片段都成功执行,没有显示任何错误:

图 7:数据处理段落

为了更加透明,让我们在代码执行后查看标记为绿色的状态(在图像右上角),如下所示:

图 8:每个段落中 Spark 代码的成功执行

现在让我们加载一些数据,以便使用以下 SQL 命令进行操作:

%sql select age, count(1) from bank where age >= 45 group by age order by age

请注意,上述代码行是一个纯 SQL 语句,用于选择年龄大于或等于 45 岁的所有客户的姓名(即年龄分布)。最后,它计算了同一客户组的数量。

现在让我们看看前面的 SQL 语句在临时视图(即bank)上是如何工作的:

图 9:选择所有年龄分布的客户姓名的 SQL 查询[表格]

现在您可以从结果部分附近的选项卡中选择图形选项,例如直方图、饼图、条形图等。例如,使用直方图,您可以看到年龄组>=45的相应计数。

图 10:选择所有年龄分布的客户姓名的 SQL 查询[直方图]

这是使用饼图的效果:

图 11:选择所有年龄分布的客户姓名的 SQL 查询[饼图]

太棒了!现在我们几乎可以使用 Zeppelin 进行更复杂的数据分析问题了。

使用 Zeppelin 进行复杂数据分析

在本节中,我们将看到如何使用 Zeppelin 执行更复杂的分析。首先,我们将明确问题,然后将探索将要使用的数据集。最后,我们将应用一些可视化分析和机器学习技术。

问题定义

在本节中,我们将构建一个垃圾邮件分类器,用于将原始文本分类为垃圾邮件或正常邮件。我们还将展示如何评估这样的模型。我们将尝试专注于使用和处理 DataFrame API。最终,垃圾邮件分类器模型将帮助您区分垃圾邮件和正常邮件。以下图像显示了两条消息的概念视图(分别为垃圾邮件和正常邮件):

图 12:垃圾邮件和正常邮件示例

我们使用一些基本的机器学习技术来构建和评估这种类型问题的分类器。具体来说,逻辑回归算法将用于解决这个问题。

数据集描述和探索

我们从archive.ics.uci.edu/ml/datasets/SMS+Spam+Collection下载的垃圾数据集包含 5,564 条短信,已经被手动分类为正常或垃圾。这些短信中只有 13.4%是垃圾短信。这意味着数据集存在偏斜,并且只提供了少量垃圾短信的示例。这是需要记住的一点,因为它可能在训练模型时引入偏差:

图 13:短信数据集的快照

那么,这些数据是什么样子的呢?您可能已经看到,社交媒体文本可能会非常肮脏,包含俚语、拼写错误、缺少空格、缩写词,比如uursyrs等等,通常违反语法规则。有时甚至包含消息中的琐碎词语。因此,我们需要处理这些问题。在接下来的步骤中,我们将遇到这些问题,以更好地解释分析结果。

第 1 步。在 Zeppelin 上加载所需的包和 API - 让我们加载所需的包和 API,并在 Zeppelin 上创建第一个段落,然后再将数据集导入:

图 14:包/ API 加载段落

第 2 步。加载和解析数据集 - 我们将使用 Databricks 的 CSV 解析库(即com.databricks.spark.csv)将数据读入 DataFrame:

图 15:数据摄取/加载段落

第 3 步。使用StringIndexer创建数字标签 - 由于原始 DataFrame 中的标签是分类的,我们需要将它们转换回来,以便在机器学习模型中使用:

图 16:StringIndexer 段落,输出显示原始标签、原始文本和相应标签。

第 4 步。使用RegexTokenizer创建词袋 - 我们将使用RegexTokenizer去除不需要的单词并创建一个词袋:

图 17:RegexTokenizer 段落,输出显示原始标签、原始文本、相应标签和标记

第 5 步。删除停用词并创建一个经过筛选的 DataFrame - 我们将删除停用词并创建一个经过筛选的 DataFrame 以进行可视化分析。最后,我们展示 DataFrame:

图 18:StopWordsRemover 段落,输出显示原始标签、原始文本、相应标签、标记和去除停用词的筛选标记

第 6 步。查找垃圾消息/单词及其频率 - 让我们尝试创建一个仅包含垃圾单词及其相应频率的 DataFrame,以了解数据集中消息的上下文。我们可以在 Zeppelin 上创建一个段落:

图 19:带有频率段落的垃圾邮件标记

现在,让我们通过 SQL 查询在图表中查看它们。以下查询选择所有频率超过 100 的标记。然后,我们按照它们的频率降序排序标记。最后,我们使用动态表单来限制记录的数量。第一个是原始表格格式:

图 20:带有频率可视化段落的垃圾邮件标记[表格]

然后,我们将使用条形图,这提供了更多的视觉洞察。现在我们可以看到,垃圾短信中最频繁出现的单词是 call 和 free,分别出现了 355 次和 224 次:

图 21:带有频率可视化段落的垃圾邮件标记[直方图]

最后,使用饼图提供了更好更广泛的可见性,特别是如果您指定了列范围:

图 22:带有频率可视化段落的垃圾邮件标记[饼图]

第 7 步。使用 HashingTF 进行词频- 使用HashingTF生成每个过滤标记的词频,如下所示:

图 23:HashingTF 段落,输出显示原始标签、原始文本、相应标签、标记、过滤后的标记和每行的相应词频

第 8 步。使用 IDF 进行词频-逆文档频率(TF-IDF)- TF-IDF 是一种在文本挖掘中广泛使用的特征向量化方法,用于反映术语对语料库中文档的重要性:

图 24:IDF 段落,输出显示原始标签、原始文本、相应标签、标记、过滤后的标记、词频和每行的相应 IDF

词袋:词袋为句子中每个单词的出现赋予值1。这可能不是理想的,因为句子的每个类别很可能具有相同的theand等词的频率;而viagrasale等词可能在确定文本是否为垃圾邮件方面应该具有更高的重要性。

TF-IDF:这是文本频率-逆文档频率的缩写。这个术语本质上是每个词的文本频率和逆文档频率的乘积。这在 NLP 或文本分析中的词袋方法中常用。

使用 TF-IDF:让我们来看看词频。在这里,我们考虑单个条目中单词的频率,即术语。计算文本频率(TF)的目的是找到在每个条目中似乎重要的术语。然而,诸如“the”和“and”之类的词在每个条目中可能出现得非常频繁。我们希望降低这些词的重要性,因此我们可以想象将前述 TF 乘以整个文档频率的倒数可能有助于找到重要的词。然而,由于文本集合(语料库)可能相当大,通常会取逆文档频率的对数。简而言之,我们可以想象 TF-IDF 的高值可能表示对确定文档内容非常重要的词。创建 TF-IDF 向量需要我们将所有文本加载到内存中,并在开始训练模型之前计算每个单词的出现次数。

第 9 步。使用 VectorAssembler 生成 Spark ML 管道的原始特征- 正如您在上一步中看到的,我们只有过滤后的标记、标签、TF 和 IDF。然而,没有任何可以输入任何 ML 模型的相关特征。因此,我们需要使用 Spark VectorAssembler API 根据前一个 DataFrame 中的属性创建特征,如下所示:

图 25:VectorAssembler 段落,显示使用 VectorAssembler 进行特征创建

第 10 步。准备训练和测试集- 现在我们需要准备训练和测试集。训练集将用于在第 11 步中训练逻辑回归模型,测试集将用于在第 12 步中评估模型。在这里,我将其设置为 75%用于训练,25%用于测试。您可以根据需要进行调整:

图 26:准备训练/测试集段落

第 11 步。训练二元逻辑回归模型- 由于问题本身是一个二元分类问题,我们可以使用二元逻辑回归分类器,如下所示:

图 27:LogisticRegression 段落,显示如何使用必要的标签、特征、回归参数、弹性网参数和最大迭代次数训练逻辑回归分类器

请注意,为了获得更好的结果,我们已经迭代了 200 次的训练。我们已经将回归参数和弹性网参数设置得非常低-即 0.0001,以使训练更加密集。

步骤 12. 模型评估 - 让我们计算测试集的原始预测。然后,我们使用二元分类器评估器来实例化原始预测,如下所示:

****图 28: 模型评估段落

现在让我们计算模型在测试集上的准确性,如下所示:

图 29: 准确性计算段落

这相当令人印象深刻。然而,如果您选择使用交叉验证进行模型调优,例如,您可能会获得更高的准确性。最后,我们将计算混淆矩阵以获得更多见解:

图 30: 混淆段落显示了正确和错误预测的数量,以计数值总结,并按每个类别进行了分解

数据和结果协作

此外,Apache Zeppelin 提供了一个功能,用于发布您的笔记本段落结果。使用此功能,您可以在自己的网站上展示 Zeppelin 笔记本段落结果。非常简单;只需在您的页面上使用<iframe>标签。如果您想分享 Zeppelin 笔记本的链接,发布段落结果的第一步是复制段落链接。在 Zeppelin 笔记本中运行段落后,单击位于右侧的齿轮按钮。然后,在菜单中单击链接此段落,如下图所示:

图 31: 链接段落

然后,只需复制提供的链接,如下所示:

图 32: 获取与协作者共享段落的链接

现在,即使您想发布复制的段落,您也可以在您的网站上使用<iframe>标签。这是一个例子:

<iframe src="img/...?asIframe" height="" width="" ></iframe>

现在,您可以在您的网站上展示您美丽的可视化结果。这更多或少是我们使用 Apache Zeppelin 进行数据分析旅程的结束。有关更多信息和相关更新,您应该访问 Apache Zeppelin 的官方网站zeppelin.apache.org/;您甚至可以订阅 Zeppelin 用户 users-subscribe@zeppelin.apache.org。

摘要

Apache Zeppelin 是一个基于 Web 的笔记本,可以让您以交互方式进行数据分析。使用 Zeppelin,您可以使用 SQL、Scala 等制作美丽的数据驱动、交互式和协作文档。它正在日益受到欢迎,因为最近的版本中添加了更多功能。然而,由于页面限制,并且为了让您更专注于仅使用 Spark,我们展示了仅适用于使用 Scala 的 Spark 的示例。但是,您可以用 Python 编写您的 Spark 代码,并以类似的轻松方式测试您的笔记本。

在本章中,我们讨论了如何使用 Apache Zeppelin 进行后端使用 Spark 进行大规模数据分析。我们看到了如何安装和开始使用 Zeppelin。然后,我们看到了如何摄取您的数据并解析和分析以获得更好的可见性。然后,我们看到了如何将其可视化以获得更好的见解。最后,我们看到了如何与协作者共享 Zeppelin 笔记本。

posted @ 2024-05-21 12:56  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报