原文:
zh.annas-archive.org/md5/7A35D303E4132E910DFC5ADB5679B82A
译者:飞龙
第十一章:使用 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 上可用的转换。这些包括map
,flatMap
,filter
,join
和reduceByKey
。
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 个元素打印到控制台,通常用于调试和测试。 -
saveAsObjectFile
、saveAsTextFiles
和saveAsHadoopFiles
:这些函数将每个批次输出到与 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-persistence
和spark.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]
。
在幕后,流式回归模型使用foreachRDD
和map
来完成这一点。它还在每个批次后更新模型变量,并公开最新训练的模型,这使我们可以在其他应用程序中使用这个模型或将其保存到外部位置。
流式回归模型可以像标准批处理回归一样配置步长和迭代次数的参数-使用的模型类是相同的。我们还可以设置初始模型权重向量。
当我们首次开始训练模型时,可以将初始权重设置为零向量,或随机向量,或者从离线批处理过程的结果中加载最新模型。我们还可以决定定期将模型保存到外部系统,并使用最新模型状态作为起点(例如,在节点或应用程序故障后重新启动时)。
一个简单的流式回归程序
为了说明流式回归的使用,我们将创建一个类似于前面的简单示例,使用模拟数据。我们将编写一个生成器程序,它生成随机特征向量和目标变量,给定一个已知的固定权重向量,并将每个训练示例写入网络流。
我们的消费应用程序将运行一个流式回归模型,对我们的模拟数据流进行训练和测试。我们的第一个示例消费者将简单地将其预测打印到控制台上。
创建流式数据生成器
数据生成器的操作方式类似于我们的产品事件生成器示例。回想一下第五章中的使用 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。
自定义转换器需要遵循以下步骤:
-
实现
transform
方法。 -
指定 inputCol 和 outputCol。
-
接受
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
代码清单:
管道的工作原理
我们运行一系列算法来处理和学习给定的数据集。例如,在文本分类中,我们将每个文档分割成单词,并将单词转换为数值特征向量。最后,我们使用这个特征向量和标签学习一个预测模型。
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
带有示例的机器学习管道
正如前几节讨论的那样,新的 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)
**决策树管道:**管道使用决策树估计器对 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
这里显示了 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
这里显示了 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
这里显示了 2 维散点图中预测的可视化:
以下显示了 2 维散点图中实际数据的可视化:
总结
在本章中,我们介绍了 Spark ML Pipeline 及其组件的基础知识。我们看到如何在输入 DataFrame 上训练模型,以及如何通过运行它们通过 spark ML 管道 API 来评估它们的性能,使用标准指标和度量标准。我们探讨了如何应用一些技术,如转换器和估计器。最后,我们通过在 Kaggle 的 StumbleUpon 数据集上应用不同的算法来调查管道 API。
机器学习是行业中的新星。它确实解决了许多业务问题和用例。我们希望我们的读者能够找到新的创新方式,使这些方法更加强大,并延伸了解支撑学习和智能的原则的旅程。有关机器学习和 Spark 的进一步练习和阅读,请参考www.kaggle.com
和databricks.com/spark/
。