Scala-机器学习项目-全-

Scala 机器学习项目(全)

原文:annas-archive.org/md5/4e1b7010caf1fbe3188c9c515fe244d4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

机器学习通过将数据转化为可操作的智能,已经对学术界和工业界产生了巨大的影响。另一方面,Scala 在过去几年中在数据科学和分析领域的应用稳步增长。本书是为那些具备复杂数值计算背景并希望学习更多实践机器学习应用开发的资料科学家、数据工程师和深度学习爱好者编写的。

所以,如果您精通机器学习概念,并希望通过深入实际应用,利用 Scala 的强大功能扩展您的知识,那么这本书正是您所需要的!通过 11 个完整的项目,您将熟悉如 Spark ML、H2O、Zeppelin、DeepLearning4j 和 MXNet 等流行的机器学习库。

阅读完本书并实践所有项目后,您将能够掌握数值计算、深度学习和函数式编程,执行复杂的数值任务。因此,您可以在生产环境中开发、构建并部署研究和商业项目。

本书并不需要从头到尾阅读。您可以翻阅到您正在尝试实现的目标相关的章节,或者那些激发您兴趣的章节。任何改进的反馈都是欢迎的。

祝阅读愉快!

本书的适用人群

如果您想利用 Scala 和开源库(如 Spark ML、Deeplearning4j、H2O、MXNet 和 Zeppelin)的强大功能来理解大数据,那么本书适合您。建议对 Scala 和 Scala Play 框架有较强的理解,基本的机器学习技术知识将是额外的优势。

本书涵盖的内容

第一章,分析保险严重性索赔,展示了如何使用一些广泛使用的回归技术开发预测模型来分析保险严重性索赔。我们将演示如何将此模型部署到生产环境中。

第二章,分析与预测电信流失,使用 Orange Telecoms 流失数据集,其中包含清洗后的客户活动和流失标签,指定客户是否取消了订阅,来开发一个实际的预测模型。

第三章,基于历史数据和实时数据的高频比特币价格预测,展示了如何开发一个收集历史数据和实时数据的实际项目。我们预测未来几周、几个月等的比特币价格。此外,我们演示了如何为比特币在线交易生成简单的信号。最后,本章将整个应用程序作为 Web 应用程序,使用 Scala Play 框架进行包装。

第四章,大规模聚类与种族预测,使用来自 1000 基因组计划的基因组变异数据,应用 K-means 聚类方法对可扩展的基因组数据进行分析。目的是对种群规模的基因型变异进行聚类。最后,我们训练深度神经网络和随机森林模型来预测种族。

第五章,自然语言处理中的主题建模——对大规模文本的更好洞察,展示了如何利用基于 Spark 的 LDA 算法和斯坦福 NLP 开发主题建模应用,处理大规模原始文本。

第六章,开发基于模型的电影推荐引擎,展示了如何通过奇异值分解、ALS 和矩阵分解的相互操作,开发一个可扩展的电影推荐引擎。本章将使用电影镜头数据集进行端到端项目。

第七章,使用 Q 学习和 Scala Play 框架进行期权交易,在现实的 IBM 股票数据集上应用强化 Q 学习算法,并设计一个由反馈和奖励驱动的机器学习系统。目标是开发一个名为期权交易的实际应用。最后,本章将整个应用作为 Web 应用封装,使用 Scala Play 框架。

第八章,使用深度神经网络进行银行电话营销的客户订阅评估,是一个端到端项目,展示了如何解决一个名为客户订阅评估的现实问题。将使用银行电话营销数据集训练一个 H2O 深度神经网络。最后,本章评估该预测模型的性能。

第九章,使用自编码器和异常检测进行欺诈分析,使用自编码器和异常检测技术进行欺诈分析。所用数据集是由 Worldline 与ULB布鲁塞尔自由大学)机器学习小组在研究合作期间收集和分析的欺诈检测数据集。

第十章,使用递归神经网络进行人体活动识别,包括另一个端到端项目,展示了如何使用名为 LSTM 的 RNN 实现进行人体活动识别,使用智能手机传感器数据集。

第十一章,使用卷积神经网络进行图像分类,展示了如何开发预测分析应用,如图像分类,使用卷积神经网络对名为 Yelp 的真实图像数据集进行处理。

为了最大限度地利用本书

本书面向开发人员、数据分析师和深度学习爱好者,适合那些对复杂数值计算没有太多背景知识,但希望了解深度学习是什么的人。建议具备扎实的 Scala 编程基础及其函数式编程概念。对 Spark ML、H2O、Zeppelin、DeepLearning4j 和 MXNet 的基本了解及高层次知识将有助于理解本书。此外,假设读者具备基本的构建工具(如 Maven 和 SBT)知识。

所有示例都使用 Scala 在 Ubuntu 16.04 LTS 64 位和 Windows 10 64 位系统上实现。你还需要以下内容(最好是最新版本):

  • Apache Spark 2.0.0(或更高版本)

  • MXNet、Zeppelin、DeepLearning4j 和 H2O(请参见章节和提供的pom.xml文件中的详细信息)

  • Hadoop 2.7(或更高版本)

  • Java(JDK 和 JRE)1.7+/1.8+

  • Scala 2.11.x(或更高版本)

  • Eclipse Mars 或 Luna(最新版本),带有 Maven 插件(2.9+)、Maven 编译插件(2.3.2+)和 Maven 组装插件(2.4.1+)

  • IntelliJ IDE

  • 安装 SBT 插件和 Scala Play 框架

需要一台至少配备 Core i3 处理器的计算机,建议使用 Core i5,或者使用 Core i7 以获得最佳效果。然而,多核处理将提供更快的数据处理和可扩展性。对于独立模式,建议至少有 8GB RAM;对于单个虚拟机,使用至少 32GB RAM,对于集群则需要更高配置。你应该有足够的存储空间来运行大型作业(具体取决于你将处理的数据集大小);最好有至少 50GB 的空闲硬盘存储空间(独立模式和 SQL 数据仓库均适用)。

推荐使用 Linux 发行版(包括 Debian、Ubuntu、Fedora、RHEL、CentOS 等)。更具体地说,例如,对于 Ubuntu,建议使用 14.04(LTS)64 位(或更高版本)的完整安装,VMWare Player 12 或 VirtualBox。你可以在 Windows(XP/7/8/10)或 Mac OS X(10.4.7+)上运行 Spark 作业。

下载示例代码文件

你可以从www.packtpub.com的账户中下载本书的示例代码文件。如果你从其他地方购买了本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择 SUPPORT 标签。

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

  4. 在搜索框中输入书名,并按照屏幕上的指示操作。

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

  • Windows 的 WinRAR/7-Zip

  • Zipeg/iZip/UnRarX(适用于 Mac)

  • Linux 的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,地址是 github.com/PacktPublishing/Scala-Machine-Learning-Projects。我们还提供了来自我们丰富书籍和视频目录的其他代码包,地址是 github.com/PacktPublishing/。快来看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,里面包含本书中使用的截图/图表的彩色图像。你可以在这里下载:www.packtpub.com/sites/default/files/downloads/ScalaMachineLearningProjects_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。例如:“将下载的 WebStorm-10*.dmg 磁盘镜像文件挂载为系统中的另一个磁盘。”

代码块的设置如下:

val cv = new CrossValidator()
      .setEstimator(pipeline)
      .setEvaluator(new RegressionEvaluator)
      .setEstimatorParamMaps(paramGrid)
      .setNumFolds(numFolds)

Scala 功能代码块如下所示:

 def variantId(genotype: Genotype): String = {
      val name = genotype.getVariant.getContigName
      val start = genotype.getVariant.getStart
      val end = genotype.getVariant.getEnd
      s"$name:$start:$end"
  }

当我们希望特别提醒你注意某个代码块的部分内容时,相关的行或项会以粗体显示:

var paramGrid = new ParamGridBuilder()
      .addGrid(dTree.impurity, "gini" :: "entropy" :: Nil)
      .addGrid(dTree.maxBins, 3 :: 5 :: 9 :: 15 :: 23 :: 31 :: Nil)
      .addGrid(dTree.maxDepth, 5 :: 10 :: 15 :: 20 :: 25 :: 30 :: Nil)
      .build()

任何命令行输入或输出都按如下方式书写:

$ sudo mkdir Bitcoin
$ cd Bitcoin

粗体:表示新术语、重要词汇或屏幕上显示的词汇。例如,菜单或对话框中的单词会像这样显示在文本中。示例:“在管理面板中选择系统信息。”

警告或重要提示会像这样显示。

提示和技巧会像这样显示。

与我们联系

我们始终欢迎读者的反馈。

一般反馈:通过电子邮件 feedback@packtpub.com 联系我们,并在邮件主题中注明书名。如果你对本书的任何部分有疑问,请通过 questions@packtpub.com 联系我们。

勘误:尽管我们已尽力确保内容的准确性,但难免会有错误。如果你发现本书中的错误,请向我们报告。请访问 www.packtpub.com/submit-errata,选择你的书籍,点击“勘误提交表单”链接,输入详细信息。

盗版:如果你在互联网上发现任何非法复制的我们的作品,请提供该位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并附上相关材料的链接。

如果你有兴趣成为一名作者:如果你对某个领域有专长,并且有兴趣写书或为书籍做贡献,请访问 authors.packtpub.com

评价

请留下评论。在阅读并使用本书后,何不在您购买书籍的网站上留下评论?潜在读者可以看到并参考您的客观意见做出购买决定,我们在 Packt 也能了解您对我们产品的看法,而我们的作者也能看到您对他们书籍的反馈。谢谢!

欲了解更多关于 Packt 的信息,请访问packtpub.com

第一章:分析保险赔付严重度

预测保险公司理赔的成本,从而预测赔付严重度,是一个需要准确解决的现实问题。在本章中,我们将向您展示如何使用一些最广泛使用的回归算法,开发一个用于分析保险赔付严重度的预测模型。

我们将从简单的线性回归LR)开始,看看如何通过一些集成技术(如梯度提升树GBT)回归器)提高性能。接着我们将研究如何使用随机森林回归器提升性能。最后,我们将展示如何选择最佳模型并将其部署到生产环境中。此外,我们还将提供一些关于机器学习工作流程、超参数调优和交叉验证的背景知识。

对于实现,我们将使用Spark ML API,以实现更快的计算和大规模扩展。简而言之,在整个端到端项目中,我们将学习以下主题:

  • 机器学习与学习工作流程

  • 机器学习模型的超参数调优与交叉验证

  • 用于分析保险赔付严重度的线性回归(LR)

  • 使用梯度提升回归器提高性能

  • 使用随机森林回归器提升性能

  • 模型部署

机器学习与学习工作流程

机器学习ML)是使用一组统计学和数学算法来执行任务,如概念学习、预测建模、聚类和挖掘有用模式。最终目标是以一种自动化的方式改进学习,使得不再需要人工干预,或者尽可能减少人工干预的程度。

我们现在引用Tom M. Mitchell在《机器学习》一书中的著名定义(*Tom Mitchell, McGraw Hill, 1997**),他从计算机科学的角度解释了什么是学习:

“计算机程序被称为从经验 E 中学习,针对某类任务 T 和性能度量 P,如果它在 T 类任务中的表现,通过 P 衡量,在经验 E 下有所提高。”

根据前面的定义,我们可以得出结论:计算机程序或机器可以执行以下操作:

  • 从数据和历史中学习

  • 通过经验得到改善

  • 交互式地增强一个可以用来预测结果的模型

一个典型的机器学习(ML)函数可以被表示为一个凸优化问题,目的是找到一个凸函数f的最小值,该函数依赖于一个变量向量w(权重),并且包含d条记录。形式上,我们可以将其写为以下优化问题:

在这里,目标函数的形式为:

在这里,向量 1≤i≤n 的训练数据点,它们是我们最终想要预测的相应标签。如果 L(w;x,y) 可以表示为 wTxy 的函数,我们称该方法为线性

目标函数 f 有两个组成部分:

  • 控制模型复杂度的正则化器

  • 测量模型在训练数据上误差的损失函数

损失函数 L(w;) 通常是 w 的凸函数。固定的正则化参数 λ≥0 定义了训练误差最小化和模型复杂度最小化之间的权衡,以避免过拟合。在各章节中,我们将详细学习不同的学习类型和算法。

另一方面,深度神经网络DNN)是深度学习DL)的核心,它通过提供建模复杂和高级数据抽象的算法,能够更好地利用大规模数据集来构建复杂的模型。

有一些广泛使用的基于人工神经网络的深度学习架构:DNN、胶囊网络、限制玻尔兹曼机、深度信念网络、矩阵分解机和递归神经网络。

这些架构已广泛应用于计算机视觉、语音识别、自然语言处理、音频识别、社交网络过滤、机器翻译、生物信息学和药物设计等领域。在各章节中,我们将看到多个使用这些架构的实际案例,以实现最先进的预测精度。

典型的机器学习工作流程

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

  1. 加载数据

  2. 将数据解析成算法所需的输入格式

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

  4. 将数据分为三个集合,分别用于训练、测试和验证(训练集和验证集),以及一个用于测试模型(测试数据集)

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

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

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

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

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

  10. 在生产环境中部署机器学习模型:

图 1:机器学习工作流程

上述工作流程表示了解决机器学习问题的几个步骤,其中,机器学习任务可以大致分为监督学习、无监督学习、半监督学习、强化学习和推荐系统。下面的图 2,监督学习的应用显示了监督学习的示意图。当算法找到了所需的模式后,这些模式可以用于对未标记的测试数据进行预测:

图 2:监督学习的应用

示例包括用于解决监督学习问题的分类和回归,从而可以基于这些问题构建预测分析的预测模型。在接下来的章节中,我们将提供多个监督学习的示例,如 LR、逻辑回归、随机森林、决策树、朴素贝叶斯、多层感知机等。

回归算法旨在产生连续的输出。输入可以是离散的也可以是连续的:

图 3:回归算法旨在产生连续输出

而分类算法则旨在从一组离散或连续的输入值中产生离散的输出。这一区别很重要,因为离散值的输出更适合由分类处理,这将在后续章节中讨论:

图 4:分类算法旨在产生离散输出

在本章中,我们将主要关注监督回归算法。我们将从描述问题陈述开始,然后介绍非常简单的 LR 算法。通常,这些机器学习模型的性能通过超参数调整和交叉验证技术进行优化。因此,简要了解它们是必要的,这样我们才能在后续章节中轻松使用它们。

超参数调整和交叉验证

调整算法简单来说是一个过程,通过这个过程可以使算法在运行时间和内存使用方面达到最佳表现。在贝叶斯统计学中,超参数是先验分布的一个参数。在机器学习中,超参数指的是那些无法通过常规训练过程直接学习到的参数。

超参数通常在实际训练过程开始之前就已固定。通过为这些超参数设置不同的值,训练不同的模型,然后通过测试它们来决定哪些效果最好。以下是一些典型的此类参数示例:

  • 树的叶子数、箱数或深度

  • 迭代次数

  • 矩阵分解中的潜在因子数量

  • 学习率

  • 深度神经网络中的隐藏层数量

  • k-means 聚类中的簇数量等等

简而言之,超参数调优是一种根据所呈现数据的表现选择合适的超参数组合的技术。它是从机器学习算法中获取有意义和准确结果的基本要求之一。下图展示了模型调优过程、需要考虑的事项以及工作流程:

图 5:模型调优过程

交叉验证(也称为旋转估计)是一种用于评估统计分析和结果质量的模型验证技术。其目标是使模型对独立的测试集具有较强的泛化能力。如果你希望估计预测模型在实践中部署为机器学习应用时的表现,交叉验证会有所帮助。在交叉验证过程中,通常会使用已知类型的数据集训练模型。

相反,它是使用一个未知类型的数据集进行测试。在这方面,交叉验证有助于通过使用验证集在训练阶段描述数据集,以测试模型。有两种类型的交叉验证,具体如下:

  • 穷尽性交叉验证:包括留 p 交叉验证和留一交叉验证

  • 非穷尽性交叉验证:包括 K 折交叉验证和重复随机子抽样交叉验证

在大多数情况下,研究人员/数据科学家/数据工程师使用 10 折交叉验证,而不是在验证集上进行测试(见 图 610 折交叉验证技术)。正如下图所示,这种交叉验证技术是所有使用案例和问题类型中最广泛使用的。

基本上,使用该技术时,您的完整训练数据会被分割成若干个折叠。这个参数是可以指定的。然后,整个流程会针对每个折叠运行一次,并为每个折叠训练一个机器学习模型。最后,通过分类器的投票机制或回归的平均值将获得的不同机器学习模型结合起来:

图 6:10 折交叉验证技术

此外,为了减少变异性,交叉验证会进行多次迭代,使用不同的数据分割;最后,验证结果会在各轮次中进行平均。

分析和预测保险索赔的严重性

预测保险公司索赔的费用,从而推测其严重性,是一个需要以更精确和自动化的方式解决的现实问题。在本示例中,我们将做类似的事情。

我们将从简单的逻辑回归开始,并学习如何使用一些集成技术(如随机森林回归器)来提高性能。接着,我们将看看如何使用梯度提升回归器来进一步提升性能。最后,我们将展示如何选择最佳模型并将其部署到生产环境中。

动机

当一个人遭遇严重的车祸时,他的关注点在于自己的生命、家人、孩子、朋友和亲人。然而,一旦提交了保险索赔文件,计算索赔严重程度的整个纸质流程就成了一项繁琐的任务。

这就是为什么保险公司不断寻求创新思路,自动化改进客户索赔服务的原因。因此,预测分析是预测索赔费用,从而预测其严重程度的可行解决方案,基于现有和历史数据。

数据集描述

将使用来自Allstate 保险公司的数据集,该数据集包含超过 30 万个示例,数据是经过掩码处理和匿名化的,并包含超过 100 个分类和数值属性,符合保密性约束,足够用于构建和评估各种机器学习技术。

数据集是从 Kaggle 网站下载的,网址是 www.kaggle.com/c/allstate-claims-severity/data。数据集中的每一行代表一次保险索赔。现在,任务是预测loss列的值。以cat开头的变量是分类变量,而以cont开头的变量是连续变量。

需要注意的是,Allstate 公司是美国第二大保险公司,成立于 1931 年。我们正在努力使整个过程自动化,预测事故和损坏索赔的费用,从而预测其严重程度。

数据集的探索性分析

让我们看看一些数据属性(可以使用EDA.scala文件)。首先,我们需要读取训练集,以查看可用的属性。首先,将你的训练集放在项目目录或其他位置,并相应地指向它:

val train = "data/insurance_train.csv"

我希望你已经在机器上安装并配置了 Java、Scala 和 Spark。如果没有,请先完成安装。无论如何,我假设它们已经安装好了。那么,让我们创建一个活跃的 Spark 会话,这是任何 Spark 应用程序的入口:

val spark = SparkSessionCreate.createSession()
import spark.implicits._

Scala REPL 中的 Spark 会话别名

如果你在 Scala REPL 中,Spark 会话别名spark已经定义好了,所以可以直接开始。

在这里,我有一个名为createSession()的方法,它位于SparkSessionCreate类中,代码如下:

import org.apache.spark.sql.SparkSession 

object SparkSessionCreate { 
  def createSession(): SparkSession = { 
    val spark = SparkSession 
      .builder 
      .master("local[*]") // adjust accordingly 
      .config("spark.sql.warehouse.dir", "E:/Exp/") //change accordingly 
      .appName("MySparkSession") //change accordingly 
      .getOrCreate() 
    return spark 
    }
} 

由于在本书中会频繁使用此功能,我决定创建一个专门的方法。因此,我们使用read.csv方法加载、解析并创建 DataFrame,但使用 Databricks .csv 格式(也称为com.databricks.spark.csv),因为我们的数据集是以.csv格式提供的。

此时,我必须打断一下,告诉你一个非常有用的信息。由于我们将在接下来的章节中使用 Spark MLlib 和 ML API,因此,提前解决一些问题是值得的。如果你是 Windows 用户,那么我得告诉你一个很奇怪的问题,你在使用 Spark 时可能会遇到。

好的,事情是这样的,Spark 可以在WindowsMac OSLinux上运行。当你在 Windows 上使用EclipseIntelliJ IDEA开发 Spark 应用程序(或者通过 Spark 本地作业提交)时,你可能会遇到 I/O 异常错误,导致应用程序无法成功编译或被中断。

原因在于 Spark 期望在 Windows 上有一个Hadoop的运行环境。不幸的是,Spark二进制发布版(例如v2.2.0)不包含一些 Windows 本地组件(例如,winutils.exehadoop.dll等)。然而,这些是运行Hadoop在 Windows 上所必需的(而不是可选的)。因此,如果你无法确保运行环境,就会出现类似以下的 I/O 异常:

24/01/2018 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 系统:

  1. 来自 IDE,如 Eclipse 和 IntelliJ IDEA:从github.com/steveloughran/winutils/tree/master/hadoop-2.7.1/bin/下载winutils.exe。然后下载并将其复制到 Spark 分发版中的bin文件夹——例如,spark-2.2.0-bin-hadoop2.7/bin/。然后选择项目 | 运行配置... | 环境 | 新建 | 创建一个名为HADOOP_HOME的变量,并在值字段中填入路径——例如,c:/spark-2.2.0-bin-hadoop2.7/bin/ | 确定 | 应用 | 运行。这样就完成了!

  2. 使用本地 Spark 作业提交:将winutils.exe文件路径添加到 hadoop 主目录,使用 System 设置属性——例如,在 Spark 代码中System.setProperty("hadoop.home.dir", "c:\\\spark-2.2.0-bin-hadoop2.7\\\bin\winutils.exe")

好的,让我们回到你原始的讨论。如果你看到上面的代码块,我们设置了读取 CSV 文件的头部,它直接应用于创建的 DataFrame 的列名,并且inferSchema属性被设置为true。如果你没有明确指定inferSchema配置,浮动值将被视为strings. 这可能导致VectorAssembler抛出像java.lang.IllegalArgumentException: Data type StringType is not supported的异常:

 val trainInput = spark.read 
    .option("header", "true") 
    .option("inferSchema", "true") 
    .format("com.databricks.spark.csv") 
    .load(train) 
    .cache 

现在让我们打印一下我们刚才创建的 DataFrame 的 schema。我已经简化了输出,只显示了几个列:

Println(trainInput.printSchema()) 
root 
 |-- id: integer (nullable = true) 
 |-- cat1: string (nullable = true) 
 |-- cat2: string (nullable = true) 
 |-- cat3: string (nullable = true) 
  ... 
 |-- cat115: string (nullable = true) 
 |-- cat116: string (nullable = true)
  ... 
 |-- cont14: double (nullable = true) 
 |-- loss: double (nullable = true) 

你可以看到有 116 个分类列用于分类特征。还有 14 个数值特征列。现在让我们使用count()方法看看数据集中有多少行:

println(df.count())
>>>
 188318 

上述的数字对于训练 ML 模型来说相当高。好的,现在让我们通过show()方法查看数据集的快照,但只选取一些列,以便更有意义。你可以使用df.show()来查看所有列:

df.select("id", "cat1", "cat2", "cat3", "cont1", "cont2", "cont3", "loss").show() 
>>> 

然而,如果你使用df.show()查看所有行,你会看到一些分类列包含了过多的类别。更具体地说,cat109cat116这些分类列包含了过多的类别,具体如下:

df.select("cat109", "cat110", "cat112", "cat113", "cat116").show() 
>>> 

在后续阶段,值得删除这些列,以去除数据集中的偏斜性。需要注意的是,在统计学中,偏斜度是衡量一个实值随机变量的概率分布相对于均值的非对称性的一种度量。

现在我们已经看到了数据集的快照,接下来值得查看一些其他统计信息,比如平均索赔或损失、最小值、最大损失等等,使用 Spark SQL 来进行计算。但在此之前,我们先将最后一列的loss重命名为label,因为 ML 模型会对此产生警告。即使在回归模型中使用setLabelCol,它仍然会查找名为label的列。这会导致一个令人烦恼的错误,提示org.apache.spark.sql.AnalysisException: cannot resolve 'label' given input columns

val newDF = df.withColumnRenamed("loss", "label") 

现在,由于我们想要执行 SQL 查询,我们需要创建一个临时视图,以便操作可以在内存中执行:

newDF.createOrReplaceTempView("insurance") 

现在让我们计算客户声明的平均损失:

spark.sql("SELECT avg(insurance.label) as AVG_LOSS FROM insurance").show()
>>>
+------------------+
| AVG_LOSS |
+------------------+
|3037.3376856699924|
+------------------+

类似地,让我们看看到目前为止的最低索赔:

spark.sql("SELECT min(insurance.label) as MIN_LOSS FROM insurance").show() 
>>>  
+--------+
|MIN_LOSS|
+--------+
| 0.67|
+--------+

让我们看看到目前为止的最高索赔:

spark.sql("SELECT max(insurance.label) as MAX_LOSS FROM insurance").show() 
>>> 
+---------+
| MAX_LOSS|
+---------+
|121012.25|
+---------+

由于 Scala 或 Java 没有自带便捷的可视化库,我暂时无法做其他处理,但现在我们集中精力在数据预处理上,准备训练集之前进行清理。

数据预处理

既然我们已经查看了一些数据属性,接下来的任务是进行一些预处理,如清理数据,然后再准备训练集。对于这一部分,请使用Preprocessing.scala文件。对于这部分,需要以下导入:

import org.apache.spark.ml.feature.{ StringIndexer, StringIndexerModel}
import org.apache.spark.ml.feature.VectorAssembler

然后我们加载训练集和测试集,如以下代码所示:

var trainSample = 1.0 
var testSample = 1.0 
val train = "data/insurance_train.csv" 
val test = "data/insurance_test.csv" 
val spark = SparkSessionCreate.createSession() 
import spark.implicits._ 
println("Reading data from " + train + " file") 

 val trainInput = spark.read 
        .option("header", "true") 
        .option("inferSchema", "true") 
        .format("com.databricks.spark.csv") 
        .load(train) 
        .cache 

    val testInput = spark.read 
        .option("header", "true") 
        .option("inferSchema", "true") 
        .format("com.databricks.spark.csv") 
        .load(test) 
        .cache 

下一步任务是为我们的 ML 模型准备训练集和测试集。在之前的训练数据框中,我们将loss重命名为label。接着,将train.csv的内容分割为训练数据和(交叉)验证数据,分别为 75%和 25%。

test.csv的内容用于评估 ML 模型。两个原始数据框也进行了采样,这对在本地机器上运行快速执行非常有用:

println("Preparing data for training model") 
var data = trainInput.withColumnRenamed("loss", "label").sample(false, trainSample) 

我们还应该进行空值检查。这里,我采用了一种简单的方法。因为如果训练数据框架中包含任何空值,我们就会完全删除这些行。这是有意义的,因为在 188,318 行数据中,删除少数几行并不会造成太大问题。不过,你也可以采取其他方法,如空值插补:

var DF = data.na.drop() 
if (data == DF) 
  println("No null values in the DataFrame")     
else{ 
  println("Null values exist in the DataFrame") 
  data = DF 
} 
val seed = 12345L 
val splits = data.randomSplit(Array(0.75, 0.25), seed) 
val (trainingData, validationData) = (splits(0), splits(1)) 

接着我们缓存这两个数据集,以便更快速地进行内存访问:

trainingData.cache 
validationData.cache 

此外,我们还应该对测试集进行采样,这是评估步骤中所需要的:

val testData = testInput.sample(false, testSample).cache 

由于训练集包含了数值型和分类值,我们需要分别识别并处理它们。首先,让我们只识别分类列:

def isCateg(c: String): Boolean = c.startsWith("cat") 
def categNewCol(c: String): String = if (isCateg(c)) s"idx_${c}" else c 

接下来,使用以下方法删除类别过多的列,这是我们在前一节中已经讨论过的:

def removeTooManyCategs(c: String): Boolean = !(c matches "cat(109$|110$|112$|113$|116$)")

接下来使用以下方法只选择特征列。所以本质上,我们应该删除 ID 列(因为 ID 只是客户的识别号码,不包含任何非平凡的信息)和标签列:

def onlyFeatureCols(c: String): Boolean = !(c matches "id|label") 

好的,到目前为止,我们已经处理了一些无关或不需要的列。现在下一步任务是构建最终的特征列集:

val featureCols = trainingData.columns 
    .filter(removeTooManyCategs) 
    .filter(onlyFeatureCols) 
    .map(categNewCol) 

StringIndexer将给定的字符串标签列编码为标签索引列。如果输入列是数值类型的,我们使用StringIndexer将其转换为字符串,并对字符串值进行索引。当下游管道组件(如 Estimator 或 Transformer)使用这个字符串索引标签时,必须将该组件的输入列设置为该字符串索引列名。在许多情况下,你可以通过setInputCol来设置输入列。

现在,我们需要使用StringIndexer()来处理类别列:

val stringIndexerStages = trainingData.columns.filter(isCateg) 
      .map(c => new StringIndexer() 
      .setInputCol(c) 
      .setOutputCol(categNewCol(c)) 
      .fit(trainInput.select(c).union(testInput.select(c)))) 

请注意,这不是一种高效的方法。另一种替代方法是使用 OneHotEncoder 估算器。

OneHotEncoder 将标签索引列映射到二进制向量列,每个向量最多只有一个值为 1。该编码允许期望连续特征的算法(如逻辑回归)利用类别特征。

现在让我们使用VectorAssembler()将给定的列列表转换为单一的向量列:

val assembler = new VectorAssembler() 
    .setInputCols(featureCols) 
    .setOutputCol("features")

VectorAssembler是一个转换器。它将给定的列列表合并为单一的向量列。它对于将原始特征和由不同特征转换器生成的特征合并为一个特征向量非常有用,以便训练机器学习模型,如逻辑回归和决策树。

在开始训练回归模型之前,这就是我们需要做的全部。首先,我们开始训练 LR 模型并评估其性能。

用 LR 预测保险赔偿的严重程度

正如你已经看到的,预测的损失包含连续值,也就是说,这是一个回归任务。因此,在此使用回归分析时,目标是预测一个连续的目标变量,而另一个领域——分类,则预测从有限集合中选择一个标签。

逻辑回归LR)属于回归算法家族。回归的目标是寻找变量之间的关系和依赖性。它通过线性函数建模连续标量因变量y(即标签或目标)与一个或多个(D 维向量)解释变量(也称为自变量、输入变量、特征、观察数据、观测值、属性、维度和数据点)x之间的关系:

图 9:回归图将数据点(红色圆点)分开,蓝线为回归线

LR 模型描述了因变量 y 与一组相互依赖的自变量 x[i] 之间的关系。字母 AB 分别表示描述 y 轴截距和回归线斜率的常数:

y = A+Bx

图 9回归图将数据点(红色点)与回归线(蓝色线)分开,显示了一个简单的 LR 示例,只有一个自变量——即一组数据点和一个最佳拟合线,这是回归分析的结果。可以观察到,这条线并不完全通过所有数据点。

任何数据点(实际测量值)与回归线(预测值)之间的距离称为回归误差。较小的误差有助于更准确地预测未知值。当误差被减少到最小水平时,最终的回归误差会生成最佳拟合线。请注意,在回归误差方面没有单一的度量标准,以下是几种常见的度量:

  • 均方误差MSE):它是衡量拟合线与数据点接近程度的指标。MSE 越小,拟合程度越接近数据。

  • 均方根误差RMSE):它是均方误差(MSE)的平方根,但可能是最容易解释的统计量,因为它与纵轴上绘制的量具有相同的单位。

  • R 平方:R 平方是衡量数据与拟合回归线之间接近程度的统计量。R 平方总是介于 0 和 100%之间。R 平方越高,模型越能拟合数据。

  • 平均绝对误差MAE):MAE 衡量一组预测中误差的平均幅度,而不考虑其方向。它是测试样本中预测值与实际观察值之间的绝对差异的平均值,其中所有个体差异具有相同的权重。

  • 解释方差:在统计学中,解释方差衡量数学模型在多大程度上能够解释给定数据集的变化。

使用 LR 开发保险赔偿严重性预测模型

在本小节中,我们将开发一个预测分析模型,用于预测客户在事故损失中的赔偿严重性。我们从导入所需的库开始:

import org.apache.spark.ml.regression.{LinearRegression, LinearRegressionModel} 
import org.apache.spark.ml.{ Pipeline, PipelineModel } 
import org.apache.spark.ml.evaluation.RegressionEvaluator 
import org.apache.spark.ml.tuning.ParamGridBuilder 
import org.apache.spark.ml.tuning.CrossValidator 
import org.apache.spark.sql._ 
import org.apache.spark.sql.functions._ 
import org.apache.spark.mllib.evaluation.RegressionMetrics 

然后,我们创建一个活动的 Spark 会话,作为应用程序的入口点。此外,导入 implicits__,这是隐式转换所需的,如将 RDD 转换为 DataFrame。

val spark = SparkSessionCreate.createSession() 
import spark.implicits._ 

然后,我们定义一些超参数,如交叉验证的折数、最大迭代次数、回归参数的值、容差值以及弹性网络参数,如下所示:

val numFolds = 10 
val MaxIter: Seq[Int] = Seq(1000) 
val RegParam: Seq[Double] = Seq(0.001) 
val Tol: Seq[Double] = Seq(1e-6) 
val ElasticNetParam: Seq[Double] = Seq(0.001) 

现在,我们创建一个 LR 估计器:

val model = new LinearRegression()
        .setFeaturesCol("features")
        .setLabelCol("label") 

现在,让我们通过连接变换器和 LR 估计器来构建一个管道估计器:

println("Building ML pipeline") 
val pipeline = new Pipeline()
         .setStages((Preproessing.stringIndexerStages  
         :+ Preproessing.assembler) :+ model)

Spark ML 管道包含以下组件:

  • 数据框:用作中央数据存储,所有原始数据和中间结果都存储在这里。

  • 转换器:转换器通过添加额外的特征列将一个 DataFrame 转换成另一个 DataFrame。转换器是无状态的,意味着它们没有内部记忆,每次使用时的行为都完全相同。

  • 估算器:估算器是一种机器学习模型。与转换器不同,估算器包含内部状态表示,并且高度依赖于它已经见过的数据历史。

  • 管道:将前面的组件、DataFrame、转换器和估算器连接在一起。

  • 参数:机器学习算法有许多可调整的参数。这些称为超参数,而机器学习算法通过学习数据来拟合模型的值称为参数

在开始执行交叉验证之前,我们需要有一个参数网格(paramgrid)。所以让我们通过指定最大迭代次数、回归参数值、公差值和弹性网络参数来创建参数网格,如下所示:

val paramGrid = new ParamGridBuilder() 
      .addGrid(model.maxIter, MaxIter) 
      .addGrid(model.regParam, RegParam) 
      .addGrid(model.tol, Tol) 
      .addGrid(model.elasticNetParam, ElasticNetParam) 
      .build() 

现在,为了获得更好且稳定的性能,让我们准备 K 折交叉验证和网格搜索作为模型调优的一部分。正如你们可能猜到的,我将进行 10 折交叉验证。根据你的设置和数据集,可以自由调整折数:

println("Preparing K-fold Cross Validation and Grid Search: Model tuning") 
val cv = new CrossValidator() 
      .setEstimator(pipeline) 
      .setEvaluator(new RegressionEvaluator) 
      .setEstimatorParamMaps(paramGrid) 
      .setNumFolds(numFolds) 

太棒了——我们已经创建了交叉验证估算器。现在是训练 LR 模型的时候了:

println("Training model with Linear Regression algorithm") 
val cvModel = cv.fit(Preproessing.trainingData) 

现在,我们已经有了拟合的模型,这意味着它现在能够进行预测。所以,让我们开始在训练集和验证集上评估模型,并计算 RMSE、MSE、MAE、R 平方等指标:

println("Evaluating model on train and validation set and calculating RMSE") 
val trainPredictionsAndLabels = cvModel.transform(Preproessing.trainingData)
                .select("label", "prediction")
                .map { case Row(label: Double, prediction: Double) 
                => (label, prediction) }.rdd 

val validPredictionsAndLabels = cvModel.transform(Preproessing.validationData)
                                .select("label", "prediction")
                                .map { case Row(label: Double, prediction: Double) 
                                => (label, prediction) }.rdd 

val trainRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels) 
val validRegressionMetrics = new RegressionMetrics(validPredictionsAndLabels) 

太棒了!我们已经成功计算了训练集和测试集的原始预测结果。接下来,让我们寻找最佳模型:

val bestModel = cvModel.bestModel.asInstanceOf[PipelineModel] 

一旦我们有了最佳拟合并且通过交叉验证的模型,我们可以期望得到良好的预测准确性。现在,让我们观察训练集和验证集上的结果:

val results = "n=====================================================================n" + s"Param trainSample: ${Preproessing.trainSample}n" + 
      s"Param testSample: ${Preproessing.testSample}n" + 
      s"TrainingData count: ${Preproessing.trainingData.count}n" + 
      s"ValidationData count: ${Preproessing.validationData.count}n" + 
      s"TestData count: ${Preproessing.testData.count}n" +      "=====================================================================n" +   s"Param maxIter = ${MaxIter.mkString(",")}n" + 
      s"Param numFolds = ${numFolds}n" +      "=====================================================================n" +   s"Training data MSE = ${trainRegressionMetrics.meanSquaredError}n" + 
      s"Training data RMSE = ${trainRegressionMetrics.rootMeanSquaredError}n" + 
      s"Training data R-squared = ${trainRegressionMetrics.r2}n" + 
      s"Training data MAE = ${trainRegressionMetrics.meanAbsoluteError}n" + 
      s"Training data Explained variance = ${trainRegressionMetrics.explainedVariance}n" +      "=====================================================================n" +   s"Validation data MSE = ${validRegressionMetrics.meanSquaredError}n" + 
      s"Validation data RMSE = ${validRegressionMetrics.rootMeanSquaredError}n" + 
      s"Validation data R-squared = ${validRegressionMetrics.r2}n" + 
      s"Validation data MAE = ${validRegressionMetrics.meanAbsoluteError}n" + 
      s"Validation data Explained variance = ${validRegressionMetrics.explainedVariance}n" + 
      s"CV params explained: ${cvModel.explainParams}n" + 
      s"LR params explained: ${bestModel.stages.last.asInstanceOf[LinearRegressionModel].explainParams}n" +      "=====================================================================n" 

现在,我们将打印前面的结果,如下所示:

println(results)
>>> 
Building Machine Learning pipeline 
Reading data from data/insurance_train.csv file 
Null values exist in the DataFrame 
Training model with Linear Regression algorithm
===================================================================== 
Param trainSample: 1.0 
Param testSample: 1.0 
TrainingData count: 141194 
ValidationData count: 47124 
TestData count: 125546 
===================================================================== 
Param maxIter = 1000 
Param numFolds = 10 
===================================================================== 
Training data MSE = 4460667.3666198505 
Training data RMSE = 2112.0292059107164 
Training data R-squared = -0.1514435541595276 
Training data MAE = 1356.9375609756164 
Training data Explained variance = 8336528.638733305 
===================================================================== 
Validation data MSE = 4839128.978963534 
Validation data RMSE = 2199.802031766389 
Validation data R-squared = -0.24922962724089603 
Validation data MAE = 1356.419484419514 
Validation data Explained variance = 8724661.329105612 
CV params explained: estimator: estimator for selection (current: pipeline_d5024480c670) 
estimatorParamMaps: param maps for the estimator (current: [Lorg.apache.spark.ml.param.ParamMap;@2f0c9855) 
evaluator: evaluator used to select hyper-parameters that maximize the validated metric (current: regEval_00c707fcaa06) 
numFolds: number of folds for cross validation (>= 2) (default: 3, current: 10) 
seed: random seed (default: -1191137437) 
LR params explained: aggregationDepth: suggested depth for treeAggregate (>= 2) (default: 2) 
elasticNetParam: the ElasticNet mixing parameter, in range [0, 1]. For alpha = 0, the penalty is an L2 penalty. For alpha = 1, it is an L1 penalty (default: 0.0, current: 0.001) 
featuresCol: features column name (default: features, current: features) 
fitIntercept: whether to fit an intercept term (default: true) 
labelCol: label column name (default: label, current: label) 
maxIter: maximum number of iterations (>= 0) (default: 100, current: 1000) 
predictionCol: prediction column name (default: prediction) 
regParam: regularization parameter (>= 0) (default: 0.0, current: 0.001) 
solver: the solver algorithm for optimization. If this is not set or empty, default value is 'auto' (default: auto) 
standardization: whether to standardize the training features before fitting the model (default: true) 
tol: the convergence tolerance for iterative algorithms (>= 0) (default: 1.0E-6, current: 1.0E-6) 
weightCol: weight column name. If this is not set or empty, we treat all instance weights as 1.0 (undefined) 
===================================================================== 

所以,我们的预测模型在训练集和测试集上的 MAE 约为1356.419484419514。然而,在 Kaggle 的公共和私人排行榜上,MAE 要低得多(请访问:www.kaggle.com/c/allstate-claims-severity/leaderboard),公共和私人的 MAE 分别为 1096.92532 和 1109.70772。

等等!我们还没有完成。我们仍然需要在测试集上进行预测:

println("Run prediction on the test set") 
cvModel.transform(Preproessing.testData) 
      .select("id", "prediction") 
      .withColumnRenamed("prediction", "loss") 
      .coalesce(1) // to get all the predictions in a single csv file 
      .write.format("com.databricks.spark.csv")
      .option("header", "true") 
      .save("output/result_LR.csv")

前面的代码应生成一个名为result_LR.csv的 CSV 文件。如果我们打开文件,我们应该能够看到每个 ID(即索赔)对应的损失。我们将在本章结束时查看 LR、RF 和 GBT 的内容。尽管如此,结束 Spark 会话时,调用spark.stop()方法总是个好主意。

集成方法是一种学习算法,它创建一个由其他基础模型组成的模型。Spark ML 支持两种主要的集成算法,分别是基于决策树的 GBT 和随机森林。接下来,我们将看看是否可以通过显著减少 MAE 误差来提高预测准确度,使用 GBT。

用于预测保险赔付严重性的 GBT 回归器

为了最小化loss函数,梯度提升树GBT)通过迭代训练多棵决策树。在每次迭代中,算法使用当前的集成模型来预测每个训练实例的标签。

然后,原始预测与真实标签进行比较。因此,在下一次迭代中,决策树将帮助纠正之前的错误,如果数据集被重新标记以强调对预测不准确的训练实例进行训练。

既然我们讨论的是回归,那么讨论 GBT 的回归能力及其损失计算会更有意义。假设我们有以下设置:

  • N 数据实例

  • y[i] = 实例i的标签

  • x[i] = 实例i的特征

然后,F(x[i])函数是模型的预测标签;例如,它试图最小化误差,即损失:

现在,与决策树类似,GBT 也会:

  • 处理类别特征(当然也包括数值特征)

  • 扩展到多类分类设置

  • 执行二分类和回归(目前尚不支持多类分类)

  • 不需要特征缩放

  • 捕捉线性模型中(如线性回归)严重缺失的非线性和特征交互

训练过程中的验证:梯度提升可能会过拟合,尤其是在你使用更多树训练模型时。为了防止这个问题,训练过程中进行验证是非常有用的。

既然我们已经准备好了数据集,我们可以直接进入实现基于 GBT 的预测模型来预测保险赔付严重性。让我们从导入必要的包和库开始:

import org.apache.spark.ml.regression.{GBTRegressor, GBTRegressionModel} 
import org.apache.spark.ml.{Pipeline, PipelineModel} 
import org.apache.spark.ml.evaluation.RegressionEvaluator 
import org.apache.spark.ml.tuning.ParamGridBuilder 
import org.apache.spark.ml.tuning.CrossValidator 
import org.apache.spark.sql._ 
import org.apache.spark.sql.functions._ 
import org.apache.spark.mllib.evaluation.RegressionMetrics 

现在让我们定义并初始化训练 GBT 所需的超参数,例如树的数量、最大分箱数、交叉验证中使用的折数、训练的最大迭代次数,最后是最大树深度:

val NumTrees = Seq(5, 10, 15) 
val MaxBins = Seq(5, 7, 9) 
val numFolds = 10 
val MaxIter: Seq[Int] = Seq(10) 
val MaxDepth: Seq[Int] = Seq(10) 

然后,我们再次实例化一个 Spark 会话并启用隐式转换,如下所示:

val spark = SparkSessionCreate.createSession() 
import spark.implicits._ 

既然我们关心的是一个估算器算法,即 GBT:

val model = new GBTRegressor()
                .setFeaturesCol("features")
                .setLabelCol("label") 

现在,我们通过将变换和预测器串联在一起构建管道,如下所示:

val pipeline = new Pipeline().setStages((Preproessing.stringIndexerStages :+ Preproessing.assembler) :+ model) 

在开始执行交叉验证之前,我们需要一个参数网格。接下来,我们通过指定最大迭代次数、最大树深度和最大分箱数来开始创建参数网格:

val paramGrid = new ParamGridBuilder() 
      .addGrid(model.maxIter, MaxIter) 
      .addGrid(model.maxDepth, MaxDepth) 
      .addGrid(model.maxBins, MaxBins) 
      .build() 

现在,为了获得更好且稳定的性能,让我们准备 K-fold 交叉验证和网格搜索作为模型调优的一部分。如你所料,我将进行 10-fold 交叉验证。根据你的设置和数据集,你可以自由调整折数:

println("Preparing K-fold Cross Validation and Grid Search") 
val cv = new CrossValidator() 
      .setEstimator(pipeline) 
      .setEvaluator(new RegressionEvaluator) 
      .setEstimatorParamMaps(paramGrid) 
      .setNumFolds(numFolds) 

很棒,我们已经创建了交叉验证估算器。现在是时候训练 GBT 模型了:

println("Training model with GradientBoostedTrees algorithm ") 
val cvModel = cv.fit(Preproessing.trainingData) 

现在我们已经得到了拟合的模型,这意味着它现在能够进行预测。所以让我们开始在训练集和验证集上评估模型,并计算 RMSE、MSE、MAE、R-squared 等指标:

println("Evaluating model on train and test data and calculating RMSE") 
val trainPredictionsAndLabels = cvModel.transform(Preproessing.trainingData).select("label", "prediction").map { case Row(label: Double, prediction: Double) => (label, prediction) }.rdd 

val validPredictionsAndLabels = cvModel.transform(Preproessing.validationData).select("label", "prediction").map { case Row(label: Double, prediction: Double) => (label, prediction) }.rdd 

val trainRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels) 
val validRegressionMetrics = new RegressionMetrics(validPredictionsAndLabels) 

很好!我们已经成功计算了训练集和测试集的原始预测值。让我们开始寻找最佳模型:

val bestModel = cvModel.bestModel.asInstanceOf[PipelineModel] 

如前所述,使用 GBT 可以衡量特征重要性,这样在后续阶段我们可以决定哪些特征要使用,哪些要从 DataFrame 中删除。让我们找到之前创建的最佳模型的特征重要性,并按升序列出所有特征,如下所示:

val featureImportances = bestModel.stages.last.asInstanceOf[GBTRegressionModel].featureImportances.toArray 
val FI_to_List_sorted = featureImportances.toList.sorted.toArray  

一旦我们有了最佳拟合且交叉验证的模型,就可以期待良好的预测精度。现在让我们观察训练集和验证集上的结果:

val output = "n=====================================================================n" + s"Param trainSample: ${Preproessing.trainSample}n" + 
      s"Param testSample: ${Preproessing.testSample}n" + 
      s"TrainingData count: ${Preproessing.trainingData.count}n" + 
      s"ValidationData count: ${Preproessing.validationData.count}n" + 
      s"TestData count: ${Preproessing.testData.count}n" +      "=====================================================================n" +   s"Param maxIter = ${MaxIter.mkString(",")}n" + 
      s"Param maxDepth = ${MaxDepth.mkString(",")}n" + 
      s"Param numFolds = ${numFolds}n" +      "=====================================================================n" +   s"Training data MSE = ${trainRegressionMetrics.meanSquaredError}n" + 
      s"Training data RMSE = ${trainRegressionMetrics.rootMeanSquaredError}n" + 
      s"Training data R-squared = ${trainRegressionMetrics.r2}n" + 
      s"Training data MAE = ${trainRegressionMetrics.meanAbsoluteError}n" + 
      s"Training data Explained variance = ${trainRegressionMetrics.explainedVariance}n" +      "=====================================================================n" +    s"Validation data MSE = ${validRegressionMetrics.meanSquaredError}n" + 
      s"Validation data RMSE = ${validRegressionMetrics.rootMeanSquaredError}n" + 
      s"Validation data R-squared = ${validRegressionMetrics.r2}n" + 
      s"Validation data MAE = ${validRegressionMetrics.meanAbsoluteError}n" + 
      s"Validation data Explained variance = ${validRegressionMetrics.explainedVariance}n" +      "=====================================================================n" +   s"CV params explained: ${cvModel.explainParams}n" + 
      s"GBT params explained: ${bestModel.stages.last.asInstanceOf[GBTRegressionModel].explainParams}n" + s"GBT features importances:n ${Preproessing.featureCols.zip(FI_to_List_sorted).map(t => s"t${t._1} = ${t._2}").mkString("n")}n" +      "=====================================================================n" 

现在,我们按如下方式打印之前的结果:

println(results)
 >>> 
===================================================================== 
Param trainSample: 1.0 
Param testSample: 1.0 
TrainingData count: 141194 
ValidationData count: 47124 
TestData count: 125546 
===================================================================== 
Param maxIter = 10 
Param maxDepth = 10 
Param numFolds = 10 
===================================================================== 
Training data MSE = 2711134.460296872 
Training data RMSE = 1646.5522950385973 
Training data R-squared = 0.4979619968485668 
Training data MAE = 1126.582534126603 
Training data Explained variance = 8336528.638733303 
===================================================================== 
Validation data MSE = 4796065.983773314 
Validation data RMSE = 2189.9922337244293 
Validation data R-squared = 0.13708582379658474 
Validation data MAE = 1289.9808960385383 
Validation data Explained variance = 8724866.468978886 
===================================================================== 
CV params explained: estimator: estimator for selection (current: pipeline_9889176c6eda) 
estimatorParamMaps: param maps for the estimator (current: [Lorg.apache.spark.ml.param.ParamMap;@87dc030) 
evaluator: evaluator used to select hyper-parameters that maximize the validated metric (current: regEval_ceb3437b3ac7) 
numFolds: number of folds for cross validation (>= 2) (default: 3, current: 10) 
seed: random seed (default: -1191137437) 
GBT params explained: cacheNodeIds: If false, the algorithm will pass trees to executors to match instances with nodes. If true, the algorithm will cache node IDs for each instance. Caching can speed up training of deeper trees. (default: false) 
checkpointInterval: set checkpoint interval (>= 1) or disable checkpoint (-1). E.g. 10 means that the cache will get checkpointed every 10 iterations (default: 10) 
featuresCol: features column name (default: features, current: features) 
impurity: Criterion used for information gain calculation (case-insensitive). Supported options: variance (default: variance) 
labelCol: label column name (default: label, current: label) 
lossType: Loss function which GBT tries to minimize (case-insensitive). Supported options: squared, absolute (default: squared) 
maxBins: Max number of bins for discretizing continuous features. Must be >=2 and >= number of categories for any categorical feature. (default: 32) 
maxDepth: Maximum depth of the tree. (>= 0) E.g., depth 0 means 1 leaf node; depth 1 means 1 internal node + 2 leaf nodes. (default: 5, current: 10) 
maxIter: maximum number of iterations (>= 0) (default: 20, current: 10) 
maxMemoryInMB: Maximum memory in MB allocated to histogram aggregation. (default: 256) 
minInfoGain: Minimum information gain for a split to be considered at a tree node. (default: 0.0) 
minInstancesPerNode: Minimum number of instances each child must have after split. If a split causes the left or right child to have fewer than minInstancesPerNode, the split will be discarded as invalid. Should be >= 1\. (default: 1) 
predictionCol: prediction column name (default: prediction) 
seed: random seed (default: -131597770) 
stepSize: Step size (a.k.a. learning rate) in interval (0, 1] for shrinking the contribution of each estimator. (default: 0.1) 
subsamplingRate: Fraction of the training data used for learning each decision tree, in range (0, 1]. (default: 1.0) 
GBT features importance: 
   idx_cat1 = 0.0 
   idx_cat2 = 0.0 
   idx_cat3 = 0.0 
   idx_cat4 = 3.167169394850417E-5 
   idx_cat5 = 4.745749854188828E-5 
... 
   idx_cat111 = 0.018960701085054904 
   idx_cat114 = 0.020609596772820878 
   idx_cat115 = 0.02281267960792931 
   cont1 = 0.023943087007850663 
   cont2 = 0.028078353534251005 
   ... 
   cont13 = 0.06921704925937068 
   cont14 = 0.07609111789104464 
===================================================================== 

所以我们的预测模型显示训练集和测试集的 MAE 分别为 1126.5825341266031289.9808960385383。最后一个结果对理解特征重要性至关重要(前面的列表已经简化以节省空间,但你应该收到完整的列表)。特别是,我们可以看到前三个特征完全不重要,因此我们可以安全地将它们从 DataFrame 中删除。在下一节中我们会提供更多的见解。

最后,让我们在测试集上运行预测,并为每个客户的理赔生成预测损失:

println("Run prediction over test dataset") 
cvModel.transform(Preproessing.testData) 
      .select("id", "prediction") 
      .withColumnRenamed("prediction", "loss") 
      .coalesce(1) 
      .write.format("com.databricks.spark.csv") 
      .option("header", "true") 
      .save("output/result_GBT.csv") 

上述代码应该生成一个名为 result_GBT.csv 的 CSV 文件。如果我们打开文件,我们应该能看到每个 ID 对应的损失,也就是理赔。我们将在本章末尾查看 LR、RF 和 GBT 的内容。不过,结束时调用 spark.stop() 方法停止 Spark 会话总是一个好主意。

使用随机森林回归器提升性能

在之前的章节中,尽管我们对每个实例的损失严重性做出了预测,但并没有得到预期的 MAE 值。在本节中,我们将开发一个更稳健的预测分析模型,目的是相同的,但使用随机森林回归器。不过,在正式实现之前,我们需要简要了解一下随机森林算法。

随机森林用于分类和回归

随机森林是一种集成学习技术,用于解决监督学习任务,如分类和回归。随机森林的一个优势特性是它能够克服训练数据集上的过拟合问题。随机森林中的一片“森林”通常由数百到数千棵树组成。这些树实际上是在同一训练集的不同部分上训练的。

更技术性地说,单棵树如果长得很深,往往会从高度不可预测的模式中学习。这会导致训练集上的过拟合问题。此外,较低的偏差会使分类器表现较差,即使你的数据集在特征呈现方面质量很好。另一方面,随机森林通过将多棵决策树进行平均,目的是减少方差,确保一致性,通过计算案例对之间的接近度来实现。

GBT还是随机森林?虽然 GBT 和随机森林都是树的集成方法,但它们的训练过程不同。两者之间存在一些实际的权衡,这常常会带来选择困难。然而,在大多数情况下,随机森林是更优的选择。以下是一些理由:

  • GBT 每次训练一棵树,而随机森林则可以并行训练多棵树。所以随机森林的训练时间较短。然而,在某些特殊情况下,使用较少数量的树进行 GBT 训练更简单且速度更快。

  • 在大多数情况下,随机森林不易过拟合,因此降低了过拟合的可能性。换句话说,随机森林通过增加树的数量来减少方差,而 GBT 通过增加树的数量来减少偏差。

  • 最后,随机森林相对更容易调优,因为性能会随着树的数量单调提升,但 GBT 随着树的数量增加表现较差。

然而,这会略微增加偏差,并使得结果更难以解释。但最终,最终模型的性能会显著提高。在使用随机森林作为分类器时,有一些参数设置:

  • 如果树的数量是 1,则完全不使用自助抽样;但如果树的数量大于 1,则需要使用自助抽样。支持的值有autoallsqrtlog2onethird

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

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

  • 此外,如果设置了一个实数值n,并且n的范围在(0, 1.0)之间,则将使用n*number_of_features。但是,如果设置了一个整数值n,并且n的范围在(1,特征数)之间,则仅交替使用n个特征。

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

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

  • maxDepth 是树的最大深度(例如,深度为 0 表示一个叶节点,深度为 1 表示一个内部节点加上两个叶节点)。

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

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

正如前面提到的,由于随机森林足够快速且可扩展,适合处理大规模数据集,因此 Spark 是实现 RF 的合适技术,并实现这种大规模的可扩展性。然而,如果计算了邻近性,存储需求也会呈指数增长。

好的,关于 RF 就讲到这里。现在是时候动手实践了,开始吧。我们从导入所需的库开始:

import org.apache.spark.ml.regression.{RandomForestRegressor, RandomForestRegressionModel} 
import org.apache.spark.ml.{ Pipeline, PipelineModel } 
import org.apache.spark.ml.evaluation.RegressionEvaluator 
import org.apache.spark.ml.tuning.ParamGridBuilder 
import org.apache.spark.ml.tuning.CrossValidator 
import org.apache.spark.sql._ 
import org.apache.spark.sql.functions._ 
import org.apache.spark.mllib.evaluation.RegressionMetrics 

然后,我们创建一个活动的 Spark 会话并导入隐式转换:

val spark = SparkSessionCreate.createSession() 
import spark.implicits._ 

然后我们定义一些超参数,比如交叉验证的折数、最大迭代次数、回归参数的值、公差值以及弹性网络参数,如下所示:

val NumTrees = Seq(5,10,15)  
val MaxBins = Seq(23,27,30)  
val numFolds = 10  
val MaxIter: Seq[Int] = Seq(20) 
val MaxDepth: Seq[Int] = Seq(20) 

请注意,对于基于决策树的随机森林,我们要求 maxBins 至少与每个分类特征中的值的数量一样大。在我们的数据集中,我们有 110 个分类特征,其中包含 23 个不同的值。因此,我们必须将 MaxBins 设置为至少 23。然而,还是可以根据需要调整之前的参数。好了,现在是时候创建 LR 估计器了:

val model = new RandomForestRegressor().setFeaturesCol("features").setLabelCol("label")

现在,让我们通过将变换器和 LR 估计器连接起来,构建一个管道估计器:

println("Building ML pipeline") 
val pipeline = new Pipeline().setStages((Preproessing.stringIndexerStages :+ Preproessing.assembler) :+ model) 

在我们开始执行交叉验证之前,需要有一个参数网格。所以让我们通过指定树的数量、最大树深度的数字和最大桶数参数来创建参数网格,如下所示:

val paramGrid = new ParamGridBuilder() 
      .addGrid(model.numTrees, NumTrees) 
      .addGrid(model.maxDepth, MaxDepth) 
      .addGrid(model.maxBins, MaxBins) 
      .build() 

现在,为了获得更好且稳定的性能,让我们准备 K 折交叉验证和网格搜索作为模型调优的一部分。正如你可能猜到的,我将执行 10 折交叉验证。根据你的设置和数据集,随时调整折数:

println("Preparing K-fold Cross Validation and Grid Search: Model tuning") 
val cv = new CrossValidator() 
      .setEstimator(pipeline) 
      .setEvaluator(new RegressionEvaluator) 
      .setEstimatorParamMaps(paramGrid) 
      .setNumFolds(numFolds) 

太棒了,我们已经创建了交叉验证估计器。现在是训练 LR 模型的时候了:

println("Training model with Random Forest algorithm")  
val cvModel = cv.fit(Preproessing.trainingData) 

现在我们已经有了拟合的模型,这意味着它现在能够进行预测。那么让我们开始在训练集和验证集上评估模型,并计算 RMSE、MSE、MAE、R-squared 等指标:

println("Evaluating model on train and validation set and calculating RMSE") 
val trainPredictionsAndLabels = cvModel.transform(Preproessing.trainingData).select("label", "prediction").map { case Row(label: Double, prediction: Double) => (label, prediction) }.rdd 

val validPredictionsAndLabels = cvModel.transform(Preproessing.validationData).select("label", "prediction").map { case Row(label: Double, prediction: Double) => (label, prediction) }.rdd 

val trainRegressionMetrics = new RegressionMetrics(trainPredictionsAndLabels) 
val validRegressionMetrics = new RegressionMetrics(validPredictionsAndLabels) 

很棒!我们已经成功地计算了训练集和测试集的原始预测结果。接下来,让我们寻找最佳模型:

val bestModel = cvModel.bestModel.asInstanceOf[PipelineModel]

如前所述,通过使用 RF(随机森林),可以衡量特征的重要性,以便在后续阶段决定哪些特征应该保留,哪些特征应从 DataFrame 中删除。接下来,让我们按升序查找刚刚为所有特征创建的最佳模型的特征重要性,如下所示:

val featureImportances = bestModel.stages.last.asInstanceOf[RandomForestRegressionModel].featureImportances.toArray 
val FI_to_List_sorted = featureImportances.toList.sorted.toArray  

一旦我们得到了最佳拟合并经过交叉验证的模型,就可以期待较好的预测准确性。现在,让我们观察训练集和验证集的结果:

val output = "n=====================================================================n" + s"Param trainSample: ${Preproessing.trainSample}n" + 
      s"Param testSample: ${Preproessing.testSample}n" + 
      s"TrainingData count: ${Preproessing.trainingData.count}n" + 
      s"ValidationData count: ${Preproessing.validationData.count}n" + 
      s"TestData count: ${Preproessing.testData.count}n" +      "=====================================================================n" +   s"Param maxIter = ${MaxIter.mkString(",")}n" + 
      s"Param maxDepth = ${MaxDepth.mkString(",")}n" + 
      s"Param numFolds = ${numFolds}n" +      "=====================================================================n" +   s"Training data MSE = ${trainRegressionMetrics.meanSquaredError}n" + 
      s"Training data RMSE = ${trainRegressionMetrics.rootMeanSquaredError}n" + 
      s"Training data R-squared = ${trainRegressionMetrics.r2}n" + 
      s"Training data MAE = ${trainRegressionMetrics.meanAbsoluteError}n" + 
      s"Training data Explained variance = ${trainRegressionMetrics.explainedVariance}n" +      "=====================================================================n" +   s"Validation data MSE = ${validRegressionMetrics.meanSquaredError}n" + 
      s"Validation data RMSE = ${validRegressionMetrics.rootMeanSquaredError}n" + 
      s"Validation data R-squared = ${validRegressionMetrics.r2}n" + 
      s"Validation data MAE = ${validRegressionMetrics.meanAbsoluteError}n" + 
      s"Validation data Explained variance =
${validRegressionMetrics.explainedVariance}n" +      "=====================================================================n" +   s"CV params explained: ${cvModel.explainParams}n" + 
      s"RF params explained: ${bestModel.stages.last.asInstanceOf[RandomForestRegressionModel].explainParams}n" + 
      s"RF features importances:n ${Preproessing.featureCols.zip(FI_to_List_sorted).map(t => s"t${t._1} = ${t._2}").mkString("n")}n" +      "=====================================================================n" 

现在,我们按如下方式打印前面的结果:

println(results)
>>>Param trainSample: 1.0
 Param testSample: 1.0
 TrainingData count: 141194
 ValidationData count: 47124
 TestData count: 125546
 Param maxIter = 20
 Param maxDepth = 20
 Param numFolds = 10
 Training data MSE = 1340574.3409399686
 Training data RMSE = 1157.8317412042081
 Training data R-squared = 0.7642745310548124
 Training data MAE = 809.5917285994619
 Training data Explained variance = 8337897.224852404
 Validation data MSE = 4312608.024875177
 Validation data RMSE = 2076.6819749001475
 Validation data R-squared = 0.1369507149716651"
 Validation data MAE = 1273.0714382935894
 Validation data Explained variance = 8737233.110450774

因此,我们的预测模型在训练集和测试集上分别显示出 MAE(平均绝对误差)为809.59172859946191273.0714382935894。最后的结果对于理解特征重要性非常重要(前面的列表已简化以节省空间,但您应该会收到完整的列表)。

我已经在 Python 中绘制了类别特征和连续特征及其相应的重要性,因此这里不再展示代码,只展示图表。让我们看看类别特征的特征重要性,以及对应的特征编号:

图 11:随机森林类别特征重要性

从前面的图表中可以清楚地看出,类别特征cat20cat64cat47cat69的重要性较低。因此,删除这些特征并重新训练随机森林模型,以观察更好的表现是有意义的。

现在,让我们看看连续特征与损失列的相关性及其贡献。从下图中可以看到,所有连续特征与损失列之间都有正相关关系。这也意味着,这些连续特征与我们在前面图中看到的类别特征相比并不那么重要:

图 12:连续特征与标签之间的相关性

从这两个分析中我们可以得出结论:我们可以简单地删除一些不重要的列,并训练随机森林模型,观察训练集和验证集的 MAE 值是否有所减少。最后,让我们对测试集进行预测:

println("Run prediction on the test set") 
cvModel.transform(Preproessing.testData) 
      .select("id", "prediction") 
      .withColumnRenamed("prediction", "loss") 
      .coalesce(1) // to get all the predictions in a single csv file                 
      .write.format("com.databricks.spark.csv") 
      .option("header", "true") 
      .save("output/result_RF.csv") 

此外,与 LR(逻辑回归)类似,您可以通过调用stop()方法停止 Spark 会话。现在生成的result_RF.csv文件应该包含每个 ID(即索赔)的损失。

比较分析与模型部署

你已经看到,LR 模型对于小型训练数据集来说训练起来要容易得多。然而,与 GBT(梯度提升树)和随机森林模型相比,我们并没有看到更好的准确性。然而,LR 模型的简洁性是一个非常好的起点。另一方面,我们已经讨论过,随机森林在许多方面都会胜过 GBT。让我们在表格中查看结果:

现在,让我们看看每个模型对于 20 起事故或损害索赔的预测情况:

图 13:i) 线性回归(LR)、ii) 梯度提升树(GBT)和 iii) 随机森林模型的损失预测

因此,根据表 2,我们可以清楚地看到,我们应该选择随机森林回归模型(Random Forest regressor)来预测保险理赔损失以及其生产情况。现在我们将简要概述如何将我们最好的模型,即随机森林回归模型投入生产。这个想法是,作为数据科学家,你可能已经训练了一个机器学习模型,并将其交给公司中的工程团队进行部署,以便在生产环境中使用。

在这里,我提供了一种简单的方法,尽管 IT 公司肯定有自己的模型部署方式。尽管如此,本文的最后会有专门的章节。通过使用模型持久化功能——即 Spark 提供的保存和加载模型的能力,这种场景完全可以变为现实。通过 Spark,你可以选择:

  • 保存和加载单一模型

  • 保存并加载整个工作流

单一模型相对简单,但效果较差,主要适用于基于 Spark MLlib 的模型持久化。由于我们更关心保存最好的模型,也就是随机森林回归模型,我们首先将使用 Scala 拟合一个随机森林回归模型,保存它,然后再使用 Scala 加载相同的模型:

// Estimator algorithm 
val model = new RandomForestRegressor() 
                    .setFeaturesCol("features") 
                    .setLabelCol("label") 
                    .setImpurity("gini") 
                    .setMaxBins(20) 
                    .setMaxDepth(20) 
                    .setNumTrees(50) 
fittedModel = rf.fit(trainingData) 

现在我们可以简单地调用write.overwrite().save()方法将该模型保存到本地存储、HDFS 或 S3,并使用加载方法将其重新加载以便将来使用:

fittedModel.write.overwrite().save("model/RF_model")  
val sameModel = CrossValidatorModel.load("model/RF_model") 

现在我们需要知道的是如何使用恢复后的模型进行预测。答案如下:

sameModel.transform(Preproessing.testData) 
    .select("id", "prediction") 
    .withColumnRenamed("prediction", "loss") 
    .coalesce(1) 
    .write.format("com.databricks.spark.csv") 
    .option("header", "true") 
    .save("output/result_RF_reuse.csv") 

图 14:Spark 模型在生产中的部署

到目前为止,我们只看过如何保存和加载单一的机器学习模型,但没有涉及调优或稳定的模型。这个模型可能甚至会给你很多错误的预测。因此,现在第二种方法可能更有效。

现实情况是,在实际操作中,机器学习工作流包括多个阶段,从特征提取和转换到模型拟合和调优。Spark ML 提供了工作流帮助工具,以帮助用户构建这些工作流。类似地,带有交叉验证模型的工作流也可以像我们在第一种方法中做的那样保存和恢复。

我们用训练集对交叉验证后的模型进行拟合:

val cvModel = cv.fit(Preproessing.trainingData)   

然后我们保存工作流/流水线:

cvModel.write.overwrite().save("model/RF_model") 

请注意,前面的代码行将把模型保存在你选择的位置,并具有以下目录结构:

图 15:保存的模型目录结构

//Then we restore the same model back:
val sameCV = CrossValidatorModel.load("model/RF_model") 
Now when you try to restore the same model, Spark will automatically pick the best one. Finally, we reuse this model for making a prediction as follows:
sameCV.transform(Preproessing.testData) 
      .select("id", "prediction") 
      .withColumnRenamed("prediction", "loss") 
      .coalesce(1) 
      .write.format("com.databricks.spark.csv") 
      .option("header", "true") 
      .save("output/result_RF_reuse.csv")  

基于 Spark 的模型部署用于大规模数据集

在生产环境中,我们常常需要以规模化的方式部署预训练模型。尤其是当我们需要处理大量数据时,我们的 ML 模型必须解决这个可扩展性问题,以便持续执行并提供更快速的响应。为了克服这个问题,Spark 为我们带来的一大大数据范式就是引入了内存计算(尽管它支持磁盘操作),以及缓存抽象。

这使得 Spark 非常适合大规模数据处理,并使得计算节点能够通过访问多个计算节点上的相同输入数据,执行多个操作,无论是在计算集群还是云计算基础设施中(例如,Amazon AWS、DigitalOcean、Microsoft Azure 或 Google Cloud)。为此,Spark 支持四种集群管理器(不过最后一个仍然处于实验阶段):

  • Standalone: Spark 附带的简单集群管理器,使得设置集群变得更加容易。

  • Apache Mesos: 一个通用的集群管理器,也可以运行 Hadoop MapReduce 和服务应用程序。

  • Hadoop YARN: Hadoop 2 中的资源管理器。

  • Kubernetes(实验性): 除了上述内容外,还支持 Kubernetes 的实验性功能。Kubernetes 是一个开源平台,用于提供容器为中心的基础设施。更多信息请见 spark.apache.org/docs/latest/cluster-overview.html

你可以将输入数据集上传到 Hadoop 分布式文件系统HDFS)或 S3 存储中,以实现高效计算和低成本存储大数据。然后,Spark 的 bin 目录中的 spark-submit 脚本将用于在任意集群模式下启动应用程序。它可以通过统一的接口使用所有集群管理器,因此你不需要为每个集群专门配置应用程序。

然而,如果你的代码依赖于其他项目,那么你需要将它们与应用程序一起打包,以便将代码分发到 Spark 集群中。为此,创建一个包含你的代码及其依赖项的 assembly jar 文件(也称为 fatuber jar)。然后将代码分发到数据所在的地方,并执行 Spark 作业。SBTMaven 都有 assembly 插件,可以帮助你准备这些 jar 文件。

在创建 assembly jar 文件时,也需要将 Spark 和 Hadoop 列为依赖项。这些依赖项不需要打包,因为它们会在运行时由集群管理器提供。创建了合并的 jar 文件后,可以通过以下方式传递 jar 来调用脚本:

  ./bin/spark-submit \
      --class <main-class> \
      --master <master-url> \
      --deploy-mode <deploy-mode> \
      --conf <key>=<value> \
       ... # other options
       <application-jar> \
       [application-arguments]

在上面的命令中,列出了以下一些常用的选项:

  • --class: 应用程序的入口点(例如,org.apache.spark.examples.SparkPi)。

  • --master: 集群的主 URL(例如,spark://23.195.26.187:7077)。

  • --deploy-mode: 是否将驱动程序部署在工作节点(集群)上,还是作为外部客户端在本地部署。

  • --conf: 任意的 Spark 配置属性,采用 key=value 格式。

  • application-jar:包含你的应用程序和所有依赖项的捆绑 jar 文件的路径。URL 必须在你的集群中全局可见,例如,hdfs:// 路径或在所有节点上都存在的 file:// 路径。

  • application-arguments:传递给主类主方法的参数(如果有的话)。

例如,你可以在客户端部署模式下,在 Spark 独立集群上运行 AllstateClaimsSeverityRandomForestRegressor 脚本,如下所示:

./bin/spark-submit \
   --class com.packt.ScalaML.InsuranceSeverityClaim.AllstateClaimsSeverityRandomForestRegressor\
   --master spark://207.184.161.138:7077 \
   --executor-memory 20G \
   --total-executor-cores 100 \
   /path/to/examples.jar

如需更多信息,请参见 Spark 网站:spark.apache.org/docs/latest/submitting-applications.html。不过,你也可以从在线博客或书籍中找到有用的信息。顺便提一下,我在我最近出版的一本书中详细讨论了这个话题:Md. Rezaul Karim, Sridhar Alla, Scala 和 Spark 在大数据分析中的应用,Packt Publishing Ltd. 2017。更多信息请见:www.packtpub.com/big-data-and-business-intelligence/scala-and-spark-big-data-analytics

无论如何,我们将在接下来的章节中学习更多关于如何在生产环境中部署 ML 模型的内容。因此,这一章就写到这里。

总结

在本章中,我们已经学习了如何使用一些最广泛使用的回归算法开发用于分析保险严重性索赔的预测模型。我们从简单的线性回归(LR)开始。然后我们看到如何通过使用 GBT 回归器来提升性能。接着,我们体验了使用集成技术(如随机森林回归器)来提高性能。最后,我们进行了这些模型之间的性能对比分析,并选择了最佳模型来部署到生产环境中。

在下一章中,我们将介绍一个新的端到端项目,名为 电信客户流失分析与预测。流失预测对于企业至关重要,因为它可以帮助你发现那些可能取消订阅、产品或服务的客户。它还可以最大限度地减少客户流失。通过预测哪些客户更有可能取消服务订阅,达到了这一目的。

第二章:分析与预测电信行业流失

在这一章中,我们将开发一个机器学习ML)项目,用于分析和预测客户是否可能取消其电信合同的订阅。此外,我们还将对数据进行一些初步分析,仔细查看哪些客户特征通常与这种流失相关。

广泛使用的分类算法,如决策树、随机森林、逻辑回归和支持向量机SVM),将用于分析和做出预测。最终,读者将能够选择最适合生产环境的最佳模型。

简而言之,在这个端到端的项目中,我们将学习以下主题:

  • 为什么以及如何进行流失预测?

  • 基于逻辑回归的流失预测

  • 基于 SVM 的流失预测

  • 基于决策树的流失预测

  • 基于随机森林的流失预测

  • 选择最佳模型进行部署

为什么我们要进行流失分析,如何进行流失分析?

客户流失是指客户或顾客的流失(也称为客户流失率、客户流动率或客户弃用)。这一概念最初用于电信行业,当时许多用户转向其他服务提供商。然而,这已成为其他业务领域的重要问题,如银行、互联网服务提供商、保险公司等。嗯,流失的主要原因之一是客户不满,以及竞争对手提供更便宜或更好的优惠。

如你在图 1中所见,商业行业中与客户可能签订的合同有四种类型:契约性合同、非契约性合同、自愿性合同和非自愿性合同。客户流失的全部成本包括失去的收入以及与用新客户替代这些流失客户所涉及的(电)营销成本。然而,这种类型的损失可能会给企业带来巨大的损失。想想十年前,当诺基亚是手机市场的霸主时,突然,苹果发布了 iPhone 3G,这标志着智能手机时代的革命。接着,大约 10%到 12%的客户停止使用诺基亚,转而选择了 iPhone。虽然后来诺基亚也尝试推出智能手机,但最终,它们无法与苹果竞争:

图 1:与客户可能签订的四种合同类型

流失预测对企业至关重要,因为它能帮助企业检测出可能取消订阅、产品或服务的客户。它还可以最大限度地减少客户流失。通过预测哪些客户可能取消订阅服务,企业可以为这些客户(可能取消订阅的客户)提供特别优惠或计划。这样,企业就可以减少流失率。这应该是每个在线业务的关键目标。

在员工流失预测方面,典型的任务是确定哪些因素预测员工离职。这类预测过程依赖于大量数据,通常需要利用先进的机器学习技术。然而,在本章中,我们将主要关注客户流失的预测和分析。为此,应该分析多个因素,以便理解客户行为,包括但不限于:

  • 客户的基本信息数据,如年龄、婚姻状况等

  • 客户的社交媒体情感分析

  • 从点击流日志中获取的浏览行为

  • 显示行为模式的历史数据,提示可能的客户流失

  • 客户的使用模式和地理位置使用趋势

  • 通话圈数据和支持呼叫中心统计信息

开发一个流失分析管道

在机器学习中,我们将算法的表现分为两个阶段:学习和推理。学习阶段的最终目标是准备和描述可用数据,也称为特征向量,它用于训练模型。

学习阶段是最重要的阶段之一,但也是极其耗时的。它包括从经过转换的训练数据中准备特征向量(也称为特征向量,表示每个特征值的数字向量),以便我们可以将其输入到学习算法中。另一方面,训练数据有时也包含一些不纯净的信息,需要一些预处理,例如清理。

一旦我们拥有特征向量,接下来的步骤是准备(或编写/重用)学习算法。下一个重要步骤是训练算法,以准备预测模型。通常,(当然根据数据大小),运行一个算法可能需要几个小时(甚至几天),以使特征收敛为有用的模型,如下图所示:

图 2:学习和训练预测模型 - 展示了如何从训练数据中生成特征向量,进而训练学习算法,最终产生预测模型

第二个最重要的阶段是推理,它用于智能地利用模型,例如对从未见过的数据进行预测、提供推荐、推断未来规则等。通常,与学习阶段相比,推理所需的时间较短,有时甚至是实时的。因此,推理的核心是通过新的(即未观察过的)数据测试模型,并评估模型本身的表现,如下图所示:

图 3:从现有模型进行推理以进行预测分析(特征向量由未知数据生成,用于做出预测)

然而,在整个过程中,为了使预测模型成功,数据在所有机器学习任务中都是至关重要的。考虑到这一点,下面的图表展示了电信公司可以使用的分析管道:

图 4:流失分析管道

通过这种分析,电信公司可以辨别如何预测并改善客户体验,从而防止客户流失并量身定制营销活动。在实际操作中,这类商业评估通常用于留住最可能流失的客户,而非那些可能留下的客户。

因此,我们需要开发一个预测模型,确保我们的模型对 Churn = True 样本具有敏感性——这是一个二分类问题。我们将在接下来的章节中详细探讨。

数据集描述

Orange Telecom 的客户流失数据集,包含了清理后的客户活动数据(特征),以及一个流失标签,指示客户是否取消了订阅。我们将使用该数据集来开发我们的预测模型。可以通过以下链接分别下载 churn-80 和 churn-20 数据集:

然而,由于更多的数据对于开发机器学习模型通常是有利的,因此我们将使用较大的数据集(即 churn-80)进行训练和交叉验证,使用较小的数据集(即 churn-20)进行最终测试和模型性能评估。

请注意,后者数据集仅用于评估模型(即用于演示目的)。在生产环境中,电信公司可以使用自己的数据集,经过必要的预处理和特征工程。该数据集的结构如下:

  • : String

  • 账户时长: Integer

  • 区号: Integer

  • 国际计划: String

  • 语音邮件计划: String

  • 电子邮件消息数量: Integer

  • 总白天分钟数: Double

  • 总白天电话数量: Integer

  • 总白天费用: Double

  • 总傍晚分钟数: Double

  • 总傍晚电话数量: Integer

  • 总傍晚费用: Double

  • 总夜间通话分钟数: Double

  • 总夜间电话数量: Integer

  • 总夜间费用: Double

  • 总国际分钟数: Double

  • 总国际电话数量: Integer

  • 总国际费用: Double

  • 客户服务电话: Integer

探索性分析与特征工程

在这一小节中,我们将在开始预处理和特征工程之前,对数据集进行一些探索性数据分析(EDA)。只有在此之后,创建分析管道才有意义。首先,让我们导入必要的软件包和库,代码如下:

import org.apache.spark._
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.apache.spark.sql._
import org.apache.spark.sql.Dataset

然后,让我们指定数据集的来源和模式。当将数据加载到 DataFrame 时,我们可以指定模式。这一指定相比于 Spark 2.x 之前的模式推断提供了优化的性能。

首先,我们创建一个包含所有字段的 Scala 案例类。变量名一目了然:

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)

现在,让我们创建一个自定义模式,结构与我们已创建的数据源相似,如下所示:

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

让我们创建一个 Spark 会话并导入implicit._,以便我们指定 DataFrame 操作,如下所示:

val spark: SparkSession = SparkSessionCreate.createSession("preprocessing")
import spark.implicits._

现在,让我们创建训练集。我们使用 Spark 推荐的格式com.databricks.spark.csv读取 CSV 文件。我们不需要显式的模式推断,因此将推断模式设置为 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()
>>>

太棒了!它看起来与数据结构完全相同。现在让我们使用show()方法查看一些示例数据,如下所示:

trainSet.show()
>>>

在下图中,列名已被缩短,以便在图中显示:

我们还可以使用 Spark 的describe()方法查看训练集的相关统计数据:

describe()方法是 Spark DataFrame 的内置方法,用于统计处理。它对所有数值列应用汇总统计计算,最后将计算值作为单个 DataFrame 返回。

val statsDF = trainSet.describe()
statsDF.show()
>>>

如果这个数据集可以装入内存,我们可以使用 Spark 的cache()方法将其缓存,以便快速和重复地访问:

trainSet.cache()

让我们查看一些有用的属性,比如与流失(churn)的变量相关性。例如,看看流失与国际通话总数之间的关系:

trainSet.groupBy("churn").sum("total_international_num_calls").show()
>>>
+-----+----------------------------------+
churn|sum(total_international_num_calls)|
+-----+----------------------------------+
|False| 3310.0|
| True| 856.0|
+-----+----------------------------------+

让我们看看流失与国际通话费用总额之间的关系:

trainSet.groupBy("churn").sum("total_international_charge").show()
 >>>
+-----+-------------------------------+
|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")

按照流失标签对数据进行分组并计算每个组中的实例数量,显示出假流失样本大约是实际流失样本的六倍。让我们使用以下代码验证这一说法:

trainSet.groupBy("churn").count.show()
>>>
+-----+-----+
|churn|count|
+-----+-----+
|False| 2278|
| True| 388 |
+-----+-----+

我们还可以看到前面的语句,使用 Apache Zeppelin 验证过的(有关如何配置和入门的更多细节,请参见第八章,在银行营销中使用深度信念网络),如下所示:

spark.sqlContext.sql("SELECT churn,SUM(international_num_calls) as Total_intl_call FROM UserAccount GROUP BY churn").show()
>>>

如我们所述,在大多数情况下,目标是保留那些最有可能流失的客户,而不是那些可能会留下或已经留下的客户。这也意味着我们应该准备我们的训练集,确保我们的 ML 模型能够敏感地识别真正的流失样本——即,标记为流失(True)的样本。

我们还可以观察到,前面的训练集高度不平衡。因此,使用分层抽样将两种样本类型放在同等基础上是可行的。当提供每种样本类型的返回比例时,可以使用 sampleBy() 方法来实现。

在这里,我们保留了所有 True 流失类的实例,但将 False 流失类下采样至 388/2278,约为 0.1675

val fractions = Map("False" -> 0.1675, "True" -> 1.0)

这样,我们也仅映射了 True 流失样本。现在,让我们为仅包含下采样样本的训练集创建一个新的 DataFrame:

val churnDF = trainSet.stat.sampleBy("churn", fractions, 12345L)

第三个参数是用于可重复性目的的种子值。现在让我们来看一下:

churnDF.groupBy("churn").count.show()
>>>
+-----+-----+
|churn|count|
+-----+-----+
|False| 390|
| True| 388|
+-----+-----+

现在让我们看看变量之间的关系。让我们查看白天、夜晚、傍晚和国际语音通话如何影响 churn 类别。只需执行以下代码:

spark.sqlContext.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()
>>>

在 Apache Zeppelin 上,可以看到如下的前置结果:

现在,让我们看看白天、夜晚、傍晚和国际语音通话分别对 churn 类别的前置总费用贡献了多少。只需执行以下代码:

spark.sqlContext.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()
>>>

在 Apache Zeppelin 上,可以看到如下的前置结果:

从前面的两张图表和表格可以清楚地看出,总白天通话分钟数和总白天费用是这个训练集中的高度相关特征,这对我们的 ML 模型训练并不有利。因此,最好将它们完全去除。此外,以下图表展示了所有可能的相关性(虽然是用 PySpark 绘制的):

图 5:包含所有特征的相关矩阵

让我们丢弃每对相关字段中的一列,同时也丢弃StateArea 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 ML API 要求我们的数据必须转换为 Spark DataFrame 格式,包含标签(Double 类型)和特征(Vector 类型)。

现在,我们需要创建一个管道来传递数据,并将多个变换器和估算器连接起来。这个管道随后作为特征提取器工作。更具体地说,我们已经准备好了两个StringIndexer变换器和一个VectorAssembler

StringIndexer将一个分类标签列编码为标签索引列(即数字)。如果输入列是数字类型,我们必须将其转换为字符串并对字符串值进行索引。其他 Spark 管道组件,如估算器或变换器,都会利用这个字符串索引标签。为了做到这一点,组件的输入列必须设置为这个字符串索引列的名称。在许多情况下,你可以使用setInputCol来设置输入列。有兴趣的读者可以参考这个spark.apache.org/docs/latest/ml-features.html以获取更多详情。

第一个StringIndexer将分类特征international_plan和标签转换为数字索引。第二个StringIndexer将分类标签(即churn)转换为数字。通过这种方式,索引化的分类特征使得决策树和随机森林等分类器能够适当处理分类特征,从而提高性能。

现在,添加以下代码行,对标签列进行索引标签和元数据处理。在整个数据集上进行拟合,以确保所有标签都包括在索引中:

val ipindexer = new StringIndexer()
    .setInputCol("international_plan")
    .setOutputCol("iplanIndex")

val labelindexer = new StringIndexer()
    .setInputCol("churn")
    .setOutputCol("label")

现在我们需要提取对分类最有贡献的最重要特征。由于我们已经删除了一些列,结果列集包含以下字段:

* Label → churn: True or False
* Features → {("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"}

由于我们已经使用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 是预测二元响应最常用的分类器之一。它是一种线性机器学习方法,正如在第一章中所描述的,分析保险严重性理赔loss函数是由逻辑损失给出的公式:

对于 LR 模型,loss 函数是逻辑损失函数。对于二分类问题,该算法输出一个二元 LR 模型,对于给定的新数据点,记为 x,该模型通过应用逻辑函数进行预测:

在上述方程中,z = W^TX,如果 f(W^TX)>0.5,则结果为正;否则为负。

请注意,LR 模型的原始输出 f(z) 具有概率解释。

请注意,与线性回归相比,逻辑回归为你提供了更高的分类精度。此外,它是一种灵活的方式来对模型进行正则化,以进行自定义调整,总体而言,模型的响应是概率的度量。

最重要的是,尽管线性回归只能预测连续值,线性回归仍然足够通用,可以预测离散值:

import org.apache.spark._
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.ml.classification.{BinaryLogisticRegressionSummary, LogisticRegression, LogisticRegressionModel}
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.tuning.{ParamGridBuilder, CrossValidator}
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics 
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator

既然我们已经知道线性回归的工作原理,让我们开始使用基于 Spark 的线性回归实现。首先,让我们导入所需的包和库。

现在,让我们创建一个 Spark 会话并导入隐式转换:

val spark: SparkSession = SparkSessionCreate.createSession("ChurnPredictionLogisticRegression")
import spark.implicits._

我们现在需要定义一些超参数来训练基于线性回归的管道:

val numFolds = 10
val MaxIter: Seq[Int] = Seq(100)
val RegParam: Seq[Double] = Seq(1.0) // L2 regularization param, set 1.0 with L1 regularization
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 是一个标量,用于调整约束的强度:较小的值表示软边界,因此,自然地,较大的值表示硬边界,而无限大则是最硬的边界。

默认情况下,LR 执行 L2 正则化,正则化参数设置为 1.0。相同的模型执行 L1 正则化变种的 LR,正则化参数(即 RegParam)设置为 0.10。弹性网络是 L1 和 L2 正则化的组合。

另一方面,Tol 参数用于迭代算法(如逻辑回归或线性支持向量机)的收敛容忍度。现在,一旦我们定义并初始化了超参数,接下来的任务是实例化一个线性回归估算器,如下所示:

val lr = new LogisticRegression()
    .setLabelCol("label")
    .setFeaturesCol("features")

现在,我们已经有了三个变换器和一个估算器,接下来的任务是将它们串联成一个单一的管道——即,它们每一个都作为一个阶段:

val pipeline = new Pipeline()
    .setStages(Array(PipelineConstruction.ipindexer,
    PipelineConstruction.labelindexer,
    PipelineConstruction.assembler, lr))

为了在超参数空间上执行这样的网格搜索,我们需要先定义它。在这里,Scala 的函数式编程特性非常方便,因为我们只需将函数指针和要评估的相应参数添加到参数网格中,在该网格中你设置要测试的参数,并使用交叉验证评估器来构建一个模型选择工作流。这将搜索线性回归的最大迭代次数、正则化参数、容忍度和弹性网络,以找到最佳模型:

val paramGrid = new ParamGridBuilder()
    .addGrid(lr.maxIter, MaxIter)
    .addGrid(lr.regParam, RegParam)
    .addGrid(lr.tol, Tol)
    .addGrid(lr.elasticNetParam, ElasticNetParam)
    .build()

请注意,超参数形成了一个 n 维空间,其中n是超参数的数量。这个空间中的每个点是一个特定的超参数配置,即超参数向量。当然,我们无法探索该空间中的每个点,因此我们基本上做的是在该空间中对(希望均匀分布的)子集进行网格搜索。

然后我们需要定义一个BinaryClassificationEvaluator评估器,因为这是一个二分类问题。使用该评估器,模型将通过比较测试标签列与测试预测列,根据精度指标进行评估。默认的度量标准是精度-召回曲线下面积和接收者操作特征ROC)曲线下面积:

val evaluator = new BinaryClassificationEvaluator()
    .setLabelCol("label")
    .setRawPredictionCol("prediction")

我们使用CrossValidator进行最佳模型选择。CrossValidator使用估算器管道、参数网格和分类评估器。CrossValidator使用ParamGridBuilder来遍历线性回归的最大迭代次数、回归参数、容差和弹性网参数,然后评估模型,对于每个参数值重复 10 次以获得可靠结果——即进行 10 折交叉验证:

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 predictions = cvModel.transform(Preprocessing.testSet)
al result = predictions.select("label", "prediction", "probability")
val resutDF = result.withColumnRenamed("prediction", "Predicted_label")
resutDF.show(10)
>>>

预测概率在根据客户的缺陷可能性进行排名时也非常有用。通过这种方式,电信业务可以利用有限的资源进行保留,并集中于最有价值的客户。

然而,看到之前的预测数据框,实际上很难猜测分类准确率。在第二步中,评估器通过BinaryClassificationEvaluator自行进行评估,如下所示:

val accuracy = evaluator.evaluate(predictions)
println("Classification accuracy: " + accuracy)
>>>
Classification accuracy: 0.7670592565329408

所以,我们的二分类模型的分类准确率大约为 77%。现在,使用准确率来评估二分类器并没有太大意义。

因此,研究人员经常推荐其他性能指标,如精确度-召回率曲线下的面积和 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.5761887477313975
Area under the receiver operating characteristic (ROC) curve: 0.7670592565329408

在这种情况下,评估结果为 77% 的准确率,但只有 58% 的精确度。接下来,我们计算一些其他的性能指标;例如,假阳性、真阳性和假阴性预测对评估模型的性能也非常有用:

  • 真阳性:模型正确预测订阅取消的频率

  • 假阳性:模型错误预测订阅取消的频率

  • 真阴性:模型正确预测没有取消的频率

  • 假阴性:模型错误预测没有取消的频率

val lp = predictions.select("label", "prediction")
val counttotal = predictions.count()
val correct = lp.filter($"label" === $"prediction").count()

val wrong = lp.filter(not($"label" === $"prediction")).count()
val ratioWrong = wrong.toDouble / counttotal.toDouble
val ratioCorrect = correct.toDouble / counttotal.toDouble

val truep = lp.filter($"prediction" === 0.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val truen = lp.filter($"prediction" === 1.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val falsep = lp.filter($"prediction" === 1.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

val falsen = lp.filter($"prediction" === 0.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

println("Total Count : " + counttotal)
println("Correct : " + correct)
println("Wrong: " + wrong)
println("Ratio wrong: " + ratioWrong)
println("Ratio correct: " + ratioCorrect)
println("Ratio true positive : " + truep)
println("Ratio false positive : " + falsep)
println("Ratio true negative : " + truen)
println("Ratio false negative : " + falsen)
>>>

然而,我们还没有得到良好的准确性,因此让我们继续尝试其他分类器,例如 SMV。这次,我们将使用来自 Apache Spark ML 包的线性 SVM 实现。

SVM 用于流失预测

SVM 也广泛用于大规模分类任务(即二分类以及多项分类)。此外,它也是一种线性机器学习方法,如第一章《分析保险赔偿严重性》中所描述。线性 SVM 算法输出一个 SVM 模型,其中 SVM 使用的损失函数可以通过铰链损失来定义,如下所示:

L(w;x,y):=max{0,1−yw^Tx}

Spark 中的线性 SVM 默认使用 L2 正则化进行训练。然而,它也支持 L1 正则化,通过这种方式,问题本身变成了一个线性规划问题。

现在,假设我们有一组新的数据点 x;模型根据 w^Tx** 的值做出预测。默认情况下,如果 w^T**x**≥0,则结果为正,否则为负。

现在我们已经了解了 SVM 的工作原理,让我们开始使用基于 Spark 的 SVM 实现。我们从导入所需的包和库开始:

import org.apache.spark._
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.ml.classification.{LinearSVC, LinearSVCModel}
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions.max
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.tuning.{ParamGridBuilder, CrossValidator}
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator

现在,让我们创建一个 Spark 会话并导入隐式转换:

val spark: SparkSession = SparkSessionCreate.createSession("ChurnPredictionLogisticRegression")
import spark.implicits._

我们现在需要定义一些超参数来训练基于 LR 的管道:

val numFolds = 10
val MaxIter: Seq[Int] = Seq(100)
val RegParam: Seq[Double] = Seq(1.0) // L2 regularization param, set 0.10 with L1 reguarization
val Tol: Seq[Double] = Seq(1e-8)
val ElasticNetParam: Seq[Double] = Seq(1.0) // Combination of L1 and L2

现在,一旦我们定义并初始化了超参数,下一步是实例化一个 LR 估计器,如下所示:

val svm = new LinearSVC()

现在我们已经准备好三个转换器和一个估计器,下一步是将它们串联成一个管道——也就是说,每个都充当一个阶段:

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方法,以便完整的预定义流水线,包括所有特征预处理和 LR 分类器,将被执行多次——每次使用不同的超参数向量:

val cvModel = crossval.fit(Preprocessing.trainDF)

现在是时候评估 SVM 模型在测试数据集上的预测能力了。第一步,我们需要使用模型流水线转换测试集,这将根据我们在前面特征工程步骤中描述的机制来映射特征:

val predictions = cvModel.transform(Preprocessing.testSet)
prediction.show(10)
>>>

然而,从之前的预测数据框中,确实很难猜测分类准确率。在第二步中,评估器使用BinaryClassificationEvaluator进行自我评估,如下所示:

val accuracy = evaluator.evaluate(predictions)
println("Classification accuracy: " + accuracy)
>>>
Classification accuracy: 0.7530180345969819

所以我们从我们的二分类模型中得到了大约 75%的分类准确率。现在,单单使用二分类器的准确率并没有太大意义。

因此,研究人员通常推荐其他性能指标,比如精确度-召回曲线下面积和 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.5595712265324828
Area under the receiver operating characteristic (ROC) curve: 0.7530180345969819

在这种情况下,评估返回了 75%的准确率,但仅有 55%的精确度。接下来,我们再次计算一些其他指标;例如,假阳性、真阳性、假阴性和真阴性预测也有助于评估模型的性能:

val lp = predictions.select("label", "prediction")
val counttotal = predictions.count()

val correct = lp.filter($"label" === $"prediction").count()

val wrong = lp.filter(not($"label" === $"prediction")).count()
val ratioWrong = wrong.toDouble / counttotal.toDouble

val ratioCorrect = correct.toDouble / counttotal.toDouble

val truep = lp.filter($"prediction" === 0.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val truen = lp.filter($"prediction" === 1.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val falsep = lp.filter($"prediction" === 1.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

val falsen = lp.filter($"prediction" === 0.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

println("Total Count : " + counttotal)
println("Correct : " + correct)
println("Wrong: " + wrong)
println("Ratio wrong: " + ratioWrong)
println("Ratio correct: " + ratioCorrect)
println("Ratio true positive : " + truep)
println("Ratio false positive : " + falsep)
println("Ratio true negative : " + truen)
println("Ratio false negative : " + falsen)
>>>

然而,我们使用 SVM 时并没有获得好的准确率。而且,无法选择最合适的特征,这会帮助我们用最合适的特征训练模型。这一次,我们将再次使用一个更强大的分类器,比如 Apache Spark ML 包中的决策树DTs)实现。

用于流失预测的决策树

决策树通常被认为是一种监督学习技术,用于解决分类和回归任务。

更技术性地讲,决策树中的每个分支代表一个可能的决策、事件或反应,基于统计概率。与朴素贝叶斯相比,决策树是一种更强健的分类技术。原因在于,首先,决策树将特征分为训练集和测试集。然后,它通过良好的泛化能力来推断预测标签或类别。最有趣的是,决策树算法可以处理二分类和多分类问题。

例如,在下面的示例图中,DT 从入学数据中学习,通过一组if...else决策规则来近似正弦曲线。数据集包含每个申请入学学生的记录,例如,申请进入美国大学的学生。每条记录包含研究生入学考试成绩、CGPA 成绩和排名。现在,我们需要根据这三个特征(变量)预测谁是合格的。DTs 可以在训练 DT 模型并剪枝不需要的树枝后,用于解决这种问题。通常来说,更深的树表示更复杂的决策规则和更好的拟合模型:

图 6:大学入学数据的决策树

因此,树越深,决策规则越复杂,模型拟合度越高。现在,让我们看一下 DT 的优缺点:

优点 缺点 更擅长
决策树 (DTs) -简单实现、训练和解释-可以可视化树-数据准备要求少-模型构建和预测时间较短-可以处理数值和分类数据-通过统计测试验证模型的可能性-对噪声和缺失值具有鲁棒性-高精度 -大而复杂的树难以解释-同一子树中可能出现重复-可能存在对角决策边界的问题-决策树学习者可能会创建过于复杂的树,无法很好地泛化数据-有时 DTs 可能因为数据中的微小变化而不稳定-学习 DT 本身是一个 NP 完全问题-如果某些类占主导地位,DT 学习者会创建偏倚的树 -目标是实现高准确度的分类-医学诊断和预后-信用风险分析

现在,我们已经了解了 DT 的工作原理,接下来让我们开始使用基于 Spark 的 DT 实现。首先,导入所需的包和库:

import org.apache.spark._
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.apache.spark.sql._
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.classification.{DecisionTreeClassifier, DecisionTreeClassificationModel}
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator
import org.apache.spark.ml.tuning.{ParamGridBuilder, CrossValidator}

现在让我们创建一个 Spark 会话并导入隐式转换:

val spark: SparkSession = SparkSessionCreate.createSession("ChurnPredictionDecisionTrees")
import spark.implicits._

现在,一旦我们定义并初始化了超参数,下一步是实例化一个DecisionTreeClassifier估算器,如下所示:

val dTree = new DecisionTreeClassifier()
                .setLabelCol("label")
                .setFeaturesCol("features")
                .setSeed(1234567L)

现在,我们有三个转换器和一个估算器准备好,接下来的任务是将它们串联成一个单一的管道——即它们每个都作为一个阶段:

val pipeline = new Pipeline()
                .setStages(Array(PipelineConstruction.ipindexer,
                PipelineConstruction.labelindexer,
                PipelineConstruction.assembler,dTree))

让我们定义参数网格,在超参数空间上执行这样的网格搜索。这个搜索通过 DT 的杂质、最大分箱数和最大深度来寻找最佳模型。树的最大深度:深度 0 表示 1 个叶节点;深度 1 表示 1 个内部节点+2 个叶节点。

另一方面,最大分箱数用于分离连续特征并选择每个节点上如何分裂特征。更多的分箱提供更高的粒度。简而言之,我们通过决策树的maxDepthmaxBins参数来搜索最佳模型:

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

在前面的代码段中,我们通过序列格式创建了一个逐步的参数网格。这意味着我们正在创建一个包含不同超参数组合的网格空间。这将帮助我们提供由最优超参数组成的最佳模型。

让我们定义一个BinaryClassificationEvaluator评估器来评估模型:

val evaluator = new BinaryClassificationEvaluator()
    .setLabelCol("label")
    .setRawPredictionCol("prediction")

我们使用CrossValidator进行 10 折交叉验证,以选择最佳模型:

val crossval = new CrossValidator()
    .setEstimator(pipeline)
    .setEvaluator(evaluator)
    .setEstimatorParamMaps(paramGrid)
    .setNumFolds(numFolds)

现在让我们调用fit方法,这样完整的预定义管道,包括所有特征预处理和决策树分类器,将被多次执行——每次使用不同的超参数向量:

val cvModel = crossval.fit(Preprocessing.trainDF)

现在是时候评估决策树模型在测试数据集上的预测能力了。第一步,我们需要使用模型管道转换测试集,这将按照我们在前面的特征工程步骤中描述的相同机制映射特征:

val predictions = cvModel.transform(Preprocessing.testSet)
prediction.show(10)
>>>

然而,看到前面的预测 DataFrame,真的很难猜测分类准确率。在第二步中,评估是通过使用BinaryClassificationEvaluator进行评估,如下所示:

val accuracy = evaluator.evaluate(predictions)
println("Classification accuracy: " + accuracy)
>>>
Accuracy: 0.870334928229665

所以,我们从我们的二元分类模型中得到了大约 87%的分类准确率。现在,类似于 SVM 和 LR,我们将基于以下包含测试集原始分数的 RDD,观察精确度-召回曲线下的面积和 ROC 曲线下的面积:

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.7293101942399631
Area under the receiver operating characteristic (ROC) curve: 0.870334928229665

在这种情况下,评估结果返回 87%的准确率,但只有 73%的精确度,这比 SVM 和 LR 要好得多。接下来,我们将再次计算一些其他指标;例如,假阳性和真阳性及假阴性预测也有助于评估模型的性能:

val lp = predictions.select("label", "prediction")
val counttotal = predictions.count()

val correct = lp.filter($"label" === $"prediction").count()

val wrong = lp.filter(not($"label" === $"prediction")).count()

val ratioWrong = wrong.toDouble / counttotal.toDouble

val ratioCorrect = correct.toDouble / counttotal.toDouble

val truep = lp.filter($"prediction" === 0.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val truen = lp.filter($"prediction" === 1.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val falsep = lp.filter($"prediction" === 1.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

val falsen = lp.filter($"prediction" === 0.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

println("Total Count : " + counttotal)
println("Correct : " + correct)
println("Wrong: " + wrong)
println("Ratio wrong: " + ratioWrong)
println("Ratio correct: " + ratioCorrect)
println("Ratio true positive : " + truep)
println("Ratio false positive : " + falsep)
println("Ratio true negative : " + truen)
println("Ratio false negative : " + falsen)
>>>

太棒了;我们达到了 87%的准确率,但是什么因素导致的呢?嗯,可以通过调试来获得分类过程中构建的决策树。但首先,让我们看看在交叉验证后我们在什么层次上达到了最佳模型:

val bestModel = cvModel.bestModel
println("The Best Model and Parameters:n--------------------")
println(bestModel.asInstanceOf[org.apache.spark.ml.PipelineModel].stages(3))
>>>

最佳模型和参数:

DecisionTreeClassificationModel (uid=dtc_1fb45416b18b) of depth 5 with 53 nodes.

这意味着我们在深度为 5,节点数为 53 的决策树模型上达到了最佳效果。现在,让我们通过展示树来提取树构建过程中做出的决策。这棵树帮助我们找出数据集中最有价值的特征:

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

学到的分类树模型:

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

在前面的输出中,toDebugString()函数打印了决策树的决策节点,最终预测结果出现在叶子节点上。我们也可以清楚地看到特征 11 和 3 被用来做决策;它们是客户可能流失的两个最重要因素。那么这两个特征是什么呢?我们来看一下:

println("Feature 11:" + Preprocessing.trainDF.filter(PipelineConstruction.featureCols(11)))
println("Feature 3:" + Preprocessing.trainDF.filter(PipelineConstruction.featureCols(3)))
>>>
Feature 11: [total_international_num_calls: double]
Feature 3: [total_day_mins: double]

因此,客户服务电话和总通话时长被决策树选中,因为它提供了一种自动化机制来确定最重要的特征。

等等!我们还没完成。最后但同样重要的是,我们将使用一种集成技术——随机森林(RF),它被认为比决策树(DTs)更强大的分类器。同样,让我们使用 Apache Spark ML 包中的随机森林实现。

随机森林用于流失预测

正如在第一章中所述,分析保险严重性索赔,随机森林是一种集成技术,它通过构建决策树集成来进行预测——即,多个决策树的集成。更技术地讲,它构建多个决策树,并将它们集成在一起,以获得更准确和更稳定的预测。

图 7:随机森林及其集成技术的解释

这是一个直接的结果,因为通过独立评审团的最大投票,我们得到了比最佳评审团更好的最终预测(见前图)。现在我们已经知道了随机森林(RF)的工作原理,让我们开始使用基于 Spark 的 RF 实现。首先,导入所需的包和库:

import org.apache.spark._
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.apache.spark.sql._
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.classification.{RandomForestClassifier, RandomForestClassificationModel}
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator
import org.apache.spark.ml.tuning.{ParamGridBuilder, CrossValidator}

现在让我们创建 Spark 会话并导入隐式库:

val spark: SparkSession = SparkSessionCreate.createSession("ChurnPredictionRandomForest")
import spark.implicits._

现在,一旦我们定义并初始化了超参数,接下来的任务是实例化一个DecisionTreeClassifier估计器,如下所示:

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方法,以便执行完整的预定义管道,其中包括所有的特征预处理和决策树分类器,每次都会用不同的超参数向量执行:

val cvModel = crossval.fit(Preprocessing.trainDF)

现在是时候评估决策树模型在测试数据集上的预测能力了。第一步,我们需要将测试集转换为模型管道,这将按照我们在之前的特征工程步骤中描述的相同机制映射特征:

val predictions = cvModel.transform(Preprocessing.testSet)
prediction.show(10)
>>>

然而,通过查看前述的预测数据框,确实很难猜测分类准确性。在第二步中,评估是通过使用BinaryClassificationEvaluator来进行的,如下所示:

val accuracy = evaluator.evaluate(predictions)
println("Classification accuracy: " + accuracy)
>>>
Accuracy: 0.870334928229665

因此,我们的二分类模型得到了约 87%的分类准确率。现在,类似于 SVM 和 LR,我们将根据以下包含测试集原始分数的 RDD,观察精度-召回曲线下的面积以及 ROC 曲线下的面积:

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.7293101942399631
Area under the receiver operating characteristic (ROC) curve: 0.870334928229665

在这种情况下,评估返回了 87%的准确性,但仅有 73%的精度,这比 SVM 和 LR 要好得多。接下来,我们将再次计算一些更多的指标;例如,假阳性和真阳性、假阴性和真阴性预测也有助于评估模型的性能:

val lp = predictions.select("label", "prediction")
val counttotal = predictions.count()

val correct = lp.filter($"label" === $"prediction").count()

val wrong = lp.filter(not($"label" === $"prediction")).count()

val ratioWrong = wrong.toDouble / counttotal.toDouble

val ratioCorrect = correct.toDouble / counttotal.toDouble

val truep = lp.filter($"prediction" === 0.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val truen = lp.filter($"prediction" === 1.0).filter($"label" ===
$"prediction").count() / counttotal.toDouble

val falsep = lp.filter($"prediction" === 1.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

val falsen = lp.filter($"prediction" === 0.0).filter(not($"label" ===
$"prediction")).count() / counttotal.toDouble

println("Total Count : " + counttotal)
println("Correct : " + correct)
println("Wrong: " + wrong)
println("Ratio wrong: " + ratioWrong)
println("Ratio correct: " + ratioCorrect)
println("Ratio true positive : " + truep)
println("Ratio false positive : " + falsep)
println("Ratio true negative : " + truen)
println("Ratio false negative : " + falsen)
>>>

我们将得到以下结果:

太棒了;我们达到了 91%的准确率,但是什么因素导致的呢?嗯,类似于决策树,随机森林也可以调试,获取分类过程中构建的决策树。为了打印树并选择最重要的特征,尝试 DT 的最后几行代码,您就完成了。

现在,你能猜到训练了多少个不同的模型吗?嗯,我们在交叉验证上有 10 折,超参数空间的基数为 2 到 7 的五个维度。现在来做一些简单的数学计算:10 * 7 * 5 * 2 * 3 * 6 = 12600 个模型!

请注意,我们仍然将超参数空间限制在numTreesmaxBinsmaxDepth的范围内,最大为 7。此外,请记住,较大的树通常会表现得更好。因此,欢迎在此代码上进行尝试,添加更多特性,并使用更大的超参数空间,例如,更大的树。

选择最佳模型进行部署

从前述结果可以看出,LR 和 SVM 模型的假阳性率与随机森林和决策树相同,但较高。因此,我们可以说,在真正的阳性计数方面,决策树和随机森林的准确性整体上更好。让我们通过每个模型的饼图预测分布来验证前述说法的有效性:

现在,值得一提的是,使用随机森林时,我们实际上能获得较高的准确性,但这是一个非常耗费资源和时间的工作;特别是训练,与 LR 和 SVM 相比,训练时间显著较长。

因此,如果您的内存或计算能力较低,建议在运行此代码之前增加 Java 堆空间,以避免 OOM 错误。

最后,如果您想部署最佳模型(在我们的案例中是随机森林),建议在fit()方法调用后立即保存交叉验证模型:

// Save the workflow
cvModel.write.overwrite().save("model/RF_model_churn")

训练好的模型将保存在该位置。该目录将包含:

  • 最佳模型

  • 估计器

  • 评估器

  • 训练本身的元数据

现在,接下来的任务是恢复相同的模型,如下所示:

// Load the workflow back
val cvModel = CrossValidatorModel.load("model/ RF_model_churn/")

最后,我们需要将测试集转换为模型管道,以便根据我们在前述特征工程步骤中描述的相同机制映射特征:

val predictions = cvModel.transform(Preprocessing.testSet)

最后,我们评估恢复的模型:

val evaluator = new BinaryClassificationEvaluator()
    .setLabelCol("label")
    .setRawPredictionCol("prediction")

val accuracy = evaluator.evaluate(predictions)
    println("Accuracy: " + accuracy)
    evaluator.explainParams()

val predictionAndLabels = predictions
    .select("prediction", "label")
    .rdd.map(x => (x(0).asInstanceOf[Double], x(1)
    .asInstanceOf[Double]))

val metrics = new BinaryClassificationMetrics(predictionAndLabels)
val areaUnderPR = metrics.areaUnderPR
println("Area under the precision-recall curve: " + areaUnderPR)
val areaUnderROC = metrics.areaUnderROC
println("Area under the receiver operating characteristic (ROC) curve: " + areaUnderROC)
>>>

您将收到以下输出:

好了,完成了!我们成功地重用了模型并进行了相同的预测。但是,由于数据的随机性,我们观察到略微不同的预测结果。

总结

在本章中,我们已经学习了如何开发一个机器学习项目来预测客户是否有可能取消订阅,并通过此方法开发了一个真实的预测模型。我们使用了逻辑回归(LR)、支持向量机(SVM)、决策树(DT)和随机森林(Random Forest)来构建预测模型。我们还分析了通常用来进行数据初步分析的客户数据类型。最后,我们了解了如何选择适合生产环境的模型。

在下一章中,我们将学习如何开发一个真实项目,该项目收集历史和实时的比特币数据,并预测未来一周、一个月等的价格。此外,我们还将学习如何为在线加密货币交易生成一个简单的信号。

第三章:基于历史和实时数据的高频比特币价格预测

比特币是一种全球性的加密货币和数字支付系统,被认为是第一种去中心化的数字货币,因为该系统无需中央存储库或单一管理员即可运行。近年来,它在全球范围内获得了极大的关注和流行。

在本章中,我们将展示如何使用 Scala、Spark ML、Cryptocompare API 和比特币历史(以及实时)数据来预测未来一周、一个月等的价格,这将帮助我们为在线加密货币交易做出自动化决策。此外,我们还将展示如何生成一个简单的信号用于在线比特币交易。

简要来说,在这个端到端的项目中,我们将学习以下主题:

  • 比特币,加密货币与在线交易

  • 历史和实时价格数据收集

  • 原型的高级管道

  • 使用梯度提升树回归预测比特币价格

  • 使用 Scala play 框架进行的演示预测和信号生成

  • 未来展望——使用相同的技术处理其他数据集

比特币,加密货币与在线交易

比特币,作为按发行时间和市值(截至 2017 年 12 月)排名第一的加密货币,因其易于开始交易、能够保持伪匿名性以及历史上的剧烈增长(参见表 1图 1获取一些统计数据)而吸引了投资者和交易员。这吸引了长期投资者;而其高度的波动性也吸引了日内交易者。

然而,很难预测比特币的长期价值,因为比特币背后的价值较为抽象。其价格主要反映市场认知,并且高度依赖新闻、法规、政府和银行的合作、平台的技术问题(如交易费用和区块大小)、机构投资者将比特币纳入其投资组合的兴趣等因素:

图 1:比特币及其剧烈的价格上涨

然而,从短期来看,比特币价格是市场活动的副产品,通常发生在一个被称为交易所的平台上(其中最著名的交易所包括 Bitstamp、Coinbase、Kraken 和 Bitfinex)。用户注册并完成KYC了解你的客户)程序后,可以在其中交易比特币,兑换成法币如美元和欧元,也可以交易其他加密货币,称为山寨币(例如以太坊、莱特币和达世币等较为知名的山寨币):

表 1 - 比特币历史价格变动

日期 USD: 1 BTC
2009 年 1 月到 2010 年 3 月 基本上没有
2010 年 3 月 $0.003
2010 年 5 月 低于$0.01
2010 年 7 月 $0.08
2011 年 2 月到 4 月 $1.00
2011 年 7 月 8 日 $31.00
2011 年 12 月 $2.00
2012 年 12 月 $13.00
2013 年 4 月 11 日 $266
2013 年 5 月 $130
2013 年 6 月 $100
2013 年 11 月 $350 至 $1,242
2013 年 12 月 $600 至 $1,000
2014 年 1 月 $750 至 $1,000
2014 年 2 月 $550 至 $750
2014 年 3 月 $450 至 $700
2014 年 4 月 $340 至 $530
2014 年 5 月 $440 至 $630
2015 年 3 月 $200 至 $300
2015 年 11 月初 $395 至 $504
2016 年 5 月至 6 月 $450 至 $750
2016 年 7 月至 9 月 $600 至 $630
2016 年 10 月至 11 月 $600 至 $780
2017 年 1 月 $800 至 $1,150
2017 年 1 月 5 日至 12 日 $750 至 $920
2017 年 3 月 2 日至 3 日 $1,290+
2017 年 4 月 $1,210 至 $1,250
2017 年 5 月 $2,000
2017 年 5 月至 6 月 $2,000 至 $3,200+
2017 年 8 月 $4,400
2017 年 9 月 $5,000
2017 年 9 月 12 日 $2,900
2017 年 10 月 13 日 $5,600
2017 年 10 月 21 日 $6,180
2017 年 11 月 6 日 $7,300
2017 年 11 月 12 日 $5,519 至 $6,295
2017 年 11 月 17 日至 20 日 $7,600 至 $8,100
2017 年 12 月 15 日 17,900

交易所维护订单簿——列出了所有买卖订单、其数量和价格——并在买卖双方匹配时执行交易。此外,交易所还保持并提供有关交易状态的统计信息,通常以 OCHL(开盘、最高、最低、收盘)和成交量表示,涵盖交易对的两种货币。对于这个项目,我们将使用 BTC/USD 加密货币对。

这些数据按周期汇总展示,从秒到天,甚至到月。为专业交易员和机构收集比特币数据的专用服务器也在运行。虽然不可能期望所有订单数据都能免费获取,但其中一些数据对公众开放并且可以使用。

最先进的比特币自动化交易

在传统证券的世界中,比如公司的股票,过去是由人类来进行分析、预测股票价格并进行交易的。今天,机器学习ML)的发展和数据的日益丰富几乎已将人类从高频交易中排除,因为普通人无法捕捉并处理所有数据,而且情绪会影响决策;因此,这一领域现在主要由投资机构的自动化交易系统主导。

当前,比特币交易的交易量相对于传统交易所较低;金融机构传统上谨慎且风险厌恶,尚未涉足比特币交易(至少,尚未公开知晓)。其中一个原因是高费用和对加密货币法规的不确定性。

所以今天,大多数人购买和出售比特币,伴随着所有与非理性行为相关的后果,但也有人尝试自动化比特币交易。最著名的一次尝试是在 MIT 的一篇论文中提出的,另一次是在 2014 年由斯坦福研究人员发布的。很多事情发生了变化,考虑到这三年中比特币价格的大幅上涨,任何只是买入并持有的人都会对结果感到满意:

图 2:比特币买卖订单(截至 2017 年 11 月)

当然,确实有一些交易员使用机器学习进行交易,这类应用看起来前景广阔。到目前为止,从研究论文中确定的最佳方法如下:

训练

使用订单簿数据,而不是衍生的 OHLC + 成交量数据。因此,训练和预测时,使用的数据看起来是这样的:

  • 将数据划分为一定大小的时间序列(大小是一个需要调整的参数)。

  • 将时间序列数据聚类为K个聚类(K是一个需要调整的参数)。假设会出现一些具有自然趋势的聚类(如价格剧烈下降/上升等)。

  • 对于每个聚类,训练回归模型和分类器,分别预测价格和价格变化。

预测

这种方法考虑了具有特定窗口大小的最新时间序列,并对模型进行训练。然后,它对数据进行如下分类:

  • 采用用于训练的最新时间序列和窗口大小

  • 进行分类——它属于哪个聚类?

  • 使用该聚类的 ML 模型预测价格或价格变化

这个解决方案可以追溯到 2014 年,但它仍然提供了一定程度的鲁棒性。由于需要识别许多参数,并且没有容易获得的订单簿历史数据,本项目使用了更简单的方法和数据集。

原型的高级数据管道

本章的目标是开发一个原型系统,该系统将预测比特币价格的短期变化,使用历史数据训练算法,并使用实时数据预测和选择表现更好的算法。在本项目范围内,并不尝试预测实际的美元价格,而仅仅是预测其是否会上涨。因为比特币价格在某种程度上实际上并不只是价格本身,而是市场预期。这可以看作是交易者行为中的模式,从更高层次上讲,表现为历史价格本身。

图 3:原型的高级数据管道

当然,比特币有一个客观的价格;矿工愿意出售比特币以获取利润。因此,基准价格可以通过了解所有矿工为开采比特币必须支付的费用来估算,但这超出了本项目的范围。

从这个角度来看,与其试图预测美元价格,不如寻找价格上涨、下跌或保持不变的趋势,并根据这些趋势采取行动。第二个目标是构建一个实验工具,允许我们尝试不同的价格预测方法,并能轻松在实际数据上进行评估。代码必须具有灵活性、稳健性,并且易于扩展。

因此,总结来说,系统有三个主要组成部分:

  • 用于将历史数据预处理成所需格式的 Scala 脚本

  • Scala 应用程序用于训练机器学习模型

  • Scala 网络服务预测未来价格

历史和实时价格数据收集

如前所述,我们将同时使用历史数据和实时数据。我们将使用来自 Kaggle 的比特币历史价格数据。对于实时数据,将使用 Cryptocompare API。

历史数据收集

为了训练机器学习算法,有一个公开的比特币历史价格数据数据集(版本 10),可以在 Kaggle 上找到。该数据集包含来自多个交易所的 BTC-USD 对的 1 分钟 OHLC 数据,可以从www.kaggle.com/mczielinski/bitcoin-historical-data/下载。

在项目初期,大多数数据可用的时间范围是从 2012 年 1 月 1 日到 2017 年 5 月 31 日;但是对于 Bitstamp 交易所,数据可用时间截至 2017 年 10 月 20 日(对于 Coinbase 也是如此,但该数据集稍后才提供):

图 4:Kaggle 上的比特币历史数据集

请注意,您需要是注册用户并已登录才能下载该文件。我们使用的文件是bitstampUSD_1-min_data_2012-01-01_to_2017-10-20.csv现在,让我们来获取我们拥有的数据。它有八列:

  • 时间戳:自 1970 年 1 月 1 日以来的秒数。对于第一行,它是 1,325,317,920,对于第二行也是 1,325,317,920。(完整性检查!它们之间的差异是 60 秒)。

  • 开盘价:该时间区间的开盘价格。为 4.39 美元。因此,它是发生在时间戳(第一个行中的时间戳为 1,325,317,920)之后的第一次交易的价格。

  • 收盘价:该时间区间的收盘价格。

  • 最高价:在该区间内执行的所有订单中的最高价格。

  • 最低价:与最高价相同,但它是最低价格。

  • 交易量 _(BTC):在该时间区间内转移的所有比特币的总和。因此,选择该区间内发生的所有交易并将每笔交易的 BTC 值相加。

  • 交易量 _(货币):转移的所有美元的总和。

  • 加权价格:这是根据 BTC 和 USD 的交易量得出的。通过将所有交易的美元数额除以所有比特币的交易量,我们可以得出该分钟的 BTC 加权平均价格。所以加权价格=交易量 _(货币)/交易量 _(BTC)

数据科学流程中最重要的部分之一是数据预处理——清理数据集并将其转换为适合我们需求的形式,这发生在数据收集之后(在某种意义上,数据收集是外包的;我们使用的是他人收集的数据)。

历史数据转换为时间序列

从我们的目标出发——预测价格变化的方向——我们可能会问自己,实际的美元价格是否有助于实现这一目标? 历史上,比特币的价格通常是上涨的,所以如果我们尝试拟合线性回归,它将显示出进一步的指数增长(长期内是否会如此,还需要观察)。

假设和设计选择

这个项目的一个假设是:无论我们是在考虑 2016 年 11 月比特币的交易价格大约为 700 美元,还是 2017 年 11 月的价格在 6500 至 7000 美元区间,交易模式是相似的。现在,我们还有几个其他的假设,如下所述:

  • 假设一:根据前面的说法,我们可以忽略实际价格,而是关注价格变化。作为这一变化的度量,我们可以取开盘价和收盘价之间的差值。如果为正,表示该分钟内价格上涨;如果为负,表示价格下跌;如果差值为 0,表示价格保持不变。

    在下图中,我们可以看到,观察的第一分钟 Delta 为 -1.25,第二分钟为 -12.83,第三分钟为 -0.23。有时候,开盘价与上一分钟的收盘价差异较大(尽管在观察的三分钟内,Delta 都是负值,但在第三分钟,显示的价格实际上比收盘价略高)。但这种情况并不常见,通常情况下开盘价与上一分钟的收盘价变化不大。

  • 假设二:接下来需要考虑的是在黑箱环境下预测价格变化。我们不使用新闻、Twitter 动态等其他信息来源来预测市场如何对它们做出反应。这是一个更为高级的话题。我们唯一使用的数据是价格和成交量。为了简化原型,我们可以只关注价格并构建时间序列数据。

    时间序列预测是基于该参数过去的值来预测该参数的未来值。最常见的一个例子是温度预测。尽管有许多超级计算机使用卫星和传感器数据来预测天气,但简单的时间序列分析也能得出一些有价值的结果。例如,我们可以基于 T 时刻、T-60 秒、T-120 秒等时刻的价格来预测 T+60 秒时的价格。

  • 假设三:数据集中并非所有数据都有价值。前 60 万条记录不具备信息量,因为价格变化稀少,成交量较小。这可能会影响我们训练的模型,从而导致最终结果变差。因此,数据集中前 60 万条记录会被剔除。

  • 假设四:我们需要标签化我们的数据,以便使用监督式机器学习算法。这是最简单的措施,且无需担心交易费用。

数据预处理

考虑到数据准备的目标,选择了 Scala 作为一种简便且交互性强的数据处理方式:

val priceDataFileName: String = "bitstampUSD_1-min_data_2012-01-01_to_2017-10-20.csv"

val spark = SparkSession
    .builder()
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName("Bitcoin Preprocessing")
    .getOrCreate()

val data = spark.read.format("com.databricks.spark.csv").option("header", "true").load(priceDataFileName)
data.show(10)
>>>

图 5:比特币历史价格数据集一瞥

println((data.count(), data.columns.size))
(3045857, 8)

在前面的代码中,我们从 Kaggle 下载的文件中加载数据,并查看其内容。数据集中有3045857行数据,8列,之前已经描述过。接着我们创建了Delta列,包含了收盘价与开盘价之间的差值(也就是只考虑那些已经开始有意义的交易数据):

val dataWithDelta = data.withColumn("Delta", data("Close") - data("Open"))

以下代码通过将Delta值为正的行标记为 1,其他行标记为0,为我们的数据打上标签:

import org.apache.spark.sql.functions._
import spark.sqlContext.implicits._

val dataWithLabels = dataWithDelta.withColumn("label", when($"Close" - $"Open" > 0, 1).otherwise(0))
rollingWindow(dataWithLabels, 22, outputDataFilePath, outputLabelFilePath)

这段代码将原始数据集转换为时间序列数据。它将WINDOW_SIZE行(在此实验中为22)的 Delta 值合并成一行。这样,第一行包含从t0t21的 Delta 值,第二行包含从t1t22的 Delta 值。然后我们创建相应的标签数组(10)。

最后,我们将 XY 保存到文件中,其中 612000 行数据被从原始数据集中截取;22 表示滚动窗口大小,2 个类别表示标签是二进制的 01

val dropFirstCount: Int = 612000

def rollingWindow(data: DataFrame, window: Int, xFilename: String, yFilename: String): Unit = {
 var i = 0
 val xWriter = new BufferedWriter(new FileWriter(new File(xFilename)))
 val yWriter = new BufferedWriter(new FileWriter(new File(yFilename)))
 val zippedData = data.rdd.zipWithIndex().collect()
    System.gc()
 val dataStratified = zippedData.drop(dropFirstCount)//slice 612K

 while (i < (dataStratified.length - window)) {
 val x = dataStratified
                .slice(i, i + window)
                    .map(r => r._1.getAsDouble).toList
 val y = dataStratified.apply(i + window)._1.getAsInteger
 val stringToWrite = x.mkString(",")
        xWriter.write(stringToWrite + "n")
        yWriter.write(y + "n")
        i += 1

 if (i % 10 == 0) {
            xWriter.flush()
            yWriter.flush()
            }
        }
    xWriter.close()
    yWriter.close()
}

在前面的代码段中:

val outputDataFilePath: String = "output/scala_test_x.csv"
val outputLabelFilePath: String = "output/scala_test_y.csv"

通过 Cryptocompare API 获取实时数据

对于实时数据,使用 Cryptocompare API(www.cryptocompare.com/api/#),更具体来说是 HistoMinute(www.cryptocompare.com/api/#-api-data-histominute-),它使我们可以访问最多过去七天的 OHLC 数据。API 的细节将在专门讨论实现的部分中说明,但 API 响应与我们的历史数据集非常相似,这些数据是通过常规的 HTTP 请求获取的。例如,来自 min-api.cryptocompare.com/data/histominute?fsym=BTC&tsym=USD&limit=23&aggregate=1&e=Bitstamp 的简单 JSON 响应具有以下结构:

{
    "Response":"Success",
    "Type":100,
    "Aggregated":false,
    "Data":
    [{"time":1510774800,"close":7205,"high":7205,"low":7192.67,"open":7198,                                             "volumefrom":81.73,"volumeto":588726.94},
        {"time":1510774860,"close":7209.05,"high":7219.91,"low":7205,"open":7205,                                 "volumefrom":16.39,"volumeto":118136.61},
        ... (other price data)
        ],
    "TimeTo":1510776180,
    "TimeFrom":1510774800,
    "FirstValueInArray":true,
    "ConversionType":{"type":"force_direct","conversionSymbol":""}
}

通过 Cryptocompare HistoMinute,我们可以获取每分钟的历史数据中的 openhighlowclosevolumefromvolumeto。这些数据仅保存 7 天;如果需要更多数据,可以使用按小时或按日的路径。如果数据不可用(因为该货币没有在指定的货币中进行交易),它会使用 BTC 转换:

图 6:通过 Cryptocompare HistoMinute 获取的开盘价、最高价、最低价、收盘价和交易量值

现在,以下方法获取正确格式的 Cryptocompare API URL(www.cryptocompare.com/api/#-api-data-histominute-),这是一个完全形成的 URL,指定了所有的参数,如货币、限制和聚合等。它最终返回一个 future 对象,该对象将解析响应体并转化为数据模型,价格列表将在上层进行处理:

import javax.inject.Inject
import play.api.libs.json.{JsResult, Json}
import scala.concurrent.Future
import play.api.mvc._
import play.api.libs.ws._
import processing.model.CryptoCompareResponse

class RestClient @Inject() (ws: WSClient) {
 def getPayload(url : String): Future[JsResult[CryptoCompareResponse]] = {
        val request: WSRequest = ws.url(url)
        val future = request.get()
        implicit val context = play.api.libs.concurrent.Execution.Implicits.defaultContext
        future.map {
            response => response.json.validate[CryptoCompareResponse]
            }
        }
    }

在前面的代码段中,CryptoCompareResponse 类是 API 的模型,它需要以下参数:

  • 响应

  • 类型

  • 聚合

  • 数据

  • FirstValueInArray

  • TimeTo

  • TimeFrom

现在,它具有以下签名:

case class CryptoCompareResponse(Response : String,
    Type : Int,
    Aggregated : Boolean,
    Data : List[OHLC],
    FirstValueInArray : Boolean,
    TimeTo : Long,
    TimeFrom: Long)

object CryptoCompareResponse {
    implicit val cryptoCompareResponseReads = Json.reads[CryptoCompareResponse]
    }

再次说明,前两个代码段中的 open-high-low-close(也称为 OHLC)是用于与 CryptoAPI 响应的 data 数组内部映射的模型类。它需要这些参数:

  • Time:时间戳(以秒为单位),例如 1508818680

  • Open:给定分钟间隔的开盘价。

  • High:最高价。

  • Low:最低价。

  • Close:区间结束时的价格。

  • Volumefromfrom 货币的交易量。在我们的例子中是 BTC。

  • Volumetoto 货币的交易量,在我们的例子中是 USD。

  • Volumeto 除以 Volumefrom 可以给我们带来 BTC 的加权价格。

现在,它具有以下签名:

case class OHLC(time: Long,
    open: Double,
    high: Double,
    low: Double,
    close: Double,
    volumefrom: Double,
    volumeto: Double)

    object OHLC {
    implicit val implicitOHLCReads = Json.reads[OHLC]
        }

预测模型训练

在项目中,prediction.training包下有一个名为TrainGBT.scala的 Scala 对象。在启动之前,你需要指定/更改以下四个设置:

  • 在代码中,你需要设置 spark.sql.warehouse.dir,将其指向你电脑中某个有足够存储空间的实际路径:set("spark.sql.warehouse.dir", "/home/user/spark")

  • RootDir是主文件夹,所有文件和训练模型将存储在此处:rootDir = "/home/user/projects/btc-prediction/"

  • 确保x文件名与前一步 Scala 脚本生成的文件名匹配:x = spark.read.format("com.databricks.spark.csv ").schema(xSchema).load(rootDir + "scala_test_x.csv")

  • 确保y文件名与 Scala 脚本生成的文件名匹配:y_tmp=spark.read.format("com.databricks.spark.csv").schema(ySchema).load(rootDir + "scala_test_y.csv")

训练的代码使用了 Apache Spark 的 ML 库(以及为其所需的库)来训练分类器,这意味着这些库必须出现在你的class路径中,才能运行它。最简单的做法是(由于整个项目使用 SBT),从项目根文件夹运行,输入 sbt run-main prediction.training.TrainGBT,这将解析所有依赖并启动训练。

根据迭代次数和深度的不同,训练模型可能需要几个小时。现在,让我们通过梯度提升树模型的示例来看看训练是如何进行的。首先,我们需要创建一个SparkSession对象:

val spark = SparkSession
        .builder()
        .master("local[*]")
        .config("spark.sql.warehouse.dir", ""/home/user/spark/")
        .appName("Bitcoin Preprocessing")
        .getOrCreate()

然后,我们定义xy的数据模式。我们将列重命名为t0t21,表示它是时间序列数据:

val xSchema = StructType(Array(
    StructField("t0", DoubleType, true),
    StructField("t1", DoubleType, true),
    StructField("t2", DoubleType, true),
    StructField("t3", DoubleType, true),
    StructField("t4", DoubleType, true),
    StructField("t5", DoubleType, true),
    StructField("t6", DoubleType, true),
    StructField("t7", DoubleType, true),
    StructField("t8", DoubleType, true),
    StructField("t9", DoubleType, true),
    StructField("t10", DoubleType, true),
    StructField("t11", DoubleType, true),
    StructField("t12", DoubleType, true),
    StructField("t13", DoubleType, true),
    StructField("t14", DoubleType, true),
    StructField("t15", DoubleType, true),
    StructField("t16", DoubleType, true),
    StructField("t17", DoubleType, true),
    StructField("t18", DoubleType, true),
    StructField("t19", DoubleType, true),
    StructField("t20", DoubleType, true),
    StructField("t21", DoubleType, true))
    )

然后,我们读取为模式定义的文件。为了方便起见,我们在 Scala 中生成了两个单独的文件来存储数据和标签,因此这里我们需要将它们合并为一个 DataFrame:

import spark.implicits._
val y = y_tmp.withColumn("y", 'y.cast(IntegerType))
import org.apache.spark.sql.functions._

val x_id = x.withColumn("id", monotonically_increasing_id())
val y_id = y.withColumn("id", monotonically_increasing_id())
val data = x_id.join(y_id, "id")

下一步是 Spark 要求的——我们需要将特征向量化:

val featureAssembler = new VectorAssembler()
        .setInputCols(Array("t0", "t1", "t2", "t3",
                            "t4", "t5", "t6", "t7",
                            "t8", "t9", "t10", "t11",
                            "t12", "t13", "t14", "t15",
                            "t16", "t17", "t18", "t19",
                            "t20", "t21"))
        .setOutputCol("features")

我们将数据随机拆分为训练集和测试集,比例为 75%对 25%。我们设置种子,以确保每次运行训练时,数据拆分相同:

val Array(trainingData,testData) = dataWithLabels.randomSplit(Array(0.75, 0.25), 123)

然后,我们定义模型。它告诉我们哪些列是特征,哪些是标签,同时设置参数:

val gbt = new GBTClassifier()
        .setLabelCol("label")
        .setFeaturesCol("features")
        .setMaxIter(10)
        .setSeed(123)

创建一个pipeline步骤——特征向量组合和 GBT 运行:

val pipeline = new Pipeline()
            .setStages(Array(featureAssembler, gbt))

定义评估器函数——模型如何知道自己是否表现良好。由于我们只有两个不平衡的类别,准确率是一个不好的衡量标准;ROC 曲线下的面积更合适:

val rocEvaluator = new BinaryClassificationEvaluator()
        .setLabelCol("label")
        .setRawPredictionCol("rawPrediction")
        .setMetricName("areaUnderROC")

使用 K 折交叉验证来避免过拟合;每次迭代会去除五分之一的数据,利用其余数据训练模型,然后在这一五分之一的数据上进行测试:

val cv = new CrossValidator()
        .setEstimator(pipeline)
        .setEvaluator(rocEvaluator)
        .setEstimatorParamMaps(paramGrid)
        .setNumFolds(numFolds)
        .setSeed(123)
val cvModel = cv.fit(trainingData)

在获得训练后的模型后(根据迭代次数和我们在paramGrid中指定的参数,可能需要一个小时或更长时间),我们接着在测试数据上计算预测结果:

val predictions = cvModel.transform(testData)

此外,评估预测的质量:

val roc = rocEvaluator.evaluate(predictions)

训练后的模型将被保存,以便预测服务后续使用:

val gbtModel = cvModel.bestModel.asInstanceOf[PipelineModel]
gbtModel.save(rootDir + "__cv__gbt_22_binary_classes_" + System.nanoTime() / 1000000 + ".model")

总结来说,模型训练的代码如下所示:

import org.apache.spark.{ SparkConf, SparkContext }
import org.apache.spark.ml.{ Pipeline, PipelineModel }

import org.apache.spark.ml.classification.{ GBTClassificationModel, GBTClassifier, RandomForestClassificationModel, RandomForestClassifier}
import org.apache.spark.ml.evaluation.{BinaryClassificationEvaluator, MulticlassClassificationEvaluator}
import org.apache.spark.ml.feature.{IndexToString, StringIndexer, VectorAssembler, VectorIndexer}
import org.apache.spark.ml.tuning.{CrossValidator, ParamGridBuilder}
import org.apache.spark.sql.types.{DoubleType, IntegerType, StructField, StructType}
import org.apache.spark.sql.SparkSession

object TrainGradientBoostedTree {
    def main(args: Array[String]): Unit = {
        val maxBins = Seq(5, 7, 9)
        val numFolds = 10
        val maxIter: Seq[Int] = Seq(10)
        val maxDepth: Seq[Int] = Seq(20)
        val rootDir = "output/"
        val spark = SparkSession
            .builder()
            .master("local[*]")
            .config("spark.sql.warehouse.dir", ""/home/user/spark/")
            .appName("Bitcoin Preprocessing")
            .getOrCreate()

        val xSchema = StructType(Array(
            StructField("t0", DoubleType, true),
            StructField("t1", DoubleType, true),
            StructField("t2", DoubleType, true),
            StructField("t3", DoubleType, true),
            StructField("t4", DoubleType, true),
            StructField("t5", DoubleType, true),
            StructField("t6", DoubleType, true),
            StructField("t7", DoubleType, true),
            StructField("t8", DoubleType, true),
            StructField("t9", DoubleType, true),
            StructField("t10", DoubleType, true),
            StructField("t11", DoubleType, true),
            StructField("t12", DoubleType, true),
            StructField("t13", DoubleType, true),
            StructField("t14", DoubleType, true),
            StructField("t15", DoubleType, true),
            StructField("t16", DoubleType, true),
            StructField("t17", DoubleType, true),
            StructField("t18", DoubleType, true),
            StructField("t19", DoubleType, true),
            StructField("t20", DoubleType, true),
            StructField("t21", DoubleType, true)))

        val ySchema = StructType(Array(StructField("y", DoubleType,
        true)))
        val x = spark.read.format("csv").schema(xSchema).load(rootDir +
        "scala_test_x.csv")
        val y_tmp =
        spark.read.format("csv").schema(ySchema).load(rootDir +
        "scala_test_y.csv")

        import spark.implicits._
        val y = y_tmp.withColumn("y", 'y.cast(IntegerType))

        import org.apache.spark.sql.functions._
        //joining 2 separate datasets in single Spark dataframe
        val x_id = x.withColumn("id", monotonically_increasing_id())
        val y_id = y.withColumn("id", monotonically_increasing_id())
        val data = x_id.join(y_id, "id")
        val featureAssembler = new VectorAssembler()
            .setInputCols(Array("t0", "t1", "t2", "t3", "t4", "t5", 
                                "t6", "t7", "t8", "t9", "t10", "t11", 
                                "t12", "t13", "t14", "t15", "t16",
                                "t17","t18", "t19", "t20", "t21"))
            .setOutputCol("features")
        val encodeLabel = udf[Double, String] { case "1" => 1.0 case
                                                "0" => 0.0 }
        val dataWithLabels = data.withColumn("label",
                                encodeLabel(data("y")))

        //123 is seed number to get same datasplit so we can tune
        params
        val Array(trainingData, testData) =
        dataWithLabels.randomSplit(Array(0.75, 0.25), 123)
        val gbt = new GBTClassifier()
            .setLabelCol("label")
            .setFeaturesCol("features")
            .setMaxIter(10)
            .setSeed(123)
        val pipeline = new Pipeline()
            .setStages(Array(featureAssembler, gbt))
        // ***********************************************************
        println("Preparing K-fold Cross Validation and Grid Search")
        // ***********************************************************
        val paramGrid = new ParamGridBuilder()
            .addGrid(gbt.maxIter, maxIter)
            .addGrid(gbt.maxDepth, maxDepth)
            .addGrid(gbt.maxBins, maxBins)
            .build()
        val cv = new CrossValidator()
            .setEstimator(pipeline)
            .setEvaluator(new BinaryClassificationEvaluator())
            .setEstimatorParamMaps(paramGrid)
            .setNumFolds(numFolds)
            .setSeed(123)
        // ************************************************************
        println("Training model with GradientBoostedTrees algorithm")
        // ************************************************************
        // Train model. This also runs the indexers.
        val cvModel = cv.fit(trainingData)
        cvModel.save(rootDir + "cvGBT_22_binary_classes_" +
        System.nanoTime() / 1000000 + ".model")
        println("Evaluating model on train and test data and
        calculating RMSE")
        // **********************************************************************
        // Make a sample prediction
        val predictions = cvModel.transform(testData)

        // Select (prediction, true label) and compute test error.
        val rocEvaluator = new BinaryClassificationEvaluator()
            .setLabelCol("label")
            .setRawPredictionCol("rawPrediction")
            .setMetricName("areaUnderROC")
        val roc = rocEvaluator.evaluate(predictions)
        val prEvaluator = new BinaryClassificationEvaluator()
            .setLabelCol("label")
            .setRawPredictionCol("rawPrediction")
            .setMetricName("areaUnderPR")
        val pr = prEvaluator.evaluate(predictions)
        val gbtModel = cvModel.bestModel.asInstanceOf[PipelineModel]
        gbtModel.save(rootDir + "__cv__gbt_22_binary_classes_" +
        System.nanoTime()/1000000 +".model")

        println("Area under ROC curve = " + roc)
        println("Area under PR curve= " + pr)
        println(predictions.select().show(1))
        spark.stop()
    }
}

现在让我们看看训练的进展:

Area under ROC curve = 0.6045355104779828
Area under PR curve= 0.3823834607704922


因此,我们并未获得非常高的准确度,因为最佳 GBT 模型的 ROC 仅为 60.50%。尽管如此,如果我们调整超参数,准确度会更好。

然而,由于时间不足,我并未长时间进行训练迭代,但你绝对应该尝试一下。

# Scala Play Web 服务

作为应用框架,Play2 被选择为一个易于配置且强大的框架。与 Spring(另一个流行框架)相比,Play2 在从零开始构建一个小型应用时所需时间更少。Play 自带 Guice 用于依赖注入,并且使用 SBT 作为包管理器:

+   **Spark ML**:选择 Spark ML 库是因为它是 Java 世界中维护得最好的库之一。许多库本身没有的算法是由第三方开发者实现的,并且可以在 Spark 上进行训练。Spark 的一个缺点是它相对较慢,因为它设计上是分布式的;它使用 Hadoop 并大量写入文件系统。

+   **Akka**:这使得实现 Actor 模式成为可能——拥有多个独立对象实例并在彼此之间并发传递消息,从而提高了系统的健壮性。

+   **Anorm**:这是一个基于 JDBC 的 SQL 操作库。Slick 是另一个选择,功能更强大,但由于 Akka 和 Slick 所需库之间的兼容性问题,最终选择了另一个库。

+   **H2**:一个数据库,作为 Play 和 Ruby-on-Rails 的默认数据库,易于启动,且可以将数据存储在本地数据库文件中,而无需安装数据库服务器。这提供了可移植性并加快了开发速度。在后期阶段,它可以被其他数据库替代,因为 Scala 代码与特定数据库无关;所有配置均在配置层面完成。

# 通过 Akka Actor 实现并发

通过使用 Akka Scala 库中的 `actor` 模型来实现并发。Actor 作为独立的实体,可以异步地向其他 Actor 传递消息。在这个项目中,有三个 Actor:`SchedulerActor`、`PredictionActor` 和 `TraderActor`:

+   `SchedulerActor`:请求价格数据,将其存入数据库,向`PredictionActor`发送价格消息,接收答案并将其传递给`TraderActor`。

+   `PredictionActor`:在接收到带有价格的消息后,使用最佳模型预测下一个价格(此模型需要在 `application.conf` 中选择;稍后我们会看到详细信息)。它将带有预测结果的消息传回 `SchedulerActor`,并使用 `model` 文件夹中的其他模型对历史数据进行预测,最后使用最新的价格来评估预测。这样的预测结果将存储在数据库中。

+   `TraderActor`:在接收到预测消息后,使用 `rules`(此时规则非常简单,*如果预测价格上涨则买入,**否则不操作*),它将决策写入日志。它还可以向一个 URL 发送 HTTP 请求来触发这个决策。

# Web 服务工作流

现在让我们更深入地了解代码是如何执行预测的。如前所述,每隔 60 秒,应用程序会触发从 Cryptocompare 获取数据,将价格存储到数据库中,并运行预测,保存回溯测试结果以验证预测质量。

在这一部分,我们将更深入地了解哪些 Scala 类在这个项目中扮演了重要角色以及它们是如何互相通信的。

# JobModule

当应用程序启动时,一切从 `JobModule` 开始。它配置了 `Scheduler` 的创建,`Scheduler` 根据 `application.conf` 中的设置向 `SchedulerActor` 发送消息:

```py
class JobModule extends AbstractModule with AkkaGuiceSupport {
    def configure(): Unit = {
        //configuring launch of price-fetching Actor
        bindActorSchedulerActor
        bind(classOf[Scheduler]).asEagerSingleton()
    }
}

要启用此模块,在 application.conf 中需要添加以下行:

play.modules.enabled += "modules.jobs.JobModule"

Scheduler

Schedulerapplication.conf 中获取频率常量,并使用 Actor 系统每隔 X 秒向 SchedulerActor 发送 update 消息(消息的内容不重要;SchedulerActor 对任何消息都有反应):

class Scheduler @Inject()
    (val system: ActorSystem, @Named("scheduler-actor") val schedulerActor: ActorRef, configuration:     Configuration)(implicit ec: ExecutionContext) {
    //constants.frequency is set in conf/application.conf file
    val frequency = configuration.getInt("constants.frequency").get
    var actor = system.scheduler.schedule(
    0.microseconds, //initial delay: whether execution starts immediately after app launch
    frequency.seconds, //every X seconds, specified above
    schedulerActor,
    "update")
}

SchedulerActor

相关的代码部分已经显示并解释过了。现在让我们看看如何获取价格数据:

def constructUrl(exchange: String): String =
{
 "https://min-api.cryptocompare.com/data/histominute?fsym=BTC&tsym=USD&limit=23&aggregate=1&e=" + exchange
 }

ConstructUrl 返回一个完全构造好的 URL,用于向 Cryptocompare API 发送请求。更多细节请参见与 API 相关的章节:

final val predictionActor = system.actorOf(Props(new PredictionActor(configuration, db)))
final val traderActor = system.actorOf(Props(new TraderActor(ws)))

创建 PredictionActorTraderActor 的实例:

override def receive: Receive = {

Receive 方法在 actor 特质中定义并且必须实现。当有人向这个 actor(在我们这个例子中是 Scheduler)传递消息时,它会被触发:

case _ =>
    val futureResponse=restClient.getPayload(constructUrl(exchange))

在前面的代码中,case _ => 表示我们对任何类型和内容的消息做出反应。首先,通过前面指定的 URL 异步调用 Cryptocompare API。这个过程借助 RestClient 完成,它返回一个带有响应 JSON 的 Future。在接收到响应后(在 futureResponse 的 complete 回调中),.json 被映射到自定义的 case 类 CryptoCompareResponse

case class CryptoCompareResponse(Response: String, Type: Int, Aggregated: Boolean, Data: List[OHLC],     FirstValueInArray: Boolean, TimeTo: Long,TimeFrom: Long)

这个 case 类类似于 POJOPlain Old Java Object),不需要编写构造函数和 getter/setter:

object CryptoCompareResponse {
 implicit val cryptoCompareResponseReads = Json.reads[CryptoCompareResponse]
            }

这个伴随对象用于将 JSON 映射到这个类中。CryptocompareResponse 对象存储了 API 的输出——OHLC 数据的列表、数据的时间范围以及其他与我们无关的内容。OHLC 类对应实际的价格数据:

case class OHLC(time: Long, open: Double, 
                high: Double, 
                low: Double, 
                close: Double, 
                volumefrom: Double, 
                volumeto: Double)

数据准备好后,通过调用 storePriceData(cryptoCompareResponse) 将价格存储到数据库中。首先,它使用 Anorm 的 BatchSQL 批量插入到 PRICE_STAGING 表中,然后根据时间戳去重后再插入到 PRICE 表中,因为我们收到的价格数据可能会有重叠:

val batch = BatchSql(
        """|INSERT INTO PRICE_STAGING(TIMESTAMP,EXCHANGE,PRICE_OPEN,PRICE_CLOSED,VOLUME_BTC,             
            VOLUME_USD)| VALUES({timestamp}, {exchange}, {priceOpen}, {priceClosed}, {volumeBTC},                   {volumeUSD})""".stripMargin,transformedPriceDta.head,transformedPriceDta.tail:_*)
val res: Array[Int] = batch.execute() // array of update count
val reInsert = SQL(
        """
          |INSERT INTO PRICE(TIMESTAMP, EXCHANGE, PRICE_OPEN, PRICE_CLOSED, VOLUME_BTC, VOLUME_USD)
          |SELECT  TIMESTAMP, EXCHANGE, PRICE_OPEN, PRICE_CLOSED, VOLUME_BTC, VOLUME_USD
          |FROM PRICE_STAGING AS s
          |WHERE NOT EXISTS (
          |SELECT *
          |FROM PRICE As t
          |WHERE t.TIMESTAMP = s.TIMESTAMP
          |)
        """.stripMargin).execute()
      Logger.debug("reinsert " + reInsert)

在存入数据库后,SchedulerActor将 OHLC 数据转换为(时间戳,增量)元组,其中增量是(closePrice-openPrice)。因此,格式适用于机器学习模型。转换后的数据作为消息传递给PredictionActor,并明确等待响应。这是通过使用?操作符实现的。我们向预测actor提出请求:

(predictionActor ? CryptoCompareDTOToPredictionModelTransformer.tranform(cryptoCompareResponse)).mapTo[CurrentDataWithShortTermPrediction].map {

它的响应被映射到CurrentDataWithShortTermPrediction类,并通过!操作符传递给TraderActor。与?不同,!操作符不要求响应:

predictedWithCurrent =>
traderActor ! predictedWithCurrent}

这基本上是SchedulerActor的操作流程。我们从 Cryptocompare API 读取数据,存储到数据库中,发送给PredictionActor并等待其响应。然后我们将其响应转发给TraderActor

现在让我们看看PredictionActor内部发生了什么:

PredictionActor和预测步骤

该 Scala web 应用程序每分钟从 Cryptocompare API 获取 Bitstamp 交易所最新的比特币价格数据,使用训练好的机器学习分类器预测下一分钟的价格变动方向,并通知用户决策结果。

现在,要启动它,可以从项目目录中使用sbt run(或者在需要时使用$ sudo sbt run)。现在让我们看看application.conf文件的内容:

# This is the main configuration file for the application.
# Secret key
# The secret key is used to secure cryptographics functions.
# If you deploy your application to several instances be sure to use the same key!
application.secret="%APPLICATION_SECRET%"
# The application languages
application.langs="en"
# Global object class
# Define the Global object class for this application.
# Default to Global in the root package.sb
# application.global=Global
# Router
# Define the Router object to use for this application.
# This router will be looked up first when the application is starting up,
# so make sure this is the entry point.
# Furthermore, it's assumed your route file is named properly.
# So for an application router like `my.application.Router`,
# you may need to define a router file `conf/my.application.routes`.
# Default to Routes in the root package (and conf/routes)
# application.router=my.application.Routes
# Database configuration
# You can declare as many datasources as you want.
# By convention, the default datasource is named `default`
rootDir = "<path>/Bitcoin_price_prediction/"
db.default.driver = org.h2.Driver
db.default.url = "jdbc:h2: "<path>/Bitcoin_price_prediction/DataBase"
db.default.user = user
db.default.password = ""
play.evolutions.db.default.autoApply = true
# Evolutions
# You can disable evolutions if needed
# evolutionplugin=disabled
# Logger
# You can also configure logback (http://logback.qos.ch/),
# by providing an application-logger.xml file in the conf directory.
# Root logger:
logger.root=ERROR
# Logger used by the framework:
logger.play=INFO
# Logger provided to your application:
logger.application=DEBUG
#Enable JobModule to run scheduler
play.modules.enabled += "modules.jobs.JobModule"
#Frequency in seconds to run job. Might make sense to put 30 seconds, for recent data
constants.frequency = 30
ml.model_version = "gbt_22_binary_classes_32660767.model"

现在你可以理解,有几个变量需要根据你的平台和选择进行配置/更改:

  • rootDir目录更改为你在TrainGBT中使用的目录:
rootDir = "<path>/ Bitcoin_price_prediction"
  • 指定数据库文件的名称:
db.default.url = "jdbc:h2: "<path>/Bitcoin_price_prediction/DataBase"
  • 指定用于实际预测的模型版本:
ml.model_version = "gbt_22_binary_classes_32660767.model"

请注意,具有此名称的文件夹必须位于rootDir内部。因此,在rootDir中创建一个名为models的文件夹,并将所有训练模型的文件夹复制到其中。

该类还实现了actor特性并重写了receive方法。最佳实践是,在伴随对象中定义actor可以接收的类型,从而为其他类建立接口:

object PredictionActor {
    def props = Props[PredictionActor]
    case class PriceData(timeFrom: Long,
                        timeTo: Long, 
                        priceDelta: (Long, Double)*)
        }

首先,PredictionActormodels文件夹加载模型列表并加载etalon模型:

val models: List[(Transformer, String)] =
            SubDirectoryRetriever.getListOfSubDirectories(modelFolder)
            .map(modelMap => (PipelineModel.load(modelMap("path")),modelMap("modelName")))
        .toList

首先,我们从models文件夹中提取子目录列表,并从每个子目录中加载训练好的PipeLine模型。以类似的方式加载etalon模型,但我们已经知道它的目录。以下是如何在receive方法中处理PriceData类型消息的方式:

override def receive: Receive = {
    case data: PriceData =>
        val priceData = shrinkData(data, 1, 22)
        val (predictedLabelForUnknownTimestamp, details) =             
            predictionService.predictPriceDeltaLabel(priceData,productionModel)

预测的标签(字符串)和分类详细信息会被记录,是否有可能看到每个类别的概率分布?如果actor收到其他类型的消息,则会显示错误并且不执行任何操作。然后,结果被发送回SchedulerActor,并存储在predictedWithCurrent变量中,如前面的代码所示:

sender() ! CurrentDataWithShortTermPrediction(predictedLabelForUnknownTimestamp, data)

sender是一个ActorRef引用,指向正在处理的消息的发送者对象,因此我们可以使用!运算符将消息返回给它。然后,对于我们最开始加载的每个模型,我们会预测 1 分钟前的数据(总共 23 行中的第 0-21 行),并获取我们所知道的最新一分钟的实际价格变化:

models.foreach { mlModel =>
    val (predictedLabel, details) =predictionService.predictPriceDeltaLabel(shrinkData(data, 0, 21),     mlModel._1)
    val actualDeltaPoint = data.priceDelta.toList(22)

对于每个模型,我们在数据库中存储以下信息:模型名称、每次测试预测的时间戳、模型预测的标签以及实际的价格变化(delta)。这些信息稍后会用于生成关于模型表现的报告:

storeShortTermBinaryPredictionIntoDB( mlModel._2, actualDeltaPoint._1,
predictedLabel, actualDeltaPoint._2)

TraderActor

TraderActor接收预测结果,并根据标签写入日志信息。它可以触发对指定端点的 HTTP 请求:

override def receive: Receive = {
    case data: CurrentDataWithShortTermPrediction =>
        Logger.debug("received short-term prediction" + data)
        data.prediction match {
            case "0" => notifySellShortTerm()
            case "1" => notifyHoldShortTerm()
    }

预测价格并评估模型

ShortTermPredictionServiceImpl是实际执行预测的类,使用给定的模型和数据。首先,它通过调用transformPriceData(priceData: PriceData)方法将PriceData转换为 Spark DataFrame,该 DataFrame 的模式与用于训练的数据模式相对应。接下来,调用model.transform(dataframe)方法;我们提取需要的变量,写入调试日志并返回给调用者:

override def predictPriceDeltaLabel(priceData: PriceData, mlModel: org.apache.spark.ml.Transformer): (String, Row) = {
        val df = transformPriceData(priceData)
        val prediction = mlModel.transform(df)
        val predictionData = prediction.select("probability", "prediction", "rawPrediction").head()
        (predictionData.get(1).asInstanceOf[Double].toInt.toString, predictionData)
        }

在运行时,应用程序会收集有关预测输出的数据:预测标签和实际的价格变化(delta)。这些信息用于构建根网页,显示如TPR真正率)、FPR假正率)、TNR真负率)和FNR假负率)等统计数据,这些内容在之前已经描述过。

这些统计数据是从SHORT_TERM_PREDICTION_BINARY表中动态计算得出的。基本上,通过使用CASE-WHEN构造,我们添加了新的列:TPR、FPR、TNR 和 FNR。它们的定义如下:

  • 如果预测标签为 1 且价格变化(delta)> 0,则 TPR 值为1,否则为0

  • 如果预测标签为 1 且价格变化(delta)<=0,则 FPR 值为1,否则为0

  • 如果预测标签为 0 且价格变化(delta)<= 0,则 TNR 值为1,否则为0

  • 如果预测标签为 0 且价格变化(delta)> 0,则 FNR 值为1,否则为0

然后,所有记录按照模型名称进行分组,TPR、FPR、TNR 和 FNR 进行求和,得到每个模型的总数。以下是负责此操作的 SQL 代码:

SELECT MODEL, SUM(TPR) as TPR, SUM(FPR) as FPR, SUM(TNR) as TNR, 
    SUM(FNR) as FNR, COUNT(*) as TOTAL FROM (SELECT *,
    case when PREDICTED_LABEL='1' and ACTUAL_PRICE_DELTA > 0
        then 1 else 0 end as TPR,
    case when PREDICTED_LABEL='1' and ACTUAL_PRICE_DELTA <=0
        then 1 else 0 end as FPR,
    case when PREDICTED_LABEL='0' and ACTUAL_PRICE_DELTA <=0
        then 1 else 0 end as TNR,
    case when PREDICTED_LABEL='0' and ACTUAL_PRICE_DELTA > 0
        then 1 else 0 end as FNR
FROM SHORT_TERM_PREDICTION_BINARY)
GROUP BY MODEL

使用 Scala Play 框架进行演示预测

现在我们已经看到了这个项目的所有步骤,是时候查看现场演示了。我们将把整个应用程序打包成一个 Scala Play 的 Web 应用程序。在查看演示之前,先让我们将项目启动并运行。然而,了解一些关于使用 Scala Play 的 RESTful 架构基础会非常有帮助。

为什么使用 RESTful 架构?

好吧,Play 的架构默认是 RESTful 的。其核心基于模型-视图-控制器(MVC)模式。每个入口点,配合 HTTP 动词,都会映射到一个控制器函数。控制器使得视图可以是网页、JSON、XML 或几乎任何其他形式。

Play 的无状态架构使得水平扩展成为可能,非常适合处理大量的请求,而不需要在它们之间共享资源(如会话)。它走在响应式编程(Reactive Programming)趋势的前沿,在这种编程模式下,服务器是事件驱动的,并通过并行处理来应对现代网站日益增长的需求。

在某些配置下,Play 启用完全异步和非阻塞的 I/O,贯穿整个应用程序。其目的是通过高效的线程管理和并行处理,在网络上实现更高的可扩展性,同时避免 JavaScript 解决方案常见的回调地狱问题。

AngularJS 是一个基于 JavaScript 的开源前端 Web 应用框架,主要由 Google 和一群个人及公司维护,旨在解决开发单页应用时遇到的诸多挑战。

现在问题是,为什么选择 AngularJS 呢?好吧,HTML 非常适合声明静态文档,但当我们尝试使用它来声明动态视图时,效果就不太理想。AngularJS 让您可以扩展 HTML 词汇以适应您的应用程序。最终的开发环境非常富有表现力、易于阅读,并且开发迅速。

另一个问题是,难道没有其他替代方案吗?好吧,其他框架通过抽象掉 HTML、CSS 和/或 JavaScript,或者通过提供一种命令式的方式来操作 DOM,解决了 HTML 的不足。但这些都没有解决根本问题,即 HTML 并未为动态视图设计。

最后,关于可扩展性如何呢?好吧,AngularJS 是一个用于构建最适合您应用程序开发框架的工具集。它是完全可扩展的,并且与其他库兼容。每个功能都可以被修改或替换,以适应您独特的开发工作流和功能需求。继续阅读,了解更多信息。

项目结构

包装后的 Scala Web ML 应用具有以下目录结构:

图 7:Scala ML Web 应用目录结构

在上述结构中,bitcoin_ml 文件夹包含所有后端和前端代码。models 文件夹包含所有训练好的模型。一个示例训练模型位于 gbt_22_binary_classes_32660767 文件夹中。最后,数据库文件和痕迹分别存放在 DataBase.mv.dbDataBase.trace.db 文件中。

那么我们来看看包含实际代码的 bitcoin_ml 文件夹的子文件夹结构:

图 8: bitcoin_ml 目录结构

在前面的图示中,conf 文件夹包含 Scala Web 应用配置文件 application.conf,该文件包含必要的配置(如已显示)。所有依赖项在 build.sbt 文件中定义,如下所示:

libraryDependencies ++= Seq(jdbc, evolutions,
 "com.typesafe.play" %% "anorm" % "2.5.1",
 cache, ws, specs2 % Test, ws)

unmanagedResourceDirectories in Test <+= baseDirectory(_ / "target/web/public/test")
resolvers += "scalaz-bintray" at "https://dl.bintray.com/scalaz/releases"

resolvers ++= Seq(
     "apache-snapshots" at "http://repository.apache.org/snapshots/")
    routesGenerator := InjectedRoutesGenerator
    val sparkVersion = "2.2.0"
    libraryDependencies += "org.apache.spark" %% "spark-mllib" % sparkVersion
    libraryDependencies += "org.apache.hadoop" % "hadoop-mapreduce-client-core" % "2.7.2"
    libraryDependencies += "org.apache.hadoop" % "hadoop-common" % "2.7.2"
    libraryDependencies += "commons-io" % "commons-io" % "2.4"
    libraryDependencies += "org.codehaus.janino" % "janino" % "3.0.7" //fixing     "java.lang.ClassNotFoundException: de.unkrig.jdisasm.Disassembler" exception

    libraryDependencies ++= Seq(
     "com.typesafe.slick" %% "slick" % "3.1.1",
     "org.slf4j" % "slf4j-nop" % "1.6.4"
)

坦白说,在最初写作时,我并没有想到将这个应用包装成 Scala Play Web 应用。因此,过程有些无序。然而,不必担心,想了解更多关于后端和前端的内容,请参考 第七章,使用 Q 学习和 Scala Play 框架进行期权交易

运行 Scala Play Web 应用

要运行应用程序,只需按照以下步骤操作:

  1. www.kaggle.com/mczielinski/bitcoin-historical-data 下载历史比特币数据。然后解压并提取 .csv 文件。

  2. 打开你喜欢的 IDE(例如,Eclipse/IntelliJ)并创建 Maven 或 SBT 项目。

  3. 运行 Preprocess.scala 脚本将历史数据转换为时间序列。此脚本应生成两个 .csv 文件(即 scala_test_x.csvscala_test_y.csv)。

  4. 然后使用之前生成的文件,训练 GradientBoostedTree 模型(使用 TrainGBT.scala 脚本)。

  5. 保存最好的(即交叉验证的)Pipeline 模型,该模型包含所有管道的步骤。

  6. 然后从 Packt 仓库或 GitHub 下载 Scala Play 应用程序和所有文件(即 Bitcoin_price_prediction),可在书中找到(见书中内容)。

  7. 接下来将训练好的模型复制到 Bitcoin_price_prediction/models/ 目录下。

  8. 然后:$ cd Bitcoin_price_prediction/bitcoin_ml/conf/ 并更新 application.conf 文件中的参数值,如前所示。

  9. 最后,使用 $ sudo sbt run 命令运行该项目。

启动应用后,使用 $ sudo sbt run,应用程序将从 models 文件夹读取所有模型,etalon 模型由 ml.model_version 指定。每 30 秒(在 application.conf 文件中指定为 constants.frequency = 30),从 Cryptocompare API 获取最新的价格数据。使用 etalon 模型进行预测,结果会通过控制台日志消息的形式展示给用户,并有可能触发指定端点的 HTTP 请求。

之后,models 文件夹中的所有模型都会用于基于前 22 分钟的数据进行预测,并使用最新的价格数据作为当前分钟的预测质量检查方式。每个模型所做的所有预测都会存储在一个数据库文件中。当用户访问 http://localhost:9000 时,系统会展示一个包含预测摘要的表格给用户:

  • 模型名称

  • TPR(实际上不是比率,在这种情况下只是原始计数)- 模型预测价格会增加的次数,以及这些预测中有多少次是正确的

  • FPR,模型预测价格会增加,但价格却下降或保持不变的次数

  • TNR,模型预测价格不增加并且正确的次数

  • FNR,即模型预测价格不升高时的错误次数

  • 模型进行预测的总次数

好的,接下来是,使用$ sudo sbt run(在终端中)启动应用程序:

图 9:基于历史价格和实时数据生成的模型样本信号

前面的图显示了我们基于历史价格和实时数据生成的模型样本信号。此外,我们还可以看到模型的原始预测。当你尝试通过浏览器访问http://localhost:9000时,你应该能看到这个(不过,随着时间推移,计数会增加):

图 10:使用 Scala Play2 框架的模型表现

在前面的图中,模型的表现并不令人满意,但我建议您使用最合适的超参数并进行更多次的训练,例如,训练 10,000 次。此外,在接下来的章节中,我尝试提供一些更深入的见解和改进指南。

最后,如果你计划在进行了一些扩展(如果有的话)后部署此应用程序,那么我建议你快速查看第七章中的最后一节,使用 Q-Learning 和 Scala Play 框架进行期权交易,在该节中,你将找到关于如何将应用程序部署为 Web 应用的服务器指南。

总结

本章实现了一个完整的机器学习流水线,从收集历史数据,到将其转换为适合测试假设的格式,训练机器学习模型,并在实时数据上运行预测,还可以评估多种不同的模型并选择最佳模型。

测试结果表明,与原始数据集类似,约 60 万个分钟(2.4 百万中的一部分)可以被分类为价格上涨(收盘价高于开盘价);该数据集可以视为不平衡数据集。尽管随机森林通常在不平衡数据集上表现良好,但 ROC 曲线下的面积为 0.74,仍然不是最佳值。由于我们需要减少假阳性(减少在触发购买时价格下跌的次数),我们可能需要考虑一种更加严格的模型来惩罚这种错误。

尽管分类器获得的结果不能用于盈利交易,但它为测试新方法提供了基础,可以相对快速地进行测试。在这里,列出了一些进一步发展的可能方向:

  • 实现一开始讨论的管道:将时间序列数据转换成几个簇,并为每个簇训练回归模型/分类器;然后将最近的数据分类到某个簇中,并使用为该簇训练的预测模型。根据定义,机器学习是从数据中推导模式,因此可能不会有一种适合比特币历史所有数据的模式;这就是为什么我们需要理解市场可能处于不同的阶段,而每个阶段有其独特的模式。

  • 比特币价格预测的一个主要挑战可能是,训练数据(历史数据)在随机划分为训练集和测试集时,可能与测试数据的分布不一致。由于 2013 年和 2016 年间价格模式发生了变化,它们可能属于完全不同的分布。这可能需要对数据进行人工检查,并制作一些信息图表。可能已经有人做过这项研究。

  • 其中一个主要的尝试方向是训练两个一对多分类器:一个用于预测价格上涨超过 20 美元,另一个用于预测价格下跌超过 20 美元;这样就可以相应地采取做多/做空的策略。

  • 也许,预测下一分钟的变化并不是我们需要的;我们更愿意预测平均价格。因为开盘价可能比上一分钟的收盘价高很多,而下一分钟的收盘价可能稍微低于开盘价,但仍高于当前价格,这样的交易就会是有利可图的。所以,如何精确标记数据也是一个悬而未决的问题。

  • 尝试使用不同的时间序列窗口大小(甚至 50 分钟可能适合),并使用 ARIMA 时间序列预测模型,因为它是最广泛使用的算法之一。然后,尝试预测价格变化,不是预测下一分钟的价格,而是预测接下来的 2-3 分钟的价格。此外,还可以尝试加入交易量。

  • 如果价格在接下来的三个分钟中的至少一个时间段内上涨了 20 美元,标记数据为价格上涨,这样我们就可以从交易中获利。

  • 目前,Scheduler与 Cryptocompare 的分钟数据不同步。这意味着我们可以在下一个分钟的任何时刻获取关于 12:00:00 - 12:00:59 的分钟间隔数据,或者是 12:01:00 到 12:01:59 的数据。在后一种情况下,进行交易是没有意义的,因为我们是基于已经过时的数据做出的预测。

  • 与其每分钟基于旧的数据进行预测来积累actor的预测结果,不如直接获取最大可用的 HistoMinute 数据(七天),然后使用用于历史数据的 Scala 脚本将其拆分成时间序列数据,并对七天的数据进行预测。将这个过程作为定时任务每天运行一次,这样可以减少数据库和PredictionActor的负载。

  • 与通常的数据集相比,在比特币中,历史数据的行是按日期升序排列的,这意味着:

    • 最新的数据可能与今天的价格更相关,而“少即是多”的方法也适用;采用数据的一个较小子集可能会带来更好的性能。

    • 数据的子抽样方法可能会影响结果(如将数据分为训练集和测试集)。

    • 最后,尝试使用 LSTM 网络以获得更好的预测准确性(可以参考第十章获得一些提示)。

对基因组序列中变异的理解有助于我们识别容易罹患常见疾病的个体,解决罕见疾病问题,并从一个更大的群体中找到相应的特定人群。尽管经典的机器学习技术可以帮助研究人员识别相关变量的群体(簇),但这些方法在处理像全人类基因组这样的大型和高维数据集时,其准确性和有效性会有所下降。另一方面,深度神经网络架构(深度学习的核心)能更好地利用大规模数据集来构建复杂的模型。

在下一章中,我们将看到如何在《千人基因组计划》的大规模基因组数据上应用 K-means 算法,旨在对人群规模上的基因型变异进行聚类。然后,我们将训练一个基于 H2O 的深度学习模型,用于预测地理民族。最后,将使用基于 Spark 的随机森林算法来提高预测准确性。

第四章:人口规模的聚类与民族预测

理解基因组序列的变异有助于我们识别易患常见疾病的人群、治愈罕见疾病,并从更大的群体中找到对应的目标群体。尽管经典的机器学习技术可以帮助研究人员识别相关变量的群体(即簇),但这些方法在处理如整个基因组这类大规模和高维度数据集时,准确性和有效性会下降。

另一方面,深度神经网络 (DNNs) 形成了深度学习 (DL)的核心,并提供了建模复杂、高层次数据抽象的算法。它们能够更好地利用大规模数据集来构建复杂的模型。

本章中,我们将应用 K-means 算法对来自 1000 基因组计划分析的大规模基因组数据进行聚类,旨在在人群规模上聚类基因型变异。最后,我们训练一个基于 H2O 的 DNN 模型和一个基于 Spark 的随机森林模型,用于预测地理民族。本章的主题是给我你的基因变异数据,我会告诉你你的民族

尽管如此,我们将配置 H2O,以便在接下来的章节中也可以使用相同的设置。简而言之,我们将在这个端到端项目中学习以下内容:

  • 人口规模的聚类与地理民族预测

  • 1000 基因组计划——一个深入的人的基因变异目录

  • 算法与工具

  • 使用 K-means 进行人口规模的聚类

  • 使用 H2O 进行民族预测

  • 使用随机森林进行民族预测

人口规模聚类与地理民族

下一代基因组测序 (NGS)减少了基因组测序的开销和时间,以前所未有的方式产生了大数据。相比之下,分析这些大规模数据在计算上非常昂贵,且逐渐成为关键瓶颈。随着 NGS 数据中样本数量和每个样本特征的增加,这对大规模并行数据处理提出了需求,从而对机器学习解决方案和生物信息学方法带来了前所未有的挑战。在医疗实践中使用基因组信息需要高效的分析方法来应对来自数千人及其数百万变异的数据。

其中一项最重要的任务是分析基因组特征,以便将个体归类为特定的民族群体,或分析核苷酸单倍型与疾病易感性的关系。来自 1000 基因组计划的数据作为分析全基因组单核苷酸多态性 (SNPs)的主要来源,旨在预测个体的祖先背景,涉及大陆和区域的起源。

基因变异的机器学习

研究表明,来自亚洲、欧洲、非洲和美洲的群体可以根据其基因组数据进行区分。然而,准确预测单倍群和起源大陆(即,地理、民族和语言)的难度更大。其他研究表明,Y 染色体谱系可以在地理上定位,为(地理)聚类人类基因型中的人类等位基因提供证据。

因此,个体的聚类与地理来源和祖先有关系。由于种族也依赖于祖先,聚类与更传统的种族概念之间也存在关联,但这种关联并不完全准确,因为基因变异是按照概率原则发生的。因此,它并不遵循在不同种族间的连续分布,而是呈现出交叉或重叠的现象。

因此,确定祖先,甚至是种族,可能对生物医学有一定的用处,但任何直接评估与疾病相关的基因变异,最终将提供更为准确和有益的信息。

各种基因组学项目提供的数据集,如癌症基因组图谱TCGA)、国际癌症基因组联盟ICGC)、1000 基因组计划以及个人基因组计划PGP),都包含了大规模数据。为了快速处理这些数据,已提出基于 ADAM 和 Spark 的解决方案,并且这些解决方案现在广泛应用于基因组数据分析研究。

Spark 形成了最有效的数据处理框架,并且提供了内存集群计算的基本构件,例如用于反复查询用户数据的功能。这使得 Spark 成为机器学习算法的优秀候选框架,其性能超过了基于 Hadoop 的 MapReduce 框架。通过使用来自 1000 基因组计划的基因变异数据集,我们将尝试回答以下问题:

  • 人类的基因变异在不同群体之间的地理分布是怎样的?

  • 我们能否利用个体的基因组信息,将其归类为特定的群体,或从其核苷酸单倍型中推导出疾病易感性?

  • 个体的基因组数据是否适合预测其地理来源(即,个体所属的群体)?

在本项目中,我们以一种可扩展且更高效的方式解决了前述问题。特别地,我们研究了如何应用 Spark 和 ADAM 进行大规模数据处理,使用 H2O 对整个群体进行 K-means 聚类以确定群体内外的组别,以及通过调节更多超参数来进行基于 MLP 的监督学习,以更准确地根据个体的基因组数据预测该个体所属的群体。现在不必担心;我们将在后续部分提供关于这些技术的工作细节。

然而,在开始之前,让我们简要了解一下 1000 基因组项目的数据集,以便为您提供一些关于为什么跨技术互操作如此重要的理由。

1000 基因组项目数据集描述

1000 基因组项目的数据是一个非常庞大的人类遗传变异目录。该项目旨在确定在研究的人群中频率超过 1%的遗传变异。数据已经公开,并通过公共数据仓库向全球科学家自由访问。此外,1000 基因组项目的数据广泛用于筛选在遗传性疾病个体的外显子数据中发现的变异,以及在癌症基因组项目中的应用。

变异调用格式VCF)中的基因型数据集提供了人类个体(即样本)及其遗传变异的数据,此外,还包括全球的等位基因频率,以及各超人群的等位基因频率。数据指明了每个样本所属的人群地区,这些信息用于我们方法中的预测类别。特定的染色体数据(以 VCF 格式呈现)可能包含其他信息,表明样本的超人群或所使用的测序平台。对于多等位基因变异,每个替代等位基因频率AF)以逗号分隔的列表形式呈现,如下所示:

1 15211 rs78601809 T G 100 PASS AC=3050;
 AF=0.609026;
 AN=5008;
 NS=2504;
 DP=32245;
 EAS_AF=0.504;
 AMR_AF=0.6772;
 AFR_AF=0.5371;
 EUR_AF=0.7316;
 SAS_AF=0.6401;
 AA=t|||;
 VT=SNP

等位基因频率AF)是通过等位基因计数AC)与等位基因总数AN)的商计算得出的,而 NS 则是具有数据的样本总数,而_AF表示特定区域的 AF 值。

1000 基因组计划始于 2008 年;该联盟由 400 多名生命科学家组成,第三阶段于 2014 年 9 月完成,共涵盖了来自 26 个人群(即种族背景)的2,504个个体。总共识别出了超过 8800 万个变异(8470 万个单核苷酸多态性SNPs)、360 万个短插入/缺失(indels)和 6 万个结构变异),这些变异被确定为高质量的单倍型。

简而言之,99.9%的变异是由 SNPs 和短插入/缺失(indels)组成的。较不重要的变异——包括 SNPs、indels、缺失、复杂的短替代以及其他结构变异类别——已被去除以进行质量控制。因此,第三阶段发布的数据保留了 8440 万个变异。

这 26 个人群中的每一个大约有 60 到 100 个来自欧洲、非洲、美洲(南美和北美)以及亚洲(南亚和东亚)的个体。这些人群样本根据其主要祖先的来源被分为超人群组:东亚人群(CHBJPTCHSCDX,和KHV)、欧洲人群(CEUTSIFINGBR,和IBS)、非洲人群(YRILWKGWDMSLESNASW,和ACB)、美洲人群(MXLPURCLM,和PEL)以及南亚人群(GIHPJLBEBSTU,和ITU)。具体内容请参考图 1

图 1:来自 1000 基因组项目发布版 3 的地理种族群体(来源 www.internationalgenome.org/

发布的数据集提供了来自 2,504 名健康成年人的数据(年龄 18 岁及以上,第三阶段项目);只有至少 70 碱基对 (bp) 的读取被使用,直到有更先进的解决方案可用为止。所有来自所有样本的基因组数据被整合,以便将所有变异归因于某个区域。然而,值得注意的是,特定的单倍型可能不会出现在某个特定区域的基因组中;也就是说,多样本方法允许将变异归因于个体的基因型,即使这些变异未被该样本的测序读取覆盖。

换句话说,提供的是重叠的读取数据,并且单一样本基因组未必已被整合。所有个体均使用以下两种技术进行测序:

  • 全基因组测序 (平均深度 = 7.4x,其中 x 表示在给定参考 bp 上,平均可能对齐的读取数量)

  • 靶向外显子组测序 (平均深度 = 65.7x)

此外,个体及其直系亲属(如成人后代)通过高密度 SNP 微阵列进行基因分型。每个基因型包含所有 23 对染色体,并且一个单独的面板文件记录了样本和种群信息。表 1 给出了 1000 基因组项目不同发布版的概览:

表 1 – 1000 基因组项目基因型数据集统计 (来源: www.internationalgenome.org/data

1000 基因组发布版 变异 个体 种群 文件格式
第 3 阶段 第 3 阶段 2,504 26 VCF
第 1 阶段 3,790 万 1,092 14 VCF
试点 1,480 万 179 4 VCF

计算五个超级种群组中的等位基因频率(AF):EAS=东亚EUR=欧洲AFR=非洲AMR=美洲SAS=南亚,这些频率来自等位基因数(AN,范围 = [0, 1])。

请参阅面板文件的详细信息:ftp://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/integrated_call_samples_v3.20130502.ALL.panel。

算法、工具和技术

来自 1000 基因组项目发布版 3 的大规模数据贡献了 820 GB 的数据。因此,使用 ADAM 和 Spark 来以可扩展的方式预处理和准备数据(即训练、测试和验证集),以供 MLP 和 K-means 模型使用。Sparkling water 用于在 H2O 和 Spark 之间转换数据。

然后,使用 K-means 聚类和多层感知机(MLP,使用 H2O)进行训练。对于聚类和分类分析,需要使用样本 ID、变异 ID 以及替代等位基因的计数,这些基因型信息来自每个样本,我们所使用的大多数变异为 SNP 和插入缺失(indels)。

现在,我们应该了解每个工具的最基本信息,如 ADAM、H2O 以及关于算法的一些背景信息,如 K-means、MLP 用于聚类和分类人口群体。

H2O 和 Sparkling water

H2O 是一个机器学习的人工智能平台。它提供了丰富的机器学习算法和一个基于网页的数据处理用户界面,既有开源版本,也有商业版本。使用 H2O,可以通过多种语言(如 Java、Scala、Python 和 R)开发机器学习和深度学习应用:

图 2:H2O 计算引擎及其可用功能(来源:https://h20.ai/)

它还具有与 Spark、HDFS、SQL 和 NoSQL 数据库接口的能力。简而言之,H2O 可在 Hadoop/Yarn、Spark 或笔记本电脑上与 R、Python 和 Scala 配合使用。另一方面,Sparkling water 将 H2O 的快速、可扩展机器学习算法与 Spark 的功能结合起来。它通过 Scala/R/Python 驱动计算,并利用 H2O flow 用户界面。简而言之,Sparkling water = H2O + Spark

在接下来的几章中,我们将探索 H2O 和 Sparkling water 的广泛丰富功能;然而,我认为提供一张涵盖所有功能领域的图表会更有帮助:

图 3:可用算法和支持的 ETL 技术概览(来源:https://h20.ai/)

这是从 H2O 网站整理出的功能和技术列表。它可以用于数据清洗、使用数据建模以及评分结果模型:

  • 流程

  • 模型

  • 评分工具

  • 数据概况

  • 广义线性模型GLM

  • 预测

  • 总结统计

  • 决策树

  • 混淆矩阵

  • 聚合、过滤、分箱和推导列

  • 梯度提升机GBM

  • AUC

  • 切片、对数变换和匿名化

  • K-means

  • 命中率

  • 变量创建

  • 异常检测

  • PCA/PCA 评分

  • 深度学习(DL)

  • 多模型评分

  • 训练和验证抽样计划

  • 朴素贝叶斯

  • 网格搜索

以下图表展示了如何清晰地描述 H2O Sparkling water 如何被用来扩展 Apache Spark 的功能。H2O 和 Spark 都是开源系统。Spark MLlib 包含大量功能,而 H2O 在此基础上扩展了许多额外功能,包括深度学习(DL)。它提供了数据转换、建模和评分的工具,正如我们在 Spark ML 中看到的那样。它还提供了一个基于网页的用户界面来进行交互:

图 4:Sparkling water 扩展 H2O,并与 Spark 互操作(来源:https://h20.ai/)

以下图表展示了 H2O 如何与 Spark 集成。正如我们所知,Spark 有主节点和工作节点;工作节点创建执行器来执行实际的工作。以下是运行基于 Sparkling water 的应用程序的步骤:

  • Spark 的提交命令将 Sparkling water JAR 发送到 Spark 主节点

  • Spark 主节点启动工作节点并分发 JAR 文件

  • Spark 工作节点启动 Executor JVM 以执行工作

  • Spark 执行器启动一个 H2O 实例

H2O 实例嵌入在 Executor JVM 中,因此它与 Spark 共享 JVM 堆内存。当所有 H2O 实例启动后,H2O 将形成一个集群,并提供 H2O flow 网页界面:

图 5:Sparkling Water 如何融入 Spark 架构(来源:http://blog.cloudera.com/blog/2015/10/how-to-build-a-machine-learning-app-using-sparkling-water-and-apache-spark/)

前面的图解释了 H2O 如何融入 Spark 架构以及如何启动,但数据共享又该如何处理呢?现在的问题是:数据如何在 Spark 和 H2O 之间传递?下面的图解释了这一点:

图 6:Spark 和 H2O 之间的数据传递机制

为了更清晰地查看前面的图,已经为 H2O 和 Sparkling Water 创建了一个新的 H2O RDD 数据结构。它是基于 H2O 框架顶部的一个层,每一列代表一个数据项,并独立压缩,以提供最佳的压缩比。

ADAM 用于大规模基因组数据处理

分析 DNA 和 RNA 测序数据需要大规模的数据处理,以根据其上下文解释数据。优秀的工具和解决方案已经在学术实验室中开发出来,但往往在可扩展性和互操作性方面存在不足。因此,ADAM 是一个基因组分析平台,采用了 Apache Avro、Apache Spark 和 Parquet 构建的专用文件格式。

然而,像 ADAM-Spark 这样的规模化数据处理解决方案可以直接应用于测序管道的输出数据,也就是说,在质量控制、比对、读段预处理和变异定量之后,使用单一样本数据进行处理。举几个例子,DNA 测序的 DNA 变异、RNA 测序的读数计数等。

更多内容见bdgenomics.org/以及相关的出版物:Massie, Matt 和 Nothaft, Frank 等人,《ADAM:用于云规模计算的基因组格式与处理模式》,UCB/EECS-2013-207,加利福尼亚大学伯克利分校电子工程与计算机科学系。

在我们的研究中,使用 ADAM 实现了支持 VCF 文件格式的可扩展基因组数据分析平台,从而将基于基因型的 RDD 转化为 Spark DataFrame。

无监督机器学习

无监督学习是一种机器学习算法,用于通过推理未标记数据集(即由没有标签的输入数据组成的训练集)来对相关数据对象进行分组并发现隐藏的模式。

让我们看一个现实生活中的例子。假设你在硬盘上有一个装满非盗版且完全合法的 MP3 文件的大文件夹。如果你能建立一个预测模型,帮助你自动将相似的歌曲分组并组织成你喜欢的类别,比如乡村、说唱和摇滚,如何?

这是一种将项目分配到组中的行为,以便 MP3 文件能够以无监督的方式添加到相应的播放列表中。对于分类,我们假设你提供了一个正确标记的数据训练集。不幸的是,当我们在现实世界中收集数据时,我们并不总是有这种奢侈的条件。

例如,假设我们想将大量的音乐分成有趣的播放列表。如果我们没有直接访问它们的元数据,我们如何可能将歌曲分组呢?一种可能的方法是混合使用各种机器学习技术,但聚类通常是解决方案的核心:

图 7:聚类数据样本一览

换句话说,无监督学习算法的主要目标是探索输入数据中未知/隐藏的模式,这些数据是没有标签的。然而,无监督学习也包含其他技术,以探索性方式解释数据的关键特征,寻找隐藏的模式。为了克服这一挑战,聚类技术被广泛应用于基于某些相似性度量,无监督地对未标记数据点进行分组。

人群基因组学与聚类

聚类分析是将数据样本或数据点划分并放入相应的同质类或聚类中。因此,聚类的简单定义可以被看作是将对象组织成一些成员在某种方式上相似的组的过程,如下所示。

这样,聚类就是将一些在某种程度上彼此相似的对象集合在一起,并与属于其他聚类的对象不相似的集合。如果给定的是遗传变异集合,聚类算法会基于相似性将这些对象放入一个组中——也就是人口组或超级人口组。

K 均值算法是如何工作的?

聚类算法,如 K 均值算法,定位数据点组的质心。然而,为了使聚类更加准确和有效,算法会评估每个点与聚类质心的距离。

最终,聚类的目标是确定一组未标记数据中的内在分组。例如,K 均值算法尝试将相关的数据点聚类到预定义的个(即k = 3)聚类中,如图 8所示:

图 8:典型聚类算法的结果及聚类中心的表示

在我们的案例中,结合使用 Spark、ADAM 和 H2O 的方法能够处理大量不同的数据点。假设我们有 n 个数据点(x[i],i=1, 2… n,举例来说,是遗传变异),这些数据点需要被分为k个簇。然后,K-means 将每个数据点分配给一个簇,目标是找到簇的位置μ[i]i=1...k,以最小化数据点到簇的距离。从数学上讲,K-means 试图通过解一个方程来实现这一目标——也就是一个优化问题:

在前面的方程中,c[i]是分配给簇i的数据点集合,d(x,μ[i])=∥x−μ[i]∥[2]²是需要计算的欧几里得距离。该算法通过最小化簇内平方和(即WCSS),计算数据点与 k 个簇中心之间的距离,其中c[i]是属于簇i的点的集合。

因此,我们可以理解,使用 K-means 进行的总体聚类操作并非一项简单的任务,而是一个 NP 难优化问题。这也意味着 K-means 算法不仅尝试找到全局最小值,而且经常陷入不同的解中。K-means 算法通过交替执行两个步骤进行:

  • 簇分配步骤:将每个观测值分配给使得均值产生最小WCSS的簇。平方和是平方欧几里得距离。

  • 质心更新步骤:计算新簇中观测值的均值作为质心。

简而言之,K-means 训练的整体方法可以用下图描述:

图 9:K-means 算法过程的总体方法

用于地理族群预测的 DNN

多层感知器MLP)是一个示例 DNN,它是一个前馈神经网络;即神经元之间只有不同层之间的连接。它有一个(通过)输入层,一个或多个线性阈值单元LTUs)(称为隐藏层),以及一个 LTU 的最终层(称为输出层)。

每一层(输出层除外)都涉及一个偏置神经元,并且与下一层完全连接,形成一个完全连接的二分图。信号只从输入流向输出,即单向(前馈)

直到最近,MLP 是通过反向传播训练算法进行训练的,但现在优化版本(即梯度下降)使用反向模式自动微分;即神经网络通过 SGD 和反向传播作为梯度计算技术进行训练。在 DNN 训练中,为了解决分类问题,使用了两层抽象:

  • 梯度计算:使用反向传播

  • 优化层级:使用 SGD、ADAM、RMSPro 和 Momentum 优化器来计算之前计算的梯度

在每个训练周期中,算法将数据输入到网络中,并计算每个神经元在连续层中的状态和输出。然后,算法衡量网络中的输出误差,即期望输出与当前输出之间的差距,并计算最后一个隐藏层中每个神经元对该误差的贡献。

逐步地,输出误差通过所有隐藏层反向传播到输入层,并在反向传播过程中计算所有连接权重的误差梯度:

图 10:由输入层、ReLU 和 softmax 组成的现代 MLP

对于多类别分类任务,输出层通常由一个共享的 softmax 函数决定(更多内容请参见图 2),与单独的激活函数不同,每个输出神经元提供对应类别的估计概率。

此外,我们将使用树集成方法,例如随机森林来进行分类。目前,我认为我们可以跳过随机森林的基础介绍,因为我们已经在第一章《分析保险赔付严重性》、第二章《分析和预测电信流失》和第三章《基于历史数据的高频比特币价格预测》中详细讲解过它。好了,现在是时候开始了。不过,在动手之前,确保你的编程环境已经准备好总是一个好习惯。

配置编程环境

在本节中,我们将介绍如何配置我们的编程环境,以便能够与 Spark、H2O 和 ADAM 互操作。请注意,在笔记本电脑或台式机上使用 H2O 会消耗大量资源。因此,请确保你的笔记本至少有 16GB 的 RAM 和足够的存储空间。

无论如何,我打算在 Eclipse 上将这个项目设置为 Maven 项目。不过,你也可以尝试在 SBT 中定义相同的依赖项。让我们在 pom.xml 文件中定义属性标签,以便构建一个适合 Maven 的项目:

<properties>
    <spark.version>2.2.1</spark.version>
    <scala.version>2.11.12</scala.version>
    <h2o.version>3.16.0.2</h2o.version>
    <sparklingwater.version>2.2.6</sparklingwater.version>
    <adam.version>0.23.0</adam.version>
</properties>

然后,我们可以使用 Spark 2.2.1 的最新版本(任何 2.x 版本甚至更高版本都应该能正常工作):

<dependency>
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-core_2.11</artifactId>
    <version>${spark.version}</version>
</dependency>

然后,我们需要声明 H2O 和 Sparkling Water 的依赖项,确保它们与属性标签中指定的版本匹配。较新版本可能也能正常工作,你可以尝试:

<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>sparkling-water-core_2.11</artifactId>
    <version>2.2.6</version>
</dependency>
<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>sparkling-water-examples_2.11</artifactId>
    <version>2.2.6</version>
</dependency>
<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>h2o-core</artifactId>
    <version>${h2o.version}</version>
</dependency>
<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>h2o-scala_2.11</artifactId>
    <version>${h2o.version}</version>
</dependency>
<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>h2o-algos</artifactId>
    <version>${h2o.version}</version>
</dependency>
<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>h2o-app</artifactId>
    <version>${h2o.version}</version>
</dependency>
<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>h2o-persist-hdfs</artifactId>
    <version>${h2o.version}</version>
</dependency>
<dependency>
    <groupId>ai.h2o</groupId>
    <artifactId>google-analytics-java</artifactId>
    <version>1.1.2-H2O-CUSTOM</version>
</dependency>

最后,让我们定义 ADAM 及其依赖项:

<dependency>
    <groupId>org.bdgenomics.adam</groupId>
    <artifactId>adam-core_2.11</artifactId>
    <version>0.23.0</version>
</dependency>

当我在 Windows 机器上尝试时,我还需要安装 joda-time 依赖项。让我们来做这件事(但根据你的平台,可能不需要):

<dependency>
    <groupId>joda-time</groupId>
    <artifactId>joda-time</artifactId>
    <version>2.9.9</version>
</dependency>

一旦你在 Eclipse 中创建了一个 Maven 项目(无论是通过 IDE 手动创建还是使用 $ mvn install),所有必需的依赖项将会被下载!我们现在可以开始编码了!

等等!如何在浏览器中查看 H2O 的 UI 界面?为此,我们必须手动下载 H2O 的 JAR 文件并将其作为常规的.jar文件运行。简而言之,这是一个三步过程:

  • www.h2o.ai/download/下载最新稳定版H[2]O。然后解压,它包含了开始所需的一切。

  • 从你的终端/命令提示符中,使用java -jar h2o.jar运行.jar文件。

  • 在浏览器中输入http://localhost:54321

图 11:H2O FLOW 的 UI 界面

这显示了最新版本(即截至 2018 年 1 月 19 日的 h2o-3.16.0.4 版)H2O 的可用功能。然而,我不打算在这里解释所有内容,所以让我们停止探索,因为我相信目前为止,这些关于 H2O 和 Sparkling Water 的知识已经足够。

数据预处理和特征工程

我已经说明所有的 24 个 VCF 文件贡献了 820 GB 的数据。因此,我决定只使用 Y 染色体的遗传变异来使演示更加清晰。其大小约为 160 MB,这样不会带来巨大的计算挑战。你可以从 ftp://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/下载所有的 VCF 文件以及面板文件。

让我们开始吧。我们从创建SparkSession开始,这是 Spark 应用程序的网关:

val spark:SparkSession = SparkSession
    .builder()
    .appName("PopStrat")
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "C:/Exp/")
    .getOrCreate()

然后让我们告诉 Spark VCF 文件和面板文件的路径:

val genotypeFile = "<path>/ALL.chrY.phase3_integrated_v2a.20130502.genotypes.vcf"
val panelFile = "<path>/integrated_call_samples_v3.20130502.ALL.panel "

我们使用 Spark 处理面板文件,以访问目标种群数据并识别种群组。我们首先创建一个我们想要预测的种群集合:

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、种群组、族群、超种群组以及性别,具体如下所示:

图 12:样本面板文件的内容

然后加载 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(唯一标识特定遗传变异);以及替代等位基因的计数(仅在样本与参考基因组不同的情况下)。

准备样本变异的签名如下所示;它接受sampleIDvariationIdalternateCount

case class SampleVariant(sampleId: String,
        variantId: Int,
        alternateCount: Int)

好的!让我们从genotype文件中找到variantIDvariantId是一个String类型,由名称、起始位置和染色体上的终止位置组成:

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,就应该寻找替代计数。在 genotype 文件中,那些没有等位基因参考的对象大致上是遗传变体:

def alternateCount(genotype: Genotype): Int = {
      genotype.getAlleles.asScala.count(_ != GenotypeAllele.REF)
   }

最后,我们构建一个简单的变体对象。为此,我们需要对样本 ID 进行内联,因为它们在 VCF 文件中会频繁出现:

def toVariant(genotype: Genotype): SampleVariant = {
 new SampleVariant(genotype.getSampleId.intern(),
            variantId(genotype).hashCode(),
            alternateCount(genotype))
        }

很棒!我们已经能够构建简单的变体了。现在,下一项具有挑战性的任务是准备 variantsRDD,然后我们才能创建 variantsBySampleId RDD:

val variantsRDD: RDD[SampleVariant] = genotypes.map(toVariant)

然后,我们必须按样本 ID 对变体进行分组,以便逐个样本处理变体。之后,我们可以获得将用于查找缺失变体的样本总数。最后,我们必须按变体 ID 对变体进行分组,并过滤掉那些在某些样本中缺失的变体:

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
    }

现在,让我们创建一个变体 ID → 替代计数大于零的样本计数的映射。然后我们过滤掉那些不在所需频率范围内的变体。这里的目标只是减少数据集中的维度数量,以便更容易训练模型:

val variantFrequencies: collection.Map[Int, Int] = variantsByVariantId
.map {
 case (variantId, sampleVariants) =>
        (variantId, sampleVariants.count(_.alternateCount > 0))
        }.collectAsMap()

在基于变体 ID 对样本进行分组并过滤掉没有支持的变体之前,样本(或个体)的总数已确定,以简化数据预处理并更好地应对大量变体(总计 8440 万个)。

图 13 显示了 1000 基因组项目中基因型变体集合的概念视图,并展示了从相同数据中提取特征,以训练我们的 K-means 和 MLP 模型的过程:

图 13:1000 基因组项目中基因型变体集合的概念视图

指定的范围是任意的,选择它是因为它包含了一个合理数量的变体,但不至于太多。具体来说,对于每个变体,都计算了替代等位基因的频率,排除了少于 12 个替代等位基因的变体,分析中留下了约 300 万个变体(来自 23 个染色体文件):

val permittedRange = inclusive(11, 11)
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构建一个包含区域和替代计数的 Row 类型 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)
        }

因此,我们只需使用第一个来构建训练数据框架的头部:

val header = StructType(
        Seq(StructField("Region", StringType)) ++
        sortedVariantsBySampleId
            .first()
            ._2
            .map(variant => {
                StructField(variant.variantId.toString, IntegerType)
        }))

干得好!到目前为止,我们已经得到了我们的 RDD 和 StructType 头信息。现在,我们可以通过最小的调整/转换来使用 H2O 和 Spark 的深度/机器学习算法。整个端到端项目的流程如下图所示:

图 14:整体方法的管道流程

模型训练与超参数调优

一旦我们有了rowRDD和表头,接下来的任务是使用表头和rowRDD构建 Schema DataFrame 的行:

val sqlContext = spark.sqlContext
val schemaDF = sqlContext.createDataFrame(rowRDD, header)
schemaDF.printSchema()
schemaDF.show(10)
>>>

图 15:包含特征和标签(即 Region)列的训练数据集快照

在前面的 DataFrame 中,仅显示了少数列,包括标签,以便适合页面。

基于 Spark 的 K-means 用于大规模人口聚类

在前面的章节中,我们已经了解了 K-means 的工作原理。所以我们可以直接进入实现部分。由于训练是无监督的,我们需要删除标签列(即Region):

val sqlContext = sparkSession.sqlContext
val schemaDF = sqlContext.createDataFrame(rowRDD, header).drop("Region")
schemaDF.printSchema()
schemaDF.show(10)
>>>

图 16:没有标签(即 Region)的 K-means 训练数据集快照

现在,我们已经在第一章,《分析保险严重性索赔》和第二章,《分析与预测电信流失》中看到,Spark 期望监督训练时有两列(即特征和标签),而无监督训练时它只期望包含特征的单列。由于我们删除了标签列,现在需要将整个变量列合并为一个features列。因此,我们将再次使用VectorAssembler()转换器。首先,我们选择要嵌入向量空间的列:

val featureCols = schemaDF.columns

然后,我们实例化VectorAssembler()转换器,指定输入列和输出列:

val assembler = 
new VectorAssembler()
    .setInputCols(featureCols)
    .setOutputCol("features")
val assembleDF = assembler.transform(schemaDF).select("features")

现在让我们看看它的效果:

assembleDF.show()
>>>

图 17:K-means 特征向量的快照

由于我们的数据集维度非常高,我们可以使用一些降维算法,如 PCA。因此,我们通过实例化一个PCA()转换器来实现,如下所示:

val pca = 
new PCA()
    .setInputCol("features")
    .setOutputCol("pcaFeatures")
    .setK(50)
    .fit(assembleDF)

然后我们转换组合后的 DataFrame(即已组合的)和前 50 个主成分。你可以调整这个数量。最后,为了避免歧义,我们将pcaFeatures列重命名为features

val pcaDF = pca.transform(assembleDF)
            .select("pcaFeatures")
            .withColumnRenamed("pcaFeatures", "features")
pcaDF.show()
>>>

图 18:前 50 个主成分的快照,作为最重要的特征

很棒!一切顺利。最后,我们准备好训练 K-means 算法了:

val kmeans = 
new KMeans().setK(5).setSeed(12345L)
val model = kmeans.fit(pcaDF)

所以我们通过计算组内平方误差和WSSSE)来评估聚类效果:

val WSSSE = model.computeCost(pcaDF)
println("Within-Cluster Sum of Squares for k = 5 is" + WSSSE)
>>>

确定最佳聚类数

像 K-means 这样的聚类算法的优点是,它们可以对具有无限特征数的数据进行聚类。当你拥有原始数据并且希望了解数据中的模式时,它们是非常有用的工具。然而,在进行实验之前决定聚类数可能并不成功,有时甚至会导致过拟合或欠拟合的问题。

另一方面,K-means、二分 K-means 和高斯混合这三种算法的一个共同点是,聚类数必须事先确定,并作为参数传递给算法。因此,非正式地说,确定聚类数是一个独立的优化问题,需要解决。

现在,我们将使用基于肘部法则的启发式方法。从 K=2 个聚类开始,然后通过增加 K 值运行 K-means 算法,观察成本函数 WCSS 的变化:

val iterations = 20
for (i <- 2 to iterations) {
 val kmeans = new KMeans().setK(i).setSeed(12345L)
 val model = kmeans.fit(pcaDF)
 val WSSSE = model.computeCost(pcaDF)
        println("Within-Cluster Sum of Squares for k = " + i + " is " +
                WSSSE)
    }

在某些时刻,可以观察到成本函数有一个大幅下降,但随着k值增加,改进变得非常微小。正如聚类分析文献中所建议的,我们可以选择在 WCSS 的最后一次大幅下降之后的k值作为最优值。现在,让我们来看一下在 2 到 20 之间的不同聚类数的 WCSS 值,例如:

Within-Cluster Sum of Squares for k = 2 is 453.161838161838
Within-Cluster Sum of Squares for k = 3 is 438.2392344497606
Within-Cluster Sum of Squares for k = 4 is 390.2278787878787
Within-Cluster Sum of Squares for k = 5 is 397.72112098427874
Within-Cluster Sum of Squares for k = 6 is 367.8890909090908
Within-Cluster Sum of Squares for k = 7 is 362.3360347662672
Within-Cluster Sum of Squares for k = 8 is 347.49306362861336
Within-Cluster Sum of Squares for k = 9 is 327.5002901103624
Within-Cluster Sum of Squares for k = 10 is 327.29376873556436
Within-Cluster Sum of Squares for k = 11 is 315.2954156954155
Within-Cluster Sum of Squares for k = 12 is 320.2478696814693
Within-Cluster Sum of Squares for k = 13 is 308.7674242424241
Within-Cluster Sum of Squares for k = 14 is 314.64784054938576
Within-Cluster Sum of Squares for k = 15 is 297.38523698523704
Within-Cluster Sum of Squares for k = 16 is 294.26114718614707
Within-Cluster Sum of Squares for k = 17 is 284.34890572390555
Within-Cluster Sum of Squares for k = 18 is 280.35662525879917
Within-Cluster Sum of Squares for k = 19 is 272.765762015762
Within-Cluster Sum of Squares for k = 20 is 272.05702362771336

现在让我们讨论如何利用肘部法则来确定聚类数。如下面所示,我们计算了成本函数 WCSS,作为 K-means 算法在选定人群组的 Y 染色体基因变异上的聚类数函数。

可以观察到,当k = 9时出现了一个相对较大的下降(尽管这并不是一个剧烈的下降)。因此,我们选择将聚类数设置为 10,如图 10所示:

图 19:聚类数与 WCSS 的关系

使用 H2O 进行种族预测

到目前为止,我们已经看到如何聚类基因变异。我们还使用了肘部法则,找到了最优k值和初步的聚类数。现在我们应该探索另一个我们一开始计划的任务——即种族预测。

在之前的 K-means 部分中,我们准备了一个名为schemaDF的 Spark 数据框。这个数据框不能直接用于 H2O。然而,我们需要进行额外的转换。我们使用asH2OFrame()方法将 Spark 数据框转换为 H2O 框架:

val dataFrame = h2oContext.asH2OFrame(schemaDF)

现在,有一件重要的事情你需要记住,当使用 H2O 时,如果没有将标签列转换为类别型,它会将分类任务视为回归任务。为了避免这种情况,我们可以使用 H2O 的toCategoricalVec()方法。由于 H2O 框架是弹性的,我们可以进一步更新相同的框架:

dataFrame.replace(dataFrame.find("Region"),
dataFrame.vec("Region").toCategoricalVec()).remove()
dataFrame.update()

现在我们的 H2O 框架已准备好训练基于 H2O 的深度学习模型(即 DNN,或者更具体地说,是深度 MLP)。然而,在开始训练之前,让我们使用 H2O 内置的FrameSplitter()方法随机将数据框拆分为 60%的训练集、20%的测试集和 20%的验证集:

val frameSplitter = new FrameSplitter(
        dataFrame, Array(.8, .1), Array("training", "test", "validation")
        .map(Key.make[Frame]),null)

water.H2O.submitTask(frameSplitter)
val splits = frameSplitter.getResult
val training = splits(0)
val test = splits(1)
val validation = splits(2)

太棒了!我们的训练集、测试集和验证集已经准备好,现在让我们为我们的深度学习模型设置参数:

// Set the parameters for our deep learning model.
val deepLearningParameters = new DeepLearningParameters()
        deepLearningParameters._train = training
        deepLearningParameters._valid = validation
        deepLearningParameters._response_column = "Region"
        deepLearningParameters._epochs = 200
        deepLearningParameters._l1 = 0.01
        deepLearningParameters._seed = 1234567
        deepLearningParameters._activation = Activation.RectifierWithDropout
        deepLearningParameters._hidden = ArrayInt

在前面的设置中,我们已经指定了一个具有三层隐藏层的 MLP,隐藏层神经元数分别为 128、256 和 512。因此,总共有五层,包括输入层和输出层。训练将迭代最多 200 次。由于隐藏层神经元数量较多,我们应该使用 dropout 来避免过拟合。为了获得更好的正则化效果,我们使用了 L1 正则化。

前面的设置还表明,我们将使用训练集来训练模型,同时使用验证集来验证训练结果。最后,响应列为Region。另一方面,种子用于确保结果的可复现性。

一切准备就绪!现在,让我们来训练深度学习模型:

val deepLearning = new DeepLearning(deepLearningParameters)
val deepLearningTrained = deepLearning.trainModel
val trainedModel = deepLearningTrained.get

根据你的硬件配置,这可能需要一段时间。因此,现在是时候休息一下,喝杯咖啡了!一旦我们得到训练好的模型,我们可以查看训练误差:

val error = trainedModel.classification_error()
println("Training Error: " + error)
>>>
Training Error: 0.5238095238095238

不幸的是,训练结果并不理想!不过,我们应该尝试不同的超参数组合。尽管误差较高,但让我们不要过于担心,先评估一下模型,计算一些模型指标,并评估模型质量:

val trainMetrics = ModelMetricsSupport.modelMetricsModelMetricsMultinomial
val met = trainMetrics.cm()

println("Accuracy: "+ met.accuracy())
println("MSE: "+ trainMetrics.mse)
println("RMSE: "+ trainMetrics.rmse)
println("R2: " + trainMetrics.r2)
>>>
Accuracy: 0.42105263157894735
MSE: 0.49369297490740655
RMSE: 0.7026328877211816
R2: 0.6091597281983032

准确率不高!不过,你应该尝试其他 VCF 文件,并调整超参数。例如,在减少隐藏层神经元数量、使用 L2 正则化和 100 轮训练后,我的准确率提高了大约 20%:

val deepLearningParameters = new DeepLearningParameters()
        deepLearningParameters._train = training
        deepLearningParameters._valid = validation
        deepLearningParameters._response_column = "Region"
        deepLearningParameters._epochs = 100
        deepLearningParameters._l2 = 0.01
        deepLearningParameters._seed = 1234567
        deepLearningParameters._activation = Activation.RectifierWithDropout
        deepLearningParameters._hidden = ArrayInt
>>>
Training Error: 0.47619047619047616
Accuracy: 0.5263157894736843
MSE: 0.39112548936806274
RMSE: 0.6254002633258662
R2: 0.690358987583617

另一个改进的线索在这里。除了这些超参数外,使用基于 H2O 的深度学习算法的另一个优势是我们可以获取相对的变量/特征重要性。在前面的章节中,我们已经看到,在 Spark 中使用随机森林算法时,也可以计算变量的重要性。

因此,基本思路是,如果模型表现不佳,值得删除一些不重要的特征并重新进行训练。现在,在有监督训练过程中,我们可以找到特征的重要性。我观察到以下特征重要性:

图 20:使用 H2O 计算的相对特征重要性

现在的问题是,为什么不尝试删除这些特征,然后重新训练并观察准确性是否提高?这个问题留给读者思考。

使用随机森林进行种族预测

在上一节中,我们已经看到了如何使用 H2O 进行种族预测。然而,我们未能取得更好的预测准确性。因此,H2O 目前还不够成熟,无法计算所有必要的性能指标。

那么,为什么不尝试基于 Spark 的树集成技术,比如随机森林或梯度提升树(GBT)呢?因为我们已经看到在大多数情况下,随机森林(RF)表现出更好的预测精度,所以我们就从这个开始试试。

在 K-means 部分,我们已经准备好了名为schemaDF的 Spark DataFrame。因此,我们可以简单地将变量转换成我们之前描述的特征向量。然而,为了实现这一点,我们需要排除标签列。我们可以使用drop()方法来做到这一点,如下所示:

val featureCols = schemaDF.columns.drop(1)
val assembler = 
new VectorAssembler()
    .setInputCols(featureCols)
    .setOutputCol("features")
val assembleDF = assembler.transform(schemaDF).select("features", "Region")
assembleDF.show()

在此阶段,您可以进一步降低维度,并使用 PCA 或任何其他特征选择算法提取最主要的成分。然而,我将把这个留给您决定。由于 Spark 期望标签列是数值型的,我们必须将民族名称转换为数值。我们可以使用StringIndexer()来完成这一操作。这是非常简单的:

val indexer = 
new StringIndexer()
    .setInputCol("Region")
    .setOutputCol("label")

val indexedDF =  indexer.fit(assembleDF)
                .transform(assembleDF)
                .select("features", "label") 

然后我们随机划分数据集进行训练和测试。在我们的例子中,假设我们使用 75%作为训练数据,其余作为测试数据:

val seed = 12345L
val splits = indexedDF.randomSplit(Array(0.75, 0.25), seed)
val (trainDF, testDF) = (splits(0), splits(1))

由于这是一个小数据集,考虑到这一点,我们可以缓存训练集和测试集,以便更快地访问:

trainDF.cache
testDF.cache
val rf = new RandomForestClassifier()
    .setLabelCol("label")
    .setFeaturesCol("features")
    .setSeed(1234567L)

现在让我们创建一个paramGrid,用于在决策树的maxDepth参数中进行搜索,以获得最佳模型:

val paramGrid =
new ParamGridBuilder()
    .addGrid(rf.maxDepth, 3 :: 5 :: 15 :: 20 :: 25 :: 30 :: Nil)
    .addGrid(rf.featureSubsetStrategy, "auto" :: "all" :: Nil)
    .addGrid(rf.impurity, "gini" :: "entropy" :: Nil)
    .addGrid(rf.maxBins, 3 :: 5 :: 10 :: 15 :: 25 :: 35 :: 45 :: Nil)
    .addGrid(rf.numTrees, 5 :: 10 :: 15 :: 20 :: 30 :: Nil)
    .build()

val evaluator = new MulticlassClassificationEvaluator()
    .setLabelCol("label")
    .setPredictionCol("prediction")

接着,我们设置 10 折交叉验证,以获得优化且稳定的模型。这将减少过拟合的可能性:

val numFolds = 10
val crossval = 
new CrossValidator()
    .setEstimator(rf)
    .setEvaluator(evaluator)
    .setEstimatorParamMaps(paramGrid)
    .setNumFolds(numFolds)

好的,现在我们准备进行训练了。那么让我们使用最佳超参数设置来训练随机森林模型:

val cvModel = crossval.fit(trainDF)

现在我们已经得到了交叉验证和最佳模型,为什么不使用测试集来评估模型呢?为什么不呢?首先,我们为每个实例计算预测 DataFrame。然后我们使用MulticlassClassificationEvaluator()来评估性能,因为这是一个多类分类问题。

此外,我们还计算了性能指标,如准确率精确率召回率F1值。请注意,使用 RF 分类器时,我们可以获得加权精确率加权召回率

val predictions = cvModel.transform(testDF)
predictions.show(10)
>>>

图 21:使用随机森林的原始预测概率、真实标签和预测标签

val metric = 
new MulticlassClassificationEvaluator()
    .setLabelCol("label")
    .setPredictionCol("prediction")

val evaluator1 = metric.setMetricName("accuracy")
val evaluator2 = metric.setMetricName("weightedPrecision")
val evaluator3 = metric.setMetricName("weightedRecall")
val evaluator4 = metric.setMetricName("f1")

现在让我们计算分类的准确率精确率召回率F1值以及测试数据上的错误率:

val accuracy = evaluator1.evaluate(predictions)
val precision = evaluator2.evaluate(predictions)
val recall = evaluator3.evaluate(predictions)
val f1 = evaluator4.evaluate(predictions)

最后,我们打印出性能指标:

println("Accuracy = " + accuracy);
println("Precision = " + precision)
println("Recall = " + recall)
println("F1 = " + f1)
println(s"Test Error = ${1 - accuracy}")
>>>
Accuracy = 0.7196470196470195
Precision = 0.7196470196470195
Recall = 0.7196470196470195
F1 = 0.7196470196470195
Test Error = 0.28035298035298046

是的,结果证明它的表现更好。这有点出乎意料,因为我们原本希望从深度学习模型中获得更好的预测准确性,但并没有得到。如我之前所说,我们仍然可以尝试使用 H2O 的其他参数。无论如何,现在我们看到使用随机森林大约提高了 25%。不过,可能仍然可以进一步改进。

总结

在本章中,我们展示了如何与一些大数据工具(如 Spark、H2O 和 ADAM)进行交互,以处理大规模基因组数据集。我们应用了基于 Spark 的 K-means 算法,分析了来自 1000 基因组计划的数据,旨在对人群规模的基因型变异进行聚类。

然后,我们应用基于 H2O 的深度学习(DL)算法和基于 Spark 的随机森林模型来预测地理族群。此外,我们还学习了如何安装和配置 H2O 进行深度学习。这些知识将在后续章节中使用。最后,也是最重要的,我们学会了如何使用 H2O 计算变量重要性,以便选择训练集中最重要的特征。

在下一章中,我们将看到如何有效地使用潜在狄利克雷分配LDA)算法来发现数据中的有用模式。我们将比较其他主题建模算法以及 LDA 的可扩展性。同时,我们还将利用自然语言处理NLP)库,如斯坦福 NLP。

第五章:主题建模 - 对大规模文本的深入理解

主题建模TM)是一种广泛用于从大量文档中挖掘文本的技术。通过这些主题,可以总结和组织包含主题词及其相对权重的文档。这个项目将使用的数据集是纯文本格式,未经过结构化处理。

我们将看到如何有效地使用潜在狄利克雷分配LDA)算法来发现数据中的有用模式。我们将比较其他 TM 算法及 LDA 的可扩展性。此外,我们还将利用自然语言处理NLP)库,如斯坦福 NLP。

简而言之,在这个端到端的项目中,我们将学习以下主题:

  • 主题建模与文本聚类

  • LDA 算法是如何工作的?

  • 使用 LDA、Spark MLlib 和标准 NLP 进行主题建模

  • 其他主题模型与 LDA 的可扩展性测试

  • 模型部署

主题建模与文本聚类

在 TM 中,主题是通过一组词汇来定义的,每个词汇在该主题下都有一个出现的概率,不同的主题有各自的词汇集合及其相应的概率。不同的主题可能共享一些词汇,而一个文档可能与多个主题相关联。简而言之,我们有一组文本数据集,即一组文本文件。现在,挑战在于使用 LDA 从数据中发现有用的模式。

有一种流行的基于 LDA 的 TM 方法,其中每个文档被视为多个主题的混合,每个文档中的词汇被认为是从文档的主题中随机抽取的。这些主题被视为隐藏的,必须通过分析联合分布来揭示,从而计算给定观察变量和文档中的词语的条件分布(主题)。TM 技术广泛应用于从大量文档中挖掘文本的任务。这些主题随后可以用来总结和组织包含主题词及其相对权重的文档(见图 1):

图 1:TM 概述(来源:Blei, D.M.等,概率主题模型,ACM 通信,55(4),77-84,2012)

如前图所示,主题的数量远小于与文档集合相关联的词汇量,因此主题空间的表示可以被看作是一种降维过程:

图 2:TM 与文本聚类的对比

与 TM 相比,在文档聚类中,基本思想是根据一种广为人知的相似性度量,将文档分组。为了进行分组,每个文档都由一个向量表示,该向量表示文档中词汇的权重。

通常使用词频-逆文档频率(也称为 TF-IDF 方案)来进行加权。聚类的最终结果是一个簇的列表,每个文档出现在某一个簇中。TM 和文本聚类的基本区别可以通过下图来说明:

LDA 算法是如何工作的?

LDA 是一个主题模型,用于从一组文本文档中推断主题。LDA 可以被看作是一个聚类算法,其中主题对应于聚类中心,文档对应于数据集中的实例(行)。主题和文档都存在于特征空间中,其中特征向量是词频向量(词袋)。LDA 不是通过传统的距离估计聚类,而是使用基于文本文档生成的统计模型的函数(见 图 3):

图 3:LDA 算法在一组文档上的工作原理

具体来说,我们希望讨论人们在大量文本中最常谈论的话题。自 Spark 1.3 发布以来,MLlib 支持 LDA,这是文本挖掘和自然语言处理领域中最成功使用的主题模型(TM)技术之一。

此外,LDA 还是第一个采用 Spark GraphX 的 MLlib 算法。以下术语是我们正式开始 TM 应用之前值得了解的:

  • "word" = "term":词汇表中的一个元素

  • "token":出现在文档中的术语实例

  • "topic":表示某一概念的词汇的多项式分布

在 Spark 中开发的基于 RDD 的 LDA 算法是一个为文本文档设计的主题模型。它基于原始的 LDA 论文(期刊版):Blei, Ng, 和 Jordan,Latent Dirichlet Allocation,JMLR,2003。

此实现通过 setOptimizer 函数支持不同的推理算法。EMLDAOptimizer 使用 期望最大化EM)在似然函数上进行聚类学习,并提供全面的结果,而 OnlineLDAOptimizer 使用迭代的小批量采样进行在线变分推理,并且通常对内存友好。

EM 是一种迭代方式,用于逼近最大似然函数。在实际应用中,当输入数据不完整、缺失数据点或存在隐藏潜在变量时,最大似然估计可以找到 最佳拟合 模型。

LDA 输入一组文档,作为词频向量,并使用以下参数(通过构建器模式设置):

  • K:主题的数量(即聚类中心的数量)(默认值是 10)。

  • ldaOptimizer:用于学习 LDA 模型的优化器,可以是 EMLDAOptimizerOnlineLDAOptimizer(默认是 EMLDAOptimizer)。

  • Seed:用于可重复性的随机种子(虽然是可选的)。

  • docConcentration:文档主题分布的 Dirichlet 参数。较大的值会鼓励推断出的分布更平滑(默认值是 Vectors.dense(-1))。

  • topicConcentration: Drichilet 参数,用于先验主题在术语(单词)分布上的分布。较大的值确保推断分布更加平滑(默认为 -1)。

  • maxIterations: 迭代次数的限制(默认为 20)。

  • checkpointInterval: 如果使用检查点(在 Spark 配置中设置),此参数指定创建检查点的频率。如果 maxIterations 很大,使用检查点可以帮助减少磁盘上的洗牌文件大小,并有助于故障恢复(默认为 10)。

图 4:主题分布及其外观

让我们看一个例子。假设篮子里有 n 个球,有 w 种不同的颜色。现在假设词汇表中的每个术语都有 w 种颜色之一。现在还假设词汇表中的术语分布在 m 个主题中。现在篮子中每种颜色出现的频率与对应术语在主题 φ 中的权重成比例。

然后,LDA 算法通过使每个球的大小与其对应术语的权重成比例来包含一个术语加权方案。在 图 4 中,n 个术语在一个主题中具有总权重,例如主题 0 到 3。图 4 展示了从随机生成的 Twitter 文本中的主题分布。

现在我们已经看到通过使用 TM,我们可以在非结构化的文档集合中找到结构。一旦 发现 结构,如 图 4 所示,我们可以回答几个问题,如下所示:

  • 文档 X 是关于什么的?

  • 文档 X 和 Y 有多相似?

  • 如果我对主题 Z 感兴趣,我应该先阅读哪些文档?

在接下来的部分中,我们将看到一个使用基于 Spark MLlib 的 LDA 算法的 TM 示例,以回答前面的问题。

使用 Spark MLlib 和 Stanford NLP 进行主题建模

在本小节中,我们使用 Spark 表示了一种半自动化的 TM 技术。在模型重用和部署阶段,我们将使用从 GitHub 下载的数据集训练 LDA,位于 github.com/minghui/Twitter-LDA/tree/master/data/Data4Model/test。然而,稍后在本章节中,我们将使用更知名的文本数据集。

实施

以下步骤展示了从数据读取到打印主题的 TM 过程,以及它们的术语权重。以下是 TM 管道的简短工作流程:

object topicmodelingwithLDA {
 def main(args: Array[String]): Unit = {
 val lda = 
 new LDAforTM() 
// actual computations are done here
 val defaultParams = Params().copy(input = "data/docs/") //Loading parameters for training
        lda.run(defaultParams) 
// Training the LDA model with the default parameters.
      }
}

我们还需要导入一些相关的包和库:

import edu.stanford.nlp.process.Morphology
import edu.stanford.nlp.simple.Document
import org.apache.log4j.{Level, Logger}
import scala.collection.JavaConversions._
import org.apache.spark.{SparkConf, SparkContext}
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.feature._
import org.apache.spark.ml.linalg.{Vector => MLVector}
import org.apache.spark.mllib.clustering.{DistributedLDAModel, EMLDAOptimizer, LDA, OnlineLDAOptimizer, LDAModel}
import org.apache.spark.mllib.linalg.{ Vector, Vectors }
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.{Row, SparkSession}

实际上,TM 的计算是在 LDAforTM 类中完成的。Params 是一个案例类,用于加载用于训练 LDA 模型的参数。最后,我们通过 Params 类设置的参数来训练 LDA 模型。现在我们将详细解释每个步骤,逐步源代码:

第一步 - 创建一个 Spark 会话

让我们通过以下方式创建一个 Spark 会话:定义计算核心数量、SQL 仓库和应用程序名称。

val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "C:/data/")
    .appName(s"LDA")
    .getOrCreate()

步骤 2 - 创建词汇表和标记计数以训练 LDA 模型,在文本预处理后进行

run() 方法接受 params,如输入文本、预定义的词汇表大小和停用词文件:

def run(params: Params)

然后,它将开始为 LDA 模型进行文本预处理,如下所示(即在 run 方法内部):

// Load documents, and prepare them for LDA.
val preprocessStart = System.nanoTime()
val (corpus, vocabArray, actualNumTokens) = preprocess(params.input, params.vocabSize, params.stopwordFile)  

Params case 类用于定义训练 LDA 模型的参数。代码如下所示:

//Setting the parameters before training the LDA model
case class Params(var input: String = "", var ldaModel: LDAModel = null,
    k: Int = 5,
    maxIterations: Int = 100,
    docConcentration: Double = 5,
    topicConcentration: Double = 5,
    vocabSize: Int = 2900000,
    stopwordFile: String = "data/docs/stopWords.txt",
    algorithm: String = "em",
    checkpointDir: Option[String] = None,
    checkpointInterval: Int = 100)

为了获得更好的结果,你需要通过反复试验来设置这些参数。或者,你可以选择交叉验证以获得更好的性能。如果你想检查当前的参数,可以使用以下代码:

if (params.checkpointDir.nonEmpty) {
    spark.sparkContext.setCheckpointDir(params.checkpointDir.get)
     }

preprocess 方法用于处理原始文本。首先,让我们使用 wholeTextFiles() 方法读取整个文本,如下所示:

val initialrdd = spark.sparkContext.wholeTextFiles(paths).map(_._2) 
initialrdd.cache()  

在前面的代码中,paths 是文本文件的路径。然后,我们需要根据 lemma 文本准备从原始文本中提取的形态学 RDD,如下所示:

val rdd = initialrdd.mapPartitions { partition =>
 val morphology = new Morphology()
    partition.map { value => helperForLDA.getLemmaText(value, morphology) }
}.map(helperForLDA.filterSpecialCharacters)

在这里,helperForLDA 类中的 getLemmaText() 方法提供了经过过滤特殊字符后的 lemma 文本,如 ("""[! @ # $ % ^ & * ( ) _ + - − , " ' ; : . ` ? --]),作为正则表达式,使用 filterSpecialCharacters() 方法。方法如下所示:

def getLemmaText(document: String, morphology: Morphology) = {
 val string = 
 new StringBuilder()
 val value = 
 new Document(document).sentences().toList.flatMap { 
        a =>
 val words = a.words().toList
 val tags = a.posTags().toList
        (words zip tags).toMap.map { 
        a =>
 val newWord = morphology.lemma(a._1, a._2)
 val addedWoed = 
 if (newWord.length > 3) {
        newWord
            }
 else { "" }
        string.append(addedWoed + " ")
        }
        }
    string.toString()
} 

需要注意的是,Morphology() 类通过移除仅有屈折变化的部分(而非派生形态学)来计算英语单词的基本形式。也就是说,它只处理名词复数、代词的格、动词词尾,而不涉及比较级形容词或派生名词等内容。getLemmaText() 方法接收文档及其对应的形态学信息,并最终返回已词干化的文本。

这个来自斯坦福 NLP 小组。要使用它,你需要在主类文件中添加以下导入:edu.stanford.nlp.process.Morphology。在 pom.xml 文件中,你需要包含以下条目作为依赖项:

<dependency>
    <groupId>edu.stanford.nlp</groupId>
    <artifactId>stanford-corenlp</artifactId>
    <version>3.6.0</version>
</dependency>
<dependency>
    <groupId>edu.stanford.nlp</groupId>
    <artifactId>stanford-corenlp</artifactId>
    <version>3.6.0</version>
    <classifier>models</classifier>
</dependency>

filterSpecialCharacters() 方法如下所示:

def filterSpecialCharacters(document: String) = document.replaceAll("""[! @ # $ % ^ & * ( ) _ + - − , " ' ; : . ` ? --]""", " ")

一旦我们得到了移除特殊字符的 RDD,就可以创建 DataFrame 来构建文本分析管道:

rdd.cache()
initialrdd.unpersist()
val df = rdd.toDF("docs")
df.show() 

DataFrame 只包含文档标签。DataFrame 的快照如下:

图 5:来自输入数据集的原始文本

现在,如果你仔细观察前面的 DataFrame,你会发现我们仍然需要对其进行分词。此外,DataFrame 中包含停用词,如 this、with 等,因此我们还需要将它们移除。首先,让我们使用 RegexTokenizer API 对其进行分词,如下所示:

val tokenizer = new RegexTokenizer()
                .setInputCol("docs")
                .setOutputCol("rawTokens")

现在让我们按照以下方式移除所有停用词:

val stopWordsRemover = new StopWordsRemover()
                        .setInputCol("rawTokens")
                        .setOutputCol("tokens")
stopWordsRemover.setStopWords(stopWordsRemover.getStopWords ++ customizedStopWords)

此外,我们还需要应用计数向量,以便从标记中找到仅重要的特征。这将有助于将管道阶段链接在一起。我们按照以下方式进行:

val countVectorizer = new CountVectorizer()
                    .setVocabSize(vocabSize)
                    .setInputCol("tokens")
                    .setOutputCol("features")

当先验词典不可用时,可以使用CountVectorizer作为估算器来提取词汇并生成CountVectorizerModel。换句话说,CountVectorizer用于将一组文本文档转换为标记(即术语)计数的向量。CountVectorizerModel为文档提供稀疏表示,这些文档会基于词汇表然后输入到 LDA 模型中。从技术上讲,当调用fit()方法进行拟合时,CountVectorizer会选择按术语频率排序的前vocabSize个词。

现在,通过链接转换器(tokenizer、stopWordsRemovercountVectorizer)来创建管道,代码如下:

val pipeline = new Pipeline().setStages(Array(tokenizer, stopWordsRemover, countVectorizer)) 

现在,让我们将管道拟合并转换为词汇表和标记数:

val model = pipeline.fit(df)
val documents = model.transform(df).select("features").rdd.map {
 case Row(features: MLVector) => Vectors.fromML(features)
    }.zipWithIndex().map(_.swap) 

最后,返回词汇和标记计数对,代码如下:

(documents, model.stages(2).asInstanceOf[CountVectorizerModel].vocabulary, documents.map(_._2.numActives).sum().toLong) Now let's see the statistics of the training data: 

println() println("Training corpus summary:") 
println("-------------------------------")
println("Training set size: " + actualCorpusSize + " documents")
println("Vocabulary size: " + actualVocabSize + " terms")
println("Number of tockens: " + actualNumTokens + " tokens")
println("Preprocessing time: " + preprocessElapsed + " sec")
println("-------------------------------")
println()
>>>
Training corpus summary:
-------------------------------
Training set size: 19 documents
Vocabulary size: 21611 terms
Number of tockens: 75784 tokens
Preprocessing time: 46.684682086 sec

第 3 步 - 在训练之前实例化 LDA 模型

在开始训练 LDA 模型之前,让我们实例化 LDA 模型,代码如下:

val lda = new LDA() 

第 4 步 - 设置 NLP 优化器

为了从 LDA 模型获得更好的优化结果,我们需要设置一个包含 LDA 算法的优化器,它执行实际的计算,并存储算法的内部数据结构(例如图形或矩阵)及其他参数。

在这里,我们使用了EMLDAOPtimizer优化器。你也可以使用OnlineLDAOptimizer()优化器。EMLDAOPtimizer存储一个数据+参数图形,以及算法参数。其底层实现使用 EM(期望最大化)。

首先,让我们通过添加(1.0 / actualCorpusSize)以及一个非常低的学习率(即 0.05)到MiniBatchFraction来实例化EMLDAOptimizer,以便在像我们这样的小数据集上收敛训练,代码如下:

val optimizer = params.algorithm.toLowerCase 
 match {
 case "em" => 
 new EMLDAOptimizer
// add (1.0 / actualCorpusSize) to MiniBatchFraction be more robust on tiny datasets.
 case "online" => 
 new OnlineLDAOptimizer().setMiniBatchFraction(0.05 + 1.0 / actualCorpusSize)
 case _ => 
 thrownew IllegalArgumentException("Only em, online are supported but got 
            ${params.algorithm}.")
    } 

现在,使用 LDA API 中的setOptimizer()方法设置优化器,代码如下:

lda.setOptimizer(optimizer)
    .setK(params.k)
    .setMaxIterations(params.maxIterations)
    .setDocConcentration(params.docConcentration)
    .setTopicConcentration(params.topicConcentration)
    .setCheckpointInterval(params.checkpointInterval)

第 5 步 - 训练 LDA 模型

让我们开始使用训练语料库训练 LDA 模型,并跟踪训练时间,代码如下:

val startTime = System.nanoTime()
ldaModel = lda.run(corpus)

val elapsed = (System.nanoTime() - startTime) / 1e9
println("Finished training LDA model. Summary:")
println("Training time: " + elapsed + " sec")

现在,此外,我们可以保存训练好的模型以供将来重用,保存的代码如下:

//Saving the model for future use
params.ldaModel.save(spark.sparkContext, "model/LDATrainedModel")

请注意,一旦完成训练并获得最优训练效果,部署模型之前需要取消注释前面的行。否则,它将在模型重用阶段因抛出异常而被停止。

对于我们拥有的文本,LDA 模型训练耗时 6.309715286 秒。请注意,这些时间代码是可选的。我们仅提供它们供参考,以便了解训练时间:

第 6 步 - 准备感兴趣的主题

准备前 5 个主题,每个主题包含 10 个术语。包括术语及其对应的权重:

val topicIndices = ldaModel.describeTopics(maxTermsPerTopic = 10)
println(topicIndices.length)
val topics = topicIndices.map {
 case (terms, termWeights) => terms.zip(termWeights).map {
 case (term, weight) => (vocabArray(term.toInt), weight) 
   }
}

第 7 步 - 主题建模

打印出前 10 个主题,展示每个主题的权重最高的词汇。同时,还包括每个主题的总权重,代码如下:

var sum = 0.0
println(s"${params.k} topics:")
topics.zipWithIndex.foreach {
 case (topic, i) =>
        println(s"TOPIC $i")
        println("------------------------------")
        topic.foreach {
 case (term, weight) =>
        term.replaceAll("\s", "")
        println(s"$termt$weight")
        sum = sum + weight
    }
println("----------------------------")
println("weight: " + sum)
println()

现在让我们看看 LDA 模型在主题建模方面的输出:

 5 topics:
 TOPIC 0
 ------------------------------
 come 0.0070183359426213635
 make 0.006893251344696077
 look 0.006629265338364568
 know 0.006592594912464674
 take 0.006074234442310174
 little 0.005876330712306203
 think 0.005153843469004155
 time 0.0050685675513282525
 hand 0.004524837827665401
 well 0.004224698942533204
 ----------------------------
 weight: 0.05805596048329406
 TOPIC 1
 ------------------------------
 thus 0.008447268016707914
 ring 0.00750959344769264
 fate 0.006802070476284118
 trojan 0.006310545607626158
 bear 0.006244268350438889
 heav 0.005479939900136969
 thro 0.005185211621694439
 shore 0.004618008184651363
 fight 0.004161178536600401
 turnus 0.003899151842042464
 ----------------------------
 weight: 0.11671319646716942
 TOPIC 2
 ------------------------------
 aladdin 7.077183389325728E-4
 sultan 6.774311890861097E-4
 magician 6.127791175835228E-4
 genie 6.06094509479989E-4
 vizier 6.051618911188781E-4
 princess 5.654756758514474E-4
 fatima 4.050749957608771E-4
 flatland 3.47788388834721E-4
 want 3.4263963705536023E-4
 spaceland 3.371784715458026E-4
 ----------------------------
 weight: 0.1219205386824187
 TOPIC 3
 ------------------------------
 aladdin 7.325869707607238E-4
 sultan 7.012354862373387E-4
 magician 6.343184784726607E-4
 genie 6.273921840260785E-4
 vizier 6.264266945018852E-4
 princess 5.849046214967484E-4
 fatima 4.193089052802858E-4
 flatland 3.601371993827707E-4
 want 3.5398019331108816E-4
 spaceland 3.491505202713831E-4
 ----------------------------
 weight: 0.12730997993615964
 TOPIC 4
 ------------------------------
 captain 0.02931475169407467
 fogg 0.02743105575940755
 nautilus 0.022748371008515483
 passepartout 0.01802140608022664
 nemo 0.016678258146358142
 conseil 0.012129894049747918
 phileas 0.010441664411654412
 canadian 0.006217638883315841
 vessel 0.00618937301246955
 land 0.00615311666365297
 ----------------------------
 weight: 0.28263550964558276

从上述输出中,我们可以看到输入文档的主题五占据了最大权重,权重为 0.28263550964558276。该主题涉及的词汇包括 captainfoggnemovesselland 等。

第 8 步 - 测量两个文档的似然度

现在为了获取一些其他统计数据,例如文档的最大似然或对数似然,我们可以使用以下代码:

if (ldaModel.isInstanceOf[DistributedLDAModel]) {
 val distLDAModel = ldaModel.asInstanceOf[DistributedLDAModel]
 val avgLogLikelihood = distLDAModel.logLikelihood / actualCorpusSize.toDouble
    println("The average log likelihood of the training data: " +
avgLogLikelihood)
    println()
}

上述代码计算了 LDA 模型的平均对数似然度,作为 LDA 分布式版本的一个实例:

The average log likelihood of the training data: -209692.79314860413

有关似然度测量的更多信息,感兴趣的读者可以参考en.wikipedia.org/wiki/Likelihood_function

现在假设我们已经计算了文档 X 和 Y 的前述度量。然后我们可以回答以下问题:

  • 文档 X 和 Y 有多相似?

关键在于,我们应该尝试从所有训练文档中获取最低的似然值,并将其作为前述比较的阈值。最后,回答第三个也是最后一个问题:

  • 如果我对主题 Z 感兴趣,应该先阅读哪些文档?

最小化的答案:通过仔细查看主题分布和相关词汇的权重,我们可以决定首先阅读哪个文档。

LDA 的可扩展性与其他主题模型的比较

在整个端到端的项目中,我们使用了 LDA,它是最流行的文本挖掘主题建模算法之一。我们还可以使用更强大的主题建模算法,如 概率潜在语义分析pLSA)、帕金科分配模型PAM)和 层次狄利克雷过程HDP)算法。

然而,pLSA 存在过拟合问题。另一方面,HDP 和 PAM 是更复杂的主题建模算法,用于处理复杂的文本挖掘任务,例如从高维文本数据或非结构化文本文档中挖掘主题。最后,非负矩阵分解是另一种在文档集合中寻找主题的方法。无论采用哪种方法,所有主题建模算法的输出都是一个包含相关词汇簇的主题列表。

上述示例展示了如何使用 LDA 算法作为独立应用程序来执行主题建模。LDA 的并行化并不简单,许多研究论文提出了不同的策略。关键障碍在于所有方法都涉及大量的通信。

根据 Databricks 网站上的博客(databricks.com/blog/2015/03/25/topic-modeling-with-lda-mllib-meets-graphx.html),以下是实验过程中使用的数据集以及相关的训练和测试集的统计数据:

  • 训练集大小:460 万份文档

  • 词汇表大小:110 万个词条

  • 训练集大小:11 亿个词条(约 239 个词/文档)

  • 100 个主题

  • 例如,16 个工作节点的 EC2 集群,可以选择 M4.large 或 M3.medium,具体取决于预算和需求

对于上述设置,经过 10 次迭代,平均时间结果为 176 秒/迭代。从这些统计数据可以看出,LDA 对于非常大的语料库也是非常具有可扩展性的。

部署训练好的 LDA 模型

对于这个小型部署,我们使用一个现实生活中的数据集:PubMed。包含 PubMed 术语的示例数据集可以从以下链接下载:nlp.stanford.edu/software/tmt/tmt-0.4/examples/pubmed-oa-subset.csv。这个链接实际上包含一个 CSV 格式的数据集,但其文件名为奇怪的4UK1UkTX.csv

更具体地说,该数据集包含了一些生物学文章的摘要、它们的出版年份以及序列号。以下图像给出了一些示例:

图 6:示例数据集的快照

在以下代码中,我们已经保存了训练好的 LDA 模型以备未来使用,如下所示:

params.ldaModel.save(spark.sparkContext, "model/LDATrainedModel")

训练好的模型将被保存到之前提到的位置。该目录将包含模型和训练本身的数据以及元数据,如下图所示:

图 7:训练和保存的 LDA 模型的目录结构

如预期的那样,数据文件夹中包含了一些 parquet 文件,这些文件包含全球主题、它们的计数、标记及其计数,以及主题与相应的计数。现在,接下来的任务是恢复相同的模型,如下所示:

//Restoring the model for reuse
val savedLDAModel = DistributedLDAModel.load(spark.sparkContext, "model/LDATrainedModel/")

//Then we execute the following workflow:
val lda = new LDAforTM() 
// actual computations are done here 

 // Loading the parameters to train the LDA model 
val defaultParams = Params().copy(input = "data/4UK1UkTX.csv", savedLDAModel)
lda.run(defaultParams) 
// Training the LDA model with the default parameters.
spark.stop()
>>>
 Training corpus summary:
 -------------------------------
 Training set size: 1 documents
 Vocabulary size: 14670 terms
 Number of tockens: 14670 tokens
 Preprocessing time: 12.921435786 sec
 -------------------------------
 Finished training LDA model.
 Summary:
 Training time: 23.243336895 sec
 The average log likelihood of the training data: -1008739.37857908
 5 topics:
 TOPIC 0
 ------------------------------
 rrb 0.015234818404037585
 lrb 0.015154125349208018
 sequence 0.008924621534990771
 gene 0.007391453509409655
 cell 0.007020265462594214
 protein 0.006479622004524878
 study 0.004954523307983932
 show 0.0040023453035193685
 site 0.0038006126784248945
 result 0.0036634344941610534
 ----------------------------
 weight: 0.07662582204885438
 TOPIC 1
 ------------------------------
 rrb 1.745030693927338E-4
 lrb 1.7450110447001028E-4
 sequence 1.7424254444446083E-4
 gene 1.7411236867642102E-4
 cell 1.7407234230511066E-4
 protein 1.7400587965300172E-4
 study 1.737407317498879E-4
 show 1.7347354627656383E-4
 site 1.7339989737227756E-4
 result 1.7334522348574853E-4
 ---------------------------
 weight: 0.07836521875668061
 TOPIC 2
 ------------------------------
 rrb 1.745030693927338E-4
 lrb 1.7450110447001028E-4
 sequence 1.7424254444446083E-4
 gene 1.7411236867642102E-4
 cell 1.7407234230511066E-4
 protein 1.7400587965300172E-4
 study 1.737407317498879E-4
 show 1.7347354627656383E-4
 site 1.7339989737227756E-4
 result 1.7334522348574853E-4
 ----------------------------
 weight: 0.08010461546450684
 TOPIC 3
 ------------------------------
 rrb 1.745030693927338E-4
 lrb 1.7450110447001028E-4
 sequence 1.7424254444446083E-4
 gene 1.7411236867642102E-4
 cell 1.7407234230511066E-4
 protein 1.7400587965300172E-4
 study 1.737407317498879E-4
 show 1.7347354627656383E-4
 site 1.7339989737227756E-4
 result 1.7334522348574853E-4
 ----------------------------
 weight: 0.08184401217233307
 TOPIC 4
 ------------------------------
 rrb 1.745030693927338E-4
 lrb 1.7450110447001028E-4
 sequence 1.7424254444446083E-4
 gene 1.7411236867642102E-4
 cell 1.7407234230511066E-4
 protein 1.7400587965300172E-4
 study 1.737407317498879E-4
 show 1.7347354627656383E-4
 site 1.7339989737227756E-4
 result 1.7334522348574853E-4
 ----------------------------
 weight: 0.0835834088801593

做得好!我们成功地重新使用了模型并进行了相同的预测。但由于数据的随机性,我们观察到略有不同的预测结果。让我们看看完整的代码以便更清晰地了解:

package com.packt.ScalaML.Topicmodeling
import org.apache.spark.sql.SparkSession
import org.apache.spark.mllib.clustering.{DistributedLDAModel, LDA}

object LDAModelReuse {
 def main(args: Array[String]): Unit = {
 val spark = SparkSession
                    .builder
                    .master("local[*]")
                    .config("spark.sql.warehouse.dir", "data/")
                    .appName(s"LDA_TopicModelling")
                    .getOrCreate()

//Restoring the model for reuse
 val savedLDAModel = DistributedLDAModel.load(spark.sparkContext, "model/LDATrainedModel/")
 val lda = new LDAforTM() 
// actual computations are done here
 val defaultParams = Params().copy(input = "data/4UK1UkTX.csv", savedLDAModel) 
//Loading params 
    lda.run(defaultParams) 
// Training the LDA model with the default parameters.
    spark.stop()
        }
    }

总结

在本章中,我们已经看到如何高效地使用和结合 LDA 算法与自然语言处理库,如斯坦福 NLP,从大规模文本中发现有用的模式。我们还进行了 TM 算法与 LDA 可扩展性之间的对比分析。

最后,对于一个现实的示例和用例,有兴趣的读者可以参考以下博客文章:blog.codecentric.de/en/2017/01/topic-modeling-codecentric-blog-articles/

Netflix 是一家美国娱乐公司,由 Reed Hastings 和 Marc Randolph 于 1997 年 8 月 29 日在加利福尼亚州斯科茨谷成立。它专门提供流媒体媒体和按需视频服务,通过在线和 DVD 邮寄的方式提供。在 2013 年,Netflix 扩展到电影和电视制作,以及在线分发。Netflix 为其订阅者使用基于模型的协同过滤方法进行实时电影推荐。

在下一章,我们将看到两个端到端的项目:一个基于项目的协同过滤电影相似度测量,以及一个基于模型的电影推荐引擎,利用 Spark 为新用户推荐电影。我们将看到如何在这两个可扩展的电影推荐引擎中实现ALS矩阵分解的互操作。

第六章:开发基于模型的电影推荐引擎

Netflix 是一家由 Reed Hastings 和 Marc Randolph 于 1997 年 8 月 29 日在加利福尼亚州 Scotts Valley 创立的美国娱乐公司。它专注于并提供流媒体、视频点播在线服务以及 DVD 邮寄服务。2013 年,Netflix 扩展到电影和电视制作及在线分发。Netflix 使用基于模型的协同过滤方法,为其订阅用户提供实时电影推荐。

本章中,我们将看到两个端到端的项目,并为电影相似度测量开发基于物品的协同过滤模型,以及使用 Spark 的基于模型的电影推荐引擎,后者能够为新用户推荐电影。我们将看到如何在 ALS 和矩阵分解MF)之间进行交互操作,以实现这两个可扩展的电影推荐引擎。我们将使用电影镜头数据集进行该项目。最后,我们将看到如何将最佳模型部署到生产环境中。

简而言之,我们将通过两个端到端的项目学习以下内容:

  • 推荐系统—如何以及为什么?

  • 基于物品的协同过滤用于电影相似度测量

  • 基于模型的电影推荐与 Spark

  • 模型部署

推荐系统

推荐系统(即推荐引擎RE)是信息过滤系统的一个子类,它帮助根据用户对某个项目的评分预测其评分偏好。近年来,推荐系统变得越来越流行。简而言之,推荐系统试图根据其他用户的历史记录预测某个用户可能感兴趣的潜在项目。

因此,它们被广泛应用于电影、音乐、新闻、书籍、研究文章、搜索查询、社交标签、产品、合作、喜剧、餐厅、时尚、金融服务、寿险和在线约会等多个领域。开发推荐引擎的方式有很多,通常会生成一系列推荐结果,例如基于协同过滤和基于内容的过滤,或者基于个性化的方式。

协同过滤方法

使用协同过滤方法,可以基于用户过去的行为来构建推荐引擎,其中会根据用户购买的物品给出数值评分。有时,它还可以基于其他用户做出的相似决策来开发,这些用户也购买了相同的物品。从下图中,你可以对不同的推荐系统有一些了解:

图 1:不同推荐系统的比较视图

基于协同过滤的方法通常会面临三个问题——冷启动、可扩展性和稀疏性:

  • 冷启动:当需要大量关于用户的数据来做出更准确的推荐时,有时会陷入困境。

  • 可扩展性:通常需要大量的计算能力来从拥有数百万用户和产品的数据集中计算推荐。

  • 稀疏性:当大量商品在主要电商网站上销售时,通常会发生这种情况,尤其是在众包数据集的情况下。在这种情况下,活跃用户可能只会对少数几件商品进行评分——也就是说,即使是最受欢迎的商品也会有很少的评分。因此,用户与商品的矩阵变得非常稀疏。换句话说,不能处理一个大规模的稀疏矩阵。

为了克服这些问题,一种特定类型的协同过滤算法使用 MF,一种低秩矩阵近似技术。我们将在本章后面看到一个例子。

基于内容的过滤方法

使用基于内容的过滤方法,利用项目的离散特征系列推荐具有相似属性的其他项目。有时它基于对项目的描述和用户偏好的个人资料。这些方法尝试推荐与用户过去喜欢或当前正在使用的项目相似的项目。

基于内容的过滤的一个关键问题是,系统是否能够从用户对某个内容源的行为中学习用户偏好,并将其应用于其他内容类型。当这种类型的推荐引擎被部署时,它可以用来预测用户感兴趣的项目或项目的评分。

混合推荐系统

如你所见,使用协同过滤和基于内容的过滤各有优缺点。因此,为了克服这两种方法的局限性,近年来的趋势表明,混合方法通过结合协同过滤和基于内容的过滤,可能更加有效和准确。有时,为了使其更强大,会使用如 MF 和奇异值分解SVD)等因式分解方法。混合方法可以通过几种方式实现:

  • 最初,基于内容的预测和基于协同的预测是分别计算的,之后我们将它们结合起来,即将这两者统一为一个模型。在这种方法中,FM 和 SVD 被广泛使用。

  • 向基于协同的方式添加基于内容的能力,或反之。再次,FM 和 SVD 被用来进行更好的预测。

Netflix 是一个很好的例子,它使用这种混合方法向订阅者推荐内容。该网站通过两种方式进行推荐:

  • 协同过滤:通过比较相似用户的观看和搜索习惯

  • 基于内容的过滤:通过提供与用户高度评分的电影共享特征的电影

基于模型的协同过滤

图 1所示,我确实计划使用因式分解机来实施一个系统化的项目,但最终由于时间限制未能实现。因此,决定开发一个基于协同过滤的方法的电影推荐系统。基于协同过滤的方法可以分为:

  • 基于记忆的算法,即基于用户的算法

  • 基于模型的协同过滤算法,即核映射

在基于模型的协同过滤技术中,用户和产品由一组较小的因素描述,这些因素也被称为潜在因素LFs)。然后使用这些潜在因素来预测缺失的条目。交替最小二乘法ALS)算法用于学习这些潜在因素。从计算的角度来看,基于模型的协同过滤通常用于许多公司,如 Netflix,用于实时电影推荐。

效用矩阵

在一个混合推荐系统中,有两类实体:用户和物品(例如电影、产品等)。现在,作为一个用户,你可能会对某些物品有偏好。因此,这些偏好必须从关于物品、用户或评分的数据中提取出来。通常这些数据表示为效用矩阵,例如用户-物品对。这种值可以表示已知的该用户对某个物品的偏好程度。矩阵中的条目,即一个表格,可以来自有序集合。例如,可以使用整数 1-5 来表示用户为物品评分的星级。

我们曾指出,通常用户可能没有对物品进行评分;也就是说,大多数条目是未知的。这也意味着矩阵可能是稀疏的。一个未知的评分意味着我们没有关于用户对物品偏好的明确反馈。表 1展示了一个效用矩阵示例。该矩阵表示用户对电影的评分,评分范围为 1 到 5,5 为最高评分。空白条目表示没有用户为这些电影提供评分。

在这里,HP1HP2HP3分别是电影哈利·波特 IIIIII的缩写;TW代表暮光之城SW1SW2SW3分别代表星球大战系列的第123部。用户由大写字母ABCD表示:

图 2:效用矩阵(用户与电影矩阵)

用户-电影对中有许多空白条目。这意味着用户没有为那些电影评分。在实际场景中,矩阵可能更加稀疏,典型的用户仅为所有可用电影中的一小部分评分。现在,利用这个矩阵,目标是预测效用矩阵中的空白部分。让我们来看一个例子。假设我们想知道用户A是否喜欢SW2。然而,由于矩阵中在表 1中几乎没有相关证据,确定这一点是非常困难的。

因此,在实际应用中,我们可能会开发一个电影推荐引擎来考虑电影的一些不常见属性,如制片人名称、导演名称、主演,甚至是它们名称的相似性。通过这种方式,我们可以计算电影SW1SW2的相似性。这种相似性会引导我们得出结论:由于 A 不喜欢SW1,他们也不太可能喜欢SW2

然而,这对于更大的数据集可能不适用。因此,随着数据量的增大,我们可能会观察到那些同时评分过SW1SW2的用户倾向于给予它们相似的评分。最终,我们可以得出结论:A也会像评分SW1那样对SW2给出低分。

基于 Spark 的电影推荐系统

Spark MLlib 中的实现支持基于模型的协同过滤。在基于模型的协同过滤技术中,用户和产品通过一组小的因子(也称为 LF)来描述。在本节中,我们将看到两个完整的示例,展示它如何为新用户推荐电影。

基于物品的协同过滤用于电影相似度计算

首先,我们从文件中读取评分数据。对于这个项目,我们可以使用来自www.grouplens.org/node/73的 MovieLens 100k 评分数据集。训练集评分数据保存在一个名为ua.base的文件中,而电影项数据保存在u.item中。另一方面,ua.test包含了用于评估我们模型的测试集。由于我们将使用这个数据集,因此我们应该感谢明尼苏达大学的 GroupLens 研究项目团队,他们编写了以下文字:

F. Maxwell Harper 和 Joseph A. Konstan. 2015. The MovieLens 数据集: 历史与背景。ACM 交互式智能系统交易(TiiS)5, 4, 第 19 号文章(2015 年 12 月),共 19 页。DOI:dx.doi.org/10.1145/2827872

该数据集包含了来自 943 名用户对 1682 部电影的 1 至 5 分的 100,000 条评分。每个用户至少评分过 20 部电影。数据集还包含了关于用户的基本人口统计信息(如年龄、性别、职业和邮政编码)。

第 1 步 - 导入必要的库并创建 Spark 会话

我们需要导入一个 Spark 会话,以便我们可以创建 Spark 会话,这是我们 Spark 应用程序的入口:

import org.apache.spark.sql.SparkSession 
val spark: SparkSession = SparkSession 
    .builder() 
    .appName("MovieSimilarityApp") 
    .master("local[*]") 
    .config("spark.sql.warehouse.dir", "E:/Exp/") 
    .getOrCreate() 

第 2 步 - 读取和解析数据集

我们可以使用 Spark 的textFile方法从你首选的存储系统(如 HDFS 或本地文件系统)读取文本文件。然而,我们需要自己指定如何分割字段。在读取输入数据集时,我们首先进行groupBy操作,并在与flatMap操作进行联接后进行转换,以获取所需字段:

val TRAIN_FILENAME = "data/ua.base" 
val TEST_FIELNAME = "data/ua.test" 
val MOVIES_FILENAME = "data/u.item" 

  // get movie names keyed on id 
val movies = spark.sparkContext.textFile(MOVIES_FILENAME) 
    .map(line => { 
      val fields = line.split("\|") 
      (fields(0).toInt, fields(1)) 
    }) 
val movieNames = movies.collectAsMap() 
  // extract (userid, movieid, rating) from ratings data 
val ratings = spark.sparkContext.textFile(TRAIN_FILENAME) 
    .map(line => { 
      val fields = line.split("t") 
      (fields(0).toInt, fields(1).toInt, fields(2).toInt) 
    }) 

第 3 步 - 计算相似度

通过基于物品的协同过滤,我们可以计算两部电影之间的相似度。我们按照以下步骤进行:

  1. 对于每一对电影(AB),我们找出所有同时评分过AB的用户。

  2. 现在,使用前述的评分,我们计算出电影A的向量,比如X,和电影B的向量,比如Y

  3. 然后我们计算XY之间的相关性

  4. 如果一个用户观看了电影C,我们可以推荐与其相关性最高的电影

然后我们计算每个评分向量XY的各种向量度量,如大小、点积、范数等。我们将使用这些度量来计算电影对之间的各种相似度度量,也就是(AB)。对于每对电影(AB),我们计算多个度量,如余弦相似度、Jaccard 相似度、相关性和常规相关性。让我们开始吧。前两步如下:

// get num raters per movie, keyed on movie id 
val numRatersPerMovie = ratings 
    .groupBy(tup => tup._2) 
    .map(grouped => (grouped._1, grouped._2.size)) 

// join ratings with num raters on movie id 
val ratingsWithSize = ratings 
    .groupBy(tup => tup._2) 
    .join(numRatersPerMovie) 
    .flatMap(joined => { 
      joined._2._1.map(f => (f._1, f._2, f._3, joined._2._2)) 
    }) 

ratingsWithSize变量现在包含以下字段:usermovieratingnumRaters。接下来的步骤是创建评分的虚拟副本以进行自连接。技术上,我们通过userid进行连接,并过滤电影对,以避免重复计数并排除自对:

val ratings2 = ratingsWithSize.keyBy(tup => tup._1) 
val ratingPairs = 
    ratingsWithSize 
      .keyBy(tup => tup._1) 
      .join(ratings2) 
      .filter(f => f._2._1._2 < f._2._2._2) 

现在让我们计算每对电影的相似度度量的原始输入:

val vectorCalcs = ratingPairs 
      .map(data => { 
        val key = (data._2._1._2, data._2._2._2) 
        val stats = 
          (data._2._1._3 * data._2._2._3, // rating 1 * rating 2 
            data._2._1._3, // rating movie 1 
            data._2._2._3, // rating movie 2 
            math.pow(data._2._1._3, 2), // square of rating movie 1 
            math.pow(data._2._2._3, 2), // square of rating movie 2 
            data._2._1._4, // number of raters movie 1 
            data._2._2._4) // number of raters movie 2 
        (key, stats) 
      }) 
.groupByKey() 
.map(data => { 
    val key = data._1 
    val vals = data._2 
    val size = vals.size 
    val dotProduct = vals.map(f => f._1).sum 
    val ratingSum = vals.map(f => f._2).sum 
    val rating2Sum = vals.map(f => f._3).sum 
    val ratingSq = vals.map(f => f._4).sum 
    val rating2Sq = vals.map(f => f._5).sum 
    val numRaters = vals.map(f => f._6).max 
    val numRaters2 = vals.map(f => f._7).max 
        (key, (size, dotProduct, ratingSum, rating2Sum, ratingSq, rating2Sq, numRaters, numRaters2))}) 

这是计算相似度的第三步和第四步。我们为每对电影计算相似度度量:

  val similarities = 
    vectorCalcs 
      .map(fields => { 
        val key = fields._1 
        val (size, dotProduct, ratingSum, rating2Sum, ratingNormSq, rating2NormSq, numRaters, numRaters2) = fields._2 
        val corr = correlation(size, dotProduct, ratingSum, rating2Sum, ratingNormSq, rating2NormSq) 
        val regCorr = regularizedCorrelation(size, dotProduct, ratingSum, rating2Sum,ratingNormSq, rating2NormSq, PRIOR_COUNT, PRIOR_CORRELATION) 
        val cosSim = cosineSimilarity(dotProduct, scala.math.sqrt(ratingNormSq), scala.math.sqrt(rating2NormSq)) 
        val jaccard = jaccardSimilarity(size, numRaters, numRaters2) 
        (key, (corr, regCorr, cosSim, jaccard))}) 

接下来是我们刚才使用的方法的实现。我们从correlation()方法开始,用来计算两个向量(AB)之间的相关性,公式为cov(A, B)/(stdDev(A) * stdDev(B))

def correlation(size: Double, dotProduct: Double, ratingSum: Double, 
    rating2Sum: Double, ratingNormSq: Double, rating2NormSq: Double) = { 
    val numerator = size * dotProduct - ratingSum * rating2Sum 
    val denominator = scala.math.sqrt(size * ratingNormSq - ratingSum * ratingSum)  
                        scala.math.sqrt(size * rating2NormSq - rating2Sum * rating2Sum) 
    numerator / denominator} 

现在,通过在先验上添加虚拟伪计数来对相关性进行常规化,RegularizedCorrelation = w * ActualCorrelation + (1 - w) * PriorCorrelation,其中 w = # actualPairs / (# actualPairs + # virtualPairs)

def regularizedCorrelation(size: Double, dotProduct: Double, ratingSum: Double, 
    rating2Sum: Double, ratingNormSq: Double, rating2NormSq: Double, 
    virtualCount: Double, priorCorrelation: Double) = { 
    val unregularizedCorrelation = correlation(size, dotProduct, ratingSum, rating2Sum, ratingNormSq, rating2NormSq) 
    val w = size / (size + virtualCount) 
    w * unregularizedCorrelation + (1 - w) * priorCorrelation 
  } 

两个向量 A,B 之间的余弦相似度为 dotProduct(A, B) / (norm(A) * norm(B)):

def cosineSimilarity(dotProduct: Double, ratingNorm: Double, rating2Norm: Double) = { 
    dotProduct / (ratingNorm * rating2Norm) 
  } 

最后,两个集合AB之间的 Jaccard 相似度为|Intersection (A, B)| / |Union (A, B)|

def jaccardSimilarity(usersInCommon: Double, totalUsers1: Double, totalUsers2: Double) = { 
    val union = totalUsers1 + totalUsers2 - usersInCommon 
    usersInCommon / union 
    } 

第 4 步 - 测试模型

让我们看看与Die Hard (1998)最相似的 10 部电影,按常规相关性排名:

evaluateModel("Die Hard (1988)") 
>>>

在前面的图表中,列包括电影 1,电影 2,相关性,常规相关性,余弦相似度和 Jaccard 相似度。现在,让我们看看与Postino, Il(1994)最相似的 10 部电影,按常规相关性排名:

evaluateModel("Postino, Il (1994)") 
>>>

最后,让我们看看与Star Wars (1977)最相似的 10 部电影,按常规相关性排名:

evaluateModel("Star Wars (1977)") 
>>>

现在,从输出结果中,我们可以看到一些电影对的共同评分者非常少;可以看出,使用原始的相关性计算得出的相似度并不理想。虽然余弦相似度是协同过滤方法中的标准相似度度量,但其表现不佳。

原因在于有许多电影的余弦相似度为 1.0。顺便提一下,前面的evaluateModel()方法会测试几部电影(用相关的电影名称替代 contains 调用),具体如下:

def evaluateModel(movieName: String): Unit = { 
    val sample = similarities.filter(m => { 
    val movies = m._1
    (movieNames(movies._1).contains(movieName)) 
    }) 
// collect results, excluding NaNs if applicable 
val result = sample.map(v => { 
val m1 = v._1._1 
val m2 = v._1._2 
val corr = v._2._1 
val rcorr = v._2._2 
val cos = v._2._3 
val j = v._2._4 
(movieNames(m1), movieNames(m2), corr, rcorr, cos, j) 
}).collect().filter(e => !(e._4 equals Double.NaN)) // test for NaNs must use equals rather than == 
      .sortBy(elem => elem._4).take(10) 
    // print the top 10 out 
result.foreach(r => println(r._1 + " | " + r._2 + " | " + r._3.formatted("%2.4f") + " | " + r._4.formatted("%2.4f") 
      + " | " + r._5.formatted("%2.4f") + " | " + r._6.formatted("%2.4f"))) } 

你可以理解基于这些协同过滤方法的局限性。当然,这些方法有计算复杂性,但你部分是对的。最重要的方面是,这些方法无法预测在实际应用中缺失的条目。它们还存在一些前面提到的问题,如冷启动、可扩展性和稀疏性。因此,我们将看看如何使用 Spark MLlib 中的基于模型的推荐系统来改进这些局限性。

基于模型的推荐(使用 Spark)

为了为任何用户做出偏好预测,协同过滤使用其他兴趣相似的用户的偏好,并预测你可能感兴趣但未知的电影。Spark MLlib 使用 交替最小二乘法ALS)来进行推荐。以下是 ALS 算法中使用的一种协同过滤方法的概览:

表 1 – 用户-电影矩阵

用户 M1 M2 M3 M4
U1 2 4 3 1
U2 0 0 4 4
U3 3 2 2 3
U4 2 ? 3 ?

在前面的表格中,用户对电影的评分表示为一个矩阵(即用户-物品矩阵),其中每个单元格表示一个用户对特定电影的评分。单元格中的 ? 代表用户 U4 不知道或没有看过的电影。根据 U4 当前的偏好,单元格中的 ? 可以通过与 U4 兴趣相似的用户的评分来填充。因此,ALS 本身无法完成此任务,但可以利用 LF 来预测缺失的条目。

Spark API 提供了 ALS 算法的实现,该算法用于基于以下六个参数学习这些 LF:

  • numBlocks: 这是用于并行计算的块数(设置为 -1 会自动配置)。

  • rank: 这是模型中 LF(潜在因子)的数量。

  • iterations: 这是 ALS 运行的迭代次数。ALS 通常在 20 次迭代或更少的次数内收敛到合理的解决方案。

  • lambda: 这是 ALS 中指定的正则化参数。

  • implicitPrefs: 这是指定是否使用 ALS 变体中的显式反馈(或用户定义的)来处理隐式反馈数据。

  • alpha: 这是 ALS 的隐式反馈变体中的一个参数,用于控制偏好观察的基准信心。

请注意,要构造一个使用默认参数的 ALS 实例,可以根据需要设置相应的值。默认值如下:numBlocks: -1rank: 10iterations: 10lambda: 0.01implicitPrefs: falsealpha: 1.0

数据探索

电影和相应的评分数据集是从 MovieLens 网站下载的(movielens.org)。根据 MovieLens 网站上的数据说明,所有评分都记录在 ratings.csv 文件中。该文件中的每一行(包括标题行)代表一个用户对某部电影的评分。

该 CSV 数据集包含以下列:userIdmovieIdratingtimestamp。这些在图 14中显示。行按照userId排序,并在每个用户内部按movieId排序。评分采用五分制,并有半星递增(从 0.5 星到 5.0 星)。时间戳表示自 1970 年 1 月 1 日午夜以来的秒数,时间格式为协调世界时UTC)。我们从 668 个用户那里收到了 105,339 个评分,涵盖 10,325 部电影:

图 2:评分数据集的快照

另一方面,电影信息包含在movies.csv文件中。每行(除去表头信息)代表一部电影,包含这些列:movieIdtitlegenres(见图 2)。电影标题要么是手动创建或插入的,要么是从电影数据库网站(www.themoviedb.org/)导入的。上映年份则以括号形式显示。

由于电影标题是手动插入的,因此这些标题可能存在一些错误或不一致的情况。因此,建议读者查阅 IMDb 数据库(www.imdb.com/),确保没有不一致或错误的标题和对应的上映年份:

图 3:前 20 部电影的标题和类型

类型以分隔列表的形式出现,并从以下类型类别中选择:

  • 动作、冒险、动画、儿童、喜剧和犯罪

  • 纪录片、剧情、幻想、黑色电影、恐怖和音乐剧

  • 神秘、浪漫、科幻、惊悚、西部和战争

使用 ALS 进行电影推荐

在本小节中,我们将通过一个系统的示例向您展示如何向其他用户推荐电影,从数据收集到电影推荐。

步骤 1 - 导入软件包,加载、解析并探索电影和评分数据集

我们将加载、解析并进行一些探索性分析。不过,在此之前,我们先导入必要的软件包和库:

package com.packt.ScalaML.MovieRecommendation 
import org.apache.spark.sql.SparkSession 
import org.apache.spark.mllib.recommendation.ALS 
import org.apache.spark.mllib.recommendation.MatrixFactorizationModel 
import org.apache.spark.mllib.recommendation.Rating 
import scala.Tuple2 
import org.apache.spark.rdd.RDD 

该代码段应返回评分的 DataFrame:

val ratigsFile = "data/ratings.csv"
val df1 = spark.read.format("com.databricks.spark.csv").option("header", true).load(ratigsFile)    
val ratingsDF = df1.select(df1.col("userId"), df1.col("movieId"), df1.col("rating"), df1.col("timestamp"))
ratingsDF.show(false)

以下代码段展示了电影的 DataFrame:

val moviesFile = "data/movies.csv"
val df2 = spark.read.format("com.databricks.spark.csv").option("header", "true").load(moviesFile)
val moviesDF = df2.select(df2.col("movieId"), df2.col("title"), df2.col("genres"))

步骤 2 - 注册两个 DataFrame 作为临时表,以便更方便地查询

要注册这两个数据集,我们可以使用以下代码:

ratingsDF.createOrReplaceTempView("ratings")
moviesDF.createOrReplaceTempView("movies")

这将通过在内存中创建一个临时视图作为表来加快内存查询的速度。使用createOrReplaceTempView()方法创建的临时表的生命周期与用于创建该 DataFrame 的[[SparkSession]]相关联。

步骤 3 - 探索和查询相关统计数据

让我们检查与评分相关的统计数据。只需使用以下代码行:

val numRatings = ratingsDF.count()
val numUsers = ratingsDF.select(ratingsDF.col("userId")).distinct().count()
val numMovies = ratingsDF.select(ratingsDF.col("movieId")).distinct().count() 
println("Got " + numRatings + " ratings from " + numUsers + " users on " + numMovies + " movies.") 
>>>
Got 105339 ratings from 668 users on 10325 movies.

你应该会发现668用户在10,325部电影上有105,339条评分。现在,让我们获取最大和最小评分以及评分过电影的用户数量。然而,你需要对我们在上一步骤中创建的评分表执行 SQL 查询。在这里进行查询很简单,类似于从 MySQL 数据库或关系型数据库管理系统(RDBMS)中进行查询。

然而,如果你不熟悉基于 SQL 的查询,建议查看 SQL 查询规范,了解如何使用SELECT从特定表中选择数据,如何使用ORDER进行排序,以及如何使用JOIN关键字进行连接操作。好吧,如果你熟悉 SQL 查询,你应该使用复杂的 SQL 查询来获取新的数据集,如下所示:

// Get the max, min ratings along with the count of users who have rated a movie.
val results = spark.sql("select movies.title, movierates.maxr, movierates.minr, movierates.cntu "
       + "from(SELECT ratings.movieId,max(ratings.rating) as maxr,"
       + "min(ratings.rating) as minr,count(distinct userId) as cntu "
       + "FROM ratings group by ratings.movieId) movierates "
       + "join movies on movierates.movieId=movies.movieId " + "order by movierates.cntu desc") 
results.show(false) 

输出:

图 4:最大和最小评分以及评分过电影的用户数量

为了获得一些洞察,我们需要更多了解用户及其评分。现在,让我们找出排名前 10 的活跃用户及其评分次数:

val mostActiveUsersSchemaRDD = spark.sql("SELECT ratings.userId, count(*) as ct from ratings "+ "group by ratings.userId order by ct desc limit 10")
mostActiveUsersSchemaRDD.show(false) 
>>> 

图 5:排名前 10 的活跃用户及其评分次数

让我们来看一下特定用户,并找出例如用户668评分高于4的电影:

val results2 = spark.sql( 
              "SELECT ratings.userId, ratings.movieId,"  
              + "ratings.rating, movies.title FROM ratings JOIN movies" 
              + "ON movies.movieId=ratings.movieId"  
              + "where ratings.userId=668 and ratings.rating > 4") 
results2.show(false) 
>>>

图 6:用户 668 评分高于 4 分的电影

步骤 4 - 准备训练和测试评分数据并检查计数

以下代码将评分 RDD 拆分为训练数据 RDD(75%)和测试数据 RDD(25%)。这里的种子是可选的,但为了可重复性,需要指定:

// Split ratings RDD into training RDD (75%) & test RDD (25%) 
val splits = ratingsDF.randomSplit(Array(0.75, 0.25), seed = 12345L) 
val (trainingData, testData) = (splits(0), splits(1)) 
val numTraining = trainingData.count() 
val numTest = testData.count() 
println("Training: " + numTraining + " test: " + numTest)

你应该注意到,训练数据中有 78,792 条评分,测试数据中有 26,547 条评分。

DataFrame。

步骤 5 - 准备数据以构建使用 ALS 的推荐模型

ALS 算法使用训练数据的评分 RDD。为此,以下代码展示了如何使用 API 构建推荐模型:

val ratingsRDD = trainingData.rdd.map(row => { 
                    val userId = row.getString(0) 
                    val movieId = row.getString(1) 
                    val ratings = row.getString(2) 
                    Rating(userId.toInt, movieId.toInt, ratings.toDouble)
})

ratingsRDD是一个包含userIdmovieId及相应评分的 RDD,来源于我们在上一步骤中准备的训练数据集。另一方面,也需要一个测试 RDD 来评估模型。以下testRDD也包含来自我们在上一步骤中准备的测试 DataFrame 的相同信息:

val testRDD = testData.rdd.map(row => { 
    val userId = row.getString(0) 
    val movieId = row.getString(1) 
    val ratings = row.getString(2) 
    Rating(userId.toInt, movieId.toInt, ratings.toDouble)
})

步骤 6 - 构建 ALS 用户-电影矩阵

基于ratingsRDD构建一个 ALS 用户矩阵模型,通过指定最大迭代次数、块数、alpha、rank、lambda、种子以及implicitPrefs来实现。基本上,这种技术根据其他用户对其他电影的相似评分来预测特定用户和特定电影的缺失评分:

val rank = 20 
val numIterations = 15 
val lambda = 0.10 
val alpha = 1.00 val block = -1 
val seed = 12345L 
val implicitPrefs = false 

val model = new ALS().setIterations(numIterations)
        .setBlocks(block).setAlpha(alpha)
        .setLambda(lambda)
        .setRank(rank) .setSeed(seed)
        .setImplicitPrefs(implicitPrefs)
        .run(ratingsRDD)

最后,我们迭代训练了模型 15 次。在这个设置下,我们得到了良好的预测准确性。建议读者进行超参数调优,以找到这些参数的最优值。此外,将用户块和产品块的块数设置为-1,以便并行化计算并自动配置块数。该值为-1。

步骤 7 - 进行预测

让我们为用户668获取前六部电影的预测。以下源代码可以用于进行预测:

// Making Predictions. Get the top 6 movie predictions for user 668 
println("Rating:(UserID, MovieID, Rating)") println("----------------------------------") 
val topRecsForUser = model.recommendProducts(668, 6) for (rating <- topRecsForUser) { println(rating.toString()) } println("----------------------------------")
>>>

图 7:用户 668 的前六部电影预测

步骤 8 - 评估模型

为了验证模型的质量,均方根误差RMSE)被用来衡量模型预测值与实际观测值之间的差异。默认情况下,计算的误差越小,模型越好。为了测试模型的质量,使用了测试数据(该数据在步骤 4中已拆分)。

根据许多机器学习从业者的说法,RMSE 是一个良好的准确度衡量标准,但仅适用于比较不同模型在特定变量上的预测误差。他们表示,RMSE 不适合用于比较不同变量之间的误差,因为它依赖于尺度。以下代码行计算了使用训练集训练的模型的 RMSE 值:

val rmseTest = computeRmse(model, testRDD, true) 
println("Test RMSE: = " + rmseTest) //Less is better

对于这个设置,我们得到以下输出:

Test RMSE: = 0.9019872589764073

该方法通过计算 RMSE 来评估模型。RMSE 越小,模型和预测能力越好。需要注意的是,computeRmse()是一个 UDF,其实现如下:

def computeRmse(model: MatrixFactorizationModel, data: RDD[Rating], implicitPrefs: Boolean): Double = {         val predictions: RDD[Rating] = model.predict(data.map(x => (x.user, x.product))) 
    val predictionsAndRatings = predictions.map { x => ((x.user, x.product), x.rating) }
        .join(data.map(x => ((x.user, x.product), x.rating))).values 
    if (implicitPrefs) { println("(Prediction, Rating)")                 
        println(predictionsAndRatings.take(5).mkString("n")) } 
        math.sqrt(predictionsAndRatings.map(x => (x._1 - x._2) * (x._1 - x._2)).mean()) 
    }
>>>

最后,让我们为特定用户提供一些电影推荐。让我们为用户668获取前六部电影的预测:

println("Recommendations: (MovieId => Rating)") 
println("----------------------------------") 
val recommendationsUser = model.recommendProducts(668, 6) 
recommendationsUser.map(rating => (rating.product, rating.rating)).foreach(println) println("----------------------------------")
>>>

我们相信,前一个模型的性能可以进一步提高。然而,迄今为止,基于 MLlib 的 ALS 算法没有我们所知的模型调优工具。

有兴趣的读者可以参考这个网址,了解更多关于调优基于 ML 的 ALS 模型的内容:spark.apache.org/docs/preview/ml-collaborative-filtering.html

选择并部署最佳模型

值得一提的是,第一个项目中开发的第一个模型无法持久化,因为它仅是计算电影相似性的几行代码。它还有另一个之前未提到的限制。它可以计算两部电影之间的相似度,但如果是多于两部电影呢?坦率地说,像第一个模型这样的模型很少会应用于真实的电影推荐。因此,我们将重点关注基于模型的推荐引擎。

尽管用户的评分会不断出现,但仍然值得存储当前的评分。因此,我们还希望持久化当前的基础模型,以便以后使用,从而在启动服务器时节省时间。我们的想法是使用当前模型进行实时电影推荐。

然而,如果我们持久化一些已生成的 RDD,尤其是那些处理时间较长的 RDD,可能也能节省时间。以下代码保存了我们训练好的 ALS 模型(具体细节请参见MovieRecommendation.scala脚本):

//Saving the model for future use 
val savedALSModel = model.save(spark.sparkContext, "model/MovieRecomModel")

与其他 Spark 模型不同,我们保存的 ALS 模型将仅包含训练过程中数据和一些元数据,采用 parquet 格式,具体如下图所示:

现在,下一个任务是恢复相同的模型,并提供与前面步骤中展示的类似的工作流:

val same_model = MatrixFactorizationModel.load(spark.sparkContext, "model/MovieRecomModel/")

不过我不会让你感到困惑,特别是如果你是 Spark 和 Scala 的新手的话。这是预测用户 558 评分的完整代码:

package com.packt.ScalaML.MovieRecommendation 

import org.apache.spark.sql.SparkSession 
import org.apache.spark.mllib.recommendation.ALS 
import org.apache.spark.mllib.recommendation.MatrixFactorizationModel 
import org.apache.spark.mllib.recommendation.Rating 
import scala.Tuple2 
import org.apache.spark.rdd.RDD 

object RecommendationModelReuse { 
 def main(args: Array[String]): Unit = { 
 val spark: SparkSession = SparkSession.builder()
                                  .appName("JavaLDAExample")
                                  .master("local[*]")
                                  .config("spark.sql.warehouse.dir", "E:/Exp/")
                                  .getOrCreate() 

 val ratigsFile = "data/ratings.csv" 
 val ratingDF =  spark.read
                        .format("com.databricks.spark.csv")
                        .option("header", true)
                        .load(ratigsFile) 

 val selectedRatingsDF = ratingDF.select(ratingDF.col("userId"), ratingDF.col("movieId"),                                                     ratingDF.col("rating"), ratingDF.col("timestamp")) 

        // Randomly split ratings RDD into training data RDD (75%) and test data RDD (25%) 
        val splits = selectedRatingsDF.randomSplit(Array(0.75, 0.25), seed = 12345L) 
        val testData = splits(1) 
        val testRDD = testData.rdd.map(row => { 
        val userId = row.getString(0) 
        val movieId = row.getString(1) 
        val ratings = row.getString(2) 
        Rating(userId.toInt, movieId.toInt, ratings.toDouble) }) 

        //Load the workflow back 
        val same_model = MatrixFactorizationModel.load(spark.sparkContext, "model/MovieRecomModel/") 

        // Making Predictions. Get the top 6 movie predictions for user 668 
        println("Rating:(UserID, MovieID, Rating)") 
        println("----------------------------------") 
        val topRecsForUser = same_model.recommendProducts(458, 10) 

        for (rating <- topRecsForUser) { 
            println(rating.toString()) } 

        println("----------------------------------") 
        val rmseTest = MovieRecommendation.computeRmse(same_model, testRDD, true) 
        println("Test RMSE: = " + rmseTest) //Less is better 

        //Movie recommendation for a specific user. Get the top 6 movie predictions for user 668 
        println("Recommendations: (MovieId => Rating)") 
        println("----------------------------------") 
        val recommendationsUser = same_model.recommendProducts(458, 10) 

        recommendationsUser.map(rating => 
        (rating.product, rating.rating)).foreach(println) 
        println("----------------------------------") 
        spark.stop() 
    } 
}

如果前面的脚本成功执行,您应该会看到以下输出:

做得好!我们成功地重用了模型,并为不同的用户(即 558)进行了相同的预测。然而,可能由于数据的随机性,我们观察到略微不同的 RMSE。

总结

在本章中,我们实现了两个端到端项目,分别开发了基于项目的协同过滤来进行电影相似度测量和基于模型的推荐,均使用 Spark 完成。我们还展示了如何在 ALS 和 MF 之间进行互操作,并开发可扩展的电影推荐引擎。最后,我们看到了如何将此模型部署到生产环境中。

作为人类,我们通过过去的经验学习。我们之所以变得如此迷人,并非偶然。多年的正面赞美和批评都帮助我们塑造了今天的自我。你通过与朋友、家人,甚至陌生人互动,学习如何让别人开心;你通过尝试不同的肌肉运动,直到自行车骑行技巧自然流畅,来学会骑车。当你执行某些动作时,有时会立即获得奖励。这一切都是关于强化学习RL)。

下一章将讨论如何设计一个由反馈和奖励驱动的机器学习项目。我们将看到如何应用强化学习(RL)算法,利用现实中的 IBM 股票和期权价格数据集开发期权交易应用。

第七章:使用 Q 学习和 Scala Play 框架进行期权交易

作为人类,我们通过经验学习。我们并不是偶然变得如此迷人。多年的正面夸奖和负面批评,塑造了今天的我们。我们通过尝试不同的肌肉动作来学习骑自行车,直到掌握为止。当你执行某些动作时,往往会立即获得奖励。这就是强化学习RL)的核心。

本章将专注于设计一个由批评和奖励驱动的机器学习系统。我们将展示如何将强化学习算法应用于实际数据集的预测模型中。

从交易的角度来看,期权是一种合约,赋予持有者在固定价格(行权价)下,在固定日期(到期日)或之前买入(看涨期权)或卖出(看跌期权)金融资产(标的资产)的权利。

我们将展示如何利用强化学习算法(称为QLearning)为期权交易开发一个实际应用。更具体地说,我们将解决计算期权交易中最佳策略的问题,并希望在某些市场条件和交易数据下进行特定类型的期权交易。

我们将使用 IBM 股票数据集来设计一个由批评和奖励驱动的机器学习系统。我们将从强化学习及其理论背景开始,以便更容易理解这个概念。最后,我们将通过使用 Scala Play 框架将整个应用程序封装为一个 Web 应用。

简言之,在这个从头到尾的项目中,我们将学习以下内容:

  • 使用 Q 学习——一种强化学习算法

  • 期权交易——它到底是怎么回事?

  • 技术概述

  • 为期权交易实现 Q 学习

  • 使用 Scala Play 框架将应用程序封装为 Web 应用

  • 模型部署

强化学习与监督学习和无监督学习的比较

虽然监督学习和无监督学习处于光谱的两端,但强化学习位于其中间。它不是监督学习,因为训练数据来自算法在探索和利用之间的选择。此外,它也不是无监督学习,因为算法会从环境中获得反馈。只要你处于一个执行某个动作能带来奖励的状态,你就可以使用强化学习来发现一个能够获得最大预期奖励的良好动作序列。

强化学习(RL)智能体的目标是最大化最终获得的总奖励。第三个主要子元素是价值函数。奖励决定了状态的即时吸引力,而价值则表示状态的长期吸引力,考虑到可能跟随的状态以及这些状态中的可用奖励。价值函数是根据所选择的策略来定义的。在学习阶段,智能体尝试那些能确定最高价值状态的动作,因为这些动作最终将获得最佳的奖励数量。

使用强化学习(RL)

图 1 显示了一个人做出决策以到达目的地。此外,假设你从家到公司时,总是选择相同的路线。然而,有一天你的好奇心占了上风,你决定尝试一条不同的道路,希望能更快到达。这个尝试新路线与坚持最熟悉路线的困境,就是探索与利用之间的例子:

图 1:一个代理始终尝试通过特定路线到达目的地

强化学习技术正在许多领域中被应用。目前正在追求的一个普遍理念是创建一个算法,它只需要任务的描述而不需要其他任何东西。当这种性能实现时,它将被几乎应用于所有领域。

强化学习中的符号、策略和效用

你可能会注意到,强化学习的术语涉及将算法化身为在情境中采取动作以获得奖励。事实上,算法通常被称为与环境互动的代理。你可以将它看作一个智能硬件代理,使用传感器感知环境,并利用执行器与环境互动。因此,强化学习理论在机器人学中的广泛应用并不令人惊讶。现在,为了进一步展开讨论,我们需要了解一些术语:

  • 环境:环境是任何拥有状态以及在不同状态之间转换机制的系统。例如,一个机器人的环境就是它所操作的景观或设施。

  • 代理:代理是一个与环境互动的自动化系统。

  • 状态:环境或系统的状态是完全描述环境的变量或特征的集合。

  • 目标:目标是一个状态,它提供比任何其他状态更高的折扣累计奖励。高累计奖励能够防止最佳策略在训练过程中依赖于初始状态。

  • 动作:动作定义了状态之间的转变,代理负责执行或至少推荐某个动作。在执行动作后,代理会从环境中收获奖励(或惩罚)。

  • 策略:策略定义了在环境的任何状态下需要执行的动作。

  • 奖励:奖励量化了代理与环境之间的正向或负向互动。奖励本质上是学习引擎的训练集。

  • 回合 (*也称为 试验):这定义了从初始状态到达目标状态所需的步骤数量。

我们将在本节稍后讨论更多关于策略和效用的内容。图 2 展示了状态动作奖励之间的相互作用。如果你从状态 s[1] 开始,你可以执行动作 a[1] 来获得奖励 r (s[1], a[1])。箭头代表动作状态由圆圈表示:

图 2:代理在一个状态下执行一个动作会产生回报

机器人执行动作以在不同状态之间变化。但它如何决定采取哪种动作呢?嗯,这一切都与使用不同的或具体的策略有关。

策略

在 RL 术语中,我们称一个策略为策略。RL 的目标是发现一个好的策略。解决 RL 问题的最常见方法之一是通过观察在每个状态下采取动作的长期后果。短期后果很容易计算:它就是回报。尽管执行某个动作会产生即时回报,但贪婪地选择回报最好的动作并不总是一个好主意。这也是生活中的一课,因为最直接的最佳选择可能不会在长远看来是最令人满足的。最好的策略被称为最优策略,它通常是 RL 中的“圣杯”,如图 3所示,展示了在给定任何状态下的最优动作:

图 3:策略定义了在给定状态下要采取的动作

我们看到过一种类型的策略,其中代理始终选择具有最大即时回报的动作,称为贪心策略。另一个简单的策略例子是随意选择一个动作,称为随机策略。如果你想出一个策略来解决一个 RL 问题,通常一个好主意是重新检查你的学习策略是否优于随机策略和贪心策略。

此外,我们还将看到如何开发另一种强健的策略,称为策略梯度,在这种策略中,神经网络通过使用来自环境的反馈调整其权重,通过梯度下降学习选择动作的策略。我们将看到,尽管两种方法都被使用,策略梯度更为直接且充满乐观。

效用

长期回报被称为效用。事实证明,如果我们知道在某一状态下执行某个动作的效用,那么解决强化学习(RL)就变得容易。例如,为了决定采取哪种动作,我们只需选择产生最高效用的动作。然而,揭示这些效用值是困难的。在状态s下执行动作a的效用被写为一个函数,Q(s, a),称为效用函数。它预测期望的即时回报,以及根据最优策略执行后续回报,如图 4所示:

图 4:使用效用函数

大多数 RL 算法归结为三个主要步骤:推断、执行和学习。在第一步中,算法根据当前所掌握的知识选择给定状态s下的最佳动作a。接下来,执行该动作以获得回报r以及下一个状态s'。然后,算法利用新获得的知识(s, r, a, s')来改进对世界的理解。然而,正如你可能同意的那样,这只是计算效用的一种朴素方法。

现在,问题是:有什么更稳健的方法来计算它呢?我们可以通过递归地考虑未来动作的效用来计算某个特定状态-动作对(s, a)的效用。当前动作的效用不仅受到即时奖励的影响,还受到下一最佳动作的影响,如下式所示:

s'表示下一个状态,a'表示下一个动作。在状态s下采取动作a的奖励用r(s, a)表示。这里,γ是一个超参数,你可以选择它,称为折扣因子。如果γ0,那么智能体选择的是最大化即时奖励的动作。较高的γ值会使智能体更加重视长期后果。在实际应用中,我们还需要考虑更多这样的超参数。例如,如果一个吸尘器机器人需要快速学习解决任务,但不一定要求最优,我们可能会希望设置一个较快的学习率。

另外,如果允许机器人有更多时间来探索和利用,我们可以调低学习率。我们将学习率称为α,并将我们的效用函数修改如下(请注意,当α = 1时,两个方程是相同的):

总结来说,如果我们知道这个Q(s, a)函数,就可以解决一个强化学习问题。接下来是一个叫做 Q 学习的算法。

一个简单的 Q 学习实现

Q 学习是一种可以用于金融和市场交易应用的算法,例如期权交易。一个原因是最佳策略是通过训练生成的。也就是说,强化学习通过在 Q 学习中定义模型,并随着每一个新的实验不断更新它。Q 学习是一种优化(累计)折扣奖励的方法,使得远期奖励低于近期奖励;Q 学习是一种无模型的强化学习方法。它也可以看作是异步动态规划DP)的一种形式。

它为智能体提供了通过体验行动的后果来学习在马尔科夫领域中最优行动的能力,而无需它们建立领域的映射。简而言之,Q 学习被认为是一种强化学习技术,因为它不严格要求标签数据和训练。此外,Q 值不一定是连续可微的函数。

另一方面,马尔科夫决策过程提供了一个数学框架,用于在结果部分随机且部分受决策者控制的情况下建模决策过程。在这种框架中,随机变量在未来某一时刻的概率仅依赖于当前时刻的信息,而与任何历史值无关。换句话说,概率与历史状态无关。

Q 学习算法的组成部分

这个实现深受 Patrick R. Nicolas 所著《Scala for Machine Learning - Second Edition》一书中的 Q 学习实现的启发,出版于 Packt Publishing Ltd.,2017 年 9 月。感谢作者和 Packt Publishing Ltd.。源代码可以在github.com/PacktPublishing/Scala-for-Machine-Learning-Second-Edition/tree/master/src/main/scala/org/scalaml/reinforcement获取。

有兴趣的读者可以查看原始实现,扩展版课程可以从 Packt 仓库或本书的 GitHub 仓库下载。Q 学习算法实现的关键组件有几个类——QLearningQLSpaceQLConfigQLActionQLStateQLIndexedStateQLModel——如以下几点所描述:

  • QLearning:实现训练和预测方法。它使用类型为QLConfig的配置定义一个类型为ETransform的数据转换。

  • QLConfig:这个参数化的类定义了 Q 学习的配置参数。更具体地说,它用于保存用户的显式配置。

  • QLAction 这是一个定义在源状态和多个目标状态之间执行的动作的类。

  • QLPolicy:这是一个枚举器,用于定义在 Q 学习模型训练过程中更新策略时使用的参数类型。

  • QLSpace:它有两个组成部分:类型为QLState的状态序列和序列中一个或多个目标状态的标识符id

  • QLState:包含一系列QLAction实例,帮助从一个状态过渡到另一个状态。它还用作要评估和预测状态的对象或实例的引用。

  • QLIndexedState:这个类返回一个索引状态,用于在搜索目标状态的过程中索引一个状态。

  • QLModel:这个类用于通过训练过程生成一个模型。最终,它包含最佳策略和模型的准确性。

注意,除了前面的组件外,还有一个可选的约束函数,限制从当前状态搜索下一个最有回报的动作的范围。以下图示展示了 Q 学习算法的关键组件及其交互:

图 5:QLearning 算法的组成部分及其交互

QLearning 中的状态和动作

QLAction类指定了从一个状态到另一个状态的过渡。它接受两个参数——即从和到。它们各自有一个整数标识符,且需要大于 0:

  • from:动作的源

  • to:动作的目标

其签名如下所示:

case class QLAction(from: Int, to: Int) {
    require(from >= 0, s"QLAction found from: 
    $from required: >=0")require(to >= 0, s"QLAction found to: 
    $to required: >=0")

override def toString: String = s"n
    Action: state 
    $from => state $to"
}

QLState类定义了 Q 学习中的状态。它接受三个参数:

  • id:一个唯一标识状态的标识符

  • actions:从当前状态过渡到其他状态的动作列表,

  • instance:状态可能具有T类型的属性,与状态转移无关

这是类的签名:

case class QLStateT {
 import QLState._check(id)
 final def isGoal: Boolean = actions.nonEmpty
 override def toString: String =s"state: $id ${actions.mkString(" ")
        }
    nInstance: ${instance.toString}"
}

在上述代码中,toString()方法用于表示 Q-learning 中状态的文本形式。状态由其 ID 和可能触发的动作列表定义。

状态可能没有任何动作。通常这种情况发生在目标状态或吸收状态中。在这种情况下,列表为空。参数化实例是指为其计算状态的对象。

现在我们知道要执行的状态和动作。然而,QLearning代理需要知道形如(状态 x 动作)的搜索空间。下一步是创建图形或搜索空间。

搜索空间

搜索空间是负责任何状态序列的容器。QLSpace类定义了 Q-learning 算法的搜索空间(状态 x 动作),如下图所示:

图 6:带有 QLData(Q 值、奖励、概率)的状态转移矩阵

搜索空间可以通过最终用户提供状态和动作的列表来提供,或者通过提供以下参数来自动创建状态数量:

  • States:Q-learning 搜索空间中定义的所有可能状态的序列

  • goalIds:目标状态的标识符列表

现在让我们来看一下这个类的实现。这是一个相当大的代码块。因此,我们从构造函数开始,它生成一个名为statesMap的映射。它通过id获取状态,并使用目标数组goalStates

private[scalaml] class QLSpace[T] protected (states: Seq[QLState[T]], goalIds: Array[Int]) {
 import QLSpace._check(states, goalIds)

然后它创建一个不可变的状态映射,映射包含状态 ID 和状态实例:

private[this] val statesMap: immutable.Map[Int, QLState[T]] = states.map(st => (st.id, st)).toMap

现在我们已经有了策略和动作状态,接下来的任务是根据状态和策略计算最大值:

final def maxQ(state: QLState[T], policy: QLPolicy): Double = {
 val best=states.filter(_ != state).maxBy(st=>policy.EQ(state.id, st.id))policy.EQ(state.id, best.id)
    }

此外,我们还需要通过访问搜索空间中的状态数来知道状态的数量:

final def getNumStates: Int = states.size

然后,init方法选择一个初始状态用于训练集。 如果state0参数无效,则随机选择该状态:

def init(state0: Int): QLState[T] =
 if (state0 < 0) {
 val r = new Random(System.currentTimeMillis 
                + Random.nextLong)states(r.nextInt(states.size - 1))
        } 
 else states(state0)

最后,nextStates方法检索执行所有与该状态相关的动作后得到的状态列表。搜索空间QLSpace由在QLSpace伴生对象中定义的工厂方法apply创建,如下所示:

final def nextStates(st: QLState[T]): Seq[QLState[T]] =
 if (st.actions.isEmpty)Seq.empty[QLState[T]]
 else st.actions.flatMap(ac => statesMap.get(ac.to))

此外,如何知道当前状态是否为目标状态?嗯,isGoal()方法可以解决这个问题。

它接受一个名为state的参数,它是一个被测试是否为目标状态的状态,并且如果该状态是目标状态,则返回Boolean: true;否则返回 false:

final def isGoal(state: QLState[T]): Boolean = goalStates.contains(state.id)

apply 方法使用实例集合、目标和约束函数constraints作为输入,创建一个状态列表。每个状态都会创建一个动作列表。动作是从这个状态到任何其他状态生成的:

def applyT: QLSpace[T] =             
    apply(ArrayInt, instances, constraints)

函数约束限制了从任何给定状态触发的操作范围,如图 X 所示。

策略和行动值

QLData 类通过创建一个具有给定奖励、概率和 Q 值的 QLData 记录或实例来封装 Q-learning 算法中策略的属性,这些值在训练过程中被计算和更新。概率变量用于建模执行操作的干预条件。

如果操作没有任何外部约束,则概率为 1(即最高概率),否则为零(即无论如何该操作都不被允许)。签名如下所示:

final private[scalaml] class QLData(
 val reward: Double, 
 val probability: Double = 1.0) {

 import QLDataVar._
 var value: Double = 0.0
    @inline final def estimate: Double = value * probability

 final def value(varType: QLDataVar): Double = varType 
 match {
 case REWARD => reward
 case PROB => probability
 case VALUE => value
            }
override def toString: String = s"nValue= $value Reward= $reward Probability= $probability"}

在前面的代码块中,Q 值通过 Q-learning 公式在训练过程中更新,但整体值是通过使用奖励调整其概率来计算的,然后返回调整后的值。然后,value() 方法使用属性的类型选择 Q-learning 策略元素的属性。它接受属性的 varType(即 REWARDPROBABILITYVALUE),并返回该属性的值。

最后,toString() 方法有助于表示值、奖励和概率。现在我们知道数据将如何操作,接下来的任务是创建一个简单的模式,用于初始化与每个操作相关的奖励和概率。以下 Scala 示例是一个名为 QLInput 的类;它输入到 Q-learning 搜索空间(QLSpace)和策略(QLPolicy)中:

case class QLInput(from: Int, to: Int, reward: Double = 1.0, prob: Double = 1.0)

在前面的签名中,构造函数创建了一个 Q-learning 的操作输入。它接受四个参数:

  • from,源状态的标识符

  • to,目标或目的地状态的标识符

  • reward,即从状态 from 转移到状态 to 的奖励或惩罚

  • prob,表示从状态 from 转移到状态 to 的概率

在前面的类中,fromto 参数用于特定操作,而最后两个参数分别是操作完成后收集的奖励和其概率。默认情况下,这两个操作的奖励和概率均为 1。简而言之,我们只需要为那些具有更高奖励或更低概率的操作创建输入。

状态数和输入序列定义了 QLPolicy 类型的策略,这是一个数据容器。一个操作有一个 Q 值(也称为行动值)、一个奖励和一个概率。实现通过三个独立的矩阵定义这三个值——Q 用于行动值,R 用于奖励,P 用于概率——以保持与数学公式的一致性。以下是此类的工作流程:

  1. 使用输入的概率和奖励初始化策略(参见 qlData 变量)。

  2. 根据输入大小计算状态数(参见numStates变量)。

  3. 设置从状态from到状态to的动作的 Q 值(见setQ方法),并通过get()方法获取 Q 值。

  4. 获取从状态from到状态to的状态转移动作的 Q 值(见 Q 方法)。

  5. 获取从状态from到状态to的状态转移动作的估计值(见EQ方法),并以double类型返回该值。

  6. 获取从状态from到状态to的状态转移动作的奖励(见 R 方法)。

  7. 获取从状态from到状态to的状态转移动作的概率(见P方法)。

  8. 计算Q的最小值和最大值(见minMaxQ方法)。

  9. 获取一对(源状态索引,目标状态索引),其转移值为正。状态的索引将转换为 Double 类型(见EQ: Vector[DblPair]方法)。

  10. 使用第一个toString()方法获取此策略的奖励矩阵的文本描述。

  11. 使用第二个toString()方法,文本表示以下任意一项:Q 值、奖励或概率矩阵。

  12. 使用check()方法验证fromto的值。

现在让我们来看一下包含前述工作流的类定义:

final private[scalaml] class QLPolicy(val input: Seq[QLInput]) {
 import QLDataVar._QLPolicy.check(input)
 private[this] val qlData = input.map(qlIn => new QLData(qlIn.reward, qlIn.prob))
 private[this] val numStates = Math.sqrt(input.size).toInt

 def setQ(from: Int, to: Int, value: Double): Unit = 
        {check(from, to, "setQ")qlData(from * numStates + to).value = value}

 final def get(from: Int, to: Int, varType: QLDataVar): String
    {f"${qlData(from * numStates + to).value(varType)}%2.2f"}

 final def Q(from: Int, to: Int): Double = {check(from, to, "Q") qlData(from * numStates + to).value}
 final def EQ(from: Int, to: Int): Double = {check(from, to, "EQ") qlData(from * numStates + to).estimate}
 final def R(from: Int, to: Int): Double = {check(from, to, "R") qlData(from * numStates + to).reward}
 final def P(from: Int, to: Int): Double = {check(from, to, "P") qlData(from * numStates + to).probability}

 final def minMaxQ: DblPair = {
 val r = Range(0, numStates)
 val _min = r.minBy(from => r.minBy(Q(from, _)))
 val _max = r.maxBy(from => r.maxBy(Q(from, _)))(_min, _max)}

 final def EQ: Vector[DblPair] = {
 import scala.collection.mutable.ArrayBuffer
 val r = Range(0, numStates)r.flatMap(from =>r.map(to => (from, to, Q(from, to)))).map { 
 case (i, j, q) => 
 if (q > 0.0) (i.toDouble, j.toDouble) 
 else (0.0, 0.0) }.toVector}

override def toString: String = s"Rewardn${toString(REWARD)}"

def toString(varType: QLDataVar): String = {
 val r = Range(1, numStates)r.map(i => r.map(get(i, _, varType)).mkString(",")).mkString("n")}
 private def check(from: Int, to: Int, meth: String): Unit = {require(from >= 0 && from <                         numStates,s"QLPolicy.
            $meth Found from:
            $from required >= 0 and < 
            $numStates")require(to >= 0 && to < numStates,s"QLPolicy.
            $meth Found to: $to required >= 0 and < $numStates")
}

QLearning 模型的创建与训练

QLearning类封装了 Q 学习算法,更具体地说,是动作-值更新方程。它是ETransform类型的数据转换(我们稍后会讨论),并有一个明确的QLConfig类型配置。该类是一个泛型参数化类,实现了QLearning算法。Q 学习模型在类实例化时进行初始化和训练,以便它能处于正确的状态,进行运行时预测。

因此,类实例只有两种状态:成功训练和失败训练(我们稍后会看到这一点)。

实现不假设每个回合(或训练周期)都会成功。训练完成后,计算初始训练集上标签的比例。客户端代码负责通过测试该比例来评估模型的质量(见模型评估部分)。

构造函数接受算法的配置(即config)、搜索空间(即qlSpace)和策略(即qlPolicy)参数,并创建一个 Q 学习算法:

final class QLearningT
 extends ETransform[QLState[T], QLState[T]](conf) with Monitor[Double]

如果在类实例化过程中达到(或训练)最小覆盖率,模型会自动有效地创建,这本质上是一个 Q 学习模型。

以下的train()方法应用于每个回合,并随机生成初始状态。然后,它根据由conf对象提供的minCoverage配置值计算覆盖率,即每个目标状态达到的回合数:

private def train: Option[QLModel] = Try {
 val completions = Range(0, conf.numEpisodes).map(epoch => 
 if (heavyLiftingTrain (-1)) 1 else 0)
        .sum
        completions.toDouble / conf.numEpisodes
        }
    .filter(_ > conf.minCoverage).map(new QLModel(qlPolicy, _)).toOption;

在上述代码块中,heavyLiftingTrain(state0: Int)方法在每个回合(或迭代)中执行繁重的工作。它通过选择初始状态 state 0 或使用新种子生成的随机生成器r来触发搜索,如果state0小于 0。

首先,它获取当前状态的所有相邻状态,然后从相邻状态列表中选择回报最高的状态。如果下一个回报最高的状态是目标状态,那么任务完成。否则,它将使用奖励矩阵(即QLPolicy.R)重新计算状态转移的策略值。

对于重新计算,它通过更新 Q 值来应用 Q 学习更新公式,然后使用新的状态和递增的迭代器调用搜索方法。让我们来看一下该方法的主体:

private def heavyLiftingTrain(state0: Int): Boolean = {
    @scala.annotation.tailrec
 def search(iSt: QLIndexedState[T]): QLIndexedState[T] = {
 val states = qlSpace.nextStates(iSt.state)
 if (states.isEmpty || iSt.iter >= conf.episodeLength)
            QLIndexedState(iSt.state, -1)
 else {
 val state = states.maxBy(s => qlPolicy.EQ(iSt.state.id, s.id))
 if (qlSpace.isGoal(state))
                QLIndexedState(state, iSt.iter)

 else {
 val fromId = iSt.state.id
 val r = qlPolicy.R(fromId, state.id)
 val q = qlPolicy.Q(fromId, state.id)
 val nq = q + conf.alpha * (r + conf.gamma * qlSpace.maxQ(state, qlPolicy) - q)
                count(QVALUE_COUNTER, nq)
                qlPolicy.setQ(fromId, state.id, nq)
                search(QLIndexedState(state, iSt.iter + 1))
                }
            }
        }

val finalState = search(QLIndexedState(qlSpace.init(state0), 0))
if (finalState.iter == -1)
 false else
    qlSpace.isGoal(finalState.state)
    }
}

给出一组策略和训练覆盖率后,让我们获取训练后的模型:

private[this] val model: Option[QLModel] = train

请注意,上述模型是通过用于训练 Q 学习算法的输入数据(参见类QLPolicy)和内联方法getInput()进行训练的:

def getInput: Seq[QLInput] = qlPolicy.input

现在我们需要执行一个在期权交易应用中将会用到的重要步骤。因此,我们需要将 Q 学习的模型作为一个选项进行检索:

@inline
finaldef getModel: Option[QLModel] = model

如果模型未定义,则整体应用程序会失败(参见validateConstraints()方法进行验证):

@inline
finaldef isModel: Boolean = model.isDefined
override def toString: String = qlPolicy.toString + qlSpace.toString

然后,使用 Scala 尾递归执行下一最有回报的状态的递归计算。其思路是在所有状态中搜索,并递归选择为最佳策略给予最多奖励的状态。

@scala.annotation.tailrec
private def nextState(iSt: QLIndexedState[T]): QLIndexedState[T] = {
 val states = qlSpace.nextStates(iSt.state)
 if (states.isEmpty || iSt.iter >= conf.episodeLength)
                iSt
 else {
 val fromId = iSt.state.id
 val qState = states.maxBy(s => model.map(_.bestPolicy.EQ(fromId, s.id)).getOrElse(-1.0))
                nextState(QLIndexedStateT)
        }
}

在上述代码块中,nextState()方法检索可以从当前状态转移到的合适状态。然后,它通过递增迭代计数器来提取具有最高回报策略的状态qState。最后,如果没有更多状态或方法未在由config.episodeLength参数提供的最大迭代次数内收敛,它将返回状态。

尾递归:在 Scala 中,尾递归是一种非常有效的结构,用于对集合中的每个项应用操作。它在递归过程中优化了函数栈帧的管理。注解触发了编译器优化函数调用所需的条件验证。

最后,Q 学习算法的配置QLConfig指定:

  • 学习率,alpha

  • 折扣率,gamma

  • 一个回合的最大状态数(或长度),episodeLength

  • 训练中使用的回合数(或迭代次数),numEpisodes

  • 选择最佳策略所需的最小覆盖率,minCoverage

这些内容如下所示:

case class QLConfig(alpha: Double,gamma: Double,episodeLength: Int,numEpisodes: Int,minCoverage: Double) 
extends Config {
import QLConfig._check(alpha, gamma, episodeLength, numEpisodes, minCoverage)}

现在我们几乎完成了,除了验证尚未完成。然而,让我们先看一下 Q 学习算法配置的伴生对象。此单例定义了QLConfig类的构造函数,并验证其参数:

private[scalaml] object QLConfig {
 private val NO_MIN_COVERAGE = 0.0
 private val MAX_EPISODES = 1000

 private def check(alpha: Double,gamma: Double,
                          episodeLength: Int,numEpisodes: Int,
                          minCoverage: Double): Unit = {
                    require(alpha > 0.0 && alpha < 1.0,s"QLConfig found alpha: $alpha required 
                            > 0.0 and < 1.0")
                    require(gamma > 0.0 && gamma < 1.0,s"QLConfig found gamma $gamma required 
                           > 0.0 and < 1.0")
                    require(numEpisodes > 2 && numEpisodes < MAX_EPISODES,s"QLConfig found 
                            $numEpisodes $numEpisodes required > 2 and < $MAX_EPISODES")
                    require(minCoverage >= 0.0 && minCoverage <= 1.0,s"QLConfig found $minCoverage 
                            $minCoverage required > 0 and <= 1.0")
        }

太棒了!我们已经看到了如何在 Scala 中实现 QLearning 算法。然而,正如我所说,实施是基于公开的来源,训练可能并不总是收敛。对于这种在线模型,一个重要的考虑因素是验证。商业应用(或甚至是我们将在下一节讨论的高大上的 Scala Web 应用)可能需要多种验证机制,涉及状态转换、奖励、概率和 Q 值矩阵。

QLearning 模型验证

一个关键的验证是确保用户定义的约束函数不会在 Q-learning 的搜索或训练中产生死锁。约束函数确定了从给定状态通过行动可以访问的状态列表。如果约束过于严格,一些可能的搜索路径可能无法到达目标状态。下面是对约束函数的一个简单验证:

def validateConstraints(numStates: Int, constraint: Int => List[Int]): Boolean = {require(numStates > 1,         s"QLearning validateConstraints found $numStates states should be >1")!Range(0,                 
        numStates).exists(constraint(_).isEmpty)
}

使用训练好的模型进行预测

现在我们可以递归地选择给定最佳策略的最多奖励的状态(参见下面代码中的nextState方法),例如,可以对期权交易执行 Q-learning 算法的在线训练。

因此,一旦 Q-learning 模型使用提供的数据进行了训练,下一状态就可以通过覆盖数据转换方法(PipeOperator,即|)来使用 Q-learning 模型进行预测,转换为预测的目标状态:

override def |> : PartialFunction[QLState[T], Try[QLState[T]]] = {
 case st: QLState[T] 
 if isModel =>
            Try(
 if (st.isGoal) st 
 else nextState(QLIndexedStateT).state)
    }

我想这已经够多了,虽然评估模型会很好。但是,在真实数据集上进行评估会更好,因为在假数据上运行和评估模型的表现,就像是买了辆新车却从未开过。因此,我想结束实现部分,继续进行基于这个 Q-learning 实现的期权交易应用。

使用 Q-learning 开发期权交易 Web 应用

交易算法是利用计算机编程,按照定义的一组指令执行交易,以生成人类交易员无法匹敌的速度和频率的利润。定义的规则集基于时机、价格、数量或任何数学模型。

问题描述

通过这个项目,我们将根据当前一组从到期时间、证券价格和波动性派生的观察特征,预测期权在未来N天的价格。问题是:我们应该使用什么模型来进行这种期权定价?答案是,实际上有很多模型;其中 Black-Scholes 随机偏微分方程PDE)是最为人熟知的。

在数学金融学中,Black-Scholes 方程是必然的偏微分方程,它覆盖了欧式看涨期权或欧式看跌期权在 Black-Scholes 模型下的价格演变。对于不支付股息的标的股票的欧式看涨期权或看跌期权,方程为:

其中 V 表示期权价格,是股票价格 S 和时间 t 的函数,r 是无风险利率,σ 是股票的波动率。方程背后的一个关键金融洞察是,任何人都可以通过正确的方式买卖标的资产来完美对冲期权而不承担任何风险。这种对冲意味着只有一个正确的期权价格,由 Black-Scholes 公式返回。

考虑一种行使价格为 $95 的 IBM 一月到期期权。你写出一种行使价格为 $85 的 IBM 一月看跌期权。让我们考虑和关注给定安全性 IBM 的看涨期权。下图绘制了 2014 年 5 月 IBM 股票及其衍生看涨期权的每日价格,行使价格为 $190:

图 7:2013 年 5 月至 10 月期间 IBM 股票和行使价格为 $190 的看涨期权定价

现在,如果 IBM 在期权到期日以 $87 出售,这个头寸的盈亏将是多少?或者,如果 IBM 以 $100 出售呢?嗯,计算或预测答案并不容易。然而,在期权交易中,期权价格取决于一些参数,如时间衰减、价格和波动率:

  • 期权到期时间(时间衰减)

  • 标的证券的价格

  • 标的资产收益的波动率

定价模型通常不考虑标的证券的交易量变化。因此,一些研究人员将其纳入期权交易模型中。正如我们所描述的,任何基于强化学习的算法应该具有显式状态(或状态),因此让我们使用以下四个归一化特征定义期权的状态:

  • 时间衰减 (timeToExp):这是归一化后的到期时间在 (0, 1) 范围内。

  • 相对波动性 (volatility):在一个交易会话内,这是标的证券价格相对变化的相对值。这与 Black-Scholes 模型中定义的更复杂收益波动性不同。

  • 波动性相对于成交量 (vltyByVol):这是调整后的标的证券价格相对于其成交量的相对波动性。

  • 当前价格与行权价格之间的相对差异 (priceToStrike):这衡量的是价格与行权价格之间差异与行权价格的比率。

下图显示了可以用于 IBM 期权策略的四个归一化特征:

图 8:IBM 股票的归一化相对股价波动性、相对于交易量的波动性以及相对于行权价格的股价

现在让我们看看股票和期权价格的数据集。有两个文件,IBM.csvIBM_O.csv,分别包含 IBM 股票价格和期权价格。股票价格数据集包含日期、开盘价、最高价、最低价、收盘价、交易量和调整后的收盘价。数据集的一部分如下图所示:

图 9:IBM 股票数据

另一方面,IBM_O.csv包含了 127 个 IBM 190 Oct 18, 2014 的期权价格。其中几个值为 1.41、2.24、2.42、2.78、3.46、4.11、4.51、4.92、5.41、6.01 等。到此为止,我们能否利用QLearning算法开发一个预测模型,帮助我们回答之前提到的问题:它能告诉我们如何通过利用所有可用特征帮助 IBM 实现最大利润吗?

好的,我们知道如何实现QLearning,也知道什么是期权交易。另一个好处是,本项目将使用的技术,如 Scala、Akka、Scala Play 框架和 RESTful 服务,已经在第三章《从历史数据中进行高频比特币价格预测》中进行了讨论。因此,可能是可行的。接下来我们尝试开发一个 Scala Web 项目,帮助我们最大化利润。

实现期权交易 Web 应用程序

本项目的目标是创建一个期权交易的 Web 应用程序,该程序从 IBM 股票数据中创建一个 QLearning 模型。然后,应用程序将从模型中提取输出作为 JSON 对象,并将结果显示给用户。图 10显示了整体工作流程:

图 10:期权交易 Scala Web 的工作流程

计算 API 为 Q-learning 算法准备输入数据,算法通过从文件中提取数据来构建期权模型。然后,它对数据进行归一化和离散化等操作。所有这些数据都传递给 Q-learning 算法以训练模型。之后,计算 API 从算法中获取模型,提取最佳策略数据,并将其放入 JSON 中返回给 Web 浏览器。期权交易策略的实现,使用 Q-learning 包含以下几个步骤:

  • 描述期权的属性

  • 定义函数近似

  • 指定状态转换的约束条件

创建一个期权属性

考虑到市场波动性,我们需要更现实一点,因为任何长期预测都相当不可靠。原因是它将超出离散马尔科夫模型的约束。因此,假设我们想预测未来两天的价格——即 N= 2。这意味着期权未来两天的价格是利润或损失的奖励值。那么,让我们封装以下四个参数:

  • timeToExp:期权到期前剩余时间,占期权整体持续时间的百分比

  • 波动性标准化:给定交易时段内,基础证券的相对波动性

  • vltyByVol:在给定交易时段内,相对于该时段交易量的基础证券波动性

  • priceToStrike:相对于行使价的基础证券价格,在给定交易时段内

OptionProperty 类定义了一个交易期权的属性。构造函数为期权创建属性:

class OptionProperty(timeToExp: Double,volatility: Double,vltyByVol: Double,priceToStrike: Double) {
 val toArray = ArrayDouble
        require(timeToExp > 0.01, s"OptionProperty time to expiration found $timeToExp required 0.01")
    }

创建一个期权模型

现在,我们需要创建一个 OptionModel 来充当期权属性的容器和工厂。它接受以下参数,并通过访问之前描述的四个特征的数据源,创建一个期权属性列表 propsList

  • 证券的符号。

  • option 的行使价格,strikePrice

  • data 的来源,src

  • 最小时间衰减或到期时间,minTDecay。期权价值低于行使价的期权会变得毫无价值,而价值高于行使价的期权在接近到期时价格行为差异很大。因此,期权到期日前的最后 minTDecay 个交易时段不会参与训练过程。

  • 用于近似每个特征值的步数(或桶数),nSteps。例如,四步近似会创建四个桶:(0,25),(25,50),(50,75),和(75,100)。

然后,它会组装 OptionProperties 并计算期权到期的最小标准化时间。接着,它通过将实际价值离散化为多个层次,从期权价格数组中近似计算期权的价值;最后,它返回一个包含期权价格和精度层次的映射。以下是该类的构造函数:

class OptionModel(
    symbol: String,
    strikePrice: Double,
    src: DataSource,
    minExpT: Int,
    nSteps: Int
    )

在这个类的实现中,首先通过 check() 方法进行验证,检查以下内容:

  • strikePrice:需要一个正的价格

  • minExpT:此值必须介于 2 和 16 之间

  • nSteps:至少需要两个步数

以下是调用该方法的示例:

check(strikePrice, minExpT, nSteps)

上述方法的签名如下所示:

def check(strikePrice: Double, minExpT: Int, nSteps: Int): Unit = {
    require(strikePrice > 0.0, s"OptionModel.check price found $strikePrice required > 0")
    require(minExpT > 2 && minExpT < 16,s"OptionModel.check Minimum expiration time found $minExpT                     required ]2, 16")
    require(nSteps > 1,s"OptionModel.check, number of steps found $nSteps required > 1")
    }

一旦满足前述约束条件,期权属性列表 propsList 被创建如下:

val propsList = (for {
    price <- src.get(adjClose)
    volatility <- src.get(volatility)
    nVolatility <- normalize[Double
    vltyByVol <- src.get(volatilityByVol)
    nVltyByVol <- normalizeDouble
    priceToStrike <- normalizeDouble)
    } 
 yield {
        nVolatility.zipWithIndex./:(List[OptionProperty]()) {
 case (xs, (v, n)) =>
 val normDecay = (n + minExpT).toDouble / (price.size + minExpT)
 new OptionProperty(normDecay, v, nVltyByVol(n), priceToStrike(n)) :: xs
        }
     .drop(2).reverse
    }).get

在前面的代码块中,工厂使用了zipWithIndex的 Scala 方法来表示交易会话的索引。所有的特征值都在区间(0, 1)内进行归一化,包括normDecay期权的时间衰减(或到期时间)。

OptionModel类的quantize()方法将每个期权属性的归一化值转换为一个桶索引数组。它返回一个以桶索引数组为键的盈亏映射表:

def quantize(o: Array[Double]): Map[Array[Int], Double] = {
 val mapper = new mutable.HashMap[Int, Array[Int]]
 val acc: NumericAccumulator[Int] = propsList.view.map(_.toArray)
    map(toArrayInt(_)).map(ar => {
 val enc = encode(ar)
        mapper.put(enc, ar)
        enc
            })
    .zip(o)./:(
 new NumericAccumulator[Int]) {
 case (_acc, (t, y)) => _acc += (t, y); _acc
            }
        acc.map {
 case (k, (v, w)) => (k, v / w) }
            .map { 
 case (k, v) => (mapper(k), v) }.toMap
    }

该方法还创建了一个映射器实例,用于索引桶数组。一个类型为NumericAccumulator的累加器acc扩展了Map[Int, (Int, Double)],并计算这个元组(每个桶中特征的出现次数,期权价格的增减总和)

toArrayInt方法将每个期权属性(如timeToExpvolatility等)的值转换为相应桶的索引。然后,索引数组被编码以生成一个状态的 id 或索引。该方法更新累加器,记录每个交易会话的期权出现次数及其总盈亏。最后,它通过对每个桶的盈亏进行平均计算每个操作的奖励。encode()toArrayInt()方法的签名如下所示:

private def encode(arr: Array[Int]): Int = arr./:((1, 0)) { 
 case ((s, t), n) => (s * nSteps, t + s * n) }._2
 private def toArrayInt(feature: Array[Double]): Array[Int] = feature.map(x => (nSteps *         
            x).floor.toInt)

final class NumericAccumulator[T] 
 extends mutable.HashMap[T, (Int, Double)] {
 def +=(key: T, x: Double): Option[(Int, Double)] = {
 val newValue = 
 if (contains(key)) (get(key).get._1 + 1, get(key).get._2 + x) 
 else (1, x)
 super.put(key, newValue)
    }
}

最后,也是最重要的,如果前述约束条件得到满足(不过你可以修改这些约束),并且一旦OptionModel类的实例化成功生成OptionProperty元素的列表;否则,它将生成一个空列表。

将它们汇总在一起

由于我们已经实现了 Q-learning 算法,我们现在可以使用 Q-learning 开发期权交易应用程序。然而,首先,我们需要使用DataSource类加载数据(稍后我们将看到其实现)。然后,我们可以为给定股票创建一个期权模型,使用OptionModel,它定义了一个在证券上交易的期权模型,并设置默认的行权价和最短到期时间参数。然后,我们需要为期权的盈亏模型创建基础证券。

盈亏被调整为产生正值。它实例化了一个 Q-learning 类的实例,即一个实现了 Q-learning 算法的通用参数化类。Q-learning 模型在类实例化时被初始化和训练,因此它可以在运行时进行预测时处于正确的状态。

因此,类的实例只有两种状态:成功训练和失败训练的 Q-learning 值操作。然后模型被返回并处理和可视化。

那么,让我们创建一个 Scala 对象并命名为QLearningMain。接着,在QLearningMain对象内部,定义并初始化以下参数:

  • Name:用于指示强化算法的名称(在我们的例子中是 Q-learning)

  • STOCK_PRICES: 包含股票数据的文件

  • OPTION_PRICES: 包含可用期权数据的文件

  • STRIKE_PRICE: 期权行权价格

  • MIN_TIME_EXPIRATION: 记录的期权最小到期时间

  • QUANTIZATION_STEP: 用于对证券值进行离散化或近似的步长

  • ALPHA: Q-learning 算法的学习率

  • DISCOUNT(gamma):Q-learning 算法的折扣率

  • MAX_EPISODE_LEN: 每个回合访问的最大状态数

  • NUM_EPISODES: 训练过程中使用的回合数

  • MIN_COVERAGE: Q-learning 模型训练过程中允许的最小覆盖率

  • NUM_NEIGHBOR_STATES: 从任何其他状态可访问的状态数

  • REWARD_TYPE: 最大奖励或随机

每个参数的初步初始化如下代码所示:

val name: String = "Q-learning"// Files containing the historical prices for the stock and option
val STOCK_PRICES = "/static/IBM.csv"
val OPTION_PRICES = "/static/IBM_O.csv"// Run configuration parameters
val STRIKE_PRICE = 190.0 // Option strike price
val MIN_TIME_EXPIRATION = 6 // Min expiration time for option recorded
val QUANTIZATION_STEP = 32 // Quantization step (Double => Int)
val ALPHA = 0.2 // Learning rate
val DISCOUNT = 0.6 // Discount rate used in Q-Value update equation
val MAX_EPISODE_LEN = 128 // Max number of iteration for an episode
val NUM_EPISODES = 20 // Number of episodes used for training.
val NUM_NEIGHBHBOR_STATES = 3 // No. of states from any other state

现在,run()方法接受作为输入的奖励类型(在我们的例子中是最大奖励)、量化步长(在我们的例子中是QUANTIZATION_STEP)、alpha(学习率,在我们的例子中是ALPHA)和 gamma(在我们的例子中是DISCOUNT,Q-learning 算法的折扣率)。它显示了模型中的值分布。此外,它在散点图上显示了最佳策略的估计 Q 值(我们稍后会看到)。以下是前述方法的工作流程:

  1. 首先,它从IBM.csv文件中提取股票价格

  2. 然后它使用股票价格和量化方法quantizeR创建一个选项模型createOptionModel(有关更多信息,请参见quantize方法和稍后的主方法调用)

  3. 期权价格从IBM_o.csv文件中提取

  4. 然后,使用期权模型创建另一个模型model,并使用期权价格oPrices对其进行评估

  5. 最后,估计的 Q 值(即,Q 值 = 值 * 概率)在散点图上显示,使用display方法

通过结合前述步骤,以下是run()方法的签名:

private def run(rewardType: String,quantizeR: Int,alpha: Double,gamma: Double): Int = {
 val sPath = getClass.getResource(STOCK_PRICES).getPath
 val src = DataSource(sPath, false, false, 1).get
 val option = createOptionModel(src, quantizeR)

 val oPricesSrc = DataSource(OPTION_PRICES, false, false, 1).get
 val oPrices = oPricesSrc.extract.get

 val model = createModel(option, oPrices, alpha, gamma)model.map(m => {if (rewardType != "Random")
    display(m.bestPolicy.EQ,m.toString,s"$rewardType with quantization order             
            $quantizeR")1}).getOrElse(-1)
}

现在,这是createOptionModel()方法的签名,该方法使用(请参见OptionModel类)创建一个期权模型:

private def createOptionModel(src: DataSource, quantizeR: Int): OptionModel =
 new OptionModel("IBM", STRIKE_PRICE, src, MIN_TIME_EXPIRATION, quantizeR)

接着,createModel()方法创建一个期权的利润和亏损模型,给定基础证券。请注意,期权价格是使用之前定义的quantize()方法量化的。然后,使用约束方法限制给定状态下可用的动作数。这个简单的实现计算了该状态范围内的所有状态列表。然后,它确定了一个预定义半径内的邻接状态。

最后,它使用输入数据训练 Q-learning 模型,计算最小的利润值和亏损值,以便最大亏损被转化为零利润。请注意,利润和亏损被调整为正值。现在让我们看看此方法的签名:

def createModel(ibmOption: OptionModel,oPrice: Seq[Double],alpha: Double,gamma: Double): Try[QLModel] = {
 val qPriceMap = ibmOption.quantize(oPrice.toArray)
 val numStates = qPriceMap.size
 val neighbors = (n: Int) => {
def getProximity(idx: Int, radius: Int): List[Int] = {
 val idx_max =
 if (idx + radius >= numStates) numStates - 1 
 else idx + radius
 val idx_min = 
 if (idx < radius) 0 
 else idx - radiusRange(idx_min, idx_max + 1).filter(_ != idx)./:(List[Int]())((xs, n) => n :: xs)}getProximity(n, NUM_NEIGHBHBOR_STATES)
        }
 val qPrice: DblVec = qPriceMap.values.toVector
 val profit: DblVec = normalize(zipWithShift(qPrice, 1).map {
 case (x, y) => y - x}).get
 val maxProfitIndex = profit.zipWithIndex.maxBy(_._1)._2
 val reward = (x: Double, y: Double) => Math.exp(30.0 * (y - x))
 val probabilities = (x: Double, y: Double) => 
 if (y < 0.3 * x) 0.0 
 else 1.0println(s"$name Goal state index: $maxProfitIndex")
 if (!QLearning.validateConstraints(profit.size, neighbors))
 thrownew IllegalStateException("QLearningEval Incorrect states transition constraint")
 val instances = qPriceMap.keySet.toSeq.drop(1)
 val config = QLConfig(alpha, gamma, MAX_EPISODE_LEN, NUM_EPISODES, 0.1)
 val qLearning = QLearning[Array[Int]](config,ArrayInt,profit,reward,probabilities,instances,Some(neighbors))    val modelO = qLearning.getModel
 if (modelO.isDefined) {
 val numTransitions = numStates * (numStates - 1)println(s"$name Coverage ${modelO.get.coverage} for $numStates states and $numTransitions transitions")
 val profile = qLearning.dumpprintln(s"$name Execution profilen$profile")display(qLearning)Success(modelO.get)} 
 else Failure(new IllegalStateException(s"$name model undefined"))
}

请注意,如果前面的调用无法创建一个选项模型,代码不会显示模型创建失败的消息。尽管如此,请记住,考虑到我们使用的小数据集,接下来的这一行中使用的minCoverage非常重要(因为算法会非常快速地收敛):

val config = QLConfig(alpha, gamma, MAX_EPISODE_LEN, NUM_EPISODES, 0.0)

尽管我们已经说明模型的创建和训练未必成功,但一个简单的线索是使用一个非常小的minCoverage值,范围在0.00.22之间。如果前面的调用成功,那么模型已训练完成,可以进行预测。如果成功,那么就可以使用显示方法,在散点图中显示估算的Q 值 = 值 * 概率。方法的签名如下:

private def display(eq: Vector[DblPair],results: String,params: String): Unit = {
 import org.scalaml.plots.{ScatterPlot, BlackPlotTheme, Legend}
 val labels = Legend(name, s"Q-learning config: $params", "States", "States")ScatterPlot.display(eq, 
        labels, new BlackPlotTheme)
}

稍等一下,不要急!我们终于准备好查看一个简单的rn并检查结果。让我们来看看:

def main(args: Array[String]): Unit = {
 run("Maximum reward",QUANTIZATION_STEP, ALPHA, DISCOUNT)
 } 
>>> 
Action: state 71 => state 74
Action: state 71 => state 73
Action: state 71 => state 72
Action: state 71 => state 70
Action: state 71 => state 69
Action: state 71 => state 68...Instance: I@1f021e6c - state: 124
Action: state 124 => state 125
Action: state 124 => state 123
Action: state 124 => state 122
Action: state 124 => state 121Q-learning Coverage 0.1 for 126 states and 15750 transitions
Q-learning Execution profile
Q-Value -> 5.572310105096295, 0.013869013819834967, 4.5746487300071825, 0.4037703812585325, 0.17606260549479869, 0.09205272504875522, 0.023205692430068765, 0.06363082458984902, 50.405283888218435... 6.5530411130514015
Model: Success(Optimal policy: Reward - 1.00,204.28,115.57,6.05,637.58,71.99,12.34,0.10,4939.71,521.30,402.73, with coverage: 0.1)

评估模型

上述输出显示了从一个状态到另一个状态的转变,对于0.1的覆盖率,QLearning模型在 126 个状态中有 15,750 次转变,最终达到了目标状态 37,且获得了最优奖励。因此,训练集相当小,只有少数桶包含实际值。所以我们可以理解,训练集的大小对状态的数量有影响。对于一个小的训练集(比如我们这个例子中的情况),QLearning会收敛得太快。

然而,对于更大的训练集,QLearning需要一定的时间才能收敛;它会为每个由近似生成的桶提供至少一个值。同时,通过查看这些值,很难理解 Q 值和状态之间的关系。

那么,如果我们能看到每个状态的 Q 值呢?当然可以!我们可以在散点图中看到它们:

![图 11:每个状态的 Q 值现在让我们展示 Q 值(QLData.value)对数的曲线,作为递归搜索(或训练)过程中不同轮次或周期的进展。测试使用学习率α = 0.1和折扣率γ = 0.9(更多细节见部署部分):

图 12:Q-learning 训练过程中,不同周期的对数 Q 值曲线

上述图表说明了每个轮次中的 Q 值与训练的顺序无关。然而,达到目标状态所需的迭代次数则取决于在此示例中随机选择的初始状态。为了获得更多的见解,请检查编辑器中的输出,或者访问 API 端点http://localhost:9000/api/compute(见下文)。那么,如果我们在模型中显示值的分布,并在散点图中展示给定配置参数下最佳策略的估算 Q 值会怎么样呢?

图 13:在量化 32 的情况下,QLearning 的最大奖励

最终评估包括评估学习率和折扣率对训练覆盖率的影响:

图 14:学习率和折扣率对训练覆盖率的影响

随着学习率的增加,覆盖率降低。这个结果验证了使用学习率 < 0.2的普遍规律。为了评估折扣率对覆盖率的影响,进行的类似测试并没有得出明确结论。我们可能会有成千上万种这种配置参数的不同选择和组合。那么,如果我们能将整个应用程序包装成类似于我们在第三章中做的那样的 Scala Web 应用程序——基于历史数据的高频比特币价格预测,会怎么样呢?我猜这应该不会是个坏主意。那么让我们深入研究一下吧。

将期权交易应用程序封装为 Scala Web 应用程序

这个想法是获取训练好的模型并构建最佳策略的 JSON 输出,以便得到最大回报的情况。PlayML是一个 Web 应用程序,使用期权交易 Q-learning 算法,提供一个计算 API 端点,接收输入数据集和一些选项来计算 q 值,并以 JSON 格式返回这些值,以便在前端进行建模。

封装后的 Scala Web ML 应用程序具有以下目录结构:

图 15:Scala ML Web 应用程序目录结构

在前面的结构中,应用程序文件夹包含了原始的 QLearning 实现(见ml文件夹)以及一些额外的后端代码。controller子文件夹中有一个名为API.scala的 Scala 类,它作为 Scala 控制器,用于控制前端的模型行为。最后,Filters.scala作为DefaultHttpFilters起作用:

图 16:ml 目录结构

conf文件夹包含 Scala Web 应用程序的配置文件application.conf,其中包含必要的配置。所有的依赖项都在build.sbt文件中定义,如下所示:

name := "PlayML"version := "1.0"
lazy val `playml` = (project in file(".")).enablePlugins(PlayScala)
resolvers += "scalaz-bintray" 
scalaVersion := "2.11.11"
libraryDependencies ++= Seq(filters, cache, ws, "org.apache.commons" % "commons-math3" %                 
        "3.6","com.typesafe.play" %% "play-json" % "2.5",
        "org.jfree" % "jfreechart" % "1.0.17",
        "com.typesafe.akka" %% "akka-actor" % "2.3.8",
        "org.apache.spark" %% "spark-core" % "2.1.0",
        "org.apache.spark" %% "spark-mllib" % "2.1.0",
        "org.apache.spark" %% "spark-streaming" % "2.1.0")

lib文件夹包含一些作为外部依赖项的.jar文件,这些依赖项在build.sbt文件中定义。public文件夹包含 UI 中使用的静态页面。此外,数据文件IBM.csvIBM_O.csv也存放在其中。最后,target文件夹保存打包后的应用程序(如果有的话)。

后端

在后端,我封装了前面提到的 Q-learning 实现,并额外创建了一个 Scala 控制器,来控制前端模型的行为。其结构如下:

import java.nio.file.Paths
import org.codehaus.janino.Java
import ml.stats.TSeries.{normalize, zipWithShift}
import ml.workflow.data.DataSource
import ml.trading.OptionModel
import ml.Predef.{DblPair, DblVec}
import ml.reinforcement.qlearning.{QLConfig, QLModel, QLearning}
import scala.util.{Failure, Success, Try}
import play.api._
import play.api.data.Form
import play.api.libs.json._
import play.api.mvc._
import scala.util.{Failure, Success, Try}

class API extends Controller {
 protected val name: String = "Q-learning"
 private var sPath = Paths.get((s"${"public/data/IBM.csv"}")).toAbsolutePath.toString
 private var oPath = Paths.get((s"${"public/data/IBM_O.csv"}")).toAbsolutePath.toString

   // Run configuration parameters
 private var STRIKE_PRICE = 190.0 // Option strike price
 private var MIN_TIME_EXPIRATION = 6 // Minimum expiration time for the option recorded
 private var QUANTIZATION_STEP = 32 // Quantization step (Double => Int)
 private var ALPHA = 0.2 // Learning rate
 private var DISCOUNT = 0.6 // Discount rate used in the Q-Value update equation
 private var MAX_EPISODE_LEN = 128 // Maximum number of iteration for an episode
 private var NUM_EPISODES = 20 // Number of episodes used for training.
 private var MIN_COVERAGE = 0.1
 private var NUM_NEIGHBOR_STATES = 3 // Number of states accessible from any other state
 private var REWARD_TYPE = "Maximum reward"
 private var ret = JsObject(Seq())
 private var retry = 0

 private def run(REWARD_TYPE: String,quantizeR: Int,alpha: Double,gamma: Double) = {
 val maybeModel = createModel(createOptionModel(DataSource(sPath, false, false, 1).get, quantizeR),             DataSource(oPath, false, false, 1).get.extract.get, alpha, gamma)
 if (maybeModel != None) {
 val model = maybeModel.get
 if (REWARD_TYPE != "Random") {
 var value = JsArray(Seq())
 var x = model.bestPolicy.EQ.distinct.map(x => {value = value.append(JsObject(Seq("x" ->                     JsNumber(x._1), "y" -> JsNumber(x._2))))})ret = ret.+("OPTIMAL", value)
                }
            }
        }
/** Create an option model for a given stock with default strike and minimum expiration time parameters.
*/
 privatedef createOptionModel(src: DataSource, quantizeR: Int): OptionModel =
 new OptionModel("IBM", STRIKE_PRICE, src, MIN_TIME_EXPIRATION, quantizeR)
/** Create a model for the profit and loss on an option given
* the underlying security. The profit and loss is adjusted to
* produce positive values.
*/
 privatedef createModel(ibmOption: OptionModel,oPrice: Seq[Double],alpha: Double,gamma: Double): Option[QLModel] = {
 val qPriceMap = ibmOption.quantize(oPrice.toArray)
 val numStates = qPriceMap.size
 val neighbors = (n: Int) => {
 def getProximity(idx: Int, radius: Int): List[Int] = {
 val idx_max = if (idx + radius >= numStates) numStates - 1
            else idx + radius
 val idx_min = if (idx < radius) 0 
                        else idx - radiusscala.collection.immutable.Range(idx_min, idx_max + 1)
                            .filter(_ != idx)./:(List[Int]())((xs, n) => n :: xs)
                        }
                getProximity(n, NUM_NEIGHBOR_STATES)
            }
       // Compute the minimum value for the profit, loss so the maximum loss is converted to a null profit
 val qPrice: DblVec = qPriceMap.values.toVector
 val profit: DblVec = normalize(zipWithShift(qPrice, 1).map {
        case (x, y) => y - x }).get
 val maxProfitIndex = profit.zipWithIndex.maxBy(_._1)._2
 val reward = (x: Double, y: Double) => Math.exp(30.0 * (y - x))

 val probabilities = (x: Double, y: Double) =>
             if (y < 0.3 * x) 0.0 else 1.0ret = ret.+("GOAL_STATE_INDEX", JsNumber(maxProfitIndex))
 if (!QLearning.validateConstraints(profit.size, neighbors)) {ret = ret.+("error",                             JsString("QLearningEval Incorrect states transition constraint"))

 thrownew IllegalStateException("QLearningEval Incorrect states transition constraint")}

 val instances = qPriceMap.keySet.toSeq.drop(1)
 val config = QLConfig(alpha, gamma, MAX_EPISODE_LEN, NUM_EPISODES, MIN_COVERAGE)
 val qLearning = QLearning[Array[Int]](config,Array[Int]                
                (maxProfitIndex),profit,reward,probabilities,instances,Some(neighbors))    
            val modelO = qLearning.getModel

 if (modelO.isDefined) {
 val numTransitions = numStates * (numStates - 1)ret = ret.+("COVERAGE",             
                JsNumber(modelO.get.coverage))ret = ret.+("COVERAGE_STATES", JsNumber(numStates))
                ret = ret.+("COVERAGE_TRANSITIONS", JsNumber(numTransitions))
 var value = JsArray()
 var x = qLearning._counters.last._2.distinct.map(x => {value = value.append(JsNumber(x))
                })    
                ret = ret.+("Q_VALUE", value)modelO
                } 
 else {
                if (retry > 5) {ret = ret.+("error", JsString(s"$name model undefined"))
 return None
                 }
                retry += 1Thread.sleep(500)
 return createModel(ibmOption,oPrice,alpha,gamma)
            }        
        }
def compute = Action(parse.anyContent) { request =>
 try {
        if (request.body.asMultipartFormData != None) {
 val formData = request.body.asMultipartFormData.get
 if (formData.file("STOCK_PRICES").nonEmpty && formData.file("STOCK_PRICES").get.filename.nonEmpty)sPath = formData.file("STOCK_PRICES").get.ref.file.toString
 if (formData.file("OPTION_PRICES").nonEmpty && formData.file("OPTION_PRICES").get.filename.nonEmpty)oPath = formData.file("OPTION_PRICES").get.ref.file.toString
 val parts = formData.dataParts
 if (parts.get("STRIKE_PRICE") != None)STRIKE_PRICE = parts.get("STRIKE_PRICE").get.mkString("").toDouble
 if (parts.get("MIN_TIME_EXPIRATION") != None)MIN_TIME_EXPIRATION = parts.get("MIN_TIME_EXPIRATION").get.mkString("").toInt
 if (parts.get("QUANTIZATION_STEP") != None)QUANTIZATION_STEP = parts.get("QUANTIZATION_STEP").get.mkString("").toInt
 if (parts.get("ALPHA") != None)ALPHA = parts.get("ALPHA").get.mkString("").toDouble
 if (parts.get("DISCOUNT") != None)DISCOUNT = parts.get("DISCOUNT").get.mkString("").toDouble
 if (parts.get("MAX_EPISODE_LEN") != None)MAX_EPISODE_LEN = parts.get("MAX_EPISODE_LEN").get.mkString("").toInt
 if (parts.get("NUM_EPISODES") != None)NUM_EPISODES = parts.get("NUM_EPISODES").get.mkString("").toInt
 if (parts.get("MIN_COVERAGE") != None)MIN_COVERAGE = parts.get("MIN_COVERAGE").get.mkString("").toDouble
 if (parts.get("NUM_NEIGHBOR_STATES") != None)NUM_NEIGHBOR_STATES = parts.get("NUM_NEIGHBOR_STATES").get.mkString("").toInt
 if (parts.get("REWARD_TYPE") != None)REWARD_TYPE = parts.get("REWARD_TYPE").get.mkString("")
            }
        ret = JsObject(Seq("STRIKE_PRICE" ->
        JsNumber(STRIKE_PRICE),"MIN_TIME_EXPIRATION" -> JsNumber(MIN_TIME_EXPIRATION),
        "QUANTIZATION_STEP" -> 
JsNumber(QUANTIZATION_STEP),
        "ALPHA" -> JsNumber(ALPHA),
        "DISCOUNT" -> JsNumber(DISCOUNT),
        "MAX_EPISODE_LEN" -> 
JsNumber(MAX_EPISODE_LEN),
        "NUM_EPISODES" -> JsNumber(NUM_EPISODES),
        "MIN_COVERAGE" -> JsNumber(MIN_COVERAGE),
        "NUM_NEIGHBOR_STATES" -> 
JsNumber(NUM_NEIGHBOR_STATES),
        "REWARD_TYPE" -> JsString(REWARD_TYPE)))
        run(REWARD_TYPE, QUANTIZATION_STEP, ALPHA, DISCOUNT)
    }
 catch {
        case e: Exception => {
            ret = ret.+("exception", JsString(e.toString))
                }
            }
       Ok(ret)
    }
}

仔细查看前面的代码,它与QLearningMain.scala文件的结构差不多。这里只有两件重要的事,如下所示:

  • 计算作为一个 Action 进行,该 Action 接收来自 UI 的输入并计算结果值

  • 然后,结果作为 JSON 对象通过JsObject()方法返回,用于在 UI 上显示(见下文)

前端

该应用由两个主要部分组成:API 端点,使用 Play 框架构建,以及前端单页面应用,使用Angular.js构建。前端应用将数据发送到 API 进行计算,然后使用chart.js展示结果。我们需要的步骤如下:

  • 初始化表单

  • 与 API 通信

  • 用覆盖数据和图表填充视图

算法的 JSON 输出应如下所示:

  • 所有的配置参数都会被返回

  • GOAL_STATE_INDEX,最大利润指数

  • COVERAGE,达到预定义目标状态的训练试验或周期的比率

  • COVERAGE_STATES,量化期权值的大小

  • COVERAGE_TRANSITIONS,状态的平方数

  • Q_VALUE,所有状态的 q 值

  • OPTIMAL,如果奖励类型不是随机的,返回最多奖励的状态

前端代码使用如下代码初始化Angular.js应用,并集成chart.js模块(见PlayML/public/assets/js/main.js文件):

angular.module("App", ['chart.js']).controller("Ctrl", ['$scope', '$http', function ($scope, $http) {
// First we initialize the form:
$scope.form = {REWARD_TYPE: "Maximum reward",NUM_NEIGHBOR_STATES: 3,STRIKE_PRICE: 190.0,MIN_TIME_EXPIRATION: 6,QUANTIZATION_STEP: 32,ALPHA: 0.2,DISCOUNT: 0.6,MAX_EPISODE_LEN: 128,NUM_EPISODES: 20,MIN_COVERAGE: 0.1
};

然后,运行按钮的操作准备表单数据并将其发送到 API,接着将返回的数据传递给结果变量,在前端使用。接下来,它会清除图表并重新创建;如果找到最优解,则初始化最优图表。最后,如果找到了 Q 值,则初始化 Q 值图表:

$scope.run = function () {
    var formData = new FormData(document.getElementById('form'));
    $http.post('/api/compute', formData, {
    headers: {'Content-Type': undefined}}).then(function successCallback(response) {
    $scope.result = response.data;
    $('#canvasContainer').html('');

    if (response.data.OPTIMAL) {
        $('#canvasContainer').append('<canvas id="optimalCanvas"></canvas>')
        Chart.Scatter(document.getElementById("optimalCanvas").getContext("2d"), {data: { datasets:             [{data: response.data.OPTIMAL}] }, options: {...}});}if (response.data.Q_VALUE) {
        $('#canvasContainer').append('<canvas id="valuesCanvas"></canvas>')
        Chart.Line(document.getElementById("valuesCanvas").getContext("2d"), {
        data: { labels: new Array(response.data.Q_VALUE.length), datasets: [{
        data: response.data.Q_VALUE }] }, options: {...}});}});}}]
    );

上述前端代码随后嵌入到 HTML 中(见PlayML/public/index.html),使 UI 能够作为一个精美的应用通过 Web 在http://localhost:9000/访问。根据您的需求,您可以随意编辑内容。我们很快会看到详细信息。

运行和部署说明

正如在第三章《从历史数据中预测高频比特币价格》中已经提到的,您需要 Java 1.8+和 SBT 作为依赖。然后按照以下说明操作:

  • 下载应用。我将代码命名为PlayML.zip

  • 解压文件后,您将得到一个文件夹ScalaML

  • 转到 PlayML 项目文件夹。

  • 运行$ sudo sbt run来下载所有依赖并运行应用。

然后可以通过http://localhost:9000/访问该应用,在这里我们可以上传 IBM 的股票和期权价格,并且提供其他配置参数:

图 17:使用 QLearning 进行期权交易的 UI

现在,如果您上传股票价格和期权价格数据并点击运行按钮,系统将生成如下图表:

图 18:QLearning 以 0.2 的覆盖率在 126 个状态和 15,750 次转换中达到了目标状态 81

另一方面,API 端点可以通过localhost:9000/api/compute进行访问。

图 19:API 端点(简化版)

模型部署

您可以通过将应用程序的 HTTP 端口设置为 9000 来轻松地将应用程序部署为独立服务器,例如:

$ /path/to/bin/<project-name> -Dhttp.port=9000

请注意,您可能需要根权限才能将进程绑定到此端口。以下是一个简短的工作流程:

  • 运行$ sudo sbt dist来构建应用程序二进制文件。输出可以在PlayML /target/universal/APP-NAME-SNAPSHOT.zip找到。在我们的案例中,它是playml-1.0.zip

  • 现在,要运行该应用程序,解压文件,然后在bin目录中运行脚本:

$ unzip APP-NAME-SNAPSHOT.zip$ APP-NAME-SNAPSHOT /bin/ APP-NAME -Dhttp.port=9000

然后,您需要配置您的 web 服务器,以映射到应用程序的端口配置。不过,您可以通过将应用程序的 HTTP 端口设置为9000,轻松将应用程序部署为独立服务器:

$ /path/to/bin/<project-name> -Dhttp.port=9000

然而,如果您打算在同一服务器上托管多个应用程序,或为了可扩展性或容错性而对多个应用程序实例进行负载均衡,您可以使用前端 HTTP 服务器。请注意,使用前端 HTTP 服务器通常不会比直接使用 Play 服务器提供更好的性能。

然而,HTTP 服务器非常擅长处理 HTTPS、条件 GET 请求和静态资源,许多服务假设前端 HTTP 服务器是您架构的一部分。更多信息可以在www.playframework.com/documentation/2.6.x/HTTPServer中找到。

总结

本章中,我们学习了如何使用 Q-learning 算法开发一个名为“期权交易”的真实应用程序。我们使用了 IBM 股票数据集来设计一个由批评和奖励驱动的机器学习系统。此外,我们还学习了一些理论背景。最后,我们学习了如何使用 Scala Play Framework 将一个 Scala 桌面应用程序打包为 Web 应用,并部署到生产环境中。

在下一章中,我们将看到使用 H2O 在银行营销数据集上构建非常稳健和准确的预测模型的两个示例。在这个例子中,我们将使用银行营销数据集。该数据与葡萄牙银行机构的电话营销活动相关。营销活动是通过电话进行的。该端到端项目的目标是预测客户是否会订阅定期存款。

第八章:使用深度神经网络评估银行电话营销客户订阅情况

本章将展示两个例子,说明如何使用 H2O 在银行营销数据集上构建非常稳健且准确的预测模型进行预测分析。数据与葡萄牙一家银行机构的直接营销活动相关,这些营销活动基于电话进行。这个端到端项目的目标是预测客户是否会订阅定期存款。

本项目将涵盖以下主题:

  • 客户订阅评估

  • 数据集描述

  • 数据集的探索性分析

  • 使用 H2O 进行客户订阅评估

  • 调整超参数

通过电话营销进行客户订阅评估

一段时间前,由于全球金融危机,银行在国际市场获得信贷的难度加大。这使得银行开始关注内部客户及其存款以筹集资金。这导致了对客户存款行为及其对银行定期电话营销活动响应的需求。通常,为了评估产品(银行定期存款)是否会被()或()订阅,需要与同一客户进行多次联系。

本项目的目的是实现一个机器学习模型,预测客户是否会订阅定期存款(变量y)。简而言之,这是一个二分类问题。在开始实现应用之前,我们需要了解数据集。接着,我们将进行数据集的解释性分析。

数据集描述

我想感谢两个数据来源。此数据集曾被 Moro 等人在论文《A Data-Driven Approach to Predict the Success of Bank Telemarketing》中使用,发表在《决策支持系统》期刊(Elsevier,2014 年 6 月)。之后,它被捐赠到 UCI 机器学习库,并可以从archive.ics.uci.edu/ml/datasets/bank+marketing下载。根据数据集描述,数据集包括四个子集:

  • bank-additional-full.csv:包含所有示例(41,188 个)和 20 个输入,按日期排序(从 2008 年 5 月到 2010 年 11 月),与 Moro 等人 2014 年分析的数据非常接近

  • bank-additional.csv:包含 10%的示例(4,119 个),从 1 和 20 个输入中随机选择

  • bank-full.csv:包含所有示例和 17 个输入,按日期排序(该数据集的旧版本,输入较少)

  • bank.csv:包含 10%的示例和 17 个输入,随机选择自三个输入(该数据集的旧版本,输入较少)

数据集包含 21 个属性。独立变量,即特征,可以进一步分类为与银行客户相关的数据(属性 1 到 7),与本次活动的最后一次联系相关的数据(属性 8 到 11),其他属性(属性 12 到 15),以及社会和经济背景属性(属性 16 到 20)。因变量由y指定,即最后一个属性(21):

ID 属性 解释
1 age 年龄(数值)。
2 job 这是工作类型的分类格式,可能的值有:adminblue-collarentrepreneurhousemaidmanagementretiredself-employedservicesstudenttechnicianunemployedunknown
3 marital 这是婚姻状况的分类格式,可能的值有:divorcedmarriedsingleunknown。其中,divorced表示离婚或丧偶。
4 education 这是教育背景的分类格式,可能的值如下:basic.4ybasic.6ybasic.9yhigh.schoolilliterateprofessional.courseuniversity.degreeunknown
5 default 这是一个分类格式,表示信用是否违约,可能的值为noyesunknown
6 housing 客户是否有住房贷款?
7 loan 个人贷款的分类格式,可能的值为noyesunknown
8 contact 这是联系的沟通方式,采用分类格式。可能的值有cellulartelephone
9 month 这是最后一次联系的月份,采用分类格式,可能的值为janfebmar、...、novdec
10 day_of_week 这是最后一次联系的星期几,采用分类格式,可能的值有montuewedthufri
11 duration 这是最后一次联系的持续时间,单位为秒(数值)。这个属性对输出目标有很大影响(例如,如果duration=0,则y=no)。然而,持续时间在通话之前是未知的。此外,通话结束后,y显然已知。因此,只有在基准测试时才应包括此输入,如果目的是建立一个现实的预测模型,则应丢弃此输入。
12 campaign 这是本次活动中与此客户进行的联系次数。
13 pdays 这是自上次与客户的前一个活动联系以来经过的天数(数值;999 表示客户之前没有被联系过)。
14 previous 这是此客户在本次活动之前进行的联系次数(数值)。
15 poutcome 上一次营销活动的结果(分类:failurenonexistentsuccess)。
16 emp.var.rate 就业变化率—季度指标(数值)。
17 cons.price.idx 消费者价格指数—月度指标(数字)。
18 cons.conf.idx 消费者信心指数—月度指标(数字)。
19 euribor3m 欧元区 3 个月利率—每日指标(数字)。
20 nr.employed 员工人数—季度指标(数字)。
21 y 表示客户是否订阅了定期存款。其值为二进制(yesno)。

表 1:银行营销数据集描述

对于数据集的探索性分析,我们将使用 Apache Zeppelin 和 Spark。我们将首先可视化分类特征的分布,然后是数值特征。最后,我们将计算一些描述数值特征的统计信息。但在此之前,让我们配置 Zeppelin。

安装并开始使用 Apache Zeppelin

Apache Zeppelin 是一个基于 Web 的笔记本,允许您以交互方式进行数据分析。使用 Zeppelin,您可以制作美丽的、数据驱动的、互动的和协作的文档,支持 SQL、Scala 等。Apache Zeppelin 的解释器概念允许将任何语言/数据处理后端插件集成到 Zeppelin 中。目前,Apache Zeppelin 支持许多解释器,如 Apache Spark、Python、JDBC、Markdown 和 Shell。

Apache Zeppelin 是 Apache 软件基金会推出的一项相对较新的技术,它使数据科学家、工程师和从业者能够进行数据探索、可视化、共享和协作,支持多种编程语言的后端(如 Python、Scala、Hive、SparkSQL、Shell、Markdown 等)。由于本书的目标不是使用其他解释器,因此我们将在 Zeppelin 上使用 Spark,所有代码将使用 Scala 编写。因此,在本节中,我们将向您展示如何使用仅包含 Spark 解释器的二进制包配置 Zeppelin。Apache Zeppelin 官方支持并在以下环境中经过测试:

要求 值/版本
Oracle JDK 1.7+(设置 JAVA_HOME

| 操作系统 | Mac OS X Ubuntu 14.X+

CentOS 6.X+

Windows 7 Pro SP1+ |

如上表所示,执行 Spark 代码需要 Java。因此,如果未设置 Java,请在前述任何平台上安装并配置 Java。可以从 zeppelin.apache.org/download.html 下载 Apache Zeppelin 的最新版本。每个版本都有三种选项:

  • 包含所有解释器的二进制包:包含对多个解释器的支持。例如,Zeppelin 目前支持 Spark、JDBC、Pig、Beam、Scio、BigQuery、Python、Livy、HDFS、Alluxio、Hbase、Scalding、Elasticsearch、Angular、Markdown、Shell、Flink、Hive、Tajo、Cassandra、Geode、Ignite、Kylin、Lens、Phoenix 和 PostgreSQL 等。

  • 包含 Spark 解释器的二进制包:通常,这仅包含 Spark 解释器。它还包含一个解释器网络安装脚本。

  • Source:你也可以从 GitHub 仓库构建带有所有最新更改的 Zeppelin(稍后详细讲解)。为了向你展示如何安装和配置 Zeppelin,我们从此网站的镜像下载了二进制包。下载后,将其解压到你的机器上的某个位置。假设你解压的路径是/home/Zeppelin/

从源代码构建

你还可以从 GitHub 仓库构建带有所有最新更改的 Zeppelin。如果你想从源代码构建,必须首先安装以下依赖项:

  • Git:任何版本

  • Maven:3.1.x 或更高版本

  • JDK:1.7 或更高版本

如果你还没有安装 Git 和 Maven,可以查看zeppelin.apache.org/docs/latest/install/build.html#build-requirements中的构建要求。由于页面限制,我们没有详细讨论所有步骤。感兴趣的读者应参考此 URL,获取更多关于 Apache Zeppelin 的信息:zeppelin.apache.org/

启动和停止 Apache Zeppelin

在所有类 Unix 平台(如 Ubuntu、Mac 等)上,使用以下命令:

$ bin/zeppelin-daemon.sh start

如果前面的命令成功执行,你应该在终端中看到以下日志:

图 1:从 Ubuntu 终端启动 Zeppelin

如果你使用 Windows,使用以下命令:

$ binzeppelin.cmd 

在 Zeppelin 成功启动后,使用你的网页浏览器访问http://localhost:8080,你将看到 Zeppelin 正在运行。更具体地说,你将在浏览器中看到以下内容:

图 2:Zeppelin 正在http://localhost:8080上运行

恭喜!你已经成功安装了 Apache Zeppelin!现在,让我们在浏览器中访问 Zeppelin,地址是http://localhost:8080/,并在配置好首选解释器后开始我们的数据分析。现在,要从命令行停止 Zeppelin,请执行以下命令:

$ bin/zeppelin-daemon.sh stop

创建笔记本

一旦你进入http://localhost:8080/,你可以探索不同的选项和菜单,帮助你了解如何熟悉 Zeppelin。有关 Zeppelin 及其用户友好界面的更多信息,感兴趣的读者可以参考zeppelin.apache.org/docs/latest/。现在,首先让我们创建一个示例笔记本并开始使用。如下图所示,你可以通过点击图 2中的“Create new note”选项来创建一个新的笔记本:

图 3:创建示例 Zeppelin 笔记本

图 3所示,默认解释器被选为 Spark。在下拉列表中,你只会看到 Spark,因为你已下载了仅包含 Spark 的 Zeppelin 二进制包。

数据集的探索性分析

做得好!我们已经能够安装、配置并开始使用 Zeppelin。现在我们开始吧。我们将看到变量与标签之间的关联。首先,我们在 Apache 中加载数据集,如下所示:

val trainDF = spark.read.option("inferSchema", "true")
            .format("com.databricks.spark.csv")
            .option("delimiter", ";")
            .option("header", "true")
            .load("data/bank-additional-full.csv")
trainDF.registerTempTable("trainData")

标签分布

我们来看看类别分布。我们将使用 SQL 解释器来进行此操作。在 Zeppelin 笔记本中执行以下 SQL 查询:

%sql select y, count(1) from trainData group by y order by y
>>>

职业分布

现在我们来看看职位名称是否与订阅决策相关:

%sql select job,y, count(1) from trainData group by job, y order by job, y

从图表中可以看到,大多数客户的职位是行政人员、蓝领工人或技术员,而学生和退休客户的计数(y) / 计数(n)比率最高。

婚姻分布

婚姻状况与订阅决策有关吗?让我们看看:

%sql select marital,y, count(1) from trainData group by marital,y order by marital,y
>>>

分布显示,订阅与实例数量成比例,而与客户的婚姻状况无关。

教育分布

现在我们来看看教育水平是否与订阅决策有关:

%sql select education,y, count(1) from trainData group by education,y order by education,y

因此,与婚姻状况类似,教育水平并不能揭示关于订阅的任何线索。现在我们继续探索其他变量。

默认分布

我们来检查默认信用是否与订阅决策相关:

%sql select default,y, count(1) from trainData group by default,y order by default,y

该图表显示几乎没有客户有默认信用,而没有默认信用的客户有轻微的订阅比率。

住房分布

现在我们来看看是否拥有住房与订阅决策之间有趣的关联:

%sql select housing,y, count(1) from trainData group by housing,y order by housing,y

以上图表显示住房也不能揭示关于订阅的线索。

贷款分布

现在我们来看看贷款分布:

%sql select loan,y, count(1) from trainData group by loan,y order by loan,y

图表显示大多数客户没有个人贷款,贷款对订阅比率没有影响。

联系方式分布

现在我们来检查联系方式是否与订阅决策有显著关联:

%sql select contact,y, count(1) from trainData group by contact,y order by contact,y

月份分布

这可能听起来有些奇怪,但电话营销的月份与订阅决策可能有显著的关联:

%sql select month,y, count(1) from trainData group by month,y order by month,y

所以,之前的图表显示,在实例较少的月份(例如 12 月、3 月、10 月和 9 月)中,订阅比率最高。

日期分布

现在,星期几与订阅决策之间有何关联:

%sql select day_of_week,y, count(1) from trainData group by day_of_week,y order by day_of_week,y

日期特征呈均匀分布,因此不那么显著。

之前的结果分布

那么,先前的结果及其与订阅决策的关联情况如何呢:

%sql select poutcome,y, count(1) from trainData group by poutcome,y order by poutcome,y

分布显示,来自上次营销活动的成功结果的客户最有可能订阅。同时,这些客户代表了数据集中的少数。

年龄特征

让我们看看年龄与订阅决策的关系:

%sql select age,y, count(1) from trainData group by age,y order by age,y

标准化图表显示,大多数客户的年龄在2560岁之间。

以下图表显示,银行在年龄区间(25, 60)内的客户有较高的订阅率。

持续时间分布

现在让我们来看看通话时长与订阅之间的关系:

%sql select duration,y, count(1) from trainData group by duration,y order by duration,y

图表显示,大多数通话时间较短,并且订阅率与通话时长成正比。扩展版提供了更深入的见解:

活动分布

现在我们来看一下活动分布与订阅之间的相关性:

%sql select campaign, count(1), y from trainData group by campaign,y order by campaign,y

图表显示,大多数客户的联系次数少于五次,而客户被联系的次数越多,他们订阅的可能性就越低。现在,扩展版提供了更深入的见解:

Pdays 分布

现在让我们来看看 pdays 分布与订阅之间的关系:

%sql select pdays, count(1), y from trainData group by pdays,y order by pdays,y

图表显示,大多数客户此前没有被联系过。

先前分布

在以下命令中,我们可以看到之前的分布如何影响订阅:

%sql select previous, count(1), y from trainData group by previous,y order by previous,y

与之前的图表类似,这张图表确认大多数客户在此次活动前没有被联系过。

emp_var_rate 分布

以下命令显示了 emp_var_rate 分布与订阅之间的相关性:

%sql select emp_var_rate, count(1), y from trainData group by emp_var_rate,y order by emp_var_rate,y

图表显示,雇佣变动率较少见的客户更可能订阅。现在,扩展版提供了更深入的见解:

cons_price_idx 特征

con_price_idx 特征与订阅之间的相关性可以通过以下命令计算:

%sql select cons_price_idx, count(1), y from trainData group by cons_price_idx,y order by cons_price_idx,y

图表显示,消费者价格指数较少见的客户相比其他客户更有可能订阅。现在,扩展版提供了更深入的见解:

cons_conf_idx 分布

cons_conf_idx 分布与订阅之间的相关性可以通过以下命令计算:

%sql select cons_conf_idx, count(1), y from trainData group by cons_conf_idx,y order by cons_conf_idx,y

消费者信心指数较少见的客户相比其他客户更有可能订阅。

Euribor3m 分布

让我们看看euribor3m的分布与订阅之间的相关性:

%sql select euribor3m, count(1), y from trainData group by euribor3m,y order by euribor3m,y

该图表显示,euribor 三个月期利率的范围较大,大多数客户聚集在该特征的四个或五个值附近。

nr_employed 分布

nr_employed分布与订阅的相关性可以通过以下命令查看:

%sql select nr_employed, count(1), y from trainData group by nr_employed,y order by nr_employed,y

图表显示,订阅率与员工数量呈反比。

数值特征统计

现在,我们来看一下数值特征的统计数据:

import org.apache.spark.sql.types._

val numericFeatures = trainDF.schema.filter(_.dataType != StringType)
val description = trainDF.describe(numericFeatures.map(_.name): _*)

val quantils = numericFeatures
                .map(f=>trainDF.stat.approxQuantile(f.name,                 
                Array(.25,.5,.75),0)).transposeval 

rowSeq = Seq(Seq("q1"+:quantils(0): _*),
            Seq("median"+:quantils(1): _*),
            Seq("q3"+:quantils(2): _*))

val rows = rowSeq.map(s=> s match{ 
    case Seq(a:String,b:Double,c:Double,d:Double,
             e:Double,f:Double,g:Double,                                              
             h:Double,i:Double,j:Double,k:Double)=> (a,b,c,d,e,f,g,h,i,j,k)})
         val allStats = description.unionAll(sc.parallelize(rows).toDF)
         allStats.registerTempTable("allStats")

%sql select * from allStats
>>>
summary age duration campaign pdays previous
count 41188.00 41188.00 41188.00 41188.00 41188.00
mean 40.02 258.29 2.57 962.48 0.17
stddev 10.42 259.28 2.77 186.91 0.49
min 17.00 0.00 1.00 0.00 0.00
max 98.00 4918.00 56.00 999.00 7.00
q1 32.00 102.00 1.00 999.00 0.00
median 38.00 180.00 2.00 999.00 0.00
q3 47.00 319.00 3.00 999.00 0.00
summary emp_var_rate cons_price_idx cons_conf_idx euribor3m nr_employed
count 41188.00 41188.00 41188.00 41188.00 41188.00
mean 0.08 93.58 -40.50 3.62 5167.04
stddev 1.57 0.58 4.63 1.73 72.25
min -3.40 92.20 -50.80 0.63 4963.60
max 1.40 94.77 -26.90 5.05 5228.10
q1 -1.80 93.08 -42.70 1.34 5099.10
median 1.10 93.75 -41.80 4.86 5191.00
q3 1.40 93.99 -36.40 4.96 5228.10

实现客户订阅评估模型

为了预测客户订阅评估,我们使用 H2O 中的深度学习分类器实现。首先,我们设置并创建一个 Spark 会话:

val spark = SparkSession.builder
        .master("local[*]")
        .config("spark.sql.warehouse.dir", "E:/Exp/") // change accordingly
        .appName(s"OneVsRestExample")
        .getOrCreate()

然后我们将数据集加载为数据框:

spark.sqlContext.setConf("spark.sql.caseSensitive", "false");
val trainDF = spark.read.option("inferSchema","true")
            .format("com.databricks.spark.csv")
            .option("delimiter", ";")
            .option("header", "true")
            .load("data/bank-additional-full.csv")

尽管这个数据集中包含了分类特征,但由于这些分类特征的域较小,因此无需使用StringIndexer。如果将其索引,会引入一个并不存在的顺序关系。因此,更好的解决方案是使用 One Hot Encoding,事实证明,H2O 默认使用此编码策略处理枚举。

在数据集描述中,我已经说明了duration特征只有在标签已知后才可用,因此不能用于预测。因此,在调用客户之前,我们应该丢弃它作为不可用的特征:

val withoutDuration = trainDF.drop("duration")

到目前为止,我们已经使用 Spark 的内置方法加载了数据集并删除了不需要的特征,但现在我们需要设置h2o并导入其隐式功能:

implicit val h2oContext = H2OContext.getOrCreate(spark.sparkContext)
import h2oContext.implicits._implicit 

val sqlContext = SparkSession.builder().getOrCreate().sqlContext
import sqlContext.implicits._

然后我们将训练数据集打乱,并将其转换为 H2O 框架:

val H2ODF: H2OFrame = withoutDuration.orderBy(rand())

字符串特征随后被转换为分类特征("2 Byte"类型表示 H2O 中的字符串类型):

H2ODF.types.zipWithIndex.foreach(c=> if(c._1.toInt== 2) toCategorical(H2ODF,c._2))

在前面的代码行中,toCategorical()是一个用户定义的函数,用于将字符串特征转换为类别特征。以下是该方法的签名:

def toCategorical(f: Frame, i: Int): Unit = {f.replace(i,f.vec(i).toCategoricalVec)f.update()}

现在是时候将数据集分为 60%的训练集、20%的验证集和 20%的测试集:

val sf = new FrameSplitter(H2ODF, Array(0.6, 0.2), 
                            Array("train.hex", "valid.hex", "test.hex")
                            .map(Key.makeFrame), null)

water.H2O.submitTask(sf)
val splits = sf.getResultval (train, valid, test) = (splits(0), splits(1), splits(2))

然后我们使用训练集训练深度学习模型,并使用验证集验证训练,具体如下:

val dlModel = buildDLModel(train, valid)

在前面的代码行中,buildDLModel()是一个用户定义的函数,用于设置深度学习模型并使用训练和验证数据框架进行训练:

def buildDLModel(train: Frame, valid: Frame,epochs: Int = 10, 
                l1: Double = 0.001,l2: Double = 0.0,
                hidden: Array[Int] = ArrayInt
               )(implicit h2oContext: H2OContext): 
     DeepLearningModel = {import h2oContext.implicits._
                // Build a model
    val dlParams = new DeepLearningParameters()
        dlParams._train = traindlParams._valid = valid
        dlParams._response_column = "y"
        dlParams._epochs = epochsdlParams._l1 = l2
        dlParams._hidden = hidden

    val dl = new DeepLearning(dlParams, water.Key.make("dlModel.hex"))
    dl.trainModel.get
    }

在这段代码中,我们实例化了一个具有三层隐藏层的深度学习(即 MLP)网络,L1 正则化,并且仅计划迭代训练 10 次。请注意,这些是超参数,尚未调优。因此,您可以自由更改这些参数并查看性能,以获得一组最优化的参数。训练阶段完成后,我们打印训练指标(即 AUC):

val auc = dlModel.auc()println("Train AUC: "+auc)
println("Train classification error" + dlModel.classification_error())
>>>
Train AUC: 0.8071186909427446
Train classification error: 0.13293674881631662

大约 81%的准确率看起来并不好。现在我们在测试集上评估模型。我们预测测试数据集的标签:

val result = dlModel.score(test)('predict)

然后我们将原始标签添加到结果中:

result.add("actual",test.vec("y"))

将结果转换为 Spark DataFrame 并打印混淆矩阵:

val predict_actualDF = h2oContext.asDataFrame(result)predict_actualDF.groupBy("actual","predict").count.show
>>>

现在,前面的混淆矩阵可以通过以下图表在 Vegas 中表示:

Vegas().withDataFrame(predict_actualDF)
    .mark(Bar)
     .encodeY(field="*", dataType=Quantitative, AggOps.Count, axis=Axis(title="",format=".2f"),hideAxis=true)
    .encodeX("actual", Ord)
    .encodeColor("predict", Nominal, scale=Scale(rangeNominals=List("#FF2800", "#1C39BB")))
    .configMark(stacked=StackOffset.Normalize)
    .show()
>>>

图 4:混淆矩阵的图形表示——归一化(左)与未归一化(右)

现在让我们看看测试集上的整体性能摘要——即测试 AUC:

val trainMetrics = ModelMetricsSupport.modelMetricsModelMetricsBinomialprintln(trainMetrics)
>>>

所以,AUC 测试准确率为 76%,这并不是特别好。但为什么我们不再迭代训练更多次(比如 1000 次)呢?嗯,这个问题留给你去决定。但我们仍然可以直观地检查精确度-召回率曲线,以看看评估阶段的情况:

val auc = trainMetrics._auc//tp,fp,tn,fn
val metrics = auc._tps.zip(auc._fps).zipWithIndex.map(x => x match { 
    case ((a, b), c) => (a, b, c) })

val fullmetrics = metrics.map(_ match { 
    case (a, b, c) => (a, b, auc.tn(c), auc.fn(c)) })

val precisions = fullmetrics.map(_ match {
     case (tp, fp, tn, fn) => tp / (tp + fp) })

val recalls = fullmetrics.map(_ match { 
    case (tp, fp, tn, fn) => tp / (tp + fn) })

val rows = for (i <- 0 until recalls.length) 
    yield r(precisions(i), recalls(i))

val precision_recall = rows.toDF()

//precision vs recall
Vegas("ROC", width = 800, height = 600)
    .withDataFrame(precision_recall).mark(Line)
    .encodeX("re-call", Quantitative)
    .encodeY("precision", Quantitative)
    .show()
>>>

图 5:精确度-召回率曲线

然后我们计算并绘制敏感度特异度曲线:

val sensitivity = fullmetrics.map(_ match { 
    case (tp, fp, tn, fn) => tp / (tp + fn) })

val specificity = fullmetrics.map(_ match {
    case (tp, fp, tn, fn) => tn / (tn + fp) })
val rows2 = for (i <- 0 until specificity.length) 
    yield r2(sensitivity(i), specificity(i))
val sensitivity_specificity = rows2.toDF

Vegas("sensitivity_specificity", width = 800, height = 600)
    .withDataFrame(sensitivity_specificity).mark(Line)
    .encodeX("specificity", Quantitative)
    .encodeY("sensitivity", Quantitative).show()
>>>

图 6:敏感度特异度曲线

现在,敏感度特异度曲线告诉我们正确预测的类别与两个标签之间的关系。例如,如果我们正确预测了 100%的欺诈案例,那么就不会有正确分类的非欺诈案例,反之亦然。最后,从另一个角度仔细观察这个问题,手动遍历不同的预测阈值,计算在两个类别中正确分类的案例数量,将会非常有益。

更具体地说,我们可以通过不同的预测阈值(例如0.01.0)来直观检查真正例、假正例、真负例和假负例:

val withTh = auc._tps.zip(auc._fps).zipWithIndex.map(x => x match {
    case ((a, b), c) => (a, b, auc.tn(c), auc.fn(c), auc._ths(c)) })

val rows3 = for (i <- 0 until withTh.length) 
    yield r3(withTh(i)._1, withTh(i)._2, withTh(i)._3, withTh(i)._4, withTh(i)._5)

首先,让我们绘制真正例:

Vegas("tp", width = 800, height = 600).withDataFrame(rows3.toDF)
    .mark(Line).encodeX("th", Quantitative)
    .encodeY("tp", Quantitative)
    .show
>>>

图 7:在[0.0, 1.0]之间不同预测阈值下的真正例

第二步,让我们绘制假阳性:

Vegas("fp", width = 800, height = 600)
    .withDataFrame(rows3.toDF).mark(Line)
    .encodeX("th", Quantitative)
    .encodeY("fp", Quantitative)
    .show
>>>

图 8:在[0.0, 1.0]范围内,不同预测阈值下的假阳性

接下来是正确的负类:

Vegas("tn", width = 800, height = 600)
    .withDataFrame(rows3.toDF).mark(Line)
    .encodeX("th", Quantitative)
    .encodeY("tn", Quantitative)
    .show
>>>

图 9:在[0.0, 1.0]范围内,不同预测阈值下的假阳性

最后,让我们绘制假阴性:

Vegas("fn", width = 800, height = 600)
    .withDataFrame(rows3.toDF).mark(Line)
    .encodeX("th", Quantitative)
    .encodeY("fn", Quantitative)
    .show
>>>

图 10:在[0.0, 1.0]范围内,不同预测阈值下的假阳性

因此,前面的图表告诉我们,当我们将预测阈值从默认的0.5提高到0.6时,可以在不丢失正确分类的欺诈案例的情况下,增加正确分类的非欺诈案例数量。

除了这两种辅助方法外,我还定义了三个 Scala case 类来计算precisionrecallsensitivityspecificity、真正例(tp)、真负例(tn)、假阳性(fp)、假阴性(fn)等。其签名如下:

case class r(precision: Double, recall: Double)
case class r2(sensitivity: Double, specificity: Double)
case class r3(tp: Double, fp: Double, tn: Double, fn: Double, th: Double)

最后,停止 Spark 会话和 H2O 上下文。stop()方法调用将分别关闭 H2O 上下文和 Spark 集群:

h2oContext.stop(stopSparkContext = true)
spark.stop()

第一个尤其重要;否则,有时它并不会停止 H2O 流,但仍会占用计算资源。

超参数调优和特征选择

神经网络的灵活性也是它们的主要缺点之一:有许多超参数需要调整。即使在一个简单的 MLP 中,你也可以更改层数、每层的神经元数量、每层使用的激活函数类型、训练轮次、学习率、权重初始化逻辑、丢弃保持概率等。那么,如何知道哪种超参数组合最适合你的任务呢?

当然,你可以使用网格搜索结合交叉验证来为线性机器学习模型寻找合适的超参数,但对于深度学习模型来说,有很多超参数需要调优。而且,由于在大数据集上训练神经网络需要大量时间,你只能在合理的时间内探索超参数空间的一小部分。以下是一些有用的见解。

隐藏层数量

对于许多问题,你可以从一两个隐藏层开始,使用两个隐藏层并保持相同的神经元总数,训练时间大致相同,效果也很好。对于更复杂的问题,你可以逐渐增加隐藏层的数量,直到开始出现过拟合。非常复杂的任务,如大规模图像分类或语音识别,通常需要几十层的网络,并且需要大量的训练数据。

每个隐藏层的神经元数量

显然,输入层和输出层的神经元数量是由任务所需的输入和输出类型决定的。例如,如果你的数据集形状为 28 x 28,那么它的输入神经元数量应该是 784,输出神经元数量应等于要预测的类别数。

在这个项目中,我们通过下一个使用 MLP 的示例,展示了它在实践中的运作方式,我们设置了 256 个神经元,每个隐藏层 4 个;这只是一个需要调节的超参数,而不是每层一个。就像层数一样,你可以逐渐增加神经元的数量,直到网络开始过拟合。

激活函数

在大多数情况下,你可以在隐藏层使用 ReLU 激活函数。它比其他激活函数计算速度更快,而且与逻辑函数或双曲正切函数相比,梯度下降在平坦区域更不容易停滞,因为后者通常在 1 处饱和。

对于输出层,softmax 激活函数通常是分类任务的不错选择。对于回归任务,你可以简单地不使用任何激活函数。其他激活函数包括 Sigmoid 和 Tanh。当前基于 H2O 的深度学习模型支持以下激活函数:

  • 指数线性整流器(ExpRectifier)

  • 带 Dropout 的指数线性整流器(ExpRectifierWithDropout)

  • Maxout

  • 带 Dropout 的 Maxout(MaxoutWithDropout)

  • 线性整流器(Rectifier)

  • 带 Dropout 的线性整流器(RectifierWthDropout)

  • Tanh

  • 带 Dropout 的 Tanh(TanhWithDropout)

除了 Tanh(H2O 中的默认函数),我没有尝试过其他激活函数用于这个项目。然而,你应该肯定尝试其他的。

权重和偏置初始化

初始化隐藏层的权重和偏置是需要注意的一个重要超参数:

  • 不要进行全零初始化:一个看似合理的想法是将所有初始权重设置为零,但实际上并不可行,因为如果网络中的每个神经元计算相同的输出,那么它们的权重初始化为相同的值时,就不会有神经元之间的对称性破坏。

  • 小随机数:也可以将神经元的权重初始化为小数值,而不是完全为零。或者,也可以使用从均匀分布中抽取的小数字。

  • 初始化偏置:将偏置初始化为零是可能的,且很常见,因为破坏对称性是通过权重中的小随机数来完成的。将偏置初始化为一个小常数值,例如将所有偏置设为 0.01,确保所有 ReLU 单元能够传播梯度。然而,这种做法既不能很好地执行,也没有持续的改进效果。因此,推荐将偏置设为零。

正则化

有几种方法可以控制神经网络的训练,防止在训练阶段过拟合,例如 L2/L1 正则化、最大范数约束和 Dropout:

  • L2 正则化:这可能是最常见的正则化形式。通过梯度下降参数更新,L2 正则化意味着每个权重都会线性衰减到零。

  • L1 正则化:对于每个权重w,我们将项λ∣w∣添加到目标函数中。然而,也可以结合 L1 和 L2 正则化以实现弹性网正则化。

  • 最大范数约束:用于对每个隐藏层神经元的权重向量的大小施加绝对上限。然后,可以使用投影梯度下降进一步强制执行该约束。

  • Dropout(丢弃法):在使用神经网络时,我们需要另一个占位符用于丢弃法,这是一个需要调优的超参数,它仅影响训练时间而非测试时间。其实现方式是通过以某种概率(假设为p<1.0)保持一个神经元活跃,否则将其设置为零。其理念是在测试时使用一个没有丢弃法的神经网络。该网络的权重是经过训练的权重的缩小版。如果在训练过程中一个单元在dropout_keep_prob < 1.0时被保留,那么该单元的输出权重在测试时会乘以p图 17)。

除了这些超参数,使用基于 H2O 的深度学习算法的另一个优点是我们可以得到相对变量/特征的重要性。在之前的章节中,我们看到通过在 Spark 中使用随机森林算法,也可以计算变量重要性。

所以,基本思想是,如果你的模型表现不佳,去掉不太重要的特征然后重新训练可能会有所帮助。现在,在监督学习过程中是可以找到特征重要性的。我观察到的特征重要性如下:

图 25:相对变量重要性

现在问题是:为什么不去掉它们,再次训练看看准确性是否有所提高?嗯,我将这个问题留给读者自己思考。

摘要

在本章中,我们展示了如何使用 H2O 在银行营销数据集上开发一个机器学习ML)项目来进行预测分析。我们能够预测客户是否会订阅定期存款,准确率达到 80%。此外,我们还展示了如何调优典型的神经网络超参数。考虑到这是一个小规模数据集,最终的改进建议是使用基于 Spark 的随机森林、决策树或梯度提升树来提高准确性。

在下一章中,我们将使用一个包含超过 284,807 个信用卡使用实例的数据集,其中只有 0.172%的交易是欺诈的——也就是说,这是一个高度不平衡的数据集。因此,使用自编码器预训练一个分类模型并应用异常检测来预测可能的欺诈交易是有意义的——也就是说,我们预期我们的欺诈案件将是整个数据集中的异常。

第九章:使用自编码器和异常检测进行欺诈分析

在金融公司,如银行、保险公司和信用合作社,检测和防止欺诈是一个重要任务,这对于业务的增长至关重要。到目前为止,在上一章中,我们已经学习了如何使用经典的有监督机器学习模型;现在是时候使用其他的无监督学习算法,比如自编码器。

在本章中,我们将使用一个包含超过 284,807 个信用卡使用实例的数据集,其中只有 0.172%的交易是欺诈交易。因此,这是一个高度不平衡的数据。因此,使用自编码器来预训练分类模型并应用异常检测技术以预测可能的欺诈交易是有意义的;也就是说,我们预计欺诈案件将在整个数据集中表现为异常。

总结来说,通过这个端到端项目,我们将学习以下主题:

  • 使用异常值进行异常检测

  • 在无监督学习中使用自编码器

  • 开发一个欺诈分析预测模型

  • 超参数调优,最重要的是特征选择

异常值和异常检测

异常是观察世界中不寻常和意外的模式。因此,分析、识别、理解和预测从已知和未知数据中的异常是数据挖掘中最重要的任务之一。因此,检测异常可以从数据中提取关键信息,这些信息随后可以用于许多应用。

虽然异常是一个广泛接受的术语,但在不同的应用领域中,通常会使用其他同义词,如异常值、背离观察、例外、偏差、惊讶、特异性或污染物。特别是,异常和异常值常常可以互换使用。异常检测在信用卡、保险或医疗保健的欺诈检测、网络安全的入侵检测、安全关键系统的故障检测以及敌方活动的军事监视等领域中得到了广泛应用。

异常检测的重要性源于这样一个事实:在许多应用领域,数据中的异常通常转化为具有重要可操作性的信息。当我们开始探索一个高度不平衡的数据集时,可以使用峰度对数据集进行三种可能的解释。因此,在应用特征工程之前,以下问题需要通过数据探索来回答和理解:

  • 所有可用字段中,数据中存在或不存在空值或缺失值的比例是多少?然后,尝试处理这些缺失值,并在不丢失数据语义的情况下很好地解释它们。

  • 各个字段之间的相关性是什么?每个字段与预测变量之间的相关性是什么?它们取什么值(即,分类的或非分类的、数值的或字母数字的,等等)?

然后找出数据分布是否有偏。你可以通过查看异常值或长尾来识别偏度(如图 1 所示,可能是稍微偏向右侧或正偏,稍微偏向左侧或负偏)。现在确定异常值是否有助于预测。更准确地说,你的数据具有以下三种可能的峰度之一:

  • 如果峰度值小于但接近 3,则为正态峰态(Mesokurtic)

  • 如果峰度值大于 3,则为高峰态(Leptokurtic)

  • 如果峰度值小于 3,则为低峰态(Platykurtic)

图 1:不平衡数据集中不同类型的偏度

让我们举个例子。假设你对健身步行感兴趣,在过去四周内(不包括周末),你曾在运动场或乡间步行。你花费的时间如下(完成 4 公里步行赛道所需的分钟数):15,16,18,17.16,16.5,18.6,19.0,20.4,20.6,25.15,27.27,25.24,21.05,21.65,20.92,22.61,23.71,35,39,50。使用 R 计算并解释这些值的偏度和峰度,将生成如下的密度图。

图 2中关于数据分布(运动时间)的解释显示,密度图右偏,因此是高峰态(leptokurtic)。因此,位于最右端的数据点可以被视为在我们使用场景中不寻常或可疑。因此,我们可以考虑识别或移除它们以使数据集平衡。然而,这不是该项目的目的,目的仅仅是进行识别。

图 2:运动时间的直方图(右偏)

然而,通过去除长尾,我们不能完全去除不平衡问题。还有另一种方法叫做异常值检测,去除这些数据点可能会有帮助。

此外,我们还可以查看每个单独特征的箱型图。箱型图根据五数概括显示数据分布:最小值第一个四分位数、中位数、第三个四分位数最大值,如图 3所示,我们可以通过查看超出三倍四分位距(IQR)的异常值来判断:

图 3:超出三倍四分位距(IQR)的异常值

因此,探索去除长尾是否能为监督学习或无监督学习提供更好的预测是有用的。但对于这个高度不平衡的数据集,暂时没有明确的建议。简而言之,偏度分析在这方面对我们没有帮助。

最后,如果你发现你的模型无法提供完美的分类,但均方误差MSE)可以为你提供一些线索,帮助识别异常值或异常数据。例如,在我们的案例中,即使我们投影的模型不能将数据集分为欺诈和非欺诈案例,欺诈交易的均方误差(MSE)肯定高于常规交易。所以,即使听起来有些天真,我们仍然可以通过应用 MSE 阈值来识别异常值。例如,我们可以认为 MSE > 0.02 的实例是异常值/离群点。

那么问题是,我们该如何做到这一点呢?通过这个端到端的项目,我们将看到如何使用自编码器和异常检测。我们还将看到如何使用自编码器来预训练一个分类模型。最后,我们将看到如何在不平衡数据上衡量模型的表现。让我们从了解自编码器开始。

自编码器和无监督学习

自编码器是能够在没有任何监督的情况下(即训练集没有标签)学习输入数据高效表示的人工神经网络。这种编码通常具有比输入数据更低的维度,使得自编码器在降维中非常有用。更重要的是,自编码器充当强大的特征检测器,可以用于深度神经网络的无监督预训练。

自编码器的工作原理

自编码器是一个包含三层或更多层的网络,其中输入层和输出层具有相同数量的神经元,而中间(隐藏)层的神经元数量较少。该网络的训练目标是仅仅将每个输入数据的输入模式在输出中再现。问题的显著特点是,由于隐藏层中的神经元数量较少,如果网络能够从示例中学习,并在可接受的范围内进行概括,它将执行数据压缩:隐藏神经元的状态为每个示例提供了输入和输出公共状态的压缩版本。

问题的一个显著特点是,由于隐藏层中的神经元数量较少,如果网络能够从示例中学习,并在可接受的范围内进行概括,它将执行数据压缩:隐藏神经元的状态为每个示例提供了压缩版本输入输出公共状态。自编码器的有用应用包括数据去噪数据可视化的降维

以下图示展示了自编码器的典型工作原理。它通过两个阶段来重建接收到的输入:一个编码阶段,它对应于原始输入的维度缩减,一个解码阶段,它能够从编码(压缩)表示中重建原始输入:

图 4:自编码器中的编码器和解码器阶段

作为一种无监督神经网络,自编码器的主要特点在于其对称结构。自编码器有两个组成部分:一个编码器将输入转换为内部表示,接着是一个解码器将内部表示转换回输出。换句话说,自编码器可以看作是编码器和解码器的组合,其中编码器将输入编码为代码,而解码器则将代码解码/重建为原始输入作为输出。因此,多层感知机MLP)通常具有与自编码器相同的结构,除了输出层中的神经元数量必须等于输入数量。

如前所述,训练自编码器的方式不止一种。第一种方法是一次性训练整个层,类似于多层感知机(MLP)。不过,与使用一些标记输出计算代价函数(如监督学习中一样)不同的是,我们使用输入本身。因此,代价函数显示实际输入与重构输入之间的差异。

第二种方法是通过贪心训练逐层进行。这种训练实现源自于监督学习中反向传播方法所带来的问题(例如,分类)。在具有大量层的网络中,反向传播方法在梯度计算中变得非常缓慢和不准确。为了解决这个问题,Geoffrey Hinton 应用了一些预训练方法来初始化分类权重,而这种预训练方法是一次对两个相邻层进行的。

高效的数据表示与自编码器

所有监督学习系统面临的一个大问题是所谓的维度诅咒:随着输入空间维度的增加,性能逐渐下降。这是因为为了充分采样输入空间,所需的样本数随着维度的增加呈指数级增长。为了解决这些问题,已经开发出一些优化网络。

第一类是自编码器网络:这些网络被设计和训练用来将输入模式转化为其自身,以便在输入模式的降级或不完整版本出现时,能够恢复原始模式。网络经过训练,可以生成与输入相似的输出数据,而隐藏层则存储压缩后的数据,也就是捕捉输入数据基本特征的紧凑表示。

第二类优化网络是玻尔兹曼机:这类网络由一个输入/输出可见层和一个隐藏层组成。可见层和隐藏层之间的连接是无方向的:数据可以双向流动,即从可见层到隐藏层,或从隐藏层到可见层,不同的神经元单元可以是完全连接的或部分连接的。

让我们看一个例子。决定以下哪个序列你认为更容易记住:

  • 45, 13, 37, 11, 23, 90, 79, 24, 87, 47

  • 50, 25, 76, 38, 19, 58, 29, 88, 44, 22, 11, 34, 17, 52, 26, 13, 40, 20

看完前面两个序列,似乎第一个序列对于人类来说更容易记住,因为它更短,包含的数字比第二个序列少。然而,如果仔细观察第二个序列,你会发现偶数正好是后一个数字的两倍,而奇数后面跟着一个数字,乘以三再加一。这是一个著名的数字序列,叫做冰雹序列

然而,如果你能轻松记住长序列,你也能更轻松、更快速地识别数据中的模式。在 1970 年代,研究人员观察到,国际象棋高手能够仅仅通过看棋盘五秒钟,就记住游戏中所有棋子的摆放位置。听起来可能有些争议,但国际象棋专家的记忆力并不比你我更强大。问题在于,他们比非棋手更容易识别棋盘上的模式。自编码器的工作原理是,它首先观察输入,将其转化为更好的内部表示,并能够吸收它已经学习过的内容:

图 5:国际象棋游戏中的自编码器

看一下一个更现实的图形,关于我们刚才讨论的国际象棋例子:隐藏层有两个神经元(即编码器本身),而输出层有三个神经元(换句话说,就是解码器)。因为内部表示的维度低于输入数据(它是 2D 而不是 3D),所以这个自编码器被称为“欠完备”。一个欠完备的自编码器无法轻松地将其输入复制到编码中,但它必须找到一种方法输出其输入的副本。

它被迫学习输入数据中最重要的特征,并丢弃不重要的特征。通过这种方式,自编码器可以与主成分分析PCA)进行比较,PCA 用于使用比原始数据更少的维度来表示给定的输入。

到目前为止,我们已经了解了自编码器是如何工作的。现在,了解通过离群值识别进行异常检测将会很有意义。

开发欺诈分析模型

在我们完全开始之前,我们需要做两件事:了解数据集,然后准备我们的编程环境。

数据集的描述与线性模型的使用

对于这个项目,我们将使用 Kaggle 上的信用卡欺诈检测数据集。数据集可以从www.kaggle.com/dalpozz/creditcardfraud下载。由于我正在使用这个数据集,因此通过引用以下出版物来保持透明性是一个好主意:

  • Andrea Dal Pozzolo、Olivier Caelen、Reid A. Johnson 和 Gianluca Bontempi,《用欠采样校准概率进行不平衡分类》,在 IEEE 计算智能与数据挖掘研讨会(CIDM)上发表于 2015 年。

数据集包含 2013 年 9 月欧洲持卡人的信用卡交易,仅为两天。总共有 285,299 笔交易,其中只有 492 笔是欺诈交易,占 284,807 笔交易的 0.172%,表明数据集严重不平衡,正类(欺诈)占所有交易的 0.172%。

它只包含数值输入变量,这些变量是 PCA 转换的结果。不幸的是,由于保密问题,我们无法提供有关数据的原始特征和更多背景信息。有 28 个特征,即V1V2、...、V28,这些是通过 PCA 获得的主成分,除了TimeAmount。特征Class是响应变量,在欺诈案例中取值为 1,否则为 0。我们稍后会详细了解。

问题描述

鉴于类别不平衡比率,我们建议使用精度-召回率曲线下面积AUPRC)来衡量准确性。对于不平衡分类,混淆矩阵准确性并不具有意义。关于此,可以通过应用过采样或欠采样技术使用线性机器学习模型,如随机森林、逻辑回归或支持向量机。或者,我们可以尝试在数据中找到异常值,因为假设整个数据集中只有少数欺诈案例是异常。

在处理如此严重的响应标签不平衡时,我们在测量模型性能时也需要小心。由于欺诈案例很少,将所有预测为非欺诈的模型已经达到了超过 99%的准确率。但尽管准确率很高,线性机器学习模型不一定能帮助我们找到欺诈案例。

因此,值得探索深度学习模型,如自编码器。此外,我们需要使用异常检测来发现异常值。特别是,我们将看到如何使用自编码器来预训练分类模型,并在不平衡数据上测量模型性能。

准备编程环境

具体而言,我将为这个项目使用多种工具和技术。以下是解释每种技术的列表:

  • H2O/Sparking water:用于深度学习平台(详见上一章节)

  • Apache Spark:用于数据处理环境

  • Vegas:Matplotlib 的替代品,类似于 Python,用于绘图。它可以与 Spark 集成以进行绘图目的。

  • Scala:我们项目的编程语言

嗯,我将创建一个 Maven 项目,所有依赖项都将注入到pom.xml文件中。pom.xml文件的完整内容可以从 Packt 仓库下载。所以让我们开始吧:

<dependencies>
   <dependency>
      <groupId>ai.h2o</groupId>
      <artifactId>sparkling-water-core_2.11</artifactId>
      <version>2.2.2</version>
   </dependency>
   <dependency>
      <groupId>org.vegas-viz</groupId>
      <artifactId>vegas_2.11</artifactId>
      <version>0.3.11</version>
   </dependency>
   <dependency>
     <groupId>org.vegas-viz</groupId>
     <artifactId>vegas-spark_2.11</artifactId>
     <version>0.3.11</version>
     </dependency>
</dependencies>

现在,Eclipse 或你喜欢的 IDE 将拉取所有的依赖项。第一个依赖项也会拉取与该 H2O 版本兼容的所有 Spark 相关依赖项。然后,创建一个 Scala 文件并提供一个合适的名称。接下来,我们就准备好了。

步骤 1 - 加载所需的包和库

所以,让我们从导入所需的库和包开始:

package com.packt.ScalaML.FraudDetection

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.sql._
import org.apache.spark.h2o._
import _root_.hex.FrameSplitter
import water.Key
import water.fvec.Frame
import _root_.hex.deeplearning.DeepLearning
import _root_.hex.deeplearning.DeepLearningModel.DeepLearningParameters
import _root_.hex.deeplearning.DeepLearningModel.DeepLearningParameters.Activation
import java.io.File
import water.support.ModelSerializationSupport
import _root_.hex.{ ModelMetricsBinomial, ModelMetrics }
import org.apache.spark.h2o._
import scala.reflect.api.materializeTypeTag
import water.support.ModelSerializationSupport
import water.support.ModelMetricsSupport
import _root_.hex.deeplearning.DeepLearningModel
import vegas._
import vegas.sparkExt._
import org.apache.spark.sql.types._

步骤 2 - 创建 Spark 会话并导入隐式转换

然后我们需要创建一个 Spark 会话作为我们程序的入口:

val spark = SparkSession
        .builder
        .master("local[*]")
        .config("spark.sql.warehouse.dir", "tmp/")
        .appName("Fraud Detection")
        .getOrCreate()

此外,我们还需要导入 spark.sql 和 h2o 的隐式转换:

implicit val sqlContext = spark.sqlContext
import sqlContext.implicits._
val h2oContext = H2OContext.getOrCreate(spark)
import h2oContext._
import h2oContext.implicits._

步骤 3 - 加载和解析输入数据

我们加载并获取交易数据。然后我们获得分布:

val inputCSV = "data/creditcard.csv"

val transactions = spark.read.format("com.databricks.spark.csv")
        .option("header", "true")
        .option("inferSchema", true)
        .load(inputCSV)

步骤 4 - 输入数据的探索性分析

如前所述,数据集包含V1V28的数值输入变量,这些变量是原始特征经过 PCA 转换后的结果。响应变量Class告诉我们交易是否是欺诈行为(值=1)或正常交易(值=0)。

还有两个额外的特征,TimeAmountTime列表示当前交易和第一次交易之间的秒数,而Amount列表示本次交易转账的金额。所以让我们看看输入数据的一个简要展示(这里只显示了V1V2V26V27)在图 6中:

图 6:信用卡欺诈检测数据集的快照

我们已经能够加载交易数据,但上面的 DataFrame 没有告诉我们类别的分布情况。所以,让我们计算类别分布并考虑绘制它们:

val distribution = transactions.groupBy("Class").count.collect
Vegas("Class Distribution").withData(distribution.map(r => Map("class" -> r(0), "count" -> r(1)))).encodeX("class", Nom).encodeY("count", Quant).mark(Bar).show
>>>

图 7:信用卡欺诈检测数据集中的类别分布

现在,让我们看看时间是否对可疑交易有重要的影响。Time列告诉我们交易发生的顺序,但并未提供任何关于实际时间(即一天中的时间)的信息。因此,将它们按天进行标准化,并根据一天中的时间将其分为四组,以便从Time构建一个Day列会很有帮助。我为此编写了一个 UDF:

val daysUDf = udf((s: Double) => 
if (s > 3600 * 24) "day2" 
else "day1")

val t1 = transactions.withColumn("day", daysUDf(col("Time")))
val dayDist = t1.groupBy("day").count.collect

现在让我们绘制它:

Vegas("Day Distribution").withData(dayDist.map(r => Map("day" -> r(0), "count" -> r(1)))).encodeX("day", Nom).encodeY("count", Quant).mark(Bar).show
>>>

图 8:信用卡欺诈检测数据集中的天数分布

上面的图表显示了这两天的交易数量相同,但更具体地说,day1的交易数量略多。现在让我们构建dayTime列。我为此编写了一个 UDF:

val dayTimeUDf = udf((day: String, t: Double) => if (day == "day2") t - 86400 else t)
val t2 = t1.withColumn("dayTime", dayTimeUDf(col("day"), col("Time")))

t2.describe("dayTime").show()
>>>
+-------+------------------+
|summary| dayTime |
+-------+------------------+
| count| 284807|
| mean| 52336.926072744|
| stddev|21049.288810608432|
| min| 0.0|
| max| 86400.0|
+-------+------------------+

现在我们需要获取分位数(q1、中位数、q2)并构建时间区间(gr1gr2gr3gr4):


val d1 = t2.filter($"day" === "day1")
val d2 = t2.filter($"day" === "day2")
val quantiles1 = d1.stat.approxQuantile("dayTime", Array(0.25, 0.5, 0.75), 0)

val quantiles2 = d2.stat.approxQuantile("dayTime", Array(0.25, 0.5, 0.75), 0)

val bagsUDf = udf((t: Double) => 
 if (t <= (quantiles1(0) + quantiles2(0)) / 2) "gr1" 
 elseif (t <= (quantiles1(1) + quantiles2(1)) / 2) "gr2" 
 elseif (t <= (quantiles1(2) + quantiles2(2)) / 2) "gr3" 
 else "gr4")

val t3 = t2.drop(col("Time")).withColumn("Time", bagsUDf(col("dayTime")))

然后让我们获取类别01的分布:

val grDist = t3.groupBy("Time", "class").count.collect
val grDistByClass = grDist.groupBy(_(1))

现在让我们绘制类别0的组分布:

Vegas("gr Distribution").withData(grDistByClass.get(0).get.map(r => Map("Time" -> r(0), "count" -> r(2)))).encodeX("Time", Nom).encodeY("count", Quant).mark(Bar).show
>>>

图 9:信用卡欺诈检测数据集中类别 0 的组分布

从前面的图表来看,显然大部分是正常交易。现在让我们看看 class 1 的分组分布:

Vegas("gr Distribution").withData(grDistByClass.get(1).get.map(r => Map("Time" -> r(0), "count" -> r(2)))).encodeX("Time", Nom).encodeY("count", Quant).mark(Bar).show
>>>

图 10:信用卡欺诈检测数据集中类 1 的分组分布

所以,四个Time区间中的交易分布显示,大多数欺诈案件发生在组 1。我们当然可以查看转账金额的分布:

val c0Amount = t3.filter($"Class" === "0").select("Amount")
val c1Amount = t3.filter($"Class" === "1").select("Amount")

println(c0Amount.stat.approxQuantile("Amount", Array(0.25, 0.5, 0.75), 0).mkString(","))

Vegas("Amounts for class 0").withDataFrame(c0Amount).mark(Bar).encodeX("Amount", Quantitative, bin = Bin(50.0)).encodeY(field = "*", Quantitative, aggregate = AggOps.Count).show
>>>

图 11:类 0 转账金额的分布

现在让我们为 class 1 绘制相同的图表:

Vegas("Amounts for class 1").withDataFrame(c1Amount).mark(Bar).encodeX("Amount", Quantitative, bin = Bin(50.0)).encodeY(field = "*", Quantitative, aggregate = AggOps.Count).show
>>>

图 12:类 1 转账金额的分布

因此,从前面这两张图表可以看出,欺诈性信用卡交易的转账金额均值较高,但最大金额却远低于常规交易。正如我们在手动构建的 dayTime 列中看到的那样,这并不十分显著,因此我们可以直接删除它。我们来做吧:

val t4 = t3.drop("day").drop("dayTime")

步骤 5 - 准备 H2O DataFrame

到目前为止,我们的 DataFrame(即 t4)是 Spark DataFrame,但它不能被 H2O 模型使用。所以,我们需要将其转换为 H2O frame。那么我们来进行转换:

val creditcard_hf: H2OFrame = h2oContext.asH2OFrame(t4.orderBy(rand()))

我们将数据集划分为,假设 40% 为有监督训练,40% 为无监督训练,20% 为测试集,使用 H2O 内置的分割器 FrameSplitter:

val sf = new FrameSplitter(creditcard_hf, Array(.4, .4), 
                Array("train_unsupervised", "train_supervised", "test")
                .map(Key.makeFrame), null)

water.H2O.submitTask(sf)
val splits = sf.getResult
val (train_unsupervised, train_supervised, test) = (splits(0), splits(1), splits(2))

在上面的代码片段中,Key.makeFrame 被用作低级任务,用于根据分割比例分割数据帧,同时帮助获得分布式的键/值对。

在 H2O 计算中,键非常关键。H2O 支持分布式的键/值存储,并且具有精确的 Java 内存模型一致性。关键点是,键是用来在云中找到链接值、将其缓存到本地,并允许对链接值进行全局一致的更新的手段。

最后,我们需要将 Time 列从字符串类型显式转换为类别类型(即枚举):

toCategorical(train_unsupervised, 30)
toCategorical(train_supervised, 30)
toCategorical(test, 30)

步骤 6 - 使用自编码器进行无监督预训练

如前所述,我们将使用 Scala 和 h2o 编码器。现在是时候开始无监督自编码器训练了。由于训练是无监督的,这意味着我们需要将 response 列从无监督训练集中排除:

val response = "Class"
val features = train_unsupervised.names.filterNot(_ == response)

接下来的任务是定义超参数,例如隐藏层的数量和神经元、用于重现性的种子、训练轮数以及深度学习模型的激活函数。对于无监督预训练,只需将自编码器参数设置为 true

var dlParams = new DeepLearningParameters()
    dlParams._ignored_columns = Array(response))// since unsupervised, we ignore the label
    dlParams._train = train_unsupervised._key // use the train_unsupervised frame for training
    dlParams._autoencoder = true // use H2O built-in autoencoder    dlParams._reproducible = true // ensure reproducibility    dlParams._seed = 42 // random seed for reproducibility
    dlParams._hidden = ArrayInt
    dlParams._epochs = 100 // number of training epochs
    dlParams._activation = Activation.Tanh // Tanh as an activation function
    dlParams._force_load_balance = false var dl = new DeepLearning(dlParams)
val model_nn = dl.trainModel.get

在上面的代码中,我们应用了一种叫做瓶颈训练的技术,其中中间的隐藏层非常小。这意味着我的模型必须降低输入数据的维度(在这种情况下,降到两个节点/维度)。

然后,自编码器模型将学习输入数据的模式,而不考虑给定的类别标签。在这里,它将学习哪些信用卡交易是相似的,哪些交易是异常值或离群点。不过,我们需要记住,自编码器模型对数据中的离群点非常敏感,这可能会破坏其他典型模式。

一旦预训练完成,我们应该将模型保存在.csv目录中:

val uri = new File(new File(inputCSV).getParentFile, "model_nn.bin").toURI ModelSerializationSupport.exportH2OModel(model_nn, uri)

重新加载模型并恢复以便进一步使用:

val model: DeepLearningModel = ModelSerializationSupport.loadH2OModel(uri)

现在,让我们打印模型的指标,看看训练的效果如何:

println(model)
>>>

图 13:自编码器模型的指标

太棒了!预训练进展非常顺利,因为我们可以看到 RMSE 和 MSE 都相当低。我们还可以看到,一些特征是相当不重要的,例如v16v1v25等。我们稍后会分析这些。

步骤 7 - 使用隐藏层进行降维

由于我们使用了一个中间有两个节点的浅层自编码器,因此使用降维来探索我们的特征空间是值得的。我们可以使用scoreDeepFeatures()方法提取这个隐藏特征并绘制图形,展示输入数据的降维表示。

scoreDeepFeatures()方法会即时评分自编码重构,并提取给定层的深度特征。它需要以下参数:原始数据的框架(可以包含响应,但会被忽略),以及要提取特征的隐藏层的层索引。最后,返回一个包含深度特征的框架,其中列数为隐藏[层]。

现在,对于监督训练,我们需要提取深度特征。我们从第 2 层开始:

var train_features = model_nn.scoreDeepFeatures(train_unsupervised, 1) 
train_features.add("Class", train_unsupervised.vec("Class"))

最终聚类识别的绘图如下:

train_features.setNames(train_features.names.map(_.replaceAll("[.]", "-")))
train_features._key = Key.make()
water.DKV.put(train_features)

val tfDataFrame = asDataFrame(train_features) Vegas("Compressed").withDataFrame(tfDataFrame).mark(Point).encodeX("DF-L2-C1", Quantitative).encodeY("DF-L2-C2", Quantitative).encodeColor(field = "Class", dataType = Nominal).show
>>>

图 14:类别 0 和 1 的最终聚类

从前面的图中,我们无法看到任何与非欺诈实例明显区分的欺诈交易聚类,因此仅使用我们的自编码器模型进行降维不足以识别数据集中的欺诈行为。但我们可以使用隐藏层之一的降维表示作为模型训练的特征。例如,可以使用第一层或第三层的 10 个特征。现在,让我们从第 3 层提取深度特征:

train_features = model_nn.scoreDeepFeatures(train_unsupervised, 2)
train_features._key = Key.make()
train_features.add("Class", train_unsupervised.vec("Class"))
water.DKV.put(train_features)

val features_dim = train_features.names.filterNot(_ == response)
val train_features_H2O = asH2OFrame(train_features)

现在,让我们再次使用新维度的数据集进行无监督深度学习:

dlParams = new DeepLearningParameters()
        dlParams._ignored_columns = Array(response)
        dlParams._train = train_features_H2O
        dlParams._autoencoder = true
        dlParams._reproducible = true
        dlParams._ignore_const_cols = false
        dlParams._seed = 42
        dlParams._hidden = ArrayInt
        dlParams._epochs = 100
        dlParams._activation = Activation.Tanh
        dlParams._force_load_balance = false dl = new DeepLearning(dlParams)
val model_nn_dim = dl.trainModel.get

然后我们保存模型:

ModelSerializationSupport.exportH2OModel(model_nn_dim, new File(new File(inputCSV).getParentFile, "model_nn_dim.bin").toURI)

为了衡量模型在测试数据上的表现,我们需要将测试数据转换为与训练数据相同的降维形式:

val test_dim = model_nn.scoreDeepFeatures(test, 2)
val test_dim_score = model_nn_dim.scoreAutoEncoder(test_dim, Key.make(), false)

val result = confusionMat(test_dim_score, test, test_dim_score.anyVec.mean)
println(result.deep.mkString("n"))
>>>
Array(38767, 29)
Array(18103, 64)

现在,从识别欺诈案例的角度来看,这实际上看起来相当不错:93%的欺诈案例已被识别!

步骤 8 - 异常检测

我们还可以问一下哪些实例被认为是我们测试数据中的离群值或异常值。根据之前训练的自编码器模型,输入数据将被重构,并为每个实例计算实际值与重构值之间的 MSE。我还计算了两个类别标签的平均 MSE:

test_dim_score.add("Class", test.vec("Class"))
val testDF = asDataFrame(test_dim_score).rdd.zipWithIndex.map(r => Row.fromSeq(r._1.toSeq :+ r._2))

val schema = StructType(Array(StructField("Reconstruction-MSE", DoubleType, nullable = false), StructField("Class", ByteType, nullable = false), StructField("idRow", LongType, nullable = false)))

val dffd = spark.createDataFrame(testDF, schema)
dffd.show()
>>>

图 15:显示均方误差(MSE)、类别和行 ID 的数据框

看着这个数据框,确实很难识别出离群值。但如果将它们绘制出来,可能会提供更多的见解:

Vegas("Reduced Test", width = 800, height = 600).withDataFrame(dffd).mark(Point).encodeX("idRow", Quantitative).encodeY("Reconstruction-MSE", Quantitative).encodeColor(field = "Class", dataType = Nominal).show
>>>

图 16:不同行 ID 下重建的 MSE 分布

正如我们在图表中所看到的,欺诈与非欺诈案例之间并没有完美的分类,但欺诈交易的平均 MSE 确实高于常规交易。但是需要进行最低限度的解释。

从前面的图表中,我们至少可以看到,大多数idRows的 MSE 为。或者,如果我们将 MSE 阈值提高到10µ,那么超出此阈值的数据点可以视为离群值或异常值,即欺诈交易。

步骤 9 - 预训练的监督模型

现在我们可以尝试使用自编码器模型作为监督模型的预训练输入。这里,我再次使用神经网络。该模型现在将使用来自自编码器的权重进行模型拟合。然而,需要将类别从整数转换为类别型,以便进行分类训练。否则,H2O 训练算法将把它当作回归问题处理:

toCategorical(train_supervised, 29)

现在,训练集(即train_supervised)已经为监督学习准备好了,我们可以开始进行训练了:

val train_supervised_H2O = asH2OFrame(train_supervised)
        dlParams = new DeepLearningParameters()
        dlParams._pretrained_autoencoder = model_nn._key
        dlParams._train = train_supervised_H2O
        dlParams._reproducible = true
        dlParams._ignore_const_cols = false
        dlParams._seed = 42
        dlParams._hidden = ArrayInt
        dlParams._epochs = 100
        dlParams._activation = Activation.Tanh
        dlParams._response_column = "Class"
        dlParams._balance_classes = true dl = new DeepLearning(dlParams)
val model_nn_2 = dl.trainModel.get

做得很好!我们现在已经完成了监督训练。接下来,让我们看看预测类别与实际类别的对比:

val predictions = model_nn_2.score(test, "predict")
test.add("predict", predictions.vec("predict"))
asDataFrame(test).groupBy("Class", "predict").count.show //print
>>>
+-----+-------+-----+
|Class|predict|count|
+-----+-------+-----+
| 1| 0| 19|
| 0| 1| 57|
| 0| 0|56804|
| 1| 1| 83|
+-----+-------+-----+

现在,这看起来好多了!我们确实错过了 17% 的欺诈案例,但也没有错误地分类太多非欺诈案例。在现实生活中,我们会花更多的时间通过示例来改进模型,执行超参数调优的网格搜索,回到原始特征并尝试不同的工程特征和/或尝试不同的算法。现在,如何可视化前面的结果呢?让我们使用 Vegas 包来实现:

Vegas().withDataFrame(asDataFrame(test)).mark(Bar).encodeY(field = "*", dataType = Quantitative, AggOps.Count, axis = Axis(title = "", format = ".2f"), hideAxis = true).encodeX("Class", Ord).encodeColor("predict", Nominal, scale = Scale(rangeNominals = List("#EA98D2", "#659CCA"))).configMark(stacked = StackOffset.Normalize).show
>>>

图 17:使用监督训练模型的预测类别与实际类别对比

步骤 10 - 在高度不平衡的数据上进行模型评估

由于数据集在非欺诈案例上高度不平衡,因此使用模型评估指标(如准确率或曲线下面积AUC))是没有意义的。原因是,这些指标会根据大多数类的高正确分类率给出过于乐观的结果。

AUC 的替代方法是使用精确度-召回率曲线,或者敏感度(召回率)-特异度曲线。首先,让我们使用ModelMetricsSupport类中的modelMetrics()方法来计算 ROC:

val trainMetrics = ModelMetricsSupport.modelMetricsModelMetricsBinomial
val auc = trainMetrics._auc
val metrics = auc._tps.zip(auc._fps).zipWithIndex.map(x => x match { case ((a, b), c) => (a, b, c) })

val fullmetrics = metrics.map(_ match { case (a, b, c) => (a, b, auc.tn(c), auc.fn(c)) })
val precisions = fullmetrics.map(_ match { case (tp, fp, tn, fn) => tp / (tp + fp) })

val recalls = fullmetrics.map(_ match { case (tp, fp, tn, fn) => tp / (tp + fn) })
val rows = for (i <- 0 until recalls.length) yield r(precisions(i), recalls(i))
val precision_recall = rows.toDF()

现在,我们已经有了precision_recall数据框,绘制它会非常令人兴奋。那么我们就这样做:

Vegas("ROC", width = 800, height = 600).withDataFrame(precision_recall).mark(Line).encodeX("recall", Quantitative).encodeY("precision", Quantitative).show
>>>

图 18:精确度-召回率曲线

精确度是预测为欺诈的测试案例中,真正是欺诈的比例,也叫做真实正例预测。另一方面,召回率或敏感度是被识别为欺诈的欺诈案例的比例。而特异度是被识别为非欺诈的非欺诈案例的比例。

前面的精确度-召回率曲线告诉我们实际欺诈预测与被预测为欺诈的欺诈案例的比例之间的关系。现在,问题是如何计算敏感度和特异度。好吧,我们可以使用标准的 Scala 语法来做到这一点,并通过Vegas包绘制它:

val sensitivity = fullmetrics.map(_ 
 match { 
 case (tp, fp, tn, fn) => tp / (tp + fn) })
 val specificity = fullmetrics.map(_ 
 match { 
 case (tp, fp, tn, fn) => tn / (tn + fp) })
 val rows2 = 
 for (i <- 0 until specificity.length) 
 yield r2(sensitivity(i), specificity(i))

val sensitivity_specificity = rows2.toDF
Vegas("sensitivity_specificity", width = 800, height = 600).withDataFrame(sensitivity_specificity).mark(Line).encodeX("specificity", Quantitative).encodeY("sensitivity", Quantitative).show
>>>

图 19:敏感度与特异度曲线

现在,前面的敏感度-特异度曲线告诉我们两个标签下正确预测类别之间的关系——例如,如果我们有 100%正确预测的欺诈案例,那么将没有正确分类的非欺诈案例,反之亦然。

最后,通过手动检查不同的预测阈值,并计算两个类别中正确分类的案例数量,从不同角度更深入地分析会很有帮助。更具体地说,我们可以直观地检查不同预测阈值下的真实正例、假正例、真实负例和假负例——例如,从 0.0 到 1.0:

val withTh = auc._tps.zip(auc._fps)
            .zipWithIndex
            .map(x => x match { case ((a, b), c) 
            => (a, b, auc.tn(c), auc.fn(c), auc._ths(c)) })
val rows3 = for (i <- 0 until withTh.length) yield r3(withTh(i)._1, withTh(i)._2, withTh(i)._3, withTh(i)._4, withTh(i)._5)

首先,让我们绘制真实正例的图:

Vegas("tp", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("tp", Quantitative).show
>>>

图 20:在[0.0, 1.0]范围内的真实正例数量随不同预测阈值变化

其次,让我们绘制假正例的图:

Vegas("fp", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("fp", Quantitative).show
>>>

图 21:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化

然而,前面的图形不容易解读。所以让我们为datum.th设置一个 0.01 的阈值,并重新绘制:

Vegas("fp", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).filter("datum.th > 0.01").encodeX("th", Quantitative).encodeY("fp", Quantitative).show
>>>

图 22:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化

然后,轮到绘制真实负例了:

Vegas("tn", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("tn", Quantitative).show
>>>

图 23:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化

最后,让我们绘制假负例的图,如下所示:

Vegas("fn", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("fn", Quantitative).show
>>>

图 24:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化

因此,前面的图表告诉我们,当我们将预测阈值从默认的 0.5 提高到 0.6 时,我们可以增加正确分类的非欺诈性案例的数量,而不会丧失正确分类的欺诈性案例。

第 11 步 - 停止 Spark 会话和 H2O 上下文

最后,停止 Spark 会话和 H2O 上下文。以下的stop()方法调用将分别关闭 H2O 上下文和 Spark 集群:

h2oContext.stop(stopSparkContext = true)
spark.stop()

第一种尤其重要,否则有时它不会停止 H2O 流动,但仍然占用计算资源。

辅助类和方法

在之前的步骤中,我们看到了一些类或方法,这里也应该进行描述。第一个方法名为toCategorical(),它将 Frame 列从 String/Int 转换为枚举类型;用于将dayTime袋(即gr1gr2gr3gr4)转换为类似因子的类型。该函数也用于将Class列转换为因子类型,以便执行分类:

def toCategorical(f: Frame, i: Int): Unit = {
    f.replace(i, f.vec(i).toCategoricalVec)
    f.update()
    }

这个方法根据一个阈值构建异常检测的混淆矩阵,如果实例被认为是异常的(如果其 MSE 超过给定阈值):

def confusionMat(mSEs:water.fvec.Frame,actualFrame:water.fvec.Frame,thresh: Double):Array[Array[Int]] = {
 val actualColumn = actualFrame.vec("Class");
 val l2_test = mSEs.anyVec();
 val result = Array.ofDimInt
 var i = 0
 var ii, jj = 0

 for (i <- 0 until l2_test.length().toInt) {
        ii = if (l2_test.at(i) > thresh) 1 else 0;
        jj = actualColumn.at(i).toInt
        result(ii)(jj) = result(ii)(jj) + 1
        }
    result
    }

除了这两个辅助方法外,我还定义了三个 Scala 案例类,用于计算精准度、召回率;灵敏度、特异度;真正例、假正例、真负例和假负例等。签名如下:

caseclass r(precision: Double, recall: Double)
caseclass r2(sensitivity: Double, specificity: Double)
caseclass r3(tp: Double, fp: Double, tn: Double, fn: Double, th: Double)

超参数调优和特征选择

以下是通过调节超参数来提高准确度的一些方法,如隐藏层的数量、每个隐藏层中的神经元数、训练轮次(epochs)以及激活函数。当前基于 H2O 的深度学习模型实现支持以下激活函数:

  • ExpRectifier

  • ExpRectifierWithDropout

  • Maxout

  • MaxoutWithDropout

  • Rectifier

  • RectifierWithDropout

  • Tanh

  • TanhWithDropout

除了Tanh之外,我没有尝试其他激活函数用于这个项目。不过,你应该尝试一下。

使用基于 H2O 的深度学习算法的最大优势之一是我们可以获得相对的变量/特征重要性。在前面的章节中,我们已经看到,使用 Spark 中的随机森林算法也可以计算变量的重要性。因此,如果你的模型表现不好,可以考虑丢弃不重要的特征,并重新进行训练。

让我们来看一个例子;在图 13中,我们已经看到在自动编码器的无监督训练中最重要的特征。现在,在监督训练过程中也可以找到特征的重要性。我在这里观察到的特征重要性:

图 25:不同预测阈值下的假正例,范围为[0.0, 1.0]

因此,从图 25中可以观察到,特征 Time、V21V17V6的重要性较低。那么为什么不将它们去除,再进行训练,看看准确率是否有所提高呢?

然而,网格搜索或交叉验证技术仍然可能提供更高的准确率。不过,我将把这个决定留给你。

总结

在本章中,我们使用了一个数据集,该数据集包含了超过 284,807 个信用卡使用实例,并且每笔交易中只有 0.172%的交易是欺诈性的。我们已经看到如何使用自编码器来预训练分类模型,以及如何应用异常检测技术来预测可能的欺诈交易,尤其是在高度不平衡的数据中——也就是说,我们期望欺诈案件在整个数据集中是异常值。

我们的最终模型现在正确识别了 83%的欺诈案件和几乎 100%的非欺诈案件。然而,我们已经了解了如何使用异常检测来识别离群值,一些超参数调整的方法,最重要的是,特征选择。

循环神经网络RNN)是一类人工神经网络,其中单元之间的连接形成一个有向循环。RNN 利用来自过去的信息,这样它们就能在具有高时间依赖性的数据中做出预测。这会创建一个网络的内部状态,使其能够展示动态的时间行为。

RNN 接收多个输入向量进行处理,并输出其他向量。与经典方法相比,使用带有长短期记忆单元LSTM)的 RNN 几乎不需要特征工程。数据可以直接输入到神经网络中,神经网络就像一个黑箱,能够正确建模问题。就预处理的数据量而言,这里的方法相对简单。

在下一章,我们将看到如何使用名为LSTM的循环神经网络(RNN)实现,开发一个人类活动识别HAR)的机器学习项目,使用的是智能手机数据集。简而言之,我们的机器学习模型将能够从六个类别中分类运动类型:走路、走楼梯、下楼梯、坐着、站立和躺下。

第十章:使用循环神经网络进行人类活动识别

循环神经网络RNN)是一类人工神经网络,其中单元之间的连接形成一个有向循环。RNN 利用过去的信息,这样它们就能够对具有高时间依赖性的数据进行预测。这会创建一个网络的内部状态,使其能够表现出动态的时间行为。

RNN 接受多个输入向量进行处理并输出其他向量。与传统方法相比,使用带有长短期记忆单元(LSTM)的 RNN 几乎不需要,或者只需极少的特征工程。数据可以直接输入到神经网络中,神经网络像一个黑盒子一样,正确地建模问题。这里的方法在预处理数据的多少上相对简单。

在本章中,我们将看到如何使用 RNN 实现开发机器学习项目,这种实现称为 LSTM,用于人类活动识别HAR),并使用智能手机数据集。简而言之,我们的机器学习模型将能够从六个类别中分类运动类型:走路、走楼梯、下楼梯、坐着、站立和躺下。

简而言之,在这个从头到尾的项目中,我们将学习以下主题:

  • 使用循环神经网络

  • RNN 的长期依赖性和缺点

  • 开发用于人类活动识别的 LSTM 模型

  • 调优 LSTM 和 RNN

  • 摘要

使用 RNN

在本节中,我们首先将提供一些关于 RNN 的背景信息。然后,我们将强调传统 RNN 的一些潜在缺点。最后,我们将看到一种改进的 RNN 变体——LSTM,来解决这些缺点。

RNN 的背景信息及其架构

人类不会从零开始思考;人类思维有所谓的记忆持久性,即将过去的信息与最近的信息关联起来的能力。而传统的神经网络则忽略了过去的事件。例如,在电影场景分类器中,神经网络无法使用过去的场景来分类当前的场景。RNN 的出现是为了解决这个问题:

图 1:RNN 具有循环结构

与传统神经网络不同,RNN 是带有循环的网络,允许信息保持(图 1)。在一个神经网络中,比如A:在某个时刻t,输入x[t]并输出一个值h[t]。因此,从图 1来看,我们可以把 RNN 看作是多个相同网络的副本,每个副本将信息传递给下一个副本。如果我们展开之前的网络,会得到什么呢?嗯,下面的图给出了些许启示:

图 2:图 1 中表示的同一 RNN 的展开表示

然而,前面的展开图并没有提供关于 RNN 的详细信息。相反,RNN 与传统神经网络不同,因为它引入了一个过渡权重W,用于在时间之间传递信息。RNN 一次处理一个顺序输入,更新一种包含所有过去序列元素信息的向量状态。下图展示了一个神经网络,它将X(t)的值作为输入,然后输出Y(t)的值:

图 3:一个 RNN 架构可以利用网络的先前状态来发挥优势

图 1所示,神经网络的前半部分通过函数Z(t) = X(t) * W[in] 表示,神经网络的后半部分则呈现为Y(t)= Z(t) * W[out]。如果你愿意,整个神经网络就是函数Y(t) = (X(t) * W[in]) * W[out]*。

在每个时间点t,调用已学习的模型,这种架构没有考虑之前运行的知识。这就像只看当天数据来预测股市趋势。一个更好的方法是利用一周或几个月的数据中的总体模式:

图 4:一个 RNN 架构,其中所有层中的所有权重都需要随着时间学习

一个更为明确的架构可以在图 4中找到,其中时间共享的权重w2(用于隐藏层)必须在w1(用于输入层)和w3(用于输出层)之外进行学习。

难以置信的是,在过去的几年里,RNN 已被用于各种问题,如语音识别、语言建模、翻译和图像描述。

RNN 和长期依赖问题

RNN 非常强大,也很流行。然而,我们通常只需要查看最近的信息来执行当前任务,而不是很久以前存储的信息。这在自然语言处理中(NLP)进行语言建模时尤为常见。让我们来看一个常见的例子:

图 5:如果相关信息与所需位置之间的间隙较小,RNN 可以学会利用过去的信息

假设一个语言模型试图基于先前的单词预测下一个单词。作为人类,如果我们试图预测the sky is blue中的最后一个词,在没有进一步上下文的情况下,我们最可能预测下一个词是blue。在这种情况下,相关信息与位置之间的间隙较小。因此,RNN 可以轻松学习使用过去的信息。

但是考虑一个更长的句子:Asif 在孟加拉国长大……他在韩国学习……他讲一口流利的孟加拉语,我们需要更多的上下文。在这个句子中,最新的信息告诉我们,下一个单词可能是某种语言的名称。然而,如果我们想要缩小是哪种语言,我们需要从前面的词汇中得到孟加拉国的上下文:

图 6:当相关信息和所需位置之间的间隔较大时,RNNs 无法学会使用过去的信息

在这里,信息间的间隙更大,因此 RNNs 变得无法学习到这些信息。这是 RNN 的一个严重缺点。然而,LSTM 出现并拯救了这一局面。

LSTM 网络

一种 RNN 模型是LSTM。LSTM 的具体实现细节不在本书的范围内。LSTM 是一种特殊的 RNN 架构,最初由 Hochreiter 和 Schmidhuber 在 1997 年提出。这种类型的神经网络最近在深度学习领域被重新发现,因为它避免了梯度消失问题,并提供了出色的结果和性能。基于 LSTM 的网络非常适合时间序列的预测和分类,正在取代许多传统的深度学习方法。

这个名字很有趣,但它的意思正如其字面所示。这个名字表明短期模式在长期内不会被遗忘。LSTM 网络由相互连接的单元(LSTM 块)组成。每个 LSTM 块包含三种类型的门控:输入门、输出门和遗忘门,分别实现写入、读取和重置单元记忆的功能。这些门控不是二进制的,而是模拟的(通常由一个 sigmoid 激活函数管理,映射到范围(0, 1),其中 0 表示完全抑制,1 表示完全激活)。

如果你把 LSTM 单元看作一个黑箱,它的使用方式与基本单元非常相似,唯一不同的是它的表现会更好;训练过程会更快收敛,而且它能够检测数据中的长期依赖关系。那么,LSTM 单元是如何工作的呢?一个基本的 LSTM 单元架构如图 7所示:

图 7:LSTM 单元的框图

现在,让我们看看这个架构背后的数学符号。如果我们不看 LSTM 箱子内部的内容,LSTM 单元本身看起来就像一个普通的存储单元,唯一的区别是它的状态被分成两个向量,h(t)c(t)

  • c 是单元

  • h(t) 是短期状态

  • c(t) 是长期状态

现在让我们打开这个盒子!关键思想是,网络可以学习在长期状态中存储什么,丢弃什么,以及从中读取什么。当长期状态c[(t-1)]从左到右遍历网络时,你会看到它首先经过一个遗忘门,丢弃一些记忆,然后通过加法操作(将输入门选择的记忆添加进去)加入一些新的记忆。最终得到的c(t)直接输出,不经过进一步的转换。

因此,在每个时间戳,某些记忆被丢弃,某些记忆被添加。此外,在加法操作后,长期状态会被复制并通过tanh函数,然后结果会被输出门过滤。这产生了短期状态h(t)(即该时间步的单元输出y(t))。现在让我们看看新记忆是从哪里来的,以及门是如何工作的。首先,将当前输入向量x(t)和前一个短期状态h(t-1)送入四个不同的全连接层。

这些门的存在使得 LSTM 单元可以在无限时间内记住信息;如果输入门低于激活阈值,单元将保留之前的状态;如果当前状态被启用,它将与输入值结合。如其名所示,遗忘门会重置单元的当前状态(当其值清零时),而输出门决定是否必须执行单元的值。以下方程用于执行 LSTM 计算,得到单元在每个时间步的长期状态、短期状态和输出:

在前面的方程中,W[xi]W[xf]W[xo]W[xg]是每一层的权重矩阵,用于连接输入向量x[(t)]。另一方面,W[hi]W[hf]W[ho]W[hg]是每一层的权重矩阵,用于连接前一个短期状态h[(t-1)]。最后,b[i]b[f]b[o]b[g]是每一层的偏置项。

既然我们已经了解了这些,那么 RNN 和 LSTM 网络是如何工作的呢?是时候动手实践了。我们将开始实现一个基于 MXNet 和 Scala 的 LSTM 模型来进行 HAR。

使用 LSTM 模型的人类活动识别

人类活动识别HAR)数据库是通过记录 30 名研究参与者执行日常生活活动ADL)时佩戴带有惯性传感器的腰部智能手机的活动数据构建的。目标是将活动分类为执行的六种活动之一。

数据集描述

实验是在一组 30 名志愿者中进行的,年龄范围为 19 至 48 岁。每个人完成了六项活动,即走路、走楼梯、下楼、坐着、站立和躺下,佩戴的设备是三星 Galaxy S II 智能手机,固定在腰部。通过加速度计和陀螺仪,作者以 50 Hz 的恒定速率捕获了三轴线性加速度和三轴角速度。

仅使用了两个传感器,即加速度计和陀螺仪。传感器信号通过应用噪声滤波器进行预处理,然后在 2.56 秒的固定宽度滑动窗口中采样,重叠 50%。这意味着每个窗口有 128 个读数。通过 Butterworth 低通滤波器将来自传感器加速度信号的重力和身体运动分量分离为身体加速度和重力。

欲了解更多信息,请参考以下论文:Davide Anguita, Alessandro Ghio, Luca Oneto, Xavier Parra 和 Jorge L. Reyes-Ortiz。 使用智能手机的人体活动识别的公开数据集第 21 届欧洲人工神经网络、计算智能与机器学习研讨会,ESANN 2013。比利时布鲁日,2013 年 4 月 24 日至 26 日。

为简便起见,假设重力只包含少数但低频的分量。因此,使用了 0.3 Hz 截止频率的滤波器。从每个窗口中,通过计算时间和频域的变量,得出了一个特征向量。

实验已通过视频录制,手动标注数据。获得的数据集已随机划分为两个集合,其中 70%的志愿者用于生成训练数据,30%用于生成测试数据。现在,当我浏览数据集时,训练集和测试集具有以下文件结构:

图 8:HAR 数据集文件结构

数据集中的每个记录提供以下内容:

  • 来自加速度计的三轴加速度和估计的身体加速度

  • 来自陀螺仪传感器的三轴角速度

  • 一个包含时间和频域变量的 561 特征向量

  • 它的活动标签

  • 执行实验的主体的标识符

现在我们知道需要解决的问题,是时候探索技术和相关挑战了。正如我之前所说,我们将使用基于 MXNet 的 LSTM 实现。你可能会问:为什么我们不使用 H2O 或 DeepLearning4j?嗯,答案是这两者要么没有基于 LSTM 的实现,要么无法应用于解决这个问题。

设置和配置 Scala 中的 MXNet

Apache MXNet 是一个灵活高效的深度学习库。构建一个高性能的深度学习库需要做出许多系统级设计决策。在这篇设计说明中,我们分享了在设计 MXNet 时所做的具体选择的理由。我们认为这些见解可能对深度学习实践者以及其他深度学习系统的构建者有所帮助。

对于这个项目,我们将需要不同的包和库:Scala、Java、OpenBLAS、ATLAS、OpenCV,最重要的,还有 MXNet。现在让我们一步步地开始配置这些工具。对于 Java 和 Scala,我假设你已经配置好了 Java 和 Scala。接下来的任务是安装构建工具和git,因为我们将使用来自 GitHub 仓库的 MXNet。只需要在 Ubuntu 上执行以下命令:

$ sudo apt-get update 
$ sudo apt-get install -y build-essential git 

然后,我们需要安装 OpenBLAS 和 ATLAS。这些库是 MXNet 进行线性代数运算时所必需的。要安装它们,只需执行以下命令:

$ sudo apt-get install -y libopenblas-dev 
$ sudo apt-get install -y libatlas-base-dev 

我们还需要安装 OpenCV 进行图像处理。让我们通过执行以下命令来安装它:

 $ sudo apt-get install -y libopencv-dev 

最后,我们需要生成预构建的 MXNet 二进制文件。为此,我们需要克隆并构建 MXNet 的 Scala 版本:

$ git clone --recursive https://github.com/apache/incubator-mxnet.git mxnet --branch 0.12.0 
$ cd mxnet 
$ make -j $(nproc) USE_OPENCV=1 USE_BLAS=openblas 
$ make scalapkg 
$ make scalainsta 

现在,如果前面的步骤顺利进行,MXNet 的预构建二进制文件将在/home/$user_name/mxnet/scala-package/assembly/linux-x86_64-cpu(如果配置了 GPU,则为linux-x86_64-gpu,在 macOS 上为osx-x86_64-cpu)中生成。请看一下 Ubuntu 上的 CPU 截图:

图 9:生成的 MXNet 预构建二进制文件

现在,开始编写 Scala 代码之前(在 Eclipse 或 IntelliJ 中作为 Maven 或 SBT 项目),下一步任务是将这个 JAR 文件包含到构建路径中。此外,我们还需要一些额外的依赖项来支持 Scala 图表和args4j

<dependency>
    <groupId>org.sameersingh.scalaplot</groupId>
    <artifactId>scalaplot</artifactId>
    <version>0.0.4</version>
</dependency>
<dependency>
    <groupId>args4j</groupId>
    <artifactId>args4j</artifactId>
    <version>2.0.29</version>
</dependency>

做得好!一切准备就绪,我们可以开始编码了!

实现一个用于 HAR 的 LSTM 模型

整体算法(HumanAR.scala)的工作流程如下:

  • 加载数据

  • 定义超参数

  • 使用命令式编程和超参数设置 LSTM 模型

  • 应用批处理训练,即选择批量大小的数据,将其输入模型,然后在若干次迭代中评估模型,打印批次损失和准确率

  • 输出训练和测试误差的图表

前面的步骤可以通过管道方式进行跟踪和构建:

图 10:生成的 MXNet 预构建二进制文件

现在让我们一步一步地开始实现。确保你理解每一行代码,然后将给定的项目导入到 Eclipse 或 SBT 中。

步骤 1 - 导入必要的库和包

现在开始编码吧。我们从最基础的开始,也就是导入库和包:

package com.packt.ScalaML.HAR 

import ml.dmlc.mxnet.Context 
import LSTMNetworkConstructor.LSTMModel 
import scala.collection.mutable.ArrayBuffer 
import ml.dmlc.mxnet.optimizer.Adam 
import ml.dmlc.mxnet.NDArray 
import ml.dmlc.mxnet.optimizer.RMSProp 
import org.sameersingh.scalaplot.MemXYSeries 
import org.sameersingh.scalaplot.XYData 
import org.sameersingh.scalaplot.XYChart 
import org.sameersingh.scalaplot.Style._ 
import org.sameersingh.scalaplot.gnuplot.GnuplotPlotter 
import org.sameersingh.scalaplot.jfreegraph.JFGraphPlotter  

步骤 2 - 创建 MXNet 上下文

然后我们为基于 CPU 的计算创建一个 MXNet 上下文。由于我是在使用 CPU,所以我为 CPU 实例化了它。如果你已经配置了 GPU,可以通过提供设备 ID 来使用 GPU:

// Retrieves the name of this Context object 
val ctx = Context.cpu() 

第 3 步 - 加载和解析训练集与测试集

现在让我们加载数据集。我假设你已经将数据集复制到UCI_HAR_Dataset/目录下。然后,还需要将其他数据文件放在之前描述的地方:

val datasetPath = "UCI_HAR_Dataset/" 
val trainDataPath = s"$datasetPath/train/Inertial Signals" 
val trainLabelPath = s"$datasetPath/train/y_train.txt" 
val testDataPath = s"$datasetPath/test/Inertial Signals" 
val testLabelPath = s"$datasetPath/test/y_test.txt" 

现在是时候分别加载训练集和测试集了。为此,我写了两个方法,分别是loadData()loadLabels(),它们位于Utils.scala文件中。这两个方法及其签名稍后会提供:

val trainData = Utils.loadData(trainDataPath, "train") 
val trainLabels = Utils.loadLabels(trainLabelPath) 
val testData = Utils.loadData(testDataPath, "test") 
val testLabels = Utils.loadLabels(testLabelPath) 

loadData()方法加载并映射来自每个.txt文件的数据,基于INPUT_SIGNAL_TYPES数组中定义的输入信号类型,格式为Array[Array[Array[Float]]]

def loadData(dataPath: String, name: String): Array[Array[Array[Float]]] = { 
    val dataSignalsPaths = INPUT_SIGNAL_TYPES.map( signal => s"$dataPath/${signal}${name}.txt" ) 
    val signals = dataSignalsPaths.map { path =>  
      Source.fromFile(path).mkString.split("n").map { line =>  
        line.replaceAll("  ", " ").trim().split(" ").map(_.toFloat) } 
    } 

    val inputDim = signals.length 
    val numSamples = signals(0).length 
    val timeStep = signals(0)(0).length   

    (0 until numSamples).map { n =>  
      (0 until timeStep).map { t => 
        (0 until inputDim).map( i => signals(i)(n)(t) ).toArray 
      }
    .toArray 
    }
    .toArray 
  } 

如前所述,INPUT_SIGNAL_TYPES包含了一些有用的常量:它们是神经网络的独立、归一化输入特征:

private val INPUT_SIGNAL_TYPES = Array( 
    "body_acc_x_", 
    "body_acc_y_", 
    "body_acc_z_", 
    "body_gyro_x_", 
    "body_gyro_y_", 
    "body_gyro_z_", 
    "total_acc_x_", 
    "total_acc_y_", 
    "total_acc_z_") 

另一方面,loadLabels()也是一个用户定义的方法,用于仅加载训练集和测试集中的标签:

def loadLabels(labelPath: String): Array[Float] = {          
       Source.fromFile(labelPath).mkString.split("n").map(_.toFloat - 1)
            } 

标签在另一个数组中定义,如以下代码所示:

// Output classes: used to learn how to classify 
private val LABELS = Array( 
    "WALKING",  
    "WALKING_UPSTAIRS",  
    "WALKING_DOWNSTAIRS",  
    "SITTING",  
    "STANDING",  
    "LAYING") 

第 4 步 - 数据集的探索性分析

现在,让我们来看一些关于训练系列数量的统计数据(如前所述,每个系列之间有 50%的重叠)、测试系列数量、每个系列的时间步数以及每个时间步的输入参数数量:

val trainingDataCount = trainData.length // No. of training series  
val testDataCount = testData.length // No. of testing series 
val nSteps = trainData(0).length // No. of timesteps per series 
val nInput = trainData(0)(0).length // No. of input parameters per timestep 

println("Number of training series: "+ trainingDataCount) 
println("Number of test series: "+ testDataCount) 
println("Number of timestep per series: "+ nSteps) 
println("Number of input parameters per timestep: "+ nInput) 
>>>

输出结果是:

Number of training series: 7352
Number of test series: 2947
Number of timestep per series: 128
Number of input parameters per timestep: 9

第 5 步 - 定义内部 RNN 结构和 LSTM 超参数

现在,让我们定义 LSTM 网络的内部神经网络结构和超参数:

val nHidden = 128 // Number of features in a hidden layer  
val nClasses = 6 // Total classes to be predicted  

val learningRate = 0.001f 
val trainingIters = trainingDataCount * 100  // iterate 100 times on trainset: total 7352000 iterations 
val batchSize = 1500 
val displayIter = 15000  // To show test set accuracy during training 
val numLstmLayer = 3 

第 6 步 - LSTM 网络构建

现在,让我们使用前述参数和结构来设置 LSTM 模型:

val model = LSTMNetworkConstructor.setupModel(nSteps, nInput, nHidden, nClasses, batchSize, ctx = ctx) 

在前述行中,setupModel()是完成此任务的方法。getSymbol()方法实际上构建了 LSTM 单元。稍后我们将看到它的签名。它接受序列长度、输入数量、隐藏层数量、标签数量、批次大小、LSTM 层数量、丢弃率 MXNet 上下文,并使用LSTMModel的 case 类构建 LSTM 模型:

case class LSTMModel(exec: Executor, symbol: Symbol, data: NDArray, label: NDArray, argsDict: Map[String,                     NDArray], gradDict: Map[String, NDArray]) 

现在这是setupModel()的方法签名:

def setupModel(seqLen: Int, nInput: Int, numHidden: Int, numLabel: Int, batchSize: Int, numLstmLayer: Int = 1, dropout: Float = 0f, ctx: Context = Context.cpu()): LSTMModel = { 
//get the symbolic model 
    val sym = LSTMNetworkConstructor.getSymbol(seqLen, numHidden, numLabel, numLstmLayer = numLstmLayer) 
    val argNames = sym.listArguments() 
    val auxNames = sym.listAuxiliaryStates() 
// defining the initial argument and binding them to the model 
    val initC = for (l <- 0 until numLstmLayer) yield (s"l${l}_init_c", (batchSize, numHidden)) 
    val initH = for (l <- 0 until numLstmLayer) yield (s"l${l}_init_h", (batchSize, numHidden)) 
    val initStates = (initC ++ initH).map(x => x._1 -> Shape(x._2._1, x._2._2)).toMap 
    val dataShapes = Map("data" -> Shape(batchSize, seqLen, nInput)) ++ initStates 
    val (argShapes, outShapes, auxShapes) = sym.inferShape(dataShapes) 

    val initializer = new Uniform(0.1f) 
    val argsDict = argNames.zip(argShapes).map { case (name, shape) => 
       val nda = NDArray.zeros(shape, ctx) 
       if (!dataShapes.contains(name) && name != "softmax_label") { 
         initializer(name, nda) 
       } 
       name -> nda 
    }.toMap 

    val argsGradDict = argNames.zip(argShapes) 
         .filter(x => x._1 != "softmax_label" && x._1 != "data") 
         .map( x => x._1 -> NDArray.zeros(x._2, ctx) ).toMap 

    val auxDict = auxNames.zip(auxShapes.map(NDArray.zeros(_, ctx))).toMap 
    val exec = sym.bind(ctx, argsDict, argsGradDict, "write", auxDict, null, null) 
    val data = argsDict("data") 
    val label = argsDict("softmax_label")  
    LSTMModel(exec, sym, data, label, argsDict, argsGradDict)
} 

在前述方法中,我们通过getSymbol()方法获得了深度 RNN 的符号模型,如下所示。我已经提供了详细的注释,并认为这些足以理解代码的工作流程:

 private def getSymbol(seqLen: Int, numHidden: Int, numLabel: Int, numLstmLayer: Int = 1, 
                        dropout: Float = 0f): Symbol = {  
                //symbolic training and label variables 
                var inputX = Symbol.Variable("data") 
                val inputY = Symbol.Variable("softmax_label") 

                //the initial parameters and cells 
                var paramCells = Array[LSTMParam]() 
                var lastStates = Array[LSTMState]() 
                //numLstmLayer is 1  
                for (i <- 0 until numLstmLayer) { 
                    paramCells = paramCells :+ LSTMParam(i2hWeight =
                    Symbol.Variable(s"l${i}_i2h_weight"), 
                    i2hBias = Symbol.Variable(s"l${i}_i2h_bias"),                                                                                     
                    h2hWeight = Symbol.Variable(s"l${i}_h2h_weight"),                                                                                                                                   
                    h2hBias = Symbol.Variable(s"l${i}_h2h_bias")) 
                    lastStates = lastStates :+ LSTMState(c =
                    Symbol.Variable(s"l${i}_init_c"),                                                                      
                    h = Symbol.Variable(s"l${i}_init_h")) 
            } 
            assert(lastStates.length == numLstmLayer) 
            val lstmInputs = Symbol.SliceChannel()(inputX)(Map("axis" 
            > 1, "num_outputs" -> seqLen,       
            "squeeze_axis" -> 1)) 

            var hiddenAll = Array[Symbol]() 
            var dpRatio = 0f 
            var hidden: Symbol = null 

//for each one of the 128 inputs, create a LSTM Cell 
            for (seqIdx <- 0 until seqLen) { 
                  hidden = lstmInputs.get(seqIdx) 
// stack LSTM, where numLstmLayer is 1 so the loop will be executed only one time 
                  for (i <- 0 until numLstmLayer) { 
                        if (i == 0) dpRatio = 0f else dpRatio = dropout 
//for each one of the 128 inputs, create a LSTM Cell 
                        val nextState = lstmCell(numHidden, inData = hidden, 
                          prevState = lastStates(i), 
                          param = paramCells(i), 
                          seqIdx = seqIdx, layerIdx = i, dropout =
                        dpRatio) 
                    hidden = nextState.h // has no effect 
                    lastStates(i) = nextState // has no effect 
              } 
// adding dropout before softmax has no effect- dropout is 0 due to numLstmLayer == 1 
              if (dropout > 0f) hidden = Symbol.Dropout()()(Map("data" -> hidden, "p" -> dropout)) 
// store the lstm cells output layers 
                  hiddenAll = hiddenAll :+ hidden
    } 

总结一下,该算法使用 128 个 LSTM 单元并行工作,我将这 128 个单元连接起来并送入输出激活层。让我们来连接这些单元,输出结果:

val finalOut = hiddenAll.reduce(_+_) 

然后我们将它们连接到一个输出层,该层对应 6 个标签:

 val fc = Symbol.FullyConnected()()(Map("data" -> finalOut, "num_hidden" -> numLabel)) 
 //softmax activation against the label 
 Symbol.SoftmaxOutput()()(Map("data" -> fc, "label" -> inputY)) 

在前面的代码片段中,LSTMStateLSTMParam是两个案例类,用于定义每个 LSTM 单元的状态,后者接受构建 LSTM 单元所需的参数。最终案例类LSTMState(c: Symbol, h: Symbol)LSTMParam(i2hWeight: Symbol, i2hBias: Symbol, h2hWeight: Symbol, h2hBias: Symbol)

现在是讨论最重要的步骤——LSTM 单元构建的时候了。我们将使用一些图示和图例,如下图所示:

图 11:在接下来的内容中描述 LSTM 单元所用的图例

LSTM 中的重复模块包含四个相互作用的层,如下图所示:

图 12:在 LSTM 单元内部,即 LSTM 中的重复模块包含四个相互作用的层

一个 LSTM 单元由其状态和参数定义,如前面两个案例类所定义:

  • LSTM 状态c是单元状态(它的记忆知识),用于训练过程中,h是输出

  • LSTM 参数:通过训练算法进行优化

  • i2hWeight:输入到隐藏的权重

  • i2hBias:输入到隐藏的偏置

  • h2hWeight:隐藏到隐藏的权重

  • h2hBias:隐藏到隐藏的偏置

  • i2h:输入数据的神经网络

  • h2h:来自前一个h的神经网络

在代码中,两个全连接层已经创建、连接,并通过以下代码转换为四个副本。让我们添加一个大小为numHidden * 4numHidden设置为 28)的隐藏层,它以inputdata作为输入:

val i2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_i2h")()(Map("data" -> inDataa, "weight" ->                 param.i2hWeight, "bias" -> param.i2hBias, "num_hidden" -> numHidden * 4)) 

然后我们添加一个大小为numHidden * 4numHidden设置为 28)的隐藏层,它以单元的先前输出作为输入:

val h2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_h2h")()(Map("data" -> prevState.h,"weight" ->             param.h2hWeight,"bias" -> param.h2hBias,"num_hidden" -> numHidden * 4)) 

现在让我们将它们连接起来:

val gates = i2h + h2h 

然后我们在计算门之前制作四个副本:

val sliceGates = Symbol.SliceChannel(s"t${seqIdx}_l${layerIdx}_slice")(gates)(Map("num_outputs" -> 4)) 

然后我们计算各个门:

val sliceGates = Symbol.SliceChannel(s"t${seqIdx}_l${layerIdx}_slice")(gates)(Map("num_outputs" -> 4)) 

现在,遗忘门的激活表示为以下代码:

val forgetGate = Symbol.Activation()()(Map("data" -> sliceGates.get(2), "act_type" -> "sigmoid")) 

我们可以在以下图示中看到这一点:

图 13:LSTM 单元中的遗忘门

现在,输入门和输入变换的激活表示为以下代码:

val ingate = Symbol.Activation()()(Map("data" -> sliceGates.get(0), "act_type" -> "sigmoid"))   
val inTransform = Symbol.Activation()()(Map("data" -> sliceGates.get(1), "act_type" -> "tanh")) 

我们也可以在图 14中看到这一点:

图 14:LSTM 单元中的输入门和变换门

下一个状态由以下代码定义:

val nextC = (forgetGate * prevState.c) + (ingate * inTransform) 

前面的代码也可以用以下图示表示:

图 15:LSTM 单元中的下一个或转换门

最后,输出门可以用以下代码表示:

val nextH = outGate * Symbol.Activation()()(Map("data" -> nextC, "act_type" -> "tanh")) 

前面的代码也可以用以下图示表示:

图 16:LSTM 单元中的输出门

太复杂了?没关系,这里我提供了该方法的完整代码:

  // LSTM Cell symbol 
  private def lstmCell( numHidden: Int, inData: Symbol, prevState: LSTMState, param: LSTMParam, 
                        seqIdx: Int, layerIdx: Int, dropout: Float = 0f): LSTMState = { 
        val inDataa = { 
              if (dropout > 0f) Symbol.Dropout()()(Map("data" -> inData, "p" -> dropout)) 
              else inData 
                } 
        // add an hidden layer of size numHidden * 4 (numHidden set //to 28) that takes as input) 
        val i2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_i2h")()(Map("data" -> inDataa,"weight"                             -> param.i2hWeight,"bias" -> param.i2hBias,"num_hidden" -> numHidden * 4)) 
        // add an hidden layer of size numHidden * 4 (numHidden set to 28) that takes output of the cell  
        val h2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_h2h")()(Map("data" ->                                    prevState.h,"weight" -> param.h2hWeight,"bias" -> param.h2hBias,"num_hidden" -> numHidden * 4)) 

        //concatenate them                                        
        val gates = i2h + h2h  

        //make 4 copies of gates 
        val sliceGates=Symbol.SliceChannel(s"t${seqIdx}_l${layerIdx}_slice")(gates)(Map("num_outputs" 
       -> 4)) 
        // compute the gates 
        val ingate = Symbol.Activation()()(Map("data" -> sliceGates.get(0), "act_type" -> "sigmoid")) 
        val inTransform = Symbol.Activation()()(Map("data" -> sliceGates.get(1), "act_type" -> "tanh")) 
        val forgetGate = Symbol.Activation()()(Map("data" -> sliceGates.get(2), "act_type" -> "sigmoid")) 
        val outGate = Symbol.Activation()()(Map("data" -> sliceGates.get(3), "act_type" -> "sigmoid")) 
        // get the new cell state and the output 
        val nextC = (forgetGate * prevState.c) + (ingate * inTransform) 
        val nextH = outGate * Symbol.Activation()()(Map("data" -> nextC, "act_type" -> "tanh")) 
        LSTMState(c = nextC, h = nextH) 
  } 

步骤 7 - 设置优化器

正如许多研究人员所建议的,RMSProp优化器帮助 LSTM 网络快速收敛。因此,我也决定在这里使用它:

val opt = new RMSProp(learningRate = learningRate) 

此外,待优化的模型参数是其所有参数,除了训练数据和标签(权重和偏置):

val paramBlocks = model.symbol.listArguments() 
      .filter(x => x != "data" && x != "softmax_label") 
      .zipWithIndex.map { case (name, idx) => 
        val state = opt.createState(idx, model.argsDict(name)) 
        (idx, model.argsDict(name), model.gradDict(name), state, name) 
      }
    .toArray 

第 8 步 - 训练 LSTM 网络

现在我们将开始训练 LSTM 网络。不过,在开始之前,我们先定义一些变量来跟踪训练的表现:

val testLosses = ArrayBuffer[Float]() 
val testAccuracies = ArrayBuffer[Float]() 
val trainLosses = ArrayBuffer[Float]() 
val trainAccuracies = ArrayBuffer[Float]()     

然后,我们开始执行训练步骤,每次循环进行batch_size次迭代:

var step = 1 
while (step * batchSize <= trainingIters) { 
    val (batchTrainData, batchTrainLabel) = { 
        val idx = ((step - 1) * batchSize) % trainingDataCount 
        if (idx + batchSize <= trainingDataCount) { 
          val datas = trainData.drop(idx).take(batchSize) 
          val labels = trainLabels.drop(idx).take(batchSize) 
          (datas, labels) 
        } else { 
          val right = (idx + batchSize) - trainingDataCount 
          val left = trainingDataCount - idx 
          val datas = trainData.drop(idx).take(left) ++ trainData.take(right) 
          val labels = trainLabels.drop(idx).take(left) ++ trainLabels.take(right) 
          (datas, labels) 
    }  
} 

不要偏离主题,但快速回顾一下第 6 步,我们在这里实例化了 LSTM 模型。现在是时候将输入和标签传递给 RNN 了:

model.data.set(batchTrainData.flatten.flatten) 
model.label.set(batchTrainLabel) 

然后我们进行前向和后向传播:

model.exec.forward(isTrain = true) 
model.exec.backward() 

此外,我们需要使用在第 7 步中定义的RMSProp优化器来更新参数:

paramBlocks.foreach { 
 case (idx, weight, grad, state, name) => opt.update(idx, weight, grad, state) 
    } 

获取如训练误差(即训练数据上的损失和准确度)等指标也会非常有用:

val (acc, loss) = getAccAndLoss(model.exec.outputs(0), batchTrainLabel) 
      trainLosses += loss / batchSize 
      trainAccuracies += acc / batchSize 

在前面的代码段中,getAccAndLoss()是一个计算损失和准确度的方法,具体实现如下:

def getAccAndLoss(pred: NDArray, label: Array[Float], dropNum: Int = 0): (Float, Float) = { 
    val shape = pred.shape 
    val maxIdx = NDArray.argmax_channel(pred).toArray 
    val acc = { 
      val sum = maxIdx.drop(dropNum).zip(label.drop(dropNum)).foldLeft(0f){ case (acc, elem) =>  
        if (elem._1 == elem._2) acc + 1 else acc 
      } 
      sum 
    } 
    val loss = pred.toArray.grouped(shape(1)).drop(dropNum).zipWithIndex.map { case (array, idx) => 
        array(maxIdx(idx).toInt)   
      }.map(-Math.log(_)).sum.toFloat    
 (acc, loss)  
} 

此外,为了更快的训练,评估网络的某些步骤是很令人兴奋的:

if ( (step * batchSize % displayIter == 0) || (step == 1) || (step * batchSize > trainingIters) ) { 
        println(s"Iter ${step * batchSize}, Batch Loss = ${"%.6f".format(loss / batchSize)}, 
        Accuracy = ${acc / batchSize}") 
    }
Iter 1500, Batch Loss = 1.189168, Accuracy = 0.14266667
 Iter 15000, Batch Loss = 0.479527, Accuracy = 0.53866667
 Iter 30000, Batch Loss = 0.293270, Accuracy = 0.83933336
 Iter 45000, Batch Loss = 0.192152, Accuracy = 0.78933334
 Iter 60000, Batch Loss = 0.118560, Accuracy = 0.9173333
 Iter 75000, Batch Loss = 0.081408, Accuracy = 0.9486667
 Iter 90000, Batch Loss = 0.109803, Accuracy = 0.9266667
 Iter 105000, Batch Loss = 0.095064, Accuracy = 0.924
 Iter 120000, Batch Loss = 0.087000, Accuracy = 0.9533333
 Iter 135000, Batch Loss = 0.085708, Accuracy = 0.966
 Iter 150000, Batch Loss = 0.068692, Accuracy = 0.9573333
 Iter 165000, Batch Loss = 0.070618, Accuracy = 0.906
 Iter 180000, Batch Loss = 0.089659, Accuracy = 0.908
 Iter 195000, Batch Loss = 0.088301, Accuracy = 0.87333333
 Iter 210000, Batch Loss = 0.067824, Accuracy = 0.9026667
 Iter 225000, Batch Loss = 0.060650, Accuracy = 0.9033333
 Iter 240000, Batch Loss = 0.045368, Accuracy = 0.93733335
 Iter 255000, Batch Loss = 0.049854, Accuracy = 0.96
 Iter 270000, Batch Loss = 0.062839, Accuracy = 0.968
 Iter 285000, Batch Loss = 0.052522, Accuracy = 0.986
 Iter 300000, Batch Loss = 0.060304, Accuracy = 0.98733336
 Iter 315000, Batch Loss = 0.049382, Accuracy = 0.9993333
 Iter 330000, Batch Loss = 0.052441, Accuracy = 0.9766667
 Iter 345000, Batch Loss = 0.050224, Accuracy = 0.9546667
 Iter 360000, Batch Loss = 0.057141, Accuracy = 0.9306667
 Iter 375000, Batch Loss = 0.047664, Accuracy = 0.938
 Iter 390000, Batch Loss = 0.047909, Accuracy = 0.93333334
 Iter 405000, Batch Loss = 0.043014, Accuracy = 0.9533333
 Iter 420000, Batch Loss = 0.054124, Accuracy = 0.952
 Iter 435000, Batch Loss = 0.044272, Accuracy = 0.95133334
 Iter 450000, Batch Loss = 0.058916, Accuracy = 0.96066666
 Iter 465000, Batch Loss = 0.072512, Accuracy = 0.9486667
 Iter 480000, Batch Loss = 0.080431, Accuracy = 0.94733334
 Iter 495000, Batch Loss = 0.072193, Accuracy = 0.9726667
 Iter 510000, Batch Loss = 0.068242, Accuracy = 0.972
 Iter 525000, Batch Loss = 0.057797, Accuracy = 0.964
 Iter 540000, Batch Loss = 0.063531, Accuracy = 0.918
 Iter 555000, Batch Loss = 0.068177, Accuracy = 0.9126667
 Iter 570000, Batch Loss = 0.053257, Accuracy = 0.9206667
 Iter 585000, Batch Loss = 0.058263, Accuracy = 0.9113333
 Iter 600000, Batch Loss = 0.054180, Accuracy = 0.90466666
 Iter 615000, Batch Loss = 0.051008, Accuracy = 0.944
 Iter 630000, Batch Loss = 0.051554, Accuracy = 0.966
 Iter 645000, Batch Loss = 0.059238, Accuracy = 0.9686667
 Iter 660000, Batch Loss = 0.051297, Accuracy = 0.9713333
 Iter 675000, Batch Loss = 0.052069, Accuracy = 0.984
 Iter 690000, Batch Loss = 0.040501, Accuracy = 0.998
 Iter 705000, Batch Loss = 0.053661, Accuracy = 0.96066666
 ter 720000, Batch Loss = 0.037088, Accuracy = 0.958
 Iter 735000, Batch Loss = 0.039404, Accuracy = 0.9533333

第 9 步 - 评估模型

做得好!我们已经完成了训练。现在如何评估测试集呢:

 val (testLoss, testAcc) = test(testDataCount, batchSize, testData, testLabels, model)         
  println(s"TEST SET DISPLAY STEP:  Batch Loss = ${"%.6f".format(testLoss)}, Accuracy = $testAcc") 
        testAccuracies += testAcc 
        testLosses += testLoss 
      } 
      step += 1 
    }     
  val (finalLoss, accuracy) = test(testDataCount, batchSize, testData, testLabels, model) 
  println(s"FINAL RESULT: Batch Loss= $finalLoss, Accuracy= $accuracy") 
TEST SET DISPLAY STEP: Batch Loss = 0.065859, Accuracy = 0.9138107
 TEST SET DISPLAY STEP: Batch Loss = 0.077047, Accuracy = 0.912114
 TEST SET DISPLAY STEP: Batch Loss = 0.069186, Accuracy = 0.90566677
 TEST SET DISPLAY STEP: Batch Loss = 0.059815, Accuracy = 0.93043774
 TEST SET DISPLAY STEP: Batch Loss = 0.064162, Accuracy = 0.9192399
 TEST SET DISPLAY STEP: Batch Loss = 0.063574, Accuracy = 0.9307771
 TEST SET DISPLAY STEP: Batch Loss = 0.060209, Accuracy = 0.9229725
 TEST SET DISPLAY STEP: Batch Loss = 0.062598, Accuracy = 0.9290804
 TEST SET DISPLAY STEP: Batch Loss = 0.062686, Accuracy = 0.9311164
 TEST SET DISPLAY STEP: Batch Loss = 0.059543, Accuracy = 0.9250085
 TEST SET DISPLAY STEP: Batch Loss = 0.059646, Accuracy = 0.9263658
 TEST SET DISPLAY STEP: Batch Loss = 0.062546, Accuracy = 0.92941976
 TEST SET DISPLAY STEP: Batch Loss = 0.061765, Accuracy = 0.9263658
 TEST SET DISPLAY STEP: Batch Loss = 0.063814, Accuracy = 0.9307771
 TEST SET DISPLAY STEP: Batch Loss = 0.062560, Accuracy = 0.9324737
 TEST SET DISPLAY STEP: Batch Loss = 0.061307, Accuracy = 0.93518835
 TEST SET DISPLAY STEP: Batch Loss = 0.061102, Accuracy = 0.93281305
 TEST SET DISPLAY STEP: Batch Loss = 0.054946, Accuracy = 0.9375636
 TEST SET DISPLAY STEP: Batch Loss = 0.054461, Accuracy = 0.9365456
 TEST SET DISPLAY STEP: Batch Loss = 0.050856, Accuracy = 0.9290804
 TEST SET DISPLAY STEP: Batch Loss = 0.050600, Accuracy = 0.9334917
 TEST SET DISPLAY STEP: Batch Loss = 0.057579, Accuracy = 0.9277231
 TEST SET DISPLAY STEP: Batch Loss = 0.062409, Accuracy = 0.9324737
 TEST SET DISPLAY STEP: Batch Loss = 0.050926, Accuracy = 0.9409569
 TEST SET DISPLAY STEP: Batch Loss = 0.054567, Accuracy = 0.94027823
 FINAL RESULT: Batch Loss= 0.0545671,
 Accuracy= 0.94027823

哇!我们成功达到了 94%的准确度,真是非常棒。在之前的代码中,test()是用来评估模型性能的方法。模型的签名如下所示:

def test(testDataCount: Int, batchSize: Int, testDatas: Array[Array[Array[Float]]], 
      testLabels: Array[Float], model: LSTMModel): (Float, Float) = { 
    var testLoss, testAcc = 0f 
    for (begin <- 0 until testDataCount by batchSize) { 
      val (testData, testLabel, dropNum) = { 
        if (begin + batchSize <= testDataCount) { 
          val datas = testDatas.drop(begin).take(batchSize) 
          val labels = testLabels.drop(begin).take(batchSize) 
          (datas, labels, 0) 
        } else { 
          val right = (begin + batchSize) - testDataCount 
          val left = testDataCount - begin 
          val datas = testDatas.drop(begin).take(left) ++ testDatas.take(right) 
          val labels = testLabels.drop(begin).take(left) ++ testLabels.take(right) 
          (datas, labels, right) 
        } 
      } 
      //feed the test data to the deepNN 
      model.data.set(testData.flatten.flatten) 
      model.label.set(testLabel) 

      model.exec.forward(isTrain = false) 
      val (acc, loss) = getAccAndLoss(model.exec.outputs(0), testLabel) 
      testLoss += loss 
      testAcc += acc 
    } 
    (testLoss / testDataCount, testAcc / testDataCount) 
  } 

完成后,最好销毁模型以释放资源:

model.exec.dispose() 

我们之前看到,在测试集上取得了高达 93%的准确率。那么,如何通过图形展示之前的准确度和误差呢:

    // visualize 
    val xTrain = (0 until trainLosses.length * batchSize by batchSize).toArray.map(_.toDouble) 
    val yTrainL = trainLosses.toArray.map(_.toDouble) 
    val yTrainA = trainAccuracies.toArray.map(_.toDouble) 

    val xTest = (0 until testLosses.length * displayIter by displayIter).toArray.map(_.toDouble) 
    val yTestL = testLosses.toArray.map(_.toDouble) 
    val yTestA = testAccuracies.toArray.map(_.toDouble) 
    var series = new MemXYSeries(xTrain, yTrainL, "Train losses") 
    val data = new XYData(series)       
    series = new MemXYSeries(xTrain, yTrainA, "Train accuracies") 
    data += series 
    series = new MemXYSeries(xTest, yTestL, "Test losses") 
    data += series     
    series = new MemXYSeries(xTest, yTestA, "Test accuracies") 
    data += series 
    val chart = new XYChart("Training session's progress over iterations!", data) 
    chart.showLegend = true 
    val plotter = new JFGraphPlotter(chart)
    plotter.gui() 
>>>

图 17:每次迭代的训练和测试损失及准确度

从前面的图表来看,很明显,经过几次迭代,我们的 LSTM 模型很好地收敛,并且产生了非常好的分类准确度。

调整 LSTM 超参数和 GRU

然而,我仍然相信,通过增加更多的 LSTM 层,能够达到接近 100%的准确率。以下是我仍然会尝试调整的超参数,以便查看准确度:

// Hyper parameters for the LSTM training
val learningRate = 0.001f
val trainingIters = trainingDataCount * 1000 // Loop 1000 times on the dataset
val batchSize = 1500 // I would set it 5000 and see the performance
val displayIter = 15000 // To show test set accuracy during training
val numLstmLayer = 3 // 5, 7, 9 etc.

LSTM 单元有很多其他变种。其中一个特别流行的变种是门控循环单元GRU)单元,它是 LSTM 的稍微变化形式。它还将单元状态和隐藏状态合并,并做了一些其他改动。结果模型比标准的 LSTM 模型更简单,并且越来越受欢迎。这个单元是 Kyunghyun Cho 等人在 2014 年的一篇论文中提出的,论文还介绍了我们之前提到的编码器-解码器网络。

对于这种类型的 LSTM,感兴趣的读者可以参考以下文献:

  • 使用 RNN 编码器-解码器进行统计机器翻译的学习短语表示,K. Cho 等人(2014)。

  • Klaus Greff 等人于 2015 年发表的论文 LSTM: A Search Space Odyssey,似乎表明所有 LSTM 变体的表现大致相同。

从技术上讲,GRU 单元是 LSTM 单元的简化版,其中两个状态向量合并成一个叫做 h(t) 的向量。一个单一的门控制器控制着遗忘门和输入门。如果门控制器输出 1,输入门打开,遗忘门关闭:

图 18:GRU 单元的内部结构

另一方面,如果输出为 0,则会发生相反的情况。每当需要存储记忆时,首先会清除它将被存储的位置,这实际上是 LSTM 单元的一种常见变体。第二个简化是,由于每个时间步都会输出完整的状态向量,因此没有输出门。然而,引入了一个新的门控制器,用来控制前一个状态的哪个部分会显示给主层。以下方程用于进行 GRU 单元在每个时间步长的长短期状态计算及输出:

LSTM 和 GRU 单元是近年来 RNN 成功的主要原因之一,尤其是在 NLP 应用中。

总结

在本章中,我们已经学习了如何使用 RNN 实现开发 ML 项目,并使用智能手机数据集进行 HAR 的 LSTM 模型。我们的 LSTM 模型能够从六个类别中分类运动类型:步行、走楼梯、下楼梯、坐着、站着和躺着。特别地,我们达到了 94% 的准确率。接着,我们讨论了如何通过使用 GRU 单元进一步提高准确性的一些可能方法。

卷积神经网络CNN)是一种前馈神经网络,其中神经元之间的连接模式受到动物视觉皮层的启发。近年来,CNN 在复杂的视觉任务中表现出超越人类的性能,如图像搜索服务、自动驾驶汽车、自动视频分类、语音识别和 自然语言处理NLP)。

考虑到这些,在下一章我们将看到如何开发一个端到端项目,使用基于 Scala 和 Deeplearning4j 框架的 CNN 来处理多标签(即每个实体可以属于多个类别)图像分类问题,并且使用真实的 Yelp 图像数据集。我们还将在开始之前讨论一些 CNN 的理论方面。更进一步地,我们将讨论如何调整超参数,以获得更好的分类结果。

第十一章:使用卷积神经网络进行图像分类

到目前为止,我们还没有开发过任何用于图像处理任务的 机器学习ML)项目。线性机器学习模型和其他常规 深度神经网络DNN)模型,例如 多层感知器MLPs)或 深度置信网络DBNs),无法从图像中学习或建模非线性特征。

另一方面,卷积神经网络CNN)是一种前馈神经网络,其中神经元之间的连接模式受到动物视觉皮层的启发。在过去的几年里,CNN 在复杂视觉任务中展现了超越人类的表现,例如图像搜索服务、自动驾驶汽车、自动视频分类、语音识别和 自然语言处理NLP)。

在本章中,我们将看到如何基于 Scala 和 Deeplearning4jDL4j)框架,使用真实的 Yelp 图像数据集开发一个端到端的多标签(即每个实体可以属于多个类别)图像分类项目。我们还将讨论一些 CNN 的理论方面,以及如何调整超参数以获得更好的分类结果,之后再开始实际操作。

简而言之,在这个端到端项目中,我们将学习以下主题:

  • 常规 DNN 的缺点

  • CNN 架构:卷积操作和池化层

  • 使用 CNN 进行图像分类

  • 调整 CNN 超参数

图像分类和 DNN 的缺点

在我们开始开发基于 CNN 的图像分类端到端项目之前,我们需要进行一些背景学习,比如常规 DNN 的缺点、CNN 相较于 DNN 在图像分类中的适用性、CNN 的构建方式、CNN 的不同操作等。虽然常规 DNN 对于小尺寸图像(例如 MNIST、CIFAR-10)效果良好,但对于更大尺寸的图像,它会因为所需的巨大参数数量而崩溃。例如,一张 100 x 100 的图像有 10,000 个像素,如果第一层只有 1,000 个神经元(这已经极大限制了传递到下一层的信息量),那么就意味着总共有 1,000 万个连接。而这仅仅是第一层的情况。

CNN 通过使用部分连接层解决了这个问题。由于连续层之间仅部分连接,并且由于其权重的高度重用,CNN 的参数远少于完全连接的 DNN,这使得它训练速度更快,减少了过拟合的风险,并且需要的训练数据量更少。此外,当 CNN 学会了能够检测某一特征的卷积核时,它可以在图像的任何位置检测到该特征。相比之下,当 DNN 在某个位置学到一个特征时,它只能在该位置检测到该特征。由于图像通常具有非常重复的特征,CNN 在图像处理任务(如分类)中能够比 DNN 更好地进行泛化,并且只需较少的训练样本。

重要的是,DNN 并不事先了解像素是如何组织的;它不知道相邻的像素是彼此接近的。而 CNN 的架构则将这种先验知识嵌入其中。较低层通常识别图像的小区域中的特征,而较高层则将低级特征组合成更大的特征。这在大多数自然图像中效果很好,使 CNN 相比 DNN 具有决定性的优势:

图 1:普通 DNN 与 CNN

例如,在图 1中,左侧显示了一个普通的三层神经网络。右侧,ConvNet 将它的神经元以三维(宽度、高度和深度)进行排列,如其中一层的可视化所示。ConvNet 的每一层将 3D 输入体积转换为 3D 输出体积的神经元激活。红色输入层承载着图像,因此它的宽度和高度就是图像的维度,而深度则是三(红色、绿色和蓝色通道)。

所以,我们之前看到的所有多层神经网络的层都是由一长串神经元组成的,在将输入图像或数据传递给神经网络之前,我们需要将其展平成 1D。但是,当你尝试直接将 2D 图像输入时会发生什么呢?答案是,在 CNN 中,每一层都是以 2D 形式表示的,这使得将神经元与它们对应的输入进行匹配变得更加容易。我们将在接下来的部分看到相关示例。

另一个重要的事实是,特征图中的所有神经元共享相同的参数,因此它显著减少了模型中的参数数量,但更重要的是,它意味着一旦 CNN 学会在某个位置识别某个模式,它就能够在任何其他位置识别该模式。相比之下,一旦普通的 DNN 学会在某个位置识别某个模式,它只能在那个特定位置识别该模式。

CNN 架构

在多层网络中,例如 MLP 或 DBN,输入层所有神经元的输出都连接到隐藏层中的每个神经元,因此输出将再次作为输入传递给全连接层。而在 CNN 网络中,定义卷积层的连接方式有显著不同。卷积层是 CNN 中的主要层类型,每个神经元都连接到输入区域的某个区域,这个区域称为感受野

在典型的 CNN 架构中,几个卷积层以级联样式连接,每一层后面跟着一个整流线性单元ReLU)层,然后是一个池化层,再接几个卷积层(+ReLU),再接另一个池化层,如此循环。

每个卷积层的输出是一组由单个核过滤器生成的对象,称为特征图。这些特征图可以用来定义下一个层的输入。CNN 网络中的每个神经元都会生成一个输出,之后是一个激活阈值,该阈值与输入成比例,并且没有限制:

图 2:CNN 的概念架构

图 2所示,池化层通常被放置在卷积层之后。然后,卷积区域会被池化层划分为子区域。接着,使用最大池化或平均池化技术选择一个代表性值,从而减少后续层的计算时间。

这样,特征相对于其空间位置的鲁棒性也得到了增强。更具体地说,当图像特性(作为特征图)通过网络时,它们随着网络的推进变得越来越小,但通常会变得更深,因为更多的特征图将被添加到网络中。在堆叠的顶部,加入了一个常规的前馈神经网络,就像 MLP 一样,可能由几层全连接层(+ReLUs)组成,最后一层输出预测结果,例如,softmax 层输出多类分类的估计类别概率。

卷积操作

卷积是一个数学运算,它将一个函数滑过另一个函数,并测量它们逐点相乘的积分。它与傅里叶变换和拉普拉斯变换有着深刻的联系,并广泛应用于信号处理。卷积层实际上使用的是互相关,它与卷积非常相似。

因此,CNN 的最重要组成部分是卷积层:第一卷积层中的神经元并不是与输入图像中的每一个像素相连接(如同前几章所述),而只是与它们感受野中的像素相连接——见图 3。反过来,第二卷积层中的每个神经元仅与第一层中位于小矩形区域内的神经元相连接:

图 3:具有矩形局部感受野的 CNN 层

这种架构使得网络能够在第一隐藏层中集中关注低级特征,然后在接下来的隐藏层中将它们组装成更高级的特征,依此类推。这种层次结构在现实世界的图像中很常见,这也是 CNN 在图像识别中表现如此出色的原因之一。

池化层和填充操作

一旦你理解了卷积层的工作原理,池化层就非常容易理解了。池化层通常会独立地处理每一个输入通道,因此输出深度与输入深度相同。你也可以选择在深度维度上进行池化,正如我们接下来将看到的那样,在这种情况下,图像的空间维度(高度和宽度)保持不变,但通道的数量会减少。让我们看一下来自一个知名 TensorFlow 网站的池化层的正式定义:

“池化操作在输入张量上扫过一个矩形窗口,为每个窗口计算一个归约操作(平均、最大值或带有 argmax 的最大值)。每个池化操作使用称为 ksize 的矩形窗口,窗口间隔由偏移步幅定义。例如,如果步幅全为 1,则每个窗口都会被使用;如果步幅全为 2,则每个维度中的每隔一个窗口就会被使用,依此类推。”

因此,就像在卷积层中一样,池化层中的每个神经元与前一层中有限数量的神经元的输出相连接,这些神经元位于一个小的矩形感受野内。你必须像之前一样定义其大小、步幅和填充类型。然而,池化神经元没有权重;它所做的只是使用聚合函数(如最大值或均值)对输入进行聚合。

目标使用池化是为了对子输入图像进行子采样,以减少计算负荷、内存使用和参数数量。这有助于在训练阶段避免过拟合。减少输入图像的大小还使得神经网络能够容忍一定程度的图像位移。在以下示例中,我们使用了 2 x 2 的池化核和步幅为 2 的设置,并且没有填充。只有每个池化核中的最大输入值会传递到下一层,因为其他输入会被丢弃:

图 4:使用最大池化的示例,即子采样

通常,(stride_length) x + filter_size <= input_layer_size* 是大多数基于 CNN 的网络开发中推荐的。

子采样操作

如前所述,位于给定层中的神经元与前一层中神经元的输出相连接。现在,为了使一个层具有与前一层相同的高度和宽度,通常会在输入周围添加零,如图所示。这称为SAME零填充

"SAME"一词意味着输出特征图具有与输入特征图相同的空间维度。零填充被引入,以便在需要时使形状匹配,并且在输入图的每一侧填充相等。另一方面,"VALID"意味着没有填充,只是丢弃最右侧的列(或最下方的行):

图 5:CNN 中的 SAME 与 VALID 填充

现在我们已经掌握了关于 CNN 及其架构的最基本理论知识,是时候动手实践了,使用 Deeplearning4j(简称 DL4j)创建卷积、池化和子采样操作。DL4j 是最早的商业级分布式开源深度学习库之一,专为 Java 和 Scala 编写。它还提供对 Hadoop 和 Spark 的集成支持。DL4j 旨在用于商业环境中的分布式 GPU 和 CPU。

DL4j 中的卷积和子采样操作

在开始之前,设置我们的编程环境是一个先决条件。所以我们先做这个。

配置 DL4j、ND4s 和 ND4j

以下库可以与 DL4j 集成。无论你是在 Java 还是 Scala 中开发机器学习应用程序,它们都会使你的 JVM 体验更加顺畅:

  • DL4j:神经网络平台

  • ND4J:JVM 上的 NumPy

  • DataVec:机器学习 ETL 操作工具

  • JavaCPP:Java 与本地 C++之间的桥梁

  • Arbiter:机器学习算法评估工具

  • RL4J:JVM 上的深度强化学习

ND4j 就像 JVM 上的 NumPy。它提供了一些线性代数的基本操作,例如矩阵创建、加法和乘法。另一方面,ND4S 是一个科学计算库,专注于线性代数和矩阵操作。基本上,它支持 JVM 语言的 n 维数组。

如果你在 Eclipse(或任何其他编辑器,如 IntelliJ IDEA)中使用 Maven,请在pom.xml文件(位于<dependencies>标签内)中使用以下依赖项来解决 DL4j、ND4s 和 ND4j 的依赖问题:

<dependency>
    <groupId>org.deeplearning4j</groupId>
    <artifactId>deeplearning4j-core</artifactId>
    <version>0.4-rc3.9</version>
</dependency>
<dependency>
    <artifactId>canova-api</artifactId>
    <groupId>org.nd4j</groupId>
    <version>0.4-rc3.9</version>
</dependency>
<dependency>
    <groupId>org.nd4j</groupId>
    <artifactId>nd4j-native</artifactId>
    <version>0.4-rc3.9</version>
</dependency>
<dependency>
    <groupId>org.nd4j</groupId>
    <artifactId>canova-api</artifactId>
    <version>0.0.0.17</version>
</dependency>

我使用的是旧版本,因为遇到了一些兼容性问题,但它仍在积极开发中。不过你可以自由地采用最新版本。我相信读者可以轻松完成这一点。

此外,如果你的系统没有配置本地的 BLAS,ND4j 的性能将会下降。一旦你运行简单的 Scala 代码,就会看到警告:

****************************************************************
WARNING: COULD NOT LOAD NATIVE SYSTEM BLAS
ND4J performance WILL be reduced
****************************************************************

然而,安装和配置 BLAS(如 OpenBLAS 或 IntelMKL)并不难,你可以投入一些时间去完成它。详情请参阅以下网址:nd4j.org/getstarted.html#open。还需要注意的是,在使用 DL4j 时,以下是必备条件:

  • Java(开发者版本)1.8+(仅支持 64 位版本)

  • Apache Maven:用于自动构建和依赖管理

  • IntelliJ IDEA 或 Eclipse

  • Git

做得好!我们的编程环境已经准备好进行简单的深度学习应用开发。现在是时候动手编写一些示例代码了。让我们看看如何使用 CIFAR-10 数据集构建和训练一个简单的 CNN。CIFAR-10 是最受欢迎的基准数据集之一,包含成千上万的标注图像。

DL4j 中的卷积和子采样操作

在这一小节中,我们将展示如何构建一个用于 MNIST 数据分类的 CNN 示例。该网络将包含两个卷积层、两个子采样层、一个全连接层和一个输出层。第一层是卷积层,接着是子采样层,随后是另一个卷积层。然后是子采样层,接着是全连接层,最后是输出层。

让我们看看这些层在使用 DL4j 时的表现。第一个卷积层,使用 ReLU 作为激活函数:

val layer_0 = new ConvolutionLayer.Builder(5, 5)
    .nIn(nChannels)
    .stride(1, 1)
    .nOut(20)
    .activation("relu")
    .build()

DL4j 当前支持以下激活函数:

  • ReLU

  • Leaky ReLU

  • Tanh

  • Sigmoid

  • Hard Tanh

  • Softmax

  • Identity

  • ELU指数线性单元

  • Softsign

  • Softplus

第二层(即第一个子采样层)是一个子采样层,池化类型为MAX,卷积核大小为 2 x 2,步幅为 2 x 2,但没有激活函数:

val layer_1 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
    .kernelSize(2, 2)
    .stride(2, 2)
    .build()

第三层(第二个卷积层)是一个卷积层,使用 ReLU 作为激活函数,步幅为 1*1:


val layer_2 = new ConvolutionLayer.Builder(5, 5)
    .nIn(nChannels)
    .stride(1, 1)
    .nOut(50)
    .activation("relu")
    .build()

第四层(即第二个子采样层)是一个子采样层,池化类型为MAX,卷积核大小为 2 x 2,步幅为 2 x 2,但没有激活函数:

val layer_3 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
    .kernelSize(2, 2)
    .stride(2, 2)
    .build()

第五层是一个全连接层,使用 ReLU 作为激活函数:

val layer_4 = new DenseLayer.Builder()
    .activation("relu")
    .nOut(500)
    .build()

第六层(即最后一层全连接层)使用 Softmax 作为激活函数,类别数量为待预测的类别数(即 10):

val layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
    .nOut(outputNum)
    .activation("softmax")
    .build()

一旦各层构建完成,接下来的任务是通过链接所有的层来构建 CNN。使用 DL4j,操作如下:

val builder: MultiLayerConfiguration.Builder = new NeuralNetConfiguration.Builder()
    .seed(seed)
    .iterations(iterations)
    .regularization(true).l2(0.0005)
    .learningRate(0.01)
    .weightInit(WeightInit.XAVIER)
   .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
    .updater(Updater.NESTEROVS).momentum(0.9)
    .list()
        .layer(0, layer_0)
        .layer(1, layer_1)
        .layer(2, layer_2)
        .layer(3, layer_3)
        .layer(4, layer_4)
        .layer(5, layer_5)
    .backprop(true).pretrain(false) // feedforward and supervised so no pretraining

最后,我们设置所有的卷积层并初始化网络,如下所示:

new ConvolutionLayerSetup(builder, 28, 28, 1) //image size is 28*28
val conf: MultiLayerConfiguration = builder.build()
val model: MultiLayerNetwork = new MultiLayerNetwork(conf)
model.init()

按照惯例,要训练一个 CNN,所有的图像需要具有相同的形状和大小。所以我在前面的代码中将尺寸设置为 28 x 28,便于说明。现在,你可能会想,我们如何训练这样的网络呢?好吧,接下来我们就会看到这一点,但在此之前,我们需要准备 MNIST 数据集,使用MnistDataSetIterator()方法,如下所示:

val nChannels = 1 // for grayscale image
val outputNum = 10 // number of class
val nEpochs = 10 // number of epoch
val iterations = 1 // number of iteration
val seed = 12345 // Random seed for reproducibility
val batchSize = 64 // number of batches to be sent
log.info("Load data....")
val mnistTrain: DataSetIterator = new MnistDataSetIterator(batchSize, true, 12345)
val mnistTest: DataSetIterator = new MnistDataSetIterator(batchSize, false, 12345)

现在让我们开始训练 CNN,使用训练集并为每个周期进行迭代:

log.info("Model training started...")
model.setListeners(new ScoreIterationListener(1))
var i = 0
while (i <= nEpochs) {
    model.fit(mnistTrain);
    log.info("*** Completed epoch {} ***", i)
    i = i + 1
    }
var ds: DataSet = null var output: INDArray = null

一旦我们训练好了 CNN,接下来的任务是评估模型在测试集上的表现,如下所示:

log.info("Model evaluation....")
val eval: Evaluation = new Evaluation(outputNum)
while (mnistTest.hasNext()) {
    ds = mnistTest.next()
    output = model.output(ds.getFeatureMatrix(), false)
    }
eval.eval(ds.getLabels(), output)

最后,我们计算一些性能矩阵,如AccuracyPrecisionRecallF1 measure,如下所示:

println("Accuracy: " + eval.accuracy())
println("F1 measure: " + eval.f1())
println("Precision: " + eval.precision())
println("Recall: " + eval.recall())
println("Confusion matrix: " + "n" + eval.confusionToString())
log.info(eval.stats())
mnistTest.reset()
>>>
==========================Scores=======================================
 Accuracy: 1
 Precision: 1
 Recall: 1
 F1 Score: 1
=======================================================================

为了方便你,我在这里提供了这个简单图像分类器的完整源代码:

package com.example.CIFAR

import org.canova.api.records.reader.RecordReader
import org.canova.api.split.FileSplit
import org.canova.image.loader.BaseImageLoader
import org.canova.image.loader.NativeImageLoader
import org.canova.image.recordreader.ImageRecordReader
import org.deeplearning4j.datasets.iterator.DataSetIterator
import org.canova.image.recordreader.ImageRecordReader
import org.deeplearning4j.datasets.canova.RecordReaderDataSetIterator
import org.deeplearning4j.datasets.iterator.impl.MnistDataSetIterator
import org.deeplearning4j.eval.Evaluation
import org.deeplearning4j.nn.api.OptimizationAlgorithm
import org.deeplearning4j.nn.conf.MultiLayerConfiguration
import org.deeplearning4j.nn.conf.NeuralNetConfiguration
import org.deeplearning4j.nn.conf.Updater
import org.deeplearning4j.nn.conf.layers.ConvolutionLayer
import org.deeplearning4j.nn.conf.layers.DenseLayer
import org.deeplearning4j.nn.conf.layers.OutputLayer
import org.deeplearning4j.nn.conf.layers.SubsamplingLayer
import org.deeplearning4j.nn.conf.layers.setup.ConvolutionLayerSetup
import org.deeplearning4j.nn.multilayer.MultiLayerNetwork
import org.deeplearning4j.nn.weights.WeightInit
import org.deeplearning4j.optimize.listeners.ScoreIterationListener
import org.nd4j.linalg.api.ndarray.INDArray
import org.nd4j.linalg.api.rng.Random
import org.nd4j.linalg.dataset.DataSet
import org.nd4j.linalg.dataset.SplitTestAndTrain
import org.nd4j.linalg.lossfunctions.LossFunctions
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import java.util.ArrayList
import java.util.List

object MNIST {
 val log: Logger = LoggerFactory.getLogger(MNIST.getClass)
 def main(args: Array[String]): Unit = {
 val nChannels = 1 // for grayscale image
 val outputNum = 10 // number of class
 val nEpochs = 1 // number of epoch
 val iterations = 1 // number of iteration
 val seed = 12345 // Random seed for reproducibility
 val batchSize = 64 // number of batches to be sent

    log.info("Load data....")
 val mnistTrain: DataSetIterator = new MnistDataSetIterator(batchSize, true, 12345)
 val mnistTest: DataSetIterator = new MnistDataSetIterator(batchSize, false, 12345)

    log.info("Network layer construction started...")
    //First convolution layer with ReLU as activation function
 val layer_0 = new ConvolutionLayer.Builder(5, 5)
        .nIn(nChannels)
        .stride(1, 1)
        .nOut(20)
        .activation("relu")
        .build()

    //First subsampling layer
 val layer_1 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
        .kernelSize(2, 2)
        .stride(2, 2)
        .build()

    //Second convolution layer with ReLU as activation function
 val layer_2 = new ConvolutionLayer.Builder(5, 5)
        .nIn(nChannels)
        .stride(1, 1)
        .nOut(50)
        .activation("relu")
        .build()

    //Second subsampling layer
 val layer_3 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
        .kernelSize(2, 2)
        .stride(2, 2)
        .build()

    //Dense layer
 val layer_4 = new DenseLayer.Builder()
        .activation("relu")
        .nOut(500)
        .build()

    // Final and fully connected layer with Softmax as activation function
 val layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
        .nOut(outputNum)
        .activation("softmax")
        .build()

    log.info("Model building started...")
 val builder: MultiLayerConfiguration.Builder = new NeuralNetConfiguration.Builder()
        .seed(seed)
        .iterations(iterations)
        .regularization(true).l2(0.0005)
        .learningRate(0.01)
        .weightInit(WeightInit.XAVIER)
        .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
        .updater(Updater.NESTEROVS).momentum(0.9)
        .list()
            .layer(0, layer_0)
            .layer(1, layer_1)
            .layer(2, layer_2)
            .layer(3, layer_3)
            .layer(4, layer_4)
            .layer(5, layer_5)
    .backprop(true).pretrain(false) // feedforward so no backprop

// Setting up all the convlutional layers and initialize the network
new ConvolutionLayerSetup(builder, 28, 28, 1) //image size is 28*28
val conf: MultiLayerConfiguration = builder.build()
val model: MultiLayerNetwork = new MultiLayerNetwork(conf)
model.init()

log.info("Model training started...")
model.setListeners(new ScoreIterationListener(1))
 var i = 0
 while (i <= nEpochs) {
        model.fit(mnistTrain);
        log.info("*** Completed epoch {} ***", i)
        i = i + 1
 var ds: DataSet = null
 var output: INDArray = null
        log.info("Model evaluation....")
 val eval: Evaluation = new Evaluation(outputNum)

 while (mnistTest.hasNext()) {
            ds = mnistTest.next()
            output = model.output(ds.getFeatureMatrix(), false)
                }
        eval.eval(ds.getLabels(), output)
        println("Accuracy: " + eval.accuracy())
        println("F1 measure: " + eval.f1())
        println("Precision: " + eval.precision())
        println("Recall: " + eval.recall())
        println("Confusion matrix: " + "n" + eval.confusionToString())
        log.info(eval.stats())
        mnistTest.reset()
                }
    log.info("****************Example finished********************")
            }
    }

使用 CNN 进行大规模图像分类

在本节中,我们将展示一个逐步开发实际 ML 项目(用于图像分类)的示例。然而,我们首先需要了解问题描述,以便知道需要进行什么样的图像分类。此外,在开始之前,了解数据集是必须的。

问题描述

如今,食物自拍和以照片为中心的社交故事讲述正在成为社交趋势。美食爱好者愿意将大量与食物合影的自拍和餐厅的照片上传到社交媒体和相应的网站。当然,他们还会提供书面评论,这能显著提升餐厅的知名度:

图 6:从 Yelp 数据集中挖掘一些商业洞察

例如,数百万独立访问者访问 Yelp 并撰写了超过 1.35 亿条评论。平台上有大量照片和上传照片的用户。商家可以发布照片并与顾客互动。通过这种方式,Yelp 通过向这些本地商家 出售广告 来赚钱。一个有趣的事实是,这些照片提供了丰富的本地商业信息,涵盖了多个类别。因此,训练计算机理解这些照片的上下文并非一件简单的事,也不是一项容易的任务(参考 图 6 获取更多的见解)。

现在,这个项目的理念是充满挑战的:我们如何将这些图片转化为文字?让我们试试看。更具体地说,你将获得属于某个商家的照片。现在我们需要建立一个模型,使其能够自动为餐馆标记多个用户提交的照片标签——也就是说,预测商家的属性。

图像数据集的描述

对于这样的挑战,我们需要一个真实的数据集。别担心,有多个平台提供公开的数据集,或者可以在一定的条款和条件下下载。一种这样的平台是 Kaggle,它为数据分析和机器学习实践者提供了一个平台,参与机器学习挑战并赢取奖品。Yelp 数据集及其描述可以在以下网址找到:www.kaggle.com/c/yelp-restaurant-photo-classification

餐馆的标签是由 Yelp 用户在提交评论时手动选择的。数据集中包含 Yelp 社区标注的九种不同标签:

  • 0: good_for_lunch

  • 1: good_for_dinner

  • 2: takes_reservations

  • 3: outdoor_seating

  • 4: restaurant_is_expensive

  • 5: has_alcohol

  • 6: has_table_service

  • 7: ambience_is_classy

  • 8: good_for_kids

所以我们需要尽可能准确地预测这些标签。需要注意的一点是,由于 Yelp 是一个社区驱动的网站,数据集中存在重复的图片,原因有很多。例如,用户可能会不小心将同一张照片上传到同一商家多次,或者连锁商家可能会将相同的照片上传到不同的分店。数据集包含以下六个文件:

  • train_photos.tgz: 用作训练集的照片(234,545 张图片)

  • test_photos.tgz: 用作测试集的照片(500 张图片)

  • train_photo_to_biz_ids.csv: 提供照片 ID 和商家 ID 之间的映射(234,545 行)

  • test_photo_to_biz_ids.csv: 提供照片 ID 和商家 ID 之间的映射(500 行)

  • train.csv: 这是主要的训练数据集,包含商家 ID 和它们对应的标签(1996 行)

  • sample_submission.csv: 一个示例提交文件——参考正确的格式来提交你的预测结果,包括 business_id 和对应的预测标签

整个项目的工作流程

在这个项目中,我们将展示如何将.jpg格式的图像读取为 Scala 中的矩阵表示。接着,我们将进一步处理并准备这些图像,以便 CNN 能够接收。我们将看到几种图像操作,比如将所有图像转换为正方形,并将每个图像调整为相同的尺寸,然后对图像应用灰度滤镜:

图 7:用于图像分类的 CNN 概念视图

然后我们在训练数据上训练九个 CNN,每个分类一个。一旦训练完成,我们保存训练好的模型、CNN 配置和参数,以便以后恢复,接着我们应用一个简单的聚合函数来为每个餐厅分配类别,每个餐厅都有多个与之相关联的图像,每个图像都有其对应的九个类别的概率向量。然后我们对测试数据打分,最后,使用测试图像评估模型。

现在让我们看看每个 CNN 的结构。每个网络将有两个卷积层,两个子采样层,一个密集层,以及作为完全连接层的输出层。第一层是卷积层,接着是子采样层,再之后是另一个卷积层,然后是子采样层,再接着是一个密集层,最后是输出层。我们稍后会看到每一层的结构。

实现 CNN 用于图像分类

包含main()方法的 Scala 对象有以下工作流:

  1. 我们从train.csv文件中读取所有的业务标签

  2. 我们读取并创建一个从图像 ID 到业务 ID 的映射,格式为imageIDbusID

  3. 我们从photoDir目录获取图像列表,加载和处理图像,最后获取 10,000 张图像的图像 ID(可以自由设置范围)

  4. 然后我们读取并处理图像,形成photoID → 向量映射

  5. 我们将步骤 3步骤 4的输出链接起来,对齐业务特征、图像 ID 和标签 ID,以提取 CNN 所需的特征

  6. 我们构建了九个 CNN。

  7. 我们训练所有的 CNN 并指定模型保存的位置

  8. 然后我们重复步骤 2步骤 6来从测试集提取特征

  9. 最后,我们评估模型并将预测结果保存到 CSV 文件中

现在让我们看看前述步骤在高层次的图示中是如何表示的:

图 8:DL4j 图像处理管道用于图像分类

从程序的角度来看,前述步骤可以表示如下:

val labelMap = readBusinessLabels("data/labels/train.csv")
val businessMap = readBusinessToImageLabels("data/labels/train_photo_to_biz_ids.csv")
val imgs = getImageIds("data/images/train/", businessMap, businessMap.map(_._2).toSet.toList).slice(0,100) // 20000 images

println("Image ID retreival done!")
val dataMap = processImages(imgs, resizeImgDim = 128)
println("Image processing done!")
val alignedData = new featureAndDataAligner(dataMap, businessMap, Option(labelMap))()

println("Feature extraction done!")
val cnn0 = trainModelEpochs(alignedData, businessClass = 0, saveNN = "models/model0")
val cnn1 = trainModelEpochs(alignedData, businessClass = 1, saveNN = "models/model1")
val cnn2 = trainModelEpochs(alignedData, businessClass = 2, saveNN = "models/model2")
val cnn3 = trainModelEpochs(alignedData, businessClass = 3, saveNN = "models/model3")
val cnn4 = trainModelEpochs(alignedData, businessClass = 4, saveNN = "models/model4")
val cnn5 = trainModelEpochs(alignedData, businessClass = 5, saveNN = "models/model5")
val cnn6 = trainModelEpochs(alignedData, businessClass = 6, saveNN = "models/model6")
val cnn7 = trainModelEpochs(alignedData, businessClass = 7, saveNN = "models/model7")
val cnn8 = trainModelEpochs(alignedData, businessClass = 8, saveNN = "models/model8")

val businessMapTE = readBusinessToImageLabels("data/labels/test_photo_to_biz.csv")

val imgsTE = getImageIds("data/images/test//", businessMapTE, businessMapTE.map(_._2).toSet.toList)

val dataMapTE = processImages(imgsTE, resizeImgDim = 128) // make them 128*128

val alignedDataTE = new featureAndDataAligner(dataMapTE, businessMapTE, None)()
val Results = SubmitObj(alignedDataTE, "results/ModelsV0/")
val SubmitResults = writeSubmissionFile("kaggleSubmitFile.csv", Results, thresh = 0.9)

觉得太复杂了吗?别担心,我们现在将详细查看每一步。如果仔细看前面的步骤,你会发现步骤 1步骤 5基本上是图像处理和特征构建。

图像处理

当我试图开发这个应用程序时,我发现照片的大小和形状各不相同:一些图像是高的,一些是宽的,一些在外面,一些在里面,大多数是食物图片。然而,还有一些其他的随机物品。另一个重要的方面是,尽管训练图像在纵向/横向和像素数量上有所不同,大多数都是大致正方形的,许多都是确切的 500 x 375:

图 9:调整大小后的图像(左边为原始和高大的图像,右边为正方形的图像)

正如我们已经看到的,CNN 无法处理尺寸和形状异质的图像。有许多强大且有效的图像处理技术可以仅提取感兴趣区域(ROI)。但是,老实说,我不是图像处理专家,所以决定简化这个调整大小的步骤。

卷积神经网络(CNN)有一个严重的限制,即不能处理方向和相对空间关系。因此,这些组成部分对 CNN 来说并不重要。简而言之,CNN 不太适合具有异构形状和方向的图像。因此,现在人们开始讨论胶囊网络。详细内容请参见原始论文:arxiv.org/pdf/1710.09829v1.pdfopenreview.net/pdf?id=HJWLfGWRb

简单地说,我将所有图像都制作成了正方形,但仍然努力保持质量。在大多数情况下,ROI 是居中的,因此仅捕获每个图像的中心最方正的部分并不那么简单。尽管如此,我们还需要将每个图像转换为灰度图像。让我们把不规则形状的图像变成正方形。请看下面的图像,左边是原始图像,右边是正方形图像(见图 9)。

现在我们已经生成了一个正方形图像,我们是如何做到这一点的呢?好吧,我首先检查高度和宽度是否相同,如果是,则不进行调整大小。在另外两种情况下,我裁剪了中心区域。以下方法可以达到效果(但随意执行SquaringImage.scala脚本以查看输出):

def makeSquare(img: java.awt.image.BufferedImage): java.awt.image.BufferedImage = {
 val w = img.getWidth
 val h = img.getHeight
 val dim = List(w, h).min
    img match {
 case x 
 if w == h => img // do nothing and returns the original one
 case x 
 if w > h => Scalr.crop(img, (w - h) / 2, 0, dim, dim)
 case x 
 if w < h => Scalr.crop(img, 0, (h - w) / 2, dim, dim)
        }
    }

干得好!现在我们所有的训练图像都是正方形的,下一个重要的预处理任务是将它们全部调整大小。我决定将所有图像都调整为 128 x 128 的大小。让我们看看之前(原始的)调整大小后的图像如何看起来:

图 10:图像调整(256 x 256, 128 x 128, 64 x 64 和 32 x 32 分别)

以下方法可以达到效果(但随意执行ImageResize.scala脚本以查看演示):

def resizeImg(img: java.awt.image.BufferedImage, width: Int, height: Int) = {
    Scalr.resize(img, Scalr.Method.BALANCED, width, height) 
}

顺便说一句,为了图像调整和制作正方形,我使用了一些内置的图像读取包和一些第三方处理包:

import org.imgscalr._
import java.io.File
import javax.imageio.ImageIO

要使用上述包,请在 Maven 友好的pom.xml文件中添加以下依赖项:

<dependency>
    <groupId>org.imgscalr</groupId>
    <artifactId>imgscalr-lib</artifactId>
    <version>4.2</version>
</dependency>
<dependency>
    <groupId>org.datavec</groupId>
    <artifactId>datavec-data-image</artifactId>
    <version>0.9.1</version>
</dependency>
<dependency>
    <groupId>com.sksamuel.scrimage</groupId>
    <artifactId>scrimage-core_2.10</artifactId>
    <version>2.1.0</version>
</dependency>

虽然基于 DL4j 的卷积神经网络(CNN)可以处理彩色图像,但使用灰度图像可以简化计算。虽然彩色图像更具吸引力且效果更好,但通过这种方式,我们可以使整体表示更简单,并且节省空间。

让我们举一个之前步骤的例子。我们将每个图像调整为 256 x 256 像素的图像,表示为 16,384 个特征,而不是为一个有三个 RGB 通道的彩色图像表示为 16,384 x 3(执行GrayscaleConverter.scala来查看演示)。让我们看看转换后的图像效果:

图 11:左 - 原始图像,右 - 灰度图像 RGB 平均化

上述转换是使用名为pixels2Gray()makeGray()的两个方法完成的:

def pixels2Gray(R: Int, G: Int, B: Int): Int = (R + G + B) / 3
def makeGray(testImage: java.awt.image.BufferedImage): java.awt.image.BufferedImage = {
 val w = testImage.getWidth
 val h = testImage.getHeight
 for { 
        w1 <- (0 until w).toVector
        h1 <- (0 until h).toVector
        } 
 yield 
    {
 val col = testImage.getRGB(w1, h1)
 val R = (col & 0xff0000) / 65536
 val G = (col & 0xff00) / 256
 val B = (col & 0xff)
 val graycol = pixels2Gray(R, G, B)
testImage.setRGB(w1, h1, new Color(graycol, graycol, graycol).getRGB)
    }
testImage
}

那么,幕后发生了什么呢?我们将前面提到的三个步骤串联起来:首先将所有图像调整为正方形,然后将它们转换为 25 x 256,最后将调整大小后的图像转换为灰度图像:

val demoImage = ImageIO.read(new File(x))
    .makeSquare
    .resizeImg(resizeImgDim, resizeImgDim) // (128, 128)
    .image2gray

总结一下,现在我们已经对所有图像进行了正方形化和调整大小,图像已经变为灰度。以下图像展示了转换步骤的一些效果:

图 12:调整大小后的图像(左侧为原始高图,右侧为调整后的正方形图像)

以下的步骤链式操作也需要额外的努力。现在我们将这三个步骤放在代码中,最终准备好所有的图像:

import scala.Vector
import org.imgscalr._

object imageUtils {
 implicitclass imageProcessingPipeline(img: java.awt.image.BufferedImage) {
    // image 2 vector processing
 def pixels2gray(R: Int, G:Int, B: Int): Int = (R + G + B) / 3
 def pixels2color(R: Int, G:Int, B: Int): Vector[Int] = Vector(R, G, B)
 private def image2vecA => A ): Vector[A] = {
 val w = img.getWidth
 val h = img.getHeight
 for {
            w1 <- (0 until w).toVector
            h1 <- (0 until h).toVector
            } 
 yield {
 val col = img.getRGB(w1, h1)
 val R = (col & 0xff0000) / 65536
 val G = (col & 0xff00) / 256
 val B = (col & 0xff)
        f(R, G, B)
                }
            }

 def image2gray: Vector[Int] = image2vec(pixels2gray)
 def image2color: Vector[Int] = image2vec(pixels2color).flatten

    // make image square
 def makeSquare = {
 val w = img.getWidth
 val h = img.getHeight
 val dim = List(w, h).min
        img match {
 case x     
 if w == h => img
 case x 
 if w > h => Scalr.crop(img, (w-h)/2, 0, dim, dim)
 case x 
 if w < h => Scalr.crop(img, 0, (h-w)/2, dim, dim)
              }
            }

    // resize pixels
 def resizeImg(width: Int, height: Int) = {
        Scalr.resize(img, Scalr.Method.BALANCED, width, height)
            }
        }
    }

提取图像元数据

到目前为止,我们已经加载并预处理了原始图像,但我们还不知道需要哪些图像元数据来让我们的 CNN 进行学习。因此,现在是时候加载包含每个图像元数据的 CSV 文件了。

我写了一个方法来读取这种 CSV 格式的元数据,叫做readMetadata(),稍后两个方法readBusinessLabelsreadBusinessToImageLabels也会使用它。这三个方法定义在CSVImageMetadataReader.scala脚本中。下面是readMetadata()方法的签名:

def readMetadata(csv: String, rows: List[Int]=List(-1)): List[List[String]] = {
 val src = Source.fromFile(csv)

 def reading(csv: String): List[List[String]]= {
        src.getLines.map(x => x.split(",").toList)
            .toList
            }
 try {
 if(rows==List(-1)) reading(csv)
 else rows.map(reading(csv))
            } 
 finally {
            src.close
            }
        }

readBusinessLabels()方法将商业 ID 映射到标签,格式为 businessID → Set (标签):

def readBusinessLabels(csv: String, rows: List[Int]=List(-1)): Map[String, Set[Int]] = {
 val reader = readMetadata(csv)
    reader.drop(1)
        .map(x => x match {
 case x :: Nil => (x(0).toString, Set[Int]())
 case _ => (x(0).toString, x(1).split(" ").map(y => y.toInt).toSet)
        }).toMap
}

readBusinessToImageLabels()方法将图像 ID 映射到商业 ID,格式为 imageIDbusinessID

def readBusinessToImageLabels(csv: String, rows: List[Int] = List(-1)): Map[Int, String] = {
 val reader = readMetadata(csv)
    reader.drop(1)
        .map(x => x match {
 case x :: Nil => (x(0).toInt, "-1")
 case _ => (x(0).toInt, x(1).split(" ").head)
        }).toMap
}

图像特征提取

到目前为止,我们已经看到了如何预处理图像,以便从中提取特征并将其输入到 CNN 中。此外,我们还看到了如何提取和映射元数据并将其与原始图像链接。现在是时候从这些预处理过的图像中提取特征了。

我们还需要记住每个图像元数据的来源。正如你所猜的那样,我们需要三次映射操作来提取特征。基本上,我们有三个映射。详情请参见imageFeatureExtractor.scala脚本:

  1. 商业映射形式为 imageIDbusinessID

  2. 数据映射形式为 imageID → 图像数据

  3. 标签映射形式为 businessID → 标签

我们首先定义一个正则表达式模式,从 CSV ImageMetadataReader类中提取.jpg名称,该类用于与训练标签匹配:

val patt_get_jpg_name = new Regex("[0-9]")

然后,我们提取出所有与相应业务 ID 关联的图像 ID:

def getImgIdsFromBusinessId(bizMap: Map[Int, String], businessIds: List[String]): List[Int] = {
    bizMap.filter(x => businessIds.exists(y => y == x._2)).map(_._1).toList 
    }

现在,我们需要加载并处理所有已经预处理过的图像,通过与从业务 ID 提取的 ID 进行映射,如前所示,来提取图像 ID:

def getImageIds(photoDir: String, businessMap: Map[Int, String] = Map(-1 -> "-1"), businessIds:         
    List[String] = List("-1")): List[String] = {
 val d = new File(photoDir)
 val imgsPath = d.listFiles().map(x => x.toString).toList
 if (businessMap == Map(-1 -> "-1") || businessIds == List(-1)) {
        imgsPath
    } 
 else {
 val imgsMap = imgsPath.map(x => patt_get_jpg_name.findAllIn(x).mkString.toInt -> x).toMap
 val imgsPathSub = getImgIdsFromBusinessId(businessMap, businessIds)
        imgsPathSub.filter(x => imgsMap.contains(x)).map(x => imgsMap(x))
        } 
    }

到目前为止,我们已经能够提取出所有与至少一个业务相关的图像 ID。下一步是读取并处理这些图像,形成imageID → 向量映射:

def processImages(imgs: List[String], resizeImgDim: Int = 128, nPixels: Int = -1): Map[Int,Vector[Int]]= {
    imgs.map(x => patt_get_jpg_name.findAllIn(x).mkString.toInt -> {
 val img0 = ImageIO.read(new File(x))
        .makeSquare
        .resizeImg(resizeImgDim, resizeImgDim) // (128, 128)
        .image2gray
 if(nPixels != -1) img0.slice(0, nPixels)
 else img0
        }
    ).filter( x => x._2 != ())
    .toMap
    }

做得很好!我们只差一步,就能提取出训练 CNN 所需的数据。特征提取的最后一步是提取像素数据:

图 13:图像数据表示

总结起来,我们需要为每个图像跟踪四个对象的组成部分——即imageIDbusinessID、标签和像素数据。因此,如前图所示,主要数据结构由四种数据类型(四元组)构成——imgIDbusinessID、像素数据向量和标签:

List[(Int, String, Vector[Int], Set[Int])]

因此,我们应该有一个包含这些对象所有部分的类。别担心,我们需要的所有内容都已在featureAndDataAligner.scala脚本中定义。一旦我们在Main.scala脚本中的main方法下,通过以下代码行实例化featureAndDataAligner的实例,就可以提供businessMapdataMaplabMap

val alignedData = new featureAndDataAligner(dataMap, businessMap, Option(labelMap))()

在这里,labMap的选项类型被使用,因为在对测试数据评分时我们没有这个信息——即,对于该调用使用None

class featureAndDataAligner(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String], labMap: Option[Map[String, Set[Int]]])(rowindices: List[Int] = dataMap.keySet.toList) {
 def this(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String])(rowindices: List[Int]) =         this(dataMap, bizMap, None)(rowindices)

 def alignBusinessImgageIds(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String])
        (rowindices: List[Int] = dataMap.keySet.toList): List[(Int, String, Vector[Int])] = {
 for { 
            pid <- rowindices
 val imgHasBiz = bizMap.get(pid) 
            // returns None if img doe not have a bizID
 val bid = if(imgHasBiz != None) imgHasBiz.get 
 else "-1"
 if (dataMap.keys.toSet.contains(pid) && imgHasBiz != None)
            } 
 yield {
        (pid, bid, dataMap(pid))
           }
        }
def alignLabels(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String], labMap: Option[Map[String,     Set[Int]]])(rowindices: List[Int] = dataMap.keySet.toList): List[(Int, String, Vector[Int], Set[Int])] = {
 def flatten1A, B, C, D, D)): (A, B, C, D) = (t._1._1, t._1._2, t._1._3, t._2)
 val al = alignBusinessImgageIds(dataMap, bizMap)(rowindices)
 for { p <- al
        } 
 yield {
 val bid = p._2
 val labs = labMap match {
 case None => Set[Int]()
 case x => (if(x.get.keySet.contains(bid)) x.get(bid) 
        else Set[Int]())
            }
            flatten1(p, labs)
        }
    }
 lazy val data = alignLabels(dataMap, bizMap, labMap)(rowindices)
   // getter functions
 def getImgIds = data.map(_._1)
 def getBusinessIds = data.map(_._2)
 def getImgVectors = data.map(_._3)
 def getBusinessLabels = data.map(_._4)
 def getImgCntsPerBusiness = getBusinessIds.groupBy(identity).mapValues(x => x.size) 
}

很好!到目前为止,我们已经成功提取了用于训练 CNN 的特征。然而,目前形式下的特征仍然不适合输入到 CNN 中,因为我们只有特征向量而没有标签。因此,我们需要进行中间转换。

准备 ND4j 数据集

如我所说,我们需要进行中间转换和预处理,以便将训练集包含特征向量以及标签。这个转换过程非常直接:我们需要特征向量和业务标签。

为此,我们有makeND4jDataSets类(详见makeND4jDataSets.scala)。该类通过alignLables函数中的数据结构(以List[(imgID, bizID, labels, pixelVector)]的形式)创建 ND4j 数据集对象。首先,我们使用makeDataSet()方法准备数据集:

def makeDataSet(alignedData: featureAndDataAligner, bizClass: Int): DataSet = {
 val alignedXData = alignedData.getImgVectors.toNDArray
 val alignedLabs = alignedData.getBusinessLabels.map(x => 
 if (x.contains(bizClass)) Vector(1, 0) 
    else Vector(0, 1)).toNDArray
 new DataSet(alignedXData, alignedLabs)
    }

然后,我们需要进一步转换前面的数据结构,转换为INDArray,这样 CNN 就可以使用:

def makeDataSetTE(alignedData: featureAndDataAligner): INDArray = {
    alignedData.getImgVectors.toNDArray
    }

训练 CNN 并保存训练好的模型

到目前为止,我们已经看到如何准备训练集;现在我们面临一个挑战。我们必须训练 234,545 张图片。尽管测试阶段只用 500 张图片会轻松一些,但最好还是使用批处理模式,通过 DL4j 的MultipleEpochsIterator来训练每个 CNN。以下是一些重要的超参数及其详细信息:

  • 层数:正如我们在简单的 5 层 MNIST 网络中已经观察到的,我们获得了卓越的分类精度,这非常有前景。在这里,我将尝试构建一个类似的网络。

  • 样本数量:如果你训练所有图片,可能需要很长时间。如果你使用 CPU 而不是 GPU 进行训练,那将需要数天时间。当我尝试使用 50,000 张图片时,一台配置为 i7 处理器和 32 GB 内存的机器用了整整一天。现在你可以想象,如果使用整个数据集会需要多长时间。此外,即使你使用批处理模式进行训练,它也至少需要 256 GB 的 RAM。

  • 训练轮次:这是遍历所有训练记录的次数。

  • 输出特征图的数量(即 nOut):这是特征图的数量。可以仔细查看 DL4j GitHub 仓库中的其他示例。

  • 学习率:从类似 TensorFlow 的框架中,我获得了一些启示。在我看来,设置学习率为 0.01 和 0.001 会非常合适。

  • 批次数量:这是每个批次中的记录数量——32、64、128,依此类推。我使用了 128。

现在,使用前面的超参数,我们可以开始训练我们的 CNN。以下代码实现了这一功能。首先,我们准备训练集,然后定义所需的超参数,接着我们对数据集进行归一化,使 ND4j 数据框架被编码,且任何被认为为真实的标签是 1,其余为 0。然后我们对编码后的数据集的行和标签进行洗牌。

现在,我们需要使用ListDataSetIteratorMultipleEpochsIterator分别为数据集迭代器创建 epoch。将数据集转换为批次模型后,我们就可以开始训练构建的 CNN:

def trainModelEpochs(alignedData: featureAndDataAligner, businessClass: Int = 1, saveNN: String = "") = {
 val ds = makeDataSet(alignedData, businessClass)
 val nfeatures = ds.getFeatures.getRow(0).length // Hyperparameter
 val numRows = Math.sqrt(nfeatures).toInt //numRows*numColumns == data*channels
 val numColumns = Math.sqrt(nfeatures).toInt //numRows*numColumns == data*channels
 val nChannels = 1 // would be 3 if color image w R,G,B
 val outputNum = 9 // # of classes (# of columns in output)
 val iterations = 1
 val splitTrainNum = math.ceil(ds.numExamples * 0.8).toInt // 80/20 training/test split
 val seed = 12345
 val listenerFreq = 1
 val nepochs = 20
 val nbatch = 128 // recommended between 16 and 128

    ds.normalizeZeroMeanZeroUnitVariance()
    Nd4j.shuffle(ds.getFeatureMatrix, new Random(seed), 1) // shuffles rows in the ds.
    Nd4j.shuffle(ds.getLabels, new Random(seed), 1) // shuffles labels accordingly

 val trainTest: SplitTestAndTrain = ds.splitTestAndTrain(splitTrainNum, new Random(seed))

    // creating epoch dataset iterator
 val dsiterTr = new ListDataSetIterator(trainTest.getTrain.asList(), nbatch)
 val dsiterTe = new ListDataSetIterator(trainTest.getTest.asList(), nbatch)
 val epochitTr: MultipleEpochsIterator = new MultipleEpochsIterator(nepochs, dsiterTr)

 val epochitTe: MultipleEpochsIterator = new MultipleEpochsIterator(nepochs, dsiterTe)
    //First convolution layer with ReLU as activation function
 val layer_0 = new ConvolutionLayer.Builder(6, 6)
        .nIn(nChannels)
        .stride(2, 2) // default stride(2,2)
        .nOut(20) // # of feature maps
        .dropOut(0.5)
        .activation("relu") // rectified linear units
        .weightInit(WeightInit.RELU)
        .build()

    //First subsampling layer
 val layer_1 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
        .kernelSize(2, 2)
        .stride(2, 2)
        .build()

    //Second convolution layer with ReLU as activation function
 val layer_2 = new ConvolutionLayer.Builder(6, 6)
        .nIn(nChannels)
        .stride(2, 2)
        .nOut(50)
        .activation("relu")
        .build()

    //Second subsampling layer
 val layer_3 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
        .kernelSize(2, 2)
        .stride(2, 2)
        .build()

    //Dense layer
 val layer_4 = new DenseLayer.Builder()
        .activation("relu")
        .nOut(500)
        .build()

    // Final and fully connected layer with Softmax as activation function
 val layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
        .nOut(outputNum)
        .weightInit(WeightInit.XAVIER)
        .activation("softmax")
        .build()
 val builder: MultiLayerConfiguration.Builder = new NeuralNetConfiguration.Builder()
        .seed(seed)
        .iterations(iterations)
        .miniBatch(true)
        .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
        .regularization(true).l2(0.0005)
        .learningRate(0.01)
        .list(6)
            .layer(0, layer_0)
            .layer(1, layer_1)
            .layer(2, layer_2)
            .layer(3, layer_3)
            .layer(4, layer_4)
            .layer(5, layer_5)
    .backprop(true).pretrain(false)

 new ConvolutionLayerSetup(builder, numRows, numColumns, nChannels)
 val conf: MultiLayerConfiguration = builder.build()
 val model: MultiLayerNetwork = new MultiLayerNetwork(conf)

    model.init()
    model.setListeners(SeqIterationListener).asJava)
    model.fit(epochitTr)

 val eval = new Evaluation(outputNum)
 while (epochitTe.hasNext) {
 val testDS = epochitTe.next(nbatch)
 val output: INDArray = model.output(testDS.getFeatureMatrix)
        eval.eval(testDS.getLabels(), output)
        }
 if (!saveNN.isEmpty) {
        // model config
        FileUtils.write(new File(saveNN + ".json"), model.getLayerWiseConfigurations().toJson())
        // model parameters
 val dos: DataOutputStream = new DataOutputStream(Files.newOutputStream(Paths.get(saveNN + ".bin")))
        Nd4j.write(model.params(), dos)
        }
    }

在前面的代码中,我们还保存了一个.json文件,包含所有网络配置,以及一个.bin文件,用于存储所有 CNN 的权重和参数。这是通过两个方法完成的;即在NeuralNetwok.scala脚本中定义的saveNN()loadNN()。首先,让我们看看saveNN()方法的签名,代码如下:

def saveNN(model: MultiLayerNetwork, NNconfig: String, NNparams: String) = {
    // save neural network config
    FileUtils.write(new File(NNconfig), model.getLayerWiseConfigurations().toJson())
    // save neural network parms
 val dos: DataOutputStream = new DataOutputStream(Files.newOutputStream(Paths.get(NNparams)))
    Nd4j.write(model.params(), dos)
}

这个想法既有远见又很重要,因为,正如我所说,你不会为了评估一个新的测试集而第二次训练整个网络——也就是说,假设你只想测试一张图片。我们还有另一种方法叫做loadNN(),它将之前创建的.json.bin文件读取回MultiLayerNetwork并用于评分新的测试数据。方法如下:

def loadNN(NNconfig: String, NNparams: String) = {
    // get neural network config
 val confFromJson: MultiLayerConfiguration =                     
    MultiLayerConfiguration.fromJson(FileUtils.readFileToString(new File(NNconfig)))

    // get neural network parameters
 val dis: DataInputStream = new DataInputStream(new FileInputStream(NNparams))
 val newParams = Nd4j.read(dis)

    // creating network object
 val savedNetwork: MultiLayerNetwork = new MultiLayerNetwork(confFromJson)
    savedNetwork.init()
    savedNetwork.setParameters(newParams)
    savedNetwork 
    }

评估模型

我们将使用的评分方法非常简单。它通过平均图像级别的预测来分配业务级别的标签。我知道我做得比较简单,但你可以尝试更好的方法。我做的是,如果某个业务的所有图像属于类别0的概率平均值大于 0.5,则为该业务分配标签0

def scoreModel(model: MultiLayerNetwork, ds: INDArray) = {
    model.output(ds)
}

然后我们从scoreModel()方法收集模型预测,并与alignedData合并:


def aggImgScores2Business(scores: INDArray, alignedData: featureAndDataAligner ) = {
    assert(scores.size(0) == alignedData.data.length, "alignedData and scores length are different. They     must be equal")

def getRowIndices4Business(mylist: List[String], mybiz: String): List[Int] = mylist.zipWithIndex.filter(x     => x._1 == mybiz).map(_._2)

def mean(xs: List[Double]) = xs.sum / xs.size
    alignedData.getBusinessIds.distinct.map(x => (x, {
 val irows = getRowIndices4Business(alignedData.getBusinessIds, x)
 val ret = 
 for(row <- irows) 
 yield scores.getRow(row).getColumn(1).toString.toDouble
        mean(ret)
        }))
    }

最后,我们可以恢复训练并保存的模型,恢复它们,并生成 Kaggle 的提交文件。关键是我们需要将图像预测聚合成每个模型的业务分数。

通过执行 main()方法进行总结

让我们通过查看模型的性能来总结整体讨论。以下代码是一个总体概览:

package Yelp.Classifier
import Yelp.Preprocessor.CSVImageMetadataReader._
import Yelp.Preprocessor.featureAndDataAligner
import Yelp.Preprocessor.imageFeatureExtractor._
import Yelp.Evaluator.ResultFileGenerator._
import Yelp.Preprocessor.makeND4jDataSets._
import Yelp.Evaluator.ModelEvaluation._
import Yelp.Trainer.CNNEpochs._
import Yelp.Trainer.NeuralNetwork._

object YelpImageClassifier {
 def main(args: Array[String]): Unit = {
        // image processing on training data
 val labelMap = readBusinessLabels("data/labels/train.csv")
 val businessMap = readBusinessToImageLabels("data/labels/train_photo_to_biz_ids.csv")
 val imgs = getImageIds("data/images/train/", businessMap, 
        businessMap.map(_._2).toSet.toList).slice(0,20000) // 20000 images

        println("Image ID retreival done!")
 val dataMap = processImages(imgs, resizeImgDim = 256)
        println("Image processing done!")

 val alignedData = 
 new featureAndDataAligner(dataMap, businessMap, Option(labelMap))()
        println("Feature extraction done!")

        // training one model for one class at a time. Many hyperparamters hardcoded within
 val cnn0 = trainModelEpochs(alignedData, businessClass = 0, saveNN = "models/model0")
 val cnn1 = trainModelEpochs(alignedData, businessClass = 1, saveNN = "models/model1")
 val cnn2 = trainModelEpochs(alignedData, businessClass = 2, saveNN = "models/model2")
 val cnn3 = trainModelEpochs(alignedData, businessClass = 3, saveNN = "models/model3")
 val cnn4 = trainModelEpochs(alignedData, businessClass = 4, saveNN = "models/model4")
 val cnn5 = trainModelEpochs(alignedData, businessClass = 5, saveNN = "models/model5")
 val cnn6 = trainModelEpochs(alignedData, businessClass = 6, saveNN = "models/model6")
 val cnn7 = trainModelEpochs(alignedData, businessClass = 7, saveNN = "models/model7")
 val cnn8 = trainModelEpochs(alignedData, businessClass = 8, saveNN = "models/model8")

    // processing test data for scoring
 val businessMapTE = readBusinessToImageLabels("data/labels/test_photo_to_biz.csv")
 val imgsTE = getImageIds("data/images/test//", businessMapTE,     
        businessMapTE.map(_._2).toSet.toList)

 val dataMapTE = processImages(imgsTE, resizeImgDim = 128) // make them 256x256
 val alignedDataTE = new featureAndDataAligner(dataMapTE, businessMapTE, None)()

        // creating csv file to submit to kaggle (scores all models)
 val Results = SubmitObj(alignedDataTE, "results/ModelsV0/")
 val SubmitResults = writeSubmissionFile("kaggleSubmitFile.csv", Results, thresh = 0.9)
        }
    }
>>>
==========================Scores======================================
 Accuracy: 0.6833
 Precision: 0.53
 Recall: 0.5222
 F1 Score: 0.5261
======================================================================

那么,你的印象如何?确实,我们没有得到优秀的分类准确度。但我们仍然可以尝试调整超参数。下一部分提供了一些见解。

调整和优化 CNN 超参数

以下超参数非常重要,必须调整以获得优化结果。

  • 丢弃法(Dropout):用于随机省略特征检测器,以防止过拟合

  • 稀疏性:用于强制激活稀疏/罕见输入

  • 自适应梯度法(Adagrad):用于特征特定的学习率优化

  • 正则化:L1 和 L2 正则化

  • 权重转换:对深度自编码器有用

  • 概率分布操控:用于初始权重生成

  • 梯度归一化和裁剪

另一个重要的问题是:你什么时候想要添加一个最大池化层,而不是具有相同步幅的卷积层?最大池化层根本没有参数,而卷积层有很多。有时,添加一个局部响应归一化层,可以让最强激活的神经元抑制同一位置但邻近特征图中的神经元,鼓励不同的特征图进行专门化,并将它们分开,迫使它们探索更广泛的特征。通常用于较低层,以便拥有更多低级特征供上层构建。

在训练大规模神经网络时观察到的主要问题之一是过拟合,即为训练数据生成非常好的逼近,但在单个点之间的区域产生噪声。在过拟合的情况下,模型专门针对训练数据集进行调整,因此不能用于泛化。因此,尽管在训练集上表现良好,但在测试集和后续测试中的表现较差,因为它缺乏泛化能力:

图 14:丢弃法与不丢弃法的对比

该方法的主要优点是避免了同一层的所有神经元同步优化它们的权重。这种在随机组中进行的适应,避免了所有神经元收敛到相同的目标,从而使得适应的权重不相关。应用 dropout 时发现的第二个特性是,隐藏单元的激活变得稀疏,这也是一种理想的特性。

由于在 CNN 中,目标函数之一是最小化计算出的代价,我们必须定义一个优化器。DL4j 支持以下优化器:

  • SGD(仅学习率)

  • Nesterov 的动量

  • Adagrad

  • RMSProp

  • Adam

  • AdaDelta

在大多数情况下,如果性能不满意,我们可以采用已实现的 RMSProp,它是梯度下降的高级形式。RMSProp 表现更好,因为它将学习率除以平方梯度的指数衰减平均值。建议的衰减参数值为 0.9,而学习率的一个良好默认值为 0.001。

更技术性地说,通过使用最常见的优化器,如随机梯度下降SGD),学习率必须按 1/T 的比例进行缩放才能收敛,其中 T 是迭代次数。RMSProp 尝试通过自动调整步长来克服这一限制,使步长与梯度处于相同的尺度。因此,如果你正在训练神经网络,但计算梯度是必须的,使用 RMSProp 将是小批量训练中更快的学习方式。研究人员还建议在训练深度 CNN 或 DNN 时使用动量优化器。

从分层架构的角度来看,CNN 与 DNN 不同;它有不同的需求和调优标准。CNN 的另一个问题是卷积层需要大量的 RAM,尤其是在训练过程中,因为反向传播的反向传递需要保留前向传播过程中计算的所有中间值。在推理过程中(即对新实例进行预测时),一个层占用的 RAM 可以在下一个层计算完毕后释放,因此你只需要两个连续层所需的内存。

然而,在训练过程中,前向传播过程中计算的所有内容都需要在反向传播时保留下来,因此所需的内存量至少是所有层所需的总内存量。如果你的 GPU 在训练 CNN 时内存不足,这里有五个解决问题的建议(除了购买更大内存的 GPU):

  • 减小小批量的大小

  • 使用较大的步幅在一层或多层中减少维度

  • 移除一层或多层

  • 使用 16 位浮点数代替 32 位浮点数

  • 将 CNN 分布到多个设备上

总结

在本章中,我们已经看到如何使用和构建基于卷积神经网络(CNN)的现实应用,CNN 是一种前馈人工神经网络,其神经元之间的连接模式受到动物视觉皮层组织的启发。我们使用 CNN 构建的图像分类应用可以以可接受的准确度对现实世界中的图像进行分类,尽管我们没有达到更高的准确度。然而,鼓励读者在代码中调整超参数,并尝试使用其他数据集采用相同的方法。

然而,重要的是,由于卷积神经网络的内部数据表示没有考虑到简单和复杂物体之间的重要空间层级,因此 CNN 在某些实例中有一些严重的缺点和限制。因此,我建议你查看 GitHub 上关于胶囊网络的最新活动:github.com/topics/capsule-network。希望你能从中获得一些有用的信息。

这基本上是我们使用 Scala 和不同开源框架开发机器学习项目的小旅程的结束。在各章中,我尝试为你提供了多个示例,展示如何有效地使用这些出色的技术来开发机器学习项目。在写这本书的过程中,我必须考虑到许多限制条件,例如页面数量、API 可用性和我的专业知识。但我尽量使书籍保持简洁,并且避免了过多的理论细节,因为关于 Apache Spark、DL4j 和 H2O 的理论内容,你可以在许多书籍、博客和网站上找到。

我还会在我的 GitHub 仓库上更新这本书的代码:github.com/PacktPublishing/Scala-Machine-Learning-Projects。随时欢迎提出新问题或提交任何拉取请求,以改善这本书,并保持关注。

最后,我写这本书并不是为了赚钱,但大部分版税将用于资助孟加拉国我家乡地区的儿童教育。我想感谢并对购买并享受这本书的读者表示衷心的感谢!

posted @ 2025-07-13 15:43  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报