Spark-机器学习-全-

Spark 机器学习(全)

原文:zh.annas-archive.org/md5/7A35D303E4132E910DFC5ADB5679B82A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

近年来,收集、存储和分析的数据量急剧增加,特别是与网络和移动设备上的活动以及通过传感器网络收集的物理世界的数据相关。尽管大规模数据存储、处理、分析和建模以前主要是谷歌、雅虎、Facebook、Twitter 和 Salesforce 等最大机构的领域,但越来越多的组织面临着如何处理大量数据的挑战。

面对如此大量的数据和实时利用的共同需求,人力系统很快变得不可行。这导致了所谓的大数据和机器学习系统的兴起,这些系统从数据中学习以做出自动决策。

为了应对处理越来越大规模的数据而不带来任何限制性成本的挑战,新的开源技术在谷歌、雅虎、亚马逊和 Facebook 等公司出现,旨在通过在计算机集群中分布数据存储和计算来更轻松地处理大规模数据量。

其中最广泛使用的是 Apache Hadoop,它显著地简化了存储大量数据(通过 Hadoop 分布式文件系统或 HDFS)和在这些数据上运行计算(通过 Hadoop MapReduce,在计算集群中并行执行计算任务的框架)的成本。

然而,MapReduce 存在一些重要的缺点,包括启动每个作业的高开销和依赖存储中间数据和计算结果到磁盘,这两点使得 Hadoop 相对不适合迭代或低延迟性质的用例。Apache Spark 是一个新的分布式计算框架,从根本上设计为优化低延迟任务,并将中间数据和结果存储在内存中,从而解决了 Hadoop 框架的一些主要缺点。Spark 提供了一个清晰、功能性和易于理解的 API 来编写应用程序,并且与 Hadoop 生态系统完全兼容。

此外,Spark 提供了 Scala、Java、Python 和 R 的本地 API。Scala 和 Python 的 API 允许直接在 Spark 应用程序中使用 Scala 或 Python 语言的所有优点,包括使用相关的解释器进行实时、交互式的探索。Spark 本身现在提供了一个工具包(1.6 中的 Spark MLlib 和 2.0 中的 Spark ML)用于分布式机器学习和数据挖掘模型,正在积极开发中,并已经包含了许多常见机器学习任务的高质量、可扩展和高效的算法,其中一些我们将在本书中深入探讨。

将机器学习技术应用于大规模数据集是具有挑战性的,主要是因为大多数众所周知的机器学习算法并不是为并行架构设计的。在许多情况下,设计这样的算法并不是一件容易的事。机器学习模型的性质通常是迭代的,因此 Spark 对于这种用例具有很强的吸引力。虽然有许多竞争性的并行计算框架,但 Spark 是少数几个将速度、可扩展性、内存处理和容错性与编程的简易性以及灵活、表达力和强大的 API 设计结合在一起的框架之一。

在本书中,我们将专注于机器学习技术的实际应用。虽然我们可能会简要涉及一些机器学习算法的理论方面和机器学习所需的数学知识,但本书通常会采用实际的、应用的方法,重点是使用示例和代码来说明如何有效地使用 Spark 和 MLlib 的特性,以及其他众所周知和免费提供的机器学习和数据分析包,来创建一个有用的机器学习系统。

本书涵盖的内容

第一章,“开始并运行 Spark”,展示了如何安装和设置 Spark 框架的本地开发环境,以及如何使用 Amazon EC2 在云中创建 Spark 集群。将介绍 Spark 编程模型和 API,并使用 Scala、Java 和 Python 创建一个简单的 Spark 应用程序。

第二章,“机器学习的数学”,提供了机器学习的数学介绍。理解数学和许多技术对于掌握算法的内部工作方式并获得最佳结果非常重要。

第三章,“设计机器学习系统”,介绍了机器学习系统的一个真实用例示例。我们将基于这个示例用例设计一个基于 Spark 的智能系统的高层架构。

第四章,“使用 Spark 获取、处理和准备数据”,详细介绍了如何获取用于机器学习系统的数据,特别是来自各种免费和公开可用的来源。我们将学习如何处理、清理和转换原始数据,以便在机器学习模型中使用,利用可用的工具、库和 Spark 的功能。

第五章,“使用 Spark 构建推荐引擎”,涉及基于协同过滤方法创建推荐模型。该模型将用于向特定用户推荐项目,以及创建与给定项目相似的项目列表。这里将介绍评估推荐模型性能的标准指标。

第六章,“使用 Spark 构建分类模型”,详细介绍了如何为二元分类创建模型,以及如何利用标准的分类任务性能评估指标。

第七章,“使用 Spark 构建回归模型”,展示了如何为回归创建模型,扩展了第六章“使用 Spark 构建分类模型”中创建的模型。这里将详细介绍回归模型性能的评估指标。

第八章,“使用 Spark 构建聚类模型”,探讨了如何创建聚类模型以及如何使用相关的评估方法。您将学习如何分析和可视化生成的聚类。

第九章,“使用 Spark 进行降维”,带领我们通过方法从数据中提取潜在结构,并减少数据的维度。您将学习一些常见的降维技术以及如何应用和分析它们。您还将了解如何将生成的数据表示用作另一个机器学习模型的输入。

第十章,“使用 Spark 进行高级文本处理”,介绍了处理大规模文本数据的方法,包括从文本中提取特征的技术,以及处理文本数据中典型的高维特征。

第十一章,“使用 Spark Streaming 进行实时机器学习”,概述了 Spark Streaming 以及它如何与在线和增量学习方法结合,应用于数据流的机器学习。

第十二章,“Spark ML 的管道 API”,提供了一套统一的 API,构建在数据框架之上,帮助用户创建和调整机器学习管道。

本书所需内容

在本书中,我们假设您具有 Scala 或 Python 编程的基本经验,并且对机器学习、统计和数据分析有一些基本知识。

本书的受众

本书旨在面向初级到中级数据科学家、数据分析师、软件工程师和从事机器学习或数据挖掘并对大规模机器学习方法感兴趣的从业者,但不一定熟悉 Spark。您可能具有一些统计或机器学习软件的经验(可能包括 MATLAB、scikit-learn、Mahout、R、Weka 等)或分布式系统(包括对 Hadoop 的一些了解)。

约定

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“Spark 将用户脚本放置到bin目录中运行 Spark。”

代码块设置如下:

val conf = new SparkConf()
.setAppName("Test Spark App")
.setMaster("local[4]")
val sc = new SparkContext(conf)

任何命令行输入或输出都以以下方式编写:

>tar xfvz spark-2.1.0-bin-hadoop2.7.tgz
>cd spark-2.1.0-bin-hadoop2.7

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“这些可以从 AWS 主页上通过单击“帐户|安全凭据|访问凭据”来获取。”

警告或重要说明会出现在这样的框中。

提示和技巧会以这种方式出现。

第一章:开始并运行 Spark

Apache Spark 是一个分布式计算框架;该框架旨在简化编写并行程序的过程,这些程序可以在计算机集群或虚拟机中的许多节点上并行运行。它试图将资源调度、作业提交、执行、跟踪和节点之间的通信以及并行数据处理中固有的低级操作抽象出来。它还提供了一个更高级的 API 来处理分布式数据。因此,它类似于其他分布式处理框架,如 Apache Hadoop;但是,底层架构有所不同。

Spark 最初是加州大学伯克利分校(amplab.cs.berkeley.edu/projects/spark-lightning-fast-cluster-computing/)的 AMP 实验室的一个研究项目。该大学专注于分布式机器学习算法的用例。因此,它从头开始设计,以实现迭代性质的高性能,其中相同的数据被多次访问。这种性能主要通过将数据集缓存在内存中并结合低延迟和启动并行计算任务的开销来实现。除了容错性、灵活的分布式内存数据结构和强大的函数 API 等其他特性,Spark 已被证明在大规模数据处理任务中广泛适用,超越了机器学习和迭代分析。

更多信息,请访问:

在性能方面,Spark 在相关工作负载方面比 Hadoop 快得多。请参考以下图表:

来源:https://amplab.cs.berkeley.edu/wp-content/uploads/2011/11/spark-lr.png

Spark 有四种运行模式:

  • 独立的本地模式,其中所有 Spark 进程都在同一个Java 虚拟机JVM)进程中运行

  • 使用 Spark 自带的作业调度框架,设置独立集群模式

  • 使用Mesos,一个流行的开源集群计算框架

  • 使用 YARN(通常称为 NextGen MapReduce),Hadoop

在本章中,我们将进行以下操作:

  • 下载 Spark 二进制文件并设置在 Spark 的独立本地模式下运行的开发环境。本环境将在整本书中用于运行示例代码。

  • 使用 Spark 的交互式控制台来探索 Spark 的编程模型和 API。

  • 在 Scala、Java、R 和 Python 中编写我们的第一个 Spark 程序。

  • 使用亚马逊的弹性云计算EC2)平台设置 Spark 集群,该平台可用于大型数据和更重的计算需求,而不是在本地模式下运行。

  • 使用亚马逊弹性 Map Reduce 设置 Spark 集群

如果您之前有设置 Spark 的经验并且熟悉编写 Spark 程序的基础知识,可以跳过本章。

在本地安装和设置 Spark

Spark 可以在本地模式下使用内置的独立集群调度程序运行。这意味着所有 Spark 进程都在同一个 JVM 内运行,实际上是 Spark 的单个多线程实例。本地模式非常适用于原型设计、开发、调试和测试。然而,这种模式在实际场景中也可以用于在单台计算机的多个核心上执行并行计算。

由于 Spark 的本地模式与集群模式完全兼容;在本地编写和测试的程序只需进行一些额外的步骤即可在集群上运行。

在本地设置 Spark 的第一步是下载最新版本spark.apache.org/downloads.html,其中包含下载各种版本的 Spark 的链接,以及通过 GitHub 获取最新源代码的链接。

spark.apache.org/docs/latest/提供的文档/文档是了解 Spark 的全面资源。我们强烈建议您探索一下!

为了访问Hadoop 分布式文件系统HDFS)以及标准和自定义 Hadoop 输入源 Cloudera 的 Hadoop 分发、MapR 的 Hadoop 分发和 Hadoop 2(YARN),Spark 需要根据特定版本的 Hadoop 构建。除非您希望根据特定的 Hadoop 版本构建 Spark,我们建议您从d3kbcqa49mib13.cloudfront.net/spark-2.0.2-bin-hadoop2.7.tgz的 Apache 镜像下载预构建的 Hadoop 2.7 包。

在撰写本书时,Spark 需要 Scala 编程语言(版本为 2.10.x 或 2.11.x)才能运行。幸运的是,预构建的二进制包包含了 Scala 运行时包,因此您无需单独安装 Scala 即可开始。但是,您需要安装Java 运行环境JRE)或Java 开发工具包JDK)。

有关安装说明,请参考本书代码包中的软件和硬件列表。需要 R 3.1+。

下载 Spark 二进制包后,通过运行以下命令解压包的内容并切换到新创建的目录:

 $ tar xfvz spark-2.0.0-bin-hadoop2.7.tgz
 $ cd spark-2.0.0-bin-hadoop2.7

Spark 将用户脚本放置在bin目录中以运行 Spark。您可以通过运行 Spark 中包含的示例程序之一来测试一切是否正常工作。运行以下命令:

 $ bin/run-example SparkPi 100

这将在 Spark 的本地独立模式下运行示例。在此模式下,所有 Spark 进程都在同一个 JVM 中运行,并且 Spark 使用多个线程进行并行处理。默认情况下,上面的示例使用的线程数等于系统上可用的核心数。程序执行完毕后,您应该看到输出的末尾类似于以下行:

...
16/11/24 14:41:58 INFO Executor: Finished task 99.0 in stage 0.0 
    (TID 99). 872 bytes result sent to driver
16/11/24 14:41:58 INFO TaskSetManager: Finished task 99.0 in stage 
    0.0 (TID 99) in 59 ms on localhost (100/100)
16/11/24 14:41:58 INFO DAGScheduler: ResultStage 0 (reduce at 
    SparkPi.scala:38) finished in 1.988 s
16/11/24 14:41:58 INFO TaskSchedulerImpl: Removed TaskSet 0.0, 
    whose tasks have all completed, from pool 
16/11/24 14:41:58 INFO DAGScheduler: Job 0 finished: reduce at 
    SparkPi.scala:38, took 2.235920 s
Pi is roughly 3.1409527140952713

上述命令调用org.apache.spark.examples.SparkPi类。

此类以local[N]形式接受参数,其中N是要使用的线程数。例如,要仅使用两个线程,请运行以下命令instead:N是要使用的线程数。给定local[*]将使用本地机器上的所有核心--这是常见用法。

要仅使用两个线程,请运行以下命令:

 $ ./bin/spark-submit  --class org.apache.spark.examples.SparkPi 
 --master local[2] ./examples/jars/spark-examples_2.11-2.0.0.jar 100 

Spark 集群

Spark 集群由两种类型的进程组成:驱动程序和多个执行器。在本地模式下,所有这些进程都在同一个 JVM 中运行。在集群中,这些进程通常在单独的节点上运行。

例如,运行在 Spark 独立模式下的典型集群(即使用 Spark 内置的集群管理模块)将具有以下内容:

  • 运行 Spark 独立主进程和驱动程序的主节点

  • 多个工作节点,每个节点运行一个执行器进程

虽然在本书中,我们将使用 Spark 的本地独立模式来说明概念和示例,但我们编写的相同 Spark 代码可以在 Spark 集群上运行。在上面的示例中,如果我们在 Spark 独立集群上运行代码,我们可以简单地传递主节点的 URL,如下所示:

 $ MASTER=spark://IP:PORT --class org.apache.spark.examples.SparkPi 
 ./examples/jars/spark-examples_2.11-2.0.0.jar 100

这里,IP是 Spark 主节点的 IP 地址,PORT是端口。这告诉 Spark 在运行 Spark 主进程的集群上运行程序。

Spark 的集群管理和部署的全面处理超出了本书的范围。但是,我们将简要教您如何在本章后面设置和使用 Amazon EC2 集群。

有关 Spark 集群应用部署的概述,请查看以下链接:

Spark 编程模型

在我们深入了解 Spark 设计的高级概述之前,我们将介绍SparkContext对象以及 Spark shell,我们将使用它们来交互式地探索 Spark 编程模型的基础知识。

虽然本节提供了对 Spark 的简要概述和示例,但我们建议您阅读以下文档以获得详细的理解:

请参阅以下 URL:

SparkContext 和 SparkConf

编写任何 Spark 程序的起点是SparkContext(在 Java 中为JavaSparkContext)。SparkContext使用包含各种 Spark 集群配置设置的SparkConf对象的实例进行初始化(例如,主节点的 URL)。

这是 Spark 功能的主要入口点。SparkContext是与 Spark 集群的连接。它可以用于在集群上创建 RDD、累加器和广播变量。

每个 JVM 只能有一个活动的SparkContext。在创建新的SparkContext之前,必须调用stop()来停止活动的SparkContext

初始化后,我们将使用SparkContext对象中的各种方法来创建和操作分布式数据集和共享变量。Spark shell(在 Scala 和 Python 中,遗憾的是 Java 不支持)会为我们处理这个上下文的初始化,但以下代码示例展示了在 Scala 中创建本地模式下运行的上下文的示例:

val conf = new SparkConf() 
.setAppName("Test Spark App") 
.setMaster("local[4]") 
val sc = new SparkContext(conf)

这将创建一个在本地模式下运行的上下文,使用四个线程,应用程序的名称设置为Test Spark App。如果我们希望使用默认配置值,我们也可以调用SparkContext对象的以下简单构造函数,它的工作方式完全相同:

val sc = new SparkContext("local[4]", "Test Spark App")

下载示例代码

您可以从您在www.packtpub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您从其他来源购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

SparkSession

SparkSession允许使用DataFrame和 Dataset API 进行编程。它是这些 API 的唯一入口点。

首先,我们需要创建SparkConf类的实例,并使用它创建SparkSession实例。考虑以下示例:

val spConfig = (new SparkConf).setMaster("local").setAppName("SparkApp")
 val spark = SparkSession
   .builder()
   .appName("SparkUserData").config(spConfig)
   .getOrCreate()

接下来,我们可以使用 spark 对象来创建一个DataFrame

val user_df = spark.read.format("com.databricks.spark.csv")
   .option("delimiter", "|").schema(customSchema)
   .load("/home/ubuntu/work/ml-resources/spark-ml/data/ml-100k/u.user")
val first = user_df.first()

Spark shell

Spark 支持使用 Scala、Python 或 R 的REPL(即Read-Eval-Print-Loop,或交互式 shell)进行交互式编程。当我们输入代码时,shell 会立即提供反馈,因为该代码会立即被评估。在 Scala shell 中,运行一段代码后还会显示返回结果和类型。

要在 Scala 中使用 Spark shell,只需从 Spark 基本目录运行./bin/spark-shell。这将启动 Scala shell 并初始化SparkContext,作为 Scala 值sc对我们可用。在 Spark 2.0 中,SparkSession实例以 Spark 变量的形式也在控制台中可用。

您的控制台输出应该类似于以下内容:

$ ~/work/spark-2.0.0-bin-hadoop2.7/bin/spark-shell 
Using Spark's default log4j profile: org/apache/spark/log4j-
    defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel).
16/08/06 22:14:25 WARN NativeCodeLoader: Unable to load native-
    hadoop library for your platform... using builtin-java classes 
    where applicable
16/08/06 22:14:25 WARN Utils: Your hostname, ubuntu resolves to a 
    loopback address: 127.0.1.1; using 192.168.22.180 instead (on 
    interface eth1)
16/08/06 22:14:25 WARN Utils: Set SPARK_LOCAL_IP if you need to 
    bind to another address
16/08/06 22:14:26 WARN Utils: Service 'SparkUI' could not bind on 
    port 4040\. Attempting port 4041.
16/08/06 22:14:27 WARN SparkContext: Use an existing SparkContext, 
    some configuration may not take effect.
Spark context Web UI available at http://192.168.22.180:4041
Spark context available as 'sc' (master = local[*], app id = local-
    1470546866779).
Spark session available as 'spark'.
Welcome to
 ____              __
 / __/__  ___ _____/ /__
 _ / _ / ______/ __/  '_/
 /___/ .__/_,_/_/ /_/_   version 2.0.0
 /_/

Using Scala version 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, 
    Java 1.7.0_60)
Type in expressions to have them evaluated.
Type :help for more information.

scala> 

要使用 Python shell 与 Spark 一起使用,只需运行./bin/pyspark命令。与 Scala shell 一样,Python 的SparkContext对象应该作为 Python 变量sc可用。您的输出应该类似于这样:

~/work/spark-2.0.0-bin-hadoop2.7/bin/pyspark 
Python 2.7.6 (default, Jun 22 2015, 17:58:13) 
[GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more 
    information.
Using Spark's default log4j profile: org/apache/spark/log4j-
    defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel).
16/08/06 22:16:15 WARN NativeCodeLoader: Unable to load native-
    hadoop library for your platform... using builtin-java classes 
    where applicable
16/08/06 22:16:15 WARN Utils: Your hostname, ubuntu resolves to a 
    loopback address: 127.0.1.1; using 192.168.22.180 instead (on 
    interface eth1)
16/08/06 22:16:15 WARN Utils: Set SPARK_LOCAL_IP if you need to 
    bind to another address
16/08/06 22:16:16 WARN Utils: Service 'SparkUI' could not bind on 
    port 4040\. Attempting port 4041.
Welcome to
 ____              __
 / __/__  ___ _____/ /__
 _ / _ / ______/ __/  '_/
 /__ / .__/_,_/_/ /_/_   version 2.0.0
 /_/

Using Python version 2.7.6 (default, Jun 22 2015 17:58:13)
SparkSession available as 'spark'.
>>> 

R是一种语言,具有用于统计计算和图形的运行时环境。它是 GNU 项目。R 是S的另一种实现(由贝尔实验室开发的语言)。

R 提供了统计(线性和非线性建模、经典统计测试、时间序列分析、分类和聚类)和图形技术。它被认为是高度可扩展的。

要使用 R 来使用 Spark,运行以下命令打开 Spark-R shell:

$ ~/work/spark-2.0.0-bin-hadoop2.7/bin/sparkR
R version 3.0.2 (2013-09-25) -- "Frisbee Sailing"
Copyright (C) 2013 The R Foundation for Statistical Computing
Platform: x86_64-pc-linux-gnu (64-bit)

R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.

 Natural language support but running in an English locale

R is a collaborative project with many contributors.
Type 'contributors()' for more information and
'citation()' on how to cite R or R packages in publications.

Type 'demo()' for some demos, 'help()' for on-line help, or
'help.start()' for an HTML browser interface to help.
Type 'q()' to quit R.

Launching java with spark-submit command /home/ubuntu/work/spark- 
    2.0.0-bin-hadoop2.7/bin/spark-submit   "sparkr-shell" 
    /tmp/RtmppzWD8S/backend_porta6366144af4f 
Using Spark's default log4j profile: org/apache/spark/log4j-
    defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel).
16/08/06 22:26:22 WARN NativeCodeLoader: Unable to load native-
    hadoop library for your platform... using builtin-java classes 
    where applicable
16/08/06 22:26:22 WARN Utils: Your hostname, ubuntu resolves to a 
    loopback address: 127.0.1.1; using 192.168.22.186 instead (on 
    interface eth1)
16/08/06 22:26:22 WARN Utils: Set SPARK_LOCAL_IP if you need to 
    bind to another address
16/08/06 22:26:22 WARN Utils: Service 'SparkUI' could not bind on 
    port 4040\. Attempting port 4041.

 Welcome to
 ____              __ 
 / __/__  ___ _____/ /__ 
 _ / _ / _ ____/ __/  '_/ 
 /___/ .__/_,_/_/ /_/_   version  2.0.0 
 /_/ 
 SparkSession available as 'spark'.
During startup - Warning message:
package 'SparkR' was built under R version 3.1.1 
> 

Resilient Distributed Datasets

Spark 的核心是一个叫做Resilient Distributed DatasetRDD)的概念。RDD 是一个记录(严格来说,是某种类型的对象)的集合,分布或分区在集群中的许多节点上(对于 Spark 本地模式,单个多线程进程可以以相同的方式来看待)。Spark 中的 RDD 是容错的;这意味着如果给定的节点或任务失败(除了错误的用户代码之外的某些原因,如硬件故障、通信丢失等),RDD 可以在剩余的节点上自动重建,作业仍将完成。

创建 RDDs

RDDs 可以是您之前启动的 Scala Spark shells:

val collection = List("a", "b", "c", "d", "e") 
val rddFromCollection = sc.parallelize(collection)

RDDs 也可以从基于 Hadoop 的输入源创建,包括本地文件系统、HDFS 和 Amazon S3。基于 Hadoop 的 RDD 可以利用实现 Hadoop InputFormat接口的任何输入格式,包括文本文件、其他标准 Hadoop 格式、HBase、Cassandra、tachyon 等等。

以下代码是一个示例,演示如何从本地文件系统上的文本文件创建 RDD:

val rddFromTextFile = sc.textFile("LICENSE")

textFile方法返回一个 RDD,其中每个记录都是一个代表文本文件一行的String对象。前面命令的输出如下:

rddFromTextFile: org.apache.spark.rdd.RDD[String] = LICENSE   
MapPartitionsRDD[1] at textFile at <console>:24

以下代码是一个示例,演示如何使用hdfs://协议从 HDFS 上的文本文件创建 RDD:

val rddFromTextFileHDFS = sc.textFile("hdfs://input/LICENSE ")

以下代码是一个示例,演示如何使用s3n://协议从 Amazon S3 上的文本文件创建 RDD:

val rddFromTextFileS3 = sc.textFile("s3n://input/LICENSE ")

Spark 操作

一旦我们创建了一个 RDD,我们就有了一个可以操作的分布式记录集合。在 Spark 的编程模型中,操作分为转换和动作。一般来说,转换操作将某个函数应用于数据集中的所有记录,以某种方式改变记录。动作通常运行一些计算或聚合操作,并将结果返回给运行SparkContext的驱动程序程序。

Spark 操作是函数式的风格。对于熟悉 Scala、Python 或 Java 8 中的 Lambda 表达式的函数式编程的程序员来说,这些操作应该看起来很自然。对于没有函数式编程经验的人,不用担心;Spark API 相对容易学习。

在 Spark 程序中,您将使用的最常见的转换之一是 map 操作符。这将对 RDD 的每个记录应用一个函数,从而将输入映射到一些新的输出。例如,以下代码片段将我们从本地文本文件创建的 RDD,并将size函数应用于 RDD 中的每个记录。请记住,我们创建了一个 String 的 RDD。使用map,我们可以将每个字符串转换为整数,从而返回一个Ints的 RDD:

val intsFromStringsRDD = rddFromTextFile.map(line => line.size)

您应该在 shell 中看到类似以下行的输出;这表示 RDD 的类型:

intsFromStringsRDD: org.apache.spark.rdd.RDD[Int] = 
MapPartitionsRDD[2] at map at <console>:26

在前面的代码中,我们看到了=>语法的使用。这是 Scala 中匿名函数的语法,它是一个不是命名方法的函数(也就是说,使用def关键字在 Scala 或 Python 中定义的方法)。

虽然匿名函数的详细处理超出了本书的范围,但它们在 Scala 和 Python 的 Spark 代码中被广泛使用,以及在 Java 8 中(在示例和实际应用中),因此涵盖一些实用性是有用的。

=> line.size的语法意味着我们正在应用一个函数,其中=>是操作符,输出是=>操作符右侧代码的结果。在这种情况下,输入是行,输出是调用line.size的结果。在 Scala 中,将字符串映射为整数的函数表示为String => Int

这种语法使我们不必每次使用 map 等方法时单独定义函数;当函数简单且只使用一次时,这是很有用的,就像这个例子。

现在,我们可以对我们的 RDD 应用一个常见的动作操作,count,以返回记录的数量:

intsFromStringsRDD.count

结果应该看起来像以下的控制台输出:

res0: Long = 299

也许我们想要找到这个文本文件中每行的平均长度。我们可以首先使用sum函数将所有记录的长度相加,然后将总和除以记录的数量:

val sumOfRecords = intsFromStringsRDD.sum 
val numRecords = intsFromStringsRDD.count 
val aveLengthOfRecord = sumOfRecords / numRecords

结果将如下所示:

scala> intsFromStringsRDD.count
res0: Long = 299

scala> val sumOfRecords = intsFromStringsRDD.sum
sumOfRecords: Double = 17512.0

scala> val numRecords = intsFromStringsRDD.count
numRecords: Long = 299

scala> val aveLengthOfRecord = sumOfRecords / numRecords
aveLengthOfRecord: Double = 58.5685618729097

Spark 操作在大多数情况下都会返回一个新的 RDD,除了大多数动作,它们返回计算的结果(例如在前面的示例中的Long表示计数,Double表示求和)。这意味着我们可以自然地链接操作以使我们的程序流更简洁和表达力更强。例如,可以使用以下代码实现与前一行代码相同的结果:

val aveLengthOfRecordChained = rddFromTextFile.map(line => line.size).sum / rddFromTextFile.count

一个重要的要点是,Spark 的转换是惰性的。也就是说,在 RDD 上调用转换不会立即触发计算。相反,转换被链接在一起,只有在调用动作时才有效地计算。这使得 Spark 能够更有效地只在必要时将结果返回给驱动程序,以便大多数操作在集群上并行执行。

这意味着如果您的 Spark 程序从不使用动作操作,它将永远不会触发实际的计算,也不会得到任何结果。例如,以下代码将简单地返回一个表示转换链的新 RDD:

val transformedRDD = rddFromTextFile.map(line => line.size).filter(size => size > 10).map(size => size * 2)

这将在控制台中返回以下结果:

transformedRDD: org.apache.spark.rdd.RDD[Int] = 
MapPartitionsRDD[6] at map at <console>:26

请注意,没有实际的计算发生,也没有结果返回。如果我们现在对生成的 RDD 调用一个动作,比如 sum,计算将被触发:

val computation = transformedRDD.sum

现在,您将看到运行了一个 Spark 作业,并且结果显示在控制台上:

computation: Double = 35006.0

有关 RDD 可能的所有转换和操作的完整列表,以及一组更详细的示例,都可以在 Spark 编程指南(位于spark.apache.org/docs/latest/programming-guide.html#rdd-operations)和 API 文档(Scala API 文档)中找到(位于spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.rdd.RDD)。

缓存 RDDs

Spark 最强大的功能之一是能够在整个集群中将数据缓存在内存中。这是通过在 RDD 上使用 cache 方法来实现的:

rddFromTextFile.cache
res0: rddFromTextFile.type = MapPartitionsRDD[1] at textFile at 
<console>:27

在 RDD 上调用cache告诉 Spark 应该将 RDD 保存在内存中。第一次调用 RDD 上的操作以启动计算时,数据将从其源读取并放入内存。因此,第一次调用此类操作时,运行任务所需的时间部分取决于从输入源读取数据所需的时间。但是,当下一次访问数据时(例如,在分析中的后续查询或机器学习模型的迭代中),数据可以直接从内存中读取,从而避免昂贵的 I/O 操作,并在许多情况下显着加快计算速度。

如果我们现在在缓存的 RDD 上调用countsum函数,RDD 将加载到内存中:

val aveLengthOfRecordChained = rddFromTextFile.map(line => 
line.size).sum / rddFromTextFile.count

Spark 还允许更精细地控制缓存行为。您可以使用persist方法指定 Spark 用于缓存数据的方法。有关 RDD 缓存的更多信息,请参见此处:

spark.apache.org/docs/latest/programmingguide.html#rdd-persistence

广播变量和累加器

Spark 的另一个核心特性是能够创建两种特殊类型的变量--广播变量和累加器。

广播变量是从驱动程序对象创建的只读变量,并提供给将执行计算的节点。这在需要以高效的方式将相同数据提供给工作节点的应用程序中非常有用,例如分布式系统。Spark 使创建广播变量变得非常简单,只需在SparkContext上调用一个方法即可,如下所示:

val broadcastAList = sc.broadcast(List("a", "b", "c", "d", "e"))

广播变量可以通过在变量上调用value来从创建它的驱动程序之外的节点(即工作节点)访问:

sc.parallelize(List("1", "2", "3")).map(x => broadcastAList.value ++  
  x).collect

此代码使用来自集合(在本例中为 Scala List)的三条记录创建一个新的 RDD。在映射函数中,它返回一个新的集合,其中包含从我们的新 RDD 附加到broadcastAList的相关记录,这是我们的广播变量:

...
res1: Array[List[Any]] = Array(List(a, b, c, d, e, 1), List(a, b, 
c, d, e, 2), List(a, b, c, d, e, 3))

请注意前面代码中的collect方法。这是一个 Spark 操作,它将整个 RDD 作为 Scala(或 Python 或 Java)集合返回给驱动程序。

我们经常在希望在驱动程序中本地应用进一步处理结果时使用。

请注意,collect通常只应在我们真正希望将完整结果集返回给驱动程序并执行进一步处理的情况下使用。如果我们尝试在非常大的数据集上调用collect,可能会在驱动程序上耗尽内存并使程序崩溃。

最好尽可能在我们的 Spark 集群上执行尽可能多的重型处理,以防止驱动程序成为瓶颈。然而,在许多情况下,例如在许多机器学习模型的迭代中,将结果收集到驱动程序是必要的。

检查结果后,我们将看到我们的新 RDD 中的每个三条记录,现在都有一个记录,即我们原始广播的List,并将新元素附加到其中(即现在末尾有"1""2""3"):

累加器也是广播到工作节点的变量。广播变量和累加器之间的关键区别在于,虽然广播变量是只读的,但累加器可以添加。这方面存在一些限制,即特别是加法必须是可关联的操作,以便可以正确地并行计算全局累积值并将其返回到驱动程序。每个工作节点只能访问并添加到其本地累加器值,只有驱动程序才能访问全局值。累加器也可以使用value方法在 Spark 代码中访问。

有关广播变量和累加器的更多详细信息,请参考Spark 编程指南中的共享变量部分,网址为spark.apache.org/docs/latest/programming-guide.html#shared-variables

SchemaRDD

SchemaRDD是 RDD 和模式信息的组合。它还提供了许多丰富且易于使用的 API(即DataSet API)。SchemaRDD 在 2.0 中不再使用,而是由DataFrameDataset API 在内部使用。

模式用于描述结构化数据的逻辑组织方式。在获取模式信息后,SQL 引擎能够为相应的数据提供结构化查询功能。DataSet API 是 Spark SQL 解析器函数的替代品。它是一个用于实现原始程序逻辑树的 API。后续处理步骤重用了 Spark SQL 的核心逻辑。我们可以安全地将DataSet API 的处理函数视为与 SQL 查询完全等效。

SchemaRDD 是一个 RDD 子类。当程序调用DataSet API 时,会创建一个新的 SchemaRDD 对象,并通过在原始逻辑计划树上添加一个新的逻辑操作节点来创建new对象的逻辑计划属性。DataSet API 的操作(与 RDD 一样)有两种类型--TransformationAction

与关系操作相关的 API 归属于 Transformation 类型。

与数据输出源相关的操作属于 Action 类型。与 RDD 一样,只有在调用 Action 类型操作时,Spark 作业才会被触发并交付给集群执行。

Spark 数据框

在 Apache Spark 中,Dataset是分布式数据集合。Dataset是自 Spark 1.6 以来新增的接口。它结合了 RDD 的优点和 Spark SQL 的执行引擎的优点。Dataset可以从 JVM 对象构建,然后使用功能转换(mapflatMapfilter等)进行操作。Dataset API 仅适用于 Scala 和 Java,不适用于 Python 或 R。

DataFrame是一个带有命名列的数据集。它相当于关系数据库中的表或 R/Python 中的数据框,但具有更丰富的优化。DataFrame可以从结构化数据文件、Hive 中的表、外部数据库或现有的 RDD 构建。DataFrame API 在 Scala、Python、Java 和 R 中都可用。

Spark DataFrame首先需要实例化 Spark 会话:

import org.apache.spark.sql.SparkSession 
val spark = SparkSession.builder().appName("Spark SQL").config("spark.some.config.option", "").getOrCreate() 
import spark.implicits._

接下来,我们使用spark.read.json函数从 Json 文件创建一个DataFrame

scala> val df = spark.read.json("/home/ubuntu/work/ml-resources
  /spark-ml/Chapter_01/data/example_one.json")

请注意,Spark Implicits正在被用来隐式地将 RDD 转换为数据框类型:

org.apache.spark.sql
Class SparkSession.implicits$
Object org.apache.spark.sql.SQLImplicits
Enclosing class: [SparkSession](https://spark.apache.org/docs/2.0.0/api/java/org/apache/spark/sql/SparkSession.html)

Scala 中可用的隐式方法,用于将常见的 Scala 对象转换为DataFrames

输出将类似于以下清单:

df: org.apache.spark.sql.DataFrame = [address: struct<city: 
string, state: string>, name: string]

现在我们想看看这实际上是如何加载到DataFrame中的:

scala> df.show
+-----------------+-------+
|          address|   name|
+-----------------+-------+
|  [Columbus,Ohio]|    Yin|
|[null,California]|Michael|
+-----------------+-------+

Spark 程序在 Scala 中的第一步

我们现在将使用我们在上一节介绍的思想来编写一个基本的 Spark 程序来操作数据集。我们将从 Scala 开始,然后在 Java 和 Python 中编写相同的程序。我们的程序将基于探索来自在线商店的一些数据,关于哪些用户购买了哪些产品。数据包含在名为UserPurchaseHistory.csv逗号分隔值CSV)文件中。该文件应该位于data目录中。

内容如下所示。CSV 的第一列是用户名,第二列是产品名称,最后一列是价格:

John,iPhone Cover,9.99
John,Headphones,5.49
Jack,iPhone Cover,9.99
Jill,Samsung Galaxy Cover,8.95
Bob,iPad Cover,5.49

对于我们的 Scala 程序,我们需要创建两个文件-我们的 Scala 代码和我们的项目构建配置文件-使用构建工具Scala Build ToolSBT)。为了方便使用,我们建议您在本章中使用-spark-app。此代码还包含了 data 目录下的 CSV 文件。您需要在系统上安装 SBT 才能运行此示例程序(我们在撰写本书时使用的是版本 0.13.8)。

设置 SBT 超出了本书的范围;但是,您可以在www.scala-sbt.org/release/docs/Getting-Started/Setup.html找到更多信息。

我们的 SBT 配置文件build.sbt看起来像这样(请注意,代码每一行之间的空行是必需的):

name := "scala-spark-app" 
version := "1.0" 
scalaVersion := "2.11.7" 
libraryDependencies += "org.apache.spark" %% "spark-core" % "2.0.0"

最后一行将 Spark 的依赖项添加到我们的项目中。

我们的 Scala 程序包含在ScalaApp.scala文件中。我们将逐步讲解程序。首先,我们需要导入所需的 Spark 类:

import org.apache.spark.SparkContext 
import org.apache.spark.SparkContext._ 

/** 
 * A simple Spark app in Scala 
 */ 
object ScalaApp {

在我们的主方法中,我们需要初始化我们的SparkContext对象,并使用它来访问我们的 CSV 数据文件的textFile方法。然后,我们将通过在分隔符字符上拆分字符串并提取有关用户名、产品和价格的相关记录来映射原始文本:

def main(args: Array[String]) { 
  val sc = new SparkContext("local[2]", "First Spark App") 
  // we take the raw data in CSV format and convert it into a 
   set of records of the form (user, product, price) 
  val data = sc.textFile("data/UserPurchaseHistory.csv") 
    .map(line => line.split(",")) 
    .map(purchaseRecord => (purchaseRecord(0), 
     purchaseRecord(1), purchaseRecord(2)))

现在我们有了一个 RDD,其中每条记录由(用户产品价格)组成,我们可以为我们的商店计算各种有趣的指标,例如以下指标:

  • 购买总数

  • 购买的独特用户数量

  • 我们的总收入

  • 我们最受欢迎的产品

让我们计算前述指标:

// let's count the number of purchases 
val numPurchases = data.count() 
// let's count how many unique users made purchases 
val uniqueUsers = data.map{ case (user, product, price) => user 
}.distinct().count() 
// let's sum up our total revenue 
val totalRevenue = data.map{ case (user, product, price) => 
price.toDouble }.sum() 
// let's find our most popular product 
val productsByPopularity = data 
  .map{ case (user, product, price) => (product, 1) } 
  .reduceByKey(_ + _) 
  .collect() 
  .sortBy(-_._2)     
val mostPopular = productsByPopularity(0)

计算最受欢迎产品的最后一段代码是 Hadoop 流行的Map/Reduce模式的一个例子。首先,我们将我们的记录(用户产品价格)映射到(产品1)的记录。然后,我们执行了reduceByKey操作,对每个唯一产品的 1 进行求和。

一旦我们有了这个转换后的 RDD,其中包含了每种产品的购买数量,我们将调用collect,将计算结果返回给驱动程序作为本地 Scala 集合。然后我们会在本地对这些计数进行排序(请注意,在实践中,如果数据量很大,我们通常会使用 Spark 操作(例如sortByKey)并行进行排序)。

最后,我们将在控制台上打印出我们的计算结果:

    println("Total purchases: " + numPurchases) 
    println("Unique users: " + uniqueUsers) 
    println("Total revenue: " + totalRevenue) 
    println("Most popular product: %s with %d 
    purchases".format(mostPopular._1, mostPopular._2)) 
  } 
}

我们可以通过在项目的基本目录中运行sbt run或者在使用 Scala IDE 时运行程序来运行此程序。输出应该类似于以下内容:

...
[info] Compiling 1 Scala source to ...
[info] Running ScalaApp
...
Total purchases: 5
Unique users: 4
Total revenue: 39.91
Most popular product: iPhone Cover with 2 purchases

我们可以看到我们有来自四个不同用户的5次购买,总收入为39.91。我们最受欢迎的产品是一个带有2次购买的iPhone 保护套

Java 中 Spark 程序的第一步

Java API 在原则上与 Scala API 非常相似。但是,虽然 Scala 可以很容易地调用 Java 代码,但在某些情况下,从 Java 调用 Scala 代码是不可能的。特别是当 Scala 代码使用 Scala 特性,如隐式转换、默认参数和 Scala 反射 API 时。

Spark 通常大量使用这些功能,因此有必要专门为 Java 提供一个单独的 API,其中包括常见类的 Java 版本。因此,SparkContext变成了JavaSparkContext,而 RDD 变成了 JavaRDD。

Java 8 之前的版本不支持匿名函数,也没有简洁的函数式编程语法,因此 Spark Java API 中的函数必须实现WrappedFunction接口,并具有call方法签名。虽然这种方式更加冗长,但我们经常会创建一次性的匿名类,将其传递给我们的 Spark 操作,这些匿名类实现了这个接口和call方法,从而实现了与 Scala 中匿名函数几乎相同的效果。

Spark 支持 Java 8 的匿名函数(或lambda)语法。使用这种语法使得用 Java 8 编写的 Spark 程序看起来非常接近等效的 Scala 程序。

在 Scala 中,键/值对的 RDD 提供了特殊的运算符(例如reduceByKeysaveAsSequenceFile),这些运算符可以通过隐式转换自动访问。在 Java 中,需要特殊类型的JavaRDD类才能访问类似的函数。这些包括JavaPairRDD用于处理键/值对和JavaDoubleRDD用于处理数值记录。

在本节中,我们介绍了标准的 Java API 语法。有关在 Java 中使用 RDD 以及 Java 8 lambda 语法的更多细节和示例,请参阅Spark 编程指南中的 Java 部分,网址为spark.apache.org/docs/latest/programming-guide.html#rdd-operations

我们将在接下来的 Java 程序中看到大部分这些差异的示例,该程序包含在本章示例代码的java-spark-app目录中。code目录还包含data子目录下的 CSV 数据文件。

我们将使用Maven构建工具构建和运行这个项目,我们假设您已经在系统上安装了它。

安装和设置 Maven 超出了本书的范围。通常,可以使用 Linux 系统上的软件包管理器,或者在 Mac OS X 上使用 HomeBrew 或 MacPorts 轻松安装 Maven。

详细的安装说明可以在maven.apache.org/download.cgi找到。

该项目包含一个名为JavaApp.java的 Java 文件,其中包含我们的程序代码:

import org.apache.spark.api.java.JavaRDD; 
import org.apache.spark.api.java.JavaSparkContext; 
import scala.Tuple2; 
import java.util.*; 
import java.util.stream.Collectors; 

/** 
 * A simple Spark app in Java 
 */ 
public class JavaApp { 
  public static void main(String[] args) {

与我们的 Scala 示例一样,我们首先需要初始化我们的上下文。请注意,我们将在这里使用JavaSparkContext类,而不是之前使用的SparkContext类。我们将以相同的方式使用JavaSparkContext类来使用textFile访问我们的数据,然后将每一行拆分为所需的字段。请注意我们如何使用匿名类来定义一个拆分函数,该函数在突出显示的代码中执行字符串处理:

JavaSparkContext sc = new JavaSparkContext("local[2]", 
     "First Spark App"); 
// we take the raw data in CSV format and convert it into a 
// set of records of the form (user, product, price) 
JavaRDD<String[]> data =   sc.textFile("data/UserPurchaseHistory.csv").map(s ->         s.split(","));

现在,我们可以计算与我们在 Scala 示例中所做的相同的指标。请注意,一些方法对于 Java 和 Scala API 是相同的(例如distinctcount)。还请注意我们传递给 map 函数的匿名类的使用。这段代码在这里突出显示:

// let's count the number of purchases 
long numPurchases = data.count(); 
// let's count how many unique users made purchases 
long uniqueUsers = data.map(strings ->  
      strings[0]).distinct().count(); 
// let's sum up our total revenue 
Double totalRevenue = data.map(strings ->  
      Double.parseDouble(strings[2])).reduce((Double v1,  
Double v2) -> new Double(v1.doubleValue() + v2.doubleValue()));

在以下代码行中,我们可以看到计算最受欢迎产品的方法与 Scala 示例中的方法相同。额外的代码可能看起来复杂,但它主要与创建匿名函数所需的 Java 代码相关(我们在这里进行了突出显示)。实际功能是相同的:

// let's find our most popular product 
List<Tuple2<String, Integer>> pairs = data.mapToPair(strings -> new Tuple2<String, Integer>(strings[1], 1)).reduceByKey((Integer i1, Integer i2) -> i1 + i2).collect(); 

Map<String, Integer> sortedData = new HashMap<>(); 
Iterator it = pairs.iterator(); 
while (it.hasNext()) { 
    Tuple2<String, Integer> o = (Tuple2<String, Integer>) it.next(); 
    sortedData.put(o._1, o._2); 
} 
List<String> sorted = sortedData.entrySet() 
        .stream() 
        .sorted(Comparator.comparing((Map.Entry<String, Integer> 
          entry) -> entry.getValue()).reversed())
         .map(Map.Entry::getKey) 
        .collect(Collectors.toList()); 
String mostPopular = sorted.get(0); 
            int purchases = sortedData.get(mostPopular); 
    System.out.println("Total purchases: " + numPurchases); 
    System.out.println("Unique users: " + uniqueUsers); 
    System.out.println("Total revenue: " + totalRevenue); 
    System.out.println(String.format("Most popular product:
     %s with %d purchases", mostPopular, purchases)); 
  } 
}

可以看到,一般结构与 Scala 版本相似,除了额外的样板代码用于通过匿名内部类声明变量和函数。通过逐行比较 Scala 代码和 Java 代码,理解如何在每种语言中实现相同的结果是一个很好的练习。

可以通过从项目的基本目录执行以下命令来运行此程序:

  $ mvn exec:java -Dexec.mainClass="JavaApp"

您将看到与 Scala 版本非常相似的输出,计算结果相同:

...
14/01/30 17:02:43 INFO spark.SparkContext: Job finished: collect 
at JavaApp.java:46, took 0.039167 s
Total purchases: 5
Unique users: 4
Total revenue: 39.91
Most popular product: iPhone Cover with 2 purchases

Python 中 Spark 程序的第一步

Spark 的 Python API 在 Python 语言中几乎暴露了 Spark 的 Scala API 的所有功能。有一些功能目前尚不支持(例如,使用 GraphX 进行图处理以及一些 API 方法)。有关更多详细信息,请参阅Spark 编程指南的 Python 部分(spark.apache.org/docs/latest/programming-guide.html)。

PySpark是使用 Spark 的 Java API 构建的。数据在本地 Python 中处理,缓存,并在 JVM 中进行洗牌。Python 驱动程序的SparkContext使用 Py4J 来启动 JVM 并创建JavaSparkContext。驱动程序使用 Py4J 在 Python 和 Java 的SparkContext对象之间进行本地通信。Python 中的 RDD 转换映射到 Java 中的PythonRDD对象上的转换。PythonRDD对象在远程工作机器上启动 Python 子进程,并使用管道与它们通信。这些子进程用于发送用户的代码和处理数据。

在前面的例子之后,我们现在将写一个 Python 版本。我们假设你的系统上已安装了 Python 2.6 及更高版本(例如,大多数 Linux 和 Mac OS X 系统都预装了 Python)。

示例程序包含在本章的示例代码中,位于名为python-spark-app的目录中,该目录还包含data子目录下的 CSV 数据文件。该项目包含一个名为pythonapp.py的脚本,如下所示。

一个简单的 Python 中的 Spark 应用:

from pyspark import SparkContext

sc = SparkContext("local[2]", "First Spark App")
# we take the raw data in CSV format and convert it into a set of 
    records of the form (user, product, price)
data = sc.textFile("data/UserPurchaseHistory.csv").map(lambda 
    line: line.split(",")).map(lambda record: (record[0], record[1], 
    record[2]))
# let's count the number of purchases
numPurchases = data.count()
# let's count how many unique users made purchases
uniqueUsers = data.map(lambda record: record[0]).distinct().count()
# let's sum up our total revenue
totalRevenue = data.map(lambda record: float(record[2])).sum()
# let's find our most popular product
products = data.map(lambda record: (record[1], 
    1.0)).reduceByKey(lambda a, b: a + b).collect()
mostPopular = sorted(products, key=lambda x: x[1], reverse=True)[0]

print "Total purchases: %d" % numPurchases
print "Unique users: %d" % uniqueUsers
print "Total revenue: %2.2f" % totalRevenue
print "Most popular product: %s with %d purchases" % 
    (mostPopular[0], mostPopular[1])

如果你比较我们程序的 Scala 和 Python 版本,你会发现语法看起来非常相似。一个关键的区别是我们如何表达匿名函数(也称为lambda函数;因此,Python 语法中使用了这个关键字)。在 Scala 中,我们已经看到,将输入x映射到输出y的匿名函数表示为x => y,而在 Python 中,它是lambda x: y。在前面代码的突出行中,我们应用了一个将两个输入ab(通常是相同类型的)映射到输出的匿名函数。在这种情况下,我们应用的函数是加法函数;因此,lambda a, b: a + b

运行脚本的最佳方法是从示例项目的基本目录运行以下命令:

 $SPARK_HOME/bin/spark-submit pythonapp.py

在这里,SPARK_HOME变量应该被替换为你在本章开始时解压 Spark 预构建二进制包的目录路径。

运行脚本后,你应该看到与 Scala 和 Java 示例相似的输出,我们的计算结果也是相同的:

...
14/01/30 11:43:47 INFO SparkContext: Job finished: collect at 
pythonapp.py:14, took 0.050251 s
Total purchases: 5
Unique users: 4
Total revenue: 39.91
Most popular product: iPhone Cover with 2 purchases

在 R 中编写 Spark 程序的第一步

SparkR是一个 R 包,提供了一个前端来使用 Apache Spark。在 Spark 1.6.0 中,SparkR 提供了一个分布式数据框架用于大型数据集。SparkR 还支持使用 MLlib 进行分布式机器学习。在阅读机器学习章节时,你应该尝试一下这个。

SparkR 数据框

DataFrame是一个由名称列组织的分布式数据集合。这个概念与关系数据库或 R 的数据框非常相似,但优化更好。这些数据框的来源可以是 CSV、TSV、Hive 表、本地 R 数据框等。

Spark 分发可以使用./bin/sparkR shell来运行。

在前面的例子之后,我们现在将写一个 R 版本。我们假设你的系统上已安装了 R(例如,大多数 Linux 和 Mac OS X 系统都预装了 Python)。

示例程序包含在本章的示例代码中,位于名为r-spark-app的目录中,该目录还包含data子目录下的 CSV 数据文件。该项目包含一个名为r-script-01.R的脚本,如下所示。确保你将PATH更改为适合你的环境的值。

Sys.setenv(SPARK_HOME = "/PATH/spark-2.0.0-bin-hadoop2.7") 
.libPaths(c(file.path(Sys.getenv("SPARK_HOME"), "R", "lib"), 
 .libPaths())) 
#load the Sparkr library 
library(SparkR) 
sc <- sparkR.init(master = "local", sparkPackages="com.databricks:spark-csv_2.10:1.3.0") 
sqlContext <- sparkRSQL.init(sc) 

user.purchase.history <- "/PATH/ml-resources/spark-ml/Chapter_01/r-spark-app/data/UserPurchaseHistory.csv" 
data <- read.df(sqlContext, user.purchase.history, "com.databricks.spark.csv", header="false") 
head(data) 
count(data) 

parseFields <- function(record) { 
  Sys.setlocale("LC_ALL", "C") # necessary for strsplit() to work correctly 
  parts <- strsplit(as.character(record), ",") 
  list(name=parts[1], product=parts[2], price=parts[3]) 
} 

parsedRDD <- SparkR:::lapply(data, parseFields) 
cache(parsedRDD) 
numPurchases <- count(parsedRDD) 

sprintf("Number of Purchases : %d", numPurchases) 
getName <- function(record){ 
  record[1] 
} 

getPrice <- function(record){ 
  record[3] 
} 

nameRDD <- SparkR:::lapply(parsedRDD, getName) 
nameRDD = collect(nameRDD) 
head(nameRDD) 

uniqueUsers <- unique(nameRDD) 
head(uniqueUsers) 

priceRDD <- SparkR:::lapply(parsedRDD, function(x) { as.numeric(x$price[1])}) 
take(priceRDD,3) 

totalRevenue <- SparkR:::reduce(priceRDD, "+") 

sprintf("Total Revenue : %.2f", s) 

products <- SparkR:::lapply(parsedRDD, function(x) { list( toString(x$product[1]), 1) }) 
take(products, 5) 
productCount <- SparkR:::reduceByKey(products, "+", 2L) 
productsCountAsKey <- SparkR:::lapply(productCount, function(x) { list( as.integer(x[2][1]), x[1][1])}) 

productCount <- count(productsCountAsKey) 
mostPopular <- toString(collect(productsCountAsKey)[[productCount]][[2]]) 
sprintf("Most Popular Product : %s", mostPopular)

在 bash 终端上使用以下命令运行脚本:

  $ Rscript r-script-01.R 

你的输出将类似于以下清单:

> sprintf("Number of Purchases : %d", numPurchases)
[1] "Number of Purchases : 5"

> uniqueUsers <- unique(nameRDD)
> head(uniqueUsers)
[[1]]
[[1]]$name
[[1]]$name[[1]]
[1] "John"
[[2]]
[[2]]$name
[[2]]$name[[1]]
[1] "Jack"
[[3]]
[[3]]$name
[[3]]$name[[1]]
[1] "Jill"
[[4]]
[[4]]$name
[[4]]$name[[1]]
[1] "Bob"

> sprintf("Total Revenue : %.2f", totalRevenueNum)
[1] "Total Revenue : 39.91"

> sprintf("Most Popular Product : %s", mostPopular)
[1] "Most Popular Product : iPad Cover"

在亚马逊 EC2 上运行 Spark

Spark 项目提供了在亚马逊的 EC2 服务上在云中运行 Spark 集群的脚本。这些脚本位于ec2目录中。你可以使用以下命令在这个目录中运行spark-ec2脚本:

>./ec2/spark-ec2 

以这种方式运行它而不带参数将显示帮助输出:

Usage: spark-ec2 [options] <action> <cluster_name>
<action> can be: launch, destroy, login, stop, start, get-master

Options:
...

在创建 Spark EC2 集群之前,您需要确保您有一个

亚马逊账户。

如果您没有 Amazon Web Services 账户,可以在aws.amazon.com/注册。

AWS 控制台可在aws.amazon.com/console/找到。

您还需要创建一个 Amazon EC2 密钥对并检索相关的安全凭据。EC2 的 Spark 文档(可在spark.apache.org/docs/latest/ec2-scripts.html找到)解释了要求:

为自己创建一个 Amazon EC2 密钥对。这可以通过登录您的 Amazon Web Services 账户,点击左侧边栏上的密钥对,创建和下载一个密钥来完成。确保将私钥文件的权限设置为 600(即只有您可以读取和写入它),以便 ssh 正常工作。

每当您想要使用 spark-ec2 脚本时,将环境变量AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY设置为您的 Amazon EC2 访问密钥ID和秘密访问密钥。这些可以从 AWS 主页上通过点击账户|安全凭据|访问凭据来获取。

在创建密钥对时,选择一个易于记住的名称。我们将简单地使用名称spark作为密钥对。密钥对文件本身将被称为spark.pem。如前所述,确保密钥对文件权限设置正确,并且通过以下命令导出 AWS 凭据的环境变量:

  $ chmod 600 spark.pem
 $ export AWS_ACCESS_KEY_ID="..."
 $ export AWS_SECRET_ACCESS_KEY="..."

您还需要小心保管您下载的密钥对文件,不要丢失,因为它只能在创建时下载一次!

请注意,在下一节中启动 Amazon EC2 集群将会对您的 AWS 账户产生费用。

启动 EC2 Spark 集群

现在我们已经准备好通过切换到ec2目录然后运行集群启动命令来启动一个小的 Spark 集群:

 $  cd ec2
 $ ./spark-ec2 --key-pair=rd_spark-user1 --identity-file=spark.pem  
    --region=us-east-1 --zone=us-east-1a launch my-spark-cluster

这将启动一个名为 test-cluster 的新 Spark 集群,其中包含一个 master 和一个 slave 节点,实例类型为m3.medium。此集群将使用为 Hadoop 2 构建的 Spark 版本。我们使用的密钥对名称是 spark,密钥对文件是spark.pem(如果您给文件取了不同的名称或者有现有的 AWS 密钥对,请使用该名称)。

集群完全启动和初始化可能需要相当长的时间。在运行启动命令后,您应该立即看到类似以下的内容:

Setting up security groups...
Creating security group my-spark-cluster-master
Creating security group my-spark-cluster-slaves
Searching for existing cluster my-spark-cluster in region 
    us-east-1...
Spark AMI: ami-5bb18832
Launching instances...
Launched 1 slave in us-east-1a, regid = r-5a893af2
Launched master in us-east-1a, regid = r-39883b91
Waiting for AWS to propagate instance metadata...
Waiting for cluster to enter 'ssh-ready' state...........
Warning: SSH connection error. (This could be temporary.)
Host: ec2-52-90-110-128.compute-1.amazonaws.com
SSH return code: 255
SSH output: ssh: connect to host ec2-52-90-110-128.compute- 
    1.amazonaws.com port 22: Connection refused
Warning: SSH connection error. (This could be temporary.)
Host: ec2-52-90-110-128.compute-1.amazonaws.com
SSH return code: 255
SSH output: ssh: connect to host ec2-52-90-110-128.compute-
    1.amazonaws.com port 22: Connection refused
Warnig: SSH connection error. (This could be temporary.)
Host: ec2-52-90-110-128.compute-1.amazonaws.com
SSH return code: 255
SSH output: ssh: connect to host ec2-52-90-110-128.compute-
    1.amazonaws.com port 22: Connection refused
Cluster is now in 'ssh-ready' state. Waited 510 seconds.

如果集群已经成功启动,最终你应该会看到类似以下清单的控制台输出:

./tachyon/setup.sh: line 5: /root/tachyon/bin/tachyon: 
    No such file or directory
./tachyon/setup.sh: line 9: /root/tachyon/bin/tachyon-start.sh: 
    No such file or directory
[timing] tachyon setup:  00h 00m 01s
Setting up rstudio
spark-ec2/setup.sh: line 110: ./rstudio/setup.sh: 
    No such file or directory
[timing] rstudio setup:  00h 00m 00s
Setting up ganglia
RSYNC'ing /etc/ganglia to slaves...
ec2-52-91-214-206.compute-1.amazonaws.com
Shutting down GANGLIA gmond:                               [FAILED]
Starting GANGLIA gmond:                                    [  OK  ]
Shutting down GANGLIA gmond:                               [FAILED]
Starting GANGLIA gmond:                                    [  OK  ]
Connection to ec2-52-91-214-206.compute-1.amazonaws.com closed.
Shutting down GANGLIA gmetad:                              [FAILED]
Starting GANGLIA gmetad:                                   [  OK  ]
Stopping httpd:                                            [FAILED]
Starting httpd: httpd: Syntax error on line 154 of /etc/httpd
    /conf/httpd.conf: Cannot load /etc/httpd/modules/mod_authz_core.so 
    into server: /etc/httpd/modules/mod_authz_core.so: cannot open 
    shared object file: No such file or directory              [FAILED]
[timing] ganglia setup:  00h 00m 03s
Connection to ec2-52-90-110-128.compute-1.amazonaws.com closed.
Spark standalone cluster started at 
    http://ec2-52-90-110-128.compute-1.amazonaws.com:8080
Ganglia started at http://ec2-52-90-110-128.compute-
    1.amazonaws.com:5080/ganglia
Done!
ubuntu@ubuntu:~/work/spark-1.6.0-bin-hadoop2.6/ec2$

这将创建两个 VM - Spark Master 和 Spark Slave,类型为 m1.large,如下截图所示:

为了测试我们是否可以连接到我们的新集群,我们可以运行以下命令:

  $ ssh -i spark.pem root@ ec2-52-90-110-128.compute-1.amazonaws.com

请记住,用正确的 Amazon EC2 公共域名替换主节点的公共域名(在上述命令中root@之后的地址),该域名将在启动集群后显示在您的控制台输出中。

您还可以通过运行以下代码来检索集群的主公共域名:

  $ ./spark-ec2 -i spark.pem get-master test-cluster

成功运行ssh命令后,您将连接到 EC2 中的 Spark 主节点,并且您的终端输出应该与以下截图相匹配:

我们可以通过切换到Spark目录并在本地模式下运行示例来测试我们的集群是否正确设置了 Spark:

  $ cd spark
 $ MASTER=local[2] ./bin/run-example SparkPi

您应该看到类似于在本地计算机上运行相同命令时得到的输出:

...
14/01/30 20:20:21 INFO SparkContext: Job finished: reduce at 
SparkPi.scala:35, took 0.864044012 s
Pi is roughly 3.14032
...

现在我们有了一个实际的多节点集群,我们可以在集群模式下测试 Spark。我们可以通过传入主 URL 而在集群上运行相同的示例,使用我们的一个 slave 节点:

    `$ MASTER=spark://` ec2-52-90-110-128.compute-
      1.amazonaws.com:`7077 ./bin/run-example SparkPi` 

请注意,您需要用您特定集群的正确域名替换前面的主机域名。

再次,输出应类似于在本地运行示例;但是,日志消息将显示您的驱动程序已连接到 Spark 主机:

...
14/01/30 20:26:17 INFO client.Client$ClientActor: Connecting to 
    master spark://ec2-54-220-189-136.eu-
    west-1.compute.amazonaws.com:7077
14/01/30 20:26:17 INFO cluster.SparkDeploySchedulerBackend: 
    Connected to Spark cluster with app ID app-20140130202617-0001
14/01/30 20:26:17 INFO client.Client$ClientActor: Executor added: 
    app-20140130202617-0001/0 on worker-20140130201049-
    ip-10-34-137-45.eu-west-1.compute.internal-57119 
    (ip-10-34-137-45.eu-west-1.compute.internal:57119) with 1 cores
14/01/30 20:26:17 INFO cluster.SparkDeploySchedulerBackend:
    Granted executor ID app-20140130202617-0001/0 on hostPort 
    ip-10-34-137-45.eu-west-1.compute.internal:57119 with 1 cores, 
    2.4 GB RAM
14/01/30 20:26:17 INFO client.Client$ClientActor: 
    Executor updated: app-20140130202617-0001/0 is now RUNNING
14/01/30 20:26:18 INFO spark.SparkContext: Starting job: reduce at 
    SparkPi.scala:39
...

随时尝试您的集群。例如,尝试在 Scala 中使用交互式控制台:

  **$ ./bin/spark-shell --master spark://** ec2-52-90-110-128.compute-
    1.amazonaws.com**:7077**

完成后,键入exit以离开控制台。您还可以通过运行以下命令尝试 PySpark 控制台:

  **$ ./bin/pyspark --master spark://** ec2-52-90-110-128.compute-
    1.amazonaws.com**:7077**

您可以使用 Spark Master Web 界面查看与主机注册的应用程序。要加载 Master Web UI,请导航至ec2-52-90-110-128.compute-1.amazonaws.com:8080(再次,请记住用您自己的主机域名替换此域名)。

记住您将被 Amazon 收费用于使用集群。完成测试后,请不要忘记停止或终止此测试集群。要执行此操作,您可以首先通过键入exit退出ssh会话,返回到您自己的本地系统,然后运行以下命令:

  $ ./ec2/spark-ec2 -k spark -i spark.pem destroy test-cluster

您应该看到以下输出:

Are you sure you want to destroy the cluster test-cluster?
The following instances will be terminated:
Searching for existing cluster test-cluster...
Found 1 master(s), 1 slaves
> ec2-54-227-127-14.compute-1.amazonaws.com
> ec2-54-91-61-225.compute-1.amazonaws.com
ALL DATA ON ALL NODES WILL BE LOST!!
Destroy cluster test-cluster (y/N): y
Terminating master...
Terminating slaves...

Y然后按Enter来销毁集群。

恭喜!您刚刚在云中设置了一个 Spark 集群,在该集群上运行了一个完全并行的示例程序,并终止了它。如果您想在集群上尝试后续章节中的任何示例代码(或您自己的 Spark 程序),请随时尝试使用 Spark EC2 脚本并启动您选择大小和实例配置文件的集群。(只需注意成本,并在完成后记得关闭它!)

在 Amazon Elastic Map Reduce 上配置和运行 Spark

使用 Amazon Elastic Map Reduce 安装了 Spark 的 Hadoop 集群。执行以下步骤创建安装了 Spark 的 EMR 集群:

  1. 启动 Amazon EMR 集群。

  2. console.aws.amazon.com/elasticmapreduce/上打开 Amazon EMR UI 控制台。

  3. 选择创建集群:

  1. 选择适当的 Amazon AMI 版本 3.9.0 或更高版本,如下截图所示:

  1. 要安装的应用程序字段中,从用户界面上显示的列表中选择 Spark 1.5.2 或更高版本,然后单击添加。

  2. 根据需要选择其他硬件选项:

  • 实例类型

  • 用于 SSH 的密钥对

  • 权限

  • IAM 角色(默认或自定义)

请参考以下截图:

  1. 单击创建集群。集群将开始实例化,如下截图所示:

  1. 登录到主机。一旦 EMR 集群准备就绪,您可以 SSH 登录到主机:
 **$ ssh -i rd_spark-user1.pem**
   hadoop@ec2-52-3-242-138.compute-1.amazonaws.com 

输出将类似于以下清单:

     Last login: Wed Jan 13 10:46:26 2016

 __|  __|_  )
 _|  (     /   Amazon Linux AMI
 ___|___|___|

 https://aws.amazon.com/amazon-linux-ami/2015.09-release-notes/
 23 package(s) needed for security, out of 49 available
 Run "sudo yum update" to apply all updates.
 [hadoop@ip-172-31-2-31 ~]$ 

  1. 启动 Spark Shell:
      [hadoop@ip-172-31-2-31 ~]$ spark-shell
 16/01/13 10:49:36 INFO SecurityManager: Changing view acls to: 
          hadoop
 16/01/13 10:49:36 INFO SecurityManager: Changing modify acls to: 
          hadoop
 16/01/13 10:49:36 INFO SecurityManager: SecurityManager: 
          authentication disabled; ui acls disabled; users with view 
          permissions: Set(hadoop); users with modify permissions: 
          Set(hadoop)
 16/01/13 10:49:36 INFO HttpServer: Starting HTTP Server
 16/01/13 10:49:36 INFO Utils: Successfully started service 'HTTP 
          class server' on port 60523.
 Welcome to
 ____              __
 / __/__  ___ _____/ /__
 _ / _ / _ &grave;/ __/  '_/
 /___/ .__/_,_/_/ /_/_   version 1.5.2
 /_/
 scala> sc

  1. 从 EMR 运行基本的 Spark 示例:
    scala> val textFile = sc.textFile("s3://elasticmapreduce/samples
      /hive-ads/tables/impressions/dt=2009-04-13-08-05
      /ec2-0-51-75-39.amazon.com-2009-04-13-08-05.log")
 scala> val linesWithCartoonNetwork = textFile.filter(line =>  
      line.contains("cartoonnetwork.com")).count()

您的输出将如下所示:

     linesWithCartoonNetwork: Long = 9

Spark 中的 UI

Spark 提供了一个 Web 界面,可用于监视作业,查看环境并运行 SQL 命令。

SparkContext在端口4040上启动 Web UI,显示有关应用程序的有用信息。这包括以下内容:

  • 调度程序阶段和任务的列表

  • RDD 大小和内存使用情况摘要

  • 环境信息

  • 有关正在运行的执行程序的信息

可以通过在 Web 浏览器中转到http://<driver-node>:4040来访问此界面。如果在同一主机上运行多个SparkContexts,它们将绑定到以端口404040414042等)开头的端口。

以下截图显示 Web UI 提供的一些信息:

显示 Spark 内容环境的 UI

显示可用执行程序的 UI 表

Spark 支持的机器学习算法

以下算法由 Spark ML 支持:

  • 协同过滤

  • 交替最小二乘法(ALS): 协同过滤经常用于推荐系统。这些技术旨在填补用户-项目关联矩阵的缺失条目。spark.mllib目前支持基于模型的协同过滤。在这种实现中,用户和产品由一小组潜在因子描述,这些因子可以用来预测缺失的条目。spark.mllib使用 ALS 算法来学习这些潜在因子。

  • 聚类:这是一个无监督学习问题,其目的是基于相似性的概念将实体的子集分组在一起。聚类用于探索性分析,并作为分层监督学习流水线的组成部分。在学习流水线中,为每个簇训练不同的分类器或回归模型。以下聚类技术在 Spark 中实现:

  • k 均值:这是一种常用的聚类算法,将数据点聚类到预定义数量的簇中。用户可以选择簇的数量。spark.mllib的实现包括 k 均值++方法的并行化变体(theory.stanford.edu/~sergei/papers/vldb12-kmpar.pdf)。

  • 高斯混合高斯混合模型GMM)表示一个复合分布,其中的点来自 k 个高斯子分布之一。每个分布都有自己的概率。spark.mllib的实现使用期望最大化算法来诱导给定一组样本的最大似然模型。

  • 幂迭代聚类(PIC):这是一种可扩展的算法,用于根据边属性的成对相似性对图的顶点进行聚类。它使用幂迭代计算图的(归一化的亲和矩阵的)伪特征向量。

幂迭代是一种特征值算法。给定一个矩阵X,该算法将产生一个数字λ(特征值)和一个非零向量v(特征向量),使得Xv = λv

矩阵的伪特征向量可以被视为附近矩阵的特征向量。更具体地说,伪特征向量被定义为:

A为一个n乘以n的矩阵。设E为任何矩阵,使得||E|| = €。那么A + E的特征向量被定义为A的伪特征向量。这个特征向量用于对图顶点进行聚类。

spark.mllib包括使用GraphX实现的 PIC。它接受一个元组的 RDD,并输出具有聚类分配的模型。相似性必须是非负的。PIC 假设相似性度量是对称的。

(在统计学中,相似性度量或相似性函数是一种实值函数,用于量化两个对象之间的相似性。这些度量是距离度量的倒数;其中的一个例子是余弦相似性)

无论顺序如何,输入数据中的一对(srcIddstId)应该最多出现一次。

    • 潜在狄利克雷分配LDA):这是一种从文本文档集合中推断主题的主题模型。LDA 是一种聚类算法。以下几点解释了主题:

主题是聚类中心,文档对应于数据集中的示例。主题和文档都存在于特征空间中,其中特征向量是词频向量(也称为词袋)。

LDA 不使用传统的距离方法来估计聚类,而是使用基于文本文档生成模型的函数。

    • 二分 k 均值:这是一种层次聚类的类型。层次聚类分析HCA)是一种构建聚类层次结构的聚类分析方法。在这种方法中,所有观察开始在一个簇中,并且随着向下移动层次结构,递归地执行分裂。

层次聚类是一种常用的聚类分析方法,旨在构建一个集群的层次结构。

    • 流式 k 均值:当数据以流的形式到达时,我们希望动态估计集群并在新数据到达时更新它们。spark.mllib支持流式 k 均值聚类,具有控制估计衰减的参数。该算法使用小批量 k 均值更新规则的泛化。
  • 分类

  • 决策树:决策树及其集成是分类和回归的方法之一。决策树很受欢迎,因为它们易于解释,处理分类特征,并扩展到多类分类设置。它们不需要特征缩放,也能捕捉非线性和特征交互。树集成算法,随机森林和提升是分类和回归场景中的顶级表现者之一。

spark.mllib实现了用于二元和多类分类和回归的决策树。它支持连续和分类特征。该实现通过行对数据进行分区,从而允许使用数百万个实例进行分布式训练。

    • 朴素贝叶斯:朴素贝叶斯分类器是一类简单的概率分类器,基于应用贝叶斯定理(en.wikipedia.org/wiki/Bayes%27_theorem)并假设特征之间有强(朴素)独立性。

朴素贝叶斯是一种多类分类算法,假设每对特征之间都是独立的。在训练数据的单次传递中,该算法计算每个特征在给定标签的条件概率分布,然后应用贝叶斯定理来计算给定观察结果的标签的条件概率分布,然后用于预测。spark.mllib支持多项式朴素贝叶斯和伯努利朴素贝叶斯。这些模型通常用于文档分类。

    • 概率分类器:在机器学习中,概率分类器是一种可以预测给定输入的类别集上的概率分布的分类器,而不是输出样本应属于的最有可能的类别。概率分类器提供一定程度的分类确定性,这在单独使用或将分类器组合成集成时可能会有用。
  • 逻辑回归:这是一种用于预测二元响应的方法。逻辑回归通过估计概率来衡量分类因变量和自变量之间的关系,使用逻辑函数。这个函数是一个累积逻辑分布。

这是广义线性模型GLM)的特例,用于预测结果的概率。有关更多背景和实施细节,请参阅spark.mllib中关于逻辑回归的文档。

GLM 被认为是允许具有非正态分布的响应变量的线性回归的泛化。

    • 随机森林:这些算法使用决策树的集成来决定决策边界。随机森林结合了许多决策树。这降低了过拟合的风险。

Spark ML 支持用于二元和多类分类以及回归的随机森林。它可以用于连续或分类值。

  • 降维:这是减少机器学习变量数量的过程。它可以用于从原始特征中提取潜在特征,或者在保持整体结构的同时压缩数据。MLlib 在RowMatrix类的基础上支持降维。

    • 奇异值分解(SVD):矩阵M:m x n(实数或复数)的奇异值分解是一个形式为UΣV**的分解,其中U是一个m x R矩阵。Σ是一个R x R的矩形对角矩阵,对角线上有非负实数,V是一个n x r的酉矩阵。r等于矩阵M*的秩。
  • 主成分分析(PCA):这是一种统计方法,用于找到一个旋转,使得第一个坐标轴上的方差最大。依次,每个后续坐标轴的方差都尽可能大。旋转矩阵的列称为主成分。PCA 在降维中被广泛使用。

MLlib 支持使用RowMatrix对以行为导向格式存储的高瘦矩阵进行 PCA。

Spark 支持使用 TF-IDF、ChiSquare、Selector、Normalizer 和 Word2Vector 进行特征提取和转换。

  • 频繁模式挖掘

  • FP-growth:FP 代表频繁模式。算法首先计算数据集中项(属性和值对)的出现次数,并将它们存储在头表中。

在第二次遍历中,算法通过插入实例(由项组成)来构建 FP 树结构。每个实例中的项按其在数据集中的频率降序排序;这确保了树可以快速处理。不满足最小覆盖阈值的实例中的项将被丢弃。对于许多实例共享最频繁项的用例,FP 树在树根附近提供了高压缩。

    • 关联规则:关联规则学习是一种在大型数据库中发现变量之间有趣关系的机制。

它实现了一个并行规则生成算法,用于构建具有单个项作为结论的规则。

  • PrefixSpan:这是一种序列模式挖掘算法。

  • 评估指标spark.mllib配备了一套用于评估算法的指标。

  • PMML 模型导出预测模型标记语言PMML)是一种基于 XML 的预测模型交换格式。PMML 提供了一种机制,使分析应用程序能够描述和交换由机器学习算法产生的预测模型。

spark.mllib允许将其机器学习模型导出为 PMML 及其等效的 PMML 模型。

  • 优化(开发人员)

  • 随机梯度下降:这用于优化梯度下降以最小化目标函数;该函数是可微函数的和。

梯度下降方法和随机次梯度下降SGD)作为 MLlib 中的低级原语,各种 ML 算法都是在其之上开发的。

  • 有限内存 BFGS(L-BFGS):这是一种优化算法,属于拟牛顿方法家族,近似Broyden-Fletcher-Goldfarb-ShannoBFGS)算法。它使用有限的计算机内存。它用于机器学习中的参数估计。

BFGS 方法近似牛顿法,这是一类寻找函数的稳定点的爬山优化技术。对于这样的问题,一个必要的最优条件是梯度应该为零

与现有库相比,使用 Spark ML 的好处

伯克利的 AMQ 实验室评估了 Spark,并通过一系列在 Amazon EC2 上的实验以及用户应用程序的基准测试来评估了 RDD。

  • 使用的算法:逻辑回归和 k-means

  • 用例:第一次迭代,多次迭代。

所有测试都使用了具有 4 个核心和 15 GB RAM 的m1.xlarge EC2 节点。HDFS 用于存储,块大小为 256 MB。参考以下图表:

上面的图表显示了 Hadoop 和 Spark 在逻辑回归的第一次和后续迭代中的性能比较:

前面的图表显示了 K 均值聚类算法的第一次和后续迭代中 Hadoop 和 Spark 的性能比较。

总体结果如下:

  • Spark 在迭代式机器学习和图应用程序中的性能比 Hadoop 快 20 倍。这种加速来自于通过将数据存储在内存中作为 Java 对象来避免 I/O 和反序列化成本。

  • 编写的应用程序表现良好并且扩展性好。Spark 可以将在 Hadoop 上运行的分析报告加速 40 倍。

  • 当节点失败时,Spark 可以通过仅重建丢失的 RDD 分区来快速恢复。

  • Spark 被用来与 1TB 数据集进行交互式查询,延迟为 5-7 秒。

有关更多信息,请访问people.csail.mit.edu/matei/papers/2012/nsdi_spark.pdf

Spark 与 Hadoop 的 SORT 基准测试-2014 年,Databricks 团队参加了 SORT 基准测试(sortbenchmark.org/)。这是在一个 100TB 数据集上进行的。Hadoop 在一个专用数据中心运行,而 Spark 集群在 EC2 上运行了 200 多个节点。Spark 在 HDFS 分布式存储上运行。

Spark 比 Hadoop 快 3 倍,并且使用的机器数量少 10 倍。请参考以下图表:

Google Compute Engine 上的 Spark 集群-DataProc

Cloud Dataproc是在 Google Compute Engine 上运行的 Spark 和 Hadoop 服务。这是一个托管服务。Cloud Dataproc 自动化帮助快速创建集群,轻松管理它们,并在您不需要它们时关闭集群以节省费用。

在本节中,我们将学习如何使用 DataProc 创建一个 Spark 集群,并在其上运行一个示例应用程序。

确保您已经创建了 Google Compute Engine 帐户并安装了 Google Cloud SDK (cloud.google.com/sdk/gcloud/)。

Hadoop 和 Spark 版本

DataProc 支持以下 Hadoop 和 Spark 版本。请注意,随着新版本的推出,这些将会发生变化:

有关更多信息,请访问cloud.google.com/dataproc-versions

在接下来的步骤中,我们将使用 Google Cloud 控制台(用于创建 Spark 集群和提交作业的用户界面)。

创建集群

您可以通过转到 Cloud 平台控制台来创建一个 Spark 集群。选择项目,然后点击“继续”打开“集群”页面。如果您已经创建了任何集群,您将看到属于您项目的 Cloud Dataproc 集群。

点击“创建集群”按钮,打开“创建 Cloud Data pros 集群”页面。请参考以下截图:

一旦您点击“创建集群”,一个详细的表单将显示出来,如下截图所示:

前面的截图显示了默认字段自动填充为新集群-1 集群的“创建 Cloud Dataproc 集群”页面。请看以下截图:

您可以展开工作节点、存储桶、网络、版本、初始化和访问选项面板,以指定一个或多个工作节点、一个暂存桶、网络、初始化、Cloud Dataproc 镜像版本、操作和项目级别的访问权限。提供这些值是可选的。

默认集群创建时没有工作节点,自动创建的暂存桶和默认网络。它还具有最新发布的 Cloud Dataproc 镜像版本。您可以更改这些默认设置:

在页面上配置所有字段后,点击“创建”按钮创建集群。创建的集群名称将显示在集群页面上。一旦创建了 Spark 集群,状态就会更新为“运行”。

点击之前创建的集群名称以打开集群详情页面。它还有一个概述选项卡和 CPU 利用率图表选定。

您可以从其他选项卡查看集群的作业、实例等。

提交作业

要从 Cloud Platform 控制台向集群提交作业,请转到 Cloud Platform UI。选择适当的项目,然后点击“继续”。第一次提交作业时,会出现以下对话框:

点击“提交作业”:

要提交一个 Spark 示例作业,请在“提交作业”页面上填写字段,如下所示:

  1. 在屏幕上的集群列表中选择一个集群名称。

  2. 将作业类型设置为 Spark。

  3. file:///usr/lib/spark/lib/spark-examples.jar添加到 Jar 文件。这里,file:///表示 Hadoop 的LocalFileSystem方案;Cloud Dataproc 在创建集群时会在主节点上安装/usr/lib/spark/lib/spark-examples.jar。或者,您可以指定一个 Cloud Storage 路径(gs://my-bucket/my-jarfile.jar)或一个HDFS路径(hdfs://examples/myexample.jar)到其中一个自定义的 jar。

  4. Main类或 jar 设置为org.apache.spark.examples.SparkPi

  5. 将参数设置为单个参数1000

点击提交以开始作业。

作业开始后,它将被添加到作业列表中。请参考以下截图:

作业完成后,其状态会发生变化:

查看此处列出的job输出。

从终端执行带有适当作业 ID 的命令。

在我们的案例中,作业 ID 是1ed4d07f-55fc-45fe-a565-290dcd1978f7,项目 ID 是rd-spark-1;因此,命令如下所示:

  $ gcloud beta dataproc --project=rd-spark-1 jobs wait 1ed4d07f-
    55fc-45fe-a565-290dcd1978f7

(删节)输出如下所示:

Waiting for job output...
16/01/28 10:04:29 INFO akka.event.slf4j.Slf4jLogger: Slf4jLogger 
    started
16/01/28 10:04:29 INFO Remoting: Starting remoting
...
Submitted application application_1453975062220_0001
Pi is roughly 3.14157732 

您还可以通过 SSH 连接到 Spark 实例,并以交互模式运行 spark-shell。

摘要

在本章中,我们介绍了如何在我们自己的计算机上以及作为在云上运行的集群上本地设置 Spark。您学习了如何在 Amazon EC2 上运行 Spark。您还学习了如何使用 Google Compute Engine 的 Spark 服务来创建集群并运行简单作业。我们讨论了 Spark 的编程模型和 API 的基础知识,使用交互式 Scala 控制台编写了相同的基本 Spark 程序,并在 Scala、Java、R 和 Python 中编写了相同的基本 Spark 程序。我们还比较了不同机器学习算法的 Hadoop 与 Spark 的性能指标,以及 SORT 基准测试。

在下一章中,我们将考虑如何使用 Spark 创建一个机器学习系统。

第二章:机器学习数学

机器学习用户需要对机器学习概念和算法有一定的了解。熟悉数学是机器学习的重要方面。我们通过理解语言的基本概念和结构来学习编程。同样,我们通过理解数学概念和算法来学习机器学习,数学用于解决复杂的计算问题,是理解和欣赏许多计算机科学概念的学科。数学在掌握理论概念和选择正确算法方面起着基本作用。本章涵盖了机器学习线性代数微积分的基础知识。

在本章中,我们将涵盖以下主题:

  • 线性代数

  • 环境设置

  • 在 Intellij 中设置 Scala 环境

  • 在命令行上设置 Scala 环境

  • 领域

  • 向量

  • 向量空间

  • 向量类型:

  • 密集向量

  • 稀疏向量

  • Spark 中的向量

  • 向量运算

  • 超平面

  • 机器学习中的向量

  • 矩阵

  • 介绍

  • 矩阵类型:

  • 密集矩阵

  • CSC 矩阵

  • Spark 中的矩阵

  • 矩阵运算

  • 行列式

  • 特征值和特征向量

  • 奇异值分解

  • 机器学习中的矩阵

  • 函数

  • 定义

  • 函数类型:

  • 线性函数

  • 多项式函数

  • 身份函数

  • 常数函数

  • 概率分布函数

  • 高斯函数

  • 功能组合

  • 假设

  • 梯度下降

  • 先验、似然和后验

  • 微积分

  • 微分计算

  • 积分微积分

  • 拉格朗日乘数

  • 绘图

线性代数

线性代数是解决线性方程组和变换的研究。向量、矩阵和行列式是线性代数的基本工具。我们将使用Breeze详细学习这些内容。Breeze 是用于数值处理的基础线性代数库。相应的 Spark 对象是 Breeze 的包装器,并作为 Spark ML 库的公共接口,以确保即使 Breeze 在内部发生变化,Spark ML 库的一致性也得到保证。

在 Intellij 中设置 Scala 环境

最好使用像 IntelliJ 这样的 IDE 来编辑 Scala 代码,它提供了更快的开发工具和编码辅助。代码完成和检查使编码和调试更快更简单,确保您专注于学习机器学习的最终目标。

IntelliJ 2016.3 将 Akka、Scala.meta、Memory view、Scala.js 和 Migrators 作为 Scala 插件的一部分引入到 IntelliJ IDE 中。现在,让我们按照以下步骤在 Intellij 中设置 Scala 环境:

  1. 转到首选项 | 插件,并验证是否安装了 Scala 插件。SBT 是 Scala 的构建工具,默认配置如下截图所示:

  1. 选择文件 | 新建 | 从现有资源创建项目 | \(GIT_REPO/Chapter_02/breeze 或\)GIT_REPO/Chapter_02/spark。这里,$GIT_REPO 是您克隆了书籍源代码的存储库路径。

  2. 通过选择 SBT 选项导入项目:

  1. 保持 SBT 的默认选项,然后单击完成。

  2. SBT 将花一些时间从build.sbt中导入引用。

  1. 最后,在源文件上右键单击,然后选择运行'Vector'。

在命令行上设置 Scala 环境

要在本地设置环境,请按照下面列出的步骤进行:

  1. 转到第二章的根目录,并选择适当的文件夹。
 $ cd /PATH/spark-ml/Chapter_02/breeze

或者,选择以下内容:

 $ cd /PATH/spark-ml/Chapter_02/spark

  1. 编译代码。
      $ sbt compile

  1. 运行编译后的代码,并选择要运行的程序(显示的类取决于是否在 Spark 或 Breeze 文件夹中执行sbt run)。
 $ sbt run

 Multiple main classes detected, select one to run:
 ....
 Enter number:

领域

领域是数学中定义的基本结构。我们现在将看一下最基本的类型。

实数

实数是我们可以想到的任何数字;实数包括整数(0, 1, 2, 3)、有理数(2/6, 0.768, 0.222...,3.4)和无理数(π,√3)。实数可以是正数、负数或零。另一方面,虚数就像√−1(负 1 的平方根);请注意,无穷大不是实数。

复数

我们知道一个数的平方永远不可能是负数。在这种情况下,我们如何解决x2 = -9?在数学中,我们有 i 的概念,作为一个解,即x = 3i。诸如 i、-i、3i 和 2.27i 的数字称为虚数。"一个实数" + "一个虚数"形成一个"复数"。

复数 = (实部) + (虚部) I

以下示例展示了使用 Breeze 库进行数学运算的复数表示:

import breeze.linalg.DenseVector 
import breeze.math.Complex 
val i = Complex.i 

// add 
println((1 + 2 * i) + (2 + 3 * i)) 

// sub 
println((1 + 2 * i) - (2 + 3 * i)) 

// divide 
println((5 + 10 * i) / (3 - 4 * i)) 

// mul 
println((1 + 2 * i) * (-3 + 6 * i)) 
println((1 + 5 * i) * (-3 + 2 * i)) 

// neg 
println(-(1 + 2 * i)) 

// sum of complex numbers 
val x = List((5 + 7 * i), (1 + 3 * i), (13 + 17 * i)) 
println(x.sum) 
// product of complex numbers 
val x1 = List((5 + 7 * i), (1 + 3 * i), (13 + 17 * i)) 
println(x1.product) 
// sort list of complex numbers 
val x2 = List((5 + 7 * i), (1 + 3 * i), (13 + 17 * i)) 
println(x2.sorted) 

上述代码给出了以下结果:

3.0 + 5.0i
-1.0 + -1.0i -1.0 + 2.0i
-15.0 + 0.0i
-13.0 + -13.0i
-1.0 + -2.0i
19.0 + 27.0i
-582.0 + 14.0i
List(1.0 + 3.0i, 5.0 + 7.0i, 13.0 + 17.0i)

向量

向量是一个数学对象,描述为一组有序的数字。它类似于一个集合,只是向量中保持顺序。所有成员都是实数的一部分。具有维度 n 的向量在几何上表示为n维空间中的一个点。向量的原点从零开始。

例子:

[2, 4, 5, 9, 10]
[3.14159, 2.718281828, −1.0, 2.0]
[1.0, 1.1, 2.0]

向量空间

线性代数被广泛认为是向量空间的代数。实数或复数类型的向量对象可以通过将向量与标量数α相乘来进行加法和缩放。

向量空间是一组可以相加和相乘的向量对象。两个向量可以组合成第三个向量或向量空间中的另一个对象。向量空间的公理具有有用的性质。向量空间中的空间有助于研究物理空间的性质,例如,找出物体的近或远。向量空间的一个例子是三维欧几里德空间中的向量集合。向量空间V在域F上具有以下属性:

  • 向量加法:由v + w表示,其中vw是空间V的元素

  • 标量乘法:用α * v表示,其中αF的元素

  • 结合性:由u + (v + w) = (u + v) + w表示,其中uvw是空间V的元素

  • 可交换:由v + w = w + v表示

  • 分配:由α * (v + w) = α * v + α * w表示

在机器学习中,特征是向量空间的维度。

向量类型

在 Scala 中,我们将使用 Breeze 库来表示向量。向量可以表示为密集向量或稀疏向量。

Breeze 中的向量

Breeze 使用两种基本向量类型-breeze.linalg.DenseVectorbreeze.linalg.SparseVector-来表示前面显示的两种向量类型。

DenseVector是一个围绕数组的包装器,支持数值运算。让我们首先看一下密集向量的计算;我们将使用 Breeze 创建一个密集向量对象,然后将索引三更新为一个新值。

import breeze.linalg.DenseVector 
val v = DenseVector(2f, 0f, 3f, 2f, -1f) 
v.update(3, 6f) 
println(v) 

这给出了以下结果:DenseVector (2.0, 0.0, 3.0, 6.0, -1.0)

SparseVector是一个大部分值为零的向量,并支持数值运算。让我们看一下稀疏向量的计算;我们将使用 Breeze 创建一个稀疏向量对象,然后将值更新为 1。

import breeze.linalg.SparseVectorval sv:SparseVector[Double] = 
SparseVector(5)() 
sv(0) = 1 
sv(2) = 3 
sv(4) = 5 
val m:SparseVector[Double] = sv.mapActivePairs((i,x) => x+1) 
println(m) 

这给出了以下结果:SparseVector((0,2.0), (2,4.0), (4,6.0))

Spark 中的向量

Spark MLlib 使用 Breeze 和 JBlas 进行内部线性代数运算。它使用自己的类来表示使用org.apache.spark.mllib.linalg.Vector工厂定义的向量。本地向量具有整数类型和基于 0 的索引。其值存储为双精度类型。本地向量存储在单台机器上,不能分布。Spark MLlib 支持两种类型的本地向量,使用工厂方法创建的密集和稀疏向量。

以下代码片段显示了如何在 Spark 中创建基本的稀疏和密集向量:

val dVectorOne: Vector = Vectors.dense(1.0, 0.0, 2.0) 
println("dVectorOne:" + dVectorOne) 
//  Sparse vector (1.0, 0.0, 2.0, 3.0) 
// corresponding to nonzero entries. 
val sVectorOne: Vector = Vectors.sparse(4,  Array(0, 2,3), 
   Array(1.0, 2.0, 3.0)) 
// Create a sparse vector (1.0, 0.0, 2.0, 2.0) by specifying its 
// nonzero entries. 
val sVectorTwo: Vector = Vectors.sparse(4, Seq((0, 1.0), (2, 2.0), 
  (3, 3.0))) 

上述代码产生以下输出:

dVectorOne:[1.0,0.0,2.0]
sVectorOne:(4,[0,2,3],[1.0,2.0,3.0])
sVectorTwo:(4,[0,2,3],[1.0,2.0,3.0])

Spark 提供了各种方法来访问和发现向量值,如下所示:

val sVectorOneMax = sVectorOne.argmax
val sVectorOneNumNonZeros = sVectorOne.numNonzeros
val sVectorOneSize = sVectorOne.size
val sVectorOneArray = sVectorOne.toArray
val sVectorOneJson = sVectorOne.toJson

println("sVectorOneMax:" + sVectorOneMax)
println("sVectorOneNumNonZeros:" + sVectorOneNumNonZeros)
println("sVectorOneSize:" + sVectorOneSize)
println("sVectorOneArray:" + sVectorOneArray)
println("sVectorOneJson:" + sVectorOneJson)
val dVectorOneToSparse = dVectorOne.toSparse

前面的代码产生了以下输出:

sVectorOneMax:3
sVectorOneNumNonZeros:3
sVectorOneSize:4
sVectorOneArray:[D@38684d54
sVectorOneJson:{"type":0,"size":4,"indices":[0,2,3],"values":
  [1.0,2.0,3.0]}
dVectorOneToSparse:(3,[0,2],[1.0,2.0])

向量操作

向量可以相加,相减,并且可以乘以标量。向量的其他操作包括找到平均值,归一化,比较和几何表示。

  • 加法操作:此代码显示了向量对象上的逐元素加法操作:
        // vector's 
        val v1 = DenseVector(3, 7, 8.1, 4, 5) 
        val v2 = DenseVector(1, 9, 3, 2.3, 8) 
        // elementwise add operation 
        def add(): Unit = { 
          println(v1 + v2) 
        } 

这段代码给出的结果如下:DenseVector(4.0, 16.0, 11.1, 6.3, 13.0)

  • 乘法和点操作:这是一种代数操作,它接受两个相等长度的数字序列,并返回一个数字;代数上,它是两个数字序列对应条目的乘积的和。数学上表示如下:

        a   b = |a| × |b| × cos(θ) OR a   b = ax × bx + ay × by 

        import breeze.linalg.{DenseVector, SparseVector} 
        val a = DenseVector(0.56390, 0.36231, 0.14601, 0.60294, 
           0.14535) 
        val b = DenseVector(0.15951, 0.83671, 0.56002, 0.57797, 
           0.54450) 
       println(a.t * b) 
       println(a dot b) 

前面的代码给出了以下结果:

 0.9024889161, 0.9024889161

        import breeze.linalg.{DenseVector, SparseVector} 
        val sva = 
           SparseVector(0.56390,0.36231,0.14601,0.60294,0.14535) 
        val svb = 
           SparseVector(0.15951,0.83671,0.56002,0.57797,0.54450) 
        println(sva.t * svb) 
        println(sva dot svb) 

最后的代码给出的结果如下:0.9024889161, 0.9024889161

  • 寻找平均值:此操作返回向量元素沿第一个数组维度的平均值,其大小不等于1。数学上表示如下:
        import breeze.linalg.{DenseVector, SparseVector} 
        import breeze.stats.mean 
        val mean = mean(DenseVector(0.0,1.0,2.0)) 
        println(mean) 

这给出了以下结果:

1.0

        import breeze.linalg.{DenseVector, SparseVector} 
        import breeze.stats.mean 
        val svm = mean(SparseVector(0.0,1.0,2.0)) 
        val svm1 = mean(SparseVector(0.0,3.0)) 
        println(svm, svm1) 

这给出了以下结果:

(1.0,1.5)

  • 归一化向量:每个向量都有一个大小,使用毕达哥拉斯定理计算,如|v| = sqrt(x² + y² + z²); 这个大小是从原点(0,0,0)到向量指示的点的长度。如果向量的大小是1,则向量是正规的。归一化向量意味着改变它,使其指向相同的方向(从原点开始),但其大小为一。因此,归一化向量是一个指向相同方向的向量,但其规范(长度)为1。它由^X 表示,并由以下公式给出:

其中的范数。它也被称为单位向量。

        import breeze.linalg.{norm, DenseVector, SparseVector} 
        import breeze.stats.mean 
        val v = DenseVector(-0.4326, -1.6656, 0.1253, 0.2877, -
          1.1465) 
        val nm = norm(v, 1) 

        //Normalizes the argument such that its norm is 1.0 
        val nmlize = normalize(v) 

        // finally check if the norm of normalized vector is 1 or not 
        println(norm(nmlize)) 

这给出了以下结果:

 Norm(of dense vector) = 3.6577

 Normalized vector is = DenseVector(-0.2068389122442966,  
      -0.7963728438143791, 0.05990965257561341, 0.1375579173663526,     
      -0.5481757117154094)

 Norm(of normalized vector) = 0.9999999999999999

  • 显示向量中的最小和最大元素:
        import breeze.linalg._ 
        val v1 = DenseVector(2, 0, 3, 2, -1) 
        println(argmin(v1)) 
        println(argmax(v1)) 
        println(min(v1)) 
        println(max(v1)) 

这给出了以下结果:

4, 2, -1, 3

  • 比较操作:这比较两个向量是否相等,以及进行小于或大于的操作:
        import breeze.linalg._ 
        val a1 = DenseVector(1, 2, 3) 
        val b1 = DenseVector(1, 4, 1) 
        println((a1 :== b1)) 
        println((a1 :<= b1)) 
        println((a1 :>= b1)) 
        println((a1 :< b1)) 
        println((a1 :> b1)) 

这给出了以下结果:

 BitVector(0), BitVector(0, 1), BitVector(0, 2),   
      BitVector(1),    
      BitVector(2)

  • 向量的几何表示:

超平面

如果n不是 1,2 或 3,实数域的向量很难可视化。熟悉的对象如线和平面对于任何值的n都是有意义的。沿着向量v定义的方向的线L,通过向量u标记的点P,可以写成如下形式:

L = {u + tv | t ∈ R}

给定两个非零向量uv,如果这两个向量不在同一条直线上,并且其中一个向量是另一个的标量倍数,则它们确定一个平面。两个向量的加法是通过将向量头尾相接以创建一个三角形序列来完成的。如果uv在一个平面上,那么它们的和也在uv的平面上。由两个向量uv表示的平面可以用数学方式表示如下:

{P + su + tv | s, t ∈ R}

我们可以将平面的概念推广为一组x + 1个向量和P, v1, . . . , vxR, n 中,其中x ≤ n确定一个 x 维超平面:

(P + X x i=1 λivi | λi *∈ *R)

机器学习中的向量

在机器学习中,特征使用 n 维向量表示。在机器学习中,要求用数字格式表示数据对象,以便进行处理和统计分析。例如,图像使用像素向量表示。

矩阵

在域F上的矩阵是一个二维数组,其条目是F的元素。实数域上的矩阵示例如下:

1 2 3

10 20 30

先前的矩阵有两行三列;我们称之为2×3矩阵。 传统上通过数字来指代行和列。 第 1 行是(1 2 3),第 2 行是(10 20 30);第 1 列是(1 10),第 2 列是(2 20),第 3 列是(3 30)。 通常,具有 m 行 n 列的矩阵称为m×n矩阵。 对于矩阵A,元素(i, j)被定义为第 i 行和第 j 列中的元素,并使用 Ai, j 或 Aij 表示。 我们经常使用 pythonese 表示法,A[i, j]。 第i行是向量(A[i, 0], A[i, 1], A[i, 2], , A[i, m − 1]),第 j 列是向量(A[0, j], A[1, j], A[2, j], , A[n − 1, j])。

矩阵类型

在 Scala 中,我们将使用 Breeze 库来表示矩阵。 矩阵可以表示为密集矩阵或 CSC 矩阵。

  • 密集矩阵:使用构造方法调用创建密集矩阵。 可以访问和更新其元素。 它是列主序的,可以转置为行主序。
        val a = DenseMatrix((1,2),(3,4)) 
          println("a : n" + a) 
         val m = DenseMatrix.zerosInt 

        The columns of a matrix can be accessed as Dense Vectors, and    
        the rows as  Dense Matrices. 

          println( "m.rows :" + m.rows + " m.cols : "  + m.cols) 
          m(::,1) 
         println("m : n" + m) 

  • 转置矩阵:转置矩阵意味着交换其行和列。 P×Q 矩阵的转置,写作 MT,是一个 Q×P 矩阵,使得(MT)j, I = Mi, j 对于每个 I ∈ P,j ∈ Q。 向量转置以创建矩阵行。
        m(4,::) := DenseVector(5,5,5,5,5).t 
        println(m) 

上述程序的输出如下:


 a : 
 1  2 
 3  4 
 Created a 5x5 matrix
 0  0  0  0  0 
 0  0  0  0  0 
 0  0  0  0  0 
 0  0  0  0  0 
 0  0  0  0  0 
 m.rows :5 m.cols : 5
 First Column of m : 
            DenseVector(0, 0, 0, 0, 0)
            Assigned 5,5,5,5,5 to last row of m.

 0  0  0  0  0 
 0  0  0  0  0 
 0  0  0  0  0 
      0  0  0  0  0 
      5  5  5  5  5 

  • CSC 矩阵CSC矩阵被称为压缩稀疏列矩阵。 CSC 矩阵支持所有矩阵操作,并使用Builder构建。
        val builder = new CSCMatrix.BuilderDouble 
        builder.add(3,4, 1.0) 
        // etc. 
        val myMatrix = builder.result() 

Spark 中的矩阵

Spark 中的本地矩阵具有整数类型的行和列索引。 值为双精度类型。 所有值都存储在单台机器上。 MLlib 支持以下矩阵类型:

  • 密集矩阵:条目值存储在列主序的单个双精度数组中。

  • 稀疏矩阵:非零条目值以列主序的 CSC 格式存储的矩阵。 例如,以下稠密矩阵存储在一维数组[2.0, 3.0, 4.0, 1.0, 4.0, 5.0]中,矩阵大小为(3, 2):

2.0 3.0``4.0 1.0``4.0 5.0

这是一个密集和稀疏矩阵的示例:

       val dMatrix: Matrix = Matrices.dense(2, 2, Array(1.0, 2.0, 3.0, 
          4.0)) 
        println("dMatrix: n" + dMatrix) 

        val sMatrixOne: Matrix = Matrices.sparse(3, 2, Array(0, 1, 3), 
           Array(0, 2, 1), Array(5, 6, 7)) 
        println("sMatrixOne: n" + sMatrixOne) 

        val sMatrixTwo: Matrix = Matrices.sparse(3, 2, Array(0, 1, 3), 
           Array(0, 1, 2), Array(5, 6, 7)) 
        println("sMatrixTwo: n" + sMatrixTwo) 

上述代码的输出如下:

 [info] Running linalg.matrix.SparkMatrix 
 dMatrix: 
 1.0  3.0 
 2.0  4.0 
 sMatrixOne: 
 3 x 2 CSCMatrix
 (0,0) 5.0
 (2,1) 6.0
 (1,1) 7.0
 sMatrixTwo: 
 3 x 2 CSCMatrix
 (0,0) 5.0
 (1,1) 6.0
 (2,1) 7.0

Spark 中的分布式矩阵

Spark 中的分布式矩阵具有长类型的行和列索引。 它具有双精度值,以分布方式存储在一个或多个 RDD 中。 Spark 中已实现了四种不同类型的分布式矩阵。 它们都是DistributedMatrix的子类。

RowMatrixRowMatrix是一种无意义的行索引的面向行的分布式矩阵。 (在面向行的矩阵中,数组的行的连续元素在内存中是连续的)。 RowMatrix实现为其行的 RDD。 每行是一个本地向量。 列数必须小于或等于2³¹,以便将单个本地向量传输到驱动程序,并且还可以使用单个节点进行存储或操作。

以下示例显示了如何从Vectors类创建行矩阵(密集和稀疏):

val spConfig = (new 
    SparkConf).setMaster("local").setAppName("SparkApp") 
     val sc = new SparkContext(spConfig) 
     val denseData = Seq( 
       Vectors.dense(0.0, 1.0, 2.1), 
       Vectors.dense(3.0, 2.0, 4.0), 
       Vectors.dense(5.0, 7.0, 8.0), 
       Vectors.dense(9.0, 0.0, 1.1) 
     ) 
     val sparseData = Seq( 
       Vectors.sparse(3, Seq((1, 1.0), (2, 2.1))), 
       Vectors.sparse(3, Seq((0, 3.0), (1, 2.0), (2, 4.0))), 
       Vectors.sparse(3, Seq((0, 5.0), (1, 7.0), (2, 8.0))), 
       Vectors.sparse(3, Seq((0, 9.0), (2, 1.0))) 
     ) 

val denseMat = new RowMatrix(sc.parallelize(denseData, 2)) 
val sparseMat = new RowMatrix(sc.parallelize(sparseData, 2)) 

println("Dense Matrix - Num of Rows :" + denseMat.numRows()) 
println("Dense Matrix - Num of Cols:" + denseMat.numCols()) 
println("Sparse Matrix - Num of Rows :" + sparseMat.numRows()) 
println("Sparse Matrix - Num of Cols:" + sparseMat.numCols()) 

sc.stop() 

上述代码的输出如下:

Using Spark's default log4j profile: 
org/apache/spark/log4j-  
defaults.properties
16/01/27 04:51:59 INFO SparkContext: Running Spark version 
1.6.0
Dense Matrix - Num of Rows :4
Dense Matrix - Num of Cols:3
...
Sparse Matrix - Num of Rows :4
Sparse Matrix - Num of Cols :3

IndexedRowMatrixIndexedRowMatrix类似于RowMatrix,但具有行索引,可用于标识行并执行连接。 在以下代码清单中,我们创建一个 4x3 的IndexedMatrix,并使用适当的行索引:

val data = Seq(
(0L, Vectors.dense(0.0, 1.0, 2.0)),
(1L, Vectors.dense(3.0, 4.0, 5.0)),
(3L, Vectors.dense(9.0, 0.0, 1.0))
).map(x => IndexedRow(x._1, x._2))
val indexedRows: RDD[IndexedRow] = sc.parallelize(data, 2)
val indexedRowsMat = new IndexedRowMatrix(indexedRows)
 println("Indexed Row Matrix - No of Rows: " + 
indexedRowsMat.numRows())
 println("Indexed Row Matrix - No of Cols: " + 
indexedRowsMat.numCols())

上述代码清单的输出如下:

Indexed Row Matrix - No of Rows: 4
Indexed Row Matrix - No of Cols: 3

CoordinateMatrix:这是以坐标列表(COO)格式存储的分布式矩阵,由其条目的 RDD 支持。

COO 格式存储了一个(行,列,值)元组的列表。 条目按(行索引,然后列索引)排序,以提高随机访问时间。 此格式适用于增量矩阵构建。

val entries = sc.parallelize(Seq( 
      (0, 0, 1.0), 
      (0, 1, 2.0), 
      (1, 1, 3.0), 
      (1, 2, 4.0), 
      (2, 2, 5.0), 
      (2, 3, 6.0), 
      (3, 0, 7.0), 
      (3, 3, 8.0), 
      (4, 1, 9.0)), 3).map { case (i, j, value) => 
      MatrixEntry(i, j, value) 
    } 
val coordinateMat = new CoordinateMatrix(entries) 
println("Coordinate Matrix - No of Rows: " + 
  coordinateMat.numRows()) 
println("Coordinate Matrix - No of Cols: " + 
  coordinateMat.numCols()) 

上述代码的输出如下:

Coordinate Matrix - No of Rows: 5
Coordinate - No of Cols: 4

矩阵操作

可以对矩阵执行不同类型的操作。

  • 逐元素加法:给定两个矩阵ab,两者的加法(a + b)意味着将两个矩阵的每个元素相加。

Breeze

        val a = DenseMatrix((1,2),(3,4)) 
        val b = DenseMatrix((2,2),(2,2)) 
        val c = a + b 
        println("a: n" + a) 
        println("b: n" + b) 
        println("a + b : n" + c) 

最后一段代码的输出如下:

 a:1  2 
 3  4 
 b: 2  2 
 2  2 
 a + b : 
 3  4 
 5  6 

  • 逐元素乘法:在这个操作中,矩阵a的每个元素都与矩阵相乘

Breeze

        a :* b  
        val d = a*b 
        println("Dot product a*b : n" + d) 

前面命令的输出如下:

 Dot product a*b :
 6   6 
 14  14

  • 逐元素比较:在这个操作中,将矩阵a的每个元素与b进行比较。Breeze 中的代码如下所示:

Breeze

        a :< b 

前面代码的输出如下:

 a :< b 
 false  false 
 false  false

  • 原地加法:这意味着将a的每个元素加 1。

Breeze

前面代码的输出如下:

 Inplace Addition : a :+= 1
 2  3 
 4  5 
      value = a :+= 1
 println("Inplace Addition : a :+= 1n" + e)

  • 逐元素求和:这用于添加矩阵的所有元素。Breeze 中的代码如下所示:

Breeze

        val sumA = sum(a) 
        println("sum(a):n" + sumA) 

前述代码的输出如下:

 sum(a):
 14

  • 逐元素最大值:为了找到矩阵中所有元素的最大值,我们使用
        a.max

Breeze

Breeze 中的代码可以写成如下形式:

        println("a.max:n" + a.max) 

  • 逐元素 argmax:这用于获取具有最大值的元素的位置。

Breeze

代码:

        println("argmax(a):n" + argmax(a)) 

前面命令的输出如下:

 argmax(a):
 (1,1)

  • Ceiling:这将每个矩阵元素四舍五入到下一个整数。

Breeze

代码:

        val g = DenseMatrix((1.1, 1.2), (3.9, 3.5)) 
        println("g: n" + g) 
        val gCeil =ceil(g) 
        println("ceil(g)n " + gCeil) 

前面代码的输出如下:

 g: 
          1.1  1.2 
          3.9  3.5 

 ceil(g)
          2.0  2.0 
          4.0  4.0 

  • Floor:Floor 将每个元素的值四舍五入为较低值的最接近整数。

Breeze

代码:

        val gFloor =floor(g) 
        println("floor(g)n" + gFloor) 

输出将如下所示:

 floor(g)
 1.0  1.0
 3.0  3.0

行列式

tr M 表示矩阵M的迹;它是沿对角线的元素的总和。矩阵的迹通常被用作矩阵的“大小”的度量。行列式被称为沿其对角线的元素的乘积。

行列式主要用于线性方程组;它指示列是否线性相关,并且还有助于找到矩阵的逆。对于大矩阵,行列式是使用拉普拉斯展开来计算的。

val detm: Matrix = Matrices.dense(3, 3, Array(1.0, 3.0, 5.0, 2.0, 
  4.0, 6.0, 2.0, 4.0, 5.0)) 
print(det(detm)) 

特征值和特征向量

Ax = b是从静态问题中产生的线性方程。另一方面,特征值用于动态问题。让我们将 A 视为一个具有 x 作为向量的矩阵;我们现在将在线性代数中解决新方程Ax= λx

A乘以x时,向量x改变了它的方向。但是有一些向量与Ax的方向相同-这些被称为特征向量,对于这些特征向量,以下方程成立:

Ax= λx

在最后一个方程中,向量Ax是向量x的λ倍,λ被称为特征值。特征值λ给出了向量的方向-如果它被反转,或者在相同的方向上。

Ax= λx也传达了det(A - λI) = 0,其中I是单位矩阵。这确定了n个特征值。

特征值问题定义如下:

A x = λ x

A x-λ x = 0

A x-λ I x = 0

(A-λ I) x = 0

如果x不为零,则前述方程只有解当且仅当|A-λ I| = 0。使用这个方程,我们可以找到特征值。

val A = DenseMatrix((9.0,0.0,0.0),(0.0,82.0,0.0),(0.0,0.0,25.0)) 
val es = eigSym(A) 
val lambda = es.eigenvalues 
val evs = es.eigenvectors 
println("lambda is : " + lambda) 
println("evs is : " + evs) 

这段最后的代码给出了以下结果:

lambda is : DenseVector(9.0, 25.0, 82.0)
evs is : 1.0  0.0  0.0 
0.0  0.0  1.0 
0.0  1.0  -0.0 

奇异值分解

矩阵M的奇异值分解:m x n(实数或复数)是一个形式为UΣV的分解,其中U是一个m x R矩阵。Σ是一个R x R的矩形对角矩阵,对角线上有非负实数,V是一个n x r酉矩阵。r等于矩阵M的秩。

Sigma 的对角线条目Σii被称为M的奇异值。U的列和V的列分别称为M的左奇异向量和右奇异向量。

以下是 Apache Spark 中 SVD 的示例:

package linalg.svd 

import org.apache.spark.{SparkConf, SparkContext} 
import org.apache.spark.mllib.linalg.distributed.RowMatrix 
import org.apache.spark.mllib.linalg.{Matrix,       
SingularValueDecomposition, Vector, Vectors} 
object SparkSVDExampleOne { 

  def main(args: Array[String]) { 
    val denseData = Seq( 
      Vectors.dense(0.0, 1.0, 2.0, 1.0, 5.0, 3.3, 2.1), 
      Vectors.dense(3.0, 4.0, 5.0, 3.1, 4.5, 5.1, 3.3), 
      Vectors.dense(6.0, 7.0, 8.0, 2.1, 6.0, 6.7, 6.8), 
      Vectors.dense(9.0, 0.0, 1.0, 3.4, 4.3, 1.0, 1.0) 
    ) 
    val spConfig = (new 
      SparkConf).setMaster("local").setAppName("SparkSVDDemo") 
    val sc = new SparkContext(spConfig) 
    val mat: RowMatrix = new RowMatrix(sc.parallelize(denseData, 2)) 

     // Compute the top 20 singular values and corresponding    
       singular vectors. 
    val svd: SingularValueDecomposition[RowMatrix, Matrix] = 
    mat.computeSVD(7, computeU = true) 
    val U: RowMatrix = svd.U // The U factor is a RowMatrix. 
    val s: Vector = svd.s // The singular values are stored in a 
      local dense  vector. 
    val V: Matrix = svd.V // The V factor is a local dense matrix. 
    println("U:" + U) 
    println("s:" + s) 
    println("V:" + V) 
    sc.stop() 
  } 
}

机器学习中的矩阵

矩阵被用作数学对象来表示图像、实际机器学习应用中的数据集,如面部或文本识别、医学成像、主成分分析、数值精度等。

例如,特征分解在这里得到解释。许多数学对象可以通过将它们分解为组成部分或找到普遍性质来更好地理解。

就像整数被分解成质因数一样,矩阵分解被称为特征分解,其中我们将矩阵分解为特征向量和特征值。

矩阵A的特征向量v是这样的,乘以A只改变v的比例,如下所示:

Av = λv

标量λ称为与该特征向量对应的特征值。然后矩阵A的特征分解如下:

A = V diag(λ)V −1

矩阵的特征分解与矩阵共享许多事实。如果任何一个特征值为 0,则矩阵是奇异的。实对称矩阵的特征分解也可用于优化二次表达式等。特征向量和特征值用于主成分分析

以下示例显示了如何使用DenseMatrix来获取特征值和特征向量:

// The data 
val msData = DenseMatrix( 
  (2.5,2.4), (0.5,0.7), (2.2,2.9), (1.9,2.2), (3.1,3.0), 
  (2.3,2.7), (2.0,1.6), (1.0,1.1), (1.5,1.6), (1.1,0.9)) 

def main(args: Array[String]): Unit = { 
       val pca = breeze.linalg.princomp(msData) 

       print("Center" , msData(*,::) - pca.center) 

       //the covariance matrix of the data 

       print("covariance matrix", pca.covmat) 

       // the eigenvalues of the covariance matrix, IN SORTED ORDER 
       print("eigen values",pca.eigenvalues) 

       // eigenvectors 
       print("eigen vectors",pca.loadings) 
       print(pca.scores) 
} 

这给我们以下结果:

eigen values = DenseVector(1.2840277121727839, 0.04908339893832732)
eigen vectors = -0.6778733985280118  -0.735178655544408 

函数

要定义数学对象如函数,我们必须首先了解集合是什么。

集合是一个无序的对象集合,如 S = {-4, 4, -3, 3, -2, 2, -1, 1, 0}。如果集合 S 不是无限的,我们用|S|表示元素的数量,这被称为集合的基数。如果AB是有限集合,则|AB|=|A||B|,这被称为笛卡尔积。

对于集合 A 中的每个输入元素,函数从另一个集合 B 中分配一个单一的输出元素。A 称为函数的定义域,B 称为值域。函数是一组(x, y)对,其中没有这些对具有相同的第一个元素。

例如:定义域为{1, 2, 3, . . .}的函数,将其输入加倍得到集合{(1,2),(2,4),(3,6),(4,8),...}

例如:定义域为{1, 2, 3, . . .},值域为{1, 2, 3, . . .}的函数,将其输入的数字相乘得到{((1,1),1),((1,2),2)),...,((2,1),2),((2,2),4),((2,3),6),... ((3,1),3),((3,2),6),((3,3),9),...

给定输入的输出称为该输入的像。函数 f 下 q 的像用f (q)表示。如果f(q)=s,我们说 q 在 f 下映射到 s。我们将其写为q->s。所有输出被选择的集合称为值域。

当我们想要说 f 是一个定义域为 D,值域为F的函数时,我们将其写为f: D -> F

函数类型

程序与函数

过程是对计算的描述,给定一个输入,产生一个输出。

函数或计算问题不指示如何从给定输入计算输出。

相同的规范可能存在许多方法。

对于每个输入,计算问题可能有几种可能的输出。

我们将在 Breeze 中编写程序;通常称为函数,但我们将保留该术语用于数学对象。

  • 一一函数

f : D -> F 如果f (x) = f (y)意味着x = y,即xy都在D中,则f : D -> F是一一函数。

  • 满射函数

F: D -> F 如果对于每个F的元素z,存在一个元素 a 在D中,使得f (a) = z,则称为满射。

如果函数是一一对应的并且满射的,则它是可逆的。

  • 线性函数:线性函数是其图形为直线的函数。线性函数的形式为z = f(x) = a + bx。线性函数有一个因变量和一个自变量。因变量是z,自变量是x

  • 多项式函数:多项式函数只涉及 x 的非负整数幂,如二次的、三次的、四次的等等。我们可以给出多项式的一般定义,并定义它的次数。次数为 n 的多项式是形式为f(x) = anx n + an−1x n−1 + . . . + a2x 2 + a1x + a0的函数,其中 a 是实数,也称为多项式的系数。

例如:f(x) = 4x 3 − 3x 2 + 2

  • 恒等函数:对于任何域DidD: D -> D将每个域元素d映射到它自己。

  • 常数函数:这是一个特殊的函数,表示为一条水平线。

  • 概率分布函数:用于定义特定实验不同结果的相对可能性。它为每个潜在结果分配一个概率。所有结果的概率必须总和等于 1。通常,概率分布是均匀分布。这意味着它为每个结果分配相同的概率。当我们掷骰子时,可能的结果是 1、2、3、4、5,概率定义为Pr(1) = Pr(2) = Pr(3) = Pr(4) = Pr(5) = 1/5

  • 高斯函数:当事件数量很大时,可以使用高斯函数来描述事件。高斯分布被描述为一个连续函数,也称为正态分布。正态分布的均值等于中位数,并且关于中心对称。

函数组合

对于函数f: A -> Bg: B -> C,函数f和函数g的函数组合是函数(g o f): A -> C,由(g o f)(x) = g(f(x))定义。例如,如果f : {1,2,3} -> {A,B,C,D}g : {A,B,C,D} -> {4,5}g(y)=y2f(x)=x+1的组合是(g o f)(x)=(x+1)2

函数组合是将一个函数应用于另一个函数的结果。因此,在(g o f)(x) = g(f(x))中,首先应用f(),然后是g()。一些函数可以分解为两个(或更多)更简单的函数。

假设

X表示输入变量,也称为输入特征,y表示我们试图预测的输出或目标变量。对(x, y)这对被称为一个训练示例,用于学习的数据集是一个包含m个训练示例的列表,其中{(x, y)}是一个训练集。我们还将使用X表示输入值的空间,Y表示输出值的空间。对于一个训练集,为了学习一个函数h: X → Y,使得h(x)y值的预测值。函数h被称为假设

当要预测的目标变量是连续的时,我们称学习问题为回归问题。当y可以取少量离散值时,我们称之为分类问题。

假设我们选择将y近似为x的线性函数。

假设函数如下:

在这个最后的假设函数中,θi是参数,也称为权重,它们参数化从XY的线性函数空间。为了简化表示法,我们还引入了一个约定,即让x0 = 1(这是截距项),如下所示:

在 RHS 上,我们将θx都视为向量,n 是输入变量的数量。

现在在我们继续之前,重要的是要注意,我们现在将从数学基础过渡到学习算法。优化成本函数和学习θ将奠定理解机器学习算法的基础。

给定一个训练集,我们如何学习参数θ?一种可能的方法是让h(x)接近给定训练示例的y。我们将定义一个函数,用于衡量每个θ值的h(x(i))与相应的y(i)之间的接近程度。我们将这个函数定义为成本函数。

梯度下降

梯度下降的 SGD 实现使用数据示例的简单分布式采样。损失是优化问题的一部分,因此是真子梯度。

这需要访问完整的数据集,这并不是最佳的。

参数miniBatchFraction指定要使用的完整数据的分数。在这个子集上的梯度的平均值

是随机梯度。S是大小为|S|= miniBatchFraction的样本子集。

在以下代码中,我们展示了如何使用随机梯度下降在小批量上计算权重和损失。该程序的输出是一组权重和损失的向量。

object SparkSGD { 
 def main(args: Array[String]): Unit = { 
    val m = 4 
    val n = 200000 
    val sc = new SparkContext("local[2]", "") 
    val points = sc.parallelize(0 until m, 
      2).mapPartitionsWithIndex { (idx, iter) => 
      val random = new Random(idx) 
      iter.map(i => (1.0, 
       Vectors.dense(Array.fill(n)(random.nextDouble())))) 
    }.cache() 
    val (weights, loss) = GradientDescent.runMiniBatchSGD( 
      points, 
      new LogisticGradient, 
      new SquaredL2Updater, 
      0.1, 
      2, 
      1.0, 
      1.0, 
      Vectors.dense(new ArrayDouble)) 
    println("w:"  + weights(0)) 
    println("loss:" + loss(0)) 
    sc.stop() 
  } 

先验,似然和后验

贝叶斯定理陈述如下:

后验=先验似然*

这也可以表示为P(A|B) = (P(B|A) * P(A)) / P(B),其中P(A|B)是给定B的* A*的概率,也称为后验。

先验:表示在观察数据对象之前或之前的知识或不确定性的概率分布

后验:表示在观察数据对象后参数可能性的条件概率分布

似然:落入特定类别或类别的概率。

这表示为:

微积分

微积分是一种数学工具,有助于研究事物的变化。它为建模存在变化的系统提供了一个框架,并推断了这些模型的预测。

微分微积分

微积分的核心是导数,其中导数被定义为给定函数相对于其变量之一的瞬时变化率。寻找导数的研究被称为微分。几何上,已知点的导数由函数图的切线的斜率给出,前提是导数存在,并且在该点被定义。

微分是积分的反向。微分有几个应用,比如在物理学中,位移的导数是速度,速度的导数是加速度。导数主要用于找到函数的最大值或最小值。

在机器学习中,我们处理对具有数百个或更多维度的变量或特征进行操作的函数。我们计算变量的每个维度的导数,并将这些偏导数组合成一个向量,这给我们所谓的梯度。类似地,对梯度进行二阶导数运算给我们一个被称为海森的矩阵。

梯度和海森矩阵的知识帮助我们定义诸如下降方向和下降速率之类的事物,告诉我们应该如何在函数空间中移动,以便到达最底部点,以最小化函数。

以下是一个简单目标函数的例子(使用向量化符号表示的带有权重xN数据点和D维度的线性回归:

拉格朗日乘数法是微积分中在涉及约束时最大化或最小化函数的标准方法。

积分微积分

积分微积分将颗粒状的部分连接在一起以找到总数。它也被称为反微分,其中微分是将其分成小块并研究其在前一节中描述的变化。

积分通常用于找到函数图形下方的面积。

拉格朗日乘数

在数学优化问题中,拉格朗日乘数法被用作在等式约束下找到函数的局部极小值和极大值的工具。一个例子涉及找到在给定约束条件下的最大熵分布。

这最好通过一个例子来解释。假设我们必须最大化K(x, y) = -x2 -y2,以y = x + 1*为约束。

约束函数为g(x, y) = x-y+1=0。然后L乘数变为这样:

关于xy和λ的微分,并设置为0,我们得到以下结果:

解决上述方程,我们得到x=-0.5y=0.5lambda=-1

绘图

在这一部分,我们将看到如何使用 Breeze 从 Breeze DenseVector创建一个简单的线图。

Breeze 使用了大部分 Scala 绘图工具的功能,尽管 API 不同。在下面的例子中,我们创建了两个向量x1y,并绘制了一条线并将其保存为 PNG 文件:

package linalg.plot 
import breeze.linalg._ 
import breeze.plot._ 

object BreezePlotSampleOne { 
  def main(args: Array[String]): Unit = { 

    val f = Figure() 
    val p = f.subplot(0) 
    val x = DenseVector(0.0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8) 
    val y = DenseVector(1.1, 2.1, 0.5, 1.0,3.0, 1.1, 0.0, 0.5,2.5) 
    p += plot(x,  y) 
    p.xlabel = "x axis" 
    p.ylabel = "y axis" 
    f.saveas("lines-graph.png") 
  } 
 } 

上述代码生成了以下线图:

Breeze 还支持直方图。这是为各种样本大小100,000100,0000绘制的,以及100个桶中的正态分布随机数。

package linalg.plot 
import breeze.linalg._ 
import breeze.plot._ 

object BreezePlotGaussian { 
  def main(args: Array[String]): Unit = { 
    val f = Figure() 
    val p = f.subplot(2, 1, 1) 
    val g = breeze.stats.distributions.Gaussian(0, 1) 
    p += hist(g.sample(100000), 100) 
    p.title = "A normal distribution" 
    f.saveas("plot-gaussian-100000.png") 
  } 
 } 

下一张图片显示了具有 1000000 个元素的高斯分布:

具有 100 个元素的高斯分布

摘要

在本章中,您学习了线性代数的基础知识,这对机器学习很有用,以及向量和矩阵等基本构造。您还学会了如何使用 Spark 和 Breeze 对这些构造进行基本操作。我们研究了诸如 SVD 之类的技术来转换数据。我们还研究了线性代数中函数类型的重要性。最后,您学会了如何使用 Breeze 绘制基本图表。在下一章中,我们将介绍机器学习系统、组件和架构的概述。

第三章:设计一个机器学习系统

在本章中,我们将为一个智能的、分布式的机器学习系统设计一个高层架构,该系统以 Spark 作为其核心计算引擎。我们将专注于采用自动化机器学习系统来支持业务的关键领域,对现有的基于 Web 的业务架构进行重新设计。

在我们深入研究我们的场景之前,我们将花一些时间了解机器学习是什么。

然后我们将:

  • 介绍一个假设的业务场景

  • 提供当前架构的概述

  • 探索机器学习系统可以增强或替代某些业务功能的各种方式

  • 基于这些想法提供一个新的架构

现代大规模数据环境包括以下要求:

  • 它必须与系统的其他组件集成,特别是与数据收集和存储系统、分析和报告以及前端应用程序集成

  • 它应该易于扩展,并且独立于其他架构。理想情况下,这应该以水平和垂直可扩展的形式存在

  • 它应该允许对所考虑的工作负载类型进行有效的计算,即机器学习和迭代分析应用

  • 如果可能的话,它应该支持批处理和实时工作负载

作为一个框架,Spark 符合这些标准。然而,我们必须确保在 Spark 上设计的机器学习系统也符合这些标准。实施一个最终导致我们的系统在这些要求中的一个或多个方面失败的算法是没有意义的。

什么是机器学习?

机器学习是数据挖掘的一个子领域。虽然数据挖掘已经存在了 50 多年,但机器学习是一个子集,其中使用大量机器来分析和从大型数据集中提取知识。

机器学习与计算统计密切相关。它与数学优化有着密切的联系;它为该领域提供了方法、理论和应用领域。机器学习被应用于各种类型的计算任务,其中设计和编程明确算法是不可行的。示例应用包括垃圾邮件过滤、光学字符识别(OCR)、搜索引擎和计算机视觉。机器学习有时与数据挖掘结合使用,后者更注重探索性数据分析,被称为无监督学习。

根据学习系统可用的学习信号的性质,机器学习系统可以分为三类。学习算法从提供的输入中发现结构。它可以有一个目标(隐藏的模式),或者它可以是一种试图找到特征的手段。

  • 无监督学习:学习系统没有给出输出的标签。它自己从给定的输入中找到结构

  • 监督学习:系统由人类提供输入和期望的输出,目标是学习一个模型将输入映射到输出

  • 强化学习:系统与环境互动,在没有人明确告诉它是否接近目标的情况下,执行一个规定的目标

在后面的章节中,我们将把监督学习和无监督学习映射到各个章节。

介绍 MovieStream

为了更好地说明我们架构的设计,我们将介绍一个实际的场景。假设我们刚刚被任命为 MovieStream 的数据科学团队负责人,MovieStream 是一个虚构的互联网业务,向用户提供流媒体电影和电视节目。

MovieStream 系统概述如下图所示:

MovieStream 的当前架构

正如我们在前面的图表中所看到的,目前,MovieStream 的内容编辑团队负责决定在网站的各个部分推广和展示哪些电影和节目。他们还负责为 MovieStream 的大规模营销活动创建内容,其中包括电子邮件和其他直接营销渠道。目前,MovieStream 基本上收集了用户在聚合基础上观看的标题的基本数据,并且可以访问用户在注册服务时收集的一些人口统计数据。此外,他们可以访问其目录中标题的一些基本元数据。

MovieStream 可以以自动化的方式处理许多目前由内容团队处理的功能。

机器学习系统的业务用例

也许我们应该回答的第一个问题是,“为什么要使用机器学习?”

为什么 MovieStream 不简单地继续人为决策?使用机器学习有许多原因(当然也有一些原因不使用),但最重要的原因在这里提到:

  • 涉及的数据规模意味着随着 MovieStream 的增长,完全依赖人类参与很快变得不可行。

  • 基于模型驱动的方法,如机器学习和统计学,通常可以从数据集的规模和复杂性导致人类无法发现的模式中受益。

  • 模型驱动的方法可以避免人为和情感偏见(只要正确的流程得到仔细应用)。

然而,并没有理由为什么模型驱动和人为驱动的流程和决策不能共存。例如,许多机器学习系统依赖于接收标记数据来训练模型。通常,标记这样的数据是昂贵的、耗时的,并需要人类的输入。这种情况的一个很好的例子是将文本数据分类到类别中或为文本分配情感指标。许多现实世界的系统使用某种形式的人为驱动系统来为这样的数据生成标签(或至少部分)以为模型提供训练数据。然后这些模型用于在更大规模的实时系统中进行预测。

在 MovieStream 的背景下,我们不必担心我们的机器学习系统会使内容团队变得多余。事实上,我们将看到我们的目标是减轻耗时的任务负担,让机器学习能够更好地执行,同时提供工具让团队更好地了解用户和内容。例如,这可能帮助他们选择要为目录获取的新内容(这涉及相当大的成本,因此是业务的关键方面)。

个性化

在 MovieStream 业务中,机器学习最重要的潜在应用之一是个性化。一般来说,个性化是指根据各种因素调整用户的体验和呈现给他们的内容,这些因素可能包括用户行为数据以及外部因素。

推荐本质上是个性化的一个子集。推荐通常指向用户呈现一系列我们希望用户感兴趣的项目。推荐可以用于网页(例如,相关产品的推荐),通过电子邮件或其他直接营销渠道,通过移动应用程序等等。

个性化与推荐非常相似,但推荐通常专注于向用户明确呈现产品或内容,而个性化更加通用,通常更加隐含。例如,将个性化应用于 MovieStream 网站的搜索可能允许我们根据关于用户的可用数据,调整给定用户的搜索结果。这可能包括基于推荐的数据(在搜索产品或内容的情况下),但也可能包括各种其他因素,如地理位置和过去的搜索历史。用户可能不会意识到搜索结果是针对其特定配置文件进行调整;这就是为什么个性化往往更加隐含。

定向营销和客户分割

与推荐类似,定向营销使用模型来选择针对用户的目标。虽然通常推荐和个性化专注于一对一的情况,分割方法可能会尝试根据特征和可能的行为数据将用户分配到组中。这种方法可能相当简单,也可能涉及尝试根据特征和可能的行为数据将用户分配到组中的机器学习模型,如聚类。无论哪种方式,结果都是一组分段分配,这可能使我们能够了解每个用户组的广泛特征,了解在组内使他们相似的因素,以及了解使他们与其他组中的其他人不同的因素。

这可以帮助 MovieStream 更好地了解用户行为的驱动因素,也可能允许更广泛的定位方法,其中以组为目标,而不是(或更可能是,除了)个性化的直接一对一定位。

这些方法也可以在我们不一定有标记数据可用的情况下(例如某些用户和内容配置文件数据),但我们仍希望执行比完全一刀切方法更加集中的定位时提供帮助。

预测建模和分析

机器学习可以应用的第三个领域是预测分析。这是一个非常广泛的术语,在某种程度上,它也包括推荐、个性化和定位。在这种情况下,由于推荐和分割有些不同,我们使用术语“预测建模”来指代寻求进行预测的其他模型。一个例子是一个模型,可以在任何关于标题可能受欢迎程度的数据可用之前,预测新标题的潜在观看活动和收入。MovieStream 可以利用过去的活动和收入数据,以及内容属性,创建一个回归模型,可以用来预测全新标题的情况。

另一个例子是,我们可以使用分类模型自动为我们只有部分数据的新标题分配标签、关键词或类别。

机器学习模型的类型

虽然我们有一个例子,但还有许多其他例子,其中一些我们将在相关章节中介绍每个机器学习任务时进行介绍。

然而,我们可以广泛地将前述用例和方法分为两类机器学习:

  • 监督学习:这些类型的模型使用标记数据进行学习。推荐引擎、回归和分类是监督学习方法的例子。这些模型中的标签可以是用户-电影评分(用于推荐)、电影标签(在前述分类示例中)、或收入数字(用于回归)。我们将在第四章中介绍监督学习模型,使用 Spark 构建推荐引擎,第六章,使用 Spark 构建分类模型,和第七章,使用 Spark 构建回归模型

  • 无监督学习:当模型不需要标记数据时,我们称之为无监督学习。这些类型的模型试图学习或提取数据中的一些潜在结构,或将数据减少到其最重要的特征。聚类、降维和一些形式的特征提取,如文本处理,都是无监督技术,将在第八章,使用 Spark 构建聚类模型,第九章,使用 Spark 进行降维,和第十章,使用 Spark 进行高级文本处理中进行讨论。

数据驱动机器学习系统的组件

我们机器学习系统的高级组件如下图所示。该图说明了我们获取数据和存储数据的机器学习流程。然后我们将其转换为可用作机器学习模型输入的形式;训练、测试和改进我们的模型;然后将最终模型部署到我们的生产系统。随着新数据的生成,该过程将重复进行。

一个通用的机器学习流程

数据摄入和存储

我们机器学习流程的第一步将是获取我们训练模型所需的数据。与许多其他企业一样,MovieStream 的数据通常由用户活动、其他系统(通常称为机器生成的数据)和外部来源(例如某个用户访问网站时的时间和天气)生成。

这些数据可以通过各种方式进行摄入,例如从浏览器和移动应用事件日志中收集用户活动数据,或访问外部 Web API 来收集地理位置或天气数据。

一旦收集机制就位,通常需要存储数据。这包括原始数据、中间处理产生的数据以及最终模型结果,用于生产环境中。

数据存储可能会很复杂,涉及各种系统,包括 HDFS、Amazon S3 和其他文件系统;诸如 MySQL 或 PostgreSQL 的 SQL 数据库;分布式 NoSQL 数据存储,如 HBase、Cassandra 和 DynamoDB;以及 Solr 或 Elasticsearch 等搜索引擎,用于流数据系统,如 Kafka、Flume 或 Amazon Kinesis。

为了本书的目的,我们将假设相关数据对我们可用,因此我们将专注于以下流程中的处理和建模步骤。

数据清洗和转换

大多数机器学习算法都是基于特征操作的,这些特征通常是输入变量的数值表示,将用于模型。

虽然我们可能希望花费大部分时间探索机器学习模型,但通过前面的摄入步骤从各种系统和来源收集的数据,在大多数情况下都是以原始形式存在的。例如,我们可能记录用户事件,比如用户何时查看电影信息页面、观看电影或提供其他反馈的详细信息。我们还可能收集外部信息,比如用户的位置(例如通过他们的 IP 地址提供)。这些事件日志通常会包含有关事件的文本和数字信息的组合(也可能包括其他形式的数据,如图像或音频)。

为了在我们的模型中使用原始数据,在几乎所有情况下,我们需要进行预处理,这可能包括:

  • 过滤数据:假设我们想要从原始数据的子集创建模型,比如只使用最近几个月的活动数据或只使用符合某些条件的事件。

  • 处理缺失、不完整或损坏的数据:许多真实世界的数据集在某种程度上是不完整的。这可能包括缺失的数据(例如由于缺少用户输入)或不正确或有缺陷的数据(例如由于数据摄入或存储错误、技术问题或错误、软件或硬件故障)。我们可能需要过滤掉不良数据,或者决定一种方法来填补缺失的数据点(例如使用数据集的平均值来填补缺失点)。

  • 处理潜在的异常、错误和离群值:错误或离群值的数据可能会扭曲模型训练的结果,因此我们可能希望过滤这些情况或使用能够处理离群值的技术。

  • 合并不同的数据源:例如,我们可能需要将每个用户的事件数据与不同的内部数据源(如用户资料)以及外部数据(如地理位置、天气和经济数据)进行匹配。

  • 聚合数据:某些模型可能需要以某种方式聚合的输入数据,比如计算每个用户的不同事件类型的总和。

一旦我们对数据进行了初始预处理,通常需要将数据转换为适合机器学习模型的表示形式。对于许多模型类型,这种表示形式将采用包含数值数据的向量或矩阵结构。数据转换和特征提取过程中常见的挑战包括:

  • 将分类数据(如地理位置的国家或电影的类别)编码为数值表示。

  • 从文本数据中提取有用的特征。

  • 处理图像或音频数据。

  • 将数值数据转换为分类数据,以减少变量可以取值的数量。一个例子是将年龄变量转换为区间(比如 25-35,45-55 等)。

  • 转换数值特征;例如,对数值变量应用对数变换可以帮助处理取值范围非常大的变量。

  • 对数值特征进行归一化和标准化,确保模型的所有不同输入变量具有一致的尺度。许多机器学习模型需要标准化的输入才能正常工作。

  • 特征工程,即将现有变量组合或转换为新特征的过程。例如,我们可以创建一个新变量,即某些其他数据的平均值,比如用户观看电影的平均次数。

我们将通过本书中的示例涵盖所有这些技术。

这些数据清洗、探索、聚合和转换步骤可以使用 Spark 的核心 API 函数以及 SparkSQL 引擎来进行,更不用说其他外部的 Scala、Java 或 Python 库。我们可以利用 Spark 的 Hadoop 兼容性从各种存储系统中读取数据并写入数据。

如果涉及流式输入,我们还可以利用 Spark 流处理。

模型训练和测试循环

一旦我们的训练数据适合我们的模型,我们可以进行模型的训练和测试阶段。在这个阶段,我们主要关注模型选择。这可以是选择最适合我们任务的建模方法,或者给定模型的最佳参数设置。事实上,模型选择这个术语通常指的是这两个过程,因为在许多情况下,我们可能希望尝试各种模型,并选择表现最佳的模型(每个模型的最佳参数设置)。在这个阶段,探索不同模型的组合(称为集成方法)也很常见。

这通常是一个相当简单的过程,即在训练数据集上运行我们选择的模型,并在测试数据集上测试其性能(即一组数据,用于评估模型在训练阶段未见过的模型)。这个过程被称为交叉验证。

有时,模型会出现过拟合或者不完全收敛,这取决于数据集的类型和使用的迭代次数。

使用集成方法,如梯度提升树和随机森林,是避免过拟合的机器学习和 Spark 中使用的技术。

然而,由于我们通常处理的数据规模很大,通常有必要在我们完整数据集的一个较小代表样本上进行这个初始的训练-测试循环,或者在可能的情况下使用并行方法进行模型选择。

对于管道的这一部分,Spark 内置的机器学习库 MLlib 非常合适。在本书中,我们将主要关注使用 MLlib 和 Spark 的核心功能,对各种机器学习技术进行模型训练、评估和交叉验证步骤。

模型部署和集成

一旦找到了最佳的训练-测试循环,我们可能仍然面临将模型部署到生产系统的任务,以便用于进行可操作的预测。

通常,这个过程涉及将训练好的模型导出到一个中央数据存储中,生产系统可以从中获取最新版本。因此,实时系统会定期更新模型,以便使用新训练的模型。

模型监控和反馈

在生产中监控机器学习系统的性能非常重要。一旦部署了最佳训练的模型,我们希望了解它在“野外”的表现。它在新的、未见过的数据上表现如我们所期望的吗?它的准确性是否足够?事实上,无论我们在早期阶段进行了多少模型选择和调整,衡量真正性能的唯一方法是观察在生产系统中发生的情况。

除了批处理模式的模型创建外,还有使用 Spark 流处理构建的实时模型。

另外,请记住,模型准确度和预测性能只是现实世界系统的一个方面。通常,我们关注与业务绩效相关的其他指标(例如收入和盈利能力)或用户体验(例如在我们网站上花费的时间以及我们的用户总体活跃度)。在大多数情况下,我们无法轻易将模型预测性能与这些业务指标相匹配。推荐或定位系统的准确性可能很重要,但它只间接与我们关心的真正指标相关,即我们是否正在改善用户体验、活动性和最终收入。

因此,在现实世界的系统中,我们应该监控模型准确度指标以及业务指标。如果可能的话,我们应该能够在生产中尝试不同的模型,以便通过对模型进行更改来优化这些业务指标。这通常是通过实时分割测试来完成的。然而,正确地进行这项工作并不容易,实时测试和实验是昂贵的,因为错误、性能不佳以及使用基准模型(它们提供了我们测试生产模型的对照)可能会对用户体验和收入产生负面影响。

这一阶段的另一个重要方面是模型反馈。这是我们的模型预测通过用户行为反馈到模型的过程。在现实世界的系统中,我们的模型实质上通过影响决策和潜在用户行为来影响自己未来的训练数据。

例如,如果我们部署了一个推荐系统,那么通过推荐,我们可能会影响用户行为,因为我们只允许用户有限的选择。我们希望这个选择对我们的模型是相关的;然而,这种反馈循环反过来又会影响我们模型的训练数据。这又反过来影响现实世界的性能。可能会陷入一个不断变窄的反馈循环;最终,这可能会对模型准确度和我们重要的业务指标产生负面影响。

幸运的是,我们有一些机制可以尝试限制这种反馈循环的潜在负面影响。这些机制包括通过让一小部分来自未接触我们模型的用户的数据提供一些无偏的训练数据,或者在探索和开发的平衡方式上保持原则,以了解更多关于我们的数据,以及利用我们所学到的知识来改善系统的性能。

我们将在第十一章中简要介绍使用 Spark Streaming 进行实时机器学习

批处理与实时

在前面的章节中,我们概述了常见的批处理方法,即使用所有数据或所有数据的子集定期重新训练模型。由于前面的管道需要一些时间才能完成,因此可能无法使用这种方法立即更新模型以适应新数据的到来。

虽然在本书中我们将主要介绍批处理机器学习方法,但有一类被称为在线学习的机器学习算法;它们在新数据被馈送到模型时立即更新,从而实现实时系统。一个常见的例子是线性模型的在线优化算法,比如随机梯度下降。我们可以通过示例学习这个算法。这些方法的优势在于系统可以非常快速地对新信息做出反应,同时系统可以适应底层行为的变化(即,如果输入数据的特征和分布随时间变化,这在现实世界的情况下几乎总是发生的)。

然而,在生产环境中,在线学习模型也面临着自己独特的挑战。例如,实时摄取和转换数据可能很困难。在纯在线设置中进行适当的模型选择也可能很复杂。在线培训和模型选择和部署阶段的延迟可能对真实实时需求来说太高(例如,在在线广告中,延迟要求以两位数毫秒为单位)。最后,面向批处理的框架可能使处理流式处理的实时过程变得尴尬。

幸运的是,Spark 的实时流处理非常适合实时机器学习工作流。我们将在第十一章中探讨 Spark Streaming 和在线学习,使用 Spark Streaming 进行实时机器学习

由于真实实时机器学习系统固有的复杂性,在实践中,许多系统针对近实时操作。这本质上是一种混合方法,其中模型不一定在新数据到达时立即更新;相反,新数据被收集到一小组训练数据的小批次中。这些小批次可以被馈送到在线学习算法中。在许多情况下,这种方法与定期批处理过程相结合,该过程可能在整个数据集上重新计算模型并执行更复杂的处理和模型选择。这可以确保实时模型不会随着时间的推移而退化。

另一种类似的方法涉及对更复杂的模型进行近似更新,以便在新数据到达时,定期以批处理过程重新计算整个模型。通过这种方式,模型可以从新数据中学习,但由于应用了近似值,随着时间的推移,模型会变得越来越不准确。定期重新计算通过在所有可用数据上重新训练模型来解决这个问题。

Apache Spark 中的数据管道

正如我们所看到的电影镜头用例,运行一系列机器学习算法来处理和学习数据是非常常见的。另一个例子是简单的文本文档处理工作流,其中可以包括几个阶段:

  • 将文档的文本拆分成单词

  • 将文档的单词转换为数字特征向量

  • 从特征向量和标签中学习预测模型

Spark MLlib 将这样的工作流表示为管道;它由顺序的管道阶段(转换器和估计器)组成,这些阶段按特定顺序运行。

管道被指定为一系列阶段。每个阶段都是一个转换器或一个估计器。转换器将一个数据框转换为另一个数据框。另一方面,估计器是一个学习算法。管道阶段按顺序运行,并且输入数据框在通过每个阶段时进行转换。

在转换器阶段,对数据框调用transform()方法。对于估计器阶段,调用fit()方法以生成一个转换器(它成为 PipelineModel 或拟合管道的一部分)。转换器的transform()方法在数据框上执行。

机器学习系统的架构

现在我们已经探讨了我们的机器学习系统在 MovieStream 环境中可能的工作方式,我们可以为我们的系统概述一个可能的架构:

MovieStream 的未来架构

正如我们所看到的,我们的系统包含了前面图表中概述的机器学习管道;该系统还包括:

  • 收集关于用户、他们的行为和我们的内容标题的数据

  • 将这些数据转换为特征

  • 训练我们的模型,包括我们的训练测试和模型选择阶段

  • 将训练好的模型部署到我们的实时模型服务系统以及将这些模型用于离线流程

  • 通过推荐和定位页面将模型结果反馈到 MovieStream 网站

  • 将模型结果反馈到 MovieStream 的个性化营销渠道

  • 使用离线模型为 MovieStream 的各个团队提供工具,以更好地了解用户行为、内容目录的特征和业务收入的驱动因素

在下一节中,我们稍微偏离了 Movie Stream,概述了 MLlib-Spark 的机器学习模块。

Spark MLlib

Apache Spark 是一个用于大型数据集处理的开源平台。它非常适合迭代的机器学习任务,因为它利用了 RDD 等内存数据结构。MLlib 是 Spark 的机器学习库。MLlib 提供了各种学习算法的功能-监督和无监督。它包括各种统计和线性代数优化。它与 Apache Spark 一起发布,因此可以避免像其他库那样的安装问题。MLlib 支持 Scala、Java、Python 和 R 等多种高级语言。它还提供了一个高级 API 来构建机器学习管道。

MLlib 与 Spark 的集成有很多好处。Spark 设计用于迭代计算周期;它为大型机器学习算法提供了高效的实现平台,因为这些算法本身就是迭代的。

Spark 数据结构的任何改进都会直接为 MLlib 带来收益。Spark 庞大的社区贡献帮助加快了新算法对 MLlib 的引入。

Spark 还有其他 API,如 Pipeline API GraphX,可以与 MLlib 一起使用;它使得在 MLlib 之上构建有趣的用例更容易。

Spark ML 在 Spark MLlib 上的性能改进

Spark 2.0 使用了 Tungsten 引擎,该引擎利用了现代编译器和 MPP 数据库的思想。它在运行时发出优化的字节码,将查询折叠成一个单一函数。因此,不需要虚拟函数调用。它还使用 CPU 寄存器来存储中间数据。这种技术被称为整体阶段代码生成。

参考:https://databricks.com/blog/2016/05/11/apache-spark-2-0-technical-preview-easier-faster-and-smarter.html 来源:https://databricks.com/blog/2016/05/11/apache-spark-2-0-technical-preview-easier-faster-and-smarter.html

即将出现的表格和图表显示了 Spark 1.6 和 Spark 2.0 之间单函数改进的情况:

比较 Spark 1.6 和 Spark 2.0 之间单行函数性能改进的图表

比较 Spark 1.6 和 Spark 2.0 之间单行函数性能改进的表格。

比较 MLlib 支持的算法

在本节中,我们将看一下 MLlib 版本支持的各种算法。

分类

在 1.6 版本中,支持超过 10 种分类算法,而当 Spark ML 版本 1.0 发布时,只支持 3 种算法。

聚类

在聚类算法方面进行了相当大的投资,从 1.0.0 的 1 种算法支持到 1.6.0 的 6 种实现支持。

回归

传统上,回归并不是主要关注的领域,但最近已经成为焦点,从 1.2.0 版本到 1.3.0 版本新增了 3-4 个新算法。

MLlib 支持的方法和开发者 API

MLlib 提供了学习算法的快速和分布式实现,包括各种线性模型、朴素贝叶斯、支持向量机和决策树集成(也称为随机森林)用于分类和回归问题,交替进行。

最小二乘法(显式和隐式反馈)用于协同过滤。它还支持 k 均值聚类和主成分分析PCA)用于聚类和降维。

该库提供了一些低级原语和基本实用程序,用于凸优化(spark.apache.org/docs/latest/mllib-optimization.html)、分布式线性代数(支持向量和矩阵)、统计分析(使用 Breeze 和本地函数)、特征提取,并支持各种 I/O 格式,包括对 LIBSVM 格式的本机支持。

它还支持通过 Spark SQL 和 PMML(en.wikipedia.org/wiki/Predictive_Model_Markup_Language)(Guazzelli 等人,2009)进行数据集成。您可以在此链接找到有关 PMML 支持的更多信息:spark.apache.org/docs/1.6.0/mllib-pmml-model-export.html

算法优化涉及 MLlib 包括许多优化,以支持高效的分布式学习和预测。

用于推荐的 ALS 算法利用了阻塞来减少 JVM 垃圾收集开销,并利用更高级别的线性代数操作。决策树使用了来自 PLANET 项目的想法(参考:dl.acm.org/citation.cfm?id=1687569),例如数据相关的特征离散化以减少通信成本,以及树集成在树内和树间并行学习。

广义线性模型是使用优化算法学习的,这些算法并行计算梯度,使用快速的基于 C++的线性代数库进行工作。

计算。算法受益于高效的通信原语。特别是,树形聚合可以防止驱动程序成为瓶颈。

模型更新部分地组合在一小组执行器上。然后将它们发送到驱动程序。这种实现减少了驱动程序需要处理的负载。测试表明,这些功能将聚合时间缩短了一个数量级,特别是在具有大量分区的数据集上。

(参考:databricks.com/blog/2014/09/22/spark-1-1-mllib-performance-improvements.html

Pipeline API包括实用的机器学习管道,通常涉及一系列数据预处理、特征提取、模型拟合和验证阶段。

大多数机器学习库不提供对管道构建的各种功能的本机支持。在处理大规模数据集时,将端到端管道连接在一起的过程在网络开销的角度来看既费力又昂贵。

利用 Spark 的生态系统:MLlib 包括一个旨在解决这些问题的包。

spark.ml包通过提供一组统一的高级 API(arxiv.org/pdf/1505.06807.pdf)来简化多阶段学习管道的开发和调优。它包括使用户能够在其专门的算法中替换标准学习方法的 API。

Spark 集成

MLlib 受益于 Spark 生态系统中的组件。Spark 核心提供了一个执行引擎,其中包含超过 80 个用于转换数据(数据清洗和特征化)的操作符。

MLlib 使用了与 Spark 打包在一起的其他高级库,如 Spark SQL。它提供了集成数据功能、SQL 和结构化数据处理,简化了数据清洗和预处理。它支持 DataFrame 抽象,这对于spark.ml包是基本的。

GraphXwww.usenix.org/system/files/conference/osdi14/osdi14-paper-gonzalez.pdf)支持大规模图处理,并具有强大的 API,用于实现可以视为大型稀疏图问题的学习算法,例如 LDA。

Spark Streamingwww.cs.berkeley.edu/~matei/papers/2013/sosp_spark_streaming.pdf)允许处理实时数据流,并支持在线学习算法的开发,就像 Freeman(2015)中所述。我们将在本书的一些后续章节中涵盖流处理。

MLlib 愿景

MLlib 的愿景是提供一个可扩展的机器学习平台,可以处理大规模数据集,并且相对于现有系统(如 Hadoop)具有更快的处理时间。

它还努力为监督和无监督学习分类、回归和聚类等领域尽可能多的算法提供支持。

MLlib 版本比较

在本节中,我们将比较各个版本的 MLlib 和新增的功能。

Spark 1.6 到 2.0

基于 DataFrame 的 API 将成为主要 API。

基于 RDD 的 API 正在进入维护模式。MLlib 指南(spark.apache.org/docs/2.0.0/ml-guide.html)提供了更多细节。

以下是 Spark 2.0 中引入的新功能:

  • ML 持久性:基于 DataFrames 的 API 支持在 Scala、Java、Python 和 R 中保存和加载 ML 模型和管道

  • R 中的 MLlib:SparkR 在此版本中提供了 MLlib 的 API,用于广义线性模型、朴素贝叶斯、k 均值聚类和生存回归

  • Python:2.0 中的 PySpark 支持新的 MLlib 算法,如 LDA、广义线性回归、高斯混合模型等

基于 DataFrames 的 API 新增了 GMM、二分 K 均值聚类、MaxAbsScaler 特征转换器。

总结

在本章中,我们了解了数据驱动的自动化机器学习系统中固有的组件。我们还概述了这样一个系统在现实世界中可能的高层架构。我们还从性能的角度对 MLlib-Spark 的机器学习库与其他机器学习实现进行了概述。最后,我们看了一下从 Spark 1.6 到 Spark 2.0 各个版本的新功能。

在下一章中,我们将讨论如何获取常见机器学习任务的公开可用数据集。我们还将探讨处理、清洗和转换数据的一般概念,以便用于训练机器学习模型。

第四章:使用 Spark 获取、处理和准备数据

机器学习是一个非常广泛的领域,如今,应用可以在包括网络和移动应用、物联网和传感器网络、金融服务、医疗保健以及各种科学领域等领域找到。

因此,机器学习可用的数据范围是巨大的。在本书中,我们将主要关注业务应用。在这种情况下,可用的数据通常包括组织内部的数据(例如金融服务公司的交易数据)以及外部数据源(例如同一金融服务公司的金融资产价格数据)。

例如,您会从第三章中回忆起,设计一个机器学习系统,我们假想的互联网业务 Movie Stream 的主要内部数据来源包括网站上可用电影的数据,服务的用户以及他们的行为。这包括有关电影和其他内容的数据(例如标题,类别,描述,图片,演员和导演),用户信息(例如人口统计学,位置等),以及用户活动数据(例如网页浏览,标题预览和浏览,评分,评论,以及喜欢分享等社交数据,包括 Facebook 和 Twitter 等社交网络资料)。

在这个例子中,外部数据源可能包括天气和地理位置服务,第三方电影评分和评论网站,比如IMDBRotten Tomatoes等。

一般来说,要获取真实世界服务和企业的内部数据是非常困难的,因为这些数据具有商业敏感性(特别是购买活动数据,用户或客户行为以及收入数据),对相关组织具有巨大的潜在价值。这也是为什么这些数据通常是应用机器学习的最有用和有趣的数据--一个能够做出准确预测的好的机器学习模型可能具有很高的价值(比如机器学习竞赛的成功,比如Netflix PrizeKaggle)。

在本书中,我们将利用公开可用的数据集来说明数据处理和机器学习模型训练的概念。

在本章中,我们将:

  • 简要介绍机器学习中通常使用的数据类型。

  • 提供获取有趣数据集的例子,这些数据集通常可以在互联网上公开获取。我们将在整本书中使用其中一些数据集来说明我们介绍的模型的使用。

  • 了解如何处理、清理、探索和可视化我们的数据。

  • 介绍各种技术,将我们的原始数据转换为可以用作机器学习算法输入的特征。

  • 学习如何使用外部库以及 Spark 内置功能来规范输入特征。

访问公开可用的数据集

幸运的是,虽然商业敏感数据可能很难获得,但仍然有许多有用的公开数据集可用。其中许多经常被用作特定类型的机器学习问题的基准数据集。常见数据来源的例子包括:

  • UCI 机器学习库:这是一个包含近 300 个各种类型和大小的数据集的集合,用于分类、回归、聚类和推荐系统等任务。列表可在archive.ics.uci.edu/ml/找到。

  • Amazon AWS 公共数据集:这是一组通常非常庞大的数据集,可以通过 Amazon S3 访问。这些数据集包括人类基因组计划,Common Crawl 网络语料库,维基百科数据和 Google 图书 Ngrams。这些数据集的信息可以在aws.amazon.com/publicdatasets/找到。

  • Kaggle:这是 Kaggle 举办的机器学习竞赛中使用的数据集的集合。领域包括分类、回归、排名、推荐系统和图像分析。这些数据集可以在www.kaggle.com/competitions的竞赛部分找到。

  • KDnuggets:这里有一个详细的公共数据集列表,包括之前提到的一些。列表可在www.kdnuggets.com/datasets/index.html找到。

根据具体领域和机器学习任务的不同,还有许多其他资源可以找到公共数据集。希望你也可能接触到一些有趣的学术或商业数据!

为了说明 Spark 中与数据处理、转换和特征提取相关的一些关键概念,我们将下载一个常用的用于电影推荐的数据集;这个数据集被称为MovieLens数据集。由于它适用于推荐系统以及潜在的其他机器学习任务,它作为一个有用的示例数据集。

MovieLens 100k 数据集

MovieLens 100k 数据集是与一组用户对一组电影的评分相关的 10 万个数据点。它还包含电影元数据和用户配置文件。虽然它是一个小数据集,但你可以快速下载并在其上运行 Spark 代码。这使得它非常适合作为示例。

你可以从files.grouplens.org/datasets/movielens/ml-100k.zip下载数据集。

下载数据后,使用终端解压缩它:

>unzip ml-100k.zip
inflating: ml-100k/allbut.pl 
inflating: ml-100k/mku.sh 
inflating: ml-100k/README
 ...
inflating: ml-100k/ub.base 
inflating: ml-100k/ub.test

这将创建一个名为ml-100k的目录。进入此目录并检查内容。重要的文件是u.user(用户配置文件)、u.item(电影元数据)和u.data(用户对电影的评分):

 >cd ml-100k

README文件包含有关数据集的更多信息,包括每个数据文件中存在的变量。我们可以使用 head 命令来检查各个文件的内容。

例如,我们可以看到u.user文件包含用户 ID、年龄、性别、职业和邮政编码字段,用管道(|)字符分隔:

$ head -5 u.user
 1|24|M|technician|85711
 2|53|F|other|94043
 3|23|M|writer|32067
 4|24|M|technician|43537
 5|33|F|other|15213

u.item文件包含电影 ID、标题、发布日期和 IMDB 链接字段以及一组与电影类别数据相关的字段。它也是用|字符分隔的:

$head -5 u.item
 1|Toy Story (1995)|01-Jan-1995||http://us.imdb.com/M/title-
 exact?Toy%20Story%20(1995)|0|0|0|1|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0
 2|GoldenEye (1995)|01-Jan-1995||http://us.imdb.com/M/title-
 exact?GoldenEye%20(1995)|0|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0|1|0|0
 3|Four Rooms (1995)|01-Jan-1995||http://us.imdb.com/M/title-
 exact?Four%20Rooms%20(1995)|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|1|0|0
 4|Get Shorty (1995)|01-Jan-1995||http://us.imdb.com/M/title-
 exact?Get%20Shorty%20(1995)|0|1|0|0|0|1|0|0|1|0|0|0|0|0|0|0|0|0|0
 5|Copycat (1995)|01-Jan-1995||http://us.imdb.com/M/title-
 exact?Copycat%20(1995)|0|0|0|0|0|0|1|0|1|0|0|0|0|0|0|0|1|0|0

前面列出的数据格式如下:

movie id | movie title | release date | video release date | IMDb 
 URL | unknown | Action | Adventure | Animation | Children's | 
 Comedy | Crime | Documentary | Drama | Fantasy | Film-Noir | 
 Horror | Musical | Mystery | Romance | Sci-Fi | Thriller | War | 
 Western |

最后 19 个字段是电影的流派,1 表示电影属于该流派,0 表示不属于;电影可以同时属于几种流派。

电影 ID 是u.data数据集中使用的 ID。它包含 943 个用户对 1682 个项目的 100000 个评分。每个用户至少对 20 部电影进行了评分。用户和项目从 1 开始编号。数据是随机排序的。这是一个以制表符分隔的字段列表:

user id | item id | rating | timestamp

时间戳是自 1970 年 1 月 1 日 UTC 以来的 Unix 秒。

让我们看一下u.data文件中的一些数据:

>head -5 u.data
1962423881250949
1863023891717742
223771878887116
244512880606923
1663461886397596

探索和可视化你的数据

本章的源代码可以在PATH/spark-ml/Chapter04找到:

  • Python 代码位于/MYPATH/spark-ml/Chapter_04/python

  • Scala 代码位于/MYPATH/spark-ml/Chapter_04/scala

Python 示例可用于 1.6.2 和 2.0.0 版本;我们将在本书中专注于 2.0.0 版本:

├── 1.6.2
│   ├── com
│   │   ├── __init__.py
│   │   └── sparksamples
│   │       ├── __init__.py
│   │       ├── movie_data.py
│   │       ├── plot_user_ages.py
│   │       ├── plot_user_occupations.py
│   │       ├── rating_data.py
│   │       ├── user_data.py
│   │       ├── util.py
│   │       
│   └── __init__.py
├── 2.0.0
│   └── com
│       ├── __init__.py
│       └── sparksamples
│           ├── __init__.py
│           ├── movie_data.py
│           ├── plot_user_ages.py
│           ├── plot_user_occupations.py
│           ├── rating_data.py
│           ├── spark-warehouse
│           ├── user_data.py
│           ├── util.py
│           

Scala 示例的结构如下所示:

├── 1.6.2
│   ├── build.sbt
│   ├── spark-warehouse
│   ├── src
│   │   └── main
│   │       └── scala
│   │           └── org
│   │               └── sparksamples
│   │                   ├── CountByRatingChart.scala
│   │                   ├── exploredataset
│   │                   │   ├── explore_movies.scala
│   │                   │   ├── explore_ratings.scala
│   │                   │   └── explore_users.scala
│   │                   ├── featureext
│   │                   │   ├── ConvertWordsToVectors.scala
│   │                   │   ├── StandardScalarSample.scala
│   │                   │   └── TfIdfSample.scala
│   │                   ├── MovieAgesChart.scala
│   │                   ├── MovieDataFillingBadValues.scala
│   │                   ├── MovieData.scala
│   │                   ├── RatingData.scala
│   │                   ├── UserAgesChart.scala
│   │                   ├── UserData.scala
│   │                   ├── UserOccupationChart.scala
│   │                   ├── UserRatingsChart.scala
│   │                   └── Util.scala

Scala 2.0.0 示例:

├── 2.0.0
│   ├── build.sbt
│   ├── src
│   │   └── main
│   │       └── scala
│   │           └── org
│   │               └── sparksamples
│   │                   ├── CountByRatingChart.scala
│   │                   ├── df
│   │                   ├── exploredataset
│   │                   │   ├── explore_movies.scala
│   │                   │   ├── explore_ratings.scala
│   │                   │   └── explore_users.scala
│   │                   ├── featureext
│   │                   │   ├── ConvertWordsToVectors.scala
│   │                   │   ├── StandardScalarSample.scala
│   │                   │   └── TfIdfSample.scala
│   │                   ├── MovieAgesChart.scala
│   │                   ├── MovieDataFillingBadValues.scala
│   │                   ├── MovieData.scala
│   │                   ├── RatingData.scala
│   │                   ├── UserAgesChart.scala
│   │                   ├── UserData.scala
│   │                   ├── UserOccupationChart.scala
│   │                   ├── UserRatingsChart.scala
│   │                   └── Util.scala

转到以下目录并运行以下命令来运行示例:

 $ cd /MYPATH/spark-ml/Chapter_04/scala/2.0.0
 $ sbt compile
 $ sbt run

探索用户数据集

首先,我们将分析 MovieLens 用户的特征。

我们使用custom_schema|分隔的数据加载到 DataFrame 中。这个 Python 代码在com/sparksamples/Util.py中:

def get_user_data(): 
  custom_schema = StructType([ 
  StructField("no", StringType(), True), 
  StructField("age", IntegerType(), True), 
  StructField("gender", StringType(), True), 
  StructField("occupation", StringType(), True), 
  StructField("zipCode", StringType(), True) 
]) 
frompyspark.sql import SQLContext 
frompyspark.sql.types import * 

sql_context = SQLContext(sc) 

user_df = sql_context.read  
  .format('com.databricks.spark.csv')  
  .options(header='false', delimiter='|')  
  .load("%s/ml-100k/u.user"% PATH, schema =  
custom_schema) 
returnuser_df

这个函数是从user_data.py中调用的,如下所示:

user_data = get_user_data() 
print(user_data.first)

你应该看到类似于这样的输出:

u'1|24|M|technician|85711'

代码清单:

将数据加载到 DataFrame 中的 Scala 中的类似代码如下。此代码在Util.scala中:


val customSchema = StructType(Array( 
StructField("no", IntegerType, true), 
StructField("age", StringType, true), 
StructField("gender", StringType, true), 
StructField("occupation", StringType, true), 
StructField("zipCode", StringType, true))); 
val spConfig = (new 
 SparkConf).setMaster("local").setAppName("SparkApp") 
val spark = SparkSession 
  .builder() 
  .appName("SparkUserData").config(spConfig) 
  .getOrCreate() 

val user_df = spark.read.format("com.databricks.spark.csv") 
  .option("delimiter", "|").schema(customSchema) 
  .load("/home/ubuntu/work/ml-resources/spark-ml/data/ml-
 100k/u.user") 
val first = user_df.first() 
println("First Record : " + first)

你应该看到类似于这样的输出:

u'1|24|M|technician|85711'

代码清单在:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/UserData.scala

正如我们所看到的,这是我们用户数据文件的第一行,由"|"字符分隔。

first函数类似于collect,但它只将 RDD 的第一个元素返回给驱动程序。我们还可以使用take(k)来仅将 RDD 的前k个元素收集到驱动程序。

我们将使用之前创建的 DataFrame,并使用groupBy函数,然后是count()collect()来计算用户数、性别、邮政编码和职业。然后计算用户数、性别、职业和邮政编码的数量。我们可以通过运行以下代码来实现这一点。请注意,我们不需要对数据进行缓存,因为这个大小是不必要的:

num_users = user_data.count() 
num_genders = 
 len(user_data.groupBy("gender").count().collect()) 
num_occupation = 
 len(user_data.groupBy("occupation").count().collect()) 
num_zipcodes = 
 len(user_data.groupby("zipCode").count().collect()) 
print("Users: "+ str(num_users)) 
print("Genders: "+ str(num_genders)) 
print("Occupation: "+ str(num_occupation)) 
print("ZipCodes: "+ str(num_zipcodes))

你将看到以下输出:

Users: 943
Genders: 2
Occupations: 21
ZIPCodes: 795

同样,我们可以使用 Scala 实现获取用户数、性别、职业和邮政编码的逻辑。

val num_genders = user_df.groupBy("gender").count().count() 
val num_occupations = 
 user_df.groupBy("occupation").count().count() 
val num_zipcodes = user_df.groupBy("zipCode").count().count() 

println("num_users : "+ user_df.count()) 
println("num_genders : "+ num_genders) 
println("num_occupations : "+ num_occupations) 
println("num_zipcodes: "+ num_zipcodes) 
println("Distribution by Occupation") 
println(user_df.groupBy("occupation").count().show())

你将看到以下输出:

num_users: 943
num_genders: 2
num_occupations: 21
num_zipcodes: 795

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/UserData.scala

接下来,我们将创建一个直方图来分析用户年龄的分布。

在 Python 中,首先我们将DataFrame获取到变量user_data中。接下来,我们将调用select('age')并将结果收集到 Row 对象的列表中。然后,我们迭代并提取年龄参数并填充user_ages_list

我们将使用 Python matplotlib 库的hist函数。

user_data = get_user_data() 
user_ages = user_data.select('age').collect() 
user_ages_list = [] 
user_ages_len = len(user_ages) 
for i in range(0, (user_ages_len - 1)): 
    user_ages_list.append(user_ages[i].age) 
plt.hist(user_ages_list, bins=20, color='lightblue', normed=True) 
fig = matplotlib.pyplot.gcf() 
fig.set_size_inches(16, 10) 
plt.show()

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/python/2.0.0/com/sparksamples/plot_user_ages.py

我们将user_ages_list和我们直方图的箱数(在这种情况下为 20)一起传递给hist函数。使用normed=True参数,我们还指定希望直方图被归一化,以便每个桶代表落入该桶的整体数据的百分比。

你将看到包含直方图图表的图像,看起来类似于这里显示的图像。正如我们所看到的,MovieLens 用户的年龄有些偏向年轻的观众。大量用户的年龄在 15 到 35 岁左右。

用户年龄的分布

对于 Scala 直方图图表,我们使用基于 JFreeChart 的库。我们将数据分成 16 个箱子来显示分布。

我们使用github.com/wookietreiber/scala-chart库从 Scala 映射m_sorted创建条形图。

首先,我们使用select("age")函数从userDataFrame中提取ages_array

然后,我们填充mx Map,这是用于显示的箱子。我们对 mx Map 进行排序以创建ListMap,然后用它来填充DefaultCategorySet ds

val userDataFrame = Util.getUserFieldDataFrame() 
val ages_array = userDataFrame.select("age").collect() 

val min = 0 
val max = 80 
val bins = 16 
val step = (80/bins).toInt 
var mx = Map(0 ->0) 
for (i <- step until (max + step) by step) { 
  mx += (i -> 0) 
} 
for( x <- 0 until ages_array.length) { 
  val age = Integer.parseInt( 
    ages_array(x)(0).toString) 
  for(j <- 0 until (max + step) by step) { 
    if(age >= j && age < (j + step)){ 
      mx = mx + (j -> (mx(j) + 1)) 
    } 
  } 
} 

val mx_sorted =  ListMap(mx.toSeq.sortBy(_._1):_*) 
val ds = new org.jfree.data.category.DefaultCategoryDataset 
mx_sorted.foreach{ case (k,v) => ds.addValue(v,"UserAges", k)} 
val chart = ChartFactories.BarChart(ds) 
chart.show() 
Util.sc.stop()

完整的代码可以在UserAgesChart.scala文件中找到,并在此处列出:

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/UserAgesChart.scala

按职业计数

我们统计用户各种职业的数量。

实施以下步骤以获取职业 DataFrame 并填充列表,然后使用 Matplotlib 显示。

  1. 获取user_data

  2. 使用groupby("occupation")提取职业计数并对其调用count()

  3. 从行列表中提取tuple("occupation","count")的列表。

  4. 创建x_axisy_axis中的值的numpy数组。

  5. 创建类型为 bar 的图表。

  6. 显示图表。

完整的代码清单如下:

user_data = get_user_data() 
user_occ = user_data.groupby("occupation").count().collect() 

user_occ_len = len(user_occ) 
user_occ_list = [] 
for i in range(0, (user_occ_len - 1)): 
element = user_occ[i] 
count = element. __getattr__('count') 
tup = (element.occupation, count) 
    user_occ_list.append(tup) 

x_axis1 = np.array([c[0] for c in user_occ_list]) 
y_axis1 = np.array([c[1] for c in user_occ_list]) 
x_axis = x_axis1[np.argsort(y_axis1)] 
y_axis = y_axis1[np.argsort(y_axis1)] 

pos = np.arange(len(x_axis)) 
width = 1.0 

ax = plt.axes() 
ax.set_xticks(pos + (width / 2)) 
ax.set_xticklabels(x_axis) 

plt.bar(pos, y_axis, width, color='lightblue') 
plt.xticks(rotation=30) 
fig = matplotlib.pyplot.gcf() 
fig.set_size_inches(20, 10) 
plt.show()

您生成的图像应该看起来像这里的图像。看起来最普遍的职业是学生其他教育工作者管理员工程师程序员

用户职业分布

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/python/2.0.0/com/sparksamples/plot_user_occupations.py

在 Scala 中,我们按以下步骤进行操作:

  1. 首先获取userDataFrame

  2. 我们提取职业列:

        userDataFrame.select("occupation")

  1. 按职业对行进行分组:
        val occupation_groups =
          userDataFrame.groupBy("occupation").count()

  1. 按计数对行进行排序:
        val occupation_groups_sorted = 
          occupation_groups.sort("count")

  1. occupation_groups_collection中填充默认类别集 ds

  2. 显示 Jfree Bar Chart

完整的代码清单如下:

        val userDataFrame = Util.getUserFieldDataFrame() 
        val occupation = userDataFrame.select("occupation") 
        val occupation_groups = 
         userDataFrame.groupBy("occupation").count() 
        val occupation_groups_sorted = occupation_groups.sort("count") 
        occupation_groups_sorted.show() 
        val occupation_groups_collection = 
         occupation_groups_sorted.collect() 

        val ds = new org.jfree.data.category.DefaultCategoryDataset 
        val mx = scala.collection.immutable.ListMap() 

        for( x <- 0 until occupation_groups_collection.length) { 
          val occ = occupation_groups_collection(x)(0) 
          val count = Integer.parseInt(
            occupation_groups_collection(x)(1).toString) 
          ds.addValue(count,"UserAges", occ.toString) 
        } 

        val chart = ChartFactories.BarChart(ds) 
        val font = new Font("Dialog", Font.PLAIN,5); 

        chart.peer.getCategoryPlot.getDomainAxis(). 
        setCategoryLabelPositions(CategoryLabelPositions.UP_90); 
        chart.peer.getCategoryPlot.getDomainAxis.setLabelFont(font) 
        chart.show() 
        Util.sc.stop()

此代码的输出如下所示:

以下图显示了从先前源代码生成的 JFreeChart:

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branched2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/UserOccupationChart.scala

电影数据集

接下来,我们将调查电影目录的一些属性。我们可以检查电影数据文件的一行,就像我们之前对用户数据所做的那样,然后计算电影的数量:

我们将通过使用格式com.databrick.spark.csv进行解析并给出|分隔符来创建电影数据的 DataFrame。然后,我们使用CustomSchema来填充 DataFrame 并返回它:

def getMovieDataDF() : DataFrame = { 
  val customSchema = StructType(Array( 
  StructField("id", StringType, true), 
  StructField("name", StringType, true), 
  StructField("date", StringType, true), 
  StructField("url", StringType, true))); 
  val movieDf = spark.read.format(
    "com.databricks.spark.csv") 
     .option("delimiter", "|").schema(customSchema) 
     .load(PATH_MOVIES) 
  return movieDf 
}

然后从MovieData Scala 对象调用此方法。

实施以下步骤以过滤日期并将其格式化为Year

  1. 创建一个临时视图。

  2. 使用SparkSession.Util.spark将函数Util.convertYear注册为 UDF(这是我们的自定义类)。

  3. 在此SparkSession上执行 SQL,如下所示。

  4. 将生成的 DataFrame 按Year分组并调用count()函数。

逻辑的完整代码清单如下:

def getMovieYearsCountSorted(): scala.Array[(Int,String)] = { 
  val movie_data_df = Util.getMovieDataDF() 
  movie_data_df.createOrReplaceTempView("movie_data") 
  movie_data_df.printSchema() 

  Util.spark.udf.register("convertYear", Util.convertYear _) 
  movie_data_df.show(false) 

  val movie_years = Util.spark.sql(
    "select convertYear(date) as year from movie_data") 
  val movie_years_count = movie_years.groupBy("year").count() 
  movie_years_count.show(false) 
  val movie_years_count_rdd = movie_years_count.rdd.map(
   row => (Integer.parseInt(row(0).toString), row(1).toString)) 
  val movie_years_count_collect = movie_years_count_rdd.collect() 
  val movie_years_count_collect_sort = 
  movie_years_count_collect.sortBy(_._1) 
} 

def main(args: Array[String]) { 
  val movie_years = MovieData.getMovieYearsCountSorted() 
  for( a <- 0 to (movie_years.length -1)){ 
    print(movie_years(a)) 
  } 
}

输出将与此处显示的类似:

(1900,1)
(1922,1)
(1926,1)
(1930,1)
(1931,1)
(1932,1)
(1933,2)
(1934,4)
(1935,4)
(1936,2)
(1937,4)
(1938,3)
(1939,7)
(1940,8)
(1941,5)
(1942,2)
(1943,4)
(1944,5)
(1945,4)
(1946,5)
(1947,5)
(1948,3)
(1949,4)
(1950,7)
(1951,5)
(1952,3)
(1953,2)
(1954,7)
(1955,5)
(1956,4)
(1957,8)
(1958,9)
(1959,4)
(1960,5)
(1961,3)
(1962,5)
(1963,6)
(1964,2)
(1965,5)
(1966,2)
(1967,5)
(1968,6)
(1969,4)
(1970,3)
(1971,7)
(1972,3)
(1973,4)
(1974,8)
(1975,6)
(1976,5)
(1977,4)
(1978,4)
(1979,9)
(1980,8)
(1981,12)
(1982,13)
(1983,5)
(1984,8)
(1985,7)
(1986,15)
(1987,13)
(1988,11)
(1989,15)
(1990,24)
(1991,22)
(1992,37)
(1993,126)
(1994,214)
(1995,219)
(1996,355)
(1997,286)
(1998,65)

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/MovieData.scala

接下来,我们绘制先前创建的电影收藏年龄的图表。我们在 Scala 中使用 JFreeChart,并从MovieData.getMovieYearsCountSorted()创建的收藏中填充org.jfree.data.category.DefaultCategoryDataset

object MovieAgesChart { 
  def main(args: Array[String]) { 
    val movie_years_count_collect_sort =            
    MovieData.getMovieYearsCountSorted() 

    val ds = new 
      org.jfree.data.category.DefaultCategoryDataset 
    for(i <- movie_years_count_collect_sort){ 
      ds.addValue(i._2.toDouble,"year", i._1) 
    } 
    val  chart = ChartFactories.BarChart(ds) 
    chart.show() 
    Util.sc.stop() 
  } 
}

请注意,大多数电影来自 1996 年。创建的图表如下所示:

电影年龄分布

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/MovieAgesChart.scala

探索评分数据集

现在让我们来看一下评分数据:

代码位于RatingData下:

object RatingData { 
  def main(args: Array[String]) { 
    val customSchema = StructType(Array( 
      StructField("user_id", IntegerType, true), 
      StructField("movie_id", IntegerType, true), 
      StructField("rating", IntegerType, true), 
      StructField("timestamp", IntegerType, true))) 

    val spConfig = (new SparkConf).setMaster("local").
      setAppName("SparkApp") 
    val spark = SparkSession.builder() 
      .appName("SparkRatingData").config(spConfig) 
      .getOrCreate() 

    val rating_df = spark.read.format("com.databricks.spark.csv") 
     .option("delimiter", "t").schema(customSchema) 
     .load("../../data/ml-100k/u.data") 
    rating_df.createOrReplaceTempView("df") 
    val num_ratings = rating_df.count() 
    val num_movies = Util.getMovieDataDF().count() 
    val first = rating_df.first() 
    println("first:" + first) 
    println("num_ratings:" + num_ratings) 
  } 
}

上述代码的输出如下所示:

First: 196 242 3 881250949
num_ratings:100000

有 100,000 个评分,与用户和电影数据集不同,这些记录是用制表符("t")分隔的。正如你可能已经猜到的,我们可能想要计算一些基本的摘要统计和评分值的频率直方图。让我们现在来做这个。

数据被分开了。正如你可能已经猜到的,我们可能想要计算一些基本的摘要统计和评分值的频率直方图。让我们现在来做这个:)。正如你可能已经猜到的,我们可能想要计算一些基本的摘要统计和评分值的频率直方图。让我们现在来做这个:

我们将计算最大、最小和平均评分。我们还将计算每个用户和每部电影的评分。我们正在使用 Spark SQL 来提取电影评分的最大、最小和平均值。

val max = Util.spark.sql("select max(rating)  from df") 
max.show() 

val min = Util.spark.sql("select min(rating)  from df") 
min.show() 

val avg = Util.spark.sql("select avg(rating)  from df") 
avg.show()

上述代码的输出如下所示:

+----------------+
|.  max(rating)  |
+----------------+
|              5 |
+----------------+

+----------------+
|.  min(rating)  |
+----------------+
|              1 |
+----------------+

+-----------------+
|.  avg(rating)   |
+-----------------+
|         3.52986 |
+-----------------+

在此处找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/RatingData.scala

评分计数条形图

从结果来看,用户对电影的平均评分大约为 3.5,因此我们可能期望评分的分布会偏向稍高的评分。让我们通过使用与职业相似的过程创建一个评分值的条形图来看看这是否成立。

绘制评分与计数的代码如下所示。这在文件CountByRatingChart.scala中可用:

object CountByRatingChart { 
  def main(args: Array[String]) { 
    val customSchema = StructType(Array( 
      StructField("user_id", IntegerType, true), 
      StructField("movie_id", IntegerType, true), 
      StructField("rating", IntegerType, true), 
      StructField("timestamp", IntegerType, true))) 

   val  spConfig = (new SparkConf).setMaster("local").
     setAppName("SparkApp") 
   val  spark = SparkSession 
      .builder() 
      .appName("SparkRatingData").config(spConfig) 
      .getOrCreate() 
   val rating_df = spark.read.format("com.databricks.spark.csv") 
      .option("delimiter", "t").schema(customSchema) 

   val rating_df_count = rating_df.groupBy("rating").
     count().sort("rating") 

   rating_df_count.show() 
   val rating_df_count_collection = rating_df_count.collect() 

   val ds = new org.jfree.data.category.DefaultCategoryDataset 
   val mx = scala.collection.immutable.ListMap() 

   for( x <- 0 until rating_df_count_collection.length) { 
      val occ = rating_df_count_collection(x)(0) 
      val count = Integer.parseInt( 
        rating_df_count_collection(x)(1).toString) 
      ds.addValue(count,"UserAges", occ.toString) 
    } 

    val chart = ChartFactories.BarChart(ds) 
    val font = new Font("Dialog", Font.PLAIN,5); 
    chart.peer.getCategoryPlot.getDomainAxis(). 
    setCategoryLabelPositions(CategoryLabelPositions.UP_90); 
    chart.peer.getCategoryPlot.getDomainAxis.setLabelFont(font) 
    chart.show() 
    Util.sc.stop() 
  } 
}

在执行上一个代码后,您将得到以下条形图:

评分数量的分布

我们还可以查看每个用户所做评分的分布。回想一下,我们之前通过使用制表符分割评分来计算了上述代码中使用的rating_data RDD。我们现在将在下面的代码中再次使用rating_data变量。

代码位于UserRatingChart类中。我们将从u.data文件创建一个 DataFrame,该文件是以制表符分隔的,然后按每个用户给出的评分数量进行分组并按升序排序。

object UserRatingsChart { 
  def main(args: Array[String]) { 

  } 
}

让我们首先尝试显示评分。

val customSchema = StructType(Array( 
  StructField("user_id", IntegerType, true), 
  StructField("movie_id", IntegerType, true), 
  StructField("rating", IntegerType, true), 
  StructField("timestamp", IntegerType, true))) 

val spConfig = (new      
    SparkConf).setMaster("local").setAppName("SparkApp") 
val spark = SparkSession 
   .builder() 
   .appName("SparkRatingData").config(spConfig) 
   .getOrCreate() 

val rating_df = spark.read.format("com.databricks.spark.csv") 
   .option("delimiter", "t").schema(customSchema) 
   .load("../../data/ml-100k/u.data") 

val rating_nos_by_user =       
    rating_df.groupBy("user_id").count().sort("count") 
val ds = new org.jfree.data.category.DefaultCategoryDataset 
  rating_nos_by_user.show(rating_nos_by_user.collect().length)

上述代码的输出如下所示:

+-------+-----+
|user_id|count|
+-------+-----+
|    636|   20|
|    572|   20|
|    926|   20|
|    824|   20|
|    166|   20|
|    685|   20|
|    812|   20|
|    418|   20|
|    732|   20|
|    364|   20|
....
 222|  387|
|    293|  388|
|     92|  388|
|    308|  397|
|    682|  399|
|     94|  400|
|      7|  403|
|    846|  405|
|    429|  414|
|    279|  434|
|    181|  435|
|    393|  448|
|    234|  480|
|    303|  484|
|    537|  490|
|    416|  493|
|    276|  518|
|    450|  540|
|     13|  636|
|    655|  685|
|    405|  737|
+-------+-----+

在以文本方式显示数据后,让我们通过从rating_nos_by_user DataFrame中加载数据到DefaultCategorySet来使用 JFreeChart 显示数据。

val step = (max/bins).toInt 
for(i <- step until (max + step) by step) { 
  mx += (i -> 0); 
} 
for( x <- 0 until rating_nos_by_user_collect.length) { 
  val user_id =
    Integer.parseInt(rating_nos_by_user_collect(x)(0).toString) 
  val count = 
    Integer.parseInt(rating_nos_by_user_collect(x)(1).toString) 
  ds.addValue(count,"Ratings", user_id) 
} 

val chart = ChartFactories.BarChart(ds) 
chart.peer.getCategoryPlot.getDomainAxis().setVisible(false) 

chart.show() 
Util.sc.stop()

在前面的图表中,x 轴是用户 ID,y 轴是评分数量,从最低的 20 到最高的 737 不等。

处理和转换您的数据

为了使原始数据可用于机器学习算法,我们首先需要清理数据,并可能以各种方式对其进行转换,然后从转换后的数据中提取有用的特征。转换和特征提取步骤是密切相关的,在某些情况下,某些转换本身就是特征提取的一种情况。

我们已经看到了在电影数据集中清理数据的需要的一个例子。一般来说,现实世界的数据集包含不良数据、缺失数据点和异常值。理想情况下,我们会纠正不良数据;然而,这通常是不可能的,因为许多数据集来自某种不能重复的收集过程(例如在 Web 活动数据和传感器数据中的情况)。缺失值和异常值也很常见,可以以类似于不良数据的方式处理。总的来说,广泛的选择如下:

  • 过滤或删除具有不良或缺失值的记录:有时是不可避免的;然而,这意味着丢失不良或缺失记录的好部分。

  • 填补坏数据或缺失数据:我们可以尝试根据我们可用的其余数据为坏数据或缺失数据分配一个值。方法可以包括分配零值,分配全局均值或中位数,插值附近或类似的数据点(通常在时间序列数据集中),等等。决定正确的方法通常是一个棘手的任务,取决于数据、情况和个人经验。

  • 对异常值应用健壮的技术:异常值的主要问题在于它们可能是正确的值,即使它们是极端的。它们也可能是错误的。很难知道你正在处理哪种情况。异常值也可以被移除或填充,尽管幸运的是,有统计技术(如健壮回归)来处理异常值和极端值。

  • 对潜在异常值应用转换:另一种处理异常值或极端值的方法是应用转换,比如对具有潜在异常值或显示潜在值范围较大的特征应用对数或高斯核转换。这些类型的转换可以减弱变量规模的大幅变化对结果的影响,并将非线性关系转换为线性关系。

填补坏数据或缺失数据

让我们看一下电影评论的年份并清理它。

我们已经看到了一个过滤坏数据的例子。在前面的代码之后,以下代码片段将填充方法应用于坏的发布日期记录,将空字符串分配为 1900(稍后将被中位数替换):

Util.spark.udf.register("convertYear", Util.convertYear _) 
movie_data_df.show(false) 

val movie_years = Util.spark.sql("select convertYear(date) as year from   movie_data") 

movie_years.createOrReplaceTempView("movie_years") 
Util.spark.udf.register("replaceEmptyStr", replaceEmptyStr _) 

val years_replaced =  Util.spark.sql("select replaceEmptyStr(year) 
  as r_year from movie_years")

在前面的代码中,我们使用了此处描述的replaceEmtryStr函数:

def replaceEmptyStr(v : Int): Int = { 
  try { 
    if(v.equals("") ) { 
      return 1900 
    } else { 
      returnv 
    } 
  }catch{ 
    case e: Exception => println(e) 
     return 1900 
  } 
}

接下来,我们提取不是 1900 年的经过筛选的年份,将Array[Row]替换为Array[int]并计算各种指标:

  • 条目的总和

  • 条目的总数

  • 年份的平均值

  • 年份的中位数

  • 转换后的总年数

  • 1900 的计数

val movie_years_filtered = movie_years.filter(x =>(x == 1900) ) 
val years_filtered_valid = years_replaced.filter(x => (x != 
  1900)).collect() 
val years_filtered_valid_int = new 
  ArrayInt 
for( i <- 0 until years_filtered_valid.length -1){ 
val x = Integer.parseInt(years_filtered_valid(i)(0).toString) 
  years_filtered_valid_int(i) = x 
} 
val years_filtered_valid_int_sorted = 
  years_filtered_valid_int.sorted 

val years_replaced_int = new Array[Int] 
  (years_replaced.collect().length) 

val years_replaced_collect = years_replaced.collect() 

for( i <- 0 until years_replaced.collect().length -1){ 
  val x = Integer.parseInt(years_replaced_collect(i)(0).toString) 
  years_replaced_int(i) = x 
} 

val years_replaced_rdd = Util.sc.parallelize(years_replaced_int) 

val num = years_filtered_valid.length 
var sum_y = 0 
years_replaced_int.foreach(sum_y += _) 
println("Total sum of Entries:"+ sum_y) 
println("Total No of Entries:"+ num) 
val mean = sum_y/num 
val median_v = median(years_filtered_valid_int_sorted) 
Util.sc.broadcast(mean) 
println("Mean value of Year:"+ mean) 
println("Median value of Year:"+ median_v) 
val years_x = years_replaced_rdd.map(v => replace(v , median_v)) 
println("Total Years after conversion:"+ years_x.count()) 
var count = 0 
Util.sc.broadcast(count) 
val years_with1900 = years_x.map(x => (if(x == 1900) {count +=1})) 
println("Count of 1900: "+ count)

前面代码的输出如下;替换为中位数后带有1900的值表明我们的处理是成功的

Total sum of Entries:3344062
Total No of Entries:1682
Mean value of Year:1988
Median value of Year:1995
Total Years after conversion:1682
Count of 1900: 0
Count of 1900: 0

在此处查找代码列表:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/MovieDataFillingBadValues.scala

我们在这里计算了发布年份的均值和中位数。从输出中可以看出,中位数发布年份要高得多,因为年份的分布是倾斜的。虽然要准确决定在给定情况下使用哪个填充值并不总是直截了当的,但在这种情况下,由于这种偏斜,使用中位数是可行的。

注意,前面的代码示例严格来说不太可扩展,因为它需要将所有数据收集到驱动程序中。我们可以使用 Spark 的mean函数来计算数值 RDD 的均值,但目前没有中位数函数可用。我们可以通过创建自己的函数或使用sample函数创建的数据集样本来计算中位数来解决这个问题(我们将在接下来的章节中看到更多)。

从数据中提取有用的特征

数据清理完成后,我们准备从数据中提取实际特征,用于训练机器学习模型。

“特征”是我们用来训练模型的变量。每一行数据包含我们想要提取为训练示例的信息。

几乎所有的机器学习模型最终都是基于数字表示形式的向量进行工作;因此,我们需要将原始数据转换为数字。

特征大致分为几类,如下所示:

  • 数值特征:这些特征通常是实数或整数,例如我们之前使用的用户年龄。

  • 分类特征:这些特征指的是变量在任何给定时间可以取一组可能状态中的一个。我们数据集中的示例可能包括用户的性别或职业,或电影类别。

  • 文本特征:这些是从数据中的文本内容派生出来的特征,例如电影标题、描述或评论。

  • 其他特征:大多数其他类型的特征最终都以数值形式表示。例如,图像、视频和音频可以表示为一组数值数据。地理位置可以表示为纬度和经度或地理哈希数据。

在这里,我们将涵盖数值、分类和文本特征。

数值特征

任何普通数字和数值特征之间有什么区别?实际上,任何数值数据都可以用作输入变量。然而,在机器学习模型中,您会了解每个特征的权重向量。这些权重在将特征值映射到结果或目标变量(在监督学习模型的情况下)中起着作用。

因此,我们希望使用有意义的特征,即模型可以学习特征值和目标变量之间的关系的特征。例如,年龄可能是一个合理的特征。也许增长年龄和某个结果之间存在直接关系。同样,身高是一个可以直接使用的数值特征的很好的例子。

我们经常会看到,数值特征在其原始形式下不太有用,但可以转化为更有用的表示。位置就是这样一个例子。

使用原始位置(比如纬度和经度)可能并不那么有用,除非我们的数据确实非常密集,因为我们的模型可能无法学习原始位置和结果之间的有用关系。然而,某种聚合或分箱表示的位置(例如城市或国家)与结果之间可能存在关系。

分类特征

分类特征不能以其原始形式用作输入,因为它们不是数字;相反,它们是变量可以取的一组可能值的成员。在前面提到的示例中,用户职业是一个可以取学生、程序员等值的分类变量。

为了将分类变量转换为数值表示,我们可以使用一种常见的方法,称为1-of-k编码。需要使用 1-of-k 编码这样的方法来表示。

需要使用 1-of-k 编码这样的方法来表示名义变量,使其对机器学习任务有意义。有序变量可能以其原始形式使用,但通常以与名义变量相同的方式进行编码。

假设变量可以取 k 个可能的值。如果我们为每个可能的值分配一个从 1 到 k 的索引,那么我们可以使用长度为 k 的二进制向量来表示变量的给定状态;在这里,除了对应于给定变量状态的索引处的条目设置为 1 之外,所有条目都为零。

例如,学生是[0],程序员是[1]

因此,值为:

学生变成[1,0]

程序员变成[0,1]

提取两个职业的二进制编码,然后创建长度为 21 的二进制特征向量:

val ratings_grouped = rating_df.groupBy("rating") 
ratings_grouped.count().show() 
val ratings_byuser_local = rating_df.groupBy("user_id").count() 
val count_ratings_byuser_local = ratings_byuser_local.count() 
ratings_byuser_local.show(ratings_byuser_local.collect().length) 
val movie_fields_df = Util.getMovieDataDF() 
val user_data_df = Util.getUserFieldDataFrame() 
val occupation_df = user_data_df.select("occupation").distinct() 
occupation_df.sort("occupation").show() 
val occupation_df_collect = occupation_df.collect() 

var all_occupations_dict_1:Map[String, Int] = Map() 
var idx = 0; 
// for loop execution with a range 
for( idx <- 0 to (occupation_df_collect.length -1)){ 
  all_occupations_dict_1 += 
    occupation_df_collect(idx)(0).toString() -> idx 
} 

println("Encoding of 'doctor : " + 
 all_occupations_dict_1("doctor")) 
println("Encoding of 'programmer' : " + 
 all_occupations_dict_1("programmer"))

前面println语句的输出如下:

Encoding of 'doctor : 20
Encoding of 'programmer' : 5

var k = all_occupations_dict_1.size 
var binary_x = DenseVector.zerosDouble 
var k_programmer = all_occupations_dict_1("programmer") 
binary_x(k_programmer) = 1 
println("Binary feature vector: %s" + binary_x) 
println("Length of binary vector: " + k)

前面命令的输出,显示了二进制特征向量和二进制向量的长度:

Binary feature vector: %sDenseVector(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
Length of binary vector: 21

在此处查找代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/RatingData.scala

派生特征

正如我们之前提到的,通常有必要从一个或多个可用变量计算派生特征。我们希望派生特征可以提供比仅使用原始形式的变量更多的信息。

例如,我们可以计算每个用户对其评分的所有电影的平均评分。这将是一个特征,可以为我们的模型提供用户特定的截距(事实上,这是推荐模型中常用的方法)。我们已经从原始评分数据中创建了一个新特征,可以帮助我们学习更好的模型。

从原始数据派生特征的示例包括计算平均值、中位数、方差、总和、差异、最大值或最小值和计数。我们已经在创建电影年龄特征时看到了这种情况,该特征是从电影的发行年份和当前年份派生出来的。通常,使用这些转换的背后思想是以某种方式总结数值数据,这可能会使模型更容易学习特征,例如通过分箱特征。这些常见的示例包括年龄、地理位置和时间等变量。

将时间戳转换为分类特征

提取一天中的时间

为了说明如何从数值数据中派生分类特征,我们将使用用户对电影的评分时间。从时间戳中提取日期和时间,然后提取一天中的小时

我们需要一个函数来提取评分时间戳(以秒为单位)的datetime表示;我们现在将创建这个函数:从时间戳中提取日期和时间,然后提取一天中的小时。这将导致每个评分的一天中的小时的 RDD。

Scala

首先,我们定义一个函数,该函数从日期字符串中提取currentHour

def getCurrentHour(dateStr: String) : Integer = { 
  var currentHour = 0 
  try { 
    val date = new Date(dateStr.toLong) 
    return int2Integer(date.getHours) 
  } catch { 
    case _ => return currentHour 
  } 
  return 1 
}

前面代码的输出如下:

Timestamps DataFrame is extracted from rating_df by creating a TempView df and running a select statement.

相关代码清单:

val customSchema = StructType(Array( 
StructField("user_id", IntegerType, true), 
StructField("movie_id", IntegerType, true), 
StructField("rating", IntegerType, true), 
StructField("timestamp", IntegerType, true))) 

val spConfig = (new 
 SparkConf).setMaster("local").setAppName("SparkApp") 
val spark = SparkSession 
  .builder() 
  .appName("SparkRatingData").config(spConfig) 
  .getOrCreate() 

val rating_df = spark.read.format("com.databricks.spark.csv") 
  .option("delimiter", "t").schema(customSchema) 
  .load("../../data/ml-100k/u.data") 
rating_df.createOrReplaceTempView("df") 
Util.spark.udf.register("getCurrentHour", getCurrentHour _) 

val timestamps_df = 
 Util.spark.sql("select getCurrentHour(timestamp) as hour from 
 df") 
timestamps_df.show()

在以下链接找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/RatingData.scala

提取一天中的时间

我们已经将原始时间数据转换为表示给出评分的一天中的小时的分类特征。

现在,假设我们决定这是一个太粗糙的表示。也许我们想进一步完善转换。我们可以将每个一天中的小时值分配到表示一天中的时间的定义桶中。

例如,我们可以说早晨是从上午 7 点到上午 11 点,午餐是从上午 11 点到下午 1 点,依此类推。使用这些时间段,我们可以创建一个函数,根据输入的小时来分配一天中的时间。

Scala

在 Scala 中,我们定义一个函数,该函数以 24 小时制的绝对时间作为输入,并返回一天中的时间:早晨午餐下午晚上夜晚

def assignTod(hr : Integer) : String = { 
if(hr >= 7 && hr < 12){ 
return"morning" 
}else if ( hr >= 12 && hr < 14) { 
return"lunch" 
  } else if ( hr>= 14 && hr < 18) { 
return"afternoon" 
  } else if ( hr>= 18 && hr.<(23)) { 
return"evening" 
  } else if ( hr>= 23 && hr <= 24) { 
return"night" 
  } else if (  hr< 7) { 
return"night" 
  } else { 
return"error" 
  } 
}

我们将此函数注册为 UDF,并在 select 调用中对 temp 视图时间戳进行调用。

Util.spark.udf.register("assignTod", assignTod _) 
timestamps_df.createOrReplaceTempView("timestamps") 
val tod = Util.spark.sql("select assignTod(hour) as tod from 
 timestamps") 
tod.show()

在以下链接找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/RatingData.scala

我们现在已经将时间戳变量(可以取数千个值,可能以原始形式对模型没有用)转换为小时(取 24 个值),然后转换为一天中的时间(取五个可能的值)。现在我们有了一个分类特征,我们可以使用之前概述的相同的 1-of-k 编码方法来生成一个二进制特征向量。

文本特征

在某种程度上,文本特征是一种分类和派生特征的形式。让我们以电影描述为例(我们的数据集中没有)。在这里,原始文本不能直接使用,即使作为分类特征,因为每个文本可能的值几乎是无限的。我们的模型几乎不会看到两次相同特征的出现,也无法有效学习。因此,我们希望将原始文本转换为更适合机器学习的形式,因为每个文本可能的值几乎是无限的。

处理文本的方法有很多种,自然语言处理领域致力于处理、表示和建模文本内容。全面的处理超出了本书的范围,但我们将介绍一种简单和标准的文本特征提取方法;这种方法被称为词袋模型表示。

词袋模型方法将文本内容视为文本中的单词和可能的数字的集合(这些通常被称为术语)。词袋模型的过程如下:

  • 分词:首先,对文本应用某种形式的分词,将其分割成一组标记(通常是单词、数字等)。一个例子是简单的空格分词,它在每个空格上分割文本,并可能删除不是字母或数字的标点符号和其他字符。

  • 停用词去除:接下来,通常会去除非常常见的词,比如"the"、"and"和"but"(这些被称为停用词)。

  • 词干提取:下一步可以包括词干提取,指的是将一个词语减少到其基本形式或词干。一个常见的例子是复数变成单数(例如,dogs 变成 dog,等等)。有许多词干提取的方法,文本处理库通常包含各种词干提取算法,例如 OpenNLP、NLTK 等。详细介绍词干提取超出了本书的范围,但欢迎自行探索这些库。

  • 向量化:最后一步是将处理后的术语转换为向量表示。最简单的形式可能是二进制向量表示,如果一个术语存在于文本中则赋值为 1,如果不存在则赋值为 0。这与我们之前遇到的分类 1-of-k 编码基本相同。与 1-of-k 编码一样,这需要一个术语字典,将给定的术语映射到索引号。你可能会发现,即使在停用词去除和词干提取之后,可能仍然有数百万个可能的术语。因此,使用稀疏向量表示计算time.computetime.computetime.compute时间变得至关重要。

在第十章中,使用 Spark 进行高级文本处理,我们将涵盖更复杂的文本处理和特征提取,包括加权术语的方法;这些方法超出了我们之前看到的基本二进制编码。

简单的文本特征提取

为了展示用二进制向量表示提取文本特征的示例,我们可以使用现有的电影标题。

首先,我们将创建一个函数,用于剥离每部电影的发行年份,如果有年份存在的话,只留下电影的标题。

我们将使用正则表达式,在电影标题中搜索括号之间的年份。如果我们找到与这个正则表达式匹配的内容,我们将仅提取标题直到第一个匹配的索引(即标题字符串中开括号的索引)。

Scala

首先,我们创建一个函数,该函数接受输入字符串并使用正则表达式过滤输出。

def processRegex(input:String):String= { 
  val pattern = "^[^(]*".r 
  val output = pattern.findFirstIn(input) 
  return output.get 
}

提取只有原始标题的 DataFrame 并创建一个名为titles的临时视图。使用 Spark 注册上面创建的函数,然后在select语句中对 DataFrame 运行它。

val raw_title = 
 org.sparksamples.Util.getMovieDataDF().select("name"
 raw_title.show() 
raw_title.createOrReplaceTempView("titles") 
Util.spark.udf.register("processRegex", processRegex _) 
val processed_titles = Util.spark.sql( 
"select processRegex(name) from titles") 
processed_titles.show() 
val titles_rdd = processed_titles.rdd.map(r => r(0).toString) 
titles_rdd.take(5).foreach(println)

前面代码的输出如下:

//Output of raw_title.show()
+--------------------+
|           UDF(name)|
+--------------------+
|          Toy Story |
|          GoldenEye |
|         Four Rooms |
|         Get Shorty |
|            Copycat |
|     Shanghai Triad |
|     Twelve Monkeys |
|               Babe |
|   Dead Man Walking |
|        Richard III |
|              Seven |
|Usual Suspects, The |
|   Mighty Aphrodite |
|        Postino, Il |
| Mr. Holland's Opus |
|       French Twist |
|From Dusk Till Dawn |
| White Balloon, The |
|     Antonia's Line |
| Angels and Insects |
+--------------------+

//titles_rdd.take(5).foreach(println)
Toy Story
GoldenEye
Four Rooms
Get Shorty
Copycat

然后,将我们的函数应用于原始标题,并对提取的标题应用标记化方案,将它们转换为术语,我们将使用我们之前介绍的简单的空格标记化:

接下来,我们将titles拆分成单词

val title_terms = titles_rdd.map(x => x.split("")) 
title_terms.take(5).foreach(_.foreach(println)) 
println(title_terms.count())

应用这种简单的标记化得到以下结果:

Toy
Story
GoldenEye
Four
Rooms
Get
Shorty
Copycat

然后,我们转换单词的 rdd 并找到单词的总数-我们得到总单词的集合以及"Dead""Rooms"的索引。

val all_terms_dic = new ListBuffer[String]() 
val all_terms = title_terms.flatMap(title_terms => title_terms).distinct().collect() 
for (term <- all_terms){ 
  all_terms_dic += term 
} 

println(all_terms_dic.length) 
println(all_terms_dic.indexOf("Dead")) 
println(all_terms_dic.indexOf("Rooms"))

这将导致以下输出:

Total number of terms: 2645
Index of term 'Dead': 147
Index of term 'Rooms': 1963

我们还可以使用 Spark 的zipWithIndex函数更有效地实现相同的结果。这个函数接受一个值的 RDD,并将它们与索引合并在一起,创建一个新的键值对 RDD,其中键将是术语,值将是术语字典中的索引。我们将使用collectAsMap将键值对 RDD 收集到驱动程序作为 Python dict方法:

Scala

val all_terms_withZip = title_terms.flatMap(title_terms =>
  title_terms).distinct().zipWithIndex().collectAsMap() 
println(all_terms_withZip.get("Dead")) 
println(all_terms_withZip.get("Rooms"))

输出如下:

Index of term 'Dead': 147
Index of term 'Rooms': 1963

标题的稀疏向量

最后一步是创建一个将一组术语转换为稀疏向量表示的函数。为此,我们将创建一个空的稀疏矩阵,其中有一行,列数等于字典中术语的总数。然后,我们将遍历输入术语列表中的每个术语,并检查该术语是否在我们的术语字典中。如果是,我们将在对应于字典映射中的术语的索引处为向量分配一个值1

提取的术语:

Scala

def create_vector(title_terms:Array[String], 
  all_terms_dic:ListBuffer[String]): CSCMatrix[Int] = { 
  var idx = 0 
  val x = CSCMatrix.zerosInt 
  title_terms.foreach(i => { 
    if (all_terms_dic.contains(i)) { 
      idx = all_terms_dic.indexOf(i) 
      x.update(0, idx, 1) 
    } 
  }) 
  return x 
} 

val term_vectors = title_terms.map(title_terms =>
 create_vector(title_terms, all_terms_dic)) 
term_vectors.take(5).foreach(println)

然后,我们可以检查我们新的稀疏向量 RDD 的前几条记录:

1 x 2453 CSCMatrix
(0,622) 1
(0,1326) 1
1 x 2453 CSCMatrix
(0,418) 1
1 x 2453 CSCMatrix
(0,729) 1
(0,996) 1
1 x 2453 CSCMatrix
(0,433) 1
(0,1414) 1
1 x 2453 CSCMatrix
(0,1559) 1

在以下网址找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/exploredataset/explore_movies.scala

我们可以看到,每个电影标题现在都被转换为稀疏向量。我们可以看到,我们提取了两个术语的标题在向量中有两个非零条目,我们只提取了一个术语的标题有一个非零条目,依此类推。

请注意在前面示例代码中使用了 Spark 的broadcast方法来创建一个包含术语字典的广播变量。在实际应用中,这样的术语字典可能非常庞大,因此不建议使用广播变量。

归一化特征

一旦特征被提取为向量的形式,常见的预处理步骤是对数值数据进行归一化。其背后的想法是以一种方式转换每个数值特征,使其缩放到标准大小。我们可以执行不同类型的归一化,如下所示:

  • 归一化特征:这通常是应用于数据集中的单个特征的转换,例如,减去均值(使特征居中)或应用标准正态转换(使特征的平均值为零,标准差为 1)。

  • 归一化特征向量:这通常是应用于数据集中给定行的所有特征的转换,使得结果特征向量具有归一化长度。也就是说,我们将确保向量中的每个特征都被缩放,使得向量的范数为 1(通常是在 L1 或 L2 范数上)。

我们将以第二种情况为例。我们可以使用numpynorm函数通过首先计算随机向量的 L2 范数,然后将向量中的每个元素除以这个范数来实现向量归一化:

//val vector = DenseVector.rand(10) 
val vector = DenseVector(0.49671415, -0.1382643, 
0.64768854,1.52302986, -0.23415337, -0.23413696, 1.57921282, 
  0.76743473, -0.46947439, 0.54256004) 
val norm_fact = norm(vector) 
val vec = vector/norm_fact 
println(norm_fact) 
println(vec)

前面代码的输出如下:

2.5908023998401077
DenseVector(0.19172212826059407, -0.053367366036303286, 
 0.24999534508690138, 0.5878602938201672, -0.09037870661786127, -
 0.09037237267282516, 0.6095458380374597, 0.2962150760889223, -
 0.18120810372453483, 0.20941776186153152)

使用 ML 进行特征归一化

Spark 在其机器学习库中提供了一些内置函数用于特征缩放和标准化。这些包括StandardScaler,它应用标准正态转换,以及Normalizer,它应用我们在前面示例代码中展示的相同特征向量归一化。

我们将在接下来的章节中探讨这些方法的使用,但现在,让我们简单比较一下使用 MLlib 的Normalizer和我们自己的结果:

from pyspark.mllib.feature import Normalizer 
normalizer = Normalizer() 
vector = sc.parallelize([x])

在导入所需的类之后,我们将实例化Normalizer(默认情况下,它将使用 L2 范数,就像我们之前做的那样)。请注意,在 Spark 的大多数情况下,我们需要为Normalizer提供 RDD 作为输入(它包含numpy数组或 MLlib 向量);因此,我们将从我们的向量x创建一个单元素 RDD,以便说明目的。

然后,我们将在 RDD 上使用Normalizertransform函数。由于 RDD 中只有一个向量,我们将通过调用first将我们的向量返回给驱动程序,最后通过调用toArray函数将向量转换回numpy数组:

normalized_x_mllib = 
  normalizer.transform(vector).first().toArray()

最后,我们可以打印出与之前相同的细节,比较结果:

print"x:n%s" % x 
print"2-Norm of x: %2.4f" % norm_x_2 
print"Normalized x MLlib:n%s" % normalized_x_mllib 
print"2-Norm of normalized_x_mllib: %2.4f" % 
 np.linalg.norm(normalized_x_mllib)

您将得到与我们自己的代码完全相同的归一化向量。但是,使用 MLlib 的内置方法肯定比编写我们自己的函数更方便和高效!等效的 Scala 实现如下:

object FeatureNormalizer { 
  def main(args: Array[String]): Unit = { 
    val v = Vectors.dense(0.49671415, -0.1382643, 0.64768854, 
      1.52302986, -0.23415337, -0.23413696, 1.57921282, 
      0.76743473, -0.46947439, 0.54256004) 
    val normalizer = new Normalizer(2) 
    val norm_op = normalizer.transform(v) 
    println(norm_op) 
  } 
}

前面代码的输出如下:

[0.19172212826059407,-
 0.053367366036303286,0.24999534508690138,0.5878602938201672,-
 0.09037870661786127,-
 0.09037237267282516,0.6095458380374597,0.2962150760889223,-
 0.18120810372453483,0.20941776186153152]

使用特征提取包

虽然每次都从这些常见任务中获得。当然,我们可以为此目的创建自己的可重用代码库;但是,幸运的是,我们可以依赖现有的工具和包。由于 Spark 支持 Scala、Java 和 Python 绑定,我们可以使用这些语言中提供的包,这些包提供了处理和提取特征并将其表示为向量的复杂工具。一些用于特征提取的包的示例包括 Python 中的scikit-learngensimscikit-imagematplotlibNLTK,Java 中的OpenNLP,以及 Scala 中的BreezeChalk。实际上,自从 1.0 版本以来,Breeze一直是 Spark MLlib 的一部分,我们将在后面的章节中看到如何使用一些 Breeze 功能进行线性代数。

TFID

tf-idf术语频率-逆文档频率的简称。它是一个数值统计量,旨在反映一个词对于集合或语料库中的文档的重要性。它在信息检索和文本挖掘中用作加权因子。tf-idf 值与单词在文档中出现的次数成比例增加。它受到语料库中单词频率的影响,有助于调整一些在一般情况下更频繁出现的单词。

tf-idf 被搜索引擎或文本处理引擎用作评分和排名用户查询的文档相关性的工具。

最简单的排名函数是通过对每个查询术语的 tf-idf 求和来计算的;更复杂的排名函数是这个简单模型的变体。

在术语频率tf(t,d)计算中,一种选择是使用文档中术语的原始频率:术语 t 在文档d中出现的次数。如果t的原始频率是f(t,d),那么简单的tf方案是tf(t,d) = ft,d

Spark 的tf(t.d)实现使用了哈希。通过应用哈希函数,将原始单词映射到索引(术语)。使用映射的索引计算术语频率。

参考:

IDF

逆文档频率IDF)表示单词提供的信息量:术语在语料库中是常见的还是罕见的。它是包含该单词的文档的总数与包含该术语的文档数量的倒数的对数比例TF-IDF

TF-IDF 是通过将 TF 和 IDF 相乘来计算的。

以下示例计算 Apache Spark README.md文件中每个术语的 TFIDF:

object TfIdfSample{ 
  def main(args: Array[String]) { 
    // TODO replace with path specific to your machine 
    val file = Util.SPARK_HOME + "/README.md" 
    val spConfig = (new        
      SparkConf).setMaster("local").setAppName("SparkApp") 
    val sc = new SparkContext(spConfig) 
    val documents: RDD[Seq[String]] =      
      sc.textFile(file).map(_.split("").toSeq) 
    print("Documents Size:" + documents.count) 
    val hashingTF = new HashingTF() 
    val tf = hashingTF.transform(documents) 
    for(tf_ <- tf) { 
      println(s"$tf_") 
    } 
    tf.cache() 
    val idf = new IDF().fit(tf) 
    val tfidf = idf.transform(tf) 
    println("tfidf size : " + tfidf.count) 
    for(tfidf_ <- tfidf) { 
      println(s"$tfidf_") 
    } 
  } 
}

在以下位置找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala2.0.0/src/main/scala/org/sparksamples/featureext/TfIdfSample.scala

Word2Vector

Word2Vec 工具以文本数据作为输入,并将单词向量作为输出。该工具从训练文本数据中构建词汇表并学习单词的向量表示。生成的单词向量文件可以用作许多自然语言处理和机器学习应用的特征。

调查学习到的表示的最简单方法是找到用户指定单词的最接近单词。

Apache Spark 中的 Word2Vec 实现计算单词的分布式向量表示。与 Google 提供的单机 Word2Vec 实现相比,Apache Spark 的实现是一种更可扩展的方法。

(code.google.com/archive/p/word2vec/)

Word2Vec 可以使用两种学习算法实现:连续词袋和连续跳字。

跳字模型

跳字模型的训练目标是找到对预测文档或句子中周围单词有用的单词表示。给定一系列单词w1, w2, w3, . . , wT,跳字模型最大化以下平均对数概率:

c是训练上下文的大小(可以是中心词wt的函数)。较大的c会导致更多的训练示例,从而提高准确性,但训练时间会增加。基本的跳字式公式使用softmax函数定义了p(wt+j |wt)

v[w]v' 和,w输入输出单词的向量表示,W是词汇表中的单词数

在 Spark 中,使用分层软最大值方法来预测单词wi给定单词wj

以下示例显示了如何使用 Apache Spark 创建单词向量。

object ConvertWordsToVectors{ 
  def main(args: Array[String]) { 
    val file =  
      "/home/ubuntu/work/ml-resources/" + 
      "spark-ml/Chapter_04/data/text8_10000" 
    val conf = new SparkConf().setMaster("local").
      setAppName("Word2Vector") 
    val sc = new SparkContext(conf) 
    val input = sc.textFile(file).map(line => line.split("").toSeq) 
    val word2vec = new Word2Vec() 
    val model = word2vec.fit(input) 
    val vectors = model.getVectors 
    vectors foreach (  
      (t2) =>println (t2._1 + "-->" + t2._2.mkString("")) 
    ) 
  } 
}

在以下位置找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/featureext/ConvertWordsToVectors.scala

上述代码的输出:

ideas-->0.0036772825 -9.474439E-4 0.0018383651 -6.24215E-4 -
 0.0042944895 -5.839545E-4 -0.004661157 -0.0024960344 0.0046632644 -
 0.00237432 -5.5691406E-5 -0.0033026629 0.0032463844 -0.0019799764 -
 0.0016042799 0.0016129494 -4.099998E-4 0.0031266063 -0.0051537985 
 0.004354736 -8.4361364E-4 0.0016157745 -0.006367187 0.0037806155 -
 4.4071436E-4 8.62155E-4 0.0051918332 0.004437387 -0.0012511226 -
 8.7162864E-4 -0.0035564564 -4.2263913E-4 -0.0020519749 -
 0.0034343079 0.0035128237 -0.0014698022 -7.263344E-4 -0.0030510207 
 -1.05513E-4 0.003316195 0.001853326 -0.003090298 -7.3562167E-4 -
 0.004879414 -0.007057088 1.1937474E-4 -0.0017973455 0.0034448127 
 0.005289607 9.6152216E-4 0.002103868 0.0016721261 -9.6310966E-4 
 0.0041839285 0.0035658625 -0.0038187192 0.005523701 -1.8146896E-4 -
 0.006257453 6.5041234E-4 -0.006894542 -0.0013860351 -4.7463065E-4 
 0.0044280654 -7.142674E-4 -0.005085546 -2.7047616E-4 0.0026938762 -
 0.0020157609 0.0051508015 -0.0027767695 0.003554946 -0.0052921847 
 0.0020432177 -0.002188367 -0.0010223344 -0.0031813548 -0.0032866944 
 0.0020323955 -0.0015844131 -0.0041034482 0.0044767153 -2.5071128E-4 
 0.0022343954 0.004051373 -0.0021706335 8.161181E-4 0.0042591896 
 0.0036099665 -0.0024891358 -0.0043153367 -0.0037649528 -
 0.0033249175 -9.5358933E-4 -0.0041675125 0.0029751007 -0.0017840122 
 -5.3287676E-4 1.983675E-4 -1.9737136E-5

标准缩放器

标准缩放器通过对训练集中的样本使用列摘要统计数据,将数据集的特征标准化为单位方差并去除均值(可选)。

这个过程是一个非常常见的预处理步骤。

标准化可以提高优化过程中的收敛速度。它还可以防止具有较大方差的特征在模型训练过程中产生过大的影响。

StandardScaler类在构造函数中具有以下参数:

新的 StandardScaler(withMean: Boolean, withStd: Boolean)

  • withMean:默认为False。在缩放之前使用均值对数据进行中心化。它将构建一个密集输出,在稀疏输入上不起作用,并将引发异常。

  • withStd:默认为True。将数据缩放到单位标准差。

注释

可用@Since("1.1.0" )

object StandardScalarSample { 
  def main(args: Array[String]) { 
    val conf = new SparkConf().setMaster("local"). 
     setAppName("Word2Vector") 
    val sc = new SparkContext(conf) 
    val data = MLUtils.loadLibSVMFile( sc, 
      org.sparksamples.Util.SPARK_HOME +         
      "/data/mllib/sample_libsvm_data.txt") 

    val scaler1 = new StandardScaler().fit(data.map(x => x.features) 
    val scaler2 = new StandardScaler(withMean = true, 
      withStd = true).fit(data.map(x => x.features)) 
    // scaler3 is an identical model to scaler2, and will produce   
    //identical transformations 
    val scaler3 = new StandardScalerModel(scaler2.std, scaler2.mean) 

    // data1 will be unit variance. 
    val data1 = data.map(x => 
      (x.label, scaler1.transform(x.features))) 
    println(data1.first())
    // Without converting the features into dense vectors, 
    //transformation with zero mean will raise 
    // exception on sparse vector. 
    // data2 will be unit variance and zero mean. 
    val data2 = data.map(x => (x.label,       
      scaler2.transform(Vectors.dense(x.features.toArray)))) 
    println(data2.first()) 
  } 
}

在以下链接找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_04/scala/2.0.0/src/main/scala/org/sparksamples/featureext/StandardScalarSample.scala

总结

在本章中,我们看到了如何找到常见的、公开可用的数据集,这些数据集可以用来测试各种机器学习模型。您学会了如何加载、处理和清理数据,以及如何应用常见技术将原始数据转换为特征向量,这些特征向量可以作为我们模型的训练样本。

在下一章中,您将学习推荐系统的基础知识,探索如何创建推荐模型,使用模型进行预测,并评估模型。

第五章:使用 Spark 构建推荐引擎

现在您已经学会了数据处理和特征提取的基础知识,我们将继续详细探讨各个机器学习模型,首先是推荐引擎。

推荐引擎可能是公众所知的最好的机器学习模型之一。即使人们不确切知道推荐引擎是什么,他们很可能通过使用流行网站(如亚马逊、Netflix、YouTube、Twitter、LinkedIn 和 Facebook)来体验过。推荐是所有这些业务的核心部分,在某些情况下,推荐引擎推动了它们相当大比例的收入。

推荐引擎的理念是预测人们可能喜欢什么,并揭示项目之间的关系,以帮助发现过程;在这方面,它们与搜索引擎相似,实际上通常是互补的,后者也在发现中发挥作用。然而,与搜索引擎不同,推荐引擎试图向人们呈现他们并非必然搜索或甚至可能从未听说过的相关内容。

通常,推荐引擎试图建模用户和某种类型项目之间的连接。例如,在我们的电影流场景中,我们可以使用推荐引擎向用户展示他们可能喜欢的电影。如果我们能做到这一点,我们可以通过我们的服务保持用户的参与,这对我们的用户和我们都是有利的。同样,如果我们能够很好地向用户展示与给定电影相关的电影,我们可以帮助他们在我们的网站上发现和导航,从而提高我们用户的体验、参与度和我们内容对他们的相关性。

然而,推荐引擎不仅限于电影、书籍或产品。本章将探讨的技术可以应用于几乎任何用户对项目的关系,以及用户对用户的连接,比如社交网络上的连接,使我们能够做出推荐,比如你可能认识的人或者应该关注谁。

推荐引擎在两种一般情况下最有效,它们并不是互斥的。这里进行了解释:

  • 用户可用选项的大量:当有大量可用项目时,用户要找到他们想要的东西变得越来越困难。当用户知道他们在寻找什么时,搜索可以帮助,但通常,合适的项目可能是他们以前不知道的东西。在这种情况下,被推荐相关的用户可能不知道的项目可以帮助他们发现新项目。

  • 涉及个人口味的显著程度:当个人口味在选择中起到重要作用时,推荐模型(通常利用众人的智慧方法)可以帮助根据具有相似口味配置的其他人的行为发现项目。

在本章中,我们将涵盖以下主题:

  • 介绍各种类型的推荐引擎

  • 使用关于用户偏好的数据构建推荐模型

  • 使用训练好的模型为特定用户计算推荐,同时为给定项目计算类似项目,即相关项目

  • 应用标准评估指标来衡量我们创建的模型在预测能力方面的表现如何

推荐模型的类型

推荐系统得到了广泛研究,有许多不同的方法,但其中两种可能最为普遍:基于内容的过滤和协同过滤。最近,其他方法,如排名模型,也变得越来越受欢迎。在实践中,许多方法是混合的,将许多不同方法的元素纳入模型或模型组合。

基于内容的过滤

基于内容的方法试图使用项目的内容或属性,以及两个内容之间的相似性概念,来生成与给定项目相似的项目。这些属性通常是文本内容,如标题、名称、标签和附加到项目的其他元数据,或者在媒体的情况下,它们可能包括从音频和视频内容中提取的项目的其他特征。

以类似的方式,用户推荐可以基于用户或用户资料的属性生成,然后使用相似度的度量来将其与项目属性进行匹配。例如,用户可以由他们互动过的项目的组合属性来表示。这就成为了他们的用户资料,然后将其与项目属性进行比较,以找到与用户资料匹配的项目。

这些是为每个用户或项目创建描述其性质的资料的几个例子:

  • 电影资料包括有关演员、流派、受欢迎程度等的属性。

  • 用户资料包括人口统计信息或对特定问题的回答。

  • 内容过滤使用资料来关联用户或项目。

  • 基于关键词重叠的新项目与用户资料的相似度使用 Dice 系数进行计算。还有其他方法。

协同过滤

协同过滤仅依赖于过去的行为,如先前的评分或交易。其背后的思想是相似性的概念。

基本思想是用户对项目进行评分,隐式或显式地。过去口味相似的用户将来口味也会相似。

在基于用户的方法中,如果两个用户表现出类似的偏好,即以广泛相同方式与相同项目互动的模式,那么我们会假设他们在口味上相似。为了为给定用户生成未知项目的推荐,我们可以使用表现出类似行为的其他用户的已知偏好。我们可以通过选择一组相似的用户并计算基于他们对项目的偏好的某种形式的综合评分来实现这一点。总体逻辑是,如果其他人对一组项目有类似的口味,这些项目很可能是推荐的良好候选项。

我们还可以采用基于项目的方法,计算项目之间的相似度。这通常基于现有的用户-项目偏好或评分。那些倾向于被类似用户评价的项目在这种方法下会被归类为相似的。一旦我们有了这些相似性,我们可以根据用户与其互动的项目来表示用户,并找到与这些已知项目相似的项目,然后推荐给用户。同样,一组与已知项目相似的项目被用来生成一个综合评分,以估计未知项目。

基于用户和基于项目的方法通常被称为最近邻模型,因为估计的分数是基于最相似的用户或项目集合计算的,即它们的邻居。

传统的协同过滤算法将用户表示为项目的 N 维向量,其中 N 是不同项目的数量。向量的分量是正面或负面项目。为了计算最佳项目,该算法通常将向量分量乘以频率的倒数,即评价该项目的用户数量的倒数,使得不太知名的项目更加相关。对于大多数用户来说,这个向量非常稀疏。该算法基于与用户最相似的少数用户生成推荐。它可以使用一种称为余弦相似度的常见方法来衡量两个用户XY的相似度,即两个向量之间的夹角的余弦值。

最后,有许多基于模型的方法试图对用户-物品偏好本身进行建模,以便通过将模型应用于未知的用户-物品组合来直接估计新的偏好。

协同过滤的两种主要建模方法如下:

  • 邻域方法

  • 以用户为中心的方法集中在计算用户之间的关系

  • 以物品为中心的方法根据同一用户对相邻物品的评分来评估用户对物品的偏好

  • 使用居中余弦距离进行相似性计算,也称为皮尔逊相关系数

  • 潜在因子模型

  • 潜在因子模型(LFM)方法通过表征用户和物品来解释评分,以找到隐藏的潜在特征

  • 在电影中,诸如动作或戏剧、演员类型等都是潜在因子

  • 在用户中,喜欢电影评分的特征是潜在因子的一个例子

  • 类型包括神经网络、潜在狄利克雷分配、矩阵分解

在下一节中,我们将讨论矩阵分解模型。

矩阵分解

由于 Spark 的推荐模型目前只包括矩阵分解的实现,因此我们将把注意力集中在这类模型上。这种关注是有充分理由的;然而,这些类型的模型在协同过滤中一直表现出色,并且在著名的比赛中,如 Netflix 奖,它们一直是最佳模型之一。

矩阵分解假设:

  • 每个用户可以用 n 个属性或特征来描述。例如,特征一可能是一个数字,表示每个用户对动作电影的喜欢程度。

  • 每个物品可以用一组 n 个属性或特征来描述。与前面的例子相连,电影的特征一可能是一个数字,表示电影与纯动作的接近程度。

  • 如果我们将用户的每个特征乘以物品的相应特征并将所有内容相加,这将是用户给出该物品评分的良好近似。

有关 Netflix 奖的最佳算法的更多信息和简要概述,请参阅techblog.netflix.com/2012/04/netflix-recommendations-beyond-5-stars.html

显式矩阵分解

当我们处理由用户自己提供的用户偏好数据时,我们称之为显式偏好数据。这包括用户对物品的评分、点赞、喜欢等。

我们可以将这些评分组成一个二维矩阵,以用户为行,物品为列。每个条目表示用户对某个物品的评分。由于在大多数情况下,每个用户只与相对较小的一组物品进行了交互,因此该矩阵只有少数非零条目,即非常稀疏。

举个简单的例子,假设我们有一组电影的以下用户评分:

Tom: 星球大战,5

Jane: 泰坦尼克号,4

Bill: 蝙蝠侠,3

Jane: 星球大战,2

Bill: 泰坦尼克号,3

我们将形成以下评分矩阵:

一个简单的电影评分矩阵

矩阵分解(或矩阵完成)试图直接对用户-物品矩阵进行建模,将其表示为较低维度的两个较小矩阵的乘积。因此,这是一种降维技术。如果我们有U个用户和I个物品,那么我们的用户-物品矩阵的维度为 U x I,可能看起来像下图所示的矩阵:

一个稀疏的评分矩阵

如果我们想要找到一个低维(低秩)的用户-物品矩阵的近似值,维度为k,我们将得到两个矩阵:一个是用户大小为 U x k 的矩阵,另一个是物品大小为 I x k 的矩阵;这些被称为因子矩阵。如果我们将这两个因子矩阵相乘,我们将重构原始评分矩阵的近似版本。请注意,原始评分矩阵通常非常稀疏,而每个因子矩阵是密集的,如下图所示:

用户和物品因子矩阵

这些模型通常也被称为潜在特征模型,因为我们试图发现一些隐藏特征(由因子矩阵表示),这些特征解释了用户-物品评分矩阵中固有的行为结构。虽然潜在特征或因子通常不是直接可解释的,但它们可能代表一些东西,比如用户倾向于喜欢某个导演、类型、风格或一组演员的电影。

由于我们直接对用户-物品矩阵进行建模,因此这些模型中的预测相对简单:要计算用户和物品的预测评分,我们将计算用户因子矩阵的相关行(即用户的因子向量)与物品因子矩阵的相关行(即物品的因子向量)之间的向量点积。

这在下图中突出显示的向量中得到了说明:

从用户和物品因子向量计算推荐

要找出两个物品之间的相似性,我们可以使用与最近邻模型中使用的相似性度量相同的度量,只是我们可以直接使用因子向量,通过计算两个物品因子向量之间的相似性,如下图所示:

使用物品因子向量计算相似性

因子化模型的好处在于一旦模型创建完成,推荐的计算相对容易。然而,对于非常庞大的用户和物品集,这可能会成为一个挑战,因为它需要跨可能有数百万用户和物品因子向量的存储和计算。另一个优势,正如前面提到的,是它们往往提供非常好的性能。

Oryx(github.com/OryxProject/oryx)和 Prediction.io(github.com/PredictionIO/PredictionIO)等项目专注于为大规模模型提供模型服务,包括基于矩阵因子分解的推荐系统。

不足之处在于,与最近邻模型相比,因子化模型相对更复杂,而且在模型的训练阶段通常需要更多的计算资源。

隐式矩阵因子分解

到目前为止,我们已经处理了诸如评分之类的显式偏好。然而,我们可能能够收集到的许多偏好数据是隐式反馈,即用户和物品之间的偏好并未直接给出,而是从他们可能与物品的互动中暗示出来。例如,二进制数据,比如用户是否观看了一部电影,是否购买了一个产品,以及计数数据,比如用户观看一部电影的次数。

处理隐式数据有许多不同的方法。MLlib 实现了一种特定的方法,将输入评分矩阵视为两个矩阵:一个是二进制偏好矩阵P,另一个是置信权重矩阵C

例如,假设我们之前看到的用户-电影评分实际上是每个用户观看该电影的次数。这两个矩阵看起来可能像以下截图中显示的矩阵。在这里,矩阵P告诉我们电影被用户观看了,矩阵C代表置信度加权,以观看次数的形式--通常情况下,用户观看电影的次数越多,他们实际上喜欢它的置信度就越高。

隐式偏好和置信度矩阵的表示

隐式模型仍然创建用户和物品因子矩阵。然而,在这种情况下,模型试图逼近的矩阵不是整体评分矩阵,而是偏好矩阵P。如果我们通过计算用户和物品因子向量的点积来计算推荐,得分将不是对评分的直接估计。它将更多地是对用户对物品的偏好的估计;尽管不严格在 0 到 1 之间,这些得分通常会相当接近 0 到 1 的范围。

简而言之,矩阵分解方法通过从评分模式中推断出的因子向量来表征用户和物品。用户和物品因子之间的高置信度或对应关系会导致推荐。两种主要的数据类型是显式反馈,如评分(由稀疏矩阵表示),和隐式反馈,如购买历史、搜索模式、浏览历史和点击流数据(由密集矩阵表示)。

矩阵分解的基本模型

用户和物品都被映射到维度为f的联合潜在因子空间中,用户-物品交互在该空间中被建模为内积。物品i与向量q相关联,其中q衡量物品具有潜在因子的程度,用户u与向量p相关联,其中p衡量用户对物品的兴趣程度。

qp之间的点积捕捉了用户u和物品I之间的交互,即用户对物品的兴趣。模型的关键是找到向量qp

设计模型,获取用户和物品之间的潜在关系。生成评分矩阵的低维表示。对评分矩阵执行 SVD 以获取QSP。将矩阵S降维到k维以获取qp

现在,计算推荐:

优化函数(对观察到的评分)如下图所示;学习潜在因子向量qp,系统最小化一组评分的正则化平方误差。

使用的学习算法是随机梯度下降SGD)或交替最小二乘ALS)。

交替最小二乘

ALS 是解决矩阵分解问题的优化技术;这种技术功能强大,性能良好,并且已被证明相对容易在并行环境中实现。因此,它非常适合像 Spark 这样的平台。在撰写本书时,它是 Spark ML 中唯一实现的推荐模型。

ALS 通过迭代地解决一系列最小二乘回归问题来工作。在每次迭代中,用户或物品因子矩阵中的一个被视为固定,而另一个则使用固定因子和评分数据进行更新。然后,解决的因子矩阵依次被视为固定,而另一个被更新。这个过程持续进行直到模型收敛(或者达到固定次数的迭代):

目标函数不是凸的,因为qp都是未知的,但是如果我们固定其中一个未知数,优化可以被解决。如前所述,ALS 在固定qp之间交替。

Spark 的协同过滤文档包含了支持 ALS 算法实现显式和隐式数据的论文的引用。您可以在 http://spark.apache.org/docs/latest/ml-collaborative-filtering.html 上查看文档。

以下代码解释了如何从头开始实现 ALS 算法。

让我们举个例子,展示它是如何实现的,并看一个真实的 3 部电影和 3 个用户的矩阵:

Array2DRowRealMatrix 
{{0.5306513708,0.5144338501,0.5183049}, 
{0.0612665269,0.0595122885,0.0611548878}, 
{0.3215637836,0.2964382622,0.1439834964}}

电影矩阵的第一次迭代是随机选择的:

ms = {RealVector[3]@3600} 
 0 = {ArrayRealVector@3605} "{0.489603683; 0.5979051631}" 
 1 = {ArrayRealVector@3606} "{0.2069873135; 0.4887559609}" 
 2 = {ArrayRealVector@3607} "{0.5286582698; 0.6787608323}"

用户矩阵的第一次迭代是随机选择的:

us = {RealVector[3]@3602} 
 0 = {ArrayRealVector@3611} "{0.7964247309; 0.091570682}" 
 1 = {ArrayRealVector@3612} "{0.4509758768; 0.0684475614}" 
 2 = {ArrayRealVector@3613} "{0.7812240904; 0.4180722562}"

挑选用户矩阵us的第一行,计算XtX(矩阵)和Xty(向量),如下面的代码所示:

m: {0.489603683; 0.5979051631} 
us: [Lorg.apache.commons.math3.linear.RealVector;@75961f16 
 XtX: Array2DRowRealMatrix{{0.0,0.0},{0.0,0.0}} 
 Xty: {0; 0}

j:0

u: {0.7964247309; 0.091570682} 
u.outerProduct(u): 
   Array2DRowRealMatrix{{0.634292352,0.0729291558},
   {0.0729291558,0.0083851898}} 
XtX = XtX.add(u.outerProduct(u)): 
   Array2DRowRealMatrix{{0.634292352,0.0729291558},
   {0.0729291558,0.0083851898}} 
R.getEntry(i, j)):0.5306513708051035 
u.mapMultiply(R.getEntry(i, j): {0.4226238752; 0.0485921079} 
Xty = Xty.add(u.mapMultiply(R.getEntry(i, j))): {0.4226238752; 
   0.0485921079}

挑选用户矩阵us的第二行,并按照下面的代码向XtX(矩阵)和Xty(向量)添加值:

j:1

u: {0.4509758768; 0.0684475614} 
u.outerProduct(u): Array2DRowRealMatrix{{0.2033792414,0.030868199},{0.030868199,0.0046850687}} 
XtX = XtX.add(u.outerProduct(u)): Array2DRowRealMatrix{{0.8376715935,0.1037973548},{0.1037973548,0.0130702585}} 
R.getEntry(i, j)):0.5144338501354986 
u.mapMultiply(R.getEntry(i, j): {0.2319972566; 0.0352117425} 
Xty = Xty.add(u.mapMultiply(R.getEntry(i, j))): {0.6546211318; 0.0838038505}

j:2

u: {0.7812240904; 0.4180722562} 
u.outerProduct(u): 
   Array2DRowRealMatrix{{0.6103110794,0.326608118},
   {0.326608118,0.1747844114}} 
XtX = XtX.add(u.outerProduct(u)): 
   Array2DRowRealMatrix{{1.4479826729,0.4304054728},
   {0.4304054728,0.1878546698}} 
R.getEntry(i, j)):0.5183049000396933 
u.mapMultiply(R.getEntry(i, j): {0.4049122741; 0.2166888989} 
Xty = Xty.add(u.mapMultiply(R.getEntry(i, j))): {1.0595334059; 
   0.3004927494} 
After Regularization XtX: 
   Array2DRowRealMatrix{{1.4779826729,0.4304054728},
   {0.4304054728,0.1878546698}} 
After Regularization XtX: Array2DRowRealMatrix{{1.4779826729,0.4304054728},{0.4304054728,0.2178546698}}

计算ms(使用XtXXtY的 Cholesky 分解的电影矩阵)的第一行的值:

CholeskyDecomposition{0.7422344051; -0.0870718111}

经过我们每一行的步骤后,我们得到了:

ms = {RealVector[3]@5078} 
 0 = {ArrayRealVector@5125} "{0.7422344051; -0.0870718111}" 
 1 = {ArrayRealVector@5126} "{0.0856607011; -0.007426896}" 
 2 = {ArrayRealVector@5127} "{0.4542083563; -0.392747909}"

列出了先前解释的数学实现的源代码:

object AlternatingLeastSquares { 

  var movies = 0 
  var users = 0 
  var features = 0 
  var ITERATIONS = 0 
  val LAMBDA = 0.01 // Regularization coefficient 

  private def vector(n: Int): RealVector = 
    new ArrayRealVector(Array.fill(n)(math.random)) 

  private def matrix(rows: Int, cols: Int): RealMatrix = 
    new Array2DRowRealMatrix(Array.fill(rows, cols)(math.random)) 

  def rSpace(): RealMatrix = { 
    val mh = matrix(movies, features) 
    val uh = matrix(users, features) 
    mh.multiply(uh.transpose()) 
  } 

  def rmse(targetR: RealMatrix, ms: Array[RealVector], us: 
   Array[RealVector]): Double = { 
    val r = new Array2DRowRealMatrix(movies, users) 
    for (i <- 0 until movies; j <- 0 until users) { 
      r.setEntry(i, j, ms(i).dotProduct(us(j))) 
    } 
    val diffs = r.subtract(targetR) 
    var sumSqs = 0.0 
    for (i <- 0 until movies; j <- 0 until users) { 
      val diff = diffs.getEntry(i, j) 
      sumSqs += diff * diff 
    } 
    math.sqrt(sumSqs / (movies.toDouble * users.toDouble)) 
  } 

  def update(i: Int, m: RealVector, us: Array[RealVector], R: 
   RealMatrix) : RealVector = { 
    val U = us.length 
    val F = us(0).getDimension 
    var XtX: RealMatrix = new Array2DRowRealMatrix(F, F) 
    var Xty: RealVector = new ArrayRealVector(F) 
    // For each user that rated the movie 
    for (j <- 0 until U) { 
      val u = us(j) 
      // Add u * u^t to XtX 
      XtX = XtX.add(u.outerProduct(u)) 
      // Add u * rating to Xty 
      Xty = Xty.add(u.mapMultiply(R.getEntry(i, j))) 
    } 
    // Add regularization coefs to diagonal terms 
    for (d <- 0 until F) { 
      XtX.addToEntry(d, d, LAMBDA * U) 
    } 
    // Solve it with Cholesky 
    new CholeskyDecomposition(XtX).getSolver.solve(Xty) 
  } 

  def main(args: Array[String]) { 

    movies = 100 
    users = 500 
    features = 10 
    ITERATIONS = 5 
    var slices = 2 

    val spark = 
     SparkSession.builder.master("local[2]").
     appName("AlternatingLeastS
   quares").getOrCreate() 
    val sc = spark.sparkContext 

    val r_space = rSpace() 

    // Initialize m and u randomly 
    var ms = Array.fill(movies)(vector(features)) 
    var us = Array.fill(users)(vector(features)) 

    // Iteratively update movies then users 
    val Rc = sc.broadcast(r_space) 
    var msb = sc.broadcast(ms) 
    var usb = sc.broadcast(us) 
    for (iter <- 1 to ITERATIONS) { 
      println(s"Iteration $iter:") 
      ms = sc.parallelize(0 until movies, slices) 
        .map(i => update(i, msb.value(i), usb.value, Rc.value)) 
        .collect() 
      msb = sc.broadcast(ms) // Re-broadcast ms because it was 
   updated 
      us = sc.parallelize(0 until users, slices) 
        .map(i => update(i, usb.value(i), msb.value, 
   Rc.value.transpose())) 
        .collect() 
      usb = sc.broadcast(us) // Re-broadcast us because it was 
   updated 
      println("RMSE = " + rmse(r_space, ms, us)) 
      println() 
    } 

    spark.stop() 
  } 
}

您可以在以下网址找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/2.0.0/scala-spark-app/src/main/scala/com/spark/recommendation/AlternatingLeastSquares.scala

从数据中提取正确的特征

在这一部分,我们将使用显式评分数据,没有额外的用户、物品元数据或其他与用户-物品交互相关的信息。因此,我们需要的输入特征只是用户 ID、电影 ID 和分配给每个用户和电影对的评分。

从 MovieLens 100k 数据集中提取特征

在这个例子中,我们将使用在上一章中使用的相同的 MovieLens 数据集。在下面的代码中,将使用放置 MovieLens 100k 数据集的目录作为输入路径。

首先,让我们检查原始评分数据集:

object FeatureExtraction { 

def getFeatures(): Dataset[FeatureExtraction.Rating] = { 
  val spark = SparkSession.builder.master("local[2]").appName("FeatureExtraction").getOrCreate() 

  import spark.implicits._ 
  val ratings = spark.read.textFile("/data/ml-100k 2/u.data").map(parseRating) 
  println(ratings.first()) 

  return ratings 
} 

case class Rating(userId: Int, movieId: Int, rating: Float) 
def parseRating(str: String): Rating = { 
  val fields = str.split("t") 
  Rating(fields(0).toInt, fields(1).toInt, fields(2).toFloat) 
}

您可以在以下网址找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/2.0.0/scala-spark-app/src/main/scala/com/spark/recommendation/FeatureExtraction.scala

您将看到类似于以下代码行的输出:

16/09/07 11:23:38 INFO CodeGenerator: Code generated in 7.029838 ms
16/09/07 11:23:38 INFO Executor: Finished task 0.0 in stage 0.0 (TID 
   0). 1276 bytes result sent to driver
16/09/07 11:23:38 INFO TaskSetManager: Finished task 0.0 in stage 0.0 
   (TID 0) in 82 ms on localhost (1/1)
16/09/07 11:23:38 INFO TaskSchedulerImpl: Removed TaskSet 0.0, whose 
   tasks have all completed, from pool
16/09/07 11:23:38 INFO DAGScheduler: ResultStage 0 (first at 
   FeatureExtraction.scala:25) finished in 0.106 s
16/09/07 11:23:38 INFO DAGScheduler: Job 0 finished: first at 
   FeatureExtraction.scala:25, took 0.175165 s
16/09/07 11:23:38 INFO CodeGenerator: Code generated in 6.834794 ms
Rating(196,242,3.0)

请记住,这个数据集(使用案例映射到Rating类)由userIDmovieIDratingtimestamp字段组成,由制表符("t")字符分隔。我们不需要训练模型时的评分时间,所以下面的代码片段中我们只是提取了前三个字段:

case class Rating(userId: Int, movieId: Int, rating: Float) 
def parseRating(str: String): Rating = { 
  val fields = str.split("t") 
  Rating(fields(0).toInt, fields(1).toInt, fields(2).toFloat) 
}

您可以在以下网址找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/2.0.0/scala-spark-app/src/main/scala/com/spark/recommendation/FeatureExtraction.scala

我们将首先将每条记录分割为"t"字符,这样我们就得到了一个String[]数组。然后我们将使用案例类来映射并保留数组的前3个元素,分别对应userIDmovieIDrating

训练推荐模型

一旦我们从原始数据中提取了这些简单的特征,我们就可以继续进行模型训练;ML 会为我们处理这些。我们所要做的就是提供正确解析的输入数据集以及我们选择的模型参数。

将数据集分割为训练集和测试集,比例为 80:20,如下面的代码所示:

def createALSModel() { 
  val ratings = FeatureExtraction.getFeatures(); 

  val Array(training, test) = ratings.randomSplit(Array(0.8, 0.2)) 
  println(training.first()) 
}

您可以在以下网址找到代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/2.0.0/scala-spark-app/src/main/scala/com/spark/recommendation/ALSModeling.scala

您将看到以下输出:

16/09/07 13:23:28 INFO Executor: Finished task 0.0 in stage 1.0 (TID 
   1). 1768 bytes result sent to driver
16/09/07 13:23:28 INFO TaskSetManager: Finished task 0.0 in stage 1.0 
   (TID 1) in 459 ms on localhost (1/1)
16/09/07 13:23:28 INFO TaskSchedulerImpl: Removed TaskSet 1.0, whose 
   tasks have all completed, from pool
16/09/07 13:23:28 INFO DAGScheduler: ResultStage 1 (first at 
   FeatureExtraction.scala:34) finished in 0.459 s
16/09/07 13:23:28 INFO DAGScheduler: Job 1 finished: first at 
   FeatureExtraction.scala:34, took 0.465730 s
Rating(1,1,5.0)

在 MovieLens 100k 数据集上训练模型

我们现在准备训练我们的模型!我们模型所需的其他输入如下:

  • rank:这指的是我们 ALS 模型中的因子数量,也就是我们低秩近似矩阵中的隐藏特征数量。通常来说,因子数量越多越好,但这直接影响内存使用,无论是计算还是存储模型用于服务,特别是对于大量用户或物品。因此,在实际应用中,这通常是一个权衡。它还影响所需的训练数据量。

  • 在 10 到 200 的范围内选择一个秩通常是合理的。

  • iterations:这是指要运行的迭代次数。虽然 ALS 中的每次迭代都保证会减少评级矩阵的重构误差,但 ALS 模型在相对较少的迭代后就会收敛到一个相当不错的解决方案。因此,在大多数情况下,我们不需要运行太多次迭代--大约 10 次通常是一个很好的默认值。

  • numBlocks:这是用户和物品将被分区成的块的数量,以并行化计算(默认为 10)。该数字取决于集群节点的数量以及数据的分区方式。

  • regParam:这指定 ALS 中的正则化参数(默认为 1.0)。常数λ称为正则化参数,如果用户和物品矩阵的分量过大(绝对值),它会对其进行惩罚。这对于数值稳定性很重要,几乎总是会使用某种形式的正则化。

  • implicitPrefs:这指定是否使用显式反馈 ALS 变体或者适用于隐式反馈数据的变体;默认为 false,表示使用显式反馈。

  • alpha:这是 ALS 隐式反馈变体适用的参数,它控制对偏好观察的基线置信度(默认为 1.0)。

  • nonnegative:这指定是否使用最小二乘法的非负约束(默认为false)。

我们将使用默认的rank5maxIter,以及regParam参数为0.01来说明如何训练我们的模型,如下面的代码所示:

// Build the recommendation model using ALS on the training data 
val als = new ALS() 
  .setMaxIter(5) 
  .setRegParam(0.01) 
  .setUserCol("userId") 
  .setItemCol("movieId") 
  .setRatingCol("rating") 

val model = als.fit(training)

您可以在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/2.0.0/scala-spark-app/src/main/scala/com/spark/recommendation/ALSModeling.scala找到代码清单。

这将返回一个ALSModel对象,其中包含用户和物品因子。它们分别称为userFactorsitemFactors

例如,model.userFactors

您将看到以下输出:

16/09/07 13:08:16 INFO MapPartitionsRDD: Removing RDD 16 from 
   persistence list
16/09/07 13:08:16 INFO BlockManager: Removing RDD 16
16/09/07 13:08:16 INFO Instrumentation: ALS-als_1ca69e2ffef7-
   10603412-1: training finished
16/09/07 13:08:16 INFO SparkContext: Invoking stop() from shutdown 
   hook
[id: int, features: array<float>]

我们可以看到这些因子的形式是Array[float]

MLlib 的 ALS 实现中使用的操作是惰性转换,因此实际计算只有在我们对用户和物品因子的 DataFrame 调用某种操作时才会执行。在下面的代码中,我们可以使用 Spark 操作(如count)来强制执行计算:

model.userFactors.count()

这将触发计算,并且我们将看到类似以下代码行的大量输出文本:

16/09/07 13:21:54 INFO Executor: Running task 0.0 in stage 53.0 (TID 
   166)
16/09/07 13:21:54 INFO ShuffleBlockFetcherIterator: Getting 10 non-
   empty blocks out of 10 blocks
16/09/07 13:21:54 INFO ShuffleBlockFetcherIterator: Started 0 remote 
   fetches in 0 ms
16/09/07 13:21:54 INFO Executor: Finished task 0.0 in stage 53.0 (TID 
   166). 1873 bytes result sent to driver
16/09/07 13:21:54 INFO TaskSetManager: Finished task 0.0 in stage 
   53.0 (TID 166) in 12 ms on localhost (1/1)
16/09/07 13:21:54 INFO TaskSchedulerImpl: Removed TaskSet 53.0, whose 
   tasks have all completed, from pool
16/09/07 13:21:54 INFO DAGScheduler: ResultStage 53 (count at 
   ALSModeling.scala:25) finished in 0.012 s
16/09/07 13:21:54 INFO DAGScheduler: Job 7 finished: count at 
   ALSModeling.scala:25, took 0.123073 s
16/09/07 13:21:54 INFO CodeGenerator: Code generated in 11.162514 ms
943

如果我们对电影因子调用count,将会使用以下代码完成:

model.itemFactors.count()

这将触发计算,并且我们将得到以下输出:

16/09/07 13:23:32 INFO TaskSetManager: Starting task 0.0 in stage 
   68.0 (TID 177, localhost, partition 0, ANY, 5276 bytes)
16/09/07 13:23:32 INFO Executor: Running task 0.0 in stage 68.0 (TID 
   177)
16/09/07 13:23:32 INFO ShuffleBlockFetcherIterator: Getting 10 non-
   empty blocks out of 10 blocks
16/09/07 13:23:32 INFO ShuffleBlockFetcherIterator: Started 0 remote 
   fetches in 0 ms
16/09/07 13:23:32 INFO Executor: Finished task 0.0 in stage 68.0 (TID 
   177). 1873 bytes result sent to driver
16/09/07 13:23:32 INFO TaskSetManager: Finished task 0.0 in stage 
   68.0 (TID 177) in 3 ms on localhost (1/1)
16/09/07 13:23:32 INFO TaskSchedulerImpl: Removed TaskSet 68.0, whose 
   tasks have all completed, from pool
16/09/07 13:23:32 INFO DAGScheduler: ResultStage 68 (count at 
   ALSModeling.scala:26) finished in 0.003 s
16/09/07 13:23:32 INFO DAGScheduler: Job 8 finished: count at 
   ALSModeling.scala:26, took 0.072450 s

1651

如预期的那样,我们为每个用户(943个因子)和每部电影(1651个因子)都有一个因子数组。

使用隐式反馈数据训练模型

MLlib 中的标准矩阵分解方法处理显式评分。要处理隐式数据,可以使用trainImplicit方法。它的调用方式类似于标准的train方法。还有一个额外的参数alpha,可以设置(同样,正则化参数lambda应该通过测试和交叉验证方法进行选择)。

alpha参数控制应用的基线置信度权重。较高水平的alpha倾向于使模型更加确信缺失数据意味着用户-物品对的偏好不存在。

从 Spark 版本 2.0 开始,如果评分矩阵是从其他信息推断出来的,即从其他信号中推断出来的,您可以将setImplicitPrefs设置为true以获得更好的结果,如下例所示:

val als = new ALS() 
  .setMaxIter(5) 
  .setRegParam(0.01) 
  .setImplicitPrefs(true) 
  .setUserCol("userId") 
  .setItemCol("movieId") 
  .setRatingCol("rating")

作为练习,尝试将现有的 MovieLens 数据集转换为隐式数据集。一种可能的方法是通过在某个水平上对评分应用阈值,将其转换为二进制反馈(0 和 1)。

另一种方法可能是将评分值转换为置信权重(例如,也许低评分可能意味着零权重,甚至是负权重,这是 MLlib 实现支持的)。

在此数据集上训练模型,并将以下部分的结果与您的隐式模型生成的结果进行比较。

使用推荐模型

现在我们已经训练好模型,准备使用它进行预测。

ALS 模型推荐

从 Spark v2.0 开始,org.apache.spark.ml.recommendation.ALS建模是因子分解算法的阻塞实现,它将“用户”和“产品”因子分组到块中,并通过在每次迭代时仅向每个产品块发送每个用户向量的一份副本,并且仅对需要该用户特征向量的产品块进行通信,从而减少通信。

在这里,我们将从电影数据集中加载评分数据,其中每一行包括用户、电影、评分和时间戳。然后我们将训练一个 ALS 模型,默认情况下该模型适用于显式偏好(implicitPrefsfalse)。我们将通过测量评分预测的均方根误差来评估推荐模型,具体如下:

object ALSModeling { 

  def createALSModel() { 
    val ratings = FeatureExtraction.getFeatures(); 

    val Array(training, test) = ratings.randomSplit(Array(0.8, 
   0.2)) 
    println(training.first()) 

    // Build the recommendation model using ALS on the training 
   data 
    val als = new ALS() 
      .setMaxIter(5) 
      .setRegParam(0.01) 
      .setUserCol("userId") 
      .setItemCol("movieId") 
      .setRatingCol("rating") 

    val model = als.fit(training) 
    println(model.userFactors.count()) 
    println(model.itemFactors.count()) 

    val predictions = model.transform(test) 
    println(predictions.printSchema()) 

}

您可以在以下链接找到代码列表:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/2.0.0/scala-spark-app/src/main/scala/com/spark/recommendation/ALSModeling.scala

以下是前述代码的输出:

16/09/07 17:58:42 INFO SparkContext: Created broadcast 26 from 
   broadcast at DAGScheduler.scala:1012
16/09/07 17:58:42 INFO DAGScheduler: Submitting 1 missing tasks from 
   ResultStage 67 (MapPartitionsRDD[138] at count at 
   ALSModeling.scala:31)
16/09/07 17:58:42 INFO TaskSchedulerImpl: Adding task set 67.0 with 1 
   tasks
16/09/07 17:58:42 INFO TaskSetManager: Starting task 0.0 in stage 
   67.0 (TID 176, localhost, partition 0, ANY, 5276 bytes)
16/09/07 17:58:42 INFO Executor: Running task 0.0 in stage 67.0 (TID 
   176)
16/09/07 17:58:42 INFO ShuffleBlockFetcherIterator: Getting 10 non-
   empty blocks out of 10 blocks
16/09/07 17:58:42 INFO ShuffleBlockFetcherIterator: Started 0 remote 
   fetches in 0 ms
16/09/07 17:58:42 INFO Executor: Finished task 0.0 in stage 67.0 (TID 
   176). 1960 bytes result sent to driver
16/09/07 17:58:42 INFO TaskSetManager: Finished task 0.0 in stage 
   67.0 (TID 176) in 3 ms on localhost (1/1)
16/09/07 17:58:42 INFO TaskSchedulerImpl: Removed TaskSet 67.0, whose 
   tasks have all completed, from pool
16/09/07 17:58:42 INFO DAGScheduler: ResultStage 67 (count at 
   ALSModeling.scala:31) finished in 0.003 s
16/09/07 17:58:42 INFO DAGScheduler: Job 7 finished: count at 
   ALSModeling.scala:31, took 0.060748 s
100
root
 |-- userId: integer (nullable = true)
 |-- movieId: integer (nullable = true)
 |-- rating: float (nullable = true)
 |-- timestamp: long (nullable = true)
 |-- prediction: float (nullable = true)

在我们继续之前,请注意以下关于用户和物品推荐的示例使用了 Spark v1.6 的 MLlib。请按照代码列表获取使用org.apache.spark.mllib.recommendation.ALS创建推荐模型的详细信息。

用户推荐

在这种情况下,我们希望为给定的用户生成推荐的物品。这通常采用top-K列表的形式,即我们的模型预测用户最有可能喜欢的K个物品。这是通过计算每个物品的预测得分并根据这个得分对列表进行排名来实现的。

执行此计算的确切方法取决于所涉及的模型。例如,在基于用户的方法中,使用相似用户对物品的评分来计算对用户的推荐;而在基于物品的方法中,计算基于用户评分的物品与候选物品的相似性。

在矩阵分解中,因为我们直接对评分矩阵进行建模,所以预测得分可以通过用户因子向量和物品因子向量之间的向量点积来计算。

从 MovieLens 100k 数据集生成电影推荐

由于 MLlib 的推荐模型是基于矩阵分解的,我们可以使用模型计算出的因子矩阵来计算用户的预测分数(或评分)。我们将专注于使用 MovieLens 数据的显式评分情况;然而,使用隐式模型时,方法是相同的。

MatrixFactorizationModel类有一个方便的predict方法,可以计算给定用户和项目组合的预测分数,如下面的代码所示:

val predictedRating = model.predict(789, 123)

输出如下:

14/03/30 16:10:10 INFO SparkContext: Starting job: lookup at 
   MatrixFactorizationModel.scala:45
14/03/30 16:10:10 INFO DAGScheduler: Got job 30 (lookup at 
   MatrixFactorizationModel.scala:45) with 1 output partitions 
   (allowLocal=false)
...
14/03/30 16:10:10 INFO SparkContext: Job finished: lookup at 
   MatrixFactorizationModel.scala:46, took 0.023077 s
predictedRating: Double = 3.128545693368485

正如我们所看到的,这个模型预测用户789对电影123的评分为3.12

请注意,您可能会看到与本节中显示的结果不同的结果,因为 ALS 模型是随机初始化的。因此,模型的不同运行将导致不同的解决方案。

predict方法也可以接受一个(user, item) ID 的 RDD 作为输入,并为每个生成预测。我们可以使用这个方法同时为许多用户和项目进行预测。

为了为用户生成top-K推荐项目,MatrixFactorizationModel提供了一个方便的方法叫做recommendProducts。这需要两个参数:usernum,其中user是用户 ID,num是要推荐的项目数。

它返回按预测分数排序的前num个项目。在这里,分数是通过用户因子向量和每个项目因子向量之间的点积计算的。

让我们按照以下方式为用户789生成前10个推荐项目:

val userId = 789 
val K = 10 
val topKRecs = model.recommendProducts(userId, K)

现在,我们已经为用户789的每部电影预测了一组评分。如果我们打印出来,通过编写以下代码行,我们可以检查这个用户的前10个推荐:

println(topKRecs.mkString("n"))

您应该在控制台上看到以下输出:

Rating(789,715,5.931851273771102)
Rating(789,12,5.582301095666215)
Rating(789,959,5.516272981542168)
Rating(789,42,5.458065302395629)
Rating(789,584,5.449949837103569)
Rating(789,750,5.348768847643657)
Rating(789,663,5.30832117499004)
Rating(789,134,5.278933936827717)
Rating(789,156,5.250959077906759)
Rating(789,432,5.169863417126231)

检查推荐

我们可以通过快速查看用户评价过的电影和推荐的电影的标题来对这些推荐进行一次检查。首先,我们需要加载电影数据,这是我们在上一章中探讨的数据集之一。在下面的代码中,我们将收集这些数据作为Map[Int, String]方法,将电影 ID 映射到标题:

val movies = sc.textFile("/PATH/ml-100k/u.item") 
val titles = movies.map(line => 
   line.split("|").take(2)).map(array => (array(0).toInt,
   array(1))).collectAsMap() 
titles(123)

上述代码将产生以下输出:

res68: String = Frighteners, The (1996)

对于我们的用户789,我们可以找出他们评价过的电影,取得评分最高的10部电影,然后检查标题。我们将首先使用keyBy Spark 函数从我们的ratings RDD 中创建一个键值对的 RDD,其中键将是用户 ID。然后,我们将使用lookup函数将这个键(即特定的用户 ID)的评分返回给驱动程序,如下所述:

val moviesForUser = ratings.keyBy(_.user).lookup(789)

让我们看看这个用户评价了多少部电影。这将是moviesForUser集合的size

println(moviesForUser.size)

我们将看到这个用户已经评价了33部电影。

接下来,我们将通过对moviesForUser集合使用Rating对象的rating字段进行排序,取得评分最高的10部电影。然后,我们将从我们的电影标题映射中提取相关产品 ID 附加到Rating类的电影标题,并打印出带有其评分的前10个标题,如下所示:

moviesForUser.sortBy(-_.rating).take(10).map(rating => 
   (titles(rating.product), rating.rating)).foreach(println)

您将看到以下输出显示:

(Godfather, The (1972),5.0)
(Trainspotting (1996),5.0)
(Dead Man Walking (1995),5.0)
(Star Wars (1977),5.0)
(Swingers (1996),5.0)
(Leaving Las Vegas (1995),5.0)
(Bound (1996),5.0)
(Fargo (1996),5.0)
(Last Supper, The (1995),5.0)
(Private Parts (1997),4.0)

现在,让我们看看这个用户的前10个推荐,并查看标题,使用与我们之前使用的相同方法(请注意,推荐已经排序):

topKRecs.map(rating => (titles(rating.product), 
   rating.rating)).foreach(println)

输出如下:

(To Die For (1995),5.931851273771102)
(Usual Suspects, The (1995),5.582301095666215)
(Dazed and Confused (1993),5.516272981542168)
(Clerks (1994),5.458065302395629)
(Secret Garden, The (1993),5.449949837103569)
(Amistad (1997),5.348768847643657)
(Being There (1979),5.30832117499004)
(Citizen Kane (1941),5.278933936827717)
(Reservoir Dogs (1992),5.250959077906759)
(Fantasia (1940),5.169863417126231)

我们留给您决定这些推荐是否有意义。

项目推荐

项目推荐是关于回答以下问题的:对于某个项目,与之最相似的项目是什么?在这里,相似性的精确定义取决于所涉及的模型。在大多数情况下,相似性是通过使用某些相似性度量来比较两个项目的向量表示来计算的。常见的相似性度量包括皮尔逊相关系数和余弦相似度用于实值向量,以及杰卡德相似度用于二进制向量。

为 MovieLens 100k 数据集生成相似的电影

当前的MatrixFactorizationModelAPI 不直接支持项目之间的相似度计算。因此,我们需要创建自己的代码来完成这个任务。

我们将使用余弦相似度度量,并使用 jblas 线性代数库(MLlib 的依赖项)来计算所需的向量点积。这类似于现有的predictrecommendProducts方法的工作方式,只是我们将使用余弦相似度而不仅仅是点积。

我们想要使用我们的相似度度量来比较我们选择的项目的因子向量与其他项目的因子向量。为了执行线性代数计算,我们首先需要从因子向量中创建一个向量对象,这些因子向量的形式是Array[Double]JBLASDoubleMatrixArray[Double]作为构造函数参数,如下所示:

import org.jblas.DoubleMatrix

使用以下构造函数从数组实例化DoubleMatrix

jblas类是一个用 Java 编写的线性代数库。它基于 BLAS 和 LAPACK,是矩阵计算的事实行业标准,并使用像ATLAS这样的实现来进行计算例程,使得 jBLAS 非常快速。

它是对 BLAS 和 LAPACK 例程的轻量级封装。BLAS 和 LAPACK 包起源于 Fortran 社区。

让我们看一个例子:

public DoubleMatrix(double[] newData)

使用newData作为数据数组创建一个列向量。对创建的DoubleMatrix的任何更改都将在输入数组newData中进行更改。

让我们创建一个简单的DoubleMatrix

val aMatrix = new DoubleMatrix(Array(1.0, 2.0, 3.0))

以下是前面代码的输出:

aMatrix: org.jblas.DoubleMatrix = [1.000000; 2.000000; 3.000000]

请注意,使用 jblas,向量表示为一维的DoubleMatrix类,而矩阵表示为二维的DoubleMatrix类。

我们需要一个方法来计算两个向量之间的余弦相似度。余弦相似度是n维空间中两个向量之间角度的度量。首先计算向量之间的点积,然后将结果除以分母,分母是每个向量的范数(或长度)相乘在一起(具体来说,余弦相似度中使用 L2 范数)。

在线性代数中,向量的大小称为的范数。我们将讨论几种不同类型的范数。在本讨论中,我们将向量 v 定义为一组有序的数字。

一范数:向量的一范数(也称为 L1 范数或均值范数)如下图所示,并定义为其组件的绝对值的总和:

二范数(也称为 L2 范数、均方根范数、最小二乘范数)

向量的范数如下图所示:

此外,它被定义为其组件的绝对值的平方和的平方根:

这样,余弦相似度是一个归一化的点积。余弦相似度测量值介于-11之间。值为1意味着完全相似,而值为 0 意味着独立(即没有相似性)。这个度量是有用的,因为它还捕捉到了负相似性,即值为-1意味着向量不仅不相似,而且完全不相似:

让我们在这里创建我们的cosineSimilarity函数:

def cosineSimilarity(vec1: DoubleMatrix, vec2: DoubleMatrix): Double = { 
  vec1.dot(vec2) / (vec1.norm2() * vec2.norm2()) 
}

请注意,我们为这个函数定义了一个Double的返回类型。虽然 Scala 具有类型推断功能,我们并不需要这样做。然而,为 Scala 函数记录返回类型通常是有用的。

让我们尝试对项目567的项目因子之一进行操作。我们需要从我们的模型中收集一个项目因子;我们将使用lookup方法来做到这一点,方式与我们之前收集特定用户的评分的方式类似。在下面的代码行中,我们还将使用head函数,因为lookup返回一个值数组,我们只需要第一个值(实际上,只会有一个值,即这个项目的因子向量)。

由于这将是一个构造函数Array[Double],因此我们需要从中创建一个DoubleMatrix对象,并计算与自身的余弦相似度,如下所示:

val itemId = 567 
val itemFactor = model.productFeatures.lookup(itemId).head 
val itemVector = new DoubleMatrix(itemFactor) 
cosineSimilarity(itemVector, itemVector)

相似度度量应该衡量两个向量在某种意义上的接近程度。在下面的示例中,我们可以看到我们的余弦相似度度量告诉我们,这个项目向量与自身相同,这是我们所期望的。

res113: Double = 1.0

现在,我们准备将我们的相似度度量应用于每个项目,如下所示:

val sims = model.productFeatures.map{ case (id, factor) => 
  val factorVector = new DoubleMatrix(factor) 
  val sim = cosineSimilarity(factorVector, itemVector) 
  (id, sim) 
}

接下来,我们可以通过对每个项目的相似度分数进行排序来计算前 10 个最相似的项目:

// recall we defined K = 10 earlier 
val sortedSims = sims.top(K)(Ordering.by[(Int, Double), Double] { 
   case (id, similarity) => similarity })

在上述代码片段中,我们使用了 Spark 的top函数,这是一种在分布式方式中计算top-K结果的高效方法,而不是使用collect将所有数据返回到驱动程序并在本地进行排序(请记住,在推荐模型的情况下,我们可能会处理数百万用户和项目)。

我们需要告诉 Spark 如何对sims RDD 中的(项目 ID,相似度分数)对进行排序。为此,我们将传递一个额外的参数给top,这是一个 ScalaOrdering对象,告诉 Spark 应该按照键值对中的值进行排序(即按照相似度进行排序)。

最后,我们可以打印与给定项目计算出的最高相似度度量的 10 个项目:

println(sortedSims.take(10).mkString("n"))

您将看到以下类似的输出:

(567,1.0000000000000002)
(1471,0.6932331537649621)
(670,0.6898690594544726)
(201,0.6897964975027041)
(343,0.6891221044611473)
(563,0.6864214133620066)
(294,0.6812075443259535)
(413,0.6754663844488256)
(184,0.6702643811753909)
(109,0.6594872765176396)

毫不奇怪,我们可以看到排名最高的相似项是我们的项目。其余的是我们项目集中的其他项目,按照我们的相似度度量进行排名。

检查相似项目

让我们看看我们选择的电影的标题是什么:

println(titles(itemId))

上述代码将打印以下输出:

    Wes Craven's New Nightmare (1994)

与用户推荐一样,我们可以对项目之间的相似性计算进行感知检查,并查看最相似电影的标题。这次,我们将取前 11 个,以便排除给定的电影。因此,我们将在列表中取 1 到 11 的数字:

val sortedSims2 = sims.top(K + 1)(Ordering.by[(Int, Double), 
   Double] { case (id, similarity) => similarity }) 
sortedSims2.slice(1, 11).map{ case (id, sim) => (titles(id), sim) 
   }.mkString("n")

您将看到显示电影标题和分数的输出类似于此输出:

(Hideaway (1995),0.6932331537649621)
(Body Snatchers (1993),0.6898690594544726)
(Evil Dead II (1987),0.6897964975027041)
(Alien: Resurrection (1997),0.6891221044611473)
(Stephen King's The Langoliers (1995),0.6864214133620066)
(Liar Liar (1997),0.6812075443259535)
(Tales from the Crypt Presents: Bordello of Blood (1996),0.6754663844488256)
(Army of Darkness (1993),0.6702643811753909)
(Mystery Science Theater 3000: The Movie (1996),0.6594872765176396)
(Scream (1996),0.6538249646863378)

再次注意,由于随机模型初始化,您可能会看到完全不同的结果。

现在,您已经使用余弦相似度计算了相似的项目,请尝试对用户因子向量执行相同操作,以计算给定用户的相似用户。

评估推荐模型的性能

我们如何知道我们训练的模型是否是一个好模型?我们需要能够以某种方式评估其预测性能。评估指标是模型预测能力或准确性的度量。有些是直接衡量模型预测模型目标变量的能力,例如均方误差,而其他指标则关注模型在预测可能不会直接优化的事物方面的表现,但通常更接近我们在现实世界中关心的内容,例如平均精度。

评估指标提供了一种标准化的方式,用于比较具有不同参数设置的相同模型的性能,并比较跨不同模型的性能。使用这些指标,我们可以执行模型选择,从我们希望评估的模型集中选择表现最佳的模型。

在这里,我们将向您展示如何计算推荐系统和协同过滤模型中使用的两个常见评估指标:均方误差MSE)和K 处的平均精度MAPK)。

ALS 模型评估

从 Spark v2.0 开始,我们将使用org.apache.spark.ml.evaluation.RegressionEvaluator来解决回归问题。回归评估是衡量拟合模型在留出测试数据上表现如何的度量标准。在这里,我们将使用均方根误差RMSE),它只是 MSE 度量的平方根:

object ALSModeling { 

  def createALSModel() { 
    val ratings = FeatureExtraction.getFeatures(); 

    val Array(training, test) = ratings.randomSplit(Array(0.8, 0.2)) 
    println(training.first()) 

    // Build the recommendation model using ALS on the training data 
    val als = new ALS() 
      .setMaxIter(5) 
      .setRegParam(0.01) 
      .setUserCol("userId") 
      .setItemCol("movieId") 
      .setRatingCol("rating") 

    val model = als.fit(training) 
    println(model.userFactors.count()) 
    println(model.itemFactors.count()) 

    val predictions = model.transform(test) 
    println(predictions.printSchema()) 

    val evaluator = new RegressionEvaluator() 
      .setMetricName("rmse") 
      .setLabelCol("rating") 
      .setPredictionCol("prediction") 
    val rmse = evaluator.evaluate(predictions) 

    println(s"Root-mean-square error = $rmse") 
  } 

  def main(args: Array[String]) { 
    createALSModel() 
  } 

}

你可以在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/2.0.0/scala-spark-app/src/main/scala/com/spark/recommendation/ALSModeling.scala找到代码清单。

你将看到如下输出:

16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 4 non-
   empty blocks out of 200 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 2 non-
   empty blocks out of 200 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote 
   fetches in 0 ms
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote 
   fetches in 0 ms
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 1 non-
   empty blocks out of 10 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 1 non-
   empty blocks out of 10 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote 
   fetches in 0 ms
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote 
   fetches in 0 ms
Root-mean-square error = 2.1487554400294777

在我们进一步进行之前,请注意以下评估示例使用 Spark v1.6 中的 MLLib。请按照代码清单获取使用org.apache.spark.mllib.recommendation.ALS创建推荐模型的详细信息。

均方误差

MSE 是用户-物品评分矩阵重建误差的直接度量。它也是某些模型中被最小化的目标函数,特别是包括 ALS 在内的许多矩阵分解技术。因此,在显式评分设置中通常使用它。

它被定义为平方误差之和除以观察次数。而平方误差则是给定用户-物品对的预测评分与实际评分之间的差的平方。

我们将以用户789为例。让我们从之前计算的moviesForUser集合的Ratings中取出该用户的第一个评分:

val actualRating = moviesForUser.take(1)(0)

以下是输出:

actualRating: org.apache.spark.mllib.recommendation.Rating = 
   Rating(789,1012,4.0)

我们将看到该用户-物品组合的评分为 4。接下来,我们将计算模型的预测评分:

val predictedRating = model.predict(789, actualRating.product)

模型预测评分的输出如下:

...
14/04/13 13:01:15 INFO SparkContext: Job finished: lookup at MatrixFactorizationModel.scala:46, took 0.025404 s
predictedRating: Double = 4.001005374200248

我们将看到预测评分约为 4,非常接近实际评分。最后,我们将计算实际评分和预测评分之间的平方误差:

val squaredError = math.pow(predictedRating - actualRating.rating, 
   2.0)

上述代码将输出平方误差:

squaredError: Double = 1.010777282523947E-6

因此,为了计算数据集的整体 MSE,我们需要为每个(用户电影实际评分预测评分)条目计算这个平方误差,将它们相加,然后除以评分数量。我们将在以下代码片段中执行此操作。

注意:以下代码改编自 Apache Spark ALS 的编程指南,网址为spark.apache.org/docs/latest/mllib-collaborative-filtering.html

首先,我们将从ratings RDD 中提取用户和产品 ID,并使用model.predict对每个用户-物品对进行预测。我们将使用用户-物品对作为键,预测评分作为值:

val usersProducts = ratings.map{ case Rating(user, product, 
   rating)  => (user, product)} 
val predictions = model.predict(usersProducts).map{ 
    case Rating(user, product, rating) => ((user, product), 
   rating) 
}

接下来,我们将提取实际评分,并将ratings RDD 映射,使用户-物品对成为键,实际评分成为值。现在我们有了两个具有相同键形式的 RDD,我们可以将它们连接在一起,创建一个新的 RDD,其中包含每个用户-物品组合的实际和预测评分:

val ratingsAndPredictions = ratings.map{ 
  case Rating(user, product, rating) => ((user, product), rating) 
}.join(predictions)

最后,我们将通过使用reduce求和平方误差,并除以记录数量的count方法来计算 MSE:

val MSE = ratingsAndPredictions.map{ 
    case ((user, product), (actual, predicted)) =>  math.pow((actual - predicted), 2) 
}.reduce(_ + _) / ratingsAndPredictions.count 
println("Mean Squared Error = " + MSE)

输出如下:

Mean Squared Error = 0.08231947642632852

通常使用 RMSE,它只是 MSE 度量的平方根。这更具可解释性,因为它与基础数据(即本例中的评分)具有相同的单位。它相当于预测和实际评分之间差异的标准差。我们可以简单地计算如下:

val RMSE = math.sqrt(MSE) 
println("Root Mean Squared Error = " + RMSE)

上述代码将打印 RMSE:

Root Mean Squared Error = 0.2869137090247319

为了解释前面的结果,请记住以下定义。降低 RMSE 值意味着预测值与实际值的拟合更好。在解释 RMSE 时,请记住实际数据的最小值和最大值。

K 处的平均精度

K处的平均精度是数据集中所有实例的K 处的平均精度(APK)指标的平均值。APK 是信息检索常用的度量标准。APK 是对响应查询呈现的top-K文档的平均相关性分数的度量。对于每个查询实例,我们将top-K结果集与实际相关文档集进行比较,也就是查询的真实相关文档集。

在 APK 指标中,结果集的顺序很重要,如果结果文档既相关又相关文档在结果中排名较高,则 APK 得分会更高。因此,这是推荐系统的一个很好的指标;通常,我们会为每个用户计算top-K推荐的项目,并将这些项目呈现给用户。当然,我们更喜欢那些具有最高预测分数的项目的模型,这些项目在推荐列表的顶部呈现时,实际上是用户最相关的项目。APK 和其他基于排名的指标也更适合隐式数据集的评估指标;在这里,MSE 没有太多意义。

为了评估我们的模型,我们可以使用 APK,其中每个用户相当于一个查询,而top-K推荐项目集是文档结果集。相关文档,也就是在这种情况下的真相,是用户交互的项目集。因此,APK 试图衡量我们的模型在预测用户会发现相关并选择与之交互的项目方面有多好。

以下平均精度计算的代码基于github.com/benhamner/Metrics

更多关于 MAPK 的信息可以在www.kaggle.com/wiki/MeanAveragePrecision找到。

我们的计算 APK 的函数如下所示:

def avgPrecisionK(actual: Seq[Int], predicted: Seq[Int], k: Int): 
   Double = { 
    val predK = predicted.take(k) 
    var score = 0.0 
    var numHits = 0.0 
    for ((p, i) <- predK.zipWithIndex) { 
      if (actual.contains(p)) { 
        numHits += 1.0 
        score += numHits / (i.toDouble + 1.0) 
      } 
    } 
    if (actual.isEmpty) { 
      1.0 
    } else { 
      score / scala.math.min(actual.size, k).toDouble 
    } 
  }

如您所见,这需要输入一个与用户相关联的“实际”项目 ID 列表和另一个“预测”ID 列表,以便我们的估计对用户是相关的。

我们可以计算我们示例用户789的 APK 指标如下。首先,我们将提取用户的实际电影 ID,如下所示:

val actualMovies = moviesForUser.map(_.product)

输出如下:

actualMovies: Seq[Int] = ArrayBuffer(1012, 127, 475, 93, 1161, 286, 
   293, 9, 50, 294, 181, 1, 1008, 508, 284, 1017, 137, 111, 742, 248, 
   249, 1007, 591, 150, 276, 151, 129, 100, 741, 288, 762, 628, 124)

然后,我们将使用先前制作的电影推荐来使用K = 10计算 APK 得分:

val predictedMovies = topKRecs.map(_.product)

这是输出:

predictedMovies: Array[Int] = Array(27, 497, 633, 827, 602, 849, 401, 
   584, 1035, 1014)

以下代码将产生平均精度:

val apk10 = avgPrecisionK(actualMovies, predictedMovies, 10)

前面的代码将打印以下命令行:

apk10: Double = 0.0

在这种情况下,我们可以看到我们的模型并没有很好地预测这个用户的相关电影,因为 APK 得分为0

为了计算每个用户的 APK 并对其进行平均以计算整体 MAPK,我们需要为数据集中的每个用户生成推荐列表。虽然这在大规模上可能相当密集,但我们可以使用我们的 Spark 功能来分发计算。然而,一个限制是每个工作节点必须有完整的项目因子矩阵可用,以便它可以计算相关用户向量和所有项目向量之间的点积。当项目数量非常高时,这可能是一个问题,因为项目矩阵必须适合一个机器的内存中。

实际上,没有简单的方法可以解决这个限制。一种可能的方法是仅使用近似技术,如局部敏感哈希(en.wikipedia.org/wiki/Locality-sensitive_hashing),为总项目集的子集计算推荐。

我们现在将看看如何做。首先,我们将收集项目因子并从中形成一个DoubleMatrix对象:

val itemFactors = model.productFeatures.map { case (id, factor) => 
   factor }.collect() 
val itemMatrix = new DoubleMatrix(itemFactors) 
println(itemMatrix.rows, itemMatrix.columns)

前面代码的输出如下:

(1682,50)

这给我们一个具有1682行和50列的矩阵,这是我们从1682部电影中期望的因子维度为50的矩阵。接下来,我们将将项目矩阵作为广播变量分发,以便它在每个工作节点上都可用:

val imBroadcast = sc.broadcast(itemMatrix)

您将看到以下输出:

14/04/13 21:02:01 INFO MemoryStore: ensureFreeSpace(672960) called 
   with curMem=4006896, maxMem=311387750
14/04/13 21:02:01 INFO MemoryStore: Block broadcast_21 stored as 
   values to memory (estimated size 657.2 KB, free 292.5 MB)
imBroadcast: 
   org.apache.spark.broadcast.Broadcast[org.jblas.DoubleMatrix] = 
   Broadcast(21)

现在我们准备为每个用户计算推荐。我们将通过对每个用户因子应用map函数来执行用户因子向量和电影因子矩阵之间的矩阵乘法来实现这一点。结果是一个向量(长度为1682,即我们拥有的电影数量),其中包含每部电影的预测评分。然后,我们将按预测评分对这些预测进行排序:

val allRecs = model.userFeatures.map{ case (userId, array) => 
  val userVector = new DoubleMatrix(array) 
  val scores = imBroadcast.value.mmul(userVector) 
  val sortedWithId = scores.data.zipWithIndex.sortBy(-_._1) 
  val recommendedIds = sortedWithId.map(_._2 + 1).toSeq 
  (userId, recommendedIds) 
}

您将在屏幕上看到以下内容:

allRecs: org.apache.spark.rdd.RDD[(Int, Seq[Int])] = MappedRDD[269] 
   at map at <console>:29

我们现在有一个 RDD,其中包含每个用户 ID 的电影 ID 列表。这些电影 ID 按照估计的评分顺序排列。

请注意,我们需要将返回的电影 ID 加 1(如前面的代码片段中所示),因为项目因子矩阵是从 0 开始索引的,而我们的电影 ID 从1开始。

我们还需要每个用户的电影 ID 列表,作为actual参数传递给我们的 APK 函数。我们已经准备好了ratings RDD,所以我们可以从中提取用户和电影 ID。

如果我们使用 Spark 的groupBy操作符,我们将得到一个 RDD,其中包含每个用户 ID 的(userid, movieid)对列表(因为用户 ID 是我们执行groupBy操作的键),如下所示:

val userMovies = ratings.map{ case Rating(user, product, rating) 
   => (user, product) }.groupBy(_._1)

上述代码的输出如下:

userMovies: org.apache.spark.rdd.RDD[(Int, Seq[(Int, Int)])] = 
  MapPartitionsRDD[277] at groupBy at <console>:21

最后,我们可以使用 Spark 的join操作符在用户 ID 键上将这两个 RDD 连接在一起。然后,对于每个用户,我们有实际和预测的电影 ID 列表,我们可以将其传递给我们的 APK 函数。类似于我们计算 MSE 的方式,我们将使用reduce操作来对这些 APK 分数进行求和,并除以用户数量,即allRecs RDD 的计数,如下面的代码所示:

val K = 10 
val MAPK = allRecs.join(userMovies).map{ case (userId, (predicted, actualWithIds)) => 
  val actual = actualWithIds.map(_._2).toSeq 
  avgPrecisionK(actual, predicted, K) 
}.reduce(_ + _) / allRecs.count 
println("Mean Average Precision at K = " + MAPK)

上述代码将打印K处的平均精度如下:

Mean Average Precision at K = 0.030486963254725705

我们的模型实现了一个相当低的 MAPK。但是,请注意,推荐任务的典型值通常相对较低,特别是如果项目集非常大的话。

尝试一些lambdarank(如果您使用 ALS 的隐式版本,则还有alpha)的参数设置,并查看是否可以找到基于 RMSE 和 MAPK 评估指标表现更好的模型。

使用 MLlib 的内置评估函数

虽然我们已经从头开始计算了 MSE、RMSE 和 MAPK,这是一个有用的学习练习,但是 MLlib 提供了方便的函数来在RegressionMetricsRankingMetrics类中为我们执行这些操作。

RMSE 和 MSE

首先,我们将使用RegressionMetrics计算 MSE 和 RMSE 指标。我们将通过传入表示每个数据点的预测和真实值的键值对 RDD 来实例化RegressionMetrics实例,如下面的代码片段所示。在这里,我们将再次使用我们在之前示例中计算的ratingsAndPredictions RDD:

import org.apache.spark.mllib.evaluation.RegressionMetrics 
val predictedAndTrue = ratingsAndPredictions.map { case ((user, 
   product), (predicted, actual)) => (predicted, actual) } 
val regressionMetrics = new RegressionMetrics(predictedAndTrue)

然后,我们可以访问各种指标,包括 MSE 和 RMSE。我们将在这里打印出这些指标:

println("Mean Squared Error = " + 
   regressionMetrics.meanSquaredError) 
println("Root Mean Squared Error = " + 
   regressionMetrics.rootMeanSquaredError)

在以下命令行中,您将看到 MSE 和 RMSE 的输出与我们之前计算的指标完全相同:

Mean Squared Error = 0.08231947642632852
Root Mean Squared Error = 0.2869137090247319

MAP

正如我们对 MSE 和 RMSE 所做的那样,我们可以使用 MLlib 的RankingMetrics类来计算基于排名的评估指标。类似地,与我们自己的平均精度函数一样,我们需要传入一个键值对的 RDD,其中键是用户的预测项目 ID 数组,而值是实际项目 ID 的数组。

RankingMetrics中,平均精度在 K 函数的实现与我们的略有不同,因此我们将得到不同的结果。但是,如果我们选择K非常高(比如至少与我们的项目集中的项目数量一样高),则整体平均精度(MAP,不使用 K 阈值)的计算与我们的函数相同。

首先,我们将使用RankingMetrics计算 MAP 如下:

import org.apache.spark.mllib.evaluation.RankingMetrics 
val predictedAndTrueForRanking = allRecs.join(userMovies).map{ 
   case (userId, (predicted, actualWithIds)) => 
    val actual = actualWithIds.map(_._2) 
    (predicted.toArray, actual.toArray) 
} 
val rankingMetrics = new 
   RankingMetrics(predictedAndTrueForRanking) 
println("Mean Average Precision = " + 
   rankingMetrics.meanAveragePrecision)

您将在屏幕上看到以下输出:

Mean Average Precision = 0.07171412913757183

接下来,我们将使用我们的函数以与之前完全相同的方式计算 MAP,只是将K设置为一个非常高的值,比如2000

val MAPK2000 = allRecs.join(userMovies).map{ case (userId, 
   (predicted, actualWithIds)) => 
  val actual = actualWithIds.map(_._2).toSeq 
  avgPrecisionK(actual, predicted, 2000) 
}.reduce(_ + _) / allRecs.count 
println("Mean Average Precision = " + MAPK2000)

您将看到我们自己函数计算的 MAP 与使用RankingMetrics计算的 MAP 相同:

Mean Average Precision = 0.07171412913757186.

我们将不在本章涵盖交叉验证,因为我们将在接下来的几章中提供详细的处理。但是,请注意,探讨在即将到来的章节中探索的交叉验证技术可以用于使用 MSE、RMSE 和 MAP 等性能指标来评估推荐模型的性能,这些指标我们在本节中已经涵盖。

FP-Growth 算法

我们将应用 FP-Growth 算法来找出经常推荐的电影。

FP-Growth 算法已在 Han 等人的论文中描述,Mining frequent patterns without candidate generation,可在dx.doi.org/10.1145/335191.335372上找到,其中FP代表frequent pattern。对于给定的交易数据集,FP-Growth 的第一步是计算项目频率并识别频繁项目。FP-Growth 算法实现的第二步使用后缀树(FP-tree)结构来编码交易;这是在不显式生成候选集的情况下完成的,通常对于大型数据集来说生成候选集是昂贵的。

FP-Growth 基本示例

让我们从一个非常简单的随机数字数据集开始:

val transactions = Seq( 
      "r z h k p", 
      "z y x w v u t s", 
      "s x o n r", 
      "x z y m t s q e", 
      "z", 
      "x z y r q t p") 
      .map(_.split(" "))

我们将找出最频繁的项目(在本例中是字符)。首先,我们将按如下方式获取 Spark 上下文:

val sc = new SparkContext("local[2]", "Chapter 5 App")

将我们的数据转换为 RDD:

val rdd = sc.parallelize(transactions, 2).cache()

初始化FPGrowth实例:

val fpg = new FPGrowth()

FP-Growth 可以配置以下参数:

  • minSupport:被识别为频繁项集的最小支持数。例如,如果一个项目在 10 个交易中出现 3 次,则其支持率为 3/10=0.3。

  • numPartitions:要分发工作的分区数。

设置minsupport和 FP-Growth 实例的分区数,并在 RDD 对象上调用 run。分区数应设置为数据集中的分区数--数据将从中加载的工作节点数,如下所示:

val model = fpg.setMinSupport(0.2).setNumPartitions(1).run(rdd)

获取输出的项目集并打印:

model.freqItemsets.collect().foreach { 
itemset => 
        println(itemset.items.mkString( 
"[", ",", "]") + ", " + itemset.freq 
  )

前面代码的输出如下,您可以看到[Z]出现最多:

[s], 3
[s,x], 3
[s,x,z], 2
[s,z], 2
[r], 3
[r,x], 2
[r,z], 2
[y], 3
[y,s], 2
[y,s,x], 2
[y,s,x,z], 2
[y,s,z], 2
[y,x], 3
[y,x,z], 3
[y,t], 3
[y,t,s], 2
[y,t,s,x], 2
[y,t,s,x,z], 2
[y,t,s,z], 2
[y,t,x], 3
[y,t,x,z], 3
[y,t,z], 3
[y,z], 3
[q], 2
[q,y], 2
[q,y,x], 2
[q,y,x,z], 2
[q,y,t], 2
[q,y,t,x], 2
[q,y,t,x,z], 2
[q,y,t,z], 2
[q,y,z], 2
[q,x], 2
[q,x,z], 2
[q,t], 2
[q,t,x], 2
[q,t,x,z], 2
[q,t,z], 2
[q,z], 2
[x], 4
[x,z], 3
[t], 3
[t,s], 2
[t,s,x], 2
[t,s,x,z], 2
[t,s,z], 2
[t,x], 3
[t,x,z], 3
[t,z], 3
[p], 2
[p,r], 2
[p,r,z], 2
[p,z], 2
[z], 5

应用于 Movie Lens 数据的 FP-Growth

让我们将算法应用于 Movie Lens 数据,以找到我们频繁的电影标题:

  1. 通过编写以下代码行来实例化SparkContext
        val sc = Util.sc 
        val rawData = Util.getUserData() 
        rawData.first()

  1. 获取原始评分并通过编写以下代码行打印第一个:
        val rawRatings = rawData.map(_.split("t").take(3)) 
        rawRatings.first() 
        val ratings = rawRatings.map { case Array(user, movie, 
           rating) => 
        Rating(user.toInt, movie.toInt, rating.toDouble) } 
            val ratingsFirst = ratings.first() 
        println(ratingsFirst)

  1. 加载电影数据并获取标题如下:
        val movies = Util.getMovieData() 
        val titles = movies.map(line => 
        line.split("|").take(2)).map(array 
        => (array(0).toInt, array(1))).collectAsMap() 
        titles(123)

  1. 接下来,我们将使用 FP-Growth 算法找出从 501 到 900 号用户中 400 个用户最频繁的电影。

  2. 首先通过编写以下代码行创建 FP-Growth 模型:

        val model = fpg 
              .setMinSupport(0.1) 
              .setNumPartitions(1) 
              .run(rddx)

  1. 其中0.1是要考虑的最小截止值,rddx是加载到 400 个用户的原始电影评分的 RDD。一旦我们有了模型,我们可以迭代overitemsetritemset并打印结果。

完整的代码清单在此处给出,并且也可以在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/scala-spark-app/src/main/scala/MovieLensFPGrowthApp.scala找到。

可以通过编写以下代码行来完成:

            var eRDD = sc.emptyRDD 
            var z = Seq[String]() 

            val l = ListBuffer() 
            val aj = new ArrayString 
            var i = 0 
            for( a <- 501 to 900) { 
              val moviesForUserX = ratings.keyBy(_.user). 
                lookup(a) 
             val moviesForUserX_10 = 
               moviesForUserX.sortBy(-_.rating).take(10) 
             val moviesForUserX_10_1 = moviesForUserX_10.map
               (r => r.product) 
             var temp = "" 
             for( x <- moviesForUserX_10_1){ 
                if(temp.equals("")) 
                  temp = x.toString 
                else { 
                  temp =  temp + " " + x 
                } 
             } 
             aj(i) = temp 
             i += 1 
            } 
            z = aj 
            val transaction = z.map(_.split(" ")) 
            val rddx = sc.parallelize(transaction, 2).cache() 
            val fpg = new FPGrowth() 
            val model = fpg 
              .setMinSupport(0.1) 
              .setNumPartitions(1) 
              .run(rddx) 
            model.freqItemsets.collect().foreach { itemset => 
              println(itemset.items.mkString("[", ",", "]") 
                + ", " + itemset.freq) 
            } 
            sc.stop()

前面示例的输出如下:

        [302], 40
 [258], 59
 [100], 49
 [286], 50
 [181], 45
 [127], 60
 [313], 59
 [300], 49
 [50], 94

这为用户 ID 501 到 900 提供了具有最大频率的电影。

摘要

在本章中,我们使用 Spark 的 ML 和 MLlib 库来训练协同过滤推荐模型,并学习如何使用该模型来预测给定用户可能偏好的项目。我们还使用我们的模型来找到与给定项目相似或相关的项目。最后,我们探索了评估我们推荐模型的预测能力的常见指标。

在下一章中,您将学习如何使用 Spark 训练模型来对数据进行分类,并使用标准评估机制来衡量模型的性能。

第六章:使用 Spark 构建分类模型

在本章中,您将学习分类模型的基础知识,以及它们在各种情境中的应用。分类通常指将事物分类到不同的类别中。在分类模型的情况下,我们通常希望基于一组特征分配类别。这些特征可能代表与物品或对象、事件或背景相关的变量,或者这些变量的组合。

最简单的分类形式是当我们有两个类别时;这被称为二元分类。其中一个类通常被标记为正类(分配标签 1),而另一个被标记为负类(分配标签-1,有时为 0)。下图显示了一个具有两个类的简单示例。在这种情况下,输入特征具有两个维度,并且特征值在图中的 x 和 y 轴上表示。我们的任务是训练一个模型,可以将这个二维空间中的新数据点分类为一个类(红色)或另一个类(蓝色)。

一个简单的二元分类问题

如果我们有超过两个类别,我们将称之为多类分类,类别通常使用从 0 开始的整数编号(例如,五个不同的类别的标签范围从 0 到 4)。示例如下图所示。再次强调,为了便于说明,假设输入特征是二维的:

一个简单的多类分类问题

分类是一种监督学习的形式,我们通过包含已知目标或感兴趣结果的训练示例来训练模型(即,模型受这些示例结果的监督)。分类模型可以在许多情况下使用,但一些常见的例子包括以下几种:

  • 预测互联网用户点击在线广告的概率;在这里,类别的性质是二元的(即点击或不点击)

  • 检测欺诈;同样,在这种情况下,类别通常是二元的(欺诈或无欺诈)

  • 预测贷款违约(二元)

  • 对图像、视频或声音进行分类(通常是多类,可能有很多不同的类)

  • 将新闻文章、网页或其他内容分配到类别或标签中(多类)

  • 发现电子邮件和网络垃圾邮件、网络入侵和其他恶意行为(二元或多类)

  • 检测故障情况,例如计算机系统或网络中的故障

  • 按照客户或用户购买产品或使用服务的概率对其进行排名

  • 预测可能停止使用产品、服务或提供者的客户或用户(称为流失)

这只是一些可能的用例。事实上,可以说分类是现代企业中最广泛使用的机器学习和统计技术之一,尤其是在线企业。

在本章中,我们将进行以下操作:

  • 讨论 ML 库中可用的分类模型类型

  • 使用 Spark 从原始输入数据中提取适当的特征

  • 使用 ML 库训练多个分类模型

  • 使用我们的分类模型进行预测

  • 应用多种标准评估技术来评估我们模型的预测性能

  • 说明如何使用第四章中的一些特征提取方法来改善模型性能,使用 Spark 获取、处理和准备数据

  • 探索参数调整对模型性能的影响,并学习如何使用交叉验证来选择最优的模型参数

分类模型的类型

我们将探讨 Spark 中可用的三种常见分类模型:线性模型、决策树和朴素贝叶斯模型。线性模型虽然较为简单,但相对容易扩展到非常大的数据集。决策树是一种强大的非线性技术,可能更难扩展(幸运的是,ML 库会为我们处理这个问题!)并且训练时计算量更大,但在许多情况下提供领先的性能。朴素贝叶斯模型更简单,但易于高效训练和并行化(事实上,它们只需要对数据集进行一次遍历)。在适当的特征工程使用的情况下,它们也可以在许多情况下提供合理的性能。朴素贝叶斯模型还提供了一个良好的基准模型,可以用来衡量其他模型的性能。

目前,Spark 的 ML 库支持线性模型、决策树和朴素贝叶斯模型的二元分类,以及决策树和朴素贝叶斯模型的多类分类。在本书中,为了简化示例,我们将专注于二元情况。

线性模型

线性模型(或广义线性模型)的核心思想是,我们将感兴趣的预测结果(通常称为目标因变量)建模为应用于输入变量(也称为特征或自变量)的简单线性预测器的函数。

y = f(W^Tx)

在这里,y是目标变量,w是参数向量(称为权重向量),x是输入特征向量。

wTx是权重向量w和特征向量x的线性预测器(或向量点积)。对于这个线性预测器,我们应用了一个函数f(称为链接函数)。

线性模型实际上可以用于分类和回归,只需改变链接函数。标准线性回归(在下一章中介绍)使用恒等链接(即y =W^Tx直接),而二元分类使用本文讨论的替代链接函数。

让我们来看一下在线广告的例子。在这种情况下,如果在网页上显示的广告(称为曝光)没有观察到点击,则目标变量将为 0(在数学处理中通常被分配为-1 的类标签)。如果发生了点击,则目标变量将为 1。每个曝光的特征向量将由与曝光事件相关的变量组成(例如与用户、网页、广告和广告商相关的特征,以及与事件背景相关的各种其他因素,如使用的设备类型、时间和地理位置)。

因此,我们希望找到一个模型,将给定的输入特征向量(广告曝光)映射到预测结果(点击或未点击)。为了对新数据点进行预测,我们将采用新的特征向量(未见过,因此我们不知道目标变量是什么),并计算与我们的权重向量的点积。然后应用相关的链接函数,结果就是我们的预测结果(在某些模型的情况下,应用阈值到预测结果)。

给定一组以特征向量和目标变量形式的输入数据,我们希望找到最适合数据的权重向量,即我们最小化模型预测和实际观察结果之间的某种误差。这个过程称为模型拟合、训练或优化。

更正式地说,我们试图找到最小化所有训练示例的损失(或错误)的权重向量,该损失是从某个损失函数计算出来的。损失函数将权重向量、特征向量和给定训练示例的实际结果作为输入,并输出损失。实际上,损失函数本身是由链接函数有效地指定的;因此,对于给定类型的分类或回归(即给定链接函数),存在相应的损失函数。

有关线性模型和损失函数的更多细节,请参阅Spark 编程指南中与二元分类相关的线性方法部分spark.apache.org/docs/latest/mllib-linear-methods.html#binary-classificationspark.apache.org/docs/latest/ml-classification-regression.html#linear-methods

另请参阅维基百科关于广义线性模型的条目en.wikipedia.org/wiki/Generalized_linear_model

虽然对线性模型和损失函数的详细处理超出了本书的范围,但 Spark ML 提供了两个适用于二元分类的损失函数(您可以从 Spark 文档中了解更多信息)。第一个是逻辑损失,它等同于一个称为逻辑回归的模型,而第二个是铰链损失,它等同于线性支持向量机SVM)。请注意,SVM 并不严格属于广义线性模型的统计框架,但可以像它一样使用,因为它本质上指定了损失和链接函数。

在下图中,我们展示了逻辑损失和铰链损失相对于实际零一损失的情况。零一损失是二元分类的真实损失--如果模型预测正确,则为零,如果模型预测错误,则为一。它实际上没有被使用的原因是它不是一个可微的损失函数,因此不可能轻松地计算梯度,因此非常难以优化。

其他损失函数是零一损失的近似,这使得优化成为可能:

逻辑、铰链和零一损失函数

前面的损失图是从 scikit-learn 示例调整而来的scikit-learn.org/stable/auto_examples/linear_model/plot_sgd_loss_functions.html

逻辑回归

逻辑回归是一个概率模型,也就是说,它的预测值介于 0 和 1 之间,对于二元分类,等同于模型对数据点属于正类的概率的估计。逻辑回归是最广泛使用的线性分类模型之一。

如前所述,逻辑回归中使用的链接函数是 logit 链接:

1 / (1 + exp(- W^Tx)) a

逻辑回归的相关损失函数是逻辑损失:

*log(1 + exp(-y W^Tx)) *

这里,y是实际的目标变量(正类别为 1,负类别为-1)。

多项式逻辑回归

多项式逻辑回归推广到多类问题;它允许结果变量有两个以上的类别。与二元逻辑回归一样,多项式逻辑回归也使用最大似然估计来评估概率。

多项式逻辑回归主要用于被解释变量是名义的情况。多项式逻辑回归是一个分类问题,其中观察到的特征和参数的线性组合可以用来计算依赖变量的每个特定结果的概率。

在本章中,我们将使用一个不同的数据集,而不是我们用于推荐模型的数据集,因为 MovieLens 数据对于我们解决分类问题并不多。我们将使用 Kaggle 上的一项竞赛数据集。该数据集由 StumbleUpon 提供,问题涉及对给定网页进行分类,判断其是短暂的(即短暂存在,很快就不再流行)还是长青的(即持续流行)在他们的网页内容推荐页面上。

此处使用的数据集可以从www.kaggle.com/c/stumbleupon/data下载。

下载训练数据(train.tsv)-您需要在下载数据集之前接受条款和条件。

您可以在www.kaggle.com/c/stumbleupon找到有关该竞赛的更多信息。

可以在github.com/ml-resources/spark-ml/tree/branch-ed2/Chapter_06/2.0.0/src/scala/org/sparksamples/classification/stumbleupon找到开始的代码列表。

以下是使用 Spark SQLContext 存储为临时表的 StumbleUpon 数据集的一瞥:

可视化 StumbleUpon 数据集

我们运行了自定义逻辑,将特征数量减少到两个,以便我们可以在二维平面上可视化数据集,保持数据集中的线条不变。

{ 
 val sc = new SparkContext("local[1]", "Classification") 

  // get StumbleUpon dataset 'https://www.kaggle.com/c/stumbleupon' 
  val records = sc.textFile( 
   SparkConstants.PATH + "data/train_noheader.tsv").map( 
   line => line.split("\t")) 

  val data_persistent = records.map { r => 
    val trimmed = r.map(_.replaceAll("\"", "")) 
    val label = trimmed(r.size - 1).toInt 
    val features = trimmed.slice(4, r.size - 1).map( 
     d => if (d == "?") 0.0 else d.toDouble) 
    val len = features.size.toInt 
    val len_2 = math.floor(len/2).toInt 
    val x = features.slice(0,len_2) 

    val y = features.slice(len_2 -1 ,len ) 
    var i=0 
    var sum_x = 0.0 
    var sum_y = 0.0 
    while (i < x.length) { 
    sum_x += x(i) 
    i += 1 
 } 

 i = 0 
 while (i < y.length) { 
   sum_y += y(i) 
   i += 1 
 } 

 if (sum_y != 0.0) { 
    if(sum_x != 0.0) { 
       math.log(sum_x) + "," + math.log(sum_y) 
    }else { 
       sum_x + "," + math.log(sum_y) 
    }   
 }else { 
    if(sum_x != 0.0) { 
         math.log(sum_x) + "," + 0.0 
    }else { 
        sum_x + "," + 0.0 
    } 
  } 

} 
val dataone = data_persistent.first() 
  data_persistent.saveAsTextFile(SparkConstants.PATH + 
   "/results/raw-input-log") 
  sc.stop() 

} 

一旦我们将数据转换为二维格式,就会对xy应用对数尺度以方便绘图。在我们的情况下,我们使用 D3.js 进行绘图,如下所示。这些数据将被分类为两类,并且我们将使用相同的基础图像来显示分类:

从 Kaggle/StumbleUpon 长青分类数据集中提取特征

在开始之前,我们将删除文件的第一行列名标题,以便我们更容易在 Spark 中处理数据。切换到您下载数据的目录(这里称为PATH),运行以下命令以删除第一行,并将结果导出到一个名为train_noheader.tsv的新文件中:

  > sed 1d train.tsv > train_noheader.tsv 

现在,我们准备启动我们的 Spark shell(记得从您的 Spark 安装目录运行此命令):

  >./bin/spark-shell --driver-memory 4g  

您可以直接在 Spark shell 中输入本章剩余部分的代码。

与之前章节类似,我们将将原始训练数据加载到 RDD 中,并进行检查:

val rawData = sc.textFile("/PATH/train_noheader.tsv") 
val records = rawData.map(line => line.split("\t")) 
records.first() 

屏幕上会看到以下内容:

Array[String] = Array("http://www.bloomberg.com/news/2010-12-23/ibm-predicts-holographic-calls-air-breathing-batteries-by-2015.html", "4042", ...  

您可以通过阅读数据集页面上的概述来检查可用的字段,如前面提到的。前两列包含页面的 URL 和 ID。下一列包含一些原始文本内容。下一列包含分配给页面的类别。接下来的 22 列包含各种数字或分类特征。最后一列包含目标--1 表示长青,而 0 表示非长青。

我们将从直接使用可用的数字特征开始。由于每个分类变量都是二进制的,我们已经对这些变量进行了1-of-k编码,因此我们不需要进行进一步的特征提取。

由于数据格式的原因,在初始处理过程中,我们将不得不进行一些数据清理,去除额外的引号字符(")。数据集中还存在缺失值;它们由"?"字符表示。在这种情况下,我们将简单地为这些缺失值分配一个零值。

import org.apache.spark.mllib.regression.LabeledPoint 
import org.apache.spark.mllib.linalg.Vectors 
val data = records.map { r => 
  val trimmed = r.map(_.replaceAll("\"", "")) 
  val label = trimmed(r.size - 1).toInt 
  val features = trimmed.slice(4, r.size - 1).map(d => if (d ==   "?") 0.0 else d.toDouble) 
  LabeledPoint(label, Vectors.dense(features)) 
} 

在上述代码中,我们从最后一列中提取了label变量,并在清理和处理缺失值后,提取了列 5 到 25 的features数组。我们将label变量转换为整数值,将features变量转换为Array[Double]。最后,我们将labelfeatures包装在LabeledPoint实例中,将特征转换为 MLlib 向量。

我们还将缓存数据并计算数据点的数量如下:

data.cache 
val numData = data.count 

您将看到numData的值为7395

稍后我们将更详细地探索数据集,但现在我们会告诉您数值数据中有一些负特征值。正如我们之前看到的,朴素贝叶斯模型需要非负特征,并且如果遇到负值,将会抛出错误。因此,现在我们将通过将任何负特征值设置为零来为朴素贝叶斯模型创建我们输入特征向量的版本。

val nbData = records.map { r => 
  val trimmed = r.map(_.replaceAll("\"", "")) 
  val label = trimmed(r.size - 1).toInt 
  val features = trimmed.slice(4, r.size - 1).map(d => if (d ==  
  "?") 0.0 else d.toDouble).map(d => if (d < 0) 0.0 else d) 
  LabeledPoint(label, Vectors.dense(features)) 
}  

StumbleUponExecutor

StumbleUponExecutor (github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/StumbleUponExecutor.scala) 对象可用于选择和运行相应的分类模型;例如,要运行LogisiticRegression并执行逻辑回归管道,将程序参数设置为 LR。有关其他命令,请参考以下代码片段:

case "LR" => LogisticRegressionPipeline.logisticRegressionPipeline(vectorAssembler, dataFrame) 

case "DT" => DecisionTreePipeline.decisionTreePipeline(vectorAssembler, dataFrame) 

case "RF" => RandomForestPipeline.randomForestPipeline(vectorAssembler, dataFrame) 

case "GBT" => GradientBoostedTreePipeline.gradientBoostedTreePipeline(vectorAssembler, dataFrame) 

case "NB" => NaiveBayesPipeline.naiveBayesPipeline(vectorAssembler, dataFrame) 

case "SVM" => SVMPipeline.svmPipeline(sparkContext) 

让我们通过将 StumbleUpon 数据集分成 80%的训练集和 20%的测试集来进行训练;使用LogisticRegressionTrainValidationSplit从 Spark 构建模型,并获得关于测试数据的评估指标:

// create logisitic regression object 
val lr = new LogisticRegression() 

为了创建一个管道对象,我们将使用ParamGridBuilderParamGridBuilder用于构建参数网格,这是一个供估计器选择或搜索的参数列表,以便进行最佳模型选择。您可以在以下链接找到更多详细信息:

spark.apache.org/docs/2.0.0/api/java/org/apache/spark/ml/tuning/ParamGridBuilder.html

-------------------------------------------------------------------------------------------
org.apache.spark.ml.tuning 
Class ParamGridBuilder 
Builder for a param grid used in grid search-based model selection. 
-------------------------------------------------------------------------------------------

// set params using ParamGrid builder 
val paramGrid = new ParamGridBuilder() 
  .addGrid(lr.regParam, Array(0.1, 0.01)) 
  .addGrid(lr.fitIntercept) 
  .addGrid(lr.elasticNetParam, Array(0.0, 0.25, 0.5, 0.75, 1.0)) 
  .build() 

// set pipeline to run the vector assembler and logistic regression // estimator 
val pipeline = new Pipeline().setStages(Array(vectorAssembler, 
 lr)) 

我们将使用TrainValidationSplit进行超参数调整。与CrossValidator相比,它对每个参数组合进行一次评估,而不是k次。它创建一个单一的训练、测试数据集对,并且基于trainRatio参数进行训练和测试的拆分。

Trainvalidationsplit接受Estimator,在estimatorParamMaps参数中提供的一组ParamMaps,以及Evaluator。有关更多信息,请参考以下链接:

spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.ml.tuning.TrainValidationSplit

-------------------------------------------------------------------------------------------
org.apache.spark.ml.tuning 
Class TraiValidationSplit 
Validation for hyper-parameter tuning. Randomly splits the input dataset into train and validation sets. 
-------------------------------------------------------------------------------------------

// use train validation split and regression evaluator for //evaluation 
val trainValidationSplit = new TrainValidationSplit() 
  .setEstimator(pipeline) 
  .setEvaluator(new RegressionEvaluator) 
  .setEstimatorParamMaps(paramGrid) 
  .setTrainRatio(0.8) 

val Array(training, test) = dataFrame.randomSplit(Array(0.8, 0.2), seed = 12345) 

// run the estimator 
val model = trainValidationSplit.fit(training) 

val holdout = model.transform(test).select("prediction","label") 

// have to do a type conversion for RegressionMetrics 
val rm = new RegressionMetrics(holdout.rdd.map(x => (x(0).asInstanceOf[Double], x(1).asInstanceOf[Double]))) 

logger.info("Test Metrics") 
logger.info("Test Explained Variance:") 
logger.info(rm.explainedVariance) 
logger.info("Test R² Coef:") 
logger.info(rm.r2) 
logger.info("Test MSE:") 
logger.info(rm.meanSquaredError) 
logger.info("Test RMSE:") 
logger.info(rm.rootMeanSquaredError) 

val totalPoints = test.count() 
val lrTotalCorrect = holdout.rdd.map(
  x => if (x(0).asInstanceOf[Double] == x(1).asInstanceOf[Double]) 
  1 else 0).sum() 
val accuracy = lrTotalCorrect/totalPoints 
println("Accuracy of LogisticRegression is: ", accuracy) 

您将看到以下输出显示:

Accuracy of LogisticRegression is: ,0.6374918354016982
Mean Squared Error:,0.3625081645983018
Root Mean Squared Error:,0.6020865092312747  

代码清单可以在此链接找到:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/LogisticRegressionPipeline.scala

在这两个截图中显示了预测和实际数据的二维散点图可视化:

线性支持向量机

SVM 是回归和分类的强大且流行的技术。与逻辑回归不同,它不是概率模型,而是根据模型评估是正面还是负面来预测类别。

SVM 链接函数是恒等链接,因此预测的结果如下:

y = w^Tx

因此,如果wTx的评估大于或等于阈值 0,SVM 将把数据点分配给类 1;否则,SVM 将把它分配给类 0。

(此阈值是 SVM 的模型参数,可以进行调整)。

SVM 的损失函数称为铰链损失,定义如下:

max(0, 1 - yw^Tx)

SVM 是最大间隔分类器--它试图找到一个权重向量,使得类尽可能分开。已经证明在许多分类任务上表现良好,线性变体可以扩展到非常大的数据集。

SVM 有大量的理论支持,超出了本书的范围,但您可以访问en.wikipedia.org/wiki/Support_vector_machinewww.support-vector-machines.org/了解更多详情。

在下图中,我们根据之前解释的简单二元分类示例,绘制了逻辑回归(蓝线)和线性 SVM(红线)的不同决策函数。

您可以看到 SVM 有效地聚焦于距离决策函数最近的点(边际线用红色虚线显示):

逻辑回归和线性 SVM 的二元分类决策函数

让我们通过将 StumbleUpon 数据集分为 80%的训练集和 20%的测试集,使用 Spark 中的 SVM 构建模型,并在测试数据周围获取评估指标:

// read stumble upon dataset as rdd 
val records = sc.textFile("/home/ubuntu/work/ml-resources/spark-ml/train_noheader.tsv").map(line => line.split("\t")) 

// get features and label from the rdd 
val data = records.map { r => 
    val trimmed = r.map(_.replaceAll("\"", "")) 
    val label = trimmed(r.size - 1).toInt 
    val features = trimmed.slice(4, r.size - 1).map(d => if (d == "?") 0.0 else d.toDouble) 
    LabeledPoint(label, Vectors.dense(features)) 
  } 

  // params for SVM 
  val numIterations = 10 

  // Run training algorithm to build the model 
  val svmModel = SVMWithSGD.train(data, numIterations) 

  // Clear the default threshold. 
  svmModel.clearThreshold() 

  val svmTotalCorrect = data.map { point => 
    if(svmModel.predict(point.features) == point.label) 1 else 0 
  }.sum() 

  // calculate accuracy 
  val svmAccuracy = svmTotalCorrect / data.count() 
  println(svmAccuracy) 
} 

您将看到以下输出显示:

 Area under ROC = 1.0  

代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/SVMPipeline.scala找到。

朴素贝叶斯模型

朴素贝叶斯是一个概率模型,通过计算数据点属于给定类的概率来进行预测。朴素贝叶斯模型假设每个特征对分配给类的概率做出独立贡献(假设特征之间具有条件独立性)。

由于这个假设,每个类的概率成为特征出现给定类的概率以及该类的概率的乘积函数。这使得训练模型变得可行且相对简单。类先验概率和特征条件概率都是从数据集中的频率估计得出的。分类是通过选择最可能的类来执行的,给定特征和类概率。

还对特征分布做出了假设(其参数是从数据中估计得出的)。Spark ML 实现了多项式朴素贝叶斯,假设特征分布是代表特征的非负频率计数的多项式分布。

它适用于二元特征(例如,1-of-k 编码的分类特征),通常用于文本和文档分类(正如我们在第四章中看到的,使用 Spark 获取、处理和准备数据,词袋向量是典型的特征表示)。

在 Spark 文档的ML - 朴素贝叶斯部分查看更多信息,网址为spark.apache.org/docs/latest/ml-classification-regression.html#naive-bayes

维基百科页面en.wikipedia.org/wiki/Naive_Bayes_classifier对数学公式有更详细的解释。

在下图中,我们展示了朴素贝叶斯在我们简单的二元分类示例上的决策函数:

朴素贝叶斯的决策函数用于二元分类

让我们将 StumbleUpon 数据集分成 80%的训练集和 20%的测试集,使用 Spark 中的朴素贝叶斯构建模型,并在测试数据周围获取评估指标如下:

// split data randomly into training and testing dataset 
val Array(training, test) = dataFrame.randomSplit(Array(0.8, 0.2), seed = 12345) 

// Set up Pipeline 
val stages = new mutable.ArrayBuffer[PipelineStage]() 

val labelIndexer = new StringIndexer() 
  .setInputCol("label") 
  .setOutputCol("indexedLabel") 
stages += labelIndexer 

// create naive bayes model 
val nb = new NaiveBayes() 

stages += vectorAssembler 
stages += nb 
val pipeline = new Pipeline().setStages(stages.toArray) 

// Fit the Pipeline 
val startTime = System.nanoTime() 
val model = pipeline.fit(training) 
val elapsedTime = (System.nanoTime() - startTime) / 1e9 
println(s"Training time: $elapsedTime seconds") 

val holdout = model.transform(test).select("prediction","label") 

// Select (prediction, true label) and compute test error 
val evaluator = new MulticlassClassificationEvaluator() 
  .setLabelCol("label") 
  .setPredictionCol("prediction") 
  .setMetricName("accuracy") 
val mAccuracy = evaluator.evaluate(holdout) 
println("Test set accuracy = " + mAccuracy) 

您将看到以下输出显示:

Training time: 2.114725642 seconds
Accuracy: 0.5660377358490566   

完整的代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/NaiveBayesPipeline.scala找到。

在这里显示了预测和实际数据的二维散点图的可视化:

决策树

决策树模型是一种强大的非概率技术,可以捕捉更复杂的非线性模式和特征交互。已经证明在许多任务上表现良好,相对容易理解和解释,可以处理分类和数值特征,并且不需要输入数据进行缩放或标准化。它们非常适合包含在集成方法中(例如,决策树模型的集成,称为决策森林)。

决策树模型构建了一棵树,其中叶子表示对类 0 或 1 的类分配,分支是一组特征。在下图中,我们展示了一个简单的决策树,其中二元结果是呆在家里去海滩。特征是外面的天气。

一个简单的决策树

决策树算法是自顶向下的方法,从根节点(或特征)开始,然后在每一步选择一个特征,该特征通过信息增益来衡量数据集的最佳分割。信息增益是从节点不纯度(标签在节点上相似或同质的程度)减去由分割创建的两个子节点的不纯度的加权和计算而来。对于分类任务,有两种可以用来选择最佳分割的度量。这些是基尼不纯度和熵。

有关决策树算法和分类的不纯度度量的更多细节,请参阅spark.apache.org/docs/latest/ml-classification-regression.html#decision-tree-classifier中的ML Library - Decision Tree部分的Spark 编程指南

在下面的截图中,我们已经绘制了决策树模型的决策边界,就像我们之前对其他模型所做的那样。我们可以看到决策树能够拟合复杂的非线性模型:

二元分类的决策树的决策函数

让我们将 StumbleUpon 数据集分成 80%的训练集和 20%的测试集,使用 Spark 中的决策树构建模型,并在测试数据周围获取评估指标如下:

// split data randomly into training and testing dataset 
val Array(training, test) = dataFrame.randomSplit(Array(0.8, 0.2), seed = 12345) 

// Set up Pipeline 
val stages = new mutable.ArrayBuffer[PipelineStage]() 

val labelIndexer = new StringIndexer() 
  .setInputCol("label") 
  .setOutputCol("indexedLabel") 
stages += labelIndexer 

// create Decision Tree Model 
val dt = new DecisionTreeClassifier() 
  .setFeaturesCol(vectorAssembler.getOutputCol) 
  .setLabelCol("indexedLabel") 
  .setMaxDepth(5) 
  .setMaxBins(32) 
  .setMinInstancesPerNode(1) 
  .setMinInfoGain(0.0) 
  .setCacheNodeIds(false) 
  .setCheckpointInterval(10) 

stages += vectorAssembler 
stages += dt 
val pipeline = new Pipeline().setStages(stages.toArray) 

// Fit the Pipeline 
val startTime = System.nanoTime() 
val model = pipeline.fit(training) 
val elapsedTime = (System.nanoTime() - startTime) / 1e9 
println(s"Training time: $elapsedTime seconds") 

val holdout = model.transform(test).select("prediction","label") 

// Select (prediction, true label) and compute test error 
val evaluator = new MulticlassClassificationEvaluator() 
  .setLabelCol("label") 
  .setPredictionCol("prediction") 
  .setMetricName("accuracy") 
val mAccuracy = evaluator.evaluate(holdout) 
println("Test set accuracy = " + mAccuracy) 

您将看到以下输出显示:

Accuracy: 0.3786163522012579  

代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/DecisionTreePipeline.scala找到。

在下面的图中显示了二维散点图中预测和实际数据的可视化:

树的集成

集成方法是一种机器学习算法,它创建由一组其他基本模型组成的模型。Spark 机器学习支持两种主要的集成算法:随机森林和梯度提升树。

随机森林

随机森林被称为决策树的集成,由许多决策树组成。与决策树一样,随机森林可以处理分类特征,支持多类别分类,并且不需要特征缩放。

Spark ML 支持随机森林用于二元和多类别分类以及使用连续和分类特征进行回归。

让我们通过将样本 lib SVM 数据分为 80%的训练和 20%的测试,使用 Spark 中的随机森林分类器来构建模型,并获得关于测试数据的评估指标。模型可以被持久化并加载以供以后使用。

让我们通过将 StumbleUpon 数据集分为 80%的训练和 20%的测试,使用 Spark 中的随机森林树来构建模型,并获得关于测试数据的评估指标:

// split data randomly into training and testing dataset 
val Array(training, test) = dataFrame.randomSplit(Array(0.8, 0.2), seed = 12345) 

// Set up Pipeline 
val stages = new mutable.ArrayBuffer[PipelineStage]() 

val labelIndexer = new StringIndexer() 
  .setInputCol("label") 
  .setOutputCol("indexedLabel") 
stages += labelIndexer 

// create Random Forest Model 
val rf = new RandomForestClassifier() 
  .setFeaturesCol(vectorAssembler.getOutputCol) 
  .setLabelCol("indexedLabel") 
  .setNumTrees(20) 
  .setMaxDepth(5) 
  .setMaxBins(32) 
  .setMinInstancesPerNode(1) 
  .setMinInfoGain(0.0) 
  .setCacheNodeIds(false) 
  .setCheckpointInterval(10) 

stages += vectorAssembler 
stages += rf 
val pipeline = new Pipeline().setStages(stages.toArray) 

// Fit the Pipeline 
val startTime = System.nanoTime() 
val model = pipeline.fit(training) 
val elapsedTime = (System.nanoTime() - startTime) / 1e9 
println(s"Training time: $elapsedTime seconds") 

val holdout = model.transform(test).select("prediction","label") 

// Select (prediction, true label) and compute test error 
val evaluator = new MulticlassClassificationEvaluator() 
  .setLabelCol("label") 
  .setPredictionCol("prediction") 
  .setMetricName("accuracy") 
val mAccuracy = evaluator.evaluate(holdout) 
println("Test set accuracy = " + mAccuracy) 

您将看到以下输出显示:

Accuracy: 0.348  

完整的代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/RandomForestPipeline.scala找到。

在这里显示了二维散点图中的预测和实际数据的可视化:

梯度提升树

梯度提升树是决策树的集成。梯度提升树迭代训练决策树以最小化损失函数。梯度提升树处理分类特征,支持多类别分类,并且不需要特征缩放。

Spark ML 使用现有的决策树实现梯度提升树。它支持分类和回归。

让我们通过将 StumbleUpon 数据集分为 80%的训练和 20%的测试,使用 Spark 中的梯度提升树来构建模型,并获得关于测试数据的评估指标:

// split data randomly into training and testing dataset 
val Array(training, test) = dataFrame.randomSplit(Array(0.8, 0.2), seed = 12345) 

// Set up Pipeline 
val stages = new mutable.ArrayBuffer[PipelineStage]() 

val labelIndexer = new StringIndexer() 
  .setInputCol("label") 
  .setOutputCol("indexedLabel") 
stages += labelIndexer 

// create GBT Model 
val gbt = new GBTClassifier() 
  .setFeaturesCol(vectorAssembler.getOutputCol) 
  .setLabelCol("indexedLabel") 
  .setMaxIter(10) 

stages += vectorAssembler 
stages += gbt 
val pipeline = new Pipeline().setStages(stages.toArray) 

// Fit the Pipeline 
val startTime = System.nanoTime() 
val model = pipeline.fit(training) 
val elapsedTime = (System.nanoTime() - startTime) / 1e9 
println(s"Training time: $elapsedTime seconds") 

val holdout = model.transform(test).select("prediction","label") 

// have to do a type conversion for RegressionMetrics 
val rm = new RegressionMetrics(holdout.rdd.map(x => (x(0).asInstanceOf[Double], x(1).asInstanceOf[Double]))) 

logger.info("Test Metrics") 
logger.info("Test Explained Variance:") 
logger.info(rm.explainedVariance) 
logger.info("Test R² Coef:") 
logger.info(rm.r2) 
logger.info("Test MSE:") 
logger.info(rm.meanSquaredError) 
logger.info("Test RMSE:") 
logger.info(rm.rootMeanSquaredError) 

val predictions = model.transform(test).select("prediction").rdd.map(_.getDouble(0)) 
val labels = model.transform(test).select("label").rdd.map(_.getDouble(0)) 
val accuracy = new MulticlassMetrics(predictions.zip(labels)).precision 
println(s"  Accuracy : $accuracy") 

您将看到以下输出显示:

Accuracy: 0.3647  

代码清单可以在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/GradientBoostedTreePipeline.scala找到。

在以下图表中显示了二维散点图中的预测可视化:

多层感知器分类器

神经网络是一个复杂的自适应系统,它根据通过它流动的信息改变其内部结构,使用权重。优化多层神经网络的权重称为反向传播。反向传播略微超出了本书的范围,并涉及激活函数和基本微积分。

多层感知器分类器基于前馈人工神经网络。它由多个层组成。每个神经层与网络中的下一个神经层完全连接,输入层中的节点表示输入数据。所有其他节点通过使用节点的权重和偏差执行输入的线性组合,并应用激活或链接函数将输入映射到输出。

让我们通过将样本libsvm数据分为 80%的训练和 20%的测试,使用 Spark 中的多层感知器分类器来构建模型,并获得关于测试数据的评估指标:

package org.sparksamples.classification.stumbleupon 

import org.apache.spark.ml.classification.MultilayerPerceptronClassifier 
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator 
import org.apache.spark.sql.SparkSession 

// set VM Option as -Dspark.master=local[1] 
object MultilayerPerceptronClassifierExample { 

  def main(args: Array[String]): Unit = { 
    val spark = SparkSession 
      .builder 
      .appName("MultilayerPerceptronClassifierExample") 
      .getOrCreate() 

    // Load the data stored in LIBSVM format as a DataFrame. 
    val data = spark.read.format("libsvm") 
      .load("/Users/manpreet.singh/Sandbox/codehub/github/machinelearning/spark-ml/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/dataset/spark-data/sample_multiclass_classification_data.txt") 

    // Split the data into train and test 
    val splits = data.randomSplit(Array(0.8, 0.2), seed = 1234L) 
    val train = splits(0) 
    val test = splits(1) 

    // specify layers for the neural network: 
    // input layer of size 4 (features),  
    //two intermediate of size 5 and 4 
    // and output of size 3 (classes) 
    val layers = ArrayInt 

    // create the trainer and set its parameters 
    val trainer = new MultilayerPerceptronClassifier() 
      .setLayers(layers) 
      .setBlockSize(128) 
      .setSeed(1234L) 
      .setMaxIter(100) 

    // train the model 
    val model = trainer.fit(train) 

    // compute accuracy on the test set 
    val result = model.transform(test) 
    val predictionAndLabels = result.select("prediction", "label") 
    val evaluator = new MulticlassClassificationEvaluator() 
      .setMetricName("accuracy") 

    println("Test set accuracy = " + 
     evaluator.evaluate(predictionAndLabels)) 

    spark.stop() 
  } 
} 

您将看到以下输出显示:

Precision = 1.0  

代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_06/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/classification/stumbleupon/MultilayerPerceptronClassifierExample.scala找到。

在我们进一步进行之前,请注意以下特征提取和分类的示例使用 Spark v1.6 中的 MLLib 包。请按照之前提到的代码清单来使用 Spark v2.0 基于 Dataframe 的 API。截至 Spark 2.0,基于 RDD 的 API 已进入维护模式。

从数据中提取正确的特征

您可能还记得第四章中的内容,使用 Spark 获取、处理和准备数据,大多数机器学习模型都是在特征向量的数值数据上操作的。此外,对于监督学习方法,如分类和回归,我们需要提供目标变量(或在多类情况下的变量)以及特征向量。

MLlib 中的分类模型操作LabeledPoint的实例,它是目标变量(称为label)和feature向量的包装器。

case class LabeledPoint(label: Double, features: Vector) 

在大多数分类使用的示例中,您将遇到已经以向量格式存在的现有数据集,但在实践中,您通常会从需要转换为特征的原始数据开始。正如我们已经看到的,这可能涉及预处理和转换,如对数值特征进行分箱处理,对特征进行缩放和归一化,以及对分类特征使用 1-of-k 编码。

训练分类模型

现在我们已经从数据集中提取了一些基本特征并创建了我们的输入 RDD,我们已经准备好训练多个模型了。为了比较不同模型的性能和使用情况,我们将使用逻辑回归、SVM、朴素贝叶斯和决策树来训练一个模型。您会注意到,训练每个模型看起来几乎相同,尽管每个模型都有自己特定的模型参数,可以设置。Spark ML 在大多数情况下设置了合理的默认值,但在实践中,最佳参数设置应该使用评估技术来选择,我们将在本章后面介绍。

在 Kaggle/StumbleUpon 永久分类数据集上训练分类模型

现在,我们可以将 Spark ML 中的模型应用于我们的输入数据。首先,我们需要导入所需的类,并为每个模型设置一些最小输入参数。对于逻辑回归和 SVM,这是迭代次数,而对于决策树模型,这是最大树深度。

import  
org.apache.spark.mllib.classification.LogisticRegressionWithSGD 
import org.apache.spark.mllib.classification.SVMWithSGD 
import org.apache.spark.mllib.classification.NaiveBayes 
import org.apache.spark.mllib.tree.DecisionTree 
import org.apache.spark.mllib.tree.configuration.Algo 
import org.apache.spark.mllib.tree.impurity.Entropy  
val numIterations = 10 
val maxTreeDepth = 5 

现在,依次训练每个模型。首先,我们将训练逻辑回归模型如下:

val lrModel = LogisticRegressionWithSGD.train(data, numIterations) 

您将看到以下输出:

...
14/12/06 13:41:47 INFO DAGScheduler: Job 81 finished: reduce at RDDFunctions.scala:112, took 0.011968 s
14/12/06 13:41:47 INFO GradientDescent: GradientDescent.runMiniBatchSGD finished. Last 10 stochastic losses 0.6931471805599474, 1196521.395699124, Infinity, 1861127.002201189, Infinity, 2639638.049627607, Infinity, Infinity, Infinity, Infinity
lrModel: org.apache.spark.mllib.classification.LogisticRegressionModel = (weights=[-0.11372778986947886,-0.511619752777837, 
...  

接下来,我们将这样训练一个 SVM 模型:

val svmModel = SVMWithSGD.train(data, numIterations) 

您现在将看到以下输出:

...
14/12/06 13:43:08 INFO DAGScheduler: Job 94 finished: reduce at RDDFunctions.scala:112, took 0.007192 s
14/12/06 13:43:08 INFO GradientDescent: GradientDescent.runMiniBatchSGD finished. Last 10 stochastic losses 1.0, 2398226.619666797, 2196192.9647478117, 3057987.2024311484, 271452.9038284356, 3158131.191895948, 1041799.350498323, 1507522.941537049, 1754560.9909073508, 136866.76745605646
svmModel: org.apache.spark.mllib.classification.SVMModel = (weights=[-0.12218838697834929,-0.5275107581589767,
...  

然后,我们将训练朴素贝叶斯模型;请记住使用您特殊的非负特征数据集:

val nbModel = NaiveBayes.train(nbData) 

以下是输出:

...
14/12/06 13:44:48 INFO DAGScheduler: Job 95 finished: collect at NaiveBayes.scala:120, took 0.441273 s
nbModel: org.apache.spark.mllib.classification.NaiveBayesModel = org.apache.spark.mllib.classification.NaiveBayesModel@666ac612 
...  

最后,我们将训练我们的决策树。

val dtModel = DecisionTree.train(data, Algo.Classification, Entropy, maxTreeDepth) 

输出如下:

...
14/12/06 13:46:03 INFO DAGScheduler: Job 104 finished: collectAsMap at DecisionTree.scala:653, took 0.031338 s
...
total: 0.343024
findSplitsBins: 0.119499
findBestSplits: 0.200352
chooseSplits: 0.199705
dtModel: org.apache.spark.mllib.tree.model.DecisionTreeModel = DecisionTreeModel classifier of depth 5 with 61 nodes 
...  

请注意,我们将决策树的模式或算法设置为Classification,并使用Entropy不纯度度量。

使用分类模型

我们现在已经对我们的输入标签和特征进行了四个模型的训练。现在我们将看到如何使用这些模型对我们的数据集进行预测。目前,我们将使用相同的训练数据来说明每个模型的预测方法。

为 Kaggle/StumbleUpon 永久分类数据集生成预测

我们将以逻辑回归模型为例(其他模型使用方式相同):

val dataPoint = data.first 
val prediction = lrModel.predict(dataPoint.features) 

以下是输出:

prediction: Double = 1.0  

我们看到,在我们的训练数据集中,第一个数据点的模型预测标签为 1(即永久)。让我们检查这个数据点的真实标签。

val trueLabel = dataPoint.label 

您可以看到以下输出:

trueLabel: Double = 0.0  

所以,在这种情况下,我们的模型出错了!

我们还可以通过传入RDD[Vector]来批量进行预测:

val predictions = lrModel.predict(data.map(lp => lp.features)) 
predictions.take(5) 

以下是输出:

Array[Double] = Array(1.0, 1.0, 1.0, 1.0, 1.0)  

评估分类模型的性能

当我们使用我们的模型进行预测时,就像我们之前做的那样,我们如何知道预测是好还是不好?我们需要能够评估我们的模型的表现如何。在二元分类中常用的评估指标包括预测准确度和错误、精确度和召回率、精确-召回曲线下面积、接收器操作特征(ROC)曲线、ROC 曲线下面积(AUC)和 F-度量。

准确率和预测错误

二元分类的预测错误可能是可用的最简单的度量。它是被错误分类的训练示例数除以总示例数。同样,准确率是正确分类的示例数除以总示例数。

通过对每个输入特征进行预测并将其与真实标签进行比较,我们可以计算我们在训练数据中模型的准确率。我们将总结正确分类的实例数,并将其除以数据点的总数以获得平均分类准确率。

val lrTotalCorrect = data.map { point => 
  if (lrModel.predict(point.features) == point.label) 1 else 0 
}.sum  
val lrAccuracy = lrTotalCorrect / data.count 

输出如下:

lrAccuracy: Double = 0.5146720757268425  

这给我们带来了 51.5%的准确率,看起来并不特别令人印象深刻!我们的模型只正确分类了一半的训练示例,这似乎与随机机会一样好。

模型做出的预测通常不是完全 1 或 0。输出通常是一个实数,必须转换为类预测。这是通过分类器的决策或评分函数中的阈值来实现的。

例如,二元逻辑回归是一个概率模型,它在评分函数中返回类 1 的估计概率。因此,典型的决策阈值为 0.5。也就是说,如果被估计为类 1 的概率高于 50%,模型决定将该点分类为类 1;否则,它将被分类为类 0。

阈值本身实际上是一种可以在某些模型中进行调整的模型参数。它还在评估度量中发挥作用,我们现在将看到。

其他模型呢?让我们计算其他三个的准确率:

val svmTotalCorrect = data.map { point => 
  if (svmModel.predict(point.features) == point.label) 1 else 0 
}.sum 
val nbTotalCorrect = nbData.map { point => 
  if (nbModel.predict(point.features) == point.label) 1 else 0 
}.sum 

请注意,决策树预测阈值需要明确指定,如下所示:

val dtTotalCorrect = data.map { point => 
  val score = dtModel.predict(point.features) 
  val predicted = if (score > 0.5) 1 else 0  
  if (predicted == point.label) 1 else 0 
}.sum  

我们现在可以检查其他三个模型的准确性。首先是 SVM 模型,如下所示:

val svmAccuracy = svmTotalCorrect / numData 

以下是 SVM 模型的输出:

svmAccuracy: Double = 0.5146720757268425  

接下来是我们的朴素贝叶斯模型。

val nbAccuracy = nbTotalCorrect / numData 

输出如下:

nbAccuracy: Double = 0.5803921568627451  

最后,我们计算决策树的准确率:

val dtAccuracy = dtTotalCorrect / numData 

输出如下:

dtAccuracy: Double = 0.6482758620689655  

我们可以看到 SVM 和朴素贝叶斯的表现也相当糟糕。决策树模型的准确率为 65%,但这仍然不是特别高。

精确度和召回率

在信息检索中,精确度是结果质量的常用度量,而召回率是结果完整性的度量。

在二元分类环境中,精确度被定义为真正例数(即被正确预测为类 1 的示例数)除以真正例数和假正例数之和(即被错误预测为类 1 的示例数)。因此,我们可以看到,如果分类器预测为类 1 的每个示例实际上都是类 1(即没有假正例),则可以实现 1.0(或 100%)的精确度。

召回率被定义为真正例数除以真正例数和假反例数之和(即模型错误预测为类 0 的实例数)。我们可以看到,如果模型没有错过任何属于类 1 的示例(即没有假反例),则可以实现 1.0(或 100%)的召回率。

通常,精确度和召回率是相互关联的;通常,较高的精确度与较低的召回率相关,反之亦然。为了说明这一点,假设我们构建了一个总是预测类别 1 的模型。在这种情况下,模型预测将没有假阴性,因为模型总是预测 1;它不会错过任何类别 1。因此,对于这个模型,召回率将为 1.0。另一方面,假阳性率可能非常高,这意味着精确度会很低(这取决于数据集中类的确切分布)。

精确度和召回率作为独立的度量并不特别有用,但通常一起使用以形成一个聚合或平均度量。精确度和召回率也依赖于模型选择的阈值。

直观地,以下是一些模型将始终预测类别 1 的阈值水平。因此,它将具有召回率为 1,但很可能精确度较低。在足够高的阈值下,模型将始终预测类别 0。然后,模型将具有召回率为 0,因为它无法实现任何真阳性,并且可能有许多假阴性。此外,其精确度得分将是未定义的,因为它将实现零真阳性和零假阳性。

精确度-召回率PR)曲线在下图中绘制了给定模型的精确度与召回率结果,随着分类器的决策阈值的改变。这个 PR 曲线下的面积被称为平均精度。直观地,PR 曲线下的面积为 1.0 将等同于一个完美的分类器,将实现 100%的精确度和召回率。

精确度-召回率曲线

请参阅en.wikipedia.org/wiki/Precision_and_recallen.wikipedia.org/wiki/Average_precision#Average_precision以获取有关精确度、召回率和 PR 曲线下面积的更多详细信息。

ROC 曲线和 AUC

ROC 曲线是与 PR 曲线类似的概念。它是分类器的真阳性率与假阳性率的图形表示。

真阳性率TPR)是真阳性的数量除以真阳性和假阴性的总和。换句话说,它是真阳性与所有正例的比率。这与我们之前看到的召回率相同,通常也被称为灵敏度。

假阳性率FPR)是假阳性的数量除以假阳性和真阴性的总和(即正确预测为类别 0 的示例数量)。换句话说,它是假阳性与所有负例的比率。

与精确度和召回率类似,ROC 曲线(在下图中绘制)表示分类器在不同决策阈值下 TPR 与 FPR 的性能折衷。曲线上的每个点代表分类器决策函数中的不同阈值。

ROC 曲线

ROC 曲线下的面积(通常称为 AUC)代表了一个平均值。同样,AUC 为 1.0 将代表一个完美的分类器。面积为 0.5 被称为随机分数。因此,实现 AUC 为 0.5 的模型不比随机猜测更好。

由于 PR 曲线下面积和 ROC 曲线下面积都被有效地归一化(最小为 0,最大为 1),我们可以使用这些度量来比较具有不同参数设置的模型,甚至比较完全不同的模型。因此,这些指标在模型评估和选择方面很受欢迎。

MLlib 带有一组内置例程,用于计算二元分类的 PR 曲线和 ROC 曲线下的面积。在这里,我们将为我们的每个模型计算这些度量:

import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics 
val metrics = Seq(lrModel, svmModel).map { model =>  
  val scoreAndLabels = data.map { point => 
    (model.predict(point.features), point.label) 
  } 
  val metrics = new BinaryClassificationMetrics(scoreAndLabels) 
  (model.getClass.getSimpleName, metrics.areaUnderPR, metrics.areaUnderROC) 
} 

与之前训练朴素贝叶斯模型和计算准确率一样,我们需要使用我们创建的nbData版本的数据集来计算分类指标。

val nbMetrics = Seq(nbModel).map{ model => 
  val scoreAndLabels = nbData.map { point => 
    val score = model.predict(point.features) 
    (if (score > 0.5) 1.0 else 0.0, point.label) 
  } 
  val metrics = new BinaryClassificationMetrics(scoreAndLabels) 
  (model.getClass.getSimpleName, metrics.areaUnderPR,  
  metrics.areaUnderROC) 
} 

请注意,因为DecisionTreeModel模型没有实现其他三个模型实现的ClassificationModel接口,我们需要在以下代码中单独计算该模型的结果:

val dtMetrics = Seq(dtModel).map{ model => 
  val scoreAndLabels = data.map { point => 
    val score = model.predict(point.features) 
    (if (score > 0.5) 1.0 else 0.0, point.label) 
  } 
  val metrics = new BinaryClassificationMetrics(scoreAndLabels) 
  (model.getClass.getSimpleName, metrics.areaUnderPR,  
  metrics.areaUnderROC) 
} 
val allMetrics = metrics ++ nbMetrics ++ dtMetrics 
allMetrics.foreach{ case (m, pr, roc) =>  
  println(f"$m, Area under PR: ${pr * 100.0}%2.4f%%, Area under  
  ROC: ${roc * 100.0}%2.4f%%")  
} 

你的输出将类似于这里的输出:

LogisticRegressionModel, Area under PR: 75.6759%, Area under ROC: 50.1418%
SVMModel, Area under PR: 75.6759%, Area under ROC: 50.1418%
NaiveBayesModel, Area under PR: 68.0851%, Area under ROC: 58.3559%
DecisionTreeModel, Area under PR: 74.3081%, Area under ROC: 64.8837%  

我们可以看到,所有模型在平均精度指标上取得了大致相似的结果。

逻辑回归和支持向量机的 AUC 结果约为 0.5。这表明它们的表现甚至不如随机机会!我们的朴素贝叶斯和决策树模型稍微好一些,分别达到了 0.58 和 0.65 的 AUC。但就二元分类性能而言,这仍然不是一个很好的结果。

虽然我们在这里没有涉及多类分类,但 MLlib 提供了一个类似的评估类,称为MulticlassMetrics,它提供了许多常见指标的平均版本。

改进模型性能和调整参数

那么,出了什么问题?为什么我们复杂的模型的表现甚至不如随机机会?我们的模型有问题吗?

回想一下,我们最初只是将数据投放到我们的模型中。事实上,我们甚至没有将所有数据都投放到模型中,只是那些易于使用的数值列。此外,我们对这些数值特征没有进行大量分析。

特征标准化

我们使用的许多模型对输入数据的分布或规模做出了固有的假设。其中最常见的假设形式之一是关于正态分布特征的。让我们更深入地研究一下我们特征的分布。

为此,我们可以将特征向量表示为 MLlib 中的分布矩阵,使用RowMatrix类。RowMatrix是由向量组成的 RDD,其中每个向量是矩阵的一行。

RowMatrix类带有一些有用的方法来操作矩阵,其中之一是在矩阵的列上计算统计数据的实用程序。

import org.apache.spark.mllib.linalg.distributed.RowMatrix 
val vectors = data.map(lp => lp.features) 
val matrix = new RowMatrix(vectors) 
val matrixSummary = matrix.computeColumnSummaryStatistics() 

以下代码语句将打印矩阵的均值:

println(matrixSummary.mean) 

这里是输出:

0.41225805299526636,2.761823191986623,0.46823047328614004, ...  

以下代码语句将打印矩阵的最小值:

println(matrixSummary.min) 

这里是输出:

[0.0,0.0,0.0,0.0,0.0,0.0,0.0,-1.0,0.0,0.0,0.0,0.045564223,-1.0, ...  

以下代码语句将打印矩阵的最大值:

println(matrixSummary.max) 

输出如下:

[0.999426,363.0,1.0,1.0,0.980392157,0.980392157,21.0,0.25,0.0,0.444444444, ...  

以下代码语句将打印矩阵的方差:

println(matrixSummary.variance) 

方差的输出是:

[0.1097424416755897,74.30082476809638,0.04126316989120246, ...  

以下代码语句将打印矩阵的非零数:

println(matrixSummary.numNonzeros) 

这里是输出:

[5053.0,7354.0,7172.0,6821.0,6160.0,5128.0,7350.0,1257.0,0.0, ...  

computeColumnSummaryStatistics方法计算特征的各列统计数据,包括均值和方差,并将每个统计数据存储在一个向量中,每列一个条目(也就是在我们的情况下,每个特征一个条目)。

从上面的均值和方差输出中,我们可以清楚地看到第二个特征的均值和方差比其他一些特征要高得多(你会发现还有一些其他类似的特征,还有一些更极端的特征)。因此,我们的数据在原始形式下明显不符合标准的高斯分布。为了使数据更适合我们的模型,我们可以对每个特征进行标准化,使其均值为零,标准差为单位。我们可以通过以下方式实现:从每个特征值中减去列均值,然后除以特征的列标准差。

(x - μ) / sqrt(variance)

实际上,对于输入数据集中的每个特征向量,我们可以简单地对先前的均值向量进行逐元素减法运算,然后对特征向量进行逐元素除法运算,除以特征标准差向量。标准差向量本身可以通过对方差向量进行逐元素平方根运算得到。

正如我们在[第四章中提到的,使用 Spark 获取、处理和准备数据,我们幸运地可以访问 Spark 的StandardScaler的便利方法来完成这个任务。

StandardScaler的工作方式与我们在该章节中使用的 Normalizer 特征基本相同。我们将通过传入两个参数来实例化它,告诉它是否从数据中减去平均值,以及是否应用标准差缩放。然后,我们将在我们的输入向量上拟合StandardScaler。最后,我们将在transform函数中传入一个输入向量,然后返回一个标准化向量。我们将在以下map函数中执行此操作,以保留数据集中的label

import org.apache.spark.mllib.feature.StandardScaler 
val scaler = new StandardScaler(withMean = true, withStd = true).fit(vectors) 
val scaledData = data.map(lp => LabeledPoint(lp.label, scaler.transform(lp.features))) 

我们的数据现在应该是标准化的。让我们检查原始和标准化特征的第一行。

println(data.first.features) 

前面一行代码的输出如下:

0.789131,2.055555556,0.676470588,0.205882353,  

以下代码将是标准化特征的第一行:

println(scaledData.first.features) 

输出如下:

[1.1376439023494747,-0.08193556218743517,1.025134766284205,-0.0558631837375738,  

正如我们所看到的,通过应用标准化公式,第一个特征已经被转换。我们可以通过从第一个特征中减去平均值(我们之前计算过的)并将结果除以方差的平方根(我们之前计算过的)来检查这一点。

println((0.789131 - 0.41225805299526636)/ math.sqrt(0.1097424416755897)) 

结果应该等于我们缩放向量的第一个元素:

1.137647336497682  

现在我们可以使用标准化的数据重新训练我们的模型。我们将仅使用逻辑回归模型来说明特征标准化的影响(因为决策树和朴素贝叶斯不受此影响)。

val lrModelScaled = LogisticRegressionWithSGD.train(scaledData, numIterations) 
val lrTotalCorrectScaled = scaledData.map { point => 
  if (lrModelScaled.predict(point.features) == point.label) 1 else  
  0 
}.sum 
val lrAccuracyScaled = lrTotalCorrectScaled / numData 
val lrPredictionsVsTrue = scaledData.map { point =>  
  (lrModelScaled.predict(point.features), point.label)  
} 
val lrMetricsScaled = new BinaryClassificationMetrics(lrPredictionsVsTrue) 
val lrPr = lrMetricsScaled.areaUnderPR 
val lrRoc = lrMetricsScaled.areaUnderROC 
println(f"${lrModelScaled.getClass.getSimpleName}\nAccuracy: ${lrAccuracyScaled * 100}%2.4f%%\nArea under PR: ${lrPr * 100.0}%2.4f%%\nArea under ROC: ${lrRoc * 100.0}%2.4f%%")  

结果应该看起来类似于这样:

LogisticRegressionModel
Accuracy: 62.0419%
Area under PR: 72.7254%
Area under ROC: 61.9663%   

仅仅通过对特征进行标准化,我们已经将逻辑回归的准确性和 AUC 从 50%(不比随机好)提高到了 62%。

额外的特征

我们已经看到,我们需要小心地对特征进行标准化和可能的归一化,对模型性能的影响可能很严重。在这种情况下,我们仅使用了部分可用的特征。例如,我们完全忽略了类别变量和 boilerplate 变量列中的文本内容。

这是为了便于说明而做的,但让我们评估添加额外特征(如类别特征)的影响。

首先,我们将检查类别,并形成一个索引到类别的映射,您可能会认识到这是对这个分类特征进行 1-of-k 编码的基础:

val categories = records.map(r => r(3)).distinct.collect.zipWithIndex.toMap 
val numCategories = categories.size 
println(categories) 

不同类别的输出如下:

Map("weather" -> 0, "sports" -> 6, "unknown" -> 4, "computer_internet" -> 12, "?" -> 11, "culture_politics" -> 3, "religion" -> 8, "recreation" -> 2, "arts_entertainment" -> 9, "health" -> 5, "law_crime" -> 10, "gaming" -> 13, "business" -> 1, "science_technology" -> 7)  

以下代码将打印类别的数量:

println(numCategories) 

以下是输出:

14  

因此,我们需要创建一个长度为 14 的向量来表示这个特征,并为每个数据点的相关类别的索引分配一个值为 1。然后,我们可以将这个新的特征向量放在其他数值特征向量的前面,如下所示:

val dataCategories = records.map { r => 
  val trimmed = r.map(_.replaceAll("\"", "")) 
  val label = trimmed(r.size - 1).toInt 
  val categoryIdx = categories(r(3)) 
  val categoryFeatures = Array.ofDim[Double 
  categoryFeatures(categoryIdx) = 1.0 
  val otherFeatures = trimmed.slice(4, r.size - 1).map(d => if   (d == "?") 0.0 else d.toDouble) 
  val features = categoryFeatures ++ otherFeatures 
  LabeledPoint(label, Vectors.dense(features)) 
} 
println(dataCategories.first) 

您应该看到类似于这里显示的输出。您可以看到我们特征向量的第一部分现在是一个长度为 14 的向量,其中在相关类别索引处有一个非零条目。

LabeledPoint(0.0[0.0,1.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.789131,2.055555556,0.676470588,0.205882353,0.047058824,0.023529412,0.443783175,0.0,0.0,0.09077381,0.0,0.245831182,0.003883495,1.0,1.0,24.0,0.0,5424.0,170.0,8.0,0.152941176,0.079129575])  

同样,由于我们的原始特征没有标准化,我们应该在对这个扩展数据集进行新模型训练之前,使用与之前相同的StandardScaler方法进行转换:

val scalerCats = new StandardScaler(withMean = true, withStd = true).fit(dataCategories.map(lp => lp.features)) 
val scaledDataCats = dataCategories.map(lp => LabeledPoint(lp.label, scalerCats.transform(lp.features))) 

我们可以像之前一样检查缩放前后的特征。

println(dataCategories.first.features) 

输出如下:

0.0,1.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.789131,2.055555556 ...  

以下代码将打印缩放后的特征:

println(scaledDataCats.first.features) 

您将在屏幕上看到以下内容:

[-0.023261105535492967,2.720728254208072,-0.4464200056407091,-0.2205258360869135, ...  

虽然原始的原始特征是稀疏的(即有许多条目为零),但如果我们从每个条目中减去平均值,我们将得到一个非稀疏(密集)表示,就像前面的例子中所示的那样。在这种情况下,这并不是一个问题,因为数据规模很小,但通常大规模的现实世界问题具有极其稀疏的输入数据和许多特征(在线广告和文本分类是很好的例子)。在这种情况下,不建议失去这种稀疏性,因为等效的密集表示的内存和处理要求可能会随着许多百万特征的增加而迅速增加。我们可以使用StandardScaler并将withMean设置为false来避免这种情况。

现在我们准备使用扩展的特征集训练一个新的逻辑回归模型,然后我们将评估其性能。

val lrModelScaledCats = LogisticRegressionWithSGD.train(scaledDataCats, numIterations) 
val lrTotalCorrectScaledCats = scaledDataCats.map { point => 
  if (lrModelScaledCats.predict(point.features) == point.label) 1 else 0 
}.sum 
val lrAccuracyScaledCats = lrTotalCorrectScaledCats / numData 
val lrPredictionsVsTrueCats = scaledDataCats.map { point =>  
  (lrModelScaledCats.predict(point.features), point.label)  
} 
val lrMetricsScaledCats = new BinaryClassificationMetrics(lrPredictionsVsTrueCats) 
val lrPrCats = lrMetricsScaledCats.areaUnderPR 
val lrRocCats = lrMetricsScaledCats.areaUnderROC 
println(f"${lrModelScaledCats.getClass.getSimpleName}\nAccuracy: ${lrAccuracyScaledCats * 100}%2.4f%%\nArea under PR: ${lrPrCats * 100.0}%2.4f%%\nArea under ROC: ${lrRocCats * 100.0}%2.4f%%")  

您应该看到类似于这样的输出:

LogisticRegressionModel
Accuracy: 66.5720%
Area under PR: 75.7964%
Area under ROC: 66.5483%  

通过对我们的数据应用特征标准化转换,我们将准确度和 AUC 指标从 50%提高到 62%,然后通过将类别特征添加到我们的模型中,我们进一步提高到了 66%(记得对我们的新特征集应用标准化)。

比赛中最佳的模型性能是 AUC 为 0.88906(请参阅www.kaggle.com/c/stumbleupon/leaderboard/private)。

www.kaggle.com/c/stumbleupon/forums/t/5680/beating-the-benchmark-leaderboard-auc-0-878中概述了实现几乎与最高性能相当的方法。

请注意,我们尚未使用的特征仍然存在;尤其是在 boilerplate 变量中的文本特征。领先的竞赛提交主要使用 boilerplate 特征和基于原始文本内容的特征来实现他们的性能。正如我们之前看到的,虽然添加类别可以提高性能,但大多数变量并不是很有用作预测因子,而文本内容却具有很高的预测性。

研究一些在这些比赛中表现最佳的方法可以让您了解特征提取和工程在模型性能中起到了关键作用。

使用正确的数据形式

模型性能的另一个关键方面是使用每个模型的正确数据形式。之前我们看到,将朴素贝叶斯模型应用于我们的数值特征会导致性能非常差。这是因为模型本身存在缺陷吗?

在这种情况下,请记住 MLlib 实现了一个多项式模型。该模型适用于非零计数数据的输入形式。这可以包括分类特征的二进制表示(例如之前介绍的 1-of-k 编码)或频率数据(例如文档中单词出现的频率)。我们最初使用的数值特征不符合这种假定的输入分布,因此模型表现不佳可能并不奇怪。

为了说明这一点,我们将仅使用类别特征,当进行 1-of-k 编码时,这符合模型的正确形式。我们将创建一个新的数据集,如下所示:

val dataNB = records.map { r => 
  val trimmed = r.map(_.replaceAll("\"", "")) 
  val label = trimmed(r.size - 1).toInt 
  val categoryIdx = categories(r(3)) 
  val categoryFeatures = Array.ofDimDouble 
  categoryFeatures(categoryIdx) = 1.0 
  LabeledPoint(label, Vectors.dense(categoryFeatures)) 
} 

接下来,我们将训练一个新的朴素贝叶斯模型并评估其性能。

val nbModelCats = NaiveBayes.train(dataNB) 
val nbTotalCorrectCats = dataNB.map { point => 
  if (nbModelCats.predict(point.features) == point.label) 1 else 0 
}.sum 
val nbAccuracyCats = nbTotalCorrectCats / numData 
val nbPredictionsVsTrueCats = dataNB.map { point =>  
  (nbModelCats.predict(point.features), point.label)  
} 
val nbMetricsCats = new BinaryClassificationMetrics(nbPredictionsVsTrueCats) 
val nbPrCats = nbMetricsCats.areaUnderPR 
val nbRocCats = nbMetricsCats.areaUnderROC 
println(f"${nbModelCats.getClass.getSimpleName}\nAccuracy: ${nbAccuracyCats * 100}%2.4f%%\nArea under PR: ${nbPrCats * 100.0}%2.4f%%\nArea under ROC: ${nbRocCats * 100.0}%2.4f%%") 

您应该看到以下输出:

NaiveBayesModel
Accuracy: 60.9601%
Area under PR: 74.0522%
Area under ROC: 60.5138%

因此,通过确保我们使用正确形式的输入,我们将朴素贝叶斯模型的性能略微从 58%提高到 60%。

调整模型参数

前面的部分展示了特征提取和选择对模型性能的影响,以及输入数据的形式和模型对数据分布的假设。到目前为止,我们只是简单地讨论了模型参数,但它们在模型性能中也起着重要作用。

MLlib 的默认训练方法使用每个模型参数的默认值。让我们更深入地研究一下它们。

线性模型

逻辑回归和支持向量机共享相同的参数,因为它们使用相同的随机梯度下降SGD)的优化技术。它们只在应用的损失函数上有所不同。如果我们看一下 MLlib 中逻辑回归的类定义,我们会看到以下定义:

class LogisticRegressionWithSGD private ( 
  private var stepSize: Double, 
  private var numIterations: Int, 
  private var regParam: Double, 
  private var miniBatchFraction: Double) 
  extends GeneralizedLinearAlgorithm[LogisticRegressionModel] ... 

我们可以看到可以传递给构造函数的参数是stepSizenumIterationsregParamminiBatchFraction。其中,除了regParam之外,所有参数都与底层优化技术有关。

逻辑回归的实例化代码初始化了gradientupdateroptimizer,并为optimizer(在本例中为GradientDescent)设置了相关参数。

private val gradient = new LogisticGradient() 
private val updater = new SimpleUpdater() 
override val optimizer = new GradientDescent(gradient, updater) 
  .setStepSize(stepSize) 
  .setNumIterations(numIterations) 
  .setRegParam(regParam) 
  .setMiniBatchFraction(miniBatchFraction) 

LogisticGradient设置了定义我们逻辑回归模型的逻辑损失函数。

虽然对优化技术的详细处理超出了本书的范围,但 MLlib 为线性模型提供了两种优化器:SGD 和 L-BFGS。L-BFGS 通常更准确,并且参数更少需要调整。

SGD 是默认值,而 L-BGFS 目前只能通过LogisticRegressionWithLBFGS直接用于逻辑回归。自己试一试,并将结果与 SGD 找到的结果进行比较。

有关更多详细信息,请参阅spark.apache.org/docs/latest/mllib-optimization.html

为了调查剩余参数设置的影响,我们将创建一个辅助函数,它将根据一组参数输入训练逻辑回归模型。首先,我们将导入所需的类:

import org.apache.spark.rdd.RDD 
import org.apache.spark.mllib.optimization.Updater 
import org.apache.spark.mllib.optimization.SimpleUpdater 
import org.apache.spark.mllib.optimization.L1Updater 
import org.apache.spark.mllib.optimization.SquaredL2Updater 
import org.apache.spark.mllib.classification.ClassificationModel 

接下来,我们将定义一个辅助函数来训练给定一组输入的模型:

def trainWithParams(input: RDD[LabeledPoint], regParam: Double, numIterations: Int, updater: Updater, stepSize: Double) = { 
  val lr = new LogisticRegressionWithSGD 
  lr.optimizer.setNumIterations(numIterations).  
  setUpdater(updater).setRegParam(regParam).setStepSize(stepSize) 
  lr.run(input) 
} 

最后,我们将创建第二个辅助函数,以获取输入数据和分类模型,并生成相关的 AUC 指标:

def createMetrics(label: String, data: RDD[LabeledPoint], model: ClassificationModel) = { 
  val scoreAndLabels = data.map { point => 
    (model.predict(point.features), point.label) 
  } 
  val metrics = new BinaryClassificationMetrics(scoreAndLabels) 
  (label, metrics.areaUnderROC) 
} 

我们还将缓存我们的缩放数据集,包括类别,以加快速度

我们将使用多个模型训练运行来探索这些不同的参数设置,如下所示:

scaledDataCats.cache 

迭代

许多机器学习方法都是迭代的,通过多次迭代收敛到一个解(最小化所选损失函数的最优权重向量)。SGD 通常需要相对较少的迭代次数才能收敛到一个合理的解,但可以运行更多次迭代来改善解。我们可以通过尝试一些不同的numIterations参数设置,并像这样比较 AUC 结果来看到这一点:

val iterResults = Seq(1, 5, 10, 50).map { param => 
  val model = trainWithParams(scaledDataCats, 0.0, param, new  
SimpleUpdater, 1.0) 
  createMetrics(s"$param iterations", scaledDataCats, model) 
} 
iterResults.foreach { case (param, auc) => println(f"$param, AUC =  
${auc * 100}%2.2f%%") } 

你的输出应该是这样的:

1 iterations, AUC = 64.97%
5 iterations, AUC = 66.62%
10 iterations, AUC = 66.55%
50 iterations, AUC = 66.81%  

因此,我们可以看到一旦完成了一定数量的迭代,迭代次数对结果的影响很小。

步长

在 SGD 中,步长参数控制算法在更新模型权重向量之后每个训练样本时所采取的步骤方向的梯度。较大的步长可能加快收敛,但步长太大可能会导致收敛问题,因为好的解决方案被超越。学习率确定我们采取的步骤大小,以达到(局部或全局)最小值。换句话说,我们沿着目标函数创建的表面的斜率方向向下走,直到我们到达一个山谷。

我们可以看到改变步长的影响在这里:

val stepResults = Seq(0.001, 0.01, 0.1, 1.0, 10.0).map { param => 
  val model = trainWithParams(scaledDataCats, 0.0, numIterations, new SimpleUpdater, param) 
  createMetrics(s"$param step size", scaledDataCats, model) 
} 
stepResults.foreach { case (param, auc) => println(f"$param, AUC =  
${auc * 100}%2.2f%%") } 

这将给我们以下结果,显示增加步长太多可能开始对性能产生负面影响:

0.001 step size, AUC = 64.95%
0.01 step size, AUC = 65.00%
0.1 step size, AUC = 65.52%
1.0 step size, AUC = 66.55%
10.0 step size, AUC = 61.92%

正则化

在前面的逻辑回归代码中,我们简要介绍了Updater类。MLlib 中的Updater类实现了正则化。正则化可以通过有效地惩罚模型复杂性来帮助避免模型对训练数据的过度拟合。这可以通过向损失函数添加一个项来实现,该项作用是随着模型权重向量的函数增加损失。

在实际使用情况下,几乎总是需要正则化,但当特征维度非常高(即可以学习的有效变量权重数量很高)相对于训练样本数量时,正则化尤为重要。

当没有或很低的正则化时,模型可能会过拟合。没有正则化时,大多数模型会在训练数据集上过拟合。这是使用交叉验证技术进行模型拟合的一个关键原因(我们现在将介绍)。

在我们进一步进行之前,让我们定义一下过拟合和欠拟合数据的含义。过拟合发生在模型学习训练数据中的细节和噪音,从而对新数据的性能产生负面影响的程度。模型不应该过于严格地遵循训练数据集,在欠拟合中,模型既不能对训练数据建模,也不能推广到新数据。

相反,当应用正则化时,鼓励简化模型,当正则化很高时,模型性能可能会受到影响,导致数据欠拟合。

MLlib 中可用的正则化形式如下:

  • SimpleUpdater:这等同于没有正则化,是逻辑回归的默认值

  • SquaredL2Updater:这实现了基于权重向量的平方 L2 范数的正则化器;这是 SVM 模型的默认值

  • L1Updater:这应用基于权重向量的 L1 范数的正则化器;这可能导致权重向量中的稀疏解(因为不太重要的权重被拉向零)

正则化及其与优化的关系是一个广泛而深入研究的领域。有关更多信息,请参考以下链接:

让我们使用SquaredL2Updater来探索一系列正则化参数的影响。

val regResults = Seq(0.001, 0.01, 0.1, 1.0, 10.0).map { param => 
  val model = trainWithParams(scaledDataCats, param, numIterations, new SquaredL2Updater, 1.0) 
  createMetrics(s"$param L2 regularization parameter",  
scaledDataCats, model) 
} 
regResults.foreach { case (param, auc) => println(f"$param, AUC =  
${auc * 100}%2.2f%%") } 

你的输出应该像这样:

0.001 L2 regularization parameter, AUC = 66.55%
0.01 L2 regularization parameter, AUC = 66.55%
0.1 L2 regularization parameter, AUC = 66.63%
1.0 L2 regularization parameter, AUC = 66.04%
10.0 L2 regularization parameter, AUC = 35.33%  

正如我们所看到的,在正则化水平较低时,模型性能没有太大影响。然而,随着正则化的增加,我们可以看到欠拟合对我们模型评估的影响。

当使用 L1 正则化时,您将会得到类似的结果。通过对 AUC 指标进行相同的正则化参数评估,尝试使用 L1Updater。

决策树

决策树控制树的最大深度,从而控制模型的复杂性。更深的树会导致更复杂的模型,能够更好地拟合数据。

对于分类问题,我们还可以在GiniEntropy之间选择两种不纯度度量。

调整树深度和不纯度

我们将以与逻辑回归模型相似的方式来说明树深度的影响。

首先,我们需要在 Spark shell 中创建另一个辅助函数,如下所示:

import org.apache.spark.mllib.tree.impurity.Impurity 
import org.apache.spark.mllib.tree.impurity.Entropy 
import org.apache.spark.mllib.tree.impurity.Gini 

def trainDTWithParams(input: RDD[LabeledPoint], maxDepth: Int, impurity: Impurity) = { 
  DecisionTree.train(input, Algo.Classification, impurity, maxDepth) 
} 

现在,我们准备计算不同树深度设置下的 AUC 指标。在这个例子中,我们将简单地使用我们的原始数据集,因为我们不需要数据被标准化。

请注意,决策树模型通常不需要特征被标准化或归一化,也不需要分类特征被二进制编码。

首先,使用Entropy不纯度度量和不同的树深度来训练模型,如下所示:

val dtResultsEntropy = Seq(1, 2, 3, 4, 5, 10, 20).map { param => 
  val model = trainDTWithParams(data, param, Entropy) 
  val scoreAndLabels = data.map { point => 
    val score = model.predict(point.features) 
    (if (score > 0.5) 1.0 else 0.0, point.label) 
  } 
  val metrics = new BinaryClassificationMetrics(scoreAndLabels) 
  (s"$param tree depth", metrics.areaUnderROC) 
} 
dtResultsEntropy.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%") } 

上述代码应该输出以下结果:

1 tree depth, AUC = 59.33%
2 tree depth, AUC = 61.68%
3 tree depth, AUC = 62.61%
4 tree depth, AUC = 63.63%
5 tree depth, AUC = 64.88%
10 tree depth, AUC = 76.26%
20 tree depth, AUC = 98.45%  

接下来,我们将使用Gini不纯度度量执行相同的计算(我们省略了代码,因为它非常相似,但可以在代码包中找到)。你的结果应该看起来像这样:

1 tree depth, AUC = 59.33%
2 tree depth, AUC = 61.68%
3 tree depth, AUC = 62.61%
4 tree depth, AUC = 63.63%
5 tree depth, AUC = 64.89%
10 tree depth, AUC = 78.37%
20 tree depth, AUC = 98.87%  

从前面的结果中可以看出,增加树深度参数会导致更准确的模型(正如预期的那样,因为模型允许在更大的树深度下变得更复杂)。很可能在更高的树深度下,模型会显著地过度拟合数据集。随着树深度的增加,泛化能力会降低,泛化是指机器学习模型学习的概念如何适用于模型从未见过的示例。

这两种不纯度度量的性能几乎没有什么区别。

朴素贝叶斯模型

最后,让我们看看改变朴素贝叶斯的lambda参数会产生什么影响。这个参数控制加法平滑,处理当classfeature值在数据集中没有同时出现的情况。

更多关于加法平滑的细节,请参见en.wikipedia.org/wiki/Additive_smoothing

我们将采用与之前相同的方法,首先创建一个方便的训练函数,然后使用不同水平的lambda来训练模型,如下所示:

def trainNBWithParams(input: RDD[LabeledPoint], lambda: Double) = { 
  val nb = new NaiveBayes 
  nb.setLambda(lambda) 
  nb.run(input) 
} 
val nbResults = Seq(0.001, 0.01, 0.1, 1.0, 10.0).map { param => 
  val model = trainNBWithParams(dataNB, param) 
  val scoreAndLabels = dataNB.map { point => 
    (model.predict(point.features), point.label) 
  } 
  val metrics = new BinaryClassificationMetrics(scoreAndLabels) 
  (s"$param lambda", metrics.areaUnderROC) 
} 
nbResults.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%")  
} 

训练的结果如下:

0.001 lambda, AUC = 60.51%
0.01 lambda, AUC = 60.51%
0.1 lambda, AUC = 60.51%
1.0 lambda, AUC = 60.51%
10.0 lambda, AUC = 60.51%  

我们可以看到在这种情况下lambda没有影响,因为如果特征和类标签的组合在数据集中没有出现在一起,这不会成为问题。

交叉验证

到目前为止,在这本书中,我们只是简要提到了交叉验证和样本外测试的概念。交叉验证是现实世界机器学习的关键部分,是许多模型选择和参数调整流程的核心。

交叉验证的基本思想是我们想知道我们的模型在未见数据上的表现如何。在真实的、实时数据上评估这一点(例如在生产系统中)是有风险的,因为我们并不真正知道训练好的模型是否是最佳的,能够对新数据进行准确的预测。正如我们之前在正则化方面看到的那样,我们的模型可能已经过度拟合了训练数据,在未经训练的数据上做出预测可能很差。

交叉验证提供了一种机制,我们可以使用可用数据集的一部分来训练我们的模型,另一部分来评估这个模型的性能。由于模型在训练阶段没有见过这部分数据,当在数据集的这部分上评估模型的性能时,可以给我们一个关于我们的模型在新数据点上的泛化能力的估计。

在这里,我们将使用训练-测试分离来实现一个简单的交叉验证评估方法。我们将把我们的数据集分成两个不重叠的部分。第一个数据集用于训练我们的模型,称为训练集。第二个数据集,称为测试集留出集,用于使用我们选择的评估指标评估我们的模型的性能。实际使用的常见分割包括 50/50、60/40 和 80/20 的分割,但只要训练集不太小以至于模型无法学习(通常至少 50%是一个实际的最小值),你可以使用任何分割。

在许多情况下,会创建三组数据:一个训练集,一个评估集(类似于前面提到的测试集,用于调整模型参数,如 lambda 和步长),以及一个测试集(从不用于训练模型或调整任何参数,只用于生成对完全未见数据的估计真实性能)。

在这里,我们将探讨一个简单的训练-测试分离方法。还有许多更详尽和复杂的交叉验证技术。

一个流行的例子是K 折交叉验证,其中数据集被分成K个不重叠的折叠。模型在K-1个数据折叠上进行训练,并在剩下的保留的折叠上进行测试。这个过程重复K次,然后对结果进行平均以得到交叉验证分数。训练-测试分割实际上就像是两折交叉验证。

其他方法包括留一交叉验证和随机抽样。更多细节请参见en.wikipedia.org/wiki/Cross-validation_(statistics)的文章。

首先,我们将把数据集分成 60%的训练集和 40%的测试集(我们将在这里使用一个常数随机种子 123,以确保我们获得相同的结果以便进行说明)。

val trainTestSplit = scaledDataCats.randomSplit(Array(0.6, 0.4), 123) 
val train = trainTestSplit(0) 
val test = trainTestSplit(1) 

接下来,我们将计算感兴趣的评估指标(再次,我们将使用 AUC)的一系列正则化参数设置。请注意,这里我们将使用更精细的步长在评估的正则化参数之间,以更好地说明 AUC 的差异,在这种情况下差异非常小。

val regResultsTest = Seq(0.0, 0.001, 0.0025, 0.005, 0.01).map { param => 
  val model = trainWithParams(train, param, numIterations, new SquaredL2Updater, 1.0) 
  createMetrics(s"$param L2 regularization parameter", test, model) 
} 
regResultsTest.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.6f%%")  
} 

接下来,我们将计算在训练集上训练的结果,以及在测试集上评估的结果,如下所示:

0.0 L2 regularization parameter, AUC = 66.480874%
0.001 L2 regularization parameter, AUC = 66.480874%
0.0025 L2 regularization parameter, AUC = 66.515027%
0.005 L2 regularization parameter, AUC = 66.515027%
0.01 L2 regularization parameter, AUC = 66.549180%  

现在,让我们将这与在训练集上进行训练和测试的结果进行比较。

(这是我们之前在所有数据上进行训练和测试的做法)。同样,我们将省略代码,因为它非常相似(但它在代码包中是可用的):

0.0 L2 regularization parameter, AUC = 66.260311%
0.001 L2 regularization parameter, AUC = 66.260311%
0.0025 L2 regularization parameter, AUC = 66.260311%
0.005 L2 regularization parameter, AUC = 66.238294%
0.01 L2 regularization parameter, AUC = 66.238294%  

因此,我们可以看到当我们在相同的数据集上训练和评估我们的模型时,通常在正则化较低时会获得最高的性能。这是因为我们的模型已经看到了所有的数据点,并且在低水平的正则化下,它可以过度拟合数据集并获得更高的性能。

相比之下,当我们在一个数据集上训练并在另一个数据集上测试时,通常略高水平的正则化会导致更好的测试集性能。

在交叉验证中,我们通常会找到参数设置(包括正则化以及其他各种参数,如步长等),以获得最佳的测试集性能。然后我们将使用这些参数设置在所有数据上重新训练模型,以便在新数据上进行预测。

回想一下第五章,使用 Spark 构建推荐引擎,我们没有涉及交叉验证。您可以应用我们之前使用的相同技术,将该章节中的评分数据集分成训练集和测试集。然后,您可以尝试在训练集上尝试不同的参数设置,同时在测试集上评估 MSE 和 MAP 性能指标,方式类似于我们之前所做的。试一试吧!

总结

在本章中,我们介绍了 Spark MLlib 中可用的各种分类模型,并且我们看到了如何在输入数据上训练模型,以及如何使用标准指标和度量来评估它们的性能。我们还探讨了如何应用一些先前介绍的技术来转换我们的特征。最后,我们调查了使用正确的输入数据格式或分布对模型性能的影响,以及增加更多数据对我们的模型,调整模型参数和实施交叉验证的影响。

在下一章中,我们将采用类似的方法来深入 MLlib 的回归模型。

第七章:使用 Spark 构建回归模型

在本章中,我们将继续探讨第六章中涵盖的内容,使用 Spark 构建分类模型。虽然分类模型处理代表离散类别的结果,但回归模型涉及可以取任何实际值的目标变量。基本原理非常相似--我们希望找到一个将输入特征映射到预测目标变量的模型。与分类一样,回归也是一种监督学习形式。

回归模型可用于预测几乎任何感兴趣的变量。一些例子包括以下内容:

  • 预测股票回报和其他经济变量

  • 预测贷款违约损失金额(这可以与预测违约概率的分类模型相结合,而回归模型则在违约情况下预测金额)

  • 推荐(来自第五章的交替最小二乘因子化模型,使用 Spark 构建推荐引擎,在每次迭代中使用线性回归)

  • 基于用户行为和消费模式,在零售、移动或其他业务中预测客户终身价值CLTV

在本章的不同部分,我们将做以下工作:

  • 介绍 ML 中可用的各种回归模型

  • 探索回归模型的特征提取和目标变量转换

  • 使用 ML 训练多个回归模型

  • 查看如何使用训练好的模型进行预测

  • 使用交叉验证调查回归的各种参数设置对性能的影响

回归模型的类型

线性模型(或广义线性模型)的核心思想是,我们将感兴趣的预测结果(通常称为目标或因变量)建模为应用于输入变量(也称为特征或自变量)的简单线性预测器的函数。

y = f(w^Tx)

在这里,y是目标变量,w是参数向量(称为权重向量),x是输入特征向量。

w^Tx是权重向量w和特征向量x的线性预测器(或向量点积)。对于这个线性预测器,我们应用了一个函数f(称为链接函数)。

线性模型实际上可以通过改变链接函数来用于分类和回归,标准线性回归使用恒等链接(即y = w^Tx直接),而二元分类使用其他链接函数,如本文所述。

Spark 的 ML 库提供了不同的回归模型,如下所示:

  • 线性回归

  • 广义线性回归

  • 逻辑回归

  • 决策树

  • 随机森林回归

  • 梯度提升树

  • 生存回归

  • 等温回归

  • 岭回归

回归模型定义了因变量和一个或多个自变量之间的关系。它构建了最适合独立变量或特征值的模型。

与支持向量机和逻辑回归等分类模型不同,线性回归用于预测具有广义值的因变量的值,而不是预测确切的类标签。

线性回归模型本质上与其分类对应物相同,唯一的区别是线性回归模型使用不同的损失函数、相关链接函数和决策函数。Spark ML 提供了标准的最小二乘回归模型(尽管计划使用其他类型的广义线性回归模型进行回归)。

最小二乘回归

你可能还记得第六章《使用 Spark 构建分类模型》中提到,广义线性模型可以应用各种损失函数。最小二乘法使用的损失函数是平方损失,定义如下:

½ (w^Tx - y)²

在这里,与分类设置一样,y是目标变量(这次是实值),w是权重向量,x是特征向量。

相关的链接函数是恒等链接,决策函数也是恒等函数,通常在回归中不会应用阈值。因此,模型的预测简单地是y = w^Tx

ML 库中的标准最小二乘回归不使用正则化。正则化用于解决过拟合问题。观察平方损失函数,我们可以看到对于错误预测的点,损失会被放大,因为损失被平方了。这意味着最小二乘回归容易受到数据集中的异常值和过拟合的影响。通常,对于分类问题,我们应该在实践中应用一定程度的正则化。

带有 L2 正则化的线性回归通常称为岭回归,而应用 L1 正则化称为套索。

当数据集较小或示例数量较少时,模型过拟合的倾向非常高,因此强烈建议使用 L1、L2 或弹性网络等正则化器。

有关 Spark MLlib 文档中线性最小二乘法的部分,请参阅spark.apache.org/docs/latest/mllib-linear-methods.html#linear-least-squares-lasso-and-ridge-regression以获取更多信息。

回归的决策树

就像使用线性模型进行回归任务需要改变使用的损失函数一样,使用决策树进行回归需要改变使用的节点不纯度度量。不纯度度量称为方差,定义方式与最小二乘线性回归的平方损失相同。

有关决策树算法和回归不纯度度量的更多详细信息,请参阅 Spark 文档中的MLlib - 决策树部分spark.apache.org/docs/latest/mllib-decision-tree.html

现在,我们将绘制一个只有一个输入变量的回归问题的简单示例,横轴显示在x轴上,目标变量显示在y轴上。线性模型的预测函数由红色虚线表示,而决策树的预测函数由绿色虚线表示。我们可以看到决策树允许将更复杂、非线性的模型拟合到数据中:

评估回归模型的性能

我们在第六章《使用 Spark 构建分类模型》中看到,分类模型的评估方法通常侧重于与实际类成员关联的预测类成员相关的测量。这些是二元结果(预测类是否正确),模型是否刚好预测正确并不那么重要;我们最关心的是正确和错误预测的数量。

在处理回归模型时,我们很少能够精确预测目标变量,因为目标变量可以取任意实值。然而,我们自然希望了解我们的预测值与真实值的偏差有多大,因此我们将利用一个考虑整体偏差的度量。

用于衡量回归模型性能的一些标准评估指标包括均方误差MSE)和均方根误差RMSE),平均绝对误差MAE),R 平方系数等等。

均方误差和均方根误差

MSE 是用作最小二乘回归的损失函数的平方误差的平均值:

它是所有数据点的预测值和实际目标变量之间差异的平方之和,除以数据点的数量。

RMSE 是 MSE 的平方根。MSE 以目标变量的平方为单位进行测量,而 RMSE 以与目标变量相同的单位进行测量。由于其公式,MSE,就像它导出的平方损失函数一样,有效地严厉地惩罚更大的误差。

为了评估基于误差度量的平均预测,我们将首先对LabeledPoint实例的 RDD 中的每个输入特征向量进行预测,通过使用一个函数计算每个记录的误差,该函数将预测值和真实目标值作为输入。这将返回一个包含误差值的[Double] RDD。然后我们可以使用包含双精度值的 RDD 的平均方法找到平均值。

让我们定义我们的平方误差函数如下:

Scala  
def squaredError(actual:Double, pred : Double) : Double = { 
  return Math.pow( (pred - actual), 2.0) 
} 

平均绝对误差

MAE 是预测值和实际目标之间绝对差异的平均值,表示如下:

MAE 在原则上类似于 MSE,但它不像 MSE 那样严厉地惩罚大偏差。

我们计算 MAE 的函数如下:

Scala 
def absError(actual:Double, pred: Double) : Double = { 
  return Math.abs( (pred - actual)) 
} 

均方根对数误差

这个测量并不像 MSE 和 MAE 那样被广泛使用,但它被用作使用自行车共享数据集的 Kaggle 竞赛的度量标准。实际上,它是对预测值和目标值进行对数变换后的 RMSE。当目标变量的范围很大,并且在预测值和目标值本身很高时,您不一定希望惩罚大误差时,这个测量是有用的。当您关心百分比误差而不是绝对误差的值时,它也是有效的。

Kaggle 竞赛评估页面可以在www.kaggle.com/c/bike-sharing-demand/details/evaluation找到。

计算 RMSLE 的函数如下所示:

Scala 
def squaredLogError(actual:Double, pred : Double) : Double = { 
  return Math.pow( (Math.log(pred +1) - Math.log(actual +1)), 2.0) 
} 

R 平方系数

R 平方系数,也称为确定系数,是衡量模型拟合数据集的程度的指标。它通常用于统计学。它衡量目标变量的变化程度;这是由输入特征的变化来解释的。R 平方系数通常取 0 到 1 之间的值,其中 1 等于模型的完美拟合。

从数据中提取正确的特征

由于回归的基础模型与分类情况相同,我们可以使用相同的方法来创建输入特征。唯一的实际区别是目标现在是一个实值变量,而不是一个分类变量。ML 库中的LabeledPoint类已经考虑到了这一点,因为label字段是Double类型,所以它可以处理这两种情况。

从自行车共享数据集中提取特征

为了说明本章中的概念,我们将使用自行车共享数据集。该数据集包含自行车共享系统中每小时自行车租赁数量的记录。它还包含与日期、时间、天气、季节和假日信息相关的变量。

数据集可在archive.ics.uci.edu/ml/datasets/Bike+Sharing+Dataset找到。

点击数据文件夹链接,然后下载Bike-Sharing-Dataset.zip文件。

自行车共享数据是由波尔图大学的 Hadi Fanaee-T 丰富了天气和季节数据,并在以下论文中使用:

Fanaee-T,Hadi 和 Gama Joao,事件标签组合集成检测器和背景知识,人工智能进展,第 1-15 页,斯普林格柏林海德堡,2013 年。

该论文可在link.springer.com/article/10.1007%2Fs13748-013-0040-3找到。

一旦你下载了Bike-Sharing-Dataset.zip文件,解压它。这将创建一个名为Bike-Sharing-Dataset的目录,其中包含day.csvhour.csvReadme.txt文件。

Readme.txt文件包含有关数据集的信息,包括变量名称和描述。看一下文件,你会发现我们有以下可用的变量:

  • instant:这是记录 ID

  • dteday:这是原始日期

  • season:这指的是不同的季节,如春季、夏季、冬季和秋季

  • yr:这是年份(2011 或 2012)

  • mnth:这是一年中的月份

  • hr:这是一天中的小时

  • holiday:这显示这一天是否是假日

  • weekday:这是一周的某一天

  • workingday:这指的是这一天是否是工作日

  • weathersit:这是描述特定时间天气的分类变量

  • temp:这是标准化的温度

  • atemp:这是标准化的体感温度

  • hum:这是标准化的湿度

  • 风速:这是标准化的风速

  • cnt:这是目标变量,即该小时的自行车租赁次数

我们将使用hour.csv中包含的每小时数据。如果你看一下数据集的第一行,你会发现它包含列名作为标题。以下代码片段打印标题和前 20 条记录:

val spark = SparkSession 
  .builder 
  .appName("BikeSharing") 
  .master("local[1]") 
  .getOrCreate() 

// read from csv 
val df = spark.read.format("csv").option("header", 
   "true").load("/dataset/BikeSharing/hour.csv") 
df.cache() 

df.registerTempTable("BikeSharing") 
print(df.count()) 

spark.sql("SELECT * FROM BikeSharing").show() 

前面的代码片段应该输出以下结果:

 root
 |-- instant: integer (nullable = true)
 |-- dteday: timestamp (nullable = true)
 |-- season: integer (nullable = true)
 |-- yr: integer (nullable = true)
 |-- mnth: integer (nullable = true)
 |-- hr: integer (nullable = true)
 |-- holiday: integer (nullable = true)
 |-- weekday: integer (nullable = true)
 |-- workingday: integer (nullable = true)
 |-- weathersit: integer (nullable = true)
 |-- temp: double (nullable = true)
 |-- atemp: double (nullable = true)
 |-- hum: double (nullable = true)
 |-- windspeed: double (nullable = true)
 |-- casual: integer (nullable = true)
 |-- registered: integer (nullable = true)
 |-- cnt: integer (nullable = true)

我们将使用 Scala 来演示本章的示例。本章的源代码可以在以下位置找到github.com/ml-resources/spark-ml/tree/branch-ed2/Chapter_07

我们将像往常一样加载数据集并对其进行检查;从前一个数据框中获取记录计数如下:

print(df.count()) 

这应该输出以下结果:

    17,379

所以,我们的数据集中有 17,379 条每小时的记录。我们已经检查了列名。我们将忽略记录 ID 和原始日期列。我们还将忽略casualregistered计数目标变量,并专注于总计变量cnt(这是其他两个计数的总和)。我们剩下 12 个变量。前 8 个是分类的,而最后 4 个是标准化的实值变量。

// drop record id, date, casual and registered columns 
val df1 = 
   df.drop("instant").drop("dteday").drop("casual")
   .drop("registered") 
df1.printSchema() 

这段代码的最后一部分应该输出以下结果:

 root
 |-- season: integer (nullable = true)
 |-- yr: integer (nullable = true)
 |-- mnth: integer (nullable = true)
 |-- hr: integer (nullable = true)
 |-- holiday: integer (nullable = true)
 |-- weekday: integer (nullable = true)
 |-- workingday: integer (nullable = true)
 |-- weathersit: integer (nullable = true)
 |-- temp: double (nullable = true)
 |-- atemp: double (nullable = true)
 |-- hum: double (nullable = true)
 |-- windspeed: double (nullable = true)
 |-- cnt: integer (nullable = true)

所有列都被转换为 double;以下代码片段显示了如何做到这一点:

// convert to double: season,yr,mnth,hr,holiday,weekday,workingday,weathersit,temp,atemp,hum,windspeed,casual,registered,cnt 
val df2 = df1.withColumn("season", 
   df1("season").cast("double")).withColumn("yr", 
   df1("yr").cast("double")) 
  .withColumn("mnth", df1("mnth").cast("double")).withColumn("hr", 
     df1("hr").cast("double")).withColumn("holiday", 
     df1("holiday").cast("double")) 
  .withColumn("weekday", 
     df1("weekday").cast("double")).withColumn("workingday", 
     df1("workingday").cast("double")).withColumn("weathersit", 
     df1("weathersit").cast("double")) 
  .withColumn("temp", 
     df1("temp").cast("double")).withColumn("atemp", 
     df1("atemp").cast("double")).withColumn("hum", 
     df1("hum").cast("double")) 
  .withColumn("windspeed", 
     df1("windspeed").cast("double")).withColumn("label", 
     df1("label").cast("double")) 

df2.printSchema() 

前面的代码应该输出以下结果:

 root
 |-- season: double (nullable = true)
 |-- yr: double (nullable = true)
 |-- mnth: double (nullable = true)
 |-- hr: double (nullable = true)
 |-- holiday: double (nullable = true)
 |-- weekday: double (nullable = true)
 |-- workingday: double (nullable = true)
 |-- weathersit: double (nullable = true)
 |-- temp: double (nullable = true)
 |-- atemp: double (nullable = true)
 |-- hum: double (nullable = true)
 |-- windspeed: double (nullable = true)
 |-- label: double (nullable = true)

自行车共享数据集是分类的,需要使用向量组装器向量索引器进行处理,如下所述:

  • 向量组装器是一个转换器,它将一系列列组合成单个向量列。它将原始特征组合成特征向量,以便训练线性回归和决策树等 ML 模型。

  • 向量索引器索引从向量组装器传递的分类特征。它会自动决定哪些特征是分类的,并将实际值转换为类别索引。

在我们的情况下,df2 中除了label之外的所有列都被VectorAssembler转换为rawFeatures

给定类型为Vector的输入列和名为maxCategoriesparam,它根据不同的值决定哪些特征应该是分类的,其中最多有maxCategories的特征被声明为分类的。

// drop label and create feature vector 
val df3 = df2.drop("label") 
val featureCols = df3.columns 

val vectorAssembler = new 
   VectorAssembler().setInputCols(featureCols)
   .setOutputCol("rawFeatures") 
val vectorIndexer = new 
   VectorIndexer().setInputCol("rawFeatures")
   .setOutputCol("features").setMaxCategories(4) 

完整的代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/regression/bikesharing/BikeSharingExecutor.scala找到。

训练和使用回归模型

回归模型的训练遵循与分类模型相同的程序。我们只需将训练数据传递给相关的训练方法。

BikeSharingExecutor

BikeSharingExecutor对象可用于选择和运行相应的回归模型,例如,要运行LinearRegression并执行线性回归管道,将程序参数设置为LR_<type>,其中type是数据格式;对于其他命令,请参考以下代码片段:

def executeCommand(arg: String, vectorAssembler: VectorAssembler, 
   vectorIndexer: VectorIndexer, dataFrame: DataFrame, spark: 
   SparkSession) = arg match { 
    case "LR_Vectors" => 
     LinearRegressionPipeline.linearRegressionWithVectorFormat
     (vectorAssembler, vectorIndexer, dataFrame) 
    case "LR_SVM" => 
     LinearRegressionPipeline.linearRegressionWithSVMFormat(spark) 

    case "GLR_Vectors" => 
     GeneralizedLinearRegressionPipeline
     .genLinearRegressionWithVectorFormat(vectorAssembler, 
      vectorIndexer, dataFrame) 
    case "GLR_SVM"=> 
     GeneralizedLinearRegressionPipeline
     .genLinearRegressionWithSVMFormat(spark) 

    case "DT_Vectors" => DecisionTreeRegressionPipeline
     .decTreeRegressionWithVectorFormat(vectorAssembler, 
     vectorIndexer, dataFrame) 
    case "DT_SVM"=> 
     GeneralizedLinearRegressionPipeline
     .genLinearRegressionWithSVMFormat(spark) 

    case "RF_Vectors" => 
     RandomForestRegressionPipeline
     .randForestRegressionWithVectorFormat(vectorAssembler, 
     vectorIndexer, dataFrame) 
    case "RF_SVM"=> 
     RandomForestRegressionPipeline
     .randForestRegressionWithSVMFormat(spark) 

    case "GBT_Vectors" => 
     GradientBoostedTreeRegressorPipeline
     .gbtRegressionWithVectorFormat(vectorAssembler, vectorIndexer, 
     dataFrame) 
    case "GBT_SVM"=> 
     GradientBoostedTreeRegressorPipeline
     .gbtRegressionWithSVMFormat(spark) 

} 

代码清单可在此链接找到:

github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/regression/bikesharing/BikeSharingExecutor.scala

在自行车共享数据集上训练回归模型

线性回归

线性回归是最常用的算法。回归分析的核心是通过数据图拟合一条直线的任务。线性方程式由y = c + bx描述,其中y* = 估计的因变量,c = 常数,b = 回归系数,x = 自变量。

让我们通过将自行车共享数据集分为 80%的训练和 20%的测试,使用 Spark 的回归评估器使用LinearRegression构建模型,并获得关于测试数据的评估指标。linearRegressionWithVectorFormat方法使用分类数据,而linearRegressionWithSVMFormat使用Bike-sharing数据集的libsvm格式。

def linearRegressionWithVectorFormat(vectorAssembler: 
   VectorAssembler, vectorIndexer: VectorIndexer, dataFrame: 
   DataFrame) = { 
  val lr = new LinearRegression() 
    .setFeaturesCol("features") 
    .setLabelCol("label") 
    .setRegParam(0.1) 
    .setElasticNetParam(1.0) 
    .setMaxIter(10) 

  val pipeline = new Pipeline().setStages(Array(vectorAssembler, 
   vectorIndexer, lr)) 

  val Array(training, test) = dataFrame.randomSplit(Array(0.8, 
   0.2), seed = 12345) 

  val model = pipeline.fit(training) 

  val fullPredictions = model.transform(test).cache() 
  val predictions = 
   fullPredictions.select("prediction").rdd.map(_.getDouble(0)) 
  val labels = 
   fullPredictions.select("label").rdd.map(_.getDouble(0)) 
  val RMSE = new 
   RegressionMetrics(predictions.zip(labels)).rootMeanSquaredError 
  println(s"  Root mean squared error (RMSE): $RMSE") 
} 

def linearRegressionWithSVMFormat(spark: SparkSession) = { 
  // Load training data 
  val training = spark.read.format("libsvm") 
    .load("/dataset/BikeSharing/lsvmHours.txt") 

  val lr = new LinearRegression() 
    .setMaxIter(10) 
    .setRegParam(0.3) 
    .setElasticNetParam(0.8) 

  // Fit the model 
  val lrModel = lr.fit(training) 

  // Print the coefficients and intercept for linear regression 
  println(s"Coefficients: ${lrModel.coefficients} Intercept: 
   ${lrModel.intercept}") 

  // Summarize the model over the training set and print out some 
   metrics 
  val trainingSummary = lrModel.summary 
  println(s"numIterations: ${trainingSummary.totalIterations}") 
  println(s"objectiveHistory: 
   ${trainingSummary.objectiveHistory.toList}") 
  trainingSummary.residuals.show() 
  println(s"RMSE: ${trainingSummary.rootMeanSquaredError}") 
  println(s"r2: ${trainingSummary.r2}") 
} 

前面的代码应该显示以下输出。请注意,残差代表表达式残差:(标签-预测值)

+-------------------+
|          residuals|
+-------------------+
|  32.92325797801143|
|  59.97614044359903|
|  35.80737062786482|
|-12.509886468051075|
|-25.979774633117792|
|-29.352862474201224|
|-5.9517346926691435|
| 18.453701019500947|
|-24.859327293384787|
| -47.14282080103287|
| -27.50652100848832|
| 21.865309097336535|
|  4.037722798853395|
|-25.691348213368343|
| -13.59830538387368|
|  9.336691727080336|
|  12.83461983259582|
|  -20.5026155752185|
| -34.83240621318937|
| -34.30229437825615|
+-------------------+
only showing top 20 rows
RMSE: 149.54567868651284
r2: 0.3202369690447968

代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/regression/bikesharing/LinearRegressionPipeline.scala找到。

广义线性回归

线性回归遵循高斯分布,而广义线性模型GLM)是线性模型的规范,其中响应变量Y遵循指数分布族中的某个分布。

让我们通过将自行车共享数据集分为 80%的训练和 20%的测试,使用 Spark 的回归评估器使用GeneralizedLinearRegression构建模型,并获得关于测试数据的评估指标。

@transient lazy val logger = Logger.getLogger(getClass.getName) 

def genLinearRegressionWithVectorFormat(vectorAssembler: 
   VectorAssembler, vectorIndexer: VectorIndexer, dataFrame: 
   DataFrame) = { 
   val lr = new GeneralizedLinearRegression() 
    .setFeaturesCol("features") 
    .setLabelCol("label") 
    .setFamily("gaussian") 
    .setLink("identity") 
    .setMaxIter(10) 
    .setRegParam(0.3) 

  val pipeline = new Pipeline().setStages(Array(vectorAssembler, 
   vectorIndexer, lr)) 

  val Array(training, test) = dataFrame.randomSplit(Array(0.8, 
   0.2), seed = 12345) 

  val model = pipeline.fit(training) 

  val fullPredictions = model.transform(test).cache() 
  val predictions = 
   fullPredictions.select("prediction").rdd.map(_.getDouble(0)) 
  val labels = 
   fullPredictions.select("label").rdd.map(_.getDouble(0)) 
  val RMSE = new 
   RegressionMetrics(predictions.zip(labels)).rootMeanSquaredError 
  println(s"  Root mean squared error (RMSE): $RMSE") 
} 

def genLinearRegressionWithSVMFormat(spark: SparkSession) = { 
  // Load training data 
  val training = spark.read.format("libsvm") 
    .load("/dataset/BikeSharing/lsvmHours.txt") 

  val lr = new GeneralizedLinearRegression() 
    .setFamily("gaussian") 
    .setLink("identity") 
    .setMaxIter(10) 
    .setRegParam(0.3) 

  // Fit the model 
  val model = lr.fit(training) 

  // Print the coefficients and intercept for generalized linear 
   regression model 
  println(s"Coefficients: ${model.coefficients}") 
  println(s"Intercept: ${model.intercept}") 

  // Summarize the model over the training set and print out some 
   metrics 
  val summary = model.summary 
  println(s"Coefficient Standard Errors: 
   ${summary.coefficientStandardErrors.mkString(",")}") 
  println(s"T Values: ${summary.tValues.mkString(",")}") 
  println(s"P Values: ${summary.pValues.mkString(",")}") 
  println(s"Dispersion: ${summary.dispersion}") 
  println(s"Null Deviance: ${summary.nullDeviance}") 
  println(s"Residual Degree Of Freedom Null: 
   ${summary.residualDegreeOfFreedomNull}") 
  println(s"Deviance: ${summary.deviance}") 
  println(s"Residual Degree Of Freedom: 
   ${summary.residualDegreeOfFreedom}") 
  println(s"AIC: ${summary.aic}") 
  println("Deviance Residuals: ") 
  summary.residuals().show()   
} 

这应该输出以下结果:

估计系数和截距的标准误差。

如果[GeneralizedLinearRegression.fitIntercept]设置为 true,则返回的最后一个元素对应于截距。

前面代码中的系数标准误差如下:

1.1353970394903834,2.2827202289405677,0.5060828045490352,0.1735367945
   7103457,7.062338310890969,0.5694233355369813,2.5250738792716176,
2.0099641224706573,0.7596421898012983,0.6228803024758551,0.0735818071
   8894239,0.30550603737503224,12.369537640641184

估计系数和截距的 T 统计量如下:

T Values: 15.186791802016964,33.26578339676457,-
   11.27632316133038,8.658129103690262,-
   3.8034120518318013,2.6451862430890807,0.9799958329796699,
3.731755243874297,4.957582264860384,6.02053185645345,-
   39.290272209592864,5.5283417898112726,-0.7966500413552742

估计系数和截距的双侧 p 值如下:

P Values: 0.0,0.0,0.0,0.0,1.4320532622846827E-
   4,0.008171946193283652,0.3271018275330657,1.907562616410008E-
   4,7.204877614519489E-7,
1.773422964035376E-9,0.0,3.2792739856901676E-8,0.42566519676340153

离散度如下:

Dispersion: 22378.414478769333

拟合模型的离散度对于“二项式”和“泊松”族取 1.0,否则由残差 Pearson 卡方统计量(定义为 Pearson 残差的平方和)除以残差自由度估计。

前面代码的空偏差输出如下:

Null Deviance: 5.717615910707208E8

残差自由度如下:

Residual Degree Of Freedom Null: 17378

在逻辑回归分析中,偏差用来代替平方和的计算。偏差类似于线性回归中的平方和计算,是对逻辑回归模型中数据拟合不足的度量。当“饱和”模型可用时(具有理论上完美的拟合模型),通过将给定模型与饱和模型进行比较来计算偏差。

偏差:3.886235458383082E8

参考:en.wikipedia.org/wiki/Logistic_regression

自由度

自由度的概念是从样本中估计总体统计量的原则的核心。 “自由度”通常缩写为 df。

将 df 视为在从另一个估计值中估计一个统计量时需要放置的数学限制。前面的代码将产生以下输出:

Residual Degree Of Freedom: 17366

阿凯克信息准则(AIC)是对给定数据集的统计模型相对质量的度量。给定数据的一组模型,AIC 估计每个模型相对于其他模型的质量。因此,AIC 提供了模型选择的一种方法。

参考:en.wikipedia.org/wiki/Akaike_information_criterion

拟合模型输出的 AIC 如下:

AIC: 223399.95490762248
+-------------------+
|  devianceResiduals|
+-------------------+
| 32.385412453563546|
|   59.5079185994115|
|  34.98037491140896|
|-13.503450469022432|
|-27.005954440659032|
|-30.197952952158246|
| -7.039656861683778|
| 17.320193923055445|
|  -26.0159703272054|
| -48.69166247116218|
| -29.50984967584955|
| 20.520222192742004|
| 1.6551311183207815|
|-28.524373674665213|
|-16.337935852841838|
|  6.441923904310045|
|   9.91072545492193|
|-23.418896074866524|
|-37.870797650696346|
|-37.373301622332946|
+-------------------+
only showing top 20 rows

完整的代码清单可在此链接找到:

github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/regression/bikesharing/GeneralizedLinearRegressionPipeline.scala

决策树回归

决策树模型是一种强大的、非概率的技术,可以捕捉更复杂的非线性模式和特征交互。它们已被证明在许多任务上表现良好,相对容易理解和解释,可以处理分类和数值特征,并且不需要输入数据进行缩放或标准化。它们非常适合包含在集成方法中(例如,决策树模型的集成,称为决策森林)。

决策树算法是一种自顶向下的方法,从根节点(或特征)开始,然后在每一步选择一个特征,该特征通过信息增益来衡量数据集的最佳拆分。信息增益是从节点不纯度(标签在节点上相似或同质的程度)减去由拆分创建的两个子节点的不纯度的加权和来计算的。

让我们通过将自行车共享数据集分成 80%的训练和 20%的测试,使用 Spark 中的DecisionTreeRegression和回归评估器来构建模型,并获得测试数据周围的评估指标。

@transient lazy val logger = Logger.getLogger(getClass.getName) 

def decTreeRegressionWithVectorFormat(vectorAssembler: 
   VectorAssembler, vectorIndexer: VectorIndexer, dataFrame: 
   DataFrame) = { 
  val lr = new DecisionTreeRegressor() 
    .setFeaturesCol("features") 
    .setLabelCol("label") 

  val pipeline = new Pipeline().setStages(Array(vectorAssembler, 
   vectorIndexer, lr)) 

  val Array(training, test) = dataFrame.randomSplit(Array(0.8, 
   0.2), seed = 12345) 

  val model = pipeline.fit(training) 

  // Make predictions. 
  val predictions = model.transform(test) 

  // Select example rows to display. 
  predictions.select("prediction", "label", "features").show(5) 

  // Select (prediction, true label) and compute test error. 
  val evaluator = new RegressionEvaluator() 
    .setLabelCol("label") 
    .setPredictionCol("prediction") 
    .setMetricName("rmse") 
  val rmse = evaluator.evaluate(predictions) 
  println("Root Mean Squared Error (RMSE) on test data = " + rmse) 

  val treeModel = 
   model.stages(1).asInstanceOf[DecisionTreeRegressionModel] 
  println("Learned regression tree model:\n" + 
   treeModel.toDebugString)  } 

def decTreeRegressionWithSVMFormat(spark: SparkSession) = { 
  // Load training data 
  val training = spark.read.format("libsvm") 
    .load("/dataset/BikeSharing/lsvmHours.txt") 

  // Automatically identify categorical features, and index them. 
  // Here, we treat features with > 4 distinct values as 
   continuous. 
  val featureIndexer = new VectorIndexer() 
    .setInputCol("features") 
    .setOutputCol("indexedFeatures") 
    .setMaxCategories(4) 
    .fit(training) 

  // Split the data into training and test sets (30% held out for 
   testing). 
  val Array(trainingData, testData) = 
   training.randomSplit(Array(0.7, 0.3)) 

  // Train a DecisionTree model. 
  val dt = new DecisionTreeRegressor() 
    .setLabelCol("label") 
    .setFeaturesCol("indexedFeatures") 

  // Chain indexer and tree in a Pipeline. 
  val pipeline = new Pipeline() 
    .setStages(Array(featureIndexer, dt)) 

  // Train model. This also runs the indexer. 
  val model = pipeline.fit(trainingData) 

  // Make predictions. 
  val predictions = model.transform(testData) 

  // Select example rows to display. 
  predictions.select("prediction", "label", "features").show(5) 

  // Select (prediction, true label) and compute test error. 
  val evaluator = new RegressionEvaluator() 
    .setLabelCol("label") 
    .setPredictionCol("prediction") 
    .setMetricName("rmse") 
  val rmse = evaluator.evaluate(predictions) 
  println("Root Mean Squared Error (RMSE) on test data = " + rmse) 

  val treeModel = 
   model.stages(1).asInstanceOf[DecisionTreeRegressionModel] 
  println("Learned regression tree model:\n" + 
   treeModel.toDebugString) 
} 

这应该输出以下结果:

Coefficients: [17.243038451366886,75.93647669134975,-5.7067532504873215,1.5025039716365927,-26.86098264575616,1.5062307736563205,2.4745618796519953,7.500694154029075,3.7659886477986215,3.7500707038132464,-2.8910492341273235,1.6889417934600353]
Intercept: -9.85419267296242

Coefficient Standard Errors: 1.1353970394903834,2.2827202289405677,0.5060828045490352,0.17353679457103457,7.062338310890969,0.5694233355369813,2.5250738792716176,2.0099641224706573,0.7596421898012983,0.6228803024758551,0.07358180718894239,0.30550603737503224,12.369537640641184
T Values: 15.186791802016964,33.26578339676457,-11.27632316133038,8.658129103690262,-3.8034120518318013,2.6451862430890807,0.9799958329796699,3.731755243874297,4.957582264860384,6.02053185645345,-39.290272209592864,5.5283417898112726,-0.7966500413552742
P Values: 0.0,0.0,0.0,0.0,1.4320532622846827E-4,0.008171946193283652,0.3271018275330657,1.907562616410008E-4,7.204877614519489E-7,1.773422964035376E-9,0.0,3.2792739856901676E-8,0.42566519676340153
Dispersion: 22378.414478769333

Null Deviance: 5.717615910707208E8
Residual Degree Of Freedom Null: 17378
Deviance: 3.886235458383082E8
Residual Degree Of Freedom: 17366

AIC: 223399.95490762248
Deviance Residuals:
+-------------------+
|  devianceResiduals|
+-------------------+
| 32.385412453563546|
|   59.5079185994115|
|  34.98037491140896|
|-13.503450469022432|
|-27.005954440659032|
|-30.197952952158246|
| -7.039656861683778|
| 17.320193923055445|
|  -26.0159703272054|
| -48.69166247116218|
| -29.50984967584955|
| 20.520222192742004|
| 1.6551311183207815|
|-28.524373674665213|
|-16.337935852841838|
|  6.441923904310045|
|   9.91072545492193|
|-23.418896074866524|
|-37.870797650696346|
|-37.373301622332946|
+-------------------+
only showing top 20 rows

请参考前一节(广义线性回归)以了解如何解释结果。

代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/regression/bikesharing/DecisionTreeRegressionPipeline.scala找到。

树的集成

集成方法是一种机器学习算法,它创建由一组其他基本模型组成的模型。Spark 机器学习支持两种主要的集成算法:RandomForestGradientBoostedTrees

随机森林回归

随机森林被称为决策树的集成,由许多决策树组成。与决策树一样,随机森林可以处理分类特征,支持多类别,并且不需要特征缩放。

让我们通过将自行车共享数据集分为 80%的训练和 20%的测试,使用 Spark 中的RandomForestRegressor和回归评估器构建模型,并获得关于测试数据的评估指标。

@transient lazy val logger = Logger.getLogger(getClass.getName) 

def randForestRegressionWithVectorFormat(vectorAssembler: 
  VectorAssembler, vectorIndexer: VectorIndexer, dataFrame: 
   DataFrame) = { 
   val lr = new RandomForestRegressor() 
    .setFeaturesCol("features") 
    .setLabelCol("label") 

  val pipeline = new Pipeline().setStages(Array(vectorAssembler, 
   vectorIndexer, lr)) 

  val Array(training, test) = dataFrame.randomSplit(Array(0.8, 
   0.2), seed = 12345) 

  val model = pipeline.fit(training) 

  // Make predictions. 
  val predictions = model.transform(test) 

  // Select example rows to display. 
  predictions.select("prediction", "label", "features").show(5) 

  // Select (prediction, true label) and compute test error. 
  val evaluator = new RegressionEvaluator() 
    .setLabelCol("label") 
    .setPredictionCol("prediction") 
    .setMetricName("rmse") 
  val rmse = evaluator.evaluate(predictions) 
  println("Root Mean Squared Error (RMSE) on test data = " + rmse) 

  val treeModel = 
   model.stages(1).asInstanceOf[RandomForestRegressionModel] 
  println("Learned regression tree model:\n" + treeModel.toDebugString)  } 

def randForestRegressionWithSVMFormat(spark: SparkSession) = { 
  // Load training data 
  val training = spark.read.format("libsvm") 
    .load("/dataset/BikeSharing/lsvmHours.txt") 

  // Automatically identify categorical features, and index them. 
  // Set maxCategories so features with > 4 distinct values are 
   treated as continuous. 
  val featureIndexer = new VectorIndexer() 
    .setInputCol("features") 
    .setOutputCol("indexedFeatures") 
    .setMaxCategories(4) 
    .fit(training) 

  // Split the data into training and test sets (30% held out for 
   testing). 
  val Array(trainingData, testData) = 
   training.randomSplit(Array(0.7, 0.3)) 

  // Train a RandomForest model. 
  val rf = new RandomForestRegressor() 
    .setLabelCol("label") 
    .setFeaturesCol("indexedFeatures") 

  // Chain indexer and forest in a Pipeline. 
  val pipeline = new Pipeline() 
    .setStages(Array(featureIndexer, rf)) 

  // Train model. This also runs the indexer. 
  val model = pipeline.fit(trainingData) 

  // Make predictions. 
  val predictions = model.transform(testData) 

  // Select example rows to display. 
  predictions.select("prediction", "label", "features").show(5) 

  // Select (prediction, true label) and compute test error. 
  val evaluator = new RegressionEvaluator() 
    .setLabelCol("label") 
    .setPredictionCol("prediction") 
    .setMetricName("rmse") 
  val rmse = evaluator.evaluate(predictions) 
  println("Root Mean Squared Error (RMSE) on test data = " + rmse) 

  val rfModel = 
   model.stages(1).asInstanceOf[RandomForestRegressionModel] 
  println("Learned regression forest model:\n" + 
   rfModel.toDebugString) 
} 

这应该输出以下结果:

RandomForest:   init: 2.114590873
total: 3.343042855
findSplits: 1.387490192
findBestSplits: 1.191715923
chooseSplits: 1.176991821

+------------------+-----+--------------------+
|        prediction|label|            features|
+------------------+-----+--------------------+
| 70.75171441904584|  1.0|(12,[0,1,2,3,4,5,...|
| 53.43733657257549|  1.0|(12,[0,1,2,3,4,5,...|
| 57.18242812368521|  1.0|(12,[0,1,2,3,4,5,...|
| 49.73744636247659|  1.0|(12,[0,1,2,3,4,5,...|
|56.433579398691144|  1.0|(12,[0,1,2,3,4,5,...|

Root Mean Squared Error (RMSE) on test data = 123.03866156451954
Learned regression forest model:
RandomForestRegressionModel (uid=rfr_bd974271ffe6) with 20 trees
 Tree 0 (weight 1.0):
 If (feature 9 <= 40.0)
 If (feature 9 <= 22.0)
 If (feature 8 <= 13.0)
 If (feature 6 in {0.0})
 If (feature 1 in {0.0})
 Predict: 35.0945945945946
 Else (feature 1 not in {0.0})
 Predict: 63.3921568627451
 Else (feature 6 not in {0.0})
 If (feature 0 in {0.0,1.0})
 Predict: 83.05714285714286
 Else (feature 0 not in {0.0,1.0})
 Predict: 120.76608187134502
 Else (feature 8 > 13.0)
 If (feature 3 <= 21.0)
 If (feature 3 <= 12.0)
 Predict: 149.56363636363636
 Else (feature 3 > 12.0)
 Predict: 54.73593073593074
 Else (feature 3 > 21.0)
 If (feature 6 in {0.0})
 Predict: 89.63333333333334
 Else (feature 6 not in {0.0})
 Predict: 305.6588235294118

前面的代码使用各种特征及其值创建决策树。

代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/regression/bikesharing/RandomForestRegressionPipeline.scala找到。

梯度提升树回归

梯度提升树是决策树的集成。梯度提升树迭代训练决策树以最小化损失函数。梯度提升树处理分类特征,支持多类别,并且不需要特征缩放。

Spark ML 使用现有的决策树实现梯度提升树。它支持分类和回归。

让我们通过将自行车共享数据集分为 80%的训练和 20%的测试,使用 Spark 中的 GBTRegressor 和回归评估器构建模型,并获得关于测试数据的评估指标。

@transient lazy val logger = Logger.getLogger(getClass.getName) 

def gbtRegressionWithVectorFormat(vectorAssembler: 
   VectorAssembler, vectorIndexer: VectorIndexer, dataFrame: 
   DataFrame) = { 
  val lr = new GBTRegressor() 
    .setFeaturesCol("features") 
    .setLabelCol("label") 
    .setMaxIter(10) 

  val pipeline = new Pipeline().setStages(Array(vectorAssembler, 
   vectorIndexer, lr)) 

  val Array(training, test) = dataFrame.randomSplit(Array(0.8, 
   0.2), seed = 12345) 

  val model = pipeline.fit(training) 

  // Make predictions. 
  val predictions = model.transform(test) 

  // Select example rows to display. 
  predictions.select("prediction", "label", "features").show(5) 

  // Select (prediction, true label) and compute test error. 
  val evaluator = new RegressionEvaluator() 
    .setLabelCol("label") 
    .setPredictionCol("prediction") 
    .setMetricName("rmse") 
  val rmse = evaluator.evaluate(predictions) 
  println("Root Mean Squared Error (RMSE) on test data = " + rmse) 

  val treeModel = model.stages(1).asInstanceOf[GBTRegressionModel] 
  println("Learned regression tree model:\n" + 
   treeModel.toDebugString)  } 

def gbtRegressionWithSVMFormat(spark: SparkSession) = { 
  // Load training data 
  val training = spark.read.format("libsvm") 
    .load("/dataset/BikeSharing/lsvmHours.txt") 

  // Automatically identify categorical features, and index them. 
  // Set maxCategories so features with > 4 distinct values are 
   treated as continuous. 
  val featureIndexer = new VectorIndexer() 
    .setInputCol("features") 
    .setOutputCol("indexedFeatures") 
    .setMaxCategories(4) 
    .fit(training) 

  // Split the data into training and test sets (30% held out for 
   testing). 
  val Array(trainingData, testData) = 
   training.randomSplit(Array(0.7, 0.3)) 

  // Train a GBT model. 
  val gbt = new GBTRegressor() 
    .setLabelCol("label") 
    .setFeaturesCol("indexedFeatures") 
    .setMaxIter(10) 

  // Chain indexer and GBT in a Pipeline. 
  val pipeline = new Pipeline() 
    .setStages(Array(featureIndexer, gbt)) 

  // Train model. This also runs the indexer. 
  val model = pipeline.fit(trainingData) 

  // Make predictions 
  val predictions = model.transform(testData) 

  // Select example rows to display.
   predictions.select("prediction", "label", "features").show(5) 

  // Select (prediction, true label) and compute test error. 
  val evaluator = new RegressionEvaluator() 
    .setLabelCol("label") 
    .setPredictionCol("prediction") 
    .setMetricName("rmse") 
  val rmse = evaluator.evaluate(predictions) 
  println("Root Mean Squared Error (RMSE) on test data = " + rmse) 

  val gbtModel = model.stages(1).asInstanceOf[GBTRegressionModel] 
  println("Learned regression GBT model:\n" + 
   gbtModel.toDebugString) 
} 

这应该输出以下结果:

RandomForest:   init: 1.366356823
total: 1.883186039
findSplits: 1.0378687
findBestSplits: 0.501171071
chooseSplits: 0.495084674

+-------------------+-----+--------------------+
|         prediction|label|            features|
+-------------------+-----+--------------------+
|-20.753742348814352|  1.0|(12,[0,1,2,3,4,5,...|
|-20.760717579684087|  1.0|(12,[0,1,2,3,4,5,...|
| -17.73182527714976|  1.0|(12,[0,1,2,3,4,5,...|
| -17.73182527714976|  1.0|(12,[0,1,2,3,4,5,...|
|   -21.397094071362|  1.0|(12,[0,1,2,3,4,5,...|
+-------------------+-----+--------------------+
only showing top 5 rows

Root Mean Squared Error (RMSE) on test data = 73.62468541448783
Learned regression GBT model:
GBTRegressionModel (uid=gbtr_24c6ef8f52a7) with 10 trees
 Tree 0 (weight 1.0):
 If (feature 9 <= 41.0)
 If (feature 3 <= 12.0)
 If (feature 3 <= 3.0)
 If (feature 3 <= 2.0)
 If (feature 6 in {1.0})
 Predict: 24.50709219858156
 Else (feature 6 not in {1.0})
 Predict: 74.94945848375451
 Else (feature 3 > 2.0)
 If (feature 6 in {1.0})
 Predict: 122.1732283464567
 Else (feature 6 not in {1.0})
 Predict: 206.3304347826087
 Else (feature 3 > 3.0)
 If (feature 8 <= 18.0)
 If (feature 0 in {0.0,1.0})
 Predict: 137.29818181818183
 Else (feature 0 not in {0.0,1.0})
 Predict: 257.90157480314963

代码清单可在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/2.0.0/scala-spark-app/src/main/scala/org/sparksamples/regression/bikesharing/GradientBoostedTreeRegressorPipeline.scala找到。

改进模型性能和调整参数

在第六章中,使用 Spark 构建分类模型,我们展示了特征转换和选择如何对模型的性能产生很大影响。在本章中,我们将专注于可以应用于数据集的另一种转换类型:转换目标变量本身。

转换目标变量

请记住,许多机器学习模型,包括线性模型,对输入数据和目标变量的分布做出假设。特别是,线性回归假设正态分布。

在许多实际情况下,线性回归的分布假设并不成立。例如,在这种情况下,我们知道自行车租赁数量永远不会是负数。这一点就应该表明正态分布的假设可能存在问题。为了更好地了解目标分布,通常最好绘制目标值的直方图。

我们现在将创建目标变量分布的图表如下所示:

Scala

绘制原始数据的代码可以在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_07/scala/1.6.2/scala-spark-app/src/main/scala/org/sparksamples/PlotRawData.scala找到。

object PlotRawData { 

  def main(args: Array[String]) { 
    val records = Util.getRecords()._1 
    val records_x = records.map(r => r(r.length -1)) 
    var records_int = new ArrayInt.length) 
    print(records_x.first()) 
    val records_collect = records_x.collect() 

    for (i <- 0 until records_collect.length){ 
      records_int(i) = records_collect(i).toInt 
    } 
    val min_1 = records_int.min 
    val max_1 = records_int.max 

    val min = min_1 
    val max = max_1 
    val bins = 40 
    val step = (max/bins).toInt 

    var mx = Map(0 -> 0) 
    for (i <- step until (max + step) by step) { 
      mx += (i -> 0); 
    } 

    for(i <- 0 until records_collect.length){ 
      for (j <- 0 until (max + step) by step) { 
        if(records_int(i) >= (j) && records_int(i) < (j + step)){ 
          mx = mx + (j -> (mx(j) + 1)) 
        } 
      } 
    } 
    val mx_sorted = ListMap(mx.toSeq.sortBy(_._1):_*) 
    val ds = new org.jfree.data.category.DefaultCategoryDataset 
    var i = 0 
    mx_sorted.foreach{ case (k,v) => ds.addValue(v,"", k)} 

    val chart = ChartFactories.BarChart(ds) 
    val font = new Font("Dialog", Font.PLAIN,4); 

    chart.peer.getCategoryPlot.getDomainAxis(). 
      setCategoryLabelPositions(CategoryLabelPositions.UP_90); 
    chart.peer.getCategoryPlot.getDomainAxis.setLabelFont(font) 
    chart.show() 
    Util.sc.stop() 
  } 
} 

前述输出的图如下所示:

我们处理这种情况的一种方法是对目标变量应用转换,即我们取目标值的对数而不是原始值。这通常被称为对目标变量进行对数转换(此转换也可以应用于特征值)。

我们将对以下目标变量应用对数变换,并使用以下代码绘制对数变换后的值的直方图:

Scala

object PlotLogData { 

  def main(args: Array[String]) { 
    val records = Util.getRecords()._1 
    val records_x = records.map( 
      r => Math.log(r(r.length -1).toDouble)) 
    var records_int = new ArrayInt.length) 
    print(records_x.first()) 
    val records_collect = records_x.collect() 

    for (i <- 0 until records_collect.length){ 
      records_int(i) = records_collect(i).toInt 
    } 
    val min_1 = records_int.min 
    val max_1 = records_int.max 

    val min = min_1.toFloat 
    val max = max_1.toFloat 
    val bins = 10 
    val step = (max/bins).toFloat 

    var mx = Map(0.0.toString -> 0) 
    for (i <- step until (max + step) by step) { 
      mx += (i.toString -> 0); 
    } 

    for(i <- 0 until records_collect.length){ 
      for (j <- 0.0 until (max + step) by step) { 
        if(records_int(i) >= (j) && records_int(i) < (j + step)){ 
          mx = mx + (j.toString -> (mx(j.toString) + 1)) 
        } 
      } 
    } 
    val mx_sorted = ListMap(mx.toSeq.sortBy(_._1.toFloat):_*) 
    val ds = new org.jfree.data.category.DefaultCategoryDataset 
    var i = 0 
    mx_sorted.foreach{ case (k,v) => ds.addValue(v,"", k)} 

    val chart = ChartFactories.BarChart(ds) 
    val font = new Font("Dialog", Font.PLAIN,4); 

    chart.peer.getCategoryPlot.getDomainAxis(). 
      setCategoryLabelPositions(CategoryLabelPositions.UP_90); 
    chart.peer.getCategoryPlot.getDomainAxis.setLabelFont(font) 
    chart.show() 
    Util.sc.stop() 
  } 
} 

前面输出的图表如下所示:

第二种转换类型在目标值不取负值,并且可能取值范围非常广泛的情况下非常有用,那就是对变量取平方根。

我们将在以下代码中应用平方根变换,再次绘制结果目标变量的分布:

从对数和平方根变换的图表中,我们可以看到两者都相对于原始值产生了更均匀的分布。虽然它们仍然不是正态分布,但与原始目标变量相比,它们更接近正态分布。

对对数变换目标的训练影响

那么,应用这些转换对模型性能有影响吗?让我们以对数变换数据为例,评估我们之前使用的各种指标。

我们将首先对线性模型进行操作,通过对每个LabeledPoint RDD 的label字段应用对数函数。在这里,我们只会对目标变量进行转换,不会对特征进行任何转换。

然后,我们将在转换后的数据上训练模型,并形成预测值与真实值的 RDD。

请注意,现在我们已经转换了目标变量,模型的预测将在对数尺度上,转换后数据集的目标值也将在对数尺度上。因此,为了使用我们的模型并评估其性能,我们必须首先通过使用numpy exp函数将对数数据转换回原始尺度,对预测值和真实值都进行指数化。

最后,我们将计算模型的 MSE、MAE 和 RMSLE 指标:

Scala

object LinearRegressionWithLog{ 

  def main(args: Array[String]) { 

    val recordsArray = Util.getRecords() 
    val records = recordsArray._1 
    val first = records.first() 
    val numData = recordsArray._2 

    println(numData.toString()) 
    records.cache()
     print("Mapping of first categorical feature column: " + 
       Util.get_mapping(records, 2)) 
    var list = new ListBuffer[Map[String, Long]]() 
    for( i <- 2 to 9){ 
      val m =  Util.get_mapping(records, i) 
      list += m 
    } 
    val mappings = list.toList 
    var catLen = 0 
    mappings.foreach( m => (catLen +=m.size)) 

    val numLen = records.first().slice(11, 15).size 
    val totalLen = catLen + numLen
    print("Feature vector length for categorical features:"+ 
       catLen)
     print("Feature vector length for numerical features:" +
       numLen)
     print("Total feature vector length: " + totalLen) 

    val data = { 
      records.map(r => LabeledPoint(Math.log(Util.extractLabel(r)),
         Util.extractFeatures(r, catLen, mappings)))
    } 
    val first_point = data.first() 
    println("Linear Model feature vector:" + 
       first_point.features.toString) 
    println("Linear Model feature vector length: " + 
       first_point.features.size) 

    val iterations = 10 
    val step = 0.025 
    val intercept =true 
    val linear_model = LinearRegressionWithSGD.train(data, 
       iterations, step) 
    val x = linear_model.predict(data.first().features) 
    val true_vs_predicted = data.map(p => (Math.exp(p.label), 
       Math.exp(linear_model.predict(p.features)))) 
    val true_vs_predicted_csv = data.map(p => p.label + " ," + 
       linear_model.predict(p.features)) 
    val format = new java.text.SimpleDateFormat(
       "dd-MM-yyyy-hh-mm-ss") 
    val date = format.format(new java.util.Date()) 
    val save = false 
    if (save){ 
         true_vs_predicted_csv.saveAsTextFile( 
           "./output/linear_model_" + date + ".csv") 
    } 
    val true_vs_predicted_take5 = true_vs_predicted.take(5) 
    for(i <- 0 until 5) { 
      println("True vs Predicted: " + "i :" + 
         true_vs_predicted_take5(i)) 
    } 

    Util.calculatePrintMetrics(true_vs_predicted, 
       "LinearRegressioWithSGD Log")
  } 
} 

前面代码的输出将类似于以下内容:

LinearRegressioWithSGD Log - Mean Squared Error: 5055.089410453301
LinearRegressioWithSGD Log - Mean Absolute Error: 51.56719871511336
LinearRegressioWithSGD Log - Root Mean Squared Log 
   Error:1.7785399629180894

代码清单可在以下链接找到:

如果我们将这些前面的结果与原始目标变量的结果进行比较,我们会发现所有三个值都变得更糟。

LinearRegressioWithSGD - Mean Squared Error: 35817.9777663029
LinearRegressioWithSGD - Mean Absolute Error: 136.94887209426008
LinearRegressioWithSGD - Root Mean Squared Log Error: 
    1.4482391780194306
LinearRegressioWithSGD Log - Mean Squared Error: 60192.54096079104
LinearRegressioWithSGD Log - Mean Absolute Error: 
    170.82191606911752
LinearRegressioWithSGD Log - Root Mean Squared Log Error: 
    1.9587586971094555

调整模型参数

到目前为止,在本章中,我们已经通过在相同数据集上进行训练和测试来说明了 MLlib 回归模型的模型训练和评估的概念。现在,我们将使用与之前类似的交叉验证方法来评估不同参数设置对模型性能的影响。

创建训练和测试集以评估参数。

第一步是为交叉验证目的创建测试和训练集。

在 Scala 中,拆分更容易实现,并且randomSplit函数可用:

val splits = data.randomSplit(Array(0.8, 0.2), seed = 11L) 
val training = splits(0).cache() 
val test = splits(1) 

决策树的数据拆分

最后一步是对决策树模型提取的特征应用相同的方法。

Scala

val splits = data_dt.randomSplit(Array(0.8, 0.2), seed = 11L) 
val training = splits(0).cache() 
val test = splits(1) 

线性模型参数设置的影响

现在我们已经准备好了我们的训练和测试集,我们准备研究不同参数设置对模型性能的影响。我们将首先对线性模型进行评估。我们将创建一个方便的函数,通过在训练集上训练模型,并在不同的参数设置下在测试集上评估相关性能指标。

我们将使用 RMSLE 评估指标,因为这是 Kaggle 竞赛中使用的指标,这样可以让我们将模型结果与竞赛排行榜进行比较,看看我们的表现如何。

评估函数在这里定义:

Scala

def evaluate(train: RDD[LabeledPoint],test: RDD[LabeledPoint], 
  iterations:Int,step:Double, 
  intercept:Boolean): Double ={ 
  val linReg =  
    new LinearRegressionWithSGD().setIntercept(intercept) 

  linReg.optimizer.setNumIterations(iterations).setStepSize(step) 
  val linear_model = linReg.run(train) 

  val true_vs_predicted = test.map(p => (p.label,  
    linear_model.predict(p.features))) 
  val rmsle = Math.sqrt(true_vs_predicted.map{  
    case(t, p) => Util.squaredLogError(t, p)}.mean()) 
  return rmsle 
} 

请注意,在接下来的部分,由于 SGD 的一些随机初始化,您可能会得到略有不同的结果。但是,您的结果是可以比较的。

迭代

正如我们在评估分类模型时看到的,通常情况下,我们期望使用 SGD 训练的模型随着迭代次数的增加而获得更好的性能,尽管随着迭代次数超过某个最小值,性能的提高将放缓。请注意,在这里,我们将步长设置为 0.01,以更好地说明在较高的迭代次数下的影响。

我们使用不同的迭代次数在 Scala 中实现了相同的功能,如下所示:

val data = LinearRegressionUtil.getTrainTestData() 
val train_data = data._1 
val test_data = data._2 
val iterations = 10 
//LinearRegressionCrossValidationStep$ 
//params = [1, 5, 10, 20, 50, 100, 200] 
val iterations_param = Array(1, 5, 10, 20, 50, 100, 200) 
val step =0.01 
//val steps_param = Array(0.01, 0.025, 0.05, 0.1, 1.0) 
val intercept =false 

val i = 0 
val results = new ArrayString 
val resultsMap = new scala.collection.mutable.HashMap[String, 
   String] 
val dataset = new DefaultCategoryDataset() 
for(i <- 0 until iterations_param.length) { 
  val iteration = iterations_param(i) 
  val rmsle = LinearRegressionUtil.evaluate(train_data, 
   test_data,iteration,step,intercept) 
  //results(i) = step + ":" + rmsle 
  resultsMap.put(iteration.toString,rmsle.toString) 
  dataset.addValue(rmsle, "RMSLE", iteration) 
} 

对于 Scala 实现,我们使用了 JfreeChart 的 Scala 版本。实现在 20 次迭代时达到最小的 RMSLE:

  Map(5 -> 0.8403179051522236, 200 -> 0.35682322830872604, 50 -> 
   0.07224447567763903, 1 -> 1.6381266770967882, 20 -> 
   0.23992956602621263, 100 -> 0.2525579338412989, 10 -> 
   0.5236271681647611) 

前面输出的图如下所示:

步长

我们将在下面的代码中对步长执行类似的分析:

Scala

val steps_param = Array(0.01, 0.025, 0.05, 0.1, 1.0) 
val intercept =false 

val i = 0 
val results = new ArrayString 
val resultsMap = new scala.collection.mutable.HashMap[String, String] 
val dataset = new DefaultCategoryDataset() 
for(i <- 0 until steps_param.length) { 
  val step = steps_param(i) 
  val rmsle = LinearRegressionUtil.evaluate(train_data, 
         test_data,iterations,step,intercept) 
  resultsMap.put(step.toString,rmsle.toString) 
  dataset.addValue(rmsle, "RMSLE", step) 
} 

前面代码的输出如下:

    [1.7904244862988534, 1.4241062778987466, 1.3840130355866163, 
   1.4560061007109475, nan]

前面输出的图如下所示:

现在我们可以看到为什么在最初训练线性模型时避免使用默认步长。默认值设置为1.0,在这种情况下,导致 RMSLE 指标输出为nan。这通常意味着 SGD 模型已经收敛到了一个非常糟糕的局部最小值,这是优化算法容易超过好的解决方案的情况。

我们还可以看到,对于较低的步长和相对较少的迭代次数(这里我们使用了 10 次),模型性能略差。然而,在前面的迭代部分,我们看到对于较低的步长设置,更多的迭代次数通常会收敛到更好的解决方案。

一般来说,设置步长和迭代次数涉及权衡。较低的步长意味着收敛速度较慢,但稍微更有保证。然而,它需要更多的迭代次数,在计算和时间方面更加昂贵,特别是在非常大规模的情况下。

选择最佳参数设置可能是一个密集的过程,涉及在许多参数设置的组合上训练模型并选择最佳结果。每个模型训练实例都涉及一定数量的迭代,因此当在非常大的数据集上执行时,这个过程可能非常昂贵和耗时。模型初始化也会对结果产生影响,无论是达到全局最小值,还是在梯度下降图中达到次优局部最小值。

L2 正则化

在第六章中,使用 Spark 构建分类模型,我们看到正则化会惩罚模型复杂性,形式上是一个额外的损失项,是模型权重向量的函数。L2 正则化惩罚权重向量的 L2 范数,而 L1 正则化惩罚权重向量的 L1 范数。

我们预计随着正则化的增加,训练集性能会下降,因为模型无法很好地拟合数据集。然而,我们也期望一定程度的正则化将导致最佳的泛化性能,这可以通过测试集上的最佳性能来证明。

L1 正则化

我们可以对不同水平的 L1 正则化应用相同的方法,如下所示:

params = [0.0, 0.01, 0.1, 1.0, 10.0, 100.0, 1000.0] 
metrics = [evaluate(train_data, test_data, 10, 0.1, param, 'l1', 
   False) for param in params] 
print params 
print metrics 
plot(params, metrics) 
fig = matplotlib.pyplot.gcf() 
pyplot.xscale('log') 

再次,当以图表形式绘制时,结果更加清晰。我们看到 RMSLE 有一个更加微妙的下降,需要一个非常高的值才会导致反弹。在这里,所需的 L1 正则化水平比 L2 形式要高得多;然而,整体性能较差:

[0.0, 0.01, 0.1, 1.0, 10.0, 100.0, 1000.0]
[1.5384660954019971, 1.5384518080419873, 1.5383237472930684, 
    1.5372017600929164, 1.5303809928601677, 1.4352494587433793, 
    4.7551250073268614]

使用 L1 正则化可以鼓励稀疏的权重向量。在这种情况下是否成立?我们可以通过检查权重向量中零的条目数来找出答案,随着正则化水平的增加,零的条目数也在增加。

model_l1 = LinearRegressionWithSGD.train(train_data, 10, 0.1, 
   regParam=1.0, regType='l1', intercept=False) 
model_l1_10 = LinearRegressionWithSGD.train(train_data, 10, 0.1, 
   regParam=10.0, regType='l1', intercept=False) 
model_l1_100 = LinearRegressionWithSGD.train(train_data, 10, 0.1, 
   regParam=100.0, regType='l1', intercept=False) 
print "L1 (1.0) number of zero weights: " + 
   str(sum(model_l1.weights.array == 0)) 
print "L1 (10.0) number of zeros weights: " + 
   str(sum(model_l1_10.weights.array == 0)) 
print "L1 (100.0) number of zeros weights: " + 
   str(sum(model_l1_100.weights.array == 0)) 

从结果中可以看出,正如我们所预期的,随着 L1 正则化水平的增加,模型权重向量中零特征权重的数量也在增加。

L1 (1.0) number of zero weights: 4
L1 (10.0) number of zeros weights: 20
L1 (100.0) number of zeros weights: 55

截距

线性模型的最终参数选项是是否使用截距。截距是添加到权重向量的常数项,有效地解释了目标变量的平均值。如果数据已经居中或标准化,则不需要截距;然而,在任何情况下使用截距通常也不会有坏处。

我们将评估在模型中添加截距项的影响:

Scala

object LinearRegressionCrossValidationIntercept{ 
  def main(args: Array[String]) { 
    val data = LinearRegressionUtil.getTrainTestData() 
    val train_data = data._1 
    val test_data = data._2 

    val iterations = 10 
    val step = 0.1 
    val paramsArray = new ArrayBoolean 
    paramsArray(0) = true 
    paramsArray(1) = false 
    val i = 0 
    val results = new ArrayString 
    val resultsMap = new scala.collection.mutable.HashMap[ 
    String, String] 
    val dataset = new DefaultCategoryDataset() 
    for(i <- 0 until 2) { 
      val intercept = paramsArray(i) 
      val rmsle = LinearRegressionUtil.evaluate(train_data,  
        test_data,iterations,step,intercept) 
      results(i) = intercept + ":" + rmsle 
      resultsMap.put(intercept.toString,rmsle.toString) 
      dataset.addValue(rmsle, "RMSLE", intercept.toString) 
    } 
    val chart = new LineChart( 
      "Steps" , 
      "LinearRegressionWithSGD : RMSLE vs Intercept") 
    chart.exec("Steps","RMSLE",dataset) 
    chart.lineChart.getCategoryPlot().getRangeAxis().setRange( 
    1.56, 1.57) 
    chart.pack( ) 
    RefineryUtilities.centerFrameOnScreen( chart ) 
    chart.setVisible( true ) 
    println(results) 
  } 
} 

上述输出的图表如下所示:

如前图所示,当截距为 true 时,RMSLE 值略高于截距为 false 时。

决策树参数设置的影响

决策树提供两个主要参数:最大树深度和最大箱数。我们现在将对决策树模型的参数设置效果进行相同的评估。我们的起点是创建一个模型的评估函数,类似于之前用于线性回归的函数。该函数如下所示:

Scala

def evaluate(train: RDD[LabeledPoint],test: RDD[LabeledPoint], 
  categoricalFeaturesInfo: scala.Predef.Map[Int, Int], 
  maxDepth :Int, maxBins: Int): Double = { 
    val impurity = "variance" 
    val decisionTreeModel = DecisionTree.trainRegressor(train, 
      categoricalFeaturesInfo, 
      impurity, maxDepth, maxBins) 
    val true_vs_predicted = test.map(p => (p.label,  
      decisionTreeModel.predict(p.features))) 
    val rmsle = Math.sqrt(true_vs_predicted.map{  
      case(t, p) => Util.squaredLogError(t, p)}.mean()) 
      return rmsle 
  } 

树深度

通常我们期望性能会随着更复杂的树(即更深的树)而提高。较低的树深度起到一种正则化的作用,可能会出现与线性模型中的 L2 或 L1 正则化类似的情况,即存在一个最优的树深度与测试集性能相关。

在这里,我们将尝试增加树的深度,以查看它们对测试集 RMSLE 的影响,保持箱数的默认水平为32

Scala

val data = DecisionTreeUtil.getTrainTestData() 
  val train_data = data._1 
  val test_data = data._2 
  val iterations = 10 
  val bins_param = Array(2, 4, 8, 16, 32, 64, 100) 
  val depth_param = Array(1, 2, 3, 4, 5, 10, 20) 
  val bin = 32 
  val categoricalFeaturesInfo = scala.Predef.Map[Int, Int]() 
  val i = 0 
  val results = new ArrayString 
  val resultsMap = new scala.collection.mutable.HashMap[ 
    String, String] 
  val dataset = new DefaultCategoryDataset() 
  for(i <- 0 until depth_param.length) { 
    val depth = depth_param(i) 
    val rmsle = DecisionTreeUtil.evaluate( 
    train_data, test_data, categoricalFeaturesInfo, depth, bin) 

    resultsMap.put(depth.toString,rmsle.toString) 
    dataset.addValue(rmsle, "RMSLE", depth) 
  } 
  val chart = new LineChart( 
    "MaxDepth" , 
    "DecisionTree : RMSLE vs MaxDepth") 
  chart.exec("MaxDepth","RMSLE",dataset) 
  chart.pack() 
  RefineryUtilities.centerFrameOnScreen( chart ) 
  chart.setVisible( true ) 
  print(resultsMap) 
} 

上述输出的图表如下所示:

最大箱数

最后,我们将评估决策树箱数设置的影响。与树深度一样,更多的箱数应该允许模型变得更复杂,并可能有助于处理更大的特征维度。在一定程度之后,它不太可能再有帮助,实际上可能会由于过拟合而影响测试集的性能。

Scala

object DecisionTreeMaxBins{ 
  def main(args: Array[String]) { 
    val data = DecisionTreeUtil.getTrainTestData() 
    val train_data = data._1 
    val test_data = data._2 
    val iterations = 10 
    val bins_param = Array(2, 4, 8, 16, 32, 64, 100) 
    val maxDepth = 5 
    val categoricalFeaturesInfo = scala.Predef.Map[Int, Int]() 
    val i = 0 
    val results = new ArrayString 
    val resultsMap = new scala.collection.mutable.HashMap[ 
        String, String] 
    val dataset = new DefaultCategoryDataset() 
    for(i <- 0 until bins_param.length) { 
      val bin = bins_param(i) 
      val rmsle = { 
        DecisionTreeUtil.evaluate(train_data, test_data, 
         categoricalFeaturesInfo, 5, bin) 
      } 
      resultsMap.put(bin.toString,rmsle.toString) 
      dataset.addValue(rmsle, "RMSLE", bin) 
    } 
    val chart = new LineChart( 
      "MaxBins" , 
      "DecisionTree : RMSLE vs MaxBins") 
    chart.exec("MaxBins","RMSLE",dataset) 
    chart.pack( ) 
    RefineryUtilities.centerFrameOnScreen( chart ) 
    chart.setVisible( true ) 
    print(resultsMap) 
  } 

上述输出的图表如下所示:

梯度提升树的参数设置影响

梯度提升树有两个主要参数:迭代次数和最大深度。我们将对这些进行变化并观察效果。

迭代

Scala

object GradientBoostedTreesIterations{ 

  def main(args: Array[String]) { 
    val data = GradientBoostedTreesUtil.getTrainTestData() 
    val train_data = data._1 
    val test_data = data._2 

    val iterations_param = Array(1, 5, 10, 15, 18) 

    val i = 0 
    val resultsMap = new scala.collection.mutable.HashMap[ 
        String, String] 
    val dataset = new DefaultCategoryDataset() 
    for(i <- 0 until iterations_param.length) { 
      val iteration = iterations_param(i) 
      val rmsle = GradientBoostedTreesUtil.evaluate(train_data,  
        test_data,iteration,maxDepth) 
      resultsMap.put(iteration.toString,rmsle.toString) 
      dataset.addValue(rmsle, "RMSLE", iteration) 
    } 
    val chart = new LineChart( 
      "Iterations" , 
      "GradientBoostedTrees : RMSLE vs Iterations") 
    chart.exec("Iterations","RMSLE",dataset) 
    chart.pack( ) 
    chart.lineChart.getCategoryPlot().
       getRangeAxis().setRange(1.32, 1.37) 
    RefineryUtilities.centerFrameOnScreen( chart ) 
    chart.setVisible( true ) 
    print(resultsMap) 
  } 
} 

上述输出的图表如下所示:

MaxBins

接下来我们看一下改变最大箱数如何影响 RMSLE 值。

Scala

让我们看一下 Scala 中的示例实现。我们将计算最大箱数为10163264时的 RMSLE 值。

object GradientBoostedTreesMaxBins{ 

  def main(args: Array[String]) { 
    val data = GradientBoostedTreesUtil.getTrainTestData() 
    val train_data = data._1 
    val test_data = data._2 

    val maxBins_param = Array(10,16,32,64) 
    val iteration = 10 
    val maxDepth = 3 

    val i = 0 
    val resultsMap =  
    new scala.collection.mutable.HashMap[String, String] 
    val dataset = new DefaultCategoryDataset() 
    for(i <- 0 until maxBins_param.length) { 
      val maxBin = maxBins_param(i) 
      val rmsle = GradientBoostedTreesUtil.evaluate(train_data, 
         test_data,iteration,maxDepth, maxBin) 

      resultsMap.put(maxBin.toString,rmsle.toString) 
      dataset.addValue(rmsle, "RMSLE", maxBin) 
    } 
    val chart = new LineChart( 
      "Max Bin" , 
      "GradientBoostedTrees : RMSLE vs MaxBin") 
    chart.exec("MaxBins","RMSLE",dataset) 
    chart.pack( ) 
    chart.lineChart.getCategoryPlot(). 
        getRangeAxis().setRange(1.35, 1.37) 
    RefineryUtilities.centerFrameOnScreen( chart ) 
    chart.setVisible(true) 
    print(resultsMap) 
  } 

上述输出的图表如下所示:

总结

在本章中,您看到了如何在回归模型的背景下使用 ML 库的线性模型、决策树、梯度提升树、岭回归和等温回归功能。我们探讨了分类特征提取,以及在回归问题中应用转换对目标变量的影响。最后,我们实现了各种性能评估指标,并使用它们来实施交叉验证练习,探讨线性模型和决策树中各种参数设置对测试集模型性能的影响。

在下一章中,我们将介绍一种不同的机器学习方法,即无监督学习,特别是聚类模型。

第八章:使用 Spark 构建聚类模型

在过去的几章中,我们涵盖了监督学习方法,其中训练数据带有我们想要预测的真实结果的标签(例如,推荐的评级和分类的类分配,或者在回归的情况下是真实目标变量)。

接下来,我们将考虑没有可用标记数据的情况。这被称为无监督学习,因为模型没有受到真实目标标签的监督。无监督情况在实践中非常常见,因为在许多真实场景中获取标记的训练数据可能非常困难或昂贵(例如,让人类为分类标签标记训练数据)。然而,我们仍然希望学习数据中的一些潜在结构,并使用这些结构进行预测。

这就是无监督学习方法可以发挥作用的地方。无监督学习模型也经常与监督模型结合使用;例如,应用无监督技术为监督模型创建新的输入特征。

聚类模型在许多方面类似于分类模型的无监督等价物。在分类中,我们试图学习一个模型,可以预测给定训练示例属于哪个类。该模型本质上是从一组特征到类的映射。

在聚类中,我们希望对数据进行分段,以便将每个训练示例分配给一个称为的段。这些簇的作用很像类,只是真实的类分配是未知的。

聚类模型有许多与分类相同的用例;其中包括以下内容:

  • 根据行为特征和元数据将用户或客户分成不同的群体

  • 在网站上对内容进行分组或在零售业务中对产品进行分组

  • 寻找相似基因的簇

  • 在生态学中对社区进行分割

  • 创建图像段,用于图像分析应用,如目标检测

在本章中,我们将:

  • 简要探讨几种聚类模型

  • 从数据中提取特征,特别是使用一个模型的输出作为我们聚类模型的输入特征

  • 训练一个聚类模型并使用它进行预测

  • 应用性能评估和参数选择技术来选择要使用的最佳簇数

聚类模型的类型

有许多不同形式的聚类模型可用,从简单到极其复杂。Spark MLlib 目前提供 k-means 聚类,这是最简单的方法之一。然而,它通常非常有效,而且其简单性意味着相对容易理解并且可扩展。

k-means 聚类

k-means 试图将一组数据点分成K个不同的簇(其中K是模型的输入参数)。

更正式地说,k-means 试图找到簇,以便最小化每个簇内的平方误差(或距离)。这个目标函数被称为簇内平方误差和WCSS)。

它是每个簇中每个点与簇中心之间的平方误差的总和。

从一组K个初始簇中心开始(这些中心是计算为簇中所有数据点的平均向量),K-means 的标准方法在两个步骤之间进行迭代:

  1. 将每个数据点分配给最小化 WCSS 的簇。平方和等于平方欧氏距离;因此,这相当于根据欧氏距离度量将每个点分配给最接近的簇中心。

  2. 根据第一步的簇分配计算新的簇中心。

该算法进行到达到最大迭代次数或收敛为止。收敛意味着在第一步期间簇分配不再改变;因此,WCSS 目标函数的值也不再改变。

有关更多详细信息,请参考 Spark 关于聚类的文档spark.apache.org/docs/latest/mllib-clustering.html或参考en.wikipedia.org/wiki/K-means_clustering

为了说明 K-means 的基础知识,我们将使用我们在第六章中展示的多类分类示例中所示的简单数据集,使用 Spark 构建分类模型。回想一下,我们有五个类别,如下图所示:

多类数据集

然而,假设我们实际上不知道真实的类别。如果我们使用五个簇的 k-means,那么在第一步之后,模型的簇分配可能是这样的:

第一次 K-means 迭代后的簇分配

我们可以看到,k-means 已经相当好地挑选出了每个簇的中心。在下一次迭代之后,分配可能看起来像下图所示的那样:

第二次 K-means 迭代后的簇分配

事情开始稳定下来,但总体簇分配与第一次迭代后基本相同。一旦模型收敛,最终的分配可能看起来像这样:

K-means 的最终簇分配

正如我们所看到的,模型已经相当好地分离了这五个簇。最左边的三个相当准确(有一些错误的点)。然而,右下角的两个簇不太准确。

这说明:

  • K-means 的迭代性质

  • 模型对于最初选择簇中心的方法的依赖性(这里,我们将使用随机方法)

  • 最终的簇分配对于分离良好的数据可能非常好,但对于更困难的数据可能较差

初始化方法

k-means 的标准初始化方法通常简称为随机方法,它首先随机将每个数据点分配给一个簇,然后进行第一个更新步骤。

Spark ML 提供了这种初始化方法的并行变体,称为K-means++,这是默认的初始化方法。

有关更多信息,请参阅en.wikipedia.org/wiki/K-means_clustering#Initialization_methodsen.wikipedia.org/wiki/K-means%2B%2B

使用 K-means++的结果如下所示。请注意,这一次,困难的右下角点大部分被正确地聚类了:

K-means++的最终簇分配

还有许多其他的 K-means 变体;它们侧重于初始化方法或核心模型。其中一个更常见的变体是模糊 K-means。这个模型不像 K-means 那样将每个点分配给一个簇(所谓的硬分配)。相反,它是 K-means 的软版本,其中每个点可以属于许多簇,并且由相对于每个簇的成员资格表示。因此,对于K个簇,每个点被表示为一个 K 维成员资格向量,向量中的每个条目表示在每个簇中的成员资格比例。

混合模型

混合模型本质上是模糊 K 均值背后思想的延伸;然而,它假设存在一个生成数据的潜在概率分布。例如,我们可能假设数据点是从一组 K 个独立的高斯(正态)概率分布中抽取的。簇分配也是软的,因此每个点在 K 个潜在概率分布中都由K成员权重表示。

有关混合模型的更多详细信息和混合模型的数学处理,请参见en.wikipedia.org/wiki/Mixture_model

分层聚类

分层聚类是一种结构化的聚类方法,它会产生一个多级别的簇层次结构,其中每个簇可能包含许多子簇。因此,每个子簇都与父簇相关联。这种形式的聚类通常也被称为树状聚类

凝聚聚类是一种自下而上的方法:

  • 每个数据点都开始在自己的簇中

  • 评估每对簇之间的相似性(或距离)

  • 找到最相似的一对簇;然后将这对簇合并成一个新的簇

  • 该过程重复进行,直到只剩下一个顶层簇

分裂聚类是一种自上而下的方法,从一个簇开始,然后在每个阶段将一个簇分裂成两个,直到所有数据点都被分配到自己的底层簇中。

自上而下聚类比自下而上聚类更复杂,因为需要第二个平面聚类算法作为“子程序”。如果我们不生成完整的层次结构到单个文档叶子,自上而下聚类具有更高的效率。

您可以在en.wikipedia.org/wiki/Hierarchical_clustering找到更多信息。

从数据中提取正确的特征

与迄今为止遇到的大多数机器学习模型一样,k 均值聚类需要数值向量作为输入。我们已经看到的用于分类和回归的相同特征提取和转换方法也适用于聚类。

与最小二乘回归一样,由于 k 均值使用平方误差函数作为优化目标,它往往会受到异常值和具有大方差的特征的影响。

聚类可以用来检测异常值,因为它们可能会引起很多问题。

对于回归和分类情况,输入数据可以被标准化和规范化以克服这一问题,这可能会提高准确性。然而,在某些情况下,如果例如目标是根据某些特定特征找到分割,可能不希望标准化数据。

从 MovieLens 数据集中提取特征

在使用聚类算法之前,我们将使用 ALS 算法获取用户和项目(电影)的数值特征:

  1. 首先将数据u.data加载到 DataFrame 中:
      val ratings = spark.sparkContext 
      .textFile(DATA_PATH + "/u.data") 
      .map(_.split("\t")) 
      .map(lineSplit => Rating(lineSplit(0).toInt, 
        lineSplit(1).toInt,  lineSplit(2).toFloat, 
        lineSplit(3).toLong)) 
      .toDF()

  1. 然后我们将其按 80:20 的比例分割,得到训练和测试数据:
      val Array(training, test) =  
        ratings.randomSplit(Array(0.8, 0.2))

  1. 我们实例化ALS类,将最大迭代次数设置为5,正则化参数设置为0.01
      val als = new ALS() 
        .setMaxIter(5) 
        .setRegParam(0.01) 
        .setUserCol("userId") 
        .setItemCol("movieId") 
        .setRatingCol("rating")

  1. 然后我们创建一个模型,然后计算预测:
      val model = als.fit(training) 
      val predictions = model.transform(test)

  1. 接着计算userFactorsitemFactors
      val itemFactors = model.itemFactors 
      itemFactors.show() 

      val userFactors = model.userFactors 
      userFactors.show()

  1. 我们将它们转换为 libsvm 格式并将它们持久化在一个文件中。请注意,我们持久化所有特征以及两个特征:
      val itemFactorsOrdererd = itemFactors.orderBy("id") 
      val itemFactorLibSVMFormat = 
        itemFactorsOrdererd.rdd.map(x => x(0) + " " + 
        getDetails(x(1).asInstanceOf
          [scala.collection.mutable.WrappedArray[Float]])) 
      println("itemFactorLibSVMFormat.count() : " + 
        itemFactorLibSVMFormat.count()) 
      print("itemFactorLibSVMFormat.first() : " + 
        itemFactorLibSVMFormat.first()) 

      itemFactorLibSVMFormat.coalesce(1)
        .saveAsTextFile(output + "/" + date_time + 
        "/movie_lens_items_libsvm")

movie_lens_items_libsvm的输出将如下所示:

          1 1:0.44353345 2:-0.7453435 3:-0.55146646 4:-0.40894786 
          5:-0.9921601 6:1.2012635 7:0.50330496 8:-0.23256435     
          9:0.55483425 10:-1.4781344
 2 1:0.34384087 2:-1.0242497 3:-0.20907198 4:-0.102892995 
          5:-1.0616653 6:1.1338154 7:0.5742042 8:-0.46505615  
          9:0.3823278 10:-1.0695107 3 1:-0.04743084 2:-0.6035447  
          3:-0.7999673 4:0.16897096    
          5:-1.0216197 6:0.3304353 7:1.5495727 8:0.2972699  
          9:-0.6855238 
          10:-1.5391738
 4 1:0.24745995 2:-0.33971268 3:0.025664425 4:0.16798466 
          5:-0.8462472 6:0.6734541 7:0.7537076 8:-0.7119413  
          9:0.7475001 
          10:-1.965572
 5 1:0.30903652 2:-0.8523586 3:-0.54090345 4:-0.7004097 
          5:-1.0383878 6:1.1784278 7:0.5125761 8:0.2566347         
          9:-0.020201845   
          10:-1.118083
 ....
 1681 1:-0.14603947 2:-0.4475343 3:-0.50514024 4:-0.7221697 
          5:-0.7997808 6:0.21069092 7:0.22631708 8:-0.32458723 
          9:0.20187362 10:-1.2734087
 1682 1:0.21975909 2:0.45303428 3:-0.73912954 4:-0.40584692 
          5:-0.5299451 6:0.79586357 7:0.5154468 8:-0.4033669  
          9:0.2220822 
          10:-0.70235217

  1. 接下来,我们持久化前两个特征(具有最大变化)并将它们持久化在一个文件中:
      var itemFactorsXY = itemFactorsOrdererd.rdd.map( 
        x => getXY(x(1).asInstanceOf
        [scala.collection.mutable.WrappedArray[Float]])) 
      itemFactorsXY.first() 
      itemFactorsXY.coalesce(1).saveAsTextFile(output + "/" + 
        date_time + "/movie_lens_items_xy")

movie_lens_items_xy的输出将如下所示:

          2.254384458065033, 0.5487040132284164
          -2.0540390759706497, 0.5557805597782135
          -2.303591560572386, -0.047419726848602295
          -0.7448508385568857, -0.5028514862060547
          -2.8230229914188385, 0.8093537855893373
          -1.4274845123291016, 1.4835840165615082
          -1.3214656114578247, 0.09438827633857727
          -2.028286747634411, 1.0806758720427752
          -0.798517256975174, 0.8371041417121887
          -1.556841880083084, -0.8985426127910614
          -1.0867036543786526, 1.7443277575075626
          -1.4234793484210968, 0.6246072947978973
          -0.04958712309598923, 0.14585793018341064

  1. 接下来我们计算userFactors的 libsvm 格式:
      val userFactorsOrdererd = userFactors.orderBy("id") 
      val userFactorLibSVMFormat = 
        userFactorsOrdererd.rdd.map(x => x(0) + " " + 
        getDetails(x(1).asInstanceOf
          [scala.collection.mutable.WrappedArray[Float]])) 
      println("userFactorLibSVMFormat.count() : " + 
        userFactorLibSVMFormat.count()) 
      print("userFactorLibSVMFormat.first() : " + 
        userFactorLibSVMFormat.first()) 

      userFactorLibSVMFormat.coalesce(1)
        .saveAsTextFile(output + "/" + date_time + 
        "/movie_lens_users_libsvm")

movie_lens_users_libsvm的输出将如下所示:

 1 1:0.75239724 2:0.31830165 3:0.031550772 4:-0.63495475 
          5:-0.719721 6:0.5437525 7:0.59800273 8:-0.4264512  
          9:0.6661331 
          10:-0.9702077
 2 1:-0.053673547 2:-0.24080916 3:-0.6896337 4:-0.3918436   
          5:-0.4108574 6:0.663401 7:0.1975566 8:0.43086317 9:1.0833738 
          10:-0.9398713
 3 1:0.6261427 2:0.58282375 3:-0.48752788 4:-0.36584544 
          5:-1.1869227 6:0.14955235 7:-0.17821303 8:0.3922112 
          9:0.5596394 10:-0.83293504
 4 1:1.0485783 2:0.2569924 3:-0.48094323 4:-1.8882223 
          5:-1.4912299 6:0.50734115 7:1.2781366 8:0.028034585 
          9:1.1323715 10:0.4267411
 5 1:0.31982875 2:0.13479441 3:0.5392742 4:0.33915272 
          5:-1.1892766 6:0.33669636 7:0.38314193 8:-0.9331541 
          9:0.531006 10:-1.0546529
 6 1:-0.5351592 2:0.1995535 3:-0.9234565 4:-0.5741345 
          5:-0.4506062 6:0.35505387 7:0.41615438 8:-0.32665777 
          9:0.22966743 10:-1.1040379
 7 1:0.41014928 2:-0.32102737 3:-0.73221415 4:-0.4017513 
          5:-0.87815255 6:0.3717881 7:-0.070220165 8:-0.5443932 
          9:0.24361002 10:-1.2957898
 8 1:0.2991327 2:0.3574251 3:-0.03855041 4:-0.1719838 
          5:-0.840421 6:0.89891523 7:0.024321048 8:-0.9811069 
          9:0.57676667 10:-1.2015694
 9 1:-1.4988179 2:0.42335498 3:0.5973782 4:-0.11305857 
          5:-1.3311529 6:0.91228217 7:1.461522 8:1.4502159 9:0.5554214 
          10:-1.5014526
 10 1:0.5876411 2:-0.26684982 3:-0.30273324 4:-0.78348076 
          5:-0.61448336 6:0.5506227 7:0.2809167 8:-0.08864456 
          9:0.57811487 10:-1.1085391

  1. 接下来我们提取前两个特征并将它们持久化在一个文件中:
      var userFactorsXY = userFactorsOrdererd.rdd.map( 
        x => getXY(x(1).asInstanceOf
        [scala.collection.mutable.WrappedArray[Float]])) 
      userFactorsXY.first() 
      userFactorsXY.coalesce(1).saveAsTextFile(output + "/" + 
        date_time + "/movie_lens_user_xy")

movie_lens_user_xy的输出将如下所示:

          -0.2524261102080345, 0.4112294316291809
 -1.7868174277245998, 1.435323253273964
 -0.8313295543193817, 0.09025487303733826
 -2.55482479929924, 3.3726249802857637
 0.14377352595329285, -0.736962765455246
 -2.283802881836891, -0.4298199713230133
 -1.9229961037635803, -1.2950050458312035
 -0.39439742639660835, -0.682673366740346
 -1.9222962260246277, 2.8779889345169067
 -1.3799060583114624, 0.21247059851884842

我们将需要xy特征来对两个特征进行聚类,以便我们可以创建一个二维图。

K-means - 训练聚类模型

在 Spark ML 中,对 K-means 进行训练采用了与其他模型类似的方法——我们将包含训练数据的 DataFrame 传递给KMeans对象的 fit 方法。

在这里,我们使用 libsvm 数据格式。

在 MovieLens 数据集上训练聚类模型

我们将为我们通过运行推荐模型生成的电影和用户因子训练模型。

我们需要传入簇的数量K和算法运行的最大迭代次数。如果从一次迭代到下一次迭代的目标函数的变化小于容差水平(默认容差为 0.0001),则模型训练可能会运行少于最大迭代次数。

Spark ML 的 k-means 提供了随机和 K-means ||初始化,其中默认为 K-means ||。由于这两种初始化方法在某种程度上都是基于随机选择的,因此每次模型训练运行都会返回不同的结果。

K-means 通常不会收敛到全局最优模型,因此进行多次训练运行并从这些运行中选择最优模型是一种常见做法。MLlib 的训练方法公开了一个选项,可以完成多个模型训练运行。通过评估损失函数的评估,选择最佳训练运行作为最终模型。

  1. 首先,我们创建一个SparkSession实例,并使用它来加载movie_lens_users_libsvm数据:
      val spConfig = (new 
        SparkConf).setMaster("local[1]").setAppName("SparkApp"). 
        set("spark.driver.allowMultipleContexts", "true") 

      val spark = SparkSession 
        .builder() 
        .appName("Spark SQL Example") 
        .config(spConfig) 
        .getOrCreate() 

      val datasetUsers = spark.read.format("libsvm").load( 
        "./OUTPUT/11_10_2016_10_28_56/movie_lens_users_libsvm/part-
        00000") 
      datasetUsers.show(3)

输出是:

          +-----+--------------------+
 |label|            features|
 +-----+--------------------+
 |  1.0|(10,[0,1,2,3,4,5,...|
 |  2.0|(10,[0,1,2,3,4,5,...|
 |  3.0|(10,[0,1,2,3,4,5,...|
 +-----+--------------------+
 only showing top 3 rows

  1. 然后我们创建一个模型:
      val kmeans = new KMeans().setK(5).setSeed(1L) 
      val modelUsers = kmeans.fit(datasetUsers)

  1. 最后,我们使用用户向量数据集训练一个 K-means 模型:
      val modelUsers = kmeans.fit(datasetUsers)

K-means:使用聚类模型进行预测。

使用训练好的 K-means 模型是简单的,并且类似于我们迄今为止遇到的其他模型,如分类和回归。

通过将 DataFrame 传递给模型的 transform 方法,我们可以对多个输入进行预测:

      val predictedUserClusters = modelUsers.transform(datasetUsers) 
      predictedUserClusters.show(5)

结果输出是预测列中每个数据点的聚类分配:

+-----+--------------------+----------+
|label|            features|prediction|
+-----+--------------------+----------+
|  1.0|(10,[0,1,2,3,4,5,...|         2|
|  2.0|(10,[0,1,2,3,4,5,...|         0|
|  3.0|(10,[0,1,2,3,4,5,...|         0|
|  4.0|(10,[0,1,2,3,4,5,...|         2|
|  5.0|(10,[0,1,2,3,4,5,...|         2|
+-----+--------------------+----------+
only showing top 5 rows

由于随机初始化,聚类分配可能会从模型的一次运行到另一次运行发生变化,因此您的结果可能与之前显示的结果不同。聚类 ID 本身没有固有含义;它们只是从 0 开始的任意标记。

K-means - 解释 MovieLens 数据集上的簇预测

我们已经介绍了如何对一组输入向量进行预测,但是我们如何评估预测的好坏呢?我们稍后将介绍性能指标;但是在这里,我们将看到如何手动检查和解释我们的 k-means 模型所做的聚类分配。

虽然无监督技术的优点是不需要我们提供标记的训练数据,但缺点是通常需要手动解释结果。通常,我们希望进一步检查找到的簇,并可能尝试解释它们并为它们分配某种标签或分类。

例如,我们可以检查我们找到的电影的聚类,尝试看看是否有一些有意义的解释,比如在聚类中的电影中是否有共同的流派或主题。我们可以使用许多方法,但我们将从每个聚类中取几部最接近聚类中心的电影开始。我们假设这些电影最不可能在其聚类分配方面边缘化,因此它们应该是最具代表性的电影之一。通过检查这些电影集,我们可以看到每个聚类中的电影共享哪些属性。

解释电影簇

我们将尝试通过将数据集与预测输出数据集中的电影名称进行连接,列出与每个聚类相关联的电影:

Cluster : 0
--------------------------
+--------------------+
|                name|
+--------------------+
|    GoldenEye (1995)|
|   Four Rooms (1995)|
|Shanghai Triad (Y...|
|Twelve Monkeys (1...|
|Dead Man Walking ...|
|Usual Suspects, T...|
|Mighty Aphrodite ...|
|Antonia's Line (1...|
|   Braveheart (1995)|
|  Taxi Driver (1976)|
+--------------------+
only showing top 10 rows

Cluster : 1
--------------------------
+--------------------+
|                name|
+--------------------+
|     Bad Boys (1995)|
|Free Willy 2: The...|
|        Nadja (1994)|
|     Net, The (1995)|
|       Priest (1994)|
|While You Were Sl...|
|Ace Ventura: Pet ...|
|   Free Willy (1993)|
|Remains of the Da...|
|Sleepless in Seat...|
+--------------------+
only showing top 10 rows

Cluster : 2
--------------------------
+--------------------+
|                name|
+--------------------+
|    Toy Story (1995)|
|   Get Shorty (1995)|
|      Copycat (1995)|
|  Richard III (1995)|
|Seven (Se7en) (1995)|
|Mr. Holland's Opu...|
|From Dusk Till Da...|
|Brothers McMullen...|
|Batman Forever (1...|
|   Disclosure (1994)|
+--------------------+
only showing top 10 rows

Cluster : 3
--------------------------
+--------------------+
|                name|
+--------------------+
|         Babe (1995)|
|  Postino, Il (1994)|
|White Balloon, Th...|
|Muppet Treasure I...|
|Rumble in the Bro...|
|Birdcage, The (1996)|
|    Apollo 13 (1995)|
|Belle de jour (1967)|
| Crimson Tide (1995)|
|To Wong Foo, Than...|
+--------------------+
only showing top 10 rows

解释电影簇

在本节中,我们将回顾代码,其中我们获取每个标签的预测并将它们保存在文本文件中,并绘制二维散点图。

我们将创建两个散点图,一个用于用户,另一个用于项目(在这种情况下是电影):

object MovieLensKMeansPersist { 

  val BASE= "./data/movie_lens_libsvm_2f" 
  val time = System.currentTimeMillis() 
  val formatter = new SimpleDateFormat("dd_MM_yyyy_hh_mm_ss") 

  import java.util.Calendar 
  val calendar = Calendar.getInstance() 
  calendar.setTimeInMillis(time) 
  val date_time = formatter.format(calendar.getTime()) 

  def main(args: Array[String]): Unit = { 

    val spConfig = ( 
    new SparkConf).setMaster("local[1]"). 
    setAppName("SparkApp"). 
      set("spark.driver.allowMultipleContexts", "true") 

    val spark = SparkSession 
      .builder() 
      .appName("Spark SQL Example") 
      .config(spConfig) 
      .getOrCreate() 

    val datasetUsers = spark.read.format("libsvm").load( 
      BASE + "/movie_lens_2f_users_libsvm/part-00000") 
    datasetUsers.show(3) 

    val kmeans = new KMeans().setK(5).setSeed(1L) 
    val modelUsers = kmeans.fit(datasetUsers) 

    // Evaluate clustering by computing Within  
    //Set Sum of Squared Errors. 

    val predictedDataSetUsers = modelUsers.transform(datasetUsers) 
    print(predictedDataSetUsers.first()) 
    print(predictedDataSetUsers.count()) 
    val predictionsUsers = 
    predictedDataSetUsers.select("prediction"). 
    rdd.map(x=> x(0)) 
    predictionsUsers.saveAsTextFile( 
    BASE + "/prediction/" + date_time + "/users") 

    val datasetItems = spark.read.format("libsvm").load( 
      BASE + "/movie_lens_2f_items_libsvm/part-00000") 
    datasetItems.show(3) 

    val kmeansItems = new KMeans().setK(5).setSeed(1L) 
    val modelItems = kmeansItems.fit(datasetItems) 
    // Evaluate clustering by computing Within  
    //Set Sum of Squared Errors. 
    val WSSSEItems = modelItems.computeCost(datasetItems) 
    println(s"Items :  Within Set Sum of Squared Errors = 
      $WSSSEItems") 

    // Shows the result. 
    println("Items - Cluster Centers: ") 
    modelUsers.clusterCenters.foreach(println) 
    val predictedDataSetItems = modelItems.transform(datasetItems) 
    val predictionsItems = predictedDataSetItems. 
      select("prediction").rdd.map(x=> x(0)) 
    predictionsItems.saveAsTextFile(BASE + "/prediction/" +  
      date_time + "/items") 
    spark.stop() 
  }

具有用户数据的 K 均值聚类

上图显示了用户数据的 K 均值聚类。

具有项目数据的 K 均值聚类图

上图显示了项目数据的 K 均值聚类。

K 均值绘制聚类数的效果

上图显示了具有两个特征和一个迭代的用户数据的 K 均值聚类。

上图显示了具有两个特征和 10 次迭代的用户数据的 K 均值聚类。请注意聚类边界的移动。

上图显示了具有两个特征和 10 次迭代的用户数据的 K 均值聚类。请注意聚类边界的移动。

K 均值-评估聚类模型的性能

使用回归、分类和推荐引擎等模型,可以应用许多评估指标来分析聚类模型的性能和数据点的聚类好坏。聚类评估通常分为内部评估和外部评估。内部评估是指使用相同的数据来训练模型和进行评估的情况。外部评估是指使用训练数据之外的数据进行评估。

内部评估指标

常见的内部评估指标包括我们之前介绍的 WCSS(这恰好是 K 均值的目标函数)、Davies-Bouldin 指数、Dunn 指数和轮廓系数。所有这些指标都倾向于奖励集群,其中集群内的元素相对较近,而不同集群中的元素相对较远。

聚类评估的维基百科页面en.wikipedia.org/wiki/Cluster_analysis#Internal_evaluation有更多细节。

外部评估指标

由于聚类可以被视为无监督分类,如果有某种标记(或部分标记)的数据可用,我们可以使用这些标签来评估聚类模型。我们可以使用模型对集群(即类标签)进行预测,并使用类似于分类评估的指标来评估预测(即基于真正和假负率)。

这些包括 Rand 指标、F 指标、Jaccard 指数等。

有关聚类外部评估的更多信息,请参见en.wikipedia.org/wiki/Cluster_analysis#External_evaluation

在 MovieLens 数据集上计算性能指标

Spark ML 提供了一个方便的computeCost函数,用于计算给定 DataFrame 的 WSSS 目标函数。我们将为以下项目和用户训练数据计算此指标:

val WSSSEUsers = modelUsers.computeCost(datasetUsers) 
println(s"Users :  Within Set Sum of Squared Errors = $WSSSEUsers") 
val WSSSEItems = modelItems.computeCost(datasetItems)   
println(s"Items :  Within Set Sum of Squared Errors = $WSSSEItems")

这应该输出类似于以下结果:

Users :  Within Set Sum of Squared Errors = 2261.3086181660324
Items :  Within Set Sum of Squared Errors = 5647.825222497311

衡量 WSSSE 有效性的最佳方法是如下部分所示的迭代次数。

迭代对 WSSSE 的影响

让我们找出迭代对 MovieLens 数据集的 WSSSE 的影响。我们将计算各种迭代值的 WSSSE 并绘制输出。

代码清单如下:

object MovieLensKMeansMetrics { 
  case class RatingX(userId: Int, movieId: Int, rating: Float, 
    timestamp: Long) 
  val DATA_PATH= "../../../data/ml-100k" 
  val PATH_MOVIES = DATA_PATH + "/u.item" 
  val dataSetUsers = null 

  def main(args: Array[String]): Unit = { 

    val spConfig = (new 
      SparkConf).setMaster("local[1]").setAppName("SparkApp"). 
      set("spark.driver.allowMultipleContexts", "true") 

    val spark = SparkSession 
      .builder() 
      .appName("Spark SQL Example") 
      .config(spConfig) 
      .getOrCreate() 

    val datasetUsers = spark.read.format("libsvm").load( 
      "./data/movie_lens_libsvm/movie_lens_users_libsvm/part-
      00000") 
    datasetUsers.show(3) 

    val k = 5 
    val itr = Array(1,10,20,50,75,100) 
    val result = new ArrayString 
    for(i <- 0 until itr.length){ 
      val w = calculateWSSSE(spark,datasetUsers,itr(i),5,1L) 
      result(i) = itr(i) + "," + w 
    } 
    println("----------Users----------") 
    for(j <- 0 until itr.length) { 
      println(result(j)) 
    } 
    println("-------------------------") 

    val datasetItems = spark.read.format("libsvm").load( 
      "./data/movie_lens_libsvm/movie_lens_items_libsvm/"+     
      "part-00000") 

    val resultItems = new ArrayString 
    for(i <- 0 until itr.length){ 
      val w = calculateWSSSE(spark,datasetItems,itr(i),5,1L) 
      resultItems(i) = itr(i) + "," + w 
    } 

    println("----------Items----------") 
    for(j <- 0 until itr.length) { 
      println(resultItems(j)) 
    } 
    println("-------------------------") 

    spark.stop() 
  } 

  import org.apache.spark.sql.DataFrame 

  def calculateWSSSE(spark : SparkSession, dataset : DataFrame,  
    iterations : Int, k : Int, seed : Long) : Double = { 
    val x = dataset.columns 

    val kmeans =  
      new KMeans().setK(k).setSeed(seed).setMaxIter(iterations) 

    val model = kmeans.fit(dataset) 
    val WSSSEUsers = model.computeCost(dataset) 
    return WSSSEUsers 

  }

输出是:

----------Users----------
1,2429.214784372865
10,2274.362593105573
20,2261.3086181660324
50,2261.015660051977
75,2261.015660051977
100,2261.015660051977
-------------------------

----------Items----------
1,5851.444935665099
10,5720.505597821477
20,5647.825222497311
50,5637.7439669472005
75,5637.7439669472005
100,5637.7439669472005

让我们绘制这些数字以更好地了解:

用户 WSSSE 与迭代次数

项目 WSSSE 与迭代次数

二分 KMeans

这是通用 KMeans 的变体。

参考:www.siam.org/meetings/sdm01/pdf/sdm01_05.pdf

算法的步骤是:

  1. 通过随机选择一个点,比如  然后计算M的质心w并计算:

质心是聚类的中心。质心是一个包含每个变量的一个数字的向量,其中每个数字是该聚类中观察值的平均值。

  1. M =[x1, x2, ... xn]分成两个子聚类M[L]M[R],根据以下规则:

  1. 计算M[L]M[R]的质心w[L]w[R],如第 2 步所示。

  2. 如果 w[L] = c[L] 和 w[R] = c[R],则停止。

否则,让 c[L]= w[L] c[R] = w[R],转到第 2 步。

二分 K 均值-训练聚类模型

在 Spark ML 中进行二分 K 均值的训练涉及采用类似其他模型的方法--我们将包含训练数据的 DataFrame 传递给KMeans对象的 fit 方法。请注意,这里我们使用 libsvm 数据格式:

  1. 实例化聚类对象:
        val spConfig = (new                         
        SparkConf).setMaster("local[1]").setAppName("SparkApp"). 
        set("spark.driver.allowMultipleContexts", "true") 

        val spark = SparkSession 
          .builder() 
          .appName("Spark SQL Example") 
          .config(spConfig) 
          .getOrCreate() 

        val datasetUsers = spark.read.format("libsvm").load( 
          BASE + "/movie_lens_2f_users_libsvm/part-00000") 
        datasetUsers.show(3)

命令show(3)的输出如下所示:

 +-----+--------------------+
 |label|            features|
 +-----+--------------------+
 |  1.0|(2,[0,1],[0.37140...|
 |  2.0|(2,[0,1],[-0.2131...|
 |  3.0|(2,[0,1],[0.28579...|
 +-----+--------------------+
 only showing top 3 rows

创建BisectingKMeans对象并设置参数:

          val bKMeansUsers = new BisectingKMeans() 
          bKMeansUsers.setMaxIter(10) 
          bKMeansUsers.setMinDivisibleClusterSize(5)

  1. 训练数据:
          val modelUsers = bKMeansUsers.fit(datasetUsers) 

          val movieDF = Util.getMovieDataDF(spark) 
          val predictedUserClusters = 
            modelUsers.transform(datasetUsers) 
          predictedUserClusters.show(5)

输出是:

          +-----+--------------------+----------+
 |label|            features|prediction|
 +-----+--------------------+----------+
 |  1.0|(2,[0,1],[0.37140...|         3|
 |  2.0|(2,[0,1],[-0.2131...|         3|
 |  3.0|(2,[0,1],[0.28579...|         3|
 |  4.0|(2,[0,1],[-0.6541...|         1|
 |  5.0|(2,[0,1],[0.90333...|         2|
 +-----+--------------------+----------+
 only showing top 5 rows

  1. 按聚类显示电影:
        val joinedMovieDFAndPredictedCluster = 
          movieDF.join(predictedUserClusters,predictedUserClusters
          ("label") === movieDF("id")) 
        print(joinedMovieDFAndPredictedCluster.first()) 
        joinedMovieDFAndPredictedCluster.show(5)

输出是:

 +--+---------------+-----------+-----+--------------------+----------+
 |id|          name|       date|label|      features|prediction|
 +--+---------------+-----------+-----+--------------------+----------+
 | 1| Toy Story (1995)  |01-Jan-1995|  1.0|(2,[0,1],[0.37140...|3|
 | 2| GoldenEye (1995)  |01-Jan-1995|  2.0|(2,[0,1],[-0.2131...|3|
 | 3|Four Rooms (1995)  |01-Jan-1995|  3.0|(2,[0,1],[0.28579...|3|
 | 4| Get Shorty (1995) |01-Jan-1995|  4.0|(2,[0,1],[-0.6541...|1|
 | 5| Copycat (1995)    |01-Jan-1995|  5.0|(2,[0,1],[0.90333...|2|
 +--+----------------+-----------+-----+--------------------+----------+
 only showing top 5 rows

让我们按照聚类编号打印电影:

        for(i <- 0 until 5) { 
          val prediction0 =     
          joinedMovieDFAndPredictedCluster.filter("prediction == " + i) 
          println("Cluster : " + i) 
          println("--------------------------") 
          prediction0.select("name").show(10) 
        }

输出是:

          Cluster : 0
 +--------------------+
 |                name|
 +--------------------+
 |Antonia's Line (1...|
 |Angels and Insect...|
 |Rumble in the Bro...|
 |Doom Generation, ...|
 |     Mad Love (1995)|
 | Strange Days (1995)|
 |       Clerks (1994)|
 |  Hoop Dreams (1994)|
 |Legends of the Fa...|
 |Professional, The...|
 +--------------------+
 only showing top 10 rows

 Cluster : 1
 --------------------------

 +--------------------+
 |                name|
 +--------------------+
 |   Get Shorty (1995)|
 |Dead Man Walking ...|
 |  Richard III (1995)|
 |Seven (Se7en) (1995)|
 |Usual Suspects, T...|
 |Mighty Aphrodite ...|
 |French Twist (Gaz...|
 |Birdcage, The (1996)|
 |    Desperado (1995)|
 |Free Willy 2: The...|
 +--------------------+
 only showing top 10 rows

 Cluster : 2
 --------------------------
 +--------------------+
          |                name|
 +--------------------+
          |      Copycat (1995)|
          |Shanghai Triad (Y...|
 |  Postino, Il (1994)|
          |From Dusk Till Da...|
          |   Braveheart (1995)|
 |Batman Forever (1...|
 |        Crumb (1994)|
          |To Wong Foo, Than...|
 |Billy Madison (1995)|
 |Dolores Claiborne...|
          +--------------------+
 only showing top 10 rows

          Cluster : 3
          --------------------------
          +--------------------+
 |                name|
          +--------------------+
          |    Toy Story (1995)|
 |    GoldenEye (1995)|
 |   Four Rooms (1995)|
 |Twelve Monkeys (1...|
          |         Babe (1995)|
 |Mr. Holland's Opu...|
 |White Balloon, Th...|
 |Muppet Treasure I...|
          |  Taxi Driver (1976)|
          |Brothers McMullen...|
 +--------------------+
          only showing top 10 rows

让我们计算 WSSSE:

          val WSSSEUsers = modelUsers.computeCost(datasetUsers) 
          println(s"Users : Within Set Sum of Squared Errors =                      $WSSSEUsers") 

          println("Users : Cluster Centers: ") 
          modelUsers.clusterCenters.foreach(println)

输出是:

          Users : Within Set Sum of Squared Errors = 220.213984126387
          Users : Cluster Centers: 
          [-0.5152650631965345,-0.17908608684257435]
          [-0.7330009110582011,0.5699292831746033]
          [0.4657482296168242,0.07541218866995708]
          [0.07297392612510972,0.7292946749843259]

接下来我们对物品进行预测:

          val datasetItems = spark.read.format("libsvm").load( 
            BASE + "/movie_lens_2f_items_libsvm/part-00000") 
          datasetItems.show(3) 

          val kmeansItems = new BisectingKMeans().setK(5).setSeed(1L) 
          val modelItems = kmeansItems.fit(datasetItems) 

          // Evaluate clustering by computing Within Set 
          // Sum of Squared Errors. 
          val WSSSEItems = modelItems.computeCost(datasetItems) 
          println(s"Items : Within Set Sum of Squared Errors = 
            $WSSSEItems") 

          // Shows the result. 
          println("Items - Cluster Centers: ") 
          modelUsers.clusterCenters.foreach(println) 

          Items: within Set Sum of Squared Errors = 538.4272487824393 
          Items - Cluster Centers:  
            [-0.5152650631965345,-0.17908608684257435] 
            [-0.7330009110582011,0.5699292831746033] 
            [0.4657482296168242,0.07541218866995708] 
            [0.07297392612510972,0.7292946749843259]

源代码:

github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_08/scala/2.0.0/src/main/scala/org/sparksamples/kmeans/BisectingKMeans.scala

  1. 绘制用户和物品聚类。

接下来,让我们选择两个特征,并绘制用户和物品聚类及其各自的聚类:

          object BisectingKMeansPersist { 
            val PATH = "/home/ubuntu/work/spark-2.0.0-bin-hadoop2.7/" 
            val BASE = "./data/movie_lens_libsvm_2f" 

            val time = System.currentTimeMillis() 
            val formatter = new 
              SimpleDateFormat("dd_MM_yyyy_hh_mm_ss") 

            import java.util.Calendar 
            val calendar = Calendar.getInstance() 
            calendar.setTimeInMillis(time) 
            val date_time = formatter.format(calendar.getTime()) 

            def main(args: Array[String]): Unit = { 

              val spConfig = (new     
                SparkConf).setMaster("local[1]")
                .setAppName("SparkApp"). 
              set("spark.driver.allowMultipleContexts", "true") 

              val spark = SparkSession 
                .builder() 
                .appName("Spark SQL Example") 
                .config(spConfig) 
                .getOrCreate() 

              val datasetUsers = spark.read.format("libsvm").load( 
                BASE + "/movie_lens_2f_users_libsvm/part-00000") 

              val bKMeansUsers = new BisectingKMeans() 
              bKMeansUsers.setMaxIter(10) 
              bKMeansUsers.setMinDivisibleClusterSize(5) 

              val modelUsers = bKMeansUsers.fit(datasetUsers) 
              val predictedUserClusters = 
                modelUsers.transform(datasetUsers) 

              modelUsers.clusterCenters.foreach(println) 
              val predictedDataSetUsers = 
                modelUsers.transform(datasetUsers) 
              val predictionsUsers =       
                predictedDataSetUsers.select("prediction")
                .rdd.map(x=> x(0)) 
               predictionsUsers.saveAsTextFile(BASE + 
                 "/prediction/" +      
               date_time + "/bkmeans_2f_users")    

               val datasetItems = 
                 spark.read.format("libsvm").load(BASE + 
                 "/movie_lens_2f_items_libsvm/part-00000") 

               val kmeansItems = new 
                 BisectingKMeans().setK(5).setSeed(1L) 
               val modelItems = kmeansItems.fit(datasetItems) 

               val predictedDataSetItems = 
                 modelItems.transform(datasetItems) 
               val predictionsItems =      
                 predictedDataSetItems.select("prediction")
                 .rdd.map(x=> x(0)) 
                 predictionsItems.saveAsTextFile(BASE + 
                 "/prediction/" +         
               date_time + "/bkmeans_2f_items") 
               spark.stop() 
            } 
          }

用聚类绘制 MovieLens 用户数据

上述图表显示了两个特征的用户聚类的样子。

用聚类绘制 MovieLens 物品(电影)数据

上述图表显示了两个特征的物品聚类的样子。

WSSSE 和迭代

在本节中,我们评估了对 K 均值算法进行二分时迭代次数对 WSSSE 的影响。

源代码是:

object BisectingKMeansMetrics { 
  case class RatingX(userId: Int, movieId: Int,  
    rating: Float, timestamp: Long) 
  val DATA_PATH= "../../../data/ml-100k" 
  val PATH_MOVIES = DATA_PATH + "/u.item" 
  val dataSetUsers = null 

  def main(args: Array[String]): Unit = { 

    val spConfig = ( 
      new SparkConf).setMaster("local[1]").setAppName("SparkApp"). 
      set("spark.driver.allowMultipleContexts", "true") 

    val spark = SparkSession 
      .builder() 
      .appName("Spark SQL Example") 
      .config(spConfig) 
      .getOrCreate() 

    val datasetUsers = spark.read.format("libsvm").load( 
      "./data/movie_lens_libsvm/movie_lens_users_libsvm/part-
      00000") 
    datasetUsers.show(3) 

    val k = 5 
    val itr = Array(1,10,20,50,75,100) 
    val result = new ArrayString 
    for(i <- 0 until itr.length){ 
      val w = calculateWSSSE(spark,datasetUsers,itr(i),5) 
      result(i) = itr(i) + "," + w 
    } 
    println("----------Users----------") 
    for(j <- 0 until itr.length) { 
      println(result(j)) 
    } 
    println("-------------------------") 

    val datasetItems = spark.read.format("libsvm").load( 
      "./data/movie_lens_libsvm/movie_lens_items_libsvm/part-
      00000") 
    val resultItems = new ArrayString 
    for(i <- 0 until itr.length){ 
      val w = calculateWSSSE(spark,datasetItems,itr(i),5) 
      resultItems(i) = itr(i) + "," + w 
    } 

    println("----------Items----------") 
    for(j <- 0 until itr.length) { 
      println(resultItems(j)) 
    } 
    println("-------------------------") 

    spark.stop() 
  } 

  import org.apache.spark.sql.DataFrame 

  def calculateWSSSE(spark : SparkSession, dataset : DataFrame, 
    iterations : Int, k : Int) : Double = 
  { 
    val x = dataset.columns 

    val bKMeans = new BisectingKMeans() 
    bKMeans.setMaxIter(iterations) 
    bKMeans.setMinDivisibleClusterSize(k) 

    val model = bKMeans.fit(dataset) 
    val WSSSE = model.computeCost(dataset) 
    return WSSSE 

  } 
}

图:用户迭代的 WSSSE

图:在二分 K 均值情况下物品的 WSSSE 与迭代次数

很明显,该算法在 20 次迭代后对用户和物品都达到了最佳的 WSSSE。

高斯混合模型

混合模型是一个人口中子群体的概率模型。这些模型用于对子群体的统计推断,给定汇总人口的观察结果。

高斯混合模型GMM)是一个以高斯分量密度的加权和表示的混合模型。它的模型系数是使用迭代的期望最大化EM)算法或从训练模型的最大后验MAP)估计的。

spark.ml的实现使用 EM 算法。

它具有以下参数:

  • k:期望的聚类数量

  • convergenceTol:在认为收敛达到的对数似然的最大变化

  • maxIterations:执行而不收敛的最大迭代次数

  • initialModel:可选的起始点,从这里开始 EM 算法

(如果省略此参数,将从数据中构造一个随机起始点)

使用 GMM 进行聚类

我们将为用户和物品(在这种情况下是电影)创建聚类,以更好地了解算法如何对用户和物品进行分组。

执行以下步骤:

  1. 加载用户的libsvm文件。

  2. 创建一个高斯混合实例。该实例具有以下可配置的参数:

       final val featuresCol: Param[String] 
       Param for features column name. 
       final val k: IntParam 
       Number of independent Gaussians in the mixture model. 
       final val 
       maxIter: IntParam 
       Param for maximum number of iterations (>= 0). 
       final val predictionCol: Param[String] 
       Param for prediction column name. 
       final val probabilityCol: Param[String] 
       Param for Column name for predicted class conditional 
       probabilities. 
       final val seed: LongParam 
       Param for random seed. 
       final val tol: DoubleParam

  1. 在我们的情况下,我们将只设置高斯分布的数量和种子数:
       val gmmUsers = new GaussianMixture().setK(5).setSeed(1L)

  1. 创建一个用户模型:
       Print Covariance and Mean
      for (i <- 0 until modelUsers.gaussians.length) { 
        println("Users: weight=%f\ncov=%s\nmean=\n%s\n" format 
          (modelUsers.weights(i), modelUsers.gaussians(i).cov,                           
          modelUsers.gaussians(i).mean)) 
      }

完整的代码清单是:

          object GMMClustering { 

            def main(args: Array[String]): Unit = { 
              val spConfig = (new SparkConf).setMaster("local[1]"). 
                setAppName("SparkApp"). 
                set("spark.driver.allowMultipleContexts", "true") 

              val spark = SparkSession 
                .builder() 
                .appName("Spark SQL Example") 
                .config(spConfig) 
                .getOrCreate() 

              val datasetUsers = spark.read.format("libsvm").                
               load("./data/movie_lens_libsvm/movie_lens_users_libsvm/"
               + "part-00000") 
              datasetUsers.show(3) 

              val gmmUsers = new GaussianMixture().setK(5).setSeed(1L) 
              val modelUsers = gmmUsers.fit(datasetUsers) 

              for (i <- 0 until modelUsers.gaussians.length) { 
                println("Users : weight=%f\ncov=%s\nmean=\n%s\n" 
                   format (modelUsers.weights(i),  
                   modelUsers.gaussians(i).cov,  
                   modelUsers.gaussians(i).mean)) 
                } 

              val dataSetItems = spark.read.format("libsvm").load( 
                "./data/movie_lens_libsvm/movie_lens_items_libsvm/" + 
                "part-00000") 

              val gmmItems = new 
                  GaussianMixture().setK(5).setSeed(1L) 
              val modelItems = gmmItems.fit(dataSetItems) 

              for (i <- 0 until modelItems.gaussians.length) { 
                println("Items : weight=%f\ncov=%s\nmean=\n%s\n" 
                   format (modelUsers.weights(i), 
                   modelUsers.gaussians(i).cov, 
                   modelUsers.gaussians(i).mean)) 
              } 
              spark.stop() 
            }

用 GMM 聚类绘制用户和物品数据

在这一部分,我们将看一下基于 GMM 的聚类边界随着迭代次数的增加而移动:

MovieLens 用户数据通过 GMM 分配的聚类图

MovieLens 项目数据通过 GMM 分配的聚类图

GMM - 迭代次数对聚类边界的影响

让我们看一下随着 GMM 迭代次数的增加,聚类边界如何变化:

使用一次迭代的用户数据的 GMM 聚类图

上图显示了使用一次迭代的用户数据的 GMM 聚类。

使用 10 次迭代的用户数据的 GMM 聚类图

上图显示了使用 10 次迭代的用户数据的 GMM 聚类。

使用 20 次迭代的用户数据的 GMM 聚类图

上图显示了使用 20 次迭代的用户数据的 GMM 聚类。

总结

在本章中,我们探讨了一种从未标记数据中学习结构的新模型类别 -- 无监督学习。我们通过所需的输入数据和特征提取进行了工作,并看到如何使用一个模型的输出(在我们的例子中是推荐模型)作为另一个模型的输入(我们的 k-means 聚类模型)。最后,我们评估了聚类模型的性能,既使用了对聚类分配的手动解释,也使用了数学性能指标。

在下一章中,我们将介绍另一种无监督学习方法,用于将数据减少到其最重要的特征或组件 -- 降维模型。

第九章:使用 Spark 进行降维

在本章的过程中,我们将继续探索降维的无监督学习模型。

与迄今为止我们所涵盖的模型(如回归、分类和聚类)不同,降维并不专注于进行预测。相反,它试图对具有特征维度D(即我们的特征向量的长度)的输入数据进行处理,并提取维度k的数据表示,其中k通常明显小于D。因此,它是一种预处理或特征转换,而不是一种独立的预测模型。

重要的是,提取的表示仍应能够捕获原始数据的大部分变异性或结构。其背后的想法是,大多数数据源都会包含某种潜在结构。这种结构通常是未知的(通常称为潜在特征或潜在因素),但如果我们能够揭示部分结构,我们的模型就可以从中学习并进行预测,而不是直接从原始数据中进行预测,原始数据可能存在噪声或包含许多无关特征。换句话说,降维会丢弃数据中的一些噪声,并保留其中存在的隐藏结构。

在某些情况下,原始数据的维度远高于我们拥有的数据点数量,因此,如果没有降维,其他机器学习模型(如分类和回归)将很难学习任何东西,因为它们需要拟合的参数数量远大于训练样本的数量(在这种意义上,这些方法与我们在分类和回归中看到的正则化方法有些相似)。

降维技术的一些用例包括以下内容:

  • 探索性数据分析

  • 提取特征以训练其他机器学习模型

  • 减少预测阶段非常大模型的存储和计算要求(例如,进行预测的生产系统)

  • 将大量文本文档减少到一组隐藏的主题或概念

  • 当我们的数据具有非常多的特征时(例如在处理文本、声音、图像或视频数据时,这些数据往往是高维的),使模型的学习和泛化变得更容易

在本章中,我们将进行以下操作:

  • 介绍 MLlib 中可用的降维模型类型

  • 处理人脸图像以提取适合降维的特征

  • 使用 MLlib 训练降维模型

  • 可视化和评估结果

  • 为我们的降维模型执行参数选择

降维的类型

MLlib 提供了两种降维模型;这些模型彼此密切相关。这些模型是主成分分析PCA)和奇异值分解SVD)。

主成分分析

PCA 作用于数据矩阵X,并试图从X中提取一组k个主成分。这些主成分彼此不相关,并且计算它们的方式是,第一个主成分解释了输入数据中的最大变异性。然后,每个后续的主成分依次计算,以便它解释了最大的变异性,前提是它与迄今为止计算的主成分是独立的。

这样,返回的 k 个主成分保证能够解释输入数据中的最大变化量。实际上,每个主成分的特征维度与原始数据矩阵相同。因此,实际进行降维需要投影步骤,其中原始数据被投影到由主成分表示的 k 维空间中。

奇异值分解

SVD 旨在将维度为 m x n 的矩阵 X 分解为这三个组件矩阵:

  • U 的维度为 m x m

  • S,大小为 m x n 的对角矩阵;S 的条目被称为奇异值

  • VT 的维度为 n x n

X = U * S * V ^T

从前面的公式可以看出,我们实际上并没有降低问题的维度,因为通过乘以 USV,我们重构了原始矩阵。实际上,通常计算截断奇异值分解。也就是说,只保留最高的 k 个奇异值,它们代表数据中的最大变化量,而其余的则被丢弃。然后基于组件矩阵重构 X 的公式是近似的,如下所示:

X ~ U[k] * S[k] * V[k T]

截断奇异值分解的示意图如下所示:

截断奇异值分解

保留前 k 个奇异值类似于在 PCA 中保留前 k 个主成分。实际上,SVD 和 PCA 直接相关,我们稍后会在本章中看到。

对 PCA 和 SVD 的详细数学处理超出了本书的范围。

在 Spark 文档中可以找到降维的概述:spark.apache.org/docs/latest/mllib-dimensionality-reduction.html

以下链接分别包含 PCA 和 SVD 的更深入的数学概述:en.wikipedia.org/wiki/Principal_component_analysisen.wikipedia.org/wiki/Singular_value_decomposition

与矩阵分解的关系

PCA 和 SVD 都是矩阵分解技术,它们将数据矩阵分解为具有比原始矩阵更低维度(或秩)的子组件矩阵。许多其他降维技术都是基于矩阵分解的。

您可能还记得另一个矩阵分解的例子,即协同过滤,我们在第六章中已经看到了,使用 Spark 构建分类模型。协同过滤的矩阵分解方法通过将评分矩阵分解为两个组件来工作:用户因子矩阵和物品因子矩阵。每个矩阵的维度都低于原始数据,因此这些方法也充当降维模型。

许多最佳的协同过滤方法都包括基于 SVD 的模型。Simon Funk 对 Netflix 奖的方法就是一个著名的例子。您可以在sifter.org/~simon/journal/20061211.html上查看。

聚类作为降维

我们在上一章中介绍的聚类模型也可以用于一种形式的降维。工作方式如下:

  • 假设我们使用 K 均值聚类模型对高维特征向量进行聚类,得到 k 个聚类中心。

  • 我们可以表示原始数据点中的每一个数据点与每个聚类中心的距离。也就是说,我们可以计算数据点到每个聚类中心的距离。结果是每个数据点的一组 k 个距离。

  • 这些k距离可以形成一个新的k维向量。现在,我们可以将我们的原始数据表示为相对于原始特征维度的较低维度的新向量。

根据使用的距离度量,这可能导致数据的降维和一种非线性转换形式,使我们能够学习一个更复杂的模型,同时仍然受益于线性模型的速度和可扩展性。例如,使用高斯或指数距离函数可以近似一个非常复杂的非线性特征转换。

从数据中提取正确的特征

与迄今为止我们所探索的所有机器学习模型一样,降维模型也是在我们数据的特征向量表示上操作的。

在本章中,我们将深入探讨图像处理领域,使用野外标记人脸LFW)数据集的面部图像。该数据集包含来自互联网的超过 13,000 张面部图像,并属于知名公众人物。这些面部带有人名标签。

从 LFW 数据集中提取特征

为了避免下载和处理非常庞大的数据集,我们将使用一部分图像,使用以 A 开头的人名。该数据集可以从vis-www.cs.umass.edu/lfw/lfw-a.tgz下载。

有关更多详细信息和数据的其他变体,请访问vis-www.cs.umass.edu/lfw/

原始研究论文的引用是:

Gary B. HuangManu RameshTamara BergErik Learned-Miller野外标记人脸:用于研究非受限环境中人脸识别的数据库。马萨诸塞大学阿默斯特分校,技术报告 07-49,2007 年 10 月。

它可以从vis-www.cs.umass.edu/lfw/lfw.pdf下载。

使用以下命令解压数据:

>tar xfvz lfw-a.tgz

这将创建一个名为lfw的文件夹,其中包含许多子文件夹,每个人一个。

探索面部数据

我们将使用 Spark 应用程序来分析数据。确保数据解压缩到data文件夹中,如下所示:

Chapter_09
|-- 2.0.x
|   |-- python
|   |-- scala
|-- data

实际的代码在scala文件夹中,除了一些图表在python文件夹中:

scala
|-- src
|   |-- main
|   |   |-- java
|   |   |-- resources
|   |   |-- scala
|   |   |   |-- org
|   |   |       |-- sparksamples
|   |   |           |-- ImageProcessing.scala
|   |   |           |-- Util.scala
|   |   |-- scala-2.11
|   |-- test

现在我们已经解压了数据,我们面临一个小挑战。Spark 为我们提供了一种读取文本文件和自定义 Hadoop 输入数据源的方法。但是,没有内置功能允许我们读取图像。

Spark 提供了一个名为wholeTextFiles的方法,允许我们一次操作整个文件,与我们迄今为止一直使用的textFile方法相比,后者操作文本文件(或多个文件)中的各行。

我们将使用wholeTextFiles方法来访问每个文件的位置。使用这些文件路径,我们将编写自定义代码来加载和处理图像。在下面的示例代码中,我们将使用 PATH 来引用您提取lfw子目录的目录。

我们可以使用通配符路径规范(在下面的代码片段中突出显示*字符)告诉 Spark 在lfw目录下的每个目录中查找文件:

val spConfig = (new SparkConf).setMaster("local[1]")
  .setAppName("SparkApp")
  .set("spark.driver.allowMultipleContexts", "true") 
val sc = new SparkContext(spConfig) 
val path = PATH +  "/lfw/*" 
val rdd = sc.wholeTextFiles(path) 
val first = rdd.first 
println(first)

运行first命令可能需要一些时间,因为 Spark 首先会扫描指定的目录结构以查找所有可用的文件。完成后,您应该看到类似于此处显示的输出:

first: (String, String) =  (file:/PATH/lfw/Aaron_Eckhart /Aaron_Eckhart_0001.jpg,??JFIF????? ...

您将看到wholeTextFiles返回一个包含键值对的 RDD,其中键是文件位置,而值是整个文本文件的内容。对于我们的目的,我们只关心文件路径,因为我们不能直接将图像数据作为字符串处理(请注意,在 shell 输出中显示为“二进制无意义”)。

让我们从 RDD 中提取文件路径。请注意,之前文件路径以file:文本开头。这是 Spark 在读取文件时使用的,以区分不同的文件系统(例如,本地文件系统的file://,HDFS 的hdfs://,Amazon S3 的s3n://等)。

在我们的情况下,我们将使用自定义代码来读取图像,因此我们不需要路径的这一部分。因此,我们将使用以下map函数将其删除:

val files = rdd.map { case (fileName, content) =>
  fileName.replace("file:", "") }

上述函数将显示去除了file:前缀的文件位置:

/PATH/lfw/Aaron_Eckhart/Aaron_Eckhart_0001.jpg

接下来,我们将看到我们要处理多少个文件:

println(files.count)

运行这些命令会在 Spark 中创建大量嘈杂的输出,因为它会将所有读取到的文件路径输出到控制台。忽略这部分,但在命令完成后,输出应该看起来像这样:

..., /PATH/lfw/Azra_Akin/Azra_Akin_0003.jpg:0+19927,
  /PATH/lfw/Azra_Akin/Azra_Akin_0004.jpg:0+16030
...
14/09/18 20:36:25 INFO SparkContext: Job finished:
  count at  <console>:19, took 1.151955 s
1055

因此,我们可以看到我们有1055张图像可以使用。

可视化面部数据

尽管 Scala 或 Java 中有一些工具可用于显示图像,但这是 Python 和matplotlib库发光的一个领域。我们将使用 Scala 来处理和提取图像并运行我们的模型,使用 IPython 来显示实际的图像。

您可以通过打开新的终端窗口并启动新的笔记本来运行单独的 IPython 笔记本,如下所示:

>ipython notebook

如果使用 Python Notebook,您应该首先执行以下代码片段,以确保在每个笔记本单元格之后内联显示图像(包括%字符):%pylab inline

或者,您可以启动一个普通的 IPython 控制台,而不是 Web 笔记本,使用以下命令启用pylab绘图功能:

>ipython --pylab

在撰写本书时,MLlib 中的降维技术仅在 Scala 或 Java 中可用,因此我们将继续使用 Scala Spark shell 来运行模型。因此,您不需要运行 PySpark 控制台。

我们已经提供了本章的完整 Python 代码,既作为 Python 脚本,也作为 IPython 笔记本格式。有关安装 IPython 的说明,请参阅代码包。

让我们显示通过之前提取的第一个路径给出的图像,使用 PIL 的图像库:

from PIL import Image, ImageFilter 
path = PATH + "/lfw/Aaron_Eckhart/Aaron_Eckhart_0001.jpg" 
im = Image.open(path) 
im.show()

您应该看到截图显示如下:

提取面部图像作为向量

虽然本书不涵盖图像处理的全部内容,但您需要了解一些基础知识才能继续。每个彩色图像可以表示为一个像素的三维数组或矩阵。前两个维度,即xy轴,表示每个像素的位置,而第三个维度表示每个像素的绿RGB)颜色值。

灰度图像每个像素只需要一个值(没有 RGB 值),因此它可以表示为一个普通的二维矩阵。对于许多与图像相关的图像处理和机器学习任务,通常会对灰度图像进行操作。我们将通过首先将彩色图像转换为灰度图像来实现这一点。

在机器学习任务中,将图像表示为向量而不是矩阵也是一种常见做法。我们通过将矩阵的每一行(或者每一列)连接起来形成一个长向量来实现这一点(这被称为“重塑”)。这样,每个原始的灰度图像矩阵被转换成一个特征向量,可用作机器学习模型的输入。

幸运的是,内置的 Java 抽象窗口工具包AWT)包含各种基本的图像处理功能。我们将定义一些实用函数来使用java.awt类执行此处理。

加载图像

第一个是从文件中读取图像的函数。

import java.awt.image.BufferedImage 
def loadImageFromFile(path: String): BufferedImage = { 
  ImageIO.read(new File(path)) 
}

上述代码在Util.scala中可用。

这将返回一个java.awt.image.BufferedImage类的实例,它存储图像数据,并提供许多有用的方法。让我们通过将第一张图像加载到我们的 Spark shell 中来测试一下:

val aePath = "/PATH/lfw/Aaron_Eckhart/Aaron_Eckhart_0001.jpg" 
val aeImage = loadImageFromFile(aePath)

您应该在 shell 中看到显示的图像细节。

aeImage: java.awt.image.BufferedImage = BufferedImage@f41266e: 
type =  5 ColorModel: #pixelBits = 24 numComponents = 3 color space =  java.awt.color.ICC_ColorSpace@7e420794 transparency = 1 has 
alpha =  false isAlphaPre = false ByteInterleavedRaster: 
width = 250 height =  250 #numDataElements 3 dataOff[0] = 2

这里有很多信息。我们特别感兴趣的是图像的宽度和高度是250像素,正如我们所看到的,有三个组件(即 RGB 值)在前面的输出中被突出显示。

将图像转换为灰度并调整大小

我们将定义的下一个函数将采用我们用前述函数加载的图像,将图像从彩色转换为灰度,并调整图像的宽度和高度。

这些步骤并不是严格必要的,但在许多情况下都会为了效率而执行。使用 RGB 彩色图像而不是灰度图像会使要处理的数据量增加三倍。同样,较大的图像会显著增加处理和存储开销。我们的原始 250 x 250 图像代表每个图像使用三个颜色组件的 187,500 个数据点。对于 1055 个图像集,这是 197,812,500 个数据点。即使存储为整数值,每个存储的值占用 4 字节的内存,因此仅 1055 个图像就代表大约 800 MB 的内存!正如您所看到的,图像处理任务很快就会变得极其占用内存。

如果我们将图像转换为灰度并将其调整为 50 x 50 像素,我们只需要每个图像 2500 个数据点。对于我们的 1055 个图像,这相当于 10 MB 的内存,这对于说明目的来说更容易管理。

让我们定义我们的处理函数。我们将在一步中执行灰度转换和调整大小,使用java.awt.image包:

def processImage(image: BufferedImage, width: Int, height: Int): 
  BufferedImage = { 
    val bwImage = new BufferedImage(width, height, 
    BufferedImage.TYPE_BYTE_GRAY) 
    val g = bwImage.getGraphics() 
    g.drawImage(image, 0, 0, width, height, null) 
    g.dispose() 
    bwImage 
  }

函数的第一行创建了一个所需宽度和高度的新图像,并指定了灰度颜色模型。第三行将原始图像绘制到这个新创建的图像上。drawImage方法会为我们处理颜色转换和调整大小!最后,我们返回新处理过的图像。

让我们在样本图像上测试一下。我们将把它转换为灰度,并将其调整为 100 x 100 像素:

val grayImage = processImage(aeImage, 100, 100)

您应该在控制台上看到以下输出:

grayImage: java.awt.image.BufferedImage = BufferedImage@21f8ea3b:  
type = 10 ColorModel: #pixelBits = 8 numComponents = 1 color space =  java.awt.color.ICC_ColorSpace@5cd9d8e9 transparency = 1 has 
alpha =  false isAlphaPre = false ByteInterleavedRaster: 
width = 100 height =  100 #numDataElements 1 dataOff[0] = 0

从突出显示的输出中可以看出,图像的宽度和高度确实是100,颜色组件的数量是1

接下来,我们将把处理过的图像保存到临时位置,以便我们可以读取它并使用 Python 应用程序显示它。

import javax.imageio.ImageIO 
import java.io.File 
ImageIO.write(grayImage, "jpg", new File("/tmp/aeGray.jpg"))

您应该在控制台上看到true的结果,表示您已成功将图像保存到/tmp目录中的aeGray.jpg文件中。

最后,我们将在 Python 中读取图像,并使用 matplotlib 显示图像。将以下代码键入到您的 IPython Notebook 或 shell 中(请记住这应该在一个新的终端窗口中打开):

tmp_path = PATH + "/aeGray.jpg"
ae_gary = Image.open(tmp_path)
ae_gary.show()

这应该显示图像(再次注意,我们这里没有显示图像)。您会看到它是灰度的,与原始图像相比质量稍差。此外,您会注意到轴的比例不同,表示新的 100 x 100 尺寸,而不是原始的 250 x 250 尺寸。

提取特征向量

处理管道中的最后一步是提取实际的特征向量,这些向量将成为我们降维模型的输入。正如我们之前提到的,原始灰度像素数据将成为我们的特征。我们将通过展平二维像素矩阵来形成这些向量。BufferedImage类提供了一个实用方法来执行此操作,我们将在我们的函数中使用它,如下所示:

def getPixelsFromImage(image: BufferedImage): Array[Double] = { 
  val width = image.getWidth 
  val height = image.getHeight 
  val pixels = Array.ofDimDouble 
  image.getData.getPixels(0, 0, width, height, pixels) 
}

然后,我们可以将这三个函数合并成一个实用函数,该函数接受文件位置以及所需图像的宽度和高度,并返回包含像素数据的原始Array[Double]值。

def extractPixels(path: String, width: Int, height: Int):
  Array[Double] = { 
    val raw = loadImageFromFile(path) 
    val processed = processImage(raw, width, height) 
    getPixelsFromImage(processed) 
  }

将这个前述函数应用于包含所有图像文件路径的 RDD 的每个元素,将为我们提供一个包含每个图像的像素数据的新 RDD。让我们这样做,并检查前几个元素,如下所示:

val pixels = files.map(f => extractPixels(f, 50, 50)) 
println(pixels.take(10).map(_.take(10).mkString   ("", ",", ", 
  ...")).mkString("n"))

您应该看到类似于以下的输出:

0.0,0.0,0.0,0.0,0.0,0.0,1.0,1.0,0.0,0.0, ...
241.0,243.0,245.0,244.0,231.0,205.0,177.0,160.0,150.0,147.0, ...
253.0,253.0,253.0,253.0,253.0,253.0,254.0,254.0,253.0,253.0, ...
244.0,244.0,243.0,242.0,241.0,240.0,239.0,239.0,237.0,236.0, ...
44.0,47.0,47.0,49.0,62.0,116.0,173.0,223.0,232.0,233.0, ...
0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0, ...
1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,0.0, ...
26.0,26.0,27.0,26.0,24.0,24.0,25.0,26.0,27.0,27.0, ...
240.0,240.0,240.0,240.0,240.0,240.0,240.0,240.0,240.0,240.0, ...
0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0, ...

最后一步是为每个图像创建一个 MLlibvector实例。我们将缓存 RDD 以加快后续的计算速度:

import org.apache.spark.mllib.linalg.Vectors 
val vectors = pixels.map(p => Vectors.dense(p)) 
// the setName method create a human-readable name that is 
// displayed in the Spark Web UI 
vectors.setName("image-vectors") 
// remember to cache the vectors to speed up computation 
vectors.cache

我们之前使用setName函数为 RDD 分配了一个名称。在这种情况下,我们称之为image-vectors。这样我们在查看 Spark 网络界面时可以更容易地识别它。

标准化

在运行降维模型之前,特别是对于 PCA,将输入数据标准化是一种常见做法。就像我们在第六章中所做的那样,使用 Spark 构建分类模型,我们将使用 MLlib 的feature包提供的内置StandardScaler来进行这个操作。在这种情况下,我们只会从数据中减去平均值。

import org.apache.spark.mllib.linalg.Matrix 
import org.apache.spark.mllib.linalg.distributed.RowMatrix 
import org.apache.spark.mllib.feature.StandardScaler 
val scaler = new StandardScaler(withMean = true, withStd = false)
  .fit(vectors)

标准缩放器:通过使用训练集中样本的列摘要统计信息,通过去除均值并缩放到单位标准差来标准化特征。

@param``withMean:默认为False。这会在缩放之前使用均值对数据进行居中。它构建了一个密集输出,因此在稀疏输入上不起作用,并引发异常。

@param withStd:默认为True。这会将数据缩放到单位标准差。

class StandardScaler @Since("1.1.0") (withMean: Boolean,
  withStd: Boolean) extends Logging

调用fit会触发对我们的RDD[Vector]的计算。你应该会看到类似于下面显示的输出:

...
14/09/21 11:46:58 INFO SparkContext: Job finished: reduce at  
RDDFunctions.scala:111, took 0.495859 s
scaler: org.apache.spark.mllib.feature.StandardScalerModel =  org.apache.spark.mllib.feature.StandardScalerModel@6bb1a1a1

请注意,减去均值适用于密集输入数据。在图像处理中,我们总是有密集的输入数据,因为每个像素都有一个值。然而,对于稀疏向量,从每个输入中减去均值向量将会将稀疏数据转换为密集数据。对于非常高维的输入,这可能会耗尽可用的内存资源,因此不建议这样做。

最后,我们将使用返回的scaler将原始图像向量转换为减去列均值的向量。

val scaledVectors = vectors.map(v => scaler.transform(v))

我们之前提到,调整大小的灰度图像将占用大约 10MB 的内存。确实,你可以通过在网页浏览器中输入http://localhost:4040/storage/来查看 Spark 应用程序监视器存储页面上的内存使用情况。

由于我们给我们的图像向量 RDD 取了一个友好的名字image-vectors,你应该会看到类似以下的屏幕截图(请注意,由于我们使用的是Vector[Double],每个元素占用 8 个字节而不是 4 个字节;因此,我们实际上使用了 20MB 的内存):

内存中图像向量的大小

训练降维模型

MLlib 中的降维模型需要向量作为输入。然而,与操作RDD[Vector]的聚类不同,PCA 和 SVD 计算是作为分布式RowMatrix的方法提供的(这种差异主要是语法上的,因为RowMatrix只是RDD[Vector]的一个包装器)。

在 LFW 数据集上运行 PCA

现在我们已经将图像像素数据提取到向量中,我们可以实例化一个新的RowMatrix

def computePrincipalComponents(k: Int): 矩阵

计算前k个主成分。行对应于观测值,列对应于变量。主成分存储为大小为 n-by-k的本地矩阵。每列对应一个主成分,列按组件方差的降序排列。行数据不需要首先“居中”;每列的均值为0是不必要的。

请注意,这不能在具有超过65535列的矩阵上计算。

K是前几个主成分的数量。

它返回一个大小为 n-by-k 的矩阵,其列是主成分

注解

@Since( "1.0.0" )

调用computePrincipalComponents方法来计算我们分布式矩阵的前K个主成分:

import org.apache.spark.mllib.linalg.Matrix 
import org.apache.spark.mllib.linalg.distributed.RowMatrix 
val matrix = new RowMatrix(scaledVectors) 
val K = 10 
val pc = matrix.computePrincipalComponents(K)

在模型运行时,你可能会在控制台上看到大量的输出。

如果你看到警告,比如 WARN LAPACK: Failed to load implementation from: com.github.fommil.netlib.NativeSystemLAPACK 或 WARN LAPACK: Failed to load implementation from: com.github.fommil.netlib.NativeRefLAPACK,你可以安全地忽略这些警告。

这意味着 MLlib 使用的基础线性代数库无法加载本机例程。在这种情况下,将使用基于 Java 的回退,速度较慢,但就本例而言,没有什么可担心的。

一旦模型训练完成,您应该在控制台上看到类似以下显示的结果:

pc: org.apache.spark.mllib.linalg.Matrix = 
-0.023183157256614906  -0.010622723054037303  ... (10 total)
-0.023960537953442107  -0.011495966728461177  ...
-0.024397470862198022  -0.013512219690177352  ...
-0.02463158818330343   -0.014758658113862178  ...
-0.024941633606137027  -0.014878858729655142  ...
-0.02525998879466241   -0.014602750644394844  ...
-0.025494722450369593  -0.014678013626511024  ...
-0.02604194423255582   -0.01439561589951032   ...
-0.025942214214865228  -0.013907665261197633  ...
-0.026151551334429365  -0.014707035797934148  ...
-0.026106572186134578  -0.016701471378568943  ...
-0.026242986173995755  -0.016254664123732318  ...
-0.02573628754284022   -0.017185663918352894  ...
-0.02545319635905169   -0.01653357295561698   ...
-0.025325893980995124  -0.0157082218373399...

可视化特征脸

现在我们已经训练好了 PCA 模型,结果是什么?让我们检查一下结果矩阵的维度:

val rows = pc.numRows 
val cols = pc.numCols 
println(rows, cols)

正如您从控制台输出中看到的那样,主成分的矩阵有2500行和10列。

(2500,10)

回想一下,每个图像的维度是 50 x 50,所以这里我们有前 10 个主成分,每个主成分的维度与输入图像相同。这些主成分可以被视为捕获原始数据中最大变化的一组潜在(或隐藏)特征。

在面部识别和图像处理中,这些主成分通常被称为特征脸,因为 PCA 与原始数据的协方差矩阵的特征值分解密切相关。

更多细节请参见en.wikipedia.org/wiki/Eigenface

由于每个主成分的维度与原始图像相同,因此每个成分本身可以被视为图像,并且可以将其表示为图像,从而可以像输入图像一样可视化特征脸。

与本书中经常做的一样,我们将使用 Breeze 线性代数库的功能以及 Python 的 numpy 和 matplotlib 来可视化特征脸。

首先,我们将把 pc 变量(一个 MLlib 矩阵)提取到 Breeze 的DenseMatrix中,如下所示:

import breeze.linalg.DenseMatrix 
val pcBreeze = new DenseMatrix(rows, cols, pc.toArray)

Breeze 在linalg包中提供了一个有用的函数,用于将矩阵写入 CSV 文件。我们将使用这个函数将主成分保存到临时 CSV 文件中。

import breeze.linalg.csvwrite 
csvwrite(new File("/tmp/pc.csv"), pcBreeze)

接下来,我们将在 IPython 中加载矩阵,并将主成分可视化为图像。幸运的是,numpy 提供了一个从我们创建的 CSV 文件中读取矩阵的实用函数。

pcs = np.loadtxt(PATH + "/pc.csv", delimiter=",") 
print(pcs.shape)

您应该看到以下输出,确认我们读取的矩阵与我们保存的矩阵具有相同的维度:

(2500, 10)

我们需要一个实用函数来显示图像,我们在这里定义:

def plot_gallery(images, h, w, n_row=2, n_col=5): 
        """Helper function to plot a gallery of portraits""" 
        plt.figure(figsize=(1.8 * n_col, 2.4 * n_row)) 
        plt.subplots_adjust(bottom=0, left=.01, right=.99, top=.90,
          hspace=.35) 
        for i in range(n_row * n_col): 
            plt.subplot(n_row, n_col, i + 1) 
            plt.imshow(images[:, i].reshape((h, w)),  
                cmap=plt.cm.gray) 
            plt.title("Eigenface %d" % (i + 1), size=12) 
            plt.xticks(()) 
            plt.yticks(()) 

  plt.show()

这个前面的函数是从scikit-learn文档中的 LFW 数据集示例代码中改编的,可在scikit-learn.org/stable/auto_examples/applications/face_recognition.html找到。

现在我们将使用这个函数来绘制前 10 个特征脸,如下所示:

plot_gallery(pcs, 50, 50)

这个最后的命令应该显示以下图表:

前 10 个特征脸

解释特征脸

从前面的图像中,我们可以看到 PCA 模型有效地提取了重复变化模式,这些模式代表了面部图像的各种特征。每个主成分可以像聚类模型一样被解释。与聚类一样,准确解释每个主成分代表的内容并不总是直接的。

从这些图像中,我们可以看到有些图像似乎捕捉到了方向因素(例如图像 6 和 9),有些则聚焦在头发图案上(例如图像 4、5、7 和 10),而其他一些似乎与面部特征如眼睛、鼻子和嘴相关(图像 1、7 和 9)。

使用降维模型

能够以这种方式可视化模型的结果是很有趣的;然而,使用降维的整体目的是创建数据的更紧凑表示,同时仍然捕获原始数据集中的重要特征和变异性。为此,我们需要使用训练好的模型将原始数据投影到由主成分表示的新的低维空间中。

在 LFW 数据集上使用 PCA 投影数据

我们将通过将每个 LFW 图像投影到一个十维向量中来说明这个概念。这是通过图像矩阵与主成分矩阵的矩阵乘法来实现的。由于图像矩阵是一个分布式 MLlibRowMatrix,Spark 会通过multiply函数来为我们分布计算。

val projected = matrix.multiply(pc) 
println(projected.numRows, projected.numCols)

上述函数将给出以下输出:

(1055,10)

注意,每个维度为 2500 的图像已经被转换为大小为 10 的向量。让我们来看一下前几个向量:

println(projected.rows.take(5).mkString("n"))

以下是输出:

[2648.9455749636277,1340.3713412351376,443.67380716760965, -353.0021423043161,52.53102289832631,423.39861446944354, 413.8429065865399,-484.18122999722294,87.98862070273545, -104.62720604921965]
[172.67735747311974,663.9154866829355,261.0575622447282, -711.4857925259682,462.7663154755333,167.3082231097332, -71.44832640530836,624.4911488194524,892.3209964031695, -528.0056327351435]
[-1063.4562028554978,388.3510869550539,1508.2535609357597, 361.2485590837186,282.08588829583596,-554.3804376922453, 604.6680021092125,-224.16600191143075,-228.0771984153961, -110.21539201855907]
[-4690.549692385103,241.83448841252638,-153.58903325799685, -28.26215061165965,521.8908276360171,-442.0430200747375, -490.1602309367725,-456.78026845649435,-78.79837478503592, 70.62925170688868]
[-2766.7960144161225,612.8408888724891,-405.76374113178616, -468.56458995613974,863.1136863614743,-925.0935452709143, 69.24586949009642,-777.3348492244131,504.54033662376435, 257.0263568009851]

由于投影数据是向量形式,我们可以将投影作为另一个机器学习模型的输入。例如,我们可以将这些投影输入与从各种没有人脸的图像生成的输入数据一起使用,来训练一个人脸识别模型。或者,我们可以训练一个多类分类器,其中每个人是一个类,从而创建一个学习识别特定人脸所属的模型。

PCA 和 SVD 之间的关系

我们之前提到 PCA 和 SVD 之间存在着密切的关系。事实上,我们可以恢复相同的主成分,并且也可以使用 SVD 将投影应用到主成分空间中。

在我们的例子中,通过计算 SVD 得到的右奇异向量将等同于我们计算得到的主成分。我们可以通过首先在图像矩阵上计算 SVD,然后将右奇异向量与 PCA 的结果进行比较来验证这一点。与 PCA 一样,SVD 计算作为分布式RowMatrix上的函数提供:

val svd = matrix.computeSVD(10, computeU = true) 
println(s"U dimension: (${svd.U.numRows}, ${svd.U.numCols})") 
println(s"S dimension: (${svd.s.size}, )") 
println(s"V dimension: (${svd.V.numRows}, ${svd.V.numCols})")

我们可以看到 SVD 返回一个维度为 1055 x 10 的矩阵U,一个长度为10的奇异值向量S,以及一个维度为 2500 x 10 的右奇异向量矩阵V

U dimension: (1055, 10)
S dimension: (10, )
V dimension: (2500, 10)

矩阵 V 与 PCA 的结果完全相等(忽略数值的符号和浮点数容差)。我们可以使用下一个实用程序函数来验证这一点,通过大致比较每个矩阵的数据数组来比较它们:

def approxEqual(array1: Array[Double], array2: Array[Double],    
tolerance: Double = 1e-6): Boolean = { 
  // note we ignore sign of the principal component / 
  // singular vector elements 
  val bools = array1.zip(array2).map { case (v1, v2) => if    
    (math.abs(math.abs(v1) - math.abs(v2)) > 1e-6) false else true } 
  bools.fold(true)(_ & _) 
}

我们将在一些测试数据上测试该函数,如下所示:

println(approxEqual(Array(1.0, 2.0, 3.0), Array(1.0, 2.0, 3.0)))

这将给出以下输出:

true

让我们尝试另一组测试数据:

println(approxEqual(Array(1.0, 2.0, 3.0), Array(3.0, 2.0, 1.0)))

这将给出以下输出:

false

最后,我们可以应用我们的相等函数如下:

println(approxEqual(svd.V.toArray, pc.toArray))

以下是输出:

true

PCA 和 SVD 都可以用来计算主成分和相应的特征值/奇异值;计算协方差矩阵的额外步骤可能会导致在计算特征向量时出现数值舍入误差。SVD 总结了数据偏离零的方式,而 PCA 总结了数据偏离平均数据样本的方式。

另一个保持的关系是矩阵U和向量S(或者严格来说,对角矩阵S)的乘积等同于将我们原始图像数据投影到前 10 个主成分空间中的 PCA 投影。

我们现在将展示这确实是这样。我们首先使用 Breeze 将U中的每个向量与S进行逐元素乘法。然后我们将比较 PCA 投影向量中的每个向量与我们 SVD 投影中的等价向量,并统计相等情况的数量,如下所示:

val breezeS = breeze.linalg.DenseVector(svd.s.toArray) 
val projectedSVD = svd.U.rows.map { v =>  
  val breezeV = breeze.linalg.DenseVector(v.toArray) 
  val multV = breezeV :* breezeS 
  Vectors.dense(multV.data) 
} 
projected.rows.zip(projectedSVD).map { case (v1, v2) =>
  approxEqual(v1.toArray, v2.toArray) }.filter(b => true).count

上述代码应该显示一个结果为 1055,这是我们所期望的,确认了 PCA 的每一行投影等于projectedSVD的每一行。

请注意,上述代码中突出显示的*运算符表示向量的逐元素乘法。

评估降维模型

PCA 和 SVD 都是确定性模型。也就是说,给定某个特定的输入数据集,它们总是会产生相同的结果。这与我们迄今为止看到的许多模型形成对比,这些模型依赖于某种随机因素(最常见的是模型权重向量的初始化等)。

这两种模型都保证返回前几个主成分或奇异值,因此唯一的参数是k。与聚类模型一样,增加k总是会提高模型性能(对于聚类来说是相关的错误函数,而对于 PCA 和 SVD 来说是k个成分解释的总变异量)。因此,选择k的值是在尽可能捕捉数据结构的同时保持投影数据的维度低之间的权衡。

评估 LFW 数据集上 SVD 的k

我们将检查通过对图像数据进行 SVD 计算得到的奇异值。我们可以验证每次运行时奇异值是相同的,并且它们以递减顺序返回,如下所示:

val sValues = (1 to 5).map { 
  i => matrix.computeSVD(i,  computeU = false).s 
} 
sValues.foreach(println)

这段代码应该生成类似于以下内容的输出:

[54091.00997110354]
[54091.00997110358,33757.702867982436]
[54091.00997110357,33757.70286798241,24541.193694775946]
[54091.00997110358,33757.70286798242,24541.19369477593, 23309.58418888302]
[54091.00997110358,33757.70286798242,24541.19369477593, 23309.584188882982,21803.09841158358]

奇异值

奇异值让我们理解降维的空间和时间的权衡。

与评估聚类的k值一样,在 SVD(和 PCA)的情况下,通常有必要绘制更大范围的k的奇异值,并查看图表上的点,看看每个额外奇异值所解释的额外方差量在哪个点开始明显变平。

我们将首先计算前 300 个奇异值,如下所示:

val svd300 = matrix.computeSVD(300, computeU = false) 
val sMatrix = new DenseMatrix(1, 300, svd300.s.toArray) 
println(sMatrix) 
csvwrite(new File("/home/ubuntu/work/ml-resources/
  spark-ml/Chapter_09/data/s.csv"), sMatrix)

我们将把奇异值向量 S 写入临时 CSV 文件(就像我们之前对 Eigenfaces 矩阵所做的那样),然后在 IPython 控制台中读取它,绘制每个k的奇异值。

file_name = '/home/ubuntu/work/ml-resources/spark-ml/Chapter_09/data/s.csv' 
data = np.genfromtxt(file_name, delimiter=',')  
plt.plot(data) 
plt.suptitle('Variation 300 Singular Values ') 
plt.xlabel('Singular Value No') 
plt.ylabel('Variation') 
plt.show()

您应该看到类似于这里显示的图像:

前 300 个奇异值

在前 300 个奇异值累积变化中也出现了类似的模式(我们将在y轴上绘制对数刻度)。

plt.plot(cumsum(data)) 
plt.yscale('log') 
plt.suptitle('Cumulative Variation 300 Singular Values ') 
plt.xlabel('Singular Value No') 
plt.ylabel('Cumulative Variation') 
plt.show()

Python 绘图的完整源代码可以在以下链接找到:github.com/ml-resources/spark-ml/tree/branch-ed2/Chapter_09/data/python

前 300 个奇异值的累积和

我们可以看到,在k的某个数值范围之后(在这种情况下大约为 100),图形明显变平。这表明与k值相当的奇异值(或主成分)可能足够解释原始数据的变化。

当然,如果我们正在使用降维来帮助提高另一个模型的性能,我们可以使用与该模型相同的评估方法来帮助我们选择k的值。

例如,我们可以使用 AUC 指标,结合交叉验证,来选择分类模型的模型参数以及降维模型的k值。然而,这会增加计算成本,因为我们需要重新计算完整的模型训练和测试流程。

摘要

在本章中,我们探索了两种新的无监督学习方法,PCA 和 SVD,用于降维。我们看到如何提取特征,并训练这些模型使用面部图像数据。我们可视化了模型的结果,以 Eigenfaces 的形式展现,看到如何将模型应用于将原始数据转换为降维表示,并调查了 PCA 和 SVD 之间的密切联系。

在下一章中,我们将更深入地探讨使用 Spark 进行文本处理和分析的技术。

第十章:使用 Spark 进行高级文本处理

在第四章中,使用 Spark 获取、处理和准备数据,我们涵盖了与特征提取和数据处理相关的各种主题,包括从文本数据中提取特征的基础知识。在本章中,我们将介绍 Spark ML 中可用的更高级的文本处理技术,以处理大规模文本数据集。

在本章中,我们将:

  • 通过详细示例,说明数据处理、特征提取和建模流程,以及它们与文本数据的关系

  • 基于文档中的单词评估两个文档之间的相似性

  • 使用提取的文本特征作为分类模型的输入

  • 介绍自然语言处理的最新发展,将单词本身建模为向量,并演示使用 Spark 的 Word2Vec 模型评估两个单词之间的相似性,基于它们的含义

我们将研究如何使用 Spark 的 MLlib 以及 Spark ML 进行文本处理示例,以及文档的聚类。

文本数据有何特殊之处?

处理文本数据可能会很复杂,主要有两个原因。首先,文本和语言具有固有的结构,不容易使用原始单词来捕捉(例如,含义、上下文、不同类型的单词、句子结构和不同语言等)。因此,天真的特征提取通常相对无效。

其次,文本数据的有效维度非常大,潜在无限。想想仅英语单词的数量,再加上各种特殊单词、字符、俚语等等。然后,再加入其他语言以及互联网上可能找到的各种文本类型。文本数据的维度很容易超过数千万甚至数亿个单词,即使是相对较小的数据集。例如,数十亿个网站的 Common Crawl 数据集包含超过 8400 亿个单词。

为了解决这些问题,我们需要提取更结构化的特征的方法,以及处理文本数据的巨大维度的方法。

从数据中提取正确的特征

自然语言处理NLP)领域涵盖了处理文本的各种技术,从文本处理和特征提取到建模和机器学习。在本章中,我们将重点关注 Spark MLlib 和 Spark ML 中可用的两种特征提取技术:词频-逆文档频率tf-idf)术语加权方案和特征哈希。

通过 tf-idf 的示例,我们还将探讨在特征提取过程中的处理、标记化和过滤如何帮助减少输入数据的维度,以及改善我们提取的特征的信息内容和有用性。

术语加权方案

在第四章中,使用 Spark 获取、处理和准备数据,我们研究了向量表示,其中文本特征被映射到一个简单的二进制向量,称为词袋模型。实践中常用的另一种表示称为词频-逆文档频率。

tf-idf 根据文本(称为文档)中术语的频率对每个术语进行加权。然后,基于该术语在所有文档中的频率(数据集中的文档集通常称为语料库),应用全局归一化,称为逆文档频率。tf-idf 的标准定义如下:

tf-idf(t,d) = tf(t,d) x idf(t)

这里,tf(t,d)是文档d中术语t的频率(出现次数),idf(t)是语料库中术语t的逆文档频率;定义如下:

idf(t) = log(N / d)

在这里,N是文档的总数,d是术语t出现的文档数。

tf-idf 公式意味着在文档中多次出现的术语在向量表示中会获得更高的权重,相对于在文档中出现少次的术语。然而,IDF 归一化会减少在所有文档中非常常见的术语的权重。最终结果是真正罕见或重要的术语应该被分配更高的权重,而更常见的术语(假定具有较低重要性)在权重方面应该影响较小。

关于词袋模型(或向量空间模型)的更多学习资源是《信息检索导论》,作者是克里斯托弗·D·曼宁、普拉巴卡尔·拉加万和亨里希·舒兹,剑桥大学出版社(在nlp.stanford.edu/IR-book/html/htmledition/irbook.html以 HTML 形式提供)。

它包含有关文本处理技术的部分,包括标记化、停用词去除、词干提取和向量空间模型,以及诸如 tf-idf 之类的加权方案。

也可以在en.wikipedia.org/wiki/Tf%E2%80%93idf找到概述。

特征哈希

特征哈希是一种处理高维数据的技术,通常与文本和分类数据集一起使用,其中特征可以具有许多唯一值(通常有数百万个值)。在先前的章节中,我们经常对分类特征(包括文本)使用1-of-K编码方法。虽然这种方法简单而有效,但在面对极高维数据时可能会失效。

构建和使用1-of-K特征编码需要我们保留每个可能特征值到向量中的索引的映射。此外,创建映射本身的过程至少需要对数据集进行一次额外的遍历,并且在并行场景中可能会很棘手。到目前为止,我们经常使用简单的方法来收集不同的特征值,并将此集合与一组索引进行压缩,以创建特征值到索引的映射。然后将此映射广播(无论是在我们的代码中明确地还是由 Spark 隐式地)到每个工作节点。

然而,在处理文本时常见的数千万维甚至更高维的特征时,这种方法可能会很慢,并且可能需要大量的内存和网络资源,无论是在 Spark 主节点(收集唯一值)还是工作节点(广播生成的映射到每个工作节点,以便它可以将特征编码应用于其本地的输入数据)。

特征哈希通过使用哈希函数将特征的值哈希为一个数字(通常是整数值),并基于此值为特征分配向量索引。例如,假设“美国”地理位置的分类特征的哈希值为342。我们将使用哈希值作为向量索引,该索引处的值将为1.0,以表示“美国”特征的存在。所使用的哈希函数必须是一致的(即对于给定的输入,每次返回相同的输出)。

这种编码方式与基于映射的编码方式相同,只是我们需要提前为我们的特征向量选择一个大小。由于大多数常见的哈希函数返回整数范围内的值,我们将使用运算将索引值限制为我们向量的大小,这通常要小得多(根据我们的要求,通常是几万到几百万)。

特征哈希的优点在于我们不需要构建映射并将其保存在内存中。它也很容易实现,非常快速,可以在线和实时完成,因此不需要先通过我们的数据集。最后,因为我们选择了一个明显小于数据集原始维度的特征向量维度,我们限制了模型在训练和生产中的内存使用;因此,内存使用量不随数据的大小和维度而扩展。

然而,存在两个重要的缺点,如下所示:

  • 由于我们不创建特征到索引值的映射,因此也无法进行特征索引到值的反向映射。例如,这使得在我们的模型中确定哪些特征最具信息量变得更加困难。

  • 由于我们限制了特征向量的大小,我们可能会遇到哈希冲突。当两个不同的特征被哈希到特征向量中的相同索引时,就会发生这种情况。令人惊讶的是,只要我们选择一个相对于输入数据维度合理的特征向量维度,这似乎并不会严重影响模型性能。如果哈希向量很大,冲突的影响就很小,但收益仍然很大。有关更多细节,请参阅此论文:www.cs.jhu.edu/~mdredze/publications/mobile_nlp_feature_mixing.pdf

有关哈希的更多信息可以在en.wikipedia.org/wiki/Hash_function找到。

引入哈希用于特征提取和机器学习的关键论文是:

Kilian WeinbergerAnirban DasguptaJohn LangfordAlex SmolaJosh Attenberg大规模多任务学习的特征哈希ICML 2009 年会议论文,网址为alex.smola.org/papers/2009/Weinbergeretal09.pdf

从 20 个新闻组数据集中提取 tf-idf 特征

为了说明本章中的概念,我们将使用一个名为20 Newsgroups的著名文本数据集;这个数据集通常用于文本分类任务。这是一个包含 20 个不同主题的新闻组消息的集合。有各种形式的可用数据。为了我们的目的,我们将使用数据集的bydate版本,该版本可在qwone.com/~jason/20Newsgroups上找到。

该数据集将可用数据分为训练集和测试集,分别占原始数据的 60%和 40%。在这里,测试集中的消息出现在训练集中的消息之后。该数据集还排除了一些标识实际新闻组的消息头;因此,这是一个适合测试分类模型实际性能的数据集。

有关原始数据集的更多信息可以在UCI 机器学习库页面上找到,网址为kdd.ics.uci.edu/databases/20newsgroups/20newsgroups.data.html

要开始,请下载数据并使用以下命令解压文件:

>tar xfvz 20news-bydate.tar.gz

这将创建两个文件夹:一个名为20news-bydate-train,另一个名为20news-bydate-test。让我们来看看训练数据集文件夹下的目录结构:

>cd 20news-bydate-train/ >ls

您将看到它包含许多子文件夹,每个子文件夹对应一个新闻组:

alt.atheism                comp.windows.x          rec.sport.hockey
  soc.religion.christian
comp.graphics              misc.forsale            sci.crypt
  talk.politics.guns comp.os.ms-windows.misc    rec.autos               sci.electronics
  talk.politics.mideast
comp.sys.ibm.pc.hardware   rec.motorcycles         sci.med
  talk.politics.misc
comp.sys.mac.hardware      rec.sport.baseball      sci.space
  talk.religion.misc

在每个新闻组文件夹下有许多文件;每个文件包含一个单独的消息帖子:

> ls rec.sport.hockey
52550 52580 52610 52640 53468 53550 53580 53610 53640 53670 53700 
53731 53761 53791
...

我们可以查看其中一条消息的一部分以查看格式:

> head -20 rec.sport.hockey/52550
From: dchhabra@stpl.ists.ca (Deepak Chhabra)
Subject: Superstars and attendance (was Teemu Selanne, was +/-
  leaders)
Nntp-Posting-Host: stpl.ists.ca
Organization: Solar Terresterial Physics Laboratory, ISTS
Distribution: na
Lines: 115

Dean J. Falcione (posting from jrmst+8@pitt.edu) writes:
[I wrote:]

>>When the Pens got Mario, granted there was big publicity,etc, etc,
>>and interest was immediately generated. Gretzky did the same thing for
>>LA.
>>However, imnsho, neither team would have seen a marked improvement in
>>attendance if the team record did not improve. In the year before Lemieux
>>came, Pittsburgh finished with 38 points. Following his arrival, the Pens
>>finished with 53, 76, 72, 81, 87, 72, 88, and 87 points, with a couple of
 ^^
>>Stanley Cups thrown in.
...

正如我们所看到的,每条消息都包含一些包含发件人、主题和其他元数据的标题字段,然后是消息的原始内容。

探索 20 个新闻组数据

我们将使用一个 Spark 程序来加载和分析数据集。

object TFIDFExtraction { 

  def main(args: Array[String]) { 

 } 
}

查看目录结构时,您可能会认出,我们再次有数据包含在单独的文本文件中(每个消息一个文本文件)。因此,我们将再次使用 Spark 的wholeTextFiles方法将每个文件的内容读入 RDD 中的记录。

在接下来的代码中,PATH指的是您提取20news-bydate ZIP 文件的目录:

val sc = new SparkContext("local[2]", "First Spark App") 

val path = "../data/20news-bydate-train/*" 
val rdd = sc.wholeTextFiles(path) 
// count the number of records in the dataset 
println(rdd.count)

如果您设置断点,您将看到以下行显示,指示 Spark 检测到的文件总数:

...
INFO FileInputFormat: Total input paths to process : 11314
...

命令运行完毕后,您将看到总记录数,应该与前面的要处理的总输入路径屏幕输出相同:

11314

现在让我们打印rdd的第一个元素,其中已加载数据:

16/12/30 20:42:02 INFO DAGScheduler: Job 1 finished: first at 
TFIDFExtraction.scala:27, took 0.067414 s
(file:/home/ubuntu/work/ml-resources/spark- 
ml/Chapter_10/data/20news- bydate-train/alt.atheism/53186,From:  
ednclark@kraken.itc.gu.edu.au (Jeffrey Clark)
Subject: Re: some thoughts.
Keywords: Dan Bissell
Nntp-Posting-Host: kraken.itc.gu.edu.au
Organization: ITC, Griffith University, Brisbane, Australia
Lines: 70
....

接下来,我们将查看可用的新闻组主题:

val newsgroups = rdd.map { case (file, text) => 
  file.split("/").takeRight(2).head } 
println(newsgroups.first()) 
val countByGroup = newsgroups.map(n => (n, 1)).reduceByKey(_ +
  _).collect.sortBy(-_._2).mkString("n") 
println(countByGroup)

这将显示以下结果:

(rec.sport.hockey,600)
(soc.religion.christian,599)
(rec.motorcycles,598)
(rec.sport.baseball,597)
(sci.crypt,595)
(rec.autos,594)
(sci.med,594)
(comp.windows.x,593)
(sci.space,593)
(sci.electronics,591)
(comp.os.ms-windows.misc,591)
(comp.sys.ibm.pc.hardware,590)
(misc.forsale,585)
(comp.graphics,584)
(comp.sys.mac.hardware,578)
(talk.politics.mideast,564)
(talk.politics.guns,546)
(alt.atheism,480)
(talk.politics.misc,465)
(talk.religion.misc,377)

我们可以看到消息数量在主题之间大致相等。

应用基本标记化

我们文本处理管道的第一步是将每个文档中的原始文本内容拆分为一组术语(也称为标记)。这就是标记化。我们将首先应用简单的空格标记化,同时将每个文档的每个标记转换为小写:

val text = rdd.map { case (file, text) => text } 
val whiteSpaceSplit = text.flatMap(t => t.split(" 
  ").map(_.toLowerCase)) 
println(whiteSpaceSplit.distinct.count)

在前面的代码中,我们使用了flatMap函数而不是map,因为现在我们想要一起检查所有标记以进行探索性分析。在本章的后面,我们将在每个文档的基础上应用我们的标记方案,因此我们将使用map函数。

运行此代码片段后,您将看到应用我们的标记化后的唯一标记总数:

402978

如您所见,即使对于相对较少的文本,原始标记的数量(因此,我们的特征向量的维度)也可能非常高。

让我们看一下随机选择的文档。我们将使用 RDD 的 sample 方法:

def sample( 
      withReplacement: Boolean, 
      fraction: Double, 
      seed: Long = Utils.random.nextLong): RDD[T] 

Return a sampled subset of this RDD. 
@param withReplacement can elements be sampled multiple times    
  (replaced when sampled out) 
@param fraction expected size of the sample as a fraction of this   
  RDD's size without replacement: probability that each element is    
  chosen; fraction must be [0, 1] with replacement: expected number   
  of times each element is chosen; fraction must be >= 0 
@param seed seed for the random number generator 

      println(nonWordSplit.distinct.sample( 
      true, 0.3, 42).take(100).mkString(","))

请注意,我们将sample函数的第三个参数设置为随机种子。我们将此函数设置为42,以便每次调用sample时都获得相同的结果,以便您的结果与本章中的结果相匹配。

这将显示以下结果:

atheist,resources
summary:,addresses,,to,atheism
keywords:,music,,thu,,11:57:19,11:57:19,gmt
distribution:,cambridge.,290

archive-name:,atheism/resources
alt-atheism-archive-  
name:,december,,,,,,,,,,,,,,,,,,,,,,addresses,addresses,,,,,,,
religion,to:,to:,,p.o.,53701.
telephone:,sell,the,,fish,on,their,cars,,with,and,written

inside.,3d,plastic,plastic,,evolution,evolution,7119,,,,,san,san,
san,mailing,net,who,to,atheist,press

aap,various,bible,,and,on.,,,one,book,is:

"the,w.p.,american,pp.,,1986.,bible,contains,ball,,based,based,
james,of

改进我们的标记化

前面的简单方法会产生大量的标记,并且不会过滤掉许多非单词字符(如标点符号)。大多数标记方案都会去除这些字符。我们可以通过使用正则表达式模式在非单词字符上拆分每个原始文档来实现这一点:

val nonWordSplit = text.flatMap(t => 
  t.split("""W+""").map(_.toLowerCase)) 
println(nonWordSplit.distinct.count)

这显著减少了唯一标记的数量:

130126

如果我们检查前几个标记,我们会发现我们已经消除了文本中大部分不太有用的字符:

println( 
nonWordSplit.distinct.sample(true, 0.3, 
  50).take(100).mkString(","))

您将看到以下结果显示:

jejones,ml5,w1w3s1,k29p,nothin,42b,beleive,robin,believiing,749,
steaminess,tohc4,fzbv1u,ao,
instantaneous,nonmeasurable,3465,tiems,tiems,tiems,eur,3050,pgva4,
animating,10011100b,413,randall_clark,
mswin,cannibal,cannibal,congresswoman,congresswoman,theoreticians,
34ij,logically,kxo,contoler,
contoler,13963,13963,ets,sask,sask,sask,uninjured,930420,pws,vfj,
jesuit,kocharian,6192,1tbs,octopi,
012537,012537,yc0,dmitriev,icbz,cj1v,bowdoin,computational,
jkis_ltd,
caramate,cfsmo,springer,springer,
005117,shutdown,makewindow,nowadays,mtearle,discernible,
discernible,qnh1,hindenburg,hindenburg,umaxc,
njn2e5,njn2e5,njn2e5,x4_i,x4_i,monger,rjs002c,rjs002c,rjs002c,
warms,ndallen,g45,herod,6w8rg,mqh0,suspects,
floor,flq1r,io21087,phoniest,funded,ncmh,c4uzus

虽然我们用于拆分文本的非单词模式效果相当不错,但我们仍然留下了数字和包含数字字符的标记。在某些情况下,数字可能是语料库的重要部分。对于我们的目的,管道中的下一步将是过滤掉数字和包含数字的标记。

我们可以通过应用另一个正则表达式模式来实现这一点,并使用它来过滤不匹配模式的标记,val regex = """[⁰-9]*""".r

val regex = """[⁰-9]*""".r 
val filterNumbers = nonWordSplit.filter(token => 
  regex.pattern.matcher(token).matches) 
println(filterNumbers.distinct.count)

这进一步减少了标记集的大小:

84912

println(filterNumbers.distinct.sample(true, 0.3,      
50).take(100).mkString(","))

让我们再看一下过滤后的标记的另一个随机样本。

您将看到以下输出:

jejones,silikian,reunion,schwabam,nothin,singen,husky,tenex,
eventuality,beleive,goofed,robin,upsets,aces,nondiscriminatory,
underscored,bxl,believiing,believiing,believiing,historians,
nauseam,kielbasa,collins,noport,wargame,isv,bellevue,seetex,seetex,
negotiable,negotiable,viewed,rolled,unforeseen,dlr,museum,museum,
wakaluk,wakaluk,dcbq,beekeeper,beekeeper,beekeeper,wales,mop,win,
ja_jp,relatifs,dolphin,strut,worshippers,wertheimer,jaze,jaze,
logically,kxo,nonnemacher,sunprops,sask,bbzx,jesuit,logos,aichi,
remailing,remailing,winsor,dtn,astonished,butterfield,miserable,
icbz,icbz,poking,sml,sml,makeing,deterministic,deterministic,
deterministic,rockefeller,rockefeller,explorers,bombardments,
bombardments,bombardments,ray_bourque,hour,cfsmo,mishandles,
scramblers,alchoholic,shutdown,almanac_,bruncati,karmann,hfd,
makewindow,perptration,mtearle

我们可以看到我们已经删除了所有数字字符。这仍然给我们留下了一些奇怪的单词,但我们在这里不会太担心这些。

删除停用词

停用词是指在语料库中(以及大多数语料库中)几乎所有文档中都出现多次的常见词。典型的英语停用词包括 and、but、the、of 等。在文本特征提取中,通常会排除停用词。

在使用 tf-idf 加权时,加权方案实际上会为我们处理这个问题。由于停用词的 idf 得分非常低,它们往往具有非常低的 tf-idf 权重,因此重要性较低。然而,在某些情况下,对于信息检索和搜索任务,可能希望包括停用词。然而,在特征提取过程中排除停用词仍然是有益的,因为它减少了最终特征向量的维度以及训练数据的大小。

我们可以查看我们语料库中出现次数最多的一些标记,以了解其他需要排除的停用词:

val tokenCounts = filterNumbers.map(t => (t, 1)).reduceByKey(_ + 
  _) 
val oreringDesc = Ordering.by(String, Int), Int 
println(tokenCounts.top(20)(oreringDesc).mkString("n"))

在上述代码中,我们在过滤掉数字字符后获取了标记,并生成了每个标记在整个语料库中出现次数的计数。现在我们可以使用 Spark 的 top 函数来检索按计数排名的前 20 个标记。请注意,我们需要为 top 函数提供一个排序方式,告诉 Spark 如何对我们的 RDD 元素进行排序。在这种情况下,我们希望按计数排序,因此我们将指定键值对的第二个元素。

运行上述代码片段将产生以下前几个标记:

(the,146532)
(to,75064)
(of,69034)
(a,64195)
(ax,62406)
(and,57957)
(i,53036)
(in,49402)
(is,43480)
(that,39264)
(it,33638)
(for,28600)
(you,26682)
(from,22670)
(s,22337)
(edu,21321)
(on,20493)
(this,20121)
(be,19285)
(t,18728)

正如我们所预期的,这个列表中有很多常见词,我们可能会将其标记为停用词。让我们创建一个包含其中一些常见词的停用词集

以及其他常见词。然后我们将在过滤掉这些停用词后查看标记:

val stopwords = Set( 
  "the","a","an","of","or","in","for","by","on","but", "is", 
  "not", "with", "as", "was", "if", 
  "they", "are", "this", "and", "it", "have", "from", "at", "my",  
  "be", "that", "to" 
val tokenCountsFilteredStopwords = tokenCounts.filter {  
  case (k, v) => !stopwords.contains(k)  
  } 

println(tokenCountsFilteredStopwords.top(20)   
  (oreringDesc).mkString("n"))

您将看到以下输出:

(ax,62406)
(i,53036)
(you,26682)
(s,22337)
(edu,21321)
(t,18728)
(m,12756)
(subject,12264)
(com,12133)
(lines,11835)
(can,11355)
(organization,11233)
(re,10534)
(what,9861)
(there,9689)
(x,9332)
(all,9310)
(will,9279)
(we,9227)
(one,9008)

您可能会注意到在这个前面的列表中仍然有相当多的常见词。在实践中,我们可能会有一个更大的停用词集。然而,我们将保留一些(部分是为了稍后使用 tf-idf 加权时常见词的影响)。

您可以在这里找到常见停用词列表:xpo6.com/list-of-english-stop-words/

我们将使用的另一个过滤步骤是删除长度为一个字符的任何标记。这背后的原因类似于删除停用词-这些单字符标记不太可能在我们的文本模型中提供信息,并且可以进一步减少特征维度和模型大小。我们将通过另一个过滤步骤来实现这一点:

val tokenCountsFilteredSize =  
  tokenCountsFilteredStopwords.filter {  
    case (k, v) => k.size >= 2  
  } 
println(tokenCountsFilteredSize.top(20)  
  (oreringDesc).mkString("n"))

同样,我们将在此过滤步骤之后检查剩下的标记:

(ax,62406)
(you,26682)
(edu,21321)
(subject,12264)
(com,12133)
(lines,11835)
(can,11355)
(organization,11233)
(re,10534)
(what,9861)
(there,9689)
(all,9310)
(will,9279)
(we,9227)
(one,9008)
(would,8905)
(do,8674)
(he,8441)
(about,8336)
(writes,7844)

除了我们没有排除的一些常见词之外,我们看到一些潜在更具信息量的词开始出现。

根据频率排除术语

在标记化过程中,通常会排除语料库中整体出现非常少的术语。例如,让我们来检查语料库中出现次数最少的术语(注意我们在这里使用不同的排序方式来返回按升序排序的结果):

val oreringAsc = Ordering.by(String, Int), Int 
println(tokenCountsFilteredSize.top(20)(oreringAsc)
  .mkString("n"))

您将得到以下结果:

(lennips,1)
(bluffing,1)
(preload,1)
(altina,1)
(dan_jacobson,1)
(vno,1)
(actu,1)
(donnalyn,1)
(ydag,1)
(mirosoft,1)
(xiconfiywindow,1)
(harger,1)
(feh,1)
(bankruptcies,1)
(uncompression,1)
(d_nibby,1)
(bunuel,1)
(odf,1)
(swith,1)
(lantastic,1)

正如我们所看到的,有许多术语在整个语料库中只出现一次。通常情况下,我们希望将我们提取的特征用于其他任务,如文档相似性或机器学习模型,只出现一次的标记对于学习来说是没有用的,因为相对于这些标记,我们将没有足够的训练数据。我们可以应用另一个过滤器来排除这些罕见的标记:

val rareTokens = tokenCounts.filter{ case (k, v) => v < 2 }.map {  
  case (k, v) => k }.collect.toSet 
val tokenCountsFilteredAll = tokenCountsFilteredSize.filter {    
  case (k, v) => !rareTokens.contains(k) } 
println(tokenCountsFilteredAll.top(20)    
  (oreringAsc).mkString("n"))

我们可以看到,我们剩下的标记至少在语料库中出现了两次:

(sina,2)
(akachhy,2)
(mvd,2)
(hizbolah,2)
(wendel_clark,2)
(sarkis,2)
(purposeful,2)
(feagans,2)
(wout,2)
(uneven,2)
(senna,2)
(multimeters,2)
(bushy,2)
(subdivided,2)
(coretest,2)
(oww,2)
(historicity,2)
(mmg,2)
(margitan,2)
(defiance,2)

现在,让我们统计一下唯一标记的数量:

println(tokenCountsFilteredAll.count)

您将看到以下输出:

51801

正如我们所看到的,通过在我们的标记化流程中应用所有过滤步骤,我们已将特征维度从402,978减少到51,801

现在,我们可以将所有过滤逻辑组合成一个函数,然后将其应用到我们 RDD 中的每个文档:

def tokenize(line: String): Seq[String] = { 
  line.split("""W+""") 
    .map(_.toLowerCase) 
    .filter(token => regex.pattern.matcher(token).matches) 
    .filterNot(token => stopwords.contains(token)) 
    .filterNot(token => rareTokens.contains(token)) 
    .filter(token => token.size >= 2) 
    .toSeq 
}

我们可以检查这个函数是否给我们相同的结果,使用以下代码片段:

println(text.flatMap(doc => tokenize(doc)).distinct.count)

这将输出51801,给我们与逐步流程相同的唯一标记计数。

我们可以按如下方式对 RDD 中的每个文档进行标记化:

val tokens = text.map(doc => tokenize(doc)) 
println(tokens.first.take(20))

您将看到类似以下的输出,显示我们第一个文档的标记化版本的前部分:

WrappedArray(mathew, mantis, co, uk, subject, alt, atheism, 
faq, atheist, resources, summary, books, addresses, music,         
anything, related, atheism, keywords, faq)

关于词干的一点说明

文本处理和标记化中的一个常见步骤是词干提取。这是将整个单词转换为基本形式(称为词干)的过程。例如,复数可能会转换为单数(dogs变成dog),而walkingwalker这样的形式可能会变成walk。词干提取可能会变得非常复杂,通常需要专门的 NLP 或搜索引擎软件(例如 NLTK、OpenNLP 和 Lucene 等)来处理。在这个例子中,我们将忽略词干提取。

对词干提取的全面处理超出了本书的范围。您可以在en.wikipedia.org/wiki/Stemming找到更多细节。

特征哈希

首先,我们解释什么是特征哈希,以便更容易理解下一节中的 tf-idf 模型。

特征哈希将字符串或单词转换为固定长度的向量,这样可以更容易地处理文本。

Spark 目前使用 Austin Appleby 的 MurmurHash 3 算法(MurmurHash3_x86_32)将文本哈希为数字。

您可以在这里找到实现

private[spark] def murmur3Hash(term: Any): Int = {
  term match {
  case null => seed
  case b: Boolean => hashInt(if (b) 1 else 0, seed)
  case b: Byte => hashInt(b, seed)
  case s: Short => hashInt(s, seed)
  case i: Int => hashInt(i, seed)
  case l: Long => hashLong(l, seed)
  case f: Float => hashInt(java.lang.Float
    .floatToIntBits(f), seed)
  case d: Double => hashLong(java.lang.Double.
    doubleToLongBits(d), seed)
  case s: String => val utf8 = UTF8String.fromString(s)
    hashUnsafeBytes(utf8.getBaseObject, utf8.getBaseOffset, 
    utf8.numBytes(), seed)
  case _ => throw new SparkException( 
  "HashingTF with murmur3 algorithm does not " +
    s"support type ${term.getClass.getCanonicalName} of input  
  data.")
  }
}

请注意,函数hashInthasLong等是从Util.scala中调用的

构建 tf-idf 模型

现在,我们将使用 Spark ML 将每个文档(以处理后的标记形式)转换为向量表示。第一步将是使用HashingTF实现,它利用特征哈希将输入文本中的每个标记映射到术语频率向量中的索引。然后,我们将计算全局 IDF,并使用它将术语频率向量转换为 tf-idf 向量。

对于每个标记,索引将是标记的哈希值(依次映射到特征向量的维度)。每个标记的值将是该标记的 tf-idf 加权值(即,术语频率乘以逆文档频率)。

首先,我们将导入我们需要的类并创建我们的HashingTF实例,传入一个dim维度参数。虽然默认的特征维度是 2²⁰(大约 100 万),我们将选择 2¹⁸(大约 26 万),因为大约有 5 万个标记,我们不应该遇到显著数量的哈希碰撞,而较小的维度对于说明目的来说更加节省内存和处理资源:

import org.apache.spark.mllib.linalg.{ SparseVector => SV } 
import org.apache.spark.mllib.feature.HashingTF 
import org.apache.spark.mllib.feature.IDF 
val dim = math.pow(2, 18).toInt 
val hashingTF = new HashingTF(dim) 
val tf = hashingTF.transform(tokens) 
tf.cache

请注意,我们使用SV的别名导入了 MLlib 的SparseVector。这是因为稍后,我们将使用 Breeze 的linalg模块,它本身也导入SparseVector。这样,我们将避免命名空间冲突。

HashingTFtransform函数将每个输入文档(即标记序列)映射到 MLlib 的Vector。我们还将调用cache将数据固定在内存中,以加速后续操作。

让我们检查转换后数据集的第一个元素:

请注意,HashingTF.transform返回一个RDD[Vector],因此我们将返回的结果转换为 MLlibSparseVector的实例。

transform方法也可以通过接受一个Iterable参数(例如,作为Seq[String]的文档)来处理单个文档。这将返回一个单一的向量。

val v = tf.first.asInstanceOf[SV] 
println(v.size) 
println(v.values.size) 
println(v.values.take(10).toSeq) 
println(v.indices.take(10).toSeq)

您将看到以下输出显示:

262144
706
WrappedArray(1.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 1.0, 1.0)
WrappedArray(313, 713, 871, 1202, 1203, 1209, 1795, 1862, 3115,     
3166)

我们可以看到每个稀疏向量的特征频率的维度为 262,144(或者我们指定的 2¹⁸)。然而,向量中非零条目的数量只有 706。输出的最后两行显示了向量中前几个条目的频率计数和索引。

现在,我们将通过创建一个新的IDF实例并调用fit方法来计算语料库中每个术语的逆文档频率。然后,我们将通过IDFtransform函数将我们的术语频率向量转换为 tf-idf 向量:

val idf = new IDF().fit(tf) 
val tfidf = idf.transform(tf) 
val v2 = tfidf.first.asInstanceOf[SV] 
println(v2.values.size) 
println(v2.values.take(10).toSeq) 
println(v2.indices.take(10).toSeq)

当您检查 tf-idf 转换后向量的 RDD 中的第一个元素时,您将看到类似于这里显示的输出:

706
WrappedArray(2.3869085659322193, 4.670445463955571, 
6.561295835827856, 4.597686109673142,  ...
WrappedArray(313, 713, 871, 1202, 1203, 1209, 1795, 1862, 3115,     
3166)

我们可以看到非零条目的数量没有改变(为706),术语的向量索引也没有改变。改变的是每个术语的值。早些时候,这些值代表了文档中每个术语的频率,但现在,新值代表了由 IDF 加权的频率。

当我们执行以下两行时,IDF 加权就出现了

val idf = new IDF().fit(tf) 
val tfidf = idf.transform(tf)

分析 tf-idf 加权

接下来,让我们调查一些术语的 tf-idf 加权,以说明术语的普遍性或稀有性的影响。

首先,我们可以计算整个语料库中的最小和最大 tf-idf 权重:

val minMaxVals = tfidf.map { v => 
  val sv = v.asInstanceOf[SV] 
  (sv.values.min, sv.values.max) 
} 
val globalMinMax = minMaxVals.reduce { case ((min1, max1), 
  (min2, max2)) => 
  (math.min(min1, min2), math.max(max1, max2)) 
} 
println(globalMinMax)

正如我们所看到的,最小的 tf-idf 是零,而最大的 tf-idf 显着更大:

(0.0,66155.39470409753)

我们现在将探讨附加到各种术语的 tf-idf 权重。在停用词的上一节中,我们过滤掉了许多经常出现的常见术语。请记住,我们没有删除所有这些潜在的停用词。相反,我们在语料库中保留了一些术语,以便我们可以说明应用 tf-idf 加权方案对这些术语的影响。

Tf-idf 加权将倾向于为常见术语分配较低的权重。为了证明这一点,我们可以计算我们先前计算的顶部出现列表中的一些术语的 tf-idf 表示,例如youdowe

val common = sc.parallelize(Seq(Seq("you", "do", "we"))) 
val tfCommon = hashingTF.transform(common) 
val tfidfCommon = idf.transform(tfCommon) 
val commonVector = tfidfCommon.first.asInstanceOf[SV] 
println(commonVector.values.toSeq)

如果我们形成这篇文章的 tf-idf 向量表示,我们会看到每个术语分配的以下值。请注意,由于特征散列,我们不确定哪个术语代表什么。但是,这些值说明了对这些术语应用的加权相对较低:

WrappedArray(0.9965359935704624, 1.3348773448236835, 
0.5457486182039175)

现在,让我们将相同的转换应用于一些我们可能直观地认为与特定主题或概念更相关的不太常见的术语:

val uncommon = sc.parallelize(Seq(Seq("telescope", 
  "legislation", "investment"))) 
val tfUncommon = hashingTF.transform(uncommon) 
val tfidfUncommon = idf.transform(tfUncommon) 
val uncommonVector = tfidfUncommon.first.asInstanceOf[SV] 
println(uncommonVector.values.toSeq)

从以下结果中我们可以看到,tf-idf 加权确实比更常见的术语要高得多:

WrappedArray(5.3265513728351666, 5.308532867332488, 
5.483736956357579)

使用 tf-idf 模型

尽管我们经常提到训练 tf-idf 模型,但实际上它是一个特征提取过程或转换,而不是一个机器学习模型。Tf-idf 加权通常用作其他模型的预处理步骤,例如降维、分类或回归。

为了说明 tf-idf 加权的潜在用途,我们将探讨两个例子。第一个是使用 tf-idf 向量计算文档相似性,而第二个涉及使用 tf-idf 向量作为输入特征训练多标签分类模型。

20 个新闻组数据集和 tf-idf 特征的文档相似性

您可能还记得第五章中的使用 Spark 构建推荐引擎,两个向量之间的相似度可以使用距离度量来计算。两个向量越接近(即距离度量越小),它们就越相似。我们用于计算电影之间相似度的一种度量是余弦相似度。

就像我们为电影所做的那样,我们也可以计算两个文档之间的相似性。使用 tf-idf,我们已将每个文档转换为向量表示。因此,我们可以使用与我们用于比较两个文档的电影向量相同的技术。

直觉上,如果两个文档共享许多术语,我们可能期望这两个文档彼此更相似。相反,如果它们各自包含许多彼此不同的术语,我们可能期望这两个文档更不相似。由于我们通过计算两个向量的点积来计算余弦相似度,而每个向量由每个文档中的术语组成,我们可以看到具有高重叠术语的文档将倾向于具有更高的余弦相似度。

现在,我们可以看到 tf-idf 在起作用。我们可能合理地期望,即使非常不同的文档也可能包含许多重叠的相对常见的术语(例如,我们的停用词)。然而,由于 tf-idf 加权较低,这些术语对点积的影响不大,因此对计算的相似度也没有太大影响。

例如,我们可能期望从冰球新闻组中随机选择的两条消息之间相对相似。让我们看看是否是这种情况:

val hockeyText = rdd.filter { case (file, text) => 
  file.contains("hockey") } 
val hockeyTF = hockeyText.mapValues(doc => 
  hashingTF.transform(tokenize(doc))) 
val hockeyTfIdf = idf.transform(hockeyTF.map(_._2))

在前面的代码中,我们首先过滤了原始输入 RDD,只保留了冰球主题内的消息。然后应用了我们的标记化和词项频率转换函数。请注意,使用的transform方法是适用于单个文档(以Seq[String]形式)的版本,而不是适用于 RDD 文档的版本。

最后,我们应用了IDF转换(请注意,我们使用的是已经在整个语料库上计算过的相同 IDF)。

一旦我们有了我们的冰球文档向量,我们可以随机选择其中的两个向量,并计算它们之间的余弦相似度(就像之前一样,我们将使用 Breeze 进行线性代数功能,特别是首先将我们的 MLlib 向量转换为 BreezeSparseVector实例):

import breeze.linalg._ 
val hockey1 = hockeyTfIdf.sample( 
  true, 0.1, 42).first.asInstanceOf[SV] 
val breeze1 = new SparseVector(hockey1.indices,
  hockey1.values, hockey1.size) 
val hockey2 = hockeyTfIdf.sample(true, 0.1, 
  43).first.asInstanceOf[SV] 
val breeze2 = new SparseVector(hockey2.indices,
  hockey2.values, hockey2.size) 
val cosineSim = breeze1.dot(breeze2) / 
  (norm(breeze1) * norm(breeze2)) 
println(cosineSim)

我们可以看到文档之间的余弦相似度大约为 0.06:

0.06700095047242809

虽然这可能看起来相当低,但要记住,由于处理文本数据时通常会出现大量唯一术语,因此我们特征的有效维度很高。因此,我们可以期望,即使两个文档是关于相同主题的,它们之间的术语重叠也可能相对较低,因此绝对相似度得分也会较低。

相比之下,我们可以将此相似度得分与使用相同方法在计算机图形新闻组中随机选择的另一个文档与我们的冰球文档之间计算的相似度进行比较:

val graphicsText = rdd.filter { case (file, text) => 
  file.contains("comp.graphics") } 
val graphicsTF = graphicsText.mapValues(doc => 
  hashingTF.transform(tokenize(doc))) 
val graphicsTfIdf = idf.transform(graphicsTF.map(_._2)) 
val graphics = graphicsTfIdf.sample(true, 0.1, 
  42).first.asInstanceOf[SV] 
val breezeGraphics = new SparseVector(graphics.indices, 
  graphics.values, graphics.size) 
val cosineSim2 = breeze1.dot(breezeGraphics) / (norm(breeze1) * 
  norm(breezeGraphics)) 
println(cosineSim2)

余弦相似度显著较低,为0.0047

0.001950124251275256

最后,很可能来自另一个与体育相关的主题的文档与我们的冰球文档更相似,而不像来自与计算机相关的主题的文档。但是,我们可能预期棒球文档与我们的冰球文档不太相似。让我们通过计算棒球新闻组中的随机消息与我们的冰球文档之间的相似度来看看是否如此:

// compare to sport.baseball topic 
val baseballText = rdd.filter { case (file, text) => 
  file.contains("baseball") } 
val baseballTF = baseballText.mapValues(doc => 
  hashingTF.transform(tokenize(doc))) 
val baseballTfIdf = idf.transform(baseballTF.map(_._2)) 
val baseball = baseballTfIdf.sample(true, 0.1, 
  42).first.asInstanceOf[SV] 
val breezeBaseball = new SparseVector(baseball.indices, 
  baseball.values, baseball.size) 
val cosineSim3 = breeze1.dot(breezeBaseball) / (norm(breeze1) * 
   norm(breezeBaseball)) 
println(cosineSim3)

事实上,正如我们预期的那样,我们发现棒球冰球文档的余弦相似度为0.05,这显著高于计算机图形文档,但也略低于另一个冰球文档:

0.05047395039466008

源代码:

github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_10/scala-2.0.x/src/main/scala/TFIDFExtraction.scala

使用 tf-idf 在 20 个新闻组数据集上训练文本分类器

使用 tf-idf 向量时,我们预期余弦相似度度量将捕捉文档之间的相似性,基于它们之间的术语重叠。类似地,我们预期机器学习模型,如分类器,将能够学习每个术语的加权;这将使其能够区分不同类别的文档。也就是说,应该可以学习到存在(和加权)某些术语与特定主题之间的映射。

在 20 个新闻组的例子中,每个新闻组主题都是一个类别,我们可以使用我们的 tf-idf 转换后的向量来训练分类器。

由于我们正在处理一个多类分类问题,我们将在 MLlib 中使用朴素贝叶斯模型,该模型支持多个类别。作为第一步,我们将导入我们将使用的 Spark 类:

import org.apache.spark.mllib.regression.LabeledPoint 
import org.apache.spark.mllib.classification.NaiveBayes 
import org.apache.spark.mllib.evaluation.MulticlassMetrics.

我们将保留我们的聚类代码在一个名为文档聚类的对象中

object DocumentClassification { 

  def main(args: Array[String]) { 
    val sc = new SparkContext("local[2]", "") 
    ... 
}

接下来,我们需要提取 20 个主题并将它们转换为类映射。我们可以像对1-of-K特征编码一样做,为每个类分配一个数字索引:

val newsgroupsMap = 
  newsgroups.distinct.collect().zipWithIndex.toMap 
val zipped = newsgroups.zip(tfidf) 
val train = zipped.map { case (topic, vector) => 
  LabeledPoint(newsgroupsMap(topic), vector) } 
train.cache

在前面的代码片段中,我们取了newsgroups RDD,其中每个元素都是主题,并使用zip函数将其与我们的 tf-idf 向量 RDD 中的每个元素组合在一起。然后,我们在我们的新压缩 RDD 中的每个键值元素上进行映射,并创建一个LabeledPoint实例,其中label是类索引,features是 tf-idf 向量。

请注意,zip操作符假定每个 RDD 具有相同数量的分区以及每个分区中相同数量的元素。如果不是这种情况,它将失败。我们可以做出这种假设,因为我们实际上已经通过对相同原始 RDD 进行一系列map转换来创建了tfidf RDD 和newsgroups RDD,并保留了分区结构。

现在我们有了正确形式的输入 RDD,我们可以简单地将其传递给朴素贝叶斯的train函数:

val model = NaiveBayes.train(train, lambda = 0.1)

让我们评估模型在测试数据集上的性能。我们将从20news-bydate-test目录加载原始测试数据,再次使用wholeTextFiles将每条消息读入 RDD 元素。然后,我们将从文件路径中提取类标签,方式与我们对newsgroups RDD 所做的方式相同。

val testPath = "/PATH/20news-bydate-test/*" 
val testRDD = sc.wholeTextFiles(testPath) 
val testLabels = testRDD.map { case (file, text) => 
  val topic = file.split("/").takeRight(2).head 
  newsgroupsMap(topic) 
}

对测试数据集中的文本进行转换的过程与训练数据相同-我们将应用我们的tokenize函数,然后进行词项频率转换,然后再次使用从训练数据中计算的相同 IDF 来将 TF 向量转换为 tf-idf 向量。最后,我们将测试类标签与 tf-idf 向量进行压缩,并创建我们的测试RDD[LabeledPoint]

val testTf = testRDD.map { case (file, text) => 
  hashingTF.transform(tokenize(text)) } 
val testTfIdf = idf.transform(testTf) 
val zippedTest = testLabels.zip(testTfIdf) 
val test = zippedTest.map { case (topic, vector) => 
  LabeledPoint(topic, vector) }

请注意,重要的是我们使用训练集的 IDF 来转换测试数据,因为这样可以更真实地估计模型在新数据上的性能,新数据可能包含模型尚未训练过的术语。如果基于测试数据集重新计算 IDF 向量,这将是“作弊”,更重要的是,可能会导致通过交叉验证选择的最佳模型参数的不正确估计。

现在,我们准备计算模型的预测和真实类标签。我们将使用此 RDD 来计算模型的准确性和多类加权 F-度量:

val predictionAndLabel = test.map(p =>       
  (model.predict(p.features),   p.label)) 
val accuracy = 1.0 * predictionAndLabel.filter
  (x => x._1 == x._2).count() / test.count() 
val metrics = new MulticlassMetrics(predictionAndLabel) 
println(accuracy) 
println(metrics.weightedFMeasure)

加权 F-度量是精确度和召回率性能的综合度量(类似于 ROC 曲线下面积,值越接近 1.0 表示性能越好),然后通过在类别之间进行加权平均来组合。

我们可以看到,我们简单的多类朴素贝叶斯模型的准确性和 F-度量都接近 80%:

0.7928836962294211
0.7822644376431702

评估文本处理的影响

文本处理和 tf-idf 加权是旨在减少原始文本数据的维度并提取一些结构的特征提取技术的例子。通过比较在原始文本数据上训练的模型与在处理和 tf-idf 加权文本数据上训练的模型的性能,我们可以看到应用这些处理技术的影响。

比较 20 个新闻组数据集上的原始特征和处理后的 tf-idf 特征

在这个例子中,我们将简单的哈希词项频率转换应用于使用文档文本的简单空格拆分获得的原始文本标记。我们将在这些数据上训练一个模型,并评估在测试集上的性能,就像我们对使用 tf-idf 特征训练的模型一样:

val rawTokens = rdd.map { case (file, text) => text.split(" ") } 
val rawTF = texrawTokenst.map(doc => hashingTF.transform(doc)) 
val rawTrain = newsgroups.zip(rawTF).map { case (topic, vector)  
  => LabeledPoint(newsgroupsMap(topic), vector) } 
val rawModel = NaiveBayes.train(rawTrain, lambda = 0.1) 
val rawTestTF = testRDD.map { case (file, text) => 
  hashingTF.transform(text.split(" ")) } 
val rawZippedTest = testLabels.zip(rawTestTF) 
val rawTest = rawZippedTest.map { case (topic, vector) => 
  LabeledPoint(topic, vector) } 
val rawPredictionAndLabel = rawTest.map(p => 
  (rawModel.predict(p.features), p.label)) 
val rawAccuracy = 1.0 * rawPredictionAndLabel.filter(x => x._1 
  == x._2).count() / rawTest.count() 
println(rawAccuracy) 
val rawMetrics = new MulticlassMetrics(rawPredictionAndLabel) 
println(rawMetrics.weightedFMeasure)

也许令人惊讶的是,原始模型表现得相当不错,尽管准确性和 F-度量都比 tf-idf 模型低几个百分点。这在一定程度上也反映了朴素贝叶斯模型适合以原始频率计数形式的数据。

0.7661975570897503
0.7653320418573546

使用 Spark 2.0 进行文本分类

在本节中,我们将使用 libsvm 版本的20newsgroup数据,使用 Spark DataFrame-based API 对文本文档进行分类。在当前版本的 Spark 中,支持 libsvm 版本 3.22 (www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/)

从以下链接下载 libsvm 格式的数据并将输出文件夹复制到 Spark-2.0.x 下。

访问以下链接以获取20newsgroup libsvm数据:1drv.ms/f/s!Av6fk5nQi2j-iF84quUlDnJc6G6D

org.apache.spark.ml中导入适当的包并创建 Wrapper Scala:

package org.apache.spark.examples.ml 

import org.apache.spark.SparkConf 
import org.apache.spark.ml.classification.NaiveBayes 
import        

org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator 

import org.apache.spark.sql.SparkSession 

object DocumentClassificationLibSVM { 
  def main(args: Array[String]): Unit = { 

  } 
}

接下来,我们将将libsvm数据加载到 Spark DataFrame 中:

val spConfig = (new SparkConf).setMaster("local")
  .setAppName("SparkApp") 
val spark = SparkSession 
  .builder() 
  .appName("SparkRatingData").config(spConfig) 
  .getOrCreate() 

val data = spark.read.format("libsvm").load("./output/20news-by-
  date-train-libsvm/part-combined") 

val Array(trainingData, testData) = data.randomSplit(Array(0.7,
  0.3), seed = 1L)

org.apache.spark.ml.classification.NaiveBayes类中实例化NaiveBayes模型并训练模型:

val model = new NaiveBayes().fit(trainingData) 
val predictions = model.transform(testData) 
predictions.show()

以下表格是预测 DataFrame 的输出.show()命令:

+----+-------------------+--------------------+-----------------+----------+
|label|     features     |    rawPrediction   |   probability   |prediction|
+-----+------------------+--------------------+-----------------+----------+
|0.0|(262141,[14,63,64...|[-8972.9535882773...|[1.0,0.0,1.009147...| 0.0|
|0.0|(262141,[14,329,6...|[-5078.5468878602...|[1.0,0.0,0.0,0.0,...| 0.0|
|0.0|(262141,[14,448,5...|[-3376.8302696656...|[1.0,0.0,2.138643...| 0.0|
|0.0|(262141,[14,448,5...|[-3574.2782864683...|[1.0,2.8958758424...| 0.0|
|0.0|(262141,[14,535,3...|[-5001.8808481928...|[8.85311976855360...| 12.0|
|0.0|(262141,[14,573,8...|[-5950.1635030844...|[1.0,0.0,1.757049...| 0.0|
|0.0|(262141,[14,836,5...|[-8795.2012408412...|[1.0,0.0,0.0,0.0,...| 0.0|
|0.0|(262141,[14,991,2...|[-1892.8829282793...|[0.99999999999999...| 0.0|
|0.0|(262141,[14,1176,...|[-4746.2275710890...|[1.0,5.8201E-319,...| 0.0|
|0.0|(262141,[14,1379,...|[-7104.8373572933...|[1.0,8.9577444139...| 0.0|
|0.0|(262141,[14,1582,...|[-5473.6206675848...|[1.0,5.3185120345...| 0.0|
|0.0|(262141,[14,1836,...|[-11289.582479676...|[1.0,0.0,0.0,0.0,...| 0.0|
|0.0|(262141,[14,2325,...|[-3957.9187837274...|[1.0,2.1880375223...| 0.0|
|0.0|(262141,[14,2325,...|[-7131.2028421844...|[1.0,2.6110663778...| 0.0|
|0.0|(262141,[14,3033,...|[-3014.6430319605...|[1.0,2.6341580467...| 0.0|
|0.0|(262141,[14,4335,...|[-8283.7207917560...|[1.0,8.9559011053...| 0.0|
|0.0|(262141,[14,5173,...|[-6811.3466537480...|[1.0,7.2593916980...| 0.0|
|0.0|(262141,[14,5232,...|[-2752.8846541292...|[1.0,1.8619374091...| 0.0|
|0.0|(262141,[15,5173,...|[-8741.7756643949...|[1.0,0.0,2.606005...| 0.0|
|0.0|(262141,[168,170,...|[-41636.025208445...|[1.0,0.0,0.0,0.0,...| 0.0|
+----+--------------------+-------------------+-------------------+--------+

测试模型的准确性:

val accuracy = evaluator.evaluate(predictions) 
println("Test set accuracy = " + accuracy) 
spark.stop()

如下输出所示,该模型的准确性高于0.8

Test set accuracy = 0.8768458357944477
Accuracy is better as the Naive Bayes implementation has improved 
from Spark 1.6 to Spark 2.0

Word2Vec 模型

到目前为止,我们已经使用了词袋向量,可选地使用一些加权方案,如 tf-idf 来表示文档中的文本。另一个最近流行的模型类别与将单个单词表示为向量有关。

这些模型通常在某种程度上基于语料库中单词之间的共现统计。一旦计算出向量表示,我们可以以类似于使用 tf-idf 向量的方式使用这些向量(例如,将它们用作其他机器学习模型的特征)。这样一个常见的用例是根据它们的向量表示计算两个单词之间的相似性。

Word2Vec 是指这些模型中的一个特定实现,通常被称为分布式向量表示。MLlib 模型使用skip-gram模型,该模型旨在学习考虑单词出现上下文的向量表示。

虽然对 Word2Vec 的详细处理超出了本书的范围,但 Spark 的文档spark.apache.org/docs/latest/mllib-feature-extraction.html#word2vec中包含有关算法的更多详细信息以及参考实现的链接。

Word2Vec 的主要学术论文之一是Tomas MikolovKai ChenGreg CorradoJeffrey DeanEfficient Estimation of Word Representations in Vector Space在 2013 年 ICLR 研讨会论文集中

它可以在arxiv.org/pdf/1301.3781.pdf上找到。

在词向量表示领域的另一个最近的模型是 GloVe,网址为www-nlp.stanford.edu/projects/glove/

您还可以利用第三方库进行词性标注。例如,Stanford NLP 库可以连接到 scala 代码中。有关如何执行此操作的更多详细信息,请参阅此讨论线程(stackoverflow.com/questions/18416561/pos-tagging-in-scala)。

在 20 Newsgroups 数据集上使用 Spark MLlib 的 Word2Vec

在 Spark 中训练 Word2Vec 模型相对简单。我们将传入一个 RDD,其中每个元素都是一个术语序列。我们可以使用我们已经创建的标记化文档的 RDD 作为模型的输入。

object Word2VecMllib {
  def main(args: Array[String]) {
  val sc = new SparkContext("local[2]", "Word2Vector App")
  val path = "./data/20news-bydate-train/alt.atheism/*"
  val rdd = sc.wholeTextFiles(path)
  val text = rdd.map { case (file, text) => text }
  val newsgroups = rdd.map { case (file, text) =>             
    file.split("/").takeRight(2).head }
  val newsgroupsMap =       
    newsgroups.distinct.collect().zipWithIndex.toMap
  val dim = math.pow(2, 18).toInt
  var tokens = text.map(doc => TFIDFExtraction.tokenize(doc))
  import org.apache.spark.mllib.feature.Word2Vec
  val word2vec = new Word2Vec()
  val word2vecModel = word2vec.fit(tokens)
    word2vecModel.findSynonyms("philosophers", 5).foreach(println)
  sc.stop()
  }
}

我们的代码在 Scala 对象Word2VecMllib中:

import org.apache.spark.SparkContext
import org.apache.spark.mllib.linalg.{SparseVector => SV}
object Word2VecMllib {
  def main(args: Array[String]) {
  }
}

让我们从加载文本文件开始:

val sc = new SparkContext("local[2]", "Word2Vector App")
val path = "./data/20news-bydate-train/alt.atheism/*"
val rdd = sc.wholeTextFiles(path)
val text = rdd.map { case (file, text) => text }
val newsgroups = rdd.map { case (file, text) =>
  file.split("/").takeRight(2).head }
val newsgroupsMap =      
  newsgroups.distinct.collect().zipWithIndex.toMap
val dim = math.pow(2, 18).toInt
  var tokens = text.map(doc => TFIDFExtraction.tokenize(doc))

我们使用 tf-idf 创建的标记作为 Word2Vec 的起点。让我们首先初始化对象并设置一个种子:

import org.apache.spark.mllib.feature.Word2Vec
 val word2vec = new Word2Vec()

现在,让我们通过在 tf-idf 标记上调用word2vec.fit()来创建模型:

val word2vecModel = word2vec.fit(tokens)

在训练模型时,您将看到一些输出。

训练完成后,我们可以轻松地找到给定术语的前 20 个同义词(即,与输入术语最相似的术语,由单词向量之间的余弦相似性计算得出)。例如,要找到与philosopher最相似的 20 个术语,请使用以下代码行:

word2vecModel.findSynonyms(philosophers", 5).foreach(println)
sc.stop()

从以下输出中可以看出,大多数术语与曲棍球或其他相关:

(year,0.8417112940969042) (motivations,0.833017707021745) (solution,0.8284719617235932) (whereas,0.8242997325042509) (formed,0.8042383351975712)

在 20 个新闻组数据集上使用 Spark ML 的 Word2Vec

在本节中,我们将看看如何使用 Spark ML DataFrame 和 Spark 2.0.X 中的新实现来创建 Word2Vector 模型。

我们将从数据集创建一个 DataFrame:

val spConfig = (new SparkConf).setMaster("local").setAppName("SparkApp")
val spark = SparkSession
  .builder
  .appName("Word2Vec Sample").config(spConfig)
  .getOrCreate()
import spark.implicits._
val rawDF = spark.sparkContext
  .wholeTextFiles("./data/20news-bydate-train/alt.atheism/*")
  val temp = rawDF.map( x => {
    (x._2.filter(_ >= ' ').filter(! _.toString.startsWith("(")) )
    })
  val textDF = temp.map(x => x.split(" ")).map(Tuple1.apply)
    .toDF("text")

接下来将创建Word2Vec类,并在上面创建的 DataFrame textDF上训练模型:

val word2Vec = new Word2Vec()
  .setInputCol("text")
  .setOutputCol("result")
  .setVectorSize(3)
  .setMinCount(0)
val model = word2Vec.fit(textDF)
val result = model.transform(textDF)
  result.select("result").take(3).foreach(println)
)

现在让我们尝试找一些hockey的同义词:

以下

val ds = model.findSynonyms("philosophers", 5).select("word")
  ds.rdd.saveAsTextFile("./output/philiosphers-synonyms" +             System.nanoTime())
  ds.show(

将生成以下输出:

 +--------------+ | word         | +--------------+ | Fess         | | guide        | |validinference| | problems.    | | paperback    | +--------------+

正如您所看到的,结果与我们使用 RDD 得到的结果非常不同。这是因为 Spark 1.6 和 Spark 2.0/2.1 中的 Word2Vector 转换两种实现不同。

总结

在本章中,我们深入研究了更复杂的文本处理,并探索了 MLlib 的文本特征提取能力,特别是 tf-idf 术语加权方案。我们介绍了使用生成的 tf-idf 特征向量来计算文档相似性和训练新闻组主题分类模型的示例。最后,您学会了如何使用 MLlib 的尖端 Word2Vec 模型来计算文本语料库中单词的向量表示,并使用训练好的模型找到具有类似给定单词的上下文含义的单词。我们还研究了如何在 Spark ML 中使用 Word2Vec

在下一章中,我们将看一看在线学习,您将学习 Spark Streaming 与在线学习模型的关系。

第十一章:使用 Spark Streaming 进行实时机器学习

到目前为止,在本书中,我们专注于批量数据处理。也就是说,我们所有的分析、特征提取和模型训练都应用于一个不变的数据集。这与 Spark 的 RDD 的核心抽象非常契合,RDD 是不可变的分布式数据集。一旦创建,RDD 的底层数据不会改变,尽管我们可能通过 Spark 的转换和操作符创建新的 RDD。

我们的关注也集中在批量机器学习模型上,我们在固定的批量训练数据集上训练模型,通常表示为特征向量的 RDD(在监督学习模型的情况下还有标签)。

在本章中,我们将:

  • 介绍在线学习的概念,即在新数据可用时训练和更新模型

  • 探索使用 Spark Streaming 进行流处理

  • 了解 Spark Streaming 如何与在线学习方法结合

  • 介绍结构化流处理的概念

以下部分使用 RDD 作为分布式数据集。类似地,我们可以在流数据上使用 DataFrame 或 SQL 操作。

有关 DataFrame 和 SQL 操作的更多详细信息,请参见spark.apache.org/docs/2.0.0-preview/sql-programming-guide.html

在线学习

我们在本书中应用的批量机器学习方法侧重于处理现有的固定训练数据集。通常,这些技术也是迭代的,我们对训练数据进行多次通过以收敛到最佳模型。

相比之下,在线学习是基于以完全增量的方式对训练数据进行一次顺序通过(即一次处理一个训练样本)。在看到每个训练样本后,模型对该样本进行预测,然后接收真实结果(例如,分类的标签或回归的真实目标)。在线学习的理念是,模型在接收到新信息时不断更新,而不是定期进行批量训练。

在某些情况下,当数据量非常大或生成数据的过程变化迅速时,在线学习方法可以更快地适应并几乎实时地进行,而无需在昂贵的批处理过程中重新训练。

然而,在纯在线方式下,并不一定非要使用在线学习方法。事实上,当我们使用随机梯度下降(SGD)优化来训练分类和回归模型时,我们已经看到了在批处理设置中使用在线学习模型的例子。SGD 在每个训练样本后更新模型。然而,为了收敛到更好的结果,我们仍然对训练数据进行了多次通过。

在纯在线设置中,我们不会(或者可能无法)对训练数据进行多次通过;因此,我们需要在输入到达时处理每个输入。在线方法还包括小批量方法,其中我们不是一次处理一个输入,而是处理一小批训练数据。

在线和批量方法也可以在现实世界的情况下结合使用。例如,我们可以使用批量方法定期(比如每天)对模型进行离线重新训练。然后,我们可以将训练好的模型部署到生产环境,并使用在线方法实时更新(即在批量重新训练之间的白天)以适应环境的任何变化。这与 lambda 架构非常相似,lambda 架构是支持批量和流处理方法的数据处理架构。

正如我们将在本章中看到的,在线学习设置可以很好地适应流处理和 Spark Streaming 框架。

有关在线机器学习的更多详细信息,请参见en.wikipedia.org/wiki/Online_machine_learning

流处理

在介绍使用 Spark 进行在线学习之前,我们将首先探讨流处理的基础知识,并介绍 Spark Streaming 库。

除了核心 Spark API 和功能之外,Spark 项目还包含另一个主要库(就像 MLlib 是一个主要项目库一样)称为Spark Streaming,它专注于实时处理数据流。

数据流是连续的记录序列。常见的例子包括来自网络或移动应用的活动流数据,时间戳日志数据,交易数据,以及来自传感器或设备网络的事件流。

批处理方法通常涉及将数据流保存到中间存储系统(例如 HDFS 或数据库),并在保存的数据上运行批处理。为了生成最新的结果,批处理必须定期运行(例如每天,每小时,甚至每几分钟)以处理最新可用的数据。

相比之下,基于流的方法将处理应用于生成的数据流。这允许几乎实时处理(与典型的批处理相比,处理时间为亚秒到几分之一秒的时间范围,而不是分钟,小时,天甚至几周)。

Spark Streaming 简介

处理流处理的一些不同的一般技术。其中最常见的两种如下:

  • 对每个记录进行单独处理,并在看到时立即处理。

  • 将多个记录合并成小批次。这些小批次可以根据时间或批次中的记录数量来划分。

Spark Streaming 采用第二种方法。Spark Streaming 的核心原语是离散流DStream。DStream 是一系列小批次,其中每个小批次都表示为 Spark RDD:

离散流抽象

DStream 由其输入源和称为批处理间隔的时间窗口定义。流被分成与批处理间隔相等的时间段(从应用程序的起始时间开始)。流中的每个 RDD 将包含由 Spark Streaming 应用程序在给定批处理间隔期间接收的记录。如果在给定间隔中没有数据,则 RDD 将为空。

输入源

Spark Streaming 接收器负责从输入源接收数据,并将原始数据转换为由 Spark RDD 组成的 DStream。

Spark Streaming 支持各种输入源,包括基于文件的源(其中接收器监视到达输入位置的新文件并从每个新文件中读取的内容创建 DStream)和基于网络的源(例如与基于套接字的源,Twitter API 流,Akka actors 或消息队列和分布式流和日志传输框架通信的接收器,如 Flume,Kafka 和 Amazon Kinesis)。

有关输入源的文档,请参阅spark.apache.org/docs/latest/streaming-programming-guide.html#input-dstreams以获取更多详细信息和链接到各种高级源。

转换

正如我们在第一章中看到的,使用 Spark 快速入门,以及本书中的其他地方,Spark 允许我们对 RDD 应用强大的转换。由于 DStreams 由 RDD 组成,Spark Streaming 提供了一组可用于 DStreams 的转换;这些转换类似于 RDD 上可用的转换。这些包括mapflatMapfilterjoinreduceByKey

Spark Streaming 转换,如适用于 RDD 的转换,对 DStream 底层数据的每个元素进行操作。也就是说,转换实际上应用于 DStream 中的每个 RDD,进而将转换应用于 RDD 的元素。

Spark Streaming 还提供了诸如 reduce 和 count 之类的操作符。这些操作符返回由单个元素组成的 DStream(例如,每个批次的计数值)。与 RDD 上的等效操作符不同,这些操作不会直接触发 DStreams 上的计算。也就是说,它们不是操作,但它们仍然是转换,因为它们返回另一个 DStream。

跟踪状态

当我们处理 RDD 的批处理时,保持和更新状态变量相对简单。我们可以从某个状态开始(例如,值的计数或总和),然后使用广播变量或累加器并行更新此状态。通常,我们会使用 RDD 操作将更新后的状态收集到驱动程序,并更新全局状态。

对于 DStreams,这会更加复杂,因为我们需要以容错的方式跨批次跟踪状态。方便的是,Spark Streaming 在键值对的 DStream 上提供了updateStateByKey函数,它为我们处理了这一点,允许我们创建任意状态信息的流,并在看到每个批次数据时更新它。例如,状态可以是每个键被看到的次数的全局计数。因此,状态可以表示每个网页的访问次数,每个广告的点击次数,每个用户的推文数,或者每个产品的购买次数,等等。

一般的转换

Spark Streaming API 还公开了一个通用的transform函数,它允许我们访问流中每个批次的基础 RDD。也就是说,高级函数如map将 DStream 转换为另一个 DStream,而transform允许我们将 RDD 中的函数应用于另一个 RDD。例如,我们可以使用 RDD join运算符将流的每个批次与我们在流应用程序之外单独计算的现有 RDD 进行连接(可能是在 Spark 或其他系统中)。

完整的转换列表和有关每个转换的更多信息在 Spark 文档中提供,网址为spark.apache.org/docs/latest/streaming-programming-guide.html#transformations-on-dstreams

操作

虽然我们在 Spark Streaming 中看到的一些操作符,如 count,在批处理 RDD 的情况下不是操作,但 Spark Streaming 具有 DStreams 上的操作概念。操作是输出运算符,当调用时,会触发 DStream 上的计算。它们如下:

  • print:这将每个批次的前 10 个元素打印到控制台,通常用于调试和测试。

  • saveAsObjectFilesaveAsTextFilessaveAsHadoopFiles:这些函数将每个批次输出到与 Hadoop 兼容的文件系统,并使用从批次开始时间戳派生的文件名(如果适用)。

  • forEachRDD:此运算符是最通用的,允许我们对 DStream 的每个批次中的 RDD 应用任意处理。它用于应用副作用,例如将数据保存到外部系统,为测试打印数据,将数据导出到仪表板等。

请注意,与 Spark 的批处理一样,DStream 操作符是惰性的。就像我们需要在 RDD 上调用count等操作来确保处理发生一样,我们需要调用前面的操作符之一来触发 DStream 上的计算。否则,我们的流应用实际上不会执行任何计算。

窗口操作符

由于 Spark Streaming 操作的是按时间顺序排列的批处理数据流,因此引入了一个新概念,即窗口窗口函数计算应用于流的滑动窗口的转换。

窗口由窗口的长度和滑动间隔定义。例如,使用 10 秒的窗口和 5 秒的滑动间隔,我们将每 5 秒计算一次结果,基于 DStream 中最新的 10 秒数据。例如,我们可能希望计算过去 10 秒内页面浏览次数最多的网站,并使用滑动窗口每 5 秒重新计算这个指标。

下图说明了一个窗口化的 DStream:

窗口化的 DStream

使用 Spark Streaming 进行缓存和容错处理

与 Spark RDD 类似,DStreams 可以被缓存在内存中。缓存的用例与 RDD 的用例类似-如果我们希望多次访问 DStream 中的数据(可能执行多种类型的分析或聚合,或者输出到多个外部系统),那么缓存数据将会有所好处。状态操作符,包括window函数和updateStateByKey,会自动进行这种操作以提高效率。

请记住,RDD 是不可变的数据集,并且由其输入数据源和血统(即应用于 RDD 的一系列转换和操作)来定义。RDD 的容错性是通过重新创建由于工作节点故障而丢失的 RDD(或 RDD 的分区)来实现的。

由于 DStreams 本身是 RDD 的批处理,它们也可以根据需要重新计算以处理工作节点的故障。然而,这取决于输入数据是否仍然可用。如果数据源本身是容错和持久的(例如 HDFS 或其他容错的数据存储),那么 DStream 可以被重新计算。

如果数据流源通过网络传输(这在流处理中很常见),Spark Streaming 的默认持久化行为是将数据复制到两个工作节点。这允许在发生故障时重新计算网络 DStreams。然而,请注意,当一个节点失败时,任何接收到但尚未复制的数据可能会丢失。

Spark Streaming 还支持在驱动节点发生故障时进行恢复。然而,目前对于基于网络的数据源,工作节点内存中的数据在这种情况下将会丢失。因此,Spark Streaming 在面对驱动节点或应用程序故障时并不完全容错。而是可以使用 lambda 架构。例如,夜间批处理可以通过并在发生故障时纠正事情。

有关更多详细信息,请参见spark.apache.org/docs/latest/streaming-programming-guide.html#caching-persistencespark.apache.org/docs/latest/streaming-programming-guide.html#fault-tolerance-properties

创建基本的流应用程序

我们现在将通过创建我们的第一个 Spark Streaming 应用程序来说明我们之前介绍的 Spark Streaming 的一些基本概念。

我们将扩展第一章中使用的示例应用程序,使用 Spark 快速上手,在那里我们使用了一个小型的产品购买事件示例数据集。在这个例子中,我们将创建一个简单的生产者应用程序,随机生成事件并通过网络连接发送。然后,我们将创建一些 Spark Streaming 消费者应用程序来处理这个事件流。

本章的示例项目包含您需要的代码。它被称为scala-spark-streaming-app。它包括一个 Scala SBT 项目定义文件,示例应用程序源代码,以及一个包含名为names.csv的文件的srcmainresources目录。

项目的build.sbt文件包含以下项目定义:

name := "scala-spark-streaming-app" 
version := "1.0" 
scalaVersion := "2.11.7"
val sparkVersion = "2.0.0" 

libraryDependencies ++= Seq(
  "org.apache.spark" %% "spark-core" % sparkVersion, 
  "org.apache.spark" %% "spark-mllib" % sparkVersion, 
  "org.jfree" % "jfreechart" % "1.0.14", 
  "com.github.wookietreiber" % "scala-chart_2.11" % "0.5.0", 
  "org.apache.spark" %% "spark-streaming" % sparkVersion 
)

请注意,我们添加了对 Spark MLlib 和 Spark Streaming 的依赖,其中包括对 Spark 核心的依赖。

names.csv文件包含一组 20 个随机生成的用户名。我们将使用这些名称作为我们生产者应用程序中数据生成函数的一部分:

Miguel,Eric,James,Juan,Shawn,James,Doug,Gary,Frank,Janet,Michael,
James,Malinda,Mike,Elaine,Kevin,Janet,Richard,Saul,Manuela

生产者应用程序

我们的生产者需要创建一个网络连接,并生成一些随机购买事件数据发送到这个连接上。首先,我们将定义我们的对象和主方法定义。然后,我们将从names.csv资源中读取随机名称,并创建一组带有价格的产品,从中我们将生成我们的随机产品事件:

/** 
  * A producer application that generates random "product 
  * events", up to 5 per second, and sends them over a network  
  * connection 
*/ 
object StreamingProducer { 

  def main(args: Array[String]) { 

    val random = new Random() 

    // Maximum number of events per second 
    val MaxEvents = 6 

    // Read the list of possible names 
    val namesResource = 
      this.getClass.getResourceAsStream("/names.csv") 
    val names = scala.io.Source.fromInputStream(namesResource) 
      .getLines() 
      .toList 
      .head 
      .split(",") 
      .toSeq 

    // Generate a sequence of possible products 
    val products = Seq( 
      "iPhone Cover" -> 9.99, 
      "Headphones" -> 5.49, 
      "Samsung Galaxy Cover" -> 8.95, 
      "iPad Cover" -> 7.49 
    )

使用名称列表和产品名称到价格的映射,我们将创建一个函数,该函数将从这些来源中随机选择产品和名称,生成指定数量的产品事件:

/** Generate a number of random product events */ 
def generateProductEvents(n: Int) = { 
  (1 to n).map { i => 
    val (product, price) = 
      products(random.nextInt(products.size)) 
    val user = random.shuffle(names).head 
      (user, product, price) 
  } 
}

最后,我们将创建一个网络套接字,并设置我们的生产者监听此套接字。一旦建立连接(这将来自我们的消费者流应用程序),生产者将开始以每秒 0 到 5 个之间的随机速率生成随机事件:

// create a network producer 
val listener = new ServerSocket(9999) 
println("Listening on port: 9999") 

while (true) { 
  val socket = listener.accept() 
  new Thread() { 
    override def run = { 
      println("Got client connected from: " + 
        socket.getInetAddress) 
      val out = new PrintWriter(socket.getOutputStream(), true) 

      while (true) { 
        Thread.sleep(1000) 
        val num = random.nextInt(MaxEvents) 
        val productEvents = generateProductEvents(num) 
        productEvents.foreach{ event => 
          out.write(event.productIterator.mkString(",")) 
          out.write("n") 
        } 
        out.flush() 
        println(s"Created $num events...") 
      } 
      socket.close() 
    } 
  }.start() 
}

这个生产者示例是基于 Spark Streaming 示例中的PageViewGenerator示例。

可以通过转到scala-spark-streaming-app的基本目录并使用 SBT 运行应用程序来运行生产者,就像我们在第一章中所做的那样,使用 Spark 快速启动

>cd scala-spark-streaming-app
>sbt
[info] ...
>

使用run命令来执行应用程序:

>run

您应该看到类似以下的输出:

...
Multiple main classes detected, select one to run:

[1] StreamingProducer
[2] SimpleStreamingApp
[3] StreamingAnalyticsApp
[4] StreamingStateApp
[5] StreamingModelProducer
[6] SimpleStreamingModel
[7] MonitoringStreamingModel

Enter number:

选择StreamingProducer选项。应用程序将开始运行,您应该看到以下输出:

[info] Running StreamingProducer
Listening on port: 9999

我们可以看到生产者正在端口9999上监听,等待我们的消费者应用连接。

创建基本的流式应用程序

接下来,我们将创建我们的第一个流式程序。我们将简单地连接到生产者并打印出每个批次的内容。我们的流式代码如下:

/** 
  * A simple Spark Streaming app in Scala 
**/ 
object SimpleStreamingApp { 
  def main(args: Array[String]) { 
    val ssc = new StreamingContext("local[2]", "First Streaming 
      App", Seconds(10)) 
    val stream = ssc.socketTextStream("localhost", 9999) 

    // here we simply print out the first few elements of each batch 
    stream.print() 
    ssc.start() 
    ssc.awaitTermination() 
  } 
}

看起来相当简单,这主要是因为 Spark Streaming 为我们处理了所有复杂性。首先,我们初始化了一个StreamingContext(这是我们迄今为止使用的SparkContext的流式等价物),指定了用于创建SparkContext的类似配置选项。但是请注意,这里我们需要提供批处理间隔,我们将其设置为 10 秒。

然后,我们使用预定义的流式源socketTextStream创建了我们的数据流,该流从套接字主机和端口读取文本,并创建了一个DStream[String]。然后我们在 DStream 上调用print函数;这个函数打印出每个批次的前几个元素。

在 DStream 上调用print类似于在 RDD 上调用take。它只显示前几个元素。

我们可以使用 SBT 运行此程序。打开第二个终端窗口,保持生产者程序运行,并运行sbt

**>**sbt
[info] ...
>run

同样,您应该看到几个选项可供选择:

Multiple main classes detected, select one to run:

[1] StreamingProducer
[2] SimpleStreamingApp
[3] StreamingAnalyticsApp
[4] StreamingStateApp
[5] StreamingModelProducer
[6] SimpleStreamingModel
[7] MonitoringStreamingModel

运行SimpleStreamingApp主类。您应该看到流式程序启动,并显示类似于此处显示的输出:

...
14/11/15 21:02:23 INFO scheduler.ReceiverTracker: ReceiverTracker 
started
14/11/15 21:02:23 INFO dstream.ForEachDStream:  
metadataCleanupDelay  
=  -1
14/11/15 21:02:23 INFO dstream.SocketInputDStream: 
metadataCleanupDelay = -1
14/11/15 21:02:23 INFO dstream.SocketInputDStream: Slide time =  
10000 ms
14/11/15 21:02:23 INFO dstream.SocketInputDStream: Storage level = 
StorageLevel(false, false, false, false, 1)
14/11/15 21:02:23 INFO dstream.SocketInputDStream: Checkpoint 
interval = null
14/11/15 21:02:23 INFO dstream.SocketInputDStream: Remember      
duration = 10000 ms
14/11/15 21:02:23 INFO dstream.SocketInputDStream: Initialized and 
validated  
org.apache.spark.streaming.dstream.SocketInputDStream@ff3436d
14/11/15 21:02:23 INFO dstream.ForEachDStream: Slide time = 
10000   
ms
14/11/15 21:02:23 INFO dstream.ForEachDStream: Storage level = 
StorageLevel(false, false, false, false, 1)
14/11/15 21:02:23 INFO dstream.ForEachDStream: Checkpoint 
interval  
=  null
14/11/15 21:02:23 INFO dstream.ForEachDStream: Remember duration = 
10000 ms
14/11/15 21:02:23 INFO dstream.ForEachDStream: Initialized and 
validated   
org.apache.spark.streaming.dstream.ForEachDStream@5a10b6e8
14/11/15 21:02:23 INFO scheduler.ReceiverTracker: Starting 1 
receivers
14/11/15 21:02:23 INFO spark.SparkContext: Starting job: runJob at 
ReceiverTracker.scala:275
...

同时,您应该看到运行生产者的终端窗口显示类似以下内容:

...
Got client connected from: /127.0.0.1
Created 2 events...
Created 2 events...
Created 3 events...
Created 1 events...
Created 5 events...
...

大约 10 秒后,这是我们流式批处理间隔的时间,由于我们使用了print运算符,Spark Streaming 将在流上触发计算。这应该显示批次中的前几个事件,看起来类似以下输出:

...
14/11/15 21:02:30 INFO spark.SparkContext: Job finished: take at 
DStream.scala:608, took 0.05596 s
-------------------------------------------
Time: 1416078150000 ms
-------------------------------------------
Michael,Headphones,5.49
Frank,Samsung Galaxy Cover,8.95
Eric,Headphones,5.49
Malinda,iPad Cover,7.49
James,iPhone Cover,9.99
James,Headphones,5.49
Doug,iPhone Cover,9.99
Juan,Headphones,5.49
James,iPhone Cover,9.99
Richard,iPad Cover,7.49
...

请注意,您可能会看到不同的结果,因为生产者每秒生成随机数量的随机事件。

您可以通过按Ctrl + C来终止流式应用程序。如果您愿意,您也可以终止生产者(如果这样做,您将需要在创建我们将创建的下一个流式程序之前重新启动它)。

流式分析

接下来,我们将创建一个稍微复杂一些的流式处理程序。在第一章中,使用 Spark 快速上手,我们对产品购买数据集计算了一些指标。这些指标包括购买总数、唯一用户数、总收入以及最受欢迎的产品(以及其购买数量和总收入)。

在这个例子中,我们将在购买事件流上计算相同的指标。关键区别在于这些指标将按批次计算并打印出来。

我们将在这里定义我们的流应用程序代码:

/** 
 * A more complex Streaming app, which computes statistics and 
   prints the results for each batch in a DStream 
*/ 
object StreamingAnalyticsApp { 

  def main(args: Array[String]) { 

    val ssc = new StreamingContext("local[2]", "First Streaming 
      App", Seconds(10)) 
    val stream = ssc.socketTextStream("localhost", 9999) 

    // create stream of events from raw text elements 
    val events = stream.map { record => 
      val event = record.split(",") 
      (event(0), event(1), event(2)) 
    }

首先,我们创建了完全相同的StreamingContext和套接字流,就像我们之前做的那样。我们的下一步是对原始文本应用map转换,其中每条记录都是表示购买事件的逗号分隔字符串。map函数将文本拆分并创建一个(用户,产品,价格)元组。这说明了在 DStream 上使用map以及它与在 RDD 上操作时的相同之处。

接下来,我们将使用foreachRDD在流中的每个 RDD 上应用任意处理,以计算我们需要的指标并将其打印到控制台:

/* 
  We compute and print out stats for each batch. 
  Since each batch is an RDD, we call forEeachRDD on the 
  DStream, and apply the usual RDD functions 
  we used in Chapter 1\. 
*/ 
events.foreachRDD { (rdd, time) => 
  val numPurchases = rdd.count() 
  val uniqueUsers = rdd.map { case (user, _, _) => user 
    }.distinct().count() 
  val totalRevenue = rdd.map { case (_, _, price) => 
    price.toDouble }.sum() 
  val productsByPopularity = rdd 
    .map { case (user, product, price) => (product, 1) } 
    .reduceByKey(_ + _) 
    .collect() 
    .sortBy(-_._2) 
  val mostPopular = productsByPopularity(0) 

  val formatter = new SimpleDateFormat 
  val dateStr = formatter.format(new 
    Date(time.milliseconds)) 
  println(s"== Batch start time: $dateStr ==") 
  println("Total purchases: " + numPurchases) 
  println("Unique users: " + uniqueUsers) 
  println("Total revenue: " + totalRevenue) 
  println("Most popular product: %s with %d 
    purchases".format(mostPopular._1, mostPopular._2)) 
} 

// start the context 
ssc.start() 
ssc.awaitTermination() 

} 

}

如果您比较一下在前面的foreachRDD块中操作 RDD 的代码与第一章中使用的代码,使用 Spark 快速上手,您会注意到它们几乎是相同的代码。这表明我们可以通过操作底层 RDD 以及使用内置的更高级别的流操作,在流设置中应用任何与 RDD 相关的处理。

通过调用sbt run并选择StreamingAnalyticsApp来再次运行流处理程序。

请记住,如果之前终止了程序,您可能还需要重新启动生产者。这应该在启动流应用程序之前完成。

大约 10 秒后,您应该会看到与以下类似的流处理程序输出:

...
14/11/15 21:27:30 INFO spark.SparkContext: Job finished: collect 
at 
Streaming.scala:125, took 0.071145 s
== Batch start time: 2014/11/15 9:27 PM ==
Total purchases: 16
Unique users: 10
Total revenue: 123.72
Most popular product: iPad Cover with 6 purchases
...

您可以再次使用Ctrl + C终止流处理程序。

有状态的流式处理

最后,我们将使用updateStateByKey函数应用有状态流式处理的概念,以计算每个用户的全局收入和购买数量的状态,并将其与每个 10 秒批次的新数据进行更新。我们的StreamingStateApp应用程序如下所示:

object StreamingStateApp { 
  import org.apache.spark.streaming.StreamingContext

我们首先定义一个updateState函数,该函数将根据当前批次的新数据和运行状态值计算新状态。在这种情况下,我们的状态是一个(产品数量,收入)对的元组,我们将为每个用户保留这些状态。我们将根据当前批次的(产品,收入)对集合和当前时间的累积状态计算新状态。

请注意,我们将处理当前状态的Option值,因为它可能为空(这将是第一个批次的情况),我们需要定义一个默认值,我们将使用getOrElse来实现,如下所示:

def updateState(prices: Seq[(String, Double)], currentTotal: 
  Option[(Int, Double)]) = { 
  val currentRevenue = prices.map(_._2).sum 
  val currentNumberPurchases = prices.size 
  val state = currentTotal.getOrElse((0, 0.0)) 
  Some((currentNumberPurchases + state._1, currentRevenue + 
   state._2)) 
} 

def main(args: Array[String]) { 

  val ssc = new StreamingContext("local[2]", "First Streaming 
    App", Seconds(10)) 
  // for stateful operations, we need to set a checkpoint location 
  ssc.checkpoint("/tmp/sparkstreaming/") 
  val stream = ssc.socketTextStream("localhost", 9999) 

  // create stream of events from raw text elements 
  val events = stream.map { record => 
    val event = record.split(",") 
    (event(0), event(1), event(2).toDouble) 
  } 

  val users = events.map{ case (user, product, price) => 
    (user, (product, price)) } 
  val revenuePerUser = users.updateStateByKey(updateState) 
  revenuePerUser.print() 

  // start the context 
  ssc.start() 
  ssc.awaitTermination() 

  } 
}

在应用了与之前示例中相同的字符串拆分转换后,我们在 DStream 上调用了updateStateByKey,传入了我们定义的updateState函数。然后我们将结果打印到控制台。

使用sbt run启动流处理示例,并选择[4] StreamingStateApp(如果需要,也重新启动生产者程序)。

大约 10 秒后,您将开始看到第一组状态输出。我们将再等待 10 秒钟以查看下一组输出。您将看到整体全局状态正在更新:

...
-------------------------------------------
Time: 1416080440000 ms
-------------------------------------------
(Janet,(2,10.98))
(Frank,(1,5.49))
(James,(2,12.98))
(Malinda,(1,9.99))
(Elaine,(3,29.97))
(Gary,(2,12.98))
(Miguel,(3,20.47))
(Saul,(1,5.49))
(Manuela,(2,18.939999999999998))
(Eric,(2,18.939999999999998))
...
-------------------------------------------
Time: 1416080441000 ms
-------------------------------------------
(Janet,(6,34.94))
(Juan,(4,33.92))
(Frank,(2,14.44))
(James,(7,48.93000000000001))
(Malinda,(1,9.99))
(Elaine,(7,61.89))
(Gary,(4,28.46))
(Michael,(1,8.95))
(Richard,(2,16.439999999999998))
(Miguel,(5,35.95))
...

我们可以看到每个用户的购买数量和收入总额在每个数据批次中都会增加。

现在,看看您是否可以将此示例调整为使用 Spark Streaming 的window函数。例如,您可以每隔 30 秒滑动一次,在过去一分钟内计算每个用户的类似统计信息。

使用 Spark Streaming 进行在线学习

正如我们所见,Spark Streaming 使得以与使用 RDD 类似的方式处理数据流变得容易。使用 Spark 的流处理原语结合 ML Library SGD-based 方法的在线学习能力,我们可以创建实时的机器学习模型,并在流中的新数据到达时更新它们。

流式回归

Spark 在StreamingLinearAlgorithm类中提供了内置的流式机器学习模型。目前,只有线性回归实现可用-StreamingLinearRegressionWithSGD-但未来版本将包括分类。

流式回归模型提供了两种使用方法:

  • trainOn:这需要DStream[LabeledPoint]作为其参数。这告诉模型在输入 DStream 的每个批次上进行训练。可以多次调用以在不同的流上进行训练。

  • predictOn:这也接受DStream[LabeledPoint]。这告诉模型对输入 DStream 进行预测,返回一个包含模型预测的新DStream[Double]

在幕后,流式回归模型使用foreachRDDmap来完成这一点。它还在每个批次后更新模型变量,并公开最新训练的模型,这使我们可以在其他应用程序中使用这个模型或将其保存到外部位置。

流式回归模型可以像标准批处理回归一样配置步长和迭代次数的参数-使用的模型类是相同的。我们还可以设置初始模型权重向量。

当我们首次开始训练模型时,可以将初始权重设置为零向量,或随机向量,或者从离线批处理过程的结果中加载最新模型。我们还可以决定定期将模型保存到外部系统,并使用最新模型状态作为起点(例如,在节点或应用程序故障后重新启动时)。

一个简单的流式回归程序

为了说明流式回归的使用,我们将创建一个类似于前面的简单示例,使用模拟数据。我们将编写一个生成器程序,它生成随机特征向量和目标变量,给定一个已知的固定权重向量,并将每个训练示例写入网络流。

我们的消费应用程序将运行一个流式回归模型,对我们的模拟数据流进行训练和测试。我们的第一个示例消费者将简单地将其预测打印到控制台上。

创建流式数据生成器

数据生成器的操作方式类似于我们的产品事件生成器示例。回想一下第五章中的使用 Spark 构建推荐引擎,线性模型是权重向量w和特征向量x(即wTx)的线性组合(或向量点积)。我们的生成器将使用固定的已知权重向量和随机生成的特征向量生成合成数据。这些数据完全符合线性模型的制定,因此我们期望我们的回归模型能够很容易地学习到真实的权重向量。

首先,我们将设置每秒的最大事件数(比如 100)和特征向量中的特征数(在本例中也是 100):

/** 
 * A producer application that generates random linear 
 regression data. 
*/ 
object StreamingModelProducer { 
  import breeze.linalg._ 

  def main(args: Array[String]) { 

    // Maximum number of events per second 
    val MaxEvents = 100 
    val NumFeatures = 100 

    val random = new Random()

generateRandomArray函数创建指定大小的数组,其中的条目是从正态分布中随机生成的。我们将首先使用这个函数来生成我们已知的固定权重向量w,它将在生成器的整个生命周期内保持不变。我们还将创建一个随机的intercept值,它也将是固定的。权重向量和intercept将用于生成我们流中的每个数据点:

/** Function to generate a normally distributed dense vector */ 
def generateRandomArray(n: Int) = Array.tabulate(n)(_ => 
  random.nextGaussian()) 

// Generate a fixed random model weight vector 
val w = new DenseVector(generateRandomArray(NumFeatures)) 
val intercept = random.nextGaussian() * 10

我们还需要一个函数来生成指定数量的随机数据点。每个事件由一个随机特征向量和我们通过计算已知权重向量与随机特征向量的点积并添加intercept值得到的目标组成:

/** Generate a number of random product events */ 
def generateNoisyData(n: Int) = { 
  (1 to n).map { i => 
    val x = new DenseVector(generateRandomArray(NumFeatures)) 
    val y: Double = w.dot(x) 
    val noisy = y + intercept //+ 0.1 * random.nextGaussian() 
    (noisy, x) 
  } 
}

最后,我们将使用类似于之前生产者的代码来实例化网络连接,并每秒以文本格式通过网络发送随机数量的数据点(介于 0 和 100 之间):

// create a network producer 
  val listener = new ServerSocket(9999) 
  println("Listening on port: 9999") 

  while (true) { 
    val socket = listener.accept() 
    new Thread() { 
      override def run = { 
        println("Got client connected from: " + 
          socket.getInetAddress) 
        val out = new PrintWriter(socket.getOutputStream(), 
          true) 

        while (true) { 
          Thread.sleep(1000) 
          val num = random.nextInt(MaxEvents) 
          val data = generateNoisyData(num) 
          data.foreach { case (y, x) => 
            val xStr = x.data.mkString(",") 
            val eventStr = s"$yt$xStr" 
            out.write(eventStr) 
            out.write("n") 
            } 
            out.flush() 
            println(s"Created $num events...") 
          } 
          socket.close() 
        } 
      }.start() 
    } 
  } 
}

您可以使用sbt run启动生产者,然后选择执行StreamingModelProducer的主方法。这应该会导致以下输出,从而表明生产者程序正在等待来自我们流式回归应用程序的连接:

[info] Running StreamingModelProducer
Listening on port: 9999

创建流式回归模型

在我们的示例的下一步中,我们将创建一个流式回归程序。基本布局和设置与我们之前的流式分析示例相同:

/** 
  * A simple streaming linear regression that prints out predicted   
   value for each batch 
 */ 
object SimpleStreamingModel { 

  def main(args: Array[String]) { 

  val ssc = new StreamingContext("local[2]", "First Streaming       
    App", Seconds(10)) 
  val stream = ssc.socketTextStream("localhost", 9999)

在这里,我们将设置特征数量,以匹配输入数据流中的记录。然后,我们将创建一个零向量,用作流式回归模型的初始权重向量。最后,我们将选择迭代次数和步长:

val NumFeatures = 100 
val zeroVector = DenseVector.zerosDouble 
val model = new StreamingLinearRegressionWithSGD() 
  .setInitialWeights(Vectors.dense(zeroVector.data)) 
  .setNumIterations(1) 
  .setStepSize(0.01)

接下来,我们将再次使用map函数将输入 DStream 转换为LabeledPoint实例,其中每个记录都是我们输入数据的字符串表示,包含目标值和特征向量:

// create a stream of labeled points 
val labeledStream = stream.map { event => 
  val split = event.split("t") 
  val y = split(0).toDouble 
  val features = split(1).split(",").map(_.toDouble) 
  LabeledPoint(label = y, features = Vectors.dense(features)) 
}

最后一步是告诉模型在转换后的 DStream 上进行训练和测试,并打印出每个批次中前几个元素的预测值 DStream:

// train and test model on the stream, and print predictions
// for illustrative purposes 
    model.trainOn(labeledStream) 
    //model.predictOn(labeledStream).print() 
    model.predictOnValues(labeledStream.map(lp => (lp.label,       
    lp.features))).print() 

    ssc.start() 
    ssc.awaitTermination() 

  } 
}

请注意,因为我们在流式处理中使用了与批处理相同的 MLlib 模型类,如果选择,我们可以对每个批次的训练数据进行多次迭代(这只是LabeledPoint实例的 RDD)。

在这里,我们将把迭代次数设置为1,以模拟纯在线学习。在实践中,您可以将迭代次数设置得更高,但请注意,每批训练时间会增加。如果每批训练时间远远高于批间隔时间,流式模型将开始落后于数据流的速度。

这可以通过减少迭代次数、增加批间隔时间或通过添加更多 Spark 工作节点来增加流式程序的并行性来处理。

现在,我们准备在第二个终端窗口中使用sbt run运行SimpleStreamingModel,方式与我们为生产者所做的方式相同(记住选择正确的主方法以供 SBT 执行)。一旦流式程序开始运行,您应该在生产者控制台中看到以下输出:

Got client connected from: /127.0.0.1
...
Created 10 events...
Created 83 events...
Created 75 events...
...

大约 10 秒后,您应该开始看到模型预测被打印到流式应用程序控制台,类似于这里显示的内容:

14/11/16 14:54:00 INFO StreamingLinearRegressionWithSGD: Model 
updated at time 1416142440000 ms
14/11/16 14:54:00 INFO StreamingLinearRegressionWithSGD: Current 
model: weights, [0.05160959387864821,0.05122747155689144,-
0.17224086785756998,0.05822993392274008,0.07848094246845688,-
0.1298315806501979,0.006059323642394124, ...
...
14/11/16 14:54:00 INFO JobScheduler: Finished job streaming job 
1416142440000 ms.0 from job set of time 1416142440000 ms
14/11/16 14:54:00 INFO JobScheduler: Starting job streaming job 
1416142440000 ms.1 from job set of time 1416142440000 ms
14/11/16 14:54:00 INFO SparkContext: Starting job: take at 
DStream.scala:608
14/11/16 14:54:00 INFO DAGScheduler: Got job 3 (take at 
DStream.scala:608) with 1 output partitions (allowLocal=true)
14/11/16 14:54:00 INFO DAGScheduler: Final stage: Stage 3(take at 
DStream.scala:608)
14/11/16 14:54:00 INFO DAGScheduler: Parents of final stage: List()
14/11/16 14:54:00 INFO DAGScheduler: Missing parents: List()
14/11/16 14:54:00 INFO DAGScheduler: Computing the requested 
partition locally
14/11/16 14:54:00 INFO SparkContext: Job finished: take at 
DStream.scala:608, took 0.014064 s
-------------------------------------------
Time: 1416142440000 ms
-------------------------------------------
-2.0851430248312526
4.609405228401022
2.817934589675725
3.3526557917118813
4.624236379848475
-2.3509098272485156
-0.7228551577759544
2.914231548990703
0.896926579927631
1.1968162940541283
...

恭喜!您已经创建了您的第一个流式在线学习模型!

您可以通过在每个终端窗口中按下Ctrl + C来关闭流式应用程序(以及可选地关闭生产者)。

流式 K 均值

MLlib 还包括 K 均值聚类的流式版本;这被称为StreamingKMeans。该模型是小批量 K 均值算法的扩展,其中模型根据前几批计算的簇中心和当前批计算的簇中心的组合进行更新。

StreamingKMeans支持遗忘参数alpha(使用setDecayFactor方法设置);这控制模型在给予新数据权重时的侵略性。alpha 值为 0 意味着模型只使用新数据,而 alpha 值为1时,自流应用程序开始以来的所有数据都将被使用。

我们将不在这里进一步介绍流式 K 均值(Spark 文档spark.apache.org/docs/latest/mllib-clustering.html#streaming-clustering中包含更多细节和示例)。但是,也许您可以尝试将前面的流式回归数据生成器调整为生成StreamingKMeans模型的输入数据。您还可以调整流式回归应用程序以使用StreamingKMeans

您可以通过首先选择簇数K,然后通过以下方式生成每个数据点来创建聚类数据生成器:

  • 随机选择一个簇索引。

  • 使用特定正态分布参数生成随机向量以用于每个簇。也就是说,每个K簇将具有均值和方差参数,从中将使用类似于我们前面的generateRandomArray函数的方法生成随机向量。

这样,属于同一簇的每个数据点将从相同的分布中抽取,因此我们的流式聚类模型应该能够随着时间学习正确的簇中心。

在线模型评估

将机器学习与 Spark Streaming 结合使用有许多潜在的应用和用例,包括使模型或一组模型随着新的训练数据的到来保持最新,从而使它们能够快速适应不断变化的情况或背景。

另一个有用的应用是以在线方式跟踪和比较多个模型的性能,并可能实时执行模型选择,以便始终使用性能最佳的模型来生成实时数据的预测。

这可以用于对模型进行实时的“A/B 测试”,或与更高级的在线选择和学习技术结合使用,例如贝叶斯更新方法和赌博算法。它也可以简单地用于实时监控模型性能,从而能够在某些情况下做出响应或调整。

在本节中,我们将介绍对我们的流式回归示例的简单扩展。在这个例子中,我们将比较两个具有不同参数的模型随着在输入流中看到更多数据而不断变化的误差率。

使用 Spark Streaming 比较模型性能

由于我们在生产者应用程序中使用已知的权重向量和截距生成训练数据,我们期望我们的模型最终能够学习到这个潜在的权重向量(在本例中我们没有添加随机噪声)。

因此,我们应该看到模型的误差率随着时间的推移而减少,因为它看到越来越多的数据。我们还可以使用标准的回归误差指标来比较多个模型的性能。

在这个例子中,我们将创建两个具有不同学习率的模型,同时在相同的数据流上对它们进行训练。然后我们将为每个模型进行预测,并测量每个批次的均方误差(MSE)和均方根误差(RMSE)指标。

我们的新监控流模型代码如下:

/** 
 * A streaming regression model that compares the model             
 * performance of two models, printing out metrics for 
 * each batch 
*/ 
object MonitoringStreamingModel { 
  import org.apache.spark.SparkContext._ 

  def main(args: Array[String]) { 

    val ssc = new StreamingContext("local[2]", "First Streaming 
      App", Seconds(10)) 
    val stream = ssc.socketTextStream("localhost", 9999) 

    val NumFeatures = 100 
    val zeroVector = DenseVector.zerosDouble 
    val model1 = new StreamingLinearRegressionWithSGD() 
      .setInitialWeights(Vectors.dense(zeroVector.data)) 
      .setNumIterations(1) 
      .setStepSize(0.01) 

    val model2 = new StreamingLinearRegressionWithSGD() 
      .setInitialWeights(Vectors.dense(zeroVector.data)) 
      .setNumIterations(1) 
      .setStepSize(1.0) 

    // create a stream of labeled points 
    val labeledStream = stream.map { event => 
    val split = event.split("t") 
    val y = split(0).toDouble 
    val features = split(1).split(",").map(_.toDouble) 
    LabeledPoint(label = y, features =   
      Vectors.dense(features)) 
    }

请注意,前面大部分的设置代码与我们简单的流模型示例相同。但是,我们创建了两个StreamingLinearRegressionWithSGD的实例:一个学习率为0.01,另一个学习率设置为1.0

接下来,我们将在输入流上训练每个模型,并使用 Spark Streaming 的transform函数创建一个包含每个模型的误差率的新 DStream:

// train both models on the same stream 
model1.trainOn(labeledStream) 
model2.trainOn(labeledStream) 

// use transform to create a stream with model error rates 
val predsAndTrue = labeledStream.transform { rdd => 
  val latest1 = model1.latestModel() 
  val latest2 = model2.latestModel() 
  rdd.map { point => 
    val pred1 = latest1.predict(point.features) 
    val pred2 = latest2.predict(point.features) 
    (pred1 - point.label, pred2 - point.label) 
  } 
}

最后,我们将使用foreachRDD来计算每个模型的 MSE 和 RMSE 指标,并将它们打印到控制台上:

// print out the MSE and RMSE metrics for each model per batch 
predsAndTrue.foreachRDD { (rdd, time) => 
  val mse1 = rdd.map { case (err1, err2) => err1 * err1 
  }.mean() 
  val rmse1 = math.sqrt(mse1) 
  val mse2 = rdd.map { case (err1, err2) => err2 * err2 
  }.mean() 
  val rmse2 = math.sqrt(mse2) 
  println( 
    s""" 
       |------------------------------------------- 
       |Time: $time 
       |------------------------------------------- 
     """.stripMargin) 
  println(s"MSE current batch: Model 1: $mse1; Model 2: 
    $mse2") 
  println(s"RMSE current batch: Model 1: $rmse1; Model 2:
    $rmse2") 
  println("...n") 
} 

ssc.start() 
ssc.awaitTermination() 

  } 
}

如果您之前终止了生产者,请通过执行sbt run并选择StreamingModelProducer来重新启动它。一旦生产者再次运行,在第二个终端窗口中,执行sbt run并选择MonitoringStreamingModel的主类。

您应该看到流式程序启动,大约 10 秒后,第一批数据将被处理,打印出类似以下的输出:

...
14/11/16 14:56:11 INFO SparkContext: Job finished: mean at 
StreamingModel.scala:159, took 0.09122 s

-------------------------------------------
Time: 1416142570000 ms
-------------------------------------------

MSE current batch: Model 1: 97.9475827857361; Model 2: 
97.9475827857361
RMSE current batch: Model 1: 9.896847113385965; Model 2: 
9.896847113385965
...

由于两个模型都从相同的初始权重向量开始,我们看到它们在第一批次上都做出了相同的预测,因此具有相同的误差。

如果我们让流式程序运行几分钟,我们应该最终会看到其中一个模型已经开始收敛,导致误差越来越低,而另一个模型由于过高的学习率而趋于发散,变得更差:

...
14/11/16 14:57:30 INFO SparkContext: Job finished: mean at 
StreamingModel.scala:159, took 0.069175 s

-------------------------------------------
Time: 1416142650000 ms
 -------------------------------------------

MSE current batch: Model 1: 75.54543031658632; Model 2: 
10318.213926882852
RMSE current batch: Model 1: 8.691687426304878; Model 2: 
101.57860959317593
...

如果您让程序运行几分钟,最终应该会看到第一个模型的误差率变得非常小:

...
14/11/16 17:27:00 INFO SparkContext: Job finished: mean at 
StreamingModel.scala:159, took 0.037856 s

-------------------------------------------
Time: 1416151620000 ms
-------------------------------------------

MSE current batch: Model 1: 6.551475362521364; Model 2: 
1.057088005456417E26
RMSE current batch: Model 1: 2.559584998104451; Model 2: 
1.0281478519436867E13
...

再次注意,由于随机数据生成,您可能会看到不同的结果,但总体结果应该是相同的-在第一批次中,模型将具有相同的误差,随后,第一个模型应该开始生成越来越小的误差。

结构化流处理

使用 Spark 2.0 版本,我们有结构化流处理,它表示应用程序的输出等同于在数据的前缀上执行批处理作业。结构化流处理处理引擎内部的一致性和可靠性以及与外部系统的交互。结构化流是一个简单的数据框架和数据集 API。

用户提供他们想要运行的查询以及输入和输出位置。然后系统逐渐执行查询,保持足够的状态以从故障中恢复,在外部存储中保持结果的一致性等。

结构化流处理承诺构建实时应用程序的模型更简单,建立在 Spark Streaming 中最有效的功能上。然而,结构化流处理在 Spark 2.0 中处于 alpha 阶段。

摘要

在本章中,我们连接了在线机器学习和流数据分析之间的一些关键点。我们介绍了 Spark Streaming 库和 API,用于基于熟悉的 RDD 功能进行数据流的连续处理,并通过示例演示了流分析应用程序,说明了这种功能。

最后,我们在涉及计算和比较输入特征向量流上的模型性能的流应用程序中使用了 ML 库的流回归模型。

第十二章:Spark ML 的管道 API

在本章中,您将学习 ML 管道的基础知识以及它们如何在各种情境中使用。管道由几个组件组成。ML 管道利用 Spark 平台和机器学习提供关键功能,使大规模学习管道的构建变得简单。

管道介绍

管道 API 是在 Spark 1.2 中引入的,受到了 scikit-learn 的启发。管道的概念是为了便于创建、调整和检查 ML 工作流。

ML 管道提供了一组建立在 DataFrame 之上的高级 API,帮助用户创建和调整实用的机器学习管道。Spark 机器学习中的多种算法可以组合成一个单一的管道。

ML 管道通常涉及一系列数据预处理、特征提取、模型拟合和验证阶段。

让我们以文本分类为例,其中文档经过预处理阶段,如标记化、分割和清理,提取特征向量,并使用交叉验证训练分类模型。许多涉及预处理和算法的步骤可以使用管道连接在一起。管道通常位于 ML 库之上,编排工作流程。

数据帧

Spark 管道由一系列阶段定义,每个阶段都是一个转换器或估计器。这些阶段按顺序运行,输入 DataFrame 在通过每个阶段时进行转换。

DataFrame 是通过管道流动的基本数据结构或张量。DataFrame 由一系列行的数据集表示,并支持许多类型,如数值、字符串、二进制、布尔、日期时间等。

管道组件

ML 管道或 ML 工作流是一系列转换器和估计器,安排成将管道模型拟合到输入数据集的顺序。

转换器

转换器是一个包括特征转换器和学习模型的抽象。转换器实现了transform()方法,将一个 DataFrame 转换为另一个 DataFrame。

特征转换器接收一个 DataFrame,读取文本,将其映射到一个新列,并输出一个新的 DataFrame。

学习模型接收一个 DataFrame,读取包含特征向量的列,预测每个特征向量的标签,并输出一个包含预测标签的新 DataFrame。

自定义转换器需要遵循以下步骤:

  1. 实现transform方法。

  2. 指定 inputCol 和 outputCol。

  3. 接受DataFrame作为输入,并返回DataFrame作为输出。

简而言之,转换器DataFrame =[transform]=> DataFrame

估计器

估计器是对在数据集上拟合模型的学习算法的抽象。

估计器实现了一个fit()方法,该方法接收一个 DataFrame 并生成一个模型。学习算法的一个例子是LogisticRegression

简而言之,估计器是:DataFrame =[fit]=> Model

在以下示例中,PipelineComponentExample介绍了转换器和估计器的概念:

import org.apache.spark.ml.classification.LogisticRegression 
import org.apache.spark.ml.linalg.{Vector, Vectors} 
import org.apache.spark.ml.param.ParamMap 
import org.apache.spark.sql.Row 
import org.utils.StandaloneSpark 

object PipelineComponentExample { 

  def main(args: Array[String]): Unit = { 
    val spark = StandaloneSpark.getSparkInstance() 

    // Prepare training data from a list of (label, features) tuples. 
    val training = spark.createDataFrame(Seq( 
      (1.0, Vectors.dense(0.0, 1.1, 0.1)), 
      (0.0, Vectors.dense(2.0, 1.0, -1.0)), 
      (0.0, Vectors.dense(2.0, 1.3, 1.0)), 
      (1.0, Vectors.dense(0.0, 1.2, -0.5)) 
    )).toDF("label", "features") 

    // Create a LogisticRegression instance. This instance is an Estimator. 
    val lr = new LogisticRegression() 
    // Print out the parameters, documentation, and any default values. 
    println("LogisticRegression parameters:n" + lr.explainParams() + "n") 

    // We may set parameters using setter methods. 
    lr.setMaxIter(10) 
      .setRegParam(0.01) 

    // Learn a LogisticRegression model.
    // This uses the parameters stored in lr. 
    val model1 = lr.fit(training) 
    // Since model1 is a Model (i.e., a Transformer produced by an Estimator), 
    // we can view the parameters it used during fit(). 
    // This prints the parameter (name: value) pairs,
    // where names are unique IDs for this 
    // LogisticRegression instance. 
    println("Model 1 was fit using parameters: " + 
    model1.parent.extractParamMap) 

    // We may alternatively specify parameters using a ParamMap, 
    // which supports several methods for specifying parameters. 
    val paramMap = ParamMap(lr.maxIter -> 20) 
    .put(lr.maxIter, 30) // Specify 1 Param.
    // This overwrites the original maxIter. 
    .put(lr.regParam -> 0.1, lr.threshold -> 0.55) // Specify multiple Params. 

    // One can also combine ParamMaps. 
    val paramMap2 = ParamMap(lr.probabilityCol ->             
      "myProbability") 
    // Change output column name. 
    val paramMapCombined = paramMap ++ paramMap2 

    // Now learn a new model using the paramMapCombined parameters. 
    lr.set* methods. 
    val model2 = lr.fit(training, paramMapCombined) 
    println("Model 2 was fit using parameters: " + 
      model2.parent.extractParamMap) 

    // Prepare test data. 
    val test = spark.createDataFrame(Seq( 
      (1.0, Vectors.dense(-1.0, 1.5, 1.3)), 
      (0.0, Vectors.dense(3.0, 2.0, -0.1)), 
      (1.0, Vectors.dense(0.0, 2.2, -1.5)) 
        )).toDF("label", "features") 

    // Make predictions on test data using the 
    // Transformer.transform() method. 
    // LogisticRegression.transform will only use the 'features' 
    // column. 
    // Note that model2.transform() outputs a 'myProbability' 
    // column instead of the usual 
    // 'probability' column since we renamed the       
    lr.probabilityCol 
    parameter previously. 
    model2.transform(test) 
      .select("features", "label", "myProbability", 
      "prediction") 
      .collect() 
      .foreach { case Row(features: Vector, label: Double, prob: 
        Vector, prediction: Double) => 
        println(s"($features, $label) -> prob=$prob, 
        prediction=$prediction") 
      } 
   } 
} 

您将看到以下输出:

Model 2 was fit using parameters: {
logreg_158888baeffa-elasticNetParam: 0.0,
logreg_158888baeffa-featuresCol: features,
logreg_158888baeffa-fitIntercept: true,
logreg_158888baeffa-labelCol: label,
logreg_158888baeffa-maxIter: 30,
logreg_158888baeffa-predictionCol: prediction,
logreg_158888baeffa-probabilityCol: myProbability,
logreg_158888baeffa-rawPredictionCol: rawPrediction,
logreg_158888baeffa-regParam: 0.1,
logreg_158888baeffa-standardization: true,
logreg_158888baeffa-threshold: 0.55,
logreg_158888baeffa-tol: 1.0E-6
}
17/02/12 12:32:49 INFO Instrumentation: LogisticRegression-
logreg_158888baeffa-268961738-2: training finished
17/02/12 12:32:49 INFO CodeGenerator: Code generated in 26.525405    
ms
17/02/12 12:32:49 INFO CodeGenerator: Code generated in 11.387162   
ms
17/02/12 12:32:49 INFO SparkContext: Invoking stop() from shutdown 
hook
([-1.0,1.5,1.3], 1.0) -> 
prob=[0.05707304171033984,0.9429269582896601], prediction=1.0
([3.0,2.0,-0.1], 0.0) -> 
prob=[0.9238522311704088,0.0761477688295912], prediction=0.0
([0.0,2.2,-1.5], 1.0) -> 
prob=[0.10972776114779145,0.8902722388522085], prediction=1.0

代码清单:

github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_12/2.0.0/spark-ai-apps/src/main/scala/org/textclassifier/PipelineComponentExample.scala

管道的工作原理

我们运行一系列算法来处理和学习给定的数据集。例如,在文本分类中,我们将每个文档分割成单词,并将单词转换为数值特征向量。最后,我们使用这个特征向量和标签学习一个预测模型。

Spark ML 将这样的工作流程表示为一个管道,它由一系列 PipelineStages(转换器和估计器)组成,按特定顺序运行。

PipelineStages中的每个阶段都是组件之一,可以是转换器或估计器。在输入 DataFrame 通过阶段流动时,阶段按特定顺序运行。

以下图片来自spark.apache.org/docs/latest/ml-pipeline.html#dataframe

在下图中,dp文档管道演示了文档工作流程,其中 Tokenizer、Hashing 和 Logistic Regression 是管道的组件。Pipeline.fit()方法显示了原始文本如何通过管道进行转换:

当调用Pipeline.fit()方法时,在第一个阶段,原始文本使用Tokenizer转换器被标记为单词,然后在第二个阶段,单词使用词频转换器转换为特征向量。在最后一个阶段,对Estimator Logistic Regression调用fit()方法以获得特征向量上的Logistic Regression Model(PipelineModel)。

管道是一个估计器,在运行fit()之后,它会产生一个 PipelineModel,这是一个转换器:

在测试数据上调用PipelineModels.transform方法并进行预测。

管道可以是线性的,即阶段被指定为有序数组,也可以是非线性的,其中数据流形成有向无环图DAG)。管道和 PipelineModels 在实际运行管道之前执行运行时检查。

DAG 管道示例如下:

以下示例TextClassificationPipeline介绍了转换器和估计器的概念:

package org.textclassifier 

import org.apache.spark.ml.{Pipeline, PipelineModel} 
import org.apache.spark.ml.classification.LogisticRegression 
import org.apache.spark.ml.feature.{HashingTF, Tokenizer} 
import org.apache.spark.ml.linalg.Vector 
import org.utils.StandaloneSpark 

/** 
* Created by manpreet.singh on 12/02/17\. 
 */ 
object TextClassificationPipeline { 

  def main(args: Array[String]): Unit = { 
    val spark = StandaloneSpark.getSparkInstance() 

   // Prepare training documents from a list of (id, text, label) 
   // tuples. 
   val training = spark.createDataFrame(Seq( 
     (0L, "a b c d e spark", 1.0), 
     (1L, "b d", 0.0), 
     (2L, "spark f g h", 1.0), 
     (3L, "hadoop mapreduce", 0.0) 
    )).toDF("id", "text", "label") 

    // Configure an ML pipeline, which consists of three stages: 
    // tokenizer, hashingTF, and lr. 
    val tokenizer = new Tokenizer() 
      .setInputCol("text") 
      .setOutputCol("words") 
    val hashingTF = new HashingTF() 
      .setNumFeatures(1000) 
      .setInputCol(tokenizer.getOutputCol) 
      .setOutputCol("features") 
    val lr = new LogisticRegression() 
      .setMaxIter(10) 
      .setRegParam(0.001) 
    val pipeline = new Pipeline() 
      .setStages(Array(tokenizer, hashingTF, lr)) 

    // Fit the pipeline to training documents. 
    val model = pipeline.fit(training)

    // Now we can optionally save the fitted pipeline to disk 
    model.write.overwrite().save("/tmp/spark-logistic-regression-
      model") 

    // We can also save this unfit pipeline to disk 
    pipeline.write.overwrite().save("/tmp/unfit-lr-model") 

    // And load it back in during production 
    val sameModel = PipelineModel.load("/tmp/spark-logistic-
      regression-model") 

    // Prepare test documents, which are unlabeled (id, text) tuples. 
    val test = spark.createDataFrame(Seq( 
      (4L, "spark i j k"), 
      (5L, "l m n"), 
      (6L, "spark hadoop spark"), 
      (7L, "apache hadoop") 
    )).toDF("id", "text") 

    // Make predictions on test documents. 
    model.transform(test) 
      .select("id", "text", "probability", "prediction") 
      .collect() 
      .foreach { case Row(id: Long, text: String, prob: Vector, 
        prediction: Double) => 
        println(s"($id, $text) --> prob=$prob, 
        prediction=$prediction") 
      } 
    } 
 } 

您将看到以下输出:

17/02/12 12:46:22 INFO Executor: Finished task 0.0 in stage 
30.0    
(TID 
30). 1494 bytes result sent to driver
17/02/12 12:46:22 INFO TaskSetManager: Finished task 0.0 in stage 
30.0 (TID 30) in 84 ms on localhost (1/1)
17/02/12 12:46:22 INFO TaskSchedulerImpl: Removed TaskSet 30.0,    
whose tasks have all completed, from pool 
17/02/12 12:46:22 INFO DAGScheduler: ResultStage 30 (head at 
LogisticRegression.scala:683) finished in 0.084 s
17/02/12 12:46:22 INFO DAGScheduler: Job 29 finished: head at 
LogisticRegression.scala:683, took 0.091814 s
17/02/12 12:46:22 INFO CodeGenerator: Code generated in 5.88911 ms
17/02/12 12:46:22 INFO CodeGenerator: Code generated in 8.320754 ms
17/02/12 12:46:22 INFO CodeGenerator: Code generated in 9.082379 ms
(4, spark i j k) --> 
prob=[0.15964077387874084,0.8403592261212592], 
prediction=1.0
(5, l m n) --> prob=[0.8378325685476612,0.16216743145233883], 
prediction=0.0
(6, spark hadoop spark) --> prob=    
[0.06926633132976247,0.9307336686702374], prediction=1.0 (7, apache hadoop) --> prob=   
[0.9821575333444208,0.01784246665557917], 
prediction=0.0

代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_12/2.0.0/spark-ai-apps/src/main/scala/org/textclassifier/TextClassificationPipeline.scala

带有示例的机器学习管道

正如前几节讨论的那样,新的 ML 库中最大的特性之一是引入了管道。管道提供了机器学习流程的高级抽象,并极大简化了整个工作流程。

我们将演示在 Spark 中使用StumbleUpon数据集创建管道的过程。

此处使用的数据集可以从www.kaggle.com/c/stumbleupon/data下载。

下载训练数据(train.tsv)--您需要在下载数据集之前接受条款和条件。您可以在www.kaggle.com/c/stumbleupon找到有关比赛的更多信息。

这是将StumbleUpon数据集存储为 Spark SQLContext 临时表的一瞥:

这是StumbleUpon数据集的可视化:

StumbleUponExecutor

StumbleUponExecutor对象可用于选择和运行相应的分类模型,例如运行LogisiticRegression并执行逻辑回归管道,或将程序参数设置为LR。有关其他命令,请参阅以下代码片段:

在我们继续之前,先简要介绍一下逻辑回归估计器。逻辑回归适用于类别几乎是线性可分的分类问题。它在特征空间中寻找单一的线性决策边界。Spark 中有两种类型的逻辑回归估计器:二项逻辑回归估计器用于预测二元结果,多项逻辑回归估计器用于预测多类结果。

case "LR" =>             
  LogisticRegressionPipeline.logisticRegressionPipeline(
  vectorAssembler, dataFrame) 

case "DT" =>       
  DecisionTreePipeline.decisionTreePipeline(vectorAssembler, 
  dataFrame) 

case "RF" => 
  RandomForestPipeline.randomForestPipeline(vectorAssembler, 
  dataFrame) 

case
  GradientBoostedTreePipeline.gradientBoostedTreePipeline
  (vectorAssembler, dataFrame) 

case "NB" =>                   
  NaiveBayesPipeline.naiveBayesPipeline(vectorAssembler, 
  dataFrame) 

case "SVM" => SVMPipeline.svmPipeline(sparkContext) 

代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_12/2.0.0/spark-ai-apps/src/main/scala/org/stumbleuponclassifier/StumbleUponExecutor.scala

决策树管道:管道使用决策树估计器对 StumbleUpon 数据集进行分类,作为 ML 工作流的一部分。

在 Spark 中,决策树估计器基本上使用轴对齐的线性决策边界将特征空间划分为半空间。效果是我们有一个非线性决策边界,可能不止一个:

package org.stumbleuponclassifier 

import org.apache.log4j.Logger 
import org.apache.spark.ml.classification.DecisionTreeClassifier 
import org.apache.spark.ml.evaluation.MulticlassClassification
  Evaluator 
import org.apache.spark.ml.feature.{StringIndexer,       
  VectorAssembler} 
import org.apache.spark.ml.{Pipeline, PipelineStage} 
import org.apache.spark.sql.DataFrame 
import scala.collection.mutable 

/** 
  * Created by manpreet.singh on 01/05/16\. 
  */ 
object DecisionTreePipeline { 
  @transient lazy val logger = Logger.getLogger(getClass.getName) 

  def decisionTreePipeline(vectorAssembler: VectorAssembler, 
    dataFrame: DataFrame) = { 
    val Array(training, test) = dataFrame.randomSplit(Array(0.9, 
      0.1), seed = 12345) 

    // Set up Pipeline 
    val stages = new mutable.ArrayBuffer[PipelineStage]() 

    val labelIndexer = new StringIndexer() 
      .setInputCol("label") 
      .setOutputCol("indexedLabel") 
    stages += labelIndexer 

    val dt = new DecisionTreeClassifier() 
      .setFeaturesCol(vectorAssembler.getOutputCol) 
      .setLabelCol("indexedLabel") 
      .setMaxDepth(5) 
      .setMaxBins(32) 
      .setMinInstancesPerNode(1) 
      .setMinInfoGain(0.0) 
      .setCacheNodeIds(false) 
      .setCheckpointInterval(10) 

    stages += vectorAssembler 
    stages += dt 
    val pipeline = new Pipeline().setStages(stages.toArray) 

    // Fit the Pipeline 
    val startTime = System.nanoTime() 
    //val model = pipeline.fit(training) 
    val model = pipeline.fit(dataFrame) 
    val elapsedTime = (System.nanoTime() - startTime) / 1e9 
    println(s"Training time: $elapsedTime seconds") 

    //val holdout = 
    // model.transform(test).select("prediction","label") 
    val holdout = 
      model.transform(dataFrame).select("prediction","label") 

    // Select (prediction, true label) and compute test error 
    val evaluator = new MulticlassClassificationEvaluator() 
      .setLabelCol("label") 
      .setPredictionCol("prediction") 
      .setMetricName("accuracy") 
    val mAccuracy = evaluator.evaluate(holdout) 
    println("Test set accuracy = " + mAccuracy) 
  } 
} 

您将看到以下输出显示:

Accuracy: 0.3786163522012579  

代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_12/2.0.0/spark-ai-apps/src/main/scala/org/stumbleuponclassifier/DecisionTreePipeline.scala

这里显示了 2 维散点图中预测数据的可视化:

这里显示了 2 维散点图中实际数据的可视化:

朴素贝叶斯管道:管道使用朴素贝叶斯估计器对 StumbleUpon 数据集进行分类,作为 ML 工作流的一部分。

朴素贝叶斯估计器认为类中特定特征的存在与任何其他特征的存在无关。朴素贝叶斯模型易于构建,特别适用于非常大的数据集:

package org.stumbleuponclassifier 

import org.apache.log4j.Logger 
import org.apache.spark.ml.classification.NaiveBayes 
import org.apache.spark.ml.evaluation.MulticlassClassification
  Evaluator 
import org.apache.spark.ml.feature.{StringIndexer, 
  VectorAssembler} 
import org.apache.spark.ml.{Pipeline, PipelineStage} 
import org.apache.spark.sql.DataFrame 
import scala.collection.mutable 

/** 
  * Created by manpreet.singh on 01/05/16\. 
  */ 
object NaiveBayesPipeline { 
  @transient lazy val logger = 
  Logger.getLogger(getClass.getName) 

  def naiveBayesPipeline(vectorAssembler: VectorAssembler, 
    dataFrame: DataFrame) = { 
    val Array(training, test) = dataFrame.randomSplit(Array(0.9, 
      0.1), seed = 12345) 

    // Set up Pipeline 
    val stages = new mutable.ArrayBuffer[PipelineStage]() 

    val labelIndexer = new StringIndexer() 
      .setInputCol("label") 
      .setOutputCol("indexedLabel") 
    stages += labelIndexer 

    val nb = new NaiveBayes() 

    stages += vectorAssembler 
    stages += nb 
    val pipeline = new Pipeline().setStages(stages.toArray) 

    // Fit the Pipeline 
    val startTime = System.nanoTime() 
    // val model = pipeline.fit(training) 
    val model = pipeline.fit(dataFrame) 
    val elapsedTime = (System.nanoTime() - startTime) / 1e9 
    println(s"Training time: $elapsedTime seconds") 

    // val holdout = 
    // model.transform(test).select("prediction","label") 
    val holdout = 
      model.transform(dataFrame).select("prediction","label") 

    // Select (prediction, true label) and compute test error 
    val evaluator = new MulticlassClassificationEvaluator() 
      .setLabelCol("label") 
      .setPredictionCol("prediction") 
      .setMetricName("accuracy") 
    val mAccuracy = evaluator.evaluate(holdout) 
    println("Test set accuracy = " + mAccuracy) 
  } 
} 

您将看到以下输出显示:

Training time: 2.114725642 seconds
Accuracy: 0.5660377358490566

代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_12/2.0.0/spark-ai-apps/src/main/scala/org/stumbleuponclassifier/NaiveBayesPipeline.scala

这里显示了 2 维散点图中预测数据的可视化:

这里显示了 2 维散点图中实际数据的可视化:

梯度提升管道:管道使用梯度提升树估计器对 StumbleUpon 数据集进行分类,作为 ML 工作流的一部分。

梯度提升树估计器是用于回归和分类问题的机器学习方法。梯度提升树(GBTs)和随机森林都是学习树集成的算法。GBTs 迭代训练决策树以最小化损失函数。spark.mllib 支持 GBTs。

package org.stumbleuponclassifier 

import org.apache.log4j.Logger 
import org.apache.spark.ml.classification.GBTClassifier 
import org.apache.spark.ml.feature.{StringIndexer, 
   VectorAssembler} 
import org.apache.spark.ml.{Pipeline, PipelineStage} 
import org.apache.spark.mllib.evaluation.{MulticlassMetrics, 
   RegressionMetrics} 
import org.apache.spark.sql.DataFrame 

import scala.collection.mutable 

/** 
  * Created by manpreet.singh on 01/05/16\. 
  */ 
object GradientBoostedTreePipeline { 
  @transient lazy val logger = 
    Logger.getLogger(getClass.getName) 
    def gradientBoostedTreePipeline(vectorAssembler: 
      VectorAssembler, dataFrame: DataFrame) = { 
      val Array(training, test) = dataFrame.randomSplit(Array(0.9, 
      0.1), seed = 12345) 

    // Set up Pipeline 
    val stages = new mutable.ArrayBuffer[PipelineStage]() 

      val labelIndexer = new StringIndexer() 
      .setInputCol("label") 
      .setOutputCol("indexedLabel") 
    stages += labelIndexer 

    val gbt = new GBTClassifier() 
      .setFeaturesCol(vectorAssembler.getOutputCol) 
      .setLabelCol("indexedLabel") 
      .setMaxIter(10) 

    stages += vectorAssembler 
    stages += gbt 
    val pipeline = new Pipeline().setStages(stages.toArray) 

    // Fit the Pipeline 
    val startTime = System.nanoTime() 
    //val model = pipeline.fit(training) 
    val model = pipeline.fit(dataFrame) 
    val elapsedTime = (System.nanoTime() - startTime) / 1e9 
    println(s"Training time: $elapsedTime seconds") 

    // val holdout = 
    // model.transform(test).select("prediction","label") 
    val holdout = 
    model.transform(dataFrame).select("prediction","label") 

    // have to do a type conversion for RegressionMetrics 
    val rm = new RegressionMetrics(holdout.rdd.map(x => 
      (x(0).asInstanceOf[Double], x(1).asInstanceOf[Double]))) 

    logger.info("Test Metrics") 
    logger.info("Test Explained Variance:") 
    logger.info(rm.explainedVariance) 
    logger.info("Test R² Coef:") 
    logger.info(rm.r2) 
    logger.info("Test MSE:") 
    logger.info(rm.meanSquaredError) 
    logger.info("Test RMSE:") 
    logger.info(rm.rootMeanSquaredError) 

    val predictions = model.transform(test).select("prediction")
    .rdd.map(_.getDouble(0)) 
    val labels = model.transform(test).select("label")
    .rdd.map(_.getDouble(0)) 
    val accuracy = new 
      MulticlassMetrics(predictions.zip(labels)).precision 
    println(s"  Accuracy : $accuracy") 
  } 

  def savePredictions(predictions:DataFrame, testRaw:DataFrame, 
    regressionMetrics: RegressionMetrics, filePath:String) = { 
    predictions 
      .coalesce(1) 
      .write.format("com.databricks.spark.csv") 
      .option("header", "true") 
      .save(filePath) 
  } 

} 

您将看到以下输出显示:

Accuracy: 0.3647

代码清单:github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_12/2.0.0/spark-ai-apps/src/main/scala/org/stumbleuponclassifier/GradientBoostedTreePipeline.scala

这里显示了 2 维散点图中预测的可视化:

以下显示了 2 维散点图中实际数据的可视化:

总结

在本章中,我们介绍了 Spark ML Pipeline 及其组件的基础知识。我们看到如何在输入 DataFrame 上训练模型,以及如何通过运行它们通过 spark ML 管道 API 来评估它们的性能,使用标准指标和度量标准。我们探讨了如何应用一些技术,如转换器和估计器。最后,我们通过在 Kaggle 的 StumbleUpon 数据集上应用不同的算法来调查管道 API。

机器学习是行业中的新星。它确实解决了许多业务问题和用例。我们希望我们的读者能够找到新的创新方式,使这些方法更加强大,并延伸了解支撑学习和智能的原则的旅程。有关机器学习和 Spark 的进一步练习和阅读,请参考www.kaggle.comdatabricks.com/spark/

posted @ 2024-05-21 12:53  绝不原创的飞龙  阅读(16)  评论(0编辑  收藏  举报