Маска панд/где методы против NumPy np.where

Я часто использую mask Pandas и where методы для более чистой логики при обновлении значений в серии условно. Однако для относительно критически важного кода я замечаю значительное падение производительности по сравнению с numpy.where.

Хотя я рад принять это в конкретных случаях, мне интересно узнать:

  1. mask Pandas/where методы предлагают какие-либо дополнительные функции, помимо параметров inplace/errors/try-cast? Я понимаю эти 3 параметра, но редко их использую. Например, я понятия не имею, к чему относится параметр 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

Я использую 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)

Версия для компакт-дисков примерно в 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, чтобы символ не был разрешен.

А для панд мы получаем (см. Приложение 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 памяти - __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).

Я не вижу сценария, в котором панды могли бы стать быстрее, чем numpy в версии pansas 0.19, - это просто добавляет накладные расходы к функциональности numpy. Версия Pandas 0.23.3 - совершенно другая история - здесь используется numexpr-модуль, очень возможно, что есть сценарии, для которых версия pandas (по крайней мере, немного) быстрее.

Я не уверен, что это копирование памяти действительно вызвано/необходимо - возможно, даже можно назвать это ошибкой производительности, но я просто недостаточно уверен, чтобы быть уверенным.

Мы могли бы помочь пандам не копировать, отрывая некоторые косвенные действия (передавая np.array вместо pd.Series). Например:

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

Теперь, панды только на 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, которая в основном отвечает за накладные расходы.

Мой ключ от него:

  • pandas может быть как минимум немного быстрее, чем numpy (потому что это возможно быть быстрее). Тем не менее, непрозрачная обработка данных pandas при копировании данных затрудняет прогнозирование, когда этот потенциал омрачается (ненужным) копированием данных.

  • когда производительность where/mask является узким местом, я бы использовал numba/cython для повышения производительности - см. мои довольно наивные попытки использовать numba и cython далее ниже.


Идея состоит в том, чтобы принять

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

версии и устранить необходимость создания временного - т.е. df[0]*2.

Как предложено @max9111, используя numba:

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)

Что касается фактора 5 быстрее, чем версия numpy!

И вот моя гораздо менее успешная попытка улучшить производительность с помощью 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)