8-1 向量化与apply:三档速度的康复训练 本节摘要:同一需求有循环、apply、向量化三档写法,速度差一到三个数量级。本节实测三档耗时、讲清差距的来源(解释器逐行调度与底层批量计算的对比)、划定apply的适用边界——它是复杂行逻辑的兜底,不是默认选项。 出院前最后一项训练。很多Pandas用户的代码"能跑但很贵":功能正确,百万行要等二十分钟。病灶高度一致——该向量化的地方写了循环。 三档实测 三档大约是"分钟级、百毫秒级、几毫秒级"的差距。来源:iterrows每行都要构造一个Series对象再拆开;apply至少省掉了对象构造,但解释器仍要逐行调用函数;向量化把整个操作交给NumPy底层,一批数据一次过。速度差不是玄学,是调度次数的差。
本节摘要:同一需求有循环、apply、向量化三档写法,速度差一到三个数量级。本节实测三档耗时、讲清差距的来源(解释器逐行调度与底层批量计算的对比)、划定apply的适用边界——它是复杂行逻辑的兜底,不是默认选项。
出院前最后一项训练。很多Pandas用户的代码"能跑但很贵":功能正确,百万行要等二十分钟。病灶高度一致——该向量化的地方写了循环。
import pandas as pd import numpy as np import time n = 1_000_000 df = pd.DataFrame({'金额': np.random.randint(100, 10000, n)}) # 第一档:python循环(iterrows是最贵的遍历方式) t0 = time.perf_counter() result = [] for _, row in df.iterrows(): result.append(row['金额'] * 1.1) s1 = pd.Series(result) t1 = time.perf_counter() print(f'iterrows循环:{t1 - t0:.2f}秒') # iterrows循环:78.53秒(量级示意,机器不同有差异) # 第二档:apply逐行 t0 = time.perf_counter() s2 = df['金额'].apply(lambda x: x * 1.1) t1 = time.perf_counter() print(f'apply:{t1 - t0:.2f}秒') # apply:0.31秒 # 第三档:向量化 t0 = time.perf_counter() s3 = df['金额'] * 1.1 t1 = time.perf_counter() print(f'向量化:{t1 - t0:.4f}秒') # 向量化:0.0061秒
三档大约是"分钟级、百毫秒级、几毫秒级"的差距。来源:iterrows每行都要构造一个Series对象再拆开;apply至少省掉了对象构造,但解释器仍要逐行调用函数;向量化把整个操作交给NumPy底层,一批数据一次过。速度差不是玄学,是调度次数的差。
# 需求一:多条件派生列。循环思维 vs 向量化 df['等级'] = np.where(df['金额'] > 5000, '大单', '普通单') df['等级'] = np.select( [df['金额'] > 8000, df['金额'] > 3000], ['特大单', '中大单'], default='普通单') # 需求二:映射。字典映射用map,天然向量化 level_map = {'大单': 3, '普通单': 1} df['分数'] = df['等级'].map(level_map) # 需求三:字符串列整列处理。str访问器就是向量化的 names = pd.Series([' alice ', 'BOB', ' Carol ']) print(names.str.strip().str.lower().tolist()) # ['alice', 'bob', 'carol'] # 需求四:逐列复杂逻辑。apply加axis=0按列、axis=1按行 def grade(row): # 确实需要多字段联合判断的复杂规则 if row['等级'] == '特大单' and row['金额'] % 2 == 0: return 'A' return 'B' # df['评级'] = df.apply(grade, axis=1) # 能用,但先问能不能向量化
apply不是耻辱柱,教条消灭它会让代码走向没人读得懂的嵌套向量化。我的判断标准有三条:涉及多列联合的复杂分支逻辑、表达式写不清的规则、数据量在十万行以内——三条都满足,apply完全体面。反过来,单纯算术、映射、字符串处理、条件派生这四类需求,apply就是浪费。
# 体面的apply示例:规则真的复杂 def discount_scheme(row): """会员折扣与渠道折扣叠加,且互斥规则随档位变化""" base = 0.95 if row['等级'] == '金卡' else 0.98 if row['渠道'] == '线上' and row['等级'] == '金卡': return min(base + 0.03, 0.99) # 折扣封顶 return base # df['折扣'] = df.apply(discount_scheme, axis=1)
⚠️ 常见坑:axis=1的apply每行构造一个Series,百万行时它就是慢版循环的亲戚。另见两个替代品:np.select管多分支派生,df.itertuples()在必须循环时比iterrows快得多且保留字段访问。

康复训练不能靠感觉开药。定位工具在Jupyter里是现成的:%timeit逐行计时,%prun看函数级热点。实战姿势:先把整段流水线按阶段切成几个单元格,每格头尾各打一个时间戳,慢段立刻现形;再对慢段逐行timeit。十个案子里九个慢在循环与apply,剩下一个是把全量数据重复读了多遍——读IO和逐行调度是两大惯犯,先查这两个再怀疑Pandas本身。
import time t0 = time.perf_counter() result = df['金额'] * 1.1 # 被测代码段 print(f'耗时 {time.perf_counter() - t0:.4f} 秒') # Jupyter里的更精准姿势: # %timeit df['金额'] * 1.1 # 多次运行取统计 # %%prun # 单元格级性能剖析
测出来不慢就别优化。没有基线的优化是玄学,有基线的优化才是工程。
下一节是出院前最后一关:query与内存优化。