Polars¶
Introducción a Polars ¿Es una buena alternativa a Pandas?¶
Polars es una librería para manipular datos especificamente DataFrames
increíblemente rápida, ha sido implementada en Rust utilizando Arrow Columnar Format
de Apache como modelo de memoria.
Lazy | eager execution Multi-threaded SIMD (Single Instruction, Multiple Data) Query optimization Powerful expression API Rust | Python | ...
⚠️Nota: Si no estas familiarizado con la manipulación de datos en Python, te recomiendo empezar leyendo sobre la librería Pandas
, es realmente más facil y podras comprender los principios. También, te dejo como referencia el [Curso de manipulación de datos: Data science autodidacta]
Este blog tiene como objetivo presentarle Polars🐻❄️ a través de ejemplos y comparándolo con otras soluciones.
Rendimiento frente a otras librerías¶
Polars es muy rápido y, de hecho, es una de las mejores librerias para manejar datos disponibles. Tomemos como referencia Database-like ops benchmark de h2oai. Esta página compara varias herramientas similares, en este caso de bases de datos, y que sean populares en Data science
y de código abierto. Ejecuta las últimas versiones de estos paquetes y se actualiza automáticamente.
También se incluye la sintaxis que se cronometra junto con el tiempo. De esta manera, puede ver de inmediato si está realizando estas tareas o no, y si las diferencias de tiempo le importan o no. Una diferencia de 10x puede ser irrelevante si eso es solo 1s frente a 0,1s en el tamaño de sus datos.
A modo de ejemplo, veamos algunos ejemplos de performances de distintas librerías para ejecutar distintas tareas sobre datasets de distintos tamaños. Para el caso de tareas básicas sobre un dataset de 50 GB, Polars supera a librerías espacializadas en distribución de Dataframes como Spark (143 segundos vs 568 segundos). Por otro lado, librerías conocidas en Python como Pandas o Dask tienen el problema de out of memory.
Expresiones en Polars.¶
Polars tiene un hermoso concepto llamado expresiones
. Las expresiones polares se pueden usar en varios contextos y son un mapeo funcional de Fn(Series) -> Series
, lo que significa que tienen Series
como entrada y Series
como salida. Al observar esta definición funcional, podemos ver que la salida de un Expr
también puede servir como entrada de un Expr
.
Eso puede sonar un poco extraño, así que vamos a dar un ejemplo.
La siguiente es una expresión:
pl.col("foo").sort().head(2)
El fragmento anterior dice seleccionar la columna "foo"
, luego ordenar esta columna y luego tomar los primeros 2 valores de la salida ordenada. El poder de las expresiones es que cada expresión produce una nueva expresión y que se pueden canalizar juntas. Puede ejecutar una expresión pasándola en uno de los contextos de ejecución polares. Aquí ejecutamos dos expresiones ejecutando df.select
:
df.select([
pl.col("foo").sort().head(2),
pl.col("barra").filter(pl.col("foo") == 1).sum()
])
Todas las expresiones se ejecutan en paralelo. (Tenga en cuenta que dentro de una expresión puede haber más paralelización).
Otras Expresiones¶
En esta sección veremos algunos ejemplos, pero primero vamos a crear un conjunto de datos:
import polars as pl
import numpy as np
np.random.seed(12)
df = pl.DataFrame(
{
"nrs": [1, 2, 3, None, 5],
"names": ["foo", "ham", "spam", "egg", None],
"random": np.random.rand(5),
"groups": ["A", "A", "B", "C", "B"],
}
)
print(df)
shape: (5, 4) ┌──────┬───────┬──────────┬────────┐ │ nrs ┆ names ┆ random ┆ groups │ │ --- ┆ --- ┆ --- ┆ --- │ │ i64 ┆ str ┆ f64 ┆ str │ ╞══════╪═══════╪══════════╪════════╡ │ 1 ┆ foo ┆ 0.154163 ┆ A │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ │ 2 ┆ ham ┆ 0.74 ┆ A │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ │ 3 ┆ spam ┆ 0.263315 ┆ B │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ │ null ┆ egg ┆ 0.533739 ┆ C │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ │ 5 ┆ null ┆ 0.014575 ┆ B │ └──────┴───────┴──────────┴────────┘
Puedes hacer mucho con las expresiones, veamos algunos ejemplos:
Contar valores únicos¶
Podemos contar los valores únicos en una columna. Tenga en cuenta que estamos creando el mismo resultado de diferentes maneras. Para no tener nombres de columna duplicados en el DataFrame, usamos una expresión de alias
, que cambia el nombre de una expresión.
out = df.select(
[
pl.col("names").n_unique().alias("unique_names_1"),
pl.col("names").unique().count().alias("unique_names_2"),
]
)
print(out)
shape: (1, 2) ┌────────────────┬────────────────┐ │ unique_names_1 ┆ unique_names_2 │ │ --- ┆ --- │ │ u32 ┆ u32 │ ╞════════════════╪════════════════╡ │ 5 ┆ 5 │ └────────────────┴────────────────┘
Agregaciones de varios tipos¶
Podemos hacer varias agregaciones. A continuación mostramos algunas de ellas, pero hay más, como median
, mean
, first
, etc.
out = df.select(
[
pl.sum("random").alias("sum"),
pl.min("random").alias("min"),
pl.max("random").alias("max"),
pl.col("random").max().alias("other_max"),
pl.std("random").alias("std dev"),
pl.var("random").alias("variance"),
]
)
print(out)
shape: (1, 6) ┌──────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ sum ┆ min ┆ max ┆ other_max ┆ std dev ┆ variance │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │ ╞══════════╪══════════╪══════╪═══════════╪══════════╪══════════╡ │ 1.705842 ┆ 0.014575 ┆ 0.74 ┆ 0.74 ┆ 0.293209 ┆ 0.085971 │ └──────────┴──────────┴──────┴───────────┴──────────┴──────────┘
Filtros y condicionales¶
También podemos hacer cosas bastante complejas. En el siguiente fragmento, contamos todos los nombres que terminan con la cadena "am"
.
out = df.select(
[
pl.col("names").filter(pl.col("names").str.contains(r"am$")).count(),
]
)
print(out)
shape: (1, 1) ┌───────┐ │ names │ │ --- │ │ u32 │ ╞═══════╡ │ 2 │ └───────┘
Funciones binarias y modificación¶
En el ejemplo a continuación, usamos un condicional para crear una nueva expresión when -> then -> otherwise
.
La función when()
requiere una expresión de predicado (y, por lo tanto, conduce a una serie booleana
), luego espera una expresión que se usará en caso de que el predicado se evalúe como verdadero y, de lo contrario, espera una expresión que se usará en caso de que el predicado se evalúe.
Tenga en cuenta que puede pasar cualquier expresión, o simplemente expresiones base como pl.col("foo")
, pl.lit(3)
, pl.lit("bar")
, etc.
Finalmente, multiplicamos esto con el resultado de una expresión de suma.
out = df.select(
[
pl.when(pl.col("random") > 0.5).then(0).otherwise(pl.col("random")) * pl.sum("nrs"),
]
)
print(out)
shape: (5, 1) ┌──────────┐ │ literal │ │ --- │ │ f64 │ ╞══════════╡ │ 1.695791 │ ├╌╌╌╌╌╌╌╌╌╌┤ │ 0.0 │ ├╌╌╌╌╌╌╌╌╌╌┤ │ 2.896465 │ ├╌╌╌╌╌╌╌╌╌╌┤ │ 0.0 │ ├╌╌╌╌╌╌╌╌╌╌┤ │ 0.160325 │ └──────────┘
Expresiones de ventana¶
Una expresión polar también puede hacer un GROUPBY
, AGGREGATION
y JOIN
implícitos en una sola expresión.
En los ejemplos a continuación, hacemos un GROUPBY
sobre "groups"
y AGREGATE SUM
de "random"
, y en la siguiente expresión GROUPBY OVER
"names"
y AGREGATE
una lista de "random"
. Estas funciones de ventana se pueden combinar con otras expresiones y son una forma eficaz de determinar estadísticas de grupo. Vea más expresiones en el siguiente link.
out = df[
[
pl.col("*"), # select all
pl.col("random").sum().over("groups").alias("sum[random]/groups"),
pl.col("random").list().over("names").alias("random/name"),
]
]
print(out)
shape: (5, 6) ┌──────┬───────┬──────────┬────────┬────────────────────┬─────────────┐ │ nrs ┆ names ┆ random ┆ groups ┆ sum[random]/groups ┆ random/name │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ i64 ┆ str ┆ f64 ┆ str ┆ f64 ┆ list [f64] │ ╞══════╪═══════╪══════════╪════════╪════════════════════╪═════════════╡ │ 1 ┆ foo ┆ 0.154163 ┆ A ┆ 0.894213 ┆ [0.154163] │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ 2 ┆ ham ┆ 0.74 ┆ A ┆ 0.894213 ┆ [0.74] │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ 3 ┆ spam ┆ 0.263315 ┆ B ┆ 0.2778 ┆ [0.263315] │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ null ┆ egg ┆ 0.533739 ┆ C ┆ 0.533739 ┆ [0.533739] │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ 5 ┆ null ┆ 0.014575 ┆ B ┆ 0.2778 ┆ [0.014575] │ └──────┴───────┴──────────┴────────┴────────────────────┴─────────────┘
GroupBy¶
Un enfoque multiproceso¶
Una de las formas más eficientes de procesar datos tabulares es paralelizar su procesamiento a través del enfoque "dividir-aplicar-combinar". Esta operación es el núcleo de la implementación del agrupamiento de Polars, lo que le permite lograr operaciones ultrarrápidas. Más específicamente, las fases de "división" y "aplicación" se ejecutan de forma multiproceso.
Una operación de agrupación simple se toma a continuación como ejemplo para ilustrar este enfoque:
Para las operaciones hash realizadas durante la fase de "división", Polars utiliza un enfoque sin bloqueo de subprocesos múltiples que se ilustra en el siguiente esquema:
¡Esta paralelización permite que las operaciones de agrupación y unión (por ejemplo) sean increíblemente rápidas!
¡Paralelización!¶
Todos hemos escuchado que Python es lento y "no escala". Además de la sobrecarga de ejecutar el código de bytes "lento", Python debe permanecer dentro de las restricciones del Global interpreter lock (GIL). Esto significa que si se usa la operación lambda
o una función de Python personalizada para aplicar durante una fase de paralelización, la velocidad de Polars se limita al ejecutar el código de Python, lo que evita que varios subprocesos ejecuten la función.
Todo esto se siente terriblemente limitante, especialmente porque a menudo necesitamos esos lambda
en un paso .groupby()
, por ejemplo. Este enfoque aún es compatible con Polars, pero teniendo en cuenta el código de bytes Y el precio GIL
deben pagarse.
Para mitigar esto, Polars implementa una poderosa sintaxis definida no solo en su lazy
, sino también en su uso eager
.
He decidido dividir esta entrada en 2 partes para que este más o menos ordenada: