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

Scala 和 Spark 大数据分析(全)

原文: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)范式提供了一种全新的抽象层。简而言之,本章讨论了 OOP 语言的一些最大优势:可发现性、模块化和可扩展性。特别地,我们将看到如何在 Scala 中处理变量;Scala 中的方法、类和对象;包和包对象;特质和特质线性化;以及 Java 互操作性。

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

第四章,集合 API,介绍了吸引大多数 Scala 用户的一个特性——集合 API。它功能强大且灵活,具有丰富的操作组合。我们还将展示 Scala 集合 API 的功能以及如何使用它来处理不同类型的数据,并解决各种各样的问题。在这一章中,我们将涵盖 Scala 集合 API、类型和层次结构、一些性能特性、Java 互操作性和 Scala 隐式转换。

第五章,应对大数据 - Spark 登场,概述了数据分析和大数据;我们看到了大数据所带来的挑战,分布式计算如何应对这些挑战,以及函数式编程提出的方法。我们介绍了 Google 的 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 简单且强大地查询结构化数据。此外,我们介绍了数据集,并对数据集、DataFrame 和 RDD 之间的差异进行了比较。我们还将学习如何通过 DataFrame API 进行连接操作和窗口函数,来进行复杂的数据分析。

第九章,流式处理 - Spark Streaming,带领你了解 Spark Streaming,以及如何利用 Spark API 处理数据流。此外,本章中,读者将学习如何通过实践示例,使用 Twitter 上的推文进行实时数据流处理。我们还将探讨与 Apache Kafka 的集成,实现实时处理。我们还会了解结构化流处理,能够为你的应用提供实时查询。

第十章,万物互联 - GraphX,本章中,我们将学习如何使用图模型来解决许多现实世界的问题。我们将通过 Facebook 举例,学习图论、Apache Spark 的图处理库 GraphX、VertexRDD 和 EdgeRDD、图操作符、aggregateMessages、TriangleCounting、Pregel API 以及 PageRank 算法等应用场景。

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

第十二章,高级机器学习最佳实践,提供了关于 Spark 机器学习的一些高级主题的理论和实践方面的内容。我们将了解如何使用网格搜索、交叉验证和超参数调优来优化机器学习模型的性能。在后续部分,我们将讨论如何使用 ALS 开发可扩展的推荐系统,ALS 是基于模型的推荐算法的一个例子。最后,我们还将展示一个主题建模应用,这是文本聚类技术的一个实例。

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

第十四章,是时候整理一些秩序——使用 Spark MLlib 对数据进行聚类,帮助你了解 Spark 如何在集群模式下工作及其底层架构。在前几章中,我们已经看到如何使用不同的 Spark API 开发实际应用。最后,我们将看到如何在集群上部署一个完整的 Spark 应用,无论是使用预先存在的 Hadoop 安装还是没有。

第十五章,使用 Spark ML 进行文本分析,概述了使用 Spark ML 进行文本分析这一美妙领域。文本分析是机器学习中的一个广泛领域,应用场景非常广泛,如情感分析、聊天机器人、电子邮件垃圾邮件检测、自然语言处理等。我们将学习如何使用 Spark 进行文本分析,重点讨论文本分类的应用,使用一万条 Twitter 数据样本集进行分析。我们还将研究 LDA,这是一种流行的技术,用于从文档中生成主题,而无需深入了解实际文本,并将实现基于 Twitter 数据的文本分类,看看如何将这些内容结合起来。

第十六章,Spark 调优,深入探讨了 Apache Spark 的内部机制,指出尽管 Spark 在使用时让我们感觉就像在使用另一个 Scala 集合,但我们不应忘记 Spark 实际上运行在分布式系统中。因此,在本章中,我们将介绍如何监控 Spark 作业、Spark 配置、Spark 应用开发中的常见错误,以及一些优化技术。

第十七章,进入 ClusterLand - 在集群上部署 Spark,探讨了 Spark 在集群模式下的工作原理及其底层架构。我们将了解 Spark 在集群中的架构、Spark 生态系统和集群管理,以及如何在独立集群、Mesos、Yarn 和 AWS 集群上部署 Spark。我们还将了解如何在基于云的 AWS 集群上部署应用程序。

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

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

附录 A,通过 Alluxio 加速 Spark,展示了如何将 Alluxio 与 Spark 结合使用,以提高处理速度。Alluxio 是一个开源的分布式内存存储系统,对于提高跨平台应用程序的速度非常有用,包括 Apache Spark。在本章中,我们将探讨使用 Alluxio 的可能性,以及 Alluxio 的集成如何提供更高的性能,而不需要每次运行 Spark 任务时都将数据缓存到内存中。

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

本书所需的工具

所有示例均使用 Python 版本 2.7 和 3.5 在 Ubuntu Linux 64 位系统上实现,包括 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 或更高版本)

  • Maven assembly 插件用于 Eclipse(2.4.1 或更高版本)

操作系统: 推荐使用 Linux 发行版(包括 Debian、Ubuntu、Fedora、RHEL 和 CentOS),具体来说,推荐在 Ubuntu 上安装完整的 14.04(LTS)64 位(或更高版本)系统,VMWare Player 12 或 VirtualBox。你可以在 Windows(XP/7/8/10)或 Mac OS X(10.4.7 及更高版本)上运行 Spark 作业。

硬件配置: 推荐使用 Core i3、Core i5(推荐)或 Core i7 处理器(以获得最佳效果)。不过,多核处理器将提供更快的数据处理速度和更好的扩展性。你至少需要 8-16 GB 的内存(推荐)用于独立模式,至少需要 32 GB 内存用于单个虚拟机——集群模式则需要更高的内存。你还需要足够的存储空间来运行大型作业(具体取决于你处理的数据集大小),并且最好至少有 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

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

警告或重要注意事项将以这样的方式出现。

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

读者反馈

我们始终欢迎读者反馈。请告诉我们你对这本书的看法——你喜欢或不喜欢的部分。读者的反馈对我们非常重要,它帮助我们开发出你真正能从中受益的书籍。如果你有任何建议,请通过电子邮件feedback@packtpub.com联系我们,并在邮件主题中注明书名。如果你在某个领域有专业知识,并且有兴趣为书籍写作或贡献内容,请查看我们的作者指南:www.packtpub.com/authors

客户支持

既然你已经拥有了一本 Packt 书籍,我们为你准备了多项内容,帮助你最大化地利用这次购买。

下载示例代码

你可以从你在www.packtpub.com的账户中下载本书的示例代码文件。如果你是在其他地方购买的此书,你可以访问www.packtpub.com/support并注册以直接通过电子邮件接收文件。你可以按照以下步骤下载代码文件:

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

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

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名。

  5. 选择你希望下载代码文件的书籍。

  6. 从下拉菜单中选择你购买这本书的来源。

  7. 点击“代码下载”。

下载文件后,请确保使用最新版本的工具解压或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

本书的代码包也托管在 GitHub 上,地址为:github.com/PacktPublishing/Scala-and-Spark-for-Big-Data-Analytics。我们还有其他来自我们丰富书籍和视频目录的代码包,地址为:github.com/PacktPublishing/。快去看看吧!

下载本书的彩色图片

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

勘误

尽管我们已尽一切努力确保内容的准确性,但错误还是可能发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——我们将非常感激你能向我们报告。这样做,你不仅可以帮助其他读者避免困扰,还能帮助我们改进本书的后续版本。如果你发现任何勘误,请访问www.packtpub.com/submit-errata报告,选择你的书籍,点击“勘误提交表单”链接,填写勘误详情。一旦你的勘误得到验证,提交将被接受,并且勘误将被上传到我们的网站或加入该书籍的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,在搜索框中输入书名,所需信息将显示在勘误部分。

盗版

互联网版权材料的盗版问题在所有媒体中都是一个持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可。如果你在互联网上发现我们作品的任何非法复制品,请立即向我们提供该位置地址或网站名称,以便我们采取相应措施。请通过copyright@packtpub.com与我们联系,并提供涉嫌盗版材料的链接。感谢你在保护我们的作者和我们提供有价值内容的能力方面的帮助。

问题

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

第一章:Scala 简介

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

  • Scala 引用

在过去几年中,Scala 逐渐崭露头角,得到了开发者和从业人员的广泛采用,特别是在数据科学和分析领域。另一方面,Apache Spark 是用 Scala 编写的,它是一个快速且通用的大规模数据处理引擎。Spark 的成功归因于多个因素:易用的 API、清晰的编程模型、优越的性能等。因此,自然地,Spark 对 Scala 提供了更多支持:相比于 Python 或 Java,Scala 有更多的 API;此外,新的 Scala API 在发布时通常会先于 Java、Python 和 R 的 API。

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

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

  • Scala 的历史与目的

  • 平台和编辑器

  • 安装和设置 Scala

  • Scala:可扩展的语言

  • 面向 Java 程序员的 Scala

  • 面向初学者的 Scala

  • 总结

Scala 的历史与目的

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

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

Scala 已经变得非常流行,并且得到了广泛的采用,因为它不仅支持面向对象编程范式,还融合了函数式编程的概念。此外,尽管 Scala 的符号运算符相较于 Java 来说不容易阅读,但大多数 Scala 代码相对简洁且易于阅读——例如,Java 的代码过于冗长。

就像其他编程语言一样,Scala 是为了特定的目的而提出和开发的。那么,问题是,Scala 为什么会被创造出来,它解决了哪些问题呢?为了回答这些问题,Odersky 在他的博客中说:

“Scala 的工作源自一项研究,旨在为组件软件开发更好的语言支持。我们希望通过 Scala 实验验证两个假设。首先,我们假设面向组件软件的编程语言需要具备可扩展性,意味着相同的概念能够描述从小到大的各个部分。因此,我们将重点放在抽象、组合和分解机制上,而不是添加一大堆原语,这些原语在某一层级上可能对组件有用,但在其他层级上则可能无效。第二,我们假设通过统一和泛化面向对象编程与函数式编程,编程语言可以为组件提供可扩展的支持。对于静态类型语言(如 Scala),这两种范式直到现在仍然大多是分开的。”

然而,Scala 还提供了模式匹配、高阶函数等特性,这些特性并非为了填补函数式编程(FP)与面向对象编程(OOP)之间的空白,而是因为它们是函数式编程的典型特征。为此,Scala 具有一些强大的模式匹配功能,这是一个基于演员模型的并发框架。此外,它还支持一阶和高阶函数。总的来说,“Scala”这个名字是“可扩展语言”(scalable language)的合成词,意味着它被设计成能够随着用户需求的增长而扩展。

平台与编辑器

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

  • Scala IDE

  • Scala 插件用于 Eclipse

  • IntelliJ IDEA

  • Emacs

  • VIM

在 Eclipse 上支持 Scala 编程有多个优点,借助众多的 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 运行时环境JRE)。然而,如果你需要的是Java 开发工具包JDK),通常这是编译 Java 应用程序时在 Apache Ant、Apache Maven、Eclipse 和 IntelliJ IDEA 中所需要的。

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,但最终,无论你当前运行的是哪个版本的 Windows,都没有关系:

  1. 第一步是从官方网站下载 Scala 的压缩文件。你可以在 www.Scala-lang.org/download/all.html 找到该文件。在该页面的“其他资源”部分,你会找到可以用来安装 Scala 的归档文件列表。我们选择下载 Scala 2.11.8 的压缩文件,如下图所示:

图 2: Windows 上的 Scala 安装程序

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

图 3: Windows 上的环境变量标签

  1. 从那里选择环境变量,并获取 Scala 的 bin 文件夹的路径;然后,将其添加到 PATH 环境变量中。应用更改并点击确定,如下截图所示:

图 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(第二行),就可以开始使用 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 home:

$ 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 这一极其强大的特性有助于你在代码执行之前,在非常早的阶段就能发现和捕捉大多数细微的 bug 和错误。

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 中引用类型和原始类型之间的区别依然存在,但它大部分被隐藏了)。举个例子,在 Scala 中,字符串会被隐式转换为字符集合,但在 Java 中则不是这样!

类型推导

如果你不熟悉这个术语,它其实就是编译时的类型推导。等等,这不是动态类型的意思吗?嗯,不是。注意我说的是类型推导;这与动态类型语言的做法截然不同,另外,它是在编译时完成的,而不是运行时。许多语言都内建了这一功能,但其实现因语言而异。刚开始时这可能会让人困惑,但通过代码示例会变得更清晰。让我们进入 Scala REPL 做些实验。

Scala REPL

Scala REPL 是一个强大的功能,它使得在 Scala shell 中编写 Scala 代码更加直接和简洁。REPL 代表 Read-Eval-Print-Loop,也叫做 交互式解释器。这意味着它是一个用于:

  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"
                    ^

根据奥德斯基的说法,“将字符映射到 RichString 上的字符图应当返回一个 RichString,如 Scala REPL 中的以下交互所示”。前述语句可以通过以下代码行进行验证:

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

然而,如果有人将 Char 的方法应用于 Int 再应用于 String,会发生什么呢?在这种情况下,Scala 会进行转换,因为向量整数(也称为不可变集合)是 Scala 集合的特性,正如 图 9 所示。我们将在 第四章 中详细了解 Scala 集合 API,集合 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 中展示,方法如下:

图 9: Scala REPL 示例 2

“所以结果是,map 会根据传递的函数参数的返回类型不同,产生不同的类型!”

  • 奥德斯基

嵌套函数

为什么你的编程语言需要支持嵌套函数?大多数时候,我们希望保持方法简洁,避免过大的函数。在 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 之间的区别。

导入语句

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

// 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 中是有效的:

// 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 中没有 static。你不能进行静态导入,也不能将静态方法添加到类中。在 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.
}

如需更详细的讨论,请参考这个网址: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")
  ...
}

甚至可以混合多个特质。例如,对于前面的 trait(即 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)

前面的脚本可以直接从命令 Shell 中运行:

./script.sh

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

$ sudo chmod +x script.sh

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

摘要

本章中,您已经学习了 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 的面向对象

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

  • 保罗·格雷厄姆

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

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

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

  • Scala 中的变量

  • Scala 中的方法、类和对象

  • 包和包对象

  • 特征和特征线性化

  • Java 互操作性

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

Scala 中的变量

在深入讨论面向对象编程(OOP)特性之前,首先我们需要了解 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 中几乎到处都有对象,所有数据类型都是对象,你可以在它们中调用方法,如下所示:

序号 数据类型及描述
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 数据类型、描述和范围

上面表格中列出的所有数据类型都是对象。然而,请注意,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 中,初始化变量后再使用它是一种良好的实践。然而,需要注意的是,未初始化的变量不一定是 null(考虑像 IntLongDoubleChar 等类型),而已初始化的变量也不一定是非 null(例如,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)

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 的值。这些只是使用 lazy val 概念的一些例子。有兴趣的读者可以访问这个页面以了解更多详细信息:blog.codecentric.de/en/2016/02/lazy-vals-scala-look-hood/.

Scala 中的方法、类和对象

在上一节中,我们学习了如何处理 Scala 变量、不同的数据类型及其可变性和不可变性,以及它们的使用范围。然而,在本节中,为了更好地理解面向对象编程(OOP)的概念,我们将涉及方法、对象和类。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,以及它们的 setter 和 getter。现在,如何使用它们来实现我们的目标呢?这就涉及到 Scala 对象的使用。接下来我们将讨论 Scala 对象,然后再回到我们的下一个讨论。

Scala 中的对象

Scala 中的 对象 的含义与传统面向对象编程中的对象略有不同,这一点需要说明。特别地,在面向对象编程中,对象是类的实例,而在 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 的类似。唯一的巨大区别是主方法不在类内部,而是放在对象内部。在 Scala 中,object 关键字可以表示两种不同的含义:

  • 正如在面向对象编程中,一个对象可以表示一个类的实例

  • 一个表示非常不同类型实例对象的关键字称为单例模式

单例和伴生对象

在这一小节中,我们将看到 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 一样,final关键字在 Scala 中也存在,其作用与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>

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

访问和可见性

在这一小节中,我们将试图理解 Scala 变量在面向对象编程范式中的访问和可见性。让我们看看 Scala 中的访问修饰符。Scala 的一个类似的例子:

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

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

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()
  }
}

在之前的设置中,我们在主构造函数中包含了一个次要(即,第 2 个)消息。主构造函数将实例化一个新的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)
}

现在,使用通用特性扩展前面提到的特性,我们可以按照以下代码段操作:

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

那么,Scala 中的抽象类和特性有什么区别呢?正如你所看到的,抽象类可以有构造函数参数、类型参数和多个参数。然而,Scala 中的特性只能有类型参数。

如果特性没有任何实现代码,那么它就是完全互操作的。此外,Scala 特性在 Scala 2.12 中与 Java 接口完全互操作。因为 Java 8 也允许在接口中实现方法。

特性也可能有其他用途,例如,抽象类可以扩展特性,或者如果需要,任何普通类(包括案例类)都可以扩展现有的特性。例如,抽象类也可以扩展特性:

abstract class Cat extends Animal { }

最后,一个普通的 Scala 类也可以扩展一个 Scala 特性。由于类是具体的(也就是说,可以创建实例),因此特性的抽象成员应该被实现。在下一节中,我们将讨论 Scala 代码的 Java 互操作性。现在,让我们深入探讨面向对象编程中的另一个重要概念——抽象类。我们将在下一节讨论这一内容。

抽象类

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

那么,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()
}

如果仔细查看前面的代码段,你会发现有两个修饰符(即抽象和覆盖)。现在,在前面的设置下,你可以通过以下方式使用前面的类:

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: Scala REPL 中的 case 类

包和包对象

就像 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 软件包。让我们借助使用诸如ArrayList等 Java 集合,将前面的示例翻译成 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 官方文档了解更多关于模式匹配的内容(网址: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。在继续之前,你需要通过其官方网站上适合你系统的安装方法来安装 SBT(URL: www.scala-sbt.org/release/docs/Setup.html)。

那么,让我们从 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 的官方网站。

Maven 与 Eclipse

使用 Eclipse 作为 Scala IDE,并以 Maven 作为构建工具是非常简单和直接的。在本节中,我们将通过截图演示如何在 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 项目时,你需要导航到 New | Project | Other,然后选择 Maven Project。之后,选择具有 net.alchim31.maven 作为 Group Id 的选项:

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

完成此选择后,你需要跟随向导输入必填项,如 Group Id 等。然后,点击 Finish,这样你就在工作空间中创建了第一个支持 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 更新管理器

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

图 6: 使用 Marketplace 在 Eclipse 上安装 Buildship 插件以支持 Gradle 构建

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

图 7: 使用安装新软件方式在 Eclipse 上安装 Buildship 插件以支持 Gradle 构建

例如,以下 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: 使用 Gradle 的 Eclipse 项目结构

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

概述

以合理的方式构建代码,使用类和特质,能够通过泛型增强代码的可重用性,并使用标准的广泛工具创建项目。通过了解 Scala 如何实现面向对象(OO)范式,来构建模块化的软件系统。在本章中,我们讨论了 Scala 中的基本面向对象特性,如类和对象、包和包对象、特质及特质线性化、Java 互操作性、模式匹配、隐式和泛型。最后,我们讨论了在 Eclipse 或任何其他 IDE 上构建 Spark 应用程序所需的 SBT 及其他构建系统。

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

第三章:函数式编程概念

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

  • 迈克尔·费瑟斯

使用 Scala 和 Spark 是学习大数据分析的一个非常好的组合。然而,在面向对象编程(OOP)范式的基础上,我们还需要了解为什么函数式编程(FP)概念对于编写 Spark 应用程序、最终分析数据非常重要。如前几章所述,Scala 支持两种编程范式:面向对象编程范式和函数式编程概念。在第二章,面向对象的 Scala中,我们探讨了面向对象编程范式,学习了如何在蓝图(类)中表示现实世界中的对象,并将其实例化为具有实际内存表示的对象。

在本章中,我们将重点讨论第二种范式(即函数式编程)。我们将了解什么是函数式编程,Scala 是如何支持它的,为什么它如此重要,以及使用这一概念的相关优势。更具体地说,我们将学习几个主题,例如为什么 Scala 是数据科学家的强大工具,为什么学习 Spark 范式很重要,纯函数和高阶函数HOFs)。本章还将展示一个使用 HOF 的实际案例。然后,我们将学习如何在 Scala 的标准库中处理集合之外的高阶函数中的异常。最后,我们将了解函数式 Scala 如何影响对象的可变性。

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

  • 函数式编程简介

  • 数据科学家的函数式 Scala

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

  • 纯函数与高阶函数

  • 使用高阶函数:一个实际的使用案例

  • 函数式 Scala 中的错误处理

  • 函数式编程与数据的可变性

函数式编程简介

在计算机科学中,函数式编程(FP)是一种编程范式,它是一种构建计算机程序结构和元素的独特风格。这种独特性有助于将计算视为数学函数的求值,避免了状态变化和可变数据。因此,通过使用 FP 概念,你可以学习如何以确保数据不可变的方式编写代码。换句话说,FP 是编写纯函数的编程方法,是尽可能去除隐式输入和输出,使得我们的代码尽可能只是描述输入与输出之间的关系。

这并不是一个新概念,但λ演算(Lambda Calculus),它为函数式编程提供了基础,最早是在 1930 年代提出的。然而,在编程语言的领域中,函数式编程这个术语指的是一种新的声明式编程范式,意味着编程可以借助控制、声明或表达式来完成,而不是像传统编程语言(例如 C)中常用的经典语句。

函数式编程的优势

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

随着复杂软件需求的增加,我们也需要良好结构的程序和不难编写且可调试的软件。我们还需要编写可扩展的代码,这样可以在未来节省编程成本,并且能为代码的编写和调试带来便利;甚至需要更多模块化的软件,这种软件易于扩展并且编程工作量更小。由于函数式编程的模块化特性,函数式编程被认为是软件开发中的一大优势。

在函数式编程中,其结构中有一个基本构建块,叫做无副作用的函数(或者至少是副作用非常少的函数),大多数代码都遵循这一原则。没有副作用时,求值顺序真的不重要。关于编程语言的视角,有一些方法可以强制特定的求值顺序。在某些函数式编程语言中(例如,像 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 都是你真正的伙伴,并且能在时间上为你带来回报。

为什么选择函数式编程和 Scala 来学习 Spark?

在本节中,我们将讨论为什么要学习 Spark 来解决我们的数据分析问题。接着,我们将讨论为什么 Scala 中的函数式编程概念对于数据科学家来说尤其重要,它可以使数据分析变得更加简单。我们还将讨论 Spark 的编程模型及其生态系统,帮助大家更清楚地理解。

为什么选择 Spark?

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

  • 基于批处理的应用

  • 迭代算法,在以前无法快速运行的情况下

  • 交互式查询和流处理

此外,学习 Spark 并将其应用于你的程序并不需要花费太多时间,也不需要深入理解并发和分布式系统的内部细节。Spark 是在 2009 年由 UC Berkeley 的 AMPLab 团队实现的,2010 年他们决定将其开源。之后,Spark 于 2013 年成为 Apache 项目,从那时起,Spark 一直被认为是最著名和最常用的 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 集群模式下运行并提高可扩展性,你可以考虑将亚马逊弹性计算云EC2)服务作为基础设施即服务IaaS)或平台即服务PaaS)。

请参考第十七章,前往 ClusterLand - 在集群上部署 Spark 和第十八章,在集群上测试和调试 Spark,了解如何使用 Scala 和 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 是一个机器学习框架,支持以分布式方式实现许多机器学习算法。

  • 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()

DataFrames API(这将在后面的章节中详细讨论)生成的代码同样简洁且可读,其中函数式 API 适用于大多数使用场景,最小化了 MapReduce 阶段;有许多 shuffle 操作可能导致显著的性能开销,导致这种情况的主要原因如下:

  • 大型代码库需要静态类型来消除琐碎的错误,比如ae代替age这样的错误

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

  • 通过幕后变异,DataFrames API 可以实现 2 倍的加速,这也可以通过封装状态的 OOP 和使用 mapPartitions 与 combineByKey 来实现

  • 构建功能快速的灵活性和 Scala 特性是必需的

在 Barclays,OOP 和 FP 的结合可以使一些本来非常困难的问题变得更加简单。例如,在 Barclays,最近开发了一个名为 Insights Engine 的应用程序,它可以执行任意数量的接近任意的类似 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 的范围 for 循环语法: 使用 Scala 范围的 for 循环的最简单语法是:

for( var x <- range ){

statement(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

在结束我们关于高阶函数的讨论之前,来看看一个实际的例子,也就是使用高阶函数进行柯里化。

使用高阶函数

假设你在餐厅做厨师,某个同事问你一个问题:实现一个高阶函数HOF)来执行柯里化。需要线索吗?假设你有以下两个高阶函数的签名:

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

类似地,按如下方式实现一个执行反柯里化的函数:

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

那么,如何使用高阶函数来执行柯里化操作呢?你可以创建一个特征,它封装了两个高阶函数(即柯里化和反柯里化)的签名,如下所示:

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) } }
}

这里我先实现了反柯里化,因为它更简单。等号后面的两个大括号是一个匿名函数字面量,用来接受两个参数(即类型为 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 保存了一个函数,该函数接受一个长整型并将其加 1,然后打印出 4.0。

  • increment 保存了一个函数,该函数接受一个长整型并将其加 2,最后打印出 3.0。

  • unspicedAdd 保存了一个函数,该函数将 1 加到一个长整型上,最后打印出 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 使得已检查与未检查变得非常简单:它没有已检查的异常。在 Scala 中,所有异常都是未检查的,甚至是 SQLExceptionIOException 等。因此,接下来我们将看看如何至少处理这些异常。

抛出异常

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

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

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

使用 trycatch 捕获异常

Scala 允许你在一个代码块中使用 try...catch 捕获任何异常,并使用 case 块对其进行模式匹配。使用 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 下没有名为data.txt的文件,你将遇到如下的FileNotFoundException

前面代码的输出如下:

File not found exception

现在,让我们通过一个简单的示例来展示如何在 Scala 中使用finally子句,以使try...catch块完整。

最后

假设你希望无论是否抛出异常,都执行你的代码,那么你应该使用finally子句。你可以像下面这样将它放在try 块内部。以下是一个示例:

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]是一个实例,它包含XY中的一个实例,但不能同时包含两个实例。我们将这两个子类型称为 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),因此有可能在同一代码库中混合使用这两种编程风格。在本章中,我们看到了几个例子,展示了编写纯函数是容易的。然而,将它们组合成一个完整的应用程序却很困难。你可能会同意,像单子这样的高级主题使得函数式编程看起来令人畏惧。

我和很多人谈过,他们认为递归并不自然。当你使用不可变对象时,你永远不能用其他方式修改它们。你不会有任何时刻允许这样做。这就是不可变对象的关键所在!有时候,我发现纯函数和数据的输入或输出会混淆。然而,当你需要修改时,你可以创建一个包含你修改过字段的对象副本。因此,从理论上讲,不会混淆。最后,只有使用不可变值和递归可能会导致 CPU 使用和内存的性能问题。

总结

在本章中,我们探讨了一些 Scala 中的函数式编程概念。我们了解了什么是函数式编程以及 Scala 如何支持它,为什么它很重要,以及使用函数式概念的优点。我们还看到了学习函数式编程概念对学习 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 官方文档,Scala 集合 API 的层级结构。这些都是高级抽象类或特质,既有可变的实现,也有不可变的实现。

图 1: 包下的集合 scala.collection

Traversable

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

def foreachU: Unit

该方法对 Traversable 中包含的所有操作至关重要。如果你研究过数据结构,你会熟悉遍历数据结构的元素并在每个元素上执行一个函数。foreach 方法正是做这样的事情,它遍历集合中的元素并对每个元素执行函数 f。正如我们提到的,这是一个抽象方法,它被设计成根据底层集合的不同,提供不同的定义,以确保每个集合的代码高度优化。

Iterable

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

Seq、LinearSeq 和 IndexedSeq

序列与常规 Iterable 有一些区别,它有定义的长度和顺序。Seq 有两个子特质,如 LinearSeqIndexedSeq。让我们快速浏览一下它们。

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

  • isEmpty: 检查列表是否为空

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

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

更多内容,请参考此链接: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中的集合

在每一种面向对象编程(OOP)和函数式编程语言中,数组是一个重要的集合包,帮助我们存储数据对象,之后我们可以非常容易地访问它们。在下一小节中,我们将通过一些示例详细讨论数组。

数组

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

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 的数组。如果你没有指定第 3 个参数,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()方法,你需要像下面这样导入 Scala 的Array包:

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) => ...
}

你甚至可以使用特殊运算符->来编写一个简洁的语法,用于表示两个值的元组。例如:

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 中使用映射(Maps)的世界,映射广泛用于存储基本数据类型。

映射

Map是一个Iterable,由键值对(也称为映射或关联)组成。Map也是最广泛使用的集合之一,因为它可以用于存储基本数据类型。例如:

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可以简单地看作是Tuple2的集合:

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 )

让我们再看一下使用Map的另一种方式:

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;它基本上是一个数据容器,可以存储数据。

Option

Option类型在 Scala 程序中使用频繁,可以将其与 Java 中的 null 值进行比较,null 表示没有值。Scala 的Option [T]是一个容器,用来表示给定类型的零个或一个元素。Option [T]可以是Some [T]None对象,表示缺失的值。例如,Scala 的Mapget方法如果找到了与给定键对应的值,会返回Some(值),如果给定键在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()方法。在这里,我们可以通过 Scala 的模式匹配来使用Option,如下所示:

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()方法,你可以检查该 Option 是否为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 检查一个谓词是否对可遍历集合中的至少一个元素成立。例如:

def exists(p: ((A, B)) ⇒ Boolean): Boolean  

使用 fat 箭头: => 被称为右箭头fat 箭头火箭,用于按名称传递参数。这意味着表达式将在访问参数时求值。它实际上是一个零参数函数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 的中缀表示法。假设你想对复数执行一些操作,并且有一个带有 add 方法的 case 类,用于添加两个复数:

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)

现在,要访问 case 类中的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

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

FlatMap 接受一个函数作为参数。传递给 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 集合的性能特性。

  • 常量:该操作仅需常量时间。

  • eConst:该操作实际上采用常量时间,但这可能取决于一些假设,例如向量的最大长度或哈希键的分布。

  • 线性:该操作随着集合大小按线性增长。

  • 对数:该操作随着集合大小按对数增长。

  • aConst:该操作采用摊销常量时间。有些操作的调用可能会更长,但如果执行多个操作,平均每次操作只需常量时间。

  • 不适用:操作不支持。

序列类型(不可变)的性能特性在下表中呈现。

不可变 CO* 头部 尾部 应用 更新 前置 附加 插入
列表 常量 常量 线性 线性 常量 线性 不适用
常量 常量 线性 线性 常量 线性 不适用
向量 eConst eConst eConst eConst eConst eConst 不适用
常量 常量 线性 线性 常量 线性 线性
队列 aConst aConst 线性 线性 常量 常量 不适用
范围 常量 常量 常量 不适用 不适用 不适用 不适用
字符串 常量 线性 常量 线性 线性 线性 不适用

表 1: 序列类型(不可变)的性能特性 [*CO== 集合对象]

以下表格显示了 表 1表 3 中描述的操作的含义:

头部 用于选择现有序列的前几个元素。
尾部 用于选择所有元素,除了第一个,并返回一个新的序列。
应用 用于索引目的。
更新 用于不可变序列的功能性更新。对于可变序列,它是带有副作用的更新(适用于可变序列的更新)。
前置 用于将一个元素添加到现有序列的开头。对于不可变序列,会生成一个新的序列。对于可变序列,现有序列会被修改。
附加 用于在现有序列的末尾添加一个元素。对于不可变序列,会生成一个新的序列。对于可变序列,现有序列会被修改。
插入 用于在现有序列的任意位置插入一个元素。对于可变序列,可以直接执行此操作。

表 2: 表 1 中描述的操作的含义

序列类型(可变)的性能特征显示在 表 3 中,如下所示:

可变 CO* 头部 尾部 应用 更新 前置 附加 插入
ArrayBuffer 常量 线性 常量 常量 线性 aConst 线性
ListBuffer 常量 线性 线性 线性 常量 常量 线性
StringBuilder 常量 线性 常量 常量 线性 aCconst 线性
MutableList 常量 线性 线性 线性 常量 常量 线性
队列 常量 线性 线性 线性 常量 常量 线性
ArraySeq 常量 线性 常量 常量 不适用 不适用 不适用
常量 线性 线性 线性 常量 线性 线性
ArrayStack 常量 线性 常量 常量 aConst 线性 线性
数组 常量 线性 常量 常量 不适用 不适用 不适用

表 3: 序列类型(可变)的性能特征 [*CO== 集合对象]

有关可变集合和其他类型集合的更多信息,请参考此链接 (docs.scala-lang.org/overviews/collections/performance-characteristics.html)。

集合类型和映射类型的性能特征显示在以下表格中:

集合类型 查找 添加 删除 最小值
不可变 - - - -
HashSet/HashMap eConst eConst eConst 线性
TreeSet/TreeMap 对数 对数 对数 对数
BitSet 常量 线性 线性 eConst*
ListMap 线性 线性 线性 线性
集合类型 查找 添加 删除 最小值
可变 - - - -
HashSet/HashMap eConst eConst eConst 线性
WeakHashMap eConst eConst eConst 线性
BitSet 常量 aConst 常量 eConst*
TreeSet 对数 对数 对数 对数

表 4: 集合和映射类型的性能特性 [ 仅在位图紧凑时适用 ]

以下表格显示了 表 4 中描述的每个操作的含义:

操作 含义
查找 用于测试一个元素是否包含在集合中。其次,也用于选择与特定键相关联的值。
添加 用于向集合中添加一个新元素。同时,也用于向映射中添加一个新的键/值对。
删除 用于从集合中移除一个元素或从映射中移除一个键。
最小值 用于选择集合中的最小元素或映射中的最小键。

表 5: 表 4 中描述的每个操作的含义

基本的性能指标之一是特定集合对象的内存使用情况。在接下来的章节中,我们将提供一些基于内存使用情况测量这些指标的指南。

集合对象的内存使用情况

有时,会遇到几个基准测试问题,例如:对于你的操作,ListsVectors 快,还是 VectorsLists 快?使用未封装的数组存储原始类型时能节省多少内存?当你做一些性能优化操作,比如预分配数组或使用 while 循环代替 foreach 调用时,究竟有多大影响?var l: List 还是 val b: mutable.Buffer?内存使用情况可以通过不同的 Scala 基准代码进行估算,例如,参考 github.com/lihaoyi/scala-bench

表 6 显示了各种不可变集合的估算大小(字节数),包括 0 元素、1 元素、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
Queue 40 80 200 680 2,600 10,280 41,000 162,800 647,720 2,621,480 10,485,800 41,943,080
String 40 48 48 72 168 552 2,088 8,184 32,424 131,112 524,328 2,097,192

表 6: 各种集合的估计大小(字节)

以下表格显示了 Scala 中数组的估计大小(字节),包括 0 元素、1 元素、4 元素以及四的幂,直到 1,048,576 元素。尽管大多数情况是确定的,但这些值可能会根据平台的不同而变化:

