SparkSQL 練習項目 - 出租車利用率分析
本項目是 SparkSQL 階段的練習項目, 主要目的是夯實同學們對於 SparkSQL 的理解和使用
- 數據集
-
2013年紐約市出租車乘車記錄
- 需求
-
統計出租車利用率, 到某個目的地後, 出租車等待下一個客人的間隔
1. 業務
-
數據集介紹
-
業務場景介紹
-
和其它業務的關聯
-
通過項目能學到什麼
- 數據集結構
- 業務場景
- 技術點和其它技術的關係
- 在這個小節中希望大家掌握的知識
2. 流程分析
-
分析的步驟和角度
-
流程
- 分析的視角
- 步驟分析
3. 數據讀取
-
工程搭建
-
數據讀取
- 工程搭建
</project>
創建 Scala 源碼目錄 src/main/scala
並且設置這個目錄爲 Source Root
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
}
}
Step 2
: 數據讀取- 數據讀取之前要做兩件事
-
-
初始化環境, 導入必備的一些包
-
在工程根目錄中創建
dataset
文件夾, 並拷貝數據集進去
-
- 代碼如下
-
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// 1. 創建 SparkSession
val spark = SparkSession.builder()
.master(“local[6]”)
.appName(“taxi”)
.getOrCreate()
// 2. 導入函數和隱式轉換
import spark.implicits._
import org.apache.spark.sql.functions._
// 3. 讀取文件
val taxiRaw = spark.read
.option("header", value = true)
.csv("dataset/half_trip.csv")
taxiRaw.show()
taxiRaw.printSchema()
}
}
root
|-- medallion: string (nullable = true)
|-- hack_license: string (nullable = true)
|-- vendor_id: string (nullable = true)
|-- rate_code: string (nullable = true)
|-- store_and_fwd_flag: string (nullable = true)
|-- pickup_datetime: string (nullable = true)
|-- dropoff_datetime: string (nullable = true)
|-- passenger_count: string (nullable = true)
|-- trip_time_in_secs: string (nullable = true)
|-- trip_distance: string (nullable = true)
|-- pickup_longitude: string (nullable = true)
|-- pickup_latitude: string (nullable = true)
|-- dropoff_longitude: string (nullable = true)
|-- dropoff_latitude: string (nullable = true)
5. 數據清洗
-
將
Row
對象轉爲Trip
-
處理轉換過程中的報錯
- 數據轉換
def main(args: Array[String]): Unit = {
// 此處省略 Main 方法中內容
}
}
/**
- 代表一個行程, 是集合中的一條記錄
- @param license 出租車執照號
- @param pickUpTime 上車時間
- @param dropOffTime 下車時間
- @param pickUpX 上車地點的經度
- @param pickUpY 上車地點的緯度
- @param dropOffX 下車地點的經度
- @param dropOffY 下車地點的緯度
*/
case class Trip(
license: String,
pickUpTime: Long,
dropOffTime: Long,
pickUpX: Double,
pickUpY: Double,
dropOffX: Double,
dropOffY: Double
)
Step 2
: 將 Row
對象轉爲 Trip
對象, 從而將 DataFrame
轉爲 Dataset[Trip]
首先應該創建一個新方法來進行這種轉換, 畢竟是一個比較複雜的轉換操作, 不能怠慢
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// … 省略數據讀取
// 4. 數據轉換和清洗
val taxiParsed = taxiRaw.rdd.map(parse)
}
/**
* 將 Row 對象轉爲 Trip 對象, 從而將 DataFrame 轉爲 Dataset[Trip] 方便後續操作
* @param row DataFrame 中的 Row 對象
* @return 代表數據集中一條記錄的 Trip 對象
*/
def parse(row: Row): Trip = {
}
}
case class Trip(…)
Step 3
: 創建 Row
對象的包裝類型因爲在針對 Row
類型對象進行數據轉換時, 需要對一列是否爲空進行判斷和處理, 在 Scala
中爲空的處理進行一些支持和封裝, 叫做 Option
, 所以在讀取 Row
類型對象的時候, 要返回 Option
對象, 通過一個包裝類, 可以輕鬆做到這件事
創建一個類 RichRow
用以包裝 Row
類型對象, 從而實現 getAs
的時候返回 Option
對象
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// …
// 4. 數據轉換和清洗
val taxiParsed = taxiRaw.rdd.map(parse)
}
def parse(row: Row): Trip = {…}
}
case class Trip(…)
class RichRow(row: Row) {
def getAs[T](field: String): Option[T] = {
if (row.isNullAt(row.fieldIndex(field)) || StringUtils.isBlank(row.getAsString)) {
None
} else {
Some(row.getAsT)
}
}
}
Step 4
: 轉換流程已經存在, 並且也已經爲空值處理做了支持, 現在就可以進行轉換了
首先根據數據集的情況會發現, 有如下幾種類型的信息需要處理
-
字符串類型
執照號就是字符串類型, 對於字符串類型, 只需要判斷空, 不需要處理, 如果是空字符串, 加入數據集的應該是一個
null
-
時間類型
上下車時間就是時間類型, 對於時間類型需要做兩個處理
-
轉爲時間戳, 比較容易處理
-
如果時間非法或者爲空, 則返回
0L
-
-
Double
類型上下車的位置信息就是
Double
類型,Double
類型的數據在數據集中以String
的形式存在, 所以需要將String
類型轉爲Double
類型
總結來看, 有兩類數據需要特殊處理, 一類是時間類型, 一類是 Double
類型, 所以需要編寫兩個處理數據的幫助方法, 後在 parse
方法中收集爲 Trip
類型對象
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// …
// 4. 數據轉換和清洗
val taxiParsed = taxiRaw.rdd.map(parse)
}
def parse(row: Row): Trip = {
// 通過使用轉換方法依次轉換各個字段數據
val row = new RichRow(row)
val license = row.getAsString.orNull
val pickUpTime = parseTime(row, “pickup_datetime”)
val dropOffTime = parseTime(row, “dropoff_datetime”)
val pickUpX = parseLocation(row, “pickup_longitude”)
val pickUpY = parseLocation(row, “pickup_latitude”)
val dropOffX = parseLocation(row, “dropoff_longitude”)
val dropOffY = parseLocation(row, “dropoff_latitude”)
// 創建 Trip 對象返回
Trip(license, pickUpTime, dropOffTime, pickUpX, pickUpY, dropOffX, dropOffY)
}
/**
* 將時間類型數據轉爲時間戳, 方便後續的處理
* @param row 行數據, 類型爲 RichRow, 以便於處理空值
* @param field 要處理的時間字段所在的位置
* @return 返回 Long 型的時間戳
*/
def parseTime(row: RichRow, field: String): Long = {
val pattern = “yyyy-MM-dd HH:mm:ss”
val formatter = new SimpleDateFormat(pattern, Locale.ENGLISH)
val timeOption = row.getAs[String](field)
timeOption.map( time => formatter.parse(time).getTime )
.getOrElse(0L)
}
/**
* 將字符串標識的 Double 數據轉爲 Double 類型對象
* @param row 行數據, 類型爲 RichRow, 以便於處理空值
* @param field 要處理的 Double 字段所在的位置
* @return 返回 Double 型的時間戳
*/
def parseLocation(row: RichRow, field: String): Double = {
row.getAsString.map( loc => loc.toDouble ).getOrElse(0.0D)
}
}
case class Trip(…)
class RichRow(row: Row) {…}
- 異常處理
def safe(function: Double => Double, b: Double): Either[Double, (Double, Exception)] = { (2)
try {
val result = function(b) (3)
Left(result)
} catch {
case e: Exception => Right(b, e) (4)
}
}
val result = safe(process, 0) (5)
result match { (6)
case Left® => println®
case Right((b, e)) => println(b, e)
}
1 | 一個函數, 接收一個參數, 根據參數進行除法運算 |
2 | 一個方法, 作用是讓 process 函數調用起來更安全, 在其中 catch 錯誤, 報錯後返回足夠的信息 (報錯時的參數和報錯信息) |
3 | 正常時返回 Left , 放入正確結果 |
4 | 異常時返回 Right , 放入報錯時的參數, 和報錯信息 |
5 | 外部調用 |
6 | 處理調用結果, 如果是 Right 的話, 則可以進行響應的異常處理和彌補 |
Either
和 Option
比較像, 都是返回不同的情況, 但是 Either
的 Right
可以返回多個值, 而 None
不行
如果一個 Either
有兩個結果的可能性, 一個是 Left[L]
, 一個是 Right[R]
, 則 Either
的範型是 Either[L, R]
Step 2
: 完成代碼邏輯加入一個 Safe 方法, 更安全
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// …
// 4. 數據轉換和清洗
val taxiParsed = taxiRaw.rdd.map(safe(parse))
}
/**
* 包裹轉換邏輯, 並返回 Either
*/
def safe[P, R](f: P => R): P => Either[R, (P, Exception)] = {
new Function[P, Either[R, (P, Exception)]] with Serializable {
override def apply(param: P): Either[R, (P, Exception)] = {
try {
Left(f(param))
} catch {
case e: Exception => Right((param, e))
}
}
}
}
def parse(row: Row): Trip = {…}
def parseTime(row: RichRow, field: String): Long = {…}
def parseLocation(row: RichRow, field: String): Double = {…}
}
case class Trip(…)
class RichRow(row: Row) {…}
Step 3
: 針對轉換異常進行處理對於 Either
來說, 可以獲取 Left
中的數據, 也可以獲取 Right
中的數據, 只不過如果當 Either
是一個 Right 實例時候, 獲取 Left
的值會報錯
所以, 針對於 Dataset[Either]
可以有如下步驟
-
試運行, 觀察是否報錯
-
如果報錯, 則打印信息解決報錯
-
如果解決不了, 則通過
filter
過濾掉Right
-
如果沒有報錯, 則繼續向下運行
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…
// 4. 數據轉換和清洗
val taxiParsed = taxiRaw.rdd.map(safe(parse))
val taxiGood = taxiParsed.map( either => either.left.get ).toDS()
}
…
}
…
很幸運, 在運行上面的代碼時, 沒有報錯, 如果報錯的話, 可以使用如下代碼進行過濾
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…
// 4. 數據轉換和清洗
val taxiParsed = taxiRaw.rdd.map(safe(parse))
val taxiGood = taxiParsed.filter( either => either.isLeft )
.map( either => either.left.get )
.toDS()
}
…
}
…
def main(args: Array[String]): Unit = {
…
// 5. 過濾行程無效的數據
val hours = (pickUp: Long, dropOff: Long) => {
val duration = dropOff - pickUp
TimeUnit.HOURS.convert(, TimeUnit.MILLISECONDS)
}
val hoursUDF = udf(hours)
}
…
}
Step 2:
統計時長分佈-
第一步應該按照行程時長進行分組
-
求得每個分組的個數
-
最後按照時長排序並輸出結果
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…
// 5. 過濾行程無效的數據
val hours = (pickUp: Long, dropOff: Long) => {
val duration = dropOff - pickUp
TimeUnit.MINUTES.convert(, TimeUnit.MILLISECONDS)
}
val hoursUDF = udf(hours)
taxiGood.groupBy(hoursUDF($"pickUpTime", $"dropOffTime").as("duration"))
.count()
.sort("duration")
.show()
}
…
}
會發現, 大部分時長都集中在 1 - 19
分鐘內
+--------+-----+
|duration|count|
+--------+-----+
| 0| 86|
| 1| 140|
| 2| 383|
| 3| 636|
| 4| 759|
| 5| 838|
| 6| 791|
| 7| 761|
| 8| 688|
| 9| 625|
| 10| 537|
| 11| 499|
| 12| 395|
| 13| 357|
| 14| 353|
| 15| 264|
| 16| 252|
| 17| 197|
| 18| 181|
| 19| 136|
+--------+-----+
Step 3:
註冊函數, 在 SQL 表達式中過濾數據大部分時長都集中在 1 - 19
分鐘內, 所以這個範圍外的數據就可以去掉了, 如果同學使用完整的數據集, 會發現還有一些負的時長, 好像是回到未來的場景一樣, 對於這種非法的數據, 也要過濾掉, 並且還要分析原因
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…
// 5. 過濾行程無效的數據
val hours = (pickUp: Long, dropOff: Long) => {
val duration = dropOff - pickUp
TimeUnit.MINUTES.convert(, TimeUnit.MILLISECONDS)
}
val hoursUDF = udf(hours)
taxiGood.groupBy(hoursUDF($"pickUpTime", $"dropOffTime").as("duration"))
.count()
.sort("duration")
.show()
spark.udf.register("hours", hours)
val taxiClean = taxiGood.where("hours(pickUpTime, dropOffTime) BETWEEN 0 AND 3")
taxiClean.show()
}
…
}
6. 行政區信息
- 目標和步驟
- 總結
6.1. 需求介紹
- 目標和步驟
- 思路整理
- GeoJSON 是什麼
- 總結
6.2. 工具介紹
- 目標和步驟
- JSON4S 介紹
case class Product(name: String, price: Double)
val product =
“”"
|{“name”:“Toy”,“price”:35.35}
“”".stripMargin
// 可以解析 JSON 爲對象
val obj: Product = parse(product).extra[Product]
// 可以將對象序列化爲 JSON
val str: String = compact(render(Product(“電視”, 10.5)))
// 使用序列化 API 之前, 要先導入代表轉換規則的 formats 對象隱式轉換
implicit val formats = Serialization.formats(NoTypeHints)
// 可以使用序列化的方式來將 JSON 字符串反序列化爲對象
val obj1 = readPerson
// 可以使用序列化的方式將對象序列化爲 JSON 字符串
val str1 = write(Product(“電視”, 10.5))
GeometryEngine.contains(geometry, other, csr) (3)
1 | 讀取 JSON 生成 Geometry 對象 |
2 | 重點: 一個 Geometry 對象就表示一個 GeoJSON 支持的對象, 可能是一個點, 也可能是一個多邊形 |
3 | 判斷一個 Geometry 中是否包含另外一個 Geometry |
6.3. 具體實現
- 目標和步驟
- 解析 JSON
case class Feature(
id: Int,
properties: Map[String, String],
geometry: JObject
)
case class FeatureProperties(boroughCode: Int, borough: String)
Step 2: 將 JSON
字符串解析爲目標類對象
創建工具類實現功能
object FeatureExtraction {
def parseJson(json: String): FeatureCollection = {
implicit val format: AnyRef with Formats = Serialization.formats(NoTypeHints)
val featureCollection = readFeatureCollection
featureCollection
}
}
Step 3: 讀取數據集, 轉換數據
val geoJson = Source.fromFile("dataset/nyc-borough-boundaries-polygon.geojson").mkString
val features = FeatureExtraction.parseJson(geoJson)
def getGeometry: Geometry = { (2)
GeometryEngine.geoJsonToGeometry(compact(render(geometry)), 0, Geometry.Type.Unknown).getGeometry
}
}
1 | geometry 對象需要使用 ESRI 解析並生成, 所以此處並沒有使用具體的對象類型, 而是使用 JObject 表示一個 JsonObject , 並沒有具體的解析爲某個對象, 節省資源 |
2 | 將 JSON 轉爲 Geometry 對象 |
val boroughUDF = udf(boroughLookUp)
Step 4: 測試轉換結果, 統計每個行政區的出租車數據數量
-
動機: 寫完功能最好先看看, 運行一下
taxiClean.groupBy(boroughUDF('dropOffX, 'dropOffY))
.count()
.show()
7. 會話統計
- 目標和步驟
- 會話統計的概念
- 功能實現
val boroughDurations = sessions.mapPartitions(trips => {
val viter = trips.sliding(2)
.filter(_.size == 2)
.filter(p => p.head.license == p.last.license)
viter.map(p => boroughDuration(p.head, p.last))
}).toDF(“borough”, “seconds”)
Step 4: 統計數據
boroughDurations.where("seconds > 0")
.groupBy("borough")
.agg(avg("seconds"), stddev("seconds"))
.show()