news 2026/10/26 10:17:34

Python 数据处理进阶(十四)别再写 for 循环了:Pandas 向量化提速实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python 数据处理进阶(十四)别再写 for 循环了:Pandas 向量化提速实战

写在前面

上一篇把多层索引讲完了,这一篇聊个更常见的问题:代码能跑,但跑得让人想砸键盘。

我见过太多这样的脚本——三万行订单,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+ 1
  • np.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 1

16 行当然看不出性能差异,但把这段拿去跑 30 万行,依然是毫秒级——因为每一步都是整列运算,没有一次 Python 循环。这就是向量化的真正价值:数据规模涨 100 倍,耗时几乎不变。

六、真需要循环的场景:别硬套

向量化不是万能。下面三种情况老老实实写循环(或用numba/cython):

  1. 下一行依赖上一行的结果,比如累计持仓、递归计算、状态机;
  2. 每行要调用外部接口(查库、请求 API、读文件),瓶颈在 IO 不在计算;
  3. 逻辑复杂到向量化写出来没人看得懂,维护成本比性能更贵。

第一种最常见,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.whereapply+ 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 各有各的读法,坑也各不相同。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 10:15:24

谁动了我的对象?用 RustFS 审计日志把每次请求送到 SIEM

安全事件复盘往往卡在没证据&#xff1a;误删的工单、异常的大流量下载、被悄悄改掉的对象&#xff0c;事后要查谁、什么时间、从哪台机器、动了哪个键&#xff0c;对象存储默认只管存不管记。合规与审计留存的要求&#xff0c;把这件事从「能存」推到了「能查」。RustFS 的审计…

作者头像 李华
网站建设 2026/10/26 10:15:01

第78篇:三维体素-地层体素数据——让每个体块自己带上岩性和颜色

上一篇我们把 19 根钻孔插值成了一整块地层,结尾埋了个话头: 这条线刚开了个头。 CASE_LIST 里的下一站是三维体素-地层体素数据(#78)——本篇的体素是"插值出来的层号",下一篇要处理的是"体素数据本身":当每个体素带的不是一个层号,而是一组属性时…

作者头像 李华
网站建设 2026/10/26 10:15:01

计算机存储器层次结构+主存原理

计算机组成原理&#xff5c;存储器层次结构、主存组成、RAM芯片、存储器扩展前言 存储器系统是计算机组成原理分值最高、考点最密集、计算题最多的核心大章节。如果说CPU是计算机的“大脑”&#xff0c;那存储器就是计算机的“记忆系统”。计算机所有程序、系统数据、用户文件&…

作者头像 李华
网站建设 2026/10/26 10:14:14

PHP发卡网源码_个人发卡网源码自动系统_异次元/ACG-Faka类搭建

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 10:13:32

MediaCrawler 快速上手指南:3 步跑通五平台社交媒体数据采集

MediaCrawler 快速上手指南&#xff1a;3 步跑通五平台社交媒体数据采集 【免费下载链接】MediaCrawler-new 项目地址: https://gitcode.com/GitHub_Trending/me/MediaCrawler-new 做竞品监控时&#xff0c;你要把一款新品在小红书和抖音上的表现逐条手动收集&#xff…

作者头像 李华
网站建设 2026/10/26 10:12:37

tchMaterial-parser 使用教程:3 步把电子课本 PDF 存到本地

tchMaterial-parser 使用教程&#xff1a;3 步把电子课本 PDF 存到本地 【免费下载链接】tchMaterial-parser 国家中小学智慧教育平台 电子课本下载工具&#xff0c;帮助您从智慧教育平台中获取电子课本的 PDF 文件网址并进行下载&#xff0c;让您更方便地获取课本内容。 项目…

作者头像 李华