python - ¿Son los bucles for en los pandas realmente malos? ¿Cuándo debería importarme?
iteration vectorization (1)
TLDR;
No,
for
bucles no son una manta "mala", al menos, no siempre.
Probablemente sea
más exacto decir que algunas operaciones vectorizadas son más lentas que la iteración
, en lugar de decir que la iteración es más rápida que algunas operaciones vectorizadas.
Saber cuándo y por qué es clave para obtener el máximo rendimiento de su código.
En pocas palabras, estas son las situaciones en las que vale la pena considerar una alternativa a las funciones pandas vectorizadas:
- Cuando tus datos son pequeños (... dependiendo de lo que estés haciendo),
-
Cuando se trata de dtypes
object
/ mixto -
Cuando se utilizan las funciones de acceso
str
/ regex
Examinemos estas situaciones individualmente.
Iteración v / s Vectorización en pequeños datos
Pandas sigue un enfoque de "Convención sobre Configuración" en su diseño de API. Esto significa que la misma API se ha adaptado para atender una amplia gama de datos y casos de uso.
Cuando se llama a una función de pandas, las siguientes cosas (entre otras) deben ser manejadas internamente por la función, para garantizar el funcionamiento
- Alineación índice / eje
- Manejo de tipos de datos mixtos
- Manejo de datos faltantes
Casi todas las funciones tendrán que lidiar con éstas en diferentes grados, y esto presenta una
sobrecarga
.
La sobrecarga es menor para las funciones numéricas (por ejemplo,
Series.add
), mientras que es más pronunciada para las funciones de cadena (por ejemplo,
Series.str.replace
).
for
otro lado, los bucles son más rápidos de lo que crees.
Lo que es aún mejor es que las
comprensiones de
listas (que crean listas a través
for
bucles) son incluso más rápidas, ya que son mecanismos iterativos optimizados para la creación de listas.
Lista de comprensiones siguiendo el patrón.
[f(x) for x in seq]
Donde
seq
es una serie de pandas o una columna DataFrame.
O, cuando se opera sobre múltiples columnas,
[f(x, y) for x, y in zip(seq1, seq2)]
Donde
seq1
y
seq2
son columnas.
Comparacion numerica
Considere una simple operación de indexación booleana.
El método de comprensión de la lista se ha cronometrado contra
Series.ne
(
!=
) Y
query
.
Aquí están las funciones:
# Boolean indexing with Numeric value comparison.
df[df.A != df.B] # vectorized !=
df.query(''A != B'') # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]] # list comp
Para simplificar, he usado el paquete
perfplot
para ejecutar todas las pruebas de tiempo en esta publicación.
Los tiempos para las operaciones anteriores son a continuación:
La comprensión de la lista supera la
query
de N de tamaño moderado, e incluso supera a la vectorizada, no igual a la pequeña comparación de N. Desafortunadamente, la comprensión de la lista se escala de forma lineal, por lo que no ofrece mucha ganancia de rendimiento para una N. más grande.
Nota
Vale la pena mencionar que gran parte del beneficio de la comprensión de la lista proviene de no tener que preocuparse por la alineación del índice, pero esto significa que si su código depende de la alineación de la indexación, esto se interrumpirá. En algunos casos, se puede considerar que las operaciones vectorizadas sobre los arreglos NumPy subyacentes traen lo "mejor de los dos mundos", permitiendo la vectorización sin toda la sobrecarga innecesaria de las funciones pandas. Esto significa que puede volver a escribir la operación anterior como
df[df.A.values != df.B.values]
Lo que supera tanto a los pandas como a los equivalentes de comprensión de lista:
La vectorización NumPy está fuera del alcance de esta publicación, pero definitivamente vale la pena considerarla, si el desempeño es importante.
Valor cuenta
Tomando otro ejemplo, esta vez, con otro constructo de vainilla python que es
más rápido
que un bucle for:
collections.Counter
.
collections.Counter
.
Un requisito común es calcular los recuentos de valores y devolver el resultado como un diccionario.
Esto se hace con
value_counts
,
np.unique
y
Counter
:
# Value Counts comparison.
ser.value_counts(sort=False).to_dict() # value_counts
dict(zip(*np.unique(ser, return_counts=True))) # np.unique
Counter(ser) # Counter
Los resultados son más pronunciados. El
Counter
gana sobre ambos métodos vectorizados para un rango mayor de N pequeña (~ 3500).
Nota
Más trivia (cortesía @ usuario2357112). ElCounter
se implementa con un acelerador de C , por lo que aunque todavía tiene que trabajar con objetos de Python en lugar de los tipos de datos de C subyacentes, aún es más rápido que un buclefor
. ¡Potencia de pitón!
Por supuesto, lo que quita de aquí es que el rendimiento depende de sus datos y su caso de uso. El objetivo de estos ejemplos es convencerlo de que no descarte estas soluciones como opciones legítimas. Si estos aún no le ofrecen el rendimiento que necesita, siempre hay cython y numba . Vamos a añadir esta prueba en la mezcla.
from numba import njit, prange
@njit(parallel=True)
def get_mask(x, y):
result = [False] * len(x)
for i in prange(len(x)):
result[i] = x[i] != y[i]
return np.array(result)
df[get_mask(df.A.values, df.B.values)] # numba
Numba ofrece la compilación JIT de código python loopy a código vectorizado muy potente. Comprender cómo hacer que funcione la numba implica una curva de aprendizaje.
Operaciones con tipos de
object
mixtos.
Comparación basada en cadenas
Revisando el ejemplo de filtrado de la primera sección, ¿qué sucede si las columnas que se comparan son cadenas?
Considere las mismas 3 funciones anteriores, pero con la entrada DataFrame convertida en cadena.
# Boolean indexing with string value comparison.
df[df.A != df.B] # vectorized !=
df.query(''A != B'') # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]] # list comp
Entonces, ¿qué cambió? Lo que hay que tener en cuenta aquí es que las operaciones de cadena son intrínsecamente difíciles de vectorizar. Las pandas tratan las cadenas como objetos, y todas las operaciones en los objetos recurren a una implementación lenta y lenta.
Ahora, debido a que esta implementación descabellada está rodeada por toda la sobrecarga mencionada anteriormente, existe una diferencia de magnitud constante entre estas soluciones, incluso aunque sean iguales.
Cuando se trata de operaciones en objetos mutables / complejos, no hay comparación. La comprensión de listas supera todas las operaciones que involucran dictados y listas.
Acceso a los valores del diccionario por clave
Aquí hay tiempos para dos operaciones que extraen un valor de una columna de diccionarios:
map
y la comprensión de la lista.
La configuración se encuentra en el Apéndice, bajo el encabezado "Fragmentos de código".
# Dictionary value extraction.
ser.map(operator.itemgetter(''value'')) # map
pd.Series([x.get(''value'') for x in ser]) # list comprehension
Indización de lista posicional
Tiempos para 3 operaciones que extraen el elemento 0 de una lista de columnas (manejo de excepciones),
map
,
método de acceso a
str.get
y la comprensión de la lista:
# List positional indexing.
def get_0th(lst):
try:
return lst[0]
# Handle empty lists and NaNs gracefully.
except (IndexError, TypeError):
return np.nan
ser.map(get_0th) # map
ser.str[0] # str accessor
pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]) # list comp
pd.Series([get_0th(x) for x in ser]) # list comp safe
Nota
Si el índice importa, usted querría hacer:
pd.Series([...], index=ser.index)
Al reconstruir la serie.
Lista de aplanamiento
Un último ejemplo es el aplanamiento de listas.
Este es otro problema común, y demuestra cuán poderosa es Python pura aquí.
# Nested list flattening.
pd.DataFrame(ser.tolist()).stack().reset_index(drop=True) # stack
pd.Series(list(chain.from_iterable(ser.tolist()))) # itertools.chain
pd.Series([y for x in ser for y in x]) # nested list comp
Tanto
itertools.chain.from_iterable
como la comprensión de la lista anidada son construcciones de python puras, y se escalan mucho mejor que la solución de
stack
.
Estos tiempos son una clara indicación del hecho de que los pandas no están equipados para trabajar con tipos de datos mixtos, y que probablemente debas abstenerte de usarlos para hacerlo. Siempre que sea posible, los datos deben estar presentes como valores escalares (ints / floats / strings) en columnas separadas.
Por último, la aplicabilidad de estas soluciones depende en gran medida de sus datos.
Por lo tanto, lo mejor sería probar estas operaciones en sus datos antes de decidir qué hacer.
Observe cómo no he cronometrado
apply
en estas soluciones, ya que sesgaría el gráfico (sí, es tan lento).
Operaciones Regex y métodos de
.str
Las pandas pueden aplicar operaciones de
str.contains
como
str.contains
,
str.extract
y
str.extractall
, así como otras operaciones de cadena "vectorizadas" (como
str.split
, str.find
,
str.translate`, etc.) en columnas de cadena.
Estas funciones son más lentas que las listas de comprensión, y están destinadas a ser más funciones de conveniencia que cualquier otra cosa.
Por lo general, es mucho más rápido
re.compile
un patrón de
re.compile
regulares e iterar sobre sus datos con
re.compile
(también vea
¿Vale la pena usar re.compile de Python?
).
La lista de comp equivalente a
str.contains
parece a esto:
p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])
O,
ser2 = ser[[bool(p.search(x)) for x in ser]]
Si necesita manejar NaNs, puede hacer algo como
ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]
La lista comp equivalente a
str.extract
(sin grupos) se verá algo así como:
df[''col2''] = [p.search(x).group(0) for x in df[''col'']]
Si necesita manejar no coincidencias y NaN, puede usar una función personalizada (¡aún más rápida!):
def matcher(x):
m = p.search(str(x))
if m:
return m.group(0)
return np.nan
df[''col2''] = [matcher(x) for x in df[''col'']]
La función
matcher
es muy extensible.
Puede ajustarse para devolver una lista para cada grupo de captura, según sea necesario.
Simplemente extraiga la consulta del
group
o atributo de grupo del objeto de comparación.
Para
str.extractall
, cambie
p.search
a
p.findall
.
Extracción de cuerdas
Considere una simple operación de filtrado.
La idea es extraer 4 dígitos si está precedido por una letra mayúscula.
# Extracting strings.
p = re.compile(r''(?<=[A-Z])(/d{4})'')
def matcher(x):
m = p.search(x)
if m:
return m.group(0)
return np.nan
ser.str.extract(r''(?<=[A-Z])(/d{4})'', expand=False) # str.extract
pd.Series([matcher(x) for x in ser]) # list comprehension
Más ejemplos
Revelación completa: soy el autor (en parte o en su totalidad) de las publicaciones que se enumeran a continuación.
-
Reemplace todos menos la última aparición de un carácter en un marco de datos
Conclusión
Como se muestra en los ejemplos anteriores, la iteración brilla cuando se trabaja con pequeñas filas de DataFrames, tipos de datos mixtos y expresiones regulares.
La aceleración que obtiene depende de sus datos y su problema, por lo que su millaje puede variar. Lo mejor que puede hacer es ejecutar cuidadosamente las pruebas y ver si el pago vale la pena.
Las funciones "vectorizadas" brillan en su simplicidad y legibilidad, por lo que si el rendimiento no es crítico, definitivamente debería preferirlas.
Otra nota al margen, ciertas operaciones de cadena tratan con restricciones que favorecen el uso de NumPy. Aquí hay dos ejemplos donde la vectorización NumPy cuidadosa supera a Python:
-
Cree una nueva columna con valores incrementales de una manera más rápida y eficiente - Answer by Divakar
-
Eliminación rápida de la puntuación con pandas - Respuesta de Paul Panzer
Además, a veces, simplemente operar en las matrices subyacentes a través de
.values
en lugar de en Series o DataFrames puede ofrecer una aceleración suficiente para los escenarios más habituales (consulte la
Nota
en la sección
Comparación numérica
más arriba).
Entonces, por ejemplo,
df[df.A.values != df.B.values]
mostraría mejoras instantáneas de rendimiento sobre
df[df.A != df.B]
.
El uso de
.values
puede no ser apropiado en todas las situaciones, pero es un truco útil saberlo.
Como se mencionó anteriormente, depende de usted decidir si vale la pena implementar estas soluciones.
Apéndice: Fragmentos de código
import perfplot
import operator
import pandas as pd
import numpy as np
import re
from collections import Counter
from itertools import chain
# Boolean indexing with Numeric value comparison.
perfplot.show(
setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=[''A'',''B'']),
kernels=[
lambda df: df[df.A != df.B],
lambda df: df.query(''A != B''),
lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
lambda df: df[get_mask(df.A.values, df.B.values)]
],
labels=[''vectorized !='', ''query (numexpr)'', ''list comp'', ''numba''],
n_range=[2**k for k in range(0, 15)],
xlabel=''N''
)
# Value Counts comparison.
perfplot.show(
setup=lambda n: pd.Series(np.random.choice(1000, n)),
kernels=[
lambda ser: ser.value_counts(sort=False).to_dict(),
lambda ser: dict(zip(*np.unique(ser, return_counts=True))),
lambda ser: Counter(ser),
],
labels=[''value_counts'', ''np.unique'', ''Counter''],
n_range=[2**k for k in range(0, 15)],
xlabel=''N'',
equality_check=lambda x, y: dict(x) == dict(y)
)
# Boolean indexing with string value comparison.
perfplot.show(
setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=[''A'',''B''], dtype=str),
kernels=[
lambda df: df[df.A != df.B],
lambda df: df.query(''A != B''),
lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
],
labels=[''vectorized !='', ''query (numexpr)'', ''list comp''],
n_range=[2**k for k in range(0, 15)],
xlabel=''N'',
equality_check=None
)
# Dictionary value extraction.
ser1 = pd.Series([{''key'': ''abc'', ''value'': 123}, {''key'': ''xyz'', ''value'': 456}])
perfplot.show(
setup=lambda n: pd.concat([ser1] * n, ignore_index=True),
kernels=[
lambda ser: ser.map(operator.itemgetter(''value'')),
lambda ser: pd.Series([x.get(''value'') for x in ser]),
],
labels=[''map'', ''list comprehension''],
n_range=[2**k for k in range(0, 15)],
xlabel=''N'',
equality_check=None
)
# List positional indexing.
ser2 = pd.Series([[''a'', ''b'', ''c''], [1, 2], []])
perfplot.show(
setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
kernels=[
lambda ser: ser.map(get_0th),
lambda ser: ser.str[0],
lambda ser: pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]),
lambda ser: pd.Series([get_0th(x) for x in ser]),
],
labels=[''map'', ''str accessor'', ''list comprehension'', ''list comp safe''],
n_range=[2**k for k in range(0, 15)],
xlabel=''N'',
equality_check=None
)
# Nested list flattening.
ser3 = pd.Series([[''a'', ''b'', ''c''], [''d'', ''e''], [''f'', ''g'']])
perfplot.show(
setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
kernels=[
lambda ser: pd.DataFrame(ser.tolist()).stack().reset_index(drop=True),
lambda ser: pd.Series(list(chain.from_iterable(ser.tolist()))),
lambda ser: pd.Series([y for x in ser for y in x]),
],
labels=[''stack'', ''itertools.chain'', ''nested list comp''],
n_range=[2**k for k in range(0, 15)],
xlabel=''N'',
equality_check=None
)
# Extracting strings.
ser4 = pd.Series([''foo xyz'', ''test A1234'', ''D3345 xtz''])
perfplot.show(
setup=lambda n: pd.concat([ser4] * n, ignore_index=True),
kernels=[
lambda ser: ser.str.extract(r''(?<=[A-Z])(/d{4})'', expand=False),
lambda ser: pd.Series([matcher(x) for x in ser])
],
labels=[''str.extract'', ''list comprehension''],
n_range=[2**k for k in range(0, 15)],
xlabel=''N'',
equality_check=None
)
¿Son los bucles realmente "malos"? De no ser así, ¿en qué situación sería mejor que utilizar un enfoque "vectorizado" más convencional? 1
Estoy familiarizado con el concepto de "vectorización", y cómo los pandas emplean técnicas vectorizadas para acelerar el cálculo. Las funciones vectorizadas emiten operaciones en toda la serie o DataFrame para lograr aceleraciones mucho mayores que la iteración convencional de los datos.
Sin embargo, estoy bastante sorprendido de ver una gran cantidad de código (incluidas las respuestas en Stack Overflow) que ofrecen soluciones a problemas que involucran el bucle a través de los datos
for
bucles y listas de comprensión.
La documentación y la API dicen que los bucles son "malos", y que uno "nunca" debe iterar sobre matrices, series o DataFrames.
Entonces, ¿cómo es que a veces veo que los usuarios sugieren soluciones basadas en bucles?
1 - Si bien es cierto que la pregunta suena algo amplia, la verdad es que hay situaciones muy específicas en las que,
for
bucles son mejores que la iteración convencional sobre los datos.
Este post pretende captar esto para la posteridad.