Saltar a contenido

M贸dulo 4: Escalabilidad y Ejecuci贸n Perezosa (Lazy Evaluation)

En el mundo real, los ingenieros de datos rara vez trabajan con datasets que quepan c贸modamente en la memoria RAM de su laptop. El cuello de botella m谩s com煤n en el procesamiento de datos es el L铆mite de RAM (RAM Limits).

El Problema: Carga Inmediata (Eager Loading)

La mayor铆a de los analistas comienzan utilizando un enfoque de Ejecuci贸n Inmediata (Eager Execution). En este modelo, cada operaci贸n se ejecuta en el momento en que se llama, y el resultado se almacena completamente en la memoria RAM.

驴Por qu茅 falla el enfoque Eager con Big Data?

Cuando utilizamos funciones como pd.read_csv() o pl.read_csv(), el sistema intenta cargar todo el archivo en la memoria. Si tienes un archivo de 50GB y una RAM de 16GB, el proceso fallar谩 inmediatamente con un error de OutOfMemory (OOM).

Incluso si el archivo cabe en RAM, el enfoque Eager es ineficiente porque:
1. Carga datos innecesarios: Cargamos todas las columnas y filas, aunque solo necesitemos filtrar un peque帽o subconjunto.
2. No hay optimizaci贸n global: El sistema no sabe qu茅 har谩s despu茅s, por lo que no puede optimizar la secuencia de pasos.

Ejemplo: Comparativa Pandas vs Polars (Eager)

Veamos c贸mo se comporta la carga inmediata. En este ejemplo, cargamos un dataset de vuelos y filtramos solo unos pocos registros.

import pandas as pd
import polars as pl
import time

# Enfoque Pandas (Eager)
start = time.time()
df_pd = pd.read_csv('flights.csv', dtype={"TAIL_NUMBER": "string", "ORIGIN_AIRPORT": "string", "DESTINATION_AIRPORT": "string"})
df_pd = df_pd[(df_pd['MONTH'] == 12) & (df_pd['ORIGIN_AIRPORT'] == 'SEA') & (df_pd['DESTINATION_AIRPORT'] == 'DFW')]
print(f"Pandas Eager: {time.time() - start:.4f}s")

# Enfoque Polars (Eager)
start = time.time()
df_pl = pl.read_csv('flights.csv').filter(
    (pl.col('MONTH') == 12) & (pl.col('ORIGIN_AIRPORT') == 'SEA') & (pl.col('DESTINATION_AIRPORT') == 'DFW')
)
print(f"Polars Eager: {time.time() - start:.4f}s")

Aunque Polars es significativamente m谩s r谩pido que Pandas, ambos siguen cargando todo el CSV en RAM antes de filtrar. Si el CSV fuera m谩s grande que la RAM, ambos fallar铆an.


La Soluci贸n: Ejecuci贸n Perezosa (Lazy Evaluation)

La Ejecuci贸n Perezosa (Lazy Evaluation) cambia el paradigma: en lugar de ejecutar cada l铆nea de c贸digo inmediatamente, Polars construye un Plan de Ejecuci贸n (Query Plan).

驴C贸mo funciona?

  1. Planificaci贸n (.lazy() o scan_*): Cuando usas pl.scan_csv(), Polars no lee los datos. Simplemente anota: "Tengo que leer este archivo, luego filtrar por mes, luego seleccionar estas columnas".
  2. Optimizaci贸n: El optimizador de Polars analiza el plan y aplica reglas para hacerlo m谩s eficiente (por ejemplo, moviendo el filtro lo m谩s cerca posible de la lectura del archivo).
  3. Ejecuci贸n (.collect()): Solo cuando llamas a .collect(), Polars ejecuta el plan optimizado, leyendo 煤nicamente los datos estrictamente necesarios.

Ejemplo: Polars Lazy

# Enfoque Polars (Lazy)
start = time.time()
# scan_csv NO carga los datos en RAM, crea un LazyFrame
lazy_plan = pl.scan_csv('flights.csv').filter(
    (pl.col('MONTH') == 12) & (pl.col('ORIGIN_AIRPORT') == 'SEA') & (pl.col('DESTINATION_AIRPORT') == 'DFW')
)

# .collect() dispara la ejecuci贸n optimizada
df_lazy = lazy_plan.collect()
print(f"Polars Lazy: {time.time() - start:.4f}s")

Enfoque en Apache Arrow y Formato Parquet