大小 0 1 4 16 64 256 1,024 4,069 16,192 65,536 262,144 1,048,576
Array[Object] 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
Array[Boolean] 16 24 24 32 80 272 1,040 4,088 16,208 65,552 262,160 1,048,592
Array[Byte] 16 24 24 32 80 272 1,040 4,088 16,208 65,552 262,160 1,048,592
Array[Short] 16 24 24 48 144 528 2,064 8,160 32,400 131,088 524,304 2,097,168
Array[Int] 16 24 32 80 272 1,040 4,112 16,296 64,784 262,160 1,048,592 4,194,320
Array[Long] 16 24 48 144 528 2,064 8,208 32,568 129,552 524,304 2,097,168 8,388,624
Boxed Array[Boolean] 16 40 64 112 304 1,072 4,144 16,328 64,816 262,192 1,048,624 4,194,352
Boxed Array[Byte] 16 40 96 336 1,296 5,136 8,208 20,392 68,880 266,256 1,052,688 4,198,416
Boxed Array[Short] 16 40 96 336 1,296 5,136 20,496 81,400 323,856 1,310,736 5,230,608 20,910,096
Boxed 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
Boxed 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 都有可迭代(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,在尝试修改其元素时会抛出 Exception。以下是一个示例来演示这一点:

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 隐式

我们在前面的章节中已经讨论了隐式(implicits),但这里我们将看到更多的示例。隐式参数与默认参数非常相似,但它们使用不同的机制来找到默认值。

隐式参数是传递给构造函数或方法的,并标记为隐式,这意味着如果你没有为该参数提供值,编译器将在作用域内搜索隐式值。例如:

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 对象。这通常发生在用户不关心这些参数的情况下。

一个限制是,每个方法中只能有一个隐式关键字,并且它必须放在参数列表的开头。以下是一些无效的示例:

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。但是,我们的分析依赖于获取正确的地址字段;否则,上述两个地址将被视为不同,导致我们的分析准确性降低。

分析过程始于根据分析师可能需要的数据仓库中的数据进行数据收集,收集组织中各种数据(如销售、市场营销、员工、薪资、HR 等)。数据管理员和治理团队在这里非常重要,确保收集到正确的数据,并且任何被认为是机密或私密的信息不会被意外导出,即使最终用户都是员工。

社会保障号码或完整地址可能不适合包含在分析中,因为这可能会给组织带来许多问题。

必须建立数据质量流程,以确保收集和处理的数据是正确的,并且能够满足数据科学家的需求。在这一阶段,主要目标是发现和解决可能影响分析需求准确性的数据质量问题。常用的技术包括数据概况分析和数据清洗,以确保数据集中的信息一致,并删除任何错误和重复记录。

来自不同源系统的数据可能需要使用各种数据工程技术进行合并、转换和规范化,例如分布式计算或 MapReduce 编程、流处理或 SQL 查询,然后将数据存储在 Amazon S3、Hadoop 集群、NAS 或 SAN 存储设备上,或者传统的数据仓库,如 Teradata。数据准备或工程工作涉及使用技术来操作和组织数据,以满足计划中的分析需求。

一旦我们准备好数据并检查数据质量,并且数据可供数据科学家或分析师使用,实际的分析工作就开始了。数据科学家可以使用预测建模工具和语言(如 SAS、Python、R、Scala、Spark、H2O 等)构建分析模型。该模型最初会在部分数据集上运行,以测试其在训练阶段中的准确性。训练阶段通常会进行多次迭代,这是任何分析项目中都很常见的。经过模型层面的调整,或者有时需要回到数据管理员处获取或修复正在收集或准备的一些数据,模型的输出会越来越好。最终,当进一步调整不再显著改变结果时,就达到了稳定状态;此时,我们可以认为该模型已准备好投入生产使用。

现在,模型可以在生产模式下针对完整数据集进行运行,并根据我们训练模型的方式生成结果或输出。无论是统计分析还是机器学习,构建分析时做出的选择直接影响模型的质量和目的。你无法仅凭杂货销售数据判断亚洲人是否比墨西哥人买更多牛奶,因为这需要额外的、来自人口统计数据的元素。同样,如果我们的分析重点是客户体验(产品的退货或换货),那么它基于的技术和模型与我们试图关注收入或向客户推销的模型是不同的。

你将在后续章节中看到各种机器学习技术。

分析应用可以通过多个学科、团队和技能组合来实现。分析应用可以用于生成报告,也可以自动触发业务动作。例如,你可以简单地创建每日销售报告,并在每天早上 8 点通过电子邮件发送给所有经理。但你也可以与业务流程管理应用程序或一些定制的股票交易应用程序集成,执行一些操作,如买入、卖出或对股市活动进行提醒。你还可以考虑引入新闻文章或社交媒体信息,以进一步影响决策的制定。

数据可视化是数据分析中的一个重要部分,当你面对大量的指标和计算时,理解数字变得非常困难。相反,越来越多地依赖于商业智能BI)工具,如 Tableau、QlikView 等,来探索和分析数据。当然,大规模的可视化,例如显示全国所有 Uber 车辆或显示纽约市水供应的热力图,需要构建更多的定制应用或专门的工具。

在各行各业,不同规模的组织一直面临着数据管理和分析的挑战。企业一直在努力寻找一种务实的方法来捕捉有关客户、产品和服务的信息。当公司只有少数几个客户并且他们只购买几种商品时,这并不难。这不是一个大挑战。但随着时间的推移,市场中的公司开始增长,情况变得更加复杂。现在,我们有品牌信息和社交媒体,有通过互联网买卖的商品。我们需要提出不同的解决方案。网站开发、组织、定价、社交网络和细分市场;我们正在处理许多不同的数据,这使得在处理、管理、组织数据以及试图从数据中获得见解时变得更加复杂。

大数据简介

如前一节所述,数据分析结合了技术、工具和方法,以探索和分析数据,从而为企业提供可量化的成果。结果可能是简单地选择一种颜色来粉刷店面,或者是更复杂的客户行为预测。随着企业的增长,越来越多种类的分析方法开始出现在视野中。在 1980 年代或 1990 年代,我们能得到的只是 SQL 数据仓库中可用的数据;而如今,许多外部因素都在发挥重要作用,影响着企业的运营方式。

Twitter、Facebook、Amazon、Verizon、Macy's 和 Whole Foods 等公司都在利用数据分析运营业务,并基于此做出许多决策。想一想他们正在收集什么样的数据,收集了多少数据,以及他们可能如何使用这些数据。

让我们看一下之前提到的杂货店例子。如果商店开始扩展业务,开设数百家分店,显然,销售交易的数据将需要在比单个商店大 100 倍的规模上进行收集和存储。但此时,任何企业都不再是独立运作的。外部有大量的信息,包括本地新闻、推特、Yelp 评论、客户投诉、调查活动、其他商店的竞争、人口变化以及当地经济状况等等。所有这些额外的数据都能帮助更好地理解客户行为和收入模型。

例如,如果我们发现关于商店停车设施的负面情绪在增加,那么我们可以分析这种情况并采取纠正措施,比如验证停车位,或者与城市公共交通部门谈判,提供更频繁的列车或公交服务,以提高到达的便利性。

这种数量庞大且多样化的数据,虽然提供了更好的分析能力,但也对企业 IT 组织提出了挑战,要求它们存储、处理和分析所有数据。事实上,看到 TB 级别的数据并不罕见。

每天,我们都会创造超过 2 亿亿字节的数据(2 Exa 字节),而且估计超过 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

自 1990 年代以来,大量数据的出现以及对这些数据的理解和利用需求,催生了大数据这一术语。

大数据这一术语,跨越了计算机科学和统计学/计量经济学领域,可能源于 1990 年代中期硅图形公司(Silicon Graphics)午餐桌上的讨论,其中约翰·马谢(John Mashey)是重要人物。

2001 年,Doug Laney,当时是咨询公司 Meta Group Inc(后被 Gartner 收购)的分析师,提出了 3Vs(多样性、速度和体积)的概念。现在,我们使用 4Vs 而不是 3Vs,增加了数据的真实性(Veracity)这一项。

大数据的 4 个 V

以下是大数据的 4 个 V,用于描述大数据的特性。

数据种类

数据可以来自天气传感器、汽车传感器、人口普查数据、Facebook 更新、推文、交易、销售和营销。数据格式既有结构化数据也有非结构化数据,数据类型也各不相同;二进制、文本、JSON 和 XML。

数据的速度

数据可以来自数据仓库、批量模式文件存档、近实时更新或您刚刚预订的 Uber 行程的即时实时更新。

数据量

数据可以被收集和存储一个小时、一整天、一整月、一整年,甚至长达 10 年。许多公司的数据量正在增长,达到数百 TB。

数据的真实性

数据可以被分析以获得可操作的洞察,但由于来自不同数据源的大量各种类型的数据被分析,确保数据的正确性和准确性证明非常困难。

以下是大数据的 4 个 V:

为了理解所有这些数据并将数据分析应用于大数据,我们需要扩展数据分析的概念,以更大规模地操作,处理大数据的 4 个 V。这不仅改变了分析数据时使用的工具、技术和方法,还改变了我们处理问题的方式。如果 1999 年某个企业使用 SQL 数据库来处理数据,那么现在处理同一企业的数据,我们需要一个可扩展且能够适应大数据领域细微差别的分布式 SQL 数据库。

大数据分析应用通常包括来自内部系统和外部来源的数据,例如天气数据或由第三方信息服务提供商编制的消费者人口统计数据。此外,随着用户希望对通过 Spark 的 Spark Streaming 模块或其他开源流处理引擎(如 Flink 和 Storm)将数据传入 Hadoop 系统进行实时分析,流分析应用在大数据环境中变得越来越普遍。

早期的大数据系统主要部署在本地,特别是在收集、组织和分析海量数据的大型组织中。然而,云平台供应商,如亚马逊 Web 服务AWS)和微软,已经使得在云中设置和管理 Hadoop 集群变得更加容易,像 Cloudera 和 Hortonworks 这样的 Hadoop 供应商也支持其大数据框架的分发版本在 AWS 和 Microsoft 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(PetaBytes)级别的数据成为可能。

1 ExaByte = 1024 PetaBytes (5000 万部蓝光电影)

1 PetaByte = 1024 Tera Bytes (50,000 部蓝光电影)

1 TeraByte = 1024 Giga Bytes (50 部蓝光电影)

1 部蓝光电影的平均光盘大小约为 20 GB

现在,分布式计算的范式并不是一个真正的新话题,几十年来,它在研究机构以及一些商业公司中以某种形式得到追求。大规模并行处理MPP)是几十年前在多个领域(如海洋学、地震监测和太空探索)使用的一种范式。一些公司,如 Teradata,也实施了 MPP 平台并提供了商业产品和应用程序。最终,像 Google 和 Amazon 等科技公司推动了可扩展分布式计算的细分领域,进入了一个新的进化阶段,这最终导致了伯克利大学创建了 Apache Spark。

Google 发布了关于 Map ReduceMR)和 Google 文件系统GFS)的论文,将分布式计算的原理传递给了每个人。当然,也需要给予 Doug Cutting 应有的荣誉,他通过实现 Google 白皮书中的概念,并向世界介绍了 Hadoop,使这一切成为可能。

Apache Hadoop 框架是一个用 Java 编写的开源软件框架。该框架提供的两个主要功能是存储和处理。在存储方面,Apache Hadoop 框架使用 Hadoop 分布式文件系统HDFS),该文件系统基于 2003 年 10 月发布的 Google 文件系统论文。在处理或计算方面,框架依赖于 MapReduce,该框架基于 2004 年 12 月发布的 Google 关于 MR 的论文。

MapReduce 框架从 V1(基于作业跟踪器和任务跟踪器)发展到 V2(基于 YARN)。

Hadoop 分布式文件系统(HDFS)

HDFS 是一个基于软件的文件系统,使用 Java 实现,运行在本地文件系统之上。HDFS 的主要概念是将文件分割成块(通常为 128 MB),而不是将文件视为整体处理。这使得许多功能成为可能,如分布式存储、数据复制、故障恢复,以及更重要的,使用多台机器对这些块进行分布式处理。

块大小可以是 64 MB、128 MB、256 MB 或 512 MB,根据需求选择。对于一个 1 GB 的文件,使用 128 MB 的块,计算方式为 1024 MB / 128 MB = 8 块。如果考虑复制因子为 3,则总共有 24 块。

HDFS 提供了一个具有容错性和故障恢复功能的分布式存储系统。HDFS 主要有两个组件:名称节点和数据节点。名称节点包含文件系统所有内容的所有元数据。数据节点与名称节点连接,并依赖名称节点获取有关文件系统内容的所有元数据。如果名称节点不知道任何信息,数据节点将无法为任何需要读写 HDFS 的客户端提供服务。

以下是 HDFS 架构:

NameNode 和 DataNode 是 JVM 进程,因此任何支持 Java 的机器都可以运行 NameNode 或 DataNode 进程。只有一个 NameNode(如果计算 HA 部署,第二个 NameNode 也会存在),但是有数百甚至上千个 DataNode。

不建议拥有上千个 DataNode,因为所有 DataNode 的操作会在实际生产环境中倾向于压倒 NameNode,尤其是在有大量数据密集型应用的情况下。

集群中只有一个 NameNode,这极大简化了系统的架构。NameNode 是所有 HDFS 元数据的仲裁者和存储库,任何想要读写数据的客户端都必须首先联系 NameNode 以获取元数据信息。数据不会直接通过 NameNode 流动,这使得 1 个 NameNode 能够管理数百个 DataNode(PB 级数据)。

HDFS 支持传统的层次化文件组织结构,具有类似于大多数其他文件系统的目录和文件。您可以创建、移动和删除文件和目录。NameNode 维护文件系统的命名空间,并记录所有更改和文件系统的状态。应用程序可以指定 HDFS 应该维护的文件副本数量,这些信息也由 NameNode 存储。

HDFS 旨在以分布式的方式可靠地存储非常大的文件,这些文件分布在大型数据节点集群中的多台机器上。为了应对复制、容错以及分布式计算,HDFS 将每个文件存储为一系列块。

NameNode 做出所有有关块复制的决策。这主要依赖于来自集群中每个 DataNode 的块报告,块报告会定期在心跳间隔期间发送。块报告包含 DataNode 上所有块的列表,NameNode 随后将其存储在元数据存储库中。

NameNode 将所有元数据存储在内存中,并处理所有来自客户端的读写请求。然而,由于这是维护所有 HDFS 元数据的主节点,因此保持一致且可靠的元数据是至关重要的。如果这些信息丢失,HDFS 上的内容将无法访问。

为此,HDFS NameNode 使用一个称为 EditLog 的事务日志,它会持久记录文件系统元数据发生的每一个更改。创建新文件时会更新 EditLog,移动文件、重命名文件或删除文件时也是如此。整个文件系统命名空间,包括块到文件的映射以及文件系统属性,都存储在一个名为 FsImage 的文件中。NameNode 也将所有内容存储在内存中。当 NameNode 启动时,它会加载 EditLog,并且 FsImage 会初始化自身以设置 HDFS。

然而,数据节点并不了解 HDFS,它们仅依赖于存储的数据块。数据节点完全依赖于 NameNode 执行任何操作。即使客户端要连接以读取或写入文件,也是 NameNode 告诉客户端应该连接到哪里。

HDFS 高可用性

HDFS 是主从集群,NameNode 作为主节点,而数百甚至数千个 DataNode 作为从节点,由主节点管理。这在集群中引入了单点故障SPOF)的问题,如果主 NameNode 由于某种原因出现故障,整个集群将无法使用。HDFS 1.0 支持额外的主节点称为辅助 NameNode,用于帮助集群的恢复。它通过维护文件系统所有元数据的副本来实现,但不是一个高度可用的系统,需要手动干预和维护工作。HDFS 2.0 通过添加全面支持高可用性HA)将其提升到了一个新的水平。

HA 的工作方式是使用两个 NameNode,以主备模式运行,其中一个是活动的,另一个是待机的。当主 NameNode 发生故障时,备用 NameNode 将接管主节点的角色。

下图展示了主备 NameNode 的部署方式:

HDFS 联邦

HDFS 联邦是使用多个 NameNode 来扩展文件系统命名空间的一种方式。与第一个 HDFS 版本不同,后者仅使用单个 NameNode 管理整个集群,随着集群规模的增长,这种管理方式无法很好地扩展。HDFS 联邦可以支持规模显著更大的集群,并通过多个联合的 NameNode 水平扩展 NameNode 或名称服务。请看下图:

HDFS 快照

Hadoop 2.0 还增加了一项新功能:使用快照(只读副本和写时复制)拍摄数据节点上存储的文件系统(数据块)。使用快照,您可以在不干扰其他常规 HDFS 操作的情况下无缝地拍摄目录,利用 NameNode 的数据块元数据。快照创建是即时的。

下面是关于如何在特定目录上工作的快照工作示例:

HDFS 读取

客户端连接到 NameNode,并根据文件名询问文件的位置。NameNode 查找文件的块位置并返回给客户端。然后客户端可以连接到数据节点并读取所需的块。NameNode 不参与数据传输。

下面是客户端读取请求的流程。首先,客户端获取位置信息,然后从数据节点拉取数据块。如果某个数据节点在中途失败,客户端则从另一个数据节点获取该块的副本。

HDFS 写入

客户端连接到 NameNode,并请求 NameNode 允许其写入 HDFS。NameNode 查找信息并规划使用哪些块、哪些 DataNode 来存储这些块,以及使用什么复制策略。NameNode 不处理任何数据,它只是告诉客户端该写到哪里。一旦第一个 DataNode 接收到块,基于复制策略,NameNode 会告诉第一个 DataNode 在哪里进行复制。因此,客户端接收到的块会发送到第二个 DataNode(复制块应该写入的位置),然后第二个 DataNode 会将其发送到第三个 DataNode(如果复制因子为 3 的话)。

以下是一个客户端写请求的流程。首先,客户端获取位置,然后写入第一个 DataNode。接收块的 DataNode 会将块复制到应该存储块副本的其他 DataNode。这一过程适用于从客户端写入的所有块。如果在中途某个 DataNode 发生故障,块会按照 NameNode 的指示被复制到另一个 DataNode。

到目前为止,我们已经看到了 HDFS 如何通过使用块、NameNode 和 DataNode 提供分布式文件系统。一旦数据达到 PB 级别存储,实际上处理数据也变得非常重要,以服务于业务的各种用例。

MapReduce 框架是在 Hadoop 框架中创建的,用于执行分布式计算。我们将在下一节进一步探讨这一点。

MapReduce 框架

MapReduceMR)框架使你能够编写分布式应用程序,以可靠且容错的方式处理来自像 HDFS 这样的文件系统的大量数据。当你想使用 MapReduce 框架处理数据时,它通过创建一个作业来运行,这个作业在框架上执行所需的任务。

MapReduce 作业通常通过将输入数据分割到运行Mapper任务的工作节点上以并行方式工作。在此过程中,HDFS 级别的故障或 Mapper 任务的失败都会被自动处理,从而实现容错。一旦 Mapper 完成,结果将通过网络复制到其他运行Reducer任务的机器上。

理解这个概念的一个简单方法是,假设你和你的朋友们要把一堆水果分类到箱子里。为此,你希望把每个人分配一个任务,让他们处理一篮原料水果(全部混在一起),并将水果分开放入不同的箱子。每个人然后都按同样的方法处理这篮水果。

最终,你会得到来自所有朋友的一大堆水果箱。然后,你可以指派一组人把相同种类的水果放在同一个箱子里,称重并封箱以便运输。

下图描述了通过不同种类的水果来分类水果篮子的概念:

MapReduce 框架由一个资源管理器和多个节点管理器组成(通常节点管理器与 HDFS 的数据节点共存)。当应用程序需要运行时,客户端启动应用程序主控器,然后与资源管理器协商,在集群中以容器的形式获取资源。

容器代表了分配给单个节点上的 CPU(核心)和内存,用于运行任务和进程。容器由节点管理器监督,并由资源管理器调度。

容器示例:

1 个核心 + 4 GB 内存

2 个核心 + 6 GB 内存

4 个核心 + 20 GB 内存

一些容器被分配为 Mappers,另一些容器被分配为 Reducers;这一切都由应用程序主控器与资源管理器共同协调。这个框架叫做另一个资源协商器YARN

以下是 YARN 的示意图:

一个经典的例子,展示了 MapReduce 框架的工作原理,就是词频统计示例。以下是处理输入数据的各个阶段,首先将输入数据拆分到多个工作节点上,最后生成单词的输出计数:

尽管 MapReduce 框架在全球范围内非常成功,并且被大多数公司采用,但由于其数据处理方式,它会遇到一些问题。为了让 MapReduce 更易于使用,出现了多种技术,如 Hive 和 Pig,但复杂性依然存在。

Hadoop MapReduce 有多个限制,诸如:

  • 由于基于磁盘的处理导致性能瓶颈

  • 批处理无法满足所有需求

  • 编程可能冗长且复杂

  • 由于资源重复使用较少,任务调度较慢

  • 没有很好的方式进行实时事件处理

  • 机器学习通常需要较长时间,因为机器学习通常涉及迭代处理,而 MapReduce 处理速度太慢,无法满足这一需求。

Hive 是由 Facebook 创建的,作为 MapReduce 的 SQL 类似接口。Pig 是由 Yahoo 创建的,作为 MapReduce 的脚本接口。此外,还使用了多个增强技术,如 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(支持不同类型集群计算系统的集群管理框架)的项目。请看以下表格:

版本 发布日期 里程碑
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 来读取结构化和半结构化数据源
1.4 2015-07-15 SparkR、DataFrame API、Tungsten 改进
1.5 2015-11-09 各种更改的版本发布
1.6 2016-11-07 引入了 Dataset DSL
2.0 2016-11-14 DataFrames 和 Datasets API 作为机器学习的基础层,结构化流、SparkR 改进。
2.1 2017-05-02 事件时间水印、ML、GraphX 改进

2.2 版本已于 2017-07-11 发布,包含了若干改进,尤其是结构化流处理(Structured Streaming)现在已进入 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,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 引入,现在在许多使用场景中已成为比 RDD 更为常见的选择。尽管 RDD 在处理完全非结构化数据时仍然更具灵活性,但在未来,数据集 API 可能最终会成为核心 API。

Spark SQL

Spark SQL 是 Spark 核心之上的一个组件,引入了一种新的数据抽象——SchemaRDD,它为结构化和半结构化数据提供支持。Spark SQL 提供了使用 Spark 和 Hive QL 支持的 SQL 子集操作大型分布式结构化数据的功能。Spark SQL 通过数据帧和数据集简化了结构化数据的处理,且性能远超以往,是 Tungsten 计划的一部分。Spark SQL 还支持从各种结构化格式和数据源中读取和写入数据,如文件、parquet、orc、关系型数据库、Hive、HDFS、S3 等。Spark SQL 提供了一个查询优化框架——Catalyst,用于优化所有操作,以提高速度(与 RDDs 相比,Spark SQL 的速度快了好几倍)。Spark SQL 还包括一个 Thrift 服务器,外部系统可以通过 Spark SQL 使用经典的 JDBC 和 ODBC 协议查询数据。

我们在第八章,引入一点结构 - Spark SQL中详细介绍了 Spark SQL。

Spark 流处理

Spark 流处理利用 Spark 核心的快速调度能力,通过从 HDFS、Kafka、Flume、Twitter、ZeroMQ、Kinesis 等各种数据源摄取实时流数据来执行流处理分析。Spark 流处理使用数据的微批处理方式进行数据分块处理,并使用一个称为 DStreams 的概念,Spark 流处理可以像 Spark 核心 API 中的常规 RDD 一样对 RDD 进行转换和操作。Spark 流处理操作可以使用多种技术自动从故障中恢复。Spark 流处理可以与其他 Spark 组件结合,在单个程序中统一实时处理、机器学习、SQL 和图形操作。

我们在第九章中详细介绍了 Spark 流处理,Stream Me Up, Scotty - Spark Streaming

此外,新的结构化流处理 API 使得 Spark 流处理程序更类似于 Spark 批处理程序,同时也允许在流数据上进行实时查询,这在 Spark 2.0+之前的 Spark 流处理库中是非常复杂的。

Spark GraphX

GraphX 是一个基于 Spark 的分布式图处理框架。图是由顶点和连接它们的边组成的数据结构。GraphX 提供了构建图的功能,图表示为 Graph RDD。它提供了一个 API,用于表达图计算,能够通过使用 Pregel 抽象 API 来建模用户定义的图。它还为该抽象提供了优化的运行时。GraphX 还包含图论中最重要算法的实现,如 PageRank、连通组件、最短路径、SVD++等。

我们在第十章中详细介绍了 Spark GraphX,Everything is Connected - GraphX

一个新的模块 GraphFrames 正在开发中,它使得使用基于 DataFrame 的图形更容易进行图处理。GraphX 对于 RDD 就像 GraphFrames 对于 DataFrame/数据集一样。此外,目前 GraphFrames 与 GraphX 是分开的,预计将来会支持 GraphX 的所有功能,届时可能会切换到 GraphFrames。

Spark ML

MLlib 是一个分布式机器学习框架,位于 Spark 核心之上,处理用于转换 RDD 格式数据集的机器学习模型。Spark MLlib 是一个机器学习算法库,提供各种算法,如逻辑回归、朴素贝叶斯分类、支持向量机SVMs)、决策树、随机森林、线性回归、交替最小二乘法ALS)和 K-means 聚类。Spark ML 与 Spark 核心、Spark 流处理、Spark SQL 和 GraphX 紧密集成,提供一个真正集成的平台,可以处理实时或批处理数据。

我们在第十一章中详细介绍了 Spark ML,Learning Machine Learning - Spark MLlib and 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 Core、Spark SQL、Spark Streaming、Spark GraphX 和 Spark ML,以及 PySpark 和 SparkR,它们是将 Python 和 R 语言代码与 Apache Spark 集成的手段。

现在我们已经了解了大数据分析、Hadoop 分布式计算平台的空间以及演变过程,还了解了 Apache Spark 的发展,并对 Apache Spark 如何解决一些挑战有了一个高层次的概述,我们已经准备好开始学习 Spark,并了解如何在我们的应用场景中使用它。

在下一章中,我们将更深入地探讨 Apache Spark,并开始了解其内部运作原理,详细内容请参考第六章,开始使用 Spark - REPL 和 RDDs

第六章:开始使用 Spark – REPL 和 RDDs

“所有这些现代技术只会让人们试图同时做所有事情。”

  • 比尔·沃特森(Bill Watterson)

在本章中,您将学习 Spark 的工作原理;然后,您将了解 RDD(弹性分布式数据集),它是 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 组成,Driver 协调跨工作节点集群的执行。Driver 还负责跟踪所有工作节点以及当前正在执行的任务。

让我们更深入地了解一下各个组件。关键组件是 Driver 和执行器,它们都是 JVM 进程(Java 进程):

  • Driver:Driver 程序包含应用程序和主程序。如果您使用的是 Spark shell,那么它将成为 Driver 程序,Driver 会在集群中启动执行器并控制任务的执行。

  • 执行器:接下来是执行器,它们是运行在集群工作节点上的进程。在执行器内部,个别任务或计算会被执行。每个工作节点中可能有一个或多个执行器,并且每个执行器内部可能包含多个任务。当 Driver 连接到集群管理器时,集群管理器会分配资源来运行执行器。

集群管理器可以是独立集群管理器、YARN 或 Mesos。

Cluster Manager 负责在构成集群的计算节点之间调度和分配资源。通常,这由一个管理进程来完成,它了解并管理一个资源集群,并将资源分配给如 Spark 这样的请求进程。我们将在接下来的章节中进一步讨论三种不同的集群管理器:standalone、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 解析代码,并将字节级代码序列化后传递给 executors 执行。当我们执行计算时,计算实际上会在每个节点的本地级别完成,使用内存中的处理。

解析代码和规划执行的过程是由 Driver 进程实现的关键方面。

以下是 Spark Driver 如何在集群中协调计算的过程:

有向无环图 (DAG) 是 Spark 框架的秘密武器。Driver 进程为你尝试运行的代码片段创建一个 DAG,然后,DAG 会通过任务调度器分阶段执行,每个阶段通过与 Cluster Manager 通信来请求资源以运行 executors。DAG 代表一个作业,一个作业被拆分为子集,也叫阶段,每个阶段以任务的形式执行,每个任务使用一个核心。

一个简单作业的示意图以及 DAG 如何被拆分成阶段和任务的过程如下图所示;第一张图展示了作业本身,第二张图展示了作业中的阶段和任务:

以下图表将作业/DAG 分解为阶段和任务:

阶段的数量以及阶段的组成由操作的类型决定。通常,任何转换操作都会与之前的操作属于同一个阶段,但每个像 reduce 或 shuffle 这样的操作都会创建一个新的执行阶段。任务是阶段的一部分,直接与执行器上执行操作的核心相关。

如果你使用 YARN 或 Mesos 作为集群管理器,当需要处理更多工作时,可以使用动态 YARN 调度程序来增加执行器的数量,并且可以终止空闲的执行器。

因此,驱动程序管理整个执行过程的容错性。一旦驱动程序完成作业,输出可以写入文件、数据库或直接输出到控制台。

请记住,驱动程序中的代码本身必须是完全可序列化的,包括所有变量和对象。

经常看到的例外是不可序列化的异常,这是由于从块外部包含全局变量所导致的。

因此,驱动程序进程负责整个执行过程,同时监控和管理所使用的资源,如执行器、阶段和任务,确保一切按计划运行,并在发生任务失败或整个执行器节点失败等故障时进行恢复。

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 与集群交互。在这两种情况下,驱动程序与 Spark 主节点通信以获取工作节点,在那里可以为此应用启动执行器。

多个客户端与集群交互时,会在工作节点上创建自己的执行器。此外,每个客户端都会有自己的驱动程序组件。

以下是使用主节点和工作节点的 Spark 独立部署:

现在我们来下载并安装 Spark,以独立模式在 Linux/Mac 上运行:

  1. 从链接下载 Apache Spark:spark.apache.org/downloads.html

  1. 将包解压到本地目录中:
 tar -xvzf spark-2.2.0-bin-hadoop2.7.tgz

  1. 切换到新创建的目录:
 cd spark-2.2.0-bin-hadoop2.7

  1. 通过执行以下步骤设置 JAVA_HOMESPARK_HOME 的环境变量:

    1. 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 界面:

  3. 你将看到 Scala/Spark shell,接下来你可以与 Spark 集群进行交互:

 scala>

现在,我们有一个连接到自动设置的本地集群并运行 Spark 的 Spark-shell。这是最快的在本地机器上启动 Spark 的方式。然而,你仍然可以控制 worker/执行器,并且可以连接到任何集群(独立模式/YARN/Mesos)。这就是 Spark 的强大之处,它使你能够从交互式测试快速过渡到集群上的测试,随后将作业部署到大型集群上。无缝的集成带来了很多好处,这是使用 Hadoop 和其他技术无法实现的。

如果你想了解所有的设置,可以参考官方文档:spark.apache.org/docs/latest/

有多种方法可以启动 Spark shell,如下面的代码片段所示。我们将在后面的章节中看到更多选项,并更详细地展示 Spark shell:

  • 本地机器上的默认 shell 自动将本地机器指定为主节点:
 ./bin/spark-shell

  • 本地机器上的默认 shell 指定本地机器为主节点,并使用 n 个线程:
 ./bin/spark-shell --master local[n]

  • 本地机器上的默认 shell 连接到指定的 Spark 主节点:
 ./bin/spark-shell --master spark://<IP>:<Port>

  • 本地机器上的默认 shell 以客户端模式连接到 YARN 集群:
 ./bin/spark-shell --master yarn --deploy-mode client

  • 本地机器上的默认 shell 以集群模式连接到 YARN 集群:
 ./bin/spark-shell --master yarn --deploy-mode cluster

Spark 驱动程序也有一个 Web UI,帮助你了解有关 Spark 集群的所有信息,包括运行的执行器、作业和任务、环境变量以及缓存。当然,最重要的用途是监控作业。

启动本地 Spark 集群的 Web UI,网址为 http://127.0.0.1:4040/jobs/

以下是 Web UI 中的 Jobs 标签页:

以下是显示集群所有执行器的标签页:

Spark 在 YARN 上

在 YARN 模式下,客户端与 YARN 资源管理器通信并获取容器来运行 Spark 执行。你可以将其视为为你部署的一个类似于迷你 Spark 集群的东西。

多个客户端与集群交互时,会在集群节点(节点管理器)上创建它们自己的执行器。同时,每个客户端都会有自己的驱动程序组件。

使用 YARN 运行时,Spark 可以运行在 YARN 客户端模式或 YARN 集群模式下。

YARN 客户端模式

在 YARN 客户端模式下,Driver 运行在集群外的节点上(通常是客户端所在的位置)。Driver 首先联系资源管理器请求资源来运行 Spark 任务。资源管理器分配一个容器(容器零)并回应 Driver。Driver 随后在容器零中启动 Spark 应用程序主节点。Spark 应用程序主节点接着在资源管理器分配的容器中创建执行器。YARN 容器可以位于集群中由节点管理器控制的任何节点上。因此,所有资源分配都由资源管理器管理。

即使是 Spark 应用程序主节点也需要与资源管理器通信,以获取后续容器来启动执行器。

以下是 Spark 的 YARN 客户端模式部署:

YARN 集群模式

在 YARN 集群模式中,Driver 运行在集群内的节点上(通常是应用程序主节点所在的地方)。客户端首先联系资源管理器请求资源来运行 Spark 任务。资源管理器分配一个容器(容器零)并回应客户端。客户端接着将代码提交给集群,并在容器零中启动 Driver 和 Spark 应用程序主节点。Driver 与应用程序主节点和 Spark 应用程序主节点一起运行,然后在资源管理器分配的容器中创建执行器。YARN 容器可以位于集群中由节点管理器控制的任何节点上。因此,所有资源分配都由资源管理器管理。

即使是 Spark 应用程序主节点也需要与资源管理器通信,以获取后续容器来启动执行器。

以下是 Spark 的 YARN 集群模式部署:

YARN 集群模式中没有 Shell 模式,因为 Driver 本身是在 YARN 内部运行的。

Mesos 上的 Spark

Mesos 部署与 Spark 独立模式类似,Driver 与 Mesos Master 进行通信,后者分配执行器所需的资源。如同在独立模式中,Driver 然后与执行器进行通信以运行任务。因此,在 Mesos 部署中,Driver 首先与 Master 通信,然后在所有 Mesos 从节点上获取容器的请求。

当容器分配给 Spark 任务时,Driver 会启动执行器并在执行器中运行代码。当 Spark 任务完成且 Driver 退出时,Mesos Master 会收到通知,所有 Mesos 从节点上的容器形式的资源将被回收。

多个客户端与集群交互,在从节点上创建各自的执行器。此外,每个客户端将有其自己的 Driver 组件。客户端模式和集群模式都可以使用,就像 YARN 模式一样。

以下是基于 Mesos 的 Spark 部署示意图,展示了Driver如何连接到Mesos 主节点,该节点还管理着所有 Mesos 从节点的资源:

RDD 简介

弹性分布式数据集RDD)是一个不可变的、分布式的对象集合。Spark 的 RDD 具有弹性或容错性,这使得 Spark 能够在发生故障时恢复 RDD。不可变性使得 RDD 一旦创建后就是只读的。转换操作允许对 RDD 进行操作,创建新的 RDD,但原始的 RDD 在创建后永远不会被修改。这使得 RDD 免受竞争条件和其他同步问题的影响。

RDD 的分布式特性之所以有效,是因为 RDD 仅包含对数据的引用,而实际的数据则分布在集群中各个节点的分区内。

从概念上讲,RDD 是一个分布式的数据集合,分布在集群的多个节点上。为了更好地理解 RDD,我们可以把它看作是一个跨机器分布的大型整数数组。

RDD 实际上是一个跨集群分区的数据集,这些分区的数据可以来自HDFSHadoop 分布式文件系统)、HBase 表、Cassandra 表、Amazon S3 等。

在内部,每个 RDD 具有五个主要属性:

  • 分区列表

  • 计算每个分区的函数

  • 其他 RDD 的依赖关系列表

  • 可选地,键值型 RDD 的分区器(例如,声明 RDD 是哈希分区的)

  • 可选地,计算每个分区时的首选位置列表(例如,HDFS 文件的块位置)

请看以下图示:

在程序中,驱动程序将 RDD 对象视为对分布式数据的句柄。它类似于指向数据的指针,而不是直接使用实际数据,当需要时通过它来访问实际数据。

