Spark-数据科学-全-

Spark 数据科学(全)

原文:annas-archive.org/md5/8cd899a961e3fea998473427a1ba1c82

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在这个智能时代,数据分析是保持和促进商业增长的关键。每个企业都在努力最大限度地利用其数据,采用各种数据科学工具和技术,在分析成熟度曲线上不断前进。数据科学需求的突然增加是数据科学家稀缺的明显原因。要满足市场对既精通统计学、机器学习、数学建模又具备编程能力的独角兽数据科学家的需求,实属不易。

独角兽数据科学家的可用性将随着市场需求的增加而减少,并且这种情况将持续下去。因此,迫切需要一种解决方案,不仅能够让独角兽数据科学家做得更多,还能创造出 Gartner 所称的“公民数据科学家”。公民数据科学家不是其他人,而是那些主要职能不在统计或分析领域,但足够热衷于学习数据科学的开发人员、分析师、商业智能专业人士或其他技术人员。他们正成为推动整个组织和行业实现数据分析民主化的关键推动力。

不断涌现的各种工具和技术旨在促进大数据分析的规模化。此书旨在培养能够利用 Apache Spark 分布式计算平台进行数据分析的公民数据科学家。

本书是一本学习统计分析和机器学习以构建可扩展数据产品的实用指南。它帮助掌握数据科学的核心概念,并且通过 Apache Spark 帮助你快速启动任何实际数据分析项目。全书的各章都有充足的示例,读者可以在家庭计算机上执行这些示例,轻松跟随并吸收概念。每章都力求自成一体,因此读者可以从任何一章开始,相关章节提供详细内容的指引。尽管各章从基础知识入手,供初学者学习和理解,但同时也足够全面,适合资深架构师阅读。

本书内容涵盖

第一章,大数据与数据科学 – 简介,本章简要讨论了大数据分析中面临的各种挑战,以及 Apache Spark 如何在单一平台上解决这些问题。还介绍了数据分析是如何发展成现在的样子,并简要阐述了 Spark 技术栈的基本概念。

第二章,Spark 编程模型,本章讲述了 Apache Spark 的设计考虑因素及其支持的编程语言,还详细解释了 Spark 的核心组件,并详细介绍了 RDD API,这是 Spark 的基本构建块。

第三章,数据框架简介,本章介绍了数据框架,它是数据科学家们工作中最方便、最有用的组件。它解释了 Spark SQL 和赋能数据框架的 Catalyst 优化器。此外,本章还通过代码示例展示了各种数据框架操作。

第四章,统一数据访问,本章讨论了我们从不同来源获取数据、整合并以统一方式处理的各种方法。它涵盖了实时数据收集的流式处理方面,并讲解了这些 API 的底层原理。

第五章,Spark 上的数据分析,本章讨论了完整的数据分析生命周期。通过大量代码示例,解释了如何从不同来源获取数据,使用数据清理和转化技术准备数据,并进行描述性统计和推论统计,以从数据中挖掘隐藏的洞见。

第六章,机器学习,本章解释了各种机器学习算法,如何在 MLlib 库中实现这些算法,以及如何使用管道 API 进行流畅的执行。本章涵盖了所有算法的基本原理,因此可以作为一个一站式参考。

第七章,使用 SparkR 扩展 Spark,本章主要面向那些希望利用 Spark 进行数据分析的 R 语言程序员。它解释了如何使用 SparkR 编程,以及如何使用 R 库中的机器学习算法。

第八章,分析非结构化数据,本章仅讨论非结构化数据分析。它解释了如何获取非结构化数据、处理这些数据并进行机器学习分析。它还介绍了一些在“机器学习”章节中没有涉及的降维技术。

第九章,可视化大数据,本章介绍了在 Spark 上支持的各种可视化技术。它解释了数据工程师、数据科学家和业务用户的不同可视化需求,并且还建议了合适的工具和技术。它还讨论了如何利用 IPython/Jupyter 笔记本和 Apache 项目 Zeppelin 进行数据可视化。

第十章,将所有内容整合起来,到目前为止,书中已经在不同的章节分别讨论了大部分的数据分析组件。本章旨在将典型数据科学项目的各个步骤串联起来,并展示一个逐步实施完整分析项目的过程。

第十一章,构建数据科学应用程序,到目前为止,本书主要讨论了数据科学组件以及完整的执行示例。本章提供了关于如何构建可部署到生产环境中的数据产品的概述。它还介绍了 Apache Spark 项目的当前开发状态以及未来的计划。

本书所需内容

在执行书中提到的代码之前,您的系统必须安装以下软件。不过,并非所有软件组件都适用于所有章节:

  • Ubuntu 14.4 或 Windows 7 及以上版本

  • Apache Spark 2.0.0

  • Scala: 2.10.4

  • Python 2.7.6

  • R 3.3.0

  • Java 1.7.0

  • Zeppelin 0.6.1

  • Jupyter 4.2.0

  • IPython 内核 5.1

本书适用对象

本书适合任何想要利用 Apache Spark 进行数据科学和机器学习的人。如果你是一个技术专家,想扩展自己的知识以在 Spark 中执行数据科学操作,或者是一个数据科学家,想了解 Spark 中算法的实现,或是一个经验较少的新手,想学习大数据分析,本书适合你!

约定

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

文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号如下所示:“当程序在 Spark shell 上运行时,它被称为驱动程序,其中包含用户的 main 方法。”

代码块的格式如下:

Scala> sc.parallelize(List(2, 3, 4)).count()
res0: Long = 3
Scala> sc.parallelize(List(2, 3, 4)).collect()
res1: Array[Int] = Array(2, 3, 4)
Scala> sc.parallelize(List(2, 3, 4)).first()
res2: Int = 2
Scala> sc.parallelize(List(2, 3, 4)).take(2)
res3: Array[Int] = Array(2, 3)

新术语重要词汇 用粗体显示。你在屏幕上看到的词汇,例如在菜单或对话框中,文本中会像这样显示:“它还允许用户通过 数据源 API 从未开箱支持的数据源(例如 CSV、Avro HBase、Cassandra 等)获取数据。”

注释

警告或重要说明将显示在像这样的框中。

小贴士

小窍门和技巧以这种方式呈现。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对本书的看法——你喜欢什么,或者不喜欢什么。读者反馈对我们非常重要,它帮助我们开发出你真正能从中受益的书籍。若要向我们提供一般反馈,请通过电子邮件发送至 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/Spark-for-Data-Science。我们还有其他来自我们丰富书籍和视频目录的代码包,您可以在github.com/PacktPublishing/查看它们!

下载本书的彩色图像

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

勘误表

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

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需的信息将出现在勘误表部分。

盗版

互联网盗版版权材料是一个涉及所有媒体的持续性问题。在 Packt,我们非常重视保护我们的版权和许可。如果您在互联网上发现我们作品的任何非法复制,请立即向我们提供网址或网站名称,以便我们采取措施。

请通过电子邮件联系 copyright@packtpub.com,并提供涉嫌盗版材料的链接。

感谢您帮助我们保护作者的权益,以及我们向您提供有价值内容的能力。

问题

如果您对本书的任何内容有问题,可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。

第一章 大数据与数据科学——简介

大数据绝对是一个大问题! 它承诺通过在巨大的数据孤岛中提取隐藏的洞察力,并开辟新的商业发展途径,带来丰富的机会。通过先进的分析技术利用大数据,已经成为组织创造和保持竞争优势的一种理所当然的选择。

本章介绍了大数据的概念、大数据分析面临的各种挑战,以及Apache Spark如何作为事实标准来应对计算挑战,并作为一个数据科学平台。

本章涵盖的主题如下:

  • 大数据概述——到底有什么大惊小怪的?

  • 大数据分析的挑战——为什么它如此困难?

  • 大数据分析的演变——数据分析趋势

  • Spark 数据分析——解决大数据挑战的方案

  • Spark 堆栈——构成完整大数据解决方案的所有组件

大数据概述

关于大数据是什么,已经有很多讨论和书面材料,但实际上并没有一个明确的标准来清晰定义它。它在某种程度上是一个相对的术语。无论数据是大还是小,只有在能够正确分析数据的情况下,才能有效利用它。为了从数据中提取一些有意义的内容,需要正确的分析技术,而选择合适的工具和技术在数据分析中至关重要。然而,当数据本身成为问题的一部分,且需要在进行数据分析之前解决计算挑战时,这就成了一个大数据问题。

在万维网(也称为 Web 2.0)发生了一场革命,改变了人们使用互联网的方式。静态网页变成了互动网站,开始收集越来越多的数据。云计算、社交媒体和移动计算的技术进步引发了数据的爆炸。每个数字设备都开始生成数据,许多其他来源也推动了数据洪流。来自每个角落的数据流以惊人的速度生成各种海量数据!大数据以这种方式的形成是一种自然现象,因为这正是万维网的演变方式,具体细节并没有刻意推动。这是关于过去的!如果考虑现在正在发生并将在未来发生的变化,数据生成的体量和速度超出了任何人的预期。我之所以做出这样的陈述,是因为每个设备现在都变得更加智能,这要归功于物联网IoT)。

信息技术的趋势是,技术进步也促进了数据的爆炸。随着更便宜的在线存储池集群的出现以及基本硬件的低价可用性,数据存储经历了范式转变。将来自不同来源的数据以原始形式存储在单一数据湖中,迅速取代了精心设计的数据集市和数据仓库。使用模式也从严格的模式驱动的基于 RDBMS 的方法转向了无模式、持续可用的NoSQL数据存储驱动的解决方案。因此,无论是结构化数据、半结构化数据,还是非结构化数据,其创建速度都前所未有地加快。

组织非常确信,利用大数据不仅可以回答特定的业务问题,还能带来覆盖尚未探索的业务可能性并解决相关的不确定性。因此,除了自然的数据涌入,组织开始制定战略,以生成越来越多的数据来维持其竞争优势,并为未来做好准备。在这里,一个例子有助于更好地理解这一点。假设在一个制造工厂的机器上安装了传感器,传感器不断发送数据,从而实时了解机器部件的状态,而公司能够预测机器何时会发生故障。这使得公司能够防止故障或损坏,避免计划外停机,节省大量资金。

大数据分析的挑战

在大数据分析中,通常有两类主要挑战。第一个挑战是需要一个庞大的计算平台,一旦平台建立,第二个挑战就是如何在大规模上分析并理解海量数据。

计算挑战

随着数据的增加,大数据的存储需求也不断增长。数据管理变得愈加繁琐。由于磁盘存储的寻址时间,访问数据时的延迟成为了主要瓶颈,尽管处理器的处理速度和内存的频率已经达到了标准。

从各种业务应用程序和数据孤岛中获取结构化和非结构化数据,将其整合并处理以发现有用的业务洞察是一项挑战。只有少数应用程序能够解决任何一个领域或仅几个多样化业务需求的领域。然而,将这些应用程序整合以统一方式解决大多数业务需求,只会增加复杂性。

为了应对这些挑战,人们转向了分布式计算框架和分布式文件系统,例如 Hadoop 和Hadoop 分布式文件系统HDFS)。这可以消除磁盘 I/O 带来的延迟,因为数据可以在机器集群中并行读取。

分布式计算技术在此之前已经存在了几十年,但直到行业认识到大数据的重要性后,它们才逐渐变得更加突出。因此,像 Hadoop、HDFS 和 Amazon S3 这样的技术平台成为了行业标准。在 Hadoop 之上,开发了许多其他解决方案,如 Pig、Hive、Sqoop 等,以应对不同的行业需求,如存储、提取、转换和加载ETL)和数据集成,旨在使 Hadoop 成为一个统一的平台。

分析挑战

分析数据以发现隐藏的洞察力一直以来都是一项挑战,尤其是在处理庞大数据集时所涉及的额外复杂性。传统的 BI 和 OLAP 解决方案无法解决由于大数据带来的大多数挑战。例如,如果数据集有多个维度,比如 100 个,就很难将这些变量彼此进行比较以得出结论,因为这样会有大约 100C2 种组合。此类情况需要使用统计学技术,如相关性等,来发现隐藏的模式。

尽管存在许多统计学解决方案来解决这些问题,但数据科学家或分析专业人员要切分数据并挖掘有价值的洞察力变得异常困难,除非他们将整个数据集加载到内存中的DataFrame中。主要的障碍是,大多数通用的统计分析和机器学习算法都是单线程的,并且是在数据集通常不那么庞大、可以适应单台计算机的 RAM 时编写的。这些用 R 或 Python 编写的算法,在分布式计算环境中部署时已经不再非常有用,因为存在内存计算的限制。

为了应对这一挑战,统计学家和计算机科学家不得不共同合作,重写大多数能够在分布式计算环境中良好运行的算法。因此,开发了一个名为Mahout的机器学习算法库,用于在 Hadoop 上进行并行处理。它包含了行业中最常用的多数算法。类似的举措也在其他分布式计算框架中进行。

大数据分析的发展

前一节概述了如何应对大数据需求的计算和数据分析挑战。这得以实现,是因为若干相关趋势的融合,如低成本的商品硬件、大数据的可获取性以及改进的数据分析技术。Hadoop 成为了许多大型分布式数据处理基础设施的基石。

然而,人们很快意识到 Hadoop 的局限性。Hadoop 解决方案仅适用于特定类型的大数据需求,如 ETL;因此,它只因应这些需求而获得了广泛的应用。

有些情况下,数据工程师或分析师需要对数据集执行临时查询以进行交互式数据分析。每次他们在 Hadoop 上运行查询时,数据都会从磁盘(HDFS 读取)中读取并加载到内存中——这是一项代价高昂的操作。实际上,作业运行的速度受限于网络和磁盘集群的 I/O 传输速度,而不是 CPU 和 RAM 的速度。

以下是该场景的图示表示:

大数据分析的演变

Hadoop 的 MapReduce 模型无法很好适应的另一个场景是迭代性机器学习算法。Hadoop MapReduce 的性能不佳,迭代计算存在巨大延迟。由于 MapReduce 有一个受限的编程模型,并且不允许 Map 和 Reduce 工作节点之间进行通信,所需的中间结果必须存储在持久化存储中。因此,这些结果被推送到 HDFS 中,而不是保存在 RAM 中,然后在后续的迭代中重新加载到内存。磁盘 I/O 的数量依赖于算法中的迭代次数,而每次保存和加载数据时都会有序列化和反序列化的开销。总体而言,这在计算上是昂贵的,且未能达到预期的流行程度。

以下是该场景的图示表示:

大数据分析的演变

为了解决这个问题,开发了定制的解决方案,例如 Google 的 Pregel,这是一种迭代图处理算法,优化了进程间通信和中间结果的内存存储,以加快运行速度。类似地,许多其他解决方案被开发或重新设计,以更好地满足某些算法的特定需求。

与其重新设计所有算法,不如需要一个通用引擎,能够被大多数算法用来在分布式计算平台上进行内存计算。预计这种设计将导致迭代计算和临时数据分析的执行速度更快。这也是 Spark 项目在 UC 伯克利 AMPLab 中崭露头角的原因。

数据分析的 Spark

在 Spark 项目在 AMP 实验室成功之后,它于 2010 年开源,并于 2013 年转交给了 Apache 软件基金会。目前,它由 Databricks 主导。

Spark 相较于其他分布式计算平台,具有许多显著优势,例如:

  • 用于迭代性机器学习和交互式数据分析的更快执行平台

  • 单一堆栈用于批处理、SQL 查询、实时流处理、图形处理和复杂数据分析

  • 提供高层次的 API,开发多种分布式应用程序,隐藏分布式编程的复杂性

  • 无缝支持各种数据源,如 RDBMS、HBase、Cassandra、Parquet、MongoDB、HDFS、Amazon S3 等

用于数据分析的 Spark

以下是迭代算法的内存数据共享示意图:

用于数据分析的 Spark

Spark 隐藏了编写核心 MapReduce 作业的复杂性,并通过简单的函数调用提供大多数功能。由于其简单性,Spark 能够满足更广泛的受众群体,如数据科学家、数据工程师、统计学家以及 R/Python/Scala/Java 开发者。

Spark 架构大体上包括一个数据存储层、管理框架和 API。它被设计为运行在 HDFS 文件系统之上,因此可以利用现有的生态系统。部署方式可以是独立服务器,也可以是在分布式计算框架上,如 Apache Mesos 或 YARN。提供了 Scala 语言的 API,Spark 就是用这种语言编写的,同时还支持 Java、R 和 Python。

Spark 技术栈

Spark 是一个通用的集群计算系统,它赋能其他更高层次的组件利用其核心引擎。它与 Apache Hadoop 兼容,意味着它可以从 HDFS 读取和写入数据,并且还可以与 Hadoop API 支持的其他存储系统集成。

尽管它允许在其基础上构建其他更高层次的应用程序,但它已经有几个紧密集成的组件,这些组件与其核心引擎高度结合,以利用未来的核心增强功能。这些应用程序与 Spark 一起捆绑,旨在满足行业中更广泛的需求。大多数现实世界的应用程序需要跨项目进行集成,以解决通常有一组特定需求的业务问题。Apache Spark 使这一过程变得更加轻松,因为它允许其更高层次的组件无缝集成,例如在开发项目中的库。

此外,得益于 Spark 内置对 Scala、Java、R 和 Python 的支持,更广泛的开发者和数据工程师能够利用整个 Spark 技术栈:

Spark 技术栈

Spark 核心

Spark 核心在某种程度上类似于操作系统的内核。它是通用的执行引擎,既快速又容错。整个 Spark 生态系统都建立在这个核心引擎之上。它主要负责作业调度、任务分配和在工作节点之间监控作业。它还负责内存管理、与各种异构存储系统的交互,以及其他各种操作。

Spark 核心的主要构建模块是弹性分布式数据集RDD),它是一个不可变的、容错的元素集合。Spark 可以从多种数据源创建 RDD,例如 HDFS、本地文件系统、Amazon S3、其他 RDD、Cassandra 等 NoSQL 数据存储。它们是具有弹性的,意味着在失败时会自动重建。RDD 通过惰性并行转换构建。它们可以被缓存和分区,并且可能是或不是物化的。

整个 Spark 核心引擎可以视为对分布式数据集进行的一组简单操作。在 Spark 中,所有作业的调度和执行都基于与每个 RDD 关联的方法。此外,和每个 RDD 关联的方法定义了它们自己的分布式内存计算方式。

Spark SQL

该模块设计用于查询、分析和对结构化数据进行操作。这个组件在整个 Spark 堆栈中非常重要,因为大多数组织数据是结构化的,尽管非结构化数据正在快速增长。作为一个分布式查询引擎,它使得 Hadoop Hive 查询在不做任何修改的情况下能够提升最多 100 倍的速度。除了 Hive,它还支持高效的列式存储 Apache Parquet、JSON 以及其他结构化数据格式。Spark SQL 使得可以运行 SQL 查询并与用 Python、Scala 和 Java 编写的复杂程序一起使用。

Spark SQL 提供了一种分布式编程抽象,称为数据框,之前被称为 SchemaRDD,它具有较少的相关功能。数据框是命名列的分布式集合,类似于 SQL 表或 Python 的 Pandas 数据框。它们可以通过多种具有模式的数据源构建,例如 Hive、Parquet、JSON、其他关系型数据库源以及 Spark RDD。

Spark SQL 可用于跨不同格式进行 ETL 处理,并进行临时分析。Spark SQL 配备了一个名为 Catalyst 的优化框架,它可以将 SQL 查询转换为更高效的形式。

Spark 流处理

企业数据的处理窗口正在比以往任何时候都要短。为了解决行业的实时处理需求,Spark 设计了这一组件,它具有容错性和可扩展性。Spark 支持对实时数据流进行数据分析、机器学习和图处理,从而实现实时数据分析。

它提供了一个名为离散化流DStream)的 API,用于操作实时数据流。实时数据流被切分成小批次,比如每 x 秒。Spark 将每个批次当作 RDD 进行处理,执行基本的 RDD 操作。DStream 可以通过来自 HDFS、Kafka、Flume 或任何其他可以通过 TCP 套接字传输数据的源创建。通过对 DStream 应用一些更高级的操作,可以生成其他 DStream。

Spark 流处理的最终结果可以写回 Spark 支持的各种数据存储,或者可以推送到任何仪表盘进行可视化。

MLlib

MLlib 是 Spark 堆栈中的内置机器学习库。它在 Spark 0.8 中被引入。其目标是使机器学习具有可扩展性且易于使用。开发人员可以无缝地在其选择的编程语言中使用 Spark SQL、Spark Streaming 和 GraphX,无论是 Java、Python 还是 Scala。MLlib 提供了执行各种统计分析所需的功能,如相关性、抽样、假设检验等。该组件还涵盖了分类、回归、协同过滤、聚类和分解等领域的广泛应用和算法。

机器学习工作流程涉及收集和预处理数据、构建和部署模型、评估结果以及优化模型。在现实中,预处理步骤需要大量的工作。通常这些是多阶段工作流程,涉及昂贵的中间读/写操作。通常,这些处理步骤可能会在一段时间内多次执行。为了简化这些预处理步骤,提出了一个新概念——ML 管道。管道是一个变换序列,其中一个阶段的输出是另一个阶段的输入,形成一个链条。ML 管道利用 Spark 和 MLlib,允许开发人员定义可重用的变换序列。

GraphX

GraphX 是 Spark 上的一个薄层统一图分析框架。它被设计为一个通用的分布式数据流框架,替代了专门的图处理框架。它是容错的,并且利用了内存计算。

GraphX 是一个嵌入式图处理 API,用于操作图(例如社交网络)并进行图并行计算(例如 Google 的 Pregel)。它结合了图并行和数据并行系统在 Spark 堆栈中的优势,统一了探索性数据分析、迭代图计算和 ETL 处理。它扩展了 RDD 抽象,介绍了 弹性分布式图RDG),这是一个有向图,每个顶点和边都有相关的属性。

GraphX 包含了一组相当大的图算法,例如 PageRank、K-Core、Triangle Count、LDA 等等。

SparkR

SparkR 项目的启动是为了将 R 的统计分析和机器学习功能与 Spark 的可扩展性相结合。它解决了 R 的局限性——即只能处理适合单台机器内存的数据。现在,R 程序可以通过 SparkR 在分布式环境中扩展。

SparkR 实际上是一个 R 包,它提供了一个 R shell 来利用 Spark 的分布式计算引擎。借助 R 丰富的内置数据分析包,数据科学家可以在大规模上交互式地分析大型数据集。

摘要

在本章中,我们简要介绍了大数据的概念。然后,我们讨论了大数据分析中涉及的计算和分析挑战。接着,我们回顾了大数据分析领域在一段时间内如何发展以及趋势如何。我们还介绍了 Spark 如何解决大多数大数据分析挑战,并成为一个通用的统一分析平台,适用于数据科学以及并行计算。本章的最后,我们只是简要介绍了 Spark 堆栈及其组件。

在下一章中,我们将学习 Spark 编程模型。我们将深入了解 Spark 的基本构建块——RDD。此外,我们还将学习如何在 Scala 和 Python 上使用 RDD API 进行编程。

参考文献

Apache Spark 概述:

Apache Spark 架构:

第二章。Spark 编程模型

由于开源框架的普及,大规模数据处理已成为常见做法,借助成千上万节点和内建的容错能力,Hadoop 成为了一个流行的选择。这些框架在执行特定任务(如 提取、转换和加载ETL)以及处理 Web 规模数据的存储应用)上非常成功。然而,开发人员需要面对大量工具的选择,以及已经建立完善的 Hadoop 生态系统。业界急需一个单一的、通用的开发平台,能满足批处理、流处理、交互式和迭代式的需求。这正是 Spark 的动机所在。

上一章概述了大数据分析面临的挑战,以及 Spark 如何在高层次上解决其中的大部分问题。本章将深入探讨 Spark 的设计目标与选择,以更清晰地了解其作为大数据数据科学平台的适用性。我们还将深入讲解核心抽象 弹性分布式数据集RDD)并通过示例进行说明。

本章的前提是需要具备基本的 Python 或 Scala 知识,以及对 Spark 的初步理解。本章涵盖的主题如下:

  • 编程范式 - 语言支持与设计优势

    • 支持的编程语言

    • 选择合适的语言

  • Spark 引擎 - Spark 核心组件及其含义

    • 驱动程序

    • Spark Shell

    • SparkContext

    • 工作节点

    • 执行器

    • 共享变量

    • 执行流程

  • RDD API - 理解 RDD 基础

    • RDD 基础

    • 持久化

  • RDD 操作 - 让我们动手试试

    • 开始使用 Shell

    • 创建 RDD

    • 对普通 RDD 的转换

    • 对配对 RDD 的转换

    • 操作

编程范式

为了解决大数据挑战并作为数据科学及其他可扩展应用的平台,Spark 在设计时考虑了周密的设计因素和语言支持。

Spark 提供了适用于各种应用开发者的 API,以便开发者使用标准的 API 接口创建基于 Spark 的应用程序。Spark 提供了适用于 Scala、Java、R 和 Python 编程语言的 API,详细内容在以下章节中讲解。

支持的编程语言

Spark 内建支持多种语言,可以通过 Shell 进行交互式使用,这种方式称为 读取-评估-打印-循环REPL),对任何语言的开发者来说都非常熟悉。开发者可以选择自己熟悉的语言,利用现有的库,轻松与 Spark 及其生态系统进行交互。接下来,我们将介绍 Spark 支持的语言以及它们如何融入 Spark 生态系统。

Scala

Spark 本身是用 Scala 编写的,Scala 是一种基于 Java 虚拟机 (JVM) 的函数式编程语言。Scala 编译器生成的字节码在 JVM 上执行。因此,它可以与其他基于 JVM 的系统(如 HDFS、Cassandra、HBase 等)无缝集成。选择 Scala 作为编程语言,是因为它简洁的编程接口、交互式命令行以及能够捕获函数并高效地将其传输到集群中的各个节点。Scala 是一种可扩展的(因此得名)、静态类型、有效率的多范式语言,支持函数式和面向对象的语言特性。

除了完全成熟的应用程序,Scala 还支持 Shell(Spark shell)用于在 Spark 上进行交互式数据分析。

Java

由于 Spark 是基于 JVM 的,它自然支持 Java。这有助于现有的 Java 开发者开发数据科学应用程序以及其他可扩展的应用程序。几乎所有内置的库函数都可以通过 Java 访问。在 Spark 中用 Java 编写数据科学任务相对较难,但如果非常熟悉 Java 的人,可能会觉得很容易。

这个 Java API 唯一缺少的是用于 Spark 上交互式数据分析的基于 Shell 的接口。

Python

Python 通过 PySpark 在 Spark 上得到支持,PySpark 是构建在 Spark 的 Java API 之上的(使用 Py4J)。从现在起,我们将使用 PySpark 这个术语来指代 Spark 上的 Python 环境。Python 已经在开发者中因数据处理、数据清洗和其他数据科学相关任务而广受欢迎。随着 Spark 可以解决可扩展计算的挑战,Python 在 Spark 上的支持变得更加流行。

通过 Python 在 Spark 上的交互式命令行(PySpark),可以在大规模数据上进行交互式数据分析。

R

R 通过 SparkR 在 Spark 上得到支持,SparkR 是一个 R 包,借此包 Spark 的可扩展性可以通过 R 来访问。SparkR 使得 R 克服了单线程运行时的局限性,这也是计算仅限于单个节点的原因。

由于 R 最初仅为统计分析和机器学习设计,因此它已经包含了大多数包。数据科学家现在可以在极大数据量下工作,并且几乎不需要学习曲线。R 仍然是许多数据科学家的首选。

选择正确的语言

除了开发者的语言偏好之外,有时还有其他一些约束条件可能会引起关注。以下几点可能会在选择语言时,补充你的开发体验:

  • 在开发复杂逻辑时,交互式命令行非常有用。Spark 支持的所有语言中,除了 Java 外,其他都提供了交互式命令行。

  • R 是数据科学家的通用语言。由于其拥有更丰富的库集,它显然更适合纯数据分析。Spark 1.4.0 中加入了对 R 的支持,这使得 Spark 可以覆盖在 R 上工作的数据科学家。

  • Java 拥有更广泛的开发者基础。Java 8 引入了 Lambda 表达式,从而支持了函数式编程。然而,Java 往往显得冗长。

  • Python 在数据科学领域的受欢迎程度逐渐上升。Pandas 等数据处理库的可用性以及 Python 简单而富有表现力的特性,使其成为一个强有力的候选语言。在数据聚合、数据清理、自然语言处理等场景中,Python 比 R 更具灵活性。

  • Scala 可能是进行实时分析的最佳选择,因为它与 Spark 最为接近。对于来自其他语言的开发者而言,初期的学习曲线不应成为开发生产系统的障碍。Spark 的最新功能通常会首先在 Scala 中发布。Scala 的静态类型和复杂的类型推断提高了效率,并且增强了编译时的检查。Scala 可以利用 Java 的库,因为 Scala 自身的库基础仍处于早期阶段,但正在逐步追赶。

Spark 引擎

要使用 Spark 编程,首先需要对 Spark 组件有基本的理解。在本节中,我们将解释一些重要的 Spark 组件及其执行机制,以帮助开发者和数据科学家编写程序并构建应用程序。

在深入了解细节之前,我们建议你先查看以下图表,以便在进一步阅读时能够更好地理解 Spark 各部分的描述:

Spark 引擎

驱动程序

Spark Shell 是驱动程序的一个示例。驱动程序是一个在 JVM 中执行并运行用户 main 函数的进程。它拥有一个 SparkContext 对象,该对象是与底层集群管理器的连接。当驱动程序启动时,Spark 应用程序开始;当驱动程序停止时,Spark 应用程序完成。驱动程序通过 SparkContext 实例来协调 Spark 应用程序中的所有进程。

首先,一个 RDD 的谱系 有向无环图DAG)在驱动程序端构建,包含数据源(可能是 RDD)和转换操作。当遇到 action 方法时,这个 DAG 会被提交给 DAG 调度器。DAG 调度器随后将 DAG 分割成逻辑工作单元(例如 map 或 reduce),称为阶段(stages)。每个阶段又由一组任务组成,每个任务由任务调度器分配给一个执行器(worker)。任务可以按 FIFO 顺序或轮询顺序执行,具体取决于配置。

提示

在单个 Spark 应用程序中,如果多个任务是从不同的线程提交的,它们可以并行执行。

Spark Shell

Spark shell 其实就是由 Scala 和 Python 提供的接口。它看起来与其他任何交互式 shell 非常相似。它有一个 SparkContext 对象(默认为你创建)让你能够利用分布式集群。交互式 shell 对于探索性或临时分析非常有用。你可以通过 shell 一步步地开发复杂的脚本,而不需要经历编译-构建-执行的循环过程。

SparkContext

SparkContext 是进入 Spark 核心引擎的入口点。此对象用于创建和操作 RDD,以及在集群上创建共享变量。SparkContext 对象连接到集群管理器,集群管理器负责资源分配。Spark 自带独立的集群管理器。由于集群管理器是 Spark 中的可插拔组件,因此可以通过外部集群管理器(如 Apache Mesos 或 YARN)来管理它。

当你启动 Spark shell 时,默认会为你创建一个 SparkContext 对象。你也可以通过传递一个 SparkConf 对象来创建它,该对象用于设置各种 Spark 配置参数,以键值对的形式传递。请注意,JVM 中只能有一个 SparkContext 对象。

工作节点

工作节点是集群中运行应用程序代码的节点,听从驱动程序的指令。实际的工作是由工作节点执行的。集群中的每台机器可能有一个或多个工作实例(默认为一个)。一个工作节点执行一个或多个属于一个或多个 Spark 应用程序的执行进程。它由一个 块管理器 组件组成,负责管理数据块。数据块可以是缓存的 RDD 数据、中间的洗牌数据或广播数据。当可用的 RAM 不足时,它会自动将一些数据块移到磁盘。数据在节点之间的复制是块管理器的另一项职责。

执行进程

每个应用程序都有一组执行进程。执行进程驻留在工作节点上,并在集群管理器建立连接后与驱动程序直接通信。所有执行进程都由 SparkContext 管理。执行进程是一个独立的 JVM 实例,服务于一个 Spark 应用程序。执行进程负责通过任务在每个工作节点上管理计算、存储和缓存。它可以并行运行多个任务。

共享变量

通常,代码会随着变量的独立副本一起发送到各个分区。这些变量不能用于将结果(例如中间工作计数)传递回驱动程序。共享变量用于这个目的。有两种共享变量,广播变量累加器

广播变量使程序员能够在每个节点上保留只读副本,而不是将其与任务一起传输。如果在多个操作中使用大型的只读数据,可以将其指定为广播变量,并只将其传输一次到所有工作节点。以这种方式广播的数据是以序列化形式缓存的,在运行每个任务之前会被反序列化。后续操作可以访问这些变量以及与代码一起传输的本地变量。并非所有情况都需要创建广播变量,只有在跨多个阶段的任务需要相同的只读数据副本时,才需要使用广播变量。

累加器是始终递增的变量,例如计数器或累计和。Spark 本地支持数值类型的累加器,但允许程序员为新类型添加支持。请注意,工作节点不能读取累加器的值;它们只能修改其值。

执行流程

一个 Spark 应用程序由一组进程组成,其中有一个驱动程序程序和多个工作程序执行器)程序。驱动程序包含应用程序的main函数和一个 SparkContext 对象,后者表示与 Spark 集群的连接。驱动程序和其他进程之间的协调是通过 SparkContext 对象完成的。

一个典型的 Spark 客户端程序执行以下步骤:

  1. 当程序在 Spark shell 上运行时,它被称为驱动程序,包含用户的main方法。它在运行驱动程序的系统的 JVM 中执行。

  2. 第一步是使用所需的配置参数创建一个 SparkContext 对象。当你运行 PySpark 或 Spark shell 时,它会默认实例化,但对于其他应用程序,你需要显式地创建它。SparkContext 实际上是访问 Spark 的入口。

  3. 下一步是定义一个或多个 RDD,方法是加载文件或通过传递项的数组(称为并行集合)以编程方式定义。

  4. 然后可以通过一系列转换定义更多的 RDD,这些转换通过祖先图进行追踪和管理。这些 RDD 转换可以看作是 UNIX 命令管道,其中一个命令的输出作为下一个命令的输入,以此类推。每个转换步骤的结果 RDD 都有指向其父 RDD 的指针,并且有一个用于计算其数据的函数。RDD 只有在遇到动作语句后才会被操作。所以,转换是懒操作,用于定义新的 RDD,而动作启动计算并返回值给程序或将数据写入外部存储。我们将在以下部分进一步讨论这一方面。

  5. 在这个阶段,Spark 创建一个执行图,其中节点表示 RDD,边表示转换步骤。Spark 将作业分解为多个任务,在不同的机器上运行。这是 Spark 如何将计算发送到集群中的节点上的一种方法,而不是将所有数据一起获取并进行计算。

RDD API

RDD 是一个只读、分区的、容错的记录集合。从设计的角度来看,需要一个单一的数据结构抽象,隐藏处理各种数据源(如 HDFS、文件系统、RDBMS、NOSQL 数据结构或任何其他数据源)的复杂性。用户应能够从这些源中定义 RDD。目标是支持广泛的操作,并允许用户以任何顺序组合它们。

RDD 基础知识

每个数据集在 Spark 编程接口中表示为一个名为 RDD 的对象。Spark 提供了两种创建 RDD 的方式。一种方式是并行化现有集合。另一种方式是引用外部存储系统(如文件系统)中的数据集。

RDD 由一个或多个数据源组成,可能在执行一系列包括多个运算符的转换后。每个 RDD 或 RDD 分区都知道如何在失败时重新创建自己。它具有转换的日志或血统,从稳定存储或另一个 RDD 中重新创建自己所需。因此,使用 Spark 的任何程序都可以确保具有内置的容错性,无论底层数据源和 RDD 的类型如何。

RDD 上有两种方法可用:转换和操作。转换是用于创建 RDD 的方法。操作是利用 RDD 的方法。RDD 通常被分区。用户可以选择持久化 RDD,以便在其程序中重复使用。

RDDs 是不可变(只读)数据结构,因此任何转换都会导致新的 RDD 的创建。这些转换是惰性应用的,只有在应用任何操作时才会应用它们,而不是在定义 RDD 时。每次在操作中使用 RDD 时,RDD 都会重新计算,除非用户显式将 RDD 持久化到内存中。将数据保存在内存中可以节省大量时间。如果内存不足以完全容纳 RDD,则该 RDD 的剩余部分将自动存储(溢写)到硬盘上。惰性转换的一个优点是可以优化转换步骤。例如,如果操作是返回第一行,则 Spark 只计算单个分区并跳过其余部分。

一个 RDD 可以视为一组分区(拆分),它具有一份父 RDD 的依赖列表,以及一个给定父 RDD 的函数,用于计算该分区。有时,每个父 RDD 的分区只会被一个子 RDD 使用。这叫做 窄依赖。窄依赖是理想的,因为当父 RDD 分区丢失时,只需要重新计算单个子分区。另一方面,计算一个包含诸如 按键分组 等操作的单个子 RDD 分区时,依赖于多个父 RDD 分区。每个父 RDD 分区中的数据在生成多个子 RDD 分区数据时都需要用到。这样的依赖叫做 宽依赖。在窄依赖的情况下,父 RDD 和子 RDD 分区可以保持在同一个节点上(共同分区)。但在宽依赖的情况下,由于父数据分散在多个分区中,这是不可能的。在这种情况下,数据应该在分区间进行 洗牌。数据洗牌是一个资源密集型操作,应尽可能避免。宽依赖的另一个问题是,即使只有一个父 RDD 分区丢失,所有子 RDD 分区也需要重新计算。

持久化

每当 RDD 被操作时,它都会即时计算。开发者可以覆盖这个默认行为,指示要在分区之间 持久化缓存 数据集。如果该数据集需要参与多个操作,那么持久化可以节省大量时间、CPU 周期、磁盘 I/O 和网络带宽。容错机制同样适用于缓存的分区。当任何分区由于节点故障丢失时,会通过谱系图重新计算该分区。如果可用内存不足,Spark 会优雅地将持久化的分区溢出到磁盘。开发者可以使用 unpersist 移除不需要的 RDD。然而,Spark 会自动监控缓存,并使用 最近最少使用LRU)算法移除旧的分区。

提示

Cache()persist()persist (MEMORY_ONLY) 相同。虽然 persist() 方法可以接受许多其他参数来指定不同的持久化级别,例如仅内存、内存和磁盘、仅磁盘等,但 cache() 方法仅用于内存中的持久化。

RDD 操作

Spark 编程通常从选择一个你熟悉的界面开始。如果你打算进行交互式数据分析,那么 shell 提示符显然是一个不错的选择。然而,选择 Python shell(PySpark)或 Scala shell(Spark-Shell)在一定程度上取决于你对这些语言的熟练程度。如果你正在构建一个完整的可扩展应用程序,那么熟练度就显得尤为重要,因此你应该根据自己擅长的语言(Scala、Java 或 Python)来开发应用,并提交到 Spark。我们将在本书后面详细讨论这一方面。

创建 RDD

在本节中,我们将使用 Python shell(PySpark)和 Scala shell(Spark-Shell)来创建 RDD。两个 shell 都具有预定义的、能够感知解释器的 SparkContext,并将其分配给变量 sc

让我们通过一些简单的代码示例开始。请注意,代码假定当前工作目录是 Spark 的主目录。以下代码片段启动 Spark 交互式 shell,从本地文件系统读取文件,并打印该文件的第一行:

Python

> bin/pyspark  // Start pyspark shell  
>>> _         // For simplicity sake, no Log messages are shown here 

>>> type(sc)    //Check the type of Predefined SparkContext object 
<class 'pyspark.context.SparkContext'> 

//Pass the file path to create an RDD from the local file system 
>>> fileRDD = sc.textFile('RELEASE') 

>>> type(fileRDD)  //Check the type of fileRDD object  
<class 'pyspark.rdd.RDD'> 

>>>fileRDD.first()   //action method. Evaluates RDD DAG and also returns the first item in the RDD along with the time taken 
took 0.279229 s 
u'Spark Change Log' 

Scala

> bin/Spark-Shell  // Start Spark-shell  
Scala> _      // For simplicity sake, no Log messages are shown here 

Scala> sc   //Check the type of Predefined SparkContext object 
res1: org.apache.spark.SparkContext = org.apache.spark.SparkContext@70884875 

//Pass the file path to create an RDD from the local file system 

Scala> val fileRDD = sc.textFile("RELEASE") 

Scala> fileRDD  //Check the type of fileRDD object  
res2: org.apache.spark.rdd.RDD[String] = ../ RELEASE
MapPartitionsRDD[1] at textFile at <console>:21 

Scala>fileRDD.first()   //action method. Evaluates RDD DAG and also returns the first item in the RDD along with the time taken 
0.040965 s 
res6: String = Spark Change Log 

在前面的示例中,第一行已经调用了交互式 shell。SparkContext 变量 sc 已按预期定义。我们创建了一个名为 fileRDD 的 RDD,指向文件 RELEASE。此语句只是一个转换,直到遇到操作时才会执行。你可以尝试给一个不存在的文件名,但在执行下一个语句之前不会报错,而该语句恰好是一个 action 操作。

我们已经完成了启动 Spark 应用程序(shell)、创建 RDD 并消费它的整个过程。由于 RDD 每次执行操作时都会重新计算,fileRDD 并不会持久化到内存或硬盘中。这使得 Spark 可以优化步骤顺序并智能地执行。事实上,在之前的示例中,优化器只会读取输入文件的一个分区,因为 first() 不需要进行完整的文件扫描。

回顾一下,创建 RDD 有两种方式:一种是创建指向数据源的指针,另一种是并行化现有的集合。之前的示例展示了第一种方式,即从存储系统加载文件。接下来我们将看到第二种方式,即并行化现有的集合。通过传递内存中的集合来创建 RDD 非常简单,但对于大型集合可能效果不佳,因为输入集合必须完全适应驱动节点的内存。

以下示例通过使用 parallelize 函数将 Python/Scala 列表传递来创建 RDD:

Python

// Pass a Python collection to create an RDD 
>>> numRDD = sc.parallelize([1,2,3,4],2) 
>>> type(numRDD) 
<class 'pyspark.rdd.RDD'> 
>>> numRDD 
ParallelCollectionRDD[1] at parallelize at PythonRDD.scala:396 
>>> numRDD.first() 
1 
>>> numRDD.map(lambda(x) : x*x).collect() 
[1,4,9,16] 
>>> numRDD.map(lambda(x) : x * x).reduce(lambda a,b: a+b) 
30 

提示

lambda 函数是一个没有名称的函数,通常作为其他函数的参数传递给函数。Python 中的 lambda 函数只能是一个单一的表达式。如果你的逻辑需要多个步骤,可以创建一个独立的函数,并在 lambda 表达式中使用它。

Scala

// Pass a Scala collection to create an RDD 
Scala> val numRDD = sc.parallelize(List(1,2,3,4),2) 
numRDD: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[8] at parallelize at <console>:21 

Scala> numRDD 
res15: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[8] at parallelize at <console>:21 

Scala> numRDD.first() 
res16: Int = 1 

Scala> numRDD.map(x => x*x).collect() 
res2: Array[Int] = Array(1, 4, 9, 16) 

Scala> numRDD.map(x => x * x).reduce(_+_) 
res20: Int = 30 

正如我们在前面的示例中所看到的,我们能够传递一个 Scala/Python 集合来创建一个 RDD,同时我们也可以自由指定将这些集合切分成多少个分区。Spark 为集群中的每个分区运行一个任务,因此必须仔细决定如何优化计算工作量。虽然 Spark 会根据集群自动设置分区数,但我们可以通过将其作为第二个参数传递给 parallelize 函数来手动设置分区数(例如,sc.parallelize(data, 3))。以下是一个 RDD 的示意图,该 RDD 是通过一个包含 14 条记录(或元组)的数据集创建的,并被分成 3 个分区,分布在 3 个节点上:

创建 RDD

编写 Spark 程序通常包括转换和操作。转换是延迟操作,用于定义如何构建 RDD。大多数转换接受一个函数作为参数。所有这些方法将一种数据源转换为另一种数据源。每次对任何 RDD 执行转换时,即使只是一个小的改变,也会生成一个新的 RDD,如下图所示:

创建 RDD

这是因为 RDD 是不可变(只读)抽象设计的。操作的结果可以写回存储系统,或者在需要生成最终输出时返回给驱动程序进行本地计算。

到目前为止,我们已经看到了一些简单的转换操作,它们定义了 RDD 以及一些操作来处理它们并生成输出。接下来,让我们快速浏览一些常用的转换和操作,随后再看看对成对 RDD 的转换操作。

普通 RDD 的转换操作

Spark API 包含一套丰富的转换操作符,开发人员可以将它们以任意方式组合。请在交互式 shell 中尝试以下示例,以更好地理解这些操作。

filter 操作

filter 操作返回一个 RDD,其中只包含满足 filter 条件的元素,类似于 SQL 中的 WHERE 条件。

Python:

a = sc.parallelize([1,2,3,4,5,6], 3) 
b = a.filter(lambda x: x % 3 == 0) 
b.collect() 
[3,6] 

Scala:

val a = sc.parallelize(1 to 10, 3) 
val b = a.filter(_ % 3 == 0) 
b.collect 

res0: Array[Int] = Array(3, 6, 9) 

distinct 操作

distinct ([numTasks]) 操作返回一个经过去重的新数据集的 RDD:

Python:

c = sc.parallelize(["John", "Jack", "Mike", "Jack"], 2) 
c.distinct().collect() 

['Mike', 'John', 'Jack'] 

Scala:

val c = sc.parallelize(List("John", "Jack", "Mike", "Jack"), 2) 
c.distinct.collect 
res6: Array[String] = Array(Mike, John, Jack) 

val a = sc.parallelize(List(11,12,13,14,15,16,17,18,19,20)) 
a.distinct(2).partitions.length      //create 2 tasks on two partitions of the same RDD for parallel execution 

res16: Int = 2 

交集操作

交集操作接受另一个数据集作为输入。它返回一个包含共同元素的数据集:

Python:

x = sc.parallelize([1,2,3,4,5,6,7,8,9,10]) 
y = sc.parallelize([5,6,7,8,9,10,11,12,13,14,15]) 
z = x.intersection(y) 
z.collect() 

[8, 9, 10, 5, 6, 7] 

Scala:

val x = sc.parallelize(1 to 10) 
val y = sc.parallelize(5 to 15) 
val z = x.intersection(y) 
z.collect 

res74: Array[Int] = Array(8, 9, 5, 6, 10, 7) 

union 操作

union 操作接受另一个数据集作为输入。它返回一个包含自身和输入数据集元素的数据集。如果两个集合中有共同的值,则它们会在联合后的结果集中作为重复值出现:

Python:

a = sc.parallelize([3,4,5,6,7], 1) 
b = sc.parallelize([7,8,9], 1) 
c = a.union(b) 
c.collect() 

[3, 4, 5, 6, 7, 7, 8, 9] 

Scala:

val a = sc.parallelize(3 to 7, 1) 
val b = sc.parallelize(7 to 9, 1) 
val c = a.union(b)     // An alternative way is (a ++ b).collect 

res0: Array[Int] = Array(3, 4, 5, 6, 7, 7, 8, 9) 

map 操作

map 操作通过在输入数据集的每个元素上执行一个输入函数,返回一个分布式数据集:

Python:

a = sc.parallelize(["animal", "human", "bird", "rat"], 3) 
b = a.map(lambda x: len(x)) 
c = a.zip(b) 
c.collect() 

[('animal', 6), ('human', 5), ('bird', 4), ('rat', 3)] 

Scala:

val a = sc.parallelize(List("animal", "human", "bird", "rat"), 3) 
val b = a.map(_.length) 
val c = a.zip(b) 
c.collect 

res0: Array[(String, Int)] = Array((animal,6), (human,5), (bird,4), (rat,3)) 

flatMap 操作

flatMap 操作类似于 map 操作。虽然 map 对每个输入元素返回一个元素,flatMap 对每个输入元素返回一个零个或多个元素的列表:

Python:

a = sc.parallelize([1,2,3,4,5], 4) 
a.flatMap(lambda x: range(1,x+1)).collect() 
   // Range(1,3) returns 1,2 (excludes the higher boundary element) 
[1, 1, 2, 1, 2, 3, 1, 2, 3, 4, 1, 2, 3, 4, 5] 

sc.parallelize([5, 10, 20], 2).flatMap(lambda x:[x, x, x]).collect() 
[5, 5, 5, 10, 10, 10, 20, 20, 20] 

Scala:

val a = sc.parallelize(1 to 5, 4) 
a.flatMap(1 to _).collect 
res47: Array[Int] = Array(1, 1, 2, 1, 2, 3, 1, 2, 3, 4, 1, 2, 3, 4, 5) 

//One more example 
sc.parallelize(List(5, 10, 20), 2).flatMap(x => List(x, x, x)).collect 
res85: Array[Int] = Array(5, 5, 5, 10, 10, 10, 20, 20, 20) 

keys 操作

keys 操作返回一个 RDD,其中包含每个元组的键:

Python:

a = sc.parallelize(["black", "blue", "white", "green", "grey"], 2) 
b = a.map(lambda x:(len(x), x)) 
c = b.keys() 
c.collect() 

[5, 4, 5, 5, 4] 

Scala:

val a = sc.parallelize(List("black", "blue", "white", "green", "grey"), 2) 
val b = a.map(x => (x.length, x)) 
b.keys.collect 

res2: Array[Int] = Array(5, 4, 5, 5, 4) 

笛卡尔积操作

cartesian 操作接受另一个数据集作为参数,返回两个数据集的笛卡尔积。这是一个可能比较昂贵的操作,会返回一个大小为 m x n 的数据集,其中 mn 是输入数据集的大小:

Python:

x = sc.parallelize([1,2,3]) 
y = sc.parallelize([10,11,12]) 
x.cartesian(y).collect() 

[(1, 10), (1, 11), (1, 12), (2, 10), (2, 11), (2, 12), (3, 10), (3, 11), (3, 12)] 

Scala:

val x = sc.parallelize(List(1,2,3)) 
val y = sc.parallelize(List(10,11,12)) 
x.cartesian(y).collect 

res0: Array[(Int, Int)] = Array((1,10), (1,11), (1,12), (2,10), (2,11), (2,12), (3,10), (3,11), (3,12))  

对配对 RDD 的变换

一些 Spark 操作仅适用于键值对类型的 RDD。请注意,除了计数操作之外,这些操作通常涉及洗牌,因为与某个键相关的数据可能并不总是驻留在同一个分区中。

groupByKey 操作

类似于 SQL 中的 groupBy 操作,它根据键将输入数据分组,你可以使用 aggregateKeyreduceByKey 来执行聚合操作:

Python:

a = sc.parallelize(["black", "blue", "white", "green", "grey"], 2) 
b = a.groupBy(lambda x: len(x)).collect() 
sorted([(x,sorted(y)) for (x,y) in b]) 

[(4, ['blue', 'grey']), (5, ['black', 'white', 'green'])] 

Scala:

val a = sc.parallelize(List("black", "blue", "white", "green", "grey"), 2) 
val b = a.keyBy(_.length) 
b.groupByKey.collect 

res11: Array[(Int, Iterable[String])] = Array((4,CompactBuffer(blue, grey)), (5,CompactBuffer(black, white, green))) 

join 操作

join 操作接受另一个数据集作为输入。两个数据集应该是键值对类型。结果数据集是另一个键值对数据集,包含两个数据集的键和值:

Python:

a = sc.parallelize(["blue", "green", "orange"], 3) 
b = a.keyBy(lambda x: len(x)) 
c = sc.parallelize(["black", "white", "grey"], 3) 
d = c.keyBy(lambda x: len(x)) 
b.join(d).collect() 
[(4, ('blue', 'grey')), (5, ('green', 'black')), (5, ('green', 'white'))] 

//leftOuterJoin 
b.leftOuterJoin(d).collect() 
[(6, ('orange', None)), (4, ('blue', 'grey')), (5, ('green', 'black')), (5, ('green', 'white'))] 

//rightOuterJoin 
b.rightOuterJoin(d).collect() 
[(4, ('blue', 'grey')), (5, ('green', 'black')), (5, ('green', 'white'))] 

//fullOuterJoin 
b.fullOuterJoin(d).collect() 
[(6, ('orange', None)), (4, ('blue', 'grey')), (5, ('green', 'black')), (5, ('green', 'white'))] 

Scala:

val a = sc.parallelize(List("blue", "green", "orange"), 3) 
val b = a.keyBy(_.length) 
val c = sc.parallelize(List("black", "white", "grey"), 3) 
val d = c.keyBy(_.length) 
b.join(d).collect 
res38: Array[(Int, (String, String))] = Array((4,(blue,grey)), (5,(green,black)), (5,(green,white))) 

//leftOuterJoin 
b.leftOuterJoin(d).collect 
res1: Array[(Int, (String, Option[String]))] = Array((6,(orange,None)), (4,(blue,Some(grey))), (5,(green,Some(black))), (5,(green,Some(white)))) 

//rightOuterJoin 
b.rightOuterJoin(d).collect 
res1: Array[(Int, (Option[String], String))] = Array((4,(Some(blue),grey)), (5,(Some(green),black)), (5,(Some(green),white))) 

//fullOuterJoin 
b.fullOuterJoin(d).collect 
res1: Array[(Int, (Option[String], Option[String]))] = Array((6,(Some(orange),None)), (4,(Some(blue),Some(grey))), (5,(Some(green),Some(black))), (5,(Some(green),Some(white))))  

reduceByKey 操作

reduceByKey 操作使用一个结合性减少函数来合并每个键的值。这也将在每个映射器上本地执行合并,然后将结果发送到减少器并生成哈希分区输出:

Python:

a = sc.parallelize(["black", "blue", "white", "green", "grey"], 2) 
b = a.map(lambda x: (len(x), x)) 
b.reduceByKey(lambda x,y: x + y).collect() 
[(4, 'bluegrey'), (5, 'blackwhitegreen')] 

a = sc.parallelize(["black", "blue", "white", "orange"], 2) 
b = a.map(lambda x: (len(x), x)) 
b.reduceByKey(lambda x,y: x + y).collect() 
[(4, 'blue'), (6, 'orange'), (5, 'blackwhite')] 

Scala:

val a = sc.parallelize(List("black", "blue", "white", "green", "grey"), 2) 
val b = a.map(x => (x.length, x)) 
b.reduceByKey(_ + _).collect 
res86: Array[(Int, String)] = Array((4,bluegrey), (5,blackwhitegreen)) 

val a = sc.parallelize(List("black", "blue", "white", "orange"), 2) 
val b = a.map(x => (x.length, x)) 
b.reduceByKey(_ + _).collect 
res87: Array[(Int, String)] = Array((4,blue), (6,orange), (5,blackwhite))  

聚合操作

aggregrate 操作返回一个 RDD,其中包含每个元组的键:

Python:

z = sc.parallelize([1,2,7,4,30,6], 2) 
z.aggregate(0,(lambda x, y: max(x, y)),(lambda x, y: x + y)) 
37 
z = sc.parallelize(["a","b","c","d"],2) 
z.aggregate("",(lambda x, y: x + y),(lambda x, y: x + y)) 
'abcd' 
z.aggregate("s",(lambda x, y: x + y),(lambda x, y: x + y)) 
'ssabsscds' 
z = sc.parallelize(["12","234","345","56789"],2) 
z.aggregate("",(lambda x, y: str(max(len(str(x)), len(str(y))))),(lambda x, y: str(y) + str(x))) 
'53' 
z.aggregate("",(lambda x, y: str(min(len(str(x)), len(str(y))))),(lambda x, y: str(y) + str(x))) 
'11' 
z = sc.parallelize(["12","234","345",""],2) 
z.aggregate("",(lambda x, y: str(min(len(str(x)), len(str(y))))),(lambda x, y: str(y) + str(x))) 
'01' 

Scala:

val z = sc.parallelize(List(1,2,7,4,30,6), 2) 
z.aggregate(0)(math.max(_, _), _ + _) 
res40: Int = 37 

val z = sc.parallelize(List("a","b","c","d"),2) 
z.aggregate("")(_ + _, _+_) 
res115: String = abcd 

z.aggregate("x")(_ + _, _+_) 
res116: String = xxabxcd 

val z = sc.parallelize(List("12","234","345","56789"),2) 
z.aggregate("")((x,y) => math.max(x.length, y.length).toString, (x,y) => x + y) 
res141: String = 53 

z.aggregate("")((x,y) => math.min(x.length, y.length).toString, (x,y) => x + y) 
res142: String = 11 

val z = sc.parallelize(List("12","234","345",""),2) 
z.aggregate("")((x,y) => math.min(x.length, y.length).toString, (x,y) => x + y) 
res143: String = 01 

注意

请注意,在前面的聚合示例中,结果字符串(例如,abcdxxabxcd5301)不需要与这里显示的输出完全匹配。它取决于各个任务返回其输出的顺序。

Actions

一旦创建了 RDD,各种变换只有在对其执行 action 操作时才会被执行。一个 action 操作的结果可以是写回存储系统的数据,或者返回给发起该操作的驱动程序,以便进一步在本地计算产生最终结果。

我们已经在前面的变换示例中涵盖了一些 action 函数。下面是更多的示例,但还有很多你需要探索的。

collect() 函数

collect() 函数将 RDD 操作的所有结果作为数组返回给驱动程序。通常,针对生成足够小的数据集的操作,这个函数非常有用。理想情况下,结果应该能够轻松适应承载驱动程序的系统的内存。

count() 函数

该操作返回数据集中的元素数量或 RDD 操作的结果输出。

take(n) 函数

take(n)函数返回数据集的前n个元素或 RDD 操作的结果输出。

first()函数

first()函数返回数据集的第一个元素或 RDD 操作的结果输出。它的工作方式与take(1)函数类似。

takeSample()函数

takeSample(withReplacement, num, [seed])函数返回一个包含数据集随机样本元素的数组。它有三个参数,如下所示:

  • withReplacement/withoutReplacement:表示是否进行有放回或无放回的抽样(在多次抽样时,表示是否将旧样本放回集合中再抽取新的样本,或者是直接不放回)。对于withReplacement,参数应为True,否则为False

  • num:表示样本中的元素数量。

  • Seed:这是一个随机数生成器种子(可选)。

countByKey()函数

countByKey()函数仅在键值类型的 RDD 上可用。它返回一个包含(KInt)对的表,表中记录了每个键的计数。

以下是一些 Python 和 Scala 的示例代码:

Python

>>> sc.parallelize([2, 3, 4]).count() 
3 

>>> sc.parallelize([2, 3, 4]).collect() 
[2, 3, 4] 

>>> sc.parallelize([2, 3, 4]).first() 
2 

>>> sc.parallelize([2, 3, 4]).take(2) 
[2, 3] 

Scala

Scala> sc.parallelize(List(2, 3, 4)).count() 
res0: Long = 3 

Scala> sc.parallelize(List(2, 3, 4)).collect() 
res1: Array[Int] = Array(2, 3, 4) 

Scala> sc.parallelize(List(2, 3, 4)).first() 
res2: Int = 2 

Scala> sc.parallelize(List(2, 3, 4)).take(2) 
res3: Array[Int] = Array(2, 3)  

总结

在本章中,我们涉及了支持的编程语言、它们的优缺点以及何时选择一种语言而不是另一种语言。我们讨论了 Spark 引擎的设计以及它的核心组件和执行机制。我们了解了 Spark 如何将待处理的数据发送到多个集群节点。接着我们讨论了一些 RDD 概念,学习了如何通过 Scala 和 Python 创建 RDD 并对其进行转换和操作。我们还讨论了一些 RDD 的高级操作。

在下一章中,我们将详细学习 DataFrame 及其如何证明适用于各种数据科学需求。

参考文献

Scala 语言:

Apache Spark 架构:

Spark 编程指南是学习概念的主要资源;有关可用操作的完整列表,请参阅语言特定的 API 文档:

《Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing》由 Matei Zaharia 等人编写,是 RDD 基础的原始来源:

Spark Summit,Apache Spark 的官方活动系列,提供了丰富的最新信息。查看过去活动的演示文稿和视频:

第三章:DataFrame 介绍

要解决任何实际的“大数据分析”问题,访问一个高效且可扩展的计算系统是绝对必要的。然而,如果计算能力对目标用户而言不易获取且不熟悉,那它几乎毫无意义。当数据集可以表示为命名列时,交互式数据分析会变得更简单,而这在普通的 RDD 中并不适用。因此,采用基于架构的方式来标准化数据表示的需求,正是 DataFrame 的灵感来源。

上一章概述了 Spark 的一些设计方面。我们了解了 Spark 如何通过内存计算在分布式数据集合(RDD)上实现分布式数据处理。它涵盖了大部分揭示 Spark 作为一个快速、高效和可扩展计算平台的要点。在本章中,我们将看到 Spark 如何引入 DataFrame API,使数据科学家能够轻松地进行他们日常的数据分析工作。

本章内容将作为接下来许多章节的基础,我们强烈建议你充分理解这里涵盖的概念。作为本章的前提,需具备 SQL 和 Spark 的基本理解。本章涵盖的主题如下:

  • 为什么选择 DataFrame?

  • Spark SQL

    • Catalyst 优化器
  • DataFrame API

    • DataFrame 基础

    • RDD 与 DataFrame

  • 创建 DataFrame

    • 来自 RDD

    • 来自 JSON

    • 来自 JDBC 数据源

    • 来自其他数据源

  • 操作 DataFrame

为什么选择 DataFrame?

除了强大的、可扩展的计算能力外,大数据应用还需要结合一些额外的特性,例如支持交互式数据分析的关系系统(简单的 SQL 风格)、异构数据源以及不同的存储格式和处理技术。

尽管 Spark 提供了一个函数式编程 API 来操作分布式数据集合,但最终结果还是元组(_1、_2、...)。在元组上编写代码稍显复杂且凌乱,且有时速度较慢。因此,迫切需要一个标准化的层,具备以下特点:

  • 使用带有架构的命名列(比元组更高层次的抽象),使得对它们进行操作和追踪变得容易

  • 提供将来自不同数据源的数据(如 Hive、Parquet、SQL Server、PostgreSQL、JSON,以及 Spark 原生的 RDD)整合并统一为一个公共格式的功能

  • 能够利用 Avro、CSV、JSON 等特殊文件格式中的内置架构。

  • 支持简单的关系运算以及复杂的逻辑操作

  • 消除了基于领域特定任务为机器学习算法定义列对象的需求,作为机器学习库(MLlib)中所有算法的公共数据层

  • 一个语言无关的实体,可以在不同语言的函数之间传递

为了满足上述需求,DataFrame API 被构建为在 Spark SQL 之上的又一抽象层。

Spark SQL

执行 SQL 查询以满足基本业务需求是非常常见的,几乎每个企业都通过某种数据库进行此操作。所以 Spark SQL 也支持执行使用基本 SQL 语法或 HiveQL 编写的 SQL 查询。Spark SQL 还可以用来从现有的 Hive 安装中读取数据。除了这些简单的 SQL 操作外,Spark SQL 还解决了一些棘手的问题。通过关系查询设计复杂逻辑曾经是繁琐的,甚至在某些时候几乎是不可能的。因此,Spark SQL 旨在将关系处理和函数式编程的能力结合起来,从而实现、优化和在分布式计算环境中扩展复杂逻辑。基本上有三种方式与 Spark SQL 交互,包括 SQL、DataFrame API 和 Dataset API。Dataset API 是在写本书时 Spark 1.6 中加入的实验性层,因此我们将仅限于讨论 DataFrame。

Spark SQL 将 DataFrame 显示为更高级的 API,处理所有涉及的复杂性并执行所有后台任务。通过声明式语法,用户可以专注于程序应该完成什么,而不必担心控制流,因为这将由内置于 Spark SQL 中的 Catalyst 优化器处理。

Catalyst 优化器

Catalyst 优化器是 Spark SQL 和 DataFrame 的支点。它通过 Scala 的函数式编程构造构建,具有以下特点:

  • 从各种数据格式中推断模式:

    • Spark 内置支持 JSON 模式推断。用户只需将任何 JSON 文件注册为表,并通过 SQL 语法进行查询,即可创建该表。

    • RDD 是 Scala 对象;类型信息通过 Scala 的类型系统提取,也就是案例类,如果它们包含案例类的话。

    • RDD 是 Python 对象;类型信息通过不同的方式提取。由于 Python 不是静态类型的,遵循动态类型系统,因此 RDD 可以包含多种类型。因此,Spark SQL 会对数据集进行采样,并使用类似于 JSON 模式推断的算法推断模式。

    • 未来将提供对 CSV、XML 和其他格式的内置支持。

  • 内置支持多种数据源和查询联合,以高效导入数据:

    • Spark 具有内置机制,可以通过查询联合从某些外部数据源(例如 JSON、JDBC、Parquet、MySQL、Hive、PostgreSQL、HDFS、S3 等)获取数据。它可以通过使用开箱即用的 SQL 数据类型和其他复杂数据类型,如 Struct、Union、Array 等,精确建模源数据。

    • 它还允许用户使用数据源 API从 Spark 原生不支持的数据源中获取数据(例如 CSV、Avro、HBase、Cassandra 等)。

    • Spark 使用谓词下推(将过滤或聚合操作推送到外部存储系统)来优化从外部系统获取数据,并将它们结合形成数据管道。

  • 代码生成的控制与优化:

    • 优化实际上发生在整个执行管道的最后阶段。

    • Catalyst 被设计用来优化查询执行的所有阶段:分析、逻辑优化、物理规划和代码生成,将查询的部分内容编译为 Java 字节码。

DataFrame API

类似于 Excel 表格的数据表示,或来自数据库投影(select 语句的输出),数据表示最接近人类理解的始终是由多个行和统一列组成的集合。这样的二维数据结构,通常具有带标签的行和列,在一些领域被称为 DataFrame,例如 R 的 DataFrame 和 Python 的 Pandas DataFrame。在 DataFrame 中,通常单列包含相同类型的数据,行描述与该列相关的数据点,这些数据点一起表达某些含义,无论是关于一个人的信息、一次购买,还是一场棒球比赛的结果。你可以将它视为矩阵、电子表格或关系型数据库表。

在 R 和 Pandas 中,DataFrame 在切片、重塑和分析数据方面非常方便——这些是任何数据清洗和数据分析工作流中的基本操作。这启发了在 Spark 中开发类似的概念,称为 DataFrame。

DataFrame 基础

DataFrame API 首次在 Spark 1.3.0 版本中引入,发布于 2015 年 3 月。它是 Spark SQL 对结构化和半结构化数据处理的编程抽象。它使开发人员能够通过 Python、Java、Scala 和 R 等语言利用 DataFrame 数据结构的强大功能。与 RDD 相似,Spark DataFrame 是一组分布式记录,按命名列组织,类似于关系型数据库管理系统(RDBMS)中的表或 R 或 Pandas 中的 DataFrame。与 RDD 不同的是,DataFrame 会跟踪模式(schema),并促进关系型操作以及程序化操作,如 map。在内部,DataFrame 以列式格式存储数据,但在需要程序化函数时,会动态构建行对象。

DataFrame API 带来了两个特点:

  • 内置对多种数据格式的支持,如 Parquet、Hive 和 JSON。然而,通过 Spark SQL 的外部数据源 API,DataFrame 可以访问各种第三方数据源,如数据库和 NoSQL 存储。

  • 一个更强大、功能丰富的 DSL,内置了许多用于常见任务的函数,如:

    • 元数据

    • 抽样

    • 关系型数据处理 - 投影、过滤、聚合、连接

    • 用户定义函数(UDFs)

DataFrame API 基于 Spark SQL 查询优化器,自动在集群的机器上高效执行代码。

RDDs 与 DataFrames 的区别

RDD 和 DataFrame 是 Spark 提供的两种不同类型的容错分布式数据抽象。它们在某些方面相似,但在实现上有很大区别。开发者需要清楚理解它们之间的差异,以便能够将需求与合适的抽象匹配。

相似性

以下是 RDD 和 DataFrame 之间的相似性:

  • 两者都是 Spark 中容错的、分区的数据抽象

  • 两者都能处理不同的数据源

  • 两者都是惰性求值的(执行发生在对它们进行输出操作时),从而能够采用最优化的执行计划。

  • 这两个 API 在四种语言中都可用:Scala、Python、Java 和 R。

区别

以下是 RDD 和 DataFrame 之间的区别:

  • DataFrame 是比 RDD 更高级的抽象。

  • 定义 RDD 意味着定义一个 有向无环图 (DAG),而定义 DataFrame 则会创建一个 抽象语法树 (AST)。AST 将由 Spark SQL catalyst 引擎使用并优化。

  • RDD 是一种通用的数据结构抽象,而 DataFrame 是专门用于处理二维表格数据的结构。

DataFrame API 实际上是 SchemaRDD 的重命名。重命名的目的是为了表明它不再继承自 RDD,并且为了让数据科学家对这个熟悉的名称和概念感到安心。

创建 DataFrame

Spark DataFrame 的创建方式类似于 RDD 的创建方式。要访问 DataFrame API,你需要 SQLContext 或 HiveContext 作为入口点。在本节中,我们将演示如何从各种数据源创建 DataFrame,从基本的内存集合代码示例开始:

创建 DataFrame

从 RDD 创建 DataFrame

以下代码从一个包含颜色的列表创建一个 RDD,然后是包含颜色名称及其长度的元组集合。它使用 toDF 方法将 RDD 转换为 DataFrame。toDF 方法接受一个可选的列标签列表作为参数:

Python

   //Create a list of colours 
>>> colors = ['white','green','yellow','red','brown','pink'] 
//Distribute a local collection to form an RDD 
//Apply map function on that RDD to get another RDD containing colour, length tuples 
>>> color_df = sc.parallelize(colors) 
        .map(lambda x:(x,len(x))).toDF(["color","length"]) 

>>> color_df 
DataFrame[color: string, length: bigint] 

>>> color_df.dtypes        //Note the implicit type inference 
[('color', 'string'), ('length', 'bigint')] 

>>> color_df.show()  //Final output as expected. Order need not be the same as shown 
+------+------+ 
| color|length| 
+------+------+ 
| white|     5| 
| green|     5| 
|yellow|     6| 
|   red|     3| 
| brown|     5| 
|  pink|     4| 
+------+------+ 

Scala

//Create a list of colours 
Scala> val colors = List("white","green","yellow","red","brown","pink") 
//Distribute a local collection to form an RDD 
//Apply map function on that RDD to get another RDD containing colour, length tuples 
Scala> val color_df = sc.parallelize(colors) 
         .map(x => (x,x.length)).toDF("color","length") 

Scala> color_df 
res0: org.apache.spark.sql.DataFrame = [color: string, length: int] 

Scala> color_df.dtypes  //Note the implicit type inference   
res1: Array[(String, String)] = Array((color,StringType), (length,IntegerType)) 

Scala> color_df.show()//Final output as expected. Order need not be the same as shown 
+------+------+ 
| color|length| 
+------+------+ 
| white|     5| 
| green|     5| 
|yellow|     6| 
|   red|     3| 
| brown|     5| 
|  pink|     4| 
+------+------+ 

从前面的例子可以看出,从开发者的角度来看,创建 DataFrame 与创建 RDD 类似。我们在这里创建了一个 RDD,然后将其转换为元组,这些元组接着被传递到 toDF 方法中。请注意,toDF 接受的是元组列表,而不是标量元素。即使是创建单列的 DataFrame,你也需要传递元组。每个元组类似于一行。你可以选择为列命名,否则 Spark 会创建一些模糊的名称,如 _1_2。列的类型推断是隐式进行的。

如果你已经拥有作为 RDD 的数据,Spark SQL 支持两种不同的方法将现有的 RDD 转换为 DataFrame:

  • 第一种方法通过反射推断 RDD 的模式,这些 RDD 包含特定类型的对象,意味着你已经知道该模式。

  • 第二种方法是通过编程接口,它让你可以构建一个模式,并将其应用到现有的 RDD 上。虽然这种方法更加冗长,但它允许在运行时列类型未知时构建 DataFrame。

从 JSON 创建 DataFrame

JavaScript 对象表示法(JSON)是一种独立于语言的、自描述的、轻量级的数据交换格式。JSON 已经成为一种流行的数据交换格式,并且无处不在。除了 JavaScript 和 RESTful 接口之外,像 MySQL 这样的数据库已经接受 JSON 作为数据类型,MongoDB 将所有数据作为二进制格式的 JSON 文档存储。数据与 JSON 之间的转换对于任何现代数据分析工作流来说都是至关重要的。Spark 的 DataFrame API 让开发人员可以将 JSON 对象转换为 DataFrame,反之亦然。让我们通过以下示例深入了解,帮助更好地理解:

Python

//Pass the source json data file path 
>>> df = sqlContext.read.json("./authors.json") 
>>> df.show() //json parsed; Column names and data    types inferred implicitly 
+----------+---------+ 
|first_name|last_name| 
+----------+---------+ 
|      Mark|    Twain| 
|   Charles|  Dickens| 
|    Thomas|    Hardy| 
+----------+---------+ 

Scala

//Pass the source json data file path 
Scala> val df = sqlContext.read.json("./authors.json") 
Scala> df.show()  //json parsed; Column names and    data types inferred implicitly 
+----------+---------+ 
|first_name|last_name| 
+----------+---------+ 
|      Mark|    Twain| 
|   Charles|  Dickens| 
|    Thomas|    Hardy| 
+----------+---------+ 

Spark 会自动推断模式并根据键创建相应的 DataFrame。

使用 JDBC 从数据库创建 DataFrame

Spark 允许开发人员通过 JDBC 从其他数据库创建 DataFrame,前提是你确保目标数据库的 JDBC 驱动程序可以访问。JDBC 驱动程序是一个软件组件,它允许 Java 应用程序与数据库进行交互。不同的数据库需要不同的驱动程序。通常,像 MySQL 这样的数据库提供商会提供这些驱动程序组件来访问他们的数据库。你必须确保你拥有适用于目标数据库的正确驱动程序。

以下示例假设你已经在给定的 URL 上运行了 MySQL 数据库,并且数据库 test 中有一个名为 people 的表,并且其中有一些数据,此外,你也有有效的凭证用于登录。还有一个额外的步骤是使用适当的 JAR 文件重新启动 REPL shell:

注意

如果你系统中尚未拥有 JAR 文件,可以从 MySQL 网站通过以下链接下载:dev.mysql.com/downloads/connector/j/

Python

//Launch shell with driver-class-path as a command line argument 
pyspark --driver-class-path /usr/share/   java/mysql-connector-java.jar 
   //Pass the connection parameters 
>>> peopleDF = sqlContext.read.format('jdbc').options( 
                        url = 'jdbc:mysql://localhost', 
                        dbtable = 'test.people', 
                        user = 'root', 
                        password = 'mysql').load() 
   //Retrieve table data as a DataFrame 
>>> peopleDF.show() 
+----------+---------+------+----------+----------+---------+ 
|first_name|last_name|gender|       dob|occupation|person_id| 
+----------+---------+------+----------+----------+---------+ 
|    Thomas|    Hardy|     M|1840-06-02|    Writer|      101| 
|     Emily|   Bronte|     F|1818-07-30|    Writer|      102| 
| Charlotte|   Bronte|     F|1816-04-21|    Writer|      103| 
|   Charles|  Dickens|     M|1812-02-07|    Writer|      104| 
+----------+---------+------+----------+----------+---------+ 

Scala

//Launch shell with driver-class-path as a command line argument 
spark-shell --driver-class-path /usr/share/   java/mysql-connector-java.jar 
   //Pass the connection parameters 
scala> val peopleDF = sqlContext.read.format("jdbc").options( 
           Map("url" -> "jdbc:mysql://localhost", 
               "dbtable" -> "test.people", 
               "user" -> "root", 
               "password" -> "mysql")).load() 
peopleDF: org.apache.spark.sql.DataFrame = [first_name: string, last_name: string, gender: string, dob: date, occupation: string, person_id: int] 
//Retrieve table data as a DataFrame 
scala> peopleDF.show() 
+----------+---------+------+----------+----------+---------+ 
|first_name|last_name|gender|       dob|occupation|person_id| 
+----------+---------+------+----------+----------+---------+ 
|    Thomas|    Hardy|     M|1840-06-02|    Writer|      101| 
|     Emily|   Bronte|     F|1818-07-30|    Writer|      102| 
| Charlotte|   Bronte|     F|1816-04-21|    Writer|      103| 
|   Charles|  Dickens|     M|1812-02-07|    Writer|      104| 
+----------+---------+------+----------+----------+---------+ 

从 Apache Parquet 创建 DataFrame

Apache Parquet 是一种高效的、压缩的列式数据表示方式,任何 Hadoop 生态系统中的项目都可以使用它。列式数据表示通过列来存储数据,而不是传统的按行存储数据。需要频繁查询多列(通常是两三列)的使用场景特别适合这种存储方式,因为列在磁盘上是连续存储的,而不像行存储那样需要读取不需要的列。另一个优点是在压缩方面。单列中的数据属于同一类型,值通常相似,有时甚至完全相同。这些特性极大地提高了压缩和编码的效率。Parquet 允许在列级别指定压缩方案,并允许随着新压缩编码的发明与实现而添加更多的编码方式。

Apache Spark 提供对 Parquet 文件的读取和写入支持,这些操作会自动保留原始数据的模式。以下示例将前面例子中加载到 DataFrame 中的人员数据写入 Parquet 格式,然后重新读取到 RDD 中:

Python

//Write DataFrame contents into Parquet format 
>>> peopleDF.write.parquet('writers.parquet') 
//Read Parquet data into another DataFrame 
>>> writersDF = sqlContext.read.parquet('writers.parquet')  
writersDF: org.apache.spark.sql.DataFrame = [first_name:    string, last_name: string, gender: string, dob:    date, occupation: string, person_id: int]

Scala

//Write DataFrame contents into Parquet format 
scala> peopleDF.write.parquet("writers.parquet") 
//Read Parquet data into another DataFrame 
scala> val writersDF = sqlContext.read.parquet("writers.parquet")  
writersDF: org.apache.spark.sql.DataFrame = [first_name:    string, last_name: string, gender: string, dob:    date, occupation: string, person_id: int]

从其他数据源创建 DataFrame

Spark 为多个数据源(如 JSON、JDBC、HDFS、Parquet、MYSQL、Amazon S3 等)提供内建支持。此外,它还提供了一个数据源 API,提供了一种可插拔的机制,通过 Spark SQL 访问结构化数据。基于这个可插拔组件,构建了多个库,例如 CSV、Avro、Cassandra 和 MongoDB 等。这些库不属于 Spark 代码库,它们是为特定数据源构建的,并托管在社区网站 Spark Packages 上。

DataFrame 操作

在本章的上一节中,我们学习了许多不同的方法来创建 DataFrame。在本节中,我们将重点介绍可以在 DataFrame 上执行的各种操作。开发者通过链式调用多个操作来过滤、转换、聚合和排序 DataFrame 中的数据。底层的 Catalyst 优化器确保这些操作的高效执行。这些函数类似于你在 SQL 操作中对表常见的操作:

Python

//Create a local collection of colors first 
>>> colors = ['white','green','yellow','red','brown','pink'] 
//Distribute the local collection to form an RDD 
//Apply map function on that RDD to get another RDD containing colour, length tuples and convert that RDD to a DataFrame 
>>> color_df = sc.parallelize(colors) 
        .map(lambda x:(x,len(x))).toDF(['color','length']) 
//Check the object type 
>>> color_df 
DataFrame[color: string, length: bigint] 
//Check the schema 
>>> color_df.dtypes 
[('color', 'string'), ('length', 'bigint')] 

//Check row count 
>>> color_df.count() 
6 
//Look at the table contents. You can limit displayed rows by passing parameter to show 
color_df.show() 
+------+------+ 
| color|length| 
+------+------+ 
| white|     5| 
| green|     5| 
|yellow|     6| 
|   red|     3| 
| brown|     5| 
|  pink|     4| 
+------+------+ 

//List out column names 
>>> color_df.columns 
[u'color', u'length'] 

//Drop a column. The source DataFrame color_df remains the same. //Spark returns a new DataFrame which is being passed to show 
>>> color_df.drop('length').show() 
+------+ 
| color| 
+------+ 
| white| 
| green| 
|yellow| 
|   red| 
| brown| 
|  pink| 
+------+ 
//Convert to JSON format 
>>> color_df.toJSON().first() 
u'{"color":"white","length":5}' 
//filter operation is similar to WHERE clause in SQL 
//You specify conditions to select only desired columns and rows 
//Output of filter operation is another DataFrame object that is usually passed on to some more operations 
//The following example selects the colors having a length of four or five only and label the column as "mid_length" 
filter 
------ 
>>> color_df.filter(color_df.length.between(4,5)) 
      .select(color_df.color.alias("mid_length")).show() 
+----------+ 
|mid_length| 
+----------+ 
|     white| 
|     green| 
|     brown| 
|      pink| 
+----------+ 

//This example uses multiple filter criteria 
>>> color_df.filter(color_df.length > 4) 
     .filter(color_df[0]!="white").show() 
+------+------+ 
| color|length| 
+------+------+ 
| green|     5| 
|yellow|     6| 
| brown|     5| 
+------+------+ 

//Sort the data on one or more columns 
sort 
---- 
//A simple single column sorting in default (ascending) order 
>>> color_df.sort("color").show() 
+------+------+ 
| color|length| 
+------+------+ 
| brown|     5| 
| green|     5| 
|  pink|     4| 
|   red|     3| 
| white|     5| 
|yellow|     6| 
+------+------+ 
//First filter colors of length more than 4 and then sort on multiple columns 
//The Filtered rows are sorted first on the column length in default ascending order. Rows with same length are sorted on color in descending order   
>>> color_df.filter(color_df['length']>=4).sort("length", 'color',ascending=False).show()
+------+------+ 
| color|length| 
+------+------+ 
|yellow|     6| 
| white|     5| 
| green|     5| 
| brown|     5| 
|  pink|     4| 
+------+------+ 

//You can use orderBy instead, which is an alias to sort 
>>> color_df.orderBy('length','color').take(4)
[Row(color=u'red', length=3), Row(color=u'pink', length=4), Row(color=u'brown', length=5), Row(color=u'green', length=5)]

//Alternative syntax, for single or multiple columns.  
>>> color_df.sort(color_df.length.desc(),   color_df.color.asc()).show() 
+------+------+ 
| color|length| 
+------+------+ 
|yellow|     6| 
| brown|     5| 
| green|     5| 
| white|     5| 
|  pink|     4| 
|   red|     3| 
+------+------+ 
//All the examples until now have been acting on one row at a time, filtering or transforming or reordering.  
//The following example deals with regrouping the data 
//These operations require "wide dependency" and often involve shuffling.  
groupBy 
------- 
>>> color_df.groupBy('length').count().show() 
+------+-----+ 
|length|count| 
+------+-----+ 
|     3|    1| 
|     4|    1| 
|     5|    3| 
|     6|    1| 
+------+-----+ 
//Data often contains missing information or null values. We may want to drop such rows or replace with some filler information. dropna is provided for dropping such rows 
//The following json file has names of famous authors. Firstname data is missing in one row. 
dropna 
------ 
>>> df1 = sqlContext.read.json('./authors_missing.json')
>>> df1.show() 
+----------+---------+ 
|first_name|last_name| 
+----------+---------+ 
|      Mark|    Twain| 
|   Charles|  Dickens| 
|      null|    Hardy| 
+----------+---------+ 

//Let us drop the row with incomplete information 
>>> df2 = df1.dropna() 
>>> df2.show()  //Unwanted row is dropped 
+----------+---------+ 
|first_name|last_name| 
+----------+---------+ 
|      Mark|    Twain| 
|   Charles|  Dickens| 
+----------+---------+ 

Scala

//Create a local collection of colors first 
Scala> val colors = List("white","green","yellow","red","brown","pink") 
//Distribute a local collection to form an RDD 
//Apply map function on that RDD to get another RDD containing color, length tuples and convert that RDD to a DataFrame 
Scala> val color_df = sc.parallelize(colors) 
        .map(x => (x,x.length)).toDF("color","length") 
//Check the object type 
Scala> color_df 
res0: org.apache.spark.sql.DataFrame = [color: string, length: int] 
//Check the schema 
Scala> color_df.dtypes 
res1: Array[(String, String)] = Array((color,StringType), (length,IntegerType)) 
//Check row count 
Scala> color_df.count() 
res4: Long = 6 
//Look at the table contents. You can limit displayed rows by passing parameter to show 
color_df.show() 
+------+------+ 
| color|length| 
+------+------+ 
| white|     5| 
| green|     5| 
|yellow|     6| 
|   red|     3| 
| brown|     5| 
|  pink|     4| 
+------+------+ 
//List out column names 
Scala> color_df.columns 
res5: Array[String] = Array(color, length) 
//Drop a column. The source DataFrame color_df remains the same. 
//Spark returns a new DataFrame which is being passed to show 
Scala> color_df.drop("length").show() 
+------+ 
| color| 
+------+ 
| white| 
| green| 
|yellow| 
|   red| 
| brown| 
|  pink| 
+------+ 
//Convert to JSON format 
color_df.toJSON.first() 
res9: String = {"color":"white","length":5} 

//filter operation is similar to WHERE clause in SQL 
//You specify conditions to select only desired columns and rows 
//Output of filter operation is another DataFrame object that is usually passed on to some more operations 
//The following example selects the colors having a length of four or five only and label the column as "mid_length" 
filter 
------ 
Scala> color_df.filter(color_df("length").between(4,5)) 
       .select(color_df("color").alias("mid_length")).show() 
+----------+ 
|mid_length| 
+----------+ 
|     white| 
|     green| 
|     brown| 
|      pink| 
+----------+ 

//This example uses multiple filter criteria. Notice the not equal to operator having double equal to symbols  
Scala> color_df.filter(color_df("length") > 4).filter(color_df( "color")!=="white").show() 
+------+------+ 
| color|length| 
+------+------+ 
| green|     5| 
|yellow|     6| 
| brown|     5| 
+------+------+ 
//Sort the data on one or more columns 
sort 
---- 
//A simple single column sorting in default (ascending) order 
Scala> color_df..sort("color").show() 
+------+------+                                                                  
| color|length| 
+------+------+ 
| brown|     5| 
| green|     5| 
|  pink|     4| 
|   red|     3| 
| white|     5| 
|yellow|     6| 
+------+------+ 
//First filter colors of length more than 4 and then sort on multiple columns 
//The filtered rows are sorted first on the column length in default ascending order. Rows with same length are sorted on color in descending order  
Scala> color_df.filter(color_df("length")>=4).sort($"length", $"color".desc).show() 
+------+------+ 
| color|length| 
+------+------+ 
|  pink|     4| 
| white|     5| 
| green|     5| 
| brown|     5| 
|yellow|     6| 
+------+------+ 
//You can use orderBy instead, which is an alias to sort. 
scala> color_df.orderBy("length","color").take(4) 
res19: Array[org.apache.spark.sql.Row] = Array([red,3], [pink,4], [brown,5], [green,5]) 
//Alternative syntax, for single or multiple columns 
scala> color_df.sort(color_df("length").desc, color_df("color").asc).show() 
+------+------+ 
| color|length| 
+------+------+ 
|yellow|     6| 
| brown|     5| 
| green|     5| 
| white|     5| 
|  pink|     4| 
|   red|     3| 
+------+------+ 
//All the examples until now have been acting on one row at a time, filtering or transforming or reordering. 
//The following example deals with regrouping the data.  
//These operations require "wide dependency" and often involve shuffling. 
groupBy 
------- 
Scala> color_df.groupBy("length").count().show() 
+------+-----+ 
|length|count| 
+------+-----+ 
|     3|    1| 
|     4|    1| 
|     5|    3| 
|     6|    1| 
+------+-----+ 
//Data often contains missing information or null values.  
//The following json file has names of famous authors. Firstname data is missing in one row. 
dropna 
------ 
Scala> val df1 = sqlContext.read.json("./authors_missing.json") 
Scala> df1.show() 
+----------+---------+ 
|first_name|last_name| 
+----------+---------+ 
|      Mark|    Twain| 
|   Charles|  Dickens| 
|      null|    Hardy| 
+----------+---------+ 
//Let us drop the row with incomplete information 
Scala> val df2 = df1.na.drop() 
Scala> df2.show()  //Unwanted row is dropped 
+----------+---------+ 
|first_name|last_name| 
+----------+---------+ 
|      Mark|    Twain| 
|   Charles|  Dickens| 
+----------+---------+ 

底层原理

你现在已经知道,DataFrame API 由 Spark SQL 赋能,而 Spark SQL 的 Catalyst 优化器在优化性能中起着至关重要的作用。

尽管查询是惰性执行的,但它使用了 Catalyst 的catalog组件来识别程序或表达式中使用的列名是否存在于正在使用的表中,且数据类型是否正确,并采取了许多其他预防性措施。采用这种方法的优点是,用户一输入无效表达式,错误就会立刻弹出,而不是等到程序执行时才发现。

总结

在本章中,我们解释了开发 Spark DataFrame API 的动机,以及开发 Spark 使得编程变得比以往更加容易。我们简要介绍了 DataFrame API 的设计理念,以及它如何建立在 Spark SQL 之上。我们讨论了从不同数据源(如 RDD、JSON、Parquet 和 JDBC)创建 DataFrame 的多种方式。在本章结束时,我们简单提到了如何在 DataFrame 上执行操作。我们将在接下来的章节中,结合数据科学和机器学习,更详细地讨论 DataFrame 操作。

在下一章中,我们将学习 Spark 如何支持统一的数据访问,并详细讨论 Dataset 和 Structured Stream 组件。

参考文献

Apache Spark 官方文档中的 DataFrame 参考:

Databricks:在 Apache Spark 中引入 DataFrames,用于大规模数据科学:

Databricks:从 Pandas 到 Apache Spark 的 DataFrame:

Scala API 参考指南,适用于 Spark DataFrames:

Cloudera 博客文章:Parquet——一种高效的通用列式存储格式,适用于 Apache Hadoop:

第四章:统一数据访问

来自不同数据源的数据集成一直是一个艰巨的任务。大数据的三大特征(量、速度、种类)和不断缩短的处理时间框架使得这一任务更加具有挑战性。以接近实时的方式提供清晰且精心整理的数据视图对于企业至关重要。然而,实时整理的数据以及以统一方式执行不同操作(如 ETL、临时查询和机器学习)的能力,正在成为企业的关键差异化因素。

Apache Spark 的创建旨在提供一个通用引擎,能够处理来自各种数据源的数据,并支持大规模的数据处理,适用于各种不同的操作。Spark 使得开发人员能够将 SQL、流处理、图计算和机器学习算法结合到一个工作流中!

在前几章中,我们讨论了弹性分布式数据集RDD)以及数据框(DataFrames)。在第三章,数据框简介中,我们介绍了 Spark SQL 和 Catalyst 优化器。本章在此基础上进行扩展,深入探讨这些主题,帮助你理解统一数据访问的真正本质。我们将介绍新概念,如数据集(Datasets)和结构化流处理(Structured Streaming)。具体来说,我们将讨论以下内容:

  • Apache Spark 中的数据抽象

  • 数据集

    • 使用数据集

    • 数据集 API 限制

  • Spark SQL

    • SQL 操作

    • 底层实现

  • 结构化流处理

    • Spark 流处理编程模型

    • 底层实现

    • 与其他流处理引擎的比较

  • 持续应用

  • 总结

Apache Spark 中的数据抽象

MapReduce 框架及其流行的开源实现 Hadoop 在过去十年中得到了广泛的应用。然而,迭代算法和交互式临时查询并不被很好地支持。在算法中的作业或阶段之间,任何数据共享总是通过磁盘读写实现,而不是内存数据共享。因此,逻辑上的下一步应该是拥有一种机制,能够在多个作业之间复用中间结果。RDD 是一种通用数据抽象,旨在解决这一需求。

RDD 是 Apache Spark 中的核心抽象。它是一个不可变的、容错的、分布式的静态类型对象集合,通常存储在内存中。RDD API 提供了简单的操作,如 map、reduce 和 filter,这些操作可以按任意方式组合。

DataFrame 抽象是在 RDD 之上构建的,并增加了“命名”列。因此,Spark DataFrame 具有类似于关系型数据库表格和 R、Python(pandas)中的 DataFrame 的命名列行。这种熟悉的高级抽象大大简化了开发工作,因为它让你可以像对待 SQL 表或 Excel 文件一样处理数据。此外,Catalyst 优化器在背后会编译操作并生成 JVM 字节码,以实现高效执行。然而,命名列的方法带来了一个新问题。静态类型信息不再对编译器可用,因此我们失去了编译时类型安全的优势。

Dataset API 的引入结合了 RDD 和 DataFrame 的优点,并增加了一些独特的功能。Datasets 提供了类似于 DataFrame 的行列数据抽象,但在其之上定义了结构。这个结构可以通过 Scala 中的 case class 或 Java 中的类来定义。它们提供了类型安全和类似 RDD 的 Lambda 函数。因此,它们支持类型化方法,如mapgroupByKey,也支持非类型化方法,如selectgroupBy。除了 Catalyst 优化器外,Datasets 还利用了 Tungsten 执行引擎提供的内存编码,这进一步提升了性能。

到目前为止,介绍的数据抽象构成了核心抽象。在这些抽象之上,还有一些更为专门化的数据抽象。Streaming API 被引入用于处理来自 Flume 和 Kafka 等各种来源的实时流数据。这些 API 协同工作,为数据工程师提供了一个统一的、连续的 DataFrame 抽象,可以用于交互式和批量查询。另一种专门化的数据抽象是 GraphFrame,它使开发者能够分析社交网络和其他图形数据,同时处理类似 Excel 的二维数据。

现在,了解了可用数据抽象的基本概念后,让我们来理解“统一数据访问平台”到底意味着什么:

Apache Spark 中的数据抽象

这个统一平台的目的是不仅可以将静态数据和流式数据结合在一起,还可以以统一的方式对数据进行各种不同类型的操作!从开发者的角度来看,Dataset 是核心抽象,Spark SQL 是与 Spark 功能交互的主要接口。结合 SQL 声明式编程接口的二维数据结构已成为处理数据的常见方式,从而缩短了数据工程师的学习曲线。因此,理解统一平台就意味着理解 Datasets 和 Spark SQL。

Datasets

Apache Spark 数据集是 DataFrame API 的扩展,提供了一种类型安全的面向对象编程接口。这个 API 最早在 1.6 版本中引入。Spark 2.0 版本实现了 DataFrame 和 Dataset API 的统一。DataFrame 变成了一个通用的、无类型的数据集;或者说,数据集是一个添加了结构的 DataFrame。这里的“结构”一词指的是底层数据的模式或组织,类似于关系型数据库中的表模式。结构对底层数据中可以表达或包含的内容施加了限制。这反过来能够在内存组织和物理执行上实现更好的优化。编译时的类型检查可以比运行时更早地捕获错误。例如,SQL 比较中的类型不匹配直到运行时才会被发现,而如果它是作为数据集操作序列表达的,则会在编译时被捕获。然而,Python 和 R 的固有动态特性意味着这些语言没有编译时类型安全,因此数据集的概念不适用于这些语言。数据集和 DataFrame 的统一仅适用于 Scala 和 Java API。

数据集抽象的核心是 编码器。这些编码器用于在 JVM 对象和 Spark 内部的 Tungsten 二进制格式之间进行转换。此内部表示绕过了 JVM 的内存管理和垃圾回收。Spark 有自己专门为其支持的工作流编写的 C 风格内存访问方式。最终的内部表示占用更少的内存,并且具有高效的内存管理。紧凑的内存表示在 Shuffle 操作中减少了网络负载。编码器生成的紧凑字节码直接在序列化对象上操作,而无需反序列化,从而提高了性能。提前了解模式能够在缓存数据集时实现更优化的内存布局。

使用数据集

在本节中,我们将创建数据集,并执行转换和操作,类似于 DataFrame 和 RDD。

示例 1 - 从简单集合创建数据集:

Scala:

//Create a Dataset from a simple collection 
scala> val ds1 = List.range(1,5).toDS() 
ds1: org.apache.spark.sql.Dataset[Int] = [value: int] 
//Perform an action 
scala> ds1.collect() 
res3: Array[Int] = Array(1, 2, 3, 4) 

//Create from an RDD 
scala> val colors = List("red","orange","blue","green","yellow") 
scala> val color_ds = sc.parallelize(colors).map(x => 
     (x,x.length)).toDS() 
//Add a case class 
case class Color(var color: String, var len: Int) 
val color_ds = sc.parallelize(colors).map(x => 
     Color(x,x.length)).toDS() 

如前面代码中的最后一个示例所示,case class 添加了结构信息。Spark 使用此结构来创建最佳的数据布局和编码。以下代码展示了我们要查看的结构和执行计划:

Scala:

//Examine the structure 
scala> color_ds.dtypes 
res26: Array[(String, String)] = Array((color,StringType), (len,IntegerType)) 
scala> color_ds.schema 
res25: org.apache.spark.sql.types.StructType = StructType(StructField(color,StringType,true), 
StructField(len,IntegerType,false)) 
//Examine the execution plan 
scala> color_ds.explain() 
== Physical Plan == 
Scan ExistingRDD[color#57,len#58] 

上面的示例展示了预期的结构和物理执行计划。如果你想查看更详细的执行计划,必须传入 explain(true),这会打印扩展信息,包括逻辑计划。

我们已经检查了如何从简单集合和 RDD 创建数据集。我们已经讨论过,DataFrame 只是无类型的数据集。以下示例展示了数据集和 DataFrame 之间的转换。

示例 2 - 将数据集转换为 DataFrame

Scala:

//Convert the dataset to a DataFrame 
scala> val color_df = color_ds.toDF() 
color_df: org.apache.spark.sql.DataFrame = [color: string, len: int] 

scala> color_df.show() 
+------+---+ 
| color|len| 
+------+---+ 
|   red|  3| 
|orange|  6| 
|  blue|  4| 
| green|  5| 
|yellow|  6| 
+------+---+ 

这个示例与我们在第三章,数据框介绍中看到的示例非常相似。这些转换在现实世界中非常实用。考虑为不完美的数据添加一个结构(也叫案例类)。您可以先将这些数据读取到 DataFrame 中,进行清洗,然后转换成 Dataset。另一个应用场景是,您可能希望根据某些运行时信息(如 user_id)仅暴露数据的子集(行和列)。您可以将数据读取到 DataFrame 中,将其注册为临时表,应用条件,然后将子集暴露为 Dataset。以下示例首先创建一个 DataFrame,然后将其转换为 Dataset。请注意,DataFrame 的列名必须与案例类匹配。

示例 3 - 将 DataFrame 转换为 Dataset

//Construct a DataFrame first 
scala> val color_df = sc.parallelize(colors).map(x => 
           (x,x.length)).toDF("color","len") 
color_df: org.apache.spark.sql.DataFrame = [color: string, len: int] 
//Convert the DataFrame to a Dataset with a given structure 
scala> val ds_from_df = color_df.as[Color] 
ds_from_df: org.apache.spark.sql.Dataset[Color] = [color: string, len: int] 
//Check the execution plan 
scala> ds_from_df.explain 
== Physical Plan == 
WholeStageCodegen 
:  +- Project [_1#102 AS color#105,_2#103 AS len#106] 
:     +- INPUT 
+- Scan ExistingRDD[_1#102,_2#103] 

explain 命令的响应显示 WholeStageCodegen,它将多个操作融合成一个 Java 函数调用。由于减少了多个虚拟函数调用,这增强了性能。代码生成自 Spark 1.1 以来就存在,但当时仅限于表达式评估和少量操作,如过滤。而与此不同,Tungsten 的整个阶段代码生成会为整个查询计划生成代码。

从 JSON 创建 Datasets

数据集可以通过 JSON 文件创建,类似于 DataFrame。请注意,一个 JSON 文件可以包含多个记录,但每条记录必须在一行内。如果源 JSON 文件中有换行符,您需要通过编程手段去除它们。JSON 记录可能包含数组,并且可能是嵌套的。它们不需要具有统一的模式。以下示例文件包含 JSON 记录,其中一条记录具有额外的标签和数据数组。

示例 4 - 从 JSON 创建 Dataset

Scala:

//Set filepath 
scala> val file_path = <Your path> 
file_path: String = ./authors.json 
//Create case class to match schema 
scala> case class Auth(first_name: String, last_name: String,books: Array[String]) 
defined class Auth 

//Create dataset from json using case class 
//Note that the json document should have one record per line 
scala> val auth = spark.read.json(file_path).as[Auth] 
auth: org.apache.spark.sql.Dataset[Auth] = [books: array<string>, firstName: string ... 1 more field] 

//Look at the data 
scala> auth.show() 
+--------------------+----------+---------+ 
|               books|first_name|last_name| 
+--------------------+----------+---------+ 
|                null|      Mark|    Twain| 
|                null|   Charles|  Dickens| 
|[Jude the Obscure...|    Thomas|    Hardy| 
+--------------------+----------+---------+ 

//Try explode to see array contents on separate lines 

scala> auth.select(explode($"books") as "book", 
            $"first_name",$"last_name").show(2,false) 
+------------------------+----------+---------+ 
|book                    |first_name|last_name| 
+------------------------+----------+---------+ 
|Jude the Obscure        |Thomas    |Hardy    | 
|The Return of the Native|Thomas    |Hardy    | 
+------------------------+----------+---------+ 

Datasets API 的限制

即使 Datasets API 已经结合了 RDD 和 DataFrame 的优势,但它仍然存在一些局限性,特别是在当前的开发阶段:

  • 在查询数据集时,选定的字段应赋予与案例类相同的特定数据类型,否则输出将变成 DataFrame。例如,auth.select(col("first_name").as[String])

  • Python 和 R 本质上是动态的,因此类型化的 Datasets 并不适用。

Spark SQL

Spark SQL 是 Spark 1.0 引入的一个用于结构化数据处理的 Spark 模块。这个模块是一个紧密集成的关系引擎,与核心 Spark API 协同工作。它使得数据工程师可以编写应用程序,从不同的来源加载结构化数据并将它们连接成统一的,可能是连续的,类似 Excel 的数据框;然后他们可以实施复杂的 ETL 工作流和高级分析。

Spark 2.0 版本带来了 API 的重要统一,并扩展了 SQL 功能,包括支持子查询。现在,数据集 API 和数据框架 API 已经统一,数据框架是数据集的一种“形式”。这些统一的 API 为 Spark 的未来奠定了基础,涵盖了所有库。开发者可以为他们的数据施加“结构”,并可以使用高级声明式 API,从而提高性能和生产力。性能的提升来源于底层的优化层。数据框架、数据集和 SQL 共享相同的优化和执行管道。

SQL 操作

SQL 操作是最广泛使用的数据处理构造。常见的操作包括选择所有或部分列、根据一个或多个条件进行过滤、排序和分组操作,以及计算诸如average等汇总函数在分组数据上的应用。JOIN操作用于多个数据源之间的操作,set操作如unionintersectminus也是常见的操作。此外,数据框架作为临时表注册,并通过传统的 SQL 语句执行上述操作。用户定义函数UDF)可以在注册与未注册的情况下定义和使用。我们将重点关注窗口操作,这些操作在 Spark 2.0 中刚刚引入,主要用于滑动窗口操作。例如,如果你想报告过去七天内每天的平均峰值温度,那么你就是在一个滑动的七天窗口上操作,直到今天为止。以下是一个示例,计算过去三个月的每月平均销售额。数据文件包含 24 个观测值,显示了 P1 和 P2 两个产品的每月销售数据。

示例 5-窗口示例与移动平均计算

Scala:

scala> import org.apache.spark.sql.expressions.Window 
import org.apache.spark.sql.expressions.Window 
//Create a DataFrame containing monthly sales data for two products 
scala> val monthlySales = spark.read.options(Map({"header"->"true"},{"inferSchema" -> "true"})). 
                            csv("<Your Path>/MonthlySales.csv") 
monthlySales: org.apache.spark.sql.DataFrame = [Product: string, Month: int ... 1 more field] 

//Prepare WindowSpec to create a 3 month sliding window for a product 
//Negative subscript denotes rows above current row 
scala> val w = Window.partitionBy(monthlySales("Product")).orderBy(monthlySales("Month")).rangeBetween(-2,0) 
w: org.apache.spark.sql.expressions.WindowSpec = org.apache.spark.sql.expressions.WindowSpec@3cc2f15 

//Define compute on the sliding window, a moving average in this case 
scala> val f = avg(monthlySales("Sales")).over(w) 
f: org.apache.spark.sql.Column = avg(Sales) OVER (PARTITION BY Product ORDER BY Month ASC RANGE BETWEEN 2 PRECEDING AND CURRENT ROW) 
//Apply the sliding window and compute. Examine the results 
scala> monthlySales.select($"Product",$"Sales",$"Month", bround(f,2).alias("MovingAvg")). 
                    orderBy($"Product",$"Month").show(6) 
+-------+-----+-----+---------+                                                  
|Product|Sales|Month|MovingAvg| 
+-------+-----+-----+---------+ 
|     P1|   66|    1|     66.0| 
|     P1|   24|    2|     45.0| 
|     P1|   54|    3|     48.0| 
|     P1|    0|    4|     26.0| 
|     P1|   56|    5|    36.67| 
|     P1|   34|    6|     30.0| 
+-------+-----+-----+---------+ 

Python:

    >>> from pyspark.sql import Window
    >>> import pyspark.sql.functions as func
    //Create a DataFrame containing monthly sales data for two products
    >> file_path = <Your path>/MonthlySales.csv"
    >>> monthlySales = spark.read.csv(file_path,header=True, inferSchema=True)

    //Prepare WindowSpec to create a 3 month sliding window for a product
    //Negative subscript denotes rows above current row
    >>> w = Window.partitionBy(monthlySales["Product"]).orderBy(monthlySales["Month"]).rangeBetween(-2,0)
    >>> w
    <pyspark.sql.window.WindowSpec object at 0x7fdc33774a50>
    >>>
    //Define compute on the sliding window, a moving average in this case
    >>> f = func.avg(monthlySales["Sales"]).over(w)
    >>> f
    Column<avg(Sales) OVER (PARTITION BY Product ORDER BY Month ASC RANGE BETWEEN 2 PRECEDING AND CURRENT ROW)>
    >>>
    //Apply the sliding window and compute. Examine the results
    >>> monthlySales.select(monthlySales.Product,monthlySales.Sales,monthlySales.Month,
                          func.bround(f,2).alias("MovingAvg")).orderBy(
                          monthlySales.Product,monthlySales.Month).show(6)
    +-------+-----+-----+---------+                                                 
    |Product|Sales|Month|MovingAvg|
    +-------+-----+-----+---------+
    |     P1|   66|    1|     66.0|
    |     P1|   24|    2|     45.0|
    |     P1|   54|    3|     48.0|
    |     P1|    0|    4|     26.0|
    |     P1|   56|    5|    36.67|
    |     P1|   34|    6|     30.0|
    +-------+-----+-----+---------+

底层原理

当开发者使用 RDD API 编写程序时,工作负载的高效执行是开发者的责任。Spark 并不提供数据类型和计算的支持。而当开发者使用数据框架和 Spark SQL 时,底层引擎已经了解模式和操作信息。在这种情况下,开发者可以写更少的代码,同时优化器负责处理所有复杂工作。

Catalyst 优化器包含用于表示树并应用规则进行转换的库。这些树的转换应用于创建最优化的逻辑和物理执行计划。在最终阶段,它使用 Scala 语言的特殊特性 quasiquotes 生成 Java 字节码。优化器还允许外部开发者通过添加特定于数据源的规则来扩展优化器,这些规则会将操作推送到外部系统,或者支持新的数据类型。

Catalyst 优化器会生成最优化的计划来执行当前操作。实际执行和相关改进由 Tungsten 引擎提供。Tungsten 的目标是提高 Spark 后端执行的内存和 CPU 效率。以下是该引擎的一些显著特点:

  • 通过绕过(堆外)Java 内存管理来减少内存占用并消除垃圾回收的开销。

  • 代码生成跨多个操作符融合,并避免了过多的虚拟函数调用。生成的代码看起来像手动优化过的代码。

  • 内存布局采用列式存储的内存中 Parquet 格式,因为这能够支持矢量化处理,且更贴近常见的数据访问操作。

  • 使用编码器进行内存中的编码。编码器通过运行时代码生成构建自定义字节码,实现更快速且紧凑的序列化与反序列化。许多操作可以在内存中就地执行,无需反序列化,因为它们已经是 Tungsten 二进制格式。

结构化流处理

流处理是一个看似广泛的话题!如果深入观察实际问题,企业不仅希望有一个流处理引擎来实时做出决策。一直以来,都有需求将批处理和流处理栈结合,并与外部存储系统和应用程序集成。此外,解决方案应能适应业务逻辑的动态变化,以应对新的和不断变化的业务需求。

Apache Spark 2.0 引入了首个高层次的流处理 API,称为 结构化流处理(Structured Streaming) 引擎。这个可扩展且容错的引擎依赖于 Spark SQL API,简化了实时、连续大数据应用的开发。这可能是统一批处理和流处理计算的首次成功尝试。

从技术角度讲,结构化流处理依赖于 Spark SQL API,该 API 扩展了我们之前讨论的 DataFrames/Datasets。Spark 2.0 让你以统一的方式执行完全不同的活动,例如:

  • 构建机器学习模型并将其应用于流式数据

  • 将流式数据与其他静态数据结合

  • 执行临时查询、交互式查询和批处理查询

  • 运行时改变查询

  • 聚合数据流并通过 Spark SQL JDBC 提供服务

与其他流处理引擎不同,Spark 允许你将实时 流式数据(Streaming Data)静态数据(Static data) 结合,并执行前述操作。

结构化流处理

从本质上讲,结构化流处理(Structured Streaming)得益于 Spark SQL 的 Catalyst 优化器。因此,它让开发者无需担心底层的查询优化问题,能够更高效地处理静态或实时数据流。

截至本文撰写时,Spark 2.0 的结构化流处理专注于 ETL,后续版本将引入更多操作符和库。

让我们看一个简单的例子。以下示例在本地机器上监听 Linux 上的 系统活动报告 (sar) 并计算平均空闲内存。系统活动报告提供系统活动统计信息,当前示例收集内存使用情况,每隔 2 秒报告 20 次。Spark 流读取这个流式输出并计算平均内存。我们使用一个方便的网络工具 netcat (nc) 将 sar 输出重定向到指定的端口。选项 lk 指定 nc 应该监听传入连接,并且即使当前连接完成后,它也必须继续监听另一个连接。

Scala:

示例 6 - 流式示例

//Run the following command from one terminal window 
sar -r 2 20 | nc -lk 9999 

//In spark-shell window, do the following 
//Read stream 
scala> val myStream = spark.readStream.format("socket"). 
                       option("host","localhost"). 
                       option("port",9999).load() 
myStream: org.apache.spark.sql.DataFrame = [value: string] 

//Filter out unwanted lines and then extract free memory part as a float 
//Drop missing values, if any 
scala> val myDF = myStream.filter($"value".contains("IST")). 
               select(substring($"value",15,9).cast("float").as("memFree")). 
               na.drop().select($"memFree") 
myDF: org.apache.spark.sql.DataFrame = [memFree: float] 

//Define an aggregate function 
scala> val avgMemFree = myDF.select(avg("memFree")) 
avgMemFree: org.apache.spark.sql.DataFrame = [avg(memFree): double] 

//Create StreamingQuery handle that writes on to the console 
scala> val query = avgMemFree.writeStream. 
          outputMode("complete"). 
          format("console"). 
          start() 
query: org.apache.spark.sql.streaming.StreamingQuery = Streaming Query - query-0 [state = ACTIVE] 

Batch: 0 
------------------------------------------- 
+-----------------+ 
|     avg(memFree)| 
+-----------------+ 
|4116531.380952381| 
+-----------------+ 
.... 

Python:

    //Run the following command from one terminal window
     sar -r 2 20 | nc -lk 9999

    //In another window, open pyspark shell and do the following
    >>> import pyspark.sql.functions as func
    //Read stream
    >>> myStream = spark.readStream.format("socket"). \
                           option("host","localhost"). \
                           option("port",9999).load()
    myStream: org.apache.spark.sql.DataFrame = [value: string]

    //Filter out unwanted lines and then extract free memory part as a float
    //Drop missing values, if any
    >>> myDF = myStream.filter("value rlike 'IST'"). \
               select(func.substring("value",15,9).cast("float"). \
               alias("memFree")).na.drop().select("memFree")

    //Define an aggregate function
    >>> avgMemFree = myDF.select(func.avg("memFree"))

    //Create StreamingQuery handle that writes on to the console
    >>> query = avgMemFree.writeStream. \
              outputMode("complete"). \
              format("console"). \
              start()
    Batch: 0
    -------------------------------------------
    +------------+
    |avg(memFree)|
    +------------+
    |   4042749.2|
    +------------+
    .....

前面的示例定义了一个连续数据框(也称为流),用于监听特定端口,执行一些转换和聚合操作,并显示连续输出。

Spark 流式编程模型

正如本章前面所展示的,只有一个 API 可以同时处理静态数据和流式数据。其思想是将实时数据流视为一个不断追加的表,如下图所示:

Spark 流式编程模型

因此,无论是静态数据还是流式数据,你都可以像对待静态数据表一样启动批处理查询,Spark 会将其作为增量查询在无界输入表上执行,如下图所示:

Spark 流式编程模型

因此,开发人员以相同的方式在输入表上定义查询,适用于静态有界表和动态无界表。为了理解它是如何工作的,我们来了解一下这个过程中的各种技术术语:

  • 输入: 作为追加式表格的数据源

  • 触发器: 何时检查输入数据以获取新数据

  • 查询: 对数据执行的操作,如过滤、分组等

  • 结果: 每次触发间隔后的结果表

  • 输出: 每次触发后,选择要写入数据接收器的结果部分

现在让我们看看 Spark SQL 计划器是如何处理整个过程的:

Spark 流式编程模型

特别感谢:Databricks

前面的截图在官方 Apache Spark 网站的结构化编程指南中有非常简单的解释,如 参考文献 部分所示。

Spark 流式编程模型

在这一点上,我们需要了解支持的输出模型。每次结果表更新时,必须将更改写入外部系统,如 HDFS、S3 或其他数据库。我们通常更倾向于增量写入输出。为此,结构化流提供了三种输出模式:

  • 追加模式: 在外部存储中,只有自上次触发以来追加到结果表的新行会被写入。此模式仅适用于查询中结果表中的现有行不能更改的情况(例如,对输入流的映射)。

  • 完整模式: 在外部存储中,整个更新后的结果表将被完整写入。

  • 更新模式: 在外部存储中,只有自上次触发以来更新过的行会被更改。此模式适用于可以就地更新的输出接收器,例如 MySQL 表。

在我们的示例中,我们使用了完整模式,这直接将结果写入控制台。你可能希望将数据写入某些外部文件(例如 Parquet),以便更好地理解。

幕后机制

如果你查看在 DataFrames/Datasets 上执行操作的“幕后”执行机制,它将呈现如下图所示:

幕后

请注意,Planner 已知如何将流处理的逻辑计划转换为一系列连续的增量执行计划。这可以通过以下图示表示:

幕后

Planner 可以轮询数据源中的新数据,以便能够以优化的方式规划执行。

与其他流处理引擎的比较

我们已经讨论了结构化流处理的许多独特功能。现在让我们与其他流处理引擎做一个比较:

与其他流引擎的比较

由此提供:Databricks

连续应用程序

我们讨论了 Spark 如何使统一数据访问成为可能。它让你可以以多种方式处理数据,构建端到端的连续应用程序,通过启用各种分析工作负载,如 ETL 处理、临时查询、在线机器学习建模,或生成必要的报告……这一切都通过高层次的类似 SQL 的 API 实现统一方式,让你同时处理静态和流式数据。通过这种方式,结构化流处理大大简化了实时连续应用程序的开发和维护。

连续应用

由此提供:Databricks

总结

在本章中,我们讨论了统一数据访问的真正含义以及 Spark 如何实现这一目标。我们详细介绍了 Datasets API 以及如何通过它支持实时流处理。我们学习了 Datasets 的优点,也了解了它们的局限性。我们还研究了连续应用程序背后的基本原理。

在下一章中,我们将探讨如何利用 Spark 平台进行大规模数据分析操作。

参考资料

第五章:Spark 上的数据分析

数据分析领域的规模化发展前所未有。为数据分析开发了各种库和工具,拥有丰富的算法集。同时,分布式计算技术随着时间的推移也在不断发展,以便在大规模处理庞大数据集。这两个特性必须结合起来,这就是 Spark 开发的主要目的。

前两章概述了数据科学的技术方面,涵盖了 DataFrame API、数据集、流数据的基础知识,以及它如何通过 DataFrame 促进数据表示,这是 R 和 Python 用户熟悉的方式。在介绍了这个 API 后,我们看到操作数据集变得比以往更加简单。我们还看到了 Spark SQL 如何通过其强大的功能和优化技术在后台支持 DataFrame API。在本章中,我们将介绍大数据分析的科学方面,并学习可以在 Spark 上执行的各种数据分析技术。

本章的前提是对 DataFrame API 和统计学基础有基本了解。然而,我们已尽力将内容简化,并详细涵盖了一些重要的基础知识,以便任何人都能在 Spark 上开始进行统计分析。本章将涵盖以下主题:

  • 数据分析生命周期

  • 数据采集

  • 数据准备

    • 数据整合

    • 数据清理

    • 数据转换

  • 统计学基础

    • 抽样

    • 数据分布

  • 描述性统计

    • 位置度量

    • 离散度量

    • 汇总统计

    • 图形技术

  • 推断统计

    • 离散概率分布

    • 连续概率分布

    • 标准误差

    • 置信度水平

    • 错误边际和置信区间

    • 人群中的变异性

    • 样本量估算

    • 假设检验

    • 卡方检验

    • F 检验

    • 相关性

数据分析生命周期

对于大多数现实世界的项目,都有一套定义好的步骤顺序需要遵循。然而,数据分析和数据科学并没有普遍认同的定义或边界。通常,“数据分析”一词涵盖了检查数据、发现有用洞察并传达这些洞察的技术和过程。而“数据科学”一词最好被视为一个跨学科的领域,涉及统计学计算机科学数学。这两个术语都涉及对原始数据的处理,以得出知识或洞察,通常是迭代进行的,有些人会交替使用这两个术语。

根据不同的业务需求,解决问题的方式有很多种,但并没有一个适用于所有可能场景的标准流程。一个典型的流程可以概括为:提出问题、探索、假设、验证假设、分析结果,然后重新开始。这一点通过下图中的粗箭头进行了展示。从数据的角度来看,工作流程包括数据获取、预处理、数据探索、建模和结果传达。这些通过图中的圆圈来表示。分析和可视化在每个阶段都会发生,从数据收集到结果传达都贯穿其中。数据分析工作流程涵盖了两个视图中展示的所有活动:

数据分析生命周期

在整个生命周期中,最重要的事情是当前的问题。接下来是可能包含答案(相关数据!)的数据。根据问题的不同,第一项任务是根据需要从一个或多个数据源收集正确的数据。组织通常维护数据湖,这是存储原始格式数据的庞大库。

下一步是清洗/转换数据到所需的格式。数据清洗也称为数据处理、数据整理或数据挖掘。这包括在评估手头数据质量时进行缺失值处理和异常值处理等活动。你可能还需要对数据进行汇总/绘制图表,以便更好地理解数据。这个制定最终数据矩阵的过程被认为是最耗时的步骤。这也是一个被低估的部分,通常与特征提取和数据转换等其他活动一起被视为预处理的一部分。

数据科学的核心,训练模型和提取模式,紧接其后,这需要大量使用统计学和机器学习。最后一步是发布结果。

本章剩余部分将深入探讨这些步骤,并讲解如何使用 Spark 实现这些步骤。章节中还包括了一些基础的统计知识,以便让读者更轻松地理解代码片段。

数据获取

数据获取,或数据收集,是任何数据科学项目中的第一步。通常,你不会在一个地方找到完整的所需数据,因为数据分布在业务线LOB)应用程序和系统中。

本节的大部分内容已经在上一章中讨论过,上一章概述了如何从不同的数据源获取数据并将其存储在 DataFrame 中,以便于分析。Spark 内置了从一些常见数据源获取数据的机制,对于不支持的源,则提供了数据源 API

为了更好地理解数据采集和准备阶段,让我们假设一个场景,并尝试通过示例代码片段来解决所有涉及的步骤。场景假设员工数据分布在本地 RDD、JSON 文件和 SQL 服务器中。那么,让我们看看如何将这些数据导入到 Spark DataFrame 中:

Python

// From RDD: Create an RDD and convert to DataFrame
>>> employees = sc.parallelize([(1, "John", 25), (2, "Ray", 35), (3, "Mike", 24), (4, "Jane", 28), (5, "Kevin", 26), (6, "Vincent", 35), (7, "James", 38), (8, "Shane", 32), (9, "Larry", 29), (10, "Kimberly", 29), (11, "Alex", 28), (12, "Garry", 25), (13, "Max", 31)]).toDF(["emp_id","name","age"])
>>>

// From JSON: reading a JSON file
>>> salary = sqlContext.read.json("./salary.json")
>>> designation = sqlContext.read.json("./designation.json")

Scala

// From RDD: Create an RDD and convert to DataFrame
scala> val employees = sc.parallelize(List((1, "John", 25), (2, "Ray", 35), (3, "Mike", 24), (4, "Jane", 28), (5, "Kevin", 26), (6, "Vincent", 35), (7, "James", 38), (8, "Shane", 32), (9, "Larry", 29), (10, "Kimberly", 29), (11, "Alex", 28), (12, "Garry", 25), (13, "Max", 31))).toDF("emp_id","name","age")
employees: org.apache.spark.sql.DataFrame = [emp_id: int, name: string ... 1 more field]
scala> // From JSON: reading a JSON file
scala> val salary = spark.read.json("./salary.json")
salary: org.apache.spark.sql.DataFrame = [e_id: bigint, salary: bigint]
scala> val designation = spark.read.json("./designation.json")
designation: org.apache.spark.sql.DataFrame = [id: bigint, role: string]

数据准备

数据质量一直是行业中的一个普遍问题。错误或不一致的数据可能会导致分析结果的误导。如果数据没有按照要求进行清理和准备,实施更好的算法或构建更好的模型也无法取得显著效果。有一个行业术语叫做数据工程,它指的是数据的来源和准备工作。这通常由数据科学家完成,在一些组织中,会有专门的团队负责这项工作。然而,在准备数据时,通常需要一种科学的视角来确保正确处理。例如,处理缺失值时,你可能不仅仅采用均值替代,而是需要查看数据分布,找到更合适的值进行替代。另一个例子是,你可能不仅仅看箱型图或散点图来查找异常值,因为可能存在多变量异常值,这些异常值在单变量图中可能不可见。有不同的方法,比如高斯混合模型GMMs)和期望最大化EM)算法,使用马氏距离来寻找多变量异常值。

数据准备阶段是一个极为重要的阶段,不仅仅是为了让算法正确工作,还能帮助你更好地理解数据,从而在实施算法时采取正确的处理方法。

一旦从不同的数据源获取到数据,下一步就是将它们整合起来,以便可以对整个数据进行清理、格式化和转换,以适应分析所需的格式。请注意,根据场景的不同,您可能需要从数据源中抽取样本,然后准备数据以进行进一步分析。本章后续将讨论可以使用的各种抽样技术。

数据整合

在这一部分,我们将了解如何将来自各种数据源的数据结合起来:

Python

// Creating the final data matrix using the join operation
>>> final_data = employees.join(salary, employees.emp_id == salary.e_id).join(designation, employees.emp_id == designation.id).select("emp_id", "name", "age", "role", "salary")
>>> final_data.show(5)
+------+-----+---+---------+------+
|emp_id| name|age|     role|salary|
+------+-----+---+---------+------+
|     1| John| 25|Associate| 10000|
|     2|  Ray| 35|  Manager| 12000|
|     3| Mike| 24|  Manager| 12000|
|     4| Jane| 28|Associate|  null|
|     5|Kevin| 26|  Manager|   120|
+------+-----+---+---------+------+
only showing top 5 rows

Scala

// Creating the final data matrix using the join operation
scala> val final_data = employees.join(salary, $"emp_id" === $"e_id").join(designation, $"emp_id" === $"id").select("emp_id", "name", "age", "role", "salary")
final_data: org.apache.spark.sql.DataFrame = [emp_id: int, name: string ... 3 more fields]

在将来自这些数据源的数据整合后,最终的数据集(在本例中为final_data)应具有如下格式(示例数据):

emp_id name age role salary
1 John 25 助理 10,000 $
2 Ray 35 经理 12,000 $
3 Mike 24 经理 12,000 $
4 Jane 28 助理 null
5 Kevin 26 经理 12,000 $
6 Vincent 35 高级经理 22,000 $
7 James 38 高级经理 20,000 $
8 Shane 32 经理 12,000 $
9 Larry 29 经理 10,000 $
10 Kimberly 29 助理 8,000 $
11 Alex 28 经理 12,000 $
12 Garry 25 经理 12,000 $
13 Max 31 经理 12,000 $

数据清洗

一旦将数据集中到一个地方,非常重要的一点是,在进行分析之前,要花费足够的时间和精力清洗数据。这是一个迭代过程,因为你必须验证你对数据所做的操作,并持续进行直到对数据质量感到满意。建议你花时间分析数据中发现的异常的原因。

数据中通常存在一定程度的杂质。在任何数据集中都可能存在各种问题,但我们将讨论一些常见的情况,如缺失值、重复值、数据转换或格式化(例如,给数字添加或删除数字,拆分一列成两列,或者将两列合并为一列)。

缺失值处理

处理缺失值的方式有很多种。一个方法是删除包含缺失值的行。即使只有某一列有缺失值,我们也可能希望删除这一行,或者对于不同的列采用不同的策略。我们还可以设定一个阈值,只要该行缺失值的总数低于阈值,就保留该行。另一种方法是用常数值替换空值,例如在数值型变量中使用均值替代。

在本节中,我们将提供一些 Scala 和 Python 中的示例,并尝试涵盖各种场景,以帮助你从更广泛的角度理解。

Python

// Dropping rows with missing value(s)
>>> clean_data = final_data.na.drop()
>>> 
// Replacing missing value by mean
>>> import math
>>> from pyspark.sql import functions as F
>>> mean_salary = math.floor(salary.select(F.mean('salary')).collect()[0][0])
>>> clean_data = final_data.na.fill({'salary' : mean_salary})
>>> 
//Another example for missing value treatment
>>> authors = [['Thomas','Hardy','June 2, 1840'],
       ['Charles','Dickens','7 February 1812'],
        ['Mark','Twain',None],
        ['Jane','Austen','16 December 1775'],
      ['Emily',None,None]]
>>> df1 = sc.parallelize(authors).toDF(
       ["FirstName","LastName","Dob"])
>>> df1.show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|    June 2, 1840|
|  Charles| Dickens| 7 February 1812|
|     Mark|   Twain|            null|
|     Jane|  Austen|16 December 1775|
|    Emily|    null|            null|
+---------+--------+----------------+

// Drop rows with missing values
>>> df1.na.drop().show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|    June 2, 1840|
|  Charles| Dickens| 7 February 1812|
|     Jane|  Austen|16 December 1775|
+---------+--------+----------------+

// Drop rows with at least 2 missing values
>>> df1.na.drop(thresh=2).show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|    June 2, 1840|
|  Charles| Dickens| 7 February 1812|
|     Mark|   Twain|            null|
|     Jane|  Austen|16 December 1775|
+---------+--------+----------------+

// Fill all missing values with a given string
>>> df1.na.fill('Unknown').show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|    June 2, 1840|
|  Charles| Dickens| 7 February 1812|
|     Mark|   Twain|         Unknown|
|     Jane|  Austen|16 December 1775|
|    Emily| Unknown|         Unknown|
+---------+--------+----------------+

// Fill missing values in each column with a given string
>>> df1.na.fill({'LastName':'--','Dob':'Unknown'}).show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|    June 2, 1840|
|  Charles| Dickens| 7 February 1812|
|     Mark|   Twain|         Unknown|
|     Jane|  Austen|16 December 1775|
|    Emily|      --|         Unknown|
+---------+--------+----------------+

Scala

//Missing value treatment
// Dropping rows with missing value(s)
scala> var clean_data = final_data.na.drop() //Note the var declaration instead of val
clean_data: org.apache.spark.sql.DataFrame = [emp_id: int, name: string ... 3 more fields]
scala>

// Replacing missing value by mean
scal> val mean_salary = final_data.select(floor(avg("salary"))).
            first()(0).toString.toDouble
mean_salary: Double = 20843.0
scal> clean_data = final_data.na.fill(Map("salary" -> mean_salary)) 

//Reassigning clean_data
clean_data: org.apache.spark.sql.DataFrame = [emp_id: int, name: string ... 3 more fields]
scala>

//Another example for missing value treatment
scala> case class Author (FirstName: String, LastName: String, Dob: String)
defined class Author
scala> val authors = Seq(
        Author("Thomas","Hardy","June 2, 1840"),
        Author("Charles","Dickens","7 February 1812"),
        Author("Mark","Twain",null),
        Author("Emily",null,null))
authors: Seq[Author] = List(Author(Thomas,Hardy,June 2, 1840),
   Author(Charles,Dickens,7 February 1812), Author(Mark,Twain,null),
   Author(Emily,null,null))
scala> val ds1 = sc.parallelize(authors).toDS()
ds1: org.apache.spark.sql.Dataset[Author] = [FirstName: string, LastName: string ... 1 more field]
scala> ds1.show()
+---------+--------+---------------+
|FirstName|LastName|            Dob|
+---------+--------+---------------+
|   Thomas|   Hardy|   June 2, 1840|
|  Charles| Dickens|7 February 1812|
|     Mark|   Twain|           null|
|    Emily|    null|           null|
+---------+--------+---------------+
scala>

// Drop rows with missing values
scala> ds1.na.drop().show()
+---------+--------+---------------+
|FirstName|LastName|            Dob|
+---------+--------+---------------+
|   Thomas|   Hardy|   June 2, 1840|
|  Charles| Dickens|7 February 1812|
+---------+--------+---------------+
scala>

//Drop rows with at least 2 missing values
//Note that there is no direct scala function to drop rows with at least n missing values
//However, you can drop rows containing under specified non nulls
//Use that function to achieve the same result
scala> ds1.na.drop(minNonNulls = df1.columns.length - 1).show()
//Fill all missing values with a given string
scala> ds1.na.fill("Unknown").show()
+---------+--------+---------------+
|FirstName|LastName|            Dob|
+---------+--------+---------------+
|   Thomas|   Hardy|   June 2, 1840|
|  Charles| Dickens|7 February 1812|
|     Mark|   Twain|        Unknown|
|    Emily| Unknown|        Unknown|
+---------+--------+---------------+
scala>

//Fill missing values in each column with a given string
scala> ds1.na.fill(Map("LastName"->"--",
                    "Dob"->"Unknown")).show()
+---------+--------+---------------+
|FirstName|LastName|            Dob|
+---------+--------+---------------+
|   Thomas|   Hardy|   June 2, 1840|
|  Charles| Dickens|7 February 1812|
|     Mark|   Twain|        Unknown|
|    Emily|      --|        Unknown|
+---------+--------+---------------+

异常值处理

了解什么是异常值也很重要,这样才能正确处理它。简单来说,异常值是与其余数据点特征不一致的数据点。例如:如果你有一个学生的年龄数据集,而其中有几个年龄值在 30-40 岁范围内,那么它们可能是异常值。现在让我们看一个不同的例子:如果你有一个数据集,其中某个变量的值只能在两个范围内,如 10-20 岁或 80-90 岁,那么介于这两个范围之间的数据点(例如,40 或 55 岁)也可能是异常值。在这个例子中,40 或 55 既不属于 10-20 岁范围,也不属于 80-90 岁范围,因此它们是异常值。

另外,数据中可以存在单变量异常值,也可以存在多变量异常值。为了简化起见,本书将重点讨论单变量异常值,因为在本书写作时,Spark MLlib 可能没有所有需要的算法。

为了处理异常值,你首先需要判断是否存在异常值。可以通过不同的方式来发现异常值,例如使用汇总统计和绘图技术。你可以使用 Python 的内置库函数,如matplotlib,来可视化数据。通过连接到 Spark(例如,通过 Jupyter 笔记本),你可以生成这些可视化,这在命令行中可能是无法实现的。

一旦发现异常值,你可以选择删除包含异常值的行,或者用均值替换异常值,或者根据具体情况做出更相关的处理。让我们来看看均值替代方法:

Python

// Identify outliers and replace them with mean
//The following example reuses the clean_data dataset and mean_salary computed in previous examples
>>> mean_salary
20843.0
>>> 
//Compute deviation for each row
>>> devs = final_data.select(((final_data.salary - mean_salary) ** 2).alias("deviation"))

//Compute standard deviation
>>> stddev = math.floor(math.sqrt(devs.groupBy().
          avg("deviation").first()[0]))

//check standard deviation value
>>> round(stddev,2)
30351.0
>>> 
//Replace outliers beyond 2 standard deviations with the mean salary
>>> no_outlier = final_data.select(final_data.emp_id, final_data.name, final_data.age, final_data.salary, final_data.role, F.when(final_data.salary.between(mean_salary-(2*stddev), mean_salary+(2*stddev)), final_data.salary).otherwise(mean_salary).alias("updated_salary"))
>>> 
//Observe modified values
>>> no_outlier.filter(no_outlier.salary != no_outlier.updated_salary).show()
+------+----+---+------+-------+--------------+
|emp_id|name|age|salary|   role|updated_salary|
+------+----+---+------+-------+--------------+
|    13| Max| 31|120000|Manager|       20843.0|
+------+----+---+------+-------+--------------+
>>>

Scala

// Identify outliers and replace them with mean
//The following example reuses the clean_data dataset and mean_salary computed in previous examples
//Compute deviation for each row
scala> val devs = clean_data.select(((clean_data("salary") - mean_salary) *
        (clean_data("salary") - mean_salary)).alias("deviation"))
devs: org.apache.spark.sql.DataFrame = [deviation: double]

//Compute standard deviation
scala> val stddev = devs.select(sqrt(avg("deviation"))).
            first().getDouble(0)
stddev: Double = 29160.932595617614

//If you want to round the stddev value, use BigDecimal as shown
scala> scala.math.BigDecimal(stddev).setScale(2,
             BigDecimal.RoundingMode.HALF_UP)
res14: scala.math.BigDecimal = 29160.93
scala>

//Replace outliers beyond 2 standard deviations with the mean salary
scala> val outlierfunc = udf((value: Long, mean: Double) => {if (value > mean+(2*stddev)
            || value < mean-(2*stddev)) mean else value})

//Use the UDF to compute updated_salary
//Note the usage of lit() to wrap a literal as a column
scala> val no_outlier = clean_data.withColumn("updated_salary",
            outlierfunc(col("salary"),lit(mean_salary)))

//Observe modified values
scala> no_outlier.filter(no_outlier("salary") =!=  //Not !=
             no_outlier("updated_salary")).show()
+------+----+---+-------+------+--------------+
|emp_id|name|age|   role|salary|updated_salary|
+------+----+---+-------+------+--------------+
|    13| Max| 31|Manager|120000|       20843.0|
+------+----+---+-------+------+--------------+

处理重复值

处理数据集中重复记录的方式有多种。我们将在以下代码片段中演示这些方法:

Python

// Deleting the duplicate rows
>>> authors = [['Thomas','Hardy','June 2,1840'],
    ['Thomas','Hardy','June 2,1840'],
    ['Thomas','H',None],
    ['Jane','Austen','16 December 1775'],
    ['Emily',None,None]]
>>> df1 = sc.parallelize(authors).toDF(
      ["FirstName","LastName","Dob"])
>>> df1.show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|    June 2, 1840|
|   Thomas|   Hardy|    June 2, 1840|
|   Thomas|       H|            null|
|     Jane|  Austen|16 December 1775|
|    Emily|    null|            null|
+---------+--------+----------------+

// Drop duplicated rows
>>> df1.dropDuplicates().show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|    Emily|    null|            null|
|     Jane|  Austen|16 December 1775|
|   Thomas|       H|            null|
|   Thomas|   Hardy|    June 2, 1840|
+---------+--------+----------------+

// Drop duplicates based on a sub set of columns
>>> df1.dropDuplicates(subset=["FirstName"]).show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|    Emily|    null|            null|
|   Thomas|   Hardy|    June 2, 1840|
|     Jane|  Austen|16 December 1775|
+---------+--------+----------------+
>>> 

Scala:

//Duplicate values treatment
// Reusing the Author case class
// Deleting the duplicate rows
scala> val authors = Seq(
            Author("Thomas","Hardy","June 2,1840"),
            Author("Thomas","Hardy","June 2,1840"),
            Author("Thomas","H",null),
            Author("Jane","Austen","16 December 1775"),
            Author("Emily",null,null))
authors: Seq[Author] = List(Author(Thomas,Hardy,June 2,1840), Author(Thomas,Hardy,June 2,1840), Author(Thomas,H,null), Author(Jane,Austen,16 December 1775), Author(Emily,null,null))
scala> val ds1 = sc.parallelize(authors).toDS()
ds1: org.apache.spark.sql.Dataset[Author] = [FirstName: string, LastName: string ... 1 more field]
scala> ds1.show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|     June 2,1840|
|   Thomas|   Hardy|     June 2,1840|
|   Thomas|       H|            null|
|     Jane|  Austen|16 December 1775|
|    Emily|    null|            null|
+---------+--------+----------------+
scala>

// Drop duplicated rows
scala> ds1.dropDuplicates().show()
+---------+--------+----------------+                                          
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|     Jane|  Austen|16 December 1775|
|    Emily|    null|            null|
|   Thomas|   Hardy|     June 2,1840|
|   Thomas|       H|            null|
+---------+--------+----------------+
scala>

// Drop duplicates based on a sub set of columns
scala> ds1.dropDuplicates("FirstName").show()
+---------+--------+----------------+                                           
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|    Emily|    null|            null|
|     Jane|  Austen|16 December 1775|
|   Thomas|   Hardy|     June 2,1840|
+---------+--------+----------------+

数据转换

数据转换的需求可能有多种,而且每个案例大多是独特的。我们将覆盖以下一些基本的转换类型:

  • 将两列合并为一列

  • 向现有数据添加字符/数字

  • 删除或替换现有字符/数字

  • 更改日期格式

Python

// Merging columns
//Create a udf to concatenate two column values
>>> import pyspark.sql.functions
>>> concat_func = pyspark.sql.functions.udf(lambda name, age: name + "_" + str(age))

//Apply the udf to create merged column
>>> concat_df = final_data.withColumn("name_age", concat_func(final_data.name, final_data.age))
>>> concat_df.show(4)
+------+----+---+---------+------+--------+
|emp_id|name|age|     role|salary|name_age|
+------+----+---+---------+------+--------+
|     1|John| 25|Associate| 10000| John_25|
|     2| Ray| 35|  Manager| 12000|  Ray_35|
|     3|Mike| 24|  Manager| 12000| Mike_24|
|     4|Jane| 28|Associate|  null| Jane_28|
+------+----+---+---------+------+--------+
only showing top 4 rows
// Adding constant to data
>>> data_new = concat_df.withColumn("age_incremented",concat_df.age + 10)
>>> data_new.show(4)
+------+----+---+---------+------+--------+---------------+
|emp_id|name|age|     role|salary|name_age|age_incremented|
+------+----+---+---------+------+--------+---------------+
|     1|John| 25|Associate| 10000| John_25|             35|
|     2| Ray| 35|  Manager| 12000|  Ray_35|             45|
|     3|Mike| 24|  Manager| 12000| Mike_24|             34|
|     4|Jane| 28|Associate|  null| Jane_28|             38|
+------+----+---+---------+------+--------+---------------+
only showing top 4 rows
>>> 

//Replace values in a column
>>> df1.replace('Emily','Charlotte','FirstName').show()
+---------+--------+----------------+
|FirstName|LastName|             Dob|
+---------+--------+----------------+
|   Thomas|   Hardy|    June 2, 1840|
|  Charles| Dickens| 7 February 1812|
|     Mark|   Twain|            null|
|     Jane|  Austen|16 December 1775|
|Charlotte|    null|            null|
+---------+--------+----------------+

// If the column name argument is omitted in replace, then replacement is applicable to all columns
//Append new columns based on existing values in a column
//Give 'LastName' instead of 'Initial' if you want to overwrite
>>> df1.withColumn('Initial',df1.LastName.substr(1,1)).show()
+---------+--------+----------------+-------+
|FirstName|LastName|             Dob|Initial|
+---------+--------+----------------+-------+
|   Thomas|   Hardy|    June 2, 1840|      H|
|  Charles| Dickens| 7 February 1812|      D|
|     Mark|   Twain|            null|      T|
|     Jane|  Austen|16 December 1775|      A|
|    Emily|    null|            null|   null|
+---------+--------+----------------+-------+

Scala:

// Merging columns
//Create a udf to concatenate two column values
scala> val concatfunc = udf((name: String, age: Integer) =>
                           {name + "_" + age})
concatfunc: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function2>,StringType,Some(List(StringType, IntegerType)))
scala>

//Apply the udf to create merged column
scala> val concat_df = final_data.withColumn("name_age",
                         concatfunc($"name", $"age"))
concat_df: org.apache.spark.sql.DataFrame =
         [emp_id: int, name: string ... 4 more fields]
scala> concat_df.show(4)
+------+----+---+---------+------+--------+
|emp_id|name|age|     role|salary|name_age|
+------+----+---+---------+------+--------+
|     1|John| 25|Associate| 10000| John_25|
|     2| Ray| 35|  Manager| 12000|  Ray_35|
|     3|Mike| 24|  Manager| 12000| Mike_24|
|     4|Jane| 28|Associate|  null| Jane_28|
+------+----+---+---------+------+--------+
only showing top 4 rows
scala>

// Adding constant to data
scala> val addconst = udf((age: Integer) => {age + 10})
addconst: org.apache.spark.sql.expressions.UserDefinedFunction =
      UserDefinedFunction(<function1>,IntegerType,Some(List(IntegerType)))
scala> val data_new = concat_df.withColumn("age_incremented",
                 addconst(col("age")))
data_new: org.apache.spark.sql.DataFrame =
     [emp_id: int, name: string ... 5 more fields]
scala> data_new.show(4)
+------+----+---+---------+------+--------+---------------+
|emp_id|name|age|     role|salary|name_age|age_incremented|
+------+----+---+---------+------+--------+---------------+
|     1|John| 25|Associate| 10000| John_25|             35|
|     2| Ray| 35|  Manager| 12000|  Ray_35|             45|
|     3|Mike| 24|  Manager| 12000| Mike_24|             34|
|     4|Jane| 28|Associate|  null| Jane_28|             38|
+------+----+---+---------+------+--------+---------------+
only showing top 4 rows

// Replace values in a column
//Note: As of Spark 2.0.0, there is no replace on DataFrame/ Dataset does not work so .na. is a work around
scala> ds1.na.replace("FirstName",Map("Emily" -> "Charlotte")).show()
+---------+--------+---------------+
|FirstName|LastName|            Dob|
+---------+--------+---------------+
|   Thomas|   Hardy|   June 2, 1840|
|  Charles| Dickens|7 February 1812|
|     Mark|   Twain|           null|
|Charlotte|    null|           null|
+---------+--------+---------------+
scala>

// If the column name argument is "*" in replace, then replacement is applicable to all columns
//Append new columns based on existing values in a column
//Give "LastName" instead of "Initial" if you want to overwrite
scala> ds1.withColumn("Initial",ds1("LastName").substr(1,1)).show()
+---------+--------+---------------+-------+
|FirstName|LastName|            Dob|Initial|
+---------+--------+---------------+-------+
|   Thomas|   Hardy|   June 2, 1840|      H|
|  Charles| Dickens|7 February 1812|      D|
|     Mark|   Twain|           null|      T|
|    Emily|    null|           null|   null|
+---------+--------+---------------+-------+

现在我们已经了解了基本的示例,让我们来处理一个稍微复杂一点的例子。你可能已经注意到,作者数据中的日期列有不同的日期格式。在某些情况下,月份在前,日期在后;而在其他情况下则相反。这种异常在现实世界中很常见,因为数据可能来自不同的来源。在这里,我们看到的情况是日期列中有多个不同的日期格式数据点。我们需要将所有不同的日期格式标准化为一个统一的格式。为此,我们首先需要创建一个用户定义函数udf),它可以处理不同的格式并将它们转换为统一的格式。

// Date conversions
//Create udf for date conversion that converts incoming string to YYYY-MM-DD format
// The function assumes month is full month name and year is always 4 digits
// Separator is always a space or comma
// Month, date and year may come in any order
//Reusing authors data
>>> authors = [['Thomas','Hardy','June 2, 1840'],
        ['Charles','Dickens','7 February 1812'],
        ['Mark','Twain',None],
        ['Jane','Austen','16 December 1775'],
        ['Emily',None,None]]
>>> df1 = sc.parallelize(authors).toDF(
      ["FirstName","LastName","Dob"])
>>> 

// Define udf
//Note: You may create this in a script file and execute with execfile(filename.py)
>>> def toDate(s):
 import re
 year = month = day = ""
 if not s:
  return None
 mn = [0,'January','February','March','April','May',
  'June','July','August','September',
  'October','November','December']

 //Split the string and remove empty tokens
 l = [tok for tok in re.split(",| ",s) if tok]

//Assign token to year, month or day
 for a in l:
  if a in mn:
   month = "{:0>2d}".format(mn.index(a))
  elif len(a) == 4:
   year = a
  elif len(a) == 1:
   day = '0' + a
  else:
   day = a
 return year + '-' + month + '-' + day
>>> 

//Register the udf
>>> from pyspark.sql.functions import udf
>>> from pyspark.sql.types import StringType
>>> toDateUDF = udf(toDate, StringType())

//Apply udf
>>> df1.withColumn("Dob",toDateUDF("Dob")).show()
+---------+--------+----------+
|FirstName|LastName|       Dob|
+---------+--------+----------+
|   Thomas|   Hardy|1840-06-02|
|  Charles| Dickens|1812-02-07|
|     Mark|   Twain|      null|
|     Jane|  Austen|1775-12-16|
|    Emily|    null|      null|
+---------+--------+----------+
>>> 

Scala

//Date conversions
//Create udf for date conversion that converts incoming string to YYYY-MM-DD format
// The function assumes month is full month name and year is always 4 digits
// Separator is always a space or comma
// Month, date and year may come in any order
//Reusing authors case class and data
>>> val authors = Seq(
        Author("Thomas","Hardy","June 2, 1840"),
        Author("Charles","Dickens","7 February 1812"),
        Author("Mark","Twain",null),
        Author("Jane","Austen","16 December 1775"),
        Author("Emily",null,null))
authors: Seq[Author] = List(Author(Thomas,Hardy,June 2, 1840), Author(Charles,Dickens,7 February 1812), Author(Mark,Twain,null), Author(Jane,Austen,16 December 1775), Author(Emily,null,null))
scala> val ds1 = sc.parallelize(authors).toDS()
ds1: org.apache.spark.sql.Dataset[Author] = [FirstName: string, LastName: string ... 1 more field]
scala>

// Define udf
//Note: You can type :paste on REPL to paste  multiline code. CTRL + D signals end of paste mode
def toDateUDF = udf((s: String) => {
    var (year, month, day) = ("","","")
    val mn = List("","January","February","March","April","May",
        "June","July","August","September",
        "October","November","December")
    //Tokenize the date string and remove trailing comma, if any
    if(s != null) {
      for (x <- s.split(" ")) {
        val token = x.stripSuffix(",")
        token match {
        case "" =>
        case x if (mn.contains(token)) =>
            month = "%02d".format(mn.indexOf(token))
        case x if (token.length() == 4) =>
            year = token
        case x =>
            day = token
        }
     }   //End of token processing for
     year + "-" + month + "-" + day=
   } else {
       null
   }
})
toDateUDF: org.apache.spark.sql.expressions.UserDefinedFunction
scala>

//Apply udf and convert date strings to standard form YYYY-MM-DD
scala> ds1.withColumn("Dob",toDateUDF(ds1("Dob"))).show()
+---------+--------+----------+
|FirstName|LastName|       Dob|
+---------+--------+----------+
|   Thomas|   Hardy| 1840-06-2|
|  Charles| Dickens| 1812-02-7|
|     Mark|   Twain|      null|
|     Jane|  Austen|1775-12-16|
|    Emily|    null|      null|
+---------+--------+----------+

这会整齐地排列出生日期字符串。当我们遇到更多不同的日期格式时,可以继续调整这个用户定义函数(udf)。

在这一阶段,在开始进行数据分析之前,非常重要的一点是你应该暂停片刻,重新评估从数据采集到数据清理和转换过程中所采取的所有步骤。有很多情况下,涉及大量时间和精力的工作最后失败,因为分析和建模的数据是不正确的。这些情况成了著名计算机谚语的完美例证——垃圾进,垃圾出GIGO)。

统计学基础

统计学领域主要是通过数学方法将数据集中的原始事实和数字以某种有意义的方式进行总结,使其对你有意义。这包括但不限于:收集数据、分析数据、解释数据和展示数据。

统计学的存在主要是因为通常无法收集整个总体的数据。因此,通过使用统计技术,我们利用样本统计量来估计总体参数,并应对不确定性。

在这一部分,我们将介绍一些基础的统计学和分析技术,在这些基础上,我们将逐步建立对本书中所涵盖概念的全面理解。

统计学的研究可以大致分为两个主要分支:

  • 描述性统计

  • 推断统计

以下图表展示了这两个术语,并说明了我们如何从样本中估计总体参数:

统计基础

在开始之前,了解抽样和分布是很重要的。

抽样

通过抽样技术,我们只需从总体数据集中取出一部分并进行处理:

抽样

但为什么我们要进行抽样?以下是进行抽样的各种原因:

  • 很难获得整个总体的数据;例如,某个国家公民的身高。

  • 处理整个数据集是很困难的。当我们谈论大数据计算平台(如 Spark)时,这个挑战几乎消失了。然而,也有可能遇到需要将手头的整个数据视为样本,并将分析结果推断到未来时间或更大总体的情况。

  • 难以绘制大量数据以便可视化。技术上可能存在限制。

  • 为了验证你的分析或验证预测模型——特别是在处理小数据集时,你需要依赖交叉验证。

为了有效的抽样,有两个重要的约束:一是确定样本大小,二是选择抽样技术。样本大小对总体参数的估计有很大的影响。我们将在本章后面讨论这一方面,首先会介绍一些先决的基础知识。在这一节中,我们将重点讨论抽样技术。

有多种基于概率(每个样本被选择的概率已知)和非概率(每个样本被选择的概率未知)抽样技术可供选择,但我们将仅讨论基于概率的技术。

简单随机抽样

简单随机抽样SRS)是最基本的概率抽样方法,其中每个元素被选择的概率相同。这意味着每个可能的n元素样本都有相等的选择机会。

系统抽样

系统抽样可能是所有基于概率的抽样技术中最简单的一种,在这种方法中,总体的每个第 k个元素都会被抽取。因此,这也叫做间隔抽样。它从一个随机选择的固定起点开始,然后估算一个间隔(即第 k个元素,其中k = (总体大小)/(样本大小))。这里,元素的选择会循环进行,直到达到样本大小,即从开始重新选择直到结束。

分层抽样

当种群内的子群体或子群体差异较大时,这种抽样技术是首选,因为其他抽样技术可能无法提取出一个能够良好代表整个种群的样本。通过分层抽样,将种群划分为同质的子群体,称为,并通过按照种群比例从这些层中随机选择样本来进行抽样。因此,样本中的层大小与种群大小的比例保持一致:

Python

/* ”Sample” function is defined for DataFrames (not RDDs) which takes three parameters:
withReplacement - Sample with replacement or not (input: True/False)
fraction - Fraction of rows to generate (input: any number between 0 and 1 as per your requirement of sample size)
seed - Seed for sampling (input: Any random seed)
*/
>>> sample1 = data_new.sample(False, 0.6) //With random seed as no seed value specified
>>> sample2 = data_new.sample(False, 0.6, 10000) //With specific seed value of 10000

Scala:

scala> val sample1 = data_new.sample(false, 0.6) //With random seed as no seed value specified
sample1: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [emp_id: int, name: string ... 5 more fields]
scala> val sample2 = data_new.sample(false, 0.6, 10000) //With specific seed value of 10000
sample2: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [emp_id: int, name: string ... 5 more fields]

注意

我们只看了 DataFrame 上的抽样;在 MLlib 库中也有如sampleByKeysampleByKeyExact等函数,可以在键值对的 RDD 上进行分层抽样。可以查看spark.util.random包,了解 Bernoulli、Poisson 或随机抽样器。

数据分布

了解数据的分布情况是将数据转化为信息的主要任务之一。分析变量的分布有助于发现异常值、可视化数据的趋势,并且有助于你对数据的理解。这有助于正确思考并采取正确的方式解决业务问题。绘制分布图使得直观性更强,我们将在描述性统计部分讨论这一方面。

频率分布

频率分布解释了一个变量可以取哪些值,以及它取这些值的频率。通常用一个表格来表示每个可能值及其对应的出现次数。

假设我们掷一颗六面骰子 100 次,并观察到以下频率:

频率分布

频率表

同样地,你可能会在每一组 100 次掷骰子中观察到不同的分布,因为这将取决于运气。

有时,你可能对出现次数的比例感兴趣,而不仅仅是出现的次数。在前面的掷骰子例子中,我们总共掷了 100 次骰子,因此比例分布或相对频率分布将呈现如下:

频率分布

相对频率表

概率分布

在同一个掷骰子的例子中,我们知道一个总概率为 1 的值是分布在骰子的所有面上的。这意味着每一面(从 1 到 6)上都与概率 1/6(约为 0.167)相关联。无论你掷骰子的次数是多少(假设是公平的骰子!),1/6 的概率都会均匀地分布在骰子的所有面上。所以,如果你绘制这个分布图,它将呈现如下:

概率分布

概率分布

我们在这里查看了三种分布——频率分布、相对频率分布和概率分布。

这个概率分布实际上是总体的分布。在实际情况中,有时我们已经知道总体分布(在我们的例子中,它是公平骰子六个面的概率为 0.167),而有时我们不知道。在没有总体分布的情况下,找出总体分布本身就成为推断统计的一部分。而且,与公平骰子的例子不同,所有面上的概率相同,在其他情况下,变量可能的值可以与不同的概率相关联,并且这些值也可以遵循某种特定类型的分布。

现在是揭开秘密的时候了!相对频率分布与概率分布之间的关系是统计推断的基础。相对频率分布也叫做经验分布,它是基于我们在样本中观察到的内容(这里是 100 次的样本)。如前所述,每 100 次掷骰子的经验分布会根据机会而有所不同。现在,掷骰子的次数越多,相对频率分布与概率分布的差异就越小。因此,无限次掷骰子的相对频率分布即为概率分布,而概率分布反过来就是总体分布。

概率分布有多种类型,这些分布根据变量类型(分类变量或连续变量)再次分类。我们将在本章的后续部分详细讨论这些分布。不过,我们应该了解这些分类所代表的含义!分类变量只能取几个类别。例如,及格/不及格、零/一、癌症/恶性是具有两个类别的分类变量的例子。同样,分类变量也可以有更多的类别,比如红/绿/蓝、类型 1/类型 2/类型 3/类型 4,等等。连续变量可以在给定的范围内取任意值,并且是以连续的尺度进行度量的,例如年龄、身高、工资等。理论上,在连续变量的任意两个值之间可以有无限多个可能值。例如,在身高范围 5'6"到 6'4"(英尺和英寸尺度)之间,可能有许多分数值。同样,在厘米尺度下也是如此。

描述性统计

在前一节中,我们学习了分布是如何形成的。在本节中,我们将学习如何通过描述性统计来描述这些分布。描述分布的两个重要组成部分是其位置和分布的扩展。

位置度量

位置度量是一个描述数据中心位置的单一值。三种最常见的定位度量是均值、中位数和众数。

均值

到目前为止,最常见且广泛使用的集中趋势度量是均值,也称为平均数。无论是样本还是总体,均值或平均数是所有元素的总和除以元素的总数。

中位数

中位数是当数据按任意顺序排序时位于中间的数值,使得一半数据大于中位数,另一半小于中位数。当有两个中位数值(数据项数目为偶数)时,中位数是这两个中间值的平均值。中位数是处理数据集中的异常值(极端值)时更好的位置度量。

众数

众数是出现频率最高的数据项。它可以用于定性数据和定量数据的确定。

Python

//重用在重复值处理中创建的 data_new

>>> mean_age = data_new.agg({'age': 'mean'}).first()[0]
>>> age_counts = data_new.groupBy("age").agg({"age": "count"}).alias("freq")
>>> mode_age = age_counts.sort(age_counts["COUNT(age)"].desc(), age_counts.age.asc()).first()[0]
>>> print(mean_age, mode_age)
(29.615384615384617, 25)
>>> age_counts.sort("count(age)",ascending=False).show(2)
+---+----------+                                                               
|age|count(age)|
+---+----------+
| 28|         3|
| 29|         2|
+---+----------+
only showing top 2 rows

Scala

//Reusing data_new created 
scala> val mean_age = data_new.select(floor(avg("age"))).first().getLong(0)
mean_age: Long = 29
scala> val mode_age = data_new.groupBy($"age").agg(count($"age")).
                 sort($"count(age)".desc, $"age").first().getInt(0)
mode_age: Int = 28
scala> val age_counts = data_new.groupBy("age").agg(count($"age") as "freq")
age_counts: org.apache.spark.sql.DataFrame = [age: int, freq: bigint]
scala> age_counts.sort($"freq".desc).show(2)
+---+----+                                                                     
|age|freq|
+---+----+
| 35|   2|
| 28|   2|
+---+----+

离散度度量

离散度度量描述了某一特定变量或数据项的数据的集中程度或分散程度。

范围

范围是变量最小值和最大值之间的差异。它的一个缺点是没有考虑到数据中的每一个值。

方差

为了找出数据集中的变异性,我们可以将每个值与均值相减,平方它们以去除负号(同时放大数值),然后将所有结果相加,并除以总的值个数:

方差

如果数据分布较广,方差会是一个较大的数字。它的一个缺点是对异常值赋予了过多的权重。

标准差

与方差类似,标准差也是衡量数据离散程度的指标。方差的一个局限性是数据的单位也被平方,因此很难将方差与数据集中的数值相关联。因此,标准差是通过方差的平方根计算出来的:

标准差

Python

//Reusing data_new created before
import math
>>> range_salary = data_new.agg({'salary': 'max'}).first()[0] - data_new.agg({'salary': 'min'}).first()[0]
>>> mean_salary = data_new.agg({'salary': 'mean'}).first()[0]
>>> salary_deviations = data_new.select(((data_new.salary - mean_salary) *
       (data_new.salary - mean_salary)).alias("deviation"))
>>> stddev_salary = math.sqrt(salary_deviations.agg({'deviation' : 
'avg'}).first()[0])
>>> variance_salary = salary_deviations.groupBy().avg("deviation").first()[0]
>>> print(round(range_salary,2), round(mean_salary,2),
      round(variance_salary,2), round(stddev_salary,2))
(119880.0, 20843.33, 921223322.22, 30351.66)
>>> 

Scala

//Reusing data_new created before
scala> val range_salary = data_new.select(max("salary")).first().
          getLong(0) - data_new.select(min("salary")).first().getLong(0)
range_salary: Long = 119880
scala> val mean_salary = data_new.select(floor(avg("salary"))).first().getLong(0)
mean_salary: Long = 20843
scala> val salary_deviations = data_new.select(((data_new("salary") - mean_salary)
                     * (data_new("salary") - mean_salary)).alias("deviation"))
salary_deviations: org.apache.spark.sql.DataFrame = [deviation: bigint]
scala> val variance_salary = { salary_deviations.select(avg("deviation"))
                                       .first().getDouble(0) }
variance_salary: Double = 9.212233223333334E8
scala> val stddev_salary = { salary_deviations
                    .select(sqrt(avg("deviation")))
                    .first().getDouble(0) }
stddev_salary: Double = 30351.660948510435

汇总统计

数据集的汇总统计信息是非常有用的信息,可以帮助我们快速了解当前的数据。通过使用统计学中提供的colStats函数,我们可以获得RDD[Vector]的多变量统计汇总,其中包含按列计算的最大值、最小值、均值、方差、非零值的数量和总计数。让我们通过一些代码示例来探索这个:

Python

>>> import numpy
>>> from pyspark.mllib.stat import Statistics
// Create an RDD of number vectors
//This example creates an RDD with 5 rows with 5 elements each
>>> observations = sc.parallelize(numpy.random.random_integers(0,100,(5,5)))
// Compute column summary statistics.
//Note that the results may vary because of random numbers
>>> summary = Statistics.colStats(observations)
>>> print(summary.mean())       // mean value for each column
>>> print(summary.variance())  // column-wise variance
>>> print(summary.numNonzeros())// number of nonzeros in each column

Scala

scala> import org.apache.spark.mllib.linalg.Vectors
import org.apache.spark.mllib.linalg.Vectors
scala> import org.apache.spark.mllib.stat.{
          MultivariateStatisticalSummary, Statistics}
import org.apache.spark.mllib.stat.{MultivariateStatisticalSummary, Statistics}
// Create an RDD of number vectors
//This example creates an RDD with 5 rows with 5 elements each
scala> val observations = sc.parallelize(Seq.fill(5)(Vectors.dense(Array.fill(5)(
                    scala.util.Random.nextDouble))))
observations: org.apache.spark.rdd.RDD[org.apache.spark.mllib.linalg.Vector] = ParallelCollectionRDD[43] at parallelize at <console>:27
scala>
// Compute column summary statistics.
//Note that the results may vary because of random numbers
scala> val summary = Statistics.colStats(observations)
summary: org.apache.spark.mllib.stat.MultivariateStatisticalSummary = org.apache.spark.mllib.stat.MultivariateOnlineSummarizer@36836161
scala> println(summary.mean)  // mean value for each column
[0.5782406967737089,0.5903954680966121,0.4892908815930067,0.45680701799234835,0.6611492334819364]
scala> println(summary.variance)    // column-wise variance
[0.11893608153330748,0.07673977181967367,0.023169197889513014,0.08882605965192601,0.08360159585590332]
scala> println(summary.numNonzeros) // number of nonzeros in each column
[5.0,5.0,5.0,5.0,5.0]

提示

Apache Spark MLlib 基于 RDD 的 API 从 Spark 2.0 开始进入维护模式。预计在 2.2+ 中将被弃用,并在 Spark 3.0 中移除。

图形化技术

要了解数据点的行为,您可能需要绘制它们并查看。但是,您需要一个平台来将您的数据可视化,例如箱线图散点图直方图等。 iPython/Jupyter 笔记本或任何由 Spark 支持的第三方笔记本都可以在浏览器中用于数据可视化。 Databricks 提供他们自己的笔记本。可视化在自己的章节中进行讨论,本章专注于完整的生命周期。但是,Spark 提供了直方图数据准备,使得桶范围和频率可以与客户端机器传输,而不是完整数据集。以下示例显示了相同。

Python

//Histogram
>>>from random import randint
>>> numRDD = sc.parallelize([randint(0,9) for x in xrange(1,1001)])
// Generate histogram data for given bucket count
>>> numRDD.histogram(5)
([0.0, 1.8, 3.6, 5.4, 7.2, 9], [202, 213, 215, 188, 182])
//Alternatively, specify ranges
>>> numRDD.histogram([0,3,6,10])
([0, 3, 6, 10], [319, 311, 370])

Scala:

//Histogram
scala> val numRDD = sc.parallelize(Seq.fill(1000)(
                    scala.util.Random.nextInt(10)))
numRDD: org.apache.spark.rdd.RDD[Int] =
     ParallelCollectionRDD[0] at parallelize at <console>:24
// Generate histogram data for given bucket count
scala> numRDD.histogram(5)
res10: (Array[Double], Array[Long]) = (Array(0.0, 1.8, 3.6, 5.4, 7.2, 9.0),Array(194, 209, 215, 195, 187))
scala>
//Alternatively, specify ranges
scala> numRDD.histogram(Array(0,3.0,6,10))
res13: Array[Long] = Array(293, 325, 382)

推断统计学

我们看到描述统计学在描述和展示数据方面非常有用,但它们并没有提供一种使用样本统计量来推断总体参数或验证我们可能提出的任何假设的方法。因此,推断统计学的技术出现以满足这些需求。推断统计学的一些重要用途包括:

  • 估计总体参数

  • 假设检验

请注意,样本永远无法完美地代表总体,因为每次抽样都会自然产生抽样误差,因此需要推断统计学!让我们花些时间了解可以帮助推断总体参数的各种概率分布类型。

离散概率分布

离散概率分布用于建模本质上是离散的数据,这意味着数据只能取特定的值,如整数。与分类变量不同,离散变量可以仅取数值数据,特别是一组不同整数值的计数数据。此外,随机变量所有可能值的概率之和为一。离散概率分布以概率质量函数描述。可以有各种类型的离散概率分布。以下是一些示例。

伯努利分布

伯努利分布是一种描述只有两种可能结果的试验的分布,例如成功/失败,正面/反面,六面骰子的面值为 4 或不为 4,发送的消息是否被接收等。伯努利分布可以推广为任何具有两个或更多可能结果的分类变量。

让我们以“学生通过考试的概率”为例,其中 0.6(60%)是学生通过考试的概率P,而 0.4(40%)是学生未能通过考试的概率(1-P)。让我们将失败表示为0,通过表示为1

伯努利分布

这样的分布无法回答诸如学生的预期通过率的问题,因为预期值(μ)将是一个分数,而该分布不能取这个值。它只能意味着,如果你对 1000 名学生进行抽样,那么 600 名会通过,400 名会失败。

二项分布

该分布可以描述一系列伯努利试验(每个试验只有两种可能结果)。此外,它假设一个试验的结果不会影响后续的试验,并且每次试验中事件发生的概率是相同的。一个二项分布的例子是抛掷硬币五次。在这里,第一个投掷的结果不会影响第二次投掷的结果,每次投掷的概率是相同的。

如果n是试验次数,p是每次试验中成功的概率,那么该二项分布的均值(μ)可以表示为:

μ = n * p

方差(σ2x)可以表示为:

σ2x = np(1-p)。

一般来说,遵循二项分布的随机变量X,其参数为np,可以表示为X ~ B(n, p)。对于这样的分布,获得在n次试验中恰好* k *次成功的概率可以通过概率质量函数来描述,如下所示:

Binomial distributionBinomial distribution

这里,k = 0, 1, 2, ..., n

示例问题

假设一个假设场景。假设某城市的 24%公司宣布将向受到海啸影响的地区提供支持,作为其社会责任活动的一部分。在一个随机选择的 20 家公司样本中,求出有多少公司宣布将帮助海啸受灾地区的概率:

  • 恰好三次

  • 少于三次

  • 三次或更多

解答

样本大小 = n = 20。

随机选择的公司宣布将提供帮助的概率 = P = 0.24

a) P(x = 3) = ²⁰C[3] (0.24)³ (0.76) ¹⁷ = 0.15

b) P(x < 3) = P(0) + P(1) + P(2)

= (0.76) ²⁰ + ²⁰C[1] (0.24) (0.76)¹⁹ + ²⁰C[2] (0.24)² (0.76)¹⁸

= 0.0041 + 0.0261 + 0.0783 = 0.11

c) P(x >= 3) = 1 - P(x <= 2) = 1 - 0.11 = 0.89

请注意,二项分布广泛应用于你想要模拟从一个规模为N的总体中抽取大小为n的样本成功率的场景,且在抽样过程中允许重复。如果没有替换,那么抽样将不再独立,因此不能正确遵循二项分布。然而,确实存在这样的场景,可以通过不同类型的分布进行建模,如超几何分布。

泊松分布

泊松分布可以描述在固定时间或空间区间内,以已知平均速率发生的一定数量的独立事件的概率。请注意,事件应该只有二元结果,例如:你每天接到的电话次数,或者每小时通过某个信号的汽车数量。你需要仔细观察这些例子。请注意,这里没有提供信息的另一半,比如:你每天没有接到多少个电话,或者每小时没有通过多少辆车。这样的数据点没有另一半的信息。相反,如果我说 50 个学生中有 30 个通过了考试,你可以很容易地推断出有 20 个学生没有通过!你有了这另一半的信息。

如果µ是发生事件的平均次数(在固定时间或空间区间内的已知平均速率),那么在同一时间区间内发生k次事件的概率可以通过概率质量函数来描述:

泊松分布

其中, k = 0, 1, 2, 3...

前面的公式描述了泊松分布。

对于泊松分布,均值和方差是相同的。同时,随着均值或方差的增加,泊松分布趋向于更加对称。

示例问题

假设你知道在一个工作日,消防站接到的电话平均次数是八次。那么,在某个工作日接到 11 个电话的概率是多少呢?这个问题可以通过以下基于泊松分布的公式来解决:

示例问题

连续概率分布

连续概率分布用于对连续性质的数据进行建模,这意味着数据只能取特定范围内的任何值。因此,我们处理的是与区间相关的概率,而不是与某个特定值相关的概率,因为它的概率为零。连续概率分布是实验的理论模型;它是通过无限次观测建立的相对频率分布。这意味着当你缩小区间时,观测次数会增加,而随着观测次数越来越多并接近无穷大时,它就形成了一个连续概率分布。曲线下的总面积为 1,若要找到与某个特定范围相关的概率,我们必须找到曲线下的面积。因此,连续分布通常通过概率密度函数PDF)来描述,其形式如下:

P(a ≤ X ≤ b) = a∫^b f(x) dx

连续概率分布有多种类型。以下章节展示了几个例子。

正态分布

正态分布是一种简单、直观,但非常重要的连续概率分布。因其绘制时的外观而被称为高斯分布或钟形曲线。此外,对于完美的正态分布,均值、中位数和众数都是相同的。

许多自然现象遵循正态分布(它们也可能遵循不同的分布!),例如人的身高、测量误差等。但是,正态分布不适合模拟高度倾斜或固有为正值的变量(例如股票价格或学生的测试成绩,其中难度水平很低)。这些变量可能更适合用不同的分布描述,或者在数据转换(如对数转换)后再用正态分布描述。

正态分布可以用两个描述符来描述:均值代表中心位置,标准差代表扩展(高度和宽度)。表示正态分布的概率密度函数如下:

正态分布

正态分布之所以在流行度榜首,其中一个原因是中心极限定理CLT)。它指出,无论是什么种群分布,从同一种群分布独立抽取的样本的均值几乎服从正态分布,这种正态性随着样本量的增加而增强。这种行为实际上是统计假设检验的基础。

此外,每个正态分布,无论其均值和标准差如何,都遵循经验法则(68-95-99.7 法则),即约 68%的面积位于均值的一个标准差内,约 95%的面积位于均值的两个标准差内,约 99.7%的面积位于均值的三个标准差内。

现在,为了找到事件的概率,您可以使用积分微积分,或者按照下一节中的说明将分布转换为标准正态分布。

标准正态分布

标准正态分布是一种均值为0,标准差为1的正态分布类型。这种分布在自然界中很少见。它主要设计用于找出正态分布曲线下的面积(而不是使用微积分积分)或者标准化数据点。

假设随机变量X服从均值(μ)和标准差(σ)的正态分布,则随机变量Z将服从均值0和标准差1的标准正态分布。可以通过以下方式找到Z的值:

标准正态分布

由于数据可以通过这种方式标准化,因此数据点可以表示为离均值有多少个标准差,并可以在分布中进行解读。这有助于比较两个具有不同尺度的分布。

你可以在那些想要找到落入特定范围的百分比的场景中使用正态分布——前提是分布大致正态。

考虑以下示例:

如果商店老板在某一天经营商店的时间遵循正态分布,且μ = 8小时,σ = 0.5小时,那么他在商店待少于 7.5 小时的概率是多少?

概率分布将如下所示:

标准正态分布

数据分布

标准正态分布标准正态分布

标准正态分布

所以,商店老板待在商店少于 7.5 小时的概率由以下公式给出:

P(z = -1) = 0.1587 = 15.87

注意

这是通过 Z 表计算出来的。

请注意,数据集的正态性通常是一个近似值。你首先需要检查数据的正态性,如果你的分析假设数据为正态分布,则可以继续进行。检查正态性的方法有很多种:你可以选择直方图(并且拟合一条与数据均值和标准差相符的曲线)、正态概率图或 QQ 图等技术。

卡方分布

卡方分布是统计推断中最广泛使用的分布之一。它是伽玛分布的一个特例,适用于建模非负变量的偏斜分布。它表示,如果随机变量X是正态分布的,且Z是其标准正态变量之一,那么Z[2]将具有X[²]分布,且自由度为 1。类似地,如果我们从相同分布中抽取多个这样的独立标准正态变量,将它们平方并相加,那么它们也将遵循X[²]分布,如下所示:

Z[12] + Z[22] + ... + Z[k2]将具有X[2]分布,且自由度为k

卡方分布主要用于基于样本方差或标准差推断总体方差或总体标准差。这是因为X[2]分布是通过另一种方式定义的,涉及样本方差与总体方差的比率。

为了证明这一点,让我们从一个正态分布中随机抽取样本(x[1], x[2],...,xn),其方差为卡方分布

样本均值由以下公式给出:

卡方分布

然而,样本方差由以下公式给出:

卡方分布

考虑到上述事实,我们可以将卡方统计量定义如下:

卡方分布

(记住卡方分布Z[2] 将会有 X[2] 分布。)

因此,卡方分布

因此,卡方统计量的抽样分布将遵循自由度为(n-1)的卡方分布。

具有n自由度和伽马函数Г的卡方分布的概率密度函数如下:

卡方分布

对于具有k自由度的χ2分布,均值(µ)= k,方差(σ2)= 2k

请注意,卡方分布是正偏的,但随着自由度增加,偏度减小,并接近正态分布。

示例问题

找到成人单人电影票价格的方差和标准差的 90%置信区间。所给数据代表全国电影院的选定样本。假设变量呈正态分布。

给定样本(以$计算):10, 08, 07, 11, 12, 06, 05, 09, 15, 12

解决方案:

N = 10

样本均值:

示例问题

样本的方差:

示例问题

样本的标准差:

S = sqrt(9.61)

自由度:

10-1 = 9

现在我们需要找到 90%的置信区间,这意味着数据的 10%将留在尾部。

示例问题

现在,让我们使用公式:

示例问题示例问题

然后我们可以使用表格或计算机程序找到卡方值。

要找到中间 90%的置信区间,我们可以考虑左边 95%和右边 5%。

所以,代入数字后,我们得到:

示例问题示例问题示例问题

因此,我们可以得出结论,基于全国 10 家电影票价格样本,我们对整个国家的电影票价格标准差有 90%的信心区间在$2.26 和$5.10 之间。

学生 t 分布

学生 t 分布用于估算正态分布总体的均值,当总体标准差未知或样本量过小时。在这种情况下,只能通过样本估算总体参数μσ

这个分布呈钟形,对称,类似正态分布,但尾部更重。当样本量大时,t 分布趋于正态分布。

让我们从均值为μ和方差为σ2的正态分布中随机抽取样本(x1, x2,...,xn)。

样本均值为 学生 t 分布 和样本方差 学生 t 分布

考虑到上述事实,t 统计量可以定义为:

学生 t 分布

t 统计量的抽样分布将遵循具有 (n-1) 自由度 (df) 的 t 分布。自由度越高,t 分布越接近标准正态分布。

t 分布的均值 (μ) = 0,方差 (σ2) = df/df-2

现在,为了让事情更清晰,让我们回顾一下,考虑总体 σ 已知的情况。当总体是正态分布时,无论样本量大小如何,样本均值 通常也是正态分布,且任何线性变换的 ,例如学生 t 分布,也将遵循正态分布。

如果总体不是正态分布怎么办?即使如此,根据中心极限定理,当样本量足够大时,(即抽样分布)或学生 t 分布的分布将趋近于正态分布!

另一种情况是总体的 σ 不为已知。如果总体是正态分布,那么样本均值 通常也是正态分布,但随机变量 学生 t 分布 将不遵循正态分布;它遵循具有 (n-1) 自由度的 t 分布。原因是因为分母中的 S 存在随机性,不同的样本会导致不同的值。

在上述情况下,如果总体不是正态分布,按照中心极限定理(CLT),当样本量足够大时,学生 t 分布的分布将趋近于正态分布(但小样本量时则不适用!)。因此,当样本量较大时,学生 t 分布的分布会趋近于正态分布,可以安全地假设它符合 t 分布,因为 t 分布随着样本量增加而逐渐接近正态分布。

F-分布

在统计推断中,F-分布用于研究两个正态分布总体的方差。它表明,来自两个独立正态分布总体的样本方差的抽样分布,如果这两个总体的方差相同,则遵循 F-分布。

如果样本 1 的样本方差是F-分布,并且样本 2 的样本方差是F-分布,那么F-分布将遵循 F-分布(σ12 = σ22)。

根据上述事实,我们还可以说,F-分布也将遵循 F-分布。

在前一节的卡方分布中,我们还可以说:

F-分布也将遵循具有 n1-1n2-1 自由度的 F 分布。对于这些自由度的每种组合,会有不同的 F 分布。

标准误差

统计量(如均值或方差)抽样分布的标准差称为标准误差SE),它是衡量变异性的一个指标。换句话说,均值的标准误差SEM)可以定义为样本均值对总体均值估计的标准差。

随着样本量的增加,均值的抽样分布变得越来越接近正态分布,标准差也变得越来越小。已证明:

标准误差

(n 代表样本大小)

标准误差

标准误差越小,样本对总体的代表性就越强。此外,样本量越大,标准误差越小。

标准误差在统计推断的其他度量中非常重要,例如误差范围和置信区间。

置信水平

它是衡量通过样本统计量估计总体参数时,你希望有多大的置信度(概率),从而使得期望值落在一个期望的区间或置信区间内。它是通过从 1 中减去显著性水平(α)来计算的(即,置信水平 = 1 - α)。所以,如果α = 0.05,则置信水平为1-0.05 = 0.95

通常,置信水平越高,所需的样本量也越大。然而,通常存在一定的权衡,您需要决定希望达到的置信度,从而估算所需的样本量。

误差范围和置信区间

如前所述,由于样本永远不能完全代表总体,通过推断估算总体参数时,总会存在一些抽样误差,导致误差范围。通常,样本越大,误差范围越小。然而,您需要决定接受多少误差,所需的适当样本大小也会取决于这一点。

因此,基于误差范围的样本统计量上下的数值范围称为置信区间。换句话说,置信区间是一个数值区间,我们相信总体的真实参数会有某个百分比的时间落在这个区间内(置信水平)。

请注意,像“我有 95%的把握置信区间包含真实值”这样的说法可能会误导!正确的表述方式应该是“如果我从相同大小的样本中抽取无限多次样本,那么在 95%的情况下,置信区间将包含真实值”。

例如,当你将置信水平设为 95%,并且将置信区间设为 4%,对于一个样本统计量 58(这里,58 是任何样本统计量,如均值、方差或标准差),你可以说你有 95%的把握认为总体的真实百分比在 58 - 4 = 54%和 58 + 4 = 62%之间。

总体的变异性

总体的变异性是我们在推断统计中应该考虑的最重要因素之一。它在估算样本大小时起着重要作用。无论你选择什么抽样算法来最好地代表总体,样本大小仍然起着至关重要的作用——这一点显而易见!

如果总体的变异性较大,那么所需的样本大小也会更大。

估算样本大小

我们已经在前面的章节中讨论了抽样技术。在本节中,我们将讨论如何估算样本大小。假设你必须证明某个概念或评估某个行动的结果,那么你会收集一些相关数据并试图证明你的观点。然而,如何确保你拥有足够的数据呢?样本过大浪费时间和资源,样本过小可能导致误导性的结果。估算样本大小主要取决于误差范围或置信区间、置信水平以及总体的变异性等因素。

考虑以下示例:

大学校长要求统计学老师估算其学院学生的平均年龄。需要多大的样本?统计学老师希望能有 99%的置信度,使得估算值在 1 年以内是准确的。根据以往的研究,已知年龄的标准差为 3 年。

解决方案:

估算样本大小估算样本大小

假设检验

假设检验是关于测试为总体参数所做假设的过程。这有助于确定一个结果是否具有统计学意义,或是否是偶然发生的。这是统计研究中最重要的工具之一。我们将讨论一些检验方法,看看变量在总体中是如何相互关联的。

原假设与备择假设

原假设(记作 H0)通常是关于总体参数的初始假设,通常表示无效应无关联。在我们的假设检验中,我们的目的是否定并拒绝原假设,以便接受备择假设(记作 H1)。备择假设表示实验中存在某种效应。在实验过程中,请注意你要么拒绝原假设,要么无法拒绝原假设。如果你成功地拒绝了原假设,那么备择假设就应该被考虑;如果你未能拒绝原假设,那么原假设就被视为成立(尽管它可能不成立!)。

所以,我们通常希望得到一个非常小的 P 值(低于定义的显著性水平 alpha),以便拒绝原假设。如果 P 值大于 alpha,那么你就无法拒绝原假设。

卡方检验

大多数统计推断技术用于估计总体参数或使用样本统计量(如均值)来检验假设。然而,卡方统计量采取完全不同的方法,通过检查整个分布或两个分布之间的关系来进行分析。在推断统计学领域,许多检验统计量类似于卡方分布。使用这种分布的最常见检验是卡方拟合优度检验(单向表)和卡方独立性检验(双向表)。拟合优度检验是在你想要查看样本数据是否遵循总体中的相同分布时进行的,而独立性检验是在你想要查看两个分类变量是否在总体中相互关联时进行的。

输入数据类型决定了是否进行拟合优度检验或独立性检验,而不需要明确指定这些作为开关。因此,如果你提供一个向量作为输入,则进行拟合优度检验;如果你提供一个矩阵作为输入,则进行独立性检验。在这两种情况下,都需要先提供事件的频率向量或列联矩阵作为输入,然后进行计算。让我们通过实例来探讨这些:

Python

 //Chi-Square test
>>> from pyspark.mllib.linalg import Vectors, Matrices
>>> from pyspark.mllib.stat import Statistics
>>> import random
>>> 
//Make a vector of frequencies of events
>>> vec = Vectors.dense( random.sample(xrange(1,101),10))
>>> vec
DenseVector([45.0, 40.0, 93.0, 66.0, 56.0, 82.0, 36.0, 30.0, 85.0, 15.0])
// Get Goodnesss of fit test results
>>> GFT_Result = Statistics.chiSqTest(vec)
// Here the ‘goodness of fit test’ is conducted because your input is a vector
//Make a contingency matrix
>>> mat = Matrices.dense(5,6,random.sample(xrange(1,101),30))\
//Get independense test results\\
>>> IT_Result = Statistics.chiSqTest(mat)
// Here the ‘independence test’ is conducted because your input is a vector
//Examine the independence test results
>>> print(IT_Result)
Chi squared test summary:
method: pearson
degrees of freedom = 20
statistic = 285.9423808343265
pValue = 0.0
Very strong presumption against null hypothesis: the occurrence of the outcomes is statistically independent..

Scala

scala> import org.apache.spark.mllib.linalg.{Vectors, Matrices}
import org.apache.spark.mllib.linalg.{Vectors, Matrices} 

scala> import org.apache.spark.mllib.stat.Statistics 

scala> val vec = Vectors.dense( Array.fill(10)(               scala.util.Random.nextDouble))vec: org.apache.spark.mllib.linalg.Vector = [0.4925741159101148,....] 

scala> val GFT_Result = Statistics.chiSqTest(vec)GFT_Result: org.apache.spark.mllib.stat.test.ChiSqTestResult =Chi squared test summary:
method: pearson
degrees of freedom = 9
statistic = 1.9350768763253192
pValue = 0.9924531181394086
No presumption against null hypothesis: observed follows the same distribution as expected..
// Here the ‘goodness of fit test’ is conducted because your input is a vector
scala> val mat = Matrices.dense(5,6, Array.fill(30)(scala.util.Random.nextDouble)) // a contingency matrix
mat: org.apache.spark.mllib.linalg.Matrix =..... 
scala> val IT_Result = Statistics.chiSqTest(mat)
IT_Result: org.apache.spark.mllib.stat.test.ChiSqTestResult =Chi squared test summary:
method: pearson
degrees of freedom = 20
statistic = 2.5401190679900663
pValue = 0.9999990459111089
No presumption against null hypothesis: the occurrence of the outcomes is statistically independent..
// Here the ‘independence test’ is conducted because your input is a vector

F 检验

我们已经在前面的章节中介绍了如何计算 F 统计量。现在我们将解决一个样本问题。

问题:

你想要检验一个假设:硕士学位持有者的收入波动性是否大于学士学位持有者的收入波动性。随机抽取了 21 名毕业生和 30 名硕士生。毕业生样本的标准差为$180,硕士生样本的标准差为$112。

解决方案:

零假设是:H[0] : σ[1]² =σ[2]²

给定 S[1] = $180n[1] = 21S[2] = $112n[2] = 30

假设显著性水平为α = 0.05

F = S[1]² /S[2]² = 180²/112² = 2.58

从显著性水平为 0.05 的 F 表中,df1=20 和 df2=29,我们可以看到 F 值为 1.94

由于计算出的 F 值大于表中 F 值,我们可以拒绝零假设并得出结论:σ[1]² >σ[2] ^(2)。

相关性

相关性提供了一种衡量两个数值型随机变量之间统计依赖关系的方法。它展示了两个变量相互变化的程度。基本上有两种类型的相关性度量:皮尔逊相关性和斯皮尔曼相关性。皮尔逊相关性更适合于区间尺度数据,如温度、身高等;斯皮尔曼相关性则更适合于有序尺度数据,如满意度调查,其中 1 表示不满意,5 表示最满意。此外,皮尔逊相关性是基于真实值计算的,适用于找到线性关系,而斯皮尔曼相关性则是基于排名顺序计算的,适用于找到单调关系。单调关系意味着变量确实会一起变化,但变化速率不一定恒定。请注意,这两种相关性度量只能衡量线性或单调关系,不能描绘其他类型的关系,如非线性关系。

在 Spark 中,这两者都得到了支持。如果输入是两个 RDD[Double],输出是一个Double;如果输入是一个 RDD[Vector],输出是一个相关性矩阵。在 Scala 和 Python 的实现中,如果没有提供相关性类型作为输入,则默认考虑为皮尔逊相关性。

Python

>>> from pyspark.mllib.stat import Statistics
>>> import random 
// Define two series
//Number of partitions and cardinality of both Ser_1 and Ser_2 should be the same
>>> Ser_1 = sc.parallelize(random.sample(xrange(1,101),10))       
// Define Series_1>>> Ser_2 = sc.parallelize(random.sample(xrange(1,101),10))       
// Define Series_2 
>>> correlation = Statistics.corr(Ser_1, Ser_2, method = "pearson") 
//if you are interested in Spearman method, use “spearman” switch instead
>>> round(correlation,2)-0.14
>>> correlation = Statistics.corr(Ser_1, Ser_2, method ="spearman")
>>> round(correlation,2)-0.19//Check on matrix//The following statement creates 100 rows of 5 elements each
>>> data = sc.parallelize([random.sample(xrange(1,51),5) for x in range(100)])
>>> correlMatrix = Statistics.corr(data, method = "pearson") 
//method may be spearman as per you requirement
>>> correlMatrix
array([[ 1.        ,  0.09889342, -0.14634881,  0.00178334,  0.08389984],       [ 0.09889342,  1.        , -0.07068631, -0.02212963, -0.1058252 ],       [-0.14634881, -0.07068631,  1.        , -0.22425991,  0.11063062],       [ 0.00178334, -0.02212963, -0.22425991,  1.        , -0.04864668],       [ 0.08389984, -0.1058252 ,  0.11063062, -0.04864668,  1.        
]])
>>> 

Scala

scala> val correlation = Statistics.corr(Ser_1, Ser_2, "pearson")correlation: Double = 0.43217145308272087 
//if you are interested in Spearman method, use “spearman” switch instead
scala> val correlation = Statistics.corr(Ser_1, Ser_2, "spearman")correlation: Double = 0.4181818181818179 
scala>
//Check on matrix
//The following statement creates 100 rows of 5 element Vectors
scala> val data = sc.parallelize(Seq.fill(100)(Vectors.dense(Array.fill(5)(              scala.util.Random.nextDouble))))
data: org.apache.spark.rdd.RDD[org.apache.spark.mllib.linalg.Vector] = ParallelCollectionRDD[37] at parallelize at <console>:27 
scala> val correlMatrix = Statistics.corr(data, method="pearson") 
//method may be spearman as per you requirement
correlMatrix: org.apache.spark.mllib.linalg.Matrix =1.0                    -0.05478051936343809  ... (5 total)-0.05478051936343809   1.0                   ..........

总结

在本章中,我们简要介绍了数据科学生命周期中的几个步骤,如数据获取、数据准备和通过描述性统计进行数据探索。我们还学习了如何通过一些流行的工具和技术,利用样本统计量估计总体参数。

我们从理论和实践两个方面解释了统计学的基础,通过深入研究几个领域的基本概念,能够解决业务问题。最后,我们学习了如何在 Apache Spark 上进行统计分析的几个示例,利用开箱即用的功能,这也是本章的主要目标。

我们将在下一章讨论数据科学中机器学习部分的更多细节,因为本章已经建立了统计学的基础。通过本章的学习,应该能够更有根据地连接到机器学习算法。

参考文献

Spark 支持的统计:

spark.apache.org/docs/latest/mllib-statistics.html

Databricks 的绘图功能:

docs.cloud.databricks.com/docs/latest/databricks_guide/04%20Visualizations/4%20Matplotlib%20and%20GGPlot.html

MLLIB 统计的开箱即用库函数的详细信息:

spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.mllib.stat.Statistics$

第六章 机器学习

我们每天都在使用机器学习,无论我们是否注意到。像 Google 这样的电子邮件服务提供商会自动将一些来信推送到垃圾邮件文件夹,像 Amazon 这样的在线购物网站或 Facebook 这样的社交网络网站会推荐一些意外有用的商品或信息。那么,是什么让这些软件产品能够重新连接久未联系的朋友呢?这些只是机器学习应用的几个例子。

从形式上来说,机器学习是人工智能AI)的一部分,它涉及一类能够从数据中学习并进行预测的算法。其技术和基本概念来自统计学领域。机器学习位于计算机科学和统计学的交叉点,被认为是数据科学最重要的组成部分之一。虽然它已经存在一段时间,但随着数据量和可扩展性要求的增加,其复杂性也不断提升。机器学习算法往往是资源密集型和迭代的,这使得它们不太适合 MapReduce 范式。MapReduce 非常适合单次运行的算法,但对于多次运行的算法并不太适用。正是为了应对这一挑战,Spark 研究项目应运而生。Apache Spark 在其 MLlib 库中配备了高效的算法,旨在即使在迭代计算要求下也能表现良好。

上一章概述了数据分析的生命周期及其各种组成部分,如数据清洗、数据转换、采样技术和用于可视化数据的图形技术,以及描述性统计和推断统计的概念。我们还看了一些可以在 Spark 平台上执行的统计测试。在上一章所建立的基础上,本章将介绍大多数机器学习算法及如何在 Spark 上使用它们构建模型。

本章的前提是对机器学习算法和计算机科学基础知识有一定的了解。尽管如此,我们已经通过一些理论基础和适当的实际案例讲解了这些算法,使其更加易于理解和实现。本章涵盖的主题包括:

  • 机器学习简介

    • 演化

    • 有监督学习

    • 无监督学习

  • MLlib 和管道 API

    • MLlib

    • 机器学习管道

  • 机器学习简介

    • 参数化方法

    • 非参数化方法

  • 回归方法

    • 线性回归

    • 回归的正则化

  • 分类方法

    • 逻辑回归

    • 线性支持向量机(SVM)

  • 决策树

    • 不纯度度量

    • 停止规则

    • 分割候选

    • 决策树的优点

    • 示例

  • 集成方法

    • 随机森林

    • 梯度提升树

  • 多层感知机分类器

  • 聚类技术

    • K 均值聚类
  • 总结

介绍

机器学习完全是通过示例数据来学习;这些示例为特定输入产生特定输出。机器学习在商业中有多种应用案例。让我们通过几个例子来了解它究竟是什么:

  • 一个推荐引擎,向用户推荐他们可能感兴趣的购买项目

  • 客户细分(将具有相似特征的客户分组)用于市场营销活动

  • 癌症的疾病分类——恶性/良性

  • 预测建模,例如,销售预测,天气预测

  • 绘制商业推断,例如,理解改变产品价格对销售的影响

演变

统计学习的概念在第一个计算机系统被引入之前就已存在。在十九世纪,最小二乘法(现在称为线性回归)已经被开发出来。对于分类问题,费舍尔提出了线性判别分析LDA)。大约在 1940 年代,一种 LDA 的替代方法——逻辑回归被提出,所有这些方法不仅随着时间的推移得到了改进,还激发了其他新算法的发展。

在那个时期,计算是一个大问题,因为它是通过笔和纸来完成的。因此,拟合非线性方程并不十分可行,因为它需要大量的计算。1980 年代以后,随着技术的进步和计算机系统的引入,分类/回归树被提出。随着技术和计算系统的进一步发展,统计学习在某种程度上与现在所称的机器学习融合在一起。

监督学习

正如上一节所讨论的,机器学习完全是通过示例数据来学习。根据算法如何理解数据并在其上进行训练,机器学习大致可以分为两类:监督学习无监督学习

监督统计学习涉及构建基于一个或多个输入的模型,以产生特定的输出。这意味着我们得到的输出可以根据我们提供的输入来监督我们的分析。换句话说,对于每个预测变量的观察(例如,年龄、教育和支出变量),都有一个与之相关的响应变量的测量(例如,薪水)。请参考下表了解我们尝试基于年龄教育支出变量预测薪水的示例数据集:

监督学习

监督算法可以用于预测、估算、分类以及其他类似的需求,我们将在接下来的部分中进行介绍。

无监督学习

无监督统计学习是通过一个或多个输入构建模型,但没有预期产生特定的输出。这意味着没有明确需要预测的响应/输出变量;但输出通常是具有一些相似特征的数据点分组。与监督学习不同,你并不知道将数据点分类到哪些组/标签,而是将这一决策交给算法自己去决定。

在这里,并没有一个 训练 数据集来通过构建模型将结果变量与 预测 变量关联起来,并随后使用 测试 数据集验证模型。无监督算法的输出无法根据你提供的输入来监督你的分析。这类算法可以从数据中学习关系和结构。聚类关联规则学习 是无监督学习技术的例子。

以下图片展示了如何使用聚类将具有相似特征的数据项分组:

无监督学习

MLlib 和 Pipeline API

让我们首先了解一些 Spark 的基础知识,以便能够在其上执行机器学习操作。本节将讨论 MLlib 和 Pipeline API。

MLlib

MLlib 是建立在 Apache Spark 之上的机器学习库,包含了大部分可以大规模实现的算法。MLlib 与 GraphX、SQL 和 Streaming 等其他组件的无缝集成为开发者提供了相对容易组装复杂、可扩展和高效工作流的机会。MLlib 库包含常用的学习算法和工具,包括分类、回归、聚类、协同过滤和降维等。

MLlib 与 spark.ml 包协同工作,后者提供了一个高级的 Pipeline API。这两个包之间的根本区别在于,MLlib(spark.mllib)在 RDD 之上工作,而 ML(spark.ml)包在 DataFrame 之上工作,并支持 ML Pipeline。目前,Spark 支持这两个包,但建议使用 spark.ml 包。

该库中的基本数据类型是向量和矩阵。向量是局部的,可以是密集的或稀疏的。密集向量以值数组的形式存储。稀疏向量则存储为两个数组;第一个数组存储非零值的索引,第二个数组存储实际的值。所有元素值都以双精度浮点数形式存储,索引以从零开始的整数形式存储。理解这些基本结构有助于高效使用库,并帮助从零开始编写任何新的算法。让我们看一些示例代码,帮助更好地理解这两种向量表示方式:

Scala

//Create vectors
scala> import org.apache.spark.ml.linalg.{Vector, Vectors}
import org.apache.spark.ml.linalg.{Vector, Vectors}

//Create dense vector
scala> val dense_v: Vector = Vectors.dense(10.0,0.0,20.0,30.0,0.0)
dense_v: org.apache.spark.ml.linalg.Vector = [10.0,0.0,20.0,30.0,0.0]
scala>

//Create sparse vector: pass size, position index array and value array
scala> val sparse_v1: Vector = Vectors.sparse(5,Array(0,2,3),
       Array(10.0,20.0,30.0))
sparse_v1: org.apache.spark.ml.linalg.Vector = (5,[0,2,3],[10.0,20.0,30.0])
scala>

//Another way to create sparse vector with position, value tuples
scala> val sparse_v2: Vector = Vectors.sparse(5,
        Seq((0,10.0),(2,20.0),(3,30.0)))
sparse_v2: org.apache.spark.ml.linalg.Vector = (5,[0,2,3],[10.0,20.0,30.0])
scala>  
 Compare vectors 
--------------- cala> sparse_v1 == sparse_v2
res0: Boolean = true
scala> sparse_v1 == dense_v
res1: Boolean = true      //All three objects are equal but...
scala> dense_v.toString()
res2: String = [10.0,0.0,20.0,30.0,0.0]
scala> sparse_v2.toString()
res3: String = (5,[0,2,3],[10.0,20.0,30.0]) //..internal representation
differs
scala> sparse_v2.toArray
res4: Array[Double] = Array(10.0, 0.0, 20.0, 30.0, 0.0)

Interchangeable ---------------
scala> dense_v.toSparse
res5: org.apache.spark.mllib.linalg.SparseVector = (5,[0,2,3]
[10.0,20.0,30.0])
scala> sparse_v1.toDense
res6: org.apache.spark.mllib.linalg.DenseVector = [10.0,0.0,20.0,30.0,0.0]
scala>

A common operation ------------------
scala> Vectors.sqdist(sparse_v1,
        Vectors.dense(1.0,2.0,3.0,4.0,5.0))
res7: Double = 1075.0

Python:

//Create vectors
>>> from pyspark.ml.linalg import Vector, Vectors
//Create vectors
>>> dense_v = Vectors.dense(10.0,0.0,20.0,30.0,0.0)
//Pass size, position index array and value array
>>> sparse_v1 = Vectors.sparse(5,[0,2,3],
                    [10.0,20.0,30.0])
>>> 

//Another way to create sparse vector with position, value tuples
>>> sparse_v2 = Vectors.sparse(5,
                  [[0,10.0],[2,20.0],[3,30.0]])
>>> 

Compare vectors 
--------------- >>> sparse_v1 == sparse_v2
True
>>> sparse_v1 == dense_v
True      //All three objects are equal but...
>>> dense_v
DenseVector([10.0, 0.0, 20.0, 30.0, 0.0])
>>> sparse_v1
SparseVector(5, {0: 10.0, 2: 20.0, 3: 30.0}) //..internal representation
differs
>>> sparse_v2
SparseVector(5, {0: 10.0, 2: 20.0, 3: 30.0})

Interchangeable 
---------------- //Note: as of Spark 2.0.0, toDense and toSparse are not available in pyspark
 A common operation 
------------------- >>> Vectors.squared_distance(sparse_v1,
        Vectors.dense(1.0,2.0,3.0,4.0,5.0))
1075.0

矩阵可以是局部的或分布式的,可以是稠密的或稀疏的。局部矩阵存储在单台机器上,作为一维数组。稠密的局部矩阵按照列主序存储(列成员是连续的),而稀疏矩阵的值则以压缩稀疏列(CSC格式以列主序存储。在这种格式中,矩阵以三个数组的形式存储。第一个数组包含非零值的行索引,第二个数组包含每列第一个非零值的起始位置索引,第三个数组包含所有非零值。索引的类型是整数,从零开始。第一个数组包含从零到行数减一的值。第三个数组的元素类型为双精度。第二个数组需要一些解释。该数组中的每一项对应每一列第一个非零元素的索引。例如,假设在一个 3×3 的矩阵中,每列只有一个非零元素。那么第二个数组将包含 0、1、2 作为其元素。第一个数组包含行位置,第三个数组包含三个值。如果某一列中没有非零元素,你会注意到第二个数组中的相同索引会重复。让我们看一些示例代码:

Scala:

scala> import org.apache.spark.ml.linalg.{Matrix,Matrices}
import org.apache.spark.ml.linalg.{Matrix, Matrices}

Create dense matrix 
------------------- //Values in column major order
Matrices.dense(3,2,Array(9.0,0,0,0,8.0,6))
res38: org.apache.spark.mllib.linalg.Matrix =
9.0  0.0
0.0  8.0
0.0  6.0
 Create sparse matrix 
-------------------- //1.0 0.0 4.0
0.0 3.0 5.0
2.0 0.0 6.0//
val sm: Matrix = Matrices.sparse(3,3,
        Array(0,2,3,6), Array(0,2,1,0,1,2),
        Array(1.0,2.0,3.0,4.0,5.0,6.0))
sm: org.apache.spark.mllib.linalg.Matrix =
3 x 3 CSCMatrix
(0,0) 1.0
(2,0) 2.0
(1,1) 3.0
(0,2) 4.0
(1,2) 5.0
(2,2) 6.0
 Sparse matrix, a column of all zeros 
------------------------------------ //third column all zeros
Matrices.sparse(3,4,Array(0,2,3,3,6),
    Array(0,2,1,0,1,2),values).toArray
res85: Array[Double] = Array(1.0, 0.0, 2.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0,
4.0, 5.0, 6.0)

Python:

//Create dense matrix
>>> from pyspark.ml.linalg import Matrix, Matrices

//Values in column major order
>>> Matrices.dense(3,2,[9.0,0,0,0,8.0,6])
DenseMatrix(3, 2, [9.0, 0.0, 0.0, 0.0, 8.0, 6.0], False)
>>> 

//Create sparse matrix
//1.0 0.0 4.0
0.0 3.0 5.0
2.0 0.0 6.0//
>>> sm = Matrices.sparse(3,3,
        [0,2,3,6], [0,2,1,0,1,2],
        [1.0,2.0,3.0,4.0,5.0,6.0])
>>> 

//Sparse matrix, a column of all zeros
//third column all zeros
>>> Matrices.sparse(3,4,[0,2,3,3,6],
        [0,2,1,0,1,2],
    values=[1.0,2.0,3.0,4.0,5.0,6.0]).toArray()
array([[ 1.,  0.,  0.,  4.],
       [ 0.,  3.,  0.,  5.],
       [ 2.,  0.,  0.,  6.]])
>>> 

分布式矩阵是最复杂的矩阵,选择合适的分布式矩阵类型非常重要。分布式矩阵由一个或多个 RDDs 支持。行和列的索引是long类型,以支持非常大的矩阵。分布式矩阵的基本类型是RowMatrix,它仅由其行的 RDD 支持。

每一行依次是一个局部向量。当列数非常低时,这种方式很适用。记住,我们需要传递 RDDs 来创建分布式矩阵,而不像局部矩阵那样。让我们来看一个例子:

Scala:

scala> import org.apache.spark.mllib.linalg.{Vector,Vectors}
import org.apache.spark.mllib.linalg.{Vector, Vectors}
scala> import org.apache.spark.mllib.linalg.distributed.RowMatrix
import org.apache.spark.mllib.linalg.distributed.RowMatrix

scala>val dense_vlist: Array[Vector] = Array(
    Vectors.dense(11.0,12,13,14),
    Vectors.dense(21.0,22,23,24),
    Vectors.dense(31.0,32,33,34))
dense_vlist: Array[org.apache.spark.mllib.linalg.Vector] =
Array([11.0,12.0,13.0,14.0], [21.0,22.0,23.0,24.0], [31.0,32.0,33.0,34.0])
scala>

//Distribute the vector list
scala> val rows  = sc.parallelize(dense_vlist)
rows: org.apache.spark.rdd.RDD[org.apache.spark.mllib.linalg.Vector] =
ParallelCollectionRDD[0] at parallelize at <console>:29
scala> val m: RowMatrix = new RowMatrix(rows)
m: org.apache.spark.mllib.linalg.distributed.RowMatrix =
org.apache.spark.mllib.linalg.distributed.RowMatrix@5c5043fe
scala> print("Matrix size is " + m.numRows()+"X"+m.numCols())
Matrix size is 3X4
scala>

Python:

>>> from pyspark.mllib.linalg import Vector,Vectors
>>> from pyspark.mllib.linalg.distributed import RowMatrix

>>> dense_vlist = [Vectors.dense(11.0,12,13,14),
         Vectors.dense(21.0,22,23,24), Vectors.dense(31.0,32,33,34)]
>>> rows  = sc.parallelize(dense_vlist)
>>> m = RowMatrix(rows)
>>> "Matrix size is {0} X {1}".format(m.numRows(), m.numCols())
'Matrix size is 3 X 4'

IndexedRowMatrix将行索引前缀添加到行条目。这在执行连接操作时很有用。你需要传递IndexedRow对象来创建一个IndexedRowMatrixIndexedRow对象是一个包含long类型Index和行元素的Vector的封装器。

CoordinatedMatrix将数据存储为行列索引和元素值的元组。BlockMatrix将分布式矩阵表示为局部矩阵的块。提供了将矩阵从一种类型转换为另一种类型的方法,但这些操作非常昂贵,使用时应谨慎。

ML 流水线

现实中的机器学习工作流程是一个迭代循环,包含数据提取、数据清洗、预处理、探索、特征提取、模型拟合和评估。Spark 上的 ML 流水线是一个简单的 API,供用户设置复杂的机器学习工作流程。它的设计旨在解决一些常见问题,如参数调整、基于不同数据划分(交叉验证)或不同参数集训练多个模型等。编写脚本来自动化整个过程不再是必需的,所有这些都可以在 Pipeline API 中处理。

Pipeline API 由一系列流水线阶段组成(作为 转换器估算器 等抽象的实现),这些阶段将按预定顺序执行。

在 ML 流水线中,您可以调用上一章讨论过的数据清洗/转换函数,并调用 MLlib 中可用的机器学习算法。这可以通过迭代的方式进行,直到您获得模型的理想性能。

ML pipeline

转换器

转换器是一个抽象,实现了 transform() 方法,将一个 DataFrame 转换成另一个 DataFrame。如果该方法是一个特征转换器,结果 DataFrame 可能包含一些基于您执行的操作的额外转换列。然而,如果该方法是一个学习模型,那么结果 DataFrame 将包含一个包含预测结果的额外列。

估算器

估算器是一个抽象,它可以是任何实现了 fit() 方法的学习算法,用来在 DataFrame 上训练以生成模型。从技术上讲,这个模型是给定 DataFrame 的转换器。

示例:逻辑回归是一种学习算法,因此是一个估算器。调用 fit() 会训练一个逻辑回归模型,生成的模型是一个转换器,可以生成一个包含预测列的 DataFrame。

以下示例演示了一个简单的单阶段流水线。

Scala:

//Pipeline example with single stage to illustrate syntax
scala> import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.feature._
import org.apache.spark.ml.feature._

//Create source data frame
scala> val df = spark.createDataFrame(Seq(
         ("Oliver Twist","Charles Dickens"),
        ("Adventures of Tom Sawyer","Mark Twain"))).toDF(
        "Title","Author")

//Split the Title to tokens
scala> val tok = new Tokenizer().setInputCol("Title").
          setOutputCol("words")
tok: org.apache.spark.ml.feature.Tokenizer = tok_2b2757a3aa5f

//Define a pipeline with a single stage
scala> val p = new Pipeline().setStages(Array(tok))
p: org.apache.spark.ml.Pipeline = pipeline_f5e0de400666

//Run an Estimator (fit) using the pipeline
scala> val model = p.fit(df)
model: org.apache.spark.ml.PipelineModel = pipeline_d00989625bb2

//Examine stages
scala> p.getStages   //Returns a list of stage objects
res1: Array[org.apache.spark.ml.PipelineStage] = Array(tok_55af0061af6d)

// Examine the results
scala> val m = model.transform(df).select("Title","words")
m: org.apache.spark.sql.DataFrame = [Title: string, words: array<string>]
scala> m.select("words").collect().foreach(println)
[WrappedArray(oliver, twist)]
[WrappedArray(adventures, of, tom, sawyer)]

Python:

//Pipeline example with single stage to illustrate syntax
//Create source data frame
>>> from pyspark.ml.pipeline import Pipeline
>>> from pyspark.ml.feature import Tokenizer
>>>  df = sqlContext.createDataFrame([
    ("Oliver Twist","Charles Dickens"),
    ("Adventures of Tom Sawyer","Mark Twain")]).toDF("Title","Author")
>>> 

//Split the Title to tokens
>>> tok = Tokenizer(inputCol="Title",outputCol="words")

//Define a pipeline with a single stage
>>> p = Pipeline(stages=[tok])

//Run an Estimator (fit) using the pipeline
>>> model = p.fit(df)

//Examine stages
>>> p.getStages()  //Returns a list of stage objects
[Tokenizer_4f35909c4c504637a263]

// Examine the results
>>> m = model.transform(df).select("Title","words")
>>> [x[0] for x in m.select("words").collect()]
[[u'oliver', u'twist'], [u'adventures', u'of', u'tom', u'sawyer']]
>>> 

上面的示例展示了流水线的创建和执行,尽管这里只有一个阶段,在此上下文中是一个 Tokenizer。Spark 提供了若干个“特征转换器”,这些特征转换器在数据清洗和数据准备阶段非常有用。

以下示例展示了将原始文本转换为特征向量的实际案例。如果您不熟悉 TF-IDF,可以阅读这个来自 www.tfidf.com 的简短教程。

Scala:

scala> import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.feature._
import org.apache.spark.ml.feature._
scala> 

//Create a dataframe
scala> val df2 = spark.createDataset(Array(
         (1,"Here is some text to illustrate pipeline"),
         (2, "and tfidf, which stands for term frequency inverse document
frequency"
         ))).toDF("LineNo","Text")

//Define feature transformations, which are the pipeline stages
// Tokenizer splits text into tokens
scala> val tok = new Tokenizer().setInputCol("Text").
             setOutputCol("Words")
tok: org.apache.spark.ml.feature.Tokenizer = tok_399dbfe012f8

// HashingTF maps a sequence of words to their term frequencies using hashing
// Larger value of numFeatures reduces hashing collision possibility
scala> val tf = new HashingTF().setInputCol("Words").setOutputCol("tf").setNumFeatures(100)
tf: org.apache.spark.ml.feature.HashingTF = hashingTF_e6ad936536ea
// IDF, Inverse Docuemnt Frequency is a statistical weight that reduces weightage of commonly occuring words
scala> val idf = new IDF().setInputCol("tf").setOutputCol("tf_idf")
idf: org.apache.spark.ml.feature.IDF = idf_8af1fecad60a
// VectorAssembler merges multiple columns into a single vector column
scala> val va = new VectorAssembler().setInputCols(Array("tf_idf")).setOutputCol("features")
va: org.apache.spark.ml.feature.VectorAssembler = vecAssembler_23205c3f92c8
//Define pipeline
scala> val tfidf_pipeline = new Pipeline().setStages(Array(tok,tf,idf,va))
val tfidf_pipeline = new Pipeline().setStages(Array(tok,tf,idf,va))
scala> tfidf_pipeline.getStages
res2: Array[org.apache.spark.ml.PipelineStage] = Array(tok_399dbfe012f8, hashingTF_e6ad936536ea, idf_8af1fecad60a, vecAssembler_23205c3f92c8)
scala>

//Now execute the pipeline
scala> val result = tfidf_pipeline.fit(df2).transform(df2).select("words","features").first()
result: org.apache.spark.sql.Row = [WrappedArray(here, is, some, text, to, illustrate, pipeline),(100,[0,3,35,37,69,81],[0.4054651081081644,0.4054651081081644,0.4054651081081644,0.4054651081081644,0.4054651081081644,0.4054651081081644])]

Python:

//A realistic, multi-step pipeline that converts text to TF_ID
>>> from pyspark.ml.pipeline import Pipeline
>>> from pyspark.ml.feature import Tokenizer, HashingTF, IDF, VectorAssembler, \
               StringIndexer, VectorIndexer

//Create a dataframe
>>> df2 = sqlContext.createDataFrame([
    [1,"Here is some text to illustrate pipeline"],
    [2,"and tfidf, which stands for term frequency inverse document
frequency"
    ]]).toDF("LineNo","Text")

//Define feature transformations, which are the pipeline stages
//Tokenizer splits text into tokens
>>> tok = Tokenizer(inputCol="Text",outputCol="words")

// HashingTF maps a sequence of words to their term frequencies using
hashing

// Larger the numFeatures, lower the hashing collision possibility
>>> tf = HashingTF(inputCol="words", outputCol="tf",numFeatures=1000)

// IDF, Inverse Docuemnt Frequency is a statistical weight that reduces
weightage of commonly occuring words
>>> idf = IDF(inputCol = "tf",outputCol="tf_idf")

// VectorAssembler merges multiple columns into a single vector column
>>> va = VectorAssembler(inputCols=["tf_idf"],outputCol="features")

//Define pipeline
>>> tfidf_pipeline = Pipeline(stages=[tok,tf,idf,va])
>>> tfidf_pipeline.getStages()
[Tokenizer_4f5fbfb6c2a9cf5725d6, HashingTF_4088a47d38e72b70464f, IDF_41ddb3891541821c6613, VectorAssembler_49ae83b800679ac2fa0e]
>>>

//Now execute the pipeline
>>> result = tfidf_pipeline.fit(df2).transform(df2).select("words","features").collect()
>>> [(x[0],x[1]) for x in result]
[([u'here', u'is', u'some', u'text', u'to', u'illustrate', u'pipeline'], SparseVector(1000, {135: 0.4055, 169: 0.4055, 281: 0.4055, 388: 0.4055, 400: 0.4055, 603: 0.4055, 937: 0.4055})), ([u'and', u'tfidf,', u'which', u'stands', u'for', u'term', u'frequency', u'inverse', u'document', u'frequency'], SparseVector(1000, {36: 0.4055, 188: 0.4055, 333: 0.4055, 378: 0.4055, 538: 0.4055, 597: 0.4055, 727: 0.4055, 820: 0.4055, 960: 0.8109}))]
>>> 

本示例创建并执行了一个多阶段流水线,将文本转换为可以被机器学习算法处理的特征向量。在继续之前,我们再看几个其他特性。

Scala:

scala> import org.apache.spark.ml.feature._
import org.apache.spark.ml.feature._
scala>

//Basic examples illustrating features usage
//Look at model examples for more feature examples
//Binarizer converts continuous value variable to two discrete values based on given threshold
scala> import scala.util.Random
import scala.util.Random
scala> val nums = Seq.fill(10)(Random.nextDouble*100)
...
scala> val numdf = spark.createDataFrame(nums.map(Tuple1.apply)).toDF("raw_nums")
numdf: org.apache.spark.sql.DataFrame = [raw_nums: double]
scala> val binarizer = new Binarizer().setInputCol("raw_nums").
            setOutputCol("binary_vals").setThreshold(50.0)
binarizer: org.apache.spark.ml.feature.Binarizer = binarizer_538e392f56db
scala> binarizer.transform(numdf).select("raw_nums","binary_vals").show(2)
+------------------+-----------+
|          raw_nums|binary_vals|
+------------------+-----------+
|55.209245003482884|        1.0|
| 33.46202184060426|        0.0|
+------------------+-----------+
scala>

//Bucketizer to convert continuous value variables to desired set of discrete values
scala> val split_vals:Array[Double] = Array(0,20,50,80,100) //define intervals
split_vals: Array[Double] = Array(0.0, 20.0, 50.0, 80.0, 100.0)
scala> val b = new Bucketizer().
           setInputCol("raw_nums").
           setOutputCol("binned_nums").
           setSplits(split_vals)
b: org.apache.spark.ml.feature.Bucketizer = bucketizer_a4dd599e5977
scala> b.transform(numdf).select("raw_nums","binned_nums").show(2)
+------------------+-----------+
|          raw_nums|binned_nums|
+------------------+-----------+
|55.209245003482884|        2.0|
| 33.46202184060426|        1.0|
+------------------+-----------+
scala>

//Bucketizer is effectively equal to binarizer if only two intervals are
given 
scala> new Bucketizer().setInputCol("raw_nums").
        setOutputCol("binned_nums").setSplits(Array(0,50.0,100.0)).
        transform(numdf).select("raw_nums","binned_nums").show(2)
+------------------+-----------+
|          raw_nums|binned_nums|
+------------------+-----------+
|55.209245003482884|        1.0|
| 33.46202184060426|        0.0|
+------------------+-----------+
scala>

Python:

//Some more features
>>> from pyspark.ml import feature, pipeline
>>> 

//Basic examples illustrating features usage
//Look at model examples for more examples
//Binarizer converts continuous value variable to two discrete values based on given threshold
>>> import random
>>> nums = [random.random()*100 for x in range(1,11)]
>>> numdf = sqlContext.createDataFrame(
             [[x] for x in nums]).toDF("raw_nums")
>>> binarizer = feature.Binarizer(threshold= 50,
       inputCol="raw_nums", outputCol="binary_vals")
>>> binarizer.transform(numdf).select("raw_nums","binary_vals").show(2)
+------------------+-----------+
|          raw_nums|binary_vals|
+------------------+-----------+
| 95.41304359504672|        1.0|
|41.906045589243405|        0.0|
+------------------+-----------+
>>> 

//Bucketizer to convert continuous value variables to desired set of discrete values
>>> split_vals = [0,20,50,80,100] //define intervals
>>> b =
feature.Bucketizer(inputCol="raw_nums",outputCol="binned_nums",splits=split
vals)
>>> b.transform(numdf).select("raw_nums","binned_nums").show(2)
+------------------+-----------+
|          raw_nums|binned_nums|
+------------------+-----------+
| 95.41304359504672|        3.0|
|41.906045589243405|        1.0|
+------------------+-----------+

//Bucketizer is effectively equal to binarizer if only two intervals are
given 
>>> feature.Bucketizer(inputCol="raw_nums",outputCol="binned_nums",                  
                       splits=[0,50.0,100.0]).transform(numdf).select(
                       "raw_nums","binned_nums").show(2)
+------------------+-----------+
|          raw_nums|binned_nums|
+------------------+-----------+
| 95.41304359504672|        1.0|
|41.906045589243405|        0.0|
+------------------+-----------+
>>> 

机器学习简介

在本书的前几节中,我们学习了响应/结果变量如何与预测变量相关,通常是在监督学习的背景下。如今,人们通常用不同的名称来表示这两类变量。让我们看看它们的一些同义词,并在本书中交替使用:

  • 输入变量 (X): 特征,预测变量,解释变量,自变量

  • 输出变量 (Y): 响应变量,因变量

如果存在一个 YX 之间的关系,其中 X=X[1], X[2], X[3],..., X[n] (n 个不同的预测变量),那么可以写成如下形式:

机器学习简介

这里 机器学习简介 是一个函数,表示 X 如何描述 Y,并且是未知的!这就是我们利用手头的观测数据点来找出的内容。这个术语

机器学习简介

是一个均值为零并且与 X 独立的随机误差项。

基本上,这样的方程式会涉及两种类型的误差——可约误差和不可约误差。顾名思义,可约误差与函数相关,并且可以通过提高精度来最小化。

机器学习简介

通过使用更好的学习算法或调整相同的算法来提高。

机器学习简介

, 如果与 X 无关,仍然会存在一些无法解决的误差。这被称为不可约误差 (

机器学习简介

)。总是存在一些影响结果变量的因素,但在建立模型时未考虑这些因素(因为它们大多数时候是未知的),并且这些因素会贡献到不可约误差项。因此,本书中讨论的方法将专注于最小化可约误差。

我们构建的大多数机器学习模型可以用于预测、推断或两者的组合。对于某些算法,函数

机器学习简介

可以表示为一个方程,告诉我们因变量 Y 如何与自变量 (X1, X2,..., Xn) 相关联。在这种情况下,我们可以进行推断和预测。然而,某些算法是“黑盒”算法,我们只能进行预测,无法进行推断,因为 Y 如何与 X 相关是未知的。

请注意,线性机器学习模型可能更适用于推断场景,因为它们对于业务用户来说更具可解释性。然而,在预测场景中,可能会有更好的算法提供更准确的预测,但它们的可解释性较差。当推断是目标时,我们应优先选择具有更好可解释性的限制性模型,如线性回归;而当只有预测是目标时,我们可以选择使用高度灵活的模型,如支持向量机SVM),这些模型可解释性较差但准确性更高(然而,这在所有情况下并不成立)。您需要根据业务需求仔细选择算法,权衡可解释性和准确性之间的利弊。让我们更深入地理解这些概念背后的基本原理。

基本上,我们需要一组数据点(训练数据)来构建模型以估算

机器学习介绍

(X),从而Y =

机器学习介绍

(X)。一般来说,这些学习方法可以是参数化的,也可以是非参数化的。

参数方法

参数方法遵循一个两步过程。在第一步中,您假设X的形状

参数方法

()。例如,XY呈线性关系,因此X的函数是

参数方法

(X),可以用以下线性方程表示:

参数方法

选择模型后,第二步是使用手头的数据点来训练模型,估算参数β0β1、...、βn,从而:

参数方法

这种参数化方法的一个缺点是我们假设的线性关系对于参数方法()可能在实际生活中并不成立。

非参数方法

我们不对YX之间的线性关系以及变量的数据分布做任何假设,因此X的形式是

非参数方法

()在非参数方法中。由于它不假设任何形式的

非参数方法

(),它可以通过与数据点良好拟合来产生更好的结果,这可能是一个优势。

因此,非参数方法需要比参数方法更多的数据点来估算

非参数方法

()准确。不过请注意,如果没有得到妥善处理,它可能会导致过拟合问题。我们将在进一步讨论中详细探讨这个问题。

回归方法

回归方法是一种监督学习方法。如果响应变量是定量/连续的(如年龄、薪水、身高等数值),那么这个问题可以被称为回归问题,而不管解释变量的类型。针对回归问题,有多种建模技术。本节的重点将是线性回归技术及其一些不同的变种。

回归方法可以用来预测任何实际数值的结果。以下是一些例子:

  • 根据员工的教育水平、位置、工作类型等预测薪资

  • 预测股价

  • 预测客户的购买潜力

  • 预测机器故障前的运行时间

线性回归

在我们前一节参数方法讨论的基础上,在线性假设成立后,

线性回归

(X),我们需要训练数据来拟合一个模型,该模型描述解释变量(记作 X)和响应变量(记作 Y)之间的关系。当只有一个解释变量时,称为简单线性回归;当有多个解释变量时,称为多元线性回归。简单线性回归是将一条直线拟合到二维空间中,当有两个预测变量时,它将拟合一个三维空间中的平面,依此类推,在维度更高的设置中,当变量超过两个时,也是如此。

线性回归方程的常见形式可以表示为:

Y' =

线性回归

(X) +

线性回归

这里 Y' 代表预测的结果变量。

只有一个预测变量的线性回归方程可以表示为:

线性回归

具有多个预测变量的线性回归方程可以表示为:

线性回归

这里 线性回归 是与 X 无关的无法简化的误差项,且其均值为零。我们无法控制它,但可以朝着优化的方向努力。

线性回归

(X)。由于没有任何模型能够达到 100%的准确率,因此总会有一些与之相关的误差,这些误差源自无法简化的误差成分(

线性回归

).

最常见的线性回归拟合方法叫做最小二乘法,也称为普通最小二乘法OLS)方法。该方法通过最小化每个数据点到回归线的垂直偏差的平方和,找到最适合观察数据点的回归线。为了更好地理解线性回归的工作原理,让我们现在看一个简单线性回归的例子:

线性回归

其中,β0 是回归线的 Y 截距,β1 定义了回归线的斜率。意思是,β1X 变化一个单位时 Y 的平均变化。我们以 XY 为例:

X Y
1 12
2 20
3 13
4 38
5 27

如果我们通过数据点拟合一条线性回归线,如上表所示,那么它将呈现如下:

线性回归

在上图中,红色的垂直线表示预测误差,可以定义为实际的 Y 值和预测的 Y' 值之间的差异。如果你将这些差异平方并求和,就得到了 平方误差和 (SSE),这是用来找到最优拟合线的最常见度量。下表显示了如何计算 SSE:

X Y Y' Y-Y' (Y-Y') ²
1 12 12.4 0.4 0.16
2 20 17.2 2.8 7.84
3 13 22 -9 81
4 38 26.8 11.2 125.44
5 27 31.6 -4.6 21.16
总和 235.6

在上述表格中,(Y-Y') 被称为残差。残差平方和 (RSS) 可以表示为:

RSS = residual[1]² + residual[2]² + residual[3]² + ......+ residual[n]²

请注意,回归对异常值非常敏感,如果不在应用回归之前处理异常值,可能会引入巨大的 RSS 误差。

在回归线拟合到观察数据点之后,你应该通过将残差绘制在 Y 轴上,对应于 X 轴上的解释变量来检查残差。如果图像接近直线,那么你关于线性关系的假设是有效的,否则可能表明存在某种非线性关系。如果存在非线性关系,你可能需要考虑非线性。一个技术是通过向方程中加入高阶多项式来解决。

我们看到,RSS 是拟合回归线时的重要特征(在建立模型时)。现在,为了评估回归拟合的好坏(在模型建立后),你需要另外两个统计量——残差标准误差 (RSE) 和 统计量。

我们讨论了不可减少的误差成分 ε,由于这个原因,即使你的方程完全拟合数据点并且正确估计了系数,也总会存在某种程度的误差。RSE 是 ε 标准差的估计,可以定义如下:

线性回归

这意味着实际值会平均偏离真实回归线一个 RSE 因子。

由于 RSE 实际上是以 Y 的单位来衡量的(参考我们在上一节如何计算 RSS),所以很难说它是模型精度的唯一最佳统计量。

因此,引入了一种替代方法,称为 R²统计量(也叫做决定系数)。计算 R²的公式如下:

线性回归

总平方和 (TSS) 可以通过以下方式计算:

线性回归

这里需要注意的是,TSS 衡量的是Y中固有的总变异性,即使在进行回归预测Y之前也包含在内。可以观察到其中没有Y'。相反,RSS 表示回归后Y中未解释的变异性。这意味着(TSS - RSS)能够解释回归后响应变量中的变异性。

统计量通常介于 0 到 1 之间,但如果拟合结果比拟合一个水平线还要差,它可能为负值,但这种情况很少发生。接近 1 的值表示回归方程可以解释响应变量中大部分的变异性,并且拟合效果良好。相反,接近 0 的值表示回归方程几乎没有解释响应变量中的变异性,拟合效果不好。例如, 为 0.25 意味着Y的 25%变异性由X解释,表示需要调优模型以改善效果。

现在让我们讨论如何通过回归来处理数据集中的非线性。正如前面所讨论的,当你发现非线性关系时,需要适当处理。为了使用相同的线性回归方法建模非线性方程,必须创建高阶特征,回归方法会将其视为另一个变量。例如,如果薪水是一个预测购买潜力的特征/变量,而我们发现它们之间存在非线性关系,那么我们可能会创建一个特征叫做(薪水³),具体取决于需要解决多少非线性问题。请注意,在创建这种高阶特征时,你还需要保留基础特征。在这个例子中,你必须在回归方程中同时使用(薪水)和(薪水³)。

到目前为止,我们假设所有预测变量都是连续的。如果存在类别型预测变量怎么办?在这种情况下,我们必须将这些变量进行虚拟编码(例如,将男性编码为 1,女性编码为 0),这样回归方法就会生成两个方程,一个用于性别=男性(该方程包含性别变量),另一个用于性别=女性(该方程不包含性别变量,因为它会被作为 0 值丢弃)。有时,当类别变量较少时,可以考虑根据类别变量的水平将数据集划分,并为每个部分构建单独的模型。

最小二乘线性回归的一个主要优点是它能解释结果变量如何与预测变量相关。这使得它非常易于解释,并且可以用来做推断和预测。

损失函数

许多机器学习问题可以被表述为凸优化问题。这个问题的目标是找到使平方损失最小的系数值。这个目标函数基本上包含两个部分——正则化项和损失函数。正则化项用于控制模型的复杂度(防止过拟合),而损失函数用于估计回归函数的系数,使得平方损失(RSS)最小。

用于最小二乘法的损失函数称为平方损失,如下所示:

损失函数

这里的Y是响应变量(实值),W是权重向量(系数值),X是特征向量。所以

损失函数

给出预测值,我们将其与实际值Y进行比较,以求得需要最小化的平方损失。

用于估计系数的算法称为梯度下降。不同类型的损失函数和优化算法适用于不同种类的机器学习算法,我们将在需要时介绍。

优化

最终,线性方法必须优化损失函数。在底层,线性方法使用凸优化方法来优化目标函数。MLlib 支持随机梯度下降SGD)和有限内存 - Broyden-Fletcher-Goldfarb-ShannoL-BFGS)算法。目前,大多数算法 API 支持 SGD,少数支持 L-BFGS。

SGD 是一种一阶优化技术,最适合大规模数据和分布式计算环境。目标函数(损失函数)可以写作求和形式的优化问题最适合使用 SGD 来解决。

L-BFGS 是一种优化算法,属于拟牛顿法家族,用于解决优化问题。与其他一阶优化技术如 SGD 相比,L-BFGS 通常能实现更快的收敛。

MLlib 中的一些线性方法同时支持 SGD 和 L-BFGS。你应该根据考虑的目标函数选择其中之一。一般来说,L-BFGS 相对于 SGD 更推荐,因为它收敛更快,但你需要根据需求仔细评估。

回归的正则化

在权重(系数值)较大的情况下,更容易导致模型过拟合。正则化是一种主要用于通过控制模型复杂度来消除过拟合问题的技术。通常当你发现训练数据和测试数据上的模型表现存在差异时,便可采用正则化。如果训练性能高于测试数据的性能,可能是过拟合的情况(高方差问题)。

为了解决这个问题,提出了一种正则化技术,通过对损失函数进行惩罚来改进模型。特别是在训练数据样本较少时,建议使用任何一种正则化技术。

在进一步讨论正则化技术之前,我们需要理解在有监督学习环境下,偏差方差的含义,以及为什么它们总是存在某种权衡。虽然两者都与误差有关,有偏的模型意味着它倾向于某种错误的假设,并且可能在某种程度上忽视了预测变量与响应变量之间的关系。这是欠拟合的情况!另一方面,高方差模型意味着它试图拟合每一个数据点,最终却在建模数据集中的随机噪声。这就是过拟合的情况。

带 L2 惩罚的线性回归(L2 正则化)称为岭回归,带 L1 惩罚的线性回归(L1 正则化)称为Lasso 回归。当同时使用 L1 和 L2 惩罚时,称为弹性网回归。我们将在接下来的部分中逐一讨论它们。

相较于 L1 正则化问题,L2 正则化问题通常更容易解决,因为 L2 正则化具有平滑性,但 L1 正则化问题会导致权重的稀疏性,从而产生更小且更具可解释性的模型。因此,Lasso 有时被用来进行特征选择。

岭回归

当我们将 L2 惩罚(也称为收缩惩罚)添加到最小二乘法的损失函数中时,它变成了岭回归,如下所示:

岭回归

这里的λ(大于 0)是一个调节参数,需要单独确定。前面公式中的第二项被称为收缩惩罚,只有当系数(β0β1...等)较小并接近 0 时,收缩惩罚才能变得较小。当λ = 0时,岭回归就变成了最小二乘法。随着 lambda 趋向无穷大,回归系数趋近于零(但永远不会是零)。

岭回归为每个λ值生成不同的系数值集合。因此,需要通过交叉验证仔细选择 lambda 值。随着 lambda 值的增加,回归线的灵活性减少,从而降低方差并增加偏差。

注意,收缩惩罚应用于所有解释变量,除截距项β0外。

当训练数据较少时,或者当预测变量或特征的数量大于观察值的数量时,岭回归表现得非常好。此外,岭回归所需的计算与最小二乘法几乎相同。

由于岭回归不会将任何系数值缩减为零,所有变量都会出现在模型中,这可能使得模型在变量数目较多时变得不易解释。

Lasso 回归

Lasso 是在岭回归之后引入的。当我们将 L1 惩罚添加到最小二乘的损失函数中时,它变成了 Lasso 回归,如下所示:

Lasso 回归

这里的区别在于,它不是取平方系数,而是取系数的模。与岭回归不同,Lasso 回归可以将一些系数强制设为零,这可能导致一些变量被消除。因此,Lasso 回归也可以用于变量选择!

Lasso 为每个 lambda 值生成不同的系数值。因此,需要通过交叉验证仔细选择 lambda 值。像岭回归一样,随着 lambda 的增加,方差减小,偏差增大。

与岭回归相比,Lasso 产生的模型更容易解释,因为它通常只有部分变量。若有许多类别型变量,建议选择 Lasso 而不是岭回归。

实际上,岭回归和 Lasso 回归并不是总是互相优劣。Lasso 通常在少量预测变量且它们的系数较大,而其余系数非常小的情况下表现较好。岭回归通常在有大量预测变量且几乎所有的变量系数都很大且相似时表现较好。

岭回归适用于分组选择,并且能解决多重共线性问题。而 Lasso 则无法进行分组选择,通常只会选择一个预测变量。如果一组预测变量之间高度相关,Lasso 倾向于只选择其中一个,并将其他变量的系数缩小为零。

弹性网回归

当我们将 L1 和 L2 惩罚都添加到最小二乘损失函数中时,它就变成了弹性网回归,如下所示:

弹性网回归

以下是弹性网回归的优点:

  • 强制稀疏性并有助于移除最不有效的变量

  • 鼓励分组效应

  • 结合了岭回归和 Lasso 回归的优点

弹性网回归的朴素版本会产生双重收缩问题,这会导致偏差增大和预测精度下降。为了解决这个问题,一种方法是通过将(1 + λ2)与估计的系数相乘来重新缩放它们:

Scala

import org.apache.spark.mllib.linalg.Vectors
import org.apache.spark.mllib.regression.LabeledPoint
import org.apache.spark.mllib.regression.LinearRegressionModel
import org.apache.spark.mllib.regression.LinearRegressionWithSGD
scala> import org.apache.spark.ml.regression.{LinearRegression,LinearRegressionModel}
import org.apache.spark.ml.regression.{LinearRegression,LinearRegressionModel}
// Load the data
scala> val data = spark.read.format("libsvm").load("data/mllib/sample_linear_regression_data.txt")
data: org.apache.spark.sql.DataFrame = [label: double, features: vector]

// Build the model
scala> val lrModel = new LinearRegression().fit(data)

//Note: You can change ElasticNetParam, MaxIter and RegParam
// Defaults are 0.0, 100 and 0.0
lrModel: org.apache.spark.ml.regression.LinearRegressionModel = linReg_aa788bcebc42

//Check Root Mean Squared Error
scala> println("Root Mean Squared Error = " + lrModel.summary.rootMeanSquaredError)
Root Mean Squared Error = 10.16309157133015

Python

>>> from pyspark.ml.regression import LinearRegression, LinearRegressionModel
>>>

// Load the data
>>> data = spark.read.format("libsvm").load("data/mllib/sample_linear_regression_data.txt")
>>> 

// Build the model
>>> lrModel = LinearRegression().fit(data)

//Note: You can change ElasticNetParam, MaxIter and RegParam
// Defaults are 0.0, 100 and 0.0
//Check Root Mean Squared Error
>>> print "Root Mean Squared Error = ", lrModel.summary.rootMeanSquaredError
Root Mean Squared Error = 10.16309157133015
>>> 

分类方法

如果响应变量是定性/类别型的(例如性别、贷款违约、婚姻状况等),那么无论解释变量的类型如何,这个问题都可以称为分类问题。分类方法有很多种,但在本节中我们将重点讨论逻辑回归和支持向量机。

以下是一些分类方法的应用实例:

  • 一个顾客购买某个产品或不购买

  • 一个人是否患有糖尿病

  • 一个申请贷款的个人会违约或不会违约

  • 一个电子邮件接收者会读邮件或不读

逻辑回归

逻辑回归衡量解释变量与分类响应变量之间的关系。我们不会对分类响应变量使用线性回归,因为响应变量不是连续的,因此误差项不是正态分布的。

所以逻辑回归是一个分类算法。逻辑回归不是直接建模响应变量Y,而是建模P(Y|X)的概率分布,即Y属于某一特定类别的概率。条件分布(Y|X)是伯努利分布,而不是高斯分布。逻辑回归方程可以表示为:

逻辑回归

对于二分类问题,模型的输出应该仅限于两个类别之一(例如 0 或 1)。由于逻辑回归预测的是概率而不是直接预测类别,我们使用一个逻辑函数(也称为sigmoid 函数)来将输出限制为一个单一的类别:

逻辑回归

解前面的方程会得到以下结果:

逻辑回归

它可以进一步简化为:

逻辑回归

左边的量 P(X)/1-P(X) 被称为赔率。赔率的值范围从 0 到无穷大。接近 0 的值表示概率很小,而数值较大的则表示概率很高。在某些情况下,赔率会直接代替概率使用,这取决于具体情况。

如果我们取赔率的对数,它就变成了对数赔率或 logit,可以表示如下:

逻辑回归

从前面的方程中可以看出,logit 与 X 是线性相关的。

在有两个类别 1 和 0 的情况下,我们当p >= 0.5时预测Y = 1,当p < 0.5时预测Y = 0。因此,逻辑回归实际上是一个线性分类器,决策边界为p = 0.5。在某些商业场景中,p可能默认并不设置为 0.5,您可能需要使用一些数学技巧来确定合适的值。

一种称为最大似然法的方法被用来通过计算回归系数来拟合模型,该算法可以像线性回归一样使用梯度下降。

在逻辑回归中,损失函数应关注错误分类率。因此,逻辑回归使用的损失函数称为逻辑损失,如下所示:

逻辑回归

注意

请注意,当您使用更高阶的多项式来更好地拟合模型时,逻辑回归也容易过拟合。为了解决这个问题,您可以像在线性回归中一样使用正则化项。到目前为止,Spark 不支持正则化逻辑回归,因此我们暂时跳过这一部分。

线性支持向量机(SVM)

支持向量机SVM)是一种监督学习算法,可以用于分类和回归。然而,它在解决分类问题时更为流行,且由于 Spark 将其作为 SVM 分类器提供,我们将仅限于讨论分类设置。当作为分类器使用时,与逻辑回归不同,它是一个非概率性分类器。

SVM(支持向量机)起源于一种简单的分类器,称为最大间隔分类器。由于最大间隔分类器要求类别通过线性边界可分,因此它无法应用于许多数据集。因此,它被扩展为一种改进版本,称为支持向量分类器,能够处理类别重叠且类别之间没有明显分隔的情况。支持向量分类器进一步扩展为我们所称的 SVM,以适应非线性类别边界。让我们一步步讨论 SVM 的演变,帮助我们更清楚地理解它的工作原理。

如果数据集有p维度(特征),那么我们将在这个 p 维空间中拟合一个超平面,其方程可以定义如下:

线性支持向量机(SVM)

这个超平面被称为分隔超平面,形成决策边界。结果将根据结果进行分类;如果大于 0,则位于一侧;如果小于 0,则位于另一侧,如下图所示:

线性支持向量机(SVM)

在前面的图中观察到,可以有多个超平面(它们可以是无限的)。应该有一种合理的方法来选择最优的超平面。这就是我们选择最大间隔超平面的地方。如果你计算所有数据点到分隔超平面的垂直距离,那么最小的距离被称为间隔。因此,对于最大间隔分类器,超平面应该具有最大的间隔。

与分隔超平面距离近且等距的训练观测值被称为支持向量。对于支持向量的任何轻微变化,超平面也会重新定向。这些支持向量实际上定义了间隔。那么,如果考虑的两个类别不可分怎么办?我们可能希望有一个分类器,它不完美地将两个类别分开,并且有一个较软的边界,允许一定程度的误分类。这一需求促使了支持向量分类器(也称为软间隔分类器)的引入。

从数学上讲,它是方程中的松弛变量,允许出现误分类。此外,支持向量分类器中还有一个调节参数,应该通过交叉验证来选择。这个调节参数是在偏差和方差之间进行权衡的,需要小心处理。当它较大时,边界较宽,包含许多支持向量,具有较低的方差和较高的偏差。如果它较小,边界中的支持向量较少,分类器将具有较低的偏差但较高的方差。

SVM 的损失函数可以表示如下:

线性支持向量机 (SVM)

截至本文写作时,Spark 只支持线性 SVM。默认情况下,线性 SVM 会使用 L2 正则化进行训练。Spark 还支持替代的 L1 正则化。

到目前为止都很好!但是,当类别之间存在非线性边界时,支持向量分类器如何工作呢?如下图所示:

线性支持向量机 (SVM)

任何线性分类器,例如支持向量分类器,在前述情况下都会表现得非常差。如果它通过数据点画一条直线,那么类别将无法正确分离。这就是非线性类别边界的一个例子。解决这个问题的方法是使用 SVM。换句话说,当支持向量分类器与非线性核函数结合时,它就变成了 SVM。

类似于我们在回归方程中引入高阶多项式项以处理非线性一样,SVM 中也可以做类似的处理。SVM 使用被称为核函数的东西来处理数据集中的不同类型的非线性;不同的核函数适用于不同类型的非线性。核方法将数据映射到更高维的空间中,因为如果这样做,数据可能会被很好地分离开来。此外,它还使区分不同类别变得更加容易。我们来讨论几个重要的核函数,以便能够选择正确的核函数。

线性核函数

这是最基本的一种核函数类型,它只允许我们挑选出直线或超平面。它相当于一个支持向量分类器。如果数据集中存在非线性,它无法处理。

多项式核函数

这使我们能够在多项式阶数的范围内处理一定程度的非线性。当训练数据已经规范化时,这种方法表现得很好。这个核函数通常有更多的超参数,因此会增加模型的复杂度。

径向基函数核

当你不确定使用哪个核函数时,径向基函数 (RBF) 是一个很好的默认选择。它可以让你挑选出圆形或超球体。虽然它通常比线性核函数或多项式核函数表现得更好,但当特征数非常大时,它的表现可能不佳。

Sigmoid 核函数

Sigmoid 核函数源自神经网络。因此,具有 Sigmoid 核的 SVM 等同于具有双层感知机的神经网络。

训练一个 SVM

在训练 SVM 时,建模者需要做出一些决策:

  • 如何预处理数据(转换与缩放)。分类变量应通过虚拟化转换为数值型变量。同时,需要对数值进行缩放(将其归一化到 0 到 1 或 -1 到 +1)。

  • 选择哪个核函数(如果你无法可视化数据或得出结论,可以通过交叉验证检查)。

  • SVM 的参数设置:惩罚参数和核函数参数(通过交叉验证或网格搜索找到)。

如有需要,您可以使用基于熵的特征选择方法,只包含模型中的重要特征。

Scala:

scala> import org.apache.spark.mllib.classification.{SVMModel, SVMWithSGD}
import org.apache.spark.mllib.classification.{SVMModel, SVMWithSGD}
scala> import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
scala> import org.apache.spark.mllib.util.MLUtils
import org.apache.spark.mllib.util.MLUtils
scala>

// Load training data in LIBSVM format.
scala> val data = MLUtils.loadLibSVMFile(sc, "data/mllib/sample_libsvm_data.txt")
data: org.apache.spark.rdd.RDD[org.apache.spark.mllib.regression.LabeledPoint] = MapPartitionsRDD[6] at map at MLUtils.scala:84
scala>

// Split data into training (60%) and test (40%).
scala> val splits = data.randomSplit(Array(0.6, 0.4), seed = 11L)
splits: Array[org.apache.spark.rdd.RDD[org.apache.spark.mllib.regression.LabeledPoint]] = Array(MapPartitionsRDD[7] at randomSplit at <console>:29, MapPartitionsRDD[8] at randomSplit at <console>:29)
scala> val training = splits(0).cache()
training: org.apache.spark.rdd.RDD[org.apache.spark.mllib.regression.LabeledPoint] = MapPartitionsRDD[7] at randomSplit at <console>:29
scala> val test = splits(1)
test: org.apache.spark.rdd.RDD[org.apache.spark.mllib.regression.LabeledPoint] = MapPartitionsRDD[8] at randomSplit at <console>:29
scala>

// Run training algorithm to build the model
scala> val model = SVMWithSGD.train(training, numIterations=100)
model: org.apache.spark.mllib.classification.SVMModel = org.apache.spark.mllib.classification.SVMModel: intercept = 0.0, numFeatures = 692, numClasses = 2, threshold = 0.0
scala>

// Clear the default threshold.
scala> model.clearThreshold()
res1: model.type = org.apache.spark.mllib.classification.SVMModel: intercept =
0.0, numFeatures = 692, numClasses = 2, threshold = None
scala>

// Compute raw scores on the test set.
scala> val scoreAndLabels = test.map { point =>
       val score = model.predict(point.features)
      (score, point.label)
      }
scoreAndLabels: org.apache.spark.rdd.RDD[(Double, Double)] =
MapPartitionsRDD[213] at map at <console>:37
scala>

// Get evaluation metrics.
scala> val metrics = new BinaryClassificationMetrics(scoreAndLabels)
metrics: org.apache.spark.mllib.evaluation.BinaryClassificationMetrics = org.apache.spark.mllib.evaluation.BinaryClassificationMetrics@3106aebb
scala> println("Area under ROC = " + metrics.areaUnderROC())
Area under ROC = 1.0
scala>

注意

mllib 已经进入维护模式,SVM 仍未在 ml 模块下提供,因此这里只提供了 Scala 代码示例。

决策树

决策树是一种非参数化的监督学习算法,既可以用于分类问题,也可以用于回归问题。决策树像倒立的树,根节点位于顶部,叶节点向下延展。存在不同的算法用于将数据集划分为分支状的段。每个叶节点被分配到一个类别,表示最合适的目标值。

决策树不需要对数据集进行任何缩放或转换,直接处理原始数据。它们既能处理分类特征,又能处理连续特征,并且能够解决数据集中的非线性问题。决策树本质上是一种贪心算法(它只考虑当前最佳分裂,而不考虑未来的情况),通过递归二元划分特征空间进行操作。划分是基于每个节点的信息增益,因为信息增益衡量了给定特征在目标类别或值上的区分效果。第一次分裂发生在产生最大信息增益的特征上,成为根节点。

节点的信息增益是父节点的不纯度与两个子节点的不纯度加权和之间的差值。为了估算信息增益,Spark 目前针对分类问题提供了两种 impurity 度量方法,针对回归问题提供了一种度量方法,具体如下。

不纯度度量

不纯度是衡量同质性的一种方法,也是递归划分的最佳标准。通过计算不纯度,可以决定最佳的划分候选。大多数不纯度度量方法是基于概率的:

某一类别的概率 = 该类别的观察次数 / 总观察次数

让我们花点时间来探讨 Spark 支持的几种重要的 impurity(不纯度)度量方法。

基尼指数

基尼指数主要用于数据集中连续的属性或特征。如果不是这样,它将假设所有属性和特征都是连续的。该分裂使得子节点比父节点更加纯净。基尼倾向于找到最大类别——即响应变量中观察数最多的类别。它可以定义如下:

基尼指数

如果所有响应的观察值都属于同一类别,那么该类别P的概率j,即(Pj),将为 1,因为只有一个类别,而(Pj)2也将为 1。这使得基尼指数为零。

熵主要用于数据集中的分类属性或特征。它可以定义如下:

熵

如果所有响应的观察值都属于同一类别,那么该类别的概率(Pj)将为 1,log(P)将为零。这样熵将为零。

下图显示了公平掷硬币的概率:

熵

为了说明前面的图,如果你掷一枚公平的硬币,正面或反面的概率为 0.5,因此在概率为 0.5 时将有最多的观察值。

如果数据样本完全同质,那么熵为零;如果样本可以平分为两个部分,那么熵为一。

它的计算速度比基尼指数稍慢,因为它还需要计算对数。

方差

与基尼指数和熵不同,方差用于计算回归问题的信息增益。方差可以定义为:

方差

停止规则

当满足以下条件之一时,递归树构建将在某个节点停止:

  • 节点深度等于maxDepth训练参数

  • 没有分裂候选导致的信息增益大于minInfoGain

  • 没有分裂候选产生的子节点,每个子节点至少有minInstancesPerNode个训练实例

分裂候选

数据集通常包含分类特征和连续特征的混合。我们需要了解特征如何进一步分裂成分裂候选,因为有时我们需要对它们进行一定程度的控制,以建立更好的模型。

分类特征

对于具有M个可能值(类别)的分类特征,可以提出2(M-ˆ’1)-ˆ’1个分裂候选。无论是二分类还是回归,分裂候选的数量可以通过按平均标签对分类特征值排序减少到M-ˆ’1

例如,考虑一个二元分类(0/1)问题,其中有一个具有三个类别 A、B 和 C 的分类特征,它们对应的标签-1 响应变量的比例分别为 0.2、0.6 和 0.4。在这种情况下,分类特征可以按 A、C、B 排列。所以,两个分裂候选项(M-1 = 3-1 = 2)可以是 A | (C, B)A, (C | B),其中 '|' 表示分裂。

连续特征

对于一个连续特征变量,可能没有两个值是相同的(至少我们可以假设如此)。如果有 n 个观测值,那么 n 个分裂候选项可能不是一个好主意,尤其是在大数据环境下。

在 Spark 中,通过对数据样本进行分位数计算,并相应地对数据进行分箱来完成此操作。你仍然可以通过 maxBins 参数控制最大分箱数。maxBins 的最大默认值是 32

决策树的优点

  • 它们容易理解和解释,因此也很容易向业务用户解释

  • 它们适用于分类和回归

  • 在构建决策树时,定性和定量数据都可以得到处理

决策树中的信息增益偏向于具有更多层次的属性。

决策树的缺点

  • 它们对于连续结果变量的效果不是特别好

  • 当类别很多且数据集很小时,性能较差

  • 轴平行切分会降低精度

  • 它们容易受到高方差的影响,因为它们尝试拟合几乎所有的数据点

示例

在实现方面,分类树和回归树之间没有太大区别。让我们看看在 Spark 上的实际实现。

Scala:

//Assuming ml.Pipeline and ml.features are already imported
scala> import org.apache.spark.ml.classification.{
        DecisionTreeClassifier, DecisionTreeClassificationModel}
import org.apache.spark.ml.classification.{DecisionTreeClassifier,
DecisionTreeClassificationModel}
scala>
/prepare train data
scala> val f:String = "<Your path>/simple_file1.csv"
f: String = <your path>/simple_file1.csv
scala> val trainDF = spark.read.options(Map("header"->"true",
            "inferSchema"->"true")).csv(f)
trainDF: org.apache.spark.sql.DataFrame = [Text: string, Label: int]

scala>

 //define DecisionTree pipeline
//StringIndexer maps labels(String or numeric) to label indices
//Maximum occurrence label becomes 0 and so on
scala> val lblIdx = new StringIndexer().
                setInputCol("Label").
                setOutputCol("indexedLabel")
lblIdx: org.apache.spark.ml.feature.StringIndexer = strIdx_3a7bc9c1ed0d
scala>

// Create labels list to decode predictions
scala> val labels = lblIdx.fit(trainDF).labels
labels: Array[String] = Array(2, 1, 3)
scala>

//Define Text column indexing stage
scala> val fIdx = new StringIndexer().
                setInputCol("Text").
              setOutputCol("indexedText")
fIdx: org.apache.spark.ml.feature.StringIndexer = strIdx_49253a83c717

// VectorAssembler
scala> val va = new VectorAssembler().
              setInputCols(Array("indexedText")).
              setOutputCol("features")
va: org.apache.spark.ml.feature.VectorAssembler = vecAssembler_764720c39a85

//Define Decision Tree classifier. Set label and features vector
scala> val dt = new DecisionTreeClassifier().
            setLabelCol("indexedLabel").
            setFeaturesCol("features")
dt: org.apache.spark.ml.classification.DecisionTreeClassifier = dtc_84d87d778792

//Define label converter to convert prediction index back to string
scala> val lc = new IndexToString().
                setInputCol("prediction").
                setOutputCol("predictedLabel").
                setLabels(labels)
lc: org.apache.spark.ml.feature.IndexToString = idxToStr_e2f4fa023665
scala>

//String the stages together to form a pipeline
scala> val dt_pipeline = new Pipeline().setStages(
          Array(lblIdx,fIdx,va,dt,lc))
dt_pipeline: org.apache.spark.ml.Pipeline = pipeline_d4b0e884dcbf
scala>
//Apply pipeline to the train data
scala> val resultDF = dt_pipeline.fit(trainDF).transform(trainDF)

//Check results. Watch Label and predictedLabel column values match
resultDF: org.apache.spark.sql.DataFrame = [Text: string, Label: int ... 6 more
fields]
scala>
resultDF.select("Text","Label","features","prediction","predictedLabel").show()
+----+-----+--------+----------+--------------+
|Text|Label|features|prediction|predictedLabel|
+----+-----+--------+----------+--------------+
|   A|    1|   [1.0]|       1.0|             1|
|   B|    2|   [0.0]|       0.0|             2|
|   C|    3|   [2.0]|       2.0|             3|
|   A|    1|   [1.0]|       1.0|             1|
|   B|    2|   [0.0]|       0.0|             2|
+----+-----+--------+----------+--------------+
scala>

//Prepare evaluation data
scala> val eval:String = "€œ<Your path>/simple_file2.csv"
eval: String = <Your path>/simple_file2.csv
scala> val evalDF = spark.read.options(Map("header"->"true",
            "inferSchema"->"true")).csv(eval)
evalDF: org.apache.spark.sql.DataFrame = [Text: string, Label: int]
scala>

//Apply the same pipeline to the evaluation data
scala> val eval_resultDF = dt_pipeline.fit(evalDF).transform(evalDF)
eval_resultDF: org.apache.spark.sql.DataFrame = [Text: string, Label: int ... 7
more fields]

//Check evaluation results
scala>
eval_resultDF.select("Text","Label","features","prediction","predictedLabel").sh
w()
+----+-----+--------+----------+--------------+
|Text|Label|features|prediction|predictedLabel|
+----+-----+--------+----------+--------------+
|   A|    1|   [0.0]|       1.0|             1|
|   A|    1|   [0.0]|       1.0|             1|
|   A|    2|   [0.0]|       1.0|             1|
|   B|    2|   [1.0]|       0.0|             2|
|   C|    3|   [2.0]|       2.0|             3|
+----+-----+--------+----------+--------------+
//Note that predicted label for the third row is 1 as against Label(2) as
expected

Python:

//Model training example
>>> from pyspark.ml.pipeline import Pipeline
>>> from pyspark.ml.feature import StringIndexer, VectorIndexer, VectorAssembler,
IndexToString
>>> from pyspark.ml.classification import DecisionTreeClassifier,
DecisionTreeClassificationModel
>>> 

//prepare train data
>>> file_location = "../work/simple_file1.csv"
>>> trainDF = spark.read.csv(file_location,header=True,inferSchema=True)

 //Read file
>>>

//define DecisionTree pipeline
//StringIndexer maps labels(String or numeric) to label indices
//Maximum occurrence label becomes 0 and so on
>>> lblIdx = StringIndexer(inputCol = "Label",outputCol = "indexedLabel")

// Create labels list to decode predictions
>>> labels = lblIdx.fit(trainDF).labels
>>> labels
[u'2', u'1', u'3']
>>> 

//Define Text column indexing stage
>>> fidx = StringIndexer(inputCol="Text",outputCol="indexedText")

// Vector assembler
>>> va = VectorAssembler(inputCols=["indexedText"],outputCol="features")

//Define Decision Tree classifier. Set label and features vector
>>> dt = DecisionTreeClassifier(labelCol="indexedLabel",featuresCol="features")

//Define label converter to convert prediction index back to string
>>> lc = IndexToString(inputCol="prediction",outputCol="predictedLabel",
                       labels=labels)

//String the stages together to form a pipeline
>>> dt_pipeline = Pipeline(stages=[lblIdx,fidx,va,dt,lc])
>>>
>>> 

//Apply decision tree pipeline
>>> dtModel = dt_pipeline.fit(trainDF)
>>> dtDF = dtModel.transform(trainDF)
>>> dtDF.columns
['Text', 'Label', 'indexedLabel', 'indexedText', 'features', 'rawPrediction',
'probability', 'prediction', 'predictedLabel']
>>> dtDF.select("Text","Label","indexedLabel","prediction",
"predictedLabel").show()
+----+-----+------------+----------+--------------+
|Text|Label|indexedLabel|prediction|predictedLabel|
+----+-----+------------+----------+--------------+
|   A|    1|         1.0|       1.0|             1|
|   B|    2|         0.0|       0.0|             2|
|   C|    3|         2.0|       2.0|             3|
|   A|    1|         1.0|       1.0|             1|
|   B|    2|         0.0|       0.0|             2|
+----+-----+------------+----------+--------------+

>>>

>>> //prepare evaluation dataframe
>>> eval_file_path = "../work/simple_file2.csv"
>>> evalDF = spark.read.csv(eval_file_path,header=True, inferSchema=True) 

//Read eval file
>>> eval_resultDF = dt_pipeline.fit(evalDF).transform(evalDF)
>>> eval_resultDF.columns
['Text', 'Label', 'indexedLabel', 'indexedText', 'features', 'rawPrediction', 'probability', 'prediction', 'predictedLabel']
>>> eval_resultDF.select("Text","Label","indexedLabel","prediction",
"predictedLabel").show()
+----+-----+------------+----------+--------------+
|Text|Label|indexedLabel|prediction|predictedLabel|
+----+-----+------------+----------+--------------+
|   A|    1|         1.0|       1.0|             1|
|   A|    1|         1.0|       1.0|             1|
|   A|    2|         0.0|       1.0|             1|
|   B|    2|         0.0|       0.0|             2|
|   C|    3|         2.0|       2.0|             3|
+----+-----+------------+----------+--------------+
>>> 

Accompanying data files:
simple_file1.csv Text,Label
A,1
B,2
C,3
A,1
B,2simple_file2.csv Text,Label
A,1
A,1
A,2
B,2
C,3

集成方法

顾名思义,集成方法通过使用多个学习算法来获得在预测准确性方面更精确的模型。通常,这些技术需要更多的计算能力,并使得模型更加复杂,进而增加了解释的难度。我们来讨论 Spark 上可用的各种集成技术。

随机森林

随机森林是决策树的一种集成技术。在介绍随机森林之前,我们先来看看它是如何发展的。我们知道,决策树通常存在较高的方差问题,容易导致过拟合。为了解决这个问题,引入了一个叫做 袋装(也叫做自助聚合)的概念。对于决策树来说,方法是从数据集中获取多个训练集(自助训练集),用这些训练集分别构建决策树,然后对回归树进行平均。对于分类树,我们可以取所有树的多数投票或最常见的类别。这些树生长得很深,且没有任何修剪。虽然每棵树可能会有较高的方差,但这显著降低了方差。

使用传统的袋装方法时,存在一个问题,即对于大多数自助法训练集,强预测变量位于顶部分裂位置,几乎使得袋装树看起来相似。这意味着预测结果也相似,如果你对它们进行平均,方差并没有达到预期的减少效果。为了解决这个问题,需要一种技术,它采用与袋装树类似的方法,但消除了树之间的相关性,从而形成了随机森林

在这种方法中,你构建自助法训练样本来创建决策树,但唯一的区别是每次进行分裂时,会从总共的 K 个预测变量中随机选择 P 个预测变量。这就是随机森林向这种方法注入随机性的方式。作为经验法则,我们可以将 P 设置为 Q 的平方根。

和袋装方法一样,在这种方法中,如果目标是回归,则对预测结果进行平均,如果目标是分类,则进行多数投票。Spark 提供了一些调优参数来调整该模型,具体如下:

  • numTrees:你可以指定在随机森林中考虑的树的数量。如果树的数量较多,则预测的方差较小,但所需的时间会更长。

  • maxDepth:你可以指定每棵树的最大深度。增加深度可以提高树的预测准确性。尽管它们容易过拟合单棵树,但由于我们最终会对结果进行平均,因此整体输出仍然不错,从而减少了方差。

  • subsamplingRate:此参数主要用于加速训练。它用于设置自助法训练样本的大小。值小于 1.0 会加速性能。

  • featureSubsetStrategy:此参数也有助于加速执行。它用于设置每个节点用于分裂的特征数。需要谨慎设置此值,因为过低或过高的值可能会影响模型的准确性。

随机森林的优点

  • 它们的运行速度较快,因为执行过程是并行的。

  • 它们不易过拟合。

  • 它们易于调优。

  • 与树或袋装树相比,预测准确性更高。

  • 即使预测变量是类别特征和连续特征的混合,它们也能很好地工作,并且不需要缩放。

梯度提升树

与随机森林类似,梯度提升树 (GBTs)也是一种树的集成方法。它们既可以应用于分类问题,也可以应用于回归问题。与袋装树或随机森林不同,后者是基于独立数据集并行构建的树,彼此独立,GBT 是按顺序构建的。每棵树都是基于之前已构建的树的结果来生成的。需要注意的是,GBT 不适用于自助法样本。

在每次迭代中,GBT 会使用当前的集成模型来预测训练实例的标签,并将其与真实标签进行比较,估算误差。预测精度较差的训练实例会被重新标记,以便决策树在下一次迭代中根据上一次的误差率进行修正。

找到误差率并重新标记实例的机制是基于损失函数的。GBT 的设计旨在在每次迭代中减少这个损失函数。Spark 支持以下类型的损失函数:

  • 对数损失:这用于分类问题。

  • 平方误差(L2 损失):这用于回归问题,并且是默认设置。它是所有观察值的实际输出与预测输出之间平方差的总和。对于这种损失函数,应该对异常值进行良好的处理。

  • 绝对误差(L1 损失):这也用于回归问题。它是所有观察值的实际输出与预测输出之间绝对差的总和。与平方误差相比,它对异常值更为稳健。

Spark 提供了一些调参参数来调整此模型,具体如下:

  • loss:你可以传递一个损失函数,如前节所讨论的,具体取决于你处理的数据集以及你是要进行分类还是回归。

  • numIterations:每次迭代只产生一棵树!如果你设置得很高,那么执行所需的时间也会很长,因为操作将是顺序进行的,这也可能导致过拟合。为了更好的性能和准确性,应该谨慎设置。

  • learningRate:这其实并不是一个调参参数。如果算法的行为不稳定,降低学习率可以帮助稳定模型。

  • algo分类回归,根据你需要的类型来设置。

GBT 可能会因为树的数量过多而导致过拟合,因此 Spark 提供了runWithValidation方法来防止过拟合。

提示

截至目前,Spark 上的 GBT 尚不支持多类分类。

让我们通过一个例子来说明 GBT 的实际应用。这个示例数据集包含了二十名学生的平均分数和出勤情况。数据中还包含了通过或未通过的结果,这些结果遵循一组标准。然而,几个学生(ID 为 1009 和 1020)尽管未符合标准,但却被“授予”了通过状态。现在我们的任务是检查模型是否会把这两名学生排除在外。

通过标准如下:

  • 分数应该至少为 40,出勤率应该至少为“足够”。

  • 如果分数在 40 到 60 之间,那么出勤率应该是“完整”才能通过。

以下示例还强调了在多个模型中重用管道阶段。因此,我们首先构建一个决策树分类器,然后构建 GBT。我们构建了两个共享阶段的不同管道。

输入

// Marks < 40 = Fail
// Attendence == Poor => Fail
// Marks >40 and attendence Full => Pass
// Marks > 60 and attendence Enough or Full => Pass
// Two exceptions were studentId 1009 and 1020 who were granted Pass
//This example also emphasizes the reuse of pipeline stages
// Initially the code trains a DecisionTreeClassifier
// Then, same stages are reused to train a GBT classifier

Scala:

scala> import org.apache.spark.ml.feature._
scala> import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.classification.{DecisionTreeClassifier,
                                   DecisionTreeClassificationModel}
scala> case class StResult(StudentId:String, Avg_Marks:Double,
        Attendance:String, Result:String)
scala> val file_path = "../work/StudentsPassFail.csv"
scala> val source_ds = spark.read.options(Map("header"->"true",
            "inferSchema"->"true")).csv(file_path).as[StResult]
source_ds: org.apache.spark.sql.Dataset[StResult] = [StudentId: int, Avg_Marks:
double ... 2 more fields]
scala>
//Examine source data
scala> source_ds.show(4)
+---------+---------+----------+------+
|StudentId|Avg_Marks|Attendance|Result|
+---------+---------+----------+------+
|     1001|     48.0|      Full|  Pass|
|     1002|     21.0|    Enough|  Fail|
|     1003|     24.0|    Enough|  Fail|
|     1004|      4.0|      Poor|  Fail|
+---------+---------+----------+------+

scala>           
//Define preparation pipeline
scala> val marks_bkt = new Bucketizer().setInputCol("Avg_Marks").
        setOutputCol("Mark_bins").setSplits(Array(0,40.0,60.0,100.0))
marks_bkt: org.apache.spark.ml.feature.Bucketizer = bucketizer_5299d2fbd1b2
scala> val att_idx = new StringIndexer().setInputCol("Attendance").
        setOutputCol("Att_idx")
att_idx: org.apache.spark.ml.feature.StringIndexer = strIdx_2db54ba5200a
scala> val label_idx = new StringIndexer().setInputCol("Result").
        setOutputCol("Label")
label_idx: org.apache.spark.ml.feature.StringIndexer = strIdx_20f4316d6232
scala>

//Create labels list to decode predictions
scala> val resultLabels = label_idx.fit(source_ds).labels
resultLabels: Array[String] = Array(Fail, Pass)
scala> val va = new VectorAssembler().setInputCols(Array("Mark_bins","Att_idx")).
                  setOutputCol("features")
va: org.apache.spark.ml.feature.VectorAssembler = vecAssembler_5dc2dbbef48c
scala> val dt = new DecisionTreeClassifier().setLabelCol("Label").
         setFeaturesCol("features")
dt: org.apache.spark.ml.classification.DecisionTreeClassifier = dtc_e8343ae1a9eb
scala> val lc = new IndexToString().setInputCol("prediction").
             setOutputCol("predictedLabel").setLabels(resultLabels)
lc: org.apache.spark.ml.feature.IndexToString = idxToStr_90b6693d4313
scala>

//Define pipeline
scala>val dt_pipeline = new
Pipeline().setStages(Array(marks_bkt,att_idx,label_idx,va,dt,lc))
dt_pipeline: org.apache.spark.ml.Pipeline = pipeline_95876bb6c969
scala> val dtModel = dt_pipeline.fit(source_ds)
dtModel: org.apache.spark.ml.PipelineModel = pipeline_95876bb6c969
scala> val resultDF = dtModel.transform(source_ds)
resultDF: org.apache.spark.sql.DataFrame = [StudentId: int, Avg_Marks: double ...
10 more fields]
scala> resultDF.filter("Label != prediction").select("StudentId","Label","prediction","Result","predictedLabel").show()
+---------+-----+----------+------+--------------+
|StudentId|Label|prediction|Result|predictedLabel|
+---------+-----+----------+------+--------------+\
|     1009|  1.0|       0.0|  Pass|          Fail|
|     1020|  1.0|       0.0|  Pass|          Fail|
+---------+-----+----------+------+--------------+

//Note that the difference is in the student ids that were granted pass

//Same example using Gradient boosted tree classifier, reusing the pipeline stages
scala> import org.apache.spark.ml.classification.GBTClassifier
import org.apache.spark.ml.classification.GBTClassifier
scala> val gbt = new GBTClassifier().setLabelCol("Label").
              setFeaturesCol("features").setMaxIter(10)
gbt: org.apache.spark.ml.classification.GBTClassifier = gbtc_cb55ae2174a1
scala> val gbt_pipeline = new
Pipeline().setStages(Array(marks_bkt,att_idx,label_idx,va,gbt,lc))
gbt_pipeline: org.apache.spark.ml.Pipeline = pipeline_dfd42cd89403
scala> val gbtResultDF = gbt_pipeline.fit(source_ds).transform(source_ds)
gbtResultDF: org.apache.spark.sql.DataFrame = [StudentId: int, Avg_Marks: double ... 8 more fields]
scala> gbtResultDF.filter("Label !=
prediction").select("StudentId","Label","Result","prediction","predictedLabel").show()
+---------+-----+------+----------+--------------+
|StudentId|Label|Result|prediction|predictedLabel|
+---------+-----+------+----------+--------------+
|     1009|  1.0|  Pass|       0.0|          Fail|
|     1020|  1.0|  Pass|       0.0|          Fail|
+---------+-----+------+----------+--------------+

Python

>>> from pyspark.ml.pipeline import Pipeline
>>> from pyspark.ml.feature import Bucketizer, StringIndexer, VectorAssembler, IndexToString
>>> from pyspark.ml.classification import DecisionTreeClassifier,
DecisionTreeClassificationModel
>>> 

//Get source file
>>> file_path = "../work/StudentsPassFail.csv"
>>> source_df = spark.read.csv(file_path,header=True,inferSchema=True)
>>> 

//Examine source data
>>> source_df.show(4)
+---------+---------+----------+------+
|StudentId|Avg_Marks|Attendance|Result|
+---------+---------+----------+------+
|     1001|     48.0|      Full|  Pass|
|     1002|     21.0|    Enough|  Fail|
|     1003|     24.0|    Enough|  Fail|
|     1004|      4.0|      Poor|  Fail|
+---------+---------+----------+------+

//Define preparation pipeline
>>> marks_bkt = Bucketizer(inputCol="Avg_Marks",
        outputCol="Mark_bins", splits=[0,40.0,60.0,100.0])
>>> att_idx = StringIndexer(inputCol = "Attendance",
        outputCol="Att_idx")
>>> label_idx = StringIndexer(inputCol="Result",
                   outputCol="Label")
>>> 

//Create labels list to decode predictions
>>> resultLabels = label_idx.fit(source_df).labels
>>> resultLabels
[u'Fail', u'Pass']
>>> 
>>> va = VectorAssembler(inputCols=["Mark_bins","Att_idx"],
                         outputCol="features")
>>> dt = DecisionTreeClassifier(labelCol="Label", featuresCol="features")
>>> lc = IndexToString(inputCol="prediction",outputCol="predictedLabel",
             labels=resultLabels)
>>> dt_pipeline = Pipeline(stages=[marks_bkt, att_idx, label_idx,va,dt,lc])
>>> dtModel = dt_pipeline.fit(source_df)
>>> resultDF = dtModel.transform(source_df)
>>>

//Look for obervatiuons where prediction did not match
>>> resultDF.filter("Label != prediction").select(
         "StudentId","Label","prediction","Result","predictedLabel").show()
+---------+-----+----------+------+--------------+
|StudentId|Label|prediction|Result|predictedLabel|
+---------+-----+----------+------+--------------+
|     1009|  1.0|       0.0|  Pass|          Fail|
|     1020|  1.0|       0.0|  Pass|          Fail|
+---------+-----+----------+------+--------------+

//Note that the difference is in the student ids that were granted pass
>>> 
//Same example using Gradient boosted tree classifier, reusing the pipeline
stages
>>> from pyspark.ml.classification import GBTClassifier
>>> gbt = GBTClassifier(labelCol="Label", featuresCol="features",maxIter=10)
>>> gbt_pipeline = Pipeline(stages=[marks_bkt,att_idx,label_idx,va,gbt,lc])
>>> gbtResultDF = gbt_pipeline.fit(source_df).transform(source_df)
>>> gbtResultDF.columns
['StudentId', 'Avg_Marks', 'Attendance', 'Result', 'Mark_bins', 'Att_idx',
'Label', 'features', 'prediction', 'predictedLabel']
>>> gbtResultDF.filter("Label !=
prediction").select("StudentId","Label","Result","prediction","predictedLabel").show()
+---------+-----+------+----------+--------------+
|StudentId|Label|Result|prediction|predictedLabel|
+---------+-----+------+----------+--------------+
|     1009|  1.0|  Pass|       0.0|          Fail|
|     1020|  1.0|  Pass|       0.0|          Fail|
+---------+-----+------+----------+--------------+

多层感知机分类器

多层感知器分类器MLPC)是一个前馈人工神经网络,具有多个层次的节点,节点之间以有向方式相互连接。它使用一种名为反向传播的有监督学习技术来训练网络。

中间层的节点使用 sigmoid 函数将输出限制在 0 和 1 之间,输出层的节点使用softmax函数,它是 sigmoid 函数的广义版本。

Scala

scala> import org.apache.spark.ml.classification.MultilayerPerceptronClassifier
import org.apache.spark.ml.classification.MultilayerPerceptronClassifier
scala> import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
scala> import org.apache.spark.mllib.util.MLUtils
import org.apache.spark.mllib.util.MLUtils

// Load training data
scala> val data = MLUtils.loadLibSVMFile(sc,
"data/mllib/sample_multiclass_classification_data.txt").toDF()
data: org.apache.spark.sql.DataFrame = [label: double, features: vector]

//Convert mllib vectors to ml Vectors for spark 2.0+. Retain data for previous versions
scala> val data2 = MLUtils.convertVectorColumnsToML(data)
data2: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [label: double, features: vector]

// Split the data into train and test
scala> val splits = data2.randomSplit(Array(0.6, 0.4), seed = 1234L)
splits: Array[org.apache.spark.sql.Dataset[org.apache.spark.sql.Row]] = Array([label: double, features: vector], [label: double, features: vector])
scala> val train = splits(0)
train: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [label: double, features: vector]
scala> val test = splits(1)
test: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [label: double, features: vector]

// specify layers for the neural network:
// input layer of size 4 (features), two intermediate of size 5 and 4 and output of size 3 (classes)
scala> val layers = ArrayInt
layers: Array[Int] = Array(4, 5, 4, 3)

// create the trainer and set its parameters
scala> val trainer = new MultilayerPerceptronClassifier().
           setLayers(layers).setBlockSize(128).
           setSeed(1234L).setMaxIter(100)
trainer: org.apache.spark.ml.classification.MultilayerPerceptronClassifier = mlpc_edfa49fbae3c

// train the model
scala> val model = trainer.fit(train)
model: org.apache.spark.ml.classification.MultilayerPerceptronClassificationModel = mlpc_edfa49fbae3c

// compute accuracy on the test set
scala> val result = model.transform(test)
result: org.apache.spark.sql.DataFrame = [label: double, features: vector ... 1 more field]
scala> val predictionAndLabels = result.select("prediction", "label")
predictionAndLabels: org.apache.spark.sql.DataFrame = [prediction: double, label: double]
scala> val evaluator = new MulticlassClassificationEvaluator().setMetricName("accuracy")
evaluator: org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator = mcEval_a4f43d85f261
scala> println("Accuracy:" + evaluator.evaluate(predictionAndLabels))
Accuracy:0.9444444444444444

Python: >>> from pyspark.ml.classification import MultilayerPerceptronClassifier
>>> from pyspark.ml.evaluation import MulticlassClassificationEvaluator
>>> from pyspark.mllib.util import MLUtils
>>>

  //Load training data
>>> data = spark.read.format("libsvm").load(      "data/mllib/sample_multiclass_classification_data.txt")

//Convert mllib vectors to ml Vectors for spark 2.0+. Retain data for previous versions
>>> data2 = MLUtils.convertVectorColumnsToML(data)
>>>

 // Split the data into train and test
>>> splits = data2.randomSplit([0.6, 0.4], seed = 1234L)
>>> train, test = splits[0], splits[1]
>>>

 // specify layers for the neural network:
 // input layer of size 4 (features), two intermediate of size 5 and 4 and output of size 3 (classes)
>>> layers = [4,5,4,3] 

// create the trainer and set its parameters
>>> trainer = MultilayerPerceptronClassifier(layers=layers, blockSize=128,
                 seed=1234L, maxIter=100)
// train the model
>>> model = trainer.fit(train)
>>>

// compute accuracy on the test set
>>> result = model.transform(test)
>>> predictionAndLabels = result.select("prediction", "label")
>>> evaluator = MulticlassClassificationEvaluator().setMetricName("accuracy")
>>> print "Accuracy:",evaluator.evaluate(predictionAndLabels)
Accuracy: 0.901960784314
>>> 

聚类技术

聚类是一种无监督学习技术,其中没有响应变量来监督模型。其思想是将具有一定相似度的数据点进行聚类。除了探索性数据分析外,它还作为有监督管道的一部分,分类器或回归器可以在不同的聚类上构建。聚类技术有很多种可用的。让我们来看看一些 Spark 支持的重要方法。

K-means 聚类

K-means 是最常见的聚类技术之一。k-means 问题是找到聚类中心,以最小化类内方差,即每个数据点与其聚类中心(与其最近的中心)之间的平方距离之和。你必须提前指定数据集中所需的聚类数目。

由于它使用欧几里得距离度量来找到数据点之间的差异,因此在使用 k-means 之前,需要将特征缩放到一个可比单位。欧几里得距离可以通过图形的方式更好地解释如下:

K-means 聚类

给定一组数据点(x1x2,...,xn),这些数据点的维度与变量的数量相同,k-means 聚类的目标是将这 n 个观测值划分为 k 个(小于n)集合,记作S = {S1,S2,...,Sk},以最小化类内平方和WCSS)。换句话说,它的目标是找到:

K-means 聚类

Spark 要求传递以下参数给这个算法:

  • k:这是所需的聚类数目。

  • maxIterations:这是要执行的最大迭代次数。

  • initializationMode:该参数指定随机初始化或通过 k-means 初始化||。

  • runs:这是运行 k-means 算法的次数(k-means 不保证找到全局最优解,当对给定数据集运行多次时,算法会返回最好的聚类结果)。

  • initializationSteps:这个参数确定 k-means||算法的步骤数。

  • epsilon:这个参数确定了我们认为 k-means 已收敛的距离阈值。

  • initialModel:这是一个可选的聚类中心集合,用于初始化。如果提供了此参数,则只执行一次运行。

k-means 的缺点

  • 它仅适用于数值特征

  • 在实现算法之前,需要进行缩放

  • 它容易受到局部最优解的影响(解决方法是 k-means++)

示例

让我们在相同的学生数据上运行 k-means 聚类。

scala> import org.apache.spark.ml.clustering.{KMeans, KMeansModel}
import org.apache.spark.ml.clustering.{KMeans, KMeansModel}
scala> import org.apache.spark.ml.linalg.Vectors
import org.apache.spark.ml.linalg.Vectors
scala>

//Define pipeline for kmeans. Reuse the previous stages in ENSEMBLES
scala> val km = new KMeans()
km: org.apache.spark.ml.clustering.KMeans = kmeans_b34da02bd7c8
scala> val kmeans_pipeline = new
Pipeline().setStages(Array(marks_bkt,att_idx,label_idx,va,km,lc))
kmeans_pipeline: org.apache.spark.ml.Pipeline = pipeline_0cd64aa93a88

//Train and transform
scala> val kmeansDF = kmeans_pipeline.fit(source_ds).transform(source_ds)
kmeansDF: org.apache.spark.sql.DataFrame = [StudentId: int, Avg_Marks: double ... 8 more fields]

//Examine results
scala> kmeansDF.filter("Label != prediction").count()
res17: Long = 13

Python

>>> from pyspark.ml.clustering import KMeans, KMeansModel
>>> from pyspark.ml.linalg import Vectors
>>> 

//Define pipeline for kmeans. Reuse the previous stages in ENSEMBLES
>>> km = KMeans()
>>> kmeans_pipeline = Pipeline(stages = [marks_bkt, att_idx, label_idx,va,km,lc])

//Train and transform
>>> kmeansDF = kmeans_pipeline.fit(source_df).transform(source_df)
>>> kmeansDF.columns
['StudentId', 'Avg_Marks', 'Attendance', 'Result', 'Mark_bins', 'Att_idx', 'Label', 'features', 'prediction', 'predictedLabel']
>>> kmeansDF.filter("Label != prediction").count()
4

摘要

在本章中,我们解释了各种机器学习算法,如何在 MLlib 库中实现它们,以及如何使用 pipeline API 进行简化的执行。概念通过 Python 和 Scala 代码示例进行了讲解,方便参考。

在下一章中,我们将讨论 Spark 如何支持 R 编程语言,重点介绍一些算法及其执行方式,类似于我们在本章中讲解的内容。

参考文献

MLlib 中支持的算法:

Spark ML 编程指南:

来自 2015 年 6 月峰会幻灯片的高级数据科学文档:

第七章:使用 SparkR 扩展 Spark

统计学家和数据科学家一直在使用 R 来解决几乎所有领域的挑战性问题,从生物信息学到选举活动。他们偏爱 R 是因为它强大的可视化功能、强大的社区以及丰富的统计学和机器学习包生态系统。全球许多学术机构都使用 R 语言教授数据科学和统计学。

R 最初是在 1990 年代中期,由统计学家为统计学家创建的,目的是提供一种更好、更用户友好的方式来进行数据分析。R 最初用于学术和研究领域。随着企业越来越意识到数据科学在业务增长中的作用,使用 R 进行数据分析的企业数据分析师数量也开始增长。经过二十年的发展,R 语言的用户基础已被认为超过两百万。

这一切成功背后的驱动力之一是 R 的设计目的是让分析师的工作更轻松,而不是让计算机的工作更轻松。R 本质上是单线程的,它只能处理完全适合单台机器内存的数据集。然而,现如今,R 用户正在处理越来越大的数据集。将现代分布式处理能力无缝集成到这个已经建立的 R 语言下,使数据科学家能够兼得二者的优势。这样,他们可以跟上日益增长的业务需求,同时继续享受自己喜爱的 R 语言的灵活性。

本章介绍了 SparkR,这是一个为 R 程序员设计的 Spark API,使他们能够利用 Spark 的强大功能,而无需学习新的语言。由于假设读者已具备 R、R Studio 及数据分析技能,本章不再介绍 R 基础知识。提供了一个非常简短的 Spark 计算引擎概述作为快速回顾。读者应阅读本书的前三章,以深入理解 Spark 编程模型和 DataFrame。这个知识非常重要,因为开发人员需要理解他编写的代码中哪些部分是在本地 R 环境中执行的,哪些部分是由 Spark 计算引擎处理的。本章涉及的主题如下:

  • SparkR 基础知识

  • R 与 Spark 的优势及其局限性

  • 使用 SparkR 编程

  • SparkR DataFrame

  • 机器学习

SparkR 基础知识

R 是一种用于统计计算和图形的语言和环境。SparkR 是一个 R 包,它提供了一个轻量级的前端,使得可以从 R 访问 Apache Spark。SparkR 的目标是将 R 环境提供的灵活性和易用性与 Spark 计算引擎提供的可扩展性和容错性相结合。在讨论 SparkR 如何实现其目标之前,我们先回顾一下 Spark 的架构。

Apache Spark 是一个快速的、通用的、容错的框架,用于对大规模分布式数据集进行交互式和迭代计算。它支持多种数据源和存储层。它提供了统一的数据访问,能够结合不同的数据格式、流数据,并使用高级可组合操作符定义复杂的操作。你可以使用 Scala、Python 或 R shell(或者 Java,无需 shell)交互式地开发应用。你可以在家用桌面上部署它,或者将它部署在大规模集群中,处理数 PB 的数据。

注意

SparkR 起源于 AMPLab (amplab.cs.berkeley.edu/),旨在探索将 R 的可用性与 Spark 的可扩展性相结合的不同技术。它作为 Apache Spark 1.4 的一个 alpha 组件发布,该版本于 2015 年 6 月发布。Spark 1.5 版本提高了 R 的可用性,并引入了带有广义线性模型GLMs)的 MLlib 机器学习包。2016 年 1 月发布的 Spark 1.6 版本增加了一些新特性,如模型摘要和特征交互。2016 年 7 月发布的 Spark 2.0 版本带来了多个重要特性,如 UDF、改进的模型覆盖、DataFrames 窗口函数 API 等等。

从 R 环境访问 SparkR

你可以从 R shell 或 R Studio 启动 SparkR。SparkR 的入口点是 SparkSession 对象,它代表了与 Spark 集群的连接。R 所运行的节点成为驱动程序。由 R 程序创建的任何对象都驻留在此驱动程序上。通过 SparkSession 创建的任何对象都在集群中的工作节点上创建。下图展示了 R 与在集群上运行的 Spark 交互的运行时视图。请注意,R 解释器存在于集群中的每个工作节点上。下图没有显示集群管理器,也没有显示存储层。你可以使用任何集群管理器(例如 Yarn 或 Mesos)和任何存储选项,如 HDFS、Cassandra 或 Amazon S3:

从 R 环境访问 SparkR

来源:http://www.slideshare.net/Hadoop_Summit/w-145p210-avenkataraman.

SparkSession 对象通过传递应用名称、内存、核心数和要连接的集群管理器等信息来创建。所有与 Spark 引擎的交互都通过这个 SparkSession 对象发起。如果使用 SparkR shell,则会自动为你创建一个 SparkSession 对象;否则,你需要显式创建它。这个对象取代了 Spark 1.x 版本中的 SparkContext 和 SQLContext 对象。为了向后兼容,这些对象仍然存在。即使前面的图展示的是 SparkContext,你也应当将其视为 Spark 2.0 后的 SparkSession。

现在我们已经理解了如何从 R 环境访问 Spark,接下来让我们来看看 Spark 引擎提供的核心数据抽象。

RDD 和 DataFrame

Spark 引擎的核心是其主要的数据抽象,称为弹性分布式数据集(Resilient Distributed Dataset,简称 RDD)。一个 RDD 由一个或多个数据源组成,并由用户定义为一系列在一个或多个稳定(具体的)数据源上的转换(也叫血统)。每个 RDD 或 RDD 分区都知道如何使用血统图在失败时重新创建自己,从而提供容错功能。RDD 是不可变的数据结构,这意味着它可以在不需要同步开销的情况下在线程间共享,因此适合并行化。对 RDD 的操作要么是转换,要么是动作。转换是血统中的单独步骤。换句话说,转换是创建 RDD 的操作,因为每个转换都是从一个稳定的数据源获取数据或转换一个不可变的 RDD,进而创建另一个 RDD。转换只是声明;它们在对 RDD 执行动作操作之前不会被评估。动作是利用 RDD 的操作。

Spark 会根据当前的操作优化 RDD 的计算。例如,如果操作是读取第一行,则只计算一个分区,跳过其余部分。当内存不足时,它会自动执行内存计算,并优雅地退化(当内存不足时,溢出到磁盘),并将处理分布到所有核心。你可以缓存一个 RDD,如果它在你的程序逻辑中频繁被访问,从而避免重新计算的开销。

R 语言提供了一种二维数据结构,叫做数据框(DataFrame),使得数据操作变得方便。Apache Spark 有自己的 DataFrame,这些 DataFrame 灵感来自于 R 和 Python(通过 Pandas)中的 DataFrame。Spark 的 DataFrame 是建立在 RDD 数据结构抽象之上的一种专用数据结构。它提供了分布式的 DataFrame 实现,从开发者的角度看,它与 R DataFrame 非常相似,同时还能支持非常大的数据集。Spark 数据集 API 为 DataFrame 增加了结构,而这一结构为底层优化提供了信息。

开始使用

现在我们已经理解了底层数据结构和运行时视图,是时候运行一些命令了。在这一部分,我们假设你已经成功安装了 R 和 Spark,并将其添加到了路径中。我们还假设已经设置了 SPARK_HOME 环境变量。接下来我们看看如何通过 R shell 或 R Studio 访问 SparkR:

> R  // Start R shell  
> Sys.getenv("SPARK_HOME") //Confirm SPARK_HOME is set 
  <Your SPARK_HOME path> 
> library(SparkR, lib.loc = 
    c(file.path(Sys.getenv("SPARK_HOME"), "R", "lib"))) 

Attaching package: 'SparkR' 
The following objects are masked from 'package:stats': 

    cov, filter, lag, na.omit, predict, sd, var, window 

The following objects are masked from 'package:base': 

    as.data.frame, colnames, colnames<-, drop, endsWith, intersect, 
    rank, rbind, sample, startsWith, subset, summary, transform, union 
> 

> //Try help(package=SparkR) if you want to more information 
//initialize SparkSession object 
>  sparkR.session()  
Java ref type org.apache.spark.sql.SparkSession id 1  
> 
Alternatively, you may launch sparkR shell which comes with predefined SparkSession. 

> bin/sparkR  // Start SparkR shell  
>      // For simplicity sake, no Log messages are shown here 
> //Try help(package=SparkR) if you want to more information 
> 

这就是你在 R 环境中访问 Spark DataFrame 的所有操作。

优势与限制

R 语言长期以来一直是数据科学家的通用语言。其易于理解的数据框架抽象、富有表现力的 API 和充满活力的包生态系统正是分析师所需要的。主要的挑战在于可扩展性。SparkR 通过提供分布式内存中的数据框架,同时保持在 R 生态系统内,弥补了这一缺陷。这样的共生关系使得用户能够获得以下好处:

  • 分析师无需学习新语言

  • SparkR 的 API 与 R 的 API 类似

  • 你可以通过 R Studio 访问 SparkR,并且可以使用自动补全功能。

  • 执行对非常大数据集的交互式、探索性分析不再受到内存限制或长时间等待的困扰。

  • 从不同类型的数据源访问数据变得更加容易。大多数之前必须显式操作的任务,现在已经转变为声明式任务。请参阅第四章统一数据访问,了解更多信息。

  • 你可以自由混合使用如 Spark 函数、SQL 和仍未在 Spark 中可用的 R 库等 dplyr。

尽管结合两者优点带来了许多令人兴奋的优势,但这种结合仍然存在一些局限性。这些局限性可能不会影响每个用例,但我们还是需要意识到它们:

  • R 的固有动态特性限制了 Catalyst 优化器能够获取的信息。与静态类型语言(如 Scala)相比,我们可能无法充分利用像谓词下推等优化。

  • SparkR 不支持 Scala API 等其他 API 中已经提供的所有机器学习算法。

总结来说,使用 Spark 进行数据预处理,使用 R 进行分析和可视化似乎是未来的最佳方案。

使用 SparkR 编程

到目前为止,我们已经理解了 SparkR 的运行时模型以及提供容错性和可扩展性的基本数据抽象。我们也了解了如何从 R shell 或 R Studio 访问 Spark API。现在是时候尝试一些基本且熟悉的操作了:

> 
> //Open the shell 
> 
> //Try help(package=SparkR) if you want to more information 
> 
> df <- createDataFrame(iris) //Create a Spark DataFrame 
> df    //Check the type. Notice the column renaming using underscore 
SparkDataFrame[Sepal_Length:double, Sepal_Width:double, Petal_Length:double, Petal_Width:double, Species:string] 
> 
> showDF(df,4) //Print the contents of the Spark DataFrame 
+------------+-----------+------------+-----------+-------+ 
|Sepal_Length|Sepal_Width|Petal_Length|Petal_Width|Species| 
+------------+-----------+------------+-----------+-------+ 
|         5.1|        3.5|         1.4|        0.2| setosa| 
|         4.9|        3.0|         1.4|        0.2| setosa| 
|         4.7|        3.2|         1.3|        0.2| setosa| 
|         4.6|        3.1|         1.5|        0.2| setosa| 
+------------+-----------+------------+-----------+-------+ 
>  
> head(df,2)  //Returns an R data.frame. Default 6 rows 
  Sepal_Length Sepal_Width Petal_Length Petal_Width Species 
1          5.1         3.5          1.4         0.2  setosa 
2          4.9         3.0          1.4         0.2  setosa 
> //You can use take(df,2) to get the same results 
//Check the dimensions 
> nrow(df) [1] 150 > ncol(df) [1] 5 

这些操作看起来与 R DataFrame 函数非常相似,因为 Spark DataFrame 的模型是基于 R DataFrame 和 Python(Pandas)DataFrame 的。但这种相似性可能会造成混淆,如果不小心,你可能会误以为负载会被分配,从而在 R data.frame 上运行计算密集型函数,导致本地计算机崩溃。例如,intersect 函数在两个包中的签名相同。你需要注意对象是否是 SparkDataFrame(Spark DataFrame)类,还是 data.frame(R DataFrame)类。你还需要尽量减少本地 R data.frame 对象与 Spark DataFrame 对象之间的反复转换。让我们通过尝试一些例子来感受这种区别:

> 
> //Open the SparkR shell 
> df <- createDataFrame(iris) //Create a Spark DataFrame 
> class(df) [1] "SparkDataFrame" attr(,"package") [1] "SparkR" 
> df2 <- head(df,2) //Create an R data frame 
> class(df2) 
 [1] "data.frame" 
> //Now try running some R command on both data frames 
> unique(df2$Species)   //Works fine as expected [1] "setosa" > unique(df$Species)    //Should fail Error in unique.default(df$Species) : unique() applies only to vectors > class(df$Species)   //Each column is a Spark's Column class [1] "Column" attr(,"package") [1] "SparkR" > class(df2$Species) [1] "character" 

函数名称遮蔽

现在我们已经尝试了一些基本操作,接下来让我们稍微跑题一下。我们必须了解当加载的库与基础包或已加载的其他包存在函数名重叠时会发生什么。这有时被称为函数名重叠、函数屏蔽或命名冲突。你可能注意到当加载 SparkR 包时,消息中提到了被屏蔽的对象。这对于任何加载到 R 环境中的包都是常见的,并不仅仅是 SparkR 特有的。如果 R 环境中已经存在一个与正在加载的包中函数同名的函数,那么随后的对该函数的调用将表现出最新加载的包中函数的行为。如果你希望访问以前的函数而不是 SparkR 函数,你需要显式地在该函数名前加上包名,如下所示:

//First try in R environment, without loading sparkR 
//Try sampling from a column in an R data.frame 
>sample(iris$Sepal.Length,6,FALSE) //Returns any n elements [1] 5.1 4.9 4.7 4.6 5.0 5.4 >sample(head(iris),3,FALSE) //Returns any 3 columns 
//Try sampling from an R data.frame 
//The Boolean argument is for with_replacement 
> sample(head 
> head(sample(iris,3,TRUE)) //Returns any 3 columns
  Species Species.1 Petal.Width
1  setosa    setosa         0.2 
2  setosa    setosa         0.2 
3  setosa    setosa         0.2 
4  setosa    setosa         0.2 
5  setosa    setosa         0.2 
6  setosa    setosa         0.4 

//Load sparkR, initialize sparkSession and then execute this  
> df <- createDataFrame(iris) //Create a Spark DataFrame 
> sample_df <- sample(df,TRUE,0.3) //Different signature 
> dim(sample_df)  //Different behavior [1] 44  5 
> //Returned 30% of the original data frame and all columns 
> //Try with base prefix 
> head(base::sample(iris),3,FALSE)  //Call base package's sample
  Species Petal.Width Petal.Length 
1  setosa         0.2          1.4
2  setosa         0.2          1.4 
3  setosa         0.2          1.3 
4  setosa         0.2          1.5 
5  setosa         0.2          1.4 
6  setosa         0.4          1.7 

子集数据

对 R 数据框的子集操作非常灵活,SparkR 尝试保留这些操作,并提供相同或类似的等效功能。我们已经在前面的示例中看到了一些操作,但本节将这些操作按顺序呈现:

//Subsetting data examples 
> b1 <- createDataFrame(beaver1) 
//Get one column 
> b1$temp 
Column temp    //Column class and not a vector 
> //Select some columns. You may use positions too 
> select(b1, c("day","temp")) 
SparkDataFrame[day:double, temp:double] 
>//Row subset based on conditions 
> head(subset(b1,b1$temp>37,select= c(2,3))) 
  time  temp 
1 1730 37.07 
2 1740 37.05 
3 1940 37.01 
4 1950 37.10 
5 2000 37.09 
6 2010 37.02 
> //Multiple conditions with AND and OR 
> head(subset(b1, between(b1$temp,c(36.0,37.0)) |  
        b1$time %in% 900 & b1$activ == 1,c(2:4)),2) 
 time  temp activ 
1  840 36.33     0 
2  850 36.34     0 

提示

在撰写本书时(Apache Spark 2.o 发布版),基于行索引的切片操作尚不可用。你将无法使用 df[n,]df[m:n,] 语法获取特定的行或行范围。

//For example, try on a normal R data.frame 
> beaver1[2:4,] 
  day time  temp activ 
2 346  850 36.34     0 
3 346  900 36.35     0 
4 346  910 36.42     0 
//Now, try on Spark Data frame 
> b1[2:4,] //Throws error 
Expressions other than filtering predicates are not supported in the first parameter of extract operator [ or subset() method. 
> 

列函数

你应该已经注意到在子集数据部分有列函数 between。这些函数作用于 Column 类。如其名称所示,这些函数一次处理单个列,通常用于子集化 DataFrame。还有其他一些常用的列函数,用于排序、类型转换和格式化等常见操作。除了处理列中的值外,你还可以向 DataFrame 添加列或删除一个或多个列。可以使用负的列下标来省略列,类似于 R。以下示例展示了在子集操作中使用 Column 类函数,随后进行添加和删除列的操作:

> //subset using Column operation using airquality dataset as df 
> head(subset(df,isNull(df$Ozone)),2) 
  Ozone Solar_R Wind Temp Month Day 
1    NA      NA 14.3   56     5   5 
2    NA     194  8.6   69     5  10 
> 
> //Add column and drop column examples 
> b1 <- createDataFrame(beaver1) 

//Add new column 
> b1$inRetreat <- otherwise(when(b1$activ == 0,"No"),"Yes") 
 head(b1,2) 
  day time  temp activ inRetreat 
1 346  840 36.33     0        No 
2 346  850 36.34     0        No 
> 
//Drop a column.  
> b1$day <- NULL 
> b1  // Example assumes b1$inRetreat does not exist 
SparkDataFrame[time:double, temp:double, activ:double] 
> //Drop columns using negative subscripts 
> b2 <- b1[,-c(1,4)]  > head(b2) 
   time  temp 
1  840 36.33 
2  850 36.34 
3  900 36.35 
4  910 36.42 
5  920 36.55 
6  930 36.69 
>  

分组数据

可以使用类似 SQL 的 group_by 函数对 DataFrame 数据进行子分组。有多种方式可以执行这样的操作。本节介绍了一个稍微复杂的示例。此外,我们使用了由 magrittr 库提供的 %>%,即前向管道操作符,它提供了一个链式命令的机制:

> //GroupedData example using iris data as df 
> //Open SparkR shell and create df using iris dataset  
> groupBy(df,"Species") 
GroupedData    //Returns GroupedData object 
> library(magrittr)  //Load the required library 
//Get group wise average sepal length 
//Report results sorted by species name 
>df2 <- df %>% groupBy("Species") %>%  
          avg("Sepal_Length") %>%  
          withColumnRenamed("avg(Sepal_Length)","avg_sepal_len") %>% 
          orderBy ("Species") 
//Format the computed double column 
df2$avg_sepal_len <- format_number(df2$avg_sepal_len,2) 
showDF(df2) 
+----------+-------------+ 
|   Species|avg_sepal_len| 
+----------+-------------+ 
|    setosa|         5.01| 
|versicolor|         5.94| 
| virginica|         6.59| 
+----------+-------------+ 

你可以继续使用前向管道操作符来链式调用操作。仔细观察代码中的列重命名部分。列名参数是先前操作的输出,这些操作在此操作开始之前已经完成,因此你可以放心假设 avg(sepal_len) 列已经存在。format_number 按预期工作,这又是一个便捷的 Column 操作。

下一节有另一个类似的示例,使用 GroupedData 及其等效的 dplyr 实现。

SparkR DataFrame

在本节中,我们尝试了一些有用的、常用的操作。首先,我们尝试了传统的 R/dplyr 操作,然后展示了使用 SparkR API 的等效操作:

> //Open the R shell and NOT SparkR shell  
> library(dplyr,warn.conflicts=FALSE)  //Load dplyr first 
//Perform a common, useful operation  
> iris %>%               
+   group_by(Species) %>% +   summarise(avg_length = mean(Sepal.Length),  
+             avg_width = mean(Sepal.Width)) %>% +   arrange(desc(avg_length)) 
Source: local data frame [3 x 3] 
     Species avg_length avg_width 
      (fctr)      (dbl)     (dbl) 
1  virginica      6.588     2.974 
2 versicolor      5.936     2.770 
3     setosa      5.006     3.428 

//Remove from R environment 
> detach("package:dplyr",unload=TRUE) 

这个操作与 SQL 中的 GROUP BY 很相似,后面跟着排序。它在 SparkR 中的等效实现也与 dplyr 示例非常相似。请看下面的示例。注意方法名称,并将它们的位置与前面的 dplyr 示例进行比较:

> //Open SparkR shell and create df using iris dataset  
> collect(arrange(summarize(groupBy(df,df$Species),  +     avg_sepal_length = avg(df$Sepal_Length), +     avg_sepal_width = avg(df$Sepal_Width)), +     "avg_sepal_length", decreasing = TRUE))  
     Species avg_sepal_length avg_sepal_width 
1     setosa            5.006           3.428 
2 versicolor            5.936           2.770 
3  virginica            6.588           2.974 

SparkR 旨在尽可能接近现有的 R API。因此,方法名称与 dplyr 方法非常相似。例如,查看这个示例,其中使用了 groupBy,而 dplyr 使用的是 group_by。SparkR 支持冗余的函数名称。例如,它同时提供 group_bygroupBy,以适应来自不同编程环境的开发者。dplyr 和 SparkR 中的方法名称再次与 SQL 关键字 GROUP BY 非常接近。但这些方法调用的顺序并不相同。示例还展示了一个额外的步骤:使用 collect 将 Spark DataFrame 转换为 R 的 data.frame。这些方法的顺序是由内而外的,意味着首先对数据进行分组,然后进行汇总,最后进行排序。这是可以理解的,因为在 SparkR 中,最内层方法中创建的 DataFrame 成为其直接前驱方法的参数,以此类推。

SQL 操作

如果你对前面的示例中的语法不太满意,你可以尝试如下编写 SQL 字符串,它和前面的操作完全相同,但使用了老式的 SQL 语法:

> //Register the Spark DataFrame as a table/View 
> createOrReplaceTempView(df,"iris_vw")  
//Look at the table structure and some rows
> collect(sql(sqlContext, "SELECT * FROM iris_tbl LIMIT 5"))
    Sepal_Length Sepal_Width Petal_Length Petal_Width Species 
1          5.1         3.5          1.4         0.2  setosa 
2          4.9         3.0          1.4         0.2  setosa 
3          4.7         3.2          1.3         0.2  setosa 
4          4.6         3.1          1.5         0.2  setosa 
5          5.0         3.6          1.4         0.2  setosa 
> //Try out the above example using SQL syntax 
> collect(sql(sqlContext, "SELECT Species,       avg(Sepal_Length) avg_sepal_length,      avg(Sepal_Width) avg_sepal_width       FROM iris_tbl        GROUP BY Species       ORDER BY avg_sepal_length desc")) 

  Species avg_sepal_length avg_sepal_width 

1  virginica            6.588           2.974 
2 versicolor            5.936           2.770 
3     setosa            5.006           3.428 

前面的示例看起来是实现当前操作最自然的方式,特别是如果你习惯从 RDBMS 表中提取数据的话。但我们是怎么做的呢?第一条语句告诉 Spark 注册一个临时表(或者,顾名思义,它是一个视图,是表的逻辑抽象)。这与数据库表并不完全相同。它是临时的,因为它会在 SparkSession 对象被销毁时被销毁。你并没有显式地将数据写入任何 RDBMS 数据存储(如果要这样做,你必须使用 SaveAsTable)。但是,一旦你将 Spark DataFrame 注册为临时表,你就可以自由使用 SQL 语法对该 DataFrame 进行操作。接下来的语句是一个基本的 SELECT 语句,显示了列名,后面跟着 LIMIT 关键字指定的五行数据。接下来的 SQL 语句创建了一个包含 Species 列的 Spark DataFrame,后面跟着两个平均值列,并按照平均花萼长度排序。这个 DataFrame 又通过 collect 被收集为 R 的 data.frame。最终结果与前面的示例完全相同。你可以自由选择使用任何一种语法。欲了解更多信息和示例,请查看第四章统一数据访问

集合操作

SparkR 中提供了常用的集合操作,例如unionintersectionminus,这些操作开箱即用。事实上,当加载 SparkR 时,警告信息显示intersect是被屏蔽的函数之一。以下示例基于beaver数据集:

> //Create b1 and b2 DataFrames using beaver1 and beaver2 datasets 
> b1 <- createDataFrame(beaver1) 
> b2 <- createDataFrame(beaver2) 
//Get individual and total counts 
> > c(nrow(b1), nrow(b2), nrow(b1) + nrow(b2)) 
[1] 114 100 214 
//Try adding both data frames using union operation 
> nrow(unionAll(b1,b2)) 
[1] 214     //Sum of two datsets 
> //intersect example 
//Remove the first column (day) and find intersection 
showDF(intersect(b1[,-c(1)],b2[,-c(1)])) 

+------+-----+-----+ 
|  time| temp|activ| 
+------+-----+-----+ 
|1100.0|36.89|  0.0| 
+------+-----+-----+ 
> //except (minus or A-B) is covered in machine learning examples   

合并 DataFrames

下一个示例演示了如何使用merge命令连接两个 DataFrame。示例的第一部分展示了 R 实现,接下来的部分展示了 SparkR 实现:

> //Example illustrating data frames merging using R (Not SparkR) 
> //Create two data frames with a matching column 
//Products df with two rows and two columns 
> products_df <- data.frame(rbind(c(101,"Product 1"), 
                    c(102,"Product 2"))) 
> names(products_df) <- c("Prod_Id","Product") 
> products_df 
 Prod_Id   Product 
1     101 Product 1 
2     102 Product 2 

//Sales df with sales for each product and month 24x3 
> sales_df <- data.frame(cbind(rep(101:102,each=12), month.abb, 
                    sample(1:10,24,replace=T)*10)) 
> names(sales_df) <- c("Prod_Id","Month","Sales") 

//Look at first 2 and last 2 rows in the sales_df 
> sales_df[c(1,2,23,24),] 
   Prod_Id Month Sales 
1      101   Jan    60 
2      101   Feb    40 
23     102   Nov    20 
24     102   Dec   100 

> //merge the data frames and examine the data 
> total_df <- merge(products_df,sales_df) 
//Look at the column names 
> colnames(total_df) 
> [1] "Prod_Id" "Product" "Month"   "Sales" 

//Look at first 2 and last 2 rows in the total_df 
> total_df[c(1,2,23,24),]     
   Prod_Id   Product Month Sales 
1      101 Product 1   Jan    10 
2      101 Product 1   Feb    20 
23     102 Product 2   Nov    60 
24     102 Product 2   Dec    10 

上述代码完全依赖于 R 的基础包。为了简便起见,我们在两个 DataFrame 中使用了相同的连接列名称。接下来的代码演示了使用 SparkR 的相同示例。它看起来与前面的代码相似,因此需要仔细观察其中的差异:

> //Example illustrating data frames merging using SparkR 
> //Create an R data frame first and then pass it on to Spark 
> //Watch out the base prefix for masked rbind function 
> products_df <- createDataFrame(data.frame( 
    base::rbind(c(101,"Product 1"), 
    c(102,"Product 2")))) 
> names(products_df) <- c("Prod_Id","Product") 
>showDF(products_df) 
+-------+---------+ 
|Prod_Id|  Product| 
+-------+---------+ 
|    101|Product 1| 
|    102|Product 2| 
+-------+---------+ 
> //Create Sales data frame 
> //Notice the as.data.frame similar to other R functions 
> //No cbind in SparkR so no need for base:: prefix 
> sales_df <- as.DataFrame(data.frame(cbind( 
             "Prod_Id" = rep(101:102,each=12), 
"Month" = month.abb, 
"Sales" = base::sample(1:10,24,replace=T)*10))) 
> //Check sales dataframe dimensions and some random rows  
> dim(sales_df) 
[1] 24  3 
> collect(sample(sales_df,FALSE,0.20)) 
  Prod_Id Month Sales 
1     101   Sep    50 
2     101   Nov    80 
3     102   Jan    90 
4     102   Jul   100 
5     102   Nov    20 
6     102   Dec    50 
> //Merge the data frames. The following merge is from SparkR library 
> total_df <- merge(products_df,sales_df) 
// You may try join function for the same purpose 
//Look at the columns in total_df 
> total_df 
SparkDataFrame[Prod_Id_x:string, Product:string, Prod_Id_y:string, Month:string, Sales:string] 
//Drop duplicate column 
> total_df$Prod_Id_y <- NULL    
> head(total_df) 
  Prod_Id_x   Product Month Sales 
1       101 Product 1   Jan    40 
2       101 Product 1   Feb    10 
3       101 Product 1   Mar    90 
4       101 Product 1   Apr    10 
5       101 Product 1   May    50 
6       101 Product 1   Jun    70 
> //Note: As of Spark 2.0 version, SparkR does not support 
    row sub-setting  

你可能想尝试不同类型的连接,如左外连接和右外连接,或使用不同的列名称,以更好地理解这个函数。

机器学习

SparkR 提供了对现有 MLLib 函数的封装。R 公式被实现为 MLLib 特征转换器。转换器是 ML 管道(spark.ml)的一个阶段,它接受 DataFrame 作为输入,并产生另一个 DataFrame 作为输出,通常包含一些附加列。特征转换器是一种转换器,将输入列转换为特征向量,这些特征向量会附加到源 DataFrame 上。例如,在线性回归中,字符串输入列被独热编码,数值被转换为双精度。一个标签列将会被附加(如果在数据框中尚未存在的话),作为响应变量的副本。

本节我们提供了朴素贝叶斯和高斯 GLM 模型的示例代码。我们不会详细解释模型本身或它们所产生的总结,而是直接演示如何使用 SparkR 来实现。

朴素贝叶斯模型

朴素贝叶斯模型是一个直观简单的模型,适用于分类数据。我们将使用朴素贝叶斯模型训练一个样本数据集。我们不会解释模型的工作原理,而是直接使用 SparkR 来训练模型。如果你想了解更多信息,请参考第六章,机器学习

本示例使用一个包含二十名学生的平均成绩和出勤数据的数据集。实际上,这个数据集已经在第六章中介绍过,机器学习,用于训练集成方法。然而,让我们重新回顾一下它的内容。

学生们根据一组明确定义的规则被授予PassFail。两个学生,ID 为10091020,尽管本应不及格,但被授予了Pass。即使我们没有为模型提供实际的规则,我们仍期望模型预测这两名学生的结果为Fail。以下是Pass / Fail标准:

  • Marks < 40 => Fail

  • 出勤不佳 => Fail

  • 成绩高于 40 且出勤为全勤 => Pass

  • 成绩 > 60 且出勤至少足够 => 通过。以下是训练朴素贝叶斯模型的示例:

//Example to train Naïve Bayes model 

//Read file 
> myFile <- read.csv("../work/StudentsPassFail.csv") //R data.frame 
> df <- createDataFrame(myFile) //sparkDataFrame 
//Look at the data 
> showDF(df,4) 
+---------+---------+----------+------+ 
|StudentId|Avg_Marks|Attendance|Result| 
+---------+---------+----------+------+ 
|     1001|     48.0|      Full|  Pass| 
|     1002|     21.0|    Enough|  Fail| 
|     1003|     24.0|    Enough|  Fail| 
|     1004|      4.0|      Poor|  Fail| 
+---------+---------+----------+------+ 

//Make three buckets out of Avg_marks 
// A >60; 40 < B < 60; C > 60 
> df$marks_bkt <- otherwise(when(df$Avg_marks < 40, "C"), 
                           when(df$Avg_marks > 60, "A")) 
> df$marks_bkt <- otherwise(when(df$Avg_marks < 40, "C"), 
                           when(df$Avg_marks > 60, "A")) 
> df <- fillna(df,"B",cols="marks_bkt") 
//Split train and test 
> trainDF <- sample(df,TRUE,0.7) 
> testDF <- except(df, trainDF) 

//Build model by supplying RFormula, training data 
> model <- spark.naiveBayes(Result ~ Attendance + marks_bkt, data = trainDF) 
> summary(model) 
$apriori 
          Fail      Pass 
[1,] 0.6956522 0.3043478 

$tables 
     Attendance_Poor Attendance_Full marks_bkt_C marks_bkt_B 
Fail 0.5882353       0.1764706       0.5882353   0.2941176   
Pass 0.125           0.875           0.125       0.625       

//Run predictions on test data 
> predictions <- predict(model, newData= testDF) 
//Examine results 
> showDF(predictions[predictions$Result != predictions$prediction, 
     c("StudentId","Attendance","Avg_Marks","marks_bkt", "Result","prediction")]) 
+---------+----------+---------+---------+------+----------+                     
|StudentId|Attendance|Avg_Marks|marks_bkt|Result|prediction| 
+---------+----------+---------+---------+------+----------+ 
|     1010|      Full|     19.0|        C|  Fail|      Pass| 
|     1019|    Enough|     45.0|        B|  Fail|      Pass| 
|     1014|      Full|     12.0|        C|  Fail|      Pass| 
+---------+----------+---------+---------+------+----------+ 
//Note that the predictions are not exactly what we anticipate but models are usually not 100% accurate 

高斯 GLM 模型

在这个示例中,我们尝试基于臭氧、太阳辐射和风速的值预测温度:

> //Example illustrating Gaussian GLM model using SparkR 
> a <- createDataFrame(airquality) 
//Remove rows with missing values 
> b <- na.omit(a) 
> //Inspect the dropped rows with missing values 
> head(except(a,b),2)    //MINUS set operation 
  Ozone Solar_R Wind Temp Month Day 
1    NA     186  9.2   84     6   4 
2    NA     291 14.9   91     7  14 

> //Prepare train data and test data 
traindata <- sample(b,FALSE,0.8) //Not base::sample 
testdata <- except(b,traindata) 

> //Build model 
> model <- glm(Temp ~ Ozone + Solar_R + Wind,  
          data = traindata, family = "gaussian") 
> // Get predictions 
> predictions <- predict(model, newData = testdata) 
> head(predictions[,c(predictions$Temp, predictions$prediction)], 
                 5) 
  Temp prediction 
1   90   81.84338 
2   79   80.99255 
3   88   85.25601 
4   87   76.99957 
5   76   71.75683 

总结

截至目前,SparkR 尚未支持 Spark 中的所有算法,但正在积极开发中以弥补这一差距。Spark 2.0 版本已经改善了算法覆盖,包括朴素贝叶斯、k-均值聚类和生存回归等。请查看最新文档了解支持的算法。更多工作正在进行,旨在推出 SparkR 的 CRAN 版本,并与 R 包和 Spark 包更好地集成,同时提供更好的 RFormula 支持。

参考文献

第八章:分析非结构化数据

在大数据时代,非结构化数据的激增让人不堪重负。存在多种方法(如数据挖掘、自然语言处理NLP)、信息检索等)来分析非结构化数据。由于各行各业中非结构化数据的快速增长,具备可扩展性的解决方案已经成为当务之急。Apache Spark 配备了现成的文本分析算法,同时也支持自定义开发默认不提供的算法。

在上一章中,我们展示了如何通过 SparkR(一个为 R 程序员提供的 Spark R API)利用 Spark 的强大功能,而无需学习一门新语言。在这一章中,我们将进入一个全新的维度,探索利用 Spark 从非结构化数据中提取信息的算法和技术。

作为本章的前提条件,具备 Python 或 Scala 编程的基本知识,以及对文本分析和机器学习的整体理解将是非常有帮助的。不过,我们已经通过合适的实践示例覆盖了一些理论基础,使得这些内容更易于理解和实施。本章涵盖的主题包括:

  • 非结构化数据的来源

  • 处理非结构化数据

    • 计数向量化

    • TF-IDF

    • 停用词去除

    • 标准化/缩放

    • Word2Vec

    • n-gram 建模

  • 文本分类

    • 朴素贝叶斯分类器
  • 文本聚类

    • K 均值算法
  • 降维

    • 奇异值分解

    • 主成分分析

  • 小结

非结构化数据的来源

数据分析自八十年代和九十年代的电子表格和 BI 工具以来,已经取得了长足进展。计算能力的巨大提升、复杂的算法以及开源文化推动了数据分析和其他领域的前所未有的增长。这些技术进步为新的机遇和挑战铺平了道路。企业开始着眼于从以往无法处理的数据源(如内部备忘录、电子邮件、客户满意度调查等)中生成洞察。如今,数据分析不仅仅局限于传统的行列数据,还涵盖了这种非结构化的、通常以文本为基础的数据。存储在关系数据库管理系统(RDBMS)中的高度结构化数据与完全非结构化的纯文本之间,我们有半结构化数据源,如 NoSQL 数据存储、XML 或 JSON 文档,以及图形或网络数据源。据当前估计,非结构化数据约占企业数据的 80%,且正在迅速增长。卫星图像、大气数据、社交网络、博客及其他网页、病历和医生记录、公司内部通讯等——这些只是非结构化数据来源的一部分。

我们已经看到了成功的数据产品,它们将非结构化数据与结构化数据相结合。一些公司利用社交网络的力量,为客户提供可操作的见解。像情感分析多媒体分析这样的新领域正在涌现,以从非结构化数据中提取见解。然而,分析非结构化数据仍然是一项艰巨的任务。例如,现代的文本分析工具和技术无法识别讽刺。然而,潜在的好处无疑大于这些局限性。

处理非结构化数据

非结构化数据不适合大多数编程任务。必须根据不同情况以多种方式处理它,才能作为任何机器学习算法的输入或进行可视化分析。大致而言,非结构化数据分析可以视为一系列步骤,如下图所示:

处理非结构化数据

数据预处理是任何非结构化数据分析中最关键的步骤。幸运的是,随着时间的推移,已经积累了多种经过验证的技术,这些技术非常实用。Spark 通过ml.features包提供了大部分这些技术。大多数技术的目的是将文本数据转换为简洁的数值向量,这些向量可以被机器学习算法轻松处理。开发人员应该理解其组织的具体需求,从而制定最佳的预处理工作流程。请记住,更好、更相关的数据是生成更好见解的关键。

让我们探索几个处理原始文本并将其转换为数据框的示例。第一个示例将一些文本作为输入,提取所有类似日期的字符串,而第二个示例则从 Twitter 文本中提取标签。第一个示例只是一个热身,使用一个简单的正则表达式(regex)标记器特征转换器,而没有使用任何 Spark 特定的库。它还引起你对误解可能性的关注。例如,格式为 1-11-1111 的产品代码可能被解释为日期。第二个示例展示了一个非平凡的、多步骤的提取过程,最终只提取了所需的标签。用户定义函数udf)和机器学习管道在开发这种多步骤提取过程中非常有用。本节的剩余部分介绍了 Apache Spark 中提供的一些其他方便的工具。

示例-1: 从文本中提取类似日期的字符串

Scala:

scala> import org.apache.spark.ml.feature.RegexTokenizer
import org.apache.spark.ml.feature.RegexTokenizer
scala> val date_pattern: String = "\\d{1,4}[/ -]\\d{1,4}[/ -]\\d{1,4}"
date_pattern: String = \d{1,4}[/ -]\d{1,4}[/ -]\d{1,4}
scala> val textDF  = spark.createDataFrame(Seq(
    (1, "Hello 1996-12-12 this 1-21-1111 is a 18-9-96 text "),
    (2, "string with dates in different 01/02/89 formats"))).
    toDF("LineNo","Text")
textDF: org.apache.spark.sql.DataFrame = [LineNo: int, Text: string]
scala> val date_regex = new RegexTokenizer().
        setInputCol("Text").setOutputCol("dateStr").
        setPattern(date_pattern).setGaps(false)
date_regex: org.apache.spark.ml.feature.RegexTokenizer = regexTok_acdbca6d1c4c
scala> date_regex.transform(textDF).select("dateStr").show(false)
+--------------------------------+
|dateStr                         |
+--------------------------------+
|[1996-12-12, 1-21-1111, 18-9-96]|
|[01/02/89]                      |
+--------------------------------+

Python:

// Example-1: Extract date like strings from text
>>> from pyspark.ml.feature import RegexTokenizer
>>> date_pattern = "\\d{1,4}[/ -]\\d{1,4}[/ -]\\d{1,4}"
>>> textDF  = spark.createDataFrame([
        [1, "Hello 1996-12-12 this 1-21-1111 is a 18-9-96 text "],
        [2, "string with dates in different 01/02/89 formats"]]).toDF(
        "LineNo","Text")
>>> date_regex = RegexTokenizer(inputCol="Text",outputCol="dateStr",
            gaps=False, pattern=date_pattern)
>>> date_regex.transform(textDF).select("dateStr").show(5,False)
+--------------------------------+
|dateStr                         |
+--------------------------------+
|[1996-12-12, 1-21-1111, 18-9-96]|
|[01/02/89]                      |
+--------------------------------+

上面的示例定义了一个正则表达式模式来识别日期字符串。正则表达式模式和示例文本数据框被传递到RegexTokenizer中以提取匹配的类似日期的字符串。gaps=False选项选择匹配的字符串,False值会将给定的模式用作分隔符。注意,1-21-1111,显然不是日期,也被选中。

下一个示例从 Twitter 文本中提取标签,并识别最流行的标签。你也可以使用相同的方法收集哈希(#)标签。

这个示例使用了内建函数explode,它将一个包含数组值的单行数据转换为多行,每行包含一个数组元素的值。

示例-2:从 Twitter 文本中提取标签

Scala:

//Step1: Load text containing @ from source file
scala> val path = "<Your path>/tweets.json"
path: String = <Your path>/tweets.json
scala> val raw_df = spark.read.text(path).filter($"value".contains("@"))
raw_df: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [value: string]
//Step2: Split the text to words and filter out non-tag words
scala> val df1 = raw_df.select(explode(split('value, " ")).as("word")).
        filter($"word".startsWith("@"))
df1: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [word: string]
//Step3: compute tag-wise counts and report top 5
scala> df1.groupBy($"word").agg(count($"word")).
        orderBy($"count(word)".desc).show(5)
+------------+-----------+
+                                                     
|        word|count(word)|
+------------+-----------+
|@ApacheSpark|         15|
|    @SSKapci|          9|
|@databricks:|          4|
|     @hadoop|          4|
| @ApacheApex|          4|
+------------+-----------+

Python:

>> from pyspark.sql.functions import explode, split
//Step1: Load text containing @ from source file
>>> path ="<Your path>/tweets.json"
>>> raw_df1 = spark.read.text(path)
>>> raw_df = raw_df1.where("value like '%@%'")
>>> 
//Step2: Split the text to words and filter out non-tag words
>>> df = raw_df.select(explode(split("value"," ")))
>>> df1 = df.where("col like '@%'").toDF("word")
>>> 
//Step3: compute tag-wise counts and report top 5
>>> df1.groupBy("word").count().sort(
     "count",ascending=False).show(5)
+------------+-----+
+                                                        
|        word|count|
+------------+-----+
|@ApacheSpark|   15|
|    @SSKapci|    9|
|@databricks:|    4|
| @ApacheApex|    4|
|     @hadoop|    4|
+------------+-----+

计数向量化

计数向量化从文档中提取词汇(词元),并在没有预定义字典的情况下生成CountVectorizerModel模型。顾名思义,文本文档被转换为包含词元和计数的向量。该模型产生词汇表上文档的稀疏表示。

你可以根据业务需求精细调整行为,限制词汇表大小、最小词元计数等。

//示例 3:计数向量化示例

Scala

scala> import org.apache.spark.ml.feature.{CountVectorizer, CountVectorizerModel}
import org.apache.spark.ml.feature.{CountVectorizer, CountVectorizerModel}
scala> import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.DataFrame
scala> import org.apache.spark.ml.linalg.Vector
import org.apache.spark.ml.linalg.Vector
scala> val df: DataFrame = spark.createDataFrame(Seq(
  (0, Array("ant", "bat", "cat", "dog", "eel")),
  (1, Array("dog","bat", "ant", "bat", "cat"))
)).toDF("id", "words")
df: org.apache.spark.sql.DataFrame = [id: int, words: array<string>]
scala>
// Fit a CountVectorizerModel from the corpus 
// Minimum occurrences (DF) is 2 and pick 10 top words(vocabsize) only scala> val cvModel: CountVectorizerModel = new CountVectorizer().
        setInputCol("words").setOutputCol("features").
        setMinDF(2).setVocabSize(10).fit(df)
cvModel: org.apache.spark.ml.feature.CountVectorizerModel = cntVec_7e79157ba561
// Check vocabulary. Words are arranged as per frequency 
// eel is dropped because it is below minDF = 2 scala> cvModel.vocabulary
res6: Array[String] = Array(bat, dog, cat, ant)
//Apply the model on document
scala> val cvDF: DataFrame = cvModel.transform(df)
cvDF: org.apache.spark.sql.DataFrame = [id: int, words: array<string> ... 1 more field]
//Check the word count scala> cvDF.select("features").collect().foreach(row =>
println(row(0).asInstanceOf[Vector].toDense))

[1.0,1.0,1.0,1.0]
[2.0,1.0,1.0,1.0]

Python:

>>> from pyspark.ml.feature import CountVectorizer,CountVectorizerModel
>>> from pyspark.ml.linalg import Vector
>>> 
// Define source DataFrame
>>> df = spark.createDataFrame([
    [0, ["ant", "bat", "cat", "dog", "eel"]],
    [1, ["dog","bat", "ant", "bat", "cat"]]
  ]).toDF("id", "words")
>>> 
// Fit a CountVectorizerModel from the corpus
// Minimum occorrences (DF) is 2 and pick 10 top words(vocabsize) only
>>> cvModel = CountVectorizer(inputCol="words", outputCol="features",
        minDF = 2, vocabSize = 10).fit(df)
>>> 
// Check vocabulary. Words are arranged as per frequency
// eel is dropped because it is below minDF = 2
>>> cvModel.vocabulary
[u'bat', u'ant', u'cat', u'dog']
//Apply the model on document
>>> cvDF = cvModel.transform(df)
//Check the word count
>>> cvDF.show(2,False)
+---+-------------------------+-------------------------------+
|id |words                    |features                       |
+---+-------------------------+-------------------------------+
|0  |[ant, bat, cat, dog, eel]|(4,[0,1,2,3],[1.0,1.0,1.0,1.0])|
|1  |[dog, bat, ant, bat, cat]|(4,[0,1,2,3],[2.0,1.0,1.0,1.0])|
+---+-------------------------+-------------------------------+

输入

 |id | text                  
 +---+-------------------------+-------------------------------+
 |0  | "ant", "bat", "cat", "dog", "eel"     
 |1  | "dog","bat", "ant", "bat", "cat"

输出

id| text                               | Vector 
--|------------------------------------|-------------------- 
0 | "ant", "bat", "cat", "dog", "eel" |[1.0,1.0,1.0,1.0] 
1 | "dog","bat", "ant", "bat", "cat"   |[2.0,1.0,1.0,1.0]

上面的示例演示了CountVectorizer如何作为估计器提取词汇并生成CountVectorizerModel模型。请注意,特征向量的顺序对应于词汇表而非输入序列。我们还可以看看如何通过预先构建字典来实现相同的功能。然而,请记住,它们各自有不同的使用场景。

示例 4:使用预先定义的词汇表定义 CountVectorizerModel

Scala:

// Example 4: define CountVectorizerModel with a-priori vocabulary
scala> val cvm: CountVectorizerModel = new CountVectorizerModel(
        Array("ant", "bat", "cat")).
        setInputCol("words").setOutputCol("features")
cvm: org.apache.spark.ml.feature.CountVectorizerModel = cntVecModel_ecbb8e1778d5

//Apply on the same data. Feature order corresponds to a-priory vocabulary order scala> cvm.transform(df).select("features").collect().foreach(row =>
        println(row(0).asInstanceOf[Vector].toDense))
[1.0,1.0,1.0]
[1.0,2.0,1.0]

Python:

在 Spark 2.0.0 版本中不可用

TF-IDF

词频-逆文档频率TF-IDF)可能是文本分析中最常用的度量之一。该度量表示某一术语在一组文档中的重要性。它由两个度量组成,词频TF)和逆文档频率IDF)。让我们逐一讨论它们,然后看看它们的结合效果。

TF 是衡量术语在文档中相对重要性的指标,通常是该术语在文档中出现的频率除以文档中的词数。假设一个文本文档包含 100 个单词,其中词apple出现了 8 次。则apple的 TF 为TF = (8 / 100) = 0.08。因此,术语在文档中出现的频率越高,它的 TF 系数越大。

IDF 是衡量特定术语在整个文档集合中重要性的指标,即该词在所有文档中出现的频率。术语的重要性与其出现频率成反比。Spark 提供了两种不同的方法来执行这些任务。假设我们有 600 万个文档,词apple出现在其中 6000 个文档中。那么,IDF 可以计算为IDF = Log(6,000,000 / 6,000) = 3。仔细观察可以发现,分母越小,IDF 值越高。这意味着包含特定词汇的文档越少,它的重要性越高。

因此,TF-IDF 分数将是TF * IDF = 0.08 * 3 = 0.24。请注意,它会对那些在文档中出现频率较高但不太重要的单词(如 thethisa 等)进行惩罚,而给那些重要的单词赋予更高的权重。

在 Spark 中,TF 实现为 HashingTF。它接受一个术语序列(通常是分词器的输出),并生成一个固定长度的特征向量。它通过特征哈希将术语转换为固定长度的索引。然后,IDF 会将该特征向量(HashingTF 的输出)作为输入,并根据文档集中的术语频率对其进行缩放。上一章有这个转换的示例。

停用词移除

常见的单词,如 iswasthe,被称为停用词。它们通常不会为分析增加价值,应在数据准备步骤中删除。Spark 提供了 StopWordsRemover 转换器,专门做这件事。它接受一系列字符串输入的标记(如分词器的输出),并移除所有停用词。Spark 默认提供一个停用词列表,你可以通过提供自己的停用词列表来覆盖它。你还可以选择启用 caseSensitive 匹配,默认情况下该选项为关闭状态。

示例 5:停用词移除器

Scala:

scala> import org.apache.spark.ml.feature.StopWordsRemover
import org.apache.spark.ml.feature.StopWordsRemover
scala> import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.DataFrame
scala> import org.apache.spark.ml.linalg.Vector
import org.apache.spark.ml.linalg.Vector
scala> val rawdataDF = spark.createDataFrame(Seq(
        (0, Array("I", "ate", "the", "cake")),
        (1, Array("John ", "had", "a", " tennis", "racquet")))).
        toDF("id","raw_text")
rawdataDF: org.apache.spark.sql.DataFrame = [id: int, raw_text: array<string>]
scala> val remover = new StopWordsRemover().setInputCol("raw_text").
                setOutputCol("processed_text")
remover: org.apache.spark.ml.feature.StopWordsRemover = stopWords_55edbac88edb
scala> remover.transform(rawdataDF).show(truncate=false)
+---+---------------------------------+-------------------------+
|id |raw_text                         |processed_text           |
+---+---------------------------------+-------------------------+
|0  |[I, ate, the, cake]              |[ate, cake]              |
|1  |[John , had, a,  tennis, racquet]|[John ,  tennis, racquet]|
+---+---------------------------------+-------------------------+

Python:

>>> from pyspark.ml.feature import StopWordsRemover
>>> RawData = sqlContext.createDataFrame([
    (0, ["I", "ate", "the", "cake"]),
    (1, ["John ", "had", "a", " tennis", "racquet"])
    ], ["id", "raw_text"])
>>> 
>>> remover = StopWordsRemover(inputCol="raw_text",
        outputCol="processed_text")
>>> remover.transform(RawData).show(truncate=False)
+---+---------------------------------+-------------------------+
|id |raw_text                         |processed_text           |
+---+---------------------------------+-------------------------+
|0  |[I, ate, the, cake]              |[ate, cake]              |
|1  |[John , had, a,  tennis, racquet]|[John ,  tennis, racquet]|
+---+---------------------------------+-------------------------+

假设我们有如下的 DataFrame,其中包含 idraw_text 列:

 id | raw_text 
----|---------- 
 0  | [I, ate, the, cake] 
 1  | [John, had, a, tennis, racquet] 

在应用 StopWordsRemover 时,以 raw_text 作为输入列,processed_text 作为输出列,针对上述示例,我们应该得到以下输出:


 id | raw_text                       | processed_text 
----|--------------------------------|-------------------- 
 0  | [I, ate, the, cake]            |  [ate, cake] 
 1  |[John, had, a, tennis, racquet] |[John, tennis, racquet] 

归一化/缩放

归一化是数据准备中的常见和初步步骤。当所有特征处于相同尺度时,大多数机器学习算法效果更好。例如,如果有两个特征,其中一个值比另一个大约高出 100 倍,将它们调整到相同的尺度可以反映这两个变量之间有意义的相对活动。任何非数值类型的值,如高、中、低,理想情况下应该转换为适当的数值量化,这是最佳实践。然而,在进行转换时需要小心,因为这可能需要领域专业知识。例如,如果你为高、中、低分别分配 3、2 和 1,那么应该检查这三个单位是否等距。

特征归一化的常见方法有缩放均值减法特征标准化,这里只列举几个。缩放中,每个数值特征向量都会被重新缩放,使其值的范围介于-1+101 之间,或类似的范围。在均值减法中,你计算一个数值特征向量的均值,并从每个值中减去这个均值。我们关注的是相对于均值的偏差,而绝对值可能不重要。特征标准化指的是将数据设置为零均值和单位(1)方差。

Spark 提供了一个 Normalizer 特征转换器,用于将每个向量规范化为单位范数;StandardScaler 用于规范化为单位范数且均值为零;MinMaxScaler 用于将每个特征缩放到特定的值范围。默认情况下,最小值和最大值为 0 和 1,但你可以根据数据需求自行设置值参数。

Word2Vec

Word2Vec 是一种主成分分析(PCA)(稍后你会更多了解)方法,它接收一个单词序列并生成一个映射(字符串,向量)。字符串是单词,向量是唯一的固定大小向量。生成的单词向量表示在许多机器学习和自然语言处理应用中非常有用,例如命名实体识别和标注。让我们来看一个示例。

示例 6:Word2Vec

Scala

scala> import org.apache.spark.ml.feature.Word2Vec
import org.apache.spark.ml.feature.Word2Vec

//Step1: Load text file and split to words scala> val path = "<Your path>/RobertFrost.txt"
path: String = <Your path>/RobertFrost.txt
scala> val raw_text = spark.read.text(path).select(
        split('value, " ") as "words")
raw_text: org.apache.spark.sql.DataFrame = [words: array<string>]

//Step2: Prepare features vector of size 4 scala> val resultDF = new Word2Vec().setInputCol("words").
        setOutputCol("features").setVectorSize(4).
        setMinCount(2).fit(raw_text).transform(raw_text)
resultDF: org.apache.spark.sql.DataFrame = [words: array<string>, features: vector]

//Examine results scala> resultDF.show(5)
+--------------------+--------------------+
|               words|            features|
+--------------------+--------------------+
|[Whose, woods, th...|[-0.0209098898340...|
|[His, house, is, ...|[-0.0013444167044...|
|[He, will, not, s...|[-0.0058525378408...|
|[To, watch, his, ...|[-0.0189630933296...|
|[My, little, hors...|[-0.0084691265597...|
+--------------------+--------------------+

Python:

>>> from pyspark.ml.feature import Word2Vec
>>> from pyspark.sql.functions import explode, split
>>>

//Step1: Load text file and split to words >>> path = "<Your path>/RobertFrost.txt"
>>> raw_text = spark.read.text(path).select(
        split("value"," ")).toDF("words")

//Step2: Prepare features vector of size 4 >>> resultDF = Word2Vec(inputCol="words",outputCol="features",
                 vectorSize=4, minCount=2).fit(
                 raw_text).transform(raw_text)

//Examine results scala> resultDF.show(5)
+--------------------+--------------------+
|               words|            features|
+--------------------+--------------------+
|[Whose, woods, th...|[-0.0209098898340...|
|[His, house, is, ...|[-0.0013444167044...|
|[He, will, not, s...|[-0.0058525378408...|
|[To, watch, his, ...|[-0.0189630933296...|
|[My, little, hors...|[-0.0084691265597...|
+--------------------+--------------------+

n-gram 建模

n-gram 是一个由给定文本或语音序列中的 n 项连续组成的序列。大小为 1 的 n-gram 称为 unigram,大小为 2 的称为 bigram,大小为 3 的称为 trigram。或者,它们也可以按 n 的值进行命名,例如四元组、五元组,依此类推。让我们看一个示例,以了解该模型可能的输出:


 input |1-gram sequence  | 2-gram sequence | 3-gram sequence 
-------|-----------------|-----------------|--------------- 
 apple | a,p,p,l,e       |  ap,pp,pl,le    |  app,ppl,ple 

这是一个将单词转换为 n-gram 字母的示例。同样的情况也适用于将句子(或分词后的单词)转换为 n-gram 单词。例如,句子 孩子们喜欢吃巧克力 的 2-gram 等效形式是:

'孩子们喜欢','喜欢','吃','吃巧克力'。

n-gram 建模在文本挖掘和自然语言处理中的应用非常广泛。一个例子是根据先前的上下文预测每个单词出现的概率(条件概率)。

在 Spark 中,NGram 是一个特征转换器,它将输入数组(例如,Tokenizer 的输出)中的字符串转换为一个 n-gram 数组。默认情况下,输入数组中的空值会被忽略。它返回一个由 n-gram 组成的数组,其中每个 n-gram 是由空格分隔的单词字符串表示的。

示例 7:NGram

Scala

scala> import org.apache.spark.ml.feature.NGram
import org.apache.spark.ml.feature.NGram
scala> val wordDF = spark.createDataFrame(Seq(
        (0, Array("Hi", "I", "am", "a", "Scientist")),
        (1, Array("I", "am", "just", "learning", "Spark")),
        (2, Array("Coding", "in", "Scala", "is", "easy"))
        )).toDF("label", "words")

//Create an ngram model with 3 words length (default is 2) scala> val ngramModel = new NGram().setInputCol(
                "words").setOutputCol("ngrams").setN(3)
ngramModel: org.apache.spark.ml.feature.NGram = ngram_dc50209cf693

//Apply on input data frame scala> ngramModel.transform(wordDF).select("ngrams").show(false)
+--------------------------------------------------+
|ngrams                                            |
+--------------------------------------------------+
|[Hi I am, I am a, am a Scientist]                 |
|[I am just, am just learning, just learning Spark]|
|[Coding in Scala, in Scala is, Scala is easy]     |
+--------------------------------------------------+

//Apply the model on another dataframe, Word2Vec raw_text scala>ngramModel.transform(raw_text).select("ngrams").take(1).foreach(println)
[WrappedArray(Whose woods these, woods these are, these are I, are I think, I think I, think I know.)]

Python:

>>> from pyspark.ml.feature import NGram
>>> wordDF = spark.createDataFrame([
         [0, ["Hi", "I", "am", "a", "Scientist"]],
         [1, ["I", "am", "just", "learning", "Spark"]],
         [2, ["Coding", "in", "Scala", "is", "easy"]]
         ]).toDF("label", "words")

//Create an ngram model with 3 words length (default is 2) >>> ngramModel = NGram(inputCol="words", outputCol= "ngrams",n=3)
>>> 

//Apply on input data frame >>> ngramModel.transform(wordDF).select("ngrams").show(4,False)
+--------------------------------------------------+
|ngrams                                            |
+--------------------------------------------------+
|[Hi I am, I am a, am a Scientist]                 |
|[I am just, am just learning, just learning Spark]|
|[Coding in Scala, in Scala is, Scala is easy]     |
+--------------------------------------------------+

//Apply the model on another dataframe from Word2Vec example >>> ngramModel.transform(resultDF).select("ngrams").take(1)
[Row(ngrams=[u'Whose woods these', u'woods these are', u'these are I', u'are I think', u'I think I', u'think I know.'])]

文本分类

文本分类是将一个主题、学科类别、类型或类似内容分配给文本块。例如,垃圾邮件过滤器会将邮件标记为垃圾邮件或非垃圾邮件。

Apache Spark 通过 MLlib 和 ML 包支持各种分类器。SVM 分类器和朴素贝叶斯分类器是常用的分类器,前者已在前一章中讲解过。现在让我们来看后者。

朴素贝叶斯分类器

朴素贝叶斯 (NB) 分类器是一种多类别的概率分类器,是最好的分类算法之一。它假设每对特征之间具有强独立性。它计算每个特征和给定标签的条件概率分布,然后应用贝叶斯定理计算给定观察值下标签的条件概率。在文档分类中,一个观察值就是待分类的文档。尽管它对数据有较强的假设,它仍然非常流行。它适用于少量训练数据——无论是真实数据还是离散数据。它工作非常高效,因为它只需要通过训练数据进行一次遍历;唯一的限制是特征向量必须是非负的。默认情况下,机器学习包支持多项式朴素贝叶斯。然而,如果需要伯努利朴素贝叶斯,可以将参数 modelType 设置为 Bernoulli

拉普拉斯平滑 技术可以通过指定平滑参数来应用,在需要为稀有词或新词分配一个小的非零概率的情况下,它非常有用,以避免后验概率突然降至零。

Spark 还提供了一些其他的超参数,如 thresholds,以便获得更细粒度的控制。以下是一个分类推特文本的示例。该示例包含一些手工编写的规则,用于为训练数据分配类别。如果文本中包含对应的词语,则会分配特定类别。例如,如果文本包含“survey”或“poll”,则类别为“survey”。模型基于这些训练数据进行训练,并在不同时间收集的不同文本样本上进行评估:

示例 8:朴素贝叶斯

Scala:

// Step 1: Define a udf to assign a category // One or more similar words are treated as one category (eg survey, poll)
// If input list contains any of the words in a category list, it is assigned to that category
// "General" is assigned if none of the categories matched
scala> import scala.collection.mutable.WrappedArray
import scala.collection.mutable.WrappedArray
scala> val findCategory = udf ((words: WrappedArray[String]) =>
    { var idx = 0; var category : String = ""
    val categories : List[Array[String]] =  List(
     Array("Python"), Array("Hadoop","hadoop"),
     Array("survey","poll"),
      Array("event","training", "Meetup", "summit",
          "talk", "talks", "Setting","sessions", "workshop"),
     Array("resource","Guide","newsletter", "Blog"))
    while(idx < categories.length && category.isEmpty ) {
        if (!words.intersect(categories(idx)).isEmpty) {
         category = categories(idx)(0) }  //First word in the category list
     idx += 1 }
    if (category.isEmpty) {
    category = "General"  }
    category
  })
findCategory: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(ArrayType(StringType,true))))

//UDF to convert category to a numerical label scala> val idxCategory = udf ((category: String) =>
        {val catgMap = Map({"General"->1},{"event"->2},{"Hadoop"->3},
                             {"Python"->4},{"resource"->5})
         catgMap(category)})
idxCategory: org.apache.spark.sql.expressions.UserDefinedFunction =
UserDefinedFunction(<function1>,IntegerType,Some(List(StringType)))
scala> val labels = Array("General","event","Hadoop","Python","resource")
 //Step 2: Prepare train data 
//Step 2a: Extract "text" data and split to words scala> val path = "<Your path>/tweets_train.txt"
path: String = <Your path>../work/tweets_train.txt
scala> val pattern = ""text":"
pattern: String = "text":
scala> val raw_text = spark.read.text(path).filter($"value".contains(pattern)).
               select(split('value, " ") as "words")
raw_text: org.apache.spark.sql.DataFrame = [words: array<string>]
scala>

//Step 2b: Assign a category to each line scala> val train_cat_df = raw_text.withColumn("category",

findCategory(raw_text("words"))).withColumn("label",idxCategory($"category"))
train_cat_df: org.apache.spark.sql.DataFrame = [words: array<string>, category:
string ... 1 more field]

//Step 2c: Examine categories scala> train_cat_df.groupBy($"category").agg(count("category")).show()
+--------+---------------+                                                     
|category|count(category)|
+--------+---------------+
| General|            146|
|resource|              1|
|  Python|              2|
|   event|             10|
|  Hadoop|              6|
+--------+---------------+ 

//Step 3: Build pipeline scala> import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.feature.{StopWordsRemover, CountVectorizer,
                  IndexToString}
import org.apache.spark.ml.feature.{StopWordsRemover, CountVectorizer,
StringIndexer, IndexToString}
scala> import org.apache.spark.ml.classification.NaiveBayes
import org.apache.spark.ml.classification.NaiveBayes
scala>

//Step 3a: Define pipeline stages 
//Stop words should be removed first scala> val stopw = new StopWordsRemover().setInputCol("words").
                setOutputCol("processed_words")
stopw: org.apache.spark.ml.feature.StopWordsRemover = stopWords_2fb707daa92e
//Terms to term frequency converter scala> val cv = new CountVectorizer().setInputCol("processed_words").
             setOutputCol("features")
cv: org.apache.spark.ml.feature.CountVectorizer = cntVec_def4911aa0bf
//Define model scala> val model = new NaiveBayes().
                setFeaturesCol("features").
                setLabelCol("label")
model: org.apache.spark.ml.classification.NaiveBayes = nb_f2b6c423f12c
//Numerical prediction label to category converter scala> val lc = new IndexToString().setInputCol("prediction").
              setOutputCol("predictedCategory").
              setLabels(labels)
lc: org.apache.spark.ml.feature.IndexToString = idxToStr_3d71be25382c
 //Step 3b: Build pipeline with desired stages scala> val p = new Pipeline().setStages(Array(stopw,cv,model,lc))
p: org.apache.spark.ml.Pipeline = pipeline_956942e70b3f
 //Step 4: Process train data and get predictions 
//Step 4a: Execute pipeline with train data scala> val resultsDF = p.fit(train_cat_df).transform(train_cat_df)
resultsDF: org.apache.spark.sql.DataFrame = [words: array<string>, category:
string ... 7 more fields]

//Step 4b: Examine results scala> resultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
|   event|            event|
|   event|            event|
| General|          General|
+--------+-----------------+
 //Step 4c: Look for prediction mismatches scala> resultsDF.filter("category != predictedCategory").select(
         "category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| General|            event|
| General|           Hadoop|
|resource|           Hadoop|
+--------+-----------------+
 //Step 5: Evaluate model using test data 
//Step5a: Prepare test data scala> val path = "<Your path> /tweets.json"
path: String = <Your path>/tweets.json
scala> val raw_test_df =
spark.read.text(path).filter($"value".contains(pattern)).
               select(split('value, " ") as "words"

raw_test_df: org.apache.spark.sql.DataFrame = [words: array<string>]
scala> val test_cat_df = raw_test_df.withColumn("category",

findCategory(raw_test_df("words")))withColumn("label",idxCategory($"category"))
test_cat_df: org.apache.spark.sql.DataFrame = [words: array<string>, category:
string ... 1 more field]
scala> test_cat_df.groupBy($"category").agg(count("category")).show()
+--------+---------------+                                                     
|category|count(category)|
+--------+---------------+
| General|              6|
|   event|             11|
+--------+---------------+
 //Step 5b: Run predictions on test data scala> val testResultsDF = p.fit(test_cat_df).transform(test_cat_df)
testResultsDF: org.apache.spark.sql.DataFrame = [words: array<string>,
category: string ... 7 more fields]
//Step 5c:: Examine results
scala> testResultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| General|            event|
|   event|          General|
|   event|          General|
+--------+-----------------+

//Step 5d: Look for prediction mismatches scala> testResultsDF.filter("category != predictedCategory").select(
         "category","predictedCategory").show()
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
|   event|          General|
|   event|          General|
+--------+-----------------+

Python:

// Step 1: Initialization 
//Step1a: Define a udfs to assign a category // One or more similar words are treated as one category (eg survey, poll)
// If input list contains any of the words in a category list, it is assigned to that category
// "General" is assigned if none of the categories matched
>>> def findCategory(words):
        idx = 0; category  = ""
        categories = [["Python"], ["Hadoop","hadoop"],
          ["survey","poll"],["event","training", "Meetup", "summit",
          "talk", "talks", "Setting","sessions", "workshop"],
          ["resource","Guide","newsletter", "Blog"]]
        while(not category and idx < len(categories)):
          if len(set(words).intersection(categories[idx])) > 0:
             category = categories[idx][0] #First word in the category list
          else:
             idx+=1
        if not category:   #No match found
          category = "General"
        return category
>>> 
//Step 1b: Define udf to convert string category to a numerical label >>> def idxCategory(category):
       catgDict = {"General" :1, "event" :2, "Hadoop" :2,
             "Python": 4, "resource" : 5}
       return catgDict[category]
>>> 
//Step 1c: Register UDFs >>> from pyspark.sql.functions import udf
>>> from pyspark.sql.types import StringType, IntegerType
>>> findCategoryUDF = udf(findCategory, StringType())
>>> idxCategoryUDF = udf(idxCategory, IntegerType())

//Step 1d: List categories >>> categories =["General","event","Hadoop","Python","resource"]
//Step 2: Prepare train data 
//Step 2a: Extract "text" data and split to words >>> from pyspark.sql.functions import split
>>> path = "../work/tweets_train.txt"
>>> raw_df1 = spark.read.text(path)
>>> raw_df = raw_df1.where("value like '%"text":%'").select(
             split("value", " ")).toDF("words")

//Step 2b: Assign a category to each line >>> train_cat_df = raw_df.withColumn("category",\
        findCategoryUDF("words")).withColumn(
        "label",idxCategoryUDF("category"))

//Step 2c: Examine categories scala> train_cat_df.groupBy("category").count().show()
+--------+---------------+                                                     
|category|count(category)|
+--------+---------------+
| General|            146|
|resource|              1|
|  Python|              2|
|   event|             10|
|  Hadoop|              6|
+--------+---------------+

//Step 3: Build pipeline >>> from pyspark.ml import Pipeline
>>> from pyspark.ml.feature import StopWordsRemover, CountVectorizer,
IndexToString
>>> from pyspark.ml.classification import NaiveBayes
>>>

//Step 3a: Define pipeline stages 
//Stop words should be removed first >>> stopw = StopWordsRemover(inputCol = "words",
                  outputCol = "processed_words")
//Terms to term frequency converter >>> cv = CountVectorizer(inputCol = "processed_words",
             outputCol = "features")
//Define model >>> model = NaiveBayes(featuresCol="features",
                   labelCol = "label")
//Numerical prediction label to category converter >>> lc = IndexToString(inputCol = "prediction",
           outputCol = "predictedCategory",
           labels = categories)
>>> 

//Step 3b: Build pipeline with desired stages >>> p = Pipeline(stages = [stopw,cv,model,lc])
>>> 
 //Step 4: Process train data and get predictions 
//Step 4a: Execute pipeline with train data >>> resultsDF = p.fit(train_cat_df).transform(train_cat_df)

//Step 4b: Examine results >>> resultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
|   event|            event|
|   event|            event|
| General|          General|
+--------+-----------------+
 //Step 4c: Look for prediction mismatches >>> resultsDF.filter("category != predictedCategory").select(
         "category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
|  Python|           Hadoop|
|  Python|           Hadoop|
|  Hadoop|            event|
+--------+-----------------+
 //Step 5: Evaluate model using test data 
//Step5a: Prepare test data >>> path = "<Your path>/tweets.json">>> raw_df1 = spark.read.text(path)
>>> raw_test_df = raw_df1.where("va
ue like '%"text":%'").select(
               split("value", " ")).toDF("words")
>>> test_cat_df = raw_test_df.withColumn("category",
        findCategoryUDF("words")).withColumn(
        "label",idxCategoryUDF("category"))
>>> test_cat_df.groupBy("category").count().show()
+--------+---------------+                                                     
|category|count(category)|
+--------+---------------+
| General|              6|
|   event|             11|
+--------+---------------+
 //Step 5b: Run predictions on test data >>> testResultsDF = p.fit(test_cat_df).transform(test_cat_df)
//Step 5c:: Examine results >>> testResultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| General|          General|
|   event|            event|
|   event|            event|
+--------+-----------------+
//Step 5d: Look for prediction mismatches >>> testResultsDF.filter("category != predictedCategory").select(
         "category","predictedCategory").show()
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
|   event|          General|
|   event|          General|
+--------+-----------------+

完成此步骤后,可以使用该步骤的输出训练一个模型,该模型可以对文本块或文件进行分类。

文本聚类

聚类是一种无监督学习技术。直观地说,聚类将对象分组到不相交的集合中。我们不知道数据中有多少组,也不知道这些组(簇)之间可能有什么共同之处。

文本聚类有多种应用。例如,一个组织实体可能希望根据某种相似性度量将其内部文档组织成相似的簇。相似性或距离的概念是聚类过程的核心。常用的度量方法有 TF-IDF 和余弦相似度。余弦相似度或余弦距离是两个文档的词频向量的余弦乘积。Spark 提供了多种聚类算法,可在文本分析中有效使用。

K-means

也许 K-means 是所有聚类算法中最直观的一种。其思路是根据某些相似性度量方法(如余弦距离或欧几里得距离)将数据点划分为 K 个不同的簇。该算法从 K 个随机的单点簇开始,然后将每个剩余的数据点分配到最近的簇中。接着重新计算簇的中心,并再次遍历数据点。这个过程会反复进行,直到没有重新分配数据点,或达到预定义的迭代次数。

如何确定簇的数量(K)并不是显而易见的。确定初始簇中心也不是显而易见的。有时业务需求可能会决定簇的数量;例如,将所有现有文档划分为 10 个不同的部分。但在大多数实际场景中,我们需要通过反复试验来确定 K。一种方法是逐步增加 K 值并计算簇的质量,例如簇的方差。当 K 值超过某个特定值时,簇的质量不会显著提升,这个 K 值可能就是理想的 K。还有各种其他技术,如肘部法则、赤池信息量准则AIC)和 贝叶斯信息量准则BIC)。

同样,使用不同的起始点,直到簇的质量令人满意为止。然后你可能希望使用如轮廓系数等技术来验证结果。然而,这些活动计算量很大。

Spark 提供了来自 MLlib 和 ml 包的 K-means。你可以指定最大迭代次数或收敛容忍度来优化算法的性能。

降维

想象一个拥有许多行和列的大矩阵。在许多矩阵应用中,这个大矩阵可以通过一些行列较少的狭窄矩阵来表示,而这些矩阵仍能代表原始矩阵。然后,处理这个较小的矩阵可能会产生与原始矩阵相似的结果。这种方法在计算上可能更高效。

降维是关于寻找那个小矩阵的。MLlib 支持两种算法:SVD 和 PCA,用于 RowMatrix 类的降维。这两种算法都允许我们指定感兴趣的维度数量。让我们先看一个例子,然后深入探讨其中的理论。

示例 9:降维

Scala:

scala> import scala.util.Random
import scala.util.Random
scala> import org.apache.spark.mllib.linalg.{Vector, Vectors}
import org.apache.spark.mllib.linalg.{Vector, Vectors}
scala> import org.apache.spark.mllib.linalg.distributed.RowMatrix
import org.apache.spark.mllib.linalg.distributed.RowMatrix

//Create a RowMatrix of 6 rows and 5 columns scala> var vlist: Array[Vector] = Array()
vlist: Array[org.apache.spark.mllib.linalg.Vector] = Array()
scala> for (i <- 1 to 6) vlist = vlist :+ Vectors.dense(
       Array.fill(5)(Random.nextInt*1.0))
scala> val rows_RDD = sc.parallelize(vlist)
rows_RDD: org.apache.spark.rdd.RDD[org.apache.spark.mllib.linalg.Vector] =
ParallelCollectionRDD[0] at parallelize at <console>:29
scala> val row_matrix = new RowMatrix(rows_RDD)
row_matrix: org.apache.spark.mllib.linalg.distributed.RowMatrix = org.apache.spark.mllib.linalg.distributed.RowMatrix@348a6639
 //SVD example for top 3 singular values scala> val SVD_result = row_matrix.computeSVD(3)
SVD_result:
org.apache.spark.mllib.linalg.SingularValueDecomposition[org.apache.spark.mlli
.linalg.distributed.RowMatrix,org.apache.spark.mllib.linalg.Matrix] =
SingularValueDecomposition(null,
[4.933482776606544E9,3.290744495921952E9,2.971558550447048E9],
-0.678871347405378    0.054158900880961904  -0.23905281217240534
0.2278187940802       -0.6393277579229861   0.078663353163388
0.48824560481341733   0.3139021297613471    -0.7800061948839081
-0.4970903877201546   2.366428606359744E-4  -0.3665502780139027
0.041829015676406664  0.6998515759330556    0.4403374382132576    )

scala> SVD_result.s   //Show the singular values (strengths)
res1: org.apache.spark.mllib.linalg.Vector =
[4.933482776606544E9,3.290744495921952E9,2.971558550447048E9]

//PCA example to compute top 2 principal components scala> val PCA_result = row_matrix.computePrincipalComponents(2)
PCA_result: org.apache.spark.mllib.linalg.Matrix =
-0.663822435334425    0.24038790854106118
0.3119085619707716    -0.30195355896094916
0.47440026368044447   0.8539858509513869
-0.48429601343640094  0.32543904517535094
-0.0495437635382354   -0.12583837216152594

Python:

在 Spark 2.0.0 版本中,Python 不支持此功能。

奇异值分解

奇异值分解SVD)是线性代数的核心内容之一,广泛应用于许多实际的建模需求。它提供了一种将矩阵分解成更简单、更小的矩阵的便捷方法。这导致了高维矩阵的低维表示。它帮助我们消除矩阵中不重要的部分,从而生成一个近似的表示。此技术在降维和数据压缩中非常有用。

M 为一个大小为 m 行 n 列的矩阵。矩阵的秩是指矩阵中线性无关的行数。如果一行包含至少一个非零元素,并且它不是一行或多行的线性组合,那么该行被认为是独立的。如果我们考虑列而非行(如线性代数中的定义),则会得到相同的秩。

如果一行的元素是两行的和,那么该行不是独立的。随后,通过 SVD 分解,我们得到三个矩阵 UV,它们满足以下方程:

M = U∑VT

这三个矩阵具有以下特性:

  • U:这是一个具有 m 行和 r 列的列正交规范矩阵。正交规范矩阵意味着每一列都是单位向量,且任意两列的点积为 0。

  • V:这是一个具有 n 行和 r 列的列正交规范矩阵。

  • :这是一个 r x r 的对角矩阵,主对角线上的值是按降序排列的非负实数。在对角矩阵中,除了主对角线上的元素外,其它元素都为零。

矩阵中的主对角线值称为奇异值。它们被视为连接矩阵行和列的基础概念成分。它们的大小表示对应成分的强度。例如,假设前面提到的矩阵包含六个读者对五本书的评分。SVD 可以将其分解为三个矩阵: 包含代表基础主题强度的奇异值;U 连接人和概念;V 连接概念和书籍。

在大矩阵中,我们可以将较小的奇异值替换为零,从而减少剩余两个矩阵中相应行的维度。注意,如果我们重新计算右边的矩阵乘积并将其与左边的原矩阵进行比较,它们将几乎相同。我们可以使用这种方法来保留所需的维度数。

主成分分析

主成分分析 (PCA) 是一种将 n 维数据点投影到一个较小(维度更少)的子空间的技术,同时尽量减少信息的丢失。高维空间中的一组数据点会找到使这些数据点排列最佳的方向。换句话说,我们需要找到一种旋转方式,使得第一个坐标具有可能的最大方差,每个后续坐标依次具有最大的方差。这个思路是将数据集作为一个矩阵 M,并找到 MMT 的特征向量。

如果* A 是一个方阵, e 是一个列矩阵,行数与 A 相同,且 λ 是一个常数,使得 Me = λe ,那么 e 被称为 M 的特征向量, λ 被称为 M *的特征值。在 n 维平面上,特征向量是方向,特征值是沿该方向的方差度量。我们可以丢弃特征值较低的维度,从而找到一个较小的子空间而不会丢失信息。

总结

在本章中,我们探讨了非结构化数据的来源以及分析非结构化数据背后的动机。我们解释了在预处理非结构化数据时需要的各种技术,以及 Spark 如何提供大部分这些工具。我们还介绍了 Spark 支持的一些可以用于文本分析的算法。

在下一章中,我们将介绍不同类型的可视化技术,这些技术在数据分析生命周期的不同阶段提供了深刻的见解。

参考文献:

以下是参考文献:

计数向量化:

n-gram 建模:

第九章:可视化大数据

正确的数据可视化在过去解决了许多商业问题,而无需过多依赖统计学或机器学习。即使在今天,随着技术进步、应用统计学和机器学习的发展,合适的视觉呈现依然是商业用户获取信息或分析结果的最终交付物。传递正确的信息、以正确的格式展现,这是数据科学家所追求的,而一个有效的可视化比百万个词语还要有价值。此外,以易于商业用户理解的方式呈现模型和生成的洞察至关重要。尽管如此,以可视化方式探索大数据非常繁琐且具有挑战性。由于 Spark 是为大数据处理设计的,它也支持大数据的可视化。为了这个目的,基于 Spark 构建了许多工具和技术。

前几章概述了如何对结构化和非结构化数据建模并从中生成洞察。在本章中,我们将从两个广泛的角度来看待数据可视化——一个是数据科学家的角度,数据可视化是有效探索和理解数据的基本需求;另一个是商业用户的角度,视觉呈现是交付给商业用户的最终成果,必须易于理解。我们将探索多种数据可视化工具,如IPythonNotebookZeppelin,这些工具可以在 Apache Spark 上使用。

本章的前提是,你应该对 SQL 以及 Python、Scala 或其他类似框架的编程有基本的了解。 本章涵盖的主题如下:

  • 为什么要可视化数据?

    • 数据工程师的角度

    • 数据科学家的角度

    • 商业用户的角度

  • 数据可视化工具

    • IPython notebook

    • Apache Zeppelin

    • 第三方工具

  • 数据可视化技巧

    • 总结和可视化

    • 子集和可视化

    • 抽样和可视化

    • 建模和可视化

为什么要可视化数据?

数据可视化是以可视化的形式呈现数据,以帮助人们理解数据背后的模式和趋势。地理地图、十七世纪的条形图和折线图是早期数据可视化的一些例子。Excel 可能是我们最熟悉的数据可视化工具,许多人已经使用过。所有的数据分析工具都配备了复杂的、互动的数据可视化仪表盘。然而,近年来大数据、流式数据和实时分析的激增推动了这些工具的边界,似乎已经达到了极限。目标是使可视化看起来简单、准确且相关,同时隐藏所有复杂性。根据商业需求,任何可视化解决方案理想情况下应该具备以下特点:

  • 交互性

  • 可重复性

  • 控制细节

除此之外,如果解决方案允许用户在可视化或报告上进行协作并相互共享,那么这将构成一个端到端的可视化解决方案。

特别是大数据可视化面临着自身的挑战,因为我们可能会遇到数据比屏幕上的像素还多的情况。处理大数据通常需要大量的内存和 CPU 处理,且可能存在较长的延迟。如果再加入实时或流数据,这个问题将变得更加复杂。Apache Spark 从一开始就被设计用来通过并行化 CPU 和内存使用来解决这种延迟。在探索可视化和处理大数据的工具和技术之前,我们首先要了解数据工程师、数据科学家和业务用户的可视化需求。

数据工程师的视角

数据工程师在几乎所有数据驱动的需求中都发挥着至关重要的作用:从不同的数据源获取数据,整合数据,清洗和预处理数据,分析数据,最终通过可视化和仪表板进行报告。其活动可以大致总结如下:

  • 可视化来自不同来源的数据,以便能够整合并合并它们,形成一个单一的数据矩阵

  • 可视化并发现数据中的各种异常,如缺失值、异常值等(这可能发生在抓取、数据源获取、ETL 等过程中),并进行修复

  • 向数据科学家提供有关数据集的属性和特征的建议

  • 探索多种可能的方式来可视化数据,并根据业务需求最终确定那些更具信息性和直观性的方式

请注意,数据工程师不仅在数据源获取和准备过程中发挥关键作用,还负责决定最适合业务用户的可视化输出。他们通常与业务部门密切合作,以便对业务需求和当前具体问题有非常清晰的理解。

数据科学家的视角

数据科学家在可视化数据方面的需求与数据工程师不同。请注意,在某些业务中,可能有专业人员同时承担数据工程师和数据科学家的双重角色。

数据科学家需要可视化数据,以便在进行统计分析时做出正确的决策,并确保分析项目的顺利执行。他们希望以多种方式切片和切块数据,以发现隐藏的洞察。让我们看看数据科学家在可视化数据时可能需要的一些示例需求:

  • 查看各个变量的数据分布

  • 可视化数据中的异常值

  • 可视化数据集中所有变量的缺失数据百分比

  • 绘制相关性矩阵以查找相关的变量

  • 绘制回归后的残差行为

  • 在数据清洗或转换活动后,重新绘制变量图,并观察其表现

请注意,刚才提到的一些内容与数据工程师的情况非常相似。然而,数据科学家可能在这些分析背后有更科学/统计的意图。例如,数据科学家可能会从不同的角度看待一个离群值并进行统计处理,而数据工程师则可能考虑导致这一现象的多种可能选项。

业务用户的视角

业务用户的视角与数据工程师或数据科学家的视角完全不同。业务用户通常是信息的消费者!他们希望从数据中提取更多的信息,为此,正确的可视化工具至关重要。此外,如今大多数业务问题都更加复杂且具因果关系。传统的报告已经不再足够。我们来看一些业务用户希望从报告、可视化和仪表板中提取的示例查询:

  • 在某个地区,谁是高价值客户?

  • 这些客户的共同特征是什么?

  • 预测一个新客户是否会是高价值客户

  • 在哪个媒体上做广告能获得最大的投资回报率?

  • 如果我不在报纸上做广告会怎样?

  • 影响客户购买行为的因素有哪些?

数据可视化工具

在众多可视化选项中,选择合适的可视化工具取决于特定的需求。同样,选择可视化工具也取决于目标受众和业务需求。

数据科学家或数据工程师通常会偏好一个更为互动的控制台,用于快速且粗略的分析。他们使用的可视化工具通常不面向业务用户。数据科学家或数据工程师倾向于从各个角度解构数据,以获得更有意义的洞察。因此,他们通常会更喜欢支持这些活动的笔记本类型界面。笔记本是一个互动的计算环境,用户可以在其中结合代码块并绘制数据进行探索。像IPython/JupyterDataBricks等笔记本就是可用的选项之一。

业务用户更喜欢直观且信息丰富的可视化,这样他们可以相互分享或用来生成报告。他们期望通过可视化得到最终结果。市场上有成百上千种工具,包括一些流行工具,如Tableau,企业都在使用;但通常,开发人员必须为一些独特的需求定制特定类型,并通过 Web 应用程序展示它们。微软的PowerBI和开源解决方案如Zeppelin就是一些例子。

IPython 笔记本

基于 Spark 的PySpark API 之上的 IPython/Jupyter 笔记本是数据科学家探索和可视化数据的绝佳组合。笔记本内部启动了一个新的 PySpark 内核实例。还有其他内核可用;例如,Apache 的Toree内核可以用来支持 Scala。

对于许多数据科学家来说,这是默认选择,因为它能够将文本、代码、公式和图形集成在一个 JSON 文档文件中。IPython 笔记本支持matplotlib,它是一个可以生成生产质量视觉效果的二维可视化库。生成图表、直方图、散点图、图形等变得既简单又容易。它还支持seaborn库,实际上这是建立在 matplotlib 基础上的,但它易于使用,因为它提供了更高级的抽象,隐藏了底层的复杂性。

Apache Zeppelin

Apache Zeppelin 建立在 JVM 之上,并与 Apache Spark 良好集成。它是一个基于浏览器或前端的开源工具,拥有自己的笔记本。它支持 Scala、Python、R、SQL 及其他图形模块,作为一种可视化解决方案,不仅为业务用户,也为数据科学家服务。在接下来的可视化技术部分中,我们将看看 Zeppelin 如何支持 Apache Spark 代码来生成有趣的可视化效果。你需要下载 Zeppelin(zeppelin.apache.org/)以尝试这些示例。

第三方工具

有许多产品支持 Apache Spark 作为底层数据处理引擎,并且是为了适应组织的大数据生态系统而构建的。它们利用 Spark 的处理能力,提供支持各种交互式视觉效果的可视化界面,并且支持协作。Tableau 就是一个利用 Spark 的工具示例。

数据可视化技术

数据可视化是数据分析生命周期每个阶段的核心。它对探索性分析和结果传达尤为重要。在这两种情况下,目标都是将数据转换成对人类处理高效的格式。将转换委托给客户端库的方法无法扩展到大数据集。转换必须在服务器端进行,只将相关数据发送到客户端进行渲染。Apache Spark 开箱即用地提供了大多数常见的转换。让我们仔细看看这些转换。

总结和可视化

总结和可视化 是许多商业智能BI)工具使用的一种技术。由于总结无论底层数据集的大小如何,都会生成简明的数据集,因此图表看起来足够简单并且易于渲染。有多种方法可以总结数据,例如聚合、透视等。如果渲染工具支持交互性并且具备下钻功能,用户可以从完整数据中探索感兴趣的子集。我们将展示如何通过 Zeppelin 笔记本快速、互动地进行总结。

以下图片展示了 Zeppelin 笔记本,包含源代码和分组条形图。数据集包含 24 条观测数据,记录了两个产品 P1P2 在 12 个月中的销售信息。第一个单元格包含用于读取文本文件并将数据注册为临时表的代码。此单元格使用默认的 Spark 解释器 Scala。第二个单元格使用 SQL 解释器,支持开箱即用的可视化选项。你可以通过点击右侧图标切换图表类型。请注意,Scala、Python 或 R 解释器的可视化效果是相似的。

汇总示例如下:

  1. 用于读取数据并注册为 SQL 视图的源代码:

    Scala(默认)

    总结和可视化

    PySpark

    总结和可视化

    R

    总结和可视化

    这三者都在读取数据文件并注册为临时 SQL 视图。请注意,前面的三个脚本中存在一些细微的差异。例如,我们需要为 R 移除表头行并设置列名。下一步是生成可视化,它是通过 %sql 解释器工作的。下图显示了生成每个产品季度销售额的脚本。它还显示了现成的图表类型,以及设置和选择。做出选择后,你可以折叠设置。你甚至可以使用 Zeppelin 内置的动态表单,例如在运行时接受产品输入。第二张图显示了实际输出。

  2. 用于生成两个产品季度销售额的脚本:总结和可视化

  3. 产生的输出:总结和可视化

我们在前面的示例中已经看到了 Zeppelin 的内置可视化。但我们也可以使用其他绘图库。我们的下一个示例使用 PySpark 解释器与 matplotlib 在 Zeppelin 中绘制直方图。此示例代码使用 RDD 的直方图函数计算桶间隔和桶计数,并仅将这些汇总数据传送到驱动节点。频率作为权重绘制桶,以提供与正常直方图相同的视觉效果,但数据传输非常低。

直方图示例如下:

总结和可视化

这是生成的输出(它可能作为一个单独的窗口弹出):

总结和可视化

在前面的直方图准备示例中,请注意,桶计数可以通过内置的动态表单支持进行参数化。

子集化和可视化

有时,我们可能拥有一个大型数据集,但只对其中的一部分感兴趣。“分而治之”是一种方法,我们一次探索数据的一个小部分。Spark 允许使用类似 SQL 的过滤器和聚合对行列数据集以及图形数据进行数据子集化。让我们首先执行 SQL 子集化,然后是 GraphX 示例。

以下示例使用 Zeppelin 提供的银行数据,并提取与管理者相关的几个相关数据列。它使用google 可视化库绘制气泡图。数据是使用 PySpark 读取的。数据子集化和可视化是通过 R 完成的。请注意,我们可以选择任何解释器来执行这些任务,这里选择的是随意的。

使用 SQL 进行数据子集化的示例如下:

  1. 读取数据并注册 SQL 视图:子集化与可视化

  2. 子集化管理者数据并显示气泡图:子集化与可视化

下一个示例演示了一些使用 斯坦福网络分析项目SNAP)提供的数据的 GraphX 处理。该脚本提取了一个包含给定节点集的子图。在这里,每个节点代表一个 Facebook ID,一条边代表两个节点(或人)之间的连接。此外,脚本还识别了给定节点(ID:144)的直接连接。这些是一级节点。然后,它识别这些一级节点的直接连接,这些形成了给定节点的二级节点。即使一个二级联系人可能与多个一级联系人连接,它也只显示一次,从而形成没有交叉边的连接树。由于连接树可能包含太多节点,脚本将一级和二级连接限制为最多三个连接,从而在给定的根节点下仅显示 12 个节点(一个根节点 + 三个一级节点 + 每个二级节点的三个连接)。

Scala

//Subset and visualize 
//GraphX subset example 
//Datasource: http://snap.stanford.edu/data/egonets-Facebook.html  
import org.apache.spark.graphx._ 
import org.apache.spark.graphx.util.GraphGenerators 
//Load edge file and create base graph 
val base_dir = "../data/facebook" 
val graph = GraphLoader.edgeListFile(sc,base_dir + "/0.edges") 

//Explore subgraph of a given set of nodes 
val circle = "155  99  327  140  116  147  144  150  270".split("\t").map( 
       x=> x.toInt) 
val subgraph = graph.subgraph(vpred = (id,name) 
     => circle.contains(id)) 
println("Edges: " + subgraph.edges.count +  
       " Vertices: " + subgraph.vertices.count) 

//Create a two level contact tree for a given node  
//Step1: Get all edges for a given source id 
val subgraph_level1 = graph.subgraph(epred= (ed) =>  
    ed.srcId == 144) 

//Step2: Extract Level 1 contacts 
import scala.collection.mutable.ArrayBuffer 
val lvl1_nodes : ArrayBuffer[Long] = ArrayBuffer() 
subgraph_level1.edges.collect().foreach(x=> lvl1_nodes+= x.dstId) 

//Step3: Extract Level 2 contacts, 3 each for 3 lvl1_nodes 
import scala.collection.mutable.Map 
val linkMap:Map[Long, ArrayBuffer[Long]] = Map() //parent,[Child] 
val lvl2_nodes : ArrayBuffer[Long] = ArrayBuffer() //1D Array 
var n : ArrayBuffer[Long] = ArrayBuffer() 
for (i <- lvl1_nodes.take(3)) {    //Limit to 3 
    n = ArrayBuffer() 
    graph.subgraph(epred = (ed) => ed.srcId == i && 
        !(lvl2_nodes contains ed.dstId)).edges.collect(). 
             foreach(x=> n+=x.dstId) 
    lvl2_nodes++=n.take(3)    //Append to 1D array. Limit to 3 
  linkMap(i) = n.take(3)  //Assign child nodes to its parent 
 } 

 //Print output and examine the nodes 
 println("Level1 nodes :" + lvl1_nodes) 
 println("Level2 nodes :" + lvl2_nodes) 
 println("Link map :" + linkMap) 

 //Copy headNode to access from another cell 
 z.put("headNode",144) 
 //Make a DataFrame out of lvl2_nodes and register as a view 
 val nodeDF = sc.parallelize(linkMap.toSeq).toDF("parentNode","childNodes") 
 nodeDF.createOrReplaceTempView("node_tbl") 

注意

请注意 z.putz.get 的使用。这是 Zeppelin 中用于交换单元格/解释器之间数据的一种机制。

现在我们已经创建了一个包含一级联系人及其直接联系人的数据框架,我们准备好绘制树形图了。以下脚本使用了图形可视化库 igraph 和 Spark R。

提取节点和边。绘制树形图:

子集化与可视化

上述脚本从节点表中获取父节点,这些父节点是第 2 级节点的父节点,也是与给定头节点的直接连接。创建头节点与第 1 级节点的有序对,并将其分配给 edges1。接下来的步骤是展开第 2 级节点数组,将每个数组元素形成一行。由此获得的数据框被转置并粘贴,形成边对。由于粘贴操作将数据转换为字符串,因此需要重新转换为数字。这些就是第 2 级边。第 1 级和第 2 级边被合并,形成一个边的单一列表。这些边接着被用来形成图形,如下所示。请注意,headNode 中的模糊值为 144,虽然在下图中不可见:

子集选择与可视化

给定节点的连接树

抽样与可视化

抽样和可视化技术已被统计学家使用了很长时间。通过抽样技术,我们从数据集中提取一部分数据并进行处理。我们将展示 Spark 如何支持不同的抽样技术,如随机抽样分层抽样sampleByKey等。以下示例是在 Jupyter notebook 中创建的,使用了 PySpark 核心和 seaborn 库。数据文件是 Zeppelin 提供的银行数据集。第一个图展示了每个教育类别的余额,颜色表示婚姻状况。

读取数据并随机抽取 5% 的样本:

抽样与可视化

使用 stripplot 渲染数据:

抽样与可视化

上述示例展示了随机抽样的可用数据,这比完全绘制整体数据要好得多。但是,如果感兴趣的分类变量(在本例中是 education)的级别过多,那么这个图表就会变得难以阅读。例如,如果我们想绘制工作类别的余额而不是 education,将会有太多的条形,使得图像看起来凌乱不堪。相反,我们可以只抽取所需类别级别的样本,然后再进行数据分析。请注意,这与子集选择不同,因为在正常的 SQL WHERE 子集选择中我们无法指定样本比例。我们需要使用 sampleByKey 来实现这一点,如下所示。以下示例只取了两种工作类别,并且有特定的抽样比例:

抽样与可视化

分层抽样

建模与可视化

使用 Spark 的MLLibML模块可以进行建模与可视化。Spark 的统一编程模型和多样化的编程接口使得将这些技术结合到一个环境中,从数据中获取洞察成为可能。我们已经在前几章中涵盖了大多数建模技术。然而,以下是一些供参考的示例:

  • 聚类:K-means、Gaussian 混合模型

  • 分类与回归:线性模型、决策树、朴素贝叶斯、支持向量机(SVM)

  • 降维:奇异值分解,主成分分析

  • 协同过滤

  • 统计测试:相关性,假设检验

以下示例选自第七章,使用 SparkR 扩展 Spark,该示例试图使用朴素贝叶斯模型预测学生的合格或不合格结果。其思路是利用 Zeppelin 提供的开箱即用功能,检查模型的行为。因此,我们加载数据,进行数据准备,构建模型并运行预测。然后,我们将预测结果注册为 SQL 视图,以便利用内置的可视化功能:

//Model visualization example using zeppelin visualization  
 Prepare Model and predictions 

建模与可视化

下一步是编写所需的 SQL 查询并定义适当的设置。请注意 SQL 中的 UNION 操作符的使用以及匹配列的定义方式。

定义 SQL 以查看模型表现:

建模与可视化

以下图片帮助我们理解模型预测与实际数据的偏差。这样的可视化对于获取业务用户的反馈非常有用,因为它们不需要任何数据科学的先验知识就能理解:

建模与可视化

可视化模型表现

我们通常使用误差指标来评估统计模型,但通过图形化展示而不是查看数字使其更加直观,因为通常理解图表比理解表格中的数字更容易。例如,前面的可视化可以让非数据科学领域的人也容易理解。

总结

在本章中,我们探讨了在大数据环境中 Spark 支持的最常用可视化工具和技术。我们通过代码片段解释了一些技术,以便更好地理解数据分析生命周期各个阶段的可视化需求。我们还展示了如何通过适当的可视化技术解决大数据的挑战,以满足业务需求。

下一章将是我们迄今为止解释的所有概念的总结。我们将通过一个示例数据集走完完整的数据分析生命周期。

参考文献

数据来源引用

银行数据来源(引用)

  • [Moro et al., 2011] S. Moro, R. Laureano 和 P. Cortez. 使用数据挖掘进行银行直销营销:CRISP-DM 方法的应用

  • 见 P. Novais 等人(编辑),《欧洲仿真与建模会议论文集 - ESM'2011》,第 117-121 页,葡萄牙吉马良斯,2011 年 10 月,EUROSIS

  • 可通过 [pdf] hdl.handle.net/1822/14838 获取

  • [bib] http://www3.dsi.uminho.pt/pcortez/bib/2011-esm-1.txt

Facebook 数据来源(引用)

  • J. McAuley 和 J. Leskovec. 学习在自我网络中发现社交圈。NIPS, 2012.

第十章:整合所有内容

大数据分析正在革新企业运营方式,并为许多前所未有的机会铺平道路。几乎每个企业、个人研究人员或调查记者都有大量数据需要处理。我们需要一种简洁的方法,从原始数据出发,根据当前的问题得出有意义的洞察。

在之前的章节中,我们已经讨论了使用 Apache Spark 进行数据科学的各个方面。我们从讨论大数据分析需求以及 Apache Spark 如何适应这些需求开始。逐步地,我们探讨了 Spark 编程模型、RDD 和 DataFrame 抽象,并学习了 Spark 数据集如何通过连续应用的流式处理方面实现统一数据访问。接着,我们覆盖了使用 Apache Spark 进行数据分析生命周期的全貌,随后是机器学习的内容。我们学习了在 Spark 上进行结构化和非结构化数据分析,并探索了面向数据工程师、科学家以及业务用户的可视化方面。

所有之前讨论的章节帮助我们理解了每个章节中的一个简洁方面。现在我们已经具备了穿越整个数据科学生命周期的能力。在这一章节中,我们将通过一个端到端的案例研究,应用我们迄今为止学到的所有内容。我们不会引入任何新的概念;这将帮助我们应用已获得的知识并加深理解。然而,我们会重复一些概念,避免过多细节,使这一章节能够自成一体。本章所覆盖的主题大致与数据分析生命周期中的步骤相同:

  • 快速回顾

  • 引入案例研究

  • 构建业务问题

  • 数据获取与数据清洗

  • 提出假设

  • 数据探索

  • 数据准备

  • 模型构建

  • 数据可视化

  • 向业务用户传达结果

  • 总结

快速回顾

我们已经在不同章节中详细讨论了典型数据科学项目中的各个步骤。让我们快速回顾一下我们已经覆盖的内容,并简要提及一些重要方面。以下图表展示了这些步骤的高层次概述:

快速回顾

在前面的图示中,我们尝试从更高层次解释数据科学项目中的步骤,这些步骤通常适用于许多数据科学任务。每个阶段实际上都包含更多的子步骤,但可能因项目不同而有所差异。

对数据科学家来说,一开始找到最佳方法和步骤是非常困难的。通常,数据科学项目没有像软件开发生命周期SDLC)那样明确的生命周期。数据科学项目通常会因为周期性步骤而导致交付延迟,而且这些步骤是反复迭代的。此外,跨团队的循环依赖也增加了复杂性并导致执行延迟。然而,在处理大数据分析项目时,数据科学家遵循一个明确的数据科学工作流程,无论业务案例如何,都显得尤为重要且有利。这不仅有助于组织执行,还能帮助我们保持专注于目标,因为数据科学项目在大多数情况下天生是敏捷的。同时,建议你为任何项目规划一些关于数据、领域和算法的研究。

在这一章中,我们可能无法将所有细节步骤放在一个流程中,但我们会涉及到一些重要的部分,为你提供一个概览。我们将尝试看一些之前章节未涉及的编码示例。

引入案例研究

在本章中,我们将探索奥斯卡奖的受众人口统计信息。你可以从 GitHub 仓库下载数据:www.crowdflower.com/wp-content/uploads/2016/03/Oscars-demographics-DFE.csv

该数据集基于www.crowdflower.com/data-for-everyone提供的数据。它包含了种族、出生地和年龄等人口统计信息。数据行大约有 400 条,可以在简单的家庭计算机上轻松处理,因此你可以在 Spark 上执行一个概念验证POC)来进行数据科学项目的尝试。

只需下载文件并检查数据。数据看起来可能没问题,但当你仔细查看时,你会发现它并不“干净”。例如,出生日期这一列没有统一的格式。有些年份是两位数字格式,而有些则是四位数字格式。出生地列在美国境内的地点没有包含国家信息。

同样,你会注意到数据看起来有偏差,来自美国的“白人”种族人数较多。但你可能会觉得,趋势在近几年发生了变化。到目前为止,你还没有使用任何工具或技术,只是对数据进行了快速浏览。在数据科学的实际工作中,这种看似微不足道的活动可能在整个生命周期中非常有帮助。你能够逐步对手头的数据形成感觉,并同时对数据提出假设。这将带你进入工作流程的第一步。

商业问题

如前所述,任何数据科学项目中最重要的方面是手头的问题。清楚地理解我们要解决什么问题?对项目的成功至关重要。它还决定了什么数据被视为相关,什么数据不相关。例如,在当前的案例研究中,如果我们要关注的是人口统计信息,那么电影名称和人物名称就是不相关的。有时,手头没有具体问题!那怎么办?即使没有具体问题,商业可能仍然有一些目标,或者数据科学家和领域专家可以合作,找到需要解决的商业领域。为了理解商业、职能、问题陈述或数据,数据科学家首先会进行“提问”。这不仅有助于定义工作流程,还能帮助寻找正确的数据来源。

举个例子,如果商业关注点是人口统计信息,那么可以定义一个正式的商业问题陈述:

种族和原籍地对奥斯卡奖获得者的影响是什么?

在现实场景中,这一步骤不会如此简单。提出正确的问题是数据科学家、战略团队、领域专家和项目负责人共同的责任。因为如果不服务于目的,整个过程都是徒劳的,所以数据科学家必须咨询所有相关方,并尽可能从他们那里获取信息。然而,他们最终可能会得到一些宝贵的见解或“直觉”。所有这些信息共同构成了初步假设的核心,并帮助数据科学家理解他们应该寻找什么。

在没有具体问题的情况下,商业试图找出答案的情形更为有趣,但执行起来可能更复杂!

数据采集与数据清洗

数据采集是逻辑上的下一步。它可能仅仅是从一个电子表格中选择数据,也可能是一个复杂的、持续几个月的项目。数据科学家必须尽可能多地收集相关数据。这里的“相关”是关键词。记住,更多相关的数据胜过聪明的算法。

我们已经介绍了如何从异构数据源中获取数据并将其整合成一个单一的数据矩阵,因此这里不再重复这些基础知识。相反,我们从一个单一来源获取数据,并提取其中的一个子集。

现在是时候查看数据并开始清洗它了。本章中呈现的脚本通常比之前的示例更长,但仍然不能算作生产级别的质量。实际工作中需要更多的异常检查和性能调优:

Scala

//Load tab delimited file 
scala> val fp = "<YourPath>/Oscars.txt" 
scala> val init_data = spark.read.options(Map("header"->"true", "sep" -> "\t","inferSchema"->"true")).csv(fp) 
//Select columns of interest and ignore the rest 
>>> val awards = init_data.select("birthplace", "date_of_birth", 
        "race_ethnicity","year_of_award","award").toDF( 
         "birthplace","date_of_birth","race","award_year","award") 
awards: org.apache.spark.sql.DataFrame = [birthplace: string, date_of_birth: string ... 3 more fields] 
//register temporary view of this dataset 
scala> awards.createOrReplaceTempView("awards") 

//Explore data 
>>> awards.select("award").distinct().show(10,false) //False => do not truncate 
+-----------------------+                                                        
|award                  | 
+-----------------------+ 
|Best Supporting Actress| 
|Best Director          | 
|Best Actress           | 
|Best Actor             | 
|Best Supporting Actor  | 
+-----------------------+ 
//Check DOB quality. Note that length varies based on month name 
scala> spark.sql("SELECT distinct(length(date_of_birth)) FROM awards ").show() 
+---------------------+                                                          
|length(date_of_birth)| 
+---------------------+ 
|                   15| 
|                    9| 
|                    4| 
|                    8| 
|                   10| 
|                   11| 
+---------------------+ 

//Look at the value with unexpected length 4 Why cant we show values for each of the length type ?  
scala> spark.sql("SELECT date_of_birth FROM awards WHERE length(date_of_birth) = 4").show() 
+-------------+ 
|date_of_birth| 
+-------------+ 
|         1972| 
+-------------+ 
//This is an invalid date. We can either drop this record or give some meaningful value like 01-01-1972 

Python

    //Load tab delimited file
    >>> init_data = spark.read.csv("<YOURPATH>/Oscars.txt",sep="\t",header=True)
    //Select columns of interest and ignore the rest
    >>> awards = init_data.select("birthplace", "date_of_birth",
            "race_ethnicity","year_of_award","award").toDF(
             "birthplace","date_of_birth","race","award_year","award")
    //register temporary view of this dataset
    >>> awards.createOrReplaceTempView("awards")
    scala>
    //Explore data
    >>> awards.select("award").distinct().show(10,False) //False => do not truncate
    +-----------------------+                                                       
    |award                  |
    +-----------------------+
    |Best Supporting Actress|
    |Best Director          |
    |Best Actress           |
    |Best Actor             |
    |Best Supporting Actor  |
    +-----------------------+
    //Check DOB quality
    >>> spark.sql("SELECT distinct(length(date_of_birth)) FROM awards ").show()
    +---------------------+                                                         
    |length(date_of_birth)|
    +---------------------+
    |                   15|
    |                    9|
    |                    4|
    |                    8|
    |                   10|
    |                   11|
    +---------------------+
    //Look at the value with unexpected length 4\. Note that length varies based on month name
    >>> spark.sql("SELECT date_of_birth FROM awards WHERE length(date_of_birth) = 4").show()
    +-------------+
    |date_of_birth|
    +-------------+
    |         1972|
    +-------------+
    //This is an invalid date. We can either drop this record or give some meaningful value like 01-01-1972

Most of the datasets contain a date field and unless they come from a single, controlled data source, it is highly likely that they will differ in their formats and are almost always a candidate for cleaning.

对于手头的数据集,你可能也注意到 date_of_birthbirthplace 需要大量清理。以下代码展示了两个 用户定义函数UDFs),分别清理 date_of_birthbirthplace。这些 UDF 每次处理单个数据元素,它们只是普通的 Scala/Python 函数。为了能够在 SQL 语句中使用,这些用户定义的函数应该被注册。最后一步是创建一个清理后的数据框,参与进一步的分析。

注意下面的清理 birthplace 的逻辑。它是一个比较简单的逻辑,因为我们假设任何以两个字符结尾的字符串都是美国的一个州。我们需要将其与有效的州缩写列表进行比较。同样,假设两位数的年份总是来自二十世纪也是一个容易出错的假设。根据实际情况,数据科学家/数据工程师需要决定是否保留更多行,或者只包括质量更高的数据。所有这些决策应该清晰地记录下来,供后续参考:

Scala:

//UDF to clean date 
//This function takes 2 digit year and makes it 4 digit 
// Any exception returns an empty string 
scala> def fncleanDate(s:String) : String = {  
  var cleanedDate = "" 
  val dateArray: Array[String] = s.split("-") 
  try{    //Adjust year 
     var yr = dateArray(2).toInt 
     if (yr < 100) {yr = yr + 1900 } //make it 4 digit 
     cleanedDate = "%02d-%s-%04d".format(dateArray(0).toInt, 
                dateArray(1),yr) 
     } catch { case e: Exception => None } 
     cleanedDate } 
fncleanDate: (s: String)String 

Python:

    //This function takes 2 digit year and makes it 4 digit
    // Any exception returns an empty string
    >>> def fncleanDate(s):
          cleanedDate = ""
          dateArray = s.split("-")
          try:    //Adjust year
             yr = int(dateArray[2])
             if (yr < 100):
                  yr = yr + 1900 //make it 4 digit
             cleanedDate = "{0}-{1}-{2}".format(int(dateArray[0]),
                      dateArray[1],yr)
          except :
              None
          return cleanedDate

清理日期的 UDF 接受一个带有连字符的日期字符串并将其拆分。如果最后一个组件(即年份)是两位数,那么假设它是二十世纪的日期,并加上 1900 将其转换为四位数格式。

以下 UDF 会将国家设置为美国(USA),如果国家字符串是“纽约市”或者最后一个组件是两个字符长,这时假设它是美国的一个州:

//UDF to clean birthplace 
// Data explorartion showed that  
// A. Country is omitted for USA 
// B. New York City does not have State code as well 
//This function appends country as USA if 
// A. the string contains New York City  (OR) 
// B. if the last component is of length 2 (eg CA, MA) 
scala> def fncleanBirthplace(s: String) : String = { 
        var cleanedBirthplace = "" 
        var strArray : Array[String] =  s.split(" ") 
        if (s == "New York City") 
           strArray = strArray ++ Array ("USA") 
        //Append country if last element length is 2 
        else if (strArray(strArray.length-1).length == 2) 
            strArray = strArray ++ Array("USA") 
        cleanedBirthplace = strArray.mkString(" ") 
        cleanedBirthplace } 

Python:

    >>> def fncleanBirthplace(s):
            cleanedBirthplace = ""
            strArray = s.split(" ")
            if (s == "New York City"):
                strArray += ["USA"]  //Append USA
            //Append country if last element length is 2
            elif (len(strArray[len(strArray)-1]) == 2):
                strArray += ["USA"]
            cleanedBirthplace = " ".join(strArray)
            return cleanedBirthplace

如果想通过 SELECT 字符串访问 UDF,应该注册 UDF:

Scala:

//Register UDFs 
scala> spark.udf.register("fncleanDate",fncleanDate(_:String)) 
res10: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType))) 
scala> spark.udf.register("fncleanBirthplace", fncleanBirthplace(_:String)) 
res11: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType))) 

Python:

    >>> from pyspark.sql.types import StringType
    >>> sqlContext.registerFunction("cleanDateUDF",fncleanDate, StringType())
    >>> sqlContext.registerFunction( "cleanBirthplaceUDF",fncleanBirthplace, StringType())

使用 UDF 清理数据框。执行以下清理操作:

  1. 调用 UDF fncleanDatefncleanBirthplace 来修正出生地和国家。

  2. award_year 中减去出生年份以获取获奖时的 age

  3. 保留 raceaward 原样。

Scala:

//Create cleaned data frame 
scala> var cleaned_df = spark.sql ( 
            """SELECT fncleanDate (date_of_birth) dob, 
               fncleanBirthplace(birthplace) birthplace, 
               substring_index(fncleanBirthplace(birthplace),' ',-1)  
                               country, 
               (award_year - substring_index(fncleanDate( date_of_birth),'-',-1)) age, race, award FROM awards""") 
cleaned_df: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 4 more fields] 

Python:

//Create cleaned data frame 
>>> from pyspark.sql.functions import substring_index>>> cleaned_df = spark.sql (            """SELECT cleanDateUDF (date_of_birth) dob,               cleanBirthplaceUDF(birthplace) birthplace,               substring_index(cleanBirthplaceUDF(birthplace),' ',-1) country,               (award_year - substring_index(cleanDateUDF( date_of_birth),               '-',-1)) age, race, award FROM awards""")

最后一行需要一些解释。UDF 的使用类似于 SQL 函数,且表达式被别名为有意义的名称。我们添加了一个计算列 age,因为我们希望验证年龄的影响。substring_index 函数搜索第一个参数中的第二个参数。-1 表示从右侧查找第一个出现的值。

制定假设

假设是你关于结果的最佳猜测。你根据问题、与利益相关者的对话以及查看数据来形成初步假设。你可能会为给定的问题形成一个或多个假设。这个初步假设作为一张路线图,引导你进行探索性分析。制定假设对于统计学上批准或不批准一个声明非常重要,而不仅仅是通过查看数据矩阵或视觉效果。这是因为仅凭数据可能会导致错误的认知,甚至可能具有误导性。

现在你知道,最终结果可能会证明假设是正确的,也可能证明是假设错误的。对于我们本课考虑的案例研究,我们得出了以下初步假设:

  • 获奖者大多是白人

  • 大多数获奖者来自美国

  • 最佳演员和最佳女演员往往比最佳导演年轻

现在我们已经正式化了假设,准备好进行生命周期中的下一步。

数据探索

现在我们有了一个干净的数据框,其中包含相关数据和初步假设,是时候真正探索我们拥有的内容了。DataFrames 抽象提供了像group by这样的函数,帮助你进行探索。你也可以将清理过的数据框注册为表格,并运行经过时间验证的 SQL 语句来完成相同的操作。

这也是绘制一些图表的时机。这一可视化阶段是数据可视化章节中提到的探索性分析。这个探索的目标在很大程度上受你从业务利益相关者那里获得的初步信息和假设的影响。换句话说,你与利益相关者的讨论帮助你了解要寻找什么。

有一些通用的指南适用于几乎所有的数据科学任务,但也会根据不同的使用场景而有所不同。我们来看一些通用的指南:

  • 查找缺失数据并进行处理。我们在第五章,在 Spark 上进行数据分析中已经讨论过各种处理方法。

  • 查找数据集中的离群值并进行处理。我们也讨论过这一方面。请注意,有些情况下,我们认为的离群值和正常数据点可能会根据使用场景而变化。

  • 执行单变量分析,在这个过程中,你会分别探索数据集中的每一个变量。频率分布或百分位分布是非常常见的。也许你可以绘制一些图表,以获得更清晰的理解。这还将帮助你在进行数据建模之前准备数据。

  • 验证你的初步假设。

  • 检查数值数据的最小值和最大值。如果某一列的变化范围过大,可能需要进行数据标准化或缩放处理。

  • 检查分类数据中的不同值(如城市名等字符串值)及其频率。如果某一列的不同值(也就是层级)过多,可能需要寻找减少层级数量的方法。如果某一层级几乎总是出现,那么该列对于模型区分可能的结果没有帮助,这样的列很可能是移除的候选。在探索阶段,你只需找出这些候选列,真正的操作可以留给数据准备阶段来处理。

在我们当前的数据集中,没有缺失数据,也没有可能带来挑战的数值数据。然而,在处理无效日期时,可能会有一些缺失值出现。因此,以下代码涵盖了剩余的操作项。假设cleaned_df已经创建:

Scala/Python:

cleaned_df = cleaned_df.na.drop //Drop rows with missing values 
cleaned_df.groupBy("award","country").count().sort("country","award","count").show(4,False) 
+-----------------------+---------+-----+                                        
|award                  |country  |count| 
+-----------------------+---------+-----+ 
|Best Actor             |Australia|1    | 
|Best Actress           |Australia|1    | 
|Best Supporting Actor  |Australia|1    | 
|Best Supporting Actress|Australia|1    | 
+-----------------------+---------+-----+ 
//Re-register data as table 
cleaned_df.createOrReplaceTempView("awards") 
//Find out levels (distinct values) in each categorical variable 
spark.sql("SELECT count(distinct country) country_count, count(distinct race) race_count, count(distinct award) award_count from awards").show() 
+-------------+----------+-----------+                                           
|country_count|race_count|award_count| 
+-------------+----------+-----------+ 
|           34|         6|          5| 
+-------------+----------+-----------+ 

以下可视化图表对应于最初的假设。请注意,我们的两个假设是正确的,但第三个假设是错误的。这些可视化图表是使用 zeppelins 创建的:

数据探索

需要注意的是,并非所有假设都可以仅通过可视化来验证,因为可视化有时可能会具有误导性。因此,需要进行适当的统计检验,例如 t 检验、方差分析(ANOVA)、卡方检验、相关性检验等,根据实际情况进行。我们将在本节中不详细讨论这些内容。请参阅第五章,Spark 上的数据分析,了解更多详情。

数据准备

数据探索阶段帮助我们识别了在进入建模阶段之前需要修复的所有问题。每个问题都需要仔细思考和讨论,以选择最佳的修复方法。以下是一些常见问题和可能的解决方法。最佳修复方法取决于当前的问题和/或业务背景。

类别变量中的层级过多

这是我们面临的最常见问题之一。解决这个问题的方法取决于多个因素:

  • 如果某列几乎总是唯一的,例如,它是一个交易 ID 或时间戳,那么除非你从中衍生出新的特征,否则它不会参与建模。你可以安全地删除该列而不会丢失任何信息内容。通常,你会在数据清理阶段就删除它。

  • 如果可以用较粗粒度的层级(例如,使用州或国家代替城市)来替代当前层级,并且在当前上下文中是合理的,那么通常这是解决此问题的最佳方法。

  • 你可能需要为每个不同的层级添加一个虚拟列,值为 0 或 1。例如,如果你在单个列中有 100 个层级,你可以添加 100 列。每次观察(行)中最多只有一个列会为 1。这就是独热编码(one-hot encoding),Spark 通过ml.features包默认提供此功能。

  • 另一种选择是保留最频繁的层级。你甚至可以将每个这些层级与一个“较近”的主层级进行关联。此外,你可以将其余的层级归为一个单独的桶,例如Others

  • 对于层级数量的绝对限制并没有硬性规定。这取决于每个特性所需的粒度以及性能限制。

当前数据集中,类别变量country有太多的层级。我们选择保留最频繁的层级,并将其余的层级归为Others

Scala:

//Country has too many values. Retain top ones and bundle the rest 
//Check out top 6 countries with most awards. 
scala> val top_countries_df = spark.sql("SELECT country, count(*) freq FROM awards GROUP BY country ORDER BY freq DESC LIMIT 6") 
top_countries_df: org.apache.spark.sql.DataFrame = [country: string, freq: bigint] 
scala> top_countries_df.show() 
+-------+----+                                                                   
|country|freq| 
+-------+----+ 
|    USA| 289| 
|England|  57| 
| France|   9| 
| Canada|   8| 
|  Italy|   7| 
|Austria|   7| 
+-------+----+ 
//Prepare top_countries list 
scala> val top_countries = top_countries_df.select("country").collect().map(x => x(0).toString) 
top_countries: Array[String] = Array(USA, England, New York City, France, Canada, Italy) 
//UDF to fix country. Retain top 6 and bundle the rest into "Others" 
scala> import org.apache.spark.sql.functions.udf 
import org.apache.spark.sql.functions.udf 
scala > val setCountry = udf ((s: String) => 
        { if (top_countries.contains(s)) {s} else {"Others"}}) 
setCountry: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType))) 
//Apply udf to overwrite country 
scala> cleaned_df = cleaned_df.withColumn("country", setCountry(cleaned_df("country"))) 
cleaned_df: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 4 more fields] 

Python:

    //Check out top 6 countries with most awards.
    >>> top_countries_df = spark.sql("SELECT country, count(*) freq FROM awards GROUP BY country ORDER BY freq DESC LIMIT 6")
    >>> top_countries_df.show()
    +-------+----+                                                                  
    |country|freq|
    +-------+----+
    |    USA| 289|
    |England|  57|
    | France|   9|
    | Canada|   8|
    |  Italy|   7|
    |Austria|   7|
    +-------+----+
    >>> top_countries = [x[0] for x in top_countries_df.select("country").collect()]
    //UDF to fix country. Retain top 6 and bundle the rest into "Others"
    >>> from pyspark.sql.functions import udf
    >>> from pyspark.sql.types import StringType
    >>> setCountry = udf(lambda s: s if s in top_countries else "Others", StringType())
    //Apply UDF
    >>> cleaned_df = cleaned_df.withColumn("country", setCountry(cleaned_df["country"]))

数值变量的变化过大

有时数值数据的变化可能跨越几个数量级。例如,如果你查看个人的年收入,它可能会有很大差异。Z-score 标准化(标准化处理)和最小最大值缩放是两种常用的数据处理方法。Spark 在ml.features包中已经包含了这两种转换方法。

我们当前的数据集中没有这样的变量。我们唯一的数值型变量是年龄,其值始终为两位数。这样就少了一个需要解决的问题。

请注意,并非所有数据都需要进行标准化。如果你正在比较两个尺度不同的变量,或者使用聚类算法、SVM 分类器,或者其他真正需要标准化数据的场景时,你可以对数据进行标准化处理。

缺失数据

这是一个主要的关注点。任何目标值本身缺失的观测数据应该从训练数据中移除。其余的观测数据可以保留,并填补一些缺失值,或者根据需求移除。在填补缺失值时,你需要非常小心;否则可能导致误导性输出!直接在连续变量的空白单元格中填入平均值似乎很简单,但这可能不是正确的方法。

我们当前的案例研究没有缺失数据,因此没有需要处理的情况。然而,让我们看一个例子。

假设你正在处理一个学生数据集,其中包含了从 1 班到 5 班的数据。如果有一些缺失的年龄值,而你仅仅通过求整列的平均值来填补,那么这就会成为一个离群点,并且可能导致模糊的结果。你可以选择仅计算学生所在班级的平均值,并用该值填补。这至少是一个更好的方法,但可能不是完美的。在大多数情况下,你还需要对其他变量给予一定的权重。如果这样做,你可能最终会构建一个预测模型来寻找缺失的值,这也是一个很好的方法!

连续数据

数值数据通常是连续的,必须进行离散化,因为这是某些算法的前提条件。它通常被拆分成不同的区间或值范围。然而,也可能存在一些情况,你不仅仅是根据数据的范围均匀分桶,可能还需要考虑方差、标准差或任何其他适用的原因来正确地分桶。现在,决定桶的数量也是数据科学家的自由裁量权,但这也需要仔细分析。桶太少会降低粒度,桶太多则和类别级别太多差不多。在我们的案例研究中,age就是这种数据的一个例子,我们需要将其离散化。我们将其拆分成不同的区间。例如,看看这个管道阶段,它将age转换为 10 个桶:

Scala:

scala> val splits = Array(Double.NegativeInfinity, 35.0, 45.0, 55.0, 
          Double.PositiveInfinity) 
splits: Array[Double] = Array(-Infinity, 35.0, 45.0, 55.0, Infinity) 
scala> val bucketizer = new Bucketizer().setSplits(splits). 
                 setInputCol("age").setOutputCol("age_buckets") 
bucketizer: org.apache.spark.ml.feature.Bucketizer = bucketizer_a25c5d90ac14 

Python:

    >>> splits = [-float("inf"), 35.0, 45.0, 55.0,
                   float("inf")]
    >>> bucketizer = Bucketizer(splits = splits, inputCol = "age",
                        outputCol = "age_buckets")

类别数据

我们已经讨论了将连续数据离散化并转换为类别或区间的必要性。我们还讨论了引入虚拟变量,每个类别变量的不同值都有一个虚拟变量。还有一种常见的数据准备做法是将类别级别转换为数值(离散)数据。这是必要的,因为许多机器学习算法需要处理数值数据、整数或实数,或者某些其他情况可能要求这样做。因此,我们需要将类别数据转换为数值数据。

这种方法可能会有一些缺点。将固有的无序数据引入顺序有时可能不合逻辑。例如,将数字 0、1、2、3 分别赋给颜色“红色”、“绿色”、“蓝色”和“黑色”是没有意义的。因为我们不能说“红色”距离“绿色”一单位远,“绿色”距离“蓝色”也一样远!在许多此类情况下,若适用,引入虚拟变量更有意义。

准备数据

在讨论了常见问题和可能的解决方法之后,让我们看看如何准备我们当前的数据集。我们已经涵盖了与太多类别级别相关的代码修复。下面的示例展示了其余部分。它将所有特征转换为单个特征列。它还为测试模型预留了一些数据。这段代码重度依赖于ml.features包,该包旨在支持数据准备阶段。请注意,这段代码只是定义了需要做的工作。转换尚未执行,这些将在后续定义的管道中成为阶段。执行被推迟到尽可能晚,直到实际模型构建时才会执行。Catalyst 优化器会找到实现管道的最佳路径:

Scala:

//Define pipeline to convert categorical labels to numerical labels 
scala> import org.apache.spark.ml.feature.{StringIndexer, Bucketizer, VectorAssembler} 
import org.apache.spark.ml.feature.{StringIndexer, Bucketizer, VectorAssembler} 
scala> import org.apache.spark.ml.Pipeline 
import org.apache.spark.ml.Pipeline 
//Race 
scala> val raceIdxer = new StringIndexer(). 
           setInputCol("race").setOutputCol("raceIdx") 
raceIdxer: org.apache.spark.ml.feature.StringIndexer = strIdx_80eddaa022e6 
//Award (prediction target) 
scala> val awardIdxer = new StringIndexer(). 
         setInputCol("award").setOutputCol("awardIdx") 
awardIdxer: org.apache.spark.ml.feature.StringIndexer = strIdx_256fe36d1436 
//Country 
scala> val countryIdxer = new StringIndexer(). 
         setInputCol("country").setOutputCol("countryIdx") 
countryIdxer: org.apache.spark.ml.feature.StringIndexer = strIdx_c73a073553a2 

//Convert continuous variable age to buckets 
scala> val splits = Array(Double.NegativeInfinity, 35.0, 45.0, 55.0, 
          Double.PositiveInfinity) 
splits: Array[Double] = Array(-Infinity, 35.0, 45.0, 55.0, Infinity) 

scala> val bucketizer = new Bucketizer().setSplits(splits). 
                 setInputCol("age").setOutputCol("age_buckets") 
bucketizer: org.apache.spark.ml.feature.Bucketizer = bucketizer_a25c5d90ac14 

//Prepare numerical feature vector by clubbing all individual features 
scala> val assembler = new VectorAssembler().setInputCols(Array("raceIdx", 
          "age_buckets","countryIdx")).setOutputCol("features") 
assembler: org.apache.spark.ml.feature.VectorAssembler = vecAssembler_8cf17ee0cd60 

//Define data preparation pipeline 
scala> val dp_pipeline = new Pipeline().setStages( 
          Array(raceIdxer,awardIdxer, countryIdxer, bucketizer, assembler)) 
dp_pipeline: org.apache.spark.ml.Pipeline = pipeline_06717d17140b 
//Transform dataset 
scala> cleaned_df = dp_pipeline.fit(cleaned_df).transform(cleaned_df) 
cleaned_df: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 9 more fields] 
//Split data into train and test datasets 
scala> val Array(trainData, testData) = 
        cleaned_df.randomSplit(Array(0.7, 0.3)) 
trainData: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [dob: string, birthplace: string ... 9 more fields] 
testData: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [dob: string, birthplace: string ... 9 more fields] 

Python:

    //Define pipeline to convert categorical labels to numcerical labels
    >>> from pyspark.ml.feature import StringIndexer, Bucketizer, VectorAssembler
    >>> from pyspark.ml import Pipelin
    //Race
    >>> raceIdxer = StringIndexer(inputCol= "race", outputCol="raceIdx")
    //Award (prediction target)
    >>> awardIdxer = StringIndexer(inputCol = "award", outputCol="awardIdx")
    //Country
    >>> countryIdxer = StringIndexer(inputCol = "country", outputCol = "countryIdx")

    //Convert continuous variable age to buckets
    >>> splits = [-float("inf"), 35.0, 45.0, 55.0,
                   float("inf")]
    >>> bucketizer = Bucketizer(splits = splits, inputCol = "age",
                        outputCol = "age_buckets")
    >>>
    //Prepare numerical feature vector by clubbing all individual features
    >>> assembler = VectorAssembler(inputCols = ["raceIdx", 
              "age_buckets","countryIdx"], outputCol = "features")

    //Define data preparation pipeline
    >>> dp_pipeline = Pipeline(stages = [raceIdxer,
             awardIdxer, countryIdxer, bucketizer, assembler])
    //Transform dataset
    >>> cleaned_df = dp_pipeline.fit(cleaned_df).transform(cleaned_df)
    >>> cleaned_df.columns
    ['dob', 'birthplace', 'country', 'age', 'race', 'award', 'raceIdx', 'awardIdx', 'countryIdx', 'age_buckets', 'features']

    //Split data into train and test datasets
    >>> trainData, testData = cleaned_df.randomSplit([0.7, 0.3])

在完成所有数据准备工作后,你将得到一个完全由数字组成的、没有缺失值且每个属性的值都处于可管理水平的数据集。你可能已经删除了那些对当前分析贡献不大的属性。这就是我们所说的最终数据矩阵。现在,你已经准备好开始建模数据了。首先,你将源数据分成训练数据和测试数据。模型使用训练数据“训练”,然后使用测试数据“测试”。请注意,数据的划分是随机的,如果你重新划分,可能会得到不同的训练集和测试集。

模型构建

模型是事物的表现形式,是对现实的渲染或描述。就像一座物理建筑的模型一样,数据科学模型试图理解现实;在这种情况下,现实是特征与预测变量之间的潜在关系。它们可能不是 100%准确,但仍然非常有用,能够基于数据为我们的业务领域提供深刻的见解。

有多种机器学习算法帮助我们进行数据建模,Spark 提供了其中的许多算法。然而,选择构建哪个模型依然是一个价值百万美元的问题。这取决于多个因素,比如可解释性与准确度之间的权衡、手头的数据量、分类或数值型变量、时间和内存的限制等等。在下面的代码示例中,我们随机训练了几个模型,向你展示如何进行。

我们将根据种族、年龄和国家预测奖项类型。我们将使用 DecisionTreeClassifier、RandomForestClassifier 和 OneVsRest 算法。这三种算法是随意选择的,它们都能处理多类别标签,并且容易理解。我们使用了ml包提供的以下评估指标:

  • 准确率:正确预测的观测值所占比例。

  • 加权精度:精度是正确正类观测值与所有正类观测值的比率。加权精度考虑了各类的频率。

  • 加权召回率:召回率是正类与实际正类的比率。实际正类是指真实正类和假阴性的总和。加权召回率考虑了各类的频率。

  • F1:默认的评估度量。它是精度和召回率的加权平均值。

Scala:

scala> import org.apache.spark.ml.Pipeline 
import org.apache.spark.ml.Pipeline 
scala> import org.apache.spark.ml.classification.DecisionTreeClassifier 
import org.apache.spark.ml.classification.DecisionTreeClassifier 

//Use Decision tree classifier 
scala> val dtreeModel = new DecisionTreeClassifier(). 
           setLabelCol("awardIdx").setFeaturesCol("features"). 
           fit(trainData) 
dtreeModel: org.apache.spark.ml.classification.DecisionTreeClassificationModel = DecisionTreeClassificationModel (uid=dtc_76c9e80680a7) of depth 5 with 39 nodes 

//Run predictions using testData 
scala> val dtree_predictions = dtreeModel.transform(testData) 
dtree_predictions: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 12 more fields] 

//Examine results. Your results may vary due to randomSplit 
scala> dtree_predictions.select("award","awardIdx","prediction").show(4) 
+--------------------+--------+----------+ 
|               award|awardIdx|prediction| 
+--------------------+--------+----------+ 
|       Best Director|     1.0|       1.0| 
|        Best Actress|     0.0|       0.0| 
|        Best Actress|     0.0|       0.0| 
|Best Supporting A...|     4.0|       3.0| 
+--------------------+--------+----------+ 

//Compute prediction mismatch count 
scala> dtree_predictions.filter(dtree_predictions("awardIdx") =!= dtree_predictions("prediction")).count() 
res10: Long = 88 
scala> testData.count 
res11: Long = 126 
//Predictions match with DecisionTreeClassifier model is about 30% ((126-88)*100/126) 

//Train Random forest 
scala> import org.apache.spark.ml.classification.RandomForestClassifier 
import org.apache.spark.ml.classification.RandomForestClassifier 
scala> import org.apache.spark.ml.classification.RandomForestClassificationModel 
import org.apache.spark.ml.classification.RandomForestClassificationModel 
scala> import org.apache.spark.ml.feature.{StringIndexer, IndexToString, VectorIndexer} 
import org.apache.spark.ml.feature.{StringIndexer, IndexToString, VectorIndexer} 

//Build model 
scala> val RFmodel = new RandomForestClassifier(). 
        setLabelCol("awardIdx"). 
        setFeaturesCol("features"). 
        setNumTrees(6).fit(trainData) 
RFmodel: org.apache.spark.ml.classification.RandomForestClassificationModel = RandomForestClassificationModel (uid=rfc_c6fb8d764ade) with 6 trees 
//Run predictions on the same test data using Random Forest model 
scala> val RF_predictions = RFmodel.transform(testData) 
RF_predictions: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 12 more fields] 
//Check results 
scala> RF_predictions.filter(RF_predictions("awardIdx") =!= RF_predictions("prediction")).count() 
res29: Long = 87 //Roughly the same as DecisionTreeClassifier 

//Try OneVsRest Logistic regression technique 
scala> import org.apache.spark.ml.classification.{LogisticRegression, OneVsRest} 
import org.apache.spark.ml.classification.{LogisticRegression, OneVsRest} 
//This model requires a base classifier 
scala> val classifier = new LogisticRegression(). 
            setLabelCol("awardIdx"). 
            setFeaturesCol("features"). 
            setMaxIter(30). 
            setTol(1E-6). 
            setFitIntercept(true) 
classifier: org.apache.spark.ml.classification.LogisticRegression = logreg_82cd24368c87 

//Fit OneVsRest model 
scala> val ovrModel = new OneVsRest(). 
           setClassifier(classifier). 
           setLabelCol("awardIdx"). 
           setFeaturesCol("features"). 
           fit(trainData) 
ovrModel: org.apache.spark.ml.classification.OneVsRestModel = oneVsRest_e696c41c0bcf 
//Run predictions 
scala> val OVR_predictions = ovrModel.transform(testData) 
predictions: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 10 more fields] 
//Check results 
scala> OVR_predictions.filter(OVR_predictions("awardIdx") =!= OVR_predictions("prediction")).count()          
res32: Long = 86 //Roughly the same as other models 

Python:

    >>> from pyspark.ml import Pipeline
    >>> from pyspark.ml.classification import DecisionTreeClassifier

    //Use Decision tree classifier
    >>> dtreeModel = DecisionTreeClassifier(labelCol = "awardIdx", featuresCol="features").fit(trainData)

    //Run predictions using testData
    >>> dtree_predictions = dtreeModel.transform(testData)

    //Examine results. Your results may vary due to randomSplit
    >>> dtree_predictions.select("award","awardIdx","prediction").show(4)
    +--------------------+--------+----------+
    |               award|awardIdx|prediction|
    +--------------------+--------+----------+
    |       Best Director|     1.0|       4.0|
    |       Best Director|     1.0|       1.0|
    |       Best Director|     1.0|       1.0|
    |Best Supporting A...|     4.0|       3.0|
    +--------------------+--------+----------+

    >>> dtree_predictions.filter(dtree_predictions["awardIdx"] != dtree_predictions["prediction"]).count()
    92
    >>> testData.count()
    137
    >>>
    //Predictions match with DecisionTreeClassifier model is about 31% ((133-92)*100/133)

    //Train Random forest
    >>> from pyspark.ml.classification import RandomForestClassifier, RandomForestClassificationModel
    >>> from pyspark.ml.feature import StringIndexer, IndexToString, VectorIndexer
    >>> from pyspark.ml.evaluation import MulticlassClassificationEvaluator

    //Build model
    >>> RFmodel = RandomForestClassifier(labelCol = "awardIdx", featuresCol = "features", numTrees=6).fit(trainData)

    //Run predictions on the same test data using Random Forest model
    >>> RF_predictions = RFmodel.transform(testData)
    //Check results
    >>> RF_predictions.filter(RF_predictions["awardIdx"] != RF_predictions["prediction"]).count()
    94     //Roughly the same as DecisionTreeClassifier

    //Try OneVsRest Logistic regression technique
    >>> from pyspark.ml.classification import LogisticRegression, OneVsRest

    //This model requires a base classifier
    >>> classifier = LogisticRegression(labelCol = "awardIdx", featuresCol="features",
                  maxIter = 30, tol=1E-6, fitIntercept = True)
    //Fit OneVsRest model
    >>> ovrModel = OneVsRest(classifier = classifier, labelCol = "awardIdx",
                    featuresCol = "features").fit(trainData)
    //Run predictions
    >>> OVR_predictions = ovrModel.transform(testData)
    //Check results
    >>> OVR_predictions.filter(OVR_predictions["awardIdx"] != OVR_predictions["prediction"]).count()
    90  //Roughly the same as other models

到目前为止,我们已经尝试了几种模型,发现它们的表现大致相同。验证模型性能有许多其他方法,这取决于你使用的算法、业务背景以及所产生的结果。我们来看一下 spark.ml.evaluation包中提供的一些评估指标:

Scala:

scala> import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator 
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator 
//F1 
scala> val f1_eval = new MulticlassClassificationEvaluator(). 
                     setLabelCol("awardIdx") //Default metric is F1 
f1_eval: org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator = mcEval_e855a949bb0e 

//WeightedPrecision 
scala> val wp_eval = new MulticlassClassificationEvaluator(). 
                     setMetricName("weightedPrecision").setLabelCol("awardIdx") 
wp_eval: org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator = mcEval_44fd64e29d0a 

//WeightedRecall 
scala> val wr_eval = new MulticlassClassificationEvaluator(). 
                     setMetricName("weightedRecall").setLabelCol("awardIdx") 
wr_eval: org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator = mcEval_aa341966305a 
//Compute measures for all models 
scala> val f1_eval_list = List (dtree_predictions, RF_predictions, OVR_predictions) map ( 
           x => f1_eval.evaluate(x)) 
f1_eval_list: List[Double] = List(0.2330854098674473, 0.2330854098674473, 0.2330854098674473) 
scala> val wp_eval_list = List (dtree_predictions, RF_predictions, OVR_predictions) map ( 
           x => wp_eval.evaluate(x)) 
wp_eval_list: List[Double] = List(0.2661599224979506, 0.2661599224979506, 0.2661599224979506) 

scala> val wr_eval_list = List (dtree_predictions, RF_predictions, OVR_predictions) map ( 
           x => wr_eval.evaluate(x)) 
wr_eval_list: List[Double] = List(0.31746031746031744, 0.31746031746031744, 0.31746031746031744) 

Python:

    >>> from pyspark.ml.evaluation import MulticlassClassificationEvaluator

    //F1
    >>> f1_eval = MulticlassClassificationEvaluator(labelCol="awardIdx") //Default metric is F1
    //WeightedPrecision
    >>> wp_eval = MulticlassClassificationEvaluator(labelCol="awardIdx", metricName="weightedPrecision")
    //WeightedRecall
    >>> wr_eval = MulticlassClassificationEvaluator(labelCol="awardIdx", metricName="weightedRecall")
    //Accuracy
    >>> acc_eval = MulticlassClassificationEvaluator(labelCol="awardIdx", metricName="Accuracy")
    //Compute measures for all models
    >>> f1_eval_list = [ f1_eval.evaluate(x) for x in [dtree_predictions, RF_predictions, OVR_predictions]]
    >>> wp_eval_list = [ wp_eval.evaluate(x) for x in [dtree_predictions, RF_predictions, OVR_predictions]]
    >>> wr_eval_list = [ wr_eval.evaluate(x) for x in [dtree_predictions, RF_predictions, OVR_predictions]]
    //Print results for DecisionTree, Random Forest and OneVsRest
    >>> f1_eval_list
    [0.2957949866055487, 0.2645186821042419, 0.2564967990214734]
    >>> wp_eval_list
    [0.3265407181548341, 0.31914852065228005, 0.25295826631254753]
    >>> wr_eval_list
    [0.3082706766917293, 0.2932330827067669, 0.3233082706766917]

输出:

决策树 随机森林 OneVsRest
F1 0.29579 0.26451 0.25649
加权精度 0.32654 0.26451 0.25295
加权召回率 0.30827 0.29323 0.32330

在验证模型性能后,你将需要尽可能地调整模型。调整可以在数据层面和算法层面进行。提供算法所期望的正确数据非常重要。问题在于,无论你输入什么数据,算法可能仍然会给出某些输出——它从不抱怨!因此,除了通过处理缺失值、处理单变量和多变量异常值等方式对数据进行清理外,你还可以创建更多相关的特征。这种特征工程通常被视为数据科学中最重要的部分。拥有一定的领域专业知识有助于更好地进行特征工程。至于算法层面的调整,总是有机会优化我们传递给算法的参数。你可以选择使用网格搜索来寻找最佳参数。此外,数据科学家应当自问,应该使用哪个损失函数及其原因,以及在 GD、SGD、L-BFGS 等算法中,应该选择哪种算法来优化损失函数及其原因。

请注意,前述方法仅用于演示如何在 Spark 上执行步骤。仅通过查看准确率选择某个算法可能不是最佳方法。选择算法取决于你所处理的数据类型、结果变量、业务问题/需求、计算挑战、可解释性等多种因素。

数据可视化

数据可视化 是从开始处理数据科学任务时就需要时常使用的工具。在构建任何模型之前,最好先对每个变量进行可视化,以查看它们的分布,了解它们的特征,并找出异常值以便处理。诸如散点图、箱形图、条形图等简单工具是此类目的的多功能且便捷的工具。此外,在大多数步骤中你还需要使用可视化工具,以确保你正在朝正确的方向前进。

每次你与业务用户或利益相关者合作时,通过可视化来传达分析结果总是一个好习惯。可视化可以以更有意义的方式容纳更多的数据,并且本质上具有直观性。

请注意,大多数数据科学任务的结果通常通过可视化和仪表板呈现给业务用户。我们已经有专门的章节讲解这一主题,因此这里就不再深入探讨。

向业务用户传达结果

在现实生活中,通常需要你间歇性地与业务方进行沟通。在得出最终的可投入生产的模型之前,你可能需要建立多个模型,并将结果传达给业务方。

一个可实施的模型并不总是依赖于准确性;你可能需要引入其他指标,如灵敏度、特异性,或者 ROC 曲线,还可以通过可视化图表(如增益/提升图表)或具有统计显著性的 K-S 检验输出展示结果。需要注意的是,这些技术需要业务用户的输入。这些输入通常会指导你如何构建模型或设置阈值。让我们通过几个例子更好地理解它是如何工作的:

  • 如果一个回归模型预测某个事件发生的概率,那么盲目将阈值设置为 0.5,并假设大于 0.5 的是 1,小于 0.5 的是 0,可能并不是最好的方法!你可以使用 ROC 曲线并做出更科学或更有逻辑性的决策。

  • 对癌症检测的假阴性预测可能是完全不可取的!这是一个极端的生命风险案例。

  • 相比于发送纸质副本,电子邮件营销更便宜。因此,业务方可能决定向那些预测概率低于 0.5(例如 0.35)的收件人发送电子邮件。

注意,前述决策受到业务用户或问题所有者的强烈影响,数据科学家与他们密切合作,以决定此类案例。

再次强调,如前所述,正确的可视化是与业务沟通结果的最优方式。

总结

在本章中,我们进行了一个案例研究,并完成了数据分析生命周期的整个过程。在构建数据产品的过程中,我们应用了前几章中获得的知识。我们提出了一个业务问题,形成了初步假设,获取了数据,并为建模做了准备。我们尝试了多种模型,最终找到了合适的模型。

在下一章,也是最后一章,我们将讨论如何使用 Spark 构建实际应用。

参考文献

www2.sas.com/proceedings/forum2007/073-2007.pdf

azure.microsoft.com/en-in/documentation/articles/machine-learning-algorithm-choice/

www.cs.cornell.edu/courses/cs578/2003fa/performance_measures.pdf

第十一章:构建数据科学应用

数据科学应用正在引起广泛关注,主要因为它们在利用数据并提取可消费的结果方面的巨大潜力。已经有几个成功的数据产品对我们的日常生活产生了深远影响。无处不在的推荐系统、电子邮件垃圾邮件过滤器、定向广告和新闻内容已经成为生活的一部分。音乐和电影也已成为数据产品,从 iTunes 和 Netflix 等平台流媒体播放。企业,尤其是在零售等领域,正在积极寻求通过数据驱动的方法研究市场和客户行为,从而获得竞争优势。

在前面的章节中,我们已经讨论了数据分析工作流的模型构建阶段。但模型的真正价值体现在它实际部署到生产系统中的时候。最终产品,即数据科学工作流的成果,是一个已操作化的数据产品。在本章中,我们将讨论数据分析工作流的这一关键阶段。我们不会涉及具体的代码片段,而是退一步,全面了解整个过程,包括非技术性方面。

完整的图景不仅限于开发过程。它还包括用户应用程序、Spark 本身的开发,以及大数据领域中快速变化的情况。我们将首先从用户应用程序的开发过程开始,并讨论每个阶段的各种选项。接着,我们将深入了解最新 Spark 2.0 版本中的特性和改进以及未来计划。最后,我们将尝试全面概述大数据趋势,特别是 Hadoop 生态系统。此外,每个部分的末尾会提供相关参考资料和有用链接,供读者进一步了解特定的背景信息。

开发范围

数据分析工作流大致可分为两个阶段:构建阶段和操作化阶段。第一阶段通常是一次性的工作,且需要大量人工干预。一旦我们获得了合理的最终结果,就可以准备将产品操作化。第二阶段从第一阶段生成的模型开始,并将其作为生产工作流的一部分进行部署。在本节中,我们将讨论以下内容:

  • 期望

  • 演示选项

  • 开发与测试

  • 数据质量管理

期望

数据科学应用的主要目标是构建“可操作”的洞察,"可操作"是关键字。许多使用案例,如欺诈检测,要求洞察必须生成并以可消费的方式接近实时地提供,才能期待有任何行动的可能。数据产品的最终用户根据使用案例而不同。它们可能是电子商务网站的客户,或者是某大型企业的决策者。最终用户不一定总是人类,可能是金融机构中的风险评估软件工具。单一的通用方法并不适用于许多软件产品,数据产品也不例外。然而,数据产品有一些共同的期望,如下所列:

  • 首要的期望是,基于真实世界数据的洞察生成时间框架应处于“可操作”时间范围内。实际的时间框架会根据使用案例而有所不同。

  • 数据产品应能够融入某些(通常是已经存在的)生产工作流程中。

  • 洞察结果应被转化为人们可以使用的东西,而不是晦涩难懂的数字或难以解释的图表。展示方式应该是简洁的。

  • 数据产品应该具备根据输入的数据自我调整(自适应)的能力。

  • 理想情况下,必须有某种方式接收人工反馈,并将其用作自我调节的来源。

  • 应该有一个机制,定期且自动地定量评估其有效性。

演示选项

数据产品的多样性要求不同的展示方式。有时候,数据分析的最终结果是发布研究论文。有时候,它可能是仪表板的一部分,成为多个来源在同一网页上发布结果的其中之一。它们可能是显式的,目标是供人类使用,或者是隐式的,供其他软件应用使用。你可能会使用像 Spark 这样的通用引擎来构建你的解决方案,但展示方式必须高度对准目标用户群体。

有时候,你所需要做的只是写一封电子邮件,分享你的发现,或者仅仅导出一个 CSV 文件的洞察结果。或者,你可能需要围绕数据产品开发一个专门的 Web 应用程序。这里讨论了一些常见的选项,你必须选择适合当前问题的那一个。

互动笔记本

互动笔记本是网络应用程序,允许你创建和分享包含代码块、结果、方程式、图像、视频和解释文本的文档。它们可以作为可执行文档或具有可视化和方程式支持的 REPL Shell 进行查看。这些文档可以导出为 PDF、Markdown 或 HTML 格式。笔记本包含多个“内核”或“计算引擎”,用于执行代码块。

互动式笔记本是如果你的数据分析工作流的最终目标是生成书面报告时最合适的选择。市面上有几种笔记本,并且其中很多都支持 Spark。这些笔记本在探索阶段也非常有用。我们在前几章已经介绍过 IPython 和 Zeppelin 笔记本。

参考文献

Web API

应用程序编程接口API)是软件与软件之间的接口;它是一个描述可用功能、如何使用这些功能以及输入输出是什么的规范。软件(服务)提供方将其某些功能暴露为 API。开发者可以开发一个软件组件来消费这个 API。例如,Twitter 提供 API 来获取或发布数据到 Twitter,或者通过编程方式查询数据。一位 Spark 爱好者可以编写一个软件组件,自动收集所有关于 #Spark 的推文,按需求进行分类,并将这些数据发布到他们的个人网站。Web API 是一种接口,其中接口被定义为一组超文本传输协议HTTP)请求消息,并附带响应消息结构的定义。如今,RESTful(表现层状态转移)已成为事实上的标准。

你可以将你的数据产品实现为一个 API,也许这是最强大的选择。它可以插入到一个或多个应用中,比如管理仪表板以及市场营销分析工作流。你可能会开发一个特定领域的“洞察即服务”,作为一个带订阅模式的公共 Web API。Web API 的简洁性和普及性使其成为构建数据产品时最具吸引力的选择。

参考文献

PMML 和 PFA

有时你可能需要以其他数据挖掘工具能理解的方式暴露你的模型。模型以及所有的预处理和后处理步骤应该转换为标准格式。PMML 和 PFA 就是数据挖掘领域的两种标准格式。

预测模型标记语言PMML)是一种基于 XML 的预测模型交换格式,Apache Spark API 可以直接将模型转换为 PMML。一个 PMML 消息可以包含大量的数据转换,以及一个或多个预测模型。不同的数据挖掘工具可以在无需定制代码的情况下导入或导出 PMML 消息。

分析的可移植格式PFA)是下一代预测模型交换格式。它交换 JSON 文档,并直接继承了 JSON 文档相比 XML 文档的所有优点。此外,PFA 比 PMML 更具灵活性。

参考资料

开发与测试

Apache Spark 是一个通用的集群计算系统,可以独立运行,也可以在多个现有集群管理器上运行,如 Apache Mesos、Hadoop、Yarn 和 Amazon EC2。此外,许多大数据和企业软件公司已经将 Spark 集成到他们的产品中:Microsoft Azure HDInsight、Cloudera、IBM Analytics for Apache Spark、SAP HANA,等等。Databricks 是由 Apache Spark 创始人创办的公司,提供自己的数据科学工作流产品,涵盖从数据获取到生产的全过程。你的责任是了解组织的需求和现有的人才储备,并决定哪个选项最适合你。

无论选择哪种选项,都应遵循软件开发生命周期中的常规最佳实践,如版本控制和同行评审。在适用的情况下尽量使用高级 API。生产环境中使用的数据转换管道应该与构建模型时使用的相同。记录在数据分析工作流中出现的任何问题,这些问题往往可以促使业务流程的改进。

一如既往,测试对产品的成功至关重要。你必须维护一套自动化脚本,提供易于理解的测试结果。最少的测试用例应该覆盖以下内容:

  • 遵守时间框架和资源消耗要求

  • 对不良数据(例如数据类型违规)的弹性

  • New value in a categorical feature that was not encountered during the model building phase

  • Very little data or too heavy data that is expected in the target production system

Monitor logs, resource utilization, and so on to uncover any performance bottlenecks. The Spark UI provides a wealth of information to monitor Spark applications. The following are some common tips that will help you improve performance:

  • Cache any input or intermediate data that might be used multiple times.

  • Look at the Spark UI and identify jobs that are causing a lot of shuffle. Check the code and see whether you can reduce the shuffles.

  • Actions may transfer the data from workers to the driver. See that you are not transferring any data that is not absolutely necessary.

  • Stragglers; that run slower than others; ”may increase the overall job completion time. There may be several reasons for a straggler. If a job is running slow due to a slow node, you may set spark.speculation to true. Then Spark automatically relaunches such a task on a different node. Otherwise, you may have to revisit the logic and see whether it can be improved.

References

Data quality management

At the outset, let's not forget that we are trying to build fault-tolerant software data products from unreliable, often unstructured, and uncontrolled data sources. So data quality management gains even more importance in a data science workflow. Sometimes the data may solely come from controlled data sources, such as automated internal process workflows in an organization. But in all other cases, you need to carefully craft your data cleansing processes to protect the subsequent processing.

Metadata consists of the structure and meaning of data, and obviously the most critical repository to work with. It is the information about the structure of individual data sources and what each component in that structure means. You may not always be able to write some script and extract this data. A single data source may contain data with different structures or an individual component (column) may mean different things during different times. A label such as owner or high may mean different things in different data sources. Collecting and understanding all such nuances and documenting is a tedious, iterative task. Standardization of metadata is a prerequisite to data transformation development.

Some broad guidelines that are applicable to most use cases are listed here:

  • 所有数据源必须进行版本控制并加上时间戳

  • 数据质量管理过程通常需要最高层次的主管部门参与

  • 屏蔽或匿名化敏感数据

  • 一个常常被忽视的重要步骤是保持可追溯性;即每个数据元素(比如一行)与其原始来源之间的链接

Scala 的优势

Apache Spark 允许你用 Python、R、Java 或 Scala 编写应用程序。随着这种灵活性的出现,你也需要承担选择适合自己需求的编程语言的责任。不过,无论你通常选择哪种语言,你可能都希望考虑在 Spark 驱动的应用程序中使用 Scala。在本节中,我们将解释为什么这么做。

让我们稍微跑题,首先高层次地了解一下命令式和函数式编程范式。像 C、Python 和 Java 这样的语言属于命令式编程范式。在命令式编程范式中,程序是一系列指令,并且它有一个程序状态。程序状态通常表现为在任何给定时刻变量及其值的集合。赋值和重新赋值是比较常见的。变量值在执行过程中会随着一个或多个函数的执行而变化。函数中的变量值修改不仅限于局部变量。全局变量和公共类变量就是此类变量的例子。

相比之下,用函数式编程语言如 Erlang 编写的程序可以看作是无状态的表达式求值器。数据是不可变的。如果函数以相同的输入参数被调用,那么它应该产生相同的结果(即参照透明性)。这是由于没有受到全局变量等变量上下文的干扰。这意味着函数评估的顺序不重要。函数可以作为参数传递给其他函数。递归调用取代了循环。无状态性使得并行编程变得更加容易,因为它消除了锁和潜在死锁的需求。当执行顺序不重要时,协调变得更为简化。这些因素使得函数式编程范式与并行编程非常契合。

纯函数式编程语言难以使用,因为大多数程序都需要状态的改变。包括老牌 Lisp 在内的大多数函数式编程语言都允许将数据存储在变量中(副作用)。一些语言,比如 Scala,融合了多种编程范式。

回到 Scala,它是一种基于 JVM 的静态类型多范式编程语言。其内建的类型推断机制允许程序员省略一些冗余的类型信息。这使得 Scala 在保持良好编译时检查和快速运行时的同时,具备了动态语言的灵活性。Scala 是面向对象的语言,意味着每个值都是一个对象,包括数值。函数是第一类对象,可以作为任何数据类型使用,并且可以作为参数传递给其他函数。由于 Scala 运行在 JVM 上,它与 Java 及其工具有良好的互操作性,Java 和 Scala 类可以自由混合使用。这意味着 Scala 可以轻松地与 Hadoop 生态系统进行交互。

在选择适合您应用的编程语言时,应该考虑所有这些因素。

Spark 开发状态

到 2015 年底,Apache Spark 已成为 Hadoop 生态系统中最活跃的项目之一,按贡献者数量来看。Spark 最初是 2009 年在 UC Berkeley AMPLAB 作为研究项目启动的,与 Apache Hadoop 等项目相比仍然相对年轻,且仍在积极开发中。2015 年有三次发布,从 1.3 到 1.5,包含了如 DataFrames API、SparkR 和 Project Tungsten 等特性。1.6 版本于 2016 年初发布,包含了新的数据集 API 和数据科学功能的扩展。Spark 2.0 于 2016 年 7 月发布,作为一个重要版本,包含了许多新特性和增强功能,值得单独拿出一节来介绍。

Spark 2.0 的特性和增强功能

Apache Spark 2.0 包含了三个主要的新特性以及其他一些性能改进和内部更改。本节尝试提供一个高层次的概述,并在需要时深入细节,帮助理解其概念。

统一数据集和数据框架

数据框架(DataFrames)是支持数据抽象的高级 API,其概念上等同于关系型数据库中的表格或 R 和 Python 中的 DataFrame(如 pandas 库)。数据集(Datasets)是数据框架 API 的扩展,提供类型安全的面向对象编程接口。数据集为数据框架增加了静态类型。在数据框架上定义结构为核心提供了优化信息,也有助于在分布式作业开始之前就能提前发现分析错误。

RDD、数据集(Datasets)和数据框(DataFrames)是可以互换的。RDD 仍然是低级 API。数据框、数据集和 SQL 共享相同的优化和执行管道。机器学习库使用的是数据框或数据集。数据框和数据集都在 Tungsten 上运行,Tungsten 是一个旨在提升运行时性能的计划。它们利用了 Tungsten 的快速内存编码技术,负责在 JVM 对象和 Spark 内部表示之间进行转换。相同的 API 也适用于流数据,引入了连续数据框的概念。

结构化流式计算

结构化流式 API 是基于 Spark SQL 引擎构建的高级 API,扩展了数据框和数据集。结构化流式计算统一了流处理、交互式查询和批处理查询。在大多数使用场景中,流数据需要与批处理和交互式查询结合,形成持续的应用程序。这些 API 旨在满足这一需求。Spark 负责增量和持续地执行流数据上的查询。

结构化流式计算的首次发布将专注于 ETL 工作负载。用户将能够指定输入、查询、触发器和输出类型。输入流在逻辑上等同于一个仅追加的表。用户可以像在传统 SQL 表上那样定义查询。触发器是一个时间框架,例如一秒。提供的输出模式包括完整输出、增量输出或就地更新(例如,数据库表)。

以这个例子为例:你可以对流数据进行聚合,通过 Spark SQL JDBC 服务器提供服务,并将其传递给数据库(例如 MySQL)用于下游应用。或者,你可以运行临时 SQL 查询,操作最新的数据。你还可以构建并应用机器学习模型。

项目 Tungsten 第二阶段

项目 Tungsten 的核心思想是通过本地内存管理和运行时代码生成,将 Spark 的性能推向接近硬件的极限。它首次包含在 Spark 1.4 中,并在 1.5 和 1.6 中进行了增强。其重点是通过以下几种方式显著提升 Spark 应用程序的内存和 CPU 效率:

  • 明确管理内存并消除 JVM 对象模型和垃圾回收的开销。例如,一个四字节的字符串在 JVM 对象模型中大约占用 48 字节。由于 Spark 不是一个通用应用程序,并且比垃圾回收器更了解内存块的生命周期,它能够比 JVM 更高效地管理内存。

  • 设计适合缓存的数据结构和算法。

  • Spark 执行代码生成,将查询的部分编译为 Java 字节码。这一过程已扩展到覆盖大多数内置表达式。

Spark 2.0 推出了第二阶段,它的速度提升了一个数量级,并且包括:

  • 通过消除高开销的迭代器调用和跨多个操作符的融合,实现了整体阶段的代码生成,使生成的代码看起来像手工优化的代码

  • 优化的输入和输出

接下来有什么?

预计 Apache Spark 2.1 将具备以下特性:

  • 持续 SQL (CSQL)

  • BI 应用程序集成

  • 支持更多的流式数据源和汇聚点

  • 包括用于结构化流式处理的额外运算符和库

  • 机器学习包的增强

  • Tungsten 中的列存储内存支持

大数据趋势

大数据处理在过去的十年中成为 IT 行业的一个重要组成部分。Apache Hadoop 和其他类似的努力致力于构建存储和处理海量数据的基础设施。Hadoop 平台已经运行超过 10 年,被认为成熟,几乎可以与大数据处理划上等号。Apache Spark 是一个通用的计算引擎,与 Hadoop 生态系统兼容,并且在 2015 年非常成功。

构建数据科学应用程序需要了解大数据领域和可用软件产品。我们需要仔细地映射适合我们需求的正确组件。有几个功能重叠的选择,挑选合适的工具比说起来容易得多。应用程序的成功在很大程度上取决于组合适当的技术和流程。好消息是,有几个开源选项可以降低大数据分析的成本;与此同时,你还可以通过像 Databricks 这样的公司支持的企业级端到端平台。除了手头的用例外,追踪行业趋势也同样重要。

最近 NOSQL 数据存储的激增,带来了它们自己的接口,即使它们不是关系型数据存储,也可能不遵循 ACID 属性。这是一个受欢迎的趋势,因为在关系型和非关系型数据存储之间收敛到一个单一的古老接口,提高了程序员的生产力。

在过去几十年里,运营(OLTP)和分析(OLAP)系统一直被维护为独立的系统,但这正是收敛正在发生的地方之一。这种收敛将我们带到几乎实时用例,如欺诈预防。Apache Kylin 是 Hadoop 生态系统中的一个开源分布式分析引擎,提供了一个极其快速的 OLAP 引擎。

物联网的出现加速了实时和流式分析,引入了大量新的用例。云计算解放了组织的运营和 IT 管理开销,使它们可以集中精力于其核心竞争力,特别是在大数据处理方面。基于云的分析引擎,自助数据准备工具,自助 BI,及时数据仓库,高级分析,丰富媒体分析和敏捷分析是一些常用的流行词。大数据这个术语本身正在慢慢消失或变得隐含。

在大数据领域,有大量功能重叠的软件产品和库,如下图所示(http://mattturck.com/wp-content/uploads/2016/02/matt_turck_big_data_landscape_v11.png)。为你的应用选择合适的模块是一个艰巨但非常重要的任务。以下是一个简短的项目列表,帮助你入门。该列表排除了像 Cassandra 这样的流行名字,尽量包含具有互补功能的模块,并且大多数来自 Apache 软件基金会:

  • Apache Arrow (arrow.apache.org/) 是一个内存中的列式存储层,用于加速分析处理和数据交换。它是一个高性能、跨系统的内存数据表示,预计能带来 100 倍的性能提升。

  • Apache Parquet (parquet.apache.org/) 是一种列式存储格式。Spark SQL 提供对读取和写入 parquet 文件的支持,同时自动捕获数据的结构。

  • Apache Kafka (kafka.apache.org/) 是一个流行的高吞吐量分布式消息系统。Spark Streaming 提供直接的 API 来支持从 Kafka 进行流数据摄取。

  • Alluxio (alluxio.org/),前身为 Tachyon,是一个以内存为中心的虚拟分布式存储系统,能够在集群之间以内存速度共享数据。它旨在成为大数据的事实上的存储统一层。Alluxio 位于计算框架(如 Spark)和存储系统(如 Amazon S3、HDFS 等)之间。

  • GraphFrames (databricks.com/blog/2016/03/03/introducing-graphframes.html) 是一个基于 Apache Spark 的图处理库,建立在 DataFrames API 之上。

  • Apache Kylin (kylin.apache.org/) 是一个分布式分析引擎,旨在提供 SQL 接口和多维分析(OLAP),支持 Hadoop 上的超大规模数据集。

  • Apache Sentry (sentry.apache.org/) 是一个系统,用于对存储在 Hadoop 集群中的数据和元数据执行细粒度的基于角色的授权。它在撰写本书时处于孵化阶段。

  • Apache Solr (lucene.apache.org/solr/) 是一个非常快速的搜索平台。查看这个 演示 了解如何将 Solr 与 Spark 集成。

  • TensorFlow (www.tensorflow.org/) 是一个机器学习库,广泛支持深度学习。查看这个 博客,了解如何与 Spark 一起使用。

  • Zeppelin (zeppelin.incubator.apache.org/) 是一个基于 Web 的笔记本,支持交互式数据分析。它在数据可视化章节中有介绍。

总结

在本章的最后,我们讨论了如何使用 Spark 构建现实世界的应用程序。我们讨论了包含技术性和非技术性方面的数据分析工作流的宏观视角。

参考文献

  • Spark Summit 网站包含了关于 Apache Spark 和相关项目的大量信息,来自已完成的活动。

  • Matei Zaharia 的访谈,由 KDnuggets 撰写。

  • 来自 KDnuggets 的 为什么 Spark 在 2015 年达到了临界点,作者是 Matthew Mayo

  • 上线:准备你的第一个 Spark 生产部署是一个非常好的起点。

  • 什么是 Scala? 来自 Scala 官网。

  • 马丁·奥德斯基Martin Odersky),Scala 的创始人,解释了为什么 Scala 将命令式编程和函数式编程融合在一起。

posted @ 2025-07-20 11:32  绝不原创的飞龙  阅读(22)  评论(0)    收藏  举报