Pandas vs NumPy (vs Bottleneck)

Introducción

Hace varias semanas salió un proyecto muy interesante en el que se compara la performance de Pandas con NumPy. Bien, dado que uso Pandas y NumPy a diario no me costó demasiado encontrar algunas cosas (quizá algo difusas) que estarían bien comentar o matizar. Sobre todo cuando vemos comparaciones de este estilo, ya que nos podemos quedar con alguna que otra idea errónea.

A continuación voy a tratar de explicar cómo funciona Pandas y NumPy cuando hacemos llamadas del estilo:

De hecho, aunque nos pueda parecer trivial, cuando buscamos la máxima performance hacerlo de una u otra forma toma especial relevancia. Como podéis intuir, no es lo mismo hacer d.sum() que np.sum(d). Otro apunte intersante, para los que no lo sabéis, los cálculos en Pandas se apoyan en NumPy, pero también en bottleneck (y en el futuro quizá otras librerías), por ese motivo lo incluyo en esta comparativa, lo veremos con más detalle.

Descripción del entorno y SO

Esto siempre es un factor a tener en cuenta cuando se habla de performance.

Calculando tiempos

En esta parte voy a construir un DataFrame en el que se guardan los tiempos (,nombre, statement, N) de ejecución de las siguiente llamadas:

Estas llamadas se explican con más detalle más abajo, de momento vamos a quedarnos con que son algunas de las posibles alternativas para calcular la sum de un DataFrame y que nos devuelve el mismo objeto en los 4 casos.

Para realizar la comparación de tiempos he usado la función timeit_results definida arriba, esta función usa un mini proyecto para poder hacer este tipo de comparaciones de forma más cómoda.

Los statements (stmt) los he escrito de tal forma que devuelvan el mismo objeto, un pandas.Series en este caso dado que es lo que devuelve d.sum(). De esta forma nos aseguramos de que el usuario de pandas tiene como resultado el mismo objeto y los tiempos son comparables entre sí.

timestmtNname
00.000266d.sum()10^1pandas
10.000208d.sum()10^1pandas
20.000209d.sum()10^1pandas
30.000206d.sum()10^1pandas
40.000198d.sum()10^1pandas

Donde:

  • time es el tiempo medio en segundos.
  • stmt es la línea de código que se ejecutará.
  • N es la longitud de los datos.
  • name es un alias más amigable del statement.

Resultados

Con el DataFrame en long form anterior voy a construir una tabla resumen de los tiempos medios y desviación típica para cada longitud del DataFrame pasado como input y un nombre (no es mas que un alias del statement).

namebotlenecknumpypandaspure_numpy
N
10^10.000094 (0.000003)0.000208 (0.000009)0.000203 (0.000014)0.000109 (0.000005)
10^20.000096 (0.000005)0.000227 (0.000036)0.000195 (0.000006)0.000112 (0.000005)
10^30.000096 (0.000004)0.000219 (0.000010)0.000206 (0.000010)0.000112 (0.000006)
10^40.000107 (0.000004)0.000262 (0.000008)0.000244 (0.000009)0.000141 (0.000015)
10^50.000167 (0.000007)0.000693 (0.000013)0.000677 (0.000015)0.000273 (0.000007)
10^60.001121 (0.000047)0.008130 (0.000179)0.008092 (0.000125)0.004545 (0.000105)
10^70.008969 (0.000172)0.075049 (0.001520)0.080686 (0.027643)0.045551 (0.001036)
10^80.084068 (0.001555)1.227781 (0.067859)1.463971 (0.755026)0.893592 (0.021614)

Tabla 1: Tiempos por statement y longitud del DataFrame.

La tabla muestra la media de los tiempos en segundos de cada statement. Entre paréntesis se encuentran las desviaciones típicas. Dado que una imagen vale más que mil palabras, que hable seaborn:

Gráfico 1: Scatter de la media de los tiempos de ejecución por statement y tamaño del DataFrame.
Nota: Recordemos que d es un pandas.DataFrame.

El gráfico 1 muestra en el eje de las abscisas los nombres de los statements y en el eje de las ordenadas los tiempos en segundos. Los gráficos están separados por tamaño del DataFrame, esto es debido a que hay mucha diferencia entre los tiempos en los extremos de N, y si los pintamos en un mismo gráfico no podríamos apreciar los detalles de cada caso.

