[python] Polars数据处理指北

Polars是一款高性能的DataFrame库,在许多场景下,其运行速度和内存利用效率均优于Pandas,尤其适合中大型数据集处理。得益于以Rust语言构建的高性能底层实现,以及对多核并行计算的原生支持,Polars能够充分发挥现代硬件性能,因此在数据处理任务中通常比Pandas表现更优。本文将演示Polars的基本数据处理流程,介绍其主要功能,涵盖数据读取、清洗、筛选、排序、分组、多表连接等常见操作。Polars的GitHub仓库见:Polars GitHub,官方文档见:Polars user-guide。

Polars安装代码如下:

pip install polars

查看安装版本:

import polars as pl
print(pl.__version__)   # 1.44.2

1 概览

真实项目的第一步,往往都是先把数据读进来,再确认它的结构。这一章先建立一张贯穿后面几章的示例表,随后查看其大小和类型,接着介绍CSV、Excel的读写方式,最后说明多批数据的拼接方法。

https://towardsdatascience.com/should-ai-developers-make-the-switch-from-polars-to-pandas/

1.1 示例数据

后面几章的例子大都基于这一张虚构的员工表,共六名员工、七列:

# 例子改用pandas,只需import pandas as pd,把pl.DataFrame改成pd.DataFrame
from datetime import date
import polars as pl

employees = pl.DataFrame({
    "emp_id": [1, 2, 3, 4, 5, 6],  # 员工编号
    "name": ["Alice", "Bob", "Charlie", "Diana", "Evan", "Fiona"],
    "dept": ["IT", "IT", "IT", "HR", "HR", "Finance"],  # 部门:有重复取值,后面讲分组和窗口要用
    "city": ["New York", "Los Angeles", "Chicago", "Houston", "Chicago", "Boston"],
    "age": [25, 30, 35, 40, 45, None],  # 最后一格是空值,用来讲判空和空值排序
    "salary": [8200, 9500, 12000, 7400, 8100, 15000],
    "hired_on": [date(2021, 3, 1), date(2022, 7, 15), date(2019, 1, 20),
                 date(2023, 5, 4), date(2020, 11, 30), date(2018, 9, 12)],
})
print(type(employees))

这份表看着小,但故意埋了几个后面要用的点:

  • dept有重复取值,后面讲分组和窗口计算时用得上。
  • age有一格空值,用来演示判空和空值排序。
  • hired_on是真正的Date类型,用来演示日期操作。

1.2 表结构查看

表建好之后,Polars提供了一些常用的查看与检查方法:

# pandas也有的
print(employees.columns)      # 列名
print(employees.dtypes)       # 各列数据类型
print(employees.shape)        # 行数、列数
print(employees.head())       # 前5行
print(employees.tail())       # 后5行
print(employees.describe())   # 各列统计摘要
# polars特有的
print(employees.to_pandas())       # polars转pandas
print(employees.schema)            # 列名→数据类型(pandas用:employees.dtypes)
print(employees.estimated_size())  # 估算内存占用(pandas用:employees.memory_usage(deep=True).sum())
employees.glimpse()                # 类似info()(pandas用:employees.info())

真实项目里拿到一张表,先看schema和glimpse(),比等后面报类型错误再回头查要省事得多。若需更详细的Polars教程,可参考PolarsBook中文版和Polars实战CookBook。

1.3 数据读写

1.3.1 CSV与Excel

真实项目中的数据大多来自CSV或Excel文件。以下代码展示了如何读写CSV:

# 写CSV
# 当前polars版本无法设置编码输出,输出固定为UTF-8
employees.write_csv(
    "employees.csv",
    include_header=True,          # 是否写表头
    null_value="",                # 空值在文件中的表示
)
# 写成GBK或其他编码的CSV,最好用pandas中转
employees.to_pandas().to_csv("employees_gbk.csv", index=False, encoding="gbk")

# 读CSV
employees = pl.read_csv(
    "employees.csv",
    has_header=True,              # 首行是否为表头
    skip_rows=0,                  # 表头之前跳过几行
    skip_rows_after_header=0,     # 表头之后跳过几行
    encoding="utf-8",             # 编码,默认utf8
    columns=None,                 # 只读部分列,接受列名或列索引
    n_rows=None,                  # 只读前N行,常用于预览大文件
    null_values=None,             # 视为空值的字符串
    try_parse_dates=True,         # 看到像日期的内容,自动转成日期类型
    infer_schema_length=100,      # 类型自动推断时扫描的行数
    # schema_overrides={"emp_id": pl.String}  # 指定单列类型,如员工编号当字符串
)

读写Excel类似:

# 写Excel
# 需要先装xlsxwriter:pip install xlsxwriter
employees.write_excel(
    "employees.xlsx",
    worksheet="员工信息",          # 工作表名称(默认Sheet1)
    include_header=True,          # 是否写表头
    autofit=True,                 # 自动调整列宽
)

