《Python编程实战》11.1 pandas 数据处理与性能陷阱

用 50 万行真实数据集实测 pandas 3.0 的性能陷阱:Copy-on-Write 成为默认后链式赋值的真实行为、SettingWithCopyWarning 的现状、dtype 与内存占用、apply 与向量化的耗时差距、groupby 与 merge 的坑,以及分块读取的取舍。

本节目标:用 50 万行真实数据把 pandas 3.0 的性能陷阱逐个实测——看清 Copy-on-Write 成为默认后链式赋值到底发生了什么、SettingWithCopyWarning 现在还在不在、dtype 怎么决定内存、apply/iterrows 为什么慢、groupby/merge 有哪些放大,最后给出可落地的取舍清单。
适用版本:Python 3.12+(实测 3.14.6);pandas 3.0.6、numpy 2.5.3

11.1 pandas 数据处理与性能陷阱

pandas 是 Python 数据处理的默认入口,但它也是最容易被误用的库——同一份逻辑,写法差一点,耗时能差上千倍。这一节不讲 API 清单,只讲实测出来的陷阱:先看 3.0 的行为变化(很多人还在按 2.x 的记忆写代码),再用一份 50 万行的订单数据把内存、向量化、聚合、连接逐个量出来。

所有数字都在本机实测,数据集构造如下(后文复用):

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
N = 500_000
cities = ["北京", "上海", "广州", "深圳", "杭州", "成都", "武汉", "西安"]
df = pd.DataFrame({
    "order_id": np.arange(N),
    "user_id": rng.integers(0, 50_000, N),
    "city": pd.Categorical(rng.choice(cities, N)),
    "amount": rng.uniform(1, 1000, N).round(2),
    "qty": rng.integers(1, 10, N).astype("int16"),
    "ts": pd.date_range("2026-01-01", periods=N, freq="s"),
})

11.1.1 pandas 3.0:Copy-on-Write 已经是默认行为

如果你是从 pandas 2.x 过来的,最容易踩的第一颗雷就是赋值语义变了。3.0 把 Copy-on-Write(CoW)变成不可关闭的默认行为:任何从 df 派生出来的对象(切片、布尔索引、df["col"])都表现为「写时复制」,改它不会改回原 df。实测:

import pandas as pd
print(pd.options.mode.copy_on_write)   # 打印选项本身会发 Pandas4Warning:已弃用
Pandas4Warning: The 'mode.copy_on_write' option is deprecated. Copy-on-Write can no
longer be disabled (it is always enabled with pandas >= 3.0) ...
True

选项还在、值恒为 True,但设它已经没有任何效果(会在 pandas 4.0 移除)。真正的行为差异在下面两段代码:

df = pd.DataFrame({"a": [1, 2, 3], "b": [10, 20, 30]})
sub = df[df["a"] > 1]   # 切片得到的是「写时复制」的视图
sub["b"] = 999          # 只改 sub,不碰 df
print(df); print(sub)
原 df:
   a   b
0  1  10
1  2  20
2  3  30
子集 sub:
   a    b
1  2  999
2  3  999

2.x 时代这种写法会发警告甚至改到原表,3.0 下它只改副本、不动原表——语义干净了,但如果你指望它改原表,就会得到「改了没反应」的困惑。

11.1.2 SettingWithCopyWarning 去哪了

网上铺天盖地的「SettingWithCopyWarning 怎么消」已经过时。3.0 里这个警告被移除了,实测:

print(hasattr(pd.errors, "SettingWithCopyWarning"))   # -> False

取而代之的是 ChainedAssignmentError,专门针对链式赋值 df["b"][mask] = x 这种写法:

import warnings
df2 = pd.DataFrame({"a": [1, 2, 3], "b": [10, 20, 30]})
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    df2["b"][df2["a"] > 1] = 0
    print("警告类别:", w[0].category.__name__)
print(df2)
警告类别: ChainedAssignmentError
   a   b
