告别内存溢出:用Polars高效处理百万级数据集的Python实战指南

2026-06-15 0 699

最近在做一个用户行为分析项目,数据量每天都在涨,从最初的两百万行很快飙升到了八百万行。一开始用的 Pandas 还能勉强应付,但内存占用轻松突破 8G,再加上一些分组聚合操作,笔记本风扇疯狂嘶吼,偶尔还会因为分配不出连续内存而直接报错。切换成 Polars 之后,内存在同样的操作下降低了将近一半,执行速度更是快了三四倍,体验确实有点超乎预期。本文就沿着这个真实场景,把迁移过程和关键用法梳理出来,希望能帮你少走些弯路。

为什么是Polars?

Polars 是用 Rust 实现的 DataFrame 库,底层基于 Apache Arrow 的内存模型。相比 Pandas,它有两个非常突出的特点:

  • 零拷贝与列式存储:数据在内存中以 Arrow 格式长期存放,不同操作之间传递数据基本不需要额外复制,大幅减少内存开销。
  • 懒执行与查询优化:可以像 Spark 那样先声明整个计算逻辑,然后一次性优化并执行。多余的全表扫描、中间列会被自动剪枝,避免不必要的计算。

最重要的是,Polars 的 API 设计非常接近 Pandas,但又消除了很多隐式拷贝和类型推断上的历史包袱,上手难度并不高。

环境准备与安装

Polars 同时提供 Rust 版本和 Python 绑定,我们只关心 Python 端就好。直接用 pip 安装:

pip install polars

如果你的数据存储在数据库中,还可以顺便安装连接器,例如读取 MySQL:

pip install polars[pandas, sqlalchemy]  # pandas 用于互相转换,sqlalchemy 用于数据库读取

安装完成后,在脚本中导入,通常的约定是用 pl 作为别名:

import polars as pl

快速读取数据

假设我们有一个约 800 万行的 CSV 文件 user_actions.csv,包含字段:user_id, action, product_id, timestamp, amount。先用 Polars 读进来看看:

df = pl.read_csv("user_actions.csv")
print(df.head(3))
print(df.shape)

输出类似:

shape: (3, 5)
┌──────────┬───────────┬────────────┬─────────────────────┬────────┐
│ user_id  ┆ action    ┆ product_id ┆ timestamp           ┆ amount │
│ ---      ┆ ---       ┆ ---        ┆ ---                 ┆ ---    │
│ i64      ┆ str       ┆ i64        ┆ str                 ┆ f64    │
╞══════════╪═══════════╪════════════╪═════════════════════╪════════╡
│ 1001     ┆ view      ┆ 501        ┆ 2025-01-01T12:01:05 ┆ 0.0    │
│ 1001     ┆ add_cart  ┆ 501        ┆ 2025-01-01T12:02:03 ┆ 0.0    │
│ 1002     ┆ purchase  ┆ 322        ┆ 2025-01-01T12:05:11 ┆ 299.99 │
└──────────┴───────────┴────────────┴─────────────────────┴────────┘
(8300000, 5)

默认情况下,read_csv 会尝试推断每列的数据类型,对于大文件,推断会消耗一些时间。如果你明确知道每列的类型,可以用 dtypes 参数显式指定,同时传入 infer_schema_length=0 关闭推断,读取速度会更快:

df = pl.read_csv(
    "user_actions.csv",
    dtypes={
        "user_id": pl.Int64,
        "action": pl.Utf8,
        "product_id": pl.Int64,
        "timestamp": pl.Utf8,
        "amount": pl.Float64
    },
    infer_schema_length=0
)

当然,timestamp 列后续我们会转成真正的 datetime 类型。

用表达式(Expression)操作数据

Polars 的核心操作方式是通过表达式构建“操作管道”,而不是像 Pandas 那样频繁地取列赋值。最常见的就是 selectfilterwith_columns 这几个方法。

