写在前面
上一篇把多层索引讲完了,这一篇聊个更常见的问题:代码能跑,但跑得让人想砸键盘。
我见过太多这样的脚本——三万行订单,for循环一行行算金额、一行行判等级,跑完要一分多钟;同样的活,向量化写法几百毫秒就结束。差的不是电脑,是写法。Pandas 底下的 NumPy 本来就是为「整列一起算」设计的,你一旦自己套一层 Python 循环,等于把高速公路换成乡间土路,还在每个路口设了收费站。
更隐蔽的问题是:小数据上感觉不出来。1000 行的时候iterrows也就 20 毫秒,你觉得没问题;等真实数据涨到 30 万行,同一段代码要跑一分半,你才开始怀疑人生——而此时它已经上线了。
这一篇的口径很直接:把几种写法摆在一起计时,看差距到底多大、差在哪一层,再看哪些场景确实只能循环、怎么把循环的影响压到最低。
先确认示例数据在手边:
importosimportmkdataifnotos.path.exists("data/sales.csv"):mkdata.main()print("示例数据就绪:",os.path.exists("data/sales.csv"))这段会检查第 1 篇生成的data/目录在不在,不在就自动补一次;已经有数据时只会打印一行示例数据就绪: True,不影响后面的结果。
一、先摆擂台:四种写法算同一列金额
造 5 万行销售数据,分别用for + iterrows、apply、map、纯列运算算一遍「数量 × 单价」。计时用time.perf_counter()——它比time.time()精度高,是给短代码计时的标准工具。
importtimeimportnumpyasnpimportpandasaspd rng=np.random.default_rng(7)n=50000df=pd.DataFrame({"数量":rng.integers(1,8,n),"单价":rng.choice([29.9,49.9,89.0,599.0],n),"城市":rng.choice(["杭州","上海","北京","广州"],n),})print("行数:",len(df))deftimeit(label,fn):t0=time.perf_counter()result=fn()cost=time.perf_counter()-t0print(f"{label:<22s}{cost*1000:>9.2f}ms")returnresult,cost# ① for + iterrows:最直观,也最慢defby_iterrows():out=[]for_,rowindf.iterrows():out.append(row["数量"]*row["单价"])returnout# ② apply:写法短,但仍然是逐行调 Python 函数defby_apply():returndf.apply(lambdar:r["数量"]*r["单价"],axis=1)# ③ map:只能作用在单列上,适合查表defby_map():returndf["城市"].map({"杭州":1.0,"上海":1.05,"北京":1.02,"广州":0.98})# ④ 向量化:整列一起算defby_vector():returndf["数量"]*df["单价"]r1,c1=timeit("for + iterrows",by_iterrows)r2,c2=timeit("apply(axis=1)",by_apply)r3,c3=timeit("map(单列查表)",by_map)r4,c4=timeit("向量化相乘",by_vector)print("结果一致:",round(sum(r1),2)==round(r4.sum(),2)==round(r2.sum(),2))print(f"向量化比 iterrows 快约{c1/c4:.0f}倍,比 apply 快约{c2/c4:.0f}倍")行数: 50000 for + iterrows 684.26 ms apply(axis=1) 161.73 ms map(单列查表) 5.25 ms 向量化相乘 0.23 ms 结果一致: True 向量化比 iterrows 快约 2999 倍,比 apply 快约 709 倍图 1:同一份 5 万行数据,四种写法的耗时(对数刻度)。for + iterrows 约 750 ms,向量化只要 0.2 ms 级别。
差距是数量级的。原因拆开看就三句话:
iterrows每行都要构造一个Series——不是「取一行」那么简单,而是新建对象、填数据、绑索引。5 万行就是 5 万次对象构造,光这一项就吃掉绝大部分时间;apply(axis=1)好一点,但本质仍是逐行调 Python 函数:每行一次函数调用、一次参数打包,Python 的函数调用开销在数据量面前会被放大到不可忽视;- 向量化是 NumPy 在 C 层循环:一次算完整列,中间没有 Python 对象进出,所以能快几千倍。
经验阈值:1 万行以内
apply可以接受;10 万行以上该盯上它;iterrows除了调试打印,基本不该出现在生产脚本里——它连「读」都不是最快的(要读得快用itertuples)。
二、apply 什么时候还该用
别把apply一棍子打死。它适合逻辑复杂、又确实逐行算的场景,比如要引用整行多个字段做多分支判断:
importnumpyasnpimportpandasaspd df=pd.read_csv("data/sales.csv",encoding="utf-8-sig")# 场景:金额、数量、城市三个字段一起参与的打分规则defscore(row):base=0ifrow["金额"]>=500:base+=3elifrow["金额"]>=100:base+=2ifrow["数量"]>=5:base+=2ifrow["城市"]=="杭州":base+=1returnbase df["客户分"]=df.apply(score,axis=1)print(df[["城市","数量","金额","客户分"]].head(8).to_string(index=False))print("----")print("分数分布:")print(df["客户分"].value_counts().sort_index().to_string())城市 数量 金额 客户分 杭州 3 149.7 3 上海 5 149.5 4 上海 5 149.5 4 北京 2 178.0 2 杭州 4 199.6 3 广州 6 NaN 2 广州 6 179.4 4 北京 1 49.9 0 ---- 分数分布: 客户分 0 2 1 1 2 4 3 3 4 3 5 2 6 1这段用向量化写会很啰嗦(三个np.where嵌套),而apply一眼能看懂规则——可读性也是性能的一部分,因为三个月后维护代码的是你。
判断标准:「只依赖本行、规则能用两三个条件表达」→ 向量化;「规则是一张多分支表」→apply;「规则可以做映射」→map/replace。
三、map 和 replace:查表别写 if
「把城市映射成系数」「把省份映射成大区」这类活最容易被写成apply里套if,其实一个字典就够了。
importpandasaspd df=pd.read_csv("data/sales.csv",encoding="utf-8-sig")# 城市 → 折扣系数:字典映射,未命中的给 NaNcoef={"杭州":1.0,"上海":1.05,"北京":1.02,"广州":0.98}df["系数"]=df["城市"].map(coef)print(df[["城市","系数"]].drop_duplicates().to_string(index=False))print("映射后空值个数:",df["系数"].isna().sum())print("----")# 未命中想给默认值:先 map 再 fillnadf["系数2"]=df["城市"].map(coef).fillna(1.0)print("fillna 之后空值:",df["系数2"].isna().sum())print("----")# replace:按值替换,支持字典和列表df2=df.copy()df2["产品"]=df2["产品"].replace({"A-笔记本":"笔记本","B-鼠标":"鼠标"})print(df2["产品"].value_counts().to_string())城市 系数 杭州 1.00 上海 1.05 北京 1.02 广州 0.98 NaN NaN 映射后空值个数: 1 ---- fillna 之后空值: 0 ---- 产品 鼠标 5 笔记本 4 C-键盘 4 D-显示器 3三条区别记牢:
map只接受「一个值 → 一个值」,传 Series 也行(按索引对齐),但只作用在单列;replace支持字典、列表、正则,把"A-笔记本"这类带前缀的值批量改成干净名字时最好用;map查不到的键返回 NaN,别忘了后面接fillna——这就是第 6 篇那个「凭空冒 NaN」的老朋友。
四、条件赋值三件套:np.where、np.select、pd.cut
「金额大于 500 标记为大单」「按分数分段」,用if/elif写会很啰嗦,而且慢。三个工具分工明确:
importnumpyasnpimportpandasaspd df=pd.read_csv("data/sales.csv",encoding="utf-8-sig")# ① 二选一:np.where(条件, 真值, 假值)df["是否大单"]=np.where(df["金额"]>500,"大单","普通")print(df["是否大单"].value_counts(dropna=False).to_string())print("----")# ② 多选一:np.select(条件列表, 结果列表, default)conds=[df["金额"]>=1000,df["金额"]>=300,df["金额"]>0]choices=["大额","中额","小额"]df["档位"]=np.select(conds,choices,default="异常/缺失")print(df["档位"].value_counts().to_string())print("----")# ③ 分箱:pd.cut 把连续值切段,自带区间标签df["金额区间"]=pd.cut(df["金额"],bins=[0,100,300,1000,np.inf],labels=["0-100","100-300","300-1000","1000+"],)print(df["金额区间"].value_counts().sort_index().to_string())是否大单 普通 13 大单 3 ---- 档位 小额 9 异常/缺失 3 中额 3 大额 1 ---- 金额区间 0-100 2 100-300 7 300-1000 3 1000+ 1np.where最快,只能二选一,适合打标、清洗时的「是/否」;np.select按顺序匹配第一个 True,等价于if/elif/else链,注意条件顺序要从严格到宽松——写反了「金额 > 0」会把所有正数都吃掉;pd.cut是分箱专用,labels给区间起名,导出报表时比(0, 100]好看得多;要做「等频分箱」用pd.qcut。
一个容易忽略的细节:np.where返回的是numpy 数组,赋回 DataFrame 时是「按位置」对齐的。原表索引不是 0…n-1 时,稳妥写法是pd.Series(np.where(...), index=df.index)。
五、整表替换:把一段处理流程向量化
真实的性能问题往往不是「一个公式慢」,而是「一段流程里塞了五个循环」。下面这段把「补金额 → 处理负数 → 分级 → 映射系数」四步全用向量化重写:
importtimeimportnumpyasnpimportpandasaspd raw=pd.read_csv("data/sales.csv",encoding="utf-8-sig")t0=time.perf_counter()df=raw.copy()df["金额"]=df["金额"].fillna((df["数量"]*df["单价"]).round(2))# 补缺失df["金额"]=df["金额"].clip(lower=0)# 处理负数df["等级"]=np.select([df["金额"]>=1000,df["金额"]>=300,df["金额"]>0],["A","B","C"],default="D",)df["城市系数"]=df["城市"].map({"杭州":1.0,"上海":1.05,"北京":1.02,"广州":0.98}).fillna(1.0)df["调整后金额"]=(df["金额"]*df["城市系数"]).round(2)cost=time.perf_counter()-t0print(df[["城市","金额","等级","城市系数","调整后金额"]].head(6).to_string(index=False))print("----")print(f"16 行四步处理耗时{cost*1000:.3f}ms")print("等级分布:")print(df["等级"].value_counts().sort_index().to_string())城市 金额 等级 城市系数 调整后金额 杭州 149.7 C 1.00 149.70 上海 149.5 C 1.05 156.98 上海 149.5 C 1.05 156.98 北京 178.0 C 1.02 181.56 杭州 199.6 C 1.00 199.60 广州 179.4 C 0.98 175.81 ---- 16 行四步处理耗时 2.247 ms 等级分布: 等级 A 2 B 3 C 10 D 116 行当然看不出性能差异,但把这段拿去跑 30 万行,依然是毫秒级——因为每一步都是整列运算,没有一次 Python 循环。这就是向量化的真正价值:数据规模涨 100 倍,耗时几乎不变。
六、真需要循环的场景:别硬套
向量化不是万能。下面三种情况老老实实写循环(或用numba/cython):
- 下一行依赖上一行的结果,比如累计持仓、递归计算、状态机;
- 每行要调用外部接口(查库、请求 API、读文件),瓶颈在 IO 不在计算;
- 逻辑复杂到向量化写出来没人看得懂,维护成本比性能更贵。
第一种最常见,Pandas 里其实有对应工具:shift/cumsum/cummax/rolling能把很多「伪循环」写成向量化。
importpandasaspd stock=pd.read_csv("data/stock.csv",encoding="utf-8-sig")stock["日期"]=pd.to_datetime(stock["日期"])s=stock.set_index("日期")["收盘价"].sort_index().head(8)print(pd.DataFrame({"收盘价":s,"昨日收盘":s.shift(1),"日涨跌":(s-s.shift(1)).round(2),"累计最高":s.cummax(),"5 日均":s.rolling(5).mean().round(2),}).to_string())收盘价 昨日收盘 日涨跌 累计最高 5 日均 日期 2024-01-02 100.00 NaN NaN 100.00 NaN 2024-01-03 100.48 100.00 0.48 100.48 NaN 2024-01-04 100.04 100.48 -0.44 100.48 NaN 2024-01-05 98.62 100.04 -1.42 100.48 NaN 2024-01-08 97.89 98.62 -0.73 100.48 99.41 2024-01-09 96.30 97.89 -1.59 100.48 98.67 2024-01-10 96.40 96.30 0.10 100.48 97.85 2024-01-11 98.54 96.40 2.14 100.48 97.55如果确实要循环、又嫌慢,还有两个折中:itertuples()比iterrows快 5-10 倍(用命名元组代替 Series);重计算的部分可以用numba.njit包起来,对纯数值循环能再快一个量级。
七、一张表决定怎么写
| 需求 | 推荐写法 | 别用什么 |
|---|---|---|
| 两列相乘、相除 | df["a"] * df["b"] | iterrows |
| 单列查表(字典映射) | .map(dict).fillna(默认值) | apply里套if |
| 批量替换文本 | .replace({...})或.str.replace() | 手写循环 |
| 二选一打标 | np.where | apply+ lambda |
| 多档分级 | np.select | 嵌套if/elif |
| 连续值分段 | pd.cut/pd.qcut | 手写区间判断 |
| 上一行参与计算 | .shift()/.cumsum() | for循环 |
| 多字段复杂规则 | apply(axis=1) | 别用iterrows |
| 逐行调用外部接口 | 循环 +itertuples+ 进度条 | 假装能向量化 |
常见问题
Q1:改用向量化后结果变成 NaN 了,怎么回事?
三种常见原因:两列索引没对齐;其中一列是文本("3"而不是3);map的字典里没有对应键。先print(df.dtypes)看类型,文本列用pd.to_numeric(errors="coerce")转,map之后记得fillna。
Q2:SettingWithCopyWarning一直刷,怎么根治?
不要先切一片再赋值。改成一次性loc写法:df.loc[cond, "列"] = 值;或者先df = df.copy()明确独立副本再改。本质原因是「你在一个视图上写入」,pandas 怕你没意识到改了谁。
Q3:apply里用了print调试,结果没有输出?
说明apply根本没被执行到——最常见是axis写错(写成 0 会按列调用),或者函数里提前return了。调试建议先df.head(3).apply(...)跑小样本。
Q4:向量化写出来比 apply 还慢?
可能是每行都做了字符串操作(.str系列有额外开销),或者中间产生大量临时对象。先用%%timeit分段定位,再针对性优化,别凭感觉改。
小结
这一篇就一句话:Pandas 的列运算在 C 层跑,Python 的 for 循环在解释器里跑,两者差着上千倍。具体到写法:两列运算用向量化、查表用map/replace、打标用np.where/np.select、分段用pd.cut、上一行参与计算用shift/cumsum;真正只能循环的场景很少,遇到 IO 和复杂状态机也别硬套,切到itertuples已经是性价比很高的折中。
下一篇换个方向:数据不再只从 CSV 来——JSON、网页表格、SQLite 数据库,Pandas 各有各的读法,坑也各不相同。