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


81

Estoy usando 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é en preguntar aquí.

La apply()función Pandas es lenta. Por lo que medí (que 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 marco de datos con Nfilas:

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()
#     A   B
# 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 Ay 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()
#     A   B    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 aumento Na tamaños del mundo real como 1 millón o más, entonces observo que np.vectorize()es 25 veces más rápido o más que df.apply().

A continuación se muestra un código completo de evaluación comparativa:

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()en general siempre es más rápido que df.apply(), ¿por qué np.vectorize()no se menciona más? 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 'aplicar' de Pandas a varias columnas?

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


No profundicé en los detalles de su pregunta, pero np.vectorizees básicamente un forbucle de Python (es un método de conveniencia) y applycon una lambda también está en tiempo de Python
roganjosh

"Si np.vectorize () es en general siempre más rápido que df.apply (), ¿por qué no se menciona más np.vectorize ()?" Porque no debería usar applyfila por fila a menos que sea necesario, y obviamente una función vectorizada superará a una no vectorizada.
PMende

1
@PMende pero np.vectorizeno está vectorizado. Es un nombre inapropiado bien conocido
roganjosh

1
@PMende, claro, no insinué lo contrario. No debe derivar sus opiniones sobre la implementación de los tiempos. Sí, son perspicaces. Pero pueden hacerte suponer cosas que no son ciertas.
jpp

3
@PMende juega con los accesos de pandas .str. Son más lentos que las listas por comprensión en muchos casos. Asumimos demasiado.
roganjosh

Respuestas:


115

Voy a empezar diciendo que el poder de pandas y NumPy matrices se deriva de alto rendimiento vectorizados cálculos sobre matrices numéricas. 1 El objetivo de los cálculos vectorizados es evitar bucles a nivel de Python moviendo los cálculos a un código C altamente optimizado y utilizando bloques de memoria contiguos. 2

Bucles a nivel de Python

Ahora podemos ver algunos tiempos. Estos son todos los bucles Python nivel que producen ya sea pd.Series, np.ndarrayo listlos objetos que contienen los mismos valores. A los efectos de la asignación a una serie dentro de un marco de datos, 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

Algunas conclusiones:

  1. Los tuplemétodos basados ​​en-(los primeros 4) son un factor más eficiente que los pd.Seriesmétodos basados ​​en-(los últimos 3).
  2. np.vectorize, comprensión de listas + zipy mapmétodos, es decir, los 3 primeros, todos tienen aproximadamente el mismo rendimiento. Esto se debe a que usan tuple y evitan algunas sobrecargas de Pandas pd.DataFrame.itertuples.
  3. Hay una mejora significativa en la velocidad al usar raw=Truecon pd.DataFrame.applyversus sin. Esta opción alimenta matrices NumPy a la función personalizada en lugar de pd.Seriesobjetos.

pd.DataFrame.apply: solo otro bucle

Para ver exactamente los objetos por los que pasa Pandas, puede modificar su función de manera 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: <class 'pandas.core.series.Series'>. Crear, pasar y consultar un objeto de la serie Pandas conlleva una sobrecarga significativa en relación con las matrices NumPy. Esto no debería ser una sorpresa: la serie Pandas incluye una cantidad decente de andamios para contener un índice, valores, atributos, etc.

Vuelve a hacer el mismo ejercicio con raw=Truey verás <class 'numpy.ndarray'>. Todo esto se describe en la documentación, pero verlo es más convincente.

np.vectorize: vectorización falsa

Los documentos para np.vectorizetienen la siguiente nota:

La función vectorizada evalúa pyfuncsobre tuplas sucesivas de las matrices de entrada como la función de mapa de Python, excepto que usa las reglas de transmisión de numpy.

Las "reglas de transmisión" son irrelevantes aquí, ya que las matrices de entrada tienen las mismas dimensiones. El paralelo mapes instructivo, ya que la mapversión anterior tiene un rendimiento casi idéntico. El código fuente muestra lo que está sucediendo: np.vectorizeconvierte su función de entrada en una función Universal ("ufunc") a través de np.frompyfunc. Hay alguna optimización, por ejemplo, almacenamiento en caché, que puede conducir a una mejora en el rendimiento.

En resumen, np.vectorizehace lo que debería hacer un bucle de nivel de Python , pero pd.DataFrame.applyagrega una sobrecarga considerable. No hay compilación JIT con la que veas numba(ver más abajo). Es solo una conveniencia .

Verdadera vectorización: lo que debe usar

¿Por qué no se mencionan las diferencias anteriores en ninguna parte? Porque el rendimiento de 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 más rápida de las soluciones descabelladas anteriores. Cualquiera de estos es aceptable. En mi opinión, el primero es conciso, legible y eficiente. Solo mire otros métodos, por ejemplo, a numbacontinuación, si el rendimiento 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 mediante numbamatrices NumPy subyacentes para moverse tanto como sea posible a C.

De hecho, numbamejora el rendimiento a microsegundos . Sin un trabajo engorroso, será difícil ser 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 @njit(parallel=True)puede proporcionar un impulso adicional para arreglos más grandes.


1 tipos numéricos incluyen: int, float, datetime, bool, category. Se excluyen object dtype y pueden realizarse en bloques de memoria contiguos.

2 Hay al menos 2 razones por las que las operaciones de NumPy son eficientes frente a Python:

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

1
@jpp: Usar el decorador con parallelargumento @njit(parallel=True)me da una mejora adicional sobre solo @njit. Quizás puedas agregar eso también.
Sheldore

1
Tiene una doble verificación para b [i]! = 0. El comportamiento normal de Python y Numba es verificar 0 y arrojar un error. Esto probablemente rompe cualquier vectorización SIMD y generalmente tiene una gran influencia en la velocidad de ejecución. Pero puede cambiar eso dentro de Numba a @njit (error_model = 'numpy') para evitar esta doble verificación de una división entre 0. También es recomendable asignar memoria con np.empty y establecer el resultado en 0 dentro de una instrucción else.
max9111

1
error_model numpy usa lo que da el procesador en una división por 0 -> NaN. Al menos en Numba 0.41dev ambas versiones usan la vectorización SIMD. Puede verificar esto como se describe aquí numba.pydata.org/numba-doc/dev/user/faq.html (1.16.2.3. ¿Por qué mi bucle no está vectorizado?) Simplemente agregaría una declaración else a su función (res [ i] = 0.) y allcocate la memoria con np.empty. Esto debería combinarse con error_model = 'numpy' para mejorar el rendimiento en aproximadamente un 20%. En versiones anteriores de Numba hubo una mayor influencia en el rendimiento ...
max9111

2
@ stackoverflowuser2010, No hay una respuesta universal "para funciones arbitrarias". Debe elegir la herramienta adecuada para el trabajo adecuado, que es parte de la comprensión de programación / algoritmos.
jpp

1
¡Felices vacaciones!
cs95

5

Cuanto más complejas se vuelvan sus funciones (es decir, cuanto menos se numpypuedan mover a sus propios componentes internos), 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)

Utilizando 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 numerosos ufuncobjetos cuando llamas np.vectorize. Cómo hace esto, en realidad no lo sé: tendrías que indagar más en los aspectos internos de numpy de lo que estoy dispuesto a cajero automático. Dicho esto, parece hacer un mejor trabajo en funciones simplemente numéricas que esta función basada en cadenas aquí.

Aumentando 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 = 'A E I O 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)

¿Qué pasa si subes eso a size=1000000(1 millón)?
stackoverflowuser2010

2
Estoy bastante seguro de que sus afirmaciones aquí son incorrectas. No puedo respaldar esa declaración con código por ahora, espero que alguien más pueda
hacerlo

@ stackoverflowuser2010 Lo he actualizado, junto con un enfoque vectorizado real .
PMende

0

Soy nuevo en Python. Pero en el siguiente ejemplo, 'aplicar' parece funcionar más rápido que 'vectorizar', o me falta algo.

 import numpy as np
 import pandas as pd

 B = np.random.rand(1000,1000)
 fn = np.vectorize(lambda l: 1/(1-np.exp(-l)))
 print(fn(B))

 B = pd.DataFrame(np.random.rand(1000,1000))
 fn = lambda l: 1/(1-np.exp(-l))
 print(B.apply(fn))
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.