跳到内容

从 Pandas 迁移

在此,我们列出了任何有 pandas 经验并希望尝试 Polars 的人应该了解的关键点。我们涵盖了库所基于的概念差异以及与 pandas 代码相比,您应该如何编写 Polars 代码的差异。

Polars 与 pandas 概念上的区别

Polars 没有多级索引/索引

pandas 使用索引为每一行提供标签。Polars 不使用索引,每一行都通过其在表中的整数位置进行索引。

Polars 旨在提供可预测的结果和可读的查询,因此我们认为索引无助于我们实现这一目标。我们认为查询的语义不应因索引状态或 reset_index 调用而改变。

在 Polars 中,DataFrame 始终是一个具有异构数据类型的二维表。数据类型可能存在嵌套,但表本身不会。重采样等操作将由专门的函数或方法执行,这些函数或方法像对表进行操作的“动词”一样,明确说明该“动词”操作的列。因此,我们坚信没有索引会使事情更简单、更明确、更易读且不易出错。

请注意,Polars 将把数据库中已知的“索引”数据结构用作优化技术。

Polars 遵循 Apache Arrow 内存格式来表示内存中的数据,而 pandas 使用 NumPy 数组

Polars 根据 Arrow 内存规范在内存中表示数据,而 pandas 默认使用 NumPy 数组在内存中表示数据。Apache Arrow 是一个新兴的内存列式分析标准,可以加快数据加载时间、减少内存使用并加速计算。

Polars 可以使用 to_numpy 方法将数据转换为 NumPy 格式。

Polars 比 pandas 对并行操作有更多支持

Polars 利用 Rust 中对并发的强大支持来并行运行许多操作。虽然 pandas 中的某些操作是多线程的,但该库的核心是单线程的,必须使用 Dask 等额外库来并行化操作。Polars 比所有并行化 pandas 代码的开源解决方案都快。

Polars 支持不同的引擎

Polars 原生支持针对内存处理优化的引擎以及针对大规模数据处理优化的流式引擎。此外,Polars 与支持 CuDF 的引擎原生集成。所有这些引擎都受益于 Polars 的查询优化器,并且 Polars 确保所有这些引擎之间的语义正确性。在 pandas 中,实现可以在 numpy 和 Pyarrow 之间进行调度,但由于 pandas 的宽松严格性保证,这些后端之间的数据类型输出和语义可能存在差异。这可能导致一些不易察觉的错误。

Polars 可以延迟评估查询并应用查询优化

急切评估(Eager evaluation)是指代码在您运行后立即被评估。延迟评估(Lazy evaluation)是指运行一行代码意味着将底层逻辑添加到查询计划中,而不是立即评估。

Polars 支持急切评估和延迟评估,而 pandas 只支持急切评估。延迟评估模式功能强大,因为 Polars 在检查查询计划时会执行自动查询优化,并寻找加速查询或减少内存使用的方法。

Dask 在生成查询计划时也支持延迟评估。

Polars 严格

Polars 对数据类型要求严格。Polars 中的数据类型解析取决于操作图,而 pandas 会宽松地转换类型(例如,新的缺失数据可能导致整数列转换为浮点数)。这种严格性减少了错误并使行为更可预测。

Polars 拥有更通用的 API

Polars 基于表达式构建,几乎所有操作都允许表达式输入。这意味着当您理解表达式的工作原理时,您在 Polars 中的知识可以得到推广。Pandas 没有表达式系统,通常需要 Python lambda 来表达您想要的复杂性。Polars 将需要 Python lambda 视为其 API 表达能力不足,并尽可能尝试为您提供原生支持。

关键语法差异

来自 pandas 的用户通常需要了解一件事...

polars != pandas

如果您的 Polars 代码看起来像是 pandas 代码,它可能可以运行,但很可能比它应有的速度慢。

让我们看一些典型的 pandas 代码,并了解如何在 Polars 中重写它们。

选择数据

由于 Polars 中没有索引,因此 Polars 中没有 .lociloc 方法 - 并且 Polars 中也没有 SettingWithCopyWarning

然而,在 Polars 中选择数据的最佳方式是使用表达式 API。例如,如果您想在 pandas 中选择一列,您可以执行以下操作之一:

df["a"]
df.loc[:,"a"]

但在 Polars 中,您将使用 .select 方法

df.select("a")

如果您想根据值选择行,那么在 Polars 中您可以使用 .filter 方法

df.filter(pl.col("a") < 10)

如下方表达式部分所述,Polars 可以在 .selectfilter 中并行运行操作,并且 Polars 可以对完整的数据选择条件集执行查询优化。

采用延迟评估

在延迟评估模式下工作非常简单,并且应该是您在 Polars 中的默认选择,因为延迟模式允许 Polars 执行查询优化。

我们可以通过使用隐式延迟函数(例如 scan_csv)或显式使用 lazy 方法来在延迟模式下运行。

以下是一个简单的示例,我们从磁盘读取一个 CSV 文件并进行分组(group by)操作。CSV 文件有许多列,但我们只想对其中一个 id 列 (id1) 进行分组,然后按值列 (v1) 求和。在 pandas 中,这会是这样:

df = pd.read_csv(csv_file, usecols=["id1","v1"])
grouped_df = df.loc[:,["id1","v1"]].groupby("id1").sum("v1")

在 Polars 中,您可以在延迟模式下构建此查询并进行查询优化,然后通过将急切的 pandas 函数 read_csv 替换为隐式延迟的 Polars 函数 scan_csv 来评估它。

df = pl.scan_csv(csv_file)
grouped_df = df.group_by("id1").agg(pl.col("v1").sum()).collect()

Polars 通过识别只有 id1v1 列是相关的来优化此查询,因此只会从 CSV 中读取这些列。通过在第二行末尾调用 .collect 方法,我们指示 Polars 急切地评估查询。

如果您确实想在急切模式下运行此查询,只需在 Polars 代码中将 scan_csv 替换为 read_csv

有关延迟评估的更多信息,请参阅lazy API部分。

表达式应用

典型的 pandas 脚本由按顺序执行的多个数据转换组成。然而,在 Polars 中,这些转换可以使用表达式并行执行。

列赋值

我们有一个名为 df 的 DataFrame,其中包含一个名为 value 的列。我们想添加两个新列,一个名为 tenXValue 的列,其中 value 列乘以 10;以及一个名为 hundredXValue 的列,其中 value 列乘以 100。

在 pandas 中,这会是这样:

df.assign(
    tenXValue=lambda df_: df_.value * 10,
    hundredXValue=lambda df_: df_.value * 100
)

这些列赋值是按顺序执行的。

在 Polars 中,我们使用 .with_columns 方法向 df 添加列

df.with_columns(
    tenXValue=pl.col("value") * 10,
    hundredXValue=pl.col("value") * 100,
)

这些列赋值是并行执行的。

基于谓词的列赋值

在这种情况下,我们有一个包含 abc 列的 DataFrame df。我们希望根据条件重新分配列 a 中的值。当列 c 中的值等于 2 时,我们将 a 中的值替换为 b 中的值。

在 pandas 中,这会是这样:

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 的每个分支。当分支的计算成本变得更高时,这会很有价值。

筛选

我们希望根据某些条件过滤包含住房数据的 DataFrame df

在 pandas 中,您可以通过将布尔表达式传递给 query 方法来过滤 DataFrame

df.query("m2_living > 2500 and price < 300000")

或者直接评估一个掩码

df[(df["m2_living"] > 2500) & (df["price"] < 300000)]

而在 Polars 中,您调用 filter 方法

df.filter(
    (pl.col("m2_living") > 2500) & (pl.col("price") < 300000)
)

Polars 中的查询优化器还可以检测您是否单独编写了多个过滤器,并在优化计划中将它们合并为一个过滤器。

pandas 的 transform

pandas 文档演示了一个名为 transform 的分组(group by)操作。在这种情况下,我们有一个 DataFrame df,我们想要一个显示每个组中行数的新列。

在 pandas 中,我们有:

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",计算组长度,然后将结果连接回原始 DataFrame,生成

   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 中,可以使用 window 函数实现相同的功能

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    │
└─────┴──────┴──────┘

因为我们可以将整个操作存储在单个表达式中,所以我们可以组合多个 window 函数,甚至组合不同的组!

Polars 将缓存应用于同一组的窗口表达式,因此将它们存储在单个 with_columns 中既方便又最佳。在下面的示例中,我们考察了一个对 "c" 进行两次分组统计计算的情况

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            │
└─────┴──────┴──────┴─────┴──────────────┘

缺失数据

pandas 使用 NaN 和/或 None 值来表示缺失值,具体取决于列的数据类型(dtype)。此外,pandas 中的行为也因是使用默认数据类型还是可选可空数组而异。在 Polars 中,所有数据类型的缺失数据都对应于 null 值。

对于浮点列,Polars 允许使用 NaN 值。这些 NaN 值不被视为缺失数据,而是一个特殊的浮点值。

在 pandas 中,带有缺失值的整数列会被转换为带有 NaN 值的浮点列(除非使用可选的可空整数数据类型)。在 Polars 中,整数列中的任何缺失值都只是 null 值,并且该列仍保持为整数列。

有关更多详细信息,请参阅缺失数据部分。

管道堆积

pandas 中常见的用法是利用 pipe 将某个函数应用于 DataFrame。将这种编码风格复制到 Polars 是不符合惯用法的,并且会导致次优的查询计划。

以下代码片段展示了 pandas 中常见的模式。

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 个管道,从而无法利用并行性。

在 Polars 中获得类似抽象的方法是创建生成表达式的函数。以下代码片段创建了 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")

# This single context will run all 3 expressions in parallel
df.with_columns(
    get_ham("col_a"),
    get_bar("col_b"),
    get_foo("col_c"),
)

如果您在生成表达式的函数中需要 schema,可以使用单个 pipe

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")

# Use pipe (just once) to get hold of the schema of the LazyFrame.
lf.pipe(lambda lf: lf.with_columns(
    get_ham("col_a"),
    get_bar("col_b", lf.schema),
    get_foo("col_c", lf.schema),
)

编写返回表达式的函数的另一个好处是这些函数具有可组合性,因为表达式可以被链式调用和部分应用,从而在设计中带来更大的灵活性。