【问题标题】:When should I (not) want to use pandas apply() in my code?我什么时候应该(不)想在我的代码中使用 pandas apply()?
【发布时间】:2020-10-31 07:07:39
【问题描述】:

我已经看到很多关于 Stack Overflow 上涉及使用 Pandas 方法apply 的问题的答案。我还看到用户在他们下面评论说“apply 很慢,应该避免”。

我已经阅读了很多关于性能主题的文章,这些文章解释了apply 的速度很慢。我还在文档中看到了关于 apply 如何只是传递 UDF 的便捷函数的免责声明(现在似乎找不到)。因此,普遍的共识是尽可能避免使用apply。然而,这引发了以下问题:

  1. 如果apply 如此糟糕,那为什么它会出现在 API 中?
  2. 我应该如何以及何时让我的代码apply-free?
  3. 在任何情况下apply(比其他可能的解决方案更好)?

【问题讨论】:

  • returns.add(1).apply(np.log) vs. np.log(returns.add(1)apply 通常会稍微快一些的情况,这是 jpp 下图中右下角的绿色框。
  • @Alexander 谢谢。没有详尽地指出这些情况,但了解它们很有用!
  • Apply 速度足够快,而且在 80% 的时间里都是一个很棒的 AP​​I。所以我非常不同意建议不要使用它的观点。但是,意识到它的局限性并在你的后兜里拥有一些在最佳答案中概述的技巧绝对是件好事,以防apply最终太慢。

标签: python pandas performance apply


【解决方案1】:

apply,你从来不需要的便捷功能

我们从一一解决 OP 中的问题开始。

“如果apply这么糟糕,那为什么在API中呢?”

DataFrame.applySeries.apply 分别是在 DataFrame 和 Series 对象上定义的便利函数apply 接受任何用户定义的在 DataFrame 上应用转换/聚合的函数。 apply 是有效的灵丹妙药,可以完成任何现有 pandas 功能无法完成的任务。

apply 可以做的一些事情:

  • 在 DataFrame 或 Series 上运行任何用户定义的函数
  • 在 DataFrame 上按行 (axis=1) 或按列 (axis=0) 应用函数
  • 在应用函数时执行索引对齐
  • 使用用户定义的函数执行聚合(但是,在这些情况下,我们通常更喜欢 aggtransform
  • 执行元素转换
  • 将聚合结果广播到原始行(请参阅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)

等等……


将系列转换为 strastypeapply

这似乎是 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 进行了修复。)

【讨论】:

  • 我认为我们需要小心.. 使用%timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce') 在第一次迭代之后肯定会更快,因为您将datetime 转换为... datetime?跨度>
  • @jpp 我也有同样的担忧。但是您仍然需要以任何一种方式进行线性扫描,在字符串上调用 to_datetime 与在 datetime 对象上调用它们一样快,如果不是更快的话。球场时间是相同的。另一种方法是为每个定时解决方案实施一些预复制步骤,这会偏离重点。但这是一个合理的担忧。
  • "在字符串上调用 to_datetime 和在 ...datetime 对象上一样快" .. 真的吗?我在applyfor 循环计时中包含了数据帧创建(固定成本),差异要小得多。
  • @jpp 好吧,这就是我从(诚然有限的)测试中得到的。我确定这取决于数据,但总体思路是,为了说明的目的,区别在于“说真的,别担心”。
  • 我认为另一个答案是“有没有适用的情况?”这个答案说明了这一点。请注意,一般来说,不使用apply 的解决方案要复杂得多——因此容易出错——与不考虑它并使用apply 相比。因此,就像在软件开发和一般生活中一样,您可能想要应用 80-20 规则。 80% 的时间使用 apply 是首选。但是在结果太慢的 20% 的时间里,你可以继续优化远离apply
【解决方案2】:

并非所有applys 都是一样的

下图建议何时考虑apply1。绿色意味着可能有效率;红色避免。

其中一些很直观: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 column-wise:鱼龙混杂

pd.DataFrame.apply column-wise (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. 与常规 for 循环相比,df.apply(pd.to_datetime) 处理字符串不能很好地随行扩展。

【讨论】:

  • @coldspeed,谢谢,您的帖子没有什么问题(除了与我的一些矛盾的基准测试,但可以基于输入或设置)。只是觉得有一种不同的方式来看待这个问题。
  • @jpp 我一直使用你的优秀流程图作为指导,直到今天我看到row-wise applymy solutionany 快得多。对此有什么想法吗?
  • @Stef,你在看多少行数据?构建一个包含 1mio+ 行的数据框并尝试比较逻辑,apply 应该更慢。另请注意,问题可能是mask(尝试改用np.where)。一个需要 3-5 毫秒的过程不适合进行基准测试,因为实际上,当时间如此之短时,您可能并不关心性能。
  • @jpp:你是对的:对于 1mio 行 x 100 列,anyapply 快大约 100 倍。它使用 2000 行 x 1000 列进行了我的第一次测试,这里 apply 的速度是 any 的两倍
  • @jpp 我想在演示文稿/文章中使用您的图片。你同意吗?我显然会提到来源。谢谢
【解决方案3】:

对于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)

【讨论】:

  • 我很惊讶地发现这在某些情况下给了我更好的性能。当我需要做多件事情时,它特别有用,每件事情都有不同的列值子集。 “所有应用都不相同”的答案可能有助于确定何时可能会有所帮助,但对数据样本进行测试并不是非常困难。
  • 几点建议:就性能而言,列表推导会胜过 for 循环; zip(df, row[1:]) 在这里就足够了;真的,在这个阶段,如果 func 是数字计算,请考虑 numba。有关说明,请参阅 this answer
  • @jpp - 如果你有更好的功能,请分享。从我的分析来看,我认为这非常接近最优值。是的,numba 更快,faster_df_apply 是为那些只想要与DataFrame.apply 等效但比DataFrame.apply 更快的人设计的。
  • 这实际上与.apply 的实现方式非常接近,但它做了一件显着减慢它的事情,它本质上是:row = pd.Series({f:v for f,v in zip(cols, row[1:])}) 这增加了很多阻力。我写了一个answer 描述了实现,尽管我认为它已经过时了,最近的版本试图在.apply 中利用 Cython,我相信(不要引用我的话)
  • @juanpa.arrivillaga 完美地解释了它!非常感谢。
【解决方案4】:

有没有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 的提醒,我忘记了普通的 ol 列表理解。

【讨论】:

  • 嗯,不。这比[unidecode.unidecode(x) for x in s]list(map(unidecode.unidecode, s)) 好多少?
  • 既然已经是pandas系列了,我就想用apply,是的,你说得对,用list-comp比apply好,但是downvote有点苛刻,我不是在提倡@ 987654328@,只是认为这可能是一个很好的用例。
猜你喜欢
  • 1970-01-01
  • 2010-11-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多