TLDR; No, los for
bucles 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:
- Cuando sus datos son pequeños (... dependiendo de lo que esté haciendo),
- Cuando se trata de
object
tipos / d mixtos
- Cuando se utilizan las
str
funciones 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
- Alineación de índice / eje
- Manejo de tipos de datos mixtos
- 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
).
for
los 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 for
bucles) 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 seq
hay 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 seq1
y seq2
son 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 perfplot
paquete para ejecutar todas las pruebas timeit en esta publicación. Los tiempos para las operaciones anteriores son los siguientes:
La comprensión de la lista supera query
en 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.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, Counter
triunfan sobre ambos métodos vectorizados para un rango mayor de N pequeño (~ 3500).
Nota
Más trivia (cortesía @ user2357112). El Counter
se 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 for
bucle. ¡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
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 object
tipos 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
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: map
y 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
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.get
de 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.
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
Tanto itertools.chain.from_iterable
la comprensión como la lista anidada son construcciones de Python puras y escalan mucho mejor que la stack
solució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 apply
estas soluciones, porque sesgaría el gráfico (sí, es así de lento).
Operaciones de expresiones regulares y .str
métodos de acceso
Pandas puede aplicar operaciones de expresiones regulares 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 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.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 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 matcher
función es muy extensible. Puede ajustarse para devolver una lista para cada grupo de captura, según sea necesario. Simplemente extraiga la consulta group
o el groups
atributo del objeto comparador.
Para str.extractall
, cambie p.search
a 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
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 .values
posible 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
)
pd.Series
ypd.DataFrame
ahora 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.