何时(不)应该在我的代码中使用pandas apply()函数?

229

我在Stack Overflow上看到很多关于Pandas方法apply的答案。我也看到用户在下面评论说"apply很慢,应该避免使用"。

我读了很多关于性能的文章,解释了apply很慢。我也看到文档中有一份声明,说明apply只是用于传递UDF的便利函数(现在好像找不到了)。所以,普遍的共识是尽可能避免使用apply。但是,这引发了以下问题:

  1. 如果apply如此糟糕,那么为什么它在API中存在?
  2. 我应该如何使我的代码避免使用apply
  3. 是否有任何情况下apply比其他可能的解决方案更好?

1
returns.add(1).apply(np.log)np.log(returns.add(1) 是一种情况,其中 apply 通常会稍微快一些,这是 jpp 图表中右下角的绿色框。 - Alexander
@Alexander 谢谢。虽然没有详细指出这些情况,但了解它们很有用! - cs95
2
apply方法通常速度很快,是一个非常好的API,有80%的时间可以使用。因此,我完全不同意那些建议不要使用它的观点。但是,了解其局限性并掌握一些技巧是明智的,以防万一apply方法变得太慢,可以在顶部答案中列出的技巧中找到解决方案。 - Zephaniah Grunschlag
4个回答

254

apply, 你从未需要过的便利函数

我们逐个回答OP中的问题。

"如果 apply 这么差,为什么还在API中呢?"

DataFrame.applySeries.apply是分别定义在DataFrame和Series对象上的便利函数apply接受任何用户定义的函数,对DataFrame应用变换/聚合操作。 apply实际上是一颗银弹,可以做任何现有的pandas函数无法完成的事情。

apply可以完成以下一些工作:

  • 在DataFrame或Series上运行任何用户定义的函数
  • 在DataFrame上逐行(axis=1)或逐列(axis=0)地应用函数
  • 应用函数时进行索引对齐
  • 使用用户定义的函数进行聚合(但是,在这些情况下,我们通常更喜欢使用aggtransform)
  • 执行逐元素的转换
  • 将聚合结果广播到原始行(参见result_type参数)。
  • 接受位置/关键字参数以传递给用户定义的函数。

...等等。有关更多信息,请参见文档中的逐行或逐列应用函数

那么,拥有所有这些功能,为什么apply不好? 这是因为apply。 Pandas对您的函数的性质不作任何假设,因此必要时iteratively applies your function每行/每列。 此外,处理上述所有情况意味着在每次迭代中都会产生一些重大的开销。进一步地,apply消耗了更多的内存,这对于内存受限的应用程序来说是一个挑战。

只有很少几种情况下使用 apply 是适当的 (有关详细信息请参见下面的内容)。 如果您不确定是否应该使用 apply,那么您可能不应该使用。



接下来让我们回答下一个问题。

"我应该如何在什么情况下去除对apply的调用?"

换句话说,以下是您将希望摆脱任何对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

从性能上来看,没有可比性,经过Cython优化后的等效代码要快得多。不需要绘制图表,因为即使对于玩具数据,差异也很明显。

%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和无效数据类型,可以使用自定义函数来构建并在列表推导式内部调用。

有关何时应将列表推导式视为良好选项的更多信息,请参见我的写作:Are for-loops in pandas really bad? When should I care?

请注意:
日期和日期时间操作也具有向量化版本。因此,例如,您应该优先使用pd.to_datetime(df['date']),而不是使用df['date'].apply(pd.to_datetime).

了解更多信息,请阅读文档


常见陷阱:列列表的爆炸

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向量化但不对DataFrames向量化的函数
如果您想要对多个列应用字符串操作怎么办?如果您想要将多个列转换为日期时间格式怎么办?这些函数仅对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)

将Series转换为strastypeapply的区别

使用apply将Series中的整数转换为字符串比使用astype更快,这似乎是API的奇特之处。

enter image description here 该图是使用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())

使用浮点数,我发现astypeapply的速度基本相同或略快。这是因为测试数据中的数据类型为整数。


GroupBy操作与链接转换

GroupBy.apply直到现在才被讨论,但GroupBy.apply也是一个迭代方便函数,用于处理现有GroupBy函数无法处理的任何内容。

