Polars como alternativa moderna a pandas

Cuando pandas salió en 2008, Python no tenía nada parecido para datos tabulares. Resolvió el problema, pero acumuló decisiones de diseño que hoy se sienten como deuda técnica. Polars es una biblioteca de DataFrames escrita en Rust que parte de cero, sin compatibilidad hacia atrás que defender, y toma decisiones deliberadamente distintas.

La diferencia más inmediata: Polars no tiene índice. En pandas el índice es omnipresente —cada operación lo rastrea, lo resetea o lo desalinea— y es la fuente de más de la mitad de los SettingWithCopyWarning y joins silenciosamente incorrectos que has visto en producción. En Polars, las filas no tienen identidad propia; los datos son solo columnas. La API se simplifica notablemente.

Internamente, Polars usa Apache Arrow como formato de memoria en columnas, lo que significa que las operaciones vectorizadas evitan casi completamente el overhead de Python. Y como está escrito en Rust, el multithreading es el comportamiento por defecto —no un parámetro opcional—. Cuando filtras o agregas un DataFrame grande, Polars parte el trabajo entre núcleos automáticamente sin que hagas nada.

La otra pieza central es la API lazy. En lugar de ejecutar cada operación al momento de escribirla, df.lazy() construye un grafo de ejecución que Polars puede optimizar: reordenar filtros, eliminar columnas que nunca se usan, fusionar operaciones. Solo cuando llamas a .collect() ocurre el trabajo real. Para pipelines complejos sobre millones de filas, esa diferencia puede ser un orden de magnitud.

¿Cuándo merece la pena cambiar? Cuando tienes datasets que tardan segundos en pandas, cuando tus pipelines son suficientemente complejos como para beneficiarse de la optimización lazy, o cuando estás empezando un proyecto nuevo y no tienes deuda con el ecosistema pandas. Si tu código depende de librerías que consumen DataFrames de pandas directamente (scikit-learn, seaborn, etc.), la conversión explícita existe pero añade fricción.

Lo que se rompe si subestimas las diferencias: las expresiones de Polars no son lo mismo que las operaciones de pandas. df['col'] > 5 en pandas te devuelve una Serie booleana que puedes usar directamente. En Polars, el paradigma son las pl.Expr —objetos que describen una transformación, no que la ejecutan—. Si intentas mezclar lógica imperativa de pandas con Polars, el código se vuelve confuso rápido.

import polars as pl
import pandas as pd
from datetime import date

# Dataset simulado: registros de ventas
raw = {
    "fecha": [date(2024, 1, 1), date(2024, 1, 2), date(2024, 1, 3),
              date(2024, 1, 4), date(2024, 1, 5)],
    "producto": ["A", "B", "A", "C", "B"],
    "cantidad": [10, 0, 25, 8, 15],
    "precio_unitario": [5.0, 12.0, 5.0, 30.0, 12.0],
    "region": ["norte", "sur", "norte", "sur", "norte"],
}

df = pl.DataFrame(raw)

# --- API EAGER: útil para exploración interactiva ---

# Las expresiones son composables: se pasan como argumentos, no se encadenan
# sobre el DataFrame directamente
resultado_eager = (
    df
    .filter(pl.col("cantidad") > 0)           # descarta filas con cantidad cero
    .with_columns(
        (pl.col("cantidad") * pl.col("precio_unitario")).alias("total"),
    )
    .group_by("region")
    .agg(
        pl.col("total").sum().alias("ventas_totales"),
        pl.col("producto").n_unique().alias("productos_distintos"),
    )
    .sort("ventas_totales", descending=True)
)

print("=== Resultado eager ===")
print(resultado_eager)

# --- API LAZY: Polars optimiza el plan antes de ejecutar ---
# Útil cuando el pipeline es complejo o los datos son grandes

resultado_lazy = (
    df.lazy()
    .filter(pl.col("cantidad") > 0)
    .with_columns(
        (pl.col("cantidad") * pl.col("precio_unitario")).alias("total"),
    )
    .group_by("region")
    .agg(
        pl.col("total").sum().alias("ventas_totales"),
        pl.col("producto").n_unique().alias("productos_distintos"),
    )
    .sort("ventas_totales", descending=True)
    .collect()   # aquí ocurre la ejecución real
)

print("\n=== Resultado lazy (mismo resultado, diferente ejecución) ===")
print(resultado_lazy)

# --- Expresiones composables fuera del contexto del DataFrame ---
# Una expresión describe la transformación; no necesita el DataFrame al definirse
expr_precio_alto = pl.col("precio_unitario").filter(pl.col("precio_unitario") > 10).mean()

estadisticas = df.select(
    expr_precio_alto.alias("media_precio_alto"),
    pl.col("cantidad").std().alias("std_cantidad"),
)
print("\n=== Estadísticas con expresiones reutilizables ===")
print(estadisticas)

# --- Interoperabilidad con pandas ---
df_pandas = df.to_pandas()                     # Polars → pandas (copia, no vista)
df_vuelta = pl.from_pandas(df_pandas)          # pandas → Polars
print("\n=== Round-trip a pandas y vuelta ===")
print(df_vuelta.dtypes)                        # los tipos se preservan via Arrow

Lo que hace cada decisión

El .filter() dentro de .with_columns() en expr_precio_alto es la clave del sistema de expresiones. pl.col("precio_unitario") no accede a ningún dato todavía; construye un objeto pl.Expr. Cuando llamas .filter(pl.col("precio_unitario") > 10) sobre esa expresión, estás componiendo transformaciones —como funciones en matemáticas—. Esto permite guardar expresiones en variables, reutilizarlas en distintos select o agg, y pasarlas como argumentos a funciones de tu pipeline. En pandas habrías tenido que materializar el filtro en un paso separado.

El bloque lazy muestra el mismo pipeline pero con df.lazy() al inicio. El plan de ejecución que construye internamente puede eliminar columnas que nunca llegan al agg (projection pushdown) y mover el .filter() lo más cerca posible de la fuente (predicate pushdown). Con un CSV en disco usarías pl.scan_csv() en lugar de pl.DataFrame(), y Polars ni siquiera cargaría las filas que el filtro va a descartar.

El group_by en Polars no garantiza orden de salida —por diseño, para poder paralelizar—, de ahí el .sort() explícito. En pandas el orden tras un groupby es a veces predecible y a veces no, dependiendo de la versión y el backend. Polars te obliga a ser explícito, lo que es un contrato más honesto.

La conversión to_pandas() hace una copia completa de los datos. No hay vista compartida de memoria entre los dos mundos (aunque ambos usen Arrow internamente, pandas puede modificar el buffer). Tenlo en cuenta si el DataFrame tiene varios GB: la conversión duplicará el uso de memoria momentáneamente.

Errores que debes conocer

Error: Intentar usar lógica booleana de Python (and, or) entre expresiones Polars, porque pl.Expr no define __bool__.

# ❌ Wrong
df.filter(pl.col("cantidad") > 0 and pl.col("precio_unitario") > 5)

# ✅ Right
df.filter(
    (pl.col("cantidad") > 0) & (pl.col("precio_unitario") > 5)
)

Los operadores & y | están sobrecargados en pl.Expr para producir expresiones compuestas; and y or intentan evaluar truthiness del objeto, lo que lanza InvalidOperationError.

Error: Asumir que .group_by().agg() devuelve las filas en el mismo orden que las claves de agrupación, y no ordenar explícitamente antes de comparar o hacer join.

# ❌ Wrong  — el orden es no-determinista entre ejecuciones
resultado = df.group_by("region").agg(pl.col("total").sum())
primera_region = resultado[0, "region"]   # puede ser cualquiera

# ✅ Right
resultado = (
    df.group_by("region")
    .agg(pl.col("total").sum())
    .sort("region")
)
primera_region = resultado[0, "region"]   # ahora es determinista

Polars paraleliza group_by y el orden de los grupos resultantes depende de la scheduling del runtime; añadir .sort() explícito es la única garantía real.

Error: Modificar un DataFrame de Polars in-place esperando semántica de pandas. Polars es casi siempre inmutable por diseño: las operaciones devuelven nuevos DataFrames.

# ❌ Wrong — no existe df["col"] = valores en Polars
df["nueva_col"] = df["cantidad"] * 2   # TypeError

# ✅ Right
df = df.with_columns(
    (pl.col("cantidad") * 2).alias("nueva_col")
)

with_columns devuelve un DataFrame nuevo con la columna añadida; la inmutabilidad hace que los pipelines sean más fáciles de razonar y de paralelizar.

190

Dejar un comentario

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

Scroll al inicio