首页
/ 从 pandas 迁移到 Polars:概念差异、表达式思维与代码改写实战指南

从 pandas 迁移到 Polars:概念差异、表达式思维与代码改写实战指南

2026-09-08 11:46:11作者:丁柯新Fawn

导读:本文是官方用户指南中「Coming from Pandas」(pandas.md)的深度扩展版,面向已有 pandas 经验、希望转向 Polars 的开发者。文章先厘清两大库在索引、内存格式、并行执行、求值模式与类型系统上的根本差异,再逐条演示数据选择、惰性查询、并行列赋值、窗口函数与缺失值处理的 pandas → Polars 改写套路,并结合仓库源码与配套示例讲解其底层原理,帮助读者建立"表达式优先"的 Polars 心智模型。

概念层面:pandas 与 Polars 的根本差异

pandas 与 Polars 虽然都面向表格型数据,但底层设计哲学截然不同。看懂下面这组概念差异,是写出高质量 Polars 代码的前提。

Polars 没有索引(index),更没有 MultiIndex

pandas 为每一行打上一个标签(index),因此有 .loc / .ilocset_indexreset_index 等一系列围绕索引展开的操作与隐患。而 Polars 中每一行由它在表中的整数位置唯一确定:

  • Polars 的 DataFrame 始终是一个二维、异构类型的表。列的数据类型可以嵌套(如 ListStruct),但表结构本身不会因为"索引操作"而变形;
  • 诸如重采样(resampling)等需求,由专门的函数/方法以"动词(verb)"的方式作用在表上,并显式声明其操作的列;
  • 查询的语义不会因为索引状态或一次 reset_index 调用而改变,从而保证结果可预期、查询可读
  • 官方文档的立场是:去掉索引让事情更简单、更显式、更可读、更少出错。

需要澄清的是:Polars 作为优化手段也会在内部构建类似数据库的 index 数据结构,只是它从不暴露给用户语义层。从 Python API 看,数据选择与修改的入口都集中在 frame.py 中,方法名(selectwith_columnsfilter 等)本身就是"动词",替代了 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 原生提供三类执行引擎,并保证语义一致性(各引擎输出相同的结果):

  1. 内存(in-process)引擎:针对适合装入内存的数据集优化;
  2. 流式(streaming)引擎:面向超过内存容量的大规模数据,配合惰性查询做增量流水线处理;
  3. 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 没有表达式系统,复杂逻辑常需借助 Python lambda 表达。Polars 几乎所有操作(selectfilterwith_columnsgroup_by.agg…)都接受表达式(Expr)输入——你只要学会一次表达式,知识就能在整个 API 中迁移复用。Polars 把"必须写 Python lambda"视为 API 表达力不足的信号,并尽量提供原生支持。

表达式的 Python 实现集中在 expr/expr.py,条件分支三件套 when/then/otherwiseexpr/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()

这里发生了两件重要的事:

  1. 投影下推(projection pushdown):优化器发现最终只需要 id1v1 两列,于是从 CSV 扫描阶段就只读取这两列,而不是像 pandas 那样先整表读入内存再裁剪(pandas 只能靠手动 usecols 补救);
  2. .collect() 触发求值:第二行末尾调用 .collect() 才指示 Polars 真正执行整条查询。

若确实想用 eager 模式,把 scan_csv 换回 read_csv 即可。完整的优化手段清单(谓词下推、投影下推、切片下推、公共子计划消除、表达式简化、join 排序、类型强转、基数估计等)可查阅 Optimizations 文档

pl.scan_* 系列不仅限于 CSV——官方文档说明它覆盖 CSV、IPC、Parquet、JSON 等常见格式。想确认某个 lazy 查询到底被优化成了什么样子,可以先用 explain 打印优化前后的计划树,相关说明见 Query Plan 文档

表达你自己:用表达式在单个上下文内并行

pandas 脚本的本质是多步顺序执行的变换(每个中间结果都要落一次内存);Polars 则把多个变换折叠进表达式,让它们在一个上下文内并行执行

列赋值:with_columns vs assign

假设 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

假设 dfabc 三列,当 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 文档 中的说明),尽量避免多次遍历数据。

pandas transform → 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    4

Polars 用窗口表达式一步到位(不需要手动 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            │
└─────┴──────┴──────┴─────┴──────────────┘

在这个例子里,sizereverse_type 都按 c 分组(缓存复用),而 sumtype 分组——三种窗口统计同时计算、互不干扰。

.over() 的能力不止于此:它支持多列分组(如 .over("Type 1", "Type 2")),也能配合 ranksort_byhead 等实现"组内 Top-N",官方配套示例见 window.py.over() 的完整行为在 表达式/窗口相关文档 中有进一步展开。

缺失值:nullNaN 的严格区分

pandas 的混乱 vs Polars 的整齐

pandas 依据列 dtype 混用 NaN/None 表示缺失,且行为还会因是否启用可选的可空数组而不同。Polars 的规则非常干净:

  • 所有数据类型的缺失值统一用 null 表示;
  • 浮点列允许出现 NaN,但 NaN 是"特殊的浮点值",不算缺失数据
  • 整型列出现缺失时,pandas(除非用可空 dtype)会把整型列悄悄转成带 NaN 的 float 列;Polars 中整型列的缺失值就是 null,列保持整型

处理缺失值:fill_null 的四种填充方式

关于缺失值的完整讲解见 Missing data 文档,其可执行示例位于 missing-data.pyfill_null 支持四类填充来源:

  1. 字面量.fill_null(0) 用常量替换所有 null
  2. 表达式.fill_null(pl.col("b") * 2),用另一列的派生值逐行填充;
  3. 邻值策略.fill_null(strategy="forward" | "backward"),用前/后第一个非 null 值填充;
  4. 插值.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 需要真实计算;
  • 数值聚合(meansum 等)会跳过 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),
))

"返回表达式的函数"还有额外的架构收益:表达式可链式调用、可偏应用、可组合,从而让自定义逻辑具备远超 pandas pipe 链的复用性与灵活性——这也是从 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 迁移指南

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
918
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.6 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
517
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389