Para bucles con pandas – ¿Cuándo debería importarme?

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. Después de leer la documentación, y con una comprensión decente de la API, se me da por creer que los bucles son “malos” y que uno nunca debe “nunca” iterar sobre matrices, series o DataFrames. Entonces, ¿cómo es que veo a los usuarios sugiriendo soluciones descabelladas de vez en cuando?

Entonces, para resumir … mi pregunta es:
¿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

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.

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:

  1. Cuando tus datos son pequeños (… dependiendo de lo que estés haciendo),
  2. Cuando se trata de dtypes object / mixto
  3. 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

  1. Alineación índice / eje
  2. Manejo de tipos de datos mixtos
  3. 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:

introduzca la descripción de la imagen aquí

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:
introduzca la descripción de la imagen aquí
La vectorización NumPy está fuera del scope 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 

introduzca la descripción de la imagen aquí

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). El Counter 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 bucle for . ¡Potencia de python!

Por supuesto, lo que quita de aquí es que el rendimiento depende de sus datos y su caso de uso. El objective 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 

introduzca la descripción de la imagen aquí

Numba ofrece la comstackció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 

introduzca la descripción de la imagen aquí

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 

introduzca la descripción de la imagen aquí

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.

introduzca la descripción de la imagen aquí

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 

introduzca la descripción de la imagen aquí

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'(?<=[AZ])(\d{4})') def matcher(x): m = p.search(x) if m: return m.group(0) return np.nan ser.str.extract(r'(?<=[AZ])(\d{4})', expand=False) # str.extract pd.Series([matcher(x) for x in ser]) # list comprehension 

introduzca la descripción de la imagen aquí

Más ejemplos
Revelación completa: soy el autor (en parte o en su totalidad) de las publicaciones que se enumeran a continuación.

  • Eliminación rápida de la puntuación con pandas

  • Concatenación de cuerdas de dos columnas pandas.

  • Eliminar partes no deseadas de cadenas en una columna

  • Reemplace todos menos la última aparición de un carácter en un dataframe


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'(?<=[AZ])(\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 )