默认情况下,RDD 使用哈希分区器将数据分配到集群中。分区的数量与集群中节点的数量无关。可能出现的情况是,集群中的单个节点拥有多个数据分区。数据分区的数量完全取决于集群中节点的数量以及数据的大小。如果查看任务在节点上的执行情况,运行在工作节点上执行器上的任务可能处理的数据既可以是同一本地节点上的数据,也可以是远程节点上的数据。这就是数据的本地性,执行任务会选择最本地的数据。

本地性会显著影响作业的性能。默认的本地性偏好顺序如下所示:

PROCESS_LOCAL > NODE_LOCAL > NO_PREF > RACK_LOCAL > ANY

无法保证每个节点会得到多少个分区。这会影响每个执行器的处理效率,因为如果一个节点上有太多的分区来处理多个分区,那么处理所有分区所花费的时间也会增加,导致执行器的核心负载过重,从而拖慢整个处理阶段,进而减慢整个作业的速度。实际上,分区是提高 Spark 作业性能的主要调优因素之一。请参考以下命令:

class RDD[T: ClassTag]

让我们进一步了解在加载数据时 RDD 的表现。以下是一个示例,展示了 Spark 如何使用不同的工作节点加载数据的不同分区或切片:

无论 RDD 是如何创建的,初始的 RDD 通常称为基础 RDD,随后通过各种操作创建的任何 RDD 都是该 RDD 的血统的一部分。记住这一点非常重要,因为容错和恢复的秘密在于驱动程序维护了 RDD 的血统,并能够执行这些血统以恢复丢失的 RDD 块。

以下是一个示例,展示了多个 RDD 是如何作为操作结果被创建的。我们从基础 RDD开始,它包含 24 个元素,然后派生出另一个 RDD carsRDD,它只包含匹配“汽车”这一项的元素(3):

在这种操作过程中,分区的数量不会发生变化,因为每个执行器会在内存中应用过滤转换,从而生成与原始 RDD 分区对应的新 RDD 分区。

接下来,我们将了解如何创建 RDD。

RDD 创建

RDD 是 Apache Spark 中使用的基本对象。它们是不可变的集合,代表数据集,并具有内置的可靠性和故障恢复能力。由于其特性,RDD 在任何操作(如转换或动作)后都会创建新的 RDD。RDD 还会存储血统信息,用于从故障中恢复。在上一章中,我们也看到了一些关于如何创建 RDD 以及可以应用于 RDD 的操作的细节。

可以通过几种方式创建 RDD:

  • 并行化一个集合

  • 从外部源读取数据

  • 转换一个现有的 RDD

  • 流式 API

并行化一个集合

并行化一个集合可以通过在驱动程序中调用 parallelize() 来实现。驱动程序在尝试并行化一个集合时,会将集合拆分为多个分区,并将这些数据分区分发到集群中。

以下是一个使用 SparkContext 和 parallelize() 函数从数字序列创建 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,那么 HDFS 中的分布式块将被 Spark 集群中的各个节点读取。

Spark 集群中的每个节点本质上都在执行自己的输入输出操作,每个节点独立地从 HDFS 块中读取一个或多个块。通常,Spark 会尽最大努力将尽可能多的 RDD 放入内存中。它具有通过启用 Spark 集群中的节点避免重复读取操作(例如从可能与 Spark 集群远程的 HDFS 块中读取)来减少输入输出操作的能力,称为缓存。在 Spark 程序中有许多缓存策略可供使用,我们将在后续的缓存章节中讨论。

以下是通过 Spark Context 和 textFile() 函数从文本文件加载的文本行的 RDD。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。Filter 是转换的一个典型示例。

以下是一个简单的整数 rdd,并通过将每个整数乘以 2 进行转换。我们再次使用 SparkContext 和 parallelize 函数,将整数序列分发到各个分区形式的 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.

你还可以将 Spark 代码作为可执行的 Java jar 提交,这样作业就会在集群中执行。通常,只有当你使用 shell 达到一个可行的解决方案时,才会这样做。

使用 ./bin/spark-submit 提交 Spark 作业到集群(本地、YARN 和 Mesos)。

以下是 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)

在下一节中,我们将深入探讨操作。

操作与转换

RDD 是不可变的,每个操作都会创建一个新的 RDD。现在,你可以在 RDD 上执行的两种主要操作是 转换操作

转换改变 RDD 中的元素,比如拆分输入元素、过滤掉某些元素,或者进行某种计算。可以按顺序执行多个转换;然而在规划阶段,执行并不会发生。

对于转换,Spark 将它们添加到计算的 DAG 中,只有当 driver 请求某些数据时,这个 DAG 才会被执行。这被称为 懒惰 评估。

懒惰评估的原理在于,Spark 可以查看所有的转换并计划执行,利用 Driver 对所有操作的理解。例如,如果一个过滤转换在其他转换后立即应用,Spark 会优化执行,使得每个 Executor 在每个数据分区上高效地执行这些转换。现在,只有当 Spark 等待执行某些操作时,这种优化才有可能。

操作是实际触发计算的操作。直到遇到一个操作,Spark 程序中的执行计划才会以 DAG 形式创建并保持不变。显然,执行计划中可能包含各种各样的转换操作,但在执行操作之前,什么也不会发生。

以下是对一些任意数据进行的各种操作的示意图,我们的目标只是移除所有的笔和自行车,只保留并统计汽车 每个 print 语句都是一个操作,它触发 DAG 执行计划中所有转换步骤的执行,直到该点为止,如下图所示:

例如,针对有向无环图(DAG)的变换执行count操作时,会触发从基本 RDD 到所有变换的执行。如果执行了另一个操作,那么就会有一条新的执行链发生。这就是为什么在有向无环图的不同阶段进行缓存会大大加速程序下一次执行的原因。执行优化的另一种方式是重用上次执行中的 shuffle 文件。

另一个例子是collect操作,它会将所有节点的数据收集或拉取到驱动程序。你可以在调用collect时使用部分函数,选择性地拉取数据。

变换

变换通过将变换逻辑应用于现有 RDD 中的每个元素,创建一个新的 RDD。某些变换函数涉及拆分元素、过滤元素以及执行某种计算。多个变换可以按顺序执行。然而,在计划阶段不会发生实际执行。

变换可以分为四类,如下所示。

常见变换

常见变换是处理大多数通用用途的变换函数,它将变换逻辑应用于现有的 RDD,并生成一个新的 RDD。聚合、过滤等常见操作都被称为常见变换。

常见变换函数的示例包括:

  • map

  • filter

  • flatMap

  • groupByKey

  • sortByKey

  • combineByKey

数学/统计变换

数学或统计变换是处理一些统计功能的变换函数,通常会对现有的 RDD 应用某些数学或统计操作,生成一个新的 RDD。抽样就是一个很好的例子,在 Spark 程序中经常使用。

这些变换的示例包括:

  • sampleByKey

  • `randomSplit`

集合理论/关系变换

集合理论/关系变换是处理数据集连接(Join)以及其他关系代数功能(如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) 从数据中按给定比例fraction抽取样本,支持有放回或无放回抽样,使用给定的随机数生成种子。
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 类似,reduce 任务的数量可以通过可选的第二个参数进行配置。
sortByKey([ascending], [numTasks]) 当在 (K, V) 对数据集上调用时,其中 K 实现了排序,返回一个按照键升序或降序排序的 (K, V) 对数据集,排序顺序由布尔值 ascending 参数指定。
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 后再进行排序相比,这种方法更高效,因为它可以将排序操作推到 shuffle 机制中。

我们将演示最常见的转换操作:

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 的所有元素上应用 filter 转换。

请注意,应用 filter 时,分区不会改变,并且有些分区可能为空。

coalesce

coalesce对输入分区应用transformation函数,将输入分区合并成输出 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 创建的,本质上通过根据需要合并分区来减少分区数量:

repartition

repartition对输入分区应用transformation函数,以便将输入重新分配到输出 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 创建的,本质上通过根据需要合并/拆分分区来重新分配分区:

操作

Action 触发整个DAG有向无环图)的转换,该转换通过运行代码块和函数来实现。所有操作现在都按照 DAG 的指定进行执行。

有两种操作类型:

  • Driver:一种操作是驱动程序操作,如 collect、count、count by key 等。每个这样的操作都会在远程执行器上执行一些计算,并将数据拉回到驱动程序。

基于驱动程序的操作存在一个问题:对大数据集执行操作时,可能会轻易使驱动程序的内存超负荷,导致应用程序崩溃,因此应谨慎使用涉及驱动程序的操作。

  • 分布式:另一种操作是分布式操作,它在集群的节点上执行。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()对 RDD 中的所有元素应用 reduce 函数,并将结果发送到 Driver。

以下是说明此功能的示例代码。你可以使用SparkContext和 parallelize 函数从一个整数序列创建 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 的所有元素。

Caching

缓存使得 Spark 可以在计算和操作过程中持久化数据。事实上,这也是 Spark 中加速计算的最重要的技术之一,尤其是在处理迭代计算时。

缓存通过尽可能多地存储 RDD 在内存中来工作。如果内存不足,则按 LRU 策略将当前存储的数据驱逐出去。如果要求缓存的数据大于可用内存,则性能将下降,因为将使用磁盘而不是内存。

您可以使用persist()cache()将 RDD 标记为已缓存。

