Spark3-入门指南-四-

161 阅读14分钟

Spark3 入门指南(四)

原文:Beginning Apache Spark 3

协议:CC BY-NC-SA 4.0

七、高级 Spark 流

第六章介绍了流处理的核心概念、Spark 结构化流处理引擎的特性以及开发流应用的基本步骤。现实世界的流应用程序通常需要从大规模传入的实时数据中提取见解或模式,并将这些信息提供给下游应用程序,以做出业务决策或将这些信息保存在某个存储系统中,以供进一步分析或可视化。现实世界流应用程序的另一个方面是,它们持续运行以处理实时数据。因此,他们必须对失败有弹性。

本章的前半部分介绍了结构化流中的事件时间处理和有状态处理功能,以及它们如何帮助从传入的实时数据中提取洞察力或模式。本章的后半部分解释了结构化流提供的支持,以帮助流应用程序对故障具有容错能力,并监控它们的状态和进度。

事件时间

基于数据创建时间处理输入流数据的能力是任何严肃的流处理引擎必须具备的特性。这很重要,因为要真正理解并准确地从流数据中提取见解或模式。您需要基于数据或事件发生的时间来处理它们,而不是基于它们被处理的时间。通常,事件时间处理是在聚合的上下文中进行的,聚合包括事件时间和事件中的零个或多个附加信息。

让我们以第六章描述的移动动作事件为例。您可以在一个时间窗口内应用聚合,而不是在动作类型上应用聚合,该时间窗口可以是固定的或滑动的窗口类型(在第六章中描述)。此外,您可以轻松地将动作类型添加到分组键中,以便根据时间段和动作类型进一步对移动动作事件进行分组。

以下示例处理移动数据事件;清单 7-1 显示了它的模式。ts列表示事件创建的时间,换句话说,就是用户打开或关闭应用程序的时间。移动事件数据位于<path>/chapter6/data/mobile目录下,包含file1.jsonfile2.jsonfile3.jsonnewaction.json。清单 7-2 显示了每个文件中的内容。

// file1.json
{"id":"phone1","action":"open","ts":"2018-03-02T10:02:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:03:35"}
{"id":"phone3","action":"open","ts":"2018-03-02T10:03:50"}
{"id":"phone1","action":"close","ts":"2018-03-02T10:04:35"}

// file2.json
{"id":"phone3","action":"close","ts":"2018-03-02T10:07:35"}
{"id":"phone4","action":"open","ts":"2018-03-02T10:07:50"}

// file3.json
{"id":"phone2","action":"close","ts":"2018-03-02T10:04:50"}
{"id":"phone5","action":"open","ts":"2018-03-02T10:10:50"}

// newaction.json
{"id":"phone2","action":"crash","ts":"2018-03-02T11:09:13"}
{"id":"phone5","action":"swipe","ts":"2018-03-02T11:17:29"}

Listing 7-2Mobile Event Data in file1.json, file2.json, file3.json, newaction.json

mobileDataDF.printSchema

 |-- action: string (nullable = true)
 |-- id: string (nullable = true)
 |-- ts: timestamp (nullable = true)

Listing 7-1Mobile Data Event Schema

事件时间内的固定窗口聚合

固定窗口(也称为滚动窗口)操作基于窗口长度将传入数据流离散化为不重叠的桶。每个传入数据都根据其事件时间放入一个桶中。执行聚合就是遍历每个存储桶并应用聚合逻辑,无论是计数还是求和。图 7-1 说明了固定窗口聚合逻辑。

img/419951_2_En_7_Fig1_HTML.jpg

图 7-1

固定窗口操作

固定窗口聚合的一个例子是对每个 10 分钟长的固定窗口执行移动事件数量的计数聚合。窗口长度通常由特定用例的需求和数据量决定。通过聚合结果,您可以深入了解每个窗口生成的移动事件的比率。如果您对全天和每小时的移动使用感兴趣,那么 60 分钟的窗口长度可能更合适。清单 7-3 包含执行计数聚合和聚合结果的代码。正如所料,在列出的所有四个文件中只有十个移动数据事件,输出中的总计数与该数字相匹配。

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

val mobileDataSchema = new StructType()
                          .add("id", StringType, false)
                          .add("action", StringType, false)
                          .add("ts", TimestampType, false)

val mobileSSDF = spark.readStream.schema(mobileDataSchema)
                      .json("<path>/chapter6/data/input")

val windowCountDF = mobileSSDF.groupBy(
                                 window($"ts", "10 minutes"))
                              .count()

val mobileConsoleSQ = windowCountDF.writeStream.format("console")
                                   .option("truncate", "false")
                                   .outputMode("complete")
                                   .start()

// stop the streaming query

mobileConsoleSQ.stop

// output
+------------------------------------------------------+-------+
|                        window                        |  count|
+------------------------------------------------------+-------+
|            [2018-03-02 10:00:00, 2018-03-02 10:10:00]|      7|
|            [2018-03-02 10:10:00, 2018-03-02 10:20:00]|      1|
|            [2018-03-02 11:00:00, 2018-03-02 11:10:00]|      1|
|            [2018-03-02 11:10:00, 2018-03-02 11:20:00]|      1|
+------------------------------------------------------+-------+

windowCountDF.printSchema

 |-- window: struct (nullable = false)
 |    |-- start: timestamp (nullable = true)
 |    |-- end: timestamp (nullable = true)
 |-- count: long (nullable = false)

Listing 7-3Process Mobile Event Data with a 10 Minute Window

当使用窗口执行聚合时,输出窗口是一个结构类型,它包含开始和结束时间。

除了在groupBy转换中指定一个窗口之外,您还可以从事件本身指定额外的列。清单 7-4 使用窗口长度和动作执行聚合。这为每个窗口和动作类型的计数提供了额外的见解。只需对前面的示例做一点小小的修改就可以实现这一点。清单 7-4 只包含需要修改的行。

val windActDF= mobileSSDF.groupBy(
                    window($"ts", "10 minutes"), $"action").count

val windActDFSQ = windActDF.writeStream.format("console")
                           .option("truncate", "false")
                           .outputMode("complete")
                           .start()
// result
+----------------------------------------------+--------+-------+
|        window                                |  action|  count|
+----------------------------------------------+--------+-------+
|    [2018-03-02 10:00:00, 2018-03-02 10:10:00]|  close |      3|
|    [2018-03-02 11:00:00, 2018-03-02 11:10:00]|  crash |      1|
|    [2018-03-02 11:10:00, 2018-03-02 11:20:00]|  swipe |      1|
|    [2018-03-02 10:00:00, 2018-03-02 10:10:00]|  open  |      4|
|    [2018-03-02 10:10:00, 2018-03-02 10:20:00]|  open  |      1|
+----------------------------------------------+--------+-------+

// stop the query stream
windowActionCountSQ.stop()

Listing 7-4Process Mobile Event Data with a 10 Minute Window and Action Type

该结果表中的每一行都包含关于每个 10 分钟窗口中动作类型数量的信息。如果在某个窗口中有许多崩溃动作,那么如果在那个时间范围内有一个发布,那么这种洞察力是有用的。

事件时间内的滑动窗口聚合

除了固定窗口类型,还有一种开窗类型叫做滑动窗口。定义滑动窗口需要两条信息,窗口长度和滑动间隔,滑动间隔通常小于窗口长度。假设聚合计算在传入的数据流上滑动,结果通常比固定窗口类型的结果更平滑。因此,这种窗口类型通常用于计算移动平均值。关于滑动窗口需要注意的一件重要事情是,由于重叠,一条数据可能落入多个窗口,如图 7-2 所示。

img/419951_2_En_7_Fig2_HTML.jpg

图 7-2

固定窗口操作

为了说明传入数据的滑动窗口聚合,您使用了关于数据中心计算机机架温度的小型合成数据。想象一下,每一个计算机机架都以一定的间隔发出它的温度。您希望生成一份关于所有计算机机架和每个机架在 10 分钟窗口长度和 5 分钟滑动间隔内的平均温度的报告。这个数据集在<path>/chapter7/data/iot目录中,其中包含file1.jsonfile2.json。温度数据如清单 7-5 所示。

// file1.json
{"rack":"rack1","temperature":99.5,"ts":"2017-06-02T08:01:01"}
{"rack":"rack1","temperature":100.5,"ts":"2017-06-02T08:06:02"}
{"rack":"rack1","temperature":101.0,"ts":"2017-06-02T08:11:03"}
{"rack":"rack1","temperature":102.0,"ts":"2017-06-02T08:16:04"}

// file2.json
{"rack":"rack2","temperature":99.5,"ts":"2017-06-02T08:01:02"}
{"rack":"rack2","temperature":105.5,"ts":"2017-06-02T08:06:04"}
{"rack":"rack2","temperature":104.0,"ts":"2017-06-02T08:11:06"}
{"rack":"rack2","temperature":108.0,"ts":"2017-06-02T08:16:08"}

Listing 7-5Temperature Data of Two Racks

清单 7-6 首先读取温度数据,然后在ts列的滑动窗口上执行groupBy转换。对于每个滑动窗口,将avg()功能应用于温度栏。为了便于检查输出,它使用查询名iot将数据写出到内存数据接收器。然后,您可以对这个临时表发出 SQL 查询。

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

// define schema
val iotSchema = new StructType().add("rack", StringType, false)
                                .add("temperature",
                                      DoubleType, false)
                                .add("ts", TimestampType, false)

val iotSSDF = spark.readStream.schema(iotSchema)
                   .json("<path>/chapter7/data/iot")

