先讲个真事儿。前两天晚上十一点多,我正准备关电脑回家,我们组那个小李突然在工位后面喊一句:“哥,我这脚本是不是又卡死了?”一看,他那段 pandas 脚本跑了快二十分钟,CPU 一直 100%,结果 DataFrame 还没处理完,数据量也就几十万行。
一打开代码,我就笑了:典型的“for + loc”炼丹大法。
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
你可能也写过类似的,对吧?逻辑一点问题没有,就是慢得离谱。问题不是循环本身,而是 「循环里频繁用 loc / iloc」。
为啥这么慢?简单说三点,别整太学术的:
第一,每次 df.loc[i, 'age'] 都要查一遍索引,相当于一直在问:“第 i 行你在哪?” 第二,df.loc[i, 'is_adult'] = ... 是一次一次的赋值,pandas 背后要做各种对齐、检查,很多看不见的活。 第三,你在 Python 的 for 里跑这些操作,等于让解释器在最慢的那层一行一行搬砖,完全没用到 pandas 本来擅长的“整列计算”。
所以一圈下来,你以为是 O(n),实际上所有隐形开销叠加上去,肉眼可见就慢。
那正常姿势应该怎么写?我当场就给小李改成这样:
df['is_adult'] = (df['age'] >= 18).astype(int)
一行,本地测了下,直接从几十秒变成几百毫秒级。这个就叫“向量化”:一次性对一整列算,而不是一行一行算。
再举个小李代码里的例子,他还写了这么一段拼全名的:
for i in range(len(df)):
df.loc[i, 'full_name'] = df.loc[i, 'first_name'] + ' ' + df.loc[i, 'last_name']
改完:
df['full_name'] = df['first_name'] + ' ' + df['last_name']
pandas 内部会帮你把字符串一列一列拼好,比你 for 循环快太多了。
有时候你可能说,我这个逻辑有点复杂,没法直接用一两行布尔表达式搞定,那也别第一反应就上 for + loc。至少还可以这样折中一下,用 apply:
def calc_level(row):
if row['score'] >= 90:
return 'A'
elif row['score'] >= 80:
return 'B'
else:
return 'C'
df['level'] = df.apply(calc_level, axis=1)
严格讲,apply(axis=1) 底层还是一行一行跑 Python 代码,但它至少不会在里面疯狂 .loc 回写,每一行会一次性取好整行数据传给你,少很多开销。真没办法的时候,用 apply 也比你手写 for + loc 要体面。
再狠一点,其实很多“看着复杂”的逻辑拆一拆也能变成向量化。比如按多个条件打标签,小李原来是这样:
for i in range(len(df)):
if df.loc[i, 'age'] >= 18 and df.loc[i, 'city'] == 'Beijing':
df.loc[i, 'tag'] = 'adult_bj'
else:
df.loc[i, 'tag'] = 'other'
我让他拆成两个布尔条件,用 np.where:
import numpy as np
mask = (df['age'] >= 18) & (df['city'] == 'Beijing')
df['tag'] = np.where(mask, 'adult_bj', 'other')
既清晰,又快。很多业务里的“规则表”,本质就是一堆条件组合,用布尔向量撸一撸就干净了。
那是不是以后完全不能循环了?也没这么绝对。有几种情况,真要在 Python 里绕一圈,那也尽量别用 loc/iloc:
比如你只是读,不写 DataFrame,可以用 itertuples():
for row in df.itertuples():
# row 是个 namedtuple,访问字段用 row.age、row.city
do_something(row)
itertuples() 会比 iterrows() 快一截,也比你 range(len(df)) 然后 loc 好得多。
又比如,你必须循环计算一个新列,但又想少点开销,可以先用普通 list 存结果,最后一次性塞回去,不要在循环里一行一行 loc 写:
values = []
for row in df.itertuples():
if row.age >= 18:
values.append(1)
else:
values.append(0)
df['is_adult'] = values
这种写法至少把“写回 DataFrame”这步从 n 次变成了一次。
还有一个细节顺便说下:如果你只是改单个标量,用 at / iat 会比 loc / iloc 轻量一点,比如:
df.at[10, 'age'] = 20 # 按标签
df.iat[5, 3] = 100 # 按位置
但说实话,真到了要在循环里一行一行调 at/iat 的程度,那整体思路八成还是可以改成向量化的,别把这个当成性能银弹。
回到那天晚上,小李看我把几段代码改成一两行向量化之后,脚本重新跑了一遍,还没去打个水就跑完了,他自己都愣住了。其实 pandas 本来就是为“整列、整块数据”设计的,你非要当成 list 去一行一行点 loc,就像拿挖掘机拧螺丝,工具没错,用法太折腾。
所以你以后只要记住一个小 checklist 就行了: 先想能不能用列运算、布尔过滤、where、groupby 之类搞定; 实在不行再考虑 apply; 最后万不得已再写 Python 循环,但别在循环里疯狂 loc/iloc 改 DataFrame。
行了,先这样,等你哪天碰到一个“跑一晚上”的 pandas 脚本,第一件事就去找找那个藏在角落里的 for i in range(len(df)) 吧。
希望这个小案例能帮你避开数据处理中的常见性能陷阱。如果你想了解更多类似的技术技巧和深度解析,可以关注我们的 云栈社区,那里有更多开发者分享的实战经验与避坑指南。