JVM上的Pandas: tablesaw

小蟲飛飛發表於2020-02-27

PandasPython 界最流行的資料統計基礎庫之一。但像我這樣和 Java/Scala 打交道的人,還是期望 JVM 有類似的解決方案。網上一搜發現生態還是很豐富的:大資料領域的 Spark ,支援 GUI 的資料探勘套件 weka ,主攻機器學習的 smile ,擅長聚合變換的 joinery 等等。但小蟲最終還是選擇了簡約而不簡單的 tablesaw 。緣由還得從那句老話說起: 離開場景談應用都是耍流氓

應用場景

資料處理流程

工作環境

小蟲在工作中使用 Spark 將業務產生的海量使用者行為按模組加工:過濾冗餘/簡單彙總,並匯出至 PostgreSQL 的不同表中,儲存正交化的基礎資料。比如使用者的登陸/購買行為分別記錄到, LoginTableShoppingTable 。然後在 二次處理 模組中建立不同服務,比如 S1,S2。S1 直接訪問資料庫,S2 的既要訪問資料庫,又要訪問 S1 的統計結果,還要依賴本地的 csv 檔案。

計算需求分類

  • 查詢計算。整表查詢,列的統計,變換等。
  • 篩選排序。條件查詢,自定義多重排序等。
  • 聚合連線。比如 LoginTable 記錄終端型別:android/iOS; ShoppingTable 記錄了使用者購買物品的種類和數量。當要對比不同終端使用者購買行為差異時,就要將兩個表連線並按終端型別聚合。
  • 模型驗證。評估決策需要的分析模型多變,要經過反覆調整得到最終結果。關鍵在於 快速迭代

特點彙總