列选择与类型转换

先把 timestamp 从字符串转为 datetime 类型,并且只保留我们关心的列:

df_clean = df.select(
    pl.col("user_id"),
    pl.col("action"),
    pl.col("product_id").cast(pl.Int32),   # 压缩存储
    pl.col("timestamp").str.to_datetime("%Y-%m-%dT%H:%M:%S"),
    pl.col("amount")
)
print(df_clean.dtypes)

这里的 str.to_datetime 是字符串命名空间下的方法,直接在表达式里完成转换,无需生成中间 Series。

过滤与条件组合

筛选出所有“购买”动作且金额大于 10 的记录:

purchases = df_clean.filter(
    (pl.col("action") == "purchase") & (pl.col("amount") > 10.0)
)
print(purchases.shape)

条件里的 &| 用来组合逻辑,和 Pandas 的按位运算符写法一致,但这里的表达式在 Polars 内部会被优化。

添加新列

假设我想给每行添加一个“星期几”字段,方便后续按天聚合:

df_clean = df_clean.with_columns(
    pl.col("timestamp").dt.weekday().alias("day_of_week")
)

with_columns 只修改或添加指定列,其他列保持不变,内存开销极小。

分组聚合与窗口函数

一个很常见的需求:统计每位用户的总消费金额、购买次数,以及最近一次购买时间。用 Pandas 我通常会链式写 groupbyagg,Polars 的写法几乎一样:

user_stats = df_clean.filter(pl.col("action") == "purchase").group_by("user_id").agg(
    pl.col("amount").sum().alias("total_spent"),
    pl.col("action").count().alias("purchase_count"),
    pl.col("timestamp").max().alias("last_purchase_time")
)
print(user_stats.head(5))

得到的 user_stats 是一个新的 DataFrame,只包含聚合结果。因为过滤和聚合都是惰性表达式,内部可以合并执行,实际速度比 Pandas 快了数倍。

另一个场景是计算每个用户在当日消费金额的排名,可以用窗口函数:

df_daily = df_clean.filter(pl.col("action") == "purchase").with_columns(
    pl.col("timestamp").dt.date().alias("date")
)

df_ranked = df_daily.with_columns(
    pl.col("amount").rank(method="dense", descending=True)
        .over(["user_id", "date"])
        .alias("rank_in_day")
)
print(df_ranked.select(["user_id", "date", "amount", "rank_in_day"]).head(8))

over() 指定了窗口分组,写法比 SQL 更紧凑。你不会看到临时表或子查询,所有逻辑都在表达式链条里完成。

懒执行(LazyFrame)与查询计划

平时练习我们可以用快速模式(即普通 DataFrame),但生产上强烈建议切换到 LazyFrame。它是 Polars 的性能核心,能够将一系列操作编译成一个优化的执行计划,只扫描必要的数据列,甚至在某些场景下只遍历数据一次。

把之前的清洗和聚合流程重构成懒加载版本:

qq = pl.scan_csv("user_actions.csv")  # 创建一个 LazyFrame

lf_clean = qq.select([
    pl.col("user_id"),
    pl.col("action"),
    pl.col("product_id").cast(pl.Int32),
    pl.col("timestamp").str.to_datetime("%Y-%m-%dT%H:%M:%S"),
    pl.col("amount")
]).with_columns(
    pl.col("timestamp").dt.weekday().alias("day_of_week")
)

lf_purchase = lf_clean.filter(
    (pl.col("action") == "purchase") & (pl.col("amount") > 10.0)
)

lf_stats = lf_purchase.group_by("user_id").agg(
    pl.col("amount").sum().alias("total_spent"),
    pl.col("action").count().alias("purchase_count"),
    pl.col("timestamp").max().alias("last_purchase_time")
)

# 至此尚未执行任何计算
print(lf_stats.explain())  # 查看优化后的执行计划

