python - index - ¿Por qué las funciones numpy son tan lentas en pandas series/dataframes?
pandas python tutorial (4)
Hay dos partes en la diferencia de rendimiento a tener en cuenta aquí:
- Sobrecarga de Python en cada biblioteca (los
pandas
son de mucha ayuda) - Diferencia en la implementación del algoritmo numérico (
pd.clip
realmente llama anp.where
)
Ejecutar esto en una matriz muy pequeña debe demostrar la diferencia en la sobrecarga de Python. Para Numpy, es comprensible que sea muy pequeño, sin embargo, los pandas realizan muchas comprobaciones (valores nulos, procesamiento de argumentos más flexible, etc.) antes de llegar a la gran cantidad de crujidos. Intenté mostrar un desglose aproximado de las etapas por las que pasan los dos códigos antes de llegar al lecho rocoso del código C.
data = pd.Series(np.random.random(100))
Cuando se utiliza np.clip
en un ndarray
, la sobrecarga es simplemente la función numpy wrapper que llama al método del objeto:
>>> %timeit np.clip(data.values, 0.2, 0.8) # numpy wrapper, calls .clip() on the ndarray
>>> %timeit data.values.clip(0.2, 0.8) # C function call
2.22 µs ± 125 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
1.32 µs ± 20.4 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
Los pandas gastan más tiempo en buscar casos extremos antes de llegar al algoritmo:
>>> %timeit np.clip(data, a_min=0.2, a_max=0.8) # numpy wrapper, calls .clip() on the Series
>>> %timeit data.clip(lower=0.2, upper=0.8) # pandas API method
>>> %timeit data._clip_with_scalar(0.2, 0.8) # lowest level python function
102 µs ± 1.54 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
90.4 µs ± 1.01 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
73.7 µs ± 805 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
En relación con el tiempo total, la sobrecarga de ambas bibliotecas antes de tocar el código C es bastante significativa. Para numpy, la instrucción de envoltura única toma tanto tiempo para ejecutarse como el procesamiento numérico. Pandas tiene ~ 30x más de sobrecarga justo en las primeras dos capas de llamadas a funciones.
Para aislar lo que está sucediendo a nivel de algoritmo, debemos verificar esto en una matriz más grande y comparar las mismas funciones:
>>> data = pd.Series(np.random.random(1000000))
>>> %timeit np.clip(data.values, 0.2, 0.8)
>>> %timeit data.values.clip(0.2, 0.8)
2.85 ms ± 37.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
2.85 ms ± 15.9 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit np.clip(data, a_min=0.2, a_max=0.8)
>>> %timeit data.clip(lower=0.2, upper=0.8)
>>> %timeit data._clip_with_scalar(0.2, 0.8)
12.3 ms ± 135 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
12.3 ms ± 115 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
12.2 ms ± 76.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
La sobrecarga python en ambos casos es ahora insignificante; el tiempo para las funciones de contenedor y la comprobación de argumentos es pequeño en relación con el tiempo de cálculo en 1 millón de valores. Sin embargo, hay una diferencia de velocidad de 3-4x que se puede atribuir a la implementación numérica. Al investigar un poco en el código fuente, vemos que la implementación de pandas
de clip
realmente usa np.where
, no np.clip
:
def clip_where(data, lower, upper):
'''''' Actual implementation in pd.Series._clip_with_scalar (minus NaN handling). ''''''
result = data.values
result = np.where(result >= upper, upper, result)
result = np.where(result <= lower, lower, result)
return pd.Series(result)
def clip_clip(data, lower, upper):
'''''' What would happen if we used ndarray.clip instead. ''''''
return pd.Series(data.values.clip(lower, upper))
El esfuerzo adicional requerido para verificar cada condición booleana por separado antes de hacer una sustitución condicional parece dar cuenta de la diferencia de velocidad. Especificar tanto la parte upper
como la lower
daría como resultado 4 pasadas a través de la matriz numpy (dos controles de desigualdad y dos llamadas a np.where
). La comparación de estas dos funciones muestra que la relación de velocidad 3-4x:
>>> %timeit clip_clip(data, lower=0.2, upper=0.8)
>>> %timeit clip_where(data, lower=0.2, upper=0.8)
11.1 ms ± 101 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
2.97 ms ± 76.7 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
No estoy seguro de por qué los desarrolladores de pandas fueron con esta implementación. np.clip
puede ser una función API más nueva que anteriormente requería una solución alternativa. También hay un poco más de lo que he explicado aquí, ya que los pandas verifican varios casos antes de ejecutar el algoritmo final, y esta es solo una de las implementaciones que se pueden llamar.
Considere un MWE pequeño, tomado de otra pregunta :
DateTime Data
2017-11-21 18:54:31 1
2017-11-22 02:26:48 2
2017-11-22 10:19:44 3
2017-11-22 15:11:28 6
2017-11-22 23:21:58 7
2017-11-28 14:28:28 28
2017-11-28 14:36:40 0
2017-11-28 14:59:48 1
El objetivo es recortar todos los valores con un límite superior de 1. Mi respuesta usa np.clip
, que funciona bien.
np.clip(df.Data, a_min=None, a_max=1)
array([1, 1, 1, 1, 1, 1, 0, 1])
O,
np.clip(df.Data.values, a_min=None, a_max=1)
array([1, 1, 1, 1, 1, 1, 0, 1])
Ambos devuelven la misma respuesta. Mi pregunta es sobre el rendimiento relativo de estos dos métodos. Considerar -
df = pd.concat([df]*1000).reset_index(drop=True)
%timeit np.clip(df.Data, a_min=None, a_max=1)
1000 loops, best of 3: 270 µs per loop
%timeit np.clip(df.Data.values, a_min=None, a_max=1)
10000 loops, best of 3: 23.4 µs per loop
¿Por qué hay una diferencia tan grande entre los dos, simplemente llamando values
a este último? En otras palabras...
¿Por qué las funciones numpy son tan lentas en los pandas?
La razón por la que el rendimiento es diferente es porque numpy primero tiende a buscar la implementación de pandas de la función utilizando getattr
que haciendo lo mismo en las funciones numpy incorporadas cuando se pasa un objeto pandas.
No es el numpy sobre el objeto pandas que es lento, es la versión de los pandas.
Cuando tu lo hagas
np.clip(pd.Series([1,2,3,4,5]),a_min=None,amax=1)
_wrapfunc
se llama:
# Code from source
def _wrapfunc(obj, method, *args, **kwds):
try:
return getattr(obj, method)(*args, **kwds)
Debido al método getattr
:
getattr(pd.Series([1,2,3,4,5]),''clip'')(None, 1)
# Equivalent to `pd.Series([1,2,3,4,5]).clip(lower=None,upper=1)`
# 0 1
# 1 1
# 2 1
# 3 1
# 4 1
# dtype: int64
Si pasas por la implementación de pandas, hay mucho trabajo de verificación previa realizado. Es la razón por la cual las funciones que tienen la implementación de pandas a través de numpy tienen tanta diferencia en la velocidad.
No solo clip, funciones como cumsum
, cumprod
, reshape
, searchsorted
, transpose
y mucho más utiliza la versión panda de ellos que numpy cuando les pasas un objeto pandas.
Puede parecer molesto hacer el trabajo sobre esos objetos, pero bajo la capucha es la función de los pandas.
Sí, parece que np.clip
es mucho más lento en pandas.Series
que en numpy.ndarray
s. Eso es correcto, pero en realidad (al menos asintomáticamente) no es tan malo. 8000 elementos aún se encuentran en el régimen, donde los factores constantes son los principales contribuyentes en el tiempo de ejecución. Creo que este es un aspecto muy importante de la pregunta, así que estoy visualizando esto (tomando prestado de otra respuesta ):
# Setup
import pandas as pd
import numpy as np
def on_series(s):
return np.clip(s, a_min=None, a_max=1)
def on_values_of_series(s):
return np.clip(s.values, a_min=None, a_max=1)
# Timing setup
timings = {on_series: [], on_values_of_series: []}
sizes = [2**i for i in range(1, 26, 2)]
# Timing
for size in sizes:
func_input = pd.Series(np.random.randint(0, 30, size=size))
for func in timings:
res = %timeit -o func(func_input)
timings[func].append(res)
%matplotlib notebook
import matplotlib.pyplot as plt
import numpy as np
fig, (ax1, ax2) = plt.subplots(1, 2)
for func in timings:
ax1.plot(sizes,
[time.best for time in timings[func]],
label=str(func.__name__))
ax1.set_xscale(''log'')
ax1.set_yscale(''log'')
ax1.set_xlabel(''size'')
ax1.set_ylabel(''time [seconds]'')
ax1.grid(which=''both'')
ax1.legend()
baseline = on_values_of_series # choose one function as baseline
for func in timings:
ax2.plot(sizes,
[time.best / ref.best for time, ref in zip(timings[func], timings[baseline])],
label=str(func.__name__))
ax2.set_yscale(''log'')
ax2.set_xscale(''log'')
ax2.set_xlabel(''size'')
ax2.set_ylabel(''time relative to {}''.format(baseline.__name__))
ax2.grid(which=''both'')
ax2.legend()
plt.tight_layout()
Es un diagrama de registro y registro porque creo que esto muestra las características más importantes más claramente. Por ejemplo, muestra que np.clip
en numpy.ndarray
es más rápido, pero también tiene un factor constante mucho más pequeño en ese caso. ¡La diferencia para las matrices grandes es solo ~ 3! Esa sigue siendo una gran diferencia, pero mucho menos que la diferencia en arreglos pequeños.
Sin embargo, todavía no es una respuesta a la pregunta de dónde viene la diferencia de tiempo.
La solución es bastante simple: np.clip
delega en el método de clip
del primer argumento:
>>> np.clip??
Source:
def clip(a, a_min, a_max, out=None):
"""
...
"""
return _wrapfunc(a, ''clip'', a_min, a_max, out=out)
>>> np.core.fromnumeric._wrapfunc??
Source:
def _wrapfunc(obj, method, *args, **kwds):
try:
return getattr(obj, method)(*args, **kwds)
# ...
except (AttributeError, TypeError):
return _wrapit(obj, method, *args, **kwds)
La línea getattr
de la función _wrapfunc
es la línea importante aquí, porque np.ndarray.clip
y pd.Series.clip
son métodos diferentes, sí, métodos completamente diferentes :
>>> np.ndarray.clip
<method ''clip'' of ''numpy.ndarray'' objects>
>>> pd.Series.clip
<function pandas.core.generic.NDFrame.clip>
Desafortunadamente, np.ndarray.clip
es una función C, por lo que es difícil pd.Series.clip
un perfil, sin embargo, pd.Series.clip
es una función normal de Python, por lo que es fácil pd.Series.clip
un perfil. Usemos una serie de enteros 5000 aquí:
s = pd.Series(np.random.randint(0, 100, 5000))
Para np.clip
en los values
obtengo el siguiente perfil de línea:
%load_ext line_profiler
%lprun -f np.clip -f np.core.fromnumeric._wrapfunc np.clip(s.values, a_min=None, a_max=1)
Timer unit: 4.10256e-07 s
Total time: 2.25641e-05 s
File: numpy/core/fromnumeric.py
Function: clip at line 1673
Line # Hits Time Per Hit % Time Line Contents
==============================================================
1673 def clip(a, a_min, a_max, out=None):
1674 """
...
1726 """
1727 1 55 55.0 100.0 return _wrapfunc(a, ''clip'', a_min, a_max, out=out)
Total time: 1.51795e-05 s
File: numpy/core/fromnumeric.py
Function: _wrapfunc at line 55
Line # Hits Time Per Hit % Time Line Contents
==============================================================
55 def _wrapfunc(obj, method, *args, **kwds):
56 1 2 2.0 5.4 try:
57 1 35 35.0 94.6 return getattr(obj, method)(*args, **kwds)
58
59 # An AttributeError occurs if the object does not have
60 # such a method in its class.
61
62 # A TypeError occurs if the object does have such a method
63 # in its class, but its signature is not identical to that
64 # of NumPy''s. This situation has occurred in the case of
65 # a downstream library like ''pandas''.
66 except (AttributeError, TypeError):
67 return _wrapit(obj, method, *args, **kwds)
Pero para np.clip
en la Series
obtengo un resultado de perfil totalmente diferente:
%lprun -f np.clip -f np.core.fromnumeric._wrapfunc -f pd.Series.clip -f pd.Series._clip_with_scalar np.clip(s, a_min=None, a_max=1)
Timer unit: 4.10256e-07 s
Total time: 0.000823794 s
File: numpy/core/fromnumeric.py
Function: clip at line 1673
Line # Hits Time Per Hit % Time Line Contents
==============================================================
1673 def clip(a, a_min, a_max, out=None):
1674 """
...
1726 """
1727 1 2008 2008.0 100.0 return _wrapfunc(a, ''clip'', a_min, a_max, out=out)
Total time: 0.00081846 s
File: numpy/core/fromnumeric.py
Function: _wrapfunc at line 55
Line # Hits Time Per Hit % Time Line Contents
==============================================================
55 def _wrapfunc(obj, method, *args, **kwds):
56 1 2 2.0 0.1 try:
57 1 1993 1993.0 99.9 return getattr(obj, method)(*args, **kwds)
58
59 # An AttributeError occurs if the object does not have
60 # such a method in its class.
61
62 # A TypeError occurs if the object does have such a method
63 # in its class, but its signature is not identical to that
64 # of NumPy''s. This situation has occurred in the case of
65 # a downstream library like ''pandas''.
66 except (AttributeError, TypeError):
67 return _wrapit(obj, method, *args, **kwds)
Total time: 0.000804922 s
File: pandas/core/generic.py
Function: clip at line 4969
Line # Hits Time Per Hit % Time Line Contents
==============================================================
4969 def clip(self, lower=None, upper=None, axis=None, inplace=False,
4970 *args, **kwargs):
4971 """
...
5021 """
5022 1 12 12.0 0.6 if isinstance(self, ABCPanel):
5023 raise NotImplementedError("clip is not supported yet for panels")
5024
5025 1 10 10.0 0.5 inplace = validate_bool_kwarg(inplace, ''inplace'')
5026
5027 1 69 69.0 3.5 axis = nv.validate_clip_with_axis(axis, args, kwargs)
5028
5029 # GH 17276
5030 # numpy doesn''t like NaN as a clip value
5031 # so ignore
5032 1 158 158.0 8.1 if np.any(pd.isnull(lower)):
5033 1 3 3.0 0.2 lower = None
5034 1 26 26.0 1.3 if np.any(pd.isnull(upper)):
5035 upper = None
5036
5037 # GH 2747 (arguments were reversed)
5038 1 1 1.0 0.1 if lower is not None and upper is not None:
5039 if is_scalar(lower) and is_scalar(upper):
5040 lower, upper = min(lower, upper), max(lower, upper)
5041
5042 # fast-path for scalars
5043 1 1 1.0 0.1 if ((lower is None or (is_scalar(lower) and is_number(lower))) and
5044 1 28 28.0 1.4 (upper is None or (is_scalar(upper) and is_number(upper)))):
5045 1 1654 1654.0 84.3 return self._clip_with_scalar(lower, upper, inplace=inplace)
5046
5047 result = self
5048 if lower is not None:
5049 result = result.clip_lower(lower, axis, inplace=inplace)
5050 if upper is not None:
5051 if inplace:
5052 result = self
5053 result = result.clip_upper(upper, axis, inplace=inplace)
5054
5055 return result
Total time: 0.000662153 s
File: pandas/core/generic.py
Function: _clip_with_scalar at line 4920
Line # Hits Time Per Hit % Time Line Contents
==============================================================
4920 def _clip_with_scalar(self, lower, upper, inplace=False):
4921 1 2 2.0 0.1 if ((lower is not None and np.any(isna(lower))) or
4922 1 25 25.0 1.5 (upper is not None and np.any(isna(upper)))):
4923 raise ValueError("Cannot use an NA value as a clip threshold")
4924
4925 1 22 22.0 1.4 result = self.values
4926 1 571 571.0 35.4 mask = isna(result)
4927
4928 1 95 95.0 5.9 with np.errstate(all=''ignore''):
4929 1 1 1.0 0.1 if upper is not None:
4930 1 141 141.0 8.7 result = np.where(result >= upper, upper, result)
4931 1 33 33.0 2.0 if lower is not None:
4932 result = np.where(result <= lower, lower, result)
4933 1 73 73.0 4.5 if np.any(mask):
4934 result[mask] = np.nan
4935
4936 1 90 90.0 5.6 axes_dict = self._construct_axes_dict()
4937 1 558 558.0 34.6 result = self._constructor(result, **axes_dict).__finalize__(self)
4938
4939 1 2 2.0 0.1 if inplace:
4940 self._update_inplace(result)
4941 else:
4942 1 1 1.0 0.1 return result
Dejé de ir a las subrutinas en ese punto porque ya destaca dónde el pd.Series.clip
hace mucho más trabajo que el np.ndarray.clip
. Simplemente compare el tiempo total de la llamada np.clip
en los values
(55 unidades de temporizador) a uno de los primeros controles en el método pandas.Series.clip
, el if np.any(pd.isnull(lower))
(158 unidades). En ese momento, el método de los pandas ni siquiera comenzó en el clipping y ya lleva 3 veces más.
Sin embargo, varios de estos "gastos generales" se vuelven insignificantes cuando el conjunto es grande:
s = pd.Series(np.random.randint(0, 100, 1000000))
%lprun -f np.clip -f np.core.fromnumeric._wrapfunc -f pd.Series.clip -f pd.Series._clip_with_scalar np.clip(s, a_min=None, a_max=1)
Timer unit: 4.10256e-07 s
Total time: 0.00593476 s
File: numpy/core/fromnumeric.py
Function: clip at line 1673
Line # Hits Time Per Hit % Time Line Contents
==============================================================
1673 def clip(a, a_min, a_max, out=None):
1674 """
...
1726 """
1727 1 14466 14466.0 100.0 return _wrapfunc(a, ''clip'', a_min, a_max, out=out)
Total time: 0.00592779 s
File: numpy/core/fromnumeric.py
Function: _wrapfunc at line 55
Line # Hits Time Per Hit % Time Line Contents
==============================================================
55 def _wrapfunc(obj, method, *args, **kwds):
56 1 1 1.0 0.0 try:
57 1 14448 14448.0 100.0 return getattr(obj, method)(*args, **kwds)
58
59 # An AttributeError occurs if the object does not have
60 # such a method in its class.
61
62 # A TypeError occurs if the object does have such a method
63 # in its class, but its signature is not identical to that
64 # of NumPy''s. This situation has occurred in the case of
65 # a downstream library like ''pandas''.
66 except (AttributeError, TypeError):
67 return _wrapit(obj, method, *args, **kwds)
Total time: 0.00591302 s
File: pandas/core/generic.py
Function: clip at line 4969
Line # Hits Time Per Hit % Time Line Contents
==============================================================
4969 def clip(self, lower=None, upper=None, axis=None, inplace=False,
4970 *args, **kwargs):
4971 """
...
5021 """
5022 1 17 17.0 0.1 if isinstance(self, ABCPanel):
5023 raise NotImplementedError("clip is not supported yet for panels")
5024
5025 1 14 14.0 0.1 inplace = validate_bool_kwarg(inplace, ''inplace'')
5026
5027 1 97 97.0 0.7 axis = nv.validate_clip_with_axis(axis, args, kwargs)
5028
5029 # GH 17276
5030 # numpy doesn''t like NaN as a clip value
5031 # so ignore
5032 1 125 125.0 0.9 if np.any(pd.isnull(lower)):
5033 1 2 2.0 0.0 lower = None
5034 1 30 30.0 0.2 if np.any(pd.isnull(upper)):
5035 upper = None
5036
5037 # GH 2747 (arguments were reversed)
5038 1 2 2.0 0.0 if lower is not None and upper is not None:
5039 if is_scalar(lower) and is_scalar(upper):
5040 lower, upper = min(lower, upper), max(lower, upper)
5041
5042 # fast-path for scalars
5043 1 2 2.0 0.0 if ((lower is None or (is_scalar(lower) and is_number(lower))) and
5044 1 32 32.0 0.2 (upper is None or (is_scalar(upper) and is_number(upper)))):
5045 1 14092 14092.0 97.8 return self._clip_with_scalar(lower, upper, inplace=inplace)
5046
5047 result = self
5048 if lower is not None:
5049 result = result.clip_lower(lower, axis, inplace=inplace)
5050 if upper is not None:
5051 if inplace:
5052 result = self
5053 result = result.clip_upper(upper, axis, inplace=inplace)
5054
5055 return result
Total time: 0.00575753 s
File: pandas/core/generic.py
Function: _clip_with_scalar at line 4920
Line # Hits Time Per Hit % Time Line Contents
==============================================================
4920 def _clip_with_scalar(self, lower, upper, inplace=False):
4921 1 2 2.0 0.0 if ((lower is not None and np.any(isna(lower))) or
4922 1 28 28.0 0.2 (upper is not None and np.any(isna(upper)))):
4923 raise ValueError("Cannot use an NA value as a clip threshold")
4924
4925 1 120 120.0 0.9 result = self.values
4926 1 3525 3525.0 25.1 mask = isna(result)
4927
4928 1 86 86.0 0.6 with np.errstate(all=''ignore''):
4929 1 2 2.0 0.0 if upper is not None:
4930 1 9314 9314.0 66.4 result = np.where(result >= upper, upper, result)
4931 1 61 61.0 0.4 if lower is not None:
4932 result = np.where(result <= lower, lower, result)
4933 1 283 283.0 2.0 if np.any(mask):
4934 result[mask] = np.nan
4935
4936 1 78 78.0 0.6 axes_dict = self._construct_axes_dict()
4937 1 532 532.0 3.8 result = self._constructor(result, **axes_dict).__finalize__(self)
4938
4939 1 2 2.0 0.0 if inplace:
4940 self._update_inplace(result)
4941 else:
4942 1 1 1.0 0.0 return result
Todavía hay múltiples llamadas a funciones, por ejemplo, isna
y np.where
, que toman una cantidad de tiempo significativa, pero en general esto es al menos comparable al tiempo np.ndarray.clip
(eso está en el régimen donde la diferencia de tiempo es ~ 3 en mi computadora).
La comida para llevar debería ser probablemente:
- Muchas funciones de NumPy simplemente delegan en un método del objeto pasado, por lo que puede haber grandes diferencias cuando pasas objetos diferentes.
- El perfilado, especialmente el perfil de línea, puede ser una gran herramienta para encontrar los lugares de donde proviene la diferencia de rendimiento.
- Siempre asegúrese de probar objetos de diferentes tamaños en tales casos. Podría estar comparando factores constantes que probablemente no importen, excepto si procesa muchas matrices pequeñas.
Versiones usadas:
Python 3.6.3 64-bit on Windows 10
Numpy 1.13.3
Pandas 0.21.1
Solo lee el código fuente, está claro.
def clip(a, a_min, a_max, out=None):
"""a : array_like Array containing elements to clip."""
return _wrapfunc(a, ''clip'', a_min, a_max, out=out)
def _wrapfunc(obj, method, *args, **kwds):
try:
return getattr(obj, method)(*args, **kwds)
#This situation has occurred in the case of
# a downstream library like ''pandas''.
except (AttributeError, TypeError):
return _wrapit(obj, method, *args, **kwds)
def _wrapit(obj, method, *args, **kwds):
try:
wrap = obj.__array_wrap__
except AttributeError:
wrap = None
result = getattr(asarray(obj), method)(*args, **kwds)
if wrap:
if not isinstance(result, mu.ndarray):
result = asarray(result)
result = wrap(result)
return result
rectificar:
después de pandas v0.13.0_ahl1, pandas tiene su propio implemento de clip
.