 深度解析:原理、性能陷阱与正确用法)
【Pandas】— apply() 深度解析我第一次用 apply() 是在处理一张 60 多万行的优惠券核销数据。当时只想着用 for 循环太慢了apply 总该快一点吧结果跑下来快两分钟被一个用原生向量化方法的同事版本秒成渣。后来我才意识到我对 apply 的所有认知都停留在“它能跑函数”这个层面。功能会用了原理完全没懂。这篇内容我不打算把官方文档抄一遍而是从我踩过的坑里把 apply() 的几个关键问题拆开讲透它到底是在干什么、什么时候该用它、什么时候千万别用、性能为什么慢、以及遇到各种诡异报错时怎么排查。无论你是刚接触 Pandas 的新手还是已经在数据清洗里被 apply 坑过的老手这篇内容应该能让你少走不少弯路。1. apply() 到底在解决什么问题1.1 它不是“高级循环”那么简单很多资料喜欢说“apply 就是隐式循环”这句话对了一半。apply 确实是循环但如果只把它理解成循环你会错过它最核心的定位让一个普通的 Python 函数能够作用到 DataFrame 的每一行、每一列甚至每一个分组上。举个例子你有一个 DataFrame里面存着商品的价格和折扣率import pandas as pd df pd.DataFrame({ 商品: [A, B, C], 原价: [100, 200, 150], 折扣率: [0.8, 0.65, 0.9] })现在想算折后价常规做法是直接向量化df[折后价] df[原价] * df[折扣率]这种写法好、快、清晰不需要 apply。但现实数据往往没那么乖。比如折扣率有的存成字符串 8折有的写成 满300减50价格里还混着 100元 这种带单位的数据。这时你没法直接用乘法得先写一个函数去解析这些乱七八糟的格式然后把函数逐行应用。这才是 apply 的主场。1.2 用 apply 的正确姿势先回答一个问题我现在的判断标准很简单用 apply 之前先问自己计算逻辑能不能拆成 Pandas 内置的向量化操作能就不用 apply不能再考虑 apply。这句话说起来容易做起来很多人还是拿不准。我通常用一张判断逻辑图来衡量如果是“列 A 等于某值列 B 取另一列的值”这种简单条件优先用 np.where 或直接布尔索引。如果是要把一列字符串做归一化优先用 Series.str.replace / str.extract 等字符串方法。如果是要做分组后的聚合优先用 groupby.agg。只有当逻辑真的无法用以上方式表达比如每一行的判断依赖多个字段的组合、或者需要调用一个第三方解析函数才轮到 apply。当然还有一个额外的考量维度代码可读性。先说结论如果一段逻辑用 apply 自定义函数能写得非常直白而向量化写法需要嵌套三四个方法、阅读成本极高那么我宁可牺牲一点性能来选择 apply。原因很实在代码是给人看的下一次维护这段代码的大概率是三个月后的你。1.3 拿它和 map、agg、transform 分个家apply 经常和 map、agg、transform 混淆很多人就是因为分不清这几个才踩的坑。我做了一个表平时干活时基本就靠这个表做选择函数作用对象返回结果典型用途Series.mapSeriesSeries单列映射、字典替换Series.applySeriesSeries / 标量单列逐元素处理DataFrame.applyDataFrameSeries / DataFrame整行、整列处理DataFrame.applymapDataFrameDataFrame逐元素处理已废弃改名DataFrame.aggDataFrame聚合结果对列做聚合如 sum、meanGroupBy.applyDataFrameGroupByDataFrame / Series分组后做复杂处理其中值得多说一句的是 applymap新版 Pandas 已经把它重命名成 DataFrame.map你在老代码里会经常看到。它的语义是“对 DataFrame 里每个元素执行函数”但实际开发中它的使用频率很低因为绝大多数逐元素操作都可以靠向量化完成。2. 核心细节拆解apply 的参数和返回值2.1 Series.apply 与 DataFrame.apply 完全不同这个坑我踩过。不同书籍和教程混着讲导致很多人以为 apply 就是“对每个单元格跑一遍”这是错误的。Series.apply 是对 Series 中每个元素依次调用函数返回值是一个新的 Series。比如s pd.Series([1, 2, 3]) s.apply(int) # 0 1 # 1 2 # 2 3 # dtype: int64这个例子中 int 是 Python 内置函数apply 把它逐一作用到 Series 的每个元素上。DataFrame.apply 的默认行为完全不同。它的 axis0 时是“按列扫描”函数接收的是每一列这个 Seriesaxis1 时是“按行扫描”函数接收的是每一行这个 Series。换句话说DataFrame.apply 中函数处理的是整列或整行而不是单个单元格。df pd.DataFrame({a: [1, 2], b: [3, 4]}) # axis0 时函数接收的是列 Series df.apply(lambda col: col.sum()) # a 3 # b 7 # dtype: int64 # axis1 时函数接收的是行 Series df.apply(lambda row: row[a] row[b], axis1) # 0 4 # 1 6 # dtype: int64注意看默认 axis0 的结果是一个 Series索引是列名axis1 的结果虽然也是 Series但索引是原来的行索引。2.2 axis 参数为什么经常搞反我身边几乎所有同事都在这上面翻过车。为什么 axis0 反而是“按列”因为这里的 axis 指的是“删除/操作的坐标轴方向”axis0 表示“沿着行的方向移动” 所以每移动一步就取到一列数据。axis1 表示“沿着列的方向移动”每移动一步就取到一行数据。这个记忆法现在是我的默认答案。还有一个更直接的访问方式记不住就直接写“我想要行处理还是列处理”然后通过行为来判断我想对每一行的 a、b、c 三个字段做组合计算 → 用 axis1。我想对每一列做同样的处理 → 用 axis0。但我必须提醒一句在 DataFrame.apply 中用 axis0 做逐列处理时函数收到的“一列”是 Series它的名字是列名用 axis1 做逐行处理时函数收到的一行也是 Series它的名字是行索引。这意味着在 axis1 的函数体里你依然可以用 row[列名] 的方式取数这也是大多数人常用的方式。2.3 返回值到底怎么确定apply 的返回值经常让人措手不及因为它是动态推断的。总结起来就一句话函数的返回值类型不同apply 结果的类型也会不同。如果函数返回标量比如数字、字符串、布尔值那么 DataFrame.apply 的结果是一个 Series你用 Series.apply 时结果也是 Series。如果函数返回 Series比如你在函数里构造了一个 pd.Series 并返回那么 DataFrame.apply 的结果会变成一个 DataFrame且每个返回的 Series 会成为结果 DataFrame 的一行或一列取决于 axis 方向。df pd.DataFrame({a: [1, 2], b: [3, 4]}) def calc(row): return pd.Series({ sum: row[a] row[b], diff: row[a] - row[b] }) df.apply(calc, axis1) # sum diff # 0 4 -2 # 1 6 -2注意两个返回 Series 的索引sum、diff会被自动用作结果 DataFrame 的列名。这个行为在某些场景下很实用比如同时生成两列新数据时可以直接用。但如果返回的还是 DataFrame情况就会变得复杂。函数返回 DataFrame 时apply 的结果可能会变成一个多层索引的 Series 或 DataFrame因为 Pandas 会把返回的 DataFrame 完全塞进结果里。实战中这种情况很少见我也不建议刻意使用因为结果结构很容易让人懵。2.4 关于 apply 的 raw 参数apply 还有一个经常被人忽略的参数 raw默认是 False也就是函数收到的是 Series。把 raw 设为 True 后函数收到的是 numpy 数组ndarray性能会提升不少。原理其实很直接Series 是一个包装对象带着索引、dtype 等额外信息numpy 数组更轻量。在 apply 的计算场景中绝大多数函数只关心数值本身不关心索引所以没必要让每行都包装成 Series。实测下来rawTrue 的性能提升幅度可以达到 1.5 到 3 倍尤其是在函数内部频繁做数值运算的时候。df.apply(lambda row: row[a] row[b], axis1, rawTrue)不过这里有一个坑rawTrue 时函数接收的不再是 Series不能直接用 row[a] 这种按列名取数的方式只能按下标访问比如 row[0]、row[1]。所以如果你的函数逻辑里需要按名字访问列字段rawTrue 就不太方便了。你自己权衡如果函数逻辑简单、列顺序稳定rawTrue 是很好的优化手段。3. 性能实测apply 的锅还是你写法的锅3.1 同一种需求五种写法耗时对比我专门做了一组对比实验数据是 20 万行的 DataFrame每一行有两个数值列 a 和 b需求是计算一个相对复杂的公式。我分别用原生向量化、numpy 向量化、apply、for 循环、列表推导式五种方式实现记录耗时。import pandas as pd import numpy as np import time df pd.DataFrame({ a: np.random.rand(200000) * 100, b: np.random.rand(200000) * 100 }) # 方法1Pandas 原生向量化 t0 time.time() res1 df[a] * 2 np.log1p(df[b]) df[a] * df[b] t1 time.time() # 方法2Numpy 向量化 t0 time.time() res2 np.where( df[a].values 50, df[a].values * df[b].values, df[a].values df[b].values ) t1 time.time() # 方法3apply def calc_row(row): if row[a] 50: return row[a] * row[b] return row[a] row[b] t0 time.time() res3 df.apply(calc_row, axis1) t1 time.time() # 方法4for 循环 result [] cols_a df[a].values cols_b df[b].values t0 time.time() for i in range(len(df)): if cols_a[i] 50: result.append(cols_a[i] * cols_b[i]) else: result.append(cols_a[i] cols_b[i]) t1 time.time() # 方法5列表推导式 t0 time.time() res5 [ (a * b) if a 50 else (a b) for a, b in zip(df[a].values, df[b].values) ] t1 time.time()我实际运行的结果大致分布为实现方式使用说明耗时相对值Pandas 原生向量化一行代码搞定最推荐1xNumpy 向量化和 Pandas 原生差不多取决于是不是要脱离 DataFrame1x~1.2x列表推导式 values比 apply 好不少适合简单逻辑5x~10xapply(axis1)写法很优雅但性能明显下滑50x~100xfor 循环 values慢在 Python 循环本身60x~120x我反复跑过很多次每次结果基本一致。注意 apply 和 for 循环差距不稳定因为 apply 内部做了更多包装和处理但无论如何都远慢于向量化。3.2 为什么 apply 这么慢性能差异的根本原因在于Pandas 的向量化操作底层是 C 和 numpy 的优化实现而 apply 本质上还是在 Python 层逐行执行函数。很多人以为 apply 是个“C 语言级别的循环”所以会很快。这是最大的误解。apply 的循环确实是 C 语言写的外层遍历框架但函数体是 Python 函数每调用一次函数Python 解释器都要做参数打包、类型检查、函数调用、结果解包这一整套流程才是真正耗时的地方。特别是你还在 lambda 里通过 row[列名] 取值每取一次值就要做一次索引解析会被放大更多倍。举一个极端例子同样是计算逻辑如果函数体里用 row[a]、row[b] 反复取数apply 的劣化会比使用 row.iloc[0]、row.iloc[1] 更严重。因为按列名索引涉及标签匹配按下标索引则直接按位置访问。还有一个很容易被忽略的点默认情况下 axis1 时函数收到的 row 是一个 Series一个 20 万行的 DataFrame 相当于创建了 20 万个短命的 Series 对象。每个对象都有内存开销这一加下来非常吓人。这也是为什么上文的 rawTrue 能带来明显加速的原因。3.3 什么时候 apply 反而是合理的性能差并不代表 apply 就该被完全封杀掉。我只在两种场景下主动使用 apply。第一种场景是逻辑本身很复杂复杂到向量化做不出来。比如要解析一个包含多种格式的日期字符串或者要根据多个字段做一长串 if-else 判断。这种场景下你不得不走 Python 循环apply 只是让你能写在 DataFrame 语义中代码更干净。第二种场景是数据量不大但代码可读性优先。如果数据只有几千行哪怕 apply 比向量化慢 100 倍耗时差距可能也就在几十毫秒内用户根本感知不到。此时写出清晰、直观、易维护的代码比追求极致性能更重要。我通常在团队里强调一个原则先保证逻辑正确再谈性能优化。如果一段逻辑你用 apply 写得明明白白就不要为了性能硬改成复杂的向量化写法。但如果数据量上了几十万行性能差距就会非常明显这时候必须认真对待。3.4 优化 apply 的几个实用手段如果你确实不得不用 apply还有几个手段可以让它别那么慢。一是上面的 rawTrue 参数前面已经说过能提速。二是尽量减少函数体内的索引操作。把需要的数据提前提取成局部变量不要每次都写 row[列名]。三是如果函数逻辑里只涉及一列数据就不要用 DataFrame.apply(Axis1)换成 Series.apply后者性能通常更好。四是如果逻辑真的很复杂且对性能要求高可以试试 numba。Pandas 提供了 numba 引擎可以让支持 numba 的函数在 JIT 编译后运行性能往往能接近向量化。不过 numba 有它自己的限制比如函数内部不能用 Pandas 对象只能用 numpy 数组或纯 Python 基本类型这意味着你需要先把数据拆出来再通过构造函数来操作开发成本会高不少。4. 实战拆解四个真实场景中的 apply 用法4.1 复杂行级逻辑处理多字段联合判断这是 apply 的经典使用场景。比如电商订单表规则是订单金额大于 500 且用户等级为 VIP运费减免订单金额大于 500 但普通用户运费减半其余用户全价运费。这种逻辑用向量化写需要多层 np.where嵌套多了可读性会很差。df pd.DataFrame({ 订单号: [A001, A002, A003], 金额: [680, 320, 900], 用户等级: [VIP, 普通, 普通], 基础运费: [10, 8, 15] }) def calc_freight(row): if row[金额] 500 and row[用户等级] VIP: return 0 elif row[金额] 500: return row[基础运费] / 2 else: return row[基础运费] df[实付运费] df.apply(calc_freight, axis1)这段代码的好处是逻辑都在一个函数里测试方便以后规则变了只需要改函数不需要改主流程。如果你觉得这个函数以后还能复用到别的地方甚至可以单独放到一个 utils 模块里配合单元测试。4.2 数据清洗把不规范的字符串统一转成数字处理外部数据时列里总是混着各种诡异格式。比如一个“销售额”列既有数值也有 1,200元还有 约3000 这种描述。sales pd.Series([1,200元, 3000, 约2500, 1.5万, np.nan]) def parse_sales(x): if pd.isna(x): return np.nan s str(x).replace(元, ).replace(,, ).replace(约, ).strip() if 万 in s: return float(s.replace(万, )) * 10000 return float(s) sales.apply(parse_sales) # 0 1200.0 # 1 3000.0 # 2 2500.0 # 3 15000.0 # 4 NaN # dtype: float64这种场景下apply 是最合适的选择因为麻烦在于正则匹配和分支判断向量化基本没法做。你把逻辑写成一个纯函数然后用 apply 一烤整个列就干净了。不过我想提醒一个小细节如果这个函数未来会被调用多次建议用 transform 或 map或者先缓存解析结果。反正数据清洗通常是一次性的性能要求没那么高主要还是看代码好不好维护。4.3 分组后的 applyGroupBy.apply 的注意事项groupby.apply 是一个但凡是真实业务就会遇到的功能。比如你有一个员工薪资表想按部门给每个员工算排名并且要生成“高于部门平均薪资”这样的标记。df pd.DataFrame({ 部门: [技术, 技术, 销售, 销售, 销售], 员工: [张三, 李四, 王五, 赵六, 钱七], 薪资: [15000, 12000, 10000, 8000, 6000] }) def enrich_group(group): group[部门排名] group[薪资].rank(ascendingFalse) group[是否高于平均] group[薪资] group[薪资].mean() return group result df.groupby(部门, group_keysFalse).apply(enrich_group)这里函数接收的是一个分组后的子 DataFrame你在函数内部对这个子 DataFrame 做任何操作最终都会拼回去。这里有一个值得注意的参数 group_keys。默认情况下 groupby.apply 可能在结果里加上分组键作为索引层group_keysFalse 可以避免这种多余的索引层级。不过 Pandas 新版本里的行为有调整如果你发现结果里出现了层级索引不用慌先试试这个参数。我还见过有人在这里犯过一个经典错误分组 apply 的函数里试图修改原始 DataFrame给原始 df 添加列但忘了分组后的 group 只是一个视图或副本实际原表没变。这个问题的根源是对“修改是发生在副本上还是原表上”没有把握。我的建议是函数内部创建新列并 return不要尝试在全局变量上做修改否则很容易出现“函数里明明打印了外面却没变化”的诡异问题。4.4 一次生成多列apply 返回 Series 的妙用很多时候一列计算完还不够同时要生成多列。与其分开调用多次 apply不如只调用一次让函数返回一个 Series。df pd.DataFrame({ 商品: [A, B, C], 单价: [100, 200, 150], 数量: [3, 2, 5] }) def compute(row): subtotal row[单价] * row[数量] tax subtotal * 0.13 return pd.Series({ 小计: subtotal, 税费: tax }) result df.join(df.apply(compute, axis1))这里注意apply 返回的是一个包含两列的新 DataFrame再用 join 拼回原表。如果不想用 join也可以直接把结果赋值给 df[[小计, 税费]]但前提是索引对齐做法实测下来经常会有警告。更稳的方案是用 join 或 concat。还有一个小技巧如果函数返回多个值而且不想构造 Series可以配合 result_typeexpand。比如def compute2(row): subtotal row[单价] * row[数量] tax subtotal * 0.13 return subtotal, tax df.apply(compute2, axis1, result_typeexpand)这样 apply 的结果会直接变成一个多列 DataFrame省去了构造 Series 的麻烦写起来更轻快。5. 常见问题与排查技巧5.1 明明写了 axis1为什么还是报维度错误很多人在 DataFrame.apply 里写了 axis1函数里却试图按列名索引但报错信息经常是“Series object is not callable”或维度错误。这个问题的本质往往是你用了 rawTrue函数参数变成了 numpy 数组自然不能用 row[列名]。检查一下有没有动过 raw 参数。另一个类似情况是函数体内用了 df.apply 而不是 row.apply也就是不小心对整体 DataFrame 调了 apply传入的参数变成了 DataFrame于是函数内部的索引逻辑全乱。排查这类问题建议在函数里先用 type 打印参数类型确认收到的是 Series、numpy 数组还是 DataFrame问题就会一目了然。5.2 apply 结果出现 NaN我哪一步错了这是我自己第一次用 apply 时踩的坑。场景是对 DataFrame 按行 apply 了一个返回 Series 的函数函数返回的 Series 索引和原始 DataFrame 的索引不一致Pandas 会自动做索引对齐匹配不上的位置就填 NaN。def calc_use_index(row): return pd.Series({sum_value: row[a] row[b]}, index[0]) # 错误示例 df.apply(calc_use_index, axis1)如果返回的 Series 索引不包含“sum_value”结果就会以 NaN 填充。解决办法是保证返回 Series 的索引是需要的列名或者直接用 result_typeexpand 避免返回 Series。索引对齐是 Pandas 的核心特性但也是很多诡异 NaN 的来源。遇到这种情况先不要怀疑数据去检查你的返回结果和原索引是否对齐。5.3 函数里能用全局变量吗能但有坑apply 的函数体里访问全局变量是可以的但如果你在函数内部修改全局变量比如用 total row[金额]就会出现问题。原因是在 Python 里函数内部 global 变量的更新需要声明而 apply 的执行顺序和次数又不直观容易导致结果不正确。我的建议是不要在 apply 里做有副作用的操作。它不是用来累加的工具累加请用 cumsum 或先取 values 再循环。如果你真的需要在 apply 过程中维护状态把状态放到一个可变对象里比如字典或列表然后通过 append 或 update 来保存结果但说实话这个场景已经不适合 apply 了改用 for 循环更合适。5.4 Pandas 版本更新带来的函数更名问题新版 Pandas 2.x 已经把 DataFrame.applymap 改名为 DataFrame.map老代码里如果你把 applymap 拷贝过来会直接报 AttributeError。很多人一看到这个报错就慌了其实只要改成 map 或者改回旧版本即可。另外apply 的 engine 参数在 pandas 2.0 之后也支持了 numba如果你在旧文档里看到 apply 不支持的东西可能只是版本差异。5.5 实践中的几条硬性原则这几条原则是我踩过无数坑之后总结出来的基本上每次写代码之前都会过一遍能向量化就向量化apply 是备选方案不是默认方案。如果数据量超过 10 万行先跑一下性能测试再决定用不用 apply。不要在 apply 函数里访问外部大对象比如每行都去查询一次数据库这种写法会慢到让人怀疑人生。如果 apply 函数逻辑很复杂先转换成普通 Python 函数用几行数据测试通过后再接 apply。使用 DataFrame.apply(axis1) 之前确认每一列的数据类型是否符合预期。字符串列和数值列混在一起会引发各种奇怪的 TypeError。还有一点经常被忽略apply 返回多列时尽量统一返回类型。如果有时返回 Series 有时返回标量Pandas 会自动推断结果类型结果导致结果结构不稳定。我个人的经验是不用急着把 apply 当作一个必须搞懂的高级技巧。它本质上就是一个让你在 DataFrame 上运行 Python 函数的桥梁。真正重要的是你的函数逻辑要清晰、数据预处理要到位、性能要心里有数。数据量小的项目里用 apply 通常不会出什么大问题数据量大了你自然会去研究向量化、numba、cython 这些更底层的加速方式到时候你对 apply 的理解只会更深刻。记住它是个工具不是信仰适合用的时候再用不适合的时候果断放弃。