处理过百万行订单明细之后,我才真正理解 Pandas 的“慢”,到底慢在哪个环节。第一次用 apply 跑一个逐行规则,跑了整整四十分钟,当场把我心态搞崩。后来慢慢排查才发现,问题并不在 Pandas 本身,而是我没搞清楚它背后的执行机制,也没用对工具。这篇不是教科书式教程,而是我从实际项目里总结出来的一整套 Pandas 加速链路——从定位瓶颈、缩 dtypes、优化读取,到重构循环和并行方案,每一步都有代码、有对比、有取舍,希望能帮你少走几个我踩过的坑。
1. 瓶颈根源:解释器开销与数据拷贝在如何拖慢 Pandas
1.1 逐行处理为什么这么贵
很多第一次接触 Pandas 的人都有一个直觉:DataFrame 是一张表,那我一行一行循环处理,就像在 Excel 里拖公式一样,应该没问题。但实际上这种直觉恰恰是性能问题的最大来源。
Pandas 底层依赖 NumPy 的数组,而 NumPy 的提速秘诀是“连续内存 + 编译好的 C 代码”。当你在 Pandas 里做 df["列A"] + 1 这种操作时,整个计算能直接进入 C 环境,一次循环跑完,效率极高。但一旦写 df.iterrows() 或 df.apply(..., axis=1),每一行都会被封装成一个全新的 Series 对象,交给 Python 解释器去处理。每封装一行,就要产生一遍对象开销、属性解析、类型检查。循环一万行还能忍,循环一百万行,解释器开销就会把 C 层带来的优势全部吃光。
我做过一个最简单的对照实验:构造一个 100 万行的 DataFrame,对同一列做“乘以 2”的操作。向量化写法 df["分数"] * 2 大概只要几毫秒;用 apply 写 df.apply(lambda row: row["分数"] * 2, axis=1) 直接变成几秒,差距在三个数量级以上。所以,网上很多“Pandas 很慢”的吐槽,本质上是“错误地用了 Python 层面循环处理表格”。
1.2 数据拷贝和链式索引的隐形代价
除了逐行循环,另一个容易忽视的瓶颈是链式索引。之前排查一个项目,同事写了这种代码:
python复制df[df["状态"] == "成功"]["金额"] = df[df["状态"] == "成功"]["金额"] * 0.9
这段看起来没什么问题,但实际工作流程是:先做一次布尔筛选,生成一个临时 DataFrame;再从这个临时 DataFrame 里取一列并尝试赋值。由于链式索引的中间结果为临时对象,Pandas 很可能先拷贝一份数据,最终赋值时又因为没有 copy() 或 loc 的明确语义,悄无声息地没写进原 DataFrame。这种问题不仅慢,而且会导致数据逻辑悄悄出错。
正确的做法是用 loc 一次完成筛选和赋值:
python复制df.loc[df["状态"] == "成功", "金额"] *= 0.9
loc 在内部会走统一的索引路径,而不是创建一串临时对象。你的代码如果大量使用链式索引,哪怕数据量不大,也会因为反复的内存分配变得很慢。数据量上亿时,一次多余的拷贝可能就是几十秒的差别。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前先观测:%timeit、memory_usage、line_profiler 的配合
2.1 先量化现状,再决定优化方向
我见过不少同事,上来就把循环换成 apply,或者加一行 pandarallel,结果性能没提升多少,代码反而更难维护。原因很简单:没有先定位真正的耗时代码。Pandas 性能优化最忌讳的就是“感觉哪里慢,就改哪里”。盲改一晚上,常常只能带来 10% 的提升,而用工具找一下,几分钟就能锁定 90% 的热点。
我常用的第一步是 %timeit。这个魔法命令适合测试单个表达式或函数耗时。比如我在对比不同分组聚合方式时,会这样写:
python复制%timeit df.groupby("用户ID")["金额"].sum()
%timeit df.groupby("用户ID")["金额"].apply(lambda x: x.sum())
%timeit 会自动执行多次,给出均值与标准差。注意它默认执行次数可能太少,对于很重的操作可以加参数:%timeit -n 1 -r 3,强制只跑一轮,避免等待太久。
2.2 用 memory_usage 找出吃内存的列
速度问题往往伴随着内存问题。DataFrame 占的内存越大,CPU 缓存命中率就越低,计算自然更慢。DataFrame.memory_usage(deep=True) 能列出每一列的真实内存占用,deep=True 会看透 object 类型列里的字符串本身占用。
我在处理用户行为日志时,经常看到一列“城市名称”,明明只有三十几个城市,却存了上百万条重复字符串,内存占用非常夸张。这时候 memory_usage 会直接暴露问题源头。另一个命令是 df.info(null_counts=True),它会直接汇总 DataFrame 整体的内存占用和 dtype 情况。这两条命令加在一起,足够帮你判断该不该做 dtype 收缩(后面第 3 节会展开)。
2.3 用 line_profiler 定位每一行代码的耗时
%timeit 只能测整段表达式,如果一段函数里有十行操作,你还是不知道哪行是元凶。这种场景我用 line_profiler。它不是标准库,需要先安装:
bash复制pip install line-profiler
以 IPython 或 Jupyter 为例,加载后给目标函数加上 @profile 装饰器,再执行:
python复制from line_profiler import LineProfiler
lp = LineProfiler()
lp.add_function(process_data) # 这里换成你的函数
lp.run('process_data(df)')
lp.print_stats()
输出会标明每一行的 Hits、Time、Per Hit、% Time,一眼就能看出哪一行占据了大头。我的一位同事曾经用 line_profiler 发现,最耗时的不是计算,而是他每次循环都调用了一次 DataFrame.shape,这个属性看起来无害,但在逐行循环里会被反复触发几千次,开销被无限放大。这种东西靠感觉根本找不到,必须靠 profile 来看。
3. dtype 收缩:把内存降下来,速度自然上去
3.1 为什么 64 位不一定比 32 位好
Pandas 默认会用最保守的方式推断类型:整数默认 int64,浮点默认 float64。如果你只是存年龄、数量、评分这类范围很小的数据,64 位其实是浪费。更关键的是,数据越窄,单位内存里能放下的元素越多,CPU 在加载和遍历时的效率也越高。这就像搬家时用小车多跑几趟和大车一次性拉完的区别。
我用一个真实场景说明:某个订单表有 800 万行,“用户年龄”列用 int64 存储,需要 64 MB;改成 int8 只需 8 MB,速度不止提升内存占用,连带着排序、分组、关联这些操作都会因为内存数据量变小而明显变快。
实操里我通常先看分布,再做收缩:
python复制df["年龄"].min(), df["年龄"].max()
# 如果范围在 -128~127 之间,就可以安全转成 int8
df["年龄"] = df["年龄"].astype("int8")
浮点也同理,只要精度够用,就转成 float32:
python复制df["金额"] = df["金额"].astype("float32")
在压测中,100 万行浮点列从 float64 降到 float32,内存占用减半,聚合计算耗时大约减少 20% 到 30%。别小看这 20%,大任务多跑几次累积下来的收益非常可观。
3.2 object 转 category,处理低基数字符串的利器
字符串是 Pandas 里最拖后腿的数据类型。Python 字符串不是固定大小对象,底层要维护引用计数和字符指针,内存开销远大于数值。如果某一列是“省份”“城市”“性别”“渠道”这种重复度很高的离散值,我会直接转成 category:
python复制df["渠道"] = df["渠道"].astype("category")
category 的本质是维护一个“类别编码表”,数据本身只保存整数编号,真实字符串只存一份。假设一个列有 1000 万行、20 个不同值,存 strings 可能要几百 MB,存 category 往往只需要几十 MB。分组、去重、排序时因为参与比较的是整数编码,速度也会有明显提升。
我自己的经验是,category 最有效的场景是“基数占总行数的 1% 以下”。如果十个值占了 100 万行,category 是妥妥的加速。如果一列全是唯一 ID,比如订单号、手机号,那转 category 反而会额外生成编码表,几乎没有收益,甚至更慢。所以转之前先看 nunique()。
还有一个容易踩的坑:category 列不能直接和字符串列拼接,否则结果会退化回 object,导致性能回退。比如 DataFrame 合并时,一边是 category,一边是普通 str,最好先统一类型再 merge。
3.3 datetime 类型要规范,别用 object 顶替
很多人习惯把日期列读进来当字符串处理,然后用 str.contains 去筛年份。这在几万行时没问题,到了几百万行,字符串比较远慢于 datetime 比较。正确做法是一开始就用 pd.to_datetime 转成真实的时间类型:
python复制df["下单时间"] = pd.to_datetime(df["下单时间"])
转成 datetime64[ns] 后,不仅能按时间范围快速筛选,还可以直接用 df.set_index("下单时间") 做时间序列切片,Pandas 在底层用的是纳秒级整数比较,速度根本不是字符串能比的。读取大文件时要注意,在 read_csv 里指定 parse_dates 参数往往比事后转换更快。
4. read_csv 与列裁剪:IO 阶段就要省时间
4.1 只加载你真正需要的列
有一类慢,根本不在计算阶段,而是在读取数据阶段。你一上来就把 80 列的 CSV 全部读进内存,哪怕后续只用 3 列,这 80 列也全部跑了一遍类型推断、内存分配、加载解析。最常见的优化就是把后续用不到的列直接过滤掉:
python复制df = pd.read_csv(
"orders.csv",
usecols=["订单号", "用户ID", "金额", "下单时间"],
)
usecols 能显著减少解析工作量。我在一个 300 列、600 万行的文件上测试过,只保留 10 列,读取时间从 25 秒降到 6 秒,内存占用降了一半以上。这几乎是零成本优化。如果你一开始不确定用哪些列,可以先只读第一行拿到列名列表:
python复制cols = pd.read_csv("orders.csv", nrows=0).columns.tolist()
然后再决定保留哪些列。用少量行先勘探结构,是避免反复试错的好习惯。
4.2 手动指定 dtype,跳过被高估的推断环节
CSV 文件没有类型信息,Pandas 读取时会对每一列做全量数据采样后推断类型。这个过程本身就有开销。如果你明确知道“用户ID”是字符串、“金额”是浮点,最好直接传给 dtype 参数:
python复制df = pd.read_csv(
"orders.csv",
dtype={"用户ID": "string", "金额": "float32"},
usecols=["用户ID", "金额"],
)
指定 dtype 之后,Pandas 可以减少一次扫描,也避免把“用户ID”猜成 int64 导致的问题。这里特别提醒:像用户 ID、订单号这种看起来像数字的字段,如果超过 int64 范围,或带前导零,Pandas 很容易解析错误。提前指定成 string 能规避很多脏数据坑。
4.3 engine="pyarrow" 与分块读取
Pandas 2.0 之后,read_csv 支持 engine="pyarrow",它走的是 Arrow C++ 实现,读取速度通常比默认的 C engine 更快,内存占用也更可控。我实测一个 1.2 GB 的 CSV,engine="c" 花了 21 秒,engine="pyarrow" 只要 11 秒。如果你的 Pandas 版本足够新,可以无脑试一把:
python复制df = pd.read_csv("orders.csv", engine="pyarrow")
如果你的内存连完整文件都放不下,那就别奢望一次性读入了,用 chunksize 分块读,每块处理完立刻释放:
python复制chunk_iter = pd.read_csv("orders.csv", chunksize=100_000)
result_parts = []
for chunk in chunk_iter:
part = chunk.groupby("用户ID")["金额"].sum()
result_parts.append(part)
final = pd.concat(result_parts).groupby(level=0).sum()
分块读不是一个让代码变复杂的方案,它是应对“文件比内存大”的兜底手段。实际业务里,我更推荐把中间结果保存成 Parquet 格式,而不是反复读 CSV。Parquet 是列式存储,有压缩,还保留 dtype 信息,读入速度能比 CSV 快好几倍:
python复制df.to_parquet("orders.parquet")
df = pd.read_parquet("orders.parquet")
如果你每天都要处理同一批数据,第一次花时间去转存成 parquet,后面重复读取省下的时间是很划算的。
5. 把 apply 换成向量化:最值得投入的一次重构
5.1 用 np.where 与 np.select 替代条件函数
apply 最常见的用途是根据多列条件生成新列。比如门店销售数据里,要根据“销售额”和“退货标记”打标:
python复制def mark(row):
if row["退货标记"] == 1:
return "退货"
if row["销售额"] > 10000:
return "高额"
return "普通"
df["标签"] = df.apply(mark, axis=1)
这段代码在 10 万行上可能还能忍,到了千万级数据,每行都调用一次 Python 函数,耗时是按小时算的。向量化写法是用 np.select 一次性给出条件列表和结果列表:
python复制import numpy as np
conditions = [
df["退货标记"] == 1,
df["销售额"] > 10000,
]
choices = ["退货", "高额"]
df["标签"] = np.select(conditions, choices, default="普通")
np.select 会把条件转成布尔数组,在 C 层完成扫描,速度比 apply 快几十倍。这应该成为你处理多条件分箱的第一选择。
如果只是两分支条件,用 np.where 更轻量:
python复制df["是否大单"] = np.where(df["销售额"] > 10000, "大单", "普通")
5.2 字符串批量操作和 str 访问器
Pandas 的 str 访问器把字符串方法向量化了,这点常被忽略。比如要把手机号掩码,最慢的写法是 apply(lambda x: x[:3] + "****" + x[7:]),快一点的是:
python复制df["手机号"] = df["手机号"].astype("string")
df["手机号掩码"] = df["手机号"].str.slice_replace(3, 7, "****")
str.contains 做筛选也比循环快得多。但要注意,str 访问器底层仍是对每个元素做 Python 字符串操作,只是省去了外层的 Python 循环。它的性能优于 apply,但仍然比不上纯数值运算。所以能先把字符串转成 category 或数值编码,就尽量转。
我还经常用 str.extract 从文本列里抽字段:
python复制df["套餐"] = df["订单备注"].str.extract(r"(基础版|高级版|旗舰版)")
这种正则提取放在 apply 里会慢到怀疑人生,用 str.extract 一次操作就能完成。
5.3 groupby transform 代替手动聚合关联
新手经常这样写:分组求平均值,然后关联回原表,把均值当新列:
python复制grouped = df.groupby("城市")["销售额"].mean()
df["城市均额"] = df["城市"].map(grouped)
map 已经很不错了,但更标准的向量化做法是用 groupby.transform:
python复制df["城市均额"] = df.groupby("城市")["销售额"].transform("mean")
这段代码不仅简洁,而且不需要单独维护一个映射 Series。Pandas 会把 groupby 和 transform 合并成一次扫描,减少临时对象。性能上,当分组多、数据量大时,transform 比“先 groupby 再 map”更稳。
5.4 apply 并非一无是处
把 apply 说得一无是处也不公平。如果你要调用的是无法向量化的第三方库函数,比如对每一行做日期解析、调用业务模型预测、或者处理自定义复合逻辑,那 apply 就是最直接的选择。这时要做的不是消灭 apply,而是收缩它的执行范围。可以先用向量化手段把数据粗筛一遍,把需要复杂逻辑的行数降到最低,再在少量行上 apply。我通常会把“能向量化的 95%”和“必须逐行的 5%”拆开处理,整体性能就能兼顾。
6. 并行计算的选择:pandarallel、Dask、Modin 的真实对比
6.1 先问自己:瓶颈是 CPU 还是 IO
想用并行的时候,先做一个判断:你的瓶颈是 CPU 密集,还是 IO 密集?
- 如果是 CSV 加载慢,那并行要解决的是磁盘读取,单纯把
read_csv放在多进程里不会有明显收益,磁盘 IO 才是瓶颈。 - 如果是逐行计算、分组聚合、字符串处理这类 CPU 密集操作,那么多核并行确实有用。
6.2 pandarallel:最简单的 apply 并行化
pandarallel 是给 apply 提速最省事的库。安装后用一行代码激活:
python复制pip install pandarallel
python复制from pandarallel import pandarallel
pandarallel.initialize(progress_bar=True)
df["标签"] = df.parallel_apply(mark, axis=1)
它会自动把 apply 的任务拆分到多个 CPU 核心上并行处理。要注意的是,它只能在 Linux 和 macOS 上使用,Windows 会有兼容问题。而且每个进程都会复制一份父进程的 DataFrame,如果你的 DataFrame 已经超过内存,并行会直接把你内存撑爆。
我用 pandarallel 处理过 200 万行的 apply,在 8 核心机器上,耗时从单核的约 5 分钟降到约 1 分半,收益很明显。但后来数据涨到 2000 万行后,我放弃了它,转成分块 + multiprocessing 的处理方式,因为单次复制 DataFrame 的开销已经抵消了并行收益。
6.3 Dask:适合更大数据的“延迟版 Pandas”
Dask 很像 Pandas,但它把 DataFrame 切分成多个分区,只有在触发计算时才真正执行。如果你把 100 GB 的数据当 Pandas 单机处理,内存和算力都不够,这时考虑 Dask 是合理的:
python复制import dask.dataframe as dd
ddf = dd.read_csv("orders/*.csv", assume_missing=True)
result = ddf.groupby("用户ID")["金额"].sum().compute()
Dask 的特点是惰性求值和分布式调度,对于“大文件聚合统计”这类场景非常好用。但它没有完全实现 Pandas 的全部 API,有些边界操作会报错。我在实际项目里只把它用在数据清洗和聚合阶段,一旦需要复杂的逐行逻辑,我仍然会把少量数据取回本地再处理。
6.4 Modin:模拟 Pandas API 的并行引擎
Modin 让你几乎不改变代码就能获得并行能力,它通过 Ray 或 Dask 作为后端:
python复制import modin.pandas as pd
之后所有 pd.read_csv、df.groupby 都变成并行的。听上去很方便,但我遇到过一些库和 Modin 类型不兼容的情况,尤其在 to_parquet、pivot_table 这些操作上,会多出不少小坑。我的态度是:Modin 适合快速试水,如果项目对 Pandas API 兼容性要求很高,还是谨慎引入。
6.5 多进程实现更可控的并行
如果只是想对 100 个文件并行做同一套清洗逻辑,不一定要引入大框架。Python 标准库 multiprocessing 就够用:
python复制from multiprocessing import Pool
def clean_file(file_name):
df = pd.read_csv(file_name)
df = df.dropna(subset=["金额"])
df["金额"] = df["金额"].astype("float32")
return df
with Pool(processes=4) as pool:
results = pool.map(clean_file, file_list)
final_df = pd.concat(results, ignore_index=True)
这里要注意,Pool.map 会把结果汇总回主进程,如果单个结果很大,内存还是会吃紧。更稳妥的做法是让每个进程直接把结果写到 parquet 文件,最后再统一读取合并,避免中间结果同时驻留内存。
最后再说一个我的心法:优化的顺序永远是“先降内存,再减拷贝,然后向量化,最后才并行”。很多人一上来就上并行,结果内存翻倍,IO 变高,速度反而更慢。先做 dtype 收缩和列裁剪,往往就能解决 80% 的慢问题,并行只是最后那块拼图。这些技巧我在多个千万级数据项目里反复验证过,按这个顺序走,基本不会踩到无解的大坑。
