Polars 以其卓越的速度、现代化的语法和强大的表达力备受瞩目,但许多刚从 Pandas 转过来的开发者,往往会不自觉地沿用旧习惯,导致无法发挥其真正的性能优势。
以下是新手在迁移过程中最容易遇到的 10 个典型问题及其优化思路。
1、直接使用 read_csv 而非 scan_*
面对一个大型 CSV 文件,新手常会这样操作:
df = pl.read_csv("events.csv")
这种方式会立即将整个文件加载到内存中。一旦文件达到 GB 级别,极易导致内存溢出,性能瓶颈立刻显现。正确的做法是使用惰性扫描:
lf = pl.scan_csv("events.csv")
所有后续操作都将保持在惰性状态,直到最后调用 .collect() 才会执行。这样做的好处是,Polars 的查询优化器能够将过滤(Filter)和列选择(Projection)等操作“下推”到数据扫描阶段,从而显著减少 I/O 和内存占用。
2、沿用 Python 循环或低效的 .apply()
例如,想要为数据添加一个新列,很多人会写成:
df = df.with_columns(
pl.col("price").apply(lambda x: x * 1.19)
)
这种写法迫使计算退回到 Python 层面进行逐行处理,完全丧失了向量化计算的优势,速度极慢。应当替换为 Polars 的原生表达式:
df = df.with_columns(
(pl.col("price") * 1.19).alias("price_with_vat")
)
原生表达式会在 Rust 底层运行,可利用 SIMD 指令加速,并且能融入整体的查询计划进行优化,性能差距天壤之别。这也是高效 Python 数据处理的关键。
3、过早、过频繁地调用 collect()
新手容易编写出这样的流水线:
df1 = lf.filter(...).collect()
df2 = df1.with_columns(...).collect()
每一次 .collect() 都会导致整个数据集被物化到内存。最佳实践是将所有操作链接在一起,仅在最后收集一次结果:
result = (
lf.filter(...)
.with_columns(...)
.groupby(...)
.agg(...)
)
df = result.collect()
单次 .collect() 给予了优化器进行全局优化(如谓词下推、投影下推、表达式融合)的机会,能节省大量不必要的计算和内存开销。
4、忽略列裁剪(投影下推)
假设加载一张拥有 200 列的宽表,但实际业务只用到其中 4 列。若不进行筛选,所有列都会被读入。正确做法是尽早明确指定所需的列:
lf = lf.select(["user_id", "country", "revenue", "event_time"])
Polars 会将此选择操作下推到扫描层,从磁盘读取时便只读取这几列的数据。若源文件是 Parquet 格式,效果将更为显著,速度提升非常可观。
5、过早转换为 Pandas DataFrame
有人习惯这样操作:
pd_df = lf.collect().to_pandas()
在未进行任何过滤、分组聚合等“重活”之前,就将其转换为 Pandas,导致数千万行数据在 Pandas 中缓慢处理。合理的策略是,先在 Polars 中完成核心的数据处理和聚合:
cleaned = lf.filter(...).groupby(...).agg(...)
pdf = cleaned.collect().to_pandas()
应把 Polars 视为核心计算引擎,而 Pandas 作为轻量的展示或后续精细化操作的补充,角色定位反了就会丧失性能优势。
6、混淆 DataFrame、LazyFrame 和 Expr 类型
新手可能写出这样的代码:
lf.groupby("user_id").sum() # LazyFrame 上直接调用 .sum(),忘记 .agg()
或者:
df.with_columns(lf.col("price")) # 在 DataFrame 中误用了 LazyFrame 的列
根源在于没有清晰区分三种核心类型。牢记:
DataFrame:已经物化在内存中的数据。
LazyFrame:一个等待优化的查询计划。
Expr:用于构建操作的列表达式。
lf = pl.scan_csv("file.csv") # LazyFrame
df = lf.collect() # DataFrame
expr = pl.col("amount") # Expr
理解这个模型,才能避免隐蔽的 bug,并让优化器真正发挥作用。
7、误以为 .unique() 的行为与 Pandas 一致
部分开发者期望 .unique() 返回排序后的唯一值,但 Polars 默认会保留它们首次出现的顺序:
lf.select(pl.col("country").unique())
这与 Pandas 的默认行为不同,可能引发意料之外的逻辑错误。如果需要排序结果,务必显式添加:
lf.select(pl.col("country").unique().sort())
显式排序能确保跨框架行为的一致性。
8、不重视数据类型
CSV 数据常混杂不纯,例如:"19.99", "20", "error", ""。
Pandas 可能默认为 object 类型,Polars 会尝试推断,但新手往往疏于验证。更可靠的做法是在扫描时直接指定类型:
lf = pl.scan_csv(
"orders.csv",
dtypes={"price": pl.Float64}
)
或者在读取后转换:
df = df.with_columns(pl.col("price").cast(pl.Float64))
类型明确的管道更加稳定、可预测,执行速度也更快。
9、处理海量数据聚合时未开启流式模式
面对几十亿行数据进行分组聚合:
lf.groupby("user_id").agg(...)
若直接收集,内存很可能不堪重负。此时应启用流式处理模式:
result = (
lf.groupby("user_id")
.agg(pl.col("amount").sum())
.collect(streaming=True)
)
流式模式会分块处理数据,特别适用于 ETL 流水线、日志分析等 大数据 场景。
10、多次调用 with_columns 而非合并表达式
新手容易这样写:
df = df.with_columns(pl.col("a") + pl.col("b"))
df = df.with_columns(pl.col("c") - pl.col("d"))
df = df.with_columns(pl.col("e") * 1.19)
三次独立的调用意味着三个独立的优化步骤。更好的做法是合并到一个表达式列表中:
df = df.with_columns([
(pl.col("a") + pl.col("b")).alias("ab"),
(pl.col("c") - pl.col("d")).alias("cd"),
(pl.col("e") * 1.19).alias("e_vat")
])
Polars 会将这些表达式融合成一个高度优化的单一操作,从而提升执行效率。
总结
从 Pandas 迁移到 Polars,关键在于思维模式的转变。核心要点是:惰性求值优先、善用原生表达式、延迟数据物化(collect)、避免 Python 层面循环、明确数据类型、充分利用 LazyFrame、主动应用投影与谓词下推、大数据集开启流式处理。
养成这些新的 数据处理 习惯,方能真正释放 Polars 的强大性能潜力。