# 读Excel
# 需要先装fastexcel:pip install fastexcel
employees = pl.read_excel(
    "employees.xlsx",
    # sheet_name="员工信息",       # 读哪个工作表(不写就默认第一个)
    sheet_id=1,                  # 从1开始:1=第一个表,2=第二个表…;0=读全部(返回字典)
    engine="calamine",           # 解析引擎,默认就用这个
)

1.3.2 read_csv与scan_csv

读CSV可以用read_csv和scan_csv,区别在于立刻执行还是先规划再执行:

  • read_csv:立刻读取并返回DataFrame,属于急切执行(eager)。先读进来,再处理。
  • scan_csv:不立刻读取,先返回LazyFrame。这相当于先生成一份执行计划,需要结果时才按计划执行。文件大、后续还要做筛选、选列等操作时更推荐这种方式。
import polars as pl
# read_csv:立即读取整个文件到内存,返回DataFrame
df = pl.read_csv("employees.csv")
# scan_csv:先返回LazyFrame,构建执行计划
result = (
    pl.scan_csv("employees.csv")
    .filter(pl.col("salary") > 9000)
    .select("name", "salary")
    .collect()  # 到这里才真正开始读取和计算
)

这里,read_csv会一次性读取全部数据,而scan_csv并不立即读取,只是先记录待读取的文件信息。read_csv在读取时无法预知后续操作,因此只能读取所有数据。scan_csv会在collect()时执行整个查询计划,并根据后续的select、filter等操作进行优化,从而尽可能减少不必要的数据处理。

1.4 数据拼接

数据通常按月份或来源分成多批,处理前往往需要合并为一张表。pl.concat正是用于此类拼接的函数,支持多种拼接方式,默认采用how="vertical":

df1 = pl.DataFrame({"name": ["Alice", "Bob"], "dept": ["IT", "HR"]})
df2 = pl.DataFrame({"name": ["Grace"], "dept": ["IT"]})
pl.concat(
    [df1, df2],
    how="vertical",  # 上下堆叠,要求列名一致
)
pl.concat(
    [df1, pl.DataFrame({"salary": [10000, 12000]})],
    how="horizontal_extend"
)
pl.concat(
    [df1, pl.DataFrame({"name": ["Grace"], "salary": [13000]})],
    how="diagonal",  # 取列名并集,缺失处填null
)

2 缺失值处理与列操作

读入数据后,通常需要检查缺失情况并选定目标列。本章将简要介绍缺失值处理与列操作,这些操作只返回新结果,不修改原始数据。

2.1 缺失值填充与丢弃

在Polars中,处理缺失值可以通过填充数值或丢弃缺失行。填充用fill_null函数,它将空值替换为指定值,该值可以是常量,也可以是计算结果。丢弃用drop_nulls函数,它删除含空值的行:

# 用age列自己的均值填补空值
# alias给结果列起名age_filled
# select挑出最终要显示的列
employees.with_columns(
    pl.col("age").fill_null(pl.col("age").mean()).alias("age_filled")
).select("name", "age", "age_filled")

# subset指定只检查age列
employees.drop_nulls(subset=["age"]).select("name", "age")

2.2 列的增删改

数据清洗干净后,常见工作是挑出要用的列,并在此基础上加出新列。前面内容提到,select()用于选择要输出的列。此外还有更多列操作函数:

  • drop():从表中删除列。
  • rename():批量修改列名。
  • cast():修改列类型。
  • with_row_index():显式添加一列行号。Polars没有Pandas那种隐含的行索引,需要行号时得自己加一列。
  • unique():去重,maintain_order=True 保留首次出现的顺序。

下面代码演示这些操作:

print(employees.drop("city").columns)  # 删掉一列
print(employees.rename({"dept": "department"}).columns)  # 批量改名:旧名到新名的映射
print(employees.cast({"salary": pl.Float64}).with_row_index("row_no")  # 改类型,同时加一列行号
      .select("row_no", "name", "salary"))
print(employees.select("dept").unique(maintain_order=True))  # 去重,并保留首次出现的顺序

2.3 select与with_columns

列结构整理好以后,就要频繁选列和造列。这时最常用的是select和with_columns,这里更详细地介绍这两个函数。select只保留点名的列,其余全部丢掉。with_columns保留原列,把新列加在后面,名字一样则替换原列。下面把同一个表达式分别交给它们,观察列集合的变化:

# select:只留下点名的列,原列全部丢弃
print("select ->", employees.select(pl.col("salary") / 1000))
# with_columns:原列保留,新列追加在后面(这里给新列起了名,避免与salary冲突)
print("with_columns ->", employees.with_columns(
    (pl.col("salary") / 1000).alias("salary_k")))

从结果可以看到,对于with_columns(),新增一列时,如果不手动指定名字,列名会直接拿算式里最左边那个列名来用。这样很容易和已有的列重名,一旦重名,原来的列就会被顶掉。所以最好用alias()给新列取个名字。

如果列多,逐个写alias太啰嗦。可以用pl.col()一次点名多列,再用name命名空间统一加前后缀:

