我什么时候应该(不)想在我的代码中使用 pandas apply() ?

新手上路,请多包涵

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

我读过很多关于性能主题的文章,解释 apply 很慢。我还在文档中看到了关于 apply 如何只是传递 UDF 的便利函数(现在似乎找不到)的免责声明。因此,普遍的共识是 apply 应该尽可能避免。然而,这提出了以下问题:

  1. 如果 apply 太糟糕了,那么它为什么会出现在 API 中?
  2. 我应该如何以及何时使我的代码 apply 免费?
  3. 是否有任何情况 apply 是 _好的_(比其他可能的解决方案更好)?

原文由 cs95 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 569
2 个回答

apply ,你永远不需要的便捷功能

我们首先逐一解决 OP 中的问题。

“如果 apply 太糟糕了,那为什么它会出现在 API 中?”

DataFrame.applySeries.apply 分别是定义在DataFrame和Series对象上的 _便捷函数_。 apply 接受任何在 DataFrame 上应用转换/聚合的用户定义函数。 apply 实际上是一颗灵丹妙药,可以完成任何现有熊猫函数无法完成的任务。

有些事情 apply 可以做:

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

…等等。有关详细信息,请参阅文档中的按 行或按列的函数应用

那么,有了所有这些功能,为什么 apply 不好?这是 因为 apply 。 Pandas 不会对您的函数的性质做出任何假设,因此会根据需要 迭代地将您的函数 应用于每一行/列。此外,处理上述 所有 情况意味着 apply 每次迭代都会产生一些主要开销。此外, 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

在性能方面,没有可比性,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”列中。

使用应用,这将使用

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?我什么时候应该关心? .

笔记

日期和日期时间操作也有矢量化版本。因此,例如,您应该更喜欢 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 进行矢量化,因此必须将它们 应用于 您要转换/操作的每一列。

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

比/秒

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 一样快,或稍快一些。所以这与测试中的数据是整数类型有关。


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

很难量化性能,因为它取决于数据。但总的来说,如果目标是减少 apply 是一个可接受的解决方案 groupby 调用(因为 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 上(它已为 0.25 修复, 有关更多信息,请参见此处。)

原文由 cs95 发布,翻译遵循 CC BY-SA 4.0 许可协议

并非所有 apply 都是相似的

下表建议何时考虑 apply 1 。绿色意味着可能有效;红色避免。

在此处输入图像描述

其中一些 是直观的: 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 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. df.apply(pd.to_datetime) 与常规 for 循环相比,处理字符串不能很好地扩展行。

原文由 jpp 发布,翻译遵循 CC BY-SA 4.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题