1. 引言

本文将介绍如何使用 Tablesaw 库处理表格数据。首先我们会导入数据,然后通过操作数据获取洞察。

我们将使用 avocado prices 数据集。该数据集包含美国多个市场牛油果价格和销量的历史数据。

2. 在 Tablesaw 中导入数据

首先需要导入数据。Tablesaw 支持多种格式,包括我们数据集使用的 CSV 格式。下面从 CSV 文件加载数据:

CsvReadOptions csvReadOptions =
    CsvReadOptions.builder(file)
        .separator(',')
        .header(true)
        .dateFormat(formatter)
        .build();
table = Table.read().usingOptions(csvReadOptions);

我们通过构建器创建 CsvReadOptions 对象,并正确配置选项:

  • 使用 separator() 设置列分隔符
  • 使用 header(true) 将首行作为表头
  • 提供 DateTimeFormatter 解析日期时间
  • 最后用配置好的选项读取表数据

2.1. 验证导入数据

使用 structure() 方法检查表结构,返回包含列名、索引和数据类型的表:

         avocado.csv 的结构         
 Index  |  列名   |  列类型  |
------------------------------------------
     0  |   C0    |  INTEGER |
     1  |  Date   | LOCAL_DATE |
     2  | AveragePrice | DOUBLE |
    ... |   ...   |   ...    |

再用 shape() 方法检查表形状:

assertThat(table.shape()).isEqualTo("avocado.csv: 18249 行 X 14 列");

该方法返回包含文件名、行数和列数的字符串。我们的数据集包含 18249 行和 14 列。

3. Tablesaw 内部数据表示

Tablesaw 主要通过表和列工作,它们构成了数据框的基础。简单来说,表是列的集合,每列有固定类型。表中的行是一组值,每个值对应其匹配的列。

Tablesaw 支持多种列类型。除了扩展 Java 基本类型的列,还提供文本和时间列。

3.1. 文本类型

Tablesaw 有两种文本类型:TextColumnStringColumn

  • TextColumn:通用类型,直接存储原始文本
  • StringColumn存储前先用字典结构编码值,避免重复值,提高存储效率

例如在牛油果数据集中,region 和 type 列是 StringColumn 类型。列中的重复值被高效存储,指向同一个文本实例:

StringColumn type = table.stringColumn("type");
List<String> conventional = type.where(type.isEqualTo("conventional")).asList().stream()
    .limit(2)
    .toList();
assertThat(conventional.get(0)).isSameAs(conventional.get(1));

3.2. 时间类型

Tablesaw 提供四种时间类型,对应 Java 对象:DateColumnDateTimeColumnTimeColumnInstantColumn。如前所述,导入时可配置如何解析这些值。

4. 操作列

接下来学习如何操作导入的数据并提取洞察。在 Tablesaw 中,我们可以转换单个列或操作整个表。

4.1. 创建新列

通过调用各列类型的静态 create() 方法创建新列。例如创建名为 timeTimeColumn

TimeColumn time = TimeColumn.create("Time");

使用 addColumns() 方法将列添加到表:

Table table = Table.create("test");
table.addColumns(time);
assertThat(table.columnNames()).contains("time");

4.2. 添加或修改列数据

使用 append() 方法在列末尾添加数据:

DoubleColumn averagePrice = table.doubleColumn("AveragePrice");
averagePrice.append(1.123);
assertThat(averagePrice.get(averagePrice.size() - 1)).isEqualTo(1.123);

对于表,必须为每列提供值以确保所有列至少有一个值。否则当列大小不同时会抛出 IllegalArgumentException

DoubleColumn averagePrice2 = table.doubleColumn("AveragePrice").copy();
averagePrice2.setName("AveragePrice2");
averagePrice2.append(1.123);
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> table.addColumns(averagePrice2));

使用 set() 方法修改列向量中的特定值(需知道要修改值的索引):

stringColumn.set(2, "Baeldung");

从列中删除数据可能有问题,尤其是对表而言。Tablesaw 不允许从列向量中删除值,而是使用 setMissing() 将要删除的值标记为缺失:

DoubleColumn averagePrice = table.doubleColumn("AveragePrice").setMissing(0);
assertThat(averagePrice.get(0)).isNull();

这不会从向量中删除值占位符,而是将其设为 null,因此向量大小保持不变。

5. 数据排序

接下来对导入的数据排序。首先根据一组列对表行排序。使用 sortAscending()sortDescending() 方法,它们接受列名。让我们按日期排序获取最早和最晚的日期:

Table ascendingDateSortedTable = table.sortAscendingOn("Date");
assertThat(ascendingDateSortedTable.dateColumn("Date").get(0)).isEqualTo(LocalDate.parse("2015-01-04"));
Table descendingDateSortedTable = table.sortDescendingOn("Date");
assertThat(descendingDateSortedTable.dateColumn("Date").get(0)).isEqualTo(LocalDate.parse("2018-03-25"));

但这些方法限制很大,例如无法混合升序和降序排序。为解决这些限制,使用 sortOn() 方法。它默认按列名升序排序。要对特定列降序排序,在列名前加减号“-”。例如按年份升序、平均价格降序排序:

Table ascendingYearAndAveragePriceSortedTable = table.sortOn("year", "-AveragePrice");
assertThat(ascendingYearAndAveragePriceSortedTable.intColumn("year").get(0)).isEqualTo(2015);
assertThat(ascendingYearAndAveragePriceSortedTable.numberColumn("AveragePrice").get(0)).isEqualTo(2.79);

这些方法不适用于所有场景。Tablesaw 允许为 sortOn() 方法提供自定义的 Comparator<VRow> 实现。

6. 数据过滤

过滤器允许我们从原始表中获取数据子集。过滤表会返回另一个表,使用 where()dropWhere() 方法应用过滤器

  • where():返回符合指定条件的行
  • dropWhere():删除符合条件的行

要指定过滤条件,首先需要理解 Selection

6.1. 选择器

Selection 是一个逻辑位图,即包含布尔值的数组,用于掩码列向量中的值。例如将选择器应用于列会生成包含过滤值的新列(如删除掩码为 0 的索引对应的值)。选择器向量大小与原始列相同。

通过获取 2017 年平均价格高于 2 美元的数据实践:

DateColumn dateTable = table.dateColumn("Date");
DoubleColumn averagePrice = table.doubleColumn("AveragePrice");
Selection selection = dateTable.isInYear(2017).and(averagePrice.isGreaterThan(2D));
Table table2017 = table.where(selection);
assertThat(table2017.intColumn("year")).containsOnly(2017);
assertThat(table2017.doubleColumn("AveragePrice")).allMatch(avrgPrice -> avrgPrice > 2D);

我们使用了 DateColumnisInYear()DoubleColumnisGreaterThan() 方法,通过 and() 方法组合成类查询语言。Tablesaw 提供许多内置辅助方法,简单任务很少需要自定义选择器。复杂任务可通过 and()andNot()or()列过滤器组合。

或者通过创建 Predicate 并传递给每列的 eval() 方法编写自定义过滤器,该方法返回用于过滤表或列的 Selection 对象。

7. 数据汇总

操作数据后,我们需要从中提取洞察。使用 summarize() 方法聚合数据。例如从牛油果数据集中提取平均价格的最小值、最大值、均值和标准差:

Table summary = table.summarize("AveragePrice", max, min, mean, stdDev).by("year");
System.out.println(summary.print());

首先将要聚合的列名和 AggregateFunction 列表传递给 summarize(),然后使用 by() 方法按年份分组结果。最后在标准输出打印结果:

                                              avocado.csv 汇总                                               
 year  |  平均价格均值  |  平均价格最大值  |  平均价格最小值  |  平均价格标准差  |
----------------------------------------------------------------------------------------------------------------
 2015  |    1.375590382902939  |                2.79  |                0.49  |            0.37559477067238917  |
 2016  |   1.3386396011396013  |                3.25  |                0.51  |            0.39370799476072077  |
 2017  |   1.5151275777700104  |                3.17  |                0.44  |             0.4329056466203253  |
 2018  |   1.3475308641975308  |                 2.3  |                0.56  |             0.3058577391135024  |

Tablesaw 为常见操作提供了聚合函数。也可以实现自定义 AggregateFunction,但这超出了本文范围。

8. 保存数据

目前我们一直将数据打印到标准输出。控制台打印适合临时验证结果,但需要将数据保存到文件以便他人重用。直接在表上使用 write() 方法:

summary.write().csv("summary.csv");

使用 csv() 方法将数据保存为 CSV 格式。Tablesaw 目前仅支持 CSV 格式和固定宽度格式(与 print() 方法在控制台显示的格式类似)。此外,可使用 CsvWriterOptions 自定义 CSV 输出。

9. 结论

本文探讨了使用 Tablesaw 库处理表格数据的方法。

首先介绍了数据导入,然后描述了数据内部表示及操作方法。接着探讨了修改导入表结构、创建过滤器提取必要数据,最后进行聚合并保存为 CSV 文件。

完整源代码可在 GitHub 获取。


原始标题:Working with Tabular Data Using Tablesaw