employees.select(
    pl.col("salary", "age").name.suffix("_原值"),  # suffix给每列名后加_原值,如salary→salary_原值
    pl.col("salary").name.prefix("月_"),  # prefix给每列名前加月_,如salary→月_salary
).head(2)

2.4 批量选列与条件列

列一多,逐个点名就不现实。除了name命名空间,还可以用polars.selectors按类型选列,用pl.all().exclude按名字排除列:

# 更多选列方式
import polars.selectors as cs
employees.select(cs.numeric())  # 按类型挑:只要数值列
employees.select(cs.exclude(cs.numeric()))  # 反过来只要非数值列,Date类型不算数值
employees.select(pl.col("name"), cs.numeric()).head(3)  # 点名的列和按类型取的列可以混着写
employees.select(pl.all().exclude("name", "salary"))  # 按名字排除:排除name和salary列

另一种常见的造列需求是根据条件取不同的值,用when、then、otherwise。写法是满足条件就取then的值,否则取otherwise的值。它不是对单个Python值执行if,而是构造一个针对整列数据的条件表达式。then和otherwise里要放表达式,所以像high这样的固定值不能直接写,要用pl.lit包起来变成表达式:

employees.with_columns(
    pl.when(pl.col("salary") >= 10000)  # 判断salary列是否>=10000
    .then(pl.lit("high"))               # 满足条件,填high
    .otherwise(pl.lit("normal"))        # 不满足条件,填normal
    .alias("pay_band")                  # 新列叫pay_band
).select("name", "salary", "pay_band")

2.5 逐元素变换

如果不涉及行与行的关系,只是对当前行的值做数学变换,就用逐元素运算。sqrt是开方,abs是绝对值,floor向下取整,ceil向上取整,clip限制范围,直接接在列后:

employees.select(
    pl.col("salary").sqrt().alias("开方"),
    pl.col("salary").cast(pl.Float64).round(1).alias("保留一位")
)

2.6 取值与去重

除了整列计算,日常还经常需要从列里取某个值。n_unique()返回不同值的个数,unique()返回去重后的值。first()和last()取当前顺序下的第一条和最后一条,先排序就能取到最小和最大的记录:

employees.select(
    pl.col("dept").n_unique().alias("部门数"),
    pl.col("dept").unique().alias("部门去重"),
)
employees.sort("salary").select(
    pl.col("name").first().alias("最低薪者"),
    pl.col("name").last().alias("最高薪者"),
)

如果只要一列或一个数,用get_column()取一列,item()取单个值。取最大的几行用top_k():

employees.get_column("salary").to_list()
employees.select(pl.col("salary").max()).item()
employees.top_k(2, by="salary")

2.7 嵌套数据

一个单元格存多个值时,这列的类型是list,也就是列表。普通列操作不能直接用在列表列上,需要用list命名空间处理:

nested = pl.DataFrame({
    "name": ["A", "B"],
    "tags": [["x", "y"], ["z"]],
})
nested.select(
    pl.col("tags").list.first().alias("首个"),  # 取每个列表的第一个元素
    pl.col("tags").list.len().alias("个数"),   # 统计每个列表有几个元素
)
# 把tags列表里的每个元素拆成独立一行
nested.explode("tags", empty_as_null=False)

3 筛选、排序与分组聚合

前两章处理的是数据里有什么。这一章转向留下哪些行、按什么顺序、怎么按组汇总。

3.1 行筛选

行筛选基于filter函数,可以用位运算符把多个条件组合起来判断,必须写&、|、~,不能写and/or/not:

employees.filter(pl.col("salary") > 9000)  # 单条件
employees.filter((pl.col("dept") == "IT") & (pl.col("salary") > 9000))  # 组合条件
employees.filter((pl.col("city") == "Chicago") & ~(pl.col("dept") == "HR"))  # 取反条件

在Polars里,任何跟null的比较结果都是null,不是False,所以要判断空值必须显式写出来:

employees.filter(pl.col("age").is_not_null())   # 只留下age不为空的行
employees.filter(pl.col("age").is_null())       # 只留下age为空的行

想筛选出包含某段文字的行,用str.contains。它默认会把要查找的内容当成正则表达式。如果只是找普通文字,要写literal=True:

employees.filter(pl.col("city").str.contains("New", literal=True))  # 城市名里包含New就留下

判断某个值属不属于一个集合,用is_in最合适:

employees.filter(pl.col("city").is_in(["Chicago", "Boston"]))

要是某段逻辑没法用表达式表达,比如要调外部API,或者要做复杂的标量计算,可以用map_elements兜底:

employees.with_columns(
    pl.col("age").map_elements(
        lambda a: "unknown" if a is None else ("junior" if a < 35 else "senior"),
        return_dtype=pl.String,
    ).alias("band"),
).select("name", "age", "band")

map_elements是最后手段,不是常用手段。它绕过了Polars的原生表达式执行,逐元素调用Python函数,通常会明显变慢。

3.2 排序