// group by a sliding window and perform average on the temperature column
val iotAvgDF = iotSSDF.groupBy(window($"ts",
                                 10 minutes", "5 minutes"))
                      .agg(avg("temperature") as "avg_temp")

// write the data out to memory sink with query name as iot
val iotMemorySQ = iotAvgDF.writeStream.format("memory")
                          .queryName("iot")
                          .outputMode("complete")
                          .start()

// display the data in the order of start time
spark.sql("select * from iot")
     .orderBy($"window.start").show(false)

// output

+------------------------------------------------+-------------+
|                       window                   |     avg_temp|
+------------------------------------------------+-------------+
|      [2017-06-02 07:55:00, 2017-06-02 08:05:00]|         99.5|
|      [2017-06-02 08:00:00, 2017-06-02 08:10:00]|       101.25|
|      [2017-06-02 08:05:00, 2017-06-02 08:15:00]|       102.75|
|      [2017-06-02 08:10:00, 2017-06-02 08:20:00]|       103.75|
|      [2017-06-02 08:15:00, 2017-06-02 08:25:00]|        105.0|
+------------------------------------------------+-------------+

// stop the streaming query
iotMemorySQ.stop

Listing 7-6Average Temperature of All the Computer Racks over a Sliding Window

该输出显示了合成数据集中的五个窗口。注意,由于您在groupBy转换中指定的滑动间隔的长度,每个窗口的开始时间相隔五分钟。温度栏显示平均温度正在上升,这是令人担忧的。不清楚是所有计算机机架的温度都在升高,还是只有某些机架的温度在升高。

为了帮助识别哪些计算机机架,清单 7-7 将机架列添加到 groupBy 转换中,它只显示与清单 7-6 中不同的行。

// group by a sliding window and rack column
val iotAvgByRackDF = iotSSDF.groupBy(
                      window($"ts", "10 minutes", "5 minutes"),
                             $"rack")
                     .agg(avg("temperature") as "avg_temp")

// write out to memory data sink with iot_rack query name
val iotByRackConsoleSQ = iotAvgByRackDF.writeStream
                                       .format("memory")
                                       .queryName("iot_rack")
                                       .outputMode("complete")
                                       .start()

spark.sql("select * from iot_rack").orderBy($"rack",
                   $"window.start").show(false)

+------------------------------------------+-------+------------+
|                        window            |  rack |    avg_temp|
+------------------------------------------+-------+------------+
|[2017-06-02 07:55:00, 2017-06-02 08:05:00]|  rack1|        99.5|
|[2017-06-02 08:00:00, 2017-06-02 08:10:00]|  rack1|       100.0|
|[2017-06-02 08:05:00, 2017-06-02 08:15:00]|  rack1|      100.75|
|[2017-06-02 08:10:00, 2017-06-02 08:20:00]|  rack1|       101.5|
|[2017-06-02 08:15:00, 2017-06-02 08:25:00]|  rack1|       102.0|
|[2017-06-02 07:55:00, 2017-06-02 08:05:00]|  rack2|        99.5|
|[2017-06-02 08:00:00, 2017-06-02 08:10:00]|  rack2|       102.5|
|[2017-06-02 08:05:00, 2017-06-02 08:15:00]|  rack2|      104.75|
|[2017-06-02 08:10:00, 2017-06-02 08:20:00]|  rack2|       106.0|
|[2017-06-02 08:15:00, 2017-06-02 08:25:00]|  rack2|       108.0|
+------------------------------------------+-------+------------+

// stop query stream
iotByRackConsoleSQ.stop()

Listing 7-7Average Temperature of Each Rack over a Sliding Window

输出表清楚地显示机架 1 的平均温度低于 103,您应该关注的是机架 2。

聚集状态

前面使用事件时间和附加列对固定或滑动窗口执行聚合的示例显示了在 Spark 结构化流中执行常用和复杂的流处理操作是多么容易。虽然从使用的角度来看这似乎很容易,但结构流引擎和 Spark SQL 引擎都努力工作并协同工作,以便在执行流聚合时以容错的方式维护中间聚合结果。每当对流式查询执行聚合时,必须维护中间聚合状态。这种状态在键-值对结构中维护,类似于哈希映射,其中键是组名,值是中间聚合值。在前面按滑动窗口和机架 ID 聚合的示例中,关键字是窗口的开始和结束时间以及机架名称的组合值,该值是平均温度。

中间状态存储在 Spark 执行器的内存中版本化的键/值“状态存储”中。它被写入预写日志,该日志应该被配置为驻留在像 HDFS 这样的持久性存储系统上。在每个触发点读取并更新内存“状态存储”中的状态,然后写入预写日志。当 Spark 结构化流应用程序重启失败后,状态将从预写日志中恢复,并从该点继续。这种容错状态管理会在结构化流引擎中引起一些资源和处理开销。开销的数量与它需要维护的状态数量成正比。因此,将状态的数量保持在可接受的大小是很重要的;换句话说,政府的规模不应该无限扩大。

鉴于滑动窗口的性质,窗口的数量会无限增长。这意味着滑动窗口聚合要求中间状态无限增长,除非有办法删除不再更新的旧状态。这是使用一种叫做水印的技术完成的。

水印:限制状态并处理后期数据

水印是流处理引擎中常用的技术,用于处理最新数据并限制需要维护的状态数量。现实世界中的流数据经常因为网络拥塞、网络中断或移动设备等数据生成器不在线而无序或延迟到达。作为实时流应用程序的开发人员,理解在处理某个阈值之后到达的最新数据时的权衡决策是很重要的。换句话说,相对于其他数据,您认为大部分数据到达的可接受时间是多少?最有可能的是,前一个问题的答案是它取决于用例。将最新的数据丢在地上并忽略它们可能是可以接受的。

从结构化流的角度来看,水印是事件时间中的移动阈值,落后于迄今为止所见的最大事件时间。当新数据到达时,最大事件时间被更新,这导致水印移动。图 7-3 举例说明了水印定义为十分钟的情况。实线代表水印线。它落后于最大事件时间线,由虚线表示。每个矩形框代表一段数据,它的事件时间就在框的下面。事件时间为 10:07 的数据到达得稍晚一些,大约在 10:12;然而,它仍然落在 10:03 和 10:13 之间的阈值内。因此它被照常处理。事件时间为 10:15 的数据属于同一类别。事件时间为 10:04 的数据到达较晚,大约在 10:22,这低于水位线,因此被忽略,不进行处理。

img/419951_2_En_7_Fig3_HTML.jpg

图 7-3

用水印处理过期日期

指定水印的最大好处之一是使结构流引擎能够安全地删除比水印旧的聚合状态。执行聚合的生产流应用程序应该指定一个水印,以避免内存不足问题。毫无疑问,水印是处理实时流数据中混乱部分的必要工具。

结构化流使得将水印指定为流数据帧的一部分变得非常容易。您只需要向withWatermark API 提供两个数据,事件时间列和阈值,可以是秒、分钟或小时。为了演示水印的作用,您可以通过一个简单的例子来处理<path>/chapter7/data/mobile目录中的两个 JSON 文件,并将水印指定为 10 分钟。清单 7-8 显示了这两个文件中的数据。数据的设置使得file1.json文件中的每一行都落在自己的 10 分钟窗口内。file2.json文件中的第一行落在 10:20:00 到 10:30:00 的窗口中,尽管它到达得较晚,但它的时间戳仍在可接受的阈值内,因此它被处理。file2.json 文件中的最后一行是对最新数据的模拟,其时间戳在 10:10:00 到 10:20:00 窗口内,由于超出了水印阈值,因此将被忽略,不予处理。

// file1.json
{"id":"phone1","action":"open","ts":"2018-03-02T10:15:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:22:35"}
{"id":"phone3","action":"open","ts":"2018-03-02T10:33:50"}

// file2.json
{"id":"phone4","action":"open","ts":"2018-03-02T10:29:35"}
{"id":"phone5","action":"open","ts":"2018-03-02T10:11:35"}

Listing 7-8Mobile Event Data in Two JSON Files

为了模拟处理过程,首先在<path>/chapter7/data目录下创建一个名为input的目录。然后运行清单 7-9 中的代码。下一步是将file1.json文件复制到input目录并检查输出。最后一步是将file2.json文件复制到输入目录并检查输出。

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

val mobileSchema = new StructType().add("id", StringType, false)
                                .add("action", StringType, false)
                                .add("ts", TimestampType, false)

val mobileSSDF = spark.readStream.schema(mobileSchema)
                      .json("<path>/book/chapter7/data/input")

// setup a streaming DataFrame with a watermark and group by ts and action column.
val windowCountDF = mobileSSDF.withWatermark("ts", "10 minutes")
                              .groupBy(window($"ts",
                                     "10 minutes"), $"action")
                               .count

val mobileMemorySQ = windowCountDF.writeStream
                                  .format("console")
                                  .option("truncate", "false")
                                  .outputMode("update")
                                  .start()

// the output from processing filel1.json
// as expected each row falls into its own window
+----------------------------------------------+--------+-------+
|         window                               |  action|  count|
+----------------------------------------------+--------+-------+
|    [2018-03-02 10:20:00, 2018-03-02 10:30:00]|  open  |   1   |
|    [2018-03-02 10:30:00, 2018-03-02 10:40:00]|  open  |   1   |
|    [2018-03-02 10:10:00, 2018-03-02 10:20:00]|  open  |   1   |
+----------------------------------------------+--------+-------+

// the output from processing file2.json
// notice the count for window 10:20 to 10:30 is now updated to 2
// and there was no change to the window 10:10:00 and 10:20:00
+----------------------------------------------+--------+-------+
|                       window                 | action |  count|
+----------------------------------------------+--------+-------+
|    [2018-03-02 10:20:00, 2018-03-02 10:30:00]|  open  |   2   |
+----------------------------------------------+--------+-------+

Listing 7-9Code for Processing Mobile Data Events with Late Arrival

由于file2.json文件中最后一行的时间戳超出了 10 分钟的水印阈值,因此未对其进行处理。如果删除对 watermark API 的调用,输出看起来类似于清单 7-10 。窗口 10:10 和 10:20 的计数更新为 2。

+----------------------------------------------+--------+-------+
|                        window                |  action|  count|
+----------------------------------------------+--------+-------+
|    [2018-03-02 10:20:00, 2018-03-02 10:30:00]| open   |      2|
|    [2018-03-02 10:10:00, 2018-03-02 10:20:00]|  open  |      2|
+----------------------------------------------+--------+-------+

Listing 7-10Output of Removing the Call to Watermark API

水印是一个有用的特性,因此了解正确清理聚合状态的条件非常重要。

  • 输出模式不能是完整模式,必须是更新或追加模式。原因是完整模式的语义规定必须维护所有聚合数据,并且不能违反这些语义;水印不能掉任何中间状态。

  • 通过groupBy转换的聚合必须直接在事件时间列或事件时间列的窗口上进行。

  • 水印 API 和groupBy转换中指定的事件时间列必须是同一个。

  • 当设置一个流数据帧时,必须在groupBy转换之前调用水印 API 否则,它将被忽略。

任意状态处理

按键或事件窗口聚合的中间状态由结构化流自动维护。但是,并不是所有基于事件时间的处理都可以通过简单地在一个或多个列上进行聚合来满足,并且可以使用或不使用窗口。例如,当物联网温度数据集中连续三次出现大于 100 度的温度读数时,您希望发送警报、电子邮件或寻呼机。

维护用户会话是另一个例子,其中会话长度不是由固定的时间量决定的,而是由用户的活动和缺少活动决定的。为了解决这两个例子和类似的用例,您需要对每组数据应用任意的处理逻辑,控制每组数据的窗口长度,并在触发点之间保持任意的状态。这就是结构化流任意状态处理的用武之地。

具有结构化流的任意状态处理

为了实现灵活和任意的有状态处理,结构化流提供了回调机制。它负责确保以容错方式维护和存储中间状态。回调机制使您能够为自定义状态管理逻辑提供用户定义的函数,结构化流在适当的时候调用它。这种处理方式本质上归结为执行以下两个任务之一的能力,如图 7-4 所示。

  • 映射多组数据,对每组数据应用任意处理,并且每组只生成一行。

  • 映射数据组,对每个组应用任意处理,并为每个组生成任意数量的行,包括无行。

结构化流为每项任务提供了特定的 API。对于第一个任务,这个 API 叫做mapGroupsWithState ,,对于第二个任务,这个 API 叫做flatMapGroupsWithState。这些 API 从 Spark 2.2 开始可用,并且只在 Scala 和 Java 中可用。

img/419951_2_En_7_Fig4_HTML.jpg

图 7-4

两个任意状态处理任务的可视化描述

在使用回调机制时,清楚地理解框架和回调函数之间关于输入参数的约定,以及何时调用和多久调用一次是很重要的。在这种情况下,顺序如下。

  • 要在流数据帧上执行任意有状态处理,必须首先通过调用groupByKey转换指定分组,并提供 group by 列;然后它返回一个KeyValueGroupedDataset类的实例。

  • 从一个KeyValueGroupedDataset类的实例中,你可以调用mapGroupsWithState或者flatMapGroupsWithState函数。每个 API 需要一组不同的输入参数。

  • 调用mapGroupsWithState函数时,需要提供超时类型和自定义回调函数。超时部分稍后解释。

  • 调用flatMapGroupsWithState函数时,需要提供一个输出模式,超时类型,以及一个用户自定义的回调函数。输出模式和超时部分将在稍后解释。

以下是结构化流和用户定义的回调函数之间的约定。

  • 用户定义的回调函数在每个触发器中为每个组调用一次。在每次调用中,它意味着触发器中有数据的每个组。如果一个特定的组在触发器中没有任何数据,就没有调用。因此,您不应该假设在每个组的每个触发器中都调用了该函数。

  • 每次调用用户定义的回调函数时,都会传递以下信息。

    • 组密钥的值。

    • 一个组的所有数据;不能保证它们有任何特定的顺序。

    • 组的先前状态,由同一组的先前调用返回。组状态由名为GroupState的状态持有者类管理。当需要更新一个组的状态时,必须用新的状态调用这个类的 update 函数。用户定义的类定义了每个组的状态信息。调用更新函数时,提供的自定义状态不能为空。

正如你在第六章中了解到的,只要需要保持中间状态,就只允许某些输出模式。从 Spark 2.3 开始,调用 API mapGroupsWithState API 时只支持更新输出模式;但是,在调用flatMapGroupsWithState API 时,追加和更新模式都受支持。

处理状态超时

在使用水印的事件时间聚合的情况下,中间状态的超时由结构化流在内部管理,没有任何方法可以影响它。另一方面,结构化流的任意状态处理提供了控制中间状态超时的灵活性。因为您可以维护任意状态,所以应用程序逻辑控制中间状态超时以满足特定用例是有意义的。

结构流状态处理提供了三种不同的超时类型。第一个基于处理时间,第二个基于事件时间。超时类型是在全局级别配置的,这意味着它适用于特定流数据帧内的所有组。可以为每个单独的组配置超时时间,并且可以随意更改。如果中间状态配置了超时,那么在处理回调函数中给定的值列表之前检查它是否超时是很重要的。在某些用例中不需要超时,第三种超时类型就是为这种场景设计的。超时类型在GroupStateTimeout类中定义。您在调用mapGroupsWithStateflatMapGroupsWithState函数时指定类型。分别使用处理超时和事件超时的GroupState.setTimeoutDurationGroupState.setTimeoutTimeStamp功能指定超时持续时间。

敏锐的读者可能想知道当一个特定群体的中间状态超时时会发生什么。流提供的关于这种情况的契约结构是,它使用空值列表调用用户定义的回调函数,并将标志GroupState.hasTimedOut设置为 true。

在这三种超时类型中,事件时间超时是最复杂的一种,首先介绍。事件时间超时意味着它基于事件中的时间,因此需要通过DataFrame.withWatermark在流数据帧中设置水印。为了控制每个组的超时,您需要在处理特定组的过程中为GroupState.setTimeoutTimestamp函数提供一个时间戳值。当水印前进超过提供的时间戳时,组的中间状态超时。在用户会话化用例中,当用户与您的网站交互时,只需根据用户的最新交互时间加上某个阈值更新超时时间戳,会话就会延长。这确保了只要用户与您的网站交互,用户会话就保持活动,并且中间数据不会超时。

处理超时类型的工作方式类似于事件时间超时类型;不过不同的是,它是基于服务器的挂钟,是不断前进的。为了控制每个组的超时,您可以在处理特定组的过程中为GroupState.setTimeoutDuration函数提供一个持续时间。持续时间可以是一分钟、一小时或两天。当时钟前进超过规定的持续时间时,组的中间状态超时。由于这种超时类型取决于系统时钟,因此考虑时区变化或时钟偏差时的情况非常重要。

对于敏锐的读者来说,这可能是显而易见的,但重要的是要认识到,当流中暂时没有传入数据时,不会调用用户定义的回调函数。另外水印不前进,超时函数调用不发生。

至此,您应该对结构化流中的任意状态处理是如何工作的以及涉及到哪些 API 有了很好的理解。下一节将通过几个例子演示如何实现任意状态处理。

行动中的任意状态处理

本节通过两个用例演示了结构化流中的任意状态处理。

  • 第一个是关于从数据中心计算机机架温度数据中提取模式,并将每个机架的状态保持在中间状态。每当遇到连续三个 100 度或更高的温度时,机架状态就会升级到警告级别。这个例子使用了mapGroupsWithState API。

  • 第二个例子是用户会话化,它根据用户与网站的交互来跟踪用户状态。这个例子使用了flatMapGroupsWithState API。

不管哪个 API 执行任意状态处理,都需要一组通用的设置步骤。

  1. 定义几个类来表示输入数据、中间状态和输出。

  2. 定义两个函数。第一个是结构化流调用的回调函数。第二个功能包含对每组数据的任意状态处理逻辑以及维护状态的逻辑。

  3. 决定超时类型及其合适的值。

使用 mapGroupsWithState 提取模式

此使用案例旨在识别数据中心计算机机架温度数据中的特定模式。感兴趣的模式是来自同一机架的 100 度或更高的三个连续温度读数。两个连续高温读数之间的时间差必须在 60 秒以内。当检测到这种模式时,该机架的状态被升级为警告状态。如果下一个输入温度读数低于 100 度阈值,机架状态将降级为正常。

本例的数据在<path>/chapter7/data/iot_pattern目录中,该目录由三个文件组成,它们的内容如清单 7-11 所示。file1.json的内容显示rack1的温度在 100 度上下变化。file2.json文件显示 rack2 的温度正在上升。在file3.json文件中,rack3 正在升温,但温度读数相差超过一分钟。

// file1.json
{"rack":"rack1","temperature":99.5,"ts":"2017-06-02T08:01:01"}
{"rack":"rack1","temperature":100.5,"ts":"2017-06-02T08:02:02"}
{"rack":"rack1","temperature":98.3,"ts":"2017-06-02T08:02:29"}
{"rack":"rack1","temperature":102.0,"ts":"2017-06-02T08:02:44"}

// file2.json
{"rack":"rack1","temperature":97.5,"ts":"2017-06-02T08:02:59"}
{"rack":"rack2","temperature":99.5,"ts":"2017-06-02T08:03:02"}
{"rack":"rack2","temperature":105.5,"ts":"2017-06-02T08:03:44"}
{"rack":"rack2","temperature":104.0,"ts":"2017-06-02T08:04:06"}
{"rack":"rack2","temperature":108.0,"ts":"2017-06-02T08:04:49"}

// file3.json
{"rack":"rack2","temperature":108.0,"ts":"2017-06-02T08:06:40"}
{"rack":"rack3","temperature":100.5,"ts":"2017-06-02T08:06:20"}
{"rack":"rack3","temperature":103.7,"ts":"2017-06-02T08:07:35"}
{"rack":"rack3","temperature":105.3,"ts":"2017-06-02T08:08:53"}

Listing 7-11Temperature Data in file1.json, file2.json and file3.json

接下来,准备几个类和两个函数,将模式检测逻辑应用于前面的数据。对于这个用例,机架温度数据输入数据由RackInfo类表示,中间状态和输出由同一个名为RackState.的类表示。清单 7-12 显示了代码。

case class RackInfo(rack:String, temperature:Double,
                    ts:java.sql.Timestamp)


// notice the constructor arguments are defined to be modifiable so you can update them
// the lastTS variable is used to compare the time between previous and current temperature reading
case class RackState(var rackId:String, var highTempCount:Int,
                     var status:String,
                     var lastTS:java.sql.Timestamp)


Listing 7-12Scala Case Classes for the Input and Intermediate State

接下来,定义两个函数。第一个称为updateRackState,它包含了在一定时间内对三个连续温度读数进行事件模式检测的核心逻辑。第二个函数是updateAcrossAllRackStatus,它是传入mapGroupsWithState API 的回调函数。它确保根据事件时间的顺序处理机架温度读数。清单 7-13 是代码。

import org.apache.spark.sql.streaming.GroupState

// contains the main logic to detect the temperature pattern described above

def updateRackState(rackState:RackState, rackInfo:RackInfo) : RackState = {
   // setup the conditions to decide whether to update the rack state
   val lastTS = Option(rackState.lastTS).getOrElse(rackInfo.ts)
   val withinTimeThreshold = (rackInfo.ts.getTime -
                              lastTS.getTime) <= 60000
   val meetCondition = if (rackState.highTempCount < 1) true
                      else withinTimeThreshold
   val greaterThanEqualTo100 = rackInfo.temperature >= 100.0

  (greaterThanEqualTo100, meetCondition) match {
     case (true, true) => {
        rackState.highTempCount = rackState.highTempCount + 1
        rackState.status = if (rackState.highTempCount >= 3)
                           "Warning" else "Normal"
     }
     case _ => {
       rackState.highTempCount = 0
       rackState.status = "Normal"
     }
   }
   rackState.lastTS = rackInfo.ts
   rackState

}

// call-back function to provide mapGroupsWithState API
def updateAcrossAllRackStatus(rackId:String,
                              inputs:Iterator[RackInfo],
             oldState: GroupState[RackState]) : RackState = {

   // initialize rackState with previous state if exists, otherwise create a new state
   var rackState = if (oldState.exists) oldState.get
                   else RackState(rackId, 5, "", null)

   // sort the inputs by timestamp in ascending order
   inputs.toList.sortBy(_.ts.getTime).foreach( input => {
     rackState = updateRackState(rackState, input)
   // very important to update the rackState in the state holder class GroupState
     oldState.update(rackState)
   })
   rackState
}

Listing 7-13the Functions for Performing Pattern Detection

设置步骤现在已经完成,现在您将回调函数连接到清单 7-14 中的结构化流应用程序的mapGroupsWithState中。模拟流数据的步骤与前面的示例相似,如下所示。

  1. 在<目录下创建一个名为input的目录。如果该目录已经存在,请删除该目录中的所有文件。

  2. 运行清单 7-14 中的代码。

  3. file1.json复制到输入目录,然后观察输出。对file2.jsonfile3.json重复相同的步骤。

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode}
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._

// schema for the IoT data
val iotDataSchema = new StructType()
                         .add("rack",StringType, false)
                         .add("temperature", DoubleType, false)
                         .add("ts", TimestampType, false)

val iotSSDF = spark.readStream.schema(iotDataSchema)
                   .json("<path>/chapter7/data/input")

val iotPatDF = iotSSDF.as[RackInfo].groupByKey(_.rack)
                      .mapGroupsWithState[RackState,RackState]
         (GroupStateTimeout.NoTimeout)(updateAcrossAllRackStatus)

// setup the output and start the streaming query
val iotPatternSQ = iotPatDF.writeStream.format("console")
                           .outputMode("update")
                           .start()

// after file3.json is copied over to "input" directory, run the line below stop the streaming query
iotPatternSQ.stop

// the output after processing file1.json
+--------+---------------------+----------+---------------------+
|  rackId|        highTempCount|    status|               lastTS|
+--------+---------------------+----------+---------------------+
|   rack1|                    1|    Normal|  2017-06-02 08:02:44|
+--------+---------------------+----------+---------------------+

// the output after processing file2.json
+--------+---------------------+-----------+--------------------+
|  rackId|        highTempCount|     status|              lastTS|
+--------+---------------------+-----------+--------------------+
|   rack1|                    0|     Normal| 2017-06-02 08:02:59|
|   rack2|                    3|    Warning| 2017-06-02 08:04:49|
+--------+---------------------+-----------+--------------------+

// the output after processing file3.json
+--------+---------------------+----------+---------------------+
|  rackId|        highTempCount|    status|               lastTS|
+--------+---------------------+----------+---------------------+
|   rack3|                    1|    Normal|  2017-06-02 08:08:53|
|   rack2|                    0|    Normal|  2017-06-02 08:06:40|
+--------+---------------------+----------+---------------------+

Listing 7-14Using Arbitrary State Processing to Detect Patterns in a Streaming Application

rack1 有几个温度读数超过 100 度;然而,它们不是连续的,因此输出状态处于正常水平。file2.json档中,rack2 连续三次温度读数超过 100 度,每一次与前一次的时间差距小于 60 秒;因此,机架 2 的状态处于警告级别。rack3 连续三次温度读数超过 100 度;但是每一个和之前的时间差距都在 60 秒以上;因此,其状态处于正常水平。

使用 flatMapGroupsWithState 的用户会话化

这个用例使用flatMapGroupsWithState API 执行用户会话化,它支持每组输出多行的能力。在本例中,会话化处理逻辑基于用户活动。当采取login动作时,创建一个会话。当采取logout动作时,会话结束。如果 30 分钟内没有用户活动,会话将自动结束。您可以利用超时特性来执行这种检测。每当会话开始或结束时,该信息都会发送到输出。输出信息包括用户 id、会话开始和结束时间以及访问的页面数量。

这个用例的数据在<path>/chapter7/data/sessionization目录中,它有三个文件。它们的内容如清单 7-15 所示。file1.json文件包含 user1 的活动,并且包含一个login动作,但是没有logout动作。file2.json文件包含用户 2 ,的所有活动,包括loginlogout动作。file3.json文件只包含用户 3 的login动作。设置三个文件中用户活动的时间戳,以便在处理file3.json时 user1 会话超时。到那时,user1 空闲的时间已经超过 30 分钟。

// file1.json
{"user":"user1","action":"login","page":"page1", "ts":"2017-09-06T08:08:53"}
{"user":"user1","action":"click","page":"page2", "ts":"2017-09-06T08:10:11"}
{"user":"user1","action":"send","page":"page3", "ts":"2017-09-06T08:11:10"}

// file2.json
{"user":"user2","action":"login", "page":"page1", "ts":"2017-09-06T08:44:12"}
{"user":"user2","action":"view", "page":"page7", "ts":"2017-09-06T08:45:33"}
{"user":"user2","action":"view", "page":"page8", "ts":"2017-09-06T08:55:58"}
{"user":"user2","action":"view", "page":"page6", "ts":"2017-09-06T09:10:58"}
{"user":"user2","action":"logout","page":"page9", "ts":"2017-09-06T09:16:19"}

// file3.json
{"user":"user3","action":"login", "page":"page4", "ts":"2017-09-06T09:17:11"}

Listing 7-15User Activity Data

接下来,准备几个类和两个函数,将用户会话逻辑应用到前面的数据。对于这个用例,用户活动输入数据由UserActivity类表示。用户会话数据的中间状态由UserSessionState类表示,UserSessionInfo 类表示用户会话输出。这三个类的代码如清单 7-16 所示。

case class UserActivity(user:String, action:String,
                        page:String, ts:java.sql.Timestamp)


// the lastTS field is for storing the largest user activity timestamp and this information is used
// when setting the timeout value for each user session
case class UserSessionState(var user:String, var status:String,
                            var startTS:java.sql.Timestamp,
                            var endTS:java.sql.Timestamp,
                            var lastTS:java.sql.Timestamp,
                            var numPage:Int)


// the end time stamp is filled when the session has ended.
case class UserSessionInfo(userId:String, start:java.sql.Timestamp, end:java.sql.Timestamp,  numPage:Int)


Listing 7-16Scala Case Classes for Input, Intermediate State, and Output

接下来,定义两个函数。第一个叫做updateUserActivity,负责根据用户活动更新用户会话状态。它还根据用户操作和最新的活动时间戳更新会话开始或结束时间。第二个函数叫做updateAcrossAllUserActivities .,它是传递给flatMapGroupsWithState函数的回调函数。该职能有两个主要职责。第一个是处理中间会话状态的超时,当出现这种情况时,它更新用户会话结束时间。另一个职责是确定何时向输出发送什么内容。期望的输出是在用户会话开始时发出一行,在用户会话结束时发出另一行。清单 7-17 是这两个函数的逻辑。

import org.apache.spark.sql.streaming.GroupState
import scala.collection.mutable.ListBuffer

def updateUserActivity(userSessionState:UserSessionState, userActivity:UserActivity) : UserSessionState = {
    userActivity.action match {
      case "login" => {
        userSessionState.startTS = userActivity.ts
        userSessionState.status = "Online"
      }
      case "logout" => {
        userSessionState.endTS = userActivity.ts
        userSessionState.status = "Offline"
      }
      case _ => {
        userSessionState.numPage += 1
        userSessionState.status = "Active"
      }
    }

    userSessionState.lastTS = userActivity.ts
    userSessionState
}

def updateAcrossAllUserActivities(user:String,
                     inputs:Iterator[UserActivity],
                     oldState: GroupState[UserSessionState]) :
                     Iterator[UserSessionInfo] = {

   var userSessionState = if (oldState.exists) oldState.get
                          else UserSessionState(user, "",
    new java.sql.Timestamp(System.currentTimeMillis), null, null, 0)

   var output = ListBuffer[UserSessionInfo]()

   inputs.toList.sortBy(_.ts.getTime).foreach( userActivity => {
     userSessionState = updateUserActivity(userSessionState,
                           userActivity)
                           oldState.update(userSessionState)

     if (userActivity.action == "login") {

       output += UserSessionInfo(user, userSessionState.startTS,
                                 userSessionState.endTS, 0)
     }
   })

   val sessionTimedOut = oldState.hasTimedOut
   val sessionEnded = !Option(userSessionState.endTS).isEmpty
   val shouldOutput = sessionTimedOut || sessionEnded

   shouldOutput match {
    case true => {
        if (sessionTimedOut) {
            userSessionState.endTS =
new java.sql.Timestamp(oldState.getCurrentWatermarkMs)
        }
        oldState.remove()
        output += UserSessionInfo(user, userSessionState.startTS,
                                  userSessionState.endTS,
                                  userSessionState.numPage)

    }
    case _ => {
      // extend sesion
      oldState.update(userSessionState)             oldState.setTimeoutTimestamp(userSessionState.lastTS.getTime,
"30 minutes")
    }
   }

   output.iterator
}

Listing 7-17the Functions for Performing User Sessionization

一旦设置步骤完成,下一步是将回调函数连接到结构化流应用程序中的flatMapGroupsWithState函数,如清单 7-18 所示。这个示例利用了超时特性,因此需要设置一个水位标志和事件时间超时类型。以下是模拟流数据的步骤。

  1. <path>/chapter7/data目录下创建一个名为input的目录。如果该目录已经存在,请确保删除该目录中的所有现有文件。

  2. 运行清单 7-17 中所示的代码。

  3. 将 file1.json 复制到输入目录,然后观察输出。对file2.jsonfile3.json重复这些步骤。

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode}
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._

