Scala-现代项目-全-
Scala 现代项目(全)
原文:
zh.annas-archive.org/md5/7c1863d61ec1c4dc07eac182f54e20c0译者:飞龙
前言
Scala 与 Spark 框架相结合,形成了一个丰富而强大的数据处理生态系统。本书将带您深入探索这个生态系统的奥秘。本书中展示的机器学习(ML)项目能够帮助您创建实用、健壮的数据分析解决方案,重点在于使用 Spark ML 管道 API 自动化数据工作流程。本书展示了 Scala 的函数库和其他结构,精心挑选以帮助读者推出他们自己的可扩展数据处理框架。本书中的项目使所有行业的数据从业者能够深入了解数据,帮助组织获得战略和竞争优势。现代 Scala 项目专注于监督学习 ML 技术的应用,这些技术可以分类数据和做出预测。您将从实施一个简单的机器学习模型来预测一类花卉的项目开始。接下来,您将创建一个癌症诊断分类管道,随后是深入股票价格预测、垃圾邮件过滤、欺诈检测和推荐引擎的项目。
在本书结束时,您将能够构建满足您软件要求的高效数据科学项目。
本书面向的对象
本书面向希望获得一些有趣的实际项目动手经验的 Scala 开发者。需要具备 Scala 的编程经验。
本书涵盖的内容
第一章,从 Iris 数据集中预测花卉类别,专注于利用基于回归的经过时间考验的统计方法构建机器学习模型。本章将读者引入数据处理,直至训练和测试一个相对简单的机器学习模型。
第二章,利用 Spark 和 Scala 的力量构建乳腺癌预后管道,利用公开可用的乳腺癌数据集。它评估了各种特征选择算法,转换数据,并构建了一个分类模型。
第三章,股票价格预测,指出股票价格预测可能是一项不可能的任务。在本章中,我们采取了一种新的方法。因此,我们使用训练数据构建和训练一个神经网络模型来解决股票价格预测的明显难以解决的问题。以 Spark 为核心的数据管道将模型的训练分布在集群中的多台机器上。一个真实的数据集被输入到管道中。在训练模型以拟合数据之前,训练数据会经过预处理和归一化步骤。我们还可以提供一种方法来可视化预测结果并在训练后评估我们的模型。
第四章,构建垃圾邮件分类管道,告知读者本章的主要学习目标是实现一个垃圾邮件过滤数据分析管道。我们将依赖 Spark ML 库的机器学习 API 及其支持库来构建垃圾邮件分类管道。
第五章,构建欺诈检测系统,将机器学习技术和算法应用于构建一个实用的机器学习管道,帮助发现消费者信用卡上的可疑费用。数据来源于公开可访问的消费者投诉数据库。本章展示了 Spark ML 中用于构建、评估和调整管道的工具。特征提取是 Spark ML 提供的一个功能,本章将进行介绍。
第六章,构建航班性能预测模型,使我们能够利用航班起飞和到达数据来预测用户的航班是否会延误或取消。在这里,我们将构建一个基于决策树的模型,以推导出有用的预测因子,例如,在一天中什么时间乘坐航班最有可能最小化延误。
第七章,构建推荐引擎,引导读者进入可扩展推荐引擎的实现过程。读者将根据用户过去的偏好,逐步通过一个基于阶段的推荐生成过程。
为了充分利用本书
假设读者具备 Scala 语言的基础知识。了解如 Spark ML 等基本概念将是一个附加优势。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
按照以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择“支持”标签页。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保您使用最新版本的 7-Zip/PeaZip 解压缩或提取文件夹。
-
Windows 系统上的 WinRAR/7-Zip
-
Mac 系统上的 Zipeg/iZip/UnRarX
-
Linux 系统上的 7-Zip/PeaZip
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Modern-Scala-Projects。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图片
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/ModernScalaProjects_ColorImages.pdf
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“代表女孩年龄的变量名为 Huan (Age_Huan)。”
代码块按以下方式设置:
val dataFrame = spark.createDataFrame(result5).toDF(featureVector, speciesLabel)
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
sc.getConf.getAll
res4: Array[(String, String)] = Array((spark.repl.class.outputDir,C:\Users\Ilango\AppData\Local\Temp\spark-10e24781-9aa8-495c-a8cc-afe121f8252a\repl-c8ccc3f3-62ee-46c7-a1f8-d458019fa05f), (spark.app.name,Spark shell), (spark.sql.catalogImplementation,hive), (spark.driver.port,58009), (spark.debug.maxToStringFields,150),
任何命令行输入或输出都按以下方式编写:
scala> val dataSetPath = "C:\\Users\\Ilango\\Documents\\Packt\\DevProjects\\Chapter2\\"
粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
欢迎读者反馈。
一般反馈: 请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packtpub.com.
第一章:从鸢尾花数据集中预测花的类别
本章在 Scala 和 Spark 中启动了一个机器学习(ML)倡议。谈到 Spark,其机器学习库(MLlib)位于spark.ml包下,可以通过其基于 MLlib DataFrame的 API 访问。Spark ML,也称为基于 MLlib 的DataFrame API,提供了强大的学习算法和管道构建工具,用于数据分析。不用说,从本章开始,我们将利用 MLlib 的分类算法。
Spark 生态系统,除了 Scala 之外,还提供了 R、Python 和 Java 的 API,使我们的读者,无论是初学者还是经验丰富的数据专业人士,都能够理解并从各种数据集中提取分析。
谈及数据集,鸢尾花数据集是机器学习空间中最简单而又最著名的数据分析任务。本章构建了一个解决方案,以解决鸢尾花数据集所代表的数据分析分类任务。
这里是我们将要参考的数据集:
-
UCI 机器学习仓库:鸢尾花数据集
-
访问日期:2018 年 7 月 13 日
本章的主要学习目标是实现一个 Scala 解决方案,以解决由鸢尾花数据集所代表的所谓多元分类任务。
以下列表是按章节划分的各个学习成果的概述:
-
多元分类问题
-
项目概述—问题表述
-
开始使用 Spark
-
实现多类分类管道
以下部分为读者提供了对鸢尾花数据集分类问题的深入视角。
多元分类问题
数据科学历史上最著名的数据库是罗纳德·艾尔默·费舍尔爵士的经典鸢尾花数据集,也称为安德森数据集。它在 1936 年作为理解多元(或多类)分类的研究被引入。那么什么是多元变量呢?
理解多元变量
术语多元可以有两个含义:
-
从形容词的角度来看,多元变量意味着包含一个或多个变量。
-
从名词的角度来看,多元可能代表一个数学向量,其个别元素是可变的。这个向量中的每个个别元素都是一个可测量的数量或变量。
提到的两种含义都有一个共同的分母变量。对一个实验单位进行多元分析至少涉及一个可测量的数量或变量。此类分析的典型例子是鸢尾花数据集,每个观测值都有一个或多个(结果)变量。
在本小节中,我们通过变量来理解多元变量。在下一小节中,我们将简要介绍不同种类的变量,其中之一就是分类变量。
不同种类的变量
通常,变量有两种类型:
-
定量变量:它是一个表示通过数值量化的测量的变量。定量变量的例子包括:
-
表示名叫
Huan的女孩年龄的变量(Age_Huan)。在 2017 年 9 月,代表她年龄的变量包含的值是24。明年,一年后,这个变量将是她当前年龄的 1(算术上)。 -
表示太阳系中行星数量的变量(
Planet_Number)。目前,在未来的任何新行星被发现之前,这个变量包含的数字是12。如果科学家明天发现他们认为有资格成为行星的新天体,Planet_Number变量的新值将从当前的12增加到13。 -
分类变量:在自然顺序中不能赋予数值测量的变量。例如,美国个人的状态。它可以是以下值之一:公民、永久居民或非居民。
在下一个子节中,我们将详细描述分类变量。
分类变量
我们将借鉴前一个子节中分类变量的定义。分类变量在本质上与定量变量区分开来。与表示某种东西的数值测量的定量变量相反,分类变量表示一个分组名称或类别名称,它可以取有限数量的可能类别中的一个。例如,鸢尾花的种类是一个分类变量,它所取的值可以是有限集合中的任何一个值:Iris-setosa、Iris-virginica 和 Iris-versicolor。
可能会有助于引用其他分类变量的例子;以下列出了这些例子:
-
个人的血型,如 A+、A-、B+、B-、AB+、AB-、O+或 O-
-
个人居住的县,给定密苏里州有限数量的县列表
-
美国公民的政治归属可以采取民主党、共和党或绿党的分类值
-
在全球变暖研究中,森林的类型是一个分类变量,它可以取三个值,即热带、温带或泰加。
前列出的第一个项目,即人的血型,是一个分类变量,其对应的数据(值)被分类(归类)为八个组(A、B、AB 或 O 及其正负)。类似地,鸢尾花的种类是一个分类变量,其数据(值)被分类(归类)为三个物种组—Iris-setosa、Iris-versicolor 和 Iris-virginica。
话虽如此,机器学习中一个常见的数据分析任务是索引或编码当前字符串表示的分类值到数值形式;例如,双倍。这种索引是预测目标或标签的前奏,我们将在稍后详细讨论。
关于鸢尾花数据集,其物种变量数据受到分类(或分类)任务的约束,其明确目的是能够预测鸢尾花的物种。在此阶段,我们想要检查鸢尾花数据集,其行,行特征以及更多内容,这是即将到来的主题的焦点。
费舍尔的鸢尾花数据集
鸢尾花数据集总共包含 150 行,其中每行代表一朵花。每一行也被称为观测。这个由 150 个观测值组成的鸢尾花数据集由与三种不同的鸢尾花物种相关的三种观测值组成。以下表格是说明:

鸢尾花数据集观测值分解表
参考前面的表格,很明显,鸢尾花数据集中表示了三种花种。这个数据集中的每个花种都平均贡献了 50 个观测值。每个观测值包含四个测量值。一个测量值对应一朵花的一个特征,其中每个花特征对应以下之一:
-
萼片长度
-
萼片宽度
-
花瓣长度
-
花瓣宽度
为了清晰起见,以下表格展示了之前列出的特征:

鸢尾花特征
好吧,所以鸢尾花数据集中表示了三种花种。说到物种,我们将从现在开始,每当需要坚持机器学习术语背景时,将术语物种替换为类别。这意味着#1-Iris-setosa从早先指的是类别 # 1,#2-Iris-virginica指的是类别 # 2,#3-Iris-versicolor指的是类别 # 3。
我们刚刚列出了在鸢尾花数据集中表示的三个不同的鸢尾花物种。它们看起来是什么样子?它们的特点是什么?以下截图回答了这些问题:

鸢尾花三种物种的表示
话虽如此,让我们来看看鸢尾花每个类别的萼片和花瓣部分。萼片(较大的下部部分)和花瓣(较小的下部部分)的尺寸是每个类别的鸢尾花与其他两个类别的鸢尾花之间关系的体现。在下一节中,我们将总结我们的讨论,并将鸢尾花数据集的讨论范围扩展到多类、多维分类任务。
鸢尾花数据集代表一个多类、多维分类任务
在本节中,我们将重申关于鸢尾花数据集的事实,并描述其在机器学习分类任务中的背景:
-
Iris 数据集分类任务是多类的,因为从野外新到达的 Iris 花的类别预测可以属于三个类别中的任何一个。
-
的确,本章全部内容都是关于尝试进行物种分类(推断新 Iris 花的目标类别)使用萼片和花瓣尺寸作为特征参数。
-
Iris 数据集分类是多维的,因为有四个特征。
-
有 150 个观测值,每个观测值由四个特征的测量组成。这些测量也可以用以下术语来表示:
-
输入属性或实例
-
预测变量 (
X) -
输入变量 (
X) -
在野外采集的 Iris 花的分类是通过一个模型(计算出的映射函数)来完成的,该模型提供了四个花特征测量值。
-
Iris 花分类任务的输出是通过学习(或拟合)离散数量的目标或类别标签(
Y)的过程,从预测变量识别一个(计算出的)预测值。输出或预测值可能意味着以下内容: -
分类别响应变量:在后面的章节中,我们将看到索引算法会将所有类别值转换为数字
-
响应或输出变量 (
Y)
到目前为止,我们声称我们的多类分类任务的输出(Y)取决于输入(X)。这些输入将从哪里来?这个问题将在下一节中回答。
训练数据集
我们在数据分析或分类任务中未提及的一个重要方面是训练数据集。训练数据集是我们分类任务的输入数据源(X)。我们利用这个数据集通过推导最优边界或条件来获得每个目标类别的预测。我们只是通过添加训练数据集的额外细节来重新定义我们的分类过程。对于分类任务,我们有一边的 X 和另一边的 Y,中间有一个推断的映射函数。这带我们来到了映射或预测函数,这是下一节的重点。
映射函数
到目前为止,我们已经讨论了输入变量(X)和输出变量(Y)。因此,任何分类任务的目标是发现模式并找到一个映射(预测)函数,该函数将特征测量(X)映射到输出(Y)。这个函数在数学上被表示为:
Y = f(x)
这种映射就是监督学习的工作方式。一个监督学习算法被说成是学习或发现这个函数。这将是下一节的目标。
算法和其映射函数
本节从展示映射函数组件和学习的映射函数的算法的示意图开始。算法正在学习映射函数,如下面的图所示:

输入到输出的映射函数以及学习映射函数的算法
我们分类过程的目标是通过学习(或拟合)过程让算法推导出映射函数的最佳可能近似。当我们发现野外的 Iris 花并想要对其进行分类时,我们使用其输入测量值作为新的输入数据,我们的算法的映射函数将接受这些数据以给出预测值(Y)。换句话说,给定 Iris 花的特征测量值(新数据),由监督学习算法(这将是一个随机森林)产生的映射函数将对该花进行分类。
存在两种机器学习问题,监督学习分类算法可以解决。以下是这些问题的描述:
-
分类任务
-
回归任务
在接下来的段落中,我们将通过一个例子来讨论映射函数。我们解释了“监督学习分类任务”在推导映射函数中所起的作用。引入了模型的概念。
假设我们已知 Iris 数据集分类任务的映射函数 f(x) 精确地是 x + 1 的形式,那么我们就没有必要寻找新的映射函数。如果我们回想一下,映射函数是一种将花特征(如花瓣长度和花瓣宽度)与花所属物种之间的关系映射的函数?不是。
因此,不存在预先存在的函数 x + 1 可以明确映射花特征与花种之间的关系。我们需要的是一个尽可能精确地模拟上述关系的模型。数据和其分类很少是直截了当的。一个监督学习分类任务从对函数 f(x) 一无所知开始。监督学习分类过程通过迭代推理过程应用机器学习技术和策略,最终学习出 f(x) 是什么。
在我们的情况下,这种机器学习努力是一个分类任务,在统计学或机器学习术语中,这种函数或映射函数被称为模型。
在下一节中,我们将描述什么是监督学习以及它与 Iris 数据集分类的关系。实际上,这种看似最简单的机器学习技术广泛应用于数据分析,尤其是在商业领域。
监督学习 – 它与 Iris 分类任务的关系
一开始,以下是一个监督学习的显著方面的列表:
-
监督学习中的“监督”一词源于算法正在学习或推断映射函数是什么。
-
数据分析任务,无论是分类还是回归。
-
它包含从标记的训练数据集中学习或推断映射函数的过程。
-
我们的 Iris 训练数据集包含训练示例或样本,其中每个示例可能由一个包含四个测量的输入特征向量表示。
-
监督学习算法通过对训练数据进行数据分析,学习或推断或推导出映射函数的最佳可能近似。在统计或机器学习术语中,映射函数也被称为模型。
-
该算法通过迭代过程从训练示例集或训练数据集中学习参数,如下所示:
-
每次迭代都会为新输入实例生成预测的类别标签
-
学习过程的每次迭代都会逐步产生对输出类别标签应该是什么的更好泛化,并且正如任何有终点的事物一样,算法的学习过程也以预测的高度合理性结束。
-
采用监督学习的机器学习分类过程具有正确预定的标签的算法样本。
-
红花数据集是监督学习分类过程的典型例子。术语“监督”源于算法在迭代学习过程的每一步都对其先前生成的模型构建过程进行适当的校正,以生成其下一个最佳模型。
在下一节中,我们将定义一个训练数据集。在下一节以及剩余的章节中,我们将使用随机森林分类算法来运行数据分析转换任务。这里值得注意的一个任务是将字符串标签转换为表示为双精度数的索引标签列的过程。
随机森林分类算法
在前一个部分,我们提到了输入或训练数据集所起的关键作用。在本节中,我们再次强调这个数据集的重要性。也就是说,从机器学习算法的角度来看,训练数据集是随机森林算法利用它来训练或拟合模型,通过生成它需要的参数来训练或拟合模型。这些参数是模型需要用来得出下一个最佳预测值的参数。在本章中,我们将把随机森林算法应用于训练(和测试)红花数据集。实际上,下一段将开始讨论随机森林算法或简称为随机森林。
随机森林算法包含基于决策树的监督学习方法。它可以被视为由大量决策树组成的复合整体。在机器学习术语中,随机森林是由众多决策树组成的集成。
决策树,正如其名所暗示的,是一个渐进的决策过程,由一个根节点和随后的子树组成。决策树算法沿着树爬行,在每个节点停止,从根节点开始,提出一个“你是否属于某个类别”的问题。根据答案是否为是或否,做出决定沿着某个分支向上移动,直到遇到下一个节点,算法重复其询问。当然,在每个节点,算法收到的答案决定了下一个分支。最终的结果是在一个终止的叶节点上的预测结果。
说到树、分支和节点,数据集可以看作是由多个子树组成的树。数据集节点上的每个决策以及决策树算法选择某个分支的决策,是特征变量最优组合的结果。使用随机森林算法,创建了多个决策树。这个集成中的每个决策树都是变量随机排序的结果。这带我们来到了随机森林是什么——它是众多决策树的集成。
需要注意的是,单个决策树本身对于像鸢尾花数据集这样的较小样本可能工作得不好。这就是随机森林算法介入的地方。它将决策树森林中的所有预测汇集在一起。这个森林中所有单个决策树的汇总结果将形成一个集成,更广为人知的是随机森林。
我们选择随机森林方法来做出预测,这有一个很好的理由。由预测集成的网络预测显著更准确。
在下一节中,我们将制定我们的分类问题,在随后的Spark 入门部分,将给出项目的实现细节。
项目概述 – 问题表述
本项目的目的是开发一个机器学习工作流程,或者更准确地说,是一个管道。目标是解决数据科学历史上最著名的分类问题。
如果我们在野外看到一朵我们知道属于三种鸢尾花物种之一的花,我们就面临一个分类问题。如果我们对未知的花进行了测量(X),任务就是学会识别这朵花(及其植物)所属的物种。
分类别变量代表可以分成组的数据类型。类别变量的例子有种族、性别、年龄组和教育水平。尽管后两个变量也可以通过使用年龄和最高完成等级的确切值以数值方式考虑,但通常将此类变量分类到相对较少的组中更有信息量。
常规的类别数据分析通常涉及使用数据表。一个双向表通过计数两个变量中每个组中观察值的数量来呈现类别数据,其中一个变量分为行,另一个变量分为列。
简而言之,分类问题的概述如下:

红 Iris 监督学习分类问题的概述
在 Iris 数据集中,每一行包含第五列中的类别数据(值)。每个这样的值都与一个标签(Y)相关联。
公式包括以下内容:
-
观察到的特征
-
类别标签
观察到的特征也被称为预测变量。这些变量具有预定的测量值。这些是输入 X。另一方面,类别标签表示预测变量可以采取的可能输出值。
预测变量如下:
-
sepal_length:它代表花萼长度,以厘米为单位,用作输入 -
sepal_width:它代表花萼宽度,以厘米为单位,用作输入 -
petal_length:它代表花瓣长度,以厘米为单位,用作输入 -
petal_width:它代表花瓣宽度,以厘米为单位,用作输入 -
setosa:它代表 Iris-setosa,真或假,用作目标 -
versicolour:它代表 Iris-versicolour,真或假,用作目标 -
virginica:它代表 Iris-virginica,真或假,用作目标
从每个样本测量了四个结果变量;花萼和花瓣的长度和宽度。
为了让一切正常工作,项目的总构建时间不应超过一天。对于数据科学领域的新手来说,理解背景理论、设置软件以及构建管道可能需要额外的一天或两天。
开始使用 Spark
指令适用于 Windows 用户。请注意,为了运行 Spark 版本 2 及以上版本,Java 版本 8 及以上版本,Scala 版本 2.11,简单构建工具(SBT)版本至少为 0.13.8 是先决条件。Iris 项目的代码依赖于 Spark 2.3.1,这是撰写本章时的最新发行版。后续章节的实现可能基于 2017 年 2 月 28 日发布的 Spark 2.3.0。Spark 2.3.0 是一个主要更新版本,其中包括对 1400 多个票证的修复。
Spark 2.0 带来了一系列改进。将 dataframe 作为数据的基本抽象的引入就是其中之一。读者会发现 dataframe 抽象及其支持 API 促进了他们的数据科学和分析任务,更不用说这个强大功能在 弹性分布式数据集(RDDs)上的性能改进了。在最新的 Spark 版本中,对 RDD 的支持仍然非常丰富。
设置先决软件
在跳到先决条件之前,关于硬件的一些说明。我在本章中使用的硬件基础设施包括一台 64 位 Windows Dell 8700 机器,运行 Windows 10,配备 Intel(R) Core(TM) i7-4770 CPU @ 3.40 GHz 和 32GB 的内存。
在本小节中,我们记录了在安装 Spark 之前必须准备的三种软件先决条件。
在撰写本文时,我的先决软件设置包括 JDK 8、Scala 2.11.12 和 SBT 0.13.8。以下列表是一个最小、推荐的设置(请注意,你可以尝试更高版本的 JDK 8 和 Scala 2.12.x)。
这里是本章所需的先决条件列表:
-
Java SE 开发工具包 8
-
Scala 2.11.12
-
SBT 0.13.8 或更高版本
如果你像我一样,只为了发展自己的 Spark 大数据生态系统而专门准备一个完整的箱子,这并不是一个坏主意。考虑到这一点,从一台合适的机器(有足够的空间和至少 8GB 的内存)开始,运行你偏好的操作系统,并按照顺序安装前面提到的先决条件。你可能会问,JDK 的较低版本如何?确实,JDK 的较低版本与 Spark 2.3.1 不兼容。
虽然我不会在这里详细介绍 JDK 的安装过程,但这里有一些注意事项。下载 Java 8(www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html),一旦安装程序完成安装Java文件夹,不要忘记设置两个新的系统环境变量——JAVA_HOME环境变量指向你的 Java 安装根目录,以及JAVA_HOME/bin在你的系统路径环境变量中。
在设置系统JAVA_HOME环境后,以下是如何通过在命令行上列出JAVA_HOME的值来快速进行 sanity check 的方法:
C:\Users\Ilango\Documents\Packt-Book-Writing-Project\DevProjects\Chapter1>echo %JAVA_HOME%
C:\Program Files\Java\jdk1.8.0_102
现在剩下的就是再进行一次快速检查,以确保你完美地安装了 JDK。在你的命令行或终端中运行以下命令:
注意,这个屏幕只代表 Windows 命令行:
C:\Users\Ilango\Documents\Packt\DevProjects\Chapter1>java -version
java version "1.8.0_131"
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)
C:\Users\Ilango\Documents\Packt\DevProjects\Chapter1>javac -version
javac 1.8.0_102
到目前为止,如果你的 sanity checks 通过了,下一步就是安装 Scala。以下简要步骤概述了该过程。Scala 下载页面在archive.ics.uci.edu/ml/datasets/iris上记录了许多安装 Scala 的方法(针对不同的操作系统环境)。然而,我们只列出了三种安装 Scala 的方法。
在深入 Scala 安装之前,这里做一个简要说明。虽然 Scala 的最新稳定版本是 2.12.4,但我更喜欢一个稍微旧一点的版本,即 2.11.12,这是我在本章中将使用的版本。您可以从scala-lang.org/download/2.11.12.html下载它。无论您更喜欢 2.12 还是 2.11 版本,选择权在您手中,只要版本不是低于 2.11.x 的任何版本。以下列出的安装方法将帮助您开始这一过程。
Scala 可以通过以下方法进行安装:
-
安装 Scala:在
scala-lang.org/download/中找到标题为“其他安装 Scala 方法”的部分,并从那里下载 Scala 二进制文件。然后您可以根据scala-lang.org/download/install.html中的说明安装 Scala。从www.scala-sbt.org/download.html安装 SBT,并按照www.scala-sbt.org/1.0/docs/Setup.html中的设置说明进行操作。 -
在 IntelliJ IDE 中使用 Scala:有关说明请参阅
docs.scala-lang.org/getting-started-intellij-track/getting-started-with-scala-in-intellij.html。 -
在 IntelliJ IDE 中使用 SBT 的 Scala:这是另一种方便地使用 Scala 的方法。有关说明请参阅
docs.scala-lang.org/getting-started-intellij-track/getting-started-with-scala-in-intellij.html。
在前面列表中刚刚出现的缩写SBT代表Simple Build Tool。确实,您会在本书的很多地方遇到对 SBT 的引用。
从前面列表的第一种方法中选取一个项目,并按照(主要自解释的)说明进行操作。最后,如果您忘记设置环境变量,请设置一个新的SCALA_HOME系统环境变量(类似于JAVA_HOME),或者简单地更新现有的SCALA_HOME。当然,SCALA_HOME/bin条目被添加到路径环境变量中。
您不一定需要在系统范围内安装 Scala。无论如何,SBT 环境都为我们提供了访问其自己的 Scala 环境。然而,拥有系统范围内的 Scala 安装可以让您快速实现 Scala 代码,而不是启动整个 SBT 项目。
让我们回顾一下到目前为止我们已经完成的事情。我们通过使用 Scala 安装的第一种方法安装了 Scala。
为了确认我们已经安装了 Scala,让我们运行一个基本的测试:
C:\Users\Ilango\Documents\Packt\DevProjects\Chapter1>scala -version
Scala code runner version 2.11.12 -- Copyright 2002-2017, LAMP/EPFL
上述代码列表确认我们的最基本 Scala 安装没有问题。这为系统范围内的 SBT 安装铺平了道路。再次强调,这归结为设置SBT_HOME系统环境变量,并将$SBT_HOME/bin设置在路径中。这是最基本的桥梁。接下来,让我们运行一个检查来验证 SBT 是否已正确设置。打开一个命令行窗口或终端。我们安装了 SBT 0.13.17,如下所示:
C:\Users\Ilango\Documents\Packt\DevProjects\Chapter1>sbt sbtVersion
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=256m; support was removed in 8.0
[info] Loading project definition from C:\Users\Ilango\Documents\Packt\DevProjects\Chapter1\project
[info] Set current project to Chapter1 (in build file:/C:/Users/Ilango/Documents/Packt/DevProjects/Chapter1/)
[info] 0.13.17
我们剩下的是第二种和第三种方法。这些方法留给读者作为练习。第三种方法将使我们能够利用 IDE 如 IntelliJ 的所有优秀功能。
简而言之,我们在开发管道时采取的方法是,将现有的 SBT 项目导入 IntelliJ,或者我们直接在 IntelliJ 中创建 SBT 项目。
接下来是什么?当然是 Spark 的安装。在即将到来的章节中详细了解。
以独立部署模式安装 Spark
在本节中,我们以独立部署模式设置 Spark 开发环境。要快速开始使用 Spark 并进行开发,Spark 的 shell 是最佳选择。
Spark 支持 Scala、Python、R 和 Java,并提供相应的 API。
Spark 二进制文件下载为开发者提供了两个组件:
-
Spark 的 shell
-
一个独立集群
一旦下载并解压二进制文件(后续将提供说明),Spark shell 和独立 Scala 应用程序将允许您在独立集群模式下启动一个独立集群。
这个集群是自包含且私有的,因为它仅位于一台机器上。Spark shell 允许您轻松配置这个独立集群。它不仅为您提供快速访问交互式 Scala shell,还允许您在 Scala shell 中开发可以部署到集群中的应用程序(给它命名为独立部署模式),直接在 Scala shell 中进行。
在这种模式下,集群的驱动节点和工作节点位于同一台机器上,更不用说我们的 Spark 应用程序默认会占用该机器上所有可用的核心。使所有这一切成为可能的重要特性是交互式(Spark)Scala shell。
Spark 2.3 是最新版本。它包含 1400 多个修复。在 Java 8 上安装 Spark 2.3 可能是我们在第二章“利用 Spark 和 Scala 的力量构建乳腺癌预后管道”开始下一个项目之前要做的第一件事。
不再拖延,让我们开始设置以独立部署模式运行 Spark。以下步骤很有帮助:
-
系统检查:首先确保您至少有 8 GB 的内存,并至少保留 75%的内存供 Spark 使用。我的系统有 32 GB。一旦系统检查通过,请从这里下载 Spark 2.3.1 的二进制文件:
spark.apache.org/downloads.html。 -
你需要一个能够提取
.tar.gz和.gz存档的解压缩工具,因为 Windows 对这些存档没有原生支持。7-Zip 是适合这个任务的程序。你可以从7-zip.org/download.html获取它。 -
选择为 Apache Hadoop 2.7 及以后版本预先构建的包类型,并下载
spark--2.2.1-bin-hadoop2.7.tgz。 -
将软件包解压到方便的地方,这将成为你的 Spark 根文件夹。例如,我的 Spark 根文件夹是:
C:\spark-2.2.1-bin-hadoop2.7。 -
现在,设置环境变量
SPARK_HOME指向 Spark 根文件夹。我们还需要在PATH变量中添加一个路径条目,指向SPARK_HOME/bin。 -
接下来,设置环境变量
HADOOP_HOME,例如设置为C:\Hadoop,并为 Spark 创建一个新的路径条目,指向 Spark 主目录的bin文件夹。现在,像这样启动spark-shell:
spark-shell --master local[2]
接下来发生的事情可能会让 Windows 用户感到沮丧。如果你是这些用户之一,你将遇到以下错误。以下截图是此问题的表示:

Windows 上的错误信息
为了解决这个问题,你可以按照以下步骤进行:
-
创建一个新的文件夹,命名为
C\tmp\hive。 -
然后,从这里获取缺失的
WINUTILS.exe二进制文件:github.com/steveloughran/winutils。将其放入C\Hadoop\bin。
前面的步骤 2 是必要的,因为 Spark 下载不包含运行 Hadoop 所需的WINUTILS.exe。那么,这就是java.io.IOException的来源。
在管理员模式下打开命令提示符窗口,并像这样执行新下载的WINUTILS.EXE:
winutils.exe chmod -R 777 C:\tmp\hive
接下来,发出spark-shell命令。这一次,Spark 的交互式开发环境正常启动,分别启动自己的SparkContext实例sc和SparkSession会话。虽然sc功能是访问底层本地独立集群的强大入口点,但spark是 Spark 数据处理 API 的主要入口点。
以下是从spark-shell命令输出的内容。SparkContext作为sc提供给你,Spark 会话作为spark提供给你:
C:\Users\Ilango\Documents\Packt\DevProjects\Chapter1>spark-shell --master local[2]
Spark context Web UI available at http://192.168.56.1:4040
Spark context available as 'sc' (master = local[2], app id = local-1520484594646).
Spark session available as 'spark'.
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 2.2.1
/_/
Using Scala version 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_102)
Type in expressions to have them evaluated.
Type :help for more information.
scala>
在前面显示的spark-shell启动中,local[2]选项让我们能够使用2个线程在本地运行 Spark。
在深入本节下一个主题之前,了解以下 Spark shell 开发环境特性是很有好处的,这些特性使得开发和数据分析成为可能:
-
SparkSession -
SparkBuilder -
SparkContext -
SparkConf
SparkSession API (spark.apache.org/docs/2.2.1/api/scala/index.html#org.apache.spark.sql.SparkSession) 将 SparkSession 描述为程序访问入口点,分别用于 Spark 的数据集和 dataframe API。
什么是 SparkBuilder?SparkBuilder 伴生对象包含一个 builder 方法,当调用它时,允许我们检索现有的 SparkSession 或甚至创建一个。我们现在将按照以下两步过程获取我们的 SparkSession 实例:
-
导入
SparkSession类。 -
在生成的
builder上调用getOrCreate方法来调用builder方法:
scala> import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.SparkSession
scala> lazy val session: SparkSession = SparkSession.builder().getOrCreate()
res7: org.apache.spark.sql.SparkSession = org.apache.spark.sql.SparkSession@6f68756d
SparkContext API (spark.apache.org/docs/2.2.1/api/scala/index.html#org.apache.spark.SparkContext) 将 SparkContext 描述为设置或配置 Spark 集群属性(RDD、累加器、广播变量等等)的第一线入口点,这些属性影响集群的功能。这种配置发生的一种方式是通过将 SparkConf 实例作为 SparkContext 构造函数参数传递。每个 JVM 实例存在一个 SparkContext。
在某种意义上,SparkContext 也是 Spark 驱动应用程序通过例如 Hadoop 的 Yarn ResourceManager (RM) 连接到集群的方式。
现在我们来检查我们的 Spark 环境。我们将首先启动 Spark shell。也就是说,典型的 Spark shell 交互式环境屏幕有一个可用的 SparkSession 作为 spark,我们试图在以下代码块中读取其值:
scala> spark
res21: org.apache.spark.sql.SparkSession = org.apache.spark.sql.SparkSession@6f68756d
Spark shell 也拥有自己的 SparkContext 实例 sc,它与 SparkSession spark 相关联。在下面的代码中,sc 返回 SparkContext:
scala> sc
res5: org.apache.spark.SparkContext = org.apache.spark.SparkContext@553ce348
sc 可以做更多。在下面的代码中,在 sc 上调用 version 方法会给我们显示我们集群中运行的 Spark 的版本:
scala> sc.version
res2: String = 2.2.1
scala> spark
res3: org.apache.spark.sql.SparkSession = org.apache.spark.sql.SparkSession@6f68756d
由于 sc 代表与 Spark 集群的连接,它包含一个称为 SparkConf 的特殊对象,该对象以 Array 形式持有集群配置属性。在 SparkContext 上调用 getConf 方法会得到 SparkConf,其 getAll 方法(如下所示)会得到一个包含集群(或连接)属性的 Array,如下面的代码所示:
scala> sc.getConf.getAll
res17: Array[(String, String)] = Array((spark.driver.port,51576), (spark.debug.maxToStringFields,25), (spark.jars,""), (spark.repl.class.outputDir,C:\Users\Ilango\AppData\Local\Temp\spark-47fee33b-4c60-49d0-93aa-3e3242bee7a3\repl-e5a1acbd-6eb9-4183-8c10-656ac22f71c2), (spark.executor.id,driver), (spark.submit.deployMode,client), (spark.driver.host,192.168.56.1), (spark.app.id,local-1520484594646), (spark.master,local[2]), (spark.home,C:\spark-2.2.1-bin-hadoop2.7\bin\..))
在 Spark shell 中可能有对 sqlContext 和 sqlContext.implicits._ 的引用。sqlContext 是什么?截至 Spark 2 及其之前的版本,sqlContext 已被弃用,SparkSession.builder 被用来返回一个 SparkSession 实例,我们重申这是使用数据集和 dataframe API 编程 Spark 的入口点。因此,我们将忽略那些 sqlContext 实例,而专注于 SparkSession。
注意,spark.app.name默认名称为spark-shell。让我们将app-name属性赋予一个不同的名称,即Iris-Pipeline。我们通过调用setAppName方法并传递新的应用程序名称来实现这一点,如下所示:
scala> sc.getConf.setAppName("Iris-Pipeline")
res22: org.apache.spark.SparkConf = org.apache.spark.SparkConf@e8ce5b1
为了检查配置更改是否生效,让我们再次调用getAll方法。以下输出应反映这一更改。它简单地说明了如何使用SparkContext来修改我们的集群环境:
scala> sc.conf.getAll
res20: Array[(String, String)] = Array((spark.driver.port,51576), (spark.app.name,Spark shell), (spark.sql.catalogImplementation,hive), (spark.repl.class.uri,spark://192.168.56.1:51576/classes), (spark.debug.maxToStringFields,150), (spark.jars,""), (spark.repl.class.outputDir,C:\Users\Ilango\AppData\Local\Temp\spark-47fee33b-4c60-49d0-93aa-3e3242bee7a3\repl-e5a1acbd-6eb9-4183-8c10-656ac22f71c2), (spark.executor.id,driver), (spark.submit.deployMode,client), (spark.driver.host,192.168.56.1), (spark.app.id,local-1520484594646), (spark.master,local[2]), (spark.home,C:\spark-2.2.1-bin-hadoop2.7\bin\..))
spark.app.name属性刚刚更新了其值。在下一节中,我们的目标是使用spark-shell以交互式方式分析数据。
开发一个简单的交互式数据分析工具
我们将在 Spark 壳的交互式 Scala 壳中开发一个简单的 Scala 程序。我们将重申我们的目标,即我们希望能够交互式地分析数据。这个数据集——一个名为iris.csv的外部逗号分隔值(CSV)文件——位于从spark-shell启动的同一文件夹中。
这个程序,也可以在常规 Scala Read Eval Print Loop (REPL)壳中编写,读取文件并打印其内容,完成数据分析任务。然而,这里重要的是 Spark 壳的灵活性,它还允许你编写 Scala 代码,以便你能够轻松地将数据与各种 Spark API 连接,并以某种有用的方式推导出抽象,如 dataframes 或 RDDs。关于DataFrame和Dataset的更多内容将在后面介绍:

使用 source 读取 iris.csv
在前面的程序中,没有发生什么特别的事情。我们正在尝试使用Source类读取名为iris.csv的文件。我们从scala.io包中导入Source.scala文件,然后创建一个名为DataReader的对象和其内部的main方法。在main方法内部,我们调用伴随对象Source的fromFile方法。fromFile方法接受数据集文件路径的字符串表示作为参数,并返回一个BufferedSource实例,我们将它分配给一个名为datasrc的val。顺便说一下,Source的 API 可以在www.scala-lang.org/api/current/scala/io/Source.html找到。
在BufferedSource处理句柄上,我们随后调用了getLines方法,该方法返回一个迭代器,然后调用foreach,它会打印出iris.csv中的所有行,但不包括换行符。我们将所有这些代码包裹在try、catch和finally中。finally构造存在的原因与我们需要在文件处理完毕后关闭BufferedSource实例datasrc有关。
初始时,我们遇到了FileNotFoundException异常,因为数据集文件iris.csv没有找到。然后将 CSV 文件放入,运行程序,输出结果就是我们预期的。
这并不难。在下一个子节中,目标是读取我们的iris.csv文件,并从中派生出Dataset或DataFrame。
读取数据文件并从中派生出 DataFrame
Spark API 对于spark.apache.org/docs/2.2.1/api/scala/index.html#org.apache.spark.sql.Dataset的说明是,DataFrame是Dataset[Row],而Dataset包含一个名为DataFrame的视图。根据 Spark 文档中对Dataset的描述,我们可以将Dataset重新定义为 Spark 对分布式集合的抽象,这些集合包含数据项。也就是说,Dataset[Row]包含行。Row可能是一个抽象,表示来自原始文件数据集的行。
我们需要读取iris.csv文件并将其转换为DataFrame。这就是本小节的目标,我们很快就会实现这一点。
考虑到所有这些,让我们开始构建DataFrame。我们首先在spark,我们的SparkSession上调用read方法:
scala> val dfReader1 = spark.read
dfReader1: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@66df362c
read()调用生成了DataFrameReader dfReader1,根据spark.apache.org/docs/2.2.1/api/scala/index.html#org.apache.spark.sql.DataFrameReader的说明,这是一个从外部存储系统中加载数据集的接口。
接下来,我们将通知 Spark 我们的数据是 CSV 格式。这是通过调用带有com.databricks.spark.csv参数的format方法来完成的,Spark 可以识别这个参数:
scala> val dfReader2 = dfReader1.format("com.databricks.spark.csv")
dfReader2: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@66df362c
format方法只是再次返回了DataFrameReader。iris.csv文件包含header。我们可以将其指定为输入option:
scala> val dfReader3 = dfReader2.option("header", true)
dfReader3: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@66df362c
这返回了我们熟悉的DataFrameReader。
接下来,我们需要一种方法来识别为我们识别的模式。再次调用option方法,使用键inferSchema和值为true,让 Spark 自动为我们推断模式:
scala> val dfReader4 = dfReader3.option("inferSchema",true)
dfReader4: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@66df362c
现在我们来加载我们的输入:
scala> val dFrame = dfReader4.load("iris.csv")
dFrame: org.apache.spark.sql.DataFrame = [Id: int, SepalLengthCm: double ... 4 more fields]
DataFrameReader将我们的输入 CSV 转换成了DataFrame!这正是我们一开始设定的目标。
DataFrame简单地说就是Dataset的无类型视图,表示为type DataFrame = Dataset[Row]。
由于我们的DataFrame是Dataset[Row]的视图,所以Dataset上的所有方法都是可用的。
目前,我们想看看这个数据集中有什么。原始文件中有 150 列。因此,我们希望 Spark:
-
返回数据集中的行数
-
显示数据集的前 20 行
接下来,我们将调用count方法。我们想要再次确认数据集中包含的行数:
scala> dFrame.count
res1: Long = 150
我们刚刚在我们的DataFrame上调用了count方法。它返回了数字150,这是正确的。
接下来,我们将本节中开发的全部代码合并成一行代码:
scala> val irisDataFrame = spark.read.format("com.databricks.spark.csv").option("header",true).option("inferSchema", true).load("iris.csv").show
我们刚刚创建了DataFrame irisDataFrame。如果您想查看 DataFrame,只需在它上面调用show方法。这将返回 irisDataFrame 的前 20 行DataFrame:

Iris 数据集的前 20 行
在这一点上,输入 :quit 或 Ctrl + D 以退出 Spark shell。这总结了本节内容,但为下一节打开了过渡,我们将把事情提升到下一个层次。我们不会依赖于 spark-shell 来开发更大的程序,而是将在 SBT 项目中创建我们的 Iris 预测管道程序。这是下一节的重点。
实现 Iris 管道
在本节中,我们将阐述我们的管道实现目标。我们将随着每个实现步骤的进行记录可衡量的结果。
在我们实现 Iris 管道之前,我们想要从概念和实践的角度理解管道是什么。因此,我们将管道定义为具有多个管道阶段以一定顺序运行的 DataFrame 处理工作流程。
DataFrame 是 Spark 的一种抽象,它提供了一个 API。这个 API 允许我们处理对象集合。从高层次来看,它代表了一个分布式集合,包含数据行,类似于关系数据库表。在这个 DataFrame 中,每一行成员(例如,花瓣宽度测量值)都隶属于一个名为花瓣宽度的命名列。
管道中的每个阶段都是一个算法,它要么是 Transformer,要么是 Estimator。当 DataFrame 或 DataFrame(s) 流经管道时,存在两种类型的阶段(算法):
-
Transformer阶段:这涉及一个转换动作,将一个DataFrame转换为另一个DataFrame -
Estimator阶段:这涉及在DataFrame上执行训练动作,产生另一个DataFrame。
总结来说,管道是一个单一单元,需要阶段,但包括参数和 DataFrame(s)。整个管道结构如下所示:
-
Transformer -
Estimator -
Parameters(超参数或其他) -
DataFrame
这就是 Spark 发挥作用的地方。它的 MLlib 库提供了一套管道 API,允许开发者访问多个算法,并促进它们组合成一个有序阶段的单一管道,就像芭蕾舞中的编排动作序列。在本章中,我们将使用随机森林分类器。
我们已经涵盖了管道的基本概念。这些实用性将帮助我们进入下一部分,我们将列出实现目标。
Iris 管道实现目标
在列出实现目标之前,我们将为我们的管道制定一个架构。下面展示的是两个表示机器学习工作流程(管道)的图表。
以下图表共同帮助理解这个项目的不同组件。话虽如此,这个管道涉及训练(拟合)、转换和验证操作。训练了多个模型,并选择最佳模型(或映射函数)以提供准确预测 Iris 花种类的近似值(基于这些花的测量值):

项目框图
项目框图分解如下:
-
Spark,代表 Spark 集群及其生态系统
-
训练数据集
-
模型
-
数据集属性或特征测量
-
一个推理过程,生成预测列
下图详细描述了不同阶段的功能,我们将稍后通过其构成阶段来可视化管道:
目前,该图描绘了四个阶段,从数据预处理阶段开始,这个阶段被故意视为与编号阶段分开。将管道视为两步过程:
-
数据清洗阶段或预处理阶段。一个重要的阶段,可能包括探索性数据分析(EDA)的子阶段(在后面的图中没有明确表示)。
-
一个从特征提取开始的数据分析阶段,接着是模型拟合,然后是模型验证,最后将 Uber 管道 JAR 部署到 Spark 中:

管道图
参考前面的图,第一个实现目标是设置 Spark 在 SBT 项目中。SBT 项目是一个自包含的应用程序,我们可以在命令行上运行它来预测 Iris 标签。在 SBT 项目中,依赖关系在build.sbt文件中指定,并且我们的应用程序代码将创建自己的SparkSession和SparkContext。
因此,我们列出了以下实现目标:
-
从 UCI 机器学习仓库获取 Iris 数据集
-
在 Spark shell 中进行初步 EDA
-
在 IntelliJ 中创建一个新的 Scala 项目,并执行所有实现步骤,直到评估随机森林分类器
-
将应用程序部署到您的本地 Spark 集群
第 1 步 – 从 UCI 机器学习仓库获取 Iris 数据集
访问 UCI 机器学习仓库网站archive.ics.uci.edu/ml/datasets/iris,点击下载:数据文件夹。将此文件夹提取到方便的位置,并将iris.csv复制到项目文件夹的根目录。
您可以参考项目概述以深入了解 Iris 数据集的描述。我们在此展示iris.csv文件的内容,如下所示:

Iris 数据集的快照,包含 150 个数据集
您可能还记得iris.csv文件是一个 150 行的文件,包含逗号分隔的值。
现在我们有了数据集,第一步将是对其进行 EDA。Iris 数据集是多变量的,这意味着有多个(独立)变量,因此我们将对其进行基本的多元 EDA。但我们需要DataFrame来实现这一点。如何在 EDA 之前创建 DataFrame 是下一节的目标。
第 2 步 – 初步 EDA
在我们着手构建 SBT 管道项目之前,我们将在spark-shell中进行初步的 EDA。计划是从数据集中导出一个 dataframe,然后对其计算基本统计数据。
我们手头有三个spark-shell任务:
-
启动
spark-shell -
加载
iris.csv文件并构建DataFrame -
计算统计数据
然后,我们将这段代码移植到我们的 SBT 项目中的一个 Scala 文件中。
话虽如此,让我们开始加载iris.csv文件(输入数据源),在最终构建DataFrame之前。
启动 Spark shell
通过在命令行中输入以下命令来启动 Spark Shell。
spark-shell --master local[2]
在下一步中,我们从可用的 Spark 会话spark开始。spark将是我们的 Spark 编程的入口点。它还包含连接到我们的 Spark(本地)集群所需的属性。有了这些信息,我们的下一个目标是加载 iris.csv 文件并生成一个 DataFrame。
加载 iris.csv 文件并构建 DataFrame
加载 iris csv 文件的第一步是在spark上调用read方法。read方法返回DataFrameReader,可以用来读取我们的数据集:
val dfReader1 = spark.read
dfReader1: org.apache.spark.sql.DataFrameReader=org.apache.spark.sql.DataFrameReader@6980d3b3
dfReader1是org.apache.spark.sql.DataFrameReader类型。在dfReader1上调用 Spark 的com.databricks.spark.csv CSV 格式指定字符串的format方法会再次返回DataFrameReader:
val dfReader2 = dfReader1.format("com.databricks.spark.csv")
dfReader2: org.apache.spark.sql.DataFrameReader=org.apache.spark.sql.DataFrameReader@6980d3b3
最终,iris.csv是一个 CSV 文件。
不言而喻,dfReader1和dfReader2是同一个DataFrameReader实例。
到目前为止,DataFrameReader需要一个以键值对形式存在的输入数据源option。使用两个参数调用option方法,一个字符串类型的键"header"和其布尔类型的值true:
val dfReader3 = dfReader2.option("header", true)
在下一步中,我们再次使用参数inferSchema和true值调用option方法:
val dfReader4 = dfReader3.option("inferSchema", true)
inferSchema在这里做什么?我们只是简单地告诉 Spark 为我们猜测输入数据源的架构。
到目前为止,我们一直在准备DataFrameReader以加载iris.csv。外部数据源需要为 Spark 提供一个路径,以便DataFrameReader可以加载数据并输出DataFrame。
现在是时候在DataFrameReader dfReader4上调用load方法了。将 Iris 数据集文件的路径传递给load方法。在这种情况下,文件位于项目文件夹的根目录下:
val dFrame1 = dfReader4.load("iris.csv")
dFrame1: org.apache.spark.sql.DataFrame = [Id: int, SepalLengthCm: double ... 4 more fields]
就这样。我们现在有了DataFrame!
计算统计数据
在这个DataFrame上调用describe方法应该会导致 Spark 对DataFrame的每一列执行基本统计分析:
dFrame1.describe("Id","SepalLengthCm","SepalWidthCm","PetalLengthCm","PetalWidthCm","Species")
WARN Utils: Truncated the string representation of a plan since it was too large. This behavior can be adjusted by setting 'spark.debug.maxToStringFields' in SparkEnv.conf.
res16: org.apache.spark.sql.DataFrame = [summary: string, Id: string ... 5 more fields]
让我们修复前面代码块中描述的WARN.Utils问题。修复方法是找到位于SPARK_HOME/conf下的文件spark-defaults-template.sh并将其保存为spark-defaults.sh。
在此文件的底部添加一个条目spark.debug.maxToStringFields。以下截图说明了这一点:

在spark-defaults.sh中修复 WARN Utils 问题
保存文件并重新启动spark-shell。
现在,再次检查更新的 Spark 配置。我们在 spark-defaults.sh 文件中更新了 spark.debug.maxToStringFields 的值。这个更改旨在解决 Spark 报告的截断问题。我们将立即确认我们所做的更改导致 Spark 也更新了其配置。这很容易通过检查 SparkConf 来完成。
再次检查 SparkConf
如前所述,调用 getConf 返回存储配置值的 SparkContext 实例。在该实例上调用 getAll 返回一个配置值的 Array。其中之一是 spark.debug.maxToStringFields 的更新值:
sc.getConf.getAll
res4: Array[(String, String)] = Array((spark.repl.class.outputDir,C:\Users\Ilango\AppData\Local\Temp\spark-10e24781-9aa8-495c-a8cc-afe121f8252a\repl-c8ccc3f3-62ee-46c7-a1f8-d458019fa05f), (spark.app.name,Spark shell), (spark.sql.catalogImplementation,hive), (spark.driver.port,58009), (spark.debug.maxToStringFields,150),
spark.debug.maxToStringFields 的更新值现在是 150。
在名为 Utils 的私有对象内部,spark.debug.maxToStringFields 有一个默认值 25。
再次计算统计数据
在 dataframe 的 describe 方法上运行 invoke 并传递列名:
val dFrame2 = dFrame1.describe("Id","SepalLengthCm","SepalWidthCm","PetalLengthCm","PetalWidthCm","Species"
)
dFrame2: org.apache.spark.sql.DataFrame = [summary: string, Id: string ... 5 more fields]
在 DataFrame dfReader 的 describe 方法上调用 invoke 会得到一个我们称之为 dFrame2 的转换后的 DataFrame。在 dFrame2 上,我们调用 show 方法以返回一个统计结果表。这完成了基本但重要的 EDA 的第一阶段:
val dFrame2Display= = dfReader2.show
统计分析的结果显示在下述截图:

统计分析结果
我们做了所有这些额外的工作只是为了展示单独的数据读取、加载和转换阶段。接下来,我们将所有之前的工作封装在一行代码中:
val dfReader = spark.read.format("com.databricks.spark.csv").option("header",true).option("inferSchema",true).load("iris.csv")
dfReader: org.apache.spark.sql.DataFrame = [Id: int, SepalLengthCm: double ... 4 more fields]
这就完成了在 spark-shell 上的 EDA。在下一节中,我们将采取实施、构建(使用 SBT)、部署(使用 spark-submit)和执行我们的 Spark 管道应用程序的步骤。我们首先创建一个 SBT 项目的框架。
第 3 步 – 创建 SBT 项目
在你选择的文件夹中布局你的 SBT 项目,并命名为 IrisPipeline 或任何对你有意义的名称。这将包含我们实现和运行 Iris 数据集上管道所需的所有文件。
我们 SBT 项目的结构如下所示:

项目结构
我们将在 build.sbt 文件中列出依赖项。这将是一个 SBT 项目。因此,我们将引入以下关键库:
-
Spark 核心组件
-
Spark MLlib
-
Spark SQL
下述截图展示了 build.sbt 文件:

包含 Spark 依赖项的 build.sbt 文件
前一快照中引用的 build.sbt 文件在书的下载包中 readily 可用。在 ModernScalaProjects_Code 下的 Chapter01 代码文件夹中深入挖掘,并将文件夹复制到你的电脑上的一个方便位置。
将在 第 1 步 - 从 UCI 机器学习仓库获取 Iris 数据集 中下载的 iris.csv 文件删除到我们新 SBT 项目的根目录中。请参考之前的截图,其中显示了包含 iris.csv 文件的更新后的项目结构。
第 4 步 - 在 SBT 项目中创建 Scala 文件
第 4 步分解为以下步骤:
-
在
com.packt.modern.chapter1包中创建一个名为iris.scala的 Scala 文件。 -
到目前为止,我们依赖于
SparkSession和SparkContext,这是spark-shell给我们的。这次,我们需要创建SparkSession,它反过来会给我们SparkContext。
接下来是如何在 iris.scala 文件中布局代码。
在 iris.scala 中,在包声明之后,放置以下 import 语句:
import org.apache.spark.sql.SparkSession
在一个名为 IrisWrapper 的特质内部创建 SparkSession:
lazy val session: SparkSession = SparkSession.builder().getOrCreate()
只有一个 SparkSession 被提供给所有继承自 IrisWrapper 的类。创建一个 val 来保存 iris.csv 文件路径:
val dataSetPath = "<<path to folder containing your iris.csv file>>\\iris.csv"
创建一个构建 DataFrame 的方法。此方法接受 Iris 数据集完整路径作为 String 并返回 DataFrame:
def buildDataFrame(dataSet: String): DataFrame = {
/*
The following is an example of a dataSet parameter string: "C:\\Your\\Path\\To\\iris.csv"
*/
通过更新 SparkSession 的先前 import 语句来导入 DataFrame 类:
import org.apache.spark.sql.{DataFrame, SparkSession}
在 buildDataFrame 函数内部创建一个嵌套函数来处理原始数据集。将此函数命名为 getRows。getRows 函数不接受任何参数,但返回 Array[(Vector, String)]。SparkContext 变量的 textFile 方法将 iris.csv 处理成 RDD[String]:
val result1: Array[String] = session.sparkContext.textFile(<<path to iris.csv represented by the dataSetPath variable>>)
结果 RDD 包含两个分区。每个分区反过来包含由换行符 '\n' 分隔的字符串行。RDD 中的每一行代表原始数据中的对应行。
在下一步中,我们将尝试几个数据转换步骤。我们首先在 RDD 上应用 flatMap 操作,最终创建 DataFrame。DataFrame 是 Dataset 的一个视图,而 Dataset 正好是 Spark 2.0 线中的基本数据抽象单元。
第 5 步 - 预处理、数据转换和 DataFrame 创建
我们将通过传递一个函数块给它并按以下顺序进行连续转换来开始,最终得到 Array[(org.apache.spark.ml.linalg.Vector, String)]。一个向量代表特征测量的行。
给出 Array[(org.apache.spark.ml.linalg.Vector, String)] 的 Scala 代码如下:
//Each line in the RDD is a row in the Dataset represented by a String, which we can 'split' along the new //line character
val result2: RDD[String] = result1.flatMap { partition => partition.split("\n").toList }
//the second transformation operation involves a split inside of each line in the dataset where there is a //comma separating each element of that line
val result3: RDD[Array[String]] = result2.map(_.split(","))
接下来,删除 header 列,但在删除之前先进行一个返回 Array[Array[String]] 的收集:
val result4: Array[Array[String]] = result3.collect.drop(1)
标题列已消失;现在导入 Vectors 类:
import org.apache.spark.ml.linalg.Vectors
现在,将 Array[Array[String]] 转换为 Array[(Vector, String)]:
val result5 = result4.map(row => (Vectors.dense(row(1).toDouble, row(2).toDouble, row(3).toDouble, row(4).toDouble),row(5)))
剩下的最后一步是创建一个最终的 DataFrame
DataFrame 创建
现在,我们使用参数 getRows 调用 createDataFrame 方法。这将返回包含 featureVector 和 speciesLabel(例如,Iris-setosa)的 DataFrame:
val dataFrame = spark.createDataFrame(result5).toDF(featureVector, speciesLabel)
显示新 DataFrame 的前 20 行:
dataFrame.show
+--------------------+-------------------------+
|iris-features-column|iris-species-label-column|
+--------------------+-------------------------+
| [5.1,3.5,1.4,0.2]| Iris-setosa|
| [4.9,3.0,1.4,0.2]| Iris-setosa|
| [4.7,3.2,1.3,0.2]| Iris-setosa|
.....................
.....................
+--------------------+-------------------------+
only showing top 20 rows
我们需要通过将 Iris-setosa、Iris-virginica 和 Iris-versicolor 这些字符串转换为双精度值来索引物种标签列。我们将使用 StringIndexer 来完成这个任务。
现在创建一个名为 IrisPipeline.scala 的文件。
创建一个名为 IrisPipeline 的对象,它扩展了我们的 IrisWrapper 特性:
object IrisPipeline extends IrisWrapper {
导入 StringIndexer 算法类:
import org.apache.spark.ml.feature.StringIndexer
现在创建一个 StringIndexer 算法实例。StringIndexer 将我们的物种标签列映射到一个索引学习列:
val indexer = new StringIndexer().setInputCol
(irisFeatures_CategoryOrSpecies_IndexedLabel._2).setOutputCol(irisFeatures_CategoryOrSpecies_IndexedLabel._3)
第 6 步 - 创建、训练和测试数据
现在,通过提供一个随机种子来将我们的数据集分成两部分:
val splitDataSet: Array[org.apache.spark.sql.Dataset
[org.apache.spark.sql.Row]] = dataSet.randomSplit(Array(0.85, 0.15), 98765L)
现在,我们的新 splitDataset 包含两个数据集:
-
训练数据集:包含
Array[(Vector, iris-species-label-column: String)]的数据集 -
测试数据集:包含
Array[(Vector, iris-species-label-column: String)]的数据集
确认新数据集的大小为 2:
splitDataset.size
res48: Int = 2
将训练数据集分配给一个变量,trainSet:
val trainDataSet = splitDataSet(0)
trainSet: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [iris-features-column: vector, iris-species-label-column: string]
将测试数据集分配给一个变量,testSet:
val testDataSet = splitDataSet(1)
testSet: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [iris-features-column: vector, iris-species-label-column: string]
计算训练数据集的行数:
trainSet.count
res12: Long = 14
计算测试数据集的行数:
testSet.count
res9: Long = 136
总共有 150 行。
第 7 步 - 创建随机森林分类器
参考第 5 步 - DataFrame 创建。这个 DataFrame 'dataFrame' 包含的列名与该步骤生成的 DataFrame 中的列相对应
创建分类器的第一步是将(超)参数传递给它。一个相当全面的参数列表看起来像这样:
-
从 'dataFrame' 我们需要特征列的名称 - iris-features-column
-
从 'dataFrame' 我们还需要索引标签列的名称 - iris-species-label-column
-
featureSubsetStrategy的sqrt设置 -
每次分割要考虑的特征数(我们有 150 个观察值和四个特征,这将使我们的
max_features值为2) -
杂质设置——值可以是 gini 和熵
-
要训练的树的数量(因为树的数量大于一个,我们设置树的最大深度),这是一个等于节点数量的数字
-
所需的最小特征测量数(样本观察值),也称为每个节点的最小实例数
查看 IrisPipeline.scala 文件以获取这些参数的值。
但这次,我们将采用基于参数组合的穷举网格搜索模型选择过程,其中参数值范围被指定。
创建一个 randomForestClassifier 实例。设置特征和 featureSubsetStrategy:
val randomForestClassifier = new RandomForestClassifier()
.setFeaturesCol(irisFeatures_CategoryOrSpecies_IndexedLabel._1)
.setFeatureSubsetStrategy("sqrt")
开始构建 Pipeline,它有两个阶段,Indexer 和 Classifier:
val irisPipeline = new Pipeline().setStages(ArrayPipelineStage ++ ArrayPipelineStage)
接下来,将分类器上的超参数 num_trees(树的数量)设置为 15,一个 Max_Depth 参数,以及具有两个可能值 gini 和熵的杂质。
构建一个包含所有三个超参数的参数网格:
val finalParamGrid: Array[ParamMap] = gridBuilder3.build()
第 8 步 - 训练随机森林分类器
接下来,我们想要将我们的训练集分成一个验证集和一个训练集:
val validatedTestResults: DataFrame = new TrainValidationSplit()
在这个变量上,设置Seed,设置EstimatorParamMaps,设置Estimator为irisPipeline,并将训练比例设置为0.8:
val validatedTestResults: DataFrame = new TrainValidationSplit().setSeed(1234567L).setEstimator(irisPipeline)
最后,使用我们的训练数据集和测试数据集进行拟合和转换。太好了!现在分类器已经训练好了。在下一步中,我们将应用这个分类器来测试数据。
第 9 步 – 将随机森林分类器应用于测试数据
我们的验证集的目的是能够在模型之间做出选择。我们想要一个评估指标和超参数调整。现在我们将创建一个名为TrainValidationSplit的验证估计器的实例,它将训练集分成一个验证集和一个训练集:
val validatedTestResults.setEvaluator(new MulticlassClassificationEvaluator())
接下来,我们将这个估计量拟合到训练数据集上,以生成一个模型和一个转换器,我们将使用它们来转换我们的测试数据集。最后,我们通过应用一个用于度量的评估器来进行超参数调整的验证。
新的ValidatedTestResults DataFrame应该看起来像这样:
--------+
|iris-features-column|iris-species-column|label| rawPrediction| probability|prediction|
+--------------------+-------------------+-----+--------------------+
| [4.4,3.2,1.3,0.2]| Iris-setosa| 0.0| [40.0,0.0,0.0]| [1.0,0.0,0.0]| 0.0|
| [5.4,3.9,1.3,0.4]| Iris-setosa| 0.0| [40.0,0.0,0.0]| [1.0,0.0,0.0]| 0.0|
| [5.4,3.9,1.7,0.4]| Iris-setosa| 0.0| [40.0,0.0,0.0]| [1.0,0.0,0.0]| 0.0|
让我们通过传递prediction和label列的表达式来返回一个新的数据集:
val validatedTestResultsDataset:DataFrame = validatedTestResults.select("prediction", "label")
在代码行中,我们生成了一个包含两列的新DataFrame:
-
一个输入标签
-
一个预测标签,它与输入标签列中的对应值进行比较
这就带我们到了下一步,一个评估步骤。我们想知道我们的模型表现如何。这就是下一步的目标。
第 10 步 – 评估随机森林分类器
在本节中,我们将测试模型的准确性。我们想知道我们的模型表现如何。任何机器学习过程如果没有对分类器的评估都是不完整的。
话虽如此,我们以两步过程进行评估:
-
评估模型输出
-
传入三个超参数:
val modelOutputAccuracy: Double = new MulticlassClassificationEvaluator()
设置标签列,一个度量名称,预测列label,并使用validatedTestResults数据集调用评估。
注意从modelOutputAccuracy变量中测试数据集上的模型输出结果的准确性。
需要评估的其他指标是预测标签值在'predicted'列中与(索引的)标签列中的实际标签值有多接近。
接下来,我们想要提取指标:
val multiClassMetrics = new MulticlassMetrics(validatedRDD2)
我们的管道产生了预测。与任何预测一样,我们需要保持一定的怀疑态度。自然地,我们想要了解我们的预测过程表现如何。在这方面,算法为我们做了大量的工作。话虽如此,我们在这一步所做的一切都是为了评估。这里谁在受到评估,或者哪种评估值得重复?话虽如此,我们想知道预测值与实际标签值有多接近。为了获得这种知识,我们决定使用MulticlassMetrics类来评估将给我们提供模型性能度量的指标,通过两种方法:
-
准确率
-
加权精度
以下代码行将给出准确率和加权精度的值。首先,我们将创建一个包含准确率和加权精度值的 accuracyMetrics 元组
val accuracyMetrics = (multiClassMetrics.accuracy, multiClassMetrics.weightedPrecision)
获取准确率的值。
val accuracy = accuracyMetrics._1
接下来,获取加权精度的值。
val weightedPrecsion = accuracyMetrics._2
这些指标代表了我们分类器或分类模型的评估结果。在下一步中,我们将以打包的 SBT 应用程序运行应用程序。
第 11 步 – 以 SBT 应用程序运行管道
在你的项目文件夹根目录下,执行sbt console命令,然后在 Scala shell 中导入IrisPipeline对象,然后使用iris参数调用IrisPipeline的main方法:
sbt console
scala>
import com.packt.modern.chapter1.IrisPipeline
IrisPipeline.main(Array("iris")
Accuracy (precision) is 0.9285714285714286 Weighted Precision is: 0.9428571428571428
在下一节中,我们将向您展示如何打包应用程序,使其准备好作为 Uber JAR 部署到 Spark。
第 12 步 – 打包应用程序
在你的 SBT 应用程序的根目录下运行:
sbt package
当 SBT 完成打包后,可以使用spark-submit将 Uber JAR 部署到我们的集群中,但由于我们处于独立部署模式,它将被部署到 [local]:

应用程序 JAR 文件
打包命令创建了一个位于目标文件夹下的 JAR 文件。在下一节中,我们将部署应用程序到 Spark。
第 13 步 – 将管道应用程序提交到 Spark 本地
在应用程序文件夹根目录下,使用类和 JAR 文件路径参数分别执行spark-submit命令。
如果一切顺利,应用程序将执行以下操作:
-
加载数据。
-
执行 EDA。
-
创建训练、测试和验证数据集。
-
创建一个随机森林分类器模型。
-
训练模型。
-
测试模型的准确性。这是最重要的部分——机器学习分类任务。
-
要实现这一点,我们将训练好的随机森林分类器模型应用于测试数据集。这个数据集包含了模型尚未见过的鸢尾花数据。未见数据不过是野外的鸢尾花。
-
将模型应用于测试数据集,结果是对未见(新)花朵物种的预测。
-
最后的部分是管道运行评估过程,这本质上就是检查模型是否报告了正确的物种。
-
最后,管道会报告某个特定特征在鸢尾花中的重要性。事实上,花瓣宽度在执行分类任务时比萼片宽度更重要。
这将带我们来到本章的最后一节。我们将总结我们已经学到的内容。不仅如此,我们还将向读者展示他们将在下一章中学到的东西。
摘要
在本章中,我们实现了一个机器学习工作流程或机器学习管道。该管道将数据分析的几个阶段结合成一个工作流程。我们首先加载数据,然后创建了训练数据和测试数据,对数据集进行了预处理,训练了 RandomForestClassifier 模型,将随机森林分类器应用于测试数据,评估了分类器,并计算了一个过程,展示了每个特征在分类中的重要性。我们实现了在 项目概述 – 问题定义 部分早期设定的目标。
在下一章中,我们将分析 威斯康星州乳腺癌数据集。这个数据集只有分类数据。我们将构建另一个管道,但这次,我们将设置 Hortonworks 开发平台沙盒来开发和部署乳腺癌预测管道。给定一组分类特征变量,这个管道将预测一个给定的样本是良性还是恶性。在当前章节的下一部分和最后一部分,我们将列出一系列问题,以测试您到目前为止所学知识的掌握程度。
问题
以下是一份供您参考的问题列表:
-
你如何理解 EDA?为什么它很重要?
-
为什么我们要创建训练数据和测试数据?
-
为什么我们要索引从 UCI 机器学习仓库中提取的数据?
-
为什么 Iris 数据集如此著名?
-
随机森林分类器的一个强大功能是什么?
-
什么是监督学习与无监督学习相对?
-
简要解释使用训练数据创建我们模型的过程。
-
特征变量与 Iris 数据集有何关系?
-
使用 Spark 编程的入口点是什么?
任务:Iris 数据集问题是一个统计分类问题。创建一个混淆矩阵或错误矩阵,其中行是预测的 setosa、预测的 versicolor 和预测的 virginica,列是实际物种,如 setosa、versicolor 和 virginica。完成这个任务后,解释这个矩阵。
第二章:利用 Spark 和 Scala 的力量构建乳腺癌预后流程
乳腺癌是每年女性死亡的主要原因,使其他人处于各种疾病阶段。最近,机器学习(ML)在医生和研究人员追求更好的结果和降低治疗成本方面显示出巨大的潜力。考虑到这一点,威斯康星乳腺癌 数据集代表了一系列适合的特征,这些特征足以生成机器学习模型,这些模型能够通过学习预先确定的或历史乳腺癌组织样本数据来预测未来的诊断结果。
这里是我们所参考的数据集:
-
UCI 机器学习仓库:乳腺癌威斯康星(原始)数据集
-
UCI 机器学习仓库:乳腺癌威斯康星(诊断)数据集
-
访问日期:2018 年 7 月 13 日
-
网站 URL:
archive.ics.uci.edu/ml/datasets/Breast Cancer Wisconsin (Original)
在本章中,我们将专注于实现和训练一个逻辑回归多类分类器,以预测乳腺癌肿块是否为恶性。威斯康星乳腺癌数据集是一个分类任务。
本章的主要学习目标是能够实现一个 Scala 解决方案,该方案可以预测癌症结果。从 UCI 机器学习仓库 乳腺癌数据集开始,我们将依赖 Spark ML 库的 ML API 和其支持库来构建乳腺癌预测流程。
以下列表是本章各个部分的学习成果的逐节分解:
-
乳腺癌分类问题
-
开始
-
随机森林乳腺癌流程
-
LR 乳腺癌流程
乳腺癌分类问题
目前,监督学习是商业领域中最常见的机器学习问题类别。在 第一章 从鸢尾花数据集预测花的类别 中,我们通过采用一个强大的监督学习分类算法 随机森林 来处理鸢尾花分类任务,其核心依赖于一个分类响应变量。在本章中,除了随机森林方法之外,我们还转向另一种有趣且流行的分类技术,称为 逻辑回归。这两种方法都为乳腺癌预后预测问题提供了一个独特的解决方案,而迭代学习过程是它们的共同点。本章中,逻辑回归技术占据中心舞台,优先于随机森林。然而,两者都是从包含预定测量值的样本测试数据集中学习,并在新的、未见过的数据上计算预测。
在我们进一步进行之前,关于机器学习术语的简要说明。在这个快速发展的领域中,文献有时会看到充斥着来自其他重叠领域的术语,导致不同的看法,尽管两个显然不同的术语指的是同一件事或者从语义上大多是等效的。有时,文献中经常互换使用的两个术语实际上可能相当不同;例如,术语多元和多变量就是这样的术语。在本章中,我们将避免使用多变量。话虽如此,让我们来探讨威斯康星州乳腺癌数据集,并在问题制定和实现之前理解其周围的术语。
首先,我们必须从 UCI 机器学习仓库下载数据集。它位于ModernScalaProjects_Code文件夹中。
威斯康星州乳腺癌数据集概览
威斯康星州乳腺癌数据集包含 699 行数据。每一行对应一个单独的样本(在机器学习文献中,术语示例有时与样本可以互换使用),包含九个特征测量,这些特征测量是乳腺肿块细针吸取的数字化图像。
在我们深入细节之前,这里有一个表格列出了威斯康星州乳腺癌数据集 699 行(实例)的关键特征:

乳腺癌数据集特征
前面的表格列出了乳腺癌数据集的九个细胞核属性,其中每个属性只有一个值。这九个细胞核属性值都是从某个样本的数字化图像中捕获的测量值。因此,699 个这样的乳腺癌组织样本应该构成我们的 699 个输入向量的机器学习实验单元。
为了反思输入向量是什么,我们邀请读者回顾他们之前在 Iris 数据集监督学习问题上的经验;这是一个有两个基本方面的分类任务:
-
一个输入向量
-
一个响应变量值
Y,对于其输入向量有两个可能的结果:-
Y由类别列表示,有时被称为监督信号。 -
两种结果(例如,正面或反面)意味着多于一个的类别标签。一个结果代表一个分类,就像在分类任务中一样。
-
前面的两个方面也与我们面临的乳腺癌监督学习问题——当前任务——是共有的。以下各点通过提供更多见解来描述当前任务:
-
这是一个 699 个输入向量的多类别分类任务。这个任务的特点是历史(预定)的类别数据和多个依赖或结果变量(或标签)。
-
这个任务是在一个包含 699 个观察/测量(实例)的数据集上执行的,其中每一行观察可能进一步描述如下:
-
每一行由 10 个属性组成;这些属性中的每一个都是预测变量(输入,
X),它们也被称为输入变量(X)。 -
699 个观测值都是历史数据或预先确定的(除了某些不完整的观测值/行),它们代表了从针吸活检的乳腺癌组织样本中细胞核的特性(乳腺质量细胞核)。
-
上述乳腺质量细胞核有 10 个特性;这些只是乳腺质量细胞核的测量值。
-
-
由于存在 10 个(输入)属性值作为特征参数传递到
Model以执行所谓的目标类诊断分类,因此分类任务也是多维的。 -
乳腺癌数据集中的每个实例(行)代表了在乳腺质量组织样本上进行的测量(来自数字化图像)。
-
分类任务的目标如下:
-
识别(或分类)新的乳腺癌样本的诊断,判断其属于两种诊断中的哪一种:恶性(癌性)或良性(非癌性)。
-
通过学习(或拟合)离散数量的目标或类别标签(
Y)的过程,从预测变量中推导出响应的预测值。预测值是一个分类响应(结果)变量(输出Y),也称为响应或结果变量(Y)。- 学习一个预测函数,也称为模型;这个模型计算一个预测函数,预先确定 10 个属性上的特征测量值,这将能够对诊断类型(良性或恶性)进行分类或识别。
在第一章《从鸢尾花数据集预测花的类别》中,我们使用了一种名为随机森林的有监督学习分类算法。在本章中,我们将采用被称为逻辑回归分类技术(Spark ML 算法)。这将是我们的预测分析分类流程的核心。总结一下,乳腺癌分类任务的高层次视图可以概括如下:
-
分类算法:这涉及到创建一个判别函数或模型函数,该函数发现几个独立变量和一个依赖变量(通过模型索引到一个二元虚拟变量)之间的模式、关系或交互,该依赖变量可以是名义变量或有序变量。
-
预定的特征:已被标记为恶性或其他情况的测量或观测值。
-
预测标签:在学习过程之前对未见数据打标签,在学习过程之后对新未见数据进行预测。
逻辑回归的最终目标是生成一个尽可能拟合(训练)好的模型,并能够输出预测结果。当然,预测结果是一个感兴趣的变量。
下一个部分是对逻辑回归统计技术应用的更广泛讨论的序言。
逻辑回归算法
本章在构建数据管道时使用的逻辑回归(LR)算法是一种新颖的方法,用于预测乳腺癌肿块是否为恶性。
在理解 LR 算法的关键在于:
-
“如果(分类)特征 x = …”,那么它将标签视为一个输出,类似于“标签 =..”。
-
谈到分类特征,我们可能想了解乳腺癌数据集中两个或多个特征之间的关系。此外,我们还对构建 LR 机器学习模型感兴趣,作为高效的数据推理,以推导出多个分类变量的同时效应。
在下一节中,我们将向您展示 LR 技术的概述。
LR 显著特点
以下表格列出了 LR 的显著特点:

LR 速览
在本章中,我们为管道实现的回归或分类模型是一种特殊的广义线性回归模型,称为二元逻辑回归。
在我们讨论二元逻辑回归之前,我们将回顾一下第一章,从鸢尾花数据集预测花的类别,并提醒自己不同类型的变量。其中一种变量类型是响应变量,其变化由所谓的解释变量解释。解释变量在散点图的x轴上绘制,而绘制在y轴上的响应变量则依赖于前者。以下是一个散点图的示例,尽管它与此章节不直接相关,但具有一定的意义:

散点图示例
进一步深入,我们将探讨线性回归这一主题。这种预测模型将一组解释性分类值作为超参数,这些超参数在预测期望的响应变量值中起着直接作用。我们的响应变量取某个特定值的可能性有多大?这些可能性用数学术语表示,即一个概率模型,它转化为预测函数。这样的函数做两件事:
-
接受多个解释性(或输入)变量特征测量
-
模拟响应变量的概率
在我们将所有这些应用到我们的乳腺癌数据集之前,我们将引用来自凯斯西储大学的一个(虚构的)数学程序招生过程的例子。这个过程可以用以下要点来描述:
-
它应该是一个公平、非歧视的过程,允许来自各种类别或群体的学生进入学术项目。
-
一个录取过程预测模型将预测学生成功(或不成功)录取的概率,前提是他们属于某个性别、种族或经济背景。
-
一个重要的问题是学生 A 成功进入这个项目的几率是什么?换句话说,我们如何提出一个
StudentAdmission预测函数(模型),该模型将预测admission status响应变量取特定值的几率? -
StudentAdmission模型接收一组解释变量。这个组包括代表个人某些特征的独立变量。这些是多个特征测量。一些特征可以包括性别、种族、收入群体等等。
所有这些话,我们想知道二元逻辑回归如何作为线性回归模型方法的扩展找到其位置。以下描述了两个使用示例:
-
例如,考虑一个二元逻辑回归模型仅仅预测一个事件(一个事件)是否发生。一个拥有地震数据的学者对分析未来某个时间是否会发生地震感兴趣。这种响应变量是离散的。换句话说,它是非连续的、静态的或一次性的有限发生。
-
大学录取过程可以建模为一个二元逻辑回归模型,该模型涉及多个解释变量或课程。该模型将预测响应变量(
admission status)取特定值的几率(或概率)。更进一步,学生录取过程中的预测离散值是0或1的值。
接下来,我们将列出有助于我们制定逻辑回归任务的假设。
二元逻辑回归假设
这里是一些为二元逻辑回归分类任务所做的假设:
-
因变量应该是二元的,表示互斥的状态。存在多个自变量预测因子。
-
预测变量之间的相关性由相关矩阵的元素表示。它们不能高于 0.9。
-
异常值必须不存在。异常值的存在与否可以通过将预测因子转换为标准化的统计分数来确定。
在本章中,与我们相关的唯一数据集是乳腺癌数据集。这是一个分类任务,其分析解决方案将是二元逻辑回归。在我们到达那里之前,我们将将其作为一个更简单的数据集来展示以下内容:
-
自变量
-
因变量
-
相关系数矩阵
在下一节中,我们将通过一个关于男性和他们约会成功率的调查的虚构例子来说明线性回归的基本原理。
一个虚构数据集和 LR
在这里,我们将向您展示一个虚构的数据集,仅为了展示我们对逻辑回归案例的论述,以便它能够成为我们乳腺癌数据集分类任务的候选者。
以下示例列出了约会网站创建的关于单身男性的数据。表中列出的一个因变量代表男士是否幸运,这意味着他们在第一次约会一周内能够与同一个人再次约会。
有两个自变量,它们如下:
-
男士是否参加了约会工作坊来巩固他们的约会技巧?
-
第二个变量衡量的是男士在绝望尺度上的绝望程度。分数越高,男士越绝望,100 分代表最绝望。
下面的约会调查表列出了与约会逻辑回归问题相关的数据:

逻辑回归示例
检查数据集告诉我们,超过一半的单身男士在不到一周的时间内有过第二次约会。这是假设约会调查公司没有这些单身人士的其他背景数据,并且在这个(虚构的)约会帮助行业中应用了最佳技术。
现在,我们想知道工作坊组的人是否更有可能再次约会。查看第四列,酷,更高的酷度意味着有更好的第二次约会的可能性:

相关系数表
观察到相关系数表,那些不在工作坊的男士不太可能再次约会,而那些酷因子较高的男士更有可能再次约会。
在这个数据集上应用逻辑回归将具有以下特点:
-
响应(因)变量:约会
-
测量水平:平均值和标准差
-
回归函数:逻辑或logit
-
特征行总数:20
到目前为止,我们已经让您对逻辑回归有了更好的理解。我们将在下一节更深入地探讨这个话题。我们讨论了响应变量和相关性系数,也就是二元变量,并且对这些有了很好的把握。然而,我们还没有用数学术语制定逻辑回归模型。我们想知道使用线性回归来构建模型是否适合乳腺癌分类任务。结果证明,我们无法将线性回归模型应用于当前的任务。为什么我们转向逻辑回归而不是线性回归是下一节讨论的要点之一。
逻辑回归与线性回归的区别
在选择逻辑回归和线性回归方法之前,这里有一些要点,它们重申或重述了我们在本章和第一章,“从鸢尾花数据集预测花的类别”中讨论的内容:
-
在他们的实验数据单元上工作的数据科学家正在寻求构建一个模型。自然而然地,接下来的问题可能是他们为什么对构建一个(机器学习)模型感兴趣?
-
对于上一个问题的答案可能在于,该模型有助于发现预测变量(解释变量或独立变量)与其响应变量之间的模式或潜在关系。
谈到响应变量,乳腺癌数据集中的响应变量是分类的,与其他机器学习分类任务中的响应变量不同,后者如下:
-
连续型
-
无界
这为我们带来了清晰,并随之而来的是以下工作假设:
对于我们的乳腺癌分类任务,线性回归方法可能不起作用。毕竟,响应变量 Y 既不是连续的无界变量,也不是正态分布的。在我们将线性回归方法排除在我们的目的之前,我们仍然会尝试这样的公式,在这个过程中,我们可以更多地了解为什么线性回归(LR)是我们真正想要的。
线性回归分类模型的公式化
线性回归模型的数学公式的依据可以分解如下:
-
左侧:预测变量。
-
右侧表示为
y:由系数、预测变量(独立变量)和算术运算符+(加法)和*(乘法)组成的线性结构。 -
假设有四个预测变量,
PX1、PX2、PX3和PX4,每个变量代表所谓的X。
在一开始,我们可以写出一个代表线性回归模型的方程,如下所示:
//y on the Left-Hand is the predicted variable, and PX1, PX2, PX3... are predictor varibles (X)
y = LR0 + (LR1 * PX1) + (LR2 * PX2) + (LR3 * PX3) + (LR4 * PX4) + ......
但是有一个问题!我们新的线性回归模型的特点是响应变量实际上是非二元的。好吧,我们可以站出来声称,仍然有可能提出这个方程的改进版本,这将代表具有二元响应变量的线性回归模型。但实际上,这样的改进线性模型不会起作用,原因有两个:
-
我们的(二元)响应变量需要任意分配
0和1。这是因为我们想要表示两种相互排斥的分类状态。这些状态可以是良性的或恶性的,分别对应。 -
第二个原因与事实有关,即因为响应变量值
Y是分类的,所以预测值实际上是这个变量接受某个值的概率,而不是这个值本身。
到目前为止,我们的思考过程似乎明显倾向于至少一个具有二元响应变量的回归模型,而不是线性回归模型。为什么会这样?
可能我们想要构建的模型是概率的函数,一个逻辑回归方程,它区别于左侧表示为 Y 的对数而不是 Y 本身。下一节将结合这里提出的思想,构建逻辑回归的数学公式。
对数函数作为数学方程
继续从上一节结束的地方讲起,本节试图将那些想法和结论转化为新的叙述。本节的目标是给出逻辑回归的高层次数学公式。
然而,由于逻辑回归是一个更为复杂的案例,我们将为所谓的对数函数、对数模型或对数赔率制定一个更简单的方程。
不再赘述,对数函数在高级别上表示为 Logit(p),可以扩展为赔率的均值对数,而不是 Y 本身。
话虽如此,以下是一些数学概念,有助于我们理解和编写对数函数:
-
欧拉数:欧拉数 (e) = 2.718228183
-
自然对数:如果 e 可以被提升到 y 的幂,例如,e**y = x,那么 x 的以 e 为底的对数是 log[e](x) = y
在这一点上,对对数函数的表述类似于以下方程:
//Natural Logarithm of the probability that Y equals one of two values, perhaps 0 and 1, each taken to //represent one of two mutually exclusive states
Ln[p/(1-p)] = LR0 + (LR1 * PX1) + (LR2 * PX2) + (LR3 * PX3) + (LR4 * PX4)
我们新制定的对数函数是基于赔率或概率比的自然对数的一个方程。对数函数是一个模型,其特征如下:
-
在这个对数函数中,
Ln[p/(1-p)],p/(1-p)被称为样本被标记为良性时的赔率。例如,Ln[p/(1-p)]是自然对数或对数赔率,或者简单地说是对数,其值在 -∞ 到 +∞ 之间变化。 -
对数函数可以表示为
fnLogistic(p) = ln(p/1-p),其中p在0和1之间,而0和1是绘制在 x 轴上的最大和最小值,例如,fnLogistic(0.9) = 2.197224577336。 -
LR0、LR1、LR2等被称为模型系数或相关系数。这些模型系数与预测变量(解释变量)相关联,该变量与预测变量(响应变量)相关。 -
PX1、PX2和PX3是预测变量。 -
对数函数也是一个 连接函数。它之所以是连接函数,是因为它将位于对数函数左侧的概率的自然对数与由预测变量及其相应系数组成的线性方程联系起来。
-
p被说是在 0 和 1 之间有界,这里就是这种情况。
典型的对数函数曲线看起来像这样:

对数模型图表
到目前为止,我们还没有讨论逻辑回归,它比对数模型稍微复杂一些。
对图表的解释如下:
-
非线性图表将描绘以下内容:
-
x 轴:对数值
-
y 轴:概率(或赔率)
-
观察图表,对于概率为 0 的情况,对数值为 0.5
回顾一下,我们开始于线性回归,然后转向对对数模型的讨论。理解对数几率函数是什么为在乳腺癌分类任务中逻辑回归的背景奠定了基础。
逻辑回归函数
我们之前说过,逻辑回归比对数几率函数更难。然而,正如我们将要看到的,逻辑回归的公式非常适合这个问题。我们想要对样本是良性还是恶性的命运进行预测。换句话说,基于簇厚度、细胞大小均匀性等特征测量,特定的乳腺癌组织样本的预测只能取两个互斥值之一。每个这些特征测量可以分别表示为X1、X2和X3。
这将我们带到逻辑回归函数公式的开始。
逻辑回归函数背后的核心概念是所谓的逆函数,其表示如下:

下面是对前面方程的简要解释:
在前面的方程中,p只是一个由X1、X2、X3以及更多表示的乳腺癌样本特征测量的函数。
将p重写为fLogistic(X1,X2,..),我们得到了以下完整函数定义:
fLogistic(X1,X2,X3,..) = LR0 + (LR1 * PX1) + (LR2 * PX2) + (LR3 * PX3) + ...
结果表明,我们之前讨论的对数几率函数和我们的逻辑回归函数互为逆函数。
关于逻辑回归的重要要点如下:
-
存在一个表示结果发生或未发生的概率的二分响应变量
-
以下是一个非线性关系:
-
在x轴上绘制的分类输入(独立特征测量)值。这些也被称为预测变量。
-
在y轴上的概率。这些是预测值。
-
非常重要:系数
LR0、LR1和LR2是从我们的训练数据集中计算出来的。我们的训练数据集有已知或预定的输入测量和输出标签。
到目前为止,我们已经拥有了转向 Spark ML API 以实现我们刚才讨论的逻辑回归数学模型所需的全部内容。
在下一节中,我们将构建两个数据管道:
-
使用随机森林算法的管道
-
使用逻辑回归方法的管道
我们从第一章,从鸢尾花数据集预测花卉类别中熟悉了随机森林。逻辑回归是一个经过验证的方法,它背后有成熟的统计技术,机器学习发现这些技术对于解决二元分类问题非常有用。
以下开始使用部分将指导您开始实现过程。
开始使用
开始的最佳方式是理解更大的图景——评估我们面前工作的规模。在这种情况下,我们已经确定了两个广泛的任务:
-
设置必备软件。
-
开发两个流水线,从数据收集开始,构建一个可能以预测结束的工作流程序列。这些流水线如下:
-
一个随机森林流水线
-
一个逻辑回归流水线
我们将在下一节中讨论设置先决软件。
设置先决软件
首先,请回顾第一章中的设置先决软件部分,从鸢尾花数据集预测花的类别,以审查您现有的基础设施。如果需要,您可能需要重新安装所有内容。您需要实质性更改任何内容的可能性很小。
然而,以下是我推荐的升级:
-
如果您还没有这样做,请将 JDK 升级到 1.8.0_172
-
Scala 从 2.11.12 升级到一个早期的稳定版本 2.12
-
Spark 2.2 升级到 2.3,其中 2.3 是一个主要版本,包含许多错误修复,这也是为什么建议这样做的原因。
在撰写本书时,Java 9 和 10 似乎与 Spark 不兼容。这可能会改变。为了本章的目的,您的本地 Spark shell 将是首选的开发环境。
在解决完先决条件后,我们准备好直接进入开发流水线。这次旅程从实施目标部分开始。
实施目标
我们已经实现了第一章的实施方案目标,从鸢尾花数据集预测花的类别。在该章的早期,我们开发了工作流程的初步过程。以下图表展示了这一点,它将帮助我们为本章制定实施方案目标:

初步工作流程的阶段
由于当前章节也处理与之前类似的多类分类任务,因此前图中显示的四个框是我们为设置本章实施方案目标的指南。广泛的高级目标是:
-
数据收集步骤之后是探索性数据分析(EDA)步骤
-
数据清洗/预处理步骤
-
将数据传递给算法;有模型需要训练(拟合)和预测需要生成
这为更完整的实施方案目标列表铺平了道路,它们是:
-
从 UCI 机器学习库获取乳腺癌数据集。
-
为 EDA 提取数据框。
-
在沙盒 Zeppelin 笔记本环境中进行初步的 EDA(探索性数据分析)操作,并在 Spark shell 中运行统计分析。
-
在 Zeppelin 中逐步开发流水线并将代码移植到 IntelliJ。这意味着以下内容:
-
在 IntelliJ 中创建一个新的 Scala 项目,或将现有的空项目导入 IntelliJ,并从笔记本中逐步开发出的代码创建 Scala 代码库
-
不要忘记在
build文件中连接所有必要的依赖项 -
解释管道的结果:
-
分类器表现如何?
-
预测值与原始数据集中的值有多接近?
现在,我们将逐一开始工作,从从 UCI 机器学习仓库获取威斯康星乳腺癌数据集开始。
实施目标 1 – 获取乳腺癌数据集
前往 UCI 机器学习仓库网站archive.ics.uci.edu/ml/datasets/bcw,通过点击数据文件夹下载Data文件夹。将此文件夹提取到方便的位置,并将bcw.csv复制到项目文件夹的根目录,我们将称之为Chapter2。此时,Chapter2将是空的。
您可以参考项目概述以深入了解乳腺癌数据集的描述。我们在此以如下方式描述bcw.data文件的内容:

包含 699 行的乳腺癌数据集快照
我们刚刚下载的乳腺癌数据集是多元的,这意味着它包含一组超过一个的独立变量。在对其进行任何 EDA 之前,我们需要创建一个数据集的抽象,我们称之为 dataframe。如何创建 dataframe 作为 EDA 的先导是下一节的目标。
实施目标 2 – 为 EDA 推导 dataframe
我们将威斯康星乳腺癌数据文件下载到Chapter2文件夹,并将其重命名为bcw.csv。DataFrame创建的过程从加载数据开始。
我们将按照以下方式在SparkSession上调用read方法:
scala> val dfReader1 = spark.read
dfReader1: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@3d9dc84d
返回的read方法产生DataFrameReader。由于我们的数据集是一个 CSV 文件,我们想通过在DataFrameReader上调用format方法并传入com.databricks.spark.csv格式指定符字符串来告诉 Spark:
scala> val dfReader2 = dfReader1.format("com.databricks.spark.csv")
dfReader2: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@3d9dc84d
在这一点上,DataFrameReader需要一个键值对形式的输入数据源选项。使用两个参数调用option方法,一个字符串类型的键"header"和其布尔类型的值true:
scala> val dfReader3 = dfReader2.option("header", true)
dfReader3: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@3d9dc84d
接下来,代码再次调用option方法(在DataFrameReader上),参数为名为inferSchema的参数和true值。通过调用inferSchema方法,我们希望 Spark 确定我们的输入数据源的架构,并返回我们的DataFrameReader:
scala> val dfReader4 = dfReader3.option("inferSchema", true)
dfReader4: org.apache.spark.sql.DataFrameReader = org.apache.spark.sql.DataFrameReader@3d9dc84d
接下来,通过调用load方法并将数据集文件的路径传递给它来加载bcw.csv。外部数据源,如我们的数据集,需要一个路径,以便 Spark 能够加载数据,这样DataFrameReader就可以处理文件并返回DataFrame,如下所示:
scala> val dataFrame = dfReader4.load("\\bcw.csv")
dataFrame: org.apache.spark.sql.DataFrame = [id: int, clump_thickness: int ... 9 more fields]
现在我们有了乳腺癌 dataframe!这完成了实施目标 2 – 为 EDA 推导 dataframe部分。我们的下一步是进行初步的统计分析。
最后,在我们进行下一步之前,这里有一个 dataframe 的视图:

原始数据 DataFrame
看起来我们的 DataFrame 中有数据,现在它已经准备好进行 EDA。
第一步 – 进行初步的 EDA
在这一点上,我们将对我们的数据集进行相当简单的统计分析。这将为我们提供一些有用的、初步的统计洞察,例如均值、中位数、范围和标准差等。
为了进行初步的 EDA,让我们使用所需的列名作为参数调用 describe 方法。这将给我们一个新的名为 stats 的 DataFrame。在 stats 上调用 show 方法将生成如下统计结果表:

统计分析
尽管输出看起来很丑陋且混乱,但我们看到了统计数字,如 count、mean、标准差、最小值和最大值。是的,数据集有 699 行连续的、离散的(或分类的)值。
现在初步的探索性数据分析已经完成,我们进入下一步,将数据集加载到 Spark 中。
第二步 – 加载数据并将其转换为 RDD[String]
在这一步中,我们将再次加载数据,但方式略有不同。这一阶段数据分析的目标是生成一个 DataFrame,其中数据已被读入 RDD[String]。首先,我们需要数据集的路径:
scala> val dataSetPath = "C:\\Users\\Ilango\\Documents\\Packt\\DevProjects\\Chapter2\\"
dataSetPath: String = C:\Users\Ilango\Documents\Packt\DevProjects\Chapter2\
我们刚刚创建了 dataSetpath。在下面的代码中,我们将数据集的路径传递给 textFile 方法:
scala> val firstRDD = spark.sparkContext.textFile(dataSetPath + "\\bcw.csv")
firstRDD: org.apache.spark.rdd.RDD[String] = C:\<<path to your dataset file>>
MapPartitionsRDD[1] at textFile at <console>:25
textFile 方法返回了一个 RDD[String]。为了检查数据是否已加载到 RDD 中,我们需要在 firstRDD 上调用 first 方法以获取 header 内容。我们将把这个作为练习留给读者。
接下来,我们想知道我们的 RDD 中的分区数:
scala> firstRDD.getNumPartitions
res7: Int = 2
getNumPartitions 方法返回了 firstRDD 中的分区数。由于 RDD 允许我们在低级别上处理数据,我们将继续按照需要重新组织和调整这些数据。
在下一步中,我们想要检查 RDD。我们想要重新组织和包装数据到数组中。
第三步 – 分割弹性分布式数据集并将单个行重新组织为数组
为了分割数据集,我们将从 RDD 分区开始。以以下方式思考 RDD 分区是有帮助的。
每个分区可以可视化为一个由 "\n" 分隔的行数据组成的长字符串。我们想要通过 "\n" 分隔符将这些长字符串分解为其组成部分字符串。简而言之,我们将在我们的 RDD,firstRDD 上尝试一个 flatMap 操作。每个组成部分字符串是一个 Row,它代表原始数据集中的行。
我们将对 flatMap 进行操作,并传递一个匿名函数,该函数将在由 "\n" 字符分隔的行上调用,如下所示:
scala> val secondRDD = firstRDD.flatMap{ row => row.split("\n").toList }
secondRDD: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[33] at flatMap at <console>:27
前面的代码执行了一个flatMap扁平化操作,结果创建了一个新的RDD[String],它将包含所有这些字符串(每个字符串都是我们的数据集中的一行)。在这个阶段,我们将split(沿着该行中各个字符之间的逗号)一个String,生成一个RDD[Array[String]]:
scala> val rddArrayString = secondRDD.map(_.split(","))
rddArrayString: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[34] at map at <console>:29
RDD[Array[String]]自然意味着该 RDD 包含多个Array[String]。在这个 RDD 中有多少这样的数组?
scala> rddArrayString.count
res9: Long = 700
在我们的 RDD 上调用count返回了一个数组计数为700,这正是它应有的值。
第 4 步 – 清除包含问号字符的行
在我们继续之前,让我们再次查看原始数据集。如果你仔细观察,你会注意到数据集中某些地方包含?字符。实际上,这个字符从第七列开始出现在一些行的第 25 行。带有?字符的第 25 行在下面的图中显示。这是一个问题,需要解决方案。
有时,对数据集的视觉检查可以揭示存在多余的字符。
以下是以?字符出现在第 25 行和第六列的威斯康星州乳腺癌数据集的快照:

显示?字符的数据集
显然,不仅仅是第 25 行有?字符。可能还有其他行包含需要清除的多余的?字符。一个解决方案似乎是在我们的rddArrayString上调用一个filter操作:
scala> val purgedRDD = rddArrayString.filter(_(6) != "?")
purgedRDD: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[35] at filter at <console>:31
如前述代码所示,我们刚刚运行了filter操作,它返回了一个新的RDD[Array[String],我们称之为purgedRDD。自然地,我们可能想要计算数据集中剩余的行数,我们认为这些行对于数据分析是相关的。这就是下一节的目标。
第 5 步 – 清除数据集中有疑问字符的行后的计数
我们现在将在新的purgedRDD上运行count:
scala> purgedRDD.count
res12: Long = 684
因此,在先前的代码中,我们在purgedRDD上调用了count方法。Spark 返回了一个值为684的结果。显然,有 16 行包含?字符。毕竟,许多像这样的数据集需要预处理步骤或两个。现在,我们将继续进行数据分析的下一步,因为我们知道 Spark 可能不会报告错误,尤其是在我们想要一个包含合并特征向量的新两列DataFrame的地方。
在下一节中,我们将去除header。
第 6 步 – 去除 header
之前显示的每个内部数组都包含表示特征测量的行和一个表示数据集header的行。以下代码行将我们的 RDD 转换为包含字符串行的数组:
//Drop the Array with the headers in it
scala> val headerRemoved = cleanedRDD.collect.drop(1)
headerRemoved: Array[Array[String]]
drop方法去除了header。接下来,我们将继续创建一个新的DataFrame。
第 7 步 – 创建一个两列的 DataFrame
我们已经接近目标了。在本节中,目标是创建一个输入特征向量,步骤如下:
-
导入
Vectors类。 -
在
Array上的map操作中,我们将遍历我们的无头数据集的每一行。然后,我们依次转换每一行,对包含预定细胞核测量的每一列进行操作。这些列通过使用dense方法被转换为双精度浮点数。 -
map操作处理整个数据集,并生成featureVectorArray,这是一个类型为Array[(Input Feature Vector, String representing the Class)]的结构:
//Step 1
scala> import org.apache.spark.ml.linalg.Vectors
import org.apache.spark.ml.linalg.Vectors
//Step 2
scala> val featureVectorArray = headerRemoved.map(row => (Vectors.dense(row(1).toDouble,..,row(10)))
featureVectorArray: Array[(org.apache.spark.ml.linalg.Vector, String)]
好的,我们已经创建了 featureVectorArray,这是一个由一组 (Vector, String) 元组组成的 Array。现在这个 Array 已经准备好被转换成 DataFrame。这就是下一节的目标。
第 8 步 – 创建最终的 DataFrame
本节的目标是创建一个最终的分析就绪 DataFrame 版本。SparkSession 上可用的 createDataFrame 方法是合适的,如下所示:
scala> val dataFrame = spark.createDataFrame(featureVectorArray)
dataFrame: org.apache.spark.sql.DataFrame = [_1: vector, _2: string]
//display the first 20 rows of the new DataFrame 'dataFrame'
//Readers are requested to run the show command and see what the contents are, as an exercise
scala> dataFrame.show
+--------------------+---+
| _1| _2|
+--------------------+---+
|------------------------
|-----------------------
|----------------------
|-----------------------
Displaying 20 rows..
如前所述,新的 DataFrame 有两个列,它们的命名不是很可读,分别是 _1 和 _2。
我们想要的是一个重命名的 DataFrame,包含两个可读的列,如下所示:
-
一个名为
bc-diagnosis-label-column的特征向量列和一个名为bc-indexed-category-column的目标变量标签列。 -
顺便说一下,在机器学习术语中,目标变量表示正在预测的内容。例如,它可以是良性为
0或恶性为1。由于目标变量与输出相关联,它也可以被称为结果或输出变量。定义目标变量是创建二元分类模型步骤的一个基本部分;在统计学术语中,目标变量与响应变量相同。
要得到一个重命名的 DataFrame,我们将对其进行一些转换,我们将通过创建以下两个方法来完成:
//Features column
scala> def bcFeatures = "bc-diagnosis-label-column"
bcFeatures: String
//unindexed label column
scala> def bcDiagCategory = "bc-indexed-category-column"
bcDiagCategory: String
在以下代码行中,调用 toDF 方法:
;scala> val dataFrame2 = dataFrame.toDF(bcFeatures, bcDiagCategory)
dataFrame2: org.apache.spark.sql.DataFrame = [bc-diagnosis-label-column: vector, bc-indexed-category-column: string]
调用 toDF 方法创建一个具有所需列名的 DataFrame。在 dataFrame2 上调用 show 将导致以下显示:
scala> dataFrame2.show
+-------------------------+--------------------------+
|bc-diagnosis-label-column|bc-indexed-category-column|
+-------------------------+--------------------------+
| --------------------| 2|
| --------------------| 2|
| --------------------| 2|
| --------------------| 2|
| --------------------| 2|
| --------------------| 4|
| --------------------| 2|
| --------------------| 2|
| --------------------| 2|
| --------------------| 2|
| -----------------------
| ------------------------
| ------------------------
| ------------------------
| ------------------------
| ------------------------
| ------------------------
| ------------------------
| ------------------------
| -----------------------
+-------------------------+--------------------------+
only showing top 20 rows
上述列表确认了您想要的 DataFrame 就是您得到的。在下一节中,我们将使用这个 DataFrame 来构建包含两个算法的数据管道:
-
随机森林算法
-
LR
我们首先将构建一个随机森林管道。
随机森林乳腺癌管道
开始本节的一个好方法是下载 ModernScalaProjects_Code 文件夹中的 Skeleton SBT 项目存档文件。以下是 Skeleton 项目的结构:

项目结构
读者指南:在提取之前,将文件复制并粘贴到您选择的文件夹中。将此项目导入 IntelliJ,深入到包"com.packt.modern.chapter",并将其重命名为"com.packt.modern.chapter2"。如果您想选择不同的名称,请选择一个合适的名称。乳腺癌管道项目已经设置了build.sbt、plugins.sbt和build.properties。您只需要在build.sbt中的组织元素中进行适当的更改。一旦完成这些更改,您就为开发做好了准备。有关build.sbt中依赖项的解释,请参阅第一章,从鸢尾花数据集预测花的类别。除非我们为这个项目引入新的依赖项,否则我们将坚持使用Skeleton项目中捆绑的build.sbt。
话虽如此,我们现在将开始实施。第一步将是创建 IntelliJ 中的 Scala 代码文件。请注意,完整的代码已包含在你下载的文件夹中,ModernScalaProjects_Code。
第 1 步 – 创建 RDD 并预处理数据
在com.packt.modern.chapter2包中创建一个名为BreastCancerRfPipeline.scala的 Scala 文件。到目前为止,我们依赖于SparkSession和SparkContext,这是spark-shell提供给我们的。现在我们需要创建我们的SparkSession,这将给我们SparkContext。
在BreastCancerRfPipeline.scala中,在包声明之后,放置以下导入语句:
import org.apache.spark.sql.SparkSession
在一个名为WisconsinWrapper的特质中创建一个SparkSession:
lazy val session: SparkSession = { SparkSession .builder() .master("local") .appName("breast-cancer-pipeline") .getOrCreate()
只有一个SparkSession被提供给所有继承自WisconsinWrapper的类。创建val来保存bcw.csv文件路径:
val dataSetPath = "<<path to folder containing your Breast Cancer Dataset file>>\\bcw.csv"
创建一个构建DataFrame的方法。此方法接受乳腺癌数据集的完整路径作为String,并返回DataFrame:
def buildDataFrame(dataSet: String): DataFrame
通过更新之前的import语句来导入DataFrame类,针对SparkSession:
import org.apache.spark.sql.{DataFrame, SparkSession}
在buildDataFrame内部创建一个嵌套函数来处理原始数据集。将此函数命名为getRows。getRows函数不接受任何参数,但返回Array[(Vector, String)]。SparkContext变量的textFile方法将bcw.csv处理为RDD[String]:
val result1: Array[String] = session.sparkContext.textFile(<<path to bcw.csv represented by the dataSetPath variable>>)
结果 RDD 包含两个分区。每个分区依次包含由换行符"\n"分隔的字符串行。RDD 中的每一行代表其在原始数据中的原始对应项。
在下一步中,我们将预处理这个 RDD;这包括从原始的四个特征列中创建一个单一的合并输入features列。我们通过调用flatMap并传递一个函数块来开始这个过程。在后续的转换之后,这些转换在下面的代码中列出,我们应该能够创建一个类型为Array[(org.apache.spark.ml.linalg.Vector, String)]的数组。
Vector在这种情况下代表特征测量的行。Scala 代码以给我们Array[(org.apache.spark.ml.linalg.Vector, String)]如下:
val result2: RDD[String] = result1.flatMap { partition => partition.split("\n").toList }
val result3: RDD[Array[String]] = result2.map(_.split(","))
接下来,删除header列,但在执行返回Array[Array[String]]的collect操作之前:
val result4: Array[Array[String]] = result3.collect.drop(1)
header列现在已被删除。我们现在将导入Vectors类:
import org.apache.spark.ml.linalg.Vectors
现在,将Array[Array[String]]转换为Array[(Vector, String)]:
val result5 = result4.map(row => (Vectors.dense(row(1).toDouble,..toDouble),row(5)))
现在,我们将使用名为getRows的参数调用createDataFrame方法。这将返回一个包含featureVector和speciesLabel(例如,bcw-Setos)的DataFrame:
val dataFrame = spark.createDataFrame(result5).toDF(featureVector, speciesLabel)
新的DataFrame包含两行:
-
一个名为
bcw-features-column的列 -
一个名为
bcw-species-label-column的列
我们需要通过将"bcw-Setosa"、"bcw-Virginica"和"bcw-Versicolor"字符串转换为 double 来索引species-label-column。我们将使用StringIndexer来完成此操作。
现在,创建一个名为bcwPipeline.scala的文件。
创建一个名为bcwPipeline的对象,它扩展了我们的bcwWrapper特质:
object BreastCancerRfPipeline extends WisconsinWrapper { }
导入StringIndexer算法类:
import org.apache.spark.ml.feature.StringIndexer
现在,创建一个StringIndexer算法实例。StringIndexer将species-label-column映射到一个索引学习列:
val indexer = new StringIndexer().setInputCol(bcwCategory).setOutputCol("indexedSpeciesLabel")
索引器将bcw类型列转换为 double 类型的列。这是一个将分类变量伪装成定量变量的例子。
第 2 步 – 创建训练和测试数据
现在,通过提供一个随机种子来将我们的数据集分成两部分:
val splitDataSet: Array[org.apache.spark.sql.
Dataset[org.apache.spark.sql.Row]] = indexedDataFrame.randomSplit(Array(0.75, 0.25), 98765L)
新的splitDataset包含两个数据集:
-
训练
Dataset是一个包含Array[(Vector, bcw-species-label-column: String)]的数据集 -
测试
Dataset是一个包含Array[(Vector, bcw-species-label-column: String)]的数据集
确认新的Dataset大小为2:
splitDataset.size
res48: Int = 2
将训练Dataset分配给trainSet变量:
val trainDataSet = splitDataSet(0)
trainSet: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [bcw-features-column: vector, bcw-species-label-column: string]
将测试Dataset分配给testSet变量:
val testDataSet = splitDataSet(1)
testSet: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [bcw-features-column: vector, bcw-species-label-column: string]
现在,我们将看到如何创建一个随机森林分类器。
创建一个分类器并将其超参数传递给它。我们首先设置以下参数:
-
一个名为
"features"的列名 -
一个索引的
"label"列名 -
每次分割要考虑的特征数量(我们有 150 个观测值和四个特征),这将使我们的
max_features为2
由于bcw是一个分类问题,因此featureSubsetStrategy的'sqrt'设置是我们需要的。此外,我们还将传递其他参数,如杂质、要训练的树的数量等,如下所示:
-
杂质设置—值可以是 gini 和 entropy
-
要训练的树的数量(由于树的数量大于 1,我们设置树的最大深度,这是一个等于节点数量的数字)
-
所需的最小特征测量数(样本观测值),也称为每个节点的最小实例数
这次,我们将采用基于参数组合的穷举网格搜索模型选择过程,其中指定了参数值范围。
创建一个 randomForestClassifier 实例。设置特征和 featureSubsetStrategy:
val randomForestClassifier = new RandomForestClassifier()
.setFeaturesCol(bcwFeatures_CategoryOrSpecies_IndexedLabel._1)
.setFeatureSubsetStrategy("sqrt")
开始构建一个有两个阶段的 Pipeline,一个 indexer 和一个 Classifier:
val irisPipeline = new Pipeline().setStages(ArrayPipelineStage ++ ArrayPipelineStage
接下来,将分类器上的超参数 num_trees(树的数量)设置为 15,一个 Max_Depth 参数,以及具有两个可能值 gini 和 entropy 的不纯度。然后,构建一个包含所有三个超参数的参数网格。
第 3 步 – 训练随机森林分类器
接下来,我们将现有的训练集(用于训练模型的那个)分成两部分:
-
验证集:这是训练数据集的一个子集,用于获取模型达到的技能水平的初步估计。
-
训练集:训练集是模型从中学习的那个数据集百分比。这个过程被称为训练模型。也因为模型从这些数据中学习,所以这些数据被称为拟合模型。
我们可以通过创建 TrainValidationSplit 算法的实例来完成分割:
val validatedTestResults: DataFrame = new TrainValidationSplit() .setSeed(1234567L) .setEstimatorParamMaps(finalParamGrid) .setEstimator(irisPipeline)
在这个变量上设置种子,设置 EstimatorParamMaps,设置 Estimator 为 bcwPipeline,最后将训练比例设置为 0.8。
最后,使用我们的训练 Dataset 和测试 Dataset 进行拟合和转换。
太好了!现在,分类器已经训练完成。在下一步中,我们将把这个分类器应用到测试数据上。
第 4 步 – 将分类器应用于测试数据
我们验证集的目的是能够在模型之间做出选择。我们希望有一个评估指标以及超参数调整。现在,我们将创建一个名为 TrainValidationSplit 的验证估计实例,该实例将训练集分割为验证集和训练集,如下所示:
validatedTestResults.setEvaluator(new MulticlassClassificationEvaluator())
接下来,我们将这个估计器拟合到训练数据集上,以生成一个模型和一个转换器,我们将使用它们来转换我们的测试数据集。最后,我们将通过应用一个用于指标的评估器来执行超参数调整的验证。
新的 ValidatedTestResults DataFrame 应包含以下列,包括三个新生成的列—rawPrediction、probability 和 prediction 以及一些额外的列:
-
bcw-features-column -
bcw-species-column -
label -
rawPrediction -
probability -
prediction
接下来,让我们生成一个新的数据集。在 validatedTestResults 数据集上调用 select 方法,并将 prediction 和 label 的列表达式传递给它:
val validatedTestResultsDataset:DataFrame = validatedTestResults.select("prediction", "label")
我们将在本章末尾回顾这些测试结果,届时我们将评估分类器。到那时,我们将解释如何解释这些结果以及它们如何与本章的主要目标预测乳腺癌肿块诊断的分类联系起来。
第 5 步 – 评估分类器
在本节中,我们将评估模型输出结果在测试结果上的准确性。评估从创建 MulticlassEvaluator 的实例开始:
val modelOutputAccuracy: Double = new MulticlassClassificationEvaluator()
现在,在 MulticlassEvaluationEvaluator 上,我们设置以下内容:
-
"label"列 -
一个指标名称
-
预测列
label
接下来,我们使用 validatedTestResults 数据集调用 evaluate 方法。注意从 modelOutputAccuracy 变量中获取测试数据集的模型输出结果的准确率。另一个值得评估的指标是预测列中预测的标签值与(索引的)"label"列中实际标签值之间的接近程度。
接下来,我们想要提取指标:
val multiClassMetrics = new MulticlassMetrics(validatedRDD2)
MulticlassMetrics 包含两个通过读取 accuracy 和 weightedMetrics 变量计算出的指标。
第 6 步 – 将管道作为 SBT 应用程序运行
在您的项目文件夹根目录下,运行 sbt console 命令,然后在 Scala shell 中导入 bcwPipeline 对象,然后使用 bcw 参数调用 bcwPipeline 的 main 方法:
sbt console
scala>
import com.packt.modern.chapter2.BreastCancerRfPipeline
BreastCancerRfPipeline.main("bcw")
Accuracy (precision) is 0.9285714285714286 Weighted Precision is: 0.9428571428571428
分类器报告了两个指标:
-
准确率
-
加权精确率
在下一节中,我们将打包应用程序。
第 7 步 – 打包应用程序
在您的 SBT 应用程序的根目录下,我们想要生成一个 Uber JAR。我们将运行以下命令:
sbt package
此命令生成一个 Uber JAR 文件,然后可以轻松地以独立部署模式部署到 [本地]:

应用程序 JAR 文件
管道 JAR 文件位于目标文件夹下。在下一节中,我们将部署应用程序到 Spark。
第 8 步 – 将管道应用程序部署到 Spark 本地
在应用程序文件夹根目录下,运行带有类和 JAR 文件路径参数的 spark-submit 命令。如果一切顺利,应用程序将执行以下操作:
-
加载数据。
-
执行 EDA。
-
创建训练、测试和验证数据集。
-
创建一个随机森林分类器模型。
-
训练模型。
-
测试模型的准确率,这是机器学习分类任务最重要的部分。
-
为了完成第 6 步,我们将我们的训练好的随机森林分类器模型应用于测试数据集,这是模型尚未见过的数据:
-
未见数据可以类比为分类器需要预测的新数据。
-
我们在开始时的目标是根据测试数据集中特定特征对乳腺癌肿块进行分类。
-
将模型应用于测试数据集会导致诊断预测。
-
管道运行一个评估过程,这完全是检查模型是否报告了正确的诊断。
-
最后,管道会报告某个特征在乳腺癌数据集中相对于其他特征的重要性。事实上,某个特征在执行分类任务时比其他特征更重要。
上述总结列表结束了随机森林部分,并带我们来到了关于创建逻辑回归管道的新章节的开始。
LR 乳腺癌管道
在开始实施逻辑回归管道之前,请参考第乳腺癌数据集概览部分中较早的表格,其中列出了九个乳腺癌组织样本特征(特征),以及一个类别列。为了回顾,以下特征或特征如下列出,以供参考:
-
c****lump_thickness
-
size_uniformity
-
shape_uniformity
-
marginal_adhesion
-
epithelial_size
-
bare_nucleoli
-
bland_chromatin
-
normal_nucleoli
-
mitoses
现在,让我们从逻辑回归方法的高级公式开始,说明其预期达到的目标。以下图表代表了这个公式的要素,从高级层面来看:

乳腺癌分类公式
上述图表代表了一个逻辑分类器管道的高级公式,我们知道需要将其转换为 Spark 和 Scala 中的实现。以下是一些有助于您开始的有用提示:
-
我们可以选择哪些有趣的属性来进行预测?属性或特征就像
if语句,预测的标签就是答案。例如,如果它看起来像鱼,长 100 英尺,而且是哺乳动物,那么它一定是一只鲸鱼。我们必须识别那些if语句或属性,目的是进行预测。当然,预测必须将组织样本分类为恶性或良性。 -
使用 LR 创建一个分类器模型。
-
在乳腺癌数据集中,我们有我们的类别列,它代表标签。该列包含已知(或预先确定的)标签值,用“标签”将每个特征测量行标记为恶性或良性。
-
因此,整个数据集,一个已知标签的已知测量的实验单元,被标记为恶性或良性。
在下一节中,我们将阐述我们的实施目标,我们的实施目标将是什么,以及我们计划如何实施它们。
实施目标
我们将从这个部分开始,列出以下实施目标:
-
实施目标 1:描绘我们认为的基本管道构建块,实际管道中的粗略工作流程阶段,以及每个块如何被可视化地连接到下一个块,这暗示了数据流和数据转换。连接状态意味着一系列工作流程阶段按顺序排列。
-
实施目标 2:管道的核心构建块。
-
实施目标 3:乳腺癌分类任务的 Spark ML 工作流程。
-
实施目标 4:开发两个管道阶段,并将索引器和 logit 模型分配给每个阶段。
-
实施目标 5:评估二元分类器的性能。
接下来,我们继续实施目标 1 和 2。
实施目标 1 和 2
下面的图展示了从DataFrame块通过转换过程进入特征向量创建块。一个特征向量和一个未索引的标签(为了简单起见,在下面的图中未显示)组成一个新的(转换后的)DataFrame。特征向量创建块(或阶段)是分类模型创建的前奏。最后一个块是预测阶段,其中生成预测。
这是对稍后代码中要实现的内容的简要描述:

管道的核心构建块
前面的图没有提到将 DataFrame[特征向量和标签] 分成两部分:
-
训练数据集
-
测试数据集,这是模型拟合(训练)的输入数据
这些两个数据集在下一节的图中表示为训练块和测试块。实施目标 1 和 2 在本节中概述。实施目标 3 在接下来的主题中概述。
实施目标 3 – Spark ML 工作流程用于乳腺癌分类任务
我们将开始实施目标 3。这构成了我们逻辑回归管道的基础。这个管道分为两个功能区域——图中表示为训练的训练块,以及表示为测试的测试块。
我们在训练块中填充了四个管道阶段,如下所示:
-
加载数据
-
特征提取
-
模型拟合(训练)
-
评估
同样,测试块也有它自己的四个阶段:
-
加载数据
-
特征提取
-
预测
-
评估
这两个块看起来并没有太大的不同。然而,其中还有更多。我们现在将根据训练、测试和 Spark ML 组件,展示一个新的机器学习工作流程图,如下所示:

Spark ML 工作流程用于乳腺癌分类任务
从训练块到测试块的箭头表示从训练块开始的数据转换工作流程,并继续到测试块。
我们之前的机器学习工作流程图是一个进步。它是实际管道的某种前奏,其实施细节将在实施目标 4—构建索引器和 logit 机器学习模型的编码步骤部分中展开。
在这一点上,我们必须注意,实施关键依赖于利用以下 Spark ML API 组件:
-
DataFrame
-
转换器
-
估计器
-
度量评估器
现在,我们有了足够的信息进入下一个层次,即实施目标 4—构建索引器和 logit 机器学习模型的编码步骤部分,我们将逐步构建一个两阶段管道。
实现目标 4 – 构建索引器和 logit 机器学习模型的编码步骤
在一开始,实现实现目标 5 需要导入以下内容。在以下包中创建一个空的 Scala 文件,并添加以下导入。
在所有导入完成后,创建一个新的 Scala 对象 BreastCancerLrPipeline,并让这个类扩展 WisconsinWrapper 特性:
package com.packt.modern.chapter2
import com.packt.modern.chapter2.WisconsinWrapper
import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.evaluation.{BinaryClassificationEvaluator, MulticlassClassificationEvaluator}
import org.apache.spark.ml.{Pipeline, PipelineStage}
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.{DataFrame, Row}
WisconsinWrapper 特性包含创建 SparkSession 的代码。它还包含一个接受数据集并从中创建 DataFrame 的方法。导入还带来了以下内容,这对于实现任务非常重要。例如,你会注意到它们对于导入 Spark ML API 中的 LogisticRegression 是必要的,LogisticRegression 是二进制分类中使用的算法之一。我们需要计算二进制分类指标的 API。因此,我们将继续进行下一个任务,我们将更多地讨论我们的特性 WisconsinWrapper。
使用 WisconsinWrapper 特性扩展我们的管道对象
WisconsionWrapper 包含一个名为 WisconsinWrapper 的特性,其中包含以下代码组件:
-
一个名为
lazy val的SparkSession -
一个表示乳腺癌数据集路径的
val,bcw.csv(此文件位于项目文件夹的根目录下) -
一个包含
"features"和"label"列的字符串表示的元组,我们将很快创建它 -
一个用于构建
DataFrame的方法。它接受数据集路径的完整路径,并使用名为buildDataFrame()的方法
Wrapper 特性如下所示,并包括所有四个代码组件:

WisconsinWrapper
读者可以获取 WisconsinWrapper 特性的副本,并将其粘贴到他们的 SBT 项目中。SBT 项目位于 ModernScalaProjects_Code 文件夹下。
现在,创建一个名为 object BreastCancerLrPipeline 的声明,如下所示:
object BreastCancerLrPipeline extends WisconsinWrapper { }
我们现在有一个空的 object 主体,因此我们将添加一个 import 语句来导入 StringIndexer。
导入 StringIndexer 算法并使用它
我们需要 StringIndexer 算法来索引 "label" 列中的值。这就是为什么需要导入 StringIndexer 的原因:
import org.apache.spark.ml.feature.StringIndexer
StringIndexer 是一个可以接受超参数列表的算法。以下设置了两个这样的参数:
val indexer = new StringIndexer().setInputCol(bcwFeatures_IndexedLabel._2).setOutputCol(bcwFeatures_IndexedLabel._3)
我们已经创建了一个 indexer StringIndexer 算法实例。下一步将是使用 buildDataFrame 方法在新的通用 DataFrame 上拟合模型:
val indexerModel = indexer.fit(dataSet)
val indexedDataFrame = indexerModel.transform(dataSet)
生成的 StringIndexerModel 被转换:
indexedDataFrame.show
show 方法现在显示 DataFrame 的前 20 行:

StringIndexer 的 fit 和 transform 结果
"features" 列与 "bcw-diagnoses-column" 并存是有原因的。存在于特征向量中的 "features" 是与特定组织样本的诊断紧密相关的属性。"bcw-diagnoses-column" 代表原始数据集中的类别列。这是一个二元分类问题,其中类别不能有数值度量,因此我们必须人工分配 0 或 1 的值。在这种情况下,2 和 4 分别代表良性肿瘤和恶性肿瘤。位于 "features" 列旁边的 "label" 列包含两种类型的值:
-
0.0 -
1.0
从原始 DataFrame 的 "bcw-diagnoses-column" 中索引的值是由 buildDataFrame 方法产生的,分别分配了 0.0 到 2.0 和 1.0 到 4.0 的值。
接下来,我们将深入探索机器学习(ML)领域。与任何机器学习练习一样,通常会将数据集分为训练集和测试集。这正是我们在下一个编码步骤中将要做的。
将 DataFrame 分割为训练集和测试集
我们将把我们的 DataFrame 分成两部分:
-
训练集—75%
-
测试集—25%
训练集用于训练(拟合)模型,其余 25% 将用于测试:
val splitDataSet: Array[org.apache.spark.sql.Dataset[org.apache.spark.sql.Row]] = indexedDataFrame.randomSplit(Array(0.75, 0.25), 98765L)
//create two vals to hold TrainingData and TestingData respectively
val trainDataFrame = splitDataSet(0)
val testDataFrame = splitDataSet(1)
为了验证我们的分割是否成功,我们将在 trainDataFrame 和 testDataFrame 数据帧上运行 count 方法。
我们将把这个作为练习留给读者。接下来,我们将继续创建一个 LogisticRegression 分类器模型并将其参数传递给它。
创建 LogisticRegression 分类器并设置其超参数
LogisticRegression 分类器可以接受超参数,我们将通过使用 LogisticRegression API 中的适当设置方法来设置这些超参数。由于 Spark ML 支持弹性网络正则化,我们将首先传递此参数。我们还想添加两个额外的参数:
-
"features"列 -
索引的
"label"列
val logitModel = new LogisticRegression() .setElasticNetParam(0.75) .setFamily("auto") .setFeaturesCol(bcwFeatures_IndexedLabel._1) .setLabelCol(bcwFeatures_IndexedLabel._3).fit(trainDataSet)
我们刚才所做的是通过创建一个 LR 分类器模型来启动训练过程。我们现在处于一个位置,可以通过将输入特征测量与它们的标记输出关联起来,在训练数据集上训练我们的 LR 模型。为了回顾,我们传递了一个 "features" 列,一个 "label" 列和一个弹性网络系数。
接下来,我们将通过转换操作和测试数据集数据执行我们的模型。
在测试数据集上运行 LR 模型
现在,我们将对 LogisticRegression 模型调用 transform 方法:
//Next run the model on the test dataset and obtain predictions
val testDataPredictions = logitModel.transform(testDataSet)
transform 方法调用返回一个新的 DataFrame。不仅如此,那个模型转换步骤还产生了三个新列:
-
rawPrediction -
probability -
predictions
接下来,我们将显示这个 DataFrame 的前 25 行:
testDataPredictions.show(25)
请参阅以下表格,查看由我们在测试数据集上运行的 LR 模型生成的预测结果:

模型转换产生的三个新列
Spark 的管道 API 为我们提供了必要的工具,帮助我们构建管道。一个管道是一个工作流程,由一系列我们称之为管道阶段的阶段组成。正如我们稍后将看到的,这些阶段是按顺序执行的。
话虽如此,我们现在将继续进行下一项业务——创建一个包含以下阶段的 数据管道:
-
一个对数模型
-
一个索引器
使用两个阶段构建乳腺癌管道
创建管道并添加两个阶段,如下所示:
//Start building a Pipeline that has 2 stages, an Indexer and a Classifier
val wbcPipeline = new Pipeline().setStages(
ArrayPipelineStage ++ ArrayPipelineStage
)
接下来,让我们对我们的管道做一些事情。我们可以立即做以下事情:
-
使用训练数据集训练管道
-
在我们的
pipelineModel上对测试集数据进行transform操作:
val pipelineModel = wbcPipeline.fit(trainDataSet)
接下来,我们将通过在 pipelineModel 上对测试数据集运行 transform 操作来进行预测:
val predictions = pipelineModel.transform(testDataSet)
下一步的重点是获得可衡量的指标。这些是关键性能指标,是那些通过事实和数字评估我们每个算法表现如何的指标。它们彼此之间是如何排列的?有什么图形评估工具可以帮助我们评估对特定二分类有贡献的算法的性能?为了了解进行这种评估需要什么,我们可能首先想问以下问题:某个乳腺癌样本的预测值与预先设定的标签有多接近?
实施目标 5 – 评估二分类器的性能
本节全部关于获取评估、指标和支持 Spark ML API。在本节中,我们将深入探讨评估步骤在衡量有效性可量化指标方面的重要性,如下所述:
-
我们的乳腺癌分类任务是一个监督学习分类问题。在这种问题中,有一个所谓的真实输出,以及一个分类器或机器学习模型为我们的乳腺癌数据集中的每个特征测量或数据点生成的预测输出。
-
我们现在将注意力转向通过推导某些指标来评估我们的二分类算法的性能。那么,问题是这样的:纯准确率是否足够来衡量我们分类器评估努力的正确性?在这里,纯准确率试图简单地告诉预测是否正确。好吧,有什么更好的方法?我们可以使用二分类评估器来评估正确性,从而对这种准确性的性能有一个衡量标准。
-
回到纯准确性的同一主题,再次需要问的问题是:这是一个足够好的指标吗?结果证明,纯准确率不是一个很好的指标,因为它没有考虑到错误类型。
-
由于上述原因,我们将派生更好的指标,例如ROC 曲线下面积(AUC)和精确率召回率曲线下面积(AUPCR)。我们将使用 Spark 的
BinaryClassificationMetrics来计算这些指标。
接下来,我们将继续实施指标派生过程的实现部分。首先,我们将创建一个BinaryClassificationEvaluator。我们将重复使用这个评估器,并使用一种我们称之为纯准确度的指标或分数来评估预测:
val modelOutputAccuracy: Double = new BinaryClassificationEvaluator() .setLabelCol("label") .setMetricName("areaUnderROC") //Area under Receiver Operating Characteristic Curve .setRawPredictionCol("prediction") .setRawPredictionCol("rawPrediction")
.evaluate(testDataPredictions)
我们刚刚计算了一个所谓的纯准确度分数,我们在上一行代码中将其称为modelOutputAccuracy。作为练习,读者被邀请确定他们的纯准确度分数。他们可能提出的问题是:这个分数有用吗?这是一个天真型的分数吗?
完成上述任务后,我们现在将注意力转向下一个任务,通过在我们的预测DataFrame上运行select操作来派生一个新的DataFrame:
val predAndLabelsDFrame:DataFrame = predictions.select("prediction", "label")
println("Validated TestSet Results Dataset is: " + validatedTestResultsDataset.take(10))
我们还没有完成。我们将把在前面的代码中派生的"predictions"和"label" DataFrame转换为RDD[Double, Double]:
val validatedRDD2: RDD[(Double, Double)] = predictionAndLabels.rdd.collect { case Row(predictionValue: Double, labelValue: Double) => (predictionValue,labelValue)
}
现在RDD已经准备好了。但我们为什么要这样做呢?答案是:我们讨论了派生更好、有意义且非天真型的指标分数。现在我们将创建一个名为classifierMetrics的BinaryClassificationMetrics实例:
val classifierMetrics = new BinaryClassificationMetrics(validatedRDD2)
BinaryClassificationMetrics为我们提供了工具,以派生对我们乳腺癌二分类任务有意义的评估指标。在核心上,二分类是一种机器学习方法,用于将新的、未分类的、即将到来的数据分类到两个互斥的类别之一。例如,我们的分类器将乳腺癌样本分类为良性或恶性,但当然不是两者都是。更具体地说,乳腺癌二分类器管道预测目标乳腺癌样本属于两种结果之一,即良性或恶性。这看起来像是一种简单的是或否型预测。
当然,我们的模型已经表现良好并完成了繁重的工作。然而,我们想要对其性能进行测试。为了做到这一点,我们需要数值指标,如果经过适当的思考和计算,这些指标将告诉我们模型的表现故事,并以有意义的方式呈现。
这些指标是什么,它们在何时具有相关性?当一个实验分析单元平衡时——类似于我们的乳腺癌数据集的乳腺癌样本数量——以下指标将具有相关性:
-
真正例率(TPR):这是预测输出中真正例(简称为TPs)与假阴性(简称为FNs)的比率。数学上,它表示如下:TPR = TPs / (TPs + FNs)。TPR 也被称为命中率、召回率或灵敏度。
-
假阳性率(FPR):数学上表示为FPR = 1 - (TNs / (TNs + FPs)),其中TNs代表真阴性,FPs代表假阳性。
-
在我们进一步进行之前,有必要对 TPs、TNs、FNs 和 FPs 进行一些解释:
-
真阳性指的是那些最终证明为真正恶性的预测
-
真阴性指的是那些最终证明为真正良性的预测
-
假阴性指的是那些被错误标记为良性的乳腺癌样本
-
假阳性指的是那些被错误标记为恶性的乳腺癌样本
-
接收者操作特征(ROC)曲线:此曲线下的面积是二分类性能的衡量指标。这是一个以x轴上的 FPR 和y轴上的 TPR 为图形的绘制。典型的曲线将在后面展示。
-
Precision-Recall (PR)曲线下的面积:这是以y轴上的精确率和x轴上的准确率为图形的绘制。为了绘制曲线,我们需要计算(precision value, accuracy value)对。为了计算这些值,应用以下数学方程式:精确率 = TPs / (TPs + FPs);准确率 = TPs / (TPs + FNs)。ROC 曲线将在后面展示,随后是 PR 曲线。
ROC 和 PR 曲线都代表二分类器的性能。为什么会这样?ROC 曲线下的面积成为二分类器性能的衡量指标。如果一个特定算法的曲线从左到右向上弯曲更高(其下的面积更大),那么它具有更低的假阳性率,使其比具有更高假阳性率的另一个算法的曲线更好。这很有用,尽管不一定是最优指标。典型的 ROC 曲线如下所示:

典型的 ROC 曲线
值得一提的下一个指标是所谓的精确率-召回率曲线,或简称 PR 曲线。此曲线涉及计算两个独立的指标,精确率和召回率。
PR 曲线以准确率作为x轴,精确率作为y轴进行绘制。在这种情况下,如果一个特定算法的曲线向上弯曲更高,靠近右上角,那么它在二分类任务中的表现比其他算法更好。TPs 更多,FNs 相对较少。这表明分类效果更好。
这完成了我们对二分类器指标及其计算的讨论,它们的计算标志着乳腺癌数据分析计划的一个重要里程碑。
事实上,如果我们构建的对数模型表现良好,ROC 判别曲线下的面积应该代表预测性能的有意义度量:
val accuracyMetrics = (classifierMetrics.areaUnderROC(), classifierMetrics.areaUnderPR())
//Area under ROC
val aUROC = accuracyMetrics._1
println(s"Area under Receiver Operating Characteristic (ROC) curve: ${aUROC} ")
val aPR = accuracyMetrics._2
println(s"Area under Precision Recall (PR) curve: ${aPR} ")
指标已准备好。以下是结果。运行管道应生成以下指标:
Area under Receiver Operating Characteristic (ROC) curve: 0.958521384053299
Area under Precision Recall (PR) curve: 0.9447563927932
这就结束了我们的乳腺癌分类任务。在上一个部分,关于随机森林乳腺癌管道,我们向您展示了如何将您的管道应用程序部署到 Spark 中。同样,以类似的方式,我们也可以部署我们的逻辑回归管道。
摘要
在本章中,我们学习了如何使用两种方法实现二元分类任务,例如,使用随机森林算法的机器学习管道,其次是使用逻辑回归方法。
这两个管道将数据分析的几个阶段合并到一个工作流程中。在这两个管道中,我们计算了指标,以估计我们的分类器表现得多好。在数据分析任务早期,我们引入了一个数据预处理步骤,以去除那些由占位符?填充的缺失属性值的行。通过消除 16 行不可用的属性值和 683 行仍有可用属性值的行,我们构建了一个新的DataFrame。
在每个管道中,我们还创建了训练、训练和验证数据集,然后是训练阶段,其中我们在训练数据上拟合模型。与每个 ML 任务一样,分类器可能会通过旋转训练集细节来学习,这是一种称为过拟合的普遍现象。我们通过达到一个减少但最优的属性数量来解决这个问题。我们通过用各种属性组合拟合我们的分类器模型来实现这一点。
在下一章中,我们将把我们的开发工作从本地的spark-shell转移到其他地方。这次,我们将利用运行在Hortonworks 开发平台(HDP)沙盒虚拟机内的 Zeppelin Notebook。
为了结束本章,我们将进入最后一节,在那里我们将向读者提出一系列问题。
问题
我们现在将列出一系列问题,以测试你对所学知识的掌握程度:
-
你如何理解逻辑回归?为什么它很重要?
-
逻辑回归与线性回归有何不同?
-
列出
BinaryClassifier的一个强大功能。 -
与乳腺癌数据集相关的特征变量是什么?
乳腺癌数据集问题是一个可以通过其他机器学习算法来处理的分类任务。其中,最突出的技术包括支持向量机(SVM)、k-最近邻和决策树。当你运行本章开发的管道时,比较每种情况下构建模型所需的时间和每个算法正确分类的数据集输入行数。
这就结束了本章。下一章将实现一种新的管道,即股票预测任务管道。我们将看到如何使用 Spark 处理更大的数据集。股票价格预测不是一个容易解决的问题。我们将如何应对,这是下一章的主题。
第三章:股价预测
本章的目标是通过使用机器学习(ML)来预测近或长期股票价格的价值。从投资者的角度来看,跨多个公司的投资(在股票中)是股票,而在单个公司的此类投资是股份。大多数投资者倾向于长期投资策略以获得最佳回报。投资分析师使用数学股票分析模型来帮助预测长期未来的股价或价格变动。这些模型考虑过去的股票价格和其他指标来对公司财务状况进行评估。
本章的主要学习目标是实现一个 Scala 解决方案,用于预测股市价格。从股价预测数据集开始,我们将使用 Spark ML 库的 ML API 构建股价预测管道。
下面是我们将参考的数据集:
-
股市预测每日新闻 | Kaggle。
-
文中引用:(Kaggle.com, 2018) 股市预测每日新闻。
-
Kaggle. [在线] 可用:
www.kaggle.com/aaron7sun/stocknews[访问日期:2018 年 7 月 27 日]。
以下列表是本章各个学习成果的章节划分:
-
股价二元分类问题
-
入门
-
实施目标
股价二元分类问题
股价有上涨和下跌的趋势。我们希望使用 Spark ML 和 Spark 时间序列库来探索过去几年的历史股价数据,并得出像平均收盘价这样的数字。我们还希望我们的股价预测模型能够预测未来几天股价的变化。
本章介绍了一种 ML 方法,用于降低与股价预测相关的复杂性。我们将通过特征选择获得一组更小的最优财务指标,并使用随机森林算法构建价格预测管道。
我们首先需要从ModernScalaProjects_Code文件夹中下载数据集。
股价预测数据集概览
我们将使用两个来源的数据:
-
Reddit worldnews
-
道琼斯工业平均指数(DJIA)
下面的入门部分有两个明确的目标:
-
将我们的开发环境从之前的以本地 Spark shell 为中心的开发环境迁移到虚拟设备。这自然意味着需要设置先决资源。
-
实现上述目标还意味着能够启动一个新的 Spark 集群,该集群运行在虚拟设备内部。
入门
为了实现本节的目标,我们将在尝试实现第一个目标——设置 Hortonworks 开发平台(HDP)沙箱之前,编制一个资源列表——一个需要设置的先决软件列表。关于虚拟设备概述部分,这很有帮助。
在其核心,HDP 沙箱是一个强大的数据管道开发环境。这个设备及其支持生态系统,如底层操作系统和虚拟机配置设置,构成了开发基础设施的核心。
以下是需要设置或验证的先决条件资源列表——必须设置的软件:
-
支持硬件虚拟化的 64 位主机机器。要检查处理器和主板对虚拟化的支持,请下载并运行一个名为 SecurAble 的小工具。BIOS 应启用或设置为支持虚拟化。
-
主机操作系统 Windows 7、8 或 10,macOS。
-
兼容的浏览器,如 Internet Explorer 9、Mozilla Firefox 的稳定版本、Google Chrome 或 Opera。
-
主机机器至少 16GB 的 RAM。
-
需要安装的受支持的虚拟化应用程序,例如 Oracle VirtualBox 版本 5.1 或更高版本(这是我们首选的虚拟化应用程序)或 VMWare Fusion。
-
HDP 沙箱下载文件。此文件以开放虚拟化格式存档(OVA)文件的形式交付。
在下一节中,我们将审查资源列表中的先决条件。
硬件虚拟化支持
从ModernScalaProjects_Code文件夹中获取 SecurAble 的副本。SecurAble 是一个能够告诉你关于你的机器处理器的以下信息的程序:
-
确认主机机器处理器上是否存在 64 位指令
-
是否有硬件虚拟化支持
为了确定前面的先决条件,SecurAble 不会对你的机器进行任何更改。在运行 SecurAble 应用程序文件时,它将显示一个类似于以下截图的屏幕:

运行 SecurAble 应用程序文件的屏幕截图
点击 64 最大位长度,SecurAble 将返回 64 位处理的存或不存在,如下截图所示:

显示 64 位处理存在或不存在窗口截图
我的 Windows 64 位机器上的芯片组已确认提供 64 位操作模式。接下来,点击“是,硬件虚拟化”,SecurAble 将报告我的处理器确实提供了硬件虚拟化支持,如下截图所示:

显示 SecurAble 上虚拟化硬件支持的窗口截图
如果 SecurAble 在您的机器上报告了完全相同的结果,那么您可能有一个可以支持 Oracle VirtualBox 的主机机器。请注意,在 BIOS 级别,虚拟化的支持可能已经设置。如果不是这种情况,请启用它。请注意,SecurAble 将无法报告 BIOS 对虚拟化功能的支持。
在继续之前,请确保满足先前的先决条件。接下来,考虑安装一个能够托管虚拟设备的受支持的虚拟化应用程序的先决条件。
安装受支持的虚拟化应用程序
以下安装虚拟化应用程序的步骤:
- 从 Oracle VirtualBox 网站下载最新的 VirtualBox 二进制文件:

最新 VirtualBox 二进制文件截图
- 双击 Oracle VirtualBox 二进制文件。设置欢迎屏幕如下所示:

设置窗口的截图
- 在欢迎屏幕上点击“下一步”。在随后出现的屏幕上,选择您希望 VirtualBox 安装的位置:

放置文件夹的设置步骤截图
- 点击“确定”以进入“准备安装”屏幕:

准备安装屏幕截图
- 点击“安装”并完成任何说明性的步骤以完成安装。此过程完成后,在任务栏或桌面上放置一个快捷方式。现在,按照以下方式启动 VirtualBox 应用程序:

VirtualBox 应用程序准备启动的截图
您可以选择以下方式删除无法访问的机器 vm、vm_1 和 CentOS:

展示如何删除无法访问的机器的截图
- 接下来,在文件 | 首选项... | 输入下取消选中“自动捕获键盘”选项:

设置虚拟机的最终步骤截图
虚拟机现在已全部设置。在下一步中,我们将下载并将沙盒导入其中。
下载 HDP 沙盒并导入
以下下载 HDP 沙盒的步骤:
-
转到
hortonworks.com/downloads/#sandbox并下载 Hortonworks 沙盒虚拟设备文件。 -
将沙盒虚拟设备文件移动到主机机器上的一个方便位置。按照以下顺序执行以下点击操作:文件 | 导入设备... 然后,选择要导入的虚拟设备文件,从而导入相应的磁盘映像:

导入磁盘映像需要执行的步骤
- 接下来,让我们在设备设置屏幕中调整设备设置。确保您将可用 RAM 增加到至少
10000 MB。保留其他默认设置,然后点击导入:

将虚拟设备导入 Oracle VirtualBox 的截图
虚拟设备现在已导入到 Oracle VirtualBox 中。以下部分提供了 Hortonworks Sandbox 虚拟设备的简要概述。
Hortonworks Sandbox 虚拟设备概述
Hortonworks Sandbox 是一个虚拟机或虚拟设备,以 .ova 或 .ovf 扩展名的文件形式提供。它对主机操作系统表现为裸机,并具有以下组件:
-
被底层主机操作系统视为应用程序的客户端操作系统
-
我们想要的虚拟设备文件是一个
.ova文件,它位于虚拟机文件夹下的ModernScalaProjects_Code文件夹中 -
在虚拟操作系统上运行的应用程序
如此一来,我们已完成了先决条件的设置。现在让我们第一次运行虚拟机。
打开虚拟机并启动 Sandbox
让我们看看以下步骤:
- 运行 Oracle VirtualBox 启动图标。以下是这样显示关闭电源的 Sandbox 的启动屏幕:

关闭电源的 Sandbox 启动屏幕截图
启动屏幕显示了更新后的 Hortonworks Sandbox 虚拟设备及其更新后的配置。例如,我们的基本内存现在是 10000 MB。
- 接下来,在 Sandbox 上右键单击并选择“开始”|“正常启动”:

开始步骤的截图
如果一切顺利,您应该会看到以下 Hortonworks Docker Sandbox HDP [运行中]屏幕:

Hortonworks Docker Sandbox HDP [运行中]的截图
- 我们想要登录到 Sandbox。Alt + F5 将您带到以下
sandbox-host login登录屏幕:

展示如何登录 Sandbox 的截图
使用 root 用户名和 hadoop 密码登录。
- 编辑
hosts文件,将127.0.0.1映射到sandbox-hdp.hortonworks.com。在 Windows(主机)机器上,此文件位于C:\Windows\System32\drivers\etc:

显示编辑主机文件的截图
-
保存更新的
hosts文件,并在浏览器中加载 URLsandbox-hdp.hortonworks.com:8888之前验证这些更改是否生效。 -
接下来,在您的浏览器中加载 URL
sandbox-hdp.hortonworks.com:4200以启动 Sandbox 网页客户端。将默认密码从hadoop改为其他密码。请注意,虚拟设备运行的是 CentOS Linux 虚拟操作系统:

启动 Sandbox 网页客户端的截图
在下一节中,我们将设置一个 SSH 客户端,用于在沙盒和您的本地(主机)机器之间传输文件。
设置沙盒和主机机器之间数据传输的 SSH 访问
SSH 代表 Secure Shell。我们想要设置 SSH 网络协议,以在主机机器和运行虚拟应用的虚拟机之间建立远程登录和安全的文件传输。
需要遵循两个步骤:
-
设置 PuTTY,一个第三方 SSH 和 Telnet 客户端
-
设置 WinSCP,一个 Windows 的 Secure File Transfer Protocol (SFTP) 客户端
设置 PuTTY,一个第三方 SSH 和 Telnet 客户端
让我们看看以下 PuTTY 的安装步骤:
- PuTTY 安装程序
putty-64bit-0.70-installer.exe可在ModernScalaProjects_Code文件夹中找到。您可以通过双击安装器图标来运行它,如下所示:

PuTTY 安装器图标
- 选择要安装 PuTTY 的目标文件夹并点击下一步:

安装 PuTTY 的截图
- 选择或取消选择您想要安装的任何产品功能:

产品功能列表的截图
- 然后,点击安装。PuTTY 和其他支持工具将被安装:

PuTTY 和支持工具安装的截图
- 运行 PuTTYgen。在 PuTTY 密钥生成器屏幕上,按生成按钮并遵循屏幕上的说明。点击保存公钥按钮并将生成的公钥保存到名为
authorized_keys的文件中,保存到方便的位置,但在输入密码短语之前:

运行 PuTTY 密钥生成器后要执行的步骤截图
- 点击保存私钥,如前一个截图中的 3 所示。这将允许您在方便的位置保存您的私钥。这可以与公钥位置相同,如下所示:

私钥在方便位置保存的截图
- 在这一点上,我们希望将公钥上传到我们的沙盒。启动沙盒,然后加载沙盒网页客户端,就像我们之前做的那样。按照以下截图中的步骤执行 1、2、3 和 4。公钥保存为
authorized_key:

展示将公钥上传到我们的沙盒的截图
-
使用文件 | 退出关闭 PuTTYgen。
-
打开 PuTTY 并点击会话。我们想要创建并保存一个会话。按照以下截图中的数字设置它:
-
点击会话并选择记录
-
将主机名输入为我们的沙盒
-
输入端口为
2222 -
然后点击按钮保存
-

展示创建和保存会话步骤的截图
- 通过点击保存将会话保存为
sandbox-hdp.hortonworks.com(已保存的会话)。接下来,在连接下点击数据并输入沙盒的登录名。现在不要点击打开:

保存会话后的步骤截图
- 在输入自动登录用户名后,点击连接 | SSH | 认证 | 在点击浏览...后加载私钥。加载私钥并点击打开。这应该会与沙盒建立 SSH 连接:

输入自动登录用户名后的步骤截图
让我们回顾并总结到目前为止我们为设置 PuTTY(一个第三方 SSH 和 Telnet 客户端)以及沙盒和主机机之间数据传输的 SSH 访问所采取的步骤:
-
点击会话。在沙盒虚拟设备的主机名(或 IP 地址)字段下输入主机名,然后选择适当的 SSH 协议。继续,导航到连接 | 数据并在自动登录框中输入沙盒的登录名。
-
然后,导航到连接 | SSH | 认证 | 加载私钥。
-
最后,点击会话。加载保存的会话并点击保存;这会更新会话。
WinSCP 是一个流行的 Windows 图形 SSH 客户端,它使得在本地(主机)机器和沙盒之间传输文件变得容易。现在让我们设置 WinSCP。
设置 WinSCP,一个 Windows 的 SFTP 客户端
以下步骤解释了如何设置 WinSCP:
- WinSCP 的二进制文件位于
ModernScalaProjects_Code文件夹下。下载它并运行。安装完成后,首次启动 WinSCP。登录屏幕如下所示:

登录屏幕截图
点击新建站点,确保文件协议是 SFTP,并在主机名下输入沙盒主机名。将端口从22改为2222。您可能想在用户名下输入root和沙盒 Web 客户端的密码。接下来,点击 6,这会带我们到高级站点设置屏幕:

高级站点设置屏幕截图
-
在先前的高级站点设置屏幕中,钻到 SSH 下的认证并加载私钥文件。点击确定。
-
现在,再次启动 WinSCP。点击登录应该会与沙盒建立连接,您应该能够如下进行文件传输:

显示沙盒能够双向传输文件的截图
- 连接建立后,结果屏幕应该如下所示:

连接建立后的屏幕截图
在下一步中,我们将继续进行沙盒配置更新。
更新 Zeppelin 所需的默认 Python
沙盒拥有一个完整的 Spark 开发环境,与之前我们本地的 Spark 开发环境有一个显著的不同:Zeppelin Notebook。
什么是 Zeppelin?
Zeppelin 是一个基于 Web 的笔记本,具有以下功能:
-
数据探索
-
交互式数据分析
-
数据可视化和交互式仪表板
-
协作文档共享
Zeppelin 依赖于 Python 2.7 或更高版本,但沙盒本身仅支持版本 2.6。因此,我们将不得不用 2.7 替换 2.6。在我们继续之前,让我们检查我们的 Python 版本:

展示 Python 版本的截图
没错!我们需要用 Python 2.7 替换 Python 2.6,并将 Notebook 更新到最新版本。
完成此任务的步骤总结如下:
-
设置 Anaconda 数据科学环境。您可以简单地设置一个较轻的 Anaconda 版本,即带有 Python 2.7 的 Miniconda。Miniconda 带来了许多流行的数据科学包。
-
设置 Miniconda 没有打包的任何包。确保您有 SciPy、NumPy、Matplotlib 和 Pandas。有时,我们只需将 Spark/Scala 的
DataFrame直接传递到 Pyspark 中的 Python,就可以快速生成可视化。
按照以下步骤进行操作:
- 第一步是下载 Miniconda 的安装程序并按照以下方式运行:

展示 Miniconda 安装程序的截图
通过安装过程。这很简单。安装完成后,重新启动 Web 客户端以允许更改生效。现在,使用 root 和您的秘密密码登录沙盒。
- 要检查我们是否真的有一个新的、升级后的 Python,请按照以下方式发出
python命令:

展示如何发出 Python 命令的截图
哇!我们有了新的 Python 版本:2.7.14。
在下一节中,我们将使用 curl 更新 Zeppelin 实例。
更新我们的 Zeppelin 实例
需要执行以下步骤来更新 Zeppelin 实例:
-
如果尚未安装,请安装 curl
-
使用 Hortonworks 的最新和最好的笔记本更新您的 Zeppelin 实例
请按照以下步骤安装 curl:
- 运行
curl --help命令:

展示如何运行 curl --help 命令的截图
curl --help命令确认我们已经安装了 curl。现在让我们尝试更新 Zeppelin:

展示更新 Zeppelin 的截图
- 运行
curl命令以使用最新的笔记本更新 Zeppelin 实例。以下截图显示了更新后的 Zeppelin 实例:

展示更新后的 Zeppelin 实例的截图
现在,让我们回到 hdp.hortonworks.com:8888。
启动 Ambari 仪表板和 Zeppelin UI
启动 Ambari 和 Zeppelin 所需的步骤如下:
- 点击启动仪表板按钮,以
maria_dev身份登录以导航到 Ambari 仪表板:

展示在 Ambari 仪表板中导航的截图
- 点击快速链接下的 Zeppelin UI 将带我们到 Zeppelin UI,如下所示:

Zeppelin UI 页面的截图
Zeppelin UI 在端口 9995 上运行。为了使我们的 Zeppelin Notebook 与 Spark 2 和 Python 2.7 一起工作,需要更新 Spark 和 Python 解释器。
通过添加或更新解释器来更新 Zeppelin Notebook 配置
更新 Zeppelin Notebook 需要执行以下步骤:
-
我们需要更新解释器,包括 Spark 2 解释器,并添加 Python 解释器。
-
在 Zeppelin UI 页面上,点击匿名 | 解释器,如下所示:

在 Zeppelin UI 页面上执行步骤的截图
点击解释器链接将带我们到解释器页面。首先,我们将更新 Spark 2 解释器。
更新 Spark 2 解释器
更新 Spark 2 解释器的步骤如下:
- 我们将按以下方式更新
SPARK_HOME属性:

展示如何更新 SPARK_HOME 属性的截图
- 接下来,我们将更新
zeppelin.pyspark.python属性,使其指向新的 Python 解释器:

展示如何更新 zeppelin.pyspark.python 属性的截图
- 接下来,让我们创建一个新的 Python 解释器,如下所示:

创建新 Python 解释器的截图
将 zeppelin.pyspark.python 更新为 /usr/local/bin/bin/python。
- 为了使所有这些解释器更改生效,我们需要重新启动服务。前往 Ambari 仪表板页面。在右上角找到服务操作,在下拉菜单中选择重启所有:

展示 Zeppelin Notebook 准备好进行开发的最终步骤的截图
到目前为止,Zeppelin Notebook 已经准备好进行开发。
实施目标
本节的目标是开始使用随机森林算法开发数据管道。
实施目标列表
以下实施目标相同,涵盖了随机森林管道和线性回归。我们将执行一次初步步骤,如探索性数据分析(EDA),然后开发特定于特定管道的实现代码。因此,实施目标如下列出:
-
获取股票价格数据集。
-
在 Sandbox Zeppelin Notebook 环境中执行初步的 EDA(探索性数据分析),并运行统计分析。
-
在 Zeppelin 中逐步开发管道,并将代码移植到 IntelliJ。这意味着执行以下操作:
-
在 IntelliJ 中创建一个新的 Scala 项目,或将现有的空项目导入 IntelliJ,并从笔记本中逐步开发出的代码创建 Scala 工件。
-
不要忘记在
build.sbt文件中连接所有必要的依赖项。 -
解释管道的结果,例如分类器表现如何。预测值与原始数据集中的值有多接近?
-
在下一个子节中,我们将下载股票价格数据集。
第 1 步 – 创建数据集文件路径的 Scala 表示
股票价格数据集位于ModernScalaProjects_Code文件夹中。获取一份副本并将其上传到沙盒,然后将其放置在以下方便的位置:
scala> val dataSetPath = "\\<<Path to the folder containing the Data File>>"
scala> val dataFile = dataSetPath + "\\News.csv"
在下一步中,让我们创建一个弹性分布式数据集(RDD)。
第 2 步 – 创建一个RDD[String]
调用 Spark 在沙盒中提供的sparkContext的textFile方法:
scala> val result1 = spark.sparkContext.textFile(dataSetPath + "News.csv")
result1: org.apache.spark.rdd.RDD[String] = C:\<<Path to your own Data File>>\News.csv MapPartitionsRDD[1] at textFile at <console>:25
结果 RDD result1 是一个分区结构。在下一步中,我们将遍历这些分区。
第 3 步 – 在数据集的换行符周围拆分 RDD
在result1 RDD 上调用flatMap操作,并按每个分区的"\n"(行尾)字符拆分,如下所示:
scala> val result2 = result1.flatMap{ partition => partition.split("\n").toList }
result2: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[2] at flatMap at <console>:27
每个分区后面跟着一个字符串。在下一步中,我们将转换result2 RDD。
第 4 步 – 转换 RDD[String]
在result2 RDD 上调用map操作。此 RDD(数据行)中的每一行都由逗号分隔的股票价格标题数据组成,如下所示:
scala> val result2A = result2.map(_.split(","))
result2A: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[3] at map at <console>:29
结果 RDD result2A 是一个RDD[Array[String]]。该 RDD 由字符串数组组成,其中每个字符串代表一行。
第 5 步 – 进行初步数据分析
此步骤被分解为一系列更小的步骤。过程从创建DataFrame开始。
从原始数据集创建 DataFrame
通过指定适当的option再次加载股票价格数据集文件,让 Spark 在创建DataFrame之前自动推断数据集的模式,如下所示:
scala> val spDataFrame = spark.read.format("com.databricks.spark.csv").option("delimiter", ",").option("header", "true").option("inferSchema", "true").load("News.csv")
spDataFrame: org.apache.spark.sql.DataFrame = [Date: string, Label: int ... 5 more fields]
结果结构spDataFrame是DataFrame。
从 DataFrame 中删除日期和标签列
Date和Label是我们现在可能排除的列,如下所示:
scala> val newsColsOnly = spDataFrame.drop("Date", "Label")
newsColsOnly: org.apache.spark.sql.DataFrame = [Top1: string, Top2: string ... 23 more fields]
结果结构是一个新的DataFrame,它包含所有 25 个顶级标题。
让 Spark 描述 DataFrame
按照以下顺序调用describe和show方法,以获得前 20 行的视觉表示:
scala> val expAnalysisTestFrame = spDataFrame.describe("TopHeadline1", "TopHeadline2", "TopHeadline3","TopHeadline4","TopHeadline5", TopHead.....)
expAnalysisTestFrame: org.apache.spark.sql.DataFrame = [summary: string, TopHeadline: string ... 25 more fields]
scala> newsColsOnly.show
在下一步中,让我们向DataFrame添加一个新列,一个名为AllMergedNews的expAnalysisTestFrame。
向 DataFrame 添加新列并从中推导出 Vector
让我们执行以下步骤以添加新列并推导出Vector:
- 通过创建一个新的
AllMergedNews列来替换Top1列,创建一个新的DataFrame,如下调用withColumn方法:
scala> val mergedNewsColumnsFrame = newsColsOnly.withColumn("AllMergedNews", newsColsOnly("TopHeadline1"))
mergedNewsColumnsFrame: org.apache.spark.sql.DataFrame = [TopHeadline1: string, TopHeadline2: string ... 23 more fields]
- 接下来,将
mergedNewsColumnsDataFrame 转换为Vector,如下所示:
scala> import org.apache.spark.sql.functions
import org.apache.spark.sql.functions
scala> val mergedFrameList = for (i <- 2L to newsColsOnly.count() + 1L) yield mergedNewsColumnsFrame.withColumn("AllMergedNews", functions.concat(mergedNewsColumnsFrame("AllMergedNews"), functions.lit(" "), mergedNewsColumnsFrame("Top" + i.toString)))
mergedFrameList: scala.collection.immutable.IndexedSeq[org.apache.spark.sql.DataFrame] = Vector([Top1: string, Top2: string ... 4 more fields], [Top1: string, Top2: string ... 4 more fields])
- 在下一步中,我们简单地从
mergedFrameList中派生出mergedFinalFrameDataFrame:
scala> val mergedFinalFrame = mergedFrameList(0)
mergedFinalFrame: org.apache.spark.sql.DataFrame = [Top1: string, Top2: string ... 4 more fields]
到目前为止,我们有一个需要一些预处理的 DataFrame。让我们首先去除停用词。
移除停用词——这是一个预处理步骤
我们想要消除的停用词包括诸如 a、an、the 和 in 等词。自然语言工具包(NLTK)如下提供帮助:
import org.apache.spark.ml.feature.StopWordsRemover
import org.apache.spark.ml.param.StringArrayParam
val stopwordEliminator = StopWordsRemover(new StringArrayParam("words","..), new StringArrayParam("stopEliminated", "..)
下一步将是使用 transform 操作。
转换合并的 DataFrame
将 mergedFinalFrame DataFrame 传递给 transform 方法。此 NLTK 步骤移除了分析中不必要的所有停用词:
val cleanedDataFrame = stopwordEliminator.transform(mergedFinalFrame)
cleanedDataFrame.show()
+-----------+-----+--------------------+--------------------+
| Date|label| words| stopEliminated|
+-----------+-----+--------------------+--------------------+
| 09 09 09| 0|[Latvia, downs, ...|[Latvia, downs, ...|[Latvia downs, d...|
|11 11 09 09| 1|[Why, wont, Aust...|[wont, Australia, N...|[wont Australia, Au...|
+-----------+-----+--------------------+--------------------+
在下一步中,我们将使用一个名为 NGram 的特征转换器。
将 DataFrame 转换为 NGrams 数组
什么是 n-gram?它简单地说是一系列项目,如字母、单词等(如我们的数据集)。我们的数据集类似于一个文本语料库。它是一个理想的候选者,可以处理成 n-gram 数组,这是一个由我们数据集最新版本中的单词组成的数组,不包含停用词:
//Import the feature Transformer NGram
import org.apache.spark.ml.feature.NGram
//Create an N-gram instance; create an N-Gram of size 2
val aNGram = new NGram(new StringArrayParam("stopRemoved"..), new StringArrayParam("ngrams", n=2)
// transform the cleanedDataFrame (the one devoid of stop words)
val cleanedDataFrame2 = aNGram.transform(cleanedDataFrame)
//display the first 20 rows
cleanedDataFrame2.show()
+-----------+-----+--------------------+--------------------+--------------------+
| Date|label| words| stopEliminated| Ngrams|
+-----------+-----+--------------------+--------------------+--------------------+
| 09 09 09| 0|[Latvia, downs, ...|[Latvia, downs, ...|[Latvia downs, d...|
|11 11 09 09| 1|[Why, wont, Aust...|[wont, Australia, N...|[wont Australia, Au...|
+-----------+-----+--------------------+--------------------+--------------------+
在下一步中,我们将通过添加一个名为 ndashgrams 的列来创建一个新的数据集。
向 DataFrame 添加一个不含停用词的新列
通过向 cleanedDataFrame2 DataFrame 添加一个名为 ndashgrams 的新列来派生一个新的 DataFrame,如下所示:
cleanedDataFrame3 = cleanedDataFrame2.withColumn('ndashgrams', ....)
cleanedDataFrame3.show()
+-----------+-----+--------------------+--------------------+--------------------+
| Date|label| words| stopEliminated| Ngrams|
+-----------+-----+--------------------+--------------------+--------------------+
| 09 09 09| 0|[Latvia, downs, ...|[Latvia, downs, ...|[Latvia downs, d...|
|11 11 09 09| 1|[Why, wont, Aust...|[wont, Australia, N...|[wont Australia, Au...|
+-----------+-----+--------------------+--------------------+--------------------+
下一个步骤更有趣。我们将应用所谓的计数向量器。
从我们的数据集语料库构建词汇表
为什么使用 CountVectorizer?我们需要一个来构建有关我们股价语料库的某些术语的词汇表:
import org.apache.spark.ml.feature.CountVectorizer
//We need a so-called count vectorizer to give us a CountVectorizerModel that will convert our 'corpus' //into a sparse vector of n-gram counts
val countVectorizer = new CountVectorizer
//Set Hyper-parameters that the CountVectorizer algorithm can take
countVectorizer.inputCol(new StringArrayParam("NGrams")
countVectorizer.outputCol(new StringArrayParam("SparseVectorCounts")
//set a filter to ignore rare words
countVectorizer.minTF(new DoubleParam(1.0))
CountVectorizer 生成一个 CountVectorizerModel,可以将我们的语料库转换为 n-gram 令牌计数的稀疏向量。
训练 CountVectorizer
我们希望通过传递最新版本的数据集来训练我们的 CountVectorizer,如下代码片段所示:
cleanedDataFrame3 = countVectorizer.fit(cleanedDataFrame2)
cleanedDataFrame3.show()
| Date|label| words| stopEliminated| NGrams| SparseVectorCounts|
| 09 09 09| 0|[Latvia, downs, ...|[Latvia, downs, ...|[Latvia downs, d...|
|11 11 09 09| 1|[Why, wont, Aust...|[wont, Australia, N...|[wont Australia, Au...|
+-----------+-----+--------------------+--------------------+--------------------+--------------------
使用 StringIndexer 将我们的输入标签列进行转换
现在,让我们使用 StringIndexer 按如下方式在数据集中索引 label 输入:
import org.apache.spark.ml.feature.StringIndexer
val indexedLabel = new StringIndexer(new StringArrayParam("label"), new StringArrayParam("label2"), ...)
cleanedDataFrame4 = indexedLabel.fit(cleanedDataFrame3).transform(cleanedDataFrame3)
接下来,让我们删除输入标签列 label。
删除输入标签列
按如下方式在 cleanedDataFrame4 DataFrame 上调用 drop 方法:
val cleanedDataFrame5 = cleanedDataFrame4.drop('label')
DataFrame[Date: string, words: array<string>, stopRemoved: array<string>, ngrams: array<string>, countVect: vector, label2: double]
cleanedDataFrame5.show()
| Date|label| words| stopEliminated| NGrams| SparseVectorCount|label2|
| 09 09 09| 0|[Latvia, downs, ...|[Latvia, downs, ...|[Latvia downs, d...|
|11 11 09 09| 1|[Why, wont, Aust...|[wont, Australia, N...|[wont Australia, Au...|
+-----------+-----+--------------------+--------------------+--------------------+--------------------+------+
接下来,让我们在删除的 label 列的位置添加一个名为 label2 的新列。
向我们的 DataFrame 添加一个新列
这次,调用 withColumn 方法添加 label2 列作为 label1 的替代:
val cleanedDataFrame6 = cleanedDataFrame5.withColumn('label', cleanedDataFrame.label2)
cleanedDataFrame6.show()
| Date|label| words| stopRemoved| ngrams| countVect|label2|
| 09 09 09| 0|[Latvia, downs, ...|[Latvia, downs, ...|[Latvia downs, d...|
|11 11 09 09| 1|[Why, wont, Aust...|[wont, Australia, N...|[wont Australia, Au...|
+-----------+-----+--------------------+--------------------+--------------------+--------------------+------+
现在,是我们将数据集划分为训练集和测试集的时候了。
将数据集划分为训练集和测试集
让我们将数据集分成两个数据集。85% 的数据集将是训练数据集,剩余的 15% 将是测试数据集,如下所示:
//Split the dataset in two. 85% of the dataset becomes the Training (data)set and 15% becomes the testing (data) set
val finalDataSet1: Array[org.apache.spark.sql.Dataset[org.apache.spark.sql.Row]] = cleanedDataFrame6.randomSplit(Array(0.85, 0.15), 98765L)
println("Size of the new split dataset " + finalDataSet.size)
//the testDataSet
val testDataSet = finalDataSet1(1)
//the Training Dataset
val trainDataSet = finalDataSet1(0)
让我们创建一个 StringIndexer 来索引 label2 列。
创建 labelIndexer 以索引 indexedLabel 列
现在让我们创建labelIndexer。这将创建一个新的索引输入,并输出label和indexedLabel列,如下所示:
val labelIndexer = new IndexToString().setInputCol("label").setOutputCol("indexedLabel").fit(input)
接下来,让我们将我们的索引标签transform回未索引的原始标签。
创建 StringIndexer 以索引列标签
以下步骤将帮助我们创建一个StringIndexer来索引label列:
val stringIndexer = new StringIndexer().setInputCol("prediction").setOutputCol("predictionLabel")
在下一步中,我们将创建RandomForestClassifier。
创建 RandomForestClassifier
现在让我们创建randomForestClassifier并传递适当的超参数,如下所示:
val randomForestClassifier = new RandomForestClassifier().setFeaturesCol(spFeaturesIndexedLabel._1)
.setFeatureSubsetStrategy("sqrt")
我们现在有一个分类器。现在,我们将创建一个新的管道并创建阶段,每个阶段都包含我们刚刚创建的索引器。
创建具有三个阶段的新数据管道
让我们先创建适当的导入,如下所示:
import org.apache.spark.ml.classification.RandomForestClassifier
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
import org.apache.spark.ml.param._
import org.apache.spark.ml.tuning.{ParamGridBuilder, TrainValidationSplit}
import org.apache.spark.ml.{Pipeline, PipelineStage}
现在让我们开始构建一个管道。这是一个有三个阶段的管道,分别是StringIndexer、LabelIndexer和randomForestClassifier。
创建具有超参数的新数据管道
创建新数据管道需要执行以下步骤:
- 创建具有以下三个阶段的新的管道:
val soPipeline = new Pipeline().
setStages(ArrayPipelineStage ++ ArrayPipelineStage) ++ ArrayPipelineStage]
- 创建一个名为
NumTrees的超参数,如下所示:
//Lets set the hyper parameter NumTrees
val rfNum_Trees = randomForestClassifier.setNumTrees(15)
println("Hyper Parameter num_trees is: " + rfNum_Trees.numTrees)
- 创建一个名为
MaxDepth的超参数树并将其设置为2,如下所示:
//set this default parameter in the classifier's embedded param map
val rfMax_Depth = rfNum_Trees.setMaxDepth(2)
println("Hyper Parameter max_depth is: " + rfMax_Depth.maxDepth)
是时候训练管道了。
训练我们的新数据管道
我们有一个准备好在训练数据集上训练的管道。拟合(训练)也会运行索引器,如下所示:
val stockPriceModel = pipeline.fit(trainingData)
接下来,在我们的stockPriceModel上运行transformation操作以生成股票价格预测。
生成股票价格预测
生成股票价格预测需要执行以下步骤:
- 在我们的测试数据集上运行
stockPriceModel转换操作,如下所示:
// Generate predictions.
val predictions = stockPriceModel.transform(testData)
- 现在让我们显示我们的
predictionsDataFrame的相关列,如下所示:
predictions.select("predictedLabel", "label", "features").show(5)
- 最后,我们希望评估我们模型的准确性,即其生成预测的能力,换句话说,找出生成的输出与预测标签之间的接近程度,如下所示:
modelOutputAccuracyEvaluator: Double = new MulticlassClassificationEvaluator()
.setLabelCol("indexedLabel")
.setPredictionCol("prediction")
.setMetricName("precision")
val accuracy = modelOutputAccuracyEvaluator.evaluate(predictions)
在我们结束本章之前,还有一些其他指标可以评估。我们将这个作为读者的练习。
使用 Spark ML API 中的MulticlassMetrics类,我们可以生成指标,这些指标可以告诉我们预测列中的预测标签值与label列中的实际标签值有多接近。
邀请读者提出另外两个指标:
-
准确性
-
加权精度
有许多其他方法可以构建 ML 模型来预测股票价格并帮助投资者制定长期投资策略。例如,线性回归是另一种常用的但相当流行的预测股票价格的方法。
摘要
在本章中,我们学习了如何利用随机森林算法根据历史趋势预测股票价格。
在下一章中,我们将创建一个垃圾邮件分类器。我们将从两个数据集开始,一个代表正常邮件,另一个代表垃圾邮件数据集。
问题
这里有一些问题列表:
-
你对线性回归有何理解?为什么它很重要?
-
线性回归与逻辑回归有何不同?
-
列出一个二元分类器的强大特征。
-
与股票价格数据集相关的特征变量有哪些?
第四章:构建垃圾邮件分类管道
谷歌 Gmail 服务的两大支柱尤为突出。这些是收件箱文件夹,接收良性或期望收到的电子邮件消息,以及垃圾邮件文件夹,接收未经请求的垃圾邮件,或者简单地说是垃圾邮件。
本章的重点是识别垃圾邮件并将其分类。它探讨了以下关于垃圾检测的主题:
-
如何区分垃圾邮件和正常邮件的技术是什么?
-
如果垃圾邮件过滤是一种合适的技巧,它如何被形式化为一个监督学习分类任务?
-
为什么某种算法在垃圾邮件过滤方面比另一种算法更好,以及它在哪些方面更好?
-
有效的垃圾邮件过滤带来的实际好处在哪里最明显?
本章实现了垃圾邮件过滤数据分析管道。
使用 Scala 和机器学习(ML)实现垃圾邮件分类是本章的整体学习目标。从为您创建的数据集开始,我们将依赖 Spark ML 库的机器学习 API 及其支持库来构建垃圾邮件分类管道。
以下列表是按章节划分的个别学习成果的分解:
-
垃圾邮件分类问题简介
-
项目的定义问题
-
使用各种算法实现垃圾邮件的二分类管道
-
目标是从
DataFrame开始,然后进行数据分析
垃圾邮件分类问题
任何电子邮件服务都应该智能地处理收到的邮件。这可能包括产生两个不同、排序的邮件流,即正常邮件和垃圾邮件。在哨兵级别的邮件处理涉及一个智能的审查过程——一个产生两个不同、排序的邮件流的分类任务——正常邮件和垃圾邮件。Gmail 的复杂垃圾邮件过滤引擎通过分类过程过滤掉垃圾邮件,在比喻意义上实现了“去粗取精”。
垃圾邮件在我们的日常生活中可能是一种有害现象,它与日益紧密相连的世界密切相关。例如,二元分类是到看似无辜的网站(这些网站托管恶意软件)的持续欺骗性链接。读者可以了解为什么垃圾邮件过滤器可以最大限度地减少垃圾邮件可能引起的问题。以下是一些总结:
-
不道德的公司从网络上收集电子邮件地址,并向人们发送大量的垃圾邮件。例如,一个 Gmail 用户,比如
gmailUser@gmail.com,被诱使点击一个看似无辜的网站,该网站伪装成人们普遍认为是值得信赖的知名网站。一种卑鄙的意图是将用户诱骗在进入一个所谓的流行值得信赖的网站时放弃个人信息。 -
另一封类似于一封由可疑网站运营商发送的垃圾邮件,
frz7yblahblah.viral.sparedays.net,正在利用人们渴望轻松赚钱的倾向。例如,看起来无害的链接包含了一个欺骗性的链接,指向一些可疑网站,该网站托管恶意软件,例如 rootkit 病毒。rootkit 非常难以删除。它是一种将自己嵌入到操作系统内核的病毒。它可以非常难以追踪和具有潜在的破坏性,以至于远程黑客可以控制您的系统,而您可能还没有意识到,您的网络可能突然停止工作。您将损失数小时的工作时间,如果您是一家公司,您还会损失收入。
以下是一张位于 Gmail 垃圾邮件文件夹中的垃圾邮件截图,表明了钓鱼行为。钓鱼是指通过欺骗手段故意恶意获取欺诈性访问个人信息的行为,如下面的截图所示:

一封垃圾邮件的例子
在下一节中,我们将探讨一些与垃圾邮件分类管道开发相关的话题。
相关背景话题
在开发垃圾邮件分类器之前,本节回顾了以下主题:
-
多维数据
-
特征的重要性
-
分类任务
-
与另一个特征相关的单个特征重要性
-
词频-逆文档频率(TF-IDF)
-
哈希技巧
-
停用词去除
-
归一化
在下一节中,我们将更详细地讨论每个主题。
多维数据
多维数据是包含多个特征的数据。在前面的章节中,我们已经处理了许多特征。话虽如此,让我们用一个例子来重申这一点,解释特征的含义以及为什么特征如此重要。
特征及其重要性
在多维数据集中,每个特征都是影响预测的一个因素:
-
需要对一个新样本进行预测;例如,属于某个个体的新的乳腺癌肿块样本
-
每个影响因素都有一个特定的特征重要性数字或特征权重
在影响最终预测方面,一些特征比其他特征更重要。换句话说,预测是根据新样本属于哪个类别进行的。例如,在第二章“使用 Spark 和 Scala 构建乳腺癌预后管道”中的乳腺癌数据集中,可以使用随机森林算法来估计特征的重要性。在以下列表中,最上面的特征权重最高;列表底部的特征权重最低(按重要性递减顺序):
-
Uniformity_of_Cell_Size
-
Uniformity_of_Cell_Shape
-
Bare_Nuclei
-
Bland_Chromatin
-
Single_Epithelial_Cell_Size
-
Normal_Nucleoli
-
Clump_Thickness
-
Marginal_Adhesion
-
Mitosis
这意味着第一个特征对最终预测结果的影响最大,第二个特征有第二大影响,依此类推。
我们刚刚讨论了特征、特征重要性、权重等。这个练习为本章奠定了基础。
在下一节中,我们将探讨分类。
分类任务
分类意味着一个分类动作,一个涉及类别的任务。因此,分类任务通常表示一种监督学习技术,它使我们能够对之前未见过的样本(例如,我们尚不知道其物种的鸢尾花)进行分类。通过分类,我们暗示分类任务是在用训练数据集中的预测标签之一标记未见样本。

什么是分类?
在我们继续下一个问题之前,我们将把您的注意力引向术语训练数据集。在分类结果的主题中,我们将讨论分类结果作为二元或分类的,并解释支持概念如目标变量。
分类结果
直到第三章“股票价格预测”,我们一直在处理与分类作为监督学习技术相关的机器学习问题。分类任务是以数据为中心的(数据在这里指样本、观察或测量),对于这些数据,你已经知道目标答案。这引出了术语目标变量。这是响应变量(统计学中的一个术语)的另一个常用名称。在机器学习背景下,目标变量通常是输出或结果。例如,它可能是一个只有两个分类结果——0或1的二进制结果变量。有了这一点,我们知道目标答案已知或预先确定的数据被称为标记数据。
本章开发的分类任务完全是关于监督学习,其中算法通过我们从标记样本中学习来自我教学。前一章的一个显著例子是乳腺癌数据集,它也是一个监督学习分类任务。在前一章中,我们将乳腺癌样本分为两类——良性肿瘤和恶性肿瘤。这些都是两个分类结果,是我们可以用作标记或标记所谓的训练数据的值。另一方面,未标记数据是等待诊断的新乳腺癌样本数据。更有意义的是,一个可能包含垃圾邮件和正常邮件的新到达语料库包含未标记数据。基于训练集中的标记样本,你可以尝试对未标记样本进行分类。
两种可能的分类结果
垃圾邮件过滤是一个二元分类任务,一个生成只包含两种可能分类结果的预测值的机器学习任务。在本章中,我们将着手构建一个垃圾邮件分类器。垃圾邮件分类集中的标签属于一个有限集合,包括两种类型电子邮件的文本,即垃圾邮件和正常邮件。因此,二元分类任务变成了从先前未见数据中预测(输出)标签的问题。因此,判断一封邮件是否为垃圾邮件成为一个二元分类问题。按照惯例,我们将正常邮件的互斥状态赋值为1,而将另一种状态垃圾邮件赋值为0。在下一节中,我们将对当前的垃圾邮件分类问题进行公式化。这将给我们一个项目的概述。
项目概述——问题公式化
在本章中,我们设定的目标是构建一个垃圾邮件分类器,它能够区分电子邮件中的垃圾邮件术语,这些术语与常规或预期的电子邮件内容混合在一起。重要的是要知道,垃圾邮件是指发送给多个收件人的具有相同内容的电子邮件,与常规邮件相反。我们开始使用两个电子邮件数据集,一个代表正常邮件,另一个代表垃圾邮件。经过预处理阶段后,我们在训练集上拟合模型,比如说整个数据集的 70%。
从这个意义上说,这个应用程序是一个典型的基于文本的垃圾邮件过滤应用程序。然后我们使用算法来帮助机器学习过程检测在垃圾邮件中最可能出现的单词、短语和术语。接下来,我们将从高层次上概述与垃圾邮件过滤相关的机器学习工作流程。
机器学习工作流程如下:
-
我们将开发一个使用数据框的管道。
-
一个数据框包含一个
predictions列和另一个包含预处理文本的列 -
分类过程涉及转换操作——一个
DataFrame被转换成另一个 -
我们的管道运行一系列阶段,包括 TF-IDF、哈希技巧、停用词去除和朴素贝叶斯算法
从本质上讲,垃圾邮件过滤或分类问题是一个监督学习任务,我们向管道提供标记数据。在这个任务中,自然语言处理步骤包括将标记特征数据转换为特征向量集合。
到目前为止,我们可以列出构建垃圾邮件分类器所需的步骤:

构建垃圾邮件分类器所需的步骤
上列步骤描述的步骤是有用的,并帮助我们制定垃圾邮件分类器的概要。
下面的图表示了垃圾邮件分类问题的公式化:

垃圾邮件分类器概要
这里是以下主题的复习:
-
停用词
-
标点符号
-
正则表达式
我们希望在垃圾邮件和正常邮件数据集中消除两类文本。具体如下:
-
标点符号可以分为三类:
-
终止点或标记
-
破折号和连字符
-
暂停点或标记
-
-
停用词。
以下是一个代表性标点符号列表:

标点符号
我们在文本语料库中覆盖了想要从我们的垃圾邮件和火腿数据集中移除的常见标点符号列表。
我们还需要移除停用词——即常见的词。我们的垃圾邮件分类器将在初步预处理步骤中移除这些词。
下面是一个停用词的代表性列表:

停用词的代表性列表
以下是一个帮助删除标点符号的代表性正则表达式列表。垃圾邮件语料库可能令人望而生畏。正则表达式可以变得非常复杂,以便应对垃圾邮件:

一些相关的正则表达式
在入门部分,我们将开始实施项目。
入门
为了开始,请从ModernScalaProjects_Code文件夹下载数据集,并将其放入您项目的根目录。
设置先决软件
您可以使用之前章节中现有的软件设置。Apache Log4j 2 Scala API 是一个值得注意的例外。这是一个 Log4j 2 的 Scala 包装器,它是 Log4j 1.x 版本(Spark 提供的版本)的Logger实现的一个升级版。
简单地通过在build.sbt文件中添加适当的条目,用 Log4j 2 Scala 覆盖 Spark 中现有的 Log4j(版本 1.6)。
以下表格列出了两种先决软件的选择:

实施基础设施
从ModernScalaProjects_Code文件夹下载数据集,并将其放入您项目的根目录。
垃圾邮件分类管道
本章最重要的开发目标是使用以下算法执行垃圾邮件分类任务:
-
停用词移除器
-
朴素贝叶斯
-
逆文档频率
-
哈希技巧转换器
-
规范化器
我们垃圾邮件分类任务的实用目标是这样的:给定一个新的传入文档,比如,来自收件箱或垃圾邮件的随机电子邮件集合,分类器必须能够识别语料库中的垃圾邮件。毕竟,这是有效分类的基础。开发这个分类器的现实世界好处在于给读者提供开发他们自己的垃圾邮件过滤器体验。在学习如何组装分类器之后,我们将开发它。
实施步骤将在下一节中介绍。这将直接带我们进入在 Spark 环境中开发 Scala 代码。鉴于 Spark 允许我们编写强大的分布式 ML 程序,如管道,这正是我们将着手去做的事情。我们将从理解达到目标所需的单个实施步骤开始。
实施步骤
垃圾邮件检测(或分类)管道涉及五个实施阶段,这些阶段按典型的 ML 步骤分组。具体如下:
-
加载数据
-
预处理数据
-
提取特征
-
训练垃圾邮件分类器
-
生成预测
在下一步中,我们将在 IntelliJ 中设置一个 Scala 项目。
第 1 步 – 设置你的项目文件夹
这里是 IntelliJ 中项目的样子:

IntelliJ 中的项目大纲
在下一节中,我们将升级build.sbt文件。
第 2 步 – 升级你的 build.sbt 文件
这里是升级后的build.sbt文件。这里有什么新内容?记住,之前我们谈到了一个新的Logging库。以下截图中的新条目是你需要从 Log4j 1.6 迁移到新的 Scala 包装器 Log4j 2 的新依赖项:

build.sbt 文件中的新条目
在下一节中,我们将从 Scala 代码开始,从 trait 开始。
第 3 步 – 创建名为 SpamWrapper 的 trait
在 IntelliJ 中,使用文件 | 新建 | Scala 类,在名为SpamWrapper.scala的文件中创建一个名为SpamWrapper的空 Scala trait。
首先,在文件顶部,我们将设置以下导入以实现利用此 trait 的类:
-
SparkSession——使用 Spark 编程的入口点 -
适当的 Log4J 库导入,以便我们可以降低 Spark 的日志消息:
这些是最基本的导入。接下来,创建一个空的trait。以下是一个更新的trait:
trait SpamWrapper { }
在SpamWrapper trait 内部,创建一个名为session的SparkSession实例。在此阶段,这里是一个关于 Spark 的复习:
-
我们需要一个
SparkSession对象实例作为使用 Spark 编程的入口点。 -
我们不需要单独的
SparkContext。这由SparkSession提供。底层上下文可以很容易地作为session.sparkContext提供给我们。 -
要创建
SparkSession对象实例或获取现有的SparkSession,使用构建者模式。 -
SparkSession实例在整个 Spark 作业的时间范围内都可用。
这里是更新的SpamWrapper trait:

带有 SparkSession 值的 SpamWrapper trait
回顾一下,我们创建了一个名为session的val,我们的管道类将使用它。当然,这将是我们使用 Spark 编程构建这个垃圾邮件分类器的入口点。
第 4 步 – 描述数据集
从ModernScalaProjects_Code文件夹下载数据集。它由两个文本文件组成:
-
inbox.txt:正常邮件(我从我的 Gmail 收件箱文件夹中创建了此文件) -
junk.txt:垃圾邮件(我从我的 Gmail 垃圾邮件文件夹中创建了这些)
将这些文件放入你的项目文件夹的根目录。在下一节中,我们将描述数据集。
SpamHam 数据集的描述
在我们展示实际数据集之前,这里有一些现实世界的垃圾邮件样本:

带有钓鱼示例的垃圾邮件
这里是常规或所需邮件的示例,也称为 ham:

来自 Lightbend 的一个完全正常的电子邮件
以下是我们用于垃圾邮件-ham 分类任务的实际数据集的预览。有两个数据集:
-
inbox.txt:从我的收件箱文件夹中收集的一小批常规电子邮件组成的 ham 数据集 -
junk.txt:从我的垃圾邮件/垃圾文件夹中收集的一小批垃圾邮件组成的垃圾邮件数据集
这里是常规数据集:

常规电子邮件数据集的一部分
这里是垃圾邮件数据集:

垃圾邮件数据集的一部分
前面的邮件要求你在这里确认。这是一个钓鱼尝试。这完成了我们对数据集的描述。在下一步,我们将进行数据预处理。我们需要一个新的 Scala 对象,名为SpamClassifierPipeline。
第 5 步 – 创建一个新的垃圾邮件分类器类
我们将创建一个名为SpamClassifierPipeline.scala的新 Scala 文件。首先,我们需要以下导入:

必需的导入
现在已经创建了导入,让我们在SpamWrapper特质相同的包中创建一个空的SpamClassifierPipeline对象,如下所示:
object SpamClassifierPipeline extends App with SpamWrapper { }
一个原型垃圾邮件分类器已经准备好了。我们需要在其中编写代码来做诸如数据预处理等事情,当然还有更多。在下一步,我们将列出必要的预处理步骤。
第 6 步 – 列出数据预处理步骤
预处理有一个目的。在大多数数据分析任务中,迫切需要问的问题是——我们的数据是否必然可用?答案在于,大多数现实世界的数据集都需要预处理,这是一个按摩步骤,旨在给数据一个新的可用形式。
使用垃圾邮件和 ham 数据集,我们确定了两个重要的预处理步骤:
- 移除标点符号:
- 使用正则表达式处理标点符号
- 移除停用词
在下一步,我们将编写 Scala 代码来编写两个正则表达式,这些表达式相当简单,仅针对一小部分垃圾邮件。但这是一个开始。
在下一步,我们将把我们的数据集加载到 Spark 中。自然地,我们想要一个 ham 数据框和一个 spam 数据框。我们首先承担创建 ham 数据框的任务。我们的 ham 和 spam 数据集都准备好了,可以进行预处理。这带我们到了下一步。
第 7 步 – 移除标点符号和空白的正则表达式
这里是我们当前需要的正则表达式:
val punctRegex = raw"[^A-Za-z0-9]+"
raw是标准 Scala 库中StringContext类的内联代码方法。
我们的语料库可能包含尾随和前导空格,有时格式不正确。例如,我们会在行缩进过多的地方遇到空格。为了去除空格,我们将使用正则表达式。这将使用锚点,帽子^和美元符号$来提取不带空格的文本。更新后的正则表达式现在看起来是这样的:
//matches whitespaces and punctuation marks
val regex2 = raw"[^A-Za-z0-9\s]+"
我们刚刚创建了一个正则表达式regex2,一个空格和一个标点符号去除器。很快,我们就需要这个regex2。在下一节中,我们将创建一个新的 ham 数据框,在应用几个基本预处理步骤之一——移除标点符号后。
第 8 步 – 创建一个移除标点的 ham 数据框
我们将对 ham 的弹性分布式数据集(RDD)的每一行应用正则表达式,然后使用replaceAll方法。底层的正则表达式引擎将使用正则表达式在我们的 ham 语料库中进行搜索和匹配,以找到匹配的实例,如下所示:
val hamRDD2 = hamRDD.map(_.replaceAll(regex2, "").trim)
hamRDD2: org.apache.spark.rdd.RDD[String] = inbox.txt MapPartitionsRDD[1] at textFile at <console>:23
replaceAll方法启动并替换了所有空格和标点的出现。在下一步中,我们将把这个 RDD 转换成数据框。
我们创建了一个新的 ham RDD,其中包含首尾空格,并且移除了标点符号。让我们显示这个新的 ham 数据框:
hamRDD3.take(10)
println("The HAM RDD looks like: " + hamRDD3.collect())
我们创建了一个新的 ham 数据框。我们需要通过将每个 ham 句子分配0.0标签来转换这个数据框。我们将在下一步中这样做:
case class LabeledHamSpam(label: Double, mailSentence: String)
因此,我们将创建一个名为LabeledHamSpam的案例来模拟一个句子作为一个带有Double标签的特征。接下来,创建一个新的 ham RDD,它恰好有四个分区。
创建标记的 ham 数据框
我们将重新分区我们的 ham 数据框,并对 ham 数据框中的每个"Ham sentence"应用transform操作,如下所示:
val hamRDD3: RDD[LabeledHamSpam] = hamRDD2.repartition(4).map(w => LabeledHamSpam(0.0,w))
我们重新分区并创建了一个新的 RDD,它以带有0.0标签的 ham 句子行结构化。现在,显示新的 ham RDD 的前10行:
hamRDD3.take(10)
println("The HAM RDD looks like: " + hamRDD3.collect())
因此,我们为所有 ham 句子分配了0.0。现在是创建垃圾邮件 RDD 的时候了:
val spamRDD = session.sparkContext.textFile(spamFileName)
spamRDisDset: org.apache.spark.rdd.RDD[String] = junk2.txt MapPartitionsRDD[3] at textFile at <console>:23
同样为垃圾邮件数据集重复相同的预处理步骤。
第 9 步 – 创建一个不带标点的垃圾邮件数据框
我们将使用相同的LabeledHamSpam案例类来为垃圾邮件句子分配Double类型的value值为1.0,如下所示:
/*
Replace all occurrences of punctuation and whitespace
*/
val spamRDD2 = spamRDD.map(_.replaceAll(regex2, "").trim.toLowerCase)
/*
Repartition the above RDD and transform it into a labeled RDD
*/
val spamRDD3 = spamRDD2.repartition(4).map(w => LabeledHamSpam(0.0,w))
在下一步中,我们想要一个包含垃圾邮件和 ham 数据框的合并数据框。
第 10 步 – 合并垃圾邮件和 ham 数据集
在这一步中,我们将使用++方法在Union操作中连接两个数据框:
val hamAndSpamNoCache: org.apache.spark.rdd.RDD[LabeledHamSpam] = (hamRDD3 ++ spamRDD3)
hamAndSpam: org.apache.spark.rdd.RDD[LabeledHamSpam] = UnionRDD[20] at
$plus$plus at <console>:34
在下一节中,让我们创建一个包含两列的数据框:
-
包含已移除标点的
feature sentences的行 -
预设的
label列
检查以下代码片段以获得更好的理解:
val hamAndSpamDFrame" = hamAndSpam.select(hamAndSpam("punctLessSentences"), hamAndSpam("label"))
dataFrame2: org.apache.spark.sql.DataFrame = [features: string, label: double]
我们创建了新的数据框。让我们显示这个:
hamAndSpamDFrame.show
+--------------------+-----+
| lowerCasedSentences|label|
+--------------------+-----+
|this coming tuesd...| 0.0|
|pin free dialing ...| 0.0|
|regards support team| 0.0|
| thankskat| 0.0|
|speed dialing let...| 0.0|
|keep your user in...| 0.0|
| user name ilangostl| 0.0|
|now your family m...| 0.0|
接下来,让我们运行以下可选的检查:
-
数据框的模式
-
数据框中存在的列
这是打印模式的方法:
hamAndSpamDFrame.printSchema
root
|-- features: string (nullable = true)
|-- label: double (nullable = false)
它们看起来不错!现在让我们读取columns:
hamAndSpamDFrame.columns
res23: Array[String] = Array(features, label)
到目前为止,我们已经创建了一个没有标点符号的 dataframe,但并不一定没有包含 null 值的行。因此,为了删除包含 null 值的任何行,我们需要导入DataFrameNaFunctions类,如果您还没有导入它的话:
import org.apache.spark.sql.DataFrameNaFunctions
val naFunctions: DataFrameNaFunctions = hamAndSpamDFrame.na
为了从无标点符号的 dataframe 中删除 null 值,有一个名为punctFreeSentences的列。我们将按照以下代码调用drop()方法:
val nonNullBagOfWordsDataFrame = naFunctions.drop(Array("punctFreeSentences"))
在前面的代码中调用drop方法会导致包含 null 值的句子行被删除。如果您愿意,可以显示 dataframe 的前 20 行:
println("Non-Null Bag Of punctuation-free DataFrame looks like this:")
显示 dataframe。以下代码将帮助您做到这一点:
nonNullBagOfWordsDataFrame.show()
到目前为止,一个很好的下一步是与分词无标点符号的行相关,这些行也包含我们想要分词的内容。分词是下一节的重点。分词使我们更接近下一个预处理步骤——移除停用词。
第 11 步 – 对我们的特征进行分词
分词只是由算法执行的操作。它导致每行的分词。所有以下术语都定义了分词:
-
分割
-
分割
似乎适当的术语是前面列表中的第二个术语。对于当前 dataframe 中的每一行,一个分词器通过在分隔空格处分割将其feature行分割成其构成标记。每个结果 Spark 提供了两个分词器:
-
来自
org.apache.spark.ml包的Tokenizer -
来自同一包的
RegexTokenizer
这两个分词器都是转换器。在 Spark 中,转换器是一个接受输入列作为(超)参数的算法,并输出一个具有转换输出列的新DataFrame,如下所示:
import org.apache.spark.ml.feature.Tokenizer
val mailTokenizer = new Tokenizer().setInputCol("lowerCasedSentences").setOutputCol("mailFeatureWords")
mailTokenizer: org.apache.spark.ml.feature.Tokenizer = tok_0b4186779a55
在mailTokenizer上调用transform方法将给我们一个新的转换后的 dataframe:
val tokenizedBagOfWordsDataFrame: DataFrame = mailTokenizer2.transform(nonNullBagOfWordsDataFrame)
结果 dataframe,tokenizedBagOfWordsDataFrame,是一个分词的非空单词包,全部为小写。它看起来像这样:
+--------------------+-----+--------------------+
| lowerCasedSentences|label| mailFeatureWords|
+--------------------+-----+--------------------+
|This coming tuesd...| 0.0|[this, coming, tu...|
|Pin free dialing ...| 0.0|[pin, free, diali...|
|Regards support team| 0.0|[regards, support...|
| Thanks kat| 0.0| [thankskat]|
|Speed dialing let...| 0.0|speed, dialing, ...|
|Keep your user in...| 0.0|[keep, your, user...|
| User name ilangostl| 0.0|[user, name, ilan...|
|Now your family m...| 0.0|[now, your, famil...|
这里需要注意的重要一点是,转换列中的行mailFeatureWords类似于一个单词数组。读者不会错过注意到mailFeatureWords中有一些被称为停用词的单词。这些单词对我们的垃圾邮件分类任务没有显著贡献。这些单词可以通过 Spark 的StopWordRemover算法安全地删除。在下一步中,我们将看到如何使用StopWordRemover。
第 12 步 – 移除停用词
首先,确保您在SpamClassifierPipeline类中导入了StopWordRemover。接下来,我们将创建一个StopWordRemover的实例,并将其传递给一个(超)参数列,mailFeatureWords。我们希望输出列中没有停用词:
val stopWordRemover = new StopWordsRemover().setInputCol("mailFeatureWords").setOutputCol("noStopWordsMailFeatures")
就像使用mailTokenizer一样,我们调用transform方法来获取一个新的noStopWordsDataFrame:
val noStopWordsDataFrame = stopWordRemover.transform(tokenizedBagOfWordsDataFrame)
结果 dataframe,一个分词的、非空的、无停用词的小写单词包,看起来像这样:
noStopWordsDataFrame.show()
+-----------------------+-----+
|noStopWordsMailFeatures|label|
+-----------------------+-----+
| coming| 0.0|
| tuesday| 0.0|
| going| 0.0|
| take| 0.0|
| time| 0.0|
| meeting| 0.0|
| get| 0.0|
| everyone| 0.0|
| running| 0.0|
| pathways| 0.0|
在下一步中,我们将对我们当前的数据帧进行第二次转换:
import session.implicits._
val noStopWordsDataFrame2 = noStopWordsDataFrame.select(explode($"noStopWordsMailFeatures").alias("noStopWordsMailFeatures"),noStopWordsDataFrame("label"))
分解、标记、非空、小写且无停用词的词袋看起来如下:
noStopWordsDataFrame2.show()
这完成了数据预处理。这为特征提取,一个极其重要的机器学习步骤做好了准备。
第 13 步 – 特征提取
在这一步,我们将提取这个数据集的特征。我们将执行以下操作:
-
创建特征向量
-
创建特征涉及使用 n-gram 模型将文本转换为字符的二元组
-
这些字符的二元组将被哈希到一个长度为
10000的特征向量 -
最终的特征向量将被传递到 Spark ML
查看以下代码片段:
import org.apache.spark.ml.feature.HashingTF
val hashMapper = new HashingTF().setInputCol("words").
setOutputCol("noStopWordsMailFeatures").setOutputCol("mailFeatureHashes").setNumFeatures(10000)
hashFeatures: org.apache.spark.ml.feature.HashingTF = hashingTF_5ff221eac4b4
接下来,我们将对noStopWordsDataFrame的特征版本进行transform操作:
val featurizedDF = hashMapper.transform(noStopWordsDataFrame)
//Display the featurized dataframe
featurizedDF1.show()
使用哈希特征和标记、非空、小写且无停用词的词袋,这个DataFrame看起来如下:
![图片
哈希特征和标记、非空、小写且无停用词的数据帧
到目前为止,我们已经准备好创建训练集和测试集。
第 14 步 – 创建训练集和测试集
这一步很重要,因为我们将要创建一个我们想要用训练集训练的模型。创建训练集的一种方法是将当前的数据帧分区,并将其中 80%分配给新的训练集:
val splitFeaturizedDF = featurizedDF.randomSplit(Array(0.80, 0.20), 98765L)
splitFeaturizedDF1: Array[org.apache.spark.sql.Dataset[org.apache.spark.sql.Row]] = Array([filteredMailFeatures: string, label: double ... 2 more fields], [filteredMailFeatures: string, label: double ... 2 more fields])
现在,让我们检索训练集:
val trainFeaturizedDF = splitFeaturizedDF(0)
测试数据集如下。这是我们创建它的方法:
val testFeaturizedDF = splitFeaturizedDF(1)
我们需要更进一步。需要一个修改过的训练集版本,以下列被移除:
-
mailFeatureWords -
noStopWordsMailFeatures -
mailFeatureHashes
这里是经过drop操作前几列后的新训练集:
val trainFeaturizedDFNew = trainFeaturizedDF1.drop("mailFeatureWords","noStopWordsMailFeatures","mailFeatureHashes")
trainFeaturizedDFNew.show()
调用show()方法会导致以下新训练集的显示:

训练数据帧
在训练(拟合)模型之前的重要一步是所谓的逆文档频率(IDF)。Spark 提供了一个名为 IDF 的估计器,将为我们计算 IDF。IDF 是一种算法,将在我们的当前数据帧上训练(拟合)模型:
val mailIDF = new IDF().setInputCol("mailFeatureHashes").setOutputCol("mailIDF")
现在,我们将featurizedDF数据帧传递到 IDF 算法的fit方法上。这将产生我们的模型:
val mailIDFFunction = mailIDF.fit(featurizedDF)
下一个步骤是一个归一化步骤。normalizer将不同特征的尺度进行归一化,这样不同大小的文章不会被不同地加权:
val normalizer = new Normalizer().setInputCol("mailIDF").setOutputCol("features")
让我们现在使用朴素贝叶斯算法。初始化它,并传递给它所需的超参数:
val naiveBayes = new NaiveBayes().setFeaturesCol("features").setPredictionCol("prediction")
现在是时候创建管道并设置其中的所有阶段了。这些如下:
-
StopWordRemover -
HashingTF -
mailIDF -
normalizer -
naiveBayes
代码片段设置了以下阶段:
val spamPipeline = new Pipeline().setStages(ArrayPipelineStage ++
ArrayPipelineStage ++
ArrayPipelineStage ++
ArrayPipelineStage ++
ArrayPipelineStage ++
ArrayPipelineStage
将管道拟合到训练文档中:
val mailModel1 = spamPipeline1.fit(trainFeaturizedDFNew)
在测试集上做出预测:
val rawPredictions = mailModel1.transform(testFeaturizedDF.drop("mailFeatureWords","noStopWordsMailFeatures","mailFeatureHashes"))
现在我们将显示生成的原始预测:
rawPredictions.show(20))
注意,它们不是两张表。为了视觉清晰,这是一张被分成两部分的表:

原始预测表
这是最后一步,我们只想在预测表中显示相关列。以下代码行将删除那些不需要的列:
val predictions = rawPredictions.select($"lowerCasedSentences", $"prediction").cache
显示最终的预测表。我们只需要第一列和最后一列。标签列是模型生成的预测:
predictions.show(50)
它如下显示预测:

预测
我们已经完成了,所以停止``会话:
session.stop()
摘要
在本章中,我们创建了一个垃圾邮件分类器。我们开始时使用了两个数据集,一个代表正常邮件,另一个代表垃圾邮件。我们将这两个数据集合并成一个综合语料库,然后按照实现步骤部分中提到的预处理步骤进行处理。
在下一章中,我们将基于到目前为止学到的某些技术来创建一个欺诈检测机器学习应用。
问题
这里有一些问题,将有助于加强本章中展示的所有学习材料:
-
垃圾邮件分类任务是一个二元分类任务吗?
-
在垃圾邮件分类任务中,哈希技巧的意义是什么?
-
哈希冲突是什么,以及它是如何被最小化的?
-
我们所说的逆文档频率是什么意思?
-
停用词是什么,为什么它们很重要?
-
在垃圾邮件分类中,朴素贝叶斯算法扮演了什么角色?
-
你如何在 Spark 中使用
HashingTF类来实现垃圾邮件分类过程中的哈希技巧? -
我们所说的特征向量化是什么意思?
-
你能想到一个更好的算法来实现垃圾邮件分类过程吗?
-
什么是垃圾邮件过滤的好处,为什么从商业角度来说它们很重要?
进一步阅读
以下论文是一项全面的工作,值得阅读:
www.sciencedirect.com/science/article/pii/S2405882316300412
第五章:构建欺诈检测系统
在本章中,我们将使用 Spark ML 开发一个基于高斯分布函数的算法。我们将将该算法应用于检测交易数据中的欺诈行为。这种算法可以应用于构建金融机构(如银行)的稳健欺诈检测解决方案,这些金融机构处理大量的在线交易。
在高斯分布的核心,函数是异常的概念。欺诈检测问题不仅仅是一个分类任务,而是一个非常狭窄意义上的分类任务。它是一个平衡的监督学习问题。术语“平衡”指的是数据集中的正样本相对于负样本数量较少。另一方面,异常检测问题通常是不平衡的。数据集中相对于负样本,异常(正样本)的数量显著较少。欺诈检测问题是一个典型的异常检测问题。这是一个数据集中有少量异常值或数据点,其值与正常、预期的值相差很大的问题。
本章的主要学习目标是实现一个 Scala 解决方案,该解决方案将预测金融交易中的欺诈行为。我们将依赖 Spark ML 库的 API 及其支持库来构建欺诈检测预测应用。
在本章中,我们将涵盖以下主题:
-
欺诈检测问题
-
项目概述—问题定义
-
开始
-
实施步骤
欺诈检测问题
欺诈检测问题不是一个监督学习问题。在我们的欺诈检测场景中,我们有一个不平衡的类别情况。关于目标变量的 F1 分数的重要性,我们有什么要说的吗?首先,目标变量是一个二进制标签。F1 分数与我们的欺诈检测问题相关,因为我们有一个不平衡的类别,其中一个类别实际上比另一个更重要。我们这是什么意思?欺诈检测分类过程的底线是确定某个实例是否欺诈,让分类器正确地将该实例分类或标记为欺诈。重点不是将实例标记为非欺诈。
再次强调,我们的欺诈检测问题中有两个类别:
-
欺诈
-
非欺诈
话虽如此,我们现在将查看这个实现所依赖的数据集
欺诈检测数据集概览
从ModernScalaProjects_Code下载文件夹中下载数据集。
下面是这个数据集的样子:

我们欺诈检测系统建立的数据集
高斯分布函数是我们算法的基础。
那么,F1 分数重要吗?是的。F1 分数不容忽视(在类平衡的情况下,F1 分数不一定重要)。它是衡量机器学习二分类过程准确性的指标。
每个类别都有一个 F1 分数(一个用于欺诈,另一个用于非欺诈)。因此,如果我们想计算 F1 分数,我们需要确保 F1 分数与欺诈类别相关联。
在机器学习的背景下,欺诈检测是一种分类技术方法,它允许我们构建试图检测异常值的模型。标记异常值引导我们采取应对欺诈所需的措施。例如,如果我在我居住地超过 1,000 英里远的缅因州波特兰用我的卡刷了一次,那可能意味着与我信用卡关联的潜在欺诈检测算法会标记欺诈。在这种情况下,距离导致算法声称在缅因州水边的某个海鲜场所进行的交易是假的。这是一个简单的用例。还有其他金融交易,该算法被训练来监控并标记欺诈。
例如,想象一下凯特丢失了她的卡,某个随机人在街上捡到了那张卡(让我们假设凯特直到一天后才意识到她丢失了卡)并试图用大约 50 美元的汽油填满他的卡车油箱。尽管这笔交易已经完成,假设试图使用她卡的人设法通过了邮政编码检查,凯特的信用卡欺诈检测机器学习算法将标记为可疑交易。很可能会触发算法并导致交易失败,或者即使没有发生这种情况,她也会接到信用卡公司的电话,询问她最近在哪里使用了卡。在这种情况下,她接到信用卡公司的电话是因为欺诈检测算法标记了该笔交易为可疑,这是一起需要信用卡公司采取行动的欺诈事件。
欺诈检测系统处理大量数据。本章中描述的欺诈检测分类器将筛选交易数据集并对其进行处理。Spark 的流处理能力使我们能够检测异常值,即我们数据集中那些值不在正常、预期的值范围内的样本。检测这些值并生成一组标记欺诈的预测是该章节所述欺诈检测问题的重点。
我们将通过计算指标来评估算法的性能,并查看它是否将非欺诈样本标记为欺诈或欺诈样本标记为欺诈,这些指标包括精确度、召回率以及精确度和召回率的调和平均值,即 F1 分数。
精确度、召回率和 F1 分数
以下是很重要的:
-
F1 度量
-
误差项
从数学函数的角度来看,F1 分数可以数学上定义为:
2 * precision recall / precision + recall
我们将简要讨论 F1 分数或度量。误差项用 Epsilon 符号 (ε) 表示。所有这一切的核心是我们不平衡数据集中的标记输入点。我们将优化 Epsilon 参数。我们究竟如何做呢?首先,让我们找到最佳的 F1 分数。什么是 Epsilon?在统计学中,它是一个 误差项。一个测量值可能偏离其预期值。例如,它可能是某个特定人口中所有男性的平均身高。它的目的是什么?我们可以将任意小的正数表示为 ε。在计算 Epsilon 之前,让我们保存测试数据框。我们面前有以下任务:
-
编写一个函数帮助我们计算最佳的 Epsilon 和最佳的 F1 分数
-
理解 F1 分数的含义
F1 度量可能达到的最大值是 1。它表示分类器分类过程的正确程度,即被分类为高度正确或精确的样本或实例的比例。它还告诉我们分类器有多稳健(或不是)——分类器是否只错过分类少量样本或更多。F1 分数是精确率和召回率之间的平衡平均值。
在不平衡的类别情况下,F1 分数变得更加重要。它比 准确率 更为实用。尽管准确率更直观,而且由于错误肯定和错误否定都被考虑在内,一个加权分数如 F1 在理解分类器的正确程度时更有意义。
特征选择
在制定欺诈检测程序时,仔细选择特征是一个关键步骤。选择许多特征或对分类没有实质性贡献的特征可能会影响性能或扭曲预测。
因此,如果我们希望标记欺诈交易,我们应该从小规模开始,构建一个只包含我们认为对分类有实质性贡献的两个特征的系统。我们在数据集中选择这两个特征,作为逗号分隔文件中双值列的表示。这些特征如下:
-
交易:购买某种商品或服务所花费的金钱
-
距离:从持卡人文件地址的地理距离,或持卡人邮编定义的外围以外的通用距离
话虽如此,我们欺诈检测算法的目标是,在特征选择过程到位的情况下,我们想要处理数据,处理数据集中的所有数据点,并标记潜在的欺诈。这是一个引入高斯分布函数的好地方,这是我们实施欺诈检测模型的基础。我们需要更多地讨论这个方程。它将帮助我们理解我们的算法到底做了什么以及为什么这样做。在下一节中,我们将讨论高斯分布函数。
高斯分布函数
高斯分布函数也被称为钟形曲线或正态分布曲线。钟形曲线具有以下特点:
-
这种分布(数据)被称为连续分布。
-
数据在这个曲线上分布,使其围绕曲线的钟形部分(最高点)收敛,而不是向左或向右。这个最高点处的中心也是曲线的均值。
-
正态分布曲线的最高点对应着发生事件最高概率的位置,随着曲线变窄,发生事件的概率逐渐下降到曲线两侧的斜率位置。
-
由于这一特性,钟形曲线也被称为正态分布曲线。它只需要标准差和(总体)均值。
-
在正态分布中,均值、众数和中位数这三个统计量都具有相同的值。
-
正态分布曲线是用概率密度值(也称为正常频率)绘制的。参考项目概述—问题定义部分中的图,以下是对方程中符号的含义:
-
µ = 总体均值
-
σ = 标准差
-
x = 绘制在 x 轴上,代表一个连续的随机变量
-
e = 自然对数的底数,值为 2.71
-
π = 3.1415
-
均值是一个净值,等于所有数据点值的总和除以数据点的数量。
-
需要注意的是,y 实际上就是
f(x)或p(x),其值在钟形曲线的 y 轴上绘制。
以下图表展示了钟形曲线:

钟形曲线
非欺诈数据构成了我们数据的大部分。这类数据聚集在或接近钟形曲线的峰值。一般来说,钟形曲线的顶部代表发生概率最高的事件或数据点。曲线的渐变边缘是发现异常或欺诈指示性异常的地方。
在某种意义上,我们提到欺诈检测作为一个分类问题。它属于异常检测的范畴。下表描述了分类任务和异常检测任务之间的基本区别:

分类与异常识别任务
从前面的表格中可以看出,以下是一些突出理由,以证明使用异常识别系统的合理性:
-
在一个数据集中可能异常的样本,在它们作为另一个金融交易数据集的新 incoming 样本时可能不会异常。
-
另一方面,考虑一个被归类为恶性的乳腺癌样本在乳腺癌样本实验单元中。如果相同的样本是实验单元 2 的 incoming 样本,分类结果将相同。
Spark 在所有这些中处于什么位置?
无论你在本地运行 Spark 还是处于一个拥有多个分布式节点集群的环境中,Spark 都会启动。在本地 Spark 环境中,Spark 会将 CPU 核心视为集群中的资源。
高斯分布算法值得研究。让我们看看在下一节中我们的方法应该是什么。
欺诈检测方法
以下图表展示了我们的欺诈检测管道的高级架构:

欺诈检测管道的高级架构
以下是对欺诈检测过程的快速概述:
-
首先,我们计算训练集的统计数据,该训练集作为交叉验证数据集。我们对统计数据中的平均值和标准差感兴趣。
-
接下来,我们希望计算交叉验证集中每个样本的净概率密度函数(PDF)。
-
我们将净概率密度作为单个概率密度的乘积得出。
-
在算法内部,我们比较 PDF 值与误差项值,以确定该样本是否代表一个异常值,一个潜在的欺诈交易。
-
我们通过在交叉验证数据集上执行算法来优化我们的分类过程。
-
作为优化过程的一部分,我们的算法计算误差项的最佳可能值,一个对应于计算出的最高F1分数的误差项(用 Epsilon 表示)的最佳值。经过多次迭代后,算法将得出这个最高的 Epsilon 分数。
-
观察数据集告诉我们,大多数交易数据点都位于 55-105 美元的范围内。这些交易发生在半径为两到七英里的范围内。
-
Spark 将运行这个欺诈检测程序并提取一定数量的潜在欺诈数据点,例如,一个良好的分割数据集来使用。
-
数据集的划分可能如下:
-
65% 作为训练示例,用于训练模型
-
35% 作为包含潜在欺诈实例的交叉验证集
-
-
评估我们的欺诈检测算法的性能不是通过准确度指标来完成的。原因在于,如果只有少数样本应该被标记为欺诈,那么成功标记非欺诈样本的算法可能无法标记那些确实可能是欺诈的样本。
-
相反,我们将计算精确度和召回率指标,从而计算 F1 度量,作为评估欺诈检测分类器性能的一种方式。
现在,我们将概述我们的项目,其中我们将手头的问题用数学术语表述。
项目概述 - 问题表述
这里有一个有用的流程图,概述了当前手头的欺诈检测问题:

欺诈检测流程图
话虽如此,让我们开始吧。我们首先设置一个实现基础设施。
开始
在本节中,我们将讨论设置实现基础设施或使用前几章中现有的基础设施。以下是对您基础设施的升级,这些升级是可选的但推荐使用的。
从第三章开始,股票价格预测,我们设置了Hortonworks 开发平台(HDP)Sandbox 作为虚拟机。话虽如此,有三种(隔离的)HDP Sandbox 部署方式。在这三种中,我们只会讨论两种,它们是:
-
带有虚拟机管理程序的虚拟机环境(用于 Sandbox 部署):HDP Sandbox 在 Oracle VirtualBox 虚拟机中运行。
-
基于云的 Sandbox 部署环境:此选项对主机内存有限制的用户来说很有吸引力。Sandbox 在云中运行,而不是在您的宿主机上运行的虚拟机。
在提出这个观点之后,你总是可以在 Spark shell 上运行欺诈检测系统代码。这里你有两个选择:
-
使用简单构建工具(SBT)在 Spark 环境中构建和部署您的应用程序
-
打开 Spark shell,进行交互式开发,并在 shell 中运行它
最后但同样重要的是,你需要以下软件来简单地启动 Spark shell 并在本地开发:
-
Spark 2.3
-
Scala 2.11.12
-
SBT 1.0.4
-
IntelliJ IDEA 2018.1.5 社区版
-
至少 16 GB 的 RAM;32 GB 更好。
请参阅第一章中的设置先决软件部分,从 Iris 数据集预测花的类别。这为我们设置了 Java、Scala 和 Spark,使我们能够使用 Spark shell 进行交互式开发。
在下一节中,我们将解释如何在 Microsoft Azure 云上设置 Hortonworks Sandbox 部署,从而进入实现部分。
在云中设置 Hortonworks Sandbox
您可能会问,为什么是 Microsoft Azure?像任何流行的云服务提供商一样(Google Compute Cloud 也是另一个不错的选择),Azure 自豪地提供了一套强大的云服务,允许个人用户和组织在云上开发和部署他们的应用程序。
创建您的 Azure 免费账户并登录
以下是为创建账户所需的步骤:
-
要开始使用,请访问以下网址:
azure.microsoft.com/en-us/。点击“开始免费使用”按钮。这样做将带您进入账户登录界面。如果您还没有账户,请创建一个。这个过程只会给您一个新的 Microsoft Azure 账户,而不是一个实际的云账户;至少目前还不是。 -
输入您希望与您的新 Microsoft Azure 云账户一起选择的密码。
-
接下来,转到您的电子邮件账户,通过输入安全码来验证您的电子邮件地址。
-
如果一切顺利,您的新 Azure 账户即可使用。
下一步是访问 Azure Marketplace。在这个市场中,我们将进行进一步的步骤,例如部署。现在,让我们找到市场。
Azure Marketplace
以下涉及到的步骤:
- 转到 Azure Marketplace:

寻找 Azure Marketplace
- 在点击 Azure Marketplace 后,在右侧的搜索框中输入
Hortwonworks,如下面的截图所示:

搜索 Hortonworks 数据平台链接
点击前面的截图所示的 Hortonworks 数据平台链接。这会将您带到 HDP 沙盒页面。
HDP 沙盒主页
在 HDP 页面上,您可以期待以下内容:
-
登录到 Microsoft Azure Marketplace 门户。
-
启动沙盒创建过程,并执行后续步骤。
-
在沙盒创建完成后,应该进行部署。所有后续步骤将在我们进行过程中进行解释。
请执行以下步骤以满足前面的期望:
- 目前,请点击以下截图所示的“GET IT NOW”按钮:

HDP GET IT NOW 页面
- 点击“GET IT NOW”蓝色按钮后,接下来可能发生的事情是一个对话框,要求您登录到 Microsoft Azure:

登录页面
- 登录过程将带您进入另一个页面,一个列出您需要输入详细信息的表单的页面,例如您的姓名、工作电子邮件、工作角色、国家/地区和电话号码。您将被重定向到 Azure 门户,如下面的截图所示:

欢迎来到 Microsoft Azure 门户页面
在您的门户欢迎屏幕上,如果您愿意,可以参加游览,或者简单地点击“稍后考虑”并开始工作。让我们继续在 Azure 上部署沙盒的业务。
- 下一步是找到下面的截图所示的蓝色创建按钮:

创建按钮截图
- 现在,我们将开始 Sandbox 部署过程。请注意,点击创建按钮并不会立即启动部署过程。首要任务是创建一个虚拟机:

虚拟机创建截图
现在让我们开始虚拟机的部署。
实现目标
以下实现目标涵盖了实现高斯分布算法所需的步骤。我们将执行初步步骤,例如探索性数据分析(EDA),然后开发实现代码。具体如下:
-
从UCI 机器学习仓库获取乳腺癌数据集。
-
在 Sandbox Zeppelin Notebook 环境(或 Spark shell)中执行初步的 EDA,并进行统计分析。
-
在你的本地 Spark shell 中逐步开发管道,或者在主机机器上的 Zeppelin 笔记本、托管虚拟机或 Azure 云上的虚拟机上,或者简单地作为 SBT 应用程序运行你的 Spark 欺诈检测应用程序,并通过创建 Uber JAR 使用
spark-submit来部署它。 -
在 IntelliJ 中完善你的代码。这意味着:
-
不要忘记在
build.sbt文件中连接所有必要的依赖。 -
解释分类过程,因为你想要知道分类器表现如何,预测值与原始数据集中的值有多接近,等等。
-
实现步骤
一个好的开始是从ModernScalaProjects_Code文件夹下载 SBT 项目存档文件。
步骤如下:
-
在测试(交叉验证)数据集上进行 EDA。
-
计算概率密度。
-
生成欺诈检测模型。
-
生成衡量模型准确性的分数:
-
计算最佳 F1 分数
-
计算最佳误差项
-
-
通过让模型在误差项的范围内的每个值上重复生成预测来计算异常值。
我们现在将创建一个FraudDetection特质。
创建 FraudDetection 特质
在一个空的FraudDetectionPipeline.scala文件中,添加以下导入。这些是我们需要的Logging、特征向量创建、DataFrame和SparkSession的导入:
import org.apache.log4j.{Level, Logger}
import org.apache.spark.ml.linalg.Vectors
import org.apache.spark.sql.{DataFrame, SparkSession}
这是一个至关重要的特质,包含用于创建SparkSession和其他代码的方法。从这个特质扩展的类可以共享一个SparkSession的实例:
trait FraudDetectionWrapper {
接下来,我们需要测试数据集的路径,用于交叉验证,这对我们的分类至关重要:
val trainSetFileName = "training.csv"
使用Dataset和DataFrame API 编程 Spark 的入口点是SparkSession,它为我们的欺诈检测管道创建SparkSession,如下面的代码所示:
lazy val session: SparkSession = {
SparkSession
.builder()
.master("local")
.appName("fraud-detection-pipeline")
.getOrCreate()
}
这两个语句关闭了INFO语句。你可以随意打开它们,如下所示:
Logger.getLogger("org").setLevel(Level.OFF)
Logger.getLogger("akka").setLevel(Level.OFF)
数据集文件的路径如下:
val dataSetPath = "C:\\Users\\Ilango\\Documents\\Packt\\DevProjects\\Chapter5A\\"
创建方便的元组来保存features向量列和label列名称val fdTrainSet_EDA = ("summary","fdEdaFeaturesVectors")如下:
val fdFeatures_IndexedLabel_Train = ("fd-features-vectors","label")
val fdFeatures_IndexedLabel_CV = ("fd-features-vectors","label")
此方法允许我们将交叉验证数据集转换为DataFrame。它接受训练Dataset并输出DataFrame:
def buildTestVectors(trainPath: String): DataFrame= {
def analyzeFeatureMeasurements: Array[(org.apache.spark.ml.linalg.Vector,String)] = {
val featureVectors = session.sparkContext.textFile(trainPath, 2)
.flatMap { featureLine => featureLine.split("\n").toList }
.map(_.split(",")).collect.map(featureLine => ( Vectors.dense( featureLine(0).toDouble,featureLine(1).toDouble),featureLine(2)))
featureVectors
}
通过转换一个包含Feature Vectors和Label元组的数组来创建DataFrame:
val fdDataFrame = session.createDataFrame(analyzeFeatureMeasurements).toDF(fdFeatures_IndexedLabel_CV._1, fdFeatures_IndexedLabel_CV._2)
这个package语句是必需的。将此文件放置在你选择的包中:
package com.packt.modern.chapter5
以下导入是必需的,因为我们将要传递可序列化的特征向量;因此,我们需要DenseVector。其他导入都是不言自明的。例如,在 Spark 中,我们无法没有DataFrame,这是我们数据抽象的基本单元:
import org.apache.spark.ml.linalg.DenseVector
import org.apache.spark.sql.{DataFrame, Dataset, Row}
import org.apache.spark.rdd.RDD
此程序的目的是为了开发一个检测异常值的数据管道,所谓数据异常是指指向欺诈的数据点:
object FraudDetectionPipeline extends App with FraudDetectionWrapper {
管道程序入口点如下:
def main(args: Array[String]): Unit = {
现在,将训练数据集的原始数据转换为DataFrame。测试数据位于我们的 SBT 项目文件夹根目录下的testing.csv文件中。训练数据包含两个包含双精度值的列。第一列包含Cost Data,第二列包含Distance:
val trainSetForEda: DataFrame = session.read .format("com.databricks.spark.csv") .option("header", false).option("inferSchema", "true") .load(dataSetPath + trainSetFileName)
我们刚刚获得的原始训练集DataFrame(rawTrainsSetForEda)是用于 EDA 的。例如,我们可以通过运行show()命令检查缺失值或不属于的字符。话虽如此,我们将通过运行show()命令检查数据集的所有行:
cachedTrainSet.show()
这为测试数据集构建了一个DataFrame。其主要目的是交叉验证,这是一种重要的机器学习技术,在理论部分已有解释:
val testingSet: DataFrame = buildTestVectors(dataSetPath + crossValidFileName)
显示新的DataFrame测试集:
trainSetEdaStats.show()
接下来,使用summary方法显示 EDA 的结果,包括标准差、平均值和方差。这些是我们欺诈检测分类任务所必需的:
val trainSetEdaStats: DataFrame = cachedTrainSet.summary()
接下来,使用默认存储级别(MEMORY_AND_DISK)持久化训练集数据框。
现在,让我们显示 Spark 为我们提取的摘要:
trainSetEdaStats.show()
提取包含"mean"的summary行,对于两个列:
val meanDf: DataFrame = trainSetEdaStats.where("summary == 'mean'")
显示新的DataFrame——一个单行DataFrame:
meanDf.show()
接下来,将DataFrame转换为行数组。在这个数组上发出map函数调用将"mean"行提取到包含单个元组的数组中,该元组包含Cost和Distance值的平均值。结果是包含字符串元组的"Mean Pairs"数组:
val meanDfPairs: Array[(String, String)] = meanDf.collect().map(row => (row.getString(1), row.getString(2)))
从"Mean Pairs"元组中提取两个mean值:
val transactionMean = meanDfPairs(0)._1.toDouble
val distanceMean = meanDfPairs(0)._2.toDouble
现在,我们想要发出where查询,仅提取标准差的值
从训练数据集 EDA 统计DataFrame中:
val trainSetSdDf: DataFrame = trainSetEdaStats.where("summary == 'stddev' ")
显示此标准DataFrame偏差的内容:
trainSetSdDf.show()
我们有一个包含两个标准差值的DataFrame。将这些值提取到一个元组数组中。这个数组只包含一个元组,包含两个表示标准差的字符串值:
val sdDfPairs: Array[(String, String)] = trainSetSdDf.collect().map(row => (row.getString(1), row.getString(2)))
从包围的元组中提取标准差值。首先,我们需要交易特征的方差值:
val transactionSD = sdDfPairs(0)._1.toDouble
接下来,我们想要提取距离特征的方差:
val distanceSD = sdDfPairs(0)._2.toDouble
让我们构建以下元组对以创建broadcast变量:
val meanSdTupleOfTuples = ( (transactionMean,distanceMean),(transactionSD, distanceSD) )
现在,让我们将前面的元组对包裹在DenseVector中。我们为什么要这样做?很简单。我们需要一个向量将作为broadcast变量发送到集群中。创建一个包含均值和标准差值的交易向量数组。我们希望在交易向量中看到的内容如下 [交易均值, 交易标准差]:
val meansVector = new DenseVector(Array(meanSdTupleOfTuples._1._1, meanSdTupleOfTuples._1._2))
让我们显示这个向量。Scala 提供了一种优雅的方式来显示集合结构的内容:
println("Transaction Mean and Distance Mean Vector looks like this: " + meansVector.toArray.mkString(" "))
创建一个包含均值和标准差值的距离向量数组。由于我们需要第二个向量,它看起来像这样 距离均值, 距离标准差):
val sdVector: DenseVector = new DenseVector(Array
(meanSdTupleOfTuples._2._1, meanSdTupleOfTuples._2._2))
显示标准差向量:
println("Distance Mean and Distance SD Vector looks like this: " + sdVector.toArray.mkString(" "))
现在是时候将以下内容广播到你的 Spark 集群中的所有节点:
-
均值向量
-
标准差向量
广播均值和标准差向量
sparkContext变量提供了一个broadcast方法:
val broadcastVariable = session.sparkContext.broadcast((meansVector, sdVector))
我们到目前为止所做的一切都是为了计算 PDF,这是一个表示欺诈概率的值。话虽如此,我们将在下一节中看到如何计算 PDF。
计算 PDFs
对于测试数据集中的每个样本,都需要计算一个 PDF 值。因此,我们将遍历整个数据集,并将每个特征向量传递到probabilityDensity函数中。该函数应该对每个样本计算类型为Double的probabilityDensity值。最终,我们构建了一个包含 PDF 值的整个数据集,所有类型为Double。
testingDF数据框包含两列:
-
特征向量列 -
标签
因此,在map中,每个labelledFeatureVectorRow.getAs()返回特征向量。
接下来,从测试数据框中提取特征向量。对于测试数据集中的每个样本,都需要计算一个 PDF 值。因此,我们将遍历整个数据集,并将每个特征向量传递到probabilityDensity函数中。该函数应该对每个样本计算类型为Double的probabilityDensity值。
最终,我们构建了一个包含概率密度函数值的整个数据集,所有类型为Double。以下是从 EDA 数据集中提取的两个数据集。
第一个数据集显示均值,而第二个数据集显示标准差:
+-------+-----------------+-----------------+
|summary| _c0| _c1|
+-------+-----------------+-----------------+
| mean|97.37915046250084|6.127270023033664|
+-------+-----------------+------------------+
|summary| _c0| _c1|
+-------+-----------------+------------------+
| stddev|10.83679761471887|3.2438494882693900|
+-------+-----------------+------------------+
我们在这里需要implicits来考虑这样一个事实,即需要一个隐式编码器将DataFrame转换为包含双精度值的Dataset。这些implicits由我们的SparkSession实例session提供,如下所示:
import session.implicits._
遍历测试数据集,并对其中的每个特征向量行应用一种方法来计算概率密度值。返回一个包含计算概率的双值数据集:
val fdProbabilityDensities: DataFrame = testingDframe.map(labelledFeatureVectorRow => probabilityDensity( labelledFeatureVectorRow.getAs(0) /* Vector containing 2 Doubles*/ , broadcastVariable.value) ).toDF("PDF")
显示概率值的数据集,如下所示:
fdProbabilityDensities.show()
F1 分数
由于我们的欺诈类别是重要的,我们需要以下内容来帮助我们选择具有最佳 F1 分数的分类器,如下所示
-
标签数据是测试
DataFrame——testingDf -
PDF——
probabilityDensity函数中计算的概率的乘积
考虑到我们需要标记数据(点)来达到最佳的 F1 分数,以下背景信息是有帮助的如下:
-
交叉验证的作用是什么?为了理解交叉验证,我们回顾一下验证过程,其中使用训练集样本的一个子集来训练模型。由于模型有更多的观测值可供拟合,交叉验证是对验证的一种改进。因为现在我们能够从多个模型中进行选择,交叉验证变得更有吸引力。
-
在计算 Epsilon 和 F1 分数之前需要哪些先决条件?这些先决条件如下:
-
步长 = 概率密度最大值 - 概率密度最小值 / 500
-
为了达到概率的最大值,我们需要计算测试数据集中每个样本的概率密度值,然后得出最大值
-
以向量形式表示的标记数据点和特征:
![图片
以向量形式表示的标记数据点和特征
对于每个标记的数据点,需要计算一个 PDF 值。这需要以下统计数据的先验知识:
-
平均成本,平均距离
-
标准成本偏差和实例的标准偏差
-
数据点本身
每个标记的数据点(或特征)有两个平均值和两个标准偏差值。一个标记的数据点(特征)有一个成本值和一个距离值。我们将考虑平均值和标准偏差,并计算该标记数据点的 PDF。
结果表明,每个特征对返回一个概率密度值对,当然。我们取这对概率的乘积,并返回一个组合概率值作为该特定特征行的 PDF。
我们有足够的信息来计算组合概率值:
def probabilityDensity(labelledFeaturesVector: Vector, broadcastVariableStatsVectorPair: (Vector / Transactions /, Vector / Distance / )): Double = {
}
策略是将传入的标记特征转换为数组,然后在该数组上调用map操作。我们想要每个数据点的概率密度。每个数据点像这样,{特征 1, 特征 2},其中特征 1有一个平均值和一个标准偏差,特征 2也有相同的。为了做到这一点,我们需要应用整个数据集的平均值和标准偏差。
在 PDF 内部,写下以下代码。在内部定义一个名为featureDoubles的内部函数:
def featureDoubles(features: Array[Double],
transactionSdMeanStats: Array[Double],
distanceSdMeanStats: Array[Double]): List[(Double, Double, Double)] = { }
在内部函数中,放置以下代码。思路是组装一个看起来像这样的元组列表:
(93.47397393,79.98437516250003,18.879) (6.075334279,5.13..,1.9488924384002693)
在第一个元组中,93.47397393是第一行的交易特征值,79.98437516250003是所有交易的均值,18.879是所有交易的标准差。
在第二个元组中,6.075334279是第一行的距离特征值,5.13..是所有距离的均值,1.9488924384002693是所有距离的标准差。
目标是计算每个数据点的 PDF。由于有两个特征,每个数据点有两个 PDF。因此,联合概率是这两个概率的乘积。
我们首先希望得到一个包含我们的测试DataFrame features的元组,形式为Array[Double],一个包含均值和标准差的交易DataFrame,形式为Array[Double],以及一个包含均值和标准差的距离DataFrame,形式为Array[Double]:
(features, transactionSdMeanStats, distanceSdMeanStats).zipped.toList
内部函数featureDoubles已完成。让我们定义一个名为pdF的变量,表示概率密度元组的列表。
完成的函数featureDoubles看起来像这样:
def featureDoubles(features: ....,
transactionSdMeanStats: ...,
distanceSdMeanStats: ...): List[(Double, Double, Double)] = {
(features, transactionSdMeanStats, distanceSdMeanStats).zipped.toList)
}
接下来,我们需要一个 PDF 计算器,我们称之为pDfCalculator。pDfCalculator是一个表示测试数据集中每行三个元组的List的名称;每个元组包含三个双精度浮点数。我们希望每个元组内部看起来像这样:一个交易值,一个交易均值和一个交易标准差。由于存在第二个元组,第二个元组看起来像这样:(距离值,距离均值和距离标准差)。当map被调用时,列表(元组列表)中的每个元组依次被操作。元组内的三个值都有其存在的理由。所有三个都是计算一个特征的概率密度的必要条件,如下所示:
val pDfCalculator: List[(Double, Double, Double)] = featureDoubles(
labelledFeaturesVector.toArray,
broadcastVariableStatsVectorPair._1.toArray,
broadcastVariableStatsVectorPair._2.toArray)
在下一行代码中,我们将执行一个map操作。在map操作中,我们将应用一个返回probabilityDensityValue的函数。为此,我们将求助于 Apache Commons Math 库中的NormalDistribution类。NormalDistribution类的构造函数需要均值、标准差以及数据点本身。一个特征行中包含两个特征。那个特征行包含两列——Transaction和Distance。因此,map将依次计算两个数据点的概率密度值,一个Transaction数据点和一个Distance数据点:
val probabilityDensityValue: Double = pDfCalculator.map(pDf => new NormalDistribution(pDf._2,pDf._3).density(pDf._1)).product
最终形式的probabilityDensity函数看起来像这样:
def probabilityDensity2(labelledFeaturesVector: ----, broadcastVariableStatsVectorPair: (----,----)): Double = {
def featureDoubles(features: -----,
transactionSdMeanStats: ----,
distanceSdMeanStats: -----): List[(Double, Double, Double)] = {
A tuple converted to a List[(Double, Double, Double)]
(Feature Vector, Mean and Standard Deviation of Transaction, Mean and Standard Deviation of Distance)
}
最后,我们希望probabilityDensity函数返回由val probabilityDensityValue计算出的概率密度值。
在完成概率密度计算后,我们现在将注意力转向计算最佳误差项。误差项用希腊字母 Epsilon 表示。
计算最佳误差项和最佳 F1 分数
在本节中,我们将编写一个函数来计算:
-
最佳误差项(也称为 Epsilon)
-
最佳 F1 分数
我们首先定义一个名为 errorTermCalc 的函数。它需要哪些参数?很明显,我们需要两个参数:
-
我们的交叉验证数据集——
DataFrame -
包含概率密度的
DataFrame
就这样。我们现在有一个名为 errorTermCalc 的函数,它接受两个参数并返回最佳误差项和最佳 F1。
为什么这些数字很重要?为了回答这个问题,我们首先想要检测异常值。这些标记数据点表明欺诈。在计算最佳误差项和最佳 F1 之前,首先生成一个新的数据框,将标记数据点分类为欺诈或非欺诈是第一步。
这些是:
-
所有 PDF 中的最小值——
pDfMin -
所有 PDF 中的最大值——
pDFMax
代码中的算法首先将基线值 pdFMin 分配给最佳误差项。然后,它通过一个仔细选择的步长循环到 pdfMax。记住,我们想要最佳 F1 分数,而做到这一点的方法是将 0 分配给任何 F1 分数可能具有的最差值。
算法随后通过 PDF 值的范围,并分别得到最佳误差项和最佳 F1 分数的最终值。基本上,这些最终值是通过以下主要检查获得的:
-
最佳 F1 分数的中间计算值是否大于 0?
-
任何误差项的值是否小于某个标记数据点的概率密度值?
记住,每个数据点都有一个概率密度;因此,我们正在遍历整个(交叉)验证数据集。
如果主要检查 1 中的测试通过,则该点的最佳 F1 更新为最佳 F1 分数的中间计算值。逐步比较 PDF 值与预定义的误差项。如果 PDF 小于预定义的 Epsilon,则该数据点成为预测的欺诈值。
errorTermCalc 函数的定义如下:
private def errorTermCalc(testingDframe: DataFrame, probabilityDensities: DataFrame/*Dataset[Double] */) = { }
我们将开始详细阐述新函数大括号内的细节。
概率密度的最大值和最小值
这里是如何提取概率密度的最小值和最大值的:
val maxMinArray: Array[Double] = probabilityDensities.collect().map(pbRow => pbRow.getDouble(0) )
我们需要一个合理、仔细选择的误差项步长。这就是我们在下一步将要做的。
最佳误差项计算步骤大小
现在,让我们定义一个 step 大小来计算最佳的 Epsilon:
val stepsize = (maxMinPair._1 - maxMinPair._2) / 1000.0
我们需要一个循环,以便算法可以遍历并计算误差项的每个 step 大小值下的 labelAndPredictions 数据框。这也有助于我们找到最佳 F1。
生成最佳 F1 和最佳误差项的循环
让我们找到不同 Epsilon 值的最佳 F1:
for (errorTerm <- maxMinPair._2 to maxMinPair._1 by stepsize) {
将误差项广播到 Spark。首先创建 broadcast 变量:
val broadCastedErrorTerm:Broadcast[Double] = session.sparkContext.broadcast(errorTerm)
val broadcastTerm: Double = broadCastedErrorTerm.value
在这里生成预测。如果概率密度数据框中的Double值小于broadCastedErrorTerm,则该值被标记为fraud。
可能你会遇到以下错误:
Unable to find encoder for type stored in a Dataset. Primitive types (Int, String, etc) and Product types (case classes) are supported by importing spark.implicits._ Support for serializing other types will be added in future releases。
为了解决这个问题,我们添加了以下import语句:
import session.implicits._
为了将特定数据类型的数据放入新的DataFrame中,Spark 要求你传入适当的Encoders。处理完这些之后,让我们开始生成预测。
生成预测 - 代表欺诈的异常值
我们首先将之前的probabilityDensities数据框进行转换:
val finalPreds: DataFrame= probabilityDensities.map { probRow =>
if (probRow.getDouble(0) < broadcastTerm) {
1.0 /* Fraud is flagged here */
} else 0.0
}.toDF("PDF")
现在,让我们创建一个新的数据框,包含两个数据框 - 测试数据框和最终预测数据框。在测试数据框中删除"label"列,并与finalpreds数据框进行交叉连接。别忘了用默认存储级别(MEMORY_AND_DISK)持久化新的数据框:
val labelAndPredictions: DataFrame = testingDframe.drop("label").crossJoin(finalPreds).cache()
println("Label And Predictions: " )
labelAndPredictions.show()
接下来,我们想要生成最佳误差项和最佳 F1 度量。
生成最佳误差项和最佳 F1 度量
在本节中,我们想要找出假阳性的数量、真阳性的数量和假阴性的数量。首先,我们想知道有多少假阳性:
val fPs = positivesNegatives(labelAndPredictions, 0.0, 1.0)
println("No of false negatives is: " + fPs)
现在,我们想知道有多少真阳性:
val tPs = positivesNegatives(labelAndPredictions, 1.0, 1.0)
我们还想知道有多少假阴性存在:
val fNs = positivesNegatives(labelAndPredictions, 1.0, 0.0)
现在我们有了fNs、tPs和fPs,我们可以计算precision和recall指标。
准备计算精确度和召回率
这里是实现一个简单的数学方程的代码行,用于计算精确度和召回率。
让我们计算precision:
val precision = tPs / Math.max(1.0, tPs + fPs)
紧接着计算recall:
val recall = tPs / Math.max(1.0, tPs + fNs)
我们既有precision也有recall。这为我们提供了计算 F1 分数或f1Measure所需的内容:
val f1Measure = 2.0 * precision * recall / (precision + recall)
接下来,让我们确定bestErrorTermValue和bestF1measure:
if (f1Measure > bestF1Measure){ bestF1Measure = f1Measure bestErrorTermValue = errorTerm //println("f1Measure > bestF1Measure") scores +( (1, bestErrorTermValue), (2, bestF1Measure) ) } }
我们几乎完成了最佳误差项(Epsilon)和最佳 F1 度量的计算。
在下一步中,我们将总结我们刚刚如何生成最佳 Epsilon 和最佳误差项。
回顾我们如何遍历一系列 Epsilons,最佳误差项和最佳 F1 度量
在到达这里之前,我们实现了一个循环。以下是这些步骤的伪代码:
for (errorTerm <- maxMinPair._2 to maxMinPair._1 by stepsize) {
//Step 1: We broadcast the error term (epsilon) into Spark
//Step 2: We generate predictions
//Step 3: We will crossjoin the final predictions dataframe with our initial Testing Dataframe
//Step 4: We calculate False Negatives, True Negatives, False Negatives and True Positives
//Step 5: Calculate Precision and Recall
//Step 6: Calculate F1
Step 7: Return Best Error Term and Best F1 Measure
}
在先前的Step 3中,我们推导出了labelsAndPredictions数据框。在Step 4中,我们着手计算以下内容:
-
假阳性
-
假阴性
-
真阳性
在下一节中,我们将实现名为positivesNegatives的方法来计算假阳性、假阴性和真阳性。以下是evalScores方法函数的表示,其中算法进行了大量的处理:
def evalScores(testingDframe: DataFrame,probabilityDensities: DataFrame): ListMap[ Int, Double] = {
/*
Extract the smallest value of probability density and the largest. */
val maxMinArray: Array[Double] = probabilityDensities.collect().map(pbRow => pbRow.getDouble(0) )
/*
A sensible step size
*/
val stepsize = (maxMinPair._1 - maxMinPair._2) / 750.0
/*
Write the loop to calculate the best Epsilon and the best F1 at that Best Epsilon
*/
for (errorTerm <- maxMinPair._2 to maxMinPair._1 by stepsize) {
//Step 1: We broadcast the error term (epsilon) into Spark
val broadCastedErrorTerm:Broadcast[Double] = ----
//Step 2: We generate predictions
import session.implicits._
val finalPreds: DataFrame= probabilityDensities.map { ...... }
//Step 3: We will crossjoin the final predictions dataframe with our initial Testing Dataframe
val labelAndPredictions: DataFrame = testingDframe.drop("label").crossJoin(finalPreds).cache()
//Step 4: We calculate False Negatives, True Negatives, False Negatives and True Positives
//Step 5: Calculate Precision and Recall
val fPs = <<Invoke the positivesNegatives here >>
val tPs = <<Invoke the positivesNegatives here >>
val tPs = <<Invoke the positivesNegatives here >>
//The Precision and recall based on Step 5
val precision = tPs / Math.max(1.0, tPs + fPs)
val recall = tPs / Math.max(1.0, tPs + fNs)
//Step 6: Calculate F1 based on results from Step 5
val f1Measure = 2.0 * precision * recall / (precision + recall)
//Step 7: Return Best Error Term and Best F1 Measure
/*
//The logic to get at the Best Error Term (epsilon) and the F1 is this:
// At any point of time, in the looping process, if the F1 measure value from Step 6 is
// greater than 0, then that F1 value is assigned to the Scala val representing the Best F1
// Both these value are added into a Scala ListMap
//When the loop is done executing we have an updated ListMap that contains two values: The Best F1 //and the Best Error Term
到目前为止,我们讨论了想要计算最佳误差项和最佳 F1 度量。这两个指标都需要计算精确度和召回率的值,而这些值反过来又依赖于 fPs、fNs 和 tPs 的计算值。这引出了下一个任务,即创建一个计算这些数值的函数。这就是下一步的重点。
计算假正例的函数
在本节中,我们编写了一个positivesNegatives函数,该函数接受来自第 3 步的labelsAndPredictions数据框,并输出假正例、假负例或真正例,具体取决于我们想要什么。
它还接受两个其他参数:
-
一个目标标签,可以取以下值:
-
真正例的值为
1.0 -
假正例的值为
0.0 -
假负例的值为
1.0 -
一个最终的预测值,可以取以下值:
-
真正例的值为
1.0 -
假正例的值为
1.0 -
假负例的值为
0.0
因此,这里有一个计算所有三个值(真正例、假正例和假负例)的方法:
def positivesNegatives(labelAndPredictions: DataFrame /* Dataset[(Double, Double)] */,
targetLabel: Double,
finalPrediction: Double): Double = {
}
该方法的主体是一行代码,计算一个Double值,当然:
labelAndPredictions.filter( labelAndPrediction =>
labelAndPrediction.getAs("PDF") == targetLabel &&
labelAndPrediction.get(1) == finalPrediction ).count().toDouble
完成的方法看起来是这样的:
def positivesNegatives(labelAndPredictions: DataFrame /* Dataset[(Double, Double)] */, targetLabel: Double, finalPrediction: Double): Double = {
//We do a filter operation on our labelsAndPredictions DataFrame. The filter condition is as follows:
// if the value under the label column matches the incoming targetLabel AND the value in the predictions column matches the finalPrediction value then count the number of datapoints that satisfy this condition. This will be your count of False Positives, for example.
labelAndPredictions.filter( <<the filter condition>>).count().toDouble
}
这完成了欺诈检测系统的实现。在下一节中,我们将总结本章所取得的成果。
摘要
欺诈检测不是一个监督学习问题。我们没有使用随机森林算法、决策树或逻辑回归(LR)。相反,我们利用了所谓的高斯分布方程来构建一个执行分类的算法,这实际上是一个异常检测或识别任务。选择一个合适的 Epsilon(误差项)以使算法能够找到异常样本的重要性不容小觑。否则,算法可能会走偏,将非欺诈示例标记为异常或异常值,这些异常值表明存在欺诈交易。关键是,调整 Epsilon 参数确实有助于更好的欺诈检测过程。
所需的计算能力的大部分都用于寻找所谓的最佳 Epsilon。计算最佳 Epsilon 是关键的一部分。当然,另一部分是算法本身。这正是 Spark 大显身手的地方。Spark 生态系统为我们提供了一个强大的环境,让我们能够以分布式的方式高效地并行化和编排我们的数据分析代码。
在下一章中,我们将对飞行性能数据进行数据分析任务。
问题
以下是一些将巩固和深化你对欺诈检测知识的问题:
-
高斯分布是什么?
-
在我们的欺诈检测系统中,算法在生成概率之前需要输入一些非常重要的事情,那是什么?
-
为什么在检测异常值和识别正确的假阳性和假阴性时,选择一个误差项(Epsilon)如此重要?
-
为什么欺诈检测并不完全是一个分类问题?
-
欺诈检测本质上是一个异常识别问题。你能说出定义异常识别的两个属性吗?
-
你能想到其他可以利用异常识别或异常值检测的应用吗?
-
为什么交叉验证如此重要?
-
为什么我们的欺诈检测问题不是一个监督学习问题?
-
你能说出优化高斯分布算法的几种方法吗?
-
有时候,我们的结果可能不尽如人意,因为算法未能识别某些样本为欺诈。我们还能做些什么来改进?
是时候进入最后一部分了,我们将邀请读者通过参考所指示的资源来进一步丰富他们的学习之旅。
进一步阅读
PayPal 的数据平台执行实时决策,以防止欺诈。他们的系统每天处理数个 PB 的数据。查看 qcon.ai/ 了解最近的 AI 和 ML 会议。研究他们的用例,了解像 PayPal 这样的公司如何利用 AI 和 ML 的最新进展来帮助打击欺诈。
探索 Kafka 如何与 Spark 合作,将近乎实时的欺诈检测带到您的欺诈检测流程中。
我们都熟悉 Airbnb (www.airbnb.com/trust)。了解 Airbnb 的信任和安全团队是如何在保护并扩大其基于信任的商业模式的同时,打击欺诈的。
第六章:构建航班性能预测模型
航班延误和取消是旅行的烦恼。芝加哥飞往的航班是否会晚点,导致旅客错过前往丹佛的转机航班?另一位在芝加哥机场的旅客刚刚得知他们的转机航班被延误,甚至可能被取消。如果这两位旅客都能预测他们各自经历这种情况的概率,旅行将变得更加美好。
话虽如此,实现一个能够预测上述情况的航班延误流程是本章的主要学习目标。下一节将列出本章涵盖的所有学习目标。
本章的所有学习目标都依赖于美国交通部编制的以下数据集。这些分别是航班数据、航空公司数据和航班性能数据。
本章涵盖的每个主题都有具体的学习目标,分为两类:
-
背景理论,从涵盖 2007 年和 2008 年航班、承运人和航班性能数据集开始
-
一个基于 Spark-Scala 的航班延误预测模型实现
话虽如此,当前的学习目标是理解 2007 年和 2008 年的航班准点率数据集。一个好的起点是“航班数据概览”部分:
-
理解与理解航班相关的背景理论
-
通过应用背景理论来制定航班性能问题
-
我们从美国交通部网站上学习如何选择数据集,我们选择的数据集属于 2007 年和 2008 年的数据
-
我们希望通过数据探索步骤从数据中学习到什么
-
将数据分为测试集和训练集
-
在 Scala 和 Spark 中实现模型以预测航班性能
航班延误预测概述
在本章中,我们将实现一个基于逻辑回归的机器学习模型来预测航班延误。该模型将从下一节“航班数据概览”中描述的航班数据中学习。
一个现实情况是这样的——旅行社 T 在其预订系统中新增了一个预测功能,旨在提升客户的旅行体验。如何做到这一点呢?例如,假设旅客X想从起点A(圣路易斯)乘坐西南航空公司的SW1航班前往目的地C(丹佛),并在城市B(芝加哥)转机。如果 T 的航班预订系统能够预测X的航班在芝加哥晚点的概率,以及错过转机航班的风险,X将拥有信息来决定下一步的行动。
在这些开场白之后,让我们来看看我们的航班数据集。
航班数据概览
本章的数据分析依赖于一个飞行数据集,该数据集由以下单个数据集组成。从ModernScalaProjects文件夹下载这些数据集:
-
Airports.csv -
AirlineCarriers.csv -
Flights.csv -
OnTime2007Short.xlsx -
OnTime2008Short.xlsx
以下截图是机场和航空公司数据集的整体视图:

机场和航空公司数据集
以下表格描述了准时数据集(OnTime2008Short.xlsx)的结构。它列出了所有 28 个字段。该表由非规范化、半结构化数据组成:

The OnTime2008Short 文件数据集
字段描述分为以下类别:
-
航空公司(承运人)延误原因(以分钟计):
-
FlightCarrierDelay: 表示由承运人引起的延误 -
FlightWeatherDelay: 表示由天气引起的延误 -
FlightNASDelay: 表示由国家航空系统引起的延误 -
FlightSecurityDelay: 表示由于安全检查或其他安全原因引起的延误 -
FlightLateAircraftDelay: 表示由于前述原因以外的其他原因导致飞机晚点 -
Flight aircraft data:
-
FlightUniqueCarrier: 一个大写字母的唯一两个字母序列,或者一个数字一个字母的序列(例如,US,DL,9E)
本节代表了一个项目的全面概述。首先,我们以高层次的形式概述了我们想要解决的问题的本质。问题表述步骤为实施铺平了道路。首先,让我们表述飞行延误预测问题。
飞行延误预测的问题表述
飞行延误问题的概述可以用一句话总结——我们希望实现一个预测模式,对飞行延误进行预测。简而言之,有行程的旅客想知道他的/她的航班是否晚点。
入门
本节首先概述了第四章,构建垃圾邮件分类管道的实施基础设施。本节的目标将是开始开发一个数据管道来分析飞行准时数据集。第一步是设置先决条件,然后再进行实施。这就是下一小节的目标。
设置先决条件软件
以下推荐或推荐的先决条件或先决条件检查。此列表中新增了一个先决条件:MongoDB:
-
增加 Java 内存
-
检查 JDK 版本
-
基于简单构建工具(SBT)的独立 Scala 应用程序,其中所有依赖项都连接到
build.sbt文件 -
MongoDB
我们首先详细说明增加 Spark 应用程序可用内存的步骤。我们为什么要这样做?这一点以及与 Java 堆空间内存相关的其他点将在以下主题中探讨。
增加 Java 内存
飞行准点记录,按时间顺序编译,比如,按月,变成大数据或中等数据。在本地机器上处理如此大量的数据并非易事。在大多数情况下,具有有限 RAM 的本地机器根本不够用。
尽管这种情况可能具有挑战性,但我们希望充分利用我们的本地机器。这让我们想到了为什么要增加 Java 内存。例如,尝试处理一个
典型的单次数据集文件,27 列和 509,520 行,足以导致 Java
以至耗尽内存(见以下截图):

GC 开销限制超出
首先,java.lang.OutOfMemory发生时,您的 Java 虚拟机尝试超过由-Xmx参数设置的阈值内存分配。
-Xmx参数与内存管理有关。它用于设置最大 Java 堆大小。从 Java 1.8 开始,JVM 将根据机器上的物理内存分配堆大小
为了解决这个问题,这里有一些不同的方法可以增加 Java 内存:
-
方法 1:在命令行中,我们将以下运行时参数传递给 SBT:
-
允许的最大堆大小
-
Java 线程堆栈大小
-
初始堆大小
-
方法 2:在 Java 控制面板中设置最大 Java 堆大小。
-
方法 3:在环境变量
JAVA_OPTS中全局设置这些参数。
为了解决前面截图中所展示的GC Overhead Limit exceeded问题,我们可以在命令行上快速分配更多的堆空间,如下所示:

分配堆空间
注意到-Xmx2G设置。我们使用-Xmx2G设置SBT_OPTS环境变量,这是最大分配的 Java 堆空间内存。我们设置它然后运行 SBT。
在我们继续到下一个方法之前,了解以下 JVM 堆分配统计信息可能是有用的:
-
总内存
-
最大内存
-
可用内存
这很有用。堆内存利用率数字是揭示性的。以下截图显示了如何做到这一点:

堆内存
接下来,我们将讨论方法 2,其中我们将通过步骤设置 Java 运行时参数全局。
以下步骤适用于 Windows 机器。
- 导航到开始 | 控制面板,然后在类别下选择小图标:

控制面板
- 下面的面板允许您更改计算机的设置。Java 设置就是其中之一。在控制面板中找到 Java:

所有控制面板项
- 点击 Java,如图中所示,将带您进入 Java 控制面板:

Java 控制面板
- 选择 Java 选项卡将显示 Java 运行时环境设置面板,您可以检查运行时参数,例如 Java 堆大小:

Java 控制面板的用户选项卡
参考 Java 控制面板的表示,我们想在“运行时参数”框中设置最大 Java 堆大小。Xmx2048m是最大堆空间的新值,其中m代表兆字节。修改-Xmx参数的值很容易。点击它,然后将值更改为2048并点击“确定”。
-Xmx和2048m或2 GB之间没有空格。
就这样。退出控制面板:

Java 控制面板运行时参数
说到 Java 内存管理和可用于帮助我们管理 Spark 应用程序中 Java 内存使用的设置,以下是在运行java -X命令行时可用的一系列命令行选项:

java -X 命令行
上述截图展示了命令行选项的完整列表。这些选项允许您调整与您的基于 JVM 的 Spark 应用程序内存使用相关的不同 Java 环境设置。我们感兴趣的是 Xmx 设置。
我们刚刚描述了方法 2,其中概述了如何在 Java 控制面板中设置 Java 运行时参数-Xmx。
这就留下了方法 3,其中我们描述了如何全局设置三个运行时参数。根据前面的截图,这些是:
-
-Xmx:设置(或分配)Java 堆空间允许增长到的大小(以兆字节为单位)。一个典型的默认设置是64m。 -
-Xms:设置初始 Java 堆大小。默认值是 2 MB。 -
-Xss:设置 Java 线程堆栈大小。
我们将在名为JAVA_OPTS的环境变量中设置这些参数。
以下步骤说明了如何做到这一点:
- 首先,我们右键单击“此电脑”并选择“属性”:

属性选项卡
- 点击“属性”将带我们到以下屏幕:

系统选项卡
- 点击“高级系统设置”将带我们到以下屏幕:

系统属性选项卡
- 点击“环境变量...”按钮。在接下来的屏幕中,我们将能够设置
JAVA_OPTS。如果JAVA_OPTS不存在,创建一个新的。点击“新建”,在变量名和变量值框中输入适当的值。通过点击“确定”来关闭对话框:

新系统变量
- 您的新
JAVA_OPTS变量现在已准备就绪:

环境变量
在我们刚刚设置的环境设置中,将JAVA_OPTS环境变量设置为JAVA_OPTS = =Xmx2048M -Xms64M -Xss16M。
回顾前面的截图,快速了解这些设置。
为了全面了解所有环境变量,请启动 Windows PowerShell(桌面上应该有一个 PowerShell 应用程序)。以下是所有环境变量的完整列表。注意相关的变量:

Hadoop 环境设置
回顾一下,以下是在选择合适的 Java(最大 Java 堆大小)时需要考虑的事项:
-
设置最大堆空间(以字节为单位)和初始堆大小(也以字节为单位)。这些是适当的内存分配池值,有助于控制基于 JVM 的 Spark 应用程序的内存使用量。
-
-Xmx选项更改了 VM 的最大堆空间。一些示例设置是-Xmx2048,-Xmx81920k和-Xmx1024m。
-Xmx10G等同于-Xmx1024m或-Xmx1024g。
-Xms选项允许我们设置初始堆大小。例如,默认值是 64 MB 或 640 KB,例如Xms64m。考虑以下:
-
为了确定可以设置多高的堆大小,我们建议将 Java 堆空间设置为不超过可用总 RAM 的 50%。例如,如果您的机器有 32 GB 的可用 RAM,我们建议将最大堆空间设置不超过 16 GB。
-
在我们的示例中,将最大堆空间设置为超过 16 GB 的值会导致性能问题。
接下来,我们将审查您的系统 JDK。
检查 JDK 版本
如果您有 JDK 8,那么您就可以安全地跳过这一部分。如果您想安装 JDK 9,请不要安装。Spark 与任何大于 8 的 JDK 版本都不兼容。此外,请确保您没有将 JDK 安装到包含空格的路径中。这是一个小细节,但我们想确保。
在下一节中,我们将讨论 MongoDB 的安装。我们将讨论为什么以及如何进行。
MongoDB 安装
什么是 MongoDB,我们为什么需要它?首先,MongoDB 的文档模型使得在应用程序代码中将对象映射到 MongoDB 中的等效 JSON 表示变得容易。这还有更多。Spark 与 MongoDB 有良好的集成。一个明显的优势是能够将我们的实时 dataframe 作为文档发布到 MongoDB。从 MongoDB 中检索 dataframe 文档在性能方面也很好。
安装 MongoDB(在 Windows 上)有两个先决条件:
-
只有 64 位机器能够支持 MongoDB
-
一定要获取最新的 Windows 更新
要开始安装,请从mongodb.com网站上的 MongoDB 下载中心页面下载 MongoDB 社区服务器的最新稳定版本。这将版本号为 4.0。根据您所使用的操作系统,下载相应的版本。以下说明适用于 Windows 10,64 位用户。
MongoDB 产品不再支持 32 位 x86 操作系统平台。
在接下来的几个步骤中,我们将安装 MongoDB 作为服务:
- 点击 MongoDB 安装程序,一个 MSI 文件:

MongoDB 的 MSI 文件
- 如以下截图所示,点击“安装”:

MongoDB 安装屏幕
- 点击“下一步”,并继续使用完整的安装类型:

MongoDB 的“下一步”按钮
- 点击“完成”,并继续使用完整的安装类型:

MongoDB 的完整选项
- 如前所述,我们将选择不将 MongoDB 作为服务安装。因此,请取消选中“将 MongoDB 作为服务安装”选项:

MongoDB 服务配置
注意您将 MongoDB 安装到的位置。服务器安装在C:\MongoDB\Server\4.0。数据文件夹位于C:\MongoDB\Server\4.0\data。
- 接下来,您将看到 MongoDB Compass 的屏幕:

安装 MongoDB Compass
在下一节中,我们将向您展示我们如何以及为什么使用 MongoDB。在完成先决条件并建立应用程序构建基础设施后,我们继续到实施和部署部分。
实施和部署
实施取决于设置大数据基础设施。请验证您的 MongoDB 安装是否正常运行。现在我们将按以下方式列出实施目标:
-
将数据分割成测试、训练和验证数据集
-
数据摄取
-
数据分析
实施目标
总体目标是分析 2007-2008 年对应的准时航班数据集。在 2007 年的航班数据中,80%将用作训练数据集,其余的作为验证数据集。就模型性能评估而言,2008 年航班数据的 100%将成为测试数据集。
以下是实现飞行预测模型所需实现的目标:
-
下载航班数据集。
-
您可以通过以下四种方式开发管道:
-
在您的本地 Spark shell 中逐步进行
-
通过在您的托管虚拟机上启动 Horton Sandbox,并在强大的 Zeppelin 笔记本环境中编写代码
-
在 Azure 云上开发一切
-
将应用程序作为自包含的 SBT 应用程序开发,并使用
spark-submit将其部署到您的本地 Spark 集群 -
在 IntelliJ 中完善您的代码,并在
build.sbt文件中连接所有必要的依赖项。 -
运行应用程序并解释结果。
在下一小节中,我们将逐步记录实施项目的说明。在接下来的步骤中,我们将在 IntelliJ 中创建一个新的 Scala 项目,并将其命名为Chapter6。
创建一个新的 Scala 项目
让我们创建一个名为Chapter6的 Scala 项目,具有以下工件:
-
AirlineWrapper.scala -
Aircraft.scala
以下截图展示了我们的项目外观:

IntelliJ 项目结构
让我们分解一下项目结构:
-
.idea:这些是生成的 IntelliJ 配置文件。 -
project:包含build.properties和plugins.sbt。例如,plugins.sbt可以用来指定 SBT assembly 插件。 -
src/main/scala:一个包含com.packt.modern.chapter6包中 Scala 源文件的文件夹。 -
src/main/resources:任何数据或配置文件;例如,一个名为log4j.xml的 log4j 配置文件。 -
target:这是编译过程产生的工件存储的地方。任何生成的汇编 JAR 文件都放在那里。 -
build.sbt:这是主要的 SBT 配置文件。在这里指定 Spark 及其依赖项。
到目前为止,我们将开始开发。我们从 AirlineWrapper.scala 文件开始,以将最终的应用程序 JAR 部署到 Spark 中的 spark-submit 结束。
构建 AirlineWrapper Scala 特质
AirlineWrapper 包含创建名为 session 的 SparkSession 实例的代码。它还声明了用于表示我们的航班数据集的案例类。
让我们先创建 trait 定义:
trait AirlineWrapper { }
编程的入口点是以下内容:在 trait 中,我们首先声明一个名为 session 的 lazy val。这是我们在第一次遇到时懒加载 SparkSession 实例的地方。懒加载意味着 val 只在第一次遇到时执行。会话是我们使用 DataSet 和 DataFrame API 编程 Spark 的入口点:
lazy val session = { SparkSession.builder()..getOrCreate() }
在以下代码片段中,CarrierCode 是美国运输部分配的唯一识别号码,用于识别一家航空公司(承运人):
case class AirlineCarrier(uniqueCarrierCode: String)
在以下代码中,originOfFlight 是航班的起点(IATA 机场代码),而 destOfFlight 是航班的终点(IATA 机场代码):
case class Flight(monthOfFlight: Int, /* Number between 1 and 12 */
dayOfFlight: Int, /*Number between 1 and 31 */
uniqueCarrierCode: String,
arrDelay: Int, /* Arrival Delay - Field # 15*/
depDelay: Int, /* Departure Delay - Field # 16 */
originAirportCodeOfFlight: String, /* An identification number assigned by US DOT to identify a unique airport. */
destAirportCodeOfFlight: String, /* An identification number assigned by US DOT to identify a unique airport.*/
carrierDelay: Int, /* Field # 25*/
weatherDelay: Int, /* Field # 26*/
lateAircraftDelay: Int /* Field # 29*/
)
在以下代码片段中,iataAirportCode 是国际机场缩写代码:
case class Airports(iataAirportCode: String, airportCity: String, airportCountry: String)
加载并从机场的数据集中创建一个 File 对象:
val airportsData: String = loadData("data/airports.csv")
加载并从航空公司承运人数据集中创建一个 File 对象:
val carriersData: String = loadData("data/airlines.csv")
从主要的 FAA 数据集中创建一个 File 对象:
val faaFlightsData: String = loadData("data/faa.csv")
此方法接受 resources 文件夹内数据的相对路径:
def loadData(dataset: String) = {
//Get file from resources folder
val classLoader: ClassLoader = getClass.getClassLoader
val file: File = new File(classLoader.getResource(dataset).getFile)
val filePath = file.getPath
println("File path is: " + filePath)
filePath
}
接下来,我们将编写一个名为 buildDataFrame 的方法:
import org.apache.spark.sql.SparkSession
import org.apache.spark.SparkConf
import org.apache.spark.ml.linalg.{Vector, Vectors}
import org.apache.spark.rdd.RDD
记得更新你的导入语句。必要的输入语句如下所示。这是我们能够编译到目前为止开发的所有代码所需的一切:
def buildDataFrame(dataSet: String): RDD[Array[String]] = {
//def getRows2: Array[(org.apache.spark.ml.linalg.Vector, String)] = {
def getRows2: RDD[Array[String]] = {session.sparkContext.
textFile(dataSet).flatMap {
partitionLine =>
partitionLine.split("\n").toList
}.map(_.split(","))
}
//Create a dataframe by transforming an Array of a tuple of Feature
Vectors and the Label
val dataFrame = session.createDataFrame(getRows2).
toDF(bcwFeatures_IndexedLabel._1, bcwFeatures_IndexedLabel._2)
//dataFrame
//val dataFrame = session.createDataFrame(getRows2)
getRows2
}
导入 MongoDB 包,包括连接器包,特别是:
/*
Import the MongoDB Connector Package
*/
import com.mongodb.spark._
import com.mongodb.spark.config._
import org.bson.Document
创建 Aircraft 对象:
object Aircraft extends AirlineWrapper {
在 Aircraft 对象内部创建一个 main 方法,如下所示:
def main(args: Array[String]): Unit = {
}
object 现在看起来是这样的:
object Aircraft extends AirlineWrapper {
def main(args: Array[String]): Unit = {
}
}
创建一个 case class 来表示数据集中精心挑选的特征,这些特征我们将决定将对数据分析做出最大贡献:
case class FlightsData(flightYear: String, /* 1 */
flightMonth : String, /* 2 */
flightDayOfmonth : String, /* 3 */
flightDayOfweek : String, /* 4 */
flightDepTime : String, /* 5 */
flightCrsDeptime : String, /* 6 */
flightArrtime : String, /* 7 */
flightCrsArrTime : String, /* 8 */
flightUniqueCarrier : String,/* 9 */
flightNumber : String, /* 10 */
flightTailNumber : String, /* 11 */
flightActualElapsedTime : String, /* 12 */
flightCrsElapsedTime : String, /* 13 */
flightAirTime : String, /* 14 */
flightArrDelay : String, /* 15 */
flightDepDelay : String, /* 16 */
flightOrigin : String, /* 17 */
flightDest : String, /* 18 */
flightDistance : String, /* 19 */
flightTaxiin : String, /* 20 */
flightTaxiout : String, /* 21 */
flightCancelled : String, /* 22 */
flightCancellationCode : String, /* 23 */
flightDiverted : String, /* 24 */
flightCarrierDelay : String, /* 25 */
flightWeatherDelay : String, /* 26 */
flightNasDelay : String, /* 27 */
flightSecuritDelay : String, /* 28 */
flightLateAircraftDelay : String, /* 29 */ record_insertion_time: String, /* 30 */ uuid : String /* 31 */
)
接下来,创建一个数据框来表示 FlightData:
val airFrame: DataFrame = session.read .format("com.databricks.spark.csv") .option("header", true).option("inferSchema", "true").option("treatEmptyValuesAsNulls", true) .load("2008.csv")
我们刚刚加载了数据集并创建了一个数据框。现在,我们能够打印模式:
println("The schema of the raw Airline Dataframe is: ")
airFrame.printSchema()
printschema() 方法显示以下模式:

我们需要在某些字段上使用类型转换。要调用 cast 方法,我们按照以下导入进行调用:
import org.apache.spark.sql.functions._
现在,我们将创建一个本地临时视图并将其命名为 airline_onTime。这个临时视图仅存在于我们创建 dataframe 所使用的 SparkSession 的生存期内:
airFrame.createOrReplaceTempView("airline_onTime")
对 dataframe 中的行数进行 count 操作:
print("size of one-time dataframe is: " + airFrame.count())
使用给定的名称创建一个本地临时视图。这个临时视图的生存期
与创建此数据集所使用的 SparkSession 相关联:
airFrame.createOrReplaceTempView("airline_ontime")
print("size of one-time dataframe is: " + airFrame.count())
使用给定的名称创建一个本地临时视图。这个临时视图的生存期与创建此数据集所使用的 SparkSession 相关联:
airFrame.createOrReplaceTempView("airline_ontime")
print("size of one-time dataframe is: " + airFrame.count())
在对字段进行裁剪和类型转换并确保数值列正常工作后,我们现在可以将数据保存为 JSON 行和 parquet。调用 toJSON 方法将数据集的内容作为 JSON 字符串的 dataset 返回:
val airFrameJSON: Dataset[String] = clippedAirFrameForDisplay.toJSON
以 JSON 格式显示新数据集:
println("Airline Dataframe as JSON is: ")
airFrameJSON.show(10)
将我们的 JSON 空中数据 dataframe 保存为 .gzip JSON 文件:
airFrameJSON.rdd.saveAsTextFile("json/airlineOnTimeDataShort.json.gz", classOf[org.apache.hadoop.io.compress.GzipCodec])
接下来,我们需要将我们的 dataframe 转换为 parquet 记录。以下代码正是这样做的:
clippedAirFrameForDisplay.write.format("parquet").save("parquet/airlineOnTimeDataShort.parquet")
让我们读取我们新创建的 JSON 归档并显示其前 20 行:
val airlineOnTime_Json_Frame: DataFrame = session.read.json("json/airlineOnTimeDataShort.json.gz")
println("JSON version of the Airline dataframe is: ")
airlineOnTime_Json_Frame.show()
让我们同时加载 parquet 版本:
val airlineOnTime_Parquet_Frame: DataFrame = session.read.format("parquet").load("parquet/airlineOnTimeDataShort.parquet")
打印出空中数据 dataframe 的 parquet 版本:
println("Parquet version of the Airline dataframe is: ")
airlineOnTime_Parquet_Frame.show(10)
接下来,将数据写入 MongoDB 数据库,airlineOnTimeData。调用 save 方法会产生一个包含 .mode 方法的 DataFrameWriter;mode 接受一个 "overwrite" 参数。因此,如果 collection 已经存在于 Mongo 中,新记录仍然会被写入 MongoDB 数据库:
MongoSpark.save( airlineOnTime_Parquet_Frame.write.option("collection", "airlineOnTimeData").mode("overwrite") )
为了确认数据已写入 MongoDB,启动 MongoDB Compass Community 应用程序。在“连接到主机”打开屏幕中,点击“连接”,然后在结果屏幕中点击数据库 test。写入 MongoDB 的好处是,它为我们提供了一个简单的方法来检索我们的数据,并在数据 airlineOnTimeData 集合被损坏的情况下将其导入 Spark。
最后,使用 spark-submit 命令将应用程序提交到 Spark 本地集群。
摘要
在本章中,我们对飞行性能数据进行了 机器学习(ML)数据分析任务。其中一项任务是针对数据的一个训练子集实现回归模型。给定一个新或未知的延误起飞的航班数据,该模型能够预测正在调查的航班是否弥补了失去的时间并在目的地准时到达。从这个机器学习练习中,我们得到的一个重要启示是——从起点到目的地的距离对预测时间增益的贡献最大。承运人延误对预测的贡献最小。结果证明,飞行时间更长,能够获得更多的时间。
本章为构建更复杂的模型奠定了基础。具有更多预测变量(例如,考虑天气和安全延误)的模型可以产生更深入、更尖锐的预测。话虽如此,本章希望为读者打开一扇了解航班性能洞察如何帮助旅行者以金钱和时间成本获得最佳旅行体验的机会。
在下一章和最后一章中,我们将开发一个推荐系统。从亚马逊的推荐算法和 Netflix 的评分系统中获得灵感,这些系统为我们带来了相关的电影。我们将构建的推荐系统将利用到目前为止我们在 Spark ML 中积累的所有技能。
问题
在读者进入下一章之前,我们邀请读者尝试提升航班性能模型。想法是这样的——输入几个额外的预测因子,以增强航班延误的 ML 过程,使其预测更加深入和尖锐。
这里有一些问题,以进一步拓宽学习视野:
-
parquet文件是什么,它的优点是什么,尤其是在数据集变得更大,节点之间需要数据洗牌时? -
列式格式压缩数据的优点是什么?
-
有时你可能会遇到这个错误:“
无法在 Dataset 中找到存储的编码器。原始类型(Int、String 等)和产品类型(case classes)通过导入 spark.implicits._ 得到支持”。你如何解决这个问题?根本原因是什么?提示——使用第一章中的数据集构建一个简单的 dataframe。使用spark.read方法,并尝试对其执行printSchema。如果产生上述错误,调查是否需要显式模式 -
作为 MongoDB 的替代方案,你更愿意将航班性能数据提交到 HDFS 吗?
-
为什么 MongoDB 在本章中证明是有用的?
-
什么是半结构化数据?
-
列举 Spark 相对于 Hadoop 的一个大优点,使其脱颖而出?例如,考虑编程范式。
-
你能从 Kafka 中读取航班数据吗?如果是的话,你是如何做到的,以及这样做的原因可能是什么?
-
数据丰富化是什么,它与 munging 有何关系,如果这两个术语都有关联的话?
-
使用两个案例类创建一个 dataframe,每个案例类分别从承运人 CSV 和机场 CSV 数据集的小子集中提取。你将如何将此写入 MongoDB?
进一步阅读
以下关于多元回归分析简介的文章讲述了回归分析的重要性:www.ncbi.nlm.nih.gov/pmc/articles/PMC3049417/
第七章:构建推荐引擎
数百万人在亚马逊上订购商品,在那里他们可以节省金钱和时间。推荐算法是从客户的订单偏好中学习得来的,它们为你提供个性化的你可能也会喜欢的推荐,这些建议有助于客户更新购物车或添加有趣的商品到愿望清单以备将来使用。
构建我们自己的推荐引擎是一个学习之旅,在这个过程中,我们会达到几个目标。在问题定义阶段,我们了解到推荐是一个协同过滤的机器学习问题。我们将利用 Spark ML 协同过滤算法来实现一个基于评分的推荐引擎。
Netflix 因其电影推荐功能而闻名,你可能喜欢它的推荐功能。回到 2006 年,Netflix 宣布了一项 100 万美元的奖金,用于对其老化的CineMatch电影推荐算法的最佳改进。这场开创性的比赛催生了机器学习领域的一些最佳进展。在全球各地,几个顶尖的编码团队在 Netflix 发布的电影评分宝库中竞争最高奖项。他们的目标是构建一个算法,该算法能够预测用户评分(从而提供更好的推荐),比 CineMatch 高出 10%。
自那时起,为用户推荐商品的算法已经取得了长足的进步。在本章中,我们将使用 Scala 和 Apache Spark 构建一个推荐系统。这个推荐系统将要解决什么问题?这个问题以及其他问题将很快得到解答。
本章的主要学习目标是实现一个推荐引擎。以下列表是对各个学习目标的全面分解:
-
学习推荐的入门知识;推荐系统也被称为推荐器系统。
-
以例学习——通过截图了解亚马逊的现场推荐是双刃剑;它们提高了客户满意度,并为亚马逊增加了销售收益。
-
在线商店提供的众多产品选择中,顾客需要尽可能多的帮助。在本章中,我们将学习到推荐可以帮助人们更好地、更快地做出选择。这对顾客和想要将潜在客户转化为客户的在线零售商都有好处。
-
下一个可衡量的学习目标是理解哪些推荐是隐式的,哪些不是。
-
了解不同类型的推荐及其功能是好的。我们希望通过学习哪些类型的数据不需要太多细节。为什么?我们想要建立数据集来模拟推荐系统,并将这个数据集与一个只需要用户和产品之间关系的合适算法相匹配。不多也不少。符合这一要求的算法就是协同过滤算法。
-
协同过滤可以实现的是什么,这是一个正在进行中的工作。只有当我们创建自定义数据集,构建基于数据的协同过滤算法,并查看结果时,我们才能更多地了解该算法。
我们将学习如何利用 Spark ML 提供的基于模型的协同过滤算法来构建推荐系统。我们将了解到,我们实现的推荐系统,就像其同类系统一样,基于其他客户的偏好来推荐产品。
我们将从问题概述部分开始。
在本章中,我们将涵盖以下主题:
-
问题概述
-
详细概述
-
实施和部署
问题概述
我们将按顺序组织本节,涵盖选定主题的概述。以下是我们要讨论的主题:
-
亚马逊的推荐
-
推荐系统,也称为推荐系统或推荐引擎
-
对推荐进行分类,例如:
-
隐式推荐
-
明确推荐
-
机器学习的推荐
-
明确推荐的公式化问题——细节
-
武器销售线索和过去销售数据——细节
每个主题都将进行回顾和解释。我们将从第一个主题——亚马逊的推荐开始。
亚马逊的推荐
这个主题分为两部分——简要概述和详细概述。
简要概述
这个主题(细节在详细概述部分中展开)将制定一个路线图,从非机器学习角度对推荐的一般理解开始。我们将通过支持插图向你展示亚马逊的推荐看起来是什么样子。不仅如此,我们还将强调强大的机器学习算法如何为亚马逊的推荐系统提供动力,帮助用户更轻松地做出产品选择。
这个主题的简要概述已经过去了。其详细概述如下。
详细概述
要构建推荐系统,我们必须采取的方法是专注于在概念层面上理解推荐。以下是一些提供对推荐洞察的问题示例:
-
推荐是什么?
-
两种重要的推荐类型是什么?
无论你是希望以盈利方式使推荐引擎为你服务的在线零售商,还是希望近距离探索 Spark ML 强大的推荐算法的人,本节将帮助你入门。
我们将专注于适合的机器学习技术,以便我们可以利用这些技术构建一个推荐系统。
杰夫·贝索斯,拥有数十亿美元的商业帝国,Amazon.com,继续报告健康的销售数字。推荐系统始终为亚马逊带来了增加的收入流。这些系统由机器学习推荐算法支持,帮助实时提供针对特定客户的推荐。毫无疑问,推荐是亚马逊景观的一个组成部分,在客户购买过程的各个方面都发挥着作用。
亚马逊的推荐分为两大类:
-
站内推荐
-
站外推荐
我们将只关注站内推荐。站内和站外推荐都是亚马逊的大收入来源。然而,本章不涵盖站外推荐,但鼓励读者探索亚马逊推荐景观的这一方面。在本章的最后部分,我们有两个关于站外推荐的问题。
站内推荐
通过简单地点击 XYZ 的 Amazon.com 链接,可以轻松获得两种主要的站内推荐类型。这些是:
- 为您推荐,XYZ:
“为您推荐,XYZ”链接看起来是这样的:

观察“为您推荐,XYZ”链接下的推荐内容
这包含了亚马逊认为您可能会点击并购买的产品推荐。这些推荐是如何到达您的呢?这个问题有两个答案。首先,推荐算法跟踪了您的浏览历史。其次,这将带您到一个显示来自各个类别产品列表的页面。
- 您最近查看的商品和特色推荐:
另一个相关的推荐示例如下。根据亚马逊的机器学习推荐系统,这些推荐分布在几个类别中,例如:
- 根据您的浏览历史:
以下页面反映了“根据您的浏览历史”类型的推荐。我们可以看到亚马逊推荐系统的强大作用:

观察在“根据您的浏览历史”链接下的商品
- 根据您的购买:
再次强调,目标很简单——将一系列产品摆放在客户面前。这使得客户能够轻松地购买不同产品,在这种情况下,是一本与兴趣类别下的书籍密切相关的新书。亚马逊是如何在“根据您的浏览历史”类别下提出这些书籍的系列的呢?推荐系统在某个时间点向您推荐了您可能感兴趣的产品:

观察在“根据您的购买”链接下的商品
- 经常一起购买:
这种类型的推荐更有趣。比如说,您点击了Lego Mindstorms书籍,如下截图所示。我们被带到一个新的页面,该页面有经常一起购买的推荐。
下面的截图显示了经常一起购买的推荐:

观察经常一起购买链接
- 购买此商品的用户:
这种类型的推荐是亚马逊的向上销售和交叉销售推荐功能。当您点击刚刚点击的Lego Mindstorm书籍时,推荐系统会提供其他客户一起购买的产品:

观察客户购买此商品也购买了链接
下一个问题概述部分是基于机器学习的推荐。亚马逊的推荐与基于强大机器学习推荐系统相关联。接下来的主题是尝试从高级、非机器学习角度描述推荐系统。因此,我们想知道在高级层面,这样的系统可能或可能不处理的推荐类型。
对推荐以及推荐系统达到这种理解水平,将为进一步探索机器学习空间中某些子集的推荐问题领域铺平道路。
所有类型的推荐都共享以下目标:
-
客户满意度
-
提高亚马逊的销售收入
我们将依次介绍每种推荐类型。首先介绍最重要的现场推荐,即为您推荐,XYZ页面。
话虽如此,我们将进入下一个主题,推荐系统。
推荐系统
在前面的概述主题中,我们探讨了亚马逊推荐和推荐系统的显著方面。让我们尝试将其中一些内容结合起来,并给出推荐系统的定义。
定义
推荐系统可以被定义为从数据中提取并学习,如偏好、他们的行为(例如点击)、浏览历史和生成的推荐,这些是系统认为用户在近期内可能感兴趣的产品。
下面的图示代表了一个典型的推荐系统:

推荐系统
在前面的图中,可以将其视为一个推荐生态系统,其中推荐系统是其核心。这个系统需要三个实体:
-
用户
-
产品
-
用户和产品之间的交易,其中交易包含用户对产品的反馈
可以将交易视为以下行为——用户对一个产品进行评分。但这还不止这些。交易的性质意味着用户正在提供关于产品(们)的反馈。这解释了从用户框开始并延伸到产品框的坚实箭头。正如从图中明显看出,推荐系统在收集所有用户-产品交互,即反馈数据后,生成一个推荐。
存在着不同类型的交易,这让我们来看看不同类型的推荐。
推荐分类
这个主题将借鉴前一个主题。特别是,我们提到了用户-产品交互或反馈数据。实际上,可能存在两种这样的交互。用户反馈是一个更好的术语。
根据用户反馈的类型,我们可以识别出两种类型的推荐,如下所示:
-
隐式
-
显式
每种类型的推荐将依次介绍。我们将首先解释使用隐式反馈的推荐。
隐式推荐
这种数据的一个好例子是隐式信息,例如用户偏好、他们的点击、浏览历史、购买历史、搜索词等等。
这种场景代表了一个基于隐式反馈的推荐系统在工作中的例子。这种系统的关键特征是——用户做了什么?以亚马逊上的用户为例,一些隐式用户反馈的例子包括——用户买了什么?他们点击了哪本书?他们的搜索词是什么?所有这些问题都反映了用户的行为。因此,我们将直接进入问题定义阶段,在那里我们将记录构建协同过滤推荐问题所需的内容。这个推荐系统是隐式还是显式将在那个阶段决定。
显式推荐
这是一个需要显式数据来建模每个用户(客户)与产品(项目)之间关系的协同过滤问题。这种数据的一个好例子是显式评分。
机器学习推荐
我们讨论了在亚马逊中推荐所起的作用。这让我们对推荐是什么,以及推荐系统是什么,从普通人的角度来看有了很好的理解。我们为每种类型的推荐提供了例子。亚马逊的推荐是由机器学习算法生成的,这是这里的共同点。
话虽如此,这个主题的目的是明确地解释机器学习在推荐背景下的作用。像往常一样,这个主题分为两部分:一个简要概述部分,给出对主题的总结性看法,以及一个详细概述部分。以下是简要概述。
协同过滤算法
推荐是机器学习空间中的协同过滤问题。两个基本原理定义了协同过滤算法的工作方式:
-
过滤
-
协同
过滤部分与推荐行为相关。算法通过从许多用户那里获取偏好信息来促成推荐的发生。一个简单的例子可以很好地说明协同过滤算法是如何工作的。想象一下,我们的算法正在基于三个用户(国家)U1、U2 和 U3 的一个池子中工作。尽管这个案例可能很微不足道,但它将解释协同过滤算法是如何工作的。比如说,在最近的一次全球航空展上,寻找新型战斗机的国家被要求对三种一线战斗机进行评分。拉法埃尔是法国战斗机。苏-35是俄罗斯战斗机,F-35是美国战斗机,可以说是世界上最先进的空中优势战斗机。
飞机-国家表如下所示,这是一个基于协同过滤和用户-产品矩阵的推荐算法:

用户-产品矩阵
观察前面的表格,每个国家对某种飞机的评分略有不同。印度对三种战斗机给出了相当好的评分,其中侧卫获得了最高的评分。第二个国家土耳其只对拉法埃尔和 F-35 给出了好的评分,对苏-35 没有给出任何评分。假设没有提供评分的国家得到了负评分 -1。最后,沙特阿拉伯喜欢拉法埃尔和 F-35,而对苏-35 没有发表任何意见。矩阵中有空位。它们留空是有原因的。
假设我们有一个名为 CF 的协同算法。我们希望 CF 能够按照计划在这个矩阵上工作。计划的第一部分是告诉算法找出哪些用户喜欢相同的产品。算法将开始工作并得出以下观察结果:
-
用户 U1(印度)喜欢以下产品:
-
产品 1(拉法埃尔)
-
产品 2(侧卫)
-
产品 3(F-35)
-
用户 U2(土耳其)喜欢以下产品:
-
产品 1(拉法埃尔)
-
产品 3(F-35)
-
用户 U3(沙特阿拉伯)喜欢以下产品:
-
产品 1(拉法埃尔)
算法有更大的权限。它需要更仔细地查看矩阵,并为沙特阿拉伯关于飞机的推荐做出判断。
协同过滤算法为沙特阿拉伯提出了以下推理的推荐:
印度喜欢所有三种飞机(拉法埃尔、侧卫和 F-35),而土耳其喜欢两种(拉法埃尔和 F-35)。喜欢拉法埃尔的国家也喜欢 F-35。注意大写的单词也。基于印度和土耳其有相似的喜好,算法决定沙特阿拉伯会喜欢印度和土耳其喜欢的——在这种情况下,就是 F-35。为了理解最终的推荐,我们将绘制一个类似维恩图的图形:

文氏图
我们刚刚展示了推荐能为我们做什么。到目前为止我们所做的是朝着正确方向迈出的一步,关于实施方面。
我们还没有说明这些数据集中可用的数据类型是明确还是隐式。这些数据集描述了用户和产品之间的交互。我们从先前的讨论中看到了这些交互的证据。我们还就沙特阿拉伯提出了建议。我们预测沙特阿拉伯未来可能购买的武器系统。用户-产品矩阵是我们得出对沙特阿拉伯这一建议的依据。这个矩阵有一个特点——用户-产品交互。这是一个硬性数字和评分,这使得这些数据是明确的。
这是一个需要隐式数据来建模每个用户(客户)与产品(项目)之间关系的协同过滤问题。
是时候进入下一个主题了,这个主题恰好是对明确推荐问题表述的简要概述。
推荐问题表述
如其标题所示,这个主题将提供一个(推荐)问题表述。换句话说,它将基于明确的(用户)反馈构建推荐系统的轮廓。
问题表述主题代表了实施一种推荐系统过程中的一个关键阶段。接下来要讨论的主题将涉及武器销售线索数据和过往武器销售数据。
理解数据集
从上一个主题留下的地方继续,这次重要的讨论提出了两个数据集,分别是武器销售线索数据集和过往销售数据集。
到目前为止,我们已经完成了对我们要覆盖的主题的简要概述,我们决定这些主题与推荐系统的实施相关。
到目前为止,我们已经概述了实施推荐系统所必需的主题。这些主题如下:
-
推荐
-
隐式推荐
-
明确推荐
-
推荐问题表述
-
武器销售线索和过往销售数据
-
推荐与机器学习
详细概述
本主题的详细概述部分是本章最重要的部分。
关于问题表述的推荐
在这个主题的详细版本中,我们将构建一个叙事(或故事),其中以下特征占主导地位:
-
用户
-
产品
-
对销售线索和过往销售的理解
-
在理解销售线索和过往销售的基础上备份数据
在数据方面,我们将汇编与武器系统相关的定制销售线索数据和过往销售数据。在这个阶段,我们将着手正式表述和支撑描述基于用户明确反馈的推荐系统。这种表述被分解为两个任务:
-
定义什么是明确的反馈
-
围绕涉及明确反馈的推荐问题构建叙事(故事)
哪些数据构成明确的反馈?我们立即着手回答这个问题。
定义明确的反馈
明确反馈,就像其隐含对应物一样,取决于用户偏好。我们稍后将要构建的机器学习模型基于此类明确反馈。我们即将描述的数据集包含明确反馈。此类明确反馈数据是用户/客户/客户对其选择的某些武器系统(产品/项目)偏好的汇编。结果是我们正在构建一个推荐系统,该系统将预测用户可能会为喜爱的产品(或不喜爱的产品)留下的评分。确实,评级是反馈的一个很好的例子。我们都熟悉星级评级的模样。我们使用一点 CSS 和 HTML 生成了以下星级评分图形。这与餐厅门户www.yelp.com/上看到的星级评分类似:

餐厅评级
我们实际上不会为即将推出的基于明确反馈的推荐系统生成星级评分图形。然而,这里的关键点是评级在本章中占有核心地位。
构建叙事
武器制造商 X 是一个满足全球各国国防需求的武器制造商。对我们来说,这个武器制造商就是WMX。政府客户 IEX 是典型的 WMX 客户,简单地被称为 IEX。IEX 希望用现代第五代战斗机逐步淘汰其老化的战斗机。IEX 的数据在 WMX 的过去销售数据记录中非常突出。因此,WMX 认为 IEX 不仅仅是一个潜在客户。在这种情况下,有两种类型的参与者明显存在:
-
WMX—武器系统产品供应商
-
像 IEX 这样的客户—这些是购买 X 的武器系统并对它们进行评分的国家
我们现在有一个有趣用例的轮廓。参与者 1 和 2 没有数据就毫无用处。我们计划提供两个数据集:
-
武器销售线索—来自销售线索活动的数据
-
过去武器销售数据—描述哪些客户购买了哪些武器系统(项目或产品)的数据
在进一步深入讨论后续用例之前,我们将进行必要的迂回。我们想要解释武器销售线索中的销售线索部分。那么,线索究竟是什么?过去销售数据的作用是什么?这两个问题将在下一阶段得到解答。
销售线索和过去销售
当然,一个企业需要销售产品或服务来赚钱。一个熟悉的企业策略是生成销售线索。什么是销售线索?线索就像调查人员在刑事调查案件中偶然发现的线索或突破。这个线索是识别可能引导调查人员追踪特定调查线索的有用信息。这样的线索,在熟练、经验丰富的调查人员手中,可能会帮助潜在地破解案件,锁定罪犯,或锁定潜在嫌疑人。
将刑事调查的类比应用到我们的销售用例中,销售线索在销售过程中的早期阶段是一种标识符,因为它代表了识别数据。自然地,线索会产生某种预期,即某个个人或公司将来可能成为潜在客户。销售线索不一定必须锁定潜在客户。然而,一个精心策划的销售线索生成活动可以利用“过往销售数据”帮助业务识别个人或其他企业作为近或长期潜在客户。在过往销售数据中出现的付费客户可能指向未来的潜在客户,这可能是重复付费的客户。因此,企业不是在黑暗中射击,因为它可以使用这些信息来确定要联系哪个客户。
我们的绕行到此结束。进入下一阶段,我们将应用我们最近获得的销售线索和过往销售数据的知识,将其应用于实际的武器销售线索和过往销售数据集。
武器销售线索和过往销售数据
要开始,请从 ModernScalaProjects 文件夹下载以下文件。
-
PastWeaponSalesOrders.csv,从头开始编制,无需引用 -
WeaponSalesLeads.csv,从头开始编制,无需引用
PastWeaponSalesOrders.csv 代表 WMX 的过往武器销售数据,而 WeaponSalesLeads.csv 代表 WMX 武器销售线索数据。这两个数据集都是为了构建推荐引擎而设计的。
假设我们的武器公司 WMX 将其过往销售数据记录存储在 PastWeaponSalesOrders.csv 中。此数据集的记录如下所示:

过往销售数据记录
在我们描述字段中的数据之前,我们需要一个代表该数据集中字段的模式:

数据集中字段的表示
有七个列,其中前两列代表客户数据。以下按字母顺序列出八个客户:
-
澳大利亚—客户 #1
-
塞舌尔—客户 #2
-
斐济—客户 #3
-
土耳其—客户 #4
-
约旦—客户 #5
-
韩国—客户 #6
-
吉布提—客户 #7
-
印度—客户 #8
接下来的两列,ItemId,然后是ItemName,代表一个武器系统。第五列存储每个武器系统单位的单价(以百万美元计)。第六列OrderSize中的每个数据单元格代表某个客户订购的某个武器系统的单位数量。例如,澳大利亚在过去某个时候订购了 25 单位的WeaponsSystem217,单价为每单位 200 万美元。
尽管这两个数据集远非全面,但它们是具有代表性的样本,足以创建我们的推荐系统。
在我们的案例中,我们正在构建一个基于过去销售订单的样本销售线索预测模型。
这里是两个数据集的一些样本记录:

样本数据集
这两个数据集都已准备好。我们想知道如何处理这些数据,以及从这里开始下一步。我们着手整理数据,有一个直接的目标——创建一个模型,以某种方式帮助我们做出预测。当时这个目标并不那么明确。现在我们有了两个数据集,我们希望有一个明确的目标。关键在于武器销售线索数据集。如果我们想建立一个基于购买历史数据的武器销售线索模型,那会怎么样?我们过去的武器销售数据集代表了购买历史。那么,我们就有了更明确的目标——实施武器销售预测评级模型。换句话说,我们希望我们的模型能够做到以下几方面:
-
预测为每位客户推荐什么。有 8 个客户,从澳大利亚到印度不等。我们希望我们的模型只推荐适合特定客户的武器系统。适合客户的正确武器系统基于他们过去的订购情况。
-
在向每位客户提供建议时,该模型还在生成一个评级,它认为客户会给予他/她之前未购买的新产品或产品的评级。
让我们重申一下这两个数据集的共同点。第一个很简单——有一个客户,在这个案例中是一个国家。然后,有一个产品(项目),在这个案例中是武器系统。第一个数据集有如下说明:
-
有一个国家订购了一定数量的武器系统,每个这样的系统以百万美元为单位的价格。
-
第二个数据集并没有在其声明的简要说明之外揭示太多,其简要说明是提供潜在客户的列表。制造这些系统的公司决定,其中一些潜在客户不仅仅是潜在客户。
我们在这里试图说明的是,尽管这并不那么明显,但现在很清楚,数据不必揭示太多细节。客户是一个以特定价格购买了一定武器系统的国家。就是这样,但这也足够了。另一方面,武器销售线索数据讲述了一个不同类型的故事,一个未来情景的可能情景,其中一家公司,据公司估计,可能会对某种类型的武器系统表示出兴趣。
我们有关于用户和产品的数据。不是详细的信息,但显然足够。这类数据需要一个只需要提取用户和产品之间关系的算法。它只需要看到两者之间互动的证据。手头的两个数据集似乎都适合协同过滤算法。这就是为什么我们可以在下一节中开始讨论驱动协同过滤算法运作的基本机制。
实现和部署
以下是需要实现推荐系统所需的目标:
-
从
ModernScalaProjects数据文件夹下载过去的武器销售订单和武器销售线索数据集。 -
您可以通过三种方式开发管道:
-
在您的本地 Spark Shell 中逐步进行。
-
推荐:在 IntelliJ 中完善你的代码,并在
build.sbt文件中连接所有必要的依赖项。通过连接 assembly 插件来设置 SBT 以生成胖 JAR。然后,将生成的自包含 SBT 应用程序部署到您的本地 Spark 集群,使用spark-submit。 -
运行应用程序并解释结果。
-
-
在下一节“实现”中,我们将逐步记录实现项目的指令。
实现
实现将在以下子节中记录。所有代码都在 IntelliJ 代码编辑器中开发。第一步是创建一个名为Chapter7的空 Scala 项目。
第 1 步 – 创建 Scala 项目
让我们创建一个名为Chapter7的 Scala 项目,并具有以下工件:
-
RecommendationSystem.scala -
RecommendationWrapper.scala
让我们分解一下项目的结构:
-
.idea:生成的 IntelliJ 配置文件。 -
project:包含build.properties和plugins.sbt。 -
project/assembly.sbt:此文件指定了构建部署的胖 JAR 所需的sbt-assembly插件。 -
src/main/scala:这是一个包含com.packt.modern.chapter7包中 Scala 源文件的文件夹。 -
target:这是编译过程生成的工件存储的地方。生成的 assembly JAR 文件将在这里。 -
build.sbt:这是主要的 SBT 配置文件。在这里指定了 Spark 及其依赖项。
在这一点上,我们将开始在 IntelliJ 代码编辑器中开发代码。我们将从AirlineWrapper Scala 文件开始,并以使用spark-submit将最终应用程序 JAR 部署到 Spark 结束。
第 2 步 – 创建 AirlineWrapper 定义
让我们创建trait定义。这个 trait 将包含SparkSession变量、数据集的模式定义以及构建 dataframe 的方法:
trait RecWrapper { }
接下来,让我们为过去的武器销售订单创建一个模式。
第 3 步 – 创建武器销售订单模式
让我们为过去的销售订单数据集创建一个模式:
val salesOrderSchema: StructType = StructType(Array(
StructField("sCustomerId", IntegerType,false),
StructField("sCustomerName", StringType,false),
StructField("sItemId", IntegerType,true),
StructField("sItemName", StringType,true),
StructField("sItemUnitPrice",DoubleType,true),
StructField("sOrderSize", DoubleType,true),
StructField("sAmountPaid", DoubleType,true)
))
接下来,让我们为武器销售线索创建一个模式。
第 4 步 – 创建武器销售线索模式
这里是武器销售线索数据集的模式定义:
val salesLeadSchema: StructType = StructType(Array(
StructField("sCustomerId", IntegerType,false),
StructField("sCustomerName", StringType,false),
StructField("sItemId", IntegerType,true),
StructField("sItemName", StringType,true)
))
接下来,让我们构建一个武器销售订单 dataframe。
第 5 步 – 构建武器销售订单 dataframe
让我们在SparkSession实例上调用read方法并将其缓存。我们将在RecSystem对象中稍后调用此方法:
def buildSalesOrders(dataSet: String): DataFrame = {
session.read
.format("com.databricks.spark.csv")
.option("header", true).schema(salesOrderSchema).option("nullValue", "")
.option("treatEmptyValuesAsNulls", "true")
.load(dataSet).cache()
}
接下来,让我们构建一个销售线索 dataframe:
def buildSalesLeads(dataSet: String): DataFrame = {
session.read
.format("com.databricks.spark.csv")
.option("header", true).schema(salesLeadSchema).option("nullValue", "")
.option("treatEmptyValuesAsNulls", "true")
.load(dataSet).cache()
}
这完成了trait。总体来看,它看起来是这样的:
trait RecWrapper {
1) Create a lazy SparkSession instance and call it session.
2) Create a schema for the past sales orders dataset
3) Create a schema for sales lead dataset
4) Write a method to create a dataframe that holds past sales order
data. This method takes in sales order dataset and
returns a dataframe
5) Write a method to create a dataframe that holds lead sales data
}
引入以下导入:
import org.apache.spark.mllib.recommendation.{ALS, Rating}
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.{DataFrame, Dataset, SparkSession}
创建一个名为RecSystem的 Scala 对象:
object RecSystem extends App with RecWrapper { }
在继续之前,引入以下导入:
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.DataFrame
在这个对象内部,首先加载过去的销售订单数据。这将是我们训练数据。按照以下方式加载销售订单数据集:
val salesOrdersDf = buildSalesOrders("sales\\PastWeaponSalesOrders.csv")
验证模式。模式看起来是这样的:
salesOrdersDf.printSchema()
root
|-- sCustomerId: integer (nullable = true)
|-- sCustomerName: string (nullable = true)
|-- sItemId: integer (nullable = true)
|-- sItemName: string (nullable = true)
|-- sItemUnitPrice: double (nullable = true)
|-- sOrderSize: double (nullable = true)
|-- sAmountPaid: double (nullable = true)
这里是显示过去武器销售订单数据的 dataframe 的部分视图:

显示过去武器销售订单数据 dataframe 的部分视图
现在,我们已经有了创建评分 dataframe 所需的所有内容:
val ratingsDf: DataFrame = salesOrdersDf.map( salesOrder =>
Rating( salesOrder.getInt(0),
salesOrder.getInt(2),
salesOrder.getDouble(6)
) ).toDF("user", "item", "rating")
在命令行中保存所有内容并编译项目:
C:\Path\To\Your\Project\Chapter7>sbt compile
你很可能会遇到以下错误:
[error] C:\Path\To\Your\Project\Chapter7\src\main\scala\com\packt\modern\chapter7\RecSystem.scala:50:50: Unable to find encoder for type stored in a Dataset. Primitive types (Int, String, etc) and Product types (case classes) are supported by importing spark.implicits._ Support for serializing other types will be added in future releases.
[error] val ratingsDf: DataFrame = salesOrdersDf.map( salesOrder =>
[error] ^
[error] two errors found
[error] (compile:compileIncremental) Compilation failed
为了解决这个问题,将以下语句放置在评分 dataframe 声明的顶部。它应该看起来像这样:
import session.implicits._
val ratingsDf: DataFrame = salesOrdersDf.map( salesOrder => UserRating( salesOrder.getInt(0), salesOrder.getInt(2), salesOrder.getDouble(6) ) ).toDF("user", "item", "rating")
保存并重新编译项目。这次,它编译得很好。接下来,从org.apache.spark.mllib.recommendation包中导入Rating类。这会将我们之前获得的评分 dataframe 转换为它的 RDD 等价物:
val ratings: RDD[Rating] = ratingsDf.rdd.map( row => Rating( row.getInt(0), row.getInt(1), row.getDouble(2) ) )
println("Ratings RDD is: " + ratings.take(10).mkString(" ") )
以下几行代码非常重要。我们将使用 Spark MLlib 中的 ALS 算法来创建和训练一个MatrixFactorizationModel,它需要一个RDD[Rating]对象作为输入。ALS 训练方法可能需要以下训练超参数的组合:
-
numBlocks:在自动配置设置中预设为-1。这个参数旨在并行化计算。 -
custRank:特征的数量,也称为潜在因子。 -
iterations:这个参数代表 ALS 执行迭代的次数。为了得到一个合理的解决方案,这个算法大约需要 20 次或更少的迭代。 -
regParam:正则化参数。 -
implicitPrefs:这个超参数是一个指定器。它允许我们使用以下任何一个:-
显式反馈
-
隐式反馈
-
-
alpha:这是一个与 ALS 算法的隐式反馈变体相关的超参数。它的作用是控制对偏好观察的基线信心。
我们刚刚解释了 ALS 算法的 train 方法所需的每个参数的作用。
让我们开始引入以下导入:
import org.apache.spark.mllib.recommendation.MatrixFactorizationModel
现在,让我们使用 ALS 算法开始训练矩阵分解模型。
让我们根据客户(用户)对某些物品(产品)的评分 RDD(弹性分布式数据集)训练一个矩阵分解模型。我们的基于 ALS 算法的 train 方法将接受以下四个参数:
-
评级。
-
一个排名。
-
迭代次数。
-
一个 Lambda 值或正则化参数:
val ratingsModel: MatrixFactorizationModel = ALS.train(ratings,
6, /* THE RANK */
10, /* Number of iterations */
15.0 /* Lambda, or regularization parameter */
)
接下来,我们加载销售线索文件并将其转换为元组格式:
val weaponSalesLeadDf = buildSalesLeads("sales\\ItemSalesLeads.csv")
在下一节中,我们将显示新的武器销售线索数据框。
第 6 步 – 显示武器销售数据框
首先,我们必须调用show方法:
println("Weapons Sales Lead dataframe is: ")
weaponSalesLeadDf.show
这里是武器销售线索数据框的视图:

武器销售线索数据框的视图
接下来,创建一个结构为(客户,项目)元组的数据框版本:
val customerWeaponsSystemPairDf: DataFrame = weaponSalesLeadDf.map(salesLead => ( salesLead.getInt(0), salesLead.getInt(2) )).toDF("user","item")
在下一节中,让我们显示我们刚刚创建的数据框。
第 7 步 – 显示客户-武器-系统数据框
让我们使用以下方式调用show方法:
println("The Customer-Weapons System dataframe as tuple pairs looks like: ")
customerWeaponsSystemPairDf.show
这里是新的客户-武器-系统数据框作为元组对的截图:

新的客户-武器-系统数据框作为元组对
接下来,我们将前面的数据框转换为 RDD:
val customerWeaponsSystemPairRDD: RDD[(Int, Int)] = customerWeaponsSystemDf.rdd.map(row =>
(row.getInt(0),
row.getInt(1))
)
/*
Notes: As far as the algorithm is concerned, customer corresponds to "user" and "product" or item corresponds to a "weapons system"
*/
我们之前创建了一个MatrixFactorization模型,我们使用武器系统销售订单数据集对其进行训练。我们现在可以预测每个客户国家未来可能会如何评价一个武器系统。在下一节中,我们将生成预测。
第 8 步 – 生成预测
这里是我们将如何生成预测。我们的模型中的predict方法就是为了这个目的设计的。它将生成一个名为weaponRecs的预测 RDD。它代表了客户国家(在过去的销售订单数据中列出)之前未评价的武器系统的评级:
val weaponRecs: RDD[Rating] = ratingsModel.predict(customerWeaponsSystemPairRDD).distinct()
接下来,我们将显示最终的预测。
第 9 步 – 显示预测
这里是如何以表格格式显示预测结果的方法:
println("Future ratings are: " + weaponRecs.foreach(rating => { println( "Customer: " + rating.user + " Product: " + rating.product + " Rating: " + rating.rating ) } ) )
下面的表格显示了未来每个国家预期将如何评价某个系统,即他们之前未评价的武器系统:

各国对系统的评级
我们的推荐系统证明了其生成未来预测的能力。
到目前为止,我们还没有说明如何编译和部署所有前面的代码。我们将在下一节中探讨这个问题。
编译和部署
编译和部署的步骤如下:
-
编译
-
构建推荐系统应用程序的 assembly JAR 文件
-
使用
spark-submit命令部署推荐系统应用程序
我们首先编译项目。
编译项目
在Chapter7项目的根目录中调用sbt compile项目。你应该会得到以下输出:

编译项目的输出
除了加载build.sbt,编译任务还从assembly.sbt加载设置,这是我们尚未讨论过的文件,但我们将很快创建它。
什么是assembly.sbt文件?
我们尚未讨论assembly.sbt文件。我们的基于 Scala 的 Spark 应用程序是一个 Spark 作业,它将被提交到(本地)Spark 集群作为一个 JAR 文件。除了 Spark 库之外,这个文件还需要包含我们的推荐系统作业成功完成所需的其他依赖项。所谓的胖 JAR(fat JAR)是指将所有依赖项捆绑在一个 JAR 文件中。为了构建这样的胖 JAR,我们需要一个sbt-assembly插件。这解释了为什么需要创建一个新的assembly.sbt和组装插件。
创建assembly.sbt
在你的 IntelliJ 项目视图中创建一个新的assembly.sbt,并将其保存在你的project文件夹下,如下所示:

创建assembly.sbt
assembly.sbt将包含什么内容?我们将在下一节中探讨。
assembly.sbt的内容
将以下内容粘贴到新创建的assembly.sbt文件中(位于项目文件夹下)。输出应如下所示:

将assembly.sbt内容放置后的输出
sbt-assembly插件,版本 0.14.7,为我们提供了运行sbt-assembly任务的能力。有了这个,我们就更接近于构建一个胖 JAR 或 Uber JAR 了。这一操作将在下一步中记录。
运行sbt assembly任务
发出sbt assembly命令,如下所示:

运行sbt assembly命令
这次,组装任务在assembly.sbt中加载了assembly-plugin。然而,由于一个常见的重复错误,进一步的组装操作停止了。这个错误是由于几个重复项引起的,需要移除多个依赖文件的副本,组装任务才能成功完成。为了解决这个问题,需要升级build.sbt。
升级build.sbt文件
需要在以下代码行中添加,如下所示:

升级build.sbt文件的代码行
要测试更改的效果,保存此文件并转到命令行重新执行sbt assembly任务。
重新运行组装命令
按如下方式运行组装任务:

重新运行组装任务
这次,assembly.sbt文件中的设置被加载。任务成功完成。为了验证,深入到target文件夹。如果一切顺利,你应该看到一个胖 JAR,如下所示:

作为 JAR 文件的输出
我们在target文件夹下的 JAR 文件是需要部署到 Spark 的推荐系统应用的 JAR 文件。这将在下一步中说明。
部署推荐应用
spark-submit命令是我们将应用程序部署到 Spark 的方式。以下是spark-submit命令的两种格式。第一个是一个较长的格式,它设置的参数比第二个多:
spark-submit --class "com.packt.modern.chapter7.RecSystem" --master local[2] --deploy-mode client --driver-memory 16g -num-executors 2 --executor-memory 2g --executor-cores 2 <path-to-jar>
借助于前面的格式,让我们提交我们的 Spark 作业,向其提供各种参数:

Spark 的参数
不同的参数解释如下:

Spark 作业参数的表格说明
摘要
我们学习了如何构建显式反馈类型的推荐系统。我们使用 Spark MLlib 协同过滤算法实现了一个预测模型,该模型从历史销售数据中学习,并为顾客提供基于评分的产品推荐。正如我们所知,该算法根据未知顾客-产品交互定制了其产品预测。
我们利用 Spark 对推荐的支持构建了一个预测模型,该模型根据销售线索和过去武器销售数据为未知顾客-产品交互生成推荐。我们利用 Spark 的交替最小二乘算法实现我们的协同过滤推荐系统。


浙公网安备 33010602011771号