4. 懒执行与查询优化

在金融数据清洗中,我们经常面对几十 GB 的行情快照、逐笔成交或因子面板。如果每一步都立即执行,Polars 会反复扫描磁盘、重复计算中间结果,性能损耗极大。懒执行(Lazy Execution)的核心思想是:先描述“要算什么”,而不是立刻“去算”。Polars 用 LazyFrame 记录整条计算链路,再由查询优化器统一改写、裁剪、合并,最后才触发一次高效执行。

一、LazyFrame:把计算计划先“挂起来”

LazyFrame 与 DataFrame 的最大区别在于:DataFrame 是已经存在于内存中的数据,而 LazyFrame 是一份待执行的查询计划。调用 pl.scan_parquet()、pl.scan_csv() 或 df.lazy() 时,Polars 并不会真正读取全部数据,而是构建一个逻辑计划节点树。

import polars as pl

lf = (
    pl.scan_parquet("trades_2024.parquet")
      .filter(pl.col("symbol") == "600519.SH")
      .select(["ts", "price", "volume"])
      .with_columns((pl.col("price") * pl.col("volume")).alias("amount"))
)

# 此时并未执行任何计算
print(lf.explain())          # 查看未优化逻辑计划
print(lf.explain(optimized=True))  # 查看优化后的计划
df = lf.collect()            # 此刻才真正执行

金融场景中,这种“延迟”带来两个直接收益:一是可以在执行前观察计划、发现冗余;二是优化器有足够信息做全局改写,而不是被每一步的中间结果绑死。

二、查询计划与四大优化手段

Polars 的查询优化器会在 collect() 之前对逻辑计划做多轮改写。对金融数据清洗最关键的四种优化如下:

  • 谓词下推(Predicate Pushdown):把 filter 尽量推到扫描层。例如只取某交易日、某板块的数据,Parquet 的行组统计信息可直接跳过无关数据块,I/O 大幅下降。
  • 投影下推(Projection Pushdown):只读取真正用到的列。逐笔数据往往有几十列,若只算 VWAP,就只加载 price、volume、ts,列式存储的优势被彻底释放。
  • 公共子表达式消除(CSE):同一表达式在多个分支重复出现时只计算一次。因子研究中常出现“同一中间因子被多个衍生因子引用”,CSE 能避免重复扫描与重复计算。
  • 流式处理(Streaming):当数据大于内存时,以分块方式执行,边读边算边输出,避免一次性物化全量中间结果。
提示:优化不是“自动万能”。谓词下推依赖数据源支持统计信息(Parquet、IPC 友好,CSV 较弱);CSE 需要表达式结构一致;流式处理对某些聚合与连接算子有额外约束。

三、优化流程与可视化

下面这张图展示了从 LazyFrame 构建到最终 collect 的完整链路:逻辑计划先经过谓词下推、投影下推、CSE 等改写,再选择内存执行或流式执行,最终产出 DataFrame。

Polars 懒执行查询优化链路 LazyFrame 构建 scan / filter / select 逻辑计划 节点树 + 表达式 查询优化器 改写与合并 物理计划 执行策略 谓词下推 filter 推至 scan 跳过无关行组 投影下推 只读必要列 列式 I/O 优势 公共子表达式消除 重复表达式只算一次 因子复用 执行:collect() / collect(streaming=True) 内存执行 或 分块流式执行

四、金融清洗实战案例

假设我们要从 2024 年逐笔成交中计算某只股票每分钟的成交金额,并过滤掉异常价格。朴素写法会先全量读取再逐步过滤;懒执行写法如下:

lf = (
    pl.scan_parquet("trades_2024/*.parquet")
      .filter(pl.col("symbol") == "600519.SH")
      .filter(pl.col("price") > 0)
      .select(["ts", "price", "volume"])
      .with_columns((pl.col("price") * pl.col("volume")).alias("amount"))
      .group_by_dynamic("ts", every="1m")
      .agg(pl.col("amount").sum().alias("minute_amount"))
)

result = lf.collect(streaming=True)

优化器会做三件事:把两个 filter 合并并下推到扫描层;只读取 ts、price、volume、symbol 四列;group_by_dynamic 在流式模式下分块聚合。相比立即执行,I/O 与内存占用通常能下降一个数量级。

优化手段作用对象金融场景收益注意事项
谓词下推filter 条件只扫描目标股票/日期CSV 无统计信息,收益有限
投影下推select 列逐笔几十列只读几列需显式 select,避免 select *
公共子表达式消除重复表达式多因子共享中间量表达式结构需一致
流式处理collect 模式超内存数据可分块处理部分算子不支持流式

五、常见误区与复盘

误区一:以为 scan_* 一定比 read_* 快。若后续立刻 collect() 且没有过滤/投影,两者差距不大,懒执行的价值在于“有机会优化”。
误区二:在 LazyFrame 中混用 Python 循环或 map_elements,会打断优化链路,使下推失效。
误区三:忽略 explain(optimized=True),凭感觉判断性能。金融数据列多、分区多,计划视图才是排查依据。

复盘建议:每次写完清洗链路,先看优化后计划,确认 filter 是否落到 scan、列是否被裁剪、是否有重复计算;数据量超过内存时再切换 streaming=True。把“先计划、后执行”变成习惯,Polars 的懒执行才能真正转化为金融数据清洗的吞吐优势。