Spark概览笔记

一、Spark浅析

1. Spark应用程序由一个driver进程和多个executor进程组成,driver进程运行main()函数,位于集群的一个节点上,它负责三件事:

(1)维护Spark应用程序的相关信息;

(2)回应用户的程序或输入;

(3)分析任务并分发给若干executor处理。

driver在整个application生命周期中扮演着维护所有相关信息和聚合结果的作用

executor负责执行driver分配给它的实际计算工作,这意味着executor只负责两件事:

(1)执行driver端分配给它的代码;

(2)将该executor的计算状态报告给driver节点。如下图所示:

2. DataFrame是最常见的结构化API,是Spark几种核心数据抽象(还有DataSet、SQL表和RDD)的一种。简单来说它是包含行和列的数据表。说明这些列和列类型的一些规则被称为Schema,可以将DataFrame想象成具有多个命名列的表格,可跨越多台机器,如下所示:

要更改DataFrame,可以使用transformation操作,例如:

val divisBy2 = myRange.where("number % 2 = 0")

注意这种转换并没有实际输出,这是因为仅指定了一个抽象转换。在调用一个action操作之前,Spark不会真的执行转换操作。转换操作是使用Spark表达业务逻辑的核心,有两类转换操作:第一类是指定窄依赖关系的转换操作,第二类是指定宽依赖关系的转换操作。

具有窄依赖关系(narrowdependency)的转换操作(称为窄转换)是每个输入分区仅决定一个输出分区的转换。在上面代码中,where语句指定了一个窄依赖关系,其中一个分区最多只会对一个输出分区有影响,如下图所示:

具有宽依赖关系(widedependency)的转换(或宽转换)是每个输入分区决定了多个输出分区,这种宽依赖关系的转换经常被叫做洗牌(shuffle)操作,它会在整个集群中执行互相交换分区数据的功能。如果是窄转换,Spark将自动执行流水线处理(pipelining),这意味着如果在DataFrame上指定了多个过滤操作,它们将全部在内存中执行。而属于宽转换的shuffle操作不是这样,当执行shuffle操作时,Spark会将结果写入磁盘,如下图所示:

3. 为了让多个executor并行工作,Spark将数据分解为多个数据块,称为分区,它是位于集群中一台机器上的多行数据的集合,多个分区分布在不同机器上,一台机器称为worker,worker上可能有多个executor进程,每个executor里同时执行多个task(和CPU核数有关),每个task处理一个partition。

4. 惰性评估(lazy evaluation)的意思就是等到绝对需要时才执行计算。Spark中,当用户表达一些对数据的操作时,不是立即修改数据,而是建立一个作用到原始数据的转换计划。Spark会首先将这个计划编译为可以在集群中高效运行的流水线式的物理执行计划,然后等待直到最后时刻才开始执行代码。

这会带来很多好处,因为Spark可以优化整个从输入端到输出端的数据流。例如DataFrame的谓词下推(predicate pushdown),假设构建一个含有多个转换操作的Spark作业,并在最后指定了一个过滤操作,假设这个过滤操作只需要输入数据中的某一行数据,则最有效的方法就是在最开始就仅访问需要的这单个记录,Spark会通过自动下推这个过滤操作来优化整个物理执行计划

transformation操作能够建立逻辑转换计划。为了实际触发计算,需要运行一个动作操作(action)。一个动作指示Spark在一系列转换操作后计算一个结果。最简单的动作操作是count,它计算一个DataFrame中的记录总数:

divisBy2.count()

当然count并不是唯一的动作,有三类action

(1)在控制台中查看数据的动作;

(2)在某个语言中将数据汇集为原生对象(nativeobject)的动作,如reduceByKey()

(3)写入输出数据源的动作。

5. 举一个完整的程序例子:例如使用Spark分析美国交通局统计的航班数据,CSV文件的内容格式如下:

在这个例子中,将要执行一种被称作模式推理(schemainference)的操作,即让Spark猜测DataFrame的模式,可以指定文件的第一行是文件头,即通过设置选项来指定。为了获取模式信息,Spark会从文件中读取一些数据,然后根据Spark支持的类型尝试解析读取到的这些行中的数据类型。当然也可以在读取数据时选择严格指定模式(建议在实际生产应用中严格指定模式)。代码如下所示:

val flightData2015 = spark
  .read
  .option("inferSchema", "true")
  .option("header", "true")
  .csv("/data/flight-data/csv/2015-summary.csv")

每个DataFrame都有一些列,但是行数没有指定。行数未指定的原因是因为读取数据是一种转换操作,所以也是一种惰性操作。Spark只偷看了几行数据后,试图猜测每列应该是什么类型。如下图所示:

如果在DataFrame上执行take操作,将看到下面的结果:

flightData2015.take(3)

Array([United States,Romania,15], [United States,Croatia...

需要注意的是,sort操作不会修改DataFrame,因为sort是一个transformation操作,它通过转换以前的DataFrame来返回新的DataFrame。来看看当在结果DataFrame上执行take操作时发生了什么:

调用sort时,什么也不会发生,因为这只是一个转换操作。但是可以通过调用explain函数观察到Spark正在创建一个执行计划,并且可以看到这个计划将会怎样在集群上执行,调用某个DataFrame的explain操作会显示DataFrame的血统(lineage,即Spark是如何执行查询操作的):

flightData2015.sort("count").explain()

== Physical Plan ==
*Sort [count#195 ASC NULLS FIRST], true, 0
+-Exchange rangepartitioning(count#195 ASC NULLS FIRST, 200)
  +-*FileScan csv [DEST_COUNTRY_NAME#193,ORIGIN_COUNTRY_NAME#194,count#195] ...

可以用从上到下的方式阅读解释计划,上面是最终结果,下面是数据源。在这种情况下,如果查看每行的第一个关键字,将看到排序、交换和FileScan。这是因为排序其实是一个宽转换,行需要相互比较和交换。

现在需要指定一个动作来触发这个计划的执行。在做之前首先完成一个配置,默认情况下shuffle操作会输出200个shuffle分区,将此值设置为5以减少shuffle输出分区的数量:

spark.conf.set("spark.sql.shuffle.partitions", "5")
flightData2015.sort("count").take(2)

... Array([United States,Singapore,1], [Moldova,United States,1])

该操作的过程如下所示,需要注意的是除了逻辑转换外,这里还给出了物理分区的数量:

6. 可以使用SQL或DataFrame表达业务逻辑,并且在实际执行代码之前,Spark会将该逻辑编译到底层执行计划(可以在解释计划中看到)。编写SQL查询或编写DataFrame代码并不会造成性能差异,它们都会被“编译”成相同的底层执行计划。可以使用一个简单的方法将任何DataFrame放入数据表或视图中,如下所示:

flightData2015.createOrReplaceTempView("flight_data_2015")

现在可以在SQL中查询数据,将使用spark.sql函数(注意spark是SparkSession变量),它可返回新的DataFrame。尽管这在逻辑上会有种绕圈的感觉,即对DataFrame的SQL查询返回另一个DataFrame,但实际上非常有用,这使得可以在任何给定的时间点以最方便的方式指定转换操作,而不会牺牲效率。为了理解这种情况,来看看下面的解释计划:

val sqlWay = spark.sql("""
SELECT DEST_COUNTRY_NAME, count(1)
FROM flight_data_2015
GROUP BY DEST_COUNTRY_NAME
""")
val dataFrameWay = flightData2015
  .groupBy('DEST_COUNTRY_NAME)
  .count()
sqlWay.explain
dataFrameWay.explain

== Physical Plan ==
*HashAggregate(keys=[DEST_COUNTRY_NAME#182], functions=[count(1)])
+-Exchange hashpartitioning(DEST_COUNTRY_NAME#182, 5)
  +-*HashAggregate(keys=[DEST_COUNTRY_NAME#182], functions=[partial_count(1)])
    +-*FileScan csv [DEST_COUNTRY_NAME#182] ...
== Physical Plan ==
*HashAggregate(keys=[DEST_COUNTRY_NAME#182], functions=[count(1)])
+-Exchange hashpartitioning(DEST_COUNTRY_NAME#182, 5)
  +-*HashAggregate(keys=[DEST_COUNTRY_NAME#182], functions=[partial_count(1)])
    +-*FileScan csv [DEST_COUNTRY_NAME#182] ...

可以看到,SQL和用Scala写DataFrame这两种计划编译后是完全相同的物理执行计划。接下来使用max()函数来统计往返任何特定位置的航班最大数量,这要扫描DataFrame中相关列中的每个值,并检查它是否大于先前看到的值。这是一个transformation,因为不断过滤最后仅得到一行。下面来看看如何编写Spark程序:

spark.sql("SELECT max(count) from flight_data_2015").take(1)
// in Scala
import org.apache.spark.sql.functions.max
flightData2015.select(max("count")).take(1)

这是一个简单例子,结果为370,002。来执行一些更复杂的操作,即在数据中找到前五个目标国家,先从一个相当简单的SQL聚合开始:

val maxSql = spark.sql("""
SELECT DEST_COUNTRY_NAME, sum(count) as destination_total
FROM flight_data_2015
GROUP BY DEST_COUNTRY_NAME
ORDER BY sum(count) DESC
LIMIT 5
""")
maxSql.show()

结果如下所示:

现在看看相同功能的DataFrame语法,它的语义和SQL相似但实现略有不同,但是两者的物理执行计划是相同的。接下来运行查询并观察现象,如下所示:

import org.apache.spark.sql.functions.desc
flightData2015
  .groupBy("DEST_COUNTRY_NAME")
  .sum("count")
  .withColumnRenamed("sum(count)", "destination_total")
  .sort(desc("destination_total"))
  .limit(5)
  .show()

从输入数据开始需要七个步骤,这可以在DataFrame的解释计划中看到这一点。下图显示了在代码中执行的一系列步骤:

由于Spark会针对物理执行计划做一系列优化,所以真正的执行计划(调用explain函数返回的执行计划)将不同于上图所示的执行计划。这个执行计划是一个有向无环图(DAG)的转换,每个transformation产生一个新的不可变的DataFrame,可以在这个DataFrame上调用一个action来产生一个结果。

上面代码第一步是读取数据。之前定义了DataFrame,但是Spark实际上并没有真正读取它,直到在DataFrame上调用action操作后才会真正读取它。

第二步是分组。当调用groupBy时,最终得到了一个RelationalGroupedDataset对象,它是一个DataFrame对象,它具有指定的分组,但需要用户指定聚合操作然后才能进一步查询,按键(或键集合)分组,然后再对每个键对应分组进行聚合操作。

第三步是指定聚合操作。使用sum聚合操作,这需要输入一个列表达式,或者简单的一个列名称。sum方法调用的结果是产生一个新的DataFrame,它有一个新的表结构,它也知道每个列的类型。再次强调,到这里还是没有执行计算,这只是表达的另一种transformation操作,而Spark能够通过这些转换操作跟踪类型信息。

第四步是简单的重命名。使用带有两个参数的withColumnRenamed方法,即原始列名称和新列名称。当然,这还不会执行计算,这也只是一种转换。

第五步对数据进行排序。让所有行按照destination_total列的大小排序,获得该DataFrame中destination_total值较大的一些行作为结果,可以看到必须导入一个函数来执行此操作,即desc函数,而且desc函数不是返回一个字符串,而是一个Column。通常来说,许多DataFrame方法将接受字符串(作为列名称)、Column类型、表达式,列和表达式实际上是完全相同的东西。

倒数第二步,指定了一个限制。这只是说明只想返回最终DataFrame中的前五个值而不是所有数据。

最后一步show()才是真正开始执行的action!现在才实际上开始收集DataFrame的结果,Spark将返回一个所用语法的数组或列表。来看看前面查询的解释计划:

== Physical Plan ==
TakeOrderedAndProject(limit=5, orderBy=[destination_total#16194L DESC], outpu...
+-*HashAggregate(keys=[DEST_COUNTRY_NAME#7323], functions=[sum(count#7325L)])
  +-Exchange hashpartitioning(DEST_COUNTRY_NAME#7323, 5)
    +-*HashAggregate(keys=[DEST_COUNTRY_NAME#7323], functions=[partial_sum...
      +-InMemoryTableScan [DEST_COUNTRY_NAME#7323, count#7325L]
        +-InMemoryRelation [DEST_COUNTRY_NAME#7323, ORIGIN_COUNTRY_NA...
          +-*Scan csv [DEST_COUNTRY_NAME#7578,ORIGIN_COUNTRY_NAME...

可以看到limit语句以及orderBy(在第一行),也可以看到聚合操作是如何在partial_sum调用中的两个阶段发生的,这是因为数字的sum操作是可交换的,并且Spark可以在每个分区单独执行sum操作。当然,也可以看到如何在DataFrame中读取数据。

二、Spark工具集介绍

7. Spark除了提供低级API和结构化API,还包括一系列标准库来提供额外的功能,如下所示:

在spark安装路径下使用spark-submit提交本地任务的命令例子如下所示,该程序计算pi的值以达到某个精度:

./bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master local \
./examples/jars/spark-examples_2.11-2.2.0.jar 10

8. 上面例子中的DataFrame是一个类型为Row的对象集合,它可以存储多种类型的表格数据,而Dataset用于在Java和Scala中编写静态类型的代码。Dataset API在Python和R中不可用,因为这些语言是动态类型的。Dataset API让用户可以用Java / Scala类定义DataFrame中的每条记录,并将其作为类型对象的集合来操作,类似于Java ArrayList或Scala Seq。Dataset中可用的API是类型安全的,这意味着Dataset中的对象不会被视为与初始定义的类不相同的另一个类。这使得Dataset在编写大型应用程序时尤其有效,这样多个工程师可以通过协商好的接口进行交互。

Dataset类通过内部包含的对象类型进行参数化,如Java中的Dataset <T>,和Scala中的Dataset [T] Dataset [Person]将仅包含Person类的对象。从类型需要Spark 2.0开始,受支持的类型遵循Java中的JavaBean模式,或是Scala中的case类。之所以这些类型需要受到限制,是因为Spark要能够自动分析类型T,并为Dataset中的表格数据创建适当的模式

Dataset的一个好处是,在任何需要的代码位置可以使用。例如,定义自己的数据类型,并通过某种map函数和filter函数来操作它,完成操作之后Spark可以自动将其重新转换为DataFrame,并且可以使用其他函数进一步处理它,这样可以很容易地降到较低的级别,在必要时执行类型安全的编码,并且也可以升级到更高级的SQL,以进行更快速的分析。下面的例子展示了如何使用类型安全函数和DataFrame类SQL表达式来快速编写业务逻辑:

case class Flight(DEST_COUNTRY_NAME: String,
  ORIGIN_COUNTRY_NAME: String,
  count: BigInt)
val flightsDF = spark.read
  .parquet("/data/flight-data/parquet/2010-summary.parquet/")
val flights = flightsDF.as[Flight]

还有一个好处是,当在Dataset上调用collect或take时,它将会收集Dataset中合适类型的对象,而不是DataFrame的Row对象。这样可以很容易地保证类型安全,并安全地执行操作,且无需更改代码,如下所示:

flights
  .filter(flight_row => flight_row.ORIGIN_COUNTRY_NAME != "Canada")
  .map(flight_row => flight_row)
  .take(5)
flights
  .take(5)
  .filter(flight_row => flight_row.ORIGIN_COUNTRY_NAME != "Canada")
  .map(fr => Flight(fr.DEST_COUNTRY_NAME, fr.ORIGIN_COUNTRY_NAME, fr.count + 5))

9. 结构化流处理(structured streaming)是用于数据流处理的高级API,在Spark 2.2后可用。可以如同在批处理模式下一样使用Spark的结构化API执行结构化流处理,并以流式方式运行它们,使用结构化流处理可以减少延迟并允许增量处理。最重要的是,它可以快速地从流式系统中提取有价值的信息,而且几乎不需要更改代码。可以按照传统批处理作业的模式进行设计,然后将其转换为流式作业,即增量处理数据,这样就使得流处理任务变得异常简单。

可以通过一个简单例子来说明如何使用Spark结构化流处理。例如使用一个销售数据集,这个数据集有日期和时间信息,程序将使用按天分组的文件,每一个文件夹中包含一天的数据。可以用另外一个进程来模拟持续产生的数据,假设这些数据是由零售商持续生成的,并由结构化流式处理作业进行处理。大概的数据格式如下:

InvoiceNo,StockCode,Description,Quantity,InvoiceDate,UnitPrice,CustomerID,Country
536365,85123A,WHITE HANGING HEART T-LIGHT HOLDER,6,2010-12-01 08:26:00,2.55,17...
536365,71053,WHITE METAL LANTERN,6,2010-12-01 08:26:00,3.39,17850.0,United Kin...
536365,84406B,CREAM CUPID HEARTS COAT HANGER,8,2010-12-01 08:26:00,2.75,17850...

首先按照静态数据集的处理方法来进行分析,并创建一个DataFrame来执行此操作,并且还将从这个静态数据集创建一个schema模式,如下所示:

val staticDataFrame = spark.read.format("csv")
  .option("header", "true")
  .option("inferSchema", "true")
  .load("/data/retail-data/by-day/*.csv")

staticDataFrame.createOrReplaceTempView("retail_data")
val staticSchema = staticDataFrame.schema

因为处理的是时间序列数据,所以在此之前需要强调如何对数据进行分组和聚合操作。在这个例子中,将看看特定客户(CustomerId)进行大量采购的时间。例如,添加一个列用于统计总费用,并查看客户花费最多的那个日期。

窗口函数包含每天的所有数据,它只是数据中时间序列栏的一个窗口,这是一个用于处理日期和时间戳的有用工具,因为可以通过时间间隔指定需求,而Spark将会将所有数据集合起来,如下所示:

import org.apache.spark.sql.functions.{window, column, desc, col}

staticDataFrame
  .selectExpr(
    "CustomerId",
    "(UnitPrice * Quantity) as total_cost",
    "InvoiceDate")
  .groupBy(
    col("CustomerId"), window(col("InvoiceDate"), "1 day"))
  .sum("total_cost")
  .show(5)

值得一提的是也可以像SQL代码那样运行它,以下是看到的输出:

null表示某些交易没有customerId标签,这是静态的DataFrame版本。现在来看看上面代码的流处理版本,实际上需要更改的地方很少,最大的变化是使用readStream而不是read,另外会注意到maxFilesPerTrigger选项,它只是指定应该一次读入的文件数量,如下所示:

val streamingDataFrame = spark.readStream
  .schema(staticSchema)
  .option("maxFilesPerTrigger", 1)
  .format("csv")
  .option("header", "true")
  .load("/data/retail-data/by-day/*.csv")

现在可以看到该DataFrame是否代表流数据:

streamingDataFrame.isStreaming // 返回true

10. 从上面代码可以看到,对流数据执行的业务逻辑与之前对静态DataFrame执行的业务逻辑相同(按时间窗口统计费用)。然后对读取的数据进行处理:

val purchaseByCustomerPerHour = streamingDataFrame
  .selectExpr(
    "CustomerId",
    "(UnitPrice * Quantity) as total_cost",
    "InvoiceDate")
  .groupBy(
    $"CustomerId", window($"InvoiceDate", "1 day"))
  .sum("total_cost")

这仍然是一个惰性操作,所以需要调用一个对流数据的动作来让这个流处理开始执行。流数据动作与静态数据动作有点不同,因为首先要将流数据缓存到某个地方,而不是像对静态数据那样直接调用count函数(对流数据没有任何意义)。流数据将被缓存到一个内存上的数据表里,当每次被触发器触发(trigger)后更新这个内存缓存。在这个例子中,因为之前设置的maxFilesPerTrigger选项,每次读完一个文件后都会被触发,Spark将基于新读入的文件更新内存数据表的内容,这样聚合操作可以始终维护着历史数据中的最大值。后续的action操作如下所示:

purchaseByCustomerPerHour.writeStream
  .format("memory") // memory代表将表存入内存
  .queryName("customer_purchases") // 存入内存的表的名称
  .outputMode("complete") // complete表示保存表中所有记录
  .start()

当启动数据流后,可以运行查询来调试结果,下面代码查看结果是否已经被写入结果的接收器:

spark.sql("""
  SELECT *
  FROM customer_purchases
  ORDER BY `sum(total_cost)` DESC
  """)
  .show(5)

可以看到,输出表格的内容会随着读入更多的数据而发生实时变化。在处理完每个文件后,结果可能会根据数据发生改变,也可能不会。当然,在该例子中因为要根据客户购买能力对客户进行分组,所以希望随着时间的推移,客户的最大购买量会增加。另外,也可以将结果输出到控制台,只要把上面的.format("memory")改为.format("console")就行。

11. Spark的另一个优势是,它使用称为MLlib的机器学习算法内置库支持大规模机器学习。Mllib支持对数据进行预处理、整理、模型训练和大规模预测,甚至可以使用MLlib中训练的模型在structured streaming中对流数据进行预测。Spark提供了一个复杂的机器学习API,用于执行各种机器学习任务,从分类到回归,从聚类到深度学习。

接下来会使用k-means标准聚类算法作为例子,对数据执行一些基本的聚类操作,k-means首先从数据中随机选出k个初始聚类中心,最接近某个中心的那些点被分配到一个聚类里,并根据分配到该聚类的点计算它们的中心,这个中心被称为centroid。然后,将最接近该centroid的点标记为属于该centroid的点,并根据分配到某个centroid的点群计算新的中心用来更新centroid,重复这个过程进行有限次的迭代,或者直到收敛(中心点停止变化)。

Spark准备了许多内置的预处理方法,这些预处理方法将原始数据转换为合适的数据格式,它将在之后用于的实际训练模型中,并进一步进行预测,用到的数据各字段格式如下:

root
  |--InvoiceNo: string (nullable = true)
  |--StockCode: string (nullable = true)
  |--Description: string (nullable = true)
  |--Quantity: integer (nullable = true)
  |--InvoiceDate: timestamp (nullable = true)
  |--UnitPrice: double (nullable = true)
  |--CustomerID: double (nullable = true)
  |--Country: string (nullable = true)

MLlib中的机器学习算法要求将数据表示为数值形式,而上面的数据由多种不同类型表示,包括时间戳、整数和字符串等,因此需要将这些数据转换为数值。在这个例子中,将使用几个DataFrame转换来处理日期数据:

import org.apache.spark.sql.functions.date_format
val preppedDataFrame = staticDataFrame
  .na.fill(0)
  .withColumn("day_of_week", date_format($"InvoiceDate", "EEEE"))
  .coalesce(5)

同样,也需要将数据分成训练和测试集。在该示例中,可以手动将某个购买日期之前的数据作为训练集,其之后的数据为测试集。也可以使用MLlib的转换API通过训练验证分割或交叉验证来创建训练和测试集,如下所示:

val trainDataFrame = preppedDataFrame
  .where("InvoiceDate < '2011-07-01'")
val testDataFrame = preppedDataFrame
  .where("InvoiceDate >= '2011-07-01'")

现在已经准备好了数据,再把它分成一个训练集和一个测试集。由于这是一组时间序列数据,因此在数据集中选择一个一个日期作为分割,可以看到数据集被大致分为trainDataFrame和testDataFrame两部分。需要注意的是,这些transformation是DataFrame转换操作,接下来使用StringIndexer这种MLib提供的转换:

import org.apache.spark.ml.feature.StringIndexer
val indexer = new StringIndexer()
  .setInputCol("day_of_week")
  .setOutputCol("day_of_week_index")

这将使每周的星期几转换成相应的数值,例如Spark可以将星期六表示为6,将星期一表示为1。但是通过此编号方案,会发现星期六大于星期一(因为由纯数值表示),但是在业务场景中这样是不正确的。为了解决这个问题,需要使用一个OneHotEncoder来将每个值编码为其原来对应的列,这些布尔变量标识了该数值是否为与星期几相关的日子:

import org.apache.spark.ml.feature.OneHotEncoder
val encoder = new OneHotEncoder()
  .setInputCol("day_of_week_index")
  .setOutputCol("day_of_week_encoded")

其中每一个都会产生一组列,我们将“组合”它们成一个向量。Spark中的机器学习算法输入都为Vector类型,即一组数值

import org.apache.spark.ml.feature.VectorAssembler
val vectorAssembler = new VectorAssembler()
  .setInputCols(Array("UnitPrice", "Quantity", "day_of_week_encoded"))
  .setOutputCol("features")

在这里有三个关键特征:价格、数量和星期几。接下来将把上面那几个操作设置为流水线处理模式,这样一来,就可以通过完全相同的流程对未来新产生的数据进行转换:

import org.apache.spark.ml.Pipeline
val transformationPipeline = new Pipeline()
  .setStages(Array(indexer, encoder, vectorAssembler))

训练的准备过程需要两步,首先需要为数据设置合适的转换操作,StringIndexer需要知道有多少非重复值,这样才能对应每个字符串一个数值,另外编码操作很容易,但Spark必须查看要索引的列中存在的所有不同值,这样才可以在稍后存储这些值:

val fittedPipeline = transformationPipeline.fit(trainDataFrame)

12. 在配置好了训练数据后,下一步是采用流水线处理模型完成整个数据预处理过程,以持续和可重复的模式来转换所有数据:

val transformedTraining = fittedPipeline.transform(trainDataFrame)

在此值得一提的是,可以将模型训练过程也加入到流水线处理过程中,而不这样做是为了缓存整个训练数据,这样可以对模型训练过程中的超参数进行调整,避免持续重复的进行训练过程中的转换操作。对于缓存过程,它将中间转换数据集的副本立即放入内存,这样能以较低的性能开销反复访问数据,这远比重新运行整个流水线处理得到训练数据集节省开销。如果想比较这会造成多大的性能区别,可以尝试对两种情况进行分别训练,会看到性能的显著差别:

transformedTraining.cache()

现在有了一套训练数据集,是时候训练模型了。首先导入想要使用的相关模型包,并使用和实例化它:

import org.apache.spark.ml.clustering.KMeans
val kmeans = new KMeans()
  .setK(20)
  .setSeed(1L)

在Spark中,训练机器学习模型是一个两阶段过程。首先,初始化一个未经训练的模型,然后进行训练。MLlib的DataFrame API中的每种算法有两种类型,对于未经训练的算法版本,它们遵循“XXAlgorithm”的命名方式,对于训练后的算法版本,使用“XXXAlgorithmModel”的命名方式。在当前例子中,就是未训练的“KMeans”和训练完的“KMeansModel”。

MLlib的DataFrame API中的估计器,与之前看到的像StringIndexer这样的预处理转换操作使用大致相同的接口,它使得整个流水线处理过程(包括模型训练)变得简单。在这个例子中,先选择不把模型训练包含到流水线处理过程中:

val kmModel = kmeans.fit(transformedTraining)

在训练完这个模型之后,可以根据训练集上的一些评价指标来评估开销。处理这个数据集带来的开销实际上相当高,这可能是由于预处理和数据扩展部分没有做好,如下所示:

val transformedTest = fittedPipeline.transform(testDataFrame)
kmModel.computeCost(transformedTraining)

当然,可以继续改进这个模型,执行更多的预处理过程,以及执行超参数调整,以确保获得一个更好的模型。

13. Spark包含了很多低级API原语,以支持通过弹性分布式数据集(RDD)对任意的Java和Python对象进行操作,事实上,Spark中的所有对象都建立在RDD之上,DataFrame操作都是基于RDD之上的,这些高级操作被编译到较低级的RDD上执行。有时候可能会使用RDD,特别是在读取或操作原始数据时,但大多数情况下应该坚持使用高级的结构化API。RDD比DataFrame更低级,因为它向终端用户暴露物理执行特性(如分区)。

可以使用RDD来并行化已经存储在driver内存中的原始数据。例如并行化一些简单的数字并创建一个DataFrame,可以将RDD转换为DataFrame,以便与其他DataFrame一起使用它:

spark.sparkContext.parallelize(Seq(1, 2, 3)).toDF()

RDD可以在Scala和Python中使用,但是它们并不完全等价,这与DataFrame API(执行特性相同)有所不同,这是由于RDD某些底层实现细节导致的区别。作为用户,除非维护的是较老的Spark代码,否则不需要使用RDD来执行任务。Spark最新版本基本上没有RDD的实例,所以除了处理一些非常原始的未处理和非结构化数据之外,应该使用结构化API而不是RDD

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值