Scala-机器学习快速启动指南-全-
Scala 机器学习快速启动指南(全)
原文:
annas-archive.org/md5/7cee668dc80e7e6b8a656779b72e2561译者:飞龙
前言
机器学习不仅对学术界产生了巨大影响,也对工业界产生了巨大影响,因为它将数据转化为可操作的智能。Scala 不仅是一种面向对象和函数式编程语言,还可以利用Java 虚拟机(JVM)的优势。Scala 提供了代码复杂度优化,并提供了简洁的表示法,这可能是它在过去几年中持续增长的原因,尤其是在数据科学和分析领域。
本书面向有志于成为数据科学家、数据工程师和深度学习爱好者的初学者,他们希望在学习机器学习最佳实践方面有一个良好的起点。即使你对机器学习概念不太熟悉,但仍然想通过 Scala 深入实践监督学习、无监督学习和推荐系统来扩展你的知识,你将能够轻松掌握本书的内容!
在各章节中,你将熟悉 Scala 中流行的机器学习库,学习如何使用线性方法和基于树的集成技术进行回归和分类分析,以及查看聚类分析、降维和推荐系统,最后深入到深度学习。
阅读本书后,你将在解决更复杂的机器学习任务方面有一个良好的起点。本书并非要求从头到尾阅读。你可以翻到看起来像是你想要完成的章节,或者激发你兴趣的章节。
欢迎提出改进建议。祝您阅读愉快!
本书面向对象
对于想要在 Scala 中学习如何训练机器学习模型,而又不想花费太多时间和精力的机器学习开发者来说,这本书将会非常有用。你只需要一些 Scala 编程的基本知识和一些统计学和线性代数的基础知识,就可以开始阅读这本书。
本书涵盖内容
第一章,《使用 Scala 的机器学习入门》,首先解释了机器学习的一些基本概念和不同的学习任务。然后讨论了基于 Scala 的机器学习库,接着是配置编程环境。最后简要介绍了 Apache Spark,并在最后通过一个逐步示例进行演示。
第二章,《Scala 回归分析》,通过示例介绍了监督学习任务回归分析,随后是回归度量。然后解释了一些回归分析算法,包括线性回归和广义线性回归。最后,它展示了使用 Scala 中的 Spark ML 逐步解决回归分析任务的方法。
第三章,Scala 用于学习分类,简要解释了另一个称为分类的监督学习任务,并举例说明,随后解释了如何解释性能评估指标。然后它涵盖了广泛使用的分类算法,如逻辑回归、朴素贝叶斯和支持向量机(SVMs)。最后,它通过使用 Spark ML 在 Scala 中逐步解决一个分类问题的示例来演示。
第四章,Scala 用于基于树的集成技术,涵盖了非常强大且广泛使用的基于树的途径,包括决策树、梯度提升树和随机森林算法,用于分类和回归分析。然后它回顾了第二章,Scala 用于回归分析,和第三章,Scala 用于学习分类的示例,在解决这些问题时使用这些基于树的算法。
第五章,Scala 用于降维和聚类,简要讨论了不同的聚类分析算法,随后通过一个解决聚类问题的逐步示例。最后,它讨论了高维数据中的维度诅咒,并在使用主成分分析(PCA)解决该问题的示例之前进行说明。
第六章,Scala 用于推荐系统,简要介绍了基于相似度、基于内容和协同过滤的方法来开发推荐系统。最后,它通过一个使用 Spark ML 在 Scala 中的示例来演示一个书籍推荐系统。
第七章,使用 Scala 的深度学习简介,简要介绍了深度学习、人工神经网络和神经网络架构。然后讨论了一些可用的深度学习框架。最后,它通过一个使用长短期记忆(LSTM)网络的逐步示例来演示如何解决癌症类型预测问题。
为了充分利用这本书
所有示例都已使用一些开源库在 Scala 中实现,包括 Apache Spark MLlib/ML 和 Deeplearning4j。然而,为了充分利用这一点,你应该拥有一台功能强大的计算机和软件栈。
Linux 发行版更受欢迎(例如,Debian、Ubuntu 或 CentOS)。例如,对于 Ubuntu,建议在 VMware Workstation Player 12 或 VirtualBox 上至少安装 64 位的 14.04(LTS)完整版。你同样可以在 Windows(7/8/10)或 macOS X(10.4.7+)上运行 Spark 作业。
推荐使用具有 Core i5 处理器的计算机,足够的存储空间(例如,运行 Spark 作业时,您至少需要 50 GB 的空闲磁盘存储空间用于独立集群和 SQL 仓库),以及至少 16 GB 的 RAM。如果想要在 GPU 上执行神经网络训练(仅限于最后一章),则需要安装带有 CUDA 和 CuDNN 配置的 NVIDIA GPU 驱动程序。
为了执行本书中的源代码,需要以下 API 和工具:
-
Java/JDK,版本 1.8
-
Scala,版本 2.11.8
-
Spark,版本 2.2.0 或更高
-
Spark csv_2.11,版本 1.3.0
-
ND4j 后端版本 nd4j-cuda-9.0-platform 用于 GPU;否则,nd4j-native
-
ND4j,版本 1.0.0-alpha
-
DL4j,版本 1.0.0-alpha
-
Datavec,版本 1.0.0-alpha
-
Arbiter,版本 1.0.0-alpha
-
Eclipse Mars 或 Luna(最新版本)或 IntelliJ IDEA
-
Maven Eclipse 插件(2.9 或更高)
-
用于 Eclipse 的 Maven 编译插件(2.3.2 或更高版本)
-
Maven Eclipse 插件(2.4.1 或更高)
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip
-
适用于 Mac 的 Zipeg/iZip/UnRarX
-
适用于 Linux 的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他代码包,这些代码包来自我们丰富的书籍和视频目录,可在以下网址找到:github.com/PacktPublishing/。查看它们吧!
代码实战
访问以下链接查看代码运行的视频:
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“它给了我一个马修斯相关系数为0.3888239300421191。”
代码块设置如下:
rawTrafficDF.select("Hour (Coded)", "Immobilized bus", "Broken Truck",
"Vehicle excess", "Fire", "Slowness in traffic (%)").show(5)
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
// Create a decision tree estimator
val dt = new DecisionTreeClassifier()
.setImpurity("gini")
.setMaxBins(10)
.setMaxDepth(30)
.setLabelCol("label")
.setFeaturesCol("features")
任何命令行输入或输出都应如下编写:
+-----+-----+
|churn|count|
+-----+-----+
|False| 2278|
| True| 388 |
+-----+-----+
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中会这样显示。例如:“点击 Next 按钮将您带到下一屏幕。”
警告或重要注意事项会像这样显示。
小贴士和技巧会像这样显示。
联系我们
欢迎读者们的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至customercare@packtpub.com。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问packt.com。
第一章:Scala 机器学习简介
在本章中,我们将解释一些将在所有后续章节中使用的机器学习(ML)的基本概念。我们将从对 ML 的简要介绍开始,包括基本学习工作流程、ML 的经验法则和不同的学习任务。然后我们将逐步介绍最重要的机器学习任务。
此外,我们将讨论如何开始使用 Scala 和基于 Scala 的机器学习库,以便为下一章快速入门。最后,我们将通过解决一个实际问题来开始使用 Scala 和 Spark ML 进行机器学习。本章将简要介绍以下主题:
-
机器学习概述
-
机器学习任务
-
Scala 简介
-
Scala 机器学习库
-
使用 Spark ML 开始机器学习
技术要求
您需要具备 Scala 和 Java 的基本知识。由于 Scala 也是一种基于 JVM 的语言,请确保您的机器上已安装并配置了 Java JRE 和 JDK。更具体地说,您需要安装 Scala 2.11.x 和 Java 1.8.x 版本。此外,您需要一个带有必要插件的 IDE,例如 Eclipse、IntelliJ IDEA 或 Scala IDE。但是,如果您使用 IntelliJ IDEA,Scala 已经集成。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter01
查看以下视频,了解代码的实际应用:
机器学习概述
机器学习方法基于一系列统计和数学算法,以执行分类、回归分析、概念学习、预测建模、聚类和有用模式的挖掘等任务。使用机器学习,我们旨在自动改进整个学习过程,这样我们可能不需要完整的人类交互,或者我们至少可以尽可能减少这种交互的水*。
学习算法的工作原理
托马斯·M·米切尔从计算机科学的角度解释了学习真正意味着什么:
“如果一个计算机程序在任务 T 中,根据性能度量 P,从经验 E 中学习,那么它的性能会随着经验 E 的提高而提高。”
根据这个定义,我们可以得出结论,计算机程序或机器可以执行以下操作:
-
从数据和历史中学习
-
随经验改进
-
逐步提升一个可以用来预测问题结果的模型
由于前述要点是预测分析的核心,我们使用的几乎所有机器学习算法都可以被视为一个优化问题。这涉及到寻找最小化目标函数的参数,例如,两个术语(如成本函数和正则化)的加权总和。通常,目标函数有两个组成部分:
-
正则化器,它控制着模型复杂性
-
损失,它衡量模型在训练数据上的误差
另一方面,正则化参数定义了最小化训练误差和模型复杂度之间的权衡,旨在避免过拟合问题。现在,如果这两个组件都是凸的,那么它们的和也是凸的。因此,在使用机器学习算法时,目标是获得一个函数的最佳超参数,该函数在做出预测时返回最小误差。因此,通过使用凸优化技术,我们可以最小化函数,直到它收敛到最小误差。
由于问题具有凸性,通常更容易分析算法的渐*行为,这显示了随着模型观察越来越多的训练数据,其收敛速度有多快。机器学习的任务是要训练一个模型,使其能够从给定的输入数据中识别复杂的模式,并且能够以自动化的方式做出决策。
因此,推理就是测试模型对新(即未观察到的)数据,并评估模型本身的性能。然而,在整个过程中,以及为了使预测模型成功,数据在所有机器学习任务中都是第一位的公民。实际上,我们提供给机器学习系统的数据必须由数学对象组成,例如向量,这样它们才能消费这样的数据。例如,在以下图中,原始图像被嵌入到称为特征向量的数值中,在输入到学习算法之前:

根据可用的数据和特征类型,你的预测模型的表现可能会发生剧烈波动。因此,在推理发生之前选择正确的特征是其中最重要的步骤之一。这被称为特征工程,其中使用关于数据的领域知识来创建仅选择性的或有用的特征,以帮助准备用于机器学习算法的特征向量。
例如,除非我们已经有在多个酒店住宿的个人经验,否则比较酒店相当困难。然而,借助已经从数千条评论和特征中训练出来的机器学习模型(例如,酒店有多少颗星,房间大小,位置,客房服务等等),现在这变得相当可行了。我们将在章节中看到几个例子。然而,在开发这样的机器学习模型之前,了解一些机器学习概念也很重要。
通用机器学习经验法则
通用机器学习经验法则是,数据越多,预测模型越好。然而,拥有更多的特征往往会导致混乱,以至于性能急剧下降,尤其是在数据集是高维的情况下。整个学习过程需要可以分成三种类型(或已经以这种形式提供)的输入数据集:
-
训练集是从历史数据或实时数据中获取的知识库,用于拟合机器学习算法的参数。在训练阶段,机器学习模型利用训练集来找到网络的最佳权重,并通过最小化训练误差来实现目标函数。在这里,使用反向传播规则或优化算法来训练模型,但在学习过程开始之前,所有超参数都需要设置好。
-
验证集是一组用于调整机器学习模型参数的示例。它确保模型训练良好,并泛化以避免过拟合。一些机器学习从业者也将其称为开发集或 dev 集。
-
测试集用于评估训练模型在未见数据上的性能。这一步骤也被称为模型推理。在测试集上评估最终模型(即当我们对模型性能完全满意时),我们不需要进一步调整模型,但训练好的模型可以部署到生产就绪环境中。
一种常见的做法是将输入数据(在必要的预处理和特征工程之后)分为 60%用于训练,10%用于验证,20%用于测试,但这实际上取决于具体用例。有时,我们还需要根据数据集的可用性和质量对数据进行上采样或下采样。
在下一节中,我们将讨论不同类型的训练集上的学习规则可能会有所不同。然而,在那之前,让我们快速看一下机器学习中的一些常见现象。
机器学习模型中的通用问题
当我们使用这些输入数据进行训练、验证和测试时,通常学习算法无法 100%准确地学习,这涉及到训练、验证和测试误差(或损失)。在机器学习模型中,可能会遇到两种类型的误差:
-
不可减少误差
-
可减少误差
即使是最稳健和复杂的模型也无法减少不可减少误差。然而,可减少误差,它有两个组成部分,称为偏差和方差,是可以减少的。 因此,为了理解模型(即预测误差),我们只需要关注偏差和方差:
-
偏差意味着预测值与实际值之间的距离。通常,如果*均预测值与实际值(标签)非常不同,那么偏差就更高。
-
一个机器学习模型会因为无法建模输入和输出变量之间的关系(无法很好地捕捉数据的复杂性)而具有很高的偏差,并变得非常简单。因此,一个过于简单的模型具有高方差,会导致数据欠拟合。
以下图表提供了一些高级见解,同时也展示了恰到好处的拟合模型应该是什么样子:

方差表示预测值和实际值之间的可变性(它们有多分散)。
识别高偏差和高方差:如果模型具有高训练误差以及验证误差或测试误差与训练误差相同,则模型具有高偏差。另一方面,如果模型具有低训练误差但具有高验证误差或高测试误差,则模型具有高方差。
机器学习模型通常在训练集上表现良好,但在测试集上表现不佳(因为误差率高)。最终,这会导致欠拟合模型。我们可以再次总结过拟合和欠拟合:
-
欠拟合:如果你的训练误差和验证误差都相对相等且非常高,那么你的模型很可能是欠拟合了训练数据。
-
过拟合:如果你的训练误差低而验证误差高,那么你的模型很可能是过拟合了训练数据。恰到好处的模型学习得很好,并且在未见过的数据上表现也更好。
偏差-方差权衡:高偏差和高方差问题通常被称为偏差-方差权衡,因为一个模型不能同时过于复杂或过于简单。理想情况下,我们应该努力寻找具有低偏差和低方差的最佳模型。
现在我们已经了解了机器学习算法的基本工作原理。然而,基于问题类型和解决问题的方法,机器学习任务可能会有所不同,例如,监督学习、无监督学习和强化学习。我们将在下一节中更详细地讨论这些学习任务。
机器学习任务
尽管每个机器学习问题或多或少都是一个优化问题,但解决它们的方式可能会有所不同。实际上,学习任务可以分为三种类型:监督学习、无监督学习和强化学习。
监督学习
监督学习是最简单且最著名的自动学习任务。它基于一系列预定义的示例,其中每个输入应属于哪个类别已经已知,如下面的图所示:

上述图显示了监督学习的典型工作流程。一个演员(例如,数据科学家或数据工程师)执行提取、转换、加载(ETL)和必要的特征工程(包括特征提取、选择等),以获取具有特征和标签的适当数据,以便它们可以被输入到模型中。然后他会将数据分为训练集、开发集和测试集。训练集用于训练机器学习模型,验证集用于验证训练以防止过拟合和正则化,然后演员会在测试集(即未见过的数据)上评估模型的表现。
然而,如果性能不满意,他可以通过额外的调整来根据超参数优化获得最佳模型。最后,他将在一个生产就绪的环境中部署最佳模型。以下图表简要总结了这些步骤:

在整个生命周期中,可能会有许多参与者(例如,数据工程师、数据科学家或机器学习工程师)独立或协作地执行每个步骤。监督学习环境包括分类和回归任务;分类用于预测数据点属于哪个类别(离散值)。它也用于预测类属性的标签。另一方面,回归用于预测连续值并对类属性进行数值预测。
在监督学习的情况下,对输入数据集的学习过程被随机分为三个集合,例如,60%用于训练集,10%用于验证集,剩余的 30%用于测试集。
无监督学习
如果没有给出标签,你将如何总结和分组一个数据集?你可能试图通过寻找数据集的潜在结构并测量统计属性,如频率分布、均值、标准差等来回答这个问题。如果问题是“你将如何有效地以压缩格式表示数据?”你可能会回答说你会使用一些软件来进行压缩,尽管你可能不知道该软件是如何做到这一点的。以下图表显示了无监督学习任务的典型工作流程:

这些正是无监督学习的两个主要目标,它基本上是一个数据驱动的过程。我们称这种学习为“无监督”学习,因为您将不得不处理未标记的数据。以下引言来自 Yann LeCun,AI 研究总监(来源:预测学习,NIPS 2016,Yann LeCun,Facebook Research):
“人类和动物的大多数学习都是无监督学习。如果智能是一块蛋糕,无监督学习就是蛋糕本身,监督学习就是蛋糕上的糖霜,强化学习就是蛋糕上的樱桃。我们知道如何制作糖霜和樱桃,但我们不知道如何制作蛋糕。在我们甚至考虑达到真正的 AI 之前,我们需要解决无监督学习问题。”
两个最广泛使用的无监督学习任务包括以下内容:
-
聚类:根据相似性(或统计属性)对数据点进行分组。例如,像 Airbnb 这样的公司经常将它的公寓和房屋分组到社区中,以便客户可以更容易地浏览列表。
-
降维:尽可能多地保留结构和统计属性地压缩数据。例如,通常需要减少数据集的维度以进行建模和可视化。
-
异常检测:在多个应用中很有用,例如在信用卡欺诈检测中识别,在工业工程过程中识别有缺陷的硬件,以及在大型数据集中识别异常值。
-
关联规则挖掘:常用于市场篮子分析,例如询问哪些商品经常一起购买。
强化学习
强化学习是一种人工智能方法,它侧重于通过系统与环境交互来学习。在强化学习中,系统的参数根据从环境中获得的反馈进行调整,反过来,环境又对系统的决策提供反馈。以下图表显示一个人在做出决策以到达目的地。让我们以从家到工作的路线为例:

在这种情况下,你每天走相同的路线去上班。然而,有一天你突然好奇,决定尝试一条不同的路线,以寻找最短路径。同样,根据你的经验和不同路线所花费的时间,你会决定是否应该更频繁地选择特定的路线。我们可以再举一个系统模拟棋手的例子。为了提高其性能,系统利用其先前移动的结果;这样的系统被称为具有强化学习的系统。
到目前为止,我们已经学习了机器学习的基本工作原理和不同的学习任务。然而,对每个学习任务进行总结并给出一些示例用例是必要的,我们将在下一小节中看到这一点。
总结学习类型及其应用
我们已经看到了机器学习算法的基本工作原理。然后我们看到了基本机器学习任务是什么以及它们如何构建特定领域的问题。然而,每个学习任务都可以使用不同的算法来解决。以下图表提供了一个概览:

学习类型和相关问题
以下图表总结了之前提到的机器学习任务和一些应用:

来自不同应用领域的机器学习任务和一些用例
然而,前面的图表只列出了几个使用不同机器学习任务的用例和应用。在实践中,机器学习被用于无数的用例和应用。我们将尝试在本书中涵盖其中的一些。
Scala 概述
Scala 是一种可扩展的、函数式和面向对象的编程语言,与 Java 最为紧密相关。然而,Scala 被设计得更加简洁,并具有函数式编程语言的特征。例如,用 Scala 编写的 Apache Spark 是一个快速且通用的用于大规模数据处理的引擎。
Scala 的成功归因于许多因素:它有许多工具能够实现简洁的表达,它非常简洁,因为你需要输入更少的代码,因此需要阅读的也较少,并且它还提供了非常好的性能。这就是为什么 Spark 对 Scala 的支持更多,与 R、Python 和 Java 相比,有更多的 API 是用 Scala 编写的。Scala 的符号运算符易于阅读,与 Java 相比,大多数 Scala 代码相对简洁且易于阅读;Java 则过于冗长。模式匹配和高级函数等函数式编程概念也存在于 Scala 中。
开始使用 Scala 的最佳方式是使用 Scala 通过Scala 构建工具(SBT)或通过集成开发环境(IDE)使用 Scala。无论哪种方式,第一步都是下载、安装和配置 Scala。然而,由于 Scala 运行在Java 虚拟机(JVM)上,因此需要在您的机器上安装和配置 Java。因此,我不会介绍如何进行这一步骤。相反,我将提供一些有用的链接(en.wikipedia.org/wiki/Integrated_development_environment)。
请遵循如何在www.scala-lang.org/download/上设置 Java 和 IDE(例如,IntelliJ IDEA)或构建工具(例如,SBT)的说明。如果您使用的是 Windows(例如,Windows 10)或 Linux(例如,Ubuntu),请访问www.journaldev.com/7456/download-install-scala-linux-unix-windows。最后,这里有一些 macOS 的说明:sourabhbajaj.com/mac-setup/Scala/README.html。
Java 程序员通常在需要为代码添加一些函数式编程风格时更喜欢 Scala,因为 Scala 运行在 JVM 上。在编辑器方面,有各种其他选择。以下是一些可供选择的选择:
-
Scala IDE
-
Eclipse 的 Scala 插件
-
IntelliJ IDEA
-
Emacs
-
Vim
Eclipse 使用众多 beta 插件和本地、远程以及高级调试设施,具有语义高亮和代码补全功能,因此在 Scala 方面具有多个优势。
Scala 中的 ML 库
虽然与 Java 和 Python 相比,Scala 是一种相对较新的编程语言,但当我们已经有 Python 和 R 时,为什么还需要考虑学习它的问题将会出现。嗯,Python 和 R 是两种领先的快速原型设计和数据分析编程语言,包括构建、探索和操作强大的模型。
但 Scala 也正在成为功能产品开发的关键语言,这些产品非常适合大数据分析。大数据应用通常需要稳定性、灵活性、高速、可扩展性和并发性。所有这些需求都可以通过 Scala 来实现,因为 Scala 不仅是一种通用语言,而且也是数据科学(例如,Spark MLlib/ML)的一个强大选择。我过去几年一直在使用 Scala,我发现越来越多的 Scala ML 库正在开发中。接下来,我们将讨论可用于开发 ML 应用的可用和广泛使用的 Scala 库。
感兴趣的读者可以快速查看这个列表,它列出了 15 个最受欢迎的 Scala ML 和数据科学库:
www.datasciencecentral.com/profiles/blogs/top-15-scala-libraries-for-data-science-in-2018-1
Spark MLlib 和 ML
MLlib 是一个库,它提供了使用 Scala 实现的用户友好的 ML 算法。然后,相同的 API 被公开以提供对 Java、Python 和 R 等其他语言的支持。Spark MLlib 为存储在单台机器上的本地向量和矩阵数据类型以及由一个或多个弹性分布式数据集(RDDs)支持的分布式矩阵提供支持。
RDD 是 Apache Spark 的主要数据抽象,通常称为 Spark Core,它表示一个不可变、分区元素集合,可以在并行操作上操作。其容错性使得 RDD 具有容错性(基于 RDD 血缘图)。即使在 Spark 集群的多个节点上存储数据时,RDD 也可以帮助进行分布式计算。此外,RDD 可以转换为数据集,作为具有元组或其他对象等原始值的分区数据集合。
Spark ML 是一组新的 ML API,它允许用户在数据集之上快速组装和配置实用的机器学习管道,这使得将多个算法组合成一个单一管道变得更加容易。例如,一个 ML 算法(称为 estimator)和一组转换器(例如,一个StringIndexer,一个StandardScalar和一个VectorAssembler)可以连接在一起,作为阶段执行 ML 任务,而无需按顺序运行它们。
感兴趣的读者可以查看 Spark MLlib 和 ML 指南:spark.apache.org/docs/latest/ml-guide.html。
到目前为止,我必须告诉你一些非常有用的信息。由于我们将在接下来的章节中继续使用 Spark MLlib 和 ML API,因此提前解决一些问题将是有益的。如果你是 Windows 用户,那么让我告诉你一个你在使用 Spark 时可能会遇到的一个非常奇怪的问题。问题是 Spark 在 Windows、macOS 和 Linux 上都可以运行。当你在 Windows 上使用 Eclipse 或 IntelliJ IDEA 开发 Spark 应用程序时,你可能会遇到 I/O 异常错误,从而导致你的应用程序可能无法成功编译或可能中断。
Spark 在 Windows 上也需要 Hadoop 的运行环境。不幸的是,Spark 的二进制分发版(例如 v2.4.0)不包含 Windows 原生组件,如 winutils.exe 或 hadoop.dll。然而,如果你不能确保运行环境,那么在 Windows 上运行 Hadoop 是必需的(不是可选的),否则会出现以下 I/O 异常:
03/02/2019 11:11:10 ERROR util.Shell: Failed to locate the winutils binary in the hadoop binary path
java.io.IOException: Could not locate executable null\bin\winutils.exe in the Hadoop binaries.
在 Windows 和像 Eclipse 和 IntelliJ IDEA 这样的 IDE 中处理此问题的有两种方法:
-
从
github.com/steveloughran/winutils/tree/master/hadoop-2.7.1/bin/下载winutls.exe。 -
下载并将其复制到 Spark 分发版中的
bin文件夹内——例如,spark-2.2.0-bin-hadoop2.7/bin/。 -
选择“项目”|“运行配置...”|“环境”|“新建”|创建一个名为
HADOOP_HOME的变量,然后将路径放入“值”字段。以下是一个示例:c:/spark-2.2.0-bin-hadoop2.7/bin/| 确定 | 应用 | 运行。
ScalNet 和 DynaML
ScalNet 是围绕 Deeplearning4J 的包装器,旨在模拟 Keras 类型的 API 以开发深度学习应用程序。如果你已经熟悉神经网络架构并且来自 JVM 背景,那么探索基于 Scala 的 ScalNet 库将是有价值的:
-
GitHub (
github.com/deeplearning4j/deeplearning4j/tree/master/scalnet) -
示例 (
github.com/deeplearning4j/ScalNet/tree/master/src/test/scala/org/deeplearning4j/scalnet/examples)
DynaML 是一个用于研究、教育和行业的 Scala 和 JVM 机器学习工具箱。这个库提供了一种交互式、端到端且企业友好的方式来开发机器学习应用程序。如果你感兴趣,可以查看更多信息:transcendent-ai-labs.github.io/DynaML/。
ScalaNLP、Vegas 和 Breeze
Breeze 是 Scala 的主要科学计算库之一,它提供了一种快速高效的数据操作方法,例如矩阵和向量操作,用于创建、转置、填充数字、执行元素级操作和计算行列式。
Breeze 基于netlib-java库提供基本操作,该库能够实现极快的代数计算。此外,Breeze 提供了一种执行信号处理操作的方法,这对于处理数字信号是必要的。
以下为 GitHub 链接:
-
Breeze (
github.com/scalanlp/breeze/) -
Breeze 示例 (
github.com/scalanlp/breeze-examples) -
Breeze 快速入门 (
github.com/scalanlp/breeze/wiki/Quickstart)
另一方面,ScalaNLP 是一个科学计算、机器学习和自然语言处理套件,它还充当包括 Breeze 和 Epic 在内的几个库的母项目。Vegas 是另一个 Scala 数据可视化库,它允许绘制过滤、转换和聚合等规范。Vegas 比其他数值处理库 Breeze 更具有函数式。
更多信息和 Vegas 和 Breeze 的使用示例,请参阅 GitHub:
-
Vegas (
github.com/vegas-viz/Vegas) -
Breeze (
github.com/scalanlp/breeze)
由于 Breeze 的可视化库由 Breeze 和 JFreeChart 支持,而 Vegas 可以被视为 Scala 和 Spark 的 Matplotlib 的缺失库,因为它提供了通过和交互式笔记本环境(如 Jupyter 和 Zeppelin)渲染图表的多种选项。
参考本书 GitHub 仓库中每个章节的 Zeppelin 笔记本解决方案。
开始学习
在本节中,我们将看到一个真实生活中的分类问题示例。想法是开发一个分类器,给定性别、年龄、时间、疣的数量、类型和面积等值,将预测患者是否需要进行冷冻疗法。
数据集描述
我们将使用来自 UCI 机器学习仓库最*添加的冷冻疗法数据集。数据集可以从archive.ics.uci.edu/ml/datasets/Cryotherapy+Dataset+#下载。
此数据集包含 90 名患者使用冷冻疗法治疗疣的结果信息。如果你不知道,疣是由人乳头瘤病毒感染引起的一种皮肤问题。疣通常是小而粗糙、质地坚硬的生长物,颜色与周围皮肤相似。
对于这个问题有两种可行的治疗方法:
-
水杨酸:一种含有水杨酸的凝胶,用于治疗性创可贴。
-
冷冻疗法:将一种冷冻液体(通常是氮气)喷洒在疣上。这将破坏受影响区域的细胞。冷冻疗法后,通常会出现水泡,最终形成硬痂,大约一周后脱落。
数据集中有 90 个样本或实例,这些样本或实例被建议进行冷冻疗法或无需冷冻疗法出院。数据集有七个属性:
-
sex:患者性别,由1(男性)或0(女性)表示。 -
age:患者年龄。 -
Time:观察和治疗时间(以小时计)。 -
Number_of_Warts:疣的数量。 -
Type:疣的类型。 -
Area:受影响区域的数量。 -
Result_of_Treatment:治疗建议的结果,由1(是)或0(否)表示。它也是目标列。
正如您所理解的,这是一个分类问题,因为我们将不得不预测离散标签。更具体地说,这是一个二元分类问题。由于这是一个只有六个特征的较小数据集,我们可以从一个非常基本的分类算法开始,称为逻辑回归,其中逻辑函数应用于回归以获得它属于任一类的概率。我们将在 第三章 中学习更多关于逻辑回归和其他分类算法的细节,Scala for Learning Classification。为此,我们使用 Scala 中基于 Spark ML 的逻辑回归实现。
配置编程环境
我假设 Java 已经安装到您的机器上,并且已经设置了 JAVA_HOME。此外,我假设您的 IDE 已经安装了 Maven 插件。如果是这样,那么只需创建一个 Maven 项目,并按以下方式添加项目属性:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<jdk.version>1.8</jdk.version>
<spark.version>2.3.0</spark.version>
</properties>
在先前的 properties 标签中,我指定了 Spark 版本(即 2.3.0),但您可以进行调整。然后在 pom.xml 文件中添加以下依赖项:
<dependencies>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-mllib_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-graphx_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-yarn_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-network-shuffle_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming-flume_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>com.databricks</groupId>
<artifactId>spark-csv_2.11</artifactId>
<version>1.3.0</version>
</dependency>
</dependencies>
然后,如果一切顺利,所有 JAR 文件都将作为 Maven 依赖项下载到项目主目录中。好的!然后我们可以开始编写代码。
Apache Spark 入门
由于您来到这里是为了学习如何在 Scala 中解决现实生活中的问题,因此探索可用的 Scala 库将是有价值的。不幸的是,我们除了 Spark MLlib 和 ML 以外没有太多选择,它们可以非常轻松和舒适地用于回归分析。重要的是,它实现了所有回归分析算法的高级接口。我假设 Scala、Java 以及您喜欢的 IDE,如 Eclipse 或 IntelliJ IDEA 已经配置在您的机器上。我们将介绍一些 Spark 的概念,但不会提供太多细节,我们将在接下来的章节中继续学习。
首先,我将介绍 SparkSession,这是从 Spark 2.0 引入的 Spark 应用程序的统一入口点。技术上,SparkSession 是通过一些结构(如 SparkContext、HiveContext 和 SQLContext)与 Spark 的一些功能交互的网关,这些结构都被封装在 SparkSession 中。之前,您可能已经看到了如何创建这样的会话,可能并不知道。嗯,SparkSession 可以像以下这样作为构建者模式创建:
import org.apache.spark.sql.SparkSession
val spark = SparkSession
.builder // the builder itself
.master("local[4]") // number of cores (i.e. 4, use * for all cores)
.config("spark.sql.warehouse.dir", "/temp") // Spark SQL Hive Warehouse location
.appName("SparkSessionExample") // name of the Spark application
.getOrCreate() // get the existing session or create a new one
之前的构建器将尝试获取现有的 SparkSession 或创建一个新的,然后新创建的 SparkSession 将被分配为全局默认。
顺便说一句,当使用 spark-shell 时,你不需要显式创建 SparkSession,因为它已经创建并且可以通过 spark 变量访问。
创建 DataFrame 可能是每个数据分析任务中最重要的任务。Spark 提供了一个 read() 方法,可以用于从各种格式的多种来源读取数据,如 CSV、JSON、Avro 和 JDBC。例如,以下代码片段显示了如何读取 CSV 文件并创建 Spark DataFrame:
val dataDF = spark.read
.option("header", "true") // we read the header to know the column and structure
.option("inferSchema", "true") // we infer the schema preserved in the CSV
.format("com.databricks.spark.csv") // we're using the CSV reader from DataBricks
.load("data/inputData.csv") // Path of the CSV file
.cache // [Optional] cache if necessary
一旦创建了一个 DataFrame,我们就可以通过调用 show() 方法查看一些样本(即行),以及使用 printSchema() 方法打印模式。调用 describe().show() 将显示 DataFrame 的统计信息:
dataDF.show() // show first 10 rows
dataDF.printSchema() // shows the schema (including column name and type)
dataDF.describe().show() // shows descriptive statistics
在许多情况下,我们必须使用 spark.implicits._ 包*,这是最有用的导入之一。它很方便,提供了许多隐式方法,可以将 Scala 对象转换为数据集,反之亦然。一旦我们创建了一个 DataFrame,我们就可以创建一个视图(临时或全局),以便使用 createOrReplaceTempView() 方法或 createGlobalTempView() 方法执行 SQL:
dataDF.createOrReplaceTempView("myTempDataFrame") // create or replace a local temporary view with dataDF
dataDF.createGlobalTempView("myGloDataFrame") // create a global temporary view with dataframe dataDF
现在可以发出一个 SQL 查询来查看表格格式的数据:
spark.sql("SELECT * FROM myTempDataFrame")// will show all the records
要删除这些视图,分别可以调用 spark.catalog.dropTempView("myTempDataFrame") 或 spark.catalog.dropGlobalTempView("myGloDataFrame")。顺便说一句,一旦你调用了 spark.stop() 方法,它将销毁 SparkSession 以及 Spark 应用程序分配的所有资源。感兴趣的读者可以阅读详细的 API 文档,请访问 https://spark.apache.org/ 获取更多信息。
读取训练数据集
有一个名为 Cryotherapy.xlsx 的 Excel 文件,其中包含数据以及数据使用协议文本。因此,我只是复制了数据并将其保存到一个名为 Cryotherapy.csv 的 CSV 文件中。让我们先创建 SparkSession——访问 Spark 的门户:
val spark = SparkSession
.builder
.master("local[*]")
.config("spark.sql.warehouse.dir", "/temp")
.appName("CryotherapyPrediction")
.getOrCreate()
import spark.implicits._
然后让我们读取训练集并看看它的一瞥:
var CryotherapyDF = spark.read.option("header", "true")
.option("inferSchema", "true")
.csv("data/Cryotherapy.csv")
让我们看看之前的 CSV 读取器是否成功正确地读取了数据,包括标题和数据类型:
CryotherapyDF.printSchema()
如以下截图所示,Spark DataFrame 的模式已被正确识别。此外,正如预期的那样,我的机器学习算法的所有特征都是数值的(换句话说,是整数或双精度格式):