cache()只是persist(MEMORY_ONLY)`的同义词。

persist可以使用内存或磁盘或两者:

persist(newLevel: StorageLevel) 

以下是存储级别的可能值:

存储级别 含义
MEMORY_ONLY 将 RDD 作为反序列化的 Java 对象存储在 JVM 中。如果 RDD 不适合内存,则某些分区将不会被缓存,并且在每次需要时会即时重新计算。这是默认级别。
MEMORY_AND_DISK 将 RDD 作为反序列化的 Java 对象存储在 JVM 中。如果 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

  • load 从 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 不同,因为使用wholeTextFiles()时,文件的完整内容作为单个记录加载:

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 的类型

弹性分布式数据集 (RDDs) 是 Apache Spark 中使用的基本对象。RDD 是不可变的集合,代表数据集,并具有内建的可靠性和故障恢复能力。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 范式的基本概念。

创建 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 元素如何转换为 (key - value) 对:

DoubleRDD

DoubleRDD 是一个由双精度值集合构成的 RDD。由于这一特性,可以对 DoubleRDD 使用许多统计函数。

以下是 DoubleRDD 的示例,其中我们从一组双精度数字创建了一个 RDD:

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 是从 SequenceFile 创建的,SequenceFile 是 Hadoop 文件系统中的一种文件格式。SequenceFile 可以是压缩的或未压缩的。

Map Reduce 过程可以使用 SequenceFiles,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 是一个将其父 RDD 进行 cogroup 操作的 RDD。两个父 RDD 必须是 pairRDD 才能工作,因为 cogroup 操作本质上会生成一个包含共同键和值列表的 pairRDD,值列表来自两个父 RDD。请看以下代码片段:

class CoGroupedRDD[K] extends RDD[(K, Array[Iterable[_]])] 

以下是 CoGroupedRDD 的示例,我们创建了两个 pairRDD 的 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))

下图展示了根据键进行洗牌,将相同键(State)的记录发送到同一分区的过程:

UnionRDD

UnionRDD 是两个 RDD 进行联合操作后的结果。联合操作仅仅是创建一个包含两个 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 提供了读取存储在 HDFS、HBase 表、Amazon S3 中的数据的核心功能,使用的是来自 Hadoop 2.x 的新 MapReduce API。libraries.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 []

让我们看另一个例子,在这个例子中,我们将使用 SparkContext 中的 newAPIHadoopFile 函数:

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,Driver 再将这些子集相加,从而计算 RDD 中的元素总数。

在本节中,我们的主要焦点是聚合函数,这些函数用于通过键收集和合并数据。正如本章前面所看到的,PairRDD 是一个 (key - value) 对的 RDD,其中 key 和 value 是任意的,可以根据具体应用场景进行自定义。

在我们关于州人口的例子中,PairRDD 可以是 <State, <Population, Year>> 的对,这意味着 State 被作为键,<Population, Year> 的元组被作为值。这种将键和值分解的方式可以生成如 按州的人口最多年份 等聚合结果。相反,如果我们的聚合是围绕年份进行的,例如 按年份的人口最多的州,我们可以使用一个 pairRDD,其中包含 <Year, <State, Population>> 的对。

以下是生成 pairRDD 的示例代码,数据来源于 StatePopulation 数据集,既有以 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)))

如前所述,我们还可以使用 Year 作为键,<State, Population> 的元组作为值,生成一个 PairRDD,如以下代码片段所示:

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通过将所有分区的元素发送到基于分区器的分区中,从而将相同键的所有(key-value)对收集到同一个分区中。一旦完成,就可以轻松地进行聚合操作。

下面是调用groupByKey时发生情况的示意图:

reduceByKey

groupByKey涉及大量的数据洗牌,而reduceByKey通过不使用洗牌将所有PairRDD的元素发送,而是使用本地的 combiner 先在本地做一些基本聚合,然后再像groupByKey那样发送结果元素。这样大大减少了数据传输量,因为我们不需要传输所有内容。reduceBykey通过使用结合性和交换性的 reduce 函数合并每个键的值来工作。当然,首先会...

在每个映射器上本地执行合并操作,然后将结果发送到归约器。

如果你熟悉 Hadoop MapReduce,这与 MapReduce 编程中的 combiner 非常相似。

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将所有分区的元素发送到指定的分区,以便将相同键的所有(key-value)对收集到同一个分区。但在洗牌之前,首先会进行本地聚合,减少需要洗牌的数据量。一旦完成,最终分区中就可以轻松地进行聚合操作。

以下图示说明了调用reduceBykey时发生的情况:

aggregateByKey

aggregateByKeyreduceByKey非常相似,唯一不同的是aggregateByKey在分区内和分区之间聚合时提供了更多的灵活性和定制性,允许处理更复杂的用例,例如在一次函数调用中生成所有<Year, Population>对以及每个州的总人口。

aggregateByKey 通过使用给定的合并函数和中立的初始/零值来聚合每个键的值。

该函数可以返回不同的结果类型 U,而不是此 RDD 中值的类型 V,这是最大的区别。因此,我们需要一个操作将 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 的 (key - value) 对都会收集到同一个分区中;然而,聚合的方式以及生成的输出不像 groupByKeyreduceByKey 那样固定,而是使用 aggregateByKey 时更加灵活和可定制的。

以下图示说明了调用 aggregateByKey 时发生的情况。与 groupByKeyreduceByKey 中将计数相加不同,在这里我们为每个 Key 生成值的列表:

combineByKey

combineByKeyaggregateByKey 非常相似;实际上,combineByKey 内部调用了 combineByKeyWithClassTag,而 aggregateByKey 也会调用它。和 aggregateByKey 一样,combineByKey 也是通过在每个分区内应用操作,然后在合并器之间进行操作来工作的。

combineByKeyRDD[K,V] 转换为 RDD[K,C],其中 C 是在键 K 下收集或合并的 V 列表。

调用 combineByKey 时,期望有三个函数。

  • createCombinerV 转换为 C,其中 C 是一个包含一个元素的列表

  • mergeValue 用于将 V 合并为 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 的 (key - value) 对都将收集到同一个分区中,但聚合的方式以及生成的输出不像 groupByKeyreduceByKey 那样固定,而是更加灵活和可定制的。

以下图示说明了调用 combineByKey 时发生的情况:

groupByKeyreduceByKeycombineByKeyaggregateByKey 的比较

让我们考虑一个 StatePopulation RDD 的例子,它生成一个 <State, <Year, Population>>pairRDD

如前一节所见,groupByKey 会通过生成键的哈希码进行 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. 转换为 pair 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. aggregateByKey - 对每个键下的人口进行聚合并加总:

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 Context 的 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 是 Spark 中的默认分区器,通过计算每个 RDD 元素键的哈希值来工作。所有具有相同哈希值的元素将被分配到相同的分区,如以下代码片段所示:

partitionIndex = hashcode(key) % numPartitions

以下是字符串 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,要么来自集群中的核心数。

以下图示说明了哈希分区是如何工作的。我们有一个包含 abe 三个元素的 RDD。通过使用字符串的 hashcode,我们可以根据设置的 6 个分区数量计算每个元素的 partitionIndex

范围分区器

RangePartitioner 通过将 RDD 分成大致相等的区间来工作。由于区间需要知道每个分区的起始和结束键,因此在使用 RangePartitioner 之前,RDD 需要先进行排序。

RangePartitioning首先需要根据 RDD 为分区设定合理的边界,然后创建一个从键 K 到partitionIndex(元素所在分区的索引)之间的函数。最后,我们需要根据RangePartitioner重新分区 RDD,以根据我们确定的范围正确地分配 RDD 元素。

以下是如何使用PairRDDRangePartitioning的示例。我们还可以看到,在使用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

洗牌(Shuffling)

无论使用何种分区器,许多操作都会导致数据在 RDD 的分区之间重新分配。新分区可以被创建,或者多个分区可以被合并。所有用于重新分区所需的数据移动过程都被称为洗牌(Shuffling),这是编写 Spark 作业时需要理解的一个重要概念。洗牌可能会导致很大的性能延迟,因为计算不再保存在同一执行器的内存中,而是执行器通过网络交换数据。

一个好的例子是我们在聚合(Aggregations)部分中看到的groupByKey()示例。显然,为了确保所有相同键的值都收集到同一个执行器上以执行groupBy操作,大量的数据在执行器之间流动。

洗牌(Shuffling)还决定了 Spark 作业的执行过程,并影响作业如何被拆分成阶段(Stages)。正如我们在本章和上一章中所看到的,Spark 持有一个 RDD 的有向无环图(DAG),该图表示了 RDD 的血统关系,Spark 不仅利用这个血统来规划作业的执行,还可以从任何执行器丢失中恢复。当一个 RDD 正在进行转换时,系统会尽力确保操作在与数据相同的节点上执行。然而,我们经常使用连接操作(join)、聚合(reduce)、分组(group)或其他聚合操作,这些操作往往会有意或无意地导致重新分区。这个洗牌过程反过来决定了数据处理中的某一阶段何时结束以及新的阶段何时开始。

以下图示说明了 Spark 作业如何被拆分成多个阶段。这个示例展示了一个pairRDD在执行groupByKey之前,先经过过滤和使用 map 转换的过程,最后再通过map()进行一次转换:

我们的洗牌操作越多,作业执行过程中就会有更多的阶段,从而影响性能。Spark Driver 使用两个关键方面来确定这些阶段。这是通过定义 RDD 的两种依赖关系类型来完成的,分别是窄依赖(narrow dependencies)和宽依赖(wide dependencies)。

窄依赖(Narrow Dependencies)

当一个 RDD 可以通过简单的一对一转换(如 filter() 函数、map() 函数、flatMap() 函数等)从另一个 RDD 派生时,子 RDD 被认为是基于一对一关系依赖于父 RDD。这种依赖关系被称为窄依赖,因为数据可以在包含原始 RDD/父 RDD 分区的同一节点上进行转换,而不需要通过其他执行器之间的网络传输任何数据。

窄依赖处于作业执行的同一阶段。

下图展示了窄依赖如何将一个 RDD 转换为另一个 RDD,并对 RDD 元素应用一对一转换:

宽依赖

当一个 RDD 可以通过在网络上传输数据或交换数据来重新分区或重新分发数据(使用函数,如 aggregateByKeyreduceByKey 等)从一个或多个 RDD 派生时,子 RDD 被认为依赖于参与 shuffle 操作的父 RDD。这种依赖关系被称为宽依赖,因为数据不能在包含原始 RDD/父 RDD 分区的同一节点上进行转换,因此需要通过其他执行器之间的网络传输数据。

宽依赖会在作业执行过程中引入新的阶段。

下图展示了宽依赖如何将一个 RDD 转换为另一个 RDD,并在执行器之间进行数据交换:

广播变量

广播变量是跨所有执行器共享的变量。广播变量在驱动程序中创建一次,然后在执行器中只读。虽然广播简单数据类型(如 Integer)是易于理解的,但广播的概念远不止于简单变量。整个数据集可以在 Spark 集群中进行广播,以便执行器能够访问广播的数据。所有在执行器中运行的任务都可以访问广播变量。

广播使用各种优化方法使广播的数据对所有执行器可用。这是一个需要解决的重要挑战,因为如果广播的数据集的大小很大,你不能指望数百或数千个执行器连接到驱动程序并拉取数据集。相反,执行器通过 HTTP 连接拉取数据,最新的方式类似于 BitTorrent,其中数据集像种子一样在集群中分发。这使得广播变量的分发方式更加可扩展,而不是让每个执行器逐个从驱动程序拉取数据,这可能会导致当执行器数量较多时,驱动程序出现故障。

驱动程序只能广播它所拥有的数据,不能通过引用广播 RDD。这是因为只有驱动程序知道如何解释 RDD,而执行器只知道它们所处理的特定数据分区。

如果深入了解广播的工作原理,会发现机制是首先由 Driver 将序列化对象分割成小块,然后将这些小块存储在 Driver 的 BlockManager 中。当代码被序列化并在执行器上运行时,每个执行器首先尝试从自己内部的 BlockManager 获取对象。如果广播变量已经被获取,它会找到并使用该变量。如果不存在,执行器会通过远程获取来从 Driver 和/或其他执行器拉取小块。一旦获取到小块,它会将这些小块存储到自己的 BlockManager 中,准备供其他执行器使用。这可以防止 Driver 成为发送多个广播数据副本(每个执行器一个副本)的瓶颈。

以下图示演示了广播在 Spark 集群中的工作原理:

广播变量既可以创建,也可以销毁。我们将探讨广播变量的创建与销毁方法。此外,我们还将讨论如何从内存中移除广播变量。

创建广播变量

创建广播变量可以使用 Spark Context 的 broadcast() 函数,适用于任何数据类型的可序列化数据/变量。

让我们来看一下如何广播一个 Integer 变量,并在执行器上执行的转换操作中使用该广播变量:

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

以下是一个简单的整数 RDD 转换示例,通过查找 HashMap,将每个元素与另一个整数相乘。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)

销毁广播变量

你还可以销毁广播变量,完全从所有执行器和驱动程序中删除它们,使其无法访问。这在优化集群资源管理时非常有帮助。

调用 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 Context 创建和使用长整型累加器的示例,使用 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 的灵活性是其真正的力量,它使得适应大多数用例并执行必要的操作以实现目标变得非常容易。

在下一章中,我们将转向 RDDs 之上,Tungsten 计划所增加的更高层次的抽象——DataFrames 和 Spark SQL,以及它们如何在第八章中汇聚,引入一些结构 – Spark SQL

第八章:简介结构 - Spark SQL

“一台机器可以做五十个普通人能做的工作,但没有一台机器能做一个非凡人能做的工作。”

  • 埃尔伯特·哈伯德

在本章中,你将学习如何使用 Spark 来分析结构化数据(如需将无结构数据,比如包含任意文本或其他格式的文档,转换为结构化形式);我们将看到 DataFrames/datasets 在这里是基础,Spark SQL 的 API 如何使查询结构化数据既简单又强大。此外,我们还会介绍 datasets,并探讨 datasets、DataFrames 和 RDDs 之间的区别。总的来说,本章将涵盖以下主题:

  • Spark SQL 和 DataFrames

  • DataFrame 和 SQL API

  • DataFrame 模式

  • 数据集和编码器

  • 数据加载和保存

  • 聚合操作

  • 连接操作

Spark SQL 和 DataFrames

在 Apache Spark 之前,Apache Hive 是每当需要对大量数据运行 SQL 类似查询时的首选技术。Apache Hive 实质上将 SQL 查询转换为类似 MapReduce 的逻辑,从而自动使得执行各种大数据分析变得非常简单,而不需要学习编写复杂的 Java 和 Scala 代码。

随着 Apache Spark 的出现,我们在大数据规模上执行分析的方式发生了范式转变。Spark SQL 提供了一个易于使用的 SQL 类似层,建立在 Apache Spark 的分布式计算能力之上。实际上,Spark SQL 可以作为一个在线分析处理数据库使用。

Spark SQL 的工作原理是通过将 SQL 类似语句解析为抽象语法树AST),随后将该计划转换为逻辑计划,并优化逻辑计划为可执行的物理计划。最终执行使用底层的 DataFrame API,这使得任何人都可以通过使用类似 SQL 的接口而不是学习所有内部实现,轻松使用 DataFrame API。由于本书深入探讨了各种 API 的技术细节,我们将主要介绍 DataFrame API,并在某些地方展示 Spark SQL API,以对比不同的 API 使用方式。

因此,DataFrame API 是 Spark SQL 底层的基础层。在本章中,我们将展示如何使用各种技术创建 DataFrames,包括 SQL 查询和对 DataFrame 执行操作。

DataFrame 是 弹性分布式数据集RDD)的抽象,处理通过 Catalyst 优化器优化的更高级功能,并且通过 Tungsten Initiative 实现高性能。你可以将数据集看作是 RDD 的高效表格,并具有经过优化的二进制数据表示。二进制表示是通过编码器实现的,编码器将各种对象序列化为二进制结构,从而提供比 RDD 表示更好的性能。由于 DataFrame 内部无论如何都使用 RDD,因此 DataFrame/数据集也像 RDD 一样分布式,因此它也是一个分布式数据集。显然,这也意味着数据集是不可变的。

以下是数据的二进制表示示意图:

数据集(datasets)在 Spark 1.6 中被添加,并在 DataFrame 之上提供强类型的好处。事实上,从 Spark 2.0 开始,DataFrame 实际上是数据集的别名。

org.apache.spark.sql 将类型 DataFrame 定义为 dataset[Row],这意味着大多数 API 可以很好地与数据集和 DataFrame 配合使用。

type DataFrame = dataset[Row]

DataFrame 在概念上类似于关系数据库中的表。因此,DataFrame 包含数据行,每行由多个列组成。

我们需要记住的第一件事是,像 RDD 一样,DataFrame 也是不可变的。DataFrame 的这种不可变性意味着每个转换或操作都会创建一个新的 DataFrame。

让我们开始深入了解 DataFrame 以及它们如何与 RDD 不同。正如之前所见,RDD 是 Apache Spark 中的数据操作的低级 API。DataFrame 是在 RDD 之上创建的,目的是抽象化 RDD 的低级内部工作原理,并暴露出更高层次的 API,这些 API 更易于使用,并且提供了大量开箱即用的功能。DataFrame 的创建遵循了类似于 Python pandas 包、R 语言、Julia 语言等中的概念。

正如我们之前提到的,DataFrame 将 SQL 代码和特定领域语言表达式转换为优化执行计划,这些计划将在 Spark Core API 上运行,以便 SQL 语句能够执行各种操作。DataFrame 支持多种不同类型的输入数据源和操作类型。包括所有类型的 SQL 操作,如连接、分组、聚合和窗口函数,类似于大多数数据库。Spark SQL 也非常类似于 Hive 查询语言,并且由于 Spark 提供了与 Apache Hive 的自然适配器,已经在 Apache Hive 中工作的用户可以轻松地将他们的知识转移到 Spark SQL,从而最大限度地减少过渡时间。

DataFrames 本质上依赖于表的概念,正如之前所见。可以非常类似于 Apache Hive 的方式操作表。事实上,Apache Spark 中对表的许多操作与 Apache Hive 处理表及操作表的方式非常相似。一旦有了作为 DataFrame 的表,可以将 DataFrame 注册为表,并且可以使用 Spark SQL 语句操作数据,而不是使用 DataFrame API。

DataFrames 依赖于催化剂优化器和钨丝性能改进,因此让我们简要地看一下催化剂优化器是如何工作的。催化剂优化器从输入的 SQL 创建一个解析后的逻辑计划,然后通过查看 SQL 语句中使用的所有各种属性和列来分析这个逻辑计划。一旦分析完逻辑计划,催化剂优化器进一步尝试通过合并多个操作和重新排列逻辑来优化计划,以获得更好的性能。

要理解催化剂优化器,可以将其看作是一个常识逻辑优化器,它可以重新排序诸如过滤器和转换等操作,有时将多个操作组合成一个操作,以尽量减少在工作节点之间传输的数据量。例如,催化剂优化器可能决定在不同数据集之间执行联合操作时广播较小的数据集。使用 explain 查看任何数据框的执行计划。催化剂优化器还计算 DataFrame 的列和分区的统计信息,提高执行速度。

例如,如果数据分区上有转换和过滤操作,则过滤数据和应用转换的顺序对操作的整体性能至关重要。由于所有优化,生成了优化的逻辑计划,然后将其转换为物理计划。显然,有几个物理计划可以执行相同的 SQL 语句并生成相同的结果。成本优化逻辑根据成本优化和估算选择一个好的物理计划。

钨丝性能改进是 Spark 2.x 提供的显著性能改进背后的秘密武器之一,与之前的版本如 Spark 1.6 和更早的版本相比。钨丝实现了对内存管理和其他性能改进的完全改造。最重要的内存管理改进使用对象的二进制编码,并在堆外和堆内存中引用它们。因此,钨丝允许使用二进制编码机制来编码所有对象以使用办公堆内存。二进制编码的对象占用的内存要少得多。项目钨丝还改进了洗牌性能。

通常通过 DataFrameReader 将数据加载到 DataFrames 中,并通过 DataFrameWriter 从 DataFrames 中保存数据。

DataFrame API 和 SQL API

创建数据框可以通过多种方式进行:

  • 通过执行 SQL 查询

  • 加载外部数据,如 Parquet、JSON、CSV、文本、Hive、JDBC 等

  • 将 RDD 转换为数据框

可以通过加载 CSV 文件来创建一个数据框。我们将查看一个名为statesPopulation.csv的 CSV 文件,它被加载为一个数据框。

该 CSV 文件包含 2010 年到 2016 年间美国各州的人口数据格式。

年份 人口
阿拉巴马州 2010 4785492
阿拉斯加州 2010 714031
亚利桑那州 2010 6408312
阿肯色州 2010 2921995
加利福尼亚州 2010 37332685

由于此 CSV 文件包含标题行,我们可以使用它快速加载到数据框中,并进行隐式的模式检测。

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.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,使用逗号作为分隔符,并且可以隐式推断模式。

数据框通过解析逻辑计划、分析逻辑计划、优化计划,然后最终执行物理执行计划来工作。

使用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>

数据框也可以注册为表名(如下所示),这将允许你像关系型数据库一样编写 SQL 语句。

scala> statesDF.createOrReplaceTempView("states")

一旦我们获得了结构化的数据框或表格,我们就可以运行命令来对数据进行操作:

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 实际上是转换为数据框 API 进行执行,SQL 只是一个简化使用的领域特定语言(DSL)。

使用数据框上的sort操作,你可以按任何列对数据框中的行进行排序。以下是使用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我们可以按任何列对数据框进行分组。以下是按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操作,你可以对数据框中的列执行多种不同的操作,例如查找列的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>

数据框操作可以很好地链式连接,这样执行就能利用成本优化(Tungsten 性能改进和 Catalyst 优化器协同工作)。

我们还可以将操作链式连接在一个语句中,如下所示,其中我们不仅按 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 列通过将每个唯一值转换为一个实际的列,创建了几个新的列。最终结果是,现在我们不仅仅查看年份列,而是可以使用每年的列来按 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 还支持过滤器(Filters),可以快速过滤 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)

UDF 定义了基于列的新的函数,扩展了 Spark SQL 的功能。通常,Spark 中内置的函数无法处理我们所需的精确功能。在这种情况下,Apache Spark 支持创建 UDF,我们可以使用它们。

udf() 内部调用了一个案例类用户自定义函数,它内部又调用了 ScalaUDF。

让我们通过一个示例来了解一个简单的用户自定义函数(UDF),该函数将 State 列的值转换为大写。

首先,我们在 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._

编码器

Spark 2.x 支持为复杂数据类型定义模式的另一种方式。首先,我们来看一个简单的例子。

必须通过 import 语句导入编码器,以便使用编码器:

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 是 Seq 类型的 StructFields。此外,不允许有两个同名的字段。

加载和保存数据集

我们需要将数据读取到集群作为输入,并将输出或结果写回存储,这样才能对代码做实际操作。输入数据可以从多种数据集和来源读取,如文件、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 可以通过 DataFrameWriter 接口将数据保存到外部存储系统,如文件、Hive 表和 JDBC 数据库。

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")

聚合

聚合是根据某个条件收集数据并对其进行分析的方法。聚合对于理解各种规模的数据非常重要,因为仅仅拥有原始数据记录对于大多数用例来说并不那么有用。

举个例子,如果你查看下面的表格和它的聚合视图,显然仅仅是原始记录并不能帮助你理解数据。

想象一下,一个表格,其中记录了世界上每个城市五年内每天的气温测量数据。

以下展示了一个包含每天每个城市的平均气温记录的表格:

城市 日期 气温
波士顿 2016/12/23 32
纽约 2016/12/24 36
波士顿 2016/12/24 30
费城 2016/12/25 34
波士顿 2016/12/25 28

如果我们想计算上表中每个城市的日均气温,我们可以看到的结果将类似于下面的表格:

城市 平均气温
波士顿 30 - (32 + 30 + 28)/3
纽约 36
费城 34

聚合函数

大多数聚合操作可以使用在org.apache.spark.sql.functions包中找到的函数来完成。此外,还可以创建自定义聚合函数,称为用户定义的聚合函数UDAF)。

每个分组操作返回一个RelationalGroupeddataset,你可以在其上指定聚合操作。

我们将加载示例数据,以说明本节中所有不同类型的聚合函数:

val statesPopulationDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")

计数

count是最基本的聚合函数,它简单地计算指定列的行数。其扩展版本是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.

让我们来看一个调用 sum 的例子,计算 DataFrame 中 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.

让我们来看一个调用 kurtosis 的例子,针对 Population 列的 DataFrame:

import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(kurtosis("Population")).show
+--------------------+
|kurtosis(Population)|
+--------------------+
| 7.727421920829375|
+--------------------+

偏度

偏度衡量数据中各值围绕平均值或均值的非对称性。

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 的例子,针对 Population 列的 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.

现在,让我们来看一个调用 var_pop 的例子,计算 Population 的方差:

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.

让我们来看一个调用 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.

让我们来看一个调用 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|
+---------+---------------+--------------------+

汇总

汇总是用于执行层次或嵌套计算的多维聚合。例如,如果我们想显示每个State+Year组的记录数,以及每个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 年,以及加利福尼亚州(汇总所有年份)。

立方体

立方体是用于执行层次或嵌套计算的多维聚合,类似于汇总,但不同的是立方体对所有维度执行相同的操作。例如,如果我们想显示每个StateYear组的记录数,以及每个State的记录数(汇总所有年份,给出每个州的总数,不考虑Year),我们可以按如下方式使用汇总。此外,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 是窗口聚合中常见的一种方法,通常用于将输入数据集分为 n 个部分。例如,在预测分析中,十等分(10 个部分)通常用于首先对数据进行分组,然后将其分为 10 个部分,以获得公平的数据分布。这是窗口函数方法的自然功能,因此 ntiles 是窗口函数如何提供帮助的一个很好的例子。

例如,如果我们想按StatestatesPopulationDF进行分区(如前所示的窗口规范),按人口排序,然后将其分为两部分,我们可以在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的行分为两等份。

该功能的常见用途之一是计算数据科学模型中使用的十等分(deciles)。

连接

在传统数据库中,连接用于将一张交易表与另一张查找表连接,以生成更完整的视图。例如,如果你有一张按客户 ID 分类的在线交易表和另一张包含客户城市及客户 ID 的表,你可以使用连接来生成按城市分类的交易报告。

交易表:以下表格包含三列,客户 ID购买商品以及客户为商品支付的价格:

客户 ID 购买商品 支付价格
1 耳机 25.00
2 手表 100.00
3 键盘 20.00
1 鼠标 10.00
4 电缆 10.00
3 耳机 30.00

客户信息表:以下表格包含两列,客户 ID和客户所在的城市

客户 ID 城市
1 波士顿
2 纽约
3 费城
4 波士顿

将交易表与客户信息表连接将生成如下视图:

客户 ID 购买商品 支付价格 城市
1 耳机 25.00 波士顿
2 手表 100.00 纽约
3 键盘 20.00 费城
1 鼠标 10.00 波士顿
4 电缆 10.00 波士顿
3 耳机 30.00 费城

现在,我们可以使用这个连接后的视图来生成按城市分类的总销售价格报告:

城市 #商品 总销售价格
波士顿 3 45.00
费城 2 50.00
纽约 1 100.00

连接是 Spark SQL 的重要功能,因为它允许你将两个数据集结合起来,如前所示。当然,Spark 不仅仅用于生成报告,它还用于处理 PB 级别的数据,以应对实时流处理、机器学习算法或普通的分析任务。为了实现这些目标,Spark 提供了所需的 API 函数。

一个典型的数据集连接通过使用左侧和右侧数据集的一个或多个键来完成,然后在键的集合上评估条件表达式作为布尔表达式。如果布尔表达式的结果为真,则连接成功,否则连接后的 DataFrame 将不包含相应的连接数据。

Join 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 来理解如何使用 join 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") 

注意,连接的详细内容将在接下来的几部分中讨论。

Join 的内部工作原理

Join 通过在多个执行器上操作 DataFrame 的分区来工作。然而,实际的操作和随后的性能取决于join的类型和所连接数据集的性质。在下一部分中,我们将讨论各种连接类型。

Shuffle 连接

在两个大数据集之间进行连接时,涉及到 shuffle join,其中左侧和右侧数据集的分区被分布到各个执行器中。Shuffle 操作是非常昂贵的,因此必须分析逻辑,确保分区和 shuffle 的分布是最优的。以下是 shuffle join 如何在内部工作的示意图:

广播连接

在一个大数据集和一个小数据集之间进行连接时,可以通过将小数据集广播到所有执行器来完成,前提是左侧数据集的分区存在。以下是广播连接如何在内部工作的示意图:

连接类型

以下是不同类型的连接表。这非常重要,因为在连接两个数据集时所做的选择对结果和性能有着决定性影响。

Join type Description
inner 内连接将left中的每一行与right中的每一行进行比较,并且只有当两者都有非 NULL 值时,才会将匹配的leftright行组合在一起。
cross cross join 将left中的每一行与right中的每一行进行匹配,生成一个笛卡尔积。
outer, full, fullouter 全外连接返回leftright中的所有行,如果某一侧没有数据,则填充 NULL。
leftanti leftanti Join 仅返回left中的行,基于right侧的不存在。
left, leftouter leftouter Join 返回left中的所有行以及leftright的共同行(内连接)。如果不在right中,则填充 NULL。
leftsemi leftsemi Join 仅返回left中的行,基于right侧的存在。它不包括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")

内连接

内连接(Inner join)会返回来自statesPopulationDFstatesTaxRatesDF的数据行,前提是两个数据集中state列的值都非空。

按照state列连接这两个数据集,如下所示:

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中共同的行。

按照state列连接这两个数据集,如下所示:

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)返回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|

外连接

外连接(Outer join)返回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|

左反连接

左反连接(Left anti join)只会返回来自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|
+--------+----+----------+

左半连接

左半连接(Left semi join)只会返回来自statesPopulationDF的数据行,前提是statesTaxRatesDF中有对应的行。

按照state列连接这两个数据集,如下所示:

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|
+----------+----+----------+

交叉连接

交叉连接(Cross join)会将表的每一行与表的每一行匹配,生成一个笛卡尔积。

按照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|
+-------+----+----------+-----------+-------+

你还可以使用cross连接类型来代替调用交叉连接(cross join)API。statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State").isNotNull, "cross").count

连接的性能影响

选择的连接类型直接影响连接的性能。这是因为连接操作需要在执行器之间对数据进行洗牌,因此在使用连接时,需要考虑不同的连接类型,甚至连接的顺序。

以下是你在编写Join代码时可以参考的表格:

Join type 性能考虑和提示
inner 内连接要求左表和右表具有相同的列。如果左表或右表中有重复或多个键值,连接操作会迅速膨胀成一种笛卡尔连接,导致执行时间远长于正确设计的连接,后者能够最小化多个键的影响。
cross 交叉连接将表的每一行与表的每一行匹配,生成一个笛卡尔积。使用时需谨慎,因为这是性能最差的连接类型,应该仅在特定用例中使用。
outer, full, fullouter 完全外连接(Fullouter Join)返回表和表中的所有行,如果某行仅存在于表或表中,则填充为 NULL。如果用于表之间的公共部分很少的情况,可能会导致结果非常大,从而影响性能。
leftanti Leftanti 连接仅根据right侧的不存不存在返回left中的行。性能非常好,因为只有一个表被完全考虑,另一个表只是根据连接条件进行检查。
left, leftouter Leftouter 连接返回所有来自left的行,并加上leftright的公共行(内连接)。如果在right中不存在,则填充 NULL。如果在两个表之间的共同部分很少,可能会导致结果非常庞大,从而导致性能变慢。
leftsemi Leftsemi 连接仅根据right侧的存在返回left中的行。不会包括right侧的值。性能非常好,因为只有一个表被完全考虑,另一个表只是根据连接条件进行检查。
right, rightouter Rightouter 连接提供了所有来自right的行,并加上leftright的公共行(内连接)。如果在left中不存在,则填充 NULL。性能与前面表格中的左外连接相似。

总结

在本章中,我们讨论了 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,或者订车,或者发推特分享你刚买的汉堡,或者查看你最喜欢的球队的最新新闻或体育动态。我们依赖手机和互联网做很多事情,无论是工作,还是浏览,还是给朋友发邮件。这个现象已经无可避免,而且应用和服务的数量与种类只会随着时间增长。

结果是,智能设备无处不在,并且它们时刻产生大量数据。这种现象,也被广泛称为物联网,永远改变了数据处理的动态。无论何时你使用 iPhone、Android 或 Windows 手机上的任何服务或应用程序,以某种形式,实时数据处理都在发挥作用。由于大量依赖于应用程序的质量和价值,人们非常关注各种初创公司和成熟公司如何应对SLA服务级别协议)、有用性以及数据的及时性等复杂挑战。

许多组织和服务提供商正在研究并采用的一个范式是构建非常可扩展的、接近实时或实时的数据处理框架,运行在非常前沿的平台或基础设施上。所有东西都必须快速并且对变化和故障具有反应能力。如果你的 Facebook 每小时更新一次,或者你每天只收到一次电子邮件,你可能不会喜欢;因此,数据流、处理和使用必须尽可能接近实时。我们感兴趣的许多系统都会生成大量数据,作为一个无限期持续的事件流。

与任何其他数据处理系统一样,我们面临着数据收集、存储和处理的基本挑战。然而,额外的复杂性来自于平台的实时需求。为了收集这些不确定的事件流,并随后处理所有这些事件以生成可操作的见解,我们需要使用高度可扩展的专业架构来应对海量的事件速率。因此,许多系统已经在几十年中发展起来,从 AMQ、RabbitMQ、Storm、Kafka、Spark、Flink、Gearpump、Apex 等开始。

为了应对如此大量的流数据,现代系统采用了非常灵活且可扩展的技术,这些技术不仅效率极高,而且比以往更能帮助实现业务目标。通过使用这些技术,几乎可以立即或根据需要在稍后的时间里,消费来自各种数据源的数据,并将其用于各种使用场景。

让我们谈谈当你拿出智能手机并预定 Uber 车去机场时发生了什么。通过在手机屏幕上轻点几下,你可以选择一个地点、选择信用卡、完成支付并预定乘车。交易完成后,你可以在手机上的地图上实时监控车辆的进展。当汽车向你驶来时,你可以精确看到车辆的位置,同时,你也可以决定在等车时去附近的 Starbucks 买杯咖啡。

你还可以通过查看预计的汽车到达时间来做出关于汽车和随后的机场之行的明智决策。如果看起来汽车接你需要花费相当长的时间,而且这可能会影响到你即将乘坐的航班,那么你可以取消这次乘车并选择附近恰好有空的出租车。或者,如果恰好由于交通情况无法按时到达机场,从而可能影响到你即将乘坐的航班,那么你也可以做出重新安排或取消航班的决定。

现在,为了理解这种实时流架构是如何工作并提供如此宝贵的信息的,我们需要理解流架构的基本原则。一方面,实时流架构必须能够以极高的速率消费大量数据,另一方面,还需要确保获取的数据也能够被处理。

以下图示展示了一个通用的流处理系统,生产者将事件放入消息系统,消费者则从消息系统读取事件:

实时流数据处理可以分为以下三种基本范式:

  • 至少一次处理

  • 至多一次处理

  • 精确一次处理

让我们来看一下这三种流处理范式对我们的业务用例意味着什么。

尽管实时事件的精确一次处理是我们的最终目标,但在不同场景下,始终实现这一目标非常困难。我们必须在某些情况下对精确一次处理的特性进行妥协,因为这样的保证的好处往往被实现的复杂性所抵消。

至少一次处理

至少一次处理范式涉及一种机制,在事件实际处理并且结果已持久化之后,仅仅在事件被处理后才保存最后接收到事件的位置,这样,如果发生故障并且消费者重启,消费者将重新读取旧的事件并进行处理。然而,由于无法保证接收到的事件没有被完全处理或部分处理,这会导致事件被重复获取,从而可能导致事件重复处理。这就导致了“事件至少被处理一次”的行为。

至少一次处理理想适用于任何涉及更新某些瞬时计量器或仪表来显示当前值的应用程序。任何累计总和、计数器或依赖于聚合结果准确性的应用场景(如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可以直接用于流处理作业中。StreamingContextSparkContext相似,不同之处在于,StreamingContext还要求程序指定批处理时间间隔或持续时间,单位可以是毫秒或分钟。

记住,SparkContext是主要的入口点,任务调度和资源管理是SparkContext的一部分,因此StreamingContext复用了这部分逻辑。

StreamingContext

StreamingContext是流处理的主要入口点,负责流处理应用的各个方面,包括检查点、转换和对 DStreams 的 RDD 操作。

创建 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 示例

以下展示了一个简单的 Spark Streaming 示例,使用textFileStream。在这个例子中,我们从 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-2.1.1-bin-hadoop2.7文件夹下创建一个streamouts文件夹,其中安装了 Spark。当应用程序运行时,streamouts文件夹将收集推文并保存为文本文件。

  3. 下载以下的 JAR 文件到目录中:

  4. 使用指定的 Twitter 集成所需的 jars 启动 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 基于一个叫做 离散化流(Discretized Streams) 的抽象,简称 DStreams。DStream 表示为一系列 RDD,每个 RDD 在每个时间间隔内创建。DStream 可以像常规 RDD 一样使用类似的概念(如基于有向无环图的执行计划)进行处理。与常规 RDD 处理类似,执行计划中的转换操作和行动操作也会用于 DStream。

DStream 本质上是将源源不断的数据流基于时间间隔划分为较小的块,称为微批次(micro-batches),并将每个微批次物化为一个 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]]()

以下是一个 DStream 的示例,展示了每 T 秒创建的 RDD:

在以下示例中,创建一个流上下文,每 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. 创建了一个类型为ReceiverInputDStreamDStream,它被定义为一个抽象类,用于定义任何需要在工作节点上启动接收器以接收外部数据的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类是专门为此功能构建的。Map 转换、窗口函数、reduce 操作和不同类型的输入流都是通过不同的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() 这计算每个键的频率,并返回一个新的 DStream,其中的元素是(key, long)对。
reduceByKey(func, [numTasks]) 这通过在源 DStream 的 RDD 上按键聚合数据,并返回一个新的 DStream,其中的元素是(键,值)对。
join(otherStream, [numTasks]) 这将两个 DStream 的(K, V)(K, W)对连接在一起,并返回一个新的 DStream,它的元素是(K, (V, W))对,合并了两个 DStream 中的值。
cogroup(otherStream, [numTasks]) 当在(K, V)(K, W)对的 DStream 上调用cogroup()时,它将返回一个新的 DStream,其中的元素是(K, Seq[V], Seq[W])元组。
transform(func) 这在源 DStream 的每个 RDD 上应用一个转换函数,并返回一个新的 DStream。
updateStateByKey(func) 这通过在每个键的先前状态和该键的新值上应用给定的函数,更新每个键的状态。通常用于维持一个状态机。

窗口操作

Spark Streaming 提供了窗口处理功能,允许你在滑动窗口中的事件上应用转换。滑动窗口是在指定的间隔上创建的。每当窗口滑过一个源 DStream 时,符合窗口规范的源 RDD 会被合并并进行操作,生成窗口化的 DStream。窗口有两个参数需要指定:

  • 窗口长度:这是指定的窗口考虑的时间间隔长度

  • 滑动间隔:这是窗口创建的间隔。

窗口长度和滑动间隔必须都是块间隔的倍数。

下面展示了一个示意图,显示了一个 DStream 的滑动窗口操作,演示了旧窗口(虚线矩形)如何滑动一个间隔到右边,进入新的窗口(实线矩形):

一些常见的窗口操作如下。

转换 含义
window(windowLength, slideInterval) 这在源 DStream 上创建一个窗口,并返回相同的 DStream 作为新的 DStream。
countByWindow(windowLength, slideInterval) 这通过应用滑动窗口返回 DStream 中元素的计数。
reduceByWindow(func, windowLength, slideInterval) 这通过在源 DStream 的每个元素上应用 reduce 函数,并在创建一个长度为windowLength的滑动窗口后,返回一个新的 DStream。
reduceByKeyAndWindow(func, windowLength, slideInterval, [numTasks]) 这通过在源 DStream 的 RDD 上应用窗口进行按键聚合,并返回一个新的 DStream,其中的元素是(键,值)对。计算由func函数提供。
reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks]) 该函数通过键对源 DStream 的 RDD 进行窗口聚合,并返回一个新的包含(键,值)对的 DStream。与前面的函数的主要区别在于invFunc,它提供了在滑动窗口开始时需要执行的计算。
countByValueAndWindow(windowLength, slideInterval, [numTasks]) 该函数计算每个键的频率,并返回一个新的包含(键,长整型)对的 DStream,该 DStream 符合指定的滑动窗口。

让我们更详细地看一下 Twitter 流的示例。我们的目标是每 5 秒打印推文中使用的前五个单词,使用一个长度为 15 秒、每 10 秒滑动一次的窗口。因此,我们可以在 15 秒内获取前五个单词。

要运行此代码,请按照以下步骤操作:

  1. 首先,打开终端并切换到spark-2.1.1-bin-hadoop2.7目录。

  2. spark-2.1.1-bin-hadoop2.7文件夹下创建一个streamouts文件夹,该文件夹是你安装 spark 的地方。当应用程序运行时,streamouts文件夹将收集推文并保存为文本文件。

  3. 将以下 jar 下载到目录中:

  4. 使用指定所需 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 使用 DStream 的概念,DStream 本质上是以 RDD 形式创建的数据微批次。我们还看到了 DStream 上可能进行的转换类型。DStream 的转换可以分为两种类型:无状态转换有状态转换

在无状态转换中,每个数据微批的处理不依赖于先前批次的数据。因此,这是一种无状态转换,每个批次独立处理,而不依赖于之前发生的任何事情。

在有状态转换中,每个微批数据的处理都完全或部分依赖于之前的数据批次。因此,这是一个有状态转换,每个批次在计算当前批次数据时都会考虑之前发生的事情,并利用这些信息。

无状态转换

无状态转换通过对 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目录在几秒钟后看起来像下面这样,显示了元数据以及 RDD 和logfiles作为检查点的一部分:

元数据检查点

元数据检查点保存定义流式操作的信息,这些操作由有向无环图DAG)表示,并将其保存到 HDFS 中。这些信息可以在发生故障并重新启动应用程序时用于恢复 DAG。驱动程序会重新启动并从 HDFS 读取元数据,重建 DAG 并恢复崩溃前的所有操作状态。

元数据包括以下内容:

  • 配置:用于创建流式应用程序的配置

  • DStream 操作:定义流式应用程序的 DStream 操作集

  • 不完整批次:排队中的作业但尚未完成的批次

数据检查点

数据检查点将实际的 RDD 保存到 HDFS 中,这样,如果流应用程序发生故障,应用程序可以恢复检查点的 RDD 并从上次中断的地方继续。虽然流应用程序恢复是数据检查点的一个典型应用场景,但检查点也有助于在某些 RDD 因缓存清理或执行器丢失而丢失时,通过实例化生成的 RDD 而无需等待所有父 RDD 在 DAG(有向无环图)中重新计算,从而实现更好的性能。

对于具有以下任何要求的应用程序,必须启用检查点:

  • 有状态转换的使用:如果应用程序中使用了updateStateByKeyreduceByKeyAndWindow(带有逆向函数),则必须提供检查点目录,以允许周期性地进行 RDD 检查点。

  • 从驱动程序故障中恢复:元数据检查点用于通过进度信息进行恢复。

如果您的流应用程序没有使用有状态转换,那么可以在不启用检查点的情况下运行该应用程序。

您的流应用程序可能会丢失接收到但尚未处理的数据。

请注意,RDD 的检查点会产生将每个 RDD 保存到存储中的成本。这可能导致检查点化的 RDD 所在的批次处理时间增加。因此,必须小心设置检查点的间隔,以避免引发性能问题。在非常小的批次(例如 1 秒)下,检查点每个微小批次的频率过高,可能会显著降低操作吞吐量。相反,检查点的频率过低会导致血统和任务大小增长,这可能会引起处理延迟,因为需要持久化的数据量较大。

对于需要 RDD 检查点的有状态转换,默认间隔是批处理间隔的倍数,至少为 10 秒。

DStream 的 5 到 10 个滑动间隔的检查点间隔是一个良好的初始设置。

驱动程序故障恢复

驱动程序故障恢复可以通过使用StreamingContext.getOrCreate()来实现,该方法可以从现有的检查点初始化StreamingContext或创建一个新的StreamingContext

启动流应用程序时需要满足以下两个条件:

  • 当程序第一次启动时,需要创建一个新的StreamingContext,设置所有的流,然后调用start()

  • 当程序在失败后重新启动时,需要从检查点目录中的检查点数据初始化一个StreamingContext,然后调用start()

我们将实现一个函数 createStreamContext(),该函数创建 StreamingContext 并设置各种 DStreams 来解析推文,并使用窗口每 15 秒生成前五个推文标签。但是,我们不会调用 createStreamContext() 然后调用 ssc.start(),而是会调用 getOrCreate(),这样如果 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 brokers 拉取数据。由于接收器从 Kafka brokers 拉取事件,接收器会将偏移量更新到 Zookeeper 中,这也被 Kafka 集群使用。关键之处在于使用 WALWrite Ahead Log),接收器在消费 Kafka 数据时持续写入 WAL。因此,当存在问题并且执行器或接收器丢失或重启时,可以使用 WAL 恢复事件并处理它们。因此,这种基于日志的设计提供了持久性和一致性。

每个接收器都会创建一个来自 Kafka 主题的输入 DStream,并查询 Zookeeper 获取 Kafka 主题、brokers、偏移量等信息。在此之后,我们之前讨论的 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 brokers 拉取消息的基于接收器的流:

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 任务与 Kafka 主题/分区之间是 1:1 的关系。这种方法不依赖于 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 拉取偏移量信息,并指导执行器根据驱动程序指定的偏移范围启动任务,从 Kafka 代理拉取事件:

结构化流处理

结构化流处理是 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 引擎之上的一个可扩展且具有容错性的流处理引擎。这使得流处理和计算更加接近批处理,而不是当前 Spark 流处理 API 所面临的 DStream 范式及其挑战。结构化流引擎解决了多个挑战,例如精准一次流处理、增量更新处理结果、聚合等。

结构化流处理 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。

下面是一个示例,我们首先创建一个inputStream,调用 SparkSession 的readStream API,然后从行中提取单词。接着,我们对单词进行分组并计算出现次数,最后将结果写入输出流:

//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 Streaming 只将时间视为接收时间,用于 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 的定义、DAGs 和 DStreams 的血统、转换和动作。我们还探讨了流处理中的窗口概念。最后,我们还看了一个实际示例,使用 Spark Streaming 消费 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的 case 类,然后通过添加朋友来建立类似于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() 实例以及它们之间的逻辑关系:

如果你想使用上面的图,仅仅找出约翰的朋友,约翰朋友的朋友,依此类推,这样我们就可以快速找到直接朋友、间接朋友(朋友的二级关系)、三级朋友(朋友的朋友的朋友),你会看到类似以下的图示:

我们可以轻松扩展 Person() 类并提供更多功能来回答不同的问题。这并不是重点,我们想要关注的是前面那个图示,展示了 PersonPerson 的朋友,以及如何将每个 Person 的所有朋友绘制出来,从而形成一个人物之间的关系网。

现在我们引入图论,它源于数学领域。图论将图定义为由顶点、节点或点组成的结构,这些顶点通过边、弧和线连接。如果你将顶点集合视为 V,边集合视为 E,那么图 G 可以定义为有序对 VE

Graph G = (V, E)
V - set of Vertices
E - set of Edges

在我们 Facebook 朋友图的例子中,我们可以简单地将每个人视为顶点集合中的一个顶点,然后两个人之间的每一条链接可以视为边集合中的一条边。

根据这个逻辑,我们可以列出顶点,如下图所示:

这种作为数学图的描述引出了多种遍历和查询图的数学方法。当这些技术应用于计算机科学,作为开发程序方法来执行必要的数学运算时,正式的做法当然是开发算法,以可扩展、高效的方式实现数学规则。

我们已经尝试使用案例类 Person 实现一个简单的图形程序,但这只是最简单的用例,应该显而易见的是,存在许多复杂的扩展是可能的,比如以下问题需要解答:

  • 从 X 到 Y 的最佳路径是什么?一个这样的例子是你的车载 GPS 告诉你去超市的最佳路线。

  • 如何识别关键边,这些边可能导致图的分割?一个这样的例子是确定连接各个城市互联网服务/水管/电力线路的关键链接。关键边会切断连通性,产生两个连接良好的城市子图,但这两个子图之间将无法进行任何通信。

回答上述问题可以得出若干算法,如最小生成树、最短路径、网页排名、ALS交替最小二乘法)、最大割最小流算法等,这些算法适用于广泛的使用场景。

其他示例包括 LinkedIn 的个人资料和连接、Twitter 的粉丝、Google 的页面排名、航空公司调度、汽车中的 GPS 等等,你可以清楚地看到一个包含顶点和边的图。通过使用图算法,可以使用不同的算法来分析 Facebook、LinkedIn 和 Google 等示例中看到的图,从而得出不同的商业用例。

以下是一些现实生活中图的实际应用示例,展示了图和图算法在一些现实生活中的用例中的应用,例如:

  • 帮助确定机场之间的航班路线

  • 规划如何将水管道布局到本地区的所有家庭

  • 让你的汽车 GPS 规划开车去超市的路线

  • 设计如何从一个城市到另一个城市、一个州到另一个州、一个国家到另一个国家的互联网流量路由

现在让我们深入探讨如何使用 Spark GraphX。

GraphX

如前一部分所示,我们可以将许多现实生活中的用例建模为一个包含顶点集合和边集合的图,这些边连接着顶点。我们还编写了简单的代码,尝试实现一些基本的图操作和查询,比如,X 是否是 Y 的朋友?然而,随着我们进一步探索,算法变得更加复杂,用例也增多,而且图的规模远远大于单台机器能够处理的范围。

不可能将十亿个 Facebook 用户及其所有的友谊关系都装入一台机器或甚至几台机器中。

我们需要做的是超越仅仅将一台机器或几台机器拼凑在一起,开始考虑高度可扩展的架构,以实现复杂的图算法,这些算法能够处理数据量和数据元素之间复杂的互联关系。我们已经看到 Spark 的介绍,Spark 如何解决分布式计算和大数据分析中的一些挑战。我们还看到了实时流处理、Spark SQL 以及 DataFrames 和 RDD。我们能否解决图算法的挑战?答案是 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扩展了一个包含顶点 ID 和顶点属性的 RDD,表示为RDD[(VertexId, VD)]。它还确保每个顶点只有一个条目,并通过预先索引条目来加速高效的连接操作。两个具有相同索引的VertexRDD可以高效地进行连接。

class VertexRDD[VD]() extends RDD[(VertexId, VD)]

VertexRDD也实现了许多函数,这些函数提供了与图操作相关的重要功能。每个函数通常接受由VertexRDD表示的顶点作为输入。

让我们将顶点加载到VertexRDD中。为此,我们首先声明一个案例类User,如下面所示:

case class User(name: String, occupation: String)

现在,使用users.txt文件创建VertexRDD

VertexID 姓名 职业
1 John 会计
2 Mark 医生
3 Sam 律师
4 Liz 医生
5 Eric 会计
6 Beth 会计
7 Larry 工程师
8 Marry 收银员
9 Dan 医生
10 Ken 图书管理员

users.txt文件的每一行包含VertexId姓名职业,所以我们可以在这里使用String的分割函数:

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 扩展而来的,并且可以同时包含边属性和顶点属性。

EdgeRDD[ED, VD]通过将边存储在每个分区的列格式中来扩展RDD[Edge[ED]],以提高性能。它还可以存储与每条边相关联的顶点属性,从而提供三元组视图:

class EdgeRDD[ED]() extends RDD[Edge[ED]]

EdgeRDD还实现了许多函数,这些函数提供了与图操作相关的重要功能。每个函数通常接受由EdgeRDD表示的边作为输入。每条边由源顶点 Id、目标顶点 Id 和边属性(如StringInteger或任何案例类)组成。在下面的例子中,我们使用String类型的朋友作为属性。稍后在本章中,我们将使用以英里为单位的距离(Integer)作为属性。

我们可以通过读取一对顶点 Id 的文件来创建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的分割函数:

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()函数查看顶点和边,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

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(顶点 ID = 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()映射每个顶点属性,保留索引以避免改变顶点 ID。改变顶点 ID 会导致索引变化,从而使后续操作失败,并且顶点将不再可达。因此,重要的是不要改变顶点 ID。

此函数的声明如下所示:

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 将顶点转换为(vertexId, User.name)对的列表。在边上,MapValues 将边转换为(srcId, dstId, string)的三元组:

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

如果一个顶点的两个邻居通过一条边相连,就会创建一个三角形。换句话说,用户将与那两个互为朋友的朋友创建一个三角形。

Graph 有一个函数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 算法如下所示:

  • 连通分量

  • 最短路径

  • 旅行商问题

  • 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] 

连通分量

连通分量本质上是图中的子图,其中顶点通过某种方式相互连接。这意味着同一组件中的每个顶点都与组件中的其他顶点有一条边。当没有其他边将顶点连接到某个组件时,就会创建一个新的组件,该组件包含该特定顶点。这个过程会一直持续,直到所有顶点都属于某个组件。

图对象提供了一个 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))))

旅行商问题

旅行商问题试图在无向图中找到一条最短路径,遍历每个顶点。例如,用户约翰想要开车到每个其他用户的地方,最小化总驾驶距离。随着顶点和边的数量增加,排列组合的数量也会呈多项式增长,覆盖从顶点到顶点的所有可能路径。时间复杂度也会多项式增长,问题的解决可能需要很长时间。与其完全准确地解决它,更常用的方法是采用贪心算法,以尽可能最优的方式解决该问题。

为了解决旅行商问题,贪心算法会快速选择最短的边,尽管我们知道如果继续进一步深度遍历,这可能是一个非最优选择。

贪心算法在用户和朋友图中的示意图如下所示,其中我们看到在每个顶点处选择最短的加权边进行遍历。同时请注意,顶点拉里7)和莉兹4)从未被访问过:

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选择两个顶点之间跳跃次数最少的最短路径。以下图示展示了约翰拉里的三种路径,其中两条路径长度为 2,一条路径长度为 3。从前面代码的结果可以清楚地看到,从拉里到约翰选择的路径长度为 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 计算从约翰拉里单源最短路径的示意图,计算过程从初始化开始,逐步迭代直到我们到达最佳路径。

图的初始化是通过将代表约翰的顶点的值设置为零,所有其他顶点的值设置为正无穷大来完成的:

初始化完成后,我们将使用 Pregel 进行四次迭代来重新计算顶点值。在每次迭代中,我们遍历所有顶点,并在每个顶点处检查是否存在更好的从源顶点到目标顶点的路径。如果存在这样的边/路径,则更新顶点值。

让我们定义两个函数 distance(v)distance(s, t),其中 distance(v) 返回一个顶点的值,distance(s,t) 返回连接 st 的边的值。

在迭代 1 中,除约翰外的每个用户都被设置为无限大,约翰的距离为 0,因为他是源顶点。现在,我们使用 Pregel 遍历各个顶点,检查是否有比无限大更好的值。以 Ken 为例,我们将检查 distance("John") + distance("John", "Ken") < distance("Ken")

这相当于检查 0 + 5 < Infinity,结果是 true;所以我们将 Ken 的距离更新为 5

类似地,我们检查 Mary,distance("Ken") + distance("Ken", "Mary") < distance("Mary"),结果是false,因为那时 Ken 仍然是无限远。因此,在迭代 1 中,我们只能更新与约翰连接的用户。

在下一次迭代中,Mary、Liz、Eric 等人都会被更新,因为现在我们已经有了来自迭代 1 中的 Ken、Mark 和 Sam 的更新值。这将继续进行,直到达到 Pregel API 调用中指定的迭代次数。

以下是计算图中单源最短路径时,各个迭代步骤的示意图:

在经过四次迭代后,从约翰拉里的最短路径显示,最短路径为五英里。从约翰拉里的路径可以通过以下路径查看:约翰 | 马克 | 萨姆 | 拉里

PageRank

PageRank 是图处理领域最重要的算法之一。该算法源于 Google,以 Google 创始人 Larry Page 的名字命名,并且基于通过关系或边缘对顶点或节点进行排名的概念,已经发展出许多不同的应用场景。

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 的边表示 a 提升了 b 的值。例如,如果一个 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 和 Spark ML。此外,我们还将提供一些涵盖特征提取与转换、降维、回归和分类分析的示例。简而言之,本章将涵盖以下主题:

  • 机器学习简介

  • Spark 机器学习 API

  • 特征提取与转换

  • 使用 PCA 进行回归的降维

  • 二分类与多分类

机器学习简介

在这一部分,我们将从计算机科学、统计学和数据分析的角度尝试定义机器学习。机器学习(ML)是计算机科学的一个分支,它使计算机能够在没有明确编程的情况下学习(Arthur Samuel,1959 年)。这个研究领域源自人工智能中的模式识别和计算学习理论的研究。

更具体地说,机器学习探索了可以从启发式方法中学习并对数据进行预测的算法的研究和构建。这类算法通过根据样本输入构建模型,从而克服了严格静态的程序指令,通过数据驱动的预测或决策来工作。现在,让我们从计算机科学的角度听听 Tom M. Mitchell 教授对机器学习的更明确和多样化的定义:

如果一个计算机程序在任务类别 T 和性能度量 P 下,随着经验 E 的增加,其在 T 任务中的表现通过 P 测量得到改进,那么我们说该程序从经验 E 中学习。

基于这个定义,我们可以得出结论:计算机程序或机器可以:

  • 从数据和历史中学习

  • 通过经验得到提升

  • 互动地增强一个可以用来预测问题结果的模型

典型的机器学习任务包括概念学习、预测建模、聚类和寻找有用的模式。最终目标是改进学习,使其变得自动化,从而不再需要人工干预,或者尽可能减少人工干预的程度。尽管机器学习有时与知识发现和数据挖掘KDDM)混淆,但 KDDM 更多地侧重于探索性数据分析,并且被称为无监督学习。典型的机器学习应用可以分为科学知识发现和更多的商业应用,从机器人学或人机交互HCI)到反垃圾邮件过滤和推荐系统。

典型的机器学习工作流程

一个典型的机器学习应用涉及几个步骤,从输入、处理到输出,形成一个科学工作流程,如图 1所示。一个典型机器学习应用所涉及的步骤如下:

  1. 加载样本数据。

  2. 将数据解析为算法的输入格式。

  3. 对数据进行预处理并处理缺失值。

  4. 将数据分成两个集:一个用于构建模型(训练数据集),另一个用于测试模型(验证数据集)。

  5. 运行算法来构建或训练你的机器学习模型。

  6. 使用训练数据进行预测并观察结果。

  7. 使用测试数据集测试和评估模型,或者使用交叉验证技术,通过第三个数据集(称为验证数据集)来验证模型。

  8. 调整模型以提高性能和准确性。

  9. 扩展模型,使其能够在未来处理大规模数据集。

  10. 将机器学习模型投入商业化应用。

图 1: 机器学习工作流程

通常,机器学习算法有一些方法来处理数据集中的偏斜性。然而,这种偏斜性有时非常严重。在第 4 步中,实验数据集被随机分割,通常分为训练集和测试集,这个过程称为抽样。训练数据集用于训练模型,而测试数据集用于在最后评估最佳模型的表现。更好的做法是尽可能多地使用训练数据集,以提高泛化性能。另一方面,建议只使用一次测试数据集,以避免在计算预测误差和相关度量时出现过拟合问题。

机器学习任务

根据学习系统可获得的反馈类型,机器学习任务或过程通常分为三大类:有监督学习、无监督学习和强化学习,如图 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)。这种通过比较相似用户的观看和搜索习惯来推荐电影、电视剧或流媒体的推荐方式被称为评分预测。

  • 基于知识的系统,通过用户和产品的知识推理,了解哪些可以满足用户需求,使用感知树、决策支持系统和案例推理。

本章将讨论基于协同过滤的电影推荐系统。

半监督学习

在监督学习和无监督学习之间,有一个小的空间用于半监督学习。在这种情况下,机器学习模型通常会接收一个不完整的训练信号。从统计学的角度来看,机器学习模型接收到的训练集部分目标输出是缺失的。半监督学习或多或少是基于假设的,通常使用三种假设算法作为未标记数据集的学习算法。使用的假设有:平滑性、聚类和流形。换句话说,半监督学习也可以被称为弱监督学习,或者是利用未标记样本的隐藏信息来增强从少量标记数据中学习的自举技术。

如前所述,获取标注数据通常需要熟练的人工操作。因此,标注过程所涉及的成本可能会使得完全标注的训练集不可行,而获取未标注数据则相对便宜。

例如:转录音频片段、确定蛋白质的三维结构或确定某个特定位置是否有石油、期望最小化和人类认知以及传递性。在这种情况下,半监督学习可以具有很大的实际价值。

Spark 机器学习 API

在本节中,我们将介绍 Spark 机器学习库(Spark MLlib 和 Spark ML)所引入的两个关键概念,以及与我们在前面章节中讨论的监督学习和无监督学习技术相对应的最常用的已实现算法。

Spark 机器学习库

正如前面所说,在 Spark 出现之前,大数据建模人员通常使用统计语言如 R、STATA 和 SAS 来构建他们的机器学习模型。然而,这种工作流程(即这些机器学习算法的执行流程)缺乏效率、可扩展性和吞吐量,也缺乏准确性,当然,执行时间也较长。

然后,数据工程师通常会重新用 Java 实现相同的模型,例如部署到 Hadoop 上。使用 Spark,可以重建、采用并部署相同的机器学习模型,使整个工作流程更加高效、稳健和快速,从而提供实际的见解来提高性能。此外,在 Hadoop 中实现这些算法意味着这些算法可以并行运行,这是 R、STATA 和 SAS 等工具无法做到的。Spark 机器学习库分为两个包:Spark MLlib(spark.mllib)和 Spark ML(spark.ml)。

Spark MLlib

MLlib 是 Spark 的可扩展机器学习库,是 Spark 核心 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 的高级 API,用于构建 ML 管道。基本上,Spark ML 提供了一套工具集,帮助你在数据上构建不同的机器学习相关转换管道。例如,它可以轻松地将特征提取、降维和分类器训练链式组合成一个模型,整体上可以用于分类任务。然而,MLlib 是一个较旧的库,开发时间较长,因此它拥有更多的功能。因此,推荐使用 Spark ML,因为它的 API 在与 DataFrame 配合时更加灵活且功能多样。

特征提取与转换

假设你要构建一个机器学习模型,用于预测信用卡交易是否为欺诈交易。根据现有的背景知识和数据分析,你可能会决定哪些数据字段(即特征)对于训练你的模型是重要的。例如,金额、客户姓名、购买公司名称以及信用卡持有者的地址都值得提供用于整个学习过程。这些需要考虑,因为如果你只提供一个随机生成的交易 ID,它将不包含任何信息,因此完全没有用。因此,一旦你决定了训练集需要包含哪些特征,你就需要对这些特征进行转换,以便更好地训练模型。特征转换有助于你向训练数据中添加更多的背景信息。这些信息最终使机器学习模型能够从中受益。为了让上述讨论更加具体,假设你有以下客户地址字符串:

"123 Main Street, Seattle, WA 98101"

如果你看到上面的地址,发现这个地址缺乏适当的语义。换句话说,这个字符串的表达能力有限。这个地址只对学习与该精确地址相关的地址模式有用,例如,在数据库中进行学习时。但将其拆解为基本部分可以提供更多特征,例如:

  • "Address"(123 Main Street)

  • "City"(Seattle)

  • "State"(WA)

  • "Zip"(98101)

如果你看到上面的模式,机器学习算法现在可以将更多不同的交易归为一类,并发现更广泛的模式。这是正常现象,因为一些客户的邮政编码比其他人更容易发生欺诈活动。Spark 提供了若干实现的算法用于特征提取,并使转换更加简便。例如,当前版本提供了以下特征提取算法:

  • TF-IDF

  • Word2vec

  • CountVectorizer

另一方面,特征转换器是一个抽象,包含了特征转换器和学习模型。技术上,转换器实现了一个名为transform()的方法,该方法将一个 DataFrame 转换为另一个,通常通过附加一个或多个列。Spark 支持以下转换器到 RDD 或 DataFrame:

  • Tokenizer

  • 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(正则表达式,默认值:s+)作为分隔符来拆分输入文本。否则,你还可以将参数 gaps 设置为 false,表示正则表达式 pattern 表示 tokens 而不是拆分空隙。这样,你可以找到所有匹配的出现,作为分词的结果。

假设你有以下句子:

  • 分词(Tokenization)是从原始文本中提取单词的过程。

  • 如果你希望进行更高级的分词,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:从原始文本中分词后的单词

然而,如果您使用RegexTokenizer API,将获得更好的结果。过程如下:

通过实例化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参数指定。目前StopWordsRemover API 的实现支持丹麦语、荷兰语、芬兰语、法语、德语、匈牙利语、意大利语、挪威语、葡萄牙语、俄语、西班牙语、瑞典语、土耳其语和英语等语言。为了提供一个示例,我们可以简单地扩展前一节中的Tokenizer示例,因为它们已经被分词。然而,在这个示例中,我们将使用RegexTokenizer API。

首先,通过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 的数据框现在,我们希望对名称列进行索引,使得最常见的名称(在我们的示例中是 Jason)获得索引 0。为此,Spark 提供了StringIndexer API。对于我们的示例,可以按如下方式进行:首先,让我们为上面的表格创建一个简单的 DataFrame:pyval 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")现在,让我们按如下方式对名称列进行索引:pyval indexer = new StringIndexer() .setInputCol("name") .setOutputCol("label") .fit(df)现在,让我们通过 transformer 下游处理 indexer,如下所示:pyval indexed = indexer.transform(df)现在让我们检查是否正常工作:pyindexed.show(false)图 13:使用 StringIndexer 创建标签

另一个重要的转换器是 OneHotEncoder,它在处理类别数据的机器学习任务中经常被使用。我们将在下一节中学习如何使用这个转换器。

OneHotEncoder

独热编码将标签索引列映射到一个二进制向量列,最多只有一个值。这个编码方式使得像逻辑回归这样的算法能够使用类别特征,尽管它们期望的是连续特征。假设你有一些类别数据,格式如下(与我们在上一节中描述 StringIndexer 使用的格式相同):

图 14: 应用 OneHotEncoder 的 DataFrame

现在,我们想要对名字列进行索引,以便数据集中最频繁的名字(在我们的例子中是Jason)得到索引0。但是,仅仅进行索引有什么用呢?换句话说,你可以进一步将它们向量化,然后你可以轻松地将 DataFrame 输入任何机器学习模型。由于我们已经在上一节中学习了如何创建 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,用于轻松创建和调优实用的机器学习管道。正如前面所讨论的,通过特征工程在机器学习管道创建过程中提取有意义的知识,涉及数据收集、预处理、特征提取、特征选择、模型拟合、验证和模型评估等一系列阶段。例如,文本分类可能涉及文本分割和清洗、提取特征以及通过交叉验证调优的分类模型训练。大多数机器学习库并不设计用于分布式计算,或者它们不提供原生支持管道创建和调优。

数据集抽象

当从其他编程语言(例如 Java)运行 SQL 查询时,结果以 DataFrame 的形式返回。DataFrame 是一种分布式数据集合,组织成命名的列。另一方面,数据集(dataset)是一个接口,旨在提供 Spark SQL 中 RDD 的优势。数据集可以从一些 JVM 对象构建,比如基本类型(例如 StringIntegerLong)、Scala 案例类和 Java Beans。机器学习管道包含多个数据集转换和模型的序列。每个转换都接受一个输入数据集,并输出转换后的数据集,后者成为下一个阶段的输入。因此,数据导入和导出是机器学习管道的起点和终点。为了简化这些过程,Spark MLlib 和 Spark ML 提供了多种应用特定类型的数据集、DataFrame、RDD 和模型的导入导出工具,包括:

  • 用于分类和回归的 LabeledPoint

  • 用于交叉验证和潜在狄利克雷分配(LDA)的 LabeledDocument

  • 协同过滤的评分与排名

然而,实际数据集通常包含多种类型,如用户 ID、物品 ID、标签、时间戳和原始记录。不幸的是,当前 Spark 实现的工具不能轻松处理包含这些类型的数据集,特别是时间序列数据集。特征转换通常构成实际机器学习管道的主要部分。特征转换可以视为从现有列创建新列,并将其附加或删除。

在下图中,你将看到文本标记器将文档拆分成一个词袋。之后,TF-IDF 算法将词袋转换成特征向量。在转换过程中,标签需要保留以供模型拟合阶段使用:

图 16:机器学习模型的文本处理(DS 表示数据源)

在这里,ID、文本和单词在转换步骤中被包含。这些在做出预测和模型检查时很有用。然而,它们在模型拟合时实际上是多余的。如果预测数据集仅包含预测标签,它们也不会提供太多信息。因此,如果你想检查预测指标,如准确率、精确度、召回率、加权真正例和加权假正例,查看预测标签以及原始输入文本和标记化单词是非常有帮助的。这个建议同样适用于使用 Spark ML 和 Spark MLlib 的其他机器学习应用。

因此,RDD、数据集和 DataFrame 之间的轻松转换已使得内存、磁盘或外部数据源(如 Hive 和 Avro)成为可能。虽然通过用户定义的函数创建新列非常简单,但数据集的实现是一个惰性操作。相比之下,数据集只支持一些标准数据类型。然而,为了增加可用性,并使其更好地适应机器学习模型,Spark 还增加了对Vector类型的支持,作为一种支持稠密和稀疏特征向量的用户定义类型,位于mllib.linalg.DenseVectormllib.linalg.Vector下。

完整的 DataFrame、数据集和 RDD 的 Java、Scala 和 Python 示例可以在 Spark 分发包中的examples/src/main/文件夹中找到。有兴趣的读者可以参考 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,拟合的模型包含一个分词器、一个 HashingTF 特征提取器和一个拟合的逻辑回归模型。拟合的管道模型充当一个转换器,可以用于预测、模型验证、模型检查,最终是模型部署。然而,为了提高预测准确度,模型本身需要进行调优。

现在我们已经了解了 Spark MLlib 和 ML 中可用的算法,是时候在正式使用它们解决有监督和无监督学习问题之前做好准备了。在下一节中,我们将开始进行特征提取和转换。

无监督机器学习

在本节中,为了使讨论更具体,我们将只讨论使用 PCA 进行的降维和用于主题建模的 LDA 文本聚类。其他无监督学习的算法将在第十三章中讨论,我的名字是贝叶斯,朴素贝叶斯,并附带一些实际例子。

降维

降维是减少考虑变量数量的过程。它可以用来从原始和噪声特征中提取潜在特征,或者在保持结构的同时压缩数据。Spark MLlib 支持在RowMatrix类上进行降维。最常用的降维算法是 PCA 和 SVD。然而,在本节中,为了使讨论更具体,我们将仅讨论 PCA。

PCA

PCA 是一种统计程序,使用正交变换将可能相关的变量观察值转换为一组线性无关的变量,称为主成分。PCA 算法可以用来将向量投影到低维空间。然后,可以基于减少后的特征向量来训练一个机器学习模型。以下示例展示了如何将 6 维特征向量投影到四维主成分。假设你有一个如下的特征向量:

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)

上述代码生成了一个特征 DataFrame,包含 PCA 的 6 维特征向量:

图 18:为 PCA 创建特征 DataFrame(6 维特征向量)

现在,让我们通过设置必要的参数来实例化 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)

上述代码生成了一个特征 DataFrame,包含 4 维主成分特征向量,使用 PCA 方法:

图 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 维向量)解释变量(也称为自变量、输入变量、特征、观察数据、观测值、属性、维度、数据点等)之间的关系,使用的是线性函数。在回归分析中,目标是预测一个连续的目标变量,如下图所示:

图 21:回归算法的目的是产生连续的输出。输入可以是以下任何形式

离散或连续(来源:Nishant Shukla,《使用 TensorFlow 的机器学习》,Manning Publications 公司,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-measure来捕捉。

精确度表示被分类为正类的样本中有多少是相关的。另一方面,召回率表示测试在检测正类样本方面的效果如何?在二分类中,召回率也叫敏感度。需要注意的是,精确度可能不会随着召回率的提高而降低。召回率与精确度之间的关系可以在图表的阶梯区域中观察到:

  • 接收操作特征(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:计算多类分类器性能指标的数学公式

其中δ^(x)称为修改的 delta 函数,并可定义如下(来源:spark.apache.org/docs/2.1.0/mllib-evaluation-metrics.html):

使用逻辑回归进行二元分类

逻辑回归广泛用于预测二元响应。这是一种线性方法,可以数学表示如下:

在上述方程中,L(w; x, y) 是称为逻辑损失的损失函数。

对于二元分类问题,算法将输出一个二元逻辑回归模型。给定一个新数据点,表示为x,模型通过应用逻辑函数进行预测:

其中z = wTx*,默认情况下,如果*f(wTx)>0.5,则结果为正,否则为负,尽管与线性 SVM 不同,逻辑回归模型的原始输出f(z) 具有概率解释(即x 为正的概率)。

线性支持向量机(Linear SVM) 是一种全新的、极其快速的机器学习(数据挖掘)算法,专门用于解决来自超大数据集的多类别分类问题,它实现了一个原始的专有裁切平面算法,用于设计线性支持向量机(来源: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 转换为 DataFrame 以便用于 ML 流水线

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: 包含特征和标签的新 DataFrame 用于训练 ML 模型

步骤 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 被选择为 枢轴 类。

对于多分类问题,算法将输出一个多项式逻辑回归模型,该模型包含 k−1 个二元 逻辑回归模型,与第一类进行回归。给定一个新的数据点,k−1 个模型 将运行,具有最大概率的类将被选择为预测类。在本节中,我们将展示一个使用 L-BFGS 逻辑回归的分类示例,以便更快的收敛。

步骤 1. 加载并解析 MNIST 数据集(LIVSVM 格式)

// 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 或 RDF 中的森林通常由数十万棵树组成。这些树实际上是基于同一个训练集的不同部分进行训练的。更技术性地讲,生长得非常深的单棵树往往倾向于学习高度不可预测的模式。树木的这种特性会在训练集上造成过拟合问题。此外,低偏差使得分类器即使在数据集的特征质量良好的情况下,性能仍然较低。另一方面,RF 通过将多个决策树平均化,目的是通过计算样本对之间的相似度来减少方差,确保一致性。

然而,这会增加一点偏差或在结果可解释性上的一些损失。但最终,最终模型的性能会显著提高。在将 RF 作为分类器使用时,参数设置如下:

  • 如果树的数量为 1,则完全不使用自助采样;然而,如果树的数量 > 1,则会使用自助采样。支持的值包括 autoallsqrtlog2onethird

  • 支持的数值范围是 (0.0-1.0][1-n]。然而,如果选择 featureSubsetStrategyauto,算法会自动选择最佳的特征子集策略。

  • 如果 numTrees == 1,则 featureSubsetStrategy 被设置为 all。但是,如果 numTrees > 1(即森林),则 featureSubsetStrategy 被设置为分类时的 sqrt

  • 此外,如果一个实数 n 设置在范围 (0, 1.0] 内,则会使用 n*number_of_features。然而,如果整数值 nrange (1, 特征数量) 内,则仅使用 n 个特征,交替进行选择。

  • categoricalFeaturesInfo 参数是一个映射,用于存储任意的分类特征。条目 (n -> k) 表示特征 n 是分类的,且有 k 个类别,索引范围为 0: {0, 1,...,k-1}

  • 纯度标准仅用于信息增益的计算。分类和回归分别支持 ginivariance 作为纯度标准。

  • maxDepth 是树的最大深度(例如,深度 0 表示 1 个叶节点,深度 1 表示 1 个内部节点 + 2 个叶节点,以此类推)。

  • maxBins 表示用于拆分特征的最大箱数,建议值为 100,以获得更好的结果。

  • 最后,随机种子用于自助法(bootstrapping)和选择特征子集,以避免结果的随机性。

如前所述,由于随机森林(RF)对大规模数据集来说足够快速且可扩展,Spark 是实现随机森林以应对大规模扩展的合适技术。然而,如果计算了相似度,存储需求也会呈指数级增长。

使用随机森林分类 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 开发一个可扩展的推荐系统,这是一种基于模型的推荐算法的例子。最后,我们将展示一个作为文本聚类技术的主题建模应用。

简而言之,我们将在本章中涵盖以下主题:

  • 机器学习最佳实践

  • 机器学习模型的超参数调优

  • 使用潜在狄利克雷分配(LDA)进行主题建模

  • 使用协同过滤的推荐系统

机器学习最佳实践

有时候,建议考虑误差率,而不仅仅是准确率。例如,假设一个机器学习系统的准确率为 99%,但误差率为 50%,这比一个准确率为 90%但误差率为 25%的系统更差。到目前为止,我们已经讨论了以下机器学习主题:

  • 回归:这是用于预测线性可分的值

  • 异常检测:这是用于寻找不寻常的数据点,通常通过使用聚类算法来实现

  • 聚类:这是用于发现数据集中隐藏的结构,以便对同质数据点进行聚类

  • 二分类:这是用于预测两类类别的任务

  • 多类分类:这是用于预测三个或更多类别的任务

我们也看到了一些适合这些任务的优秀算法。然而,选择正确的算法来解决你的问题类型,对于实现更高和卓越的准确性来说是一个具有挑战性的任务。为此,我们需要在各个阶段采取一些好的实践,也就是说,从数据收集、特征工程、模型构建、评估、调优到部署。考虑到这些,在本节中,我们将提供一些实践性的建议,帮助你在使用 Spark 开发机器学习应用时更为高效。

小心过拟合和欠拟合

一条穿过弯曲散点图的直线可以作为欠拟合的一个典型例子,如图所示。然而,如果这条直线过于贴合数据,就会出现一个相反的问题,称为过拟合。当我们说一个模型对数据集发生过拟合时,我们的意思是它可能在训练数据上的误差率很低,但却无法很好地对数据中的总体分布进行泛化。

图 1:过拟合与欠拟合的权衡(来源:《深度学习》一书,作者:Adam Gibson, Josh Patterson)

更技术性地说,如果你在训练数据上评估你的模型,而不是在测试数据或验证数据上进行评估,你可能无法明确指出模型是否发生了过拟合。常见的症状如下:

  • 用于训练的数据的预测准确度可能过于准确(也就是说,有时甚至达到 100%)。

  • 相较于随机预测,模型可能在新数据上表现出更好的性能。

  • 我们喜欢将数据集拟合到某个分布,因为如果数据集与该分布较为接近,我们可以基于该理论分布作出假设,以指导我们如何处理数据。因此,数据中的正态分布使我们可以假设,在指定条件下,统计量的抽样分布是正态分布。正态分布由其均值和标准差定义,通常在所有变体中形状相同。

图 2:数据中的正态分布有助于克服过拟合和欠拟合(来源:《深度学习》一书,作者:Adam Gibson, Josh Patterson)

有时,机器学习模型本身会在某些特定的调参或数据点上发生欠拟合,这意味着模型变得过于简化。我们的建议(如同其他人的观点一样)如下:

  • 将数据集拆分为两部分以检测过拟合情况——第一部分用于训练和模型选择,称为训练集;第二部分是用于评估模型的测试集,取代机器学习工作流部分中的评估步骤。

  • 另外,你也可以通过使用更简单的模型(例如,优先选择线性分类器而非高斯核 SVM)或通过增加机器学习模型的正则化参数(如果可用)来避免过拟合。

  • 调整模型的参数值,以避免过拟合和欠拟合。

  • 因此,解决欠拟合是首要任务,但大多数机器学习从业者建议投入更多的时间和精力,避免将模型过拟合到数据上。另一方面,许多机器学习从业者推荐将大规模数据集分成三个部分:训练集(50%)、验证集(25%)和测试集(25%)。他们还建议使用训练集构建模型,并使用验证集计算预测误差。测试集则建议用于评估最终模型的泛化误差。如果在监督学习过程中可用的标记数据较少,则不推荐拆分数据集。在这种情况下,使用交叉验证。更具体地说,将数据集分成 10 个(大致)相等的部分;然后,对于这 10 个部分中的每一个,迭代训练分类器,并使用第十部分来测试模型。

请关注 Spark MLlib 和 Spark ML

管道设计的第一步是创建构建模块(作为由节点和边组成的有向或无向图),并在这些模块之间建立链接。然而,作为数据科学家,你也应该专注于对节点(基本元素)进行扩展和优化,以便能够在后续阶段扩展你的应用程序,以处理大规模数据集,使得你的 ML 管道能够稳定地执行。管道过程还将帮助你使模型适应新数据集。不过,这些基本元素中的一些可能会显式地定义为特定领域和数据类型(例如文本、图像、视频、音频以及时空数据)。

除了这些类型的数据外,基本元素还应该适用于通用领域的统计或数学。将你的 ML 模型以这些基本元素的形式表示,将使你的工作流程更加透明、可解释、可访问且易于解释。

最近的一个例子是 ML-matrix,这是一个分布式矩阵库,可以在 Spark 上使用。请参考 JIRA 问题:issues.apache.org/jira/browse/SPARK-3434

图 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 机器学习库找到许多机器学习数据集。以下数据属性也应优先考虑:

  • 参数数量

  • 特征数量

  • 训练数据集的大小

参数数量

参数或数据属性是数据科学家在设置算法时的抓手。它们是影响算法性能的数字,比如误差容忍度、迭代次数,或者算法行为变体之间的选择。算法的训练时间和准确性有时会非常敏感,这使得找到正确的设置变得困难。通常,具有大量参数的算法需要更多的试验和错误来找到最优的组合。

尽管这是跨越参数空间的一个好方法,但随着参数数量的增加,模型构建或训练时间呈指数增长。这既是一个困境,也是时间与性能之间的权衡。其积极方面是:

  • 拥有许多参数通常表示机器学习算法具有更大的灵活性

  • 你的机器学习应用实现了更好的准确度

你的训练集有多大?

如果你的训练集较小,低偏差且低方差的分类器,如朴素贝叶斯,在低偏差且高方差的分类器(如k-最近邻算法kNN))上具有优势(也可以用于回归)。

偏差、方差与 kNN 模型: 实际上,增加 k减少方差,但增加偏差。另一方面,减少 k增加方差减少偏差。随着k的增加,这种变异性被减小。但如果我们过度增加k,那么我们就不再遵循真实的边界线,观察到的是较高的偏差。这就是偏差-方差权衡的本质。

我们已经看到过拟合和欠拟合的问题了。现在,你可以假设,处理偏差和方差就像处理过拟合和欠拟合一样。随着模型复杂度的增加,偏差减少,而方差增加。随着模型中参数的增多,模型的复杂度上升,方差成为我们主要关注的问题,而偏差则稳步下降。换句话说,偏差对模型复杂度的导数为负,而方差则有正斜率。请参考下图以便更好地理解:

图 4: 偏差和方差对总误差的贡献

因此,后者将会过拟合。然而,低偏差且高方差的分类器则在训练集线性或指数增长时开始占据优势,因为它们具有较低的渐近误差。高偏差的分类器则不足以提供准确的模型。

特征数量

对于某些类型的实验数据集,提取的特征数可能会比数据点本身的数量大得多。通常在基因组学、生物医学或文本数据中会出现这种情况。大量的特征可能会拖慢一些学习算法的速度,使得训练时间极度增加。支持向量机SVM)在这种情况下特别适用,因为它具有较高的准确性,关于过拟合的良好理论保证,并且使用合适的核函数。

支持向量机与核函数:任务是找到一组权重和偏置,使得可以最大化边距的函数:

y = w*¥(x) + b,

其中 w 是权重,¥ 是特征向量,b 是偏置。现在如果 y > 0,那么我们将数据分类为 1 类,反之为 0 类,而特征向量 ¥(x) 使得数据线性可分。然而,使用核函数使得计算过程更快、更简便,尤其当特征向量 ¥ 包含高维数据时。我们来看一个具体的例子。假设我们有以下 xy 的值:x = (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

这是一种简单的线性代数操作,将三维空间映射到九维空间。另一方面,核函数是用于支持向量机的相似性度量。因此,建议根据对不变性的先验知识选择合适的核函数值。核函数、核函数参数和正则化参数的选择可以通过优化基于交叉验证的模型选择来自动化。

然而,自动选择核函数及其参数是一个棘手的问题,因为很容易导致模型选择标准的过拟合。这可能导致一个比最初更差的模型。现在,如果我们使用核函数 K(x, y),它给出的结果与传统计算相同,但计算过程要简单得多——即 (4 + 10 + 18) ² = 32² = 1024。

机器学习模型的超参数调优

调整算法参数是一个过程,通过这个过程可以让算法在运行时间和内存使用上表现得最优。在贝叶斯统计中,超参数是先验分布的一个参数。在机器学习中,超参数指的是那些不能通过常规训练过程直接学习到的参数。超参数通常在实际训练过程开始之前就已经确定。这是通过为这些超参数设置不同的值,训练不同的模型,然后通过测试它们来决定哪些效果最好。以下是一些典型的超参数示例:

  • 树的叶节点数、箱数或深度

  • 迭代次数

  • 矩阵分解中的潜在因子数

  • 学习率

  • 深度神经网络中的隐藏层数

  • k-means 聚类中的聚类数量,等等。

本节将讨论如何使用交叉验证技术和网格搜索进行超参数调优。

超参数调优

超参数调优是一种选择合适超参数组合的技术,基于呈现数据的性能。它是从实际机器学习算法中获得有意义且准确结果的基本要求之一。下图展示了模型调优过程、考虑因素和工作流程:

图 5:模型调优过程、考虑因素和工作流程

例如,假设我们有两个超参数需要调节,来自第十一章的图 17学习机器学习 - Spark MLlib 和 Spark ML,一个使用逻辑回归估计器的 Spark ML 管道模型(虚线仅出现在管道拟合过程中)。我们可以看到,我们为每个超参数提供了三个候选值。因此,总共有九种组合。然而,图中只显示了四个,即 Tokenizer、HashingTF、Transformer 和逻辑回归(LR)。现在,我们希望找到最终能得到最佳评估结果的模型。拟合后的模型包括 Tokenizer、HashingTF 特征提取器和拟合后的逻辑回归模型:

如果你回想一下来自第十一章的图 17学习机器学习 - Spark MLlib 和 Spark ML,那么虚线仅出现在管道拟合过程中。如前所述,拟合后的管道模型是一个 Transformer。Transformer 可以用于预测、模型验证和模型检查。此外,我们还提到,机器学习算法的一个不幸的特点是,通常它们有许多超参数需要调节以提高性能。例如,这些超参数中的正则化程度,与由 Spark MLlib 优化的模型参数是不同的。

因此,没有专家知识的情况下,很难猜测或测量出最佳的超参数组合,因为需要使用的数据和算法类型都很复杂。由于复杂的数据集是基于机器学习问题类型的,管道的规模和超参数的数量可能会呈指数级(或线性)增长;即使对于机器学习专家来说,超参数调优也变得繁琐,更不用说调优结果可能变得不可靠了。

根据 Spark API 文档,Spark ML 的估算器和转换器都使用一个唯一且统一的 API 来指定。ParamMap是由一组(参数,值)对组成的,每个 Param 是一个具有自包含文档的命名参数,由 Spark 提供。技术上,有两种方式可以将参数传递给算法,如下所述:

  • 设置参数:如果 LR 是逻辑回归(即 Estimator)的一个实例,你可以调用setMaxIter()方法,如下所示:LR.setMaxIter(5)。这基本上是指向回归实例的模型拟合,如下所示:LR.fit()。在这个特定示例中,最多会进行五次迭代。

  • 第二个选项:这个选项涉及将ParamMaps传递给fit()transform()(有关详细信息,请参见图 5)。在这种情况下,任何参数都会被之前通过 setter 方法在 ML 应用程序特定代码或算法中指定的ParamMaps覆盖。

网格搜索参数调优

假设在进行必要的特征工程后,你选择了你的超参数。在这种情况下,全面的网格搜索超参数和特征的空间计算开销过大。因此,你需要在 K 折交叉验证的折叠中执行一次,而不是完全的网格搜索:

  • 使用交叉验证对训练集的折叠进行超参数调优,使用所有可用的特征

  • 使用这些超参数选择所需的特征

  • 对 K 的每个折叠重复计算

  • 最终模型使用所有数据构建,使用从每个 CV 折叠中选择的 N 个最常见的特征

有趣的是,超参数也会在交叉验证循环中使用所有数据再次调优。与完全网格搜索相比,这种方法会带来较大的缺点吗?本质上,我在每个自由参数的维度上进行线性搜索(先在一个维度上找到最佳值,保持常数,然后在下一个维度上找到最佳值),而不是每一种参数设置的所有组合。沿单个参数进行搜索,而不是一起优化所有参数的最大缺点,就是你忽略了它们之间的交互作用。

举例来说,多个参数通常会影响模型的复杂度。在这种情况下,你需要查看它们之间的交互作用,以便成功地优化超参数。根据数据集的大小以及比较的模型数量,返回最大观察性能的优化策略可能会遇到问题(无论是网格搜索还是你的策略,都存在此问题)。

其原因是,搜索大量性能估计以找到最大值会削减性能估计的方差:你可能会最终得到一个看似不错的模型和训练/测试拆分组合。更糟的是,你可能会得到多个看似完美的组合,然后优化过程就无法判断选择哪个模型,从而变得不稳定。

交叉验证

交叉验证(也称为旋转估计RE))是一种用于评估统计分析和结果质量的模型验证技术。其目标是使模型能够泛化到独立的测试集上。交叉验证技术的一个完美应用是从机器学习模型进行预测。它能帮助你估计当你将预测模型部署为机器学习应用时,模型在实际中的表现如何。交叉验证过程中,通常使用已知类型的数据集来训练模型,反之,则使用未知类型的数据集进行测试。

在这方面,交叉验证有助于描述数据集,以便在训练阶段使用验证集来测试模型。有两种交叉验证类型,如下所述:

  • 穷尽交叉验证:包括留 P 交叉验证和留一交叉验证。

  • 非穷尽交叉验证:包括 K 折交叉验证和重复随机子抽样交叉验证。

在大多数情况下,研究人员/数据科学家/数据工程师使用 10 折交叉验证,而不是在验证集上进行测试。这是跨用例和问题类型中最广泛使用的交叉验证技术,正如下图所示:

图 6:交叉验证基本上将你的完整可用训练数据分成若干折。这个参数是可以指定的。然后,整个管道会对每一折运行一次,并且每一折都会训练一个机器学习模型。最后,通过投票方案对分类器进行联合,或者通过平均对回归进行联合。

此外,为了减少变异性,会使用不同的分区进行多次交叉验证迭代;最后,验证结果会在各轮中取平均值。下图展示了使用逻辑回归进行超参数调优的示例:

图 7:使用逻辑回归进行超参数调优的示例

使用交叉验证代替常规验证有两个主要优点,概述如下:

  • 首先,如果可用的数据不足以在单独的训练集和测试集之间进行划分,就可能会失去重要的建模或测试能力。

  • 其次,K 折交叉验证估计器的方差低于单一的保留集估计器。较低的方差限制了变动,这在数据量有限时尤为重要。

在这种情况下,合理的估算模型预测和相关性能的公平方式是使用交叉验证作为一种强大的模型选择和验证技术。如果我们需要手动选择特征和参数调优,那么在此之后,我们可以对整个数据集进行 10 折交叉验证来评估模型。什么策略最合适?我们建议你选择一种提供乐观评分的策略,具体如下:

  • 将数据集分为训练集,例如 80%,和测试集 20%或其他你选择的比例

  • 使用 K 折交叉验证在训练集上调优模型

  • 重复交叉验证,直到找到优化并调优的模型。

现在,使用你的模型对测试集进行预测,以估算模型误差。

信用风险分析 – 超参数调优的示例

在本节中,我们将展示机器学习超参数调优的实际示例,涉及网格搜索和交叉验证技术。更具体地说,首先,我们将开发一个信用风险管道,该管道在银行和信用合作社等金融机构中常见。随后,我们将探讨如何通过超参数调优提高预测准确性。在深入示例之前,让我们快速概述一下什么是信用风险分析,以及它为何重要?

什么是信用风险分析?它为何重要?

当申请人申请贷款并且银行收到该申请时,基于申请人的资料,银行需要决定是否批准该贷款申请。在这方面,银行在贷款申请决策时面临两种类型的风险:

  • 申请人属于信用风险较低:这意味着客户或申请人更有可能偿还贷款。如果贷款未被批准,银行可能会因此失去业务。

  • 申请人属于信用风险较高:这意味着客户或申请人最有可能无法偿还贷款。在这种情况下,批准贷款将导致银行遭受财务损失。

该机构表示,第二种情况比第一种情况更具风险,因为银行更有可能无法收回借款。因此,大多数银行或信用合作社会评估向客户、申请人或顾客借款所涉及的风险。在商业分析中,最小化风险往往能最大化银行自身的利润。

换句话说,从财务角度来看,最大化利润并最小化损失非常重要。银行通常会根据申请人的不同因素和参数(例如有关贷款申请的各类人口和社会经济条件)来决定是否批准贷款申请。

数据集探索

德国信用数据集从 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 案例类解析数据集:

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 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 管道的数据框架 - 获取 ML 管道的数据框架

val sqlContext = new SQLContext(sc) 
import sqlContext._ 
import sqlContext.implicits._ 
val creditDF = creditRDD.toDF().cache() 

将它们保存为临时视图,以便于查询:

creditDF.createOrReplaceTempView("credit") 

让我们来看看这个数据框的快照:

creditDF.show

上述 show() 方法打印了信贷数据框:

图 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. 特征向量和标签的创建 - 如你所见,credibility 列是响应列,结果时,我们需要创建特征向量,而不考虑该列。现在,让我们按照以下方式创建特征列:

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) 

让我们查看这个数据框的前 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 是一家美国娱乐公司,由 Reed Hastings 和 Marc Randolph 于 1997 年 8 月 29 日在加利福尼亚州的 Scotts Valley 创立。它专注于并提供在线流媒体和视频点播服务,以及通过邮寄方式提供 DVD。2013 年,Netflix 扩展到电影和电视制作以及在线分发。到 2017 年,该公司将总部设在加利福尼亚州的 Los Gatos(来源:维基百科)。Netflix 是一个实时电影推荐的推荐系统。在本节中,我们将看到一个完整的例子,了解它如何为新用户推荐电影。

基于 Spark 的模型推荐

Spark MLlib 中的实现支持基于模型的协同过滤。在基于模型的协同过滤技术中,用户和产品通过一小组因子来描述,这些因子也称为 潜在因子 (LFs)。从下图中,你可以对不同的推荐系统有一些了解。图 13 说明了为什么我们要使用基于模型的协同过滤进行电影推荐示例:

图 13:不同推荐系统的对比视图

然后使用 LF 来预测缺失的条目。Spark API 提供了交替最小二乘法(也称为 ALS)算法的实现,该算法用于通过考虑六个参数来学习这些潜在因子,包括:

  • numBlocks:这是用于并行计算的块数(设置为 -1 以自动配置)。

  • rank:这是模型中潜在因子的数量。

  • iterations:这是 ALS 运行的迭代次数。ALS 通常在 20 次迭代或更少的次数内收敛到一个合理的解。

  • lambda:这指定了 ALS 中的正则化参数。

  • implicitPrefs:该参数指定是否使用 显式反馈 ALS 变体,或者使用适应于 隐式反馈 数据的变体。

  • alpha:这是一个适用于 ALS 隐式反馈变体的参数,控制在偏好观测中的 基线 信心。

请注意,要构建具有默认参数的 ALS 实例;您可以根据需求设置该值。默认值如下:numBlocks: -1rank: 10iterations: 10lambda: 0.01implicitPrefs: falsealpha: 1.0

数据探索

电影及其相应的评分数据集是从 MovieLens 网站下载的 (movielens.org)。根据 MovieLens 网站上的数据描述,所有评分都记录在 ratings.csv 文件中。该文件中的每一行(紧跟在标题之后)代表一位用户对一部电影的评分。

CSV 数据集包含以下列:userIdmovieIdratingtimestamp,如 图 14 所示。行按 userId 排序,用户内再按 movieId 排序。评分是基于五颗星的评分标准,每次增量为半颗星(从 0.5 星到 5.0 星)。时间戳表示自 1970 年 1 月 1 日午夜协调世界时(UTC)以来的秒数,我们有来自 668 个用户对 10,325 部电影的 105,339 条评分:

图 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)

该代码段应返回评分的 DataFrame。另一方面,以下代码段显示了电影的 DataFrame:

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. 将两个 DataFrame 注册为临时表以便于查询 - 为了注册这两个数据集,我们可以使用以下代码:

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 对 10,325 部电影进行了 105,339 次评分。现在,让我们获取最大和最小评分,并统计评分电影的用户数。为此,你需要在前一步创建的内存中的评分表上执行 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)

你应该会发现训练集中有 78,792 条评分,测试集中有 26,547 条评分

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 使用迭代的 mini-batch 采样进行在线变分推断,通常更节省内存。LDA 输入一组文档,作为词频向量,并使用以下参数(通过构建器模式设置):

  • k:主题数量(即聚类中心)。

  • optimizer:用于学习 LDA 模型的优化器,可以是EMLDAOptimizerOnlineLDAOptimizer

  • docConcentration:Dirichlet 参数,用于定义文档在主题分布上的先验。较大的值有助于生成更平滑的推断分布。

  • topicConcentration:Dirichlet 参数,用于定义主题在词汇(单词)上的分布的先验。较大的值有助于生成更平滑的推断分布。

  • maxIterations:迭代次数的限制。

  • checkpointInterval:如果使用检查点(在 Spark 配置中设置),该参数指定创建检查点的频率。如果maxIterations值较大,使用检查点可以帮助减少磁盘上 shuffle 文件的大小,并有助于故障恢复。

特别地,我们希望讨论从大量文本集合中,人们最常谈论的主题。自 Spark 1.3 版本发布以来,MLlib 支持 LDA,这是一种在文本挖掘和自然语言处理NLP)领域广泛使用的主题建模技术。此外,LDA 也是第一个采用 Spark GraphX 的 MLlib 算法。

要了解有关 LDA 背后理论的更多信息,请参阅 David M. Blei、Andrew Y. Ng 和 Michael I. Jordan 的《潜在狄利克雷分配》,机器学习研究期刊 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()优化器。不过,你需要在MiniBatchFraction中加入(1.0/actualCorpusSize),以便在小数据集上更加健壮。整个操作如下。首先,按以下方式实例化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案例类用于定义训练 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 模型作为分布式版本的实例时的平均对数似然。我们得到了以下输出:

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。该主题讨论了诸如 love、long、shore、shower、ring、bring、bear 等词汇。现在,为了更好地理解流程,以下是完整的源代码:

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 工作节点 EC2 集群,例如,M4.large 或 M3.medium,具体取决于预算和需求

对于前述设置,10 次迭代的平均时间结果为 176 秒/迭代。从这些统计数据可以看出,LDA 对于非常大规模的语料库也具有很好的可扩展性。

总结

在这一章中,我们提供了有关 Spark 中一些高级机器学习主题的理论和实践方面的内容。我们还提供了关于机器学习最佳实践的一些建议。接下来,我们展示了如何使用网格搜索、交叉验证和超参数调优来调优机器学习模型,以获得更好和更优化的性能。在后续部分,我们展示了如何使用 ALS 开发一个可扩展的推荐系统,这是一个基于模型的协同过滤方法的模型推荐系统示例。最后,我们展示了如何开发一个文本聚类技术的主题建模应用。

有兴趣了解更多机器学习最佳实践的读者,可以参考名为《Spark 大规模机器学习》的书籍,网址为www.packtpub.com/big-data-and-business-intelligence/large-scale-machine-learning-spark

在下一章中,我们将进入 Spark 的更高级应用。尽管我们已经讨论并提供了二分类和多分类的对比分析,但我们将进一步了解其他 Spark 中的多项式分类算法,如朴素贝叶斯、决策树和一对多分类器(One-vs-Rest)。

第十三章:我的名字是贝叶斯,朴素贝叶斯

"预测是非常困难的,尤其是当它涉及未来时"

-尼尔斯·玻尔

机器学习(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 个正类的概率估计。比较它们,最大概率对应的类别即为该样本所属类别。Spark 提供了通过 OVTR 算法将多类问题转换为二分类问题,其中 逻辑回归 算法被用作基础分类器。

现在我们来看看另一个真实数据集的示例,演示 Spark 如何使用 OVTR 算法对所有特征进行分类。OVTR 分类器最终预测来自 光学字符识别 (OCR) 数据集的手写字符。然而,在深入演示之前,我们先来探索一下 OCR 数据集,以了解数据的探索性特征。需要注意的是,当 OCR 软件首次处理文档时,它会将纸张或任何物体划分为一个矩阵,使得网格中的每个单元格都包含一个字形(也称为不同的图形形状),这仅仅是对字母、符号、数字或任何来自纸张或物体的上下文信息的详细描述方式。

为了演示 OCR 流水线,假设文档仅包含与 26 个大写字母(即 AZ)匹配的英文字母字符,我们将使用来自 UCI 机器学习数据库 的 OCR 字母数据集。该数据集由 W* Frey* 和 D. J. Slate 提供。在探索数据集时,您应该会看到 20,000 个示例,包含 26 个英文字母的大写字母。大写字母通过 20 种不同的、随机重塑和扭曲的黑白字体作为图形呈现,具有不同的形状。简而言之,从 26 个字母中预测所有字符将问题本身转化为一个具有 26 个类别的多类分类问题。因此,二分类器将无法达到我们的目的。

图 1: 一些打印字形(来源:使用荷兰风格自适应分类器的字母识别,ML,第 6 卷,第 161-182 页,W. Frey 和 D.J. Slate [1991])

上图显示了我之前解释过的图像。数据集 提供了经过这种方式扭曲的打印字形的示例,因此这些字母对计算机来说具有挑战性,难以识别。然而,人类可以轻松识别这些字形。以下图展示了前 20 行的统计属性:

图 2: 数据集的快照,显示为数据框

OCR 数据集的探索与准备

根据数据集描述,字形通过 OCR 阅读器扫描到计算机上,然后它们会被自动转换为像素。因此,所有 16 个统计属性(见图 2)也会记录到计算机中。黑色像素在框的各个区域中的浓度提供了一种方法,可以通过 OCR 或经过训练的机器学习算法来区分 26 个字母。

回想一下,支持向量机SVM)、逻辑回归、朴素贝叶斯分类器或任何其他分类器算法(连同它们的学习器)都要求所有特征都是数字格式。LIBSVM 允许你使用稀疏训练数据集,以非传统格式存储数据。在将正常的训练数据集转换为 LIBSVM 格式时,只有数据集中的非零值才会以稀疏数组/矩阵的形式存储。索引指定实例数据的列(特征索引)。然而,任何缺失的数据也会被视为零值。索引用于区分不同的特征/参数。例如,对于三个特征,索引 1、2 和 3 分别对应于 xyz 坐标。不同数据实例中相同索引值之间的对应关系仅在构造超平面时才是数学上的;这些值作为坐标。如果跳过了中间的任何索引,它应被默认赋值为零。

在大多数实际情况下,我们可能需要对所有特征点进行数据归一化。简而言之,我们需要将当前的制表符分隔 OCR 数据转换为 LIBSVM 格式,以便简化训练步骤。因此,我假设你已经下载了数据并使用他们的脚本转换为 LIBSVM 格式。转换为 LIBSVM 格式后,数据集包含标签和特征,如下图所示:

图 3: LIBSVM 格式的 OCR 数据集 20 行快照

有兴趣的读者可以参考以下研究文章以深入了解:Chih-Chung ChangChih-Jen LinLIBSVM:一个支持向量机库ACM Intelligent Systems and Technology Transactions,2:27:1--27:27,2011。你还可以参考我在 GitHub 仓库提供的公共脚本,地址是 github.com/rezacsedu/RandomForestSpark/,该脚本可以将 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 不得不牺牲一些准确性。

层次分类

在层次分类任务中,分类问题可以通过将输出空间划分为树的方式来解决。在这棵树中,父节点被划分为多个子节点。这个过程一直持续,直到每个子节点代表一个单一的类别。基于层次分类技术,已经提出了几种方法。计算机视觉就是一个典型的例子,其中识别图片或书写文本是使用层次处理的应用。关于这种分类器的详细讨论超出了本章的范围。

从二分类到多分类的扩展

这是一种将现有的二分类器扩展到多类分类问题的技术。为了解决多类分类问题,基于神经网络、决策树(DT)、随机森林、k 近邻、朴素贝叶斯和支持向量机(SVM)等算法已经提出并开发出来。在接下来的章节中,我们将讨论朴素贝叶斯和决策树算法,作为该类别的两个代表。

现在,在使用朴素贝叶斯算法解决多类分类问题之前,让我们在下一节中简要回顾一下贝叶斯推理。

贝叶斯推理

在这一节中,我们将简要讨论贝叶斯推理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%

结果是,大多数人也是快乐的!很好。为了更清楚地说明,现在假设世界人口为 1000,为了简化计算。然后,根据我们的计算,存在以下两个事实:

  • 事实 1:这告诉我们 400 人是快乐的,而哈佛的研究表明,这些快乐的人中有 40 个人也是富人。

  • 事实 2:共有 50 个富人,因此其中快乐的比例是 40/50 = 80%。

这证明了贝叶斯定理及其有效性。然而,更全面的示例可以在 onlinecourses.science.psu.edu/stat414/node/43 找到。

我的名字是 Bayes,Naive Bayes。

我是 Bayes,Naive Bayes(NB)。我是一个成功的分类器,基于 最大后验MAP)的原理。作为分类器,我具有高度的可扩展性,需要的参数数目与学习问题中的变量(特征/预测因子)数量成线性关系。我有几个特点,例如,我计算更快,如果你雇佣我来分类,我很容易实现,并且我可以很好地处理高维数据集。此外,我可以处理数据集中的缺失值。尽管如此,我是可适应的,因为模型可以在不重新构建的情况下用新的训练数据进行修改。

在贝叶斯统计中,MAP 估计是未知量的估计,它等于后验分布的众数。MAP 估计可以用于基于经验数据获取一个未观察量的点估计。

听起来像詹姆斯·邦德的电影情节吗?嗯,你/我们可以将分类器看作是 007 特工,对吧?开玩笑的。我相信我并不像 Naive Bayes 分类器的参数,因为它们像先验和条件概率一样,是通过一套确定的步骤来学习或决定的:这涉及到两个非常简单的操作,这在现代计算机上可以非常快速地执行,即计数和除法。没有迭代。没有周期。没有代价方程的优化(代价方程通常比较复杂,平均而言至少是三次方或二次方复杂度)。没有误差反向传播。没有涉及解矩阵方程的操作。这些使得 Naive Bayes 及其整体训练更加高效。

然而,在雇用此代理之前,您/我们可以发现它的优缺点,以便我们只利用它的优势像王牌一样使用它。好了,这里有一张表格总结了这个代理的优缺点:

代理 优点 缺点 更适合
Naive Bayes (NB) - 计算速度快 - 实现简单 - 对高维度数据效果好 - 可以处理缺失值 - 训练模型所需数据量小 - 可扩展 - 可适应性强,因为可以通过新增训练数据来修改模型,而无需重新构建模型 - 依赖于独立性假设,因此如果假设不成立,表现会差 - 准确率较低 - 如果某个类标签和某个特征值一起没有出现,那么基于频率的概率估计会是零 - 当数据中有大量缺失值时 - 当特征之间的依赖关系相似时 - 垃圾邮件过滤和分类 - 对新闻文章进行分类(如技术、政治、体育等) - 文本挖掘

表格 1: Naive Bayes 算法的优缺点

使用 NB 构建可扩展分类器

在本节中,我们将通过一步一步的示例来展示如何使用 Naive BayesNB)算法。正如前面所述,NB 具有很强的可扩展性,所需的参数数量与学习问题中变量(特征/预测因子)的数量成线性关系。这种可扩展性使得 Spark 社区能够使用该算法在大规模数据集上进行预测分析。Spark MLlib 中当前的 NB 实现支持多项式 NB 和 Bernoulli NB。

如果特征向量是二进制的,Bernoulli NB 是非常有用的。一个应用场景是使用词袋(BOW)方法进行文本分类。另一方面,多项式 NB 通常用于离散计数。例如,如果我们有一个文本分类问题,我们可以将 Bernoulli 试验的思想进一步拓展,在文档中使用频率计数,而不是词袋(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

现在与之前的结果相比,效果好多了,对吧?请注意,由于数据集的随机拆分和你使用的平台,你可能会得到稍有不同的结果。

决策树

在这一部分,我们将详细讨论决策树算法。还将讨论朴素贝叶斯和决策树的比较分析。决策树通常被认为是一种监督学习技术,用于解决分类和回归任务。决策树只是一个决策支持工具,使用类似树状的图(或决策模型)及其可能的结果,包括机会事件结果、资源成本和效用。从技术角度讲,决策树中的每一分支代表一个可能的决策、事件或反应,具体体现在统计概率上。

与朴素贝叶斯相比,决策树(DT)是一种更为强大的分类技术。其原因在于,决策树首先将特征划分为训练集和测试集。然后它会生成一个良好的泛化模型,以推断预测标签或类别。更有趣的是,决策树算法可以处理二分类和多分类问题。

图 8: 使用 R 的 Rattle 包在入学测试数据集上生成的决策树示例

举例来说,在前面的示例图中,DT 通过录取数据学习,用一组if...else决策规则来逼近正弦曲线。数据集包含每个申请入学的学生的记录,假设是申请美国大学的学生。每条记录包括研究生入学考试分数、CGPA 分数和列的排名。现在,我们需要根据这三个特征(变量)预测谁是合格的。DT 可以用于解决这种问题,在训练 DT 模型并修剪掉不需要的树枝后进行预测。通常,树越深,意味着决策规则越复杂,模型的拟合度越好。因此,树越深,决策规则越复杂,模型越拟合。

如果你想绘制前面的图形,只需运行我的 R 脚本,在 RStudio 中执行它,并提供录取数据集。脚本和数据集可以在我的 GitHub 仓库中找到:github.com/rezacsedu/AdmissionUsingDecisionTree

使用 DT 的优缺点

在雇佣我之前,你可以从表 3 中了解我的优缺点以及我最擅长的工作时间,以免你事后后悔!

代理 优点 缺点 更擅长于
决策树(DTs) - 实现、训练和解释简单 - 树形结构可视化 - 数据准备要求较少 - 较少的模型构建和预测时间 - 能处理数值型和类别型数据 - 可通过统计检验验证模型 - 对噪声和缺失值具有鲁棒性 - 高准确率 - 大型和复杂的树难以解释 - 同一子树中可能出现重复 - 可能存在对角线决策边界问题 - DT 学习器可能创建过于复杂的树,无法很好地泛化数据 - 有时 DTs 可能因数据的小变动而不稳定 - 学习 DT 本身是一个 NP 完全问题(即非确定性多项式时间完全问题) - 如果某些类别占主导地位,DT 学习器会创建有偏的树 - 目标是高度准确的分类 - 医学诊断和预后 - 信用风险分析

表 3: 决策树的优缺点

决策树与朴素贝叶斯比较

如前表所述,DT 由于其对训练数据集的灵活性,非常易于理解和调试。它们既适用于分类问题,也适用于回归问题。

如果你尝试预测分类值或连续值,决策树(DT)可以同时处理这两种问题。因此,如果你只有表格数据,将其输入到决策树中,它将构建模型来分类你的数据,无需任何额外的前期或手动干预。总之,决策树非常简单,易于实现、训练和解释。只需极少的数据准备,决策树就能在更短的预测时间内构建模型。正如前面所说,它们可以处理数字数据和分类数据,并且对噪声和缺失值非常鲁棒。使用统计测试验证模型也非常简单。更有趣的是,构建的树可以进行可视化。总体而言,决策树提供了非常高的准确性。

然而,决策树的缺点是,它们有时会导致训练数据的过拟合问题。这意味着你通常需要修剪树并找到一个最优的树模型,以提高分类或回归的准确性。此外,同一子树中可能会出现重复现象。有时它还会在决策边界上产生斜对角问题,从而导致过拟合和欠拟合的问题。此外,决策树学习器可能会生成过于复杂的树,无法很好地泛化数据,这使得整体解释变得困难。由于数据中的微小变动,决策树可能不稳定,因此学习决策树本身是一个 NP 完全问题。最后,如果某些类别在数据中占主导地位,决策树学习器可能会生成有偏的树。

读者可以参考表 1表 3,获取朴素贝叶斯和决策树的对比总结。

另一方面,使用朴素贝叶斯时有一种说法:NB 要求你手动构建分类器。你无法直接将一堆表格数据输入它,它不会自动挑选最适合分类的特征。在这种情况下,选择正确的特征以及重要的特征由用户自己决定,也就是你自己。另一方面,决策树会从表格数据中选择最佳特征。鉴于这一点,你可能需要将朴素贝叶斯与其他统计技术结合,以帮助选择最佳特征并在之后进行分类。或者,使用决策树来提高准确性,特别是在精确度、召回率和 F1 度量方面。另一个关于朴素贝叶斯的优点是,它会作为一个连续的分类器进行输出。然而,缺点是它们更难调试和理解。朴素贝叶斯在训练数据中没有良好的特征且数据量较小时表现得相当不错。

总之,如果你试图从这两种方法中选择一个更好的分类器,通常最好测试每个模型来解决问题。我的建议是,使用你拥有的训练数据构建决策树和朴素贝叶斯分类器,然后使用可用的性能指标比较它们的表现,再根据数据集的特性决定哪一个最适合解决你的问题。

使用决策树算法构建可扩展的分类器

正如你已经看到的,使用 OVTR 分类器时,我们在 OCR 数据集上观察到了以下性能指标值:

Accuracy = 0.5217246545696688
Precision = 0.488360500637862
Recall = 0.5217246545696688
F1 = 0.4695649096879411
Test Error = 0.47827534543033123

这意味着模型在该数据集上的准确度非常低。在本节中,我们将看到如何通过使用 DT 分类器来提升性能。我们将展示一个使用 Spark 2.1.0 的例子,使用相同的 OCR 数据集。这个例子将包含多个步骤,包括数据加载、解析、模型训练,最后是模型评估。

由于我们将使用相同的数据集,为避免冗余,我们将跳过数据集探索步骤,直接进入示例:

第 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 步:创建数据框 - 将存储在 LIBSVM 格式中的数据加载为数据框,如下所示:

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 聚类你的数据

“如果你试图把一个星系做大,它就变成了一个星系团,而不是一个星系。如果你试图让它变得比这个还小,它似乎会自我分裂。”

  • 杰里迈·P·奥斯特里克

在本章中,我们将深入探讨机器学习,并了解如何利用它将属于某个特定组或类别的记录聚类到无监督观察数据集中的方式。简而言之,本章将涵盖以下主题:

  • 无监督学习

  • 聚类技术

  • 层次聚类(HC)

  • 基于质心的聚类(CC)

  • 基于分布的聚类(DC)

  • 确定聚类数目

  • 聚类算法的比较分析

  • 提交计算集群上的任务

无监督学习

在本节中,我们将简要介绍无监督机器学习技术,并提供适当的示例。让我们从一个实际例子开始讨论。假设你在硬盘上的一个拥挤且庞大的文件夹里有大量未被盗版的、完全合法的 mp3 文件。现在,如果你能够构建一个预测模型,帮助自动将相似的歌曲分组,并将它们组织到你最喜欢的类别中,比如乡村、说唱、摇滚等等,那该多好。这个将项目分配到某一组的行为,就像是将 mp3 文件添加到相应的播放列表中,是一种无监督的方式。在前几章中,我们假设你得到了一个标注正确的训练数据集。然而,现实世界中,我们并不总是能够拥有这样的奢侈。例如,假设我们想要将大量音乐分成有趣的播放列表。如果我们无法直接访问它们的元数据,那我们如何可能将这些歌曲分组呢?一种可能的方法是将多种机器学习技术结合使用,但聚类通常是解决方案的核心。

简而言之,在无监督机器学习问题中,训练数据集的正确类别是不可用或未知的。因此,类别必须从结构化或非结构化数据集中推导出来,如图 1所示。这本质上意味着,这种类型的算法的目标是以某种结构化方式对数据进行预处理。换句话说,无监督学习算法的主要目标是探索输入数据中未标注的隐藏模式。然而,无监督学习还包括其他技术,以探索数据的关键特征,从而找到隐藏的模式。为了解决这一挑战,聚类技术被广泛应用于根据某些相似性度量以无监督的方式对未标注的数据点进行分组。

要深入了解无监督算法的理论知识,请参考以下三本书:Bousquet, O.; von Luxburg, U.; Raetsch, G., eds(2004)。高级机器学习讲座Springer-Verlag。ISBN 978-3540231226,或 Duda, Richard O.Hart, Peter E.Stork, David G。(2001)。无监督学习与聚类模式分类(第 2 版)。Wiley。ISBN 0-471-05669-3 和 Jordan, Michael I.Bishop, Christopher M。(2004)神经网络。收录于 Allen B. Tucker 计算机科学手册,第 2 版(第七部分:智能系统)。佛罗里达州博卡拉顿:Chapman and Hall/CRC Press LLC。ISBN 1-58488-360-X。

图 1: 使用 Spark 的无监督学习

无监督学习示例

在聚类任务中,算法通过分析输入示例之间的相似性,将相关特征分组成类别,其中相似的特征被聚集并用圆圈标出。聚类的应用包括但不限于以下几个方面:搜索结果分组,如客户分组,异常检测用于发现可疑模式,文本分类用于在文本中发现有用模式,社交网络分析用于发现一致的群体,数据中心计算机集群用于将相关计算机组合在一起,天文数据分析用于银河系形成,房地产数据分析用于根据相似特征识别邻里。我们将展示一个基于 Spark MLlib 的解决方案,适用于最后一个用例。

聚类技术

在本节中,我们将讨论聚类技术、挑战以及适用的示例。还将简要概述层次聚类、基于质心的聚类和基于分布的聚类。

无监督学习与聚类

聚类分析是将数据样本或数据点划分并放入相应的同质类或聚类中的过程。因此,聚类的一个简单定义可以被认为是将对象组织成在某种方式上相似的组。

因此,一个聚类是一个对象集合,这些对象在彼此之间是相似的,而与属于其他聚类的对象是不同的。如图 2所示,如果给定一组对象,聚类算法会根据相似性将这些对象分组。像 K-means 这样的聚类算法会定位数据点组的质心。然而,为了使聚类更加准确有效,算法需要评估每个点与聚类质心之间的距离。最终,聚类的目标是确定一组未标记数据中的内在分组。

图 2: 聚类原始数据

Spark 支持许多聚类算法,如K-means高斯混合幂迭代聚类PIC)、潜在狄利克雷分配LDA)、二分 K-means流式 K-means。LDA 常用于文档分类和聚类,广泛应用于文本挖掘。PIC 用于聚类图的顶点,该图的边属性由成对相似度表示。然而,为了让本章的目标更加清晰和集中,我们将仅讨论 K-means、二分 K-means 和高斯混合算法。

层次聚类

层次聚类技术基于这样一个基本思想:对象或特征与附近的对象或特征相比,更相关,而与远离的对象或特征的相关性较低。二分 K-means 是这样一种层次聚类算法的例子,它根据数据对象之间的对应距离将数据对象连接成簇。

在层次聚类技术中,聚类可以通过连接聚类各部分所需的最大距离来简单地描述。因此,不同的聚类会在不同的距离下形成。从图形上看,这些聚类可以使用树状图表示。有趣的是,层次聚类这一常见名称来源于树状图的概念。

基于中心的聚类

在基于中心的聚类技术中,聚类通过一个中心向量来表示。然而,这个向量本身不一定是数据点的成员。在这种类型的学习中,必须在训练模型之前提供一个预设的聚类数目。K-means 是这种学习类型的一个非常著名的例子,其中,如果你将聚类数目设置为一个固定的整数 K,K-means 算法就会将其定义为一个优化问题,这是一个独立的问题,用于找到 K 个聚类中心,并将数据对象分配到距离它们最近的聚类中心。简而言之,这是一个优化问题,目标是最小化聚类间的平方距离。

基于分布的聚类

基于分布的聚类算法是基于统计分布模型的,这些模型提供了更便捷的方式,将相关的数据对象聚类到相同的分布中。尽管这些算法的理论基础非常稳健,但它们大多存在过拟合的问题。然而,通过对模型复杂度施加约束,可以克服这一限制。

基于中心的聚类(CC)

在这一部分,我们将讨论基于中心的聚类技术及其计算挑战。将通过使用 Spark MLlib 的 K-means 示例,帮助更好地理解基于中心的聚类。

CC 算法中的挑战

如前所述,在像 K-means 这样的基于质心的聚类算法中,设定聚类数 K 的最优值是一个优化问题。这个问题可以被描述为 NP-hard(即非确定性多项式时间困难),具有较高的算法复杂性,因此常见的方法是尝试仅得到一个近似解。因此,解决这些优化问题会增加额外的负担,并因此带来不容忽视的缺点。此外,K-means 算法假设每个聚类的大小大致相同。换句话说,为了获得更好的聚类效果,每个聚类中的数据点必须是均匀的。

该算法的另一个主要缺点是,它试图优化聚类中心而不是聚类边界,这常常会导致错误地切割聚类之间的边界。然而,有时我们可以通过视觉检查来弥补这一点,但这通常不适用于超平面上的数据或多维数据。尽管如此,关于如何找到 K 的最优值的完整内容将在本章后面讨论。

K-means 算法是如何工作的?

假设我们有 n 个数据点 x[i]i=1...n,需要将它们划分为 k 个聚类。现在目标是为每个数据点分配一个聚类。K-means 算法的目标是通过求解以下方程来找到聚类的位置 μ[i],i=1...k,以最小化数据点到聚类的距离。数学上,K-means 算法通过解决以下优化问题来实现这一目标:

在上述方程中,c[i] 是分配给聚类 i 的数据点集合,d(x,μ[i]) =||x−μ[i]||²[2] 是要计算的欧几里得距离(我们稍后会解释为什么要使用这种距离度量)。因此,我们可以理解,使用 K-means 进行的整体聚类操作并非一个简单的问题,而是一个 NP-hard 优化问题。这也意味着 K-means 算法不仅仅是寻找全局最小值,还经常会陷入不同的局部解。

现在,让我们看看在将数据输入 K-means 模型之前,我们如何能制定算法。首先,我们需要预先决定聚类数 k。然后,通常你需要遵循以下步骤:

这里 |c| 表示 c 中元素的数量。

使用 K-means 算法进行聚类时,首先将所有坐标初始化为质心。随着算法的每次迭代,每个点会根据某种距离度量(通常是欧几里得距离)分配到离它最近的质心。

距离计算: 请注意,还有其他方法可以计算距离,例如:

切比雪夫距离 可以用来通过只考虑最显著的维度来度量距离。

哈明距离算法可以识别两个字符串之间的差异。另一方面,为了使距离度量具有尺度不变性,可以使用马氏距离来标准化协方差矩阵。曼哈顿距离用于通过仅考虑轴对齐的方向来衡量距离。闵可夫斯基距离算法用于统一欧几里得距离、曼哈顿距离和切比雪夫距离。哈弗辛距离用于测量球面上两点之间的大圆距离,也就是经度和纬度之间的距离。

考虑到这些距离测量算法,可以清楚地看出,欧几里得距离算法将是解决 K-means 算法中距离计算问题的最合适选择。接着,质心将被更新为该次迭代中分配给它的所有点的中心。这一过程将重复,直到质心变化最小。简而言之,K-means 算法是一个迭代算法,分为两个步骤:

  • 聚类分配步骤:K-means 算法会遍历数据集中的每一个 m 个数据点,并将其分配到最接近的 k 个质心所代表的聚类中。对于每个点,计算它到每个质心的距离,并简单地选择距离最小的一个。

  • 更新步骤:对于每个聚类,计算一个新的质心,该质心是该聚类中所有点的均值。从前一步骤中,我们得到了一个已分配到聚类中的点集。现在,对于每一个这样的点集,我们计算均值,并将其声明为新的聚类质心。

使用 Spark MLlib 的 K-means 聚类示例

为了进一步展示聚类的例子,我们将使用从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: 来自萨拉托加纽约住宅数据集的示例数据

该聚类技术的目标是基于每个房屋的特征,进行探索性分析,寻找可能的邻里区域,以便为位于同一地区的房屋找到潜在的邻居。在进行特征提取之前,我们需要加载并解析萨拉托加 NY 房屋数据集。此步骤还包括加载包和相关依赖项,读取数据集作为 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:萨拉托加 NY 房屋数据集快照

以下是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)
  )
}

读取所有特征为 double 类型的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-means 模型时,我们需要确保所有数据点和特征都是数值类型。因此,我们还需要将所有数据点转换为 double 类型,如下所示:

// 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)转换为一个包含 double 类型数据的 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(包含 double 类型数据)转换为一个包含稠密向量的 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-means 模型 - 通过指定 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-means 实现通过使用K-means++算法初始化一组集群中心开始工作, Bahmani 等人提出的K-means++,VLDB 2012。这是 K-means++的一种变体,试图通过从一个随机中心开始,然后进行多次选择,通过一个概率方法选择更多的中心,概率与它们到当前集群集合的平方距离成正比。它产生了一个可证明的接近最优聚类的结果。原始论文可以在theory.stanford.edu/~sergei/papers/vldb12-kmpar.pdf找到。

步骤 6:评估模型误差率 - 标准的 K-means 算法旨在最小化每组数据点之间的距离平方和,即平方欧几里得距离,这也是 WSSSE 的目标。K-means 算法旨在最小化每组数据点(即聚类中心)之间的距离平方和。然而,如果你真的想最小化每组数据点之间的距离平方和,你最终会得到一个模型,其中每个聚类都是自己的聚类中心;在这种情况下,那个度量值将是 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 转换为 DataFrame,代码如下:

import spark.sqlContext.implicits._val predCluster = predictions.toDF("Price", "CLUSTER")
predCluster.show()

这将生成如下图所示的输出结果:

图 4: 聚类预测的快照

由于数据集中没有可区分的 ID,我们使用Price字段来进行关联。从前面的图中,你可以了解某个价格的房子属于哪个聚类,即属于哪个簇。为了更好的可视化效果,我们将预测的 DataFrame 与原始的 DataFrame 进行合并,以便知道每个房子对应的具体聚类编号:

val newDF = landDF.join(predCluster, "Price") 
newDF.show()

你应该在下图中观察到输出结果:

图 5: 每个房子预测的聚类快照

为了进行分析,我们将输出数据导入到 RStudio 中,并生成了如图 6所示的聚类。R 脚本可以在我的 GitHub 仓库中找到,网址是github.com/rezacsedu/ScalaAndSparkForBigDataAnalytics。另外,你也可以编写自己的脚本并据此进行可视化。

图 6: 社区的聚类

现在,为了进行更广泛的分析和可视化,我们可以观察每个聚类的相关统计数据。例如,下面我打印了与聚类 3 和 4 相关的统计数据,分别在图 8图 9中展示:

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-means 算法示例中,我们可以理解该算法存在一些局限性。例如,很难预测 K 值,且全局簇表现不佳。此外,不同的初始分区可能会导致不同的最终簇,最后,它对不同大小和密度的簇表现不佳。

为了克服这些局限性,本书中介绍了一些更强大的算法,如 MCMC(马尔可夫链蒙特卡洛;参见en.wikipedia.org/wiki/Markov_chain_Monte_Carlo)在书中呈现:Tribble, Seth D., 马尔可夫链蒙特卡洛算法使用完全均匀分布的驱动序列,斯坦福大学博士论文,2007 年。

层次聚类(HC)

在本节中,我们讨论层次聚类技术及其计算挑战。还将展示一个使用 Spark MLlib 的层次聚类的双分 K-means 算法示例,以更好地理解层次聚类。

层次聚类算法概述及挑战

层次聚类技术与基于质心的聚类在计算距离的方式上有所不同。这是最受欢迎和广泛使用的聚类分析技术之一,旨在构建一个簇的层次结构。由于一个簇通常包含多个对象,因此还会有其他候选项来计算距离。因此,除了通常选择的距离函数外,还需要决定使用的连接标准。简而言之,层次聚类中有两种策略:

  • 自底向上方法:在这种方法中,每个观察从其自身簇开始。之后,簇的对会合并在一起,然后向上移动到层次结构中。

  • 自顶向下方法:在这种方法中,所有观察从一个簇开始,分裂是递归进行的,然后向下移动到层次结构中。

这些自底向上或自顶向下的方法基于单链聚类SLINK)技术,该技术考虑最小的对象距离;完全链聚类CLINK),该方法考虑对象距离的最大值;以及无权重配对组法平均法UPGMA)。后者也被称为平均链聚类。从技术上讲,这些方法不会从数据集中产生唯一的划分(即不同的簇)。

对这三种方法的比较分析可以在nlp.stanford.edu/IR-book/completelink.html.找到。

然而,用户仍然需要从层次结构中选择合适的簇,以获得更好的聚类预测和分配。虽然这一类算法(如二分 K-means)在计算上比 K-means 算法更快,但这种类型的算法也有三个缺点:

  • 首先,这些方法对于异常值或包含噪声或缺失值的数据集并不是非常稳健。这个缺点会导致附加的簇,甚至可能导致其他簇合并。这个问题通常被称为链式现象,尤其在单链聚类(single-linkage clustering)中比较常见。

  • 其次,从算法分析来看,聚合型聚类和分裂型聚类的复杂度较高,这使得它们对于大数据集来说过于缓慢。

  • 第三,SLINK 和 CLINK 曾经在数据挖掘任务中广泛使用,作为聚类分析的理论基础,但如今它们被认为是过时的。

使用 Spark MLlib 实现二分 K-means

二分 K-means 通常比常规 K-means 更快,但它通常会产生不同的聚类结果。二分 K-means 算法基于论文《A comparison of document clustering》中的方法,作者为 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)

在这一节中,我们将讨论基于分布的聚类技术及其计算挑战。为了更好地理解基于分布的聚类,将展示一个使用高斯混合模型GMMs)与 Spark MLlib 的示例。

DC 算法中的挑战

像 GMM 这样的基于分布的聚类算法是一种期望最大化算法。为了避免过拟合问题,GMM 通常使用固定数量的高斯分布来建模数据集。这些分布是随机初始化的,并且相关参数也会进行迭代优化,以便更好地将模型拟合到训练数据集。这是 GMM 最强大的特点,有助于模型向局部最优解收敛。然而,算法的多次运行可能会产生不同的结果。

换句话说,与二分 K-means 算法和软聚类不同,GMM 是针对硬聚类进行优化的,为了获得这种类型,通常会将对象分配到高斯分布中。GMM 的另一个优势是,它通过捕捉数据点和属性之间所需的所有相关性和依赖关系,生成复杂的聚类模型。

不过,GMM 对数据的格式和形状有一些假设,这就给我们(即用户)增加了额外的负担。更具体地说,如果以下两个标准不满足,性能会急剧下降:

  • 非高斯数据集:GMM 算法假设数据集具有潜在的高斯分布,这是生成性分布。然而,许多实际数据集不满足这一假设,可能导致较差的聚类性能。

  • 如果聚类的大小不均,较小的聚类很可能会被较大的聚类所主导。

高斯混合模型是如何工作的?

使用 GMM 是一种流行的软聚类技术。GMM 试图将所有数据点建模为有限的高斯分布混合体;计算每个点属于每个聚类的概率,并与聚类相关的统计数据一起表示一个合成分布:所有点都来自 K 个具有自身概率的高斯子分布之一。简而言之,GMM 的功能可以用三步伪代码描述:

  1. Objective function(目标函数):使用期望最大化(EM)作为框架,计算并最大化对数似然。

  2. EM 算法:

    • E 步骤(E step):计算后验概率 - 即靠近的数据点。

    • M 步骤(M step):优化参数。

  3. Assignment(分配):在 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 和权重。这些参数表示不同聚类之间的最大似然(我们这里有五个聚类)。这可以如下演示:

// 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-means 算法这样的聚类算法的优点在于,它可以对具有无限特征的数据进行聚类。当你有原始数据并希望了解数据中的模式时,这是一个非常好的工具。然而,在实验之前确定簇的数量可能并不成功,有时还可能导致过拟合或欠拟合问题。另一方面,K-means、二分 K-means 和高斯混合模型这三种算法的共同之处在于,簇的数量必须事先确定,并作为参数提供给算法。因此,非正式地说,确定簇的数量是一个独立的优化问题,需要解决。

在本节中,我们将使用基于肘部法则的启发式方法。我们从 K = 2 个簇开始,然后通过增加 K 并观察成本函数簇内平方和(Within-Cluster Sum of Squares)WCSS)的值,运行 K-means 算法处理相同的数据集。在某些时刻,可以观察到成本函数有一个大的下降,但随着 K 值的增加,改进变得微乎其微。如聚类分析文献所建议的,我们可以选择 WCSS 最后一次大幅下降后的 K 值作为最优值。

通过分析以下参数,你可以找出 K-means 的性能:

  • 中介性(Betweenness): 这是中介平方和,也称为簇内相似度(intracluster similarity)。

  • 簇内平方和(Withiness): 这是簇内平方和,也叫做簇间相似度(intercluster similarity)。

  • 总簇内平方和(Totwithinss): 这是所有簇内的平方和的总和,也叫做总簇内相似度(total intracluster similarity)。

值得注意的是,一个稳健且准确的聚类模型将具有较低的簇内平方和和较高的中介性值。然而,这些值取决于簇的数量,即 K 值,这个值需要在构建模型之前选择。

现在让我们讨论如何利用肘部法则来确定簇的数量。如下面所示,我们计算了 K-means 算法应用于家庭数据(基于所有特征)时,聚类数与成本函数 WCSS 的关系。可以观察到,当 K = 5 时,出现了一个大幅下降。因此,我们选择了 5 作为簇的数量,如图 10所示。基本上,这是最后一次大幅下降之后的值。

图 14: 聚类数与 WCSS 的关系

聚类算法的比较分析

高斯混合模型主要用于期望最小化,这是优化算法的一个例子。与普通 K-means 算法相比,二分 K-means 更快,并且产生略微不同的聚类结果。下面我们尝试对比这三种算法。我们将展示每种算法在模型构建时间和计算成本方面的性能对比。如以下代码所示,我们可以通过 WCSS 计算成本。以下代码行可以用来计算 K-means 和二分算法的 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 的表现稍微好一些。不幸的是,我们没有像 WCSS 这样的度量指标来评估 GMM 算法。现在让我们观察这三种算法的模型构建时间。我们可以在开始模型训练前启动系统时钟,并在训练结束后立即停止时钟,如下所示(对于 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

在不同的研究文章中发现,二分 K-means 算法在数据点的聚类分配上表现得更好。此外,与 K-means 相比,二分 K-means 也能更好地收敛到全局最小值。而 K-means 则容易陷入局部最小值。换句话说,使用二分 K-means 算法,我们可以避免 K-means 可能遭遇的局部最小值问题。

请注意,根据机器的硬件配置和数据集的随机性,您可能会观察到前述参数的不同值。

更详细的分析留给读者从理论角度进行。感兴趣的读者还应参考基于 Spark MLlib 的聚类技术,详情请见 spark.apache.org/docs/latest/mllib-clustering.html 以获得更多见解。

提交 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、二分 K-means 和高斯混合算法,无法保证算法在多次运行时产生相同的聚类结果。例如,我们观察到,使用相同参数多次运行 K-means 算法时,每次运行产生的结果略有不同。

关于 K-means 和高斯混合模型的性能对比,请参见Jung 等人的聚类分析讲义。除了 K-means、二分 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 进行文本分析

“程序必须为人类阅读而编写,只有在偶然的情况下才是为了机器执行。”

  • 哈罗德·阿贝尔森

在本章中,我们将讨论使用 Spark ML 进行文本分析这一美妙的领域。文本分析是机器学习中的一个广泛领域,并且在许多用例中都很有用,比如情感分析、聊天机器人、电子邮件垃圾邮件检测和自然语言处理。我们将学习如何使用 Spark 进行文本分析,重点介绍使用 Twitter 的 10,000 个样本数据集进行文本分类的用例。

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

  • 理解文本分析

  • 转换器和估计器

  • 分词器

  • StopWordsRemover

  • NGrams

  • TF-IDF

  • Word2Vec

  • CountVectorizer

  • 使用 LDA 进行主题建模

  • 实现文本分类

理解文本分析

在过去几章中,我们已经探索了机器学习的世界以及 Apache Spark 对机器学习的支持。正如我们所讨论的,机器学习有一个工作流程,这些流程可以通过以下步骤来解释:

  1. 加载或获取数据。

  2. 清洗数据。

  3. 从数据中提取特征。

  4. 在数据上训练模型,根据特征生成期望的结果。

  5. 根据数据评估或预测某些结果。

一个典型流水线的简化视图如下所示:

因此,在训练模型并随后部署之前,数据的转换阶段有很多种可能性。此外,我们应该预期特征和模型属性的精细调整。我们甚至可以探索一种完全不同的算法,作为新工作流的一部分重复整个任务序列。

可以通过多个转换步骤创建一个流水线,因此我们使用领域特定语言DSL)来定义节点(数据转换步骤),从而创建一个DAG有向无环图)节点。因此,ML 流水线是一个由多个转换器和估计器组成的序列,用于将输入数据集拟合到流水线模型中。流水线中的每个阶段称为流水线阶段,如下所示:

  • 估计器

  • 模型

  • 流水线

  • 转换器

  • 预测器

当你看一行文本时,我们看到句子、短语、单词、名词、动词、标点符号等,它们组合在一起时具有意义和目的。人类非常擅长理解句子、单词、俚语、注释或上下文。这来自于多年的练习和学习如何读写、正确的语法、标点符号、感叹词等。那么,我们如何编写计算机程序来尝试复制这种能力呢?

文本分析

文本分析是从一组文本中解锁意义的方法。通过使用各种技术和算法处理和分析文本数据,我们可以揭示数据中的模式和主题。所有这些的目标是理解非结构化的文本,以便得出上下文的意义和关系。

文本分析利用几种广泛的技术类别,接下来我们将讨论这些技术。

情感分析

分析 Facebook、Twitter 和其他社交媒体上人们的政治观点是情感分析的一个良好示例。同样,分析 Yelp 上餐厅的评论也是情感分析的另一个很好的示例。

自然语言处理NLP)框架和库,如 OpenNLP 和斯坦福 NLP,通常用于实现情感分析。

主题建模

主题建模是检测文档语料库中主题或主题的一种有用技术。这是一种无监督算法,可以在一组文档中找到主题。例如,检测新闻文章中涉及的主题。另一个例子是检测专利申请中的思想。

潜在狄利克雷分配LDA)是一个流行的无监督聚类模型,而潜在语义分析LSA)则在共现数据上使用概率模型。

TF-IDF(词频-逆文档频率)

TF-IDF 衡量单词在文档中出现的频率以及在一组文档中的相对频率。此信息可用于构建分类器和预测模型。例如垃圾邮件分类、聊天对话等。

命名实体识别(NER)

命名实体识别通过检测句子中单词和名词的使用来提取关于人、组织、地点等的信息。这提供了关于文档实际内容的重要上下文信息,而不仅仅是将单词视为主要实体。

斯坦福 NLP 和 OpenNLP 都实现了 NER 算法。

事件抽取

事件抽取在 NER 基础上扩展,通过建立检测到的实体之间的关系。这可以用于推断两个实体之间的关系。因此,它增加了语义理解的层次,帮助理解文档内容。

变换器和估计器

Transformer 是一个函数对象,通过将变换逻辑(函数)应用于输入数据集,生成输出数据集,从而将一个数据集转换为另一个数据集。变换器有两种类型:标准变换器和估计器变换器。

标准变换器

标准变换器将输入数据集转换为输出数据集,明确地将变换函数应用于输入数据。除了读取输入列和生成输出列外,不依赖于输入数据。

这种变换器的调用方式如下所示:

*outputDF = transfomer.*transform*(inputDF)*

标准变换器的示例如下,并将在后续章节中详细解释:

  • Tokenizer:此工具使用空格作为分隔符将句子拆分为单词。

  • RegexTokenizer:此工具使用正则表达式将句子拆分为单词。

  • StopWordsRemover:此工具从单词列表中去除常用的停用词。

  • Binarizer:将字符串转换为二进制数字 0/1

  • NGram:从句子中创建 N 个单词短语

  • HashingTF:使用哈希表索引单词来创建词频计数

  • SQLTransformer:实现由 SQL 语句定义的变换

  • VectorAssembler:将给定的列列表组合成一个单一的向量列

标准 Transformer 的示意图如下,其中来自输入数据集的输入列被转换为输出列,从而生成输出数据集:

估算器变换器

估算器变换器通过首先根据输入数据集生成一个变换器来将输入数据集转换为输出数据集。然后,变换器处理输入数据,读取输入列并生成输出列,最终形成输出数据集。

这些变换器如下所示:

*transformer = estimator.*fit*(inputDF)* *outputDF = transformer.*transform*(inputDF)*

估算器变换器的示例如下:

  • IDF

  • LDA

  • Word2Vec

估算器变换器的示意图如下,其中来自输入数据集的输入列被转换为输出列,从而生成输出数据集:

在接下来的几个部分,我们将深入探讨文本分析,使用一个简单的示例数据集,数据集包含多行文本(句子),如以下截图所示:

以下代码用于将文本数据加载到输入数据集中。

使用一对 ID 和文本的序列来初始化一组句子,具体如下所示。

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 和句子的 Sentence 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|

分词

分词器将输入字符串转换为小写,并通过空格将字符串拆分为单独的标记。给定的句子通过默认的空格分隔符或使用自定义正则表达式的分词器拆分为单词。无论哪种方式,输入列都会转换为输出列。特别地,输入列通常是字符串,而输出列是一个单词序列。

分词器可以通过导入接下来的两个包来使用,分别是TokenizerRegexTokenizer

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、句子和输出列的单词,后者包含单词的序列:

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的内容,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”,将其视为停用词:

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、句子和输出列ngram,其中包含了 n-gram 序列:

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,我们就可以通过将TFIDF相乘来计算TF-IDF值:

TF-IDF = TF * IDF

接下来,我们将看看如何使用 Spark ML 中的 HashingTF 转换器生成TF

HashingTF

HashingTF是一个转换器,它接受一组术语并通过哈希每个术语来生成固定长度的向量,为每个术语生成索引。然后,使用哈希表的索引生成术语频率。

在 Spark 中,HashingTF 使用MurmurHash3算法来对术语进行哈希处理。

为了使用HashingTF,您需要导入以下包:

import org.apache.spark.ml.feature.HashingTF

首先,您需要初始化一个HashingTF,指定输入列和输出列。在这里,我们选择由StopWordsRemover转换器创建的过滤词列,并生成输出列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 内部使用一个hashMapmutable.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 转换器的输出。

为了调用 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转换器,指定输入列和输出列。这里,我们选择由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()函数会生成一个输出转换器:

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转换器,指定输入列和输出列。这里,我们选择由StopWordRemover创建的filteredWords列,并生成输出列特征:

scala> val countVectorizer = new CountVectorizer().setInputCol("filteredWords").setOutputCol("features")
countVectorizer: org.apache.spark.ml.feature.CountVectorizer = cntVec_555716178088

接下来,对输入数据集调用fit()函数会生成一个输出转换器:

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. 首先,你需要初始化一个 LDA 模型,设置 10 个主题和 10 次聚类迭代:

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 步。 去除停用词并创建一个新数据框,包含过滤后的单词:

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 数据框:


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 进行文本分析的世界,重点讲解了文本分类。我们了解了 Transformers 和 Estimators。我们看到了如何使用 Tokenizers 将句子分解为单词,如何去除停用词,以及生成 n-grams。我们还学习了如何实现HashingTFIDF来生成基于 TF-IDF 的特征。我们还看到了如何使用Word2Vec将单词序列转换为向量。

然后,我们还查看了 LDA,一种常用的技术,用于从文档中生成主题,而无需深入了解实际文本内容。最后,我们在来自 Twitter 数据集的 1 万个推文数据集上实施了文本分类,看看如何通过使用 Transformers、Estimators 和 Logistic Regression 模型进行二元分类,将这一切结合起来。

在下一章,我们将进一步深入探讨如何调整 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 界面,用于运行 Spark 应用程序,以便在 Firefox 或 Google Chrome 等 Web 浏览器中监控作业的执行。当 SparkContext 启动时,一个显示应用程序有用信息的 Web UI 会在独立模式下启动,并监听 4040 端口。Spark Web UI 的可用方式根据应用程序是仍在运行还是已完成执行而有所不同。

此外,你可以在应用程序执行完成后通过使用EventLoggingListener将所有事件持久化,从而使用 Web UI。然而,EventLoggingListener不能单独工作,必须结合 Spark 历史服务器一起使用。将这两项功能结合起来,可以实现以下设施:

  • 调度器阶段和任务列表

  • RDD 大小的摘要

  • 内存使用

  • 环境信息

  • 关于正在运行的执行器的信息

你可以通过在 Web 浏览器中访问http://<driver-node>:4040来访问 UI。例如,在独立模式下提交并运行的 Spark 作业可以通过http://localhost:4040访问。

请注意,如果多个 SparkContext 在同一主机上运行,它们将绑定到从 4040 开始的连续端口,依次为 4041、4042 等。默认情况下,这些信息仅在 Spark 应用程序运行期间有效。这意味着当你的 Spark 作业执行完成时,这些绑定将不再有效或可访问。

只要作业正在运行,就可以在 Spark UI 上观察到阶段。然而,要在作业完成执行后查看 Web UI,你可以在提交 Spark 作业之前将spark.eventLog.enabled设置为 true。这会强制 Spark 将所有事件记录到存储中(如本地文件系统或 HDFS),以便在 UI 中显示。

在前一章中,我们看到如何将 Spark 作业提交到集群。让我们重用其中一个命令来提交 k-means 聚类,如下所示:

# 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 作业的状态。当您使用 Web 浏览器访问 Spark UI 上的作业标签,地址为http://localhost:4040(对于独立模式),您应该看到以下选项:

  • 用户:显示提交 Spark 作业的活跃用户

  • 总正常运行时间:显示作业的总正常运行时间

  • 调度模式:在大多数情况下,是先进先出(即 FIFO)

  • 活跃作业:显示活跃作业的数量

  • 已完成作业:显示已完成作业的数量

  • 事件时间线:显示已完成执行的作业的时间线

在内部,作业标签由JobsTab类表示,它是具有作业前缀的自定义 SparkUI 标签。作业标签使用JobProgressListener访问关于 Spark 作业的统计信息,以在页面上显示上述信息。请看下面的屏幕截图:

图 2: Spark Web UI 中的作业标签

如果您在作业标签中进一步展开“活跃作业”选项,您将能够看到执行计划、状态、已完成阶段的数量以及该特定作业的作业 ID,如 DAG 可视化所示:

图 3: Spark Web UI 中任务的 DAG 可视化(简化版)

当用户在 Spark 控制台中输入代码(例如,Spark shell 或使用 Spark submit),Spark Core 创建操作图。这基本上是当用户在特定节点上对 RDD 执行操作(例如 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 调度器随后会跟踪阶段输出的 RDD 来源。它会找到一个最小化的调度来运行作业,并将相关的操作符划分为任务阶段。根据输入数据的分区,一个阶段包含多个任务。然后,操作符与 DAG 调度器一起进行流水线化。例如,多个 map 或 reduce 操作符(例如)可以在一个阶段中调度。

图 5: 执行动作会导致 DAGScheduler 中新的 ResultStage 和 ActiveJob 的产生

DAG 调度器中的两个基本概念是作业和阶段。因此,它必须通过内部注册表和计数器进行跟踪。技术上讲,DAG 调度器是 SparkContext 初始化的一部分,仅在驱动程序上工作(任务调度器和调度器后端准备好之后立即)。DAG 调度器负责 Spark 执行中的三项主要任务。它计算作业的执行 DAG,也就是阶段的 DAG。它确定每个任务的首选节点,并处理由于 shuffle 输出文件丢失而导致的失败。

图 6: 由 SparkContext 创建的 DAGScheduler 与其他服务

DAG 调度器的最终结果是一个阶段集合。因此,大多数统计数据和作业状态可以通过这种可视化查看,例如执行计划、状态、已完成的阶段数量以及特定作业的作业 ID。

阶段

Spark UI 中的阶段选项卡显示 Spark 应用程序中所有作业的所有阶段的当前状态,包括阶段和池详情的任务和阶段统计信息的两个可选页面。请注意,仅当应用程序以公平调度模式运行时才可用此信息。您应该能够在http://localhost:4040/stages访问阶段选项卡。请注意,当没有提交作业时,该选项卡除标题外不显示任何内容。阶段选项卡显示 Spark 应用程序中的阶段。在此选项卡中可以看到以下阶段:

  • 活跃阶段

  • 待处理阶段

  • 已完成阶段

例如,当您本地提交 Spark 作业时,您应该能够看到以下状态:

图 7: Spark Web UI 中所有作业的阶段

在这种情况下,只有一个活动阶段。然而,在接下来的章节中,当我们将我们的 Spark 作业提交到 AWS EC2 集群时,我们将能够观察到其他阶段。

要进一步深入到已完成作业的摘要,请单击“描述”列中包含的任何链接,您应该找到有关执行时间统计作为度量标准的相关统计信息。在度量标准中,还可以看到最小值、中位数、第 25 百分位数、第 75 百分位数和最大值的大约时间。以下图中还可以看到:

图 8: Spark Web UI 上已完成作业的摘要

您的情况可能不同,因为在撰写本书期间,我仅执行并提交了两个作业以进行演示。您还可以查看有关执行器的其他统计信息。对于我的情况,我通过使用 8 核和 32 GB RAM 在独立模式下提交了这些作业。除此之外,还显示了与执行器相关的信息,例如 ID、带有相关端口号的 IP 地址、任务完成时间、任务数量(包括失败任务、被杀任务和成功任务的数量)以及每个记录的数据集输入大小。

图像中的其他部分显示与这两个任务相关的其他信息,例如索引、ID、尝试、状态、本地性级别、主机信息、启动时间、持续时间、垃圾收集GC)时间等。

存储

存储选项卡显示每个 RDD、DataFrame 或 Dataset 的大小和内存使用情况。您应该能够看到 RDDs、DataFrames 或 Datasets 的存储相关信息。以下图显示存储元数据,例如 RDD 名称、存储级别、缓存分区的数量、已缓存数据的分数百分比以及 RDD 在主内存中的大小:

图 9: 存储选项卡显示 RDD 在磁盘中消耗的空间

请注意,如果 RDD 无法缓存在主内存中,则将使用磁盘空间。在本章的后续部分将进行更详细的讨论。

图 10: RDD 在磁盘中的数据分布和使用的存储

环境

Environment 标签页显示当前在你的机器(即驱动程序)上设置的环境变量。更具体地说,可以在 Runtime Information 下看到运行时信息,如 Java Home、Java 版本和 Scala 版本。Spark 属性,如 Spark 应用程序 ID、应用程序名称、驱动程序主机信息、驱动程序端口、执行器 ID、主节点 URL 以及调度模式等,也可以查看。此外,系统相关属性和作业属性,如 AWT 工具包版本、文件编码类型(例如 UTF-8)和文件编码包信息(例如 sun.io)也可以在 System Properties 下查看。

图 11: Spark Web UI 上的 Environment 标签页

Executors

Executors 标签页使用ExecutorsListener收集关于 Spark 应用程序执行器的信息。执行器是一个分布式代理,负责执行任务。执行器的实例化方式有多种。例如,它们可以在CoarseGrainedExecutorBackend接收到RegisteredExecutor消息时实例化,适用于 Spark Standalone 和 YARN。第二种情况是在 Spark 作业提交到 Mesos 时,Mesos 的MesosExecutorBackend会进行注册。第三种情况是当你本地运行 Spark 作业时,也就是LocalEndpoint被创建。执行器通常在整个 Spark 应用程序生命周期内运行,这称为执行器的静态分配,尽管你也可以选择动态分配。执行器后端专门管理计算节点或集群中的所有执行器。执行器定期向驱动程序的HeartbeatReceiver RPC 端点报告心跳和活动任务的部分度量,结果会发送回驱动程序。它们还为通过块管理器由用户程序缓存的 RDD 提供内存存储。有关更清晰的理解,请参见下图:

图 12: Spark 驱动程序实例化一个执行器,负责处理 HeartbeatReceiver 的心跳消息

当执行器启动时,它首先向驱动程序注册,并直接与驱动程序通信以执行任务,如下图所示:

图 13: 使用 TaskRunners 在执行器上启动任务

你应该能够通过http://localhost:4040/executors访问 Executors 标签页。

图 14: Spark Web UI 上的 Executors 标签页

如上图所示,可以查看与执行器相关的信息,包括 Executor 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 作业作为 DAG 在 Spark Web UI 上执行

观察正在运行和已完成的 Spark 作业

要访问和观察正在运行及已完成的 Spark 作业,请在 Web 浏览器中打开http://spark_driver_host:4040。请注意,你需要根据实际情况将spark_driver_host替换为相应的 IP 地址或主机名。

请注意,如果多个 SparkContext 在同一主机上运行,它们会绑定到从 4040 开始的连续端口,如 4040、4041、4042 等。默认情况下,这些信息仅在 Spark 应用程序执行期间可用。这意味着当 Spark 作业完成执行后,该绑定将不再有效或可访问。

现在,要访问仍在执行的活动作业,请点击“活动作业”链接,你将看到相关作业的信息。另一方面,要访问已完成作业的状态,请点击“已完成作业”,你将看到如前一节所述的 DAG 样式的相关信息。

图 16: 观察正在运行和已完成的 Spark 作业

你可以通过点击“活动作业”或“已完成作业”下的作业描述链接来实现这些操作。

使用日志调试 Spark 应用程序

查看所有正在运行的 Spark 应用程序的信息取决于你使用的集群管理器。在调试 Spark 应用程序时,请遵循以下说明:

  • Spark 独立模式:前往 Spark 主节点 UI,地址为http://master:18080。主节点和每个工作节点会展示集群及相关作业的统计信息。此外,每个作业的详细日志输出也会写入每个工作节点的工作目录。我们将在后续内容中讨论如何使用log4j手动启用 Spark 日志。

  • 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.log-aggregation-enableyarn-site.xml 中为 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*。

然后,log4j.properties 文件将在应用程序启动时被 Spark 自动加载,所以我们除了将其放置在指定位置外,别无他法。

现在让我们来看一下如何创建我们自己的日志系统。请查看以下代码并尝试理解这里发生了什么:

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")
  }
}

解决这个问题也很简单;只需要声明 Scala 对象时使用 extends Serializable,现在代码看起来如下所示:

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 脚本从命令行传递的配置具有更高的优先级。

例如,如果你想在不同的主节点、执行器或不同的内存配置下运行你的应用程序,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 上的 Environment 标签下列出了所有 Spark 属性。

环境变量

环境变量可以用于设置计算节点或机器的配置。例如,可以通过每个计算节点上的 conf/spark-env.sh 脚本设置 IP 地址。下表列出了需要设置的环境变量的名称和功能:

图 18: 环境变量及其含义

日志记录

最后,日志记录可以通过你 Spark 应用程序树下的 log4j.properties 文件进行配置,如前面所述。Spark 使用 log4j 进行日志记录。log4j 与 Spark 支持多个有效的日志级别,它们如下所示:

日志级别 用途
OFF 这是最具体的,完全不记录日志
FATAL 这是最具体的日志级别,显示带有少量数据的致命错误
ERROR 仅显示常规错误
WARN 显示建议修复但不是强制的警告
INFO 显示 Spark 作业所需的信息
DEBUG 在调试时,这些日志将被打印出来
TRACE 这是最不具体的错误追踪,包含大量数据
ALL 最不具体的日志级别,包含所有数据

表格 1: log4j 和 Spark 的日志级别

你可以在 conf/log4j.properties 中设置 Spark shell 的默认日志记录。在独立的 Spark 应用程序中或在 Spark Shell 会话中,使用 conf/log4j.properties.template 作为起点。在本章的早期部分,我们建议你将 log4j.properties 文件放在项目目录下,适用于像 Eclipse 这样的基于 IDE 的环境。然而,为了完全禁用日志记录,你应该使用以下 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 阶段

现在你可以执行一个聚合函数,例如,像这样执行收集操作:

rdd3.collect()

好吧!你已经开发了一个由三个阶段组成的 Spark 作业。从概念上讲,这可以通过以下方式展示:

图 23: rdd3.collect() 操作的三个阶段

现在,如果其中一个阶段失败,你的任务最终会失败。因此,最终的rdd3.collect()语句会抛出关于阶段失败的异常。此外,你可能会遇到以下四个因素的问题:

  • 聚合操作中的错误

  • 主线程中的异常

  • 面向对象编程(OOP)

  • 使用spark-submit脚本提交作业时出现类未找到异常

  • 关于 Spark 核心库中某些 API/方法的误解

为了摆脱上述问题,我们的通用建议是确保在执行任何 map、flatMap 或聚合操作时没有犯任何错误。其次,确保在使用 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 至少需要 8 GB 的主内存,并且独立模式下需要有足够的磁盘空间。另一方面,要获取完整的集群计算能力,这个要求通常会更高。

准备包含所有依赖项的 JAR 文件以执行 Spark 作业至关重要。许多从业者使用 Google 的 Guava,它在大多数发行版中包含,但并不能保证向后兼容。这意味着,有时即使你明确提供了 Guava 类,你的 Spark 作业也找不到该类;这是因为 Guava 库的两个版本中的一个会优先于另一个版本,而这个版本可能不包含所需的类。为了克服这个问题,通常需要使用 shading。

确保在使用 IntelliJ、Vim、Eclipse、Notepad 等编码时,已经通过 –Xmx 参数设置了足够大的 Java 堆空间。在集群模式下工作时,提交 Spark 作业时应指定执行器内存,使用 Spark-submit 脚本。假设你需要解析一个 CSV 文件并使用随机森林分类器进行一些预测分析,你可能需要指定合适的内存量,比如 20 GB,如下所示:

--executor-memory 20G

即使你收到 OOM 错误,你可以将内存增加到例如 32 GB 或更高。由于随机森林计算密集型,要求较大的内存,这只是随机森林的一个例子。你可能在仅解析数据时也会遇到类似的问题。即使是某个特定的阶段也可能由于这个 OOM 错误而失败。因此,确保你了解这个错误。

对于 class not found exception,确保已将主类包含在生成的 JAR 文件中。该 JAR 文件应包含所有依赖项,以便在集群节点上执行 Spark 作业。我们将在第十七章中提供详细的 JAR 准备指南,前往 ClusterLand - 在集群上部署 Spark。

对于最后一个问题,我们可以提供一些关于 Spark 核心库的常见误解的例子。例如,当你使用 wholeTextFiles 方法从多个文件准备 RDD 或 DataFrame 时,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/日志中列出的地址完全一致。然后,在启动 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 垃圾回收有些忙,或者无法快速执行垃圾回收。例如,以下图示显示了任务 0 花费了 10 小时来完成垃圾回收!我在 2014 年遇到过这个问题,那时我刚接触 Spark。然而,这类问题的控制并不在我们手中。因此,我们的建议是,应该让 JVM 释放资源,然后重新提交作业。

图 26: 一个垃圾回收暂停的示例

第四个因素可能是响应缓慢或作业性能较差,原因是缺乏数据序列化。这个问题将在下一节中讨论。第五个因素可能是代码中的内存泄漏,它会导致应用程序消耗更多的内存,留下打开的文件或逻辑设备。因此,请确保没有任何可能导致内存泄漏的选项。例如,完成 Spark 应用程序时,最好调用 sc.stop()spark.stop()。这将确保一个 SparkContext 仍然保持打开并处于活动状态。否则,你可能会遇到不必要的异常或问题。第六个问题是我们常常保持过多的打开文件,这有时会在洗牌或合并阶段引发 FileNotFoundException

优化技术

调优 Spark 应用程序以获得更好的优化技术有多个方面。在本节中,我们将讨论如何通过数据序列化来进一步优化 Spark 应用程序,通过更好的内存管理调优主内存。我们还可以通过在开发 Spark 应用程序时调优 Scala 代码中的数据结构来优化性能。另一方面,可以通过利用序列化的 RDD 存储来良好维护存储。

其中最重要的一个方面是垃圾回收(GC),以及如果你使用 Java 或 Scala 编写了 Spark 应用程序,如何调优它。我们将讨论如何通过调优来优化性能。对于分布式环境和基于集群的系统,必须确保一定程度的并行性和数据局部性。此外,通过使用广播变量,还可以进一步提高性能。

数据序列化

序列化是任何分布式计算环境中提高性能和优化的重要调优手段。Spark 也不例外,但 Spark 作业通常数据和计算都非常繁重。因此,如果你的数据对象格式不佳,首先需要将它们转换为序列化的数据对象。这会占用大量内存的字节。最终,整个过程会大幅拖慢整个处理和计算的速度。

结果是,你常常会体验到计算节点的响应迟缓。这意味着我们有时未能 100%利用计算资源。的确,Spark 试图在方便性和性能之间保持平衡。这也意味着数据序列化应该是 Spark 调优的第一步,以获得更好的性能。

Spark 提供了两种数据序列化选项:Java 序列化和 Kryo 序列化库:

  • Java 序列化:Spark 使用 Java 的ObjectOutputStream框架序列化对象。你通过创建任何实现了java.io.Serializable的类来处理序列化。Java 序列化非常灵活,但通常速度较慢,这对于大数据对象的序列化并不适用。

  • Kryo 序列化:你也可以使用 Kryo 库更快地序列化数据对象。与 Java 序列化相比,Kryo 序列化速度快 10 倍,而且比 Java 序列化更紧凑。然而,它有一个问题,即它不支持所有可序列化类型,但你需要注册你的类。

你可以通过使用SparkConf初始化你的 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 效率之间的不同权衡。如果你的 RDDs 可以舒适地适应默认存储级别(MEMORY_ONLY),那么就让你的 Spark 驱动程序或主节点使用这个级别。这是内存效率最高的选项,允许 RDD 操作尽可能快速地运行。你应该让它使用这个级别,因为这是最节省内存的选择。这也允许对 RDDs 执行许多操作,使其尽可能快。

如果你的 RDDs 不适合主内存,也就是说,MEMORY_ONLY 无法正常工作,你应该尝试使用 MEMORY_ONLY_SER。强烈建议除非你的 UDF(即你为处理数据集定义的 用户定义函数)太过复杂,否则不要将 RDDs 溢出到磁盘。如果你的 UDF 在执行阶段过滤了大量数据,这也适用。在其他情况下,重新计算一个分区,即重新分区,可能会更快地从磁盘读取数据对象。最后,如果你需要快速的故障恢复,使用复制的存储级别。

总结来说,Spark 2.x 支持以下存储级别:(名称中的数字 _2 表示有 2 个副本):

  • DISK_ONLY:适用于基于磁盘的 RDDs 操作。

  • DISK_ONLY_2:适用于基于磁盘的操作,适用于有 2 个副本的 RDDs。

  • MEMORY_ONLY:这是默认的缓存操作存储级别,适用于内存中的 RDDs。

  • MEMORY_ONLY_2:这是默认的内存缓存操作,适用于有 2 个副本的 RDDs。

  • MEMORY_ONLY_SER:如果你的 RDDs 不适合主内存,也就是说,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 字节的开销。最后,如果你的主内存小于 32GB(即 RAM),请设置 JVM 标志-XX:+UseCompressedOops,将指针大小从 8 字节改为 4 字节。

前述选项可以在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 然后执行多个操作时,GC 不会成为大问题,但如果您在驱动程序中存储了大量关于 RDD 的数据对象,Java 虚拟机JVM)的 GC 可能会变得复杂且具有问题。当 JVM 需要从旧对象中移除过时且未使用的对象,为新对象腾出空间时,必须识别并最终从内存中移除这些对象。然而,这个操作在处理时间和存储方面是一个昂贵的操作。您可能会想,GC 的成本与存储在主内存中的 Java 对象数量成正比。因此,我们强烈建议您调优数据结构。此外,建议在内存中存储更少的对象。

GC 调优的第一步是收集与您的机器上 JVM 执行垃圾回收的频率相关的统计信息。此时需要的第二个统计数据是 JVM 在您的机器或计算节点上进行 GC 时所花费的时间。可以通过在您的 IDE(例如 Eclipse)中的 JVM 启动参数中添加-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps,并指定 GC 日志文件的名称和位置来实现,如下所示:

图 27: 在 Eclipse 中设置 GC verbose

另外,您可以在提交 Spark 作业时,通过 Spark-submit 脚本指定verbose:gc,如下所示:

--conf “spark.executor.extraJavaOptions = -verbose:gc -XX:-PrintGCDetails -XX:+PrintGCTimeStamps"

简而言之,在为 Spark 指定 GC 选项时,您必须确定希望将 GC 选项指定在哪里,是在执行器(executors)上还是在驱动程序(driver)上。当您提交作业时,指定--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。有关如何在 Spark 应用中进行 GC 调优的更好方法,可以参见databricks.com/blog/2015/05/28/tuning-java-garbage-collection-for-spark-applications.html

并行度

尽管你可以通过可选参数来控制要执行的 map 任务的数量(例如SparkContext.text文件),但 Spark 会根据文件的大小自动为每个文件设置相同的值。除此之外,对于诸如groupByKeyreduceByKey之类的分布式reduce操作,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 web UI 来监视您的作业。我们还讨论了一些 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 作业可以运行在基于 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:用于大规模流应用程序开发,提供 Spark 与其他流数据源(如 Kafka、Flink 和 Twitter)的无缝集成。

  3. SparkMLlibSparKML:这些是基于 RDD 和数据集/DataFrame 的机器学习和管道创建工具。

  4. GraphX:用于大规模图计算和处理,使你的图数据对象完全连接。

  5. SparkR:Spark 上的 R 帮助进行基本的统计计算和机器学习。

正如我们之前所述,完全可以无缝结合这些 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 集群中,通常,主节点由NameNodeDataNodeJobTrackerTaskTracker组成。另一方面,工作节点可以配置成既作为 DataNode,又作为 TaskTracker。

出于安全原因,大多数大数据集群可能会设置在网络防火墙后面,以便通过计算节点克服或至少减少防火墙带来的复杂性。否则,计算节点将无法从外部网络访问,也就是外部网。下图展示了一个简化的大数据集群,通常用于 Spark:

图 2: JVM 上的大数据处理通用架构

上图显示了一个由五个计算节点组成的集群。这里每个节点都有一个专用的执行器 JVM,每个 CPU 核心一个,而 Spark Driver JVM 位于集群外部。磁盘直接连接到节点,采用 JBOD仅为一堆磁盘)方式。非常大的文件会被分区存储到磁盘上,虚拟文件系统(如 HDFS)使这些分区数据以一个大的虚拟文件呈现。以下简化的组件模型显示了位于集群外部的 Driver JVM。它与集群管理器(参见 图 4)通信,以获得在工作节点上调度任务的权限,因为集群管理器会跟踪集群中所有进程的资源分配。

如果你使用 Scala 或 Java 开发了 Spark 应用程序,那么你的作业就是基于 JVM 的进程。对于这种 JVM 基于的进程,你可以通过指定以下两个参数来简单地配置 Java 堆内存:

  • -Xmx: 此参数指定了 Java 堆内存的上限

  • -Xms: 这个参数是 Java 堆内存的下限

一旦你提交了 Spark 作业,堆内存需要为你的 Spark 作业分配。下图提供了一些分配方法的见解:

图 3:JVM 内存管理

如前图所示,Spark 启动 Spark 作业时,JVM 堆内存为 512 MB。然而,为了确保 Spark 作业的持续处理并避免出现内存不足OOM)错误,Spark 允许计算节点仅使用堆的 90%(即约 461 MB),这个比例最终可以通过控制 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 内存的计算方法如下:

Shuffle memory= Heap Size * spark.shuffle.safetyFraction * spark.shuffle.memoryFraction

spark.shuffle.safetyFractionspark.shuffle.memoryFraction 的默认值分别是 80% 和 20%。因此,在实际操作中,你最多可以使用 JVM 堆内存的 0.80.2 = 16%* 用于 shuffle 操作。最后,解压内存是指计算节点中可以被解压过程利用的主内存量。计算方式如下:

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 工作负载,甚至可以与 Spark 执行器并行管理非 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 中指定的线程数(即使用的核心数),例如,对于 4 个核心/线程为 local [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 分发版 bin 目录下的 Spark-submit 脚本提交(即 $SPARK_HOME/bin)。根据 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 参数和配置属性。配置属性的格式为 key=value。

  • <master-url> 指定集群的主 URL(例如,spark://HOST_NAME:PORT)用于连接 Spark 独立集群的主节点,local 用于在本地运行 Spark 作业。默认情况下,它只允许使用一个工作线程且没有并行性。local [k] 可以用于在本地运行 Spark 作业,并使用 K 个工作线程。需要注意的是,K 是你机器上的核心数。最后,如果你指定 local[*] 作为主节点来本地运行 Spark 作业,那么你就是在允许 spark-submit 脚本使用你机器上的所有工作线程(逻辑核心)。最后,你可以将主节点指定为 mesos://IP_ADDRESS:PORT 来连接可用的 Mesos 集群。或者,你也可以使用 yarn 来运行 Spark 作业在基于 YARN 的集群上。

有关主 URL 的其他选项,请参考下图:

图 9: Spark 支持的主 URL 详细信息\

  • <deploy-mode> 你必须指定这个选项,如果你想将驱动程序部署在工作节点(集群)上,或者作为外部客户端(客户端)本地运行。支持四种模式:local、standalone、YARN 和 Mesos。

  • <app-jar> 是你构建的 JAR 文件,其中包含依赖项。提交作业时只需传递该 JAR 文件。

  • <python-file> 是使用 Python 编写的应用程序主源代码。提交任务时只需传递 .py 文件即可。

  • [app-arguments] 可能是应用程序开发人员指定的输入或输出参数。

在使用spark-submit脚本提交 Spark 作业时,您可以使用--jars选项指定 Spark 应用程序的主 jar(以及包含的其他相关 JAR 文件)。然后,所有 JAR 文件将被传输到集群中。在--jars后面提供的 URLs 必须用逗号分隔。

然而,如果您使用 URL 指定 jar 文件,最好在--jars后面用逗号分隔 JAR 文件。Spark 使用以下 URL 方案,允许为分发 JAR 文件采用不同策略:

  • file: 指定绝对路径和file:/

  • hdfs****:, http****:, https:, ftp****: JAR 文件或任何其他文件将从您指定的 URL/URI 按预期下载

  • local:local:/开头的 URI 可用于指向每个计算节点上的本地 jar 文件。

需要注意的是,依赖的 JAR 文件、R 代码、Python 脚本或任何其他相关的数据文件需要被复制或复制到每个计算节点的 SparkContext 工作目录中。这有时会产生较大的开销,并且需要相当大的磁盘空间。随着时间的推移,磁盘使用量会增加。因此,在某个时间点,未使用的数据对象或相关代码文件需要清理。然而,这在 YARN 上是相当容易的。YARN 会定期处理清理工作,并可以自动处理。例如,在 Spark 独立模式下,自动清理可以通过提交 Spark 作业时配置 spark.worker.cleanup.appDataTtl属性来实现。

在计算方面,Spark 的设计是,在作业提交(使用spark-submit脚本)过程中,默认的 Spark 配置值可以从属性文件中加载并传递到 Spark 应用程序中。主节点将从名为spark-default.conf的配置文件中读取指定的选项。该文件的确切路径是SPARK_HOME/conf/spark-defaults.conf,位于您的 Spark 分发目录中。然而,如果您在命令行中指定所有参数,这将具有更高的优先级,并将相应地使用。

本地和独立模式下运行 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

默认情况下,您应该能够访问 Spark Web UI,网址为http://localhost:8080。请按照以下图中所示的 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

一旦作业启动,访问 Spark Web UI,主节点的网址是http://localhost:80810,工作节点的网址是http://localhost:8081,你可以查看你的作业进度,如第十四章所讨论的,为数据分类 - 使用 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参数中指定主节点的 URL(即地址)。然而,在 YARN 模式下,资源管理器的地址是从 Hadoop 配置文件中读取的。因此,--master参数为yarn。在提交 Spark 作业之前,您需要设置 YARN 集群。接下来的小节将详细介绍如何一步一步完成此操作。

配置单节点 YARN 集群

在本小节中,我们将介绍如何在 YARN 集群上运行 Spark 作业之前设置 YARN 集群。有几个步骤,请保持耐心,并按步骤操作:

第 1 步:下载 Apache Hadoop

从 Hadoop 官网(hadoop.apache.org/)下载最新的发行版。我使用的是最新的稳定版本 2.7.3,在 Ubuntu 14.04 上如以下所示:

$  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

详细信息请参考第一章《Scala 简介》中的 Java 设置部分,并应用相同的更改。

第 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文件中。首先,复制并替换原始模板文件为以下内容:

$  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.xml 中设置的 dfs.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 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 个核心和 8 GB 内存。现在,如果您使用四个每个核心一个的执行器,会发生什么?嗯,当您使用执行器时,Spark 会从 YARN 中保留它,并且 YARN 会分配所需的核心数(例如,在我们的情况下为一个)和所需的内存。实际上,内存需要比您要求的更多,以实现更快的处理。如果您请求 1 GB,实际上将分配几乎 1.5 GB,其中包括 500 MB 的开销。此外,它可能会为驱动程序分配一个执行器,其内存使用量可能为 1024 MB(即 1 GB)。

有时,Spark 作业需要多少内存并不重要,重要的是它保留了多少内存。在前面的例子中,它不会占用 50MB 的测试内存,而是每个执行器大约占用 1.5 GB(包括开销)。我们将在本章稍后讨论如何在 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 和 32 GB 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 传入。通常在创建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)

实现这一目标的第二种方式是使用spak-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

集群模式

Spark 在 Mesos 上也支持集群模式。如果驱动程序已经启动了 Spark 作业(在集群上),并且计算已完成,客户端可以通过 Mesos Web UI 访问结果(来自驱动程序)。如果你通过SPARK_HOME/sbin/start-mesos-dispatcher.sh脚本在集群中启动了MesosClusterDispatcher,那么你可以使用集群模式。

同样,条件是,在创建SparkContext时必须传入 Mesos 的主 URL(例如,mesos://host:5050)。以集群模式启动 Mesos 时,还会在主机机器上启动MesosClusterDispatcher作为守护进程。

若要实现更灵活和高级的 Spark 作业执行,还可以使用Marathon。使用 Marathon 的优势在于,你可以通过 Marathon 运行MesosClusterDispatcher。如果这样做,请确保MesosClusterDispatcher在前台运行。

Marathon 是一个 Mesos 框架,旨在启动长时间运行的应用程序,在 Mesosphere 中,它替代了传统的初始化系统。它具有许多功能,可以简化在集群环境中运行应用程序的过程,如高可用性、节点约束、应用健康检查、脚本化和服务发现的 API,以及一个易于使用的 Web 用户界面。它为 Mesosphere 的功能集添加了扩展和自我修复能力。Marathon 可以用来启动其他 Mesos 框架,它还可以启动任何可以在常规 shell 中启动的进程。由于它是为长时间运行的应用程序设计的,因此它将确保它启动的应用程序继续运行,即使它们所在的从节点发生故障。有关在 Mesosphere 中使用 Marathon 的更多信息,请参阅 GitHub 页面 github.com/mesosphere/marathon

更具体地说,从客户端,你可以通过使用 spark-submit 脚本并指定 MesosClusterDispatcher 的 URL(例如 mesos://dispatcher:7077)来提交 Spark 作业到你的 Mesos 集群。过程如下:

$ SPARK_HOME /bin/spark-class org.apache.spark.deploy.mesos.MesosClusterDispatcher

你可以在 Spark 集群的 Web 用户界面上查看驱动程序的状态。例如,使用以下作业提交命令来查看:

$ 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 添加 JAR 文件,这将不起作用。为了避免这个问题,请确保客户端上的 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 Elastic Compute Cloud (EC2) 服务作为 IaaS 或 Platform as a Service (PaaS)。有关定价和相关信息,请参考 aws.amazon.com/ec2/pricing/

第一步:密钥对和访问密钥配置

我们假设你已经创建了 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的脚本,用于从本地机器启动 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 主目录下的ec2目录中($SPARK_HOME/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)来指定所有节点,并确保这些机器可以从您的主节点进行无密码 SSH 访问。

假设您已经创建并配置了实例配置文件。现在,您已准备好启动 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中的集群主页。

第三步:在 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 端口看到作业的状态和相关统计信息。

第四步:暂停、重新启动和终止 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/

有时候,移动大数据集(比如 1 TB 的原始数据文件)非常困难。在这种情况下,如果你希望你的应用能够在大规模数据集上扩展,最快的方式是从 Amazon S3 或 EBS 设备加载数据到你节点上的 HDFS,并使用hdfs://指定数据文件路径。

数据文件或任何其他文件(数据、JAR、脚本等)可以托管在 HDFS 上,以便更易于访问:

1. 通过http://拥有 URIs/URLs(包括 HTTP)

2. 通过 Amazon S3 使用s3n://

3. 通过 HDFS 使用hdfs://

如果你设置了HADOOP_CONF_DIR环境变量,通常参数会设置为hdfs://...;否则是file://

总结

在本章中,我们讨论了 Spark 在集群模式下的工作原理及其底层架构。你还学习了如何在集群上部署一个完整的 Spark 应用程序。你了解了如何为运行 Spark 应用程序部署集群,涵盖了不同的集群模式,如本地模式、独立模式、YARN 模式和 Mesos 模式。最后,你还看到了如何使用 EC2 脚本在 AWS 上配置 Spark 集群。我们相信本章内容将帮助你对 Spark 有一定的了解。然而,由于篇幅限制,我们未能覆盖许多 API 及其底层功能。

如果你遇到任何问题,请不要忘记将其报告到 Spark 用户邮件列表 user@spark.apache.org。在此之前,请确保你已订阅该邮件列表。在下一章中,你将学习如何测试和调试 Spark 应用程序。

第十八章:测试与调试 Spark

“每个人都知道调试比编写程序本身要难两倍。所以如果你在写程序时尽可能聪明,那你怎么能调试它呢?”

  • Brian W. Kernighan

在理想的世界里,我们编写完美的 Spark 代码,所有事情都完美运行,对吧?开玩笑;实际上,我们知道,处理大规模数据集几乎从来都不那么简单,总会有一些数据点暴露出代码的任何边角问题。

考虑到上述挑战,因此,在本章中,我们将看到测试一个分布式应用程序是多么困难;接下来,我们将看到一些应对方法。简而言之,本章将涵盖以下主题:

  • 在分布式环境中的测试

  • 测试 Spark 应用程序

  • 调试 Spark 应用程序

在分布式环境中的测试

Leslie Lamport 定义了分布式系统这个术语,具体如下:

“分布式系统是这样一个系统,在其中我无法完成任何工作,因为一台我从未听说过的机器崩溃了。”

通过万维网(也叫WWW)进行资源共享,连接计算机的网络(也叫集群),是分布式系统的一个好例子。这些分布式环境通常是复杂的,且经常出现异质性。在这些异质环境中进行测试也充满挑战。在这一部分,首先,我们将观察在与此类系统工作时常见的一些问题。

分布式环境

关于分布式系统有许多定义。让我们看看一些定义,然后我们将尝试关联上述类别。Coulouris 将分布式系统定义为一种硬件或软件组件位于联网计算机上的系统,这些组件仅通过消息传递来通信并协调其操作。另一方面,Tanenbaum 通过几种方式定义了这个术语:

  • 一组独立的计算机,对系统用户而言,表现为一台单一的计算机。

  • 一个由两台或更多独立计算机组成的系统,这些计算机通过同步或异步的消息传递协调它们的处理过程。

  • 分布式系统是一组通过网络连接的自治计算机,软件设计用来提供一个集成的计算设施。

现在,基于上述定义,分布式系统可以被分类如下:

  • 只有硬件和软件是分布式的:本地分布式系统通过局域网(LAN)连接。

  • 用户是分布式的,但有一些计算和硬件资源在后台运行,例如 WWW。

  • 用户和硬件/软件都分布式:通过广域网(WAN)连接的分布式计算集群。例如,当你使用 Amazon AWS、Microsoft Azure、Google Cloud 或 Digital Ocean 的 droplets 时,你可以获得这些类型的计算设施。

分布式系统中的问题

在这里,我们将讨论在软件和硬件测试过程中需要注意的一些主要问题,以确保 Spark 作业能够在集群计算中顺利运行,而集群计算本质上是一个分布式计算环境。

请注意,所有这些问题都是不可避免的,但我们至少可以对它们进行调整以达到更好的效果。你应该遵循上一章中给出的指示和建议。根据Kamal Sheel MishraAnil Kumar Tripathi在《分布式软件系统的一些问题、挑战和难题》中提到的内容,见于《国际计算机科学与信息技术杂志》,第 5 卷(4 期),2014 年,4922-4925 页,网址: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-testing base

然而,在开始测试你用 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

请随意浏览前面的链接,以了解更多关于这些特性的内容;这将使本教程的其余部分顺利进行。

需要注意的是,TDD(测试驱动开发)是一种用于开发软件的编程技术,它指出你应该从测试开始开发。因此,它不会影响如何编写测试,而是影响何时编写测试。在ScalaTest.FunSuiteAssertionsBeforeAndAfter中没有强制或鼓励 TDD 的特性或测试风格,它们更类似于 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 表达式来编写断言。你可以简单地开始编写你的简单单元测试用例,使用 AssertionsPredef 是一个对象,在其中定义了 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 将会突然终止并抛出 Assertion Error。与 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特性包括以下语法:

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)
  }
}

如果你执行上述代码,你应该观察到以下输出:Number of words: 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 JUnit 测试运行 Scala 代码

如果测试用例通过,你应该在 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 来使测试更为明确。测试的工作方式如下:

  • 使用来自 Scala 的 ScalaTest 包中的 FunSuiteBeforeAndAfterAll 扩展测试类

  • 重写创建 Spark 上下文的 beforeAll() 方法

  • 使用 test() 方法执行测试,并在 test() 方法内部使用 assert() 方法

  • 重写停止 Spark 上下文的 afterAll() 方法

根据前面的步骤,让我们看看用于测试 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"

请注意,建议在 test 范围内添加上述依赖项,方法是为 Maven 和 SBT 两种情况都指定 <scope>test</scope>。除了这些之外,还有其他需要考虑的问题,比如内存需求、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 上运行,但它是为在类 UNIX 操作系统上运行而设计的。因此,如果您在 Windows 环境中工作,您需要特别小心。

在使用 Eclipse 或 IntelliJ 开发 Spark 应用程序时,如果你在 Windows 平台上解决数据分析、机器学习、数据科学或深度学习应用,你可能会遇到 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 运行时环境,确保 Spark 能够正确执行。I/O 异常的详细信息可以在下图中看到:

图 14:由于未能在 Hadoop 二进制路径中找到 winutils 二进制文件,导致 I/O 异常发生

那么,如何解决这个问题呢?解决方案很简单。正如错误信息所示,我们需要一个可执行文件,即winutils.exe。现在,从github.com/steveloughran/winutils/tree/master/hadoop-2.7.1/bin下载winutils.exe文件,将其粘贴到 Spark 分发目录中,并配置 Eclipse。更具体地说,假设你的 Spark 分发包包含 Hadoop,位于C:/Users/spark-2.1.0-bin-hadoop2.7,在 Spark 分发包内部有一个名为 bin 的目录。现在,将可执行文件粘贴到该目录中(即,path = C:/Users/spark-2.1.0-bin-hadoop2.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 与 Spark 时,也需要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 的日志混淆。从这里开始,我们将指向保存自己日志的文件,在此案例中为/var/log/sparkU.log。当应用程序启动时,Spark 会加载这个log4j.properties文件,因此我们只需要将其放置在指定的位置,无需做其他操作:

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")
  }
}

你应该会遇到如下的异常,提示 Task 不可序列化:

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

首先,我们可以尝试用一种简单的方法来解决这个问题。你可以做的是仅使用 extends Serializable 使 Scala 类(执行实际操作的类)可序列化。例如,代码如下:

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 编程和问题处理方法。为了更高效地解决 task not serializable 错误,编译器会尝试通过使整个对象(而不仅仅是 lambda)变为可序列化来发送,并强制 Spark 接受这一做法。然而,这样会显著增加数据洗牌,尤其是对于大对象!其他方法包括使整个类 Serializable 或者仅在传递给 map 操作的 lambda 函数内声明实例。有时,让不可序列化的对象跨节点传递也能解决问题。最后,使用 forEachPartition()mapPartitions() 而不是仅仅使用 map() 来创建不可序列化的对象。总而言之,解决此问题的方法有以下几种:

  • 使类可序列化

  • 仅在传递给 map 操作的 lambda 函数内声明实例

  • 将不可序列化对象设置为静态,并且每台机器只创建一次

  • 调用 forEachPartition()mapPartitions() 而不是 map() 来创建不可序列化对象

在前面的代码中,我们使用了注解 @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 闭包中通过 map() 计算乘法,你将会得到我们之前描述的 Task Not Serializable 错误。现在,我们可以简单地使用 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 应用程序

本节将讨论如何调试本地运行的 Spark 应用程序,使用 Eclipse 或 IntelliJ,作为 YARN 或 Mesos 上的独立模式或集群模式。在开始之前,你还可以阅读调试文档,网址是https://hortonworks.com/hadoop-tutorial/setting-spark-development-environment-scala/

在 Eclipse 中调试 Spark 应用程序作为 Scala 调试

为了实现这一点,只需将 Eclipse 配置为以常规的 Scala 代码调试方式调试你的 Spark 应用程序。配置时选择 运行 | 调试配置 | Scala 应用程序,如下图所示:

图 17: 配置 Eclipse 以调试 Spark 应用程序作为常规的 Scala 代码调试

假设我们想调试我们的KMeansDemo.scala并要求 Eclipse(你在 IntelliJ 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.driver.extraJavaOptionsspark.executor.extraJavaOptions,我们在前一节看到的这些,在 spark-defaults.conf 中并不是 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 的运行菜单下的 Debug 来启动调试器。然后,如果调试器成功连接到你的远程 Spark 应用程序,你将看到 IntelliJ 中应用程序控制台的日志信息。现在,如果你可以设置断点,其他的步骤就是正常的调试过程。下图展示了在 IntelliJ 中暂停 Spark 作业并设置断点时,你将看到的界面:

图 21: 在 IntelliJ 中暂停 Spark 作业并设置断点时的示例

尽管它运行得很好,但有时我发现使用 SPARK_JAVA_OPTS 并不会在 Eclipse 或甚至 IntelliJ 的调试过程中提供太多帮助。相反,在实际集群(如 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"

然后启动你的主节点,如下所示:

$ SPARKH_HOME/sbin/start-master.sh

现在,打开 SSH 连接到远程机器,该机器上正在实际运行 Spark 作业,并将本地主机的 4000 端口(即 localhost:4000)映射到 host_name_to_your_computer.org:5000,假设集群位于 host_name_to_your_computer.org:5000 并监听 5000 端口。现在,Eclipse 会认为你仅在调试一个本地 Spark 应用程序或进程。然而,要实现这一点,你需要在 Eclipse 上配置远程调试器,如下图所示:

图 22: 在 Eclipse 中连接远程主机进行 Spark 应用程序调试

就是这样!现在你可以像调试桌面上的应用程序一样在实时集群上调试。前述示例适用于将 Spark 主节点设置为 YARN-client 的情况。然而,它在 Mesos 集群上运行时也应该可以工作。如果你在 YARN-cluster 模式下运行,你可能需要将驱动程序设置为连接到你的调试器,而不是将调试器连接到驱动程序,因为你不一定能提前知道驱动程序将在哪种模式下执行。

使用 SBT 调试 Spark 应用程序

前述设置主要适用于 Eclipse 或 IntelliJ 中使用 Maven 项目的情况。假设你已经完成了应用程序并正在使用你偏好的 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)使用 Spark 的一些技术细节。

许多数据科学家使用 Python,因为它拥有丰富的数值库,专注于统计、机器学习或优化。然而,在 Python 中处理大规模数据集通常很繁琐,因为其运行时是单线程的。因此,只能处理适合主内存的数据。考虑到这一限制,为了在 Python 中充分体验 Spark,PySpark 最初作为 Python 到 Apache Spark 的轻量级前端开发,并使用 Spark 的分布式计算引擎。这样,Spark 就能提供在非 JVM 语言(如 Python)中的 API。

本节 PySpark 的目的是提供使用 PySpark 的基本分布式算法。请注意,PySpark 是一个用于基本测试和调试的交互式命令行工具,不适用于生产环境。

安装与配置

在 Python IDE(如 PyCharm、Spider 等)中安装和配置 PySpark 有多种方式。或者,如果你已经安装了 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 配置文件后,修改内容将在使用简单 Python 命令运行 PySpark 应用时反映出来。

然而,不幸的是,在撰写本文时,使用 PySpark 并没有 pip 安装的优势。但预计在 Spark 2.2.0 版本中会提供此功能(更多信息,请参见 issues.apache.org/jira/browse/SPARK-1267)。没有 pip 安装 PySpark 的原因可以在 JIRA 工单 issues.apache.org/jira/browse/SPARK-1267 中找到。

通过在 Python IDE 中设置 PySpark

我们还可以通过 Python IDE(如 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+

然而,如果你使用的是 Windows 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 结果

使用 DataFrame 和 RDD

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")

上述 MNIST 数据集可以从www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/mnist.bz2下载。这将返回一个 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 文件

让我们从加载、解析和查看简单的航班数据开始。首先,下载纽约市航班数据集的 CSV 文件,链接地址为:s3-us-west-2.amazonaws.com/sparkr-data/nycflights13.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:纽约市航班数据集的架构

现在,让我们使用show()方法查看数据集的快照,方法如下:

df.show() 

现在,让我们查看数据的样本,方法如下:

图 9:纽约市航班数据集的样本

读取并操作原始文本文件

您可以使用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 上进行一些分析任务。在接下来的部分,我们将展示一个使用 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)。数据框架的前五行被标注为正常。这意味着这些数据点是正常的。现在,我们需要为整个数据集中的每种标签类型确定标签的计数:

#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-means 模型的 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 中处理大规模数据集通常是繁琐的,因为其运行时是单线程的。因此,只有适合机器内存的数据集才能被处理。考虑到这一限制,并为了充分发挥 Spark 在 R 中的优势,SparkR 最初在 AMPLab 开发,作为 R 到 Apache Spark 的轻量级前端,并使用 Spark 的分布式计算引擎。

这样,它使得 R 程序员能够通过 RStudio 从 R shell 使用 Spark 进行大规模数据分析。在 Spark 2.1.0 中,SparkR 提供了一个支持选择、过滤和聚合等操作的分布式数据框架实现。这与 R 中的数据框架(如 dplyr)有些相似,但可以扩展到大规模数据集。

为什么选择 SparkR?

你也可以使用 SparkR 编写 Spark 代码,支持使用 MLlib 进行分布式机器学习。总之,SparkR 继承了与 Spark 紧密集成的诸多优势,包括以下几点:

  • 支持各种数据源 API: SparkR 可用于从多种数据源读取数据,包括 Hive 表、JSON 文件、关系数据库(RDBMS)和 Parquet 文件。

  • DataFrame 优化: SparkR DataFrame 还继承了计算引擎中在代码生成、内存管理等方面所做的所有优化。从下图可以看出,Spark 的优化引擎使得 SparkR 能与 Scala 和 Python 相媲美:

图 18: SparkR DataFrame 与 Scala/Python DataFrame 对比

  • 可扩展性: 在 SparkR DataFrame 上执行的操作会自动分布到 Spark 集群中所有可用的核心和机器上。因此,SparkR DataFrame 可用于处理数 TB 的数据,并且可以在具有成千上万台机器的集群上运行。

安装和开始使用

使用 SparkR 的最佳方式是通过 RStudio。你的 R 程序可以通过 RStudio 使用 R shell、Rescript 或其他 R IDE 连接到 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。为此,请在 RStudio 上创建一个 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")) 

此外,还可以在 RStudio 上使用sparkR.session设置以下 Spark 驱动程序属性:

图 19:可以使用sparkR.session从 RStudio 设置 Spark 驱动程序属性的sparkConfig

入门

让我们从加载、解析和查看简单的航班数据开始。首先,从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 的纽约市航班数据集中的相同样本数据

如此,你可以看到相同的结构。做得好!现在是时候探索更多内容了,比如使用 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,我们可以对已保存为 TempView 的 DataFrame 执行 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)上安装这些 API,例如 PySpark 在 PyCharm 中安装,SparkR 在 RStudio 中安装。我们还展示了如何在这些 IDE 中使用 DataFrame 和 RDD。进一步,我们讨论了如何从 PySpark 和 SparkR 执行 Spark SQL 查询。然后,我们也讨论了如何通过可视化数据集来执行一些分析。最后,我们展示了如何使用 PySpark 中的 UDF,并通过示例进行讲解。

因此,我们已经讨论了 Spark 两个 API 的若干方面:PySpark 和 SparkR。还有更多内容待探索。感兴趣的读者可以参考它们的官方网站获取更多信息:

第二十章:使用 Alluxio 加速 Spark

“显而易见,我们的技术已经超越了人类的能力。”

  • 阿尔伯特·爱因斯坦

在这里,你将学习如何将 Alluxio 与 Spark 结合使用,以加速处理速度。Alluxio 是一个开源的分布式内存存储系统,有助于加速跨平台的许多应用程序的速度,包括 Apache Spark。

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

  • Alluxio 的需求

  • 开始使用 Alluxio

  • 与 YARN 的集成

  • 在 Spark 中使用 Alluxio

Alluxio 的需求

我们已经看到了 Apache Spark 及其围绕 Spark 核心的各种功能,包括 Streaming、GraphX、Spark SQL 和 Spark 机器学习。我们还讨论了许多围绕数据操作和处理的用例和操作。任何处理任务中的关键步骤是数据输入、数据处理和数据输出。

这里展示的是一个 Spark 作业的示意图:

如图所示,一个作业的输入和输出通常依赖于基于磁盘的较慢存储选项,而处理通常是通过内存/RAM 完成的。由于内存的访问速度是磁盘的 100 倍,如果我们能够减少磁盘使用并更多地使用内存,作业的性能就可以显著提升。并不是说在任何作业中都不使用磁盘是不必要的,甚至是不可行的;而是我们只是希望尽可能多地使用内存。

首先,我们可以尽可能将数据缓存到内存中,以便通过执行器加速处理。虽然这对于一些作业可能有效,但对于在分布式集群中运行的大型作业来说,无法提供如此多的 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了解更多选项和详细信息。

你还可以通过打开浏览器并输入http://localhost:19999/来使用 Web UI 查看 Alluxio 进程。

概览

概览标签页显示了集群的总结信息,例如主节点地址、运行中的工作节点、版本和集群的运行时间。还显示了集群使用情况总结,展示了工作节点的容量和文件系统的 UnderFS 容量。接着,存储使用情况总结也会显示,包括空间容量和已用空间:

浏览

浏览标签页允许你查看内存中文件系统的当前内容。此标签显示文件系统中的内容、文件名、大小和块大小、是否已将数据加载到内存中,以及文件的 ACLs 和权限,指定谁可以访问并执行读写等操作。你将看到所有在 Alluxio 中管理的文件都出现在浏览标签中:

配置

配置标签页显示了所有使用的配置参数。最重要的一些参数包括配置目录、CPU 资源和分配给主节点及工作节点的内存资源。此外,还可以看到文件系统名称、路径、JDK 设置等。这些都可以被重写,以根据你的使用案例定制 Alluxio。任何在此处的更改都需要重新启动集群:

工作节点

Workers 标签页简单地显示了 Alluxio 集群中的工作节点。在本地设置的情况下,它只会显示本地机器,但在典型的多个工作节点集群中,你将看到所有工作节点及其状态,工作节点的容量、已用空间以及最后接收到的心跳信息,后者可以告诉你一个工作节点是否存活并参与集群操作:

内存数据

“内存数据”标签页显示了 Alluxio 文件系统内存中的当前数据。这显示了集群内存中的内容。典型的每个数据集的信息包括权限、所有者、创建时间和修改时间:

日志

“日志”标签页允许你查看各种日志文件以进行调试和监控。你将看到名为 master.log 的主节点日志文件,名为 worker.log 的工作节点日志文件,task.logproxy.log,以及一个用户日志文件。每个日志文件独立增长,对于诊断问题或仅仅监控集群健康非常有用:

指标

“指标”标签页显示了监控 Alluxio 文件系统当前状态的有用指标。这里的主要信息包括主节点的容量和文件系统的容量。还展示了各种操作的计数器,如创建和删除的文件的逻辑操作,以及创建和删除的目录。另一个部分显示了你可以用来监控 CreateFile、DeleteFile 和 GetFileBlockInfo 等操作的 RPC 调用:

当前功能

如前所述,Alluxio 提供了许多功能来支持高速度的内存文件系统,从而显著加速 Spark 或其他许多计算系统。当前版本有许多功能,以下是一些主要功能的描述:

  • 灵活的文件 API 提供了一个兼容 Hadoop 的文件系统,使得 Hadoop MapReduce 和 Spark 可以使用 Alluxio。

  • Pluggable under storage 将内存数据检查点存储到底层存储系统,支持 Amazon S3、Google Cloud Storage、OpenStack Swift、HDFS 等。

  • 分层存储 除了内存外,还可以管理 SSD 和 HDD,从而允许更大的数据集存储在 Alluxio 中。

  • 统一命名空间 通过挂载功能实现跨不同存储系统的有效数据管理。此外,透明命名确保了在将 Alluxio 中创建的对象持久化到底层存储系统时,文件名和对象的目录层级得以保留。

  • Lineage 可以通过使用血统(lineage)实现高吞吐量的写操作,而不影响容错性,丢失的输出可以通过重新执行创建输出的作业来恢复,就像 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 磁盘提供一些内存;1 GB 通常足够用于 JVM 内存,因为这部分内存仅用于缓存和元数据。

可以通过设置 alluxio.worker.memory.size 来配置 RAM 磁盘内存。

存储在非内存层(如 SSD 或 HDD)中的数据无需包含在内存大小计算中。

Alluxio master 内存

Alluxio master 存储有关 Alluxio 中每个文件的元数据,因此它的内存应该至少为 1 GB,对于较大的集群部署,最多可达 32 GB。

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 步,将 Alluxio 的 JAR 文件复制到 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 和不通过 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 软件基金会推出的相对较新的技术,能够帮助数据科学家、工程师和从业人员利用数据探索、可视化、共享和协作功能。

安装与入门

由于本书的目标并不是使用其他解释器,而是使用 Spark 在 Zeppelin 上,因此所有的代码将使用 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. 包含所有解释器的二进制包:它包含对许多解释器的支持。例如,目前 Zeppelin 支持 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 等。

  2. 包含 Spark 解释器的二进制包:它通常只包含 Spark 解释器,同时也包含解释器的 net-install 脚本。

  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 及其用户友好界面的信息(你也可以根据可用版本参考最新的快速入门文档)。

现在,让我们首先创建一个示例笔记本并开始操作。如以下图所示,你可以通过点击“创建新笔记”选项来创建一个新的笔记本:

图 3:创建一个示例 Zeppelin 笔记本

如前图所示,默认的解释器被选择为 Spark。在下拉列表中,你也只会看到 Spark,因为我们已经为 Zeppelin 下载了仅包含 Spark 的二进制包。

配置解释器

每个解释器都属于一个解释器组。解释器组是启动/停止解释器的单位。默认情况下,每个解释器都属于一个单独的组,但一个组可能包含更多的解释器。例如,Spark 解释器组包括 Spark 支持、pySpark、Spark SQL 和依赖加载器。如果你想在 Zeppelin 上执行 SQL 语句,你应该使用%符号指定解释器类型;例如,使用 SQL 时应使用%sql,使用 markdown 时应使用%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 条短信,这些短信经过人工分类为正常短信(ham)或垃圾短信(spam)。其中只有 13.4% 的短信是垃圾短信。这意味着数据集是偏斜的,只有少量的垃圾短信样本。需要注意的是,这可能在训练模型时引入偏差:

图 13:SMS 数据集快照

那么,这些数据看起来怎么样呢?正如你可能看到的,社交媒体文本常常很“脏”,包含俚语、拼写错误、缺失的空格、缩写词,例如 uursyrs 等等,并且经常违反语法规则。有时,消息中甚至包含琐碎的词语。因此,我们也需要处理这些问题。在接下来的步骤中,我们将遇到这些问题,以便更好地解读分析结果。

步骤 1. 在 Zeppelin 上加载所需的包和 API - 在我们将数据集导入 Zeppelin 之前,让我们加载所需的包和 API,并创建第一个段落:

图 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)的目的是找出在每个条目中似乎很重要的术语。然而,像 theand 这样的词在每个条目中可能会非常频繁地出现。我们希望减少这些词的权重,因此我们可以想象,将前面的 TF 乘以文档频率的倒数可能有助于找出重要的词语。然而,由于文本集合(语料库)可能非常庞大,通常会对倒数文档频率取对数。简而言之,我们可以想象,TF-IDF 的高值可能表示在确定文档内容时非常重要的词语。创建 TF-IDF 向量需要将所有文本加载到内存中,并在开始训练模型之前计算每个单词的出现次数。

步骤 9. 使用 VectorAssembler 为 Spark ML 管道生成原始特征 - 如你在上一步中看到的,我们只有过滤后的标记、标签、TF 和 IDF。然而,当前没有可以输入任何 ML 模型的相关特征。因此,我们需要使用 Spark 的 VectorAssembler API,基于前面 DataFrame 中的属性来创建特征,具体如下:

图 25:使用 VectorAssembler 进行特征创建的段落

步骤 10. 准备训练集和测试集 - 现在我们需要准备训练集和测试集。训练集将用于在步骤 11 中训练逻辑回归模型,而测试集将用于在步骤 12 中评估模型。在这里,我将训练集比例设为 75%,测试集比例设为 25%。你可以根据需要调整:

图 26:准备训练/测试集段落

步骤 11. 训练二分类逻辑回归模型 - 由于这个问题本身是一个二分类问题,我们可以使用二分类逻辑回归分类器,具体如下:

图 27:逻辑回归段落,展示了如何使用必要的标签、特征、回归参数、弹性网参数和最大迭代次数来训练逻辑回归分类器

请注意,为了获得更好的结果,我们将训练迭代了 200 次。我们将回归参数和弹性网参数设置得非常低——即 0.0001,以使训练更加密集。

步骤 12. 模型评估 - 让我们计算测试集的原始预测结果。然后,我们使用二分类评估器来实例化原始预测,具体如下:

****图 28:模型评估器段落

现在让我们计算模型在测试集上的准确度,具体如下:

图 29:准确度计算段落

这相当令人印象深刻。然而,如果你选择使用交叉验证进行模型调优,例如,你可能会获得更高的准确度。最后,我们将计算混淆矩阵以获得更多的洞察:

图 30:混淆矩阵段落展示了正确和错误预测的数量,通过每个类别进行汇总和拆解

数据和结果协作

此外,Apache Zeppelin 提供了一个发布笔记本段落结果的功能。使用这个功能,你可以在自己的网站上展示 Zeppelin 笔记本段落的结果。这非常简单;只需在你的页面上使用 <iframe> 标签。如果你想分享 Zeppelin 笔记本的链接,发布段落结果的第一步是复制段落链接。在 Zeppelin 笔记本中运行一个段落后,点击右侧的齿轮按钮。然后,在菜单中点击“Link this paragraph”选项,如下图所示:

图 31:链接段落

然后,只需复制提供的链接,如下所示:

图 32:获取段落链接以便与合作者共享

现在,即使你想发布已复制的段落,也可以在自己的网站上使用 <iframe> 标签。以下是一个示例:

<iframe src="img/...?asIframe" height="" width="" ></iframe>

现在,你可以在自己的网站上展示漂亮的可视化结果。这基本上标志着我们在 Apache Zeppelin 中的数据分析旅程的结束。有关更多信息和相关更新,请访问 Apache Zeppelin 的官方网站 zeppelin.apache.org/;你甚至可以通过 users-subscribe@zeppelin.apache.org 订阅 Zeppelin 用户。

总结

Apache Zeppelin 是一个基于 Web 的笔记本,能够以交互式方式进行数据分析。使用 Zeppelin,你可以制作美观的数据驱动、交互式和协作型文档,支持 SQL、Scala 等语言。随着更多新特性不断加入到最近的版本中,它正日益受到欢迎。然而,由于页面限制,并且为了让你更专注于仅使用 Spark,我们展示的示例仅适用于使用 Spark 和 Scala。然而,你也可以用 Python 编写 Spark 代码,并且可以同样轻松地测试你的笔记本。

在本章中,我们讨论了如何使用 Apache Zeppelin 进行大规模数据分析,Spark 在后台作为解释器。我们了解了如何安装并开始使用 Zeppelin。接着,我们看到如何获取数据并解析分析,以便更好地可视化。然后,我们看到如何通过可视化提供更深入的洞察。最后,我们了解了如何与合作者共享 Zeppelin 笔记本。

posted @ 2025-07-20 11:32  绝不原创的飞龙  阅读(67)  评论(0)    收藏  举报