数据STUDIO

Polars 2.0 强势升级

Image

前段时间,Polars 迎来了大版本2.0 升级。平时用它处理 CSV、做 join、跑 group_by 的朋友,可能最关心的不是版本号变了多少,而是以前写好的代码还能不能正常跑,算出来的结果会不会变。

Image

这次升级有几个地方挺值得注意。比如,同样的 join,连接出来的记录可以完全一致,返回的行顺序却可能变了;Int64 和 UInt64 混合运算,结果类型可能从 Float64 变为 Int128。还有 LazyFrame.collect() 默认改用流式引擎,也容易让人以为所有查询都能直接处理超出内存的数据,其实没那么简单。

今天云朵君就结合 Polars 1.43.2 和 2.0.0,和大家聊聊这些变化到底意味着什么。尤其是已经在用 Polars 做数据清洗、统计报表的朋友,升级前不妨先了解一下行序、数值精度和类型这几个容易忽略的细节。(截稿刚刚升级到2.1.0,感兴趣的小伙伴可以试用一下)

01从两张 CSV 算出分类销售额

主流程是 LazyFrame:扫描两份 CSV,按商品编号左连接,计算净额,再按类别和地区分组。脚本显式指定数量、折扣和单价的整数类型,避免把输入类型交给推断。在 polars-demo 的上一级目录运行以下示例;分别使用前文的两个锁定版本环境:

import polars as pl
from pathlib import Path

root = Path("polars-demo")
orders = pl.scan_csv(
    root / "orders.csv",
    schema_overrides={"quantity": pl.Int64, "discount_cents": pl.Int64},
)
products = pl.scan_csv(
    root / "products.csv",
    schema_overrides={"unit_price_cents": pl.Int64},
)
joined = orders.join(products, on="product_id", how="left")
report = (
    joined.with_columns(
        (pl.col("unit_price_cents") * pl.col("quantity")
         - pl.col("discount_cents")).alias("net_cents")
    )
    .group_by("category", "region")
    .agg(
        pl.len().alias("order_lines"),
        pl.col("quantity").sum().alias("units"),
        pl.col("net_cents").sum().alias("net_cents"),
    )
    .sort(["category", "region"])
)
result = report.collect()

Polars 2.0 迁移指南说明,LazyFrame.collect() 与 collect_async() 在 engine="auto" 时默认使用流式引擎;这项默认切换不等于所有 Polars API 都改用流式,eager DataFrame 也不受同一默认变化影响。指南还提醒,join、group_by、unpivot 等不要求顺序语义的操作不保证保留原先观察到的行序。这里的排序因此写在查询中:需要可复现的报表顺序,就明确写排序键,而不是让调用方依赖一次运行碰巧得到的排列。

两份保存记录都连接出 5,000 行。把完整 join 的业务列排序后规范化为 JSON,两版的 SHA-256 都是 59bdaff3c892c22011795e0dec4acedf23c44b1697b3d0b1973bda14baf2e8fd。这支持的是这组输入的内容一致,不能用来证明默认行序稳定。

同一份输入按 category, region 显式排序后,两版的 20 组汇总完全一致。再把四个地区加总,电子类净额为 73,294.86 元,家居 38,031.88 元,食品 33,909.92 元,户外 19,702.26 元,配件 16,534.62 元。图上半部是这份虚构数据的分类汇总,展示了 scan_csv → join → 金额表达式 → group_by 实际算出的结果;这些数值不是现实销售规模。

Image
5,000 条订单的分类净销售额

图下半部则用同一批数据检查顺序:先对未排序的 joined.collect() 调用 with_row_index("join_position") 记下返回位置,再固定抽出 O-00001 至 O-00012。保存的 1.43.2 运行中这 12 笔位于第 0—11 行;保存的 2.0.0 运行里位置分散,例如 O-00001 在第 431 行。位置由本次执行记录决定,重复运行也可能变化。若报表或导出文件依赖行序,就要对业务键显式 sort(),不能把这张图里的任何位置当成 API 保证。

02再看类型:普通订单金额之外的精度探针

几千分、几万分的订单行不容易暴露浮点数在大整数上的边界。为了专门检查类型提升,演示脚本另设四组 Int64 + UInt64 整数探针;这些大整数是边界样本,不是订单金额,也没有混进上面的销售图表。

其中最直观的一组是 Int64(1) + UInt64(9007199254740993),精确整数和为 9007199254740994。9007199254740993 已超过 Float64 能逐个精确表示整数的边界。两版的保存结果如下:1.43.2 将混合结果解析为 Float64,本组值为 9007199254740992.0,与精确和不同;2.0.0 的结果类型为 Int128,值为 9007199254740994。官方迁移指南描述的正是有符号整数与 UInt64 混算时,supertype 从可能有损的 Float64 改为 Int128。这不是说所有整数表达式都会变,也不是说更精确的结果本身是退步;迁移时仍要看下游是否接受新的 schema。

完整四组探针中,1.43.2 的结果 schema 为 Float64,有三组与精确整数期望不一致;2.0.0 的结果 schema 为 Int128,四组都精确匹配。图中逐项列出了实际值和整数期望;它是刻意构造的边界测试,不代表普通订单金额,也不能外推到所有表达式。

Image
四组 Int64 与 UInt64 混算样本在 Polars 1.43.2 和 2.0.0 中的结果类型与精确匹配情况

因此,类型检查不能只看查询是否成功。探针的关键几行可以这样写,expected 用 Python 整数计算,再把 Polars 的结果与它逐条比较。此片段沿用上方代码块的库导入;单独复制时需先完成相同的导入。两个代码片段只展示查询,跨版本对照来自分别运行并保存的完整脚本:

probe = pl.LazyFrame({
"signed_int64": pl.Series([1], dtype=pl.Int64),
"unsigned_uint64": pl.Series([2**53 + 1], dtype=pl.UInt64),
}).with_columns(
    (pl.col("signed_int64") + pl.col("unsigned_uint64")).alias("mixed_sum")
)
expected = 2**53 + 2
print(probe.collect_schema()["mixed_sum"])
print(probe.collect()["mixed_sum"][0] == expected)

如果下游序列化、数据库列或接口固定要求某种类型,数值变准确之外,还要单独核对 schema 相容性。

03默认流式,不代表任意查询都能超出内存

Polars 2.0 发布说明列出初步支持溢写磁盘的操作,例如排序、窗口函数和许多表达式;发布时 join 与 group-by 尚不属于 out-of-core 能力保证。本文只验证了这组小型合成数据的输出,没有测运行时间、峰值内存或磁盘使用,也没有做溢写测试。高内存任务应在自己的数据规模和运行环境中单独测量,不能从默认引擎变化推导出速度或资源结论。

Image

04写在最后

升级时先固定两个版本和输入,比较排序后的完整记录。随后找出下游依赖的行序并显式排序;对关键混合类型表达式,同时核对计算值与 collect_schema()。高内存任务还要另测时间、峰值内存和磁盘需求。这份订单汇总与整数探针只回答各自输入的问题,不能代替生产规模回归。

Image