filter决定保留哪些行,sort决定这些行按什么顺序出现。Polars排序时,空值默认排在最前面,升序和降序都一样,这与Pandas的默认行为正好相反。Polars中降序要使用descending,没有reverse参数:

employees.select("name", "age").sort("age")  # 升序,空值排在最前
employees.select("name", "age").sort("age", descending=True)  # 降序,空值还是在最前
employees.select("name", "age").sort("age", nulls_last=True)  # 用nulls_last把空值挪到最后
employees.select("dept", "salary").sort("dept", "salary", descending=[False, True])  # 多列排序部门升序,部门内薪资降序
employees.select("name", "salary", "age").sort(pl.col("salary") / pl.col("age"))  # 排序键可以是算出来的表达式,不一定是现成的列

3.3 分组聚合

如果希望不是对整张表计算,而是分别计算每个组的结果,就需要先用group_by()分组,再用agg()做聚合。例如:

employees.group_by("dept").agg(                   # dept分组,并对每组做聚合统计
    pl.len().alias("headcount"),                    # 组内行数 -> 人数
    pl.col("salary").sum().alias("total"),          # 薪资求和
    pl.col("salary").mean().round(1).alias("avg"),  # 薪资均值并保留1位小数
    ).sort("dept")

分组键不一定只有一个。传入多个列时,Polars会按照这些列的组合进行分组:

employees.group_by("dept", "city").agg(
    pl.len().alias("n")
    ).sort("dept", "city") # 先按dept排序,dept相同的按city排序

分组之后,有时还需要根据聚合结果对分组进行筛选。例如,只保留平均薪资超过9000的部门,这时就可以用having()。having()和filter()都是筛选,区别在于filter()筛的是行,having()筛的是组。正因为having()筛的是组,所以它后面仍然要通过agg()才能得到分组结果:

employees.group_by("dept").having(pl.col("salary").mean() > 9000).agg(
        pl.col("salary").mean().alias("avg")
    ).sort("avg", descending=True)

3.4 窗口计算

分组的思路是把多行压成一行,但有时需求不是保留哪些组,而是保留这些组里的哪些原始行。这时就要用到窗口计算。over()的作用是分组计算后不压缩数据,而是把组级结果附加到每一行上,让原始行全部保留。例如想保留平均薪资超过9000的部门的全部员工记录,可以先用over()算出每个部门的平均薪资,再把这个组级结果贴回该部门的每一行,最后用filter()筛选:

employees.filter(pl.col("salary").mean().over("dept") > 9000)

这样得到的仍然是原始员工记录,而不是每个部门一行的汇总结果。

另一种常见的逐行需求是拿当前行和上一行做比较。比如按薪资排序后,想看每个人比前一个人多拿多少。shift()就是把一列往下挪几行,挪一行就能看到上一行的值。

employees.sort("salary").select(
    "name",
    "salary",
    pl.col("salary").shift(1).alias("前一行"),  # 往下挪一行,拿到上一个人的薪资
    (pl.col("salary") - pl.col("salary").shift(1)).alias("差额"),  # 当前减上一行
)

第一行上面没有数据,所以shift(1)返回null,差额也是null。另外要注意,shift是按当前行顺序挪的。如果不先sort,所谓的上一行是谁就没有意义。

默认情况下shift是整张表一起偏移。如果想让每一组各算各的,就在shift后面加over,over括号里写按哪一列分组。比如over("dept")表示按dept分组,dept相同的行分到一组,每组单独偏移。

# 不加over:整列一起偏移
pl.col("salary").shift(1)
# 加over:按dept分组,每组单独偏移
pl.col("salary").shift(1).over("dept")

还有一种介于两者之间的情况,计算时需要往前看几行,这就是滚动窗口,更多内容可参考Polars Windowing Functions。这类操作常以rolling_mean为例,rolling_mean(3)表示当前行和前面两行一起算平均,前面不够三行时结果为null:

employees.select(
    pl.col("salary").rolling_mean(3).alias("近三行均值"),
)

4 多表连接与形状重塑

前面都是在单表里操作。这一章转向两类常见的问题:两张表怎么按某个键连接,以及表本身的形状怎么改。前者用join,后者用pivot和unpivot。

4.1 表连接

先准备两张小表。一张是部门信息,一张是员工评价:

# 部门信息表
departments = pl.DataFrame({
    "dept": ["IT", "HR", "Finance", "Legal"],
    "floor": [3, 2, 5, 4],
    "head": ["Grace", "Henry", "Ivy", "Jack"],
})
# 员工评价表
reviews = pl.DataFrame({
    "emp_id": [1, 2, 4],
    "score": ["A", "B", "A"],
})

使用join时,最重要的是先想清楚两个问题:

  1. 用哪一列进行匹配?
  2. 匹配不上的数据要不要保留?

想清楚这两个问题后,再选择对应的参数:

  • on:指定用哪一列进行匹配,也就是上面第一个问题的答案。
  • how:指定连接方式,决定匹配不上的数据要不要保留,也就是上面第二个问题的答案。