result = lf_stats.collect()  # 真正执行
print(result.head(5))

通过 explain() 可以直观看到查询优化器做了什么:它可能合并过滤器,提前进行列裁剪,甚至把某些聚合操作推到扫描阶段。这对于调试性能非常有用,也是我养成的习惯。

与Pandas无缝互转

团队中可能仍有部分成员习惯用 Pandas,或者某些可视化库只接受 Pandas DataFrame。Polars 提供了零拷贝互转:

# Polars -> Pandas
pdf = result.to_pandas()

# Pandas -> Polars (同样通过 Arrow 中转,高效)
pl_df = pl.from_pandas(pdf)

如果数据包含类别型或时间等特殊类型,可以设置 use_pyarrow_extension_array=True 以保留更精确的类型。

一个完整实战:找出高价值商品

最后我们串联一个较完整的例子:从原始 CSV 中读取用户操作数据,计算每个商品的总销售额、总购买次数、最后一次购买日期,并按销售额降序取前 10 件商品。完整脚本如下:

import polars as pl

# 懒加载 CSV
lf = pl.scan_csv("user_actions.csv")

# 转换时间列并过滤购买行为
purchases = (
    lf.filter(pl.col("action") == "purchase")
    .with_columns(
        pl.col("timestamp").str.to_datetime("%Y-%m-%dT%H:%M:%S")
    )
    .filter(pl.col("amount") > 0)
)

# 按产品聚合
product_stats = (
    purchases.group_by("product_id")
    .agg([
        pl.col("amount").sum().alias("total_revenue"),
        pl.col("user_id").count().alias("purchase_count"),
        pl.col("timestamp").max().alias("last_sale")
    ])
    .sort("total_revenue", descending=True)
    .limit(10)
)

# 执行并转 Pandas(可选)
top_products = product_stats.collect()
print(top_products)

# 如果要导出结果
top_products.write_csv("top_products.csv")

在我的那台 16GB 内存的 MacBook 上,处理 830 万行数据,整个流程不到 2 秒,内存峰值稳定在 1GB 左右。而之前用 Pandas 跑同样的逻辑,要花上近 10 秒,内存更是突破 3GB。这还只是单机情况,没有任何分布式加持。

常见坑与使用建议

  • 类型推断:默认 scan_csv 会读取前 100 行推断类型,如果某列前面都是空值,可能推断为 Null 类型,导致后续操作报错。建议用 infer_schema_length 调大或手动指定 dtypes
  • 索引概念缺失:Polars 没有 Pandas 的 index 概念,所有数据都是列式存储。如果要按某列排序后用位置访问,直接 sort 后加 rowhead
  • 表达式可组合:复杂逻辑尽量拆成多个变量,不要在一个 select 里塞几十列,但也要注意不要因为拆得太散而多次触发 collect。最好的方式就是全程使用 LazyFrame,最后一次性执行。
  • 内存溢出:即使是 Polars,如果加载的数据集远超物理内存,还是要考虑用 scan_parquet 分块读取,或者结合 pl.read_csv_batched 等方法做增量处理。

总结

从实际的迁移体验来看,Polars 并不是要完全推翻 Pandas 的生态,而是在处理较大规模数据时提供了一个更现代、更省资源的选项。学习成本不高,很多操作和 Pandas 一一对应,但又在性能和内存上拉开了明显差距。如果你日常工作中经常遇到数据量快速增长、内存吃紧的情况,很值得花一两个小时把常用操作迁移过来试一试。

目前 Polars 的绘图生态、第三方支持还没有 Pandas 那么丰富,不过这个问题可以通过 to_pandas() 轻松绕过。综合来看,它已经成为我日常数据分析的首选工具之一了。

告别内存溢出:用Polars高效处理百万级数据集的Python实战指南
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python 告别内存溢出:用Polars高效处理百万级数据集的Python实战指南 https://www.taomawang.com/server/python/2151.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务