从 pandas 迁移到 Polars:概念差异、表达式思维与代码改写实战指南
【免费下载链接】polarsExtremely fast Query Engine for DataFrames, written in Rust项目地址: https://gitcode.com/GitHub_Trending/po/polars
导读:本文是官方用户指南中「Coming from Pandas」(pandas.md)的深度扩展版,面向已有 pandas 经验、希望转向 Polars 的开发者。文章先厘清两大库在索引、内存格式、并行执行、求值模式与类型系统上的根本差异,再逐条演示数据选择、惰性查询、并行列赋值、窗口函数与缺失值处理的 pandas → Polars 改写套路,并结合仓库源码与配套示例讲解其底层原理,帮助读者建立"表达式优先"的 Polars 心智模型。
概念层面:pandas 与 Polars 的根本差异
pandas 与 Polars 虽然都面向表格型数据,但底层设计哲学截然不同。看懂下面这组概念差异,是写出高质量 Polars 代码的前提。
Polars 没有索引(index),更没有 MultiIndex
pandas 为每一行打上一个标签(index),因此有.loc/.iloc、set_index、reset_index等一系列围绕索引展开的操作与隐患。而 Polars 中每一行由它在表中的整数位置唯一确定:
- Polars 的 DataFrame 始终是一个二维、异构类型的表。列的数据类型可以嵌套(如
List、Struct),但表结构本身不会因为"索引操作"而变形; - 诸如重采样(resampling)等需求,由专门的函数/方法以"动词(verb)"的方式作用在表上,并显式声明其操作的列;
- 查询的语义不会因为索引状态或一次
reset_index调用而改变,从而保证结果可预期、查询可读; - 官方文档的立场是:去掉索引让事情更简单、更显式、更可读、更少出错。
需要澄清的是:Polars 作为优化手段也会在内部构建类似数据库的 index 数据结构,只是它从不暴露给用户语义层。从 Python API 看,数据选择与修改的入口都集中在 frame.py 中,方法名(select、with_columns、filter等)本身就是"动词",替代了 pandas 中围绕.loc的各种读写套路。
内存表示:Apache Arrow 列式格式 vs NumPy 数组
pandas 默认以 NumPy 数组承载数据,而 Polars 严格遵循Apache Arrow 内存规范(对应仓库中的 polars-arrow crate,其 Cargo 清单见 polars-arrow/Cargo.toml)。Arrow 是内存列式分析的事实标准,可以带来更快的加载速度、更低的内存占用和更快的计算,并且天然支持与其他 Arrow 生态工具零拷贝互操作。
若需要把 Polars 数据交给 NumPy 生态处理,官方提供了显式转换通道:to_numpy方法。
并行能力:线程级并发 vs 单线程核心
pandas 只有部分操作是多线程的,核心仍是单线程;要并行化通常得额外引入 Dask 之类的框架。Polars 用 Rust 编写,充分利用 Rust 的并发安全能力把大量操作拆到多线程并行执行。
这一点在仓库结构上非常直观:整个计算栈被拆分为 polars-mem-engine(内存态执行器,其实现位于该 crate 的src/executors)与 polars-stream(流式执行器,src/nodes目录下是按算子拆分的 153 个节点实现)。表达式层面,凡是在同一个select/with_columns/filter上下文内的操作都可以并行,而不像 pandas 需要靠外部框架分片。
注:原指南文档称"Polars 比所有并行化 pandas 代码的开源方案都快",这是项目官方文档中的表述;实际相对性能取决于硬件、数据形态与具体算子,建议以自己场景的基准测试为准。
多引擎支持:内存引擎、流式引擎与 GPU 引擎
Polars 原生提供三类执行引擎,并保证语义一致性(各引擎输出相同的结果):
- 内存(in-process)引擎:针对适合装入内存的数据集优化;
- 流式(streaming)引擎:面向超过内存容量的大规模数据,配合惰性查询做增量流水线处理;
- CuDF 支持的 GPU 引擎:把查询下推到 GPU 执行,详见 GPU engine 指南。
这些引擎共享同一个查询优化器。pandas 虽然也可以在 NumPy 与 PyArrow 后端之间切换,但由于其类型约束松散,两个后端可能产生不同的 dtype 与语义,容易埋下隐蔽 bug;Polars 用统一类型系统规避了这一点。
惰性求值与自动查询优化
- Eager(即时)求值:代码一执行就出结果;
- Lazy(惰性)求值:执行某行代码只是把逻辑加入一棵"查询计划(query plan)",并不真正计算。
pandas 只支持 eager;Dask 通过生成查询计划支持 lazy。Polars两种都支持,而且 lazy 模式的价值在于:查询优化器会在真正执行前分析整棵计划树,寻找加速查询或降低内存的手段(详见后文"查询优化")。
在 Python 侧,.lazy()到collect()的完整链路由 lazyframe/frame.py 承载,可参看 Lazy API 使用指南 与 执行 Lazy 查询。
严格类型系统
Polars 对数据类型非常严格:类型解析取决于操作图,由优化器统一推导。pandas 则会宽松地"隐式转型",例如在整型列中引入缺失值后,整型列会被悄悄转成 float 列。Polars 的做法带来更少 bug 与更可预测的行为——整型列里的缺失值就是null,列仍保持整型。
基于表达式的、更通用的 API
pandas 没有表达式系统,复杂逻辑常需借助 Pythonlambda表达。Polars 几乎所有操作(select、filter、with_columns、group_by.agg…)都接受表达式(Expr)输入——你只要学会一次表达式,知识就能在整个 API 中迁移复用。Polars 把"必须写 Python lambda"视为 API 表达力不足的信号,并尽量提供原生支持。
表达式的 Python 实现集中在 expr/expr.py,条件分支三件套when/then/otherwise见 expr/whenthen.py。后文的大量示例都会体现这一点。
关键语法差异:从 pandas 逐行改写
官方文档把最关键的一句话总结为:
polars != pandas如果你的 Polars 代码看起来像 pandas 代码,它可能能跑,但大概率比应有的速度慢——因为 pandas 的写法(逐行 eager 变换、lambda、局部掩码)会阻止 Polars 进入最优执行路径。
数据选择:用表达式代替.loc/.iloc
因为 Polars 没有索引,所以没有.loc/.iloc,相应地也没有 pandas 的SettingWithCopyWarning。
pandas 取列:
df["a"] df.loc[:, "a"]Polars 取列用.select:
df.select("a")按值筛选行,pandas 用布尔掩码或query,Polars 用.filter:
df.filter(pl.col("a") < 10)由于select/filter接受表达式并整体交给优化器,多组选择条件可以被并行执行并联合优化。
变得"懒惰":用scan_csv+collect替代read_csv
惰性模式应该成为 Polars 的默认工作方式,因为只有 lazy 才能触发查询优化。进入 lazy 有两种途径:使用隐式惰性的读取函数(如scan_csv),或对已有DataFrame调用.lazy()。
考虑官方文档的例子:磁盘上有一个列很多的 CSV,我们只想按id1分组并对v1求和。
pandas 写法:
df = pd.read_csv(csv_file, usecols=["id1", "v1"]) grouped_df = df.loc[:, ["id1", "v1"]].groupby("id1").sum()Polars 惰性写法(只需把 eager 的read_csv换成惰性的scan_csv):
df = pl.scan_csv(csv_file) grouped_df = df.group_by("id1").agg(pl.col("v1").sum()).collect()这里发生了两件重要的事:
- 投影下推(projection pushdown):优化器发现最终只需要
id1、v1两列,于是从 CSV扫描阶段就只读取这两列,而不是像 pandas 那样先整表读入内存再裁剪(pandas 只能靠手动usecols补救); .collect()触发求值:第二行末尾调用.collect()才指示 Polars 真正执行整条查询。
若确实想用 eager 模式,把scan_csv换回read_csv即可。完整的优化手段清单(谓词下推、投影下推、切片下推、公共子计划消除、表达式简化、join 排序、类型强转、基数估计等)可查阅 Optimizations 文档。
pl.scan_*系列不仅限于 CSV——官方文档说明它覆盖 CSV、IPC、Parquet、JSON 等常见格式。想确认某个 lazy 查询到底被优化成了什么样子,可以先用explain打印优化前后的计划树,相关说明见 Query Plan 文档。
表达你自己:用表达式在单个上下文内并行
pandas 脚本的本质是多步顺序执行的变换(每个中间结果都要落一次内存);Polars 则把多个变换折叠进表达式,让它们在一个上下文内并行执行。
列赋值:with_columnsvsassign
假设df有一列value,我们要新增tenXValue(×10)与hundredXValue(×100)两列。
pandas 用assign+ lambda,两步顺序执行:
df.assign( tenXValue=lambda df_: df_.value * 10, hundredXValue=lambda df_: df_.value * 100, )Polars 用.with_columns一次性挂多个表达式,且可以并行执行:
df.with_columns( tenXValue=pl.col("value") * 10, hundredXValue=pl.col("value") * 100, )基于条件的列赋值:when → then → otherwise
假设df有a、b、c三列,当c == 2时用b覆盖a。
pandas 用mask:
df.assign(a=lambda df_: df_["a"].mask(df_["c"] == 2, df_["b"]))Polars 用条件表达式:
df.with_columns( pl.when(pl.col("c") == 2) .then(pl.col("b")) .otherwise(pl.col("a")) .alias("a") )注意 Polars 可以并行计算if → then → otherwise的每个分支——当分支本身很昂贵时(比如每个分支内是复杂聚合),这种并行性价值尤为明显。
过滤与过滤融合
pandas 过滤房产数据:
df.query("m2_living > 2500 and price < 300000") # 或等价掩码 df[(df["m2_living"] > 2500) & (df["price"] < 300000)]Polars:
df.filter( (pl.col("m2_living") > 2500) & (pl.col("price") < 300000) )更进一步:即使你把过滤写成多个分散的.filter调用,查询优化器也会检测到并把它们合并为单个 filter(属于谓词下推/表达式简化范畴,可对照 Optimizations 文档 中的说明),尽量避免多次遍历数据。
pandastransform→ Polars 窗口表达式.over()
pandas 文档中经典的groupby(...).transform(...)用法,在 Polars 中对应的是窗口函数。给定如下DataFrame,我们希望新增一列size,表示每个c分组内的行数:
df = pd.DataFrame({ "c": [1, 1, 1, 2, 2, 2, 2], "type": ["m", "n", "o", "m", "m", "n", "n"], }) df["size"] = df.groupby("c")["type"].transform(len)pandas 的思路是:按c分组 → 取type→ 算组长度 → 再把结果join 回原表:
c type size 0 1 m 3 1 1 n 3 2 1 o 3 3 2 m 4 4 2 m 4 5 2 n 4 6 2 n 4Polars 用窗口表达式一步到位(不需要手动 join):
df.with_columns( pl.col("type").count().over("c").alias("size") )shape: (7, 3) ┌─────┬──────┬──────┐ │ c ┆ type ┆ size │ │ --- ┆ --- ┆ --- │ │ i64 ┆ str ┆ u32 │ ╞═════╪══════╪══════╡ │ 1 ┆ m ┆ 3 │ │ 1 ┆ n ┆ 3 │ │ 1 ┆ o ┆ 3 │ │ 2 ┆ m ┆ 4 │ │ 2 ┆ m ┆ 4 │ │ 2 ┆ n ┆ 4 │ │ 2 ┆ n ┆ 4 │ └─────┴──────┴──────┘为什么窗口比"transform + join"更强?因为整组逻辑被压缩进一个表达式,你可以在同一个上下文中组合多个窗口函数,甚至可以基于不同的分组键同时计算。而且 Polars 会缓存作用于同一分组的窗口表达式——把多次.over("c")放进同一个.with_columns既方便又是最优选择:
df.with_columns( pl.col("c").count().over("c").alias("size"), pl.col("c").sum().over("type").alias("sum"), pl.col("type").reverse().over("c").alias("reverse_type"), )shape: (7, 5) ┌─────┬──────┬──────┬─────┬──────────────┐ │ c ┆ type ┆ size ┆ sum ┆ reverse_type │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ i64 ┆ str ┆ u32 ┆ i64 ┆ str │ ╞═════╪══════╪══════╪═════╪══════════════╡ │ 1 ┆ m ┆ 3 ┆ 5 ┆ o │ │ 1 ┆ n ┆ 3 ┆ 5 ┆ n │ │ 1 ┆ o ┆ 3 ┆ 1 ┆ m │ │ 2 ┆ m ┆ 4 ┆ 5 ┆ n │ │ 2 ┆ m ┆ 4 ┆ 5 ┆ n │ │ 2 ┆ n ┆ 4 ┆ 5 ┆ m │ │ 2 ┆ n ┆ 4 ┆ 5 ┆ m │ └─────┴──────┴──────┴─────┴──────────────┘在这个例子里,size和reverse_type都按c分组(缓存复用),而sum按type分组——三种窗口统计同时计算、互不干扰。
.over()的能力不止于此:它支持多列分组(如.over("Type 1", "Type 2")),也能配合rank、sort_by、head等实现"组内 Top-N",官方配套示例见 window.py;.over()的完整行为在 表达式/窗口相关文档 中有进一步展开。
缺失值:null与NaN的严格区分
pandas 的混乱 vs Polars 的整齐
pandas 依据列 dtype 混用NaN/None表示缺失,且行为还会因是否启用可选的可空数组而不同。Polars 的规则非常干净:
- 所有数据类型的缺失值统一用
null表示; - 浮点列允许出现
NaN,但NaN是"特殊的浮点值",不算缺失数据; - 整型列出现缺失时,pandas(除非用可空 dtype)会把整型列悄悄转成带
NaN的 float 列;Polars 中整型列的缺失值就是null,列保持整型。
处理缺失值:fill_null的四种填充方式
关于缺失值的完整讲解见 Missing data 文档,其可执行示例位于 missing-data.py。fill_null支持四类填充来源:
- 字面量:
.fill_null(0)用常量替换所有null; - 表达式:
.fill_null(pl.col("b") * 2),用另一列的派生值逐行填充; - 邻值策略:
.fill_null(strategy="forward" | "backward"),用前/后第一个非null值填充; - 插值:
.fill_null(pl.col("x").interpolate())——注意用interpolate方法而非fill_null,且序列首尾的null保持为null。
NaN的特殊语义
Polars 把NaN视为浮点数值而非缺失,因此:
NaN不计入null_count;fill_null不填充NaN,需要用专门的fill_nan;- 与
null不同,Polars 并不为NaN维护元数据,is_nan需要真实计算; - 数值聚合(
mean、sum等)会跳过null,但会把NaN计入并让它传播到结果。若希望聚合忽略NaN,可先用fill_nan(None)(等价写法fill_nan(None)将NaN转成null)再聚合。
顺带一提:把NaN写进整型列,pandas 会静默转型成 float;Polars 不会转型而是直接抛异常——这正是前文"严格类型系统"的体现。
别到处.pipe():用"返回表达式的函数"替代
pandas 生态里很流行用.pipe把一个函数依次作用在DataFrame上:
def add_foo(df: pd.DataFrame) -> pd.DataFrame: df["foo"] = ... return df def add_bar(df: pd.DataFrame) -> pd.DataFrame: df["bar"] = ... return df def add_ham(df: pd.DataFrame) -> pd.DataFrame: df["ham"] = ... return df (df .pipe(add_foo) .pipe(add_bar) .pipe(add_ham) )如果把这套习惯原样搬进 Polars,你会得到3 个独立的with_columns上下文,迫使 Polars 串行执行 3 段变换,并行度为零,并产生次优的查询计划。
正确做法是:把每个变换写成"创建表达式"的函数。下面的写法在同一个with_columns上下文中注入 3 个表达式,从而被允许并行执行:
def get_foo(input_column: str) -> pl.Expr: return pl.col(input_column).some_computation().alias("foo") def get_bar(input_column: str) -> pl.Expr: return pl.col(input_column).some_computation().alias("bar") def get_ham(input_column: str) -> pl.Expr: return pl.col(input_column).some_computation().alias("ham") # 单个上下文,3 个表达式并行运行 df.with_columns( get_ham("col_a"), get_bar("col_b"), get_foo("col_c"), )如果这些生成表达式的函数确实需要读取 schema才能决定分支,才使用(而且只用一次)pipe——在管道的最外层把LazyFrame传给一个闭包,闭包内读取lf.schema后再构造with_columns:
from collections import OrderedDict def get_foo(input_column: str, schema: OrderedDict) -> pl.Expr: if "some_col" in schema: # branch_a ... else: # branch b ... def get_bar(input_column: str, schema: OrderedDict) -> pl.Expr: if "some_col" in schema: # branch_a ... else: # branch b ... def get_ham(input_column: str) -> pl.Expr: return pl.col(input_column).some_computation().alias("ham") # 仅在需要获取 LazyFrame 的 schema 时使用一次 pipe lf.pipe(lambda lf: lf.with_columns( get_ham("col_a"), get_bar("col_b", lf.schema), get_foo("col_c", lf.schema), ))"返回表达式的函数"还有额外的架构收益:表达式可链式调用、可偏应用、可组合,从而让自定义逻辑具备远超 pandaspipe链的复用性与灵活性——这也是从 pandas 迁移到 Polars 时最值得刻意练习的思维转变。
迁移要点速查
- 忘掉索引:用整数位置理解行,用
select/filter/with_columns这些"动词"操作数据; - 默认走 lazy:文件入口用
scan_csv/scan_parquet等,内存 DataFrame 调.lazy(),末尾.collect();让投影下推与谓词下推帮你少读数据; - 拒绝 lambda:凡是一个 pandas lambda 能做的事,先想想 Polars 是否已有原生表达式(
when/then/otherwise、算术、字符串、聚合、窗口…); - 把多步
assign/pipe收敛为单个上下文内的多个表达式,换取并行执行与更优查询计划; - 数据在列内嵌套没关系,但表永远是二维的;需要"行级标签"语义时,显式创建一列即可;
- 缺失值只认
null(浮点列另有不算缺失的NaN),整型列不再因为缺失而悄悄变 float。
完整的惰性 API 讲解可继续阅读 Lazy API 使用指南(配套示例 using.py)与 执行 Lazy 查询;若想对照 Spark 的迁移思路,仓库还提供了一份平行的 Spark 迁移指南。
【免费下载链接】polarsExtremely fast Query Engine for DataFrames, written in Rust项目地址: https://gitcode.com/GitHub_Trending/po/polars
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考