前两天晚上快十一点,我在公司楼下奶茶店蹲着改报表,电脑风扇呼呼响,SQL跑得飞快,唯独Python那段代码死活跑不完。点开一看,果然是熟悉的味道:
for i in range(len(df)):
if df.loc[i, 'age'] > 18:
df.loc[i, 'is_adult'] = 1
else:
df.loc[i, 'is_adult'] = 0
是不是感觉很眼熟?很多人(包括我自己)第一次接触Pandas时,都习惯性地这么写。然而,一旦数据量上来,这种写法立刻会让你体验到什么叫“卡到怀疑人生”。
那么,为什么在循环里使用 loc/iloc 会如此之慢呢?
简单来说,df.loc[i, 'col'] 并不是像访问一个简单的数组元素那样轻量。每次调用,它背后都要经历索引对齐、类型检查、数据复制等一系列开销。想象一下,你让快递小哥每次只送一张纸,却要跑遍整条街的每一户。循环10次可能没感觉,但循环10万、100万次呢?性能开销会呈指数级增长。
正确的姿势:能不用循环就别用循环
针对上面那个按年龄打标签的例子,Pandas 的正确写法其实非常简洁:
import numpy as np
# 新列先给个默认值
df['is_adult'] = 0
# 条件索引 + 一次性赋值
df.loc[df['age'] >= 18, 'is_adult'] = 1
或者,用更直接的方式:
df['is_adult'] = np.where(df['age'] >= 18, 1, 0)
这里有几个关键点值得牢记,它们在实战中极其常用:
- 向量化比较:
df['age'] >= 18 会直接对整个Series进行比较,返回一个布尔值的 Series。
- 布尔索引:使用这个布尔
Series 作为 loc 的行选择器,可以一次性选中所有符合条件的行。
- 批量赋值:对选中的所有行进行统一赋值,而不是逐行修改。
这就是所谓的“向量化”操作:直接对整个数据块进行操作,彻底告别显式的 for 循环。
进阶场景:多字段复合计算
再举一个稍微复杂点的例子,比如电商场景中常见的计算“含税总价”并判断“是否为大额订单”。
如果按照传统思路,可能会写成这样:
for i in range(len(df)):
price = df.loc[i, 'price']
tax = df.loc[i, 'tax_rate']
total = price * (1 + tax)
df.loc[i, 'total_price'] = total
df.loc[i, 'is_big'] = 1 if total > 1000 else 0
但用 Pandas 的向量化思维,只需要两行:
df['total_price'] = df['price'] * (1 + df['tax_rate'])
df['is_big'] = (df['total_price'] > 1000).astype(int)
这里用到了一个小技巧:将布尔值 (True/False) 直接通过 .astype(int) 转换为 1 或 0,比写三元表达式更高效。
无法避免循环时,如何降低性能损耗?
当然,业务逻辑千变万化,总有一些场景我们“被迫”使用循环,例如:
- 需要为每一行数据调用一个外部API。
- 每一行的计算都依赖于一个极其复杂的规则引擎。
- 操作本身具有前后依赖关系,无法并行化。
如果真的必须循环,我们也要尽量减少性能损失。首要原则就是:避免在循环中频繁调用 loc/iloc。
至少可以优化成这样:
for i, row in df.iterrows():
# row 是一个包含该行数据的Series
score = complex_func(row['a'], row['b'])
df.at[i, 'score'] = score
或者,使用更快的 itertuples():
for row in df.itertuples():
score = complex_func(row.a, row.b)
df.at[row.Index, 'score'] = score
这里有两点需要注意:
itertuples() 通常比 iterrows() 更快,因为它返回的是类似命名元组的对象。
- 写回数据时,使用
.at / .iat 进行单值赋值,这比 loc/iloc 更加轻量。
不过,只要你看到代码中出现了 for i in range(len(df)): 这样的模式,就应该立刻警觉起来,思考是否有更优的向量化解法。
transform 方法在实际项目中堪称“性能救星”。举个例子:我们需要计算每个订单金额占该用户所有订单总额的比例。
很多人第一反应是写双层循环,或者在外面 groupby 后再进行复杂的合并。其实,transform 可以优雅地解决:
user_sum = df.groupby('user_id')['amount'].transform('sum')
df['ratio'] = df['amount'] / user_sum
transform('sum') 的作用是:先按 user_id 分组对 amount 求和,然后将这个“求和结果”广播回原始数据的每一行,并且自动完成了索引对齐。你直接用它做除法即可。整个过程完全没有显式循环,性能自然大幅提升。对于需要处理分组聚合后数据与原始行对应关系的场景,这属于非常高效的数据处理范式。
为什么要执着于改掉这个习惯?
因为我在实际工作中被它坑过太多次了。有一次,一个线上报告脚本逻辑很简单,就是对几百万行数据打标签,结果跑了十几分钟,运维都准备报警了。最后一看,核心代码居然就是:
for i in range(len(df)):
if df.loc[i, 'xxx'] == 'A':
df.loc[i, 'tag'] = '...'
我将它改写为纯向量化操作,并合理运用了一两个 groupby 后,运行时间从10分钟骤降到十几秒。业务方还以为我升级了服务器,其实只是优化了几行代码。
你可以从养成一个小习惯开始:只要在 Pandas 代码里看到这种模式——
for ...
df.loc[...]
就立刻给自己一个提醒:能否将这段操作转换为对整列或整块数据的操作? 如果确实无法避免,再考虑使用 itertuples + .at 的“减伤版”循环方案。
性能优化往往就藏在这些细节习惯里。不妨现在就翻翻你的项目,找出那些还在循环里使用 loc/iloc 的老代码,改掉一处,就能赚到一笔可观的性能提升。如果你在实践过程中遇到了其他有趣的Python或大数据性能案例,欢迎到云栈社区与其他开发者交流探讨。