1. 背景:为什么在Pandas 已经很好用的年代还要学 Polars
1.1 数据工程师的三次觉醒
做工业数采的人都会经历三个阶段:
- 行式内存布局:按记录连续存储,读取一列要跨过整行所有字段,CPU 缓存利用率低;
- 单线程执行:现代 CPU 8 核 16 线程摆在那里,Pandas 只用一个核;
- 急切执行:每写一行代码就立刻算完,中间产生大量临时 DataFrame,内存峰值失控;
- 函数名混乱:df[df['a']>1]、df.query()、df.loc[…]、df.filter()……同一个操作有四五种写法,可读性灾难。
1.2 Polars 是什么
Polars 是 2020 年起由 Ritchie Vink 发起、用 Rust 编写的开源 DataFrame 库(MIT 协议)。它的核心承诺是:
| 快 | 列式存储(Apache Arrow 内存格式)+ SIMD 向量化 + 多线程并行 |
| 省 | 惰性执行(LazyFrame)整棵查询树优化后再执行,无中间临时帧 |
| 稳 | 类型严格(区分 Int32/Int64/Float32/Float64),拒绝「静默隐式转换」 |
| 爽 | 统一表达式 API,一个 .expr() 语法贯穿选择、过滤、聚合、窗口 |
它与 Pandas 不是替代与被替代,而是 通用分析工具 vs 高性能引擎的分层关系:
| 内存布局 | 行式(BlockManager) | 列式(Arrow) |
| 执行模型 | 急切(Eager) | 急切 + 惰性(Lazy) |
| 并行 | 单线程为主 | 多线程(默认全部核心) |
| 类型系统 | 宽松,隐式转换 | 严格,类型敏感 |
| 表达式 | 方法分散、写法多 | 统一表达式体系 |
| 适用 | 探索性分析、机器学习特征工程、生态依赖 | 大批量数据清洗、ETL、报表聚合、流式处理 |
2. 核心概念速览
先建立 Polars 的世界观,它只有几个关键概念,但每个都比 Pandas 更较真。
2.1 DataFrame 与 Series(和 Pandas 同名但不同内核)
- pl.DataFrame:二维表,底层是 Arrow 列式存储,每一列是一个连续内存数组,类型一致;
- pl.Series:单列,本质是指向 Arrow ChunkedArray 的句柄;
- 列的类型有严格语义:Int8/16/32/64、UInt*、Float32/64、String、Boolean、Date/Datetime/Duration、List、Struct、Categorical、Enum。
2.2 表达式(Expression):Polars 的灵魂
Pandas 的 API 是方法作用在 DataFrame 上;Polars 的 API 是 表达式作用在列上,再传给上下文。
# Pandas 风格:方法分散
df[df['ts'].dt.hour == 8]['value'].mean()
# Polars 风格:表达式统一
df.filter(pl.col('ts').dt.hour() == 8).select(pl.col('value').mean())
表达式可以组合、复用、并行求值。一个表达式对象可以在多个上下文里用:select(选列)、filter(过滤)、with_columns(新增列)、group_by(分组聚合)、sort(排序)。
2.3 Eager 与 Lazy 双模式
- Eager:.collect() 前的 DataFrame 操作,每步立刻执行;
- Lazy:.lazy() 生成 LazyFrame,只记录查询计划,最终 .collect() 才执行。中间不会产生任何临时帧,且会触发查询优化器。
这是 Polars 性能神话的核心之一:lazy 模式会把你的整条流水线(读文件 → 过滤 → 聚合 → 排序)合并优化后再执行,而不是像 Pandas 一样每一步建一个临时表。
2.4 列式存储为什么快
数据在内存中是按列连续存放的。这意味着:
- 读取一列 = 顺序扫描一段连续内存,CPU 缓存命中率高;
- 过滤一列时,其他列完全不碰(Pandas 行式存储要整行读出再丢弃);
- 数值计算天然向量化(一列数据就是一片同类型数组,可以直接上 SIMD);
- 压缩友好(同类型数据压缩率高,pl.read_parquet 是主流格式)。
2.5 流式处理(Streaming)
面对「超过内存」的数据,Polars 提供流式执行:.collect(streaming=True),查询引擎会在必要时把中间结果落盘(如排序、聚合),用边读边算代替全量载入内存。
3. 安装与快速上手
pip install polars # 当前稳定版 1.x
pip install pyarrow # 可选的 Arrow 互操作扩展
pip install polars[timezone] # 时区支持
Polars 是编译好的二进制 wheel,不需要本地 Rust 环境。导入:
import polars as pl
五分钟最小闭环:
# 构造
df = pl.DataFrame({
"device": ["CNC-01", "CNC-01", "CNC-02", "CNC-02"],
"ts": ["2026-10-23 08:00:00", "2026-10-23 08:00:01",
"2026-10-23 08:00:00", "2026-10-23 08:00:01"],
"value": [10.5, 12.1, 8.2, 9.0],
}).with_columns(pl.col("ts").str.to_datetime())
# 过滤 + 聚合(Polars 风格)
result = (
df.lazy()
.filter(pl.col("value") > 9)
.group_by("device")
.agg(pl.col("value").mean().alias("avg_value"))
.sort("avg_value", descending=True)
.collect()
)
print(result)
输出:
shape: (2, 2)
┌─────────┬───────────┐
│ device ┆ avg_value │
╞═════════╪═══════════╡
│ CNC-01 ┆ 11.3 │
│ CNC-02 ┆ 8.2 │
└─────────┴───────────┘
4. API 说明(高频速查)
4.1 创建与 IO
| 从 dict / list / numpy 构造 | pl.DataFrame({…})、pl.DataFrame(arr, schema=…) |
| 读 CSV | pl.read_csv(path, schema_overrides={…}, try_parse_dates=True) |
| 读 Parquet / Arrow IPC | pl.read_parquet(path)、pl.read_ipc(path) |
| 读 JSON / NDJSON | pl.read_json(path)、pl.read_ndjson(path) |
| 连接数据库 | pl.read_database(uri=…, query=…) |
| 写文件 | df.write_csv(…)、df.write_parquet(…)、df.write_json(…)、df.write_ipc(…) |
| 与 Pandas / NumPy 互转 | df.to_pandas()、df.to_numpy()、pl.from_pandas(pd_df)、pl.from_numpy(arr) |
4.2 选择与操作上下文
| df.select(…) | 选列 / 计算列 | df.select(pl.col("a"), (pl.col("b")*2).alias("b2")) |
| df.with_columns(…) | 新增 / 覆盖列 | df.with_columns(pl.col("v").diff().alias("delta")) |
| df.filter(…) | 行过滤 | df.filter(pl.col("q") == 1) |
| df.group_by(…).agg(…) | 分组聚合 | df.group_by("device").agg(pl.len(), pl.col("v").mean()) |
| df.sort(…) | 排序 | df.sort("ts")、df.sort(pl.col("v").rank()) |
| df.unique() | 去重 | df.unique(subset=["device"]) |
4.3 列选择语法
- pl.col("a")、pl.col("a", "b")、pl.col("^prefix_.*$")(正则)、pl.all()、pl.exclude("ts");
- df.select(pl.col("a").name.prefix("raw_")) 批量改名;
- 取子集:df.select(["device", "value"]) 直接传字符串列表也可以。
4.4 常用表达式函数族
| 数值 | sum/mean/median/std/var/min/max、diff、pct_change、rank、cum_sum、clip、round、abs、log、pow、shift |
| 字符串 | str.len_chars、str.contains、str.starts_with、str.to_uppercase、str.split、str.extract、str.replace |
| 时间 | dt.year/month/day/hour/minute/second、dt.truncate("1h")、dt.round("5m")、dt.epoch("s")、dt.strftime |
| 条件 | pl.when(cond).then(a).otherwise(b)(对标 np.where)、is_in、is_null、is_between |
| 聚合 | first/last/n_unique/unique/count/len、quantile(0.95)、arg_max/arg_min、n_unique |
| 列表/结构 | list.len、list.first、list.sum、list.eval、struct.field |
4.5 窗口函数
over() 是 Polars 的分组窗口语法,对标 SQL 的 PARTITION BY:
df.with_columns(
pl.col("value").mean().over("device").alias("device_avg"), # 组内均值广播
pl.col("value").rank().over(["device", pl.col("ts").dt.hour()]).alias("rank_in_hour"),
)
4.6 Join
df1.join(df2, on="device", how="left") # left/right/inner/outer/anti/semi/cross
df1.join_asof(df2, on="ts", by="device") # 最近时间匹配,工业时序对齐神器
join_asof 是 Polars(及 DuckDB)独有亮点:两表按时间就近对齐,做设备档案与点位明细关联时不需要先做时间分箱。
4.7 其他高频方法
- df.describe():统计摘要;
- df.head()/tail()、df.sample(n=1000):抽样;
- df.explode("list_col"):把列表列炸开成多行;
- df.pivot(values="v", index="device", columns="hour"):透视;
- df.estimated_size():估算内存;
- df.glimpse():列类型快速预览(类似 df.info())。
5. 详细使用说明(8 组可运行示例)
以下示例围绕工业数采场景组织,全部可直接运行。
5.1 最小闭环:从 CSV 读点位明细并做分钟级聚合
import polars as pl
# 模拟 1 台设备 3 天的秒级点位(这里直接造数据,实际用 pl.read_csv)
df = pl.DataFrame({
"device": ["CNC-01"] * 259200,
"ts": pl.datetime_range(
start="2026-10-20 00:00:00", end="2026-10-22 23:59:59",
interval="1s", eager=True,
),
"load": [60 + (i % 40) for i in range(259200)],
}).with_columns(pl.col("ts").dt.cast_time_unit("ms").alias("ts_ms"))
minute_stats = (
df.lazy()
.with_columns(pl.col("ts").dt.truncate("1m").alias("minute"))
.group_by(["device", "minute"])
.agg(
pl.col("load").mean().alias("avg_load"),
pl.col("load").max().alias("max_load"),
pl.col("load").quantile(0.95).alias("p95_load"),
)
.collect()
)
print(minute_stats.head())
要点:dt.truncate("1m") 把时间对齐到分钟;group_by 内一次性算三个指标,比 Pandas 三次 agg 再 concat 省得多。
5.2 惰性查询优化:三步流水线合并为一次执行
# 先 lzma,再 collect:中间不产生临时 DataFrame
plan = (
df.lazy()
.filter(pl.col("load") > 50)
.with_columns((pl.col("load") – 60).alias("overload"))
.group_by("device")
.agg(pl.col("overload").mean())
)
print(plan.explain()) # 查看优化后的执行计划
result = plan.collect() # 真正执行
explain() 能直接看到:Polars 会把 filter 下推到读取阶段(ProjectionPushdown),整条链路只扫描一次数据。
5.3 窗口函数:设备日均值 + 相对偏差
df.with_columns(
pl.col("ts").dt.date().alias("day"),
).with_columns(
(pl.col("load") / pl.col("load").mean().over(["device", "day"]) – 1).alias("deviation"),
).select("device", "day", "deviation").head()
5.4 多表关联:点位明细 × 设备档案 × 工艺参数
details = pl.DataFrame({…}) # 秒级点位
devices = pl.DataFrame({ # 设备档案
"device": ["CNC-01", "CNC-02"],
"line": ["L1", "L2"],
"model": ["VMC-850", "VMC-1060"],
})
limits = pl.DataFrame({ # 工艺参数阈值
"model": ["VMC-850", "VMC-1060"],
"load_hi": [85, 88],
})
enriched = (
details
.join(devices, on="device", how="left")
.join(limits, on="model", how="left")
.with_columns(
pl.when(pl.col("load") > pl.col("load_hi"))
.then(pl.lit("ALARM")).otherwise(pl.lit("OK")).alias("status")
)
)
5.5 join_asof:档案变更与点位时序对齐
events = pl.DataFrame({ # 换刀、改参等事件
"ts": ["2026-10-22 09:10:00", "2026-10-22 14:30:00"],
"device": ["CNC-01", "CNC-01"],
"event": ["tool_change", "param_update"],
}).with_columns(pl.col("ts").str.to_datetime())
aligned = df.join_asof(events, on="ts", by="device", strategy="backward")
每个点位自动带上最近一次发生的事件,做根因回溯非常好用。
5.6 流式处理:处理超过内存的 Parquet
# 假设 hourly 目录下有 10 GB Parquet 分片
lf = pl.scan_parquet("data/hourly/*.parquet") # scan_* 系列天然惰性
summary = (
lf.filter(pl.col("q") == 1)
.group_by(["device", pl.col("ts").dt.truncate("1h")])
.agg(pl.col("value").mean())
.collect(streaming=True)
)
scan_parquet 只读取查询所需的列(列裁剪)+ 只读取满足过滤的行组(谓词下推),配合 streaming=True 可以处理远超内存的数据。
5.7 与 Pandas / Matplotlib 互操作
# Polars 算,Pandas/Matplotlib 画(或直接 Matplotlib 消费 numpy)
import matplotlib.pyplot as plt
trend = (
df.lazy()
.group_by(pl.col("ts").dt.truncate("1h"))
.agg(pl.col("load").mean())
.collect()
)
plt.plot(trend["ts"].to_numpy(), trend["load"].to_numpy())
5.8 生产骨架:批量落盘 + 增量追加 + 压缩
# 每小时把聚合结果追加到 Parquet(用 pyarrow 或直接写分片文件)
def write_hourly(df: pl.DataFrame, base: str):
hour = df["ts"].min().strftime("%Y%m%d_%H")
path = f"{base}/agg_{hour}.parquet"
df.write_parquet(path, compression="zstd", compression_level=19)
6. 底层实现剖析(它为什么这么快)
6.1 Apache Arrow 列式内存格式
Polars 的所有数据都装在 Arrow 的 ChunkedArray 里。每列是一块连续的同类型缓冲,另外还有一个 validity bitmap(位图)表示空值——所以空值不需要单独的 NaN 哨兵,也不占 8 字节。内存布局对 CPU 缓存极度友好:扫描一列 = 顺序读一片连续内存。
6.2 多线程并行
Polars 默认使用机器全部核心。它的并行策略是:
- 按列并行:select 里多个表达式可以并行求值;
- 按块并行:单列操作把数据切成 chunk 并行处理,再拼接;
- 按组并行:group_by 先并行哈希分组,再合并各组结果。
对比 Pandas 的单线程,8 核机器上纯计算任务轻松快 5~20 倍。
6.3 SIMD 向量化
Rust 编译器(LLVM)自动为热循环生成 AVX2/AVX-512 指令,一次处理 8 个 float64 或 16 个 int32。列式布局让「整列是连续同类型数组」成为常态,SIMD 收益最大化。
6.4 查询优化器(Lazy 的秘密)
Polars 的 LazyFrame 会做以下优化(explain() 可见):
- Predicate Pushdown:把 filter 尽量推到读取/扫描阶段,只读需要的行;
- Projection Pushdown:只读取/计算需要的列;
- Slice Pushdown:head/tail 提前截断;
- CSE(公共子表达式消除):多个表达式重复计算的部分只算一次;
- Join 重排:小表优先、hash join 等策略选择。
6.5 表达式系统与零中间帧
表达式是 AST(抽象语法树):pl.col("load").mean().over("device") 构建一棵树,执行时由引擎统一调度。正因为 Lazy 模式整棵树一次执行,中间不会物化临时 DataFrame,内存峰值大幅下降——这是 Pandas 急切执行无法避免的痛点。
6.6 内存与缓存策略
- 大文件读取使用 mmap(scan_parquet 依赖 Arrow 的内存映射);
- 写入使用批缓冲;collect(streaming=True) 时排序、聚合等算子自动 spill 到磁盘临时文件。
网硕互联帮助中心





评论前必须登录!
注册