与 Pandas 的比较

与 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)
shape: (1, 3)
nameageindex
stri64str
"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))
X Y Z
a 8 1 9
b 1 0 0
c 6 9 7
d 4 2 3
e 7 4 9
X Z
a 13 18
c 14 11
e 14 12
X Y Z
a -5.0 1.0 -9.0
b 1.0 0.0 0.0
c -8.0 9.0 -4.0
d 4.0 2.0 3.0
e -7.0 4.0 -3.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)
indexXYZ
stri32i32i32
"a"819
"b"100
"c"697
"d"423
"e"749
shape: (3, 3)
indexXZ
stri32i32
"a"1318
"c"1411
"e"1412
shape: (5, 4)
indexXYZ
stri32i32i32
"a"-51-9
"b"100
"c"-89-4
"d"423
"e"-74-3

列名的差别#

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']])
name age age
index
a Alice 25 25
b Bob 30 30
c Charlie 35 35
age age
index
a 25 25
b 30 30
c 35 35

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)
nameageage_2
stri64i64
"Alice"2525
"Bob"3030
"Charlie"3535
shape: (3, 2)
ageage_2
i64i64
2525
3030
3535

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'))
shape: (3, 1)
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()
)
shape: (3, 3)
xydist
i64i64f64
144.123106
255.385165
366.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()
)
shape: (3, 1)
dist
f64
4.123106
5.385165
6.708204

使用表达式有三个好处。

  1. 延迟求值:表达式定义时不执行,等到需要结果时才算,因此用不到的列不会做无用计算。

  2. 链式:多个表达式可以串联起来,复杂的处理也能从上往下读,不必为每一步定义中间变量。

  3. 可优化: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)
nameagesalary
stri64i64
"Alice"2550000
"Bob"3060000
"Charlie"3570000
shape: (3, 4)
nameagesalaryupdated_salary
stri64i64i64
"Alice"255000050000
"Bob"306000065000
"Charlie"357000075000

这不只是风格问题,它还决定了代码如何调试、如何复用。

  1. 安全:原始数据不变,函数调用不会意外修改调用方的表。

  2. 易于调试:每一步的表都会保留,可以追踪值在哪一步发生变化。

  3. 与链式调用天然契合:每个操作都返回新表,所以可以直接连成一条链,不必先复制再修改。

嵌套类型#

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() #❶
)
shape: (3, 2)
namemean_score
strf64
"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