El rendimiento de Pandas se aplica contra np.vectorize para crear una nueva columna a partir de columnas existentes

Estoy utilizando los marcos de datos de Pandas y quiero crear una nueva columna en función de las columnas existentes. No he visto una buena discusión sobre la diferencia de velocidad entre df.apply() y np.vectorize() , así que pensé que lo pediría aquí.

La función de Pandas apply() es lenta. Por lo que np.vectorize() se muestra a continuación en algunos experimentos), usar np.vectorize() es 25 veces más rápido (o más) que usar la función DataFrame apply() , al menos en mi MacBook Pro 2016. ¿Es este un resultado esperado, y por qué?

Por ejemplo, supongamos que tengo el siguiente dataframe con N filas:

 N = 10 A_list = np.random.randint(1, 100, N) B_list = np.random.randint(1, 100, N) df = pd.DataFrame({'A': A_list, 'B': B_list}) df.head() # AB # 0 78 50 # 1 23 91 # 2 55 62 # 3 82 64 # 4 99 80 

Supongamos además que quiero crear una nueva columna en función de las dos columnas A y B En el siguiente ejemplo, usaré una función simple divide() . Para aplicar la función, puedo usar df.apply() o np.vectorize() :

 def divide(a, b): if b == 0: return 0.0 return float(a)/b df['result'] = df.apply(lambda row: divide(row['A'], row['B']), axis=1) df['result2'] = np.vectorize(divide)(df['A'], df['B']) df.head() # AB result result2 # 0 78 50 1.560000 1.560000 # 1 23 91 0.252747 0.252747 # 2 55 62 0.887097 0.887097 # 3 82 64 1.281250 1.281250 # 4 99 80 1.237500 1.237500 

Si incremento N a tamaños reales como 1 millón o más, observo que np.vectorize() es 25 veces más rápido o más que df.apply() .

A continuación hay un código completo de benchmarking:

 import pandas as pd import numpy as np import time def divide(a, b): if b == 0: return 0.0 return float(a)/b for N in [1000, 10000, 100000, 1000000, 10000000]: print '' A_list = np.random.randint(1, 100, N) B_list = np.random.randint(1, 100, N) df = pd.DataFrame({'A': A_list, 'B': B_list}) start_epoch_sec = int(time.time()) df['result'] = df.apply(lambda row: divide(row['A'], row['B']), axis=1) end_epoch_sec = int(time.time()) result_apply = end_epoch_sec - start_epoch_sec start_epoch_sec = int(time.time()) df['result2'] = np.vectorize(divide)(df['A'], df['B']) end_epoch_sec = int(time.time()) result_vectorize = end_epoch_sec - start_epoch_sec print 'N=%d, df.apply: %d sec, np.vectorize: %d sec' % \ (N, result_apply, result_vectorize) # Make sure results from df.apply and np.vectorize match. assert(df['result'].equals(df['result2'])) 

Los resultados se muestran a continuación:

 N=1000, df.apply: 0 sec, np.vectorize: 0 sec N=10000, df.apply: 1 sec, np.vectorize: 0 sec N=100000, df.apply: 2 sec, np.vectorize: 0 sec N=1000000, df.apply: 24 sec, np.vectorize: 1 sec N=10000000, df.apply: 262 sec, np.vectorize: 4 sec 

Si np.vectorize() es en general siempre más rápido que df.apply() , entonces ¿por qué no se menciona más np.vectorize() ? Solo veo publicaciones de StackOverflow relacionadas con df.apply() , como:

Los pandas crean una nueva columna basada en valores de otras columnas.

¿Cómo uso la función de “aplicación” de Pandas en varias columnas?

Cómo aplicar una función a dos columnas del dataframe de Pandas

Comenzaré diciendo que el poder de las matrices Pandas y NumPy se deriva de cálculos vectorizados de alto rendimiento en matrices numéricas. 1 Todo el punto de los cálculos vectorizados es evitar los bucles de nivel de Python moviendo los cálculos a un código C altamente optimizado y utilizando bloques de memoria contiguos. 2