可以使用 show() 方法查看数据集的快照。我们可以限制行数;这里,让我们说 5:
CryotherapyDF.show(5)
上一行代码的输出显示了 DataFrame 的前五个样本:

预处理和特征工程
根据 UCI 机器学习仓库中的数据集描述,没有空值。此外,基于 Spark ML 的分类器期望模型化时使用数值。好事是,如方案所示,所有必需的字段都是数值(即整数或浮点值)。此外,Spark ML 算法期望有一个label列,在我们的案例中是Result_of_Treatment。让我们使用 Spark 提供的withColumnRenamed()方法将其重命名为label:
//Spark ML algorithm expect a 'label' column, which is in our case 'Survived". Let's rename it to 'label'
CryotherapyDF = CryotherapyDF.withColumnRenamed("Result_of_Treatment", "label")
CryotherapyDF.printSchema()
所有基于 Spark ML 的分类器都期望包含两个对象的训练数据,称为label(我们已经有)和features。我们已经看到我们有六个特征。然而,这些特征必须被组装成特征向量。这可以通过使用VectorAssembler()方法来完成。它是 Spark ML 库中的一种转换器。但首先我们需要选择除了label列之外的所有列:
val selectedCols = Array("sex", "age", "Time", "Number_of_Warts", "Type", "Area")
然后我们实例化一个VectorAssembler()转换器,并按以下方式转换:
val vectorAssembler = new VectorAssembler()
.setInputCols(selectedCols)
.setOutputCol("features")
val numericDF = vectorAssembler.transform(CryotherapyDF)
.select("label", "features")
numericDF.show()
如预期的那样,前面代码段的最后一行显示了组装好的 DataFrame,其中包含label和features,这是训练 ML 算法所需的:

准备训练数据和训练分类器
接下来,我们将训练集和测试集分开。假设 80%的训练集将用于训练,其余的 20%将用于评估训练好的模型:
val splits = numericDF.randomSplit(Array(0.8, 0.2))
val trainDF = splits(0)
val testDF = splits(1)
通过指定不纯度、最大分箱数和树的深度来实例化一个决策树分类器。此外,我们设置了label和feature列:
val dt = new DecisionTreeClassifier()
.setImpurity("gini")
.setMaxBins(10)
.setMaxDepth(30)
.setLabelCol("label")
.setFeaturesCol("features")
现在数据和分类器都准备好了,我们可以进行训练:
val dtModel = dt.fit(trainDF)
评估模型
由于这是一个二元分类问题,我们需要BinaryClassificationEvaluator()估计器来评估模型在测试集上的性能:
val evaluator = new BinaryClassificationEvaluator()
.setLabelCol("label")
现在训练已完成,我们有一个训练好的决策树模型,我们可以在测试集上评估训练好的模型:
val predictionDF = dtModel.transform(testDF)
最后,我们计算分类准确率:
val accuracy = evaluator.evaluate(predictionDF)
println("Accuracy = " + accuracy)
您应该体验到大约 96%的分类准确率:
Accuracy = 0.9675436785432
最后,我们通过调用stop()方法来停止SparkSession:
spark.stop()
我们已经通过最少的努力实现了大约 96%的准确率。然而,还有其他性能指标,如精确度、召回率和 F1 度量。我们将在接下来的章节中讨论它们。此外,如果您是机器学习的新手,并且没有完全理解这个示例中的所有步骤,请不要担心。我们将在其他章节中通过各种其他示例回顾所有这些步骤。
摘要
在本章中,我们学习了一些机器学习(ML)的基本概念,这些概念用于解决现实生活中的问题。我们从一个对 ML 的简要介绍开始,包括基本的学习工作流程、ML 的经验法则以及不同的学习任务,然后我们逐步涵盖了重要的 ML 任务,如监督学习、无监督学习和强化学习。此外,我们还讨论了基于 Scala 的 ML 库。最后,我们看到了如何通过解决一个简单的分类问题来开始使用 Scala 和 Spark ML 进行机器学习。
现在我们已经了解了基本的 ML 和基于 Scala 的 ML 库,我们可以以更结构化的方式进行学习。在下一章中,我们将学习关于回归分析的技术。然后我们将开发一个预测分析应用程序,使用线性回归和广义线性回归算法来预测交通拥堵。
第二章:Scala 回归分析
在本章中,我们将详细学习回归分析。我们将从回归分析工作流程开始学习,然后是线性回归(LR)和广义线性回归(GLR)算法。然后我们将使用 LR 和 GLR 算法及其在 Scala 中基于 Spark ML 的实现来开发一个预测交通拥堵速度的回归模型。最后,我们将学习使用交叉验证和网格搜索技术进行超参数调整。简而言之,我们将在整个端到端项目中学习以下主题:
-
回归分析概述
-
回归分析算法
-
通过示例学习回归分析
-
线性回归
-
广义线性回归
-
超参数调整和交叉验证
技术要求
确保 Scala 2.11.x 和 Java 1.8.x 已安装并配置在您的机器上。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter02
查看以下视频以查看代码的实际应用:
回归分析概述
在上一章中,我们已经对机器学习(ML)过程有了基本的了解,因为我们已经看到了回归和分类之间的基本区别。回归分析是一组用于估计一组称为因变量的变量与一个或多个自变量之间关系的统计过程。因变量的值取决于自变量的值。
回归分析技术帮助我们理解这种依赖关系,即当任何一个自变量改变时,因变量的值如何变化,而其他自变量保持不变。例如,假设当人们变老时,他们的银行账户中的储蓄会更多。在这里,储蓄的金额(比如说以百万美元为单位)取决于年龄(即年龄,例如以年为单位):
| 年龄(年) | 储蓄(百万美元) |
|---|---|
| 40 | 1.5 |
| 50 | 5.5 |
| 60 | 10.8 |
| 70 | 6.7 |
因此,我们可以在一个二维图中绘制这两个值,其中因变量(储蓄)绘制在 y 轴上,而自变量(年龄)应该绘制在 x 轴上。一旦这些数据点被绘制出来,我们就可以看到相关性。如果理论图表确实代表了随着年龄增长对储蓄的影响,那么我们就能说,一个人越老,他们的银行账户中的储蓄就会越多。
现在的问题是,我们如何判断年龄对某人银行账户中获取更多金钱的程度有多大?为了回答这个问题,可以在图表中所有数据点的中间画一条线。这条线被称为回归线,可以使用回归分析算法精确计算。回归分析算法接受离散或连续(或两者)输入特征,并产生连续值。
分类任务用于预测类属性的标签,而回归任务用于对类属性进行数值预测。
使用这样的回归模型对未见过的和新的观察进行预测,就像创建一个由多个组件协同工作的数据管道一样,我们观察到算法的性能分为两个阶段:学习和推理。在整个过程中,为了使预测模型成功,数据在所有机器学习任务中扮演着第一公民的角色。
学习
在学习阶段的一个重要任务是准备和转换数据,将其转换为特征向量(每个特征的数值向量)。以特征向量格式训练的数据可以输入到学习算法中,以训练模型,该模型可用于推理。通常,根据数据量的大小,运行算法可能需要数小时(甚至数天),以便特征收敛到一个有用的模型,如下面的图所示:

学习和训练预测模型——展示如何从训练数据生成特征向量以训练产生预测模型的算法
推理
在推理阶段,训练好的模型被用于智能地使用模型,例如从未见过的数据中进行预测、提供建议和推导未来规则。通常,与学习阶段相比,推理阶段所需时间更少,有时甚至可以实时进行。因此,推理主要是测试模型对新(即未观察到的)数据的性能,并评估模型本身的性能,如下面的图所示:

从现有模型进行推理以进行预测分析(从未知数据生成特征向量以进行预测)
总结来说,当使用回归分析时,目标是预测一个连续的目标变量。现在我们知道了如何构建一个监督学习任务的基本工作流程,了解一些可用的回归算法将提供更多具体信息,了解如何应用这些回归算法。
回归分析算法
提出了许多算法,可用于回归分析。例如,LR 尝试寻找变量之间的关系和依赖性。它使用线性函数来模型化一个连续的因变量 y(即标签或目标)与一个或多个自变量 x 之间的关系。回归算法的例子包括以下:
-
线性回归(LR)
-
广义线性回归(GLR)
-
生存回归(SR)
-
等调回归(IR)
-
决策树回归器(DTR)
-
随机森林回归(RFR)
-
梯度提升树回归(GBTR)
我们首先用最简单的 LR 算法来解释回归,该算法模型化了一个因变量 y 与相关变量 x 之间的关系,其中 x 涉及到相关变量的线性组合:
![img/11963a25-c48f-40da-a3e7-5f14b4f4ff65.png]
在前面的方程中,字母 β[0] 和 β[1] 分别是 y-轴截距和直线的斜率的两个常数。LR 是关于学习一个模型,这个模型是输入示例(数据点)特征的线性组合。
看看下面的图表,想象一下红线不存在。我们有一些蓝色的点(数据点)。我们能否合理地开发一个机器学习(回归)模型来分离大多数点?现在,如果我们在这两类数据之间画一条直线,那些数据几乎被分开了,不是吗?这样的线(在我们的例子中是红色)被称为决策边界,在回归分析的情况下也称为回归线(以下例子中了解更多):
![img/ed09f3bd-c70c-461d-96d9-ceab36e4465d.png]
如果我们给定一组标记的示例,例如 ![img/4bbeb78b-a1d8-46cf-a394-d26d3489e864.png],其中 N 是数据集中的样本数量,x[i] 是样本 i = 1, 2… N 的 D-维特征向量,而 y[i] 是一个实值 y ∈ R,其中 R 表示所有实数的集合,称为目标变量,每个特征 x[i] 是一个实数。然后结合这些,下一步是构建以下数学模型,f:
![img/000a67b5-7349-4298-8ca3-3c8e63c0d435.png]
在这里,w 是一个 D-维参数化向量,而 b 是一个实数。符号 f[w,b] 表示模型 f 由值 w 和 b 参数化。一旦我们有一个定义良好的模型,现在就可以用它来预测给定 x 的未知 y,即 y ← fw,b。然而,存在一个问题,因为模型由两个不同的值(w,b)参数化,这意味着当应用于相同的样本时,模型往往会产生两个不同的预测,即使它们来自相同的分布。
严格来说,它可以被称作一个优化问题——其目标是在满足参数最优值(即最小值,例如)的条件下找到最优值
,这样参数的最优值将意味着模型倾向于做出更准确的预测。简而言之,在 LR 模型中,我们旨在找到
和
的最优值以最小化以下目标函数:

在前面的方程中,表达式 (f [w,b] (X[i]) - y[i])² 被称为损失函数,它是对于样本i给出错误预测的惩罚(即错误或损失)的度量。这个损失函数的形式是*方误差损失。然而,也可以使用其他损失函数,如下面的方程所示:

方程 1 中的*方误差(SE)被称为L[2]损失,它是回归分析任务的默认损失函数。另一方面,方程(2)中的绝对误差(AE)被称为L[1]损失。
在数据集有许多异常值的情况下,使用L[1]损失比L[2]更推荐,因为L[1]对异常值更鲁棒。
所有基于模型的机器学习算法都与一个损失函数相关联。然后我们尝试通过最小化成本函数来找到最佳模型。在我们的线性回归(LR)案例中,成本函数由*均损失(也称为经验风险)定义,它可以表示为将模型拟合到训练数据(可能包含许多样本)所获得的全部惩罚的*均值。
图 4展示了简单线性回归的一个例子。假设我们的想法是预测储蓄额与年龄的关系。因此,在这种情况下,我们有一个自变量x(即一组一维数据点和在我们的案例中,年龄)和一个因变量y(储蓄额,以百万美元计)。一旦我们有一个训练好的回归模型,我们可以使用这条线来预测新未标记输入示例的目标y[l]的值,即x[l]。然而,在D维特征向量(例如,2D或3D)的情况下,它将是一个*面(对于2D)或超*面(对于>=3D):

图 4:一条回归线将数据点分开以解决年龄与储蓄额的关系:i) 左侧模型基于训练数据分离数据点;ii) 右侧模型对未知观察值进行预测
现在您可以看到为什么要求回归超*面尽可能靠*训练样本是如此重要:如果 图 4(右侧的模型)中的蓝色线远离蓝色点,预测值 y[l] 就不太可能是正确的。最佳拟合线,预期将通过大多数数据点,是回归分析的结果。然而,在实践中,由于回归误差的存在,它并不通过所有数据点。
回归误差是任何数据点(实际)与线(预测)之间的距离。
由于解决回归问题本身就是一个优化问题,我们期望尽可能小的误差范围,因为较小的误差有助于提高预测准确性,同时预测未见过的观测值。尽管 LR 算法在许多情况下并不高效,但最好的一点是 LR 模型通常不会过拟合,这对于更复杂的模型来说是不太可能的。
在上一章中,我们讨论了过拟合(一个现象,即模型在训练期间预测得非常好,但在应用于测试集时却犯了更多错误)和欠拟合(如果您的训练误差低而验证误差高,那么您的模型很可能是过拟合了训练数据)。这两种现象通常是由于偏差和方差引起的。
性能指标
为了衡量回归模型的预测性能,提出了几个基于回归误差的指标,可以概括如下:
-
均方误差 (MSE): 它是预测值和估计值之间差异的度量,即拟合线与数据点之间的接*程度。MSE 越小,拟合线与数据越接*。
-
均方根误差 (RMSE): 它是 MSE 的*方根,但具有与垂直轴上绘制的数量相同的单位。
-
R²: 它是确定系数,用于评估数据与拟合回归线之间的接*程度,范围在 0 到 1 之间。R² 越高,模型与数据的拟合度越好。
-
*均绝对误差 (MAE): 它是连续变量精度的度量,不考虑其方向。MAE 越小,模型与数据的拟合度越好。
现在我们已经了解了回归算法的工作原理以及如何使用几个指标来评估性能,下一个重要的任务是应用这些知识来解决现实生活中的问题。
通过示例学习回归分析
在上一节中,我们讨论了一个简单的现实生活中的问题(即年龄与储蓄)。然而,在实践中,存在许多涉及更多因素和参数(即数据属性)的现实生活问题,在这些问题中也可以应用回归。让我们首先介绍一个现实生活中的问题。想象一下,你住在巴西的圣保罗市,每天由于不可避免的诸如公交车停滞、损坏的卡车、车辆过多、事故受害者、超车、消防车辆、涉及危险货物的意外、电力短缺、火灾和洪水等原因,你宝贵的时间有几小时被浪费了。
现在,为了衡量浪费了多少人工小时,我们可以开发一种自动化的技术,该技术可以预测交通的缓慢程度,这样你就可以避免某些路线,或者至少得到一些粗略的估计,了解你到达城市某个地点需要多长时间。使用机器学习的预测分析应用程序可能是预测这种缓慢程度的首选解决方案。是的,为此我们将使用巴西圣保罗市城市交通行为数据集。在下一节中,我们将使用这个数据集。
数据集描述
该数据集从archive.ics.uci.edu/ml/datasets/Behavior+of+the+urban+traffic+of+the+city+of+Sao+Paulo+in+Brazil下载。它包含 2009 年 12 月 14 日至 2009 年 12 月 18 日之间巴西圣保罗市城市交通行为记录。该数据集具有以下特征:
-
小时:在道路上花费的总小时数
-
停滞的公交车:停滞的公交车数量
-
损坏的卡车:损坏的卡车数量
-
车辆过多:多余的车辆数量
-
事故受害者:道路或道路旁的事故受害者数量
-
碾压:碾压或超车案例的数量
-
消防车辆:消防车和其他车辆的数量
-
货运事故:卡车大量运输的货物数量
-
涉及危险货物的意外:发生事故的运输散装卡车数量
-
电力短缺:受影响地区无电的小时数
-
火灾:火灾事件的数量
-
洪水点:洪水区域的点数量
-
施工或危险标志的表现:显示施工正在进行或存在危险标志的地方数量
-
有轨电车网络故障:有轨电车网络中的故障数量
-
道路上的树木:道路上或道路旁的树木数量,它们造成障碍
-
信号灯关闭:用作信号的机械装置,带有臂、灯光或旗帜的数量
-
间歇性信号灯:在一定时间内用作信号的机械装置,带有臂、灯光或旗帜
-
交通缓慢:由于上述原因,人们因交通堵塞而花费的*均小时数
最后一个特征是目标列,这是我们想要预测的。由于我使用了这个数据集,我想感谢以下出版物:
Ferreira, R. P., Affonso, C., & Sassi, R. J. (2011, November). Combination of Artificial Intelligence Techniques for Prediction the Behavior of Urban Vehicular Traffic in the City of Sao Paulo. In 10th Brazilian Congress on Computational Intelligence (CBIC) - Fortaleza, Brazil. (pp.1-7), 2011.
数据集的探索性分析
首先,我们读取用于探索性数据分析(EDA)的训练集。读者可以参考EDA.scala文件。一旦提取出来,将会有一个名为Behavior of the urban traffic of the city of Sao Paulo in Brazil.csv的 CSV 文件。让我们将文件重命名为UrbanTraffic.csv。此外,Slowness in traffic (%),即最后一列,以一个不寻常的格式表示缓慢的百分比:它使用逗号(,)表示实数,例如4,1而不是4.1。因此,我将该列中所有逗号(,)替换为句号(.)。否则,Spark CSV 读取器将把该列视为String类型:
val filePath= "data/UrbanTraffic.csv"
首先,让我们使用read.csv()方法加载、解析并创建一个 DataFrame,但使用 Databricks CSV 格式(也称为com.databricks.spark.csv),通过将其设置为读取 CSV 文件的标题,这直接应用于创建的 DataFrame 的列名;并且将inferSchema属性设置为true,因为如果你没有明确指定inferSchema配置,浮点值将被视为字符串. 这可能会导致VectorAssembler抛出异常,例如java.lang.IllegalArgumentException: Data type StringType is not supported:
val rawTrafficDF = spark.read
.option("header", "true")
.option("inferSchema", "true")
.option("delimiter", ";")
.format("com.databricks.spark.csv")
.load("data/UrbanTraffic.csv")
.cache
现在让我们打印我们刚刚创建的 DataFrame 的模式,以确保结构得到保留:
rawTrafficDF.printSchema()
如以下截图所示,Spark DataFrame 的模式已被正确识别。此外,正如预期的那样,我的机器学习算法的所有特征都是数值的(换句话说,以整数或双精度格式)。

你可以看到,没有任何一列是分类特征。因此,我们不需要任何数值转换。现在让我们使用count()方法看看数据集中有多少行:
println(rawTrafficDF.count())
这给出了 135 个样本计数。现在让我们使用show()方法查看数据集的快照,但只选择一些列,以便它更有意义,而不是显示所有列。但请随意使用rawTrafficDF.show()来查看所有列:
rawTrafficDF.select("Hour (Coded)", "Immobilized bus", "Broken Truck",
"Vehicle excess", "Fire", "Slowness in traffic (%)").show(5)
由于Slowness in traffic (%)列包含连续值,我们必须处理回归任务。现在我们已经看到了数据集的快照,查看一些其他统计数据可能也很有价值,例如*均索赔或损失、最小和最大损失,使用 Spark SQL 的sql()接口:

然而,在那之前,让我们将最后一列从交通中的速度(%)重命名为label,因为 ML 模型会对此提出异议。即使在回归模型上使用了setLabelCol之后,它仍然在寻找名为label的列。这引入了一个令人讨厌的错误,表示org.apache.spark.sql.AnalysisException: cannot resolve 'label' given input columns:
var newTrafficDF = rawTrafficDF.withColumnRenamed("Slowness in traffic (%)", "label")
由于我们想要执行一些 SQL 查询,我们需要创建一个临时视图,以便在内存中执行操作:
newTrafficDF.createOrReplaceTempView("slDF")
现在让我们以百分比的形式*均速度(与标准小时数的偏差):
spark.sql("SELECT avg(label) as avgSlowness FROM slDF").show()
上一行代码应该在每天的*均情况下在路线和基于其他因素上显示 10%的延迟:
+------------------+
| avgSlowness |
+------------------+
|10.051851851851858|
+------------------+
此外,我们还可以看到城市中的洪水点数量。然而,为了做到这一点,我们可能需要一些额外的努力,将列名更改为单个字符串,因为它是一个包含空格的多字符串,所以 SQL 无法解析它:
newTrafficDF = newTrafficDF.withColumnRenamed("Point of flooding", "NoOfFloodPoint")
spark.sql("SELECT max(NoOfFloodPoint) FROM slDF").show()
这应该显示多达七个可能非常危险的洪水点:
+-------------------+
|max(NoOfFloodPoint)|
+-------------------+
| 7|
+-------------------+
然而,describe()方法将更灵活地提供这些类型的统计信息。让我们为所选列执行此操作:
rawTrafficDF.select("Hour (Coded)", "Immobilized bus", "Broken Truck",
"Point of flooding", "Fire", "Slowness in traffic (%)")
.describe().show()
因此,我们可以看到速度在3.4和23.4之间变化,这相当高。这就是为什么我们需要高效的数据处理步骤,以便保留这种关系。现在让我们专注于数据预处理:

特征工程和数据准备
现在我们已经看到了数据集的一些属性,并且由于没有空值或分类特征,我们不需要任何其他预处理或中间转换。我们只需要在我们获得训练和测试集之前做一些特征工程。
在获取这些集合之前的第一步是准备 Spark 回归模型可消费的训练数据。为此,Spark 分类和回归算法期望有两个组件,称为features和label。幸运的是,我们已经有label列。接下来,features列必须包含除label列之外的所有列的数据,这可以通过使用VectorAssembler()转换器来实现。
由于所有列都是数值型的,我们可以直接从 Spark ML 库中使用VectorAssembler()将给定的列列表转换为一个单一的向量列。因此,让我们收集所需的列列表。正如你可能已经猜到的,我们将不得不排除label列,这可以通过使用标准 Scala 的dropRight()方法来完成:
val colNames = newTrafficDF.columns.dropRight(1)
val assembler = new VectorAssembler()
.setInputCols(colNames)
.setOutputCol("features")
现在我们有了VectorAssembler()估计器,我们现在调用transform()方法,该方法将所选列嵌入到单个向量列中:
val assembleDF = assembler.transform(newTrafficDF).select("features", "label")
assembleDF.show()
如预期的那样,上一代码段中的最后一行显示了包含label和features的组装 DataFrame,这些是训练 ML 算法所需的:

我们现在可以继续生成单独的训练集和测试集。此外,我们可以缓存这两个集合以实现更快的内存访问。我们使用 60%的数据来训练模型,其余的 40%将用于评估模型:
val seed = 12345L
val splits = data.randomSplit(Array(0.60, 0.40), seed)
val (trainingData, validationData) = (splits(0), splits(1))
trainingData.cache // cache in memory for quicker access
validationData.cache // cache in memory for quicker access
在开始训练回归模型之前,我们需要做的一切都准备好了。首先,我们开始训练 LR 模型并评估其性能。
线性回归
在本节中,我们将开发一个预测分析模型,使用 LR 算法预测数据每一行的交通缓慢情况。首先,我们创建一个 LR 估计器,如下所示:
val lr = new LinearRegression()
.setFeaturesCol("features")
.setLabelCol("label")
然后我们调用fit()方法在训练集上执行训练,如下所示:
println("Building ML regression model")
val lrModel = lr.fit(trainingData)
现在我们有了拟合好的模型,这意味着它现在能够进行预测。所以,让我们开始在训练集和验证集上评估模型,并计算 RMSE、MSE、MAE、R *方等:
println("Evaluating the model on the test set and calculating the regression metrics")
// **********************************************************************
val trainPredictionsAndLabels = lrModel.transform(testData).select("label", "prediction")
.map {case Row(label: Double, prediction: Double)
=> (label, prediction)}.rdd
val testRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels)
太棒了!我们已经成功计算了训练集和测试集的原始预测结果。现在我们有了训练集和验证集上的性能指标,让我们观察训练集和验证集的结果:
val results = "\n=====================================================================\n" +
s"TrainingData count: ${trainingData.count}\n" +
s"TestData count: ${testData.count}\n" +
"=====================================================================\n" +
s"TestData MSE = ${testRegressionMetrics.meanSquaredError}\n" +
s"TestData RMSE = ${testRegressionMetrics.rootMeanSquaredError}\n" +
s"TestData R-squared = ${testRegressionMetrics.r2}\n" +
s"TestData MAE = ${testRegressionMetrics.meanAbsoluteError}\n" +
s"TestData explained variance = ${testRegressionMetrics.explainedVariance}\n" +
"=====================================================================\n"
println(results)
前面的代码段应该显示类似的内容。尽管如此,由于随机性,你可能会得到略微不同的输出:
=====================================================================
TrainingData count: 80
TestData count: 55
=====================================================================
TestData MSE = 7.904822843038552
TestData RMSE = 2.8115516788845536
TestData R-squared = 0.3699441827613118
TestData MAE = 2.2173672546414536
TestData explained variance = 20.293395978801147
=====================================================================
然而,现在我们有了测试集上的预测结果,但我们不能直接说它是一个好或最优的回归模型。为了进一步降低 MAE,Spark 还提供了线性回归实现的广义版本,称为 GLR。
广义线性回归(GLR)
在线性回归(LR)中,输出被假定为遵循高斯分布。相比之下,在广义线性模型(GLMs)中,响应变量Y[i]遵循来自参数化概率分布集的某种随机分布,这些概率分布具有某种形式。正如我们在前面的例子中所看到的,跟随和创建 GLR 估计器不会很困难:
val glr = new GeneralizedLinearRegression()
.setFamily("gaussian")//continuous value prediction (or gamma)
.setLink("identity")//continuous value prediction (or inverse)
.setFeaturesCol("features")
.setLabelCol("label")
对于基于 GLR 的预测,根据数据类型支持以下响应和身份链接函数(来源:spark.apache.org/docs/latest/ml-classification-regression.html#generalized-linear-regression):

然后我们调用fit()方法在训练集上执行训练,如下所示:
println("Building ML regression model")
val glrModel = glr.fit(trainingData)
当前通过 Spark 中的GeneralizedLinearRegression接口的实现仅支持最多 4,096 个特征。现在我们有了拟合好的模型(这意味着它现在能够进行预测),让我们开始在训练集和验证集上评估模型,并计算 RMSE、MSE、MAE、R *方等:
// **********************************************************************
println("Evaluating the model on the test set and calculating the regression metrics")
// **********************************************************************
val trainPredictionsAndLabels = glrModel.transform(testData).select("label", "prediction")
.map { case Row(label: Double, prediction: Double)
=> (label, prediction) }.rdd
val testRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels)
太棒了!我们已经成功计算了训练集和测试集的原始预测结果。现在我们有了训练集和测试集上的性能指标,让我们观察训练集和验证集的结果:
val results = "\n=====================================================================\n" +
s"TrainingData count: ${trainingData.count}\n" +
s"TestData count: ${testData.count}\n" +
"=====================================================================\n" +
s"TestData MSE = ${testRegressionMetrics.meanSquaredError}\n" +
s"TestData RMSE = ${testRegressionMetrics.rootMeanSquaredError}\n" +
s"TestData R-squared = ${testRegressionMetrics.r2}\n" +
s"TestData MAE = ${testRegressionMetrics.meanAbsoluteError}\n" +
s"TestData explained variance = ${testRegressionMetrics.explainedVariance}\n" +
"=====================================================================\n"
println(results)
前面的代码段应该显示类似的结果。尽管如此,由于随机性,你可能会遇到略微不同的输出:
=====================================================================
TrainingData count: 63
TestData count: 72
=====================================================================
TestData MSE = 9.799660597570348
TestData RMSE = 3.130440958965741
TestData R-squared = -0.1504361865072692
TestData MAE = 2.5046175463628546
TestData explained variance = 19.241059408685135
=====================================================================
使用 GLR,我们可以看到略差的 MAE 值,同时 RMSE 也更高。如果你看到这两个例子,我们还没有调整超参数,而是简单地让模型训练并评估每个参数的单个值。我们甚至可以使用正则化参数来减少过拟合。然而,ML 管道的性能通常随着超参数调整而提高,这通常是通过网格搜索和交叉验证来完成的。在下一节中,我们将讨论如何通过交叉验证模型获得更好的性能。
超参数调整和交叉验证
在机器学习中,超参数一词指的是那些不能从常规训练过程中直接学习的参数。这些是你可以在机器学习算法上调整的各种旋钮。超参数通常通过用不同参数组合训练模型,并通过测试决定哪些参数效果最好来决定。最终,提供最佳模型组合的参数将是我们最终的超参数。设置超参数可以对训练模型的性能产生重大影响。
另一方面,交叉验证通常与超参数调整一起使用。交叉验证(也称为旋转估计)是一种模型验证技术,用于评估统计分析的质量和结果。交叉验证有助于描述数据集,在训练阶段使用验证集来测试模型。
超参数调整
不幸的是,没有快捷或直接的方法可以根据明确的配方选择正确的超参数组合——当然,经验有所帮助。例如,在训练随机森林、矩阵分解、k-means 或逻辑/LR 算法时,可能需要适当的超参数。以下是一些此类超参数的典型示例:
-
基于树的算法中树的数量、bins 或深度
-
迭代次数
-
正则化值
-
矩阵分解中的潜在因子数量
-
k-means 聚类中的聚类数量等等
从技术上讲,超参数形成一个称为参数网格的n-维空间,其中n是超参数的数量。这个空间中的每一个点代表一种特定的超参数配置,即超参数向量。
如第一章“使用 Scala 的机器学习入门”中所述,过拟合和欠拟合是机器学习中的两种问题现象。因此,有时不需要完全收敛到最佳模型参数集,甚至可能更倾向于这样做,因为几乎最佳拟合的模型往往在新数据或设置上表现更好。换句话说,如果你关心最佳拟合模型,你实际上并不需要最佳参数集。
实际上,我们无法探索这个空间中的每一个点,因此通常使用网格搜索来搜索该空间的一个子集。以下图表展示了某些高级概念:

图 5:机器学习模型的超参数调整
虽然有几种这样的方案,但随机搜索或网格搜索可能是最著名的两种技术:
-
网格搜索:使用这种方法,在字典中定义了你想测试的不同超参数。然后在将它们输入 ML 模型之前构建一个参数网格,以便可以使用不同的组合进行训练。最后,算法会告诉你哪个超参数组合的准确率最高。
-
随机搜索:正如你可以理解的,使用所有可能的超参数组合来训练一个机器学习模型是一个既昂贵又耗时的操作。然而,我们通常没有那么多灵活性,但我们仍然想调整这些参数。在这种情况下,随机搜索可能是一个解决方案。随机搜索通过评估超参数空间中的n个均匀随机点来执行,并选择模型性能最佳的组合。
交叉验证
有两种交叉验证类型,称为穷举交叉验证,包括留出-p 个样本交叉验证和留出一个样本交叉验证,以及非穷举交叉验证,它基于 K 折交叉验证和重复随机子采样交叉验证,例如,5 折或 10 折交叉验证,非常常见。
在大多数情况下,使用 10 折交叉验证而不是在验证集上进行测试。此外,训练集应该尽可能大(因为具有高质量特征的数据对训练模型有利),不仅是为了训练模型,而且因为大约 5%到 10%的训练集可以用于交叉验证。
使用 K 折交叉验证技术,将完整训练数据分成 K 个子集。模型在 K-1 个子集上训练;保留最后一个用于验证。这个过程重复 K 次,这样每次都使用 K 个子集中的一个作为验证集,其他 K-1 个子集用于形成训练集。这样,每个子集(折)至少被用于训练和验证一次。
最后,通过袋装(或提升)方案将获得的不同的机器学习模型结合在一起,用于分类器或通过*均(即回归)。以下图表解释了 10 折交叉验证技术:

图 6:10 折交叉验证技术
Spark ML 中的调整和交叉验证
在 Spark ML 中,在进行交叉验证之前,我们需要有一个paramGrid(即参数网格)。ParamGridBuilder接口用于定义CrossValidator必须搜索的超参数空间,最后,CrossValidator()函数接受我们的流水线、LR 回归器的超参数空间以及交叉验证的折数作为参数。
因此,让我们通过指定最大迭代次数、正则化参数的值、容忍度的值以及弹性网络参数来开始创建paramGrid,如下所示(因为我们观察到对于这个模型 MAE 较低):
// ***********************************************************
println("Preparing K-fold Cross Validation and Grid Search")
// ***********************************************************
val paramGrid = new ParamGridBuilder()
.addGrid(lr.maxIter, Array(10, 20, 30, 50, 100, 500, 1000))
.addGrid(lr.regParam, Array(0.001, 0.01, 0.1))
.addGrid(lr.tol, Array(0.01, 0.1))
.build()
正则化参数通过减少估计回归参数的方差来减少过拟合。现在,为了获得更好的更稳定的性能,我们可以进行 10 折交叉验证。由于我们的任务是预测连续值,我们需要定义RegressionEvaluator,即回归评估器,它期望两个输入列——prediction和label——并根据 MSE、RMSE、R-squared 和 MAE 评估训练:
println("Preparing 10-fold Cross Validation")
val numFolds = 10 //10-fold cross-validation
val cv = new CrossValidator()
.setEstimator(lr)
.setEvaluator(new RegressionEvaluator())
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
太棒了,我们已经创建了交叉验证估计器。现在是时候训练 LR 模型了:
println("Training model with the Linear Regression algorithm")
val cvModel = cv.fit(trainingData)
顺便说一句,Spark 提供了一个使用save()方法保存训练好的 ML 模型的方法:
// Save the workflow
cvModel.write.overwrite().save("model/LR_model")
然后,可以使用load()方法从磁盘恢复相同的模型:
val sameCVModel = LinearRegressionModel.load("model/LR_model")
然后,我们像 LR 和 GLR 模型一样在测试集上计算模型的指标:
println("Evaluating the cross validated model on the test set and calculating the regression metrics")
val trainPredictionsAndLabelsCV = cvModel.transform(testData).select("label", "prediction")
.map { case Row(label: Double, prediction: Double)
=> (label, prediction) }.rdd
val testRegressionMetricsCV = new RegressionMetrics(trainPredictionsAndLabelsCV)
最后,我们收集指标并打印以获得一些见解:
val cvResults = "\n=====================================================================\n" +
s"TrainingData count: ${trainingData.count}\n" +
s"TestData count: ${testData.count}\n" +
"=====================================================================\n" +
s"TestData MSE = ${testRegressionMetricsCV.meanSquaredError}\n" +
s"TestData RMSE = ${testRegressionMetricsCV.rootMeanSquaredError}\n" +
s"TestData R-squared = ${testRegressionMetricsCV.r2}\n" +
s"TestData MAE = ${testRegressionMetricsCV.meanAbsoluteError}\n" +
s"TestData explained variance = ${testRegressionMetricsCV.explainedVariance}\n" +
"=====================================================================\n"
println(cvResults)
前面的代码段应该显示类似的内容。尽管如此,由于随机性的存在,你可能会得到略微不同的输出:
=====================================================================
TrainingData count: 80
TestData count: 55
=====================================================================
TestData MSE = 7.889401628365509
TestData RMSE = 2.8088078660466453
TestData R-squared = 0.3510269588724132
TestData MAE = 2.2158433237623667
TestData explained variance = 20.299135214455085
=====================================================================
如我们所见,RMSE 和 MAE 都略低于未进行交叉验证的 LR 模型。理想情况下,我们应该体验到这些指标甚至更低的值。然而,由于训练集和测试集的规模较小,LR 和 GLR 模型可能都出现了过拟合。尽管如此,我们将在第四章,“基于树的集成技术 Scala”,尝试使用稳健的回归分析算法。更具体地说,我们将尝试使用决策树、随机森林和 GBTRs 来解决相同的问题。
摘要
在本章中,我们看到了如何使用 LR 和 GLR 算法开发用于分析保险严重索赔的回归模型。我们还看到了如何使用交叉验证和网格搜索技术来提高 GLR 模型的表现,这些技术提供了最佳的超参数组合。最后,我们看到了一些常见问题,以便可以将类似的回归技术应用于解决其他现实生活中的问题。
在下一章中,我们将通过一个名为通过客户流失预测分析流失客户的真实问题来了解另一种监督学习技术,即分类。在 Scala 中,我们将使用几种分类算法来进行预测。客户流失预测对商业至关重要,因为它可以帮助你检测可能取消订阅、产品或服务的客户,通过预测哪些客户可能取消对服务的订阅,还可以最小化客户流失。
第三章:使用 Scala 学习分类
在上一章中,我们看到了如何将分析保险严重索赔的预测模型作为回归分析问题来开发。我们应用了非常简单的线性回归,以及广义线性回归(GLR)。
在本章中,我们将学习另一个监督学习任务,称为分类。我们将使用广泛使用的算法,如逻辑回归、朴素贝叶斯(NB)和支持向量机(SVMs),来分析和预测客户是否可能取消其电信合同的订阅。
特别是,我们将涵盖以下主题:
-
分类简介
-
通过实际案例学习分类
-
用于流失预测的逻辑回归
-
用于流失预测的支持向量机(SVM)
-
用于预测的 NB
技术要求
确保 Scala 2.11.x 和 Java 1.8.x 已安装并配置在您的机器上。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter03
查看以下视频,了解代码的实际应用:
分类概述
作为监督学习任务,分类是根据一个或多个独立变量识别哪些观测集(样本)属于哪个集合的问题。这个过程基于包含关于类别或标签成员资格的观测(或实例)的训练集。通常,分类问题是我们训练模型来预测定量(但离散)目标的情况,例如垃圾邮件检测、流失预测、情感分析、癌症类型预测等。
假设我们想要开发一个预测模型,该模型将根据学生的托福和 GRE 能力预测其是否有足够的竞争力被录取到计算机科学专业。此外,假设我们有一些以下范围/格式的历史数据:
-
托福成绩:介于 0 到 100 之间
-
GRE:介于 0 到 100 之间
-
录取:如果录取则为 1,如果不录取则为 0
现在,为了了解我们是否可以使用如此简单的数据来做出预测,让我们创建一个散点图,将所有记录的录取和拒绝作为因变量,托福和GRE作为自变量,如下所示:

通过观察数据点(想象一下图中没有对角线),我们可以合理地开发一个线性模型来分离大部分数据点。现在,如果我们在这两类数据之间画一条直线,它们几乎就能被分开。这样的线(在我们的例子中是绿色)被称为决策边界。因此,如果决策边界合理地分离了最大数据点,它可以用于对未见数据做出预测,我们也可以说,我们预测的线上方的数据点是合格的,而线下方的学生则不够合格。
尽管这个例子是为了对回归分析有一个基本的入门,但分离数据点并不容易。因此,为了计算在哪里画线来分离如此大量的数据点,我们可以使用逻辑回归或其他分类算法,我们将在接下来的章节中讨论。我们还将看到,画一条普通的直线可能不是正确的选择,因此我们通常不得不画曲线。
如果我们仔细观察与录取相关的数据图,可能一条直线并不是分离每个数据点的最佳方式——曲线会更好,如下面的图表所示:

然而,要得到曲线的决策边界,我们必须不仅改变负责从线性到某些高阶多项式的函数(称为决策边界函数),还要将数据改为二次多项式。
这意味着我们必须将我们的问题建模为一个逻辑回归模型。也就是说,我们需要将数据从 {GRE, TOEFL**} 格式转换为二次函数格式, {GRE, GRE², TOEFL, TOEFL², GRE∗TOEFL**}. 然而,以手工方式这样做是繁琐的,并且对于大数据集来说将不可行。幸运的是,Spark MLlib 实现了许多用于建模此类问题以及解决其他分类问题的算法,包括以下内容:
-
逻辑回归(LR)
-
SVM
-
NB
-
多层感知器(MLP)
-
决策树(DT)
-
随机森林(RF)
-
梯度提升树(GBT)
对于一个分类问题,实际(即真实)标签(即类别)和预测标签(即类别)存在于用于训练或测试分类器的样本中;这可以分配到以下类别之一:
-
真阳性(TP):真实标签为正,且分类器做出的预测也是正
-
真阴性(TN):真实标签为负,且分类器做出的预测也是负
-
假阳性(FP):真实标签为负,但分类器做出的预测为正
-
假阴性(FN):真实标签为正,但分类器做出的预测为负
这些指标(TP、FP、TN 和 FN)是我们之前列出的大多数分类器评估指标的基础。然而,通常用于识别正确预测数量的纯准确率并不是一个好的指标,因此使用其他指标,如精度、召回率、F1 分数、AUC 和 Matthew 的相关系数(MCC):
- 准确率是指分类器正确预测的样本数(包括正负样本)与总样本数的比例:

- 精度是指属于正类(真阳性)的正确预测样本数除以实际属于正类的总样本数:

- 召回率是指正确预测属于负类的样本数除以实际属于负类的总元素数:

- F1 分数是精度和召回率的调和*均数。由于 F1 分数是召回率和精度的*衡,它可以被认为是准确率的替代品:

接收者操作特征(ROC)是通过绘制不同阈值值的 FPR(到 x 轴)和 TPR(到 y 轴)而得到的曲线。因此,对于分类器的不同阈值,我们计算 TPR 和 FPR,绘制 ROC 曲线,并计算 ROC 曲线下方的面积(也称为 AUC)。这可以如下可视化:

MCC 被视为二元分类器的*衡度量,即使对于具有非常不*衡类别的数据集也是如此:

让我们讨论一个更贴*现实生活的分类问题示例,即客户流失分析。客户流失是指任何业务中客户或客户的流失,这正在成为商业不同领域的首要关注点,如银行、互联网服务提供商、保险公司等。客户不满和竞争对手的更好报价是这一现象背后的主要原因。在电信行业,当许多用户转而使用其他服务提供商时,公司不仅会失去那些客户和收入——这也会给其他,常规客户或计划开始使用他们服务的人留下坏印象。
最终,客户流失的全部成本包括失去的收入以及用新客户替换这些客户所涉及的电话营销成本。然而,这类损失可能对一家企业造成巨大损失。还记得诺基亚曾是手机市场的霸主吗?突然之间,苹果发布了 iPhone 3G,这在智能手机时代引发了一场革命。随后,大约有 10%到 12%的客户停止使用诺基亚,转而使用 iPhone。尽管诺基亚后来也尝试发布智能手机,但他们无法与苹果竞争。
简而言之,客户流失预测对商业至关重要,因为它可以帮助你检测那些可能取消订阅、产品或服务的不同类型的客户。简而言之,这个想法是预测现有客户是否会取消现有服务的订阅,即一个二元分类问题。
开发客户流失预测模型
如果您首先确定哪些客户可能取消现有服务的订阅,并提供特别优惠或计划给这些客户,那么准确识别流失的可能性可以最小化客户流失。当涉及到员工流失预测和开发预测模型时,这个过程高度依赖数据驱动,可以使用机器学习来理解客户的行为。这是通过分析以下内容来完成的:
-
人口统计数据,如年龄、婚姻状况和就业状况
-
基于社交媒体数据的情感分析
-
使用他们的浏览点击流日志进行行为分析
-
呼叫圈数据和客服中心统计数据
通过以下三个步骤可以开发一个自动化的客户流失分析流程:
-
首先,确定分析客户流失的典型任务,这将取决于公司政策
-
然后,收集和分析数据,并开发预测模型
-
最后,将模型部署到生产就绪的环境中
最终,电信公司能够预测并提升客户体验,防止客户流失,并定制营销活动。在实践中,这种分析将有助于保留那些最有可能离开的客户。这意味着我们不需要担心那些可能留下的客户。
数据集描述
我们可以使用 Orange 电信的客户流失数据集来开发预测模型,该模型将预测哪些客户可能希望取消现有服务的订阅。该数据集经过充分研究,内容全面,用于开发小型原型。它包含 churn-80 和 churn-20 数据集,可以从以下链接下载:
由于这两个数据集都来自具有相同结构的同一分布,我们将使用 churn-80 数据集进行训练和 10 折交叉验证。然后,将使用 churn-20 评估训练好的模型。这两个数据集具有相似的结构,因此具有以下模式:
-
州:
字符串 -
账户长度:
整数 -
区号:
整数 -
国际计划:
字符串 -
语音邮件计划:
字符串 -
电子邮件消息数量:
整数 -
总白天分钟数:
双精度浮点数 -
总白天通话次数:
整数 -
总白天费用:
双精度浮点数 -
总晚上分钟数:
双精度浮点数 -
总晚上通话次数:
整数 -
总晚上费用:
双精度浮点数 -
总夜间分钟数:
双精度浮点数 -
总夜间通话次数:
整数 -
总夜间费用:
双精度浮点数 -
总国际分钟数:
双精度浮点数 -
总国际通话次数:
整数 -
总国际费用:
双倍 -
客户服务电话次数:
整数
探索性分析和特征工程
首先,在将数据作为 Spark DataFrame 加载之前,我们指定完全相同的模式(即自定义模式),如下所示:
val schema = StructType(Array(
StructField("state_code", StringType, true),
StructField("account_length", IntegerType, true),
StructField("area_code", StringType, true),
StructField("international_plan", StringType, true),
StructField("voice_mail_plan", StringType, true),
StructField("num_voice_mail", DoubleType, true),
StructField("total_day_mins", DoubleType, true),
StructField("total_day_calls", DoubleType, true),
StructField("total_day_charge", DoubleType, true),
StructField("total_evening_mins", DoubleType, true),
StructField("total_evening_calls", DoubleType, true),
StructField("total_evening_charge", DoubleType, true),
StructField("total_night_mins", DoubleType, true),
StructField("total_night_calls", DoubleType, true),
StructField("total_night_charge", DoubleType, true),
StructField("total_international_mins", DoubleType, true),
StructField("total_international_calls", DoubleType, true),
StructField("total_international_charge", DoubleType, true),
StructField("total_international_num_calls", DoubleType, true),
StructField("churn", StringType, true)))
然后,我们必须创建一个包含所有指定字段的 Scala case class,并使前面的模式(变量名称是自解释的)对齐:
case class CustomerAccount(state_code: String, account_length: Integer, area_code: String,
international_plan: String, voice_mail_plan: String, num_voice_mail: Double,
total_day_mins: Double, total_day_calls: Double, total_day_charge: Double,
total_evening_mins: Double, total_evening_calls: Double, total_evening_charge: Double,
total_night_mins: Double, total_night_calls: Double, total_night_charge: Double,
total_international_mins: Double, total_international_calls: Double,
total_international_charge: Double, total_international_num_calls: Double, churn: String)
让我们创建一个 Spark 会话并导入implicit._包,它允许我们指定 DataFrame 操作,如下所示:
import spark.implicits._
现在,让我们创建训练集。我们使用 Spark 推荐的格式com.databricks.spark.csv读取 CSV 文件。我们不需要任何显式的模式推断;因此,我们将inferSchema设置为false,但使用我们之前创建的自己的模式。然后,我们从我们希望的位置加载数据文件,并最终指定我们的数据源,以便我们的 DataFrame 看起来与我们指定的完全相同:
val trainSet: Dataset[CustomerAccount] = spark.read.
option("inferSchema", "false")
.format("com.databricks.spark.csv")
.schema(schema)
.load("data/churn-bigml-80.csv")
.as[CustomerAccount]
trainSet.printSchema()
如以下截图所示,Spark DataFrame 的模式已被正确识别。然而,一些特征是非数字的,而是分类的。然而,正如机器学习算法所期望的,所有特征都必须是数字的(即,整数或双精度浮点数格式):

太棒了!它看起来与数据结构完全相同。现在,让我们使用show()方法查看一些样本数据,如下所示:
trainSet.show()
前一行代码的输出显示了 DataFrame 的前 20 个样本:

在前面的截图中,为了提高可见性,列名被缩短了。我们还可以通过使用describe()方法查看训练集的相关统计信息:
val statsDF = trainSet.describe()
statsDF.show()
以下摘要统计不仅让我们对数据的分布有了些了解,包括均值和标准差,还提供了一些描述性统计,如 DataFrame 中每个特征的样本数(即计数)、最小值和最大值:

如果这个数据集可以适应 RAM,我们可以使用 Spark 的cache()方法将其缓存,以便快速和重复访问:
trainSet.cache()
让我们看看一些有用的属性,例如变量与churn的相关性。例如,让我们看看churn与总国际通话次数之间的关系:
trainSet.groupBy("churn").sum("total_international_num_calls").show()
如以下输出所示,通话更多国际电话的客户不太可能(即,False)更换运营商:
+-----+----------------------------------+
|churn|sum(total_international_num_calls)|
+-----+----------------------------------+
|False| 3310.0|
|True | 856.0|
+-----+----------------------------------+
让我们看看churn与总国际通话费用之间的关系:
trainSet.groupBy("churn").sum("total_international_charge").show()
如以下输出所示,通话更多国际电话的客户(如前所述)被收取更多费用,但仍然不太可能(即,False)更换运营商:
+-----+-------------------------------+
|churn|sum(total_international_charge)|
+-----+-------------------------------+
|False| 6236.499999999996|
| True| 1133.63|
+-----+-------------------------------+
现在我们还需要准备测试集来评估模型,让我们准备与训练集相似的相同集,如下所示:
val testSet: Dataset[CustomerAccount] = spark.read
.option("inferSchema", "false")
.format("com.databricks.spark.csv")
.schema(schema)
.load("data/churn-bigml-20.csv")
.as[CustomerAccount]
现在,让我们将它们缓存起来,以便更快地进行进一步的操作:
testSet.cache()
让我们看看一些与训练集相关的属性,以了解它是否适合我们的目的。首先,让我们为这个会话创建一个临时视图以进行持久化。尽管如此,我们可以创建一个目录作为接口,用于创建、删除、修改或查询底层数据库、表、函数等:
trainSet.createOrReplaceTempView("UserAccount")
spark.catalog.cacheTable("UserAccount")
我们现在可以按churn标签对数据进行分组,并计算每个组中的实例数量,如下所示:
trainSet.groupBy("churn").count.show()
前一行应该显示只有388名客户可能更换到另一个运营商。然而,2278名客户仍然将当前运营商作为他们首选的运营商:
+-----+-----+
|churn|count|
+-----+-----+
|False| 2278|
| True| 388 |
+-----+-----+
因此,我们有大约七倍的False churn 样本比True churn 样本。由于目标是保留最有可能离开的客户,我们将准备我们的训练集,以确保预测机器学习模型对True churn 样本敏感。
此外,由于训练集高度不*衡,我们应该将False churn 类别下采样到 388/2278 的分数,这给我们0.1675:
val fractions = Map("False" -> 0.1675, "True" -> 1.0)
这样,我们也在映射仅包含True churn 样本。现在,让我们创建一个新的 DataFrame,用于训练集,只包含使用sampleBy()方法从下采样中提取的样本:
val churnDF = trainSet.stat.sampleBy("churn", fractions, 12345L)
第三个参数是用于可重复性的种子。让我们看看这个:
churnDF.groupBy("churn").count.show()
现在,我们可以看到类别几乎*衡:
+-----+-----+
|churn|count|
+-----+-----+
|False| 390|
| True| 388|
+-----+-----+
现在,让我们看看这些变量是如何相互关联的。让我们看看白天、晚上、傍晚和国际语音通话是如何对churn类别做出贡献的:
spark.sql()("SELECT churn, SUM(total_day_charge) as TDC,
SUM(total_evening_charge) as TEC, SUM(total_night_charge) as TNC,
SUM(total_international_charge) as TIC,
SUM(total_day_charge) + SUM(total_evening_charge) +
SUM(total_night_charge) + SUM(total_international_charge)
as Total_charge FROM UserAccount GROUP BY churn
ORDER BY Total_charge DESC").show()
然而,这并没有给出任何明显的相关性,因为可能留下的客户比想要离开的其他客户在白天、晚上、傍晚和国际语音通话上通话更多:

现在,让我们看看白天、晚上、傍晚和国际语音通话的通话分钟数是如何对churn类别的Total_charge做出贡献的:
spark.sql()("SELECT churn, SUM(total_day_mins) +
SUM(total_evening_mins) +
SUM(total_night_mins) +
SUM(total_international_mins) as Total_minutes
FROM UserAccount GROUP BY churn")
.show()
从前面的两个表中可以看出,总日分钟数和总日费用是这个训练集中高度相关的特征,这对我们的 ML 模型训练没有好处。因此,最好完全删除它们。让我们删除每对相关字段中的一列,以及state_code和area_code列,因为那些将不会被使用:
val trainDF = churnDF
.drop("state_code")
.drop("area_code")
.drop("voice_mail_plan")
.drop("total_day_charge")
.drop("total_evening_charge")
极好!最后,我们有了我们的训练 DataFrame,它可以用于更好的预测建模。让我们看看结果 DataFrame 的一些列:
trainDF.select("account_length", "international_plan", "num_voice_mail",
"total_day_calls","total_international_num_calls", "churn")
.show(10)
然而,我们还没有完成——当前的 DataFrame 不能直接输入到模型中。这被称为估算器。正如我们之前所描述的,我们的数据需要转换为 Spark DataFrame 格式,包括标签(在Double中)和特征(在Vector中):

现在,我们需要创建一个管道,通过链式连接多个转换器和估算器来传递数据。然后,该管道作为一个特征提取器工作。更具体地说,我们已经准备了两个StringIndexer转换器和一个VectorAssembler。
第一个StringIndexer将String分类特征international_plan和标签转换为数字索引。第二个StringIndexer将分类标签(即churn)转换为数值格式。这样,通过索引分类特征,允许决策树和随机森林等分类器适当地处理分类特征,从而提高性能:
val ipindexer = new StringIndexer()
.setInputCol("international_plan")
.setOutputCol("iplanIndex")
val labelindexer = new StringIndexer()
.setInputCol("churn")
.setOutputCol("label")
现在,我们需要提取对分类有贡献的最重要特征。由于我们已经删除了一些列,结果列集包括以下字段:
-
标签 → 活跃度:
True或False -
特征 →
{账户时长,iplanIndex,num_voice_mail,total_day_mins,total_day_calls,total_evening_mins,total_evening_calls,total_night_mins,total_night_calls,total_international_mins,total_international_calls,total_international_num_calls}
由于我们已经在StringIndexer的帮助下将分类标签转换为数值,下一个任务是提取特征:
val featureCols = Array("account_length", "iplanIndex", "num_voice_mail",
"total_day_mins", "total_day_calls", "total_evening_mins",
"total_evening_calls", "total_night_mins", "total_night_calls",
"total_international_mins", "total_international_calls",
"total_international_num_calls")
现在,让我们使用VectorAssembler()将特征转换为特征向量,它接受所有的featureCols并将它们组合/转换成一个名为features的单列:
val assembler = new VectorAssembler()
.setInputCols(featureCols)
.setOutputCol("features")
现在我们已经有了真实的训练集,其中包含准备好的标签和特征向量,接下来的任务是创建一个估算器——管道的第三个元素。我们将从一个非常简单但强大的 LR 分类器开始。
活跃度预测的 LR
LR 是一种分类算法,它预测二元响应。它与我们在第二章中描述的线性回归类似,即Scala for Regression Analysis,但它不预测连续值——它预测离散类别。损失函数是 sigmoid 函数(或逻辑函数):

与线性回归类似,成本函数背后的直觉是惩罚那些实际响应与预测响应之间误差大的模型:

对于给定的新的数据点x,LR 模型使用以下方程进行预测:

在前面的方程中,对回归应用了逻辑函数以得到它属于任一类的概率,其中z = w^T x,如果f(w^T x) > 0.5,结果为正;否则为负。这意味着分类线的阈值被假定为0.5。
现在我们知道了 LR 算法是如何工作的,让我们开始使用基于 Spark ML 的 LR 估计器开发,这将预测客户是否可能发生流失。首先,我们需要定义一些超参数来训练基于 LR 的流水线:
val numFolds = 10
val MaxIter: Seq[Int] = Seq(100)
val RegParam: Seq[Double] = Seq(1.0) // L2 regularization param set 1.0 with L1 reg. to reduce overfitting
val Tol: Seq[Double] = Seq(1e-8)// for convergence tolerance for iterative algorithms
val ElasticNetParam: Seq[Double] = Seq(0.0001) //Combination of L1 & L2
RegParam是一个标量,帮助我们调整约束的强度:小值表示软边界,而大值表示硬边界。Tol参数用于迭代算法(如 LR 或线性 SVM)的收敛容差。一旦我们定义并初始化了超参数,我们的下一个任务就是实例化一个 LR 估计器,如下所示:
val lr = new LogisticRegression()
.setLabelCol("label")
.setFeaturesCol("features")
现在,让我们使用Pipeline()方法构建一个流水线估计器,将三个转换器(ipindexer、labelindexer和assembler向量)和 LR 估计器(即lr)链接到一个单一的流水线中——即它们各自作为一个阶段:
val pipeline = new Pipeline()
.setStages(Array(PipelineConstruction.ipindexer,
PipelineConstruction.labelindexer,
PipelineConstruction.assembler, lr))
Spark ML 流水线可以包含以下组件:
-
DataFrame:用于存储原始数据和中间转换后的数据。
-
Transformer:用于通过添加额外的特征列将一个 DataFrame 转换为另一个 DataFrame。
-
Estimator:估计器是一个 ML 模型,例如线性回归。
-
Pipeline:用于将前面的组件、DataFrame、转换器和估计器链接在一起。
-
Parameter:ML 算法有许多可调整的旋钮。这些被称为超参数,而 ML 算法学习以适应数据的值被称为参数。
为了在超参数空间上执行此类网格搜索,我们首先需要定义它。在这里,Scala 的函数式编程特性非常有用,因为我们只需将函数指针和相应的参数添加到参数网格中。在这里,交叉验证评估器将搜索 LR 的最大迭代次数、正则化参数、容差和弹性网络以找到最佳模型:
val paramGrid = new ParamGridBuilder()
.addGrid(lr.maxIter, MaxIter)
.addGrid(lr.regParam, RegParam)
.addGrid(lr.tol, Tol)
.addGrid(lr.elasticNetParam, ElasticNetParam)
.build()
注意,超参数形成一个n-维空间,其中n是超参数的数量。这个空间中的每一个点代表一个特定的超参数配置,即一个超参数向量。当然,我们无法探索这个空间中的每一个点,所以我们基本上是在这个空间的一个(理想情况下均匀分布的)子集上进行网格搜索。然后我们需要定义一个BinaryClassificationEvaluator评估器,因为这是一个二元分类问题:
val evaluator = new BinaryClassificationEvaluator()
.setLabelCol("label")
.setRawPredictionCol("prediction")
我们通过使用ParamGridBuilder来迭代 LR 的最大迭代次数、回归参数、容差和弹性网络参数,并通过 10 折交叉验证来使用CrossValidator:
val crossval = new CrossValidator()
.setEstimator(pipeline)
.setEvaluator(evaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
前面的代码旨在执行交叉验证。验证器本身使用BinaryClassificationEvaluator估计器在每一折的渐进网格空间上评估训练,并确保不会发生过度拟合。
虽然幕后有很多事情在进行,但我们的CrossValidator对象接口仍然保持简洁且广为人知,因为CrossValidator也扩展了估计器并支持fit方法。这意味着在调用fit方法后,包括所有特征预处理和 LR 分类器在内的完整预定义管道将被多次执行——每次使用不同的超参数向量:
val cvModel = crossval.fit(Preprocessing.trainDF)
现在,是时候使用测试数据集评估 LR 模型了。首先,我们需要转换测试集,类似于我们之前描述的训练集:
val predDF= cvModel.transform(Preprocessing.testSet)
val result = predDF.select("label", "prediction", "probability")
val resutDF = result.withColumnRenamed("prediction", "Predicted_label")
resutDF.show(10)
前面的代码块显示了模型生成的Predicted_label和原始probability,此外还显示了实际标签。正如我们所见,对于某些实例,模型预测正确,但对于其他实例,它却感到困惑:

预测概率也可以非常有用,可以根据客户的不完美可能性对客户进行排名。这样,在电信业务中,可以有限地利用资源,专注于最有价值的客户。然而,通过查看前面的预测 DataFrame,很难猜测分类准确率。然而,在第二步中,评估器使用BinaryClassificationEvaluator进行自我评估,如下所示:
val accuracy = evaluator.evaluate(predDF)
println("Classification accuracy: " + accuracy)
这应该显示我们的二元分类模型的约 77%分类准确率:
Classification accuracy: 0.7679333824070667
我们计算另一个性能指标,称为精确率-召回率曲线下的面积和 ROC 曲线下的面积。为此,我们可以构建一个包含测试集上原始得分的 RDD:
val predictionAndLabels = predDF
.select("prediction", "label")
.rdd.map(x => (x(0).asInstanceOf[Double], x(1)
.asInstanceOf[Double]))
现在,前面的 RDD 可以用来计算上述性能指标:
val metrics = new BinaryClassificationMetrics(predictionAndLabels)
println("Area under the precision-recall curve: " + metrics.areaUnderPR)
println("Area under the receiver operating characteristic (ROC) curve : " + metrics.areaUnderROC)
在这种情况下,评估结果显示 77%的准确率,但只有 58%的精确率:
Area under the precision-recall curve: 0.5770932703444629
Area under the receiver operating characteristic (ROC) curve: 0.7679333824070667
在下面的代码中,我们正在计算更多的指标。假阳性和假阴性预测对于评估模型性能也是有用的。然后,我们将结果打印出来以查看指标,如下所示:
val tVSpDF = predDF.select("label", "prediction") // True vs predicted labels
val TC = predDF.count() //Total count
val tp = tVSpDF.filter($"prediction" === 0.0)
.filter($"label" === $"prediction")
.count() / TC.toDouble
val tn = tVSpDF.filter($"prediction" === 1.0)
.filter($"label" === $"prediction")
.count() / TC.toDouble
val fp = tVSpDF.filter($"prediction" === 1.0)
.filter(not($"label" === $"prediction"))
.count() / TC.toDouble
val fn = tVSpDF.filter($"prediction" === 0.0)
.filter(not($"label" === $"prediction"))
.count() / TC.toDouble
println("True positive rate: " + tp *100 + "%")
println("False positive rate: " + fp * 100 + "%")
println("True negative rate: " + tn * 100 + "%")
println("False negative rate: " + fn * 100 + "%")
上述代码段显示了真正例、假正例、真负例和假负例的比率,我们将使用这些比率来计算后续的 MCC 分数:
True positive rate: 66.71664167916042%
False positive rate: 19.04047976011994%
True negative rate: 10.944527736131935%
False negative rate: 3.2983508245877062%
最后,我们也计算了 MCC 分数,如下所示:
val MCC = (tp * tn - fp * fn) / math.sqrt((tp + fp) * (tp + fn) * (fp + tn) * (tn + fn))
println("Matthews correlation coefficient: " + MCC)
上述行给出了马修斯相关系数为 0.41676531680973805。这是一个正值,表明我们的模型具有一定的鲁棒性。然而,我们还没有获得良好的准确率,所以让我们继续尝试其他分类器,例如 NB。这次,我们将使用 Apache Spark ML 包中的线性 NB 实现。
NB 用于客户流失预测
NB 分类器基于贝叶斯定理,具有以下假设:
-
每对特征之间的独立性
-
特征值是非负的,例如计数
例如,如果癌症与年龄有关,这可以用来评估患者可能患有癌症的概率. 贝叶斯定理可以用以下数学公式表示:

在此方程中,A 和 B 是具有 P (B) ≠ 0 的事件。其他项可以描述如下:
-
P (A | B) 被称为在 B 为真的条件下观察事件 A 的后验概率或条件概率
-
P (B| A) 是在 A 为真的条件下事件 B 发生的可能性
-
P(A) 是先验,P(B) 是先验概率,也称为边缘似然或边缘概率
高斯 NB 是 NB 的一个推广版本,用于分类,它基于数据的二项分布。例如,我们的客户流失预测问题可以表述如下:

以下列表可以用来解决我们的问题如下:
-
P(class|data) 是使用独立变量 (data) 建模预测 class 的后验概率
-
P(data|class) 是给定 class 的预测因子似然或概率
-
P(class) 是 class 的先验概率和预测因子的 P(data) 或边缘似然
众所周知的哈佛幸福研究显示,只有 10% 的快乐人是富人。虽然你可能认为这个统计数据非常有说服力,但你可能对真正快乐的人中富人的百分比也感兴趣。贝叶斯定理帮助你通过使用两个额外的线索来计算这个保留统计:
-
总体中快乐的人的百分比—that is, P(A)
-
总体中富有的人的百分比—that is, P(B)
贝叶斯定理背后的关键思想是通过考虑整体比率来反转统计。假设以下信息是可用的先验信息:
-
40% 的人感到快乐 => P(A)
-
5% 的人是富人 => P(B)
现在,让我们假设哈佛研究是正确的—that is, P(B|A) = 10%。既然我们知道快乐的人中富有的比例, P(A|B) 可以计算如下:
P(A|B) = {P(A) P(B|A)} / P(B) = (40%10%)/5% = 80%
因此,大多数人也是快乐的!太好了。为了使这一点更清晰,让我们假设整个世界的人口为 5,000,为了简单起见。根据我们的计算,存在两个事实:
-
事实 1:这告诉我们有 500 人感到快乐,哈佛的研究告诉我们其中 50 个快乐的人也是富有的
-
事实 2:总共有 60 个富人,因此其中快乐的人的比例是 50/60 ~ 83%
这证明了贝叶斯定理及其有效性。要使用 NB,我们需要实例化一个 NB 估计器,如下所示:
val nb = new NaiveBayes()
.setLabelCol("label")
.setFeaturesCol("features")
现在我们有了转换器和估计器,下一个任务是链式连接一个单一的管道——也就是说,它们中的每一个都充当一个阶段:
val pipeline = new Pipeline()
.setStages(Array(PipelineConstruction.ipindexer,
PipelineConstruction.labelindexer,
PipelineConstruction.assembler,nb))
让我们定义paramGrid以在超参数空间中进行网格搜索。然后交叉验证器将通过 NB 的smoothing参数搜索最佳模型。与 LR 或 SVM 不同,NB 算法中没有超参数:
val paramGrid = new ParamGridBuilder()
.addGrid(nb.smoothing, Array(1.0, 0.1, 1e-2, 1e-4))// default value is 1.0
.build()
加权*滑,或拉普拉斯*滑,是一种用于*滑分类数据的技巧。
让我们定义一个BinaryClassificationEvaluator评估器来评估模型:
val evaluator = new BinaryClassificationEvaluator()
.setLabelCol("label")
.setRawPredictionCol("prediction")
我们使用CrossValidator执行 10 折交叉验证以进行最佳模型选择:
val crossval = new CrossValidator()
.setEstimator(pipeline)
.setEvaluator(evaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
让我们调用fit()方法,这样就可以执行完整的预定义pipeline,包括所有特征预处理和 LR 分类器,每次都使用不同的超参数向量:
val cvModel = crossval.fit(Preprocessing.trainDF)
现在,是时候评估 SVM 模型在测试数据集上的预测能力了。首先,我们需要使用模型管道转换测试集,这将根据我们在前面的特征工程步骤中描述的相同机制映射特征:
val predDF = cvModel.transform(Preprocessing.testSet)
然而,通过查看前面的预测 DataFrame,很难猜测分类精度。然而,在第二步中,评估器使用BinaryClassificationEvaluator评估自己,如下所示:
val accuracy = evaluator.evaluate(predDF)
println("Classification accuracy: " + accuracy)
前面的代码行应该显示我们的二元分类模型的 75%分类准确率:
Classification accuracy: 0.600772911299227
就像我们之前做的那样,我们构建了一个包含测试集上原始得分的 RDD:
val predictionAndLabels = predDF.select("prediction", "label")
.rdd.map(x => (x(0).asInstanceOf[Double], x(1)
.asInstanceOf[Double]))
现在,前面提到的 RDD 可以用来计算上述性能指标:
val metrics = new BinaryClassificationMetrics(predictionAndLabels)
println("Area under the precision-recall curve: " + metrics.areaUnderPR)
println("Area under the receiver operating characteristic (ROC) curve : " + metrics.areaUnderROC)
在这种情况下,评估返回了 75%的准确率,但只有 55%的精确率:
Area under the precision-recall curve: 0.44398397740763046
Area under the receiver operating characteristic (ROC) curve: 0.600772911299227
在下面的代码中,我们再次计算了一些更多的指标。错误的和真正的正负预测对于评估模型性能也是有用的:
val tVSpDF = predDF.select("label", "prediction") // True vs predicted labels
val TC = predDF.count() //Total count
val tp = tVSpDF.filter($"prediction" === 0.0)
.filter($"label" === $"prediction")
.count() / TC.toDouble
val tn = tVSpDF.filter($"prediction" === 1.0)
.filter($"label" === $"prediction")
.count() / TC.toDouble
val fp = tVSpDF.filter($"prediction" === 1.0)
.filter(not($"label" === $"prediction"))
.count() / TC.toDouble
val fn = tVSpDF.filter($"prediction" === 0.0)
.filter(not($"label" === $"prediction"))
.count() / TC.toDouble
println("True positive rate: " + tp *100 + "%")
println("False positive rate: " + fp * 100 + "%")
println("True negative rate: " + tn * 100 + "%")
println("False negative rate: " + fn * 100 + "%")
前面的代码段显示了真正例、假正例、真反例和假反例的比率,我们将使用这些比率来计算 MCC 评分:
True positive rate: 66.71664167916042%
False positive rate: 19.04047976011994%
True negative rate: 10.944527736131935%
False negative rate: 3.2983508245877062%
最后,我们也计算了 MCC 评分,如下所示:
val MCC = (tp * tn - fp * fn) / math.sqrt((tp + fp) * (tp + fn) * (fp + tn) * (tn + fn))
println("Matthews correlation coefficient: " + MCC)
前一行给出了马修斯相关系数为0.14114315409796457,这次我们在准确性和 MCC 评分方面表现得更差。因此,尝试使用另一个分类器,如 SVM 是值得的。我们将使用 Spark ML 包中的线性 SVM 实现。
SVM 用于客户流失预测
SVM 也是分类的一种流行算法。SVM 基于决策*面的概念,它定义了我们本章开头讨论的决策边界。以下图表显示了 SVM 算法的工作原理:

SVM 使用核函数,它找到具有最大边界的线性超*面来分离类别。以下图表显示了使用基于最大边界的决策边界如何将属于两个不同类别(红色与蓝色)的数据点(即支持向量)分开:

上述支持向量分类器可以用以下数学方式表示为点积:

如果要分离的数据非常高维,核技巧使用核函数将数据转换到更高维的特征空间,以便它们可以用于分类的线性可分。从数学上讲,核技巧是用核替换点积,这将允许非线性决策边界和计算效率:

既然我们已经了解了 SVM,让我们开始使用基于 Spark 的 SVM 实现。首先,我们需要定义一些超参数来训练一个基于 LR 的 pipeline:
val numFolds = 10
val MaxIter: Seq[Int] = Seq(100)
val RegParam: Seq[Double] = Seq(1.0) // L2 regularization param, set 0.10 with L1 regularization
val Tol: Seq[Double] = Seq(1e-8)
val ElasticNetParam: Seq[Double] = Seq(1.0) // Combination of L1 and L2
一旦我们定义并初始化了超参数,下一个任务就是实例化一个 SVM 估计器,如下所示:
val svm = new LinearSVC()
现在我们有了转换器和估计器,下一个任务是链式连接一个单一的 pipeline——也就是说,它们中的每一个都充当一个阶段:
val pipeline = new Pipeline()
.setStages(Array(PipelineConstruction.ipindexer,
PipelineConstruction.labelindexer,
PipelineConstruction.assembler,svm))
让我们定义paramGrid以在超参数空间中进行网格搜索。这搜索 SVM 的最大迭代次数、正则化参数、容差和弹性网络以找到最佳模型:
val paramGrid = new ParamGridBuilder()
.addGrid(svm.maxIter, MaxIter)
.addGrid(svm.regParam, RegParam)
.addGrid(svm.tol, Tol)
.addGrid(svm.elasticNetParam, ElasticNetParam)
.build()
让我们定义一个BinaryClassificationEvaluator评估器来评估模型:
val evaluator = new BinaryClassificationEvaluator()
.setLabelCol("label")
.setRawPredictionCol("prediction")
我们使用CrossValidator进行 10 折交叉验证以选择最佳模型:
val crossval = new CrossValidator()
.setEstimator(pipeline)
.setEvaluator(evaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
现在,让我们调用 fit 方法,以便执行完整的预定义pipeline,包括所有特征预处理和 LR 分类器,多次执行——每次使用不同的超参数向量:
val cvModel = crossval.fit(Preprocessing.trainDF)
现在,是时候评估 SVM 模型在测试数据集上的预测能力了:
val predDF= cvModel.transform(Preprocessing.testSet)
predDF.show(10)
上述代码块显示了模型生成的预测标签和原始概率,以及实际标签。
如我们所见,对于某些实例,模型预测正确,但对于某些其他实例,它感到困惑:

然而,通过查看前面的预测 DataFrame,很难猜测分类准确率。然而,在第二步中,评估器使用BinaryClassificationEvaluator评估自己,如下所示:
val accuracy = evaluator.evaluate(predDF)
println("Classification accuracy: " + accuracy)
因此,我们从我们的二分类模型中获得了大约 75%的分类准确率:
Classification accuracy: 0.7530180345969819
现在,我们构建一个包含测试集原始分数的 RDD,它将被用来计算性能指标,如精确度-召回率曲线下的面积(AUC)和接收机操作特征曲线下的面积(ROC):
val predictionAndLabels = predDF
.select("prediction", "label")
.rdd.map(x => (x(0).asInstanceOf[Double], x(1)
.asInstanceOf[Double]))
现在,可以使用前面的 RDD 来计算上述性能指标:
val metrics = new BinaryClassificationMetrics(predictionAndLabels)
println("Area under the precision-recall curve: " + metrics.areaUnderPR)
println("Area under the receiver operating characteristic (ROC) curve : " + metrics.areaUnderROC)
在这种情况下,评估返回了 75%的准确率,但只有 55%的精确度:
Area under the precision-recall curve: 0.5595712265324828
Area under the receiver operating characteristic (ROC) curve: 0.7530180345969819
我们还可以计算一些更多的指标;例如,错误和正确预测的正负样本也是评估模型性能的有用信息:
val tVSpDF = predDF.select("label", "prediction") // True vs predicted labels
val TC = predDF.count() //Total count
val tp = tVSpDF.filter($"prediction" === 0.0)
.filter($"label" === $"prediction")
.count() / TC.toDouble
val tn = tVSpDF.filter($"prediction" === 1.0)
.filter($"label" === $"prediction")
.count() / TC.toDouble
val fp = tVSpDF.filter($"prediction" === 1.0)
.filter(not($"label" === $"prediction"))
.count() / TC.toDouble
val fn = tVSpDF.filter($"prediction" === 0.0)
.filter(not($"label" === $"prediction"))
.count() / TC.toDouble
println("True positive rate: " + tp *100 + "%")
println("False positive rate: " + fp * 100 + "%")
println("True negative rate: " + tn * 100 + "%")
println("False negative rate: " + fn * 100 + "%")
前面的代码段显示了真正例、假正例、真反例和假反例的比率,我们将使用这些比率来计算 MCC 分数:
True positive rate: 66.71664167916042%
False positive rate: 19.04047976011994%
True negative rate: 10.944527736131935%
False negative rate: 3.2983508245877062%
最后,我们还计算了 MCC 分数,如下所示:
val MCC = (tp * tn - fp * fn) / math.sqrt((tp + fp) * (tp + fn) * (fp + tn) * (tn + fn))
println("Matthews correlation coefficient: " + MCC)
这给了我一个马修斯相关系数为0.3888239300421191。尽管我们尝试使用尽可能多的三个分类算法,但我们仍然没有获得良好的准确率。考虑到 SVM 设法给我们带来了 76%的准确率,这仍然被认为是较低的。此外,没有最适合的特征选择选项,这有助于我们使用最合适的特征来训练模型。为了提高分类准确率,我们需要使用基于树的算法,如 DT、RF 和 GBT,这些算法预计将提供更强大的响应。我们将在下一章中这样做。
摘要
在本章中,我们学习了不同的经典分类算法,如 LR、SVM 和 NB。使用这些算法,我们预测客户是否可能取消他们的电信订阅。我们还讨论了构建成功的客户流失预测模型所需的数据类型。
基于树的分类器和树集成分类器非常实用且稳健,并且广泛应用于解决分类和回归任务。在下一章中,我们将探讨如何使用基于树和集成技术(如 DT、RF 和 GBT)来开发这样的分类器和回归器,用于分类和回归。
第四章:Scala 用于树集成技术
在上一章中,我们使用线性模型解决了分类和回归问题。我们还使用了逻辑回归、支持向量机和朴素贝叶斯。然而,在这两种情况下,我们没有获得很好的准确度,因为我们的模型表现出低置信度。
另一方面,基于树和树集成分类器对于分类和回归任务来说非常有用、稳健且广泛使用。本章将简要介绍使用基于树和集成技术(如决策树(DTs)、随机森林(RF)和梯度提升树(GBT))开发这些分类器和回归器的方法,用于分类和回归。更具体地说,我们将重新审视并解决之前讨论过的回归(来自第二章,Scala 用于回归分析)和分类(来自第三章,Scala 用于分类学习)问题。
本章将涵盖以下主题:
-
决策树和树集成
-
用于监督学习的决策树
-
用于监督学习的梯度提升树
-
用于监督学习的随机森林
-
接下来是什么?
技术要求
确保 Scala 2.11.x 和 Java 1.8.x 已安装并配置在您的机器上。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter04
查看以下播放列表,以查看本章的代码实战视频:
决策树和树集成
DTs 通常属于监督学习技术,用于识别和解决与分类和回归相关的问题。正如其名所示,DTs 有各种分支——每个分支表示基于统计概率的可能决策、外观或反应。就特征而言,DTs 分为两大类:训练集和测试集,这有助于对预测标签或类别的更新产生良好的效果。
DT 算法可以处理二元和多类分类问题,这也是它在各种问题中广泛应用的原因之一。例如,对于我们在第三章中介绍的招生示例,“Scala 用于分类学习”,DTs 通过一组if...else决策规则从招生数据中学习,以*似正弦曲线,如图所示:

基于大学招生数据使用 DTs 生成决策规则
通常,树越大,决策规则越复杂,模型拟合度越高。决策树(DT)的另一个令人兴奋的功能是它们可以用来解决分类和回归问题。现在让我们看看 DT 的一些优缺点。两种广泛使用的基于树的集成技术是 RF 和 GBT。这两种技术之间的主要区别在于训练树的方式和顺序:
-
RF 独立地训练每棵树,但基于数据的随机样本。这些随机样本有助于使模型比单个 DT 更稳健,因此它不太可能对训练数据产生过载。
-
GBT 一次训练一棵树。先前训练的树产生的错误将被每棵新训练的树纠正。随着树的增加,模型的表达能力更强。
随机森林(RF)从观测值和变量子集中选取一部分来构建,这是一个决策树的集成。这些树实际上是在同一训练集的不同部分上训练的,但单个树生长得很深,往往能从高度不可预测的模式中学习。
有时非常深的树是 DT 模型中过拟合问题的原因。此外,这些偏差可能会使分类器表现不佳,即使就数据集而言,表示的特征质量很好。
当构建决策树时,随机森林将它们整合在一起以获得更准确和稳定的预测。RF 通过计算成对案例之间的邻*度来*均多个决策树,目的是减少方差以确保一致性。这是对 RF 的直接后果。通过一组独立陪审团的多数投票,我们得到比最佳陪审团更好的最终预测。以下图显示了两个森林的决策如何集成以获得最终预测:

基于树的集成及其组装技术
最后,RF 和 GBT 都产生一个决策树的加权集合,随后从每个集成模型的单个树中进行预测组合结果。当使用这些方法(作为分类器或回归器)时,参数设置如下:
-
如果树木的数量为 1,则不应用自助法。如果树木的数量大于 1,则应用自助法,支持的值有
auto、all、sqrt、log2和三分之一。 -
支持的数值范围是[0.0-1.0]和[1-n]。如果
numTrees为1,则featureSubsetStrategy设置为all。如果numTrees大于 1(对于 RF),则featureSubsetStrategy设置为分类的sqrt。如果选择auto作为featureSubsetStrategy,则算法会自动推断最佳特征子集策略。 -
杂质标准仅用于信息增益的计算,分类时支持的值是基尼指数,回归时支持的值是方差。
-
maxDepth是树的最大深度(例如,深度 0 表示 1 个叶子节点,深度 1 表示 1 个内部节点和 2 个叶子节点,依此类推)。 -
maxBins表示用于分割特征的 bin 的最大数量,建议值为 100 以获得更好的结果。
既然我们已经处理了回归分析和分类问题,让我们看看如何更舒适地使用 DT、RF 和 GBT 来解决这些问题。让我们从 DT 开始。
用于监督学习的决策树
在本节中,我们将看到如何使用 DT 来解决回归和分类问题。在前面的两章中,第二章,“Scala for Regression Analysis”和第三章,“Scala for Learning Classification”,我们解决了客户流失和保险严重索赔问题。这些分别是分类和回归问题。在这两种方法中,我们都使用了其他经典模型。然而,我们将看到如何使用基于树和集成技术来解决它们。我们将使用 Scala 中的 Apache Spark ML 包中的 DT 实现。
用于分类的决策树
首先,我们在第三章中了解了客户流失预测问题,“Scala for Learning Classification”,我们也了解了相关数据。我们还知道了决策树(DT)的工作原理。因此,我们可以直接使用基于 Spark 的决策树实现来进入编码部分。首先,我们通过实例化DecisionTreeClassifier接口创建一个DecisionTreeClassifier估计器。此外,我们还需要指定标签和特征向量列:
val dTree = new DecisionTreeClassifier()
.setLabelCol("label")// Setting label column
.setFeaturesCol("features") // Setting feature vector column
.setSeed(1234567L)// for reproducibility
如前几章所述,我们有三个转换器(ipindexer、labelindexer和assembler)和一个估计器(dTree)。我们现在可以将它们链接在一起形成一个单一的管道,这样每个转换器都将作为一个阶段:
val pipeline = new Pipeline()
.setStages(Array(PipelineConstruction.ipindexer,
PipelineConstruction.labelindexer,
PipelineConstruction.assembler,dTree))
由于我们希望执行超参数调整和交叉验证,我们需要创建一个paramGrid变量,该变量将在 K 折交叉验证期间用于超参数空间的网格搜索:
var paramGrid = new ParamGridBuilder()
.addGrid(dTree.impurity, "gini" :: "entropy" :: Nil)
.addGrid(dTree.maxBins, 2 :: 5 :: 10 :: 15 :: 20 :: 25 :: 30 :: Nil)
.addGrid(dTree.maxDepth, 5 :: 10 :: 15 :: 20 :: 25 :: 30 :: 30 :: Nil)
.build()
更具体地说,这将搜索 DT 的impurity、maxBins和maxDepth以找到最佳模型。最大 bin 数用于分离连续特征,并选择在每个节点上如何分割特征。算法结合搜索 DT 的maxDepth和maxBins参数以找到最佳模型。
在前面的代码段中,我们创建了一个渐进的paramGrid变量,其中我们指定组合为字符串或整数值的列表。这意味着我们正在创建具有不同超参数组合的网格空间。这将帮助我们提供最佳模型,包括最优超参数。然而,为了做到这一点,我们需要一个BinaryClassificationEvaluator评估器来评估每个模型,并在交叉验证期间选择最佳模型:
val evaluator = new BinaryClassificationEvaluator()
.setLabelCol("label")
.setRawPredictionCol("prediction")
我们使用CrossValidator进行 10 折交叉验证以选择最佳模型:
println("Preparing for 10-fold cross-validation")
val numFolds = 10
val crossval = new CrossValidator()
.setEstimator(pipeline)
.setEvaluator(evaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
现在我们调用fit方法,以便执行完整的预定义管道,包括所有特征预处理和 DT 分类器,多次执行——每次使用不同的超参数向量:
val cvModel = crossval.fit(Preprocessing.trainDF)
现在是时候评估 DT 模型在测试数据集上的预测能力了:
val predictions = cvModel.transform(Preprocessing.testSet)
prediction.show(10)
这将导致以下 DataFrame 显示预测标签与实际标签的对比。此外,它还显示了原始概率:

然而,根据前面的预测 DataFrame,很难猜测分类的准确率。但在第二步,评估是使用BinaryClassificationEvaluator进行的,如下所示:
val accuracy = evaluator.evaluate(predictions)
println("Classification accuracy: " + accuracy)
这将提供一个包含准确率值的输出:
Accuracy: 0.8441663599558337
因此,我们从我们的二元分类模型中获得了大约 84%的分类准确率。就像 SVM 和 LR 一样,我们将观察基于以下 RDD 的精确率-召回率曲线下面积和接收器操作特征(ROC)曲线下面积,该 RDD 包含测试集上的原始分数:
val predictionAndLabels = predictions
.select("prediction", "label")
.rdd.map(x => (x(0).asInstanceOf[Double], x(1)
.asInstanceOf[Double]))
之前提到的 RDD 可以用来计算前面提到的两个性能指标:
val metrics = new BinaryClassificationMetrics(predictionAndLabels)
println("Area under the precision-recall curve: " + metrics.areaUnderPR)
println("Area under the receiver operating characteristic (ROC) curve: " + metrics.areaUnderROC)
在这种情况下,评估返回了 84%的准确率,但只有 67%的精确度,这比 SVM 和 LR 要好得多:
Area under the precision-recall curve: 0.6665988000794282
Area under the receiver operating characteristic (ROC) curve: 0.8441663599558337
然后,我们计算一些更多的指标,例如,假阳性和真阳性,以及假阴性和真阴性,因为这些预测也有助于评估模型的表现:
val TC = predDF.count() //Total count
val tp = tVSpDF.filter($"prediction" === 0.0).filter($"label" === $"prediction")
.count() / TC.toDouble // True positive rate
val tn = tVSpDF.filter($"prediction" === 1.0).filter($"label" === $"prediction")
.count() / TC.toDouble // True negative rate
val fp = tVSpDF.filter($"prediction" === 1.0).filter(not($"label" === $"prediction"))
.count() / TC.toDouble // False positive rate
val fn = tVSpDF.filter($"prediction" === 0.0).filter(not($"label" === $"prediction"))
.count() / TC.toDouble // False negative rate
此外,我们计算马修斯相关系数:
val MCC = (tp * tn - fp * fn) / math.sqrt((tp + fp) * (tp + fn) * (fp + tn) * (tn + fn))
让我们观察模型置信度有多高:
println("True positive rate: " + tp *100 + "%")
println("False positive rate: " + fp * 100 + "%")
println("True negative rate: " + tn * 100 + "%")
println("False negative rate: " + fn * 100 + "%")
println("Matthews correlation coefficient: " + MCC)
太棒了!我们只达到了 70%的准确率,这可能是我们树的数量较少的原因,但具体是哪些因素呢?
True positive rate: 70.76461769115441%
False positive rate: 14.992503748125937%
True negative rate: 12.293853073463268%
False negative rate: 1.9490254872563717%
Matthews correlation coefficient: 0.5400720075807806
现在我们来看看交叉验证后我们达到了最佳模型的水*:
val bestModel = cvModel.bestModel
println("The Best Model and Parameters:\n--------------------")
println(bestModel.asInstanceOf[org.apache.spark.ml.PipelineModel].stages(3))
根据以下输出,我们在深度 5和53 个节点达到了最佳的树模型:
The Best Model and Parameters:
DecisionTreeClassificationModel of depth 5 with 53 nodes
让我们通过显示树来提取在树构建过程中采取的移动(即决策)。这棵树帮助我们找到数据集中最有价值的特征:
bestModel.asInstanceOf[org.apache.spark.ml.PipelineModel]
.stages(3)
.extractParamMap
val treeModel = bestModel.asInstanceOf[org.apache.spark.ml.PipelineModel]
.stages(3)
.asInstanceOf[DecisionTreeClassificationModel]
println("Learned classification tree model:\n" + treeModel.toDebugString)
在以下输出中,toDebugString()方法打印了树的决策节点和最终预测结果在叶子节点:
Learned classification tree model:
If (feature 3 <= 245.2)
If (feature 11 <= 3.0)
If (feature 1 in {1.0})
If (feature 10 <= 2.0)
Predict: 1.0
Else (feature 10 > 2.0)
If (feature 9 <= 12.9)
Predict: 0.0
Else (feature 9 > 12.9)
Predict: 1.0
…
Else (feature 7 > 198.0)
If (feature 2 <= 28.0)
Predict: 1.0
Else (feature 2 > 28.0)
If (feature 0 <= 60.0)
Predict: 0.0
Else (feature 0 > 60.0)
Predict: 1.0
我们还可以看到某些特征(在我们的例子中是3和11)被用于决策,即客户可能流失的两个最重要的原因。但这两个特征是什么?让我们看看:
println("Feature 11:" + Preprocessing.trainDF.filter(PipelineConstruction.featureCols(11)))
println("Feature 3:" + Preprocessing.trainDF.filter(PipelineConstruction.featureCols(3)))
根据以下输出,特征 3 和 11 是最重要的预测因子:
Feature 11: [total_international_num_calls: double]
Feature 3: [total_day_mins: double]
客户服务电话和总日分钟数由 DTs 选择,因为它们提供了一个自动化的机制来确定最重要的特征。
回归决策树
在第三章,“用于学习的 Scala 分类”,我们学习了如何预测关于交通缓慢的问题。我们应用了线性回归(LR)和广义线性回归来解决此问题。我们也非常了解数据。
如前所述,决策树(DT)在回归问题中也能提供非常强大响应和性能。类似于DecisionTreeClassifier,可以使用DecisionTreeRegressor()方法实例化DecisionTreeRegressor估计器。此外,我们需要明确指定标签和特征列:
// Estimator algorithm
val model = new DecisionTreeRegressor().setFeaturesCol("features").setLabelCol("label")
在实例化前面的估计器时,我们可以设置最大分箱数、树的数量、最大深度和纯度:
然而,由于我们将执行 k 折交叉验证,我们可以在创建paramGrid时设置这些参数:
// Search through decision tree's parameter for the best model
var paramGrid = new ParamGridBuilder()
.addGrid(rfModel.impurity, "variance" :: Nil)// variance for regression
.addGrid(rfModel.maxBins, 25 :: 30 :: 35 :: Nil)
.addGrid(rfModel.maxDepth, 5 :: 10 :: 15 :: Nil)
.addGrid(rfModel.numTrees, 3 :: 5 :: 10 :: 15 :: Nil)
.build()
为了获得更好和更稳定的性能,让我们准备 k 折交叉验证和网格搜索作为模型调优的一部分。正如你所猜到的,我将执行 10 折交叉验证。请根据你的设置和数据集自由调整折数:
println("Preparing K-fold Cross Validation and Grid Search: Model tuning")
val numFolds = 10 // 10-fold cross-validation
val cv = new CrossValidator()
.setEstimator(rfModel)
.setEvaluator(new RegressionEvaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
太棒了!我们已经创建了交叉验证估计器。现在是时候使用交叉验证训练 DT 回归模型了:
println("Training model with decision tree algorithm")
val cvModel = cv.fit(trainingData)
现在我们有了拟合好的模型,我们可以进行预测。所以让我们开始评估模型在训练集和验证集上的表现,并计算 RMSE、MSE、MAE、R *方等:
println("Evaluating the model on the test set and calculating the regression metrics")
val trainPredictionsAndLabels = cvModel.transform(testData).select("label", "prediction")
.map { case Row(label: Double, prediction: Double)
=> (label, prediction) }.rdd
val testRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels)
一旦我们有了最佳拟合和交叉验证模型,我们可以期待良好的预测精度。让我们观察训练集和验证集上的结果:
val results = "\n=====================================================================\n" +
s"TrainingData count: ${trainingData.count}\n" +
s"TestData count: ${testData.count}\n" +
"=====================================================================\n" +
s"TestData MSE = ${testRegressionMetrics.meanSquaredError}\n" +
s"TestData RMSE = ${testRegressionMetrics.rootMeanSquaredError}\n" +
s"TestData R-squared = ${testRegressionMetrics.r2}\n" +
s"TestData MAE = ${testRegressionMetrics.meanAbsoluteError}\n" +
s"TestData explained variance = ${testRegressionMetrics.explainedVariance}\n" +
"=====================================================================\n"
println(results)
以下输出显示了测试集上的 MSE、RMSE、R *方、MAE 和解释方差:
=====================================================================
TrainingData count: 80
TestData count: 55
=====================================================================
TestData MSE = 7.871519100933004
TestData RMSE = 2.8056227652578323
TestData R-squared = 0.5363607928629964
TestData MAE = 2.284866391184572
TestData explained variance = 20.213067468774792
=====================================================================
太好了!我们已经成功计算了训练集和测试集上的原始预测,并且我们可以看到与 LR 回归模型相比的改进。让我们寻找能帮助我们实现更好精度的模型:
val bestModel = cvModel.bestModel.asInstanceOf[DecisionTreeRegressionModel]
此外,我们可以通过观察森林中的决策树来了解决策是如何做出的:
println("Decision tree from best cross-validated model: " + bestModel.toDebugString)
以下为输出结果:
Decision tree from best cross-validated model at depth 5 with 39 nodes
If (feature 0 <= 19.0)
If (feature 0 <= 3.0)
If (feature 0 <= 1.0)
If (feature 3 <= 0.0)
If (feature 4 <= 0.0)
Predict: 4.1
Else (feature 4 > 0.0)
Predict: 3.4000000000000004
....
Predict: 15.30909090909091
Else (feature 0 > 25.0)
Predict: 12.800000000000002
Else (feature 11 > 1.0)
Predict: 22.100000000000023
Else (feature 9 > 1.0)
Predict: 23.399999999999977
使用决策树(DTs),我们可以测量特征重要性,这样在后续阶段我们可以决定使用哪些特征以及从 DataFrame 中删除哪些特征。让我们找出我们刚刚创建的最佳模型的所有特征的重要性,这些特征按以下升序排列:
val featureImportances = bestModel.featureImportances.toArray
val FI_to_List_sorted = featureImportances.toList.sorted.toArray
println("Feature importance generated by the best model: ")
for(x <- FI_to_List_sorted) println(x)
以下是由模型生成的特征重要性:
Feature importance generated by the best model:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
7.109215735617604E-5
2.1327647206851872E-4
0.001134987328520092
0.00418143999334111
0.025448271970345014
0.03446268498009088
0.057588305610674816
0.07952108027588178
0.7973788612117217
最后的结果对于理解特征重要性很重要。正如你所见,随机森林(RF)将一些特征排名为更重要。例如,最后几个特征是最重要的,而其中八个则相对不那么重要。我们可以删除这些不重要的列,并再次训练 DT 模型,以观察测试集上 MAE 的减少和 R *方的增加是否有所改善。
监督学习的梯度提升树
在本节中,我们将看到如何使用 GBT 来解决回归和分类问题。在前两章中,第二章,Scala 回归分析,和第三章,Scala 学习分类,我们解决了客户流失和保险严重索赔问题,分别是分类和回归问题。在这两种方法中,我们使用了其他经典模型。然而,我们将看到如何使用基于树和集成技术来解决它们。我们将使用 Scala 中的 Spark ML 包中的 GBT 实现。
用于分类的梯度提升树
我们从第三章,Scala 学习分类中了解到客户流失预测问题,并且我们对数据很熟悉。我们已经知道 RF 的工作原理,所以让我们开始使用基于 Spark 的 RF 实现:
- 通过调用
GBTClassifier()接口实例化一个GBTClassifier估计器:
val gbt = new GBTClassifier()
.setLabelCol("label")
.setFeaturesCol("features")
.setSeed(1234567L)
- 我们已经有了三个转换器和一个估计器就绪。将它们链式连接成一个单一管道,即它们各自作为一个阶段:
// Chain indexers and tree in a Pipeline.
val pipeline = new Pipeline()
.setStages(Array(ScalaClassification.PipelineConstruction.ipindexer,
ScalaClassification.PipelineConstruction.labelindexer,
ScalaClassification.PipelineConstruction.assembler,
gbt))
- 定义
paramGrid变量以在超参数空间中进行网格搜索:
// Search through decision tree's maxDepth parameter for best model
val paramGrid = new ParamGridBuilder()
.addGrid(gbt.maxDepth, 3 :: 5 :: 10 :: Nil) // :: 15 :: 20 :: 25 :: 30 :: Nil)
.addGrid(gbt.impurity, "gini" :: "entropy" :: Nil)
.addGrid(gbt.maxBins, 5 :: 10 :: 20 :: Nil) //10 :: 15 :: 25 :: 35 :: 45 :: Nil)
.build()
- 定义一个
BinaryClassificationEvaluator评估器来评估模型:
val evaluator = new BinaryClassificationEvaluator()
.setLabelCol("label")
.setRawPredictionCol("prediction")
- 我们使用
CrossValidator进行 10 折交叉验证以选择最佳模型:
// Set up 10-fold cross validation
val numFolds = 10
val crossval = new CrossValidator()
.setEstimator(pipeline)
.setEvaluator(evaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
- 现在让我们调用
fit方法,这样完整的预定义管道,包括所有特征预处理和 DT 分类器,就会执行多次——每次使用不同的超参数向量:
val cvModel = crossval.fit(Preprocessing.trainDF)
现在是时候评估 DT 模型在测试数据集上的预测能力了:
- 使用模型管道转换测试集,这将根据我们在前面的特征工程步骤中描述的相同机制更新特征:
val predictions = cvModel.transform(Preprocessing.testSet)
prediction.show(10)
这将导致以下 DataFrame,显示预测标签与实际标签的对比。此外,它还显示了原始概率:

然而,在看到前面的预测 DataFrame 后,很难猜测分类准确率。
- 但在第二步中,评估是使用
BinaryClassificationEvaluator进行的,如下所示:
val accuracy = evaluator.evaluate(predictions)
println("Classification accuracy: " + accuracy)
这将给我们提供分类准确率:
Accuracy: 0.869460802355539
因此,我们从我们的二元分类模型中获得大约 87%的分类准确率。就像 SVM 和 LR 一样,我们将根据以下 RDD 观察精确率-召回率曲线下的面积和 ROC 曲线下的面积,该 RDD 包含测试集上的原始分数:
val predictionAndLabels = predictions
.select("prediction", "label")
.rdd.map(x => (x(0).asInstanceOf[Double], x(1)
.asInstanceOf[Double]))
前面的 RDD 可用于计算之前提到的性能指标:
val metrics = new BinaryClassificationMetrics(predictionAndLabels)
println("Area under the precision-recall curve: " + metrics.areaUnderPR)
println("Area under the receiver operating characteristic (ROC) curve: " + metrics.areaUnderROC)
这将分享准确性和预测的价值:
Area under the precision-recall curve: 0.7270259009251356
Area under the receiver operating characteristic (ROC) curve: 0.869460802355539
在这种情况下,评估返回 87% 的准确率,但只有 73% 的精确率,这比 SVM 和 LR 好得多。然后我们计算更多的错误和真实指标。正负预测也可以用来评估模型性能:
val TC = predDF.count() //Total count
val tp = tVSpDF.filter($"prediction" === 0.0).filter($"label" === $"prediction")
.count() / TC.toDouble // True positive rate
val tn = tVSpDF.filter($"prediction" === 1.0).filter($"label" === $"prediction")
.count() / TC.toDouble // True negative rate
val fp = tVSpDF.filter($"prediction" === 1.0).filter(not($"label" === $"prediction"))
.count() / TC.toDouble // False positive rate
val fn = tVSpDF.filter($"prediction" === 0.0).filter(not($"label" === $"prediction"))
.count() / TC.toDouble // False negative rate
此外,我们计算马修斯相关系数:
val MCC = (tp * tn - fp * fn) / math.sqrt((tp + fp) * (tp + fn) * (fp + tn) * (tn + fn))
让我们观察模型置信度有多高:
println("True positive rate: " + tp *100 + "%")
println("False positive rate: " + fp * 100 + "%")
println("True negative rate: " + tn * 100 + "%")
println("False negative rate: " + fn * 100 + "%")
println("Matthews correlation coefficient: " + MCC)
现在让我们看看真正的阳性、假阳性、真正性和假阴性率。此外,我们看到了 MCC:
True positive rate: 0.7781109445277361
False positive rate: 0.07946026986506746
True negative rate: 0.1184407796101949
False negative rate: 0.0239880059970015
Matthews correlation coefficient: 0.6481780577821629
这些比率看起来很有希望,因为我们经历了正 MCC,这表明大多数情况下有正相关,表明这是一个稳健的分类器。现在,类似于决策树,随机森林在分类过程中也可以进行调试。为了打印树并选择最重要的特征,请运行决策树中的最后几行代码。请注意,我们仍然通过将 numTrees、maxBins 和 maxDepth 限制为 7 来限制超参数空间。记住,更大的树更有可能表现更好。因此,请随意尝试这段代码,添加特征,并使用更大的超参数空间,例如更大的树。
GBT 回归
为了减少损失函数的大小,GBT 将训练许多决策树。对于每个实例,算法将使用当前可用的集成来预测每个训练实例的标签。
与决策树类似,GBT 可以执行以下操作:
-
处理分类和数值特征
-
可用于二元分类和回归(多类分类尚不支持)
-
不需要特征缩放
-
从非常高维的数据集中捕获非线性特征和特征交互
假设我们拥有 N 个数据实例(其中 x[i] 表示实例 i 的特征)和 y 是标签(其中 y[i] 表示实例 i 的标签),那么 f(x[i]) 是 GBT 模型对实例 i 的预测标签,它试图最小化以下损失之一:



第一个方程称为 log 损失,是二项式负 log 似然的两倍。第二个称为*方误差,通常被称为 L2 损失,是 GBT 基于回归任务的默认损失。最后,第三个称为绝对误差,通常称为 L1 损失,如果数据点有许多异常值,则比*方误差更稳健。
现在我们已经了解了 GBT 回归算法的最小工作原理,我们可以开始。让我们通过调用 GBTRegressor() 接口来实例化 GBTRegressor 估计器:
val gbtModel = new GBTRegressor().setFeaturesCol("features").setLabelCol("label")
在实例化前面的估计器时,我们可以设置最大箱数、树的数量、最大深度和纯度。然而,由于我们将执行 k 折交叉验证,我们也可以在创建 paramGrid 变量时设置这些参数:
// Search through GBT's parameter for the best model
var paramGrid = new ParamGridBuilder()
.addGrid(gbtModel.impurity, "variance" :: Nil)// variance for regression
.addGrid(gbtModel.maxBins, 25 :: 30 :: 35 :: Nil)
.addGrid(gbtModel.maxDepth, 5 :: 10 :: 15 :: Nil)
.addGrid(gbtModel.numTrees, 3 :: 5 :: 10 :: 15 :: Nil)
.build()
训练过程中的验证:梯度提升可能会过拟合,尤其是在你用更多树训练模型时。为了防止这个问题,在训练过程中进行验证(例如,使用交叉验证)是有用的。
为了获得更好的性能和更稳定的性能,让我们准备 k 折交叉验证和网格搜索作为模型调优的一部分。正如你所猜到的,我将执行 10 折交叉验证。请根据你的设置和数据集自由调整折数:
println("Preparing K-fold Cross Validation and Grid Search: Model tuning")
val numFolds = 10 // 10-fold cross-validation
val cv = new CrossValidator()
.setEstimator(gbtModel)
.setEvaluator(new RegressionEvaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
太棒了!我们已经创建了交叉验证估计器。现在,是时候用交叉验证来训练RandomForestRegression模型了:
println("Training model with RandomForestRegressor algorithm")
val cvModel = cv.fit(trainingData)
现在我们有了拟合的模型,我们可以进行预测。让我们开始评估模型在训练集和验证集上的表现,并计算 RMSE、MSE、MAE 和 R *方误差:
println("Evaluating the model on the test set and calculating the regression metrics")
val trainPredictionsAndLabels = cvModel.transform(testData).select("label", "prediction")
.map { case Row(label: Double, prediction: Double)
=> (label, prediction) }.rdd
val testRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels)
一旦我们有了最佳拟合和交叉验证的模型,我们可以期待高预测准确率。现在让我们观察训练集和验证集上的结果:
val results = "\n=====================================================================\n" +
s"TrainingData count: ${trainingData.count}\n" +
s"TestData count: ${testData.count}\n" +
"=====================================================================\n" +
s"TestData MSE = ${testRegressionMetrics.meanSquaredError}\n" +
s"TestData RMSE = ${testRegressionMetrics.rootMeanSquaredError}\n" +
s"TestData R-squared = ${testRegressionMetrics.r2}\n" +
s"TestData MAE = ${testRegressionMetrics.meanAbsoluteError}\n" +
s"TestData explained variance = ${testRegressionMetrics.explainedVariance}\n" +
"=====================================================================\n"
println(results)
以下输出显示了测试集上的均方误差(MSE)、均方根误差(RMSE)、R-squared、MAE 和解释方差:
=====================================================================
TrainingData count: 80
TestData count: 55
=====================================================================
TestData MSE = 5.99847335425882
TestData RMSE = 2.4491780977011084
TestData R-squared = 0.4223425609926217
TestData MAE = 2.0564380367107646
TestData explained variance = 20.340666319995183
=====================================================================
太好了!我们已经成功计算了训练集和测试集上的原始预测,并且我们可以看到与 LR、DT 和 GBT 回归模型相比的改进。让我们寻找帮助我们实现更高准确率的模型:
val bestModel = cvModel.bestModel.asInstanceOf[GBTRegressionModel]
此外,我们可以通过观察森林中的决策树(DTs)来了解决策是如何做出的:
println("Decision tree from best cross-validated model: " + bestModel.toDebugString)
在以下输出中,toDebugString()方法打印了树的决策节点和最终预测结果在最终叶子节点:
Decision tree from best cross-validated model with 10 trees
Tree 0 (weight 1.0):
If (feature 0 <= 16.0)
If (feature 2 <= 1.0)
If (feature 15 <= 0.0)
If (feature 13 <= 0.0)
If (feature 16 <= 0.0)
If (feature 0 <= 3.0)
If (feature 3 <= 0.0)
Predict: 6.128571428571427
Else (feature 3 > 0.0)
Predict: 3.3999999999999986
....
Tree 9 (weight 1.0):
If (feature 0 <= 22.0)
If (feature 2 <= 1.0)
If (feature 1 <= 1.0)
If (feature 0 <= 1.0)
Predict: 3.4
...
使用随机森林,我们可以测量特征重要性,这样在后续阶段,我们可以决定使用哪些特征以及从 DataFrame 中删除哪些特征。让我们找出我们刚刚创建的最佳模型中所有特征的排序,如下所示:
val featureImportances = bestModel.featureImportances.toArray
val FI_to_List_sorted = featureImportances.toList.sorted.toArray
println("Feature importance generated by the best model: ")
for(x <- FI_to_List_sorted) println(x)
以下是模型生成的特征重要性:
Feature importance generated by the best model:
0.0
0.0
5.767724652714395E-4
0.001616872851121874
0.006381209526062637
0.008867810069950395
0.009420668763121653
0.01802097742361489
0.026755738338777407
0.02761531441902482
0.031208534172407782
0.033620224027091
0.03801721834820778
0.05263475066123412
0.05562565266841311
0.13221209076999635
0.5574261654957049
最后的结果对于理解特征重要性非常重要。正如你所见,随机森林(RF)将一些看起来更重要的特征进行了排名。例如,最后两个特征是最重要的,而前两个则不那么重要。我们可以删除一些不重要的列,并训练 RF 模型来观察测试集上的 R-squared 和 MAE 值是否有任何减少。
监督学习的随机森林
在本节中,我们将看到如何使用 RF 来解决回归和分类问题。我们将使用 Scala 中的 Spark ML 包中的 DT 实现。尽管 GBT 和 RF 都是树的集成,但它们的训练过程是不同的。例如,RF 使用 bagging 技术进行示例,而 GBT 使用 boosting。尽管如此,两者之间有几个实际的权衡,可能会造成选择的困境。然而,在大多数情况下,RF 将是赢家。以下是一些理由:
-
GBTs 一次训练一棵树,但 RF 可以并行训练多棵树。因此,RF 的训练时间更低。然而,在某些特殊情况下,使用 GBTs 训练和较少的树数量更快、更方便。
-
RFs 不太容易过拟合。换句话说,RFs 通过增加树的数量来减少方差,而 GBTs 通过增加树的数量来减少偏差。
-
RFs 更容易调整,因为性能随着树的数量单调增加,但 GBTs 随着树的数量增加表现不佳。
用于分类的随机森林
我们熟悉客户流失预测问题,来自第三章,《Scala 用于学习分类》,并且我们也对数据很了解。我们还了解随机森林的工作原理。因此,我们可以直接跳入使用基于 Spark 的 RF 实现进行编码。
我们通过调用RandomForestClassifier()接口来实例化一个RandomForestClassifier估计器:
val rf = new RandomForestClassifier()
.setLabelCol("label")
.setFeaturesCol("features")
.setSeed(1234567L) // for reproducibility
现在我们已经有了三个转换器和估计器,下一个任务是将它们链式连接成一个单一的管道,即每个都作为阶段:
val pipeline = new Pipeline()
.setStages(Array(PipelineConstruction.ipindexer,
PipelineConstruction.labelindexer,
PipelineConstruction.assembler,rf))
让我们定义paramGrid以在超参数空间中进行网格搜索:
val paramGrid = new ParamGridBuilder()
.addGrid(rf.maxDepth, 3 :: 5 :: 15 :: 20 :: 50 :: Nil)
.addGrid(rf.featureSubsetStrategy, "auto" :: "all" :: Nil)
.addGrid(rf.impurity, "gini" :: "entropy" :: Nil)
.addGrid(rf.maxBins, 2 :: 5 :: 10 :: Nil)
.addGrid(rf.numTrees, 10 :: 50 :: 100 :: Nil)
.build()
让我们定义一个BinaryClassificationEvaluator评估器来评估模型:
val evaluator = new BinaryClassificationEvaluator()
.setLabelCol("label")
.setRawPredictionCol("prediction")
我们使用CrossValidator进行 10 折交叉验证以选择最佳模型:
val crossval = new CrossValidator()
.setEstimator(pipeline)
.setEvaluator(evaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
让我们称这个fit方法为执行完整预定义管道,包括所有特征预处理和 DT 分类器,多次执行——每次使用不同的超参数向量:
val cvModel = crossval.fit(Preprocessing.trainDF)
现在是时候评估 DT 模型在测试数据集上的预测能力了。
作为第一步,我们需要使用模型管道转换测试集,这将根据我们在特征工程步骤中描述的相同机制映射特征:
val predictions = cvModel.transform(Preprocessing.testSet)
prediction.show(10)
这将导致以下 DataFrame,显示预测标签与实际标签的对比。此外,它还显示了原始概率:

然而,基于先前的预测 DataFrame,很难猜测分类准确率。
但在第二步中,评估是使用BinaryClassificationEvaluator进行的,如下所示:
val accuracy = evaluator.evaluate(predictions)
println("Classification accuracy: " + accuracy)
下面的输出是:
Accuracy: 0.8800055207949945
因此,我们从我们的二分类模型中获得大约 87%的分类准确率。现在,类似于 SVM 和 LR,我们将观察基于以下 RDD 的精确度-召回曲线下的面积和 ROC 曲线下的面积,该 RDD 包含测试集上的原始分数:
val predictionAndLabels = predictions
.select("prediction", "label")
.rdd.map(x => (x(0).asInstanceOf[Double], x(1)
.asInstanceOf[Double]))
前面的 RDD 可以用来计算之前提到的性能指标:
val metrics = new BinaryClassificationMetrics(predictionAndLabels)
println("Area under the precision-recall curve: " + metrics.areaUnderPR)
println("Area under the receiver operating characteristic (ROC) curve: " + metrics.areaUnderROC)
在这种情况下,评估返回了 88%的准确率,但只有 73%的精确度,这比 SVM 和 LR 要好得多:
Area under the precision-recall curve: 0.7321042166486744
Area under the receiver operating characteristic (ROC) curve: 0.8800055207949945
然后我们计算一些更多的指标,例如,假正例和真正例以及真负例和假负例预测,这些将有助于评估模型性能:
val TC = predDF.count() //Total count
val tp = tVSpDF.filter($"prediction" === 0.0).filter($"label" === $"prediction")
.count() / TC.toDouble // True positive rate
val tn = tVSpDF.filter($"prediction" === 1.0).filter($"label" === $"prediction")
.count() / TC.toDouble // True negative rate
val fp = tVSpDF.filter($"prediction" === 1.0).filter(not($"label" === $"prediction"))
.count() / TC.toDouble // False positive rate
val fn = tVSpDF.filter($"prediction" === 0.0).filter(not($"label" === $"prediction"))
.count() / TC.toDouble // False negative rate
此外,我们计算马修斯相关系数:
val MCC = (tp * tn - fp * fn) / math.sqrt((tp + fp) * (tp + fn) * (fp + tn) * (tn + fn))
让我们观察模型置信度有多高:
println("True positive rate: " + tp *100 + "%")
println("False positive rate: " + fp * 100 + "%")
println("True negative rate: " + tn * 100 + "%")
println("False negative rate: " + fn * 100 + "%")
println("Matthews correlation coefficient: " + MCC)
现在,让我们看一下真正例(true positive)、假正例(false positive)、真负例(true negative)和假负例(false negative)的比率。此外,我们还可以看到 MCC:
True positive rate: 0.7691154422788605
False positive rate: 0.08845577211394302
True negative rate: 0.12293853073463268
False negative rate: 0.019490254872563718
Matthews correlation coefficient: 0.6505449208932913
就像 DT 和 GBT 一样,RF 不仅表现出稳健的性能,而且略有改进。并且像 DT 和 GBT 一样,RF 可以被调试以获取分类过程中构建的 DT。为了打印树和选择最重要的特征,尝试 DT 的最后几行代码,然后完成。
你能猜到训练了多少个不同的模型吗?嗯,我们在交叉验证中有 10 折,并且在 2 到 7 之间的 5 维超参数空间中。现在让我们做一些简单的数学计算:10 * 7 * 5 * 2 * 3 * 6 = 12,600个模型!
现在我们已经看到了如何在分类设置中使用 RF,让我们看看回归分析的另一个例子。
随机森林回归
由于 RF 足够快且可扩展,适用于大规模数据集,基于 Spark 的 RF 实现可以帮助你实现大规模可扩展性。幸运的是,我们已经知道了 RF 的工作原理。
如果在 RF 中计算邻*度,存储需求也会呈指数增长。
我们可以直接使用基于 Spark 的 RF 回归实现进行编码。我们通过调用RandomForestClassifier()接口来实例化RandomForestClassifier估计器:
val rfModel = new RandomForestRegressor()
.setFeaturesCol("features")
.setLabelCol("label")
现在,让我们通过指定一些超参数,如最大箱数、树的最大深度、树的数量和纯度类型来创建一个网格空间:
// Search through decision tree's maxDepth parameter for best model
var paramGrid = new ParamGridBuilder()
.addGrid(rfModel.impurity, "variance" :: Nil)// variance for regression
.addGrid(rfModel.maxBins, 25 :: 30 :: 35 :: Nil)
.addGrid(rfModel.maxDepth, 5 :: 10 :: 15 :: Nil)
.addGrid(rfModel.numTrees, 3 :: 5 :: 10 :: 15 :: Nil)
.build()
为了获得更好、更稳定的性能,让我们准备 k 折交叉验证和网格搜索作为模型调优的一部分。正如你所猜到的,我将执行 10 折交叉验证。请根据你的设置和数据集自由调整折数:
println("Preparing K-fold Cross Validation and Grid Search: Model tuning")
val numFolds = 10 // 10-fold cross-validation
val cv = new CrossValidator()
.setEstimator(rfModel)
.setEvaluator(new RegressionEvaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(numFolds)
太棒了!我们已经创建了交叉验证估计器。现在是时候使用交叉验证来训练随机森林回归模型了:
println("Training model with RandomForestRegressor algorithm")
val cvModel = cv.fit(trainingData)
现在我们有了拟合的模型,我们可以进行预测。让我们开始评估模型在训练集和验证集上的表现,并计算 RMSE、MSE、MAE 和 R *方:
println("Evaluating the model on the test set and calculating the regression metrics")
val trainPredictionsAndLabels = cvModel.transform(testData).select("label", "prediction")
.map { case Row(label: Double, prediction: Double)
=> (label, prediction) }.rdd
val testRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels)
一旦我们有了最佳拟合和交叉验证的模型,我们可以期待良好的预测准确率。现在让我们在训练集和验证集上观察结果:
val results = "\n=====================================================================\n" +
s"TrainingData count: ${trainingData.count}\n" +
s"TestData count: ${testData.count}\n" +
"=====================================================================\n" +
s"TestData MSE = ${testRegressionMetrics.meanSquaredError}\n" +
s"TestData RMSE = ${testRegressionMetrics.rootMeanSquaredError}\n" +
s"TestData R-squared = ${testRegressionMetrics.r2}\n" +
s"TestData MAE = ${testRegressionMetrics.meanAbsoluteError}\n" +
s"TestData explained variance = ${testRegressionMetrics.explainedVariance}\n" +
"=====================================================================\n"
println(results)
下面的输出显示了测试集上的均方误差(MSE)、均方根误差(RMSE)、决定系数(R-squared)、*均绝对误差(MAE)和解释方差:
=====================================================================
TrainingData count: 80
TestData count: 55
=====================================================================
TestData MSE = 5.99847335425882
TestData RMSE = 2.4491780977011084
TestData R-squared = 0.4223425609926217
TestData MAE = 2.0564380367107646
TestData explained variance = 20.340666319995183
=====================================================================
太好了!我们已经成功计算了训练集和测试集上的原始预测值,并且我们可以看到与 LR、DT 和 GBT 回归模型相比的改进。让我们寻找帮助我们实现更高准确率的模型:
val bestModel = cvModel.bestModel.asInstanceOf[RandomForestRegressionModel]
此外,我们可以通过查看森林中的决策树来了解决策是如何做出的:
println("Decision tree from best cross-validated model: " + bestModel.toDebugString)
在下面的输出中,toDebugString()方法打印了树的决策节点和最终预测结果在叶子节点:
Decision tree from best cross-validated model with 10 trees
Tree 0 (weight 1.0):
If (feature 0 <= 16.0)
If (feature 2 <= 1.0)
If (feature 15 <= 0.0)
If (feature 13 <= 0.0)
If (feature 16 <= 0.0)
If (feature 0 <= 3.0)
If (feature 3 <= 0.0)
Predict: 6.128571428571427
Else (feature 3 > 0.0)
Predict: 3.3999999999999986
....
Tree 9 (weight 1.0):
If (feature 0 <= 22.0)
If (feature 2 <= 1.0)
If (feature 1 <= 1.0)
If (feature 0 <= 1.0)
Predict: 3.4
...
使用 RF,我们可以测量特征重要性,这样在以后阶段,我们可以决定使用哪些特征以及从 DataFrame 中删除哪些特征。在我们将所有特征按升序排列之前,让我们先找出我们刚刚创建的最佳模型中的特征重要性:
val featureImportances = bestModel.featureImportances.toArray
val FI_to_List_sorted = featureImportances.toList.sorted.toArray
println("Feature importance generated by the best model: ")
for(x <- FI_to_List_sorted) println(x)
以下是模型生成的特征重要性:
Feature importance generated by the best model:
0.0
0.0
5.767724652714395E-4
0.001616872851121874
0.006381209526062637
0.008867810069950395
0.009420668763121653
0.01802097742361489
0.026755738338777407
0.02761531441902482
0.031208534172407782
0.033620224027091
0.03801721834820778
0.05263475066123412
0.05562565266841311
0.13221209076999635
0.5574261654957049
最后的结果对于理解特征重要性非常重要。正如所见,一些特征的权重高于其他特征。甚至其中一些特征的权重为零。权重越高,特征的相对重要性就越高。例如,最后两个特征是最重要的,而前两个则相对不那么重要。我们可以删除一些不重要的列,并训练 RF 模型来观察测试集上的 R-squared 和 MAE 值是否有任何减少。
接下来是什么?
到目前为止,我们主要介绍了回归和分类的经典和基于树的算法。我们看到了与经典算法相比,集成技术表现出最佳性能。然而,还有其他算法,例如一对一算法,它适用于使用其他分类器(如逻辑回归)解决分类问题。
除了这些,基于神经网络的算法,如多层感知器(MLP)、卷积神经网络(CNN)和循环神经网络(RNN),也可以用来解决监督学习问题。然而,正如预期的那样,这些算法需要大量的训练样本和强大的计算基础设施。到目前为止,我们在示例中使用的数据集样本数量很少。此外,这些数据集的维度也不是很高。这并不意味着我们不能用它们来解决这两个问题;我们可以,但这会导致由于训练样本不足而导致的巨大过拟合。
我们如何解决这个问题?嗯,我们可以寻找其他数据集或随机生成训练数据。我们将讨论并展示如何训练基于神经网络的深度学习模型来解决其他问题。
摘要
在本章中,我们简要介绍了用于解决分类和回归任务的强大基于树的算法,如决策树(DTs)、梯度提升树(GBT)和随机森林(RF)。我们看到了如何使用基于树和集成技术来开发这些分类器和回归器。通过两个现实世界的分类和回归问题,我们看到了如何基于树的集成技术优于基于决策树的分类器或回归器。
我们已经涵盖了结构化和标记数据的监督学习,包括分类和回归。然而,随着云计算、物联网和社交媒体的兴起,非结构化数据正以前所未有的速度增长,超过 80%的数据,其中大部分是无标签的。
无监督学习技术,例如聚类分析和降维,是数据驱动研究和工业环境中从非结构化数据集中自动发现隐藏结构的关键应用。有许多聚类算法,如 k-means 和二分 k-means。然而,这些算法在高维输入数据集上表现不佳,并且经常遭受维度灾难的困扰。使用主成分分析(PCA)等算法降低维度并将潜在数据输入是有助于聚类数十亿数据点的。
在下一章中,我们将使用一种基因组数据来根据人群的主要祖先聚类,也称为地理种族。我们还将学习如何评估聚类分析结果,以及关于降维技术以避免维度灾难的内容。
第五章:Scala 用于降维和聚类
在前面的章节中,我们看到了几个监督学习的例子,包括分类和回归。我们在结构化和标记数据上执行了监督学习技术。然而,正如我们之前提到的,随着云计算、物联网和社交媒体的兴起,非结构化数据正在以前所未有的速度增加。总的来说,超过 80%的数据是非结构化的,其中大部分是无标签的。
无监督学习技术,如聚类分析和降维,是数据驱动研究和工业环境中寻找非结构化数据集中隐藏结构的关键应用。为此,提出了许多聚类算法,如 k-means、二分 k-means 和高斯混合模型。然而,这些算法不能在高维输入数据集上高效运行,并且经常遭受维度灾难。因此,使用主成分分析(PCA)等算法降低维度,并输入潜在数据,是聚类数十亿数据点的一种有用技术。
在本章中,我们将使用一种基因变体(一种基因组数据)根据他们的主要血统(也称为地理种族)对人群进行聚类。我们将评估聚类分析结果,然后进行降维技术,以避免维度灾难。
本章我们将涵盖以下主题:
-
无监督学习概述
-
学习聚类——聚类地理种族
-
使用 PCA 进行降维
-
使用降维数据进行聚类
技术要求
确保 Scala 2.11.x 和 Java 1.8.x 已安装并配置在您的机器上。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter05
查看以下视频,了解代码的实际应用:
无监督学习概述
在无监督学习中,在训练阶段向系统提供一个输入集。与监督学习相反,输入对象没有标记其类别。虽然在分类分析中训练数据集是标记的,但在现实世界中收集数据时,我们并不总是有这种优势,但我们仍然希望找到数据的重要值或隐藏结构。在 2016 年的 NeuralIPS 上,Facebook AI 首席科学家 Yann LeCun 介绍了蛋糕类比:
“如果智能是一块蛋糕,无监督学习就是蛋糕本身,监督学习就是蛋糕上的糖霜,强化学习就是蛋糕上的樱桃。我们知道如何制作糖霜和樱桃,但我们不知道如何制作蛋糕。”
为了创建这样的蛋糕,需要使用包括聚类、维度约简、异常检测和关联规则挖掘在内的几个无监督学习任务。如果无监督学习算法能够在不需要标签的情况下帮助在数据集中找到先前未知的模式,我们可以为这一章学习以下类比:
-
K-means 是一种流行的聚类分析算法,用于将相似数据点分组在一起
-
维度约简算法,如 PCA,有助于在数据集中找到最相关的特征
在本章中,我们将通过实际示例讨论这两种聚类分析技术。
聚类分析
聚类分析和维度约简是无监督学习的两个最流行的例子,我们将在本章中通过示例进行讨论。假设您在电脑或智能手机中有大量法律 MP3 文件。在这种情况下,如果没有直接访问它们的元数据,您如何将歌曲分组在一起?
一种可能的方法可能是混合各种机器学习技术,但聚类通常是最佳解决方案。这是因为我们可以开发一个聚类模型,以自动将相似的歌曲分组并组织到您最喜欢的类别中,例如乡村、说唱或摇滚。
尽管数据点没有标签,我们仍然可以进行必要的特征工程并将相似的对象分组在一起,这通常被称为聚类。
聚类是指根据某些相似性度量将数据点分组在一起的一组数据点。
然而,这对人类来说并不容易。相反,一种标准的方法是定义两个对象之间的相似性度量,然后寻找任何对象簇,这些对象簇之间的相似性比它们与其他簇中的对象之间的相似性更大。一旦我们对数据点(即 MP3 文件)进行了聚类(即验证完成),我们就知道了数据的模式(即哪种类型的 MP3 文件属于哪个组)。
左侧图显示了播放列表中所有的MP3 曲目,它们是分散的。右侧部分显示了基于流派如何对 MP3 进行聚类:

聚类分析算法
聚类算法的目标是将一组相似的无标签数据点分组在一起,以发现潜在的模式。以下是一些已经提出并用于聚类分析算法的算法:
-
K-means
-
二分 k-means
-
高斯混合模型(GMM)
-
幂迭代聚类(PIC)
-
潜在狄利克雷分配(LDA)
-
流式 k-means
K-means、二分 k-means 和 GMM 是最广泛使用的。我们将详细说明,以展示快速入门的聚类分析。然而,我们还将查看仅基于 k-means 的示例。
K-means 聚类分析
K-means 寻找一个固定的簇数k(即质心的数量),将数据点划分为k个簇,并通过尽可能保持质心最小来将每个数据点分配到最*的簇。
质心是一个想象中的或实际的位置,代表簇的中心。
K-means 通过最小化成本函数,称为簇内*方和(WCSS),来计算数据点到k个簇中心的距离(通常是欧几里得距离)。k-means 算法通过交替进行以下两个步骤进行:
-
簇分配步骤:每个数据点被分配到具有最小*方欧几里得距离的簇,从而产生最低的 WCSS
-
质心更新步骤:计算新簇中观测值的新均值,并将其用作新的质心
前面的步骤可以用以下图表表示:

当质心稳定或达到预定义的迭代次数时,k-means 算法完成。尽管 k-means 使用欧几里得距离,但还有其他计算距离的方法,例如:
-
切比雪夫距离可以用来通过仅考虑最显著的维度来测量距离
-
汉明距离算法可以识别两个字符串之间的差异
-
为了使距离度量不受尺度影响,可以使用马氏距离来归一化协方差矩阵
-
曼哈顿距离通过仅考虑轴对齐方向来测量距离
-
闵可夫斯基距离算法用于生成欧几里得距离、曼哈顿距离和切比雪夫距离
-
哈夫曼距离用于测量球面上两点之间的球面距离,即经纬度
二分 k-means
二分 k-means 可以看作是 k-means 和层次聚类的组合,它从单个簇中的所有数据点开始。然后,它随机选择一个簇进行分割,使用基本的 k-means 返回两个子簇。这被称为二分步骤。
二分 k-means 算法基于一篇题为“A Comparison of Document Clustering Techniques”的论文,由 Michael Steinbach 等人撰写,发表于 2000 年的 KDD 文本挖掘研讨会,该论文已被扩展以适应 Spark MLlib。
然后,对二分步骤进行预定义的次数迭代(通常由用户/开发者设置),并收集产生具有最高相似度的所有分割。这些步骤一直持续到达到所需的簇数。尽管二分 k-means 比常规 k-means 更快,但它产生的聚类不同,因为二分 k-means 随机初始化簇。
高斯混合模型
GMM 是一种概率模型,它强假设所有数据点都是由有限数量的高斯分布的混合生成的,且参数未知。因此,它也是一种基于分布的聚类算法,该算法基于期望最大化方法。
GMM 也可以被视为一种广义的 k-means,其中模型参数通过迭代优化以更好地拟合训练数据集。整个过程可以用以下三步伪代码表示:
-
目标函数:使用期望最大化(EM)计算并最大化对数似然
-
EM 步骤:这个 EM 步骤包括两个子步骤,称为期望和最大化:
-
步骤 E:计算最*数据点的后验概率
-
步骤 M:更新和优化模型参数以拟合高斯混合模型
-
-
分配:在步骤 E期间进行软分配
前面的步骤可以非常直观地表示如下:

其他聚类分析算法
其他聚类算法包括 PIC,它用于根据给定的成对相似度(如边)对图中的节点进行聚类。LDA 在文本聚类用例中经常被使用,如主题建模。
另一方面,流式 k-means 与 k-means 类似,但适用于流数据。例如,当我们想要动态估计簇,以便在新的数据到达时更新聚类分配时,使用流式 k-means 是一个好的选择。对于更详细的讨论和示例,感兴趣的读者可以参考以下链接:
-
基于 Spark ML 的聚类算法 (
spark.apache.org/docs/latest/ml-clustering.html) -
基于 Spark MLlib 的聚类算法 (
spark.apache.org/docs/latest/mllib-clustering.html)
通过示例进行聚类分析
在聚类分析中,最重要的任务之一是对基因组图谱进行分析,以将个体归入特定的种族群体,或者对疾病易感性进行核苷酸单倍型分析。根据亚洲、欧洲、非洲和美洲的基因组数据,可以区分人类祖先。研究表明,Y 染色体谱系可以在地理上定位,这为将人类基因型的等位基因进行聚类提供了证据。根据国家癌症研究所(www.cancer.gov/publications/dictionaries/genetics-dictionary/def/genetic-variant):
“遗传变异是 DNA 最常见核苷酸序列的改变。变异一词可以用来描述可能良性、致病或意义未知的改变。变异一词越来越多地被用来代替突变。”
更好地理解遗传变异有助于我们找到相关的种群群体,识别易患常见疾病的患者,以及解决罕见疾病。简而言之,想法是根据遗传变异将地理民族群体进行聚类。然而,在进一步探讨之前,让我们先了解数据。
数据集描述
1,000 基因组项目的数据是人类遗传变异的大型目录。该项目旨在确定在研究人群中频率超过 1%的遗传变异。1,000 基因组项目的第三阶段于 2014 年 9 月完成,涵盖了来自 26 个种群和 8,440,000,000 个遗传变异的 2,504 个个体。根据其主要的血统,种群样本被分为五个超级种群群体:
-
亚洲东部(CHB、JPT、CHS、CDX 和 KHV)
-
欧洲地区(CEU、TSI、FIN、GBR 和 IBS)
-
非洲地区(YRI、LWK、GWD、MSL、ESN、ASW 和 ACB)
-
美国地区(MXL、PUR、CLM 和 PEL)
-
南亚地区(GIH、PJL、BEB、STU 和 ITU)
每个基因型由 23 条染色体和一个包含样本和种群信息的单独的 PANEL 文件组成。变异调用格式(VCF)中的数据以及 PANEL 文件可以从 ftp://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/下载。
准备编程环境
由于 1,000 基因组项目的第三次发布贡献了大约 820 GB 的数据,因此需要使用可扩展的软件和硬件来处理它们。为此,我们将使用以下组件组成的软件栈:
-
ADAM:这可以用来实现支持 VCF 文件格式的可扩展基因组数据分析*台,从而将基于基因型的 RDD 转换为 Spark DataFrame。
-
Sparkling Water:H20 是一个机器学习 AI *台,以及一个支持 Java、Python 和 R 等编程语言的基于 Web 的数据处理 UI。简而言之,Sparkling Water 等于 H2O 加上 Spark。
-
基于 Spark-ML 的 k-means 用于聚类分析。
对于这个例子,我们需要使用多个技术和软件栈,例如 Spark、H2O 和 Adam。在使用 H20 之前,请确保您的笔记本电脑至少有 16 GB 的 RAM 和足够的存储空间。我将把这个解决方案作为一个 Maven 项目来开发。
让我们在pom.xml文件上定义属性标签,以适应 Maven 友好的项目:
<properties>
<spark.version>2.4.0</spark.version>
<scala.version>2.11.7</scala.version>
<h2o.version>3.22.1.1</h2o.version>
<sparklingwater.version>2.4.1</sparklingwater.version>
<adam.version>0.23.0</adam.version>
</properties>
一旦你在 Eclipse 上创建了一个 Maven 项目(从一个 IDE 或使用mvn install命令),所有必需的依赖项都将被下载!
聚类地理民族
24 个 VCF 文件贡献了大约 820 GB 的数据,这将带来巨大的计算挑战。为了克服这一点,使用最小的染色体 Y 中的遗传变异。这个 VCF 文件的大小大约为 160 MB。让我们通过创建SparkSession开始:
val spark:SparkSession = SparkSession
.builder()
.appName("PopStrat")
.master("local[*]")
.config("spark.sql.warehouse.dir", "temp/")
.getOrCreate()
现在,让我们向 Spark 展示 VCF 和 PANEL 文件的路由:
val genotypeFile = "Downloads/ALL.chr22.phase3_shapeit2_mvncall_integrated_v5a.20130502.genotypes.vcf"
val panelFile = "Downloads/integrated_call_samples_v3.20130502.ALL.panel"
我们使用 Spark 处理 PANEL 文件,以访问目标人群数据并识别人群组。首先,我们创建一组我们想要形成聚类的 populations:
val populations = Set("FIN", "GBR", "ASW", "CHB", "CLM")
然后,我们需要创建样本 ID 和给定人群之间的映射,以便我们可以过滤掉我们不感兴趣的样本:
def extract(file: String, filter: (String, String) => Boolean): Map[String, String] = {
Source
.fromFile(file)
.getLines()
.map(line => {
val tokens = line.split(Array('\t', ' ')).toList
tokens(0) -> tokens(1)
})
.toMap
.filter(tuple => filter(tuple._1, tuple._2))
}
val panel: Map[String, String] = extract(
panelFile,(sampleID: String, pop: String) => populations.contains(pop))
面板文件生成了所有个体的样本 ID、人群组、民族、超人群组和性别:

请查看面板文件的详细信息:ftp://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/integrated_call_samples_v3.20130502.ALL.panel。
然后,加载 ADAM 基因型并过滤基因型,以便我们只留下对我们感兴趣的人群中的那些:
val allGenotypes: RDD[Genotype] = sc.loadGenotypes(genotypeFile).rdd
val genotypes: RDD[Genotype] = allGenotypes.filter(genotype => {
panel.contains(genotype.getSampleId)
})
接下来,将 Genotype 对象转换为我们的 SampleVariant 对象以节省内存。然后,将 genotype 对象转换为包含需要进一步处理的数据的 SampleVariant 对象:
-
样本 ID:用于唯一标识特定的样本
-
变异 ID:用于唯一标识特定的遗传变异
-
替代等位基因计数:当样本与参考基因组不同时需要
准备 SampleVariant 的签名如下,它接受 sampleID、variationId 和 alternateCount 对象:
// Convert the Genotype objects to our own SampleVariant objects to try and conserve memory
case class SampleVariant(sampleId: String, variantId: Int, alternateCount: Int)
然后,我们必须从基因型文件中找到 variantID。varitantId 是一个由名称、染色体中的起始和结束位置组成的字符串类型:
def variantId(genotype: Genotype): String = {
val name = genotype.getVariant.getContigName
val start = genotype.getVariant.getStart
val end = genotype.getVariant.getEnd
s"$name:$start:$end"
}
一旦我们有了 variantID,我们就应该寻找 alternateCount。在基因型文件中,具有等位基因参考的对象将是遗传替代物:
def alternateCount(genotype: Genotype): Int = {
genotype.getAlleles.asScala.count(_ != GenotypeAllele.REF)
}
最后,我们将构建一个 SampleVariant 对象。为此,我们需要将样本 ID 内部化,因为它们在 VCF 文件中会重复很多次:
def toVariant(genotype: Genotype): SampleVariant = {
new SampleVariant(genotype.getSampleId.intern(),
variantId(genotype).hashCode(),
alternateCount(genotype))
}
现在,我们需要准备 variantsRDD。首先,我们必须按样本 ID 对变异进行分组,以便我们可以逐个处理变异。然后,我们可以获取用于查找某些样本缺失变异的总样本数。最后,我们必须按变异 ID 对变异进行分组,并过滤掉某些样本缺失的变异:
val variantsRDD: RDD[SampleVariant] = genotypes.map(toVariant)
val variantsBySampleId: RDD[(String, Iterable[SampleVariant])] = variantsRDD.groupBy(_.sampleId)
val sampleCount: Long = variantsBySampleId.count()
println("Found " + sampleCount + " samples")
val variantsByVariantId: RDD[(Int, Iterable[SampleVariant])] =
variantsRDD.groupBy(_.variantId).filter {
case (_, sampleVariants) => sampleVariants.size == sampleCount
}
现在,让我们将 variantId 与具有大于零的替代计数的样本数量进行映射。然后,我们过滤掉不在我们期望的频率范围内的变异。这里的目的是减少数据集的维度数量,使其更容易训练模型:
val variantFrequencies: collection.Map[Int, Int] = variantsByVariantId
.map {
case (variantId, sampleVariants) =>
(variantId, sampleVariants.count(_.alternateCount > 0))
}
.collectAsMap()
样本总数(或个体数)已经确定。现在,在根据变异 ID 对它们进行分组之前,我们可以过滤掉不太重要的变异。由于我们有超过 8400 万个遗传变异,过滤可以帮助我们处理维度诅咒。
指定的范围是任意的,因为它包括合理数量的变体,但不是太多。更具体地说,对于每个变体,已经计算了等位基因的频率,并且排除了具有少于 12 个等位基因的变体,从而在分析中留下了大约 3,000,000 个变体(对于 23 个染色体文件):
val permittedRange = inclusive(11, 11) // variants with less than 12 alternate alleles
val filteredVariantsBySampleId: RDD[(String, Iterable[SampleVariant])] =
variantsBySampleId.map {
case (sampleId, sampleVariants) =>
val filteredSampleVariants = sampleVariants.filter(
variant =>
permittedRange.contains(
variantFrequencies.getOrElse(variant.variantId, -1)))
(sampleId, filteredSampleVariants)
}
一旦我们有了 filteredVariantsBySampleId,我们需要对每个样本 ID 的变体进行排序。每个样本现在应该具有相同数量的排序变体:
val sortedVariantsBySampleId: RDD[(String, Array[SampleVariant])] =
filteredVariantsBySampleId.map {
case (sampleId, variants) =>
(sampleId, variants.toArray.sortBy(_.variantId))
}
println(s"Sorted by Sample ID RDD: " + sortedVariantsBySampleId.first())
RDD 中的所有项现在都应该具有相同的变体,并且顺序相同。最终任务是使用 sortedVariantsBySampleId 来构建一个包含区域和等位基因计数的行 RDD:
val rowRDD: RDD[Row] = sortedVariantsBySampleId.map {
case (sampleId, sortedVariants) =>
val region: Array[String] = Array(panel.getOrElse(sampleId, "Unknown"))
val alternateCounts: Array[Int] = sortedVariants.map(_.alternateCount)
Row.fromSeq(region ++ alternateCounts)
}
因此,我们只需使用第一个来构建我们的训练 DataFrame 的标题:
val header = StructType(
Array(StructField("Region", StringType)) ++
sortedVariantsBySampleId
.first()
._2
.map(variant => {
StructField(variant.variantId.toString, IntegerType)
}))
干得好!我们有了我们的 RDD 和 StructType 标题。现在,我们可以用最小的调整/转换来玩 Spark 机器学习算法。
训练 k-means 算法
一旦我们有了 rowRDD 和标题,我们需要使用标题和 rowRDD 从变体中构建我们的模式 DataFrame 的行:
// Create the SchemaRDD from the header and rows and convert the SchemaRDD into a Spark DataFrame
val sqlContext = sparkSession.sqlContext
var schemaDF = sqlContext.createDataFrame(rowRDD, header)
schemaDF.show(10)
>>>
前面的 show() 方法应该显示包含特征和 label 列(即 Region)的训练数据集快照:

在前面的 DataFrame 中,只显示了少数 feature 列和 label 列,以便它适合页面。由于训练将是无监督的,我们需要删除 label 列(即 Region):
schemaDF = sqlContext.createDataFrame(rowRDD, header).drop("Region")
schemaDF.show(10)
>>>
前面的 show() 方法显示了以下 k-means 的训练数据集快照。注意,没有 label 列(即 Region):

在第一章,《使用 Scala 的机器学习入门》,和第二章,《Scala 回归分析》,我们了解到 Spark 预期用于监督训练有两个列(features 和 label)。然而,对于无监督训练,只需要包含特征的单一列。由于我们删除了 label 列,我们现在需要将整个 variable 列合并为一个单一的 features 列。为此,我们将使用 VectorAssembler() 转换器。让我们选择要嵌入到向量空间中的列:
val featureCols = schemaDF.columns
然后,我们将通过指定输入列和输出列来实例化 VectorAssembler() 转换器:
// Using vector assembler to create feature vector
val featureCols = schemaDF.columns
val assembler = new VectorAssembler()
.setInputCols(featureCols)
.setOutputCol("features")
val assembleDF = assembler.transform(schemaDF).select("features")
现在,让我们看看 k-means 的特征向量是什么样的:
assembleDF.show()
前面的行显示了组装的向量,这些向量可以用作 k-means 模型的特征向量:

最后,我们准备好训练 k-means 算法并通过计算 WCSS 来评估聚类:
val kmeans = new KMeans().setK(5).setSeed(12345L)
val model = kmeans.fit(assembleDF)
val WCSS = model.computeCost(assembleDF)
println("Within Set Sum of Squared Errors for k = 5 is " + WCSS)
}
下面的 WCSS 值为 k = 5:
Within Set Sum of Squared Errors for k = 5 is 59.34564329865
我们成功地将 k-means 应用于聚类遗传变异。然而,我们注意到 WCSS 很高,因为 k-means 无法分离不同相关的高维特征之间的非线性。这是因为基因组测序数据集由于大量的遗传变异而具有非常高的维度。
在下一节中,我们将看到如何使用降维技术,如 PCA,在将数据输入到 k-means 之前降低输入数据的维度,以获得更好的聚类质量。
降维
由于人类是视觉生物,理解高维数据集(甚至超过三个维度)是不可能的。即使是对于机器(或者说,我们的机器学习算法),也很难从相关的高维特征中建模非线性。在这里,降维技术是一个救星。
从统计学的角度来看,降维是减少随机变量的数量,以找到数据的一个低维表示,同时尽可能保留尽可能多的信息。
PCA 的整体步骤可以在以下图表中直观地表示:

主成分分析(PCA)和奇异值分解(SVD)是降维中最受欢迎的算法。从技术上讲,PCA 是一种用于强调变异并从数据集中提取最显著模式(即特征)的统计技术,这不仅对聚类有用,对分类和可视化也有帮助。
基于 Spark ML 的主成分分析
基于 Spark-ML 的 PCA 可以用来将向量投影到低维空间,在将它们输入到 k-means 模型之前降低遗传变异特征的维度。以下示例展示了如何将以下特征向量投影到 4 维主成分:
val data = Array(
Vectors.dense(1.2, 3.57, 6.8, 4.5, 2.25, 3.4),
Vectors.dense(4.60, 4.10, 9.0, 5.0, 1.67, 4.75),
Vectors.dense(5.60, 6.75, 1.11, 4.5, 2.25, 6.80))
val df = spark.createDataFrame(data.map(Tuple1.apply)).toDF("features")
df.show(false)
现在我们有一个具有 6 维特征向量的特征 DataFrame,它可以被输入到 PCA 模型中:

首先,我们必须通过设置必要的参数来实例化 PCA 模型,如下所示:
val pca = new PCA()
.setInputCol("features")
.setOutputCol("pcaFeatures")
.setK(4)
.fit(df)
为了区分原始特征和基于主成分的特征,我们使用setOutputCol()方法将输出列名设置为pcaFeatures。然后,我们设置 PCA 的维度(即主成分的数量)。最后,我们将 DataFrame 拟合以进行转换。可以从旧数据中加载模型,但explainedVariance将会有一个空向量。现在,让我们展示生成的特征:
val result = pca.transform(df).select("features", "pcaFeatures")
result.show(false)
上述代码使用 PCA 生成一个具有 4 维特征向量的特征 DataFrame,作为主成分:

同样,我们可以将上一步组装的 DataFrame(即assembleDF)和前五个主成分进行转换。你可以调整主成分的数量。
最后,为了避免任何歧义,我们将 pcaFeatures 列重命名为 features:
val pcaDF = pca.transform(assembleDF)
.select("pcaFeatures")
.withColumnRenamed("pcaFeatures", "features")
pcaDF.show()
上述代码行显示了嵌入的向量,这些向量可以用作 k-means 模型的特征向量:

上述截图显示了前五个主成分作为最重要的特征。太好了——一切顺利。最后,我们准备训练 k-means 算法并通过计算 WCSS 来评估聚类:
val kmeans = new KMeans().setK(5).setSeed(12345L)
val model = kmeans.fit(pcaDF)
val WCSS = model.computeCost(pcaDF)
println("Within Set Sum of Squared Errors for k = 5 is " + WCSS)
}
这次,WCSS 略微降低(与之前的值 59.34564329865 相比):
Within Set Sum of Squared Errors for k = 5 is 52.712937492025276
通常,我们随机设置 k 的数量(即 5)并计算 WCSS。然而,这种方法并不能总是设置最佳聚类数量。为了找到一个最佳值,研究人员提出了两种技术,称为肘部方法和轮廓分析,我们将在下一小节中探讨。
确定最佳聚类数量
有时候,在开始训练之前天真地假设聚类数量可能不是一个好主意。如果假设与最佳聚类数量相差太远,模型会因为引入的过拟合或欠拟合问题而表现不佳。因此,确定最佳聚类数量是一个独立的优化问题。有两种流行的技术来解决此问题:
-
被称为肘部方法的启发式方法
-
轮廓分析,用于观察预测聚类的分离距离
肘部方法
我们首先将 k 值设置为 2,并在相同的数据集上运行 k-means 算法,通过增加 k 并观察 WCSS 的值。正如预期的那样,成本函数(即 WCSS 值)在某一点应该会有一个急剧下降。然而,在急剧下降之后,随着 k 值的增加,WCSS 的值变得微不足道。正如肘部方法所建议的,我们可以在 WCSS 的最后一次大幅下降后选择 k 的最佳值:
val iterations = 20
for (k <- 2 to iterations) {
// Trains a k-means model.
val kmeans = new KMeans().setK(k).setSeed(12345L)
val model = kmeans.fit(pcaDF)
// Evaluate clustering by computing Within Set Sum of Squared Errors.
val WCSS = model.computeCost(pcaDF)
println("Within Set Sum of Squared Errors for k = " + k + " is " + WCSS)
}
现在,让我们看看不同数量聚类(例如 2 到 20)的 WCSS 值:
Within Set Sum of Squared Errors for k = 2 is 135.0048361804504
Within Set Sum of Squared Errors for k = 3 is 90.95271589232344
...
Within Set Sum of Squared Errors for k = 19 is 11.505990055606803
Within Set Sum of Squared Errors for k = 20 is 12.26634441065655
如前述代码所示,我们计算了成本函数 WCSS 作为聚类数量的函数,并将其应用于所选种群组的 Y 染色体遗传变异。可以观察到,当 k = 5 时会出现一个大的下降(尽管不是急剧下降)。因此,我们选择聚类数量为 10。
轮廓分析
通过观察预测聚类的分离距离来分析轮廓。绘制轮廓图将显示数据点与其邻*聚类之间的距离,然后我们可以通过视觉检查多个聚类,以便相似的数据点得到良好的分离。
轮廓得分,用于衡量聚类质量,其范围为 [-1, 1]。通过计算轮廓得分来评估聚类质量:
val evaluator = new ClusteringEvaluator()
for (k <- 2 to 20 by 1) {
val kmeans = new KMeans().setK(k).setSeed(12345L)
val model = kmeans.fit(pcaDF)
val transformedDF = model.transform(pcaDF)
val score = evaluator.evaluate(transformedDF)
println("Silhouette with squared Euclidean distance for k = " + k + " is " + score)
}
我们得到以下输出:
Silhouette with squared Euclidean distance for k = 2 is 0.9175803927739566
Silhouette with squared Euclidean distance for k = 3 is 0.8288633816548874
....
Silhouette with squared Euclidean distance for k = 19 is 0.5327466913746908
Silhouette with squared Euclidean distance for k = 20 is 0.45336547054142284
如前述代码所示,轮廓的高度值是通过k = 2生成的,为0.9175803927739566。然而,这表明遗传变异应该分为两组。肘部方法建议k = 5作为最佳聚类数量。
让我们使用*方欧几里得距离来找出轮廓,如下面的代码块所示:
val kmeansOptimal = new KMeans().setK(2).setSeed(12345L)
val modelOptimal = kmeansOptimal.fit(pcaDF)
// Making predictions
val predictionsOptimalDF = modelOptimal.transform(pcaDF)
predictionsOptimalDF.show()
// Evaluate clustering by computing Silhouette score
val evaluatorOptimal = new ClusteringEvaluator()
val silhouette = evaluatorOptimal.evaluate(predictionsOptimalDF)
println(s"Silhouette with squared Euclidean distance = $silhouette")
k = 2的*方欧几里得距离轮廓值为0.9175803927739566。
已经发现,分割 k 均值算法可以对数据点的聚类分配产生更好的结果,收敛到全局最小值。另一方面,k 均值算法往往陷入局部最小值。请注意,根据您的机器硬件配置和数据集的随机性,您可能会观察到前面参数的不同值。
感兴趣的读者还应参考基于 Spark-MLlib 的聚类技术spark.apache.org/docs/latest/mllib-clustering.html,以获得更多见解。
摘要
在本章中,我们讨论了一些聚类分析方法,如 k 均值、分割 k 均值和 GMM。我们看到了如何根据遗传变异对族群进行聚类的逐步示例。特别是,我们使用了 PCA 进行降维,k 均值进行聚类,以及 H2O 和 ADAM 处理大规模基因组数据集。最后,我们学习了肘部和轮廓方法来寻找最佳聚类数量。
聚类是大多数数据驱动应用的关键。读者可以尝试在更高维度的数据集上应用聚类算法,例如基因表达或 miRNA 表达,以聚类相似和相关的基因。一个很好的资源是基因表达癌症 RNA-Seq 数据集,它是开源的。此数据集可以从 UCI 机器学习存储库下载,网址为archive.ics.uci.edu/ml/datasets/gene+expression+cancer+RNA-Seq。
在下一章中,我们将讨论推荐系统中的基于物品的协同过滤方法。我们将学习如何开发一个图书推荐系统。技术上,它将是一个基于 Scala 和 Spark 的模型推荐引擎。我们将看到如何实现 ALS 和矩阵分解之间的互操作。
第六章:用于推荐系统的 Scala
在本章中,我们将学习开发推荐系统的不同方法。然后我们将学习如何开发一个书籍推荐系统。技术上,它将是一个基于交替最小二乘法(ALS)和矩阵分解算法的模型推荐引擎。我们将使用基于 Spark MLlib 的这些算法的 Scala 实现。简而言之,我们将在本章中学习以下主题:
-
推荐系统概述
-
基于相似度的推荐系统
-
基于内容的推荐系统
-
协同方法
-
混合推荐系统
-
开发基于模型的书籍推荐系统
技术要求
确保 Scala 2.11.x 和 Java 1.8.x 已安装并配置在您的机器上。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter06
查看以下视频以查看代码的实际应用:
推荐系统概述
推荐系统是一种信息过滤方法,它预测用户对项目的评分。然后,预测评分高的项目将被推荐给用户。推荐系统现在在推荐电影、音乐、新闻、书籍、研究文章、产品、视频、书籍、新闻、Facebook 朋友、餐厅、路线、搜索查询、社交标签、产品、合作伙伴、笑话、餐厅、服装、金融服务、Twitter 页面、Android/iOS 应用、酒店、人寿保险,甚至在在线约会网站上被或多或少地使用。
推荐系统的类型
开发推荐引擎有几种方法,通常会产生一个推荐列表,如以下图中所示的基于相似度、基于内容、协同和混合推荐系统:

我们将讨论基于相似度、基于内容、协同和混合推荐系统。然后基于它们的优缺点,我们将通过一个实际示例展示如何开发一个书籍推荐系统。
基于相似度的推荐系统
基于相似度的两种主要方法:用户-用户相似度和用户-项目相似度。这些方法可以用来构建推荐系统。要使用用户-用户项目相似度方法,首先构建一个用户-用户相似度矩阵。然后它会选择那些被相似用户喜欢的项目,最后为特定用户推荐项目。
假设我们想要开发一个图书推荐系统:自然地,会有许多图书用户(读者)和一系列图书。为了简洁起见,让我们选择以下与机器学习相关的图书作为读者的代表:

然后,基于用户-用户相似度的推荐系统将根据某些相似度度量技术使用相似度度量来推荐图书。例如,余弦相似度的计算如下:

在前面的方程中,A 和 B 代表两个用户。如果相似度阈值大于或等于定义的阈值,用户 A 和 B 很可能具有相似偏好:

然而,基于用户-用户相似度的推荐系统并不稳健。有以下几个原因:
-
用户偏好和口味通常会随时间变化
-
由于需要从非常稀疏的矩阵计算中计算许多案例的相似度,因此它们在计算上非常昂贵
亚马逊和 YouTube 拥有数百万的订阅用户,因此你创建的任何用户-用户效用矩阵都将是一个非常稀疏的矩阵。一种解决方案是使用项目-项目相似度,这也会计算出一个项目-项目效用矩阵,找到相似的项目,最后推荐相似的项目,就像以下图示:

这种方法与用户-用户相似度方法相比有一个优点,即通常在初始阶段之后,给定项目的评分不会发生很大的变化。以《百页机器学习书》为例,尽管它只发布了几个月,但在亚马逊上已经获得了非常好的评分。因此,即使在未来几个月内,有几个人给出了较低的评分,其评分在初始阶段之后也不会有太大变化。
有趣的是,这也是一个假设,即评分在一段时间内不会发生很大的变化。然而,这个假设在用户数量远多于项目数量的情况下非常有效。
基于内容的过滤方法
基于内容的过滤方法基于经典的机器学习技术,如分类或回归。这类系统学习如何表示一个项目(图书)I[j] 和一个用户 U[i]。然后,在将它们组合为特征向量之前,为 I[j] 和 U[i] 创建单独的特征矩阵。然后,将特征向量输入到训练的分类或回归模型中。这样,ML 模型生成标签 L[ij],这有趣的是用户 U[i] 对项目 I[j] 给出的相应评分:

一个一般的警告是,应该创建特征,以便它们对评分(标签)有直接影响。这意味着特征应该尽可能依赖,以避免相关性。
协同过滤方法
协同过滤的想法是,当我们有很多喜欢某些物品的用户时,这些物品可以推荐给尚未看到它们的用户。假设我们有四位读者和四本书,如下面的图所示:

此外,想象所有这些用户都购买了物品 1(即使用 TensorFlow 进行预测分析)和物品 2(即使用 TensorFlow 进行深度学习)。现在,假设用户 4阅读了物品 1、2 和 3,而用户 1和用户 2购买了物品 3(即精通机器学习算法)。然而,由于用户 4尚未看到物品 4(即Python 机器学习),用户 3可以向他推荐它。
因此,基本假设是,之前推荐过物品的用户倾向于在将来也给出推荐。如果这个假设不再成立,那么就无法构建协同过滤推荐系统。这可能是协同过滤方法遭受冷启动、可扩展性和稀疏性问题的主要原因。
冷启动:协同过滤方法可能会陷入困境,无法进行推荐,尤其是在用户-物品矩阵中缺少大量用户数据时。
效用矩阵
假设我们有一组用户,他们偏好一组书籍。用户对书籍的偏好越高,评分就越高,介于 1 到 10 之间。让我们尝试使用矩阵来理解这个问题,其中行代表用户,列代表书籍:

假设评分范围从 1 到 10,10 是最高偏好级别。那么,在先前的表中,用户(第 1 行)对第一本书(第 1 列)给出了7的评分,对第二本书评分为6。还有许多空单元格,表示用户没有对那些书籍进行任何评分。
这个矩阵通常被称为用户-物品或效用矩阵,其中每一行代表一个用户,每一列代表一个物品(书籍),而单元格代表用户对该物品给出的相应评分。
在实践中,效用矩阵非常稀疏,因为大量单元格是空的。原因是物品数量众多,单个用户几乎不可能对所有物品进行评分。即使一个用户对 10%的物品进行了评分,这个矩阵的其他 90%的单元格仍然为空。这些空单元格通常用 NaN 表示,即不是一个数字,尽管在我们的效用矩阵示例中我们使用了?。这种稀疏性通常会创建计算复杂性。让我给你举个例子。
假设有 100 万用户(n)和 10,000 个项目(电影,m),这是10,000,000 * 10,000或10¹¹,一个非常大的数字。现在,即使一个用户评了 10 本书,这也意味着总的评分数量将是10 * 1 百万 = 10⁷。这个矩阵的稀疏度可以计算如下:
S[m ]= 空单元格数 / 总单元格数 = (10^(10 )- 10⁷)/10¹⁰ = 0.9999
这意味着 99.99%的单元格仍然为空。
基于模型的书籍推荐系统
在本节中,我们将展示如何使用 Spark MLlib 库开发一个基于模型的书籍推荐系统。书籍及其对应的评分是从以下链接下载的:www2.informatik.uni-freiburg.de/~cziegler/BX/。这里有三个 CSV 文件:
-
BX-Users.csv: 包含用户的统计数据,每个用户都指定了用户 ID(User-ID)。 -
BX-Books.csv: 包含书籍相关信息,如Book-Title、Book-Author、Year-Of-Publication和Publisher。每本书都有一个 ISBN 标识。此外,还提供了Image-URL-S、Image-URL-M和Image-URL-L。 -
BX-Book-Ratings.csv: 包含由Book-Rating列指定的评分。评分在1到10的范围内(数值越高表示越高的评价),或者隐式表达为0。
在我们进入编码部分之前,我们需要了解一些关于矩阵分解技术,如奇异值分解(SVD)的更多信息。SVD 可以将项目和用户条目转换到相同的潜在空间,这代表了用户和项目之间的交互。矩阵分解背后的原理是潜在特征表示用户如何评分项目。
矩阵分解
因此,给定用户和项目的描述,这里的任务是预测用户将如何评分那些尚未评分的项目。更正式地说,如果用户U[i]喜欢项目V[1]、V[5]和V[7],那么任务就是向用户U[i]推荐他们可能也会喜欢的项目V[j],如图所示:

一旦我们有了这样的应用,我们的想法是每次我们收到新的数据时,我们将其更新到训练数据集,然后更新通过 ALS 训练获得的模型,其中使用了协同过滤方法。为了处理用户-书籍效用矩阵,使用了一个低秩矩阵分解算法:

由于并非所有书籍都被所有用户评分,这个矩阵中的并非所有条目都是已知的。前面章节中讨论的协同过滤方法在这里作为救星出现。嗯,使用协同过滤,我们可以解决一个优化问题,通过分解用户因素(V)和书籍因素(V)来*似评分矩阵,如下所示:

这两个矩阵被选择,使得用户-书籍对(在已知评分的情况下)的错误最小化。ALS 算法首先用随机值(在我们的案例中是 1 到 10 之间)填充用户矩阵,然后优化这些值以使错误最小化。然后 ALS 将书籍矩阵保持固定,并使用以下数学方程优化用户矩阵的值:

Spark MLlib 支持基于模型的协同过滤方法。在这种方法中,用户和物品由一组小的潜在因素来描述,以预测用户-物品效用矩阵中缺失的条目。如前所述,ALS 算法可以通过迭代方式学习这些潜在因素。ALS 算法接受六个参数,即numBlocks、rank、iterations、lambda、implicitPrefs和alpha。numBlocks是并行计算所需的块数。rank参数是潜在因素的数量。iterations参数是 ALS 收敛所需的迭代次数。lambda参数表示正则化参数。implicitPrefs参数表示我们希望使用其他用户的显式反馈,最后,alpha是偏好观察的基线置信度。
探索性分析
在本小节中,我们将对评分、书籍和相关统计进行一些探索性分析。这种分析将帮助我们更好地理解数据:
val ratigsFile = "data/BX-Book-Ratings.csv"
var ratingDF = spark.read.format("com.databricks.spark.csv")
.option("delimiter", ";")
.option("header", true)
.load(ratigsFile)
以下代码片段显示了来自BX-Books.csv文件的书籍 DataFrame:
/* Explore and query on books */
val booksFile = "data/BX-Books.csv"
var bookDF = spark.read.format("com.databricks.spark.csv")
.option("header", "true")
.option("delimiter", ";")
.load(booksFile)
bookDF = bookDF.select(bookDF.col("ISBN"),
bookDF.col("Book-Title"),
bookDF.col("Book-Author"),
bookDF.col("Year-Of-Publication"))
bookDF = bookDF.withColumnRenamed("Book-Title", "Title")
.withColumnRenamed("Book-Author", "Author")
.withColumnRenamed("Year-Of-Publication", "Year")
bookDF.show(10)
以下为输出结果:

让我们看看有多少独特的书籍:
val numDistinctBook = bookDF.select(bookDF.col("ISBN")).distinct().count()
println("Got " + numDistinctBook + " books")
以下为输出结果:
Got 271,379 books
这些信息对于后续案例将非常有价值,这样我们就可以知道在评分数据集中有多少书籍缺少评分。为了注册这两个数据集,我们可以使用以下代码:
ratingsDF.createOrReplaceTempView("ratings")
moviesDF.createOrReplaceTempView("books")
这将通过创建一个临时视图作为内存中的表来加快内存查询速度。让我们检查与评分相关的统计信息。只需使用以下代码行:
/* Explore and query ratings for books */
val numRatings = ratingDF.count()
val numUsers = ratingDF.select(ratingDF.col("UserID")).distinct().count()
val numBooks = ratingDF.select(ratingDF.col("ISBN")).distinct().count()
println("Got " + numRatings + " ratings from " + numUsers + " users on " + numBooks + " books")
您应该找到“从 105283 个用户对 340556 本书进行了 1149780 次评分”。现在,让我们获取最大和最小评分,以及评分书籍的用户数量:
// Get the max, min ratings along with the count of users who have rated a book.
val statDF = spark.sql("select books.Title, bookrates.maxRating, bookrates.minRating, bookrates.readerID "
+ "from(SELECT ratings.ISBN,max(ratings.Rating) as maxRating,"
+ "min(ratings.Rating) as minRating,count(distinct UserID) as readerID "
+ "FROM ratings group by ratings.ISBN) bookrates "
+ "join books on bookrates.ISBN=books.ISBN " + "order by bookrates.readerID desc")
statDF.show(10)
前面的代码应该生成最大和最小评分,以及评分书籍的用户数量:

现在,为了获得更深入的洞察,我们需要更多地了解用户及其评分,这可以通过找到最活跃的十个用户以及他们为书籍评分的次数来实现:
// Show the top 10 most-active users and how many times they rated a book
val mostActiveReaders = spark.sql("SELECT ratings.UserID, count(*) as CT from ratings "
+ "group by ratings.UserID order by CT desc limit 10")
mostActiveReaders.show()
前面的代码行应该显示最活跃的十个用户以及他们为书籍评分的次数:

现在,让我们查看一个特定的用户,并找到那些用户130554评分高于5的书籍:
// Find the movies that user 130554 rated higher than 5
val ratingBySpecificReader = spark.sql(
"SELECT ratings.UserID, ratings.ISBN,"
+ "ratings.Rating, books.Title FROM ratings JOIN books "
+ "ON books.ISBN=ratings.ISBN "
+ "WHERE ratings.UserID=130554 and ratings.Rating > 5")
ratingBySpecificReader.show(false)
如描述,上述代码行应显示用户 130554 评分超过 5 分的所有电影名称:

准备训练和测试评分数据
以下代码将评分 RDD 分割为训练数据 RDD(60%)和测试数据 RDD(40%)。第二个参数(即1357L)是种子,通常用于可重复性目的:
val splits = ratingDF.randomSplit(Array(0.60, 0.40), 1357L)
val (trainingData, testData) = (splits(0), splits(1))
trainingData.cache
testData.cache
val numTrainingSample = trainingData.count()
val numTestSample = testData.count()
println("Training: " + numTrainingSample + " test: " + numTestSample)
你会看到训练 DataFrame 中有 689,144 个评分,测试 DataFrame 中有 345,774 个评分。ALS 算法需要训练的评分 RDD。以下代码展示了如何使用 API 构建推荐模型:
val trainRatingsRDD = trainingData.rdd.map(row => {
val userID = row.getString(0)
val ISBN = row.getInt(1)
val ratings = row.getString(2)
Rating(userID.toInt, ISBN, ratings.toDouble)
})
trainRatingsRDD是一个包含UserID、ISBN以及对应评分的 RDD,这些评分来自我们在前一步准备的训练数据集。同样,我们还从测试 DataFrame 中准备了一个另一个 RDD:
val testRatingsRDD = testData.rdd.map(row => {
val userID = row.getString(0)
val ISBN = row.getInt(1)
val ratings = row.getString(2)
Rating(userID.toInt, ISBN, ratings.toDouble)
})
基于上述trainRatingsRDD,我们通过添加最大迭代次数、块的数量、alpha、rank、lambda、seed 和隐式偏好来构建一个 ALS 用户模型。这种方法通常用于分析和预测特定用户的缺失评分:
val model : MatrixFactorizationModel = new ALS()
.setIterations(10)
.setBlocks(-1)
.setAlpha(1.0)
.setLambda(0.01)
.setRank(25)
.setSeed(1234579L)
.setImplicitPrefs(false) // We want explicit feedback
.run(trainRatingsRDD)
最后,我们迭代模型进行学习10次。在这个设置下,我们得到了良好的预测准确度。建议读者应用超参数调整以找到这些参数的最佳值。为了评估模型的质量,我们计算均方根误差(RMSE)。以下代码计算了使用训练集开发的模型的 RMSE 值:
var rmseTest = computeRmse(model, testRatingsRDD, true)
println("Test RMSE: = " + rmseTest) //Less is better
对于上述设置,我们得到以下输出:
Test RMSE: = 1.6867585251053991
前述方法计算 RMSE 来评估模型。RMSE 越低,模型及其预测能力越好,如下所示:
//Compute the RMSE to evaluate the model. Less the RMSE better the model and it's prediction capability.
def computeRmse(model: MatrixFactorizationModel, ratingRDD: RDD[Rating], implicitPrefs: Boolean): Double = {
val predRatingRDD: RDD[Rating] = model.predict(ratingRDD.map(entry => (entry.user, entry.product)))
val predictionsAndRatings = predRatingRDD.map {entry => ((entry.user, entry.product), entry.rating)}
.join(ratingRDD
.map(entry => ((entry.user, entry.product), entry.rating)))
.values
math.sqrt(predictionsAndRatings.map(x => (x._1 - x._2) * (x._1 - x._2)).mean()) // return MSE
}
最后,让我们为特定用户做一些电影推荐。让我们获取用户276747的前十本书的预测:
println("Recommendations: (ISBN, Rating)")
println("----------------------------------")
val recommendationsUser = model.recommendProducts(276747, 10)
recommendationsUser.map(rating => (rating.product, rating.rating)).foreach(println)
println("----------------------------------")
我们得到以下输出:
Recommendations: (ISBN => Rating)
(1051401851,15.127044702142243)
(2056910662,15.11531283195148)
(1013412890,14.75898119158678)
(603241602,14.53024153450836)
(1868529062,14.180262929540024)
(746990712,14.121654522195225)
(1630827789,13.741728003481194)
(1179316963,13.571754513473993)
(505970947,13.506755847456258)
(632523982,13.46591014905454)
----------------------------------
我们相信前述模型的表现可以进一步提高。然而,据我们所知,MLlib 基于的 ALS 算法没有可用的模型调整功能。
想要了解更多关于调整基于 ML 的 ALS 模型的信息的读者应参考spark.apache.org/docs/preview/ml-collaborative-filtering.html
添加新的用户评分和进行新的预测
我们可以创建一个新用户 ID、书的 ISBN 和上一步预测的评分的序列:
val new_user_ID = 300000 // new user ID randomly chosen
//The format of each line is (UserID, ISBN, Rating)
val new_user_ratings = Seq(
(new_user_ID, 817930596, 15.127044702142243),
(new_user_ID, 1149373895, 15.11531283195148),
(new_user_ID, 1885291767, 14.75898119158678),
(new_user_ID, 459716613, 14.53024153450836),
(new_user_ID, 3362860, 14.180262929540024),
(new_user_ID, 1178102612, 14.121654522195225),
(new_user_ID, 158895996, 13.741728003481194),
(new_user_ID, 1007741925, 13.571754513473993),
(new_user_ID, 1033268461, 13.506755847456258),
(new_user_ID, 651677816, 13.46591014905454))
val new_user_ratings_RDD = spark.sparkContext.parallelize(new_user_ratings)
val new_user_ratings_DF = spark.createDataFrame(new_user_ratings_RDD).toDF("UserID", "ISBN", "Rating")
val newRatingsRDD = new_user_ratings_DF.rdd.map(row => {
val userId = row.getInt(0)
val movieId = row.getInt(1)
val ratings = row.getDouble(2)
Rating(userId, movieId, ratings)
})
现在我们将它们添加到我们将用于训练推荐模型的原始数据中。我们使用 Spark 的union()转换来完成这个操作:
val complete_data_with_new_ratings_RDD = trainRatingsRDD.union(newRatingsRDD)
最后,我们使用之前(在小数据集使用时)选定的所有参数来训练 ALS 模型:
val newModel : MatrixFactorizationModel = new ALS()
.setIterations(10)
.setBlocks(-1)
.setAlpha(1.0)
.setLambda(0.01)
.setRank(25)
.setSeed(123457L)
.setImplicitPrefs(false)
.run(complete_data_with_new_ratings_RDD)
每当用户添加新的评分时,我们都需要重复这个过程。理想情况下,我们将批量处理,而不是为每个用户系统中每个单独的评分进行处理。然后我们可以再次为其他用户,例如之前缺少评分的276724,提供推荐:
// Making Predictions. Get the top 10 book predictions for user 276724
//Book recommendation for a specific user. Get the top 10 book predictions for reader 276747
println("Recommendations: (ISBN, Rating)")
println("----------------------------------")
val newPredictions = newModel.recommendProducts(276747, 10)
newPredictions.map(rating => (rating.product, rating.rating)).foreach(println)
println("----------------------------------")
以下为输出结果:
Recommendations: (ISBN, Rating)
----------------------------------
(1901261462,15.48152758068679)
(1992983531,14.306018295431224)
(1438448913,14.05457411015043)
(2022242154,13.516608439192192)
(817930596,13.487733919030019)
(1079754533,12.991618591680165)
(611897245,12.716161072778828)
(11041460,12.44511878072316)
(651596038,12.13345082904184)
(1955775932,11.7254312955358)
----------------------------------
最后,我们计算 RMSE:
var newrmseTest = computeRmse(newModel, testRDD, true)
println("Test RMSE: = " + newrmseTest) //Less is better
以下为输出结果:
Test RMSE: = 4.892434600794704
摘要
在本章中,我们学习了推荐系统的不同方法,例如基于相似度、基于内容、协同过滤和混合。此外,我们还讨论了这些方法的缺点。然后我们实现了一个端到端的书籍推荐系统,这是一个基于 Spark 的模型推荐系统。我们还看到了如何高效地处理效用矩阵,通过在 ALS 和矩阵分解之间进行交互操作。
在下一章中,我们将解释深度学习(DL)的一些基本概念,它是机器学习(ML)的一个新兴分支。我们将简要讨论一些最著名和最广泛使用的神经网络架构。然后,我们将探讨深度学习框架和库的各种特性。
然后,我们将了解如何准备编程环境,在开始使用一些开源深度学习库(如Deeplearning4j(DL4J))进行编码之前。最后,我们将使用两种神经网络架构,即多层感知器(MLP)和长短期记忆(LSTM),来解决一个现实生活中的问题。
第七章:使用 Scala 进行回归分析简介
在 第二章 “使用 Scala 进行回归分析” 到 第六章 “使用 Scala 进行推荐系统” 中,我们通过实际案例学习了线性经典 机器学习(ML)算法。在本章中,我们将解释一些 深度学习(DL)的基本概念。我们将从深度学习开始,这是机器学习的一个新兴分支。我们将简要讨论一些最著名和最广泛使用的神经网络架构和深度学习框架和库。
最后,我们将使用来自 The Cancer Genome Atlas(TCGA)的非常高维数据集的 长短期记忆(LSTM)架构进行癌症类型分类。本章将涵盖以下主题:
-
深度学习与机器学习
-
深度学习与神经网络
-
深度神经网络架构
-
深度学习框架
-
开始学习
技术要求
确保您的机器上已安装并配置了 Scala 2.11.x 和 Java 1.8.x。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter07
查看以下视频,了解代码的实际应用:
深度学习与机器学习
在小规模数据分析中使用的一些简单机器学习方法不再有效,因为随着大型和高维数据集的增加,机器学习方法的有效性会降低。于是出现了深度学习——一种基于一组试图在数据中模拟高级抽象的算法的机器学习分支。Ian Goodfellow 等人(《深度学习》,麻省理工学院出版社,2016 年)将深度学习定义为如下:
“深度学习是一种特殊的机器学习方法,通过学习将世界表示为嵌套的概念层次结构,每个概念都是相对于更简单的概念定义的,并且更抽象的表示是通过更不抽象的表示来计算的,从而实现了强大的功能和灵活性。”
与机器学习模型类似,深度学习模型也接受一个输入 X,并从中学习高级抽象或模式以预测输出 Y。例如,基于过去一周的股票价格,深度学习模型可以预测下一天的股票价格。在训练此类历史股票数据时,深度学习模型试图最小化预测值与实际值之间的差异。这样,深度学习模型试图推广到它之前未见过的新输入,并在测试数据上做出预测。
现在,你可能想知道,如果 ML 模型可以完成同样的任务,为什么我们还需要 DL?嗯,DL 模型在大数据量下往往表现良好,而旧的 ML 模型在某个点之后就会停止改进。DL 的核心概念灵感来源于大脑的结构和功能,被称为人工神经网络(ANNs)。作为 DL 的核心,ANNs 帮助您学习输入和输出集合之间的关联,以便做出更稳健和准确的预测。然而,DL 不仅限于 ANNs;已经有许多理论进步、软件堆栈和硬件改进,使 DL 普及。让我们看一个例子;假设我们想要开发一个预测分析模型,例如动物识别器,我们的系统必须解决两个问题:
-
要分类图像是否代表猫或狗
-
要对猫和狗的图像进行聚类
如果我们使用典型的机器学习(ML)方法来解决第一个问题,我们必须定义面部特征(耳朵、眼睛、胡须等)并编写一个方法来识别在分类特定动物时哪些特征(通常是非线性)更重要。
然而,与此同时,我们无法解决第二个问题,因为用于聚类图像的经典 ML 算法(如 k-means)无法处理非线性特征。看看以下流程图,它显示了如果我们想要分类给定的图像是否为猫时我们将遵循的流程:

DL 算法将这两个问题进一步推进,在确定哪些特征对分类或聚类最为重要后,最重要的特征将被自动提取。相比之下,当使用经典 ML 算法时,我们必须手动提供特征。
深度学习(DL)算法会采取更复杂的步骤。例如,首先,它会识别在聚类猫或狗时最相关的边缘。然后,它会尝试以分层的方式找到各种形状和边缘的组合。这一步被称为提取、转换和加载(ETL)。然后,经过几次迭代后,将进行复杂概念和特征的分层识别。然后,基于识别出的特征,DL 算法将决定哪些特征对分类动物最为重要。这一步被称为特征提取。最后,它会提取标签列并使用自动编码器(AEs)进行无监督训练,以提取要重新分配给 k-means 进行聚类的潜在特征。然后,聚类分配硬化损失(CAH 损失)和重建损失将共同优化以实现最佳的聚类分配。
然而,在实践中,深度学习算法使用的是原始图像表示,它并不像我们看待图像那样看待图像,因为它只知道每个像素的位置及其颜色。图像被划分为各种分析层。在较低层次,软件分析,例如,几个像素的网格,任务是检测某种颜色或各种细微差别。如果它发现某些东西,它会通知下一层,此时该层检查给定的颜色是否属于更大的形状,例如一条线。
这个过程一直持续到算法理解以下图中所示的内容:

虽然狗与猫是一个非常简单的分类器的例子,但现在能够执行这些类型任务的软件已经非常普遍,例如在识别面部或搜索谷歌图片的系统中发现。这类软件基于深度学习算法。相反,使用线性机器学习算法,我们无法构建这样的应用程序,因为这些算法无法处理非线性图像特征。
此外,使用机器学习方法,我们通常只处理几个超参数。然而,当引入神经网络时,事情变得过于复杂。在每个层中,都有数百万甚至数十亿个超参数需要调整——如此之多,以至于代价函数变得非凸。另一个原因是,在隐藏层中使用的激活函数是非线性的,因此代价是非凸的。
深度学习与 ANNs(人工神经网络)
受人类大脑工作方式启发的 ANNs(人工神经网络)是深度学习的核心和真正实现。今天围绕深度学习的革命如果没有 ANNs(人工神经网络)是不可能发生的。因此,为了理解深度学习,我们需要了解神经网络是如何工作的。
ANNs(人工神经网络)与人类大脑
ANNs(人工神经网络)代表了人类神经系统的一个方面,以及神经系统由许多通过轴突相互通信的神经元组成。感受器接收来自内部或外部世界的刺激。然后,它们将此信息传递给生物神经元以进行进一步处理。
除了另一个被称为轴突的长延伸之外,还有许多树突。在其末端,有微小的结构称为突触末端,用于将一个神经元连接到其他神经元的树突。生物神经元从其他神经元接收称为信号的短暂电脉冲,作为回应,它们触发自己的信号。
因此,我们可以总结说,神经元由细胞体(也称为胞体)、一个或多个用于接收来自其他神经元信号的树突,以及一个用于执行神经元产生的信号的轴突组成。当神经元向其他神经元发送信号时,它处于活跃状态。然而,当它从其他神经元接收信号时,它处于非活跃状态。在空闲状态下,神经元积累所有接收到的信号,直到达到一定的激活阈值。这一切激励研究人员测试人工神经网络(ANNs)。
人工神经网络简史
人工神经网络和深度学习最显著的进步可以用以下时间线来描述。我们已经看到,人工神经元和感知器分别在 1943 年和 1958 年为基础。然后,1969 年,明斯基(Minsky)等人将 XOR 表述为线性不可分问题,但后来在 1974 年,韦伯斯(Werbos)等人证明了用于训练感知器的反向传播算法。
然而,最显著的进步发生在 20 世纪 80 年代,当时约翰·霍普菲尔德(John Hopfield)等人于 1982 年提出了霍普菲尔德网络。然后,神经网络和深度学习的奠基人之一辛顿及其团队于 1985 年提出了玻尔兹曼机。然而,可能最显著的进步发生在 1986 年,当时辛顿等人成功训练了多层感知器(MLP),乔丹等人提出了 RNNs。同年,斯莫伦斯基(Smolensky)等人还提出了改进的玻尔兹曼机,称为受限玻尔兹曼机(RBM)。
然而,在 20 世纪 90 年代,最显著的一年是 1997 年,当时勒克伦(Lecun)等人于 1990 年提出了 LeNet,乔丹(Jordan)等人于 1997 年提出了循环神经网络(RNN)。同年,舒斯特(Schuster)等人提出了改进的 LSTM 和原始 RNN 的改进版本,称为双向 RNN。以下时间线简要概述了不同神经网络架构的历史:

尽管计算取得了显著进步,但从 1997 年到 2005 年,我们并没有经历太多的进步,直到辛顿在 2006 年再次取得突破,当时他和他的团队通过堆叠多个 RBM 提出了深度信念网络(DBN)。然后,在 2012 年,辛顿发明了 dropout,这显著提高了深度神经网络的正则化和过拟合。
之后,伊恩·古德费洛(Ian Goodfellow)等人引入了生成对抗网络(GANs),这在图像识别领域是一个重要的里程碑。2017 年,辛顿(Hinton)提出了 CapsNet 以克服常规卷积神经网络(CNNs)的局限性,这至今为止是最重要的里程碑之一。
人工神经网络是如何学习的?
基于生物神经元的理念,人工神经网络(ANNs)的术语和概念应运而生。与生物神经元相似,人工神经元由以下部分组成:
-
一个或多个汇聚来自神经元信号的输入连接
-
一个或多个输出连接,用于将信号传递到其他神经元
-
激活函数,它决定了输出信号的数值
除了神经元的当前状态外,还考虑了突触权重,这影响了网络内的连接。每个权重都有一个由 W[ij] 表示的数值,它是连接神经元 i 和神经元 j 的突触权重。现在,对于每个神经元 i,可以定义一个输入向量 x[i] = (x[1], x[2],…x[n]) 和一个权重向量 w[i] = (w[i1], w[i2],…w[in])。现在,根据神经元的定位,权重和输出函数决定了单个神经元的行为。然后,在正向传播过程中,隐藏层中的每个单元都会接收到以下信号:

尽管如此,在权重中,还有一种特殊的权重类型,称为偏置单元,b。技术上讲,偏置单元不连接到任何前一层,因此它们没有真正的活动。但仍然,偏置 b 的值允许神经网络将激活函数向左或向右移动。考虑偏置单元后,修改后的网络输出如下所示:

前面的方程表示每个隐藏单元都得到输入的总和,乘以相应的权重——这被称为 求和节点。然后,求和节点中的结果输出通过激活函数,如图所示进行压缩:

人工神经元模型的工作原理
然而,一个实际的神经网络架构是由输入、隐藏和输出层组成的,这些层由 nodes 构成网络结构,但仍遵循前面图表中所示的人工神经元模型的工作原理。输入层只接受数值数据,例如实数特征、具有像素值的图像等:

一个具有一个输入层、三个隐藏层和一个输出层的神经网络
在这里,隐藏层执行大部分计算以学习模式,网络通过使用称为损失函数的特殊数学函数来评估其预测与实际输出的准确性。它可能很复杂,也可能非常简单,可以定义为以下:

在前面的方程中,
表示网络做出的预测,而 Y 代表实际或预期的输出。最后,当错误不再减少时,神经网络收敛并通过输出层进行预测。
训练神经网络
神经网络的训练过程被配置为一个迭代优化权重的过程。权重在每个时代更新。一旦开始训练,目标是通过最小化损失函数来生成预测。然后,网络的性能在测试集上评估。我们已经了解了人工神经元的基本概念。然而,仅生成一些人工信号是不够学习复杂任务的。因此,常用的监督学习算法是反向传播算法,它被广泛用于训练复杂的 ANN。
最终,训练这样的神经网络也是一个优化问题,我们通过迭代调整网络权重和偏差,使用通过梯度下降(GD)的反向传播来最小化误差。这种方法迫使网络反向遍历所有层,以更新节点间的权重和偏差,方向与损失函数相反。
然而,使用梯度下降法(GD)的过程并不能保证达到全局最小值。隐藏单元的存在和输出函数的非线性意味着误差的行为非常复杂,并且有许多局部最小值。这个反向传播步骤通常要执行成千上万次,使用许多训练批次,直到模型参数收敛到最小化成本函数的值。当验证集上的误差开始增加时,训练过程结束,因为这可能标志着过拟合阶段的开始。
使用 GD 的缺点是它收敛得太慢,这使得它无法满足处理大规模训练数据的需求。因此,提出了一个更快的 GD,称为随机梯度下降(SDG),它也是 DNN 训练中广泛使用的优化器。在 SGD 中,我们使用训练集中的单个训练样本在每个迭代中更新网络参数,这是对真实成本梯度的随机*似。
现在还有其他一些高级优化器,如 Adam、RMSProp、ADAGrad、Momentum 等。它们中的每一个都是 SGD 的直接或间接优化版本。
权重和偏差初始化
现在,这里有一个棘手的问题:我们如何初始化权重?好吧,如果我们把所有权重初始化为相同的值(例如,0 或 1),每个隐藏神经元将接收到完全相同的信号。让我们来分析一下:
-
如果所有权重都初始化为 1,那么每个单元接收到的信号等于输入的总和
-
如果所有权重都是 0,这甚至更糟糕,那么隐藏层中的每个神经元都将接收到零信号
对于网络权重初始化,广泛使用 Xavier 初始化。它与随机初始化类似,但通常效果更好,因为它可以默认根据输入和输出神经元的总数来确定初始化速率。
你可能想知道在训练常规深度神经网络(DNN)时是否可以去掉随机初始化。好吧,最*,一些研究人员一直在讨论随机正交矩阵初始化,这种初始化对于训练 DNN 来说比任何随机初始化都好。当涉及到初始化偏差时,我们可以将它们初始化为零。
但是将偏差设置为一个小常数值,例如所有偏差的 0.01,确保所有修正线性单元(ReLUs)都能传播一些梯度。然而,它既没有表现出良好的性能,也没有显示出一致的改进。因此,建议坚持使用零。
激活函数
为了使神经网络能够学习复杂的决策边界,我们对其某些层应用非线性激活函数。常用的函数包括 Tanh、ReLU、softmax 及其变体。从技术上讲,每个神经元接收一个信号,该信号是突触权重和连接为输入的神经元的激活值的加权和。为此目的最广泛使用的函数之一是所谓的 sigmoid 逻辑函数,其定义如下:

这个函数的定义域包括所有实数,而陪域是(0, 1)。这意味着从神经元(根据其激活状态的计算)获得的任何输出值都将始终介于零和一之间。以下图中表示的Sigmoid函数提供了对神经元饱和率的解释,从非激活状态(等于0)到完全饱和,这发生在预定的最大值(等于1):

Sigmoid 与 Tanh 激活函数
另一方面,双曲正切,或Tanh,是另一种激活函数形式。Tanh将一个介于-1和1之间的实数值拉*。前面的图表显示了Tanh和Sigmoid激活函数之间的差异。特别是,从数学上讲,tanh激活函数可以表示如下:

通常,在前馈神经网络(FFNN)的最后一层,应用 softmax 函数作为决策边界。这是一个常见的情况,尤其是在解决分类问题时。在多类分类问题中,softmax 函数用于对可能的类别进行概率分布。
对于回归问题,我们不需要使用任何激活函数,因为网络生成的是连续值——即概率。然而,我注意到现在有些人使用 IDENTITY 激活函数来解决回归问题。
总结来说,选择合适的激活函数和网络权重初始化是使网络发挥最佳性能并有助于获得良好训练的两个问题。既然我们已经了解了神经网络简短的历史,那么让我们在下一节深入探讨不同的架构,这将给我们一个关于它们用法的想法。
神经网络架构
我们可以将深度学习架构分为四组:
-
深度神经网络(DNNs)
-
卷积神经网络(CNNs)
-
循环神经网络(RNNs)
-
涌现架构(EAs)
然而,DNNs、CNNs 和 RNNs 有许多改进的变体。尽管大多数变体都是为了解决特定领域的研究问题而提出或开发的,但它们的基本工作原理仍然遵循原始的 DNN、CNN 和 RNN 架构。以下小节将简要介绍这些架构。
DNNs
DNNs 是具有复杂和更深架构的神经网络,每一层都有大量神经元,并且它们之间有许多连接。尽管 DNN 指的是一个非常深的网络,但为了简单起见,我们将 MLP、堆叠自编码器(SAE)和深度信念网络(DBNs)视为 DNN 架构。这些架构大多作为 FFNN 工作,意味着信息从输入层传播到输出层。
多个感知器堆叠在一起形成 MLP,其中层以有向图的形式连接。本质上,MLP 是最简单的 FFNN 之一,因为它有三层:输入层、隐藏层和输出层。这样,信号以单向传播,从输入层到隐藏层再到输出层,如下面的图所示:

自编码器和 RBM 是 SAE 和 DBN 的基本构建块。与以监督方式训练的 FFNN MLP 不同,SAE 和 DBN 都是在两个阶段进行训练的:无监督预训练和监督微调。在无监督预训练中,层按顺序堆叠并以分层方式使用未标记的数据进行训练。在监督微调中,堆叠一个输出分类器层,并通过使用标记数据进行重新训练来优化整个神经网络。
MLP 的一个问题是它经常过拟合数据,因此泛化能力不好。为了克服这个问题,Hinton 等人提出了 DBN。它使用一种贪婪的、层级的、预训练算法。DBN 由一个可见层和多个隐藏单元层组成。DBN 的构建块是 RBM,如下面的图所示,其中几个 RBM 一个接一个地堆叠:

最上面的两层之间有未定向、对称的连接,但底层有从前一层的有向连接。尽管 DBNs 取得了许多成功,但现在它们正被 AEs 所取代。
自编码器
AEs 也是从输入数据自动学习的特殊类型的神经网络。AE 由两个组件组成:编码器和解码器。编码器将输入压缩成潜在空间表示。然后,解码器部分试图从这个表示中重建原始输入数据:
-
编码器:使用称为 h=f(x) 的函数将输入编码或压缩成潜在空间表示。
-
解码器:使用称为 r=g(h) 的函数从潜在空间表示解码或重建输入。
因此,一个 AE 可以通过一个函数来描述 g(f(x)) = o,其中我们希望 0 尽可能接*原始输入 x。以下图显示了 AE 通常的工作方式:

AEs 在数据去噪和降维以用于数据可视化方面非常有用。AEs 比 PCA 更有效地学习数据投影,称为表示。
CNNs
CNNs 取得了很大的成就,并在计算机视觉(例如,图像识别)中得到广泛应用。在 CNN 网络中,连接方案与 MLP 或 DBN 相比有显著不同。一些卷积层以级联方式连接。每一层都由一个 ReLU 层、一个池化层和额外的卷积层(+ReLU)以及另一个池化层支持,然后是一个全连接层和一个 softmax 层。以下图是用于面部识别的 CNN 架构示意图,它以面部图像为输入,预测情绪,如愤怒、厌恶、恐惧、快乐、悲伤等。

用于面部识别的 CNN 的示意图架构
重要的是,DNNs 对像素的排列没有先验知识,因为它们不知道附*的像素是接*的。CNNs 通过在图像的小区域使用特征图来利用这种先验知识,而高层将低级特征组合成更高级的特征。
这与大多数自然图像都很好,使 CNNs 在 DNNs 中取得了决定性的领先优势。每个卷积层的输出是一组对象,称为特征图,由单个核滤波器生成。然后,特征图可以用来定义下一层的新输入。CNN 网络中的每个神经元都产生一个输出,随后是一个激活阈值,该阈值与输入成正比,没有界限。
RNNs
在 RNNs 中,单元之间的连接形成一个有向循环。RNN 架构最初由 Hochreiter 和 Schmidhuber 在 1997 年构思。RNN 架构具有标准的 MLP,并增加了循环,以便它们可以利用 MLP 强大的非线性映射能力。它们也具有某种形式的记忆。以下图显示了一个非常基本的 RNN,它有一个输入层、两个循环层和一个输出层:

然而,这个基本的 RNN 受梯度消失和爆炸问题的影响,无法建模长期依赖。这些架构包括 LSTM、门控循环单元(GRUs)、双向-LSTM 和其他变体。因此,LSTM 和 GRU 可以克服常规 RNN 的缺点:梯度消失/爆炸问题和长期短期依赖。
生成对抗网络(GANs)
Ian Goodfellow 等人在一篇名为《生成对抗网络》(见更多内容https://arxiv.org/abs/1406.2661v1)的论文中介绍了 GANs。以下图表简要展示了 GAN 的工作原理:

GAN 的工作原理
GANs 是由两个网络组成的深度神经网络架构,一个生成器和一个判别器,它们相互对抗(因此得名,对抗):
-
生成器试图从一个特定的概率分布中生成数据样本,并且与实际对象非常相似
-
判别器将判断其输入是否来自原始训练集或生成器部分
许多深度学习实践者认为,GANs 是其中最重要的进步之一,因为 GANs 可以用来模拟任何数据分布,并且基于数据分布,GANs 可以学会创建机器人艺术家图像、超分辨率图像、文本到图像合成、音乐、语音等。
例如,由于对抗训练的概念,Facebook 的人工智能研究总监 Yann LeCun 将 GAN 称为过去 10 年机器学习中最有趣的想法。
胶囊网络
在 CNN 中,每一层通过缓慢的接受场或最大池化操作以更细粒度的水*理解图像。如果图像有旋转、倾斜或非常不同的形状或方向,CNN 无法提取此类空间信息,在图像处理任务中表现出非常差的性能。即使 CNN 中的池化操作也无法在很大程度上帮助对抗这种位置不变性。CNN 中的这个问题促使我们通过 Geoffrey Hinton 等人撰写的题为《胶囊之间的动态路由》(见更多内容https://arxiv.org/abs/1710.09829)的论文,最*在 CapsNet 方面取得了进展:
“胶囊是一组神经元,其活动向量表示特定类型实体(如对象或对象部分)的实例化参数。”
与我们不断添加层的常规 DNN 不同,在 CapsNets 中,想法是在单个层内添加更多层。这样,CapsNet 是一个嵌套的神经网络层集。在 CapsNet 中,胶囊的向量输入和输出通过路由算法计算,该算法迭代地传输信息和处理自洽场(SCF)过程,这在物理学中应用:

上述图表显示了简单三层 CapsNet 的示意图。DigiCaps层中每个胶囊的活动向量长度表示每个类实例的存在,这被用来计算损失。
现在我们已经了解了神经网络的工作原理和不同的神经网络架构,动手实现一些内容将会很棒。然而,在那之前,让我们看看一些流行的深度学习库和框架,它们提供了这些网络架构的实现。
深度学习框架
有几个流行的深度学习框架。每个框架都有其优缺点。其中一些是基于桌面的,而另一些是基于云的*台,您可以在这些*台上部署/运行您的深度学习应用。然而,大多数开源许可证下发布的库在人们使用图形处理器时都有帮助,这最终有助于加快学习过程。
这些框架和库包括 TensorFlow、PyTorch、Keras、Deeplearning4j、H2O 以及微软认知工具包(CNTK)。甚至就在几年前,其他实现如 Theano、Caffee 和 Neon 也被广泛使用。然而,这些现在都已过时。由于我们将专注于 Scala 的学习,基于 JVM 的深度学习库如 Deeplearning4j 可以是一个合理的选择。Deeplearning4j(DL4J)是第一个为 Java 和 Scala 构建的商业级、开源、分布式深度学习库。这也提供了对 Hadoop 和 Spark 的集成支持。DL4J 是为在分布式 GPU 和 CPU 上用于商业环境而构建的。DL4J 旨在成为前沿和即插即用,具有比配置更多的惯例,这允许非研究人员快速原型设计。以下图表显示了去年的 Google 趋势,说明了 TensorFlow 有多受欢迎:

不同深度学习框架的趋势——TensorFlow 和 Keras 占据主导地位;然而,Theano 正在失去其受欢迎程度;另一方面,Deeplearning4j 在 JVM 上崭露头角
它的众多库可以与 DL4J 集成,无论您是在 Java 还是 Scala 中开发机器学习应用,都将使您的 JVM 体验更加容易。类似于 JVM 的 NumPy,ND4J 提供了线性代数的基本操作(矩阵创建、加法和乘法)。然而,ND4S 是一个用于线性代数和矩阵操作的科学研究库。它还为基于 JVM 的语言提供了多维数组。
除了上述库之外,还有一些最*在云上进行的深度学习倡议。想法是将深度学习能力带给拥有数以亿计数据点和高维数据的大数据。例如,亚马逊网络服务(AWS)、微软 Azure、谷歌云*台以及NVIDIA GPU 云(NGC)都提供了其公共云本地的机器和深度学习服务。
2017 年 10 月,AWS 为Amazon Elastic Compute Cloud(Amazon EC2)P3 实例发布了深度学习 AMI(DLAMIs)。这些 AMI 预先安装了深度学习框架,如 TensorFlow、Gluon 和 Apache MXNet,这些框架针对 Amazon EC2 P3 实例中的 NVIDIA Volta V100 GPU 进行了优化。该深度学习服务目前提供三种类型的 AMI:Conda AMI、Base AMI 和带源代码的 AMI。
CNTK 是 Azure 的开源深度学习服务。类似于 AWS 的提供,它专注于可以帮助开发者构建和部署深度学习应用程序的工具。工具包安装在 Python 2.7 的根环境中。Azure 还提供了一个模型库,其中包括代码示例等资源,以帮助企业开始使用该服务。
另一方面,NGC 通过 GPU 加速容器(见www.nvidia.com/en-us/data-center/gpu-cloud-computing/)为 AI 科学家和研究人员提供支持。NGC 具有容器化的深度学习框架,如 TensorFlow、PyTorch、MXNet 等,这些框架由 NVIDIA 经过调整、测试和认证,可在参与云服务提供商的最新 NVIDIA GPU 上运行。尽管如此,通过它们各自的市场,也有第三方服务可用。
现在你已经了解了神经网络架构的工作原理,并且对可用于实现深度学习解决方案的 DL 框架有了简要的了解,让我们继续到下一部分进行一些动手学习。
开始学习
大规模癌症基因组数据通常以多*台和异构形式出现。这些数据集在生物信息学方法和计算算法方面提出了巨大的挑战。许多研究人员提出了利用这些数据来克服几个挑战的方法,使用经典机器学习算法作为主要主题或癌症诊断和预后支持的元素。
数据集描述
基因组数据涵盖了与生物体 DNA 相关的所有数据。尽管在本论文中我们也会使用其他类型的数据,例如转录组数据(RNA 和 miRNA),为了方便起见,所有数据都将被称为基因组数据。由于人类基因组计划(HGP)(1984-2000)在测序人类 DNA 全序列方面的成功,*年来人类遗传学研究取得了巨大的突破。现在,让我们看看一个可以用于我们目的的真实数据集是什么样的。我们将使用基因表达癌症 RNA-Seq数据集,该数据集可以从 UCI ML 存储库下载(更多信息请见archive.ics.uci.edu/ml/datasets/gene+expression+cancer+RNA-Seq)。
这个数据集是以下论文中报告的另一个数据集的随机子集:Weinstein, John N.,et al. The cancer genome atlas pan-cancer analysis project. Nature Genetics 45.10 (2013): 1113-1120。该项目的名称是全癌症分析项目。它汇集了来自数千名患者的数据,这些患者的主要肿瘤发生在身体的不同部位。它涵盖了 12 种肿瘤类型,包括以下内容:
-
多形性胶质母细胞瘤 (GBM)
-
淋巴细胞性急性髓系白血病 (AML)
-
头颈鳞状细胞癌 (HNSC)
-
肺腺癌 (LUAD)
-
肺鳞状细胞癌 (LUSC)
-
乳腺癌 (BRCA)
-
肾脏肾细胞癌 (KIRC)
-
卵巢癌 (OV)
-
膀胱癌 (BLCA)
-
结肠腺癌 (COAD)
-
子宫颈和子宫内膜癌 (UCEC)
-
直肠腺癌 (READ)
这组数据是 RNA-Seq (HiSeq) PANCAN 数据集的一部分。它是来自不同类型肿瘤(BRCA、KIRC、COAD、LUAD 和 PRAD)患者的基因表达的随机提取。
这个数据集是从 801 名癌症患者中随机收集的,每位患者有 20,531 个属性。样本(instances)按行存储。每个样本的变量(attributes)是 Illumina HiSeq *台测量的 RNA-Seq 基因表达水*。每个属性提供了一个虚拟名称(gene_XX)。属性按与原始提交一致的顺序排列。例如,gene_1在sample_0上是显著且差异表达的,其值为2.01720929003。
当你下载数据集时,你会看到有两个 CSV 文件:
-
data.csv:包含每个样本的基因表达数据 -
labels.csv:与每个样本关联的标签
让我们看看处理过的数据集。请注意,考虑到以下截图中的高维性,我们只会查看一些选定的特征,其中第一列代表样本 ID(即匿名患者 ID)。其余的列表示患者肿瘤样本中特定基因表达的发生情况:

现在,请查看下表中标签。在这里,id列包含样本 ID,而Class列表示癌症标签:

现在,你可以想象我为什么选择这个数据集了。尽管我们不会有太多的样本,但数据集仍然是高度多维的。此外,这种高度多维的数据集非常适合应用深度学习算法。因此,如果给出了特征和标签,我们能否根据特征和真实情况对这些样本进行分类?为什么不呢?我们将尝试使用 DL4J 库来解决这个问题。首先,我们必须配置我们的编程环境,以便我们可以编写我们的代码。
准备编程环境
在本节中,我们将讨论在开始编码之前如何配置 DL4J、ND4s、Spark 和 ND4J。以下是在使用 DL4J 时你必须考虑的先决条件:
-
Java 1.8+ (仅 64 位)
-
Apache Maven 用于自动构建和依赖关系管理器
-
IntelliJ IDEA 或 Eclipse IDE
-
Git 用于版本控制和 CI/CD
以下库可以与 DJ4J 集成,以增强你在开发机器学习应用程序时的 JVM 体验:
-
DL4J:核心神经网络框架,包含许多深度学习架构和底层功能。
-
ND4J:可以被认为是 JVM 的 NumPy。它包含一些线性代数的基本操作。例如矩阵创建、加法和乘法。
-
DataVec:这个库在执行特征工程的同时允许 ETL 操作。
-
JavaCPP:这个库充当 Java 和原生 C++之间的桥梁。
-
Arbiter:这个库为深度学习算法提供基本的评估功能。
-
RL4J:JVM 的深度强化学习。
-
ND4S:这是一个科学计算库,它也支持基于 JVM 的语言的 n 维数组。
如果你正在你喜欢的 IDE 上使用 Maven,让我们定义项目属性,在pom.xml文件中提及这些版本:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jdk.version>1.8</jdk.version>
<spark.version>2.2.0</spark.version>
<nd4j.version>1.0.0-alpha</nd4j.version>
<dl4j.version>1.0.0-alpha</dl4j.version>
<datavec.version>1.0.0-alpha</datavec.version>
<arbiter.version>1.0.0-alpha</arbiter.version>
<logback.version>1.2.3</logback.version>
</properties>
然后,使用pom.xml文件中显示的 DL4J、ND4S 和 ND4J 所需的全部依赖项。顺便说一句,DL4J 自带 Spark 2.1.0。另外,如果你的机器上没有配置本地系统 BLAS,ND4J 的性能将会降低。一旦你执行任何用 Scala 编写的简单代码,你将体验到以下警告:
****************************************************************
WARNING: COULD NOT LOAD NATIVE SYSTEM BLAS
ND4J performance WILL be reduced
****************************************************************
然而,安装和配置 BLAS,如 OpenBLAS 或 IntelMKL,并不那么困难;你可以投入一些时间并完成它。有关更多详细信息,请参阅以下 URL:
干得好!我们的编程环境已准备好进行简单的深度学习应用开发。现在,是时候用一些示例代码来动手实践了。
预处理
由于我们没有未标记的数据,我想随机选择一些样本进行测试。还有一点需要注意,特征和标签在两个单独的文件中。因此,我们可以执行必要的预处理,然后将它们合并在一起,这样我们的预处理数据将包含特征和标签。
然后,将使用剩余的数据进行训练。最后,我们将训练和测试集保存到单独的 CSV 文件中,以便以后使用。按照以下步骤开始:
- 首先,让我们加载样本并查看统计信息。在这里,我们使用 Spark 的
read()方法,但也要指定必要的选项和格式:
val data = spark.read.option("maxColumns", 25000).format("com.databricks.spark.csv")
.option("header", "true") // Use first line of all files as header
.option("inferSchema", "true") // Automatically infer data types
.load("TCGA-PANCAN/TCGA-PANCAN-HiSeq-801x20531/data.csv");// set this path accordingly
- 然后,我们将看到一些相关的统计信息,例如特征数量和样本数量:
val numFeatures = data.columns.length
val numSamples = data.count()
println("Number of features: " + numFeatures)
println("Number of samples: " + numSamples)
因此,有来自801个不同患者的801个样本,由于它有20532个特征,数据集的维度非常高:
Number of features: 20532
Number of samples: 801
- 此外,由于
id列仅代表患者的匿名 ID,因此我们可以简单地删除它:
val numericDF = data.drop("id") // now 20531 features left
- 然后,我们使用 Spark 的
read()方法加载标签,并指定必要的选项和格式:
val labels = spark.read.format("com.databricks.spark.csv")
.option("header", "true")
.option("inferSchema", "true")
.load("TCGA-PANCAN/TCGA-PANCAN-HiSeq-801x20531/labels.csv")
labels.show(10)
我们已经看到了标签 DataFrame 的样子。我们将跳过id列。然而,Class列是分类的。正如我们之前提到的,DL4J 不支持需要预测的分类标签。因此,我们必须将其转换为数值格式(更具体地说,是一个整数);为此,我将使用 Spark 的StringIndexer():
- 首先,我们创建一个
StringIndexer(),将索引操作应用于Class列,并将其重命名为label。此外,我们跳过空值条目:
val indexer = new StringIndexer().setInputCol("Class")
.setOutputCol("label")
.setHandleInvalid("skip"); // skip null/invalid values
- 然后,我们通过调用
fit()和transform()操作执行索引操作,如下所示:
val indexedDF = indexer.fit(labels).transform(labels)
.select(col("label")
.cast(DataTypes.IntegerType)); // casting data types to integer
- 现在,让我们看一下索引后的 DataFrame:
indexedDF.show()
上一行代码应该将label列转换为数值格式:

- 太棒了!现在,所有列(包括特征和标签)都是数值的。因此,我们可以将特征和标签合并到一个 DataFrame 中。为此,我们可以使用 Spark 的
join()方法,如下所示:
val combinedDF = numericDF.join(indexedDF)
- 现在,我们可以通过随机拆分
combinedDF来生成训练集和测试集,如下所示:
val splits = combinedDF.randomSplit(Array(0.7, 0.3), 12345L) //70% for training, 30% for testing
val trainingDF = splits(0)
val testDF = splits(1)
- 现在,让我们看看每个集合中的样本
count:
println(trainingDF.count())// number of samples in training set
println(testDF.count())// number of samples in test set
- 训练集中应该有 561 个样本,测试集中应该有 240 个样本。最后,我们将它们保存在单独的 CSV 文件中,以供以后使用:
trainingDF.coalesce(1).write
.format("com.databricks.spark.csv")
.option("header", "false")
.option("delimiter", ",")
.save("output/TCGA_train.csv")
testDF.coalesce(1).write
.format("com.databricks.spark.csv")
.option("header", "false")
.option("delimiter", ",")
.save("output/TCGA_test.csv")
- 现在我们有了训练集和测试集,我们可以用训练集训练网络,并用测试集评估模型。
Spark 将在项目根目录下的output文件夹下生成 CSV 文件。然而,你可能会看到一个非常不同的名称。我建议你将它们分别重命名为TCGA_train.csv和TCGA_test.csv,以区分训练集和测试集。
考虑到高维性,我更愿意尝试一个更好的网络,比如 LSTM,它是 RNN 的改进版本。在这个时候,了解一些关于 LSTM 的上下文信息将有助于把握这个想法,这些信息将在以下部分提供。
数据集准备
在上一节中,我们准备了训练集和测试集。然而,我们需要做一些额外的工作来使它们能够被 DL4J 使用。更具体地说,DL4J 期望训练数据是数值格式,并且最后一列是label列。其余的数据应该是特征。
现在,我们将尝试像那样准备我们的训练集和测试集。首先,我们将找到我们保存训练集和测试集的文件:
// Show data paths
val trainPath = "TCGA-PANCAN/TCGA_train.csv"
val testPath = "TCGA-PANCAN/TCGA_test.csv"
然后,我们将定义所需的参数,例如特征数量、类别数量和批量大小。在这里,我使用128作为batchSize,但你可以相应地调整它:
// Preparing training and test set.
val labelIndex = 20531
val numClasses = 5
val batchSize = 128
这个数据集用于训练:
val trainingDataIt: DataSetIterator = readCSVDataset(trainPath, batchSize, labelIndex, numClasses)
这是我们想要分类的数据:
val testDataIt: DataSetIterator = readCSVDataset(testPath, batchSize, labelIndex, numClasses)
如前两行代码所示,readCSVDataset()基本上是一个包装器,它读取 CSV 格式的数据,然后RecordReaderDataSetIterator()方法将记录读取器转换为数据集迭代器。
LSTM 网络构建
使用 DL4J 创建神经网络从MultiLayerConfiguration开始,它组织网络层及其超参数。然后,使用NeuralNetConfiguration.Builder()接口添加创建的层。如图所示,LSTM 网络由五个层组成:一个输入层,后面跟着三个 LSTM 层。最后一层是 RNN 层,在这种情况下也是输出层:

一个用于癌症类型预测的 LSTM 网络,它接受 20,531 个特征和固定的偏置(即 1),并生成多类输出
要创建 LSTM 层,DL4J 提供了 LSTM 类的实现。然而,在我们开始为网络创建层之前,让我们定义一些超参数,例如输入/隐藏/输出节点的数量(神经元):
// Network hyperparameters
val numInputs = labelIndex
val numOutputs = numClasses
val numHiddenNodes = 5000
然后,我们通过指定层来创建网络。第一、第二和第三层是 LSTM 层。最后一层是 RNN 层。对于所有的隐藏 LSTM 层,我们指定输入和输出单元的数量,并使用 ReLU 作为激活函数。然而,由于这是一个多类分类问题,我们在输出层使用SOFTMAX作为激活函数,MCXNET作为损失函数:
//First LSTM layer
val layer_0 = new LSTM.Builder()
.nIn(numInputs)
.nOut(numHiddenNodes)
.activation(Activation.RELU)
.build()
//Second LSTM layer
val layer_1 = new LSTM.Builder()
.nIn(numHiddenNodes)
.nOut(numHiddenNodes)
.activation(Activation.RELU)
.build()
//Third LSTM layer
val layer_2 = new LSTM.Builder()
.nIn(numHiddenNodes)
.nOut(numHiddenNodes)
.activation(Activation.RELU)
.build()
//RNN output layer
val layer_3 = new RnnOutputLayer.Builder()
.activation(Activation.SOFTMAX)
.lossFunction(LossFunction.MCXENT)
.nIn(numHiddenNodes)
.nOut(numOutputs)
.build()
在前面的代码块中,softmax 激活函数给出了类别的概率分布,MCXENT是多类分类设置中的交叉熵损失函数。
然后,使用 DL4J,我们通过NeuralNetConfiguration.Builder()接口添加我们之前创建的层。首先,我们添加所有的 LSTM 层,然后是最终的 RNN 输出层:
//Create network configuration and conduct network training
val LSTMconf: MultiLayerConfiguration = new NeuralNetConfiguration.Builder()
.seed(seed) //Random number generator seed for improved repeatability. Optional.
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.weightInit(WeightInit.XAVIER)
.updater(new Adam(5e-3))
.l2(1e-5)
.list()
.layer(0, layer_0)
.layer(1, layer_1)
.layer(2, layer_2)
.layer(3, layer_3)
.pretrain(false).backprop(true).build()
在前面的代码块中,我们使用了 SGD 作为优化器,它试图优化MCXNET损失函数。然后,我们使用XAVIER初始化网络权重,Adam作为网络更新器与 SGD 一起工作。最后,我们使用前面的多层配置初始化一个多层网络:
val model: MultiLayerNetwork = new MultiLayerNetwork(LSTMconf)
model.init()
此外,我们还可以检查整个网络中各层的超参数数量。通常,这种类型的网络有很多超参数。让我们打印网络中的参数数量(以及每个层的参数数量):
//print the score with every 1 iteration
model.setListeners(new ScoreIterationListener(1))
//Print the number of parameters in the network (and for each layer)
val layers = model.getLayers()
var totalNumParams = 0
var i = 0
for (i <- 0 to layers.length-1) {
val nParams = layers(i).numParams()
println("Number of parameters in layer " + i + ": " + nParams)
totalNumParams = totalNumParams + nParams
}
println("Total number of network parameters: " + totalNumParams)
前面代码的输出如下:
Number of parameters in layer 0: 510640000
Number of parameters in layer 1: 200020000
Number of parameters in layer 2: 200020000
Number of parameters in layer 3: 25005
Total number of network parameters: 910705005
如我之前所述,我们的网络有 9.1 亿个参数,这是一个巨大的数字。这也给调整超参数带来了巨大的挑战。
网络训练
首先,我们将使用前面的MultiLayerConfiguration创建一个MultiLayerNetwork。然后,我们将初始化网络并在训练集上开始训练:
var j = 0
println("Train model....")
for (j <- 0 to numEpochs-1) {
model.fit(trainingDataIt)
最后,我们还指定我们不需要进行任何预训练(这在 DBN 或堆叠自编码器中通常是必需的)。
评估模型
一旦训练完成,接下来的任务是评估模型,我们将在测试集上完成这项任务。对于评估,我们将使用Evaluation()方法。此方法创建一个具有五个可能类别的评估对象。
首先,让我们对每个测试样本进行迭代评估,并从训练模型中获得网络的预测。最后,eval()方法将预测与真实类别进行核对:
println("Evaluate model....")
val eval: Evaluation = new Evaluation(5) //create an evaluation object with 5 possible classes
while (testDataIt.hasNext()) {
val next:DataSet = testDataIt.next()
val output:INDArray = model.output(next.getFeatureMatrix()) //get the networks prediction
eval.eval(next.getLabels(), output) //check the prediction against the true class
}
println(eval.stats())
println("****************Example finished********************")
}
以下是输出:
==========================Scores========================================
# of classes: 5
Accuracy: 0.9900
Precision: 0.9952
Recall: 0.9824
F1 Score: 0.9886
Precision, recall & F1: macro-averaged (equally weighted avg. of 5 classes)
========================================================================
****************Example finished******************
哇!难以置信!我们的 LSTM 网络已经准确地分类了样本。最后,让我们看看分类器是如何预测每个类别的:
Actual label 0 predicted by the model as 0: 82 times
Actual label 1 predicted by the model as 0: 1 times
Actual label 1 predicted by the model as 1: 17 times
Actual label 2 predicted by the model as 2: 35 times
Actual label 3 predicted by the model as 0: 1 times
Actual label 3 predicted by the model as 3: 30 times
使用 LSTM 进行癌症类型预测的预测准确率异常高,不是吗?我们的模型欠拟合了吗?我们的模型过拟合了吗?
使用 Deeplearning4j UI 观察训练
由于我们的准确率异常高,我们可以观察训练过程。是的,有方法可以找出它是否过度拟合,因为我们可以在 DL4J UI 上观察到训练、验证和测试损失。然而,这里我不会讨论细节。请查看deeplearning4j.org/docs/latest/deeplearning4j-nn-visualization获取更多关于如何做到这一点的信息。
摘要
在本章中,我们看到了如何根据从 TCGA 中精心挑选的非常高维度的基因表达数据集对癌症患者进行基于肿瘤类型的分类。我们的 LSTM 架构成功实现了 99%的准确率,这是非常出色的。尽管如此,我们讨论了 DL4J 的许多方面,这些将在未来的章节中有所帮助。最后,我们看到了与该项目、LSTM 网络和 DL4J 超参数/网络调整相关的常见问题的答案。
这,或多或少,标志着我们使用 Scala 和不同开源框架开发 ML 项目的短暂旅程的结束。在整个章节中,我试图为您提供几个如何高效使用这些优秀技术来开发 ML 项目的示例。在撰写这本书的过程中,我不得不在心中牢记许多限制条件;例如,页数限制、API 可用性,当然还有我的专业知识。
然而,总的来说,我试图通过避免在理论上的不必要细节来使这本书变得简单,因为您可以在许多书籍、博客和网站上找到这些内容。我还会在 GitHub 仓库github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide上更新这本书的代码。您可以随意打开一个新问题或任何 pull request 来改进代码,并保持关注。
尽管如此,我会将每个章节的解决方案上传为 Zeppelin 笔记本,这样您就可以交互式地运行代码。顺便说一句,Zeppelin 是一个基于网页的笔记本,它通过 SQL 和 Scala 实现数据驱动的交互式数据分析以及协作文档。一旦您在您首选的*台配置了 Zeppelin,您就可以从 GitHub 仓库下载笔记本,将它们导入 Zeppelin,然后开始使用。更多详情,您可以查看 zeppelin.apache.org/。


浙公网安备 33010602011771号