0  1  10
1  2  20
2  3  30

关键点:链式赋值既发警告又完全无效——df2 一个字没变。正确写法永远是单步 .loc:

df2.loc[df2["a"] > 1, "b"] = 0   # 一步完成,语义明确,改的就是原表

记法:只要你在写 df[...][...] = ,就是错的;把它改写成 df.loc[行条件, 列] = 。

11.1.3 dtype 决定内存:category 能省 76 倍

处理大表,内存往往比 CPU 先到瓶颈。df.memory_usage(deep=True) 是量内存的入口(deep=True 才会把字符串、对象列的真实字节数算进去)。50 万行的表实测:

mu = df.memory_usage(deep=True)
for col, b in mu.items():
    print(f"{col:<12} {b/1024/1024:8.2f} MB")
print(f"{'合计':<12} {mu.sum()/1024/1024:8.2f} MB")
Index            0.00 MB
order_id         3.81 MB
user_id          3.81 MB
city             0.48 MB
amount           3.81 MB
qty              0.95 MB
ts               3.81 MB
合计             16.69 MB

注意 qty 只有 0.95 MB——因为它被显式存成了 int16。默认 int64 会占 3.81 MB。数值列降位(int16/int32/float32)是零成本的省内存手段,前提是取值范围放得下。

city 只有 8 个唯一值,把它存成 category 收益巨大。同一列三种 dtype 实测:

dtype内存(50 万行)说明
category0.48 MB内部只存「整数码 + 字典」,重复值几乎不额外占空间
str(3.0 默认)36.72 MB每个字符串单独存,重复值也各存一份
object36.72 MB同 str,2.x 时代的默认

约 76 倍差距。规律:低基数的字符串列(类别、地区、状态)一律转 category;高基数(用户 ID、订单号)转 category 反而更慢更费内存,别乱用。

这里还藏着一个 3.0 的变化:pd.Series(["a", "b"]).dtype 现在是 str,不再是 object。str 和 object 在无 pyarrow 环境下内存一致,但语义上 str 更明确;如果你的代码里 dtype == "object" 用来判断字符串列,升级到 3.0 后判断会失效,要改成 is_string_dtype()。

11.1.4 向量化 vs apply vs map:差 400 倍

这是最经典的性能分水岭。对 50 万行的 amount 列做 x * 1.1,三种写法实测(各跑 3 次取最快):

.apply(lambda)  42.7 ms
向量化 * 1.1     0.1 ms
.map(lambda)    45.7 ms

apply/map 逐元素调 Python 函数,慢约 400 倍。换成有分支的逻辑更明显:

df["amount"].apply(lambda x: "high" if x > 500 else "low")  # 38.8 ms
np.where(df["amount"] > 500, "high", "low")                 #  0.5 ms
apply 分档    38.8 ms
np.where 分档  0.5 ms

np.where / df.mask / df.select 把条件写成整列运算,交给底层 C/NumPy 循环,比逐行 Python 快两个数量级。判断标准很简单:写代码时你脑子里有没有出现「对每一行……」,只要有,就去找向量化替代。

一个反直觉的实测:字符串列用 .str.upper() 并不总是比 .apply(str.upper) 快。200 万行实测 .str.upper() 188.6 ms、.apply 151.7 ms——.str 访问器有分派开销。所以别迷信「.str 一定更快」,它真正的价值是自动跳过 NaN、语义统一;真正的性能杀手是逐行 apply,不是 .str 本身。

11.1.5 iterrows 是最大的坑

比 apply 更糟的是 iterrows()——它每行构造一个 Series,开销爆炸。2 万行求和实测:

iterrows 2万行:  206.2 ms
向量化   2万行:    0.1 ms

