Máscara de Pandas / métodos de dónde versus NumPy np.donde


A menudo uso Pandas mask y where métodos para una lógica más limpia al actualizar valores en una serie condicionalmente. Sin embargo, para el código relativamente crítico de rendimiento, noto una caída significativa del rendimiento en relación con numpy.where.

Aunque estoy feliz de aceptar esto para casos específicos, estoy interesado en saber:

  1. Do Pandas mask / where los métodos ofrecen cualquier funcionalidad adicional, aparte de inplace / errors / try-cast ¿parámetros? Me comprenda esos 3 parámetros, pero rara vez los use. Por ejemplo, no tengo idea de a qué se refiere el parámetro level.
  2. ¿Hay algún contra-ejemplo no trivial donde mask / where ¿supera numpy.where? Si tal ejemplo existe, podría influir en cómo elijo los métodos apropiados en el futuro.

Para referencia, aquí hay algunos puntos de referencia sobre 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

El rendimiento parece divergir más para no escalar valores:

%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
Author: jpp, 2018-08-23

1 answers

Estoy usando pandas 0.23.3 y Python 3.6, por lo que puedo ver una diferencia real en el tiempo de ejecución solo para su segundo ejemplo.

Pero vamos a investigar una versión ligeramente diferente de su segundo ejemplo (por lo que conseguimos2*df[0] fuera del camino). Aquí está nuestra línea de base en mi máquina:

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)

La versión de Numpy es aproximadamente 2,3 veces más rápida que la de los pandas.

Así que vamos a perfilar ambas funciones para ver la diferencia-perfilar es una buena manera de obtener el panorama general cuando uno no es muy familiarizado con la base de código: es más rápido que la depuración y menos propenso a errores que tratar de averiguar lo que está pasando con solo leer el código.

Estoy en Linux y uso perf. Para la versión de numpy obtenemos (para la lista ver apéndice 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

Como podemos ver, la mayor parte del tiempo se gasta en PyArray_Where - alrededor del 69%. El símbolo desconocido es una función del núcleo (de hecho clear_page) - corro sin privilegios de root por lo que el símbolo no es resuelto.

Y para los pandas obtenemos (véase el Apéndice B para el código):

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

Una situación bastante diferente: {[28]]}

  • pandas no usa PyArray_Where bajo el capó - el consumidor de tiempo más prominente es vm_engine_iter_task, que es numexpr-funcionalidad.
  • hay un copiado de memoria pesado en curso - __memmove_ssse3_back utiliza aproximadamente 25% del tiempo! Probablemente algunas de las funciones del núcleo también están conectadas a los accesos a la memoria.

En realidad, pandas-0.19 utilizado PyArray_Where bajo el capó, para la versión anterior el perf-report se vería como:

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

Así que básicamente usaría np.where bajo el capó + algo de sobrecarga (todo lo anterior copia de datos, ver __memmove_ssse3_back) en ese entonces.

No veo ningún escenario en el que los pandas puedan llegar a ser más rápidos que numpy en la versión 0.19 de pandas - solo agrega sobrecarga a la funcionalidad de numpy. La versión 0.23.3 de Pandas es una historia completamente diferente - aquí se utiliza numexpr-module, es muy posible que haya escenarios para los que la versión de pandas es (al menos ligeramente) más rápida.

No estoy seguro de que esta copia de memoria sea realmente necesaria/necesaria-tal vez uno incluso podría llamarlo error de rendimiento, pero simplemente no sé lo suficiente para estar seguro.

Podríamos ayudar a los pandas a no copiar, quitando algunas indirectas (pasando np.array en lugar de pd.Series). Por ejemplo:

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

Ahora, pandas es solo un 25% más lento. El perf dice:

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

Mucho menos copia de datos, pero aún más que en el numpy versión que es principalmente responsable de la sobrecarga.

Mi clave toma-aways de ella:

  • Pandas tiene el potencial de ser al menos un poco más rápido que numpy (porque es posible ser más rápido). Sin embargo, el manejo algo opaco de la copia de datos por parte de pandas hace difícil predecir cuándo este potencial se ve eclipsado por la copia de datos (innecesaria).

  • Cuando el rendimiento de where/mask es el cuello de botella, usaría numba / cython para mejorar el rendimiento-ver mi ingenuo intenta usar numba y cython más abajo.


La idea es tomar

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

Versión y para eliminar la necesidad de crear un temporal - es decir, df[0]*2.

Como propuso @max9111, usando 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)

Que es aproximadamente factor 5 más rápido que la versión de numpy!

Y aquí está mi intento mucho menos exitoso de mejorar el rendimiento con la ayuda de 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)

Da un 25% de aceleración. Ni claro, por qué Cython es mucho más lento que numba sin embargo.


Listados:

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)
 21
Author: ead,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-08-27 08:18:36