精通-Spark-2-x-机器学习-全-

精通 Spark 2.x 机器学习(全)

原文:zh.annas-archive.org/md5/3BA1121D202F8663BA917C3CD75B60BC

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

大数据-这是我们几年前探索 Spark 机器学习世界的动力。我们希望构建能够利用大量数据训练模型的机器学习应用程序,但一开始并不容易。Spark 仍在不断发展,它并不包含强大的机器学习库,我们仍在努力弄清楚构建机器学习应用程序意味着什么。

但是,逐步地,我们开始探索 Spark 生态系统的不同方面,并跟随 Spark 的发展。对我们来说,关键部分是一个强大的机器学习库,它将提供 R 或 Python 库所提供的功能。对我们来说,这是一项容易的任务,因为我们积极参与了 H2O 的机器学习库及其名为 Sparkling Water 的分支的开发,该分支使得可以从 Spark 应用程序中使用 H2O 库。然而,模型训练只是机器学习冰山的一角。我们还必须探索如何将 Sparkling Water 连接到 Spark RDDs、DataFrames 和 DataSets,如何将 Spark 连接到不同的数据源并读取数据,或者如何导出模型并在不同的应用程序中重用它们。

在我们的旅程中,Spark 也在不断发展。最初作为纯 Scala 项目,它开始提供 Python 和后来的 R 接口。它还将其 Spark API 从低级别的 RDDs 发展到高级别的 DataSet,并提供了类似 SQL 的接口。此外,Spark 还引入了机器学习管道的概念,这是从 Python 中已知的 scikit-learn 库中采用的。所有这些改进使 Spark 成为数据转换和数据处理的强大工具。

基于这种经验,我们决定通过这本书与世界其他地方分享我们的知识。它的目的很简单:通过示例演示构建 Spark 机器学习应用程序的不同方面,并展示如何不仅使用最新的 Spark 功能,还使用低级别的 Spark 接口。在我们的旅程中,我们还发现了许多技巧和捷径,不仅与 Spark 有关,还与开发机器学习应用程序或源代码组织的过程有关。所有这些都在本书中分享,以帮助读者避免我们所犯的错误。

本书采用 Scala 语言作为示例的主要实现语言。在使用 Python 和 Scala 之间做出了艰难的决定,但最终选择了 Scala。使用 Scala 的两个主要原因是:它提供了最成熟的 Spark 接口,大多数生产部署的应用程序都使用 Scala,主要是因为其在 JVM 上的性能优势。此外,本书中显示的所有源代码也都可以在线获取。

希望您喜欢我们的书,并且它能帮助您在 Spark 世界和机器学习应用程序的开发中进行导航。

本书涵盖的内容

第一章,大规模机器学习简介,邀请读者进入机器学习和大数据的世界,介绍了历史范式,并描述了包括 Apache Spark 和 H2O 在内的当代工具。

第二章,探测暗物质:希格斯玻色子粒子,着重介绍了二项模型的训练和评估。

第三章,多类分类的集成方法,进入健身房,并尝试基于从身体传感器收集的数据来预测人类活动。

第四章,使用 NLP 预测电影评论,介绍了使用 Spark 进行自然语言处理的问题,并展示了它在电影评论情感分析中的强大功能。

第五章,使用 Word2Vec 进行在线学习,详细介绍了当代自然语言处理技术。

第六章,从点击流数据中提取模式,介绍了频繁模式挖掘的基础知识和 Spark MLlib 中提供的三种算法,然后在 Spark Streaming 应用程序中部署了其中一种算法。

第七章,使用 GraphX 进行图分析,使读者熟悉图和图分析的基本概念,解释了 Spark GraphX 的核心功能,并介绍了 PageRank 等图算法。

第八章,Lending Club Loan Prediction,结合了前几章介绍的所有技巧,包括数据处理、模型搜索和训练,以及作为 Spark Streaming 应用程序的模型部署的端到端示例。

本书所需内容

本书提供的代码示例使用 Apache Spark 2.1 及其 Scala API。此外,我们使用 Sparkling Water 软件包来访问 H2O 机器学习库。在每一章中,我们都会展示如何使用 spark-shell 启动 Spark,以及如何下载运行代码所需的数据。

总之,运行本书提供的代码的基本要求包括:

  • Java 8

  • Spark 2.1

本书适合的读者是谁

您是一位具有机器学习和统计背景的开发人员,感到当前的慢速和小数据机器学习工具限制了您的发展吗?那么这本书就是为您而写!在本书中,您将使用 Spark 创建可扩展的机器学习应用程序,以支持现代数据驱动的业务。我们假设您已经了解机器学习的概念和算法,并且已经在 Spark 上运行(无论是在集群上还是本地),并且具有对 Spark 中包含的各种库的基本知识。

约定

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是一些样式的示例及其含义的解释。文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们还附加了魔术列row_id,它唯一标识数据集中的每一行。” 代码块设置如下:

import org.apache.spark.ml.feature.StopWordsRemover 
val stopWords= StopWordsRemover.loadDefaultStopWords("english") ++ Array("ax", "arent", "re")

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

val MIN_TOKEN_LENGTH = 3
val toTokens= (minTokenLen: Int, stopWords: Array[String], 

任何命令行输入或输出都写成如下形式:

tar -xvf spark-2.1.1-bin-hadoop2.6.tgz 
export SPARK_HOME="$(pwd)/spark-2.1.1-bin-hadoop2.6 

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中以这种方式出现:“按照以下截图下载 DECLINED LOAN DATA”

警告或重要提示会以这种形式出现。

技巧会以这种形式出现。

第一章:大规模机器学习和 Spark 简介

"信息是 21 世纪的石油,分析是内燃机。"

--彼得·桑德加德,高德纳研究

到 2018 年,预计公司将在大数据相关项目上花费 1140 亿美元,比 2013 年增长了大约 300%(www.capgemini-consulting.com/resource-file-access/resource/pdf/big_data_pov_03-02-15.pdf)。支出增加的很大程度上是由于正在创建的数据量以及我们如何更好地利用分布式文件系统(如 Hadoop)来存储这些数据。

然而,收集数据只是一半的战斗;另一半涉及数据提取、转换和加载到计算系统中,利用现代计算机的能力应用各种数学方法,以了解数据和模式,并提取有用信息以做出相关决策。在过去几年里,整个数据工作流程得到了提升,不仅增加了计算能力并提供易于访问和可扩展的云服务(例如,Amazon AWS,Microsoft Azure 和 Heroku),还有一些工具和库,帮助轻松管理、控制和扩展基础设施并构建应用程序。计算能力的增长还有助于处理更大量的数据,并应用以前无法应用的算法。最后,各种计算昂贵的统计或机器学习算法开始帮助从数据中提取信息。

最早被广泛采用的大数据技术之一是 Hadoop,它允许通过将中间结果保存在磁盘上进行 MapReduce 计算。然而,它仍然缺乏适当的大数据工具来进行信息提取。然而,Hadoop 只是一个开始。随着机器内存的增长,出现了新的内存计算框架,它们也开始提供基本支持进行数据分析和建模,例如,SystemML 或 Spark 的 Spark ML 和 Flink 的 FlinkML。这些框架只是冰山一角-大数据生态系统中还有很多,它在不断发展,因为数据量不断增长,需要新的大数据算法和处理方法。例如,物联网代表了一个新的领域,它从各种来源产生大量的流数据(例如,家庭安全系统,Alexa Echo 或重要传感器),不仅带来了从数据中挖掘有用信息的无限潜力,还需要新的数据处理和建模方法。

然而,在本章中,我们将从头开始解释以下主题:

  • 数据科学家的基本工作任务

  • 分布环境中大数据计算的方面

  • 大数据生态系统

  • Spark 及其机器学习支持

数据科学

找到数据科学的统一定义,就像品尝葡萄酒并在朋友中比较口味一样-每个人都有自己的定义,没有一个描述比其他更准确。然而,在其核心,数据科学是关于对数据提出智能问题并获得对关键利益相关者有意义的智能答案的艺术。不幸的是,相反的也是真的-对数据提出糟糕的问题会得到糟糕的答案!因此,仔细制定问题是从数据中提取有价值见解的关键。因此,公司现在正在聘请数据科学家来帮助制定并提出这些问题。

图 1 - 大数据和数据科学的增长谷歌趋势

21 世纪最性感的角色-数据科学家?

起初,很容易对典型的数据科学家的形象有一个刻板印象:T 恤,运动裤,厚框眼镜,正在用 IntelliJ 调试一段代码……你懂的。除了审美外,数据科学家的一些特质是什么?我们最喜欢的一张海报描述了这个角色,如下图所示:

图 2 - 什么是数据科学家?

数学、统计学和计算机科学的一般知识是必备的,但我们在从业者中看到的一个陷阱与理解业务问题有关,这又回到了对数据提出智能问题。无法再强调:对数据提出更多智能问题取决于数据科学家对业务问题和数据限制的理解;没有这种基本理解,即使是最智能的算法也无法基于摇摇欲坠的基础得出坚实的结论。

一个数据科学家的一天

这可能会让你们中的一些人感到震惊——成为一名数据科学家不仅仅是阅读学术论文、研究新工具和模型构建直到清晨,靠浓缩咖啡提神;事实上,这只是数据科学家真正玩耍的时间的一小部分(然而,对于每个人来说,咖啡因的部分是 100%真实的)!然而,大部分时间都是在开会中度过的,更好地了解业务问题,分析数据以了解其限制(放心,本书将让您接触到大量不同的特征工程或特征提取任务),以及如何最好地向非数据科学人员呈现发现。这就是真正的香肠制作过程所在,最优秀的数据科学家是那些热爱这个过程的人,因为他们更多地了解了成功的要求和基准。事实上,我们可以写一本全新的书来描述这个过程的始终!

那么,关于数据的提问涉及什么(和谁)?有时,这是将数据保存到关系数据库中,并运行 SQL 查询以找到数据的见解的过程:“对于购买了这种特定产品的数百万用户,还购买了哪 3 种其他产品?”其他时候,问题更复杂,比如,“鉴于一部电影的评论,这是一个积极的还是消极的评论?”本书主要关注复杂的问题,比如后者。回答这些类型的问题是企业从其大数据项目中真正获得最大影响的地方,也是我们看到新兴技术大量涌现,旨在使这种问答系统更容易,功能更多。

一些最受欢迎的开源框架,旨在帮助回答数据问题,包括 R、Python、Julia 和 Octave,所有这些框架在小型(X < 100 GB)数据集上表现得相当不错。在这一点上,值得停下来指出大数据与小数据之间的明显区别。我们办公室的一般经验法则如下:

如果您可以使用 Excel 打开数据集,那么您正在处理小数据。

处理大数据

当所讨论的数据集如此庞大,以至于无法适应单台计算机的内存,并且必须分布在大型计算集群中的多个节点上时,会发生什么?难道我们不能简单地重写一些 R 代码,例如,扩展它以适应多于单节点的计算?如果事情只是那么简单就好了!有许多原因使得算法扩展到更多机器变得困难。想象一个简单的例子,一个文件包含一个名单:

B
D
X
A
D
A

我们想要计算文件中各个单词的出现次数。如果文件适合在一台机器上,您可以轻松地使用 Unix 工具sortuniq来计算出现次数:

bash> sort file | uniq -c

输出如下所示:

2 A
1 B
1 D
1 X

然而,如果文件很大并分布在多台机器上,就需要采用略有不同的计算策略。例如,计算每个适合内存的文件部分中各个单词的出现次数,并将结果合并在一起。因此,即使是简单的任务,比如在分布式环境中计算名称的出现次数,也会变得更加复杂。

使用分布式环境的机器学习算法

机器学习算法将简单的任务组合成复杂的模式,在分布式环境中更加复杂。例如,让我们以简单的决策树算法为例。这个特定的算法创建一个二叉树,试图拟合训练数据并最小化预测错误。然而,为了做到这一点,它必须决定将每个数据点发送到树的哪个分支(不用担心,我们将在本书的后面介绍这个算法的工作原理以及一些非常有用的参数)。让我们用一个简单的例子来演示:

图 3 - 覆盖 2D 空间的红色和蓝色数据点的示例。

考虑前面图中描述的情况。一个二维棋盘,上面有许多点涂成两种颜色:红色和蓝色。决策树的目标是学习和概括数据的形状,并帮助决定一个新点的颜色。在我们的例子中,我们很容易看出这些点几乎遵循着象棋盘的模式。然而,算法必须自己找出结构。它首先要找到一个垂直或水平线的最佳位置,这条线可以将红点与蓝点分开。

找到的决策存储在树根中,并且步骤递归地应用在两个分区上。当分区中只有一个点时,算法结束:

图 4 - 最终的决策树及其预测在原始点空间中的投影。

将数据分割成多台机器

现在,让我们假设点的数量很大,无法适应单台机器的内存。因此,我们需要多台机器,并且我们必须以这样的方式对数据进行分区,使得每台机器只包含数据的一个子集。这样,我们解决了内存问题;然而,这也意味着我们需要在机器集群中分布计算。这是与单机计算的第一个不同之处。如果您的数据适合单台机器的内存,那么很容易做出关于数据的决策,因为算法可以一次性访问所有数据,但在分布式算法的情况下,这不再成立,算法必须在访问数据方面变得“聪明”。由于我们的目标是构建一个决策树,以预测棋盘上一个新点的颜色,我们需要找出如何制作与单机上构建的树相同的树。

朴素的解决方案是构建一个基于机器边界分隔点的平凡树。但这显然是一个糟糕的解决方案,因为数据分布根本不反映颜色点。

另一个解决方案尝试在XY轴的方向上尝试所有可能的分割决策,并尽量在分离两种颜色时做得最好,也就是将点分成两组并最小化另一种颜色的点数。想象一下,算法正在通过线X = 1.6测试分割。这意味着算法必须询问集群中的每台机器报告分割机器的本地数据的结果,合并结果,并决定是否是正确的分割决策。如果找到了最佳分割,它需要通知所有机器关于决策,以记录每个点属于哪个分区。

与单机场景相比,构建决策树的分布式算法更复杂,需要一种在多台机器之间分配计算的方式。如今,随着对大型数据集分析需求的增加以及对机器群集的轻松访问,这成为了标准要求。

即使这两个简单的例子表明,对于更大的数据,需要适当的计算和分布式基础设施,包括以下内容:

  • 分布式数据存储,即,如果数据无法放入单个节点,我们需要一种在多台机器上分发和处理数据的方式

  • 一种处理和转换分布式数据并应用数学(和统计)算法和工作流的计算范式

  • 支持持久化和重用定义的工作流和模型

  • 支持在生产中部署统计模型

简而言之,我们需要一个支持常见数据科学任务的框架。这可能被认为是一个不必要的要求,因为数据科学家更喜欢使用现有工具,如 R、Weka 或 Python 的 scikit。然而,这些工具既不是为大规模分布式处理设计的,也不是为大数据的并行处理设计的。尽管有支持有限并行或分布式编程的 R 或 Python 库,但它们的主要局限是基础平台,即 R 和 Python,不是为这种数据处理和计算设计的。

从 Hadoop MapReduce 到 Spark

随着数据量的增长,单机工具无法满足行业需求,因此为新的数据处理方法和工具创造了空间,特别是基于最初在 Google 论文中描述的想法的 Hadoop MapReduce,MapReduce: Simplified Data Processing on Large Clusters (research.google.com/archive/mapreduce.html)。另一方面,它是一个通用框架,没有任何明确支持或库来创建机器学习工作流。经典 MapReduce 的另一个局限是,在计算过程中执行了许多磁盘 I/O 操作,而没有从机器内存中受益。

正如您所见,存在多种现有的机器学习工具和分布式平台,但没有一个完全匹配于在大数据和分布式环境中执行机器学习任务。所有这些说法为 Apache Spark 打开了大门。

进入房间,Apache Spark!

Apache Spark 项目于 2010 年在加州大学伯克利分校 AMP 实验室(算法、机器、人)创建,其目标是速度、易用性和高级分析。Spark 与 Hadoop 等其他分布式框架的一个关键区别是,数据集可以缓存在内存中,这非常适合机器学习,因为它的迭代性质(稍后会详细介绍!)以及数据科学家经常多次访问相同的数据。

Spark 可以以多种方式运行,例如以下方式:

  • 本地模式:这涉及在单个主机上执行的单个Java 虚拟机JVM

  • 独立的 Spark 集群:这涉及多个主机上的多个 JVM

  • 通过资源管理器,如 Yarn/Mesos:这种应用部署是由资源管理器驱动的,它控制节点、应用程序、分发和部署的分配

什么是 Databricks?

如果您了解 Spark 项目,那么很可能您也听说过一个名为Databricks的公司。然而,您可能不知道 Databricks 和 Spark 项目之间的关系。简而言之,Databricks 是由 Apache Spark 项目的创建者成立的,并占据了 Spark 项目超过 75%的代码库。除了在开发方面对 Spark 项目有着巨大的影响力之外,Databricks 还为开发人员、管理员、培训师和分析师提供各种 Spark 认证。然而,Databricks 并不是代码库的唯一主要贡献者;像 IBM、Cloudera 和微软这样的公司也积极参与 Apache Spark 的开发。

另外,Databricks 还组织了 Spark Summit(在欧洲和美国举办),这是首屈一指的 Spark 会议,也是了解项目最新发展以及其他人如何在其生态系统中使用 Spark 的绝佳场所。

在本书中,我们将提供推荐的链接,这些链接每天都会提供很好的见解,同时也会介绍关于新版本 Spark 的重要变化。其中最好的资源之一是 Databricks 博客,该博客不断更新着优质内容。一定要定期查看databricks.com/blog

此外,这里还有一个链接,可以查看过去的 Spark Summit 讲座,可能会对您有所帮助:

slideshare.net/databricks.

盒子里

那么,您已经下载了最新版本的 Spark(取决于您计划如何启动 Spark),并运行了标准的Hello, World!示例....现在呢?

Spark 配备了五个库,可以单独使用,也可以根据我们要解决的任务一起使用。请注意,在本书中,我们计划使用各种不同的库,都在同一个应用程序中,以便您能最大程度地接触 Spark 平台,并更好地了解每个库的优势(和局限性)。这五个库如下:

  • 核心:这是 Spark 的核心基础设施,提供了用于表示和存储数据的原语,称为弹性分布式数据集RDDs),并使用任务和作业来操作数据。

  • SQL:该库通过引入 DataFrames 和 SQL 来提供用户友好的 API,以操作存储的数据。

  • MLlib(机器学习库):这是 Spark 自己的机器学习库,其中包含了内部开发的算法,可以在 Spark 应用程序中使用。

  • Graphx:用于图形和图形计算;我们将在后面的章节中深入探讨这个特定的库。

  • Streaming:该库允许从各种来源实时流式传输数据,例如 Kafka、Twitter、Flume 和 TCP 套接字等。本书中许多应用程序将利用 MLlib 和 Streaming 库来构建我们的应用程序。

Spark 平台也可以通过第三方软件包进行扩展。例如,支持读取 CSV 或 Avro 文件,与 Redshift 集成以及 Sparkling Water,它封装了 H2O 机器学习库。

介绍 H2O.ai

H2O 是一个开源的机器学习平台,与 Spark 非常兼容;事实上,它是最早被认定为“在 Spark 上认证”的第三方软件包之一。

Sparkling Water(H2O + Spark)是 H2O 在 Spark 项目中集成其平台的一部分,它将 H2O 的机器学习能力与 Spark 的所有功能结合在一起。这意味着用户可以在 Spark RDD/DataFrame 上运行 H2O 算法,用于探索和部署。这是可能的,因为 H2O 和 Spark 共享相同的 JVM,这允许在两个平台之间无缝切换。H2O 将数据存储在 H2O 框架中,这是您的数据集的列压缩表示,可以从 Spark RDD 和/或 DataFrame 创建。在本书的大部分内容中,我们将引用 Spark 的 MLlib 库和 H2O 平台的算法,展示如何使用这两个库来为给定任务获得尽可能好的结果。

以下是 Sparkling Water 配备的功能摘要:

  • 在 Spark 工作流中使用 H2O 算法

  • 在 Spark 和 H2O 数据结构之间的转换

  • 使用 Spark RDD 和/或 DataFrame 作为 H2O 算法的输入

  • 将 H2O 框架用作 MLlib 算法的输入(在进行特征工程时会很方便)

  • Sparkling Water 应用程序在 Spark 顶部的透明执行(例如,我们可以在 Spark 流中运行 Sparkling Water 应用程序)

  • 探索 Spark 数据的 H2O 用户界面

Sparkling Water 的设计

Sparkling Water 被设计为可执行的常规 Spark 应用程序。因此,它在提交应用程序后在 Spark 执行器内启动。此时,H2O 启动服务,包括分布式键值(K/V)存储和内存管理器,并将它们编排成一个云。创建的云的拓扑结构遵循底层 Spark 集群的拓扑结构。

如前所述,Sparkling Water 可以在不同类型的 RDD/DataFrame 和 H2O 框架之间进行转换,反之亦然。当从 hex 框架转换为 RDD 时,会在 hex 框架周围创建一个包装器,以提供类似 RDD 的 API。在这种情况下,数据不会被复制,而是直接从底层的 hex 框架提供。从 RDD/DataFrame 转换为 H2O 框架需要数据复制,因为它将数据从 Spark 转换为 H2O 特定的存储。但是,存储在 H2O 框架中的数据被大量压缩,不再需要作为 RDD 保留:

Sparkling Water 和 Spark 之间的数据共享

H2O 和 Spark 的 MLlib 有什么区别?

如前所述,MLlib 是使用 Spark 构建的流行机器学习算法库。毫不奇怪,H2O 和 MLlib 共享许多相同的算法,但它们在实现和功能上有所不同。H2O 的一个非常方便的功能是允许用户可视化其数据并执行特征工程任务,我们将在后面的章节中深入介绍。数据的可视化是通过一个友好的网络 GUI 完成的,并允许用户在代码 shell 和笔记本友好的环境之间无缝切换。以下是 H2O 笔记本的示例 - 称为 Flow - 您很快将熟悉的:

另一个很好的补充是,H2O 允许数据科学家对其算法附带的许多超参数进行网格搜索。网格搜索是一种优化算法的所有超参数的方法,使模型配置更加容易。通常,很难知道要更改哪些超参数以及如何更改它们;网格搜索允许我们同时探索许多超参数,测量输出,并根据我们的质量要求帮助选择最佳模型。H2O 网格搜索可以与模型交叉验证和各种停止标准结合使用,从而产生高级策略,例如从巨大的参数超空间中选择 1000 个随机参数,并找到可以在两分钟内训练且 AUC 大于 0.7 的最佳模型

数据整理

问题的原始数据通常来自多个来源,格式不同且通常不兼容。Spark 编程模型的美妙之处在于其能够定义数据操作,处理传入的数据并将其转换为常规形式,以便用于进一步的特征工程和模型构建。这个过程通常被称为数据整理,这是数据科学项目中取得胜利的关键。我们故意将这一部分简短,因为展示数据整理的力量和必要性最好的方式是通过示例。所以,放心吧;在这本书中,我们有很多实践要做,重点是这个基本过程。

数据科学-一个迭代的过程

很多大数据项目的流程是迭代的,这意味着不断地测试新的想法,包括新的特征,调整各种超参数等等,态度是“快速失败”。这些项目的最终结果通常是一个能够回答提出的问题的模型。请注意,我们没有说准确地回答提出的问题!如今许多数据科学家的一个缺陷是他们无法将模型泛化到新数据,这意味着他们已经过度拟合了数据,以至于当给出新数据时,模型会提供糟糕的结果。准确性极大地取决于任务,并且通常由业务需求决定,同时进行一些敏感性分析以权衡模型结果的成本效益。然而,在本书中,我们将介绍一些标准的准确性度量,以便您可以比较各种模型,看看对模型的更改如何影响结果。

H2O 经常在美国和欧洲举办见面会,并邀请其他人参加机器学习见面会。每个见面会或会议的幻灯片都可以在 SlideShare(www.slideshare.com/0xdata)或 YouTube 上找到。这两个网站不仅是关于机器学习和统计的重要信息来源,也是关于分布式系统和计算的重要信息来源。例如,其中一个最有趣的演示重点介绍了“数据科学家工作中的前 10 个陷阱”(www.slideshare.net/0xdata/h2o-world-top-10-data-science-pitfalls-mark-landry)。

总结

在本章中,我们想要简要地让您了解数据科学家的生活,这意味着什么,以及数据科学家经常面临的一些挑战。鉴于这些挑战,我们认为 Apache Spark 项目理想地定位于帮助解决这些主题,从数据摄入和特征提取/创建到模型构建和部署。我们故意将本章保持简短,言辞轻松,因为我们认为通过示例和不同的用例来工作是比抽象地和冗长地谈论某个数据科学主题更好的利用时间。在本书的其余部分,我们将专注于这个过程,同时给出最佳实践建议和推荐阅读,以供希望学习更多的用户参考。请记住,在着手进行下一个数据科学项目之前,一定要在前期清晰地定义问题,这样您就可以向数据提出一个明智的问题,并(希望)得到一个明智的答案!

一个关于数据科学的很棒的网站是 KDnuggets(www.kdnuggets.com)。这里有一篇关于所有数据科学家必须学习的语言的好文章,以便取得成功(www.kdnuggets.com/2015/09/one-language-data-scientist-must-master.html)。

第二章:探测暗物质 - 弥散子粒子

真或假?积极或消极?通过还是不通过?用户点击广告与不点击广告?如果你以前曾经问过/遇到过这些问题,那么你已经熟悉二元分类的概念。

在其核心,二元分类 - 也称为二项分类 - 试图使用分类规则将一组元素分类为两个不同的组,而在我们的情况下,可以是一个机器学习算法。本章将展示如何在 Spark 和大数据的背景下处理这个问题。我们将解释和演示:

  • Spark MLlib 二元分类模型包括决策树、随机森林和梯度提升机

  • H2O 中的二元分类支持

  • 在参数的超空间中寻找最佳模型

  • 二项模型的评估指标

Type I 与 Type II 错误

二元分类器具有直观的解释,因为它们试图将数据点分成两组。这听起来很简单,但我们需要一些衡量这种分离质量的概念。此外,二元分类问题的一个重要特征是,通常一个标签组的比例与另一个标签组的比例可能不成比例。这意味着数据集可能在一个标签方面不平衡,这需要数据科学家仔细解释。

例如,假设我们试图在 1500 万人口中检测特定罕见疾病的存在,并且我们发现 - 使用人口的大子集 - 只有 10,000 或 1 千万人实际上携带疾病。如果不考虑这种巨大的不成比例,最天真的算法会简单地猜测剩下的 500 万人中“没有疾病存在”,仅仅因为子集中有 0.1%的人携带疾病。假设在剩下的 500 万人中,同样的比例,0.1%,携带疾病,那么这 5000 人将无法被正确诊断,因为天真的算法会简单地猜测没有人携带疾病。这种情况下,二元分类所带来的错误的成本是需要考虑的一个重要因素,这与所提出的问题有关。

考虑到我们只处理这种类型问题的两种结果,我们可以创建一个二维表示可能的不同类型错误的表示。保持我们之前的例子,即携带/不携带疾病的人,我们可以将我们的分类规则的结果考虑如下:

图 1 - 预测和实际值之间的关系

从上表中可以看出,绿色区域代表我们在个体中正确预测疾病的存在/不存在,而白色区域代表我们的预测是错误的。这些错误的预测分为两类,称为Type IType II错误:

  • Type I 错误:当我们拒绝零假设(即一个人没有携带疾病),而实际上,实际上是真的

  • Type II 错误:当我们预测个体携带疾病时,实际上个体并没有携带疾病

显然,这两种错误都不好,但在实践中,有些错误比其他错误更可接受。

考虑这样一种情况,即我们的模型产生的 II 型错误明显多于 I 型错误;在这种情况下,我们的模型会预测患病的人数比实际上更多 - 保守的方法可能比我们未能识别疾病存在的 II 型错误更为可接受。确定每种错误的成本是所提出的问题的函数,这是数据科学家必须考虑的事情。在我们建立第一个尝试预测希格斯玻色子粒子存在/不存在的二元分类模型之后,我们将重新讨论错误和模型质量的一些其他指标。

寻找希格斯玻色子粒子

2012 年 7 月 4 日,来自瑞士日内瓦的欧洲 CERN 实验室的科学家们提出了强有力的证据,证明了他们认为是希格斯玻色子的粒子,有时被称为上帝粒子。为什么这一发现如此有意义和重要?正如知名物理学家和作家迈克·卡库所写:

"在量子物理学中,是一种类似希格斯的粒子引发了宇宙大爆炸(即大爆炸)。换句话说,我们周围看到的一切,包括星系、恒星、行星和我们自己,都归功于希格斯玻色子。"

用通俗的话来说,希格斯玻色子是赋予物质质量的粒子,并为地球最初的形成提供了可能的解释,因此在主流媒体渠道中备受欢迎。

LHC 和数据生成

为了检测希格斯玻色子的存在,科学家们建造了人造最大的机器,称为日内瓦附近的大型强子对撞机LHC)。LHC 是一个环形隧道,长 27 公里(相当于伦敦地铁的环线),位于地下 100 米。

通过这条隧道,亚原子粒子在磁铁的帮助下以接近光速的速度相反方向发射。一旦达到临界速度,粒子就被放在碰撞轨道上,探测器监视和记录碰撞。有数以百万计的碰撞和亚碰撞! - 而由此产生的粒子碎片有望检测到希格斯玻色子的存在。

希格斯玻色子的理论

相当长一段时间以来,物理学家已经知道一些基本粒子具有质量,这与标准模型的数学相矛盾,该模型规定这些粒子应该是无质量的。在 20 世纪 60 年代,彼得·希格斯和他的同事们通过研究大爆炸后的宇宙挑战了这个质量难题。当时,人们普遍认为粒子应该被视为量子果冻中的涟漪,而不是彼此弹来弹去的小台球。希格斯认为,在这个早期时期,所有的粒子果冻都像水一样稀薄;但随着宇宙开始冷却,一个粒子果冻,最初被称为希格斯场,开始凝结变厚。因此,其他粒子果冻在与希格斯场相互作用时,由于惯性而被吸引;根据艾萨克·牛顿爵士的说法,任何具有惯性的粒子都应该含有质量。这种机制解释了标准模型中的粒子如何获得质量 - 起初是无质量的。因此,每个粒子获得的质量量与其感受到希格斯场影响的强度成正比。

文章plus.maths.org/content/particle-hunting-lhc-higgs-boson是对好奇读者的一个很好的信息来源。

测量希格斯玻色子

测试这个理论回到了粒子果冻波纹的最初概念,特别是希格斯果冻,它 a)可以波动,b)在实验中会类似于一个粒子:臭名昭著的希格斯玻色子。那么科学家们如何利用 LHC 检测这种波纹呢?

为了监测碰撞和碰撞后的结果,科学家们设置了探测器,它们就像三维数字摄像机,测量来自碰撞的粒子轨迹。这些轨迹的属性 - 即它们在磁场中的弯曲程度 - 被用来推断生成它们的粒子的各种属性;一个非常常见的可以测量的属性是电荷,据信希格斯玻色子存在于 120 到 125 吉电子伏特之间。也就是说,如果探测器发现一个电荷存在于这两个范围之间的事件,这将表明可能存在一个新的粒子,这可能是希格斯玻色子的迹象。

数据集

2012 年,研究人员向科学界发布了他们的研究结果,随后公开了 LHC 实验的数据,他们观察到并确定了一种信号,这种信号表明存在希格斯玻色子粒子。然而,在积极的发现中存在大量的背景噪音,这导致数据集内部不平衡。我们作为数据科学家的任务是构建一个机器学习模型,能够准确地从背景噪音中识别出希格斯玻色子粒子。你现在应该考虑这个问题的表述方式,这可能表明这是一个二元分类问题(即,这个例子是希格斯玻色子还是背景噪音?)。

您可以从archive.ics.uci.edu/ml/datasets/HIGGS下载数据集,或者使用本章的bin文件夹中的getdata.sh脚本。

这个文件有 2.6 吉字节(未压缩),包含了 1100 万个被标记为 0 - 背景噪音和 1 - 希格斯玻色子的例子。首先,您需要解压缩这个文件,然后我们将开始将数据加载到 Spark 中进行处理和分析。数据集总共有 29 个字段:

  • 字段 1:类别标签(1 = 希格斯玻色子信号,2 = 背景噪音)

  • 字段 2-22:来自碰撞探测器的 21 个“低级”特征

  • 字段 23-29:由粒子物理学家手工提取的七个“高级”特征,用于帮助将粒子分类到适当的类别(希格斯或背景噪音)

在本章的后面,我们将介绍一个深度神经网络DNN)的例子,它将尝试通过非线性转换层来学习这些手工提取的特征。

请注意,为了本章的目的,我们将使用数据的一个子集,即前 100,000 行,但我们展示的所有代码也适用于原始数据集。

Spark 启动和数据加载

现在是时候启动一个 Spark 集群了,这将为我们提供 Spark 的所有功能,同时还允许我们使用 H2O 算法和可视化我们的数据。和往常一样,我们必须从spark.apache.org/downloads.html下载 Spark 2.1 分发版,并在执行之前声明执行环境。例如,如果您从 Spark 下载页面下载了spark-2.1.1-bin-hadoop2.6.tgz,您可以按照以下方式准备环境:

tar -xvf spark-2.1.1-bin-hadoop2.6.tgz 
export SPARK_HOME="$(pwd)/spark-2.1.1-bin-hadoop2.6 

当环境准备好后,我们可以使用 Sparkling Water 包和本书包启动交互式 Spark shell:

export SPARKLING_WATER_VERSION="2.1.12"
export SPARK_PACKAGES=\
"ai.h2o:sparkling-water-core_2.11:${SPARKLING_WATER_VERSION},\
ai.h2o:sparkling-water-repl_2.11:${SPARKLING_WATER_VERSION},\
ai.h2o:sparkling-water-ml_2.11:${SPARKLING_WATER_VERSION},\
com.packtpub:mastering-ml-w-spark-utils:1.0.0"

$SPARK_HOME/bin/spark-shell \      

            --master 'local[*]' \
            --driver-memory 4g \
            --executor-memory 4g \
            --packages "$SPARK_PACKAGES"

H2O.ai 一直在与 Spark 项目的最新版本保持同步,以匹配 Sparkling Water 的版本。本书使用 Spark 2.1.1 分发版和 Sparkling Water 2.1.12。您可以在h2o.ai/download/找到适用于您版本 Spark 的最新版本 Sparkling Water。

本案例使用提供的 Spark shell,该 shell 下载并使用 Sparkling Water 版本 2.1.12 的 Spark 软件包。这些软件包由 Maven 坐标标识 - 在本例中,ai.h2o代表组织 ID,sparkling-water-core标识 Sparkling Water 实现(对于 Scala 2.11,因为 Scala 版本不兼容),最后,2.1.12是软件包的版本。此外,我们正在使用本书特定的软件包,该软件包提供了一些实用工具。

所有已发布的 Sparkling Water 版本列表也可以在 Maven 中央仓库上找到:search.maven.org

该命令在本地模式下启动 Spark - 也就是说,Spark 集群在您的计算机上运行一个单节点。假设您成功完成了所有这些操作,您应该会看到标准的 Spark shell 输出,就像这样:

图 2 - 注意 shell 启动时显示的 Spark 版本。

提供的书籍源代码为每一章提供了启动 Spark 环境的命令;对于本章,您可以在chapter2/bin文件夹中找到它。

Spark shell 是一个基于 Scala 的控制台应用程序,它接受 Scala 代码并以交互方式执行。下一步是通过导入我们将在示例中使用的软件包来准备计算环境。

import org.apache.spark.mllib 
import org.apache.spark.mllib.regression.LabeledPoint 
import org.apache.spark.mllib.linalg._ 
import org.apache.spark.mllib.linalg.distributed.RowMatrix 
import org.apache.spark.mllib.util.MLUtils 
import org.apache.spark.mllib.evaluation._ 
import org.apache.spark.mllib.tree._ 
import org.apache.spark.mllib.tree.model._ 
import org.apache.spark.rdd._ 

让我们首先摄取您应该已经下载的.csv文件,并快速计算一下我们的子集中有多少数据。在这里,请注意,代码期望数据文件夹"data"相对于当前进程的工作目录或指定的位置:

val rawData = sc.textFile(s"${sys.env.get("DATADIR").getOrElse("data")}/higgs100k.csv")
println(s"Number of rows: ${rawData.count}") 

输出如下:

您可以观察到执行命令sc.textFile(...)几乎没有花费时间并立即返回,而执行rawData.count花费了大部分时间。这正好展示了 Spark 转换操作之间的区别。按设计,Spark 采用惰性评估 - 这意味着如果调用了一个转换,Spark 会直接记录它到所谓的执行图/计划中。这非常适合大数据世界,因为用户可以堆叠转换而无需等待。另一方面,操作会评估执行图 - Spark 会实例化每个记录的转换,并将其应用到先前转换的输出上。这个概念还帮助 Spark 在执行之前分析和优化执行图 - 例如,Spark 可以重新组织转换的顺序,或者决定如果它们是独立的话并行运行转换。

现在,我们定义了一个转换,它将数据加载到 Spark 数据结构RDD[String]中,其中包含输入数据文件的所有行。因此,让我们看一下前两行:

rawData.take(2) 

前两行包含从文件加载的原始数据。您可以看到一行由一个响应列组成,其值为 0,1(行的第一个值),其他列具有实际值。但是,这些行仍然表示为字符串,并且需要解析和转换为常规行。因此,基于对输入数据格式的了解,我们可以定义一个简单的解析器,根据逗号将输入行拆分为数字:

val data = rawData.map(line => line.split(',').map(_.toDouble)) 

现在我们可以提取响应列(数据集中的第一列)和表示输入特征的其余数据:

val response: RDD[Int] = data.map(row => row(0).toInt)   
val features: RDD[Vector] = data.map(line => Vectors.dense(line.slice(1, line.size))) 

进行这个转换之后,我们有两个 RDD:

  • 一个代表响应列

  • 另一个包含持有单个输入特征的数字的密集向量

接下来,让我们更详细地查看输入特征并进行一些非常基本的数据分析:

val featuresMatrix = new RowMatrix(features) 
val featuresSummary = featuresMatrix.computeColumnSummaryStatistics() 

我们将这个向量转换为分布式RowMatrix。这使我们能够执行简单的摘要统计(例如,计算均值、方差等)。


import org.apache.spark.utils.Tabulizer._ 
println(s"Higgs Features Mean Values = ${table(featuresSummary.mean, 8)}")

输出如下:

看一下以下代码:

println(s"Higgs Features Variance Values = ${table(featuresSummary.variance, 8)}") 

输出如下:

接下来,让我们更详细地探索列。我们可以直接获取每列中非零值的数量,以确定数据是密集还是稀疏。密集数据主要包含非零值,稀疏数据则相反。数据中非零值的数量与所有值的数量之间的比率代表了数据的稀疏度。稀疏度可以驱动我们选择计算方法,因为对于稀疏数据,仅迭代非零值更有效:

val nonZeros = featuresSummary.numNonzeros 
println(s"Non-zero values count per column: ${table(nonZeros, cols = 8, format = "%.0f")}") 

输出如下:

然而,该调用只是给出了所有列的非零值数量,这并不那么有趣。我们更感兴趣的是包含一些零值的列:

val numRows = featuresMatrix.numRows
 val numCols = featuresMatrix.numCols
 val colsWithZeros = nonZeros
   .toArray
   .zipWithIndex
   .filter { case (rows, idx) => rows != numRows }
 println(s"Columns with zeros:\n${table(Seq("#zeros", "column"), colsWithZeros, Map.empty[Int, String])}")

在这种情况下,我们通过每个值的索引增加了原始的非零向量,然后过滤掉原始矩阵中等于行数的所有值。然后我们得到:

我们可以看到列 8、12、16 和 20 包含一些零数,但仍然不足以将矩阵视为稀疏。为了确认我们的观察,我们可以计算矩阵的整体稀疏度(剩余部分:矩阵不包括响应列):

val sparsity = nonZeros.toArray.sum / (numRows * numCols)
println(f"Data sparsity: ${sparsity}%.2f") 

输出如下:

计算出的数字证实了我们之前的观察 - 输入矩阵是密集的。

现在是时候更详细地探索响应列了。作为第一步,我们通过计算响应向量中的唯一值来验证响应是否只包含值01

val responseValues = response.distinct.collect
 println(s"Response values: ${responseValues.mkString(", ")}") 

下一步是探索响应向量中标签的分布。我们可以直接通过 Spark 计算速率:

val responseDistribution = response.map(v => (v,1)).countByKey
 println(s"Response distribution:\n${table(responseDistribution)}") 

输出如下:

在这一步中,我们简单地将每行转换为表示行值的元组,以及表示该值在行中出现一次的1。拥有成对 RDDs 后,Spark 方法countByKey通过键聚合成对,并给我们提供了键计数的摘要。它显示数据意外地包含了略微更多代表希格斯玻色子的情况,但我们仍然可以认为响应是平衡的。

我们还可以利用 H2O 库以可视化的方式探索标签分布。为此,我们需要启动由H2OContext表示的 H2O 服务:

import org.apache.spark.h2o._ 
val h2oContext = H2OContext.getOrCreate(sc) 

该代码初始化了 H2O 库,并在 Spark 集群的每个节点上启动了 H2O 服务。它还提供了一个名为 Flow 的交互式环境,用于数据探索和模型构建。在控制台中,h2oContext打印出了暴露的 UI 的位置:

h2oContext: org.apache.spark.h2o.H2OContext =  
Sparkling Water Context: 
 * H2O name: sparkling-water-user-303296214 
 * number of executors: 1 
 * list of used executors: 
  (executorId, host, port) 
  ------------------------ 
  (driver,192.168.1.65,54321) 
  ------------------------ 
  Open H2O Flow in browser: http://192.168.1.65:54321 (CMD + click in Mac OSX) 

现在我们可以直接打开 Flow UI 地址并开始探索数据。但是,在这样做之前,我们需要将 Spark 数据发布为名为response的 H2O 框架:

val h2oResponse = h2oContext.asH2OFrame(response, "response")

如果您导入了H2OContext公开的隐式转换,您将能够根据赋值左侧的定义类型透明地调用转换:

例如:

import h2oContext.implicits._ 
val h2oResponse: H2OFrame = response 

现在是时候打开 Flow UI 了。您可以通过访问H2OContext报告的 URL 直接打开它,或者在 Spark shell 中键入h2oContext.openFlow来打开它。

图 3 - 交互式 Flow UI

Flow UI 允许与存储的数据进行交互式工作。让我们通过在突出显示的单元格中键入getFrames来查看 Flow 暴露的数据:

图 4 - 获取可用的 H2O 框架列表

通过点击响应字段或键入getColumnSummary "response", "values",我们可以直观地确认响应列中值的分布,并看到问题略微不平衡:

图 5 - 名为“response”的列的统计属性。

标记点向量

在使用 Spark MLlib 运行任何监督机器学习算法之前,我们必须将数据集转换为标记点向量,将特征映射到给定的标签/响应;标签存储为双精度,这有助于它们用于分类和回归任务。对于所有二元分类问题,标签应存储为01,我们从前面的摘要统计中确认了这一点对我们的例子成立。

val higgs = response.zip(features).map {  
case (response, features) =>  
LabeledPoint(response, features) } 

higgs.setName("higgs").cache() 

标记点向量的示例如下:

(1.0, [0.123, 0.456, 0.567, 0.678, ..., 0.789]) 

在前面的例子中,括号内的所有双精度数都是特征,括号外的单个数字是我们的标签。请注意,我们尚未告诉 Spark 我们正在执行分类任务而不是回归任务,这将在稍后发生。

在这个例子中,所有输入特征只包含数值,但在许多情况下,数据包含分类值或字符串数据。所有这些非数值表示都需要转换为数字,我们将在本书的后面展示。

数据缓存

许多机器学习算法具有迭代性质,因此需要对数据进行多次遍历。然而,默认情况下,存储在 Spark RDD 中的所有数据都是瞬时的,因为 RDD 只存储要执行的转换,而不是实际数据。这意味着每个操作都会通过执行 RDD 中存储的转换重新计算数据。

因此,Spark 提供了一种持久化数据的方式,以便我们需要对其进行迭代。Spark 还发布了几个StorageLevels,以允许使用各种选项存储数据:

  • NONE:根本不缓存

  • MEMORY_ONLY:仅在内存中缓存 RDD 数据

  • DISK_ONLY:将缓存的 RDD 数据写入磁盘并释放内存

  • MEMORY_AND_DISK:如果无法将数据卸载到磁盘,则在内存中缓存 RDD

  • OFF_HEAP:使用不属于 JVM 堆的外部内存存储

此外,Spark 为用户提供了以两种方式缓存数据的能力:原始(例如MEMORY_ONLY)和序列化(例如MEMORY_ONLY_SER)。后者使用大型内存缓冲区直接存储 RDD 的序列化内容。使用哪种取决于任务和资源。一个很好的经验法则是,如果你正在处理的数据集小于 10 吉字节,那么原始缓存优于序列化缓存。然而,一旦超过 10 吉字节的软阈值,原始缓存比序列化缓存占用更大的内存空间。

Spark 可以通过在 RDD 上调用cache()方法或直接通过调用带有所需持久目标的 persist 方法(例如persist(StorageLevels.MEMORY_ONLY_SER))来强制缓存。有用的是 RDD 只允许我们设置存储级别一次。

决定缓存什么以及如何缓存是 Spark 魔术的一部分;然而,黄金法则是在需要多次访问 RDD 数据并根据应用程序偏好选择目标时使用缓存,尊重速度和存储。一个很棒的博客文章比这里提供的更详细,可以在以下链接找到:

sujee.net/2015/01/22/understanding-spark-caching/#.VpU1nJMrLdc

缓存的 RDD 也可以通过在 H2O Flow UI 中评估带有getRDDs的单元格来访问:

创建训练和测试集

与大多数监督学习任务一样,我们将创建数据集的拆分,以便在一个子集上模型,然后测试其对新数据的泛化能力,以便与留出集进行比较。在本例中,我们将数据拆分为 80/20,但是拆分比例没有硬性规定,或者说 - 首先应该有多少拆分:

// Create Train & Test Splits 
val trainTestSplits = higgs.randomSplit(Array(0.8, 0.2)) 
val (trainingData, testData) = (trainTestSplits(0), trainTestSplits(1)) 

通过在数据集上创建 80/20 的拆分,我们随机抽取了 880 万个示例作为训练集,剩下的 220 万个作为测试集。我们也可以随机抽取另一个 80/20 的拆分,并生成一个具有相同数量示例(880 万个)但具有不同数据的新训练集。这种拆分我们原始数据集的方法引入了抽样偏差,这基本上意味着我们的模型将学会拟合训练数据,但训练数据可能不代表“现实”。鉴于我们已经使用了 1100 万个示例,这种偏差并不像我们的原始数据集只有 100 行的情况那样显著。这通常被称为模型验证的留出法

您还可以使用 H2O Flow 来拆分数据:

  1. 将希格斯数据发布为 H2OFrame:
val higgsHF = h2oContext.asH2OFrame(higgs.toDF, "higgsHF") 
  1. 在 Flow UI 中使用splitFrame命令拆分数据(见图 07)。

  2. 然后将结果发布回 RDD。

图 7 - 将希格斯数据集拆分为代表 80%和 20%数据的两个 H2O 框架。

与 Spark 的惰性评估相比,H2O 计算模型是急切的。这意味着splitFrame调用会立即处理数据并创建两个新框架,可以直接访问。

交叉验证呢?

通常,在较小的数据集的情况下,数据科学家会使用一种称为交叉验证的技术,这种技术在 Spark 中也可用。CrossValidator类首先将数据集分成 N 折(用户声明),每个折叠被用于训练集 N-1 次,并用于模型验证 1 次。例如,如果我们声明希望使用5 折交叉验证CrossValidator类将创建五对(训练和测试)数据集,使用四分之四的数据集创建训练集,最后四分之一作为测试集,如下图所示。

我们的想法是,我们将看到我们的算法在不同的随机抽样数据集上的性能,以考虑我们在 80%的数据上创建训练/测试拆分时固有的抽样偏差。一个不太好泛化的模型的例子是,准确性(例如整体错误)会在不同的错误率上大幅度变化,这表明我们需要重新考虑我们的模型。

图 8 - 5 折交叉验证的概念模式。

关于应该执行多少次交叉验证并没有固定的规则,因为这些问题在很大程度上取决于所使用的数据类型、示例数量等。在某些情况下,进行极端的交叉验证是有意义的,其中 N 等于输入数据集中的数据点数。在这种情况下,测试集只包含一行。这种方法称为留一法LOO)验证,计算成本更高。

一般来说,建议在模型构建过程中进行一些交叉验证(通常建议使用 5 折或 10 折交叉验证),以验证模型的质量 - 尤其是当数据集很小的时候。

我们的第一个模型 - 决策树

我们尝试使用决策树算法来对希格斯玻色子和背景噪音进行分类。我们故意不解释这个算法背后的直觉,因为这已经有大量支持文献供读者消化(www.saedsayad.com/decision_tree.htm, http://spark.apache.org/docs/latest/mllib-decision-tree.html)。相反,我们将专注于超参数以及如何根据特定标准/错误度量来解释模型的有效性。让我们从基本参数开始:

val numClasses = 2 
val categoricalFeaturesInfo = Map[Int, Int]() 
val impurity = "gini" 
val maxDepth = 5 
val maxBins = 10 

现在我们明确告诉 Spark,我们希望构建一个决策树分类器,用于区分两类。让我们更仔细地看看我们决策树的一些超参数,看看它们的含义:

numClasses:我们要分类多少类?在这个例子中,我们希望区分希格斯玻色子粒子和背景噪音,因此有四类:

  • categoricalFeaturesInfo:一种规范,声明哪些特征是分类特征,不应被视为数字(例如,邮政编码是一个常见的例子)。在这个数据集中,我们不需要担心有分类特征。

  • 杂质:节点标签同质性的度量。目前在 Spark 中,关于分类有两种杂质度量:基尼和熵,回归有一个杂质度量:方差。

  • maxDepth:限制构建树的深度的停止准则。通常,更深的树会导致更准确的结果,但也会有过拟合的风险。

  • maxBins:树在进行分裂时考虑的箱数(考虑“值”)。通常,增加箱数允许树考虑更多的值,但也会增加计算时间。

基尼与熵

为了确定使用哪种杂质度量,重要的是我们先了解一些基础知识,从信息增益的概念开始。

在本质上,信息增益就是它听起来的样子:在两种状态之间移动时的信息增益。更准确地说,某个事件的信息增益是事件发生前后已知信息量的差异。衡量这种信息的一种常见方法是查看,可以定义为:

其中p[j]是节点上标签j的频率。

现在您已经了解了信息增益和熵的概念,我们可以继续了解基尼指数的含义(与基尼系数完全没有关联)。

基尼指数:是一个度量,表示如果随机选择一个元素,根据给定节点的标签分布随机分配标签,它会被错误分类的频率。

与熵的方程相比,由于没有对数计算,基尼指数的计算速度应该稍快一些,这可能是为什么它是许多其他机器学习库(包括 MLlib)的默认选项。

但这是否使它成为我们决策树分裂的更好度量?事实证明,杂质度量的选择对于单个决策树算法的性能几乎没有影响。根据谭等人在《数据挖掘导论》一书中的说法,原因是:

“...这是因为杂质度量在很大程度上是一致的 [...]. 实际上,用于修剪树的策略对最终树的影响大于杂质度量的选择。”

现在是时候在训练数据上训练我们的决策树分类器了:

val dtreeModel = DecisionTree.trainClassifier( 
trainingData,  
numClasses,  
categoricalFeaturesInfo, 
impurity,  
maxDepth,  
maxBins) 

// Show the tree 
println("Decision Tree Model:\n" + dtreeModel.toDebugString) 

这应该产生一个最终输出,看起来像这样(请注意,由于数据的随机分割,您的结果可能会略有不同):

输出显示决策树的深度为5,有63个节点按层次化的决策谓词组织。让我们继续解释一下,看看前五个决策。它的读法是:“如果特征 25 的值小于或等于 1.0559 并且小于或等于 0.61558 并且特征 27 的值小于或等于 0.87310 并且特征 5 的值小于或等于 0.89683 并且最后,特征 22 的值小于或等于 0.76688,那么预测值为 1.0(希格斯玻色子)。但是,这五个条件必须满足才能成立。”请注意,如果最后一个条件不成立(特征 22 的值大于 0.76688),但前四个条件仍然成立,那么预测将从 1 变为 0,表示背景噪音。

现在,让我们对我们的测试数据集对模型进行评分并打印预测错误:

val treeLabelAndPreds = testData.map { point =>
   val prediction = dtreeModel.predict(point.features)
   (point.label.toInt, prediction.toInt)
 }

 val treeTestErr = treeLabelAndPreds.filter(r => r._1 != r._2).count.toDouble / testData.count()
 println(f"Tree Model: Test Error = ${treeTestErr}%.3f") 

输出如下:

一段时间后,模型将对所有测试集数据进行评分,然后计算一个我们在前面的代码中定义的错误率。同样,你的错误率可能会略有不同,但正如我们所展示的,我们的简单决策树模型的错误率约为 33%。然而,正如你所知,我们可能会犯不同类型的错误,因此值得探索一下通过构建混淆矩阵来了解这些错误类型是什么:

val cm = treeLabelAndPreds.combineByKey( 
  createCombiner = (label: Int) => if (label == 0) (1,0) else (0,1),  
  mergeValue = (v:(Int,Int), label:Int) => if (label == 0) (v._1 +1, v._2) else (v._1, v._2 + 1), 
  mergeCombiners = (v1:(Int,Int), v2:(Int,Int)) => (v1._1 + v2._1, v1._2 + v2._2)).collect 

前面的代码使用了高级的 Spark 方法combineByKey,它允许我们将每个(K,V)对映射到一个值,这个值将代表按键操作的输出。在这种情况下,(K,V)对表示实际值 K 和预测值 V。我们通过创建一个组合器(参数createCombiner)将每个预测映射到一个元组 - 如果预测值为0,则映射为(1,0);否则,映射为(0,1)。然后我们需要定义组合器如何接受新值以及如何合并组合器。最后,该方法产生:

cm: Array[(Int, (Int, Int))] = Array((0,(5402,4131)), (1,(2724,7846))) 

生成的数组包含两个元组 - 一个用于实际值0,另一个用于实际值1。每个元组包含预测01的数量。因此,很容易提取所有必要的内容来呈现一个漂亮的混淆矩阵。

val (tn, tp, fn, fp) = (cm(0)._2._1, cm(1)._2._2, cm(1)._2._1, cm(0)._2._2) 
println(f"""Confusion Matrix 
  |   ${0}%5d ${1}%5d  ${"Err"}%10s 
  |0  ${tn}%5d ${fp}%5d ${tn+fp}%5d ${fp.toDouble/(tn+fp)}%5.4f 
  |1  ${fn}%5d ${tp}%5d ${fn+tp}%5d ${fn.toDouble/(fn+tp)}%5.4f 
  |   ${tn+fn}%5d ${fp+tp}%5d ${tn+fp+fn+tp}%5d ${(fp+fn).toDouble/(tn+fp+fn+tp)}%5.4f 
  |""".stripMargin) 

该代码提取了所有真负和真正的预测,还有错过的预测和基于图 9模板的混淆矩阵的输出:

在前面的代码中,我们使用了一个强大的 Scala 特性,称为字符串插值println(f"...")。它允许通过组合字符串输出和实际的 Scala 变量来轻松构造所需的输出。Scala 支持不同的字符串“插值器”,但最常用的是sfs插值器允许引用任何 Scala 变量甚至代码:s"True negative: ${tn}"。而f插值器是类型安全的 - 这意味着用户需要指定要显示的变量类型:f"True negative: ${tn}%5d" - 并引用变量tn作为十进制类型,并要求在五个十进制空间上打印。

回到本章的第一个例子,我们可以看到我们的模型在检测实际的玻色子粒子时出现了大部分错误。在这种情况下,代表玻色子检测的所有数据点都被错误地分类为非玻色子。然而,总体错误率非常低!这是一个很好的例子,说明总体错误率可能会对具有不平衡响应的数据集产生误导。

图 9 - 混淆矩阵模式。

接下来,我们将考虑另一个用于评判分类模型的建模指标,称为曲线下面积(受试者工作特征)AUC(请参见下图示例)。受试者工作特征ROC)曲线是真正率假正率的图形表示:

  • 真正阳性率:真正阳性的总数除以真正阳性和假阴性的总和。换句话说,它是希格斯玻色子粒子的真实信号(实际标签为 1)与希格斯玻色子的所有预测信号(我们的模型预测标签为 1)的比率。该值显示在y轴上。

  • 假正率:假阳性的总数除以假阳性和真阴性的总和,这在x轴上绘制。

  • 有关更多指标,请参见“从混淆矩阵派生的指标”图。

图 10 - 具有 AUC 值 0.94 的样本 AUC 曲线

由此可见,ROC 曲线描绘了我们的模型在给定决策阈值下 TPR 与 FPR 的权衡。因此,ROC 曲线下的面积可以被视为平均模型准确度,其中 1.0 代表完美分类,0.5 代表抛硬币(意味着我们的模型在猜测 1 或 0 时做了一半的工作),小于 0.5 的任何值都意味着抛硬币比我们的模型更准确!这是一个非常有用的指标,我们将看到它可以用来与不同的超参数调整和不同的模型进行比较!让我们继续创建一个函数,用于计算我们的决策树模型的 AUC,以便与其他模型进行比较:

type Predictor = {  
  def predict(features: Vector): Double 
} 

def computeMetrics(model: Predictor, data: RDD[LabeledPoint]): BinaryClassificationMetrics = { 
    val predAndLabels = data.map(newData => (model.predict(newData.features), newData.label)) 
      new BinaryClassificationMetrics(predAndLabels) 
} 

val treeMetrics = computeMetrics(dtreeModel, testData) 
println(f"Tree Model: AUC on Test Data = ${treeMetrics.areaUnderROC()}%.3f") 

输出如下:

Spark MLlib 模型没有共同的接口定义;因此,在前面的例子中,我们必须定义类型Predictor,公开方法predict并在方法computeMetrics的定义中使用 Scala 结构化类型。本书的后面部分将展示基于统一管道 API 的 Spark ML 包。

图 11 - 从混淆矩阵派生的指标。

对这个主题感兴趣吗?没有一本圣经是万能的。斯坦福大学著名统计学教授 Trevor Hastie 的书《统计学习的要素》是一个很好的信息来源。这本书为机器学习的初学者和高级实践者提供了有用的信息,强烈推荐。

需要记住的是,由于 Spark 决策树实现在内部使用RandomForest算法,如果未指定随机生成器的种子,运行之间的结果可能会略有不同。问题在于 Spark 的 MLLib APIDecisionTree不允许将种子作为参数传递。

下一个模型 - 树集成

随机森林(RF)或梯度提升机(GBM)(也称为梯度提升树)等算法是目前在 MLlib 中可用的集成基于树的模型的两个例子;您可以将集成视为代表基本模型集合的超级模型。想要了解集成在幕后的工作原理,最好的方法是考虑一个简单的类比:

“假设你是一家著名足球俱乐部的主教练,你听说了一位来自巴西的不可思议的运动员的传闻,签下这位年轻运动员可能对你的俱乐部有利,但你的日程安排非常繁忙,所以你派了 10 名助理教练去评估这位球员。你的每一位助理教练都根据他/她的教练理念对球员进行评分——也许有一位教练想要测量球员跑 40 码的速度,而另一位教练认为身高和臂展很重要。无论每位教练如何定义“运动员潜力”,你作为主教练,只想知道你是否应该立即签下这位球员或者等待。于是你的教练们飞到巴西,每位教练都做出了评估;到达后,你走到每位教练面前问:“我们现在应该选这位球员还是等一等?”根据多数投票的简单规则,你可以做出决定。这是一个关于集成在分类任务中背后所做的事情的例子。”

您可以将每个教练看作是一棵决策树,因此您将拥有 10 棵树的集合(对应 10 个教练)。每个教练如何评估球员都是非常具体的,我们的树也是如此;对于创建的 10 棵树,每个节点都会随机选择特征(因此 RF 中有随机性,因为有很多树!)。引入这种随机性和其他基本模型的原因是防止过度拟合数据。虽然 RF 和 GBM 都是基于树的集合,但它们训练的方式略有不同,值得一提。

GBM 必须一次训练一棵树,以最小化loss函数(例如log-loss,平方误差等),通常比 RF 需要更长的时间来训练,因为 RF 可以并行生成多棵树。

然而,在训练 GBM 时,建议制作浅树,这反过来有助于更快的训练。

  • RFs 通常不像 GBM 那样过度拟合数据;也就是说,我们可以向我们的森林中添加更多的树,而不容易过度拟合,而如果我们向我们的 GBM 中添加更多的树,就更容易过度拟合。

  • RF 的超参数调整比 GBM 简单得多。在他的论文《超参数对随机森林准确性的影响》中,Bernard 等人通过实验证明,在每个节点选择的 K 个随机特征数是模型准确性的关键影响因素。相反,GBM 有更多必须考虑的超参数,如loss函数、学习率、迭代次数等。

与大多数数据科学中的“哪个更好”问题一样,选择 RF 和 GBM 是开放式的,非常依赖任务和数据集。

随机森林模型

现在,让我们尝试使用 10 棵决策树构建一个随机森林。

val numClasses = 2 
val categoricalFeaturesInfo = Map[Int, Int]() 
val numTrees = 10 
val featureSubsetStrategy = "auto"  
val impurity = "gini" 
val maxDepth = 5 
val maxBins = 10 
val seed = 42 

val rfModel = RandomForest.trainClassifier(trainingData, numClasses, categoricalFeaturesInfo, 
  numTrees, featureSubsetStrategy, impurity, maxDepth, maxBins, seed) 

就像我们的单棵决策树模型一样,我们首先声明超参数,其中许多参数您可能已经从决策树示例中熟悉。在前面的代码中,我们将创建一个由 10 棵树解决两类问题的随机森林。一个不同的关键特性是特征子集策略,描述如下:

featureSubsetStrategy对象给出了要在每个节点进行分割的候选特征数。可以是一个分数(例如 0.5),也可以是基于数据集中特征数的函数。设置auto允许算法为您选择这个数字,但一个常见的软规则是使用您拥有的特征数的平方根。

现在我们已经训练好了我们的模型,让我们对我们的留出集进行评分并计算总误差:

def computeError(model: Predictor, data: RDD[LabeledPoint]): Double = {  
  val labelAndPreds = data.map { point => 
    val prediction = model.predict(point.features) 
    (point.label, prediction) 
  } 
  labelAndPreds.filter(r => r._1 != r._2).count.toDouble/data.count 
} 
val rfTestErr = computeError(rfModel, testData) 
println(f"RF Model: Test Error = ${rfTestErr}%.3f") 

输出如下:

还可以使用已定义的computeMetrics方法计算 AUC:


val rfMetrics = computeMetrics(rfModel, testData) 
println(f"RF Model: AUC on Test Data = ${rfMetrics.areaUnderROC}%.3f") 

我们的 RF - 在其中硬编码超参数 - 相对于整体模型错误和 AUC 表现得比我们的单棵决策树要好得多。在下一节中,我们将介绍网格搜索的概念以及我们如何尝试变化超参数值/组合并衡量对模型性能的影响。

再次强调,结果在运行之间可能略有不同。但是,与决策树相比,可以通过将种子作为RandomForest.trainClassifier方法的参数传递来使运行确定性。

网格搜索

在 MLlib 和 H2O 中,与大多数算法一样,有许多可以选择的超参数,这些超参数对模型的性能有显著影响。鉴于可能存在无限数量的组合,我们是否可以以智能的方式开始查看哪些组合比其他组合更有前途?幸运的是,答案是“YES!”解决方案被称为网格搜索,这是运行使用不同超参数组合的许多模型的 ML 术语。

让我们尝试使用 RF 算法运行一个简单的网格搜索。在这种情况下,RF 模型构建器被调用,用于从定义的超参数空间中的每个参数组合:

val rfGrid =  
    for ( 
    gridNumTrees <- Array(15, 20); 
    gridImpurity <- Array("entropy", "gini"); 
    gridDepth <- Array(20, 30); 
    gridBins <- Array(20, 50)) 
        yield { 
    val gridModel = RandomForest.trainClassifier(trainingData, 2, Map[Int, Int](), gridNumTrees, "auto", gridImpurity, gridDepth, gridBins) 
    val gridAUC = computeMetrics(gridModel, testData).areaUnderROC 
    val gridErr = computeError(gridModel, testData) 
    ((gridNumTrees, gridImpurity, gridDepth, gridBins), gridAUC, gridErr) 
  } 

我们刚刚写的是一个for循环,它将尝试不同组合的数量,涉及树的数量、不纯度类型、树的深度和 bin 值(即要尝试的值);然后,对于基于这些超参数排列组合创建的每个模型,我们将对训练模型进行评分,同时计算 AUC 指标和整体错误率。总共我们得到2222=16*个模型。再次强调,您的模型可能与我们在此处展示的模型略有不同,但您的输出应该类似于这样:

查看我们输出的第一个条目:

|(15,entropy,20,20)|0.697|0.302|

我们可以这样解释:对于 15 棵决策树的组合,使用熵作为我们的不纯度度量,以及树深度为 20(对于每棵树)和 bin 值为 20,我们的 AUC 为0.695。请注意,结果按照您最初编写它们的顺序显示。对于我们使用 RF 算法的网格搜索,我们可以轻松地获得产生最高 AUC 的超参数组合:

val rfParamsMaxAUC = rfGrid.maxBy(g => g._2)
println(f"RF Model: Parameters ${rfParamsMaxAUC._1}%s producing max AUC = ${rfParamsMaxAUC._2}%.3f (error = ${rfParamsMaxAUC._3}%.3f)") 

输出如下:

梯度提升机

到目前为止,我们能够达到的最佳 AUC 是一个 15 棵决策树的 RF,其 AUC 值为0.698。现在,让我们通过相同的过程来运行一个使用硬编码超参数的单个梯度提升机,然后对这些参数进行网格搜索,以查看是否可以使用该算法获得更高的 AUC。

回顾一下,由于其迭代性质试图减少我们事先声明的总体loss函数,GBM 与 RF 略有不同。在 MLlib 中,截至 1.6.0,有三种不同的损失函数可供选择:

  • 对数损失:对于分类任务使用这个loss函数(请注意,对于 Spark,GBM 仅支持二元分类。如果您希望对多类分类使用 GBM,请使用 H2O 的实现,我们将在下一章中展示)。

  • 平方误差:对于回归任务使用这个loss函数,它是这种类型问题的当前默认loss函数。

  • 绝对误差:另一个可用于回归任务的loss函数。鉴于该函数取预测值和实际值之间的绝对差异,它比平方误差更好地控制异常值。

考虑到我们的二元分类任务,我们将使用log-loss函数并开始构建一个 10 棵树的 GBM 模型:

import org.apache.spark.mllib.tree.GradientBoostedTrees
 import org.apache.spark.mllib.tree.configuration.BoostingStrategy
 import org.apache.spark.mllib.tree.configuration.Algo

 val gbmStrategy = BoostingStrategy.defaultParams(Algo.Classification)
 gbmStrategy.setNumIterations(10)
 gbmStrategy.setLearningRate(0.1)
 gbmStrategy.treeStrategy.setNumClasses(2)
 gbmStrategy.treeStrategy.setMaxDepth(10)
 gbmStrategy.treeStrategy.setCategoricalFeaturesInfo(java.util.Collections.emptyMap[Integer, Integer])

 val gbmModel = GradientBoostedTrees.train(trainingData, gbmStrategy)

请注意,我们必须在构建模型之前声明一个提升策略。原因是 MLlib 不知道我们要解决什么类型的问题:分类还是回归?因此,这个策略让 Spark 知道这是一个二元分类问题,并使用声明的超参数来构建我们的模型。

以下是一些训练 GBM 时要记住的超参数:

  • numIterations:根据定义,GBM 一次构建一棵树,以最小化我们声明的loss函数。这个超参数控制要构建的树的数量;要小心不要构建太多的树,因为测试时的性能可能不理想。

  • loss:您声明使用哪个loss函数取决于所提出的问题和数据集。

  • learningRate:优化学习速度。较低的值(<0.1)意味着学习速度较慢,泛化效果更好。然而,它也需要更多的迭代次数,因此计算时间更长。

让我们对保留集对这个模型进行评分,并计算我们的 AUC:

val gbmTestErr = computeError(gbmModel, testData) 
println(f"GBM Model: Test Error = ${gbmTestErr}%.3f") 
val gbmMetrics = computeMetrics(dtreeModel, testData) 
println(f"GBM Model: AUC on Test Data = ${gbmMetrics.areaUnderROC()}%.3f") 

输出如下:

最后,我们将对一些超参数进行网格搜索,并且与我们之前的 RF 网格搜索示例类似,输出组合及其相应的错误和 AUC 计算:

val gbmGrid =  
for ( 
  gridNumIterations <- Array(5, 10, 50); 
  gridDepth <- Array(2, 3, 5, 7); 
  gridLearningRate <- Array(0.1, 0.01))  
yield { 
  gbmStrategy.numIterations = gridNumIterations 
  gbmStrategy.treeStrategy.maxDepth = gridDepth 
  gbmStrategy.learningRate = gridLearningRate 

  val gridModel = GradientBoostedTrees.train(trainingData, gbmStrategy) 
  val gridAUC = computeMetrics(gridModel, testData).areaUnderROC 
  val gridErr = computeError(gridModel, testData) 
  ((gridNumIterations, gridDepth, gridLearningRate), gridAUC, gridErr) 
} 

我们可以打印前 10 行结果,按 AUC 排序:

println(
s"""GBM Model: Grid results:
      |${table(Seq("iterations, depth, learningRate", "AUC", "error"), gbmGrid.sortBy(-_._2).take(10), format = Map(1 -> "%.3f", 2 -> "%.3f"))}
""".stripMargin)

输出如下:

而且我们可以很容易地得到产生最大 AUC 的模型:

val gbmParamsMaxAUC = gbmGrid.maxBy(g => g._2) 
println(f"GBM Model: Parameters ${gbmParamsMaxAUC._1}%s producing max AUC = ${gbmParamsMaxAUC._2}%.3f (error = ${gbmParamsMaxAUC._3}%.3f)") 

输出如下:

最后一个模型-H2O 深度学习

到目前为止,我们使用 Spark MLlib 构建了不同的模型;然而,我们也可以使用 H2O 算法。所以让我们试试吧!

首先,我们将我们的训练和测试数据集传输到 H2O,并为我们的二元分类问题创建一个 DNN。重申一遍,这是可能的,因为 Spark 和 H2O 共享相同的 JVM,这有助于将 Spark RDD 传递到 H2O 六角框架,反之亦然。

到目前为止,我们运行的所有模型都是在 MLlib 中,但现在我们将使用 H2O 来使用相同的训练和测试集构建一个 DNN,这意味着我们需要将这些数据发送到我们的 H2O 云中,如下所示:

val trainingHF = h2oContext.asH2OFrame(trainingData.toDF, "trainingHF") 
val testHF = h2oContext.asH2OFrame(testData.toDF, "testHF") 

为了验证我们已成功转移我们的训练和测试 RDD(我们转换为数据框),我们可以在我们的 Flow 笔记本中执行这个命令(所有命令都是用Shift+Enter执行的)。请注意,我们现在有两个名为trainingRDDtestRDD的 H2O 框架,您可以通过运行命令getFrames在我们的 H2O 笔记本中看到。

图 12 - 通过在 Flow UI 中输入“getFrames”可以查看可用的 H2O 框架列表。

我们可以很容易地探索框架,查看它们的结构,只需在 Flow 单元格中键入getFrameSummary "trainingHF",或者只需点击框架名称(参见图 13)。

图 13 - 训练框架的结构。

上图显示了训练框架的结构-它有 80,491 行和 29 列;有名为features0features1的数值列,具有实际值,第一列标签包含整数值。

由于我们想进行二元分类,我们需要将“label”列从整数转换为分类类型。您可以通过在 Flow UI 中点击Convert to enum操作或在 Spark 控制台中执行以下命令来轻松实现:

trainingHF.replace(0, trainingHF.vecs()(0).toCategoricalVec).remove() 
trainingHF.update() 

testHF.replace(0, testHF.vecs()(0).toCategoricalVec).remove() 
testHF.update() 

该代码将第一个向量替换为转换后的向量,并从内存中删除原始向量。此外,调用update将更改传播到共享的分布式存储中,因此它们对集群中的所有节点都是可见的。

构建一个 3 层 DNN

H2O 暴露了略有不同的构建模型的方式;然而,它在所有 H2O 模型中是统一的。有三个基本构建模块:

  • 模型参数:定义输入和特定算法参数

  • 模型构建器:接受模型参数并生成模型

  • 模型:包含模型定义,但也包括有关模型构建的技术信息,如每次迭代的得分时间或错误率。

在构建我们的模型之前,我们需要为深度学习算法构建参数:

import _root_.hex.deeplearning._ 
import DeepLearningParameters.Activation 

val dlParams = new DeepLearningParameters() 
dlParams._train = trainingHF._key 
dlParams._valid = testHF._key 
dlParams._response_column = "label" 
dlParams._epochs = 1 
dlParams._activation = Activation.RectifierWithDropout 
dlParams._hidden = ArrayInt 

让我们浏览一下参数,并找出我们刚刚初始化的模型:

  • trainvalid:指定我们创建的训练和测试集。请注意,这些 RDD 实际上是 H2O 框架。

  • response_column:指定我们使用的标签,我们之前声明的是每个框架中的第一个元素(从 0 开始索引)。

  • epochs:这是一个非常重要的参数,它指定网络应该在训练数据上传递多少次;通常,使用更高epochs训练的模型允许网络学习新特征并产生更好的模型结果。然而,这种训练时间较长的网络容易出现过拟合,并且可能在新数据上泛化效果不佳。

  • 激活:这些是将应用于输入数据的各种非线性函数。在 H2O 中,有三种主要的激活函数可供选择:

  • Rectifier:有时被称为整流线性单元ReLU),这是一个函数,其下限为0,但以线性方式达到正无穷大。从生物学的角度来看,这些单元被证明更接近实际的神经元激活。目前,这是 H2O 中默认的激活函数,因为它在图像识别和速度等任务中的结果。

图 14 - 整流器激活函数

  • Tanh:一个修改后的逻辑函数,其范围在-11之间,但在(0,0)处通过原点。由于其在0周围的对称性,收敛通常更快。

图 15 - 双曲正切激活函数和逻辑函数 - 注意双曲正切之间的差异。

  • Maxout:一种函数,其中每个神经元选择来自 k 个单独通道的最大值:

  • hidden:另一个非常重要的超参数,这是我们指定两件事的地方:

  • 层的数量(您可以使用额外的逗号创建)。请注意,在 GUI 中,默认参数是一个具有每层 200 个隐藏神经元的两层隐藏网络。

  • 每层的神经元数量。与大多数关于机器学习的事情一样,关于这个数字应该是多少并没有固定的规则,通常最好进行实验。然而,在下一章中,我们将介绍一些额外的调整参数,这将帮助您考虑这一点,即:L1 和 L2 正则化和丢失。

添加更多层

增加网络层的原因来自于我们对人类视觉皮层工作原理的理解。这是大脑后部的一个专门区域,用于识别物体/图案/数字等,并由复杂的神经元层组成,用于编码视觉信息并根据先前的知识进行分类。

毫不奇怪,网络需要多少层才能产生良好的结果并没有固定的规则,强烈建议进行实验!

构建模型和检查结果

现在您已经了解了一些关于参数和我们想要运行的模型的信息,是时候继续训练和检查我们的网络了:

val dl = new DeepLearning(dlParams) 
val dlModel = dl.trainModel.get 

代码创建了DeepLearning模型构建器并启动了它。默认情况下,trainModel的启动是异步的(即它不会阻塞,但会返回一个作业),但可以通过调用get方法等待计算结束。您还可以在 UI 中探索作业进度,甚至可以通过在 Flow UI 中键入getJobs来探索未完成的模型(参见图 18)。

图 18 - 命令 getJobs 提供了一个已执行作业的列表及其状态。

计算的结果是一个深度学习模型 - 我们可以直接从 Spark shell 探索模型及其细节:

println(s"DL Model: ${dlModel}") 

我们还可以通过调用模型的score方法直接获得测试数据的预测框架:

val testPredictions = dlModel.score(testHF) 

testPredictions: water.fvec.Frame = 
Frame _95829d4e695316377f96db3edf0441ee (19912 rows and 3 cols): 
         predict                   p0                    p1 
    min           0.11323123896925524  0.017864442175851737 
   mean            0.4856033079851807    0.5143966920148184 
 stddev            0.1404849885490033   0.14048498854900326 
    max            0.9821355578241482    0.8867687610307448 
missing                           0.0                   0.0 
      0        1   0.3908680007591152    0.6091319992408847 
      1        1   0.3339873797352686    0.6660126202647314 
      2        1   0.2958578897481016    0.7041421102518984 
      3        1   0.2952981947808155    0.7047018052191846 
      4        0   0.7523906949762337   0.24760930502376632 
      5        1   0.53559438105240... 

表格包含三列:

  • predict:基于默认阈值的预测值

  • p0:选择类 0 的概率

  • p1:选择类 1 的概率

我们还可以获得测试数据的模型指标:

import water.app.ModelMetricsSupport._ 
val dlMetrics = binomialMM(dlModel, testHF) 

输出直接显示了 AUC 和准确率(相应的错误率)。请注意,该模型在预测希格斯玻色子方面确实很好;另一方面,它的假阳性率很高!

最后,让我们看看如何使用 GUI 构建类似的模型,只是这一次,我们将从模型中排除物理学家手工提取的特征,并在内部层使用更多的神经元:

  1. 选择用于 TrainingHF 的模型。

正如您所看到的,H2O 和 MLlib 共享许多相同的算法,但功能级别不同。在这里,我们将选择深度学习,然后取消选择最后八个手工提取的特征。

图 19- 选择模型算法

  1. 构建 DNN 并排除手工提取的特征。

在这里,我们手动选择忽略特征 21-27,这些特征代表物理学家提取的特征,希望我们的网络能够学习它们。还要注意,如果选择这条路线,还可以执行 k 折交叉验证。

图 20 - 选择输入特征。

  1. 指定网络拓扑。

正如您所看到的,我们将使用整流器激活函数构建一个三层 DNN,其中每一层将有 1,024 个隐藏神经元,并且将运行 100 个epochs

图 21 - 配置具有 3 层,每层 1024 个神经元的网络拓扑。

  1. 探索模型结果。

运行此模型后,需要一些时间,我们可以单击“查看”按钮来检查训练集和测试集的 AUC:

图 22 - 验证数据的 AUC 曲线。

如果您点击鼠标并在 AUC 曲线的某个部分上拖放,实际上可以放大该曲线的特定部分,并且 H2O 会提供有关所选区域的阈值的准确性和精度的摘要统计信息。

图 23 - ROC 曲线可以轻松探索以找到最佳阈值。

此外,还有一个标有预览普通的 Java 对象POJO)的小按钮,我们将在后面的章节中探讨,这是您将模型部署到生产环境中的方式。

好的,我们已经建立了几十个模型;现在是时候开始检查我们的结果,并找出哪一个在整体错误和 AUC 指标下给我们最好的结果。有趣的是,当我们在办公室举办许多聚会并与顶级 kagglers 交谈时,这些显示结果的表格经常被构建,这是一种跟踪 a)什么有效和什么无效的好方法,b)回顾您尝试过的东西作为一种文档形式。

模型 错误 AUC
决策树 0.332 0.665
网格搜索:随机森林 0.294 0.704
网格搜索:GBM 0.287 0.712
深度学习 - 所有特征 0.376 0.705
深度学习 - 子集特征 0.301 0.716

那么我们选择哪一个?在这种情况下,我们喜欢 GBM 模型,因为它提供了第二高的 AUC 值和最低的准确率。但是这个决定总是由建模目标驱动 - 在这个例子中,我们严格受到模型在发现希格斯玻色子方面的准确性的影响;然而,在其他情况下,选择正确的模型或模型可能会受到各种方面的影响 - 例如,找到并构建最佳模型的时间。

摘要

本章主要讨论了二元分类问题:真或假,对于我们的示例来说,信号是否表明希格斯玻色子或背景噪音?我们已经探索了四种不同的算法:单决策树、随机森林、梯度提升机和 DNN。对于这个确切的问题,DNN 是当前的世界冠军,因为这些模型可以继续训练更长时间(即增加epochs的数量),并且可以添加更多的层。

除了探索四种算法以及如何对许多超参数执行网格搜索之外,我们还研究了一些重要的模型指标,以帮助您更好地区分模型并了解如何定义“好”的方式。我们本章的目标是让您接触到不同算法和 Spark 和 H2O 中的调整,以解决二元分类问题。在下一章中,我们将探讨多类分类以及如何创建模型集成(有时称为超学习者)来找到我们真实示例的良好解决方案。

第三章:多类分类的集成方法

我们现代世界已经与许多收集有关人类行为数据的设备相互连接-例如,我们的手机是我们口袋里的小间谍,跟踪步数、路线或我们的饮食习惯。甚至我们现在戴的手表也可以追踪从我们走的步数到我们在任何给定时刻的心率的一切。

在所有这些情况下,这些小工具试图根据收集的数据猜测用户正在做什么,以提供一天中用户活动的报告。从机器学习的角度来看,这个任务可以被视为一个分类问题:在收集的数据中检测模式,并将正确的活动类别分配给它们(即,游泳、跑步、睡觉)。但重要的是,这仍然是一个监督问题-这意味着为了训练模型,我们需要提供由实际类别注释的观察。

在本节中,我们将重点关注集成方法来建模多类分类问题,有时也称为多项分类,使用 UCI 数据集库提供的传感器数据集。

请注意,多类分类不应与多标签分类混淆,多标签分类可以为给定示例预测多个标签。例如,一篇博客文章可以被标记为多个标签,因为一篇博客可以涵盖任意数量的主题;然而,在多类分类中,我们被迫选择一个N个可能主题中的一个,其中N > 2 个可能标签。

读者将在本章学习以下主题:

  • 为多类分类准备数据,包括处理缺失值

  • 使用 Spark RF 算法进行多类分类

  • 使用不同的指标评估 Spark 分类模型的质量

  • 构建 H2O 基于树的分类模型并探索其质量

数据

在本章中,我们将使用由尔湾大学机器学习库发布的Physical Activity Monitoring Data SetPAMAP2):archive.ics.uci.edu/ml/datasets/PAMAP2+Physical+Activity+Monitoring

完整的数据集包含52个输入特征和3,850,505个事件,描述了 18 种不同的身体活动(例如,步行、骑车、跑步、看电视)。数据是由心率监测器和三个惯性测量单元记录的,分别位于手腕、胸部和主侧踝部。每个事件都由描述地面真相的活动标签和时间戳进行注释。数据集包含由值NaN表示的缺失值。此外,一些传感器生成的列被标记为无效(“方向”-请参阅数据集描述):

图 1:由尔湾大学机器学习库发布的数据集属性。

该数据集代表了活动识别的完美示例:我们希望训练一个强大的模型,能够根据来自物理传感器的输入数据来预测执行的活动。

此外,数据集分布在多个文件中,每个文件代表一个单个主体的测量,这是由多个数据源产生的数据的另一个现实方面,因此我们需要利用 Spark 从目录中读取并合并文件以创建训练/测试数据集的能力。

以下行显示了数据的一个样本。有几个重要的观察值值得注意:

  • 个别值由空格字符分隔

  • 每行中的第一个值表示时间戳,而第二个值保存了activityId

199.38 0 NaN 34.1875 1.54285 7.86975 5.88674 1.57679 7.65264 5.84959 -0.0855996 ... 1 0 0 0 
199.39 11 NaN 34.1875 1.46513 7.94554 5.80834 1.5336 7.81914 5.92477 -0.0907069 ...  1 0 0 0 
199.4 11 NaN 34.1875 1.41585 7.82933 5.5001 1.56628 8.03042 6.01488 -0.0399161 ...  1 0 0 0 

activityId由数字值表示;因此,我们需要一个翻译表来将 ID 转换为相应的活动标签,数据集提供了这个翻译表,我们如下所示:

1 躺着 2 坐着
3 站立 4 步行
5 跑步 6 骑车
7 挪威步行 9 看电视
10 电脑工作 11 开车
12 上楼梯 13 下楼梯
16 吸尘 17 熨烫
18 叠衣服 19 打扫房子
20 踢足球 24 跳绳
0 其他(瞬态活动)

示例行代表一个“其他活动”,然后是两个代表“开车”的测量值。

第三列包含心率测量,而其余列代表来自三种不同惯性测量单位的数据:列 4-20 来自手部传感器,21-37 包含来自胸部传感器的数据,最后列 38-54 包含踝部传感器的测量数据。每个传感器测量 17 个不同的值,包括温度、3D 加速度计、陀螺仪和磁力计数据以及方向。然而,在这个数据集中,方向列被标记为无效。

输入数据包含两个不同的文件夹 - 协议和可选测量,其中包含一些执行了一些额外活动的受试者的数据。在本章中,我们将只使用可选文件夹中的数据。

建模目标

在这个例子中,我们希望基于有关身体活动的信息构建模型,以对未知数据进行分类并用相应的身体活动进行注释。

挑战

对于传感器数据,有许多探索和构建模型的方法。在本章中,我们主要关注分类;然而,有几个方面需要更深入的探索,特别是以下方面:

  • 训练数据代表了一系列事件的时间顺序流,但我们不打算反映时间信息,而是将数据视为一整个完整的信息

  • 测试数据也是一样 - 单个活动事件是在执行活动期间捕获的事件流的一部分,如果了解实际上下文,可能更容易对其进行分类

然而,目前,我们忽略时间维度,并应用分类来探索传感器数据中可能存在的模式,这些模式将表征执行的活动。

机器学习工作流程

为了构建初始模型,我们的工作流程包括几个步骤:

  1. 数据加载和预处理,通常称为提取-转换-加载ETL)。
  • 加载

  • 解析

  • 处理缺失值

  1. 将数据统一成算法所期望的形式。
  • 模型训练

  • 模型评估

  • 模型部署

启动 Spark shell

第一步是准备 Spark 环境进行分析。与上一章一样,我们将启动 Spark shell;但是,在这种情况下,命令行稍微复杂一些:

export SPARKLING_WATER_VERSION="2.1.12" 
export SPARK_PACKAGES=\ 
"ai.h2o:sparkling-water-core_2.11:${SPARKLING_WATER_VERSION},\ 
ai.h2o:sparkling-water-repl_2.11:${SPARKLING_WATER_VERSION},\ 
ai.h2o:sparkling-water-ml_2.11:${SPARKLING_WATER_VERSION},\ 
com.packtpub:mastering-ml-w-spark-utils:1.0.0" 

$SPARK_HOME/bin/spark-shell \ 
        --master 'local[*]' \ 
        --driver-memory 8g \ 
        --executor-memory 8g \ 
        --conf spark.executor.extraJavaOptions=-XX:MaxPermSize=384M
        \ 
        --conf spark.driver.extraJavaOptions=-XX:MaxPermSize=384M \ 
        --packages "$SPARK_PACKAGES" 

在这种情况下,我们需要更多的内存,因为我们将加载更大的数据。我们还需要增加 PermGen 的大小 - JVM 内存的一部分,它存储有关加载的类的信息。只有在使用 Java 7 时才需要这样做。

Spark 作业的内存设置是作业启动的重要部分。在我们使用的简单的基于local[*]的场景中,Spark 驱动程序和执行程序之间没有区别。然而,对于部署在独立或 YARN Spark 集群上的较大作业,驱动程序内存和执行程序内存的配置需要反映数据的大小和执行的转换。

此外,正如我们在上一章中讨论的,您可以通过使用巧妙的缓存策略和正确的缓存目的地(例如磁盘,离堆内存)来减轻内存压力。

探索数据

第一步涉及数据加载。在多个文件的情况下,SparkContext 的wholeTextFiles方法提供了我们需要的功能。它将每个文件读取为单个记录,并将其作为键值对返回,其中键包含文件的位置,值包含文件内容。我们可以通过通配符模式data/subject*直接引用输入文件。这不仅在从本地文件系统加载文件时很有用,而且在从 HDFS 加载文件时尤为重要。

val path = s"${sys.env.get("DATADIR").getOrElse("data")}/subject*"
val dataFiles = sc.wholeTextFiles(path)
println(s"Number of input files: ${dataFiles.count}")

由于名称不是输入数据的一部分,我们定义一个变量来保存列名:

val allColumnNames = Array( 
  "timestamp", "activityId", "hr") ++ Array( 
  "hand", "chest", "ankle").flatMap(sensor => 
    Array( 
      "temp",  
      "accel1X", "accel1Y", "accel1Z", 
      "accel2X", "accel2Y", "accel2Z", 
      "gyroX", "gyroY", "gyroZ", 
      "magnetX", "magnetY", "magnetZ", 
      "orientX", "orientY", "orientZ"). 
    map(name => s"${sensor}_${name}")) 

我们简单地定义了前三个列名,然后是每个三个位置传感器的列名。此外,我们还准备了一个在建模中无用的列索引列表,包括时间戳和方向数据:

val ignoredColumns =  
  Array(0,  
    3 + 13, 3 + 14, 3 + 15, 3 + 16, 
    20 + 13, 20 + 14, 20 + 15, 20 + 16, 
    37 + 13, 37 + 14, 37 + 15, 37 + 16) 

下一步是处理引用文件的内容并创建一个RDD,我们将其用作数据探索和建模的输入。由于我们希望多次迭代数据并执行不同的转换,我们将在内存中缓存数据:

val rawData = dataFiles.flatMap { case (path, content) =>  
  content.split("\n") 
}.map { row =>  
  row.split(" ").map(_.trim). 
  zipWithIndex. 
  map(v => if (v.toUpperCase == "NAN") Double.NaN else v.toDouble). 
  collect {  
    case (cell, idx) if !ignoredColumns.contains(idx) => cell 
  } 
} 
rawData.cache() 

println(s"Number of rows: ${rawData.count}") 

输出如下:

在这种情况下,对于每个键值对,我们提取其内容并根据行边界进行拆分。然后我们根据文件分隔符对每行进行转换,该分隔符是特征之间的空格。由于文件只包含数值和字符串值NaN作为缺失值的标记,我们可以简单地将所有值转换为 Java 的Double,将Double.NaN作为缺失值的表示。

我们可以看到我们的输入文件有 977,972 行。在加载过程中,我们还跳过了时间戳列和数据集描述中标记为无效的列(参见ignoredColumns数组)。

RDD 的接口遵循函数式编程的设计原则,这个原则也被 Scala 编程语言采用。这个共享的概念为操作数据结构提供了统一的 API;另一方面,了解何时在本地对象(数组、列表、序列)上调用操作,以及何时导致分布操作(RDD)是很重要的。

为了保持数据集的一致视图,我们还需要根据在先前步骤中准备的忽略列的列表来过滤列名:

import org.apache.spark.utils.Tabulizer._
 val columnNames = allColumnNames.
   zipWithIndex.
   filter { case (_, idx) => !ignoredColumns.contains(idx) }.
   map { case (name, _) => name }

 println(s"Column names:${table(columnNames, 4, None)}") 

输出如下:

始终要摆脱对建模无用的数据。动机是在计算和建模过程中减轻内存压力。例如,可以删除包含随机 ID、时间戳、常量列或已在数据集中表示的列等的列。

从直觉上讲,例如对建模 ID 术语进行建模并不太有意义,考虑到该领域的性质。特征选择是一个非常重要的话题,我们将在本书的后面花费大量时间来讨论这个话题。

现在让我们看看数据集中个体活动的分布。我们将使用与上一章相同的技巧;但是,我们也希望看到活动的实际名称,而不仅仅是基于数字的表示。因此,首先我们定义了描述活动编号与其名称之间关系的映射:

val activities = Map( 
  1 -> "lying", 2 -> "sitting", 3 -> "standing", 4 -> "walking",  
  5 -> "running", 6 -> "cycling", 7 -> "Nordic walking",  
  9 -> "watching TV", 10 -> "computer work", 11 -> "car driving", 
 12 -> "ascending stairs", 13 -> "descending stairs",  
 16 -> "vacuum cleaning", 17 -> "ironing", 
 18 -> "folding laundry", 19 -> "house cleaning", 
 20 -> "playing soccer", 24 -> "rope jumping", 0 -> "other") 

然后我们使用 Spark 方法reduceByKey计算数据中个体活动的数量。

val dataActivityId = rawData.map(l => l(0).toInt)

 val activityIdCounts = dataActivityId.
   map(n => (n, 1)).
   reduceByKey(_ + _)

 val activityCounts = activityIdCounts.
   collect.
   sortBy { case (activityId, count) =>
     -count
 }.map { case (activityId, count) =>
   (activitiesMap(activityId), count)
 }

 println(s"Activities distribution:${table({activityCounts})}")

该命令计算个体活动的数量,将活动编号转换为其标签,并根据计数按降序对结果进行排序:

或者根据活动频率进行可视化,如图 2所示。

图 2:输入数据中不同活动的频率。

始终要考虑对数据应用的个体转换的顺序。在前面的例子中,我们在使用 Spark collect操作将所有数据收集到本地后应用了sortBy转换。在这种情况下,这是有道理的,因为我们知道collect操作的结果是相当小的(我们只有 22 个活动标签),而sortBy是应用在本地集合上的。另一方面,在collect操作之前放置sortBy会强制调用 Spark RDD 的转换,并安排排序作为 Spark 分布式任务。

缺失数据

数据描述提到用于活动跟踪的传感器并不完全可靠,结果包含缺失数据。我们需要更详细地探索它们,看看这个事实如何影响我们的建模策略。

第一个问题是我们的数据集中有多少缺失值。我们从数据描述中知道,所有缺失值都由字符串NaN标记(即,不是一个数字),现在在RDD rawData中表示为Double.NaN。在下一个代码片段中,我们计算每行的缺失值数量和数据集中的总缺失值数量:

val nanCountPerRow = rawData.map { row => 
  row.foldLeft(0) { case (acc, v) =>  
    acc + (if (v.isNaN) 1 else 0)  
  } 
} 
val nanTotalCount = nanCount.sum 

val ncols = rawData.take(1)(0).length 
val nrows = rawData.count 

val nanRatio = 100.0 * nanTotalCount / (ncols * nrows)  

println(f"""|NaN count = ${nanTotalCount}%.0f 
            |NaN ratio = ${nanRatio}%.2f %%""".stripMargin) 

输出如下:

现在,我们已经对我们的数据中缺失值的数量有了整体的了解。但我们不知道缺失值是如何分布的。它们是均匀分布在整个数据集上吗?还是有包含更多缺失值的行/列?在接下来的文本中,我们将尝试找到这些问题的答案。

一个常见的错误是使用比较运算符比较数值和Double.NaN。例如,if (v == Double.NaN) { ... }是错误的,因为 Java 规范规定:

"NaN是无序的:(1)如果一个或两个操作数是NaN,则数值比较运算符<<=>>=返回false,(2)等式运算符==如果任一操作数是NaN,则返回false。"

因此,Double.NaN == Double.NaN总是返回false。用正确的方式比较数值和Double.NaN是使用isNaN方法:if (v.isNaN) { ... }(或使用相应的静态方法java.lang.Double.isNaN)。

首先,考虑到我们已经计算了上一步中每行的缺失值数量。对它们进行排序并取唯一值,让我们了解到行是如何受缺失值影响的:

val nanRowDistribution = nanCountPerRow.
   map( count => (count, 1)).
   reduceByKey(_ + _).sortBy(-_._1).collect

 println(s"${table(Seq("#NaN","#Rows"), nanRowDistribution, Map.empty[Int, String])}") 

输出如下:

现在我们可以看到大多数行包含一个缺失值。然而,有很多行包含 13 或 14 个缺失值,甚至有 40 行包含 27 个NaNs,以及 107 行包含超过 30 个缺失值(104 行包含 40 个缺失值,3 行包含 39 个缺失值)。考虑到数据集包含 41 列,这意味着有 107 行是无用的(大部分值都缺失),剩下 3386 行至少有两个缺失值需要关注,以及 885,494 行有一个缺失值。我们现在可以更详细地查看这些行。我们选择所有包含超过给定阈值的缺失值的行,例如 26。我们还收集行的索引(这是基于零的索引!):

val nanRowThreshold = 26 
val badRows = nanCountPerRow.zipWithIndex.zip(rawData).filter(_._1._1 > nanRowThreshold).sortBy(-_._1._1) 
println(s"Bad rows (#NaN, Row Idx, Row):\n${badRows.collect.map(x => (x._1, x._2.mkString(","))).mkString("\n")}") 

现在我们确切地知道哪些行是没有用的。我们已经观察到有 107 行是不好的,它们不包含任何有用的信息。此外,我们可以看到有 27 个缺失值的行是在代表手和脚踝 IMU 传感器的位置上。

最后,大多数行都分配了activityId 10、19 或 20,分别代表computer workhouse cleaningplaying soccer活动,这些是数据集中频率最高的类别。这可能导致我们的理论是“坏”行是由受试者明确拒绝测量设备而产生的。此外,我们还可以看到每行错误的索引,并在输入数据集中验证它们。现在,我们将留下坏行,专注于列。

我们可以问同样的问题关于列 - 是否有任何包含更多缺失值的列?我们可以删除这样的列吗?我们可以开始收集每列的缺失值数量:

val nanCountPerColumn = rawData.map { row =>
   row.map(v => if (v.isNaN) 1 else 0)
 }.reduce((v1, v2) => v1.indices.map(i => v1(i) + v2(i)).toArray)

 println(s"""Number of missing values per column:
      ^${table(columnNames.zip(nanCountPerColumn).map(t => (t._1, t._2, "%.2f%%".format(100.0 * t._2 / nrows))).sortBy(-_._2))}
      ^""".stripMargin('^')) 

输出如下:

结果显示,第二列(不要忘记我们在数据加载过程中已经删除了无效列),代表受试者心率的列,包含了大量的缺失值。超过 90%的数值被标记为NaN,这可能是由实验的测量过程引起的(受试者可能在日常活动中不佩戴心率监测器,只有在进行运动时才佩戴)。

其余的列包含零星的缺失值。

另一个重要的观察是,包含activityId的第一列不包含任何缺失值——这是个好消息,意味着所有的观察都被正确注释,我们不需要删除任何观察(例如,没有训练目标,我们就无法训练模型)。

RDD 的reduce方法代表动作。这意味着它强制评估RDD的结果,并且 reduce 的结果是一个单一的值而不是RDD。不要将其与reduceByKey混淆,后者是一个RDD操作,返回一个新的键值对RDD

下一步是决定如何处理缺失数据。有许多策略可供选择;然而,我们需要保留数据的含义。

我们可以简单地删除包含缺失数据的所有行或列——事实上这是一个非常常见的方法!对于受到太多缺失值污染的行来说是有意义的,但在这种情况下这并不是一个好的全局策略,因为我们观察到缺失值几乎分布在所有的列和行上。因此,我们需要一个更好的策略来处理缺失值。

缺失值来源和插补方法的摘要可以在 A. Gelman 和 J. Hill 的书Data Analysis Using Regression and Mutlilevel/Hierarchical Modelswww.stat.columbia.edu/~gelman/arm/missing.pdf)或演示文稿www.amstat.org/sections/srms/webinarfiles/ModernMethodWebinarMay2012.pdfwww.utexas.edu/cola/prc/_files/cs/Missing-Data.pdf中找到。

首先考虑心率列,我们不能删除它,因为高心率和运动活动之间存在明显的联系。然而,我们仍然可以用一个合理的常数填充缺失值。在心率的情境下,用列值的平均值替换缺失值——有时被称为平均计算缺失值的技术是有意义的。我们可以用以下代码来计算它:

val heartRateColumn = rawData. 
  map(row => row(1)). 
  filter(_.isNaN). 
  map(_.toInt) 

val heartRateValues = heartRateColumn.collect 
val meanHeartRate = heartRateValues.sum / heartRateValues.count 
scala.util.Sorting.quickSort(heartRateValues) 
val medianHeartRate = heartRateValues(heartRateValues.length / 2) 

println(s"Mean heart rate: ${meanHeartRate}") 
println(s"Median heart rate: ${medianHeartRate}") 

输出如下:

我们可以看到平均心率是一个相当高的值,这反映了心率测量主要与运动活动相关(读者可以验证)。但是,例如,考虑到看电视这项活动,超过 90 的数值略高于预期值,因为平均静息心率在 60 到 100 之间(根据维基百科)。

因此,在这种情况下,我们可以用平均静息心率(80)替换缺失的心率值,或者我们可以采用计算得到的心率的平均值。之后,我们将填补计算得到的平均值并比较或合并结果(这称为多重插补方法)。或者我们可以附加一个标记有缺失值的列(例如,参见www.utexas.edu/cola/prc/_files/cs/Missing-Data.pdf)。

下一步是替换其余列中的缺失值。我们应该执行与心率列相同的分析,并查看缺失数据是否存在模式,或者它们只是随机缺失。例如,我们可以探索缺失值与我们的预测目标(在本例中为activityId)之间的依赖关系。因此,我们再次收集每列的缺失值数量;但是,现在我们还记住了每个缺失值的activityId

def incK,V], v: (K, V)) // (3)
             (implicit num: Numeric[V]): Seq[(K,V)] =
 if (l.exists(_._1 == v._1)) l.map(e => e match {
   case (v._1, n) => (v._1, num.plus(n, v._2))
   case t => t
 }) else l ++ Seq(v)

 val distribTemplate = activityIdCounts.collect.map { case (id, _) => (id, 0) }.toSeq
 val nanColumnDistribV1 = rawData.map { row => // (1)
   val activityId = row(0).toInt
   row.drop(1).map { v =>
     if (v.isNaN) inc(distribTemplate, (activityId, 1)) else distribTemplate
   } // Tip: Make sure that we are returning same type
 }.reduce { (v1, v2) =>  // (2)
   v1.indices.map(idx => v1(idx).foldLeft(v2(idx))(inc)).toArray
 }

 println(s"""
         ^NaN Column x Response distribution V1:
         ^${table(Seq(distribTemplate.map(v => activitiesMap(v._1)))
                  ++ columnNames.drop(1).zip(nanColumnDistribV1).map(v => Seq(v._1) ++ v._2.map(_._2)), true)}
           """.stripMargin('^')) 

输出如下:

前面的代码稍微复杂,值得解释。调用(1)将每行中的每个值转换为(K, V)对的序列,其中K表示存储在行中的activityId,如果相应的列包含缺失值,则V1,否则为0。然后,reduce 方法(2)递归地将由序列表示的行值转换为最终结果,其中每列都有一个分布,由(K,V)对的序列表示,其中KactivityIdV表示具有activityId的行中的缺失值数量。该方法很简单,但使用了一个非平凡的函数inc (3),过于复杂。此外,这种天真的解决方案在内存效率上非常低,因为对于每一列,我们都重复了关于activityId的信息。

因此,我们可以通过略微改变结果表示来重申天真的解决方案,不是按列计算分布,而是计算所有列,每个activityId的缺失值计数:

val nanColumnDistribV2 = rawData.map(row => {
   val activityId = row(0).toInt
   (activityId, row.drop(1).map(v => if (v.isNaN) 1 else 0))
 }).reduceByKey( (v1, v2) =>
   v1.indices.map(idx => v1(idx) + v2(idx)).toArray
 ).map { case (activityId, d) =>
   (activitiesMap(activityId), d)
 }.collect

 println(s"""
         ^NaN Column x Response distribution V2:
         ^${table(Seq(columnNames.toSeq) ++ nanColumnDistribV2.map(v => Seq(v._1) ++ v._2), true)}
         """.stripMargin('^'))

在这种情况下,结果是一个键值对数组,其中键是活动名称,值包含各列中缺失值的分布。通过运行这两个样本,我们可以观察到第一个样本所需的时间比第二个样本长得多。此外,第一个样本具有更高的内存需求,而且更加复杂。

最后,我们可以将结果可视化为热图,其中x轴对应列,y轴表示活动,如图 3 所示。这样的图形表示给我们提供了一个清晰的概述,说明了缺失值如何与响应列相关:

图 3:热图显示按活动分组的每列缺失值数量。

生成的热图很好地显示了缺失值的相关性。我们可以看到缺失值与传感器相连。如果传感器不可用或发生故障,那么所有测量值都不可用。例如,这在踝传感器和踢足球等其他活动中是可见的。另一方面,活动看电视并没有显示与传感器相关的任何缺失值模式。

此外,缺失数据与活动之间没有其他直接可见的联系。因此,目前我们可以决定用0.0填充缺失值,以表示缺失传感器提供默认值。但是,我们的目标是灵活地尝试不同的插补策略(例如,使用相同activityId的观测均值来插补)。

缺失值分析摘要

现在我们可以总结我们对缺失值学到的所有事实:

  • 有 107 行是无用的,需要被过滤掉

  • 有 44 行有2627个缺失值。这些行似乎是无用的,所以我们将它们过滤掉。

  • 心率列包含大部分缺失值。由于我们期望该列包含可以帮助区分不同运动活动的重要信息,我们不打算忽略该列。但是,我们将根据不同的策略填补缺失值:

  • 基于医学研究的平均静息心率

  • 根据可用数据计算的平均心率

  • 其余列中的缺失值存在一种模式 - 缺失值严格与传感器相关。我们将用值0.0替换所有这些缺失值。

数据统一

这种探索性分析给了我们关于数据形状和我们需要执行的操作的概述,以处理缺失值。然而,我们仍然需要将数据转换为 Spark 算法所期望的形式。这包括:

  • 处理缺失值

  • 处理分类值

缺失值

缺失值处理步骤很容易,因为我们已经在前一节中执行了缺失值探索,并总结了所需的转换。接下来的步骤将实现它们。

首先,我们定义一个缺失值列表 - 对于每一列,我们分配一个单一的Double值:

val imputedValues = columnNames.map { 
  _ match { 
    case "hr" => 60.0 
    case _ => 0.0 
  } 
} 

以及一个允许我们将值注入数据集的函数:

import org.apache.spark.rdd.RDD 
def imputeNaN( 
  data: RDD[Array[Double]],  
  values: Array[Double]): RDD[Array[Double]] = { 
    data.map { row => 
      row.indices.map { i => 
        if (row(i).isNaN) values(i) 
        else row(i) 
      }.toArray 
    } 
} 

定义的函数接受一个 Spark RDD,其中每一行都表示为一个Double数字数组,以及一个包含每列替换缺失值的值的参数。

在下一步中,我们定义一个行过滤器 - 一个方法,它删除包含的缺失值超过给定阈值的所有行。在这种情况下,我们可以轻松地重用已经计算的值nanCountPerRow

def filterBadRows( 
  rdd: RDD[Array[Double]], 
  nanCountPerRow: RDD[Int], 
  nanThreshold: Int): RDD[Array[Double]] = { 
    rdd.zip(nanCountPerRow).filter { case (row, nanCount) => 
      nanCount > nanThreshold 
  }.map { case (row, _) => 
        row 
  } 
} 

请注意,我们参数化了定义的转换。保持代码足够灵活以允许进一步尝试不同的参数是一个好的做法。另一方面,最好避免构建复杂的框架。经验法则是参数化功能,我们希望在不同上下文中使用,或者我们需要在配置代码常量时具有自由度。

分类值

Spark 算法可以处理不同形式的分类特征,但它们需要被转换为算法所期望的形式。例如,决策树可以处理分类特征,而线性回归或神经网络需要将分类值扩展为二进制列。

在这个例子中,好消息是我们数据集中的所有输入特征都是连续的。然而,目标特征 - activityId - 表示多类特征。Spark MLlib 分类指南(spark.apache.org/docs/latest/mllib-linear-methods.html#classification)说:

“训练数据集在 MLlib 中由 LabeledPoint 的 RDD 表示,其中标签是从零开始的类索引。”

但是我们的数据集包含不同数量的 activityIds - 参见计算的变量activityIdCounts。因此,我们需要通过定义从activityIdactivityIdx的映射,将它们转换为 MLlib 所期望的形式:

val activityId2Idx = activityIdCounts. 
  map(_._1). 
  collect. 
  zipWithIndex. 
  toMap 

最终转换

最后,我们可以将所有定义的功能组合在一起,为模型构建准备数据。首先,rawData RDD被过滤,所有不良行都被filterBadRows移除,然后结果由imputeNaN方法处理,该方法在缺失值的位置注入给定的值:

val processedRawData = imputeNaN( 
  filterBadRows(rawData, nanCountPerRow, nanThreshold = 26), 
  imputedValues) 

最后,通过至少计算行数来验证我们调用了正确的转换:

println(s"Number of rows before/after: ${rawData.count} / ${ processedRawData.count}") 

输出如下:

我们可以看到,我们过滤掉了 151 行,这对应于我们之前的观察。

了解数据是数据科学的关键点。这也包括了解缺失数据。永远不要跳过这个阶段,因为它可能导致产生过于良好结果的偏见模型。而且,正如我们不断强调的那样,不了解你的数据将导致你提出不好的问题,最终导致乏味的答案。

用随机森林对数据建模

随机森林是一种可以用于不同问题的算法 - 如我们在上一章中展示的二项式,回归,或者多类分类。随机森林的美妙之处在于它将由决策树表示的多个弱学习器组合成一个整体。

此外,为了减少单个决策树的方差,算法使用了 bagging(自举聚合)的概念。每个决策树都是在通过随机选择并替换生成的数据子集上训练的。

不要混淆装袋和提升。提升通过训练每个新模型来强调先前模型错误分类的观察结果来逐步构建集成。通常,在将弱模型添加到集成后,数据会被重新加权,错误分类的观察结果会增加权重,反之亦然。此外,装袋可以并行调用,而提升是一个顺序过程。然而,提升的目标与装袋的目标相同 - 结合几个弱模型的预测,以改善单个模型的泛化和鲁棒性。

提升方法的一个例子是梯度提升机GBM),它使用提升方法将弱模型(决策树)组合成一个集成;然而,它通过允许使用任意损失函数来概括这种方法:而不是试图纠正先前的弱模型错误分类的观察结果,GBM 允许您最小化指定的损失函数(例如,回归的均方误差)。

GBM 有不同的变体 - 例如,将提升与装袋相结合的随机 GBM。常规 GBM 和随机 GBM 都可以在 H2O 的机器学习工具箱中找到。此外,重要的是要提到 GBM(以及 RandomForest)是一种在不需要广泛调整参数的情况下构建相当不错模型的算法。

有关 GBM 的更多信息可以在 J.H. Friedman 的原始论文中找到:贪婪函数逼近:梯度提升机 www-stat.stanford.edu/~jhf/ftp/trebst.pdf

此外,RandomForest 采用所谓的“特征装袋” - 在构建决策树时,它选择一个随机特征子集来做出分裂决策。动机是构建一个弱学习器并增强泛化能力 - 例如,如果一个特征对于给定的目标变量是一个强预测因子,它将被大多数树选择,导致高度相似的树。然而,通过随机选择特征,算法可以避免强预测因子,并构建能够找到数据更精细结构的树。

RandomForest 还有助于轻松选择最具预测性的特征,因为它允许以不同的方式计算变量重要性。例如,通过计算所有树的整体特征不纯度增益,可以很好地估计强特征的重要性。

从实现的角度来看,RandomForest 可以很容易地并行化,因为构建树步骤是独立的。另一方面,分布 RandomForest 计算是一个稍微困难的问题,因为每棵树都需要探索几乎完整的数据集。

RandomForest 的缺点是解释性复杂。得到的集成很难探索和解释个别树之间的交互。然而,如果我们需要获得一个不需要高级参数调整的良好模型,它仍然是最好的模型之一。

RandomForest 的一个很好的信息来源是 Leo Breiman 和 Adele Cutler 的原始论文,例如可以在这里找到:www.stat.berkeley.edu/~breiman/RandomForests/cc_home.htm

使用 Spark RandomForest 构建分类模型

在前一节中,我们探索了数据并将其统一成一个没有缺失值的形式。我们仍然需要将数据转换为 Spark MLlib 所期望的形式。如前一章所述,这涉及到创建LabeledPointsRDD。每个LabeledPoint由一个标签和定义输入特征的向量组成。标签用作模型构建者的训练目标,并引用分类变量的索引(参见准备好的转换activityId2Idx):

import org.apache.spark.mllib 
import org.apache.spark.mllib.regression.LabeledPoint 
import org.apache.spark.mllib.linalg.Vectors 
import org.apache.spark.mllib.tree.RandomForest 
import org.apache.spark.mllib.util.MLUtils 

val data = processedRawData.map { r =>  
    val activityId = r(0) 
    val activityIdx = activityId2Idx(activityId) 
    val features = r.drop(1) 
    LabeledPoint(activityIdx, Vectors.dense(features)) 
} 

下一步是为训练和模型验证准备数据。我们简单地将数据分为两部分:80%用于训练,剩下的 20%用于验证:

val splits = data.randomSplit(Array(0.8, 0.2)) 
val (trainingData, testData) =  
    (splits(0), splits(1)) 

在这一步之后,我们准备调用工作流程的建模部分。构建 Spark RandomForest 模型的策略与我们在上一章中展示的 GBM 相同,通过在对象RandomForest上调用静态方法trainClassifier来实现:

import org.apache.spark.mllib.tree.configuration._ 
import org.apache.spark.mllib.tree.impurity._ 
val rfStrategy = new Strategy( 
  algo = Algo.Classification, 
  impurity = Entropy, 
  maxDepth = 10, 
  maxBins = 20, 
  numClasses = activityId2Idx.size, 
  categoricalFeaturesInfo = Map[Int, Int](), 
  subsamplingRate = 0.68) 

val rfModel = RandomForest.trainClassifier( 
    input = trainingData,  
    strategy = rfStrategy, 
    numTrees = 50,  
    featureSubsetStrategy = "auto",  
    seed = 42) 

在这个例子中,参数被分成两组:

  • 定义构建决策树的常见参数的策略

  • RandomForest 特定参数

策略参数列表与上一章讨论的决策树算法的参数列表重叠:

  • input:引用由LabeledPointsRDD表示的训练数据。

  • numClasses:输出类的数量。在这种情况下,我们仅对输入数据中包含的类建模。

  • categoricalFeaturesInfo:分类特征及其度量的映射。我们的输入数据中没有分类特征,因此我们传递一个空映射。

  • impurity:用于树节点分裂的不纯度度量。

  • subsamplingRate:用于构建单棵决策树的训练数据的分数。

  • maxDepth:单棵树的最大深度。深树倾向于对输入数据进行编码和过拟合。另一方面,在 RandomForest 中,通过组装多棵树来平衡过拟合。此外,更大的树意味着更长的训练时间和更高的内存占用。

  • maxBins:连续特征被转换为具有最多maxBins可能值的有序离散特征。离散化是在每个节点分裂之前完成的。

RandomForest 特定参数如下:

  • numTrees:结果森林中的树的数量。增加树的数量会减少模型的方差。

  • featureSubsetStrategy:指定一种方法,用于选择用于训练单棵树的特征数量。例如:"sqrt"通常用于分类,而"onethird"用于回归问题。查看RandomForest.supportedFeatureSubsetStrategies的值以获取可用值。

  • seed:用于随机生成器初始化的种子,因为 RandomForest 依赖于特征和行的随机选择。

参数numTreesmaxDepth经常被引用为停止标准。Spark 还提供了额外的参数来停止树的生长并生成细粒度的树:

  • minInstancesPerNode:如果节点提供的左节点或右节点包含的观察次数小于此参数指定的值,则不再分裂节点。默认值为 1,但通常对于回归问题或大树,该值应该更高。

  • minInfoGain:分裂必须获得的最小信息增益。默认值为 0.0。

此外,Spark RandomForest 接受影响执行性能的参数(请参阅 Spark 文档)。

RandomForest 在定义上是一个依赖于随机化的算法。然而,如果您试图重现结果或测试边缘情况,那么非确定性运行并不是正确的行为。在这种情况下,seed 参数提供了一种固定执行并提供确定性结果的方法。

这是非确定性算法的常见做法;然而,如果算法是并行化的,并且其结果取决于线程调度,那么这还不够。在这种情况下,需要采用临时方法(例如,通过仅使用一个计算线程限制并行化,通过限制输入分区的数量限制并行化,或切换任务调度程序以提供固定的调度)。

分类模型评估

现在,当我们有一个模型时,我们需要评估模型的质量,以决定模型是否足够满足我们的需求。请记住,与模型相关的所有质量指标都需要根据您的特定情况考虑,并与您的目标目标(如销售增长、欺诈检测等)一起评估。

Spark 模型指标

首先,使用 Spark API 提供的嵌入模型指标。我们将使用与上一章相同的方法。我们首先定义一个方法,用于提取给定模型和数据集的模型指标:

import org.apache.spark.mllib.evaluation._ 
import org.apache.spark.mllib.tree.model._ 
def getMetrics(model: RandomForestModel, data: RDD[LabeledPoint]): 
    MulticlassMetrics = { 
        val predictionsAndLabels = data.map(example => 
            (model.predict(example.features), example.label) 
        ) 
        new MulticlassMetrics(predictionsAndLabels) 
} 

然后我们可以直接计算 Spark 的MulticlassMetrics

val rfModelMetrics = getMetrics(rfModel, testData) 

然后首先查看有趣的分类模型指标,称为混淆矩阵。它由类型org.apache.spark.mllib.linalg.Matrix表示,允许您执行代数运算:

println(s"""|Confusion matrix: 
  |${rfModelMetrics.confusionMatrix}""".stripMargin) 

输出如下:

在这种情况下,Spark 在列中打印预测的类。预测的类存储在rfModelMetrics对象的labels字段中。然而,该字段仅包含已翻译的索引(请参见创建的变量activityId2Idx)。尽管如此,我们可以轻松地创建一个函数来将标签索引转换为实际的标签字符串:

def idx2Activity(idx: Double): String =  
  activityId2Idx. 
  find(e => e._2 == idx.asInstanceOf[Int]). 
  map(e => activitiesMap(e._1)). 
  getOrElse("UNKNOWN") 

val rfCMLabels = rfModelMetrics.labels.map(idx2Activity(_)) 
println(s"""|Labels: 
  |${rfCMLabels.mkString(", ")}""".stripMargin) 

输出如下:

例如,我们可以看到其他活动与其他活动多次被错误预测 - 它在36455个案例中被正确预测;然而,在1261个案例中,模型预测了其他活动,但实际活动是家务清洁。另一方面,模型预测了叠衣服活动而不是其他活动。

您可以直接看到,我们可以基于混淆矩阵对角线上正确预测的活动直接计算整体预测准确度:

val rfCM = rfModelMetrics.confusionMatrix 
val rfCMTotal = rfCM.toArray.sum 
val rfAccuracy = (0 until rfCM.numCols).map(i => rfCM(i,i)).sum / rfCMTotal 
println(f"RandomForest accuracy = ${rfAccuracy*100}%.2f %%") 

输出如下:

然而,总体准确度可能会在类别不均匀分布的情况下产生误导(例如,大多数实例由单个类别表示)。在这种情况下,总体准确度可能会令人困惑,因为模型只是预测一个主导类将提供高准确度。因此,我们可以更详细地查看我们的预测,并探索每个单独类别的准确度。然而,首先我们查看实际标签和预测标签的分布,以查看(1)是否有主导类,以及(2)模型是否保留了类别的输入分布并且没有偏向于预测单一类别:

import org.apache.spark.mllib.linalg.Matrix
 def colSum(m: Matrix, colIdx: Int) = (0 until m.numRows).map(m(_, colIdx)).sum
 def rowSum(m: Matrix, rowIdx: Int) = (0 until m.numCols).map(m(rowIdx, _)).sum
 val rfCMActDist = (0 until rfCM.numRows).map(rowSum(rfCM, _)/rfCMTotal)
 val rfCMPredDist = (0 until rfCM.numCols).map(colSum(rfCM, _)/rfCMTotal)

 println(s"""^Class distribution
             ^${table(Seq("Class", "Actual", "Predicted"),
                      rfCMLabels.zip(rfCMActDist.zip(rfCMPredDist)).map(p => (p._1, p._2._1, p._2._2)),
                      Map(1 -> "%.2f", 2 -> "%.2f"))}
           """.stripMargin('^')) 

输出如下:

我们很容易看到没有主导类;然而,这些类并不是均匀分布的。值得注意的是,该模型保留了实际类别的分布,并且没有倾向于偏爱单一类别。这只是确认了我们基于混淆矩阵的观察。

最后,我们可以查看各个类别并计算精确度(又称阳性预测值)、召回率(或称灵敏度)和F-1分数。为了提醒上一章的定义:精确度是给定类别的正确预测的比例(即 TP/TP+TF),而召回率被定义为所有正确预测的类实例的比例(即 TP/TP+FN)。最后,F-1分数结合了它们两个,因为它是精确度和召回率的加权调和平均数。我们可以使用我们已经定义的函数轻松计算它们:

def rfPrecision(m: Matrix, feature: Int) = m(feature, feature) / colSum(m, feature)
 def rfRecall(m: Matrix, feature: Int) = m(feature, feature) / rowSum(m, feature)
 def rfF1(m: Matrix, feature: Int) = 2 * rfPrecision(m, feature) * rfRecall(m, feature) / (rfPrecision(m, feature) + rfRecall(m, feature))

 val rfPerClassSummary = rfCMLabels.indices.map { i =>
   (rfCMLabels(i), rfRecall(rfCM, i), rfPrecision(rfCM, i), rfF1(rfCM, i))
 }

 println(s"""^Per class summary:
             ^${table(Seq("Label", "Recall", "Precision", "F-1"),
                      rfPerClassSummary,
                      Map(1 -> "%.4f", 2 -> "%.4f", 3 -> "%.4f"))}
           """.stripMargin('^')) 

输出如下:

在我们的案例中,我们处理了一个相当不错的模型,因为大多数值都接近于 1.0。这意味着该模型对每个输入类别的表现良好 - 生成了较少的假阳性(精确度)和假阴性(召回)。

Spark API 的一个很好的特性是它已经提供了计算我们手动计算的所有三个指标的方法。我们可以轻松调用precisionrecallfMeasure方法,并使用标签索引获得相同的值。然而,在 Spark 的情况下,每次调用都会收集混淆矩阵,从而增加整体计算时间。

在我们的案例中,我们使用已经计算的混淆矩阵并直接获得相同的结果。读者可以验证以下代码是否给出了与rfPerClassSummary中存储的相同数字:

val rfPerClassSummary2 = rfCMLabels.indices.map { i =>  
    (rfCMLabels(i), rfModelMetrics.recall(i), rfModelMetrics.precision(i), rfModelMetrics.fMeasure(i))  
} 

通过每个类的统计数据,我们可以通过计算每个计算指标的平均值来简单地计算宏平均指标:

val rfMacroRecall = rfCMLabels.indices.map(i => rfRecall(rfCM, i)).sum/rfCMLabels.size 
val rfMacroPrecision = rfCMLabels.indices.map(i => rfPrecision(rfCM, i)).sum/rfCMLabels.size 
val rfMacroF1 = rfCMLabels.indices.map(i => rfF1(rfCM, i)).sum/rfCMLabels.size 

println(f"""|Macro statistics 
  |Recall, Precision, F-1 
  |${rfMacroRecall}%.4f, ${rfMacroPrecision}%.4f, ${rfMacroF1}%.4f""".stripMargin) 

输出如下:

Macro统计数据为我们提供了所有特征统计的整体特征。我们可以看到预期值接近 1.0,因为我们的模型在测试数据上表现相当不错。

此外,Spark ModelMetrics API 还提供了加权精度、召回率和F-1分数,这些主要在处理不平衡的类时非常有用:

println(f"""|Weighted statistics 
  |Recall, Precision, F-1 
  |${rfModelMetrics.weightedRecall}%.4f, ${rfModelMetrics.weightedPrecision}%.4f, ${rfModelMetrics.weightedFMeasure}%.4f 
  |""".stripMargin) 

输出如下:

最后,我们将看一种计算模型指标的方法,这种方法在类别分布不均匀的情况下也很有用。该方法称为一对所有,它提供了分类器相对于一个类的性能。这意味着我们将为每个输出类别计算一个混淆矩阵 - 我们可以将这种方法视为将分类器视为一个二元分类器,预测一个类作为正例,其他任何类作为负例:

import org.apache.spark.mllib.linalg.Matrices 
val rfOneVsAll = rfCMLabels.indices.map { i => 
    val icm = rfCM(i,i) 
    val irowSum = rowSum(rfCM, i) 
    val icolSum = colSum(rfCM, i) 
    Matrices.dense(2,2,  
      Array( 
        icm, irowSum - icm, 
        icolSum - icm, rfCMTotal - irowSum - icolSum + icm)) 
  } 
println(rfCMLabels.indices.map(i => s"${rfCMLabels(i)}\n${rfOneVsAll(i)}").mkString("\n")) 

这将为我们提供每个类别相对于其他类别的性能,由简单的二进制混淆矩阵表示。我们可以总结所有矩阵并得到一个混淆矩阵,以计算每个类的平均准确度和微平均指标:

val rfOneVsAllCM = rfOneVsAll.foldLeft(Matrices.zeros(2,2))((acc, m) => 
  Matrices.dense(2, 2,  
    Array(acc(0, 0) + m(0, 0),  
          acc(1, 0) + m(1, 0), 
          acc(0, 1) + m(0, 1), 
          acc(1, 1) + m(1, 1))) 
) 
println(s"Sum of oneVsAll CM:\n${rfOneVsAllCM}") 

输出如下:

有了整体的混淆矩阵,我们可以计算每个类的平均准确度:

println(f"Average accuracy: ${(rfOneVsAllCM(0,0) + rfOneVsAllCM(1,1))/rfOneVsAllCM.toArray.sum}%.4f") 

输出如下:

该矩阵还给出了微平均指标(召回率、精度、F-1)。然而,值得一提的是我们的rfOneVsAllCM矩阵是对称的。这意味着召回率精度F-1具有相同的值(因为 FP 和 FN 是相同的):

println(f"Micro-averaged metrics: ${rfOneVsAllCM(0,0)/(rfOneVsAllCM(0,0)+rfOneVsAllCM(1,0))}%.4f") 

输出如下:

Spark ModelMetrics API 的概述由 Spark 文档提供spark.apache.org/docs/latest/mllib-evaluation-metrics.html

此外,了解模型指标,特别是多类分类中混淆矩阵的作用是至关重要的,但不仅仅与 Spark API 有关。一个很好的信息来源是 Python scikit 文档(scikit-learn.org/stable/modules/model_evaluation.html)或各种 R 包(例如,blog.revolutionanalytics.com/2016/03/com_class_eval_metrics_r.html)。

使用 H2O RandomForest 构建分类模型

H2O 提供了多种算法来构建分类模型。在本章中,我们将再次专注于树集成,但我们将演示它们在传感器数据问题的背景下的使用。

我们已经准备好了数据,可以直接用来构建 H2O RandomForest 模型。要将它们转换为 H2O 格式,我们需要创建H2OContext,然后调用相应的转换:

import org.apache.spark.h2o._ 
val h2oContext = H2OContext.getOrCreate(sc) 

val trainHF = h2oContext.asH2OFrame(trainingData, "trainHF") 
trainHF.setNames(columnNames) 
trainHF.update() 
val testHF = h2oContext.asH2OFrame(testData, "testHF") 
testHF.setNames(columnNames) 
testHF.update() 

我们创建了两个表,分别以trainHFtestHF命名。代码还通过调用setNames方法更新了列的名称,因为输入的RDD不包含有关列的信息。重要的一步是调用update方法将更改保存到 H2O 的分布式内存存储中。这是 H2O API 暴露的一个重要模式 - 对对象进行的所有更改都是在本地完成的;为了使它们对其他计算节点可见,有必要将它们保存到内存存储中(所谓的分布式键值存储DKV))。

将数据存储为 H2O 表后,我们可以通过调用h2oContext.openFlow打开 H2O Flow 用户界面,并以图形方式探索数据。例如,数值特征activityId列的分布如图 4所示:

图 4:需要转换为分类类型的数值列 activityId 的视图。

我们可以直接比较结果,并通过一段 Spark 代码验证我们观察到正确的分布:

println(s"""^Distribution of activityId:
             ^${table(Seq("activityId", "Count"),
                      testData.map(row => (row.label, 1)).reduceByKey(_ + _).collect.sortBy(_._1),
                      Map.empty[Int, String])}
             """.stripMargin('^')) 

输出如下:

下一步是准备输入数据来运行 H2O 算法。首先,我们需要验证列类型是否符合算法所期望的形式。H2O Flow UI 提供了带有基本属性的列的列表(图 5):

图 5:在 Flow UI 中显示的导入训练数据集的列。

我们可以看到activityId列是数值的;然而,为了进行分类,H2O 要求列必须是分类的。因此,我们需要通过在 UI 中点击"转换为枚举"或以编程方式进行转换:

trainHF.replace(0, trainHF.vec(0).toCategoricalVec).remove 
trainHF.update 
testHF.replace(0, testHF.vec(0).toCategoricalVec).remove 
testHF.update 

再次,我们需要通过调用update方法更新内存存储中的修改后的帧。此外,我们正在将一个向量转换为另一个向量类型,我们不再需要原始向量,因此我们可以在replace调用的结果上调用remove方法。

转换后,activityId列是分类的;然而,向量域包含值"0","1",..."6" - 它们存储在字段trainHF.vec("activityId").domain中。然而,我们可以使用实际的类别名称更新向量。我们已经准备好了索引到名称转换,称为idx2Activity - 因此我们准备一个新的域,并更新训练和测试表的activityId向量域:

val domain = trainHF.vec(0).domain.map(i => idx2Activity(i.toDouble)) 
trainHF.vec(0).setDomain(domain) 
water.DKV.put(trainHF.vec(0)) 
testHF.vec(0).setDomain(domain) 
water.DKV.put(testHF.vec(0)) 

在这种情况下,我们还需要更新内存存储中修改后的向量 - 代码不是调用update方法,而是显式调用water.DKV.put方法,直接将对象保存到内存存储中。

在 UI 中,我们可以再次探索测试数据集的activityId列,并将其与计算结果进行比较- 图 6:

图 6:测试数据集中的 activityId 值分布。

在这一点上,我们已经准备好执行模型构建的数据。H2O RandomForest 的分类问题配置遵循我们在上一章中介绍的相同模式:

import _root_.hex.tree.drf.DRF 
import _root_.hex.tree.drf.DRFModel 
import _root_.hex.tree.drf.DRFModel.DRFParameters 
import _root_.hex.ScoreKeeper._ 
import _root_.hex.ConfusionMatrix 
import water.Key.make 

val drfParams = new DRFParameters 
drfParams._train = trainHF._key 
drfParams._valid = testHF._key 
drfParams._response_column = "activityId" 
drfParams._max_depth = 20 
drfParams._ntrees = 50 
drfParams._score_each_iteration = true 
drfParams._stopping_rounds = 2 
drfParams._stopping_metric = StoppingMetric.misclassification 
drfParams._stopping_tolerance = 1e-3 
drfParams._seed = 42 
drfParams._nbins = 20 
drfParams._nbins_cats = 1024 

val drfModel = new DRF(drfParams, makeDRFModel).trainModel.get 

H2O 算法与 Spark 之间有几个重要的区别。第一个重要的区别是我们可以直接指定验证数据集作为输入参数(_valid字段)。这并不是必需的,因为我们可以在构建模型后进行验证;然而,当指定验证数据集时,我们可以在构建过程中实时跟踪模型的质量,并在我们认为模型已经足够好时停止模型构建(参见图 7 - "取消作业"操作停止训练,但模型仍然可用于进一步操作)。此外,稍后我们可以继续模型构建并添加更多的树,如果需要的话。参数_score_each_iteration控制评分应该多频繁进行:

图 7:在 Flow UI 中可以跟踪模型训练,并通过按下"取消作业"按钮停止。

另一个区别在于参数_nbins_nbins_top_level_nbins_cats。Spark RandomForest 实现接受参数maxBins来控制连续特征的离散化。在 H2O 的情况下,它对应于参数_nbins。然而,H2O 机器学习平台允许对离散化进行更精细的调整。由于顶层分割最重要,并且可能因为离散化而导致信息丢失,H2O 允许通过参数_nbins_top_level临时增加离散类别的数量。此外,高值分类特征(> 1,024 个级别)通常会通过强制算法考虑所有可能的分割成两个不同子集来降低计算性能。对于这种情况,H2O 引入了参数_nbins_cats,它控制分类级别的数量 - 如果一个特征包含的分类级别多于参数中存储的值,则这些值将重新分组以适应_nbins_cats个箱子。

最后一个重要的区别是,我们在集成中指定了额外的停止标准,以及传统的深度和树的数量。该标准限制了在验证数据上计算的误分类的改善 - 在这种情况下,我们指定,如果验证数据上连续两次评分测量(字段_stopping_rounds)不提高 0.001(字段_stopping_tolerance的值),则模型构建应该停止。如果我们知道模型的预期质量并希望限制模型训练时间,这是一个完美的标准。在我们的情况下,我们可以探索生成集成中的树的数量:

println(s"Number of trees: ${drfModel._output._ntrees}") 

输出如下:

即使我们要求 50 棵树,由于模型训练在给定阈值下未改善误分类率,因此生成的模型只有14棵树。

H2O API 公开了多个停止标准,可以被任何算法使用 - 用户可以使用 AUC 值进行二项问题或 MSE 进行回归问题。这是最强大的功能之一,可以让您在探索大量超参数空间时减少计算时间。

模型的质量可以通过两种方式来探索:(1)直接使用 Scala API 并访问模型字段_output,其中包含所有输出指标,或者(2)使用图形界面以更用户友好的方式来探索指标。例如,可以在 Flow UI 中的模型视图中直接显示指定验证集上的混淆矩阵。参考下图:

图 8:由 14 棵树组成的初始 RandomForest 模型的混淆矩阵。

它直接给出了错误率(0.22%)和每个类别的误分类,我们可以直接与使用 Spark 模型计算的准确性进行比较。此外,混淆矩阵可以用于计算我们探索的其他指标。

例如,计算每个类别的召回率、精确度和F-1指标。我们可以简单地将 H2O 的混淆矩阵转换为 Spark 的混淆矩阵,并重用所有定义的方法。但是我们必须小心不要混淆结果混淆矩阵中的实际值和预测值(Spark 矩阵的预测值在列中,而 H2O 矩阵的预测值在行中):

val drfCM = drfModel._output._validation_metrics.cm 
def h2oCM2SparkCM(h2oCM: ConfusionMatrix): Matrix = { 
  Matrices.dense(h2oCM.size, h2oCM.size, h2oCM._cm.flatMap(x => x)) 
} 
val drfSparkCM = h2oCM2SparkCM(drfCM) 

您可以看到指定验证数据集的计算指标存储在模型输出字段_output._validation_metrics中。它包含混淆矩阵,还包括在训练过程中跟踪的模型性能的其他信息。然后我们简单地将 H2O 表示转换为 Spark 矩阵。然后我们可以轻松地计算每个类别的宏性能:

val drfPerClassSummary = drfCM._domain.indices.map { i =>
   (drfCM._domain(i), rfRecall(drfSparkCM, i), rfPrecision(drfSparkCM, i), rfF1(drfSparkCM, i))
 }

 println(s"""^Per class summary
             ^${table(Seq("Label", "Recall", "Precision", "F-1"),
                      drfPerClassSummary,
                      Map(1 -> "%.4f", 2 -> "%.4f", 3 -> "%.4f"))}
           """.stripMargin('^')) 

输出如下:

您可以看到,结果略优于之前计算的 Spark 结果,尽管 H2O 使用的树较少。解释需要探索 H2O 实现的随机森林算法 - H2O 使用的算法是基于为每个输出类生成一个回归决策树的方法 - 这种方法通常被称为“一对所有”方案。该算法允许针对各个类别进行更精细的优化。因此,在这种情况下,14 个随机森林树在内部由 14*7 = 98 个内部决策树表示。

读者可以在 Ryan Rifkin 和 Aldebaro Klautau 的论文In Defense of One-Vs-All Classification中找到更多关于“一对所有”方案在多类分类问题中的好处的解释。作者表明,该方案与其他方法一样准确;另一方面,该算法强制生成更多的决策树,这可能会对计算时间和内存消耗产生负面影响。

我们可以探索关于训练模型的更多属性。随机森林的一个重要指标是变量重要性。它存储在模型的字段_output._varimp下。该对象包含原始值,可以通过调用scaled_values方法进行缩放,或者通过调用summary方法获得相对重要性。然而,它们可以在 Flow UI 中以图形方式进行探索,如图 9所示。图表显示,最重要的特征是来自所有三个传感器的测量温度,其次是各种运动数据。令人惊讶的是,与我们的预期相反,心率并未包含在最重要的特征中。

图 9:模型“drfModel”的变量重要性。最重要的特征包括测量温度。

如果我们对模型的质量不满意,可以通过增加更多的树来扩展它。我们可以重用定义的参数,并以以下方式修改它们:

  • 设置所需的集成树的数量(例如,20)。

  • 禁用早停准则,以避免在达到所需数量的树之前停止模型训练。

  • 配置所谓的模型检查点,指向先前训练过的模型。模型检查点是 H2O 机器学习平台的独特功能,适用于所有已发布的模型。在需要通过执行更多的训练迭代来改进给定模型的情况下,它非常有用。

之后,我们可以简单地再次启动模型构建。在这种情况下,H2O 平台简单地继续模型训练,重建模型状态,并构建并附加新树到新模型中。

drfParams._ntrees = 20 
drfParams._stopping_rounds = 0 
drfParams._checkpoint = drfModel._key 

val drfModel20 = new DRF(drfParams, makeDRFModel).trainModel.get 
println(s"Number of trees: ${drfModel20._output._ntrees}") 

输出如下:

在这种情况下,只构建了6棵树 - 要查看这一点,用户可以在控制台中探索模型训练输出,并找到一个以模型训练输出和报告结束的行:

第 6 棵树在 2 秒内生成,并且是附加到现有集成中创建新模型的最后一棵树。我们可以再次探索新构建模型的混淆矩阵,并看到整体错误率从 0.23 降至 0.2%的改善(见图 9):

图 10:具有 20 棵树的随机森林模型的混淆矩阵。

总结

本章介绍了几个重要概念,包括数据清理和处理缺失和分类值,使用 Spark 和 H2O 训练多分类模型,以及分类模型的各种评估指标。此外,本章介绍了模型集成的概念,以 RandomForest 作为决策树的集成。

读者应该看到数据准备的重要性,在每个模型训练和评估过程中都起着关键作用。在不了解建模背景的情况下训练和使用模型可能会导致误导性的决策。此外,每个模型都需要根据建模目标进行评估(例如,最小化假阳性)。因此,了解分类模型的不同模型指标的权衡是至关重要的。

在本章中,我们没有涵盖所有可能的分类模型建模技巧,但还有一些对好奇的读者来说仍然是开放的。

我们使用了一个简单的策略来填补心率列中的缺失值,但还有其他可能的解决方案 - 例如,均值插补,或者将插补与额外的二进制列相结合,标记具有缺失值的行。这两种策略都可以提高模型的准确性,我们将在本书的后面部分使用它们。

此外,奥卡姆剃刀原则表明,更倾向于选择一个简单的模型,而不是一个复杂的模型,尽管它们提供相同的准确性是一个好主意。因此,一个好主意是定义一个参数的超空间,并使用探索策略找到最简单的模型(例如,更少的树木,更少的深度),它提供与本章训练的模型相同(或更好)的准确性。

总结本章,重要的是要提到,本章介绍的树集成是集成和超学习器强大概念的一个原始实例,我们将在本书的后面部分介绍。

第四章:使用 NLP 和 Spark 流处理预测电影评论

在本章中,我们将深入研究自然语言处理NLP)领域,不要与神经语言编程混淆!NLP 有助于分析原始文本数据并提取有用信息,如句子结构、文本情感,甚至不同语言之间的翻译。由于许多数据源包含原始文本(例如评论、新闻文章和医疗记录),NLP 变得越来越受欢迎,因为它提供了对文本的洞察,并有助于更轻松地做出自动化决策。

在幕后,NLP 通常使用机器学习算法来提取和建模文本的结构。如果将 NLP 应用于另一个机器方法的背景下,例如文本可以代表输入特征之一,NLP 的力量就更加明显。

在本章中,我们将应用 NLP 来分析电影评论的情感。基于标注的训练数据,我们将构建一个分类模型,用于区分正面和负面的电影评论。重要的是要提到,我们不直接从文本中提取情感(基于诸如爱、恨等词语),而是利用我们在上一章中已经探讨过的二元分类。

为了实现这一目标,我们将采用事先手动评分的原始电影评论,并训练一个集成模型-一组模型-如下所示:

  1. 处理电影评论以合成我们模型的特征。

在这里,我们将探讨使用文本数据创建各种特征的方法,从词袋模型到加权词袋模型(例如 TF-IDF),然后简要探讨 word2vec 算法,我们将在第五章中详细探讨,即预测和聚类的 Word2vec。

与此同时,我们将研究一些基本的特征选择/省略方法,包括去除停用词和标点,或者词干提取。

  1. 利用生成的特征,我们将运行各种监督的二元分类算法,帮助我们对正面和负面的评论进行分类,其中包括以下内容:
  • 分类决策树

  • 朴素贝叶斯

  • 随机森林

  • 梯度提升树

  1. 利用四种不同学习算法的综合预测能力,我们将创建一个超级学习模型,该模型将四种模型的所有“猜测”作为元特征,训练一个深度神经网络输出最终预测。

  2. 最后,我们将为此过程创建一个 Spark 机器学习管道,该管道执行以下操作:

  • 从新的电影评论中提取特征

  • 提出一个预测

  • 在 Spark 流应用程序中输出这个预测(是的,你将在本书的剩余章节中构建你的第一个机器学习应用程序!)

如果这听起来有点雄心勃勃,那就放心吧!我们将以一种有条理和有目的的方式逐步完成这些任务,这样你就可以有信心构建自己的 NLP 应用;但首先,让我们简要了解一下这个令人兴奋的领域的一些背景历史和理论。

NLP - 简要介绍

就像人工神经网络一样,NLP 是一个相对“古老”的主题,但最近由于计算能力的提升和机器学习算法在包括但不限于以下任务中的各种应用,它引起了大量关注:

  • 机器翻译(MT):在其最简单的形式中,这是机器将一种语言的词翻译成另一种语言的词的能力。有趣的是,机器翻译系统的提议早于数字计算机的创建。第一个自然语言处理应用之一是在二战期间由美国科学家沃伦·韦弗(Warren Weaver)创建的,他的工作是试图破译德国密码。如今,我们有高度复杂的应用程序,可以将一段文本翻译成我们想要的任意数量的不同语言!

  • 语音识别(SR):这些方法和技术试图利用机器识别和翻译口语到文本。我们现在在智能手机中看到这些技术,这些手机使用语音识别系统来帮助我们找到最近的加油站的方向,或者查询谷歌周末的天气预报。当我们对着手机说话时,机器能够识别我们说的话,然后将这些话翻译成计算机可以识别并执行某些任务的文本。

  • 信息检索(IR):你是否曾经阅读过一篇文章,比如新闻网站上的一篇文章,然后想看看与你刚刚阅读的文章类似的新闻文章?这只是信息检索系统的一个例子,它以一段文本作为“输入”,并寻求获取与输入文本类似的其他相关文本。也许最简单和最常见的信息检索系统的例子是在基于网络的搜索引擎上进行搜索。我们提供一些我们想要“了解更多”的词(这是“输入”),输出是搜索结果,希望这些结果与我们的输入搜索查询相关。

  • 信息提取(IE):这是从非结构化数据(如文本、视频和图片)中提取结构化信息的任务。例如,当你阅读某个网站上的博客文章时,通常会给这篇文章打上几个描述这篇文章一般主题的关键词,这可以使用信息提取系统进行分类。信息提取的一个极其受欢迎的领域是称为视觉信息提取,它试图从网页的视觉布局中识别复杂实体,这在典型的自然语言处理方法中无法捕捉到。

  • 文本摘要(该项没有缩写!):这是一个非常受欢迎的研究领域。这是通过识别主题等方式,对各种长度的文本进行摘要的任务。在下一章中,我们将通过主题模型(如潜在狄利克雷分配(LDA)和潜在语义分析(LSA))来探讨文本摘要的两种流行方法。

在本章中,我们将使用自然语言处理技术来帮助我们解决来自国际电影数据库(IMDb)的电影评论的二元分类问题。现在让我们将注意力转移到我们将使用的数据集,并学习更多关于使用 Spark 进行特征提取的技术。

数据集

最初发表在 Andrew L. Maas 等人的论文《为情感分析学习词向量》中的《大型电影评论数据库》可以从ai.stanford.edu/~amaas/data/sentiment/下载。

下载的存档包含两个标记为traintest的文件夹。对于训练,有 12,500 条正面评价和 12,500 条负面评价,我们将在这些上训练一个分类器。测试数据集包含相同数量的正面和负面评价,总共有 50,000 条正面和负面评价在这两个文件中。

让我们看一个评论的例子,看看数据是什么样子的:

“Bromwell High”简直太棒了。剧本写得精彩,表演完美,这部对南伦敦公立学校的学生和老师进行讽刺的喜剧让你捧腹大笑。它粗俗、挑衅、机智而敏锐。角色们是对英国社会(或者更准确地说,是对任何社会)的绝妙夸张。跟随凯莎、拉特丽娜和娜特拉的冒险,我们的三位“主角”,这部节目毫不避讳地对每一个可以想象的主题进行了讽刺。政治正确在每一集中都被抛在了窗外。如果你喜欢那些不怕拿每一个禁忌话题开玩笑的节目,那么《布朗韦尔高中》绝对不会让你失望!

看起来我们唯一需要处理的是来自电影评论的原始文本和评论情感;除了文本之外,我们对发布日期、评论者以及其他可能有用的数据一无所知。

数据集准备

在运行任何数据操作之前,我们需要像在前几章中那样准备好 Spark 环境。让我们启动 Spark shell,并请求足够的内存来处理下载的数据集:

export SPARK_HOME="<path to your Spark2.0 distribution"
export SPARKLING_WATER_VERSION="2.1.12"
export SPARK_PACKAGES=\
"ai.h2o:sparkling-water-core_2.11:${SPARKLING_WATER_VERSION},\
ai.h2o:sparkling-water-repl_2.11:${SPARKLING_WATER_VERSION},\
ai.h2o:sparkling-water-ml_2.11:${SPARKLING_WATER_VERSION},\
com.packtpub:mastering-ml-w-spark-utils:1.0.0"
$SPARK_HOME/bin/spark-shell \
--master 'local[*]' \
--driver-memory 10g \
--executor-memory 10g \
--confspark.executor.extraJavaOptions=-XX:MaxPermSize=384M \
--confspark.driver.extraJavaOptions=-XX:MaxPermSize=384M \
--packages "$SPARK_PACKAGES" "$@"

为了避免 Spark 产生过多的日志输出,可以通过在 SparkContext 上调用setLogLevel来直接控制运行时的日志级别:

sc.setLogLevel("WARN")

该命令减少了 Spark 输出的冗长程度。

下一个挑战是读取训练数据集,它由 25,000 条积极和消极的电影评论组成。以下代码将读取这些文件,然后创建我们的二进制标签,0 表示消极评论,1 表示积极评论。

我们直接利用了暴露的 Spark sqlContext方法textFile,它允许读取多个文件并返回 Dataset[String]。这与前几章提到的方法不同,前几章使用的是wholeTextFiles方法,产生的是 RDD[String]:

val positiveReviews= spark.sqlContext.read.textFile("../data/aclImdb/train/pos/*.txt") 
   .toDF("reviewText") 
println(s"Number of positive reviews: ${positiveReviews.count}") 
Number of positive reviews: 12500

我们可以直接使用数据集方法show来显示前五行(您可以修改截断参数以显示评论的完整文本):

println("Positive reviews:")
positiveReviews.show(5, truncate = true)

接下来,我们将对消极评论做同样的处理:

val negativeReviews= spark.sqlContext.read.textFile("../data/aclImdb/train/neg/*.txt")
                .toDF("reviewText")
println(s"Number of negative reviews: ${negativeReviews.count}")

看一下以下的截图:

现在,positiveReviewnegativeReviews变量分别表示加载的评论的 RDD。数据集的每一行包含一个表示单个评论的字符串。然而,我们仍然需要生成相应的标签,并将加载的两个数据集合并在一起。

标记很容易,因为我们将消极和积极的评论加载为分开的 Spark 数据框。我们可以直接添加一个表示消极评论的标签 0 和表示积极评论的标签 1 的常量列:

import org.apache.spark.sql.functions._
val pos= positiveReviews.withColumn("label", lit(1.0))
val neg= negativeReviews.withColumn("label", lit(0.0))
var movieReviews= pos.union(neg).withColumn("row_id", monotonically_increasing_id)
println("All reviews:")
movieReviews.show(5)

看一下以下的截图:

在这种情况下,我们使用了withColumn方法,它会在现有数据集中添加一个新列。新列lit(1.0)的定义意味着一个由数字文字1.0定义的常量列。我们需要使用一个实数来定义目标值,因为 Spark API 需要它。最后,我们使用union方法将这两个数据集合并在一起。

我们还添加了魔术列row_id,它唯一标识数据集中的每一行。这个技巧简化了我们在需要合并多个算法的输出时的工作流程。

为什么我们使用双精度值而不是字符串标签表示?在代码标记单个评论时,我们使用了表示双精度数字的数字文字来定义一个常量列。我们也可以使用lit("positive")来标记积极的评论,但是使用纯文本标签会迫使我们在后续步骤中将字符串值转换为数值。因此,在这个例子中,我们将直接使用双精度值标签来简化我们的生活。此外,我们直接使用双精度值,因为 Spark API 要求这样做。

特征提取

在这个阶段,我们只有一个代表评论的原始文本,这不足以运行任何机器学习算法。我们需要将文本转换为数字格式,也就是进行所谓的“特征提取”(就像它听起来的那样;我们正在提取输入数据并提取特征,这些特征将用于训练模型)。该方法基于输入特征生成一些新特征。有许多方法可以将文本转换为数字特征。我们可以计算单词的数量、文本的长度或标点符号的数量。然而,为了以一种系统化的方式表示文本,反映文本结构,我们需要更复杂的方法。

特征提取方法-词袋模型

现在我们已经摄取了我们的数据并创建了我们的标签,是时候提取我们的特征来构建我们的二元分类模型了。顾名思义,词袋模型方法是一种非常常见的特征提取技术,我们通过这种方法将一段文本,比如一部电影评论,表示为它的单词和语法标记的袋子(也称为多重集)。让我们通过几个电影评论的例子来看一个例子:

评论 1: 《侏罗纪世界》真是个失败!

评论 2: 《泰坦尼克号》……一个经典。摄影和表演一样出色!

对于每个标记(可以是一个单词和/或标点符号),我们将创建一个特征,然后计算该标记在整个文档中的出现次数。我们的词袋数据集对于第一条评论将如下所示:

评论 ID a 失败 侏罗纪 如此 世界 !
评论 1 1 1 1 1 1 1

首先,注意到这个数据集的排列方式,通常称为文档-术语矩阵(每个文档[行]由一定的一组单词[术语]组成,构成了这个二维矩阵)。我们也可以以不同的方式排列它,并转置行和列,创建-你猜对了-一个术语-文档矩阵,其中列现在显示具有该特定术语的文档,单元格内的数字是计数。还要意识到单词的顺序是按字母顺序排列的,这意味着我们失去了任何单词顺序的意义。这意味着“失败”一词与“侏罗纪”一词的相似度是相等的,虽然我们知道这不是真的,但这突显了词袋模型方法的一个局限性:单词顺序丢失了,有时,不同的文档可以有相同的表示,但意思完全不同。

在下一章中,您将了解到一种在谷歌开发并包含在 Spark 中的极其强大的学习算法,称为word-to-vectorword2vec),它本质上是将术语数字化以“编码”它们的含义。

其次,注意到对于我们给定的包括标点符号在内的六个标记的评论,我们有六列。假设我们将第二条评论添加到我们的文档-术语-矩阵中;我们原始的词袋模型会如何改变?

评论 ID a 表演 一个 摄影 经典 失败 出色 瞬间 侏罗纪 如此 泰坦尼克号 世界 . !
评论 1 1 0 0 0 0 0 1 0 0 1 1 0 0 1 0 1
评论 2 0 1 1 2 1 1 0 1 1 0 0 1 1 0 1 2

我们将我们原始的特征数量从五个增加到 16 个标记,这带来了这种方法的另一个考虑。鉴于我们必须为每个标记创建一个特征,很容易看出我们很快将拥有一个非常宽且非常稀疏的矩阵表示(稀疏是因为一个文档肯定不会包含每个单词/符号/表情符号等,因此大多数单元格输入将为零)。这对于我们的算法的维度来说提出了一些有趣的问题。

考虑这样一种情况,我们试图在文本文档上使用词袋方法训练一个随机森林,其中有 200,000 多个标记,其中大多数输入将为零。请记住,在基于树的学习器中,它要做出“向左还是向右”的决定,这取决于特征类型。在词袋示例中,我们可以将特征计数为真或假(即,文档是否具有该术语)或术语的出现次数(即,文档具有该术语的次数)。对于我们树中的每个后续分支,算法必须考虑所有这些特征(或者至少考虑特征数量的平方根,例如在随机森林的情况下),这可能是非常宽泛和稀疏的,并且做出影响整体结果的决定。

幸运的是,您将要学习 Spark 如何处理这种类型的维度和稀疏性,以及我们可以在下一节中采取的一些步骤来减少特征数量。

文本标记化

要执行特征提取,我们仍然需要提供组成原始文本的单词标记。但是,我们不需要考虑所有的单词或字符。例如,我们可以直接跳过标点符号或不重要的单词,如介词或冠词,这些单词大多不会带来任何有用的信息。

此外,常见做法是将标记规范化为通用表示。这可以包括诸如统一字符(例如,仅使用小写字符,删除变音符号,使用常见字符编码,如 utf8 等)或将单词放入通用形式(所谓的词干提取,例如,“cry”/“cries”/“cried”表示为“cry”)的方法。

在我们的示例中,我们将使用以下步骤执行此过程:

  1. 将所有单词转换为小写(“Because”和“because”是相同的单词)。

  2. 使用正则表达式函数删除标点符号。

  3. 删除停用词。这些基本上是没有上下文意义的禁令和连接词,例如inattheandetc,等等,这些词对我们想要分类的评论没有任何上下文意义。

  4. 查找在我们的评论语料库中出现次数少于三次的“稀有标记”。

  5. 最后,删除所有“稀有标记”。

前述序列中的每个步骤都代表了我们在对文本进行情感分类时的最佳实践。对于您的情况,您可能不希望将所有单词转换为小写(例如,“Python”语言和“python”蛇类是一个重要的区别!)。此外,您的停用词列表(如果选择包含)可能会有所不同,并且会根据您的任务融入更多的业务逻辑。一个收集停用词列表做得很好的网站是www.ranks.nl/stopwords

声明我们的停用词列表

在这里,我们可以直接重用 Spark 提供的通用英语停用词列表。但是,我们可以通过我们特定的停用词来丰富它:

import org.apache.spark.ml.feature.StopWordsRemover 
val stopWords= StopWordsRemover.loadDefaultStopWords("english") ++ Array("ax", "arent", "re")

正如前面所述,这是一项非常微妙的任务,严重依赖于您要解决的业务问题。您可能希望在此列表中添加与您的领域相关的术语,这些术语不会帮助预测任务。

声明一个标记器,对评论进行标记,并省略所有停用词和长度太短的单词:

val *MIN_TOKEN_LENGTH* = 3
val *toTokens*= (minTokenLen: Int, stopWords: Array[String], 
    review: String) =>
      review.split("""\W+""")
            .map(_.toLowerCase.replaceAll("[^\\p{IsAlphabetic}]", ""))
            .filter(w =>w.length>minTokenLen)
            .filter(w => !stopWords.contains(w))

让我们逐步查看这个函数,看看它在做什么。它接受单个评论作为输入,然后调用以下函数:

  • .split("""\W+"""):这将电影评论文本拆分为仅由字母数字字符表示的标记。

  • .map(_.toLowerCase.replaceAll("[^\\p{IsAlphabetic}]", "")): 作为最佳实践,我们将标记转换为小写,以便在索引时Java = JAVA = java。然而,这种统一并不总是成立,你需要意识到将文本数据转换为小写可能会对模型产生的影响。例如,计算语言"Python"转换为小写后是"python",这也是一种蛇。显然,这两个标记不相同;然而,转换为小写会使它们相同!我们还将过滤掉所有的数字字符。

  • .filter(w =>w.length>minTokenLen): 只保留长度大于指定限制的标记(在我们的例子中,是三个字符)。

  • .filter(w => !stopWords.contains(w)): 使用之前声明的停用词列表,我们可以从我们的标记化数据中删除这些术语。

现在我们可以直接将定义的函数应用于评论的语料库:

import spark.implicits._ 
val toTokensUDF= udf(toTokens.curried(MIN_TOKEN_LENGTH)(stopWords)) 
movieReviews= movieReviews.withColumn("reviewTokens", 
                                      toTokensUDF('reviewText)) 

在这种情况下,我们通过调用udf标记将函数toTokens标记为 Spark 用户定义的函数,这将公共 Scala 函数暴露给在 Spark DataFrame 上下文中使用。之后,我们可以直接将定义的udf函数应用于加载的数据集中的reviewText列。函数的输出创建了一个名为reviewTokens的新列。

我们将toTokenstoTokensUDF的定义分开,因为在一个表达式中定义它们会更容易。这是一个常见的做法,可以让你在不使用和了解 Spark 基础设施的情况下单独测试toTokens方法。

此外,你可以在不一定需要基于 Spark 的不同项目中重用定义的toTokens方法。

以下代码找到了所有的稀有标记:

val RARE_TOKEN = 2
val rareTokens= movieReviews.select("reviewTokens")
               .flatMap(r =>r.getAs[Seq[String]]("reviewTokens"))
               .map((v:String) => (v, 1))
               .groupByKey(t => t._1)
               .reduceGroups((a,b) => (a._1, a._2 + b._2))
               .map(_._2)
               .filter(t => t._2 <RARE_TOKEN)
               .map(_._1)
               .collect()

稀有标记的计算是一个复杂的操作。在我们的例子中,输入由包含标记列表的行表示。然而,我们需要计算所有唯一标记及其出现次数。

因此,我们使用flatMap方法将结构展平为一个新的数据集,其中每行表示一个标记。

然后,我们可以使用在前几章中使用的相同策略。我们可以为每个单词生成键值对(word, 1)

这对表示了给定单词的出现次数。然后,我们只需将所有具有相同单词的对分组在一起(groupByKey方法),并计算代表一组的单词的总出现次数(reduceGroups)。接下来的步骤只是过滤掉所有太频繁的单词,最后将结果收集为单词列表。

下一个目标是找到稀有标记。在我们的例子中,我们将考虑出现次数少于三次的每个标记:

println(s"Rare tokens count: ${rareTokens.size}")
println(s"Rare tokens: ${rareTokens.take(10).mkString(", ")}")

输出如下:

现在我们有了我们的标记化函数,是时候通过定义另一个 Spark UDF 来过滤出稀有标记了,我们将直接应用于reviewTokens输入数据列:

val rareTokensFilter= (rareTokens: Array[String], tokens: Seq[String]) =>tokens.filter(token => !rareTokens.contains(token)) 
val rareTokensFilterUDF= udf(rareTokensFilter.curried(rareTokens)) 

movieReviews= movieReviews.withColumn("reviewTokens", rareTokensFilterUDF('reviewTokens)) 

println("Movie reviews tokens:") 
movieReviews.show(5) 

电影评论的标记如下:

根据你的特定任务,你可能希望添加或删除一些停用词,或者探索不同的正则表达式模式(例如,使用正则表达式挖掘电子邮件地址是非常常见的)。现在,我们将使用我们拥有的标记构建我们的数据集。

还原和词形还原

在 NLP 中一个非常流行的步骤是将单词还原为它们的词根形式。例如,"accounts"和"accounting"都会被还原为"account",乍一看似乎非常合理。然而,还原会出现以下两个问题,你应该注意:

1. 过度还原:这是指还原未能将具有不同含义的两个单词区分开。例如,还原("general," "genetic") = "gene"。

  1. 欠词干化:这是无法将具有相同含义的单词减少到它们的根形式的能力。例如,stem("jumping","jumpiness")= jumpi,但 stem("jumped","jumps")= "jump"。在这个例子中,我们知道前面的每个术语只是根词"jump"的一个变形;然而,根据您选择使用的词干提取器(最常见的两种词干提取器是 Porter [最古老和最常见]和 Lancaster),您可能会陷入这种错误。

考虑到语料库中单词的过度和不足词干化的可能性,自然语言处理从业者提出了词形还原的概念来帮助解决这些已知问题。单词"lemming"是根据单词的上下文,以一组相关单词的规范(词典)形式。例如,lemma("paying","pays","paid")= "pay"。与词干提取类似,词形还原试图将相关单词分组,但它进一步尝试通过它们的词义来分组单词,因为毕竟,相同的两个单词在不同的上下文中可能有完全不同的含义!考虑到本章已经很深入和复杂,我们将避免执行任何词形还原技术,但感兴趣的人可以在stanfordnlp.github.io/CoreNLP/上进一步阅读有关这个主题的内容。

特征化-特征哈希

现在,是时候将字符串表示转换为数字表示了。我们采用词袋方法;然而,我们使用了一个叫做特征哈希的技巧。让我们更详细地看一下 Spark 如何使用这种强大的技术来帮助我们高效地构建和访问我们的标记数据集。我们使用特征哈希作为词袋的时间高效实现,正如前面所解释的。

在其核心,特征哈希是一种快速和空间高效的方法,用于处理高维数据-在处理文本时很典型-通过将任意特征转换为向量或矩阵中的索引。这最好用一个例子来描述。假设我们有以下两条电影评论:

  1. 电影《好家伙》物有所值。演技精湛!

  2. 《好家伙》是一部扣人心弦的电影,拥有一流的演员阵容和精彩的情节-所有电影爱好者必看!

对于这些评论中的每个标记,我们可以应用"哈希技巧",从而为不同的标记分配一个数字。因此,前面两条评论中唯一标记的集合(在小写+文本处理后)将按字母顺序排列:

{"acting": 1, "all": 2, "brilliant": 3, "cast": 4, "goodfellas": 5, "great": 6, "lover": 7, "money": 8, "movie": 9, "must": 10, "plot": 11, "riveting": 12, "see": 13, "spent": 14, "well": 15, "with": 16, "worth": 17}

然后,我们将应用哈希来创建以下矩阵:

[[1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1]
[0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0]]

特征哈希的矩阵构造如下:

  • 代表电影评论编号。

  • 代表特征(而不是实际单词!)。特征空间由一系列使用的哈希函数表示。请注意,对于每一行,列的数量是相同的,而不仅仅是一个不断增长的宽矩阵。

  • 因此,矩阵中的每个条目(i,j)= k表示在第i行,特征j出现k次。例如,标记"movie"被哈希到特征 9 上,在第二条评论中出现了两次;因此,矩阵(2,9)= 2。

  • 使用的哈希函数会产生间隙。如果哈希函数将一小组单词哈希到大的数字空间中,得到的矩阵将具有很高的稀疏性。

  • 重要的一点是要考虑的是哈希碰撞的概念,即两个不同的特征(在这种情况下是标记)被哈希到我们的特征矩阵中的相同索引号。防范这种情况的方法是选择大量要哈希的特征,这是我们可以在 Spark 中控制的参数(Spark 中的默认设置是 2²⁰〜100 万个特征)。

现在,我们可以使用 Spark 的哈希函数,它将每个标记映射到一个哈希索引,这将组成我们的特征向量/矩阵。与往常一样,我们将从我们需要的类的导入开始,然后将创建哈希的特征的默认值更改为大约 4096(2¹²)。

在代码中,我们将使用 Spark ML 包中的HashingTF转换器(您将在本章后面学习更多关于转换的内容)。它需要输入和输出列的名称。对于我们的数据集movieReviews,输入列是reviewTokens,其中包含在前面步骤中创建的标记。转换的结果存储在一个名为tf的新列中:

val hashingTF= new HashingTF hashingTF.setInputCol("reviewTokens")
                   .setOutputCol("tf")
                   .setNumFeatures(1 <<12) // 2¹²
                   .setBinary(false)
val tfTokens= hashingTF.transform(movieReviews)
println("Vectorized movie reviews:")
tfTokens.show(5)

输出如下:

调用转换后,生成的tfTokens数据集中除了原始数据之外,还包含一个名为tf的新列,该列保存了每个输入行的org.apache.spark.ml.linalg实例。向量。在我们的情况下,向量是稀疏向量(因为哈希空间远大于唯一标记的数量)。

术语频率-逆文档频率(TF-IDF)加权方案

现在,我们将使用 Spark ML 应用一个非常常见的加权方案,称为 TF-IDF,将我们的标记化评论转换为向量,这将成为我们机器学习模型的输入。这种转换背后的数学相对简单:

对于每个标记:

  1. 找到给定文档(在我们的情况下是电影评论)内的术语频率。

  2. 将此计数乘以查看标记在所有文档中出现的频率的对数的逆文档频率(通常称为语料库)。

  3. 取逆是有用的,因为它将惩罚在文档中出现太频繁的标记(例如,“电影”),并提升那些不太频繁出现的标记。

现在,我们可以根据先前解释的逆文档频率公式来缩放术语。首先,我们需要计算一个模型-关于如何缩放术语频率的规定。在这种情况下,我们使用 Spark IDF 估计器基于前一步hashingTF生成的输入数据创建模型:

import org.apache.spark.ml.feature.IDF
val idf= new IDF idf.setInputCol(hashingTF.getOutputCol)
                    .setOutputCol("tf-idf")
val idfModel= idf.fit(tfTokens)

现在,我们将构建一个 Spark 估计器,该估计器在输入数据(=上一步转换的输出)上进行了训练(拟合)。IDF 估计器计算单个标记的权重。有了模型,就可以将其应用于包含在拟合期间定义的列的任何数据:

val tfIdfTokens= idfModel.transform(tfTokens)
println("Vectorized and scaled movie reviews:")
tfIdfTokens.show(5)

让我们更详细地看一下单个行和hashingTFIDF输出之间的差异。这两个操作都产生了相同长度的稀疏向量。我们可以查看非零元素,并验证这两行在相同位置包含非零值:

import org.apache.spark.ml.linalg.Vector
val vecTf= tfTokens.take(1)(0).getAsVector.toSparse
val vecTfIdf= tfIdfTokens.take(1)(0).getAsVector.toSparse
println(s"Both vectors contains the same layout of non-zeros: ${java.util.Arrays.equals(vecTf.indices, vecTfIdf.indices)}")

我们还可以打印一些非零值:

println(s"${vecTf.values.zip(vecTfIdf.values).take(5).mkString("\n")}")

您可以直接看到,在句子中具有相同频率的标记根据它们在所有句子中的频率而产生不同的分数。

让我们进行一些(模型)训练!

此时,我们已经对文本数据进行了数值表示,以简单的方式捕捉了评论的结构。现在是建模的时候了。首先,我们将选择需要用于训练的列,并拆分生成的数据集。我们将保留数据集中生成的row_id列。但是,我们不会将其用作输入特征,而只会将其用作简单的唯一行标识符:

valsplits = tfIdfTokens.select("row_id", "label", idf.getOutputCol).randomSplit(Array(0.7, 0.1, 0.1, 0.1), seed = 42)
val(trainData, testData, transferData, validationData) = (splits(0), splits(1), splits(2), splits(3))
Seq(trainData, testData, transferData, validationData).foreach(_.cache())

请注意,我们已经创建了数据的四个不同子集:训练数据集、测试数据集、转移数据集和最终验证数据集。转移数据集将在本章后面进行解释,但其他所有内容应该已经非常熟悉了。

此外,缓存调用很重要,因为大多数算法将迭代地查询数据集数据,我们希望避免重复评估所有数据准备操作。

Spark 决策树模型

首先,让我们从一个简单的决策树开始,并对一些超参数进行网格搜索。我们将遵循第二章中的代码,探测暗物质:希格斯玻色子粒子来构建我们的模型,这些模型经过训练以最大化 AUC 统计量。然而,我们将不再使用 MLlib 库中的模型,而是采用 Spark ML 包中的模型。在后面需要将模型组合成管道时,使用 ML 包的动机将更加清晰。然而,在下面的代码中,我们将使用DecisionTreeClassifier,将其拟合到trainData,为testData生成预测,并借助BinaryClassificationEvaluato评估模型的 AUC 性能:

import org.apache.spark.ml.classification.DecisionTreeClassifier
import org.apache.spark.ml.classification.DecisionTreeClassificationModel
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator
import java.io.File
val dtModelPath = s" $ MODELS_DIR /dtModel"
val dtModel= {
  val dtGridSearch = for (
    dtImpurity<- Array("entropy", "gini");
    dtDepth<- Array(3, 5))
    yield {
      println(s"Training decision tree: impurity $dtImpurity,
              depth: $dtDepth")
      val dtModel = new DecisionTreeClassifier()
          .setFeaturesCol(idf.getOutputCol)
          .setLabelCol("label")
          .setImpurity(dtImpurity)
          .setMaxDepth(dtDepth)
          .setMaxBins(10)
          .setSeed(42)
          .setCacheNodeIds(true)
          .fit(trainData)
      val dtPrediction = dtModel.transform(testData)
      val dtAUC = new BinaryClassificationEvaluator().setLabelCol("label")
          .evaluate(dtPrediction)
      println(s" DT AUC on test data: $dtAUC")
      ((dtImpurity, dtDepth), dtModel, dtAUC)
    }
    println(dtGridSearch.sortBy(-_._3).take(5).mkString("\n"))
    val bestModel = dtGridSearch.sortBy(-_._3).head._2
    bestModel.write.overwrite.save(dtModelPath)
    bestModel
  }

在选择最佳模型之后,我们将把它写入文件。这是一个有用的技巧,因为模型训练可能会耗费时间和资源,下一次,我们可以直接从文件中加载模型,而不是重新训练它:

val dtModel= if (new File(dtModelPath).exists()) {
  DecisionTreeClassificationModel.load(dtModelPath)
} else { /* do training */ }

Spark 朴素贝叶斯模型

接下来,让我们来使用 Spark 的朴素贝叶斯实现。作为提醒,我们故意避免深入算法本身,因为这在许多机器学习书籍中已经涵盖过;相反,我们将专注于模型的参数,最终,我们将在本章后面的 Spark 流应用中“部署”这些模型。

Spark 对朴素贝叶斯的实现相对简单,我们只需要记住一些参数。它们主要如下:

  • getLambda:有时被称为“加法平滑”或“拉普拉斯平滑”,这个参数允许我们平滑观察到的分类变量的比例,以创建更均匀的分布。当你尝试预测的类别数量非常低,而你不希望由于低采样而错过整个类别时,这个参数尤为重要。输入 lambda 参数可以通过引入一些类别的最小表示来“帮助”你解决这个问题。

  • getModelType:这里有两个选项:“multinomial”(默认)或“Bernoulli”。Bernoulli模型类型会假设我们的特征是二进制的,在我们的文本示例中将是“评论中是否有单词 _____?是或否?”然而,multinomial模型类型采用离散的词频。另一个目前在 Spark 中朴素贝叶斯中没有实现但你需要知道的模型类型是高斯模型类型。这使我们的模型特征可以来自正态分布。

考虑到在这种情况下我们只有一个超参数要处理,我们将简单地使用我们的 lamda 的默认值,但是你也可以尝试网格搜索方法以获得最佳结果:

import org.apache.spark.ml.classification.{NaiveBayes, NaiveBayesModel}
val nbModelPath= s"$MODELS_DIR/nbModel"
val nbModel= {
  val model = new NaiveBayes()
      .setFeaturesCol(idf.getOutputCol)
      .setLabelCol("label")
      .setSmoothing(1.0)
      .setModelType("multinomial") // Note: input data are multinomial
      .fit(trainData)
  val nbPrediction = model.transform(testData)
  val nbAUC = new BinaryClassificationEvaluator().setLabelCol("label")
                 .evaluate(nbPrediction)
  println(s"Naive Bayes AUC: $nbAUC")
  model.write.overwrite.save(nbModelPath)
  model
}

比较不同模型在相同输入数据集上的性能是很有趣的。通常情况下,即使是简单的朴素贝叶斯算法也非常适合文本分类任务。部分原因在于该算法的第一个形容词:“朴素”。具体来说,这个特定的算法假设我们的特征——在这种情况下是全局加权的词项频率——是相互独立的。在现实世界中这是真的吗?更常见的情况是这个假设经常被违反;然而,这个算法仍然可以表现得和更复杂的模型一样好,甚至更好。

Spark 随机森林模型

接下来,我们将转向我们的随机森林算法,正如你从前面的章节中记得的那样,它是各种决策树的集成,我们将再次进行网格搜索,交替使用不同的深度和其他超参数,这将是熟悉的:

import org.apache.spark.ml.classification.{RandomForestClassifier, RandomForestClassificationModel}
val rfModelPath= s"$MODELS_DIR/rfModel"
val rfModel= {
  val rfGridSearch = for (
    rfNumTrees<- Array(10, 15);
    rfImpurity<- Array("entropy", "gini");
    rfDepth<- Array(3, 5))
    yield {
      println( s"Training random forest: numTrees: $rfNumTrees, 
              impurity $rfImpurity, depth: $rfDepth")
     val rfModel = new RandomForestClassifier()
         .setFeaturesCol(idf.getOutputCol)
         .setLabelCol("label")
         .setNumTrees(rfNumTrees)
         .setImpurity(rfImpurity)
         .setMaxDepth(rfDepth)
         .setMaxBins(10)
         .setSubsamplingRate(0.67)
         .setSeed(42)
         .setCacheNodeIds(true)
         .fit(trainData)
     val rfPrediction = rfModel.transform(testData)
     val rfAUC = new BinaryClassificationEvaluator()
                 .setLabelCol("label")
                 .evaluate(rfPrediction)
     println(s" RF AUC on test data: $rfAUC")
     ((rfNumTrees, rfImpurity, rfDepth), rfModel, rfAUC)
   }
   println(rfGridSearch.sortBy(-_._3).take(5).mkString("\n"))
   val bestModel = rfGridSearch.sortBy(-_._3).head._2 
   // Stress that the model is minimal because of defined gird space^
   bestModel.write.overwrite.save(rfModelPath)
   bestModel
}

从我们的网格搜索中,我们看到的最高 AUC 是0.769

Spark GBM 模型

最后,我们将继续使用梯度提升机GBM),这将是我们模型集成中的最终模型。请注意,在之前的章节中,我们使用了 H2O 的 GBM 版本,但现在,我们将坚持使用 Spark,并使用 Spark 的 GBM 实现如下:

import org.apache.spark.ml.classification.{GBTClassifier, GBTClassificationModel}
val gbmModelPath= s"$MODELS_DIR/gbmModel"
val gbmModel= {
  val model = new GBTClassifier()
      .setFeaturesCol(idf.getOutputCol)
      .setLabelCol("label")
      .setMaxIter(20)
      .setMaxDepth(6)
      .setCacheNodeIds(true)
      .fit(trainData)
  val gbmPrediction = model.transform(testData)
  gbmPrediction.show()
  val gbmAUC = new BinaryClassificationEvaluator()
      .setLabelCol("label")
      .setRawPredictionCol(model.getPredictionCol)
      .evaluate(gbmPrediction)
  println(s" GBM AUC on test data: $gbmAUC")
  model.write.overwrite.save(gbmModelPath)
  model
}

现在,我们已经训练了四种不同的学习算法:(单个)决策树、随机森林、朴素贝叶斯和梯度提升机。每个模型提供了不同的 AUC,如表中所总结的。我们可以看到表现最好的模型是随机森林,其次是 GBM。然而,公平地说,我们并没有对 GBM 模型进行详尽的搜索,也没有使用通常建议的高数量的迭代:

决策树 0.659
朴素贝叶斯 0.484
随机森林 0.769
GBM 0.755

超级学习者模型

现在,我们将结合所有这些算法的预测能力,借助神经网络生成一个“超级学习者”,该神经网络将每个模型的预测作为输入,然后尝试给出更好的预测,考虑到各个单独训练模型的猜测。在高层次上,架构会看起来像这样:

我们将进一步解释构建“超级学习者”的直觉和这种方法的好处,并教您如何构建您的 Spark 流应用程序,该应用程序将接收您的文本(即,您将写的电影评论)并将其通过每个模型的预测引擎。使用这些预测作为输入到您的神经网络,我们将利用各种算法的综合能力产生积极或消极的情绪。

超级学习者

在前面的章节中,我们训练了几个模型。现在,我们将使用深度学习模型将它们组合成一个称为超级学习者的集成。构建超级学习者的过程很简单(见前面的图):

  1. 选择基本算法(例如,GLM、随机森林、GBM 等)。

  2. 选择一个元学习算法(例如,深度学习)。

  3. 在训练集上训练每个基本算法。

  4. 对这些学习者进行 K 折交叉验证,并收集每个基本算法的交叉验证预测值。

  5. 从每个 L 基本算法中交叉验证预测的 N 个值可以组合成一个新的 NxL 矩阵。这个矩阵连同原始响应向量被称为“一级”数据。

  6. 在一级数据上训练元学习算法。

  7. 超级学习者(或所谓的“集成模型”)由 L 个基本学习模型和元学习模型组成,然后可以用于在测试集上生成预测。

集成的关键技巧是将一组不同的强学习者组合在一起。我们已经在随机森林算法的上下文中讨论了类似的技巧。

Erin LeDell 的博士论文包含了关于超级学习者及其可扩展性的更详细信息。您可以在www.stat.berkeley.edu/~ledell/papers/ledell-phd-thesis.pdf找到它。

在我们的示例中,我们将通过跳过交叉验证但使用单个留出数据集来简化整个过程。重要的是要提到,这不是推荐的方法!

作为第一步,我们使用训练好的模型和一个转移数据集来获得预测,并将它们组合成一个新的数据集,通过实际标签来增强它。

这听起来很容易;然而,我们不能直接使用DataFrame#withColumn方法并从不同数据集的多个列创建一个新的DataFrame,因为该方法只接受左侧DataFrame或常量列的列。

然而,我们已经通过为每一行分配一个唯一的 ID 来为这种情况准备了数据集。在这种情况下,我们将使用它,并根据row_id来合并各个模型的预测。我们还需要重命名每个模型预测列,以便在数据集中唯一标识模型预测:

import org.apache.spark.ml.PredictionModel 
import org.apache.spark.sql.DataFrame 

val models = Seq(("NB", nbModel), ("DT", dtModel), ("RF", rfModel), ("GBM", gbmModel)) 
def mlData(inputData: DataFrame, responseColumn: String, baseModels: Seq[(String, PredictionModel[_, _])]): DataFrame= { 
baseModels.map{ case(name, model) => 
model.transform(inputData) 
     .select("row_id", model.getPredictionCol ) 
     .withColumnRenamed("prediction", s"${name}_prediction") 
  }.reduceLeft((a, b) =>a.join(b, Seq("row_id"), "inner")) 
   .join(inputData.select("row_id", responseColumn), Seq("row_id"), "inner") 
} 
val mlTrainData= mlData(transferData, "label", models).drop("row_id") 
mlTrainData.show() 

该表由模型的预测组成,并由实际标签注释。看到个体模型在预测值上的一致性/不一致性是很有趣的。

我们可以使用相同的转换来准备超级学习器的验证数据集:

val mlTestData = mlData(validationData, "label", models).drop("row_id") 

现在,我们可以构建我们的元学习算法。在这种情况下,我们将使用 H2O 机器学习库提供的深度学习算法。但是,它需要一点准备-我们需要将准备好的训练和测试数据发布为 H2O 框架:

import org.apache.spark.h2o._ 
val hc= H2OContext.getOrCreate(sc) 
val mlTrainHF= hc.asH2OFrame(mlTrainData, "metaLearnerTrain") 
val mlTestHF= hc.asH2OFrame(mlTestData, "metaLearnerTest") 

我们还需要将label列转换为分类列。这是必要的;否则,H2O 深度学习算法将执行回归,因为label列是数值型的:

importwater.fvec.Vec
val toEnumUDF= (name: String, vec: Vec) =>vec.toCategoricalVec
mlTrainHF(toEnumUDF, 'label).update()
mlTestHF(toEnumUDF, 'label).update()

现在,我们可以构建一个 H2O 深度学习模型。我们可以直接使用该算法的 Java API;但是,由于我们希望将所有步骤组合成一个单独的 Spark 管道,因此我们将利用一个暴露 Spark 估计器 API 的包装器:

val metaLearningModel= new H2ODeepLearning()(hc, spark.sqlContext)
      .setTrainKey(mlTrainHF.key)
      .setValidKey(mlTestHF.key)
      .setResponseColumn("label")
      .setEpochs(10)
      .setHidden(Array(100, 100, 50))
      .fit(null)

由于我们直接指定了验证数据集,我们可以探索模型的性能:

或者,我们可以打开 H2O Flow UI(通过调用hc.openFlow)并以可视化形式探索其性能:

您可以轻松地看到该模型在验证数据集上的 AUC 为 0.868619-高于所有个体模型的 AUC 值。

将所有转换组合在一起

在前一节中,我们使用了 Spark 原语(即 UDF、本地 Spark 算法和 H2O 算法)开发了个别步骤。但是,要在未知数据上调用所有这些转换需要大量的手动工作。因此,Spark 引入了管道的概念,主要受到 Python scikit 管道的启发(scikit-learn.org/stable/modules/generated/sklearn.pipeline.Pipeline.html)。

要了解 Python 背后的设计决策更多信息,我们建议您阅读 Lars Buitinck 等人的优秀论文"API design for machine learning software: experiences from the scikit-learn project"(arxiv.org/abs/1309.0238)。

管道由由估计器和转换器表示的阶段组成:

  • 估计器:这些是核心元素,公开了一个创建模型的 fit 方法。大多数分类和回归算法都表示为估计器。

  • 转换器:这些将输入数据集转换为新数据集。转换器公开了transform方法,该方法实现了转换的逻辑。转换器可以生成单个或多个向量。大多数估计器生成的模型都是转换器-它们将输入数据集转换为表示预测的新数据集。本节中使用的 TF 转换器就是一个例子。

管道本身公开了与估计器相同的接口。它有 fit 方法,因此可以进行训练并生成"管道模型",该模型可用于数据转换(它具有与转换器相同的接口)。因此,管道可以按层次结合在一起。此外,单个管道阶段按顺序调用;但是,它们仍然可以表示有向无环图(例如,一个阶段可以有两个输入列,每个列由不同的阶段产生)。在这种情况下,顺序必须遵循图的拓扑排序。

在我们的示例中,我们将把所有的转换组合在一起。然而,我们不会定义一个训练管道(即,一个将训练所有模型的管道),而是使用已经训练好的模型来设置管道阶段。我们的动机是定义一个可以用来对新的电影评论进行评分的管道。

因此,让我们从我们示例的开始开始-我们在输入数据上应用的第一个操作是一个简单的分词器。它是由一个 Scala 函数定义的,我们将其包装成了 Spark UDF 的形式。然而,为了将其作为管道的一部分使用,我们需要将定义的 Scala 函数包装成一个转换。Spark 没有提供任何简单的包装器来做到这一点,因此需要从头开始定义一个通用的转换。我们知道我们将把一个列转换成一个新列。在这种情况下,我们可以使用UnaryTransformer,它确切地定义了一对一的列转换。我们可以更加通用一些,定义一个 Scala 函数(也就是 Spark UDFs)的通用包装器:

import org.apache.spark.ml.{Pipeline, UnaryTransformer} 
import org.apache.spark.sql.types._ 
import org.apache.spark.ml.param.ParamMap
import org.apache.spark.ml.util.{MLWritable, MLWriter} 

class UDFTransformerT, U 
extendsUnaryTransformer[T, U, UDFTransformer[T, U]] with MLWritable { 

override protected defcreateTransformFunc: T =>U = f 

override protected defvalidateInputType(inputType: DataType): Unit = require(inputType == inType) 

override protected defoutputDataType: DataType = outType 

override defwrite: MLWriter = new MLWriter { 
override protected defsaveImpl(path: String): Unit = {} 
 } 
} 

UDFTransformer类包装了一个函数f,该函数接受一个通用类型T,并产生类型U。在 Spark 数据集级别上,它将一个输入列(参见UnaryTransformer)的类型inType转换为一个新的输出列(同样,该字段由UnaryTransformer定义)的outType类型。该类还具有特质MLWritable的虚拟实现,支持将转换器序列化到文件中。

现在,我们只需要定义我们的分词器转换器:

val tokenizerTransformer= new UDFTransformer[String, Array[String]](
  "tokenizer", toTokens.curried(MIN_TOKEN_LENGTH)(stopWords),
  StringType, new ArrayType(StringType, true))

定义的转换器接受一个字符串列(即电影评论),并产生一个包含表示电影评论标记的字符串数组的新列。该转换器直接使用了我们在本章开头使用的toTokens函数。

接下来的转换应该是删除稀有单词。在这种情况下,我们将使用与上一步类似的方法,并利用定义的UDFTransformer函数:

val rareTokensFilterTransformer= new UDFTransformer[Seq[String], Seq[String]](
  "rareWordsRemover",
  rareTokensFilter.curried(rareTokens),
  newArrayType(StringType, true), new ArrayType(StringType, true))

这个转换器接受一个包含标记数组的列,并产生一个包含过滤后标记数组的新列。它使用了已经定义的rareTokensFilter Scala 函数。

到目前为止,我们还没有指定任何输入数据依赖关系,包括输入列的名称。我们将把它留到最终的管道定义中。

接下来的步骤包括使用TF方法进行向量化,将字符串标记哈希成一个大的数字空间,然后基于构建的IDF模型进行转换。这两个转换已经以期望的形式定义好了-第一个hashingTF转换已经是一个将一组标记转换为数值向量的转换器,第二个idfModel接受数值向量并根据计算的系数对其进行缩放。

这些步骤为训练好的二项模型提供了输入。每个基础模型代表一个产生多个新列的转换器,例如预测、原始预测和概率。然而,重要的是要提到,并非所有模型都提供完整的列集。例如,Spark GBM 目前(Spark 版本 2.0.0)只提供预测列。尽管如此,对于我们的示例来说已经足够了。

生成预测后,我们的数据集包含许多列;例如,输入列、带有标记的列、转换后的标记等等。然而,为了应用生成的元学习器,我们只需要基础模型生成的预测列。因此,我们将定义一个列选择器转换,删除所有不必要的列。在这种情况下,我们有一个接受 N 列并产生一个新的 M 列数据集的转换。因此,我们不能使用之前定义的UnaryTransformer,我们需要定义一个名为ColumnSelector的新的特定转换:

import org.apache.spark.ml.Transformer 
class ColumnSelector(override valuid: String, valcolumnsToSelect: Array[String]) extends Transformer with MLWritable { 

  override deftransform(dataset: Dataset[_]): DataFrame= { 
    dataset.select(columnsToSelect.map(dataset.col): _*) 
  } 

  override deftransformSchema(schema: StructType): StructType = { 
    StructType(schema.fields.filter(col=>columnsToSelect
                            .contains(col.name))) 
  } 

  override defcopy(extra: ParamMap): ColumnSelector = defaultCopy(extra) 

  override defwrite: MLWriter = new MLWriter { 
    override protected defsaveImpl(path: String): Unit = {} 
  } 
} 

ColumnSelector表示一个通用的转换器,它从输入数据集中仅选择给定的列。重要的是要提到整体的两阶段概念-第一阶段转换模式(即,与每个数据集相关联的元数据)和第二阶段转换实际数据集。这种分离允许 Spark 在调用实际数据转换之前对转换器进行早期检查,以查找不兼容之处。

我们需要通过创建columnSelector的实例来定义实际的列选择器转换器-请注意指定要保留的正确列:

val columnSelector= new ColumnSelector( 
  "columnSelector",  Array(s"DT_${dtModel.getPredictionCol}", 
  s"NB_${nbModel.getPredictionCol}", 
  s"RF_${rfModel.getPredictionCol}", 
  s"GBM_${gbmModel.getPredictionCol}") 

在这一点上,我们的转换器已经准备好组成最终的“超级学习”管道。管道的 API 很简单-它接受按顺序调用的单个阶段。然而,我们仍然需要指定单个阶段之间的依赖关系。大多数情况下,依赖关系是由输入和输出列名描述的:

val superLearnerPipeline = new Pipeline() 
 .setStages(Array( 
// Tokenize 
tokenizerTransformer 
     .setInputCol("reviewText") 
     .setOutputCol("allReviewTokens"), 
// Remove rare items 
rareTokensFilterTransformer 
     .setInputCol("allReviewTokens") 
     .setOutputCol("reviewTokens"), 
hashingTF, 
idfModel, 
dtModel 
     .setPredictionCol(s"DT_${dtModel.getPredictionCol}") 
     .setRawPredictionCol(s"DT_${dtModel.getRawPredictionCol}") 
     .setProbabilityCol(s"DT_${dtModel.getProbabilityCol}"), 
nbModel 
     .setPredictionCol(s"NB_${nbModel.getPredictionCol}") 
     .setRawPredictionCol(s"NB_${nbModel.getRawPredictionCol}") 
     .setProbabilityCol(s"NB_${nbModel.getProbabilityCol}"), 
rfModel 
     .setPredictionCol(s"RF_${rfModel.getPredictionCol}") 
     .setRawPredictionCol(s"RF_${rfModel.getRawPredictionCol}") 
     .setProbabilityCol(s"RF_${rfModel.getProbabilityCol}"), 
gbmModel// Note: GBM does not have full API of PredictionModel 
.setPredictionCol(s"GBM_${gbmModel.getPredictionCol}"), 
columnSelector, 
metaLearningModel 
 )) 

有一些值得一提的重要概念:

  • tokenizerTransformerrareTokensFilterTransformer通过列allReviewTokens连接-第一个是列生产者,第二个是列消费者。

  • dtModelnbModelrfModelgbmModel模型都将相同的输入列定义为idf.getOutputColumn。在这种情况下,我们有效地使用了计算 DAG,它是按拓扑顺序排列成一个序列

  • 所有模型都具有相同的输出列(在 GBM 的情况下有一些例外),由于管道期望列的唯一名称,因此不能将所有模型的输出列一起追加到结果数据集中。因此,我们需要通过调用setPredictionColsetRawPredictionColsetProbabilityCol来重命名模型的输出列。重要的是要提到,GBM 目前不会产生原始预测和概率列。

现在,我们可以拟合管道以获得管道模型。实际上,这是一个空操作,因为我们的管道只由转换器组成。然而,我们仍然需要调用fit方法:

val superLearnerModel= superLearnerPipeline.fit(pos)

哇,我们有了我们的超级学习模型,由多个 Spark 模型组成,并由 H2O 深度学习模型编排。现在是使用模型进行预测的时候了!

使用超级学习模型

模型的使用很简单-我们需要提供一个名为reviewText的单列数据集,并用superLearnerModel进行转换:

val review = "Although I love this movie, I can barely watch it, it is so real....."
val reviewToScore= sc.parallelize(Seq(review)).toDF("reviewText")
val reviewPrediction= superLearnerModel.transform(reviewToScore)

返回的预测reviewPrediction是一个具有以下结构的数据集:

reviewPrediction.printSchema()

第一列包含基于 F1 阈值决定的预测值。列p0p1表示各个预测类别的概率。

如果我们探索返回的数据集的内容,它包含一行:

reviewPrediction.show()

总结

本章演示了三个强大的概念:文本处理、Spark 管道和超级学习者。

文本处理是一个强大的概念,正在等待被行业广泛采用。因此,我们将在接下来的章节中深入探讨这个主题,并看看自然语言处理的其他方法。

对于 Spark 管道也是一样,它们已经成为 Spark 的固有部分和 Spark ML 包的核心。它们提供了一种优雅的方式,在训练和评分时重复使用相同的概念。因此,我们也希望在接下来的章节中使用这个概念。

最后,通过超级学习者,也就是集成学习,您学会了如何通过元学习器的帮助从多个模型中获益的基本概念。这提供了一种简单但强大的方式来构建强大的学习者,这些学习者仍然足够简单易懂。

第五章:用于预测和聚类的 Word2vec

在前几章中,我们涵盖了一些基本的 NLP 步骤,比如分词、停用词移除和特征创建,通过创建一个词频-逆文档频率TF-IDF)矩阵,我们执行了一个监督学习任务,预测电影评论的情感。在本章中,我们将扩展我们之前的例子,现在包括由 Google 研究人员 Tomas Mikolov 和 Ilya Sutskever 推广的词向量的惊人力量,他们在论文Distributed Representations of Words and Phrases and their Compositionality中提出。

我们将从词向量背后的动机进行简要概述,借鉴我们对之前 NLP 特征提取技术的理解,然后解释代表 word2vec 框架的一系列算法的概念(确实,word2vec 不仅仅是一个单一的算法)。然后,我们将讨论 word2vec 的一个非常流行的扩展,称为 doc2vec,我们在其中对整个文档进行向量化,转换为一个固定长度的 N 个数字的数组。我们将进一步研究这个极其流行的 NLP 领域,或认知计算研究。接下来,我们将把 word2vec 算法应用到我们的电影评论数据集中,检查生成的词向量,并通过取个别词向量的平均值来创建文档向量,以执行一个监督学习任务。最后,我们将使用这些文档向量来运行一个聚类算法,看看我们的电影评论向量有多好地聚集在一起。

词向量的力量是一个爆炸性的研究领域,谷歌和 Facebook 等公司都在这方面进行了大量投资,因为它具有对个别单词的语义和句法含义进行编码的能力,我们将很快讨论。不是巧合的是,Spark 实现了自己的 word2vec 版本,这也可以在谷歌的 Tensorflow 库和 Facebook 的 Torch 中找到。最近,Facebook 宣布了一个名为 deep text 的新的实时文本处理,使用他们预训练的词向量,他们展示了他们对这一惊人技术的信念以及它对他们的业务应用产生的或正在产生的影响。然而,在本章中,我们将只涵盖这个激动人心领域的一小部分,包括以下内容:

  • 解释 word2vec 算法

  • word2vec 思想的泛化,导致 doc2vec

  • 两种算法在电影评论数据集上的应用

词向量的动机

与我们在上一章中所做的工作类似,传统的 NLP 方法依赖于将通过分词创建的个别单词转换为计算机算法可以学习的格式(即,预测电影情感)。这需要我们将N个标记的单个评论转换为一个固定的表示,通过创建一个 TF-IDF 矩阵。这样做在幕后做了两件重要的事情:

  1. 个别的单词被分配了一个整数 ID(例如,一个哈希)。例如,单词friend可能被分配为 39,584,而单词bestie可能被分配为 99,928,472。认知上,我们知道friendbestie非常相似;然而,通过将这些标记转换为整数 ID,任何相似性的概念都会丢失。

  2. 通过将每个标记转换为整数 ID,我们因此失去了标记使用的上下文。这很重要,因为为了理解单词的认知含义,从而训练计算机学习friendbestie是相似的,我们需要理解这两个标记是如何使用的(例如,它们各自的上下文)。

考虑到传统 NLP 技术在编码单词的语义和句法含义方面的有限功能,托马斯·米科洛夫和其他研究人员探索了利用神经网络来更好地将单词的含义编码为N个数字的向量的方法(例如,向量好朋友 = [0.574, 0.821, 0.756, ... , 0.156])。当正确计算时,我们会发现好朋友朋友的向量在空间中是接近的,其中接近是指余弦相似度。事实证明,这些向量表示(通常称为单词嵌入)使我们能够更丰富地理解文本。

有趣的是,使用单词嵌入还使我们能够学习跨多种语言的相同语义,尽管书面形式有所不同(例如,日语和英语)。例如,电影的日语单词是eiga);因此,使用单词向量,这两个单词,movie*,在向量空间中应该是接近的,尽管它们在外观上有所不同。因此,单词嵌入允许应用程序是语言无关的——这也是为什么这项技术非常受欢迎的另一个原因!

word2vec 解释

首先要明确的是,word2vec 并不代表单一算法,而是一系列试图将单词的语义和句法含义编码为N个数字的向量的算法(因此,word-to-vector = word2vec)。我们将在本章中深入探讨这些算法的每一个,同时也给您机会阅读/研究文本向量化的其他领域,这可能会对您有所帮助。

什么是单词向量?

在其最简单的形式中,单词向量仅仅是一种独热编码,其中向量中的每个元素代表词汇中的一个单词,给定的单词被编码为1,而所有其他单词元素被编码为0。假设我们的词汇表只包含以下电影术语:爆米花糖果苏打水电影票票房大片

根据我们刚刚解释的逻辑,我们可以将术语电影票编码如下:

使用这种简单的编码形式,也就是我们创建词袋矩阵时所做的,我们无法对单词进行有意义的比较(例如,爆米花是否与苏打水相关;糖果是否类似于电影票?)。

考虑到这些明显的限制,word2vec 试图通过为单词提供分布式表示来解决这个问题。假设对于每个单词,我们有一个分布式向量,比如说,由 300 个数字表示一个单词,其中我们词汇表中的每个单词也由这 300 个元素中的权重分布来表示。现在,我们的情况将会发生显著变化,看起来会像这样:

现在,鉴于将单词的分布式表示为 300 个数字值,我们可以使用余弦相似度等方法在单词之间进行有意义的比较。也就是说,使用电影票苏打水的向量,我们可以确定这两个术语不相关,根据它们的向量表示和它们之间的余弦相似度。这还不是全部!在他们具有突破性的论文中,米科洛夫等人还对单词向量进行了数学函数的运算,得出了一些令人难以置信的发现;特别是,作者向他们的 word2vec 字典提出了以下数学问题

V(国王) - V(男人) + V(女人) ~ V(皇后)

事实证明,与传统 NLP 技术相比,这些单词的分布式向量表示在比较问题(例如,A 是否与 B 相关?)方面非常强大,这在考虑到这些语义和句法学习知识是来自观察大量单词及其上下文而无需其他信息时显得更加令人惊讶。也就是说,我们不需要告诉我们的机器爆米花是一种食物,名词,单数等等。

这是如何实现的呢?Word2vec 以一种受监督的方式利用神经网络的力量来学习单词的向量表示(这是一项无监督的任务)。如果一开始听起来有点像矛盾,不用担心!通过一些示例,一切都会变得更清晰,首先从连续词袋模型开始,通常简称为CBOW模型。

CBOW 模型

首先,让我们考虑一个简单的电影评论,这将成为接下来几节中的基本示例:

现在,想象我们有一个窗口,它就像一个滑块,包括当前焦点单词(在下图中用红色突出显示),以及焦点单词前后的五个单词(在下图中用黄色突出显示):

黄色的单词形成了围绕当前焦点单词ideas的上下文。这些上下文单词作为输入传递到我们的前馈神经网络,每个单词通过单热编码(其他元素被清零)编码,具有一个隐藏层和一个输出层:

在上图中,我们的词汇表的总大小(例如,分词后)由大写 C 表示,我们对上下文窗口中的每个单词进行单热编码--在这种情况下,是焦点单词ideas前后的五个单词。在这一点上,我们通过加权和将编码向量传播到我们的隐藏层,就像正常的前馈神经网络一样--在这里,我们预先指定了隐藏层中的权重数量。最后,我们将一个 sigmoid 函数应用于单隐藏层到输出层,试图预测当前焦点单词。这是通过最大化观察到焦点单词(idea)在其周围单词的上下文(filmwithplentyofsmartregardingtheimpactofalien)的条件概率来实现的。请注意,输出层的大小也与我们最初的词汇表 C 相同。

这就是 word2vec 算法族的有趣特性所在:它本质上是一种无监督学习算法,并依赖于监督学习来学习单词向量。这对于 CBOW 模型和跳字模型都是如此,接下来我们将介绍跳字模型。需要注意的是,在撰写本书时,Spark 的 MLlib 仅包含了 word2vec 的跳字模型。

跳字模型

在先前的模型中,我们使用了焦点词前后的单词窗口来预测焦点词。跳字模型采用了类似的方法,但是颠倒了神经网络的架构。也就是说,我们将以焦点词作为输入到我们的网络中,然后尝试使用单隐藏层来预测周围的上下文单词:

正如您所看到的,跳字模型与 CBOW 模型完全相反。网络的训练目标是最小化输出层中所有上下文单词的预测误差之和,在我们的示例中,输入是ideas,输出层预测filmwithplentyofsmartregardingtheimpactofalien

在前一章中,您看到我们使用了一个分词函数,该函数删除了停用词,例如thewithto等,我们故意没有在这里展示,以便清楚地传达我们的例子,而不让读者迷失。在接下来的示例中,我们将执行与第四章相同的分词函数,使用 NLP 和 Spark Streaming 预测电影评论,它将删除停用词。

单词向量的有趣玩法

现在我们已经将单词(标记)压缩成数字向量,我们可以对它们进行一些有趣的操作。您可以尝试一些来自原始 Google 论文的经典示例,例如:

  • 数学运算:正如前面提到的,其中一个经典的例子是v(国王) - v(男人) + v(女人) ~ v(皇后)。使用简单的加法,比如v(软件) + v(工程师),我们可以得出一些迷人的关系;以下是一些更多的例子:

  • 相似性:鉴于我们正在处理一个向量空间,我们可以使用余弦相似度来比较一个标记与许多其他标记,以查看相似的标记。例如,与v(Spark)相似的单词可能是v(MLlib)v(scala)v(graphex)等等。

  • 匹配/不匹配:给定一个单词列表,哪些单词是不匹配的?例如,doesn't_match[v(午餐, 晚餐, 早餐, 东京)] == v(东京)

  • A 对 B 就像 C 对?:根据 Google 的论文,以下是通过使用 word2vec 的 skip-gram 实现可能实现的单词比较列表:

余弦相似度

通过余弦相似度来衡量单词的相似性/不相似性,这个方法的一个很好的特性是它的取值范围在-11之间。两个单词之间的完全相似将产生一个得分为1,没有关系将产生0,而-1表示它们是相反的。

请注意,word2vec 算法的余弦相似度函数(目前仅在 Spark 中的 CBOW 实现中)已经内置到 MLlib 中,我们很快就会看到。

看一下下面的图表:

对于那些对其他相似性度量感兴趣的人,最近发表了一项研究,强烈建议使用Earth-Mover's DistanceEMD),这是一种与余弦相似度不同的方法,需要一些额外的计算,但显示出了有希望的早期结果。

解释 doc2vec

正如我们在本章介绍中提到的,有一个 word2vec 的扩展,它编码整个文档而不是单个单词。在这种情况下,文档可以是句子、段落、文章、散文等等。毫不奇怪,这篇论文是在原始 word2vec 论文之后发表的,但同样也是由 Tomas Mikolov 和 Quoc Le 合著的。尽管 MLlib 尚未将 doc2vec 引入其算法库,但我们认为数据科学从业者有必要了解这个 word2vec 的扩展,因为它在监督学习和信息检索任务中具有很大的潜力和结果。

与 word2vec 一样,doc2vec(有时称为段落向量)依赖于监督学习任务,以学习基于上下文单词的文档的分布式表示。Doc2vec 也是一类算法,其架构将与你在前几节学到的 word2vec 的 CBOW 和 skip-gram 模型非常相似。接下来你会看到,实现 doc2vec 将需要并行训练单词向量和代表我们所谓的文档的文档向量。

分布式记忆模型

这种特定的 doc2vec 模型与 word2vec 的 CBOW 模型非常相似,算法试图预测一个焦点单词,给定其周围的上下文单词,但增加了一个段落 ID。可以将其视为另一个帮助预测任务的上下文单词向量,但在我们认为的文档中是恒定的。继续我们之前的例子,如果我们有这个电影评论(我们定义一个文档为一个电影评论),我们的焦点单词是ideas,那么我们现在将有以下架构:

请注意,当我们在文档中向下移动并将焦点单词ideas更改为regarding时,我们的上下文单词显然会改变;然而,文档 ID:456保持不变。这是 doc2vec 中的一个关键点,因为文档 ID 在预测任务中被使用:

分布式词袋模型

doc2vec 中的最后一个算法是模仿 word2vec 跳字模型,唯一的区别是--我们现在将文档 ID 作为输入,尝试预测文档中随机抽样的单词,而不是使用焦点单词作为输入。也就是说,我们将完全忽略输出中的上下文单词:

与 word2vec 一样,我们可以使用这些段落向量对 N 个单词的文档进行相似性比较,在监督和无监督任务中都取得了巨大成功。以下是 Mikolov 等人在最后两章中使用的相同数据集进行的一些实验!

信息检索任务(三段,第一段应该听起来比第三段更接近第二段):

在接下来的章节中,我们将通过取个别词向量的平均值来创建一个穷人的文档向量,以将 n 长度的整个电影评论编码为 300 维的向量。

在撰写本书时,Spark 的 MLlib 没有 doc2vec 的实现;然而,有许多项目正在利用这项技术,这些项目处于孵化阶段,您可以测试。

应用 word2vec 并使用向量探索我们的数据

现在您已经对 word2vec、doc2vec 以及词向量的强大功能有了很好的理解,是时候将我们的注意力转向原始的 IMDB 数据集,我们将进行以下预处理:

  • 在每个电影评论中按空格拆分单词

  • 删除标点符号

  • 删除停用词和所有字母数字单词

  • 使用我们从上一章的标记化函数,最终得到一个逗号分隔的单词数组

因为我们已经在第四章中涵盖了前面的步骤,使用 NLP 和 Spark Streaming 预测电影评论,我们将在本节中快速重现它们。

像往常一样,我们从启动 Spark shell 开始,这是我们的工作环境:

export SPARKLING_WATER_VERSION="2.1.12" 
export SPARK_PACKAGES=\ 
"ai.h2o:sparkling-water-core_2.11:${SPARKLING_WATER_VERSION},\ 
ai.h2o:sparkling-water-repl_2.11:${SPARKLING_WATER_VERSION},\ 
ai.h2o:sparkling-water-ml_2.11:${SPARKLING_WATER_VERSION},\ 
com.packtpub:mastering-ml-w-spark-utils:1.0.0" 

$SPARK_HOME/bin/spark-shell \ 
        --master 'local[*]' \ 
        --driver-memory 8g \ 
        --executor-memory 8g \ 
        --conf spark.executor.extraJavaOptions=-XX:MaxPermSize=384M \ 
        --conf spark.driver.extraJavaOptions=-XX:MaxPermSize=384M \ 
        --packages "$SPARK_PACKAGES" "$@"

在准备好的环境中,我们可以直接加载数据:

val DATASET_DIR = s"${sys.env.get("DATADIR").getOrElse("data")}/aclImdb/train"
 val FILE_SELECTOR = "*.txt" 

case class Review(label: Int, reviewText: String) 

 val positiveReviews = spark.read.textFile(s"$DATASET_DIR/pos/$FILE_SELECTOR")
     .map(line => Review(1, line)).toDF
 val negativeReviews = spark.read.textFile(s"$DATASET_DIR/neg/$FILE_SELECTOR")
   .map(line => Review(0, line)).toDF
 var movieReviews = positiveReviews.union(negativeReviews)

我们还可以定义标记化函数,将评论分割成标记,删除所有常见单词:

import org.apache.spark.ml.feature.StopWordsRemover
 val stopWords = StopWordsRemover.loadDefaultStopWords("english") ++ Array("ax", "arent", "re")

 val MIN_TOKEN_LENGTH = 3
 val toTokens = (minTokenLen: Int, stopWords: Array[String], review: String) =>
   review.split("""\W+""")
     .map(_.toLowerCase.replaceAll("[^\\p{IsAlphabetic}]", ""))
     .filter(w => w.length > minTokenLen)
     .filter(w => !stopWords.contains(w))

所有构建块准备就绪后,我们只需将它们应用于加载的输入数据,通过一个新列reviewTokens对它们进行增强,该列保存从评论中提取的单词列表:


 val toTokensUDF = udf(toTokens.curried(MIN_TOKEN_LENGTH)(stopWords))
 movieReviews = movieReviews.withColumn("reviewTokens", toTokensUDF('reviewText))

reviewTokens列是 word2vec 模型的完美输入。我们可以使用 Spark ML 库构建它:

val word2vec = new Word2Vec()
   .setInputCol("reviewTokens")
   .setOutputCol("reviewVector")
   .setMinCount(1)
val w2vModel = word2vec.fit(movieReviews)

Spark 实现具有几个额外的超参数:

  • setMinCount:这是我们可以创建单词的最小频率。这是另一个处理步骤,以便模型不会在低计数的超级稀有术语上运行。

  • setNumIterations:通常,我们看到更多的迭代次数会导致更准确的词向量(将这些视为传统前馈神经网络中的时代数)。默认值设置为1

  • setVectorSize:这是我们声明向量大小的地方。它可以是任何整数,默认大小为100。许多公共预训练的单词向量倾向于更大的向量大小;然而,这纯粹取决于应用。

  • setLearningRate:就像我们在第二章中学到的常规神经网络一样,数据科学家需要谨慎--学习率太低,模型将永远无法收敛。然而,如果学习率太大,就会有风险在网络中得到一组非最优的学习权重。默认值为0

现在我们的模型已经完成,是时候检查一些我们的词向量了!请记住,每当您不确定您的模型可以产生什么值时,总是按tab按钮,如下所示:

w2vModel.findSynonyms("funny", 5).show()

输出如下:

让我们退一步考虑我们刚刚做的事情。首先,我们将单词funny压缩为由 100 个浮点数组成的向量(回想一下,这是 Spark 实现的 word2vec 算法的默认值)。因为我们已经将评论语料库中的所有单词都减少到了相同的分布表示形式,即 100 个数字,我们可以使用余弦相似度进行比较,这就是结果集中的第二个数字所反映的(在这种情况下,最高的余弦相似度是nutty一词).

请注意,我们还可以使用getVectors函数访问funny或字典中的任何其他单词的向量,如下所示:

w2vModel.getVectors.where("word = 'funny'").show(truncate = false)

输出如下:

基于这些表示,已经进行了许多有趣的研究,将相似的单词聚类在一起。在本章后面,当我们在下一节执行 doc2vec 的破解版本后,我们将重新讨论聚类。

创建文档向量

所以,现在我们可以创建编码单词含义的向量,并且我们知道任何给定的电影评论在标记化后是一个由N个单词组成的数组,我们可以开始创建一个简易的 doc2vec,方法是取出构成评论的所有单词的平均值。也就是说,对于每个评论,通过对个别单词向量求平均值,我们失去了单词的具体顺序,这取决于您的应用程序的敏感性,可能会产生差异:

v(word_1) + v(word_2) + v(word_3) ... v(word_Z) / count(words in review)

理想情况下,人们会使用 doc2vec 的一种变体来创建文档向量;然而,截至撰写本书时,MLlib 尚未实现 doc2vec,因此,我们暂时使用这个简单版本,正如您将看到的那样,它产生了令人惊讶的结果。幸运的是,如果模型包含一个标记列表,Spark ML 实现的 word2vec 模型已经对单词向量进行了平均。例如,我们可以展示短语funny movie的向量等于funnymovie标记的向量的平均值:

val testDf = Seq(Seq("funny"), Seq("movie"), Seq("funny", "movie")).toDF("reviewTokens")
 w2vModel.transform(testDf).show(truncate=false)

输出如下:

因此,我们可以通过简单的模型转换准备我们的简易版本 doc2vec:

val inputData = w2vModel.transform(movieReviews)

作为这个领域的从业者,我们有机会与各种文档向量的不同变体一起工作,包括单词平均、doc2vec、LSTM 自动编码器和跳跃思想向量。我们发现,对于单词片段较小的情况,单词的顺序并不重要,简单的单词平均作为监督学习任务效果出奇的好。也就是说,并不是说它不能通过 doc2vec 和其他变体来改进,而是基于我们在各种客户应用程序中看到的许多用例的观察结果。

监督学习任务

就像在前一章中一样,我们需要准备训练和验证数据。在这种情况下,我们将重用 Spark API 来拆分数据:

val trainValidSplits = inputData.randomSplit(Array(0.8, 0.2))
val (trainData, validData) = (trainValidSplits(0), trainValidSplits(1))

现在,让我们使用一个简单的决策树和一些超参数进行网格搜索:

val gridSearch =
for (
     hpImpurity <- Array("entropy", "gini");
     hpDepth <- Array(5, 20);
     hpBins <- Array(10, 50))
yield {
println(s"Building model with: impurity=${hpImpurity}, depth=${hpDepth}, bins=${hpBins}")
val model = new DecisionTreeClassifier()
         .setFeaturesCol("reviewVector")
         .setLabelCol("label")
         .setImpurity(hpImpurity)
         .setMaxDepth(hpDepth)
         .setMaxBins(hpBins)
         .fit(trainData)

val preds = model.transform(validData)
val auc = new BinaryClassificationEvaluator().setLabelCol("label")
         .evaluate(preds)
       (hpImpurity, hpDepth, hpBins, auc)
     }

我们现在可以检查结果并显示最佳模型 AUC:

import com.packtpub.mmlwspark.utils.Tabulizer.table
println(table(Seq("Impurity", "Depth", "Bins", "AUC"),
               gridSearch.sortBy(_._4).reverse,
Map.empty[Int,String]))

输出如下:

使用这个简单的决策树网格搜索,我们可以看到我们的简易 doc2vec产生了 0.7054 的 AUC。让我们还将我们的确切训练和测试数据暴露给 H2O,并尝试使用 Flow UI 运行深度学习算法:

import org.apache.spark.h2o._
val hc = H2OContext.getOrCreate(sc)
val trainHf = hc.asH2OFrame(trainData, "trainData")
val validHf = hc.asH2OFrame(validData, "validData")

现在我们已经成功将我们的数据集发布为 H2O 框架,让我们打开 Flow UI 并运行深度学习算法:

hc.openFlow()

首先,请注意,如果我们运行getFrames命令,我们将看到我们无缝从 Spark 传递到 H2O 的两个 RDD:

我们需要通过单击 Convert to enum 将标签列的类型从数值列更改为分类列,对两个框架都进行操作:

接下来,我们将运行一个深度学习模型,所有超参数都设置为默认值,并将第一列设置为我们的标签:

如果您没有明确创建训练/测试数据集,您还可以使用先前的nfolds超参数执行n 折交叉验证

运行模型训练后,我们可以点击“查看”查看训练和验证数据集上的 AUC:

我们看到我们简单的深度学习模型的 AUC 更高,约为 0.8289。这是没有任何调整或超参数搜索的结果。

我们可以执行哪些其他步骤来进一步改进 AUC?我们当然可以尝试使用网格搜索超参数来尝试新算法,但更有趣的是,我们可以调整文档向量吗?答案是肯定和否定!这部分是否定的,因为正如您所记得的,word2vec 本质上是一个无监督学习任务;但是,通过观察返回的一些相似单词,我们可以了解我们的向量的强度。例如,让我们看看单词drama

w2vModel.findSynonyms("drama", 5).show()

输出如下:

直观地,我们可以查看结果,并询问这五个单词是否真的是单词drama的最佳同义词(即最佳余弦相似性)。现在让我们尝试通过修改其输入参数重新运行我们的 word2vec 模型:

val newW2VModel = new Word2Vec()
   .setInputCol("reviewTokens")
   .setOutputCol("reviewVector")
   .setMinCount(3)
   .setMaxIter(250)
   .setStepSize(0.02)
   .fit(movieReviews)
    newW2VModel.findSynonyms("drama", 5).show()

输出如下:

您应该立即注意到同义词在相似性方面更好,但也要注意余弦相似性对这些术语来说显着更高。请记住,word2vec 的默认迭代次数为 1,现在我们已将其设置为250,允许我们的网络真正定位一些高质量的词向量,这可以通过更多的预处理步骤和进一步调整 word2vec 的超参数来进一步改进,这应该产生更好质量的文档向量。

总结

许多公司(如谷歌)免费提供预训练的词向量(在 Google News 的子集上训练,包括前三百万个单词/短语)以供各种向量维度使用:例如,25d、50d、100d、300d 等。您可以在此处找到代码(以及生成的词向量)。除了 Google News,还有其他来源的训练词向量,使用维基百科和各种语言。您可能会有一个问题,即如果谷歌等公司免费提供预训练的词向量,为什么还要自己构建?这个问题的答案当然是应用相关的;谷歌的预训练词典对于单词java有三个不同的向量,基于大小写(JAVA、Java 和 java 表示不同的含义),但也许,您的应用只涉及咖啡,因此只需要一个版本的 java。

本章的目标是为您清晰简洁地解释 word2vec 算法以及该算法的非常流行的扩展,如 doc2vec 和序列到序列学习模型,这些模型采用各种风格的循环神经网络。正如总是一章的时间远远不足以涵盖自然语言处理这个极其激动人心的领域,但希望这足以激发您的兴趣!

作为这一领域的从业者和研究人员,我们(作者)不断思考将文档表示为固定向量的新方法,有很多论文致力于解决这个问题。您可以考虑LDA2vecSkip-thought Vectors以进一步阅读该主题。

其他一些博客可添加到您的阅读列表,涉及自然语言处理NLP)和向量化,如下所示:

在下一章中,我们将再次看到词向量,我们将结合到目前为止学到的所有知识来解决一个需要在各种处理任务和模型输入方面“应有尽有”的问题。 敬请关注!

第六章:从点击流数据中提取模式

在收集个别测量或事件之间的真实世界数据时,通常会有非常复杂和高度复杂的关系需要观察。本章的指导示例是用户在网站及其子域上生成的点击事件的观察。这样的数据既有趣又具有挑战性。它有趣,因为通常有许多模式显示出用户在其浏览行为中的行为和某些规则。至少对于运行网站的公司和可能成为他们数据科学团队的焦点,了解用户群体的见解是有趣的。方法论方面,建立一个能够实时检测模式的生产系统,例如查找恶意行为,技术上可能非常具有挑战性。能够理解和实施算法和技术两方面是非常有价值的。

在本章中,我们将深入研究两个主题:在 Spark 中进行模式挖掘和处理流数据。本章分为两个主要部分。在第一部分中,我们将介绍 Spark 目前提供的三种可用模式挖掘算法,并将它们应用于一个有趣的数据集。在第二部分中,我们将更加技术化地看待问题,并解决使用第一部分算法部署流数据应用时出现的核心问题。特别是,您将学习以下内容:

  • 频繁模式挖掘的基本原则。

  • 应用程序的有用和相关数据格式。

  • 如何加载和分析用户在MSNBC.com上生成的点击流数据集。

  • 在 Spark 中了解和比较三种模式挖掘算法,即FP-growth,关联规则前缀跨度

  • 如何将这些算法应用于 MSNBC 点击数据和其他示例以识别相关模式。

  • Spark Streaming的基础知识以及它可以涵盖哪些用例。

  • 如何通过使用 Spark Streaming 将任何先前的算法投入生产。

  • 使用实时聚合的点击事件实现更实际的流应用程序。

通过构建,本章在技术上更加涉及到了末尾,但是通过Spark Streaming,它也允许我们介绍 Spark 生态系统中另一个非常重要的工具。我们首先介绍模式挖掘的一些基本问题,然后讨论如何解决这些问题。

频繁模式挖掘

当面对一个新的数据集时,一个自然的问题序列是:

  • 我们看什么样的数据;也就是说,它有什么结构?

  • 数据中可以经常发现哪些观察结果;也就是说,我们可以在数据中识别出哪些模式或规则?

  • 我们如何评估什么是频繁的;也就是说,什么是良好的相关性度量,我们如何测试它?

在非常高的层次上,频繁模式挖掘正是在解决这些问题。虽然很容易立即深入研究更高级的机器学习技术,但这些模式挖掘算法可以提供相当多的信息,并帮助建立对数据的直觉。

为了介绍频繁模式挖掘的一些关键概念,让我们首先考虑一个典型的例子,即购物车。对顾客对某些产品感兴趣并购买的研究长期以来一直是全球营销人员的主要关注点。虽然在线商店确实有助于进一步分析顾客行为,例如通过跟踪购物会话中的浏览数据,但已购买的物品以及购买行为中的模式的问题也适用于纯线下场景。我们很快将看到在网站上积累的点击流数据的更复杂的例子;目前,我们将在假设我们可以跟踪的事件中只有物品的实际支付交易的情况下进行工作。

例如,对于超市或在线杂货购物车的给定数据,会引发一些有趣的问题,我们主要关注以下三个问题:

  • 哪些物品经常一起购买?例如,有传闻证据表明啤酒和尿布经常在同一次购物会话中一起购买。发现经常一起购买的产品的模式可能允许商店将这些产品放在彼此更近的位置,以增加购物体验或促销价值,即使它们乍一看并不属于一起。在在线商店的情况下,这种分析可能是简单推荐系统的基础。

  • 基于前面的问题,在购物行为中是否有任何有趣的影响或规则?继续以购物车为例,我们是否可以建立关联,比如如果购买了面包和黄油,我们也经常在购物车中找到奶酪?发现这样的关联规则可能非常有趣,但也需要更多澄清我们认为的经常是什么意思,也就是,频繁意味着什么。

  • 注意,到目前为止,我们的购物车只是被简单地视为一个物品袋,没有额外的结构。至少在在线购物的情况下,我们可以为数据提供更多信息。我们将关注物品的顺序性;也就是说,我们将注意产品被放入购物车的顺序。考虑到这一点,类似于第一个问题,人们可能会问,我们的交易数据中经常可以找到哪些物品序列?例如,购买大型电子设备后可能会跟随购买额外的实用物品。

我们之所以特别关注这三个问题,是因为 Spark MLlib 正好配备了三种模式挖掘算法,它们大致对应于前面提到的问题,能够回答这些问题。具体来说,我们将仔细介绍FP-growth关联规则前缀跨度,以解决这些问题,并展示如何使用 Spark 解决这些问题。在这样做之前,让我们退一步,正式介绍到目前为止我们已经为之努力的概念,以及一个运行的例子。我们将在接下来的小节中提到前面的三个问题。

模式挖掘术语

我们将从一组项目I = {a[1], ..., a[n]}开始,这将作为所有以下概念的基础。事务 T 只是 I 中的一组项目,如果它包含l个项目,则我们说 T 是长度为l的事务。事务数据库 D 是事务 ID 和它们对应的事务的数据库。

为了给出一个具体的例子,考虑以下情况。假设要购物的完整物品集由I = {面包,奶酪,菠萝,鸡蛋,甜甜圈,鱼,猪肉,牛奶,大蒜,冰淇淋,柠檬,油,蜂蜜,果酱,羽衣甘蓝,盐}给出。由于我们将查看很多物品子集,为了使以后的事情更容易阅读,我们将简单地用它们的第一个字母缩写这些物品,也就是说,我们将写I = {b,c,a,e,d,f,p,m,g,i,l,o,h,j,k,s}。给定这些物品,一个小的交易数据库 D 可能如下所示:

交易 ID 交易
1 a, c, d, f, g, i, m, p
2 a, b, c, f, l, m, o
3 b, f, h, j, o
4 b, c, k, s, p
5 a, c, e, f, l, m, n, p

表 1:一个包含五个交易的小购物车数据库

频繁模式挖掘问题

鉴于交易数据库的定义,模式P 是包含在 D 中的交易,模式的支持supp(P)是这个为真的交易数量,除以或归一化为 D 中的交易数量:

supp(s) = suppD = |{ s' ∈ S | s <s'}| / |D|

我们使用<符号来表示s作为s'的子模式,或者反过来,称s's的超模式。请注意,在文献中,您有时也会找到一个略有不同的支持版本,它不会对值进行归一化。例如,模式{a,c,f}可以在交易 1、2 和 5 中找到。这意味着{a,c,f}是我们数据库 D 中支持为 0.6 的模式的模式。

支持是一个重要的概念,因为它给了我们一个测量模式频率的第一个例子,这正是我们追求的。在这种情况下,对于给定的最小支持阈值t,我们说P是一个频繁模式,当且仅当supp(P)至少为t。在我们的运行示例中,长度为 1 且最小支持0.6的频繁模式是{a},{b},{c},{p},和{m},支持为 0.6,以及{f},支持为 0.8。在接下来的内容中,我们经常会省略项目或模式的括号,并写f代替{f},例如。

给定最小支持阈值,找到所有频繁模式的问题被称为频繁模式挖掘问题,实际上,这是前面提到的第一个问题的形式化版本。继续我们的例子,我们已经找到了t = 0.6的长度为 1 的所有频繁模式。我们如何找到更长的模式?在理论上,鉴于资源是无限的,这并不是什么大问题,因为我们所需要做的就是计算项目的出现次数。然而,在实际层面上,我们需要聪明地处理这个问题,以保持计算的高效性。特别是对于足够大以至于 Spark 能派上用场的数据库来说,解决频繁模式挖掘问题可能会非常计算密集。

一个直观的解决方法是这样的:

  1. 找到所有长度为 1 的频繁模式,这需要进行一次完整的数据库扫描。这就是我们在前面的例子中开始的方式。

  2. 对于长度为 2 的模式,生成所有频繁 1-模式的组合,即所谓的候选项,并通过对 D 的另一次扫描来测试它们是否超过最小支持。

  3. 重要的是,我们不必考虑不频繁模式的组合,因为包含不频繁模式的模式不能变得频繁。这种推理被称为先验原则

  4. 对于更长的模式,迭代地继续这个过程,直到没有更多的模式可以组合。

这种算法使用生成和测试方法进行模式挖掘,并利用先验原则来限制组合,称为先验算法。这种基线算法有许多变体,它们在可扩展性方面存在类似的缺点。例如,需要进行多次完整的数据库扫描来执行迭代,这对于庞大的数据集可能已经成本过高。此外,生成候选本身已经很昂贵,但计算它们的组合可能根本不可行。在下一节中,我们将看到 Spark 中的FP-growth算法的并行版本如何克服刚才讨论的大部分问题。

关联规则挖掘问题

为了进一步介绍概念,让我们接下来转向关联规则,这是首次在大型数据库中挖掘项集之间的关联规则中引入的,可在arbor.ee.ntu.edu.tw/~chyun/dmpaper/agrama93.pdf上找到。与仅计算数据库中项的出现次数相反,我们现在想要理解模式的规则或推论。我的意思是,给定模式P[1]和另一个模式P[2],我们想知道在D中可以找到P[1]时,P[2]是否经常出现,我们用P[1 ]⇒ P[2]来表示这一点。为了更加明确,我们需要一个类似于模式支持的规则频率的概念,即置信度。对于规则P[1 ]⇒ P[2],置信度定义如下:

conf(P[1] ⇒ P[2]) = supp(P[1] ∪ P[2]) / supp(P[1])

这可以解释为P[1]给出P[2]的条件支持;也就是说,如果将D限制为支持P[1]的所有交易,那么在这个受限制的数据库中,P[2]的支持将等于conf(P[1 ]⇒ P[2])。如果它超过最小置信度阈值t,我们称P[1 ]⇒ P[2]D中的规则,就像频繁模式的情况一样。找到置信度阈值的所有规则代表了第二个问题关联规则挖掘的正式答案。此外,在这种情况下,我们称P[1 ]前提P[2]结论。通常,对前提或结论的结构没有限制。但在接下来的内容中,为简单起见,我们将假设结论的长度为 1。

在我们的运行示例中,模式{f,m}出现了三次,而{f,m,p}只出现了两次,这意味着规则{f,m}⇒{p}的置信度为2/3。如果我们将最小置信度阈值设置为t = 0.6,我们可以轻松地检查以下具有长度为 1 的前提和结论的关联规则对我们的情况有效:

{a}⇒{c},{a}⇒{f},{a}⇒{m},{a}⇒{p},{c}⇒{a},{c}⇒{f},{c}⇒{m},{c}⇒{p},{f}⇒{a},{f}⇒{c},{f}⇒{m},{m}⇒{a},{m}⇒{c},{m}⇒{f},{m}⇒{p},{p}⇒{a},{p}⇒{c},{p}⇒{f},{p}⇒{m}

从置信度的前面定义可以清楚地看出,一旦我们有了所有频繁模式的支持值,计算关联规则就相对简单。实际上,正如我们将很快看到的那样,Spark 对关联规则的实现是基于预先计算频繁模式的。

此时应该指出的是,虽然我们将限制自己在支持和置信度的度量上,但还有许多其他有趣的标准可用,我们无法在本书中讨论;例如,信念、杠杆、提升的概念。有关其他度量的深入比较,请参阅www.cse.msu.edu/~ptan/papers/IS.pdf

顺序模式挖掘问题

让我们继续正式化,这是我们在本章中处理的第三个也是最后一个模式匹配问题。让我们更详细地看一下序列。序列与我们之前看到的交易不同,因为现在顺序很重要。对于给定的项目集I,长度为l的序列SI中定义如下:

s = <s[1,] s[2],..., s[l]>

在这里,每个单独的s[i]都是项目的连接,即s[i] = (a[i1] ... a[im)],其中a[ij]I中的一个项目。请注意,我们关心序列项s[i]的顺序,但不关心s[i]中各个a[ij]的内部顺序。序列数据库S由序列 ID 和序列的成对组成,类似于我们之前的内容。这样的数据库示例可以在下表中找到,其中的字母代表与我们之前的购物车示例中相同的项目:

序列 ID 序列
1 <a(abc)(ac)d(cf)>
2 <(ad)c(bc)(ae)>
3 <(ef)(ab)(df)cb>
4 <eg(af)cbc>

表 2:一个包含四个短序列的小序列数据库。

在示例序列中,注意圆括号将单个项目分组为序列项。还要注意,如果序列项由单个项目组成,我们会省略这些冗余的大括号。重要的是,子序列的概念需要比无序结构更加小心。我们称u = (u[1], ..., u[n])s = (s[1],..., s[l])子序列,并写为u <s,如果存在索引1 **≤ i1 < i2 < ... < in ≤ m,使得我们有以下关系:

u[1] < s[i1], ..., u[n] <s[in]

在这里,最后一行中的< 符号表示u[j]s[ij]的子模式。粗略地说,如果u的所有元素按给定顺序是s的子模式,那么u就是s的子序列。同样地,我们称su的超序列。在前面的例子中,我们看到<a(ab)ac>a(cb)(ac)dc><a(abc)(ac)d(cf)>的子序列的例子,而<(fa)c><eg(af)cbc>的子序列的例子。

借助超序列的概念,我们现在可以定义给定序列数据库S中序列s支持度如下:

suppS = supp(s) = |{ s' ∈ S | s <s'}| / |S|

请注意,结构上,这与无序模式的定义相同,但<符号表示的是另一种含义,即子序列。与以前一样,如果上下文中的信息清楚,我们在支持度的表示法中省略数据库下标。具备了支持度的概念,顺序模式的定义完全类似于之前的定义。给定最小支持度阈值t,序列S中的序列s如果supp(s)大于或等于t,则称为顺序模式。第三个问题的形式化被称为顺序模式挖掘问题,即找到在给定阈值tS中的所有顺序模式的完整集合。

即使在我们只有四个序列的小例子中,手动检查所有顺序模式也可能是具有挑战性的。举一个支持度为 1.0的顺序模式的例子,所有四个序列的长度为 2 的子序列是。找到所有顺序模式是一个有趣的问题,我们将在下一节学习 Spark 使用的所谓前缀 span算法来解决这个问题。

使用 Spark MLlib 进行模式挖掘

在激发和介绍了三个模式挖掘问题以及必要的符号来正确讨论它们之后,我们将讨论如何使用 Spark MLlib 中可用的算法解决这些问题。通常情况下,由于 Spark MLlib 为大多数算法提供了方便的run方法,实际应用算法本身相当简单。更具挑战性的是理解算法及其随之而来的复杂性。为此,我们将逐一解释这三种模式挖掘算法,并研究它们是如何实现以及如何在玩具示例中使用它们。只有在完成所有这些之后,我们才会将这些算法应用于从MSNBC.com检索到的点击事件的真实数据集。

Spark 中模式挖掘算法的文档可以在spark.apache.org/docs/2.1.0/mllib-frequent-pattern-mining.html找到。它为希望立即深入了解的用户提供了一个很好的入口点。

使用 FP-growth 进行频繁模式挖掘

当我们介绍频繁模式挖掘问题时,我们还快速讨论了一种基于 apriori 原则来解决它的策略。这种方法是基于一遍又一遍地扫描整个交易数据库,昂贵地生成不断增长长度的模式候选项并检查它们的支持。我们指出,这种策略对于非常大的数据可能是不可行的。

所谓的FP-growth 算法,其中FP代表频繁模式,为这个数据挖掘问题提供了一个有趣的解决方案。该算法最初是在Mining Frequent Patterns without Candidate Generation中描述的,可在www.cs.sfu.ca/~jpei/publications/sigmod00.pdf找到。我们将首先解释这个算法的基础知识,然后继续讨论其分布式版本parallel FP-growth,该版本在PFP: Parallel FP-Growth for Query Recommendation中介绍,可在static.googleusercontent.com/media/research.google.com/en//pubs/archive/34668.pdf找到。虽然 Spark 的实现是基于后一篇论文,但最好先了解基线算法,然后再进行扩展。

FP-growth 的核心思想是在开始时精确地扫描感兴趣的交易数据库 D 一次,找到所有长度为 1 的频繁模式,并从这些模式构建一个称为FP-tree的特殊树结构。一旦完成了这一步,我们不再使用 D,而是仅对通常要小得多的 FP-tree 进行递归计算。这一步被称为算法的FP-growth 步骤,因为它从原始树的子树递归构造树来识别模式。我们将称这个过程为片段模式增长,它不需要我们生成候选项,而是建立在分而治之策略上,大大减少了每个递归步骤中的工作量。

更准确地说,让我们首先定义 FP 树是什么,以及在示例中它是什么样子。回想一下我们在上一节中使用的示例数据库,显示在表 1中。我们的项目集包括以下 15 个杂货项目,用它们的第一个字母表示:bcaedfpmilohjks。我们还讨论了频繁项目;也就是说,长度为 1 的模式,对于最小支持阈值t = 0.6,由{f, c, b, a, m, p}给出。在 FP-growth 中,我们首先利用了一个事实,即项目的排序对于频繁模式挖掘问题并不重要;也就是说,我们可以选择呈现频繁项目的顺序。我们通过按频率递减的顺序对它们进行排序。总结一下情况,让我们看一下下表:

交易 ID 交易 有序频繁项
1 a, c, d, f, g, i, m, p f, c, a, m, p
2 a, b, c, f, l, m, o f, c, a, b, m
3 b, f, h, j, o f, b
4 b, c, k, s, p c, b, p
5 a, c, e, f, l, m, n, p f, c, a, m, p

表 3:继续使用表 1 开始的示例,通过有序频繁项扩充表格。

正如我们所看到的,像这样有序的频繁项已经帮助我们识别一些结构。例如,我们看到项集{f, c, a, m, p}出现了两次,并且稍微改变为{f, c, a, b, m}。FP 增长的关键思想是利用这种表示来构建树,从有序频繁项中反映出项在表 3的第三列中的结构和相互依赖关系。每个 FP 树都有一个所谓的节点,用作连接构造的有序频繁项的基础。在以下图表的右侧,我们可以看到这是什么意思:

图 1:FP 树和我们频繁模式挖掘的运行示例的表头表。

图 1的左侧显示了我们将在稍后解释和正式化的表头表,右侧显示了实际的 FP 树。对于我们示例中的每个有序频繁项,都有一条从根开始的有向路径,从而表示它。树的每个节点不仅跟踪频繁项本身,还跟踪通过该节点的路径数。例如,五个有序频繁项集中有四个以字母f开头,一个以c开头。因此,在 FP 树中,我们在顶层看到f: 4c: 1。这个事实的另一个解释是,f是四个项集的前缀c是一个。对于这种推理的另一个例子,让我们将注意力转向树的左下部,即叶节点p: 2。两次p的出现告诉我们,恰好有两条相同的路径到此结束,我们已经知道:{f, c, a, m, p}出现了两次。这个观察很有趣,因为它已经暗示了 FP 增长中使用的一种技术--从树的叶节点开始,或者项集的后缀,我们可以追溯每个频繁项集,所有这些不同根节点路径的并集产生所有路径--这对于并行化是一个重要的想法。

图 1左侧的表头表是一种存储项的聪明方式。请注意,通过树的构造,一个节点不同于一个频繁项,而是,项可以并且通常会多次出现,即每个它们所属的不同路径都会出现一次。为了跟踪项及其关系,表头表本质上是项的链表,即每个项的出现都通过这个表与下一个项相连。我们在图 1中用水平虚线表示每个频繁项的链接,仅用于说明目的。

有了这个例子,现在让我们给出 FP 树的正式定义。FP 树T是一棵树,由根节点和从根节点开始的频繁项前缀子树以及频繁项表头表组成。树的每个节点由一个三元组组成,即项名称、出现次数和一个节点链接,指向相同名称的下一个节点,如果没有这样的下一个节点,则为null

为了快速回顾,构建T,我们首先计算给定最小支持阈值t的频繁项,然后,从根开始,将每个由事务的排序频繁模式列表表示的路径插入树中。现在,我们从中获得了什么?要考虑的最重要的属性是,解决频繁模式挖掘问题所需的所有信息都被编码在 FP 树T中,因为我们有效地编码了所有频繁项的重复共现。由于T的节点数最多与频繁项的出现次数一样多,T通常比我们的原始数据库 D 小得多。这意味着我们已经将挖掘问题映射到了一个较小的数据集上,这本身就降低了与之前草率方法相比的计算复杂性。

接下来,我们将讨论如何从构建的 FP 树中递归地从片段中生长模式。为此,让我们做出以下观察。对于任何给定的频繁项x,我们可以通过跟随x的节点链接,从x的头表条目开始,通过分析相应的子树来获得涉及x的所有模式。为了解释具体方法,我们进一步研究我们的例子,并从头表的底部开始,分析包含p的模式。从我们的 FP 树T来看,p出现在两条路径中:(f:4, c:3, a:3, m:3, p:2)(c:1, b:1, p:1),跟随p的节点链接。现在,在第一条路径中,p只出现了两次,也就是说,在原始数据库 D 中{f, c, a, m, p}模式的总出现次数最多为两次。因此,在p存在的条件下,涉及p的路径实际上如下:(f:2, c:2, a:2, m:2)(c:1, b:1)。事实上,由于我们知道我们想要分析模式,给定p,我们可以简化符号,简单地写成(f:2, c:2, a:2, m:2)(c:1, b:1)。这就是我们所说的p 的条件模式基。再进一步,我们可以从这个条件数据库构建一个新的 FP 树。在p出现三次的条件下,这棵新树只包含一个节点,即(c:3)。这意味着我们最终得到了{c, p}作为涉及p的单一模式,除了p本身。为了更好地讨论这种情况,我们引入以下符号:p的条件 FP 树用{(c:3)}|p表示。

为了更直观,让我们考虑另一个频繁项并讨论它的条件模式基。继续从底部到顶部并分析m,我们再次看到两条相关的路径:(f:4, c:3, a:3, m:2)(f:4, c:3, a:3, b:1, m:1)。请注意,在第一条路径中,我们舍弃了末尾的p:2,因为我们已经涵盖了p的情况。按照相同的逻辑,将所有其他计数减少到所讨论项的计数,并在m的条件下,我们得到了条件模式基{(f:2, c:2, a:2), (f:1, c:1, a:1, b:1)}。因此,在这种情况下,条件 FP 树由{f:3, c:3, a:3}|m给出。现在很容易看出,实际上每个mfca的每种可能组合都形成了一个频繁模式。给定m,完整的模式集合是{m}{am}{cm}{fm}{cam}{fam}{fcm}{fcam}。到目前为止,应该清楚如何继续了,我们不会完全进行这个练习,而是总结其结果如下表所示:

频繁模式 条件模式基 条件 FP 树
p {(f:2, c:2, a:2, m:2), (c:1, b:1)} {(c:3)}|p
m {(f :2, c:2, a:2), (f :1, c:1, a:1, b:1)} {f:3, c:3, a:3}|m
b {(f :1, c:1, a:1), (f :1), (c:1)} null
a {(f:3, c:3)} {(f:3, c:3)}|a
c {(f:3)} {(f:3)}|c
f null null

表 4:我们运行示例的条件 FP 树和条件模式基的完整列表。

由于这种推导需要非常仔细的注意,让我们退一步总结一下到目前为止的情况:

  1. 从原始 FP 树T开始,我们使用节点链接迭代所有项目。

  2. 对于每个项目x,我们构建了它的条件模式基和条件 FP 树。这样做,我们使用了以下两个属性:

  • 在每个潜在模式中,我们丢弃了跟随x之后的所有项目,即我们只保留了x前缀

  • 我们修改了条件模式基中的项目计数,以匹配x的计数。

  1. 使用后两个属性修改路径,我们称x的转换前缀路径。

最后,要说明算法的 FP 增长步骤,我们需要两个在示例中已经隐含使用的基本观察结果。首先,在条件模式基中项目的支持与其在原始数据库中的表示相同。其次,从原始数据库中的频繁模式x和任意一组项目y开始,我们知道如果且仅当y是频繁模式时xy也是频繁模式。这两个事实可以很容易地一般推导出来,但在前面的示例中应该清楚地证明。

这意味着我们可以完全专注于在条件模式基中查找模式,因为将它们与频繁模式连接又是一种模式,这样,我们可以找到所有模式。因此,通过计算条件模式基递归地增长模式的机制被称为模式增长,这就是为什么 FP 增长以此命名。考虑到所有这些,我们现在可以用伪代码总结 FP 增长过程,如下所示:

def fpGrowth(tree: FPTree, i: Item):
    if (tree consists of a single path P){
        compute transformed prefix path P' of P
        return all combinations p in P' joined with i
    }
    else{
        for each item in tree {
            newI = i joined with item
            construct conditional pattern base and conditional FP-tree newTree
            call fpGrowth(newTree, newI)
        }
    }

通过这个过程,我们可以总结完整的 FP 增长算法的描述如下:

  1. 从 D 计算频繁项,并从中计算原始 FP 树TFP 树计算)。

  2. 运行fpGrowth(T, null)FP 增长计算)。

在理解了基本构造之后,我们现在可以继续讨论基于 Spark 实现的 FP 增长的并行扩展,即 Spark 实现的基础。并行 FP 增长,或简称PFP,是 FP 增长在诸如 Spark 之类的并行计算引擎中的自然演变。它解决了基线算法的以下问题:

  • 分布式存储:对于频繁模式挖掘,我们的数据库 D 可能无法适应内存,这已经使得原始形式的 FP 增长不适用。出于明显的原因,Spark 在这方面确实有所帮助。

  • 分布式计算:有了分布式存储,我们将不得不适当地并行化算法的所有步骤,并且 PFP 正是这样做的。

  • 适当的支持值:在处理查找频繁模式时,我们通常不希望将最小支持阈值t设置得太高,以便在长尾中找到有趣的模式。然而,一个小的t可能会导致 FP 树无法适应足够大的 D 而强制我们增加t。PFP 也成功地解决了这个问题,我们将看到。

考虑到 Spark 的实现,PFP 的基本概述如下:

  • 分片:我们将数据库 D 分布到多个分区,而不是将其存储在单个机器上。无论特定的存储层如何,使用 Spark,我们可以创建一个 RDD 来加载 D。

  • 并行频繁项计数:计算 D 的频繁项的第一步可以自然地作为 RDD 上的映射-归约操作执行。

  • 构建频繁项组:频繁项集被划分为多个组,每个组都有唯一的组 ID。

  • 并行 FP 增长:FP 增长步骤分为两步,以利用并行性:

  • 映射阶段:映射器的输出是一对,包括组 ID 和相应的交易。

  • 减少阶段:Reducer 根据组 ID 收集数据,并对这些组相关的交易进行 FP 增长。

  • 聚合:算法的最后一步是对组 ID 的结果进行聚合。

鉴于我们已经花了很多时间研究 FP-growth 本身,而不是深入了解 Spark 中 PFP 的太多实现细节,让我们看看如何在我们一直在使用的玩具示例上使用实际算法:

import org.apache.spark.mllib.fpm.FPGrowth
import org.apache.spark.rdd.RDD

val transactions: RDD[Array[String]] = sc.parallelize(Array(
  Array("a", "c", "d", "f", "g", "i", "m", "p"),
  Array("a", "b", "c", "f", "l", "m", "o"),
  Array("b", "f", "h", "j", "o"),
  Array("b", "c", "k", "s", "p"),
  Array("a", "c", "e", "f", "l", "m", "n", "p")
))

val fpGrowth = new FPGrowth()
  .setMinSupport(0.6)
  .setNumPartitions(5)
val model = fpGrowth.run(transactions)

model.freqItemsets.collect().foreach { itemset =>
  println(itemset.items.mkString("[", ",", "]") + ", " + itemset.freq)
}

代码很简单。我们将数据加载到transactions中,并使用最小支持值为0.65个分区初始化 Spark 的FPGrowth实现。这将返回一个模型,我们可以在之前构建的交易上运行。这样做可以让我们访问指定最小支持的模式或频繁项集,通过调用freqItemsets,以格式化的方式打印出来,总共有 18 个模式的输出如下:

请记住,我们已经将交易定义为“集合”,我们通常称它们为项目集。这意味着在这样的项目集中,特定项目只能出现一次,FPGrowth依赖于此。例如,如果我们将前面示例中的第三个交易替换为Array("b", "b", "h", "j", "o"),在这些交易上调用run将会抛出错误消息。我们将在后面看到如何处理这种情况。

在类似于我们刚刚在 FP-growth 中所做的方式中已经解释了关联规则和前缀跨度之后,我们将转向在真实数据集上应用这些算法。

关联规则挖掘

回想一下关联规则介绍中,在计算关联规则时,一旦我们有了频繁项集,也就是指定最小阈值的模式,我们就已经完成了大约一半。事实上,Spark 的关联规则实现假设我们提供了一个FreqItemsets[Item]的 RDD,我们已经在之前调用model.freqItemsets中看到了一个例子。除此之外,计算关联规则不仅作为一个独立的算法可用,而且还可以通过FPGrowth使用。

在展示如何在我们的运行示例上运行相应算法之前,让我们快速解释一下 Spark 中如何实现关联规则:

  1. 该算法已经提供了频繁项集,因此我们不需要再计算它们了。

  2. 对于每一对模式 X 和Y,计算同时出现的 X 和 Y 的频率,并存储(X,(Y,supp(XY))。我们称这样的模式对为“候选对”,其中X充当潜在的前提,Y充当结论。

  3. 将所有模式与候选对连接起来,以获得形式为(X,((Y,supp(XY)),supp(X)))的语句。

  4. 然后,我们可以通过所需的最小置信度值过滤形式为(X,((Y,supp(XY)),supp(X)))的表达式,以返回所有具有该置信度水平的规则X ⇒ Y

假设我们在上一节中没有通过 FP-growth 计算模式,而是只给出了这些项目集的完整列表,我们可以从头开始创建一个 RDD,然后在其上运行AssociationRules的新实例:

import org.apache.spark.mllib.fpm.AssociationRules
import org.apache.spark.mllib.fpm.FPGrowth.FreqItemset

val patterns: RDD[FreqItemset[String]] = sc.parallelize(Seq(
  new FreqItemset(Array("m"), 3L),
  new FreqItemset(Array("m", "c"), 3L),
  new FreqItemset(Array("m", "c", "f"), 3L), 
  new FreqItemset(Array("m", "a"), 3L), 
  new FreqItemset(Array("m", "a", "c"), 3L),
  new FreqItemset(Array("m", "a", "c", "f"), 3L),  
  new FreqItemset(Array("m", "a", "f"), 3L), 
  new FreqItemset(Array("m", "f"), 3L), 
  new FreqItemset(Array("f"), 4L), 
  new FreqItemset(Array("c"), 4L), 
  new FreqItemset(Array("c", "f"), 3L), 
  new FreqItemset(Array("p"), 3L), 
  new FreqItemset(Array("p", "c"), 3L), 
  new FreqItemset(Array("a"), 3L), 
  new FreqItemset(Array("a", "c"), 3L), 
  new FreqItemset(Array("a", "c", "f"), 3L), 
  new FreqItemset(Array("a", "f"), 3L), 
  new FreqItemset(Array("b"), 3L)
))

val associationRules = new AssociationRules().setMinConfidence(0.7)
val rules = associationRules.run(patterns)

rules.collect().foreach { rule =>
  println("[" + rule.antecedent.mkString(",") + "=>"
    + rule.consequent.mkString(",") + "]," + rule.confidence)
}

请注意,在初始化算法后,我们将最小置信度设置为0.7,然后收集结果。此外,运行AssociationRules将返回一个Rule类型的规则 RDD。这些规则对象具有antecedentconsequentconfidence的访问器,我们使用这些访问器来收集结果,结果如下:

我们从头开始展示这个例子的原因是为了传达关联规则在 Spark 中确实是一个独立的算法。由于目前在 Spark 中计算模式的唯一内置方式是通过 FP-growth,而且关联规则无论如何都依赖于FreqItemset的概念(从FPGrowth子模块导入),这似乎有点不切实际。使用我们从之前的 FP-growth 示例中得到的结果,我们完全可以编写以下内容来实现相同的效果:

val patterns = model.freqItemsets

有趣的是,关联规则也可以直接通过FPGrowth的接口进行计算。继续使用之前示例中的符号,我们可以简单地写出以下内容,以得到与之前相同的一组规则:

val rules = model.generateAssociationRules(confidence = 0.7)

在实际情况下,虽然这两种表述都有用,但后一种肯定会更简洁。

使用前缀跨度进行顺序模式挖掘

转向顺序模式匹配,前缀跨度算法比关联规则稍微复杂一些,因此我们需要退一步,首先解释基础知识。前缀跨度首次在hanj.cs.illinois.edu/pdf/tkde04_spgjn.pdf中被描述为所谓的 FreeSpan 算法的自然扩展。该算法本身相对于其他方法(如广义顺序模式(GSP))来说是一个显著的改进。后者基于先验原则,我们之前讨论的关于许多基于它的算法的缺点也适用于顺序挖掘,即昂贵的候选生成,多次数据库扫描等。

前缀跨度,在其基本形式中,使用与 FP-growth 相同的基本思想,即将原始数据库投影到通常较小的结构中进行分析。而在 FP-growth 中,我们递归地为原始 FP 树中的每个分支的后缀构建新的 FP 树,前缀跨度通过考虑前缀来增长或跨越新的结构,正如其名称所示。

让我们首先在序列的上下文中正确定义前缀和后缀的直观概念。在接下来的内容中,我们将始终假设序列项内的项目按字母顺序排列,也就是说,如果 s = <s[1,] s[2],..., s[l]>是 S 中的一个序列,每个 s[i]都是项目的连接,也就是 s[i] = (a[i1] ... a[im]),其中 a[ij]是 I 中的项目,我们假设 s[i]中的所有 a[ij]都按字母顺序排列。在这种情况下,如果 s' = <s'[1,] s'[2],..., s'm>是 s 的前缀,当且仅当满足以下三个属性时,s'被称为 s 的前缀:

  • 对于所有 i < m,我们有序列项的相等,也就是 s'[i] = s[i]

  • s'[m] < s[m],也就是说,s'的最后一项是 s[m]的子模式

  • 如果我们从 s[m]中减去 s'[m],也就是从 s[m]中删除子模式 s'[m],那么 s[m] - s'[m]中剩下的所有频繁项都必须在 s'[m]中的所有元素之后按字母顺序排列

前两点都相当自然,最后一点可能看起来有点奇怪,所以让我们通过一个例子来解释。给定一个序列< a(abc)>,来自数据库 D,其中 a,b 和 c 确实频繁,那么< aa>和< a(ab)>是< a(abc)>的前缀,但< ab>不是,因为在最后序列项的差异中,<(abc)> - = <(ac)>,字母 a 并不按字母表顺序在后面。基本上,第三个属性告诉我们,前缀只能在它影响的最后序列项的开头切除部分。

有了前缀的概念,现在很容易说出后缀是什么。使用与之前相同的符号,如果 s'是 s 的前缀,那么 s'' = <(s[m] - s'[m]), s[m+1], ..., s[l]>就是这个前缀的后缀,我们将其表示为 s'' = s / s'。此外,我们将 s = s's''写成乘积符号。例如,假设< a(abc)>是原始序列,< aa>是前缀,我们将此前缀的后缀表示如下:

<(_bc)> = <a(abc)> /

请注意,我们使用下划线符号来表示前缀对序列的剩余部分。

前缀和后缀的概念都有助于将原始的顺序模式挖掘问题分割成更小的部分,如下所示。让{<p[1]>, ...,<p[n]>}成为长度为 1 的完整顺序模式集。然后,我们可以得出以下观察结果:

  • 所有的顺序模式都以p[i]中的一个开头。这意味着我们可以将所有的顺序模式分成n个不相交的集合,即以p[i]开头的那些,其中i1n之间。

  • 应用这种推理递归地,我们得到以下的陈述:如果s是一个给定的长度为 1 的顺序模式,{s¹, ..., sm}*是长度为*l+1*的*s*的完整顺序超模式列表,那么所有具有前缀*s*的顺序模式可以被分成*m*个由*si为前缀的集合。

这两个陈述都很容易得出,但提供了一个强大的工具,将原始问题集合划分为不相交的较小问题。这种策略被称为“分而治之”。有了这个想法,我们现在可以非常类似于 FP-growth 中对条件数据库所做的事情,即根据给定的前缀对数据库进行投影。给定一个顺序模式数据库 S 和一个前缀ss-投影数据库S|[s],是 S 中所有s的后缀的集合。

我们需要最后一个定义来陈述和分析前缀跨度算法。如果s是 S 中的一个顺序模式,x是一个具有前缀s的模式,那么在S|[s]x支持计数,用suppS|s表示,是S|[s]中序列y的数量,使得x < sy;也就是说,我们简单地将支持的概念延续到了 s-投影数据库。我们可以从这个定义中得出一些有趣的性质,使得我们的情况变得更容易。例如,根据定义,我们看到对于任何具有前缀s的序列x,我们有以下关系:

suppS = suppS|s

也就是说,在这种情况下,无论我们在原始数据库中还是在投影数据库中计算支持度都没有关系。此外,如果s's的前缀,很明显S|[s] = (S|[s'])|[s],这意味着我们可以连续地添加前缀而不会丢失信息。从计算复杂性的角度来看,最后一个最重要的陈述是,投影数据库的大小不会超过其原始大小。这个性质应该再次从定义中清楚地看出来,但它对于证明前缀跨度的递归性质是极其有帮助的。

有了所有这些信息,我们现在可以用伪代码勾勒出前缀跨度算法,如下所示。请注意,我们区分一个项目s'被附加到顺序模式s的末尾和从s'生成的序列<s'>被添加到s的末尾。举个例子,我们可以将字母e添加到<a(abc)>形成<a(abce)>,或者在末尾添加形成<a(abc)e>

def prefixSpan(s: Prefix, l: Length, S: ProjectedDatabase):
  S' = set of all s' in S|s if {
    (s' appended to s is a sequential pattern) or
    (<s'> appended to s is a sequential pattern)
  }
  for s' in S' {
    s'' = s' appended to s
    output s''
    call prefixSpan(s'', l+1, S|s'')
  }
}
call prefixSpan(<>, 0, S)

如所述,前缀跨度算法找到所有的顺序模式;也就是说,它代表了解决顺序模式挖掘问题的解决方案。我们无法在这里概述这个陈述的证明,但我们希望已经为您提供了足够的直觉来看到它是如何以及为什么它有效的。

以 Spark 为例,注意我们没有讨论如何有效地并行化基线算法。如果您对实现细节感兴趣,请参阅github.com/apache/spark/blob/v2.2.0/mllib/src/main/scala/org/apache/spark/mllib/fpm/PrefixSpan.scala,因为并行版本涉及的内容有点太多,不适合在这里介绍。我们将首先研究表 2中提供的示例,即四个序列<a(abc)(ac)d(cf)><(ad)c(bc)(ae)><(ef)(ab)(df)cb><eg(af)cbc>。为了编码序列的嵌套结构,我们使用字符串的数组数组,并将它们并行化以创建 RDD。初始化和运行PrefixSpan的实例的方式与其他两个算法基本相同。这里唯一值得注意的是,除了通过setMinSupport将最小支持阈值设置为0.7之外,我们还通过setMaxPatternLength将模式的最大长度指定为5。最后一个参数用于限制递归深度。尽管实现很巧妙,但算法(特别是计算数据库投影)可能需要很长时间:

import org.apache.spark.mllib.fpm.PrefixSpan

val sequences:RDD[Array[Array[String]]] = sc.parallelize(Seq(
  Array(Array("a"), Array("a", "b", "c"), Array("a", "c"), Array("d"), Array("c", "f")),
 Array(Array("a", "d"), Array("c"), Array("b", "c"), Array("a", "e")),
 Array(Array("e", "f"), Array("a", "b"), Array("d", "f"), Array("c"), Array("b")),
 Array(Array("e"), Array("g"), Array("a", "f"), Array("c"), Array("b"), Array("c")) ))
val prefixSpan = new PrefixSpan()
  .setMinSupport(0.7)
  .setMaxPatternLength(5)
val model = prefixSpan.run(sequences)
model.freqSequences.collect().foreach {
  freqSequence => println(freqSequence.sequence.map(_.mkString("[", ", ", "]")).mkString("[", ", ", "]") + ", " + freqSequence.freq) }

在您的 Spark shell 中运行此代码应该产生 14 个顺序模式的以下输出:

MSNBC 点击流数据的模式挖掘

在花费了相当多的时间来解释模式挖掘的基础知识之后,让我们最终转向一个更现实的应用。我们接下来要讨论的数据来自msnbc.com的服务器日志(部分来自msn.com,与新闻相关),代表了这些网站用户的页面浏览活动的整整一天。这些数据是在 1999 年 9 月收集的,并且已经可以在archive.ics.uci.edu/ml/machine-learning-databases/msnbc-mld/msnbc990928.seq.gz上下载。将此文件存储在本地并解压缩,msnbc990928.seq文件基本上由标题和长度不等的整数的空格分隔行组成。以下是文件的前几行:

% Different categories found in input file:

frontpage news tech local opinion on-air misc weather msn-news health living business msn-sports sports summary bbs travel

% Sequences:

1 1 
2 
3 2 2 4 2 2 2 3 3 
5 
1 
6 
1 1 
6 
6 7 7 7 6 6 8 8 8 8 

这个文件中的每一行都是用户当天的编码页面访问序列。页面访问并没有被收集到最精细的级别,而是被分成了 17 个与新闻相关的类别,这些类别被编码为整数。与这些类别对应的类别名称列在前面的标题中,大多数都是不言自明的(除了bbs,它代表公告板服务)。此列表中的第 n 个项目对应于第 n 个类别;例如,1代表frontpage,而travel被编码为17。例如,这个文件中的第四个用户点击了opinion一次,而第三个用户总共有九次页面浏览,从tech开始,以tech结束。

重要的是要注意,每行中的页面访问确实已经按时间顺序存储,也就是说,这确实是关于页面访问顺序的顺序数据。总共收集了 989,818 个用户的数据;也就是说,数据集确实有这么多序列。不幸的是,我们不知道有多少个 URL 已经分组成每个类别,但我们确实知道它的范围相当广,从 10 到 5,000。有关更多信息,请参阅archive.ics.uci.edu/ml/machine-learning-databases/msnbc-mld/msnbc.data.html上提供的描述。

仅从这个数据集的描述中,就应该清楚到目前为止我们讨论过的所有三种模式挖掘问题都可以应用于这些数据--我们可以在这个序列数据库中搜索顺序模式,并且忽略顺序性,分析频繁模式和关联规则。为此,让我们首先使用 Spark 加载数据。接下来,我们将假设文件的标题已被删除,并且已经从存储序列文件的文件夹创建了一个 Spark shell 会话:

val transactions: RDD[Array[Int]] = sc.textFile("./msnbc990928.seq") map { line =>
  line.split(" ").map(_.toInt)
}

首先将序列文件加载到整数值数组的 RDD 中。回想一下,频繁模式挖掘中交易的一个假设是项目集实际上是集合,因此不包含重复项。因此,为了应用 FP-growth 和关联规则挖掘,我们必须删除重复的条目,如下所示:

val uniqueTransactions: RDD[Array[Int]] = transactions.map(_.distinct).cache()

请注意,我们不仅限制了每个交易的不同项目,而且缓存了生成的 RDD,这是所有三种模式挖掘算法的推荐做法。这使我们能够在这些数据上运行 FP-growth,为此我们必须找到一个合适的最小支持阈值t。到目前为止,在玩具示例中,我们选择了t相当大(在 0.6 和 0.8 之间)。在更大的数据库中,不现实地期望任何模式具有如此大的支持值。尽管我们只需要处理 17 个类别,但用户的浏览行为可能会因人而异。因此,我们选择支持值只有 5%来获得一些见解:

val fpGrowth = new FPGrowth().setMinSupport(0.05)
val model = fpGrowth.run(uniqueTransactions)
val count = uniqueTransactions.count()

model.freqItemsets.collect().foreach { itemset =>
    println(itemset.items.mkString("[", ",", "]") + ", " + itemset.freq / count.toDouble )
}

这个计算的输出显示,对于t=0.05,我们只恢复了 14 个频繁模式,如下所示:

不仅模式可能比您预期的要少,而且在这些模式中,除了一个之外,所有模式的长度都为1。不足为奇的是,front page被最频繁地访问,占 31%,其次是on-airnews类别。front pagenews站点只有 7%的用户在当天访问过,没有其他一对站点类别被超过 5%的用户群体访问。类别 5、15、16 和 17 甚至都没有进入列表。如果我们将实验重复一次,将t值改为 1%,模式的数量将增加到总共 74 个。

让我们看看其中有多少长度为 3 的模式:

model.freqItemsets.collect().foreach { itemset =>
  if (itemset.items.length >= 3)
    println(itemset.items.mkString("[", ",", "]") + ", " + itemset.freq / count.toDouble )
}

使用最小支持值t=0.01FPGrowth实例运行这个操作将产生以下结果:

正如人们可能猜到的那样,最频繁的长度为 1 的模式也是 3 模式中占主导地位的。在这 11 个模式中,有 10 个涉及front page,而九个涉及news。有趣的是,根据先前的分析,misc类别虽然只有 7%的访问量,但在总共的四个 3 模式中出现。如果我们对潜在的用户群有更多的信息,跟进这个模式将是有趣的。可以推测,对许多杂项主题感兴趣的用户最终会进入这个混合类别,以及其他一些类别。

接下来进行关联规则的分析在技术上很容易;我们只需运行以下代码来从现有的 FP-growth model中获取所有置信度为0.4的规则:

val rules = model.generateAssociationRules(confidence = 0.4)
rules.collect().foreach { rule =>
  println("[" + rule.antecedent.mkString(",") + "=>"
    + rule.consequent.mkString(",") + "]," + (100 * rule.confidence).round / 100.0)
}

请注意,我们可以方便地访问相应规则的前提、结果和置信度。这次输出的结果如下;这次将置信度四舍五入到两位小数:

同样,自然地,最频繁的长度为 1 的模式出现在许多规则中,尤其是frontpage作为结果。在这个例子中,我们选择了支持和置信度的值,以便输出简短且计数容易手动验证,但是让我们对规则集进行一些自动计算,不受限制:

rules.count
val frontPageConseqRules = rules.filter(_.consequent.head == 1)
frontPageConseqRules.count
frontPageConseqRules.filter(_.antecedent.contains(2)).count

执行这些语句,我们看到大约三分之二的规则都有front page作为结果,即总共 22 条规则中的 14 条,其中有九条包含news在它们的前提中。

接下来是针对这个数据集的序列挖掘问题,我们需要将原始的transactions转换为Array[Array[Int]]类型的 RDD,因为嵌套数组是 Spark 中用于对前缀 span 编码序列的方式,正如我们之前所见。虽然有些显而易见,但仍然很重要指出,对于序列,我们不必丢弃重复项目的附加信息,就像我们刚刚对 FP-growth 所做的那样。

事实上,通过对单个记录施加顺序性,我们甚至可以获得更多的结构。要进行刚刚指示的转换,我们只需执行以下操作:

val sequences: RDD[Array[Array[Int]]] = transactions.map(_.map(Array(_))).cache()

再次,我们缓存结果以提高算法的性能,这次是prefixspan。运行算法本身与以前一样:

val prefixSpan = new PrefixSpan().setMinSupport(0.005).setMaxPatternLength(15)
val psModel = prefixSpan.run(sequences)

我们将最小支持值设置得非常低,为 0.5%,这样这次可以得到一个稍微更大的结果集。请注意,我们还搜索不超过 15 个序列项的模式。通过运行以下操作来分析频繁序列长度的分布:

psModel.freqSequences.map(fs => (fs.sequence.length, 1))
  .reduceByKey(_ + _)
  .sortByKey()
  .collect()
  .foreach(fs => println(s"${fs._1}: ${fs._2}"))

在这一系列操作中,我们首先将每个序列映射到一个由其长度和计数 1 组成的键值对。然后进行一个 reduce 操作,通过键对值进行求和,也就是说,我们计算这个长度出现的次数。其余的只是排序和格式化,得到以下结果:

正如我们所看到的,最长的序列长度为 14,这特别意味着我们的最大值 15 并没有限制搜索空间,我们找到了所选支持阈值t=0.005的所有顺序模式。有趣的是,大多数用户的频繁顺序访问在msnbc.com上的触点数量在两到六个之间。

为了完成这个例子,让我们看看每个长度的最频繁模式是什么,以及最长的顺序模式实际上是什么样的。回答第二个问题也会给我们第一个答案,因为只有一个长度为 14 的模式。计算这个可以这样做:

psModel.freqSequences
  .map(fs => (fs.sequence.length, fs))
  .groupByKey()
  .map(group => group._2.reduce((f1, f2) => if (f1.freq > f2.freq) f1 else f2))
  .map(_.sequence.map(_.mkString("[", ", ", "]")).mkString("[", ", ", "]"))
  .collect.foreach(println)

由于这是我们迄今为止考虑的比较复杂的 RDD 操作之一,让我们讨论一下涉及的所有步骤。我们首先将每个频繁序列映射到一个由其长度和序列本身组成的对。这一开始可能看起来有点奇怪,但它允许我们按长度对所有序列进行分组,这是我们在下一步中要做的。每个组由其键和频繁序列的迭代器组成。我们将每个组映射到其迭代器,并通过仅保留具有最大频率的序列来减少序列。然后,为了正确显示此操作的结果,我们两次使用mkString来从否则不可读的嵌套数组(在打印时)中创建字符串。前述链的结果如下:

我们之前讨论过首页是迄今为止最频繁的项目,这在直觉上是有很多意义的,因为它是网站的自然入口点。然而,令人惊讶的是,在所选阈值下,所有长度最频繁的序列都只包括首页点击。显然,许多用户在首页及其周围花费了大量时间和点击,这可能是它相对于其他类别页面的广告价值的第一个迹象。正如我们在本章的介绍中所指出的,分析这样的数据,特别是如果结合其他数据源,对于各自网站的所有者来说可能具有巨大的价值,我们希望已经展示了频繁模式挖掘技术如何在其中发挥作用。

部署模式挖掘应用

在上一节中开发的示例是一个有趣的实验场,可以应用我们在整章中精心制定的算法,但我们必须承认一个事实,那就是我们只是被交给了数据。在撰写本书时,构建数据产品的文化往往在实时数据收集和聚合之间,以及(通常是离线的)数据分析之间划清界限,然后将获得的见解反馈到生产系统中。虽然这种方法有其价值,但也有一定的缺点。不考虑整体情况,我们可能不会准确了解数据的收集细节。缺少这样的信息可能导致错误的假设,最终得出错误的结论。虽然专业化在一定程度上既有用又必要,但至少从业者应该努力获得对应用程序的基本理解。

当我们在上一节介绍 MSNBC 数据集时,我们说它是从网站的服务器日志中检索出来的。我们大大简化了这意味着什么,让我们仔细看一看:

  • 高可用性和容错性:网站上的点击事件需要在一天中的任何时间点进行跟踪,而不会出现停机。一些企业,特别是在涉及任何形式的支付交易时,例如在线商店,不能承受丢失某些事件的风险。

  • 实时数据的高吞吐量和可扩展性:我们需要一个系统,可以实时存储和处理这些事件,并且可以在不减速的情况下处理一定的负载。例如,MSNBC 数据集中大约一百万个独立用户意味着平均每秒大约有 11 个用户的活动。还有许多事件需要跟踪,特别是要记住我们只测量了页面浏览。

  • 流数据和批处理:原则上,前两点可以通过将事件写入足够复杂的日志来解决。然而,我们甚至还没有涉及聚合数据的话题,我们更需要一个在线处理系统来做到这一点。首先,每个事件都必须归因于一个用户,该用户将必须配备某种 ID。接下来,我们将不得不考虑用户会话的概念。虽然 MSNBC 数据集中的用户数据已经在日常级别上进行了聚合,但这对于许多目的来说还不够细粒度。分析用户的行为在他们实际活跃的时间段内是有意义的。因此,习惯上考虑活动窗口,并根据这些窗口聚合点击和其他事件。

  • 流数据分析:假设我们有一个像我们刚刚描述的系统,并且实时访问聚合的用户会话数据,我们可以希望实现什么?我们需要一个分析平台,允许我们应用算法并从这些数据中获得见解。

Spark 解决这些问题的提议是其 Spark Streaming 模块,我们将在下文简要介绍。使用 Spark Streaming,我们将构建一个应用程序,至少可以模拟生成和聚合事件,然后应用我们研究的模式挖掘算法到事件流中。

Spark Streaming 模块

在这里没有足够的时间对 Spark Streaming 进行深入介绍,但至少我们可以涉及一些关键概念,提供一些示例,并为更高级的主题提供一些指导。

Spark Streaming 是 Spark 的流数据处理模块,它确实具备我们在前面列表中解释的所有属性:它是一个高度容错、可扩展和高吞吐量的系统,用于处理和分析实时数据流。它的 API 是 Spark 本身的自然扩展,许多可用于 RDD 和 DataFrame 的工具也适用于 Spark Streaming。

Spark Streaming 应用程序的核心抽象是“DStream”的概念,它代表“离散流”。为了解释这个术语,我们经常将数据流想象为连续的事件流,当然,这是一个理想化的想法,因为我们所能测量的只是离散的事件。无论如何,这连续的数据流将进入我们的系统,为了进一步处理它,我们将其离散化为不相交的数据批次。这个离散数据批次流在 Spark Streaming 中被实现为 DStream,并且在内部被实现为一系列 RDD。

以下图表概述了 Spark Streaming 的数据流和转换:

图 2:输入数据被馈入 Spark Streaming,它将这个流离散化为所谓的 DStream。然后,这些 RDD 序列可以通过 Spark 和其任何模块进一步转换和处理。

正如图表所示,数据通过输入数据流进入 Spark Streaming。这些数据可以从许多不同的来源产生和摄入,我们将在后面进一步讨论。我们称生成事件的系统为 Spark Streaming 可以处理的“来源”。输入 DStreams 通过这些来源的“接收器”从来源获取数据。一旦创建了输入 DStream,它可以通过丰富的 API 进行处理,这个 API 允许进行许多有趣的转换。将 DStreams 视为 RDD 的序列或集合,并通过与 Spark 核心中 RDD 非常接近的接口对其进行操作是一个很好的思维模型。例如,map-reduce 和 filter 等操作也适用于 DStreams,并且可以将相应功能从单个 RDD 转移到 RDD 序列。我们将更详细地讨论所有这些内容,但首先让我们转向一个基本示例。

作为开始使用 Spark Streaming 的第一个示例,让我们考虑以下情景。假设我们已经从先前加载了 MSNBC 数据集,并从中计算出了前缀跨度模型(psModel)。这个模型是用来自单日用户活动的数据拟合的,比如昨天的数据。今天,新的用户活动事件进来了。我们将创建一个简单的 Spark Streaming 应用程序,其中包含一个基本的源,精确地生成用户数据,其模式与我们在 MSNBC 数据中的模式相同;也就是说,我们得到了包含 1 到 17 之间数字的空格分隔字符串。然后,我们的应用程序将接收这些事件并从中创建DStream。然后,我们可以将我们的前缀跨度模型应用于DStream的数据,以找出新输入到系统中的序列是否确实是根据psModel频繁的序列。

首先,我们需要创建一个所谓的StreamingContextAPI,按照惯例,它将被实例化为ssc。假设我们从头开始启动一个应用程序,我们创建以下上下文:

import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, SparkContext}

val conf = new SparkConf()
  .setAppName("MSNBC data first streaming example")
  .setMaster("local[2]")
val sc = new SparkContext(conf)
val ssc = new StreamingContext(sc, batchDuration = Seconds(10))

如果您使用 Spark shell,除了第一行和最后一行之外,其他行都是不必要的,因为在这种情况下,您将已经提供了一个 Spark 上下文(sc)。我们包括后者的创建,因为我们的目标是一个独立的应用程序。创建一个新的StreamingContextAPI 需要两个参数,即SparkContext和一个名为batchDuration的参数,我们将其设置为 10 秒。批处理持续时间是告诉我们如何离散化DStream数据的值,通过指定流数据应该收集多长时间来形成DStream中的批处理,即序列中的一个 RDD。我们还想要吸引您的注意的另一个细节是,通过设置local[2],Spark 主节点设置为两个核心。由于我们假设您是在本地工作,将至少分配两个核心给应用程序是很重要的。原因是一个线程将用于接收输入数据,而另一个线程将空闲以处理数据。在更高级的应用程序中,如果有更多的接收器,您需要为每个接收器保留一个核心。

接下来,我们基本上重复了前缀跨度模型的部分,以完善这个应用程序。与之前一样,序列是从本地文本文件加载的。请注意,这次我们假设文件在项目的资源文件夹中,但您可以选择将其存储在任何位置:

val transactions: RDD[Array[Int]] = sc.textFile("src/main/resources/msnbc990928.seq") map { line =>
  line.split(" ").map(_.toInt)
}
val trainSequences = transactions.map(_.map(Array(_))).cache()
val prefixSpan = new PrefixSpan().setMinSupport(0.005).setMaxPatternLength(15)
val psModel = prefixSpan.run(trainSequences)
val freqSequences = psModel.freqSequences.map(_.sequence).collect()

在前面计算的最后一步中,我们在主节点上收集所有频繁序列,并将它们存储为freqSequences。我们这样做的原因是要将这些数据与传入的数据进行比较,以查看新数据的序列是否与当前模型(psModel)相对频繁。不幸的是,与 MLlib 中的许多算法不同,Spark 中的三个可用的模式挖掘模型都不是在训练后接受新数据的,因此我们必须自己使用freqSequences进行比较。

接下来,我们最终可以创建一个String类型的DStream对象。为此,我们在流处理上下文中调用socketTextStream,这将允许我们从运行在localhost端口8000上的服务器上接收数据,监听 TCP 套接字:

val rawSequences: DStream[String] = ssc.socketTextStream("localhost", 8000)

我们称之为rawSequences的数据是通过该连接接收的,离散为 10 秒的间隔。在讨论如何实际发送数据之前,让我们先继续处理一旦接收到数据的示例。请记住,输入数据的格式与之前相同,因此我们需要以完全相同的方式对其进行预处理,如下所示:

val sequences: DStream[Array[Array[Int]]] = rawSequences
 .map(line => line.split(" ").map(_.toInt))
 .map(_.map(Array(_)))

我们在这里使用的两个map操作在原始 MSNBC 数据上在结构上与之前相同,但请记住,这次map具有不同的上下文,因为我们使用的是 DStreams 而不是 RDDs。定义了sequences,一个Array[Array[Int]]类型的 RDD 序列,我们可以使用它与freqSequences进行匹配。我们通过迭代 sequences 中的每个 RDD,然后再次迭代这些 RDD 中包含的每个数组来做到这一点。接下来,我们计算freqSequences中相应数组的出现频率,如果找到了,我们打印出与array对应的序列确实是频繁的:

print(">>> Analyzing new batch of data")
sequences.foreachRDD(
 rdd => rdd.foreach(
   array => {
     println(">>> Sequence: ")
     println(array.map(_.mkString("[", ", ", "]")).mkString("[", ", ", "]"))
     freqSequences.count(_.deep == array.deep) match {
       case count if count > 0 => println("is frequent!")
       case _ => println("is not frequent.")
     }
   }
 )
)
print(">>> done")

请注意,在前面的代码中,我们需要比较数组的深层副本,因为嵌套数组不能直接比较。更准确地说,可以检查它们是否相等,但结果将始终为 false。

完成转换后,应用程序接收端唯一剩下的事情就是实际告诉它开始监听传入的数据:

ssc.start()
ssc.awaitTermination()

通过流上下文ssc,我们告诉应用程序启动并等待其终止。请注意,在我们特定的上下文中,以及对于这种类型的大多数其他应用程序,我们很少想要终止程序。按设计,该应用程序旨在作为长时间运行的作业,因为原则上,我们希望它无限期地监听和分析新数据。当然,会有维护的情况,但我们也可能希望定期使用新获取的数据更新(重新训练)psModel

我们已经看到了一些关于 DStreams 的操作,并建议您参考最新的 Spark Streaming 文档(spark.apache.org/docs/latest/streaming-programming-guide.html)以获取更多详细信息。基本上,许多(功能性)编程功能在基本的 Scala 集合上都是可用的,我们也从 RDD 中知道,它们也可以无缝地转移到 DStreams。举几个例子,这些是filterflatMapmapreducereduceByKey。其他类似 SQL 的功能,如 cogroup、countcountByValuejoinunion,也都可以使用。我们将在第二个例子中看到一些更高级的功能。

现在我们已经涵盖了接收端,让我们简要讨论一下如何为我们的应用程序创建数据源。从命令行通过 TCP 套接字发送输入数据的最简单方法之一是使用Netcat,它适用于大多数操作系统,通常是预安装的。要在本地端口8000上启动 Netcat,在与您的 Spark 应用程序或 shell 分开的终端中运行以下命令:

nc -lk 8000

假设您已经启动了用于接收数据的 Spark Streaming 应用程序,现在我们可以在 Netcat 终端窗口中输入新的序列,并通过按Enter键确认每个序列。例如,在10 秒内输入以下四个序列:

你会看到以下输出:

如果你打字速度很慢,或者在 10 秒窗口快要结束时开始打字,输出可能会分成更多部分。看看实际的输出,你会发现经常讨论的首页新闻,由类别 1 和 2 表示,是频繁的。此外,由于 23 不是原始数据集中包含的序列项,它不能是频繁的。最后,序列<4, 5>显然也不频繁,这是我们以前不知道的。

选择 Netcat 作为本例的示例是一个自然的选择,但在严肃的生产环境中,你永远不会看到它用于这个目的。一般来说,Spark Streaming 有两种类型的可用源:基本和高级。基本源还可以是 RDD 队列和其他自定义源,除了文件流,前面的例子就是代表。在高级源方面,Spark Streaming 有许多有趣的连接器可供选择:Kafka、Kinesis、Flume 和高级自定义源。这种广泛的高级源的多样性使其成为将 Spark Streaming 作为生产组件并入其他基础架构组件的吸引力所在。

退后几步,考虑一下我们通过讨论这个例子所取得的成就,你可能会倾向于说,除了介绍 Spark Streaming 本身并与数据生产者和接收者一起工作之外,应用程序本身并没有解决我们之前提到的许多问题。这种批评是有效的,在第二个例子中,我们希望解决我们方法中的以下剩余问题:

  • 我们的 DStreams 的输入数据与我们的离线数据具有相同的结构,也就是说,它已经针对用户进行了预聚合,这并不是非常现实的。

  • 除了两次对map的调用和一次对foreachRDD的调用之外,我们在操作 DStreams 方面并没有看到太多功能和附加值

  • 我们没有对数据流进行任何分析,只是将它们与预先计算的模式列表进行了检查

为了解决这些问题,让我们稍微重新定义我们的示例设置。这一次,让我们假设一个事件由一个用户点击一个站点来表示,其中每个站点都属于 1-17 中的一个类别,就像以前一样。现在,我们不可能模拟一个完整的生产环境,所以我们做出了简化的假设,即每个唯一的用户已经被分配了一个 ID。有了这些信息,让我们假设事件以用户 ID 和此点击事件的类别组成的键值对的形式出现。

有了这个设置,我们必须考虑如何对这些事件进行聚合,以生成序列。为此,我们需要在给定的窗口中为每个用户 ID 收集数据点。在原始数据集中,这个窗口显然是一整天,但根据应用程序的不同,选择一个更小的窗口可能是有意义的。如果我们考虑用户浏览他最喜欢的在线商店的情景,点击和其他事件可能会影响他或她当前的购买欲望。因此,在在线营销和相关领域做出的一个合理假设是将感兴趣的窗口限制在大约 20-30 分钟,即所谓的用户会话。为了让我们更快地看到结果,我们将在我们的应用程序中使用一个更小的 20 秒窗口。我们称之为窗口长度

现在我们知道了我们想要从给定时间点分析数据的时间跨度,我们还必须定义多久我们想要进行聚合步骤,我们将其称为滑动间隔。一个自然的选择是将两者都设置为相同的时间,导致不相交的聚合窗口,即每 20 秒。然而,选择一个更短的 10 秒滑动窗口也可能很有趣,这将导致每 10 秒重叠的聚合数据。以下图表说明了我们刚刚讨论的概念:

图 3:将 DStream 转换为另一个的窗口操作的可视化。在这个例子中,Spark Streaming 应用程序的批处理持续时间设置为 10 秒。用于对数据批次进行转换的窗口长度为 40 秒,我们每 20 秒进行一次窗口操作,导致每次重叠 20 秒,并得到一个以 20 秒为一块的 DStream。

要将这些知识转化为具体示例,我们假设事件数据的形式为键:值,也就是说,这样的一个事件可能是137: 2,意味着 ID 为137的用户点击了一个类别为新闻的页面。为了处理这些事件,我们必须修改我们的预处理如下:

val rawEvents: DStream[String] = ssc.socketTextStream("localhost", 9999)
val events: DStream[(Int, String)] = rawEvents.map(line => line.split(": "))
 .map(kv => (kv(0).toInt, kv(1)))

有了这些键值对,我们现在可以着手进行必要的聚合,以便按用户 ID 对事件进行分组。如前所述,我们通过在给定的 20 秒窗口上进行聚合,并设置 10 秒的滑动间隔来实现这一点:

val duration = Seconds(20)
val slide = Seconds(10)

val rawSequencesWithIds: DStream[(Int, String)] = events
  .reduceByKeyAndWindow((v1: String, v2: String) => v1 + " " + v2, duration, slide)
val rawSequences = rawSequencesWithIds.map(_.2)
// remainder as in previous example

在前面的代码中,我们使用了更高级的 DStreams 操作,即reduceByKeyAndWindow,其中我们指定了键值对的值的聚合函数,以及窗口持续时间和滑动间隔。在计算的最后一步中,我们剥离了用户 ID,使rawSequences的结构与之前的示例相同。这意味着我们已成功将我们的示例转换为在未处理的事件上运行,并且它仍将检查我们基线模型的频繁序列。我们不会展示此应用程序输出的更多示例,但我们鼓励您尝试一下这个应用程序,并看看如何对键值对进行聚合。

为了结束这个示例和本章,让我们再看一种有趣的聚合事件数据的方法。假设我们想要动态计算某个 ID 在事件流中出现的频率,也就是说,用户生成了多少次页面点击。我们已经定义了我们之前的events DStream,所以我们可以按照以下方式处理计数:

val countIds = events.map(e => (e._1, 1))
val counts: DStream[(Int, Int)] = countIds.reduceByKey(_ + _)

在某种程度上,这符合我们的意图;它计算了 ID 的事件数量。但是,请注意,返回的是一个 DStream,也就是说,我们实际上没有在流式窗口之间进行聚合,而只是在 RDD 序列内进行聚合。为了在整个事件流中进行聚合,我们需要从一开始就跟踪计数状态。Spark Streaming 提供了一个用于此目的的 DStreams 方法,即updateStateByKey。通过提供updateFunction,它可以使用当前状态和新值作为输入,并返回更新后的状态。让我们看看它在实践中如何为我们的事件计数工作:

def updateFunction(newValues: Seq[Int], runningCount: Option[Int]): Option[Int] = {
  Some(runningCount.getOrElse(0) + newValues.sum)
}
val runningCounts = countIds.updateStateByKeyInt

我们首先定义了我们的更新函数本身。请注意,updateStateByKey的签名要求我们返回一个Option,但实质上,我们只是计算状态和传入值的运行总和。接下来,我们为updateStateByKey提供了一个Int类型的签名和先前创建的updateFunction方法。这样做,我们就得到了我们最初想要的聚合。

总结一下,我们介绍了事件聚合、DStreams 上的两个更复杂的操作(reduceByKeyAndWindowupdateStateByKey),并使用这个示例在流中计算了事件的数量。虽然这个示例在所做的事情上仍然很简单,但我们希望为读者提供了更高级应用的良好入口点。例如,可以扩展这个示例以计算事件流上的移动平均值,或者改变它以在每个窗口基础上计算频繁模式。

总结

在本章中,我们介绍了一类新的算法,即频繁模式挖掘应用,并向您展示了如何在实际场景中部署它们。我们首先讨论了模式挖掘的基础知识以及可以使用这些技术解决的问题。特别是,我们看到了如何在 Spark 中实现三种可用的算法,即 FP-growth、关联规则和前缀跨度。作为我们应用的运行示例,我们使用了 MSNBC 提供的点击流数据,这也帮助我们在质量上比较了这些算法。

接下来,我们介绍了 Spark Streaming 的基本术语和入口点,并考虑了一些实际场景。我们首先讨论了如何首先部署和评估频繁模式挖掘算法与流上下文。之后,我们解决了从原始流数据中聚合用户会话数据的问题。为此,我们必须找到一种解决方案来模拟提供点击数据作为流事件。

第七章:GraphX 的图形分析

在我们相互连接的世界中,图形是无处不在的。万维网WWW)只是一个我们可以考虑为图形的复杂结构的例子,其中网页代表着通过它们之间的传入和传出链接连接的实体。在 Facebook 的社交图中,成千上万的用户形成一个网络,连接着全球的朋友。我们今天看到并且可以收集数据的许多其他重要结构都具有自然的图形结构;也就是说,它们可以在非常基本的层面上被理解为一组通过我们称之为的方式相互连接的顶点的集合。以这种一般性的方式陈述,这一观察反映了图形是多么普遍。它的价值在于图形是经过深入研究的结构,并且有许多可用的算法可以让我们获得关于这些图形代表的重要见解。

Spark 的 GraphX 库是研究大规模图形的自然入口点。利用 Spark 核心中的 RDD 来编码顶点和边,我们可以使用 GraphX 对大量数据进行图形分析。在本章中,您将学习以下主题:

  • 基本图形属性和重要的图形操作

  • GraphX 如何表示属性图形以及如何处理它们

  • 以各种方式加载图形数据并生成合成图形数据以进行实验

  • 使用 GraphX 的核心引擎来实现基本图形属性

  • 使用名为 Gephi 的开源工具可视化图形

  • 使用 GraphX 的两个关键 API 实现高效的图形并行算法。

  • 使用 GraphFrames,这是 DataFrame 到图形的扩展,并使用优雅的查询语言研究图形

  • 在社交图上运行 GraphX 中可用的重要图形算法,包括转发和一起出现在电影中的演员的图形

基本图形理论

在深入研究 Spark GraphX 及其应用之前,我们将首先在基本层面上定义图形,并解释它们可能具有的属性以及在我们的上下文中值得研究的结构。在介绍这些属性的过程中,我们将给出更多我们在日常生活中考虑的图形的具体例子。

图形

为了简要地形式化引言中简要概述的图形概念,在纯数学层面上,图形G = (V, E)可以描述为一对顶点V 和E,如下所示:

V = {v[1], ..., v[n]}

E = {e[1], ..., e[m]}

我们称 V 中的元素v[i]为一个顶点,称 E 中的e[i]为一条边,其中连接两个顶点v[1]v[2]的每条边实际上只是一对顶点,即e[i] = (v[1], v[2])。让我们构建一个由五个顶点和六条边组成的简单图形,如下图所示:

V ={v[1], v[2], v[3], v[4], v[5]}

E = {e[1] = (v[1], v[2]), e[2] = (v[1], v[3]), e[3] = (v[2], v[3]),

*       e[4] = (v[3], v[4]), e[5] = (v[4], v[1]), e[6] = (v[4], v[5])}*

这就是图形的样子:

图 1:一个由五个顶点和六条边组成的简单无向图

请注意,在图 1中实现的图形的实现中,节点相对位置、边的长度和其他视觉属性对于图形是不重要的。实际上,我们可以通过变形以任何其他方式显示图形。图形的定义完全决定了它的拓扑

有向图和无向图

在构成边e的一对顶点中,按照惯例,我们称第一个顶点为,第二个顶点为目标。这里的自然解释是,边e所代表的连接具有方向;它从源流向目标。请注意,在图 1中,显示的图形是无向的;也就是说,我们没有区分源和目标。

使用完全相同的定义,我们可以创建我们图的有向版本,如下图所示。请注意,图在呈现方式上略有不同,但顶点和边的连接保持不变:

图 2:具有与前一个相同拓扑结构的有向图。事实上,忘记边的方向将产生与图 1 中相同的图形

每个有向图自然地有一个相关的无向图,通过简单地忘记所有边的方向来实现。从实际角度来看,大多数图的实现本质上都建立在有向边上,并在需要时抑制方向的附加信息。举个例子,将前面的图看作是由关系“友谊”连接的五个人组成的。我们可以认为友谊是一种对称属性,如果你是我的朋友,我也是你的朋友。根据这种解释,方向性在这个例子中并不是一个非常有用的概念,因此我们实际上最好将其视为一个无向图的例子。相比之下,如果我们要运行一个允许用户主动向其他用户发送好友请求的社交网络,有向图可能更适合编码这些信息。

顺序和度

对于任何图,无论是有向的还是不是,我们都可以得出一些基本的性质,这些性质在本章后面会讨论。我们称顶点的数量|V|为图的顺序,边的数量|E|为它的,有时也称为价度。顶点的度是具有该顶点作为源或目标的边的数量。对于有向图和给定的顶点v,我们还可以区分入度,即指向v的所有边的总和,和出度,即从v开始的所有边的总和。举个例子,图 1 中的无向图的顺序为 5,度为 6,与图 2 中显示的有向图相同。在后者中,顶点 v1 的出度为 2,入度为 1,而 v5 的出度为 0,入度为 1。

在最后两个例子中,我们用它们各自的标识符注释了顶点和边,如定义G = (V, E)所指定的那样。对于接下来的大多数图形可视化,我们将假设顶点和边的标识是隐含已知的,并将通过为我们的图形加上额外信息来代替它们。我们明确区分标识符和标签的原因是 GraphX 标识符不能是字符串,我们将在下一节中看到。下图显示了一个带有一组人的关系的标记图的示例:

图 3:显示了一组人及其关系的有向标记图

有向无环图

我们接下来要讨论的概念是无环性。循环图是指至少有一个顶点,通过图中的路径连接到自身。我们称这样的路径为循环。在无向图中,任何形成循环的链都可以,而在有向图中,只有当我们可以通过遵循有向边到达起始顶点时,我们才谈论循环。例如,考虑我们之前看到的一些图。在图 2 中,由{e2, e4, e5}形成了一个循环,而在其无向版本中,即图 1 中,有两个循环,分别是{e2, e4, e5}和{e1, e2, e3}。

有几种值得在这里提到的循环图的特殊情况。首先,如果一个顶点通过一条边与自身相连,我们将说图中有一个循环。其次,一个不包含任何两个顶点之间双向边的有向图被称为定向图。第三,包含三角形的图被认为包含三角形。三角形的概念是重要的,因为它经常用于评估图的连通性,我们将在后面讨论。以下图显示了一个具有不同类型循环的人工示例:

图 4:一个玩具图,说明了循环或自环、双向边和三角形。

一般来说,研究图中任意自然数n的循环可以告诉你很多关于图的信息,但三角形是最常见的。由于有向循环不仅计算成本更高,而且比它们的无向版本更少见,我们通常只会在图中寻找无向三角形;也就是说,我们会忽略它的有向结构。

在许多应用程序中反复出现的一类重要图是有向无环图(DAGs)。我们已经从上一段知道了 DAG 是什么,即一个没有循环的有向图,但由于 DAG 是如此普遍,我们应该花更多的时间来了解它们。

我们在前面的所有章节中隐式使用的一个 DAG 实例是 Spark 的作业执行图。请记住,任何 Spark 作业都由按特定顺序执行的阶段组成。阶段由在每个分区上执行的任务组成,其中一些可能是独立的,而其他则彼此依赖。因此,我们可以将 Spark 作业的执行解释为由阶段(或任务)组成的有向图,其中边表示一个计算的输出被下一个计算所需。典型的例子可能是需要前一个映射阶段的输出的减少阶段。自然地,这个执行图不包含任何循环,因为这意味着我们要将一些运算符的输出无限地输入到图中,从而阻止我们的程序最终停止。因此,这个执行图可以被表示,并实际上在 Spark 调度器中实现为 DAG:

图 5:用 Spark 在 RDD 上执行的一系列操作的可视化。执行图从定义上是一个 DAG。

连通分量

图的另一个重要属性是连通性。如果我们选择的任意两个顶点之间存在一条边的路径,无论边的方向如何,我们就说图是连通的。因此,对于有向图,我们在这个定义中完全忽略方向。对于有向图,可以使用更严格的连通性定义吗?如果任意两个顶点都可以通过有向边连接,我们就说图是强连通的。请注意,强连通性是对有向图施加的一个非常严格的假设。特别地,任何强连通图都是循环的。这些定义使我们能够定义(强)连通分量的相关概念。每个图都可以分解为连通分量。如果它是连通的,那么恰好有一个这样的分量。如果不是,那么至少有两个。正式定义,连通分量是给定图的最大子图,仍然是连通的。强连通分量也是同样的道理。连通性是一个重要的度量,因为它使我们能够将图的顶点聚类成自然属于一起的组。

例如,一个人可能对社交图中表示友谊的连接组件数量感兴趣。在一个小图中,可能有许多独立的组件。然而,随着图的规模变大,人们可能会怀疑它更有可能只有一个连接的组件,遵循着普遍接受的理由,即每个人都通过大约六个连接与其他人相连。

我们将在下一节中看到如何使用 GraphX 计算连接组件;现在,让我们只检查一个简单的例子。在下面的图表中,我们看到一个有十二个顶点的有向图:

图 6:在小图中,连接和强连接组件可以很容易地读取,但对于更大的图来说,这变得越来越困难。

我们可以立即看到它有三个连接的组件,即三组顶点{1, 2, 3}, {4, 5}, 和 {6, 7, 8, 9, 10, 11, 12}。至于强连接组件,这需要比快速的视觉检查更多的努力。我们可以看到{4, 5}形成了一个强连接组件,{8, 9, 10, 11}也是如此。其他六个顶点形成了自己的强连接组件,也就是说,它们是孤立的。这个例子继续说明,对于一个有数百万个顶点的大图,通过正确的可视化工具,我们可能会幸运地找到大致连接的组件,但强连接组件的计算会更加复杂,这正是 Spark GraphX 派上用场的一个用例。

有了我们手头的连接组件的定义,我们可以转向另一类有趣的图,即树。是一个连接的图,在其中恰好有一条路径连接任何给定的顶点到另一个顶点。由一组树的不相交组成的图称为森林。在下面的图表中,我们看到了一个在众所周知的鸢尾花数据集上运行的示意决策树。请注意,这仅用于说明目的,即展示此算法的输出如何被视为一个图:

图 7:在鸢尾花数据集上运行的简单决策树,通过两个特征,即花瓣长度(PL)和花瓣宽度(PW),将其分类为三个类别 Setosa,Virginica 和 Versicolor

多重图

一般来说,没有环或多重边的图被称为简单。在本章的应用中,我们将遇到的大多数图都不具备这个属性。通常,从现实世界数据构建的图会在顶点之间有多重边。在文献中,具有多重边的图被称为多重图或伪图。在整个章节中,我们将坚持多重图的概念,并遵循这样一个约定,即这样的多重图也可以包括环。由于 Spark 支持多重图(包括环),这个概念在应用中将非常有用。在下面的图表中,我们看到了一个复杂的多重图,其中有多个连接的组件:

图 8:一个稍微复杂的社交多重图,带有环和多重边。

属性图

在我们继续介绍 GraphX 作为图处理引擎之前,让我们看一下我们之前所见过的图的扩展。我们已经考虑过标记的图作为一种方便的方式来命名顶点和边。一般来说,在应用中我们将考虑的图数据将附加更多信息到顶点和边上,我们需要一种方法在我们的图中对这些额外的信息进行建模。为此,我们可以利用属性图的概念。

从图的基本定义作为顶点和边的一对开始,直接向这两个结构附加额外信息是不可能的。历史上,规避这一点的一种方法是扩展图并创建更多与属性对应的顶点,通过新的边与原始顶点连接,这些边编码与新顶点的关系。例如,在我们之前的朋友图示例中,如果我们还想在图中编码家庭地址,表示一个人的每个顶点必须与表示他们地址的顶点连接,它们之间的边是lives at。不难想象,这种方法会产生很多复杂性,特别是如果顶点属性相互关联。通过主语-谓语-宾语三元组在图中表示属性已经在所谓的资源描述框架RDF)中得到了形式化,并且其结果被称为 RDF 模型。RDF 是一个独立的主题,并且比我们所介绍的更灵活。无论如何,熟悉这个概念并了解其局限性是很好的。

相比之下,在属性图中,我们可以为顶点和边增加基本上任意的附加结构。与任何事物一样,获得这种一般性的灵活性通常是一种权衡。在我们的情况下,许多图数据库中实现的基本图允许对查询进行强大的优化,而在属性图中,当涉及性能时,我们应该小心。在下一节中,当我们展示 Spark GraphX 如何实现属性图时,我们将更详细地讨论这个话题。

在本章的其余部分,我们将使用以下约定来表示属性图。附加到顶点的额外数据称为顶点数据,附加到边的数据称为边数据。为了举例更复杂的顶点和边数据,请参见以下图表,扩展了我们扩展朋友图的想法。这个例子也展示了我们所说的三元组,即带有其相邻顶点及其所有属性的边:

图 9:显示通过地址数据增强的朋友属性图,通过多个关系连接。属性数据以 JSON 格式编码。

请注意,在前面的例子中,我们故意保持简单,但在更现实的情况下,我们需要嵌套数据结构--例如,回答欠款金额和到期时间。

在我们的上下文中,属性图的一个有趣的特殊情况是加权图,其中边、顶点或两者都具有权重,例如,附加到它们的整数或浮点数。这种情况的一个典型例子是一个由一组城市作为顶点组成的图,连接它们的边携带着位置之间的距离。在这种情况下会出现一些经典问题。一个例子是找到两个给定城市之间的最短路径。相关问题是旅行推销员问题,其中一个假设的推销员被要求使用可能的最短路线访问每个城市。

作为本节的结束语,重要的是要知道,在文献中,有一个广泛使用的与顶点同义的概念,即节点。我们在这里不使用这个术语,因为在 Spark 的上下文中,它很容易与执行任务的计算节点混淆。相反,我们将在整个章节中坚持使用顶点。此外,每当我们谈论图时,我们通常假设它是一个有限,也就是说,顶点和边的数量是有限的,在实践中,这几乎不算是限制。

GraphX 分布式图处理引擎

除了 Spark MLlib 用于机器学习,我们在本书中已经遇到了几次,以及其他组件,如我们将在第八章“Lending Club Loan Prediction”中介绍的 Spark Streaming,Spark GraphX 是 Spark 生态系统的核心组件之一。GraphX 通过构建在 RDD 之上,专门用于以高效的方式处理大型图形。

使用上一节开发的命名法,GraphX 中的图形是一个带有环的有限多重图,其中图形实际上是指之前讨论的属性图扩展。接下来,我们将看到 GraphX 中图形是如何在内部构建的。

对于使用的示例,我们建议在本地启动spark-shell,这将自动为 GraphX 提供依赖项。要测试这在您的设置中是否正常工作,请尝试使用 Scala 的通配符运算符导入完整的 GraphX 核心模块,如下所示:

import org.apache.spark.graphx._

在您的屏幕上,您应该看到以下提示:

如果您更愿意通过使用 sbt 构建一个包来跟随示例,您应该在您的build.sbt中包含以下libraryDependencies

"org.apache.spark" %% "spark-graphx" % "2.1.1"

这样做应该允许你导入 GraphX,就像之前展示的那样,创建一个你可以用 spark-submit 调用的应用程序。

GraphX 中的图形表示

回想一下,对于我们来说,属性图是一个具有自定义数据对象的有向多重图。GraphX 的中心入口点是Graph API,具有以下签名:

class Graph[VD, ED] {
  val vertices: VertexRDD[VD]
  val edges: EdgeRDD[ED]
}

因此,在 GraphX 中,图形内部由一个编码顶点的 RDD 和一个编码边的 RDD 表示。在这里,VD是顶点数据类型,ED是我们属性图的边数据类型。我们将更详细地讨论VertexRDDEdgeRDD,因为它们对接下来的内容非常重要。

在 Spark GraphX 中,顶点具有Long类型的唯一标识符,称为VertexIdVertexRDD[VD]实际上只是RDD[(VertexId, VD)]的扩展,但经过优化并具有大量的实用功能列表,我们将详细讨论。因此,简而言之,GraphX 中的顶点是带有标识符和顶点数据的 RDD,这与之前发展的直觉相一致。

为了解释EdgeRDD的概念,让我们快速解释一下 GraphX 中的Edge是什么。简化形式上,Edge由以下签名定义:

case class Edge[ED] (
  var srcId: VertexId,
  var dstId: VertexId,
  var attr: ED
)

因此,边完全由源顶点 ID(称为srcId)、目标或目的地顶点 ID(称为dstId)和ED数据类型的属性对象attr确定。与前面的顶点 RDD 类似,我们可以将EdgeRDD[ED]理解为RDD[Edge[ED]]的扩展。因此,GraphX 中的边由ED类型的边的 RDD 给出,这与我们迄今讨论的内容一致。

我们现在知道,从 Spark 2.1 开始,GraphX 中的图形本质上是顶点和边 RDD 的对。这是重要的信息,因为它原则上允许我们将 Spark 核心的 RDD 的全部功能和能力应用到这些图形中。然而,需要警告的是,图形带有许多针对图形处理目的进行优化的功能。每当你发现自己在使用基本的 RDD 功能时,看看是否可以找到特定的图形等效功能,这可能会更高效。

举个具体的例子,让我们使用刚刚学到的知识从头开始构建一个图。我们假设您有一个名为sc的 Spark 上下文可用。我们将创建一个人与彼此连接的图,即上一节中图 3中的图,即一个带标签的图。在我们刚刚学到的 GraphX 语言中,要创建这样一个图,我们需要顶点和边数据类型都是String类型。我们通过使用parallelize来创建顶点,如下所示:

import org.apache.spark.rdd.RDD
val vertices: RDD[(VertexId, String)] = sc.parallelize(
  Array((1L, "Anne"),
    (2L, "Bernie"),
    (3L, "Chris"),
    (4L, "Don"),
    (5L, "Edgar")))

同样,我们可以创建边;请注意以下定义中Edge的使用:

val edges: RDD[Edge[String]] = sc.parallelize(
  Array(Edge(1L, 2L, "likes"),
    Edge(2L, 3L, "trusts"),
    Edge(3L, 4L, "believes"),
    Edge(4L, 5L, "worships"),
    Edge(1L, 3L, "loves"),
    Edge(4L, 1L, "dislikes")))

拥有这两个准备好的 RDD 已经足以创建Graph,就像以下一行一样简单:

val friendGraph: Graph[String, String] = Graph(vertices, edges)

请注意,我们明确地为所有变量写出类型,这只是为了清晰。我们可以把它们留空,依赖 Scala 编译器为我们推断类型。此外,如前面的签名所示,我们可以通过friendGraph.vertices访问顶点,通过friendGraph.edges访问边。为了初步了解可能的操作,我们现在可以收集所有顶点并打印它们如下:

friendGraph.vertices.collect.foreach(println)

以下是输出:

请注意,这不使用任何 GraphX 特定的功能,只是使用我们已经从 RDD 中知道的知识。举个例子,让我们计算所有源 ID 大于目标 ID 的边的数量。可以这样做:

friendGraph.edges.map( e => e.srcId > e.dstId ).filter(_ == true).count

这给出了预期的答案,即1,但有一个缺点。一旦我们在图上调用.edges,我们就完全失去了之前拥有的所有图结构。假设我们想要进一步处理具有转换边的图,这不是正确的方法。在这种情况下,最好使用内置的Graph功能,比如以下的mapEdges方法:

val mappedEdgeGraph: Graph[String, Boolean] = 
  friendGraph.mapEdges( e => e.srcId > e.dstId )

请注意,这种情况下的返回值仍然是一个图,但是边的数据类型现在是Boolean,正如预期的那样。我们将在接下来看到更多关于图处理可能性的例子。看完这个例子后,让我们退一步,讨论为什么 Spark GraphX 实现图的方式。一个原因是我们可以有效地利用数据并行性图并行性。在前几章中,我们已经了解到 Spark 中的 RDD 和数据框利用数据并行性,通过在每个节点上将数据分布到分区中并将数据保存在内存中。因此,如果我们只关心顶点或边本身,而不想研究它们的关系,那么使用顶点和边 RDD 将非常高效。

相比之下,通过图并行性,我们指的是相对于图的概念进行并行操作。例如,图并行任务将是对每个顶点的所有入边的权重进行求和。要执行此任务,我们需要处理顶点和边数据,这涉及多个 RDD。要高效地执行此操作,需要合适的内部表示。GraphX 试图在这两种范式之间取得平衡,而其他一些替代程序则没有提供这种平衡。

图属性和操作

看完另一个人工例子后,让我们转而看一个更有趣的例子,用它来研究我们在上一节中学习的一些核心属性。本章中我们将考虑的数据可以在networkrepository.com/找到,这是一个拥有大量有趣数据的开放网络数据存储库。首先,我们将加载从 Twitter 获取的一个相对较小的数据集,可以从networkrepository.com/rt-occupywallstnyc.php下载。下载此页面上提供的 zip 文件,即存储 rt_occupywallstnyc.zip 并解压以访问文件 rt_occupywallstnyc.edges。该文件以逗号分隔的 CSV 格式。每一行代表了有关纽约市占领华尔街运动的推文的转发。前两列显示了 Twitter 用户 ID,第三列表示转发的 ID;也就是说,第二列中的用户转发了第一列中相应用户的推文。

前十个项目如下所示:

3212,221,1347929725
3212,3301,1347923714
3212,1801,1347714310
3212,1491,1347924000
3212,1483,1347923691
3212,1872,1347939690
1486,1783,1346181381
2382,3350,1346675417
2382,1783,1342925318
2159,349,1347911999

例如,我们可以看到用户 3,212 的推文至少被转发了六次,但由于我们不知道文件是否以任何方式排序,并且其中包含大约 3.6k 个顶点,我们应该利用 GraphX 来为我们回答这样的问题。

要构建一个图,我们将首先从该文件创建一个边的 RDD,即RDD[Edge[Long]],使用基本的 Spark 功能:

val edges: RDD[Edge[Long]] =
  sc.textFile("./rt_occupywallstnyc.edges").map { line =>
    val fields = line.split(",")
    Edge(fields(0).toLong, fields(1).toLong, fields(2).toLong)
  }

请记住,GraphX 中的 ID 是Long类型,这就是为什么在加载文本文件并通过逗号拆分每一行后,我们将所有值转换为Long的原因;也就是说,在这种情况下,我们的边数据类型是Long。在这里,我们假设所讨论的文件位于我们启动spark-shell的同一文件夹中;如果需要,可以根据自己的需求进行调整。有了这样的边 RDD,我们现在可以使用Graph伴生对象的fromEdges方法,如下所示:

val rtGraph: Graph[String, Long] = Graph.fromEdges(edges, defaultValue =  "")

也许不足为奇的是,我们需要为这个方法提供edges,但defaultValue关键字值得一些解释。请注意,到目前为止,我们只知道边,虽然顶点 ID 隐式地作为边的源和目标可用,但我们仍然没有确定任何 GraphX 图所需的顶点数据类型VDdefaultValue允许您创建一个默认的顶点数据值,带有一个类型。在我们的情况下,我们选择了一个空字符串,这解释了rtGraph的签名。

加载了这个第一个真实世界的数据图后,让我们检查一些基本属性。使用之前的符号,图的顺序可以计算如下:

val order = rtGraph.numVertices
val degree = rtGraph.numEdges

前面的代码将分别产生 3,609 和 3,936。至于各个顶点的度,GraphX 提供了 Graphs 上的degrees方法,返回整数顶点数据类型的图,用于存储度数。让我们计算一下我们的转发图的平均度:

val avgDegree = rtGraph.degrees.map(_._2).reduce(_ + _) / order.toDouble

这个操作的结果应该大约是2.18,这意味着每个顶点平均连接了大约两条边。这个简洁操作中使用的符号可能看起来有点密集,主要是因为使用了许多通配符,所以让我们来详细解释一下。为了解释这一点,我们首先调用 degrees,如前所述。然后,我们通过映射到对中的第二个项目来提取度数;也就是说,我们忘记了顶点 ID。这给我们留下了一个整数值的 RDD,我们可以通过加法减少来总结。最后一步是将order.toDouble转换为确保我们得到浮点除法,然后除以这个总数。下一个代码清单显示了相同的四个步骤以更详细的方式展开:

val vertexDegrees: VertexRDD[Int] = rtGraph.degrees
val degrees: RDD[Int] = vertexDegrees.map(v => v._2)
val sumDegrees: Int = degrees.reduce((v1, v2) => v1 + v2 )
val avgDegreeAlt = sumDegrees / order.toDouble

接下来,我们通过简单地调用inDegreesoutDegrees来计算这个有向图的入度和出度。为了使事情更有趣,让我们计算图中所有顶点的最大入度,以及最小出度,并返回其 ID。我们首先解决最大入度:

val maxInDegree: (Long, Int) = rtGraph.inDegrees.reduce(
  (v1,v2) => if (v1._2 > v2._2) v1 else v2
)

进行这个计算,你会看到 ID 为1783的顶点的入度为 401,这意味着具有这个 ID 的用户转发了 401 条不同的推文。因此,一个有趣的后续问题是,“这个用户转发了多少不同用户的推文?”同样,我们可以通过计算所有边中这个目标的不同来源来非常快速地回答这个问题:

rtGraph.edges.filter(e => e.dstId == 1783).map(_.srcId).distinct()

执行这个命令应该会提示 34,所以平均而言,用户1783从任何给定的用户那里转发了大约 12 条推文。这反过来意味着我们找到了一个有意义的多图的例子--在这个图中有许多不同连接的顶点对。现在回答最小出度的问题就很简单了:

val minOutDegree: (Long, Int) = rtGraph.outDegrees.reduce(
  (v1,v2) => if (v1._2 < v2._2) v1 else v2
)

在这种情况下,答案是1,这意味着在这个数据集中,每条推文至少被转发了一次。

请记住,属性图的三元组由边及其数据以及连接顶点及其各自的数据组成。在 Spark GraphX 中,这个概念是在一个叫做EdgeTriplet的类中实现的,我们可以通过attr检索边数据,通过srcAttrdstAttrsrcIddstId自然地检索顶点数据和 ID。为了获得我们的转发图的三元组,我们可以简单地调用以下内容:

val triplets: RDD[EdgeTriplet[String, Long]] = rtGraph.triplets

三元组通常很实用,因为我们可以直接检索相应的边和顶点数据,否则这些数据将分别存在于图中的不同 RDD 中。例如,我们可以通过执行以下操作,快速将生成的三元组转换为每次转发的可读数据:

val tweetStrings = triplets.map(
  t => t.dstId + " retweeted " + t.attr + " from " + t.srcId
)
tweetStrings.take(5)

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

当我们之前讨论friendGraph示例时,我们注意到mapEdges在某些方面优于先调用edges然后再map它们。对于顶点和三元组也是如此。假设我们想要将图的顶点数据简单地更改为顶点 ID 而不是先前选择的默认值。这可以通过以下方式最快、最有效地实现:

val vertexIdData: Graph[Long, Long] = rtGraph.mapVertices( (id, _) => id)

同样地,我们可以直接从我们的初始图开始,而不是首先检索三元组,然后使用mapTriplets直接转换三元组,返回一个具有修改后的边数据的图形对象。为了实现与前面的tweetStrings相同的效果,但保持图形结构不变,我们可以运行以下操作:

val mappedTripletsGraph = rtGraph.mapTriplets(
  t => t.dstId + " retweeted " + t.attr + " from " + t.srcId
)

作为基本图处理功能的最后一个示例,我们现在将看一下给定图的子图以及如何将图形彼此连接。考虑提取我们的图中至少被转发 10 次的所有 Twitter 用户的信息的任务。我们已经看到如何从rtGraph.outDegrees中获取出度。为了使这些信息在我们的原始图中可访问,我们需要将这些信息连接到原始图中。为此,GraphX 提供了outerJoinVertices的功能。为了这样做,我们需要提供一个顶点数据类型UVertexRDD,以及一个确定如何聚合顶点数据的函数。如果我们称要加入的 RDD 为other,那么在纸上看起来如下:

def outerJoinVerticesU, VD2])
  (mapFunc: (VertexId, VD, Option[U]) => VD2): Graph[VD2, ED]

请注意,由于我们进行了外连接,原始图中的所有 ID 可能在other中没有相应的值,这就是为什么我们在相应的映射函数中看到Option类型的原因。对于我们手头的具体例子,这样做的工作方式如下:

val outDegreeGraph: Graph[Long, Long] =
  rtGraph.outerJoinVerticesInt, Long(
    mapFunc = (id, origData, outDeg) => outDeg.getOrElse(0).toLong
  )

我们将我们的原始图与出度VertexRDD连接,并将映射函数简单地丢弃原始顶点数据并替换为出度。如果没有出度可用,我们可以使用getOrElse将其设置为0来解决Option

接下来,我们想要检索该图的子图,其中每个顶点至少有 10 次转发。图的子图由原始顶点和边的子集组成。形式上,我们定义子图为对边、顶点或两者的谓词的结果。我们指的是在顶点或边上评估的表达式,返回 true 或 false。图上子图方法的签名定义如下:

def subgraph(
  epred: EdgeTriplet[VD,ED] => Boolean = (x => true),
  vpred: (VertexId, VD) => Boolean = ((v, d) => true)): Graph[VD, ED]

请注意,由于提供了默认函数,我们可以选择只提供vpredepred中的一个。在我们具体的例子中,我们想要限制至少有10度的顶点,可以按照以下方式进行:

val tenOrMoreRetweets = outDegreeGraph.subgraph(
  vpred = (id, deg) => deg >= 10
)
tenOrMoreRetweets.vertices.count
tenOrMoreRetweets.edges.count

生成的图仅有10个顶点和5条边,但有趣的是这些有影响力的人似乎彼此之间的连接大致与平均水平相当。

为了结束这一部分,一个有趣的技术是掩码。假设我们现在想知道具有少于 10 次转发的顶点的子图,这与前面的tenOrMoreRetweets相反。当然,这可以通过子图定义来实现,但我们也可以通过以下方式掩盖原始图形tenOrMoreRetweets

val lessThanTenRetweets = rtGraph.mask(tenOrMoreRetweets)

如果我们愿意,我们可以通过将tenOrMoreRetweetslessThanTenRetweets连接来重建rtGraph

构建和加载图

在上一节中,我们在图分析方面取得了很大进展,并讨论了一个有趣的转发图。在我们深入研究更复杂的操作之前,让我们退一步考虑使用 GraphX 构建图的其他选项。完成了这个插曲后,我们将快速查看可视化工具,然后转向更复杂的应用。

实际上,我们已经看到了创建 GraphX 图的两种方法,一种是显式地构建顶点和边 RDD,然后从中构建图;另一种是使用Graph.fromEdges。另一个非常方便的可能性是加载所谓的边列表文件。这种格式的一个例子如下:

1 3
5 3
4 2
3 2
1 5

因此,边列表文件是一个文本文件,每行有一对 ID,用空格分隔。假设我们将前面的数据存储为edge_list.txt在当前工作目录中,我们可以使用GraphLoader接口从中一行加载一个图对象:

import org.apache.spark.graphx.GraphLoader
val edgeListGraph = GraphLoader.edgeListFile(sc, "./edge_list.txt")

这代表了一个非常方便的入口点,因为我们有以正确格式提供的数据。加载边列表文件后,还必须将其他顶点和边数据连接到生成的图中。从前面的数据构建图的另一种类似方法是使用Graph对象提供的fromEdgeTuples方法,可以像下面的代码片段中所示那样使用:

val rawEdges: RDD[(VertexId, VertexId)] = sc.textFile("./edge_list.txt").map { 
  line =>
    val field = line.split(" ")
    (field(0).toLong, field(1).toLong)
}
val edgeTupleGraph = Graph.fromEdgeTuples(
  rawEdges=rawEdges, defaultValue="")

与之前的构建不同之处在于,我们创建了一个原始边 RDD,其中包含顶点 ID 对,连同顶点数据的默认值,一起输入到图的构建中。

通过最后一个例子,我们基本上已经看到了 GraphX 目前支持的从给定数据加载图的每一种方式。然而,还有生成随机和确定性图的可能性,这对于测试、快速检查和演示非常有帮助。为此,我们导入以下类:

import org.apache.spark.graphx.util.GraphGenerators

这个类有很多功能可供使用。两种确定性图构建方法有助于构建星形网格图。星形图由一个中心顶点和几个顶点组成,这些顶点只通过一条边连接到中心顶点。以下是如何创建一个有十个顶点连接到中心顶点的星形图:

val starGraph = GraphGenerators.starGraph(sc, 11)

以下图片是星形图的图形表示:

图 10:一个星形图,有十个顶点围绕着一个中心顶点。

图的另一种确定性构建方法是构建网格,意味着顶点被组织成一个矩阵,每个顶点都与其直接邻居在垂直和水平方向上连接。在一个有n行和m列的网格图中,有精确地n(m-1) + m(n-1)条边--第一项是所有垂直连接,第二项是所有水平网格连接。以下是如何在 GraphX 中构建一个有 40 条边的55网格:

val gridGraph = GraphGenerators.gridGraph(sc, 5, 5)

图 11:一个由 12 个顶点组成的 3x3 的二次网格图。

就随机图而言,我们将介绍一种创建方法,它在结构上大致反映了许多现实世界的图,即对数正态图。现实生活中许多结构都遵循幂律,其中一个实体的度量由另一个的幂给出。一个具体的例子是帕累托原则,通常称为 80/20 原则,它意味着 80%的财富由 20%的人拥有,也就是说,大部分财富归属于少数人。这个原则的一个变体,称为齐夫定律,适用于我们的情景,即少数顶点具有非常高的度,而大多数顶点连接很少。在社交图的背景下,很少有人倾向于拥有很多粉丝,而大多数人拥有很少的粉丝。这导致了顶点度数的分布遵循对数正态分布图 10中的星形图是这种行为的一个极端变体,其中所有的边都集中在一个顶点周围。

在 GraphX 中创建一个具有 20 个顶点的对数正态图很简单,如下所示:

val logNormalGraph  = GraphGenerators.logNormalGraph(
  sc, numVertices = 20, mu=1, sigma = 3
)

在上述代码片段中,我们还对每个顶点施加了一个平均出度和三个标准差。让我们看看是否可以确认顶点出度的对数正态分布:

logNormalGraph.outDegrees.map(_._2).collect().sorted

这将产生一个 Scala 数组,应该如下所示。

请注意,由于图是随机生成的,您可能会得到不同的结果。接下来,让我们看看如何可视化我们迄今为止构建的一些图。

使用 Gephi 可视化图形

GraphX 没有内置的图形可视化工具,因此为了处理可视化大规模图形,我们必须考虑其他选项。有许多通用的可视化库,以及一些专门的图形可视化工具。在本章中,我们选择Gephi基本上有两个原因:

  • 这是一个免费的开源工具,适用于所有主要平台

  • 我们可以利用一个简单的交换格式 GEXF 来保存 GraphX 图,并可以将它们加载到 Gephi GUI 中,以指定可视化。

虽然第一个观点应该被普遍认为是一个优点,但并不是每个人都喜欢 GUI,对于大多数开发人员来说,以编程方式定义可视化更符合精神。请注意,事实上,使用 Gephi 也是可能的,但稍后再详细讨论。我们选择上述方法的原因是为了使本书内容自包含,而关于 Spark 的编码部分仅使用 Gephi 提供的强大可视化。

Gephi

要开始,请从gephi.org/下载 Gephi 并在本地安装在您的机器上。在撰写本书时,稳定版本是 0.9.1,我们将在整个过程中使用。打开 Gephi 应用程序时,您将收到欢迎消息,并可以选择一些示例来探索。我们将使用Les Miserables.gexf来熟悉工具。我们将在稍后更详细地讨论 GEXF 文件格式;现在,让我们专注于应用程序。这个例子的基础图数据包括代表作品《悲惨世界》中的角色的顶点,以及表示角色关联的边,加权表示连接的重要性评估。

Gephi 是一个非常丰富的工具,我们只能在这里讨论一些基础知识。一旦您打开前面的文件,您应该已经看到示例图的预览。Gephi 有三个主要视图:

  • 概述:这是我们可以操纵图的所有视觉属性并获得预览的视图。对于我们的目的,这是最重要的视图,我们将更详细地讨论它。

  • 数据实验室:此视图以表格格式显示原始图形数据,分为节点,也可以根据需要进行扩展和修改。

  • 预览:预览视图用于查看结果,即图形可视化,它也可以导出为各种格式,如 SVG、PDF 和 PNG。

如果尚未激活,请选择概述以继续。在应用程序的主菜单中,可以选择各种选项卡。确保打开图形、预览设置、外观、布局和统计,如下图所示:

图 12:Gephi 的三个主要视图和概述视图中使用的基本选项卡

Graphtab 可以用于最后的润色和视觉检查,您应该已经看到了样本悲惨世界图的视觉表示。例如,窗口左侧的矩形选择允许您通过选择顶点来选择子图,而使用拖动,您可以根据自己的审美需求移动顶点。

预览设置中,可能是我们最感兴趣的选项卡,我们可以配置图形的大部分视觉方面。预设允许您更改图形的一般样式,例如曲线与直线边。我们将保持默认设置不变。您可能已经注意到,图形预览没有顶点或边的标签,因此无法看到每个顶点代表什么。我们可以通过在节点标签类别中选择显示标签,然后取消选择比例大小复选框来更改这一点,以便所有标签具有相同的大小。如果现在转到预览视图,您看到的图形应该如下图所示:

图 13:悲惨世界示例图,经过 Gephi 轻微修改。顶点是作品中的角色,边表示连接的重要性,通过边的粗细表示。顶点大小由度确定,顶点还根据颜色分组以表示家族成员资格,后者在打印中看不到。

请注意,前面的图形具有我们没有专门设置的视觉属性。顶点大小与顶点度成比例,边的粗细由权重决定,图形的颜色编码显示了个体角色所属的家族。为了了解这是如何完成的,我们接下来讨论外观选项卡,它还区分了节点。在该选项卡的右上角,有四个选项可供选择,我们选择大小,它用一个显示几个圆圈的图标表示。这样做后,我们可以首先在左上角选择节点,然后在其下方选择排名。在下拉菜单中,我们可以选择一个属性来确定节点的大小,前面的例子中是。同样,前面讨论过的另外两个属性也可以配置。

继续,我们讨论的下一个选项卡是布局,在这里我们可以选择自动排列图形的方法。有趣的布局包括两种可用的力引导方案,它们模拟顶点相互吸引和排斥的属性。在图 13中,没有选择布局,但探索一下可能会很有趣。无论您选择哪种布局,都可以通过点击运行按钮来激活它们。

使用统计选项卡,我们可以在 Gephi 内探索图形属性,例如连通分量和 PageRank。由于我们将讨论如何在 GraphX 中执行此操作,而且 GraphX 的性能也更高,因此我们将就此结束,尽管鼓励您在此选项卡中尝试功能,因为它可以帮助快速建立直觉。

在我们根据需要配置属性后,我们现在可以切换到预览视图,看看生成的图形是否符合我们的预期。假设一切顺利,预览设置选项卡的 SVG/PDF/PNG 按钮可以用来导出我们的最终信息图,以供在您的产品中使用,无论是报告、进一步分析还是其他用途。

创建 GEXF 文件从 GraphX 图

要将 Gephi 的图形可视化能力与 Spark GraphX 图形连接起来,我们需要解决两者之间的通信方式。这样做的标准候选者是 Gephi 的图形交换 XML 格式GEXF),其描述可以在gephi.org/gexf/format/找到。在以下代码清单中显示了如何以这种格式描述图形的一个非常简单的示例:

<?xml version="1.0" encoding="UTF-8"?>
<gexf  version="1.2">
    <meta lastmodifieddate="2009-03-20">
        <creator>Gexf.net</creator>
        <description>A hello world! file</description>
    </meta>
    <graph mode="static" defaultedgetype="directed">
        <nodes>
            <node id="0" label="Hello" />
            <node id="1" label="Word" />
        </nodes>
        <edges>
            <edge id="0" source="0" target="1" />
        </edges>
    </graph>
</gexf>

除了 XML 的头部和元数据之外,图形编码本身是不言自明的。值得知道的是,前面的 XML 只是图形描述所需的最低限度,实际上,GEXF 还可以用于编码其他属性,例如边的权重或甚至 Gephi 自动捕捉的视觉属性。

为了连接 GraphX,让我们编写一个小的辅助函数,它接受一个Graph版本并返回前面 XML 格式的String版本:

def toGexfVD, ED: String = {
  val header =
    """<?xml version="1.0" encoding="UTF-8"?>
      |<gexf  version="1.2">
      |  <meta>
      |    <description>A gephi graph in GEXF format</description>
      |  </meta>
      |    <graph mode="static" defaultedgetype="directed">
    """.stripMargin

  val vertices = "<nodes>\n" + g.vertices.map(
    v => s"""<node id=\"${v._1}\" label=\"${v._2}\"/>\n"""
  ).collect.mkString + "</nodes>\n"

  val edges = "<edges>\n" + g.edges.map(
    e => s"""<edge source=\"${e.srcId}\" target=\"${e.dstId}\" label=\"${e.attr}\"/>\n"""
  ).collect.mkString + "</edges>\n"

  val footer = "</graph>\n</gexf>"

  header + vertices + edges + footer
}

虽然代码乍一看可能有点神秘,但实际上发生的事情很少。我们定义了 XML 的头部和尾部。我们需要将边和顶点属性映射到<nodes><edges> XML 标签。为此,我们使用 Scala 方便的${}符号直接将变量注入到字符串中。改变一下,让我们在一个完整的 Scala 应用程序中使用这个toGexf函数,该应用程序使用了我们之前的简单朋友图。请注意,为了使其工作,假设toGexfGephiApp可用。因此,要么将其存储在相同的对象中,要么存储在另一个文件中以从那里导入。如果您想继续使用 spark-shell,只需粘贴导入和主方法的主体,不包括创建confsc,应该可以正常工作:

import java.io.PrintWriter
import org.apache.spark._
import org.apache.spark.graphx._
import org.apache.spark.rdd.RDD

object GephiApp {
  def main(args: Array[String]) {

    val conf = new SparkConf()
      .setAppName("Gephi Test Writer")
      .setMaster("local[4]")
    val sc = new SparkContext(conf)

    val vertices: RDD[(VertexId, String)] = sc.parallelize(
      Array((1L, "Anne"),
        (2L, "Bernie"),
        (3L, "Chris"),
        (4L, "Don"),
        (5L, "Edgar")))

    val edges: RDD[Edge[String]] = sc.parallelize(
      Array(Edge(1L, 2L, "likes"),
        Edge(2L, 3L, "trusts"),
        Edge(3L, 4L, "believes"),
        Edge(4L, 5L, "worships"),
        Edge(1L, 3L, "loves"),
        Edge(4L, 1L, "dislikes")))

    val graph: Graph[String, String] = Graph(vertices, edges)

    val pw = new PrintWriter("./graph.gexf")
    pw.write(toGexf(graph))
    pw.close()
  }
}

这个应用程序将我们的朋友图存储为graph.gexf,我们可以将其导入到 Gephi 中使用。要这样做,转到“文件”,然后点击“打开”以选择此文件并导入图形。通过使用之前描述的选项卡和方法调整视觉属性,以下图表显示了此过程的结果:

图 14:使用 Gephi 显示的我们的示例朋友图

正如前面所述,确实可以使用Gephi Toolkit以编程方式定义视觉属性,这是一个可以导入到项目中的 Java 库。还有其他语言包装器可用,但这是支持的库,可作为单个 JAR 文件使用。讨论工具包远远超出了本书的范围,但如果您感兴趣,可以参考gephi.org/toolkit/,这是一个很好的入门点。

高级图处理

在快速介绍了图生成和可视化之后,让我们转向更具挑战性的应用程序和更高级的图分析技术。总结一下,到目前为止,我们在图处理方面所做的只是使用 GraphX 图的基本属性,以及一些转换,包括mapVerticesmapEdgesmapTriplets。正如我们所见,这些技术已经非常有用,但单独使用还不足以实现图并行算法。为此,GraphX 图有两个强大的候选者,我们将在下一节讨论。包括三角形计数、PageRank 等大多数内置的 GraphX 算法都是使用其中一个或另一个实现的。

聚合消息

首先,我们讨论 GraphX 图带有的aggregateMessages方法。基本思想是在整个图中并行沿着边传递消息,合适地聚合这些消息并将结果存储以供进一步处理。让我们更仔细地看一下aggregateMessages是如何定义的:

def aggregateMessagesMsg: ClassTag => Msg,
  tripletFields: TripletFields = TripletFields.All
): VertexRDD[Msg]

如您所见,要实现aggregateMessages算法,我们需要指定消息类型Msg并提供三个函数,我们将在下面解释。您可能会注意到我们之前没有遇到的两种额外类型,即EdgeContextTripletFields。简而言之,边上下文是我们已经看到的EdgeTriplets的扩展,即边加上所有关于相邻顶点的信息,唯一的区别是我们还可以额外发送信息到源顶点和目标顶点,定义如下:

def sendToSrc(msg: A): Unit
def sendToDst(msg: A): Unit

TripletFields允许限制计算中使用的EdgeContext字段,默认为所有可用字段。实际上,在接下来的内容中,我们将简单地使用tripletFields的默认值,并专注于sendMsgmergeMsg。如本主题的介绍所示,sendMsg用于沿着边传递消息,mergeMsg对它们进行聚合,并将此操作的结果存储在Msg类型的顶点 RDD 中。为了使这更具体化,考虑以下示例,这是一种计算先前的小伙伴图中所有顶点的入度的替代方法:

val inDegVertexRdd: VertexRDD[Int] = friendGraph.aggregateMessagesInt,
  mergeMsg = (msg1, msg2) => msg1+msg2
)
assert(inDegVertexRdd.collect.deep == friendGraph.inDegrees.collect.deep)

在这个例子中,发送消息是通过使用其sendToDst方法从边上下文中定义的,向每个目标顶点发送一个整数消息,即数字 1。这意味着并行地,对于每条边,我们向该边指向的每个顶点发送一个 1。这样,顶点就会收到我们需要合并的消息。这里的mergeMsg应该被理解为 RDD 中reduce的方式,也就是说,我们指定了如何合并两个消息,并且这个方法被用来将所有消息合并成一个。在这个例子中,我们只是将所有消息求和,这根据定义得到了每个顶点的入度。我们通过断言在主节点上收集到的inDegVertexRddfriendGraph.inDegrees的数组的相等性来确认这一点。

请注意,aggregateMessages的返回值是顶点 RDD,而不是图。因此,使用这种机制进行迭代,我们需要在每次迭代中生成一个新的图对象,这并不理想。由于 Spark 在迭代算法方面特别强大,因为它可以将分区数据保存在内存中,而且许多有趣的图算法实际上都是迭代的,接下来我们将讨论略微复杂但非常强大的 Pregel API。

Pregel

Pregel 是 Google 内部开发的系统,其伴随论文非常易于访问,并可在www.dcs.bbk.ac.uk/~dell/teaching/cc/paper/sigmod10/p135-malewicz.pdf上下载。它代表了一种高效的迭代图并行计算模型,允许实现大量的图算法。GraphX 对 Pregel 的实现与前述论文略有不同,但我们无法详细讨论这一点。

在口味上,GraphX 的Pregel实现与aggregateMessages非常接近,但有一些关键的区别。两种方法共享的特征是发送和合并消息机制。除此之外,使用 Pregel,我们可以定义一个所谓的顶点程序vprog,在发送之前执行以转换顶点数据。此外,我们在每个顶点上都有一个共享的初始消息,并且可以指定要执行vprog-send-merge循环的迭代次数,也就是说,迭代是规范的一部分。

Pregel 实现的apply方法是草图。请注意,它接受两组输入,即由图本身、初始消息、要执行的最大迭代次数和名为activeDirection的字段组成的四元组。最后一个参数值得更多关注。我们还没有讨论的 Pregel 规范的一个细节是,我们只从在上一次迭代中收到消息的顶点发送新消息。活动方向默认为Either,但也可以是InOut。这种行为自然地让算法在许多情况下收敛,并且也解释了为什么第三个参数被称为maxIterations - 我们可能会比指定的迭代次数提前停止:

object Pregel {
  def apply[VD: ClassTag, ED: ClassTag, A: ClassTag]
    (graph: Graph[VD, ED],
     initialMsg: A,
     maxIterations: Int = Int.MaxValue,
     activeDirection: EdgeDirection = EdgeDirection.Either)
    (vprog: (VertexId, VD, A) => VD,
     sendMsg: EdgeTriplet[VD, ED] => Iterator[(VertexId, A)],
     mergeMsg: (A, A) => A)
  : Graph[VD, ED]
}

Pregel 的第二组参数是我们已经草拟的三元组,即顶点程序,以及发送和合并消息函数。与以前的唯一值得注意的区别是sendMsg的签名,它返回一个顶点 ID 和消息对的迭代器。这对我们来说没有太大变化,但有趣的是,在 Spark 1.6 之前,aggregateMessagesendMsg的签名一直是这样的迭代器,并且在 Spark 2.0 的更新中已更改为我们之前讨论的内容。很可能,Pregel 的签名也会相应地进行更改,但截至 2.1.1,它仍然保持原样。

为了说明 Pregel API 的可能性,让我们草拟一个计算连接组件的算法的实现。这是对 GraphX 中当前可用的实现的轻微修改。我们定义了ConnectedComponents对象,其中有一个名为run的方法,该方法接受任何图和最大迭代次数。算法的核心思想很容易解释。对于每条边,每当其源 ID 小于其目标 ID 时,将源 ID 发送到目标 ID,反之亦然。为了聚合这些消息,只需取所有广播值的最小值,并迭代此过程足够长,以便它耗尽更新。在这一点上,与另一个顶点连接的每个顶点都具有相同的 ID 作为顶点数据,即原始图中可用的最小 ID:

import org.apache.spark.graphx._
import scala.reflect.ClassTag

object ConnectedComponents extends Serializable {

  def runVD: ClassTag, ED: ClassTag
  : Graph[VertexId, ED] = {

    val idGraph: Graph[VertexId, ED] = graph.mapVertices((id, _) => id)

    def vprog(id: VertexId, attr: VertexId, msg: VertexId): VertexId = {
      math.min(attr, msg)
    }

    def sendMsg(edge: EdgeTriplet[VertexId, ED]): Iterator[(VertexId, VertexId)] = {
      if (edge.srcAttr < edge.dstAttr) {
        Iterator((edge.dstId, edge.srcAttr))
      } else if (edge.srcAttr > edge.dstAttr) {
        Iterator((edge.srcId, edge.dstAttr))
      } else {
        Iterator.empty
      }
    }

    def mergeMsg(v1: VertexId, v2: VertexId): VertexId = math.min(v1, v2)

    Pregel(
      graph = idGraph,
      initialMsg = Long.MaxValue,
      maxIterations,
      EdgeDirection.Either)(
      vprog,
      sendMsg,
      mergeMsg)
  }
}

逐步进行,算法的步骤如下。首先,我们通过定义idGraph来忘记所有先前可用的顶点数据。接下来,我们定义顶点程序以发出当前顶点数据属性和当前消息的最小值。这样我们就可以将最小顶点 ID 存储为顶点数据。sendMsg方法将较小的 ID 传播到源或目标的每条边上,如前所述,mergeMsg再次只是取 ID 的最小值。定义了这三个关键方法后,我们可以简单地在指定的maxIterations上运行idGraph上的Pregel。请注意,我们不关心消息流向的方向,因此我们使用EdgeDirection.Either。此外,我们从最大可用的 Long 值作为我们的初始消息开始,这是有效的,因为我们在顶点 ID 上取最小值。

定义了这一点使我们能够在先前的转发图rtGraph上找到连接的组件,如下所示,选择五次迭代作为最大值:

val ccGraph = ConnectedComponents.run(rtGraph, 5)
cc.vertices.map(_._2).distinct.count

对结果图的不同顶点数据项进行计数,可以得到连接组件的数量(在这种情况下只有一个组件),也就是说,如果忘记方向性,数据集中的所有推文都是连接的。有趣的是,我们实际上需要五次迭代才能使算法收敛。使用更少的迭代次数运行它,即 1、2、3 或 4,会得到 1771、172、56 和 4 个连接组件。由于至少有一个连接组件,我们知道进一步增加迭代次数不会改变结果。然而,一般情况下,我们宁愿不指定迭代次数,除非时间或计算能力成为问题。通过将前面的 run 方法包装如下,我们可以在图上运行此算法,而无需显式提供迭代次数:

def runVD: ClassTag, ED: ClassTag
: Graph[VertexId, ED] = {
  run(graph, Int.MaxValue)
}

只需将此作为ConnectedComponents对象的附加方法。对于转发图,我们现在可以简单地编写。看过aggregateMessages和 Pregel 后,读者现在应该足够有能力开发自己的图算法:

val ccGraph = ConnectedComponents.run(rtGraph)

GraphFrames

到目前为止,为了计算给定图上的任何有趣的指标,我们必须使用图的计算模型,这是我们从 RDDs 所知的扩展。考虑到 Spark 的 DataFrame 或 Dataset 概念,读者可能会想知道是否有可能使用类似 SQL 的语言来对图进行分析运行查询。查询语言通常提供了一种快速获取结果的便捷方式。

GraphFrames 确实可以做到这一点。该库由 Databricks 开发,并作为 GraphX 图的自然扩展到 Spark DataFrames。不幸的是,GraphFrames 不是 Spark GraphX 的一部分,而是作为 Spark 软件包提供的。要在启动 spark-submit 时加载 GraphFrames,只需运行

spark-shell --packages graphframes:graphframes:0.5.0-spark2.1-s_2.11

并适当调整您首选的 Spark 和 Scala 版本的先前版本号。将 GraphX 图转换为GraphFrame,反之亦然,就像变得那么容易;在接下来,我们将我们之前的朋友图转换为GraphFrame,然后再转换回来:

import org.graphframes._

val friendGraphFrame = GraphFrame.fromGraphX(friendGraph)
val graph = friendGraphFrame.toGraphX

如前所述,GraphFrames 的一个附加好处是您可以与它们一起使用 Spark SQL,因为它们是建立在 DataFrame 之上的。这也意味着 GraphFrames 比图快得多,因为 Spark 核心团队通过他们的 catalyst 和 tungsten 框架为 DataFrame 带来了许多速度提升。希望我们在接下来的发布版本中看到 GraphFrames 添加到 Spark GraphX 中。

我们不再看 Spark SQL 示例,因为这应该已经在之前的章节中很熟悉了,我们考虑 GraphFrames 可用的另一种查询语言,它具有非常直观的计算模型。GraphFrames 从图数据库neo4j中借用了Cypher SQL 方言,可以用于非常表达式的查询。继续使用friendGraphFrame,我们可以非常容易地找到所有长度为 2 的路径,这些路径要么以顶点"Chris"结尾,要么首先通过边"trusts",只需使用一个简洁的命令:

friendGraphFrame.find("(v1)-[e1]->(v2); (v2)-[e2]->(v3)").filter(
  "e1.attr = 'trusts' OR v3.attr = 'Chris'"
).collect.foreach(println)

注意我们可以以一种让您以实际图的方式思考的方式指定图结构,也就是说,我们有两条边e1e2,它们通过一个共同的顶点v2连接在一起。此操作的结果列在以下屏幕截图中,确实给出了满足前述条件的三条路径:

不幸的是,我们无法在这里更详细地讨论 GraphFrames,但感兴趣的读者可以参考graphframes.github.io/上的文档获取更多详细信息。相反,我们现在将转向 GraphX 中可用的算法,并将它们应用于大规模的演员数据图。

图算法和应用

在这个应用程序部分中,我们将讨论三角形计数、(强)连通组件、PageRank 和 GraphX 中可用的其他算法,我们将从networkrepository.com/加载另一个有趣的图数据集。这次,请从networkrepository.com/ca-hollywood-2009.php下载数据,该数据集包含一个无向图,其顶点表示出现在电影中的演员。文件的每一行包含两个顶点 ID,表示这些演员在一部电影中一起出现。

该数据集包括约 110 万个顶点和 5630 万条边。尽管文件大小即使解压后也不是特别大,但这样大小的图对于图处理引擎来说是一个真正的挑战。由于我们假设您在本地使用 Spark 的独立模式工作,这个图很可能不适合您计算机的内存,并且会导致 Spark 应用程序崩溃。为了防止这种情况发生,让我们稍微限制一下数据,这也给了我们清理文件头的机会。我们假设您已经解压了ca-hollywood-2009.mtx并将其存储在当前工作目录中。我们使用 unix 工具tailhead删除前两行,然后限制到前一百万条边:

tail -n+3 ca-hollywood-2009.mtx | head -1000000 > ca-hollywood-2009.txt

如果这些工具对您不可用,任何其他工具都可以,包括手动修改文件。从前面描述的结构中,我们可以简单地使用edgeListFile功能将图加载到 Spark 中,并确认它确实有一百万条边:

val actorGraph = GraphLoader.edgeListFile(sc, "./ca-hollywood-2009.txt")
actorGraph.edges.count()

接下来,让我们看看 GraphX 能够如何分析这个图。

聚类

给定一个图,一个自然的问题是是否有任何子图与之自然地相连,也就是说,以某种方式对图进行聚类。这个问题可以用许多种方式来解决,其中我们已经自己实现了一种,即通过研究连接的组件。这次我们不使用我们自己的实现,而是使用 GraphX 的内置版本。为此,我们可以直接在图本身上调用connectedComponents

val actorComponents = actorGraph.connectedComponents().cache 
actorComponents.vertices.map(_._2).distinct().count

与我们自己的实现一样,图的顶点数据包含集群 ID,这些 ID 对应于集群中可用的最小顶点 ID。这使我们能够直接计算连接的组件,通过收集不同的集群 ID。我们受限制的集群图的答案是 173。计算组件后,我们缓存图,以便可以进一步用于其他计算。例如,我们可能会询问连接的组件有多大,例如通过计算顶点数量的最大值和最小值来计算。我们可以通过使用集群 ID 作为键,并通过计算每个组的项数来减少每个组来实现这一点:

val clusterSizes =actorComponents.vertices.map(
  v => (v._2, 1)).reduceByKey(_ + _)
clusterSizes.map(_._2).max
clusterSizes.map(_._2).min

结果表明,最大的集群包含了一个庞大的 193,518 名演员,而最小的集群只有三名演员。接下来,让我们忽略这样一个事实,即所讨论的图实际上没有方向性,因为一起出现在电影中是对称的,并且假装边对是有方向性的。我们不必在这里强加任何东西,因为在 Spark GraphX 中,边始终具有源和目标。这使我们也能够研究连接的组件。我们可以像对连接的组件那样调用这个算法,但在这种情况下,我们还必须指定迭代次数。原因是在“追踪”有向边方面,与我们对连接的组件和收敛速度相比,计算要求更高,收敛速度更慢。

让我们只进行一次迭代来进行计算,因为这非常昂贵:

val strongComponents = actorGraph.stronglyConnectedComponents(numIter = 1)
strongComponents.vertices.map(_._2).distinct().count

这个计算可能需要几分钟才能完成。如果您在您的机器上运行甚至这个例子时遇到问题,请考虑进一步限制actorGraph

接下来,让我们为演员图计算三角形,这是另一种对其进行聚类的方法。为此,我们需要稍微准备一下图,也就是说,我们必须规范化边并指定图分区策略。规范化图意味着摆脱循环和重复边,并确保对于所有边,源 ID 始终小于目标 ID:

val canonicalGraph = actorGraph.mapEdges(
  e => 1).removeSelfEdges().convertToCanonicalEdges()

图分区策略,就像我们已经遇到的 RDD 分区一样,关注的是如何有效地在集群中分发图。当然,有效意味着在很大程度上取决于我们对图的处理方式。粗略地说,有两种基本的分区策略,即顶点切割边切割。顶点切割策略意味着通过切割顶点来强制以不相交的方式分割边,也就是说,如果需要,顶点会在分区之间重复。边切割策略则相反,其中顶点在整个集群中是唯一的,但我们可能会复制边。GraphX 有四种基于顶点切割的分区策略。我们不会在这里详细讨论它们,而是只使用RandomVertexCut,它对顶点 ID 进行哈希处理,以便使顶点之间的所有同向边位于同一分区。

请注意,当创建图时没有指定分区策略时,图会通过简单地采用已提供用于构建的底层 EdgeRDD 的结构来进行分发。根据您的用例,这可能不是理想的,例如因为边的分区可能非常不平衡。

为了对canonicalGraph进行分区并继续进行三角形计数,我们现在使用上述策略对我们的图进行分区,如下所示:

val partitionedGraph = canonicalGraph.partitionBy(PartitionStrategy.RandomVertexCut)

计算三角形在概念上是很简单的。我们首先收集每个顶点的所有相邻顶点,然后计算每条边的这些集合的交集。逻辑是,如果源顶点和目标顶点集合都包含相同的第三个顶点,则这三个顶点形成一个三角形。作为最后一步,我们将交集集合的计数发送到源和目标,从而将每个三角形计数两次,然后我们简单地除以二得到每个顶点的三角形计数。现在进行三角形计数实际上就是运行:

import org.apache.spark.graphx.lib.TriangleCount
val triangles = TriangleCount.runPreCanonicalized(partitionedGraph)

事实上,我们可以不需要显式地规范化actorGraph,而是可以直接在初始图上直接施加triangleCount,也就是通过计算以下内容:

actorGraph.triangleCount()

同样,我们也可以导入TriangleCount并在我们的 actor 图上调用它,如下所示:

import org.apache.spark.graphx.lib.TriangleCount
TriangleCount.run(actorGraph)

然而,需要注意的是,这两个等价操作实际上将以相同的方式规范化所讨论的图,而规范化是一个计算上非常昂贵的操作。因此,每当你看到已经以规范形式加载图的机会时,第一种方法将更有效。

顶点重要性

在一个相互连接的朋友图中,一个有趣的问题是谁是群体中最有影响力的人。是拥有最多连接的人,也就是具有最高度的顶点吗?对于有向图,入度可能是一个很好的第一猜测。或者更确切地说,是那些认识一些人,而这些人本身又有很多连接的人?肯定有很多方法来描述一个顶点的重要性或权威性,具体的答案将在很大程度上取决于问题域,以及我们在图中附加的其他数据。此外,在我们给出的例子中,对于图中的特定人物,另一个人可能因为他们自己非常主观的原因而是最有影响力的。

寻找给定图中顶点的重要性是一个具有挑战性的问题,一个历史上重要的算法示例是PageRank,它在 1998 年的开创性论文"The Anatomy of a Large-Scale Hypertextual Web Search Engine"中被描述,可以在ilpubs.stanford.edu:8090/361/1/1998-8.pdf上找到。在这篇论文中,Sergey Brin 和 Larry Page 奠定了他们的搜索引擎 Google 在公司刚刚起步时运行的基础。虽然 PageRank 对于在由链接连接的庞大网页图中找到相关的搜索结果产生了重大影响,但这个算法在多年来已经被 Google 内部的其他方法所取代。然而,PageRank 仍然是如何对网页或图进行排名的一个主要示例,以获得更深入的理解。GraphX 提供了 PageRank 的实现,在描述算法本身之后我们将对其进行介绍。

PageRank 是一个针对有向图的迭代算法,通过将相同的值1/N初始化为每个顶点的值,其中N表示图的阶数,也就是顶点的数量。然后,它重复相同的更新顶点值的过程,也就是它们的 PageRank,直到我们选择停止或满足某些收敛标准。更具体地说,在每次迭代中,一个顶点将其当前 PageRank 除以其出度发送到所有它有出站连接的顶点,也就是说,它将其当前 PageRank 均匀分布到所有出站边上。然后顶点们将接收到的所有值相加以设置它们的新 PageRank。如果整体 PageRank 在上一次迭代中没有发生太大变化,则停止该过程。这是算法的非常基本的公式,我们将在讨论 GraphX 实现时进一步指定停止标准。

然而,我们还需要通过引入阻尼因子 d稍微扩展基线算法。阻尼因子是为了防止所谓的排名汇。想象一个强连接组件,它只有来自图的其余部分的入边,那么按照前面的规定,这个组件将在每次迭代中通过入边积累越来越多的 PageRank,但从不通过出边“释放”任何 PageRank。这种情况被称为排名汇,为了摆脱它,我们需要通过阻尼引入更多的排名源。PageRank 所做的是模拟一个完全随机的用户,以链接目标的 PageRank 给出的概率随机地跟随链接。阻尼的概念改变了这一点,引入了一个概率 d 的机会,用户按照他们当前的路径前进,并以概率(1-d)继续阅读一个完全不同的页面。

在上面的排名示例中,用户将离开强连接组件,然后在图中的其他地方停留,从而增加了其他部分的相关性,也就是 PageRank。为了总结这个解释,带有阻尼的 PageRank 更新规则可以写成如下形式:

也就是说,为了更新顶点v的 PageRank PR,我们对所有入边顶点w的 PageRank 除以它们各自的出度out(w)求和。

Spark GraphX 有两种 PageRank 的实现,一种称为静态,另一种称为动态。在静态版本中,我们只需对预先指定的固定次数numIter执行前面的更新规则。在动态版本中,我们为收敛指定了一个容差tol,即如果顶点在上一次迭代中其 PageRank 至少没有变化tol,那么它将退出计算,这意味着它既不会发出新的 PageRanks,也不会再更新自己。让我们为微小的friendGraph计算静态和动态版本的 PageRank。使用 10 次迭代的静态版本如下调用:

friendGraph.staticPageRank(numIter = 10).vertices.collect.foreach(println)

运行算法后,我们只需在主节点上收集所有顶点并打印它们,得到以下结果:

 (1,0.42988729103845036)
 (2,0.3308390977362031)
 (3,0.6102873825386869)
 (4,0.6650182732476072)
 (5,0.42988729103845036)

看到 PageRanks 随着迭代次数的变化而变化是很有趣的;请参阅以下表格以获取详细信息:

numIter / vertex Anne Bernie Chris Don Edgar
1 0.213 0.213 0.341 0.277 0.213
2 0.267 0.240 0.422 0.440 0.267
3 0.337 0.263 0.468 0.509 0.337
4 0.366 0.293 0.517 0.548 0.366
5 0.383 0.305 0.554 0.589 0.383
10 0.429 0.330 0.610 0.665 0.429
20 0.438 0.336 0.622 0.678 0.438
100 0.438 0.336 0.622 0.678 0.483

虽然在只有两次迭代后,哪个顶点比其他顶点更重要的一般趋势已经确定,但请注意,即使对于这个微小的图形,PageRanks 稳定下来也需要大约 20 次迭代。因此,如果您只对粗略排名顶点感兴趣,或者运行动态版本太昂贵,静态算法可以派上用场。要计算动态版本,我们将容差tol指定为0.0001,将所谓的resetProb指定为0.15。后者不过是1-d,也就是说,离开当前路径并在图中的随机顶点出现的概率。实际上,0.15resetProb的默认值,并反映了原始论文的建议:

friendGraph.pageRank(tol = 0.0001, resetProb = 0.15)

运行这个程序会产生以下的 PageRank 值,显示在图 15中。这些数字应该看起来很熟悉,因为它们与具有 20 次或更多迭代的静态版本相同:

图 15:使用动态 GraphX 实现计算的我们的玩具朋友图的 PageRanks。

对于一个更有趣的例子,让我们再次转向演员图。使用与前面示例中相同的容差,我们可以快速找到具有最高 PageRank 的顶点 ID:

val actorPrGraph: Graph[Double, Double] = actorGraph.pageRank(0.0001)
actorPrGraph.vertices.reduce((v1, v2) => {
  if (v1._2 > v2._2) v1 else v2
})

这返回 ID 33024,PageRank 为 7.82。为了突出 PageRank 与简单地将入度作为顶点重要性的想法有何不同,考虑以下分析:

actorPrGraph.inDegrees.filter(v => v._1 == 33024L).collect.foreach(println)

限制为所讨论的顶点 ID 并检查其入度结果为 62 个入边。让我们看看图中最高的十个入度是什么:

actorPrGraph.inDegrees.map(_._2).collect().sorted.takeRight(10)

这导致Array(704, 733, 746, 756, 762, 793, 819, 842, 982, 1007),这意味着具有最高 PageRank 的顶点甚至没有接近具有最高入度的顶点。事实上,总共有 2167 个顶点至少有 62 个入边,可以通过运行以下命令来查看:

actorPrGraph.inDegrees.map(_._2).filter(_ >= 62).count

因此,虽然这仍然意味着该顶点在入度方面处于所有顶点的前 2%,但我们看到 PageRank 得出了与其他方法完全不同的答案。

GraphX 的上下文

在整个章节中看到了许多图分析的应用之后,一个自然的问题是 GraphX 如何适应 Spark 生态系统的其他部分,以及我们如何将其与之前看到的 MLlib 等系统一起用于机器学习应用。

简而言之,尽管图的概念仅限于 Spark GraphX,但由于图的基础顶点和边 RDD,我们可以无缝地与 Spark 的任何其他模块进行交流。事实上,我们在整个章节中使用了许多核心 RDD 操作,但并不止于此。MLlib 确实在一些特定的地方使用了 GraphX 功能,比如潜在狄利克雷分析幂迭代聚类,但这超出了本章的范围。相反,我们专注于从第一原理解释 GraphX 的基础知识。然而,鼓励读者将本章学到的知识与之前的知识结合起来,并尝试使用前面的算法进行实验。为了完整起见,GraphX 中完全实现了一种机器学习算法,即SVD++,您可以在public.research.att.com/~volinsky/netflix/kdd08koren.pdf上了解更多信息,这是一种基于图的推荐算法。

总结

在本章中,我们已经看到了如何使用 Spark GraphX 将大规模图分析付诸实践。将实体关系建模为具有顶点和边的图是一种强大的范例,可以评估许多有趣的问题。

在 GraphX 中,图是有限的、有向的属性图,可能具有多个边和环。GraphX 对顶点和边 RDD 的高度优化版本进行图分析,这使您可以利用数据和图并行应用。我们已经看到这样的图可以通过从edgeListFile加载或从其他 RDD 单独构建来读取。除此之外,我们还看到了如何轻松地创建随机和确定性图数据进行快速实验。仅使用Graph模型的丰富内置功能,我们已经展示了如何调查图的核心属性。为了可视化更复杂的图形,我们介绍了Gephi及其接口,这使得我们可以直观地了解手头的图结构。

在 Spark GraphX 提供的许多其他可能性中,我们介绍了两种强大的图分析工具,即aggregateMessagesPregel API。大多数 GraphX 内置算法都是使用这两个选项之一编写的。我们已经看到如何使用这些 API 编写我们自己的算法。我们还简要介绍了 GraphFrames 包,它建立在 DataFrames 之上,配备了一种优雅的查询语言,这种语言在普通的 GraphX 中不可用,并且可以在分析目的上派上用场。

在实际应用方面,我们看到了一个有趣的转发图,以及好莱坞电影演员图的应用。我们仔细解释并应用了谷歌的 PageRank 算法,研究了图的(强)连通组件,并计算三角形作为聚类的手段。最后,我们讨论了 Spark MLlib 和 GraphX 在高级机器学习应用中的关系。

第八章:Lending Club 贷款预测

我们几乎已经到了本书的结尾,但最后一章将利用我们在前几章中涵盖的所有技巧和知识。我们向您展示了如何利用 Spark 的强大功能进行数据处理和转换,以及我们向您展示了包括线性模型、树模型和模型集成在内的数据建模的不同方法。本质上,本章将是各种问题的“综合章节”,我们将一次性处理许多问题,从数据摄入、处理、预处理、异常值处理和建模,一直到模型部署。

我们的主要目标之一是提供数据科学家日常生活的真实画面——从几乎原始数据开始,探索数据,构建几个模型,比较它们,找到最佳模型,并将其部署到生产环境——如果一直都这么简单就好了!在本书的最后一章中,我们将借鉴 Lending Club 的一个真实场景,这是一家提供点对点贷款的公司。我们将应用您学到的所有技能,看看是否能够构建一个确定贷款风险性的模型。此外,我们将与实际的 Lending Club 数据进行比较,以评估我们的过程。

动机

Lending Club 的目标是最小化提供坏贷款的投资风险,即那些有很高违约或延迟概率的贷款,但也要避免拒绝好贷款,从而损失利润。在这里,主要标准是由接受的风险驱动——Lending Club 可以接受多少风险仍然能够盈利。

此外,对于潜在的贷款,Lending Club 需要提供一个反映风险并产生收入的适当利率,或者提供贷款调整。因此,如果某项贷款的利率较高,我们可能推断出这种贷款的固有风险比利率较低的贷款更大。

在我们的书中,我们可以从 Lending Club 的经验中受益,因为他们提供了不仅是良好贷款而且是坏贷款的历史追踪。此外,所有历史数据都可用,包括代表最终贷款状态的数据,这为扮演 Lending Club 数据科学家的角色并尝试匹配甚至超越他们的预测模型提供了独特的机会。

我们甚至可以再进一步——我们可以想象一个“自动驾驶模式”。对于每笔提交的贷款,我们可以定义投资策略(即,我们愿意接受多少风险)。自动驾驶将接受/拒绝贷款,并提出机器生成的利率,并计算预期收益。唯一的条件是,如果您使用我们的模型赚了一些钱,我们希望分享利润!

目标

总体目标是创建一个机器学习应用程序,能够根据给定的投资策略训练模型,并将这些模型部署为可调用的服务,处理进入的贷款申请。该服务将能够决定是否批准特定的贷款申请并计算利率。我们可以从业务需求开始,自上而下地定义我们的意图。记住,一个优秀的数据科学家对所提出的问题有着牢固的理解,这取决于对业务需求的理解,具体如下:

  • 我们需要定义投资策略的含义以及它如何优化/影响我们的机器学习模型的创建和评估。然后,我们将采用模型的发现,并根据指定的投资策略将其应用于我们的贷款组合,以最大程度地优化我们的利润。

  • 我们需要定义基于投资策略的预期回报计算,并且应用程序应该提供出借人的预期回报。这对于投资者来说是一个重要的贷款属性,因为它直接连接了贷款申请、投资策略(即风险)和可能的利润。我们应该记住这一点,因为在现实生活中,建模管道是由不是数据科学或统计专家的用户使用的,他们更感兴趣于对建模输出的更高层次解释。

  • 此外,我们需要设计并实现一个贷款预测管道,其中包括以下内容:

  • 基于贷款申请数据和投资策略的模型决定贷款状态-贷款是否应该被接受或拒绝。

  • 模型需要足够健壮,以拒绝所有不良贷款(即导致投资损失的贷款),但另一方面,不要错过任何好贷款(即不要错过任何投资机会)。

  • 模型应该是可解释的-它应该解释为什么会拒绝贷款。有趣的是,关于这个主题有很多研究;关键利益相关者希望得到比“模型说了算”更具体的东西。

对于那些对模型可解释性感兴趣的人,UCSD 的 Zachary Lipton 有一篇名为模型可解释性的神话的杰出论文,arxiv.org/abs/1606.03490直接讨论了这个话题。对于那些经常需要解释他们的魔法的数据科学家来说,这是一篇特别有用的论文!

    • 还有另一个模型,它推荐接受贷款的利率。根据指定的贷款申请,模型应该决定最佳利率,既不能太高以至于失去借款人,也不能太低以至于错失利润。
  • 最后,我们需要决定如何部署这个复杂的、多方面的机器学习管道。就像我们之前的章节一样,将多个模型组合成一个管道,我们将使用数据集中的所有输入-我们将看到它们是非常不同类型的-并进行处理、特征提取、模型预测和基于我们的投资策略的推荐:这是一个艰巨的任务,但我们将在本章中完成!

数据

Lending Club 提供所有可用的贷款申请及其结果。2007-2012 年和 2013-2014 年的数据可以直接从www.lendingclub.com/info/download-data.action下载。

下载拒绝贷款数据,如下截图所示:

下载的文件包括filesLoanStats3a.CSVLoanStats3b.CSV

我们拥有的文件包含大约 230k 行,分为两个部分:

  • 符合信用政策的贷款:168k

  • 不符合信用政策的贷款:62k(注意不平衡的数据集)

和往常一样,建议通过查看样本行或前 10 行来查看数据;鉴于我们这里的数据集的大小,我们可以使用 Excel 来查看一行是什么样子:

要小心,因为下载的文件可能包含一行 Lending Club 下载系统的注释。最好在加载到 Spark 之前手动删除它。

数据字典

Lending Club 下载页面还提供了包含单独列解释的数据字典。具体来说,数据集包含 115 个具有特定含义的列,收集关于借款人的数据,包括他们的银行历史、信用历史和贷款申请。此外,对于已接受的贷款,数据包括付款进度或贷款的最终状态-如果完全支付或违约。研究数据字典的一个重要原因是防止使用可能会预示你试图预测的结果的列,从而导致模型不准确。这个信息很清楚但非常重要:研究并了解你的数据!

环境准备

在本章中,我们将使用 Scala API 构建两个独立的 Spark 应用程序,一个用于模型准备,另一个用于模型部署,而不是使用 Spark shell。在 Spark 的情况下,Spark 应用程序是一个正常的 Scala 应用程序,具有作为执行入口的主方法。例如,这是一个用于模型训练的应用程序的框架:

object Chapter8 extends App {

val spark = SparkSession.builder()
     .master("local[*]")
     .appName("Chapter8")
     .getOrCreate()

val sc = spark.sparkContext
sc.setLogLevel("WARN")
script(spark, sc, spark.sqlContext)

def script(spark: SparkSession, sc: SparkContext, sqlContext: SQLContext): Unit = {
      // ...code of application
}
}

此外,我们将尝试提取可以在两个应用程序之间共享的部分到一个库中。这将使我们能够遵循 DRY(不要重复自己)原则:

object Chapter8Library {
    // ...code of library
  }

数据加载

通常情况下,第一步涉及将数据加载到内存中。在这一点上,我们可以决定使用 Spark 或 H2O 的数据加载能力。由于数据存储在 CSV 文件格式中,我们将使用 H2O 解析器快速地了解数据:

val DATASET_DIR = s"${sys.env.get("DATADIR").getOrElse("data")}" val DATASETS = Array("LoanStats3a.CSV", "LoanStats3b.CSV")
import java.net.URI

import water.fvec.H2OFrame
val loanDataHf = new H2OFrame(DATASETS.map(name => URI.create(s"${DATASET_DIR}/${name}")):_*)

加载的数据集可以直接在 H2O Flow UI 中进行探索。我们可以直接验证存储在内存中的数据的行数、列数和大小:

探索-数据分析

现在,是时候探索数据了。我们可以问很多问题,比如:

  • 我们想要模拟支持我们目标的目标特征是什么?

  • 每个目标特征的有用训练特征是什么?

  • 哪些特征不适合建模,因为它们泄漏了关于目标特征的信息(请参阅前一节)?

  • 哪些特征是无用的(例如,常量特征,或者包含大量缺失值的特征)?

  • 如何清理数据?对缺失值应该怎么处理?我们能工程化新特征吗?

基本清理

在数据探索过程中,我们将执行基本的数据清理。在我们的情况下,我们可以利用两种工具的力量:我们使用 H2O Flow UI 来探索数据,找到数据中可疑的部分,并直接用 H2O 或者更好地用 Spark 进行转换。

无用的列

第一步是删除每行包含唯一值的列。这种典型的例子是用户 ID 或交易 ID。在我们的情况下,我们将根据数据描述手动识别它们:

import com.packtpub.mmlwspark.utils.Tabulizer.table
val idColumns = Seq("id", "member_id")
println(s"Columns with Ids: ${table(idColumns, 4, None)}")

输出如下:

下一步是识别无用的列,例如以下列:

  • 常量列

  • 坏列(只包含缺失值)

以下代码将帮助我们做到这一点:

val constantColumns = loanDataHf.names().indices
   .filter(idx => loanDataHf.vec(idx).isConst || loanDataHf.vec(idx).isBad)
   .map(idx => loanDataHf.name(idx))
println(s"Constant and bad columns: ${table(constantColumns, 4, None)}")

输出如下:

字符串列

现在,是时候探索数据集中不同类型的列了。简单的步骤是查看包含字符串的列-这些列就像 ID 列一样,因为它们包含唯一值:

val stringColumns = loanDataHf.names().indices
   .filter(idx => loanDataHf.vec(idx).isString)
   .map(idx => loanDataHf.name(idx))
println(s"String columns:${table(stringColumns, 4, None)}")

输出显示在以下截图中:

问题是url特征是否包含我们可以提取的任何有用信息。我们可以直接在 H2O Flow 中探索数据,并在以下截图中查看特征列中的一些数据样本:

我们可以直接看到url特征只包含指向 Lending Club 网站的指针,使用我们已经删除的应用程序 ID。因此,我们可以决定删除它。

贷款进度列

我们的目标是基于贷款申请数据做出固有风险的预测,但是一些列包含了关于贷款支付进度的信息,或者它们是由 Lending Club 自己分配的。在这个例子中,为了简单起见,我们将放弃它们,只关注贷款申请流程中的列。重要的是要提到,在现实场景中,甚至这些列可能包含有用的信息(例如支付进度)可用于预测。然而,我们希望基于贷款的初始申请来构建我们的模型,而不是在贷款已经被 a)接受和 b)有历史支付记录的情况下。根据数据字典,我们检测到以下列:

val loanProgressColumns = Seq("funded_amnt", "funded_amnt_inv", "grade", "initial_list_status",
"issue_d", "last_credit_pull_d", "last_pymnt_amnt", "last_pymnt_d",
"next_pymnt_d", "out_prncp", "out_prncp_inv", "pymnt_plan",
"recoveries", "sub_grade", "total_pymnt", "total_pymnt_inv",
"total_rec_int", "total_rec_late_fee", "total_rec_prncp")

现在,我们可以直接记录所有我们需要删除的列,因为它们对建模没有任何价值:

val columnsToRemove = (idColumns ++ constantColumns ++ stringColumns ++ loanProgressColumns)

分类列

在下一步中,我们将探索分类列。H2O 解析器只有在列包含有限的字符串值集时才将列标记为分类列。这是与标记为字符串列的列的主要区别。它们包含超过 90%的唯一值(例如,我们在上一段中探索的url列)。让我们收集我们数据集中所有分类列的列表,以及各个特征的稀疏性:

val categoricalColumns = loanDataHf.names().indices
  .filter(idx => loanDataHf.vec(idx).isCategorical)
  .map(idx => (loanDataHf.name(idx), loanDataHf.vec(idx).cardinality()))
  .sortBy(-_._2)

println(s"Categorical columns:${table(tblize(categoricalColumns, true, 2))}")

输出如下:

现在,我们可以探索单独的列。例如,“purpose”列包含 13 个类别,主要目的是债务合并:

这个列看起来是有效的,但现在,我们应该关注可疑的列,即,首先是高基数列:emp_titletitledesc。有几个观察结果:

  • 每列的最高值是一个空的“值”。这可能意味着一个缺失的值。然而,对于这种类型的列(即,表示一组值的列),一个专门的级别用于缺失值是非常合理的。它只代表另一个可能的状态,“缺失”。因此,我们可以保持它不变。

  • “title”列与“purpose”列重叠,可以被删除。

  • emp_titledesc列纯粹是文本描述。在这种情况下,我们不会将它们视为分类,而是应用 NLP 技术以后提取重要信息。

现在,我们将专注于以“mths_”开头的列,正如列名所示,该列应该包含数字值,但我们的解析器决定这些列是分类的。这可能是由于收集数据时的不一致性造成的。例如,当我们探索“mths_since_last_major_derog”列的域时,我们很容易就能发现一个原因:

列中最常见的值是一个空值(即,我们之前已经探索过的相同缺陷)。在这种情况下,我们需要决定如何替换这个值以将列转换为数字列:它应该被缺失值替换吗?

如果我们想尝试不同的策略,我们可以为这种类型的列定义一个灵活的转换。在这种情况下,我们将离开 H2O API 并切换到 Spark,并定义我们自己的 Spark UDF。因此,与前几章一样,我们将定义一个函数。在这种情况下,一个给定替换值和一个字符串的函数,产生代表给定字符串的浮点值,或者如果字符串为空则返回指定值。然后,将该函数包装成 Spark UDF:

import org.apache.spark.sql.functions._
val toNumericMnths = (replacementValue: Float) => (mnths: String) => {
if (mnths != null && !mnths.trim.isEmpty) mnths.trim.toFloat else replacementValue
}
val toNumericMnthsUdf = udf(toNumericMnths(0.0f))

一个好的做法是保持我们的代码足够灵活,以允许进行实验,但不要使其过于复杂。在这种情况下,我们只是为我们期望更详细探讨的情况留下了一个开放的大门。

还有两列需要我们关注:int_raterevol_util。两者都应该是表示百分比的数字列;然而,如果我们对它们进行探索,我们很容易看到一个问题--列中包含“%”符号而不是数字值。因此,我们有两个更多的候选列需要转换:

然而,我们不会直接处理数据,而是定义 Spark UDF 转换,将基于字符串的利率转换为数字利率。但是,在我们的 UDF 定义中,我们将简单地使用 H2O 提供的信息,确认两列中的类别列表只包含以百分号结尾的数据:

import org.apache.spark.sql.functions._
val toNumericRate = (rate: String) => {
val num = if (rate != null) rate.stripSuffix("%").trim else ""
if (!num.isEmpty) num.toFloat else Float.NaN
}
val toNumericRateUdf = udf(toNumericRate)

定义的 UDF 将在稍后与其他 Spark 转换一起应用。此外,我们需要意识到这些转换需要在训练和评分时应用。因此,我们将它们放入我们的共享库中。

文本列

在前面的部分中,我们确定了emp_titledesc列作为文本转换的目标。我们的理论是这些列可能包含有用的信息,可以帮助区分好坏贷款。

缺失数据

我们数据探索旅程的最后一步是探索缺失值。我们已经观察到一些列包含表示缺失值的值;然而,在本节中,我们将专注于纯缺失值。首先,我们需要收集它们:

val naColumns = loanDataHf.names().indices
   .filter(idx => loanDataHf.vec(idx).naCnt() >0)
   .map(idx =>
          (loanDataHf.name(idx),
            loanDataHf.vec(idx).naCnt(),
f"${100*loanDataHf.vec(idx).naCnt()/loanDataHf.numRows().toFloat}%2.1f%%")
   ).sortBy(-_._2)
println(s"Columns with NAs (#${naColumns.length}):${table(naColumns)}")

列表包含 111 列,缺失值的数量从 0.2%到 86%不等:

有很多列缺少五个值,这可能是由于错误的数据收集引起的,如果它们呈现出某种模式,我们可以很容易地将它们过滤掉。对于更“污染的列”(例如,有许多缺失值的列),我们需要根据数据字典中描述的列语义找出每列的正确策略。

在所有这些情况下,H2O Flow UI 允许我们轻松快速地探索数据的基本属性,甚至执行基本的数据清理。但是,对于更高级的数据操作,Spark 是正确的工具,因为它提供了一个预先准备好的转换库和本地 SQL 支持。

哇!正如我们所看到的,数据清理虽然相当费力,但对于数据科学家来说是一项非常重要的任务,希望能够得到对深思熟虑的问题的良好答案。在解决每一个新问题之前,这个过程必须经过仔细考虑。正如古老的广告语所说,“垃圾进,垃圾出”-如果输入不正确,我们的模型将遭受后果。

此时,可以将所有确定的转换组合成共享库函数:

def basicDataCleanup(loanDf: DataFrame, colsToDrop: Seq[String] = Seq()) = {
   (
     (if (loanDf.columns.contains("int_rate"))
       loanDf.withColumn("int_rate", toNumericRateUdf(col("int_rate")))
else loanDf)
       .withColumn("revol_util", toNumericRateUdf(col("revol_util")))
       .withColumn("mo_sin_old_il_acct", toNumericMnthsUdf(col("mo_sin_old_il_acct")))
       .withColumn("mths_since_last_delinq", toNumericMnthsUdf(col("mths_since_last_delinq")))
       .withColumn("mths_since_last_record", toNumericMnthsUdf(col("mths_since_last_record")))
       .withColumn("mths_since_last_major_derog", toNumericMnthsUdf(col("mths_since_last_major_derog")))
       .withColumn("mths_since_recent_bc", toNumericMnthsUdf(col("mths_since_recent_bc")))
       .withColumn("mths_since_recent_bc_dlq", toNumericMnthsUdf(col("mths_since_recent_bc_dlq")))
       .withColumn("mths_since_recent_inq", toNumericMnthsUdf(col("mths_since_recent_inq")))
       .withColumn("mths_since_recent_revol_delinq", toNumericMnthsUdf(col("mths_since_recent_revol_delinq")))
   ).drop(colsToDrop.toArray :_*)
 }

该方法以 Spark DataFrame 作为输入,并应用所有确定的清理转换。现在,是时候构建一些模型了!

预测目标

进行数据清理后,是时候检查我们的预测目标了。我们理想的建模流程包括两个模型:一个控制贷款接受的模型,一个估计利率的模型。你应该已经想到,第一个模型是一个二元分类问题(接受或拒绝贷款),而第二个模型是一个回归问题,结果是一个数值。

贷款状态模型

第一个模型需要区分好坏贷款。数据集已经提供了loan_status列,这是我们建模目标的最佳特征表示。让我们更详细地看看这一列。

贷款状态由一个分类特征表示,有七个级别:

  • 全额支付:借款人支付了贷款和所有利息

  • 当前:贷款按计划积极支付

  • 宽限期内:逾期付款 1-15 天

  • 逾期(16-30 天):逾期付款

  • 逾期(31-120 天):逾期付款

  • 已冲销:贷款逾期 150 天

  • 违约:贷款丢失

对于第一个建模目标,我们需要区分好贷款和坏贷款。好贷款可能是已全额偿还的贷款。其余的贷款可以被视为坏贷款,除了需要更多关注的当前贷款(例如,存活分析),或者我们可以简单地删除包含“Current”状态的所有行。为了将 loan_status 特征转换为二进制特征,我们将定义一个 Spark UDF:

val toBinaryLoanStatus = (status: String) => status.trim.toLowerCase() match {
case "fully paid" =>"good loan"
case _ =>"bad loan"
}
val toBinaryLoanStatusUdf = udf(toBinaryLoanStatus)

我们可以更详细地探索各个类别的分布。在下面的截图中,我们还可以看到好贷款和坏贷款之间的比例非常不平衡。在训练和评估模型时,我们需要牢记这一事实,因为我们希望优化对坏贷款的召回概率:

loan_status 列的属性。

基本模型

此时,我们已经准备好了目标预测列并清理了输入数据,现在可以构建一个基本模型了。基本模型可以让我们对数据有基本的直觉。为此,我们将使用除了被检测为无用的列之外的所有列。我们也将跳过处理缺失值,因为我们将使用 H2O 和 RandomForest 算法,它可以处理缺失值。然而,第一步是通过定义的 Spark 转换来准备数据集:

import com.packtpub.mmlwspark.chapter8.Chapter8Library._
val loanDataDf = h2oContext.asDataFrame(loanDataHf)(sqlContext)
val loanStatusBaseModelDf = basicDataCleanup(
   loanDataDf
     .where("loan_status is not null")
     .withColumn("loan_status", toBinaryLoanStatusUdf($"loan_status")),
   colsToDrop = Seq("title") ++ columnsToRemove)

我们将简单地删除所有已知与我们的目标预测列相关的列,所有携带文本描述的高分类列(除了titledesc,我们稍后会使用),并应用我们在前面部分确定的所有基本清理转换。

下一步涉及将数据分割成两部分。像往常一样,我们将保留大部分数据用于训练,其余部分用于模型验证,并将其转换为 H2O 模型构建器接受的形式:

val loanStatusDfSplits = loanStatusBaseModelDf.randomSplit(Array(0.7, 0.3), seed = 42)

val trainLSBaseModelHf = toHf(loanStatusDfSplits(0).drop("emp_title", "desc"), "trainLSBaseModelHf")(h2oContext)
val validLSBaseModelHf = toHf(loanStatusDfSplits(1).drop("emp_title", "desc"), "validLSBaseModelHf")(h2oContext)
def toHf(df: DataFrame, name: String)(h2oContext: H2OContext): H2OFrame = {
val hf = h2oContext.asH2OFrame(df, name)
val allStringColumns = hf.names().filter(name => hf.vec(name).isString)
     hf.colToEnum(allStringColumns)
     hf
 }

有了清理后的数据,我们可以轻松地构建一个模型。我们将盲目地使用 RandomForest 算法,因为它直接为我们提供了数据和个体特征的重要性。我们之所以说“盲目”,是因为正如你在第二章中回忆的那样,探测暗物质 - 强子玻色子粒子,RandomForest 模型可以接受许多不同类型的输入,并使用不同的特征构建许多不同的树,这让我们有信心使用这个算法作为我们的开箱即用模型,因为它在包括所有特征时表现得非常好。因此,该模型也定义了一个我们希望通过构建新特征来改进的基线。

我们将使用默认设置。RandomForest 提供了基于袋外样本的验证模式,因此我们暂时可以跳过交叉验证。然而,我们将增加构建树的数量,但通过基于 Logloss 的停止准则限制模型构建的执行。此外,我们知道预测目标是不平衡的,好贷款的数量远远高于坏贷款,因此我们将通过启用 balance_classes 选项要求对少数类进行上采样:


import _root_.hex.tree.drf.DRFModel.DRFParameters
import _root_.hex.tree.drf.{DRF, DRFModel}
import _root_.hex.ScoreKeeper.StoppingMetric
import com.packtpub.mmlwspark.utils.Utils.let

val loanStatusBaseModelParams = let(new DRFParameters) { p =>
   p._response_column = "loan_status" p._train = trainLSBaseModelHf._key
p._ignored_columns = Array("int_rate")
   p._stopping_metric = StoppingMetric.logloss
p._stopping_rounds = 1
p._stopping_tolerance = 0.1
p._ntrees = 100
p._balance_classes = true p._score_tree_interval = 20
}
val loanStatusBaseModel1 = new DRF(loanStatusBaseModelParams, water.Key.makeDRFModel)
   .trainModel()
   .get()

模型构建完成后,我们可以像在之前的章节中那样探索其质量,但我们首先要看的是特征的重要性:

最令人惊讶的事实是,zip_code 和 collection_recovery_fee 特征的重要性远高于其他列。这是可疑的,可能表明该列与目标变量直接相关。

我们可以重新查看数据字典,其中将zip_code列描述为“借款人在贷款申请中提供的邮政编码的前三个数字”,第二列描述为“后收费用”。后者指示与响应列的直接联系,因为“好贷款”将具有等于零的值。我们还可以通过探索数据来验证这一事实。在 zip_code 的情况下,与响应列没有明显的联系。

因此,我们将进行一次模型运行,但在这种情况下,我们将尝试忽略zip_codecollection_recovery_fee列:

loanStatusBaseModelParams._ignored_columns = Array("int_rate", "collection_recovery_fee", "zip_code")
val loanStatusBaseModel2 = new DRF(loanStatusBaseModelParams, water.Key.makeDRFModel)
   .trainModel()
   .get()

构建模型后,我们可以再次探索变量重要性图,并看到变量之间的重要性分布更有意义。根据图表,我们可以决定仅使用前 10 个输入特征来简化模型的复杂性并减少建模时间。重要的是要说,我们仍然需要考虑已删除的列作为相关的输入特征:

基础模型性能

现在,我们可以查看创建模型的模型性能。我们需要记住,在我们的情况下,以下内容适用:

  • 模型的性能是基于袋外样本报告的,而不是未见数据。

  • 我们使用固定参数作为最佳猜测;然而,进行随机参数搜索将有益于了解输入参数如何影响模型的性能。

我们可以看到在袋外样本数据上测得的 AUC 相当高。即使对于最小化各个类别准确率的选择阈值,各个类别的错误率也很低。然而,让我们探索模型在未见数据上的性能。我们将使用准备好的部分数据进行验证:

import _root_.hex.ModelMetrics
val lsBaseModelPredHf = loanStatusBaseModel2.score(validLSBaseModelHf)
println(ModelMetrics.getFromDKV(loanStatusBaseModel2, validLSBaseModelHf))

输出如下:

计算得到的模型指标也可以在 Flow UI 中进行可视化探索。

我们可以看到 AUC 较低,各个类别的错误率较高,但仍然相当不错。然而,所有测量的统计属性都无法给我们任何关于模型的“业务”价值的概念-借出了多少钱,违约贷款损失了多少钱等等。在下一步中,我们将尝试为模型设计特定的评估指标。

声明模型做出错误预测是什么意思?它可以将良好的贷款申请视为不良的,这将导致拒绝申请。这也意味着从贷款利息中损失利润。或者,模型可以将不良的贷款申请推荐为良好的,这将导致全部或部分借出的资金损失。让我们更详细地看看这两种情况。

前一种情况可以用以下函数描述:

def profitMoneyLoss = (predThreshold: Double) =>
     (act: String, predGoodLoanProb: Double, loanAmount: Int, intRate: Double, term: String) => {
val termInMonths = term.trim match {
case "36 months" =>36
case "60 months" =>60
}
val intRatePerMonth = intRate / 12 / 100
if (predGoodLoanProb < predThreshold && act == "good loan") {
         termInMonths*loanAmount*intRatePerMonth / (1 - Math.pow(1+intRatePerMonth, -termInMonths)) - loanAmount
       } else 0.0
}

该函数返回如果模型预测了不良贷款,但实际数据表明贷款是良好的时候损失的金额。返回的金额考虑了预测的利率和期限。重要的变量是predGoodLoanProb,它保存了模型预测的将实际贷款视为良好贷款的概率,以及predThreshold,它允许我们设置一个标准,当预测良好贷款的概率对我们来说足够高时。

类似地,我们将描述后一种情况:

val loanMoneyLoss = (act: String, predGoodLoanProb: Double, predThreshold: Double, loanAmount: Int) => {
if (predGoodLoanProb > predThreshold /* good loan predicted */
&& act == "bad loan" /* actual is bad loan */) loanAmount else 0
}

要意识到我们只是按照假阳性和假阴性的混淆矩阵定义,并应用我们对输入数据的领域知识来定义特定的模型评估指标。

现在,是时候利用这两个函数并定义totalLoss了-如果我们遵循模型的建议,接受不良贷款和错过良好贷款时我们可以损失多少钱:

import org.apache.spark.sql.Row
def totalLoss(actPredDf: DataFrame, threshold: Double): (Double, Double, Long, Double, Long, Double) = {

val profitMoneyLossUdf = udf(profitMoneyLoss(threshold))
val loanMoneyLossUdf = udf(loanMoneyLoss(threshold))

val lostMoneyDf = actPredDf
     .where("loan_status is not null and loan_amnt is not null")
     .withColumn("profitMoneyLoss", profitMoneyLossUdf($"loan_status", $"good loan", $"loan_amnt", $"int_rate", $"term"))
     .withColumn("loanMoneyLoss", loanMoneyLossUdf($"loan_status", $"good loan", $"loan_amnt"))

   lostMoneyDf
     .agg("profitMoneyLoss" ->"sum", "loanMoneyLoss" ->"sum")
     .collect.apply(0) match {
case Row(profitMoneyLossSum: Double, loanMoneyLossSum: Double) =>
       (threshold,
         profitMoneyLossSum, lostMoneyDf.where("profitMoneyLoss > 0").count,
         loanMoneyLossSum, lostMoneyDf.where("loanMoneyLoss > 0").count,
         profitMoneyLossSum + loanMoneyLossSum
       )
   }
 }

totalLoss函数是为 Spark DataFrame 和阈值定义的。Spark DataFrame 包含实际验证数据和预测,由三列组成:默认阈值的实际预测、良好贷款的概率和不良贷款的概率。阈值帮助我们定义良好贷款概率的合适标准;也就是说,如果良好贷款概率高于阈值,我们可以认为模型建议接受贷款。

如果我们对不同的阈值运行该函数,包括最小化各个类别错误的阈值,我们将得到以下表格:

import _root_.hex.AUC2.ThresholdCriterion
val predVActHf: Frame = lsBaseModel2PredHf.add(validLSBaseModelHf)
 water.DKV.put(predVActHf)
val predVActDf = h2oContext.asDataFrame(predVActHf)(sqlContext)
val DEFAULT_THRESHOLDS = Array(0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95)

println(
table(Array("Threshold", "Profit Loss", "Count", "Loan loss", "Count", "Total loss"),
         (DEFAULT_THRESHOLDS :+
               ThresholdCriterion.min_per_class_accuracy.max_criterion(lsBaseModel2PredModelMetrics.auc_obj()))
          .map(threshold =>totalLoss(predVActDf, threshold)),
Map(1 ->"%,.2f", 3 ->"%,.2f", 5 ->"%,.2f")))

输出如下:

从表中可以看出,我们的指标的最低总损失是基于阈值0.85,这代表了一种相当保守的策略,侧重于避免坏账。

我们甚至可以定义一个函数,找到最小的总损失和相应的阈值:

// @Snippet
def findMinLoss(model: DRFModel,
                 validHf: H2OFrame,
                 defaultThresholds: Array[Double]): (Double, Double, Double, Double) = {
import _root_.hex.ModelMetrics
import _root_.hex.AUC2.ThresholdCriterion
// Score model
val modelPredHf = model.score(validHf)
val modelMetrics = ModelMetrics.getFromDKV(model, validHf)
val predVActHf: Frame = modelPredHf.add(validHf)
   water.DKV.put(predVActHf)
//
val predVActDf = h2oContext.asDataFrame(predVActHf)(sqlContext)
val min = (DEFAULT_THRESHOLDS :+ ThresholdCriterion.min_per_class_accuracy.max_criterion(modelMetrics.auc_obj()))
     .map(threshold =>totalLoss(predVActDf, threshold)).minBy(_._6)
   ( /* Threshold */ min._1, /* Total loss */ min._6, /* Profit loss */ min._2, /* Loan loss */ min._4)
 }
val minLossModel2 = findMinLoss(loanStatusBaseModel2, validLSBaseModelHf, DEFAULT_THRESHOLDS)
println(f"Min total loss for model 2: ${minLossModel2._2}%,.2f (threshold = ${minLossModel2._1})")

输出如下:

基于报告的结果,我们可以看到模型将总损失最小化到阈值约为0.85,这比模型识别的默认阈值(F1 = 0.66)要高。然而,我们仍然需要意识到这只是一个基本的朴素模型;我们没有进行任何调整和搜索正确的训练参数。我们仍然有两个字段,titledesc,我们可以利用。是时候改进模型了!

emp_title 列转换

第一列emp_title描述了就业头衔。然而,它并不统一-有多个版本具有相同的含义(“Bank of America”与“bank of america”)或类似的含义(“AT&T”和“AT&T Mobility”)。我们的目标是将标签统一成基本形式,检测相似的标签,并用一个共同的标题替换它们。理论上,就业头衔直接影响偿还贷款的能力。

标签的基本统一是一个简单的任务-将标签转换为小写形式并丢弃所有非字母数字字符(例如“&”或“.”)。对于这一步,我们将使用 Spark API 进行用户定义的函数:

val unifyTextColumn = (in: String) => {
if (in != null) in.toLowerCase.replaceAll("[^\\w ]|", "") else null
}
val unifyTextColumnUdf = udf(unifyTextColumn)

下一步定义了一个分词器,一个将句子分割成单独标记并丢弃无用和停用词(例如,太短的词或连词)的函数。在我们的情况下,我们将使最小标记长度和停用词列表作为输入参数灵活:

val ALL_NUM_REGEXP = java.util.regex.Pattern.compile("\\d*")
val tokenizeTextColumn = (minLen: Int) => (stopWords: Array[String]) => (w: String) => {
if (w != null)
     w.split(" ").map(_.trim).filter(_.length >= minLen).filter(!ALL_NUM_REGEXP.matcher(_).matches()).filter(!stopWords.contains(_)).toSeq
else Seq.empty[String]
 }
import org.apache.spark.ml.feature.StopWordsRemover
val tokenizeUdf = udf(tokenizeTextColumn(3)(StopWordsRemover.loadDefaultStopWords("english")))

重要的是要提到,Spark API 已经提供了停用词列表作为StopWordsRemover转换的一部分。我们对tokenizeUdf的定义直接利用了提供的英文停用词列表。

现在,是时候更详细地查看列了。我们将从已创建的 DataFrame loanStatusBaseModelDf中选择emp_title列,并应用前面定义的两个函数:

val empTitleColumnDf = loanStatusBaseModelDf
   .withColumn("emp_title", unifyTextColumnUdf($"emp_title"))
   .withColumn("emp_title_tokens", tokenizeUdf($"emp_title"))

现在,我们有一个重要的 Spark DataFrame,其中包含两个重要的列:第一列包含统一的emp_title,第二列由标记列表表示。借助 Spark SQL API,我们可以轻松地计算emp_title列中唯一值的数量,或者具有超过 100 个频率的唯一标记的数量(即,这意味着该单词在超过 100 个emp_titles中使用):

println("Number of unique values in emp_title column: " +
        empTitleColumn.select("emp_title").groupBy("emp_title").count().count())
println("Number of unique tokens with freq > 100 in emp_title column: " +
        empTitleColumn.rdd.flatMap(row => row.getSeqString.map(w => (w, 1)))
          .reduceByKey(_ + _).filter(_._2 >100).count)

输出如下:

您可以看到emp_title列中有许多唯一值。另一方面,只有717个标记一遍又一遍地重复。我们的目标是压缩列中唯一值的数量,并将相似的值分组在一起。我们可以尝试不同的方法。例如,用一个代表性标记对每个emp_title进行编码,或者使用基于 Word2Vec 算法的更高级的技术。

在前面的代码中,我们将 DataFrame 查询功能与原始 RDD 的计算能力相结合。许多查询可以用强大的基于 SQL 的 DataFrame API 来表达;然而,如果我们需要处理结构化数据(例如前面示例中的字符串标记序列),通常 RDD API 是一个快速的选择。

让我们看看第二个选项。Word2Vec 算法将文本特征转换为向量空间,其中相似的单词在表示单词的相应向量的余弦距离方面彼此靠近。这是一个很好的特性;然而,我们仍然需要检测“相似单词组”。对于这个任务,我们可以简单地使用 KMeans 算法。

第一步是创建 Word2Vec 模型。由于我们的数据在 Spark DataFrame 中,我们将简单地使用ml包中的 Spark 实现:

import org.apache.spark.ml.feature.Word2Vec
val empTitleW2VModel = new Word2Vec()
  .setInputCol("emp_title_tokens")
  .setOutputCol("emp_title_w2vVector")
  .setMinCount(1)
  .fit(empTitleColumn)

算法输入由存储在“tokens”列中的句子表示的标记序列定义。outputCol参数定义了模型的输出,如果用于转换数据的话:


 val empTitleColumnWithW2V =   w2vModel.transform(empTitleW2VModel)
 empTitleColumnWithW2V.printSchema()

输出如下:

从转换的输出中,您可以直接看到 DataFrame 输出不仅包含emp_titleemp_title_tokens输入列,还包含emp_title_w2vVector列,它代表了 w2vModel 转换的输出。

需要提到的是,Word2Vec 算法仅针对单词,但 Spark 实现也将句子(即单词序列)转换为向量,方法是通过对句子表示的所有单词向量进行平均。

接下来,我们将构建一个 K 均值模型,将代表个人就业头衔的向量空间划分为预定义数量的聚类。在这之前,重要的是要考虑为什么这样做是有益的。想想你所知道的“软件工程师”的许多不同变体:程序分析员,SE,高级软件工程师等等。鉴于这些本质上意思相同并且将由相似向量表示的变体,聚类为我们提供了一种将相似头衔分组在一起的方法。然而,我们需要指定我们应该检测到多少 K 个聚类-这需要更多的实验,但为简单起见,我们将尝试500个聚类:

import org.apache.spark.ml.clustering.KMeans
val K = 500
val empTitleKmeansModel = new KMeans()
  .setFeaturesCol("emp_title_w2vVector")
  .setK(K)
  .setPredictionCol("emp_title_cluster")
  .fit(empTitleColumnWithW2V)

该模型允许我们转换输入数据并探索聚类。聚类编号存储在一个名为emp_title_cluster的新列中。

指定聚类数量是棘手的,因为我们正在处理无监督的机器学习世界。通常,从业者会使用一个简单的启发式方法,称为肘部法则(参考以下链接:en.wikipedia.org/wiki/Determining_the_number_of_clusters_in_a_data_set),基本上通过许多 K 均值模型,增加 K 聚类的数量作为每个聚类之间的异质性(独特性)的函数。通常情况下,随着 K 聚类数量的增加,收益会递减,关键是找到增加变得边际的点,以至于收益不再值得运行时间。

另外,还有一些信息准则统计量,被称为AIC阿凯克信息准则)(en.wikipedia.org/wiki/Akaike_information_criterion)和BIC贝叶斯信息准则)(en.wikipedia.org/wiki/Bayesian_information_criterion),对此感兴趣的人应该进一步了解。需要注意的是,在撰写本书时,Spark 尚未实现这些信息准则,因此我们不会详细介绍。

看一下以下代码片段:

val clustered = empTitleKmeansModel.transform(empTitleColumnWithW2V)
clustered.printSchema()

输出如下:

此外,我们可以探索与随机聚类相关的单词:

println(
s"""Words in cluster '133':
 |${clustered.select("emp_title").where("emp_title_cluster = 133").take(10).mkString(", ")}
 |""".stripMargin)

输出如下:

看看前面的聚类,问自己,“这些标题看起来像是一个逻辑聚类吗?”也许需要更多的训练,或者也许我们需要考虑进一步的特征转换,比如运行 n-grammer,它可以识别高频发生的单词序列。感兴趣的人可以在 Spark 中查看 n-grammer 部分。

此外,emp_title_cluster列定义了一个新特征,我们将用它来替换原始的emp_title列。我们还需要记住在列准备过程中使用的所有步骤和模型,因为我们需要重现它们来丰富新数据。为此,Spark 管道被定义为:

import org.apache.spark.ml.Pipeline
import org.apache.spark.sql.types._

val empTitleTransformationPipeline = new Pipeline()
   .setStages(Array(
new UDFTransformer("unifier", unifyTextColumn, StringType, StringType)
       .setInputCol("emp_title").setOutputCol("emp_title_unified"),
new UDFTransformer("tokenizer",
                        tokenizeTextColumn(3)(StopWordsRemover.loadDefaultStopWords("english")),
                        StringType, ArrayType(StringType, true))
       .setInputCol("emp_title_unified").setOutputCol("emp_title_tokens"),
     empTitleW2VModel,
     empTitleKmeansModel,
new ColRemover().setKeep(false).setColumns(Array("emp_title", "emp_title_unified", "emp_title_tokens", "emp_title_w2vVector"))
   ))

前两个管道步骤代表了用户定义函数的应用。我们使用了与第四章中使用的相同技巧,将 UDF 包装成 Spark 管道转换器,并借助定义的UDFTransformer类。其余步骤代表了我们构建的模型。

定义的UDFTransformer类是将 UDF 包装成 Spark 管道转换器的一种好方法,但对于 Spark 来说,它是一个黑匣子,无法执行所有强大的转换。然而,它可以被 Spark SQLTransformer 的现有概念所取代,后者可以被 Spark 优化器理解;另一方面,它的使用并不那么直接。

管道仍然需要拟合;然而,在我们的情况下,由于我们只使用了 Spark 转换器,拟合操作将所有定义的阶段捆绑到管道模型中:

val empTitleTransformer = empTitleTransformationPipeline.fit(loanStatusBaseModelDf)

现在,是时候评估新特征对模型质量的影响了。我们将重复我们之前在评估基本模型质量时所做的相同步骤:

  • 准备训练和验证部分,并用一个新特征emp_title_cluster来丰富它们。

  • 构建模型。

  • 计算总损失金额并找到最小损失。

对于第一步,我们将重用准备好的训练和验证部分;然而,我们需要用准备好的管道对它们进行转换,并丢弃“原始”列desc

val trainLSBaseModel3Df = empTitleTransformer.transform(loanStatusDfSplits(0))
val validLSBaseModel3Df = empTitleTransformer.transform(loanStatusDfSplits(1))
val trainLSBaseModel3Hf = toHf(trainLSBaseModel3Df.drop("desc"), "trainLSBaseModel3Hf")(h2oContext)
val validLSBaseModel3Hf = toHf(validLSBaseModel3Df.drop("desc"), "validLSBaseModel3Hf")(h2oContext)

当数据准备好时,我们可以使用与基本模型训练相同的参数重复模型训练,只是我们使用准备好的输入训练部分:

loanStatusBaseModelParams._train = trainLSBaseModel3Hf._key
val loanStatusBaseModel3 = new DRF(loanStatusBaseModelParams, water.Key.makeDRFModel)
   .trainModel()
   .get()

最后,我们可以在验证数据上评估模型,并根据总损失金额计算我们的评估指标:

val minLossModel3 = findMinLoss(loanStatusBaseModel3, validLSBaseModel3Hf, DEFAULT_THRESHOLDS)
println(f"Min total loss for model 3: ${minLossModel3._2}%,.2f (threshold = ${minLossModel3._1})")

输出如下:

我们可以看到,利用自然语言处理技术来检测相似的职位标题略微提高了模型的质量,导致了在未知数据上计算的总美元损失的减少。然而,问题是我们是否可以根据desc列进一步改进我们的模型,其中可能包含有用的信息。

desc 列转换

我们将要探索的下一列是desc。我们的动机仍然是从中挖掘任何可能的信息,并提高模型的质量。desc列包含了借款人希望贷款的纯文本描述。在这种情况下,我们不打算将它们视为分类值,因为大多数都是唯一的。然而,我们将应用自然语言处理技术来提取重要信息。与emp_title列相反,我们不会使用 Word2Vec 算法,而是尝试找到能够区分坏贷款和好贷款的词语。

为了达到这个目标,我们将简单地将描述分解为单独的单词(即标记化),并根据 tf-idf 赋予每个使用的单词权重,并探索哪些单词最有可能代表好贷款或坏贷款。我们可以使用词频而不是 tf-idf 值,但 tf-idf 值更好地区分了信息性词语(如“信用”)和常见词语(如“贷款”)。

让我们从我们在emp_title列的情况下执行的相同过程开始,定义将desc列转录为统一标记列表的转换:

import org.apache.spark.sql.types._
val descColUnifier = new UDFTransformer("unifier", unifyTextColumn, StringType, StringType)
   .setInputCol("desc")
.setOutputCol("desc_unified")

val descColTokenizer = new UDFTransformer("tokenizer",
                                           tokenizeTextColumn(3)(StopWordsRemover.loadDefaultStopWords("english")),
                                           StringType, ArrayType(StringType, true))
.setInputCol("desc_unified")
.setOutputCol("desc_tokens")

转换准备了一个包含每个输入desc值的单词列表的desc_tokens列。现在,我们需要将字符串标记转换为数字形式以构建 tf-idf 模型。在这种情况下,我们将使用CountVectorizer,它提取所使用的单词的词汇表,并为每一行生成一个数值向量。数值向量中的位置对应于词汇表中的单个单词,值表示出现的次数。我们希望将标记转换为数值向量,因为我们希望保留向量中的数字与表示它的标记之间的关系。与 Spark HashingTF 相反,CountVectorizer保留了单词与生成向量中其出现次数之间的双射关系。我们稍后将重用这种能力:

import org.apache.spark.ml.feature.CountVectorizer
val descCountVectorizer = new CountVectorizer()
   .setInputCol("desc_tokens")
   .setOutputCol("desc_vector")
   .setMinDF(1)
   .setMinTF(1)

定义 IDF 模型:

import org.apache.spark.ml.feature.IDF
val descIdf = new IDF()
   .setInputCol("desc_vector")
   .setOutputCol("desc_idf_vector")
   .setMinDocFreq(1)

当我们将所有定义的转换放入单个管道中时,我们可以直接在输入数据上训练它:

import org.apache.spark.ml.Pipeline
val descFreqPipeModel = new Pipeline()
   .setStages(
Array(descColUnifier,
           descColTokenizer,
           descCountVectorizer,
           descIdf)
   ).fit(loanStatusBaseModelDf)

现在,我们有一个管道模型,可以为每个输入desc值转换一个数值向量。此外,我们可以检查管道模型的内部,并从计算的CountVectorizerModel中提取词汇表,从IDFModel中提取单词权重:

val descFreqDf = descFreqPipeModel.transform(loanStatusBaseModelDf)
import org.apache.spark.ml.feature.IDFModel
import org.apache.spark.ml.feature.CountVectorizerModel
val descCountVectorizerModel = descFreqPipeModel.stages(2).asInstanceOf[CountVectorizerModel]
val descIdfModel = descFreqPipeModel.stages(3).asInstanceOf[IDFModel]
val descIdfScores = descIdfModel.idf.toArray
val descVocabulary = descCountVectorizerModel.vocabulary
println(
s"""
     ~Size of 'desc' column vocabulary: ${descVocabulary.length} ~Top ten highest scores:
     ~${table(descVocabulary.zip(descIdfScores).sortBy(-_._2).take(10))}
""".stripMargin('~'))

输出如下:

在这一点上,我们知道单词的权重;然而,我们仍然需要计算哪些单词被“好贷款”和“坏贷款”使用。为此,我们将利用由准备好的管道模型计算的单词频率信息,并存储在desc_vector列中(实际上,这是CountVectorizer的输出)。我们将分别为好贷款和坏贷款单独总结所有这些向量:

import org.apache.spark.ml.linalg.{Vector, Vectors}
val rowAdder = (toVector: Row => Vector) => (r1: Row, r2: Row) => {
Row(Vectors.dense((toVector(r1).toArray, toVector(r2).toArray).zipped.map((a, b) => a + b)))
 }

val descTargetGoodLoan = descFreqDf
   .where("loan_status == 'good loan'")
   .select("desc_vector")
   .reduce(rowAdder((row:Row) => row.getAsVector)).getAsVector.toArray

val descTargetBadLoan = descFreqDf
   .where("loan_status == 'bad loan'")
   .select("desc_vector")
   .reduce(rowAdder((row:Row) => row.getAsVector)).getAsVector.toArray

计算了值之后,我们可以轻松地找到只被好/坏贷款使用的单词,并探索它们计算出的 IDF 权重:

val descTargetsWords = descTargetGoodLoan.zip(descTargetBadLoan)
   .zip(descVocabulary.zip(descIdfScores)).map(t => (t._1._1, t._1._2, t._2._1, t._2._2))
println(
s"""
      ~Words used only in description of good loans:
      ~${table(descTargetsWords.filter(t => t._1 >0 && t._2 == 0).sortBy(-_._1).take(10))} ~
      ~Words used only in description of bad loans:
      ~${table(descTargetsWords.filter(t => t._1 == 0 && t._2 >0).sortBy(-_._1).take(10))}
""".stripMargin('~'))

输出如下:

产生的信息似乎并不有用,因为我们只得到了非常罕见的单词,这些单词只允许我们检测到一些高度特定的贷款描述。然而,我们希望更通用,并找到更常见的单词,这些单词被两种贷款类型使用,但仍然允许我们区分好坏贷款。

因此,我们需要设计一个单词得分,它将针对在好(或坏)贷款中高频使用的单词,但惩罚罕见的单词。例如,我们可以定义如下:

def descWordScore = (freqGoodLoan: Double, freqBadLoan: Double, wordIdfScore: Double) =>
   Math.abs(freqGoodLoan - freqBadLoan) * wordIdfScore * wordIdfScore

如果我们在词汇表中的每个单词上应用单词得分方法,我们将得到一个基于得分降序排列的单词列表:

val numOfGoodLoans = loanStatusBaseModelDf.where("loan_status == 'good loan'").count()
val numOfBadLoans = loanStatusBaseModelDf.where("loan_status == 'bad loan'").count()

val descDiscriminatingWords = descTargetsWords.filter(t => t._1 >0 && t. _2 >0).map(t => {
val freqGoodLoan = t._1 / numOfGoodLoans
val freqBadLoan = t._2 / numOfBadLoans
val word = t._3
val idfScore = t._4
       (word, freqGoodLoan*100, freqBadLoan*100, idfScore, descWordScore(freqGoodLoan, freqBadLoan, idfScore))
     })
println(
table(Seq("Word", "Freq Good Loan", "Freq Bad Loan", "Idf Score", "Score"),
     descDiscriminatingWords.sortBy(-_._5).take(100),
Map(1 ->"%.2f", 2 ->"%.2f")))

输出如下:

根据生成的列表,我们可以识别有趣的单词。我们可以选择其中的 10 个或 100 个。然而,我们仍然需要弄清楚如何处理它们。解决方案很简单;对于每个单词,我们将生成一个新的二进制特征-如果单词出现在desc值中,则为 1;否则为 0:

val descWordEncoder = (denominatingWords: Array[String]) => (desc: String) => {
if (desc != null) {
val unifiedDesc = unifyTextColumn(desc)
       Vectors.dense(denominatingWords.map(w =>if (unifiedDesc.contains(w)) 1.0 else 0.0))
     } else null }

我们可以在准备好的训练和验证样本上测试我们的想法,并衡量模型的质量。再次,第一步是准备带有新特征的增强数据。在这种情况下,新特征是一个包含由 descWordEncoder 生成的二进制特征的向量:

val trainLSBaseModel4Df = trainLSBaseModel3Df.withColumn("desc_denominating_words", descWordEncoderUdf($"desc")).drop("desc")
val validLSBaseModel4Df = validLSBaseModel3Df.withColumn("desc_denominating_words", descWordEncoderUdf($"desc")).drop("desc")
val trainLSBaseModel4Hf = toHf(trainLSBaseModel4Df, "trainLSBaseModel4Hf")
val validLSBaseModel4Hf = toHf(validLSBaseModel4Df, "validLSBaseModel4Hf")
 loanStatusBaseModelParams._train = trainLSBaseModel4Hf._key
val loanStatusBaseModel4 = new DRF(loanStatusBaseModelParams, water.Key.makeDRFModel)
   .trainModel()
   .get()

现在,我们只需要计算模型的质量:

val minLossModel4 = findMinLoss(loanStatusBaseModel4, validLSBaseModel4Hf, DEFAULT_THRESHOLDS)
println(f"Min total loss for model 4: ${minLossModel4._2}%,.2f (threshold = ${minLossModel4._1})")

输出如下:

我们可以看到新特征有所帮助,并提高了我们模型的精度。另一方面,它也为实验开辟了很多空间-我们可以选择不同的单词,甚至在单词是desc列的一部分时使用 IDF 权重而不是二进制值。

总结我们的实验,我们将比较我们产生的三个模型的计算结果:(1)基础模型,(2)在通过emp_title特征增强的数据上训练的模型,以及(3)在通过desc特征丰富的数据上训练的模型:

println(
s"""
     ~Results:
     ~${table(Seq("Threshold", "Total loss", "Profit loss", "Loan loss"),
Seq(minLossModel2, minLossModel3, minLossModel4),
Map(1 ->"%,.2f", 2 ->"%,.2f", 3 ->"%,.2f"))}
""".stripMargin('~'))

输出如下:

我们的小实验展示了特征生成的强大概念。每个新生成的特征都改善了基础模型的质量,符合我们的模型评估标准。

此时,我们可以完成对第一个模型的探索和训练,以检测好/坏贷款。我们将使用我们准备的最后一个模型,因为它给出了最好的质量。仍然有许多方法可以探索数据和提高我们的模型质量;然而,现在是构建我们的第二个模型的时候了。

利率模型

第二个模型预测已接受贷款的利率。在这种情况下,我们将仅使用对应于良好贷款的训练数据的部分,因为它们已经分配了适当的利率。然而,我们需要了解,剩下的坏贷款可能携带与利率预测相关的有用信息。

与其他情况一样,我们将从准备训练数据开始。我们将使用初始数据,过滤掉坏贷款,并删除字符串列:

val intRateDfSplits = loanStatusDfSplits.map(df => {
   df
     .where("loan_status == 'good loan'")
     .drop("emp_title", "desc", "loan_status")
     .withColumn("int_rate", toNumericRateUdf(col("int_rate")))
 })
val trainIRHf = toHf(intRateDfSplits(0), "trainIRHf")(h2oContext)
val validIRHf = toHf(intRateDfSplits(1), "validIRHf")(h2oContext)

在下一步中,我们将利用 H2O 随机超空间搜索的能力,在定义的参数超空间中找到最佳的 GBM 模型。我们还将通过额外的停止标准限制搜索,这些标准基于请求的模型精度和整体搜索时间。

第一步是定义通用的 GBM 模型构建器参数,例如训练、验证数据集和响应列:

import _root_.hex.tree.gbm.GBMModel.GBMParameters
val intRateModelParam = let(new GBMParameters()) { p =>
   p._train = trainIRHf._key
p._valid = validIRHf._key
p._response_column = "int_rate" p._score_tree_interval  = 20
}

下一步涉及定义要探索的参数超空间。我们可以对任何有趣的值进行编码,但请记住,搜索可能使用任何参数组合,甚至是无用的参数:

import _root_.hex.grid.{GridSearch}
import water.Key
import scala.collection.JavaConversions._
val intRateHyperSpace: java.util.Map[String, Array[Object]] = Map[String, Array[AnyRef]](
"_ntrees" -> (1 to 10).map(v => Int.box(100*v)).toArray,
"_max_depth" -> (2 to 7).map(Int.box).toArray,
"_learn_rate" ->Array(0.1, 0.01).map(Double.box),
"_col_sample_rate" ->Array(0.3, 0.7, 1.0).map(Double.box),
"_learn_rate_annealing" ->Array(0.8, 0.9, 0.95, 1.0).map(Double.box)
 )

现在,我们将定义如何遍历定义的参数超空间。H2O 提供两种策略:简单的笛卡尔搜索,逐步构建每个参数组合的模型,或者随机搜索,从定义的超空间中随机选择参数。令人惊讶的是,随机搜索的性能相当不错,特别是当用于探索庞大的参数空间时:

import _root_.hex.grid.HyperSpaceSearchCriteria.RandomDiscreteValueSearchCriteria
val intRateHyperSpaceCriteria = let(new RandomDiscreteValueSearchCriteria) { c =>
   c.set_stopping_metric(StoppingMetric.RMSE)
   c.set_stopping_tolerance(0.1)
   c.set_stopping_rounds(1)
   c.set_max_runtime_secs(4 * 60 /* seconds */)
 }

在这种情况下,我们还将通过两个停止条件限制搜索:基于 RMSE 的模型性能和整个网格搜索的最大运行时间。此时,我们已经定义了所有必要的输入,现在是启动超级搜索的时候了:

val intRateGrid = GridSearch.startGridSearch(Key.make("intRateGridModel"),
                                              intRateModelParam,
                                              intRateHyperSpace,
new GridSearch.SimpleParametersBuilderFactory[GBMParameters],
                                              intRateHyperSpaceCriteria).get()

搜索结果是一组称为grid的模型。让我们找一个具有最低 RMSE 的模型:

val intRateModel = intRateGrid.getModels.minBy(_._output._validation_metrics.rmse())
println(intRateModel._output._validation_metrics)

输出如下:

在这里,我们可以定义我们的评估标准,并选择正确的模型,不仅基于选择的模型指标,还要考虑预测值和实际值之间的差异,并优化利润。然而,我们将相信我们的搜索策略找到了最佳的可能模型,并直接跳入部署我们的解决方案。

使用模型进行评分

在前几节中,我们探索了不同的数据处理步骤,并构建和评估了几个模型,以预测已接受贷款的贷款状态和利率。现在,是时候使用所有构建的工件并将它们组合在一起,对新贷款进行评分了。

有多个步骤需要考虑:

  1. 数据清理

  2. emp_title列准备管道

  3. desc列转换为表示重要单词的向量

  4. 用于预测贷款接受状态的二项模型

  5. 用于预测贷款利率的回归模型

要重用这些步骤,我们需要将它们连接成一个单一的函数,该函数接受输入数据并生成涉及贷款接受状态和利率的预测。

评分函数很简单-它重放了我们在前几章中所做的所有步骤:

import _root_.hex.tree.drf.DRFModel
def scoreLoan(df: DataFrame,
                     empTitleTransformer: PipelineModel,
                     loanStatusModel: DRFModel,
                     goodLoanProbThreshold: Double,
                     intRateModel: GBMModel)(h2oContext: H2OContext): DataFrame = {
val inputDf = empTitleTransformer.transform(basicDataCleanup(df))
     .withColumn("desc_denominating_words", descWordEncoderUdf(col("desc")))
     .drop("desc")
val inputHf = toHf(inputDf, "input_df_" + df.hashCode())(h2oContext)
// Predict loan status and int rate
val loanStatusPrediction = loanStatusModel.score(inputHf)
val intRatePrediction = intRateModel.score(inputHf)
val probGoodLoanColName = "good loan" val inputAndPredictionsHf = loanStatusPrediction.add(intRatePrediction).add(inputHf)
   inputAndPredictionsHf.update()
// Prepare field loan_status based on threshold
val loanStatus = (threshold: Double) => (predGoodLoanProb: Double) =>if (predGoodLoanProb < threshold) "bad loan" else "good loan" val loanStatusUdf = udf(loanStatus(goodLoanProbThreshold))
   h2oContext.asDataFrame(inputAndPredictionsHf)(df.sqlContext).withColumn("loan_status", loanStatusUdf(col(probGoodLoanColName)))
 }

我们使用之前准备的所有定义-basicDataCleanup方法,empTitleTransformerloanStatusModelintRateModel-并按相应顺序应用它们。

请注意,在scoreLoan函数的定义中,我们不需要删除任何列。所有定义的 Spark 管道和模型只使用它们定义的特征,并保持其余部分不变。

该方法使用所有生成的工件。例如,我们可以以以下方式对输入数据进行评分:

val prediction = scoreLoan(loanStatusDfSplits(0), 
                            empTitleTransformer, 
                            loanStatusBaseModel4, 
                            minLossModel4._4, 
                            intRateModel)(h2oContext)
 prediction.show(10)

输出如下:

然而,为了独立于我们的训练代码对新贷款进行评分,我们仍然需要以某种可重复使用的形式导出训练好的模型和管道。对于 Spark 模型和管道,我们可以直接使用 Spark 序列化。例如,定义的empTitleTransormer可以以这种方式导出:

val MODELS_DIR = s"${sys.env.get("MODELSDIR").getOrElse("models")}" val destDir = new File(MODELS_DIR)
 empTitleTransformer.write.overwrite.save(new File(destDir, "empTitleTransformer").getAbsolutePath)

我们还为desc列定义了转换为udf函数descWordEncoderUdf。然而,我们不需要导出它,因为我们将其定义为共享库的一部分。

对于 H2O 模型,情况更加复杂,因为有几种模型导出的方式:二进制、POJO 和 MOJO。二进制导出类似于 Spark 导出;然而,要重用导出的二进制模型,需要运行 H2O 集群的实例。其他方法消除了这种限制。POJO 将模型导出为 Java 代码,可以独立于 H2O 集群进行编译和运行。最后,MOJO 导出模型以二进制形式存在,可以在不运行 H2O 集群的情况下进行解释和使用。在本章中,我们将使用 MOJO 导出,因为它简单直接,也是模型重用的推荐方法。

loanStatusBaseModel4.getMojo.writeTo(new FileOutputStream(new File(destDir, "loanStatusModel.mojo")))
 intRateModel.getMojo.writeTo(new FileOutputStream(new File(destDir, "intRateModel.mojo")))

我们还可以导出定义输入数据的 Spark 模式。这对于新数据的解析器的定义将很有用:

def saveSchema(schema: StructType, destFile: File, saveWithMetadata: Boolean = false) = {
import java.nio.file.{Files, Paths, StandardOpenOption}

import org.apache.spark.sql.types._
val processedSchema = StructType(schema.map {
case StructField(name, dtype, nullable, metadata) =>StructField(name, dtype, nullable, if (saveWithMetadata) metadata else Metadata.empty)
case rec => rec
    })

   Files.write(Paths.get(destFile.toURI),
               processedSchema.json.getBytes(java.nio.charset.StandardCharsets.UTF_8),
               StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)
 }
saveSchema(loanDataDf.schema, new File(destDir, "inputSchema.json"))

请注意,saveSchema方法处理给定的模式并删除所有元数据。这不是常见的做法。然而,在这种情况下,我们将删除它们以节省空间。

还要提到的是,从 H2O 框架中创建数据的过程会隐式地将大量有用的统计信息附加到生成的 Spark DataFrame 上。

模型部署

模型部署是模型生命周期中最重要的部分。在这个阶段,模型由现实生活数据提供支持决策的结果(例如,接受或拒绝贷款)。

在本章中,我们将构建一个简单的应用程序,结合 Spark 流式处理我们之前导出的模型和共享代码库,这是我们在编写模型训练应用程序时定义的。

最新的 Spark 2.1 引入了结构化流,它建立在 Spark SQL 之上,允许我们透明地利用 SQL 接口处理流数据。此外,它以“仅一次”语义的形式带来了一个强大的特性,这意味着事件不会被丢弃或多次传递。流式 Spark 应用程序的结构与“常规”Spark 应用程序相同:

object Chapter8StreamApp extends App {

val spark = SparkSession.builder()
     .master("local[*]")
     .appName("Chapter8StreamApp")
     .getOrCreate()

script(spark,
          sys.env.get("MODELSDIR").getOrElse("models"),
          sys.env.get("APPDATADIR").getOrElse("appdata"))

def script(ssc: SparkSession, modelDir: String, dataDir: String): Unit = {
// ...
val inputDataStream = spark.readStream/* (1) create stream */

val outputDataStream = /* (2) transform inputDataStream */

 /* (3) export stream */ outputDataStream.writeStream.format("console").start().awaitTermination()
   }
 }

有三个重要部分:(1)输入流的创建,(2)创建流的转换,(3)写入结果流。

流创建

有几种方法可以创建流,Spark 文档中有描述(spark.apache.org/docs/2.1.1/structured-streaming-programming-guide.html)),包括基于套接字、Kafka 或基于文件的流。在本章中,我们将使用基于文件的流,指向一个目录并传递出现在目录中的所有新文件。

此外,我们的应用程序将读取 CSV 文件;因此,我们将将流输入与 Spark CSV 解析器连接。我们还需要使用从模型训练应用程序中导出的输入数据模式配置解析器。让我们先加载模式:

def loadSchema(srcFile: File): StructType = {
import org.apache.spark.sql.types.DataType
StructType(
     DataType.fromJson(scala.io.Source.fromFile(srcFile).mkString).asInstanceOf[StructType].map {
case StructField(name, dtype, nullable, metadata) =>StructField(name, dtype, true, metadata)
case rec => rec
     }
   )
 }
val inputSchema = Chapter8Library.loadSchema(new File(modelDir, "inputSchema.json"))

loadSchema方法通过将所有加载的字段标记为可为空来修改加载的模式。这是一个必要的步骤,以允许输入数据在任何列中包含缺失值,而不仅仅是在模型训练期间包含缺失值的列。

在下一步中,我们将直接配置一个 CSV 解析器和输入流,以从给定的数据文件夹中读取 CSV 文件:

val inputDataStream = spark.readStream
   .schema(inputSchema)
   .option("timestampFormat", "MMM-yyy")
   .option("nullValue", null)
   .CSV(s"${dataDir}/*.CSV")

CSV 解析器需要进行一些配置,以设置时间戳特征的格式和缺失值的表示。在这一点上,我们甚至可以探索流的结构:

inputDataStream.schema.printTreeString()

输出如下:

流转换

输入流发布了与 Spark DataSet 类似的接口;因此,它可以通过常规 SQL 接口或机器学习转换器进行转换。在我们的情况下,我们将重用在前几节中保存的所有训练模型和转换操作。

首先,我们将加载empTitleTransformer-它是一个常规的 Spark 管道转换器,可以借助 Spark 的PipelineModel类加载:

val empTitleTransformer = PipelineModel.load(s"${modelDir}/empTitleTransformer")

loanStatusintRate模型以 H2O MOJO 格式保存。要加载它们,需要使用MojoModel类:

val loanStatusModel = MojoModel.load(new File(s"${modelDir}/loanStatusModel.mojo").getAbsolutePath)
val intRateModel = MojoModel.load(new File(s"${modelDir}/intRateModel.mojo").getAbsolutePath)

此时,我们已经准备好所有必要的工件;但是,我们不能直接使用 H2O MOJO 模型来转换 Spark 流。但是,我们可以将它们包装成 Spark transformer。我们已经在第四章中定义了一个名为 UDFTransfomer 的转换器,使用 NLP 和 Spark Streaming 预测电影评论,因此我们将遵循类似的模式:

class MojoTransformer(override val uid: String,
                       mojoModel: MojoModel) extends Transformer {

case class BinomialPrediction(p0: Double, p1: Double)
case class RegressionPrediction(value: Double)

implicit def toBinomialPrediction(bmp: AbstractPrediction) =
BinomialPrediction(bmp.asInstanceOf[BinomialModelPrediction].classProbabilities(0),
                        bmp.asInstanceOf[BinomialModelPrediction].classProbabilities(1))
implicit def toRegressionPrediction(rmp: AbstractPrediction) =
RegressionPrediction(rmp.asInstanceOf[RegressionModelPrediction].value)

val modelUdf = {
val epmw = new EasyPredictModelWrapper(mojoModel)
     mojoModel._category match {
case ModelCategory.Binomial =>udf[BinomialPrediction, Row] { r: Row => epmw.predict(rowToRowData(r)) }
case ModelCategory.Regression =>udf[RegressionPrediction, Row] { r: Row => epmw.predict(rowToRowData(r)) }
     }
   }

val predictStruct = mojoModel._category match {
case ModelCategory.Binomial =>StructField("p0", DoubleType)::StructField("p1", DoubleType)::Nil
case ModelCategory.Regression =>StructField("pred", DoubleType)::Nil
}

val outputCol = s"${uid}Prediction" override def transform(dataset: Dataset[_]): DataFrame = {
val inputSchema = dataset.schema
val args = inputSchema.fields.map(f => dataset(f.name))
     dataset.select(col("*"), modelUdf(struct(args: _*)).as(outputCol))
   }

private def rowToRowData(row: Row): RowData = new RowData {
     row.schema.fields.foreach(f => {
       row.getAsAnyRef match {
case v: Number => put(f.name, v.doubleValue().asInstanceOf[Object])
case v: java.sql.Timestamp => put(f.name, v.getTime.toDouble.asInstanceOf[Object])
case null =>// nop
case v => put(f.name, v)
       }
     })
   }

override def copy(extra: ParamMap): Transformer =  defaultCopy(extra)

override def transformSchema(schema: StructType): StructType =  {
val outputFields = schema.fields :+ StructField(outputCol, StructType(predictStruct), false)
     StructType(outputFields)
   }
 }

定义的MojoTransformer支持二项式和回归 MOJO 模型。它接受一个 Spark 数据集,并通过新列对其进行丰富:对于二项式模型,两列包含真/假概率,对于回归模型,一个列代表预测值。这体现在transform方法中,该方法使用 MOJO 包装器modelUdf来转换输入数据集:

dataset.select(col("*"), modelUdf(struct(args: _)).as(outputCol*))

modelUdf模型实现了将数据表示为 Spark Row 转换为 MOJO 接受的格式,调用 MOJO 以及将 MOJO 预测转换为 Spark Row 格式的转换。

定义的MojoTransformer允许我们将加载的 MOJO 模型包装成 Spark transformer API:

val loanStatusTransformer = new MojoTransformer("loanStatus", loanStatusModel)
val intRateTransformer = new MojoTransformer("intRate", intRateModel)

此时,我们已经准备好所有必要的构建模块,并且可以将它们应用于输入流:

val outputDataStream =
   intRateTransformer.transform(
     loanStatusTransformer.transform(
       empTitleTransformer.transform(
         Chapter8Library.basicDataCleanup(inputDataStream))
         .withColumn("desc_denominating_words", descWordEncoderUdf(col("desc"))))

代码首先调用共享库函数basicDataCleanup,然后使用另一个共享库函数descWordEncoderUdf转换desc列:这两种情况都是基于 Spark DataSet SQL 接口实现的。其余步骤将应用定义的转换器。同样,我们可以探索转换后的流的结构,并验证它是否包含我们转换引入的字段:

outputDataStream.schema.printTreeString()

输出如下:

我们可以看到模式中有几个新字段:empTitle 集群的表示,命名词向量和模型预测。概率来自贷款状态模型,实际值来自利率模型。

流输出

Spark 为流提供了所谓的“输出接收器”。接收器定义了流如何以及在哪里写入;例如,作为 parquet 文件或作为内存表。但是,对于我们的应用程序,我们将简单地在控制台中显示流输出:

outputDataStream.writeStream.format("console").start().awaitTermination()

前面的代码直接启动了流处理,并等待应用程序终止。该应用程序简单地处理给定文件夹中的每个新文件(在我们的情况下,由环境变量APPDATADIR给出)。例如,给定一个包含五个贷款申请的文件,流会生成一个包含五个评分事件的表:

事件的重要部分由最后一列表示,其中包含预测值:

如果我们在文件夹中再写入一个包含单个贷款申请的文件,应用程序将显示另一个评分批次:

通过这种方式,我们可以部署训练模型和相应的数据处理操作,并让它们评分实际事件。当然,我们只是演示了一个简单的用例;实际情况会复杂得多,涉及适当的模型验证,当前使用模型的 A/B 测试,以及模型的存储和版本控制。

摘要

本章总结了整本书中你学到的一切,通过端到端的示例。我们分析了数据,对其进行了转换,进行了几次实验,以找出如何设置模型训练流程,并构建了模型。本章还强调了需要良好设计的代码,可以在多个项目中共享。在我们的示例中,我们创建了一个共享库,用于训练时和评分时使用。这在称为“模型部署”的关键操作上得到了证明,训练好的模型和相关工件被用来评分未知数据。

本章还将我们带到了书的结尾。我们的目标是要展示,用 Spark 解决机器学习挑战主要是关于对数据、参数、模型进行实验,调试数据/模型相关问题,编写可测试和可重用的代码,并通过获得令人惊讶的数据洞察和观察来获得乐趣。

posted @ 2024-05-21 12:53  绝不原创的飞龙  阅读(37)  评论(0编辑  收藏  举报