2000 倍。而且 iterrows 还有个隐蔽的坑:它会把每行的 dtype 强制转成公共类型(整数列变成 float),改起来还不会写回原表。结论:生产代码里出现 iterrows/itertuples 基本可以判死刑,改用向量化,或用 df.itertuples()(比 iterrows 快很多但仍远慢于向量化)。真需要逐行循环的场景,往往是逻辑没抽干净。

11.1.6 groupby 与 merge 的陷阱

groupby 聚合本身很快,50 万行按 city 求和实测 7.1 ms:

df.groupby("city", observed=True)["amount"].agg(["sum", "mean", "count"])

注意 observed=True:当分组键是 category 时,默认 observed=False 会为所有类别(哪怕没出现)生成一行空分组,既慢又污染结果。类别列做 groupby 务必显式 observed=True。

顺带纠正一个流传很广的说法:「groupby.apply 比 agg 慢十倍」。在本机 3.0.6 实测两者几乎持平(20 万行:apply 10.8 ms vs agg 9.4 ms),因为 3.0 对 apply 做了优化。能不用 apply 就不用(写起来啰嗦、可控性差),但不必为性能恐慌。

merge 的陷阱在「键不唯一」。50 万行订单左连 5 万行用户表实测:

唯一键 merge     11.0 ms
非唯一键 merge   35.7 ms

非唯一键时,右表每个重复键都会把左表匹配行放大复制——不仅慢,还可能让结果行数暴涨。所以 merge 前先确认右表键唯一:users["user_id"].is_unique,不唯一要么去重、要么明确这是「多对多」的语义。merge 后的行数一定要和预期对一遍,这是数据管道里最常见的静默错误来源。

11.1.7 分块读取:省内存,但不省时间

单表大到内存放不下时,read_csv(..., chunksize=...) 是标准解法——每次只读一块,处理完就丢:

totals = {}
for chunk in pd.read_csv("orders.csv", chunksize=100_000):
    g = chunk[chunk["amount"] > 500].groupby("city", observed=True)["amount"].sum()
    for k, v in g.items():
        totals[k] = totals.get(k, 0.0) + v

同一份 500 万字节级 CSV(50 万行、约 13.8 MB),全量读 vs 分块读 vs polars 惰性实测:

pandas 全量读 :  128.8 ms
pandas 分块读 :  131.4 ms
polars 惰性   :   12.5 ms

分块读几乎没有更快——CSV 解析(把文本转成数值)的成本才是大头,分块只是把同样的解析量拆开做,省的只是峰值内存。所以:内存够就一次性读;内存不够才分块,别指望它提速。真正想提速,要么换 Parquet(列式、免解析),要么用下一节的 polars。

延伸阅读

小结

  • pandas 3.0 的 CoW 不可关闭:切片/布尔索引派生的对象改了不回写原表;链式赋值 df["b"][mask] = x 发 ChainedAssignmentError 且完全无效,一律改写 df.loc[行, 列] = x。
  • SettingWithCopyWarning 已被移除(hasattr(pd.errors, "SettingWithCopyWarning") 为 False),3.0 起字符串列默认 dtype 是 str 而非 object。
  • dtype 就是内存:低基数类别列转 category 省约 76 倍(0.48 MB vs 36.72 MB),数值列降位到 int16/float32 是零成本优化。
  • 向量化碾压逐行:apply 慢约 400 倍、iterrows 慢约 2000 倍;有分支用 np.where,别写「对每一行……」。
  • groupby 类别键要 observed=True;merge 怕键不唯一(35.7 ms vs 11.0 ms 且会放大行数),连完必对行数。
  • 分块读只省内存不省时间(131.4 ms vs 128.8 ms);想真提速得上列式格式或 polars。

这一节我们把「单机 pandas 怎么不踩坑」讲透了。但 pandas 的单线程模型和「先物化再计算」的路径,注定在更大数据量上撞墙。下一节换 polars:用惰性求值、查询优化和列式并行,把同一任务的耗时再压一个数量级。

阅读导航:上一节:心跳、重连与广播 · 下一节:Polars 与大规模数据 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时