val userActivitySchema = new StructType()
                              .add("user", StringType, false)
                              .add("action", StringType, false)
                              .add("page", StringType, false)
                              .add("ts", TimestampType, false)

val userActivityDF = spark.readStream.schema(userActivitySchema)
                          .json("<path>/chapter7/data/input")

// convert to DataSet of type UserActivity
val userActivityDS = userActivityDF.withWatermark("ts", "30 minutes").as[UserActivity]

// specify the event-time timeout type and wire in the call-back function
val userSessionDS = userActivityDS.groupByKey(_.user)
     .flatMapGroupsWithState[UserSessionState,UserSessionInfo]
     (OutputMode.Append,GroupStateTimeout.EventTimeTimeout)
     (updateAcrossAllUserActivities)

// setup the output and start the streaming query
val userSessionSQ = userSessionDS.writeStream
                                 .format("console")
                                 .option("truncate",false)
                                 .outputMode("append")
                                 .start()

// only run this line of code below after done copying over file3.json
userSessionSQ.stop

// the output after processing file1.json
+--------+----------------------------+-----+-------------+
|  userId|              start         | end |      numPage|
+--------+----------------------------+-----+-------------+
|  user1 |         2017-09-06 08:08:53| null|      0      |
+--------+----------------------------+-----+-------------+

// the output after processing file2.json
+--------+------------------------+--------------------+--------+
|  userId|             start      |               end  | numPage|
+--------+------------------------+--------------------+--------+
|  user2 |     2017-09-06 08:44:12| null               |       0|
|  user2 |     2017-09-06 08:44:12| 2017-09-06 09:16:19|       3|
+--------+------------------------+--------------------+--------+

// the output after processing file3.json

+--------+--------------------+--------------------+------------+
|  userId|       start        |              end   |     numPage|
+--------+--------------------+--------------------+------------+
|  user1 | 2017-09-06 08:08:53| 2017-09-06 08:46:19|           2|
|  user3 | 2017-09-06 09:17:11| null               |           0|
+--------+--------------------+--------------------+------------+

Listing 7-18Using Arbitrary State Processing to Perform User Sessionization in a Streaming Application

在处理完file1.json中的用户活动后,输出中应该有一行。这是意料之中的,因为每当updateAcrossAllUserActivities函数在用户活动中看到一个login动作,它就会将一个UserSessionInfo类的实例添加到ListBuffer输出中。处理 file2.json 后的输出中有两行,一行是针对login动作的,另一行是针对logout动作的。现在,file3.json只包含 user3 的一个带有动作login的用户活动,但是输出包含两行。user1的行是检测到user1会话超时的结果,这意味着由于缺少活动,水印已经超过该特定会话的超时值。

如前两个用例所示,结构化流中的任意状态处理功能提供了灵活而强大的方法,可以在每个组上应用用户定义的处理逻辑,并完全控制向输出发送的内容和时间。

处理重复数据

在处理数据时,重复数据删除是一种常见的需求,在批处理中很容易做到这一点。由于流数据的无界性质,它在流处理中更具挑战性。当数据生产者多次发送相同的数据以应对不可靠的网络连接或传输故障时,流数据中的数据复制就会发生。

幸运的是,结构化流使流应用程序可以轻松地执行数据复制,因此这些应用程序可以通过在重复数据到达时将其丢弃来保证一次性处理。结构化流提供的数据复制功能可以与水印结合使用,也可以不与水印结合使用。需要记住的一点是,在不指定水印的情况下执行数据复制时,状态结构化流需要在流应用程序的整个生命周期内保持无限增长,这可能会导致内存不足的问题。使用水印,比水印旧的最新数据会被自动丢弃,以避免重复。

指示结构化流执行重复数据删除的 API 非常简单。它只有一个输入:用于唯一标识每一行的列名列表。这些列的值执行重复检测,结构化流将它们存储为一个状态。演示重复数据删除功能的示例数据与移动事件数据具有相同的模式。计数聚合基于对id列的分组。idts列都用作用户定义的重复数据删除键。本例的数据在<path>/chapter7/data/deduplication中。它包含两个文件:file1.jsonfile2.json。这些文件的内容显示在清单 7-19 中。

// file1.json - each line is unique in term of id and ts columns
{"id":"phone1","action":"open","ts":"2018-03-02T10:15:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:22:35"}
{"id":"phone3","action":"open","ts":"2018-03-02T10:23:50"}

// file2.json - the first two lines are duplicate of the first two lines in file1.json above
// the third line is unique
// the fourth line is unique, but it arrives late, therefore it will not be processed
{"id":"phone1","action":"open","ts":"2018-03-02T10:15:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:22:35"}
{"id":"phone4","action":"open","ts":"2018-03-02T10:29:35"}
{"id":"phone5","action":"open","ts":"2018-03-02T10:01:35"}

Listing 7-19Sample Data for the Data Duplication Example

为了模拟重复数据删除,首先在<path>/chapter7/data目录下创建一个名为input的目录。然后运行清单 7-20 中的代码。下一步是将file1.json文件复制到输入目录,并检查输出。最后一步是将file2.json文件复制到输入目录并检查输出。

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

val mobileDataSchema = new StructType()
                             .add("id", StringType, false)
                             .add("action", StringType, false)
                             .add("ts", TimestampType, false)

// mobileDataSchema is defined in previous example
val mobileDupSSDF = spark.readStream.schema(mobileDataSchema)
                      .json("<path>/chapter7/data/deduplication")

val windowCountDupDF = mobileDupSSDF.withWatermark("ts",
                                                   "10 minutes")
                                    .dropDuplicates("id", "ts")
                                    .groupBy("id").count

val mobileMemoryDupSQ = windowCountDupDF.writeStream
                                       .format("console")
                                     .option("truncate", "false")
                                     .outputMode("update")
                                     .start()
// output after copying file1.json to input directory
+---------+--------+
|   id    |   count|
+---------+--------+
|   phone3| 1      |
|   phone1| 1      |
|   phone2| 1      |
+---------+--------+

// output after coping file2.json to input directory
+---------+--------+
|   id    |  count |
+---------+--------+
|   phone4| 1      |
+---------+--------+

Listing 7-20Deduplicating Data Using dropDuplicates API

不出所料,file2.json复制到输入目录后,控制台只显示一行。前两行与 file1.json 中的前两行重复,所以被过滤掉了。最后一行的时间戳为 10:10,这被认为是晚数据,因为时间戳早于 10 分钟的水位线阈值。所以没有处理掉。

容错

开发流式应用程序并将其部署到生产环境中时,最重要的考虑因素之一是处理故障恢复。根据墨菲定律,任何可能出错的事情都会出错。机器会出故障,软件会有 bug。

幸运的是,结构化流提供了一种在出现故障时重启或恢复流应用程序的方法,它会从中断的地方继续运行。要利用这种恢复机制,您需要通过在设置流式查询时指定检查点位置,将流式应用程序配置为使用检查点和预写日志。理想情况下,检查点位置应该是一个可靠且容错的文件系统上的目录,比如 HDFS 或亚马逊 S3。结构流定期保存所有的进度信息,例如正在处理的数据的偏移量细节和到检查点位置的中间状态值。为流式查询指定检查点位置非常简单。您只需要向您的流查询添加一个选项,名称为checkpointLocation,值为目录名。列表 7-21 就是一个例子。

val userSessionSQ = userSessionDS.writeStream.format("console")
                                 .option("truncate",false)
               .option("checkpointLocation","/reliable/location")
               .outputMode("append")
               .start()

Listing 7-21Add the checkpointLocation Option to a Streaming Query

如果您查看指定的检查点位置,应该会看到以下子目录:commitsmetadataoffsetssourcesstats。这些目录中的信息特定于特定的流式查询;因此,每个都必须使用不同的检查点位置。

像大多数软件应用程序一样,流式应用程序会随着时间的推移而发展,因为需要改进处理逻辑或性能或修复错误。重要的是要记住这可能会如何影响保存在检查点位置的信息,并了解哪些更改被认为是安全的。概括地说,有两类变化。一个是对流式应用程序代码的更改,另一个是对 Spark 运行时的更改。

流式应用程序代码更改

检查点位置中的信息旨在对流式应用程序的变化具有一定的弹性。有几种变更被认为是不兼容的变更。第一个是改变聚合的方式,比如改变键列、添加更多的键列或者删除一个现有的键列。第二个是改变用于存储中间状态的类结构,例如,当一个字段被删除时,或者当一个字段的类型从字符串变为整数时。当重新启动期间检测到不兼容的更改时,结构化流会通过异常通知您。在这种情况下,您必须使用新的检查点位置,或者删除以前的检查点位置中的内容。

Spark 运行时间变化

检查点格式旨在向前兼容,以便流式应用程序可以跨 Spark 次要补丁版本或次要版本(即从 Spark 2.2.0 升级到 2.2.1 或从 Spark 2.2.x 升级到 2.3.x)从旧的检查点重新启动。唯一的例外是当有关键的错误修复时。当 Spark 引入不兼容的变化时,发行说明中清楚地记录了这一点,这很好。

如果由于不兼容问题而无法使用现有检查点位置启动串流应用程序,则需要使用新的检查点位置。您可能还需要为您的应用程序提供一些关于从中读取数据的偏移量的信息。

流式查询度量和监控

与其他长期运行的应用程序(如在线服务)一样,了解流式应用程序的进度、传入数据速率或中间状态消耗的内存量非常重要。结构化流提供了一些 API 来提取最近的执行进度,并提供了一种异步方式来监控流应用程序中的所有流查询。

流式查询度量

在任何时候,关于流式查询的最基本的有用信息是它的当前状态。您可以通过调用StreamingQuery.status函数以人类可读的格式检索和显示这些信息。返回的对象是类型StreamingQueryStatus,,它可以很容易地将状态信息转换成 JSON 格式。清单 7-22 展示了状态信息的一个例子。

// use a streaming query from the example above
userSessionSQ.status

// output
res11: org.apache.spark.sql.streaming.StreamingQueryStatus =
{
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,
  "isTriggerActive" : false
}

Listing 7-22Query Status Information in JSON Format

状态提供了关于流查询中正在发生的事情的非常基本的信息。要从最近的进展中获得更多的细节,比如传入数据速率、处理速率、水印、数据源的偏移量以及一些关于中间状态的信息,可以调用StreamingQuery.recentProgress函数。该函数返回一组StreamingQueryProgress实例,这些实例可以将信息转换成 JSON 格式。默认情况下,每个流式查询被配置为保留 100 个进度更新,这个数字可以通过更新名为spark.sql.streaming.numRecentProgressUpdates的 Spark 配置来更改。要查看最近的流查询进度,可以调用StreamingQuery.lastProgress函数。清单 7-23 展示了一个流查询进程的例子。

{
  "id" : "9ba6691d-7612-4906-b64d-9153544d81e9",
  "runId" : "c6d79bee-a691-4d2f-9be2-c93f3a88eb0c",
  "name" : null,
  "timestamp" : "2018-04-23T17:20:12.023Z",
  "batchId" : 0,
  "numInputRows" : 3,
  "inputRowsPerSecond" : 250.0,
  "processedRowsPerSecond" : 1.728110599078341,
  "durationMs" : {
    "addBatch" : 1548,
    "getBatch" : 8,
    "getOffset" : 36,
    "queryPlanning" : 110,
    "triggerExecution" : 1736,
    "walCommit" : 26
  },
  "eventTime" : {
    "avg" : "2017-09-06T15:10:04.666Z",
    "max" : "2017-09-06T15:11:10.000Z",
    "min" : "2017-09-06T15:08:53.000Z",
    "watermark" : "1970-01-01T00:00:00.000Z"
  },
  "stateOperators" : [ {
    "numRowsTotal" : 1,
    "numRowsUpdated" : 1,
    "memoryUsedBytes" : 16127
  } ],
  "sources" : [ {
    "description" : "FileStreamSource[file:<path>/chapter7/data/input]",
    "startOffset" : null,
    "endOffset" : {
      "logOffset" : 0
    },
    "numInputRows" : 3,
    "inputRowsPerSecond" : 250.0,
    "processedRowsPerSecond" : 1.728110599078341
  } ],
  "sink" : {
    "description" : "org.apache.spark.sql.execution.streaming.ConsoleSinkProvider@37dc4031"
  }
}

Listing 7-23Streaming Query Progress Details

在此流式处理进度状态中,有几个重要的关键指标需要注意。输入速率表示从输入源流入流式应用程序的输入数据量。处理速率告诉您流式应用程序处理传入数据的速度。在理想状态下,处理速率应该高于输入速率,如果不是这样,就需要考虑增加 Spark 集群中的节点数量。如果流应用程序通过groupBy转换隐式地维护状态,或者通过任意状态处理 API 显式地维护状态,那么注意stateOperators部分下的指标是很重要的。

Spark UI 在作业、阶段和任务级别提供了一组丰富的指标。流应用程序中的每个触发器都映射到 Spark UI 中的一个作业,在这里可以很容易地检查查询计划和任务持续时间。

Note

流式查询状态和进度详细信息可通过流式查询的实例获得。当流式应用程序在生产中运行时,您无权访问这些流式查询。如果您想从远程主机上看到这些信息,该怎么办呢?一种选择是在您的流应用程序中嵌入一个小型 HTTP 服务器,并公开几个简单的 URL 来检索这些信息。

通过回调监控流式查询

结构化流提供了一种回调机制,用于在流应用程序中异步接收流查询的事件和进度。这是通过注册一个StreamingQueryListener接口的实现来完成的。这个接口定义了几个回调方法来接收关于流查询的状态,比如什么时候开始,什么时候有进展,什么时候终止。该接口的实现完全控制如何处理所提供的信息。实现的一个例子是将该信息发送到 Kafka 主题或其他发布-订阅系统进行离线分析,或者发送到其他流应用程序进行处理。清单 7-24 包含了StreamingQueryListener接口的一个非常简单的实现。它只是将信息打印到控制台。