緯度 特點 說明
資料量 較小單機可承載 原始資料由 spark 彙總
格式 資料庫,本地 csv,json json 多來自於 restful 服務
計算 增刪改查,條件查詢,表連線,聚合,統計 內建運算元越多,擴充套件性越高越好
互動 輸出到指定格式,視覺化,可互動 等 網頁渲染,終端,[Jupyter Notebook](https://jupyter.org/)
整合 輕量,以庫而非服務的形式 便於嵌入程式和其他邏輯互動

為何選擇 tablesaw

很簡單,就因為它完美契合小蟲的應用場景。借用 tablesaw官網 的特性列表:

  • 資料匯入:RDBMS,Excel,CSV,Json,HTML 或固定寬度檔案。除了支援本地訪問,還支援通過 http,S3 等遠端訪問。
  • 資料匯出:CSV,Json,HTML 或固定寬度檔案。
  • 表格操作:類似 Pandas DataFrame ,增刪改查,連線,排序。

以上是基礎功能,小蟲覺得下面幾個點更有意思:

  • 基於Plotly 的視覺化框架。擺脫 java 的 UI 系統,更好的和 Web 對接。支援 2D、3D 檢視,圖表型別也很豐富:曲線,散點,箱形統計,蠟燭圖,熱力圖,餅狀圖等。更重要的是:
    • 互動式圖表。特別適合多種資料集對比,以及三維視角旋轉。
    • 圖表匯出為字串形式 Javascript 。方便結合 Web Service 渲染 html。
  • smile 對接。tablesaw 可將表格匯出為 smile 識別的資料格式,便於利用其強大的機器學習庫。

好了,說了這麼多,直接上乾貨吧。

基本應用

安裝

tablesaw 包含多個庫,小蟲推薦安裝 tablesaw-coretablesaw-jsplot 。前者是基礎庫,後者用於渲染圖表。其它如 tablesaw-html, tablesaw-json, tablesaw-breakerx 主要是對資料格式變化的支援,可按需選擇。其實結合需求寫兩行程式碼就行,輕量又靈活。以 tablesaw-core 為例說明 Jar 包安裝方法:

maven倉庫配置:

<dependency>
  <groupId>tech.tablesaw</groupId>
  <artifactId>tablesaw-core</artifactId>
  <version>0.37.3</version>
</dependency>
複製程式碼

sbt倉庫配置:

libraryDependencies += "tech.tablesaw" % "tablesaw-core" % "0.37.3"
複製程式碼

表格建立

兩種基本方式:

  • 從資料來源讀取直接建立
  • 建立空表格編碼增加列或行

表格建立

下面先定義需要處理的 csv 檔案格式。第一列為日期,第二列為姓名,第三列為工時(當日工作時長,單位是小時),第四列為報酬(單位是元)。然後舉三個典型例子來說明匯入的不同方式。

1. CSV 直接匯入

// 讀取csv檔案input.csv 自動推測schema
val tbl = Table.read().csv("input.csv")

// 產看讀入的表格內容
println(tbl.printAll())

// 檢視schema
println(tbl.columnArray().mkString("\n")) 
複製程式碼

輸出表格內容為:

date name 工時 報酬
2019-01-08 tom 8 1000
2019-01-09 jerry 7 500
2019-01-10 張三 8 999
2019-01-10 jerry 8 550
2019-01-10 tom 8 1000
2019-01-11 張三 6 800
2019-01-11 李四 12 1500
2019-01-11 王五 8 900
2019-01-11 tom 6.5 800

可以發現能夠比較完美的推測,並對中文支援良好。輸出 schema 為:

Date column: date

String column: name

Double column: 工時

Integer column: 報酬

tablesaw 目前支援的資料型別有以下幾種:SHORT, INTEGER, LONG ,FLOAT ,BOOLEAN ,STRING ,DOUBLE ,LOCAL_DATE ,LOCAL_TIME ,LOCAL_DATE_TIME, INSTANT, TEXT, SKIP。絕大部分列和普通資料表型別沒有差異,需要強調的是兩類:

  • INSTANT。可以精確到納秒的時間戳,自 Java 8 引入。
  • SKIP。指定列忽略不讀入。

2. 指定 schema 從 CSV 匯入

有時自動推測並不會非常精準,比如期望使用 LONG ,但識別為 INTEGER ;或在讀入後追加資料時型別會有變化,比如報酬讀入是整型但隨後動態增加會有浮點資料。這時就需要預先設定 csv 的 schema ,這時可以利用 tablesaw 提供的 CsvReadOptions 實現。比如預先設定報酬為浮點:

import tech.tablesaw.api.ColumnType
import tech.tablesaw.io.csv.CsvReadOptions

// 按序指定csv 各列的資料型別
val colTypes: Array[ColumnType] = Array(ColumnType.LOCAL_DATE, ColumnType.STRING, ColumnType.DOUBLE, ColumnType.DOUBLE)
val csvReadOptions = CsvReadOptions.builder("demo.csv").columnTypes(colTypes)
val tbl = Table.read().usingOptions(csvReadOptions)

// 檢視schema
println(tbl.columnArray().mkString("\n")) 
複製程式碼

輸出 schema 為:

Date column: date

String column: name

Double column: 工時

Double column: 報酬

3. 編碼設定 schema 和資料填充

該方法適合各種場景,可以執行時從不同資料來源匯入資料。

基本流程是:

  • 建立空表格,同時設定名稱

  • 設定 schema:向表格中按序增加指定了 名稱資料型別 的列。

  • 向表格中按行追加資料。每行中的元素分別新增到指定列中。

    將之前的例子做些變化,假設資料來自於網路,序列化到本地記憶體的資料結構為:

// 以case class 的形式定義資料來源轉化到本地的記憶體結構
case class RowData(date: LocalDate, name: String, workTime: Double, salary: Double)
複製程式碼

建立一個函式將獲取的資料集合新增到表格中:

// @param tableName 表格名稱
// @param colNames  表格各列的名稱列表
// @param colTypes  表格各列的資料型別列表
// @param rows      列資料
def createTable(tblName: String, colNames: Seq[String], colTypes: Seq[ColumnType], rows: Seq[RowData]): Table = {
  // 建立表格設定名稱
  val tbl = Table.create(tblName)

  // 建立schema :按序增加列
  val colCnt = math.min(colTypes.length, colNames.length)
  val cols = (0 until colCnt).map { i =>
    colTypes(i).create(colNames(i))
  }
  tbl.addColumns(cols: _*)

  // 新增資料
  rows.foreach { row =>
    tbl.dateColumn(0).append(row.date)
    tbl.stringColumn(1).append(row.name)
    tbl.doubleColumn(2).append(row.workTime)
    tbl.doubleColumn(3).append(row.salary)
  }

  tbl
}
複製程式碼

上面的說明了資料新增的完整過程:建立表格,增加列,列中追加元素。基於這三個基本操作基本可以實現所有的建立和形變。

列處理

列操作是表格處理的基礎。前面介紹了列的資料型別,名稱設定和元素追加,下面繼續介紹幾個基礎操作。

1. 遍歷與形變

比如按序輸出 demo 表格中所有記錄的姓名:

// 獲取姓名列,根據列名索引
val nameCol = tbl.stringColumn("name")

// 根據行號遍歷
(0 until nameCol.size()).foreach( i =>
      println(nameCol.get(i))
)

// 直接使用column 提供的遍歷介面
nameCol.forEacch(println)
複製程式碼

除了遍歷外,另一種常見應用是將列形變到另外一列:型別不變值變化;型別變化。以工時為例,我們將工時不小於 8 則視為全勤:

// 根據列的索引獲取工時一列
val workTimeCol = tbl.doubleColumn(2)

// 形變1: map,輸出列型別與輸入列保持一致
val fullTimeCol = workTimeCol.map { time =>
  // 工時型別是Double,因此需要將形變結果也轉化為 Double,否則編譯失敗
  if (time >= 8)
    1.0
  else
    0.0
}

// 形變 2: mapInto,輸入/輸出列的資料型別可以不同,但需提前建立大小相同的目標列
val fullTimeCol = BooleanColumn.create("全勤", 
                                       workTimeCol.size()) // 建立記錄全勤標籤的Boolean列
val mapFunc: Double2BooleanFunction = 
  (workTime: Double) => workTime >= 8.0                    // 基於SAM 建立對映函式
workTimeCol.mapInto(mapFunc, fullTimeCol)                  // 形變
tbl.addColumns(fullTimeCol)                                // 將列新增到表格中
複製程式碼

輸出結果為:

date name 工時 報酬 全勤
2019-01-08 tom 8 1000 true
2019-01-09 jerry 7 500 false
2019-01-10 張三 8 999 true
2019-01-10 jerry 8 550 true
2019-01-10 tom 8 1000 true
2019-01-11 張三 6 800 false
2019-01-11 李四 12 1500 true
2019-01-11 王五 8 900 true
2019-01-11 tom 6.5 800 false

2. 列運算

tablesaw 提供了豐富的針對列的運算函式,而且針對不同資料型別提供了不同特化介面。建議優先查閱 API 文件,最後考慮寫程式碼。這裡介紹幾個大類:

  • 多列交叉運算。比如一列中所有元素和同一資料計算,或者兩列元素按序交叉計算。比如每人的時薪:
// 第三列報酬除以第二列工時得到時薪
tbl.doubleColumn(3).divide(tbl.doubleColumn(2))
複製程式碼
  • 單列的統計。均值,標準差,最大 N 個值,最小 N 個值,視窗函式等。
// 第三列報酬的標準差
tbl.doubleColumn(3).workTimeCol.standardDeviation() 
複製程式碼
  • 排序。數值,時間,字串型別預設支援增序、降序,也支援自定義排序。

3. 過濾

tablesaw 對列的過濾條件定義為 Selection ,不同的條件可以按“與、或、非”組合。每種型別的列均提供 "is" 作為字首的介面直接生成條件。下面舉個例子,找到工作時間在 2019-01-09 - 2019-01-10 之間工時等於 8 且報酬小於 1000 的所有記錄:

// 設定時間的過濾條件
val datePattern = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val dateSel = tbl.dateColumn(0)
                 .isBetweenIncluding(LocalDate.parse("2019-01-09", datePattern),
                                     LocalDate.parse("2019-01-10", datePattern))
// 設定工時過濾條件
val workTimeSel = tbl.doubleColumn(2).isEqualTo(8.0)
// 設定報酬過濾條件
val salarySel = tbl.doubleColumn(3).isLessThan(1000)
// 綜合各條件過濾表格
tbl.where(dateSel.and(workTimeSel).and(salarySel))
複製程式碼

輸出結果符合預期:

date name 工時 報酬 全勤
2019-01-10 張三 8 999 true
2019-01-10 jerry 8 550 true

表格處理

除了基礎操作可以參考官網說明外,有三種表格的操作特別值得一提:連線,分組聚合,分表。

連線

將有公共列名的兩個表連線起來,基本方式是以公共列為 key,將各表同行其它列資料拼接起來生成新表。根據方式的不同組合有所差異:

  • inner. 公共列中的資料取交集,其他過濾。
  • outer. 公共列中的資料取並集,缺失的資料設定預設空值。具體又可以分為三類:
    • leftOuter. 結果表公共列資料與左側表完全相同,不在其中的過濾,缺失的設定空值。
    • rightOuter. 結果表公共列資料與右側表完全相同,不在其中的過濾,缺失的設定空值。
    • fullOuter. 結果表公共列資料為兩個表的並集,缺失的設定空值。

舉個例子,增加一個新表 tbl2 記錄每人的工作地點:

name 地點
張三 總部
李四 門店 1
王五 門店 2

採用 inner 方式和 demo 表連線:

val tbl3 = tbl.joinOn("name").inner(tbl2)
複製程式碼

tbl3 的內容是:

date name 工時 報酬 全勤 地點
2019-01-10 張三 8 999 true 總部
2019-01-11 張三 6 800 false 總部
2019-01-11 李四 12 1500 true 門店 1
2019-01-11 王五 8 900 true 門店 2

可以發現,按照 name 的交集連線,tom 和 jerry 都被過濾掉了。

分組聚合

類似於 SQL 中的 groupby,介面為: tbl.summarize(col1, col2, col3, aggFunc1, aggFunc2 ...).by(groupCol1, groupCol2) 。其中 by 的參數列示分組列名集合。summarize 的 col1, col2, col3 表示分組後需要被聚合處理的列名集合, aggFunc1, aggFunc2 表示聚合函式,會被用於所有的聚合列。舉個例子計算每人的總報酬:

tbl3.summarize("報酬", sum).by("name")
複製程式碼
name Sum [報酬]
tom 2800
jerry 1050
張三 1799
李四 1500
王五 900

分表

和分組聚合不同,按列分組後,可能並不需要將同組資料聚合為一個值,而是要儲存下來做更加複雜的操作,這時就需要分表。介面很簡單: tbl.splitOn(col ...) 設定分表的列名集合。比如:

// 按照名稱和地點分表,並將生成的各個子表儲存到 List 中
tbl.splitOn("name", "地點").asTableList()
複製程式碼

視覺化

tablesaw 可以將表格匯出為互動式 html,也支援除錯時直接調研呼叫瀏覽器開啟,並針對不同型別圖表做了個性化封裝。舉個簡單例子,檢視每人報酬的時間變化曲線:

//含義是:將tbl 按照 name 列分組,以 date 列為時間軸,顯示 報酬 的變化曲線
//並將圖表的名稱設定為:薪酬變化曲線
val fig = TimeSeriesPlot.create("薪酬變化曲線", tbl, "date", "報酬", "name")
Plot.show(fig)
複製程式碼

檢視簡介
其它型別的圖表還有很多,使用方法大同小異,只需根據官方文件傳入正確引數即可。

小結

小蟲向大家簡單介紹了 tablesaw 的功能和使用方法,從我自己的使用經驗而言,我最喜歡它的的地方在於:

  • api 介面的統一,清晰
  • 互動式圖表生成簡單,能夠和 web 對接

此外, tablesaw 的開發和維護也如火如荼,期待後續有更多的有趣的功能新增進來。

相關文章