小编典典

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

all

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

我已经阅读了许多关于性能主题的文章,这些文章解释apply得很慢。我还在文档中看到了关于如何apply简单地传递 UDF
的便利功能的免责声明(现在似乎找不到)。因此,普遍的共识是,apply如果可能的话,应该避免这种情况。然而,这引发了以下问题:

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

阅读 63

收藏
2022-08-05

共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参数)。
  • 接受位置/关键字参数以传递给用户定义的函数。

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

那么,有了所有这些功能,为什么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”列中。

使用 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,您可以使用自定义函数在此基础上进行构建,然后可以在列表推导中使用参数调用。

注意
日期和日期时间操作也有矢量化版本。因此,例如,您应该更喜欢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请注意,使用或仅使用显式循环也是有意义的。所有这些选项都比 using 稍快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将 Series 中的整数转换为字符串与使用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

在 <0.25 的 pandas 版本中也可以看到这种行为GroupBy.apply

2022-08-05