[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的读写方式,最后说明多批数据的拼接方法。

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时,最重要的是先想清楚两个问题:
- 用哪一列进行匹配?
- 匹配不上的数据要不要保留?
想清楚这两个问题后,再选择对应的参数:
- 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。
计时采用以下策略:
- 先执行一次预热,避免首次运行带来的额外开销;
- 正式执行三次;
- 取最小耗时作为最终结果;
- 每轮测试前显式执行垃圾回收,减少内存波动对结果的影响。
测试覆盖数据筛选、分组聚合、连接和字符串匹配:
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 参考
本文来自博客园,作者:落痕的寒假,转载请注明原文链接:https://www.cnblogs.com/luohenyueji/p/23184458

浙公网安备 33010602011771号