RSS
菜单
全部文章快讯开发科技深度热点

第 45 章 常见报错与排查大全(三):性能与内存问题与调试方法论

内容摘要

本篇是"常见报错与排查"三部曲的终章,也是整个系列的**重点篇**。前两章解决"代码直接抛错"的问题,本篇解决两类更棘手的问题:**① 性能与内存类问题**(不抛错,但慢到怀疑人生、或直接 OOM 被杀);**② 一套可迁移的报错排查方法论**——无论你下次遇到的是 KeyError、TypeError 还是一个诡异的 NaN,都能按同一套流程定位。前置知识:[第 16 章](16-性能优化与实战案例.md)性能优化总原则、[第 23 章](23-向量化性能优化大全.md)向量化技巧、[第 43 章](43-常见报错与排查大全(一):文件读取与索引类报错.md)与[第 44 章](44-常见报错与排查大全(二):类型与运算类报错.md)。学完后你能做到:看到慢/卡/被杀不再抓瞎,按流程图一步步定位瓶颈。

📖 本篇简介:本篇是"常见报错与排查"三部曲的终章,也是整个系列的重点篇。前两章解决"代码直接抛错"的问题,本篇解决两类更棘手的问题:① 性能与内存类问题(不抛错,但慢到怀疑人生、或直接 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 里那些"快"到底是怎么实现的。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 45 章 常见报错与排查大全(三):性能与内存问题与调试方法论》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。