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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章