Pandas 是
Python
界最流行的数据统计基础库之一。但像我这样和Java/Scala
打交道的人,还是期望JVM
有类似的解决方案。网上一搜发现生态还是很丰富的:大数据领域的 Spark ,支持 GUI 的数据挖掘套件 weka ,主攻机器学习的 smile ,擅长聚合变换的 joinery 等等。但小虫最终还是选择了简约而不简单的 tablesaw 。缘由还得从那句老话说起: 离开场景谈应用都是耍流氓 。
小虫在工作中使用 Spark
将业务产生的海量用户行为按模块加工:过滤冗余/简单汇总,并导出至 PostgreSQL
的不同表中,存储正交化的基础数据。比如用户的登陆/购买行为分别记录到, LoginTable
和 ShoppingTable
。然后在 二次处理
模块中建立不同服务,比如 S1,S2。S1 直接访问数据库,S2 的既要访问数据库,又要访问 S1 的统计结果,还要依赖本地的 csv 文件。
LoginTable
记录终端类型:android/iOS; ShoppingTable
记录了用户购买物品的种类和数量。当要对比不同终端用户购买行为差异时,就要将两个表连接并按终端类型聚合。纬度 | 特点 | 说明 |
---|---|---|
数据量 | 较小单机可承载 | 原始数据由 spark 汇总 |
格式 | 数据库,本地 csv,json | json 多来自于 restful 服务 |
计算 | 增删改查,条件查询,表连接,聚合,统计 | 内置算子越多,扩展性越高越好 |
交互 | 输出到指定格式,可视化,可交互 等 | 网页渲染,终端,[Jupyter Notebook](https://jupyter.org/) |
集成 | 轻量,以库而非服务的形式 | 便于嵌入进程和其他逻辑交互 |
很简单,就因为它完美契合小虫的应用场景。借用 tablesaw官网 的特性列表:
Pandas DataFrame
,增删改查,连接,排序。以上是基础功能,小虫觉得下面几个点更有意思:
java
的 UI 系统,更好的和 Web 对接。支持 2D、3D 视图,图表类型也很丰富:曲线,散点,箱形统计,蜡烛图,热力图,饼状图等。更重要的是:
Javascript
。方便结合 Web Service 渲染 html。smile
对接。tablesaw 可将表格导出为 smile
识别的数据格式,便于利用其强大的机器学习库。好了,说了这么多,直接上干货吧。
tablesaw
包含多个库,小虫推荐安装 tablesaw-core
和 tablesaw-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 文件格式。第一列为日期,第二列为姓名,第三列为工时(当日工作时长,单位是小时),第四列为报酬(单位是元)。然后举三个典型例子来说明导入的不同方式。
// 读取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。绝大部分列和普通数据表类型没有差异,需要强调的是两类:
有时自动推测并不会非常精准,比如期望使用 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: 报酬
该方法适合各种场景,可以运行时从不同数据源导入数据。
基本流程是:
创建空表格,同时设定名称
设定 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 } 复制代码
上面的说明了数据添加的完整过程:创建表格,增加列,列中追加元素。基于这三个基本操作基本可以实现所有的创建和形变。
列操作是表格处理的基础。前面介绍了列的数据类型,名称设置和元素追加,下面继续介绍几个基础操作。
比如按序输出 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 |
tablesaw
提供了丰富的针对列的运算函数,而且针对不同数据类型提供了不同特化接口。建议优先查阅 API 文档,最后考虑写代码。这里介绍几个大类:
// 第三列报酬除以第二列工时得到时薪 tbl.doubleColumn(3).divide(tbl.doubleColumn(2)) 复制代码
// 第三列报酬的标准差 tbl.doubleColumn(3).workTimeCol.standardDeviation() 复制代码
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,将各表同行其它列数据拼接起来生成新表。根据方式的不同组合有所差异:
举个例子,增加一个新表 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
的功能和使用方法,从我自己的使用经验而言,我最喜欢它的的地方在于:
此外, tablesaw
的开发和维护也如火如荼,期待后续有更多的有趣的功能添加进来。