how最常见的四种取值如下:

  • inner(内连接):两张表能对上的数据才留下。比如员工表和部门表,只有员工能找到对应部门的,才留下来。
  • left(左连接):左表的数据全留下,右表只留下能对上的。比如左表是员工表,那所有员工都留下。右表是部门表,员工能找到部门就填上部门信息,找不到就让部门信息空着(补null)。
  • right(右连接):右表的数据全留下,左表只留下能对上的。比如右表是部门表,那所有部门都留下。左表是员工表,部门有员工就填上员工信息,没有员工就让员工信息空着(补null)。
  • full(全连接):两张表的数据全留下。不管是员工找不到部门,还是部门找不到员工,都留下,缺的那一边补null。

例如,把员工信息和部门信息连接起来:

# 内连接:只有能找到对应部门的员工才会留下
employees.select("emp_id", "name", "dept").join(
    departments,
    on="dept",
    how="inner",
)

# 左连接:所有员工都留下,没有评价的员工score为null
employees.select("emp_id", "name").join(
    reviews,
    on="emp_id",
    how="left",
)

# 全连接:两边都不丢
employees.select("emp_id", "name", "dept").join(
    departments,
    on="dept",
    how="full",
    coalesce=True,
)

实际工作中,左连接往往最常用,因为它把一张表作为主表放在左边,再把其他表的信息补进来。这样代码读起来也比较直观。

有时候我们并不是想把两张表真正拼起来,只是想检查左表中的记录在右表有没有对应项。这时可以用semi和anti:

departments.join(employees, on="dept", how="semi")  # 找得到员工的部门
departments.join(employees, on="dept", how="anti")  # 找不到员工的部门

它们都只返回左表的列。semi返回右表里能找到对应记录的左表行,anti返回右表里找不到对应记录的左表行。semi和anti看的是左表,把两张表交换位置,结果的含义也会跟着改变。

4.2 连接的特殊情况

理解了join以后,还需要处理实际工作中经常遇到的几个情况。如果两张表的连接列名字不同,就不能直接写on,而要分别指定左右两边的列:

employees.join(
    reviews.rename({"emp_id": "employee_id"}),
    left_on="emp_id",
    right_on="employee_id",
    how="inner",
)

如果需要同时满足多个条件才能匹配,就把多个列放进on:

site_plan = pl.DataFrame({
    "dept": ["IT", "IT", "HR", "HR", "Finance"],
    "city": ["New York", "Chicago", "Houston", "Chicago", "Boston"],
})
result = employees.select("name", "dept", "city").join(
    site_plan,
    on=["dept", "city"],
    how="left",
)

这里dept和city两个条件都必须相等,使用复合键连接时,最常见的错误是只看到其中一个字段匹配,就误以为整条记录已经匹配成功。两张表连接时,除了用来连接的列,其他同名的列,Polars会自动给右表的列名加_right:

dup = employees.select("dept", "city").join(
    employees.select("dept", "city"),
    on="dept",
    how="inner",
)
print(dup.columns)
# ['dept', 'city', 'city_right']

还有一种特殊情况是笛卡尔积。如果不需要任何匹配条件,而是希望两边的每一行都和另一边的每一行组合,可以使用cross:

pl.DataFrame({"size": ["S", "M"]}).join(
    pl.DataFrame({"color": ["Red", "Blue"]}),
    how="cross",
)

两种尺寸乘上两种颜色会得到4行。它最大的风险也很明显,行数会变成两张表行数的乘积,大表上误用很容易造成数据爆炸。

4.3 时间近似连接

处理时间序列时,我们经常遇到两个表的时间戳没有完全对上,却想找离当前时间最近且已经发生的那条记录。普通join要求连接键完全相等,做不到这件事。可以使用join_asof找时间上最近且符合方向要求的记录。例如交易记录发生在每天零点,报价按日更新,那么对于1月2日零点的交易,我们希望找到1月1日的报价。这时使用join_asof():

quotes = pl.DataFrame({
    "hired_on": [date(2020, 1, 1), date(2021, 6, 1), date(2022, 1, 1),
                 date(2023, 1, 1), date(2024, 1, 1)],
    "dept": ["IT", "IT", "HR", "HR", "Finance"],
    "price": [9.0, 10.5, 12.0, 13.0, 15.5],
})
# join_asof要求on列升序,先把两边排好
employees = employees.sort("hired_on")
quotes = quotes.sort("hired_on")

# backward:找不晚于当前时间的最近一条
employees.join_asof(quotes, on="hired_on", strategy="backward")
# forward:找不早于当前时间的最近一条
employees.join_asof(quotes, on="hired_on", strategy="forward")
# nearest:找绝对距离最近的一条
employees.join_asof(quotes, on="hired_on", strategy="nearest")
# by=分组键,每个dept组内找不晚于当前时间的最近一条
employees.join_asof(quotes, on="hired_on", by="dept", strategy="backward", check_sortedness=False)
# tolerance=最大允许时间差,超出范围不匹配,结果为null
employees.join_asof(quotes, on="hired_on", tolerance="1d")