一个常见的要求是执行GroupBy,然后执行两个主要操作,例如“滞后累加”:

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 调用(因为 groupby 也很昂贵),那么 apply 是一个可接受的解决方案。



其他注意事项

除了上面提到的注意事项之外,值得一提的是,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

这种行为在pandas版本<0.25的GroupBy.apply中也有出现(在0.25版本中已修复,更多信息请参见此处)。


@jpp 我也有同样的担忧。但无论如何,您仍然需要进行线性扫描,将字符串调用 to_datetime 与调用 datetime 对象一样快,如果不是更快。大致时间相同。另一种选择是为每个定时解决方案实现一些预复制步骤,这会削弱主要点。但这是一个有效的问题。 - cs95
@cs95,我发现apply和列表推导式的速度几乎相同。请查看此存储库:https://github.com/tseth92/pandas_experiments/blob/master/pandas_iterators.py,它们比iterrows和iterloops快得多。我没有与cythonized向量进行比较,但如果是特定的自定义函数,例如在存储库中,我应该考虑使用列表推导式而不是apply吗?如果是这样,为什么呢?因为它们似乎几乎同样快。 - Tushar Seth
4
“是否有适用apply的情况?”这个问题的另一个答案可以通过这个答案阐述。请注意,通常而言,不使用“apply”的解决方案比只需考虑并使用“apply”更加复杂 -因此更容易出错。因此,就像在软件开发中一样,你可能希望应用80-20法则。80%的时间使用“apply”更佳。但在20%的时间内,如果结果太慢,你可以优化去除“apply”。 - Zephaniah Grunschlag
感谢您的详细帖子!不过这个结论还有效吗?我在比较pandas 1.3.2中的df.apply(np.sum)df.sum()时似乎找不到相同的时间:df.sum()只比df.apply(np.sum)快约20%,而df.apply(np.sum, raw=True)则快了两倍!我在changelog中没有找到特定于apply的性能改进,所以有点迷惑... - Tranbi
@Tranbi 这个可能需要稍微修改一下,因为实现细节容易随时改变,没有事先通知的时间。 - cs95
显示剩余3条评论

83

并非所有的apply都相同

下面的图表建议何时考虑使用apply1。绿色意味着可能有效;红色避免。

enter image description here

一些是直观的:pd.Series.apply是一种Python级别的逐行循环,同样pd.DataFrame.apply也是逐行操作(axis=1)。这些滥用很多且范围广泛,其他帖子会更详细地介绍。流行的解决方案是使用向量化方法、列表推导式(假设数据干净)或高效工具,如pd.DataFrame构造器(例如,避免apply(pd.Series))。

如果您正在使用pd.DataFrame.apply逐行操作,请尽可能指定raw=True有利于性能提升。在此阶段,通常最好选择numba

GroupBy.apply:通常受欢迎

重复使用groupby操作以避免apply会损害性能。通常情况下,GroupBy.apply就可以胜任此项工作,前提是您在自定义函数中使用的方法本身是向量化的。有时,在您希望应用组合聚合的情况下,Pandas没有本地方法。在这种情况下,对于一小部分组,使用自定义函数的apply仍然可以提供合理的性能。

pd.DataFrame.apply逐列操作:各有千秋

pd.DataFrame.apply逐列操作(axis=0)是一个有趣的案例。当行数较少,列数较多时,它几乎总是代价高昂的。对于相对于列而言行数较多的情况,更常见的情况是,您可能会有时通过使用apply看到显着的性能提升:

# Python 3.7, Pandas 0.23.4
np.random.seed(0)
df = pd.DataFrame(np.random.random((10**7, 3)))     # Scenario_1, many rows
df = pd.DataFrame(np.random.random((10**4, 10**3))) # Scenario_2, many columns

                                               # Scenario_1  | Scenario_2
%timeit df.sum()                               # 800 ms      | 109 ms
%timeit df.apply(pd.Series.sum)                # 568 ms      | 325 ms

%timeit df.max() - df.min()                    # 1.63 s      | 314 ms
%timeit df.apply(lambda x: x.max() - x.min())  # 838 ms      | 473 ms

%timeit df.mean()                              # 108 ms      | 94.4 ms
%timeit df.apply(pd.Series.mean)               # 276 ms      | 233 ms