import org.apache.spark.sql.streaming.StreamingQueryListener
import org.apache.spark.sql.streaming.StreamingQueryListener.{
                       QueryStartedEvent, QueryProgressEvent,
                       QueryTerminatedEvent}

class ConsoleStreamingQueryListener extends StreamingQueryListener {
  override def onQueryStarted(event: QueryStartedEvent): Unit = {
      println(s"streaming query started: ${event.id} -
                                 ${event.name} - ${event.runId}")
  }

override def onQueryProgress(event: QueryProgressEvent): Unit = {
      println(s"streaming query progress: ${event.progress}")
}

override def onQueryTerminated(event: QueryTerminatedEvent): Unit = {
     println(s"streaming query terminated: ${event.id} -
                                           ${event.runId}")
  }
}

Listing 7-24a Simple Implementation of StreamingQueryListener Interface

一旦实现了StreamingQueryListener,下一步就是向StreamQueryManager注册它,它可以处理多个侦听器。清单 7-25 展示了如何注册和注销一个监听器。

Val listener = new ConsoleStreamingQueryListener

// to register
spark.streams.addListener(listener)

// to unregister
spark.streams.removeListener(listener)

Listing 7-25Register and Unregister an Instance of StreamingQueryListener with StreamQueryManager

需要记住的一点是,每个侦听器都会接收流式应用程序中所有流式查询的流式查询事件。如果需要将事件处理逻辑应用于特定的流式查询,您可以使用流式查询名称来标识感兴趣的查询。

通过可视化用户界面监控流式查询

Spark 3.0 引入了一种新的简单方法,通过 Spark UI 的结构化流选项卡来监控所有流查询,如图 7-5 所示。可视化 UI 旨在帮助 Spark 应用程序开发人员在开发阶段对其结构化流应用程序进行故障排除,并深入了解实时指标。UI 显示两种不同的统计数据。

img/419951_2_En_7_Fig5_HTML.png

图 7-5

Spark UI 中的结构化流选项卡

  • 每个流式查询的摘要信息

  • 每个流式查询的统计信息,包括输入速率、处理速率、输入行数和批处理持续时间

流式查询摘要信息

一个结构化流应用程序可以有多个流查询,只要其中一个被启动,它就会在 Spark UI 的 Structured Streaming 选项卡中列出。活动的和已完成的流式查询都有摘要信息,但在单独的部分中。图 7-6 显示了两个流式查询的汇总信息。

摘要信息表包含每个流式查询的基本信息,包括查询名称、状态、ID、运行 ID、开始时间、查询持续时间和聚集统计信息,如平均输入速率和平均处理速率。流式查询可以处于以下状态之一:正在运行、已完成和失败。“错误”列包含有关失败查询的异常详细信息的有用信息。

img/419951_2_En_7_Fig6_HTML.png

图 7-6

包含流查询摘要信息的结构化流选项卡

通过单击运行 ID 列中的链接,可以查看特定流式查询的详细统计信息。

流式查询详细的统计信息

「串流查询统计数据」页面会显示有用的测量结果,让您深入了解串流应用程序的性能、健康状况和除错问题。图 7-7 显示了一个样本流查询的详细统计信息。

img/419951_2_En_7_Fig7_HTML.jpg

图 7-7

流式查询统计示例

以下部分描述了图 7-7 中所示的指标。将鼠标放在指标名称旁边的问号上,可以看到每个指标的简要说明。

  • 输入速率:数据流查询的所有输入源的数据到达速率。该比率显示为一个集合,即代表所有输入源的比率的单个值。

  • 处理速率:结构化流媒体引擎处理传入数据的事件处理速率。与前面的指标一样,它是所有输入源的集合。

  • 批次持续时间:每个微批次的加工持续时间

  • 操作持续时间:(在批处理环境中)执行各种操作所花费的时间,以毫秒为单位。

    • addBatch:读取、处理批处理的输出并将其写入接收器所需的时间。这将占用批处理持续时间的大部分时间。

    • getBatch:准备逻辑查询以读取输入所需的时间。

    • getOffset:查询输入源是否有新的输入数据所需的时间。

    • walCommit:将偏移量写入元数据日志所需的时间。

    • queryPlanning:生成执行计划所需的时间。

流式查询故障排除

有了流查询的详细指标,下一步就是利用它们来了解正在发生的事情以及要采取的措施,以提高结构化流应用程序的性能。本节讨论两种情况,并分享一些提高流式查询性能的建议。

第一种情况是当输入速率远高于处理速率度量时。这表明您的流式查询落后了,无法跟上数据生成者。以下是可以采取的一些措施。

  • 增加更多的执行资源,如增加执行者的数量

  • 或者增加分区数量以减少每个分区的工作量

第二种情况是当输入速率与过程速率度量大致相同,但是批处理持续时间度量相当高。这表明您的流式查询是稳定的,并且能够提供给数据生产者,但是处理每个批处理的延迟很高。以下是可以采取的一些措施。

  • 提高流式查询的并行性
    • 如果输入源是 Kafka,则增加 Kafka 分区的数量

    • 增加每个 Spark 执行器的内核数量

新的结构化流式用户界面提供了每个流式查询的汇总和详细统计信息。这有助于 Spark 开发人员深入了解其结构化流应用程序的性能,以便采取适当的措施来解决性能问题。

摘要

Spark 结构化流引擎提供了许多高级功能和构建复杂和精密流应用程序的灵活性。

  • 任何严肃的流处理引擎都必须支持在事件时间之前处理输入数据的能力。结构化流不仅支持做的能力,还支持基于固定和滑动窗口的窗口聚合。此外,它以容错方式自动保持中间状态。

  • 随着流式应用程序长时间处理越来越多的数据,维护中间状态会带来内存耗尽的风险。引入水印是为了更容易推理出最新的数据,并删除不再需要的中间状态。

  • 任意有状态处理允许以用户定义的方式处理每个组的值,并保持其中间状态。结构化流通过回调 API 提供了一种简单的方法,可以灵活地为每个组生成一行或多行输出。

  • 结构化流提供端到端的一次性保证。这是通过使用检查点和预写日志机制实现的。通过提供一个位于容错文件系统上的检查点位置,这两者都可以很容易地打开。通过读取保存在检查点位置的信息,流式应用程序可以轻松重启,并从故障前停止的地方继续运行。

  • 生产流应用程序需要能够洞察流查询的状态和指标。结构化流提供了流查询状态的简短摘要,以及有关传入数据速率、处理速率的详细指标,以及有关中间状态内存消耗的一些详细信息。为了监控流查询的生命周期和它们的详细进度,您可以注册一个或多个StreamingQueryListener接口的实例。Spark 3.0 中引入的新结构流 UI 提供了每个流查询的汇总和详细统计信息。

八、Spark 机器学习

近年来,围绕人工智能(AI)、机器学习(ML)和深度学习(DL)有很多令人兴奋的事情。人工智能专家和研究人员预测,人工智能将从根本上改变未来人类生活、工作和做生意的方式。对于世界各地的企业来说,人工智能是他们数字化转型之旅的下一步之一,一些企业在将人工智能纳入其商业战略方面取得了比其他企业更多的进展。企业希望人工智能能够高效快速地帮助解决他们的业务问题,并创造新的商业价值,以增加他们的竞争优势。像谷歌、亚马逊、微软、苹果和脸书这样的互联网巨头在投资、采用和将人工智能纳入其产品组合方面处于领先地位。2017 年,超过 150 亿美元的风险投资(VC)资金投资于全球人工智能相关的初创公司,预计这一趋势将持续下去。

人工智能是计算机科学的一个广阔领域,它试图让机器看起来像具有智能。帮助人类进步是一个大胆的目标。人工智能的一个子领域是机器学习,它专注于教会计算机在没有显式编程的情况下进行学习。学习过程包括使用算法从大量数据集中提取模式,并建立一个模型来解释世界。这些算法可以根据它们设计的任务分成不同的组。这些算法的一个共同特点是,它们通过优化内部参数的迭代过程来学习,以获得最佳结果。

深度学习(DL)是受人脑工作方式启发的机器学习方法之一,通过将复杂模式表示为嵌套的概念层次,它已被证明擅长从数据中学习复杂模式。随着大型和精选数据集的可用性与图形处理单元(GPU)的进步相结合,DL 已被证明可以有效地解决对象识别、图像识别、语音识别和机器翻译等领域的问题。在 ImageNet 图像分类挑战赛中,使用 DL 方法训练的计算机系统在图像分类方面击败了人类。这一成就和类似成就的含义是,现在计算机系统可以在与它们的创造者相同的水平上看到、识别物体和听到。图 8-1 说明了 AI、ML 和 DL 之间的关系以及它们的时间线。

img/419951_2_En_8_Fig1_HTML.jpg

图 8-1

AI、ML 和 DL 之间的关系及其时间线

创建 Spark 的动机之一是帮助应用程序大规模高效地运行迭代算法。在 Spark 的最近几个版本中,MLlib 库稳步增加了它的产品,通过提供一组常用的 ML 算法和一组工具来简化 ML 模型的构建和评估过程,使实用的 ML 变得可伸缩和简单。

为了理解 MLlib 库提供的特性,有必要对构建 ML 应用程序的过程有一个基本的了解。本章介绍了 MLlib 库中可用的特性和 API。

机器学习概述

本节提供了机器学习和 ML 应用程序开发过程的简要概述。这并不意味着详尽无遗;如果你已经熟悉机器学习,请随意跳过。

机器学习是一个广阔而迷人的研究领域,它结合了其他研究领域的部分内容,如数学、统计学和计算机科学。它教会计算机学习模式,并从历史数据中获得洞察力,通常用于决策或预测。与传统的硬编码软件不同,ML 只给你基于你提供的不完美数据的概率输出。向 ML 算法提供的数据越多,输出就越准确。ML 可以解决比传统软件有趣和困难得多的问题,并且这些问题不是特定于任何行业或商业领域的。相关领域的示例包括图像识别、语音识别、语言翻译、欺诈检测、产品推荐、机器人、自动驾驶汽车、加快药物发现过程、医疗诊断、客户流失预测、推荐等等。

鉴于人工智能的目标是让机器看起来像有智能,衡量这一点的最佳方式之一是通过比较机器智能和人类智能。

近几十年来,有几个众所周知的和公开的这种比较的例子。第一个是名为“深蓝”的计算机系统,它在 1997 年根据严格的比赛规则击败了世界象棋冠军。这个例子表明,在游戏中,计算机可以比人类思考得更快更好,有大量但有限的可能走法。

第二个是关于一个名叫沃森的计算机系统,它在 2011 年参加了一个 Jeopardy 游戏节目,与两位传奇冠军进行了比赛,赢得了 100 万美元的一等奖。这个例子表明,计算机可以理解特定问答结构中的人类语言,然后利用其庞大的知识库来开发概率答案。

第三个是关于一个名为 AlphGo 的计算机程序,它在 2016 年的一场历史性比赛中击败了一名世界冠军围棋选手。这个例子展示了人工智能领域进步的巨大飞跃。围棋是一种复杂的棋盘游戏,需要直觉、创造力和战略思维。执行穷举搜索移动是不可行的,因为它具有的可能移动的数量大于宇宙中的原子数量。

机器学习术语

在深入 ML 之前,学习这个领域的一些基本术语是很重要的。这在以后引用这些术语时很有帮助。为了更容易理解这些术语,在名为垃圾邮件分类的规范 ML 示例中提供了解释。

  • 观察是一个来自统计学领域的术语。一个观察是用于学习的实体的一个实例。例如,电子邮件被认为是观察。

  • 标签是标记观察值的值。例如,“垃圾邮件”或“非垃圾邮件”是用于标记电子邮件的两个可能值。

  • 特征是关于最有可能对预测输出产生最大影响的观察值的重要属性,例如,电子邮件发件人 IP 地址、字数和大写单词数。

  • 训练数据是训练 ML 算法以产生模型的观察值的一部分。通常的做法是将收集的数据分成三部分:训练数据、验证数据和测试数据。测试数据部分大约是原始数据集的 70%或 80%。

  • 验证数据是在模型调整过程中评估 ML 模型性能的一部分观察结果。

  • 测试数据是在调整过程完成后评估 ML 模型性能的一部分观察结果。

  • ML 算法是迭代运行的步骤的集合,用于从给定的测试数据中提取洞察或模式。ML 算法的主要目标是学习从输入到输出的映射。一套众所周知的 ML 算法可供您选择。挑战在于选择正确的算法来解决特定的 ML 问题。对于垃圾邮件检测问题,您可能会选择朴素贝叶斯算法。

  • 模型:ML 算法从给定的输入数据中学习后,产生一个模型。然后,使用模型对新数据执行预测或做出决策。一个模型用一个数学公式来表示。我们的目标是生成一个通用模型,它可以很好地处理以前没有见过的任何新数据。

图 8-2 最好地说明了 ML 算法、数据和模型之间的关系。

img/419951_2_En_8_Fig2_HTML.jpg

图 8-2

最大似然算法、数据和模型之间的关系

应用机器学习时要记住的一个要点是,永远不要用测试数据来训练 ML 算法,因为这违背了产生通用 ML 模型的目的。另一个需要注意的要点是,ML 是一个广阔的领域,当你深入这个领域时,你会发现更多的术语和概念。希望这些基本术语能够帮助你开始学习 ML 的旅程。

机器学习类型

ML 是教机器从数据中学习模式,以做出决策或预测。这些任务广泛适用于许多不同类型的问题,其中每个问题类型需要不同的学习方式。有三种学习类型,如图 8-3 所示。

img/419951_2_En_8_Fig3_HTML.jpg

图 8-3

不同的机器学习类型

监督学习

在三种不同的学习类型中,这一种被广泛使用并且更受欢迎,因为它可以帮助解决分类和回归中的一大类问题。

分类是将观察值分类到标签的离散或分类类别中。分类问题的例子包括预测电子邮件是否是垃圾邮件;产品评论是正面的还是负面的;图像是否包含狗、猫、海豚或鸟;一篇新闻文章的主题是关于体育、医学、政治还是宗教;特定手写数字是 1 还是 2;以及第四季度收入是否符合预期。当分类结果正好有两个离散值时,称为二元分类。当它有两个以上的离散值时,称为多类分类

回归是根据观察预测真实值。与分类不同,预测值不是离散的,而是连续的。回归问题的例子包括根据他们的位置和大小预测房价,根据一组人的背景和教育预测一个人的收入,等等。

这种类型的学习与其他类型的学习之间的一个关键区别因素是,训练数据中的每个观察必须包含一个标签,无论它是离散的还是连续的。换句话说,正确的答案被提供给算法,以通过迭代和递增地改进其对训练数据的预测来学习。一旦预测值和实际值之间达到可接受的误差范围,它就会停止。

区分分类和回归的一个简单的心理模型是,前者是关于将数据分成不同的桶,而后者是关于将最佳线拟合到数据。图 8-4 显示了这个心智模型的视觉表现。

img/419951_2_En_8_Fig4_HTML.jpg

图 8-4

分类和回归心理模型

设计了大量算法来解决分类和回归机器学习问题。本章涉及 Spark MLlib 组件中支持的一些,如表 8-1 中所列。

表 8-1

MLlib 中的监督学习算法

|

任务

|

算法

| | --- | --- | | 分类 | 逻辑回归决策图表随机森林梯度增强树线性支持向量机奈伊夫拜厄斯 | | 回归 | 线性回归广义线性回归决策树回归随机森林回归梯度推进回归 |

无监督学习

这种学习方法的名字意味着没有监督;换句话说,训练 ML 算法的数据不包含标签。这种学习类型旨在解决一类不同的问题,例如发现数据中隐藏的结构或模式,这取决于我们人类来解释这些见解背后的意义。其中一个隐藏的结构叫做聚类,对于在聚类内的观察值之间导出有意义的关系或相似性是有用的。图 8-5 描述了集群的例子。

img/419951_2_En_8_Fig5_HTML.jpg

图 8-5

聚类的可视化

事实证明,这种学习方法可以解决很多实际问题。假设有大量的文档,但事先不知道某个文档属于哪个主题。您可以使用无监督学习来发现相关文档的聚类,并从那里为每个聚类分配一个主题。无监督学习可以帮助解决的另一个有趣而常见的问题是信用卡欺诈检测。在将用户信用卡交易分组后,发现异常值并不太困难,这些异常值代表小偷偷走信用卡后的异常信用卡交易。表 8-2 列出了 Spark 中支持的无监督学习算法。

表 8-2

MLlib 中的无监督学习算法

|

任务

|

算法

| | --- | --- | | 使聚集 | k 均值潜在狄利克雷分配平分k——意思是高斯的 |

强化学习

与前两种类型的学习不同,这种学习不从数据中学习。相反,它通过一系列动作和接收到的反馈,从与环境的互动中学习。根据反馈,它做出调整,向最大化回报的目标靠近。换句话说,它从自己的经验中学习。

直到最近,这种学习方式还没有像前两种那样受到关注,因为除了电脑游戏之外,它还没有取得重大的实际成功。2016 年,谷歌 DeepMind 能够成功地应用这种学习类型来玩一场雅达利游戏,然后将其纳入其 AlphGo 程序,该程序在围棋比赛中击败了一名世界冠军。

此时,Spark MLlib 不包含任何强化学习算法。接下来的部分集中在前两种类型的学习。

Note

术语被监督隐喻性地指的是一个教师(人类)“监督”学习者,这就是 ML 算法,通过专门提供答案(标签)以及一组例子(训练数据)。

机器学习开发过程

为了有效地应用机器学习来开发智能应用程序,您应该考虑研究和采用大多数 ML 从业者遵循的一套最佳实践。有人说,有效地应用机器学习是一门手艺——一半是科学,一半是艺术。幸运的是,一个众所周知的结构化流程由一系列步骤组成,有助于提供合理的可重复性和一致性,如图 8-6 所示。

img/419951_2_En_8_Fig6_HTML.jpg

图 8-6

机器学习应用程序开发流程

这个过程的第一步是清楚地了解你认为 ML 可以帮助你的商业目标或挑战。评估 ML 的替代解决方案以了解成本和权衡是有益的。有时候,从简单的基于规则的解决方案开始会更快。如果有强有力的证据表明,ML 是高效、快速地交付有价值的商业见解的更好选择,那么您将进入下一步,即建立一套您和您的利益相关者都同意的成功度量标准。

成功指标从商业角度建立了 ML 项目的成功标准。它们是可衡量的,并且与商业成功直接相关。度量标准的例子是增加一定百分比的客户转化率,增加一定数量的广告点击率,增加一定数量的收入。成功指标也有助于决定何时因成本或未产生预期收益而放弃 ML 项目。

在成功度量被识别之后,下一步是识别和收集适当数量的数据来训练 ML 算法。收集的数据的质量和数量直接影响训练的 ML 模型的性能。需要记住的重要一点是,确保收集的数据代表了您试图解决的问题。短语“垃圾输入,垃圾输出”仍然适用于描述 ML 中的关键限制。

特色工程是这一过程中最重要也是最耗时的步骤之一。它主要是关于数据清洗和使用领域知识来识别观察值中的关键属性或特征,以帮助 ML 算法学习训练数据和提供的标签之间的直接关系。数据清理任务通常使用探索性数据分析框架来完成,以便从数据分布、相关性、异常值等方面更好地理解数据。这一步是一个昂贵的步骤,因为需要让人们参与进来,并使用他们的领域知识。DL 已经被证明是优于 ML 的学习方法,因为它可以自动提取特征而无需人工干预。

特征工程之后的下一步是选择合适的 ML 模型或算法并训练它。鉴于有许多可用的算法来解决类似的 ML 任务,问题是,使用什么模型是最好的?像大多数事情一样,决定最好的一个需要结合对手头问题的良好理解,对每个算法的各种特征的良好工作知识,以及在过去将它们应用于类似问题的经验。换句话说,在选择最佳算法时,一半是科学,一半是艺术。找到最佳算法需要一些实验。一旦选择了算法,就让它从特征工程步骤中产生的特征中学习。训练步骤的输出是一个模型,然后您可以继续执行一个模型评估,看看它的表现如何。导致这一步的所有先前步骤的目标是产生一个一般化的模型,意味着它在以前从未见过的数据上执行得有多好。

ML 开发过程中的另一个重要步骤是模型评估任务。这既是必要的,也是具有挑战性的。这一步不仅旨在回答模型性能如何的问题,还旨在知道何时停止调整模型,因为其性能已经达到了既定的成功标准。评估过程可以离线或在线完成。前一种情况是指使用训练数据评估模型,后一种情况是指使用生产数据或新数据评估模型。有一组常用的指标来理解模型性能:精度、召回、F1 分数和 AUC。

这一步的艺术部分是理解哪些指标适用于某些 ML 任务。模型性能结果决定了是继续生产部署步骤,还是返回到收集更多数据或不同类型数据的步骤。

这些信息旨在提供 ML 开发流程的概述,并不全面。很容易用一整章的时间来充分涵盖的内部细节和最佳实践。

Spark 机器学习库

本章的其余部分涵盖了 Spark MLlib 组件的主要特性,并提供了将 Spark 中提供的 ML 算法应用于以下每个 ML 任务的示例:分类、回归、聚类和推荐。

Note

在 Python 世界中,scikit-learn 是最受欢迎的开源机器学习库之一。它构建在 NumPy、SciPy 和 matplotlib 库之上。它提供了一套有监督和无监督的学习算法。它被设计成一个简单高效的库,是在单机上学习和练习机器学习的完美之作。当数据大小超过单台机器的存储容量时,就该切换到 Spark MLlib 了。

近年来,有许多可用的 ML 库可供选择来训练 ML 模型。在大数据时代,有两个理由选择 Spark MLlib 而不是其他选项。第一个是易用性。Spark SQL 提供了一种非常用户友好的方式来执行探索性数据分析。MLlib 库提供了一种构建、管理和持久化复杂 ML 管道的方法。第二个原因是关于大规模训练 ML。Spark 统一数据分析引擎和 MLlib 库的结合可以支持训练具有数十亿次观察和数千个特征的机器学习模型。

机器学习管道

ML 流程本质上是一个管道,由一系列按顺序运行的步骤组成。管道通常需要运行多次才能产生最佳模型。为了使实用的机器学习变得容易,Spark MLlib 提供了一组抽象来帮助简化数据清理的步骤,包括工程,训练模型,模型调整和评估,并将它们组织到一个管道中,以便于理解,维护和重复。管道概念的灵感来自 scikit-learn 库。

有四个主要的抽象来形成端到端的 ML 管道:转换器、估计器、评估器和管道。他们提供了一套标准接口,便于与另一位数据科学家合作并理解他的管道。图 8-7 描述了 ML 过程的核心步骤和 MLlib 提供的主要抽象之间的相似性。

img/419951_2_En_8_Fig7_HTML.jpg

图 8-7

ML 主要步骤和 MLlib 管道主要概念之间的相似性

这些抽象的一个共同点是,输入和输出的类型主要是数据帧,这意味着您需要将输入数据转换成数据帧来使用这些抽象。

Note

像 Spark 统一数据分析引擎中的其他组件一样,MLlib 正在切换到基于 DataFrame 的 API,以提供更加用户友好的 API,并利用 Spark SQL 引擎的优化。org.apache.spark.ml 包中提供了新的 API。第一个 MLlib 版本是在基于 RDD 的 API 上开发的,现在仍受支持,但只是处于维护模式。旧的 API 可以在 org.apache.spark.mllib 包中找到。一旦达到功能对等,那么基于 RDD 的 API 将被弃用。

变形金刚(电影名)

转换器被设计成通过在特征工程和模型评估步骤期间操纵一个或多个列来转换数据帧中的数据。转换过程是在构建由 ML 算法学习使用的特征的环境中进行的。这个过程通常包括添加或删除列(要素),将列值从文本转换为数值,或者规范化特定列的值。

在 MLlib 中使用 ML 算法有一个严格的要求;它们要求所有要素都是双精度数据类型,包括标注。

从技术角度来看,转换器有一个transform函数,它对输入列执行转换,结果存储在输出列中。输入列和输出列的名称可以在构造转换器的过程中指定。如果未指定,则使用默认的列名。图 8-8 描绘了变压器的样子;DF1 中的阴影列表示输入列。DF2 中较暗的阴影列表示输出列。

img/419951_2_En_8_Fig8_HTML.jpg

图 8-8

变压器输入和输出

每个列数据类型需要一组不同的数据转换器。MLlib 提供了大约 30 个变压器。表 8-3 列出了每种数据转换的各种转换器。

表 8-3

不同变压器类型的变压器

|

类型

|

变形金刚

| | --- | --- | | 一般 | SQL 转换器向量汇编器 | | 数字数据 | 斗式提升机量化分解器标准鞋匠 MixMaxScalerMaxAbsScaler 标准化者 | | 文本数据 | IndexToStringOneHotEncoder 令牌设备,雨令牌设备停用词去除器 NGram 哈希 |

本节讨论几种常见的变压器。

Binarizer转换器只是将一个或多个输入列的值转换成一个或多个输出列。输出值为 0 或 1。小于或等于指定阈值的值在输出列中被转换为零。对于大于指定阈值的值,它们的值在输出列中被转换为 1。输入列类型必须是 double 或 VectorUDT。清单 8-1 将温度列的值转换成两个桶。

import org.apache.spark.ml.feature.Binarizer

val arrival_data = spark.createDataFrame(Seq(
                 ("SFO", "B737", 18, 95.1, "late"),
                 ("SEA", "A319", 5, 65.7, "ontime"),
                 ("LAX", "B747", 15, 31.5, "late"),
                 ("ATL", "A319", 14, 40.5, "late") ))
                 .toDF("origin", "model", "hour",
                       "temperature", "arrival")

val binarizer = new Binarizer().setInputCol("temperature")
                               .setOutputCol("freezing")
                               .setThreshold(35.6)

binarizer.transform(arrival_data).show

// show the current values of the parameters in binarizer transformer
binarizer.explainParams

inputCol: input column name (current: temperature)
outputCol: output column name (default: binarizer_60430bb4e97f__output, current: freezing)
threshold: threshold used to binarize continuous features (default: 0.0, current: 35.6)

// show the transformation result
binarizer.transform(arrival_data)
         .select("temperature", "freezing").show

+----------------+----------+
|     temperature|  freezing|
+----------------+----------+
|            95.1|       1.0|
|            65.7|       1.0|
|            31.5|       0.0|
|            40.5|       1.0|
+----------------+----------+

Listing 8-1Use Binarizer Transformer Convert Temperature into Two Buckets

Bucketizer transformer 是二进制化器的通用版本,它可以将列值转换成您选择的桶。控制桶的数量和每个桶的值的范围的方法是以双精度值数组的形式指定一个桶边界列表。在列的值是连续的,并且您希望将它们转换为分类值的情况下,此转换器非常有用。例如,您有一个包含居住在特定州的每个人的收入金额的列,并且您希望将他们的收入分成以下几个类别:高收入、中等收入和低收入。

值存储桶边界数组必须是 double 类型,并且必须遵守以下要求。

  • 最小的存储桶边界值必须小于数据帧中输入列的最小值。

  • 最大存储桶边界值必须大于数据帧中输入列的最大值

  • 输入数组中必须至少有三个桶边界,这将创建两个桶。

在一个人的收入中,很容易知道最小的收入额是 0。最小的桶边界值可以小于 0。当不可能预测最小列值时,可以指定负无穷大。同样,当无法预测最大列值时,则指定正无穷大。

清单 8-2 是使用这个转换器将温度列分成三个桶的例子,这意味着桶边界数组必须包含至少四个值。它按温度列排序,以便于查看。

import org.apache.spark.ml.feature.Bucketizer

val bucketBorders = Array(-1.0, 32.0, 70.0, 150.0)
val bucketer = new Bucketizer().setSplits(bucketBorders)
                               .setInputCol("temperature")
                               .setOutputCol("intensity")

val output = bucketer.transform(arrival_data)
                     .output.select("temperature", "intensity")
                     .orderBy("temperature")
                     .show

+----------------+-----------+
|     temperature|  intensity|
+----------------+-----------+
|            31.5|        0.0|
|            40.5|        1.0|
|            65.7|        1.0|
|            95.1|        2.0|
+----------------+-----------+

Listing 8-2Use Bucketizer Transformer Convert Temperature into Three Buckets

当处理数值分类值时,通常使用OneHotEncoder转换器。如果分类值是字符串类型,首先应用StringIndexer估计器,并将它们转换成数字类型。OneHotEncoder本质上是将一个数值分类值映射到一个二进制向量,以有目的地删除数值的隐式排序。列表 8-3 代表学生专业,其中每个专业被分配一个序号值,这表明某个专业高于其他专业。该转换器将序数值转换成向量,以在 ML 训练步骤中消除这种非预期的偏差。清单 8-3 是使用这个变压器的一个例子。

import org.apache.spark.ml.feature.OneHotEncoder

val student_major_data = spark.createDataFrame(
                               Seq(("John", "Math", 3),
                                   ("Mary", "Engineering", 2),
                                   ("Jeff", "Philosophy", 7),
                                   ("Jane", "Math", 3),
                                   ("Lyna", "Nursing", 4) ))
                              .toDF("user", "major", "majorIdx")

val oneHotEncoder = new OneHotEncoder().setInputCol("majorIdx")
                                       .setOutputCol("majorVect")

oneHotEncoder.transform(student_major_data).show()

+------+---------------+------------+----------------+
|  user|          major|    majorIdx|       majorVect|
+------+---------------+------------+----------------+
|  John|           Math|           3|   (7,[3],[1.0])|
|  Mary|    Engineering|           2|   (7,[2],[1.0])|
|  Jeff|     Philosophy|           7|       (7,[],[])|
|  Jane|           Math|           3|   (7,[3],[1.0])|
|  Lyna|        Nursing|           4|  ( 7,[4],[1.0])|
+------+---------------+------------+----------------+

Listing 8-3Use OneHotEncoder Transformer the Ordinal Value of the Categorical Values

处理字符串分类值时的另一个常见需求是将它们转换为序数值,这可以使用 StringIndexer 估计器来完成。这个估计器在“估计器”一节中描述。

有许多有趣的机器学习用例,其中输入是自由格式的文本。它需要一些转换来将自由形式的文本转换成数字表示,以便 ML 算法可以使用它。其中包括标记化和统计词频。

最有可能的是,你可以猜到Tokenizer转换器是做什么的。它对由空格分隔的单词字符串执行标记化,并返回单词数组。如果分隔符不是空格,那么您可以使用带有指定分隔符的RegexTokenizer。清单 8-4 是使用Tokenizer变压器的一个例子。

import org.apache.spark.ml.feature.Tokenizer
import org.apache.spark.sql.functions._

val text_data = spark.createDataFrame(Seq(
             (1, "Spark is a unified data analytics engine"),
             (2, "It is fun to work with Spark"),
             (3, "There is a lot of exciting sessions at upcoming
                    Spark summit"),
             (4, "mllib transformer estimator evaluator
                    and pipelines"))).toDF("id", "line")

val tokenizer = new Tokenizer().setInputCol("line")
                               .setOutputCol("words")

val tokenized = tokenizer.transform(text_data)

tokenized.select("words")
         .withColumn("tokens", size(col("words")))
         .show(false)

+-----------------------------------------------------------------+-------+
|        words                                                    | tokens|
+-----------------------------------------------------------------+-------+
|[spark, is, a, unified, data, analytics, engine]                 |      7|
|[spark, is cool, and, it, is, fun, to, work, with,               |     11|
|[there, is, a, lot, of, exciting, sessions, at, upcoming, spark, summit] |      11|
|[mllib, transformer, estimator, evaluator, and, pipelines]           |      6|
+-----------------------------------------------------------------+-------+

Listing 8-4Use Tokenizer Transformer to Perform Tokenization

停用词是语言中常用的词。在自然语言处理或机器学习的背景下,停用词往往会添加不必要的噪音,不会添加任何有意义的贡献。因此,它们通常在标记化步骤后立即被删除。StopWordsRemover transformer 旨在帮助这一努力。

从 Spark 2.3 版本开始,Spark 发行版中包含了以下语言的停用词:丹麦语、荷兰语、英语、芬兰语、法语、德语、匈牙利语、意大利语、挪威语、葡萄牙语、俄语、西班牙语、瑞典语和土耳其语。它被设计得很灵活,所以你可以从一个文件中提供一组停用词。

要在特定语言中使用停用词,首先要调用StopWordsRemover.loadDefaultStopWords(<language in lower case>)来加载它们,并将它们提供给StopWordsRemover的实例。此外,您可以请求该转换器执行不区分大小写的停用词过滤。清单 8-5 是使用StopWordsRemover转换器删除英语停用词的一个例子。

import org.apache.spark.ml.feature.StopWordsRemover

val enSWords = StopWordsRemover.loadDefaultStopWords("english")

val remover = new StopWordsRemover().setStopWords(enSWords)
                                    .setInputCol("words")
                                    .setOutputCol("filtered")

// use the tokenized from Listing 8-5 example
val cleanedTokens = remover.transform(tokenized)

cleanedTokens.select("words","filtered").show(false)

Listing 8-5Use StopWordsRemover Transformer to Remove English Stop Words

img/419951_2_En_8_Figa_HTML.jpg

HashingTF转换器通过计算每个单词的频率,将单词集合转换成数字表示。通过应用名为 MurmurHash 3 的哈希函数,每个单词都被映射到一个索引中。这种方法是有效的,但是它遭受潜在的散列冲突,这意味着多个单词可能映射到同一个索引。最小化冲突的一种方法是以 2 的幂指定大量的桶,以均匀地分布字。清单 8-6 将来自清单 8-5 的过滤后的柱送入HashingTF变压器。

import org.apache.spark.ml.feature.HashingTF

val tf = new HashingTF().setInputCol("filtered")
                        .setOutputCol("TFOut")
                        .setNumFeatures(4096)

val tfResult = tf.transform(cleanedTokens)

tfResult.select("filtered", "TFOut").show(false)

Listing 8-6Use HashingTF Transformer to Transform Words into Numerical Representation Via Hashing and Counting

img/419951_2_En_8_Figb_HTML.jpg

本节讨论的最后一个转换器是VectorAssembler,它将一组列组合成一个向量列。在机器学习术语中,这相当于将单个特征组合成单个向量特征,供 ML 算法学习。单个输入列的类型必须是以下类型之一:数值、布尔或向量类型。输出向量列包含按指定顺序排列的所有列的值。这个变换器实际上用在每一个 ML 流水线中,它的输出被传递到一个估计器中。清单 8-7 是使用VectorAssembler变压器的一个例子。

import org.apache.spark.ml.feature.VectorAssembler

val arrival_features  = spark.createDataFrame(Seq(
                                          (18, 95.1, true),
                                           (5, 65.7, true),
                                           (15, 31.5, false),
                                           (14, 40.5, false) ))
                          .toDF("hour", "temperature", "on_time")

val assembler = new VectorAssembler().setInputCols(
                       Array("hour", "temperature", "on_time"))
                                     .setOutputCol("features")

val output = assembler.transform(arrival_features)
output.show

+-----+-----------------+-----------+-------------------+
| hour|      temperature|    on_time|           features|
+-----+-----------------+-----------+-------------------+
|   18|             95.1|       true|    [18.0,95.1,1.0]|
|    5|             65.7|       true|     [5.0,65.7,1.0]|
|   15|             31.5|      false|    [15.0,31.5,0.0]|
|   14|             40.5|      false|    [14.0,40.5,0.0]|
+-----+-----------------+-----------+-------------------+

Listing 8-7Use VectorAssembler Transformer to Combines Features into a Vector Feature

为了方便一次转换多个列,Spark 版本增加了对这些转换器的支持:BinarizerStringIndexerStopWordsRemover。清单 8-8 显示了一个用Binarizer转换器.转换多个列的小例子,您可以选择指定一个或多个阈值。如果指定了单个阈值,那么它将用于所有输入列。如果指定了多个阈值,则第一个阈值用于第一个输入列,依此类推。

import org.apache.spark.ml.feature.Binarizer

val temp_data = spark.createDataFrame(
                      Seq((65.3,95.1),(60.7,99.1),
                          (75.3, 105.3)))
                     .toDF("morning_temp", "night_temp")

val temp_bin = new Binarizer()
           .setInputCols(Array("morning_temp", "night_temp"))
           .setOutputCols(Array("morning_oput","night_out"))
           .setThresholds(Array(65,96))

temp_bin.transform(temp_data).show

+------------+----------+------------+---------+
|morning_temp|night_temp|morning_oput|night_out|
+------------+----------+------------+---------+
|        65.3|      95.1|         1.0|      0.0|
|        60.7|      99.1|         0.0|      1.0|
|        75.3|     105.3|         1.0|      1.0|
+------------+----------+------------+---------+

Listing 8-8Transforming Multiple Columns With Binarizer Transformer

了解转换器如何工作以及 MLlib 中可用的转换器在 ML 开发过程的特性工程步骤中起着重要的作用。通常,VectorAssembler 转换器的输出由估计器消耗,这将在下一节讨论。

估计量

估计器是对 ML 学习算法或任何其他对数据进行操作的算法的抽象。一个估计量可以是两种算法中的一种,这是相当令人困惑的。第一种类型的一个例子是称为LinearRegression的 ML 算法,它用于预测房价的回归任务。第二种算法的一个例子是StringIndexer,它将分类值编码成索引。每个分类值的索引值基于它在数据帧的整个输入列中出现的频率。在高层次上,这种估计器将一列的值转换成另一列的值;然而,它需要在整个数据帧上通过两次才能产生预期的输出。

从技术角度来看,估计器有一个fit函数,它在输入列上应用一个算法。产生的结果封装在一个名为Model的对象类型中,这是一个Transformer类型。输入列和输出列的名称可以在构造估计器的过程中指定。图 8-9 描述了估计器的样子及其输入和输出。

img/419951_2_En_8_Fig9_HTML.jpg

图 8-9

估计量及其输入和输出

为了给这两种类型的估计器一个概念,表 8-4 提供了 MLlib 中可用估计器的子集。

表 8-4

MLlib 中可用估计量的样本

|

类型

|

估计量

| | --- | --- | | 机器学习算法 | 逻辑回归决策树分类器随机应变分类器线性回归随机森林回归量聚类皱胃向左移平分意味着 | | 数据转换算法 | 综合资料的文件(intergrated Data File)公式 StringIndexeronehotencoderestomator 口腔癌标准鞋匠 MixMaxScalerMaxAbsScalerWord2Vec |

下一节提供了一些在处理文本和数字数据时常用的估计量的例子。

RFormula是一个有趣的通用估计器,其中转换逻辑以声明方式表达。它可以处理数值和分类值,其输出是一个特征向量。MLlib 从 R 语言中借用了这个估计器的思想,它只支持 R 中可用的运算符的子集。表 8-5 中列出了基本的和支持的运算符。理解转换语言以充分利用RFormula估算器的灵活性和强大功能需要时间。

表 8-5

公式转换器中支持的运算符

|

操作员

|

描述

| | --- | --- | | ~ | 目标和术语之间的分隔符 | | + | 连接术语 | | - | 删除一个术语 | | : | 其他术语之间的相互作用创造了新的特征。乘法用于数值,二进制用于分类值。 | | 。 | 除目标之外的所有列 |

清单 8-9 将到达列和其余列中的标签指定为特性。此外,它使用小时和温度列之间的交互创建了一个新功能。因为这两列是数字类型,所以它们的值相乘。

import org.apache.spark.ml.feature.RFormula

val arrival_data = spark.createDataFrame(Seq(
                     ("SFO", "B737", 18, 95.1, "late"),
                     ("SEA", "A319", 5, 65.7, "ontime"),
                     ("LAX", "B747", 15, 31.5, "late"),
                     ("ATL", "A319", 14, 40.5, "late") ))
                         .toDF("origin", "model", "hour",
                               "temperature", "arrival")

val formula = new RFormula().setFormula(
                       "arrival ~ . + hour:temperature")
                            .setFeaturesCol("features")
                            .setLabelCol("label")

// call fit function first, which returns a model (type of transformer), then call transform
val output = formula.fit(arrival_data).transform(arrival_data)

output.select("*").show(false)

Listing 8-9Use RFomula Transformer to Create a Feature Vector

img/419951_2_En_8_Figc_HTML.jpg

在处理文本时,最常用的估计量之一是 IDF 估计量。它的名字是逆文档频率的首字母缩写。这个估计器通常在文本被标记化和计算出词频后立即使用。这个估计器背后的思想是通过计算每个单词出现在文档中的数量来计算它的重要性或权重。这种想法背后的直觉是,一个出现频率高、流行范围广的词不太重要;例如,单词。相反,仅在少数文档中出现频率高的单词表示更高的重要性;比如分类这个词。在数据帧的上下文中,文档指的是一行。敏锐的读者会发现,计算每个单词的重要性需要遍历每一行,因此 IDF 是一个估计器,而不是转换器。清单 8-10 将TokenizerHashingTF变压器与IDF估算器链接在一起。与变压器不同,估值器被急切地评估,这意味着当调用fit函数时,它触发一个 Spark 作业。

import org.apache.spark.ml.feature.Tokenizer
import org.apache.spark.ml.feature.HashingTF
import org.apache.spark.ml.feature.IDF

val text_data = spark.createDataFrame(Seq(
          (1, "Spark is a unified data analytics engine"),
          (2, "Spark is cool and it is fun to work with Spark"),
          (3, "There is a lot of exciting sessions at upcoming
               Spark summit"),
          (4, "mllib transformer estimator evaluator and
               pipelines")  )).toDF("id", "line")

val tokenizer = new Tokenizer().setInputCol("line")
                               .setOutputCol("words")

// the output column of the Tokenizer transformer is the input to HashingTF
val tf = new HashingTF().setInputCol("words")
                        .setOutputCol("wordFreqVect")
                        .setNumFeatures(4096)

val tfResult = tf.transform(tokenizer.transform(text_data))

// the output of the HashingTF transformer is the input to IDF estimator
val idf = new IDF().setInputCol("wordFreqVect")
                   .setOutputCol("features")

// since IDF is an estimator, call the fit function
val idfModel = idf.fit(tfResult)

// the returned object is a Model, which is of type Transformer
val weightedWords = idfModel.transform(tfResult)

weightedWords.select("label", "features").show(false)

weightedWords.printSchema

 |-- id: integer (nullable = false)
 |-- line: string (nullable = true)
 |-- words: array (nullable = true)
 |    |-- element: string (containsNull = true)
 |-- wordFreqVect: vector (nullable = true)
 |-- features: vector (nullable = true)

// the feature column contains a vector for the weight of each word, since it is long, the output is not included //below
weightedWords.select("wordFreqVect", "features").show(false)

Listing 8-10Use IDF Estimator to Compute the Weight of Each Word

当处理包含分类值的文本数据时,一个常用的估计器是StringIndexer估计器。它将类别值编码到基于其频率的索引中,使得最频繁的类别值的索引值为 0,依此类推。对于这个估计器来说,要得出一个分类值的索引值,它首先必须计算每个分类值的频率,最后给每个分类值分配一个索引值。为了执行计数和分配索引值,它必须从数据帧的开始到结束遍历输入列的所有值。如果输入列是数字,这个估计器在计算它的频率之前先转换它的字符串。

清单 8-11 提供了一个使用StringIndexer估算器对电影类型进行编码的例子。

import org.apache.spark.ml.feature.StringIndexer

val movie_data = spark.createDataFrame(Seq(
                                           (1, "Comedy"),
                                           (2, "Action"),
                                           (3, "Comedy"),
                                           (4, "Horror"),
                                           (5, "Action"),
                                           (6, "Comedy"))
                                     ).toDF("id", "genre")

val movieIndexer = new StringIndexer().setInputCol("genre")
                                      .setOutputCol("genreIdx")

// first fit the data
val movieIndexModel = movieIndexer.fit(movie_data)

// use returned transformer to transform the data
val indexedMovie = movieIndexModel.transform(movie_data)

indexedMovie.orderBy("genreIdx").show()

+---+-----------+------------+
| id|      genre|    genreIdx|
+---+-----------+------------+
|  3|     Comedy|         0.0|
|  6|     Comedy|         0.0|
|  1|     Comedy|         0.0|
|  5|     Action|         1.0|
|  2|     Action|         1.0|
|  4|     Horror|         2.0|
+---+-----------+------------+

Listing 8-11StringIndex Estimator to Encode Movie Genre

该估计器基于频率的降序来分配索引。这种默认行为可以很容易地更改为频率的升序。它支持另外两种排序类型:降序和升序。要更改默认的排序类型,只需用下列值之一调用setStringOrderType("<ordering type>")函数:frequencyDesc, frequencyAsc, alphabetDescalphabetAsc

在 Spark 版中,StringIndexer估算器可以支持对数据帧中的多列分类值进行编码。当有这样的需要时,您可以简单地调用setInputCols函数来指定要编码的输入列名,并通过调用setOutputCols函数来相应地指定输出列名。

import org.apache.spark.ml.feature.StringIndexer

val movie_data2 = spark.createDataFrame(Seq(
                                        (1, "Comedy", "G"),
                                        (2, "Action", "PG"),
                                        (3, "Comedy", "NC-17"),
                                        (4, "Horror", "PG-13"))
                                  ).toDF("id", "genre", "rating")

val movieIdx2 = new StringIndexer()
                   .setInputCols(Array("genre", "rating"))
                   .setOutputCols(Array("genreIdx", "ratingIdx"))

movieIdx2.fit(movie_data2)
         .transform(movie_data2)
         .orderBy('genreIdx)
         .show()

+---+------+------+--------+---------+
| id| genre|rating|genreIdx|ratingIdx|
+---+------+------+--------+---------+
|  3|Comedy| NC-17|     0.0|      1.0|
|  1|Comedy|     G|     0.0|      0.0|
|  2|Action|    PG|     1.0|      2.0|
|  4|Horror| PG-13|     2.0|      3.0|
+---+------+------+--------+--------+

Listing 8-12StringIndex Estimator to Encode Multiple Columns

在特定分类值存在于训练数据集中但不存在于测试数据集中的情况下。默认情况下,StringIndexer估计器抛出一个错误来指示这种情况。它提供了另外两种处理这种情况的方法。

  • 跳过:过滤掉无效数据的行

  • 保存:将无效数据放入专门的附加桶中

您可以通过为setHandleInvalid函数指定以下参数来说明您希望StringIndexer估计器如何处理这个场景:keepskiperror

处理分类值时另一个有用的估计器是OneHotEncoderEstimator,它将分类值的索引编码成一个二进制向量。从 Spark 版本 2.3.0 开始,OneHotEncoder transformer 已经被弃用,因为它在处理未知类别方面存在限制。该估计器通常与StringIndexer估计器结合使用,其中StringIndexer的输出成为该估计器的输入。清单 8-13 展示了两种估算器的用法。

import org.apache.spark.ml.feature.OneHotEncoderEstimator

// the input column genreIdx is the output column of StringIndex in listing 8-9
val oneHotEncoderEst = new OneHotEncoderEstimator().setInputCols(
                                  Array("genreIdx"))
                          .setOutputCols(Array("genreIdxVector"))

// fit the indexedMovie data produced in listing 8-10
val oneHotEncoderModel = oneHotEncoderEst.fit(indexedMovie)

val oneHotEncVect = oneHotEncoderModel.transform(indexedMovie)

oneHotEncVect.orderBy("genre").show()

+---+--------+------------+--------------------+
|id |  genre |    genreIdx|      genreIdxVector|
+---+--------+------------+--------------------+
| 5 | Action |    1.0     |      (2,[1],[1.0]) |
| 2 | Action |    1.0     |      (2,[1],[1.0]) |
| 3 | Comedy |    2.0     |       (2,[],[])    |
| 6 | Comedy |    2.0     |       (2,[],[])    |
| 1 | Comedy |    2.0     |       (2,[],[])    |
| 4 | Horror |    0.0     |       (2,[0],[1.0])|
+---+--------+------------+--------------------+

Listing 8-13OneHotEncoderEstimator Consumes the Output of the StringIndexer Estimator

在处理自由文本时,Word2Vec估算器很有用。它代表字到矢量。该估计器利用众所周知的单词嵌入技术,该技术将单词标记转换成数字向量表示,使得语义相似的单词被映射到附近的点。这种技术背后的直觉是,相似的单词往往一起出现,并且具有相似的上下文。换句话说,当两个不同的单词有非常相似的相邻单词时,那么它们很可能在意义上非常相似或者是相关的。这种技术已经在一些自然语言处理应用中被证明是有效的,例如单词类比、单词相似性、实体识别和机器翻译。

Word2Vec估算器有几种配置,需要提供适当的值来控制输出。表 8-6 描述了这些配置。

表 8-6

Word2Vec 配置

|

名字

|

缺省值

|

描述

| | --- | --- | --- | | 向量大小 | One hundred | 输出向量的大小。 | | windows size(windows size) | five | 用作上下文的单词数。 | | minCount | five | 令牌必须出现在输出中的最小次数。 | | maxsentexcelength | One thousand | 其他术语之间的相互作用创造了新的特征。乘法用于数值,二进制用于分类值。 |

清单 8-14 展示了如何使用Word2Vec估计器,以及如何找到相似的单词。

import org.apache.spark.ml.feature.Word2Vec

val documentDF = spark.createDataFrame(Seq(
                "Unified data analytics engine Spark".split(" "),
                "People use Hive for data analytics".split(" "),
                "MapReduce is not fading away".split(" "))
                      .map(Tuple1.apply)).toDF("word")

val word2Vec = new Word2Vec().setInputCol("word")
                             .setOutputCol("feature")
                             .setVectorSize(3)
                             .setMinCount(0)

val model = word2Vec.fit(documentDF)
val result = model.transform(documentDF)

result.show(false)

Listing 8-14Use Word2Vec Estimator to Compute Word Embeddings and Find Similar Words

img/419951_2_En_8_Figd_HTML.jpg

// find similar words to Spark, the result shows both Hive and MapReduce are similar.
model.findSynonyms("Spark", 3).show

+----------------+-----------------------------+
|            word|                   similarity|
+----------------+-----------------------------+
|          engine|           0.9133241772651672|
|       MapReduce|           0.7623026967048645|
|            Hive|           0.7179173827171326|
+----------------+-----------------------------+

// find similar words to Hive, the result shows Spark is similar
model.findSynonyms("Hive", 3).show
+---------+------------------------------+
|     word|                    similarity|
+---------+------------------------------+
|    Spark|            0.7179174423217773|
|   fading|            0.5859972238540649|
|   engine|           0.43200281262397766|
+---------+------------------------------+

下一个评估者是关于规范化和标准化数字数据的。使用这些估计值的原因是为了确保使用距离作为测量值的学习算法不会对具有较大值的要素施加比另一个具有较小值的要素更大的权重。

规范化数字数据是将其原始范围映射到从零到一的范围的过程。当观测值具有多个不同范围的属性时,这尤其有用。比如说你有一个员工的工资和身高。工资的价值以千计。高度的值是个位数。这就是MinMaxScaler估算器的设计目的。它使用列汇总统计数据,将每个要素(列)分别线性重新缩放到最小值和最大值的公共范围。例如,如果最小值为 0.0,最大值为 3.0,则所有值都在该范围内。清单 8-15 提供了一个使用带有薪水和身高信息的 employee_data 与MinMaxScaler一起工作的例子。这两个特性的值之间的幅度相当大,但是在运行了MinMaxScaler之后,情况就不一样了。

import org.apache.spark.ml.feature.MinMaxScaler
import org.apache.spark.ml.linalg.Vectors

val employee_data = spark.createDataFrame(Seq(
                               (1, Vectors.dense(125400, 5.3)),
                               (2, Vectors.dense(179100, 6.9)),
                               (3, Vectors.dense(154770, 5.2)),
                               (4, Vectors.dense(199650, 4.11))))
                         .toDF("empId", "features")

val minMaxScaler = new MinMaxScaler().setMin(0.0)
                                     .setMax(5.0)
                                     .setInputCol("features")
                                     .setOutputCol("sFeatures")

val scalerModel = minMaxScaler.fit(employee_data)

val scaledData = scalerModel.transform(employee_data)

println(s"Features scaled to range:
          [${minMaxScaler.getMin}, ${minMaxScaler.getMax}]")
Features scaled to range: [0.0, 5.0]

scaledData.select("features", "sFeatures").show(false)

+--------------------+------------------------------------------+
|     features       | scaledFeatures                           |
+--------------------+------------------------------------------+
|     [125400.0,5.3] | [0.0,2.1326164874551963]                 |
|     [179100.0,6.9] | [3.616161616161616,5.0]                  |
|     [154770.0,5.2] | [1.9777777777777779,1.9534050179211468]  |
|    [199650.0,4.11] | [5.0,0.0]                                |
+--------------------+------------------------------------------+

Listing 8-15Use MinMaxScaler to Rescale Features

除了数字数据规范化,另一个经常用于处理数字数据的操作是标准化。当数值数据具有钟形曲线分布时,此操作尤其适用。标准化操作有助于将数据转换为规范化形式,其中数据在-1 和–1 的范围内,平均值为 0。这样做的原因是为了帮助某些 ML 算法在数据具有围绕零均值的分布时更好地学习。StandardScaler估算器是为标准化操作而设计的。清单 8-16 使用与清单 8-14 相同的输入数据集。输出显示要素值现在以 0 为中心,并带有一个单位的标准差。

import org.apache.spark.ml.feature.StandardScaler
import org.apache.spark.ml.linalg.Vectors

val employee_data = spark.createDataFrame(Seq(
                               (1, Vectors.dense(125400, 5.3)),
                               (2, Vectors.dense(179100, 6.9)),
                               (3, Vectors.dense(154770, 5.2)),
                               (4, Vectors.dense(199650, 4.11))))
                         .toDF("empId", "features")

// set the unit standard deviation to true and center around the mean
val standardScaler = new StandardScaler().setWithStd(true)
                                       .setWithMean(true)
                                       .setInputCol("features")
                                       .setOutputCol("sFeatures")

val standardMode = standardScaler.fit(employee_data)

val standardData = standardMode.transform(employee_data)

standardData.show(false)

+-----+--------------+------------------------------------------+
|empId| feature      |          sFeatures                       |
+-----+--------------+------------------------------------------+
|  1  |[125400.0,5.3]|[-1.2290717420781212,-0.06743742573177587]|
|  2  |[179100.0,6.9]|   [0.4490658767775897,1.3248191055048935]|
|  3  |[154770.0,5.2]|[-0.3112523404805006,-0.15445345893406737]|
|  4  |[199650.0,4.1]|    [1.091258205781032,-1.102928220839048]|
+-----+--------------+------------------------------------------+

Listing 8-16Use StandardScaler to Standard the Features Around the Mean of Zero

MLlib 中有更多的估算器可以用来执行大量的数据转换和映射。它们都遵循符合输入数据的标准抽象,并产生一个Model实例。这些例子旨在说明如何使用这些估算器。第二种估计量的例子是最大似然算法,将在下面的章节中介绍。

管道

在机器学习中,通常会运行一系列步骤来清理和转换数据,然后训练一个或多个 ML 算法来从数据中学习,最后调整模型以实现最佳的模型性能。MLlib 中的管道抽象旨在使该工作流更易于开发和维护。从技术角度来看,MLlib 有一个用于管理一系列阶段的Pipeline类。每一个都由PipelineStage类表示,要么是转换器,要么是估计器。Pipeline抽象是一种估计器。

设置管道的第一步是创建一个 stage 集合,创建一个Pipeline类的实例,并用 stage 数组对其进行配置。Pipeline类按照指定的顺序运行这些阶段。如果一个阶段是一个变压器,那么transform()功能被调用。如果一个阶段是一个估计器,则调用fit()函数来产生一个转换器。

让我们看一个使用转换器和估算器处理文本的小工作流示例。图 8-10 中描述的小型管道由两个变压器和一个估算器组成。当调用Pipeline.fit()函数时,输入数据帧的原始文本被传递到Tokenizer转换器,其输出被传递到HashingTF转换器,后者将单词转换成特征。Pipeline类将LogisticRegression识别为一个估计器,使用计算出的特征调用fit函数来产生逻辑回归模式l

图 8-10 中描述了Pipeline的代码,清单 8-17 中列出了该代码。抽象是一个估计器。因此,一旦创建并配置了Pipeline的实例,就必须使用训练数据作为输入来调用fit()函数,以触发阶段的执行。输出是PipelineModel的一个实例,它是一种转换器。此时,您可以将测试数据传递给transform()函数来执行预测。

MLlib 提供了 ML 持久性特性,使得将管道或模型保存到磁盘并在以后加载以执行预测变得容易。持久性特性的好处在于,它被设计成以一种语言中立的格式保存信息。因此,当管道或模型在 Scala 中持久化时,可以用不同的语言读回,比如 Java 或 Python。

许多实际生产管道由许多阶段组成。阶段数多了,就很难理解流程和维护。MLlib Pipeline抽象可以帮助应对这些挑战。另一个需要注意的关键点是PipelinesPipelineModels都被设计成确保训练和测试数据流通过相同的特征处理步骤。机器学习中的一个常见错误是不一致地处理训练和测试数据,这在模型评估结果中产生了差异。

img/419951_2_En_8_Fig10_HTML.jpg

图 8-10

小型管道示例

import org.apache.spark.ml.{Pipeline, PipelineModel}
import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.feature.{HashingTF, Tokenizer}

val text_data = spark.createDataFrame(Seq(
      (1, "Spark is a unified data analytics engine", 0.0),
      (2, "Spark is cool and it is fun to work with Spark", 0.0),
      (3, "There is a lot of exciting sessions at upcoming Spark
          summit", 0.0),
      (4, "signup to win a million dollars", 0.0)  ))
                     .toDF("id", "line", "label")

val tokenizer = new Tokenizer().setInputCol("line")
                               .setOutputCol("words")

val hashingTF = new HashingTF()
                    .setInputCol(tokenizer.getOutputCol)
                    .setOutputCol("features")
                    .setNumFeatures(4096)

val logisticReg = new LogisticRegression().setMaxIter(5)
                                          .setRegParam(0.01)

val pipeline = new Pipeline().setStages(Array(
                              tokenizer, hashingTF, logisticReg))
val logisticRegModel = pipeline.fit(text_data)

// persist model and pipeline
logisticRegModel.write.overwrite()
                .save("/tmp/logistic-regression-model")

pipeline.write.overwrite()
                .save("/tmp/logistic-regression-pipeline")

// load model and pipeline
val prevModel = PipelineModel.load("/tmp/spark-logistic-regression-model")

val prevPipeline = Pipeline.load("/tmp/logistic-regression-pipeline")

Listing 8-17Using Pipepline to Small a Small Workflow

管道持久性:保存和加载

一旦对模型进行了训练和评估,您可以保存该模型或训练该模型的管道,以便在以后的日子里或在 Spark 集群重新启动后,使用其他数据集进一步评估您的模型。后一种方法是首选的,因为它记住了模型类型;否则,您必须在加载步骤中指定它。

持久化您的模型的主要好处是节省时间和跳过训练步骤,这可能需要几个小时才能完成。

模型调整

模型调整步骤旨在使用一组参数来训练模型,以实现最佳模型性能,从而满足 ML 开发过程的第一步中定义的目标。这个步骤通常是乏味的、重复的和耗时的,因为它需要试验不同的 ML 算法或几组参数。

本节旨在描述 MLlib 提供的一些工具,以帮助完成模型调优步骤中的繁重部分。本节并不打算展示如何执行模型调优。

在详细介绍 MLlib 提供的工具之前,理解以下术语很重要。

  • 模型超参数有

    • 管理 ML 算法训练过程的配置

    • 模型外部的配置,无法从训练数据中学习

    • 机器学习实践者在培训过程开始之前提供的配置

    • 通过迭代方式为给定的机器学习任务调整的配置

  • 模型参数是

    • 机器学习实践者不提供的属性

    • 在训练过程中学习到的训练数据的属性

    • 在培训过程中优化的属性

    • 执行预测的模型的属性

模型超参数的示例包括 k 均值聚类算法中的聚类数、逻辑回归算法中应用的正则化量以及学习率。

模型参数的示例包括线性回归模型中的系数或决策树模型中的分支位置。

MLlib 中帮助模型调优的两个常用类是CrossValidatorTrainValidationSplit,,它们都是Estimator类型。这些类也被称为验证器,它们需要以下输入。

  • 第一个输入是需要调整的内容——一个 ML 算法或一个Pipeline实例。它一定是一种估计量。

  • 第二个输入是用于调整所提供的估计器的一组参数。这些参数也被称为参数网格,用于搜索以找到最佳模型。名为ParagramGridBuilder的便利实用程序可用于构建参数网格。

  • 最后一个输入是一个评估器,用于根据保留的测试数据评估模型的性能。MLlib 为每个机器学习任务提供了一个特定的评估器,它可以产生一个或多个评估指标,供您了解模型性能。支持常用的机器学习指标,如均方根误差、精度、召回率和准确度

在高层次上,验证器对给定的输入执行以下步骤。

  1. 根据指定的比率,将输入特征数据分为训练数据集和测试数据集。

  2. 对于参数网格中的每个组合,给定的估计器与训练数据和参数组合相匹配。

  3. 指定的评估器根据测试数据评估输出模型。记录并比较性能指标。

  4. 产生最佳性能的模型与所使用的参数集一起返回。

这些步骤如图 8-11 所示,使得验证器中发生的事情更加直观。

img/419951_2_En_8_Fig11_HTML.jpg

图 8-11

在验证器内部

TrainValidationSplit验证器根据指定的比率将给定的输入数据分割成训练和验证数据集,然后根据每个参数组合训练和评估数据集对。例如,如果给定的参数集有六个组合,给定的估计器被训练和评估大小次,每次用不同的参数组合。

清单 8-18 提供了一个使用TrainValidationSplit通过六个参数组合的参数网格调整线性回归估计器的例子。这个例子的重点是TrainValidationSplit。假设特征工程已经完成,数据帧中有一个名为features的列。

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.tuning.{ParamGridBuilder, TrainValidationSplit}
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator

val text_data = spark.createDataFrame(Seq(
            (1, "Spark is a unified data analytics engine", 0.0),
            (2, "Spark is cool and it is fun to work with Spark",
                0.0),
            (3, "There is a lot of exciting sessions at upcoming
                 Spark summit", 0.0),
            (4, "signup to win a million dollars", 0.0)  ))
                     .toDF("id", "line", "label")

val tokenizer = new Tokenizer().setInputCol("line")
                               .setOutputCol("words")

val hashingTF = new HashingTF().setInputCol(
                                    tokenizer.getOutputCol)
                               .setOutputCol("features")

val logisticReg = new LogisticRegression().setMaxIter(5)

val pipeline = new Pipeline().setStages(
                      Array(tokenizer, hashingTF, logisticReg))

// the first parameter has 3 values and second parameter has 2 values,
// therefore the total parameter combinations is 6
val paramGrid = new ParamGridBuilder().addGrid(
                      hashingTF.numFeatures, Array(10, 100, 250))
                 .addGrid(logisticReg.regParam, Array(0.1, 0.05))
                 .build()

// setting up the validator with required inputs - estimator, evaluator, parameter grid and train ratio
val trainValSplit = new TrainValidationSplit()
                         .setEstimator(pipeline)
                        .setEvaluator(
                             new BinaryClassificationEvaluator)
                        .setEstimatorParamMaps(paramGrid)
                        .setTrainRatio(0.8)

// train the linear regression estimator
val model = trainValidationSplit.fit(training)

Listing 8-18Example of TrainValidationSplit

CrossValidator验证器实现了机器学习社区中广为人知的技术来帮助模型调整步骤。这种技术通过将观察值随机分成大小大致相同的非重叠 k 组或折叠,最大化了用于训练和测试的数据量。每个都只使用一次。一折用于测试,剩下的用于训练。这个过程重复 k 次,并且每次都根据随机划分的训练和测试折叠来训练和评估估计器。

图 8-12 以 k 为四个褶皱说明了这个过程。CrossValidator生成四个训练和测试数据集对,四分之一的数据用于测试,四分之三的数据用于测试。重要的是选择合理的 k 值,以便每个训练和测试组在统计上代表可用的观察值。每个文件夹都有大致相同数量的样本数据。

img/419951_2_En_8_Fig12_HTML.jpg

图 8-12

k=4 的 k 倍示例

当使用带有大量参数组合的验证器时,要注意很长的完成时间,这一点很重要。这是因为图 8-12 中描述的每个实验都是针对每个参数组合进行的。例如,如果 k 是 4,参数组合的数量是 6,那么估计器被训练和评估的总次数是 24。清单 8-17 将清单 8-15 中的TrainValidationSplit替换为CrossValidator,的一个实例,并配置为 4 作为 k 值。实际上, k 的值通常为 10 或更高。清单 8-17 结束了对评估者的 25 次训练和评估。

在识别出具有最佳性能的模型后,CrossValidator在整个数据集上使用相同的参数集来重新训练或重新拟合您的模型。这就是清单 8-19 中的模型总共被训练 25 次的原因。

import org.apache.spark.ml.tuning.CrossValidator

val crossValidator = new CrossValidator()
                       .setEstimator(pipeline)
                       .setEvaluator(
                           new BinaryClassificationEvaluator)
                       .setEstimatorParamMaps(paramGrid)
                        .setNumFolds(4)

val model = crossValidator.fit(text_data)

Listing 8-19Example of CrossValidator

如果需要研究或分析中间模型,CrossValidator可以在调整过程中保留它们。您所需要做的就是在调用setCollectSubmModels函数时指定一个真值,然后通过调用getCollectSubmModels()函数.来访问中间模型

加速模型调整

TrainValidationSplitCrossValidator估计器被设计成消除机器学习开发过程中模型调整步骤的痛苦。您可能会发现,由于不同的参数组合,训练和评估所有不同的模型需要一段时间。参数组合的数量越多,花费的时间就越多。

默认情况下,估计器以连续的方式一次训练和评估一个模型。为了加快这个过程,您可能希望增加并行性,以利用 Spark 集群的计算和内存资源。这是通过在启动模型调整过程之前将并行度设置为 2 或更大的值来实现的。作为《Spark 调谐指南》中的一般指导原则,最大值为 10 通常就足够了。清单 8-20 将 crossValidator 的并行度设置为 6。

crossValidator.setParallelism(6).fit(text_data)

Listing 8-20Setting CrossValidator Parallelism to 6

模型评估者

为了理解模型的性能,您首先需要知道如何计算和评估模型评估指标。每项机器学习任务都使用一组不同的指标,计算它们是乏味的,并且使用数学。幸运的是,MLlib 提供了一组名为 evaluator 的工具来计算指标,这样验证器就可以测量拟合模型在测试数据上的表现。表 8-7 列出了 MLlib 中支持的不同赋值器、支持指标的子集以及简短描述。

表 8-7

支持的评估者

|

名字

|

支持的指标

|

描述

| | --- | --- | --- | | 回归评估器 | rmse,姆塞,r2,mae,var | 对于回归任务 | | 二元分类计算器 | areaUnderROC,areaUnderPR | 对于只有两个类别分类任务 | | 多类分类评估器 | 加权精度、加权回收等 | 对于只有两个以上类别分类任务 | | 多层分类评估器 | 子准确度,精确度,汉明洛斯,召回,精确度按标签,召回按标签,f1 测量按标签 | 对于多标签分类任务 | | 分级评估员 | meanAveragePrecisionAtK,PrecisionAtK,ndcgAtK,recallAtK | 对于分级任务 |

行动中的机器学习任务

本节汇集了本章中描述的概念和工具,并将其应用于以下机器学习任务:分类、回归和推荐。使用真实数据集完成机器学习开发过程,可以更清楚地了解所有部分是如何组合在一起的。

本节并不打算全面涵盖每个机器学习算法的超参数,模型调整步骤留给读者作为练习。

分类

分类是研究和使用最广泛的机器学习任务之一,因为它能够帮助解决许多现实生活中与分类相关的问题。比如这是不是信用卡欺诈交易?这是垃圾邮件吗?这是一只猫、一只狗还是一只鸟的图像?

有三种类型的分类。

  • 二元分类:这里要预测的标签只有两种可能的类别(例如,欺诈与否,会议论文是否被接受,肿瘤是良性还是恶性)。

  • 多类分类:这是指要预测的标签有两个以上可能的类别(例如,图像是狗、猫还是鸟)。

  • 多标签分类:这是每个观察可以属于多个类别的地方。电影类型就是一个很好的例子。一部电影可以分为动作片和喜剧片。MLlib 本身不支持这种类型的分类。

MLlib 为分类任务提供了一些机器学习算法。

  • 逻辑回归

  • 决策图表

  • 随机森林

  • 梯度增强树

  • 线性支持向量机

  • 一对多

  • 奈伊夫拜厄斯

模型超参数

本例中使用了逻辑回归算法。以下是其模型超参数的子集。每个模型超参数都有一个默认值。

  • family:可能的值有autobinomialmultinomial。默认值为auto,这意味着算法会根据标签列中的值自动选择系列为binomialmultinomialbinomial是针对二元分类的。multinomial用于多类分类。

  • regParam:这是控制过拟合的正则化参数。默认值为 0.0。

例子

列表 8-21 试图预测哪些泰坦尼克号乘客在悲剧中幸存。这是一个二元分类机器学习问题。该示例使用逻辑回归算法。信息和数据可在 www.kaggle.com/c/titanic 获得。数据为 CSV 格式,有两个文件:train.csvtest.csvtrain.csv文件包含标签列。

提供的数据包含许多有趣的特征;然而,清单 8-21 仅使用年龄、性别和机票等级作为特征。

import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.feature.StringIndexer
import org.apache.spark.ml.feature.VectorAssembler
import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator

val titanic_data = spark.read.format("csv")
                        .option("header", "true")
                        .option("inferSchema","true")
                        .load("/<folder>/train.csv")

// explore the schema
titanic_data.printSchema
 |-- PassengerId: integer (nullable = true)
 |-- Survived: integer (nullable = true)
 |-- Pclass: integer (nullable = true)
 |-- Name: string (nullable = true)
 |-- Sex: string (nullable = true)
 |-- Age: double (nullable = true)
 |-- SibSp: integer (nullable = true)
 |-- Parch: integer (nullable = true)
 |-- Ticket: string (nullable = true)
 |-- Fare: double (nullable = true)
 |-- Cabin: string (nullable = true)
 |-- Embarked: string (nullable = true)

// to start out with, we will use only three features
// filter out rows where age is null
val titanic_data1 = titanic_data.select('Survived.as("label"),
                      'Pclass.as("ticket_class"),
                      'Sex.as("gender"), 'Age.as("age"))
                                .filter('age.isNotNull)

// split the data into training and test with 80% and 20% split
val Array(training, test) = titanic_data1.randomSplit(
                                       Array(0.8, 0.2))

println(s"training count: ${training.count}, test count:
                          ${test.count}")

// estimator:  to convert gender string to numbers
val genderIndxr = new StringIndexer().setInputCol("gender")
                                       .setOutputCol("genderIdx")

// transformer: assemble the features into a vector
val assembler = new VectorAssembler().setInputCols(
                       Array("ticket_class", "genderIdx", "age"))
                                     .setOutputCol("features")

// estimator: the algorithm
val logisticRegression = new LogisticRegression()
                                      .setFamily("binomial")

// set up the pipeline with three stages
val pipeline = new Pipeline().setStages(Array(genderIndxr,
                                  assembler, logisticRegression))

// train the algorithm with the training data
val model = pipeline.fit(training)

// perform the predictions
val predictions = model.transform(test)

// perform the evaluation of the model performance, the default metric is the area under the ROC
val evaluator = new BinaryClassificationEvaluator()

evaluator.evaluate(predictions)
res10: Double = 0.8746657754010692

evaluator.getMetricName
res11: String = areaUnderROC

Listing 8-21Use Logistic Regression Algorithm to Predict the Survival of Titanic Passengers

BinaryClassificationEvaluator产生的度量值为 0.87,对于使用三个特性来说,这是一个不错的性能。然而,这个例子没有探究各种超参数和训练参数。我强烈建议您试验各种超参数,看看您的模型是否能比 0.87 表现得更好。

回归

另一个流行的机器学习任务称为回归,它旨在预测一个实数或连续值。例如,您希望预测下一季度的销售收入,或者人口的收入,或者世界上某个地区的降雨量。

MLlib 为回归任务提供了以下机器学习算法。

  • 线性回归

  • 广义线性回归

  • 决策树

  • 随机森林

  • 梯度增强树

  • 保序回归

模型超参数

以下示例使用带有以下超参数的线性回归。

  • regParam:该正则化参数控制过拟合。默认值为 0.0。

  • fitIntercept:该参数决定是否拟合截距。默认值为 true。

例子

清单 8-22 试图根据房屋的一系列特征来预测房价。数据集在 www.kaggle.com/c/house-prices-advanced-regression-techniques/data 可用。数据以 CSV 格式提供,有两个文件,train.csv,test.csvtrain.csv文件中的标签列称为SalePrice

提供的数据包含许多有趣的特征;然而,清单 8-22 只使用了其中的一个子集。

import org.apache.spark.sql.functions._
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.feature.StringIndexer
import org.apache.spark.ml.feature.VectorAssembler
import org.apache.spark.ml.regression.LinearRegression
import org.apache.spark.ml.feature.RFormula
import org.apache.spark.ml.evaluation.RegressionEvaluator
import org.apache.spark.mllib.evaluation.RegressionMetrics

val house_data = spark.read.format("csv")
                      .option("header", "true")
                      .option("inferSchema","true")
                      .load("<path>/train.csv")

// select columns to use as features
val cols = SeqString

val colNames = cols.map(n => col(n))

// select only needed columns
val skinny_house_data = house_data.select(colNames:_*)

// create a new column called "TotalSF" by adding the value of "1stFlrSF" and "2ndFlrSF" columns
// cast the "SalePrice" column to double
val skinny_house_data1 = skinny_house_data.withColumn("TotalSF",
                              col("1stFlrSF") + col("2ndFlrSF"))
                             .drop("1stFlrSF", "2ndFlrSF")
                             .withColumn("SalePrice",
                                    $"SalePrice".cast("double"))

// examine the statistics of the label column called "SalePrice"
skinny_house_data1.describe("SalePrice").show

+------------+-----------------------------+
|     summary|                    SalePrice|
+------------+-----------------------------+
|       count|                         1460|
|        mean|           180921.19589041095|
|      stddev|            79442.50288288663|
|         min|                      34900.0|
|         max|                     755000.0|
+------------+-----------------------------+

// create estimators and transformers to setup a pipeline

// set the invalid categorical value handling policy to skip to avoid error
// at evaluation time
val roofStyleIndxr = new StringIndexer()
                               .setInputCol("RoofStyle")
                               .setOutputCol("RoofStyleIdx")
                               .setHandleInvalid("skip")

val heatingIndxr = new StringIndexer()
                             .setInputCol("Heating")
                             .setOutputCol("HeatingIdx")
                             .setHandleInvalid("skip")

val linearReg = new LinearRegression().setLabelCol("SalePrice")

// assembler to assemble the features into a feature vector
val assembler = new VectorAssembler().setInputCols(
                        Array("LotArea", "RoofStyleIdx",
                              "HeatingIdx", "LotArea",
                              "BedroomAbvGr", "KitchenAbvGr",
                              "GarageCars", "TotRmsAbvGrd",
                              "YearBuilt", "TotalSF"))
                                     .setOutputCol("features")

// setup the pipeline
val pipeline = new Pipeline().setStages(Array(roofStyleIndxr,
                      heatingIndxr, assembler, linearReg))

// split the data into training and test pair
val Array(training, test) = skinny_house_data1.randomSplit(
                                     Array(0.8, 0.2))

// train the pipeline
val model = pipeline.fit(training)

// perform prediction
val predictions = model.transform(test)

val evaluator = new RegressionEvaluator().setLabelCol("SalePrice")
                                                                    .setPredictionCol("prediction")
                                                                    .setMetricName("rmse")

val rmse = evaluator.evaluate(predictions)
rmse: Double = 37579.253919082395

Listing 8-22Use Linear Regression Algorithm to Predict House Price

RMSE 代表均方根误差。在这种情况下,RMSE 值约为 37,000 美元,这表明还有很大的改进空间。

建议

推荐系统是最直观和最著名的机器学习应用之一。也许事实就是如此,因为几乎每个人都在亚马逊和网飞等热门网站上看到过推荐系统的例子。几乎每一个受欢迎的网站或互联网电子商务公司都有一个或多个推荐系统。推荐系统的常见例子包括你可能在 Spotify 上喜欢的歌曲、你想在 Twitter 上关注的人、你可能在 Coursera 或 Udacity 上喜欢的课程。推荐系统给公司的用户和它自己都带来了好处。用户很高兴不用花太多力气就能找到或发现自己喜欢的物品。由于用户参与度、忠诚度和利润的增加,公司都很高兴。如果一个推荐系统表现好,那就是双赢。

构建推荐系统的常用方法包括基于内容的过滤、协同过滤以及两者的混合。第一种方法需要收集关于被推荐的项目和每个用户的简档的信息。第二种方法需要通过显式或隐式方式仅收集用户活动或行为。显性行为的例子包括对亚马逊上的电影或商品进行评级。隐含行为的例子包括观看电影预告片或描述。第二种方法背后的直觉是“群众的智慧”概念,即过去同意的人将来也会同意。

本节重点介绍协作过滤方法,这种方法的一种流行算法叫做 ALS,代表交替最小二乘。该算法需要的唯一输入是用户-项目评级矩阵,该矩阵通过矩阵分解发现用户偏好和项目属性。一旦找到这两条信息,它们就能预测用户对以前没有见过的物品的偏好。MLlib 实现了 ALS 算法。

模型超参数

MLlib 中的 ALS 算法实现有几个重要的超参数需要注意。以下部分仅包含一个子集。请查阅 https://spark.apache.org/docs/latest/ml-collaborative-filtering.html 的文档。

  • rank:该参数指定在训练过程中学习到的关于用户和项目的潜在因素或属性的数量。等级的最佳值通常由实验和对准确描述项目所需的属性数量的直觉来确定。默认值为 10。

  • regParam:处理过拟合的正则化量。该参数的最佳值通常由实验确定。默认值为 0.1

  • implicitPrefs : ALS 算法支持显性和隐性的用户活动或行为。这个参数告诉我们输入数据代表哪一个。默认值为 false,意味着活动或行为是显式的。

例子

该示例使用在 https://grouplens.org/datasets/movielens/ 的电影评级数据集来构建电影推荐系统。具体数据集为 http://files.grouplens.org/datasets/movielens/ml-latest-small.zip 最新的 MovieLens 100K 数据集。该数据集包含 700 名用户对 9000 部电影的大约 100,000 个评级。zip 文件中包含四个文件:links.csvmovies.csvratings.csvtags.csv。文件中的每一行代表一个用户对一部电影的评价。是这样的格式:userId, movieId, rating, timestamp。等级从 0 到 5,增量为半星。

清单 8-23 用一组参数训练 ALS 算法,然后根据 RMSE 度量评估模型性能。此外,它在ALSModel类中调用几个有趣的提供的 API 来获得电影和用户的推荐。

import org.apache.spark.mllib.evaluation.RankingMetrics
import org.apache.spark.ml.evaluation.RegressionEvaluator
import org.apache.spark.ml.recommendation.ALS
import org.apache.spark.ml.tuning.{ParamGridBuilder, CrossValidator}
import org.apache.spark.sql.functions._

// we don't need the timestamp column, so drop it immediately
val ratingsDF = spark.read.option("header", "true")
                          .option("inferSchema", "true")
                          .csv("<path>/ratings.csv")
                          .drop("timestamp")

// quick check on the number of ratings
ratingsDF.count

res14: Long = 100004

// quick check who are the active movie raters
val ratingsByUserDF = ratingsDF.groupBy("userId").count()

ratingsByUserDF.orderBy($"count".desc).show(10)

+--------+-------+
|  userId|  count|
+--------+-------+
|     547|   2391|
|     564|   1868|
|     624|   1735|
|      15|   1700|
|      73|   1610|
|     452|   1340|
|     468|   1291|
|     380|   1063|
|     311|   1019|
|      30|   1011|
+--------+-------+

println("# of rated movies: " +ratingsDF.select("movieId").distinct().count)
# of rated movies: 9066

println("# of users: " + ratingsByUserDF.count)
# of users: 671

// analyze the movies largest number of ratings
val ratingsByMovieDF = ratingsDF.groupBy("movieId").count()
ratingsByMovieDF.orderBy($"count".desc).show(10)

+----------+-------+
|   movieId|  count|
+----------+-------+
|       356|    341|
|       296|    324|
|       318|    311|
|       593|    304|
|       260|    291|
|       480|    274|
|      2571|    259|
|         1|    247|
|       527|    244|
|       589|    237|
+----------+-------+

// prepare data for training and testing
val Array(trainingData, testData) = ratingsByUserDF.randomSplit(Array(0.8, 0.2))

// setting up an instance of ALS
val als = new ALS().setRank(12)
                     .setMaxIter(10)
                     .setRegParam(0.03)
                     .setUserCol("userId")
                     .setItemCol("movieId")
                     .setRatingCol("rating")

// train the model
val model = als.fit(trainingData)

// perform predictions
val predictions = model.transform(testData).na.drop

// setup an evaluator to calculate the RMSE metric
val evaluator = new RegressionEvaluator().setMetricName("rmse")
                                                     .setLabelCol("rating")
                                                     .setPredictionCol("prediction")

val rmse = evaluator.evaluate(predictions)
println(s"Root-mean-square error = $rmse")
Root-mean-square error = 1.06027809686058

Listing 8-23Building a Recommender System Using ALS Algorithm Implementation in MLlib

ALSModel类提供了两组有用的函数来执行推荐。第一组向所有用户或一组特定用户推荐前 n 个项目。第二组用于向前 n 用户推荐所有项目或一组特定项目。清单 8-24 提供了一个调用这些函数的例子。

// recommend the top 5 movies for all users
model.recommendForAllUsers(5).show(false)

// active raters
val activeMovieRaters = Seq((547), (564), (624), (15),
                            (73)).toDF("userId")

model.recommendForUserSubset(activeMovieRaters, 5).show(false)

+------+---------------------------------------------------------------------------------------------------+
|userId|             recommendations                                               |
+------+---------------------------------------------------------------------------------------------------+
|  15  | [[363, 5.4706035],   [422, 5.4109325],  [1192, 5.3407555], [1030, 5.329553],  [2467, 5.214414]]   |
| 547  | [[1298, 5.752393],   [1235, 5.4936843], [994, 5.426885],   [926, 5.28749],    [3910, 5.2009006]]  |
| 564  | [[121231, 6.199452], [2454, 5.4714866], [3569, 5.4276495], [1096, 5.4212027], [1292, 5.4203687]]  |
| 624  | [[1960, 5.4001703],  [1411, 5.2505665], [3083, 5.1079946], [3030, 5.0170803], [132333, 5.0165534]]|
|  73  | [[2068, 5.0426316],  [5244, 5.004793],  [923, 4.992707],   [85342, 4.979018], [1411, 4.9703207]]  |
+-------+--------------------------------------------------------------------------------------------------+

// recommend top 3 users for each movie
val recMovies = model.recommendForAllItems(3)

// read in movies dataset so we can see the movie title
val moviesDF = spark.read.option("header", "true")
                         .option("inferSchema", "true")
                         .csv("<path>/movies.csv")

val recMoviesWithInfoDF = recMovies.join(moviesDF, "movieId")

recMoviesWithInfoDF.select("movieId", "title", "recommendations")
                   .show(5, false)

+--------+----------------------------------+---------------------------------------------------------+
| movieId| title                     | recommendations                              |
+--------+----------------------------------+---------------------------------------------------------+
|  1580  | Men in Black (a.k.a. MIB) (1997) | [[46, 5.6861496],  [113, 5.6780157], [145, 5.3410296]]  |
|  5300  | 3:10 to Yuma (1957)              | [[545, 5.475599],  [354, 5.2230153], [257, 5.0623646]]  |
|  6620  | American Splendor (2003)         | [[156, 5.9004226], [83, 5.699677],   [112, 5.6194253]]  |
|  7340  | Just One of the Guys (1985)      | [[621, 4.5778027], [451, 3.9995837], [565, 3.6733315]]  |
| 32460  | Knockin' on Heaven's Door (1997) | [[565, 5.5728054], [298, 5.00507],   [476, 4.805148]]   |
+--------+----------------------------------+---------------------------------------------------------+
// top rated movies
val topRatedMovies = Seq((356), (296), (318),
                         (593)).toDF("movieId")

// recommend top 3 users per movie in topRatedMovies
val recUsers =  model.recommendForItemSubset(topRatedMovies, 3)

recUsers.join(moviesDF, "movieId")
        .select("movieId", "title", "recommendations")
        .show(false)

+----------+----------------------------------+-------------------------------------------------------+
| movieId| title                     | recommendations                            |
+----------+----------------------------------+-------------------------------------------------------+
| 296      | Pulp Fiction (1994)              | [[4, 5.8505774],   [473, 5.81865],   [631, 5.588397]] |
| 593      | Silence of the Lambs, The (1991) | [[153, 5.839533],  [586, 5.8279104], [473, 5.5933723]]|
| 318      | Shawshank Redemption, The (1994) | [[112, 5.8578305], [656, 5.8488774], [473, 5.795221]] |
| 356      | Forrest Gump (1994)              | [[464, 5.6555476], [58, 5.6497917],  [656, 5.625555]] |
+---------+----------------------------------+-------------------------------------------------------+

Listing 8-24Using ALSModel to Perform Recommendations

在清单 8-24 中,ALS 算法的一个实例用一组参数训练,RSME 大约是 1.06。让我们尝试使用CrossValidator使用一组参数组合重新训练 ALS 算法的实例,看看是否可以降低 RSME 值。

清单 8-25 为两个超参数设置了一个搜索网格,总共有四个参数组合,还有一个CrossValidator有三个折叠。这意味着 ALS 算法被训练和评估 12 次,因此需要一两分钟来完成。

val paramGrid = new ParamGridBuilder()
                        .addGrid(als.regParam,Array(0.05, 0.15))
                        .addGrid(als.rank, Array(12,20))
                        .build

val crossValidator = new CrossValidator().setEstimator(als)
                           .setEvaluator(evaluator)
                           .setEstimatorParamMaps(paramGrid)
                           .setNumFolds(3)

// print out the 4 hyperparameter combinations
crossValidator.getEstimatorParamMaps.foreach(println)
{
      als_d2ec698bdd1a-rank: 12,
      als_d2ec698bdd1a-regParam: 0.05
}
{
      als_d2ec698bdd1a-rank: 20,
      als_d2ec698bdd1a-regParam: 0.05
}
{
      als_d2ec698bdd1a-rank: 12,
      als_d2ec698bdd1a-regParam: 0.15
}
{
      als_d2ec698bdd1a-rank: 20,
      als_d2ec698bdd1a-regParam: 0.15
}

// this will take a while to run through more than 10 experiments
val cvModel = crossValidator.fit(trainingData)

// perform the predictions and drop the
val predictions2 = cvModel.transform(testData).na.drop

val evaluator2 = new RegressionEvaluator()
                                 .setMetricName("rmse")
                                 .setLabelCol("rating")
                                 .setPredictionCol("prediction")

val rmse2 = evaluator2.evaluate(predictions2)
rmse2: Double = 0.9881840432547675

Listing 8-25Use CrossValidator to Tune the ALS Model

通过利用CrossValidator来帮助调整模型,您已经成功地降低了 RMSE。训练最佳模型可能需要一段时间,但 MLlib 使试验一组参数组合变得很容易。

深度学习管道

如果没有提到深度学习主题,这一章将是不完整的,深度学习是人工智能和机器学习领域最热门的主题之一。已经有许多资源以书籍、博客、课程和研究论文的形式解释深度学习的每个方面。在技术方面,开源社区、大学和大型公司(如谷歌、脸书、微软和其他公司)有许多创新,提出了深度学习框架和最佳实践。这里是深度学习框架的当前列表。

  • TensorFlow 是 Google 创建的开源框架。

  • PyTorch 是由脸书开发的开源深度学习框架。

  • MXNet 是由一群大学和公司开发的深度学习框架。

  • Caffe 是由加州大学伯克利分校开发的深度学习框架。

  • CNTK 是微软开发的开源深度学习框架。

  • Theano 是蒙特利尔大学开发的另一个开放的深度学习框架。

  • BigDL 是英特尔开发的开源深度学习框架。

在 Apache Spark 这边,Databricks 正在推动开发一个名为深度学习管道的项目。它不是另一个深度学习框架,而是旨在现有流行的深度学习框架之上工作。本着 Spark 和 MLlib 的精神,深度学习管道项目提供了高级和易于使用的 API,用于使用 Apache Spark 在 Python 中构建可扩展的深度学习应用程序。这个项目目前正在 Apache Spark 开源项目之外开发,最终,它将被合并到主主干中。在撰写本文时,深度学习管道项目提供了以下功能。

  • 常见的深度学习用例只需几行代码就可以实现。

  • 在 Spark 中处理图像

  • 应用预先训练的深度学习模型进行可扩展预测

  • 进行迁移学习的能力,将为类似任务训练的模型应用于当前任务

  • 分布式超参数调谐

  • 让公开深度学习模型变得容易,这样其他人就可以将它们作为 SQL 中的一个函数来进行预测

有关令人兴奋的深度学习管道项目的更多信息,请访问 https://github.com/databricks/spark-deep-learning

摘要

人工智能和机器学习的采用正在稳步增加,未来几年将有许多令人兴奋的突破。MLlib 组件建立在 Spark 的强大基础之上,旨在帮助以简单和可伸缩的方式构建智能应用程序。

  • 人工智能是一个广阔的领域,其目标是让机器看起来像具有智能。机器学习是其中一个子领域;它专注于通过用数据训练机器来教会它们学习。

  • 构建机器学习应用程序由一系列步骤组成,并且是高度迭代的。

  • Spark MLlib 组件包括用于功能工程、构建、评估和调整机器学习管道的工具和抽象,以及一组众所周知的机器学习算法,如分类、回归、聚类和协作过滤。

  • MLlib 组件引入的有助于构建和维护复杂管道的核心概念是转换器、估计器和管道。管道是一个编排器,确保训练和测试数据流通过相同的特征处理步骤。

  • 模型调优是 ML 应用程序开发过程中的一个关键步骤。这是乏味和费时的,因为它涉及在一组参数组合上训练和评估模型。结合管道抽象,MLlib 提供了两个有帮助的工具:CrossValidatorTrainValidationSplit