用join_asof有个前提容易被忽略:两张表都必须先按on指定的时间列排好序,否则结果会报错。实践中一般先各自sort一次,再连接。

4.4 长宽表转换

拼接解决的是多张表怎么接起来,但有时表本身的组织方式也需要调整。例如原始数据通常更适合保存成长表,一行表示一条观测,维度以列的形式存放、取值落在行里,方便存储和过滤。展示或汇总时,又可能希望把某个维度展开成多列,得到宽表,交叉对比一目了然。

长表转宽表使用pivot,on指定哪一列的取值变成列名,index指定哪些列保持为行,values指定往格子里填什么:

long = pl.DataFrame({
    "city": ["New York", "New York", "Chicago", "Chicago"],
    "quarter": ["q1", "q2", "q1", "q2"],
    "amount": [10, 30, 20, 40],
})
wide = long.pivot(
    on="quarter",  # 把quarter的取值展开成新列
    index="city",  # city继续作为行标识
    values="amount",  # 单元格填入amount
)

反过来,如果又想把q1、q2这样的多列收回来,恢复成长表,就使用unpivot,它可以理解为pivot的逆操作:

wide.unpivot(
    on=["q1", "q2"],  # 把这两列收回到一列
    index="city",  # city继续保留为行标识
    variable_name="quarter",  # 原列名放入quarter
    value_name="amount",  # 原列值放入amount
)

5 日期时间与字符串

前面几章主要处理数值、布尔和常规列,而日期时间与字符串虽然也沿用表达式的写法,却各自有一套专用规则。日期时间涉及类型、区间、差值、偏移、时区和时间窗口,字符串则主要涉及大小写、切片、替换、拆分和正则。在Polars中,这两类操作分别通过.dt和.str命名空间完成。理解了这两个命名空间的分工,再记住几个容易混淆的默认值,日常处理就会比较顺手。

5.1 日期与时间

日期时间的处理首先要从类型开始。只有列本身是日期或时间类型,后面的.dt操作才能正常使用。日期是独立的Date类型,如果原始数据中的日期还是字符串,需要先解析成日期类型,再进行日期运算。最常见的三类操作就是提取时间分量、按日期筛选和做日期计算:

from datetime import date, datetime
import polars as pl

# 字符串日期解析成Date类型
dates = pl.DataFrame({"d": ["2023-01-01", "2023-06-01"]}).select(
    pl.col("d").str.strptime(pl.Date, "%Y-%m-%d").alias("date")
)

print(dates)

# 提取时间分量,结果都是普通整数列,可以继续分组、筛选和计算
# weekday()取值为1到7,周一为1
employees.select(
    "name",
    pl.col("hired_on").dt.year().alias("year"),
    pl.col("hired_on").dt.month().alias("month"),
    pl.col("hired_on").dt.day().alias("day"),
    pl.col("hired_on").dt.weekday().alias("weekday"),
).head(4)
# 按日期筛选
employees.filter(
    pl.col("hired_on") >= date(2020, 1, 1)
).select("name", "hired_on")
# is_between()默认两端都包含
employees.filter(
    pl.col("hired_on").is_between(
        date(2019, 1, 1),
        date(2021, 12, 31),
        closed="both",
    )
).select("name", "hired_on")

# 日期可以直接做加减,两个时间相减得到的是Duration而不是普通数字
employees.select(
    "name",
    "hired_on",
    (date(2024, 1, 1) - pl.col("hired_on")).alias("tenure"),
    (date(2024, 1, 1) - pl.col("hired_on"))
        .dt.total_days()
        .alias("days")
).head(4)

如果数据的时间戳并不规则,但希望按固定频率(如周、月、小时)对齐后再统计,可以使用group_by_dynamic()。例如下面把每天的数据按周汇总:

# 创建每天一条的记录,从2023-01-01到2023-01-14
events = pl.DataFrame({
    "ts": pl.datetime_range(
        datetime(2023, 1, 1),
        datetime(2023, 1, 14),
        interval="1d",
        eager=True, # 立即计算并返回Series
    ),
    "amount": list(range(1, 15)),
})

weekly = events.group_by_dynamic(
    "ts",
    every="1w",  # 按周分组,窗口长度为1周
    ).agg(
        pl.col("amount").sum().alias("weekly"),  # 聚合amount求和
        pl.len().alias("n"),                     # 统计每个窗口的记录数
    )

5.2 时区

时区是日期时间处理中最容易混淆的地方之一,尤其在Polars中,replace_time_zone()和convert_time_zone()这两个函数很容易用错。replace_time_zone()只给时间加上时区标签,它不改变本地钟面时间,但改变了对应的UTC时刻。convert_time_zone()则真正做时区换算,改变的是本地钟面时间。用一个例子就能看出区别:

  • replace_time_zone():12:00→12:00,只改变它属于哪个时区的标签
  • convert_time_zone():12:00 UTC→20:00 CST,真正换算了时刻
from datetime import datetime
import polars as pl

