python - guia - Conversión de una serie de pasos en cadenas: ¿por qué se aplica mucho más rápido que un astype?
qgis manual (2)
Comencemos con un poco de consejos generales: si está interesado en encontrar los cuellos de botella del código Python, puede usar un generador de perfiles para encontrar las funciones / partes que consumen la mayor parte del tiempo. En este caso, uso un perfilador de línea porque realmente puede ver la implementación y el tiempo empleado en cada línea.
Sin embargo, estas herramientas no funcionan con C o Cython de forma predeterminada. Dado que CPython (que es el intérprete de Python que estoy usando), NumPy y los pandas hacen un uso intensivo de C y Cython, habrá un límite de cuánto llegaré con la creación de perfiles.
En realidad: probablemente se podría extender el perfilado al código de Cython y probablemente también al código C al compilarlo con símbolos de depuración y seguimiento, sin embargo, no es una tarea fácil compilar estas bibliotecas, así que no haré eso (pero si a alguien le gusta hacerlo que la documentación de Cython incluye una página sobre cómo crear un perfil de código de Cython ).
Pero veamos hasta dónde puedo llegar:
Código de línea de Python código
Voy a usar line-profiler y un cuaderno de Jupyter aquí:
%load_ext line_profiler
import numpy as np
import pandas as pd
x = pd.Series(np.random.randint(0, 100, 100000))
Perfil x.astype
%lprun -f x.astype x.astype(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
87 @wraps(func)
88 def wrapper(*args, **kwargs):
89 1 12 12.0 0.0 old_arg_value = kwargs.pop(old_arg_name, None)
90 1 5 5.0 0.0 if old_arg_value is not None:
91 if mapping is not None:
...
118 1 663354 663354.0 100.0 return func(*args, **kwargs)
Así que eso es simplemente un decorador y el 100% del tiempo se gasta en la función decorada. Así que vamos a perfilar la función decorada:
%lprun -f x.astype.__wrapped__ x.astype(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
3896 @deprecate_kwarg(old_arg_name=''raise_on_error'', new_arg_name=''errors'',
3897 mapping={True: ''raise'', False: ''ignore''})
3898 def astype(self, dtype, copy=True, errors=''raise'', **kwargs):
3899 """
...
3975 """
3976 1 28 28.0 0.0 if is_dict_like(dtype):
3977 if self.ndim == 1: # i.e. Series
...
4001
4002 # else, only a single dtype is given
4003 1 14 14.0 0.0 new_data = self._data.astype(dtype=dtype, copy=copy, errors=errors,
4004 1 685863 685863.0 99.9 **kwargs)
4005 1 340 340.0 0.0 return self._constructor(new_data).__finalize__(self)
Una vez más, una línea es el cuello de botella, así que _data.astype
método _data.astype
:
%lprun -f x._data.astype x.astype(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
3461 def astype(self, dtype, **kwargs):
3462 1 695866 695866.0 100.0 return self.apply(''astype'', dtype=dtype, **kwargs)
Bien, otro delegado, veamos lo que _data.apply
hace:
%lprun -f x._data.apply x.astype(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
3251 def apply(self, f, axes=None, filter=None, do_integrity_check=False,
3252 consolidate=True, **kwargs):
3253 """
...
3271 """
3272
3273 1 12 12.0 0.0 result_blocks = []
...
3309
3310 1 10 10.0 0.0 aligned_args = dict((k, kwargs[k])
3311 1 29 29.0 0.0 for k in align_keys
3312 if hasattr(kwargs[k], ''reindex_axis''))
3313
3314 2 28 14.0 0.0 for b in self.blocks:
...
3329 1 674974 674974.0 100.0 applied = getattr(b, f)(**kwargs)
3330 1 30 30.0 0.0 result_blocks = _extend_blocks(applied, result_blocks)
3331
3332 1 10 10.0 0.0 if len(result_blocks) == 0:
3333 return self.make_empty(axes or self.axes)
3334 1 10 10.0 0.0 bm = self.__class__(result_blocks, axes or self.axes,
3335 1 76 76.0 0.0 do_integrity_check=do_integrity_check)
3336 1 13 13.0 0.0 bm._consolidate_inplace()
3337 1 7 7.0 0.0 return bm
Y de nuevo ... una llamada de función está tomando todo el tiempo, esta vez es x._data.blocks[0].astype
:
%lprun -f x._data.blocks[0].astype x.astype(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
542 def astype(self, dtype, copy=False, errors=''raise'', values=None, **kwargs):
543 1 18 18.0 0.0 return self._astype(dtype, copy=copy, errors=errors, values=values,
544 1 671092 671092.0 100.0 **kwargs)
.. que es otro delegado ...
%lprun -f x._data.blocks[0]._astype x.astype(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
546 def _astype(self, dtype, copy=False, errors=''raise'', values=None,
547 klass=None, mgr=None, **kwargs):
548 """
...
557 """
558 1 11 11.0 0.0 errors_legal_values = (''raise'', ''ignore'')
559
560 1 8 8.0 0.0 if errors not in errors_legal_values:
561 invalid_arg = ("Expected value of kwarg ''errors'' to be one of {}. "
562 "Supplied value is ''{}''".format(
563 list(errors_legal_values), errors))
564 raise ValueError(invalid_arg)
565
566 1 23 23.0 0.0 if inspect.isclass(dtype) and issubclass(dtype, ExtensionDtype):
567 msg = ("Expected an instance of {}, but got the class instead. "
568 "Try instantiating ''dtype''.".format(dtype.__name__))
569 raise TypeError(msg)
570
571 # may need to convert to categorical
572 # this is only called for non-categoricals
573 1 72 72.0 0.0 if self.is_categorical_astype(dtype):
...
595
596 # astype processing
597 1 16 16.0 0.0 dtype = np.dtype(dtype)
598 1 19 19.0 0.0 if self.dtype == dtype:
...
603 1 8 8.0 0.0 if klass is None:
604 1 13 13.0 0.0 if dtype == np.object_:
605 klass = ObjectBlock
606 1 6 6.0 0.0 try:
607 # force the copy here
608 1 7 7.0 0.0 if values is None:
609
610 1 8 8.0 0.0 if issubclass(dtype.type,
611 1 14 14.0 0.0 (compat.text_type, compat.string_types)):
612
613 # use native type formatting for datetime/tz/timedelta
614 1 15 15.0 0.0 if self.is_datelike:
615 values = self.to_native_types()
616
617 # astype formatting
618 else:
619 1 8 8.0 0.0 values = self.values
620
621 else:
622 values = self.get_values(dtype=dtype)
623
624 # _astype_nansafe works fine with 1-d only
625 1 665777 665777.0 99.9 values = astype_nansafe(values.ravel(), dtype, copy=True)
626 1 32 32.0 0.0 values = values.reshape(self.shape)
627
628 1 17 17.0 0.0 newb = make_block(values, placement=self.mgr_locs, dtype=dtype,
629 1 269 269.0 0.0 klass=klass)
630 except:
631 if errors == ''raise'':
632 raise
633 newb = self.copy() if copy else self
634
635 1 8 8.0 0.0 if newb.is_numeric and self.is_numeric:
...
642 1 6 6.0 0.0 return newb
... está bien, todavía no está allí. Vamos a ver astype_nansafe
:
%lprun -f pd.core.internals.astype_nansafe x.astype(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
640 def astype_nansafe(arr, dtype, copy=True):
641 """ return a view if copy is False, but
642 need to be very careful as the result shape could change! """
643 1 13 13.0 0.0 if not isinstance(dtype, np.dtype):
644 dtype = pandas_dtype(dtype)
645
646 1 8 8.0 0.0 if issubclass(dtype.type, text_type):
647 # in Py3 that''s str, in Py2 that''s unicode
648 1 663317 663317.0 100.0 return lib.astype_unicode(arr.ravel()).reshape(arr.shape)
...
Nuevamente, una es una línea que toma el 100%, así que voy a ir más allá:
%lprun -f pd.core.dtypes.cast.lib.astype_unicode x.astype(str)
UserWarning: Could not extract a code object for the object <built-in function astype_unicode>
Bien, encontramos una built-in function
, eso significa que es una función C. En este caso es una función de Cython. Pero significa que no podemos profundizar más con el perfilador de líneas. Así que voy a parar aquí por ahora.
Perfilado x.apply
%lprun -f x.apply x.apply(str)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
2426 def apply(self, func, convert_dtype=True, args=(), **kwds):
2427 """
...
2523 """
2524 1 84 84.0 0.0 if len(self) == 0:
2525 return self._constructor(dtype=self.dtype,
2526 index=self.index).__finalize__(self)
2527
2528 # dispatch to agg
2529 1 11 11.0 0.0 if isinstance(func, (list, dict)):
2530 return self.aggregate(func, *args, **kwds)
2531
2532 # if we are a string, try to dispatch
2533 1 12 12.0 0.0 if isinstance(func, compat.string_types):
2534 return self._try_aggregate_string_function(func, *args, **kwds)
2535
2536 # handle ufuncs and lambdas
2537 1 7 7.0 0.0 if kwds or args and not isinstance(func, np.ufunc):
2538 f = lambda x: func(x, *args, **kwds)
2539 else:
2540 1 6 6.0 0.0 f = func
2541
2542 1 154 154.0 0.1 with np.errstate(all=''ignore''):
2543 1 11 11.0 0.0 if isinstance(f, np.ufunc):
2544 return f(self)
2545
2546 # row-wise access
2547 1 188 188.0 0.1 if is_extension_type(self.dtype):
2548 mapped = self._values.map(f)
2549 else:
2550 1 6238 6238.0 3.3 values = self.asobject
2551 1 181910 181910.0 95.5 mapped = lib.map_infer(values, f, convert=convert_dtype)
2552
2553 1 28 28.0 0.0 if len(mapped) and isinstance(mapped[0], Series):
2554 from pandas.core.frame import DataFrame
2555 return DataFrame(mapped.tolist(), index=self.index)
2556 else:
2557 1 19 19.0 0.0 return self._constructor(mapped,
2558 1 1870 1870.0 1.0 index=self.index).__finalize__(self)
De nuevo, es una función que lleva la mayor parte del tiempo: lib.map_infer
...
%lprun -f pd.core.series.lib.map_infer x.apply(str)
Could not extract a code object for the object <built-in function map_infer>
Bueno, esa es otra función de Cython.
Esta vez hay otro colaborador (aunque menos significativo) con ~ 3%: values = self.asobject
. Pero ignoraré esto por ahora, porque estamos interesados en los principales contribuyentes.
Entrando en C / Cython
Las funciones llamadas por astype
Esta es la función astype_unicode
:
cpdef ndarray[object] astype_unicode(ndarray arr):
cdef:
Py_ssize_t i, n = arr.size
ndarray[object] result = np.empty(n, dtype=object)
for i in range(n):
# we can use the unsafe version because we know `result` is mutable
# since it was created from `np.empty`
util.set_value_at_unsafe(result, i, unicode(arr[i]))
return result
Esta función utiliza este ayudante:
cdef inline set_value_at_unsafe(ndarray arr, object loc, object value):
cdef:
Py_ssize_t i, sz
if is_float_object(loc):
casted = int(loc)
if casted == loc:
loc = casted
i = <Py_ssize_t> loc
sz = cnp.PyArray_SIZE(arr)
if i < 0:
i += sz
elif i >= sz:
raise IndexError(''index out of bounds'')
assign_value_1d(arr, i, value)
Que a su vez utiliza esta función C:
PANDAS_INLINE int assign_value_1d(PyArrayObject* ap, Py_ssize_t _i,
PyObject* v) {
npy_intp i = (npy_intp)_i;
char* item = (char*)PyArray_DATA(ap) + i * PyArray_STRIDE(ap, 0);
return PyArray_DESCR(ap)->f->setitem(v, item, ap);
}
Funciones llamadas por apply
Esta es la implementación de la función map_infer
:
def map_infer(ndarray arr, object f, bint convert=1):
cdef:
Py_ssize_t i, n
ndarray[object] result
object val
n = len(arr)
result = np.empty(n, dtype=object)
for i in range(n):
val = f(util.get_value_at(arr, i))
# unbox 0-dim arrays, GH #690
if is_array(val) and PyArray_NDIM(val) == 0:
# is there a faster way to unbox?
val = val.item()
result[i] = val
if convert:
return maybe_convert_objects(result,
try_float=0,
convert_datetime=0,
convert_timedelta=0)
return result
Con este ayudante:
cdef inline object get_value_at(ndarray arr, object loc):
cdef:
Py_ssize_t i, sz
int casted
if is_float_object(loc):
casted = int(loc)
if casted == loc:
loc = casted
i = <Py_ssize_t> loc
sz = cnp.PyArray_SIZE(arr)
if i < 0 and sz > 0:
i += sz
elif i >= sz or sz == 0:
raise IndexError(''index out of bounds'')
return get_value_1d(arr, i)
Que utiliza esta función C:
PANDAS_INLINE PyObject* get_value_1d(PyArrayObject* ap, Py_ssize_t i) {
char* item = (char*)PyArray_DATA(ap) + i * PyArray_STRIDE(ap, 0);
return PyArray_Scalar(item, PyArray_DESCR(ap), (PyObject*)ap);
}
Algunas reflexiones sobre el código Cython.
Hay algunas diferencias entre los códigos de Cython que se llaman eventualmente.
La tomada por astype
usa unicode
mientras que la ruta de apply
usa la función pasada. Veamos si eso hace una diferencia (nuevamente, IPython / Jupyter hace que sea muy fácil compilar el código Cython):
%load_ext cython
%%cython
import numpy as np
cimport numpy as np
cpdef object func_called_by_astype(np.ndarray arr):
cdef np.ndarray[object] ret = np.empty(arr.size, dtype=object)
for i in range(arr.size):
ret[i] = unicode(arr[i])
return ret
cpdef object func_called_by_apply(np.ndarray arr, object f):
cdef np.ndarray[object] ret = np.empty(arr.size, dtype=object)
for i in range(arr.size):
ret[i] = f(arr[i])
return ret
Sincronización:
import numpy as np
arr = np.random.randint(0, 10000, 1000000)
%timeit func_called_by_astype(arr)
514 ms ± 11.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_apply(arr, str)
632 ms ± 43.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Está bien, hay una diferencia pero está mal , en realidad indicaría que apply
sería un poco más lento .
¿Pero recuerda la llamada de objeto que mencioné anteriormente en la función de apply
? ¿Podría ser esa la razón? Veamos:
import numpy as np
arr = np.random.randint(0, 10000, 1000000)
%timeit func_called_by_astype(arr)
557 ms ± 33.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_apply(arr.astype(object), str)
317 ms ± 13.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Ahora se ve mejor. La conversión a una matriz de objetos hizo que la función llamada aplicara mucho más rápido. Hay una razón simple para esto: str
es una función de Python y generalmente son mucho más rápidas si ya tiene objetos de Python y NumPy (o Pandas) no necesita crear un contenedor de Python para el valor almacenado en la matriz (que es generalmente no es un objeto de Python, excepto cuando la matriz es de object
dtype).
Sin embargo, eso no explica la gran diferencia que has visto. Mi sospecha es que en realidad hay una diferencia adicional en las formas en que se repiten las matrices y se establecen los elementos en el resultado. Muy probablemente el:
val = f(util.get_value_at(arr, i))
if is_array(val) and PyArray_NDIM(val) == 0:
val = val.item()
result[i] = val
parte de la función map_infer
es más rápida que:
for i in range(n):
# we can use the unsafe version because we know `result` is mutable
# since it was created from `np.empty`
util.set_value_at_unsafe(result, i, unicode(arr[i]))
que es llamado por el astype(str)
. Los comentarios de la primera función parecen indicar que el escritor de map_infer
realmente intentó hacer el código lo más rápido posible (vea el comentario sobre "¿hay una manera más rápida de desempaquetar?" Mientras que la otra tal vez fue escrita sin especial cuidado rendimiento, pero eso es sólo una conjetura.
También en mi computadora estoy bastante cerca del rendimiento de x.astype(str)
y x.apply(str)
ya:
import numpy as np
arr = np.random.randint(0, 100, 1000000)
s = pd.Series(arr)
%timeit s.astype(str)
535 ms ± 23.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_astype(arr)
547 ms ± 21.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit s.apply(str)
216 ms ± 8.48 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_apply(arr.astype(object), str)
272 ms ± 12.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Tenga en cuenta que también he comprobado algunas otras variantes que devuelven un resultado diferente:
%timeit s.values.astype(str) # array of strings
407 ms ± 8.56 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit list(map(str, s.values.tolist())) # list of strings
184 ms ± 5.02 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
Curiosamente, el bucle de Python con la list
y el map
parece ser el más rápido en mi computadora.
En realidad hice un pequeño punto de referencia que incluye la trama
import pandas as pd
import simple_benchmark
def Series_astype(series):
return series.astype(str)
def Series_apply(series):
return series.apply(str)
def Series_tolist_map(series):
return list(map(str, series.values.tolist()))
def Series_values_astype(series):
return series.values.astype(str)
arguments = {2**i: pd.Series(np.random.randint(0, 100, 2**i)) for i in range(2, 20)}
b = simple_benchmark.benchmark(
[Series_astype, Series_apply, Series_tolist_map, Series_values_astype],
arguments,
argument_name=''Series size''
)
%matplotlib notebook
b.plot()
Tenga en cuenta que es un gráfico log-log debido a la amplia gama de tamaños que cubrí en el punto de referencia. Sin embargo, más bajo significa más rápido aquí.
Los resultados pueden ser diferentes para las diferentes versiones de Python / NumPy / Pandas. Así que si quieres compararlo, estas son mis versiones:
Versions
--------
Python 3.6.5
NumPy 1.14.2
Pandas 0.22.0
Tengo un pandas.Series
contienen enteros, pero necesito convertirlos en cadenas para algunas herramientas pandas.Series
. Supongamos que tengo un objeto de la Series
:
import numpy as np
import pandas as pd
x = pd.Series(np.random.randint(0, 100, 1000000))
En StackOverflow y otros sitios web, he visto a la mayoría de las personas argumentar que la mejor manera de hacerlo es:
%% timeit
x = x.astype(str)
Esto lleva unos 2 segundos.
Cuando uso x = x.apply(str)
, solo toma 0.2 segundos.
¿Por qué x.astype(str)
tan lento? ¿Debería la forma recomendada ser x.apply(str)
?
Estoy principalmente interesado en el comportamiento de python 3 para esto.
Actuación
Vale la pena ver el rendimiento real antes de comenzar cualquier investigación, ya que, contrariamente a la opinión popular, la list(map(str, x))
parece ser más lenta que x.apply(str)
.
import pandas as pd, numpy as np
### Versions: Pandas 0.20.3, Numpy 1.13.1, Python 3.6.2 ###
x = pd.Series(np.random.randint(0, 100, 100000))
%timeit x.apply(str) # 42ms (1)
%timeit x.map(str) # 42ms (2)
%timeit x.astype(str) # 559ms (3)
%timeit [str(i) for i in x] # 566ms (4)
%timeit list(map(str, x)) # 536ms (5)
%timeit x.values.astype(str) # 25ms (6)
Puntos a destacar:
- (5) es ligeramente más rápido que (3) / (4), lo cual esperamos a medida que se mueva más trabajo a C [suponiendo que no se utilice la función
lambda
]. - (6) es, con mucho, el más rápido.
- (1) / (2) son similares.
- (3) / (4) son similares.
¿Por qué es rápido x.map / x.apply?
Esto parece ser porque usa el código Cython de compilación rápida:
cpdef ndarray[object] astype_str(ndarray arr):
cdef:
Py_ssize_t i, n = arr.size
ndarray[object] result = np.empty(n, dtype=object)
for i in range(n):
# we can use the unsafe version because we know `result` is mutable
# since it was created from `np.empty`
util.set_value_at_unsafe(result, i, str(arr[i]))
return result
¿Por qué x.astype (str) es lento?
Pandas aplica str
a cada elemento de la serie, sin usar el Cython anterior.
Por lo tanto, el rendimiento es comparable a [str(i) for i in x]
/ list(map(str, x))
.
¿Por qué es tan rápido x.values.astype (str)?
Numpy no aplica una función en cada elemento de la matriz. Una descripción de esto encontré:
Si hizo
s.values.astype(str)
lo que obtiene es un objeto que contieneint
. Este es elnumpy
realiza la conversión, mientras que los pandas iteran sobre cada elemento y llaman astr(item)
en él. Entonces, si lo hace en un tipo des.astype(str)
, tiene un objeto que contienestr
.
Hay una razón técnica por la que la versión numpy no se ha implementado en el caso de no nulos.