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):当数据大于内存时,以分块方式执行,边读边算边输出,避免一次性物化全量中间结果。
三、优化流程与可视化
下面这张图展示了从 LazyFrame 构建到最终 collect 的完整链路:逻辑计划先经过谓词下推、投影下推、CSE 等改写,再选择内存执行或流式执行,最终产出 DataFrame。
四、金融清洗实战案例
假设我们要从 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 的懒执行才能真正转化为金融数据清洗的吞吐优势。