naive = pl.DataFrame({
    "ts": [datetime(2023, 6, 1, 12, 0)]  # 创建一个没有时区信息的Datetime列
})

naive.with_columns(
    pl.col("ts")
      .dt.replace_time_zone("Asia/Shanghai")  # 只贴时区标签,显示的数字仍为12:00
      .alias("replace_only"),
    pl.col("ts")
      .dt.replace_time_zone("UTC")            # 先按UTC贴标签,显示的数字仍为12:00
      .dt.convert_time_zone("Asia/Shanghai")  # 再换算到上海时区,显示的数字变为20:00
      .alias("replace_then_convert"),
)

如果误用了replace_time_zone(),可能导致时间对应的实际UTC时刻发生偏移,而且这种错误有时不容易从表面的日期时间数值中发现。

5.3 字符串操作

字符串操作统一放在.str命名空间下。常见任务可以归为规范化文本、替换内容、拆分与提取。最基础的处理包括大小写转换、长度统计和字符串切片。这些操作不会修改原列,而是返回新的表达式:

employees.select(
    "name",
    pl.col("name").str.to_lowercase().alias("lower"),  # 转小写
    pl.col("name").str.to_uppercase().alias("upper"),  # 转大写
    pl.col("name").str.len_chars().alias("length"),    # 统计字符数
    pl.col("name").str.slice(0, 3).alias("first3"),    # 从位置0截取3个字符
).head(4)

字符串清洗中,替换、拆分和正则提取最常用。replace()有两个重要默认值,n=1表示默认只替换第一处,literal=False表示默认把模式当作正则表达式。因此需要先区分替换一次还是全部替换:

df = pl.DataFrame({"t": ["a.a.a", "abc", "a.b"]})

df.select(
    pl.col("t").str.replace(".", "-", literal=True).alias("replace_n1"),    # 只替换第一处
    pl.col("t").str.replace_all(".", "-", literal=True).alias("replace_all"),  # 替换全部
    pl.col("t").str.replace(".", "X").alias("regex"),                      # 默认按正则解释点号匹配任意字符
    pl.col("t").str.replace(".", "X", literal=True).alias("literal"),      # 加literal后点号才表示真正的点号
)

除了替换内容,字符串清洗中还经常需要去除空白或拆分字段。strip_chars()可以去除字符串首尾的指定字符,默认用于去除首尾空白。split()则可以按照指定分隔符拆分字符串,并返回list[str]类型:

pl.DataFrame({"t": ["a,b", "  hi  "]}).select(
    pl.col("t").str.strip_chars().alias("clean"),
    pl.col("t").str.split(",").alias("parts"),
)

当数据格式比较固定时,简单的split()就可以完成拆分。如果需要从字符串中按照某种模式寻找特定内容,则可以进一步使用正则表达式。str.extract()用于提取匹配结果中的捕获组,默认返回第一个捕获组;如果一个字符串中可能存在多处匹配,则使用str.extract_all()获取全部结果:

pl.DataFrame({"t": ["Hello123", "a1b22c333"]}).select(
    pl.col("t").str.extract(r"(\d+)").alias("first_nums"),
    pl.col("t").str.extract_all(r"(\d+)").alias("all_nums"),
)

6 Pandas与Polars性能对比

前几章重点介绍Polars的核心语法与数据处理方式。然而,是否值得从Pandas迁移到Polars,最终仍取决于两者的实际性能表现。如果想进一步了解两者的性能差异,可阅读Polars vs Pandas千万级数据实测。

6.1 数据处理性能

本节选取数据分析中最常见的数据筛选、分组聚合、表连接以及字符串处理这四类操作,分别使用Pandas与Polars实现,并在1万行、100万行和1000万行这三种数据规模下进行基准测试。

为了保证测试结果具有可比性,首先构造统一的测试数据集,并定义通用计时函数。测试数据包含分组标识、数值和计数三个字段,每次生成数据后,同时构建Pandas DataFrame和Polars DataFrame。

计时采用以下策略:

  1. 先执行一次预热,避免首次运行带来的额外开销;
  2. 正式执行三次;
  3. 取最小耗时作为最终结果;
  4. 每轮测试前显式执行垃圾回收,减少内存波动对结果的影响。

测试覆盖数据筛选、分组聚合、连接和字符串匹配:

import gc, time
import numpy as np, pandas as pd, polars as pl

def make_data(n):
    """生成n行数据,返回Pandas和Polars两份"""
    rng = np.random.default_rng(42) 
    df = pd.DataFrame({
        "group": rng.choice(["A", "B", "C", "D"], n),   # 分组、字符串匹配用
        "value": rng.exponential(500, n).round(2),      # 筛选、聚合用
        "count": rng.integers(1, 50, n),                # 计数
    })
    return df, pl.from_pandas(df)

def timeit(fn):
    """预热一次,正式跑三次取最小,减少抖动"""
    fn() # 预热                   
    times = []
    for _ in range(3):
        gc.collect()  # 回收,避免前次影响          
        t0 = time.perf_counter()
        fn()
        times.append(time.perf_counter() - t0)
    return min(times)

