Pandas mask / where方法与NumPy np.where方法的区别

38
我经常使用Pandas的maskwhere方法来更新系列值,以实现更清晰的逻辑条件。然而,对于相对性能关键的代码,我注意到与numpy.where相比存在显著的性能下降。
虽然我可以接受特定情况下的这种性能差异,但我想知道:
  1. 除了inplace / errors / try-cast参数之外,Pandas的mask / where方法是否提供任何其他功能?我理解这三个参数,但很少使用它们。例如,我不知道level参数是什么意思。
  2. 是否有任何非平凡的反例,其中mask / where优于numpy.where?如果存在这样的示例,它可能影响我选择适当的方法。
参考以下是在Pandas 0.19.2 / Python 3.6.0上的一些基准测试:
np.random.seed(0)

n = 10000000
df = pd.DataFrame(np.random.random(n))

assert (df[0].mask(df[0] > 0.5, 1).values == np.where(df[0] > 0.5, 1, df[0])).all()

%timeit df[0].mask(df[0] > 0.5, 1)       # 145 ms per loop
%timeit np.where(df[0] > 0.5, 1, df[0])  # 113 ms per loop

非标量值的性能似乎会更加分散:

%timeit df[0].mask(df[0] > 0.5, df[0]*2)       # 338 ms per loop
%timeit np.where(df[0] > 0.5, df[0]*2, df[0])  # 153 ms per loop

1
@ead,这很有趣。在这种情况下,答案可能会评论有什么变化(以及哪个版本?)。我没有看到任何实现更改的提及。此外,您是否认为两个示例具有相同的性能? - jpp
2
好的,我忽略了第二个例子。对于第一个例子,我得到了 96.9ms vs 92.7ms,而对于第二个例子,我得到了 276ms vs 120ms - ead
4
我需要搜索源代码才能得到一个复杂的答案。但从内存使用情况来看,df [0] .mask 进行了更多的临时内存分配,而 np.where 则没有。我还在 Numba 中实现了一个并行化版本,在原地更新时比 np.where 快8倍,在非原地更新时只快3倍。我猜测 np.where 是一个简单的编译过的循环语句(for,if,else),就像我的 Numba 解决方案一样,而 pandas 则创建了一个临时掩码数组。 - max9111
@max9111,这非常有趣;实际上超出了我精确的问题。我假设numba只能处理数字输入,对于我的用例来说这很好。如果有一个临时掩码数组,我想mask/where永远无法超过np.where - jpp
1
不适用于大数组。对于小数组,其中临时数据适合CPU缓存,这可能是相同的(取决于精确实现和编译器/编译器设置)。关于缓存效应的示例:https://dev59.com/5anka4cB1Zd3GeqPKTSg - max9111
1个回答

44

我使用的是 pandas 0.23.3 和 Python 3.6,在你的第二个例子中,只有使用这些版本才能看到真正的运行时间差异。

不过,让我们来探究一下稍微不同的第二个例子(因此我们可以先处理掉2*df[0])。在我的机器上,这是我们的基准:

twice = df[0]*2
mask = df[0] > 0.5
%timeit np.where(mask, twice, df[0])  
# 61.4 ms ± 1.51 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit df[0].mask(mask, twice)
# 143 ms ± 5.27 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Numpy的版本大约比pandas快2.3倍。

因此,让我们对两个函数进行性能分析,以了解它们之间的差异 - 当一个人不太熟悉代码基础时,性能分析是获取整体情况的好方法:它比调试更快,比仅通过阅读代码来弄清楚正在发生什么要少出错。

我使用Linux,并使用perf。对于Numpy的版本,我们得到以下结果(列表见附录A):

>>> perf record python np_where.py
>>> perf report

Overhead  Command  Shared Object                                Symbol                              
  68,50%  python   multiarray.cpython-36m-x86_64-linux-gnu.so   [.] PyArray_Where
   8,96%  python   [unknown]                                    [k] 0xffffffff8140290c
   1,57%  python   mtrand.cpython-36m-x86_64-linux-gnu.so       [.] rk_random

如我们所见,大部分时间都花在了 PyArray_Where 上 - 约69%。未知的符号是一个内核函数(实际上是 clear_page)- 我没有 root 权限运行,因此该符号未被解析。

而对于 pandas,我们得到以下结果(有关代码请参见附录 B):

>>> perf record python pd_mask.py
>>> perf report

Overhead  Command  Shared Object                                Symbol                                                                                               
  37,12%  python   interpreter.cpython-36m-x86_64-linux-gnu.so  [.] vm_engine_iter_task
  23,36%  python   libc-2.23.so                                 [.] __memmove_ssse3_back
  19,78%  python   [unknown]                                    [k] 0xffffffff8140290c
   3,32%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] DOUBLE_isnan
   1,48%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] BOOL_logical_not

情况大不相同:

  • pandas在底层不使用PyArray_Where——最耗时的是vm_engine_iter_task,它是numexpr功能
  • 有一些严重的内存复制正在进行——__memmove_ssse3_back约占25%的时间!也许一些内核函数也与内存访问有关。

实际上,pandas-0.19在底层使用了PyArray_Where,对于旧版本来说,性能报告会看起来像这样:

Overhead  Command        Shared Object                     Symbol                                                                                                     
  32,42%  python         multiarray.so                     [.] PyArray_Where
  30,25%  python         libc-2.23.so                      [.] __memmove_ssse3_back
  21,31%  python         [kernel.kallsyms]                 [k] clear_page
   1,72%  python         [kernel.kallsyms]                 [k] __schedule

基本上它会在幕后使用 np.where 加一些开销(所有上述数据复制,参见 __memmove_ssse3_back)。

我想在 pandas 版本 0.19 中没有场景能让 pandas 变得比 numpy 更快 - 它只是为 numpy 的功能增加了开销。然而,在完全不同的故事中,pandas 版本 0.23.3 使用了 numexpr 模块,很可能存在某些场景使得 pandas 的版本(至少略微)更快。

我不确定这种内存复制是否确实需要/必要 - 或许它甚至被称为性能缺陷,但我不确定知道得足够多。我们可以通过剥离一些间接层(传递 np.array 而非 pd.Series)来帮助 pandas 不进行复制。例如:

%timeit df[0].mask(mask.values > 0.5, twice.values)
# 75.7 ms ± 1.5 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

现在,pandas 只慢了25%。性能指标如下:

Overhead  Command  Shared Object                                Symbol                                                                                                
  50,81%  python   interpreter.cpython-36m-x86_64-linux-gnu.so  [.] vm_engine_iter_task
  14,12%  python   [unknown]                                    [k] 0xffffffff8140290c
   9,93%  python   libc-2.23.so                                 [.] __memmove_ssse3_back
   4,61%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] DOUBLE_isnan
   2,01%  python   umath.cpython-36m-x86_64-linux-gnu.so        [.] BOOL_logical_not

数据复制的数量比numpy版本少了很多,但仍然比numpy版本多,这主要是导致过度开销的原因。

我的主要收获:

  • pandas有可能比numpy略快(因为可能更快),但是pandas对数据复制的处理方式不太透明,很难预测何时不必要的数据复制会使这种潜力被掩盖。

  • where/mask的性能成为瓶颈时,我会使用numba/cython来提高性能-请参阅下面进一步尝试使用numba和cython的相对天真的尝试。


思路是取

np.where(df[0] > 0.5, df[0]*2, df[0])

使用 Numba 提出的方法,可以提高版本,并消除创建临时变量的需要 - 例如,df[0]*2

import numba as nb
@nb.njit
def nb_where(df):
    n = len(df)
    output = np.empty(n, dtype=np.float64)
    for i in range(n):
        if df[i]>0.5:
            output[i] = 2.0*df[i]
        else:
            output[i] = df[i]
    return output

assert(np.where(df[0] > 0.5, twice, df[0])==nb_where(df[0].values)).all()
%timeit np.where(df[0] > 0.5, df[0]*2, df[0])
# 85.1 ms ± 1.61 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit nb_where(df[0].values)
# 17.4 ms ± 673 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

这个方法比numpy版本快大约5倍!

以下是我使用Cython尝试提高性能的方法,但效果远不如上述方法:

%%cython -a
cimport numpy as np
import numpy as np
cimport cython

@cython.boundscheck(False)
@cython.wraparound(False)
def cy_where(double[::1] df):
    cdef int i
    cdef int n = len(df)
    cdef np.ndarray[np.float64_t] output = np.empty(n, dtype=np.float64)
    for i in range(n):
        if df[i]>0.5:
            output[i] = 2.0*df[i]
        else:
            output[i] = df[i]
    return output

assert (df[0].mask(df[0] > 0.5, 2*df[0]).values == cy_where(df[0].values)).all()

%timeit cy_where(df[0].values)
# 66.7± 753 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

提升了25%的速度。不确定为什么Cython比Numba慢那么多。


清单:

A: np_where.py:

import pandas as pd
import numpy as np

np.random.seed(0)

n = 10000000
df = pd.DataFrame(np.random.random(n))

twice = df[0]*2
for _ in range(50):
      np.where(df[0] > 0.5, twice, df[0])  

B: pd_mask.py:

import pandas as pd
import numpy as np

np.random.seed(0)

n = 10000000
df = pd.DataFrame(np.random.random(n))

twice = df[0]*2
mask = df[0] > 0.5
for _ in range(50):
      df[0].mask(mask, twice)

2
据我理解,如果 other 参数的维度数量(即少于)与 df [0] 不同,则会使用 level(与 axis 参数一起使用),以控制对齐方式的某种程度。 我非常确定,在您的情况下,它不会对性能产生任何影响,并且不确定它是否应该成为此答案的一部分。 - ead
1
好的,我会尝试不同的维度来解决问题。没问题,不需要更新你的答案。它已经很棒了。 - jpp
2
@jpp 你可能已经跟踪了代码,但为了将信息保存在某个地方/供他人使用,请参考以下链接:https://github.com/pandas-dev/pandas/blob/v0.23.4/pandas/core/generic.py#L7544 和 https://pandas.pydata.org/pandas-docs/stable/generated/pandas.Series.align.html - ead
2
我猜Numba表现非常好,因为循环进行了SIMD向量化。您可以使用llvmlite.binding作为llvm来检查这一点。通过正确的C编译器设置,Cython也可能执行相同的操作。(相当于Clang编译器的-O3,-march=native) - max9111

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