与 Pandas 的比较#
Polars 和 Pandas 都是常用的数据处理库,但两者对“一张数据表是什么”的理解并不一样:Pandas 把行索引当作表结构的一部分,Polars 则完全不设行索引。这个差别会影响运算的写法、性能表现和出错的方式。
这一章从行索引、列名、表达式、数据可变性、嵌套类型五个角度对比两者。重点不是逐条列出 API 的对应关系,而是说明两边的心智模型差在哪里:哪些 Pandas 习惯可以照搬,哪些必须换一种写法。
读完之后,应该能判断一段 Pandas 代码搬到 Polars 上该怎么写,以及哪些习惯需要调整。
import pandas as pd
import polars as pl
import numpy as np
from helper.jupyter import row
行索引#
Pandas 用行索引(Index 对象)按标签引用行。索引不是普通的一列,而是 DataFrame 结构的一部分:它参与按标签选行,参与两个表之间的对齐,也参与分组和绘图时的坐标。默认是从 0 开始的整数,也可以指定任意标签,甚至使用层次化的 MultiIndex。
按标签选行的 df.loc[...] 走哈希表,复杂度是 \(O(1)\);因此需要反复按某个标签取行时,把它设成索引会快得多。索引还可以有名字,reset_index() 能把它变回普通列,set_index() 则把一列提升为索引。
也正因为索引是“隐式”的结构,读写 CSV、拼接表、做聚合时,索引的对齐规则会间接影响结果;这层差异在从 Pandas 转到 Polars 时最容易被忽略。
data = {"name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35], "index": ["a", "b", "c"]}
df = pd.DataFrame(data).set_index("index")
df.loc[["a"]] # 走索引,所以是 O(1)
| name | age | |
|---|---|---|
| index | ||
| a | Alice | 25 |
Polars 没有行索引,行只能通过列的值来选择:把条件传给 filter(),复杂度是 \(O(N)\) 的线性扫描。
这样设计的好处是不依赖行号,内存布局和并行都更容易优化;代价是按标签取一行没有捷径。如果行序或行号本身有含义,应把它作为一列显式保存,而不是依赖索引。
从 Pandas 迁移时,第一件事通常就是 reset_index():把索引转成普通列,再交给 Polars。之后所有按标签的查找都写成 filter(pl.col(...) == ...),语义和 Pandas 的 loc 相同,只是复杂度从 \(O(1)\) 变成 \(O(N)\)。
df = pl.DataFrame(data)
df.filter(pl.col("index") == "a") # 按列的值查找,所以是 O(N)
| name | age | index |
|---|---|---|
| str | i64 | str |
| "Alice" | 25 | "a" |
两者的差别整理成下表。这里的“适用场景”并非绝对的好坏:数据量小时,用 Pandas 的索引反而更方便。
特征 |
Pandas |
Polars |
|---|---|---|
行索引 |
有 |
没有 |
访问速度 |
\(O(1)\)(走索引时) |
\(O(N)\)(基于列的过滤) |
设计取向 |
重视行列操作的灵活性 |
重视列式与效率 |
适用场景 |
小到中等规模的交互式处理 |
大规模数据的高速处理 |
Pandas 在计算前会先把行索引和列索引对齐,所以两个行顺序不同的表相减也不会错位。对齐时取索引的并集,缺失的位置用 fill_value 补上;这是 Pandas 的默认行为,不需要显式写出来。
Polars 没有这套机制,同样的操作需要显式连接:下面的例子用 df1 减 df2,其中 df2 只包含 a、c、e 三行。连接方式、缺失值怎么填、结果保留哪些行,都要在代码里写清楚。
df1 = pd.DataFrame(
np.random.randint(0, 10, (5, 3)),
columns=['X', 'Y', 'Z'],
index=['a', 'b', 'c', 'd', 'e']
)
df2 = pd.DataFrame(
np.random.randint(10, 20, (3, 2)),
columns=['X', 'Z'],
index=['a', 'c', 'e']
)
row(df1, df2, df1.sub(df2, fill_value=0))
|
|
|
要用 Polars 做同样的事,需要先用 join() 把行对齐,再按列执行计算。这里写一个通用的 align_op():先按 index 做左连接,把 df2 的列并到 df1 上;fill_null() 把 df2 缺失的位置补成 0;with_columns() 对两边都有的列逐个套用运算;最后 select() 恢复 df1 的列顺序。
由于使用了 LazyFrame,连接、填空和列运算会合成一个查询,交给 Rust 侧优化后再整体执行,而不是分三步分别执行。这样既保留了 Pandas 对齐的语义,又不必在中间物化临时表。
def align_op(df1, df2, op, on="index", fill_value=0):
# 只对两边都有的列做运算(结合键除外)
common_columns = [c for c in df1.columns if c in df2.columns and c != on]
return (
df1.lazy()
.join(df2.lazy(), on=on, how="left", maintain_order="left")
.fill_null(fill_value) # df2 里没有的行按 0 处理
.with_columns(
[
op(pl.col(col), pl.col(f"{col}_right")).alias(col)
for col in common_columns
]
)
.select(df1.columns)
.collect()
)
# 把 Pandas 的索引落到列上,再交给 Polars
df3 = pl.from_pandas(df1.reset_index())
df4 = pl.from_pandas(df2.reset_index())
row(df3, df4, align_op(df3, df4, pl.Expr.sub))
shape: (5, 4)
|
shape: (3, 3)
|
shape: (5, 4)
|
列名的差别#
Pandas 允许列名重复,也能一次选出同名的多列:下面的例子里 age 出现两次,df2[["age"]] 会把两列都取出来。这种表在透视或拼接之后很常见,因为 Pandas 用位置区分同名列。
Polars 不允许列名重复。它的表达式用列名定位列,pl.col("age") 必须能唯一确定一列;如果允许重名,表达式的含义就不明确了。所以在 Polars 里遇到同名列,要么先改名,要么用 alias() 生成新的列名。
df = pd.DataFrame(data).set_index('index')
df2 = df[['name', 'age', 'age']]
row(df2, df2[['age']])
|
|
Polars 不允许列名重复,想用两次同一列就必须用 alias() 指定别名。下面的代码把第二个 age 改成 age_2,再用正则 ^age.*$ 把以 age 开头的列一起选出来。
正则选择器是 Polars 弥补“不能重名”的常用手段:列名不能重复,但可以按模式批量选。
df = pl.DataFrame(data)
df2 = df.select('name', 'age', pl.col('age').alias('age_2'))
row(df2, df2.select(pl.col('^age.*$')))
shape: (3, 3)
|
shape: (3, 2)
|
Pandas 的列名可以是整数或元组,这样能把多级表头放进一个 DataFrame。这样做虽然灵活,但列名类型不统一时,代码里会大量出现 df[10] 这样的魔法值,可读性很差。
data = {10: ['Alice', 'Bob', 'Charlie'], 20: [25, 30, 35]}
df = pd.DataFrame(data)
df[10]
0 Alice
1 Bob
2 Charlie
Name: 10, dtype: str
Polars 的列名必须是字符串。类型固定下来,表达式和方法的参数更易书写和阅读。需要用整数键时,先转成字符串再传入。
df = pl.DataFrame({str(key):value for key, value in data.items()})
df.select(pl.col('10'))
| 10 |
|---|
| str |
| "Alice" |
| "Bob" |
| "Charlie" |
表达式#
表达式(expression)表示“对列的计算”,它本身不是结果。Polars 先构造表达式,再交给 select() 或 with_columns(),到这时才真正求值。可以把表达式理解成一棵描述计算的树:叶子是列引用和常量,节点是加减乘除、聚合、条件分支等操作。
正因为计算被推迟,优化器才有机会合并整条查询、裁掉不需要的部分:用不到的列不会被读取,能合并的过滤会下推到扫描阶段,重复的子表达式只算一次。Pandas 没有这个概念,写下的操作会立刻执行,也不会跨语句优化。
df = pd.DataFrame(dict(
x=[1, 2, 3],
y=[4, 5, 6]
))
df['dist'] = (df['x']**2 + df['y']**2)**0.5
df
| x | y | dist | |
|---|---|---|---|
| 0 | 1 | 4 | 4.123106 |
| 1 | 2 | 5 | 5.385165 |
| 2 | 3 | 6 | 6.708204 |
Pandas 想把计算合在一起写,也有办法:把表达式写成字符串交给 eval() 即可。但 eval() 支持的语法有限,遇到自定义函数就只能改为逐条执行;它也不会改变“立即求值”这一事实,只是把多条语句合并成一次解析。
Polars 的表达式不是字符串,而是真正的对象,可以包含任意表达式方法,也可以在执行前被优化器分析。
df['dist2'] = df.eval('(x**2 + y**2)**0.5')
df
| x | y | dist | dist2 | |
|---|---|---|---|---|
| 0 | 1 | 4 | 4.123106 | 4.123106 |
| 1 | 2 | 5 | 5.385165 | 5.385165 |
| 2 | 3 | 6 | 6.708204 | 6.708204 |
Polars 的写法是把表达式传给 select() 或 with_columns(),调用时才开始求值。
df = pl.DataFrame(dict(
x=[1, 2, 3],
y=[4, 5, 6]
))
df.with_columns(
dist = (pl.col('x')**2 + pl.col('y')**2).sqrt()
)
| x | y | dist |
|---|---|---|
| i64 | i64 | f64 |
| 1 | 4 | 4.123106 |
| 2 | 5 | 5.385165 |
| 3 | 6 | 6.708204 |
LazyFrame 把求值再推迟一步:调用 select()、with_columns() 时只是向计划中添加节点,直到 collect() 才执行。
Polars 2.0 的 collect() 默认走流式引擎,所以内存装不下的数据也能分块处理。
(df
.lazy()
.select(
x2 = pl.col('x')**2,
y2 = pl.col('y')**2
)
.select(
xy2 = pl.col('x2') + pl.col('y2')
)
.select(
dist = pl.col('xy2').sqrt()
)
.collect()
)
| dist |
|---|
| f64 |
| 4.123106 |
| 5.385165 |
| 6.708204 |
使用表达式有三个好处。
延迟求值:表达式定义时不执行,等到需要结果时才算,因此用不到的列不会做无用计算。
链式:多个表达式可以串联起来,复杂的处理也能从上往下读,不必为每一步定义中间变量。
可优化:Polars 先查看整条查询再决定执行计划,表达式分开写也能在一次遍历中完成。
Pandas 里常用 inplace=True 或按标签赋值来就地更新:下面的例子直接把符合条件的那几行的工资修改掉,df 本身也随之改变。这种写法节省内存,但副作用隐藏在调用里,读代码时不容易看出哪一步改了原表。
Polars 没有 inplace。任何“修改”都返回一个新表,原表保持不变;要把结果留下来,必须显式赋值给变量。
data = {"name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35], "salary": [50000, 60000, 70000]}
df = pd.DataFrame(data)
# 选出年龄 30 以上的人,工资加 5000
df.loc[df["age"] >= 30, "salary"] += 5000
df
| name | age | salary | |
|---|---|---|---|
| 0 | Alice | 25 | 50000 |
| 1 | Bob | 30 | 65000 |
| 2 | Charlie | 35 | 75000 |
Polars 的 DataFrame 是不可变的:任何“修改”都返回一个新表,原表保持不变。
df = pl.DataFrame(data)
# 定义表达式:年龄 30 以上时工资加 5000
expr = (
pl.when(pl.col("age") >= 30)
.then(pl.col("salary") + 5000)
.otherwise(pl.col("salary"))
)
# 套用表达式,得到新表
df2 = df.with_columns(expr.alias("updated_salary"))
row(df, df2)
shape: (3, 3)
|
shape: (3, 4)
|
这不只是风格问题,它还决定了代码如何调试、如何复用。
安全:原始数据不变,函数调用不会意外修改调用方的表。
易于调试:每一步的表都会保留,可以追踪值在哪一步发生变化。
与链式调用天然契合:每个操作都返回新表,所以可以直接连成一条链,不必先复制再修改。
嵌套类型#
Polars 原生支持列表(List)和结构体(Struct),而且可以任意嵌套,所以“一行里带一个列表”的数据不必拆成多张表就能保存。List 的每个元素类型相同,Struct 的每个字段可以有不同的类型,两者组合起来可以表达 JSON 那样的层次结构。
下面的代码中 attrs 是结构体列,其中的 score 字段又是列表,❶ 处的表达式直接对这个列表求平均。
df = pl.DataFrame({
"name": ['A', 'B', 'C'],
"attrs": [
{"age": 30, "score":[89, 90, 100]},
{"age": 25, "score":[60]},
{"age": 35, "score":[70, 80]},
]
})
df.select(
"name",
mean_score=pl.col("attrs").struct.field("score").list.mean() #❶
)
| name | mean_score |
|---|---|
| str | f64 |
| "A" | 93.0 |
| "B" | 60.0 |
| "C" | 75.0 |
Pandas 没有对应的类型系统,嵌套数据只能放进 object 列,每个元素都是 Python 对象。object 列在内存里是一组指针,既没有列式存储的连续性,也无法用 NumPy 的向量化指令处理。
计算时需要逐个元素回到 Python 层,行数一多就会变慢。下面这段代码中,attrs 的每个元素都是字典,str['score'] 再对每个字典取值,最后 apply(np.mean) 逐元素求平均。
import numpy as np
df = pd.DataFrame({
"name": ['A', 'B', 'C'],
"attrs": [
{"age": 30, "score":[89, 90, 100]},
{"age": 25, "score":[60]},
{"age": 35, "score":[70, 80]},
]
})
df['mean_score'] = df['attrs'].str['score'].apply(np.mean)
df
| name | attrs | mean_score | |
|---|---|---|---|
| 0 | A | {'age': 30, 'score': [89, 90, 100]} | 93.0 |
| 1 | B | {'age': 25, 'score': [60]} | 60.0 |
| 2 | C | {'age': 35, 'score': [70, 80]} | 75.0 |