1 有一些例外,但它们通常是边缘的或不常见的。以下是一些示例:

  1. df['col'].apply(str) 可能会略微优于 df['col'].astype(str)
  2. df.apply(pd.to_datetime) 在处理字符串时,与普通的 for 循环相比,在行数较多时性能不良好。

1
@coldspeed,谢谢,你的帖子没有太大问题(除了一些与我的基准测试相矛盾的地方,但可能是输入或设置有关)。只是感觉有一种不同的看待问题的方式。 - jpp
@jpp,直到今天我看到逐行“应用”比我用any解决方案快得多,我一直使用您出色的流程图作为指导。您有什么想法吗? - Stef
@Stef,你要查看多少行数据?构建一个有100万行以上的数据框并尝试比较逻辑,apply应该会更慢。还要注意问题可能是mask(尝试使用np.where代替)。一个需要3-5毫秒的过程不适合基准测试,因为在现实中,当时间如此短时,你可能并不关心性能。 - jpp
1
@jpp:你说得对:对于1百万行x 100列,anyapply快大约100倍。我用2000行x 1000列进行了第一次测试,在这里applyany快两倍。 - Stef
1
@jpp,我想在演示文稿/文章中使用你的图片。你同意吗?我会注明出处。谢谢! - Erfan
@Erfan,当然,请继续。 - jpp

9

对于 axis=1(即按行计算的函数),您可以使用以下函数代替 apply。我想知道为什么这不是 pandas 的默认行为。(未经复合索引测试,但似乎比 apply 快得多)

def faster_df_apply(df, func):
    cols = list(df.columns)
    data, index = [], []
    for row in df.itertuples(index=True):
        row_dict = {f:v for f,v in zip(cols, row[1:])}
        data.append(func(row_dict))
        index.append(row[0])
    return pd.Series(data, index=index)

我非常惊讶地发现在某些情况下这给了我更好的性能。当我需要做多个事情,每个事情都有不同的列值子集时,它尤其有用。 "所有应用程序并不相同" 的答案可能有助于确定何时它可能有帮助,但在数据样本上进行测试并不是非常困难。 - denson
1
一些提示:为了提高性能,列表推导式将优于for循环;在这里,zip(df, row[1:])已足够;如果func是数值计算,请考虑使用numba。请参见此答案以获取解释。 - jpp
这实际上与 .apply 的实现非常接近,但它执行了一项显著减慢速度的操作,它基本上执行了:row = pd.Series({f:v for f,v in zip(cols, row[1:])}),这增加了很多负担。我写了一个[答案](https://dev59.com/z1kT5IYBdhLWcg3wMMur#38938507),描述了实现方式,尽管我认为它已经过时,最近的版本尝试在 .apply 中利用Cython,我相信(别引用我) - juanpa.arrivillaga
为什么要创建一个新的索引?为什么不直接在zip上调用dict,而是使用推导式?为什么要将列转换为列表,而不是遍历列? - Daniel Gibson
@DanielGibson - 我无法理解你的任何问题。我的观点是df.apply(func, axis=1)将返回与faster_df_apply(df, func)相同的结果,但在行数很多的数据框上运行速度会更快。 如果您有更好的解决方案,请分享。我认为告诉人们“不要调用apply”(正如其他人所做的那样)是一个愚蠢的非解决方案。有些人确实想要调用apply,而faster_df_apply是一个完全替代品,可以更快地运行。 - Pete Cacioppi
显示剩余2条评论

1

是否存在适用apply的情况? 是的,有时候。

任务:解码Unicode字符串。

import numpy as np
import pandas as pd
import unidecode

s = pd.Series(['mañana','Ceñía'])
s.head()
0    mañana
1     Ceñía


s.apply(unidecode.unidecode)
0    manana
1     Cenia

更新
我绝不是在提倡使用apply,只是认为既然NumPy无法处理上述情况,pandas apply可能是一个不错的选择。但是,由于@jpp的提醒,我忘记了简单的列表推导。


2
不是很好。这比 [unidecode.unidecode(x) for x in s]list(map(unidecode.unidecode, s)) 更好在哪里? - jpp
1
既然它已经是一个Pandas系列,我很想使用apply。是的,你说得对,使用列表推导比apply更好。但是,批评有点过分了,我并没有为apply进行辩护,只是认为这可能是一个很好的用例。 - BhishanPoudel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接