3. 表达式系统核心

在 Polars 中,表达式(Expression)不是“写一行算一行”的即时命令,而是一棵可被优化器整体分析的计算树。理解这一点,是掌握后续懒执行优化的前提。本节围绕列选择、过滤、排序、新增列、聚合、窗口函数以及链式组合,建立一套可复用的表达式心智模型。

一、核心原理:表达式是“声明”,不是“执行”

当你写下 pl.col("close") 时,并没有立刻读取任何数据,而是构造了一个描述“我要对 close 列做什么”的节点。多个表达式通过 select、with_columns、filter、group_by 等上下文组合后,Polars 会先构建逻辑计划,再交给优化器做谓词下推、投影下推、公共子表达式消除等处理,最后才在引擎中执行。

因此,表达式系统的关键特征是:上下文决定语义,表达式决定计算。同一个 pl.col("close").mean(),放在 select 中返回全局均值,放在 group_by().agg() 中返回分组均值,放在 over() 中则返回按窗口分组的均值。

表达式计算树:从列引用到上下文求值 列引用 pl.col("close") 变换节点 .log().diff() 聚合节点 .mean().std() 窗口节点 .over("symbol") 上下文(Context)决定求值语义 select with_columns filter group_by().agg() over() 优化器:谓词下推 · 投影下推 · 公共子表达式消除 · 常量折叠 逻辑计划 → 优化计划 → 物理执行

二、方法流程:七类核心操作与链式调用

表达式系统的操作可以归纳为七类,它们既可以单独使用,也可以任意组合成链。

  1. 列选择:pl.col("open")、pl.col("^close.*$")、pl.exclude("date")、pl.all()。正则与排除是金融宽表清洗的高频手段。
  2. 过滤:filter(pl.col("volume") > 0),支持 &、|、~ 组合条件,且条件表达式会被下推到扫描阶段。
  3. 排序:sort(["symbol", "date"], descending=[false, true]),多键排序在时序对齐中非常关键。
  4. 新增列:with_columns((pl.col("high") - pl.col("low")).alias("range")),一次可新增多列并保持原列。
  5. 聚合:group_by("symbol").agg(pl.col("close").mean().alias("avg_close")),聚合表达式在分组上下文中逐组求值。
  6. 窗口函数:pl.col("close").mean().over("symbol"),在不减少行数的前提下计算分组统计量,是计算移动均线、分组排名、组内占比的核心。
  7. 组合与链式调用:表达式可嵌套、可复用、可命名,链式调用让清洗逻辑保持线性可读。

链式调用的本质是“表达式树不断生长”,而不是“数据不断被复制”。Polars 会尽量在单次遍历中完成多个表达式计算,这也是它比逐列循环更快的原因之一。

三、案例场景:金融日线数据的表达式清洗

假设有一张日线行情表,包含 symbol、date、open、high、low、close、volume。下面用一段链式表达式完成过滤、排序、新增列、窗口统计与聚合。

import polars as pl

df = pl.read_parquet("daily_bars.parquet")

result = (
    df
    .filter(
        (pl.col("volume") > 0) &
        (pl.col("close") > 0) &
        pl.col("date").is_not_null()
    )
    .sort(["symbol", "date"])
    .with_columns([
        (pl.col("high") - pl.col("low")).alias("intraday_range"),
        (pl.col("close") / pl.col("open") - 1).alias("daily_return"),
        pl.col("close").mean().over("symbol").alias("symbol_avg_close"),
        pl.col("volume").rank(descending=True).over("symbol").alias("vol_rank"),
    ])
    .group_by("symbol")
    .agg([
        pl.col("daily_return").mean().alias("avg_return"),
        pl.col("daily_return").std().alias("volatility"),
        pl.col("intraday_range").max().alias("max_range"),
        pl.len().alias("trading_days"),
    ])
    .sort("volatility", descending=True)
)

这段代码体现了表达式系统的几个关键点:过滤条件在扫描阶段即可下推;over("symbol") 让均值与排名在组内计算但不减少行数;group_by().agg() 再对逐行结果做二次聚合;最后排序输出。整个过程没有显式循环,也没有中间 DataFrame 落地。

操作 典型表达式 金融场景 是否改变行数
列选择 pl.col("^close.*$") 批量选取价格列 否
过滤 filter(pl.col("volume") > 0) 剔除停牌与异常成交 是
排序 sort(["symbol", "date"]) 时序对齐 否
新增列 with_columns(...alias(...)) 计算收益率、振幅 否
聚合 group_by().agg() 按标的汇总统计 是(减少)
窗口函数 .mean().over("symbol") 组内均值、排名、占比 否

四、误区与复盘

误区一:把 over() 当成 group_by()。 over() 保留原始行数,适合做“组内广播”;group_by().agg() 会折叠行数。若在 over() 后误用聚合语义,会导致结果行数与预期不符。

误区二:在 with_columns 中重复写相同子表达式。 例如多次写 pl.col("close") / pl.col("open") - 1。应先用 alias 命名,再在后续表达式中引用该列,既减少重复计算,也提升可读性。

复盘建议: 每次写完链式表达式后,用 explain() 查看逻辑计划,确认过滤是否下推、投影是否裁剪、窗口是否被正确分组。表达式写得对,不等于执行计划最优;而 Polars 的价值,正是让“写得对”和“跑得快”可以同时成立。

掌握本节七类操作与上下文语义后,你就具备了用表达式描述复杂清洗逻辑的能力。下一阶段,我们将把这些表达式放入懒执行框架中,观察优化器如何进一步改写与加速。