在 Pandas 的数据处理中,apply() 函数是一个出镜率极高的工具。它像一座桥梁,将我们自定义的逻辑与数据结构无缝连接起来。无论是简单的元素变换,还是复杂的跨列运算,apply() 都能以清晰的语法完成。
然而,很多人在使用 apply() 时,往往只知其然而不知其所以然------为什么有时候需要 axis=1?为什么直接传入 Series 会报错?apply() 和向量化操作到底该如何选择?
今天,我们从头梳理 apply() 的使用方法,并深入探讨它背后的设计思想,帮助你从"会用"进阶到"精通"。
一、Series 上的 apply:逐元素处理的利器
Series 是 Pandas 的一维数据结构,可以看作是带标签的数组。当我们在 Series 上调用 apply() 时,传入的函数会被应用到 Series 的每一个元素上。
1.1 基本用法
定义一个简单的函数,将每个元素乘以 20:
import pandas as pd
def multiply_by_20(x):
return x * 20
s = pd.Series([10, 20, 30])
result = s.apply(multiply_by_20)
print(result)
输出:
0 200
1 400
2 600
dtype: int64
这里的 multiply_by_20 依次接收了 10、20、30,并返回计算结果。整个过程类似于 Python 内置的 map() 函数,但 apply() 会保留索引并返回 Series。
如果逻辑简单,我们更常用 lambda 表达式:
s.apply(lambda x: x * 20)
1.2 传递额外参数
有时函数需要额外的参数,apply() 允许我们通过关键字参数将其传递进去:
def multiply(x, factor):
return x * factor
s.apply(multiply, factor=3)
输出:
0 30
1 60
2 90
dtype: int64
这里的关键是:apply() 会将自己没有匹配上的参数(如 factor=3)在调用函数时作为实参传递。这种设计使得函数复用变得更加方便。
二、DataFrame 上的 apply:按行或按列处理
DataFrame 是二维表格,apply() 在 DataFrame 上的行为与 Series 不同:它不再处理单个元素,而是处理一个完整的 Series(即一行或一列)。这个行为由 axis 参数控制。
2.1 axis=0:按列处理
axis=0 表示沿着行方向移动,即对每一列进行操作。这是 apply() 的默认值。
df = pd.DataFrame({"a": [10, 20, 30], "b": [40, 50, 60]})
def col_sum(s):
return s.sum()
print(df.apply(col_sum))
输出:
a 60
b 150
dtype: int64
在这里,函数 col_sum 被依次传入了列 "a" 和列 "b" 的 Series,分别计算它们的和。注意,传入函数的 s 是一个 Series,包含了该列的所有值。
2.2 axis=1:按行处理
当设置 axis=1 时,apply() 会按行处理,函数接收的是当前行的数据(也是一个 Series)。
def row_sum(s):
return s.sum()
print(df.apply(row_sum, axis=1))
输出:
0 50
1 70
2 90
dtype: int64
此时,s 代表一行,包含该行所有列的值。我们可以通过列名访问特定列的数据。
2.3 常见误区:为什么有时必须用 axis=1?
一个典型的场景是:我们需要基于当前行的多个列计算一个新值。比如,计算 a 列除以 b 列的结果。
如果使用 axis=0(默认),函数只能看到一列的数据,无法同时获取 a 和 b 两列。因此,必须指定 axis=1,让函数按行处理:
def divide(s):
return s["a"] / s["b"]
print(df.apply(divide, axis=1))
输出:
0 0.25
1 0.40
2 0.50
dtype: float64
这个例子很好地说明了 axis 参数的本质:它决定了函数接收的 Series 是列还是行。理解这一点,才能避免在使用 apply() 时犯方向错误。
三、向量化函数:当 apply() 遇到性能瓶颈
虽然 apply() 非常灵活,但它的本质是 Python 级别的循环。当数据量较大时,这种逐行或逐列的处理方式会显得力不从心。Pandas 真正的性能优势来自于向量化运算------利用底层 C 语言实现的数组操作,能够极大地提升速度。
3.1 直接向量化的困境
假设我们需要实现一个安全的除法:如果分母为 0,则返回 NaN;否则返回正常结果。最自然的写法可能是这样的:
import numpy as np
def safe_divide(x, y):
if y == 0:
return np.nan
return x / y
df = pd.DataFrame({"a": [10, 20, 30], "b": [40, 0, 60]})
# 直接传入 Series 会报错
safe_divide(df["a"], df["b"])
运行这段代码会抛出 ValueError,因为 if y == 0 语句试图用一个向量(Series)与标量 0 进行比较,这在 Python 中是不允许的。
3.2 使用 np.vectorize() 将函数向量化
numpy 提供了 vectorize() 函数,它可以将一个普通的 Python 函数包装成能够接受数组或 Series 作为输入的向量化函数。包装后的函数会逐元素地应用原始逻辑,并返回一个 NumPy 数组。
方式一:显式调用
def safe_divide(x, y):
if y == 0:
return np.nan
return x / y
safe_divide_vec = np.vectorize(safe_divide)
result = safe_divide_vec(df["a"], df["b"])
print(result)
输出:
[0.25 nan 0.5 ]
方式二:使用装饰器
如果你希望函数本身就可以直接接收向量输入,可以使用 @np.vectorize 装饰器:
@np.vectorize
def safe_divide(x, y):
if y == 0:
return np.nan
return x / y
print(safe_divide(df["a"], df["b"]))
结果与上面完全相同。
3.3 向量化函数的本质与取舍
需要说明的是,np.vectorize() 本质上并没有将函数编译成真正的向量化操作,它只是在 Python 层面进行循环,然后打包成数组输出。它的优势在于:
- 语法简洁:函数定义与普通函数无异,调用时直接传入 Series 即可。
- 可读性强:特别适合包含多个分支条件的复杂逻辑。
- 自动广播:能够处理不同形状的数组输入。
对于追求极致性能的场景,可以考虑使用 numba 或 Cython,但对于绝大多数数据分析任务,np.vectorize() 已经足够高效且易于维护。
四、总结:如何在实际工作中选择
apply() 和向量化函数各有千秋,选择哪一种取决于具体场景:
- 当逻辑简单且可以向量化时,优先使用 Pandas 内置的向量化操作(如 df["a"] / df["b"]),这是性能最好的方式。
- 当逻辑复杂且难以向量化,但数据量不大时,apply() 是最直接的选择。它代码清晰,易于调试。
- 当逻辑复杂且数据量较大时,可以考虑使用 np.vectorize() 包装函数,兼顾可读性和性能。
- 当需要跨行或跨列引用多个值时,务必根据需求正确设置 axis 参数。axis=0 处理列,axis=1 处理行,这是避免错误的根本。
最后,我想强调一点:不要盲目追求性能而牺牲代码的可读性。在数据分析的日常工作中,代码的清晰度和可维护性往往比微小的性能提升更重要。apply() 之所以经久不衰,正是因为它让复杂的逻辑变得直观易懂。当你遇到无法直接向量化的场景时,大胆使用 apply();当性能成为瓶颈时,再考虑用 np.vectorize() 或重构为向量化操作。