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("open")、pl.col("^close.*$")、pl.exclude("date")、pl.all()。正则与排除是金融宽表清洗的高频手段。 - 过滤:
filter(pl.col("volume") > 0),支持&、|、~组合条件,且条件表达式会被下推到扫描阶段。 - 排序:
sort(["symbol", "date"], descending=[false, true]),多键排序在时序对齐中非常关键。 - 新增列:
with_columns((pl.col("high") - pl.col("low")).alias("range")),一次可新增多列并保持原列。 - 聚合:
group_by("symbol").agg(pl.col("close").mean().alias("avg_close")),聚合表达式在分组上下文中逐组求值。 - 窗口函数:
pl.col("close").mean().over("symbol"),在不减少行数的前提下计算分组统计量,是计算移动均线、分组排名、组内占比的核心。 - 组合与链式调用:表达式可嵌套、可复用、可命名,链式调用让清洗逻辑保持线性可读。
链式调用的本质是“表达式树不断生长”,而不是“数据不断被复制”。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 的价值,正是让“写得对”和“跑得快”可以同时成立。
掌握本节七类操作与上下文语义后,你就具备了用表达式描述复杂清洗逻辑的能力。下一阶段,我们将把这些表达式放入懒执行框架中,观察优化器如何进一步改写与加速。