¿Son realmente malos los bucles for en los pandas? ¿Cuándo debería importarme?

106

¿Los forbucles son realmente "malos"? De no ser así, ¿en qué situaciones serían mejores 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 transmiten operaciones sobre toda la serie o DataFrame para lograr aceleraciones mucho mayores que la iteración convencional sobre 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 implican recorrer datos mediante forbucles y listas por 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 a los usuarios sugiriendo soluciones basadas en bucles?


1 - Si bien es cierto que la pregunta suena algo amplia, lo cierto es que hay situaciones muy específicas en las que los forbucles suelen ser mejores que la iteración convencional sobre datos. Esta publicación tiene como objetivo capturar esto para la posteridad.

cs95
fuente

Respuestas:

143

TLDR; No, los forbucles no son "malos" generales, al menos, no siempre. Probablemente sea más exacto decir que algunas operaciones vectorizadas son más lentas que iterar , 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 sus datos son pequeños (... dependiendo de lo que esté haciendo),
  2. Cuando se trata de objecttipos / d mixtos
  3. Cuando se utilizan las strfunciones de acceso / regex

Examinemos estas situaciones individualmente.


Iteración frente a vectorización en datos pequeños

Pandas sigue un enfoque de "Convención sobre configuración" en el diseño de su API. Esto significa que se ha adaptado la misma API para atender una amplia gama de datos y casos de uso.

Cuando se llama a una función pandas, las siguientes cosas (entre otras) deben ser manejadas internamente por la función, para asegurar el funcionamiento

  1. Alineación de índice / eje
  2. Manejo de tipos de datos mixtos
  3. Manejo de datos faltantes

Casi todas las funciones tendrán que lidiar con estos en diferentes grados, y esto presenta una sobrecarga . La sobrecarga es menor para funciones numéricas (por ejemplo, Series.add), mientras que es más pronunciada para funciones de cadena (por ejemplo, Series.str.replace).

forlos bucles, por otro lado, son más rápidos de lo que cree. Lo que es aún mejor es que las listas por comprensión (que crean listas a través de forbucles) son aún más rápidas, ya que son mecanismos iterativos optimizados para la creación de listas.

Las listas por comprensión siguen el patrón

[f(x) for x in seq]

Donde seqhay una serie de pandas o una columna DataFrame. O, al operar en varias columnas,

[f(x, y) for x, y in zip(seq1, seq2)]

Donde seq1y seq2son columnas.

Comparación numérica
Considere una simple operación de indexación booleana. El método de comprensión de listas 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

Por simplicidad, he usado el perfplotpaquete para ejecutar todas las pruebas timeit en esta publicación. Los tiempos para las operaciones anteriores son los siguientes:

ingrese la descripción de la imagen aquí

La comprensión de la lista supera queryen rendimiento a N de tamaño moderado, e incluso supera al vectorizado no es igual a la comparación para la N minúscula.

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 romperá. En algunos casos, se puede considerar que las operaciones vectorizadas sobre las matrices NumPy subyacentes aportan "lo mejor de ambos mundos", lo que permite la vectorización sin toda la sobrecarga innecesaria de las funciones pandas. Esto significa que puede reescribir 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 listas: la

vectorización NumPy está fuera del alcance de esta publicación, pero definitivamente vale la pena considerarla, si el rendimiento importa.

El valor cuenta
Tomando otro ejemplo, esta vez, con otra construcción vanilla de Python que es más rápida que un bucle for 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.uniquey 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

ingrese la descripción de la imagen aquí

Los resultados son más pronunciados, Countertriunfan sobre ambos métodos vectorizados para un rango mayor de N pequeño (~ 3500).

Nota
Más trivia (cortesía @ user2357112). El Counterse implementa con un acelerador de C , por lo que aunque todavía tiene que funcionar con objetos de Python en lugar de los tipos de datos C subyacentes, sigue siendo más rápido que un forbucle. ¡Poder de Python!

Por supuesto, la conclusión de aquí es que el rendimiento depende de sus datos y caso de uso. El objetivo de estos ejemplos es convencerle de que no descarte estas soluciones como opciones legítimas. Si estos aún no le brindan el rendimiento que necesita, siempre hay cython y numba . Agreguemos esta prueba a 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

ingrese la descripción de la imagen aquí

Numba ofrece compilación JIT de código Python descabellado en código vectorizado muy potente. Comprender cómo hacer que numba funcione implica una curva de aprendizaje.


Operaciones con objecttipos mixtos / d

Comparación basada en cadenas
Revisando el ejemplo de filtrado de la primera sección, ¿qué pasa si las columnas que se comparan son cadenas? Considere las mismas 3 funciones anteriores, pero con el DataFrame de entrada convertido a 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

ingrese 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. Pandas trata las cadenas como objetos, y todas las operaciones en los objetos recurren a una implementación lenta y descabellada.

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, a pesar de que escalan de la misma manera.

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 A
continuación, se muestran los tiempos para dos operaciones que extraen un valor de una columna de diccionarios: mapy la lista de comprensión. La configuración se encuentra en el Apéndice, bajo el título "Fragmentos de código".

# Dictionary value extraction.
ser.map(operator.itemgetter('value'))     # map
pd.Series([x.get('value') for x in ser])  # list comprehension

ingrese la descripción de la imagen aquí