Para que la ejecuci贸n perezosa sea verdaderamente potente, necesitamos un formato de almacenamiento que soporte el acceso selectivo. Aqu铆 es donde entra Apache Arrow.

Apache Arrow: El est谩ndar de memoria

Apache Arrow es un formato de memoria columnar dise帽ado para el an谩lisis de datos eficiente. Permite que diferentes herramientas (Polars, DuckDB, Spark) compartan datos sin necesidad de copiarlos (Zero-copy).

Parquet: Arrow en el disco

Si Arrow es el formato en memoria, Apache Parquet es su representaci贸n en disco. Parquet almacena los datos en columnas en lugar de filas. Esto es fundamental para dos optimizaciones cr铆ticas:

1. Projection Pushdown (Poda de Columnas)

En un CSV, para leer la columna 10, el sistema debe leer las primeras 9. En Parquet, Polars puede saltar directamente a la columna necesaria en el disco y leer solo esa.
* Resultado: Menos I/O de disco y menor uso de RAM.

2. Predicate Pushdown (Poda de Filas)

Parquet almacena metadatos (como el valor m铆nimo y m谩ximo) para bloques de datos. Si filtras MONTH == 12, Polars puede leer los metadatos y decir: "Este bloque de 10,000 filas solo tiene meses del 1 al 6, no necesito leerlo".
* Resultado: El sistema ignora archivos o bloques enteros de datos antes de cargarlos en RAM.

Ejemplo: Optimizaci贸n con Parquet

# Planificar acceso con Predicate y Projection Pushdown
lf = pl.scan_parquet("events/*.parquet")
result = (
    lf.filter(pl.col("country") == "IN")               # Predicate Pushdown
      .filter(pl.col("ts") >= pl.datetime(2025, 9, 1)) # Predicate Pushdown
      .select(["ts", "country", "path"])              # Projection Pushdown
      .collect()
)

En este c贸digo, Polars no lee todo el directorio events/. Lee los metadatos de Parquet, descarta los archivos que no cumplen los filtros y solo extrae las tres columnas solicitadas.


Casos Pr谩cticos de Escalabilidad

1. Datasets Particionados (Hive Partitioning)

Si los datos est谩n organizados en carpetas como a帽o=2025/mes=10/, Polars puede optimizar la b煤squeda bas谩ndose 煤nicamente en la estructura de carpetas.

# B煤squeda focalizada en particiones espec铆ficas
lf = pl.scan_parquet("path/YYYY=2025/MM=10/*/*.parquet")
today = lf.filter(pl.col("DD") == "08").collect()

2. Joins Eficientes entre Formatos

La ejecuci贸n perezosa permite unir datos de diferentes fuentes (CSV y Parquet) optimizando la operaci贸n global antes de cargar cualquier dato en RAM.

q_join = (
    pl.scan_parquet("Parquet/*/*/*.parquet")
    .join(pl.scan_csv("taxi_zone_lookup.csv"), left_on="PULocationID", right_on="LocationID")
    .filter(pl.col("total_amount") > 25)
    .group_by("Zone")
    .agg(
        (pl.col("total_amount") /
        (pl.col("tpep_dropoff_datetime") - pl.col("tpep_pickup_datetime")).dt.total_minutes()
        ).mean().alias("cost_per_minute")
    ).sort("cost_per_minute", descending=True)
)

r_join = q_join.collect()

3. Pipeline de Conversi贸n Directa (Streaming)

Puedes convertir datos de CSV a Parquet sin cargar el archivo completo en RAM utilizando .sink_parquet(), que procesa los datos en batches (streaming).

lf = pl.scan_csv("raw/*.csv", dtypes={"ts": pl.Datetime, "user_id": pl.Int64})
(
    lf.filter(pl.col("ts") >= pl.datetime(2025, 10, 1))
      .with_columns(pl.col("path").str.slice(0, 64).alias("path_64"))
      .sink_parquet("curated/2025-10.parquet")
)

Resumen: Eager vs Lazy

Caracter铆stica Ejecuci贸n Inmediata (Eager) Ejecuci贸n Perezosa (Lazy)
Comportamiento Ejecuta cada paso al instante Crea un plan de ejecuci贸n
Uso de RAM Alto (carga todo el dataset) Optimizado (carga solo lo necesario)
Optimizaci贸n Ninguna (secuencial) Global (optimiza el plan)
Ideal para... Exploraci贸n r谩pida, datasets peque帽os Pipelines de producci贸n, Big Data
Comandos Clave read_csv(), read_parquet() scan_csv(), scan_parquet(), .collect()