📖 本篇简介:本篇是"常见报错与排查"三部曲的终章,也是整个系列的重点篇。前两章解决"代码直接抛错"的问题,本篇解决两类更棘手的问题:① 性能与内存类问题(不抛错,但慢到怀疑人生、或直接 OOM 被杀);② 一套可迁移的报错排查方法论——无论你下次遇到的是 KeyError、TypeError 还是一个诡异的 NaN,都能按同一套流程定位。前置知识:第 16 章性能优化总原则、第 23 章向量化技巧、第 43 章与第 44 章。学完后你能做到:看到慢/卡/被杀不再抓瞎,按流程图一步步定位瓶颈。
很多初学者对 pandas 的恐惧不是"报错",而是"它不报错,但跑了 10 分钟还没完 / 直接闪退了"。本篇把这类"沉默的故障"讲透,并给你一套任何人都能复用的调试方法论——这才是从"会写 pandas"到"会调 pandas"的分水岭。
目录
1. 本篇定位:性能/内存问题与"硬报错"有何不同
2. 报错 1:MemoryError / 进程被系统 Killed(内存爆炸 OOM)
3. 报错 2:read_csv 读大文件卡死/慢
4. 报错 3:apply 慢到令人发指
5. 报错 4:groupby 极慢
6. 报错 5:内存占用远超预期(str/object dtype 隐形膨胀)
7. 性能剖析工具箱:time / %%timeit / cProfile / df.info
8. 向量化 vs 循环:1e6~1e7 实跑对比表
9. 报错排查通用方法论(本篇重点)
10. 排查流程图(文字版)与速查表
11. 本章小结与自测清单
12. 练习题
1. 本篇定位:性能/内存问题与"硬报错"有何不同
前两章(43、44)讲的都是"硬报错":pandas 主动抛 KeyError/ValueError/TypeError,附一段 Traceback,照着改即可。本篇讲的是更隐蔽、更折磨人的另一类问题:
| 维度 | 硬报错(43/44 章) | 性能/内存问题(本篇) |
|------|--------------------|------------------------|
| 是否抛异常 | 是,Traceback 明确 | 多数不抛,或只抛 MemoryError |
| 症状 | 立刻中断 | 跑 10 分钟、卡死、机器卡顿、闪退、Killed |
| 根因定位 | 看 Traceback 最后一行 | 需要剖析工具(cProfile / memory_usage) |
| 修复方式 | 改一行代码 | 改写法、换 dtype、分块、压缩 |
| 新手友好度 | 报错即答案 | "我都不知道哪儿错了" |
⚠️ 关键认知:性能问题不是"bug",而是"代码没写错,但写法不对"。同一段业务逻辑,写法 A 跑 10 秒,写法 B 跑 0.01 秒,结果完全一样——这就是本篇要帮你省下来的时间。
本篇与 第 16 章、第 23 章的分工:
- 16 章:性能优化的总原则(怎么优化)。
- 23 章:向量化的全套技巧(具体怎么写更快)。
- 本篇(45):当你不知道自己慢在哪、不知道内存被谁吃了时,怎么诊断、定位、确认。本篇侧重"问题发现与定位方法论",不重复堆砌优化技巧,但会把实测数据摆在你面前。
2. 报错 1:MemoryError / 进程被系统 Killed(内存爆炸 OOM)
① 现象长什么样
# 形态 A:Python 主动抛 MemoryError
Traceback (most recent call last):
File "read_big.py", line 5, in <module>
df = pd.read_csv("huge.csv")
File "pandas/io/parsers/readers.py", line 550, in read_csv
...
File "pandas/_libs/parsers.pyx", line 833, in pandas._libs.parsers.TextReader._read_rows
MemoryError: Unable to allocate 1.xx GiB for an array with shape (xxxxx,) and data type float64
# 形态 B:进程直接被系统杀掉(Windows 上常见现象是"python.exe 已停止工作"或命令行窗口闪退;
# Linux/Mac 上常见打印 "Killed")
$ python read_big.py
Killed
⚠️ 重要区分:Killed(进程被系统杀掉)不是 pandas 抛的错,而是操作系统的 OOM Killer 在内存耗尽时强杀进程。它没有 Traceback、没有 Python 异常,初学者最容易误判为"pandas 崩溃了"。这是环境/内存问题,不是代码语法错误。② 触发场景
pd.read_csv("10GB 的日志.csv")一把梭全量读入;df = pd.concat([...])拼接了几十万个小块;- 对一个 object/str 列做
.str.contains(...)后又生成了一堆中间副本; - 在 Jupyter 里反复跑同一个 cell,每次都生成一个新的大 DataFrame 但旧变量没释放。
③ 原因分析
pandas 把整张表加载进内存,而不是像数据库那样磁盘上按需取。一张表在内存里的体积 ≈ 每列 dtype 字节数 × 行数,再乘以 2~3 倍的中间副本系数。
一张 1 亿行、10 列的表,如果全是 float64,光原始数据就要:
1e8 行 × 10 列 × 8 字节 = 8e9 字节 ≈ 7.5 GiB
再加上读入时的临时缓冲区、中间副本,实际峰值常常是原始大小的 2~3 倍。16GB 内存的机器读 5GB 的 CSV 就可能 OOM。
④ 解决方案(按代价从低到高)
方案 1:`usecols` 只读需要的列 + 指定 dtype——立竿见影。
import pandas as pd
# ❌ 一把读入 50 列、全部默认 dtype(int64 / str 都是最宽类型)
# df = pd.read_csv("huge.csv")
# ✅ 只读 4 列,并指定最小够用的 dtype
df = pd.read_csv(
"huge.csv",
usecols=["user_id", "city", "amount", "dt"], # ① 只读需要的列
dtype={"user_id": "int32", # ② int32 比 int64 省一半
"city": "category", # 低基数字符串列大杀器
"amount": "float32"}, # float32 比 float64 省一半
)
方案 2:`chunksize` 分块读取——内存实在装不下整表时,每 50 万行读一块处理完就释放:
total_amount, n_rows = 0.0, 0
for chunk in pd.read_csv("huge.csv", chunksize=500_000,
usecols=["amount"], dtype={"amount": "float32"}):
total_amount += chunk["amount"].sum()
n_rows += len(chunk)
# 全程内存里只有 50 万行,而不是 1 亿行
方案 3:换存储格式——长期项目直接用 Parquet(列存 + 压缩 + 自带 dtype),别再用 CSV:
pd.read_csv("huge.csv").to_parquet("huge.parquet", index=False)
df = pd.read_parquet("huge.parquet", columns=["user_id", "city", "amount"])
⑤ 预防建议
- 读文件前先
os.path.getsize("huge.csv")看一眼文件多大,估算内存峰值 ≈ 文件大小 × 2~3; - 机器 16GB 内存,单张 DataFrame 超过 3~4GB 就要警惕;
- Jupyter 里调试大表,用
del df+gc.collect()主动释放,别让旧 cell 的大变量占着内存; - 长期数据一律 Parquet,CSV 只做交换格式。
3. 报错 2:read_csv 读大文件卡死/慢
① 现象长什么样
你执行了:
>>> df = pd.read_csv("2GB日志.csv")
然后命令行光标停在那里,CPU 跑满,风扇狂转,5 分钟过去还没出结果。
没报错、没进度条、不知道它是在干活还是死了。
② 触发场景
- CSV 文件几百 MB 到几十 GB;
- 列数很多(几十上百列),其中混杂了字符串列;
- 用了默认参数,没指定
dtype、usecols; - 还开了
parse_dates解析一个巨大的日期列。
③ 原因分析
read_csv 默认要做三件重活:
1. 推断每一列的 dtype——它会扫完整列猜"这列是 int 还是 float 还是 str",对混合列还会逐格判断;
2. 把所有列都读进内存——不管你用不用;
3. 字符串列按对象方式存储——pandas 3.0 之前是 object(每个元素是 Python str 对象指针),3.0 起默认是 str dtype,但仍然比数值列贵得多。
我们用本机 173.5MB 的 CSV(200 万行 × 7 列)实测对比(环境:Windows / Python 3.14.7 / pandas 3.0.6):
全量读(默认): 4659.1ms shape=(2000000, 7) 内存=108.7MB
usecols+dtype: 3301.5ms shape=(2000000, 4) 内存=19.1MB
chunksize=500k 分块求和: 2557.0ms 累计行数=2,000,000 sum(v1)=-4744.981
📌 解读:同样一个文件,默认读法 4.6 秒 / 108MB;只读需要的 4 列 + 指定 dtype 后 3.3 秒 / 19MB——内存降到原来的 1/5。文件越大,差距越夸张。
④ 解决方案
df = pd.read_csv(
"2GB日志.csv",
usecols=["user_id", "city", "amount", "dt"], # ① 只读 4 列
dtype={"user_id": "int32", "city": "category", "amount": "float32"},
parse_dates=["dt"], # 日期列单独解析
low_memory=False, # 避免混合列分块推断的 dtype 不一致警告
# chunksize=500_000, # ③ 实在读不下就打开这行
)
⚠️ 关于 `low_memory`:pandas 默认分块读取并推断 dtype,可能在不同块里对同一列推断出不同类型(典型警告DtypeWarning: Columns (X) have mixed types)。low_memory=False强制一次性扫描全部数据推断 dtype,慢一点但更准;生产环境建议配合显式dtype,从根上消除这个警告。
⑤ 预防建议
- 先
nrows=5读 5 行看一眼列名和样例,再决定usecols和dtype; - 别一上来就
df = pd.read_csv("xxx.csv")——这是新手最大的坏习惯; - 频繁读的中间结果,导出成 Parquet(见第 22 章),下次读取快一个数量级。
4. 报错 3:apply 慢到令人发指
① 现象长什么样
df["new_col"] = df.apply(lambda r: r["a"] * 2 + r["b"], axis=1)
200 万行的数据,这一行跑了 30 秒还没结束。CPU 占用 100%,风扇响。
② 触发场景
- 对整表
df.apply(..., axis=1)做逐行计算; - 对一列
df["col"].apply(lambda x: ...)做逐元素变换; - 在
groupby之后又套了一层apply。
③ 原因分析
apply(axis=1) 的本质是:pandas 把每一行抽出来,包装成一个 Series,再传给你的 Python 函数。这意味着:
- 每行都要做一次"行 → Series"的对象构造;
- 函数在 Python 解释器里逐行执行,完全无法利用底层 NumPy 的 C 级向量化;
- 行数 × 每行的 Python 调度开销,累积起来就是几十上百倍的差距。
我们在本机实跑(n=1,000,000 行,计算 a*2+b):
向量化: 8.34 ms
apply: 11628.80 ms (约 11.6 秒)
zip 循环: 614.38 ms
加速比 (apply/向量化): 1394.3 x
📌 1400 倍。这不是"优化一下",这是"写法完全错误"。
④ 解决方案
99% 的 `apply` 都能改写成向量化写法。对照 第 23 章 的完整对照表,这里给最常见的几种:
import pandas as pd, numpy as np
rng = np.random.default_rng(0)
df = pd.DataFrame({"a": rng.standard_normal(1_000_000),
"b": rng.standard_normal(1_000_000),
"score": rng.standard_normal(1_000_000)})
# ❌ 逐行 apply
# df["r"] = df.apply(lambda r: r["a"] * 2 + r["b"], axis=1)
# ✅ 算术:直接整列运算
df["r"] = df["a"] * 2 + df["b"]
# ✅ if/else:np.where 或 np.select
df["level"] = np.where(df["score"] > 1, "高",
np.where(df["score"] > 0, "中", "低"))
# ✅ 截断:np.clip
df["score_clipped"] = np.clip(df["score"], -3, 3)
# ✅ 字符串:.str 访问器(如果真的有字符串列)
# df["name_upper"] = df["name"].str.upper()
⑤ 什么时候才该用 apply
apply 不是洪水猛兽,以下场景用它是合理的:
| 场景 | 为什么该用 apply |
|------|------------------|
| 行数很少(几千行以内) | 性能无所谓,可读性优先 |
| 逻辑极度复杂、向量化写不出来 | 例如要调用外部 API、正则嵌套、状态机 |
| groupby 后对每组做自定义聚合 | 用 .groupby("k").apply(func),配合 第 19 章 |
| 需要逐行访问多个列做非常规判断 | 先尝试 np.select,写不出来再 apply |
⚠️ 判断标准:写完 apply 后问自己一句——"这个操作能不能下沉到 NumPy?" 能,就改;不能(比如要发 HTTP 请求),才保留 apply。5. 报错 4:groupby 极慢
① 现象长什么样
df.groupby("city")["amount"].sum()
500 万行的表,groupby 一下花了 1~2 秒;如果分组键是字符串且基数很高(比如 20 万个不同的城市),更慢。
② 触发场景
- 分组键是字符串列(
city、user_id、category); - 分组基数很高(几万、几十万个不同的键);
- 默认
sort=True,结果还要排序; - 用了
.apply或.agg(lambda x: ...)这种慢聚合函数。
③ 原因分析
groupby 的开销主要在两处:
1. 哈希分组:要为每个分组键算哈希、建哈希表。字符串键比整数键慢;
2. 排序:默认 sort=True,分组后还要按键排序,这是 O(n log n) 的开销。
我们在本机实测(500 万行):
【20 个分组】
object + sort=True: 368.4 ms
object + sort=False: 299.8 ms
category + sort=False: 251.0 ms
category + sort=True: 222.1 ms
【20 万个分组】
object + sort=True: 1348.3 ms
object + sort=False: 1173.9 ms
category + sort=False: 871.7 ms
category + sort=True: 357.9 ms
📌 关键结论:
- 分组基数越高,把分组键转成 category 收益越大(20 万组时 1348ms → 358ms,快 3.8 倍);- sort=False 在大基数下也能省 10~20%;- category + sort=True 在高基数下反而最快——因为 category 内部是整数编码,排序整数比排序字符串快得多。
④ 解决方案
import pandas as pd
# 假设 df 有 500 万行,"city" 是字符串列
# 三招:category + sort=False + 用内置聚合函数
# ① 分组键转 category(低基数列尤其有效)
df["city"] = df["city"].astype("category")
# ② sort=False:如果你不关心结果顺序,省掉排序开销
# ③ 用内置聚合函数(sum/mean/count),别用 lambda
result = df.groupby("city", sort=False)["amount"].sum()
# ④ 多列聚合一次性算完,别反复 groupby
agg = df.groupby("city", sort=False).agg(
total_amount=("amount", "sum"),
avg_amount=("amount", "mean"),
n=("amount", "size"),
)
⚠️ 版本提示:pandas 3.0 起,groupby(...).agg(...)推荐用上面这种命名聚合写法(new_col=("col", "func")),比字典写法更清晰。旧版常用的df.groupby("city")["amount"].agg(["sum","mean"])仍然能用,但命名聚合可读性更好。
⑤ 预防建议
- 低基数字符串列(城市、性别、类别)读进来就转 category,别等到 groupby 才想起来;
- 结果不需要排序时,永远加
sort=False; - 聚合函数优先用 pandas/NumPy 内置的(
sum/mean/median/std),不要写agg(lambda x: x.sum())——那只是给内置函数套了层 Python 壳。
6. 报错 5:内存占用远超预期(str/object dtype 隐形膨胀)
① 现象长什么样
你读了一个表,df.shape 显示 200 万行 × 5 列,按理说数值列每列才十几 MB,但 df.info() 一看内存 300MB+。不知道内存被谁吃了。
② 触发场景
- 表里有大量字符串列(城市、类别、备注);
- 用了
object(pandas < 3.0)或str(pandas ≥ 3.0)dtype 存字符串; - 没调用
memory_usage(deep=True),只看了默认值。
③ 原因分析
这是新手最容易踩的坑:`df.memory_usage()` 默认不统计字符串列的真实内容,只统计指针数组。你必须加 deep=True 才能看到真实占用。
我们在本机实测(200 万行,8 个城市名循环):
str/object 列: 26.70 MB
category 列: 1.91 MB (压缩到 7.1%)
int8 编码列: 1.91 MB (压缩到 7.1%)
dtype: str | category | int8
📌 同一份数据,用 `str` 存要 26.7MB,用 `category` 只要 1.9MB——差 14 倍。行数越多、字符串越长,差距越大。
数值列的压缩同理(500 万行实测):
f64: 38.15 MB dtype=float64
i64: 38.15 MB dtype=int64
f32: 19.07 MB dtype=float32 (省一半)
i32: 19.07 MB dtype=int32 (省一半)
i16: 9.54 MB dtype=int16 (省 3/4)
④ 解决方案
# 第一步:诊断——先看真实内存
df = pd.read_csv("some_data.csv")
print(df.memory_usage(deep=True).sort_values(ascending=False)) # deep=True 才准
# 第二步:压缩字符串列 → category(低基数列:nunique() << len(df))
df["city"] = df["city"].astype("category")
# 第三步:压缩数值列 → 选够用的最小类型
df["age"] = df["age"].astype("int16") # 年龄 0~120,int16 足够
df["score"] = df["score"].astype("float32") # 分数不需要 float64 的精度
⚠️ category 的陷阱:category 只适合低基数列(不同取值 < 行数的 1/10)。如果一列有 100 万个不同值(比如user_id是字符串),转 category 反而更占内存(因为它要存一个 100 万元的类别表)。这种列应该直接astype("int32"/"int64")编码,或者就别转。
⑤ 预防建议
- 读表后第一件事:
df.memory_usage(deep=True).sort_values(ascending=False),看哪列最吃内存; - 低基数字符串列,读进来立刻
astype("category"); - 数值列默认是 int64/float64,按业务范围往下压(见第 8 章数据类型);
- 大表中间结果随时
del+gc.collect(),别让 Jupyter 的 cell 变量一直堆着。
7. 性能剖析工具箱:time / %%timeit / cProfile / df.info
当你不知道"慢在哪"时,必须用工具定位,而不是猜。下面四个工具按使用频率排序。
7.1 time.perf_counter:最简单的分段计时
import time
t0 = time.perf_counter()
df = pd.read_csv("big.csv")
print(f"读文件: {time.perf_counter()-t0:.2f}s")
t0 = time.perf_counter()
result = df.groupby("city")["amount"].sum()
print(f"groupby: {time.perf_counter()-t0:.2f}s")
📌 用途:在脚本里掐头去尾,一眼看出哪段最耗时。80% 的情况下,你不需要更复杂的工具。
7.2 Jupyter 里的 %%timeit:多次平均
# Jupyter cell 开头写:
%%timeit -n 10 -r 3
df.groupby("city", sort=False)["amount"].sum()
# 自动跑 10 次 × 3 轮,输出均值和标准差
📌 用途:对比两种写法谁更快。-n 10跑 10 次,-r 3重复 3 轮取最优。命令行脚本里用timeit.timeit()。
7.3 df.info(memory_usage='deep'):看内存
df.info(memory_usage="deep")
本机实跑输出样例:
<class 'pandas.DataFrame'>
RangeIndex: 20 entries, 0 to 19
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 name 20 non-null str
1 age 20 non-null int64
2 score 20 non-null float64
dtypes: float64(1), int64(1), str(1)
memory usage: 732.0 bytes
📌 注意:必须加 memory_usage="deep",否则字符串列只显示指针大小(几十字节),会严重低估。7.4 cProfile:找到罪魁祸首函数
当你掐段计时发现"整个脚本慢",但不知道慢在哪个函数里,用 cProfile:
import cProfile, pstats, io
pr = cProfile.Profile()
pr.enable()
# ↓ 这里放你要剖析的代码
df.groupby("b")["a"].sum()
df["a"].apply(lambda x: x * 2)
# ↑
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumtime")
ps.print_stats(10) # 打印累计时间 top10
print(s.getvalue())
本机实跑(200 行小表跑 20 次循环)输出关键片段:
ncalls tottime percall cumtime percall filename:lineno(function)
20 0.000 0.000 2.578 0.129 pandas/core/series.py:4960(apply)
20 0.004 0.000 2.578 0.129 pandas/core/apply.py:1502(apply)
20 1.612 0.081 2.484 0.124 pandas/core/base.py:996(_map_values)
📌 怎么读这张表:按cumtime(累计时间)排序;上面这张表里apply一行占了 2.578 秒,而整个程序才 2.855 秒——90% 的时间都耗在 apply 上,一眼定位改成向量化即可。
📌 剖析流程速记:① 先用time.perf_counter给大段代码掐表,定位到"慢在哪一步";② 用df.info(memory_usage='deep')看是不是内存问题;③ 还定位不到,上 cProfile 按cumtime排序,top1 就是凶手;④ 改完再掐一次表对比。80% 的情况第①步就够了。
8. 向量化 vs 循环:1e6~1e7 实跑对比表
这一节把本机实测数据汇总成一张表,让你对"快和慢到底差多少"有直观体感。环境:Windows / Python 3.14.7 / pandas 3.0.6 / numpy 2.5.3,计算任务为 a*2+b(两列浮点数)。
| 行数 n | 写法 | 耗时 | 相对向量化倍数 |
|--------|------|------|----------------|
| 1,000,000 | 向量化 df["a"]*2+df["b"] | 8.34 ms | 1×(基准) |
| 1,000,000 | Python zip 循环 | 614 ms | 74× 慢 |
| 1,000,000 | df.apply(..., axis=1) | 11,629 ms | 1394× 慢 |
| 10,000,000 | 向量化 | 83.27 ms | 1×(基准) |
| 10,000,000 | Python zip 循环 | 5,275 ms | 63× 慢 |
| 10,000,000 | apply(按 1e6 比例外推) | ≈ 121,800 ms(≈ 2 分钟) | ≈ 1460× 慢 |
📌 从这张表要记住的三件事:
1. 向量化是常数级别的快——100 万行 8 毫秒,1000 万行 83 毫秒,几乎线性;
2. apply 比向量化慢 1000 倍以上——100 万行就要 11.6 秒,1000 万行直接 2 分钟;
3. 连纯 Python zip 循环都比 apply 快 20 倍——说明 apply 的开销主要在 pandas 包装每行成 Series 的"调度税",而不是你的函数本身。
一致性校验:三种写法算出来的结果完全相同(max|vec-apply|=0、max|vec-loop|=0),所以改写没有任何代价,纯粹是赚时间。
9. 报错排查通用方法论(本篇重点)
前面 2~6 节给的是"具体病的药方"。这一节给你的是"看病的流程"——不管下次遇到什么报错,按这套流程走,90% 的问题都能自己解决,不用去问别人。
9.1 原则一:从 Traceback 的最后一行往上读
新手看 Traceback 常常从头开始读,被中间一堆 pandas 内部堆栈吓住。正确读法是倒着读:先看最后一行(异常类型 + message),再往上找第一个属于你自己代码的帧(不是 pandas/... 路径)。
本机实跑一个拼错列名的例子:
Traceback (most recent call last):
File "mini45.py", line 23, in <module>
df["nam"] ← 你的代码:第 23 行
~~^^^^^^^
File ".../pandas/core/frame.py", line 4378, in __getitem__
...
KeyError: 'nam' ← 最后一行:异常类型 + message
📌 你只需要关心两行:df["nam"](你写的)和KeyError: 'nam'(pandas 说的)。中间那一长串 pandas 内部代码直接跳过。拼错方法名时 pandas 3.0 还会贴心提示Did you mean: 'drop_duplicates'?,照抄即可。
9.2 原则二:构造最小复现(MRE, Minimal Reproducible Example)
当报错很复杂、你不确定根因时,逐步删代码,直到报错仍然存在但代码已经短到不能再短。操作要点:
1. 把出错的那段代码原样复制出来,能稳定复现;
2. 删掉所有跟报错无关的代码(画图、打印、写文件……);
3. 把 DataFrame 换成 3~5 行假数据,看报错还在不在;
4. 换假数据报错消失 → 是数据本身的问题(去看 df.head()、df.dtypes);还在 → 是写法问题,对着几行假数据调试即可。
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
df["c"] = df["a"] + df["b"]
# 在这个 4 行片段里调试,5 分钟搞定
📌 MRE 的价值:90% 的报错,当你缩到 10 行以内时,答案自己就跳出来了——你只是被大段代码淹没了视线。
9.3 原则三:先看 dtype / shape / index / head
很多报错不是"逻辑错",而是"你以为数据长这样,其实它长那样"。在怀疑代码之前,先打印:
print(df.shape); print(df.dtypes); print(df.head())
print(df.columns.tolist()) # 所有列名,专治列名拼错
常见的"数据其实不是你想的那样":
| 你以为 | 实际是 | 典型后果 |
|--------|--------|----------|
| age 是数值列 | age 是 object(含 "N/A" 字符串) | df["age"].mean() 抛 TypeError |
| 表有 100 万行 | 表是空的(0 行) | groupby 结果空,merge 后全 NaN |
| 列名是 "user_id" | 列名是 "user_id "(尾部空格) | KeyError: 'user_id' |
| 索引是 0,1,2,... | 索引是日期字符串 | .iloc 和 .loc 用混 |
⚠️ 列名带空格/不可见字符是高频坑。用 df.columns.tolist() 一眼就能看到。9.4 原则四:用报错原文去搜
当自己实在看不出问题,复制报错最后一行(异常类型 + message)去搜,公式是:
pandas <异常类型> <关键 message 片段>
例如:KeyError: 'nam' → 搜 pandas KeyError column name;ValueError: could not convert string to float → 搜同样关键词;MemoryError: Unable to allocate → 加 read_csv large file。
看结果时重点:Stack Overflow 高赞回答通常就是标准答案;pandas GitHub issue 会标注"哪个版本修复了";高赞评论常补充"2.0+ 这个方法改名了"。
⚠️ 别直接复制答案跑——先看懂它在干嘛。旧答案里的df.append()、df.iteritems()在 pandas 3.0 里已删除,照抄会再报错。
9.5 原则五:修复后必须回归验证
改完代码,别高兴太早——加几个断言确认结果是对的:
df = pd.read_csv("huge.csv", usecols=["a", "b"])
df["c"] = df["a"] + df["b"]
assert df.shape == (1000000, 3), f"shape 不对: {df.shape}" # 形状没变
assert abs(df["c"].sum() - 旧脚本记录的总数) < 1e-6 # 求和对得上
assert df["c"].notna().all(), "c 列出现了 NaN" # 没引入新 NaN
📌 为什么要断言:把 apply 换成向量化时,很容易"快了但算错了"。加几个断言,等于给自己上保险。
10. 排查流程图(文字版)与速查表
10.1 文字版排查流程图
代码出问题了
│
├─► 有 Traceback?──是──► 读最后一行(异常+message)
│ │
│ ▼
│ 找你自己代码那一行
│ │
│ ▼
│ 打印 shape/dtypes/head/columns
│ │
│ ▼
│ 构造最小复现 MRE
│ │
│ ▼
│ 搜报错原文 → 修复 → 加断言
│
└─► 没报错但慢/卡/闪退?
│
┌────────────┴────────────┐
▼ ▼
慢(几十秒~分钟) OOM / Killed
│ │
▼ ▼
time 分段计时 df.info(deep=True)
│ │
▼ ▼
apply? groupby? 找最吃内存的列
│ │
▼ ▼
cProfile 找 top1 usecols/dtype/chunksize
10.2 排查顺序速查表
| 症状 | 第一步 | 第二步 | 常见根因 |
|------|--------|--------|----------|
| KeyError: 'xxx' | print(df.columns.tolist()) | 看列名是不是拼错/有空格 | 列名打错、尾部空格、读进来列名变了 |
| ValueError: could not convert ... to float | print(df.dtypes) | 看那列是不是 object,含了非数字字符串 | 数据里有 "N/A"、"未知" 这类脏值 |
| TypeError: unsupported operand | print(df["col"].dtype) | 看是不是拿 str 在做算术 | 该列被识别成字符串了 |
| MemoryError / 进程被杀 | df.info(memory_usage='deep') | 找最吃内存的列 | 全量读大 CSV、字符串列没压缩 |
| 跑得慢(几十秒) | time.perf_counter 分段 | 定位到慢在哪一步 | apply 逐行、字符串 groupby |
| 跑得慢(几分钟) | cProfile 按 cumtime | top1 函数就是凶手 | apply、iterrows、字符串操作 |
| 结果全是 NaN | df.head()、df.merge 后 shape | 看 join key 类型一致吗 | merge 两边 key 一个 int 一个 str |
| 改完代码结果不对 | 加 assert 对比旧结果 | 算 sum/shape/行数 | 向量化改写时漏了 NaN 处理 |
10.3 三句话心法
1. 先看数据,再看代码——80% 的"bug"是你以为数据长那样,其实不是;
2. 先最小复现,再调试——在 10 行代码里改,比在 500 行里改快 10 倍;
3. 先剖析,再优化——别凭感觉优化,用 cProfile 找到真正的瓶颈再动手。
11. 本章小结与自测清单
=== 本章核心 ===
1. 性能/内存问题不抛错,但更折磨人;定位靠工具(time/info/cProfile),不靠猜;
2. OOM 四板斧:usecols、dtype、chunksize、Parquet;
3. apply 比向量化慢 1000 倍以上,99% 都能改写;
4. groupby 慢:字符串键转 category + sort=False + 内置聚合函数;
5. 字符串列是内存隐形大户:low-cardinality 一律 category;
6. 调试五原则:倒读 Traceback、构造 MRE、看 dtype/shape/head、搜报错原文、改完加断言。
自测清单:
- [ ] 看到
Killed能立刻判断"这是 OOM,不是 pandas 报错"吗? - [ ] 能不查表说出
memory_usage(deep=True)里deep=True的作用吗? - [ ] 给你一张 10GB 的 CSV,你能说出三步内存优化组合拳吗?
- [ ] 看到
df.apply(..., axis=1)能本能地写出向量化替代吗? - [ ] groupby 慢的时候,三个开关(category/sort=False/内置聚合)你能立刻想到几个?
- [ ] Traceback 你是从第一行开始读,还是从最后一行开始读?
- [ ] MRE(最小复现)怎么构造,能给同事讲清楚吗?
- [ ] cProfile 输出按哪一列排序?top1 意味着什么?
12. 练习题
练习 1(诊断题):你运行 pd.read_csv("5GB日志.csv"),命令行直接打印 Killed,没有任何 Python Traceback。请回答:
(a)这是 pandas 的 bug 吗?为什么?
(b)你会怎么诊断和规避?给出至少三种方案。
练习 2(内存压缩):一张表有 1000 万行,其中:
user_id是 int64(范围 0~200 万);city是字符串,只有 15 个城市;score是 float64。
请写出压缩这三列 dtype 的代码,并估算压缩前后的内存差。
练习 3(向量化改写):下面这段代码在 200 万行上跑了 8 秒,请改写成向量化版本:
def f(r):
if r["a"] > 0 and r["b"] > 0:
return r["a"] + r["b"]
elif r["a"] > 0:
return r["a"]
else:
return 0
df["c"] = df.apply(f, axis=1)
练习 4(groupby 优化):500 万行数据,按字符串列 category(共 500 个不同取值)分组求 amount 的和。写出你能想到的最快写法,并说明每个参数为什么。
练习 5(Traceback 阅读):下面这段报错,请问:
(a)异常类型是什么?
(b)触发错误的是用户哪一行代码?
(c)最可能的根因和修复方式?
Traceback (most recent call last):
File "my_script.py", line 12, in <module>
df["total"] = df["price"] * df["qty"]
File ".../pandas/core/frame.py", line ...
TypeError: can't multiply sequence by non-int of type 'float'
练习 6(方法论):请用自己的话,把"报错排查五原则"(倒读 Traceback / MRE / 看 dtype shape head / 搜原文 / 加断言)串成一段你自己遇到问题时的"操作手册",不超过 200 字。
下一篇预告:第 46 章 NumPy 数组基础与创建 —— pandas 的底层就是 NumPy 数组,从这一章开始我们下沉到 NumPy,看清 pandas 里那些"快"到底是怎么实现的。
文章回复
0 条公开回复