Bucles de nivel de Python

Ahora podemos ver algunos tiempos. A continuación se muestran todos los bucles de nivel de Python que producen pd.Series , np.ndarray o objetos de list que contienen los mismos valores. A los efectos de la asignación a una serie dentro de un dataframe, los resultados son comparables.

 # Python 3.6.5, NumPy 1.14.3, Pandas 0.23.0 np.random.seed(0) N = 10**5 %timeit list(map(divide, df['A'], df['B'])) # 43.9 ms %timeit np.vectorize(divide)(df['A'], df['B']) # 48.1 ms %timeit [divide(a, b) for a, b in zip(df['A'], df['B'])] # 49.4 ms %timeit [divide(a, b) for a, b in df[['A', 'B']].itertuples(index=False)] # 112 ms %timeit df.apply(lambda row: divide(*row), axis=1, raw=True) # 760 ms %timeit df.apply(lambda row: divide(row['A'], row['B']), axis=1) # 4.83 s %timeit [divide(row['A'], row['B']) for _, row in df[['A', 'B']].iterrows()] # 11.6 s 

Algunos puntos para llevar:

  1. Los métodos basados ​​en la tuple (los primeros 4) son un factor más eficiente que los métodos basados ​​en pd.Series (los últimos 3).
  2. np.vectorize , lista de comprensión + zip y métodos de map , es decir, los 3 primeros, todos tienen aproximadamente el mismo rendimiento. Esto se debe a que usan la tuple y evitan algunos gastos generales de Pandas de pd.DataFrame.itertuples .
  3. Hay una mejora significativa en la velocidad al usar raw=True con pd.DataFrame.apply versus sin. Esta opción alimenta matrices NumPy a la función personalizada en lugar de objetos pd.Series .

pd.DataFrame.apply : solo otro bucle

Para ver exactamente los objetos por los que pasa Pandas, puede modificar su función de forma trivial:

 def foo(row): print(type(row)) assert False # because you only need to see this once df.apply(lambda row: foo(row), axis=1) 

Salida: . Crear, pasar y consultar un objeto de la serie Pandas conlleva gastos generales significativos en relación con las matrices NumPy. Esto no debería ser una sorpresa: las series Pandas incluyen una cantidad decente de andamios para contener un índice, valores, atributos, etc.

Haga el mismo ejercicio de nuevo con raw=True y verá . Todo esto se describe en los documentos, pero verlo es más convincente.

np.vectorize : vectorización falsa

La documentación para np.vectorize tiene la siguiente nota:

La función vectorizada evalúa pyfunc sobre tuplas sucesivas de las matrices de entrada como la función de mapa python, excepto que usa las reglas de difusión de números.

Las “reglas de transmisión” son irrelevantes aquí, ya que las matrices de entrada tienen las mismas dimensiones. El paralelo al map es instructivo, ya que la versión del map anterior tiene un rendimiento casi idéntico. El código fuente muestra lo que está sucediendo: np.vectorize convierte su función de entrada en una función Universal (“ufunc”) a través de np.frompyfunc . Existe cierta optimización, por ejemplo, el almacenamiento en caché, que puede llevar a algunas mejoras en el rendimiento.

En resumen, np.vectorize hace lo que debería hacer un bucle de nivel de Python, pero pd.DataFrame.apply agrega una sobrecarga gruesa. No hay una comstackción JIT que se ve con numba (ver más abajo). Es sólo una conveniencia .

Verdadera vectorización: lo que debes usar.

¿Por qué las diferencias anteriores no se mencionan en ninguna parte? Debido a que el rendimiento de los cálculos verdaderamente vectorizados los hace irrelevantes:

 %timeit np.where(df['B'] == 0, 0, df['A'] / df['B']) # 1.17 ms %timeit (df['A'] / df['B']).replace([np.inf, -np.inf], 0) # 1.96 ms 