Lista de posiciones de indexación
con sincronizaciones para 3 operaciones que extraen el elemento 0th de una lista de columnas (manejo de excepciones), map, str.getde acceso método , y la lista de comprensión:

# 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 es importante, querrá hacer lo siguiente:

pd.Series([...], index=ser.index)

Al reconstruir la serie.

ingrese la descripción de la imagen aquí

Acoplamiento de
listas Un último ejemplo es el acoplado de listas. Este es otro problema común y demuestra cuán poderoso es el pitón puro 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

ingrese la descripción de la imagen aquí

Tanto itertools.chain.from_iterablela comprensión como la lista anidada son construcciones de Python puras y escalan mucho mejor que la stacksolución.

Estos tiempos son una fuerte indicación del hecho de que los pandas no están equipados para trabajar con tipos d mixtos y que probablemente debería abstenerse 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 que puede hacer sería probar estas operaciones en sus datos antes de decidir qué hacer. Observe cómo no he cronometrado applyestas soluciones, porque sesgaría el gráfico (sí, es así de lento).


Operaciones de expresiones regulares y .strmétodos de acceso

Pandas puede aplicar operaciones de expresiones regulares como str.contains, str.extracty str.extractall, así como otras operaciones de cadena "vectorizadas" (como str.splitstr.find ,str.translate`, etc.) en columnas de cadena. Estas funciones son más lentas que las listas por comprensión y están destinadas a ser funciones más prácticas que cualquier otra cosa.

Por lo general, es mucho más rápido compilar previamente un patrón de expresiones regulares e iterar sobre sus datos con re.compile(consulte también ¿Vale la pena usar la compilación de Python? ). La lista de composición equivalente a se str.containsparece 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 NaN, puede hacer algo como

ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]

La lista de composición equivalente a str.extract(sin grupos) se verá así:

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ápido!):

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 matcherfunción es muy extensible. Puede ajustarse para devolver una lista para cada grupo de captura, según sea necesario. Simplemente extraiga la consulta groupo el groupsatributo del objeto comparador.

Para str.extractall, cambie p.searcha p.findall.

Extracción de cadenas
Considere una operación de filtrado simple. La idea es extraer 4 dígitos si va precedido de 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

ingrese la descripción de la imagen aquí

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


Conclusión

Como se muestra en los ejemplos anteriores, la iteración brilla cuando se trabaja con filas pequeñas 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 kilometraje puede variar. Lo mejor que puede hacer es ejecutar pruebas con cuidado y ver si el pago vale la pena.

Las funciones "vectorizadas" brillan por 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 en los que la vectorización cuidadosa de NumPy supera a Python:

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 lo suficientemente saludable para la mayoría de los escenarios habituales (consulte la Nota en la sección Comparación numérica anterior). Entonces, por ejemplo df[df.A.values != df.B.values], mostraría aumentos instantáneos de rendimiento df[df.A != df.B]. Es .valuesposible que el uso no sea apropiado en todas las situaciones, pero es un truco útil para 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
)
cs95
fuente
pd.Seriesy pd.DataFrameahora soporta la construcción de iterables. Eso significa que uno puede simplemente pasar un generador de Python a las funciones del constructor en lugar de tener que construir una lista primero (usando listas por comprensión), lo que podría ser más lento en muchos casos. Sin embargo, el tamaño de la salida del generador no se puede determinar de antemano. No estoy seguro de cuánto tiempo / memoria podría causar.
GZ0
@ GZ0 IIRC, aceptar iteradores es una adición más reciente a la API. En cuanto a "eso significa que uno puede simplemente pasar un generador de Python a las funciones del constructor en lugar de tener que construir una lista primero", no estoy de acuerdo. Memoria tal vez, rendimiento no. En mi experiencia, por lo general, casi siempre es más rápido generar la lista y transmitirla. Lista comps FTW.
cs95
@ cs95 ¿Por qué no cree que los generadores pueden mejorar el rendimiento? ¿O habías hecho alguna prueba sobre eso?
GZ0
@ GZ0 No dije que los generadores no conducen a una ganancia de rendimiento, estoy diciendo que no se gana tanto como con las listas por comprensión. Hay gastos generales asociados con la inicialización de genexps, el mantenimiento del estado, etc., que las composiciones de listas no tienen. Aquí hay muchos ejemplos con listas de comprensión que puede comparar con las comprensiones del generador. Aquí hay un ejemplo que puede ejecutar ahora mismo ser = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']] * 10000):; %timeit pd.Series(y for x in ser for y in x); %timeit pd.Series([y for x in ser for y in x])
cs95
@ cs95 Probablemente tengas razón. Acabo de echar un vistazo rápido al código del constructor de pd.Seriesy resulta que si el tamaño de la salida iterable no se puede determinar, se construiría una lista a partir del iterable de todos modos. Por lo tanto, en la mayoría de los casos, el uso de generadores no generaría ganancias. Sin embargo, cuando ejecuté los ejemplos que dio, la versión del generador es más lenta. No estoy seguro de dónde viene la diferencia.
GZ0
1

En breve

  • for loop + iterrowses extremadamente lento. La sobrecarga no es significativa en ~ 1k filas, pero se nota en más de 10k filas.
  • for loop + itertupleses mucho más rápido que iterrowso apply.
  • La vectorización suele ser mucho más rápida que itertuples

Punto de referencia ingrese la descripción de la imagen aquí

Artoby
fuente