
本文详解如何在 Polars 中优雅、高效地对多个数值列(如 value1/value2/value3)按多个分组键(如 strike/maturity)和多种滚动窗口(如 30d/90d)批量计算 Z-Score,避免冗余聚合,充分利用 over() 窗口函数与正则列选择。
本文详解如何在 polars 中优雅、高效地对多个数值列(如 value1/value2/value3)按多个分组键(如 strike/maturity)和多种滚动窗口(如 30d/90d)批量计算 z-score,避免冗余聚合,充分利用 `over()` 窗口函数与正则列选择。
在 Polars 中实现分组滚动 Z-Score(即 (x − μ) / σ),关键在于避免显式 rolling().agg() 后再 join 或 broadcast 的低效模式——这不仅代码冗长,还易引发内存膨胀与逻辑耦合。Polars 的最佳实践是直接利用 over() 窗口函数 + 正则列选择 + 自定义表达式函数,实现“一行定义、并行执行”。
✅ 推荐方案:基于 over() 的向量化 Z-Score
以下代码以 value* 列为示例,按 ["strike", "maturity"] 分组,在 date 时间轴上应用 30 天滚动窗口计算 Z-Score:
import polars as pl
result = (
df.lazy()
.sort("date", "strike", "maturity")
.set_sorted("date") # 告知 Polars date 已排序,加速 rolling 操作
.with_columns(
(
(pl.col(r"^value.*$") -
pl.col(r"^value.*$").rolling_mean(window_size="30d", by="date", min_periods=1))
/ pl.col(r"^value.*$").rolling_std(window_size="30d", by="date", min_periods=1)
)
.over(["strike", "maturity"])
.name.suffix("_zscore_30d")
)
.select("date", "maturity", "strike", pl.col(r"^.*zscore_30d$"))
.collect()
)
? 核心要点说明:
- pl.col(r"^value.*$"):正则匹配所有以 value 开头的列,自动广播至所有匹配列,无需循环构造列表;
- .rolling_mean/std(..., by="date"):指定 date 列作为时间索引进行滚动计算(要求已排序,故调用 .set_sorted("date") 提升性能);
- .over(["strike", "maturity"]):等价于 pandas 的 groupby(...).transform(...),确保滚动统计严格在每个分组内独立计算;
- .name.suffix("_zscore_30d"):统一为结果列添加后缀,语义清晰且避免硬编码列名。
? 扩展支持多窗口:封装为可复用函数
当需同时计算 30d、90d、180d 等多种窗口时,建议将逻辑抽象为函数,保持代码简洁与可维护性:
def rolling_zscore(
expr: pl.Expr,
window_size: str,
by: str = "date",
min_periods: int = 1,
**kwargs
) -> pl.Expr:
"""对表达式计算分组滚动 Z-Score"""
mean = expr.rolling_mean(window_size=window_size, by=by, min_periods=min_periods, **kwargs)
std = expr.rolling_std(window_size=window_size, by=by, min_periods=min_periods, **kwargs)
return ((expr - mean) / std).name.suffix(f"_zscore_{window_size}")
# 应用于多个窗口(自动并行执行)
windows = ["30d", "90d", "180d"]
result = (
df.lazy()
.sort("date", "strike", "maturity")
.set_sorted("date")
.with_columns(
rolling_zscore(pl.col(r"^value.*$"), window_size=w)
.over(["strike", "maturity"])
for w in windows
)
.select(
"date", "maturity", "strike",
pl.col(r"^.*zscore_.*$")
)
.collect()
)
✅ 该写法优势显著:
- 零冗余计算:每个 rolling_mean/std 仅执行一次,而非重复聚合;
- 完全惰性 & 并行:.lazy() 链式操作由 Polars 查询优化器统一调度,多窗口自动并行;
- 强可扩展性:新增窗口只需追加到 windows 列表,无需修改核心逻辑;
- 类型安全 & 可读性强:函数签名明确参数语义,.name.suffix() 保证输出列名自解释。
⚠️ 注意事项与性能提示
- ✅ 务必对 by 列(如 "date")调用 .set_sorted(),否则 rolling_* 将触发隐式排序,大幅降低性能;
- ⚠️ min_periods=1 可避免首几行因数据不足返回 null,但需结合业务判断是否合理(如要求满窗才计算,可设为对应窗口最小有效长度);
- ? 若分组键组合基数极高(如百万级 strike × maturity 组合),建议先 .filter() 或采样验证逻辑,防止内存压力;
- ? 使用 .explain() 查看物理计划,确认 rolling 和 over 是否被优化为单次扫描而非嵌套循环。
通过以上方法,你不仅能写出更简短、更健壮的 Polars 代码,还能获得接近底层 Rust 引擎极限的计算效率——这才是真正发挥 Polars 表达力与性能优势的正确姿势。