def run(name, f_pd, f_pl, sizes=(10_000, 1_000_000, 10_000_000)):
    """同一操作,两个库各跑一遍,三档规模依次打印"""
    print(f"\n{name}")
    for n in sizes:
        pdf, ldf = make_data(n)
        a = timeit(lambda: f_pd(pdf))   # Pandas耗时
        b = timeit(lambda: f_pl(ldf))   # Polars耗时
        print(f"{n // 10000:>5}万行 |"
              f"pandas {a * 1000:>7.1f} ms |"
              f"polars {b * 1000:>7.1f} ms "
              f"{a / b:>5.1f}x")

# 数据筛选
run("筛选",
    lambda pd_df: pd_df[pd_df["value"] > 1000],
    lambda pl_df: pl_df.filter(pl.col("value") > 1000))

# 分组聚合
run("分组",
    lambda pd_df: pd_df.groupby("group")["value"].mean(),
    lambda pl_df: pl_df.group_by("group").agg(pl.col("value").mean()))

# 连接测试使用的维表
dim = pd.DataFrame({
    "group": ["A", "B", "C", "D"],
    "label": ["甲", "乙", "丙", "丁"]
})
dim_pl = pl.from_pandas(dim)

# 表连接
run("连接",
    lambda pd_df: pd_df.merge(dim, on="group", how="left"),
    lambda pl_df: pl_df.join(dim_pl, on="group", how="left"))

# 字符串匹配
run("字符串",
    lambda pd_df: pd_df[pd_df["group"].str.contains("A")],
    lambda pl_df: pl_df.filter(pl.col("group").str.contains("A")))

运行上述基准测试可以看到,数据量较小时两者性能差异有限。随着数据规模增加,Polars在分组聚合和表连接等计算密集型操作中的优势逐渐扩大,而简单筛选等轻量操作差异相对较小。不同硬件环境下的具体耗时和加速倍数可能有所变化,但整体趋势基本一致。

6.2 文件读写性能

数据分析任务中,文件读写往往占据相当一部分时间开销。特别是在处理数百万行数据时,读取数据所需时间甚至可能超过后续计算时间。本节对比Pandas与Polars在CSV和Excel文件上的读写性能。为避免线程数量差异影响测试结果,这里将Polars限制为单线程运行,让两者在相同线程条件下比较。

import os
# Polars默认多线程,Pandas默认单线程。
# 设置Polars单线程,想测默认性能就删掉这行。
# 该行代码必须放到导入polars前
os.environ["POLARS_MAX_THREADS"] = "1"
import numpy as np
import pandas as pd
import polars as pl

# 复用前面案例的timeit()函数
def run_io(name, f_pd, f_pl):
    """同一文件操作,两个库各跑一遍"""

    a = timeit(f_pd)
    b = timeit(f_pl)

    print(f"{name}")
    print(f"pandas {a * 1000:>7.1f} ms | "
          f"polars {b * 1000:>7.1f} ms | "
          f"{a / b:>5.1f}x")

# 生成测试文件
n = 1_000_000
rng = np.random.default_rng(42)

df = pd.DataFrame({
    "group": rng.choice(["A", "B", "C", "D"], n),
    "value": rng.exponential(500, n).round(2),
    "count": rng.integers(1, 50, n),
})
# 准备数据
df.to_csv("sales.csv", index=False)
df.to_excel("sales.xlsx", index=False)
pd_df = pd.read_csv("sales.csv")
pl_df = pl.read_csv("sales.csv")

# CSV读取
run_io("CSV读取",
    lambda: pd.read_csv("sales.csv"),
    lambda: pl.read_csv("sales.csv"))
# CSV写入
run_io("CSV写入",
    lambda: pd_df.to_csv("out_pd.csv", index=False),
    lambda: pl_df.write_csv("out_pl.csv"))

# Excel读取(calamine是最快的读取引擎)
# pip install openpyxl python-calamine fastexcel
run_io("Excel读取",
    lambda: pd.read_excel("sales.xlsx", engine="calamine"),
    lambda: pl.read_excel("sales.xlsx", engine="calamine"))
# Excel写入,这个对比不公平,两者引擎无法一样
# run_io("Excel写入",
#     lambda: pd_df.to_excel("out_pd.xlsx", index=False),
#     lambda: pl_df.write_excel("out_pl.xlsx"))

在相同线程条件下,Polars在CSV读写等常见场景中表现出一定优势。Excel文件的性能更多取决于底层解析引擎。两者差异通常小于CSV场景。实际运行时,Polars默认启用多线程,其性能优势会进一步扩大。这种多线程优势并不作用于Excel读写,Polars的Excel读写不走自身的线程池,读取完全交给外部的fastexcel,它基于calamine,不提供线程参数,所以调整线程数不影响读写速度。

7 参考

posted @ 2026-10-02 09:49  落痕的寒假  阅读(71)  评论(0)    收藏  举报