Máscara de pandas / donde los métodos versus NumPy np.where

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

Aunque me complace aceptar esto para casos específicos, me interesa saber:

  1. ¿Los métodos de mask / where Pandas ofrecen alguna funcionalidad adicional, aparte de los inplace / errors / try-cast ? Entiendo esos 3 parámetros pero raramente los uso. Por ejemplo, no tengo idea de a qué se refiere el parámetro de level .
  2. ¿Hay algún contraejemplo no trivial donde mask / where supere a numpy.where ? Si existe un ejemplo de este tipo, podría influir en la forma en que elijo los métodos apropiados para avanzar.

Para referencia, aquí hay algunos puntos de referencia en 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 los valores no escalares:

 %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 

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

Pero investiguemos una versión ligeramente diferente de tu segundo ejemplo (así obtenemos 2*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 los pandas.

Así que perfilemos ambas funciones para ver la diferencia: crear perfiles es una buena manera de obtener una visión general cuando uno no está muy familiarizado con el código: es más rápido que depurar y es menos propenso a errores que intentar averiguar qué está pasando sólo con leer el código.

Estoy en Linux y uso perf . Para la versión de la numpy que obtenemos (para el listado, ver el 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 kernel (de hecho, clear_page ) – Corro sin privilegios de root, por lo que el símbolo no se resuelve.

Y para los pandas que obtenemos (ver 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:

  • pandas no usa PyArray_Where bajo el capó: el consumidor de tiempo más importante es vm_engine_iter_task , que es la funcionalidad numexpr .
  • se está realizando una gran cantidad de copias de memoria: __memmove_ssse3_back usa aproximadamente el 25 % del tiempo. Probablemente algunas de las funciones del kernel también están conectadas a los accesos a la memoria.

En realidad, pandas-0.19 usó PyArray_Where bajo el capó, para la versión anterior el informe de perf se vería así:

 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 

Entonces, básicamente, usaría np.where bajo el capó + algo de sobrecarga (todo lo anterior, copia de datos, vea __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 los números en la versión 0.19 de los pandas, solo agrega sobrecarga a la funcionalidad de números. La versión 0.23.3 de Pandas es una historia completamente diferente: aquí se usa numexpr-module, es muy posible que haya escenarios en los que la versión de pandas sea (al menos ligeramente) más rápida.

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

Podríamos ayudar a los pandas a no copiar, despegando algunas indirecciones (pasando np.array 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, los pandas son solo un 25% más lentos. 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 la versión del número, que es principalmente responsable de la sobrecarga.

Mi clave para sacar de ella:

  • pandas tiene el potencial de ser al menos un poco más rápido que los números (porque es posible que sea más rápido). Sin embargo, el manejo un tanto opaco de la copia de datos por parte de los pandas hace que sea difícil predecir cuándo este potencial se ve opacado 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; mira mis bashs bastante ingenuos de usar numba y cython más adelante.


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 archivo temporal, es decir, df[0]*2 .

Según lo propuesto por @ 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) 

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

Y aquí está mi bash 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. No estoy seguro, porque cython es mucho más lento que numba.


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)