Sí, eso es ~ 40 veces más rápido que la solución más rápida de las anteriores. Cualquiera de estos son aceptables. En mi opinión, el primero es sucinto, legible y eficiente. Solo observe otros métodos, por ejemplo, numba continuación, si el desempeño es crítico y esto es parte de su cuello de botella.

numba.njit : mayor eficiencia

Cuando los bucles se consideran viables, generalmente se optimizan a través de numba con matrices NumPy subyacentes para moverse lo más posible a C.

De hecho, numba mejora el rendimiento a microsegundos . Sin un trabajo engorroso, será difícil obtener mucho más eficiente que esto.

 from numba import njit @njit def divide(a, b): res = np.empty(a.shape) for i in range(len(a)): if b[i] != 0: res[i] = a[i] / b[i] else: res[i] = 0 return res %timeit divide(df['A'].values, df['B'].values) # 717 µs 

El uso de @njit(parallel=True) puede proporcionar un impulso adicional para matrices más grandes.


1 Los tipos numéricos incluyen: int , float , datetime , bool , category . Excluyen el tipo de object y pueden mantenerse en bloques de memoria contiguos.

2 Existen al menos 2 razones por las que las operaciones NumPy son eficientes en comparación con Python:

  • Todo en Python es un objeto. Esto incluye, a diferencia de C, los números. Por lo tanto, los tipos Python tienen una sobrecarga que no existe con los tipos C nativos.
  • Los métodos NumPy son usualmente basados ​​en C. Además, se utilizan algoritmos optimizados siempre que sea posible.

Cuanto más complejas sean sus funciones (es decir, cuanto menos numpy puedan moverse a sus propias numpy internas), más verá que el rendimiento no será tan diferente. Por ejemplo:

 name_series = pd.Series(np.random.choice(['adam', 'chang', 'eliza', 'odom'], replace=True, size=100000)) def parse_name(name): if name.lower().startswith('a'): return 'A' elif name.lower().startswith('e'): return 'E' elif name.lower().startswith('i'): return 'I' elif name.lower().startswith('o'): return 'O' elif name.lower().startswith('u'): return 'U' return name parse_name_vec = np.vectorize(parse_name) 

Haciendo algunos tiempos:

Usando Aplicar

 %timeit name_series.apply(parse_name) 

Resultados:

 76.2 ms ± 626 µs per loop (mean ± std. dev. of 7 runs, 10 loops each) 

Usando np.vectorize

 %timeit parse_name_vec(name_series) 

Resultados:

 77.3 ms ± 216 µs per loop (mean ± std. dev. of 7 runs, 10 loops each) 

Numpy intenta convertir las funciones de python en objetos ufunc ufunc cuando llama a np.vectorize . Cómo lo hace, realmente no lo sé, tendría que profundizar más en los aspectos internos de lo que estoy dispuesto a hacer cajeros automáticos. Dicho esto, parece que hace un mejor trabajo en funciones simplemente numéricas que esta función basada en cadenas aquí.

Poniendo en marcha el tamaño hasta 1.000.000:

 name_series = pd.Series(np.random.choice(['adam', 'chang', 'eliza', 'odom'], replace=True, size=1000000)) 

apply

 %timeit name_series.apply(parse_name) 

Resultados:

 769 ms ± 5.88 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) 

np.vectorize

 %timeit parse_name_vec(name_series) 

Resultados:

 794 ms ± 4.85 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) 

Una forma mejor ( vectorizada ) con np.select :

 cases = [ name_series.str.lower().str.startswith('a'), name_series.str.lower().str.startswith('e'), name_series.str.lower().str.startswith('i'), name_series.str.lower().str.startswith('o'), name_series.str.lower().str.startswith('u') ] replacements = 'AEIO U'.split() 

Tiempos:

 %timeit np.select(cases, replacements, default=name_series) 

Resultados:

 67.2 ms ± 683 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)