Lo primero que resalta del gráfico 1 son los tiempos de los casos llamados pandas y numpy, los cuales contrastan con los pure_numpy y bottleneck. Si vamos un poco más al detalle, numpy (con el statement np.nansum(d)), es más lenta que la versión pandas, d.sum(), sin embargo, numpy va alcanzando a pandas a medida que aumenta N, hasta el punto en el que se invierten las posiciones, esto ocurre a partir del tamaño N=10^6, esto sólo es en media. Los tiempos varían mucho más en el caso pandas, y se aprecia en las barras de error que se aprecian claramente para los tamaños grandes.

Por otra parte, las implementaciones pure_numpy y bottleneck se encuentran en las primeras posiciones del ranking de performance, que son con diferencia mucho más rápidas que las anteriores. La pega está en que las implementaciones no son tan “high level” como las otras, tenemos que acceder al values, llamar a la función correspondiente en numpy/bottleneck y luego envolverlo todo en Pandas. Es curioso como el caso pure_numpy se aleja de bottleneck a medida que aumenta N, especialmente notorio a partir de N=10^5.

Fijaos que claro, en Pandas son muy listos al usar Bottleneck siempre que sea posible, porque al final la “lucha” está entre NumPy y Bottleneck.

Interpretando los statements:

Caso Pandas

La lógica de Pandas es más complicada, y no es tan inmediato de ver en el código como en el caso de NumPy (explicado en el siguiente apartado). Lo podríamos resumir en lo siguiente:

Cuando invocamos al método .sum() de Pandas, por defecto, tanto las Series como los DataFrames, usan la función nanops.nansum de Pandas que heredan de la clase NDFrame:

Arriba puede verse que en la clase NDFrame se define el método sum como un alias de nanops.nansum en la última línea y nanops.nansum hace lo siguiente: In [13]:

Lo que hace esta función, pandas.core.nanops.nansum, es comprobar si es posible usar bottleneck (ver decorador línea 1) y si no, llama al método sum de values, es decir, llama a numpy.nansum (ver línea 9) (ver línea 6993 de pandas.core.generic) y nos devuelve el resultado.

Caso numpy

¿Qué hace numpy en este caso? Veamos el código:

Aquí la clave está en las líneas 96-98: primero numpy.sum comprueba si el objeto que ha recibido NO ES un ndarray (línea 96), si no lo es, intenta acceder al método .sum() del objeto. En nuestro caso, el objeto es un pandas.DataFrame y por tanto entra en el condicional (no es un ndarray) y llama al d.sum() que tenga implementado Pandas.

El esquema sería algo como lo siguiente:

Caso pure_numpy

El caso pure numpy es lo que se llamaría “ir al grano”. En este caso sabemos lo que hacen las librerías y se lo ponemos fácil:

Caso bottleneck

Este paso ahorra muchas llamadas intermedias si Pandas puede usar Bottleneck.

Nota: Dado que sabemos que no tenemos NaNs podríamos ir un paso más allá y aplicar la np.sum, pero estaríamos siendo injustos con Pandas.

Nota 2: En el caso de la suma, Pandas no usa Bottleneck (más detalles en el siguiente apartado).

El papel de Bottleneck en Pandas

Como hemos comentado antes, Pandas usa para algunos cálculos Bottleneck en lugar de NumPy, esto es debido a que bottleneck tiene un conjunto de funciones que gestionan NaNs implementadas en C, lo cual le dota de una gran velocidad de cálculo. Las funciones que tratan hacer uso de Bottleneck son las siguientes:

  • mean
  • median
  • std
  • var
  • min
  • max

Esto lo podemos ver en pandas.core.nanops, todas la funciones que usan el decorador @bottleneck_switch intentarán usar Bottleneck para realizar la operación.

Si tenéis curiosidad por este decorador os lo dejo por aquí:

Ahora ya sabéis qué ocurre en Pandas y NumPy por debajo cuando se hacen las llamadas que hemos visto. Pandas, a medida que aumenta el tamaño de los datos, se vuelve más eficiente, sin embargo, en ningún caso, gana a una implementación “ad hoc” como las pure_numpy o bottleneck. Siempre se puede atajar, pero Pandas lo hace muy bien en general, no nos tenemos que preocupar de si lo que devuelve es una Serie, DataFrame, array, int, float, etc, nos devuelve lo que tiene que ser, y por si fuera poco sin preocuparnos por los índices, columnas, nombres, dtypes… si lo miramos todo en conjunto no está nada mal.

That’s all folks!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

+ nineteen = twenty six