python - Máscara de pandas/donde los métodos versus NumPy np.where
performance series (1)
A menudo utilizo la mask
Pandas y where
métodos para una lógica más limpia al actualizar los valores en una serie condicionalmente. 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:
- ¿Los métodos de
mask
/where
Pandas ofrecen alguna funcionalidad adicional, aparte de losinplace
/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 delevel
. - ¿Hay algún contraejemplo no trivial donde
mask
/where
supere anumpy.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 aún 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 la base del código: es más rápido que la depuración 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 (como 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 esvm_engine_iter_task
, que es numexpr-functionality . - se está realizando una gran cantidad de copias de memoria:
__memmove_ssse3_back
usa aproximadamente el25
% 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
Básicamente, usaría np.where
bajo el capó + algo de sobrecarga (todos los datos de copia anteriores, 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 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, eliminando 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 numpy (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 intentos 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 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. 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)