apply,你从来不需要的便捷功能
我们从一一解决 OP 中的问题开始。
“如果apply这么糟糕,那为什么在API中呢?”
DataFrame.apply 和 Series.apply 分别是在 DataFrame 和 Series 对象上定义的便利函数。 apply 接受任何用户定义的在 DataFrame 上应用转换/聚合的函数。 apply 是有效的灵丹妙药,可以完成任何现有 pandas 功能无法完成的任务。
apply 可以做的一些事情:
- 在 DataFrame 或 Series 上运行任何用户定义的函数
- 在 DataFrame 上按行 (
axis=1) 或按列 (axis=0) 应用函数
- 在应用函数时执行索引对齐
- 使用用户定义的函数执行聚合(但是,在这些情况下,我们通常更喜欢
agg 或 transform)
- 执行元素转换
- 将聚合结果广播到原始行(请参阅
result_type 参数)。
- 接受位置/关键字参数以传递给用户定义的函数。
...等等。有关详细信息,请参阅文档中的 Row or Column-wise Function Application。
那么,有了所有这些功能,为什么apply 不好?这是因为apply 慢。 Pandas 不对您的函数的性质做出任何假设,因此根据需要迭代地将您的函数应用于每一行/列。此外,处理上述所有情况意味着apply 在每次迭代中都会产生一些重大开销。此外,apply 消耗更多的内存,这对内存受限的应用程序来说是一个挑战。
在极少数情况下apply 适合使用(更多内容见下文)。 如果您不确定是否应该使用apply,您可能不应该使用。
让我们解决下一个问题。
“我应该如何以及何时让我的代码apply-free?”
换种说法,这里有一些常见的情况,您希望摆脱对apply的任何调用。
数值数据
如果您正在处理数字数据,可能已经有一个矢量化 cython 函数可以完全满足您的要求(如果没有,请在 Stack Overflow 上提问或在 GitHub 上打开功能请求) .
对比apply简单加法运算的性能。
df = pd.DataFrame({"A": [9, 4, 2, 1], "B": [12, 7, 5, 4]})
df
A B
0 9 12
1 4 7
2 2 5
3 1 4
df.apply(np.sum)
A 16
B 28
dtype: int64
df.sum()
A 16
B 28
dtype: int64
性能方面,没有可比性,cythonized 等价物要快得多。不需要图表,因为即使是玩具数据,差异也很明显。
%timeit df.apply(np.sum)
%timeit df.sum()
2.22 ms ± 41.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
471 µs ± 8.16 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
即使您启用使用 raw 参数传递原始数组,它的速度仍然是原来的两倍。
%timeit df.apply(np.sum, raw=True)
840 µs ± 691 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
另一个例子:
df.apply(lambda x: x.max() - x.min())
A 8
B 8
dtype: int64
df.max() - df.min()
A 8
B 8
dtype: int64
%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.max() - df.min()
2.43 ms ± 450 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
1.23 ms ± 14.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
一般来说,尽可能寻找矢量化替代方案。
字符串/正则表达式
Pandas 在大多数情况下都提供“矢量化”字符串函数,但在极少数情况下,这些函数不...“应用”,可以这么说。
一个常见的问题是检查一列中的值是否存在于同一行的另一列中。
df = pd.DataFrame({
'Name': ['mickey', 'donald', 'minnie'],
'Title': ['wonderland', "welcome to donald's castle", 'Minnie mouse clubhouse'],
'Value': [20, 10, 86]})
df
Name Value Title
0 mickey 20 wonderland
1 donald 10 welcome to donald's castle
2 minnie 86 Minnie mouse clubhouse
这应该返回第二行和第三行,因为“donald”和“minnie”出现在它们各自的“Title”列中。
使用 apply,这将使用
df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)
0 False
1 True
2 True
dtype: bool
df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]
Name Title Value
1 donald welcome to donald's castle 10
2 minnie Minnie mouse clubhouse 86
但是,使用列表推导存在更好的解决方案。
df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]
Name Title Value
1 donald welcome to donald's castle 10
2 minnie Minnie mouse clubhouse 86
%timeit df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]
%timeit df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]
2.85 ms ± 38.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
788 µs ± 16.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
这里要注意的是迭代例程恰好比apply 快,因为开销较低。如果您需要处理 NaN 和无效 dtypes,您可以使用自定义函数在此基础上进行构建,然后可以在列表推导中使用参数调用。
有关何时应将列表推导视为一个好的选择的更多信息,请参阅我的文章:Are for-loops in pandas really bad? When should I care?。
注意
日期和日期时间操作也有矢量化版本。因此,例如,您应该更喜欢 pd.to_datetime(df['date']),而不是,
比如说df['date'].apply(pd.to_datetime)。
阅读更多
docs.
一个常见的陷阱:列表爆炸列
s = pd.Series([[1, 2]] * 3)
s
0 [1, 2]
1 [1, 2]
2 [1, 2]
dtype: object
人们很想使用apply(pd.Series)。就性能而言,这可怕。
s.apply(pd.Series)
0 1
0 1 2
1 1 2
2 1 2
更好的选择是列出列并将其传递给 pd.DataFrame。
pd.DataFrame(s.tolist())
0 1
0 1 2
1 1 2
2 1 2
%timeit s.apply(pd.Series)
%timeit pd.DataFrame(s.tolist())
2.65 ms ± 294 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
816 µs ± 40.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
最后,
“有apply好的情况吗?”
Apply 是一个方便的函数,所以 在 的情况下开销可以忽略不计,可以原谅。这真的取决于函数被调用了多少次。
为系列矢量化的函数,而不是数据帧
如果你想对多个列应用字符串操作怎么办?如果要将多列转换为日期时间怎么办?这些函数仅针对 Series 进行了矢量化处理,因此它们必须应用您要转换/操作的每一列。
df = pd.DataFrame(
pd.date_range('2018-12-31','2019-01-31', freq='2D').date.astype(str).reshape(-1, 2),
columns=['date1', 'date2'])
df
date1 date2
0 2018-12-31 2019-01-02
1 2019-01-04 2019-01-06
2 2019-01-08 2019-01-10
3 2019-01-12 2019-01-14
4 2019-01-16 2019-01-18
5 2019-01-20 2019-01-22
6 2019-01-24 2019-01-26
7 2019-01-28 2019-01-30
df.dtypes
date1 object
date2 object
dtype: object
这是apply 的可接受案例:
df.apply(pd.to_datetime, errors='coerce').dtypes
date1 datetime64[ns]
date2 datetime64[ns]
dtype: object
请注意,stack 也有意义,或者只使用显式循环。所有这些选项都比使用apply 稍快,但差异小到可以原谅。
%timeit df.apply(pd.to_datetime, errors='coerce')
%timeit pd.to_datetime(df.stack(), errors='coerce').unstack()
%timeit pd.concat([pd.to_datetime(df[c], errors='coerce') for c in df], axis=1)
%timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')
5.49 ms ± 247 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.94 ms ± 48.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.16 ms ± 216 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
2.41 ms ± 1.71 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
您可以为其他操作(例如字符串操作或转换为类别)进行类似的案例。
u = df.apply(lambda x: x.str.contains(...))
v = df.apply(lambda x: x.astype(category))
v/s
u = pd.concat([df[c].str.contains(...) for c in df], axis=1)
v = df.copy()
for c in df:
v[c] = df[c].astype(category)
等等……
将系列转换为 str:astype 与 apply
这似乎是 API 的一个特点。使用apply 将系列中的整数转换为字符串与使用astype 相当(有时更快)。
该图是使用perfplot 库绘制的。
import perfplot
perfplot.show(
setup=lambda n: pd.Series(np.random.randint(0, n, n)),
kernels=[
lambda s: s.astype(str),
lambda s: s.apply(str)
],
labels=['astype', 'apply'],
n_range=[2**k for k in range(1, 20)],
xlabel='N',
logx=True,
logy=True,
equality_check=lambda x, y: (x == y).all())
使用浮点数,我看到astype 始终与apply 一样快,或者略快于apply。所以这与测试中的数据是整数类型有关。
GroupBy 具有链式转换的操作
GroupBy.apply 直到现在还没有被讨论过,但GroupBy.apply 也是一个迭代便捷函数,可以处理现有GroupBy 函数不能处理的任何事情。
一个常见的要求是执行 GroupBy,然后执行两个素数运算,例如“滞后 cumsum”:
df = pd.DataFrame({"A": list('aabcccddee'), "B": [12, 7, 5, 4, 5, 4, 3, 2, 1, 10]})
df
A B
0 a 12
1 a 7
2 b 5
3 c 4
4 c 5
5 c 4
6 d 3
7 d 2
8 e 1
9 e 10
你需要两个连续的 groupby 调用:
df.groupby('A').B.cumsum().groupby(df.A).shift()
0 NaN
1 12.0
2 NaN
3 NaN
4 4.0
5 9.0
6 NaN
7 3.0
8 NaN
9 1.0
Name: B, dtype: float64
使用apply,您可以将其缩短为一次调用。
df.groupby('A').B.apply(lambda x: x.cumsum().shift())
0 NaN
1 12.0
2 NaN
3 NaN
4 4.0
5 9.0
6 NaN
7 3.0
8 NaN
9 1.0
Name: B, dtype: float64
性能很难量化,因为它取决于数据。但总的来说,如果目标是减少对groupby 的调用,apply 是一个可以接受的解决方案(因为groupby 也非常昂贵)。
其他注意事项
除了上面提到的注意事项之外,还值得一提的是apply 对第一行(或列)进行了两次操作。这样做是为了确定函数是否有任何副作用。如果没有,apply 可能能够使用快速路径来评估结果,否则它会退回到缓慢的实现。
df = pd.DataFrame({
'A': [1, 2],
'B': ['x', 'y']
})
def func(x):
print(x['A'])
return x
df.apply(func, axis=1)
# 1
# 1
# 2
A B
0 1 x
1 2 y
在 GroupBy.apply 中也可以看到这种行为(它已针对 0.25、see here for more information 进行了修复。)